From af3d3311147a40590730a10da22aae86a935004c Mon Sep 17 00:00:00 2001 From: LOUIS POTEVIN Date: Thu, 2 Jul 2026 14:46:55 +0200 Subject: [PATCH] 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. --- README.md | 13 ++- astro.config.mjs | 25 ++++++ src/components/BrandIcon.astro | 31 +++++++ src/components/SiteFooter.astro | 8 +- src/components/SiteNav.astro | 6 +- src/components/TechIcon.astro | 18 +--- src/components/scene/voyage.ts | 83 ++++++++++++++--- .../showcase/ComponentShowcase.astro | 61 ++++++++++--- src/data/projects.ts | 8 +- src/i18n/ui.ts | 56 ++++++------ src/layouts/BaseLayout.astro | 4 + src/lib/brandIcons.ts | 61 +++++++++++++ src/lib/ndsMeta.ts | 45 ++++++++++ src/views/DocsView.astro | 18 ++-- src/views/HomeView.astro | 88 +++++++------------ 15 files changed, 385 insertions(+), 140 deletions(-) create mode 100644 src/components/BrandIcon.astro create mode 100644 src/lib/brandIcons.ts create mode 100644 src/lib/ndsMeta.ts diff --git a/README.md b/README.md index 271e220..8e8535c 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ nébuleuses, ceinture d'astéroïdes (une balise lumineuse par projet) et portai - 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). +## Métadonnées NDS toujours à jour + +`src/lib/ndsMeta.ts` : la liste des composants (et leur nombre) est parsée depuis +l'index d'exports du paquet installé, et la version est récupérée sur le registre +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. + ## /docs — la documentation technique Une page dédiée (`/docs`, FR/EN) documente tout le fonctionnement du site en 13 chapitres, @@ -88,7 +96,10 @@ Toutes épinglées (section haute + stage sticky), pilotées par `scrub.ts`, fal ## À faire côté déploiement -1. **`public/og-fr.png` et `public/og-en.png`** (1200×630) — visuels de partage. +1. **Désactiver « Email Address Obfuscation »** dans Cloudflare (Scrape Shield) : + sinon CF injecte `email-decode.min.js`, casse le mailto sans JS et affiche + « [email protected] » aux crawlers de recruteurs. +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/`). 3. `/about` et `/contact` redirigent vers `/#about` et `/#contact` (config Astro) — les anciennes URL indexées ne cassent pas. diff --git a/astro.config.mjs b/astro.config.mjs index f218a4c..5217ead 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -3,6 +3,26 @@ import { defineConfig } from 'astro/config'; 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 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), ); @@ -38,6 +58,11 @@ export default defineConfig({ ], vite: { + plugins: [stripFontImport], + build: { + // Source maps help debugging in prod and satisfy Lighthouse's audit. + sourcemap: true, + }, css: { preprocessorOptions: { scss: { diff --git a/src/components/BrandIcon.astro b/src/components/BrandIcon.astro new file mode 100644 index 0000000..18bdca4 --- /dev/null +++ b/src/components/BrandIcon.astro @@ -0,0 +1,31 @@ +--- +/** + * Minimal, valid brand icon: one fill, no , 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> +)} diff --git a/src/components/SiteFooter.astro b/src/components/SiteFooter.astro index d11a319..be33d76 100644 --- a/src/components/SiteFooter.astro +++ b/src/components/SiteFooter.astro @@ -1,5 +1,5 @@ --- -import { Github, Gitea, Npm } from 'simple-icons-astro'; +import BrandIcon from './BrandIcon.astro'; import BrandLinkedin from './BrandLinkedin.astro'; import { site } from '../data/site'; import { getLocaleFromUrl, useTranslations } from '../i18n'; @@ -18,13 +18,13 @@ const year = new Date().getFullYear(); <nav class="footer__links" aria-label="Social"> <a href={site.links.gitea.url} title="Gitea" rel="me noopener" target="_blank"> - <Gitea size={20} aria-hidden="true" /><span class="sr-only">Gitea</span> + <BrandIcon icon="Gitea" size={20} color="current" /><span class="sr-only">Gitea</span> </a> <a href={site.links.github.url} title="GitHub" rel="me noopener" target="_blank"> - <Github size={20} aria-hidden="true" /><span class="sr-only">GitHub</span> + <BrandIcon icon="Github" size={20} color="current" /><span class="sr-only">GitHub</span> </a> <a href={site.links.npm.url} title="npm" rel="me noopener" target="_blank"> - <Npm size={20} aria-hidden="true" /><span class="sr-only">npm</span> + <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> diff --git a/src/components/SiteNav.astro b/src/components/SiteNav.astro index 6cf8032..af48804 100644 --- a/src/components/SiteNav.astro +++ b/src/components/SiteNav.astro @@ -12,13 +12,11 @@ const locale = getLocaleFromUrl(Astro.url); const t = useTranslations(locale); const route = unlocalizePath(Astro.url.pathname); -// About & Contact live on the home timeline - anchors seek the voyage there. +// 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 = [ - { href: localizePath('/', locale), label: t('nav.home'), match: '/' }, { href: localizePath('/work', locale), label: t('nav.work'), match: '/work' }, { href: localizePath('/docs', locale), label: t('nav.docs'), match: '/docs' }, - { href: localizePath('/#about', locale), label: t('nav.about'), match: '/about' }, - { href: localizePath('/#contact', locale), label: t('nav.contact'), match: '/contact' }, ]; const isActive = (match: string) => diff --git a/src/components/TechIcon.astro b/src/components/TechIcon.astro index 981068f..5f4e47f 100644 --- a/src/components/TechIcon.astro +++ b/src/components/TechIcon.astro @@ -1,13 +1,9 @@ --- /** - * Renders a technology icon by simple-icons name, with its label. - * Icons are imported explicitly so the build only ships what's used. + * Renders a technology icon (via BrandIcon) with its label. The icon is + * decorative: only the visible label is announced, once. */ -import { - Apache, Astro as AstroIcon, Cloudflare, Docker, Figma, Git, Gitea, - Javascript, Linux, Nodedotjs, Npm, Postgresql, Python, Rust, Sass, - Threedotjs, Typescript, Vuedotjs, -} from 'simple-icons-astro'; +import BrandIcon from './BrandIcon.astro'; export interface Props { name: string; @@ -15,18 +11,12 @@ export interface Props { size?: number; } -const icons: Record<string, (props: Record<string, unknown>) => unknown> = { - Apache, Astro: AstroIcon, Cloudflare, Docker, Figma, Git, Gitea, - Javascript, Linux, Nodedotjs, Npm, Postgresql, Python, Rust, Sass, - Threedotjs, Typescript, Vuedotjs, -}; const { name, icon, size = 18 } = Astro.props; -const Icon = icons[icon]; --- <span class="tech"> - {Icon && <Icon size={size} aria-hidden="true" />} + <BrandIcon icon={icon} size={size} /> <span>{name}</span> </span> diff --git a/src/components/scene/voyage.ts b/src/components/scene/voyage.ts index fbd9f3c..e1415a2 100644 --- a/src/components/scene/voyage.ts +++ b/src/components/scene/voyage.ts @@ -56,6 +56,13 @@ const smoothstep = (a: number, b: number, x: number) => { 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; @@ -350,10 +357,18 @@ export function mountVoyage(opts: { 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(); @@ -373,6 +388,18 @@ export function mountVoyage(opts: { 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)); @@ -380,8 +407,36 @@ export function mountVoyage(opts: { 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); - // Camera along the path. - const u = progress; + // 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. @@ -501,17 +556,21 @@ export function mountVoyage(opts: { 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() { - cancelAnimationFrame(frame); - window.removeEventListener('resize', onResize); - document.removeEventListener('visibilitychange', onVisibility); - renderer.dispose(); - document.documentElement.classList.remove('cinema'); - }, - }; + return { seek, dispose }; } diff --git a/src/components/showcase/ComponentShowcase.astro b/src/components/showcase/ComponentShowcase.astro index c9e4275..3a04348 100644 --- a/src/components/showcase/ComponentShowcase.astro +++ b/src/components/showcase/ComponentShowcase.astro @@ -28,8 +28,13 @@ import { } 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); @@ -166,11 +171,7 @@ const scatter = [ [0, 22], [36, 2], ]; -const library = [ - 'Avatar', 'Badge', 'Breadcrumb', 'Button', 'Card', 'Checkbox', 'Link', 'ListItem', - 'LoadingBar', 'Modal', 'Navbar', 'Notification', 'NumericStepper', 'Pagination', - 'Radio', 'Select', 'Sidebar', 'Tab', 'TextField', 'Toggle', 'Tooltip', -]; +const library = NDS_COMPONENTS; const actLabels: Record<string, Localized> = { a1: { fr: 'Acte I - Tout part des tokens.', en: 'Act I - Everything starts with tokens.' }, @@ -317,11 +318,10 @@ const actLabels: Record<string, Localized> = { ))} </ul> <div class="stats"> - <p><strong data-count-to="29">0</strong><span>{locale === 'fr' ? 'composants' : 'components'}</span></p> - <p><strong data-count-to="6">0</strong><span>{locale === 'fr' ? 'catégories' : 'categories'}</span></p> - <p><strong>v1.2.1</strong><span>npm</span></p> + <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 @unkn0wndo3s/nova-design-system</code></p> + <p class="install"><code data-typed>{`npm install ${NDS_PACKAGE}`}</code></p> </div> <p class="hint" aria-hidden="true">{t('nds.showcase.hint')}</p> @@ -330,6 +330,7 @@ const actLabels: Record<string, Localized> = { <script> import { scrub, clamp01, window01 } from '../../lib/scrub'; + import { getSmooth } from '../../lib/smooth'; function init() { const root = document.querySelector<HTMLElement>('[data-forge]'); @@ -364,6 +365,7 @@ const actLabels: Record<string, Localized> = { 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. ---- @@ -379,8 +381,13 @@ const actLabels: Record<string, Localized> = { act2.style.visibility = inTunnel > 0.01 ? 'visible' : 'hidden'; const tp = clamp01((p - A2[0]) / (A2[1] - A2[0])); // 0..1 inside act II - const f = tp * n; // fractional slide index - const idx = Math.min(n - 1, Math.floor(f)); + 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 @@ -425,6 +432,38 @@ const actLabels: Record<string, Localized> = { }, 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(); diff --git a/src/data/projects.ts b/src/data/projects.ts index d154c69..f1872b5 100644 --- a/src/data/projects.ts +++ b/src/data/projects.ts @@ -3,6 +3,8 @@ import type { Locale } from '../i18n'; /** 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 { @@ -35,8 +37,8 @@ export const projects: Project[] = [ en: 'Astro component library - tokens, accessibility, npm distribution.', }, summary: { - fr: "29 composants Astro répartis en six familles, un système complet de tokens (couleur, typographie, espacement, rayons) et un thème clair/sombre piloté par variables CSS. Publié sur npm en v1.2.1, il propulse ce portfolio.", - en: 'A library of 29 Astro components across six families, a complete token system (color, typography, spacing, radius) and a light/dark theme driven by CSS variables. Published on npm at v1.2.1, it powers this portfolio.', + 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: [ { @@ -53,7 +55,7 @@ export const projects: Project[] = [ }, ], highlights: [ - { fr: '29 composants exportés, six familles', en: '29 exported components, six families' }, + { fr: `${NDS_COMPONENT_COUNT} composants exportés sur npm`, en: `${NDS_COMPONENT_COUNT} components exported on npm` }, { 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' }, diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 8db5c8a..57a672e 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -1,10 +1,12 @@ +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 (front, back, web)', + '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', @@ -23,7 +25,7 @@ const fr = { // Nav 'nav.home': 'Accueil', 'nav.work': 'Projets', - 'nav.docs': 'Docs', + 'nav.docs': "Comment c'est fait", 'nav.about': 'À propos', 'nav.contact': 'Contact', 'nav.cta': 'Me contacter', @@ -38,25 +40,24 @@ const fr = { // Home 'home.hero.title.role': 'Développeur full-stack', 'home.hero.lead': - "Je conçois et développe des applications web de bout en bout : de l'interface au back-end, jusqu'au déploiement en production. Aussi à l'aise sur le composant que vous regardez que sur le service qui le sert.", + "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, à l’aise sur toute la chaîne.', 'home.profile.body1': - "J'ai l'expérience d'applications métier en production (alternance chez Legrand) et de projets personnels menés jusqu'au bout : un design system publié sur npm, une infrastructure auto-hébergée, des outils en Rust et Python.", + "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': - 'Ce qui me motive : maîtriser le cycle complet - le composant que vous regardez, le service qui le délivre et la CI qui le déploie. Ce site en est la preuve, construit entièrement sur mon propre design system.', + "À 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': - 'Interfaces soignées et accessibles avec Astro, Vue.js, TypeScript et Sass - jusqu’au design system maison.', + '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': - 'Services et API côté serveur, modélisation de données et logique métier avec Node et PostgreSQL.', + 'Node et Express en production chez Legrand, PostgreSQL pour les données.', 'home.profile.ops.title': 'Outils & déploiement', 'home.profile.ops.body': - 'Conteneurisation Docker, CI/CD automatisée et mise en production de bout en bout.', + "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', @@ -66,16 +67,16 @@ const fr = { 'home.stack.eyebrow': 'Stack technique', 'home.stack.title': 'Mes outils du quotidien.', - 'home.cta.title': 'On en parle ?', + 'home.cta.title': 'Un poste, une mission, une question ?', 'home.cta.body': - 'Je cherche un poste full-stack en CDI dès septembre 2026, et je suis ouvert aux missions freelance. Partout en France, sur site ou en remote.', + "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': - "Chaque projet est mené comme un produit : design, code, tests, CI/CD et mise en production. Cliquez pour explorer le détail de chacun.", + "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', @@ -128,9 +129,9 @@ const fr = { // Voyage (home cinematic scroll) 'voyage.hint': 'Scrollez - le voyage commence', 'voyage.progress': 'Progression du voyage', - 'home.projects.intro.title': 'Quatre projets, menés comme des produits.', + 'home.projects.intro.title': 'Un design system, une infra, un outil Rust. Et ce site.', 'home.projects.intro.body': - 'Design, code, tests, CI/CD, production : chaque projet va au bout. En route.', + '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.", @@ -164,7 +165,7 @@ const fr = { } as const; const en: Record<UIKey, string> = { - 'meta.home.title': 'Louis Potevin - Full-stack Developer (front, back, web)', + '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', @@ -182,7 +183,7 @@ const en: Record<UIKey, string> = { 'nav.home': 'Home', 'nav.work': 'Projects', - 'nav.docs': 'Docs', + 'nav.docs': "How it's built", 'nav.about': 'About me', 'nav.contact': 'Contact', 'nav.cta': 'Get in touch', @@ -195,25 +196,24 @@ const en: Record<UIKey, string> = { 'home.hero.title.role': 'Full-stack developer', 'home.hero.lead': - 'I design and build web applications end to end: from the interface to the back-end, all the way to production. Equally at home on the component you look at and the service that delivers it.', + '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': - 'I have hands-on experience with business applications in production (apprenticeship at Legrand) and personal projects shipped for real: a design system published on npm, self-hosted infrastructure, tools built with Rust and Python.', + "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': - 'What drives me: owning the whole cycle - the component you look at, the service that delivers it, the CI that deploys it. This site is proof, built entirely on my own design system.', + "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': - 'Polished, accessible interfaces with Astro, Vue.js, TypeScript and Sass - all the way to an in-house design system.', + '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': - 'Server-side services and APIs, data modeling and business logic with Node and PostgreSQL.', + 'Node and Express in production at Legrand, PostgreSQL for the data.', 'home.profile.ops.title': 'Tools & deployment', 'home.profile.ops.body': - 'Docker containerization, automated CI/CD and end-to-end production deployment.', + "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', @@ -223,15 +223,15 @@ const en: Record<UIKey, string> = { 'home.stack.eyebrow': 'Tech stack', 'home.stack.title': 'My everyday tools.', - 'home.cta.title': "Let's talk!", + 'home.cta.title': 'A role, a mission, a question?', 'home.cta.body': - 'I am looking for a full-stack permanent position from September 2026, and I am open to freelance work. Anywhere in France, on-site or remote.', + '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': - 'Every project is run like a product: design, code, tests, CI/CD and production. Click through to explore each one in detail.', + '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', @@ -279,9 +279,9 @@ const en: Record<UIKey, string> = { 'voyage.hint': 'Scroll - the journey begins', 'voyage.progress': 'Journey progress', - 'home.projects.intro.title': 'Four projects, run like products.', + 'home.projects.intro.title': 'A design system, an infra, a Rust tool. And this site.', 'home.projects.intro.body': - 'Design, code, tests, CI/CD, production: every project ships for real. Off we go.', + '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.', diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index b36ed8d..4b855e9 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -51,6 +51,10 @@ const ldBlocks = [personLd(locale), webSiteLd(), ...jsonLd]; <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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} diff --git a/src/lib/brandIcons.ts b/src/lib/brandIcons.ts new file mode 100644 index 0000000..325c994 --- /dev/null +++ b/src/lib/brandIcons.ts @@ -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 carries a duplicate `fill` + * attribute, and each icon embeds a 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 = { + 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'), +}; diff --git a/src/lib/ndsMeta.ts b/src/lib/ndsMeta.ts new file mode 100644 index 0000000..fdb282f --- /dev/null +++ b/src/lib/ndsMeta.ts @@ -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 { + 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; +} diff --git a/src/views/DocsView.astro b/src/views/DocsView.astro index 20ed127..4638477 100644 --- a/src/views/DocsView.astro +++ b/src/views/DocsView.astro @@ -16,6 +16,7 @@ import { Badge } from '@unkn0wndo3s/nova-design-system'; import { Code } from 'astro:components'; import { breadcrumbLd } from '../lib/seo'; import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n'; +import { getNdsVersion } from '../lib/ndsMeta'; import configRaw from '../../astro.config.mjs?raw'; import smoothRaw from '../lib/smooth.ts?raw'; @@ -29,6 +30,7 @@ import enHomeRaw from '../pages/en/index.astro?raw'; const locale = getLocaleFromUrl(Astro.url); const t = useTranslations(locale); const L = (fr: string, en: string) => (locale === 'fr' ? fr : en); +const ndsVersion = await getNdsVersion(); /** Slice a source between two unique markers - fails the build if missing. */ function slice(src: string, from: string, to: string, label: string): string { @@ -44,12 +46,13 @@ const snips = { scrub: scrubRaw.trimEnd(), segments: slice(voyageRaw, ' // --- Timeline from the DOM', ' // --- Scene ---', 'segments'), camera: slice(voyageRaw, ' // Flight path:', ' // Starfield', 'camera path'), - camTick: slice(voyageRaw, ' // Camera along the path.', ' // Nebulae drift.', 'camera tick'), + camTick: slice(voyageRaw, ' // Camera along the path', ' // Nebulae drift.', 'camera tick'), overlay: slice(voyageRaw, ' const applyOverlay', ' // --- Frame loop', 'overlay'), cinemaCss: slice(homeRaw, ' /* Cinema: fixed overlays', ' /* ------------------------------- Cards', 'cinema css'), - energy: slice(voyageRaw, ' // Scroll → timeline progress', ' // Camera along the path.', 'energy'), + energy: slice(voyageRaw, ' // Scroll → timeline progress', ' // Idle snap:', 'energy'), collisions: slice(voyageRaw, ' // Collisions only while agitated', ' belt.instanceMatrix', 'collisions'), seek: slice(voyageRaw, ' const seek = (id: string', ' dots.forEach', 'seek'), + snap: slice(voyageRaw, ' // Idle snap:', ' // Camera along the path', 'snap'), gate: slice(voyageRaw, ' // --- Capability gate', ' // --- Timeline from the DOM', 'gate'), infraDash: slice(infraRaw, ' .edge {', ' .node {', 'infra dash'), sorterMeasure: slice(sorterRaw, ' const measure = () => {', ' let lastP = 0;', 'sorter measure'), @@ -98,7 +101,7 @@ const miniFiles = [ Three.js Lenis Astro 7 - NDS v1.2.1 + {`NDS v${ndsVersion}`} {t('docs.reading')}

@@ -251,6 +254,11 @@ const miniFiles = [ +

{L( + "Deux garde-fous là-dessus, ajoutés après les premiers tests. D'abord une courbe de « dwell » : dans chaque segment, la caméra parcourt les 38 premiers pourcents, se fige au centre, puis repart - un petit coup de molette pendant qu'une carte est affichée ne fait presque rien bouger. Ensuite un snap au repos : si le scroll s'arrête au milieu d'un segment, on glisse doucement vers son centre pour cadrer l'étape proprement. C'est un verrou souple - le moindre nouvel input de scroll reprend la main :", + "Two guard rails on top of that, added after the first tests. First a “dwell” curve: within each segment, the camera travels the first 38%, holds at the center, then moves on - a small wheel nudge while a card is displayed barely shifts anything. Then an idle snap: if scrolling stops mid-segment, we glide gently to its center to frame the step cleanly. It's a soft lock - any new scroll input takes over instantly:", + )}

+ @@ -415,8 +423,6 @@ const miniFiles = [ - - + +