Skip to main content

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​

PathPage
/Engagements
/engagements/:id (/team, /overrides, /settings, /audit)Engagement layout and tabs
/engagements/:id/datasets/newUpload 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, /methodologyGlobal 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" and redirect: "manual";
  • in development sign-in mode, adds x-dev-user from localStorage (workbench.devUser);
  • turns an opaqueredirect or 401 into a single page reload (Access session expired), guarded by sessionStorage (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:

  • CurrentReferenceProvider wraps every page in the app shell with the live version (for the engagement list, new engagements, Methodology and Admin);
  • DatasetLayout wraps dataset pages in ReferenceProvider version={dataset.referenceVersion}, so a dataset is always scored and described with its pinned version;
  • useReference() returns the compiled Reference; 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.css removes 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) gives canWrite and canLead for the UI; the server enforces the same rules.
  • Accessibility: a skip link, labelled controls, aria-live progress, 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.