Plan: Portal Design-Fidelity Pass (Epic &53)

On this page
NOTE

Authored from a re-baselined design-fidelity gap analysis (two parallel-agent workflows + a full fresh-screenshot capture of both portals, 2026-06-05). The authoritative gap report is committed at design/canopy-web/gap-analysis-2026-06-05.md.

Read Corrected Scope (re-baselining, 2026-06-05) before implementing. A first analysis pass ran against week-stale screenshots and was systematically absence-biased — it claimed components were missing that in fact ship. The corrected pass (fresh shots) flips several "missing" → "present-but-thin", which changes the work from "build the primitive" to "consume the primitive everywhere". The design primitives (HeroStrip, StatusPill, ProgramTag, EditorialFlag, MoneyCell, four-state panel macros) almost all exist in CSS + Askama macros and are simply not called — so a large fraction of the gap closes by wiring, not building.

Status

Step Description Status

MR-0 — Token & class hygiene (correctness; unblocks all visual work)

0.1

Define --orchard-accent-3 (or repoint to --orchard-accent) — referenced 4× in canopy-web.css with NO fallback (broken intake-stepper + info-banner color). Add the missing --r-2/--r-3 or repoint the ~19 references to the real --r-sm..--r-3xl scale.

Done (2026-06-05) — accent-3 → --orchard-primary (stepper) / --orchard-info (banner); 19 --r-2/--r-3 refs repointed to --r-md/--r-lg/--r-xl (rendered radii preserved; one off-scale 5px nav-link → 6px --r-lg).

0.2

