Files
ResumeSite/docs/spec/TECH_SPEC.md
T
2026-07-03 21:07:25 +02:00

100 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.