ADR-0136: Premium UX — a sovereign motion/design-token layer, native View Transitions, and the Alpine.js CSP build
- Status: Accepted
- Date: 2026-07-16
- Deciders: BDFL / Owner
- Relates to: Prompt 1 (Core Architecture: "Public Paths: HTMX + Alpine.js only"; "Minimal Runtime Dependencies … approved via RFC"), ADR-0133 (public docs), ADR-0135 (VCB theme contract)
Context
VayuPress wants a genuinely premium, elegant UI/UX — themes and the VayuOS console that feel on par with Linear/Vercel/Raycast/Tailwind UI (the aesthetics the constitution already names as the bar) — without weakening the strict CSP, the zero-third-party guarantee, the no-build-step architecture, or single- VPS performance.
Two popular options were evaluated against those constraints:
- Tailwind (CDN) is disqualified: it is a CDN (violates
connect-src 'self'and the zero-third-party rule), it is a runtime JIT that injects<style>elements (violatesstyle-src 'self'), and it ships a large in-browser compiler that runs on every load (violates single-VPS performance). - Tailwind (build-time, purged, self-hosted) produces CSP-safe CSS, but requires a Node/PostCSS build step, which collides directly with "one binary, one command" and "minimal runtime dependencies." VayuPress already has a superior sovereign styling engine — the Go theme compiler emitting CSS custom properties — so Tailwind-the-framework would fork the system for no gain.
- Alpine.js (standard build) is disqualified under our CSP: it evaluates
directive expressions with
new Function(), which requiresscript-src 'unsafe-eval'— forbidden by the baseline policy. - Alpine.js (
@alpinejs/cspbuild) is the eval-free official build: components are registered as real functions (Alpine.data(...)) and referenced by name, so it runs underscript-src 'self' 'nonce-…'. It is small (~15 KB gzipped), self-hostable, and telemetry-free. The constitution already sanctions Alpine.js for public paths; this ADR pins which build and the conditions.
The admin (admin-os.css) already carries a mature token system — spacing
(--sp-*), radius (--r-*), elevation (--sh-sm/--sh/--sh-lg, light-mode
adapted), and motion/easing (--t-fast/--t/--t-slow/--t-spring/--t-enter/ --t-exit). The public themes compiled by internal/theme do not: the
compiler emits only colour/font/radius/width variables. That asymmetry is the
real gap.
Decision
- Adopt Tailwind's aesthetic, not its runtime. No Tailwind dependency in
the product, in any mode. Instead, extend the Go theme compiler to emit a
shared design-primitive block — an elevation/shadow scale, easing curves,
duration tokens, a spacing scale, and motion tokens — so every public theme
inherits the same premium vocabulary the admin already uses. Pure CSS custom
properties, served same-origin via
/theme.css, zero build step, zero deps. Shadow tokens adapt per colour scheme (subtler on dark, more present on light), mirroring the admin's existing light-mode shadow overrides. - Respect motion preferences. A global
@media (prefers-reduced-motion: reduce)rule neutralises non-essential animation everywhere. Motion is an enhancement, never a requirement. - Native View Transitions for app-like navigation. Use the platform View
Transitions API — cross-document
@view-transition { navigation: auto }for the public site and named transitions for hero/article elements, plus HTMX-swap transitions in VayuOS. These run on the compositor (GPU), off the main thread, with no JavaScript animation loops and no framework. Everything degrades cleanly where unsupported. - HTMX stays the server-interaction backbone. Add
hx-boost+ view- transition swap wiring for buttery navigation without an SPA. No new dependency — HTMX is already self-hosted. - Alpine.js CSP build, vendored, for genuinely client-side islands only,
loaded only where an island exists. Self-host the eval-free
@alpinejs/cspbuild understatic/js/(with its MIT LICENSE), wired through a smallAlpine.data()registry — never inline expressions. It is used only where reactive client state genuinely beats an HTMX round-trip. Because Alpine installs a document-wideMutationObserveron start, the runtime is emitted only on pages that actually host an island (detected from anx-dataattribute in the rendered body): island-free admin pages — the overwhelming majority — ship no Alpine at all, so there is no parse cost and no always-on observer where it would do nothing. When present it stays within the admin "heavy-framework" budget (< 50 KB gzipped). HTMX remains the default; Alpine is the exception, not the rule. - The VCB theme contract validates the new tokens. As themes gain motion
and elevation tokens, the VCB theme validator (ADR-0135) is extended: it
exports the sanctioned
--vp-*token vocabulary (derived from the compiler, so it cannot drift) and warns when a third-party theme'scustom_cssreferences a--vp-*token the compiler never emits with no fallback — a typo that would silently resolve to nothing. This keeps the polish safe to scale across the ecosystem. The flagship themes (Apex, Vayu) are themselves rebuilt on this vocabulary — consuming the scheme-adaptive elevation and motion tokens rather than hardcoding their own — as the reference examples.
Consequences
- Premium, cohesive UX across public themes and VayuOS, achieved with native
CSS + a tiny sanctioned island runtime — no CDN, no build step, no
unsafe-eval, no telemetry. The single-binary, sovereign story is intact. - Faster, not slower. Avoiding a runtime CSS framework removes hundreds of KB of in-browser compilation and a third-party round-trip; compositor-driven CSS animations and View Transitions replace JS animation work, improving TTI and INP.
- The public theme output grows by a small, static token block (shared across every theme); the payload cost is negligible and cached.
- Alpine (CSP build) is the first new client dependency admitted since HTMX; it is self-hosted, nonce'd, budgeted, and scoped to the admin. Standard Alpine and any CDN delivery remain prohibited by the CSP.
- The marketing site (
docs/site, GitHub Pages) is unaffected: it already uses CDN Tailwind + Alpine and lives outside the product's CSP; nothing here touches it.
Vendor provenance
static/js/alpine-csp.min.js—@alpinejs/csp3.15.12, fetched canonically from the npm registry (CDN hostsunpkg/jsdelivrare egress- blocked, which is itself consistent with the no-CDN rule), tarball verified against the registry shasum. SHA-256 of the vendored file:566167134bb2347110904e2ced6e816d2e8d837200c158f98b72372b3bb0b9a6. Proven eval-free (zeroeval(/new Function), enforced going forward byTestVendoredAlpineIsEvalFree. MIT — license text instatic/js/alpine-csp.LICENSE.static/js/vayu-islands.js— first-partyAlpine.data()registry (filterList,disclosure,copyable), registered onalpine:init, components referenced by name only (no inline expressions). Loaded before the Alpine build; both deferred, same-origin, nonce-threaded.