# apps/web adopt audit — 2026-07-17 (gap report, read-only)

**Method.** Harness `adopt` audit (read-only; no fixes, nothing edited) of
`apps/web/src` against the harness KB bars, read directly from
`D:\Dev\harness\frontend\playbooks\` (the pack's plugin skills were dormant this
session; the bar documents themselves were read in full — X1 accessibility,
A1 semantic HTML, C1 component architecture, C2 forms, C6 UX states,
B1 CSS architecture, B2 design tokens, B4 typography, D1 client state,
D2 routing/URL state, E2 server state, E3 API integration, E4 client auth,
H2 runtime resilience). Three parallel read-only auditors (accessibility ·
component/design-system · state/rendering); every High/Critical claim below was
spot-verified against source by the coordinating session, including independent
contrast computation. **Static audit only** — the app was not run; axe and the
manual keyboard/screen-reader passes X1 mandates before ship were not run.

**Verdict frame.** This is a *fix/extend* codebase, not a rebuild candidate.
The architecture underneath is sound (TanStack Query server-state layer, one
HTTP wrapper, APG-conformant shared Modal/Toast/QueryState, a well-built
`--cm-*` token system). The gaps are a **stalled design-system migration**
(three styling systems live at once), a **legacy gold palette that fails WCAG
contrast app-wide**, and a handful of cross-cutting shell gaps (URL state,
titles, token storage, code-splitting).

**Counts:** 3 Critical · 8 High · 11 Medium · 6 Low (28 finding clusters).

---

## Critical

### CR-1 · Three competing styling systems live simultaneously — B1/B2
The B1/B2 bar expects one authored system; the tree runs three at once:
1. the approved token system — `styles/tokens.css` (65 lines, `--cm-*`) +
   `components/ui/ui.css` primitives, consumed by only ~9 files (Agent section
   + Home + parts of Settings);
2. the legacy gold/navy palette — `styles/global.css:9-45` (parallel `:root`
   set: `--navy`, `--gold`, `--red`…), still dominant by usage
   (`var(--navy)` ×175, `var(--muted-ink)` ×159, `var(--line)` ×105,
   `var(--gold-2)` ×92);
3. inline JS style objects — `routes/agent/styles.ts` (408 lines of
   `React.CSSProperties`) plus **669** `style={{…}}` literals tree-wide
   (only 21 of them in `routes/agent/`).
`tokens.css:5-10` documents the coexistence as a screen-by-screen retirement;
usage counts show the retirement has stalled at the Agent section. The scope
board's "tokens are law" executor rule currently governs a minority of the app.

### CR-2 · Font fragmentation, none self-hosted, brand-font contradiction — B4
B4 bar: one self-hosted WOFF2 variable font, CLS ≈ 0. Reality: two
render-blocking Google-Fonts `@import`s loading four families —
`tokens.css:15` (Inter + EB Garamond) and `global.css:6` (Cormorant Garamond +
DM Mono + DM Sans). Actual usage runs on the legacy set (`'DM Mono'` ×127,
`'DM Sans'` ×56); the token fonts appear ~once each. `tokens.css:42` declares
the brand wordmark font as EB Garamond while every rendering consumer uses
Cormorant Garamond (`routes/agent/styles.ts:12`, `global.css:122,545`). No
self-hosting, no preload, no fallback metric override.

### CR-3 · `routes/Settings.tsx` is a 3,115-line god-module — C1
59 `useState` + 40 `useQuery`/`useMutation` + 104 inline `style={{}}` blocks +
~40 top-level declarations in one file, fusing data fetching, form state and
rendering (verified by count). Seven more files over the same C1 bar:
`components/templates/TemplateEditorForm.tsx` 1,014 · `routes/Analytics.tsx`
907 · `routes/CampaignDetail.tsx` 843 · `routes/Campaigns.tsx` 785 ·
`routes/Templates.tsx` 772 · `routes/FlowEditor.tsx` 714 ·
`routes/Automation.tsx` 657 · `components/contacts/SegmentBuilder.tsx` 626.

---

## High

### HI-1 · Global focus ring fails 3:1 non-text contrast — X1 (SC 2.4.11/1.4.11 → C1/B1)
The single app-wide focus indicator is gold #C9A96E: `global.css:67`
`:focus-visible { outline: 2px solid var(--gold) }`, re-applied at
`global.css:82-89`; form fields get a 45%-alpha gold halo (`global.css:78-80`,
`--gold-focus`). Independently computed: **2.16:1 on the #F5F5F0 canvas,
2.24:1 on white** — under the 3:1 an indicator requires. The `--cm-*`
primitives use `--cm-accent-ink` (~6:1) correctly (`ui.css:102-106`), so the
defect is the legacy `so-*` chrome — i.e. most screens.

### HI-2 · Gold small text below 4.5:1, app-wide — X1 (SC 1.4.3 → Design/B2)
Brand gold used as *text*: `.so-eyebrow` (#C9A96E, `global.css:160-168`,
rendered by `PageHeader.tsx:27` on every route) computes **~2.1:1**; `--gold-2`
#B08F4F computes **2.94:1 on canvas / 3.05:1 on white** — used for section
titles (`MessageDetailDrawer.tsx:206-213,293-301`), `AppShell.tsx:273` brand
prefix, `Login.tsx:147-154` subtitle, `.so-pill` chip text
(`global.css:255-270`). 8+ style rules, visible on essentially every screen.

### HI-3 · List UI state in `useState`, not the URL — D2
Back/reload/share loses filters, tabs, sort, and page nearly everywhere; only
5 non-test files use `useSearchParams`. Evidence: `Templates.tsx:68-74` (tab,
search, sortBy, sortDir, page, folderFilter all local — verified);
`Contacts.tsx:31-33` (7 filter facets); `CampaignDetail.tsx:55-60`;
`Inbox.tsx:36-38` (only `contactId` is URL-backed, `:28,:35`);
`Analytics.tsx:90` (dashboard range resets to 7d on every reload). The correct
pattern already exists in-tree: `Campaigns.tsx:80-81,133-139` (`?tab=` with
`replace: true`) — it just isn't replicated.

### HI-4 · Bearer token in `localStorage`; no cross-tab logout — E4
`lib/api.ts:26-42` (`TOKEN_KEY = "whatapp_token"`, verified) — XSS can
exfiltrate the token. No refresh/rotation flow (session re-hydrated via `/me`,
`AuthContext.tsx:50-61`); `logout()` (`AuthContext.tsx:69-76`) clears only the
current tab (no `storage`-event listener). Mitigating: `credentials: "include"`
is already sent (`api.ts:178`), so an httpOnly-cookie move is feasible. The
401 pub/sub lifecycle itself is well designed (`api.ts:74,153-158`).

### HI-5 · Eight badge-like components re-derive one concept — C1/B2
Five parallel status-pill implementations with divergent tokens/radii/fonts:
`components/ui/Pill.tsx` (the DS-correct one) · `components/StatusPill.tsx`
(inline styles, legacy tokens; its own doc at `:8` says it "replaces the
half-dozen near-identical hand-rolled pills" — which still exist) ·
`components/inbox/StatusBadge.tsx:74` · `components/templates/StatusBadge.tsx:99`
· `components/campaigns/CampaignStatusBadge.tsx`; plus `charts/QualityBadge`,
`templates/HealthBadge`, `contacts/ConsentBadge`. Each re-derives status→colour
independently. (Credit: all pair colour with glyph+label — "never colour
alone" is honoured everywhere.)

### HI-6 · Duplicate `SegmentedControl` — copy-drift — C1
`components/ui/SegmentedControl.tsx` and `routes/agent/SegmentedControl.tsx`
share identical roving-focus radiogroup logic; they diverge only in styling —
the agent copy uses legacy vars plus hardcoded `#fff` / `rgba(0,0,0,.05)`
(`routes/agent/SegmentedControl.tsx:85-88`). The second copy should not exist.

