Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.8 KiB
TECH_SPEC.md
Phase 2 — technology stack evaluation and recommendation. No implementation code; decisions + justification.
1. Constraints that drive every choice
- The site is a work sample. Core Web Vitals, a11y, and view-source cleanliness are product requirements (Phase 1, P2 persona).
- 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).
- Self-hosted. Deploys to Connor's own infrastructure (Docker, nginx reverse proxy, Gitea at git.cesnimda.uk). No vendor lock to Vercel/Netlify.
- Bilingual first-class. EN root +
/no/localised slugs, hreflang, per-language OG (Phase 1 IA §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@thememechanism. 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
IntersectionObserverutility (the "helper"), toggling classes. Header compression: CSSscroll-driven animationswhere supported, with the observer fallback (progressive enhancement — degrade per MOTION_GUIDELINES: content never gated). - Trace draw: SVG
stroke-dashoffsetkeyframe. 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
i18nconfig (defaultLocaleenat root,noprefixed). 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/contactto 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)
- Static gates: typecheck, lint,
astro check. - 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.
- 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.
- Accessibility: axe scan on all page templates, both themes — zero critical violations is a merge gate.
- 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.