chore: scaffold Astro 5 project with token/style layer

- Astro 5 static config (trailing-slash canonical, i18n en-root/no-prefixed)
- Tailwind 4 over CSS custom-property tokens (both themes via [data-theme])
- design tokens: colour/spacing/type/radius/motion per Phase 1 specs
- motion layer with reduced-motion overrides
- strict TypeScript, ESLint flat config, Prettier
- self-hosted variable fonts (Space Grotesk / Inter / JetBrains Mono)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-04 00:51:15 +02:00
parent 1f695d3932
commit ff27b918ae
12 changed files with 6712 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Normalise line endings to LF in the repo; check out native on Windows.
* text=auto eol=lf
# Binary assets — never touch.
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.avif binary
*.pdf binary
*.woff binary
*.woff2 binary
*.ico binary
# Shell scripts and container files must stay LF even on Windows checkout.
*.sh text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
+6
View File
@@ -0,0 +1,6 @@
dist/
.astro/
node_modules/
pnpm-lock.yaml
public/
*.pdf
+8
View File
@@ -0,0 +1,8 @@
{
"printWidth": 100,
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
"overrides": [{ "files": "*.astro", "options": { "parser": "astro" } }]
}
+41
View File
@@ -0,0 +1,41 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath } from 'node:url';
// Canonical origin used for absolute URLs (sitemap, OG, hreflang, JSON-LD).
// Overridable at build time so staging emits its own origin. See DOCKER_SPEC §3.
const SITE = process.env.PUBLIC_SITE_URL ?? 'https://cesnimda.co.uk';
const r = (p) => fileURLToPath(new URL(p, import.meta.url));
// https://astro.build/config
export default defineConfig({
site: SITE,
// ROUTING_SPEC §1: trailing-slash canonical, directory output.
trailingSlash: 'always',
build: { format: 'directory', inlineStylesheets: 'auto' },
// I18N_SPEC §1: en at root, no prefixed. Localised slugs are handled by explicit
// page files + the slug map module (ARCHITECTURE A5), not automatic locale routing.
i18n: {
defaultLocale: 'en',
locales: ['en', 'no'],
routing: { prefixDefaultLocale: false, redirectToDefaultLocale: false },
},
prefetch: false,
compressHTML: true,
vite: {
plugins: [tailwindcss()],
resolve: {
alias: {
'@': r('./src'),
'@i18n': r('./src/i18n'),
'@components': r('./src/components'),
'@layouts': r('./src/layouts'),
'@styles': r('./src/styles'),
'@scripts': r('./src/scripts'),
'@lib': r('./src/lib'),
},
},
},
});
+24
View File
@@ -0,0 +1,24 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import astro from 'eslint-plugin-astro';
export default [
{ ignores: ['dist/', '.astro/', 'node_modules/', '**/*.d.ts'] },
js.configs.recommended,
...tseslint.configs.recommended,
...astro.configs.recommended,
{
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': 'error',
},
},
{
// Behaviour modules run in the browser.
files: ['src/scripts/**/*.ts'],
languageOptions: { globals: { window: 'readonly', document: 'readonly', localStorage: 'readonly' } },
},
];
+44
View File
@@ -0,0 +1,44 @@
{
"name": "resumesite",
"type": "module",
"version": "0.1.0",
"private": true,
"description": "Connor Babbington — bilingual portfolio (Astro static site)",
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@9.15.9",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"check": "astro check && tsc --noEmit",
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fontsource-variable/inter": "^5.1.1",
"@fontsource-variable/jetbrains-mono": "^5.1.2",
"@fontsource-variable/space-grotesk": "^5.1.1",
"@resvg/resvg-js": "^2.6.2",
"astro": "^5.2.5",
"satori": "^0.12.1",
"sharp": "^0.33.5"
},
"devDependencies": {
"@eslint/js": "^9.20.0",
"@tailwindcss/vite": "^4.0.6",
"@types/node": "^22.13.1",
"eslint": "^9.20.1",
"eslint-plugin-astro": "^1.3.1",
"typescript-eslint": "^8.24.0",
"prettier": "^3.5.1",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.6",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}
+6113
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_SITE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+181
View File
@@ -0,0 +1,181 @@
/* Entry stylesheet. Order: Tailwind, tokens, theme mapping, base, motion. */
@import 'tailwindcss';
@import './tokens.css';
@import './motion.css';
/*
Map design tokens into Tailwind's theme namespaces with `inline` so generated
utilities reference the live CSS variables — theme switching works at runtime
without regenerating classes (ARCHITECTURE A4). Tailwind's default 0.25rem
spacing scale already matches our 8pt grid, so no spacing override is needed.
*/
@theme inline {
--color-surface-0: var(--surface-0);
--color-surface-1: var(--surface-1);
--color-surface-2: var(--surface-2);
--color-ink: var(--ink);
--color-ink-muted: var(--ink-muted);
--color-ink-faint: var(--ink-faint);
--color-line: var(--border);
--color-line-strong: var(--border-strong);
--color-accent: var(--accent);
--color-accent-ink: var(--accent-ink);
--color-amber: var(--amber);
--color-danger: var(--danger);
--font-display: var(--font-display);
--font-body: var(--font-body);
--font-mono: var(--font-mono);
--text-display: var(--text-display);
--text-h1: var(--text-h1);
--text-h2: var(--text-h2);
--text-h3: var(--text-h3);
--text-h4: var(--text-h4);
--text-body-lg: var(--text-body-lg);
--text-body: var(--text-body);
--text-small: var(--text-small);
--text-mono-label: var(--text-mono-label);
--text-mono-meta: var(--text-mono-meta);
--radius-xs: var(--radius-xs);
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-full: var(--radius-full);
}
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
/* Anchor scroll offset = header + breathing room (SPACING_SYSTEM §6). */
scroll-padding-top: calc(var(--header-h) + var(--space-5));
}
body {
margin: 0;
background-color: var(--surface-0);
color: var(--ink);
font-family: var(--font-body);
font-size: var(--text-body);
line-height: var(--leading-relaxed);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100vh;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-display);
font-weight: 600;
line-height: var(--leading-snug);
margin: 0;
text-wrap: balance;
}
p {
margin: 0;
text-wrap: pretty;
}
a {
color: inherit;
text-decoration: none;
}
img,
svg,
picture {
display: block;
max-width: 100%;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
}
/* Visible focus in both themes (COLOUR_SYSTEM §5, TYPOGRAPHY §4). */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
:focus:not(:focus-visible) {
outline: none;
}
::selection {
background: var(--accent);
color: var(--accent-ink);
}
}
@layer components {
/* Faint blueprint dot-grid — hero + section headers only (DESIGN_SYSTEM §4). */
.dot-grid {
background-image: radial-gradient(var(--dot-grid) 1px, transparent 1px);
background-size: 24px 24px;
}
/* Single permitted gradient: hero accent halo (COLOUR_SYSTEM §4). */
.hero-halo {
background: radial-gradient(55% 55% at 28% 18%, var(--halo), transparent 70%);
}
/* Mono metadata label — the identity voice (TYPOGRAPHY §2). */
.mono-label {
font-family: var(--font-mono);
font-size: var(--text-mono-label);
font-weight: 500;
letter-spacing: var(--tracking-label);
text-transform: uppercase;
color: var(--ink-faint);
}
.mono-meta {
font-family: var(--font-mono);
font-size: var(--text-mono-meta);
color: var(--ink-muted);
}
/* Constrained prose measure for long-form case-study body (TYPOGRAPHY §3). */
.prose-measure {
max-width: var(--measure);
}
/* Skip-link: off-screen until focused (MICRO_INTERACTIONS, a11y). */
.skip-link {
position: fixed;
top: var(--space-3);
left: var(--space-3);
z-index: 100;
padding: var(--space-2) var(--space-4);
background: var(--accent);
color: var(--accent-ink);
border-radius: var(--radius-sm);
font-weight: 600;
transform: translateY(-150%);
transition: transform var(--dur-quick) var(--ease-out);
}
.skip-link:focus {
transform: translateY(0);
}
}
/* High-contrast / forced-colors: drop decorative texture (COLOUR_SYSTEM §5). */
@media (forced-colors: active) {
.dot-grid,
.hero-halo {
background: none;
}
}
+131
View File
@@ -0,0 +1,131 @@
/*
Motion layer (ANIMATION_SPEC). Only transform / opacity / stroke-dashoffset animate.
Reduced-motion is a single global override — not per-component JS (ANIMATION_SPEC §1).
*/
/* ---- Reveal (A2): opacity + 12px rise, staggered by --reveal-index ---- */
[data-reveal] {
opacity: 0;
transform: translateY(12px);
transition:
opacity var(--dur-entrance) var(--ease-out),
transform var(--dur-entrance) var(--ease-out);
transition-delay: calc(var(--reveal-index, 0) * 60ms);
}
[data-reveal].is-revealed {
opacity: 1;
transform: none;
}
/* ---- Trace draw (A1) ---- */
@keyframes trace-draw {
from {
stroke-dashoffset: var(--trace-len, 600);
}
to {
stroke-dashoffset: 0;
}
}
@keyframes dot-blink {
0%,
100% {
opacity: 1;
}
40% {
opacity: 0.2;
}
}
.trace-line {
stroke-dasharray: var(--trace-len, 600);
stroke-dashoffset: var(--trace-len, 600);
animation: trace-draw var(--dur-signature) var(--ease-in-out) forwards;
}
[data-seen] .trace-line {
animation: none;
stroke-dashoffset: 0;
}
.trace-dot {
animation: dot-blink 900ms var(--ease-out) 700ms 1;
}
/* ---- Status chip pulse (A4): 2 iterations, ~1Hz ---- */
@keyframes chip-pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.4);
opacity: 0.55;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.is-revealed .status-dot--active {
animation: chip-pulse 900ms var(--ease-out) 2;
}
/* ---- Timeline spine grow (A8) ---- */
.timeline-spine {
transform: scaleY(0);
transform-origin: top;
transition: transform var(--dur-entrance) var(--ease-out);
}
.is-revealed .timeline-spine {
transform: scaleY(1);
}
/* ---- 404 fray (A10) ---- */
@keyframes fray {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 8;
}
}
/* ---- Check-draw for form success (A9) ---- */
@keyframes check-draw {
to {
stroke-dashoffset: 0;
}
}
.check-path {
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: check-draw var(--dur-standard) var(--ease-out) forwards;
}
/* ---- View-transition language/route cross-fade (A6/A7) ---- */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: var(--dur-quick);
}
/* ============================================================
Reduced motion: durations collapse, translations disabled,
animated states render at their final frame (no content lost).
============================================================ */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
[data-reveal] {
opacity: 1;
transform: none;
}
.trace-line {
stroke-dashoffset: 0;
}
.timeline-spine {
transform: scaleY(1);
}
}
+113
View File
@@ -0,0 +1,113 @@
/*
Design tokens — single source of truth (Phase 1: COLOUR/SPACING/TYPOGRAPHY/MOTION specs).
Dark is the design-lead theme (defaults on :root); light overrides under [data-theme='light'].
Theme switching is a pure attribute swap — no JS re-render (ARCHITECTURE A4).
*/
:root {
color-scheme: dark;
/* ---- Surfaces & ink (COLOUR_SYSTEM §2, dark) ---- */
--surface-0: #0b0e14;
--surface-1: #11151d;
--surface-2: #171c26;
--ink: #e8ecf2;
--ink-muted: #9aa4b2;
--ink-faint: #5c6675;
--border: rgba(232, 236, 242, 0.08);
--border-strong: rgba(232, 236, 242, 0.16);
/* ---- Accent & functional (status-LED usage only) ---- */
--accent: #3ecfae;
--accent-ink: #062a22;
--amber: #e5a93d;
--amber-chip-bg: rgba(229, 169, 61, 0.1);
--danger: #e5606b;
/* ---- Effects ---- */
--halo: rgba(62, 207, 174, 0.07); /* single permitted gradient, hero only */
--dot-grid: rgba(232, 236, 242, 0.05);
--shadow-card: none; /* dark uses border-strong instead of shadow */
--shadow-overlay: 0 24px 60px -20px rgba(0, 0, 0, 0.7);
/* ---- Radius (DESIGN_SYSTEM §3) ---- */
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-full: 9999px;
/* ---- Spacing scale, 8pt base (SPACING_SYSTEM §1) ---- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 64px;
--space-9: 96px;
--space-10: 128px;
/* ---- Type families (TYPOGRAPHY_GUIDE §1) ---- */
--font-display: 'Space Grotesk Variable', 'Segoe UI', system-ui, sans-serif;
--font-body: 'Inter Variable', 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono Variable', ui-monospace, 'Cascadia Code', monospace;
/* ---- Type scale (TYPOGRAPHY_GUIDE §2), fluid where responsive ---- */
--text-display: clamp(2.5rem, 1.8rem + 3.1vw, 3.8125rem); /* 40 → 61 */
--text-h1: clamp(2.125rem, 1.6rem + 2.3vw, 3.0625rem); /* 34 → 49 */
--text-h2: clamp(1.75rem, 1.4rem + 1.5vw, 2.4375rem); /* 28 → 39 */
--text-h3: clamp(1.5rem, 1.3rem + 0.9vw, 1.9375rem); /* 24 → 31 */
--text-h4: 1.25rem; /* 20 */
--text-body-lg: 1.125rem; /* 18 */
--text-body: 1rem; /* 16 */
--text-small: 0.875rem; /* 14 */
--text-mono-label: 0.75rem; /* 12 */
--text-mono-meta: 0.8125rem; /* 13 */
--leading-tight: 1.1;
--leading-snug: 1.2;
--leading-normal: 1.5;
--leading-relaxed: 1.65;
--tracking-label: 0.08em;
--measure: 68ch;
/* ---- Motion (MOTION_GUIDELINES §2) ---- */
--dur-instant: 80ms;
--dur-quick: 160ms;
--dur-standard: 240ms;
--dur-entrance: 400ms;
--dur-signature: 700ms;
--ease-out: cubic-bezier(0.2, 0, 0, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
/* ---- Layout ---- */
--content-max: 1200px;
--header-h: 72px;
--header-h-scrolled: 56px;
}
:root[data-theme='light'] {
color-scheme: light;
--surface-0: #fafaf8;
--surface-1: #ffffff;
--surface-2: #f1f2ef;
--ink: #171a20;
--ink-muted: #555e6b;
--ink-faint: #8a93a0;
--border: rgba(23, 26, 32, 0.1);
--border-strong: rgba(23, 26, 32, 0.2);
--accent: #0e8c72;
--accent-ink: #ffffff;
--amber: #8a6114;
--amber-chip-bg: #f5e5c2;
--danger: #b3323e;
--halo: rgba(14, 140, 114, 0.06);
--dot-grid: rgba(23, 26, 32, 0.05);
--shadow-card: 0 2px 4px rgba(23, 26, 32, 0.06);
--shadow-overlay: 0 24px 60px -20px rgba(23, 26, 32, 0.25);
}
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"allowJs": false,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@i18n/*": ["src/i18n/*"],
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@styles/*": ["src/styles/*"],
"@scripts/*": ["src/scripts/*"],
"@lib/*": ["src/lib/*"]
}
},
"include": [".astro/types.d.ts", "src/**/*", "*.config.*"],
"exclude": ["dist", "node_modules"]
}