### HI-7 · `ui/` primitives unadopted outside the Agent section — C1
`ui/Table` is imported only by `routes/agent/{Activity,Models,ReviewQueue,Triggers}.tsx`
(verified); **14 files elsewhere hand-roll `<table>`** with copy-pasted
scaffolding: `routes/{Analytics,ApiDocs,Automation,Campaigns,Contacts,
CsvBroadcast,Flows,Reminders,Settings,Templates}.tsx`,
`components/campaigns/RecipientTable.tsx`, `components/flows/ExecutionsView.tsx`,
`components/templates/TemplateStatsPanel.tsx`, `routes/agent/Knowledge.tsx`.
Only ~9 files import from `components/ui` at all; the rest re-implement
Card/Field/Input/EmptyState as raw markup.

### HI-8 · Two empty/loading-state systems — C6/B2 seam
`components/QueryState.tsx` (legacy tokens, used ×27) vs
`components/ui/EmptyState.tsx` (`cm-empty`, used ×6) both own the empty/loading
panel with different visual language and token sets. Users crossing screens see
two designs for the identical state. (Both are individually well-built — the
defect is the split, not either implementation.)

---

## Medium

### ME-1 · Marginal text contrast in pills and muted text — X1 (SC 1.4.3)
`--cm-ok`/`--cm-ok-soft` 4.48:1 and `--cm-warn`/`--cm-warn-soft` 4.49:1 at 11px
bold (`ui.css:21-33`, `tokens.css:35-36`); `--cm-muted` #71717A on `--cm-hover`
rows 4.40:1; legacy `--muted` #6B6B6B on the `--bg-2` #ECEAE0 gradient band
~4.42:1 — `global.css:36-38` itself documents `--muted` as borderline, and the
body renders on a gradient (`global.css:107-111`) so ratios dip with position.

