Frontend workbench stack — Vite SPA + TanStack, schema-derived types, pnpm-hardened
Context
An owner directive (2026-07-21, amendment AM-15) authorizes a local-first developer
workbench UI for Detent: single-user, no auth, strictly read-only over the engine’s
read-only query contracts (effects, findings, benchmark-run summaries — specified in
docs/rfc-002-engine-design.md §9), rendering evidence: lifecycle timelines, receipts,
findings with resolutions, benchmark summaries. Constraints inherited from the repo: the UI
can never reach gate/dispatch/resolve (master doc §6.3 trust boundary; resolution is
deliberately omitted from the query surface); zero telemetry; the <30-minute stranger test
(master doc §10) is already budgeted by the CLI bootstrap, so the workbench may add no
required steps — strangers never need Node; assets ship prebuilt with the CLI. The frontend
is TypeScript regardless of ADR-0013: it consumes language-neutral JSON contracts, not
backend objects. ADR-0013’s TypeScript rejection cited npm’s worm-class supply-chain
campaigns (CISA 2025-09-23); the frontend cannot avoid npm and must therefore confine it
(dev-time only) and mitigate it mechanically. Static-serve-from-CLI is the verified pattern
of the closest comparable tools (Temporal UI assets embedded in ui-server; Airflow 3 React
UI served by the FastAPI api-server; Prefect UI served by create_ui_app); Vite’s own docs
disclaim vite preview as a production server.
Sequencing note (recorded, per policy): the independent simplification review (C2,
2026-07-21) recommended deferring the workbench until after M8. The owner’s written
directive of 2026-07-21 overrules that deferral: frontend work proceeds now, on its own
branch (web/ arrives via the frontend workstream), subject to every constraint above. The
review’s substantive cut — the two-band diff exhibit and other polish — stays deferred; the
first-slice evidence surface remains detent inspect plus a field table.
Decision
Build the workbench as a focused Vite single-page app (Vite 8, Rolldown) in web/
at the repo root, shipped as static assets served locally by the detent CLI (the local
read-only endpoint is un-deferred by AM-15; routes = query contracts + static assets + SPA
history fallback; loopback-only; zero mutation routes). Stack, one line per layer:
- Runtime/PM: Node 24 LTS (pinned), pnpm 11 with lifecycle scripts blocked
(
allowBuilds: []),minimumReleaseAge10080 (7 days),trustPolicy: no-downgrade,blockExoticSubdeps: true, committed lockfile,--frozen-lockfileCI, exact-pinned direct deps, a direct-dependency budget with per-dep justification. - Framework/routing: React + TanStack Router (typed path params for
effect_id/finding_id/run_idroutes; schema-validated typed search params carrying query filters and cursors so every evidence view is a shareable URL). No SSR: loopback serving, no SEO, no auth, no personalized first paint. - Data: TanStack Query v5 (polling via
refetchInterval;useInfiniteQueryover the cursor envelope). Types generated from the repo’s JSON Schemas by json-schema-to-typescript (schema → TS, matching the repo’s direction of authority; generated output committed, CI fails on regeneration drift). No runtime validator in the app path (loopback trust domain;schema_versionchecked at the fetch wrapper); schema validation enforced at the fixture boundary by the existingcheck-jsonschemagate pattern. Until the deferred record schemas are admitted (ADR-0019), codegen targets a vendored copy of their proposal text, clearly marked, swapped at admission. - Mock mode/fixtures: MSW 2 as the single mock layer (dev browser, Vitest,
Storybook, Playwright). Fixtures are standalone JSON files shaped as the query contracts,
schema-validated in CI, typed via
satisfiesat the handler import, resolved by scenario name — never hardcoded into components; replaceable by demo-seed-derived captures once the engine exists. - Client state beyond Query: none. URL state lives in typed search params; ephemeral state in local component state. Zustand only against a recorded trigger (cross-cutting client-only state needed by ≥3 unrelated components, not derivable from URL/server state).
- Tables: TanStack Table v8 (v9 is beta — not adopted). TanStack Virtual deferred behind a measured threshold (>1,000 mounted rows by design AND INP >200 ms); below that, cursor pagination is the fix.
- Graphs: none. Every first-slice view is a linear sequence, table, or fixed diagram — a hand-rolled static SVG timeline, not React Flow. React Flow only if a genuinely interactive multi-effect graph view is added.
- Animation: CSS transitions + View Transitions API, honoring
prefers-reduced-motion; Motion deferred. Command palette: deferred; cmdk when added. - Quality gates: Storybook 10 (CSF factories, a11y addon, vitest addon) with stories as the component-test corpus; Vitest 4 browser mode (Playwright provider) + node mode for pure logic; Playwright E2E over the built assets with local, in-repo visual baselines pinned to one CI environment; axe-core enforced per story and per E2E flow; ESLint 10 flat config + typescript-eslint strictTypeChecked + jsx-a11y; Prettier.
- Integration: Make remains the single entry point (
web-setup,web-codegen,web-dev,web-check,web-test,web-e2e,web-build);make checkrunsweb-checkwhenweb/exists; CI jobs are path-filtered onweb/**, layered, SHA-pinned actions, no secrets in any frontend job.
Alternatives
- Next.js — SSR/RSC machinery and a server runtime a local-first, loopback, no-auth workbench cannot use; largest dependency and upgrade surface; static-export mode fights its defaults.
- TanStack Start — same router plus an SSR/server-function layer whose “server” role is
already taken by the
detentCLI. - React Router 7 — untyped search params in SPA mode forfeit the typed-URL-state property the evidence views are built on.
- TypeBox / zod codegen — both invert or blur the direction of authority (JSON Schema files are the canonical, ADR-gated trust boundary); zod adds a runtime validator with imperfect 2020-12 semantics.
- Fixture server instead of MSW — a second process to run and drift.
- Biome instead of ESLint+Prettier — attractive single-binary posture, but type-aware and a11y rule coverage is currently partial vs typescript-eslint strict (see Reopen).
- npm as package manager — no default script-blocking, release-age delay, or exotic-source blocking; strictly weaker against the documented attack class.
- apps/web + JS workspace tooling — implies a multi-app JS monorepo; there is one app and the task runner is Make.
Consequences
Easier: a single static build artifact the CLI serves; typed URLs make every filtered evidence view shareable; schema-derived types + fixture validation make frontend/contract drift a CI failure in the same commit that changes a schema; stories triple as docs, visual review, and tests; strangers remain Node-free. Harder: a second toolchain (Node/pnpm) exists for contributors and CI; codegen adds a build step; polling bounds freshness at the poll interval. Conformance: CI codegen-drift check (types ≡ schemas); fixture schema-validation gate; the read-only property is structural (no mutation routes) and E2E asserts no workbench interaction issues a mutating request in mock mode.
Risks
npm supply chain remains the dominant risk even confined to dev-time: CVE-2026-45321 (TanStack namespace compromise, 2026-05) proved valid provenance can attest to malware. Blast radius: a contributor/CI machine at build time, never a stranger’s machine or the engine runtime; the mitigations above (script-blocking, 7-day release age, lockfile-only CI, exotic-source blocking, dep budget) would have neutralized that incident. CSF factories are Preview (React-only). TanStack Table v9 will eventually force a major migration. Solo-maintainer cost of a second stack — bounded by the minimal-deps budget and deferred per-need libraries.
Reopen trigger
Any of: (a) the workbench needs SSR or server-only code paths; (b) a view crosses the virtualization threshold; (c) a genuinely interactive multi-effect graph view is added; (d) Biome reaches typed-lint + a11y parity with typescript-eslint strict; (e) a supply-chain incident touches any pinned direct dependency — immediate audit; (f) TanStack Router or Query goes unmaintained >6 months; (g) Node 24 exits Active LTS (2026-10) — scheduled bump to Node 26 LTS.