The web app
apps/web is a React 19 single-page app built with Vite 8 and Tailwind CSS 4, using TanStack Query for server state and React Router 7 for routing. It holds the UI and every export.
Layout
apps/web/src
main.tsx QueryClientProvider + RouterProvider
App.tsx routes (every page lazy-loaded)
styles.css Tailwind theme: the brand tokens, and nothing else
components/
layout/ AppShell (top bar, footer), Page (PageHeader, TabStrip), UserMenu
ui/ Button, Card, Dialog, Field, Feedback (Loading, ErrorState, Empty, Notice, Pill, Stat, Progress), Ai
Provenance.tsx ProvenanceBadge, MethodologyPanel
AuditTable.tsx audit rows with action labels
lib/
api.ts the API client and session handling
data.ts query client, query keys, useWorkforce, fetchAllRows
reference.tsx reference data providers
ingest.ts, upload.ts staff-list mapping and the upload pipeline
spreadsheet*.ts, parse.worker.ts bounded parsing in a Web Worker
referenceEdit.ts reference editor helpers
charts.ts chart colours and props
pages/ EngagementsPage, engagement/*, ingest/*, dataset/*, admin/*, Benchmarks, Methodology
exports/ every deliverable (see Exports)
Routes
| Path | Page |
|---|---|
/ | Engagements |
/engagements/:id (/team, /overrides, /settings, /audit) | Engagement layout and tabs |
/engagements/:id/datasets/new | Upload a staff list |
/engagements/:id/compare?a=&b= | Compare staff lists |
/datasets/:id (/explorer, /organisation, /savings, /classifications, /copilot, /rollout, /exports, /assumptions) | Dataset layout and tabs |
/benchmarks, /methodology | Global pages |
/admin (/cache, /audit, /reference, /prompts, /ai) | Admin |
Pages load on demand (lazy), so the first paint stays small; ExcelJS and pptxgenjs load only when someone exports (and are split into their own chunks in vite.config.ts).
The API client and sessions
lib/api.ts wraps fetch for every endpoint (api.me(), api.dataset(id), …) with types from @workbench/core. request():
- sends JSON,
credentials: "same-origin"andredirect: "manual"; - in development sign-in mode, adds
x-dev-userfromlocalStorage(workbench.devUser); - turns an
opaqueredirector401into a single page reload (Access session expired), guarded bysessionStorage(workbench.sessionReloadAt, 30 seconds); - turns network failures into We couldn't reach the Workbench… and error bodies into an
ApiError(status, code, message).
useMe() (in AppShell.tsx) loads /api/me, and calls setSignInMode() so the client knows whether it's under Access or dev sign-in.
Server state
lib/data.ts configures the QueryClient (30-second stale time, one retry except on 401/403/404, no refetch on focus) and defines query keys (keys.engagements, keys.dataset(id), keys.titles(id), …). Mutations invalidate the keys they affect.
useWorkforce(datasetId, settings, ref) is the heart of the interactive views: it fetches every row (fetchAllRows: 10,000 a page, four pages in parallel, with progress), fetches the title classifications, and runs scoreDataset(ref, rows, classifications, settings) in a useMemo. The Explorer, Organisation, Savings by group, Copilot case, Rollout plan, Assumptions preview and exports all use it; passing different settings is how the Assumptions tab previews changes without saving.
Reference data in the browser
lib/reference.tsx:
CurrentReferenceProviderwraps every page in the app shell with the live version (for the engagement list, new engagements, Methodology and Admin);DatasetLayoutwraps dataset pages inReferenceProvider version={dataset.referenceVersion}, so a dataset is always scored and described with its pinned version;useReference()returns the compiledReference;useReferenceVersion(v)loads any version (the override dialog uses the latest).
Published versions never change, so they're cached for the session.
UI conventions
- Tokens only.
styles.cssremoves Tailwind's default palette (--color-*: initial) and defines the brand colours and tints, Calibri/Carlito, radii and easings. Using a colour outside the tokens isn't possible by accident. - Icons: Lucide at a 1.5 stroke (applied globally).
- States: every page handles loading (
Loading,Progress), empty (Empty), error with retry (ErrorState) and permission-limited states (controls hidden or disabled, with a note). - Access flags:
accessFor(engagement, isAdmin)givescanWriteandcanLeadfor the UI; the server enforces the same rules. - Accessibility: a skip link, labelled controls,
aria-liveprogress, visible focus (dark teal on light surfaces, salmon on the teal bar), reduced motion honoured. - 375px: every screen works on a phone; tab strips scroll; the top bar collapses into a menu below 768px.
- Copy: British English, "we" not "you", honest labels.
See docs/DESIGN.md for the full design system.
Tests
Pure helpers get Vitest tests in apps/web/test: the API client's session handling (api.test.ts), ingest mapping and row building (ingest.test.ts), spreadsheet limits and parsing (spreadsheet-core.test.ts), reference editor helpers (referenceEdit.test.ts) and exports (exports.test.ts). Journeys are Playwright tests in apps/web/e2e. See Testing.