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
Deploy Documentation / check-and-deploy (push) Successful in 16s
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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, à l’aise sur toute la chaîne.',
|
'home.profile.title': 'Un profil full-stack, à l’aise 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 - jusqu’au 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.',
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user