### ME-2 · Clickable `<tr>` rows are mouse-only — X1 (SC 2.1.1 + 4.1.2 → C1)
No role/tabIndex/key handler on row-click interactions:
`components/flows/ExecutionsView.tsx:52` (verified; expand affordance is a bare
`aria-hidden` span — RunDetail unreachable by keyboard),
`routes/agent/Activity.tsx:224-228` (turn detail opens on row click only),
`routes/agent/ReviewQueue.tsx:169-172`. The good in-tree patterns:
`Contacts.tsx:289` (real `<Link>`), `SortableTh.tsx:33` (real `<button>` +
`aria-sort`).

### ME-3 · No per-route `document.title` — X1 (SC 2.4.2)
Zero `document.title` writes in `src` (verified: 0 matches across ~30 routes).
Every page shares one static tab title; SR/tab users cannot tell routes apart.

### ME-4 · No skip link — X1 (SC 2.4.1 → A1/C1)
8-item `<nav aria-label="Primary">` (`AppShell.tsx:70`) precedes `<main>`
(`AppShell.tsx:132`) on every page with no bypass mechanism; `<main>` has no
id/tabIndex target. Landmarks themselves are correct, which mitigates for SR
landmark nav but not keyboard-only users.

### ME-5 · No route-change focus management — X1
`<Outlet>` swaps content with no focus reset or announcement; after
`navigate()` focus stays on the stale trigger.

### ME-6 · Toast live region not pre-rendered — X1/C6
`Toast.tsx:187` returns `null` when no toasts, so the `aria-live` container
(`:189-197`) is inserted together with the first toast — several screen readers
miss mutations to a region that did not pre-exist. Everything else about the
toast a11y is correct.

### ME-7 · Raw hex/rgba bypass both token tiers — B2
Outside `tokens.css`: 65 hex + 50 rgba in CSS (`global.css` ×33 hex/×25 rgba —
its whole `--*-soft/--*-line` sub-palette is hardcoded rgba at `:11-45`;
`routes/agent/posts.css` ×20 hex — a feature sheet inventing colours
off-system), plus ~110 raw hex in `.tsx/.ts` inline styles
(`routes/agent/styles.ts:363`, `Pager.tsx:115,133`,
`routes/agent/SegmentedControl.tsx:85`).

### ME-8 · 669 inline `style={{}}` blocks as a primary styling mechanism — B1
Defeats cascade/layers/lint entirely. Concentrations: `Settings.tsx` ×104,
`components/flows/flow-step-editors.tsx` ×47, `Analytics.tsx` ×35,
`TemplateEditorForm.tsx` ×30, `FlowEditor.tsx` ×26. Even shared primitives
(`StatusPill`, both `StatusBadge`s, `Pager`, `Pagination`) are authored inline.

### ME-9 · One global ErrorBoundary; no per-route containment — H2
Single `<ErrorBoundary>` at `App.tsx:147` (verified) mounted *outside*
`AppShell` — any route render error blanks the entire shell including nav
(`ErrorBoundary.tsx:76-77` says so). No `window` `error`/`unhandledrejection`
last-resort handlers (0 matches). The boundary itself offers reset + reload
(`ErrorBoundary.tsx:53-64`) — placement/granularity is the gap.

