feat: add Home, Project, and Work Index views with responsive design and SEO enhancements
Deploy Documentation / check-and-deploy (push) Successful in 20s
Deploy Documentation / check-and-deploy (push) Successful in 20s
- Implemented HomeView with a timeline layout, featuring sections for hero, profile, projects, stack, and contact. - Created ProjectView to display individual project details, including status, highlights, and tech stack. - Developed WorkIndexView to list all projects with breadcrumb navigation. - Enhanced TypeScript configuration for improved module resolution.
This commit is contained in:
+1
-14
@@ -1,17 +1,4 @@
|
||||
# build
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
|
||||
# env & logs
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# os / editor
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -1,94 +1,96 @@
|
||||
# louis-potevin.dev
|
||||
# louis-potevin.dev — Portfolio v2
|
||||
|
||||
Professional portfolio of **Louis Potevin** - full-stack developer.
|
||||
Static multi-page website built with **Astro + TypeScript + Sass**, styled
|
||||
**exclusively** by the in-house design system **`@unkn0wndo3s/nova-design-system`**
|
||||
(tokens, components), featuring a 3D space background controlled via scrolling.
|
||||
Portfolio bilingue (FR/EN) construit avec **Astro 7**, **Nova Design System**, **Three.js** et **Lenis**.
|
||||
Accueil = un « voyage » : le scroll ne fait jamais défiler une page, il pilote une caméra 3D
|
||||
et l'animation continue. Chaque projet a sa propre scène sur-mesure.
|
||||
|
||||
---
|
||||
## Stack
|
||||
|
||||
## Getting Started
|
||||
- [Astro 7](https://astro.build) — SSG, i18n natif, zéro JS par défaut
|
||||
- [`@unkn0wndo3s/nova-design-system`](https://www.npmjs.com/package/@unkn0wndo3s/nova-design-system) — composants + tokens (source unique du design)
|
||||
- [Three.js](https://threejs.org) — le moteur du voyage (accueil)
|
||||
- [Lenis](https://lenis.darkroom.engineering) — smooth scroll ; toutes les animations lisent le scroll depuis Lenis, d'où la sensation d'une seule courbe de mouvement
|
||||
- Sass — les mixins typographiques NDS sont exposés via un `loadPath` (voir `astro.config.mjs`)
|
||||
|
||||
## Démarrer
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # dev server → http://localhost:4321
|
||||
npm run build # static build → ./dist
|
||||
npm run preview # preview the build
|
||||
npm install # .npmrc force legacy-peer-deps (requis par @lucide/astro avec Astro 7)
|
||||
npm run dev # http://localhost:4321
|
||||
npm run build # sortie statique dans dist/
|
||||
npm run check # astro check (TS + diagnostics) - 0 erreur attendu
|
||||
```
|
||||
|
||||
> **Installation Note.** `@lucide/astro` has not yet declared its
|
||||
compatibility with Astro 7 in its `peerDependencies`. The **`.npmrc`**
|
||||
> file therefore enables `legacy-peer-deps=true` so that `npm install` succeeds.
|
||||
> Functionality is not affected.
|
||||
|
||||
---
|
||||
Node >= 22.12 (contrainte du NDS).
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├─ data/
|
||||
│ ├─ site.ts # profile + links (single source of truth)
|
||||
│ └─ projects.ts # project content
|
||||
├─ layouts/
|
||||
│ └─ BaseLayout.astro # NDS styles + 3D background + nav + footer + HUD
|
||||
├─ components/
|
||||
│ ├─ scene/scene.ts # Three.js background (stars, nebula, ambient asteroid)
|
||||
│ ├─ SpaceBackground.astro
|
||||
│ ├─ ProjectCard.astro # project card (NDS Card component)
|
||||
│ ├─ SiteNav / SiteFooter / Eyebrow / TechIcon / BrandLinkedin
|
||||
└─ pages/
|
||||
├─ index.astro # home
|
||||
├─ work/index.astro # all projects
|
||||
├─ work/[slug].astro # project details (getStaticPaths)
|
||||
├─ about.astro
|
||||
└─ contact.astro
|
||||
├── data/ site.ts (profil, liens), projects.ts (projets localisés + stages du showcase NDS)
|
||||
├── i18n/ index.ts (helpers locale/chemins/hreflang), ui.ts (dictionnaires FR/EN)
|
||||
├── lib/seo.ts JSON-LD : Person, WebSite, BreadcrumbList, SoftwareSourceCode, ProfilePage
|
||||
├── lib/smooth.ts Singleton Lenis · lib/scrub.ts - progression 0..1 des scènes épinglées
|
||||
├── layouts/ BaseLayout.astro (head SEO complet, nav, footer, init Lenis)
|
||||
├── components/
|
||||
│ ├── scene/ voyage.ts - moteur de l'accueil · scene.ts - fond ambiant des sous-pages
|
||||
│ ├── scenes/ InfraScene, BlueprintScene, SorterScene - scènes projet sur-mesure
|
||||
│ └── showcase/ ComponentShowcase.astro - composants NDS "volants" (page projet NDS)
|
||||
├── views/ La logique de chaque page (partagée entre locales)
|
||||
└── pages/ Routes minces : / (FR, défaut) et /en/* (miroirs)
|
||||
```
|
||||
|
||||
## Design system
|
||||
**Règle i18n** : une page = une view. Les routes (`pages/` et `pages/en/`) ne font
|
||||
qu'importer la view ; la locale est déduite de l'URL (`getLocaleFromUrl`).
|
||||
`/` = français (cible : recruteurs FR), `/en/` = anglais. Pour inverser, changer
|
||||
`DEFAULT_LOCALE` dans `src/i18n/index.ts` **et** `i18n.defaultLocale` dans `astro.config.mjs`.
|
||||
|
||||
Components come from `@unkn0wndo3s/nova-design-system` :
|
||||
`import { Button, Card, Badge, Navbar, Breadcrumb, TextField, Select, ... }`.
|
||||
Global styles and tokens : `import '@unkn0wndo3s/nova-design-system/styles'`
|
||||
(done once in `BaseLayout.astro`).
|
||||
## Le voyage (accueil)
|
||||
|
||||
The package ships its raw `.astro`/`.scss` source files - Astro compiles them
|
||||
on the consumer side. Since its `exports` field does not publish token partials
|
||||
individually, the typography partial is exposed via a Sass
|
||||
`loadPath` (see `astro.config.mjs`) and then re-exported by
|
||||
`src/styles/_type.scss` (mixins `text-sm` … `text-5xl`).
|
||||
`voyage.ts` transforme la hauteur de scroll en **timeline** : les sections HTML sont des
|
||||
overlays fixes, et le scroll avance une caméra sur une courbe Catmull-Rom à travers
|
||||
nébuleuses, ceinture d'astéroïdes (une balise lumineuse par projet) et portail final.
|
||||
|
||||
Icons : `@lucide/astro` (UI) and `simple-icons-astro` (tech). Since LinkedIn is
|
||||
no longer distributed by either of them (trademark policy), its glyph is provided
|
||||
locally in `BrandLinkedin.astro`.
|
||||
- **Scroll down** → les astéroïdes dérivent et se percutent (collisions élastiques,
|
||||
étincelles additives).
|
||||
- **Scroll up** → **vortex gravitationnel** : spirale vers la formation d'origine.
|
||||
- Barre de progression + points de navigation latéraux (clic = seek animé).
|
||||
- Les ancres (`/#about`, `/#contact`…) deviennent des seeks sur la timeline.
|
||||
- Progressive enhancement : le même markup rend une page normale empilée sans JS,
|
||||
sans WebGL ou avec `prefers-reduced-motion` (la classe `html.cinema` n'est jamais posée).
|
||||
|
||||
## 3D Background
|
||||
## /docs — la documentation technique
|
||||
|
||||
An ambient asteroid slowly rotates and floats in the background, in the middle of a
|
||||
starfield and a discrete nebula (Three.js). It does not follow scroll actions
|
||||
and produces no engine flame effects: it is a backdrop, not the subject.
|
||||
Une page dédiée (`/docs`, FR/EN) documente tout le fonctionnement du site en 13 chapitres,
|
||||
avec sommaire sticky (scrollspy) et snippets **extraits du vrai code source au build**
|
||||
(imports Vite `?raw` + découpe par marqueurs — si un marqueur disparaît, le build échoue
|
||||
au lieu de publier une doc périmée). Coloration syntaxique Shiki via `astro:components`.
|
||||
|
||||
- **Without WebGL** : the canvas hides cleanly, the site remains readable.
|
||||
## Scènes projet sur-mesure
|
||||
|
||||
Toutes épinglées (section haute + stage sticky), pilotées par `scrub.ts`, fallback statique :
|
||||
|
||||
| Projet | Scène |
|
||||
| --- | --- |
|
||||
| nova-design-system | `ComponentShowcase` (« la forge ») — 3 actes : les tokens (vrais swatches) convergent, 8 vrais composants NDS défilent dans un tunnel 3D avec specs + snippet d'usage, la bibliothèque se fige en grille avec compteurs |
|
||||
| nova-infra | `InfraScene` — le graphe Visiteur → Cloudflare → Apache/VPS se dessine au scroll, puis un paquet circule en continu |
|
||||
| portfolio | `BlueprintScene` — le wireframe du site se trace (dashoffset) puis s'allume aux couleurs NDS |
|
||||
| file-organizer | `SorterScene` — 20 fichiers éparpillés (seed déterministe) se rangent en colonnes au scroll |
|
||||
|
||||
## SEO
|
||||
|
||||
Meticulous search engine optimization, pre-configured:
|
||||
- `hreflang` fr / en / x-default + canonical sur chaque page
|
||||
- JSON-LD : `Person` (@id partagé), `WebSite`, `BreadcrumbList` par page,
|
||||
`SoftwareSourceCode` par projet, `ProfilePage` sur /about
|
||||
- Open Graph + Twitter cards localisés (`og-fr.png` / `og-en.png`)
|
||||
- Sitemap i18n (`@astrojs/sitemap`) + `robots.txt`
|
||||
- Pas de meta `keywords` (ignorées par Google, signal spam pour certains crawlers)
|
||||
|
||||
- Per-page metadata (title, description, keywords), canonical URLs;
|
||||
- Open Graph + Twitter Card with `public/og.png` (1200×630) ;
|
||||
- Structured data JSON-LD (`Person` + `WebSite`) ;
|
||||
- `sitemap-index.xml` (via `@astrojs/sitemap`) + `public/robots.txt`.
|
||||
## À faire côté déploiement
|
||||
|
||||
Remember to update `og.png` if you change your job title, and confirm the
|
||||
links in `site.ts` (used in the `sameAs` property of the structured data).
|
||||
|
||||
## Contact Form
|
||||
|
||||
The site is 100% static: the form in `contact.astro` does not have a
|
||||
backend. The **Send** button opens the user's mail client with a
|
||||
pre-filled message (`mailto:`). For a server-side submission, connect a service
|
||||
(Formspree, Resend, edge function…) to the `#cf-send` button.
|
||||
|
||||
---
|
||||
|
||||
Built with Astro + Nova Design System.
|
||||
1. **`public/og-fr.png` et `public/og-en.png`** (1200×630) — visuels de partage.
|
||||
2. Brancher le workflow **Gitea Actions** existant (`npm ci && npm run build`, publier `dist/`).
|
||||
3. `/about` et `/contact` redirigent vers `/#about` et `/#contact` (config Astro) —
|
||||
les anciennes URL indexées ne cassent pas.
|
||||
4. Tester le voyage sur un vrai GPU/trackpad et ajuster les poids `data-weight`
|
||||
des sections dans `HomeView.astro` (durée relative de chaque étape).
|
||||
|
||||
+34
-7
@@ -1,20 +1,47 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ndsTokens = fileURLToPath(
|
||||
new URL('./node_modules/@unkn0wndo3s/nova-design-system/src/styles/tokens', import.meta.url),
|
||||
);
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://louis-potevin.dev',
|
||||
trailingSlash: 'ignore',
|
||||
integrations: [sitemap()],
|
||||
|
||||
// About & Contact now live on the home timeline.
|
||||
redirects: {
|
||||
'/about': '/#about',
|
||||
'/contact': '/#contact',
|
||||
'/en/about': '/en/#about',
|
||||
'/en/contact': '/en/#contact',
|
||||
},
|
||||
|
||||
i18n: {
|
||||
defaultLocale: 'fr',
|
||||
locales: ['fr', 'en'],
|
||||
routing: {
|
||||
prefixDefaultLocale: false,
|
||||
},
|
||||
},
|
||||
|
||||
integrations: [
|
||||
sitemap({
|
||||
i18n: {
|
||||
defaultLocale: 'fr',
|
||||
locales: { fr: 'fr-FR', en: 'en-US' },
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
vite: {
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
quietDeps: true,
|
||||
loadPaths: [
|
||||
'node_modules',
|
||||
'node_modules/@unkn0wndo3s/nova-design-system/src/styles',
|
||||
'src',
|
||||
],
|
||||
loadPaths: [ndsTokens],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+1412
-551
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -1,28 +1,28 @@
|
||||
{
|
||||
"name": "louis-potevin-dev",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"name": "louis-potevin.dev",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "Portfolio Louis Potevin - louis-potevin.dev. Astro + Nova Design System.",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"type": "module",
|
||||
"description": "Portfolio of Louis Potevin - full-stack developer. Astro + TypeScript + Sass, powered by Nova Design System.",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
"check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "^3.7.3",
|
||||
"@lucide/astro": "^1.21.0",
|
||||
"@lucide/astro": "^1.23.0",
|
||||
"@types/three": "^0.185.0",
|
||||
"@unkn0wndo3s/nova-design-system": "^1.2.1",
|
||||
"astro": "^7.0.2",
|
||||
"astro": "^7.0.5",
|
||||
"lenis": "^1.3.25",
|
||||
"sass": "^1.101.0",
|
||||
"simple-icons-astro": "^16.1.0",
|
||||
"three": "^0.184.0"
|
||||
"three": "^0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/three": "^0.184.0",
|
||||
"sass": "^1.101.0"
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-11
@@ -1,12 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#040B0B"/>
|
||||
<!-- traînée de feu -->
|
||||
<path d="M7 25 L13 19 L17 23 L11 29 Z" fill="#E0A23A" opacity="0.9"/>
|
||||
<path d="M9 27 L14 22 L16 24 L11 29 Z" fill="#E05A59" opacity="0.85"/>
|
||||
<!-- astéroïde -->
|
||||
<circle cx="20" cy="12" r="6" fill="#2A4040"/>
|
||||
<circle cx="20" cy="12" r="6" fill="none" stroke="#34C2C9" stroke-width="1.2"/>
|
||||
<circle cx="18" cy="10.5" r="1.1" fill="#040B0B"/>
|
||||
<circle cx="22" cy="13.5" r="0.9" fill="#040B0B"/>
|
||||
<circle cx="21" cy="9.5" r="0.7" fill="#040B0B"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- Nova mark: a four-point spark on the NDS dark background. -->
|
||||
<rect width="64" height="64" rx="14" fill="#040B0B"/>
|
||||
<path d="M32 8 L38.5 25.5 L56 32 L38.5 38.5 L32 56 L25.5 38.5 L8 32 L25.5 25.5 Z" fill="#2EFAFA"/>
|
||||
<circle cx="47" cy="15" r="3.4" fill="#34C2C9"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 608 B After Width: | Height: | Size: 345 B |
@@ -1,23 +1,19 @@
|
||||
---
|
||||
// LinkedIn is no longer distributed by @lucide/astro or simple-icons-astro
|
||||
// (trademark policy). Official glyph reproduced inline, monochrome
|
||||
// via `currentColor` to remain consistent with the other icons.
|
||||
export interface Props {
|
||||
size?: number;
|
||||
}
|
||||
const { size = 18 } = Astro.props;
|
||||
/**
|
||||
* LinkedIn glyph, provided locally: neither lucide nor simple-icons
|
||||
* distributes it anymore (trademark policy).
|
||||
*/
|
||||
export interface Props { size?: number }
|
||||
const { size = 20 } = Astro.props;
|
||||
---
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="LinkedIn"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.85 0-2.14 1.45-2.14 2.94v5.67H9.34V9h3.42v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.22.79 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z"
|
||||
></path>
|
||||
<path d="M20.45 20.45h-3.55v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.55V9h3.57v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.72v20.55C0 23.23.79 24 1.77 24h20.45c.98 0 1.78-.77 1.78-1.73V1.72C24 .77 23.2 0 22.22 0z"/>
|
||||
</svg>
|
||||
@@ -1,39 +1,29 @@
|
||||
---
|
||||
export interface Props {
|
||||
index?: string; // ex. "01"
|
||||
label: string;
|
||||
index?: string;
|
||||
}
|
||||
const { index, label } = Astro.props;
|
||||
const { index } = Astro.props;
|
||||
---
|
||||
|
||||
<p class="eyebrow">
|
||||
{index && <span class="eyebrow__idx">{index}</span>}
|
||||
<span class="eyebrow__line" aria-hidden="true"></span>
|
||||
<span class="eyebrow__label">{label}</span>
|
||||
{index && <span class="eyebrow__index" aria-hidden="true">{index}</span>}
|
||||
<span class="eyebrow__label"><slot /></span>
|
||||
</p>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'styles/type' as *;
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin: 0 0 var(--nds-spacing-md);
|
||||
@include type.text-label;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--nds-spacing-xs);
|
||||
color: var(--nds-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.eyebrow__idx {
|
||||
.eyebrow__index {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--nds-accent);
|
||||
}
|
||||
.eyebrow__line {
|
||||
width: 28px;
|
||||
height: 1px;
|
||||
background: var(--nds-border-strong);
|
||||
}
|
||||
.eyebrow__label {
|
||||
@include text-eyebrow;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,104 +1,118 @@
|
||||
---
|
||||
import { Card, Badge, Link } from '@unkn0wndo3s/nova-design-system';
|
||||
import { ArrowUpRight } from '@lucide/astro';
|
||||
import { Badge, Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import TechIcon from './TechIcon.astro';
|
||||
import { statusMeta, type Project } from '../data/projects';
|
||||
import type { Project } from '../data/projects';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
export interface Props {
|
||||
project: Project;
|
||||
index?: number;
|
||||
headingLevel?: 'h2' | 'h3';
|
||||
}
|
||||
const { project } = Astro.props;
|
||||
const status = statusMeta[project.status];
|
||||
const href = `/work/${project.slug}`;
|
||||
|
||||
const { project, index, headingLevel: Heading = 'h3' } = Astro.props;
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const statusType = { live: 'success', maintained: 'info', wip: 'warning' } as const;
|
||||
const statusLabel = {
|
||||
live: t('work.status.live'),
|
||||
maintained: t('work.status.maintained'),
|
||||
wip: t('work.status.wip'),
|
||||
} as const;
|
||||
|
||||
const href = localizePath(`/work/${project.slug}`, locale);
|
||||
---
|
||||
|
||||
<article class="proj">
|
||||
<Card title={project.title} subtitle={project.tagline}>
|
||||
<div class="proj__meta">
|
||||
<span class="proj__code">{project.designation}</span>
|
||||
<Badge type={status.tone} variant="soft">{status.label}</Badge>
|
||||
</div>
|
||||
<p class="proj__summary">{project.summary}</p>
|
||||
<div class="proj__stack" aria-label="Stack technique">
|
||||
{
|
||||
project.stack.map((s) => (
|
||||
<span class="proj__chip" title={s.label}>
|
||||
<TechIcon name={s.icon} size={15} />
|
||||
{s.label}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div slot="footer" class="proj__footer">
|
||||
<Link url={href}>
|
||||
View project
|
||||
<ArrowUpRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<article class="project-card">
|
||||
<header class="project-card__head">
|
||||
{typeof index === 'number' && (
|
||||
<span class="project-card__index" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||||
)}
|
||||
<Badge type={statusType[project.status]}>{statusLabel[project.status]}</Badge>
|
||||
</header>
|
||||
|
||||
<Heading class="project-card__title">
|
||||
<a href={href}>{project.name}</a>
|
||||
</Heading>
|
||||
<p class="project-card__tagline">{project.tagline[locale]}</p>
|
||||
<p class="project-card__summary">{project.summary[locale]}</p>
|
||||
|
||||
<ul class="project-card__stack" aria-label={t('work.stack')}>
|
||||
{project.stack.map((tech) => (
|
||||
<li><TechIcon name={tech.name} icon={tech.icon} size={16} /></li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<footer class="project-card__footer">
|
||||
<Button href={href} type="secondary" size="sm">{t('home.projects.view')}</Button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'styles/type' as *;
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.proj :global(.card) {
|
||||
width: 100%;
|
||||
.project-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-sm);
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--nds-surface) 78%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-color: color-mix(in srgb, var(--nds-border) 90%, transparent);
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
border-color 200ms ease,
|
||||
box-shadow 200ms ease;
|
||||
}
|
||||
.proj :global(.card:hover) {
|
||||
transform: translateY(-3px);
|
||||
border-color: color-mix(in srgb, var(--nds-primary) 45%, var(--nds-border));
|
||||
padding: clamp(20px, 3vw, 32px);
|
||||
background: color-mix(in srgb, var(--nds-surface) 88%, transparent);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-xl);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--nds-border-strong);
|
||||
box-shadow: var(--nds-shadow-lg);
|
||||
}
|
||||
.proj :global(.card__body) {
|
||||
gap: var(--nds-spacing-sm);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.proj__meta {
|
||||
.project-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
justify-content: space-between;
|
||||
}
|
||||
.proj__code {
|
||||
|
||||
.project-card__index {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nds-accent);
|
||||
}
|
||||
.proj__summary {
|
||||
@include text-base;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0;
|
||||
}
|
||||
.proj__stack {
|
||||
|
||||
.project-card__title {
|
||||
@include type.text-2xl;
|
||||
|
||||
a {
|
||||
color: var(--nds-text);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { color: var(--nds-primary); }
|
||||
}
|
||||
}
|
||||
|
||||
.project-card__tagline {
|
||||
@include type.text-lg;
|
||||
color: var(--nds-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-card__summary {
|
||||
@include type.text-base;
|
||||
color: var(--nds-neutral);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.project-card__stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
margin-top: var(--nds-spacing-2xs);
|
||||
}
|
||||
.proj__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
padding: 3px var(--nds-spacing-xs);
|
||||
border-radius: var(--nds-radius-full);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
background: var(--nds-surface-2);
|
||||
color: var(--nds-neutral);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.proj__footer {
|
||||
margin-top: auto;
|
||||
gap: var(--nds-spacing-xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.project-card__footer { margin-top: var(--nds-spacing-xs); }
|
||||
</style>
|
||||
|
||||
@@ -1,134 +1,98 @@
|
||||
---
|
||||
import { Gitea, Github, Npm } from 'simple-icons-astro';
|
||||
import { Mail } from '@lucide/astro';
|
||||
import { Github, Gitea, Npm } from 'simple-icons-astro';
|
||||
import BrandLinkedin from './BrandLinkedin.astro';
|
||||
import { site } from '../data/site';
|
||||
import { getLocaleFromUrl, useTranslations } from '../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
const year = new Date().getFullYear();
|
||||
const socials = [
|
||||
{ ...site.links.gitea, Comp: Gitea },
|
||||
{ ...site.links.github, Comp: Github },
|
||||
{ ...site.links.npm, Comp: Npm },
|
||||
];
|
||||
---
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer__inner">
|
||||
<div class="footer__brand">
|
||||
<span class="footer__mark" aria-hidden="true"></span>
|
||||
<div>
|
||||
<p class="footer__name">{site.name}</p>
|
||||
<p class="footer__role">{site.role} · {site.location}</p>
|
||||
</div>
|
||||
<p class="footer__role">{t('footer.role')}</p>
|
||||
</div>
|
||||
|
||||
<nav class="footer__links" aria-label="Liens externes">
|
||||
{
|
||||
socials.map((s) => (
|
||||
<a class="footer__link" href={s.url} target="_blank" rel="noopener noreferrer" title={s.label}>
|
||||
<s.Comp size={18} fill="currentColor" />
|
||||
<span>{s.label}</span>
|
||||
<nav class="footer__links" aria-label="Social">
|
||||
<a href={site.links.gitea.url} title="Gitea" rel="me noopener" target="_blank">
|
||||
<Gitea size={20} aria-hidden="true" /><span class="sr-only">Gitea</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<a class="footer__link" href={site.links.linkedin.url} target="_blank" rel="noopener noreferrer" title="LinkedIn">
|
||||
<BrandLinkedin size={18} />
|
||||
<span>LinkedIn</span>
|
||||
<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>
|
||||
</a>
|
||||
<a class="footer__link" href={`mailto:${site.email}`} title="E-mail">
|
||||
<Mail size={18} />
|
||||
<span>E-mail</span>
|
||||
<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>
|
||||
</a>
|
||||
<a href={site.links.linkedin.url} title="LinkedIn" rel="me noopener" target="_blank">
|
||||
<BrandLinkedin size={20} /><span class="sr-only">LinkedIn</span>
|
||||
</a>
|
||||
<a href={`mailto:${site.email}`} title="E-mail">{t('contact.email.label')}</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="footer__base">
|
||||
<span>© {year} {site.name}</span>
|
||||
<span class="footer__sep" aria-hidden="true">·</span>
|
||||
<span>Made with Astro + Nova Design System</span>
|
||||
</div>
|
||||
<p class="footer__legal">© {year} {site.name} · {t('footer.madeWith')}</p>
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'styles/type' as *;
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
border-top: var(--nds-border-width-thin) solid
|
||||
color-mix(in srgb, var(--nds-border) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--nds-surface) 55%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
margin-top: var(--nds-spacing-3xl);
|
||||
margin-top: clamp(48px, 8vh, 96px);
|
||||
padding: var(--nds-spacing-2xl) clamp(20px, 5vw, 56px) var(--nds-spacing-xl);
|
||||
border-top: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
background: color-mix(in srgb, var(--nds-surface) 60%, transparent);
|
||||
}
|
||||
|
||||
.footer__inner {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: var(--nds-spacing-2xl) clamp(20px, 5vw, 56px) var(--nds-spacing-lg);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-lg);
|
||||
}
|
||||
.footer__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
}
|
||||
.footer__mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--nds-radius-md);
|
||||
background: linear-gradient(135deg, var(--nds-primary), var(--nds-accent));
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--nds-primary) 45%, transparent);
|
||||
}
|
||||
.footer__name {
|
||||
@include text-xl;
|
||||
margin: 0;
|
||||
justify-content: space-between;
|
||||
gap: var(--nds-spacing-lg);
|
||||
max-width: 1200px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.footer__name { @include type.text-xl; }
|
||||
.footer__role {
|
||||
@include text-sm;
|
||||
@include type.text-base;
|
||||
color: var(--nds-neutral);
|
||||
margin: 2px 0 0;
|
||||
margin-top: var(--nds-spacing-2xs);
|
||||
}
|
||||
|
||||
.footer__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-md);
|
||||
}
|
||||
.footer__link {
|
||||
|
||||
a {
|
||||
@include type.text-label;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
color: var(--nds-neutral);
|
||||
text-decoration: none;
|
||||
@include text-label;
|
||||
transition: color 140ms ease;
|
||||
&:hover {
|
||||
color: var(--nds-primary);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--nds-ring);
|
||||
border-radius: var(--nds-radius-sm);
|
||||
transition: color 0.15s ease;
|
||||
&:hover { color: var(--nds-primary); }
|
||||
}
|
||||
}
|
||||
.footer__base {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: var(--nds-spacing-md) clamp(20px, 5vw, 56px) var(--nds-spacing-2xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
border-top: var(--nds-border-width-thin) solid
|
||||
color-mix(in srgb, var(--nds-border) 50%, transparent);
|
||||
@include text-sm;
|
||||
|
||||
.footer__legal {
|
||||
@include type.text-sm;
|
||||
max-width: 1200px;
|
||||
margin: var(--nds-spacing-lg) auto 0;
|
||||
color: var(--nds-neutral);
|
||||
font-family: var(--nds-font-mono);
|
||||
}
|
||||
.footer__sep {
|
||||
opacity: 0.5;
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
+157
-38
@@ -1,57 +1,176 @@
|
||||
---
|
||||
import { Navbar, Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import { Send } from '@lucide/astro';
|
||||
import { Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import {
|
||||
getLocaleFromUrl,
|
||||
useTranslations,
|
||||
localizePath,
|
||||
unlocalizePath,
|
||||
LOCALES,
|
||||
} from '../i18n';
|
||||
|
||||
const path = Astro.url.pathname.replace(/\/$/, '') || '/';
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
const route = unlocalizePath(Astro.url.pathname);
|
||||
|
||||
// About & Contact live on the home timeline - anchors seek the voyage there.
|
||||
const links = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/work', label: 'Projects' },
|
||||
{ href: '/about', label: 'About me' },
|
||||
{ href: '/contact', label: 'Contact' },
|
||||
{ href: localizePath('/', locale), label: t('nav.home'), match: '/' },
|
||||
{ href: localizePath('/work', locale), label: t('nav.work'), match: '/work' },
|
||||
{ href: localizePath('/docs', locale), label: t('nav.docs'), match: '/docs' },
|
||||
{ href: localizePath('/#about', locale), label: t('nav.about'), match: '/about' },
|
||||
{ href: localizePath('/#contact', locale), label: t('nav.contact'), match: '/contact' },
|
||||
];
|
||||
const isActive = (href: string) =>
|
||||
href === '/' ? path === '/' : path === href || path.startsWith(href + '/');
|
||||
|
||||
const isActive = (match: string) =>
|
||||
match === '/' ? route === '/' : route === match || route.startsWith(`${match}/`);
|
||||
---
|
||||
|
||||
<div class="site-nav">
|
||||
<Navbar brand="L · POTEVIN">
|
||||
{
|
||||
links.map((l) => (
|
||||
<a
|
||||
class={`navbar__link ${isActive(l.href) ? 'navbar__link--active' : ''}`}
|
||||
href={l.href}
|
||||
aria-current={isActive(l.href) ? 'page' : undefined}
|
||||
>
|
||||
{l.label}
|
||||
<header class="nav" data-nav>
|
||||
<a class="nav__brand" href={localizePath('/', locale)} aria-label={t('nav.home')}>
|
||||
L<span aria-hidden="true"> · </span>POTEVIN
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<Button slot="right" type="primary" size="sm" href="/contact">
|
||||
<Send slot="icon-left" size={15} />
|
||||
Get in touch
|
||||
</Button>
|
||||
</Navbar>
|
||||
</div>
|
||||
|
||||
<nav class="nav__links" aria-label="Main">
|
||||
{links.map((l) => (
|
||||
<a href={l.href} aria-current={isActive(l.match) ? 'page' : undefined}>{l.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div class="nav__right">
|
||||
<nav class="nav__lang" aria-label={t('a11y.langSwitch')}>
|
||||
{LOCALES.map((l, i) => (
|
||||
<>
|
||||
{i > 0 && <span aria-hidden="true">/</span>}
|
||||
<a
|
||||
href={localizePath(route, l)}
|
||||
hreflang={l}
|
||||
aria-current={l === locale ? 'true' : undefined}
|
||||
>
|
||||
{l.toUpperCase()}
|
||||
</a>
|
||||
</>
|
||||
))}
|
||||
</nav>
|
||||
<span class="nav__cta">
|
||||
<Button href={localizePath('/#contact', locale)} size="sm">{t('nav.cta')}</Button>
|
||||
</span>
|
||||
<button class="nav__burger" data-nav-toggle aria-expanded="false" aria-label="Menu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
.site-nav {
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 60;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-lg);
|
||||
padding: var(--nds-spacing-sm) clamp(20px, 5vw, 56px);
|
||||
background: color-mix(in srgb, var(--nds-background) 78%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
}
|
||||
|
||||
// Nova Navbar rendered translucent to let the starfield show through.
|
||||
:global(.navbar) {
|
||||
background: color-mix(in srgb, var(--nds-background) 72%, transparent);
|
||||
backdrop-filter: blur(14px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.1);
|
||||
border-bottom: var(--nds-border-width-thin) solid
|
||||
color-mix(in srgb, var(--nds-border) 80%, transparent);
|
||||
.nav__brand {
|
||||
@include type.text-xl;
|
||||
color: var(--nds-text);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav__links {
|
||||
display: flex;
|
||||
gap: var(--nds-spacing-lg);
|
||||
margin-inline: auto;
|
||||
|
||||
a {
|
||||
@include type.text-label;
|
||||
color: var(--nds-neutral);
|
||||
text-decoration: none;
|
||||
padding-block: var(--nds-spacing-2xs);
|
||||
border-bottom: var(--nds-border-width-medium) solid transparent;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover { color: var(--nds-text); }
|
||||
&[aria-current='page'] {
|
||||
color: var(--nds-primary);
|
||||
border-bottom-color: var(--nds-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.site-nav :global(.navbar__links) {
|
||||
.nav__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-md);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nav__lang {
|
||||
@include type.text-label;
|
||||
display: flex;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
color: var(--nds-neutral);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
&:hover { color: var(--nds-text); }
|
||||
&[aria-current='true'] { color: var(--nds-primary); }
|
||||
}
|
||||
}
|
||||
|
||||
.nav__burger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: var(--nds-spacing-xs);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: var(--nds-text);
|
||||
border-radius: var(--nds-radius-full);
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
&[aria-expanded='true'] {
|
||||
span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
|
||||
span:nth-child(2) { opacity: 0; }
|
||||
span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.nav { flex-wrap: wrap; }
|
||||
.nav__burger { display: flex; }
|
||||
.nav__cta { display: none; }
|
||||
|
||||
.nav__links {
|
||||
display: none;
|
||||
flex-basis: 100%;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-sm);
|
||||
padding-block: var(--nds-spacing-md);
|
||||
|
||||
.nav[data-open] & { display: flex; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const nav = document.querySelector('[data-nav]');
|
||||
const toggle = document.querySelector('[data-nav-toggle]');
|
||||
toggle?.addEventListener('click', () => {
|
||||
const open = nav?.toggleAttribute('data-open');
|
||||
toggle.setAttribute('aria-expanded', String(Boolean(open)));
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,51 +1,27 @@
|
||||
---
|
||||
// Persistent space background. Placed in the layout → loaded on all pages.
|
||||
// Content remains 100% readable and functional even if WebGL fails (decorative).
|
||||
/**
|
||||
* Fixed WebGL background. Three.js is imported dynamically so the page
|
||||
* stays interactive before the scene loads; the canvas removes itself
|
||||
* when WebGL is unavailable or reduced motion is preferred.
|
||||
*/
|
||||
---
|
||||
|
||||
<canvas id="space-canvas" class="space-canvas" aria-hidden="true"></canvas>
|
||||
<canvas id="space-bg" aria-hidden="true"></canvas>
|
||||
|
||||
<style lang="scss">
|
||||
.space-canvas {
|
||||
<style>
|
||||
#space-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
// slight overlay tint to ground the content on top
|
||||
background:
|
||||
radial-gradient(
|
||||
120% 80% at 50% -10%,
|
||||
color-mix(in srgb, var(--nds-primary) 7%, transparent),
|
||||
transparent 60%
|
||||
),
|
||||
var(--nds-background);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { initScene, type SceneHandle } from './scene/scene';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__novaScene?: SceneHandle;
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('space-canvas') as HTMLCanvasElement | null;
|
||||
|
||||
if (canvas && !window.__novaScene) {
|
||||
try {
|
||||
const handle = initScene(canvas);
|
||||
window.__novaScene = handle;
|
||||
// notify page scripts that the scene is ready
|
||||
window.dispatchEvent(new CustomEvent<SceneHandle>('nova:scene-ready', { detail: handle }));
|
||||
} catch (err) {
|
||||
// WebGL unavailable: fail silently, the website remains fully usable.
|
||||
console.warn('[nova] 3D background disabled:', err);
|
||||
canvas.style.display = 'none';
|
||||
}
|
||||
const canvas = document.getElementById('space-bg') as HTMLCanvasElement | null;
|
||||
if (canvas) {
|
||||
import('./scene/scene').then(({ mountSpaceScene }) => mountSpaceScene(canvas));
|
||||
}
|
||||
</script>
|
||||
@@ -1,56 +1,49 @@
|
||||
---
|
||||
// Monochrome tech icon (inherits `color` via fill=currentColor).
|
||||
/**
|
||||
* Renders a technology icon by simple-icons name, with its label.
|
||||
* Icons are imported explicitly so the build only ships what's used.
|
||||
*/
|
||||
import {
|
||||
Typescript,
|
||||
Javascript,
|
||||
Postgresql,
|
||||
Docker,
|
||||
Astro as AstroIcon,
|
||||
Sass,
|
||||
Gitea,
|
||||
Linux,
|
||||
Cloudflare,
|
||||
Threedotjs,
|
||||
Rust,
|
||||
Python,
|
||||
Npm,
|
||||
Github,
|
||||
Svelte,
|
||||
Git,
|
||||
Nodedotjs,
|
||||
Vuedotjs,
|
||||
Figma,
|
||||
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 {
|
||||
name: string;
|
||||
icon: string;
|
||||
size?: number;
|
||||
}
|
||||
const { name, size = 18 } = Astro.props;
|
||||
|
||||
const map: Record<string, any> = {
|
||||
typescript: Typescript,
|
||||
javascript: Javascript,
|
||||
postgresql: Postgresql,
|
||||
docker: Docker,
|
||||
astro: AstroIcon,
|
||||
sass: Sass,
|
||||
gitea: Gitea,
|
||||
linux: Linux,
|
||||
cloudflare: Cloudflare,
|
||||
threedotjs: Threedotjs,
|
||||
rust: Rust,
|
||||
python: Python,
|
||||
npm: Npm,
|
||||
github: Github,
|
||||
svelte: Svelte,
|
||||
git: Git,
|
||||
nodedotjs: Nodedotjs,
|
||||
vue: Vuedotjs,
|
||||
figma: Figma,
|
||||
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 Icon = map[name];
|
||||
const { name, icon, size = 18 } = Astro.props;
|
||||
const Icon = icons[icon];
|
||||
---
|
||||
|
||||
{Icon && <Icon size={size} fill="currentColor" />}
|
||||
<span class="tech">
|
||||
{Icon && <Icon size={size} aria-hidden="true" />}
|
||||
<span>{name}</span>
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.tech {
|
||||
@include type.text-label;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
padding: var(--nds-spacing-xs) var(--nds-spacing-sm);
|
||||
color: var(--nds-text);
|
||||
background: var(--nds-surface);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-full);
|
||||
|
||||
:global(svg) { color: var(--nds-primary); flex-shrink: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+314
-202
@@ -1,248 +1,360 @@
|
||||
/**
|
||||
* Space scene - ambient starfield + nebula, plus a scroll-reactive asteroid belt.
|
||||
*
|
||||
* Behavior contract:
|
||||
* - Scrolling DOWN injects energy: asteroids drift apart, collide elastically
|
||||
* and emit spark bursts on impact.
|
||||
* - Scrolling UP does NOT rewind: a gravity vortex wakes up and pulls the
|
||||
* belt into a slow spiral until it settles back into formation.
|
||||
* - No WebGL, `prefers-reduced-motion`, or a lost context -> the canvas is
|
||||
* removed and the site remains fully readable.
|
||||
*
|
||||
* Everything runs on a single fixed canvas, capped at DPR 2, paused when the
|
||||
* tab is hidden.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Ambient space background: starfield, subtle nebula, and a slow-drifting,
|
||||
// slow-rotating asteroid. No scroll binding, no engine flames.
|
||||
// Slight pointer parallax. Respects prefers-reduced-motion.
|
||||
const ASTEROID_COUNT = 90;
|
||||
const BELT_RADIUS = 26;
|
||||
const BELT_THICKNESS = 9;
|
||||
const ASTEROID_MIN_R = 0.28;
|
||||
const ASTEROID_MAX_R = 1.15;
|
||||
const SPARK_POOL = 240;
|
||||
|
||||
export interface SceneHandle {
|
||||
/** Kept for compatibility - no-op (no more impact/flame effect). */
|
||||
setImpact(value: number): void;
|
||||
destroy(): void;
|
||||
interface AsteroidState {
|
||||
position: THREE.Vector3;
|
||||
velocity: THREE.Vector3;
|
||||
spin: THREE.Vector3;
|
||||
rotation: THREE.Euler;
|
||||
radius: number;
|
||||
/** Slot in the resting formation, used by the vortex to settle back. */
|
||||
home: THREE.Vector3;
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
star: 0xecf5f5,
|
||||
starCool: 0x34c2c9,
|
||||
starAccent: 0x2efafa,
|
||||
nebulaA: 0x288181,
|
||||
nebulaB: 0x1fa6ad,
|
||||
rock: 0x1a2a2a,
|
||||
rim: 0x34c2c9,
|
||||
};
|
||||
|
||||
const prefersReduced =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/** Small reusable radial texture (soft glow). */
|
||||
function radialTexture(inner: string, outer = 'rgba(0,0,0,0)'): THREE.Texture {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = 128;
|
||||
const ctx = c.getContext('2d')!;
|
||||
const g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
|
||||
g.addColorStop(0, inner);
|
||||
g.addColorStop(0.4, inner);
|
||||
g.addColorStop(1, outer);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
export function initScene(canvas: HTMLCanvasElement): SceneHandle {
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
export function mountSpaceScene(canvas: HTMLCanvasElement): (() => void) | null {
|
||||
if (prefersReducedMotion()) {
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
} catch {
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(0x040b0b, 0.012);
|
||||
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 400);
|
||||
camera.position.set(0, 0, 46);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
55,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1,
|
||||
400,
|
||||
);
|
||||
camera.position.set(0, 0, 18);
|
||||
|
||||
// Lighting (cold, consistent with Nova tokens)
|
||||
const ambient = new THREE.AmbientLight(0x16323a, 1.0);
|
||||
scene.add(ambient);
|
||||
const key = new THREE.DirectionalLight(COLORS.starCool, 0.9);
|
||||
key.position.set(-6, 5, 9);
|
||||
scene.add(new THREE.AmbientLight(0x9fdadd, 0.35));
|
||||
const key = new THREE.DirectionalLight(0x7ff5f5, 1.4);
|
||||
key.position.set(18, 22, 30);
|
||||
scene.add(key);
|
||||
const rimLight = new THREE.DirectionalLight(COLORS.starAccent, 0.4);
|
||||
rimLight.position.set(8, -3, 4);
|
||||
scene.add(rimLight);
|
||||
|
||||
// Starfield
|
||||
const STAR_COUNT = prefersReduced ? 700 : 1700;
|
||||
const starGeo = new THREE.BufferGeometry();
|
||||
const starPos = new Float32Array(STAR_COUNT * 3);
|
||||
const starCol = new Float32Array(STAR_COUNT * 3);
|
||||
const palette = [
|
||||
new THREE.Color(COLORS.star),
|
||||
new THREE.Color(COLORS.starCool),
|
||||
new THREE.Color(COLORS.starAccent),
|
||||
];
|
||||
for (let i = 0; i < STAR_COUNT; i++) {
|
||||
starPos[i * 3] = (Math.random() - 0.5) * 120;
|
||||
starPos[i * 3 + 1] = (Math.random() - 0.5) * 120;
|
||||
starPos[i * 3 + 2] = (Math.random() - 0.5) * 120 - 20;
|
||||
const col = palette[Math.random() < 0.78 ? 0 : Math.random() < 0.6 ? 1 : 2];
|
||||
starCol[i * 3] = col.r;
|
||||
starCol[i * 3 + 1] = col.g;
|
||||
starCol[i * 3 + 2] = col.b;
|
||||
/* ── Starfield ─────────────────────────────────────────────────────────── */
|
||||
const starGeometry = new THREE.BufferGeometry();
|
||||
const starCount = 1400;
|
||||
const starPositions = new Float32Array(starCount * 3);
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const r = 90 + Math.random() * 220;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
starPositions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
starPositions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||
starPositions[i * 3 + 2] = r * Math.cos(phi);
|
||||
}
|
||||
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
|
||||
starGeo.setAttribute('color', new THREE.BufferAttribute(starCol, 3));
|
||||
const starMat = new THREE.PointsMaterial({
|
||||
size: 0.42,
|
||||
map: radialTexture('rgba(255,255,255,0.95)'),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
vertexColors: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const stars = new THREE.Points(starGeo, starMat);
|
||||
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
|
||||
const stars = new THREE.Points(
|
||||
starGeometry,
|
||||
new THREE.PointsMaterial({ color: 0xbfeeee, size: 0.55, sizeAttenuation: true, transparent: true, opacity: 0.8 }),
|
||||
);
|
||||
scene.add(stars);
|
||||
|
||||
// Nebula (soft sprites, pushed far into the background)
|
||||
const nebula = new THREE.Group();
|
||||
const nebTex = radialTexture('rgba(40,129,129,0.55)');
|
||||
[
|
||||
{ x: -14, y: 8, z: -45, s: 60, c: COLORS.nebulaA },
|
||||
{ x: 16, y: -10, z: -55, s: 72, c: COLORS.nebulaB },
|
||||
{ x: 4, y: 14, z: -40, s: 46, c: COLORS.nebulaA },
|
||||
].forEach((n) => {
|
||||
const mat = new THREE.SpriteMaterial({
|
||||
map: nebTex,
|
||||
color: n.c,
|
||||
/* ── Nebula (two soft additive sprites) ────────────────────────────────── */
|
||||
const nebulaTexture = makeGlowTexture();
|
||||
for (const [x, y, s, o] of [
|
||||
[-34, 14, 120, 0.10],
|
||||
[30, -20, 150, 0.07],
|
||||
] as const) {
|
||||
const sprite = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: nebulaTexture,
|
||||
color: 0x1fa6ad,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
blending: THREE.AdditiveBlending,
|
||||
opacity: o,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sp = new THREE.Sprite(mat);
|
||||
sp.position.set(n.x, n.y, n.z);
|
||||
sp.scale.set(n.s, n.s, 1);
|
||||
nebula.add(sp);
|
||||
});
|
||||
scene.add(nebula);
|
||||
|
||||
// Asteroid (cold rock, subtle halo)
|
||||
const asteroid = new THREE.Group();
|
||||
const rockGeo = new THREE.IcosahedronGeometry(1.9, 2);
|
||||
const pos = rockGeo.attributes.position as THREE.BufferAttribute;
|
||||
const v = new THREE.Vector3();
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
v.fromBufferAttribute(pos, i);
|
||||
const h = Math.sin(v.x * 4.1) * Math.cos(v.y * 3.7) * Math.sin(v.z * 4.3);
|
||||
const d = 1 + h * 0.16;
|
||||
v.multiplyScalar(d);
|
||||
pos.setXYZ(i, v.x, v.y, v.z);
|
||||
blending: THREE.AdditiveBlending,
|
||||
}),
|
||||
);
|
||||
sprite.position.set(x, y, -60);
|
||||
sprite.scale.setScalar(s);
|
||||
scene.add(sprite);
|
||||
}
|
||||
rockGeo.computeVertexNormals();
|
||||
const rockMat = new THREE.MeshStandardMaterial({
|
||||
color: COLORS.rock,
|
||||
emissive: COLORS.rim,
|
||||
emissiveIntensity: 0.12,
|
||||
|
||||
/* ── Asteroid belt (instanced) ─────────────────────────────────────────── */
|
||||
const rockGeometry = makeRockGeometry();
|
||||
const rockMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x2a4040,
|
||||
roughness: 0.92,
|
||||
metalness: 0.12,
|
||||
flatShading: true,
|
||||
});
|
||||
const rock = new THREE.Mesh(rockGeo, rockMat);
|
||||
asteroid.add(rock);
|
||||
const belt = new THREE.InstancedMesh(rockGeometry, rockMaterial, ASTEROID_COUNT);
|
||||
belt.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
scene.add(belt);
|
||||
|
||||
// very light cold halo, just to separate the rock from the background
|
||||
const glow = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: radialTexture('rgba(52,194,201,0.7)'),
|
||||
color: COLORS.rim,
|
||||
transparent: true,
|
||||
opacity: 0.28,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
}),
|
||||
const asteroids: AsteroidState[] = [];
|
||||
for (let i = 0; i < ASTEROID_COUNT; i++) {
|
||||
const angle = (i / ASTEROID_COUNT) * Math.PI * 2 + Math.random() * 0.35;
|
||||
const radius = BELT_RADIUS + (Math.random() - 0.5) * BELT_THICKNESS;
|
||||
const home = new THREE.Vector3(
|
||||
Math.cos(angle) * radius,
|
||||
(Math.random() - 0.5) * 10,
|
||||
-8 + Math.sin(angle) * 10,
|
||||
);
|
||||
glow.scale.set(7.5, 7.5, 1);
|
||||
asteroid.add(glow);
|
||||
asteroids.push({
|
||||
position: home.clone(),
|
||||
velocity: new THREE.Vector3(),
|
||||
spin: new THREE.Vector3(Math.random(), Math.random(), Math.random()).multiplyScalar(0.4),
|
||||
rotation: new THREE.Euler(Math.random() * Math.PI, Math.random() * Math.PI, 0),
|
||||
radius: ASTEROID_MIN_R + Math.random() * (ASTEROID_MAX_R - ASTEROID_MIN_R),
|
||||
home,
|
||||
});
|
||||
}
|
||||
|
||||
// ambient position: top right, set back
|
||||
asteroid.position.set(6.5, 4.5, -2);
|
||||
scene.add(asteroid);
|
||||
/* ── Spark pool for collision impacts ──────────────────────────────────── */
|
||||
const sparkGeometry = new THREE.BufferGeometry();
|
||||
const sparkPositions = new Float32Array(SPARK_POOL * 3);
|
||||
sparkGeometry.setAttribute('position', new THREE.BufferAttribute(sparkPositions, 3));
|
||||
const sparkMaterial = new THREE.PointsMaterial({
|
||||
color: 0x2efafa,
|
||||
size: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const sparks = new THREE.Points(sparkGeometry, sparkMaterial);
|
||||
scene.add(sparks);
|
||||
const sparkVel: THREE.Vector3[] = [];
|
||||
const sparkLife: number[] = [];
|
||||
for (let i = 0; i < SPARK_POOL; i++) {
|
||||
sparkVel.push(new THREE.Vector3());
|
||||
sparkLife.push(0);
|
||||
sparkPositions.set([0, -999, 0], i * 3);
|
||||
}
|
||||
let sparkCursor = 0;
|
||||
|
||||
// State & loop
|
||||
const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
|
||||
function burst(at: THREE.Vector3, strength: number) {
|
||||
const n = 10 + Math.floor(strength * 8);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const idx = sparkCursor;
|
||||
sparkCursor = (sparkCursor + 1) % SPARK_POOL;
|
||||
sparkPositions.set([at.x, at.y, at.z], idx * 3);
|
||||
sparkVel[idx]!
|
||||
.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5)
|
||||
.normalize()
|
||||
.multiplyScalar(3 + Math.random() * 5 * strength);
|
||||
sparkLife[idx] = 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Scroll energy model ───────────────────────────────────────────────── */
|
||||
let lastScrollY = window.scrollY;
|
||||
let downEnergy = 0; // fuels drift + collisions
|
||||
let upEnergy = 0; // fuels the vortex
|
||||
let beltVisible = true;
|
||||
|
||||
const beltAnchor = document.querySelector<HTMLElement>('[data-belt-anchor]');
|
||||
if (beltAnchor) {
|
||||
beltVisible = false;
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
beltVisible = entries[0]?.isIntersecting ?? true;
|
||||
},
|
||||
{ rootMargin: '20% 0px' },
|
||||
).observe(beltAnchor);
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const delta = window.scrollY - lastScrollY;
|
||||
lastScrollY = window.scrollY;
|
||||
if (!beltVisible) return;
|
||||
if (delta > 0) downEnergy = Math.min(1.6, downEnergy + delta * 0.004);
|
||||
else if (delta < 0) upEnergy = Math.min(1.6, upEnergy - delta * 0.004);
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
/* ── Simulation ────────────────────────────────────────────────────────── */
|
||||
const matrix = new THREE.Matrix4();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scaleVec = new THREE.Vector3();
|
||||
const tmp = new THREE.Vector3();
|
||||
// Manual timing (THREE.Clock is deprecated).
|
||||
let lastTime = performance.now();
|
||||
let elapsed = 0;
|
||||
let raf = 0;
|
||||
let running = true;
|
||||
|
||||
function onPointer(e: PointerEvent) {
|
||||
pointer.tx = (e.clientX / window.innerWidth - 0.5) * 2;
|
||||
pointer.ty = (e.clientY / window.innerHeight - 0.5) * 2;
|
||||
function step() {
|
||||
raf = requestAnimationFrame(step);
|
||||
if (!running) return;
|
||||
const now = performance.now();
|
||||
const dt = Math.min((now - lastTime) / 1000, 0.05);
|
||||
lastTime = now;
|
||||
elapsed += dt;
|
||||
const t = elapsed;
|
||||
|
||||
stars.rotation.y = t * 0.004;
|
||||
downEnergy = Math.max(0, downEnergy - dt * 0.55);
|
||||
upEnergy = Math.max(0, upEnergy - dt * 0.4);
|
||||
|
||||
for (const a of asteroids) {
|
||||
// Scroll down: outward drift + slight chaos -> collisions happen.
|
||||
if (downEnergy > 0.01) {
|
||||
tmp.copy(a.position).sub(new THREE.Vector3(0, 0, -8)).normalize();
|
||||
a.velocity.addScaledVector(tmp, downEnergy * dt * 2.2);
|
||||
a.velocity.x += (Math.random() - 0.5) * downEnergy * dt * 3;
|
||||
a.velocity.y += (Math.random() - 0.5) * downEnergy * dt * 3;
|
||||
}
|
||||
|
||||
// Scroll up: vortex - tangential pull + gentle fall toward home orbit.
|
||||
if (upEnergy > 0.01) {
|
||||
tmp.set(-(a.position.y - a.home.y) - a.position.x * 0.3, a.position.x - a.home.x, 0);
|
||||
a.velocity.addScaledVector(tmp.normalize(), upEnergy * dt * 3.4);
|
||||
tmp.copy(a.home).sub(a.position);
|
||||
a.velocity.addScaledVector(tmp, upEnergy * dt * 0.9);
|
||||
}
|
||||
|
||||
// Always: soft spring back to formation + drag.
|
||||
tmp.copy(a.home).sub(a.position);
|
||||
a.velocity.addScaledVector(tmp, dt * 0.25);
|
||||
a.velocity.multiplyScalar(1 - dt * 0.8);
|
||||
a.position.addScaledVector(a.velocity, dt);
|
||||
|
||||
a.rotation.x += a.spin.x * dt;
|
||||
a.rotation.y += a.spin.y * dt;
|
||||
}
|
||||
|
||||
// Elastic collisions (spatially naive is fine at N=90).
|
||||
if (downEnergy > 0.05) {
|
||||
for (let i = 0; i < asteroids.length; i++) {
|
||||
const a = asteroids[i]!;
|
||||
for (let j = i + 1; j < asteroids.length; j++) {
|
||||
const b = asteroids[j]!;
|
||||
tmp.copy(b.position).sub(a.position);
|
||||
const dist = tmp.length();
|
||||
const minDist = a.radius + b.radius;
|
||||
if (dist > 0.0001 && dist < minDist) {
|
||||
tmp.normalize();
|
||||
const relative = tmp.dot(b.velocity.clone().sub(a.velocity));
|
||||
if (relative < 0) {
|
||||
const impulse = -relative;
|
||||
a.velocity.addScaledVector(tmp, -impulse * 0.5);
|
||||
b.velocity.addScaledVector(tmp, impulse * 0.5);
|
||||
const overlap = minDist - dist;
|
||||
a.position.addScaledVector(tmp, -overlap / 2);
|
||||
b.position.addScaledVector(tmp, overlap / 2);
|
||||
if (impulse > 1.2) {
|
||||
burst(a.position.clone().addScaledVector(tmp, a.radius), Math.min(impulse / 4, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < asteroids.length; i++) {
|
||||
const a = asteroids[i]!;
|
||||
quaternion.setFromEuler(a.rotation);
|
||||
scaleVec.setScalar(a.radius);
|
||||
matrix.compose(a.position, quaternion, scaleVec);
|
||||
belt.setMatrixAt(i, matrix);
|
||||
}
|
||||
belt.instanceMatrix.needsUpdate = true;
|
||||
|
||||
for (let i = 0; i < SPARK_POOL; i++) {
|
||||
if (sparkLife[i]! <= 0) continue;
|
||||
sparkLife[i]! -= dt;
|
||||
sparkVel[i]!.multiplyScalar(1 - dt * 2);
|
||||
sparkPositions[i * 3] += sparkVel[i]!.x * dt;
|
||||
sparkPositions[i * 3 + 1] += sparkVel[i]!.y * dt;
|
||||
sparkPositions[i * 3 + 2] += sparkVel[i]!.z * dt;
|
||||
if (sparkLife[i]! <= 0) sparkPositions[i * 3 + 1] = -999;
|
||||
}
|
||||
sparkGeometry.attributes.position!.needsUpdate = true;
|
||||
sparkMaterial.opacity = 0.5 + downEnergy * 0.3;
|
||||
|
||||
camera.position.x = Math.sin(t * 0.05) * 1.2;
|
||||
camera.position.y = Math.cos(t * 0.04) * 0.8;
|
||||
camera.lookAt(0, 0, -8);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
function onVisibility() {
|
||||
running = document.visibilityState === 'visible';
|
||||
if (running) loop();
|
||||
if (running) lastTime = performance.now(); // swallow the pause
|
||||
}
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('pointermove', onPointer, { passive: true });
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
let rafId = 0;
|
||||
step();
|
||||
|
||||
function loop() {
|
||||
if (!running) return;
|
||||
rafId = requestAnimationFrame(loop);
|
||||
const dt = Math.min(clock.getDelta(), 0.05);
|
||||
const t = clock.elapsedTime;
|
||||
|
||||
// slow background drift
|
||||
stars.rotation.y += dt * 0.01;
|
||||
nebula.rotation.z += dt * 0.004;
|
||||
|
||||
// asteroid: self-rotation + slight float (no scroll binding)
|
||||
if (!prefersReduced) {
|
||||
rock.rotation.x += dt * 0.18;
|
||||
rock.rotation.y += dt * 0.26;
|
||||
asteroid.position.y = 4.5 + Math.sin(t * 0.25) * 0.5;
|
||||
asteroid.position.x = 6.5 + Math.cos(t * 0.18) * 0.4;
|
||||
}
|
||||
|
||||
// smooth camera parallax via pointer
|
||||
pointer.x += (pointer.tx - pointer.x) * dt * 2;
|
||||
pointer.y += (pointer.ty - pointer.y) * dt * 2;
|
||||
camera.position.x = pointer.x * 1.2;
|
||||
camera.position.y = -pointer.y * 0.8 + Math.sin(t * 0.3) * 0.15;
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
loop();
|
||||
|
||||
return {
|
||||
setImpact() {
|
||||
/* no-op: kept for API compatibility */
|
||||
},
|
||||
destroy() {
|
||||
running = false;
|
||||
cancelAnimationFrame(rafId);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('pointermove', onPointer);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
renderer.dispose();
|
||||
starGeo.dispose();
|
||||
rockGeo.dispose();
|
||||
scene.traverse((o) => {
|
||||
const m = (o as THREE.Mesh).material;
|
||||
if (Array.isArray(m)) m.forEach((mm) => mm.dispose());
|
||||
else if (m) (m as THREE.Material).dispose();
|
||||
});
|
||||
},
|
||||
rockGeometry.dispose();
|
||||
starGeometry.dispose();
|
||||
sparkGeometry.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Irregular rock: a dodecahedron with jittered vertices. */
|
||||
function makeRockGeometry(): THREE.BufferGeometry {
|
||||
const geometry = new THREE.DodecahedronGeometry(1, 0);
|
||||
const pos = geometry.attributes.position!;
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const jitter = 0.78 + Math.random() * 0.44;
|
||||
pos.setXYZ(i, pos.getX(i) * jitter, pos.getY(i) * jitter, pos.getZ(i) * jitter);
|
||||
}
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** Radial-gradient sprite texture used for the nebula glow. */
|
||||
function makeGlowTexture(): THREE.Texture {
|
||||
const size = 128;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = size;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const gradient = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
||||
gradient.addColorStop(0, 'rgba(255,255,255,1)');
|
||||
gradient.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* The home "voyage" engine.
|
||||
*
|
||||
* One fixed WebGL canvas + fixed HTML overlays. The page's scroll height is a
|
||||
* timeline: scrolling never moves content - it advances a camera along a 3D
|
||||
* path (nebulae, asteroid belt, one beacon per project, a final portal) and
|
||||
* cross-fades the matching overlay. Scroll velocity feeds the belt physics:
|
||||
* scrolling down shakes asteroids into collisions (sparks), scrolling up pulls
|
||||
* them into a gravitational vortex back to formation.
|
||||
*
|
||||
* Progressive enhancement: without WebGL or with prefers-reduced-motion the
|
||||
* `cinema` class is never added and the sections render as a normal page.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type Lenis from 'lenis';
|
||||
|
||||
interface Segment {
|
||||
id: string;
|
||||
el: HTMLElement;
|
||||
weight: number;
|
||||
side: 'left' | 'right' | 'center';
|
||||
isProject: boolean;
|
||||
/** Global progress window [start, end] (0..1). */
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface VoyageHandle {
|
||||
seek(id: string, immediate?: boolean): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const DEPTH = 44; // world units per weight unit of scroll
|
||||
const BG = 0x040b0b;
|
||||
const CYAN = 0x2efafa;
|
||||
const TEAL = 0x34c2c9;
|
||||
const DEEP = 0x155e75;
|
||||
|
||||
function glowTexture(hex: string): THREE.Texture {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = 256;
|
||||
const ctx = c.getContext('2d')!;
|
||||
const g = ctx.createRadialGradient(128, 128, 0, 128, 128, 128);
|
||||
g.addColorStop(0, `${hex}ff`);
|
||||
g.addColorStop(0.35, `${hex}66`);
|
||||
g.addColorStop(1, `${hex}00`);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
}
|
||||
|
||||
const smoothstep = (a: number, b: number, x: number) => {
|
||||
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
export function mountVoyage(opts: {
|
||||
canvas: HTMLCanvasElement;
|
||||
container: HTMLElement;
|
||||
lenis: Lenis;
|
||||
}): VoyageHandle | null {
|
||||
const { canvas, container, lenis } = opts;
|
||||
|
||||
// --- Capability gate -----------------------------------------------------
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMobile = window.matchMedia('(max-width: 840px)').matches;
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.75 : 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setClearColor(BG, 1);
|
||||
|
||||
// --- Timeline from the DOM ----------------------------------------------
|
||||
const els = Array.from(container.querySelectorAll<HTMLElement>('[data-seg]'));
|
||||
if (!els.length) return null;
|
||||
|
||||
const segments: Segment[] = els.map((el) => ({
|
||||
id: el.dataset.seg!,
|
||||
el,
|
||||
weight: Number(el.dataset.weight ?? 1),
|
||||
side: (el.dataset.side as Segment['side']) ?? 'center',
|
||||
isProject: 'project' in el.dataset,
|
||||
start: 0,
|
||||
end: 0,
|
||||
}));
|
||||
const totalWeight = segments.reduce((s, x) => s + x.weight, 0);
|
||||
let acc = 0;
|
||||
for (const s of segments) {
|
||||
s.start = acc / totalWeight;
|
||||
acc += s.weight;
|
||||
s.end = acc / totalWeight;
|
||||
}
|
||||
|
||||
// Cinema mode on: sections become fixed overlays, the container becomes the timeline.
|
||||
document.documentElement.classList.add('cinema');
|
||||
container.style.height = `${totalWeight * 100 + 60}vh`;
|
||||
|
||||
// --- Scene ---------------------------------------------------------------
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(BG, 0.0072);
|
||||
const camera = new THREE.PerspectiveCamera(58, window.innerWidth / window.innerHeight, 0.1, 400);
|
||||
|
||||
// Flight path: one control point per segment boundary, gently weaving.
|
||||
const points: THREE.Vector3[] = [];
|
||||
let cum = 0;
|
||||
for (let i = 0; i <= segments.length; i++) {
|
||||
points.push(new THREE.Vector3(7 * Math.sin(i * 1.9), 2.4 * Math.sin(i * 1.3 + 1), -cum * DEPTH));
|
||||
cum += segments[i]?.weight ?? 1;
|
||||
}
|
||||
const path = new THREE.CatmullRomCurve3(points, false, 'catmullrom', 0.35);
|
||||
const END_Z = points[points.length - 1]!.z;
|
||||
|
||||
// Starfield: a long tube of stars around the whole path.
|
||||
{
|
||||
const count = isMobile ? 900 : 2000;
|
||||
const pos = new Float32Array(count * 3);
|
||||
const col = new Float32Array(count * 3);
|
||||
const cCyan = new THREE.Color(CYAN);
|
||||
const cWhite = new THREE.Color(0xdfffff);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const r = 16 + Math.random() * 85;
|
||||
pos[i * 3] = Math.cos(a) * r;
|
||||
pos[i * 3 + 1] = Math.sin(a) * r * 0.7;
|
||||
pos[i * 3 + 2] = 30 + Math.random() * (END_Z - 90);
|
||||
const c = Math.random() < 0.25 ? cCyan : cWhite;
|
||||
col[i * 3] = c.r;
|
||||
col[i * 3 + 1] = c.g;
|
||||
col[i * 3 + 2] = c.b;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
|
||||
scene.add(
|
||||
new THREE.Points(
|
||||
geo,
|
||||
new THREE.PointsMaterial({
|
||||
size: 0.55,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Nebulae: big soft sprites drifting near the path.
|
||||
const nebulae: THREE.Sprite[] = [];
|
||||
{
|
||||
const colors = ['#2EFAFA', '#34C2C9', '#155E75', '#2EFAFA', '#0E7490'];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const mat = new THREE.SpriteMaterial({
|
||||
map: glowTexture(colors[i]!),
|
||||
transparent: true,
|
||||
opacity: 0.14 + (i % 2) * 0.08,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sp = new THREE.Sprite(mat);
|
||||
const u = (i + 0.5) / 5;
|
||||
const p = path.getPointAt(u);
|
||||
sp.position.set(p.x + (i % 2 ? -1 : 1) * (40 + i * 8), p.y + (i % 2 ? 14 : -10), p.z - 30);
|
||||
const s = 150 + i * 30;
|
||||
sp.scale.set(s, s, 1);
|
||||
nebulae.push(sp);
|
||||
scene.add(sp);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Asteroid belt across the project segments ---------------------------
|
||||
const projectSegs = segments.filter((s) => s.isProject);
|
||||
const beltZ0 = -((projectSegs[0]?.start ?? 0.3) * totalWeight) * DEPTH + 10;
|
||||
const beltZ1 = -((projectSegs[projectSegs.length - 1]?.end ?? 0.7) * totalWeight) * DEPTH - 10;
|
||||
|
||||
const AST = isMobile ? 48 : 110;
|
||||
const belt = new THREE.InstancedMesh(
|
||||
new THREE.DodecahedronGeometry(1, 0),
|
||||
new THREE.MeshBasicMaterial({ color: 0x0d3b3f, wireframe: false }),
|
||||
AST,
|
||||
);
|
||||
const beltWire = new THREE.InstancedMesh(
|
||||
new THREE.DodecahedronGeometry(1.001, 0),
|
||||
new THREE.MeshBasicMaterial({ color: TEAL, wireframe: true, transparent: true, opacity: 0.45 }),
|
||||
AST,
|
||||
);
|
||||
interface Rock {
|
||||
base: THREE.Vector3;
|
||||
pos: THREE.Vector3;
|
||||
vel: THREE.Vector3;
|
||||
r: number;
|
||||
spin: THREE.Vector3;
|
||||
rot: THREE.Euler;
|
||||
}
|
||||
const rocks: Rock[] = [];
|
||||
for (let i = 0; i < AST; i++) {
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const rad = 10 + Math.random() * 17;
|
||||
const base = new THREE.Vector3(
|
||||
Math.cos(a) * rad,
|
||||
Math.sin(a) * rad * 0.55,
|
||||
beltZ0 + Math.random() * (beltZ1 - beltZ0),
|
||||
);
|
||||
rocks.push({
|
||||
base,
|
||||
pos: base.clone(),
|
||||
vel: new THREE.Vector3((Math.random() - 0.5), (Math.random() - 0.5), (Math.random() - 0.5)).multiplyScalar(2.2),
|
||||
r: 0.45 + Math.random() * 1.3,
|
||||
spin: new THREE.Vector3(Math.random(), Math.random(), Math.random()).multiplyScalar(0.8),
|
||||
rot: new THREE.Euler(Math.random() * 3, Math.random() * 3, Math.random() * 3),
|
||||
});
|
||||
}
|
||||
scene.add(belt, beltWire);
|
||||
|
||||
// Spark pool for collisions.
|
||||
const SPARKS = 300;
|
||||
const sparkPos = new Float32Array(SPARKS * 3);
|
||||
const sparkVel: THREE.Vector3[] = [];
|
||||
const sparkLife = new Float32Array(SPARKS);
|
||||
for (let i = 0; i < SPARKS; i++) sparkVel.push(new THREE.Vector3());
|
||||
const sparkGeo = new THREE.BufferGeometry();
|
||||
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sparkPos, 3));
|
||||
const sparks = new THREE.Points(
|
||||
sparkGeo,
|
||||
new THREE.PointsMaterial({
|
||||
color: CYAN,
|
||||
size: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.95,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
scene.add(sparks);
|
||||
let sparkCursor = 0;
|
||||
const burst = (at: THREE.Vector3, n: number) => {
|
||||
for (let k = 0; k < n; k++) {
|
||||
const i = sparkCursor++ % SPARKS;
|
||||
sparkPos[i * 3] = at.x;
|
||||
sparkPos[i * 3 + 1] = at.y;
|
||||
sparkPos[i * 3 + 2] = at.z;
|
||||
sparkVel[i]!.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).multiplyScalar(9);
|
||||
sparkLife[i] = 1;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Beacons (one per project) + final portal -----------------------------
|
||||
interface Beacon {
|
||||
group: THREE.Group;
|
||||
ring: THREE.Mesh;
|
||||
glow: THREE.Sprite;
|
||||
seg: Segment;
|
||||
}
|
||||
const beacons: Beacon[] = [];
|
||||
const beaconGlowTex = glowTexture('#2EFAFA');
|
||||
for (const seg of projectSegs) {
|
||||
const mid = (seg.start + seg.end) / 2;
|
||||
const p = path.getPointAt(mid);
|
||||
const dir = seg.side === 'left' ? 1 : -1; // beacon opposite to the text card
|
||||
const group = new THREE.Group();
|
||||
group.position.set(p.x + dir * 7.5, p.y + 0.5, p.z - 6);
|
||||
|
||||
const core = new THREE.Mesh(
|
||||
new THREE.IcosahedronGeometry(1.1, 1),
|
||||
new THREE.MeshBasicMaterial({ color: CYAN, wireframe: true, transparent: true, opacity: 0.9 }),
|
||||
);
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(2.2, 0.045, 10, 72),
|
||||
new THREE.MeshBasicMaterial({ color: TEAL, transparent: true, opacity: 0.7 }),
|
||||
);
|
||||
ring.rotation.x = Math.PI / 2.4;
|
||||
const glow = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: beaconGlowTex,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
glow.scale.set(9, 9, 1);
|
||||
group.add(core, ring, glow);
|
||||
scene.add(group);
|
||||
beacons.push({ group, ring, glow, seg });
|
||||
}
|
||||
|
||||
const portal = new THREE.Group();
|
||||
{
|
||||
const g1 = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: glowTexture('#2EFAFA'),
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
g1.scale.set(46, 46, 1);
|
||||
const r1 = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(9, 0.12, 12, 96),
|
||||
new THREE.MeshBasicMaterial({ color: CYAN, transparent: true, opacity: 0.8 }),
|
||||
);
|
||||
const r2 = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(12.5, 0.06, 12, 96),
|
||||
new THREE.MeshBasicMaterial({ color: DEEP, transparent: true, opacity: 0.7 }),
|
||||
);
|
||||
portal.add(g1, r1, r2);
|
||||
portal.position.set(0, 0, END_Z - 46);
|
||||
scene.add(portal);
|
||||
}
|
||||
|
||||
// --- Overlay + progress UI ------------------------------------------------
|
||||
const progressBar = document.querySelector<HTMLElement>('[data-voyage-progress]');
|
||||
const dots = Array.from(document.querySelectorAll<HTMLElement>('[data-voyage-dot]'));
|
||||
|
||||
const applyOverlay = (p: number) => {
|
||||
for (const s of segments) {
|
||||
const span = s.end - s.start;
|
||||
const local = (p - s.start) / span;
|
||||
const first = s === segments[0];
|
||||
const last = s === segments[segments.length - 1];
|
||||
const inV = first ? 1 : smoothstep(0.04, 0.22, local);
|
||||
const outV = last ? 1 : 1 - smoothstep(0.8, 0.97, local);
|
||||
const vis = Math.max(0, Math.min(inV, outV));
|
||||
const el = s.el;
|
||||
if (local < -0.25 || local > 1.25) {
|
||||
if (el.style.visibility !== 'hidden') el.style.visibility = 'hidden';
|
||||
continue;
|
||||
}
|
||||
el.style.visibility = 'visible';
|
||||
el.style.opacity = String(vis);
|
||||
const y = (1 - inV) * 52 - (1 - outV) * 52;
|
||||
el.style.transform = `translateY(${y.toFixed(1)}px) scale(${(0.985 + 0.015 * vis).toFixed(3)})`;
|
||||
el.classList.toggle('is-active', vis > 0.45);
|
||||
}
|
||||
if (progressBar) progressBar.style.transform = `scaleX(${p.toFixed(4)})`;
|
||||
const active = segments.findIndex((s) => p >= s.start && p < s.end);
|
||||
dots.forEach((d, i) => d.classList.toggle('is-active', i === Math.max(0, active)));
|
||||
};
|
||||
|
||||
// --- Frame loop ------------------------------------------------------------
|
||||
let progress = 0;
|
||||
let downEnergy = 0;
|
||||
let upEnergy = 0;
|
||||
let elapsed = 0;
|
||||
let last = performance.now();
|
||||
let running = true;
|
||||
let frame = 0;
|
||||
|
||||
const camTarget = new THREE.Vector3();
|
||||
const tmp = new THREE.Vector3();
|
||||
const mat4 = new THREE.Matrix4();
|
||||
const quat = new THREE.Quaternion();
|
||||
const scl = new THREE.Vector3();
|
||||
|
||||
const maxScroll = () => Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
|
||||
|
||||
const tick = () => {
|
||||
if (!running) {
|
||||
frame = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
elapsed += dt;
|
||||
|
||||
// Scroll → timeline progress + belt energies (Lenis keeps this smooth).
|
||||
const raw = lenis.scroll / maxScroll();
|
||||
progress = Math.min(1, Math.max(0, raw));
|
||||
const v = lenis.velocity; // px per frame, signed
|
||||
downEnergy = lerp(downEnergy, Math.min(1, Math.max(0, v / 55)), 0.08);
|
||||
upEnergy = lerp(upEnergy, Math.min(1, Math.max(0, -v / 55)), 0.08);
|
||||
|
||||
// Camera along the path.
|
||||
const u = progress;
|
||||
path.getPointAt(u, camera.position);
|
||||
path.getPointAt(Math.min(1, u + 0.045), camTarget);
|
||||
// Ease the gaze toward the active beacon.
|
||||
for (const b of beacons) {
|
||||
const mid = (b.seg.start + b.seg.end) / 2;
|
||||
const w = 1 - Math.min(1, Math.abs(u - mid) / (b.seg.end - b.seg.start));
|
||||
if (w > 0) camTarget.lerp(b.group.position, w * 0.35);
|
||||
}
|
||||
camera.lookAt(camTarget);
|
||||
camera.position.y += Math.sin(elapsed * 0.7) * 0.12; // idle breathing
|
||||
|
||||
// Nebulae drift.
|
||||
for (let i = 0; i < nebulae.length; i++) {
|
||||
nebulae[i]!.material.rotation = elapsed * 0.02 * (i % 2 ? 1 : -1);
|
||||
}
|
||||
|
||||
// Belt physics.
|
||||
const agitated = downEnergy > 0.04;
|
||||
for (let i = 0; i < AST; i++) {
|
||||
const r = rocks[i]!;
|
||||
if (agitated) {
|
||||
r.pos.addScaledVector(r.vel, dt * (0.4 + downEnergy * 2.6));
|
||||
}
|
||||
if (upEnergy > 0.04) {
|
||||
// Vortex: tangential pull around the path axis + spring home.
|
||||
tmp.set(-r.pos.y, r.pos.x, 0).normalize();
|
||||
r.pos.addScaledVector(tmp, dt * upEnergy * 14);
|
||||
r.pos.lerp(r.base, dt * upEnergy * 1.6);
|
||||
} else if (!agitated) {
|
||||
r.pos.lerp(r.base, dt * 0.5);
|
||||
}
|
||||
r.rot.x += r.spin.x * dt * (1 + downEnergy * 3);
|
||||
r.rot.y += r.spin.y * dt;
|
||||
quat.setFromEuler(r.rot);
|
||||
scl.setScalar(r.r);
|
||||
mat4.compose(r.pos, quat, scl);
|
||||
belt.setMatrixAt(i, mat4);
|
||||
beltWire.setMatrixAt(i, mat4);
|
||||
}
|
||||
// Collisions only while agitated, only near the camera (cheap N² slice).
|
||||
if (agitated) {
|
||||
for (let i = 0; i < AST; i++) {
|
||||
const a = rocks[i]!;
|
||||
if (Math.abs(a.pos.z - camera.position.z) > 60) continue;
|
||||
for (let j = i + 1; j < AST; j++) {
|
||||
const b = rocks[j]!;
|
||||
const dz = a.pos.z - b.pos.z;
|
||||
if (dz > 6 || dz < -6) continue;
|
||||
const d2 = a.pos.distanceToSquared(b.pos);
|
||||
const rr = a.r + b.r;
|
||||
if (d2 < rr * rr) {
|
||||
tmp.subVectors(a.pos, b.pos).normalize();
|
||||
const va = a.vel.dot(tmp);
|
||||
const vb = b.vel.dot(tmp);
|
||||
a.vel.addScaledVector(tmp, vb - va);
|
||||
b.vel.addScaledVector(tmp, va - vb);
|
||||
a.pos.addScaledVector(tmp, rr - Math.sqrt(d2));
|
||||
burst(tmp.copy(a.pos).lerp(b.pos, 0.5), 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
belt.instanceMatrix.needsUpdate = true;
|
||||
beltWire.instanceMatrix.needsUpdate = true;
|
||||
|
||||
// Sparks.
|
||||
for (let i = 0; i < SPARKS; i++) {
|
||||
if (sparkLife[i]! <= 0) continue;
|
||||
sparkLife[i]! -= dt * 1.4;
|
||||
sparkPos[i * 3] += sparkVel[i]!.x * dt;
|
||||
sparkPos[i * 3 + 1] += sparkVel[i]!.y * dt;
|
||||
sparkPos[i * 3 + 2] += sparkVel[i]!.z * dt;
|
||||
if (sparkLife[i]! <= 0) sparkPos[i * 3 + 1] = 9999;
|
||||
}
|
||||
sparkGeo.attributes.position!.needsUpdate = true;
|
||||
|
||||
// Beacons pulse; the active one lights up.
|
||||
for (const b of beacons) {
|
||||
const mid = (b.seg.start + b.seg.end) / 2;
|
||||
const w = Math.max(0, 1 - Math.abs(u - mid) / ((b.seg.end - b.seg.start) * 0.9));
|
||||
const pulse = 1 + Math.sin(elapsed * 2.2) * 0.05;
|
||||
b.group.scale.setScalar((0.7 + w * 0.5) * pulse);
|
||||
b.ring.rotation.z = elapsed * 0.6;
|
||||
(b.glow.material as THREE.SpriteMaterial).opacity = 0.25 + w * 0.55;
|
||||
b.group.rotation.y = elapsed * 0.35;
|
||||
}
|
||||
|
||||
// Portal breathes and brightens on approach.
|
||||
const arrive = smoothstep(0.82, 1, u);
|
||||
portal.rotation.z = elapsed * 0.25;
|
||||
portal.scale.setScalar(0.85 + arrive * 0.35 + Math.sin(elapsed * 1.4) * 0.02);
|
||||
|
||||
applyOverlay(progress);
|
||||
renderer.render(scene, camera);
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
// --- Wiring ----------------------------------------------------------------
|
||||
const onResize = () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
};
|
||||
const onVisibility = () => {
|
||||
running = !document.hidden;
|
||||
if (running) last = performance.now();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
const seek = (id: string, immediate = false) => {
|
||||
const s = segments.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
const target = (s.start + (s.end - s.start) * 0.45) * maxScroll();
|
||||
lenis.scrollTo(target, immediate ? { immediate: true } : { duration: 1.8 });
|
||||
};
|
||||
|
||||
dots.forEach((d) => d.addEventListener('click', () => seek(d.dataset.voyageDot!)));
|
||||
|
||||
applyOverlay(0);
|
||||
frame = requestAnimationFrame(tick);
|
||||
|
||||
return {
|
||||
seek,
|
||||
dispose() {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', onResize);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
renderer.dispose();
|
||||
document.documentElement.classList.remove('cinema');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
---
|
||||
/**
|
||||
* Custom scene for `portfolio` (this very site): from wireframe to product.
|
||||
*
|
||||
* A pinned browser-window blueprint whose strokes draw themselves as you
|
||||
* scroll, then light up with the NDS accent - the site building itself.
|
||||
* Reduced motion / no JS: fully drawn and lit, no pin.
|
||||
*/
|
||||
import Eyebrow from '../Eyebrow.astro';
|
||||
import { getLocaleFromUrl, useTranslations } from '../../i18n';
|
||||
import type { Localized } from '../../data/projects';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const captions: Localized[] = [
|
||||
{ fr: 'Structure : Astro, routes FR/EN, données typées.', en: 'Structure: Astro, FR/EN routes, typed data.' },
|
||||
{ fr: 'Habillage : chaque pixel vient des tokens du Nova Design System.', en: 'Skin: every pixel comes from Nova Design System tokens.' },
|
||||
{ fr: 'Mise en orbite : build statique, CI Gitea, déployé sur le VPS.', en: 'Into orbit: static build, Gitea CI, deployed on the VPS.' },
|
||||
];
|
||||
|
||||
// Wireframe strokes in draw order (all use pathLength=1 for easy dash scrub).
|
||||
const strokes: { d: string; cls?: string }[] = [
|
||||
{ d: 'M 60 40 H 900 A 16 16 0 0 1 916 56 V 484 A 16 16 0 0 1 900 500 H 60 A 16 16 0 0 1 44 484 V 56 A 16 16 0 0 1 60 40 Z' }, // window
|
||||
{ d: 'M 44 88 H 916' }, // top bar divider
|
||||
{ d: 'M 76 64 a 8 8 0 1 0 0.01 0' },
|
||||
{ d: 'M 104 64 a 8 8 0 1 0 0.01 0' },
|
||||
{ d: 'M 132 64 a 8 8 0 1 0 0.01 0' },
|
||||
{ d: 'M 180 56 H 780 A 8 8 0 0 1 788 64 A 8 8 0 0 1 780 72 H 180 A 8 8 0 0 1 172 64 A 8 8 0 0 1 180 56 Z' }, // url bar
|
||||
{ d: 'M 92 150 H 420' }, // hero line 1
|
||||
{ d: 'M 92 186 H 560' }, // hero line 2 (big)
|
||||
{ d: 'M 92 222 H 340' }, // hero line 3
|
||||
{ d: 'M 92 262 H 208 A 10 10 0 0 1 218 272 V 286 A 10 10 0 0 1 208 296 H 92 A 10 10 0 0 1 82 286 V 272 A 10 10 0 0 1 92 262 Z', cls: 'accent' }, // CTA
|
||||
{ d: 'M 92 340 H 340 V 460 H 92 Z' }, // card 1
|
||||
{ d: 'M 366 340 H 614 V 460 H 366 Z' }, // card 2
|
||||
{ d: 'M 640 340 H 888 V 460 H 640 Z' }, // card 3
|
||||
{ d: 'M 640 140 a 70 70 0 1 0 0.01 0', cls: 'accent' }, // planet in hero
|
||||
{ d: 'M 610 210 q 100 -60 200 -12', cls: 'accent' }, // planet ring
|
||||
];
|
||||
---
|
||||
|
||||
<section
|
||||
class="bp"
|
||||
data-blueprint
|
||||
data-captions={JSON.stringify(captions.map((c) => c[locale]))}
|
||||
>
|
||||
<div class="stage">
|
||||
<header class="stage-head">
|
||||
<Eyebrow>{t('scene.blueprint.eyebrow')}</Eyebrow>
|
||||
<h2>{t('scene.blueprint.title')}</h2>
|
||||
</header>
|
||||
|
||||
<svg viewBox="0 0 960 540" class="draw" role="img" aria-label={t('scene.blueprint.title')}>
|
||||
<!-- Blueprint grid -->
|
||||
<g class="grid" aria-hidden="true">
|
||||
{Array.from({ length: 12 }, (_, i) => <line x1={44 + i * 80} y1="40" x2={44 + i * 80} y2="500" />)}
|
||||
{Array.from({ length: 7 }, (_, i) => <line x1="44" y1={40 + i * 78} x2="916" y2={40 + i * 78} />)}
|
||||
</g>
|
||||
|
||||
{strokes.map((s, i) => (
|
||||
<path d={s.d} pathLength="1" class:list={['stroke', s.cls]} data-draw={i} />
|
||||
))}
|
||||
|
||||
<!-- Lit state: soft fills that fade in at the end -->
|
||||
<g class="lit" data-lit aria-hidden="true">
|
||||
<rect x="82" y="262" width="136" height="34" rx="10" />
|
||||
<circle cx="640" cy="210" r="70" />
|
||||
<rect x="92" y="340" width="248" height="120" class="dim" />
|
||||
<rect x="366" y="340" width="248" height="120" class="dim" />
|
||||
<rect x="640" y="340" width="248" height="120" class="dim" />
|
||||
</g>
|
||||
|
||||
<text x="480" y="70" text-anchor="middle" class="url" data-url>louis-potevin.dev</text>
|
||||
</svg>
|
||||
|
||||
<p class="caption" data-caption aria-live="polite">{captions[0]![locale]}</p>
|
||||
<p class="hint" aria-hidden="true">{t('scene.hint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { scrub, window01 } from '../../lib/scrub';
|
||||
|
||||
function init() {
|
||||
const root = document.querySelector<HTMLElement>('[data-blueprint]');
|
||||
if (!root) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
root.classList.add('static');
|
||||
return;
|
||||
}
|
||||
root.classList.add('is-ready');
|
||||
|
||||
const paths = Array.from(root.querySelectorAll<SVGPathElement>('[data-draw]'));
|
||||
const lit = root.querySelector<SVGGElement>('[data-lit]')!;
|
||||
const url = root.querySelector<SVGTextElement>('[data-url]')!;
|
||||
const captionEl = root.querySelector<HTMLElement>('[data-caption]')!;
|
||||
const captions: string[] = JSON.parse(root.dataset.captions ?? '[]');
|
||||
|
||||
scrub(root, (p) => {
|
||||
// Strokes draw over the first 68%, staggered.
|
||||
paths.forEach((el, i) => {
|
||||
const a = 0.03 + (i / paths.length) * 0.6;
|
||||
el.style.strokeDashoffset = String(1 - window01(p, a, a + 0.1));
|
||||
});
|
||||
// Then the interface lights up.
|
||||
const glow = window01(p, 0.7, 0.92);
|
||||
lit.style.opacity = String(glow * 0.9);
|
||||
url.style.opacity = String(window01(p, 0.32, 0.42));
|
||||
root.style.setProperty('--bp-glow', String(glow));
|
||||
|
||||
const idx = p < 0.34 ? 0 : p < 0.7 ? 1 : 2;
|
||||
if (captionEl.dataset.idx !== String(idx)) {
|
||||
captionEl.dataset.idx = String(idx);
|
||||
captionEl.style.opacity = '0';
|
||||
window.setTimeout(() => {
|
||||
captionEl.textContent = captions[idx]!;
|
||||
captionEl.style.opacity = '1';
|
||||
}, 180);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../styles/type' as type;
|
||||
|
||||
.bp {
|
||||
margin-block: clamp(40px, 8vh, 96px);
|
||||
|
||||
&.is-ready {
|
||||
height: 280vh;
|
||||
}
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-lg);
|
||||
justify-items: center;
|
||||
padding-block: var(--nds-spacing-xl);
|
||||
|
||||
.is-ready & {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100svh;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-head {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
@include type.text-3xl;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
}
|
||||
|
||||
.draw {
|
||||
width: min(880px, 100%);
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 0 calc(var(--bp-glow, 0) * 14px) color-mix(in srgb, var(--nds-accent) 40%, transparent));
|
||||
}
|
||||
|
||||
.grid line {
|
||||
stroke: color-mix(in srgb, var(--nds-primary) 12%, transparent);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.stroke {
|
||||
fill: none;
|
||||
stroke: var(--nds-primary);
|
||||
stroke-width: 1.6;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
stroke-linecap: round;
|
||||
|
||||
&.accent {
|
||||
stroke: var(--nds-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.lit {
|
||||
opacity: 0;
|
||||
|
||||
rect,
|
||||
circle {
|
||||
fill: color-mix(in srgb, var(--nds-accent) 30%, transparent);
|
||||
}
|
||||
.dim {
|
||||
fill: color-mix(in srgb, var(--nds-primary) 14%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.url {
|
||||
@include type.text-label;
|
||||
fill: var(--nds-neutral);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.caption {
|
||||
@include type.text-base;
|
||||
min-height: 3em;
|
||||
max-width: 60ch;
|
||||
text-align: center;
|
||||
color: var(--nds-neutral);
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-disabled);
|
||||
|
||||
.static & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Static fallback: fully drawn and lit. */
|
||||
.bp:not(.is-ready) {
|
||||
.stroke {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
.lit {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.url {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
---
|
||||
/**
|
||||
* Custom scene for `nova-infra`: the journey of a request.
|
||||
*
|
||||
* A pinned SVG network (Visitor → Cloudflare → Apache/VPS → services) whose
|
||||
* edges and nodes are drawn by scroll. Once drawn, a packet keeps traveling
|
||||
* the graph (time-based) so the diagram feels alive. Captions follow the
|
||||
* scroll. Reduced motion / no JS: the fully drawn diagram, no pin.
|
||||
*/
|
||||
import Eyebrow from '../Eyebrow.astro';
|
||||
import { getLocaleFromUrl, useTranslations } from '../../i18n';
|
||||
import type { Localized } from '../../data/projects';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const captions: Localized[] = [
|
||||
{
|
||||
fr: 'Une requête arrive : DNS et TLS passent par Cloudflare, qui filtre et met en cache.',
|
||||
en: 'A request comes in: DNS and TLS go through Cloudflare, which filters and caches.',
|
||||
},
|
||||
{
|
||||
fr: "Apache, en reverse proxy sur le VPS OVH, route chaque domaine vers le bon service.",
|
||||
en: 'Apache, reverse-proxying on the OVH VPS, routes each domain to the right service.',
|
||||
},
|
||||
{
|
||||
fr: 'Gitea, Vaultwarden, le runner CI et les sites tournent côte à côte - auto-hébergés, sauvegardés, monitorés.',
|
||||
en: 'Gitea, Vaultwarden, the CI runner and the sites run side by side - self-hosted, backed up, monitored.',
|
||||
},
|
||||
];
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
label: string;
|
||||
sub?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
}
|
||||
const nodes: Node[] = [
|
||||
{ id: 'visitor', label: locale === 'fr' ? 'Visiteur' : 'Visitor', x: 40, y: 236, w: 130 },
|
||||
{ id: 'cf', label: 'Cloudflare', sub: 'DNS · TLS · cache', x: 250, y: 236, w: 160 },
|
||||
{ id: 'apache', label: 'Apache', sub: 'reverse proxy', x: 490, y: 236, w: 160 },
|
||||
{ id: 'gitea', label: 'Gitea', sub: 'git.novaprojects.dev', x: 750, y: 90, w: 180 },
|
||||
{ id: 'vault', label: 'Vaultwarden', sub: locale === 'fr' ? 'mots de passe' : 'passwords', x: 750, y: 236, w: 180 },
|
||||
{ id: 'sites', label: 'Sites', sub: 'louis-potevin.dev', x: 750, y: 382, w: 180 },
|
||||
];
|
||||
|
||||
// Edges as SVG path data (right side of source → left side of target).
|
||||
const edges: { id: string; d: string }[] = [
|
||||
{ id: 'e-visitor-cf', d: 'M 170 264 C 210 264 210 264 250 264' },
|
||||
{ id: 'e-cf-apache', d: 'M 410 264 C 450 264 450 264 490 264' },
|
||||
{ id: 'e-apache-gitea', d: 'M 650 264 C 710 264 690 118 750 118' },
|
||||
{ id: 'e-apache-vault', d: 'M 650 264 C 690 264 710 264 750 264' },
|
||||
{ id: 'e-apache-sites', d: 'M 650 264 C 710 264 690 410 750 410' },
|
||||
];
|
||||
---
|
||||
|
||||
<section class="infra" data-infra data-captions={JSON.stringify(captions.map((c) => c[locale]))}>
|
||||
<div class="stage">
|
||||
<header class="stage-head">
|
||||
<Eyebrow>{t('scene.infra.eyebrow')}</Eyebrow>
|
||||
<h2>{t('scene.infra.title')}</h2>
|
||||
</header>
|
||||
|
||||
<svg viewBox="0 0 960 500" class="net" role="img" aria-label={t('scene.infra.title')}>
|
||||
<!-- VPS enclosure -->
|
||||
<g data-node="vps">
|
||||
<rect x="460" y="40" width="486" height="430" rx="16" class="vps-box" />
|
||||
<text x="482" y="72" class="vps-label">VPS OVH · Debian</text>
|
||||
</g>
|
||||
|
||||
{edges.map((e) => <path id={e.id} d={e.d} pathLength="1" class="edge" data-edge />)}
|
||||
|
||||
{nodes.map((n) => (
|
||||
<g data-node={n.id} class="node">
|
||||
<rect x={n.x} y={n.y} width={n.w} height="56" rx="10" />
|
||||
<text x={n.x + 16} y={n.y + (n.sub ? 24 : 33)} class="node-label">{n.label}</text>
|
||||
{n.sub && <text x={n.x + 16} y={n.y + 42} class="node-sub">{n.sub}</text>}
|
||||
</g>
|
||||
))}
|
||||
|
||||
<circle class="packet" data-packet r="5" cx="-20" cy="-20" />
|
||||
<circle class="packet packet--trail" data-packet-trail r="9" cx="-20" cy="-20" />
|
||||
</svg>
|
||||
|
||||
<p class="caption" data-caption aria-live="polite">{captions[0]![locale]}</p>
|
||||
<p class="hint" aria-hidden="true">{t('scene.hint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { scrub, window01 } from '../../lib/scrub';
|
||||
|
||||
function init() {
|
||||
const root = document.querySelector<HTMLElement>('[data-infra]');
|
||||
if (!root) return;
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduced) {
|
||||
root.classList.add('static');
|
||||
return;
|
||||
}
|
||||
root.classList.add('is-ready');
|
||||
|
||||
const edges = Array.from(root.querySelectorAll<SVGPathElement>('[data-edge]'));
|
||||
const nodes = Array.from(root.querySelectorAll<SVGGElement>('[data-node]'));
|
||||
const packet = root.querySelector<SVGCircleElement>('[data-packet]')!;
|
||||
const trail = root.querySelector<SVGCircleElement>('[data-packet-trail]')!;
|
||||
const captionEl = root.querySelector<HTMLElement>('[data-caption]')!;
|
||||
const captions: string[] = JSON.parse(root.dataset.captions ?? '[]');
|
||||
|
||||
// Draw order: vps box → visitor → e1 → cf → e2 → apache → branches.
|
||||
const drawOrder: (SVGElement | undefined)[] = [
|
||||
nodes.find((n) => n.dataset.node === 'vps'),
|
||||
nodes.find((n) => n.dataset.node === 'visitor'),
|
||||
edges[0],
|
||||
nodes.find((n) => n.dataset.node === 'cf'),
|
||||
edges[1],
|
||||
nodes.find((n) => n.dataset.node === 'apache'),
|
||||
edges[2],
|
||||
nodes.find((n) => n.dataset.node === 'gitea'),
|
||||
edges[3],
|
||||
nodes.find((n) => n.dataset.node === 'vault'),
|
||||
edges[4],
|
||||
nodes.find((n) => n.dataset.node === 'sites'),
|
||||
];
|
||||
|
||||
let progress = 0;
|
||||
scrub(root, (p) => {
|
||||
progress = p;
|
||||
// Sequential reveal across the first 70% of the scene.
|
||||
drawOrder.forEach((el, i) => {
|
||||
if (!el) return;
|
||||
const a = 0.05 + (i / drawOrder.length) * 0.62;
|
||||
const v = window01(p, a, a + 0.08);
|
||||
if (el.hasAttribute('data-edge')) {
|
||||
(el as SVGPathElement).style.strokeDashoffset = String(1 - v);
|
||||
} else {
|
||||
el.style.opacity = String(v);
|
||||
el.style.transform = `translateY(${(1 - v) * 14}px)`;
|
||||
}
|
||||
});
|
||||
// Captions in thirds.
|
||||
const idx = p < 0.34 ? 0 : p < 0.68 ? 1 : 2;
|
||||
if (captionEl.dataset.idx !== String(idx)) {
|
||||
captionEl.dataset.idx = String(idx);
|
||||
captionEl.style.opacity = '0';
|
||||
window.setTimeout(() => {
|
||||
captionEl.textContent = captions[idx]!;
|
||||
captionEl.style.opacity = '1';
|
||||
}, 180);
|
||||
}
|
||||
});
|
||||
|
||||
// Live packet: visitor → cf → apache → (cycling branch), time-based.
|
||||
const legs = [edges[0]!, edges[1]!];
|
||||
const branches = [edges[2]!, edges[3]!, edges[4]!];
|
||||
const lens = [...legs, ...branches].map((e) => e.getTotalLength());
|
||||
let tAcc = 0;
|
||||
let lastT = performance.now();
|
||||
let branch = 0;
|
||||
const loop = (now: number) => {
|
||||
const dt = Math.min((now - lastT) / 1000, 0.05);
|
||||
lastT = now;
|
||||
if (progress > 0.2) {
|
||||
tAcc += dt * 0.55;
|
||||
if (tAcc >= 1) {
|
||||
tAcc = 0;
|
||||
branch = (branch + 1) % branches.length;
|
||||
}
|
||||
// Route = leg0 (0-0.33) → leg1 (0.33-0.66) → branch (0.66-1).
|
||||
let el: SVGPathElement;
|
||||
let local: number;
|
||||
if (tAcc < 1 / 3) {
|
||||
el = legs[0]!;
|
||||
local = tAcc * 3;
|
||||
} else if (tAcc < 2 / 3) {
|
||||
el = legs[1]!;
|
||||
local = (tAcc - 1 / 3) * 3;
|
||||
} else {
|
||||
el = branches[branch]!;
|
||||
local = (tAcc - 2 / 3) * 3;
|
||||
}
|
||||
const len = lens[[...legs, ...branches].indexOf(el)]!;
|
||||
const pt = el.getPointAtLength(local * len);
|
||||
packet.setAttribute('cx', String(pt.x));
|
||||
packet.setAttribute('cy', String(pt.y));
|
||||
trail.setAttribute('cx', String(pt.x));
|
||||
trail.setAttribute('cy', String(pt.y));
|
||||
packet.style.opacity = '1';
|
||||
trail.style.opacity = '0.35';
|
||||
} else {
|
||||
packet.style.opacity = '0';
|
||||
trail.style.opacity = '0';
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../styles/type' as type;
|
||||
|
||||
.infra {
|
||||
/* Pinned scene: tall section, sticky stage. */
|
||||
margin-block: clamp(40px, 8vh, 96px);
|
||||
|
||||
&.is-ready {
|
||||
height: 300vh;
|
||||
}
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-lg);
|
||||
justify-items: center;
|
||||
padding-block: var(--nds-spacing-xl);
|
||||
|
||||
.is-ready & {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100svh;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-head {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
@include type.text-3xl;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
}
|
||||
|
||||
.net {
|
||||
width: min(960px, 100%);
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vps-box {
|
||||
fill: color-mix(in srgb, var(--nds-surface) 40%, transparent);
|
||||
stroke: var(--nds-border);
|
||||
stroke-dasharray: 6 6;
|
||||
}
|
||||
.vps-label {
|
||||
@include type.text-label;
|
||||
fill: var(--nds-disabled);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.edge {
|
||||
fill: none;
|
||||
stroke: var(--nds-primary);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.node {
|
||||
opacity: 0;
|
||||
|
||||
rect {
|
||||
fill: color-mix(in srgb, var(--nds-surface-2) 92%, transparent);
|
||||
stroke: var(--nds-primary);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.node-label {
|
||||
fill: var(--nds-text);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 15px;
|
||||
}
|
||||
.node-sub {
|
||||
fill: var(--nds-neutral);
|
||||
font-size: 11px;
|
||||
font-family: var(--nds-font-sans);
|
||||
}
|
||||
}
|
||||
g[data-node='vps'] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.packet {
|
||||
fill: var(--nds-accent);
|
||||
opacity: 0;
|
||||
filter: drop-shadow(0 0 6px var(--nds-accent));
|
||||
}
|
||||
.packet--trail {
|
||||
fill: var(--nds-accent);
|
||||
}
|
||||
|
||||
.caption {
|
||||
@include type.text-base;
|
||||
min-height: 3em;
|
||||
max-width: 60ch;
|
||||
text-align: center;
|
||||
color: var(--nds-neutral);
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-disabled);
|
||||
|
||||
.static & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion / no JS: everything drawn, no pin. */
|
||||
.infra:not(.is-ready) {
|
||||
.edge {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
.node,
|
||||
g[data-node='vps'] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
---
|
||||
/**
|
||||
* Custom scene for `file-organizer`: from chaos to order.
|
||||
*
|
||||
* File chips start scattered across the board (seeded pseudo-random, so the
|
||||
* chaos is stable between visits); scrolling sweeps each one along a small
|
||||
* arc into its sorted column - exactly what the tool does to a messy folder.
|
||||
* Reduced motion / no JS: the sorted end state, no pin.
|
||||
*/
|
||||
import Eyebrow from '../Eyebrow.astro';
|
||||
import { getLocaleFromUrl, useTranslations } from '../../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
type Cat = 'img' | 'doc' | 'audio' | 'code';
|
||||
const columns: { cat: Cat; label: string }[] = [
|
||||
{ cat: 'img', label: 'Images' },
|
||||
{ cat: 'doc', label: locale === 'fr' ? 'Documents' : 'Documents' },
|
||||
{ cat: 'audio', label: 'Audio' },
|
||||
{ cat: 'code', label: 'Code' },
|
||||
];
|
||||
|
||||
const files: { name: string; cat: Cat }[] = [
|
||||
{ name: 'IMG_2041.jpg', cat: 'img' },
|
||||
{ name: 'screenshot.png', cat: 'img' },
|
||||
{ name: 'logo.webp', cat: 'img' },
|
||||
{ name: 'scan-04.tiff', cat: 'img' },
|
||||
{ name: 'avatar.gif', cat: 'img' },
|
||||
{ name: 'cv-2026.pdf', cat: 'doc' },
|
||||
{ name: 'notes.md', cat: 'doc' },
|
||||
{ name: 'facture_03.pdf', cat: 'doc' },
|
||||
{ name: 'rapport.docx', cat: 'doc' },
|
||||
{ name: 'todo.txt', cat: 'doc' },
|
||||
{ name: 'demo-take2.wav', cat: 'audio' },
|
||||
{ name: 'podcast.mp3', cat: 'audio' },
|
||||
{ name: 'sample.flac', cat: 'audio' },
|
||||
{ name: 'voice-memo.m4a', cat: 'audio' },
|
||||
{ name: 'main.rs', cat: 'code' },
|
||||
{ name: 'organizer.rs', cat: 'code' },
|
||||
{ name: 'watch.rs', cat: 'code' },
|
||||
{ name: 'Cargo.toml', cat: 'code' },
|
||||
{ name: 'ui.rs', cat: 'code' },
|
||||
{ name: 'fs_rules.rs', cat: 'code' },
|
||||
];
|
||||
---
|
||||
|
||||
<section class="sorter" data-sorter>
|
||||
<div class="stage">
|
||||
<header class="stage-head">
|
||||
<Eyebrow>{t('scene.sorter.eyebrow')}</Eyebrow>
|
||||
<h2>{t('scene.sorter.title')}</h2>
|
||||
</header>
|
||||
|
||||
<div class="board" data-board>
|
||||
{columns.map((col) => (
|
||||
<div class="col" data-col={col.cat}>
|
||||
<p class="col-head" data-col-head>{col.label}</p>
|
||||
{files
|
||||
.filter((f) => f.cat === col.cat)
|
||||
.map((f) => (
|
||||
<span class="chip" data-chip data-cat={f.cat}>
|
||||
<i aria-hidden="true"></i>
|
||||
{f.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p class="hint" aria-hidden="true">{t('scene.hint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { scrub, clamp01 } from '../../lib/scrub';
|
||||
|
||||
/** Deterministic PRNG so the "chaos" looks identical on every visit. */
|
||||
function mulberry32(seed: number) {
|
||||
return () => {
|
||||
seed |= 0;
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let z = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
z = (z + Math.imul(z ^ (z >>> 7), 61 | z)) ^ z;
|
||||
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function init() {
|
||||
const root = document.querySelector<HTMLElement>('[data-sorter]');
|
||||
if (!root) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
root.classList.add('static');
|
||||
return;
|
||||
}
|
||||
|
||||
const board = root.querySelector<HTMLElement>('[data-board]')!;
|
||||
const chips = Array.from(root.querySelectorAll<HTMLElement>('[data-chip]'));
|
||||
const heads = Array.from(root.querySelectorAll<HTMLElement>('[data-col-head]'));
|
||||
|
||||
interface Item {
|
||||
el: HTMLElement;
|
||||
// Sorted (target) and scattered (start) positions, board-relative.
|
||||
tx: number;
|
||||
ty: number;
|
||||
sx: number;
|
||||
sy: number;
|
||||
rot: number;
|
||||
delay: number;
|
||||
}
|
||||
let items: Item[] = [];
|
||||
|
||||
const measure = () => {
|
||||
// 1) Let the grid lay chips out sorted, measure targets.
|
||||
root.classList.remove('is-ready');
|
||||
board.style.height = '';
|
||||
chips.forEach((c) => c.removeAttribute('style'));
|
||||
const bRect = board.getBoundingClientRect();
|
||||
board.style.height = `${bRect.height}px`;
|
||||
|
||||
const rng = mulberry32(20260702);
|
||||
items = chips.map((el, i) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const tx = r.left - bRect.left;
|
||||
const ty = r.top - bRect.top;
|
||||
return {
|
||||
el,
|
||||
tx,
|
||||
ty,
|
||||
sx: rng() * (bRect.width - r.width),
|
||||
sy: rng() * (bRect.height - r.height),
|
||||
rot: (rng() - 0.5) * 34,
|
||||
delay: i / chips.length,
|
||||
};
|
||||
});
|
||||
|
||||
// 2) Freeze the board height, absolutize the chips at their scatter.
|
||||
root.classList.add('is-ready');
|
||||
apply(lastP);
|
||||
};
|
||||
|
||||
let lastP = 0;
|
||||
const apply = (p: number) => {
|
||||
lastP = p;
|
||||
for (const it of items) {
|
||||
// Each chip travels inside its own window, staggered by index.
|
||||
const local = clamp01((p - 0.06 - it.delay * 0.5) / 0.36);
|
||||
const e = local * local * (3 - 2 * local);
|
||||
const x = it.sx + (it.tx - it.sx) * e;
|
||||
const y = it.sy + (it.ty - it.sy) * e - Math.sin(e * Math.PI) * 42; // small arc
|
||||
const rot = it.rot * (1 - e);
|
||||
it.el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rot.toFixed(1)}deg)`;
|
||||
it.el.classList.toggle('is-sorted', e > 0.98);
|
||||
}
|
||||
const done = clamp01((p - 0.86) / 0.12);
|
||||
heads.forEach((h) => h.style.setProperty('--done', String(done)));
|
||||
};
|
||||
|
||||
measure();
|
||||
scrub(root, apply);
|
||||
|
||||
let resizeT = 0;
|
||||
window.addEventListener('resize', () => {
|
||||
window.clearTimeout(resizeT);
|
||||
resizeT = window.setTimeout(measure, 150);
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../styles/type' as type;
|
||||
|
||||
.sorter {
|
||||
margin-block: clamp(40px, 8vh, 96px);
|
||||
|
||||
&.is-ready {
|
||||
height: 280vh;
|
||||
}
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-lg);
|
||||
justify-items: center;
|
||||
padding-block: var(--nds-spacing-xl);
|
||||
|
||||
.is-ready & {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100svh;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-head {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
@include type.text-3xl;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
}
|
||||
|
||||
.board {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--nds-spacing-md);
|
||||
width: min(880px, 100%);
|
||||
padding: var(--nds-spacing-lg);
|
||||
border: 1px dashed var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
background: color-mix(in srgb, var(--nds-surface) 36%, transparent);
|
||||
|
||||
@media (max-width: 700px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.col {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.col-head {
|
||||
@include type.text-label;
|
||||
color: color-mix(in srgb, var(--nds-text) calc(55% + var(--done, 0) * 45%), transparent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding-bottom: var(--nds-spacing-2xs);
|
||||
border-bottom: 1px solid
|
||||
color-mix(in srgb, var(--nds-accent) calc(var(--done, 0) * 100%), var(--nds-border));
|
||||
transition: color 0.2s linear, border-color 0.2s linear;
|
||||
}
|
||||
|
||||
.chip {
|
||||
@include type.text-sm;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
width: fit-content;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-full);
|
||||
background: var(--nds-surface-2);
|
||||
color: var(--nds-text);
|
||||
font-family: var(--nds-font-mono);
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
&[data-cat='img'] i { background: var(--nds-success-high); }
|
||||
&[data-cat='doc'] i { background: var(--nds-info-high, var(--nds-primary)); }
|
||||
&[data-cat='audio'] i { background: var(--nds-warning-high); }
|
||||
&[data-cat='code'] i { background: var(--nds-accent); }
|
||||
|
||||
&.is-sorted {
|
||||
border-color: color-mix(in srgb, var(--nds-accent) 55%, var(--nds-border));
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced mode: chips leave the flow and are driven by transforms. */
|
||||
.sorter.is-ready .chip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-disabled);
|
||||
|
||||
.static & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,871 @@
|
||||
---
|
||||
/**
|
||||
* NDS scene, "la forge" - three scroll-driven acts on one pinned stage:
|
||||
*
|
||||
* Act 1 - Tokens: raw design tokens (real CSS custom properties, shown as
|
||||
* live swatches) scattered in space converge into a neat row.
|
||||
* Act 2 - Assembly: eight REAL NDS components staged one by one in a 3D
|
||||
* tunnel - each arrives from the depth, holds in focus with its
|
||||
* spec panel (category, role, tokens, usage snippet), then flies
|
||||
* past the camera.
|
||||
* Act 3 - The system: the full library snaps into a grid, with the
|
||||
* package stats counting up and the install command typing out.
|
||||
*
|
||||
* Everything is CSS 3D + the shared scrub() helper - no WebGL needed here.
|
||||
* Reduced motion / no JS: the acts render stacked and fully visible.
|
||||
*/
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Pagination,
|
||||
PaginationNumber,
|
||||
Select,
|
||||
SelectOption,
|
||||
TextField,
|
||||
Toggle,
|
||||
} from '@unkn0wndo3s/nova-design-system';
|
||||
import Eyebrow from '../Eyebrow.astro';
|
||||
import { getLocaleFromUrl, useTranslations } from '../../i18n';
|
||||
import type { Localized } from '../../data/projects';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
interface Stage {
|
||||
id: string;
|
||||
component: string;
|
||||
category: Localized;
|
||||
title: Localized;
|
||||
desc: Localized;
|
||||
tokens: string[];
|
||||
usage: string;
|
||||
}
|
||||
|
||||
const stages: Stage[] = [
|
||||
{
|
||||
id: 'button',
|
||||
component: '<Button />',
|
||||
category: { fr: 'Actions', en: 'Actions' },
|
||||
title: { fr: 'Quatre intentions, une API.', en: 'Four intents, one API.' },
|
||||
desc: {
|
||||
fr: "primary, secondary, ghost, danger - trois tailles, états focus/disabled gérés, rendu <a> ou <button> selon la prop href.",
|
||||
en: 'primary, secondary, ghost, danger - three sizes, focus/disabled states handled, renders <a> or <button> depending on the href prop.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-on-primary', '--nds-radius-md'],
|
||||
usage: '<Button type="primary" size="md">Déployer</Button>',
|
||||
},
|
||||
{
|
||||
id: 'badge',
|
||||
component: '<Badge />',
|
||||
category: { fr: 'Feedback', en: 'Feedback' },
|
||||
title: { fr: 'Un statut lisible en un regard.', en: 'A status readable at a glance.' },
|
||||
desc: {
|
||||
fr: 'Cinq types sémantiques × deux variantes (soft/solid). Les couleurs viennent des paires de tokens statut, jamais de valeurs en dur.',
|
||||
en: 'Five semantic types × two variants (soft/solid). Colors come from the status token pairs, never hard-coded values.',
|
||||
},
|
||||
tokens: ['--nds-success-high', '--nds-warning-high', '--nds-error-high'],
|
||||
usage: '<Badge type="success">En production</Badge>',
|
||||
},
|
||||
{
|
||||
id: 'toggle',
|
||||
component: '<Toggle />',
|
||||
category: { fr: 'Formulaires', en: 'Forms' },
|
||||
title: { fr: 'Un vrai Web Component.', en: 'A real Web Component.' },
|
||||
desc: {
|
||||
fr: "Encapsulé en custom element : état interne, événements, accessibilité clavier - utilisable même hors d'Astro.",
|
||||
en: 'Wrapped as a custom element: internal state, events, keyboard accessibility - usable even outside Astro.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-surface-2', '--nds-radius-full'],
|
||||
usage: '<Toggle id="notifications" data-checked="true" />',
|
||||
},
|
||||
{
|
||||
id: 'textfield',
|
||||
component: '<TextField />',
|
||||
category: { fr: 'Formulaires', en: 'Forms' },
|
||||
title: { fr: 'Label, aide, compteur, erreurs.', en: 'Label, help, counter, errors.' },
|
||||
desc: {
|
||||
fr: 'Compteur de caractères optionnel, messages liés en aria-describedby, styles de focus et d’erreur issus des tokens.',
|
||||
en: 'Optional character counter, messages linked via aria-describedby, focus and error styles driven by tokens.',
|
||||
},
|
||||
tokens: ['--nds-border', '--nds-text', '--nds-error-high'],
|
||||
usage: '<TextField id="email" label="E-mail" type="email" />',
|
||||
},
|
||||
{
|
||||
id: 'select',
|
||||
component: '<Select />',
|
||||
category: { fr: 'Formulaires', en: 'Forms' },
|
||||
title: { fr: 'Un select stylable, enfin.', en: 'A styleable select, at last.' },
|
||||
desc: {
|
||||
fr: 'Listbox custom : navigation clavier, fermeture extérieure, valeur synchronisée dans un input caché pour les formulaires natifs.',
|
||||
en: 'Custom listbox: keyboard navigation, outside-close, value mirrored into a hidden input for native forms.',
|
||||
},
|
||||
tokens: ['--nds-surface', '--nds-primary', '--nds-shadow-md'],
|
||||
usage: '<Select default="TypeScript">…</Select>',
|
||||
},
|
||||
{
|
||||
id: 'checkbox',
|
||||
component: '<Checkbox />',
|
||||
category: { fr: 'Formulaires', en: 'Forms' },
|
||||
title: { fr: 'Coche native, peau custom.', en: 'Native check, custom skin.' },
|
||||
desc: {
|
||||
fr: "L'input natif reste dans le DOM (accessibilité et formulaires gratuits) ; seul le visuel est redessiné avec les tokens.",
|
||||
en: 'The native input stays in the DOM (free accessibility and forms); only the visual layer is redrawn with tokens.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-border', '--nds-radius-sm'],
|
||||
usage: '<Checkbox id="cgu" checked />',
|
||||
},
|
||||
{
|
||||
id: 'avatar',
|
||||
component: '<Avatar />',
|
||||
category: { fr: 'Data display', en: 'Data display' },
|
||||
title: { fr: 'Photo, sinon initiales.', en: 'Photo, else initials.' },
|
||||
desc: {
|
||||
fr: "Fallback automatique : si l'image manque ou casse, les initiales prennent le relais - le layout ne bouge pas d'un pixel.",
|
||||
en: 'Automatic fallback: if the image is missing or breaks, initials take over - the layout never shifts a pixel.',
|
||||
},
|
||||
tokens: ['--nds-surface-2', '--nds-text', '--nds-radius-full'],
|
||||
usage: '<Avatar type="initials" name="Louis Potevin" />',
|
||||
},
|
||||
{
|
||||
id: 'pagination',
|
||||
component: '<Pagination />',
|
||||
category: { fr: 'Navigation', en: 'Navigation' },
|
||||
title: { fr: 'Composable, pas configurable.', en: 'Composable, not configurable.' },
|
||||
desc: {
|
||||
fr: 'Pas de prop géante : des sous-composants (PaginationNumber…) à assembler - le pattern de composition utilisé partout dans NDS.',
|
||||
en: 'No giant prop bag: sub-components (PaginationNumber…) you compose - the composition pattern used across NDS.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-neutral', '--nds-radius-md'],
|
||||
usage: '<Pagination><PaginationNumber>1</PaginationNumber>…</Pagination>',
|
||||
},
|
||||
];
|
||||
|
||||
// Act 1: the raw material - real tokens rendered as live swatches.
|
||||
const tokenField: { name: string; kind: 'color' | 'radius' | 'space' | 'type' }[] = [
|
||||
{ name: '--nds-primary', kind: 'color' },
|
||||
{ name: '--nds-accent', kind: 'color' },
|
||||
{ name: '--nds-success-high', kind: 'color' },
|
||||
{ name: '--nds-warning-high', kind: 'color' },
|
||||
{ name: '--nds-error-high', kind: 'color' },
|
||||
{ name: '--nds-surface', kind: 'color' },
|
||||
{ name: '--nds-text', kind: 'color' },
|
||||
{ name: '--nds-neutral', kind: 'color' },
|
||||
{ name: '--nds-radius-md', kind: 'radius' },
|
||||
{ name: '--nds-radius-full', kind: 'radius' },
|
||||
{ name: '--nds-spacing-md', kind: 'space' },
|
||||
{ name: '--nds-spacing-xl', kind: 'space' },
|
||||
{ name: '--nds-font-mono', kind: 'type' },
|
||||
{ name: '--nds-font-sans', kind: 'type' },
|
||||
];
|
||||
// Deterministic scatter (build-time constants, so SSR output is stable).
|
||||
const scatter = [
|
||||
[-38, -26], [32, -34], [-12, 30], [44, 18], [-46, 6], [8, -18],
|
||||
[24, 34], [-28, -8], [40, -6], [-6, -38], [16, 12], [-40, 28],
|
||||
[0, 22], [36, 2],
|
||||
];
|
||||
|
||||
const library = [
|
||||
'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> = {
|
||||
a1: { fr: 'Acte I - Tout part des tokens.', en: 'Act I - Everything starts with tokens.' },
|
||||
a2: { fr: 'Acte II - Les composants, forgés un à un.', en: 'Act II - The components, forged one by one.' },
|
||||
a3: { fr: 'Acte III - Le système.', en: 'Act III - The system.' },
|
||||
};
|
||||
---
|
||||
|
||||
<section
|
||||
class="forge"
|
||||
data-forge
|
||||
style={`--stages: ${stages.length}`}
|
||||
data-a2={actLabels.a2![locale]}
|
||||
data-a3={actLabels.a3![locale]}
|
||||
aria-label={t('nds.showcase.title')}
|
||||
>
|
||||
<div class="stage-pin">
|
||||
<header class="head">
|
||||
<Eyebrow>{t('nds.showcase.eyebrow')}</Eyebrow>
|
||||
<h2 class="section-title">{t('nds.showcase.title')}</h2>
|
||||
<p class="act-label" data-act-label>{actLabels.a1![locale]}</p>
|
||||
</header>
|
||||
|
||||
<!-- ACT I · token field -->
|
||||
<div class="act act--tokens" data-act1>
|
||||
{tokenField.map((tk, i) => (
|
||||
<span
|
||||
class:list={['tk', `tk--${tk.kind}`]}
|
||||
style={`--dx:${scatter[i]![0]}vmin; --dy:${scatter[i]![1]}vmin; --i:${i};`}
|
||||
>
|
||||
{tk.kind === 'color' && <i style={`background: var(${tk.name})`}></i>}
|
||||
{tk.kind === 'radius' && <i style={`border-radius: var(${tk.name})`}></i>}
|
||||
{tk.kind === 'space' && <i style={`width: var(${tk.name})`}></i>}
|
||||
{tk.kind === 'type' && <i>Aa</i>}
|
||||
<code>{tk.name}</code>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- ACT II · the tunnel of components -->
|
||||
<div class="act act--tunnel" data-act2>
|
||||
{stages.map((s, i) => (
|
||||
<article class="slide" data-slide={i} aria-labelledby={`forge-${s.id}`}>
|
||||
<p class="slide__ghost" aria-hidden="true">{s.component}</p>
|
||||
|
||||
<div class="slide__grid">
|
||||
<div class="slide__demo">
|
||||
<div class="demo-halo" aria-hidden="true"></div>
|
||||
{s.id === 'button' && (
|
||||
<div class="demo-row">
|
||||
<Button size="md">Primary</Button>
|
||||
<Button type="secondary" size="md">Secondary</Button>
|
||||
<Button type="ghost" size="md">Ghost</Button>
|
||||
<Button type="danger" size="md">Danger</Button>
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'badge' && (
|
||||
<div class="demo-row">
|
||||
<Badge type="primary">Primary</Badge>
|
||||
<Badge type="success">Success</Badge>
|
||||
<Badge type="warning" variant="solid">Warning</Badge>
|
||||
<Badge type="error">Error</Badge>
|
||||
<Badge type="info" variant="solid">Info</Badge>
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'toggle' && (
|
||||
<div class="demo-row demo-row--big">
|
||||
<Toggle id="forge-toggle" data-checked="true" />
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'textfield' && (
|
||||
<div class="demo-col">
|
||||
<TextField id="forge-tf" label="E-mail" type="email" placeholder="vous@exemple.dev" />
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'select' && (
|
||||
<div class="demo-col">
|
||||
<Select default="TypeScript">
|
||||
<SelectOption value="TypeScript">TypeScript</SelectOption>
|
||||
<SelectOption value="Rust">Rust</SelectOption>
|
||||
<SelectOption value="Python">Python</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'checkbox' && (
|
||||
<div class="demo-row">
|
||||
<Checkbox id="forge-cb" checked />
|
||||
<Checkbox id="forge-cb2" />
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'avatar' && (
|
||||
<div class="demo-row">
|
||||
<Avatar type="initials" name="Louis Potevin" />
|
||||
<Avatar type="initials" name="Nova Design" />
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'pagination' && (
|
||||
<div class="demo-row">
|
||||
<Pagination>
|
||||
<PaginationNumber>1</PaginationNumber>
|
||||
<PaginationNumber>2</PaginationNumber>
|
||||
<PaginationNumber>3</PaginationNumber>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="slide__spec">
|
||||
<p class="spec-cat">{s.category[locale]}</p>
|
||||
<h3 id={`forge-${s.id}`}>{s.title[locale]}</h3>
|
||||
<p class="spec-desc">{s.desc[locale]}</p>
|
||||
|
||||
<ul class="spec-tokens">
|
||||
{s.tokens.map((tok) => (
|
||||
<li>
|
||||
<i style={`background: var(${tok}, transparent)`}></i>
|
||||
<code>{tok}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div class="spec-code" aria-label="Usage">
|
||||
<div class="spec-code__bar"><span></span><span></span><span></span><em>usage.astro</em></div>
|
||||
<pre><code>{s.usage}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
<p class="counter" aria-hidden="true">
|
||||
<span data-counter>01</span> / {String(stages.length).padStart(2, '0')}
|
||||
</p>
|
||||
<ol class="ticks" aria-hidden="true">
|
||||
{stages.map((_, i) => <li data-tick={i}></li>)}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- ACT III · the system -->
|
||||
<div class="act act--finale" data-act3>
|
||||
<ul class="lib">
|
||||
{library.map((name, i) => (
|
||||
<li style={`--i:${i}`}><code>{name}</code></li>
|
||||
))}
|
||||
</ul>
|
||||
<div class="stats">
|
||||
<p><strong data-count-to="29">0</strong><span>{locale === 'fr' ? 'composants' : 'components'}</span></p>
|
||||
<p><strong data-count-to="6">0</strong><span>{locale === 'fr' ? 'catégories' : 'categories'}</span></p>
|
||||
<p><strong>v1.2.1</strong><span>npm</span></p>
|
||||
</div>
|
||||
<p class="install"><code data-typed>npm install @unkn0wndo3s/nova-design-system</code></p>
|
||||
</div>
|
||||
|
||||
<p class="hint" aria-hidden="true">{t('nds.showcase.hint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { scrub, clamp01, window01 } from '../../lib/scrub';
|
||||
|
||||
function init() {
|
||||
const root = document.querySelector<HTMLElement>('[data-forge]');
|
||||
if (!root) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
root.classList.add('static');
|
||||
return;
|
||||
}
|
||||
root.classList.add('is-ready');
|
||||
|
||||
const actLabel = root.querySelector<HTMLElement>('[data-act-label]')!;
|
||||
const labelTexts = [
|
||||
actLabel.textContent ?? '',
|
||||
root.dataset.a2 ?? '',
|
||||
root.dataset.a3 ?? '',
|
||||
];
|
||||
const act1 = root.querySelector<HTMLElement>('[data-act1]')!;
|
||||
const act2 = root.querySelector<HTMLElement>('[data-act2]')!;
|
||||
const act3 = root.querySelector<HTMLElement>('[data-act3]')!;
|
||||
const slides = Array.from(root.querySelectorAll<HTMLElement>('[data-slide]'));
|
||||
const ticks = Array.from(root.querySelectorAll<HTMLElement>('[data-tick]'));
|
||||
const counter = root.querySelector<HTMLElement>('[data-counter]')!;
|
||||
const counts = Array.from(root.querySelectorAll<HTMLElement>('[data-count-to]'));
|
||||
const typed = root.querySelector<HTMLElement>('[data-typed]')!;
|
||||
const typedFull = typed.textContent ?? '';
|
||||
const n = slides.length;
|
||||
|
||||
// Act windows on the section's 0..1 progress.
|
||||
const A1 = [0.0, 0.14] as const;
|
||||
const A2 = [0.14, 0.86] as const;
|
||||
const A3 = [0.86, 1.0] as const;
|
||||
|
||||
let lastIdx = -1;
|
||||
let lastLabel = -1;
|
||||
|
||||
scrub(root, (p) => {
|
||||
// ---- Act I: scattered tokens converge, then hand over. ----
|
||||
const gather = window01(p, 0.02, 0.1);
|
||||
const a1out = 1 - window01(p, A1[1] - 0.03, A1[1] + 0.02);
|
||||
act1.style.opacity = String(a1out);
|
||||
act1.style.setProperty('--gather', String(gather));
|
||||
act1.style.visibility = a1out > 0.01 ? 'visible' : 'hidden';
|
||||
|
||||
// ---- Act II: the tunnel. ----
|
||||
const inTunnel = window01(p, A1[1] - 0.02, A1[1] + 0.03) * (1 - window01(p, A2[1] - 0.02, A2[1] + 0.02));
|
||||
act2.style.opacity = String(inTunnel);
|
||||
act2.style.visibility = inTunnel > 0.01 ? 'visible' : 'hidden';
|
||||
|
||||
const tp = clamp01((p - A2[0]) / (A2[1] - A2[0])); // 0..1 inside act II
|
||||
const f = tp * n; // fractional slide index
|
||||
const idx = Math.min(n - 1, Math.floor(f));
|
||||
|
||||
slides.forEach((el, i) => {
|
||||
const d = f - i - 0.5; // 0 when slide i is centered
|
||||
// depth: future slides far away (negative z), past ones fly at the camera
|
||||
const z = -d * 780;
|
||||
const op = clamp01(1 - Math.abs(d) * 1.7);
|
||||
const blur = Math.min(14, Math.abs(d) * 16);
|
||||
const rot = d * -7;
|
||||
el.style.opacity = String(op);
|
||||
el.style.visibility = op > 0.01 && inTunnel > 0.01 ? 'visible' : 'hidden';
|
||||
el.style.transform = `translate(-50%, -50%) perspective(1100px) translateZ(${z.toFixed(0)}px) rotateY(${rot.toFixed(2)}deg)`;
|
||||
el.style.filter = `blur(${blur.toFixed(1)}px)`;
|
||||
el.classList.toggle('is-front', Math.abs(d) < 0.28);
|
||||
});
|
||||
|
||||
if (idx !== lastIdx && inTunnel > 0.2) {
|
||||
lastIdx = idx;
|
||||
counter.textContent = String(idx + 1).padStart(2, '0');
|
||||
ticks.forEach((tk, i) => tk.classList.toggle('is-on', i <= idx));
|
||||
}
|
||||
|
||||
// ---- Act III: the library grid + stats. ----
|
||||
const fin = window01(p, A3[0] - 0.01, A3[0] + 0.06);
|
||||
act3.style.opacity = String(fin);
|
||||
act3.style.visibility = fin > 0.01 ? 'visible' : 'hidden';
|
||||
act3.style.setProperty('--fin', String(fin));
|
||||
const count = window01(p, A3[0], A3[0] + 0.1);
|
||||
counts.forEach((c) => {
|
||||
c.textContent = String(Math.round(Number(c.dataset.countTo) * count));
|
||||
});
|
||||
const chars = Math.round(typedFull.length * window01(p, A3[0] + 0.02, A3[0] + 0.11));
|
||||
typed.textContent = typedFull.slice(0, Math.max(0, chars)) + (chars < typedFull.length && fin > 0 ? '▌' : '');
|
||||
|
||||
// ---- Act label. ----
|
||||
const which = p < A1[1] ? 0 : p < A2[1] ? 1 : 2;
|
||||
if (which !== lastLabel) {
|
||||
lastLabel = which;
|
||||
actLabel.style.opacity = '0';
|
||||
window.setTimeout(() => {
|
||||
actLabel.textContent = labelTexts[which]!;
|
||||
actLabel.style.opacity = '1';
|
||||
}, 160);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../styles/type' as type;
|
||||
|
||||
.forge {
|
||||
margin-block: clamp(48px, 9vh, 110px);
|
||||
|
||||
&.is-ready {
|
||||
/* runway: intro + one beat per stage + finale */
|
||||
height: calc(140vh + var(--stages) * 85vh + 140vh);
|
||||
}
|
||||
}
|
||||
|
||||
.stage-pin {
|
||||
position: relative;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--nds-spacing-lg);
|
||||
padding-block: var(--nds-spacing-xl);
|
||||
|
||||
.is-ready & {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100svh;
|
||||
overflow: hidden;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
text-align: center;
|
||||
z-index: 4;
|
||||
}
|
||||
.act-label {
|
||||
@include type.text-label;
|
||||
color: var(--nds-accent);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
transition: opacity 0.16s ease;
|
||||
min-height: 1.4em;
|
||||
}
|
||||
|
||||
/* ------------------------------- ACT I ---------------------------------- */
|
||||
.act--tokens {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
max-width: 760px;
|
||||
|
||||
.is-ready & {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
max-width: none;
|
||||
align-content: center;
|
||||
padding-inline: 10vw;
|
||||
}
|
||||
}
|
||||
|
||||
.tk {
|
||||
@include type.text-sm;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-full);
|
||||
background: color-mix(in srgb, var(--nds-surface) 85%, transparent);
|
||||
font-family: var(--nds-font-mono);
|
||||
color: var(--nds-neutral);
|
||||
|
||||
i {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
&.tk--space i {
|
||||
height: 4px;
|
||||
background: var(--nds-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
&.tk--radius i {
|
||||
border: 1.5px solid var(--nds-accent);
|
||||
}
|
||||
|
||||
/* scattered → gathered, driven by --gather */
|
||||
.is-ready & {
|
||||
transform: translate(
|
||||
calc(var(--dx) * (1 - var(--gather, 0))),
|
||||
calc(var(--dy) * (1 - var(--gather, 0)))
|
||||
)
|
||||
rotate(calc((1 - var(--gather, 0)) * (var(--i) - 7) * 4deg));
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- ACT II --------------------------------- */
|
||||
.act--tunnel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.is-ready & {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.slide {
|
||||
/* static: stacked list */
|
||||
position: relative;
|
||||
margin-inline: auto;
|
||||
margin-block: var(--nds-spacing-2xl);
|
||||
width: min(980px, 92vw);
|
||||
|
||||
.is-ready & {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
will-change: transform, opacity, filter;
|
||||
pointer-events: none;
|
||||
|
||||
&.is-front {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slide__ghost {
|
||||
@include type.text-5xl;
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: -0.9em;
|
||||
text-align: center;
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: clamp(3rem, 11vw, 8rem);
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 1px color-mix(in srgb, var(--nds-primary) 26%, transparent);
|
||||
letter-spacing: -0.02em;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slide__grid {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1fr;
|
||||
gap: clamp(var(--nds-spacing-lg), 4vw, var(--nds-spacing-2xl));
|
||||
align-items: center;
|
||||
padding: clamp(var(--nds-spacing-lg), 3.5vw, var(--nds-spacing-2xl));
|
||||
border: 1px solid color-mix(in srgb, var(--nds-border) 85%, transparent);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
background: color-mix(in srgb, var(--nds-background) 72%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: var(--nds-shadow-md);
|
||||
|
||||
@media (max-width: 840px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.slide__demo {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 180px;
|
||||
border: 1px dashed color-mix(in srgb, var(--nds-primary) 35%, transparent);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
padding: var(--nds-spacing-lg);
|
||||
}
|
||||
.demo-halo {
|
||||
position: absolute;
|
||||
inset: 20%;
|
||||
background: radial-gradient(closest-side, color-mix(in srgb, var(--nds-primary) 18%, transparent), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.demo-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
}
|
||||
.demo-row--big {
|
||||
scale: 1.5;
|
||||
}
|
||||
.demo-col {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-sm);
|
||||
width: min(300px, 100%);
|
||||
}
|
||||
|
||||
.slide__spec {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-sm);
|
||||
justify-items: start;
|
||||
|
||||
h3 {
|
||||
@include type.text-2xl;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
}
|
||||
.spec-cat {
|
||||
@include type.text-label;
|
||||
color: var(--nds-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.spec-desc {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-neutral);
|
||||
max-width: 46ch;
|
||||
}
|
||||
.spec-tokens {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
@include type.text-sm;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-full);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--nds-neutral);
|
||||
|
||||
i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spec-code {
|
||||
width: 100%;
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-md);
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--nds-surface) 92%, transparent);
|
||||
|
||||
&__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--nds-border);
|
||||
|
||||
span {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--nds-border);
|
||||
}
|
||||
span:first-child { background: var(--nds-error-high); }
|
||||
span:nth-child(2) { background: var(--nds-warning-high); }
|
||||
span:nth-child(3) { background: var(--nds-success-high); }
|
||||
em {
|
||||
@include type.text-sm;
|
||||
margin-left: auto;
|
||||
font-style: normal;
|
||||
font-size: 0.7rem;
|
||||
color: var(--nds-disabled);
|
||||
font-family: var(--nds-font-mono);
|
||||
}
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: var(--nds-spacing-sm) var(--nds-spacing-md);
|
||||
overflow-x: auto;
|
||||
}
|
||||
code {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--nds-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
.counter {
|
||||
@include type.text-label;
|
||||
position: absolute;
|
||||
right: clamp(16px, 4vw, 48px);
|
||||
bottom: clamp(14px, 3vh, 32px);
|
||||
margin: 0;
|
||||
font-family: var(--nds-font-mono);
|
||||
color: var(--nds-neutral);
|
||||
display: none;
|
||||
|
||||
span {
|
||||
@include type.text-3xl;
|
||||
color: var(--nds-accent);
|
||||
}
|
||||
.is-ready & { display: block; }
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: absolute;
|
||||
left: clamp(16px, 4vw, 48px);
|
||||
bottom: clamp(18px, 3vh, 36px);
|
||||
display: none;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--nds-border);
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&.is-on { background: var(--nds-accent); }
|
||||
}
|
||||
.is-ready & { display: flex; }
|
||||
}
|
||||
|
||||
/* ------------------------------- ACT III -------------------------------- */
|
||||
.act--finale {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--nds-spacing-lg);
|
||||
width: min(880px, 94vw);
|
||||
|
||||
.is-ready & {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
width: auto;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
padding-inline: 8vw;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lib {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-width: 760px;
|
||||
|
||||
li {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-full);
|
||||
background: color-mix(in srgb, var(--nds-surface) 80%, transparent);
|
||||
|
||||
code {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.is-ready & {
|
||||
opacity: calc(var(--fin, 0) * 1);
|
||||
transform: translateY(calc((1 - var(--fin, 0)) * (12px + var(--i) * 1.2px)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: clamp(var(--nds-spacing-lg), 6vw, var(--nds-spacing-2xl));
|
||||
|
||||
p {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
strong {
|
||||
@include type.text-4xl;
|
||||
font-family: var(--nds-font-mono);
|
||||
color: var(--nds-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
span {
|
||||
@include type.text-label;
|
||||
color: var(--nds-neutral);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
}
|
||||
|
||||
.install {
|
||||
code {
|
||||
@include type.text-sm;
|
||||
display: inline-block;
|
||||
padding: var(--nds-spacing-xs) var(--nds-spacing-lg);
|
||||
border: 1px solid color-mix(in srgb, var(--nds-accent) 50%, var(--nds-border));
|
||||
border-radius: var(--nds-radius-md);
|
||||
background: color-mix(in srgb, var(--nds-surface) 92%, transparent);
|
||||
font-family: var(--nds-font-mono);
|
||||
color: var(--nds-accent);
|
||||
min-height: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-disabled);
|
||||
z-index: 4;
|
||||
|
||||
.static &,
|
||||
.forge:not(.is-ready) & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+198
-131
@@ -1,187 +1,254 @@
|
||||
// Projects - real content. Edit freely.
|
||||
// `icon` matches a simple-icons-astro component (using PascalCase elsewhere).
|
||||
import type { Locale } from '../i18n';
|
||||
|
||||
export type ProjectStatus = 'live' | 'maintained' | 'archived' | 'r&d';
|
||||
/** A string localized in both site languages. */
|
||||
export type Localized = Record<Locale, string>;
|
||||
|
||||
export type ProjectStatus = 'live' | 'maintained' | 'wip';
|
||||
|
||||
export interface ProjectLink {
|
||||
label: string;
|
||||
url: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
slug: string;
|
||||
designation: string; // catalog index, e.g. "01"
|
||||
title: string;
|
||||
tagline: string;
|
||||
period: string;
|
||||
role: string;
|
||||
name: string;
|
||||
status: ProjectStatus;
|
||||
featured: boolean;
|
||||
summary: string;
|
||||
context: string[];
|
||||
contributions: string[];
|
||||
stack: { icon: string; label: string }[];
|
||||
tagline: Localized;
|
||||
summary: Localized;
|
||||
/** Long-form body, one paragraph per entry. */
|
||||
body: Localized[];
|
||||
highlights: Localized[];
|
||||
stack: { name: string; icon: string }[];
|
||||
links: ProjectLink[];
|
||||
featured: boolean;
|
||||
}
|
||||
|
||||
export const projects: Project[] = [
|
||||
{
|
||||
slug: 'nova-design-system',
|
||||
designation: '01',
|
||||
title: 'Nova Design System',
|
||||
tagline: 'Astro component library - tokens, accessibility, npm distribution.',
|
||||
period: '2026 - ongoing',
|
||||
role: 'Author · maintainer',
|
||||
name: 'Nova Design System',
|
||||
status: 'maintained',
|
||||
featured: true,
|
||||
summary:
|
||||
'A library of 29 Astro components across six families, with a complete token system (color, typography, spacing, radius) and a light/dark theme driven by CSS variables. Currently at version 1.2.1, it powers this portfolio and will be progressively adapted to my other projects, including outside the Astro ecosystem (a GPUI/Rust binding is planned for File Organizer).',
|
||||
context: [
|
||||
'Designed for clean, technical interfaces: mono fonts for headings, sans-serif for body text.',
|
||||
'Distributed under the npm scope @unkn0wndo3s, published automatically via a CI pipeline.',
|
||||
'License: free to use, resale of the design system as a standalone product is prohibited.',
|
||||
'Adoption is currently internal: the download spikes seen on npm in the days after each release are mostly bot/CI traffic rather than organic external adoption so far.',
|
||||
tagline: {
|
||||
fr: 'Bibliothèque de composants Astro - tokens, accessibilité, distribution npm.',
|
||||
en: 'Astro component library - tokens, accessibility, npm distribution.',
|
||||
},
|
||||
summary: {
|
||||
fr: "29 composants Astro répartis en six familles, un système complet de tokens (couleur, typographie, espacement, rayons) et un thème clair/sombre piloté par variables CSS. Publié sur npm en v1.2.1, il propulse ce portfolio.",
|
||||
en: 'A library of 29 Astro components across six families, a complete token system (color, typography, spacing, radius) and a light/dark theme driven by CSS variables. Published on npm at v1.2.1, it powers this portfolio.',
|
||||
},
|
||||
body: [
|
||||
{
|
||||
fr: "Nova Design System est né d'un besoin simple : arrêter de réécrire les mêmes boutons, champs et modales d'un projet à l'autre. Chaque composant est développé avec son interface TypeScript, ses états (hover, focus, disabled) et ses styles isolés en SCSS.",
|
||||
en: 'Nova Design System started from a simple need: stop rewriting the same buttons, fields and modals from one project to the next. Every component ships with its TypeScript interface, its states (hover, focus, disabled) and isolated SCSS styles.',
|
||||
},
|
||||
{
|
||||
fr: "La distribution se fait via npm (paquet public `@unkn0wndo3s/nova-design-system`), avec une CI Gitea Actions auto-hébergée qui vérifie, versionne et publie chaque release en 10 à 20 secondes. Le paquet livre ses sources `.astro`/`.scss` brutes, compilées côté consommateur.",
|
||||
en: 'Distribution happens through npm (public package `@unkn0wndo3s/nova-design-system`), with a self-hosted Gitea Actions CI that checks, versions and publishes each release in 10 to 20 seconds. The package ships raw `.astro`/`.scss` sources, compiled on the consumer side.',
|
||||
},
|
||||
{
|
||||
fr: "La suite : adapter progressivement NDS à mes autres projets, y compris hors de l'écosystème Astro - un binding GPUI/Rust est prévu pour File Organizer.",
|
||||
en: 'Next up: progressively adapting NDS to my other projects, including outside the Astro ecosystem - a GPUI/Rust binding is planned for File Organizer.',
|
||||
},
|
||||
],
|
||||
contributions: [
|
||||
'Architecture of tokens and components (Custom Elements, typed events, slots).',
|
||||
'Gitea Actions CI/CD pipeline for automated npm publishing: checkout, version check, install, publish — about 19s end to end.',
|
||||
'Current documentation: component list and status at nds.louis-potevin.dev, with technical usage docs coming next.',
|
||||
'6-12 month roadmap: automatic component export on every release, new components, complete documentation. Longer term (12+ months): ports to Vue.js, React, Svelte, Java, and a Rust package, with docs kept up to date for each target.',
|
||||
highlights: [
|
||||
{ fr: '29 composants exportés, six familles', en: '29 exported components, six families' },
|
||||
{ fr: 'Tokens couleur / typo / espacement / rayons, thème clair-sombre', en: 'Color / type / spacing / radius tokens, light-dark theme' },
|
||||
{ fr: 'CI/CD Gitea Actions auto-hébergée, publication npm en ~15 s', en: 'Self-hosted Gitea Actions CI/CD, npm publish in ~15 s' },
|
||||
{ fr: 'Licence maison : usage libre, monétisation du DS interdite', en: 'Custom license: free to use, monetizing NDS itself prohibited' },
|
||||
],
|
||||
stack: [
|
||||
{ icon: 'astro', label: 'Astro' },
|
||||
{ icon: 'typescript', label: 'TypeScript' },
|
||||
{ icon: 'sass', label: 'Sass' },
|
||||
{ icon: 'gitea', label: 'Gitea CI' },
|
||||
{ name: 'Astro', icon: 'Astro' },
|
||||
{ name: 'TypeScript', icon: 'Typescript' },
|
||||
{ name: 'Sass', icon: 'Sass' },
|
||||
{ name: 'Gitea CI', icon: 'Gitea' },
|
||||
{ name: 'npm', icon: 'Npm' },
|
||||
],
|
||||
links: [
|
||||
{ label: 'Nova Design System v1.2.1', url: 'https://nds.louis-potevin.dev/', icon: 'astro' },
|
||||
{ label: 'npm', url: 'https://www.npmjs.com/package/@unkn0wndo3s/nova-design-system', icon: 'npm' },
|
||||
{ label: 'Sources (Gitea)', url: 'https://git.novaprojects.dev/unkn0wn/nova-design-system', icon: 'gitea' },
|
||||
{ label: 'npm', url: 'https://www.npmjs.com/package/@unkn0wndo3s/nova-design-system' },
|
||||
{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn/nova-design-system' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'nova-infra',
|
||||
designation: '02',
|
||||
title: 'Self-hosted Infrastructure & Deployment',
|
||||
tagline: 'Containerized services, CI/CD, and secured ingress on a dedicated server.',
|
||||
period: '2025 - ongoing',
|
||||
role: 'Design & operations',
|
||||
name: 'Self-hosted Infrastructure',
|
||||
status: 'live',
|
||||
featured: true,
|
||||
summary:
|
||||
'Self-hosted infrastructure running on a dedicated OVH server. Four application containers currently active alongside the hosted sites, with Git, secret management, and exposure through an Apache reverse proxy.',
|
||||
context: [
|
||||
'Containerized with Docker on a dedicated OVH server.',
|
||||
'Services exposed through an Apache reverse proxy, suited to a dedicated-machine setup.',
|
||||
'Two domains managed: novaprojects.dev (4+ subdomains for self-hosted services) and louis-potevin.dev (1 subdomain).',
|
||||
tagline: {
|
||||
fr: 'Services conteneurisés, CI/CD et ingress sécurisé sur serveur dédié.',
|
||||
en: 'Containerized services, CI/CD and secured ingress on a dedicated server.',
|
||||
},
|
||||
summary: {
|
||||
fr: "Infrastructure auto-hébergée sur serveur OVH : forge Git (Gitea), gestionnaire de secrets (Vaultwarden), runners CI et sites hébergés, exposés derrière un reverse proxy Apache et Cloudflare.",
|
||||
en: 'Self-hosted infrastructure on an OVH server: Git forge (Gitea), secret manager (Vaultwarden), CI runners and hosted sites, exposed behind an Apache reverse proxy and Cloudflare.',
|
||||
},
|
||||
body: [
|
||||
{
|
||||
fr: "Tout mon outillage de développement tourne sur ma propre infrastructure : Gitea pour le code et les releases, Vaultwarden pour les secrets, un runner Gitea Actions pour la CI/CD, et les sites de production (dont celui-ci).",
|
||||
en: 'All my development tooling runs on my own infrastructure: Gitea for code and releases, Vaultwarden for secrets, a Gitea Actions runner for CI/CD, and the production sites (including this one).',
|
||||
},
|
||||
{
|
||||
fr: "Chaque service est conteneurisé et isolé ; l'exposition passe par un reverse proxy Apache avec TLS, derrière Cloudflare pour le DNS et la protection. Les déploiements sont entièrement automatisés : un push sur `main` suffit.",
|
||||
en: 'Each service is containerized and isolated; exposure goes through an Apache reverse proxy with TLS, behind Cloudflare for DNS and protection. Deployments are fully automated: a push to `main` is all it takes.',
|
||||
},
|
||||
],
|
||||
contributions: [
|
||||
'Setup and administration of a self-hosted Gitea instance with a Postgres backend and one Actions runner.',
|
||||
'Lightweight CI/CD pipelines (build, test, publish), running in about 10-20 seconds.',
|
||||
'Day-to-day operations: Docker networks, volumes, DNS, diagnostics.',
|
||||
'Identified improvement areas: no monitoring or backup strategy in place yet — next iteration of the infrastructure.',
|
||||
highlights: [
|
||||
{ fr: 'Forge Git + CI/CD + secrets 100 % auto-hébergés', en: '100% self-hosted Git forge + CI/CD + secrets' },
|
||||
{ fr: 'Reverse proxy Apache, TLS, DNS et protection Cloudflare', en: 'Apache reverse proxy, TLS, Cloudflare DNS and protection' },
|
||||
{ fr: 'Déploiement continu : push sur main = mise en production', en: 'Continuous deployment: push to main = production release' },
|
||||
],
|
||||
stack: [
|
||||
{ icon: 'docker', label: 'Docker' },
|
||||
{ icon: 'linux', label: 'Linux' },
|
||||
{ icon: 'apache', label: 'Apache (reverse proxy)' },
|
||||
{ icon: 'gitea', label: 'Gitea' },
|
||||
{ name: 'Docker', icon: 'Docker' },
|
||||
{ name: 'Linux', icon: 'Linux' },
|
||||
{ name: 'Apache', icon: 'Apache' },
|
||||
{ name: 'Gitea', icon: 'Gitea' },
|
||||
{ name: 'Cloudflare', icon: 'Cloudflare' },
|
||||
],
|
||||
links: [{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn', icon: 'gitea' }],
|
||||
links: [{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn' }],
|
||||
},
|
||||
{
|
||||
slug: 'portfolio',
|
||||
designation: '03',
|
||||
title: 'louis-potevin.dev',
|
||||
tagline: 'This website. Multi-page Astro app, 3D space background, 100% powered by Nova Design System.',
|
||||
period: '2026',
|
||||
role: 'Design & development',
|
||||
name: 'louis-potevin.dev',
|
||||
status: 'live',
|
||||
featured: true,
|
||||
summary:
|
||||
'A static multi-page portfolio (5 pages), entirely composed with Nova components, with a subtle Three.js space background. Built in one day, recently shipped.',
|
||||
context: [
|
||||
'Three.js background: starfield, nebula, and an ambient asteroid.',
|
||||
'UI entirely composed using the in-house design system.',
|
||||
'Astro with Static Site Generation (SSG), chosen for its simplicity over Svelte (already comfortable with Vue.js, and not fond of the Svelte DX).',
|
||||
],
|
||||
contributions: [
|
||||
'Full integration of Nova tokens and components.',
|
||||
'Respects prefers-reduced-motion and degrades gracefully when WebGL is unavailable.',
|
||||
'Current Lighthouse scores: Accessibility 100, Best Practices 100, SEO 92, Performance 92.',
|
||||
'Roadmap: multilingual version, dark/light mode toggle. No audience data yet (site just went live), no CV download for now.',
|
||||
],
|
||||
stack: [
|
||||
{ icon: 'astro', label: 'Astro' },
|
||||
{ icon: 'typescript', label: 'TypeScript' },
|
||||
{ icon: 'sass', label: 'Sass' },
|
||||
],
|
||||
links: [{ label: 'Unkn0wn Projects', url: 'https://louis-potevin.dev/', icon: 'astro' }],
|
||||
tagline: {
|
||||
fr: 'Ce site. Astro multi-pages, fond 3D, bilingue, 100 % Nova Design System.',
|
||||
en: 'This website. Multi-page Astro app, 3D background, bilingual, 100% Nova Design System.',
|
||||
},
|
||||
summary: {
|
||||
fr: "Portfolio statique bilingue (FR/EN), entièrement composé avec les composants Nova, avec une scène spatiale Three.js pilotée par le scroll et un SEO complet (hreflang, JSON-LD, sitemap).",
|
||||
en: 'A bilingual (FR/EN) static portfolio, entirely composed with Nova components, featuring a scroll-driven Three.js space scene and full SEO (hreflang, JSON-LD, sitemap).',
|
||||
},
|
||||
body: [
|
||||
{
|
||||
fr: "Le site sert de vitrine et de banc d'essai au design system : chaque bouton, badge, champ ou fil d'Ariane provient du paquet npm `@unkn0wndo3s/nova-design-system`, sans style dupliqué.",
|
||||
en: 'The site doubles as a showcase and test bench for the design system: every button, badge, field or breadcrumb comes from the `@unkn0wndo3s/nova-design-system` npm package, with no duplicated styling.',
|
||||
},
|
||||
{
|
||||
slug: 'rust-file-organizer',
|
||||
designation: '04',
|
||||
title: 'File Organizer - Java to Rust',
|
||||
tagline: 'File sorting tool, currently being ported from Java to Rust.',
|
||||
period: '2025 - ongoing',
|
||||
role: 'Development',
|
||||
status: 'r&d',
|
||||
featured: false,
|
||||
summary:
|
||||
'An automatic sorting tool that moves downloaded files and folders into dedicated directories (executables, archives, etc.) at the root of the downloads folder, alongside the existing images/videos/documents directories. The Java version, functional on Windows, is being ported to Rust for Linux compatibility, cleaner code, and a simpler build.',
|
||||
context: [
|
||||
'Current Java version: many dependencies, slower build, loosely structured code, requires Java installed (or shipping the executable).',
|
||||
'Rust version: better organized code, simpler compilation, expected scan performance on par with or better than Java.',
|
||||
'GUI built with GPUI (used for the interface only), with a side console for monitoring/diagnosing errors.',
|
||||
fr: "Côté 3D, une ceinture d'astéroïdes réagit au scroll sur la page d'accueil et les composants du design system flottent en 3D sur sa page projet. Le tout se coupe proprement sans WebGL ou quand l'utilisateur préfère réduire les animations.",
|
||||
en: 'On the 3D side, an asteroid belt reacts to scroll on the home page and the design system components float in 3D on its project page. Everything degrades cleanly without WebGL or when the user prefers reduced motion.',
|
||||
},
|
||||
],
|
||||
contributions: [
|
||||
'Sorting logic: moves files/folders into type-specific directories (executables, archives, etc.), with subfolders grouped into a "folders" directory.',
|
||||
'Sorting safety under review: the Java version copied then deleted the source after the copy completed (to prevent data loss); the approach will be revisited in Rust.',
|
||||
'Key goal of the port: deep recursive scanning — the Java version only knows about files/folders it has moved or seen at the surface level, not nested contents.',
|
||||
'Current state: UI nearly complete, sorting logic still to be rewritten; tested on small volumes so far, targeting 10k+ files at once since it runs on startup.',
|
||||
'Open source on GitHub; will move to Gitea once the Rust port is finished.',
|
||||
highlights: [
|
||||
{ fr: 'Bilingue FR/EN avec hreflang et sitemap localisé', en: 'Bilingual FR/EN with hreflang and localized sitemap' },
|
||||
{ fr: 'Scène Three.js pilotée par le scroll, fallback sans WebGL', en: 'Scroll-driven Three.js scene, no-WebGL fallback' },
|
||||
{ fr: 'JSON-LD Person / WebSite / BreadcrumbList / SoftwareSourceCode', en: 'Person / WebSite / BreadcrumbList / SoftwareSourceCode JSON-LD' },
|
||||
],
|
||||
stack: [
|
||||
{ icon: 'rust', label: 'Rust' },
|
||||
{ icon: 'linux', label: 'Linux (upcoming)' },
|
||||
{ name: 'Astro', icon: 'Astro' },
|
||||
{ name: 'TypeScript', icon: 'Typescript' },
|
||||
{ name: 'Sass', icon: 'Sass' },
|
||||
{ name: 'Three.js', icon: 'Threedotjs' },
|
||||
],
|
||||
links: [],
|
||||
links: [{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn/portfolio' }],
|
||||
},
|
||||
{
|
||||
slug: 'llm-tooling',
|
||||
designation: '05',
|
||||
title: 'LLM Training & Inference',
|
||||
tagline: 'Language models trained on a Reddit conversation dataset, aiming for a voice-capable, autonomous assistant.',
|
||||
period: '2025 - ongoing',
|
||||
role: 'Development',
|
||||
status: 'r&d',
|
||||
slug: 'file-organizer',
|
||||
name: 'File Organizer',
|
||||
status: 'wip',
|
||||
featured: false,
|
||||
summary:
|
||||
'A language model training and inference project on a dedicated machine, scaling up progressively: 100M, then 500M, 1B, and potentially 3B parameters. The end goal is a voice-capable conversational AI that learns from its conversations and gains a degree of autonomy.',
|
||||
context: [
|
||||
'Dedicated machine: Ryzen 9 9900X, RTX 5070 Ti, 64GB DDR5 6400MHz CL32.',
|
||||
'Dataset: Reddit conversations, starting at roughly 100GB with a target of scaling up to 1-2TB.',
|
||||
'Custom in-house inference script (currently being designed).',
|
||||
tagline: {
|
||||
fr: "Portage Java vers Rust d'un organiseur de fichiers, UI native avec GPUI.",
|
||||
en: 'Java-to-Rust port of a file organizer, native UI with GPUI.',
|
||||
},
|
||||
summary: {
|
||||
fr: "Réécriture en Rust d'un outil d'organisation de fichiers initialement en Java : surveillance du système de fichiers avec `notify`, interface native GPUI, et architecture qui sépare strictement la logique fichiers de l'UI.",
|
||||
en: 'A Rust rewrite of a file-organizing tool originally built in Java: filesystem watching with `notify`, a native GPUI interface, and an architecture that strictly separates file logic from the UI.',
|
||||
},
|
||||
body: [
|
||||
{
|
||||
fr: "L'objectif du portage : gagner en performance et en fiabilité, et apprendre Rust sur un vrai projet. La logique métier (règles de tri, déplacement, surveillance) est pure et testable, indépendante de la couche de rendu.",
|
||||
en: 'The goal of the port: gain performance and reliability, and learn Rust on a real project. The business logic (sorting rules, moves, watching) is pure and testable, independent from the rendering layer.',
|
||||
},
|
||||
],
|
||||
contributions: [
|
||||
'Assembling and structuring the Reddit conversation dataset.',
|
||||
'Setting up the training pipeline, currently in the preparation phase (training has not started yet, so no quality metrics are available at this stage).',
|
||||
highlights: [
|
||||
{ fr: 'Logique filesystem pure, découplée de l’UI', en: 'Pure filesystem logic, decoupled from the UI' },
|
||||
{ fr: 'Surveillance temps réel avec le crate notify', en: 'Real-time watching with the notify crate' },
|
||||
],
|
||||
stack: [
|
||||
{ icon: 'python', label: 'Python' },
|
||||
{ icon: 'linux', label: 'Linux' },
|
||||
{ name: 'Rust', icon: 'Rust' },
|
||||
{ name: 'GPUI', icon: 'Rust' },
|
||||
],
|
||||
links: [],
|
||||
links: [{ label: 'GitHub', url: 'https://github.com/unkn0wndo3s/file-organizer' }],
|
||||
},
|
||||
];
|
||||
|
||||
export const featuredProjects = projects.filter((p) => p.featured);
|
||||
|
||||
export const statusMeta: Record<ProjectStatus, { label: string; tone: 'success' | 'primary' | 'neutral' | 'warning' }> = {
|
||||
live: { label: 'Live', tone: 'success' },
|
||||
maintained: { label: 'Maintained', tone: 'primary' },
|
||||
'r&d': { label: 'In Progress', tone: 'warning' },
|
||||
archived: { label: 'Archived', tone: 'neutral' },
|
||||
};
|
||||
export function getProject(slug: string): Project | undefined {
|
||||
return projects.find((p) => p.slug === slug);
|
||||
}
|
||||
|
||||
/* ── NDS showcase data (project page "flying components" tour) ─────────────── */
|
||||
|
||||
export interface ShowcaseStage {
|
||||
id: string;
|
||||
component: string;
|
||||
title: Localized;
|
||||
description: Localized;
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
export const ndsShowcase: ShowcaseStage[] = [
|
||||
{
|
||||
id: 'button',
|
||||
component: 'Button',
|
||||
title: { fr: 'Button - quatre intentions, trois tailles', en: 'Button - four intents, three sizes' },
|
||||
description: {
|
||||
fr: 'Primary, secondary, ghost et danger. Rendu en lien ou en bouton selon la prop href, focus visible au clavier.',
|
||||
en: 'Primary, secondary, ghost and danger. Renders as a link or a button depending on the href prop, visible keyboard focus.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-on-primary', '--nds-radius-md', '--nds-ring'],
|
||||
},
|
||||
{
|
||||
id: 'badge',
|
||||
component: 'Badge',
|
||||
title: { fr: 'Badge - statuts sémantiques', en: 'Badge - semantic statuses' },
|
||||
description: {
|
||||
fr: 'Six types (primary, neutral, success, warning, error, info) en deux variantes, soft et solid, dérivées des tokens de statut.',
|
||||
en: 'Six types (primary, neutral, success, warning, error, info) in two variants, soft and solid, derived from the status tokens.',
|
||||
},
|
||||
tokens: ['--nds-success-medium', '--nds-warning-low', '--nds-radius-full'],
|
||||
},
|
||||
{
|
||||
id: 'textfield',
|
||||
component: 'TextField',
|
||||
title: { fr: 'TextField - saisie avec compteur', en: 'TextField - input with counter' },
|
||||
description: {
|
||||
fr: 'Texte, e-mail, mot de passe ou textarea, avec label lié, placeholder et compteur de caractères optionnel.',
|
||||
en: 'Text, e-mail, password or textarea, with a bound label, placeholder and optional character counter.',
|
||||
},
|
||||
tokens: ['--nds-surface', '--nds-border', '--nds-text', '--nds-ring'],
|
||||
},
|
||||
{
|
||||
id: 'toggle',
|
||||
component: 'Toggle',
|
||||
title: { fr: 'Toggle - Web Component', en: 'Toggle - Web Component' },
|
||||
description: {
|
||||
fr: "Implémenté selon le pattern Web Components : état interne, événements natifs, utilisable hors d'Astro.",
|
||||
en: 'Built on the Web Components pattern: internal state, native events, usable outside Astro.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-surface-2', '--nds-radius-full'],
|
||||
},
|
||||
{
|
||||
id: 'select',
|
||||
component: 'Select',
|
||||
title: { fr: 'Select - liste déroulante accessible', en: 'Select - accessible dropdown' },
|
||||
description: {
|
||||
fr: 'Select et SelectOption composables, navigation clavier, valeur par défaut et état désactivé.',
|
||||
en: 'Composable Select and SelectOption, keyboard navigation, default value and disabled state.',
|
||||
},
|
||||
tokens: ['--nds-surface', '--nds-border-strong', '--nds-shadow-md'],
|
||||
},
|
||||
{
|
||||
id: 'pagination',
|
||||
component: 'Pagination',
|
||||
title: { fr: 'Pagination - navigation par pages', en: 'Pagination - page navigation' },
|
||||
description: {
|
||||
fr: 'Pagination et PaginationNumber composables, état courant et bornes gérés par le composant.',
|
||||
en: 'Composable Pagination and PaginationNumber, current state and bounds handled by the component.',
|
||||
},
|
||||
tokens: ['--nds-primary-soft', '--nds-text', '--nds-radius-md'],
|
||||
},
|
||||
];
|
||||
|
||||
+27
-32
@@ -1,37 +1,32 @@
|
||||
/** Profile and links - single source of truth for the whole site. */
|
||||
export const site = {
|
||||
name: "Louis Potevin",
|
||||
handle: "unkn0wn",
|
||||
role: "Full-stack developer",
|
||||
domain: "louis-potevin.dev",
|
||||
location: "Limoges · Nouvelle-Aquitaine",
|
||||
// Job Search
|
||||
availability: "Permanent contract (CDI) - September 2026",
|
||||
mobility: "Anywhere in France, on-site or remote",
|
||||
// Contact
|
||||
email: "contact@louis-potevin.dev",
|
||||
// External links (icons via simple-icons-astro)
|
||||
name: 'Louis Potevin',
|
||||
email: 'contact@louis-potevin.dev',
|
||||
location: { city: 'Limoges', region: 'Nouvelle-Aquitaine', country: 'FR' },
|
||||
availability: { cdiFrom: '2026-09', freelance: true },
|
||||
links: {
|
||||
gitea: {
|
||||
label: "Gitea",
|
||||
url: "https://git.novaprojects.dev/unkn0wn",
|
||||
icon: "gitea",
|
||||
},
|
||||
github: {
|
||||
label: "GitHub",
|
||||
url: "https://github.com/unkn0wndo3s",
|
||||
icon: "github",
|
||||
},
|
||||
npm: {
|
||||
label: "npm",
|
||||
url: "https://www.npmjs.com/~unkn0wndo3s",
|
||||
icon: "npm",
|
||||
},
|
||||
linkedin: {
|
||||
label: "LinkedIn",
|
||||
url: "https://www.linkedin.com/in/louis-potevin",
|
||||
icon: "linkedin",
|
||||
},
|
||||
github: { label: 'GitHub', url: 'https://github.com/unkn0wndo3s' },
|
||||
gitea: { label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn' },
|
||||
npm: { label: 'npm', url: 'https://www.npmjs.com/~unkn0wndo3s' },
|
||||
linkedin: { label: 'LinkedIn', url: 'https://www.linkedin.com/in/louis-potevin' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type SiteLink = { label: string; url: string; icon: string };
|
||||
/** Everyday tools, shown on the home page. `icon` maps to simple-icons-astro names. */
|
||||
export const stack = [
|
||||
{ name: 'Astro', icon: 'Astro' },
|
||||
{ name: 'Vue.js', icon: 'Vuedotjs' },
|
||||
{ name: 'TypeScript', icon: 'Typescript' },
|
||||
{ name: 'JavaScript', icon: 'Javascript' },
|
||||
{ name: 'Sass', icon: 'Sass' },
|
||||
{ name: 'Node.js', icon: 'Nodedotjs' },
|
||||
{ name: 'PostgreSQL', icon: 'Postgresql' },
|
||||
{ name: 'Docker', icon: 'Docker' },
|
||||
{ name: 'Linux', icon: 'Linux' },
|
||||
{ name: 'Cloudflare', icon: 'Cloudflare' },
|
||||
{ name: 'Gitea CI/CD', icon: 'Gitea' },
|
||||
{ name: 'Rust', icon: 'Rust' },
|
||||
{ name: 'Python', icon: 'Python' },
|
||||
{ name: 'Git', icon: 'Git' },
|
||||
{ name: 'Figma', icon: 'Figma' },
|
||||
] as const;
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
|
||||
// The NDS "./styles" export maps to a raw .scss entry - no type declarations shipped.
|
||||
declare module '@unkn0wndo3s/nova-design-system/styles';
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* i18n core - locale detection, path localization, dictionary access.
|
||||
*
|
||||
* Routing model (Astro i18n, `prefixDefaultLocale: false`):
|
||||
* fr (default) -> / /work /work/[slug] /about /contact
|
||||
* en -> /en/ /en/work ...
|
||||
*/
|
||||
import { ui, type UIKey } from './ui';
|
||||
|
||||
export const LOCALES = ['fr', 'en'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = 'fr';
|
||||
|
||||
export const LOCALE_LABELS: Record<Locale, string> = { fr: 'Français', en: 'English' };
|
||||
export const OG_LOCALES: Record<Locale, string> = { fr: 'fr_FR', en: 'en_US' };
|
||||
export const HTML_LANGS: Record<Locale, string> = { fr: 'fr', en: 'en' };
|
||||
|
||||
/** Extracts the locale from a pathname ("/en/work" -> "en", "/work" -> "fr"). */
|
||||
export function getLocaleFromUrl(url: URL): Locale {
|
||||
const [, first] = url.pathname.split('/');
|
||||
return (LOCALES as readonly string[]).includes(first) && first !== DEFAULT_LOCALE
|
||||
? (first as Locale)
|
||||
: DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/** Prefixes a root-relative path for the given locale. localizePath('/work', 'en') -> '/en/work' */
|
||||
export function localizePath(path: string, locale: Locale): string {
|
||||
const clean = path.startsWith('/') ? path : `/${path}`;
|
||||
if (locale === DEFAULT_LOCALE) return clean;
|
||||
return clean === '/' ? `/${locale}/` : `/${locale}${clean}`;
|
||||
}
|
||||
|
||||
/** Strips a locale prefix, giving the canonical route shared by all locales. */
|
||||
export function unlocalizePath(pathname: string): string {
|
||||
for (const locale of LOCALES) {
|
||||
if (locale === DEFAULT_LOCALE) continue;
|
||||
if (pathname === `/${locale}` || pathname === `/${locale}/`) return '/';
|
||||
if (pathname.startsWith(`/${locale}/`)) return pathname.slice(locale.length + 1);
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/** Translation accessor bound to a locale: const t = useTranslations('fr'); t('nav.home') */
|
||||
export function useTranslations(locale: Locale) {
|
||||
return function t(key: UIKey): string {
|
||||
return ui[locale][key] ?? ui[DEFAULT_LOCALE][key] ?? key;
|
||||
};
|
||||
}
|
||||
|
||||
/** hreflang alternates for the current page (fr, en, x-default). */
|
||||
export function getAlternates(url: URL, site: string) {
|
||||
const route = unlocalizePath(url.pathname);
|
||||
const abs = (p: string) => new URL(p, site).href;
|
||||
return [
|
||||
{ hreflang: 'fr', href: abs(localizePath(route, 'fr')) },
|
||||
{ hreflang: 'en', href: abs(localizePath(route, 'en')) },
|
||||
{ hreflang: 'x-default', href: abs(localizePath(route, DEFAULT_LOCALE)) },
|
||||
];
|
||||
}
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* UI dictionaries. Keys are grouped by surface (nav, home, work, about, contact, footer).
|
||||
* Project content lives in `src/data/projects.ts` (localized per field).
|
||||
*/
|
||||
const fr = {
|
||||
// Meta
|
||||
'meta.home.title': 'Louis Potevin - Développeur full-stack (front, back, web)',
|
||||
'meta.home.description':
|
||||
"Louis Potevin, développeur web full-stack : front-end, back-end, TypeScript, Astro, Node. Disponible en CDI dès septembre 2026 et en freelance, partout en France ou en remote.",
|
||||
'meta.work.title': 'Projets - Louis Potevin, développeur full-stack',
|
||||
'meta.work.description':
|
||||
"Design system publié sur npm, infrastructure auto-hébergée, outils Rust : les projets de Louis Potevin, développeur web full-stack.",
|
||||
'meta.docs.title': 'Docs - Comment ce portfolio est construit, de A à Z',
|
||||
'meta.docs.description':
|
||||
"Documentation technique complète du portfolio de Louis Potevin : Astro, i18n, Lenis, Three.js, scènes scroll-driven, design system, SEO, déploiement - avec le vrai code source.",
|
||||
'meta.about.title': 'À propos - Louis Potevin, développeur full-stack',
|
||||
'meta.about.description':
|
||||
"Parcours de Louis Potevin : BUT MMI, alternance chez Legrand, projets personnels. Développeur full-stack TypeScript / Node, disponible en CDI et freelance.",
|
||||
'meta.contact.title': 'Contact - Louis Potevin, développeur full-stack',
|
||||
'meta.contact.description':
|
||||
"Contacter Louis Potevin, développeur web full-stack. CDI dès septembre 2026 ou mission freelance, partout en France, sur site ou en remote.",
|
||||
|
||||
// Nav
|
||||
'nav.home': 'Accueil',
|
||||
'nav.work': 'Projets',
|
||||
'nav.docs': 'Docs',
|
||||
'nav.about': 'À propos',
|
||||
'nav.contact': 'Contact',
|
||||
'nav.cta': 'Me contacter',
|
||||
'nav.skip': 'Aller au contenu',
|
||||
'nav.lang': 'Langue',
|
||||
|
||||
// Availability
|
||||
'avail.badge': 'Disponible',
|
||||
'avail.line': 'CDI dès septembre 2026 · Freelance dès maintenant',
|
||||
'avail.where': 'Partout en France, sur site ou en remote',
|
||||
|
||||
// Home
|
||||
'home.hero.title.role': 'Développeur full-stack',
|
||||
'home.hero.lead':
|
||||
"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.",
|
||||
'home.hero.viewProjects': 'Voir les projets',
|
||||
'home.hero.contact': 'Me contacter',
|
||||
|
||||
'home.profile.eyebrow': 'Profil',
|
||||
'home.profile.title': 'Un profil full-stack, à l’aise sur toute la chaîne.',
|
||||
'home.profile.body1':
|
||||
"J'ai l'expérience d'applications métier en production (alternance chez Legrand) et de projets personnels menés jusqu'au bout : un design system publié sur npm, une infrastructure auto-hébergée, des outils en Rust et Python.",
|
||||
'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.',
|
||||
'home.profile.front.title': 'Front-end',
|
||||
'home.profile.front.body':
|
||||
'Interfaces soignées et accessibles avec Astro, Vue.js, TypeScript et Sass - jusqu’au design system maison.',
|
||||
'home.profile.back.title': 'Back-end',
|
||||
'home.profile.back.body':
|
||||
'Services et API côté serveur, modélisation de données et logique métier avec Node et PostgreSQL.',
|
||||
'home.profile.ops.title': 'Outils & déploiement',
|
||||
'home.profile.ops.body':
|
||||
'Conteneurisation Docker, CI/CD automatisée et mise en production de bout en bout.',
|
||||
|
||||
'home.projects.eyebrow': 'Projets phares',
|
||||
'home.projects.title': 'Projets',
|
||||
'home.projects.all': 'Tous les projets',
|
||||
'home.projects.view': 'Voir le projet',
|
||||
|
||||
'home.stack.eyebrow': 'Stack technique',
|
||||
'home.stack.title': 'Mes outils du quotidien.',
|
||||
|
||||
'home.cta.title': 'On en parle ?',
|
||||
'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.',
|
||||
'home.cta.contact': 'Me contacter',
|
||||
'home.cta.more': 'En savoir plus',
|
||||
|
||||
// Work
|
||||
'work.title': 'Projets',
|
||||
'work.lead':
|
||||
"Chaque projet est mené comme un produit : design, code, tests, CI/CD et mise en production. Cliquez pour explorer le détail de chacun.",
|
||||
'work.status.live': 'En production',
|
||||
'work.status.maintained': 'Maintenu',
|
||||
'work.status.wip': 'En cours',
|
||||
'work.backToList': 'Tous les projets',
|
||||
'work.stack': 'Stack',
|
||||
'work.links': 'Liens',
|
||||
'work.highlights': 'Points clés',
|
||||
'work.next': 'Projet suivant',
|
||||
|
||||
// NDS showcase
|
||||
'nds.showcase.eyebrow': 'Exploration interactive',
|
||||
'nds.showcase.title': 'Les composants, un à un.',
|
||||
'nds.showcase.hint': 'Faites défiler pour parcourir les composants',
|
||||
'nds.showcase.tokens': 'Tokens utilisés',
|
||||
|
||||
// About
|
||||
'about.title': 'À propos',
|
||||
'about.lead':
|
||||
"Développeur full-stack, formé en BUT MMI (parcours développement web) et aguerri en entreprise chez Legrand - du stage à l'alternance, sur la même application métier en production.",
|
||||
'about.exp.eyebrow': 'Expérience',
|
||||
'about.exp.title': 'Parcours',
|
||||
'about.values.eyebrow': 'Méthode',
|
||||
'about.values.title': 'Ma façon de travailler',
|
||||
'about.cv': 'Télécharger mon CV',
|
||||
|
||||
// Contact
|
||||
'contact.title': 'Contact',
|
||||
'contact.lead':
|
||||
'Un poste en CDI, une mission freelance ou simplement une question : écrivez-moi, je réponds vite.',
|
||||
'contact.email.label': 'E-mail',
|
||||
'contact.email.copy': "Copier l'adresse",
|
||||
'contact.email.copied': 'Adresse copiée !',
|
||||
'contact.email.write': 'Écrire un e-mail',
|
||||
'contact.form.name': 'Votre nom',
|
||||
'contact.form.email': 'Votre e-mail',
|
||||
'contact.form.subject': 'Sujet',
|
||||
'contact.form.subject.cdi': 'Opportunité CDI',
|
||||
'contact.form.subject.freelance': 'Mission freelance',
|
||||
'contact.form.subject.other': 'Autre',
|
||||
'contact.form.message': 'Votre message',
|
||||
'contact.form.send': 'Envoyer le message',
|
||||
'contact.form.note':
|
||||
"Le bouton ouvre votre client mail avec le message pré-rempli - aucune donnée n'est stockée sur ce site.",
|
||||
'contact.elsewhere': 'Me trouver ailleurs',
|
||||
|
||||
// Footer
|
||||
'footer.role': 'Développeur full-stack · Limoges · Nouvelle-Aquitaine',
|
||||
'footer.madeWith': 'Construit avec Astro + Nova Design System',
|
||||
|
||||
// Voyage (home cinematic scroll)
|
||||
'voyage.hint': 'Scrollez - le voyage commence',
|
||||
'voyage.progress': 'Progression du voyage',
|
||||
'home.projects.intro.title': 'Quatre projets, menés comme des produits.',
|
||||
'home.projects.intro.body':
|
||||
'Design, code, tests, CI/CD, production : chaque projet va au bout. En route.',
|
||||
'home.about.summary':
|
||||
"BUT MMI (dév. web) à l'IUT du Limousin, et deux ans chez Legrand sur la même application métier - du stage à l'alternance.",
|
||||
|
||||
// Docs
|
||||
'docs.eyebrow': 'Documentation',
|
||||
'docs.title': 'Sous le capot.',
|
||||
'docs.lead':
|
||||
"Comment ce site anime le scroll, expliqué avec des démos qui tournent sous vos yeux et le vrai code du dépôt. Si vous lisez tout, vous savez le refaire.",
|
||||
'docs.toc': 'Sommaire',
|
||||
'docs.file': 'Fichier',
|
||||
'docs.why': 'Pourquoi ça marche',
|
||||
'docs.live': 'Démo - ça tourne en vrai',
|
||||
'docs.tryIt': 'Scrollez, regardez',
|
||||
'docs.trap': 'Piège',
|
||||
'docs.reading': 'Lecture ~20 min, molette obligatoire',
|
||||
|
||||
// Project scenes
|
||||
'scene.hint': 'Faites défiler - la scène est pilotée par le scroll',
|
||||
'scene.infra.eyebrow': 'Sous le capot',
|
||||
'scene.infra.title': 'Le trajet d’une requête.',
|
||||
'scene.blueprint.eyebrow': 'Plan de construction',
|
||||
'scene.blueprint.title': 'Du wireframe au produit.',
|
||||
'scene.sorter.eyebrow': 'Démonstration',
|
||||
'scene.sorter.title': 'Du chaos au rangé.',
|
||||
|
||||
// Misc
|
||||
'a11y.langSwitch': 'Changer de langue',
|
||||
'notfound.title': 'Page introuvable',
|
||||
'notfound.body': "Cette page n'existe pas (ou plus). Le reste du site, lui, fonctionne très bien.",
|
||||
'notfound.back': "Retour à l'accueil",
|
||||
} as const;
|
||||
|
||||
const en: Record<UIKey, string> = {
|
||||
'meta.home.title': 'Louis Potevin - Full-stack Developer (front, back, web)',
|
||||
'meta.home.description':
|
||||
'Louis Potevin, full-stack web developer: front-end, back-end, TypeScript, Astro, Node. Available for a permanent contract (CDI) from September 2026 and for freelance work, anywhere in France or remote.',
|
||||
'meta.work.title': 'Projects - Louis Potevin, full-stack developer',
|
||||
'meta.work.description':
|
||||
'A design system published on npm, self-hosted infrastructure, Rust tooling: the projects of Louis Potevin, full-stack web developer.',
|
||||
'meta.docs.title': 'Docs - How this portfolio is built, from A to Z',
|
||||
'meta.docs.description':
|
||||
"Complete technical documentation of Louis Potevin's portfolio: Astro, i18n, Lenis, Three.js, scroll-driven scenes, design system, SEO, deployment - with the real source code.",
|
||||
'meta.about.title': 'About - Louis Potevin, full-stack developer',
|
||||
'meta.about.description':
|
||||
"Louis Potevin's background: BUT MMI degree, apprenticeship at Legrand, personal projects. Full-stack TypeScript / Node developer, open to permanent and freelance roles.",
|
||||
'meta.contact.title': 'Contact - Louis Potevin, full-stack developer',
|
||||
'meta.contact.description':
|
||||
'Get in touch with Louis Potevin, full-stack web developer. Permanent contract from September 2026 or freelance work, anywhere in France, on-site or remote.',
|
||||
|
||||
'nav.home': 'Home',
|
||||
'nav.work': 'Projects',
|
||||
'nav.docs': 'Docs',
|
||||
'nav.about': 'About me',
|
||||
'nav.contact': 'Contact',
|
||||
'nav.cta': 'Get in touch',
|
||||
'nav.skip': 'Skip to content',
|
||||
'nav.lang': 'Language',
|
||||
|
||||
'avail.badge': 'Available',
|
||||
'avail.line': 'Permanent contract from September 2026 · Freelance now',
|
||||
'avail.where': 'Anywhere in France, on-site or remote',
|
||||
|
||||
'home.hero.title.role': 'Full-stack developer',
|
||||
'home.hero.lead':
|
||||
'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.',
|
||||
'home.hero.viewProjects': 'View projects',
|
||||
'home.hero.contact': 'Contact me',
|
||||
|
||||
'home.profile.eyebrow': 'Profile',
|
||||
'home.profile.title': 'A full-stack profile, comfortable across the entire stack.',
|
||||
'home.profile.body1':
|
||||
'I have hands-on experience with business applications in production (apprenticeship at Legrand) and personal projects shipped for real: a design system published on npm, self-hosted infrastructure, tools built with Rust and Python.',
|
||||
'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.',
|
||||
'home.profile.front.title': 'Front-end',
|
||||
'home.profile.front.body':
|
||||
'Polished, accessible interfaces with Astro, Vue.js, TypeScript and Sass - all the way to an in-house design system.',
|
||||
'home.profile.back.title': 'Back-end',
|
||||
'home.profile.back.body':
|
||||
'Server-side services and APIs, data modeling and business logic with Node and PostgreSQL.',
|
||||
'home.profile.ops.title': 'Tools & deployment',
|
||||
'home.profile.ops.body':
|
||||
'Docker containerization, automated CI/CD and end-to-end production deployment.',
|
||||
|
||||
'home.projects.eyebrow': 'Featured projects',
|
||||
'home.projects.title': 'Projects',
|
||||
'home.projects.all': 'All projects',
|
||||
'home.projects.view': 'View project',
|
||||
|
||||
'home.stack.eyebrow': 'Tech stack',
|
||||
'home.stack.title': 'My everyday tools.',
|
||||
|
||||
'home.cta.title': "Let's talk!",
|
||||
'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.',
|
||||
'home.cta.contact': 'Contact me',
|
||||
'home.cta.more': 'Learn more',
|
||||
|
||||
'work.title': 'Projects',
|
||||
'work.lead':
|
||||
'Every project is run like a product: design, code, tests, CI/CD and production. Click through to explore each one in detail.',
|
||||
'work.status.live': 'Live',
|
||||
'work.status.maintained': 'Maintained',
|
||||
'work.status.wip': 'In progress',
|
||||
'work.backToList': 'All projects',
|
||||
'work.stack': 'Stack',
|
||||
'work.links': 'Links',
|
||||
'work.highlights': 'Highlights',
|
||||
'work.next': 'Next project',
|
||||
|
||||
'nds.showcase.eyebrow': 'Interactive tour',
|
||||
'nds.showcase.title': 'The components, one by one.',
|
||||
'nds.showcase.hint': 'Scroll to browse the components',
|
||||
'nds.showcase.tokens': 'Tokens in use',
|
||||
|
||||
'about.title': 'About me',
|
||||
'about.lead':
|
||||
'Full-stack developer, trained through a BUT MMI degree (web development track) and battle-tested at Legrand - from internship to apprenticeship, on the same business application in production.',
|
||||
'about.exp.eyebrow': 'Experience',
|
||||
'about.exp.title': 'Background',
|
||||
'about.values.eyebrow': 'Method',
|
||||
'about.values.title': 'How I work',
|
||||
'about.cv': 'Download my resume',
|
||||
|
||||
'contact.title': 'Contact',
|
||||
'contact.lead':
|
||||
'A permanent role, a freelance mission or just a question: write to me, I reply fast.',
|
||||
'contact.email.label': 'E-mail',
|
||||
'contact.email.copy': 'Copy address',
|
||||
'contact.email.copied': 'Address copied!',
|
||||
'contact.email.write': 'Write an e-mail',
|
||||
'contact.form.name': 'Your name',
|
||||
'contact.form.email': 'Your e-mail',
|
||||
'contact.form.subject': 'Subject',
|
||||
'contact.form.subject.cdi': 'Permanent position (CDI)',
|
||||
'contact.form.subject.freelance': 'Freelance mission',
|
||||
'contact.form.subject.other': 'Other',
|
||||
'contact.form.message': 'Your message',
|
||||
'contact.form.send': 'Send message',
|
||||
'contact.form.note':
|
||||
'The button opens your mail client with the message pre-filled - no data is stored on this site.',
|
||||
'contact.elsewhere': 'Find me elsewhere',
|
||||
|
||||
'footer.role': 'Full-stack developer · Limoges · Nouvelle-Aquitaine, France',
|
||||
'footer.madeWith': 'Built with Astro + Nova Design System',
|
||||
|
||||
'voyage.hint': 'Scroll - the journey begins',
|
||||
'voyage.progress': 'Journey progress',
|
||||
'home.projects.intro.title': 'Four projects, run like products.',
|
||||
'home.projects.intro.body':
|
||||
'Design, code, tests, CI/CD, production: every project ships for real. Off we go.',
|
||||
'home.about.summary':
|
||||
'BUT MMI degree (web dev) at IUT du Limousin, and two years at Legrand on the same business application - from internship to apprenticeship.',
|
||||
|
||||
'docs.eyebrow': 'Documentation',
|
||||
'docs.title': 'Under the hood.',
|
||||
'docs.lead':
|
||||
'How this site animates scrolling, explained with demos running before your eyes and the real code from the repo. Read it all and you can rebuild it.',
|
||||
'docs.toc': 'Contents',
|
||||
'docs.file': 'File',
|
||||
'docs.why': 'Why it works',
|
||||
'docs.live': 'Demo - running for real',
|
||||
'docs.tryIt': 'Scroll and watch',
|
||||
'docs.trap': 'Gotcha',
|
||||
'docs.reading': '~20 min read, scroll wheel required',
|
||||
|
||||
'scene.hint': 'Keep scrolling - the scene is driven by scroll',
|
||||
'scene.infra.eyebrow': 'Under the hood',
|
||||
'scene.infra.title': 'The journey of a request.',
|
||||
'scene.blueprint.eyebrow': 'Blueprint',
|
||||
'scene.blueprint.title': 'From wireframe to product.',
|
||||
'scene.sorter.eyebrow': 'Live demo',
|
||||
'scene.sorter.title': 'From chaos to order.',
|
||||
|
||||
'a11y.langSwitch': 'Switch language',
|
||||
'notfound.title': 'Page not found',
|
||||
'notfound.body': "This page doesn't exist (anymore). The rest of the site works just fine.",
|
||||
'notfound.back': 'Back to home',
|
||||
};
|
||||
|
||||
export type UIKey = keyof typeof fr;
|
||||
export const ui = { fr, en } as const;
|
||||
@@ -6,122 +6,71 @@ import SpaceBackground from '../components/SpaceBackground.astro';
|
||||
import SiteNav from '../components/SiteNav.astro';
|
||||
import SiteFooter from '../components/SiteFooter.astro';
|
||||
import { site } from '../data/site';
|
||||
import { personLd, webSiteLd } from '../lib/seo';
|
||||
import {
|
||||
getLocaleFromUrl,
|
||||
getAlternates,
|
||||
useTranslations,
|
||||
HTML_LANGS,
|
||||
OG_LOCALES,
|
||||
LOCALES,
|
||||
} from '../i18n';
|
||||
|
||||
export interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** Page-specific keywords (appended to the global keywords list). */
|
||||
keywords?: string[];
|
||||
title: string;
|
||||
description: string;
|
||||
/** Extra JSON-LD blocks for this page (breadcrumbs, project, profile...). */
|
||||
jsonLd?: object[];
|
||||
/** og:type - "website" for index pages, "article" for project pages. */
|
||||
ogType?: 'website' | 'article';
|
||||
/** Ambient space background canvas (off on pages that mount their own scene). */
|
||||
background?: boolean;
|
||||
/** Full-bleed main (no max-width shell) for cinematic pages. */
|
||||
fullBleed?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
title = 'Louis Potevin - Full-stack Developer (front, back, web)',
|
||||
description = "Louis Potevin, full-stack web developer: front-end, back-end, TypeScript, Astro, Node. Available for a permanent contract (CDI) starting September 2026, anywhere in France or remote.",
|
||||
keywords = [],
|
||||
} = Astro.props;
|
||||
const SITE_URL = 'https://louis-potevin.dev';
|
||||
const { title, description, jsonLd = [], ogType = 'website', background = true, fullBleed = false } = Astro.props;
|
||||
|
||||
const baseKeywords = [
|
||||
// French Keywords
|
||||
'Louis Potevin',
|
||||
'développeur web',
|
||||
'developpeur web',
|
||||
'dev web',
|
||||
'développeur full-stack',
|
||||
'developpeur full stack',
|
||||
'dev front',
|
||||
'développeur front-end',
|
||||
'dev back',
|
||||
'développeur back-end',
|
||||
'développeur TypeScript',
|
||||
'développeur Astro',
|
||||
'développeur JavaScript',
|
||||
'développeur Node.js',
|
||||
'développeur freelance France',
|
||||
'portfolio développeur',
|
||||
'web developer',
|
||||
'full-stack developer',
|
||||
'full stack developer',
|
||||
'front-end developer',
|
||||
'front end dev',
|
||||
'back-end developer',
|
||||
'back end dev',
|
||||
'TypeScript developer',
|
||||
'Astro developer',
|
||||
'JavaScript developer',
|
||||
'Node.js developer',
|
||||
'software engineer France',
|
||||
'developer portfolio',
|
||||
];
|
||||
const allKeywords = [...baseKeywords, ...keywords].join(', ');
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site ?? SITE_URL).href;
|
||||
const ogImage = new URL(`/og-${locale}.png`, Astro.site ?? SITE_URL).href;
|
||||
const alternates = getAlternates(Astro.url, (Astro.site ?? new URL(SITE_URL)).href);
|
||||
const otherLocales = LOCALES.filter((l) => l !== locale);
|
||||
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href;
|
||||
const ogImage = new URL('/og.png', Astro.site ?? 'https://louis-potevin.dev').href;
|
||||
|
||||
// Structured data: Person profile (helps Google link your name ↔ profession ↔ social links).
|
||||
const personLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: site.name,
|
||||
jobTitle: 'Full-stack Developer / Développeur full-stack',
|
||||
description,
|
||||
url: canonical,
|
||||
email: `mailto:${site.email}`,
|
||||
address: { '@type': 'PostalAddress', addressCountry: 'FR', addressLocality: 'Limoges' },
|
||||
knowsAbout: [
|
||||
'Web development',
|
||||
'Développement web',
|
||||
'Front-end',
|
||||
'Back-end',
|
||||
'TypeScript',
|
||||
'JavaScript',
|
||||
'Astro',
|
||||
'Node.js',
|
||||
'Sass',
|
||||
'Docker',
|
||||
'CI/CD',
|
||||
],
|
||||
sameAs: [
|
||||
site.links.github.url,
|
||||
site.links.gitea.url,
|
||||
site.links.npm.url,
|
||||
site.links.linkedin.url,
|
||||
],
|
||||
};
|
||||
|
||||
const siteLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: `${site.name} - Portfolio`,
|
||||
url: Astro.site?.href ?? 'https://louis-potevin.dev',
|
||||
inLanguage: ['en-US', 'fr-FR'],
|
||||
};
|
||||
const ldBlocks = [personLd(locale), webSiteLd(), ...jsonLd];
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<html lang={HTML_LANGS[locale]} data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="keywords" content={allKeywords} />
|
||||
<meta name="author" content={site.name} />
|
||||
<meta name="robots" content="index, follow, max-image-preview:large" />
|
||||
<link rel="canonical" href={canonical} />
|
||||
{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:site_name" content={`${site.name} - Portfolio`} />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:locale:alternate" content="fr_FR" />
|
||||
<meta property="og:locale" content={OG_LOCALES[locale]} />
|
||||
{otherLocales.map((l) => <meta property="og:locale:alternate" content={OG_LOCALES[l]} />)}
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content={`${site.name} - ${t('home.hero.title.role')}`} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
@@ -130,23 +79,29 @@ const siteLd = {
|
||||
|
||||
<meta name="theme-color" content="#040B0B" />
|
||||
|
||||
<script type="application/ld+json" set:html={JSON.stringify(personLd)} />
|
||||
<script type="application/ld+json" set:html={JSON.stringify(siteLd)} />
|
||||
{ldBlocks.map((block) => <script is:inline type="application/ld+json" set:html={JSON.stringify(block)} />)}
|
||||
</head>
|
||||
<body>
|
||||
<SpaceBackground />
|
||||
<a class="skip" href="#content">Skip to content</a>
|
||||
{background && <SpaceBackground />}
|
||||
<a class="skip" href="#content">{t('nav.skip')}</a>
|
||||
<SiteNav />
|
||||
<main id="content" class="shell">
|
||||
<main id="content" class:list={[{ shell: !fullBleed }]}>
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
|
||||
<script>
|
||||
// Smooth scroll everywhere - every animated surface reads from Lenis,
|
||||
// so all motion sits on one easing curve.
|
||||
import { ensureSmooth } from '../lib/smooth';
|
||||
ensureSmooth();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 1120px;
|
||||
max-width: 1200px;
|
||||
margin-inline: auto;
|
||||
padding-inline: clamp(20px, 5vw, 56px);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Scroll-scrub helper for pinned scenes.
|
||||
*
|
||||
* A scene = a tall section with a sticky 100vh stage inside. This maps the
|
||||
* section's traversal to a 0..1 progress each frame (cheap: only while the
|
||||
* section is near the viewport). Works natively with Lenis since Lenis drives
|
||||
* real window scrolling.
|
||||
*/
|
||||
export function scrub(section: HTMLElement, cb: (p: number) => void): () => void {
|
||||
let raf = 0;
|
||||
let lastP = -1;
|
||||
const loop = () => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
const vh = window.innerHeight;
|
||||
if (rect.bottom > -vh && rect.top < vh * 2) {
|
||||
const total = rect.height - vh;
|
||||
const p = total > 0 ? Math.min(1, Math.max(0, -rect.top / total)) : 1;
|
||||
if (p !== lastP) {
|
||||
lastP = p;
|
||||
cb(p);
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
|
||||
export const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
|
||||
/** Progress remapped to a [a,b] window with smoothstep easing. */
|
||||
export const window01 = (p: number, a: number, b: number) => {
|
||||
const t = clamp01((p - a) / (b - a));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* JSON-LD builders. Each returns a plain object serialized by the layout.
|
||||
* Kept framework-agnostic so it can be unit-tested without Astro.
|
||||
*/
|
||||
import { site } from '../data/site';
|
||||
import type { Project } from '../data/projects';
|
||||
import type { Locale } from '../i18n';
|
||||
|
||||
const SITE_URL = 'https://louis-potevin.dev';
|
||||
|
||||
const PERSON_ID = `${SITE_URL}/#person`;
|
||||
|
||||
export function personLd(locale: Locale) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
'@id': PERSON_ID,
|
||||
name: site.name,
|
||||
jobTitle: locale === 'fr' ? 'Développeur full-stack' : 'Full-stack Developer',
|
||||
url: SITE_URL,
|
||||
email: `mailto:${site.email}`,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: site.location.city,
|
||||
addressRegion: site.location.region,
|
||||
addressCountry: site.location.country,
|
||||
},
|
||||
knowsAbout: [
|
||||
'Web development',
|
||||
'TypeScript',
|
||||
'JavaScript',
|
||||
'Astro',
|
||||
'Vue.js',
|
||||
'Node.js',
|
||||
'Sass',
|
||||
'PostgreSQL',
|
||||
'Docker',
|
||||
'CI/CD',
|
||||
'Rust',
|
||||
],
|
||||
knowsLanguage: ['fr', 'en'],
|
||||
sameAs: Object.values(site.links).map((l) => l.url),
|
||||
};
|
||||
}
|
||||
|
||||
export function webSiteLd() {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
'@id': `${SITE_URL}/#website`,
|
||||
name: `${site.name} - Portfolio`,
|
||||
url: SITE_URL,
|
||||
inLanguage: ['fr-FR', 'en-US'],
|
||||
publisher: { '@id': PERSON_ID },
|
||||
};
|
||||
}
|
||||
|
||||
export interface Crumb {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function breadcrumbLd(crumbs: Crumb[]) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: crumbs.map((c, i) => ({
|
||||
'@type': 'ListItem',
|
||||
position: i + 1,
|
||||
name: c.name,
|
||||
item: new URL(c.url, SITE_URL).href,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function projectLd(project: Project, locale: Locale, pageUrl: string) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareSourceCode',
|
||||
name: project.name,
|
||||
description: project.summary[locale],
|
||||
url: pageUrl,
|
||||
codeRepository: project.links[0]?.url,
|
||||
programmingLanguage: project.stack.map((s) => s.name),
|
||||
author: { '@id': PERSON_ID },
|
||||
inLanguage: locale === 'fr' ? 'fr-FR' : 'en-US',
|
||||
};
|
||||
}
|
||||
|
||||
export function profilePageLd(pageUrl: string) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ProfilePage',
|
||||
url: pageUrl,
|
||||
mainEntity: { '@id': PERSON_ID },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Smooth scroll (Lenis), shared as a singleton.
|
||||
*
|
||||
* Everything animated on the site reads scroll from Lenis so motion stays
|
||||
* on a single easing curve - that is what makes scrolling feel like one
|
||||
* continuous animation instead of a page moving.
|
||||
*/
|
||||
import Lenis from 'lenis';
|
||||
|
||||
let lenis: Lenis | null = null;
|
||||
let rafId = 0;
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/** Idempotent. Returns null when the user prefers reduced motion. */
|
||||
export function ensureSmooth(): Lenis | null {
|
||||
if (prefersReducedMotion()) return null;
|
||||
if (lenis) return lenis;
|
||||
|
||||
lenis = new Lenis({
|
||||
lerp: 0.09,
|
||||
wheelMultiplier: 0.95,
|
||||
// Touch keeps native scrolling; the scenes still read positions each frame.
|
||||
});
|
||||
|
||||
const raf = (time: number) => {
|
||||
lenis!.raf(time);
|
||||
rafId = requestAnimationFrame(raf);
|
||||
};
|
||||
rafId = requestAnimationFrame(raf);
|
||||
|
||||
return lenis;
|
||||
}
|
||||
|
||||
export function getSmooth(): Lenis | null {
|
||||
return lenis;
|
||||
}
|
||||
|
||||
export function destroySmooth(): void {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
lenis?.destroy();
|
||||
lenis = null;
|
||||
rafId = 0;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
---
|
||||
|
||||
<BaseLayout title={t('notfound.title')} description={t('notfound.body')}>
|
||||
<section class="notfound">
|
||||
<p class="code" aria-hidden="true">404</p>
|
||||
<h1>{t('notfound.title')}</h1>
|
||||
<p class="body">{t('notfound.body')}</p>
|
||||
<Button type="primary" href={localizePath('/', locale)}>{t('notfound.back')}</Button>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.notfound {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: var(--nds-spacing-md);
|
||||
padding-block: var(--nds-spacing-3xl);
|
||||
}
|
||||
|
||||
.code {
|
||||
@include type.text-5xl;
|
||||
margin: 0;
|
||||
color: var(--nds-accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@include type.text-3xl;
|
||||
margin: 0;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
|
||||
.body {
|
||||
@include type.text-base;
|
||||
margin: 0;
|
||||
max-width: 34rem;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
</style>
|
||||
+2
-312
@@ -1,315 +1,5 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import TechIcon from '../components/TechIcon.astro';
|
||||
import BrandLinkedin from '../components/BrandLinkedin.astro';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
Badge,
|
||||
Button,
|
||||
Link,
|
||||
ListItem,
|
||||
ListItemTitle,
|
||||
ListItemSubtitle,
|
||||
} from '@unkn0wndo3s/nova-design-system';
|
||||
import { Send, Mail } from '@lucide/astro';
|
||||
import { site } from '../data/site';
|
||||
|
||||
const title = 'About - Louis Potevin, full-stack web developer / développeur full-stack';
|
||||
const description =
|
||||
"Louis Potevin, full-stack web developer with a BUT MMI degree (DWDI track). 18 months of professional experience at Legrand France. Available for a permanent contract (CDI), anywhere in France or remote.";
|
||||
|
||||
const parcours = [
|
||||
{
|
||||
year: '2024 - 2026',
|
||||
title: 'Professional Experience · Legrand France',
|
||||
body: "18 months total: 12 weeks of internship, 1 month on a fixed-term contract (CDD), followed by 1 year of work-study apprenticeship. Web development on a production-ready internal corporate application.",
|
||||
},
|
||||
{
|
||||
year: '2024 - 2026',
|
||||
title: 'BUT MMI - DWDI Track · IUT du Limousin',
|
||||
body: "Multimedia and Internet Job Degrees (Métiers du Multimédia et de l'Internet), specializing in Web Development and Interactive Devices (DWDI). Degree completed in 2026.",
|
||||
},
|
||||
{
|
||||
year: '2026 - ongoing',
|
||||
title: 'Personal Projects',
|
||||
body: "Designing an open-source design system published on npm, managing a self-hosted server infrastructure, and building system tools with Rust and Python.",
|
||||
},
|
||||
];
|
||||
|
||||
const stack = [
|
||||
{ icon: 'astro', label: 'Astro' },
|
||||
{ icon: 'typescript', label: 'TypeScript' },
|
||||
{ icon: 'javascript', label: 'JavaScript' },
|
||||
{ icon: 'sass', label: 'Sass' },
|
||||
{ icon: 'postgresql', label: 'PostgreSQL' },
|
||||
{ icon: 'docker', label: 'Docker' },
|
||||
{ icon: 'linux', label: 'Linux' },
|
||||
{ icon: 'cloudflare', label: 'Cloudflare' },
|
||||
{ icon: 'gitea', label: 'Gitea' },
|
||||
{ icon: 'rust', label: 'Rust' },
|
||||
{ icon: 'python', label: 'Python' },
|
||||
{ icon: 'git', label: 'Git' },
|
||||
];
|
||||
import AboutView from '../views/AboutView.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<section class="head">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem href="/">Home</BreadcrumbItem>
|
||||
<BreadcrumbItem href="/about" current>About</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<Eyebrow index="//" label="About" />
|
||||
<h1 class="head__title">About me</h1>
|
||||
<p class="head__lede">
|
||||
Full-stack developer based in {site.location}. I design complete web applications,
|
||||
from front-end interfaces to back-end architecture, and manage their deployment into production.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="layout">
|
||||
<div class="layout__main">
|
||||
<section class="block">
|
||||
<Eyebrow index="01" label="Overview" />
|
||||
<p class="prose">
|
||||
I graduated with a BUT MMI degree, specializing in Web Development and Interactive
|
||||
Devices (DWDI) at the IUT du Limousin. I solidified my practical engineering skills
|
||||
in the industry: spending 18 months at Legrand France split between a 12-week internship,
|
||||
a 1-month contract, and a full year as a software apprentice, working on a business-critical
|
||||
production application.
|
||||
</p>
|
||||
<p class="prose">
|
||||
Joining Legrand as an apprentice without knowing anyone prior, I quickly integrated
|
||||
into the team and built strong working relationships across departments. I am equally
|
||||
comfortable collaborating in agile team structures as I am working independently, and I excel
|
||||
at adapting swiftly to new technical environments.
|
||||
</p>
|
||||
<p class="prose">
|
||||
Outside of my professional employment, I build and maintain my own digital infrastructure:
|
||||
a modular design system published on npm, a private self-hosted suite of services, and custom
|
||||
automation tools written in Rust and Python. This is my preferred way of keeping up with industry
|
||||
standards and thoroughly exploring engineering topics end-to-end.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<Eyebrow index="02" label="Timeline" />
|
||||
<ol class="timeline">
|
||||
{
|
||||
parcours.map((p) => (
|
||||
<li class="timeline__item">
|
||||
<span class="timeline__year">{p.year}</span>
|
||||
<div class="timeline__card">
|
||||
<ListItem>
|
||||
<ListItemTitle>{p.title}</ListItemTitle>
|
||||
<ListItemSubtitle>{p.body}</ListItemSubtitle>
|
||||
</ListItem>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<Eyebrow index="03" label="Toolkit" />
|
||||
<ul class="chips">
|
||||
{
|
||||
stack.map((s) => (
|
||||
<li class="chip">
|
||||
<TechIcon name={s.icon} size={15} />
|
||||
{s.label}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="layout__side">
|
||||
<div class="panel panel--accent">
|
||||
<p class="panel__title">Status</p>
|
||||
<p class="panel__big">{site.availability}</p>
|
||||
<div class="panel__badge">
|
||||
<Badge type="success" variant="soft">Available</Badge>
|
||||
</div>
|
||||
<p class="panel__mobility">{site.mobility}.</p>
|
||||
<Button type="primary" href="/contact">
|
||||
<Send slot="icon-left" size={15} />
|
||||
Get in touch
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<p class="panel__title">Profiles</p>
|
||||
<ul class="links">
|
||||
<li>
|
||||
<Link url={site.links.github.url} blank>
|
||||
<TechIcon name="github" size={15} /> GitHub
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link url={site.links.gitea.url} blank>
|
||||
<TechIcon name="gitea" size={15} /> Gitea
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link url={site.links.npm.url} blank>
|
||||
<TechIcon name="npm" size={15} /> npm
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link url={site.links.linkedin.url} blank>
|
||||
<BrandLinkedin size={15} /> LinkedIn
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link url={`mailto:${site.email}`}>
|
||||
<Mail size={15} /> Email
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'styles/type' as *;
|
||||
|
||||
.head {
|
||||
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-xl);
|
||||
max-width: 48rem;
|
||||
}
|
||||
.head__title {
|
||||
@include text-4xl;
|
||||
color: var(--nds-text);
|
||||
margin: var(--nds-spacing-sm) 0 var(--nds-spacing-md);
|
||||
}
|
||||
.head__lede {
|
||||
@include text-lg;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.7fr 1fr;
|
||||
gap: var(--nds-spacing-2xl);
|
||||
align-items: start;
|
||||
padding-bottom: var(--nds-spacing-3xl);
|
||||
}
|
||||
.block { margin-bottom: var(--nds-spacing-2xl); }
|
||||
.prose {
|
||||
@include text-base;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0 0 var(--nds-spacing-md);
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-md);
|
||||
}
|
||||
.timeline__item {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: var(--nds-spacing-md);
|
||||
align-items: start;
|
||||
}
|
||||
.timeline__year {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--nds-accent);
|
||||
padding-top: var(--nds-spacing-xs);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.timeline__card :global(.list-item) {
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
background: color-mix(in srgb, var(--nds-surface) 64%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: var(--nds-spacing-md);
|
||||
}
|
||||
|
||||
.chips {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
padding: 4px var(--nds-spacing-xs);
|
||||
border-radius: var(--nds-radius-full);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
background: var(--nds-surface-2);
|
||||
color: var(--nds-neutral);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
/* Side panels */
|
||||
.layout__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-md);
|
||||
position: sticky;
|
||||
top: calc(64px + var(--nds-spacing-md));
|
||||
}
|
||||
.panel {
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
background: color-mix(in srgb, var(--nds-surface) 70%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: var(--nds-spacing-lg);
|
||||
&--accent {
|
||||
border-color: color-mix(in srgb, var(--nds-primary) 38%, var(--nds-border));
|
||||
}
|
||||
}
|
||||
.panel__title {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0 0 var(--nds-spacing-sm);
|
||||
}
|
||||
.panel__big {
|
||||
@include text-lg;
|
||||
color: var(--nds-text);
|
||||
margin: 0 0 var(--nds-spacing-sm);
|
||||
}
|
||||
.panel__badge { margin-bottom: var(--nds-spacing-sm); }
|
||||
.panel__mobility {
|
||||
@include text-sm;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0 0 var(--nds-spacing-lg);
|
||||
}
|
||||
.links {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.layout { grid-template-columns: 1fr; gap: var(--nds-spacing-xl); }
|
||||
.layout__side { position: static; }
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.timeline__item { grid-template-columns: 1fr; gap: var(--nds-spacing-2xs); }
|
||||
}
|
||||
</style>
|
||||
<AboutView />
|
||||
|
||||
+2
-294
@@ -1,297 +1,5 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import TechIcon from '../components/TechIcon.astro';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
TextField,
|
||||
Select,
|
||||
SelectOption,
|
||||
Button,
|
||||
Badge,
|
||||
} from '@unkn0wndo3s/nova-design-system';
|
||||
import { Send, Mail } from '@lucide/astro';
|
||||
import BrandLinkedin from '../components/BrandLinkedin.astro';
|
||||
import { site } from '../data/site';
|
||||
|
||||
const title = 'Contact - Louis Potevin';
|
||||
const description =
|
||||
'Contact Louis Potevin, full-stack web developer. Available for permanent positions (CDI) starting September 2026, anywhere in France or remote.';
|
||||
|
||||
const directLinks = [
|
||||
{ kind: 'mail', label: site.email, url: `mailto:${site.email}` },
|
||||
{ kind: 'linkedin', label: 'LinkedIn', url: site.links.linkedin.url },
|
||||
{ kind: 'gitea', label: 'Gitea', url: site.links.gitea.url },
|
||||
{ kind: 'github', label: 'GitHub', url: site.links.github.url },
|
||||
];
|
||||
import ContactView from '../views/ContactView.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<section class="head">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem href="/">Home</BreadcrumbItem>
|
||||
<BreadcrumbItem href="/contact" current>Contact</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<Eyebrow index="//" label="Connect" />
|
||||
<h1 class="head__title">Get in touch</h1>
|
||||
<p class="head__lede">
|
||||
An opening, a project, or just a question? Email is the most direct
|
||||
channel, but the form works just as well.
|
||||
</p>
|
||||
<div class="head__avail">
|
||||
<Badge type="success" variant="soft">Available</Badge>
|
||||
<span>{site.availability} · {site.mobility}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="layout">
|
||||
{/* Form (Nova components) */}
|
||||
<section class="formWrap" aria-labelledby="form-title">
|
||||
<h2 id="form-title" class="formWrap__title">Message</h2>
|
||||
|
||||
<div class="form" id="contact-form">
|
||||
<TextField id="cf-name" label="Name" type="text" placeholder="Your name" />
|
||||
<TextField
|
||||
id="cf-email"
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
|
||||
<div class="form__field">
|
||||
<label class="form__label" for="cf-subject">Subject</label>
|
||||
<Select default="Job Opportunity (CDI)">
|
||||
<SelectOption value="Job Opportunity (CDI)">
|
||||
Job Opportunity (CDI)
|
||||
</SelectOption>
|
||||
<SelectOption value="Freelance / Contract">
|
||||
Freelance / Contract
|
||||
</SelectOption>
|
||||
<SelectOption value="Project Inquiry">
|
||||
Project Inquiry
|
||||
</SelectOption>
|
||||
<SelectOption value="Other">Other</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
id="cf-message"
|
||||
label="Your message"
|
||||
type="textarea"
|
||||
placeholder="A few lines about your needs…"
|
||||
max={800}
|
||||
/>
|
||||
|
||||
<div class="form__actions">
|
||||
<Button type="primary" size="lg" htmlType="button" id="cf-send">
|
||||
<Send slot="icon-left" size={16} />
|
||||
Send message
|
||||
</Button>
|
||||
<p class="form__hint" id="cf-hint">
|
||||
Opens your mail client with a pre-filled draft.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Direct Links */}
|
||||
<aside class="direct" aria-label="Direct channels">
|
||||
<p class="direct__title">Direct Channels</p>
|
||||
<ul class="direct__list">
|
||||
{
|
||||
directLinks.map((l) => (
|
||||
<li>
|
||||
<a
|
||||
class="direct__item"
|
||||
href={l.url}
|
||||
{...(l.kind !== 'mail'
|
||||
? { target: '_blank', rel: 'noopener noreferrer' }
|
||||
: {})}
|
||||
>
|
||||
<span class="direct__icon">
|
||||
{l.kind === 'mail' && <Mail size={17} />}
|
||||
{l.kind === 'linkedin' && <BrandLinkedin size={17} />}
|
||||
{l.kind === 'gitea' && <TechIcon name="gitea" size={17} />}
|
||||
{l.kind === 'github' && <TechIcon name="github" size={17} />}
|
||||
</span>
|
||||
<span class="direct__label">{l.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="direct__loc">
|
||||
<p class="direct__locTitle">Location</p>
|
||||
<p class="direct__locValue">{site.location}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script define:vars={{ email: site.email }}>
|
||||
const send = document.getElementById('cf-send');
|
||||
send?.addEventListener('click', () => {
|
||||
const val = (id) => document.querySelector(`#${id} input, #${id} textarea`)?.value ?? '';
|
||||
const name = val('cf-name');
|
||||
const from = val('cf-email');
|
||||
const subjectEl = document.querySelector('.select__label');
|
||||
const subject = subjectEl?.textContent?.trim() || 'Inquiry';
|
||||
const message = val('cf-message');
|
||||
|
||||
const body = `${message}\n\n- ${name}${from ? ` (${from})` : ''}`;
|
||||
const href = `mailto:${email}?subject=${encodeURIComponent(
|
||||
'[Portfolio] ' + subject,
|
||||
)}&body=${encodeURIComponent(body)}`;
|
||||
window.location.href = href;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'styles/type' as *;
|
||||
|
||||
.head {
|
||||
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-xl);
|
||||
max-width: 48rem;
|
||||
}
|
||||
.head__title {
|
||||
@include text-4xl;
|
||||
color: var(--nds-text);
|
||||
margin: var(--nds-spacing-sm) 0 var(--nds-spacing-md);
|
||||
}
|
||||
.head__lede {
|
||||
@include text-lg;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0 0 var(--nds-spacing-md);
|
||||
}
|
||||
.head__avail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--nds-neutral);
|
||||
font-family: var(--nds-font-mono);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: var(--nds-spacing-2xl);
|
||||
align-items: start;
|
||||
padding-bottom: var(--nds-spacing-3xl);
|
||||
}
|
||||
|
||||
.formWrap {
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-xl);
|
||||
background: color-mix(in srgb, var(--nds-surface) 72%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: var(--nds-spacing-xl);
|
||||
}
|
||||
.formWrap__title {
|
||||
@include text-xl;
|
||||
color: var(--nds-text);
|
||||
margin: 0 0 var(--nds-spacing-lg);
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-md);
|
||||
}
|
||||
.form__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
}
|
||||
.form__label {
|
||||
@include text-label;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
.form__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-md);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--nds-spacing-2xs);
|
||||
}
|
||||
.form__hint {
|
||||
@include text-sm;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.direct {
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-xl);
|
||||
background: color-mix(in srgb, var(--nds-surface) 60%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: var(--nds-spacing-lg);
|
||||
position: sticky;
|
||||
top: calc(64px + var(--nds-spacing-md));
|
||||
}
|
||||
.direct__title {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0 0 var(--nds-spacing-sm);
|
||||
}
|
||||
.direct__list {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--nds-spacing-lg);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
}
|
||||
.direct__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
padding: var(--nds-spacing-sm);
|
||||
border-radius: var(--nds-radius-md);
|
||||
border: var(--nds-border-width-thin) solid transparent;
|
||||
color: var(--nds-neutral);
|
||||
text-decoration: none;
|
||||
transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
|
||||
&:hover {
|
||||
color: var(--nds-text);
|
||||
border-color: color-mix(in srgb, var(--nds-primary) 40%, var(--nds-border));
|
||||
background: var(--nds-surface-2);
|
||||
}
|
||||
}
|
||||
.direct__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--nds-primary);
|
||||
}
|
||||
.direct__label {
|
||||
@include text-sm;
|
||||
font-family: var(--nds-font-mono);
|
||||
}
|
||||
.direct__loc {
|
||||
padding-top: var(--nds-spacing-md);
|
||||
border-top: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
}
|
||||
.direct__locTitle {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0 0 var(--nds-spacing-2xs);
|
||||
}
|
||||
.direct__locValue {
|
||||
@include text-base;
|
||||
color: var(--nds-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 820px) {
|
||||
.layout { grid-template-columns: 1fr; gap: var(--nds-spacing-xl); }
|
||||
.direct { position: static; }
|
||||
}
|
||||
</style>
|
||||
<ContactView />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import DocsView from '../views/DocsView.astro';
|
||||
---
|
||||
|
||||
<DocsView />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import AboutView from '../../views/AboutView.astro';
|
||||
---
|
||||
|
||||
<AboutView />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import ContactView from '../../views/ContactView.astro';
|
||||
---
|
||||
|
||||
<ContactView />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import DocsView from '../../views/DocsView.astro';
|
||||
---
|
||||
|
||||
<DocsView />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import HomeView from '../../views/HomeView.astro';
|
||||
---
|
||||
|
||||
<HomeView />
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import ProjectView from '../../../views/ProjectView.astro';
|
||||
import { projects } from '../../../data/projects';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return projects.map((project) => ({ params: { slug: project.slug }, props: { project } }));
|
||||
}
|
||||
|
||||
const { project } = Astro.props;
|
||||
---
|
||||
|
||||
<ProjectView project={project} />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import WorkIndexView from '../../../views/WorkIndexView.astro';
|
||||
---
|
||||
|
||||
<WorkIndexView />
|
||||
+2
-418
@@ -1,421 +1,5 @@
|
||||
---
|
||||
import { Button, Badge } from '@unkn0wndo3s/nova-design-system';
|
||||
import { ArrowUpRight, Send } from '@lucide/astro';
|
||||
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import ProjectCard from '../components/ProjectCard.astro';
|
||||
import TechIcon from '../components/TechIcon.astro';
|
||||
|
||||
import { site } from '../data/site';
|
||||
import { featuredProjects } from '../data/projects';
|
||||
|
||||
const capabilities = [
|
||||
{
|
||||
label: 'Front-end',
|
||||
body: 'Sleek and accessible interfaces built with Astro, TypeScript, and Sass - all the way to an in-house design system.',
|
||||
},
|
||||
{
|
||||
label: 'Back-end',
|
||||
body: 'Server-side services and APIs, data modeling, and business logic using Node and PostgreSQL.',
|
||||
},
|
||||
{
|
||||
label: 'Tools & deployment',
|
||||
body: 'Docker containerization, automated CI/CD, and end-to-end production deployment.',
|
||||
},
|
||||
];
|
||||
|
||||
const instruments = [
|
||||
{ icon: 'astro', label: 'Astro' },
|
||||
{ icon: 'vue', label: 'Vue.js'},
|
||||
{ icon: 'typescript', label: 'TypeScript' },
|
||||
{ icon: 'javascript', label: 'JavaScript' },
|
||||
{ icon: 'sass', label: 'Sass' },
|
||||
{ icon: 'postgresql', label: 'PostgreSQL' },
|
||||
{ icon: 'docker', label: 'Docker' },
|
||||
{ icon: 'linux', label: 'Linux' },
|
||||
{ icon: 'cloudflare', label: 'Cloudflare' },
|
||||
{ icon: 'gitea', label: 'Gitea CI/CD' },
|
||||
{ icon: 'rust', label: 'Rust' },
|
||||
{ icon: 'python', label: 'Python' },
|
||||
{ icon: 'git', label: 'Git' },
|
||||
{ icon: 'nodedotjs', label: 'Node.js' },
|
||||
{ icon: 'figma', label: 'Figma' },
|
||||
];
|
||||
import HomeView from '../views/HomeView.astro';
|
||||
---
|
||||
|
||||
<BaseLayout>
|
||||
{/* HERO */}
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<div class="hero__status">
|
||||
<span class="hero__dot" aria-hidden="true"></span>
|
||||
<span class="hero__statusText">Available · {site.availability}</span>
|
||||
</div>
|
||||
|
||||
<h1 id="hero-title" class="hero__title">
|
||||
Louis Potevin
|
||||
<span class="hero__role">Full-stack developer</span>
|
||||
</h1>
|
||||
|
||||
<p class="hero__lede">
|
||||
I design and develop web applications from end to end - from front-end
|
||||
to back-end and production deployment. I am equally comfortable working
|
||||
on the user interface as I am with the services and infrastructure running them.
|
||||
</p>
|
||||
|
||||
<div class="hero__cta">
|
||||
<Button type="primary" size="lg" href="/work">
|
||||
View projects
|
||||
<ArrowUpRight slot="icon-right" size={18} />
|
||||
</Button>
|
||||
<Button type="ghost" size="lg" href="/contact">
|
||||
<Send slot="icon-left" size={16} />
|
||||
Contact me
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="hero__avail">
|
||||
<Badge type="success" variant="soft">Available</Badge>
|
||||
<span class="hero__availText">{site.availability}</span>
|
||||
<span class="hero__sep" aria-hidden="true">·</span>
|
||||
<span class="hero__zones">{site.mobility}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* PROFILE / CAPABILITIES */}
|
||||
<section class="band" aria-labelledby="profile-title">
|
||||
<Eyebrow index="01" label="Profile" />
|
||||
<div class="band__split">
|
||||
<h2 id="profile-title" class="band__lead">
|
||||
A full-stack profile, comfortable across the entire stack.
|
||||
</h2>
|
||||
<div class="band__body">
|
||||
<p>
|
||||
I have hands-on experience running business applications in production while
|
||||
simultaneously developing my own side projects: a design system published
|
||||
on npm, a self-hosted infrastructure, and tools built with Rust and Python.
|
||||
</p>
|
||||
<p>
|
||||
What drives me: mastering the entire cycle - the component you look at,
|
||||
the service that delivers it, and the CI that deploys it. This website is
|
||||
a prime example, built entirely on top of my own design system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="caps" aria-label="Expertise">
|
||||
{
|
||||
capabilities.map((c) => (
|
||||
<li class="cap">
|
||||
<span class="cap__label">{c.label}</span>
|
||||
<p class="cap__body">{c.body}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* FEATURED PROJECTS */}
|
||||
<section class="band" aria-labelledby="proj-title">
|
||||
<Eyebrow index="02" label="Featured projects" />
|
||||
<div class="band__head">
|
||||
<h2 id="proj-title" class="band__lead">Projects</h2>
|
||||
<a class="band__all" href="/work">
|
||||
All projects <ArrowUpRight size={15} />
|
||||
</a>
|
||||
</div>
|
||||
<div class="grid">
|
||||
{featuredProjects.map((p) => <ProjectCard project={p} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* STACK */}
|
||||
<section class="band" aria-labelledby="stack-title">
|
||||
<Eyebrow index="03" label="Tech stack" />
|
||||
<h2 id="stack-title" class="band__lead band__lead--center">
|
||||
My everyday tools.
|
||||
</h2>
|
||||
<ul class="instruments" aria-label="Core technologies">
|
||||
{
|
||||
instruments.map((t) => (
|
||||
<li class="instruments__item">
|
||||
<span class="instruments__icon">
|
||||
<TechIcon name={t.icon} size={22} />
|
||||
</span>
|
||||
<span class="instruments__label">{t.label}</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* CONTACT CTA */}
|
||||
<section class="closing" aria-labelledby="cta-title">
|
||||
<p class="closing__eyebrow">Available - {site.availability}</p>
|
||||
<h2 id="cta-title" class="closing__title">Let's talk!</h2>
|
||||
<p class="closing__text">
|
||||
I am looking for a full-stack permanent position (CDI) starting September 2026.
|
||||
{' '}{site.mobility}.
|
||||
</p>
|
||||
<div class="closing__cta">
|
||||
<Button type="primary" size="lg" href="/contact">
|
||||
<Send slot="icon-left" size={16} />
|
||||
Contact me
|
||||
</Button>
|
||||
<Button type="ghost" size="lg" href="/about">
|
||||
Learn more
|
||||
<ArrowUpRight slot="icon-right" size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'styles/type' as *;
|
||||
|
||||
/* HERO */
|
||||
.hero {
|
||||
min-height: clamp(520px, 80vh, 820px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--nds-spacing-md);
|
||||
padding: var(--nds-spacing-3xl) 0 var(--nds-spacing-2xl);
|
||||
max-width: 56rem;
|
||||
}
|
||||
.hero__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
align-self: flex-start;
|
||||
padding: var(--nds-spacing-2xs) var(--nds-spacing-sm);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border-strong);
|
||||
border-radius: var(--nds-radius-full);
|
||||
background: color-mix(in srgb, var(--nds-surface) 60%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.hero__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: var(--nds-radius-full);
|
||||
background: var(--nds-primary);
|
||||
box-shadow: 0 0 10px var(--nds-primary);
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
.hero__statusText {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
.hero__title {
|
||||
@include text-5xl;
|
||||
margin: var(--nds-spacing-sm) 0 0;
|
||||
color: var(--nds-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
}
|
||||
.hero__role {
|
||||
@include text-2xl;
|
||||
color: var(--nds-primary);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-weight: 500;
|
||||
}
|
||||
.hero__lede {
|
||||
@include text-lg;
|
||||
color: var(--nds-neutral);
|
||||
max-width: 44rem;
|
||||
margin: var(--nds-spacing-xs) 0 0;
|
||||
}
|
||||
.hero__cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin-top: var(--nds-spacing-md);
|
||||
}
|
||||
.hero__avail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-xs);
|
||||
margin-top: var(--nds-spacing-sm);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
.hero__availText { color: var(--nds-text); }
|
||||
.hero__sep { color: var(--nds-border-strong); }
|
||||
.hero__zones {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* SECTION BANDS */
|
||||
.band { padding: var(--nds-spacing-2xl) 0; }
|
||||
.band__lead {
|
||||
@include text-3xl;
|
||||
color: var(--nds-text);
|
||||
margin: 0;
|
||||
max-width: 32rem;
|
||||
&--center {
|
||||
max-width: none;
|
||||
text-align: center;
|
||||
margin-bottom: var(--nds-spacing-2xl);
|
||||
}
|
||||
}
|
||||
.band__split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: var(--nds-spacing-2xl);
|
||||
align-items: start;
|
||||
}
|
||||
.band__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-md);
|
||||
p {
|
||||
@include text-base;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.band__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--nds-spacing-md);
|
||||
margin-bottom: var(--nds-spacing-xl);
|
||||
}
|
||||
.band__all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--nds-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
&:hover { color: var(--nds-accent); }
|
||||
}
|
||||
|
||||
/* Capabilities */
|
||||
.caps {
|
||||
list-style: none;
|
||||
margin: var(--nds-spacing-2xl) 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--nds-spacing-lg);
|
||||
}
|
||||
.cap {
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
background: color-mix(in srgb, var(--nds-surface) 64%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: var(--nds-spacing-lg);
|
||||
}
|
||||
.cap__label {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--nds-primary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.cap__body {
|
||||
@include text-sm;
|
||||
color: var(--nds-neutral);
|
||||
margin: var(--nds-spacing-xs) 0 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--nds-spacing-lg);
|
||||
}
|
||||
|
||||
/* INSTRUMENTS */
|
||||
.instruments {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: var(--nds-spacing-sm);
|
||||
}
|
||||
.instruments__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
padding: var(--nds-spacing-lg) var(--nds-spacing-sm);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
background: color-mix(in srgb, var(--nds-surface) 64%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
transition: border-color 180ms ease, transform 180ms ease, color 180ms ease;
|
||||
color: var(--nds-neutral);
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--nds-primary) 50%, var(--nds-border));
|
||||
transform: translateY(-2px);
|
||||
color: var(--nds-primary);
|
||||
}
|
||||
}
|
||||
.instruments__icon { display: grid; place-items: center; color: inherit; }
|
||||
.instruments__label {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
/* CLOSING */
|
||||
.closing {
|
||||
text-align: center;
|
||||
padding: var(--nds-spacing-3xl) 0;
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.closing__eyebrow {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--nds-accent);
|
||||
margin: 0 0 var(--nds-spacing-md);
|
||||
}
|
||||
.closing__title {
|
||||
@include text-4xl;
|
||||
color: var(--nds-text);
|
||||
margin: 0 0 var(--nds-spacing-md);
|
||||
}
|
||||
.closing__text {
|
||||
@include text-lg;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0 0 var(--nds-spacing-xl);
|
||||
}
|
||||
.closing__cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-sm);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero__dot { animation: none; }
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 900px) {
|
||||
.band__split { grid-template-columns: 1fr; gap: var(--nds-spacing-lg); }
|
||||
.caps { grid-template-columns: 1fr; }
|
||||
.grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.instruments { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.instruments { grid-template-columns: repeat(3, 1fr); }
|
||||
.hero__cta :global(.button) { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
<HomeView />
|
||||
|
||||
+4
-297
@@ -1,305 +1,12 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import TechIcon from '../../components/TechIcon.astro';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
Badge,
|
||||
Button,
|
||||
Link,
|
||||
} from '@unkn0wndo3s/nova-design-system';
|
||||
import { ArrowUpRight, ArrowLeft } from '@lucide/astro';
|
||||
import { projects, statusMeta, type Project } from '../../data/projects';
|
||||
import ProjectView from '../../views/ProjectView.astro';
|
||||
import { projects } from '../../data/projects';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return projects.map((project) => ({
|
||||
params: { slug: project.slug },
|
||||
props: { project },
|
||||
}));
|
||||
return projects.map((project) => ({ params: { slug: project.slug }, props: { project } }));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
const { project } = Astro.props;
|
||||
const status = statusMeta[project.status];
|
||||
const title = `${project.title} - Louis Potevin`;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={project.tagline}>
|
||||
<article class="detail">
|
||||
<header class="detail__head">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem href="/">Home</BreadcrumbItem>
|
||||
<BreadcrumbItem href="/work">Projects</BreadcrumbItem>
|
||||
<BreadcrumbItem href={`/work/${project.slug}`} current>
|
||||
{project.designation}
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
|
||||
<div class="detail__meta">
|
||||
<span class="detail__code">{project.designation}</span>
|
||||
<Badge type={status.tone} variant="soft">{status.label}</Badge>
|
||||
</div>
|
||||
|
||||
<h1 class="detail__title">{project.title}</h1>
|
||||
<p class="detail__tagline">{project.tagline}</p>
|
||||
|
||||
<dl class="detail__facts">
|
||||
<div class="fact">
|
||||
<dt>Role</dt>
|
||||
<dd>{project.role}</dd>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<dt>Timeline</dt>
|
||||
<dd>{project.period}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<div class="detail__grid">
|
||||
<div class="detail__main">
|
||||
<p class="detail__summary">{project.summary}</p>
|
||||
|
||||
<section class="block" aria-labelledby="ctx">
|
||||
<Eyebrow index="01" label="Context" />
|
||||
<ul class="ticks">
|
||||
{project.context.map((c) => <li>{c}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block" aria-labelledby="contrib">
|
||||
<Eyebrow index="02" label="Contributions" />
|
||||
<ul class="ticks">
|
||||
{project.contributions.map((c) => <li>{c}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="detail__side">
|
||||
<div class="panel">
|
||||
<p class="panel__title">Stack</p>
|
||||
<ul class="chips">
|
||||
{
|
||||
project.stack.map((s) => (
|
||||
<li class="chip">
|
||||
<TechIcon name={s.icon} size={15} />
|
||||
{s.label}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{
|
||||
project.links.length > 0 && (
|
||||
<div class="panel">
|
||||
<p class="panel__title">Links</p>
|
||||
<ul class="links">
|
||||
{project.links.map((l) => (
|
||||
<li>
|
||||
<Link url={l.url} blank>
|
||||
{l.icon && <TechIcon name={l.icon} size={15} />}
|
||||
{l.label}
|
||||
<ArrowUpRight size={13} />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
project.links.length === 0 && (
|
||||
<div class="panel panel--muted">
|
||||
<p class="panel__note">
|
||||
Internal or private project - no public link available.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer class="detail__foot">
|
||||
<Button type="ghost" href="/work">
|
||||
<ArrowLeft slot="icon-left" size={16} />
|
||||
All projects
|
||||
</Button>
|
||||
<Button type="primary" href="/contact">
|
||||
Discuss this project
|
||||
<ArrowUpRight slot="icon-right" size={16} />
|
||||
</Button>
|
||||
</footer>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'styles/type' as *;
|
||||
|
||||
.detail {
|
||||
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-3xl);
|
||||
max-width: 64rem;
|
||||
}
|
||||
.detail__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin: var(--nds-spacing-lg) 0 var(--nds-spacing-sm);
|
||||
}
|
||||
.detail__code {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--nds-accent);
|
||||
}
|
||||
.detail__title {
|
||||
@include text-4xl;
|
||||
color: var(--nds-text);
|
||||
margin: 0;
|
||||
}
|
||||
.detail__tagline {
|
||||
@include text-lg;
|
||||
color: var(--nds-neutral);
|
||||
margin: var(--nds-spacing-sm) 0 0;
|
||||
}
|
||||
.detail__facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-2xl);
|
||||
margin: var(--nds-spacing-lg) 0 0;
|
||||
padding-top: var(--nds-spacing-md);
|
||||
border-top: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
}
|
||||
.fact {
|
||||
margin: 0;
|
||||
dt {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
dd {
|
||||
@include text-base;
|
||||
color: var(--nds-text);
|
||||
margin: var(--nds-spacing-3xs) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
gap: var(--nds-spacing-2xl);
|
||||
margin-top: var(--nds-spacing-2xl);
|
||||
align-items: start;
|
||||
}
|
||||
.detail__summary {
|
||||
@include text-lg;
|
||||
color: var(--nds-text);
|
||||
margin: 0 0 var(--nds-spacing-xl);
|
||||
}
|
||||
.block {
|
||||
margin-bottom: var(--nds-spacing-xl);
|
||||
}
|
||||
.ticks {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-sm);
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: var(--nds-spacing-md);
|
||||
@include text-base;
|
||||
color: var(--nds-neutral);
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.55em;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
transform: rotate(45deg);
|
||||
background: var(--nds-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-md);
|
||||
position: sticky;
|
||||
top: calc(64px + var(--nds-spacing-md));
|
||||
}
|
||||
.panel {
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
background: color-mix(in srgb, var(--nds-surface) 70%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: var(--nds-spacing-lg);
|
||||
&--muted { background: color-mix(in srgb, var(--nds-surface) 40%, transparent); }
|
||||
}
|
||||
.panel__title {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0 0 var(--nds-spacing-sm);
|
||||
}
|
||||
.panel__note {
|
||||
@include text-sm;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0;
|
||||
}
|
||||
.chips {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
padding: 4px var(--nds-spacing-xs);
|
||||
border-radius: var(--nds-radius-full);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
background: var(--nds-surface-2);
|
||||
color: var(--nds-neutral);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.links {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
}
|
||||
|
||||
.detail__foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin-top: var(--nds-spacing-2xl);
|
||||
padding-top: var(--nds-spacing-lg);
|
||||
border-top: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 820px) {
|
||||
.detail__grid { grid-template-columns: 1fr; gap: var(--nds-spacing-xl); }
|
||||
.detail__side { position: static; }
|
||||
}
|
||||
</style>
|
||||
<ProjectView project={project} />
|
||||
|
||||
@@ -1,62 +1,5 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import ProjectCard from '../../components/ProjectCard.astro';
|
||||
import { Breadcrumb, BreadcrumbItem } from '@unkn0wndo3s/nova-design-system';
|
||||
import { projects } from '../../data/projects';
|
||||
|
||||
const title = 'Projects - Louis Potevin, full-stack web developer / développeur full-stack';
|
||||
const description =
|
||||
'Projects by Louis Potevin: Astro design system, self-hosted infrastructure, systems tools in Rust, and LLM explorations. Full-stack web developer.';
|
||||
import WorkIndexView from '../../views/WorkIndexView.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<section class="head">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem href="/">Home</BreadcrumbItem>
|
||||
<BreadcrumbItem href="/work" current>Projects</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
|
||||
<Eyebrow index="//" label="All projects" />
|
||||
<h1 class="head__title">Projects</h1>
|
||||
<p class="head__lede">
|
||||
A showcase of everything I build - from business software running in production to
|
||||
the infrastructure supporting it, alongside my personal tools.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="grid" aria-label="Projects list">
|
||||
{projects.map((p) => <ProjectCard project={p} />)}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'styles/type' as *;
|
||||
|
||||
.head {
|
||||
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-xl);
|
||||
max-width: 48rem;
|
||||
}
|
||||
.head__title {
|
||||
@include text-4xl;
|
||||
color: var(--nds-text);
|
||||
margin: var(--nds-spacing-sm) 0 var(--nds-spacing-md);
|
||||
}
|
||||
.head__lede {
|
||||
@include text-lg;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--nds-spacing-lg);
|
||||
padding-bottom: var(--nds-spacing-3xl);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<WorkIndexView />
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Re-exports the official typography mixins from Nova DS.
|
||||
// The partial is resolved via a loadPath pointing to the package's
|
||||
// style folder (its `exports` field prevents direct access to the subpath).
|
||||
// Usage in a component: @use "styles/type" as *; then @include text-3xl;
|
||||
@forward "tokens/typography";
|
||||
// Re-exports NDS typography mixins (text-sm ... text-5xl) + font vars.
|
||||
// The tokens directory is exposed through a Sass loadPath (see astro.config.mjs).
|
||||
@forward 'typography';
|
||||
|
||||
+52
-23
@@ -1,44 +1,73 @@
|
||||
@use 'styles/type' as *;
|
||||
@use './type' as type;
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
background: var(--nds-background);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
color: var(--nds-text);
|
||||
font-family: var(--nds-font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
background-color: var(--nds-background);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--nds-primary) 35%, transparent);
|
||||
color: var(--nds-text);
|
||||
background: var(--nds-primary);
|
||||
color: var(--nds-on-primary);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--nds-border-strong) transparent;
|
||||
h1, h2, h3 {
|
||||
font-family: var(--nds-font-mono);
|
||||
text-wrap: balance;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
p { margin: 0; }
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
img, svg, canvas { max-width: 100%; height: auto; }
|
||||
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
outline: 2px solid var(--nds-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-block: clamp(64px, 10vh, 128px);
|
||||
}
|
||||
|
||||
.eyebrow-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--nds-spacing-md);
|
||||
margin-bottom: var(--nds-spacing-lg);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include type.text-4xl;
|
||||
font-size: clamp(1.6rem, 3.4vw, 2.4rem);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Lenis smooth scroll (recommended base styles) ---- */
|
||||
html.lenis, html.lenis body { height: auto; }
|
||||
.lenis.lenis-smooth { scroll-behavior: auto !important; }
|
||||
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
|
||||
.lenis.lenis-stopped { overflow: hidden; }
|
||||
|
||||
/* ---- Cinema mode (home voyage): the footer slides over the last overlay ---- */
|
||||
html.cinema footer {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
background: var(--nds-background);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import { Badge, Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import { breadcrumbLd, profilePageLd } from '../lib/seo';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
import type { Localized } from '../data/projects';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
interface Step {
|
||||
period: Localized;
|
||||
role: Localized;
|
||||
place: string;
|
||||
detail: Localized;
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
period: { fr: 'Sept. 2025 - Août 2026', en: 'Sept. 2025 - Aug. 2026' },
|
||||
role: { fr: 'Développeur full-stack en alternance', en: 'Full-stack developer (apprenticeship)' },
|
||||
place: 'Legrand France, Limoges',
|
||||
detail: {
|
||||
fr: "Développement du DAN, application web interne pour les ateliers de production : refonte du module de gestion des utilisateurs (Vue.js/TypeScript, export CSV) et du module de statistiques avec composants graphiques sur mesure. Back-end Node.js/Express.",
|
||||
en: 'Development of DAN, an internal web app for production workshops: rework of the user management module (Vue.js/TypeScript, CSV export) and of the statistics module with custom chart components. Node.js/Express back-end.',
|
||||
},
|
||||
},
|
||||
{
|
||||
period: { fr: 'Avril - Août 2025', en: 'April - Aug. 2025' },
|
||||
role: { fr: "Stage puis job d'été", en: 'Internship, then summer job' },
|
||||
place: 'Legrand France, Limoges',
|
||||
detail: {
|
||||
fr: "Premiers travaux sur le DAN : montée en compétence sur la base de code, premières fonctionnalités livrées en production. L'expérience s'est prolongée en alternance sur la même application.",
|
||||
en: 'First work on DAN: ramping up on the codebase, first features shipped to production. The experience continued as an apprenticeship on the same application.',
|
||||
},
|
||||
},
|
||||
{
|
||||
period: { fr: '2023 - 2026', en: '2023 - 2026' },
|
||||
role: { fr: 'BUT MMI, parcours Développement Web (DWDI)', en: 'BUT MMI degree, Web Development track (DWDI)' },
|
||||
place: 'IUT du Limousin',
|
||||
detail: {
|
||||
fr: "Formation en développement web et dispositifs interactifs : front-end, back-end, UX, gestion de projet. Diplôme obtenu en 2026.",
|
||||
en: 'Training in web development and interactive systems: front-end, back-end, UX, project management. Graduating in 2026.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface Value {
|
||||
title: Localized;
|
||||
body: Localized;
|
||||
}
|
||||
|
||||
const values: Value[] = [
|
||||
{
|
||||
title: { fr: 'Livrer, pas prototyper', en: 'Ship, not prototype' },
|
||||
body: {
|
||||
fr: 'Un projet est terminé quand il tourne en production avec sa CI. Mon design system est sur npm, mon infra héberge ce site : tout ce que je montre est réellement en service.',
|
||||
en: 'A project is done when it runs in production with its CI. My design system is on npm, my infrastructure hosts this site: everything I show is actually in service.',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: { fr: 'Du composant au serveur', en: 'From component to server' },
|
||||
body: {
|
||||
fr: "Je conçois l'interface, l'API qui la sert et la machine qui l'héberge. Cette vision complète évite les frictions entre front, back et ops.",
|
||||
en: 'I design the interface, the API that serves it and the machine that hosts it. That end-to-end view removes friction between front, back and ops.',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: { fr: 'Réutilisable par défaut', en: 'Reusable by default' },
|
||||
body: {
|
||||
fr: "Tokens, composants, pipelines : je factorise ce qui se répète. C'est ce qui m'a mené à construire Nova Design System plutôt que de copier-coller des styles.",
|
||||
en: 'Tokens, components, pipelines: I factor out what repeats. That mindset is what led me to build Nova Design System instead of copy-pasting styles.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: localizePath('/', locale) },
|
||||
{ name: t('nav.about'), url: localizePath('/about', locale) },
|
||||
];
|
||||
const pageUrl = new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href;
|
||||
const jsonLd = [breadcrumbLd(crumbs), profilePageLd(pageUrl)];
|
||||
---
|
||||
|
||||
<BaseLayout title={t('meta.about.title')} description={t('meta.about.description')} jsonLd={jsonLd}>
|
||||
<header class="page-head">
|
||||
<h1 class="page-title">{t('about.title')}</h1>
|
||||
<p class="page-lead">{t('about.lead')}</p>
|
||||
<p class="availability">
|
||||
<Badge type="success">{t('avail.badge')}</Badge>
|
||||
<span>{t('avail.line')} · {t('avail.where')}</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="section" aria-labelledby="exp-title">
|
||||
<div class="eyebrow-row"><Eyebrow index="01">{t('about.exp.eyebrow')}</Eyebrow></div>
|
||||
<h2 id="exp-title" class="section-title">{t('about.exp.title')}</h2>
|
||||
<ol class="timeline">
|
||||
{steps.map((step) => (
|
||||
<li>
|
||||
<p class="timeline__period">{step.period[locale]}</p>
|
||||
<h3>{step.role[locale]}</h3>
|
||||
<p class="timeline__place">{step.place}</p>
|
||||
<p class="timeline__detail">{step.detail[locale]}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="section" aria-labelledby="values-title">
|
||||
<div class="eyebrow-row"><Eyebrow index="02">{t('about.values.eyebrow')}</Eyebrow></div>
|
||||
<h2 id="values-title" class="section-title">{t('about.values.title')}</h2>
|
||||
<ul class="values">
|
||||
{values.map((value) => (
|
||||
<li>
|
||||
<h3>{value.title[locale]}</h3>
|
||||
<p>{value.body[locale]}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<Button href={localizePath('/contact', locale)} size="lg">{t('nav.cta')}</Button>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.page-head {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-md);
|
||||
padding-block: clamp(48px, 8vh, 96px) clamp(24px, 4vh, 48px);
|
||||
}
|
||||
|
||||
.page-title { @include type.text-5xl; font-size: clamp(2rem, 5.5vw, 3.4rem); }
|
||||
.page-lead {
|
||||
@include type.text-lg;
|
||||
font-weight: 400;
|
||||
max-width: 62ch;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.availability {
|
||||
@include type.text-label;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xl);
|
||||
margin: var(--nds-spacing-xl) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: var(--nds-spacing-xl);
|
||||
border-left: var(--nds-border-width-medium) solid var(--nds-border);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -7px;
|
||||
top: 6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--nds-radius-full);
|
||||
background: var(--nds-primary);
|
||||
}
|
||||
}
|
||||
|
||||
h3 { @include type.text-xl; margin-block: var(--nds-spacing-2xs); }
|
||||
}
|
||||
|
||||
.timeline__period {
|
||||
@include type.text-label;
|
||||
color: var(--nds-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.timeline__place { @include type.text-label; color: var(--nds-neutral); }
|
||||
.timeline__detail {
|
||||
@include type.text-base;
|
||||
font-size: 1rem;
|
||||
margin-top: var(--nds-spacing-xs);
|
||||
max-width: 64ch;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.values {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
|
||||
gap: var(--nds-spacing-lg);
|
||||
margin: var(--nds-spacing-xl) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
padding: var(--nds-spacing-lg);
|
||||
background: color-mix(in srgb, var(--nds-surface) 88%, transparent);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
}
|
||||
h3 { @include type.text-xl; color: var(--nds-primary); margin-bottom: var(--nds-spacing-xs); }
|
||||
p { @include type.text-base; color: var(--nds-neutral); }
|
||||
}
|
||||
|
||||
.actions { margin-top: var(--nds-spacing-2xl); }
|
||||
</style>
|
||||
@@ -0,0 +1,350 @@
|
||||
---
|
||||
/**
|
||||
* Contact page - static-friendly by design.
|
||||
*
|
||||
* The "form" is a mailto composer: it pre-fills the visitor's mail client and
|
||||
* stores nothing server-side. To switch to a real backend later, point the
|
||||
* submit handler at a Formspree/own endpoint and keep the same fields.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import BrandLinkedin from '../components/BrandLinkedin.astro';
|
||||
import { Mail, Copy, Send } from '@lucide/astro';
|
||||
import { Github, Gitea, Npm } from 'simple-icons-astro';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
Button,
|
||||
TextField,
|
||||
Select,
|
||||
SelectOption,
|
||||
} from '@unkn0wndo3s/nova-design-system';
|
||||
import { site } from '../data/site';
|
||||
import { breadcrumbLd } from '../lib/seo';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: localizePath('/', locale) },
|
||||
{ name: t('nav.contact'), url: localizePath('/contact', locale) },
|
||||
];
|
||||
|
||||
const subjects = [
|
||||
{ value: 'cdi', label: t('contact.form.subject.cdi') },
|
||||
{ value: 'freelance', label: t('contact.form.subject.freelance') },
|
||||
{ value: 'other', label: t('contact.form.subject.other') },
|
||||
];
|
||||
|
||||
const elsewhere = [
|
||||
{ ...site.links.github, icon: 'github' },
|
||||
{ ...site.links.gitea, icon: 'gitea' },
|
||||
{ ...site.links.npm, icon: 'npm' },
|
||||
{ ...site.links.linkedin, icon: 'linkedin' },
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('meta.contact.title')}
|
||||
description={t('meta.contact.description')}
|
||||
jsonLd={[breadcrumbLd(crumbs)]}
|
||||
>
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem href={crumbs[0]!.url}>{crumbs[0]!.name}</BreadcrumbItem>
|
||||
<BreadcrumbItem current>{crumbs[1]!.name}</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</nav>
|
||||
|
||||
<header class="page-head">
|
||||
<Eyebrow>{t('avail.badge')}</Eyebrow>
|
||||
<h1 class="page-title">{t('contact.title')}</h1>
|
||||
<p class="page-lead">{t('contact.lead')}</p>
|
||||
</header>
|
||||
|
||||
<div class="contact-grid">
|
||||
<section class="composer" aria-labelledby="composer-title">
|
||||
<h2 id="composer-title" class="visually-hidden">{t('contact.form.send')}</h2>
|
||||
|
||||
<div class="composer-fields" data-contact-form data-email={site.email}>
|
||||
<div class="composer-row">
|
||||
<TextField id="contact-name" label={t('contact.form.name')} type="text" placeholder="Jane Doe" />
|
||||
<TextField
|
||||
id="contact-email"
|
||||
label={t('contact.form.email')}
|
||||
type="email"
|
||||
placeholder="jane@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="composer-subject">
|
||||
<span class="field-label">{t('contact.form.subject')}</span>
|
||||
<Select default={subjects[0]!.label}>
|
||||
{subjects.map((s) => <SelectOption value={s.label}>{s.label}</SelectOption>)}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
id="contact-message"
|
||||
label={t('contact.form.message')}
|
||||
type="textarea"
|
||||
max={1200}
|
||||
placeholder="..."
|
||||
/>
|
||||
|
||||
<div class="composer-actions">
|
||||
<Button type="primary" htmlType="button" data-contact-send>
|
||||
<Send size={16} />
|
||||
{t('contact.form.send')}
|
||||
</Button>
|
||||
<p class="composer-note">{t('contact.form.note')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="direct">
|
||||
<section class="direct-block" aria-labelledby="direct-title">
|
||||
<h2 id="direct-title" class="direct-heading">{t('contact.email.label')}</h2>
|
||||
<p class="direct-email">
|
||||
<Mail size={18} aria-hidden="true" />
|
||||
<span data-email-text>{site.email}</span>
|
||||
</p>
|
||||
<div class="direct-actions">
|
||||
<Button type="secondary" href={`mailto:${site.email}`}>{t('contact.email.write')}</Button>
|
||||
<Button
|
||||
type="ghost"
|
||||
htmlType="button"
|
||||
data-copy-email
|
||||
data-copied-label={t('contact.email.copied')}
|
||||
>
|
||||
<Copy size={16} />
|
||||
<span data-copy-label>{t('contact.email.copy')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="direct-block" aria-labelledby="elsewhere-title">
|
||||
<h2 id="elsewhere-title" class="direct-heading">{t('contact.elsewhere')}</h2>
|
||||
<ul class="elsewhere-list">
|
||||
{
|
||||
elsewhere.map((l) => (
|
||||
<li>
|
||||
<a href={l.url} target="_blank" rel="noopener noreferrer">
|
||||
{l.icon === 'github' && <Github size={18} aria-hidden="true" />}
|
||||
{l.icon === 'gitea' && <Gitea size={18} aria-hidden="true" />}
|
||||
{l.icon === 'npm' && <Npm size={18} aria-hidden="true" />}
|
||||
{l.icon === 'linkedin' && <BrandLinkedin size={18} />}
|
||||
<span>{l.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
/** Builds a mailto: URL from the composer fields and opens it. Zero data stored. */
|
||||
function initContact() {
|
||||
const root = document.querySelector<HTMLElement>('[data-contact-form]');
|
||||
if (!root) return;
|
||||
|
||||
const email = root.dataset.email ?? '';
|
||||
const send = root.querySelector<HTMLButtonElement>('[data-contact-send]');
|
||||
|
||||
send?.addEventListener('click', () => {
|
||||
const name = (document.getElementById('contact-name')?.querySelector('input') as HTMLInputElement | null)?.value?.trim() ?? '';
|
||||
const from = (document.getElementById('contact-email')?.querySelector('input') as HTMLInputElement | null)?.value?.trim() ?? '';
|
||||
const message = (document.getElementById('contact-message')?.querySelector('textarea') as HTMLTextAreaElement | null)?.value?.trim() ?? '';
|
||||
|
||||
const selected =
|
||||
root.querySelector<HTMLElement>('nds-select [aria-selected="true"]')?.dataset.value ??
|
||||
root.querySelector<HTMLElement>('nds-select')?.dataset.default ??
|
||||
'';
|
||||
|
||||
const subject = `[Portfolio] ${selected}${name ? ` - ${name}` : ''}`;
|
||||
const bodyParts = [message];
|
||||
if (name || from) bodyParts.push('', '--', [name, from].filter(Boolean).join(' · '));
|
||||
|
||||
const url = `mailto:${email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(bodyParts.join('\n'))}`;
|
||||
window.location.href = url;
|
||||
});
|
||||
|
||||
// Copy-to-clipboard with temporary label swap.
|
||||
const copyBtn = document.querySelector<HTMLButtonElement>('[data-copy-email]');
|
||||
const copyLabel = copyBtn?.querySelector<HTMLElement>('[data-copy-label]');
|
||||
copyBtn?.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
if (copyLabel && copyBtn.dataset.copiedLabel) {
|
||||
const original = copyLabel.textContent;
|
||||
copyLabel.textContent = copyBtn.dataset.copiedLabel;
|
||||
window.setTimeout(() => {
|
||||
copyLabel.textContent = original;
|
||||
}, 1800);
|
||||
}
|
||||
} catch {
|
||||
/* Clipboard unavailable (permissions/http) - the address stays visible as text. */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initContact();
|
||||
document.addEventListener('astro:page-load', initContact);
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.crumbs {
|
||||
margin-block: var(--nds-spacing-lg) var(--nds-spacing-xl);
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin-block-end: var(--nds-spacing-2xl);
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include type.text-4xl;
|
||||
margin: 0;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
|
||||
.page-lead {
|
||||
@include type.text-lg;
|
||||
margin: 0;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
gap: var(--nds-spacing-2xl);
|
||||
align-items: start;
|
||||
margin-block-end: var(--nds-spacing-3xl);
|
||||
|
||||
@media (max-width: 840px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.composer {
|
||||
background: var(--nds-surface);
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
padding: var(--nds-spacing-xl);
|
||||
box-shadow: var(--nds-shadow-sm);
|
||||
}
|
||||
|
||||
.composer-fields {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-lg);
|
||||
}
|
||||
|
||||
.composer-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--nds-spacing-lg);
|
||||
|
||||
@media (max-width: 560px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.composer-subject {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-sm);
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.composer-note {
|
||||
@include type.text-sm;
|
||||
margin: 0;
|
||||
color: var(--nds-disabled);
|
||||
}
|
||||
|
||||
.direct {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xl);
|
||||
}
|
||||
|
||||
.direct-block {
|
||||
background: var(--nds-surface);
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
padding: var(--nds-spacing-xl);
|
||||
}
|
||||
|
||||
.direct-heading {
|
||||
@include type.text-lg;
|
||||
margin: 0 0 var(--nds-spacing-md);
|
||||
color: var(--nds-text);
|
||||
}
|
||||
|
||||
.direct-email {
|
||||
@include type.text-base;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
margin: 0 0 var(--nds-spacing-md);
|
||||
color: var(--nds-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.direct-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-sm);
|
||||
}
|
||||
|
||||
.elsewhere-list {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
padding: var(--nds-spacing-sm) var(--nds-spacing-md);
|
||||
border-radius: var(--nds-radius-md);
|
||||
color: var(--nds-text);
|
||||
text-decoration: none;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--nds-surface-hover);
|
||||
color: var(--nds-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,547 @@
|
||||
---
|
||||
/**
|
||||
* Home = the voyage. One long timeline: hero → profile → asteroid belt with a
|
||||
* beacon per project → stack constellation → contact portal.
|
||||
*
|
||||
* Two rendering modes from the same markup:
|
||||
* - default (no JS, no WebGL, reduced motion): normal stacked sections
|
||||
* - `html.cinema` (set by voyage.ts): sections become fixed overlays,
|
||||
* the wrapper's height becomes the scroll timeline.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import TechIcon from '../components/TechIcon.astro';
|
||||
import BrandLinkedin from '../components/BrandLinkedin.astro';
|
||||
import { Copy, ChevronDown } from '@lucide/astro';
|
||||
import { Github, Gitea, Npm } from 'simple-icons-astro';
|
||||
import { Badge, Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import { site, stack } from '../data/site';
|
||||
import { featuredProjects, projects, type Project } from '../data/projects';
|
||||
import { breadcrumbLd, profilePageLd } from '../lib/seo';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const voyageProjects: Project[] = [...featuredProjects, ...projects.filter((p) => !p.featured)];
|
||||
const statusType = { live: 'success', maintained: 'info', wip: 'warning' } as const;
|
||||
const statusLabel = {
|
||||
live: t('work.status.live'),
|
||||
maintained: t('work.status.maintained'),
|
||||
wip: t('work.status.wip'),
|
||||
} as const;
|
||||
|
||||
const pillars = [
|
||||
{ title: t('home.profile.front.title'), body: t('home.profile.front.body') },
|
||||
{ title: t('home.profile.back.title'), body: t('home.profile.back.body') },
|
||||
{ title: t('home.profile.ops.title'), body: t('home.profile.ops.body') },
|
||||
];
|
||||
|
||||
const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.slug}`), 'stack', 'contact'];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('meta.home.title')}
|
||||
description={t('meta.home.description')}
|
||||
jsonLd={[breadcrumbLd([{ name: t('nav.home'), url: localizePath('/', locale) }]), profilePageLd(new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href)]}
|
||||
background={false}
|
||||
fullBleed
|
||||
>
|
||||
<canvas class="voyage-canvas" data-voyage-canvas aria-hidden="true"></canvas>
|
||||
|
||||
<div class="voyage" data-voyage>
|
||||
<!-- 01 · Hero -->
|
||||
<section class="seg seg--center" data-seg="hero" data-weight="1.1" id="hero">
|
||||
<div class="card card--hero">
|
||||
<p class="hero-avail"><Badge type="success" variant="soft">{t('avail.badge')}</Badge> <span>{t('avail.line')}</span></p>
|
||||
<h1 class="hero-title">
|
||||
<span class="hero-name">{site.name}</span>
|
||||
<span class="hero-role">{t('home.hero.title.role')}</span>
|
||||
</h1>
|
||||
<p class="hero-lead">{t('home.hero.lead')}</p>
|
||||
<div class="row">
|
||||
<Button type="primary" href={localizePath('/#projects', locale)}>{t('home.hero.viewProjects')}</Button>
|
||||
<Button type="ghost" href={localizePath('/#contact', locale)}>{t('home.hero.contact')}</Button>
|
||||
</div>
|
||||
<p class="hero-hint" aria-hidden="true"><ChevronDown size={16} /> {t('voyage.hint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 02 · Profil -->
|
||||
<section class="seg seg--left" data-seg="about" data-weight="1.3" data-side="left" id="about">
|
||||
<div class="card">
|
||||
<Eyebrow>{t('home.profile.eyebrow')}</Eyebrow>
|
||||
<h2 class="card-title">{t('home.profile.title')}</h2>
|
||||
<p class="card-body">{t('home.profile.body1')}</p>
|
||||
<p class="card-body card-body--muted">{t('home.about.summary')}</p>
|
||||
<ul class="pillars">
|
||||
{pillars.map((p) => (
|
||||
<li><strong>{p.title}</strong><span>{p.body}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 03 · Cap sur les projets -->
|
||||
<section class="seg seg--center" data-seg="projects" data-weight="0.7" id="projects">
|
||||
<div class="card card--center">
|
||||
<Eyebrow>{t('home.projects.eyebrow')}</Eyebrow>
|
||||
<h2 class="card-title card-title--big">{t('home.projects.intro.title')}</h2>
|
||||
<p class="card-body">{t('home.projects.intro.body')}</p>
|
||||
<div class="row row--center">
|
||||
<Button type="secondary" href={localizePath('/work', locale)}>{t('home.projects.all')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 04-07 · Un segment par projet, cartes alternées -->
|
||||
{voyageProjects.map((p, i) => (
|
||||
<section
|
||||
class={`seg seg--${i % 2 ? 'left' : 'right'}`}
|
||||
data-seg={`p-${p.slug}`}
|
||||
data-weight="1.15"
|
||||
data-side={i % 2 ? 'left' : 'right'}
|
||||
data-project
|
||||
id={`p-${p.slug}`}
|
||||
>
|
||||
<article class="card card--project">
|
||||
<p class="proj-index" aria-hidden="true">{String(i + 1).padStart(2, '0')}</p>
|
||||
<div class="proj-head">
|
||||
<Badge type={statusType[p.status]}>{statusLabel[p.status]}</Badge>
|
||||
</div>
|
||||
<h2 class="card-title">{p.name}</h2>
|
||||
<p class="card-body">{p.tagline[locale]}</p>
|
||||
<ul class="proj-highlights">
|
||||
{p.highlights.slice(0, 2).map((h) => <li>{h[locale]}</li>)}
|
||||
</ul>
|
||||
<div class="row">
|
||||
<Button type="primary" href={localizePath(`/work/${p.slug}`, locale)}>
|
||||
{t('home.projects.view')}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<!-- 08 · Stack -->
|
||||
<section class="seg seg--center" data-seg="stack" data-weight="1.05" id="stack">
|
||||
<div class="card card--center card--wide">
|
||||
<Eyebrow>{t('home.stack.eyebrow')}</Eyebrow>
|
||||
<h2 class="card-title">{t('home.stack.title')}</h2>
|
||||
<ul class="stack-grid">
|
||||
{stack.map((s) => <li><TechIcon name={s.name} icon={s.icon} /></li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 09 · Contact (arrivée au portail) -->
|
||||
<section class="seg seg--center" data-seg="contact" data-weight="1.2" id="contact">
|
||||
<div class="card card--center">
|
||||
<Eyebrow>{t('contact.title')}</Eyebrow>
|
||||
<h2 class="card-title card-title--big">{t('home.cta.title')}</h2>
|
||||
<p class="card-body">{t('home.cta.body')}</p>
|
||||
<div class="row row--center">
|
||||
<Button type="primary" href={`mailto:${site.email}`}>{t('contact.email.write')}</Button>
|
||||
<Button type="ghost" htmlType="button" data-copy-email data-copied-label={t('contact.email.copied')}>
|
||||
<Copy size={16} />
|
||||
<span data-copy-label>{t('contact.email.copy')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p class="contact-email" data-email-text>{site.email}</p>
|
||||
<nav class="socials" aria-label="Social">
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Timeline UI (cinema mode only) -->
|
||||
<div class="rail" aria-hidden="true">
|
||||
<div class="rail__bar" data-voyage-progress></div>
|
||||
</div>
|
||||
<nav class="dots" aria-label={t('voyage.progress')}>
|
||||
{dots.map((id) => <button type="button" data-voyage-dot={id} aria-label={id}></button>)}
|
||||
</nav>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
import { ensureSmooth } from '../lib/smooth';
|
||||
import type { VoyageHandle } from '../components/scene/voyage';
|
||||
|
||||
let handle: VoyageHandle | null = null;
|
||||
|
||||
async function init() {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('[data-voyage-canvas]');
|
||||
const container = document.querySelector<HTMLElement>('[data-voyage]');
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const lenis = ensureSmooth();
|
||||
if (!lenis) return; // reduced motion → static page, canvas stays hidden
|
||||
|
||||
// Three.js only downloads when the cinema mode is actually possible.
|
||||
const { mountVoyage } = await import('../components/scene/voyage');
|
||||
handle = mountVoyage({ canvas, container, lenis });
|
||||
if (!handle) return; // no WebGL → static page
|
||||
|
||||
// Deep links (/#about, nav anchors...) become smooth seeks on the timeline.
|
||||
if (location.hash) handle.seek(location.hash.slice(1), true);
|
||||
document.addEventListener('click', (e) => {
|
||||
const a = (e.target as HTMLElement).closest<HTMLAnchorElement>('a[href*="#"]');
|
||||
if (!a || !handle) return;
|
||||
if (a.pathname !== location.pathname) return;
|
||||
const id = a.hash.slice(1);
|
||||
e.preventDefault();
|
||||
history.replaceState(null, '', a.hash);
|
||||
handle.seek(id);
|
||||
});
|
||||
}
|
||||
|
||||
function initCopy() {
|
||||
const btn = document.querySelector<HTMLButtonElement>('[data-copy-email]');
|
||||
const label = btn?.querySelector<HTMLElement>('[data-copy-label]');
|
||||
const email = document.querySelector<HTMLElement>('[data-email-text]')?.textContent?.trim() ?? '';
|
||||
btn?.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
if (label && btn.dataset.copiedLabel) {
|
||||
const orig = label.textContent;
|
||||
label.textContent = btn.dataset.copiedLabel;
|
||||
window.setTimeout(() => (label.textContent = orig), 1800);
|
||||
}
|
||||
} catch {
|
||||
/* clipboard unavailable - the address stays readable as text */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
initCopy();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
/* ---------- Canvas & timeline chrome (hidden until cinema mode) --------- */
|
||||
.voyage-canvas {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
.rail,
|
||||
.dots {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(html.cinema) .voyage-canvas {
|
||||
display: block;
|
||||
}
|
||||
:global(html.cinema) .rail {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
z-index: 60;
|
||||
background: color-mix(in srgb, var(--nds-border) 60%, transparent);
|
||||
|
||||
.rail__bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--nds-primary), var(--nds-accent));
|
||||
transform-origin: left;
|
||||
transform: scaleX(0);
|
||||
}
|
||||
}
|
||||
:global(html.cinema) .dots {
|
||||
position: fixed;
|
||||
right: clamp(10px, 2vw, 26px);
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
z-index: 60;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
button {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--nds-neutral);
|
||||
border-radius: var(--nds-radius-full);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.25s ease, transform 0.25s ease, border-color 0.25s ease;
|
||||
|
||||
&.is-active {
|
||||
background: var(--nds-accent);
|
||||
border-color: var(--nds-accent);
|
||||
transform: scale(1.35);
|
||||
}
|
||||
&:hover {
|
||||
border-color: var(--nds-text);
|
||||
}
|
||||
}
|
||||
@media (max-width: 840px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------- Sections: static default --------------------- */
|
||||
.seg {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-inline: clamp(20px, 6vw, 72px);
|
||||
|
||||
&.seg--center {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
&.seg--right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cinema: fixed overlays, driven per-frame by voyage.ts */
|
||||
:global(html.cinema) .seg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
min-height: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
will-change: opacity, transform;
|
||||
|
||||
&.is-active {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- Cards ---------------------------------- */
|
||||
.card {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-md);
|
||||
justify-items: start;
|
||||
width: min(600px, 94vw);
|
||||
padding: clamp(var(--nds-spacing-lg), 3vw, var(--nds-spacing-2xl));
|
||||
background: color-mix(in srgb, var(--nds-background) 62%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--nds-border) 80%, transparent);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--nds-shadow-md);
|
||||
}
|
||||
.card--center {
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.card--wide {
|
||||
width: min(780px, 94vw);
|
||||
}
|
||||
.card--hero {
|
||||
width: min(860px, 94vw);
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-sm);
|
||||
}
|
||||
.row--center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@include type.text-3xl;
|
||||
margin: 0;
|
||||
color: var(--nds-text);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.card-title--big {
|
||||
@include type.text-4xl;
|
||||
}
|
||||
.card-body {
|
||||
@include type.text-base;
|
||||
margin: 0;
|
||||
max-width: 56ch;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
.card-body--muted {
|
||||
color: var(--nds-disabled);
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero-avail {
|
||||
@include type.text-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin: 0;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
.hero-title {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
margin: 0;
|
||||
}
|
||||
.hero-name {
|
||||
@include type.text-5xl;
|
||||
color: var(--nds-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.hero-role {
|
||||
@include type.text-xl;
|
||||
color: var(--nds-accent);
|
||||
}
|
||||
.hero-lead {
|
||||
@include type.text-lg;
|
||||
margin: 0;
|
||||
max-width: 58ch;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
.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 */
|
||||
.pillars {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding-left: var(--nds-spacing-md);
|
||||
border-left: 2px solid var(--nds-primary);
|
||||
|
||||
strong {
|
||||
@include type.text-label;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
span {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Projets */
|
||||
.card--project {
|
||||
position: relative;
|
||||
}
|
||||
.proj-index {
|
||||
@include type.text-5xl;
|
||||
position: absolute;
|
||||
top: -0.55em;
|
||||
right: var(--nds-spacing-lg);
|
||||
margin: 0;
|
||||
color: color-mix(in srgb, var(--nds-accent) 26%, transparent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
.proj-highlights {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-neutral);
|
||||
padding-left: var(--nds-spacing-md);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '◆';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--nds-accent);
|
||||
font-size: 0.6em;
|
||||
top: 0.45em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Stack */
|
||||
.stack-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Contact */
|
||||
.contact-email {
|
||||
@include type.text-base;
|
||||
margin: 0;
|
||||
color: var(--nds-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.socials {
|
||||
display: flex;
|
||||
gap: var(--nds-spacing-md);
|
||||
|
||||
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 {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.seg,
|
||||
.seg.seg--right {
|
||||
justify-content: center;
|
||||
}
|
||||
.proj-index {
|
||||
right: var(--nds-spacing-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import TechIcon from '../components/TechIcon.astro';
|
||||
import ComponentShowcase from '../components/showcase/ComponentShowcase.astro';
|
||||
import InfraScene from '../components/scenes/InfraScene.astro';
|
||||
import BlueprintScene from '../components/scenes/BlueprintScene.astro';
|
||||
import SorterScene from '../components/scenes/SorterScene.astro';
|
||||
import { Badge, Button, Breadcrumb, BreadcrumbItem, Link } from '@unkn0wndo3s/nova-design-system';
|
||||
import { projects, type Project } from '../data/projects';
|
||||
import { breadcrumbLd, projectLd } from '../lib/seo';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
export interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
const { project } = Astro.props;
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const statusType = { live: 'success', maintained: 'info', wip: 'warning' } as const;
|
||||
const statusLabel = {
|
||||
live: t('work.status.live'),
|
||||
maintained: t('work.status.maintained'),
|
||||
wip: t('work.status.wip'),
|
||||
} as const;
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: localizePath('/', locale) },
|
||||
{ name: t('nav.work'), url: localizePath('/work', locale) },
|
||||
{ name: project.name, url: localizePath(`/work/${project.slug}`, locale) },
|
||||
];
|
||||
|
||||
const pageUrl = new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href;
|
||||
const jsonLd = [breadcrumbLd(crumbs), projectLd(project, locale, pageUrl)];
|
||||
|
||||
const title = `${project.name} - ${t('meta.work.title')}`;
|
||||
const next = projects[(projects.findIndex((p) => p.slug === project.slug) + 1) % projects.length]!;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={project.summary[locale]} jsonLd={jsonLd} ogType="article">
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem href={crumbs[0]!.url}>{crumbs[0]!.name}</BreadcrumbItem>
|
||||
<BreadcrumbItem href={crumbs[1]!.url}>{crumbs[1]!.name}</BreadcrumbItem>
|
||||
<BreadcrumbItem current>{project.name}</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</nav>
|
||||
|
||||
<article>
|
||||
<header class="head">
|
||||
<Badge type={statusType[project.status]}>{statusLabel[project.status]}</Badge>
|
||||
<h1 class="head__title">{project.name}</h1>
|
||||
<p class="head__tagline">{project.tagline[locale]}</p>
|
||||
</header>
|
||||
|
||||
{project.slug === 'nova-infra' && <InfraScene />}
|
||||
{project.slug === 'portfolio' && <BlueprintScene />}
|
||||
{project.slug === 'file-organizer' && <SorterScene />}
|
||||
|
||||
<div class="layout">
|
||||
<div class="body">
|
||||
{project.body.map((paragraph) => <p>{paragraph[locale]}</p>)}
|
||||
|
||||
<h2>{t('work.highlights')}</h2>
|
||||
<ul class="highlights">
|
||||
{project.highlights.map((h) => <li>{h[locale]}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<aside class="meta" aria-label={t('work.stack')}>
|
||||
<h2>{t('work.stack')}</h2>
|
||||
<ul class="meta__stack">
|
||||
{project.stack.map((tech) => (
|
||||
<li><TechIcon name={tech.name} icon={tech.icon} size={16} /></li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{project.links.length > 0 && (
|
||||
<>
|
||||
<h2>{t('work.links')}</h2>
|
||||
<ul class="meta__links">
|
||||
{project.links.map((link) => (
|
||||
<li><Link url={link.url} blank>{link.label}</Link></li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{project.slug === 'nova-design-system' && <ComponentShowcase />}
|
||||
|
||||
<footer class="next">
|
||||
<Button href={localizePath('/work', locale)} type="ghost">{t('work.backToList')}</Button>
|
||||
<Button href={localizePath(`/work/${next.slug}`, locale)} type="secondary">
|
||||
{t('work.next')} : {next.name}
|
||||
</Button>
|
||||
</footer>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.crumbs { padding-top: var(--nds-spacing-lg); }
|
||||
|
||||
.head {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: var(--nds-spacing-md);
|
||||
padding-block: clamp(32px, 6vh, 64px) clamp(24px, 4vh, 48px);
|
||||
}
|
||||
|
||||
.head__title {
|
||||
@include type.text-5xl;
|
||||
font-size: clamp(2rem, 5.5vw, 3.4rem);
|
||||
}
|
||||
|
||||
.head__tagline {
|
||||
@include type.text-lg;
|
||||
font-weight: 400;
|
||||
color: var(--nds-primary);
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: clamp(24px, 5vw, 64px);
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 840px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-md);
|
||||
|
||||
p {
|
||||
@include type.text-base;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--nds-neutral);
|
||||
max-width: 68ch;
|
||||
}
|
||||
h2 {
|
||||
@include type.text-2xl;
|
||||
margin-top: var(--nds-spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.highlights {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
margin: 0;
|
||||
padding-left: 1.2em;
|
||||
|
||||
li {
|
||||
@include type.text-base;
|
||||
font-size: 1rem;
|
||||
color: var(--nds-neutral);
|
||||
&::marker { color: var(--nds-primary); }
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-md);
|
||||
padding: var(--nds-spacing-lg);
|
||||
background: color-mix(in srgb, var(--nds-surface) 88%, transparent);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
|
||||
h2 {
|
||||
@include type.text-label;
|
||||
color: var(--nds-neutral);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
@media (max-width: 840px) { position: static; }
|
||||
}
|
||||
|
||||
.meta__stack,
|
||||
.meta__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.next {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--nds-spacing-md);
|
||||
padding-block: clamp(40px, 7vh, 80px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import ProjectCard from '../components/ProjectCard.astro';
|
||||
import { Breadcrumb, BreadcrumbItem } from '@unkn0wndo3s/nova-design-system';
|
||||
import { projects } from '../data/projects';
|
||||
import { breadcrumbLd } from '../lib/seo';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: localizePath('/', locale) },
|
||||
{ name: t('nav.work'), url: localizePath('/work', locale) },
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title={t('meta.work.title')} description={t('meta.work.description')} jsonLd={[breadcrumbLd(crumbs)]}>
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem href={crumbs[0]!.url}>{crumbs[0]!.name}</BreadcrumbItem>
|
||||
<BreadcrumbItem current>{crumbs[1]!.name}</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</nav>
|
||||
|
||||
<header class="page-head">
|
||||
<h1 class="page-title">{t('work.title')}</h1>
|
||||
<p class="page-lead">{t('work.lead')}</p>
|
||||
</header>
|
||||
|
||||
<div class="grid" data-belt-anchor>
|
||||
{projects.map((project, i) => (
|
||||
<ProjectCard project={project} index={i} headingLevel="h2" />
|
||||
))}
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.crumbs { padding-top: var(--nds-spacing-lg); }
|
||||
|
||||
.page-head {
|
||||
padding-block: clamp(32px, 6vh, 64px);
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-md);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include type.text-5xl;
|
||||
font-size: clamp(2rem, 5.5vw, 3.4rem);
|
||||
}
|
||||
|
||||
.page-lead {
|
||||
@include type.text-lg;
|
||||
font-weight: 400;
|
||||
max-width: 60ch;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
|
||||
gap: var(--nds-spacing-lg);
|
||||
padding-bottom: var(--nds-spacing-2xl);
|
||||
}
|
||||
</style>
|
||||
+6
-3
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"],
|
||||
"compilerOptions": {
|
||||
"types": ["three"]
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", ".astro/types.d.ts"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user