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

This commit is contained in:
LOUIS POTEVIN
2026-07-02 14:46:55 +02:00
parent 611af1ac67
commit af3d331114
15 changed files with 385 additions and 140 deletions
+12 -1
View File
@@ -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, - 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). 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 ## /docs — la documentation technique
Une page dédiée (`/docs`, FR/EN) documente tout le fonctionnement du site en 13 chapitres, 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 ## À 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/`). 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) — 3. `/about` et `/contact` redirigent vers `/#about` et `/#contact` (config Astro) —
les anciennes URL indexées ne cassent pas. les anciennes URL indexées ne cassent pas.
+25
View File
@@ -3,6 +3,26 @@ import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap'; import sitemap from '@astrojs/sitemap';
import { fileURLToPath } from 'node:url'; 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( const ndsTokens = fileURLToPath(
new URL('./node_modules/@unkn0wndo3s/nova-design-system/src/styles/tokens', import.meta.url), new URL('./node_modules/@unkn0wndo3s/nova-design-system/src/styles/tokens', import.meta.url),
); );
@@ -38,6 +58,11 @@ export default defineConfig({
], ],
vite: { vite: {
plugins: [stripFontImport],
build: {
// Source maps help debugging in prod and satisfy Lighthouse's audit.
sourcemap: true,
},
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
+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>
)}
+4 -4
View File
@@ -1,5 +1,5 @@
--- ---
import { Github, Gitea, Npm } from 'simple-icons-astro'; import BrandIcon from './BrandIcon.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'; import { getLocaleFromUrl, useTranslations } from '../i18n';
@@ -18,13 +18,13 @@ const year = new Date().getFullYear();
<nav class="footer__links" aria-label="Social"> <nav class="footer__links" aria-label="Social">
<a href={site.links.gitea.url} title="Gitea" rel="me noopener" target="_blank"> <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>
<a href={site.links.github.url} title="GitHub" rel="me noopener" target="_blank"> <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>
<a href={site.links.npm.url} title="npm" rel="me noopener" target="_blank"> <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>
<a href={site.links.linkedin.url} title="LinkedIn" rel="me noopener" target="_blank"> <a href={site.links.linkedin.url} title="LinkedIn" rel="me noopener" target="_blank">
<BrandLinkedin size={20} /><span class="sr-only">LinkedIn</span> <BrandLinkedin size={20} /><span class="sr-only">LinkedIn</span>
+2 -4
View File
@@ -12,13 +12,11 @@ const locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale); const t = useTranslations(locale);
const route = unlocalizePath(Astro.url.pathname); 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 = [ const links = [
{ href: localizePath('/', locale), label: t('nav.home'), match: '/' },
{ href: localizePath('/work', locale), label: t('nav.work'), match: '/work' }, { href: localizePath('/work', locale), label: t('nav.work'), match: '/work' },
{ href: localizePath('/docs', locale), label: t('nav.docs'), match: '/docs' }, { 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) => const isActive = (match: string) =>
+4 -14
View File
@@ -1,13 +1,9 @@
--- ---
/** /**
* Renders a technology icon by simple-icons name, with its label. * Renders a technology icon (via BrandIcon) with its label. The icon is
* Icons are imported explicitly so the build only ships what's used. * decorative: only the visible label is announced, once.
*/ */
import { import BrandIcon from './BrandIcon.astro';
Apache, Astro as AstroIcon, Cloudflare, Docker, Figma, Git, Gitea,
Javascript, Linux, Nodedotjs, Npm, Postgresql, Python, Rust, Sass,
Threedotjs, Typescript, Vuedotjs,
} from 'simple-icons-astro';
export interface Props { export interface Props {
name: string; name: string;
@@ -15,18 +11,12 @@ export interface Props {
size?: number; 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 { name, icon, size = 18 } = Astro.props;
const Icon = icons[icon];
--- ---
<span class="tech"> <span class="tech">
{Icon && <Icon size={size} aria-hidden="true" />} <BrandIcon icon={icon} size={size} />
<span>{name}</span> <span>{name}</span>
</span> </span>
+71 -12
View File
@@ -56,6 +56,13 @@ const smoothstep = (a: number, b: number, x: number) => {
return t * t * (3 - 2 * t); return t * t * (3 - 2 * t);
}; };
const lerp = (a: number, b: number, t: number) => a + (b - a) * 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: { export function mountVoyage(opts: {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
@@ -350,10 +357,18 @@ export function mountVoyage(opts: {
let progress = 0; let progress = 0;
let downEnergy = 0; let downEnergy = 0;
let upEnergy = 0; let upEnergy = 0;
let idleTime = 0;
let snapped = true; // don't snap on load
let elapsed = 0; let elapsed = 0;
let last = performance.now(); let last = performance.now();
let running = true; let running = true;
let frame = 0; 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 camTarget = new THREE.Vector3();
const tmp = new THREE.Vector3(); const tmp = new THREE.Vector3();
@@ -373,6 +388,18 @@ export function mountVoyage(opts: {
last = now; last = now;
elapsed += dt; 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). // Scroll → timeline progress + belt energies (Lenis keeps this smooth).
const raw = lenis.scroll / maxScroll(); const raw = lenis.scroll / maxScroll();
progress = Math.min(1, Math.max(0, raw)); 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); 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); upEnergy = lerp(upEnergy, Math.min(1, Math.max(0, -v / 55)), 0.08);
// Camera along the path. // Idle snap: once the user pauses inside a segment, settle on its center
const u = progress; // 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(u, camera.position);
path.getPointAt(Math.min(1, u + 0.045), camTarget); path.getPointAt(Math.min(1, u + 0.045), camTarget);
// Ease the gaze toward the active beacon. // 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!))); 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); applyOverlay(0);
frame = requestAnimationFrame(tick); frame = requestAnimationFrame(tick);
return { return { seek, dispose };
seek,
dispose() {
cancelAnimationFrame(frame);
window.removeEventListener('resize', onResize);
document.removeEventListener('visibilitychange', onVisibility);
renderer.dispose();
document.documentElement.classList.remove('cinema');
},
};
} }
+50 -11
View File
@@ -28,8 +28,13 @@ import {
} from '@unkn0wndo3s/nova-design-system'; } from '@unkn0wndo3s/nova-design-system';
import Eyebrow from '../Eyebrow.astro'; import Eyebrow from '../Eyebrow.astro';
import { getLocaleFromUrl, useTranslations } from '../../i18n'; import { getLocaleFromUrl, useTranslations } from '../../i18n';
import { NDS_COMPONENTS, NDS_COMPONENT_COUNT, NDS_PACKAGE, getNdsVersion } from '../../lib/ndsMeta';
import type { Localized } from '../../data/projects'; 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 locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale); const t = useTranslations(locale);
@@ -166,11 +171,7 @@ const scatter = [
[0, 22], [36, 2], [0, 22], [36, 2],
]; ];
const library = [ const library = NDS_COMPONENTS;
'Avatar', 'Badge', 'Breadcrumb', 'Button', 'Card', 'Checkbox', 'Link', 'ListItem',
'LoadingBar', 'Modal', 'Navbar', 'Notification', 'NumericStepper', 'Pagination',
'Radio', 'Select', 'Sidebar', 'Tab', 'TextField', 'Toggle', 'Tooltip',
];
const actLabels: Record<string, Localized> = { const actLabels: Record<string, Localized> = {
a1: { fr: 'Acte I - Tout part des tokens.', en: 'Act I - Everything starts with tokens.' }, 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> </ul>
<div class="stats"> <div class="stats">
<p><strong data-count-to="29">0</strong><span>{locale === 'fr' ? 'composants' : 'components'}</span></p> <p><strong data-count-to={NDS_COMPONENT_COUNT}>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>v{ndsVersion}</strong><span>{locale === 'fr' ? 'dernière version npm' : 'latest npm version'}</span></p>
<p><strong>v1.2.1</strong><span>npm</span></p>
</div> </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> </div>
<p class="hint" aria-hidden="true">{t('nds.showcase.hint')}</p> <p class="hint" aria-hidden="true">{t('nds.showcase.hint')}</p>
@@ -330,6 +330,7 @@ const actLabels: Record<string, Localized> = {
<script> <script>
import { scrub, clamp01, window01 } from '../../lib/scrub'; import { scrub, clamp01, window01 } from '../../lib/scrub';
import { getSmooth } from '../../lib/smooth';
function init() { function init() {
const root = document.querySelector<HTMLElement>('[data-forge]'); const root = document.querySelector<HTMLElement>('[data-forge]');
@@ -364,6 +365,7 @@ const actLabels: Record<string, Localized> = {
let lastIdx = -1; let lastIdx = -1;
let lastLabel = -1; let lastLabel = -1;
let lastState = { p: 0, idx: 0, dwellT: 0.5, inTunnel: 0 };
scrub(root, (p) => { scrub(root, (p) => {
// ---- Act I: scattered tokens converge, then hand over. ---- // ---- 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'; act2.style.visibility = inTunnel > 0.01 ? 'visible' : 'hidden';
const tp = clamp01((p - A2[0]) / (A2[1] - A2[0])); // 0..1 inside act II const tp = clamp01((p - A2[0]) / (A2[1] - A2[0])); // 0..1 inside act II
const f = tp * n; // fractional slide index const f0 = tp * n;
const idx = Math.min(n - 1, Math.floor(f)); 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) => { slides.forEach((el, i) => {
const d = f - i - 0.5; // 0 when slide i is centered const d = f - i - 0.5; // 0 when slide i is centered
@@ -425,6 +432,38 @@ const actLabels: Record<string, Localized> = {
}, 160); }, 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(); init();
+5 -3
View File
@@ -3,6 +3,8 @@ import type { Locale } from '../i18n';
/** A string localized in both site languages. */ /** A string localized in both site languages. */
export type Localized = Record<Locale, string>; export type Localized = Record<Locale, string>;
import { NDS_COMPONENT_COUNT } from '../lib/ndsMeta';
export type ProjectStatus = 'live' | 'maintained' | 'wip'; export type ProjectStatus = 'live' | 'maintained' | 'wip';
export interface ProjectLink { export interface ProjectLink {
@@ -35,8 +37,8 @@ export const projects: Project[] = [
en: 'Astro component library - tokens, accessibility, npm distribution.', en: 'Astro component library - tokens, accessibility, npm distribution.',
}, },
summary: { 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.", 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 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.', 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: [ body: [
{ {
@@ -53,7 +55,7 @@ export const projects: Project[] = [
}, },
], ],
highlights: [ 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: '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: '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' }, { fr: 'Licence maison : usage libre, monétisation du DS interdite', en: 'Custom license: free to use, monetizing NDS itself prohibited' },
+28 -28
View File
@@ -1,10 +1,12 @@
import { NDS_COMPONENT_COUNT } from '../lib/ndsMeta';
/** /**
* UI dictionaries. Keys are grouped by surface (nav, home, work, about, contact, footer). * UI dictionaries. Keys are grouped by surface (nav, home, work, about, contact, footer).
* Project content lives in `src/data/projects.ts` (localized per field). * Project content lives in `src/data/projects.ts` (localized per field).
*/ */
const fr = { const fr = {
// Meta // 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': '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.", "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.title': 'Projets - Louis Potevin, développeur full-stack',
@@ -23,7 +25,7 @@ const fr = {
// Nav // Nav
'nav.home': 'Accueil', 'nav.home': 'Accueil',
'nav.work': 'Projets', 'nav.work': 'Projets',
'nav.docs': 'Docs', 'nav.docs': "Comment c'est fait",
'nav.about': 'À propos', 'nav.about': 'À propos',
'nav.contact': 'Contact', 'nav.contact': 'Contact',
'nav.cta': 'Me contacter', 'nav.cta': 'Me contacter',
@@ -38,25 +40,24 @@ const fr = {
// Home // Home
'home.hero.title.role': 'Développeur full-stack', 'home.hero.title.role': 'Développeur full-stack',
'home.hero.lead': '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.viewProjects': 'Voir les projets',
'home.hero.contact': 'Me contacter', 'home.hero.contact': 'Me contacter',
'home.profile.eyebrow': 'Profil', 'home.profile.eyebrow': 'Profil',
'home.profile.title': 'Un profil full-stack, à laise sur toute la chaîne.', 'home.profile.title': 'Un profil full-stack, à laise sur toute la chaîne.',
'home.profile.body1': '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': '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.title': 'Front-end',
'home.profile.front.body': '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.`,
'Interfaces soignées et accessibles avec Astro, Vue.js, TypeScript et Sass - jusquau design system maison.',
'home.profile.back.title': 'Back-end', 'home.profile.back.title': 'Back-end',
'home.profile.back.body': '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.title': 'Outils & déploiement',
'home.profile.ops.body': '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.eyebrow': 'Projets phares',
'home.projects.title': 'Projets', 'home.projects.title': 'Projets',
@@ -66,16 +67,16 @@ const fr = {
'home.stack.eyebrow': 'Stack technique', 'home.stack.eyebrow': 'Stack technique',
'home.stack.title': 'Mes outils du quotidien.', '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': '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.contact': 'Me contacter',
'home.cta.more': 'En savoir plus', 'home.cta.more': 'En savoir plus',
// Work // Work
'work.title': 'Projets', 'work.title': 'Projets',
'work.lead': '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.live': 'En production',
'work.status.maintained': 'Maintenu', 'work.status.maintained': 'Maintenu',
'work.status.wip': 'En cours', 'work.status.wip': 'En cours',
@@ -128,9 +129,9 @@ const fr = {
// Voyage (home cinematic scroll) // Voyage (home cinematic scroll)
'voyage.hint': 'Scrollez - le voyage commence', 'voyage.hint': 'Scrollez - le voyage commence',
'voyage.progress': 'Progression du voyage', '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': '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': '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.", "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; } as const;
const en: Record<UIKey, string> = { 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': '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.', '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.title': 'Projects - Louis Potevin, full-stack developer',
@@ -182,7 +183,7 @@ const en: Record<UIKey, string> = {
'nav.home': 'Home', 'nav.home': 'Home',
'nav.work': 'Projects', 'nav.work': 'Projects',
'nav.docs': 'Docs', 'nav.docs': "How it's built",
'nav.about': 'About me', 'nav.about': 'About me',
'nav.contact': 'Contact', 'nav.contact': 'Contact',
'nav.cta': 'Get in touch', 'nav.cta': 'Get in touch',
@@ -195,25 +196,24 @@ const en: Record<UIKey, string> = {
'home.hero.title.role': 'Full-stack developer', 'home.hero.title.role': 'Full-stack developer',
'home.hero.lead': '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.viewProjects': 'View projects',
'home.hero.contact': 'Contact me', 'home.hero.contact': 'Contact me',
'home.profile.eyebrow': 'Profile', 'home.profile.eyebrow': 'Profile',
'home.profile.title': 'A full-stack profile, comfortable across the entire stack.', 'home.profile.title': 'A full-stack profile, comfortable across the entire stack.',
'home.profile.body1': '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': '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.title': 'Front-end',
'home.profile.front.body': '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.`,
'Polished, accessible interfaces with Astro, Vue.js, TypeScript and Sass - all the way to an in-house design system.',
'home.profile.back.title': 'Back-end', 'home.profile.back.title': 'Back-end',
'home.profile.back.body': '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.title': 'Tools & deployment',
'home.profile.ops.body': '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.eyebrow': 'Featured projects',
'home.projects.title': 'Projects', 'home.projects.title': 'Projects',
@@ -223,15 +223,15 @@ const en: Record<UIKey, string> = {
'home.stack.eyebrow': 'Tech stack', 'home.stack.eyebrow': 'Tech stack',
'home.stack.title': 'My everyday tools.', 'home.stack.title': 'My everyday tools.',
'home.cta.title': "Let's talk!", 'home.cta.title': 'A role, a mission, a question?',
'home.cta.body': '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.contact': 'Contact me',
'home.cta.more': 'Learn more', 'home.cta.more': 'Learn more',
'work.title': 'Projects', 'work.title': 'Projects',
'work.lead': '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.live': 'Live',
'work.status.maintained': 'Maintained', 'work.status.maintained': 'Maintained',
'work.status.wip': 'In progress', 'work.status.wip': 'In progress',
@@ -279,9 +279,9 @@ const en: Record<UIKey, string> = {
'voyage.hint': 'Scroll - the journey begins', 'voyage.hint': 'Scroll - the journey begins',
'voyage.progress': 'Journey progress', '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': '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': '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.', 'BUT MMI degree (web dev) at IUT du Limousin, and two years at Legrand on the same business application - from internship to apprenticeship.',
+4
View File
@@ -51,6 +51,10 @@ const ldBlocks = [personLd(locale), webSiteLd(), ...jsonLd];
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <link rel="sitemap" href="/sitemap-index.xml" />
<title>{title}</title> <title>{title}</title>
+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;
}
+13 -5
View File
@@ -16,6 +16,7 @@ import { Badge } from '@unkn0wndo3s/nova-design-system';
import { Code } from 'astro:components'; import { Code } from 'astro:components';
import { breadcrumbLd } from '../lib/seo'; import { breadcrumbLd } from '../lib/seo';
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n'; import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
import { getNdsVersion } from '../lib/ndsMeta';
import configRaw from '../../astro.config.mjs?raw'; import configRaw from '../../astro.config.mjs?raw';
import smoothRaw from '../lib/smooth.ts?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 locale = getLocaleFromUrl(Astro.url);
const t = useTranslations(locale); const t = useTranslations(locale);
const L = (fr: string, en: string) => (locale === 'fr' ? fr : en); 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. */ /** Slice a source between two unique markers - fails the build if missing. */
function slice(src: string, from: string, to: string, label: string): string { function slice(src: string, from: string, to: string, label: string): string {
@@ -44,12 +46,13 @@ const snips = {
scrub: scrubRaw.trimEnd(), scrub: scrubRaw.trimEnd(),
segments: slice(voyageRaw, ' // --- Timeline from the DOM', ' // --- Scene ---', 'segments'), segments: slice(voyageRaw, ' // --- Timeline from the DOM', ' // --- Scene ---', 'segments'),
camera: slice(voyageRaw, ' // Flight path:', ' // Starfield', 'camera path'), 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'), overlay: slice(voyageRaw, ' const applyOverlay', ' // --- Frame loop', 'overlay'),
cinemaCss: slice(homeRaw, ' /* Cinema: fixed overlays', ' /* ------------------------------- Cards', 'cinema css'), 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'), collisions: slice(voyageRaw, ' // Collisions only while agitated', ' belt.instanceMatrix', 'collisions'),
seek: slice(voyageRaw, ' const seek = (id: string', ' dots.forEach', 'seek'), 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'), gate: slice(voyageRaw, ' // --- Capability gate', ' // --- Timeline from the DOM', 'gate'),
infraDash: slice(infraRaw, ' .edge {', ' .node {', 'infra dash'), infraDash: slice(infraRaw, ' .edge {', ' .node {', 'infra dash'),
sorterMeasure: slice(sorterRaw, ' const measure = () => {', ' let lastP = 0;', 'sorter measure'), sorterMeasure: slice(sorterRaw, ' const measure = () => {', ' let lastP = 0;', 'sorter measure'),
@@ -98,7 +101,7 @@ const miniFiles = [
<Badge type="primary" variant="soft">Three.js</Badge> <Badge type="primary" variant="soft">Three.js</Badge>
<Badge type="primary" variant="soft">Lenis</Badge> <Badge type="primary" variant="soft">Lenis</Badge>
<Badge type="primary" variant="soft">Astro 7</Badge> <Badge type="primary" variant="soft">Astro 7</Badge>
<Badge type="primary" variant="soft">NDS v1.2.1</Badge> <Badge type="primary" variant="soft">{`NDS v${ndsVersion}`}</Badge>
<span class="reading">{t('docs.reading')}</span> <span class="reading">{t('docs.reading')}</span>
</p> </p>
</header> </header>
@@ -251,6 +254,11 @@ const miniFiles = [
<Code code={snips.segments} lang="ts" theme={THEME} /> <Code code={snips.segments} lang="ts" theme={THEME} />
<Code code={snips.camera} lang="ts" theme={THEME} /> <Code code={snips.camera} lang="ts" theme={THEME} />
<Code code={snips.camTick} lang="ts" theme={THEME} /> <Code code={snips.camTick} lang="ts" theme={THEME} />
<p>{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:",
)}</p>
<Code code={snips.snap} lang="ts" theme={THEME} />
</section> </section>
<!-- ============================ 05 ================================ --> <!-- ============================ 05 ================================ -->
@@ -415,8 +423,6 @@ const miniFiles = [
</section> </section>
</div> </div>
</div> </div>
</BaseLayout>
<script> <script>
import { scrub, clamp01, window01 } from '../lib/scrub'; import { scrub, clamp01, window01 } from '../lib/scrub';
import { getSmooth } from '../lib/smooth'; import { getSmooth } from '../lib/smooth';
@@ -703,6 +709,8 @@ const miniFiles = [
} }
} }
</script> </script>
</BaseLayout>
<style lang="scss"> <style lang="scss">
@use '../styles/type' as type; @use '../styles/type' as type;
+30 -58
View File
@@ -11,9 +11,7 @@
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import Eyebrow from '../components/Eyebrow.astro'; import Eyebrow from '../components/Eyebrow.astro';
import TechIcon from '../components/TechIcon.astro'; import TechIcon from '../components/TechIcon.astro';
import BrandLinkedin from '../components/BrandLinkedin.astro'; import { Copy } from '@lucide/astro';
import { Copy, ChevronDown } from '@lucide/astro';
import { Github, Gitea, Npm } from 'simple-icons-astro';
import { Badge, Button } from '@unkn0wndo3s/nova-design-system'; import { Badge, Button } from '@unkn0wndo3s/nova-design-system';
import { site, stack } from '../data/site'; import { site, stack } from '../data/site';
import { featuredProjects, projects, type Project } from '../data/projects'; import { featuredProjects, projects, type Project } from '../data/projects';
@@ -55,15 +53,13 @@ const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.s
<div class="card card--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> <p class="hero-avail"><Badge type="success" variant="soft">{t('avail.badge')}</Badge> <span>{t('avail.line')}</span></p>
<h1 class="hero-title"> <h1 class="hero-title">
<span class="hero-name">{site.name}</span> <span class="hero-name">{site.name}</span><span class="sr-only">, </span>
<span class="hero-role">{t('home.hero.title.role')}</span> <span class="hero-role">{t('home.hero.title.role')}</span>
</h1> </h1>
<p class="hero-lead">{t('home.hero.lead')}</p> <p class="hero-lead">{t('home.hero.lead')}</p>
<div class="row"> <div class="row">
<Button type="primary" href={localizePath('/#projects', locale)}>{t('home.hero.viewProjects')}</Button> <Button type="primary" href={localizePath('/#projects', locale)}>{t('home.hero.viewProjects')}</Button>
<Button type="ghost" href={localizePath('/#contact', locale)}>{t('home.hero.contact')}</Button>
</div> </div>
<p class="hero-hint" aria-hidden="true"><ChevronDown size={16} /> {t('voyage.hint')}</p>
</div> </div>
</section> </section>
@@ -96,7 +92,7 @@ const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.s
<!-- 04-07 · Un segment par projet, cartes alternées --> <!-- 04-07 · Un segment par projet, cartes alternées -->
{voyageProjects.map((p, i) => ( {voyageProjects.map((p, i) => (
<section <div
class={`seg seg--${i % 2 ? 'left' : 'right'}`} class={`seg seg--${i % 2 ? 'left' : 'right'}`}
data-seg={`p-${p.slug}`} data-seg={`p-${p.slug}`}
data-weight="1.15" data-weight="1.15"
@@ -120,7 +116,7 @@ const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.s
</Button> </Button>
</div> </div>
</article> </article>
</section> </div>
))} ))}
<!-- 08 · Stack --> <!-- 08 · Stack -->
@@ -148,12 +144,7 @@ const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.s
</Button> </Button>
</div> </div>
<p class="contact-email" data-email-text>{site.email}</p> <p class="contact-email" data-email-text>{site.email}</p>
<nav class="socials" aria-label="Social"> <p class="contact-where">{t('avail.where')}</p>
<a href={site.links.github.url} target="_blank" rel="me noopener"><Github size={20} aria-hidden="true" /><span class="sr-only">GitHub</span></a>
<a href={site.links.gitea.url} target="_blank" rel="me noopener"><Gitea size={20} aria-hidden="true" /><span class="sr-only">Gitea</span></a>
<a href={site.links.npm.url} target="_blank" rel="me noopener"><Npm size={20} aria-hidden="true" /><span class="sr-only">npm</span></a>
<a href={site.links.linkedin.url} target="_blank" rel="me noopener"><BrandLinkedin size={20} /><span class="sr-only">LinkedIn</span></a>
</nav>
</div> </div>
</section> </section>
</div> </div>
@@ -165,8 +156,6 @@ const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.s
<nav class="dots" aria-label={t('voyage.progress')}> <nav class="dots" aria-label={t('voyage.progress')}>
{dots.map((id) => <button type="button" data-voyage-dot={id} aria-label={id}></button>)} {dots.map((id) => <button type="button" data-voyage-dot={id} aria-label={id}></button>)}
</nav> </nav>
</BaseLayout>
<script> <script>
import { ensureSmooth } from '../lib/smooth'; import { ensureSmooth } from '../lib/smooth';
import type { VoyageHandle } from '../components/scene/voyage'; import type { VoyageHandle } from '../components/scene/voyage';
@@ -220,6 +209,8 @@ const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.s
init(); init();
initCopy(); initCopy();
</script> </script>
</BaseLayout>
<style lang="scss"> <style lang="scss">
@use '../styles/type' as type; @use '../styles/type' as type;
@@ -262,24 +253,35 @@ const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.s
translate: 0 -50%; translate: 0 -50%;
z-index: 60; z-index: 60;
display: grid; display: grid;
gap: 10px; gap: 4px;
/* 22px hit area (touch target), 8px painted dot */
button { button {
width: 8px; position: relative;
height: 8px; width: 22px;
height: 22px;
padding: 0; padding: 0;
border: 1px solid var(--nds-neutral); border: none;
border-radius: var(--nds-radius-full);
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
transition: background-color 0.25s ease, transform 0.25s ease, border-color 0.25s ease;
&.is-active { &::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); background: var(--nds-accent);
border-color: var(--nds-accent); border-color: var(--nds-accent);
transform: scale(1.35); transform: scale(1.35);
} }
&:hover { &:hover::before {
border-color: var(--nds-text); border-color: var(--nds-text);
} }
} }
@@ -408,19 +410,6 @@ const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.s
max-width: 58ch; max-width: 58ch;
color: var(--nds-neutral); color: var(--nds-neutral);
} }
.hero-hint {
@include type.text-sm;
display: inline-flex;
align-items: center;
gap: var(--nds-spacing-2xs);
margin: var(--nds-spacing-lg) 0 0;
color: var(--nds-disabled);
animation: hint 2.4s ease-in-out infinite;
}
@keyframes hint {
0%, 100% { transform: translateY(0); opacity: 0.7; }
50% { transform: translateY(6px); opacity: 1; }
}
/* Profil */ /* Profil */
.pillars { .pillars {
@@ -503,28 +492,11 @@ const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.s
color: var(--nds-primary); color: var(--nds-primary);
word-break: break-all; word-break: break-all;
} }
.socials { .contact-where {
display: flex; @include type.text-sm;
gap: var(--nds-spacing-md); margin: 0;
color: var(--nds-disabled);
a {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border: 1px solid var(--nds-border);
border-radius: var(--nds-radius-full);
color: var(--nds-text);
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
&:hover {
color: var(--nds-accent);
border-color: var(--nds-accent);
transform: translateY(-2px);
}
}
} }
.sr-only { .sr-only {
position: absolute; position: absolute;
width: 1px; width: 1px;