feat: add contact, index, and work pages with responsive design and functionality

- Implemented a contact page with a form for inquiries and direct links to social profiles.
- Created an index page showcasing personal profile, capabilities, featured projects, and tech stack.
- Developed dynamic project detail pages with breadcrumb navigation and project-specific information.
- Added a projects overview page displaying all projects in a grid layout.
- Introduced global styles and typography mixins for consistent design across components.
This commit is contained in:
LOUIS POTEVIN
2026-06-24 14:21:30 +02:00
parent 15bc86b5a6
commit fa37f0358d
22 changed files with 2662 additions and 1 deletions
+105 -1
View File
@@ -1,2 +1,106 @@
# portfolio
# louis-potevin.dev
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.
---
## Getting Started
```bash
npm install
npm run dev # dev server → http://localhost:4321
npm run build # static build → ./dist
npm run preview # preview the build
```
> **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.
---
## Edit Before Going Live
All modifiable content is centralized. Values to be confirmed are
marked with `TODO` in the code :
- **`src/data/site.ts`** — name, role, email, external links (Gitea, GitHub,
npm, LinkedIn). In particular, verify :
- **`src/data/projects.ts`** — la liste des projets (les « stations »).
Chaque entrée : résumé, contexte, contributions, stack, liens.
---
## Structure
```
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
```
## Design system
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`).
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`).
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`.
## 3D Background
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.
- **Without WebGL** : the canvas hides cleanly, the site remains readable.
## SEO
Meticulous search engine optimization, pre-configured:
- 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`.
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.
+12
View File
@@ -0,0 +1,12 @@
<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>

After

