# 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.