### ME-10 · No runtime validation at the API response boundary — E3
No schema validation anywhere in `apps/web` (no zod/valibot dependency, no
`safeParse` in src); every `lib/*-api.ts` casts the wire to hand-written
interfaces that mirror backend DTOs by hand (e.g. `contacts-api.ts:114-118`,
`inbox-api.ts:75-90`) and will drift silently. Error parsing is an ad-hoc
union (`api.ts:113-133`), not problem+json. Notable because the project rule
is "Zod at the boundary" — the API enforces it inbound; the web app trusts
outbound blindly.

### ME-11 · No code-splitting; unbounded DOM growth in Inbox — E1/rendering
`App.tsx:14-45` statically imports all ~40 routes; zero `React.lazy`/dynamic
`import()` (verified) — Analytics charts, the agent suite and the heavy
`@xyflow/react` flow editor all ship in the single initial chunk. No
virtualization anywhere; two spots grow without bound: `Inbox.tsx:253` renders
every message of the open thread, and `Inbox.tsx:73-76,128` renders all
accumulated infinite-query pages permanently.

---

## Low

- **LO-1 · Targets under 24×24px** — X1 (SC 2.5.8): `Toast.tsx:500-510` close
  (~20px), `Pager.tsx:137-141` arrows (~22px). Row-action icon buttons are
  30×30 and fine.
- **LO-2 · IG post thumbnails `alt=""`** — A1 (SC 1.1.1):
  `routes/agent/Posts.tsx:207,321` mark content images decorative (adjacent
  captions soften it).
- **LO-3 · Form error association gaps** — C2: `NewContactDialog.tsx:130-147`
  error not tied via `aria-describedby` (leaks into the accessible name);
  `ReplyComposer.tsx:74-95` `role="tab"` without tabpanel/arrow-keys.
- **LO-4 · Templates/Campaigns fetch full lists, filter client-side** — E2:
  `Templates.tsx:91-94,161-185`, `Campaigns.tsx:92-97,114-121`; deliberate for
  live tab counts, unbounded payload as tenants grow. Contacts/CampaignDetail
  show the server-paginated pattern to copy.
- **LO-5 · `AuthContext` value not memoized; duplicated `qs()` helper** —
  D1/E3: `AuthContext.tsx:79` fresh object per render (small blast radius);
  `contacts-api.ts:105-112` vs `inbox-api.ts:82-89`.
- **LO-6 · `Pager` vs `Pagination` misleading naming** — C1: genuinely
  different concerns (page-based vs load-more; `Pagination.tsx` exports
  `LoadMore`), but the names invite confusion and both hardcode legacy
  `.so-btn` + inline styles.

---

## What passes the bar (do not re-litigate)

- **E2 server state — meets/exceeds.** TanStack Query v5 everywhere (~60
  modules): global client with deliberate freshness (`main.tsx:13` `retry: 1,
  staleTime: 30_000`), conditional polling (`CampaignDetail.tsx:78`
  `status === "sending" ? 5000 : false`; `Inbox.tsx:59,69`), cursor infinite
  query, `keepPreviousData`, invalidate-on-settle mutations, zero `setInterval`,
  zero raw fetch-in-useEffect, no server data copied into client stores, no
  race/staleness bugs found.
- **E3 single HTTP path.** `lib/api.ts` `request<T>()` is the one wrapper; all
  ~25 API modules route through it; centralized `ApiError`; Meta error
  normalization (`lib/meta-error-map.ts`) consumed at the right surfaces.
- **E4 401 lifecycle** (storage aside): pub/sub `onUnauthorized`, auth
  endpoints carved out so login 401s surface in-band; `RequireRole` guards
  before render and documents the server as real enforcer.
- **APG-conformant shared components:** `Modal.tsx` (full dialog pattern:
  focus in/trap/restore, Escape, `aria-modal`, labelled close),
  `QueryState.tsx` (all four states, `role="status"`/`role="alert"`),
  `Toast.tsx` (polite/assertive split, pause on hover/focus), both
  `SegmentedControl`s (textbook radiogroup), `SortableTh` (`<button>` in
  `<th aria-sort>`), charts expose `role="img"` + data-summarizing labels,
  icon buttons consistently `aria-label`ed, `prefers-reduced-motion` honoured
  globally (`global.css:442-455`), correct landmarks and one `<h1>` per route.
