Files
2026-07-03 21:07:25 +02:00

9.8 KiB
Raw Permalink Blame History

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.