Define-or-fix the undefined utility/component CSS classes that render real interactive surfaces as unstyled native controls: worker .u-input/.u-btn/.u-btn-{secondary,primary,danger,sm}/.u-input-sm/.u-bg-muted/.u-cursor-pointer/.rollup-stats; applicant .btn—​secondary (letters.rs:87 NOA-open button) + --portal-danger-*/--portal-radius fallback-only. (#699 — class audit, both portals.)

Done (2026-06-05) — worker .u- + .rollup-stats defined to the .btn/.u-select vocabulary (incl. .u-btn-warning from real usage); applicant .btn—​secondary defined, --portal-radius--portal-radius-btn, --portal-danger-→existing --portal-error- triple.

0.3

Fix the two semantic-color bugs (anchors: the income-discrepancy render in cases/tab_income.html + the case-detail household section template): income total_variance >= 0.0 colors $0.00 error-red (boundary — should be > 0.0), and case-detail empty household rendered in warning-amber service-error (reads as alarm). Reserve amber for genuine unadjudicated-IEVS variance.

Done (2026-06-05) — total_variance > 0.0 (case_detail.rs); tab_household.html empty-state → neutral o::empty_state.

0.4

Self-host Montserrat + JetBrains Mono via @font-face on BOTH portals (referenced as bare family strings ~11× worker + declared-only applicant → silent system-font fallback portal-wide). (#700 — fonts, both portals.)

Done (2026-06-05) — latin-subset woff2 (Montserrat 400/500/600/700 + JetBrains Mono 400/500/700, SIL OFL 1.1) vendored + @font-face (display:swap, latin unicode-range). Worker served via ServeDir /static/fonts/; applicant copied into the dx public/ bundle (Dockerfile) → /fonts/. Worker CSP gains explicit font-src 'self'. Shipped as MR-0b.

MR-1a — Worker dashboards: heroes + KPI tiles + panel states

1.1

Add o::hero_strip to supervisor.html + analyst.html (currently call only g::grid — no hero at all); thread real persona name + a big role-scoped primary stat into all three dashboards (e.g. worker: pending cases; supervisor: team caseload; analyst: applications in pipeline) — worker hero is present-but-thin (generic greeting, role-slug persona, no stat).

Done (2026-06-05, MR-1a) — o::hero_strip on all 3 surfaces; real persona first-name + initials from the session; big stat = honest in-flight applications count (statuses=submitted&statuses=processing, worker program-scoped, supervisor/analyst jurisdiction-wide, 200+ saturation guard). Fixed a dead status=pending query (also in at_a_glance.rs) that silently read 0 post-migration.

1.2

Enrich the hero_strip macro: leaf-watermark, persona avatar/eyebrow, Delta micro-stat node. at-a-glance: add Delta micro-stats + bordered KPI tiles (currently flat all-zero row).

Done (2026-06-05, MR-1a) — hero_strip enriched: gold eyebrow (caret + rule + jurisdiction meta), avatar initials tile, leaf watermark, big stat. Hero bg moved --orchard-primary--orchard-nav-bg (stays dark-green in dark mode so gold/white stay AA). at-a-glance now bordered inset KPI tiles. Real Delta micro-stats (hero stat_sub + per-tile deltas) deferred to #689 (no historical/delta endpoint exists — no fabricated deltas shipped); supervisor avatar-chips/MiniBar + analyst Sparkline/inline-multistat are the filed #701/#702/#703 (macro reserves the caller() slot).

1.3

Wire the four panel states for real on every dashboard panel: thread last_known_at/retry_url/retry_target/status_href into the error_block calls (currently empty → no service name / no Retry / no last-sync); add loading-skeleton branches where missing.

Done (2026-06-07, MR-B) — new authz-gated GET /dashboard/panel/{slug} re-renders ONE panel (reusing dispatch_fetch) so an error_block Retry re-fetches just that panel; the slug MUST be in the caller’s own composed surface (else 404 — a caseworker can’t fetch a supervisor panel by guessing the slug). New shared o::panel_error(slug, title, body) macro wires the real Retry (hx-get="/dashboard/panel/{slug}", hx-target="#panel-{slug} .panel-content", hx-indicator="#panel-{slug}" + a /healthz status link); all 18 real panels swap their empty-arg o::error_blocko::panel_error (the unknown_panel diagnostic keeps a retry-less error_block — a retry on an unknown slug would just re-fail). The loading skeleton is centralized in _panel_grid.html: each cell wraps the panel in .panel-content.htmx-content beside a .panel-skeleton (display:none until the cell flips to .htmx-request during a refresh), so the skeleton shows + the live content hides for the refresh’s duration without per-panel skeleton branches, and survives repeated refreshes (the Retry swaps .panel-content, not the whole cell). last_known_at is left empty — honest: panels keep no last-good cache, so there is no real "last synced" to show (a fabricated one would be a lie). Also fixed #715 and ~8 more error_text: format!("…: {e}") raw-upstream-error leaks (info-leak): every panel now logs {e} via tracing::warn! and renders static copy; the dead error_text field is removed. Tests: per-panel render tests assert the real Retry hx-get/hx-target + no leak; 2 new panel-states e2e (fragment returns just the panel; authz 404 on a cross-role / unknown slug).

MR-1b — Worker lists + case-detail: consume editorial primitives + states

1.4

Swap flat u-status-* / u-badge-program badges + raw enum/UUID cells → o::status_pill (soft-bg + dot) / o::program_tag / o::editorial_flag / o::money_cell across every dashboard list panel (my_queue, recent_applications, cross_program_alerts, team_queue, pending_hearings, recent_determinations, recent_notices, audit_events) + every list page (cases/applications/appeals/notices/renewals) + case-detail income/verifications. Humanize enums; replace raw UUIDs with HH-<8hex> case numbers.

Done (2026-06-05) — across MR-1b-1 (dashboard panels) + MR-1b-2 (full-page lists) + MR-1b-3 (case-detail). MR-1b-1: all 7 data panels render o::program_tag + o::status_pill via new shared format::status_pill_kind (honesty rule: no green for a non-terminal status) + util::presentational_short_id; fixed the broken team_queue data-kind="warn" pill + cross_program_alerts double-uppercasing. MR-1b-2: the six full-page lists swapped + 3 latent bugs fixed (applications Process button never showed; notices delivery pill always amber; /team-queue Programs column always blank). MR-1b-3: case-detail income tab (money_cell + status_pill + humanized income_type via new humanize_income_type), verifications pill normalize, identity-hero ELE badge + program-chip pill (status_classstatus_kind). Follow-up: §1.5 list error-states = MR-1b-2b (handlers swallow upstream errors into empty lists).

1.5

Add loading-skeleton + error branches to the empty-state-only full-page lists (applications/notices/appeals) and case-detail income/verifications/activity sections. Case-detail hero: render one ● SNAP ACTIVE status pill instead of all-program placeholder chips; income/verifications sections get panel_frame overline+gold-rule headers.

Done (2026-06-07) — full-page list error-states Done (2026-06-05, MR-1b-2b): applications/notices/appeals/renewals handlers thread a fetch_error flag and render o::error_block (role="alert" + Retry) ahead of the empty-state, so an upstream outage no longer masquerades as "no records" (the three-state pattern cases/search.html + /team-queue already used); the pre-existing /team-queue {e}-leak was brought into line in the same pass (all five full-page worker lists now uniform + non-leaking); 10 render tests pin error≠empty. Loading-skeletons are N/A for these server-rendered full-page lists (no async client load). Case-detail section error-states Done (2026-06-05, MR-1b-2c-A): the income, verifications, activity, and audit sections each thread a fetch_error flag and render o::error_block (full case-page reload Retry — #panel-active exists only in the tabs shell, so a page reload is the universally-correct retry across scroll/card-grid/tabs) ahead of their empty-state; audit keeps its separate chain-status pill; 7 render tests pin error≠empty. Hero program treatment Done (2026-06-05, program-rail MR) — built RICHER than this row’s original "one pill instead of chips" line, per the committed design (case-detail/shared.jsx:102-108 + case-detail-workspace-light.png show BOTH a single active-program pill in the hero head AND a full-width program-CARD rail below it, not a bare single pill). canopy-web now: (a) reads jurisdiction.toml::enabled_programs (ADR-006) at startup → CompositionStatebuild_identity_hero; (b) renders a card per known program with three states — live (navigable, status+detail), deployed-but-out-of-#632-scope (neutral ghost, non-navigable, no status leak), not-deployed (unconfigured dimmed ghost); (c) adds the single ● SNAP ACTIVE-style pill to the hero head; benefit/cert moved from the meta row into the SNAP card. Light+dark screenshot-verified (incl. a dark-mode active-card contrast fix caught in adversarial review — nav-muted→text-body/text-muted on the lighter dark teal); the scoped-worker ghost behavior screenshot-verified against auth/snap-worker.json. Follow-up fix (2026-06-06): at !520 the unconfigured ghost state was NOT actually reachable — the reader deserialized enabled_programs as a top-level key, but the real jurisdiction.toml nests it under [jurisdiction], so it silently fell back to all-live (and the all-live shots couldn’t reveal it). Fixed to read [jurisdiction].enabled_programs + a regression test parsing the real committed file; the unconfigured state (CAPS/WIC "Service unavailable") is now screenshot-verified light + dark. Remaining (the editorial half — a follow-up slice): panel_frame overline+gold-rule headers on the income/verifications sections. Editorial header half Done (2026-06-07, MR-1b-2c): the case-detail income (cases/tab_income.html) + verifications (case_detail/sections/_verifications.html) sections now render the editorial o::overline + o::gold_rule header (the design’s SectionLabel) — income keeps its program income-test rule note as a muted sub-line, verifications replaces its bare <h3> (the section’s accessible heading is shell-provided: the tabs panel is labelled by its tab button, scroll/card-grid shells emit an <h2>). Not a full panel_frame card wrapper (which would double-card inside the shell section containers). 2 render tests pin the overline+gold-rule (income + verifications); screenshot-verified light + dark. The dashboard-panel {e}-leaks belong to step 1.3 (filed #715). §1.5 now fully Done.

1.6

⌘K palette dialog/listbox ARIA: role="dialog"/aria-modal on .cy-cmdk; role="option"/aria-selected/aria-activedescendant on result rows (motion is already shipped — only ARIA remains of #639).

Done (2026-06-05, MR-1b-4) — .cy-cmdk role=dialog/aria-modal/aria-label; input role=combobox`aria-controls`+JS-managed `aria-activedescendant`; rows `role=option`aria-selected; empty note role=presentation. The cmdK Alpine _paintSelection assigns flat-index ids + toggles aria-selected + sets activedescendant (cleared on close/empty). Closes the #639 remainder; the motion half shipped earlier. A future nicety: an aria-live announcer for the empty-results state.

MR-1c — Applicant editorial primitives + sign-out

1.7

Applicant SideRail persona footer + Sign out (there is NO sign-out anywhere in the applicant portal today — functional gap, deferred-to-MR5 comment never followed). (#706.)

Done (2026-06-07) — Sign out Done (2026-06-06): a SignOut component (NATIVE form POST to the existing axum /logout route — revokes the Redis session + clears the cookie + 303 → /; not a Dioxus server-fn, so it works before/without WASM and needs no session data in the shell). Full labelled row in the wide side-rail footer; icon-only in the narrow authed top bar (+ new LOGOUT icon). Screenshot-verified wide+narrow × light+dark; a functional check confirms the click revokes the session (a subsequent /home/lookup). Fixed a latent side-rail bug the screenshot surfaced: the sticky rail stretched to the taller page-content height, pushing the margin-top:auto footer below the fold (the sign-out landed just past the viewport bottom — clickable but invisible) → pinned the rail to height:100vh + align-self:flex-start + overflow-y:auto. 2 SSR render tests. Persona footer Done (2026-06-07, MR-1c): the wide side-rail footer now shows the signed-in applicant’s name + reference code (HH-xxxxxxxx) + avatar initials, above the theme/sign-out controls. A new session-gated GET /shell/persona proxy (the IDOR boundary — reference code + application id come from the server-trusted session per ADR-026, never client input) projects {name, case_id, initials}: the reference code always comes from the session, and the name is a best-effort canopy-persons enrichment that degrades to "Your account" rather than failing the footer. The PersonaFooter Dioxus component client-fetches it after hydration (the Home use_effect/spawn pattern: SSR renders the rail without it, the browser fills it in) and renders nothing until loaded (no fabricated placeholder). The avatar uses the soft brand surface + the theme’s main text colour — white-on---portal-primary fails WCAG AA in dark mode (the dark primary lightens to a mid-teal), so the brand-tinted tile stays high-contrast in both themes. Tests: persona_from unit (full/first-only/empty/non-ASCII initials), the proxy flow tests (401 without session; projects name/case_id/initials), and 2 SSR render tests (loaded shows name+code+initials; loading renders nothing). Screenshot-verified light + dark.

1.8

Applicant Documents/Verifications: choice-button rows (not native <select>), UploadDropzone (not raw <input type=file>), left-accent + StatusPill verification rows, customer-voice labels (not raw "Income"/"Identity" tokens); aria-live/aria-busy on client-fetched lists. Welcome: 2 icon-rich path-cards (not 2 bare text buttons). Replace raw category tokens ("Income"/"Identity") with customer-voice instructions (e.g. "Income — documents showing your earnings"). (#712 choice-cards/labels; #710 UploadDropzone; #707 Welcome cards.)

Done (2026-06-07) — Welcome now leads with two icon-rich path cards (look-up + the emphasised apply card) instead of bare buttons; the emphasised card fills with a new --portal-primary-fill token (the deep brand green in both themes, since the dark --portal-primary lightens to a mid-teal that fails AA under white text). Documents swaps the raw <input type=file> for a dashed upload dropzone (<label>-wrapped native input — tap-to-pick, no JS under the strict CSP) and the <select> document-purpose picker for customer-voice choice-button rows (native radios styled as cards: "Proof of who you are", "Proof of income", … not raw identity/income tokens), plus a real file-kind chip derived from the filename and an aria-live/aria-busy list region. Verifications gives each open request a left-accent "needed" card + "Action needed" status pill and swaps its attach-a-document <select> for the same choice-button rows (+ the live region). Tests: SSR render tests (Welcome path cards; Documents dropzone + choice rows + customer-voice labels; loaded doc row kind-tile + pill; loaded verification row left-accent + pill + choice rows), a file_kind unit, all asserting zero inline styles. Screenshot-verified light + dark. The #707 Welcome footer + draft-resume portion stays with row 3.5a. (#712, #710, #707-cards.)

MR-2 — Data-rich content (fills the thin surfaces)

2.1

Resolve case-detail persons-fetch so the hero shows real HoH name + member count (today "Unknown / 0 members" even on a fully-seeded persona — confirmed real data gap, #562); refresh audit seed so the chain-status pill + event stream populate.

Done (2026-06-07) — persons-fetch verified working (premise was stale); audit-seed deferred to #717. Investigated live (demo seed): the case-detail hero, Household tab, and Income tab all resolve a fully-seeded persona correctly — Amara Okafor’s case (018ce0c4-0001-…) renders "Amara Okafor · Atlanta · 2 members", both members (Amara + Ada) with the address, and per-member income rows. build_identity_hero fetches /v1/households/{id} (which returns HouseholdWithMembers incl. members[].person_id, active=true) and parses it correctly; the demo dataset seeds the personas' households + active members. The earlier "Unknown / 0 members" was an orphan audit-only household (a 019e9e24/019ea2be ULID with audit events but no persons record), not a fully-seeded persona — the note conflated them. No code change needed. Audit seed: deferred to #717. Demo personas have no audit events (empty Audit trail) and the chain-status pill reports a global break from household-less test.tamper.* events (a breach-detection test polluting the shared dev chain). The architecturally correct fix is the seeder publishing domain events to the broker so canopy-security chains them (never SQL-injecting audit_events — that duplicates the security-critical ADR-014 hash chain → false Pub-1075 breaches), built into the converged seeder (#716), plus isolating the breach-tests. That’s demo-data infrastructure off the design-fidelity UI lane → tracked in #717, not built here.

2.2

Supervisor-scoped KPI aggregate panel (replace the borrowed worker at_a_glance → wrong labels + all-zero); supervisor caseload-trend sparkline + per-worker MiniBar panel (+ a sparkline primitive). (#701 KPI panel; #702 sparkline/MiniBar.)

Done (2026-06-07) — #701 KPI panel shipped; #702 sparkline/MiniBar deferred (backend-blocked, not fabricated). New supervisor-dashboard-kpis panel with four REAL jurisdiction aggregates — Cases in queue (open applications), Median age (computed from submitted_at), SLA at risk (/v1/renewals/overdue), Exceptions (open IEVS discrepancies) — replaces the borrowed worker at_a_glance on the supervisor surface (row 0). The "all-zero data" premise was stale (the borrowed panel already showed real counts, e.g. 45/0/15/15); the real fix was supervisor framing + the computed median-age. The design’s week-over-week delta micro-stats, the caseload-trend sparkline, and the per-worker MiniBar are all genuinely backend-blocked and deliberately NOT fabricated: deltas + sparkline need a caseload time-series endpoint that doesn’t exist (filed #718 — building an empty trend panel fights the panel architecture’s required-endpoint invariant, and a sparkline primitive with no data is dead scaffolding, so both build together with #718); the MiniBar needs a worker directory to enumerate the team (blocked on #597). Screenshot-verified light + dark.

2.3

Applicant Home active body: program cards / Discover / Recap / dated-timeline; Letters list/detail split + read/unread + plain-language summary (widen #666).

Done (2026-06-07) — Home program cards + "Coming up" timeline (real determinations) + Letters list→detail split shipped; 4 genuinely-blocked sub-parts filed not faked. The /home/state proxy now reads GET /v1/eligibility/determinations (new eligibility_url on the portal deps, 9 construction sites) and projects approved determinations into HomeProgramCard`s (program / benefit amount / cadence / renews) + a forward-looking "Coming up" timeline (renewal due / certification ends). Honest projection: only `approved rows card, empty household → no card (no fabricated $215), money/friendly_date format real data, no chrono dep. Letters → master-detail split (selectable list + the selected letter’s formal detail + on-demand official PDF). #666’s premise ("no seeded benefit data") was stale-but-incomplete: real determinations exist for ~50 worker_portal personas with no applicant passcode, while the loginnable approved persona (Carlos HH-ca7105ab) had an approved application with no determination row — a seed inconsistency now fixed (added Carlos’s SNAP determination + approval notice to the committed demo seed). Deferred, not faked: recap aggregation (#719), plain-language NOA summary (#720, authored+legal), read/unread store (#721), renewal hero-state derivation (#722). Screenshot-verified light + dark.

MR-3 — New surfaces (the genuinely-unbuilt builds)

3.1

Case-detail audit primitive (#503): bucketed editorial event-stream + sticky detail rail (JWS-signature confirm, diff, Cite-for-hearing, Export CSV/signed-PDF/compliance-report) + category taxonomy + actor avatar-chips + filter bar. Currently a plain 6-col data-table. The single biggest worker build. Reuse at the analyst Audit-Export panel (FU-12). Honesty: the design mock’s per-event JWS/summary/diff have no wire backing — built from real AuditEvent projections only; section-level chain pill is the tamper-evidence.

Done (2026-06-06) — #503 slices 1–8 all merged. slice 1 (bucketed editorial event-stream) Done: 8-category taxonomy (incl. security·FTI) + humanized titles + resolved actor chips + target refs + Today/Yesterday/week/earlier buckets, replacing the 6-col table. Slice 2 (per-event provenance + integrity) Done: a native <details> disclosure per row (full timestamp + audit-event/envelope ids + source + full target + the ADR-014 previous_hashevent_hash chain, "not chained" for pre-chain legacy rows) plus a role="alert" row-level breach banner when verify-chain’s `broken_at matches a displayed event (echoing the walker’s break reason) — wire-backed only, no fabricated JWS/summary/diff, no dead buttons; 4 unit tests; screenshot-verified light+dark. For the embedded case-detail section the <details> form replaces the design’s full-page sticky 380px master-detail rail (more accessible, no JS) — the Alpine sticky rail belongs to the full-page admin view below. Slice 3 (system-wide AuditLog admin page) Done: the rich stream projection extracted into a shared crate::audit::stream module + audit/_stream.html partial (case-detail now a consumer, behavior unchanged); new role-gated GET /audit-log (Admin/StudioAdmin, 403 otherwise + nav-link-gated) rendering the jurisdiction-wide stream (GET /v1/security/events without household_id) with a hero (scope + chain pill) and a functional server-side source-service/limit filter (allow-listed, no dead chips, no injection); 4 handler tests + 2 RBAC e2e (403 + no-nav-link); screenshot-verified light+dark. Slice 4 (CSV export) Done: an "Export CSV" link on the AuditLog hero downloads the current view (same source/limit filters) as a 16-column CSV (mirroring canopy-security’s bulk-export format, RFC-4180-quoted) via a role-gated GET /audit-log/export.csv, rendered worker-portal-side from the list endpoint — deliberately NOT a proxy of the bulk GET /v1/export/audit-events (that endpoint is human-attributed via audit.export.requested.sub and the BFF only holds a service token → would mis-attribute); 3 unit tests (CSV cols/escaping + filter passthrough) + 1 RBAC e2e (403 on export). Slice 5 (date-range filter) Done: From/To <input type="date"> pickers on the AuditLog filter bar filter the stream to a whole-day, UTC, half-open [from, to) window — the BFF maps the picked dates to UTC midnight bounds and forwards them to canopy-security as RFC-3339 …Z from/to params; the backend is an AuditListParams from/to DateTime<Utc> extension threaded into GET /v1/security/events + its store query (event_timestamp >= $from AND < $to) against the existing idx_audit_events_event_timestamp index (no migration; half-open matches list_audit_events_for_export); picked dates repopulate the inputs + ride the CSV-export link; unparseable dates are dropped (no 400); native picker glyph is theme-aware. Tests: resolve_query half-open-window + drop-unparseable + input-repopulate (render), contracts roundtrip + test-lib URL builder cover from/to, a canopy-security integration test (far-future window → 0 rows, history-wide window → unfiltered count); screenshot-verified light+dark. Slice 6 (master-detail rail) Done: the full-page /audit-log view becomes a two-column investigation surface — the bucketed stream beside a sticky 380px detail rail. Selecting an event (click or keyboard — rows are role="button") projects its full provenance into the rail (category/action header, actor, the ADR-014 hash chain, timestamp + event/envelope ids + source + target, and a real Open in case view link for household-scoped events); auto-selects the newest on load. Entirely client-side: each row carries its provenance in data- attrs and the auditRail Alpine component (CSP build — bare-method-ref handlers + imperative selection-paint like ⌘K) reads the selected dataset, so no fetch per selection. Honesty preserved: only real AuditEvent projections (no JWS badge / summary / diff / Copy-JSON / Cite-for-hearing — the signed-PDF "Cite for hearing" stays a tracked follow-up). The shared audit/_stream.html is parameterised by a rail_mode flag — the admin view (true) renders selectable rows + rail and drops the inline <details>; the case-detail section (false) is byte-unchanged (native <details>). AuditRow gains a household_id projection for the case link. Layout mirrors the house sticky-column pattern (minmax(0,1fr) 380px, position: sticky, collapses < 1024px; page widened to 1320px). Tests: render (rail + selectable rows + data- provenance + case link present, inline <details> + fabricated affordances absent; case-detail asserts the rail is not there) + household_id projection unit + a new audit-rail Playwright project (studio-admin) exercising the Alpine interaction (auto-select, click/keyboard select moves aria-current + updates the rail); screenshot-verified light+dark. Slice 7 (dedicated auditor role) Done: a real read-only WorkerRole::Auditor (the Pub 1075 §9 read persona) wired end-to-end — auditor Keycloak realm role + jane.auditor fixture (distinct from the narrower FTI-only fti_auditor), mapped at LOWEST precedence (any case-working/admin role a user also carries wins → no silent downgrade), own auditor slug + "Auditor" display + IdP-bundle entry. The auditor CAN read /audit-log + the CSV export and IS offered the Audit-log nav link (the nav conditional split so Studio stays admin-only), but CANNOT write (can_write excludes it), CANNOT reach Studio (StudioAdminOnly → 403) or jurisdiction-admin endpoints, and has no dashboard — / redirects to /audit-log (no auditor composition required). Tests: unit (from_keycloak_roles maps auditor + lowest-precedence ordering, as_str/can_write/slug/display) + a new auditor Playwright project asserting the full CAN/CANNOT matrix (200 on page + CSV, nav link present, //audit-log, /studio 403 + no Studio nav link). Slice 8 (signed-PDF "Cite for hearing") backend Done (ADR-029): canopy-notices is now the general signed-document renderer — canopy-typst::render_document (generic path) + service-gated POST /v1/documents/render (allow-listed template, JCS-canonical-data ES256 JWS via canopy-signingX-Canopy-Signature + embedded) + the audit/citation.typ template (provenance + ADR-014 chain + verify-chain attestation + signature) + a canopy document render CLI (ADR-007) + the canopy-notices signing key (SOPS + .keys fallback). Architecture settled (ADR-029, amends ADR-010; no PDF carries FTI → ADR-004 unconstrained). Tests: canopy-typst render (signed + broken branches) + path-traversal unit + canopy-notices integration (render→200/%PDF/JWS-verifies; unknown template→400). Slice 8 surface Done: canopy-web GET /audit-log/citation/{id}/pdf (Admin/StudioAdmin/Auditor) fetches the event + verify-chain from canopy-security, projects via crate::audit::stream, has canopy-notices render+sign (MR1 endpoint, via a new post_raw client), and streams the signed PDF as a download; the rail’s "Cite for hearing" button (Alpine :href from selected.id) is now REAL (the slice-2/6 dead button is wired). Honest: no citation without a real chain attestation (502 if verify-chain unreachable). Tests: the slice-6 render test flips to assert the button present + the citation href, a new audit-rail e2e (download → 200/application/pdf/%PDF), a non-admin-403 RBAC e2e; screenshot-verified light+dark. #503 slices 1-8 all merged. Follow-ups (still open under #503): the remaining richer filters (actor / category — need further AuditListParams + endpoint work); the full human-attributed bulk export (50k / date-windowed via the official endpoint — needs worker-identity forwarding, epic &52).

3.2

Analyst Application Pipeline funnel + Caseload Trend chart (replaces FU-11 stub; needs the sparkline primitive). (#703; confirm overlap with #591.)

Done (2026-06-07) — Pipeline funnel shipped from REAL data; Caseload Trend deferred to #718 (not faked). The FU-11 analyst-dashboard-reporting-rollup empty stub is replaced by a renamed analyst-dashboard-pipeline-funnel panel: five lifecycle stages (Submitted→Screening→Verification→Determination→Authorized mapped to submitted/processing/data_collected/determined/approved), each a real count (one status-filtered /v1/applications?statuses=…&limit=200 per stage, "200+" at the cap) + median days-in-pipeline. The design’s per-stage velocity arrows + the Caseload Trend chart are the same caseload-time-series wall as #702 → deferred to #718, NOT fabricated. Failure honesty mirrors the supervisor KPI panel (single failed stage → "—", all failed → error+Retry; pure unit-tested build_template). Renamed (not re-slugged) for correctness: composition baselines (georgia+default) + ANALYST_DASHBOARD_PLUGIN_SLUGS + analyst & supervisor panel-order e2e all moved. #591 is a hypothetical re-scope never referenced in code; #703 was the real tracking issue — implemented independently. Screenshot-verified light + dark.

3.3

Applicant Help page — Call-us (mono phone) / Your-worker + Send-a-message CTA / 5-row FAQ cards (14-line stub today, applicant-portal design ref §4.3). (#704.)

Done (2026-06-07) — Shipped the honest half; filed the missing backends (#725). The 14-line stub becomes the §4.3 reach-a-person surface: a primary-accent Call us card with the tappable mono helpline (tel: link) + hours; a Your caseworker card; and a five-question Common questions FAQ as a no-JS native <details> accordion (CSP-safe) with real answers inline (the "lost my ID" answer links the real /recover flow; income-change + address answers route to the real channels). HONESTY: the design’s "Your worker" card names the assigned worker (e.g. "Marcus Hamilton · Fulton") + a "Send a message" button opening a two-way thread (messages.jsx) — but no assigned-worker identity feed and no messaging service exist, so the card routes to the real channels that DO exist (helpline + Documents) rather than fabricate a name or ship a dead button; the two missing capabilities are filed as #725 (Part A assigned-worker feed, Part B secure messaging) and a does_not_fabricate_an_assigned_worker_or_dead_message_button test guards it. Internal routing links use plain <a href> (matching the sibling safety.rs informational page — keeps the page fully SSR-testable without a Router context). White-on-brand uses --portal-primary-fill (both themes, AA-safe). 3 SSR render tests (tappable helpline + real FAQ + /recover; honesty guard; zero inline styles). Screenshot-verified light + dark.

3.4

Applicant EligibilityPeek + EligibilityCheck "borderline nudge" on the apply income/household steps — the applicant-portal design ref.s stated center of gravity, entirely absent. (#705.)

Done (2026-06-07) — Shipped the real EligibilityPeek; the income-responsive tier is gated on &56, filed #726. The household step now renders a "Quick context" peek with the REAL SNAP monthly gross-income cap for the entered household size — fetched live from canopy-snap GET /v1/params (the ADR-011-traceable threshold, not a hardcoded FPL table) through a new public, non-PII GET /apply/snap-params portal proxy (the portal forwards with its ADR-019 service token; a WASM client can’t hold one — Kerckhoffs). New snap_url dep threaded through all 9 LookupDeps sites + the compose env (CANOPY_PORTALSNAP_URL, applying the #723 lesson). The peek is client-fetched post-hydration, keyed on a use_memo of the member count (re-runs on add/remove, not per keystroke), and renders nothing until a real cap loads (no fabricated number; a backend hiccup just hides it — never blocks the form). The design’s income-responsive 3-tier EligibilityCheck (likely/borderline/unlikely) needs a monthly-income amount the apply flow deliberately doesn’t collect — and the design itself mocks it (a tweaks-panel cyEstTier). Building it honestly requires an applicant-reported income fact with provenance + valid-time per ADR-027/-028 (epic &56, post-UAT), not a throwaway client field — so it’s filed as #726 (blocked-on &56), not faked. Tests: proxy flow (cap projection + upstream-failure→503), cap_value/thousands units, and an SSR honesty test (no fabricated cap server-side). Screenshot-verified light + dark.

3.5a

Applicant Apply inline field validation + required markers + per-step Overline eyebrows (#709).

Done (2026-06-07) — The apply "About you" step’s fields now do blur-gated inline validation (design entry-apply.jsx ValidatedField): an error appears only after a field is touched (so we don’t yell as people type), with a friendly per-field message + bell icon, an aria-invalid input, and the label tinted to the AA-readable --portal-error-text. Required fields carry a quiet "· required" marker (hidden once an error shows, the louder signal); optional fields (phone/email) show a helper hint instead. TextField/DateField were unified into one ValidatedField component that owns its touched signal and runs a Validator enum (Required/Phone/Address/Email — an enum, not a fn pointer, so the generated props PartialEq doesn’t hit unpredictable_function_pointer_comparisons); the four validators are pure + unit-tested. Each step now leads with a per-step gold Overline eyebrow (the step label) beside a quiet "Step N of 4" counter, above the headline. The design’s sans-vs-mono field-font split was left out of scope — the base .portal-field__input is mono (shared with the lookup code/passcode) and flipping it is a separate typography concern. 8 SSR/unit tests (validators flag bad + pass good; the step shows required markers + no premature errors + hints). Screenshot-verified light + dark (clean + error states).

3.5b

Applicant Lookup recover-my-access callout + passcode reveal toggle (#708); Welcome icon-rich path-cards + footer + draft-resume (#707).

Done (2026-06-07) — #708 + the Welcome footer shipped; draft-resume deferred (backend gap) → #727. The Lookup page now masks the passcode by default (type=password, privacy on shared/kiosk devices) with an eye reveal toggle (a progressive enhancement — the native POST form still types + submits without JS, just not revealable; aria-label/aria-pressed on the toggle, a new EYE_OFF icon), adds field hints (reusing .portal-field__hint), and a "Lost your Application ID or passcode?" recover callout routing to the existing /recover flow (plain <a>, no Router needed in tests). The Welcome screen gains the design’s help footer — a quiet "Stuck? We can help." card with the tappable Georgia helpline + the agency legal line. The Welcome icon-rich path-cards were already shipped (MR-D). The draft-resume ("Continue your draft") piece is backend-blocked — there is no GET /v1/applicants/drafts/{id} (canopy-applications has create/patch/finalize/reap only; the apply.rs module doc already noted resume needs a get-draft endpoint), so it'\''s filed as #727 (get-draft + client decrypt/restore) rather than faked. 3 Lookup SSR tests (masked passcode + reveal toggle + a11y label; recover callout + /recover link + hints; zero inline styles); the Welcome footer is screenshot-verified (its path-cards use Link, which panics in a Router-less SSR test). Screenshot-verified light + dark.

3.5c

Applicant Apply Safety DV-hotline reassurance + "No"-branch card (#711); locale switcher / es Fluent consumption (#660).

Done (2026-06-07) — #711 shipped; #660 (es Fluent) re-scoped as a standalone post-UAT i18n effort, not polish. The apply Safety step'\''s "Yes, I want extra care" branch now ends with a DV-hotline reassurance note ("If you'\''re in danger right now, call the National DV Hotline at 1-800-799-7233 — 24/7, free, confidential" — a tappable tel: link matching the public /safety screen), separating the immediate-danger off-ramp from the case-settings checkboxes above it. The "No" branch — previously a silent dead-end — now shows a shield-iconed acknowledgement card ("Got it — standard protections it is. If anything changes, you can tell your caseworker any time"); the copy is honest about the real follow-up channel (a caseworker) since the portal has no self-serve safety-settings page (the design'\''s "Help → Safety settings" link has no canopy target). 1 SSR test (Yes branch → hotline, not the No-card; No branch → the card, not the hotline). Screenshot-verified light + dark. #660 (locale switcher + es Fluent page-level consumption) is NOT a design-fidelity polish item — a readiness scan confirmed zero pages consume Fluent today (the en+es bundles load at startup but LocaleManager is server-only; CLAUDE.md + the roadmap flag applicant-portal i18n as post-UAT). Full es consumption needs locale plumbing SSR→WASM, every page'\''s strings extracted to .ftl keys + es translations, and a persisted switcher — a standalone post-UAT plan, not a backlog row; the real scope is documented on #660 (workflow::needs-spec). A half-wired switcher would be a dead affordance, deliberately avoided.

Cross-cutting

4.1

PREREQUISITE — land before MR-1 (the Acceptance + verification check depends on it). Visual-baseline capture tooling: land visual-roles.spec.ts + visual-applicant.spec.ts + visual-case-rich.spec.ts + the demo-gated vb-* projects (already authored — they produced this plan’s baseline) so each MR can re-capture its touched surface and compare to the design render.

Done (2026-06-07) — Shipped with the plan itself (commit 8f74d207): tests/e2e/specs/visual-{roles,applicant,case-rich}.spec.ts + the demo-gated vb- Playwright projects. Used as the per-MR acceptance check throughout MRs A–M (each touched surface re-captured light + dark before merge), and the throwaway zz- capture pattern (a public-surface variant that runs on the default seed) verified the applicant rows.

4.2

Docs + CHANGELOG + GitLab issue/epic updates per MR.

Done (2026-06-07) — Satisfied per-MR across the lane: each MR updated this plan’s Status row + a CHANGELOG entry, closed its issue (#699–#712), and — where a premise turned out stale or backend-blocked — filed an honest follow-up rather than faking the work (#716–#727: seed-converge, broker-audit, caseload time-series, service-wiring, dark-btn AA, applicant worker-feed+messaging, income-responsive estimate→&56, draft-resume→get-draft, es-Fluent→post-UAT i18n).

Epic: &53
Gap report: design/canopy-web/gap-analysis-2026-06-05.md (authoritative, re-baselined)
Design contract: design/canopy-web/renders/ (worker, PNG light+dark) · design reference · design reference (applicant, 1091 lines)
Existing issues (re-scoped, see Re-scopes of existing epic &53 children): #637 #557 #640 #639 #638 #641 #562 #560 #525 #591 #689 #666 #660 #503
New issues (filed under &53, see New issues to file under &53): #699 #700 #701 #702 #703 #704 #705 #706 #707 #708 #709 #710 #711 #712

Corrected Scope (re-baselining, 2026-06-05)

The first gap pass used screenshots from 2026-05-26 — predating the hero_strip landing (24baf3d2, 2026-06-02) and the demo design-polish pass (!477). It over-stated absences. The corrected pass captured fresh screenshots of every surface in both portals (light + dark; worker caseworker/supervisor/analyst + a populated Okafor case; applicant public + authed) and re-ran the analysis. The material corrections:

  1. HeroStrip ships on the worker dashboard (data-thin: generic greeting, role-slug persona, no name/avatar/watermark/stat) — but is genuinely absent on supervisor + analyst (supervisor.html/analyst.html never call o::hero_strip). The case-detail compact green hero ships.

  2. All editorial primitives + the four-state panel macros are DEFINED (CSS + o:: macros) — the gap is lists/panels don’t consume them. Case-search wires all four states correctly; most panels pass error_block empty args.

  3. prefers-reduced-motion is shipped on both portals — only the ⌘K dialog/option ARIA half of #639 remains.

  4. Applicant Documents/Letters/Verifications all ship and work; Home is data-rich for its state. The "raw FU stubs" are styled empty-states (one genuine leak: system_messages.html:22). The CAPS-chip contrast fail was already remediated.

  5. The case-detail "Unknown HoH / 0 members" is a real persons-fetch gap — confirmed on a fully-seeded persona (Okafor), not a seed artifact (#562).

Genuinely-confirmed real defects (highest confidence): the undefined-token (--orchard-accent-3, no fallback) + undefined-class (.u-input/.u-btn*/.rollup-stats/.btn—​secondary) bugs rendering unstyled native controls; the supervisor/analyst missing hero; the case-detail audit primitive unbuilt; applicant Help stub, EligibilityCheck absent, and missing Sign-out; fonts never `@font-face’d.

Design

Sequencing rationale

Four phases ordered by dependency (tokens/classes underpin every visual surface) and demo-leverage (heroes + editorial chips are the most visible per unit effort):

  • Phase 0 is pure correctness + cheap — fix the breakage so downstream visual work renders against real styling. Smallest, highest-confidence, do first.

  • Phase 1 is the editorial-density unlock — mostly mechanical macro-swaps + threading data into existing components. Biggest demo lift per effort, lowest risk (no new primitives).

  • Phase 2 fills the thin surfaces with real data (persons-fetch, supervisor aggregates, applicant Home body).

  • Phase 3 is the genuinely-new builds (audit primitive, analyst charts, applicant Help/EligibilityCheck) — the largest, sequenced last by demo value.

Re-scopes of existing epic &53 children

The existing children are necessary but must be re-scoped from "define the primitive" to "consume/wire it everywhere":

  • #637 — "HeroStrip + editorial rows: call o::hero_strip on all 3 dashboards + enrich the macro (watermark/avatar/eyebrow/delta) + consume status_pill/program_tag/editorial_flag/money_cell in every list & panel cell." (Was: define-once.)

  • #557 — "Consume status_pill/program_tag in all list & panel cells; humanize enums; replace UUIDs with HH-<8hex> case numbers." Spans dashboards + lists + applicant Verifications, not just "labels".

  • #638 — keep scoped to its token hygiene (define --orchard-accent-3/--r-2/--r-3, hex→token sweep). The undefined-utility-class audit and the self-host-fonts task were split out as NEW issues #699 + #700 (this plan does NOT widen #638 into a mega-issue — tighter MR scopes). #638 stays the token half of MR-0.

  • #639 — re-scope to the ⌘K dialog/option ARIA + applicant aria-live only (reduced-motion is shipped on both portals — close that half).

  • #640 — "Wire retry/status/last-known on every panel + add loading/error branches to empty-state-only lists and case-detail sections." (States exist; wiring doesn’t.)

  • #641 — sufficient (gold/amber-vs-red discipline) + the >= 0.0 red-at-zero + amber-empty bugs.

  • #562 — sufficient (persons-fetch / HoH-Unknown / persons-tab); confirm it covers audit-seed data-emptiness.

  • #560 — sufficient (worker responsive <900px; fixed 240px sidebar + .data-table overflow).

  • #525 — sufficient (htmx panel-refresh) but lower priority than the above.

  • #591 — confirm scope covers the analyst Application-Pipeline funnel + Audit-Export; if not, the NEW analyst-charts issue carries it.

  • #689 — sufficient (KPI tiles + deltas + token-fallback sweep).

  • #666widen to cover the applicant Home body sections (program cards / Discover / Recap / dated-timeline) + Letters list/detail/summary.

  • #660 — sufficient (locale switcher); #503 — confirm it covers the full audit primitive (stream + detail rail + export).

New issues to file under &53

  1. #699 — Define-or-fix undefined CSS utility classes (.u-input/.u-btn/.u-btn-*/.u-input-sm/.rollup-stats/.btn—​secondary, worker + applicant) — unstyled-native-control bug. (type::bug, priority::high)

  2. #700 — Self-host Montserrat + JetBrains Mono via @font-face (both portals). (type::feature, priority::medium)

  3. #701 — Supervisor-scoped KPI aggregate panel (replace borrowed worker at_a_glance; correct labels + deltas). (type::feature, priority::medium)

  4. #702 — Supervisor caseload-trend sparkline + per-worker MiniBar caseload panel (+ sparkline primitive). (type::feature, priority::medium)

  5. #703 — Analyst Application Pipeline funnel + Caseload Trend chart (FU-11) — if not covered by #591. (type::feature, priority::medium)

  6. #704 — Applicant Help page — Call-us / Your-worker + Send-a-message / 5-row FAQ cards (applicant-portal design ref §4.3). (type::feature, priority::high)

  7. #705 — Applicant EligibilityPeek + EligibilityCheck "borderline nudge" on apply income/household steps (applicant-portal design ref §4.5 center of gravity). (type::feature, priority::high)

  8. #706 — Applicant SideRail persona footer + Sign-out (functional gap — no sign-out anywhere). (type::feature, priority::high)

  9. #707 — Applicant Welcome icon-rich path-cards + help-phone/legal footer + draft-resume tile (applicant-portal design ref §6.3). (type::feature, priority::medium)

  10. #708 — Applicant Lookup recover-my-access callout + passcode reveal toggle. (type::feature, priority::medium)

  11. #709 — Applicant Apply inline field validation + required markers + per-step Overline eyebrows. (type::feature, priority::medium)

  12. #710 — Applicant inline document UploadDropzone (pay-stub prompt) on income step. (type::feature, priority::medium)

  13. #711 — Applicant Apply Safety DV-hotline reassurance + "No"-branch confirmation card. (type::feature, priority::medium)

  14. #712 — Applicant Documents/Verifications choice-card pickers + left-accent/StatusPill verification rows + customer-voice labels. (type::feature, priority::medium)

Acceptance + verification

The design contract is design/canopy-web/renders/ (worker, per-surface PNG light+dark) and the worker & applicant design references. Per-MR acceptance = the touched surface re-captured via the visual-baseline tooling (Step 4.1) renders within reach of its design render/JSX reference. Token/class fixes (Phase 0) are verified by the absence of undefined-symbol fallbacks and by the touched forms rendering styled controls. No raw UUID/enum tokens in user-facing cells (Phase 1). axe WCAG 2.1 AA holds on every touched surface (both portals already enforce it in e2e).

Edit this page · default