Width:  |  Height:  |  Size: 608 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://louis-potevin.dev/sitemap-index.xml
+23
View File
@@ -0,0 +1,23 @@
---
// 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;
---
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
role="img"
aria-label="LinkedIn"
>
<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>
</svg>
+39
View File
@@ -0,0 +1,39 @@
---
export interface Props {
index?: string; // ex. "01"
label: string;
}
const { index, label } = 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>
</p>
<style lang="scss">
@use 'styles/type' as *;
.eyebrow {
display: flex;
align-items: center;
gap: var(--nds-spacing-sm);
margin: 0 0 var(--nds-spacing-md);
}
.eyebrow__idx {
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>
+104
View File
@@ -0,0 +1,104 @@
---
import { Card, Badge, Link } from '@unkn0wndo3s/nova-design-system';
import { ArrowUpRight } from '@lucide/astro';
import TechIcon from './TechIcon.astro';
import { statusMeta, type Project } from '../data/projects';
export interface Props {
project: Project;
}
const { project } = Astro.props;
const status = statusMeta[project.status];
const href = `/work/${project.slug}`;
---
<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}>
Voir le projet
<ArrowUpRight size={15} />
</Link>
</div>
</Card>
</article>
<style lang="scss">
@use 'styles/type' as *;
.proj :global(.card) {
width: 100%;
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));
box-shadow: var(--nds-shadow-lg);
}
.proj :global(.card__body) {
gap: var(--nds-spacing-sm);
height: 100%;
}
.proj__meta {
display: flex;
align-items: center;
gap: var(--nds-spacing-sm);
}
.proj__code {
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 {
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;
}
</style>
+134
View File
@@ -0,0 +1,134 @@
---
import { Gitea, Github, Npm } from 'simple-icons-astro';
import { Mail } from '@lucide/astro';
import BrandLinkedin from './BrandLinkedin.astro';
import { site } from '../data/site';
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>
</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>
</a>
))
}
<a class="footer__link" href={site.links.linkedin.url} target="_blank" rel="noopener noreferrer" title="LinkedIn">
<BrandLinkedin size={18} />
<span>LinkedIn</span>
</a>
<a class="footer__link" href={`mailto:${site.email}`} title="E-mail">
<Mail size={18} />
<span>E-mail</span>
</a>
</nav>
</div>
<div class="footer__base">
<span>© {year} {site.name}</span>
<span class="footer__sep" aria-hidden="true">·</span>
<span>Bâti avec Astro + Nova Design System</span>
</div>
</footer>
<style lang="scss">
@use 'styles/type' as *;
.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);
}
.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;
}
.footer__role {
@include text-sm;
color: var(--nds-neutral);
margin: 2px 0 0;
}
.footer__links {
display: flex;
flex-wrap: wrap;
gap: var(--nds-spacing-md);
}
.footer__link {
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);
}
}
.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;
color: var(--nds-neutral);
font-family: var(--nds-font-mono);
}
.footer__sep {
opacity: 0.5;
}
</style>
+57
View File
@@ -0,0 +1,57 @@
---
import { Navbar, Button } from '@unkn0wndo3s/nova-design-system';
import { Send } from '@lucide/astro';
const path = Astro.url.pathname.replace(/\/$/, '') || '/';
const links = [
{ href: '/', label: 'Accueil' },
{ href: '/work', label: 'Projets' },
{ href: '/about', label: 'À propos' },
{ href: '/contact', label: 'Contact' },
];
const isActive = (href: string) =>
href === '/' ? path === '/' : path === href || path.startsWith(href + '/');
---
<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}
</a>
))
}
<Button slot="right" type="primary" size="sm" href="/contact">
<Send slot="icon-left" size={15} />
Établir le contact
</Button>
</Navbar>
</div>
<style lang="scss">
.site-nav {
position: sticky;
top: 0;
z-index: 60;
// 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);
}
}
@media (max-width: 620px) {
.site-nav :global(.navbar__links) {
display: none;
}
}
</style>
+51
View File
@@ -0,0 +1,51 @@
---
// Persistent space background. Placed in the layout → loaded on all pages.
// Content remains 100% readable and functional even if WebGL fails (decorative).
---
<canvas id="space-canvas" class="space-canvas" aria-hidden="true"></canvas>
<style lang="scss">
.space-canvas {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
z-index: 0;
display: block;
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';
}
}
</script>
+52
View File
@@ -0,0 +1,52 @@
---
// Monochrome tech icon (inherits `color` via fill=currentColor).
import {
Typescript,
Javascript,
Postgresql,
Docker,
Astro as AstroIcon,
Sass,
Gitea,
Linux,
Cloudflare,
Threedotjs,
Rust,
Python,
Npm,
Github,
Svelte,
Git,
Nodedotjs,
} from 'simple-icons-astro';
export interface Props {
name: 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,
};
const Icon = map[name];
---
{Icon && <Icon size={size} fill="currentColor" />}
+250
View File
@@ -0,0 +1,250 @@
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.
// ─────────────────────────────────────────────────────────────────────────────
export interface SceneHandle {
/** Kept for compatibility — no-op (no more impact/flame effect). */
setImpact(value: number): void;
destroy(): void;
}
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;
}
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);
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, 18);
// ── Lighting (cold, consistent with Nova tokens) ───────────────────────────
const ambient = new THREE.AmbientLight(0x16323a, 1.0);
scene.add(ambient);
const key = new THREE.DirectionalLight(COLORS.starCool, 0.9);
key.position.set(-6, 5, 9);
scene.add(key);
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;
}
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);
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,
transparent: true,
opacity: 0.2,
blending: THREE.AdditiveBlending,
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);
}
rockGeo.computeVertexNormals();
const rockMat = new THREE.MeshStandardMaterial({
color: COLORS.rock,
emissive: COLORS.rim,
emissiveIntensity: 0.12,
roughness: 0.92,
metalness: 0.12,
flatShading: true,
});
const rock = new THREE.Mesh(rockGeo, rockMat);
asteroid.add(rock);
// 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,
}),
);
glow.scale.set(7.5, 7.5, 1);
asteroid.add(glow);
// ambient position: top right, set back
asteroid.position.set(6.5, 4.5, -2);
scene.add(asteroid);
// ── State & loop ───────────────────────────────────────────────────────────
const pointer = { x: 0, y: 0, tx: 0, ty: 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 onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function onVisibility() {
running = document.visibilityState === 'visible';
if (running) loop();
}
window.addEventListener('resize', onResize);
window.addEventListener('pointermove', onPointer, { passive: true });
document.addEventListener('visibilitychange', onVisibility);
const clock = new THREE.Clock();
let rafId = 0;
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);
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();
});
},
};
}
+179
View File
@@ -0,0 +1,179 @@
// ─────────────────────────────────────────────────────────────────────────────
// Projects — real content. Edit freely.
// `icon` matches a simple-icons-astro component (using PascalCase elsewhere).
// ─────────────────────────────────────────────────────────────────────────────
export type ProjectStatus = 'live' | 'maintenu' | 'archive' | 'r&d';
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;
status: ProjectStatus;
featured: boolean;
summary: string;
context: string[];
contributions: string[];
stack: { icon: string; label: string }[];
links: ProjectLink[];
}
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',
status: 'maintenu',
featured: true,
summary:
'The design system that powers this website and will power my other projects. 29 Astro components across six families, a complete set of tokens (color, typography, spacing, radius), and a light/dark theme managed via CSS variables.',
context: [
'Designed for clean, technical interfaces: mono fonts for headings, sans-serif for body text.',
'Distributed under the npm scope @unkn0wndo3s, automatically published via a CI pipeline.',
'License: free to use, resale of the design system as a standalone product is prohibited.',
],
contributions: [
'Architecture of tokens and components (Custom Elements, typed events, slots).',
'Gitea Actions CI/CD pipeline for automated npm publishing and documentation deployment.',
],
stack: [
{ icon: 'astro', label: 'Astro' },
{ icon: 'typescript', label: 'TypeScript' },
{ icon: 'sass', label: 'Sass' },
{ icon: 'gitea', label: 'Gitea CI' },
],
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' },
],
},
{
slug: 'nova-infra',
designation: '02',
title: 'Self-hosted Infrastructure & Deployment',
tagline: 'Containerized services, CI/CD, and secured ingress on a fully controlled infrastructure.',
period: '2025 — ongoing',
role: 'Design & operations',
status: 'live',
featured: true,
summary:
'A comprehensive self-hosted infrastructure: Git hosting, secret management, and application services, all containerized with Docker and continuously deployed.',
context: [
'Fully containerized with Docker, orchestrated, and reproducible.',
'Certificates and DNS managed through Cloudflare.',
'Services cleanly exposed using a reverse proxy.',
],
contributions: [
'Setup of a self-hosted Gitea instance with a Postgres backend and action runners.',
'End-to-end automated CI/CD pipelines (build, test, publish).',
'Day-to-day operations: Docker networks, volume mounts, DNS, and diagnostics.',
],
stack: [
{ icon: 'docker', label: 'Docker' },
{ icon: 'linux', label: 'Linux' },
{ icon: 'cloudflare', label: 'Cloudflare Tunnel' },
{ icon: 'gitea', label: 'Gitea' },
],
links: [{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn', icon: 'gitea' }],
},
{
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',
status: 'live',
featured: true,
summary:
'A static multi-page portfolio website. The content is entirely built using Nova components, featuring a subtle, discrete Three.js space background.',
context: [
'Three.js space background: starfield, nebula, and an ambient asteroid.',
'UI entirely composed using the in-house design system.',
'Astro multi-page setup with Static Site Generation (SSG), optimized for SEO.',
],
contributions: [
'Full integration using Nova tokens and components.',
'Adherence to prefers-reduced-motion and graceful degradation when WebGL is unavailable.',
'Meticulous SEO: metadata, structured data, and sitemap generation.',
],
stack: [
{ icon: 'astro', label: 'Astro' },
{ icon: 'typescript', label: 'TypeScript' },
{ icon: 'sass', label: 'Sass' },
],
links: [{ label: 'Unkn0wn Projects', url: 'https://louis-potevin.dev/', icon: 'astro' }],
},
{
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:
'A file organizer tool that currently runs in Java on Windows. It is being converted to Rust to achieve cross-platform Linux compatibility and optimize file search performance.',
context: [
'Current version: operational in Java, targeting Windows.',
'Gradual porting to Rust for performance gains and low-level control.',
],
contributions: [
'Rewriting sorting logic from Java to Rust.',
'Setting objectives for Linux compatibility and file search optimization.',
],
stack: [
{ icon: 'rust', label: 'Rust' },
{ icon: 'linux', label: 'Linux (upcoming)' },
],
links: [],
},
{
slug: 'llm-tooling',
designation: '05',
title: 'LLM Training & Inference',
tagline: 'Small language model trained on a Reddit conversation dataset.',
period: '2025 — ongoing',
role: 'Development',
status: 'r&d',
featured: false,
summary:
'A language model training and inference project running on a dedicated machine. The model is trained on a custom dataset of Reddit conversations. Development is ongoing.',
context: [
'Execution on a dedicated workstation optimized for training and inference.',
'Dataset: conversation threads sourced from Reddit.',
],
contributions: [
'Assembling and preprocessing the conversation dataset.',
'Setting up the training and inference loop, currently iterating on results.',
],
stack: [
{ icon: 'python', label: 'Python' },
{ icon: 'linux', label: 'Linux' },
],
links: [],
},
];
export const featuredProjects = projects.filter((p) => p.featured);
export const statusMeta: Record<ProjectStatus, { label: string; tone: 'success' | 'primary' | 'neutral' | 'warning' }> = {
live: { label: 'Live', tone: 'success' },
maintenu: { label: 'Maintained', tone: 'primary' },
'r&d': { label: 'In Progress', tone: 'warning' },
archive: { label: 'Archived', tone: 'neutral' },
};
+37
View File
@@ -0,0 +1,37 @@
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)
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",
},
},
} as const;
export type SiteLink = { label: string; url: string; icon: string };
+169
View File
@@ -0,0 +1,169 @@
---
import '@unkn0wndo3s/nova-design-system/styles';
import '../styles/global.scss';
import SpaceBackground from '../components/SpaceBackground.astro';
import SiteNav from '../components/SiteNav.astro';
import SiteFooter from '../components/SiteFooter.astro';
import { site } from '../data/site';
export interface Props {
title?: string;
description?: string;
/** Page-specific keywords (appended to the global keywords list). */
keywords?: string[];
}
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 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 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'],
};
---
<!doctype html>
<html lang="en" 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" />
<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} />
<meta property="og:type" content="website" />
<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: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 name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImage} />
<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)} />
</head>
<body>
<SpaceBackground />
<a class="skip" href="#content">Skip to content</a>
<SiteNav />
<main id="content" class="shell">
<slot />
</main>
<SiteFooter />
<style lang="scss">
.shell {
position: relative;
z-index: 10;
max-width: 1120px;
margin-inline: auto;
padding-inline: clamp(20px, 5vw, 56px);
}
.skip {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
padding: var(--nds-spacing-xs) var(--nds-spacing-md);
background: var(--nds-primary);
color: var(--nds-on-primary);
border-radius: var(--nds-radius-md);
&:focus {
left: var(--nds-spacing-md);
top: var(--nds-spacing-md);
}
}
</style>
</body>
</html>
+315
View File
@@ -0,0 +1,315 @@
---
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' },
];
---
<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>
+297
View File
@@ -0,0 +1,297 @@
---
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 },
];
---
<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>
+418
View File
@@ -0,0 +1,418 @@
---
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: 'typescript', label: 'TypeScript' },
{ icon: 'javascript', label: 'JavaScript' },
{ icon: 'sass', label: 'Sass' },
{ icon: 'postgresql', label: 'PostgreSQL' },
{ icon: 'docker', label: 'Docker' },
{ icon: 'linux', label: 'Linux' },
{ icon: 'cloudflare', label: 'Cloudflare' },
{ icon: 'gitea', label: 'Gitea CI/CD' },
{ icon: 'rust', label: 'Rust' },
{ icon: 'python', label: 'Python' },
{ icon: 'git', label: 'Git' },
];
---
<BaseLayout>
{/* ─────────────────────────── 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>
+305
View File
@@ -0,0 +1,305 @@
---
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';
export function getStaticPaths() {
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>
+62
View File
@@ -0,0 +1,62 @@
---
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.';
---
<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>
+5
View File
@@ -0,0 +1,5 @@
// 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";
+44
View File
@@ -0,0 +1,44 @@
@use 'styles/type' as *;
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
margin: 0;
min-height: 100vh;
color: var(--nds-text);
font-family: var(--nds-font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background-color: var(--nds-background);
}
::selection {
background: color-mix(in srgb, var(--nds-primary) 35%, transparent);
color: var(--nds-text);
}
* {
scrollbar-width: thin;
scrollbar-color: var(--nds-border-strong) transparent;
}
a {
color: inherit;
}
img {
max-width: 100%;
display: block;
}
:focus-visible {
outline: none;
}