ADR-0041: Public website as a Vite + Vue single-page app
Status: Accepted · 2026-09-05 · refines 0018
Context
ADR-0018 shipped web/ as one hand-written index.html. That was right for a placeholder and wrong for a public face: no components, no motion system, copy and layout tangled in one 230-line file, and a Google Fonts call on every load. The site needs to sell the app the way the app sells itself (Caprine tokens, Midnight, real product mechanics on screen) and stay cheap for one person to keep current.
Decision
web/ is a Vite 8 + Vue 3 SPA, built in CI and deployed to GitHub Pages from web/dist.
Stack (all build-time; the deployed site is static files):
unplugin-vue-routerfor file-based routes insrc/pages/,unplugin-auto-importfor Vue/Router/motion composables and everything insrc/composablesandsrc/lib,unplugin-vue-componentsforsrc/components,unplugin-icons(Lucide + Simple Icons, tree-shaken SVG). No global store: shared state is composables.- Tailwind v4 with the Midnight theme's Caprine tokens (
ThemeCatalog.swift) mapped onto shadcn semantic variables insrc/assets/main.css. Colors in components go through those tokens, same rule as the app. - shadcn-vue-style primitives (
src/components/ui/*,reka-ui+class-variance-authority) with explicitcvavariants:Button(default / aurora / outline / ghost / glass / link),Badge,Card. - motion-v for entrance reveals, the hero parallax, and the scroll-telling feature section (
useScrollStepspicks the copy block nearest a focal line and swaps a sticky app-window mock). - GPU backgrounds.
AuroraCanvasrenders a domain-warped fbm aurora shader: WebGPU (WGSL) when available, WebGL2 (GLSL) otherwise, a static CSS gradient when neither exists or the user prefers reduced motion. Capped at 0.5x DPR, paused off-screen and in hidden tabs. One instance is pinned behind the page; sections use small local instances. This replaced CSS/SVG blur animations, which repainted and flickered. - No third-party requests at runtime. System font stack (SF on a Mac, which is the audience), self-hosted images, no analytics. The site keeps the Herd Guarantee's spirit even though it is not the app.
Screenshots are built, not captured. src/components/mock/* are DOM renderings of GOAT surfaces (chat + nerd stats, effort dial, Paddock, MCP gate, memory map) inside a AppWindow frame. They animate, they stay in sync with copy, and they never go stale against a build. Real screenshots can replace them per section when the product is presentation-ready (ADR-0038).
Deploy: pages.yml runs npm ci && npm run build with VITE_BASE=/<repo>/ (project-site path; set to / when a custom domain lands) and uploads web/dist. public/404.html is a copy of index.html so deep links survive Pages' lack of rewrites. public/.nojekyll stays.
Public/corporate only. The site shows Light, Pasture, and Midnight. The 1337 theme, goaties, and other whimsy are app-internal (DESIGN.md §1.3 whimsy budget) and do not appear on the public site.
Consequences
web/gainsnode_modulesand apackage-lock.json; CI needs Node 22+.npm run buildalso runsvue-tscso type errors fail the deploy..gitignorealready excludesweb/dist/; the built site is never committed.- Build config is
web/.env(tracked; public values only):VITE_REPOandVITE_BASE, both overridden by CI from the GitHub context, so nothing needs editing when the repo goes public. Secrets never go there;.env.localis ignored. - Design tokens are duplicated between Swift and CSS by hand. Acceptable at three themes; if the site ever needs all themes, generate
main.cssfromThemeCatalogJSON (ADR-0022 already exports it).
Alternatives considered
Keep the static HTML (rejected: no components, no motion system, fonts from Google); Nuxt (rejected: SSR/Nitro machinery for a one-page static site; the unplugin set gives the Nuxt DX without the runtime); Astro (rejected: fine for content sites, but the mocks are interactive Vue islands end to end, so a Vue SPA is simpler); prerendering with vite-ssg (deferred: the head tags already live in index.html, and a one-route site gains little; revisit if routes multiply or SEO demands per-route meta).

