Compare commits

..

12 Commits

Author SHA1 Message Date
unkn0wn 9d0a0c9626 Update src/layouts/BaseLayout.astro
Deploy Documentation / check-and-deploy (push) Successful in 16s
2026-07-02 12:51:54 +00:00
LOUIS POTEVIN af3d331114 Refactor TechIcon component to use BrandIcon for improved accessibility and performance; implement dwell curve and idle snapping in voyage scene for smoother camera transitions; enhance ComponentShowcase with dynamic NDS metadata; update project descriptions to reflect current NDS component count; improve UI translations and styling across various components; add brand icon paths for better SVG handling; integrate NDS metadata fetching for accurate version display; streamline HomeView layout and remove unused social links.
Deploy Documentation / check-and-deploy (push) Successful in 16s
2026-07-02 14:46:55 +02:00
LOUIS POTEVIN 611af1ac67 feat: add Home, Project, and Work Index views with responsive design and SEO enhancements
Deploy Documentation / check-and-deploy (push) Successful in 20s
- Implemented HomeView with a timeline layout, featuring sections for hero, profile, projects, stack, and contact.
- Created ProjectView to display individual project details, including status, highlights, and tech stack.
- Developed WorkIndexView to list all projects with breadcrumb navigation.
- Enhanced TypeScript configuration for improved module resolution.
2026-07-02 13:55:46 +02:00
LOUIS POTEVIN 4946d2586c fix: updating translations
Deploy Documentation / check-and-deploy (push) Successful in 13s
2026-06-25 06:40:01 +02:00
unkn0wn 5db1b69090 fix: wrong statistic
Deploy Documentation / check-and-deploy (push) Successful in 13s
2026-06-24 20:26:25 +02:00
unkn0wn 6725a4accd fix: adding missing tools
Deploy Documentation / check-and-deploy (push) Successful in 13s
2026-06-24 20:24:25 +02:00
unkn0wn deb3b8b1d2 fix: update laguage
Deploy Documentation / check-and-deploy (push) Successful in 13s
2026-06-24 20:14:35 +02:00
unkn0wn 0001b06d38 fix: updating readme
Deploy Documentation / check-and-deploy (push) Successful in 13s
2026-06-24 18:59:28 +02:00
unkn0wn 5b65c52726 fix: adding footer translation and correcting pipeline step name
Deploy Documentation / check-and-deploy (push) Successful in 13s
2026-06-24 18:57:07 +02:00
unkn0wn 58782c95cd feat: add workflow for website deployment
Deploy Documentation / check-and-deploy (push) Successful in 28s
2026-06-24 18:49:20 +02:00
unkn0wn 4c4a7e6fba fix: correct and flesh out project data 2026-06-24 18:45:04 +02:00
LOUIS POTEVIN d1cc9de1f1 fix: replace em dashes with hyphens in README.md, package.json, scene.ts, projects.ts, site.ts, BaseLayout.astro, about.astro, contact.astro, index.astro, [slug].astro, and work/index.astro 2026-06-24 14:27:42 +02:00
54 changed files with 8422 additions and 2878 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Deploy Documentation
on:
push:
branches:
- main
workflow_dispatch:
jobs:
check-and-deploy:
runs-on: host
steps:
- name: Install Node.js requirements for Actions
run: |
if ! command -v node &> /dev/null; then
apk add --no-cache nodejs npm
fi
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Build website
run: npm run build
- name: Deploy to Apache volume
run: |
find /var/www/louis-potevin.dev -mindepth 1 -delete
cp -r dist/. /var/www/louis-potevin.dev/
chmod -R 755 /var/www/louis-potevin.dev/
+1 -14
View File
@@ -1,17 +1,4 @@
# build node_modules/
dist/ dist/
.astro/ .astro/
# deps
node_modules/
# env & logs
.env
.env.*
*.log
npm-debug.log*
# os / editor
.DS_Store .DS_Store
.vscode/
.idea/
+82 -81
View File
@@ -1,106 +1,107 @@
# louis-potevin.dev # louis-potevin.dev — Portfolio v2
Professional portfolio of **Louis Potevin** — full-stack developer. Portfolio bilingue (FR/EN) construit avec **Astro 7**, **Nova Design System**, **Three.js** et **Lenis**.
Static multi-page website built with **Astro + TypeScript + Sass**, styled Accueil = un « voyage » : le scroll ne fait jamais défiler une page, il pilote une caméra 3D
**exclusively** by the in-house design system **`@unkn0wndo3s/nova-design-system`** et l'animation continue. Chaque projet a sa propre scène sur-mesure.
(tokens, components), featuring a 3D space background controlled via scrolling.
--- ## Stack
## Getting Started - [Astro 7](https://astro.build) — SSG, i18n natif, zéro JS par défaut
- [`@unkn0wndo3s/nova-design-system`](https://www.npmjs.com/package/@unkn0wndo3s/nova-design-system) — composants + tokens (source unique du design)
- [Three.js](https://threejs.org) — le moteur du voyage (accueil)
- [Lenis](https://lenis.darkroom.engineering) — smooth scroll ; toutes les animations lisent le scroll depuis Lenis, d'où la sensation d'une seule courbe de mouvement
- Sass — les mixins typographiques NDS sont exposés via un `loadPath` (voir `astro.config.mjs`)
## Démarrer
```bash ```bash
npm install npm install # .npmrc force legacy-peer-deps (requis par @lucide/astro avec Astro 7)
npm run dev # dev server → http://localhost:4321 npm run dev # http://localhost:4321
npm run build # static build → ./dist npm run build # sortie statique dans dist/
npm run preview # preview the build npm run check # astro check (TS + diagnostics) - 0 erreur attendu
``` ```
> **Installation Note.** `@lucide/astro` has not yet declared its Node >= 22.12 (contrainte du NDS).
compatibility with Astro 7 in its `peerDependencies`. The **`.npmrc`**
> file therefore enables `legacy-peer-deps=true` so that `npm install` succeeds.
> Functionality is not affected.
---
## Edit Before Going Live
All modifiable content is centralized. Values to be confirmed are
marked with `TODO` in the code :
- **`src/data/site.ts`** — name, role, email, external links (Gitea, GitHub,
npm, LinkedIn). In particular, verify :
- **`src/data/projects.ts`** — la liste des projets (les « stations »).
Chaque entrée : résumé, contexte, contributions, stack, liens.
---
## Structure ## Structure
``` ```
src/ src/
├─ data/ ├─ data/ site.ts (profil, liens), projects.ts (projets localisés + stages du showcase NDS)
│ ├─ site.ts # profile + links (single source of truth) ├── i18n/ index.ts (helpers locale/chemins/hreflang), ui.ts (dictionnaires FR/EN)
│ └─ projects.ts # project content ├── lib/seo.ts JSON-LD : Person, WebSite, BreadcrumbList, SoftwareSourceCode, ProfilePage
├─ layouts/ ├─ lib/smooth.ts Singleton Lenis · lib/scrub.ts - progression 0..1 des scènes épinglées
│ └─ BaseLayout.astro # NDS styles + 3D background + nav + footer + HUD ├── layouts/ BaseLayout.astro (head SEO complet, nav, footer, init Lenis)
├─ components/ ├─ components/
─ scene/scene.ts # Three.js background (stars, nebula, ambient asteroid) ├── scene/ voyage.ts - moteur de l'accueil · scene.ts - fond ambiant des sous-pages
├─ SpaceBackground.astro ├── scenes/ InfraScene, BlueprintScene, SorterScene - scènes projet sur-mesure
├─ ProjectCard.astro # project card (NDS Card component) └── showcase/ ComponentShowcase.astro - composants NDS "volants" (page projet NDS)
│ ├─ SiteNav / SiteFooter / Eyebrow / TechIcon / BrandLinkedin ├── views/ La logique de chaque page (partagée entre locales)
└─ pages/ └─ pages/ Routes minces : / (FR, défaut) et /en/* (miroirs)
├─ index.astro # home
├─ work/index.astro # all projects
├─ work/[slug].astro # project details (getStaticPaths)
├─ about.astro
└─ contact.astro
``` ```
## Design system **Règle i18n** : une page = une view. Les routes (`pages/` et `pages/en/`) ne font
qu'importer la view ; la locale est déduite de l'URL (`getLocaleFromUrl`).
`/` = français (cible : recruteurs FR), `/en/` = anglais. Pour inverser, changer
`DEFAULT_LOCALE` dans `src/i18n/index.ts` **et** `i18n.defaultLocale` dans `astro.config.mjs`.
Components come from `@unkn0wndo3s/nova-design-system` : ## Le voyage (accueil)
`import { Button, Card, Badge, Navbar, Breadcrumb, TextField, Select, ... }`.
Global styles and tokens : `import '@unkn0wndo3s/nova-design-system/styles'`
(done once in `BaseLayout.astro`).
The package ships its raw `.astro`/`.scss` source files - Astro compiles them `voyage.ts` transforme la hauteur de scroll en **timeline** : les sections HTML sont des
on the consumer side. Since its `exports` field does not publish token partials overlays fixes, et le scroll avance une caméra sur une courbe Catmull-Rom à travers
individually, the typography partial is exposed via a Sass nébuleuses, ceinture d'astéroïdes (une balise lumineuse par projet) et portail final.
`loadPath` (see `astro.config.mjs`) and then re-exported by
`src/styles/_type.scss` (mixins `text-sm``text-5xl`).
Icons : `@lucide/astro` (UI) and `simple-icons-astro` (tech). Since LinkedIn is - **Scroll down** → les astéroïdes dérivent et se percutent (collisions élastiques,
no longer distributed by either of them (trademark policy), its glyph is provided étincelles additives).
locally in `BrandLinkedin.astro`. - **Scroll up** → **vortex gravitationnel** : spirale vers la formation d'origine.
- Barre de progression + points de navigation latéraux (clic = seek animé).
- Les ancres (`/#about`, `/#contact`…) deviennent des seeks sur la timeline.
- Progressive enhancement : le même markup rend une page normale empilée sans JS,
sans WebGL ou avec `prefers-reduced-motion` (la classe `html.cinema` n'est jamais posée).
## 3D Background ## Métadonnées NDS toujours à jour
An ambient asteroid slowly rotates and floats in the background, in the middle of a `src/lib/ndsMeta.ts` : la liste des composants (et leur nombre) est parsée depuis
starfield and a discrete nebula (Three.js). It does not follow scroll actions l'index d'exports du paquet installé, et la version est récupérée sur le registre
and produces no engine flame effects: it is a backdrop, not the subject. npm **au moment du build** (fallback : version installée si hors-ligne). La forge,
les textes de la home et la fiche projet NDS lisent tous cette source — plus aucun
chiffre en dur à maintenir.
- **Without WebGL** : the canvas hides cleanly, the site remains readable. ## /docs — la documentation technique
Une page dédiée (`/docs`, FR/EN) documente tout le fonctionnement du site en 13 chapitres,
avec sommaire sticky (scrollspy) et snippets **extraits du vrai code source au build**
(imports Vite `?raw` + découpe par marqueurs — si un marqueur disparaît, le build échoue
au lieu de publier une doc périmée). Coloration syntaxique Shiki via `astro:components`.
## Scènes projet sur-mesure
Toutes épinglées (section haute + stage sticky), pilotées par `scrub.ts`, fallback statique :
| Projet | Scène |
| --- | --- |
| nova-design-system | `ComponentShowcase` (« la forge ») — 3 actes : les tokens (vrais swatches) convergent, 8 vrais composants NDS défilent dans un tunnel 3D avec specs + snippet d'usage, la bibliothèque se fige en grille avec compteurs |
| nova-infra | `InfraScene` — le graphe Visiteur → Cloudflare → Apache/VPS se dessine au scroll, puis un paquet circule en continu |
| portfolio | `BlueprintScene` — le wireframe du site se trace (dashoffset) puis s'allume aux couleurs NDS |
| file-organizer | `SorterScene` — 20 fichiers éparpillés (seed déterministe) se rangent en colonnes au scroll |
## SEO ## SEO
Meticulous search engine optimization, pre-configured: - `hreflang` fr / en / x-default + canonical sur chaque page
- JSON-LD : `Person` (@id partagé), `WebSite`, `BreadcrumbList` par page,
`SoftwareSourceCode` par projet, `ProfilePage` sur /about
- Open Graph + Twitter cards localisés (`og-fr.png` / `og-en.png`)
- Sitemap i18n (`@astrojs/sitemap`) + `robots.txt`
- Pas de meta `keywords` (ignorées par Google, signal spam pour certains crawlers)
- Per-page metadata (title, description, keywords), canonical URLs; ## À faire côté déploiement
- Open Graph + Twitter Card with `public/og.png` (1200×630) ;
- Structured data JSON-LD (`Person` + `WebSite`) ;
- `sitemap-index.xml` (via `@astrojs/sitemap`) + `public/robots.txt`.
Remember to update `og.png` if you change your job title, and confirm the 1. **Désactiver « Email Address Obfuscation »** dans Cloudflare (Scrape Shield) :
links in `site.ts` (used in the `sameAs` property of the structured data). sinon CF injecte `email-decode.min.js`, casse le mailto sans JS et affiche
« [email protected] » aux crawlers de recruteurs.
## Contact Form 2. **`public/og-fr.png` et `public/og-en.png`** (1200×630) — visuels de partage.
2. Brancher le workflow **Gitea Actions** existant (`npm ci && npm run build`, publier `dist/`).
The site is 100% static: the form in `contact.astro` does not have a 3. `/about` et `/contact` redirigent vers `/#about` et `/#contact` (config Astro) —
backend. The **Send** button opens the user's mail client with a les anciennes URL indexées ne cassent pas.
pre-filled message (`mailto:`). For a server-side submission, connect a service 4. Tester le voyage sur un vrai GPU/trackpad et ajuster les poids `data-weight`
(Formspree, Resend, edge function…) to the `#cf-send` button. des sections dans `HomeView.astro` (durée relative de chaque étape).
---
Built with Astro + Nova Design System.
+59 -7
View File
@@ -1,20 +1,72 @@
// @ts-check
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap'; import sitemap from '@astrojs/sitemap';
import { fileURLToPath } from 'node:url';
/**
* NDS's _typography.scss ships a Google Fonts `@import` which lands mid-file
* in the bundled CSS - invalid per spec (W3C error) and a hidden render
* blocker. The fonts are loaded via <link> in BaseLayout instead, and this
* plugin strips the stray @import from every emitted CSS asset.
*/
const stripFontImport = {
name: 'strip-google-fonts-import',
generateBundle(_, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type === 'asset' && chunk.fileName.endsWith('.css') && typeof chunk.source === 'string') {
chunk.source = chunk.source.replace(
/@import url\((['"]?)https:\/\/fonts\.googleapis\.com[^)]*\);?/g,
'',
);
}
}
},
};
const ndsTokens = fileURLToPath(
new URL('./node_modules/@unkn0wndo3s/nova-design-system/src/styles/tokens', import.meta.url),
);
// https://astro.build/config
export default defineConfig({ export default defineConfig({
site: 'https://louis-potevin.dev', site: 'https://louis-potevin.dev',
trailingSlash: 'ignore', trailingSlash: 'ignore',
integrations: [sitemap()],
// About & Contact now live on the home timeline.
redirects: {
'/about': '/#about',
'/contact': '/#contact',
'/en/about': '/en/#about',
'/en/contact': '/en/#contact',
},
i18n: {
defaultLocale: 'fr',
locales: ['fr', 'en'],
routing: {
prefixDefaultLocale: false,
},
},
integrations: [
sitemap({
i18n: {
defaultLocale: 'fr',
locales: { fr: 'fr-FR', en: 'en-US' },
},
}),
],
vite: { vite: {
plugins: [stripFontImport],
build: {
// Source maps help debugging in prod and satisfy Lighthouse's audit.
sourcemap: true,
},
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
quietDeps: true, loadPaths: [ndsTokens],
loadPaths: [
'node_modules',
'node_modules/@unkn0wndo3s/nova-design-system/src/styles',
'src',
],
}, },
}, },
}, },
+1412 -551
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -1,28 +1,28 @@
{ {
"name": "louis-potevin-dev", "name": "louis-potevin.dev",
"type": "module", "version": "2.0.0",
"version": "1.0.0",
"private": true, "private": true,
"description": "Portfolio Louis Potevin — louis-potevin.dev. Astro + Nova Design System.", "type": "module",
"engines": { "description": "Portfolio of Louis Potevin - full-stack developer. Astro + TypeScript + Sass, powered by Nova Design System.",
"node": ">=22.12.0"
},
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro" "check": "astro check"
}, },
"dependencies": { "dependencies": {
"@astrojs/sitemap": "^3.7.3", "@astrojs/sitemap": "^3.7.3",
"@lucide/astro": "^1.21.0", "@lucide/astro": "^1.23.0",
"@types/three": "^0.185.0",
"@unkn0wndo3s/nova-design-system": "^1.2.1", "@unkn0wndo3s/nova-design-system": "^1.2.1",
"astro": "^7.0.2", "astro": "^7.0.5",
"lenis": "^1.3.25",
"sass": "^1.101.0",
"simple-icons-astro": "^16.1.0", "simple-icons-astro": "^16.1.0",
"three": "^0.184.0" "three": "^0.185.1"
}, },
"devDependencies": { "devDependencies": {
"@types/three": "^0.184.0", "@astrojs/check": "^0.9.9",
"sass": "^1.101.0" "typescript": "^6.0.3"
} }
} }
+5 -11
View File
@@ -1,12 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="32" height="32" rx="7" fill="#040B0B"/> <!-- Nova mark: a four-point spark on the NDS dark background. -->
<!-- traînée de feu --> <rect width="64" height="64" rx="14" fill="#040B0B"/>
<path d="M7 25 L13 19 L17 23 L11 29 Z" fill="#E0A23A" opacity="0.9"/> <path d="M32 8 L38.5 25.5 L56 32 L38.5 38.5 L32 56 L25.5 38.5 L8 32 L25.5 25.5 Z" fill="#2EFAFA"/>
<path d="M9 27 L14 22 L16 24 L11 29 Z" fill="#E05A59" opacity="0.85"/> <circle cx="47" cy="15" r="3.4" fill="#34C2C9"/>
<!-- astéroïde -->
<circle cx="20" cy="12" r="6" fill="#2A4040"/>
<circle cx="20" cy="12" r="6" fill="none" stroke="#34C2C9" stroke-width="1.2"/>
<circle cx="18" cy="10.5" r="1.1" fill="#040B0B"/>
<circle cx="22" cy="13.5" r="0.9" fill="#040B0B"/>
<circle cx="21" cy="9.5" r="0.7" fill="#040B0B"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 608 B

After

Width:  |  Height:  |  Size: 345 B

+31
View File
@@ -0,0 +1,31 @@
---
/**
* Minimal, valid brand icon: one fill, no <title>, decorative by default.
* See src/lib/brandIcons.ts for why the simple-icons components aren't
* used directly.
*/
import { BRAND_ICONS } from '../lib/brandIcons';
export interface Props {
icon: string;
size?: number;
/** 'brand' uses the official color; 'current' inherits text color. */
color?: 'brand' | 'current';
}
const { icon, size = 18, color = 'brand' } = Astro.props;
const data = BRAND_ICONS[icon];
---
{data && (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill={color === 'brand' ? data.brand : 'currentColor'}
aria-hidden="true"
>
<path d={data.d} />
</svg>
)}
+9 -13
View File
@@ -1,23 +1,19 @@
--- ---
// LinkedIn is no longer distributed by @lucide/astro or simple-icons-astro /**
// (trademark policy). Official glyph reproduced inline, monochrome * LinkedIn glyph, provided locally: neither lucide nor simple-icons
// via `currentColor` to remain consistent with the other icons. * distributes it anymore (trademark policy).
export interface Props { */
size?: number; export interface Props { size?: number }
} const { size = 20 } = Astro.props;
const { size = 18 } = Astro.props;
--- ---
<svg <svg
xmlns="http://www.w3.org/2000/svg"
width={size} width={size}
height={size} height={size}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
role="img" role="img"
aria-label="LinkedIn" aria-hidden="true"
> >
<path <path d="M20.45 20.45h-3.55v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.55V9h3.57v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.72v20.55C0 23.23.79 24 1.77 24h20.45c.98 0 1.78-.77 1.78-1.73V1.72C24 .77 23.2 0 22.22 0z"/>
d="M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.85 0-2.14 1.45-2.14 2.94v5.67H9.34V9h3.42v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.22.79 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z" </svg>
></path>
</svg>
+13 -23
View File
@@ -1,39 +1,29 @@
--- ---
export interface Props { export interface Props {
index?: string; // ex. "01" index?: string;
label: string;
} }
const { index, label } = Astro.props; const { index } = Astro.props;
--- ---
<p class="eyebrow"> <p class="eyebrow">
{index && <span class="eyebrow__idx">{index}</span>} {index && <span class="eyebrow__index" aria-hidden="true">{index}</span>}
<span class="eyebrow__line" aria-hidden="true"></span> <span class="eyebrow__label"><slot /></span>
<span class="eyebrow__label">{label}</span>
</p> </p>
<style lang="scss"> <style lang="scss">
@use 'styles/type' as *; @use '../styles/type' as type;
.eyebrow { .eyebrow {
display: flex; @include type.text-label;
align-items: center; display: inline-flex;
gap: var(--nds-spacing-sm); align-items: baseline;
margin: 0 0 var(--nds-spacing-md); gap: var(--nds-spacing-xs);
color: var(--nds-primary);
text-transform: uppercase;
letter-spacing: 0.12em;
} }
.eyebrow__idx { .eyebrow__index {
font-family: var(--nds-font-mono); font-family: var(--nds-font-mono);
font-size: 0.75rem;
font-weight: 700;
color: var(--nds-accent);
}
.eyebrow__line {
width: 28px;
height: 1px;
background: var(--nds-border-strong);
}
.eyebrow__label {
@include text-eyebrow;
color: var(--nds-neutral); color: var(--nds-neutral);
} }
</style> </style>
+92 -78
View File
@@ -1,104 +1,118 @@
--- ---
import { Card, Badge, Link } from '@unkn0wndo3s/nova-design-system'; import { Badge, Button } from '@unkn0wndo3s/nova-design-system';
import { ArrowUpRight } from '@lucide/astro';
import TechIcon from './TechIcon.astro'; import TechIcon from './TechIcon.astro';
import { statusMeta, type Project } from '../data/projects'; import type { Project } from '../data/projects';
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
export interface Props { export interface Props {
project: Project; project: Project;
index?: number;
headingLevel?: 'h2' | 'h3';
} }
const { project } = Astro.props;
const status = statusMeta[project.status]; const { project, index, headingLevel: Heading = 'h3' } = Astro.props;
const href = `/work/${project.slug}`; const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
const statusType = { live: 'success', maintained: 'info', wip: 'warning' } as const;
const statusLabel = {
live: t('work.status.live'),
maintained: t('work.status.maintained'),
wip: t('work.status.wip'),
} as const;
const href = localizePath(`/work/${project.slug}`, locale);
--- ---
<article class="proj"> <article class="project-card">
<Card title={project.title} subtitle={project.tagline}> <header class="project-card__head">
<div class="proj__meta"> {typeof index === 'number' && (
<span class="proj__code">{project.designation}</span> <span class="project-card__index" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
<Badge type={status.tone} variant="soft">{status.label}</Badge> )}
</div> <Badge type={statusType[project.status]}>{statusLabel[project.status]}</Badge>
<p class="proj__summary">{project.summary}</p> </header>
<div class="proj__stack" aria-label="Stack technique">
{ <Heading class="project-card__title">
project.stack.map((s) => ( <a href={href}>{project.name}</a>
<span class="proj__chip" title={s.label}> </Heading>
<TechIcon name={s.icon} size={15} /> <p class="project-card__tagline">{project.tagline[locale]}</p>
{s.label} <p class="project-card__summary">{project.summary[locale]}</p>
</span>
)) <ul class="project-card__stack" aria-label={t('work.stack')}>
} {project.stack.map((tech) => (
</div> <li><TechIcon name={tech.name} icon={tech.icon} size={16} /></li>
<div slot="footer" class="proj__footer"> ))}
<Link url={href}> </ul>
Voir le projet
<ArrowUpRight size={15} /> <footer class="project-card__footer">
</Link> <Button href={href} type="secondary" size="sm">{t('home.projects.view')}</Button>
</div> </footer>
</Card>
</article> </article>
<style lang="scss"> <style lang="scss">
@use 'styles/type' as *; @use '../styles/type' as type;
.proj :global(.card) { .project-card {
width: 100%; display: flex;
height: 100%; flex-direction: column;
background: color-mix(in srgb, var(--nds-surface) 78%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-color: color-mix(in srgb, var(--nds-border) 90%, transparent);
transition:
transform 200ms ease,
border-color 200ms ease,
box-shadow 200ms ease;
}
.proj :global(.card:hover) {
transform: translateY(-3px);
border-color: color-mix(in srgb, var(--nds-primary) 45%, var(--nds-border));
box-shadow: var(--nds-shadow-lg);
}
.proj :global(.card__body) {
gap: var(--nds-spacing-sm); gap: var(--nds-spacing-sm);
height: 100%; height: 100%;
padding: clamp(20px, 3vw, 32px);
background: color-mix(in srgb, var(--nds-surface) 88%, transparent);
border: var(--nds-border-width-thin) solid var(--nds-border);
border-radius: var(--nds-radius-xl);
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
&:hover,
&:focus-within {
transform: translateY(-4px);
border-color: var(--nds-border-strong);
box-shadow: var(--nds-shadow-lg);
}
} }
.proj__meta { .project-card__head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--nds-spacing-sm); justify-content: space-between;
} }
.proj__code {
.project-card__index {
font-family: var(--nds-font-mono); font-family: var(--nds-font-mono);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--nds-accent);
}
.proj__summary {
@include text-base;
color: var(--nds-neutral); color: var(--nds-neutral);
margin: 0;
} }
.proj__stack {
.project-card__title {
@include type.text-2xl;
a {
color: var(--nds-text);
text-decoration: none;
&:hover { color: var(--nds-primary); }
}
}
.project-card__tagline {
@include type.text-lg;
color: var(--nds-primary);
font-weight: 500;
}
.project-card__summary {
@include type.text-base;
color: var(--nds-neutral);
flex: 1;
}
.project-card__stack {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--nds-spacing-2xs); gap: var(--nds-spacing-xs);
margin-top: var(--nds-spacing-2xs); margin: 0;
} padding: 0;
.proj__chip { list-style: none;
display: inline-flex;
align-items: center;
gap: var(--nds-spacing-2xs);
padding: 3px var(--nds-spacing-xs);
border-radius: var(--nds-radius-full);
border: var(--nds-border-width-thin) solid var(--nds-border);
background: var(--nds-surface-2);
color: var(--nds-neutral);
font-family: var(--nds-font-mono);
font-size: 0.6875rem;
}
.proj__footer {
margin-top: auto;
} }
.project-card__footer { margin-top: var(--nds-spacing-xs); }
</style> </style>
+57 -93
View File
@@ -1,134 +1,98 @@
--- ---
import { Gitea, Github, Npm } from 'simple-icons-astro'; import BrandIcon from './BrandIcon.astro';
import { Mail } from '@lucide/astro';
import BrandLinkedin from './BrandLinkedin.astro'; import BrandLinkedin from './BrandLinkedin.astro';
import { site } from '../data/site'; import { site } from '../data/site';
import { getLocaleFromUrl, useTranslations } from '../i18n';
const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const socials = [
{ ...site.links.gitea, Comp: Gitea },
{ ...site.links.github, Comp: Github },
{ ...site.links.npm, Comp: Npm },
];
--- ---
<footer class="footer"> <footer class="footer">
<div class="footer__inner"> <div class="footer__inner">
<div class="footer__brand"> <div>
<span class="footer__mark" aria-hidden="true"></span> <p class="footer__name">{site.name}</p>
<div> <p class="footer__role">{t('footer.role')}</p>
<p class="footer__name">{site.name}</p>
<p class="footer__role">{site.role} · {site.location}</p>
</div>
</div> </div>
<nav class="footer__links" aria-label="Liens externes"> <nav class="footer__links" aria-label="Social">
{ <a href={site.links.gitea.url} title="Gitea" rel="me noopener" target="_blank">
socials.map((s) => ( <BrandIcon icon="Gitea" size={20} color="current" /><span class="sr-only">Gitea</span>
<a class="footer__link" href={s.url} target="_blank" rel="noopener noreferrer" title={s.label}>
<s.Comp size={18} fill="currentColor" />
<span>{s.label}</span>
</a>
))
}
<a class="footer__link" href={site.links.linkedin.url} target="_blank" rel="noopener noreferrer" title="LinkedIn">
<BrandLinkedin size={18} />
<span>LinkedIn</span>
</a> </a>
<a class="footer__link" href={`mailto:${site.email}`} title="E-mail"> <a href={site.links.github.url} title="GitHub" rel="me noopener" target="_blank">
<Mail size={18} /> <BrandIcon icon="Github" size={20} color="current" /><span class="sr-only">GitHub</span>
<span>E-mail</span>
</a> </a>
<a href={site.links.npm.url} title="npm" rel="me noopener" target="_blank">
<BrandIcon icon="Npm" size={20} color="current" /><span class="sr-only">npm</span>
</a>
<a href={site.links.linkedin.url} title="LinkedIn" rel="me noopener" target="_blank">
<BrandLinkedin size={20} /><span class="sr-only">LinkedIn</span>
</a>
<a href={`mailto:${site.email}`} title="E-mail">{t('contact.email.label')}</a>
</nav> </nav>
</div> </div>
<div class="footer__base"> <p class="footer__legal">© {year} {site.name} · {t('footer.madeWith')}</p>
<span>© {year} {site.name}</span>
<span class="footer__sep" aria-hidden="true">·</span>
<span>Bâti avec Astro + Nova Design System</span>
</div>
</footer> </footer>
<style lang="scss"> <style lang="scss">
@use 'styles/type' as *; @use '../styles/type' as type;
.footer { .footer {
position: relative; position: relative;
z-index: 10; z-index: 10;
border-top: var(--nds-border-width-thin) solid margin-top: clamp(48px, 8vh, 96px);
color-mix(in srgb, var(--nds-border) 80%, transparent); padding: var(--nds-spacing-2xl) clamp(20px, 5vw, 56px) var(--nds-spacing-xl);
background: color-mix(in srgb, var(--nds-surface) 55%, transparent); border-top: var(--nds-border-width-thin) solid var(--nds-border);
backdrop-filter: blur(12px); background: color-mix(in srgb, var(--nds-surface) 60%, transparent);
-webkit-backdrop-filter: blur(12px);
margin-top: var(--nds-spacing-3xl);
} }
.footer__inner { .footer__inner {
max-width: 1120px;
margin: 0 auto;
padding: var(--nds-spacing-2xl) clamp(20px, 5vw, 56px) var(--nds-spacing-lg);
display: flex; display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--nds-spacing-lg);
}
.footer__brand {
display: flex;
align-items: center; align-items: center;
gap: var(--nds-spacing-sm); justify-content: space-between;
} gap: var(--nds-spacing-lg);
.footer__mark { max-width: 1200px;
width: 30px; margin-inline: auto;
height: 30px;
border-radius: var(--nds-radius-md);
background: linear-gradient(135deg, var(--nds-primary), var(--nds-accent));
box-shadow: 0 0 18px color-mix(in srgb, var(--nds-primary) 45%, transparent);
}
.footer__name {
@include text-xl;
margin: 0;
} }
.footer__name { @include type.text-xl; }
.footer__role { .footer__role {
@include text-sm; @include type.text-base;
color: var(--nds-neutral); color: var(--nds-neutral);
margin: 2px 0 0; margin-top: var(--nds-spacing-2xs);
} }
.footer__links { .footer__links {
display: flex; display: flex;
flex-wrap: wrap; align-items: center;
gap: var(--nds-spacing-md); gap: var(--nds-spacing-md);
}
.footer__link { a {
display: inline-flex; @include type.text-label;
align-items: center; display: inline-flex;
gap: var(--nds-spacing-xs); color: var(--nds-neutral);
color: var(--nds-neutral); transition: color 0.15s ease;
text-decoration: none; &:hover { color: var(--nds-primary); }
@include text-label;
transition: color 140ms ease;
&:hover {
color: var(--nds-primary);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--nds-ring);
border-radius: var(--nds-radius-sm);
} }
} }
.footer__base {
max-width: 1120px; .footer__legal {
margin: 0 auto; @include type.text-sm;
padding: var(--nds-spacing-md) clamp(20px, 5vw, 56px) var(--nds-spacing-2xl); max-width: 1200px;
display: flex; margin: var(--nds-spacing-lg) auto 0;
align-items: center;
gap: var(--nds-spacing-xs);
border-top: var(--nds-border-width-thin) solid
color-mix(in srgb, var(--nds-border) 50%, transparent);
@include text-sm;
color: var(--nds-neutral); color: var(--nds-neutral);
font-family: var(--nds-font-mono);
} }
.footer__sep {
opacity: 0.5; .sr-only {
position: absolute;
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
} }
</style> </style>
+156 -39
View File
@@ -1,57 +1,174 @@
--- ---
import { Navbar, Button } from '@unkn0wndo3s/nova-design-system'; import { Button } from '@unkn0wndo3s/nova-design-system';
import { Send } from '@lucide/astro'; import {
getLocaleFromUrl,
useTranslations,
localizePath,
unlocalizePath,
LOCALES,
} from '../i18n';
const path = Astro.url.pathname.replace(/\/$/, '') || '/'; const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
const route = unlocalizePath(Astro.url.pathname);
// Only real pages in the main nav - no surprise anchors. Contact lives in
// the CTA button (and on the home timeline); the brand links home.
const links = [ const links = [
{ href: '/', label: 'Accueil' }, { href: localizePath('/work', locale), label: t('nav.work'), match: '/work' },
{ href: '/work', label: 'Projets' }, { href: localizePath('/docs', locale), label: t('nav.docs'), match: '/docs' },
{ href: '/about', label: 'À propos' },
{ href: '/contact', label: 'Contact' },
]; ];
const isActive = (href: string) =>
href === '/' ? path === '/' : path === href || path.startsWith(href + '/'); const isActive = (match: string) =>
match === '/' ? route === '/' : route === match || route.startsWith(`${match}/`);
--- ---
<div class="site-nav"> <header class="nav" data-nav>
<Navbar brand="L · POTEVIN"> <a class="nav__brand" href={localizePath('/', locale)} aria-label={t('nav.home')}>
{ L<span aria-hidden="true"> · </span>POTEVIN
links.map((l) => ( </a>
<a
class={`navbar__link ${isActive(l.href) ? 'navbar__link--active' : ''}`} <nav class="nav__links" aria-label="Main">
href={l.href} {links.map((l) => (
aria-current={isActive(l.href) ? 'page' : undefined} <a href={l.href} aria-current={isActive(l.match) ? 'page' : undefined}>{l.label}</a>
> ))}
{l.label} </nav>
</a>
)) <div class="nav__right">
} <nav class="nav__lang" aria-label={t('a11y.langSwitch')}>
<Button slot="right" type="primary" size="sm" href="/contact"> {LOCALES.map((l, i) => (
<Send slot="icon-left" size={15} /> <>
Établir le contact {i > 0 && <span aria-hidden="true">/</span>}
</Button> <a
</Navbar> href={localizePath(route, l)}
</div> hreflang={l}
aria-current={l === locale ? 'true' : undefined}
>
{l.toUpperCase()}
</a>
</>
))}
</nav>
<span class="nav__cta">
<Button href={localizePath('/#contact', locale)} size="sm">{t('nav.cta')}</Button>
</span>
<button class="nav__burger" data-nav-toggle aria-expanded="false" aria-label="Menu">
<span></span><span></span><span></span>
</button>
</div>
</header>
<style lang="scss"> <style lang="scss">
.site-nav { @use '../styles/type' as type;
.nav {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 60; z-index: 50;
display: flex;
align-items: center;
gap: var(--nds-spacing-lg);
padding: var(--nds-spacing-sm) clamp(20px, 5vw, 56px);
background: color-mix(in srgb, var(--nds-background) 78%, transparent);
backdrop-filter: blur(12px);
border-bottom: var(--nds-border-width-thin) solid var(--nds-border);
}
// Nova Navbar rendered translucent to let the starfield show through. .nav__brand {
:global(.navbar) { @include type.text-xl;
background: color-mix(in srgb, var(--nds-background) 72%, transparent); color: var(--nds-text);
backdrop-filter: blur(14px) saturate(1.1); text-decoration: none;
-webkit-backdrop-filter: blur(14px) saturate(1.1); letter-spacing: 0.08em;
border-bottom: var(--nds-border-width-thin) solid white-space: nowrap;
color-mix(in srgb, var(--nds-border) 80%, transparent); }
.nav__links {
display: flex;
gap: var(--nds-spacing-lg);
margin-inline: auto;
a {
@include type.text-label;
color: var(--nds-neutral);
text-decoration: none;
padding-block: var(--nds-spacing-2xs);
border-bottom: var(--nds-border-width-medium) solid transparent;
transition: color 0.15s ease;
&:hover { color: var(--nds-text); }
&[aria-current='page'] {
color: var(--nds-primary);
border-bottom-color: var(--nds-primary);
}
} }
} }
@media (max-width: 620px) { .nav__right {
.site-nav :global(.navbar__links) { display: flex;
align-items: center;
gap: var(--nds-spacing-md);
margin-left: auto;
}
.nav__lang {
@include type.text-label;
display: flex;
gap: var(--nds-spacing-2xs);
color: var(--nds-neutral);
a {
color: inherit;
text-decoration: none;
&:hover { color: var(--nds-text); }
&[aria-current='true'] { color: var(--nds-primary); }
}
}
.nav__burger {
display: none;
flex-direction: column;
gap: 5px;
padding: var(--nds-spacing-xs);
background: none;
border: none;
cursor: pointer;
span {
width: 22px;
height: 2px;
background: var(--nds-text);
border-radius: var(--nds-radius-full);
transition: transform 0.2s ease, opacity 0.2s ease;
}
&[aria-expanded='true'] {
span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
span:nth-child(2) { opacity: 0; }
span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
}
}
@media (max-width: 840px) {
.nav { flex-wrap: wrap; }
.nav__burger { display: flex; }
.nav__cta { display: none; }
.nav__links {
display: none; display: none;
flex-basis: 100%;
flex-direction: column;
gap: var(--nds-spacing-sm);
padding-block: var(--nds-spacing-md);
.nav[data-open] & { display: flex; }
} }
} }
</style> </style>
<script>
const nav = document.querySelector('[data-nav]');
const toggle = document.querySelector('[data-nav-toggle]');
toggle?.addEventListener('click', () => {
const open = nav?.toggleAttribute('data-open');
toggle.setAttribute('aria-expanded', String(Boolean(open)));
});
</script>
+14 -38
View File
@@ -1,51 +1,27 @@
--- ---
// Persistent space background. Placed in the layout → loaded on all pages. /**
// Content remains 100% readable and functional even if WebGL fails (decorative). * Fixed WebGL background. Three.js is imported dynamically so the page
* stays interactive before the scene loads; the canvas removes itself
* when WebGL is unavailable or reduced motion is preferred.
*/
--- ---
<canvas id="space-canvas" class="space-canvas" aria-hidden="true"></canvas> <canvas id="space-bg" aria-hidden="true"></canvas>
<style lang="scss"> <style>
.space-canvas { #space-bg {
position: fixed; position: fixed;
inset: 0; inset: 0;
width: 100vw;
height: 100vh;
z-index: 0; z-index: 0;
display: block; width: 100%;
height: 100%;
pointer-events: none; pointer-events: none;
// slight overlay tint to ground the content on top
background:
radial-gradient(
120% 80% at 50% -10%,
color-mix(in srgb, var(--nds-primary) 7%, transparent),
transparent 60%
),
var(--nds-background);
} }
</style> </style>
<script> <script>
import { initScene, type SceneHandle } from './scene/scene'; const canvas = document.getElementById('space-bg') as HTMLCanvasElement | null;
if (canvas) {
declare global { import('./scene/scene').then(({ mountSpaceScene }) => mountSpaceScene(canvas));
interface Window {
__novaScene?: SceneHandle;
}
} }
</script>
const canvas = document.getElementById('space-canvas') as HTMLCanvasElement | null;
if (canvas && !window.__novaScene) {
try {
const handle = initScene(canvas);
window.__novaScene = handle;
// notify page scripts that the scene is ready
window.dispatchEvent(new CustomEvent<SceneHandle>('nova:scene-ready', { detail: handle }));
} catch (err) {
// WebGL unavailable: fail silently, the website remains fully usable.
console.warn('[nova] 3D background disabled:', err);
canvas.style.display = 'none';
}
}
</script>
+29 -42
View File
@@ -1,52 +1,39 @@
--- ---
// Monochrome tech icon (inherits `color` via fill=currentColor). /**
import { * Renders a technology icon (via BrandIcon) with its label. The icon is
Typescript, * decorative: only the visible label is announced, once.
Javascript, */
Postgresql, import BrandIcon from './BrandIcon.astro';
Docker,
Astro as AstroIcon,
Sass,
Gitea,
Linux,
Cloudflare,
Threedotjs,
Rust,
Python,
Npm,
Github,
Svelte,
Git,
Nodedotjs,
} from 'simple-icons-astro';
export interface Props { export interface Props {
name: string; name: string;
icon: string;
size?: number; size?: number;
} }
const { name, size = 18 } = Astro.props;
const map: Record<string, any> = {
typescript: Typescript,
javascript: Javascript,
postgresql: Postgresql,
docker: Docker,
astro: AstroIcon,
sass: Sass,
gitea: Gitea,
linux: Linux,
cloudflare: Cloudflare,
threedotjs: Threedotjs,
rust: Rust,
python: Python,
npm: Npm,
github: Github,
svelte: Svelte,
git: Git,
nodedotjs: Nodedotjs,
};
const Icon = map[name]; const { name, icon, size = 18 } = Astro.props;
--- ---
{Icon && <Icon size={size} fill="currentColor" />} <span class="tech">
<BrandIcon icon={icon} size={size} />
<span>{name}</span>
</span>
<style lang="scss">
@use '../styles/type' as type;
.tech {
@include type.text-label;
display: inline-flex;
align-items: center;
gap: var(--nds-spacing-xs);
padding: var(--nds-spacing-xs) var(--nds-spacing-sm);
color: var(--nds-text);
background: var(--nds-surface);
border: var(--nds-border-width-thin) solid var(--nds-border);
border-radius: var(--nds-radius-full);
:global(svg) { color: var(--nds-primary); flex-shrink: 0; }
}
</style>
+321 -211
View File
@@ -1,250 +1,360 @@
/**
* Space scene - ambient starfield + nebula, plus a scroll-reactive asteroid belt.
*
* Behavior contract:
* - Scrolling DOWN injects energy: asteroids drift apart, collide elastically
* and emit spark bursts on impact.
* - Scrolling UP does NOT rewind: a gravity vortex wakes up and pulls the
* belt into a slow spiral until it settles back into formation.
* - No WebGL, `prefers-reduced-motion`, or a lost context -> the canvas is
* removed and the site remains fully readable.
*
* Everything runs on a single fixed canvas, capped at DPR 2, paused when the
* tab is hidden.
*/
import * as THREE from 'three'; import * as THREE from 'three';
// ───────────────────────────────────────────────────────────────────────────── const ASTEROID_COUNT = 90;
// Ambient space background: starfield, subtle nebula, and a slow-drifting, const BELT_RADIUS = 26;
// slow-rotating asteroid. No scroll binding, no engine flames. const BELT_THICKNESS = 9;
// Slight pointer parallax. Respects prefers-reduced-motion. const ASTEROID_MIN_R = 0.28;
// ───────────────────────────────────────────────────────────────────────────── const ASTEROID_MAX_R = 1.15;
const SPARK_POOL = 240;
export interface SceneHandle { interface AsteroidState {
/** Kept for compatibility — no-op (no more impact/flame effect). */ position: THREE.Vector3;
setImpact(value: number): void; velocity: THREE.Vector3;
destroy(): void; spin: THREE.Vector3;
rotation: THREE.Euler;
radius: number;
/** Slot in the resting formation, used by the vortex to settle back. */
home: THREE.Vector3;
} }
const COLORS = { function prefersReducedMotion(): boolean {
star: 0xecf5f5, return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
starCool: 0x34c2c9,
starAccent: 0x2efafa,
nebulaA: 0x288181,
nebulaB: 0x1fa6ad,
rock: 0x1a2a2a,
rim: 0x34c2c9,
};
const prefersReduced =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Small reusable radial texture (soft glow). */
function radialTexture(inner: string, outer = 'rgba(0,0,0,0)'): THREE.Texture {
const c = document.createElement('canvas');
c.width = c.height = 128;
const ctx = c.getContext('2d')!;
const g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
g.addColorStop(0, inner);
g.addColorStop(0.4, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, 128, 128);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
} }
export function initScene(canvas: HTMLCanvasElement): SceneHandle { export function mountSpaceScene(canvas: HTMLCanvasElement): (() => void) | null {
const renderer = new THREE.WebGLRenderer({ if (prefersReducedMotion()) {
canvas, canvas.remove();
antialias: true, return null;
alpha: true, }
powerPreference: 'high-performance',
}); let renderer: THREE.WebGLRenderer;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75)); try {
renderer.setSize(window.innerWidth, window.innerHeight); renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setClearColor(0x000000, 0); } catch {
canvas.remove();
return null;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight, false);
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x040b0b, 0.012); const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 400);
camera.position.set(0, 0, 46);
const camera = new THREE.PerspectiveCamera( scene.add(new THREE.AmbientLight(0x9fdadd, 0.35));
55, const key = new THREE.DirectionalLight(0x7ff5f5, 1.4);
window.innerWidth / window.innerHeight, key.position.set(18, 22, 30);
0.1,
400,
);
camera.position.set(0, 0, 18);
// ── Lighting (cold, consistent with Nova tokens) ───────────────────────────
const ambient = new THREE.AmbientLight(0x16323a, 1.0);
scene.add(ambient);
const key = new THREE.DirectionalLight(COLORS.starCool, 0.9);
key.position.set(-6, 5, 9);
scene.add(key); scene.add(key);
const rimLight = new THREE.DirectionalLight(COLORS.starAccent, 0.4);
rimLight.position.set(8, -3, 4);
scene.add(rimLight);
// ── Starfield ────────────────────────────────────────────────────────────── /* ── Starfield ─────────────────────────────────────────────────────────── */
const STAR_COUNT = prefersReduced ? 700 : 1700; const starGeometry = new THREE.BufferGeometry();
const starGeo = new THREE.BufferGeometry(); const starCount = 1400;
const starPos = new Float32Array(STAR_COUNT * 3); const starPositions = new Float32Array(starCount * 3);
const starCol = new Float32Array(STAR_COUNT * 3); for (let i = 0; i < starCount; i++) {
const palette = [ const r = 90 + Math.random() * 220;
new THREE.Color(COLORS.star), const theta = Math.random() * Math.PI * 2;
new THREE.Color(COLORS.starCool), const phi = Math.acos(2 * Math.random() - 1);
new THREE.Color(COLORS.starAccent), starPositions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
]; starPositions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
for (let i = 0; i < STAR_COUNT; i++) { starPositions[i * 3 + 2] = r * Math.cos(phi);
starPos[i * 3] = (Math.random() - 0.5) * 120;
starPos[i * 3 + 1] = (Math.random() - 0.5) * 120;
starPos[i * 3 + 2] = (Math.random() - 0.5) * 120 - 20;
const col = palette[Math.random() < 0.78 ? 0 : Math.random() < 0.6 ? 1 : 2];
starCol[i * 3] = col.r;
starCol[i * 3 + 1] = col.g;
starCol[i * 3 + 2] = col.b;
} }
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3)); starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
starGeo.setAttribute('color', new THREE.BufferAttribute(starCol, 3)); const stars = new THREE.Points(
const starMat = new THREE.PointsMaterial({ starGeometry,
size: 0.42, new THREE.PointsMaterial({ color: 0xbfeeee, size: 0.55, sizeAttenuation: true, transparent: true, opacity: 0.8 }),
map: radialTexture('rgba(255,255,255,0.95)'), );
transparent: true,
depthWrite: false,
vertexColors: true,
blending: THREE.AdditiveBlending,
});
const stars = new THREE.Points(starGeo, starMat);
scene.add(stars); scene.add(stars);
// ── Nebula (soft sprites, pushed far into the background) ────────────────── /* ── Nebula (two soft additive sprites) ────────────────────────────────── */
const nebula = new THREE.Group(); const nebulaTexture = makeGlowTexture();
const nebTex = radialTexture('rgba(40,129,129,0.55)'); for (const [x, y, s, o] of [
[ [-34, 14, 120, 0.10],
{ x: -14, y: 8, z: -45, s: 60, c: COLORS.nebulaA }, [30, -20, 150, 0.07],
{ x: 16, y: -10, z: -55, s: 72, c: COLORS.nebulaB }, ] as const) {
{ x: 4, y: 14, z: -40, s: 46, c: COLORS.nebulaA }, const sprite = new THREE.Sprite(
].forEach((n) => { new THREE.SpriteMaterial({
const mat = new THREE.SpriteMaterial({ map: nebulaTexture,
map: nebTex, color: 0x1fa6ad,
color: n.c, transparent: true,
transparent: true, opacity: o,
opacity: 0.2, depthWrite: false,
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
depthWrite: false, }),
}); );
const sp = new THREE.Sprite(mat); sprite.position.set(x, y, -60);
sp.position.set(n.x, n.y, n.z); sprite.scale.setScalar(s);
sp.scale.set(n.s, n.s, 1); scene.add(sprite);
nebula.add(sp);
});
scene.add(nebula);
// ── Asteroid (cold rock, subtle halo) ──────────────────────────────────────
const asteroid = new THREE.Group();
const rockGeo = new THREE.IcosahedronGeometry(1.9, 2);
const pos = rockGeo.attributes.position as THREE.BufferAttribute;
const v = new THREE.Vector3();
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i);
const h = Math.sin(v.x * 4.1) * Math.cos(v.y * 3.7) * Math.sin(v.z * 4.3);
const d = 1 + h * 0.16;
v.multiplyScalar(d);
pos.setXYZ(i, v.x, v.y, v.z);
} }
rockGeo.computeVertexNormals();
const rockMat = new THREE.MeshStandardMaterial({ /* ── Asteroid belt (instanced) ─────────────────────────────────────────── */
color: COLORS.rock, const rockGeometry = makeRockGeometry();
emissive: COLORS.rim, const rockMaterial = new THREE.MeshStandardMaterial({
emissiveIntensity: 0.12, color: 0x2a4040,
roughness: 0.92, roughness: 0.92,
metalness: 0.12, metalness: 0.12,
flatShading: true, flatShading: true,
}); });
const rock = new THREE.Mesh(rockGeo, rockMat); const belt = new THREE.InstancedMesh(rockGeometry, rockMaterial, ASTEROID_COUNT);
asteroid.add(rock); belt.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
scene.add(belt);
// very light cold halo, just to separate the rock from the background const asteroids: AsteroidState[] = [];
const glow = new THREE.Sprite( for (let i = 0; i < ASTEROID_COUNT; i++) {
new THREE.SpriteMaterial({ const angle = (i / ASTEROID_COUNT) * Math.PI * 2 + Math.random() * 0.35;
map: radialTexture('rgba(52,194,201,0.7)'), const radius = BELT_RADIUS + (Math.random() - 0.5) * BELT_THICKNESS;
color: COLORS.rim, const home = new THREE.Vector3(
transparent: true, Math.cos(angle) * radius,
opacity: 0.28, (Math.random() - 0.5) * 10,
blending: THREE.AdditiveBlending, -8 + Math.sin(angle) * 10,
depthWrite: false, );
}), asteroids.push({
); position: home.clone(),
glow.scale.set(7.5, 7.5, 1); velocity: new THREE.Vector3(),
asteroid.add(glow); spin: new THREE.Vector3(Math.random(), Math.random(), Math.random()).multiplyScalar(0.4),
rotation: new THREE.Euler(Math.random() * Math.PI, Math.random() * Math.PI, 0),
radius: ASTEROID_MIN_R + Math.random() * (ASTEROID_MAX_R - ASTEROID_MIN_R),
home,
});
}
// ambient position: top right, set back /* ── Spark pool for collision impacts ──────────────────────────────────── */
asteroid.position.set(6.5, 4.5, -2); const sparkGeometry = new THREE.BufferGeometry();
scene.add(asteroid); const sparkPositions = new Float32Array(SPARK_POOL * 3);
sparkGeometry.setAttribute('position', new THREE.BufferAttribute(sparkPositions, 3));
const sparkMaterial = new THREE.PointsMaterial({
color: 0x2efafa,
size: 0.5,
transparent: true,
opacity: 0.9,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const sparks = new THREE.Points(sparkGeometry, sparkMaterial);
scene.add(sparks);
const sparkVel: THREE.Vector3[] = [];
const sparkLife: number[] = [];
for (let i = 0; i < SPARK_POOL; i++) {
sparkVel.push(new THREE.Vector3());
sparkLife.push(0);
sparkPositions.set([0, -999, 0], i * 3);
}
let sparkCursor = 0;
// ── State & loop ─────────────────────────────────────────────────────────── function burst(at: THREE.Vector3, strength: number) {
const pointer = { x: 0, y: 0, tx: 0, ty: 0 }; const n = 10 + Math.floor(strength * 8);
for (let i = 0; i < n; i++) {
const idx = sparkCursor;
sparkCursor = (sparkCursor + 1) % SPARK_POOL;
sparkPositions.set([at.x, at.y, at.z], idx * 3);
sparkVel[idx]!
.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5)
.normalize()
.multiplyScalar(3 + Math.random() * 5 * strength);
sparkLife[idx] = 0.9;
}
}
/* ── Scroll energy model ───────────────────────────────────────────────── */
let lastScrollY = window.scrollY;
let downEnergy = 0; // fuels drift + collisions
let upEnergy = 0; // fuels the vortex
let beltVisible = true;
const beltAnchor = document.querySelector<HTMLElement>('[data-belt-anchor]');
if (beltAnchor) {
beltVisible = false;
new IntersectionObserver(
(entries) => {
beltVisible = entries[0]?.isIntersecting ?? true;
},
{ rootMargin: '20% 0px' },
).observe(beltAnchor);
}
function onScroll() {
const delta = window.scrollY - lastScrollY;
lastScrollY = window.scrollY;
if (!beltVisible) return;
if (delta > 0) downEnergy = Math.min(1.6, downEnergy + delta * 0.004);
else if (delta < 0) upEnergy = Math.min(1.6, upEnergy - delta * 0.004);
}
window.addEventListener('scroll', onScroll, { passive: true });
/* ── Simulation ────────────────────────────────────────────────────────── */
const matrix = new THREE.Matrix4();
const quaternion = new THREE.Quaternion();
const scaleVec = new THREE.Vector3();
const tmp = new THREE.Vector3();
// Manual timing (THREE.Clock is deprecated).
let lastTime = performance.now();
let elapsed = 0;
let raf = 0;
let running = true; let running = true;
function onPointer(e: PointerEvent) { function step() {
pointer.tx = (e.clientX / window.innerWidth - 0.5) * 2; raf = requestAnimationFrame(step);
pointer.ty = (e.clientY / window.innerHeight - 0.5) * 2; if (!running) return;
const now = performance.now();
const dt = Math.min((now - lastTime) / 1000, 0.05);
lastTime = now;
elapsed += dt;
const t = elapsed;
stars.rotation.y = t * 0.004;
downEnergy = Math.max(0, downEnergy - dt * 0.55);
upEnergy = Math.max(0, upEnergy - dt * 0.4);
for (const a of asteroids) {
// Scroll down: outward drift + slight chaos -> collisions happen.
if (downEnergy > 0.01) {
tmp.copy(a.position).sub(new THREE.Vector3(0, 0, -8)).normalize();
a.velocity.addScaledVector(tmp, downEnergy * dt * 2.2);
a.velocity.x += (Math.random() - 0.5) * downEnergy * dt * 3;
a.velocity.y += (Math.random() - 0.5) * downEnergy * dt * 3;
}
// Scroll up: vortex - tangential pull + gentle fall toward home orbit.
if (upEnergy > 0.01) {
tmp.set(-(a.position.y - a.home.y) - a.position.x * 0.3, a.position.x - a.home.x, 0);
a.velocity.addScaledVector(tmp.normalize(), upEnergy * dt * 3.4);
tmp.copy(a.home).sub(a.position);
a.velocity.addScaledVector(tmp, upEnergy * dt * 0.9);
}
// Always: soft spring back to formation + drag.
tmp.copy(a.home).sub(a.position);
a.velocity.addScaledVector(tmp, dt * 0.25);
a.velocity.multiplyScalar(1 - dt * 0.8);
a.position.addScaledVector(a.velocity, dt);
a.rotation.x += a.spin.x * dt;
a.rotation.y += a.spin.y * dt;
}
// Elastic collisions (spatially naive is fine at N=90).
if (downEnergy > 0.05) {
for (let i = 0; i < asteroids.length; i++) {
const a = asteroids[i]!;
for (let j = i + 1; j < asteroids.length; j++) {
const b = asteroids[j]!;
tmp.copy(b.position).sub(a.position);
const dist = tmp.length();
const minDist = a.radius + b.radius;
if (dist > 0.0001 && dist < minDist) {
tmp.normalize();
const relative = tmp.dot(b.velocity.clone().sub(a.velocity));
if (relative < 0) {
const impulse = -relative;
a.velocity.addScaledVector(tmp, -impulse * 0.5);
b.velocity.addScaledVector(tmp, impulse * 0.5);
const overlap = minDist - dist;
a.position.addScaledVector(tmp, -overlap / 2);
b.position.addScaledVector(tmp, overlap / 2);
if (impulse > 1.2) {
burst(a.position.clone().addScaledVector(tmp, a.radius), Math.min(impulse / 4, 1));
}
}
}
}
}
}
for (let i = 0; i < asteroids.length; i++) {
const a = asteroids[i]!;
quaternion.setFromEuler(a.rotation);
scaleVec.setScalar(a.radius);
matrix.compose(a.position, quaternion, scaleVec);
belt.setMatrixAt(i, matrix);
}
belt.instanceMatrix.needsUpdate = true;
for (let i = 0; i < SPARK_POOL; i++) {
if (sparkLife[i]! <= 0) continue;
sparkLife[i]! -= dt;
sparkVel[i]!.multiplyScalar(1 - dt * 2);
sparkPositions[i * 3] += sparkVel[i]!.x * dt;
sparkPositions[i * 3 + 1] += sparkVel[i]!.y * dt;
sparkPositions[i * 3 + 2] += sparkVel[i]!.z * dt;
if (sparkLife[i]! <= 0) sparkPositions[i * 3 + 1] = -999;
}
sparkGeometry.attributes.position!.needsUpdate = true;
sparkMaterial.opacity = 0.5 + downEnergy * 0.3;
camera.position.x = Math.sin(t * 0.05) * 1.2;
camera.position.y = Math.cos(t * 0.04) * 0.8;
camera.lookAt(0, 0, -8);
renderer.render(scene, camera);
} }
function onResize() { function onResize() {
camera.aspect = window.innerWidth / window.innerHeight; camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight); renderer.setSize(window.innerWidth, window.innerHeight, false);
} }
window.addEventListener('resize', onResize);
function onVisibility() { function onVisibility() {
running = document.visibilityState === 'visible'; running = document.visibilityState === 'visible';
if (running) loop(); if (running) lastTime = performance.now(); // swallow the pause
} }
window.addEventListener('resize', onResize);
window.addEventListener('pointermove', onPointer, { passive: true });
document.addEventListener('visibilitychange', onVisibility); document.addEventListener('visibilitychange', onVisibility);
const clock = new THREE.Clock(); step();
let rafId = 0;
function loop() { return () => {
if (!running) return; cancelAnimationFrame(raf);
rafId = requestAnimationFrame(loop); window.removeEventListener('scroll', onScroll);
const dt = Math.min(clock.getDelta(), 0.05); window.removeEventListener('resize', onResize);
const t = clock.elapsedTime; document.removeEventListener('visibilitychange', onVisibility);
renderer.dispose();
// slow background drift rockGeometry.dispose();
stars.rotation.y += dt * 0.01; starGeometry.dispose();
nebula.rotation.z += dt * 0.004; sparkGeometry.dispose();
// asteroid: self-rotation + slight float (no scroll binding)
if (!prefersReduced) {
rock.rotation.x += dt * 0.18;
rock.rotation.y += dt * 0.26;
asteroid.position.y = 4.5 + Math.sin(t * 0.25) * 0.5;
asteroid.position.x = 6.5 + Math.cos(t * 0.18) * 0.4;
}
// smooth camera parallax via pointer
pointer.x += (pointer.tx - pointer.x) * dt * 2;
pointer.y += (pointer.ty - pointer.y) * dt * 2;
camera.position.x = pointer.x * 1.2;
camera.position.y = -pointer.y * 0.8 + Math.sin(t * 0.3) * 0.15;
camera.lookAt(0, 0, 0);
renderer.render(scene, camera);
}
loop();
return {
setImpact() {
/* no-op: kept for API compatibility */
},
destroy() {
running = false;
cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
window.removeEventListener('pointermove', onPointer);
document.removeEventListener('visibilitychange', onVisibility);
renderer.dispose();
starGeo.dispose();
rockGeo.dispose();
scene.traverse((o) => {
const m = (o as THREE.Mesh).material;
if (Array.isArray(m)) m.forEach((mm) => mm.dispose());
else if (m) (m as THREE.Material).dispose();
});
},
}; };
} }
/* ── Helpers ─────────────────────────────────────────────────────────────── */
/** Irregular rock: a dodecahedron with jittered vertices. */
function makeRockGeometry(): THREE.BufferGeometry {
const geometry = new THREE.DodecahedronGeometry(1, 0);
const pos = geometry.attributes.position!;
for (let i = 0; i < pos.count; i++) {
const jitter = 0.78 + Math.random() * 0.44;
pos.setXYZ(i, pos.getX(i) * jitter, pos.getY(i) * jitter, pos.getZ(i) * jitter);
}
geometry.computeVertexNormals();
return geometry;
}
/** Radial-gradient sprite texture used for the nebula glow. */
function makeGlowTexture(): THREE.Texture {
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d')!;
const gradient = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
gradient.addColorStop(0, 'rgba(255,255,255,1)');
gradient.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
+576
View File
@@ -0,0 +1,576 @@
/**
* The home "voyage" engine.
*
* One fixed WebGL canvas + fixed HTML overlays. The page's scroll height is a
* timeline: scrolling never moves content - it advances a camera along a 3D
* path (nebulae, asteroid belt, one beacon per project, a final portal) and
* cross-fades the matching overlay. Scroll velocity feeds the belt physics:
* scrolling down shakes asteroids into collisions (sparks), scrolling up pulls
* them into a gravitational vortex back to formation.
*
* Progressive enhancement: without WebGL or with prefers-reduced-motion the
* `cinema` class is never added and the sections render as a normal page.
*/
import * as THREE from 'three';
import type Lenis from 'lenis';
interface Segment {
id: string;
el: HTMLElement;
weight: number;
side: 'left' | 'right' | 'center';
isProject: boolean;
/** Global progress window [start, end] (0..1). */
start: number;
end: number;
}
export interface VoyageHandle {
seek(id: string, immediate?: boolean): void;
dispose(): void;
}
const DEPTH = 44; // world units per weight unit of scroll
const BG = 0x040b0b;
const CYAN = 0x2efafa;
const TEAL = 0x34c2c9;
const DEEP = 0x155e75;
function glowTexture(hex: string): THREE.Texture {
const c = document.createElement('canvas');
c.width = c.height = 256;
const ctx = c.getContext('2d')!;
const g = ctx.createRadialGradient(128, 128, 0, 128, 128, 128);
g.addColorStop(0, `${hex}ff`);
g.addColorStop(0.35, `${hex}66`);
g.addColorStop(1, `${hex}00`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, 256, 256);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
const smoothstep = (a: number, b: number, x: number) => {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
};
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
/**
* Dwell curve: rises to 0.5 over the first 38% of a segment, HOLDS through
* the middle, finishes over the last 38%. Applied to the camera's progress
* inside each segment, so every step of the voyage locks in place while the
* matching card is on screen - small scroll moves barely shift the view.
*/
const dwell = (t: number) => 0.5 * smoothstep(0, 0.38, t) + 0.5 * smoothstep(0.62, 1, t);
export function mountVoyage(opts: {
canvas: HTMLCanvasElement;
container: HTMLElement;
lenis: Lenis;
}): VoyageHandle | null {
const { canvas, container, lenis } = opts;
// --- Capability gate -----------------------------------------------------
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
} catch {
return null;
}
const isMobile = window.matchMedia('(max-width: 840px)').matches;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.75 : 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(BG, 1);
// --- Timeline from the DOM ----------------------------------------------
const els = Array.from(container.querySelectorAll<HTMLElement>('[data-seg]'));
if (!els.length) return null;
const segments: Segment[] = els.map((el) => ({
id: el.dataset.seg!,
el,
weight: Number(el.dataset.weight ?? 1),
side: (el.dataset.side as Segment['side']) ?? 'center',
isProject: 'project' in el.dataset,
start: 0,
end: 0,
}));
const totalWeight = segments.reduce((s, x) => s + x.weight, 0);
let acc = 0;
for (const s of segments) {
s.start = acc / totalWeight;
acc += s.weight;
s.end = acc / totalWeight;
}
// Cinema mode on: sections become fixed overlays, the container becomes the timeline.
document.documentElement.classList.add('cinema');
container.style.height = `${totalWeight * 100 + 60}vh`;
// --- Scene ---------------------------------------------------------------
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(BG, 0.0072);
const camera = new THREE.PerspectiveCamera(58, window.innerWidth / window.innerHeight, 0.1, 400);
// Flight path: one control point per segment boundary, gently weaving.
const points: THREE.Vector3[] = [];
let cum = 0;
for (let i = 0; i <= segments.length; i++) {
points.push(new THREE.Vector3(7 * Math.sin(i * 1.9), 2.4 * Math.sin(i * 1.3 + 1), -cum * DEPTH));
cum += segments[i]?.weight ?? 1;
}
const path = new THREE.CatmullRomCurve3(points, false, 'catmullrom', 0.35);
const END_Z = points[points.length - 1]!.z;
// Starfield: a long tube of stars around the whole path.
{
const count = isMobile ? 900 : 2000;
const pos = new Float32Array(count * 3);
const col = new Float32Array(count * 3);
const cCyan = new THREE.Color(CYAN);
const cWhite = new THREE.Color(0xdfffff);
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const r = 16 + Math.random() * 85;
pos[i * 3] = Math.cos(a) * r;
pos[i * 3 + 1] = Math.sin(a) * r * 0.7;
pos[i * 3 + 2] = 30 + Math.random() * (END_Z - 90);
const c = Math.random() < 0.25 ? cCyan : cWhite;
col[i * 3] = c.r;
col[i * 3 + 1] = c.g;
col[i * 3 + 2] = c.b;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
scene.add(
new THREE.Points(
geo,
new THREE.PointsMaterial({
size: 0.55,
vertexColors: true,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
}),
),
);
}
// Nebulae: big soft sprites drifting near the path.
const nebulae: THREE.Sprite[] = [];
{
const colors = ['#2EFAFA', '#34C2C9', '#155E75', '#2EFAFA', '#0E7490'];
for (let i = 0; i < 5; i++) {
const mat = new THREE.SpriteMaterial({
map: glowTexture(colors[i]!),
transparent: true,
opacity: 0.14 + (i % 2) * 0.08,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const sp = new THREE.Sprite(mat);
const u = (i + 0.5) / 5;
const p = path.getPointAt(u);
sp.position.set(p.x + (i % 2 ? -1 : 1) * (40 + i * 8), p.y + (i % 2 ? 14 : -10), p.z - 30);
const s = 150 + i * 30;
sp.scale.set(s, s, 1);
nebulae.push(sp);
scene.add(sp);
}
}
// --- Asteroid belt across the project segments ---------------------------
const projectSegs = segments.filter((s) => s.isProject);
const beltZ0 = -((projectSegs[0]?.start ?? 0.3) * totalWeight) * DEPTH + 10;
const beltZ1 = -((projectSegs[projectSegs.length - 1]?.end ?? 0.7) * totalWeight) * DEPTH - 10;
const AST = isMobile ? 48 : 110;
const belt = new THREE.InstancedMesh(
new THREE.DodecahedronGeometry(1, 0),
new THREE.MeshBasicMaterial({ color: 0x0d3b3f, wireframe: false }),
AST,
);
const beltWire = new THREE.InstancedMesh(
new THREE.DodecahedronGeometry(1.001, 0),
new THREE.MeshBasicMaterial({ color: TEAL, wireframe: true, transparent: true, opacity: 0.45 }),
AST,
);
interface Rock {
base: THREE.Vector3;
pos: THREE.Vector3;
vel: THREE.Vector3;
r: number;
spin: THREE.Vector3;
rot: THREE.Euler;
}
const rocks: Rock[] = [];
for (let i = 0; i < AST; i++) {
const a = Math.random() * Math.PI * 2;
const rad = 10 + Math.random() * 17;
const base = new THREE.Vector3(
Math.cos(a) * rad,
Math.sin(a) * rad * 0.55,
beltZ0 + Math.random() * (beltZ1 - beltZ0),
);
rocks.push({
base,
pos: base.clone(),
vel: new THREE.Vector3((Math.random() - 0.5), (Math.random() - 0.5), (Math.random() - 0.5)).multiplyScalar(2.2),
r: 0.45 + Math.random() * 1.3,
spin: new THREE.Vector3(Math.random(), Math.random(), Math.random()).multiplyScalar(0.8),
rot: new THREE.Euler(Math.random() * 3, Math.random() * 3, Math.random() * 3),
});
}
scene.add(belt, beltWire);
// Spark pool for collisions.
const SPARKS = 300;
const sparkPos = new Float32Array(SPARKS * 3);
const sparkVel: THREE.Vector3[] = [];
const sparkLife = new Float32Array(SPARKS);
for (let i = 0; i < SPARKS; i++) sparkVel.push(new THREE.Vector3());
const sparkGeo = new THREE.BufferGeometry();
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sparkPos, 3));
const sparks = new THREE.Points(
sparkGeo,
new THREE.PointsMaterial({
color: CYAN,
size: 0.5,
transparent: true,
opacity: 0.95,
blending: THREE.AdditiveBlending,
depthWrite: false,
}),
);
scene.add(sparks);
let sparkCursor = 0;
const burst = (at: THREE.Vector3, n: number) => {
for (let k = 0; k < n; k++) {
const i = sparkCursor++ % SPARKS;
sparkPos[i * 3] = at.x;
sparkPos[i * 3 + 1] = at.y;
sparkPos[i * 3 + 2] = at.z;
sparkVel[i]!.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).multiplyScalar(9);
sparkLife[i] = 1;
}
};
// --- Beacons (one per project) + final portal -----------------------------
interface Beacon {
group: THREE.Group;
ring: THREE.Mesh;
glow: THREE.Sprite;
seg: Segment;
}
const beacons: Beacon[] = [];
const beaconGlowTex = glowTexture('#2EFAFA');
for (const seg of projectSegs) {
const mid = (seg.start + seg.end) / 2;
const p = path.getPointAt(mid);
const dir = seg.side === 'left' ? 1 : -1; // beacon opposite to the text card
const group = new THREE.Group();
group.position.set(p.x + dir * 7.5, p.y + 0.5, p.z - 6);
const core = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.1, 1),
new THREE.MeshBasicMaterial({ color: CYAN, wireframe: true, transparent: true, opacity: 0.9 }),
);
const ring = new THREE.Mesh(
new THREE.TorusGeometry(2.2, 0.045, 10, 72),
new THREE.MeshBasicMaterial({ color: TEAL, transparent: true, opacity: 0.7 }),
);
ring.rotation.x = Math.PI / 2.4;
const glow = new THREE.Sprite(
new THREE.SpriteMaterial({
map: beaconGlowTex,
transparent: true,
opacity: 0.5,
blending: THREE.AdditiveBlending,
depthWrite: false,
}),
);
glow.scale.set(9, 9, 1);
group.add(core, ring, glow);
scene.add(group);
beacons.push({ group, ring, glow, seg });
}
const portal = new THREE.Group();
{
const g1 = new THREE.Sprite(
new THREE.SpriteMaterial({
map: glowTexture('#2EFAFA'),
transparent: true,
opacity: 0.85,
blending: THREE.AdditiveBlending,
depthWrite: false,
}),
);
g1.scale.set(46, 46, 1);
const r1 = new THREE.Mesh(
new THREE.TorusGeometry(9, 0.12, 12, 96),
new THREE.MeshBasicMaterial({ color: CYAN, transparent: true, opacity: 0.8 }),
);
const r2 = new THREE.Mesh(
new THREE.TorusGeometry(12.5, 0.06, 12, 96),
new THREE.MeshBasicMaterial({ color: DEEP, transparent: true, opacity: 0.7 }),
);
portal.add(g1, r1, r2);
portal.position.set(0, 0, END_Z - 46);
scene.add(portal);
}
// --- Overlay + progress UI ------------------------------------------------
const progressBar = document.querySelector<HTMLElement>('[data-voyage-progress]');
const dots = Array.from(document.querySelectorAll<HTMLElement>('[data-voyage-dot]'));
const applyOverlay = (p: number) => {
for (const s of segments) {
const span = s.end - s.start;
const local = (p - s.start) / span;
const first = s === segments[0];
const last = s === segments[segments.length - 1];
const inV = first ? 1 : smoothstep(0.04, 0.22, local);
const outV = last ? 1 : 1 - smoothstep(0.8, 0.97, local);
const vis = Math.max(0, Math.min(inV, outV));
const el = s.el;
if (local < -0.25 || local > 1.25) {
if (el.style.visibility !== 'hidden') el.style.visibility = 'hidden';
continue;
}
el.style.visibility = 'visible';
el.style.opacity = String(vis);
const y = (1 - inV) * 52 - (1 - outV) * 52;
el.style.transform = `translateY(${y.toFixed(1)}px) scale(${(0.985 + 0.015 * vis).toFixed(3)})`;
el.classList.toggle('is-active', vis > 0.45);
}
if (progressBar) progressBar.style.transform = `scaleX(${p.toFixed(4)})`;
const active = segments.findIndex((s) => p >= s.start && p < s.end);
dots.forEach((d, i) => d.classList.toggle('is-active', i === Math.max(0, active)));
};
// --- Frame loop ------------------------------------------------------------
let progress = 0;
let downEnergy = 0;
let upEnergy = 0;
let idleTime = 0;
let snapped = true; // don't snap on load
let elapsed = 0;
let last = performance.now();
let running = true;
let frame = 0;
// FPS watchdog: warm up 1.5s, then sample 2.5s. Below ~24fps the machine
// can't hold the scene (WebGL existing doesn't mean a GPU worth using) -
// tear down and give them the static page instead of a slideshow.
let fpsFrames = 0;
let fpsTime = 0;
let fpsChecked = false;
const camTarget = new THREE.Vector3();
const tmp = new THREE.Vector3();
const mat4 = new THREE.Matrix4();
const quat = new THREE.Quaternion();
const scl = new THREE.Vector3();
const maxScroll = () => Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
const tick = () => {
if (!running) {
frame = requestAnimationFrame(tick);
return;
}
const now = performance.now();
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += dt;
if (!fpsChecked && elapsed > 1.5) {
fpsFrames++;
fpsTime += dt;
if (fpsTime > 2.5) {
fpsChecked = true;
if (fpsFrames / fpsTime < 24) {
dispose();
return;
}
}
}
// Scroll → timeline progress + belt energies (Lenis keeps this smooth).
const raw = lenis.scroll / maxScroll();
progress = Math.min(1, Math.max(0, raw));
const v = lenis.velocity; // px per frame, signed
downEnergy = lerp(downEnergy, Math.min(1, Math.max(0, v / 55)), 0.08);
upEnergy = lerp(upEnergy, Math.min(1, Math.max(0, -v / 55)), 0.08);
// Idle snap: once the user pauses inside a segment, settle on its center
// so the current step is framed cleanly (a soft scroll lock, not a jail -
// any new scroll input takes over immediately).
if (Math.abs(v) > 1.5) {
idleTime = 0;
snapped = false;
} else {
idleTime += dt;
}
if (!snapped && idleTime > 0.35 && progress > 0.005 && progress < 0.995) {
const seg = segments.find((s) => progress >= s.start && progress < s.end);
if (seg) {
const center = seg.start + (seg.end - seg.start) * 0.5;
const off = Math.abs(progress - center);
if (off > 0.003 && off < (seg.end - seg.start) * 0.5) {
snapped = true;
lenis.scrollTo(center * maxScroll(), { duration: 0.9 });
}
}
}
// Camera along the path - with a per-segment dwell so each step locks.
let u = progress;
for (const s of segments) {
if (progress >= s.start && progress <= s.end) {
const span = s.end - s.start;
u = s.start + span * dwell((progress - s.start) / span);
break;
}
}
path.getPointAt(u, camera.position);
path.getPointAt(Math.min(1, u + 0.045), camTarget);
// Ease the gaze toward the active beacon.
for (const b of beacons) {
const mid = (b.seg.start + b.seg.end) / 2;
const w = 1 - Math.min(1, Math.abs(u - mid) / (b.seg.end - b.seg.start));
if (w > 0) camTarget.lerp(b.group.position, w * 0.35);
}
camera.lookAt(camTarget);
camera.position.y += Math.sin(elapsed * 0.7) * 0.12; // idle breathing
// Nebulae drift.
for (let i = 0; i < nebulae.length; i++) {
nebulae[i]!.material.rotation = elapsed * 0.02 * (i % 2 ? 1 : -1);
}
// Belt physics.
const agitated = downEnergy > 0.04;
for (let i = 0; i < AST; i++) {
const r = rocks[i]!;
if (agitated) {
r.pos.addScaledVector(r.vel, dt * (0.4 + downEnergy * 2.6));
}
if (upEnergy > 0.04) {
// Vortex: tangential pull around the path axis + spring home.
tmp.set(-r.pos.y, r.pos.x, 0).normalize();
r.pos.addScaledVector(tmp, dt * upEnergy * 14);
r.pos.lerp(r.base, dt * upEnergy * 1.6);
} else if (!agitated) {
r.pos.lerp(r.base, dt * 0.5);
}
r.rot.x += r.spin.x * dt * (1 + downEnergy * 3);
r.rot.y += r.spin.y * dt;
quat.setFromEuler(r.rot);
scl.setScalar(r.r);
mat4.compose(r.pos, quat, scl);
belt.setMatrixAt(i, mat4);
beltWire.setMatrixAt(i, mat4);
}
// Collisions only while agitated, only near the camera (cheap N² slice).
if (agitated) {
for (let i = 0; i < AST; i++) {
const a = rocks[i]!;
if (Math.abs(a.pos.z - camera.position.z) > 60) continue;
for (let j = i + 1; j < AST; j++) {
const b = rocks[j]!;
const dz = a.pos.z - b.pos.z;
if (dz > 6 || dz < -6) continue;
const d2 = a.pos.distanceToSquared(b.pos);
const rr = a.r + b.r;
if (d2 < rr * rr) {
tmp.subVectors(a.pos, b.pos).normalize();
const va = a.vel.dot(tmp);
const vb = b.vel.dot(tmp);
a.vel.addScaledVector(tmp, vb - va);
b.vel.addScaledVector(tmp, va - vb);
a.pos.addScaledVector(tmp, rr - Math.sqrt(d2));
burst(tmp.copy(a.pos).lerp(b.pos, 0.5), 7);
}
}
}
}
belt.instanceMatrix.needsUpdate = true;
beltWire.instanceMatrix.needsUpdate = true;
// Sparks.
for (let i = 0; i < SPARKS; i++) {
if (sparkLife[i]! <= 0) continue;
sparkLife[i]! -= dt * 1.4;
sparkPos[i * 3] += sparkVel[i]!.x * dt;
sparkPos[i * 3 + 1] += sparkVel[i]!.y * dt;
sparkPos[i * 3 + 2] += sparkVel[i]!.z * dt;
if (sparkLife[i]! <= 0) sparkPos[i * 3 + 1] = 9999;
}
sparkGeo.attributes.position!.needsUpdate = true;
// Beacons pulse; the active one lights up.
for (const b of beacons) {
const mid = (b.seg.start + b.seg.end) / 2;
const w = Math.max(0, 1 - Math.abs(u - mid) / ((b.seg.end - b.seg.start) * 0.9));
const pulse = 1 + Math.sin(elapsed * 2.2) * 0.05;
b.group.scale.setScalar((0.7 + w * 0.5) * pulse);
b.ring.rotation.z = elapsed * 0.6;
(b.glow.material as THREE.SpriteMaterial).opacity = 0.25 + w * 0.55;
b.group.rotation.y = elapsed * 0.35;
}
// Portal breathes and brightens on approach.
const arrive = smoothstep(0.82, 1, u);
portal.rotation.z = elapsed * 0.25;
portal.scale.setScalar(0.85 + arrive * 0.35 + Math.sin(elapsed * 1.4) * 0.02);
applyOverlay(progress);
renderer.render(scene, camera);
frame = requestAnimationFrame(tick);
};
// --- Wiring ----------------------------------------------------------------
const onResize = () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
};
const onVisibility = () => {
running = !document.hidden;
if (running) last = performance.now();
};
window.addEventListener('resize', onResize);
document.addEventListener('visibilitychange', onVisibility);
const seek = (id: string, immediate = false) => {
const s = segments.find((x) => x.id === id);
if (!s) return;
const target = (s.start + (s.end - s.start) * 0.45) * maxScroll();
lenis.scrollTo(target, immediate ? { immediate: true } : { duration: 1.8 });
};
dots.forEach((d) => d.addEventListener('click', () => seek(d.dataset.voyageDot!)));
const dispose = () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', onResize);
document.removeEventListener('visibilitychange', onVisibility);
renderer.dispose();
document.documentElement.classList.remove('cinema');
container.style.height = '';
for (const s of segments) {
s.el.removeAttribute('style');
s.el.classList.remove('is-active');
}
};
applyOverlay(0);
frame = requestAnimationFrame(tick);
return { seek, dispose };
}
+238
View File
@@ -0,0 +1,238 @@
---
/**
* Custom scene for `portfolio` (this very site): from wireframe to product.
*
* A pinned browser-window blueprint whose strokes draw themselves as you
* scroll, then light up with the NDS accent - the site building itself.
* Reduced motion / no JS: fully drawn and lit, no pin.
*/
import Eyebrow from '../Eyebrow.astro';
import { getLocaleFromUrl, useTranslations } from '../../i18n';
import type { Localized } from '../../data/projects';
const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
const captions: Localized[] = [
{ fr: 'Structure : Astro, routes FR/EN, données typées.', en: 'Structure: Astro, FR/EN routes, typed data.' },
{ fr: 'Habillage : chaque pixel vient des tokens du Nova Design System.', en: 'Skin: every pixel comes from Nova Design System tokens.' },
{ fr: 'Mise en orbite : build statique, CI Gitea, déployé sur le VPS.', en: 'Into orbit: static build, Gitea CI, deployed on the VPS.' },
];
// Wireframe strokes in draw order (all use pathLength=1 for easy dash scrub).
const strokes: { d: string; cls?: string }[] = [
{ d: 'M 60 40 H 900 A 16 16 0 0 1 916 56 V 484 A 16 16 0 0 1 900 500 H 60 A 16 16 0 0 1 44 484 V 56 A 16 16 0 0 1 60 40 Z' }, // window
{ d: 'M 44 88 H 916' }, // top bar divider
{ d: 'M 76 64 a 8 8 0 1 0 0.01 0' },
{ d: 'M 104 64 a 8 8 0 1 0 0.01 0' },
{ d: 'M 132 64 a 8 8 0 1 0 0.01 0' },
{ d: 'M 180 56 H 780 A 8 8 0 0 1 788 64 A 8 8 0 0 1 780 72 H 180 A 8 8 0 0 1 172 64 A 8 8 0 0 1 180 56 Z' }, // url bar
{ d: 'M 92 150 H 420' }, // hero line 1
{ d: 'M 92 186 H 560' }, // hero line 2 (big)
{ d: 'M 92 222 H 340' }, // hero line 3
{ d: 'M 92 262 H 208 A 10 10 0 0 1 218 272 V 286 A 10 10 0 0 1 208 296 H 92 A 10 10 0 0 1 82 286 V 272 A 10 10 0 0 1 92 262 Z', cls: 'accent' }, // CTA
{ d: 'M 92 340 H 340 V 460 H 92 Z' }, // card 1
{ d: 'M 366 340 H 614 V 460 H 366 Z' }, // card 2
{ d: 'M 640 340 H 888 V 460 H 640 Z' }, // card 3
{ d: 'M 640 140 a 70 70 0 1 0 0.01 0', cls: 'accent' }, // planet in hero
{ d: 'M 610 210 q 100 -60 200 -12', cls: 'accent' }, // planet ring
];
---
<section
class="bp"
data-blueprint
data-captions={JSON.stringify(captions.map((c) => c[locale]))}
>
<div class="stage">
<header class="stage-head">
<Eyebrow>{t('scene.blueprint.eyebrow')}</Eyebrow>
<h2>{t('scene.blueprint.title')}</h2>
</header>
<svg viewBox="0 0 960 540" class="draw" role="img" aria-label={t('scene.blueprint.title')}>
<!-- Blueprint grid -->
<g class="grid" aria-hidden="true">
{Array.from({ length: 12 }, (_, i) => <line x1={44 + i * 80} y1="40" x2={44 + i * 80} y2="500" />)}
{Array.from({ length: 7 }, (_, i) => <line x1="44" y1={40 + i * 78} x2="916" y2={40 + i * 78} />)}
</g>
{strokes.map((s, i) => (
<path d={s.d} pathLength="1" class:list={['stroke', s.cls]} data-draw={i} />
))}
<!-- Lit state: soft fills that fade in at the end -->
<g class="lit" data-lit aria-hidden="true">
<rect x="82" y="262" width="136" height="34" rx="10" />
<circle cx="640" cy="210" r="70" />
<rect x="92" y="340" width="248" height="120" class="dim" />
<rect x="366" y="340" width="248" height="120" class="dim" />
<rect x="640" y="340" width="248" height="120" class="dim" />
</g>
<text x="480" y="70" text-anchor="middle" class="url" data-url>louis-potevin.dev</text>
</svg>
<p class="caption" data-caption aria-live="polite">{captions[0]![locale]}</p>
<p class="hint" aria-hidden="true">{t('scene.hint')}</p>
</div>
</section>
<script>
import { scrub, window01 } from '../../lib/scrub';
function init() {
const root = document.querySelector<HTMLElement>('[data-blueprint]');
if (!root) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
root.classList.add('static');
return;
}
root.classList.add('is-ready');
const paths = Array.from(root.querySelectorAll<SVGPathElement>('[data-draw]'));
const lit = root.querySelector<SVGGElement>('[data-lit]')!;
const url = root.querySelector<SVGTextElement>('[data-url]')!;
const captionEl = root.querySelector<HTMLElement>('[data-caption]')!;
const captions: string[] = JSON.parse(root.dataset.captions ?? '[]');
scrub(root, (p) => {
// Strokes draw over the first 68%, staggered.
paths.forEach((el, i) => {
const a = 0.03 + (i / paths.length) * 0.6;
el.style.strokeDashoffset = String(1 - window01(p, a, a + 0.1));
});
// Then the interface lights up.
const glow = window01(p, 0.7, 0.92);
lit.style.opacity = String(glow * 0.9);
url.style.opacity = String(window01(p, 0.32, 0.42));
root.style.setProperty('--bp-glow', String(glow));
const idx = p < 0.34 ? 0 : p < 0.7 ? 1 : 2;
if (captionEl.dataset.idx !== String(idx)) {
captionEl.dataset.idx = String(idx);
captionEl.style.opacity = '0';
window.setTimeout(() => {
captionEl.textContent = captions[idx]!;
captionEl.style.opacity = '1';
}, 180);
}
});
}
init();
</script>
<style lang="scss">
@use '../../styles/type' as type;
.bp {
margin-block: clamp(40px, 8vh, 96px);
&.is-ready {
height: 280vh;
}
}
.stage {
display: grid;
gap: var(--nds-spacing-lg);
justify-items: center;
padding-block: var(--nds-spacing-xl);
.is-ready & {
position: sticky;
top: 0;
height: 100svh;
align-content: center;
}
}
.stage-head {
display: grid;
gap: var(--nds-spacing-xs);
justify-items: center;
text-align: center;
h2 {
@include type.text-3xl;
color: var(--nds-text);
}
}
.draw {
width: min(880px, 100%);
height: auto;
overflow: visible;
filter: drop-shadow(0 0 calc(var(--bp-glow, 0) * 14px) color-mix(in srgb, var(--nds-accent) 40%, transparent));
}
.grid line {
stroke: color-mix(in srgb, var(--nds-primary) 12%, transparent);
stroke-width: 0.5;
}
.stroke {
fill: none;
stroke: var(--nds-primary);
stroke-width: 1.6;
stroke-dasharray: 1;
stroke-dashoffset: 1;
stroke-linecap: round;
&.accent {
stroke: var(--nds-accent);
}
}
.lit {
opacity: 0;
rect,
circle {
fill: color-mix(in srgb, var(--nds-accent) 30%, transparent);
}
.dim {
fill: color-mix(in srgb, var(--nds-primary) 14%, transparent);
}
}
.url {
@include type.text-label;
fill: var(--nds-neutral);
font-family: var(--nds-font-mono);
font-size: 13px;
opacity: 0;
}
.caption {
@include type.text-base;
min-height: 3em;
max-width: 60ch;
text-align: center;
color: var(--nds-neutral);
transition: opacity 0.18s ease;
}
.hint {
@include type.text-sm;
color: var(--nds-disabled);
.static & {
display: none;
}
}
/* Static fallback: fully drawn and lit. */
.bp:not(.is-ready) {
.stroke {
stroke-dashoffset: 0;
}
.lit {
opacity: 0.9;
}
.url {
opacity: 1;
}
}
</style>
+329
View File
@@ -0,0 +1,329 @@
---
/**
* Custom scene for `nova-infra`: the journey of a request.
*
* A pinned SVG network (Visitor → Cloudflare → Apache/VPS → services) whose
* edges and nodes are drawn by scroll. Once drawn, a packet keeps traveling
* the graph (time-based) so the diagram feels alive. Captions follow the
* scroll. Reduced motion / no JS: the fully drawn diagram, no pin.
*/
import Eyebrow from '../Eyebrow.astro';
import { getLocaleFromUrl, useTranslations } from '../../i18n';
import type { Localized } from '../../data/projects';
const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
const captions: Localized[] = [
{
fr: 'Une requête arrive : DNS et TLS passent par Cloudflare, qui filtre et met en cache.',
en: 'A request comes in: DNS and TLS go through Cloudflare, which filters and caches.',
},
{
fr: "Apache, en reverse proxy sur le VPS OVH, route chaque domaine vers le bon service.",
en: 'Apache, reverse-proxying on the OVH VPS, routes each domain to the right service.',
},
{
fr: 'Gitea, Vaultwarden, le runner CI et les sites tournent côte à côte - auto-hébergés, sauvegardés, monitorés.',
en: 'Gitea, Vaultwarden, the CI runner and the sites run side by side - self-hosted, backed up, monitored.',
},
];
interface Node {
id: string;
label: string;
sub?: string;
x: number;
y: number;
w: number;
}
const nodes: Node[] = [
{ id: 'visitor', label: locale === 'fr' ? 'Visiteur' : 'Visitor', x: 40, y: 236, w: 130 },
{ id: 'cf', label: 'Cloudflare', sub: 'DNS · TLS · cache', x: 250, y: 236, w: 160 },
{ id: 'apache', label: 'Apache', sub: 'reverse proxy', x: 490, y: 236, w: 160 },
{ id: 'gitea', label: 'Gitea', sub: 'git.novaprojects.dev', x: 750, y: 90, w: 180 },
{ id: 'vault', label: 'Vaultwarden', sub: locale === 'fr' ? 'mots de passe' : 'passwords', x: 750, y: 236, w: 180 },
{ id: 'sites', label: 'Sites', sub: 'louis-potevin.dev', x: 750, y: 382, w: 180 },
];
// Edges as SVG path data (right side of source → left side of target).
const edges: { id: string; d: string }[] = [
{ id: 'e-visitor-cf', d: 'M 170 264 C 210 264 210 264 250 264' },
{ id: 'e-cf-apache', d: 'M 410 264 C 450 264 450 264 490 264' },
{ id: 'e-apache-gitea', d: 'M 650 264 C 710 264 690 118 750 118' },
{ id: 'e-apache-vault', d: 'M 650 264 C 690 264 710 264 750 264' },
{ id: 'e-apache-sites', d: 'M 650 264 C 710 264 690 410 750 410' },
];
---
<section class="infra" data-infra data-captions={JSON.stringify(captions.map((c) => c[locale]))}>
<div class="stage">
<header class="stage-head">
<Eyebrow>{t('scene.infra.eyebrow')}</Eyebrow>
<h2>{t('scene.infra.title')}</h2>
</header>
<svg viewBox="0 0 960 500" class="net" role="img" aria-label={t('scene.infra.title')}>
<!-- VPS enclosure -->
<g data-node="vps">
<rect x="460" y="40" width="486" height="430" rx="16" class="vps-box" />
<text x="482" y="72" class="vps-label">VPS OVH · Debian</text>
</g>
{edges.map((e) => <path id={e.id} d={e.d} pathLength="1" class="edge" data-edge />)}
{nodes.map((n) => (
<g data-node={n.id} class="node">
<rect x={n.x} y={n.y} width={n.w} height="56" rx="10" />
<text x={n.x + 16} y={n.y + (n.sub ? 24 : 33)} class="node-label">{n.label}</text>
{n.sub && <text x={n.x + 16} y={n.y + 42} class="node-sub">{n.sub}</text>}
</g>
))}
<circle class="packet" data-packet r="5" cx="-20" cy="-20" />
<circle class="packet packet--trail" data-packet-trail r="9" cx="-20" cy="-20" />
</svg>
<p class="caption" data-caption aria-live="polite">{captions[0]![locale]}</p>
<p class="hint" aria-hidden="true">{t('scene.hint')}</p>
</div>
</section>
<script>
import { scrub, window01 } from '../../lib/scrub';
function init() {
const root = document.querySelector<HTMLElement>('[data-infra]');
if (!root) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) {
root.classList.add('static');
return;
}
root.classList.add('is-ready');
const edges = Array.from(root.querySelectorAll<SVGPathElement>('[data-edge]'));
const nodes = Array.from(root.querySelectorAll<SVGGElement>('[data-node]'));
const packet = root.querySelector<SVGCircleElement>('[data-packet]')!;
const trail = root.querySelector<SVGCircleElement>('[data-packet-trail]')!;
const captionEl = root.querySelector<HTMLElement>('[data-caption]')!;
const captions: string[] = JSON.parse(root.dataset.captions ?? '[]');
// Draw order: vps box → visitor → e1 → cf → e2 → apache → branches.
const drawOrder: (SVGElement | undefined)[] = [
nodes.find((n) => n.dataset.node === 'vps'),
nodes.find((n) => n.dataset.node === 'visitor'),
edges[0],
nodes.find((n) => n.dataset.node === 'cf'),
edges[1],
nodes.find((n) => n.dataset.node === 'apache'),
edges[2],
nodes.find((n) => n.dataset.node === 'gitea'),
edges[3],
nodes.find((n) => n.dataset.node === 'vault'),
edges[4],
nodes.find((n) => n.dataset.node === 'sites'),
];
let progress = 0;
scrub(root, (p) => {
progress = p;
// Sequential reveal across the first 70% of the scene.
drawOrder.forEach((el, i) => {
if (!el) return;
const a = 0.05 + (i / drawOrder.length) * 0.62;
const v = window01(p, a, a + 0.08);
if (el.hasAttribute('data-edge')) {
(el as SVGPathElement).style.strokeDashoffset = String(1 - v);
} else {
el.style.opacity = String(v);
el.style.transform = `translateY(${(1 - v) * 14}px)`;
}
});
// Captions in thirds.
const idx = p < 0.34 ? 0 : p < 0.68 ? 1 : 2;
if (captionEl.dataset.idx !== String(idx)) {
captionEl.dataset.idx = String(idx);
captionEl.style.opacity = '0';
window.setTimeout(() => {
captionEl.textContent = captions[idx]!;
captionEl.style.opacity = '1';
}, 180);
}
});
// Live packet: visitor → cf → apache → (cycling branch), time-based.
const legs = [edges[0]!, edges[1]!];
const branches = [edges[2]!, edges[3]!, edges[4]!];
const lens = [...legs, ...branches].map((e) => e.getTotalLength());
let tAcc = 0;
let lastT = performance.now();
let branch = 0;
const loop = (now: number) => {
const dt = Math.min((now - lastT) / 1000, 0.05);
lastT = now;
if (progress > 0.2) {
tAcc += dt * 0.55;
if (tAcc >= 1) {
tAcc = 0;
branch = (branch + 1) % branches.length;
}
// Route = leg0 (0-0.33) → leg1 (0.33-0.66) → branch (0.66-1).
let el: SVGPathElement;
let local: number;
if (tAcc < 1 / 3) {
el = legs[0]!;
local = tAcc * 3;
} else if (tAcc < 2 / 3) {
el = legs[1]!;
local = (tAcc - 1 / 3) * 3;
} else {
el = branches[branch]!;
local = (tAcc - 2 / 3) * 3;
}
const len = lens[[...legs, ...branches].indexOf(el)]!;
const pt = el.getPointAtLength(local * len);
packet.setAttribute('cx', String(pt.x));
packet.setAttribute('cy', String(pt.y));
trail.setAttribute('cx', String(pt.x));
trail.setAttribute('cy', String(pt.y));
packet.style.opacity = '1';
trail.style.opacity = '0.35';
} else {
packet.style.opacity = '0';
trail.style.opacity = '0';
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
init();
</script>
<style lang="scss">
@use '../../styles/type' as type;
.infra {
/* Pinned scene: tall section, sticky stage. */
margin-block: clamp(40px, 8vh, 96px);
&.is-ready {
height: 300vh;
}
}
.stage {
display: grid;
gap: var(--nds-spacing-lg);
justify-items: center;
padding-block: var(--nds-spacing-xl);
.is-ready & {
position: sticky;
top: 0;
height: 100svh;
align-content: center;
}
}
.stage-head {
display: grid;
gap: var(--nds-spacing-xs);
justify-items: center;
text-align: center;
h2 {
@include type.text-3xl;
color: var(--nds-text);
}
}
.net {
width: min(960px, 100%);
height: auto;
overflow: visible;
}
.vps-box {
fill: color-mix(in srgb, var(--nds-surface) 40%, transparent);
stroke: var(--nds-border);
stroke-dasharray: 6 6;
}
.vps-label {
@include type.text-label;
fill: var(--nds-disabled);
font-family: var(--nds-font-mono);
font-size: 13px;
}
.edge {
fill: none;
stroke: var(--nds-primary);
stroke-width: 1.5;
stroke-dasharray: 1;
stroke-dashoffset: 1;
opacity: 0.85;
}
.node {
opacity: 0;
rect {
fill: color-mix(in srgb, var(--nds-surface-2) 92%, transparent);
stroke: var(--nds-primary);
stroke-width: 1;
}
.node-label {
fill: var(--nds-text);
font-family: var(--nds-font-mono);
font-size: 15px;
}
.node-sub {
fill: var(--nds-neutral);
font-size: 11px;
font-family: var(--nds-font-sans);
}
}
g[data-node='vps'] {
opacity: 0;
}
.packet {
fill: var(--nds-accent);
opacity: 0;
filter: drop-shadow(0 0 6px var(--nds-accent));
}
.packet--trail {
fill: var(--nds-accent);
}
.caption {
@include type.text-base;
min-height: 3em;
max-width: 60ch;
text-align: center;
color: var(--nds-neutral);
transition: opacity 0.18s ease;
}
.hint {
@include type.text-sm;
color: var(--nds-disabled);
.static & {
display: none;
}
}
/* Reduced motion / no JS: everything drawn, no pin. */
.infra:not(.is-ready) {
.edge {
stroke-dashoffset: 0;
}
.node,
g[data-node='vps'] {
opacity: 1;
}
}
</style>
+287
View File
@@ -0,0 +1,287 @@
---
/**
* Custom scene for `file-organizer`: from chaos to order.
*
* File chips start scattered across the board (seeded pseudo-random, so the
* chaos is stable between visits); scrolling sweeps each one along a small
* arc into its sorted column - exactly what the tool does to a messy folder.
* Reduced motion / no JS: the sorted end state, no pin.
*/
import Eyebrow from '../Eyebrow.astro';
import { getLocaleFromUrl, useTranslations } from '../../i18n';
const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
type Cat = 'img' | 'doc' | 'audio' | 'code';
const columns: { cat: Cat; label: string }[] = [
{ cat: 'img', label: 'Images' },
{ cat: 'doc', label: locale === 'fr' ? 'Documents' : 'Documents' },
{ cat: 'audio', label: 'Audio' },
{ cat: 'code', label: 'Code' },
];
const files: { name: string; cat: Cat }[] = [
{ name: 'IMG_2041.jpg', cat: 'img' },
{ name: 'screenshot.png', cat: 'img' },
{ name: 'logo.webp', cat: 'img' },
{ name: 'scan-04.tiff', cat: 'img' },
{ name: 'avatar.gif', cat: 'img' },
{ name: 'cv-2026.pdf', cat: 'doc' },
{ name: 'notes.md', cat: 'doc' },
{ name: 'facture_03.pdf', cat: 'doc' },
{ name: 'rapport.docx', cat: 'doc' },
{ name: 'todo.txt', cat: 'doc' },
{ name: 'demo-take2.wav', cat: 'audio' },
{ name: 'podcast.mp3', cat: 'audio' },
{ name: 'sample.flac', cat: 'audio' },
{ name: 'voice-memo.m4a', cat: 'audio' },
{ name: 'main.rs', cat: 'code' },
{ name: 'organizer.rs', cat: 'code' },
{ name: 'watch.rs', cat: 'code' },
{ name: 'Cargo.toml', cat: 'code' },
{ name: 'ui.rs', cat: 'code' },
{ name: 'fs_rules.rs', cat: 'code' },
];
---
<section class="sorter" data-sorter>
<div class="stage">
<header class="stage-head">
<Eyebrow>{t('scene.sorter.eyebrow')}</Eyebrow>
<h2>{t('scene.sorter.title')}</h2>
</header>
<div class="board" data-board>
{columns.map((col) => (
<div class="col" data-col={col.cat}>
<p class="col-head" data-col-head>{col.label}</p>
{files
.filter((f) => f.cat === col.cat)
.map((f) => (
<span class="chip" data-chip data-cat={f.cat}>
<i aria-hidden="true"></i>
{f.name}
</span>
))}
</div>
))}
</div>
<p class="hint" aria-hidden="true">{t('scene.hint')}</p>
</div>
</section>
<script>
import { scrub, clamp01 } from '../../lib/scrub';
/** Deterministic PRNG so the "chaos" looks identical on every visit. */
function mulberry32(seed: number) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let z = Math.imul(seed ^ (seed >>> 15), 1 | seed);
z = (z + Math.imul(z ^ (z >>> 7), 61 | z)) ^ z;
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
function init() {
const root = document.querySelector<HTMLElement>('[data-sorter]');
if (!root) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
root.classList.add('static');
return;
}
const board = root.querySelector<HTMLElement>('[data-board]')!;
const chips = Array.from(root.querySelectorAll<HTMLElement>('[data-chip]'));
const heads = Array.from(root.querySelectorAll<HTMLElement>('[data-col-head]'));
interface Item {
el: HTMLElement;
// Sorted (target) and scattered (start) positions, board-relative.
tx: number;
ty: number;
sx: number;
sy: number;
rot: number;
delay: number;
}
let items: Item[] = [];
const measure = () => {
// 1) Let the grid lay chips out sorted, measure targets.
root.classList.remove('is-ready');
board.style.height = '';
chips.forEach((c) => c.removeAttribute('style'));
const bRect = board.getBoundingClientRect();
board.style.height = `${bRect.height}px`;
const rng = mulberry32(20260702);
items = chips.map((el, i) => {
const r = el.getBoundingClientRect();
const tx = r.left - bRect.left;
const ty = r.top - bRect.top;
return {
el,
tx,
ty,
sx: rng() * (bRect.width - r.width),
sy: rng() * (bRect.height - r.height),
rot: (rng() - 0.5) * 34,
delay: i / chips.length,
};
});
// 2) Freeze the board height, absolutize the chips at their scatter.
root.classList.add('is-ready');
apply(lastP);
};
let lastP = 0;
const apply = (p: number) => {
lastP = p;
for (const it of items) {
// Each chip travels inside its own window, staggered by index.
const local = clamp01((p - 0.06 - it.delay * 0.5) / 0.36);
const e = local * local * (3 - 2 * local);
const x = it.sx + (it.tx - it.sx) * e;
const y = it.sy + (it.ty - it.sy) * e - Math.sin(e * Math.PI) * 42; // small arc
const rot = it.rot * (1 - e);
it.el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rot.toFixed(1)}deg)`;
it.el.classList.toggle('is-sorted', e > 0.98);
}
const done = clamp01((p - 0.86) / 0.12);
heads.forEach((h) => h.style.setProperty('--done', String(done)));
};
measure();
scrub(root, apply);
let resizeT = 0;
window.addEventListener('resize', () => {
window.clearTimeout(resizeT);
resizeT = window.setTimeout(measure, 150);
});
}
init();
</script>
<style lang="scss">
@use '../../styles/type' as type;
.sorter {
margin-block: clamp(40px, 8vh, 96px);
&.is-ready {
height: 280vh;
}
}
.stage {
display: grid;
gap: var(--nds-spacing-lg);
justify-items: center;
padding-block: var(--nds-spacing-xl);
.is-ready & {
position: sticky;
top: 0;
height: 100svh;
align-content: center;
}
}
.stage-head {
display: grid;
gap: var(--nds-spacing-xs);
justify-items: center;
text-align: center;
h2 {
@include type.text-3xl;
color: var(--nds-text);
}
}
.board {
position: relative;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--nds-spacing-md);
width: min(880px, 100%);
padding: var(--nds-spacing-lg);
border: 1px dashed var(--nds-border);
border-radius: var(--nds-radius-lg);
background: color-mix(in srgb, var(--nds-surface) 36%, transparent);
@media (max-width: 700px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.col {
display: grid;
gap: var(--nds-spacing-xs);
align-content: start;
}
.col-head {
@include type.text-label;
color: color-mix(in srgb, var(--nds-text) calc(55% + var(--done, 0) * 45%), transparent);
text-transform: uppercase;
letter-spacing: 0.1em;
padding-bottom: var(--nds-spacing-2xs);
border-bottom: 1px solid
color-mix(in srgb, var(--nds-accent) calc(var(--done, 0) * 100%), var(--nds-border));
transition: color 0.2s linear, border-color 0.2s linear;
}
.chip {
@include type.text-sm;
display: inline-flex;
align-items: center;
gap: var(--nds-spacing-2xs);
width: fit-content;
padding: 4px 10px;
border: 1px solid var(--nds-border);
border-radius: var(--nds-radius-full);
background: var(--nds-surface-2);
color: var(--nds-text);
font-family: var(--nds-font-mono);
white-space: nowrap;
i {
width: 7px;
height: 7px;
border-radius: 50%;
}
&[data-cat='img'] i { background: var(--nds-success-high); }
&[data-cat='doc'] i { background: var(--nds-info-high, var(--nds-primary)); }
&[data-cat='audio'] i { background: var(--nds-warning-high); }
&[data-cat='code'] i { background: var(--nds-accent); }
&.is-sorted {
border-color: color-mix(in srgb, var(--nds-accent) 55%, var(--nds-border));
}
}
/* Enhanced mode: chips leave the flow and are driven by transforms. */
.sorter.is-ready .chip {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.hint {
@include type.text-sm;
color: var(--nds-disabled);
.static & {
display: none;
}
}
</style>
@@ -0,0 +1,910 @@
---
/**
* NDS scene, "la forge" - three scroll-driven acts on one pinned stage:
*
* Act 1 - Tokens: raw design tokens (real CSS custom properties, shown as
* live swatches) scattered in space converge into a neat row.
* Act 2 - Assembly: eight REAL NDS components staged one by one in a 3D
* tunnel - each arrives from the depth, holds in focus with its
* spec panel (category, role, tokens, usage snippet), then flies
* past the camera.
* Act 3 - The system: the full library snaps into a grid, with the
* package stats counting up and the install command typing out.
*
* Everything is CSS 3D + the shared scrub() helper - no WebGL needed here.
* Reduced motion / no JS: the acts render stacked and fully visible.
*/
import {
Avatar,
Badge,
Button,
Checkbox,
Pagination,
PaginationNumber,
Select,
SelectOption,
TextField,
Toggle,
} from '@unkn0wndo3s/nova-design-system';
import Eyebrow from '../Eyebrow.astro';
import { getLocaleFromUrl, useTranslations } from '../../i18n';
import { NDS_COMPONENTS, NDS_COMPONENT_COUNT, NDS_PACKAGE, getNdsVersion } from '../../lib/ndsMeta';
import type { Localized } from '../../data/projects';
// Live metadata: component list parsed from the installed package,
// version fetched from the npm registry at build time.
const ndsVersion = await getNdsVersion();
const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
interface Stage {
id: string;
component: string;
category: Localized;
title: Localized;
desc: Localized;
tokens: string[];
usage: string;
}
const stages: Stage[] = [
{
id: 'button',
component: '<Button />',
category: { fr: 'Actions', en: 'Actions' },
title: { fr: 'Quatre intentions, une API.', en: 'Four intents, one API.' },
desc: {
fr: "primary, secondary, ghost, danger - trois tailles, états focus/disabled gérés, rendu <a> ou <button> selon la prop href.",
en: 'primary, secondary, ghost, danger - three sizes, focus/disabled states handled, renders <a> or <button> depending on the href prop.',
},
tokens: ['--nds-primary', '--nds-on-primary', '--nds-radius-md'],
usage: '<Button type="primary" size="md">Déployer</Button>',
},
{
id: 'badge',
component: '<Badge />',
category: { fr: 'Feedback', en: 'Feedback' },
title: { fr: 'Un statut lisible en un regard.', en: 'A status readable at a glance.' },
desc: {
fr: 'Cinq types sémantiques × deux variantes (soft/solid). Les couleurs viennent des paires de tokens statut, jamais de valeurs en dur.',
en: 'Five semantic types × two variants (soft/solid). Colors come from the status token pairs, never hard-coded values.',
},
tokens: ['--nds-success-high', '--nds-warning-high', '--nds-error-high'],
usage: '<Badge type="success">En production</Badge>',
},
{
id: 'toggle',
component: '<Toggle />',
category: { fr: 'Formulaires', en: 'Forms' },
title: { fr: 'Un vrai Web Component.', en: 'A real Web Component.' },
desc: {
fr: "Encapsulé en custom element : état interne, événements, accessibilité clavier - utilisable même hors d'Astro.",
en: 'Wrapped as a custom element: internal state, events, keyboard accessibility - usable even outside Astro.',
},
tokens: ['--nds-primary', '--nds-surface-2', '--nds-radius-full'],
usage: '<Toggle id="notifications" data-checked="true" />',
},
{
id: 'textfield',
component: '<TextField />',
category: { fr: 'Formulaires', en: 'Forms' },
title: { fr: 'Label, aide, compteur, erreurs.', en: 'Label, help, counter, errors.' },
desc: {
fr: 'Compteur de caractères optionnel, messages liés en aria-describedby, styles de focus et derreur issus des tokens.',
en: 'Optional character counter, messages linked via aria-describedby, focus and error styles driven by tokens.',
},
tokens: ['--nds-border', '--nds-text', '--nds-error-high'],
usage: '<TextField id="email" label="E-mail" type="email" />',
},
{
id: 'select',
component: '<Select />',
category: { fr: 'Formulaires', en: 'Forms' },
title: { fr: 'Un select stylable, enfin.', en: 'A styleable select, at last.' },
desc: {
fr: 'Listbox custom : navigation clavier, fermeture extérieure, valeur synchronisée dans un input caché pour les formulaires natifs.',
en: 'Custom listbox: keyboard navigation, outside-close, value mirrored into a hidden input for native forms.',
},
tokens: ['--nds-surface', '--nds-primary', '--nds-shadow-md'],
usage: '<Select default="TypeScript">…</Select>',
},
{
id: 'checkbox',
component: '<Checkbox />',
category: { fr: 'Formulaires', en: 'Forms' },
title: { fr: 'Coche native, peau custom.', en: 'Native check, custom skin.' },
desc: {
fr: "L'input natif reste dans le DOM (accessibilité et formulaires gratuits) ; seul le visuel est redessiné avec les tokens.",
en: 'The native input stays in the DOM (free accessibility and forms); only the visual layer is redrawn with tokens.',
},
tokens: ['--nds-primary', '--nds-border', '--nds-radius-sm'],
usage: '<Checkbox id="cgu" checked />',
},
{
id: 'avatar',
component: '<Avatar />',
category: { fr: 'Data display', en: 'Data display' },
title: { fr: 'Photo, sinon initiales.', en: 'Photo, else initials.' },
desc: {
fr: "Fallback automatique : si l'image manque ou casse, les initiales prennent le relais - le layout ne bouge pas d'un pixel.",
en: 'Automatic fallback: if the image is missing or breaks, initials take over - the layout never shifts a pixel.',
},
tokens: ['--nds-surface-2', '--nds-text', '--nds-radius-full'],
usage: '<Avatar type="initials" name="Louis Potevin" />',
},
{
id: 'pagination',
component: '<Pagination />',
category: { fr: 'Navigation', en: 'Navigation' },
title: { fr: 'Composable, pas configurable.', en: 'Composable, not configurable.' },
desc: {
fr: 'Pas de prop géante : des sous-composants (PaginationNumber…) à assembler - le pattern de composition utilisé partout dans NDS.',
en: 'No giant prop bag: sub-components (PaginationNumber…) you compose - the composition pattern used across NDS.',
},
tokens: ['--nds-primary', '--nds-neutral', '--nds-radius-md'],
usage: '<Pagination><PaginationNumber>1</PaginationNumber>…</Pagination>',
},
];
// Act 1: the raw material - real tokens rendered as live swatches.
const tokenField: { name: string; kind: 'color' | 'radius' | 'space' | 'type' }[] = [
{ name: '--nds-primary', kind: 'color' },
{ name: '--nds-accent', kind: 'color' },
{ name: '--nds-success-high', kind: 'color' },
{ name: '--nds-warning-high', kind: 'color' },
{ name: '--nds-error-high', kind: 'color' },
{ name: '--nds-surface', kind: 'color' },
{ name: '--nds-text', kind: 'color' },
{ name: '--nds-neutral', kind: 'color' },
{ name: '--nds-radius-md', kind: 'radius' },
{ name: '--nds-radius-full', kind: 'radius' },
{ name: '--nds-spacing-md', kind: 'space' },
{ name: '--nds-spacing-xl', kind: 'space' },
{ name: '--nds-font-mono', kind: 'type' },
{ name: '--nds-font-sans', kind: 'type' },
];
// Deterministic scatter (build-time constants, so SSR output is stable).
const scatter = [
[-38, -26], [32, -34], [-12, 30], [44, 18], [-46, 6], [8, -18],
[24, 34], [-28, -8], [40, -6], [-6, -38], [16, 12], [-40, 28],
[0, 22], [36, 2],
];
const library = NDS_COMPONENTS;
const actLabels: Record<string, Localized> = {
a1: { fr: 'Acte I - Tout part des tokens.', en: 'Act I - Everything starts with tokens.' },
a2: { fr: 'Acte II - Les composants, forgés un à un.', en: 'Act II - The components, forged one by one.' },
a3: { fr: 'Acte III - Le système.', en: 'Act III - The system.' },
};
---
<section
class="forge"
data-forge
style={`--stages: ${stages.length}`}
data-a2={actLabels.a2![locale]}
data-a3={actLabels.a3![locale]}
aria-label={t('nds.showcase.title')}
>
<div class="stage-pin">
<header class="head">
<Eyebrow>{t('nds.showcase.eyebrow')}</Eyebrow>
<h2 class="section-title">{t('nds.showcase.title')}</h2>
<p class="act-label" data-act-label>{actLabels.a1![locale]}</p>
</header>
<!-- ACT I · token field -->
<div class="act act--tokens" data-act1>
{tokenField.map((tk, i) => (
<span
class:list={['tk', `tk--${tk.kind}`]}
style={`--dx:${scatter[i]![0]}vmin; --dy:${scatter[i]![1]}vmin; --i:${i};`}
>
{tk.kind === 'color' && <i style={`background: var(${tk.name})`}></i>}
{tk.kind === 'radius' && <i style={`border-radius: var(${tk.name})`}></i>}
{tk.kind === 'space' && <i style={`width: var(${tk.name})`}></i>}
{tk.kind === 'type' && <i>Aa</i>}
<code>{tk.name}</code>
</span>
))}
</div>
<!-- ACT II · the tunnel of components -->
<div class="act act--tunnel" data-act2>
{stages.map((s, i) => (
<article class="slide" data-slide={i} aria-labelledby={`forge-${s.id}`}>
<p class="slide__ghost" aria-hidden="true">{s.component}</p>
<div class="slide__grid">
<div class="slide__demo">
<div class="demo-halo" aria-hidden="true"></div>
{s.id === 'button' && (
<div class="demo-row">
<Button size="md">Primary</Button>
<Button type="secondary" size="md">Secondary</Button>
<Button type="ghost" size="md">Ghost</Button>
<Button type="danger" size="md">Danger</Button>
</div>
)}
{s.id === 'badge' && (
<div class="demo-row">
<Badge type="primary">Primary</Badge>
<Badge type="success">Success</Badge>
<Badge type="warning" variant="solid">Warning</Badge>
<Badge type="error">Error</Badge>
<Badge type="info" variant="solid">Info</Badge>
</div>
)}
{s.id === 'toggle' && (
<div class="demo-row demo-row--big">
<Toggle id="forge-toggle" data-checked="true" />
</div>
)}
{s.id === 'textfield' && (
<div class="demo-col">
<TextField id="forge-tf" label="E-mail" type="email" placeholder="vous@exemple.dev" />
</div>
)}
{s.id === 'select' && (
<div class="demo-col">
<Select default="TypeScript">
<SelectOption value="TypeScript">TypeScript</SelectOption>
<SelectOption value="Rust">Rust</SelectOption>
<SelectOption value="Python">Python</SelectOption>
</Select>
</div>
)}
{s.id === 'checkbox' && (
<div class="demo-row">
<Checkbox id="forge-cb" checked />
<Checkbox id="forge-cb2" />
</div>
)}
{s.id === 'avatar' && (
<div class="demo-row">
<Avatar type="initials" name="Louis Potevin" />
<Avatar type="initials" name="Nova Design" />
</div>
)}
{s.id === 'pagination' && (
<div class="demo-row">
<Pagination>
<PaginationNumber>1</PaginationNumber>
<PaginationNumber>2</PaginationNumber>
<PaginationNumber>3</PaginationNumber>
</Pagination>
</div>
)}
</div>
<div class="slide__spec">
<p class="spec-cat">{s.category[locale]}</p>
<h3 id={`forge-${s.id}`}>{s.title[locale]}</h3>
<p class="spec-desc">{s.desc[locale]}</p>
<ul class="spec-tokens">
{s.tokens.map((tok) => (
<li>
<i style={`background: var(${tok}, transparent)`}></i>
<code>{tok}</code>
</li>
))}
</ul>
<div class="spec-code" aria-label="Usage">
<div class="spec-code__bar"><span></span><span></span><span></span><em>usage.astro</em></div>
<pre><code>{s.usage}</code></pre>
</div>
</div>
</div>
</article>
))}
<p class="counter" aria-hidden="true">
<span data-counter>01</span> / {String(stages.length).padStart(2, '0')}
</p>
<ol class="ticks" aria-hidden="true">
{stages.map((_, i) => <li data-tick={i}></li>)}
</ol>
</div>
<!-- ACT III · the system -->
<div class="act act--finale" data-act3>
<ul class="lib">
{library.map((name, i) => (
<li style={`--i:${i}`}><code>{name}</code></li>
))}
</ul>
<div class="stats">
<p><strong data-count-to={NDS_COMPONENT_COUNT}>0</strong><span>{locale === 'fr' ? 'composants' : 'components'}</span></p>
<p><strong>v{ndsVersion}</strong><span>{locale === 'fr' ? 'dernière version npm' : 'latest npm version'}</span></p>
</div>
<p class="install"><code data-typed>{`npm install ${NDS_PACKAGE}`}</code></p>
</div>
<p class="hint" aria-hidden="true">{t('nds.showcase.hint')}</p>
</div>
</section>
<script>
import { scrub, clamp01, window01 } from '../../lib/scrub';
import { getSmooth } from '../../lib/smooth';
function init() {
const root = document.querySelector<HTMLElement>('[data-forge]');
if (!root) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
root.classList.add('static');
return;
}
root.classList.add('is-ready');
const actLabel = root.querySelector<HTMLElement>('[data-act-label]')!;
const labelTexts = [
actLabel.textContent ?? '',
root.dataset.a2 ?? '',
root.dataset.a3 ?? '',
];
const act1 = root.querySelector<HTMLElement>('[data-act1]')!;
const act2 = root.querySelector<HTMLElement>('[data-act2]')!;
const act3 = root.querySelector<HTMLElement>('[data-act3]')!;
const slides = Array.from(root.querySelectorAll<HTMLElement>('[data-slide]'));
const ticks = Array.from(root.querySelectorAll<HTMLElement>('[data-tick]'));
const counter = root.querySelector<HTMLElement>('[data-counter]')!;
const counts = Array.from(root.querySelectorAll<HTMLElement>('[data-count-to]'));
const typed = root.querySelector<HTMLElement>('[data-typed]')!;
const typedFull = typed.textContent ?? '';
const n = slides.length;
// Act windows on the section's 0..1 progress.
const A1 = [0.0, 0.14] as const;
const A2 = [0.14, 0.86] as const;
const A3 = [0.86, 1.0] as const;
let lastIdx = -1;
let lastLabel = -1;
let lastState = { p: 0, idx: 0, dwellT: 0.5, inTunnel: 0 };
scrub(root, (p) => {
// ---- Act I: scattered tokens converge, then hand over. ----
const gather = window01(p, 0.02, 0.1);
const a1out = 1 - window01(p, A1[1] - 0.03, A1[1] + 0.02);
act1.style.opacity = String(a1out);
act1.style.setProperty('--gather', String(gather));
act1.style.visibility = a1out > 0.01 ? 'visible' : 'hidden';
// ---- Act II: the tunnel. ----
const inTunnel = window01(p, A1[1] - 0.02, A1[1] + 0.03) * (1 - window01(p, A2[1] - 0.02, A2[1] + 0.02));
act2.style.opacity = String(inTunnel);
act2.style.visibility = inTunnel > 0.01 ? 'visible' : 'hidden';
const tp = clamp01((p - A2[0]) / (A2[1] - A2[0])); // 0..1 inside act II
const f0 = tp * n;
const idx = Math.min(n - 1, Math.floor(f0));
// Dwell: each slide travels in, HOLDS pin-sharp at the center for the
// middle of its window, then leaves - small scrolls don't blur it.
const dwellT = f0 - idx;
const f = idx + (0.5 * window01(dwellT, 0, 0.38) + 0.5 * window01(dwellT, 0.62, 1));
lastState = { p, idx, dwellT, inTunnel };
slides.forEach((el, i) => {
const d = f - i - 0.5; // 0 when slide i is centered
// depth: future slides far away (negative z), past ones fly at the camera
const z = -d * 780;
const op = clamp01(1 - Math.abs(d) * 1.7);
const blur = Math.min(14, Math.abs(d) * 16);
const rot = d * -7;
el.style.opacity = String(op);
el.style.visibility = op > 0.01 && inTunnel > 0.01 ? 'visible' : 'hidden';
el.style.transform = `translate(-50%, -50%) perspective(1100px) translateZ(${z.toFixed(0)}px) rotateY(${rot.toFixed(2)}deg)`;
el.style.filter = `blur(${blur.toFixed(1)}px)`;
el.classList.toggle('is-front', Math.abs(d) < 0.28);
});
if (idx !== lastIdx && inTunnel > 0.2) {
lastIdx = idx;
counter.textContent = String(idx + 1).padStart(2, '0');
ticks.forEach((tk, i) => tk.classList.toggle('is-on', i <= idx));
}
// ---- Act III: the library grid + stats. ----
const fin = window01(p, A3[0] - 0.01, A3[0] + 0.06);
act3.style.opacity = String(fin);
act3.style.visibility = fin > 0.01 ? 'visible' : 'hidden';
act3.style.setProperty('--fin', String(fin));
const count = window01(p, A3[0], A3[0] + 0.1);
counts.forEach((c) => {
c.textContent = String(Math.round(Number(c.dataset.countTo) * count));
});
const chars = Math.round(typedFull.length * window01(p, A3[0] + 0.02, A3[0] + 0.11));
typed.textContent = typedFull.slice(0, Math.max(0, chars)) + (chars < typedFull.length && fin > 0 ? '▌' : '');
// ---- Act label. ----
const which = p < A1[1] ? 0 : p < A2[1] ? 1 : 2;
if (which !== lastLabel) {
lastLabel = which;
actLabel.style.opacity = '0';
window.setTimeout(() => {
actLabel.textContent = labelTexts[which]!;
actLabel.style.opacity = '1';
}, 160);
}
});
// Idle snap: if scrolling pauses while a component is half in/out, glide
// to the position where it sits pin-sharp. Soft lock - any new scroll
// input takes over instantly.
let idle = 0;
let snapped = true;
let lastT = performance.now();
const snapLoop = (now: number) => {
const dt = Math.min((now - lastT) / 1000, 0.05);
lastT = now;
const lenis = getSmooth();
const v = lenis?.velocity ?? 0;
if (Math.abs(v) > 1.5) {
idle = 0;
snapped = false;
} else {
idle += dt;
}
if (!snapped && idle > 0.35 && lenis && lastState.inTunnel > 0.5) {
const off = Math.abs(lastState.dwellT - 0.5);
if (off > 0.03 && off < 0.47) {
snapped = true;
const rect = root.getBoundingClientRect();
const top = lenis.scroll + rect.top;
const total = rect.height - window.innerHeight;
const pTarget = A2[0] + ((lastState.idx + 0.5) / n) * (A2[1] - A2[0]);
lenis.scrollTo(top + pTarget * total, { duration: 0.8 });
}
}
requestAnimationFrame(snapLoop);
};
requestAnimationFrame(snapLoop);
}
init();
</script>
<style lang="scss">
@use '../../styles/type' as type;
.forge {
margin-block: clamp(48px, 9vh, 110px);
&.is-ready {
/* runway: intro + one beat per stage + finale */
height: calc(140vh + var(--stages) * 85vh + 140vh);
}
}
.stage-pin {
position: relative;
display: grid;
justify-items: center;
gap: var(--nds-spacing-lg);
padding-block: var(--nds-spacing-xl);
.is-ready & {
position: sticky;
top: 0;
height: 100svh;
overflow: hidden;
grid-template-rows: auto 1fr auto;
}
}
.head {
display: grid;
justify-items: center;
gap: var(--nds-spacing-xs);
text-align: center;
z-index: 4;
}
.act-label {
@include type.text-label;
color: var(--nds-accent);
letter-spacing: 0.12em;
text-transform: uppercase;
transition: opacity 0.16s ease;
min-height: 1.4em;
}
/* ------------------------------- ACT I ---------------------------------- */
.act--tokens {
position: relative;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-content: center;
gap: var(--nds-spacing-sm);
max-width: 760px;
.is-ready & {
position: absolute;
inset: 0;
z-index: 2;
max-width: none;
align-content: center;
padding-inline: 10vw;
}
}
.tk {
@include type.text-sm;
display: inline-flex;
align-items: center;
gap: var(--nds-spacing-2xs);
padding: 5px 12px;
border: 1px solid var(--nds-border);
border-radius: var(--nds-radius-full);
background: color-mix(in srgb, var(--nds-surface) 85%, transparent);
font-family: var(--nds-font-mono);
color: var(--nds-neutral);
i {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 3px;
font-style: normal;
font-size: 10px;
line-height: 12px;
color: var(--nds-text);
}
&.tk--space i {
height: 4px;
background: var(--nds-primary);
border-radius: 2px;
}
&.tk--radius i {
border: 1.5px solid var(--nds-accent);
}
/* scattered → gathered, driven by --gather */
.is-ready & {
transform: translate(
calc(var(--dx) * (1 - var(--gather, 0))),
calc(var(--dy) * (1 - var(--gather, 0)))
)
rotate(calc((1 - var(--gather, 0)) * (var(--i) - 7) * 4deg));
transition: none;
}
}
/* ------------------------------- ACT II --------------------------------- */
.act--tunnel {
position: relative;
width: 100%;
.is-ready & {
position: absolute;
inset: 0;
z-index: 3;
visibility: hidden;
opacity: 0;
}
}
.slide {
/* static: stacked list */
position: relative;
margin-inline: auto;
margin-block: var(--nds-spacing-2xl);
width: min(980px, 92vw);
.is-ready & {
position: absolute;
top: 50%;
left: 50%;
margin: 0;
transform: translate(-50%, -50%);
visibility: hidden;
opacity: 0;
will-change: transform, opacity, filter;
pointer-events: none;
&.is-front {
pointer-events: auto;
}
}
}
.slide__ghost {
@include type.text-5xl;
position: absolute;
inset-inline: 0;
top: -0.9em;
text-align: center;
font-family: var(--nds-font-mono);
font-size: clamp(3rem, 11vw, 8rem);
color: transparent;
-webkit-text-stroke: 1px color-mix(in srgb, var(--nds-primary) 26%, transparent);
letter-spacing: -0.02em;
pointer-events: none;
white-space: nowrap;
}
.slide__grid {
position: relative;
display: grid;
grid-template-columns: 1.05fr 1fr;
gap: clamp(var(--nds-spacing-lg), 4vw, var(--nds-spacing-2xl));
align-items: center;
padding: clamp(var(--nds-spacing-lg), 3.5vw, var(--nds-spacing-2xl));
border: 1px solid color-mix(in srgb, var(--nds-border) 85%, transparent);
border-radius: var(--nds-radius-lg);
background: color-mix(in srgb, var(--nds-background) 72%, transparent);
backdrop-filter: blur(8px);
box-shadow: var(--nds-shadow-md);
@media (max-width: 840px) {
grid-template-columns: 1fr;
}
}
.slide__demo {
position: relative;
display: grid;
place-items: center;
min-height: 180px;
border: 1px dashed color-mix(in srgb, var(--nds-primary) 35%, transparent);
border-radius: var(--nds-radius-lg);
padding: var(--nds-spacing-lg);
}
.demo-halo {
position: absolute;
inset: 20%;
background: radial-gradient(closest-side, color-mix(in srgb, var(--nds-primary) 18%, transparent), transparent);
pointer-events: none;
}
.demo-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: var(--nds-spacing-sm);
}
.demo-row--big {
scale: 1.5;
}
.demo-col {
display: grid;
gap: var(--nds-spacing-sm);
width: min(300px, 100%);
}
.slide__spec {
display: grid;
gap: var(--nds-spacing-sm);
justify-items: start;
h3 {
@include type.text-2xl;
color: var(--nds-text);
}
}
.spec-cat {
@include type.text-label;
color: var(--nds-accent);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.spec-desc {
@include type.text-sm;
color: var(--nds-neutral);
max-width: 46ch;
}
.spec-tokens {
display: flex;
flex-wrap: wrap;
gap: var(--nds-spacing-2xs);
margin: 0;
padding: 0;
list-style: none;
li {
@include type.text-sm;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border: 1px solid var(--nds-border);
border-radius: var(--nds-radius-full);
font-family: var(--nds-font-mono);
font-size: 0.72rem;
color: var(--nds-neutral);
i {
width: 9px;
height: 9px;
border-radius: 50%;
}
}
}
.spec-code {
width: 100%;
border: 1px solid var(--nds-border);
border-radius: var(--nds-radius-md);
overflow: hidden;
background: color-mix(in srgb, var(--nds-surface) 92%, transparent);
&__bar {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-bottom: 1px solid var(--nds-border);
span {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--nds-border);
}
span:first-child { background: var(--nds-error-high); }
span:nth-child(2) { background: var(--nds-warning-high); }
span:nth-child(3) { background: var(--nds-success-high); }
em {
@include type.text-sm;
margin-left: auto;
font-style: normal;
font-size: 0.7rem;
color: var(--nds-disabled);
font-family: var(--nds-font-mono);
}
}
pre {
margin: 0;
padding: var(--nds-spacing-sm) var(--nds-spacing-md);
overflow-x: auto;
}
code {
font-family: var(--nds-font-mono);
font-size: 0.8rem;
color: var(--nds-primary);
white-space: pre;
}
}
.counter {
@include type.text-label;
position: absolute;
right: clamp(16px, 4vw, 48px);
bottom: clamp(14px, 3vh, 32px);
margin: 0;
font-family: var(--nds-font-mono);
color: var(--nds-neutral);
display: none;
span {
@include type.text-3xl;
color: var(--nds-accent);
}
.is-ready & { display: block; }
}
.ticks {
position: absolute;
left: clamp(16px, 4vw, 48px);
bottom: clamp(18px, 3vh, 36px);
display: none;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
li {
width: 18px;
height: 3px;
border-radius: 2px;
background: var(--nds-border);
transition: background-color 0.2s ease;
&.is-on { background: var(--nds-accent); }
}
.is-ready & { display: flex; }
}
/* ------------------------------- ACT III -------------------------------- */
.act--finale {
display: grid;
justify-items: center;
gap: var(--nds-spacing-lg);
width: min(880px, 94vw);
.is-ready & {
position: absolute;
inset: 0;
z-index: 2;
width: auto;
align-content: center;
justify-items: center;
padding-inline: 8vw;
visibility: hidden;
opacity: 0;
}
}
.lib {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--nds-spacing-2xs);
margin: 0;
padding: 0;
list-style: none;
max-width: 760px;
li {
padding: 4px 12px;
border: 1px solid var(--nds-border);
border-radius: var(--nds-radius-full);
background: color-mix(in srgb, var(--nds-surface) 80%, transparent);
code {
font-family: var(--nds-font-mono);
font-size: 0.75rem;
color: var(--nds-neutral);
}
.is-ready & {
opacity: calc(var(--fin, 0) * 1);
transform: translateY(calc((1 - var(--fin, 0)) * (12px + var(--i) * 1.2px)));
}
}
}
.stats {
display: flex;
flex-wrap: wrap;
gap: clamp(var(--nds-spacing-lg), 6vw, var(--nds-spacing-2xl));
p {
display: grid;
justify-items: center;
gap: 2px;
}
strong {
@include type.text-4xl;
font-family: var(--nds-font-mono);
color: var(--nds-text);
font-variant-numeric: tabular-nums;
}
span {
@include type.text-label;
color: var(--nds-neutral);
text-transform: uppercase;
letter-spacing: 0.12em;
}
}
.install {
code {
@include type.text-sm;
display: inline-block;
padding: var(--nds-spacing-xs) var(--nds-spacing-lg);
border: 1px solid color-mix(in srgb, var(--nds-accent) 50%, var(--nds-border));
border-radius: var(--nds-radius-md);
background: color-mix(in srgb, var(--nds-surface) 92%, transparent);
font-family: var(--nds-font-mono);
color: var(--nds-accent);
min-height: 1.5em;
}
}
.hint {
@include type.text-sm;
color: var(--nds-disabled);
z-index: 4;
.static &,
.forge:not(.is-ready) & {
display: none;
}
}
</style>
+203 -126
View File
@@ -1,179 +1,256 @@
// ───────────────────────────────────────────────────────────────────────────── import type { Locale } from '../i18n';
// Projects — real content. Edit freely.
// `icon` matches a simple-icons-astro component (using PascalCase elsewhere).
// ─────────────────────────────────────────────────────────────────────────────
export type ProjectStatus = 'live' | 'maintenu' | 'archive' | 'r&d'; /** A string localized in both site languages. */
export type Localized = Record<Locale, string>;
import { NDS_COMPONENT_COUNT } from '../lib/ndsMeta';
export type ProjectStatus = 'live' | 'maintained' | 'wip';
export interface ProjectLink { export interface ProjectLink {
label: string; label: string;
url: string; url: string;
icon?: string;
} }
export interface Project { export interface Project {
slug: string; slug: string;
designation: string; // catalog index, e.g. "01" name: string;
title: string;
tagline: string;
period: string;
role: string;
status: ProjectStatus; status: ProjectStatus;
featured: boolean; tagline: Localized;
summary: string; summary: Localized;
context: string[]; /** Long-form body, one paragraph per entry. */
contributions: string[]; body: Localized[];
stack: { icon: string; label: string }[]; highlights: Localized[];
stack: { name: string; icon: string }[];
links: ProjectLink[]; links: ProjectLink[];
featured: boolean;
} }
export const projects: Project[] = [ export const projects: Project[] = [
{ {
slug: 'nova-design-system', slug: 'nova-design-system',
designation: '01', name: 'Nova Design System',
title: 'Nova Design System', status: 'maintained',
tagline: 'Astro component library — tokens, accessibility, npm distribution.',
period: '2026 — ongoing',
role: 'Author · maintainer',
status: 'maintenu',
featured: true, featured: true,
summary: tagline: {
'The design system that powers this website and will power my other projects. 29 Astro components across six families, a complete set of tokens (color, typography, spacing, radius), and a light/dark theme managed via CSS variables.', fr: 'Bibliothèque de composants Astro - tokens, accessibilité, distribution npm.',
context: [ en: 'Astro component library - tokens, accessibility, npm distribution.',
'Designed for clean, technical interfaces: mono fonts for headings, sans-serif for body text.', },
'Distributed under the npm scope @unkn0wndo3s, automatically published via a CI pipeline.', summary: {
'License: free to use, resale of the design system as a standalone product is prohibited.', fr: `${NDS_COMPONENT_COUNT} composants Astro, un système complet de tokens (couleur, typographie, espacement, rayons) et un thème clair/sombre piloté par variables CSS. Publié sur npm, il propulse ce portfolio.`,
en: `A library of ${NDS_COMPONENT_COUNT} Astro components, a complete token system (color, typography, spacing, radius) and a light/dark theme driven by CSS variables. Published on npm, it powers this portfolio.`,
},
body: [
{
fr: "Nova Design System est né d'un besoin simple : arrêter de réécrire les mêmes boutons, champs et modales d'un projet à l'autre. Chaque composant est développé avec son interface TypeScript, ses états (hover, focus, disabled) et ses styles isolés en SCSS.",
en: 'Nova Design System started from a simple need: stop rewriting the same buttons, fields and modals from one project to the next. Every component ships with its TypeScript interface, its states (hover, focus, disabled) and isolated SCSS styles.',
},
{
fr: "La distribution se fait via npm (paquet public `@unkn0wndo3s/nova-design-system`), avec une CI Gitea Actions auto-hébergée qui vérifie, versionne et publie chaque release en 10 à 20 secondes. Le paquet livre ses sources `.astro`/`.scss` brutes, compilées côté consommateur.",
en: 'Distribution happens through npm (public package `@unkn0wndo3s/nova-design-system`), with a self-hosted Gitea Actions CI that checks, versions and publishes each release in 10 to 20 seconds. The package ships raw `.astro`/`.scss` sources, compiled on the consumer side.',
},
{
fr: "La suite : adapter progressivement NDS à mes autres projets, y compris hors de l'écosystème Astro - un binding GPUI/Rust est prévu pour File Organizer.",
en: 'Next up: progressively adapting NDS to my other projects, including outside the Astro ecosystem - a GPUI/Rust binding is planned for File Organizer.',
},
], ],
contributions: [ highlights: [
'Architecture of tokens and components (Custom Elements, typed events, slots).', { fr: `${NDS_COMPONENT_COUNT} composants exportés sur npm`, en: `${NDS_COMPONENT_COUNT} components exported on npm` },
'Gitea Actions CI/CD pipeline for automated npm publishing and documentation deployment.', { fr: 'Tokens couleur / typo / espacement / rayons, thème clair-sombre', en: 'Color / type / spacing / radius tokens, light-dark theme' },
{ fr: 'CI/CD Gitea Actions auto-hébergée, publication npm en ~15 s', en: 'Self-hosted Gitea Actions CI/CD, npm publish in ~15 s' },
{ fr: 'Licence maison : usage libre, monétisation du DS interdite', en: 'Custom license: free to use, monetizing NDS itself prohibited' },
], ],
stack: [ stack: [
{ icon: 'astro', label: 'Astro' }, { name: 'Astro', icon: 'Astro' },
{ icon: 'typescript', label: 'TypeScript' }, { name: 'TypeScript', icon: 'Typescript' },
{ icon: 'sass', label: 'Sass' }, { name: 'Sass', icon: 'Sass' },
{ icon: 'gitea', label: 'Gitea CI' }, { name: 'Gitea CI', icon: 'Gitea' },
{ name: 'npm', icon: 'Npm' },
], ],
links: [ links: [
{ label: 'Nova Design System v1.2.1', url: 'https://nds.louis-potevin.dev/', icon: 'astro' }, { label: 'npm', url: 'https://www.npmjs.com/package/@unkn0wndo3s/nova-design-system' },
{ label: 'npm', url: 'https://www.npmjs.com/package/@unkn0wndo3s/nova-design-system', icon: 'npm' }, { label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn/nova-design-system' },
{ label: 'Sources (Gitea)', url: 'https://git.novaprojects.dev/unkn0wn/nova-design-system', icon: 'gitea' },
], ],
}, },
{ {
slug: 'nova-infra', slug: 'nova-infra',
designation: '02', name: 'Self-hosted Infrastructure',
title: 'Self-hosted Infrastructure & Deployment',
tagline: 'Containerized services, CI/CD, and secured ingress on a fully controlled infrastructure.',
period: '2025 — ongoing',
role: 'Design & operations',
status: 'live', status: 'live',
featured: true, featured: true,
summary: tagline: {
'A comprehensive self-hosted infrastructure: Git hosting, secret management, and application services, all containerized with Docker and continuously deployed.', fr: 'Services conteneurisés, CI/CD et ingress sécurisé sur serveur dédié.',
context: [ en: 'Containerized services, CI/CD and secured ingress on a dedicated server.',
'Fully containerized with Docker, orchestrated, and reproducible.', },
'Certificates and DNS managed through Cloudflare.', summary: {
'Services cleanly exposed using a reverse proxy.', fr: "Infrastructure auto-hébergée sur serveur OVH : forge Git (Gitea), gestionnaire de secrets (Vaultwarden), runners CI et sites hébergés, exposés derrière un reverse proxy Apache et Cloudflare.",
en: 'Self-hosted infrastructure on an OVH server: Git forge (Gitea), secret manager (Vaultwarden), CI runners and hosted sites, exposed behind an Apache reverse proxy and Cloudflare.',
},
body: [
{
fr: "Tout mon outillage de développement tourne sur ma propre infrastructure : Gitea pour le code et les releases, Vaultwarden pour les secrets, un runner Gitea Actions pour la CI/CD, et les sites de production (dont celui-ci).",
en: 'All my development tooling runs on my own infrastructure: Gitea for code and releases, Vaultwarden for secrets, a Gitea Actions runner for CI/CD, and the production sites (including this one).',
},
{
fr: "Chaque service est conteneurisé et isolé ; l'exposition passe par un reverse proxy Apache avec TLS, derrière Cloudflare pour le DNS et la protection. Les déploiements sont entièrement automatisés : un push sur `main` suffit.",
en: 'Each service is containerized and isolated; exposure goes through an Apache reverse proxy with TLS, behind Cloudflare for DNS and protection. Deployments are fully automated: a push to `main` is all it takes.',
},
], ],
contributions: [ highlights: [
'Setup of a self-hosted Gitea instance with a Postgres backend and action runners.', { fr: 'Forge Git + CI/CD + secrets 100 % auto-hébergés', en: '100% self-hosted Git forge + CI/CD + secrets' },
'End-to-end automated CI/CD pipelines (build, test, publish).', { fr: 'Reverse proxy Apache, TLS, DNS et protection Cloudflare', en: 'Apache reverse proxy, TLS, Cloudflare DNS and protection' },
'Day-to-day operations: Docker networks, volume mounts, DNS, and diagnostics.', { fr: 'Déploiement continu : push sur main = mise en production', en: 'Continuous deployment: push to main = production release' },
], ],
stack: [ stack: [
{ icon: 'docker', label: 'Docker' }, { name: 'Docker', icon: 'Docker' },
{ icon: 'linux', label: 'Linux' }, { name: 'Linux', icon: 'Linux' },
{ icon: 'cloudflare', label: 'Cloudflare Tunnel' }, { name: 'Apache', icon: 'Apache' },
{ icon: 'gitea', label: 'Gitea' }, { name: 'Gitea', icon: 'Gitea' },
{ name: 'Cloudflare', icon: 'Cloudflare' },
], ],
links: [{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn', icon: 'gitea' }], links: [{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn' }],
}, },
{ {
slug: 'portfolio', slug: 'portfolio',
designation: '03', name: 'louis-potevin.dev',
title: 'louis-potevin.dev',
tagline: 'This website. Multi-page Astro app, 3D space background, 100% powered by Nova Design System.',
period: '2026',
role: 'Design & development',
status: 'live', status: 'live',
featured: true, featured: true,
summary: tagline: {
'A static multi-page portfolio website. The content is entirely built using Nova components, featuring a subtle, discrete Three.js space background.', fr: 'Ce site. Astro multi-pages, fond 3D, bilingue, 100 % Nova Design System.',
context: [ en: 'This website. Multi-page Astro app, 3D background, bilingual, 100% Nova Design System.',
'Three.js space background: starfield, nebula, and an ambient asteroid.', },
'UI entirely composed using the in-house design system.', summary: {
'Astro multi-page setup with Static Site Generation (SSG), optimized for SEO.', fr: "Portfolio statique bilingue (FR/EN), entièrement composé avec les composants Nova, avec une scène spatiale Three.js pilotée par le scroll et un SEO complet (hreflang, JSON-LD, sitemap).",
en: 'A bilingual (FR/EN) static portfolio, entirely composed with Nova components, featuring a scroll-driven Three.js space scene and full SEO (hreflang, JSON-LD, sitemap).',
},
body: [
{
fr: "Le site sert de vitrine et de banc d'essai au design system : chaque bouton, badge, champ ou fil d'Ariane provient du paquet npm `@unkn0wndo3s/nova-design-system`, sans style dupliqué.",
en: 'The site doubles as a showcase and test bench for the design system: every button, badge, field or breadcrumb comes from the `@unkn0wndo3s/nova-design-system` npm package, with no duplicated styling.',
},
{
fr: "Côté 3D, une ceinture d'astéroïdes réagit au scroll sur la page d'accueil et les composants du design system flottent en 3D sur sa page projet. Le tout se coupe proprement sans WebGL ou quand l'utilisateur préfère réduire les animations.",
en: 'On the 3D side, an asteroid belt reacts to scroll on the home page and the design system components float in 3D on its project page. Everything degrades cleanly without WebGL or when the user prefers reduced motion.',
},
], ],
contributions: [ highlights: [
'Full integration using Nova tokens and components.', { fr: 'Bilingue FR/EN avec hreflang et sitemap localisé', en: 'Bilingual FR/EN with hreflang and localized sitemap' },
'Adherence to prefers-reduced-motion and graceful degradation when WebGL is unavailable.', { fr: 'Scène Three.js pilotée par le scroll, fallback sans WebGL', en: 'Scroll-driven Three.js scene, no-WebGL fallback' },
'Meticulous SEO: metadata, structured data, and sitemap generation.', { fr: 'JSON-LD Person / WebSite / BreadcrumbList / SoftwareSourceCode', en: 'Person / WebSite / BreadcrumbList / SoftwareSourceCode JSON-LD' },
], ],
stack: [ stack: [
{ icon: 'astro', label: 'Astro' }, { name: 'Astro', icon: 'Astro' },
{ icon: 'typescript', label: 'TypeScript' }, { name: 'TypeScript', icon: 'Typescript' },
{ icon: 'sass', label: 'Sass' }, { name: 'Sass', icon: 'Sass' },
{ name: 'Three.js', icon: 'Threedotjs' },
], ],
links: [{ label: 'Unkn0wn Projects', url: 'https://louis-potevin.dev/', icon: 'astro' }], links: [{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn/portfolio' }],
}, },
{ {
slug: 'rust-file-organizer', slug: 'file-organizer',
designation: '04', name: 'File Organizer',
title: 'File Organizer — Java to Rust', status: 'wip',
tagline: 'File sorting tool, currently being ported from Java to Rust.',
period: '2025 — ongoing',
role: 'Development',
status: 'r&d',
featured: false, featured: false,
summary: tagline: {
'A file organizer tool that currently runs in Java on Windows. It is being converted to Rust to achieve cross-platform Linux compatibility and optimize file search performance.', fr: "Portage Java vers Rust d'un organiseur de fichiers, UI native avec GPUI.",
context: [ en: 'Java-to-Rust port of a file organizer, native UI with GPUI.',
'Current version: operational in Java, targeting Windows.', },
'Gradual porting to Rust for performance gains and low-level control.', summary: {
fr: "Réécriture en Rust d'un outil d'organisation de fichiers initialement en Java : surveillance du système de fichiers avec `notify`, interface native GPUI, et architecture qui sépare strictement la logique fichiers de l'UI.",
en: 'A Rust rewrite of a file-organizing tool originally built in Java: filesystem watching with `notify`, a native GPUI interface, and an architecture that strictly separates file logic from the UI.',
},
body: [
{
fr: "L'objectif du portage : gagner en performance et en fiabilité, et apprendre Rust sur un vrai projet. La logique métier (règles de tri, déplacement, surveillance) est pure et testable, indépendante de la couche de rendu.",
en: 'The goal of the port: gain performance and reliability, and learn Rust on a real project. The business logic (sorting rules, moves, watching) is pure and testable, independent from the rendering layer.',
},
], ],
contributions: [ highlights: [
'Rewriting sorting logic from Java to Rust.', { fr: 'Logique filesystem pure, découplée de lUI', en: 'Pure filesystem logic, decoupled from the UI' },
'Setting objectives for Linux compatibility and file search optimization.', { fr: 'Surveillance temps réel avec le crate notify', en: 'Real-time watching with the notify crate' },
], ],
stack: [ stack: [
{ icon: 'rust', label: 'Rust' }, { name: 'Rust', icon: 'Rust' },
{ icon: 'linux', label: 'Linux (upcoming)' }, { name: 'GPUI', icon: 'Rust' },
], ],
links: [], links: [{ label: 'GitHub', url: 'https://github.com/unkn0wndo3s/file-organizer' }],
},
{
slug: 'llm-tooling',
designation: '05',
title: 'LLM Training & Inference',
tagline: 'Small language model trained on a Reddit conversation dataset.',
period: '2025 — ongoing',
role: 'Development',
status: 'r&d',
featured: false,
summary:
'A language model training and inference project running on a dedicated machine. The model is trained on a custom dataset of Reddit conversations. Development is ongoing.',
context: [
'Execution on a dedicated workstation optimized for training and inference.',
'Dataset: conversation threads sourced from Reddit.',
],
contributions: [
'Assembling and preprocessing the conversation dataset.',
'Setting up the training and inference loop, currently iterating on results.',
],
stack: [
{ icon: 'python', label: 'Python' },
{ icon: 'linux', label: 'Linux' },
],
links: [],
}, },
]; ];
export const featuredProjects = projects.filter((p) => p.featured); export const featuredProjects = projects.filter((p) => p.featured);
export const statusMeta: Record<ProjectStatus, { label: string; tone: 'success' | 'primary' | 'neutral' | 'warning' }> = { export function getProject(slug: string): Project | undefined {
live: { label: 'Live', tone: 'success' }, return projects.find((p) => p.slug === slug);
maintenu: { label: 'Maintained', tone: 'primary' }, }
'r&d': { label: 'In Progress', tone: 'warning' },
archive: { label: 'Archived', tone: 'neutral' }, /* ── NDS showcase data (project page "flying components" tour) ─────────────── */
};
export interface ShowcaseStage {
id: string;
component: string;
title: Localized;
description: Localized;
tokens: string[];
}
export const ndsShowcase: ShowcaseStage[] = [
{
id: 'button',
component: 'Button',
title: { fr: 'Button - quatre intentions, trois tailles', en: 'Button - four intents, three sizes' },
description: {
fr: 'Primary, secondary, ghost et danger. Rendu en lien ou en bouton selon la prop href, focus visible au clavier.',
en: 'Primary, secondary, ghost and danger. Renders as a link or a button depending on the href prop, visible keyboard focus.',
},
tokens: ['--nds-primary', '--nds-on-primary', '--nds-radius-md', '--nds-ring'],
},
{
id: 'badge',
component: 'Badge',
title: { fr: 'Badge - statuts sémantiques', en: 'Badge - semantic statuses' },
description: {
fr: 'Six types (primary, neutral, success, warning, error, info) en deux variantes, soft et solid, dérivées des tokens de statut.',
en: 'Six types (primary, neutral, success, warning, error, info) in two variants, soft and solid, derived from the status tokens.',
},
tokens: ['--nds-success-medium', '--nds-warning-low', '--nds-radius-full'],
},
{
id: 'textfield',
component: 'TextField',
title: { fr: 'TextField - saisie avec compteur', en: 'TextField - input with counter' },
description: {
fr: 'Texte, e-mail, mot de passe ou textarea, avec label lié, placeholder et compteur de caractères optionnel.',
en: 'Text, e-mail, password or textarea, with a bound label, placeholder and optional character counter.',
},
tokens: ['--nds-surface', '--nds-border', '--nds-text', '--nds-ring'],
},
{
id: 'toggle',
component: 'Toggle',
title: { fr: 'Toggle - Web Component', en: 'Toggle - Web Component' },
description: {
fr: "Implémenté selon le pattern Web Components : état interne, événements natifs, utilisable hors d'Astro.",
en: 'Built on the Web Components pattern: internal state, native events, usable outside Astro.',
},
tokens: ['--nds-primary', '--nds-surface-2', '--nds-radius-full'],
},
{
id: 'select',
component: 'Select',
title: { fr: 'Select - liste déroulante accessible', en: 'Select - accessible dropdown' },
description: {
fr: 'Select et SelectOption composables, navigation clavier, valeur par défaut et état désactivé.',
en: 'Composable Select and SelectOption, keyboard navigation, default value and disabled state.',
},
tokens: ['--nds-surface', '--nds-border-strong', '--nds-shadow-md'],
},
{
id: 'pagination',
component: 'Pagination',
title: { fr: 'Pagination - navigation par pages', en: 'Pagination - page navigation' },
description: {
fr: 'Pagination et PaginationNumber composables, état courant et bornes gérés par le composant.',
en: 'Composable Pagination and PaginationNumber, current state and bounds handled by the component.',
},
tokens: ['--nds-primary-soft', '--nds-text', '--nds-radius-md'],
},
];
+27 -32
View File
@@ -1,37 +1,32 @@
/** Profile and links - single source of truth for the whole site. */
export const site = { export const site = {
name: "Louis Potevin", name: 'Louis Potevin',
handle: "unkn0wn", email: 'contact@louis-potevin.dev',
role: "Full-stack developer", location: { city: 'Limoges', region: 'Nouvelle-Aquitaine', country: 'FR' },
domain: "louis-potevin.dev", availability: { cdiFrom: '2026-09', freelance: true },
location: "Limoges · Nouvelle-Aquitaine",
// Job Search
availability: "Permanent contract (CDI) — September 2026",
mobility: "Anywhere in France, on-site or remote",
// Contact
email: "contact@louis-potevin.dev",
// External links (icons via simple-icons-astro)
links: { links: {
gitea: { github: { label: 'GitHub', url: 'https://github.com/unkn0wndo3s' },
label: "Gitea", gitea: { label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn' },
url: "https://git.novaprojects.dev/unkn0wn", npm: { label: 'npm', url: 'https://www.npmjs.com/~unkn0wndo3s' },
icon: "gitea", linkedin: { label: 'LinkedIn', url: 'https://www.linkedin.com/in/louis-potevin' },
},
github: {
label: "GitHub",
url: "https://github.com/unkn0wndo3s",
icon: "github",
},
npm: {
label: "npm",
url: "https://www.npmjs.com/~unkn0wndo3s",
icon: "npm",
},
linkedin: {
label: "LinkedIn",
url: "https://www.linkedin.com/in/louis-potevin",
icon: "linkedin",
},
}, },
} as const; } as const;
export type SiteLink = { label: string; url: string; icon: string }; /** Everyday tools, shown on the home page. `icon` maps to simple-icons-astro names. */
export const stack = [
{ name: 'Astro', icon: 'Astro' },
{ name: 'Vue.js', icon: 'Vuedotjs' },
{ name: 'TypeScript', icon: 'Typescript' },
{ name: 'JavaScript', icon: 'Javascript' },
{ name: 'Sass', icon: 'Sass' },
{ name: 'Node.js', icon: 'Nodedotjs' },
{ name: 'PostgreSQL', icon: 'Postgresql' },
{ name: 'Docker', icon: 'Docker' },
{ name: 'Linux', icon: 'Linux' },
{ name: 'Cloudflare', icon: 'Cloudflare' },
{ name: 'Gitea CI/CD', icon: 'Gitea' },
{ name: 'Rust', icon: 'Rust' },
{ name: 'Python', icon: 'Python' },
{ name: 'Git', icon: 'Git' },
{ name: 'Figma', icon: 'Figma' },
] as const;
+4
View File
@@ -0,0 +1,4 @@
/// <reference path="../.astro/types.d.ts" />
// The NDS "./styles" export maps to a raw .scss entry - no type declarations shipped.
declare module '@unkn0wndo3s/nova-design-system/styles';
+59
View File
@@ -0,0 +1,59 @@
/**
* i18n core - locale detection, path localization, dictionary access.
*
* Routing model (Astro i18n, `prefixDefaultLocale: false`):
* fr (default) -> / /work /work/[slug] /about /contact
* en -> /en/ /en/work ...
*/
import { ui, type UIKey } from './ui';
export const LOCALES = ['fr', 'en'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'fr';
export const LOCALE_LABELS: Record<Locale, string> = { fr: 'Français', en: 'English' };
export const OG_LOCALES: Record<Locale, string> = { fr: 'fr_FR', en: 'en_US' };
export const HTML_LANGS: Record<Locale, string> = { fr: 'fr', en: 'en' };
/** Extracts the locale from a pathname ("/en/work" -> "en", "/work" -> "fr"). */
export function getLocaleFromUrl(url: URL): Locale {
const [, first] = url.pathname.split('/');
return (LOCALES as readonly string[]).includes(first) && first !== DEFAULT_LOCALE
? (first as Locale)
: DEFAULT_LOCALE;
}
/** Prefixes a root-relative path for the given locale. localizePath('/work', 'en') -> '/en/work' */
export function localizePath(path: string, locale: Locale): string {
const clean = path.startsWith('/') ? path : `/${path}`;
if (locale === DEFAULT_LOCALE) return clean;
return clean === '/' ? `/${locale}/` : `/${locale}${clean}`;
}
/** Strips a locale prefix, giving the canonical route shared by all locales. */
export function unlocalizePath(pathname: string): string {
for (const locale of LOCALES) {
if (locale === DEFAULT_LOCALE) continue;
if (pathname === `/${locale}` || pathname === `/${locale}/`) return '/';
if (pathname.startsWith(`/${locale}/`)) return pathname.slice(locale.length + 1);
}
return pathname;
}
/** Translation accessor bound to a locale: const t = useTranslations('fr'); t('nav.home') */
export function useTranslations(locale: Locale) {
return function t(key: UIKey): string {
return ui[locale][key] ?? ui[DEFAULT_LOCALE][key] ?? key;
};
}
/** hreflang alternates for the current page (fr, en, x-default). */
export function getAlternates(url: URL, site: string) {
const route = unlocalizePath(url.pathname);
const abs = (p: string) => new URL(p, site).href;
return [
{ hreflang: 'fr', href: abs(localizePath(route, 'fr')) },
{ hreflang: 'en', href: abs(localizePath(route, 'en')) },
{ hreflang: 'x-default', href: abs(localizePath(route, DEFAULT_LOCALE)) },
];
}
+315
View File
@@ -0,0 +1,315 @@
import { NDS_COMPONENT_COUNT } from '../lib/ndsMeta';
/**
* UI dictionaries. Keys are grouped by surface (nav, home, work, about, contact, footer).
* Project content lives in `src/data/projects.ts` (localized per field).
*/
const fr = {
// Meta
'meta.home.title': 'Louis Potevin - Développeur full-stack TypeScript/Node · Limoges & remote',
'meta.home.description':
"Louis Potevin, développeur web full-stack : front-end, back-end, TypeScript, Astro, Node. Disponible en CDI dès septembre 2026 et en freelance, partout en France ou en remote.",
'meta.work.title': 'Projets - Louis Potevin, développeur full-stack',
'meta.work.description':
"Design system publié sur npm, infrastructure auto-hébergée, outils Rust : les projets de Louis Potevin, développeur web full-stack.",
'meta.docs.title': 'Docs - Comment ce portfolio est construit, de A à Z',
'meta.docs.description':
"Documentation technique complète du portfolio de Louis Potevin : Astro, i18n, Lenis, Three.js, scènes scroll-driven, design system, SEO, déploiement - avec le vrai code source.",
'meta.about.title': 'À propos - Louis Potevin, développeur full-stack',
'meta.about.description':
"Parcours de Louis Potevin : BUT MMI, alternance chez Legrand, projets personnels. Développeur full-stack TypeScript / Node, disponible en CDI et freelance.",
'meta.contact.title': 'Contact - Louis Potevin, développeur full-stack',
'meta.contact.description':
"Contacter Louis Potevin, développeur web full-stack. CDI dès septembre 2026 ou mission freelance, partout en France, sur site ou en remote.",
// Nav
'nav.home': 'Accueil',
'nav.work': 'Projets',
'nav.docs': "Comment c'est fait",
'nav.about': 'À propos',
'nav.contact': 'Contact',
'nav.cta': 'Me contacter',
'nav.skip': 'Aller au contenu',
'nav.lang': 'Langue',
// Availability
'avail.badge': 'Disponible',
'avail.line': 'CDI dès septembre 2026 · Freelance dès maintenant',
'avail.where': 'Partout en France, sur site ou en remote',
// Home
'home.hero.title.role': 'Développeur full-stack',
'home.hero.lead':
"Deux ans sur une application métier en production chez Legrand, un design system publié sur npm, une infra que j'héberge moi-même. Ce site tourne sur tout ça.",
'home.hero.viewProjects': 'Voir les projets',
'home.hero.contact': 'Me contacter',
'home.profile.eyebrow': 'Profil',
'home.profile.title': 'Un profil full-stack, à laise sur toute la chaîne.',
'home.profile.body1':
"Depuis avril 2025, je développe une application métier utilisée dans les ateliers de production de Legrand France - stage, puis job d'été, puis alternance, toujours sur le même produit. Du code que de vraies équipes utilisent tous les jours (sous NDA, donc pas de détails ici).",
'home.profile.body2':
"À côté, je construis mes propres outils : Nova Design System sur npm, un Gitea et sa CI sur mon VPS. Pas pour la ligne de CV - parce que maintenir un truc dans la durée apprend plus que le recommencer.",
'home.profile.front.title': 'Front-end',
'home.profile.front.body': `Vue.js chez Legrand, Astro sur mes projets, TypeScript partout. Et un design system maison de ${NDS_COMPONENT_COUNT} composants pour ne rien réécrire deux fois.`,
'home.profile.back.title': 'Back-end',
'home.profile.back.body':
'Node et Express en production chez Legrand, PostgreSQL pour les données.',
'home.profile.ops.title': 'Outils & déploiement',
'home.profile.ops.body':
"Mon Gitea, mes runners, mon Apache, mon VPS : quand ce site se déploie, c'est ma CI qui tourne, pas celle de quelqu'un d'autre.",
'home.projects.eyebrow': 'Projets phares',
'home.projects.title': 'Projets',
'home.projects.all': 'Tous les projets',
'home.projects.view': 'Voir le projet',
'home.stack.eyebrow': 'Stack technique',
'home.stack.title': 'Mes outils du quotidien.',
'home.cta.title': 'Un poste, une mission, une question ?',
'home.cta.body':
"CDI full-stack à partir de septembre 2026, freelance dès maintenant. Basé à Limoges, mobile partout en France, à l'aise en remote. Un mail suffit.",
'home.cta.contact': 'Me contacter',
'home.cta.more': 'En savoir plus',
// Work
'work.title': 'Projets',
'work.lead':
"Un design system sur npm, une infra auto-hébergée, ce portfolio, et un outil Rust en cours. Le détail, les choix et les ratés de chacun.",
'work.status.live': 'En production',
'work.status.maintained': 'Maintenu',
'work.status.wip': 'En cours',
'work.backToList': 'Tous les projets',
'work.stack': 'Stack',
'work.links': 'Liens',
'work.highlights': 'Points clés',
'work.next': 'Projet suivant',
// NDS showcase
'nds.showcase.eyebrow': 'Exploration interactive',
'nds.showcase.title': 'Les composants, un à un.',
'nds.showcase.hint': 'Faites défiler pour parcourir les composants',
'nds.showcase.tokens': 'Tokens utilisés',
// About
'about.title': 'À propos',
'about.lead':
"Développeur full-stack, formé en BUT MMI (parcours développement web) et aguerri en entreprise chez Legrand - du stage à l'alternance, sur la même application métier en production.",
'about.exp.eyebrow': 'Expérience',
'about.exp.title': 'Parcours',
'about.values.eyebrow': 'Méthode',
'about.values.title': 'Ma façon de travailler',
'about.cv': 'Télécharger mon CV',
// Contact
'contact.title': 'Contact',
'contact.lead':
'Un poste en CDI, une mission freelance ou simplement une question : écrivez-moi, je réponds vite.',
'contact.email.label': 'E-mail',
'contact.email.copy': "Copier l'adresse",
'contact.email.copied': 'Adresse copiée !',
'contact.email.write': 'Écrire un e-mail',
'contact.form.name': 'Votre nom',
'contact.form.email': 'Votre e-mail',
'contact.form.subject': 'Sujet',
'contact.form.subject.cdi': 'Opportunité CDI',
'contact.form.subject.freelance': 'Mission freelance',
'contact.form.subject.other': 'Autre',
'contact.form.message': 'Votre message',
'contact.form.send': 'Envoyer le message',
'contact.form.note':
"Le bouton ouvre votre client mail avec le message pré-rempli - aucune donnée n'est stockée sur ce site.",
'contact.elsewhere': 'Me trouver ailleurs',
// Footer
'footer.role': 'Développeur full-stack · Limoges · Nouvelle-Aquitaine',
'footer.madeWith': 'Construit avec Astro + Nova Design System',
// Voyage (home cinematic scroll)
'voyage.hint': 'Scrollez - le voyage commence',
'voyage.progress': 'Progression du voyage',
'home.projects.intro.title': 'Un design system, une infra, un outil Rust. Et ce site.',
'home.projects.intro.body':
'Quatre projets publics, avec leur statut réel - y compris ce qui est encore en chantier.',
'home.about.summary':
"BUT MMI (dév. web) à l'IUT du Limousin, et deux ans chez Legrand sur la même application métier - du stage à l'alternance.",
// Docs
'docs.eyebrow': 'Documentation',
'docs.title': 'Sous le capot.',
'docs.lead':
"Comment ce site anime le scroll, expliqué avec des démos qui tournent sous vos yeux et le vrai code du dépôt. Si vous lisez tout, vous savez le refaire.",
'docs.toc': 'Sommaire',
'docs.file': 'Fichier',
'docs.why': 'Pourquoi ça marche',
'docs.live': 'Démo - ça tourne en vrai',
'docs.tryIt': 'Scrollez, regardez',
'docs.trap': 'Piège',
'docs.reading': 'Lecture ~20 min, molette obligatoire',
// Project scenes
'scene.hint': 'Faites défiler - la scène est pilotée par le scroll',
'scene.infra.eyebrow': 'Sous le capot',
'scene.infra.title': 'Le trajet dune requête.',
'scene.blueprint.eyebrow': 'Plan de construction',
'scene.blueprint.title': 'Du wireframe au produit.',
'scene.sorter.eyebrow': 'Démonstration',
'scene.sorter.title': 'Du chaos au rangé.',
// Misc
'a11y.langSwitch': 'Changer de langue',
'notfound.title': 'Page introuvable',
'notfound.body': "Cette page n'existe pas (ou plus). Le reste du site, lui, fonctionne très bien.",
'notfound.back': "Retour à l'accueil",
} as const;
const en: Record<UIKey, string> = {
'meta.home.title': 'Louis Potevin - Full-stack TypeScript/Node developer · Limoges & remote',
'meta.home.description':
'Louis Potevin, full-stack web developer: front-end, back-end, TypeScript, Astro, Node. Available for a permanent contract (CDI) from September 2026 and for freelance work, anywhere in France or remote.',
'meta.work.title': 'Projects - Louis Potevin, full-stack developer',
'meta.work.description':
'A design system published on npm, self-hosted infrastructure, Rust tooling: the projects of Louis Potevin, full-stack web developer.',
'meta.docs.title': 'Docs - How this portfolio is built, from A to Z',
'meta.docs.description':
"Complete technical documentation of Louis Potevin's portfolio: Astro, i18n, Lenis, Three.js, scroll-driven scenes, design system, SEO, deployment - with the real source code.",
'meta.about.title': 'About - Louis Potevin, full-stack developer',
'meta.about.description':
"Louis Potevin's background: BUT MMI degree, apprenticeship at Legrand, personal projects. Full-stack TypeScript / Node developer, open to permanent and freelance roles.",
'meta.contact.title': 'Contact - Louis Potevin, full-stack developer',
'meta.contact.description':
'Get in touch with Louis Potevin, full-stack web developer. Permanent contract from September 2026 or freelance work, anywhere in France, on-site or remote.',
'nav.home': 'Home',
'nav.work': 'Projects',
'nav.docs': "How it's built",
'nav.about': 'About me',
'nav.contact': 'Contact',
'nav.cta': 'Get in touch',
'nav.skip': 'Skip to content',
'nav.lang': 'Language',
'avail.badge': 'Available',
'avail.line': 'Permanent contract from September 2026 · Freelance now',
'avail.where': 'Anywhere in France, on-site or remote',
'home.hero.title.role': 'Full-stack developer',
'home.hero.lead':
'Two years on a business application in production at Legrand, a design system published on npm, infrastructure I host myself. This site runs on all of it.',
'home.hero.viewProjects': 'View projects',
'home.hero.contact': 'Contact me',
'home.profile.eyebrow': 'Profile',
'home.profile.title': 'A full-stack profile, comfortable across the entire stack.',
'home.profile.body1':
"Since April 2025 I've been building a business application used in Legrand France's production workshops - internship, then summer job, then apprenticeship, always on the same product. Code that real teams use every day (under NDA, so no details here).",
'home.profile.body2':
"On the side I build my own tools: Nova Design System on npm, a Gitea and its CI on my VPS. Not for the résumé line - because maintaining something over time teaches more than restarting it.",
'home.profile.front.title': 'Front-end',
'home.profile.front.body': `Vue.js at Legrand, Astro on my own projects, TypeScript everywhere. Plus a ${NDS_COMPONENT_COUNT}-component in-house design system so nothing gets written twice.`,
'home.profile.back.title': 'Back-end',
'home.profile.back.body':
'Node and Express in production at Legrand, PostgreSQL for the data.',
'home.profile.ops.title': 'Tools & deployment',
'home.profile.ops.body':
"My Gitea, my runners, my Apache, my VPS: when this site deploys, it's my CI running, not someone else's.",
'home.projects.eyebrow': 'Featured projects',
'home.projects.title': 'Projects',
'home.projects.all': 'All projects',
'home.projects.view': 'View project',
'home.stack.eyebrow': 'Tech stack',
'home.stack.title': 'My everyday tools.',
'home.cta.title': 'A role, a mission, a question?',
'home.cta.body':
'Full-stack permanent role from September 2026, freelance right now. Based in Limoges, mobile anywhere in France, comfortable remote. One email is all it takes.',
'home.cta.contact': 'Contact me',
'home.cta.more': 'Learn more',
'work.title': 'Projects',
'work.lead':
'A design system on npm, self-hosted infrastructure, this portfolio, and a Rust tool in progress. The details, choices and missteps of each.',
'work.status.live': 'Live',
'work.status.maintained': 'Maintained',
'work.status.wip': 'In progress',
'work.backToList': 'All projects',
'work.stack': 'Stack',
'work.links': 'Links',
'work.highlights': 'Highlights',
'work.next': 'Next project',
'nds.showcase.eyebrow': 'Interactive tour',
'nds.showcase.title': 'The components, one by one.',
'nds.showcase.hint': 'Scroll to browse the components',
'nds.showcase.tokens': 'Tokens in use',
'about.title': 'About me',
'about.lead':
'Full-stack developer, trained through a BUT MMI degree (web development track) and battle-tested at Legrand - from internship to apprenticeship, on the same business application in production.',
'about.exp.eyebrow': 'Experience',
'about.exp.title': 'Background',
'about.values.eyebrow': 'Method',
'about.values.title': 'How I work',
'about.cv': 'Download my resume',
'contact.title': 'Contact',
'contact.lead':
'A permanent role, a freelance mission or just a question: write to me, I reply fast.',
'contact.email.label': 'E-mail',
'contact.email.copy': 'Copy address',
'contact.email.copied': 'Address copied!',
'contact.email.write': 'Write an e-mail',
'contact.form.name': 'Your name',
'contact.form.email': 'Your e-mail',
'contact.form.subject': 'Subject',
'contact.form.subject.cdi': 'Permanent position (CDI)',
'contact.form.subject.freelance': 'Freelance mission',
'contact.form.subject.other': 'Other',
'contact.form.message': 'Your message',
'contact.form.send': 'Send message',
'contact.form.note':
'The button opens your mail client with the message pre-filled - no data is stored on this site.',
'contact.elsewhere': 'Find me elsewhere',
'footer.role': 'Full-stack developer · Limoges · Nouvelle-Aquitaine, France',
'footer.madeWith': 'Built with Astro + Nova Design System',
'voyage.hint': 'Scroll - the journey begins',
'voyage.progress': 'Journey progress',
'home.projects.intro.title': 'A design system, an infra, a Rust tool. And this site.',
'home.projects.intro.body':
'Four public projects, with their real status - including what is still under construction.',
'home.about.summary':
'BUT MMI degree (web dev) at IUT du Limousin, and two years at Legrand on the same business application - from internship to apprenticeship.',
'docs.eyebrow': 'Documentation',
'docs.title': 'Under the hood.',
'docs.lead':
'How this site animates scrolling, explained with demos running before your eyes and the real code from the repo. Read it all and you can rebuild it.',
'docs.toc': 'Contents',
'docs.file': 'File',
'docs.why': 'Why it works',
'docs.live': 'Demo - running for real',
'docs.tryIt': 'Scroll and watch',
'docs.trap': 'Gotcha',
'docs.reading': '~20 min read, scroll wheel required',
'scene.hint': 'Keep scrolling - the scene is driven by scroll',
'scene.infra.eyebrow': 'Under the hood',
'scene.infra.title': 'The journey of a request.',
'scene.blueprint.eyebrow': 'Blueprint',
'scene.blueprint.title': 'From wireframe to product.',
'scene.sorter.eyebrow': 'Live demo',
'scene.sorter.title': 'From chaos to order.',
'a11y.langSwitch': 'Switch language',
'notfound.title': 'Page not found',
'notfound.body': "This page doesn't exist (anymore). The rest of the site works just fine.",
'notfound.back': 'Back to home',
};
export type UIKey = keyof typeof fr;
export const ui = { fr, en } as const;
+56 -97
View File
@@ -6,122 +6,75 @@ import SpaceBackground from '../components/SpaceBackground.astro';
import SiteNav from '../components/SiteNav.astro'; import SiteNav from '../components/SiteNav.astro';
import SiteFooter from '../components/SiteFooter.astro'; import SiteFooter from '../components/SiteFooter.astro';
import { site } from '../data/site'; import { site } from '../data/site';
import { personLd, webSiteLd } from '../lib/seo';
import {
getLocaleFromUrl,
getAlternates,
useTranslations,
HTML_LANGS,
OG_LOCALES,
LOCALES,
} from '../i18n';
export interface Props { export interface Props {
title?: string; title: string;
description?: string; description: string;
/** Page-specific keywords (appended to the global keywords list). */ /** Extra JSON-LD blocks for this page (breadcrumbs, project, profile...). */
keywords?: string[]; jsonLd?: object[];
/** og:type - "website" for index pages, "article" for project pages. */
ogType?: 'website' | 'article';
/** Ambient space background canvas (off on pages that mount their own scene). */
background?: boolean;
/** Full-bleed main (no max-width shell) for cinematic pages. */
fullBleed?: boolean;
} }
const { const SITE_URL = 'https://louis-potevin.dev';
title = 'Louis Potevin — Full-stack Developer (front, back, web)', const { title, description, jsonLd = [], ogType = 'website', background = true, fullBleed = false } = Astro.props;
description = "Louis Potevin, full-stack web developer: front-end, back-end, TypeScript, Astro, Node. Available for a permanent contract (CDI) starting September 2026, anywhere in France or remote.",
keywords = [],
} = Astro.props;
const baseKeywords = [ const locale = getLocaleFromUrl(Astro.url);
// French Keywords const t = useTranslations(locale);
'Louis Potevin', const canonical = new URL(Astro.url.pathname, Astro.site ?? SITE_URL).href;
'développeur web', const ogImage = new URL(`/og-${locale}.png`, Astro.site ?? SITE_URL).href;
'developpeur web', const alternates = getAlternates(Astro.url, (Astro.site ?? new URL(SITE_URL)).href);
'dev web', const otherLocales = LOCALES.filter((l) => l !== locale);
'développeur full-stack',
'developpeur full stack',
'dev front',
'développeur front-end',
'dev back',
'développeur back-end',
'développeur TypeScript',
'développeur Astro',
'développeur JavaScript',
'développeur Node.js',
'développeur freelance France',
'portfolio développeur',
'web developer',
'full-stack developer',
'full stack developer',
'front-end developer',
'front end dev',
'back-end developer',
'back end dev',
'TypeScript developer',
'Astro developer',
'JavaScript developer',
'Node.js developer',
'software engineer France',
'developer portfolio',
];
const allKeywords = [...baseKeywords, ...keywords].join(', ');
const canonical = new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href; const ldBlocks = [personLd(locale), webSiteLd(), ...jsonLd];
const ogImage = new URL('/og.png', Astro.site ?? 'https://louis-potevin.dev').href;
// Structured data: Person profile (helps Google link your name ↔ profession ↔ social links).
const personLd = {
'@context': 'https://schema.org',
'@type': 'Person',
name: site.name,
jobTitle: 'Full-stack Developer / Développeur full-stack',
description,
url: canonical,
email: `mailto:${site.email}`,
address: { '@type': 'PostalAddress', addressCountry: 'FR', addressLocality: 'Limoges' },
knowsAbout: [
'Web development',
'Développement web',
'Front-end',
'Back-end',
'TypeScript',
'JavaScript',
'Astro',
'Node.js',
'Sass',
'Docker',
'CI/CD',
],
sameAs: [
site.links.github.url,
site.links.gitea.url,
site.links.npm.url,
site.links.linkedin.url,
],
};
const siteLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: `${site.name} — Portfolio`,
url: Astro.site?.href ?? 'https://louis-potevin.dev',
inLanguage: ['en-US', 'fr-FR'],
};
--- ---
<!doctype html> <!doctype html>
<html lang="en" data-theme="dark"> <html lang={HTML_LANGS[locale]} data-theme="dark">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg?v=2" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Intel+One+Mono:wght@400;500;700&display=swap"
/>
<link rel="sitemap" href="/sitemap-index.xml" />
<title>{title}</title> <title>{title}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta name="keywords" content={allKeywords} />
<meta name="author" content={site.name} /> <meta name="author" content={site.name} />
<meta name="robots" content="index, follow, max-image-preview:large" /> <meta name="robots" content="index, follow, max-image-preview:large" />
<link rel="canonical" href={canonical} /> <link rel="canonical" href={canonical} />
{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
<meta property="og:type" content="website" /> <meta property="og:type" content={ogType} />
<meta property="og:site_name" content={`${site.name} Portfolio`} /> <meta property="og:site_name" content={`${site.name} - Portfolio`} />
<meta property="og:locale" content="en_US" /> <meta property="og:locale" content={OG_LOCALES[locale]} />
<meta property="og:locale:alternate" content="fr_FR" /> {otherLocales.map((l) => <meta property="og:locale:alternate" content={OG_LOCALES[l]} />)}
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:url" content={canonical} /> <meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} /> <meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={`${site.name} - ${t('home.hero.title.role')}`} />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} /> <meta name="twitter:title" content={title} />
@@ -130,23 +83,29 @@ const siteLd = {
<meta name="theme-color" content="#040B0B" /> <meta name="theme-color" content="#040B0B" />
<script type="application/ld+json" set:html={JSON.stringify(personLd)} /> {ldBlocks.map((block) => <script is:inline type="application/ld+json" set:html={JSON.stringify(block)} />)}
<script type="application/ld+json" set:html={JSON.stringify(siteLd)} />
</head> </head>
<body> <body>
<SpaceBackground /> {background && <SpaceBackground />}
<a class="skip" href="#content">Skip to content</a> <a class="skip" href="#content">{t('nav.skip')}</a>
<SiteNav /> <SiteNav />
<main id="content" class="shell"> <main id="content" class:list={[{ shell: !fullBleed }]}>
<slot /> <slot />
</main> </main>
<SiteFooter /> <SiteFooter />
<script>
// Smooth scroll everywhere - every animated surface reads from Lenis,
// so all motion sits on one easing curve.
import { ensureSmooth } from '../lib/smooth';
ensureSmooth();
</script>
<style lang="scss"> <style lang="scss">
.shell { .shell {
position: relative; position: relative;
z-index: 10; z-index: 10;
max-width: 1120px; max-width: 1200px;
margin-inline: auto; margin-inline: auto;
padding-inline: clamp(20px, 5vw, 56px); padding-inline: clamp(20px, 5vw, 56px);
} }
@@ -166,4 +125,4 @@ const siteLd = {
} }
</style> </style>
</body> </body>
</html> </html>
+61
View File
@@ -0,0 +1,61 @@
/**
* Brand icon paths, extracted from the installed `simple-icons-astro` package
* at build time (`?raw` + regex). Why not use its components directly? Two
* validator-breaking issues: the generated <svg> carries a duplicate `fill`
* attribute, and each icon embeds a <title> that doubles every name for
* screen readers and text extraction ("AstroAstro"). Rendering the raw path
* ourselves fixes both, and stays in sync with the package version.
*/
import apache from '../../node_modules/simple-icons-astro/dist/Apache.astro?raw';
import astro from '../../node_modules/simple-icons-astro/dist/Astro.astro?raw';
import cloudflare from '../../node_modules/simple-icons-astro/dist/Cloudflare.astro?raw';
import docker from '../../node_modules/simple-icons-astro/dist/Docker.astro?raw';
import figma from '../../node_modules/simple-icons-astro/dist/Figma.astro?raw';
import git from '../../node_modules/simple-icons-astro/dist/Git.astro?raw';
import gitea from '../../node_modules/simple-icons-astro/dist/Gitea.astro?raw';
import github from '../../node_modules/simple-icons-astro/dist/Github.astro?raw';
import javascript from '../../node_modules/simple-icons-astro/dist/Javascript.astro?raw';
import linux from '../../node_modules/simple-icons-astro/dist/Linux.astro?raw';
import nodedotjs from '../../node_modules/simple-icons-astro/dist/Nodedotjs.astro?raw';
import npm from '../../node_modules/simple-icons-astro/dist/Npm.astro?raw';
import postgresql from '../../node_modules/simple-icons-astro/dist/Postgresql.astro?raw';
import python from '../../node_modules/simple-icons-astro/dist/Python.astro?raw';
import rust from '../../node_modules/simple-icons-astro/dist/Rust.astro?raw';
import sass from '../../node_modules/simple-icons-astro/dist/Sass.astro?raw';
import threedotjs from '../../node_modules/simple-icons-astro/dist/Threedotjs.astro?raw';
import typescript from '../../node_modules/simple-icons-astro/dist/Typescript.astro?raw';
import vuedotjs from '../../node_modules/simple-icons-astro/dist/Vuedotjs.astro?raw';
export interface BrandIcon {
d: string;
brand: string;
}
function parse(raw: string, label: string): BrandIcon {
const d = raw.match(/ d="([^"]+)"/)?.[1];
const brand = raw.match(/fill="(#[0-9A-Fa-f]{3,8})"/)?.[1] ?? 'currentColor';
if (!d) throw new Error(`brandIcons: no path found for ${label}`);
return { d, brand };
}
export const BRAND_ICONS: Record<string, BrandIcon> = {
Apache: parse(apache, 'Apache'),
Astro: parse(astro, 'Astro'),
Cloudflare: parse(cloudflare, 'Cloudflare'),
Docker: parse(docker, 'Docker'),
Figma: parse(figma, 'Figma'),
Git: parse(git, 'Git'),
Gitea: parse(gitea, 'Gitea'),
Github: parse(github, 'Github'),
Javascript: parse(javascript, 'Javascript'),
Linux: parse(linux, 'Linux'),
Nodedotjs: parse(nodedotjs, 'Nodedotjs'),
Npm: parse(npm, 'Npm'),
Postgresql: parse(postgresql, 'Postgresql'),
Python: parse(python, 'Python'),
Rust: parse(rust, 'Rust'),
Sass: parse(sass, 'Sass'),
Threedotjs: parse(threedotjs, 'Threedotjs'),
Typescript: parse(typescript, 'Typescript'),
Vuedotjs: parse(vuedotjs, 'Vuedotjs'),
};
+45
View File
@@ -0,0 +1,45 @@
/**
* Nova Design System metadata - never hard-coded again.
*
* - The component list (and therefore the count) is parsed from the installed
* package's export index at build time: what npm ships is what we display.
* - The version is fetched from the npm registry at build time so the site
* shows the LATEST published release even if the local dependency lags;
* if the registry is unreachable, we fall back to the installed version.
*/
import indexRaw from '../../node_modules/@unkn0wndo3s/nova-design-system/src/components/index.ts?raw';
import pkgRaw from '../../node_modules/@unkn0wndo3s/nova-design-system/package.json?raw';
export const NDS_PACKAGE = '@unkn0wndo3s/nova-design-system';
/** Every exported component name, alphabetical. */
export const NDS_COMPONENTS: string[] = [...indexRaw.matchAll(/export \{ default as (\w+) \}/g)]
.map((m) => m[1]!)
.sort((a, b) => a.localeCompare(b));
export const NDS_COMPONENT_COUNT = NDS_COMPONENTS.length;
export const NDS_INSTALLED_VERSION: string = (JSON.parse(pkgRaw) as { version: string }).version;
let cached: string | null = null;
/** Latest published version (npm registry, build-time), falling back to the installed one. */
export async function getNdsVersion(): Promise<string> {
if (cached) return cached;
try {
const res = await fetch(`https://registry.npmjs.org/${NDS_PACKAGE}/latest`, {
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
const { version } = (await res.json()) as { version: string };
if (version) {
cached = version;
return version;
}
}
} catch {
/* offline build - installed version is the best truth we have */
}
cached = NDS_INSTALLED_VERSION;
return cached;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Scroll-scrub helper for pinned scenes.
*
* A scene = a tall section with a sticky 100vh stage inside. This maps the
* section's traversal to a 0..1 progress each frame (cheap: only while the
* section is near the viewport). Works natively with Lenis since Lenis drives
* real window scrolling.
*/
export function scrub(section: HTMLElement, cb: (p: number) => void): () => void {
let raf = 0;
let lastP = -1;
const loop = () => {
const rect = section.getBoundingClientRect();
const vh = window.innerHeight;
if (rect.bottom > -vh && rect.top < vh * 2) {
const total = rect.height - vh;
const p = total > 0 ? Math.min(1, Math.max(0, -rect.top / total)) : 1;
if (p !== lastP) {
lastP = p;
cb(p);
}
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}
export const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/** Progress remapped to a [a,b] window with smoothstep easing. */
export const window01 = (p: number, a: number, b: number) => {
const t = clamp01((p - a) / (b - a));
return t * t * (3 - 2 * t);
};
+97
View File
@@ -0,0 +1,97 @@
/**
* JSON-LD builders. Each returns a plain object serialized by the layout.
* Kept framework-agnostic so it can be unit-tested without Astro.
*/
import { site } from '../data/site';
import type { Project } from '../data/projects';
import type { Locale } from '../i18n';
const SITE_URL = 'https://louis-potevin.dev';
const PERSON_ID = `${SITE_URL}/#person`;
export function personLd(locale: Locale) {
return {
'@context': 'https://schema.org',
'@type': 'Person',
'@id': PERSON_ID,
name: site.name,
jobTitle: locale === 'fr' ? 'Développeur full-stack' : 'Full-stack Developer',
url: SITE_URL,
email: `mailto:${site.email}`,
address: {
'@type': 'PostalAddress',
addressLocality: site.location.city,
addressRegion: site.location.region,
addressCountry: site.location.country,
},
knowsAbout: [
'Web development',
'TypeScript',
'JavaScript',
'Astro',
'Vue.js',
'Node.js',
'Sass',
'PostgreSQL',
'Docker',
'CI/CD',
'Rust',
],
knowsLanguage: ['fr', 'en'],
sameAs: Object.values(site.links).map((l) => l.url),
};
}
export function webSiteLd() {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': `${SITE_URL}/#website`,
name: `${site.name} - Portfolio`,
url: SITE_URL,
inLanguage: ['fr-FR', 'en-US'],
publisher: { '@id': PERSON_ID },
};
}
export interface Crumb {
name: string;
url: string;
}
export function breadcrumbLd(crumbs: Crumb[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: crumbs.map((c, i) => ({
'@type': 'ListItem',
position: i + 1,
name: c.name,
item: new URL(c.url, SITE_URL).href,
})),
};
}
export function projectLd(project: Project, locale: Locale, pageUrl: string) {
return {
'@context': 'https://schema.org',
'@type': 'SoftwareSourceCode',
name: project.name,
description: project.summary[locale],
url: pageUrl,
codeRepository: project.links[0]?.url,
programmingLanguage: project.stack.map((s) => s.name),
author: { '@id': PERSON_ID },
inLanguage: locale === 'fr' ? 'fr-FR' : 'en-US',
};
}
export function profilePageLd(pageUrl: string) {
return {
'@context': 'https://schema.org',
'@type': 'ProfilePage',
url: pageUrl,
mainEntity: { '@id': PERSON_ID },
};
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Smooth scroll (Lenis), shared as a singleton.
*
* Everything animated on the site reads scroll from Lenis so motion stays
* on a single easing curve - that is what makes scrolling feel like one
* continuous animation instead of a page moving.
*/
import Lenis from 'lenis';
let lenis: Lenis | null = null;
let rafId = 0;
export function prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** Idempotent. Returns null when the user prefers reduced motion. */
export function ensureSmooth(): Lenis | null {
if (prefersReducedMotion()) return null;
if (lenis) return lenis;
lenis = new Lenis({
lerp: 0.09,
wheelMultiplier: 0.95,
// Touch keeps native scrolling; the scenes still read positions each frame.
});
const raf = (time: number) => {
lenis!.raf(time);
rafId = requestAnimationFrame(raf);
};
rafId = requestAnimationFrame(raf);
return lenis;
}
export function getSmooth(): Lenis | null {
return lenis;
}
export function destroySmooth(): void {
if (rafId) cancelAnimationFrame(rafId);
lenis?.destroy();
lenis = null;
rafId = 0;
}
+49
View File
@@ -0,0 +1,49 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { Button } from '@unkn0wndo3s/nova-design-system';
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
---
<BaseLayout title={t('notfound.title')} description={t('notfound.body')}>
<section class="notfound">
<p class="code" aria-hidden="true">404</p>
<h1>{t('notfound.title')}</h1>
<p class="body">{t('notfound.body')}</p>
<Button type="primary" href={localizePath('/', locale)}>{t('notfound.back')}</Button>
</section>
</BaseLayout>
<style lang="scss">
@use '../styles/type' as type;
.notfound {
display: grid;
justify-items: center;
text-align: center;
gap: var(--nds-spacing-md);
padding-block: var(--nds-spacing-3xl);
}
.code {
@include type.text-5xl;
margin: 0;
color: var(--nds-accent);
font-variant-numeric: tabular-nums;
}
h1 {
@include type.text-3xl;
margin: 0;
color: var(--nds-text);
}
.body {
@include type.text-base;
margin: 0;
max-width: 34rem;
color: var(--nds-neutral);
}
</style>
+2 -312
View File
@@ -1,315 +1,5 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import AboutView from '../views/AboutView.astro';
import Eyebrow from '../components/Eyebrow.astro';
import TechIcon from '../components/TechIcon.astro';
import BrandLinkedin from '../components/BrandLinkedin.astro';
import {
Breadcrumb,
BreadcrumbItem,
Badge,
Button,
Link,
ListItem,
ListItemTitle,
ListItemSubtitle,
} from '@unkn0wndo3s/nova-design-system';
import { Send, Mail } from '@lucide/astro';
import { site } from '../data/site';
const title = 'About — Louis Potevin, full-stack web developer / développeur full-stack';
const description =
"Louis Potevin, full-stack web developer with a BUT MMI degree (DWDI track). 18 months of professional experience at Legrand France. Available for a permanent contract (CDI), anywhere in France or remote.";
const parcours = [
{
year: '2024 — 2026',
title: 'Professional Experience · Legrand France',
body: "18 months total: 12 weeks of internship, 1 month on a fixed-term contract (CDD), followed by 1 year of work-study apprenticeship. Web development on a production-ready internal corporate application.",
},
{
year: '2024 — 2026',
title: 'BUT MMI — DWDI Track · IUT du Limousin',
body: "Multimedia and Internet Job Degrees (Métiers du Multimédia et de l'Internet), specializing in Web Development and Interactive Devices (DWDI). Degree completed in 2026.",
},
{
year: '2026 — ongoing',
title: 'Personal Projects',
body: "Designing an open-source design system published on npm, managing a self-hosted server infrastructure, and building system tools with Rust and Python.",
},
];
const stack = [
{ icon: 'astro', label: 'Astro' },
{ icon: 'typescript', label: 'TypeScript' },
{ icon: 'javascript', label: 'JavaScript' },
{ icon: 'sass', label: 'Sass' },
{ icon: 'postgresql', label: 'PostgreSQL' },
{ icon: 'docker', label: 'Docker' },
{ icon: 'linux', label: 'Linux' },
{ icon: 'cloudflare', label: 'Cloudflare' },
{ icon: 'gitea', label: 'Gitea' },
{ icon: 'rust', label: 'Rust' },
{ icon: 'python', label: 'Python' },
{ icon: 'git', label: 'Git' },
];
--- ---
<BaseLayout title={title} description={description}> <AboutView />
<section class="head">
<Breadcrumb>
<BreadcrumbItem href="/">Home</BreadcrumbItem>
<BreadcrumbItem href="/about" current>About</BreadcrumbItem>
</Breadcrumb>
<Eyebrow index="//" label="About" />
<h1 class="head__title">About me</h1>
<p class="head__lede">
Full-stack developer based in {site.location}. I design complete web applications,
from front-end interfaces to back-end architecture, and manage their deployment into production.
</p>
</section>
<div class="layout">
<div class="layout__main">
<section class="block">
<Eyebrow index="01" label="Overview" />
<p class="prose">
I graduated with a BUT MMI degree, specializing in Web Development and Interactive
Devices (DWDI) at the IUT du Limousin. I solidified my practical engineering skills
in the industry: spending 18 months at Legrand France split between a 12-week internship,
a 1-month contract, and a full year as a software apprentice, working on a business-critical
production application.
</p>
<p class="prose">
Joining Legrand as an apprentice without knowing anyone prior, I quickly integrated
into the team and built strong working relationships across departments. I am equally
comfortable collaborating in agile team structures as I am working independently, and I excel
at adapting swiftly to new technical environments.
</p>
<p class="prose">
Outside of my professional employment, I build and maintain my own digital infrastructure:
a modular design system published on npm, a private self-hosted suite of services, and custom
automation tools written in Rust and Python. This is my preferred way of keeping up with industry
standards and thoroughly exploring engineering topics end-to-end.
</p>
</section>
<section class="block">
<Eyebrow index="02" label="Timeline" />
<ol class="timeline">
{
parcours.map((p) => (
<li class="timeline__item">
<span class="timeline__year">{p.year}</span>
<div class="timeline__card">
<ListItem>
<ListItemTitle>{p.title}</ListItemTitle>
<ListItemSubtitle>{p.body}</ListItemSubtitle>
</ListItem>
</div>
</li>
))
}
</ol>
</section>
<section class="block">
<Eyebrow index="03" label="Toolkit" />
<ul class="chips">
{
stack.map((s) => (
<li class="chip">
<TechIcon name={s.icon} size={15} />
{s.label}
</li>
))
}
</ul>
</section>
</div>
<aside class="layout__side">
<div class="panel panel--accent">
<p class="panel__title">Status</p>
<p class="panel__big">{site.availability}</p>
<div class="panel__badge">
<Badge type="success" variant="soft">Available</Badge>
</div>
<p class="panel__mobility">{site.mobility}.</p>
<Button type="primary" href="/contact">
<Send slot="icon-left" size={15} />
Get in touch
</Button>
</div>
<div class="panel">
<p class="panel__title">Profiles</p>
<ul class="links">
<li>
<Link url={site.links.github.url} blank>
<TechIcon name="github" size={15} /> GitHub
</Link>
</li>
<li>
<Link url={site.links.gitea.url} blank>
<TechIcon name="gitea" size={15} /> Gitea
</Link>
</li>
<li>
<Link url={site.links.npm.url} blank>
<TechIcon name="npm" size={15} /> npm
</Link>
</li>
<li>
<Link url={site.links.linkedin.url} blank>
<BrandLinkedin size={15} /> LinkedIn
</Link>
</li>
<li>
<Link url={`mailto:${site.email}`}>
<Mail size={15} /> Email
</Link>
</li>
</ul>
</div>
</aside>
</div>
</BaseLayout>
<style lang="scss">
@use 'styles/type' as *;
.head {
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-xl);
max-width: 48rem;
}
.head__title {
@include text-4xl;
color: var(--nds-text);
margin: var(--nds-spacing-sm) 0 var(--nds-spacing-md);
}
.head__lede {
@include text-lg;
color: var(--nds-neutral);
margin: 0;
}
.layout {
display: grid;
grid-template-columns: 1.7fr 1fr;
gap: var(--nds-spacing-2xl);
align-items: start;
padding-bottom: var(--nds-spacing-3xl);
}
.block { margin-bottom: var(--nds-spacing-2xl); }
.prose {
@include text-base;
color: var(--nds-neutral);
margin: 0 0 var(--nds-spacing-md);
max-width: 42rem;
}
/* Timeline */
.timeline {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--nds-spacing-md);
}
.timeline__item {
display: grid;
grid-template-columns: 130px 1fr;
gap: var(--nds-spacing-md);
align-items: start;
}
.timeline__year {
font-family: var(--nds-font-mono);
font-size: 0.75rem;
color: var(--nds-accent);
padding-top: var(--nds-spacing-xs);
letter-spacing: 0.04em;
}
.timeline__card :global(.list-item) {
border: var(--nds-border-width-thin) solid var(--nds-border);
border-radius: var(--nds-radius-lg);
background: color-mix(in srgb, var(--nds-surface) 64%, transparent);
backdrop-filter: blur(6px);
padding: var(--nds-spacing-md);
}
.chips {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--nds-spacing-2xs);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--nds-spacing-2xs);
padding: 4px var(--nds-spacing-xs);
border-radius: var(--nds-radius-full);
border: var(--nds-border-width-thin) solid var(--nds-border);
background: var(--nds-surface-2);
color: var(--nds-neutral);
font-family: var(--nds-font-mono);
font-size: 0.6875rem;
}
/* Side panels */
.layout__side {
display: flex;
flex-direction: column;
gap: var(--nds-spacing-md);
position: sticky;
top: calc(64px + var(--nds-spacing-md));
}
.panel {
border: var(--nds-border-width-thin) solid var(--nds-border);
border-radius: var(--nds-radius-lg);
background: color-mix(in srgb, var(--nds-surface) 70%, transparent);
backdrop-filter: blur(8px);
padding: var(--nds-spacing-lg);
&--accent {
border-color: color-mix(in srgb, var(--nds-primary) 38%, var(--nds-border));
}
}
.panel__title {
font-family: var(--nds-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--nds-neutral);
margin: 0 0 var(--nds-spacing-sm);
}
.panel__big {
@include text-lg;
color: var(--nds-text);
margin: 0 0 var(--nds-spacing-sm);
}
.panel__badge { margin-bottom: var(--nds-spacing-sm); }
.panel__mobility {
@include text-sm;
color: var(--nds-neutral);
margin: 0 0 var(--nds-spacing-lg);
}
.links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--nds-spacing-2xs);
}
@media (max-width: 820px) {
.layout { grid-template-columns: 1fr; gap: var(--nds-spacing-xl); }
.layout__side { position: static; }
}
@media (max-width: 540px) {
.timeline__item { grid-template-columns: 1fr; gap: var(--nds-spacing-2xs); }
}
</style>
+2 -294
View File
@@ -1,297 +1,5 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import ContactView from '../views/ContactView.astro';
import Eyebrow from '../components/Eyebrow.astro';
import TechIcon from '../components/TechIcon.astro';
import {
Breadcrumb,
BreadcrumbItem,
TextField,
Select,
SelectOption,
Button,
Badge,
} from '@unkn0wndo3s/nova-design-system';
import { Send, Mail } from '@lucide/astro';
import BrandLinkedin from '../components/BrandLinkedin.astro';
import { site } from '../data/site';
const title = 'Contact — Louis Potevin';
const description =
'Contact Louis Potevin, full-stack web developer. Available for permanent positions (CDI) starting September 2026, anywhere in France or remote.';
const directLinks = [
{ kind: 'mail', label: site.email, url: `mailto:${site.email}` },
{ kind: 'linkedin', label: 'LinkedIn', url: site.links.linkedin.url },
{ kind: 'gitea', label: 'Gitea', url: site.links.gitea.url },
{ kind: 'github', label: 'GitHub', url: site.links.github.url },
];
--- ---
<BaseLayout title={title} description={description}> <ContactView />
<section class="head">
<Breadcrumb>
<BreadcrumbItem href="/">Home</BreadcrumbItem>
<BreadcrumbItem href="/contact" current>Contact</BreadcrumbItem>
</Breadcrumb>
<Eyebrow index="//" label="Connect" />
<h1 class="head__title">Get in touch</h1>
<p class="head__lede">
An opening, a project, or just a question? Email is the most direct
channel, but the form works just as well.
</p>
<div class="head__avail">
<Badge type="success" variant="soft">Available</Badge>
<span>{site.availability} · {site.mobility}</span>
</div>
</section>
<div class="layout">
{/* ── Form (Nova components) ── */}
<section class="formWrap" aria-labelledby="form-title">
<h2 id="form-title" class="formWrap__title">Message</h2>
<div class="form" id="contact-form">
<TextField id="cf-name" label="Name" type="text" placeholder="Your name" />
<TextField
id="cf-email"
label="Email"
type="email"
placeholder="you@example.com"
/>
<div class="form__field">
<label class="form__label" for="cf-subject">Subject</label>
<Select default="Job Opportunity (CDI)">
<SelectOption value="Job Opportunity (CDI)">
Job Opportunity (CDI)
</SelectOption>
<SelectOption value="Freelance / Contract">
Freelance / Contract
</SelectOption>
<SelectOption value="Project Inquiry">
Project Inquiry
</SelectOption>
<SelectOption value="Other">Other</SelectOption>
</Select>
</div>
<TextField
id="cf-message"
label="Your message"
type="textarea"
placeholder="A few lines about your needs…"
max={800}
/>
<div class="form__actions">
<Button type="primary" size="lg" htmlType="button" id="cf-send">
<Send slot="icon-left" size={16} />
Send message
</Button>
<p class="form__hint" id="cf-hint">
Opens your mail client with a pre-filled draft.
</p>
</div>
</div>
</section>
{/* ── Direct Links ── */}
<aside class="direct" aria-label="Direct channels">
<p class="direct__title">Direct Channels</p>
<ul class="direct__list">
{
directLinks.map((l) => (
<li>
<a
class="direct__item"
href={l.url}
{...(l.kind !== 'mail'
? { target: '_blank', rel: 'noopener noreferrer' }
: {})}
>
<span class="direct__icon">
{l.kind === 'mail' && <Mail size={17} />}
{l.kind === 'linkedin' && <BrandLinkedin size={17} />}
{l.kind === 'gitea' && <TechIcon name="gitea" size={17} />}
{l.kind === 'github' && <TechIcon name="github" size={17} />}
</span>
<span class="direct__label">{l.label}</span>
</a>
</li>
))
}
</ul>
<div class="direct__loc">
<p class="direct__locTitle">Location</p>
<p class="direct__locValue">{site.location}</p>
</div>
</aside>
</div>
</BaseLayout>
<script define:vars={{ email: site.email }}>
const send = document.getElementById('cf-send');
send?.addEventListener('click', () => {
const val = (id) => document.querySelector(`#${id} input, #${id} textarea`)?.value ?? '';
const name = val('cf-name');
const from = val('cf-email');
const subjectEl = document.querySelector('.select__label');
const subject = subjectEl?.textContent?.trim() || 'Inquiry';
const message = val('cf-message');
const body = `${message}\n\n— ${name}${from ? ` (${from})` : ''}`;
const href = `mailto:${email}?subject=${encodeURIComponent(
'[Portfolio] ' + subject,
)}&body=${encodeURIComponent(body)}`;
window.location.href = href;
});
</script>
<style lang="scss">
@use 'styles/type' as *;
.head {
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-xl);
max-width: 48rem;
}
.head__title {
@include text-4xl;
color: var(--nds-text);
margin: var(--nds-spacing-sm) 0 var(--nds-spacing-md);
}
.head__lede {
@include text-lg;
color: var(--nds-neutral);
margin: 0 0 var(--nds-spacing-md);
}
.head__avail {
display: flex;
align-items: center;
gap: var(--nds-spacing-xs);
font-size: 0.8125rem;
color: var(--nds-neutral);
font-family: var(--nds-font-mono);
}
.layout {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: var(--nds-spacing-2xl);
align-items: start;
padding-bottom: var(--nds-spacing-3xl);
}
.formWrap {
border: var(--nds-border-width-thin) solid var(--nds-border);
border-radius: var(--nds-radius-xl);
background: color-mix(in srgb, var(--nds-surface) 72%, transparent);
backdrop-filter: blur(10px);
padding: var(--nds-spacing-xl);
}
.formWrap__title {
@include text-xl;
color: var(--nds-text);
margin: 0 0 var(--nds-spacing-lg);
}
.form {
display: flex;
flex-direction: column;
gap: var(--nds-spacing-md);
}
.form__field {
display: flex;
flex-direction: column;
gap: var(--nds-spacing-2xs);
}
.form__label {
@include text-label;
color: var(--nds-text);
}
.form__actions {
display: flex;
align-items: center;
gap: var(--nds-spacing-md);
flex-wrap: wrap;
margin-top: var(--nds-spacing-2xs);
}
.form__hint {
@include text-sm;
color: var(--nds-neutral);
margin: 0;
}
.direct {
border: var(--nds-border-width-thin) solid var(--nds-border);
border-radius: var(--nds-radius-xl);
background: color-mix(in srgb, var(--nds-surface) 60%, transparent);
backdrop-filter: blur(8px);
padding: var(--nds-spacing-lg);
position: sticky;
top: calc(64px + var(--nds-spacing-md));
}
.direct__title {
font-family: var(--nds-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--nds-neutral);
margin: 0 0 var(--nds-spacing-sm);
}
.direct__list {
list-style: none;
margin: 0 0 var(--nds-spacing-lg);
padding: 0;
display: flex;
flex-direction: column;
gap: var(--nds-spacing-2xs);
}
.direct__item {
display: flex;
align-items: center;
gap: var(--nds-spacing-sm);
padding: var(--nds-spacing-sm);
border-radius: var(--nds-radius-md);
border: var(--nds-border-width-thin) solid transparent;
color: var(--nds-neutral);
text-decoration: none;
transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
&:hover {
color: var(--nds-text);
border-color: color-mix(in srgb, var(--nds-primary) 40%, var(--nds-border));
background: var(--nds-surface-2);
}
}
.direct__icon {
display: grid;
place-items: center;
color: var(--nds-primary);
}
.direct__label {
@include text-sm;
font-family: var(--nds-font-mono);
}
.direct__loc {
padding-top: var(--nds-spacing-md);
border-top: var(--nds-border-width-thin) solid var(--nds-border);
}
.direct__locTitle {
font-family: var(--nds-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--nds-neutral);
margin: 0 0 var(--nds-spacing-2xs);
}
.direct__locValue {
@include text-base;
color: var(--nds-text);
margin: 0;
}
/* ── RESPONSIVE ── */
@media (max-width: 820px) {
.layout { grid-template-columns: 1fr; gap: var(--nds-spacing-xl); }
.direct { position: static; }
}
</style>
+5
View File
@@ -0,0 +1,5 @@
---
import DocsView from '../views/DocsView.astro';
---
<DocsView />
+5
View File
@@ -0,0 +1,5 @@
---
import AboutView from '../../views/AboutView.astro';
---
<AboutView />
+5
View File
@@ -0,0 +1,5 @@
---
import ContactView from '../../views/ContactView.astro';
---
<ContactView />
+5
View File
@@ -0,0 +1,5 @@
---
import DocsView from '../../views/DocsView.astro';
---
<DocsView />
+5
View File
@@ -0,0 +1,5 @@
---
import HomeView from '../../views/HomeView.astro';
---
<HomeView />
+12
View File
@@ -0,0 +1,12 @@
---
import ProjectView from '../../../views/ProjectView.astro';
import { projects } from '../../../data/projects';
export function getStaticPaths() {
return projects.map((project) => ({ params: { slug: project.slug }, props: { project } }));
}
const { project } = Astro.props;
---
<ProjectView project={project} />
+5
View File
@@ -0,0 +1,5 @@
---
import WorkIndexView from '../../../views/WorkIndexView.astro';
---
<WorkIndexView />
+2 -415
View File
@@ -1,418 +1,5 @@
--- ---
import { Button, Badge } from '@unkn0wndo3s/nova-design-system'; import HomeView from '../views/HomeView.astro';
import { ArrowUpRight, Send } from '@lucide/astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import Eyebrow from '../components/Eyebrow.astro';
import ProjectCard from '../components/ProjectCard.astro';
import TechIcon from '../components/TechIcon.astro';
import { site } from '../data/site';
import { featuredProjects } from '../data/projects';
const capabilities = [
{
label: 'Front-end',
body: 'Sleek and accessible interfaces built with Astro, TypeScript, and Sass — all the way to an in-house design system.',
},
{
label: 'Back-end',
body: 'Server-side services and APIs, data modeling, and business logic using Node and PostgreSQL.',
},
{
label: 'Tools & deployment',
body: 'Docker containerization, automated CI/CD, and end-to-end production deployment.',
},
];
const instruments = [
{ icon: 'astro', label: 'Astro' },
{ icon: 'typescript', label: 'TypeScript' },
{ icon: 'javascript', label: 'JavaScript' },
{ icon: 'sass', label: 'Sass' },
{ icon: 'postgresql', label: 'PostgreSQL' },
{ icon: 'docker', label: 'Docker' },
{ icon: 'linux', label: 'Linux' },
{ icon: 'cloudflare', label: 'Cloudflare' },
{ icon: 'gitea', label: 'Gitea CI/CD' },
{ icon: 'rust', label: 'Rust' },
{ icon: 'python', label: 'Python' },
{ icon: 'git', label: 'Git' },
];
--- ---
<BaseLayout> <HomeView />
{/* ─────────────────────────── HERO ─────────────────────────── */}
<section class="hero" aria-labelledby="hero-title">
<div class="hero__status">
<span class="hero__dot" aria-hidden="true"></span>
<span class="hero__statusText">Available · {site.availability}</span>
</div>
<h1 id="hero-title" class="hero__title">
Louis Potevin
<span class="hero__role">Full-stack developer</span>
</h1>
<p class="hero__lede">
I design and develop web applications from end to end — from front-end
to back-end and production deployment. I am equally comfortable working
on the user interface as I am with the services and infrastructure running them.
</p>
<div class="hero__cta">
<Button type="primary" size="lg" href="/work">
View projects
<ArrowUpRight slot="icon-right" size={18} />
</Button>
<Button type="ghost" size="lg" href="/contact">
<Send slot="icon-left" size={16} />
Contact me
</Button>
</div>
<div class="hero__avail">
<Badge type="success" variant="soft">Available</Badge>
<span class="hero__availText">{site.availability}</span>
<span class="hero__sep" aria-hidden="true">·</span>
<span class="hero__zones">{site.mobility}</span>
</div>
</section>
{/* ─────────────────────── PROFILE / CAPABILITIES ─────────────────────── */}
<section class="band" aria-labelledby="profile-title">
<Eyebrow index="01" label="Profile" />
<div class="band__split">
<h2 id="profile-title" class="band__lead">
A full-stack profile, comfortable across the entire stack.
</h2>
<div class="band__body">
<p>
I have hands-on experience running business applications in production while
simultaneously developing my own side projects: a design system published
on npm, a self-hosted infrastructure, and tools built with Rust and Python.
</p>
<p>
What drives me: mastering the entire cycle — the component you look at,
the service that delivers it, and the CI that deploys it. This website is
a prime example, built entirely on top of my own design system.
</p>
</div>
</div>
<ul class="caps" aria-label="Expertise">
{
capabilities.map((c) => (
<li class="cap">
<span class="cap__label">{c.label}</span>
<p class="cap__body">{c.body}</p>
</li>
))
}
</ul>
</section>
{/* ─────────────────────────── FEATURED PROJECTS ─────────────────────────── */}
<section class="band" aria-labelledby="proj-title">
<Eyebrow index="02" label="Featured projects" />
<div class="band__head">
<h2 id="proj-title" class="band__lead">Projects</h2>
<a class="band__all" href="/work">
All projects <ArrowUpRight size={15} />
</a>
</div>
<div class="grid">
{featuredProjects.map((p) => <ProjectCard project={p} />)}
</div>
</section>
{/* ─────────────────────────── STACK ─────────────────────────── */}
<section class="band" aria-labelledby="stack-title">
<Eyebrow index="03" label="Tech stack" />
<h2 id="stack-title" class="band__lead band__lead--center">
My everyday tools.
</h2>
<ul class="instruments" aria-label="Core technologies">
{
instruments.map((t) => (
<li class="instruments__item">
<span class="instruments__icon">
<TechIcon name={t.icon} size={22} />
</span>
<span class="instruments__label">{t.label}</span>
</li>
))
}
</ul>
</section>
{/* ─────────────────────────── CONTACT CTA ─────────────────────────── */}
<section class="closing" aria-labelledby="cta-title">
<p class="closing__eyebrow">Available — {site.availability}</p>
<h2 id="cta-title" class="closing__title">Let's talk!</h2>
<p class="closing__text">
I am looking for a full-stack permanent position (CDI) starting September 2026.
{' '}{site.mobility}.
</p>
<div class="closing__cta">
<Button type="primary" size="lg" href="/contact">
<Send slot="icon-left" size={16} />
Contact me
</Button>
<Button type="ghost" size="lg" href="/about">
Learn more
<ArrowUpRight slot="icon-right" size={16} />
</Button>
</div>
</section>
</BaseLayout>
<style lang="scss">
@use 'styles/type' as *;
/* ── HERO ── */
.hero {
min-height: clamp(520px, 80vh, 820px);
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--nds-spacing-md);
padding: var(--nds-spacing-3xl) 0 var(--nds-spacing-2xl);
max-width: 56rem;
}
.hero__status {
display: inline-flex;
align-items: center;
gap: var(--nds-spacing-xs);
align-self: flex-start;
padding: var(--nds-spacing-2xs) var(--nds-spacing-sm);
border: var(--nds-border-width-thin) solid var(--nds-border-strong);
border-radius: var(--nds-radius-full);
background: color-mix(in srgb, var(--nds-surface) 60%, transparent);
backdrop-filter: blur(6px);
}
.hero__dot {
width: 7px;
height: 7px;
border-radius: var(--nds-radius-full);
background: var(--nds-primary);
box-shadow: 0 0 10px var(--nds-primary);
animation: pulse 2.4s ease-in-out infinite;
}
.hero__statusText {
font-family: var(--nds-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.1em;
color: var(--nds-neutral);
}
.hero__title {
@include text-5xl;
margin: var(--nds-spacing-sm) 0 0;
color: var(--nds-text);
display: flex;
flex-direction: column;
gap: var(--nds-spacing-2xs);
}
.hero__role {
@include text-2xl;
color: var(--nds-primary);
font-family: var(--nds-font-mono);
font-weight: 500;
}
.hero__lede {
@include text-lg;
color: var(--nds-neutral);
max-width: 44rem;
margin: var(--nds-spacing-xs) 0 0;
}
.hero__cta {
display: flex;
flex-wrap: wrap;
gap: var(--nds-spacing-sm);
margin-top: var(--nds-spacing-md);
}
.hero__avail {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--nds-spacing-xs);
margin-top: var(--nds-spacing-sm);
font-size: 0.8125rem;
color: var(--nds-neutral);
}
.hero__availText { color: var(--nds-text); }
.hero__sep { color: var(--nds-border-strong); }
.hero__zones {
font-family: var(--nds-font-mono);
font-size: 0.75rem;
}
/* ── SECTION BANDS ── */
.band { padding: var(--nds-spacing-2xl) 0; }
.band__lead {
@include text-3xl;
color: var(--nds-text);
margin: 0;
max-width: 32rem;
&--center {
max-width: none;
text-align: center;
margin-bottom: var(--nds-spacing-2xl);
}
}
.band__split {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: var(--nds-spacing-2xl);
align-items: start;
}
.band__body {
display: flex;
flex-direction: column;
gap: var(--nds-spacing-md);
p {
@include text-base;
color: var(--nds-neutral);
margin: 0;
}
}
.band__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--nds-spacing-md);
margin-bottom: var(--nds-spacing-xl);
}
.band__all {
display: inline-flex;
align-items: center;
gap: var(--nds-spacing-2xs);
font-family: var(--nds-font-mono);
font-size: 0.8125rem;
color: var(--nds-primary);
text-decoration: none;
white-space: nowrap;
&:hover { color: var(--nds-accent); }
}
/* ── Capabilities ── */
.caps {
list-style: none;
margin: var(--nds-spacing-2xl) 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--nds-spacing-lg);
}
.cap {
border: var(--nds-border-width-thin) solid var(--nds-border);
border-radius: var(--nds-radius-lg);
background: color-mix(in srgb, var(--nds-surface) 64%, transparent);
backdrop-filter: blur(6px);
padding: var(--nds-spacing-lg);
}
.cap__label {
font-family: var(--nds-font-mono);
font-size: 0.8125rem;
font-weight: 700;
color: var(--nds-primary);
letter-spacing: 0.02em;
}
.cap__body {
@include text-sm;
color: var(--nds-neutral);
margin: var(--nds-spacing-xs) 0 0;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--nds-spacing-lg);
}
/* ── INSTRUMENTS ── */
.instruments {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: var(--nds-spacing-sm);
}
.instruments__item {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--nds-spacing-xs);
padding: var(--nds-spacing-lg) var(--nds-spacing-sm);
border: var(--nds-border-width-thin) solid var(--nds-border);
border-radius: var(--nds-radius-lg);
background: color-mix(in srgb, var(--nds-surface) 64%, transparent);
backdrop-filter: blur(6px);
transition: border-color 180ms ease, transform 180ms ease, color 180ms ease;
color: var(--nds-neutral);
&:hover {
border-color: color-mix(in srgb, var(--nds-primary) 50%, var(--nds-border));
transform: translateY(-2px);
color: var(--nds-primary);
}
}
.instruments__icon { display: grid; place-items: center; color: inherit; }
.instruments__label {
font-family: var(--nds-font-mono);
font-size: 0.6875rem;
color: var(--nds-neutral);
}
/* ── CLOSING ── */
.closing {
text-align: center;
padding: var(--nds-spacing-3xl) 0;
max-width: 40rem;
margin: 0 auto;
}
.closing__eyebrow {
font-family: var(--nds-font-mono);
font-size: 0.75rem;
letter-spacing: 0.12em;
color: var(--nds-accent);
margin: 0 0 var(--nds-spacing-md);
}
.closing__title {
@include text-4xl;
color: var(--nds-text);
margin: 0 0 var(--nds-spacing-md);
}
.closing__text {
@include text-lg;
color: var(--nds-neutral);
margin: 0 0 var(--nds-spacing-xl);
}
.closing__cta {
display: flex;
flex-wrap: wrap;
gap: var(--nds-spacing-sm);
justify-content: center;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.hero__dot { animation: none; }
}
/* ── RESPONSIVE ── */
@media (max-width: 900px) {
.band__split { grid-template-columns: 1fr; gap: var(--nds-spacing-lg); }
.caps { grid-template-columns: 1fr; }
.grid { grid-template-columns: repeat(2, 1fr); }
.instruments { grid-template-columns: repeat(4, 1fr); }
}
@media (max-width: 620px) {
.grid { grid-template-columns: 1fr; }
.instruments { grid-template-columns: repeat(3, 1fr); }
.hero__cta :global(.button) { width: 100%; }
}
</style>
+4 -297
View File
@@ -1,305 +1,12 @@
--- ---
import BaseLayout from '../../layouts/BaseLayout.astro'; import ProjectView from '../../views/ProjectView.astro';
import Eyebrow from '../../components/Eyebrow.astro'; import { projects } from '../../data/projects';
import TechIcon from '../../components/TechIcon.astro';
import {
Breadcrumb,
BreadcrumbItem,
Badge,
Button,
Link,
} from '@unkn0wndo3s/nova-design-system';
import { ArrowUpRight, ArrowLeft } from '@lucide/astro';
import { projects, statusMeta, type Project } from '../../data/projects';
export function getStaticPaths() { export function getStaticPaths() {
return projects.map((project) => ({ return projects.map((project) => ({ params: { slug: project.slug }, props: { project } }));
params: { slug: project.slug },
props: { project },
}));
} }
interface Props {
project: Project;
}
const { project } = Astro.props; const { project } = Astro.props;
const status = statusMeta[project.status];
const title = `${project.title} — Louis Potevin`;
--- ---
<BaseLayout title={title} description={project.tagline}> <ProjectView project={project} />
<article class="detail">
<header class="detail__head">
<Breadcrumb>
<BreadcrumbItem href="/">Home</BreadcrumbItem>
<BreadcrumbItem href="/work">Projects</BreadcrumbItem>
<BreadcrumbItem href={`/work/${project.slug}`} current>
{project.designation}
</BreadcrumbItem>
</Breadcrumb>
<div class="detail__meta">
<span class="detail__code">{project.designation}</span>
<Badge type={status.tone} variant="soft">{status.label}</Badge>
</div>
<h1 class="detail__title">{project.title}</h1>
<p class="detail__tagline">{project.tagline}</p>
<dl class="detail__facts">
<div class="fact">
<dt>Role</dt>
<dd>{project.role}</dd>
</div>
<div class="fact">
<dt>Timeline</dt>
<dd>{project.period}</dd>
</div>
</dl>
</header>
<div class="detail__grid">
<div class="detail__main">
<p class="detail__summary">{project.summary}</p>
<section class="block" aria-labelledby="ctx">
<Eyebrow index="01" label="Context" />
<ul class="ticks">
{project.context.map((c) => <li>{c}</li>)}
</ul>
</section>
<section class="block" aria-labelledby="contrib">
<Eyebrow index="02" label="Contributions" />
<ul class="ticks">
{project.contributions.map((c) => <li>{c}</li>)}
</ul>
</section>
</div>
<aside class="detail__side">
<div class="panel">
<p class="panel__title">Stack</p>
<ul class="chips">
{
project.stack.map((s) => (
<li class="chip">
<TechIcon name={s.icon} size={15} />
{s.label}
</li>
))
}
</ul>
</div>
{
project.links.length > 0 && (
<div class="panel">
<p class="panel__title">Links</p>
<ul class="links">
{project.links.map((l) => (
<li>
<Link url={l.url} blank>
{l.icon && <TechIcon name={l.icon} size={15} />}
{l.label}
<ArrowUpRight size={13} />
</Link>
</li>
))}
</ul>
</div>
)
}
{
project.links.length === 0 && (
<div class="panel panel--muted">
<p class="panel__note">
Internal or private project — no public link available.
</p>
</div>
)
}
</aside>
</div>
<footer class="detail__foot">
<Button type="ghost" href="/work">
<ArrowLeft slot="icon-left" size={16} />
All projects
</Button>
<Button type="primary" href="/contact">
Discuss this project
<ArrowUpRight slot="icon-right" size={16} />
</Button>
</footer>
</article>
</BaseLayout>
<style lang="scss">
@use 'styles/type' as *;
.detail {
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-3xl);
max-width: 64rem;
}
.detail__meta {
display: flex;
align-items: center;
gap: var(--nds-spacing-sm);
margin: var(--nds-spacing-lg) 0 var(--nds-spacing-sm);
}
.detail__code {
font-family: var(--nds-font-mono);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.1em;
color: var(--nds-accent);
}
.detail__title {
@include text-4xl;
color: var(--nds-text);
margin: 0;
}
.detail__tagline {
@include text-lg;
color: var(--nds-neutral);
margin: var(--nds-spacing-sm) 0 0;
}
.detail__facts {
display: flex;
flex-wrap: wrap;
gap: var(--nds-spacing-2xl);
margin: var(--nds-spacing-lg) 0 0;
padding-top: var(--nds-spacing-md);
border-top: var(--nds-border-width-thin) solid var(--nds-border);
}
.fact {
margin: 0;
dt {
font-family: var(--nds-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--nds-neutral);
}
dd {
@include text-base;
color: var(--nds-text);
margin: var(--nds-spacing-3xs) 0 0;
}
}
.detail__grid {
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: var(--nds-spacing-2xl);
margin-top: var(--nds-spacing-2xl);
align-items: start;
}
.detail__summary {
@include text-lg;
color: var(--nds-text);
margin: 0 0 var(--nds-spacing-xl);
}
.block {
margin-bottom: var(--nds-spacing-xl);
}
.ticks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--nds-spacing-sm);
li {
position: relative;
padding-left: var(--nds-spacing-md);
@include text-base;
color: var(--nds-neutral);
&::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 6px;
height: 6px;
transform: rotate(45deg);
background: var(--nds-primary);
}
}
}
.detail__side {
display: flex;
flex-direction: column;
gap: var(--nds-spacing-md);
position: sticky;
top: calc(64px + var(--nds-spacing-md));
}
.panel {
border: var(--nds-border-width-thin) solid var(--nds-border);
border-radius: var(--nds-radius-lg);
background: color-mix(in srgb, var(--nds-surface) 70%, transparent);
backdrop-filter: blur(8px);
padding: var(--nds-spacing-lg);
&--muted { background: color-mix(in srgb, var(--nds-surface) 40%, transparent); }
}
.panel__title {
font-family: var(--nds-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--nds-neutral);
margin: 0 0 var(--nds-spacing-sm);
}
.panel__note {
@include text-sm;
color: var(--nds-neutral);
margin: 0;
}
.chips {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--nds-spacing-2xs);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--nds-spacing-2xs);
padding: 4px var(--nds-spacing-xs);
border-radius: var(--nds-radius-full);
border: var(--nds-border-width-thin) solid var(--nds-border);
background: var(--nds-surface-2);
color: var(--nds-neutral);
font-family: var(--nds-font-mono);
font-size: 0.6875rem;
}
.links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--nds-spacing-2xs);
}
.detail__foot {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--nds-spacing-sm);
margin-top: var(--nds-spacing-2xl);
padding-top: var(--nds-spacing-lg);
border-top: var(--nds-border-width-thin) solid var(--nds-border);
}
/* ── RESPONSIVE ── */
@media (max-width: 820px) {
.detail__grid { grid-template-columns: 1fr; gap: var(--nds-spacing-xl); }
.detail__side { position: static; }
}
</style>
+2 -59
View File
@@ -1,62 +1,5 @@
--- ---
import BaseLayout from '../../layouts/BaseLayout.astro'; import WorkIndexView from '../../views/WorkIndexView.astro';
import Eyebrow from '../../components/Eyebrow.astro';
import ProjectCard from '../../components/ProjectCard.astro';
import { Breadcrumb, BreadcrumbItem } from '@unkn0wndo3s/nova-design-system';
import { projects } from '../../data/projects';
const title = 'Projects — Louis Potevin, full-stack web developer / développeur full-stack';
const description =
'Projects by Louis Potevin: Astro design system, self-hosted infrastructure, systems tools in Rust, and LLM explorations. Full-stack web developer.';
--- ---
<BaseLayout title={title} description={description}> <WorkIndexView />
<section class="head">
<Breadcrumb>
<BreadcrumbItem href="/">Home</BreadcrumbItem>
<BreadcrumbItem href="/work" current>Projects</BreadcrumbItem>
</Breadcrumb>
<Eyebrow index="//" label="All projects" />
<h1 class="head__title">Projects</h1>
<p class="head__lede">
A showcase of everything I build — from business software running in production to
the infrastructure supporting it, alongside my personal tools.
</p>
</section>
<section class="grid" aria-label="Projects list">
{projects.map((p) => <ProjectCard project={p} />)}
</section>
</BaseLayout>
<style lang="scss">
@use 'styles/type' as *;
.head {
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-xl);
max-width: 48rem;
}
.head__title {
@include text-4xl;
color: var(--nds-text);
margin: var(--nds-spacing-sm) 0 var(--nds-spacing-md);
}
.head__lede {
@include text-lg;
color: var(--nds-neutral);
margin: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--nds-spacing-lg);
padding-bottom: var(--nds-spacing-3xl);
}
@media (max-width: 900px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 620px) {
.grid { grid-template-columns: 1fr; }
}
</style>
+3 -5
View File
@@ -1,5 +1,3 @@
// Re-exports the official typography mixins from Nova DS. // Re-exports NDS typography mixins (text-sm ... text-5xl) + font vars.
// The partial is resolved via a loadPath pointing to the package's // The tokens directory is exposed through a Sass loadPath (see astro.config.mjs).
// style folder (its `exports` field prevents direct access to the subpath). @forward 'typography';
// Usage in a component: @use "styles/type" as *; then @include text-3xl;
@forward "tokens/typography";
+52 -23
View File
@@ -1,44 +1,73 @@
@use 'styles/type' as *; @use './type' as type;
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
} background: var(--nds-background);
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
} }
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100dvh;
color: var(--nds-text); color: var(--nds-text);
font-family: var(--nds-font-sans); font-family: var(--nds-font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; overflow-x: clip;
background-color: var(--nds-background);
} }
::selection { ::selection {
background: color-mix(in srgb, var(--nds-primary) 35%, transparent); background: var(--nds-primary);
color: var(--nds-text); color: var(--nds-on-primary);
} }
* { h1, h2, h3 {
scrollbar-width: thin; font-family: var(--nds-font-mono);
scrollbar-color: var(--nds-border-strong) transparent; text-wrap: balance;
margin: 0;
} }
a { p { margin: 0; }
color: inherit;
}
img { img, svg, canvas { max-width: 100%; height: auto; }
max-width: 100%;
display: block;
}
:focus-visible { :focus-visible {
outline: none; outline: 2px solid var(--nds-primary);
outline-offset: 2px;
}
.section {
padding-block: clamp(64px, 10vh, 128px);
}
.eyebrow-row {
display: flex;
align-items: baseline;
gap: var(--nds-spacing-md);
margin-bottom: var(--nds-spacing-lg);
}
.section-title {
@include type.text-4xl;
font-size: clamp(1.6rem, 3.4vw, 2.4rem);
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ---- Lenis smooth scroll (recommended base styles) ---- */
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
.lenis.lenis-stopped { overflow: hidden; }
/* ---- Cinema mode (home voyage): the footer slides over the last overlay ---- */
html.cinema footer {
position: relative;
z-index: 30;
background: var(--nds-background);
} }
+215
View File
@@ -0,0 +1,215 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Eyebrow from '../components/Eyebrow.astro';
import { Badge, Button } from '@unkn0wndo3s/nova-design-system';
import { breadcrumbLd, profilePageLd } from '../lib/seo';
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
import type { Localized } from '../data/projects';
const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
interface Step {
period: Localized;
role: Localized;
place: string;
detail: Localized;
}
const steps: Step[] = [
{
period: { fr: 'Sept. 2025 - Août 2026', en: 'Sept. 2025 - Aug. 2026' },
role: { fr: 'Développeur full-stack en alternance', en: 'Full-stack developer (apprenticeship)' },
place: 'Legrand France, Limoges',
detail: {
fr: "Développement du DAN, application web interne pour les ateliers de production : refonte du module de gestion des utilisateurs (Vue.js/TypeScript, export CSV) et du module de statistiques avec composants graphiques sur mesure. Back-end Node.js/Express.",
en: 'Development of DAN, an internal web app for production workshops: rework of the user management module (Vue.js/TypeScript, CSV export) and of the statistics module with custom chart components. Node.js/Express back-end.',
},
},
{
period: { fr: 'Avril - Août 2025', en: 'April - Aug. 2025' },
role: { fr: "Stage puis job d'été", en: 'Internship, then summer job' },
place: 'Legrand France, Limoges',
detail: {
fr: "Premiers travaux sur le DAN : montée en compétence sur la base de code, premières fonctionnalités livrées en production. L'expérience s'est prolongée en alternance sur la même application.",
en: 'First work on DAN: ramping up on the codebase, first features shipped to production. The experience continued as an apprenticeship on the same application.',
},
},
{
period: { fr: '2023 - 2026', en: '2023 - 2026' },
role: { fr: 'BUT MMI, parcours Développement Web (DWDI)', en: 'BUT MMI degree, Web Development track (DWDI)' },
place: 'IUT du Limousin',
detail: {
fr: "Formation en développement web et dispositifs interactifs : front-end, back-end, UX, gestion de projet. Diplôme obtenu en 2026.",
en: 'Training in web development and interactive systems: front-end, back-end, UX, project management. Graduating in 2026.',
},
},
];
interface Value {
title: Localized;
body: Localized;
}
const values: Value[] = [
{
title: { fr: 'Livrer, pas prototyper', en: 'Ship, not prototype' },
body: {
fr: 'Un projet est terminé quand il tourne en production avec sa CI. Mon design system est sur npm, mon infra héberge ce site : tout ce que je montre est réellement en service.',
en: 'A project is done when it runs in production with its CI. My design system is on npm, my infrastructure hosts this site: everything I show is actually in service.',
},
},
{
title: { fr: 'Du composant au serveur', en: 'From component to server' },
body: {
fr: "Je conçois l'interface, l'API qui la sert et la machine qui l'héberge. Cette vision complète évite les frictions entre front, back et ops.",
en: 'I design the interface, the API that serves it and the machine that hosts it. That end-to-end view removes friction between front, back and ops.',
},
},
{
title: { fr: 'Réutilisable par défaut', en: 'Reusable by default' },
body: {
fr: "Tokens, composants, pipelines : je factorise ce qui se répète. C'est ce qui m'a mené à construire Nova Design System plutôt que de copier-coller des styles.",
en: 'Tokens, components, pipelines: I factor out what repeats. That mindset is what led me to build Nova Design System instead of copy-pasting styles.',
},
},
];
const crumbs = [
{ name: t('nav.home'), url: localizePath('/', locale) },
{ name: t('nav.about'), url: localizePath('/about', locale) },
];
const pageUrl = new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href;
const jsonLd = [breadcrumbLd(crumbs), profilePageLd(pageUrl)];
---
<BaseLayout title={t('meta.about.title')} description={t('meta.about.description')} jsonLd={jsonLd}>
<header class="page-head">
<h1 class="page-title">{t('about.title')}</h1>
<p class="page-lead">{t('about.lead')}</p>
<p class="availability">
<Badge type="success">{t('avail.badge')}</Badge>
<span>{t('avail.line')} · {t('avail.where')}</span>
</p>
</header>
<section class="section" aria-labelledby="exp-title">
<div class="eyebrow-row"><Eyebrow index="01">{t('about.exp.eyebrow')}</Eyebrow></div>
<h2 id="exp-title" class="section-title">{t('about.exp.title')}</h2>
<ol class="timeline">
{steps.map((step) => (
<li>
<p class="timeline__period">{step.period[locale]}</p>
<h3>{step.role[locale]}</h3>
<p class="timeline__place">{step.place}</p>
<p class="timeline__detail">{step.detail[locale]}</p>
</li>
))}
</ol>
</section>
<section class="section" aria-labelledby="values-title">
<div class="eyebrow-row"><Eyebrow index="02">{t('about.values.eyebrow')}</Eyebrow></div>
<h2 id="values-title" class="section-title">{t('about.values.title')}</h2>
<ul class="values">
{values.map((value) => (
<li>
<h3>{value.title[locale]}</h3>
<p>{value.body[locale]}</p>
</li>
))}
</ul>
<div class="actions">
<Button href={localizePath('/contact', locale)} size="lg">{t('nav.cta')}</Button>
</div>
</section>
</BaseLayout>
<style lang="scss">
@use '../styles/type' as type;
.page-head {
display: grid;
gap: var(--nds-spacing-md);
padding-block: clamp(48px, 8vh, 96px) clamp(24px, 4vh, 48px);
}
.page-title { @include type.text-5xl; font-size: clamp(2rem, 5.5vw, 3.4rem); }
.page-lead {
@include type.text-lg;
font-weight: 400;
max-width: 62ch;
color: var(--nds-neutral);
}
.availability {
@include type.text-label;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--nds-spacing-sm);
color: var(--nds-neutral);
}
.timeline {
display: grid;
gap: var(--nds-spacing-xl);
margin: var(--nds-spacing-xl) 0 0;
padding: 0;
list-style: none;
li {
position: relative;
padding-left: var(--nds-spacing-xl);
border-left: var(--nds-border-width-medium) solid var(--nds-border);
&::before {
content: '';
position: absolute;
left: -7px;
top: 6px;
width: 12px;
height: 12px;
border-radius: var(--nds-radius-full);
background: var(--nds-primary);
}
}
h3 { @include type.text-xl; margin-block: var(--nds-spacing-2xs); }
}
.timeline__period {
@include type.text-label;
color: var(--nds-primary);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.timeline__place { @include type.text-label; color: var(--nds-neutral); }
.timeline__detail {
@include type.text-base;
font-size: 1rem;
margin-top: var(--nds-spacing-xs);
max-width: 64ch;
color: var(--nds-neutral);
}
.values {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
gap: var(--nds-spacing-lg);
margin: var(--nds-spacing-xl) 0 0;
padding: 0;
list-style: none;
li {
padding: var(--nds-spacing-lg);
background: color-mix(in srgb, var(--nds-surface) 88%, transparent);
border: var(--nds-border-width-thin) solid var(--nds-border);
border-radius: var(--nds-radius-lg);
}
h3 { @include type.text-xl; color: var(--nds-primary); margin-bottom: var(--nds-spacing-xs); }
p { @include type.text-base; color: var(--nds-neutral); }
}
.actions { margin-top: var(--nds-spacing-2xl); }
</style>
+350
View File
@@ -0,0 +1,350 @@
---
/**
* Contact page - static-friendly by design.
*
* The "form" is a mailto composer: it pre-fills the visitor's mail client and
* stores nothing server-side. To switch to a real backend later, point the
* submit handler at a Formspree/own endpoint and keep the same fields.
*/
import BaseLayout from '../layouts/BaseLayout.astro';
import Eyebrow from '../components/Eyebrow.astro';
import BrandLinkedin from '../components/BrandLinkedin.astro';
import { Mail, Copy, Send } from '@lucide/astro';
import { Github, Gitea, Npm } from 'simple-icons-astro';
import {
Breadcrumb,
BreadcrumbItem,
Button,
TextField,
Select,
SelectOption,
} from '@unkn0wndo3s/nova-design-system';
import { site } from '../data/site';
import { breadcrumbLd } from '../lib/seo';
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
const crumbs = [
{ name: t('nav.home'), url: localizePath('/', locale) },
{ name: t('nav.contact'), url: localizePath('/contact', locale) },
];
const subjects = [
{ value: 'cdi', label: t('contact.form.subject.cdi') },
{ value: 'freelance', label: t('contact.form.subject.freelance') },
{ value: 'other', label: t('contact.form.subject.other') },
];
const elsewhere = [
{ ...site.links.github, icon: 'github' },
{ ...site.links.gitea, icon: 'gitea' },
{ ...site.links.npm, icon: 'npm' },
{ ...site.links.linkedin, icon: 'linkedin' },
];
---
<BaseLayout
title={t('meta.contact.title')}
description={t('meta.contact.description')}
jsonLd={[breadcrumbLd(crumbs)]}
>
<nav class="crumbs" aria-label="Breadcrumb">
<Breadcrumb>
<BreadcrumbItem href={crumbs[0]!.url}>{crumbs[0]!.name}</BreadcrumbItem>
<BreadcrumbItem current>{crumbs[1]!.name}</BreadcrumbItem>
</Breadcrumb>
</nav>
<header class="page-head">
<Eyebrow>{t('avail.badge')}</Eyebrow>
<h1 class="page-title">{t('contact.title')}</h1>
<p class="page-lead">{t('contact.lead')}</p>
</header>
<div class="contact-grid">
<section class="composer" aria-labelledby="composer-title">
<h2 id="composer-title" class="visually-hidden">{t('contact.form.send')}</h2>
<div class="composer-fields" data-contact-form data-email={site.email}>
<div class="composer-row">
<TextField id="contact-name" label={t('contact.form.name')} type="text" placeholder="Jane Doe" />
<TextField
id="contact-email"
label={t('contact.form.email')}
type="email"
placeholder="jane@company.com"
/>
</div>
<div class="composer-subject">
<span class="field-label">{t('contact.form.subject')}</span>
<Select default={subjects[0]!.label}>
{subjects.map((s) => <SelectOption value={s.label}>{s.label}</SelectOption>)}
</Select>
</div>
<TextField
id="contact-message"
label={t('contact.form.message')}
type="textarea"
max={1200}
placeholder="..."
/>
<div class="composer-actions">
<Button type="primary" htmlType="button" data-contact-send>
<Send size={16} />
{t('contact.form.send')}
</Button>
<p class="composer-note">{t('contact.form.note')}</p>
</div>
</div>
</section>
<aside class="direct">
<section class="direct-block" aria-labelledby="direct-title">
<h2 id="direct-title" class="direct-heading">{t('contact.email.label')}</h2>
<p class="direct-email">
<Mail size={18} aria-hidden="true" />
<span data-email-text>{site.email}</span>
</p>
<div class="direct-actions">
<Button type="secondary" href={`mailto:${site.email}`}>{t('contact.email.write')}</Button>
<Button
type="ghost"
htmlType="button"
data-copy-email
data-copied-label={t('contact.email.copied')}
>
<Copy size={16} />
<span data-copy-label>{t('contact.email.copy')}</span>
</Button>
</div>
</section>
<section class="direct-block" aria-labelledby="elsewhere-title">
<h2 id="elsewhere-title" class="direct-heading">{t('contact.elsewhere')}</h2>
<ul class="elsewhere-list">
{
elsewhere.map((l) => (
<li>
<a href={l.url} target="_blank" rel="noopener noreferrer">
{l.icon === 'github' && <Github size={18} aria-hidden="true" />}
{l.icon === 'gitea' && <Gitea size={18} aria-hidden="true" />}
{l.icon === 'npm' && <Npm size={18} aria-hidden="true" />}
{l.icon === 'linkedin' && <BrandLinkedin size={18} />}
<span>{l.label}</span>
</a>
</li>
))
}
</ul>
</section>
</aside>
</div>
</BaseLayout>
<script>
/** Builds a mailto: URL from the composer fields and opens it. Zero data stored. */
function initContact() {
const root = document.querySelector<HTMLElement>('[data-contact-form]');
if (!root) return;
const email = root.dataset.email ?? '';
const send = root.querySelector<HTMLButtonElement>('[data-contact-send]');
send?.addEventListener('click', () => {
const name = (document.getElementById('contact-name')?.querySelector('input') as HTMLInputElement | null)?.value?.trim() ?? '';
const from = (document.getElementById('contact-email')?.querySelector('input') as HTMLInputElement | null)?.value?.trim() ?? '';
const message = (document.getElementById('contact-message')?.querySelector('textarea') as HTMLTextAreaElement | null)?.value?.trim() ?? '';
const selected =
root.querySelector<HTMLElement>('nds-select [aria-selected="true"]')?.dataset.value ??
root.querySelector<HTMLElement>('nds-select')?.dataset.default ??
'';
const subject = `[Portfolio] ${selected}${name ? ` - ${name}` : ''}`;
const bodyParts = [message];
if (name || from) bodyParts.push('', '--', [name, from].filter(Boolean).join(' · '));
const url = `mailto:${email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(bodyParts.join('\n'))}`;
window.location.href = url;
});
// Copy-to-clipboard with temporary label swap.
const copyBtn = document.querySelector<HTMLButtonElement>('[data-copy-email]');
const copyLabel = copyBtn?.querySelector<HTMLElement>('[data-copy-label]');
copyBtn?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(email);
if (copyLabel && copyBtn.dataset.copiedLabel) {
const original = copyLabel.textContent;
copyLabel.textContent = copyBtn.dataset.copiedLabel;
window.setTimeout(() => {
copyLabel.textContent = original;
}, 1800);
}
} catch {
/* Clipboard unavailable (permissions/http) - the address stays visible as text. */
}
});
}
initContact();
document.addEventListener('astro:page-load', initContact);
</script>
<style lang="scss">
@use '../styles/type' as type;
.crumbs {
margin-block: var(--nds-spacing-lg) var(--nds-spacing-xl);
}
.page-head {
display: grid;
gap: var(--nds-spacing-sm);
margin-block-end: var(--nds-spacing-2xl);
max-width: 46rem;
}
.page-title {
@include type.text-4xl;
margin: 0;
color: var(--nds-text);
}
.page-lead {
@include type.text-lg;
margin: 0;
color: var(--nds-neutral);
}
.contact-grid {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
gap: var(--nds-spacing-2xl);
align-items: start;
margin-block-end: var(--nds-spacing-3xl);
@media (max-width: 840px) {
grid-template-columns: 1fr;
}
}
.composer {
background: var(--nds-surface);
border: 1px solid var(--nds-border);
border-radius: var(--nds-radius-lg);
padding: var(--nds-spacing-xl);
box-shadow: var(--nds-shadow-sm);
}
.composer-fields {
display: grid;
gap: var(--nds-spacing-lg);
}
.composer-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--nds-spacing-lg);
@media (max-width: 560px) {
grid-template-columns: 1fr;
}
}
.composer-subject {
display: grid;
gap: var(--nds-spacing-2xs);
max-width: 22rem;
}
.field-label {
@include type.text-sm;
color: var(--nds-neutral);
}
.composer-actions {
display: grid;
gap: var(--nds-spacing-sm);
justify-items: start;
}
.composer-note {
@include type.text-sm;
margin: 0;
color: var(--nds-disabled);
}
.direct {
display: grid;
gap: var(--nds-spacing-xl);
}
.direct-block {
background: var(--nds-surface);
border: 1px solid var(--nds-border);
border-radius: var(--nds-radius-lg);
padding: var(--nds-spacing-xl);
}
.direct-heading {
@include type.text-lg;
margin: 0 0 var(--nds-spacing-md);
color: var(--nds-text);
}
.direct-email {
@include type.text-base;
display: flex;
align-items: center;
gap: var(--nds-spacing-xs);
margin: 0 0 var(--nds-spacing-md);
color: var(--nds-primary);
word-break: break-all;
}
.direct-actions {
display: flex;
flex-wrap: wrap;
gap: var(--nds-spacing-sm);
}
.elsewhere-list {
display: grid;
gap: var(--nds-spacing-xs);
margin: 0;
padding: 0;
list-style: none;
a {
display: flex;
align-items: center;
gap: var(--nds-spacing-sm);
padding: var(--nds-spacing-sm) var(--nds-spacing-md);
border-radius: var(--nds-radius-md);
color: var(--nds-text);
text-decoration: none;
transition: background-color 150ms ease, color 150ms ease;
&:hover {
background: var(--nds-surface-hover);
color: var(--nds-accent);
}
}
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
</style>
File diff suppressed because it is too large Load Diff
+519
View File
@@ -0,0 +1,519 @@
---
/**
* Home = the voyage. One long timeline: hero → profile → asteroid belt with a
* beacon per project → stack constellation → contact portal.
*
* Two rendering modes from the same markup:
* - default (no JS, no WebGL, reduced motion): normal stacked sections
* - `html.cinema` (set by voyage.ts): sections become fixed overlays,
* the wrapper's height becomes the scroll timeline.
*/
import BaseLayout from '../layouts/BaseLayout.astro';
import Eyebrow from '../components/Eyebrow.astro';
import TechIcon from '../components/TechIcon.astro';
import { Copy } from '@lucide/astro';
import { Badge, Button } from '@unkn0wndo3s/nova-design-system';
import { site, stack } from '../data/site';
import { featuredProjects, projects, type Project } from '../data/projects';
import { breadcrumbLd, profilePageLd } from '../lib/seo';
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
const voyageProjects: Project[] = [...featuredProjects, ...projects.filter((p) => !p.featured)];
const statusType = { live: 'success', maintained: 'info', wip: 'warning' } as const;
const statusLabel = {
live: t('work.status.live'),
maintained: t('work.status.maintained'),
wip: t('work.status.wip'),
} as const;
const pillars = [
{ title: t('home.profile.front.title'), body: t('home.profile.front.body') },
{ title: t('home.profile.back.title'), body: t('home.profile.back.body') },
{ title: t('home.profile.ops.title'), body: t('home.profile.ops.body') },
];
const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.slug}`), 'stack', 'contact'];
---
<BaseLayout
title={t('meta.home.title')}
description={t('meta.home.description')}
jsonLd={[breadcrumbLd([{ name: t('nav.home'), url: localizePath('/', locale) }]), profilePageLd(new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href)]}
background={false}
fullBleed
>
<canvas class="voyage-canvas" data-voyage-canvas aria-hidden="true"></canvas>
<div class="voyage" data-voyage>
<!-- 01 · Hero -->
<section class="seg seg--center" data-seg="hero" data-weight="1.1" id="hero">
<div class="card card--hero">
<p class="hero-avail"><Badge type="success" variant="soft">{t('avail.badge')}</Badge> <span>{t('avail.line')}</span></p>
<h1 class="hero-title">
<span class="hero-name">{site.name}</span><span class="sr-only">, </span>
<span class="hero-role">{t('home.hero.title.role')}</span>
</h1>
<p class="hero-lead">{t('home.hero.lead')}</p>
<div class="row">
<Button type="primary" href={localizePath('/#projects', locale)}>{t('home.hero.viewProjects')}</Button>
</div>
</div>
</section>
<!-- 02 · Profil -->
<section class="seg seg--left" data-seg="about" data-weight="1.3" data-side="left" id="about">
<div class="card">
<Eyebrow>{t('home.profile.eyebrow')}</Eyebrow>
<h2 class="card-title">{t('home.profile.title')}</h2>
<p class="card-body">{t('home.profile.body1')}</p>
<p class="card-body card-body--muted">{t('home.about.summary')}</p>
<ul class="pillars">
{pillars.map((p) => (
<li><strong>{p.title}</strong><span>{p.body}</span></li>
))}
</ul>
</div>
</section>
<!-- 03 · Cap sur les projets -->
<section class="seg seg--center" data-seg="projects" data-weight="0.7" id="projects">
<div class="card card--center">
<Eyebrow>{t('home.projects.eyebrow')}</Eyebrow>
<h2 class="card-title card-title--big">{t('home.projects.intro.title')}</h2>
<p class="card-body">{t('home.projects.intro.body')}</p>
<div class="row row--center">
<Button type="secondary" href={localizePath('/work', locale)}>{t('home.projects.all')}</Button>
</div>
</div>
</section>
<!-- 04-07 · Un segment par projet, cartes alternées -->
{voyageProjects.map((p, i) => (
<div
class={`seg seg--${i % 2 ? 'left' : 'right'}`}
data-seg={`p-${p.slug}`}
data-weight="1.15"
data-side={i % 2 ? 'left' : 'right'}
data-project
id={`p-${p.slug}`}
>
<article class="card card--project">
<p class="proj-index" aria-hidden="true">{String(i + 1).padStart(2, '0')}</p>
<div class="proj-head">
<Badge type={statusType[p.status]}>{statusLabel[p.status]}</Badge>
</div>
<h2 class="card-title">{p.name}</h2>
<p class="card-body">{p.tagline[locale]}</p>
<ul class="proj-highlights">
{p.highlights.slice(0, 2).map((h) => <li>{h[locale]}</li>)}
</ul>
<div class="row">
<Button type="primary" href={localizePath(`/work/${p.slug}`, locale)}>
{t('home.projects.view')}
</Button>
</div>
</article>
</div>
))}
<!-- 08 · Stack -->
<section class="seg seg--center" data-seg="stack" data-weight="1.05" id="stack">
<div class="card card--center card--wide">
<Eyebrow>{t('home.stack.eyebrow')}</Eyebrow>
<h2 class="card-title">{t('home.stack.title')}</h2>
<ul class="stack-grid">
{stack.map((s) => <li><TechIcon name={s.name} icon={s.icon} /></li>)}
</ul>
</div>
</section>
<!-- 09 · Contact (arrivée au portail) -->
<section class="seg seg--center" data-seg="contact" data-weight="1.2" id="contact">
<div class="card card--center">
<Eyebrow>{t('contact.title')}</Eyebrow>
<h2 class="card-title card-title--big">{t('home.cta.title')}</h2>
<p class="card-body">{t('home.cta.body')}</p>
<div class="row row--center">
<Button type="primary" href={`mailto:${site.email}`}>{t('contact.email.write')}</Button>
<Button type="ghost" htmlType="button" data-copy-email data-copied-label={t('contact.email.copied')}>
<Copy size={16} />
<span data-copy-label>{t('contact.email.copy')}</span>
</Button>
</div>
<p class="contact-email" data-email-text>{site.email}</p>
<p class="contact-where">{t('avail.where')}</p>
</div>
</section>
</div>
<!-- Timeline UI (cinema mode only) -->
<div class="rail" aria-hidden="true">
<div class="rail__bar" data-voyage-progress></div>
</div>
<nav class="dots" aria-label={t('voyage.progress')}>
{dots.map((id) => <button type="button" data-voyage-dot={id} aria-label={id}></button>)}
</nav>
<script>
import { ensureSmooth } from '../lib/smooth';
import type { VoyageHandle } from '../components/scene/voyage';
let handle: VoyageHandle | null = null;
async function init() {
const canvas = document.querySelector<HTMLCanvasElement>('[data-voyage-canvas]');
const container = document.querySelector<HTMLElement>('[data-voyage]');
if (!canvas || !container) return;
const lenis = ensureSmooth();
if (!lenis) return; // reduced motion → static page, canvas stays hidden
// Three.js only downloads when the cinema mode is actually possible.
const { mountVoyage } = await import('../components/scene/voyage');
handle = mountVoyage({ canvas, container, lenis });
if (!handle) return; // no WebGL → static page
// Deep links (/#about, nav anchors...) become smooth seeks on the timeline.
if (location.hash) handle.seek(location.hash.slice(1), true);
document.addEventListener('click', (e) => {
const a = (e.target as HTMLElement).closest<HTMLAnchorElement>('a[href*="#"]');
if (!a || !handle) return;
if (a.pathname !== location.pathname) return;
const id = a.hash.slice(1);
e.preventDefault();
history.replaceState(null, '', a.hash);
handle.seek(id);
});
}
function initCopy() {
const btn = document.querySelector<HTMLButtonElement>('[data-copy-email]');
const label = btn?.querySelector<HTMLElement>('[data-copy-label]');
const email = document.querySelector<HTMLElement>('[data-email-text]')?.textContent?.trim() ?? '';
btn?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(email);
if (label && btn.dataset.copiedLabel) {
const orig = label.textContent;
label.textContent = btn.dataset.copiedLabel;
window.setTimeout(() => (label.textContent = orig), 1800);
}
} catch {
/* clipboard unavailable - the address stays readable as text */
}
});
}
init();
initCopy();
</script>
</BaseLayout>
<style lang="scss">
@use '../styles/type' as type;
/* ---------- Canvas & timeline chrome (hidden until cinema mode) --------- */
.voyage-canvas {
display: none;
position: fixed;
inset: 0;
z-index: 0;
}
.rail,
.dots {
display: none;
}
:global(html.cinema) .voyage-canvas {
display: block;
}
:global(html.cinema) .rail {
display: block;
position: fixed;
inset-inline: 0;
top: 0;
height: 2px;
z-index: 60;
background: color-mix(in srgb, var(--nds-border) 60%, transparent);
.rail__bar {
height: 100%;
background: linear-gradient(90deg, var(--nds-primary), var(--nds-accent));
transform-origin: left;
transform: scaleX(0);
}
}
:global(html.cinema) .dots {
position: fixed;
right: clamp(10px, 2vw, 26px);
top: 50%;
translate: 0 -50%;
z-index: 60;
display: grid;
gap: 4px;
/* 22px hit area (touch target), 8px painted dot */
button {
position: relative;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
&::before {
content: '';
position: absolute;
inset: 50%;
width: 8px;
height: 8px;
translate: -50% -50%;
border: 1px solid var(--nds-neutral);
border-radius: var(--nds-radius-full);
transition: background-color 0.25s ease, transform 0.25s ease, border-color 0.25s ease;
}
&.is-active::before {
background: var(--nds-accent);
border-color: var(--nds-accent);
transform: scale(1.35);
}
&:hover::before {
border-color: var(--nds-text);
}
}
@media (max-width: 840px) {
display: none;
}
}
/* ------------------------- Sections: static default --------------------- */
.seg {
position: relative;
z-index: 10;
min-height: 100svh;
display: flex;
align-items: center;
padding-inline: clamp(20px, 6vw, 72px);
&.seg--center {
justify-content: center;
text-align: center;
}
&.seg--right {
justify-content: flex-end;
}
}
/* Cinema: fixed overlays, driven per-frame by voyage.ts */
:global(html.cinema) .seg {
position: fixed;
inset: 0;
min-height: 0;
visibility: hidden;
opacity: 0;
pointer-events: none;
will-change: opacity, transform;
&.is-active {
pointer-events: auto;
}
}
/* ------------------------------- Cards ---------------------------------- */
.card {
display: grid;
gap: var(--nds-spacing-md);
justify-items: start;
width: min(600px, 94vw);
padding: clamp(var(--nds-spacing-lg), 3vw, var(--nds-spacing-2xl));
background: color-mix(in srgb, var(--nds-background) 62%, transparent);
border: 1px solid color-mix(in srgb, var(--nds-border) 80%, transparent);
border-radius: var(--nds-radius-lg);
backdrop-filter: blur(10px);
box-shadow: var(--nds-shadow-md);
}
.card--center {
justify-items: center;
text-align: center;
}
.card--wide {
width: min(780px, 94vw);
}
.card--hero {
width: min(860px, 94vw);
background: transparent;
border: none;
box-shadow: none;
backdrop-filter: none;
justify-items: center;
text-align: center;
}
.row {
display: flex;
flex-wrap: wrap;
gap: var(--nds-spacing-sm);
}
.row--center {
justify-content: center;
}
.card-title {
@include type.text-3xl;
margin: 0;
color: var(--nds-text);
text-wrap: balance;
}
.card-title--big {
@include type.text-4xl;
}
.card-body {
@include type.text-base;
margin: 0;
max-width: 56ch;
color: var(--nds-neutral);
}
.card-body--muted {
color: var(--nds-disabled);
}
/* Hero */
.hero-avail {
@include type.text-sm;
display: flex;
align-items: center;
gap: var(--nds-spacing-sm);
margin: 0;
color: var(--nds-neutral);
}
.hero-title {
display: grid;
gap: var(--nds-spacing-2xs);
margin: 0;
}
.hero-name {
@include type.text-5xl;
color: var(--nds-text);
letter-spacing: -0.01em;
}
.hero-role {
@include type.text-xl;
color: var(--nds-accent);
}
.hero-lead {
@include type.text-lg;
margin: 0;
max-width: 58ch;
color: var(--nds-neutral);
}
/* Profil */
.pillars {
display: grid;
gap: var(--nds-spacing-sm);
margin: 0;
padding: 0;
list-style: none;
li {
display: grid;
gap: 2px;
padding-left: var(--nds-spacing-md);
border-left: 2px solid var(--nds-primary);
strong {
@include type.text-label;
color: var(--nds-text);
}
span {
@include type.text-sm;
color: var(--nds-neutral);
}
}
}
/* Projets */
.card--project {
position: relative;
}
.proj-index {
@include type.text-5xl;
position: absolute;
top: -0.55em;
right: var(--nds-spacing-lg);
margin: 0;
color: color-mix(in srgb, var(--nds-accent) 26%, transparent);
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.proj-highlights {
display: grid;
gap: var(--nds-spacing-2xs);
margin: 0;
padding: 0;
list-style: none;
li {
@include type.text-sm;
color: var(--nds-neutral);
padding-left: var(--nds-spacing-md);
position: relative;
&::before {
content: '◆';
position: absolute;
left: 0;
color: var(--nds-accent);
font-size: 0.6em;
top: 0.45em;
}
}
}
/* Stack */
.stack-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--nds-spacing-sm);
margin: 0;
padding: 0;
list-style: none;
}
/* Contact */
.contact-email {
@include type.text-base;
margin: 0;
color: var(--nds-primary);
word-break: break-all;
}
.contact-where {
@include type.text-sm;
margin: 0;
color: var(--nds-disabled);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 840px) {
.seg,
.seg.seg--right {
justify-content: center;
}
.proj-index {
right: var(--nds-spacing-md);
}
}
</style>
+203
View File
@@ -0,0 +1,203 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import TechIcon from '../components/TechIcon.astro';
import ComponentShowcase from '../components/showcase/ComponentShowcase.astro';
import InfraScene from '../components/scenes/InfraScene.astro';
import BlueprintScene from '../components/scenes/BlueprintScene.astro';
import SorterScene from '../components/scenes/SorterScene.astro';
import { Badge, Button, Breadcrumb, BreadcrumbItem, Link } from '@unkn0wndo3s/nova-design-system';
import { projects, type Project } from '../data/projects';
import { breadcrumbLd, projectLd } from '../lib/seo';
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
export interface Props {
project: Project;
}
const { project } = Astro.props;
const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
const statusType = { live: 'success', maintained: 'info', wip: 'warning' } as const;
const statusLabel = {
live: t('work.status.live'),
maintained: t('work.status.maintained'),
wip: t('work.status.wip'),
} as const;
const crumbs = [
{ name: t('nav.home'), url: localizePath('/', locale) },
{ name: t('nav.work'), url: localizePath('/work', locale) },
{ name: project.name, url: localizePath(`/work/${project.slug}`, locale) },
];
const pageUrl = new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href;
const jsonLd = [breadcrumbLd(crumbs), projectLd(project, locale, pageUrl)];
const title = `${project.name} - ${t('meta.work.title')}`;
const next = projects[(projects.findIndex((p) => p.slug === project.slug) + 1) % projects.length]!;
---
<BaseLayout title={title} description={project.summary[locale]} jsonLd={jsonLd} ogType="article">
<nav class="crumbs" aria-label="Breadcrumb">
<Breadcrumb>
<BreadcrumbItem href={crumbs[0]!.url}>{crumbs[0]!.name}</BreadcrumbItem>
<BreadcrumbItem href={crumbs[1]!.url}>{crumbs[1]!.name}</BreadcrumbItem>
<BreadcrumbItem current>{project.name}</BreadcrumbItem>
</Breadcrumb>
</nav>
<article>
<header class="head">
<Badge type={statusType[project.status]}>{statusLabel[project.status]}</Badge>
<h1 class="head__title">{project.name}</h1>
<p class="head__tagline">{project.tagline[locale]}</p>
</header>
{project.slug === 'nova-infra' && <InfraScene />}
{project.slug === 'portfolio' && <BlueprintScene />}
{project.slug === 'file-organizer' && <SorterScene />}
<div class="layout">
<div class="body">
{project.body.map((paragraph) => <p>{paragraph[locale]}</p>)}
<h2>{t('work.highlights')}</h2>
<ul class="highlights">
{project.highlights.map((h) => <li>{h[locale]}</li>)}
</ul>
</div>
<aside class="meta" aria-label={t('work.stack')}>
<h2>{t('work.stack')}</h2>
<ul class="meta__stack">
{project.stack.map((tech) => (
<li><TechIcon name={tech.name} icon={tech.icon} size={16} /></li>
))}
</ul>
{project.links.length > 0 && (
<>
<h2>{t('work.links')}</h2>
<ul class="meta__links">
{project.links.map((link) => (
<li><Link url={link.url} blank>{link.label}</Link></li>
))}
</ul>
</>
)}
</aside>
</div>
{project.slug === 'nova-design-system' && <ComponentShowcase />}
<footer class="next">
<Button href={localizePath('/work', locale)} type="ghost">{t('work.backToList')}</Button>
<Button href={localizePath(`/work/${next.slug}`, locale)} type="secondary">
{t('work.next')} : {next.name}
</Button>
</footer>
</article>
</BaseLayout>
<style lang="scss">
@use '../styles/type' as type;
.crumbs { padding-top: var(--nds-spacing-lg); }
.head {
display: grid;
justify-items: start;
gap: var(--nds-spacing-md);
padding-block: clamp(32px, 6vh, 64px) clamp(24px, 4vh, 48px);
}
.head__title {
@include type.text-5xl;
font-size: clamp(2rem, 5.5vw, 3.4rem);
}
.head__tagline {
@include type.text-lg;
font-weight: 400;
color: var(--nds-primary);
max-width: 60ch;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: clamp(24px, 5vw, 64px);
align-items: start;
@media (max-width: 840px) { grid-template-columns: 1fr; }
}
.body {
display: grid;
gap: var(--nds-spacing-md);
p {
@include type.text-base;
font-size: 1rem;
line-height: 1.7;
color: var(--nds-neutral);
max-width: 68ch;
}
h2 {
@include type.text-2xl;
margin-top: var(--nds-spacing-lg);
}
}
.highlights {
display: grid;
gap: var(--nds-spacing-xs);
margin: 0;
padding-left: 1.2em;
li {
@include type.text-base;
font-size: 1rem;
color: var(--nds-neutral);
&::marker { color: var(--nds-primary); }
}
}
.meta {
position: sticky;
top: 88px;
display: grid;
gap: var(--nds-spacing-md);
padding: var(--nds-spacing-lg);
background: color-mix(in srgb, var(--nds-surface) 88%, transparent);
border: var(--nds-border-width-thin) solid var(--nds-border);
border-radius: var(--nds-radius-lg);
h2 {
@include type.text-label;
color: var(--nds-neutral);
text-transform: uppercase;
letter-spacing: 0.1em;
}
@media (max-width: 840px) { position: static; }
}
.meta__stack,
.meta__links {
display: flex;
flex-wrap: wrap;
gap: var(--nds-spacing-xs);
margin: 0;
padding: 0;
list-style: none;
}
.next {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--nds-spacing-md);
padding-block: clamp(40px, 7vh, 80px);
}
</style>
+67
View File
@@ -0,0 +1,67 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import ProjectCard from '../components/ProjectCard.astro';
import { Breadcrumb, BreadcrumbItem } from '@unkn0wndo3s/nova-design-system';
import { projects } from '../data/projects';
import { breadcrumbLd } from '../lib/seo';
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale);
const crumbs = [
{ name: t('nav.home'), url: localizePath('/', locale) },
{ name: t('nav.work'), url: localizePath('/work', locale) },
];
---
<BaseLayout title={t('meta.work.title')} description={t('meta.work.description')} jsonLd={[breadcrumbLd(crumbs)]}>
<nav class="crumbs" aria-label="Breadcrumb">
<Breadcrumb>
<BreadcrumbItem href={crumbs[0]!.url}>{crumbs[0]!.name}</BreadcrumbItem>
<BreadcrumbItem current>{crumbs[1]!.name}</BreadcrumbItem>
</Breadcrumb>
</nav>
<header class="page-head">
<h1 class="page-title">{t('work.title')}</h1>
<p class="page-lead">{t('work.lead')}</p>
</header>
<div class="grid" data-belt-anchor>
{projects.map((project, i) => (
<ProjectCard project={project} index={i} headingLevel="h2" />
))}
</div>
</BaseLayout>
<style lang="scss">
@use '../styles/type' as type;
.crumbs { padding-top: var(--nds-spacing-lg); }
.page-head {
padding-block: clamp(32px, 6vh, 64px);
display: grid;
gap: var(--nds-spacing-md);
}
.page-title {
@include type.text-5xl;
font-size: clamp(2rem, 5.5vw, 3.4rem);
}
.page-lead {
@include type.text-lg;
font-weight: 400;
max-width: 60ch;
color: var(--nds-neutral);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
gap: var(--nds-spacing-lg);
padding-bottom: var(--nds-spacing-2xl);
}
</style>
+7 -4
View File
@@ -1,8 +1,11 @@
{ {
"extends": "astro/tsconfigs/strict", "extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": { "compilerOptions": {
"types": ["three"] "baseUrl": ".",
} "paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*", ".astro/types.d.ts"],
"exclude": ["dist"]
} }