docs: establish approved Phase 1 design + Phase 2 spec as project foundation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# ANIMATION_SPEC.md
|
||||
Technical realisation of the Phase 1 motion system (MOTION_GUIDELINES + ANIMATION_CONCEPTS A1–A10). Library decision: **none** (TECH_SPEC §4) — CSS-first + the shared observer module.
|
||||
|
||||
---
|
||||
|
||||
## 1. Token implementation
|
||||
|
||||
Motion tokens are CSS custom properties (`--dur-instant: 80ms` … `--dur-signature: 700ms`, `--ease-out`, `--ease-in-out`) defined once in `motion.css`; every transition/keyframe references tokens. A single `@media (prefers-reduced-motion: reduce)` layer overrides: durations → 1ms (not 0 — ensures transitionend semantics still fire), translations → none, trace/pulse keyframes → final frame. No per-component reduced-motion logic anywhere.
|
||||
|
||||
## 2. Concept → mechanism map
|
||||
|
||||
| Concept (Phase 1) | Mechanism | Trigger |
|
||||
|---|---|---|
|
||||
| A1 Trace draw | Inline SVG, `stroke-dasharray/dashoffset` keyframe, dot `opacity` blink ×1 | CSS animation on load; `sessionStorage` flag adds `data-seen` on repeat visits → static |
|
||||
| A2 Section arrive | `[data-reveal]` class toggle → `opacity` + `translateY(12px)` transition; children staggered via `--reveal-index` custom property delay | `observer.ts`, threshold 0.2, once |
|
||||
| A3 Card focus | Pure CSS `:hover`/`:focus-visible` (border token, transform 4px, underline scale, arrow translate) | none (CSS) |
|
||||
| A4 Chip pulse | CSS keyframe ×2 iterations on `.status-active .dot`, started when `[data-reveal]` fires (animation-play-state pattern) | observer |
|
||||
| A5 Diagram flow | SVG with class-annotated nodes/edges; `:hover` on node → CSS sibling/descendant rules highlight edges, dim others via `opacity` | CSS only, `@media (hover: hover)` gate |
|
||||
| A6 Theme morph | Two-path SVG icon crossfade/morph via CSS; global colour transition on `data-theme` swap limited to a curated property list (avoid transitioning *everything*) | `theme.ts` |
|
||||
| A7 Language cross-fade | View Transitions API (same-document for the switch control; cross-document for navigation) with CSS-only fallback = normal navigation | browser-native, progressive |
|
||||
| A8 Timeline grow | Spine is a scaleY-transformed pseudo-element; dots opacity-staggered | observer |
|
||||
| A9 Form confirm | Panel swap with height-locked container; check icon `stroke-dashoffset` draw | `form.ts` on 2xx |
|
||||
| A10 404 fray | Static SVG with a short CSS keyframe (dash gap growth); reduced-motion static | load |
|
||||
| Header compress | `data-scrolled` boolean flag at >120px (observer sentinel element) → CSS height/blur transition. *Deviation from Phase 1:* linear scroll-mapping downgraded to a threshold toggle with smooth transition — visually near-identical, avoids scroll-linked JS entirely; scroll-driven CSS animations may restore true mapping as progressive enhancement where supported | observer |
|
||||
|
||||
## 3. Performance rules (enforceable)
|
||||
|
||||
- Animatable properties allow-list: `transform`, `opacity`, `stroke-dashoffset`, plus theme-swap colour transitions. Anything else fails review.
|
||||
- No `will-change` except trace + lightbox transitions (added/removed around use).
|
||||
- Observer instances: exactly one shared IntersectionObserver for reveals/spy + one sentinel for header. No scroll event listeners anywhere.
|
||||
- Lighthouse TBT budget (<100 ms) is the regression tripwire for animation JS.
|
||||
|
||||
## 4. Accessibility rules
|
||||
|
||||
- Reduced-motion layer per §1 — verified in Playwright with emulated `prefers-reduced-motion`.
|
||||
- Nothing flashes >3×/s (chip pulse is 2 iterations, ~1 Hz). No parallax beyond 4px.
|
||||
- Focus is never moved by animation; reveals never contain focusable elements while hidden (visibility handled so tab order is stable pre-reveal).
|
||||
- View-transition language swap must not discard focus position: switch control retains focus after navigation (tested).
|
||||
@@ -0,0 +1,105 @@
|
||||
# ARCHITECTURE.md
|
||||
System architecture for the bilingual portfolio. Companion detail specs: ROUTING_SPEC, COMPONENT_ARCHITECTURE, I18N_SPEC, DATA_MODEL, DOCKER_SPEC, DEPLOYMENT_SPEC.
|
||||
|
||||
---
|
||||
|
||||
## 1. System overview
|
||||
|
||||
```
|
||||
┌──────────────────────────── Connor's server ───────────────────────────┐
|
||||
Browser ── HTTPS ──► │ nginx (existing reverse proxy, TLS) │
|
||||
│ ├── cesnimda.co.uk/ → [site] nginx container (static) │
|
||||
│ ├── cesnimda.co.uk/api/contact → [contact-relay] .NET 9 container │
|
||||
│ └── git.cesnimda.uk → Gitea (existing) │
|
||||
│ │
|
||||
│ docker compose stack "resumesite": site + contact-relay (+ optional │
|
||||
│ analytics slot) │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Build time (CI, Gitea Actions):
|
||||
content (markdown/JSON, images) ─► Astro build ─► static dist/ ─► site image (nginx + dist)
|
||||
└► budgets/tests gate the publish
|
||||
```
|
||||
|
||||
**Architectural style: static-first with one stateless dynamic sidecar.** All pages are pre-rendered at build time (SSG). There is no runtime rendering, no database, no sessions. The only mutable runtime path is `POST /api/contact`, isolated in its own container so the site itself has zero attack surface beyond static file serving.
|
||||
|
||||
## 2. Repository layout (single repo: `ResumeSite` on git.cesnimda.uk)
|
||||
|
||||
```
|
||||
ResumeSite/
|
||||
├── site/ # Astro project
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ # route files (EN root) — see ROUTING_SPEC
|
||||
│ │ │ └── no/ # Norwegian route tree (localised slugs)
|
||||
│ │ ├── layouts/ # Base, Page, CaseStudy layouts
|
||||
│ │ ├── components/ # see COMPONENT_ARCHITECTURE
|
||||
│ │ │ ├── core/ # Header, Footer, Seo, ThemeScript, SkipLink
|
||||
│ │ │ ├── home/ # Hero, ProofStrip, SkillsGrid, ProjectCards, Timeline, ContactBand
|
||||
│ │ │ ├── case-study/ # TldrBox, MiniToc, DecisionList, Diagram, Gallery
|
||||
│ │ │ └── ui/ # Chip, Button, SplitButton, LangSwitch, ThemeToggle, Card
|
||||
│ │ ├── content/ # content collections (the bilingual data layer)
|
||||
│ │ │ ├── projects/{en,no}/...
|
||||
│ │ │ ├── experience/{en,no}/...
|
||||
│ │ │ └── profile/{en,no}.json
|
||||
│ │ ├── i18n/ # locales.ts, dictionary.{en,no}.ts, slugMap.ts, t() helper
|
||||
│ │ ├── styles/ # tokens.css (design tokens), base.css, motion.css
|
||||
│ │ ├── scripts/ # island helpers: observer.ts, lightbox.ts, nav.ts, form.ts, theme.ts
|
||||
│ │ └── assets/ # source images, diagrams (SVG), fonts
|
||||
│ ├── public/ # CVs (pdf), favicon, robots.txt
|
||||
│ └── (astro/tailwind/ts configs)
|
||||
├── relay/ # .NET 9 minimal API contact relay (own Dockerfile)
|
||||
├── deploy/ # compose files, nginx site config, env templates
|
||||
├── .gitea/workflows/ # CI/CD pipelines
|
||||
├── docs/ # this spec set + phase-1 design (moved into repo at Phase 3 start)
|
||||
└── tests/ # playwright e2e (vitest lives beside site/src)
|
||||
```
|
||||
|
||||
Rationale: one repo keeps content, design docs, infra and both services versioned together — appropriate for a single-owner product and makes the repo itself portfolio evidence.
|
||||
|
||||
## 3. Layered architecture of the site
|
||||
|
||||
```
|
||||
┌ Content layer markdown + JSON content collections, schema-validated (zod) — DATA_MODEL.md
|
||||
├ i18n layer locale config, slug map, typed dictionaries — I18N_SPEC.md
|
||||
├ Template layer layouts + components consume {content, locale} → HTML — COMPONENT_ARCHITECTURE.md
|
||||
├ Token layer CSS custom properties (themes) + Tailwind utilities
|
||||
├ Behaviour layer 5 small TS modules (theme, nav, observer, lightbox, form) as progressive enhancement
|
||||
└ Delivery layer static dist/ in nginx container; immutable-hashed assets; long-cache headers
|
||||
```
|
||||
|
||||
Dependency rule (enforced by review, mirrors the projects' clean-architecture story): **templates depend on content+i18n; content depends on nothing; behaviour modules depend on DOM contracts (data-attributes), never on content.** No component reads raw filesystem or hardcodes strings.
|
||||
|
||||
## 4. Key architectural decisions (ADR summary)
|
||||
|
||||
| # | Decision | Alternative rejected | Why |
|
||||
|---|---|---|---|
|
||||
| A1 | Full SSG, no SSR | Astro SSR/hybrid | No per-request data exists; SSR adds a Node runtime, cold-start surface, and ops burden for zero benefit |
|
||||
| A2 | Contact relay as separate container | SSR endpoint in site / SaaS form | Keeps site image immutable+static; isolates the only untrusted-input path; self-host principle |
|
||||
| A3 | Content collections in-repo | Headless CMS (Directus/Strapi/…) | Single author, git workflow is his CMS; removes a service, a database, and an attack surface |
|
||||
| A4 | CSS custom-property tokens, `data-theme` attribute switch | Tailwind `dark:` variants only | One token source drives both themes + allows no-flash inline theme script + inspectable tokens |
|
||||
| A5 | Localised slugs via explicit map module | File-name convention magic | The EN↔NO page mapping is a *contract* used by header switch, hreflang, sitemap — must be a single typed artifact testable in CI |
|
||||
| A6 | Behaviour as data-attribute modules | Framework islands | JS budget < 40 kB; three behaviours don't justify a runtime; escape hatch to Preact pre-authorised if one outgrows it |
|
||||
| A7 | Images processed at build | Runtime image service | Static hosting; fixed dimensions guarantee CLS 0; AVIF/WebP generated once |
|
||||
| A8 | CVs as versioned static assets in `public/` with stable URLs (`/cv/connor-babbington-cv-en.pdf`) | External drive links | Stable recruiter-shareable URLs; git-versioned; locale-matched download logic trivial |
|
||||
|
||||
## 5. Performance strategy (architecture-level)
|
||||
|
||||
- **Budget ownership:** budgets live in CI (TECH_SPEC §9); architecture guarantees they're achievable: no runtime framework, fonts subset + preloaded, hero has no image dependency for LCP (LCP element = H1 text), screenshots below fold lazy + aspect-locked.
|
||||
- **Caching:** hashed asset filenames → `Cache-Control: immutable, 1y`; HTML `no-cache` (revalidate) so deploys are instant; CV PDFs short cache (they update).
|
||||
- **Critical path:** inline theme-init script (tiny, blocking by design to prevent theme flash) + single CSS file per page; JS deferred entirely.
|
||||
- **Third-party requests: zero.** No CDN fonts, no analytics beacons (unless self-hosted later), no embeds. This is both performance and the GDPR/no-cookie-banner stance.
|
||||
|
||||
## 6. Security posture
|
||||
|
||||
- Static site: attack surface = nginx serving files. Headers set at the site-nginx layer: CSP (no inline scripts except the hashed theme script, no external origins), HSTS (at the edge proxy), X-Content-Type-Options, Referrer-Policy, Permissions-Policy minimal.
|
||||
- Relay: input validation + size caps, honeypot + minimum-fill-time trap, per-IP rate limit, SMTP credentials via env/secret (never in image), no persistence, structured minimal logging (no message bodies), runs as non-root, read-only filesystem.
|
||||
- Supply chain: pnpm lockfile committed, Renovate (already familiar from InboxIntel) on the repo, images pinned by digest in compose.
|
||||
|
||||
## 7. Failure modes & degradation
|
||||
|
||||
| Failure | Behaviour |
|
||||
|---|---|
|
||||
| Relay down | Form shows the fallback panel (direct e-mail + copy button) — conversion path survives |
|
||||
| JS disabled/broken | Full content readable; nav works (plain links); no lightbox/reveals — by design (progressive enhancement) |
|
||||
| Old cached HTML after deploy | HTML revalidates; hashed assets never mismatch |
|
||||
| Locale content missing (build-time) | CI fails the build (parity test) — never ships a broken language |
|
||||
@@ -0,0 +1,69 @@
|
||||
# COMPONENT_ARCHITECTURE.md
|
||||
|
||||
## 1. Principles
|
||||
|
||||
1. **Server components by default** (Astro components, zero client JS). Client behaviour only via the five behaviour modules bound by `data-*` attributes.
|
||||
2. **Components receive typed props from content + locale; they never fetch or read files.** Layouts do the data plumbing.
|
||||
3. **One component, both themes, both locales** — no `HeroNo.astro`, no `CardDark.astro`. Locale comes in as a prop bundle; theme is pure CSS.
|
||||
4. Naming and states follow the Phase 1 component inventory (DESIGN_SYSTEM §5) 1:1 — design system and component tree share vocabulary.
|
||||
|
||||
## 2. Component tree
|
||||
|
||||
```
|
||||
layouts/
|
||||
Base html/head/meta, Seo, ThemeScript(inline), fonts preload, SkipLink,
|
||||
Header, <slot/>, Footer, behaviour-module script tags (deferred)
|
||||
Page Base + page-title block + prose container (about/contact/cv/colophon)
|
||||
Home Base + section scaffolding with reveal data-attributes
|
||||
CaseStudy Base + case-study scaffold (header block, MiniToc, section renderer)
|
||||
|
||||
components/core/
|
||||
Header nav links (slug-map-driven), LangSwitch, ThemeToggle, SplitCvButton;
|
||||
states: default/scrolled via data-scrolled (observer module)
|
||||
MobileNav overlay panel (template in DOM, hidden; nav.ts toggles, traps focus)
|
||||
Footer channels, CV links, language repeat, colophon link
|
||||
Seo per-page meta, hreflang pairs (slug map), OG, JSON-LD builders
|
||||
ThemeScript inline no-flash theme init (the one permitted inline script, CSP-hashed)
|
||||
SkipLink first focusable element
|
||||
|
||||
components/ui/ (design-system atoms — dumb, prop-driven)
|
||||
Chip(kind: fact|status|stack, status?) Button(variant: primary|secondary|ghost)
|
||||
SplitCvButton(locale, cvMeta) LangSwitch(currentPageId, locale)
|
||||
ThemeToggle Card(interactive?)
|
||||
SectionLabel(number, text) TraceMotif(animated?)
|
||||
FramedImage(media, loading) Tooltip(delay 600ms)
|
||||
|
||||
components/home/
|
||||
Hero(profile) ProofStrip(4 tiles from profile/skills data)
|
||||
SkillsGrid(skillGroups) ProjectCards(featured projects, homelab slim card)
|
||||
ExperienceTimeline(entries) AboutTeaser(profile) ContactBand(profile)
|
||||
|
||||
components/case-study/
|
||||
CsHeader(project) TldrBox(project.tldr)
|
||||
MiniToc(sections, sticky ≥1200) SectionRenderer(section) — markdown → prose
|
||||
DecisionList(decisions) ArchDiagram(diagram, locale labels)
|
||||
Gallery(media) + Lightbox host NextPrev(project order)
|
||||
```
|
||||
|
||||
## 3. Behaviour modules (the entire client JS surface)
|
||||
|
||||
| Module | Binds to | Responsibility | Budget |
|
||||
|---|---|---|---|
|
||||
| `theme.ts` | ThemeToggle + inline init | read/persist preference, `data-theme` swap, icon morph class | ~1 kB |
|
||||
| `nav.ts` | Header/MobileNav | overlay open/close, focus trap, scroll lock, Esc; header `data-scrolled` flag | ~2 kB |
|
||||
| `observer.ts` | `[data-reveal]`, `[data-spy]` | shared IntersectionObserver: reveal-once classes, scroll-spy for nav/MiniToc, timeline draw trigger | ~2 kB |
|
||||
| `lightbox.ts` | Gallery | open/close/navigate, focus trap, key handling, swipe | ~3 kB |
|
||||
| `form.ts` | Contact form | client validation, honeypot timing, POST to relay, A9 success/error panels, copy-to-clipboard buttons | ~3 kB |
|
||||
|
||||
Rules: modules are independent (no shared state beyond `localStorage` keys), fail-silent (feature simply absent if errored), loaded `defer`, total ≤ 15 kB gzip — well under the 40 kB budget with margin for the language-hint logic (~1 kB, lives in `nav.ts`).
|
||||
|
||||
## 4. Contracts between design tokens and components
|
||||
|
||||
- Components use Tailwind utilities mapped to tokens; **no raw hex/px in component code** (lint-guarded where feasible).
|
||||
- State styling via data-attributes (`data-scrolled`, `data-open`, `data-revealed`, `data-status="active"`) so behaviour modules never touch inline styles — CSS owns all appearance.
|
||||
- Motion classes come exclusively from `motion.css` (token durations + reduced-motion layer) — a component never declares its own transition values.
|
||||
|
||||
## 5. Reuse & variant policy
|
||||
|
||||
- Case-study template is one component set; JobTrack vs InboxIntel differ only in content. Homelab's `capability` template reuses CsHeader/SectionRenderer, omits TldrBox/MiniToc/DecisionList.
|
||||
- The 404, hint bar, and colophon reuse existing atoms only — no bespoke components for one-offs unless an atom can't express it (guard against component sprawl in a one-person codebase).
|
||||
@@ -0,0 +1,76 @@
|
||||
# DATA_MODEL.md
|
||||
Content model for the bilingual content collections. All schemas zod-validated at build; a schema violation fails CI. (Conceptual field lists — not code.)
|
||||
|
||||
---
|
||||
|
||||
## 1. Collections overview
|
||||
|
||||
```
|
||||
content/
|
||||
├── profile/ singleton per locale (hero, chips, about)
|
||||
├── projects/ 3 entries × 2 locales (jobtrack, inboxintel, homelab)
|
||||
├── experience/ 5 entries × 2 locales (council, side roles, education, 2023-now)
|
||||
├── skills/ 3 group entries × 2 locales
|
||||
└── meta/ colophon page, 404 copy, contact page copy × 2 locales
|
||||
```
|
||||
|
||||
Locale-invariant fields live on the EN entry and merge into NO at load (I18N_SPEC §3). Below, fields marked **[inv]** are invariant; everything else is per-locale prose.
|
||||
|
||||
## 2. `profile` (singleton)
|
||||
|
||||
- `name` [inv], `roleLine`, `heroTagline` (the one-sentence claim), `heroSummary` (≤ 2 lines)
|
||||
- `chips[]`: `{ id [inv], label }` — location, permit, availability, languages
|
||||
- `statusLine` (trace-dot tooltip: "open to work" / "åpen for muligheter")
|
||||
- `aboutParagraphs[]` (3), `interestsLine`
|
||||
- `photo` [inv]: asset ref + per-locale `alt`, `caption`
|
||||
- `links` [inv]: email, linkedinRedirect (`/Linkedin`), gitea, phone
|
||||
- `cv` [inv]: `{ en: {path, sizeKb, updated}, no: {...} }`
|
||||
|
||||
## 3. `projects`
|
||||
|
||||
- `id`/`slug` [inv, per-locale slug via slug map], `name` [inv], `order` [inv]
|
||||
- `status` [inv]: `active | in-development | archived` (drives chip semantics per COLOUR_SYSTEM §3.4)
|
||||
- `valueProp` (one-liner), `cardTeaser` (2 lines for homepage card)
|
||||
- `stack[]` [inv]: `{ name, contextLine? per-locale }` (chip + tooltip)
|
||||
- `links[]` [inv]: `{ type: repo|live|docs, url, public: bool }` (repo links only if/when repos are public — publish decision is content, not code)
|
||||
- `tldr`: `{ what, why, stack, role }` (4 strings)
|
||||
- `sections[]`: ordered `{ anchorId per-locale, heading, body (markdown) }` — canonical section kinds: `problem`, `architecture`, `decisions`, `security`, `screenshots`, `next`
|
||||
- `decisions[]`: structured `{ n, choice, alternative, rationale }` (rendered as the numbered trade-off blocks)
|
||||
- `diagram` [inv]: SVG asset ref + per-locale `title`, `desc` (a11y), `nodes[]` label overrides per locale
|
||||
- `media[]` [inv refs]: `{ image, width, height, per-locale alt + caption, order }` — dimensions mandatory (CLS 0)
|
||||
- `template` [inv]: `case-study | capability` (homelab uses the lighter template)
|
||||
- `summaryOnly?` (NO-side flag per I18N_SPEC §4.2)
|
||||
|
||||
## 4. `experience`
|
||||
|
||||
- `id` [inv], `employer` [inv], `location` [inv], `period` [inv]: `{ from, to|null }`
|
||||
- `role`, `summary`, `highlights[]` (featured only)
|
||||
- `emphasis` [inv]: `featured | compact`
|
||||
- `progression?` [inv]: for the council entry — `[{from: 2015, to: 2017, label-per-locale: "Apprentice"}, {from: 2017, to: 2023, label: "System Developer"}]`
|
||||
- `anchor?` — the confirmed concrete bullet (streetlight/highways system) is content, phrased per CANONICAL_CONTENT
|
||||
- Entries: `warwickshire` (featured), `royal-vapes`, `hodcarrier`, `nuffield` (compact, `alongside: true` [inv] flag renders the "alongside" grouping), `education`, `norway-now` (2023–, norskkurs + products)
|
||||
|
||||
## 5. `skills`
|
||||
|
||||
- `id` [inv], `order` [inv], `title`, `contextLine`, `skills[]`: `{ name [inv], tooltip? }`
|
||||
- Groups: `development`, `devops-infrastructure`, `practices`
|
||||
|
||||
## 6. Page-meta (every routable page)
|
||||
|
||||
- `title`, `description` (≤155 chars), `ogImage` [generated per locale], `pageId` [inv → slug map join]
|
||||
- Case studies additionally emit structured-data fields (SEO_SPEC §3)
|
||||
|
||||
## 7. Asset conventions
|
||||
|
||||
- Screenshots: `assets/projects/<id>/<capture>.png` source of truth at 2×, seeded demo data only (Phase 3 prerequisite); pipeline emits AVIF/WebP/fallback + srcset.
|
||||
- Diagrams: hand-authored SVGs following the design-system diagram style; text within diagrams uses per-locale label injection at build (single SVG source, two rendered outputs) — avoids maintaining twin diagrams.
|
||||
- Fonts/CVs/favicons in `public/` (unprocessed, stable URLs).
|
||||
|
||||
## 8. Validation rules (CI)
|
||||
|
||||
1. Locale parity (I18N_SPEC §4.1).
|
||||
2. Every `media[]` entry has dimensions + both alts.
|
||||
3. Every project has ≥3 `decisions[]` and a `security` section (the site's promise to P2 is structural).
|
||||
4. `status` transitions logged in git only — no "archived" entry may appear on the homepage cards (query rule).
|
||||
5. Description lengths, title lengths enforced (SEO).
|
||||
6. All external `links[].url` respond 200 in a scheduled link-check workflow (not per-commit).
|
||||
@@ -0,0 +1,53 @@
|
||||
# DEPLOYMENT_SPEC.md
|
||||
|
||||
## 1. Environments
|
||||
|
||||
| Env | Where | Purpose | URL |
|
||||
|---|---|---|---|
|
||||
| **dev** | Local (Docker compose dev stack) | Inner loop, HMR | localhost:4321 |
|
||||
| **staging** | Same server, separate compose project | Pre-release verification on real infra (headers, proxy, relay SMTP against a mailbox test) | `staging.cesnimda.co.uk` (basic-auth gated at the proxy, `noindex` everywhere) |
|
||||
| **prod** | Server, compose stack `resumesite` | Live | `cesnimda.co.uk` |
|
||||
|
||||
Staging exists chiefly for the **cutover** and for occasional risky changes; day-to-day content edits may go dev → prod (single-owner pragmatism, gates make it safe).
|
||||
|
||||
## 2. Pipeline (Gitea Actions, all self-hosted)
|
||||
|
||||
```
|
||||
push / PR to main
|
||||
├─ lint + typecheck + astro check
|
||||
├─ unit (vitest: i18n parity, slug-map bijection, JSON-LD builders)
|
||||
├─ build site (static) ──► Playwright e2e vs built output + relay container
|
||||
│ ──► axe a11y scan (both locales, both themes)
|
||||
│ ──► Lighthouse CI budgets (fail = red)
|
||||
├─ build + push images :sha and :latest → Gitea registry (main only)
|
||||
└─ deploy job (main only, manual approval toggle):
|
||||
ssh to server → compose pull → compose up -d → smoke check
|
||||
(curl /, /no/, /api/contact healthz, spot-check headers) → notify
|
||||
```
|
||||
|
||||
- **Rollback:** redeploy previous `:sha` tag (one command, documented runbook); HTML no-cache means rollback is immediate. Registry retains last N images.
|
||||
- **Content-only changes** ride the same pipeline — content errors are build errors by design (schema + parity tests), which is the safety that replaces CMS preview.
|
||||
- Scheduled workflow (weekly): external link check (DATA_MODEL §8.6) + Lighthouse drift report + Renovate PRs.
|
||||
|
||||
## 3. Cutover plan (WordPress → new site)
|
||||
|
||||
1. Pre-launch: staging fully verified (both locales, CV downloads, form → real mailbox, redirects incl. `/Linkedin`, 410 map, OG previews via LinkedIn/Slack debuggers, Search Console fetch).
|
||||
2. URL inventory + redirect/410 map finalised (SEO_SPEC §6).
|
||||
3. Cutover = repoint host nginx server-block from WP to the `site` container (single reload; WP left running dark for 2 weeks as instant rollback).
|
||||
4. Post-launch checklist: submit sitemaps, watch Search Console coverage + relay logs for form spam patterns, then decommission WP + its PHP/MySQL surface (attack-surface win worth noting on the colophon).
|
||||
|
||||
## 4. Operational guardrails
|
||||
|
||||
- **Uptime:** monitored by existing homelab monitoring (add checks: `/` 200 + content match, `/no/` 200, relay healthz); alerting via his current channel.
|
||||
- **TLS:** unchanged at the existing edge proxy (already terminating for Gitea etc.).
|
||||
- **Logs:** site-nginx access logs minimal (no query bodies, standard rotation); relay logs metadata only (timestamp, IP hash, outcome) — privacy stance documented on colophon.
|
||||
- **CV updates:** replace PDF in repo → pipeline redeploys; stable URLs mean previously shared links always fetch the newest CV — an explicit product decision.
|
||||
- **Secrets rotation:** SMTP credential rotation runbook (env file update + `compose up -d relay`), noted in deploy README.
|
||||
|
||||
## 5. Definition of Done for Phase 3 (deployment perspective)
|
||||
|
||||
1. All CI gates green on main; images in registry.
|
||||
2. Staging sign-off checklist complete (incl. native-Norwegian content review — I18N_SPEC §6).
|
||||
3. Cutover executed; WP dark-standby; monitoring green 48h.
|
||||
4. Search Console: sitemaps accepted, no coverage regressions after 4 weeks (watch item, not blocker).
|
||||
5. Colophon page published describing the stack honestly (the P2 hook).
|
||||
@@ -0,0 +1,60 @@
|
||||
# DOCKER_SPEC.md
|
||||
Container strategy. Descriptive spec — actual Dockerfiles/compose files are Phase 3 artifacts.
|
||||
|
||||
---
|
||||
|
||||
## 1. Images
|
||||
|
||||
### `site` (production)
|
||||
- **Multi-stage build:** stage 1 `node:22-alpine` + pnpm → install (frozen lockfile) → `astro build` → `dist/`; stage 2 `nginx:alpine` (digest-pinned) copying `dist/` + a site-local nginx config.
|
||||
- Site-local nginx config responsibilities: gzip/brotli precompressed asset serving, cache headers (immutable hashed assets / no-cache HTML per ARCHITECTURE §5), security headers + CSP (ARCHITECTURE §6), trailing-slash 301s, `/Linkedin` 301, legacy-WP 410 map, custom 404 page wiring.
|
||||
- Runs as non-root (nginx unprivileged image), read-only filesystem, no writable volumes, port 8080 internal.
|
||||
- Image contains **zero secrets and zero build tooling** — final image is nginx + static files, ~25 MB.
|
||||
|
||||
### `contact-relay` (production)
|
||||
- Multi-stage: .NET 9 SDK build → `mcr` aspnet runtime-deps/alpine final; non-root, read-only FS, listens 8081 internal.
|
||||
- Config via environment only: `Smtp__Host/Port/User/Password (secret)`, `Relay__ToAddress`, `Relay__RateLimit*`, `Relay__AllowedOrigin` (site origin for CORS/`Origin` check).
|
||||
- Healthcheck endpoint `/healthz` (no auth, no info leakage) wired to compose healthcheck.
|
||||
|
||||
### `site-dev` (development)
|
||||
- `node:22` dev container running `astro dev` with HMR; `site/` bind-mounted, `node_modules` in a named volume (Windows-host performance), port 4321 published.
|
||||
- Relay runs alongside via `dotnet watch` in an SDK-image dev container (or directly on host — both supported); Astro dev proxy forwards `/api/contact` → relay so the form works identically in dev.
|
||||
- Purpose: parity with prod behaviour (headers/proxying approximated) while keeping the fast inner loop; contributors need only Docker, not local Node/.NET installs.
|
||||
|
||||
## 2. Compose topology
|
||||
|
||||
```
|
||||
deploy/
|
||||
docker-compose.yml # prod: site + contact-relay, internal network,
|
||||
# exposed only to the host reverse-proxy network
|
||||
docker-compose.dev.yml # dev: site-dev + relay-dev
|
||||
.env.example # documented variables, no real values
|
||||
```
|
||||
|
||||
- Prod compose joins the server's existing reverse-proxy network; the host nginx (existing, TLS-terminating) routes `cesnimda.co.uk` → `site:8080` and `cesnimda.co.uk/api/contact` → `contact-relay:8081`. The stack publishes **no host ports** itself.
|
||||
- `restart: unless-stopped`, log rotation via compose logging options, images referenced by registry tag + digest.
|
||||
- Optional third service slot (`analytics`, self-hosted Umami/Plausible) reserved in the compose file as a commented profile — decision deferred.
|
||||
|
||||
## 3. Environment variables
|
||||
|
||||
| Var | Service | Notes |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST/PORT/USER` | relay | His mail provider's submission endpoint |
|
||||
| `SMTP_PASSWORD` | relay | Secret — provided via env file with 600 perms on server (or Docker secret); never in git, never in image |
|
||||
| `RELAY_TO` | relay | Destination inbox |
|
||||
| `RELAY_ALLOWED_ORIGIN` | relay | `https://cesnimda.co.uk` |
|
||||
| `RELAY_RATE_*` | relay | Requests/window per IP |
|
||||
| `PUBLIC_SITE_URL` | site (build-time) | Canonical origin for sitemap/OG absolute URLs |
|
||||
|
||||
Build-time vs runtime separation is explicit: the static site consumes variables **only at build**; the relay **only at runtime**. `.env.example` documents every variable (pattern Connor already uses in both projects).
|
||||
|
||||
## 4. Build pipeline approach (interface to DEPLOYMENT_SPEC)
|
||||
|
||||
- CI builds both images on Gitea Actions runners, tags `:<git-sha>` + `:latest`, pushes to **Gitea's built-in container registry** (git.cesnimda.uk — keeps the whole chain self-hosted).
|
||||
- The `site` image build runs the full quality gate first (typecheck, unit, Playwright against a preview build, Lighthouse budgets) — an image only exists if gates passed.
|
||||
- Reproducibility: pnpm frozen lockfile, pinned base images, no network fetches during `astro build` beyond npm (fonts vendored in repo).
|
||||
|
||||
## 5. Local ops conventions
|
||||
|
||||
- `make`-style task runner or npm scripts wrapping compose invocations (`dev`, `build`, `test`, `deploy`) so every workflow is one documented command — README-driven, same convention as JobTrack/InboxIntel repos.
|
||||
- Backups: nothing stateful to back up (site is git + registry; relay is stateless) — the deliberate architectural win; documented so nobody adds state casually.
|
||||
@@ -0,0 +1,46 @@
|
||||
# I18N_SPEC.md
|
||||
|
||||
## 1. Architecture summary
|
||||
|
||||
Three i18n concerns, three mechanisms — never mixed:
|
||||
|
||||
| Concern | Mechanism | Examples |
|
||||
|---|---|---|
|
||||
| **Routes** | Astro built-in i18n (defaultLocale `en` at root, `no` prefixed) + slug map module | `/projects/jobtrack` ↔ `/no/prosjekter/jobtrack` |
|
||||
| **UI strings** (~60 keys) | Typed dictionary modules per locale + `t(key)` accessor, compile-time checked | `nav.projects`, `cv.download`, `form.error.email`, `a11y.skipLink` |
|
||||
| **Content** (prose, case studies, experience) | Content collections with per-locale entries sharing one zod schema | `projects/en/jobtrack.md` + `projects/no/jobtrack.md` |
|
||||
|
||||
No runtime i18n library. Locale is resolved from the URL at build time; every page is rendered once per locale into static HTML.
|
||||
|
||||
## 2. Dictionary spec
|
||||
|
||||
- Shape: nested typed object; both locale files implement the same interface → a missing key is a **compile error**, not a runtime fallback.
|
||||
- Keys are semantic (`cv.download`), never English-sentence keys.
|
||||
- Interpolation: simple `{param}` tokens only; no plural engine (the ~3 plural cases are written out per locale).
|
||||
- A11y strings are first-class dictionary entries: skip-link, lang-switch announcement ("Bytt til norsk" labelled *in the target language* per MICRO_INTERACTIONS), image alt patterns, form validation messages.
|
||||
|
||||
## 3. Content collection localisation
|
||||
|
||||
- Each collection folder splits `en/` and `no/`; entries share the same filename (`jobtrack.md`) = the join key.
|
||||
- One schema validates both locales (DATA_MODEL.md); locale-invariant fields (dates, stack chips, links, image refs, status) live in the EN entry and are **inherited** by the NO entry at load time (a small content-loader utility merges them) — this is the anti-duplication mechanism: facts exist once, prose exists twice.
|
||||
- Section anchors: schema field `anchorId` per section, per locale (`architecture`/`arkitektur`) with the pageId-level mapping used by the language switch to land on the equivalent section (J5).
|
||||
|
||||
## 4. Fallback rules (build-time, never runtime)
|
||||
|
||||
1. **Full parity is the shipping rule.** CI parity test: every EN content entry has a NO twin and vice versa; every dictionary key present in both.
|
||||
2. **Temporary asymmetry** (a new case study written EN-first): the NO twin must still exist, containing a native-Norwegian summary + link "Full versjon på engelsk" (CONTENT_STRATEGY rule). The schema supports `summaryOnly: true` for this state; CI allows it but the sitemap/hreflang still emit correctly.
|
||||
3. No silent English fallback ever renders on a `/no/` page — a Norwegian reader must never hit unexplained English mid-page. (Machine translation is banned outright.)
|
||||
|
||||
## 5. Locale-sensitive rendering rules
|
||||
|
||||
- `<html lang>`: `en-GB` / `nb-NO` per page. Date formatting: `2015–2023` style is locale-neutral; long dates use locale month names via `Intl` at build time.
|
||||
- Quotes: “ ” (EN) / « » (NO) — enforced in content review, not code.
|
||||
- The CV split-button defaults to the *current page locale's* CV; stored language preference overrides on EN pages only if the user previously chose NO (subtle: preference sets default, page locale is tiebreak).
|
||||
- OG/meta per locale (SEO_SPEC). `hreflang` emitted from the slug map: `en`, `nb`, `x-default → en`.
|
||||
|
||||
## 6. Translation management workflow
|
||||
|
||||
- Author EN and NO in the same PR (content files sit side by side; the parity test enforces it).
|
||||
- Norwegian quality gate before launch: native/fluent review pass of all NO content (tracked as a Phase 3 exit criterion; the reviewer reviews *markdown files in a PR*, which keeps review diffs clean — a deliberate benefit of content-in-git).
|
||||
- Glossary (CONTENT_STRATEGY §5.3) lives as a comment header in the NO dictionary file so it's in front of whoever edits translations.
|
||||
- No TMS tooling: two locales, one author, git history is the audit trail.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Portfolio Redesign — Phase 2 Deliverables (Architecture & Technical Specification, no code)
|
||||
|
||||
Status: **complete, awaiting approval** · July 2026 · Builds on approved Phase 1 (`../design/`)
|
||||
|
||||
## Index
|
||||
|
||||
- [TECH_SPEC.md](TECH_SPEC.md) — stack evaluation & recommendation (Astro 5 + Tailwind 4 tokens, CSS-first motion, no i18n library, .NET 9 contact relay, testing/quality gates)
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — system overview, repo layout, layering, ADR summary, performance/security posture, failure modes
|
||||
- [ROUTING_SPEC.md](ROUTING_SPEC.md) — canonical EN↔NO route table (slug map contract), redirects, legacy-WP handling, `/Linkedin` preservation
|
||||
- [I18N_SPEC.md](I18N_SPEC.md) — three-mechanism i18n (routes/dictionary/content), fallback rules, translation workflow
|
||||
- [DATA_MODEL.md](DATA_MODEL.md) — content collections, schemas, locale-invariant field inheritance, CI validation rules
|
||||
- [COMPONENT_ARCHITECTURE.md](COMPONENT_ARCHITECTURE.md) — component tree, 5 behaviour modules (≤15 kB total), token/data-attribute contracts
|
||||
- [ANIMATION_SPEC.md](ANIMATION_SPEC.md) — Phase 1 concepts A1–A10 mapped to CSS/observer mechanisms, reduced-motion layer, perf rules
|
||||
- [SEO_SPEC.md](SEO_SPEC.md) — metadata, hreflang from slug map, JSON-LD, build-time OG images, WP-migration protection
|
||||
- [DOCKER_SPEC.md](DOCKER_SPEC.md) — site (nginx static) + contact-relay images, dev containers, compose topology, env vars
|
||||
- [DEPLOYMENT_SPEC.md](DEPLOYMENT_SPEC.md) — Gitea Actions pipeline, staging, cutover plan from WordPress, rollback, DoD
|
||||
|
||||
## Headline decisions requiring sign-off before Phase 3
|
||||
|
||||
1. **Astro 5 (static output) over Next.js** — content site, zero runtime JS by default; React competence is proven by the case studies, judgement by this choice (TECH_SPEC §2)
|
||||
2. **No animation/i18n/state libraries** — CSS + 5 tiny TS modules, ≤15 kB JS total (TECH_SPEC §4–6)
|
||||
3. **Contact form = self-hosted .NET 9 relay container** with mailto/copy fallback (TECH_SPEC §7)
|
||||
4. **Everything self-hosted**: Gitea Actions CI, Gitea container registry, deploy to existing server behind existing reverse proxy (DOCKER/DEPLOYMENT)
|
||||
5. **Content lives in git** (schema-validated collections), no CMS (ARCHITECTURE A3)
|
||||
6. **Staging env + dark-standby WP cutover** with redirect/410 map and `/Linkedin` redirect preserved (DEPLOYMENT §3, ROUTING §2)
|
||||
|
||||
## Phase 3 prerequisites carried forward
|
||||
- Screenshot capture pass on seeded demo data (JobTrack, InboxIntel)
|
||||
- ATS-safe CV rework (both languages) — becomes the download default
|
||||
- Native-Norwegian review pass of all NO content (launch gate)
|
||||
- Legacy WordPress URL inventory for the redirect map
|
||||
@@ -0,0 +1,49 @@
|
||||
# ROUTING_SPEC.md
|
||||
|
||||
## 1. Canonical route table (the EN↔NO contract)
|
||||
|
||||
| Page | EN (default, root) | NO (`/no` prefix, localised slugs) |
|
||||
|---|---|---|
|
||||
| Home | `/` | `/no/` |
|
||||
| Projects index | `/projects/` | `/no/prosjekter/` |
|
||||
| JobTrack case study | `/projects/jobtrack/` | `/no/prosjekter/jobtrack/` |
|
||||
| InboxIntel case study | `/projects/inboxintel/` | `/no/prosjekter/inboxintel/` |
|
||||
| Homelab capability page | `/projects/homelab/` | `/no/prosjekter/hjemmelab/` |
|
||||
| Experience | `/experience/` | `/no/erfaring/` |
|
||||
| About | `/about/` | `/no/om-meg/` |
|
||||
| Contact | `/contact/` | `/no/kontakt/` |
|
||||
| CV hub | `/cv/` | `/no/cv/` |
|
||||
| Site meta ("how this site works") | `/colophon/` | `/no/kolofon/` |
|
||||
| 404 | `/404` (bilingual page) | — |
|
||||
|
||||
Rules: trailing-slash canonical (nginx redirects the bare form, 301). Product names (jobtrack, inboxintel) stay untranslated per CONTENT_STRATEGY glossary. This table lives in code as the single **slug map module** (`i18n/slugMap.ts` conceptually): `pageId → {en: path, no: path}`; consumed by the language switch, hreflang generation, sitemap, and nav. CI test asserts bijection (every EN path has exactly one NO twin and vice versa) and that every content entry maps to a route.
|
||||
|
||||
## 2. Non-page routes
|
||||
|
||||
| Route | Serves | Notes |
|
||||
|---|---|---|
|
||||
| `/cv/connor-babbington-cv-en.pdf` | English CV | Stable, shareable, versioned in repo |
|
||||
| `/cv/connor-babbington-cv-no.pdf` | Norwegian CV | Same |
|
||||
| `/api/contact` | POST → contact-relay container | Only non-static route; nginx proxies. 405 on GET |
|
||||
| `/sitemap-index.xml`, `/robots.txt` | build artifacts | SEO_SPEC |
|
||||
| `/og/*.png` | Pre-generated OG images per page per locale | Build-time generated |
|
||||
| `/Linkedin` | 301 → LinkedIn profile | Preserves the existing `cesnimda.co.uk/Linkedin` redirect used on the printed/PDF CV — must not break |
|
||||
| Legacy WP URLs (`/feed/`, `/wp-*`, old anchors) | 410 Gone (crawler cleanup) except any URL known to be shared → 301 to nearest new page | Inventory old URLs before cutover (Phase 3 task) |
|
||||
|
||||
## 3. Redirect & language behaviour
|
||||
|
||||
- **No automatic locale redirect.** `/` is always EN. Rationale (Phase 1): shared links must be stable; recruiters forward URLs across languages; auto-redirects break back-button and SEO.
|
||||
- **First-visit hint:** if `navigator.language` starts with `nb`/`nn`/`no`, and no stored preference, and current locale is EN → show the dismissible hint bar (client-side only, one-time, stores dismissal). Never shown on NO pages, never a redirect.
|
||||
- **Language switch:** header control resolves current pageId via the slug map → navigates to the twin URL. Stores preference (used only for the hint logic and CV-button default — never for redirects).
|
||||
- **Unknown `/no/...` path:** bilingual 404 (NO strings first when under `/no/`).
|
||||
|
||||
## 4. URL quality rules
|
||||
|
||||
- Lowercase, hyphenated, ASCII slugs (NO slugs transliterate: `om-meg`, not `om-mæg` — æøå avoided in URLs for shareability/encoding robustness).
|
||||
- No query parameters anywhere in canonical URLs; anchors (`#architecture` / `#arkitektur`) are localised to match section headings' explicit ids from the content model (stable across copy edits).
|
||||
- Case-study section anchors are part of the content schema (DATA_MODEL) so the mini-TOC, deep links, and language-switch section-mapping (J5) stay in sync.
|
||||
|
||||
## 5. Navigation state mapping
|
||||
|
||||
- `aria-current="page"` from exact pageId match; Projects nav item also marked current on case-study pages (section-current, underline 60% per Phase 1).
|
||||
- Homepage scroll-spy: section ids `#skills` `#projects` `#experience` `#about` `#contact` (EN) / `#kompetanse` `#prosjekter` `#erfaring` `#om-meg` `#kontakt` (NO) — defined in the slug map's anchor table, not ad hoc.
|
||||
@@ -0,0 +1,45 @@
|
||||
# SEO_SPEC.md
|
||||
|
||||
## 1. Goals & reality check
|
||||
|
||||
Primary discovery is *direct* (links from applications/LinkedIn/CV), not organic search. SEO effort therefore targets: (1) branded queries ("Connor Babbington developer") ranking the new site #1 in both languages, (2) clean link-preview cards everywhere recruiters paste URLs (Slack/Teams/LinkedIn/ATS), (3) not losing the existing domain's standing during the WordPress → new-site cutover.
|
||||
|
||||
## 2. Per-page metadata (both locales)
|
||||
|
||||
- **Titles** (patterns fixed in CONTENT_STRATEGY §7): home `Connor Babbington — Systems Developer (.NET, React) · Tønsberg, Norway` / `… Systemutvikler … · Tønsberg`; inner pages `<Page> · Connor Babbington`. ≤ 60 chars target.
|
||||
- **Descriptions:** handwritten per page per locale, ≤ 155 chars, answer who/what/where. Stored in page-meta content (DATA_MODEL §6), length CI-checked.
|
||||
- **Canonical:** self-referencing per locale page. **hreflang:** `en`, `nb`, `x-default → en` emitted from the slug map on every page — the twin-URL contract guarantees correctness; CI tests hreflang reciprocity.
|
||||
- **robots:** index,follow everywhere except 404/colophon-draft states; `robots.txt` allows all + sitemap pointer.
|
||||
|
||||
## 3. Structured data (JSON-LD, build-time)
|
||||
|
||||
| Page | Schema |
|
||||
|---|---|
|
||||
| Home (both locales) | `Person`: name, jobTitle "Systems Developer"/"Systemutvikler", address (locality Tønsberg, country NO), email, sameAs [LinkedIn, git.cesnimda.uk], knowsLanguage [en, nb], knowsAbout [C#, .NET, React, Docker, …] |
|
||||
| Case studies | `CreativeWork` (subtype `SoftwareApplication` where honest — JobTrack yes, InboxIntel as `SoftwareSourceCode`): name, description, author → Person, programmingLanguage, dateModified |
|
||||
| CV hub | `Person` + `subjectOf` links to CV files |
|
||||
| All | `WebSite` + `BreadcrumbList` on nested pages |
|
||||
|
||||
Builders are typed utilities with unit tests (TECH_SPEC §9.2) — malformed JSON-LD is worse than none.
|
||||
|
||||
## 4. OpenGraph / link previews
|
||||
|
||||
- Per-page, per-locale OG: `og:locale` (`en_GB`/`nb_NO`) + `og:locale:alternate`, title/description mirroring meta, `og:type` profile (home) / article (case studies).
|
||||
- **OG images pre-generated at build** (one template per page kind, design-system styled: dark surface, name/page title, role line, trace motif, monogram; case studies add project name + stack line). 1200×630 PNG, per locale. Twitter/X summary_large_image tags mirrored.
|
||||
- This directly fixes the current site's empty `og:title` embarrassment noted in Phase 1 research.
|
||||
|
||||
## 5. Sitemaps
|
||||
|
||||
- Build-generated sitemap index → one sitemap per locale; entries carry `xhtml:link` alternates (belt-and-braces with hreflang tags); `lastmod` from git commit dates of content files.
|
||||
- Submitted via Search Console (both properties/domains as relevant) at launch; the old WP sitemap URLs left to 404→410 after cutover inventory.
|
||||
|
||||
## 6. Migration / cutover protection
|
||||
|
||||
1. **Inventory before cutover** (Phase 3 task): crawl current WP site, list indexed URLs (`site:cesnimda.co.uk`), old anchors, and any URL known to be printed/shared (esp. `/Linkedin` redirect — preserved per ROUTING_SPEC).
|
||||
2. 301 map for anything with inbound value; 410 for WP cruft (`/feed/`, `/wp-json/`, `/comments/`…). Never soft-404.
|
||||
3. Keep domain, keep HTTPS, no `www`/apex flip during migration (site currently canonicalises to apex — retain).
|
||||
4. Verify: Search Console coverage watch for 4 weeks post-launch; link-preview spot checks in LinkedIn/Slack debuggers.
|
||||
|
||||
## 7. Performance & crawlability as SEO
|
||||
|
||||
Static HTML, content not JS-gated (crawler parity guaranteed by architecture), CWV budgets in CI (LCP < 1.8 s, CLS < 0.02) — the technical-SEO checklist is mostly satisfied *by construction*; this spec adds only the markup/meta layer above.
|
||||
@@ -0,0 +1,99 @@
|
||||
# TECH_SPEC.md
|
||||
Phase 2 — technology stack evaluation and recommendation. No implementation code; decisions + justification.
|
||||
|
||||
---
|
||||
|
||||
## 1. Constraints that drive every choice
|
||||
|
||||
1. **The site is a work sample.** Core Web Vitals, a11y, and view-source cleanliness are product requirements (Phase 1, P2 persona).
|
||||
2. **Content-heavy, interaction-light.** ~10 pages × 2 languages, one form, no user accounts, no server state. Motion is CSS-grade (Phase 1 motion system: transform/opacity only, one signature animation).
|
||||
3. **Self-hosted.** Deploys to Connor's own infrastructure (Docker, nginx reverse proxy, Gitea at git.cesnimda.uk). No vendor lock to Vercel/Netlify.
|
||||
4. **Bilingual first-class.** EN root + `/no/` localised slugs, hreflang, per-language OG (Phase 1 IA §5).
|
||||
5. **Maintainable by one person** who works daily in C#/.NET + React/TypeScript.
|
||||
|
||||
## 2. Frontend framework
|
||||
|
||||
### Recommendation: **Astro 5.x (TypeScript, strict)**
|
||||
|
||||
| Criterion | Astro | Next.js (App Router) | Plain Vite + React SPA |
|
||||
|---|---|---|---|
|
||||
| Output for this content shape | Static HTML, zero JS by default — ideal | Static export possible but carries React runtime (~85 kB+) for no interactive need | SPA: worst SEO/first-paint profile — rejected outright |
|
||||
| i18n routing (path prefix + localised slugs) | First-class built-in (`defaultLocale` root, per-locale routes), content collections pair cleanly | Requires middleware/next-intl wiring; static export + i18n routing is the App Router's weakest combination | Manual everything |
|
||||
| Performance ceiling | LCP = HTML+CSS only; 100/100 realistic | Good, but hydration cost is structural | Poor |
|
||||
| Islands for the 3 interactive spots (lightbox, form, nav overlay) | Astro islands — ship JS only where needed | Everything is React | Everything is React |
|
||||
| Docker/static hosting | Builds to `dist/` static files → nginx serves them; trivial, rock-solid | Static export OK; SSR mode would need a Node runtime container for no benefit | Static, but SPA routing needs nginx fallbacks |
|
||||
| Skill-signalling for Connor | Shows breadth; both project case studies already prove React | Matches CV React keyword | Matches React |
|
||||
| Longevity/maintenance | Content-site sweet spot; low upgrade churn | Heavier upgrade treadmill | Low-level maintenance burden |
|
||||
|
||||
**Decision: Astro.** The two case studies already prove React competence; the portfolio itself should prove *judgement* — choosing the right tool for a content site is exactly the "key decisions & trade-offs" thinking the site preaches. This decision itself becomes a line in the site's "how this site works" meta page.
|
||||
|
||||
**Interactivity model:** zero-framework islands. The three interactive components (mobile nav overlay, screenshot lightbox, contact form) are small enough for vanilla TypeScript modules/custom elements inside Astro components — no React island needed. If any grows past ~150 lines of imperative DOM code, promote it to a Preact island (3 kB) rather than React (Phase 3 escape hatch, pre-authorised here).
|
||||
|
||||
## 3. Styling system
|
||||
|
||||
### Recommendation: **Tailwind CSS 4.x + CSS custom properties for the token layer**
|
||||
|
||||
- The Phase 1 design system is token-complete (colour, spacing, type, radius, motion). Tokens live as **CSS custom properties on `:root`/`[data-theme]`** — the single source of truth; Tailwind consumes them via its `@theme` mechanism. This gives: theme switching by attribute swap (no JS re-render), tokens inspectable in dev tools (P2 will look), and Tailwind's utility ergonomics without hardcoded values.
|
||||
- Component-scoped styles (Astro's scoped `<style>`) only for the few things utilities express badly (trace motif keyframes, dot-grid pattern).
|
||||
- **Rejected:** CSS-in-JS (runtime cost, needs a framework), vanilla-extract (build complexity unjustified at this size), pure hand-rolled CSS (slower iteration; Tailwind's constraint model actually *enforces* the 10-value spacing scale).
|
||||
|
||||
## 4. Animation library
|
||||
|
||||
### Recommendation: **CSS-first; no animation library. One 2-kB hand-rolled helper.**
|
||||
|
||||
- Phase 1 motion spec animates only transform/opacity with 5 duration/easing tokens → all expressible as CSS transitions/keyframes toggled by `data-*` attributes.
|
||||
- Scroll reveals + scroll-spy: one shared `IntersectionObserver` utility (the "helper"), toggling classes. Header compression: CSS `scroll-driven animations` where supported, with the observer fallback (progressive enhancement — degrade per MOTION_GUIDELINES: content never gated).
|
||||
- Trace draw: SVG `stroke-dashoffset` keyframe. Theme/language cross-fades: CSS transitions.
|
||||
- `prefers-reduced-motion`: one global CSS layer that zeroes durations and disables translations — reduced-motion is a *stylesheet concern*, not per-component JS logic.
|
||||
- **Rejected:** GSAP (license + weight for zero need), Framer Motion (requires React), Motion One (fine, but even 5 kB is unjustified when the spec fits CSS). This restraint is itself the Phase 1 philosophy enforced at the dependency level.
|
||||
|
||||
## 5. i18n library
|
||||
|
||||
### Recommendation: **Astro built-in i18n routing + typed dictionary module (no library)**
|
||||
|
||||
- Routing: Astro's native `i18n` config (defaultLocale `en` at root, `no` prefixed). Localised slugs via the route-mapping table (ROUTING_SPEC).
|
||||
- UI strings (~60: nav, buttons, form, a11y labels): a typed TypeScript dictionary per locale with a `t()` accessor — compile-time key safety, zero runtime dependency.
|
||||
- Long-form content: **content collections with per-locale entries** (DATA_MODEL.md) — content is data, not translation strings.
|
||||
- **Rejected:** i18next (runtime library solving problems — pluralisation across 20 locales, lazy loading — this site doesn't have), astro-i18next (abandonware risk). Two locales with hand-written native copy need a mapping table, not a framework.
|
||||
|
||||
## 6. State management
|
||||
|
||||
**None.** The only client state: theme (persisted preference), language-hint dismissal, lightbox open state, form submission state. All are `localStorage` + component-local. Documented explicitly so Phase 3 doesn't import a store out of habit.
|
||||
|
||||
## 7. Contact form backend
|
||||
|
||||
The one dynamic requirement on an otherwise static site.
|
||||
|
||||
### Recommendation: **a minimal self-hosted form relay container ("contact-relay")**
|
||||
- Tiny HTTP service, one endpoint: validate → honeypot + time-trap spam checks → rate-limit by IP → forward via SMTP to connor.babbington@cesnimda.co.uk → return JSON for the A9 success state.
|
||||
- Implementation language: **ASP.NET Core minimal API (.NET 9)** — deliberately, because it puts a small piece of Connor's core stack *inside* the portfolio's own infrastructure ("even my contact form is a .NET service" — a P2 talking point), and he maintains it effortlessly. Node would also work; .NET is chosen for signalling + familiarity.
|
||||
- Runs as a second container in the same compose stack; nginx routes `POST /api/contact` to it. No database — it relays and forgets (privacy plus: state nothing, log minimal).
|
||||
- **Fallback path:** the form is progressive enhancement over a plain `mailto:` + visible e-mail address (click-to-copy). If the relay is down, recruiters still convert.
|
||||
- **Rejected:** Formspree/external SaaS (data leaves his control; undermines self-hosting story), embedding SSR into Astro just for one endpoint (forces Node runtime for the whole site).
|
||||
|
||||
## 8. Supporting tooling
|
||||
|
||||
| Concern | Choice | Note |
|
||||
|---|---|---|
|
||||
| Package manager | pnpm | Lockfile determinism, fast CI |
|
||||
| Linting/format | ESLint + Prettier (+ prettier-plugin-astro), stylelint optional | CI-enforced |
|
||||
| Type checking | `astro check` + `tsc --noEmit` strict | CI gate |
|
||||
| Testing | Vitest (unit: i18n dictionary completeness, slug map integrity, structured-data builders) + Playwright (smoke: both locales render, language switch maps correctly, form relay contract, a11y scan via axe) | Full strategy in TECH_SPEC §9 |
|
||||
| Image pipeline | Astro built-in image service at build time (sharp): AVIF/WebP + fallback, responsive srcsets, enforced dimensions (CLS = 0) | Screenshots stored as source PNGs in repo |
|
||||
| Fonts | Self-hosted variable fonts (2 families + mono per TYPOGRAPHY_GUIDE), subset incl. `æøå`, `font-display: swap`, preloaded | No Google Fonts CDN (GDPR + performance + self-host principle) |
|
||||
| Icons | Lucide (static SVG imports, tree-shaken at build — inline SVG in HTML output, no icon font, no runtime) | Matches 1.5px stroke aesthetic |
|
||||
| Analytics | None, or self-hosted Plausible/Umami later (optional container slot reserved) | No cookie banner needed either way |
|
||||
|
||||
## 9. Testing & quality gates (CI-enforced, defined here, built in Phase 3)
|
||||
|
||||
1. **Static gates:** typecheck, lint, `astro check`.
|
||||
2. **Unit (Vitest):** every dictionary key exists in both locales; every route has a locale twin in the slug map; structured-data builder outputs valid JSON-LD shape.
|
||||
3. **E2E smoke (Playwright, against the built static output + relay container):** home renders in both locales; language switch from a deep NO URL lands on the correct EN twin; CV downloads resolve (200, correct filename); form happy-path + honeypot rejection; keyboard: skip-link, nav, lightbox Esc.
|
||||
4. **Accessibility:** axe scan on all page templates, both themes — zero critical violations is a merge gate.
|
||||
5. **Performance budget (Lighthouse CI):** LCP < 1.8 s (simulated 4G), CLS < 0.02, TBT < 100 ms, total JS < 40 kB gzip, per-page HTML+CSS < 100 kB. Budgets fail the pipeline, not just warn.
|
||||
|
||||
## 10. Summary of the stack
|
||||
|
||||
**Astro 5 + TypeScript · Tailwind 4 over CSS custom-property tokens · CSS-first motion (no animation dependency) · built-in i18n + typed dictionaries + content collections · zero client state libraries · .NET 9 minimal-API contact relay · pnpm/Vitest/Playwright/Lighthouse CI · static output behind nginx, all self-hosted in Docker.**
|
||||
|
||||
Every "no" above (no React runtime, no animation library, no i18n framework, no CMS, no SaaS form) is a deliberate scope-fit decision and should be written up on the site's meta page as evidence of production judgement.
|
||||
Reference in New Issue
Block a user