- **The `--cm-*` token system itself** — zero raw hex inside `ui.css`, correct
  `:focus-visible` rings, tabular numerals, measure caps. Quality is on-bar;
  adoption is the gap.

## Not verified (static audit limits)

Rendered contrast on the gradient body; screen-reader announcement behaviour
(esp. ME-6 first-toast); live keyboard/tab-order pass; axe zero-violations run
(mandatory per X1 before certifying); bundle-size numbers (no build run);
`index.html` base title.

---

## Candidate board items

One line each, ready to paste into `plans/rethink/scope-board.md` backlogs.
(This report does not edit the board.)

**App shell & Home**
- A11y shell package: per-route `document.title`, skip link to `<main>`, route-change focus reset, pre-rendered toast live region (audit ME-3/4/5/6 — all shell-level, one small package).
- Focus-ring + gold-text contrast fix: replace `--gold` focus ring and `.so-eyebrow`/`--gold-2` text usages with ≥3:1 / ≥4.5:1 tokens; tune `--cm-ok/warn` soft pairs past 4.5:1 (audit HI-1/HI-2/ME-1; token change in `global.css`/`tokens.css`, app-wide effect).
- Auth-session hardening: move bearer token out of `localStorage` (httpOnly cookie or in-memory + refresh), add cross-tab logout via `storage` event (audit HI-4; needs a small API seam).
- Route-level code-splitting (`React.lazy` per route, `vite:preloadError` guard) + per-route error boundaries inside the shell so a route crash keeps the nav (audit ME-9/ME-11).
- Design-system convergence package: collapse 8 badges + 2 SegmentedControls + 2 empty-state systems into `components/ui`, then retire `global.css` legacy palette screen-by-screen with a lint gate (stylelint no-raw-hex, no new `style={{}}`) so the migration can't stall again (audit CR-1/HI-5/HI-6/HI-7/HI-8/ME-7/ME-8).
- Font consolidation: pick one font system (tokens say Inter + EB Garamond; usage says DM Sans/Mono + Cormorant), self-host WOFF2, drop both Google-Fonts `@import`s (audit CR-2 — a design decision the operator should rule on).
- Runtime validation at the web API boundary (Zod parse of responses in `lib/api.ts`, problem+json error shape) — honours the project's own "Zod at the boundary" rule (audit ME-10).

**Agent**
- Keyboard operability for row-click tables: Activity + ReviewQueue rows need a real button/link affordance (audit ME-2); Posts thumbnails need real `alt` text (audit LO-2).
- Retire `routes/agent/styles.ts` + `agent/SegmentedControl.tsx` onto the `ui/` primitives and `cm-` classes; `posts.css` colours onto tokens (audit CR-1/HI-6/ME-7 — the Agent section is the DS flagship and still carries 21 inline blocks + a 408-line style-object module).

**Channels & Connections**
- Split `routes/Settings.tsx` (3,115 lines, 59 useState, 40 queries) into per-tab modules with extracted forms (audit CR-3 — natural fit for this scope's package 2 alongside the connections surface).
- Inbox scalability: virtualize/paginate the open-thread timeline and cap rendered infinite-list pages; move Inbox filters (`search`, `failedOnly`, `templateOnly`) into the URL alongside `contactId` (audit ME-11/HI-3).
- Contacts: move the 7 filter facets + page/rowsPerPage into the URL (audit HI-3); fix `NewContactDialog` error `aria-describedby` association (audit LO-3).

**Analytics**
- Put the dashboard `range` in the URL so views are shareable/survive reload; split the 907-line `Analytics.tsx` and move its 35 inline style blocks onto tokens when this scope next opens (audit HI-3/CR-3/ME-8).

**Automation & Reminders**
- When Engine v2 replaces the black-box page: build the new screens on `ui/` primitives (Table) + tokens and URL-backed filters, rather than porting `Automation.tsx`'s hand-rolled table and local state (audit HI-7/HI-3 — cheaper as a build-time bar than a retrofit).

**WhatsApp surfaces** (dormant — fold into the already-planned reskin package)
- Reskin package should also cover: URL state for Templates/Campaigns list controls (tab/search/sort/page), server-side pagination for the full-list fetches, split of Templates/CampaignDetail/TemplateEditorForm god-files, ExecutionsView keyboard-operable rows, Pager/Pagination rename (audit HI-3/LO-4/CR-3/ME-2/LO-6 — all in dormant surfaces, none urgent until the number returns).
