Worker portal redesign — Stage 1.5 panel-state primitives upgrade

On this page
NOTE

Stage 1.5 of group epic &51 (#460). Implements #505. Non-blocking follow-up to Stage 1 (#485, !349) — runs in parallel with Stage 2 / Stage 3. Composability runtime (Stage 3) consumes these primitives but does not depend on them landing first.

Status

Step Description Status

1

Macros + smoke fixture + wrapper tests (single commit). Add four new Askama macros to the existing services/canopy-web/templates/_primitives/orchard.htmlempty_state, skeleton, skeleton_row, error_block. Extend services/canopy-web/templates/_primitives/_smoke.html with one block per macro × parameter variant. Extend services/canopy-web/tests/primitives_test.rs with per-macro variant wrappers + assertions. Gate: cargo nextest run -p canopy-web --test primitives_test clean.

Done (2026-05-21)

2

Migrate consumers. Rewrite all 20 in-tree .u-empty-state consumers (dashboard, applications/appeals/notices/renewals lists, cases search/results, and the 12 case-detail tab partials) to {% call o::empty_state(...) %}. Rewrite services/canopy-web/templates/_skeleton.html and the loading block in services/canopy-web/templates/cases/search.html to {% call o::skeleton_row(...) %}. No CSS changes (the macros emit the existing .u-empty-state / .skeleton / .u-error-block classes — they are the macro’s implementation, not the macro’s competition). Stage 1 Decision 4 (reuse existing classes; do not duplicate) carries forward.

Done (2026-05-21)

3

Validating surface + error path. cases/search.html becomes the in-tree four-state validator: loading uses o::skeleton_row, empty + populated routes through cases/_results.html (empty migrated; populated unchanged), error uses a new sibling cases/_results_error.html returned by the handler on Err paths with hx-target-error wiring. axe-core asserts the validating surface remains WCAG 2.1 AA clean for every state. New tests/e2e/specs/panel-states.spec.ts exercises each state.

Done (2026-05-21)

4

Parent plan + docs + CHANGELOG. Update parent plan worker-portal-redesign.adoc Stage 1.5 row description + acceptance + files-touched + Status Not startedDone (YYYY-MM-DD) — !XXX. Reconcile parent-plan lines 22/26/264/333/369 (stale cy- references + obsolete "rename usages" language; replace with the post-Stage-1 reality). Add "Panel state four-state convention" subsection to .claude/docs/coding-conventions.md. CHANGELOG === Changed entry under Unreleased.

Done (2026-05-21)

Tracking issue: #505
Epic: &51
Parent plan: worker-portal-redesign.adoc
Branch: feat/wpr-stage1-5-panel-state-primitives (single MR)

Context

Stage 1 (worker-portal-redesign-stage1-design-system.adoc, !349) shipped 8 Askama-macro primitives for the worker portal’s structural chrome — panels, hero strips, big numbers, status pills, money cells. Panel state surfaces (empty, loading, error) were deliberately left as CSS-only utility classes because they were already in place (.u-empty-state, .skeleton + @keyframes pulse) or trivial to add (.u-error-block). Stage 1’s Decision 4 was "reuse existing classes; do not duplicate" — the trade-off was that every consumer still hand-rolls the <div class="card u-empty-state"> chrome for empty states + the <div class="skeleton u-h-10 u-w-full"> chrome for loading bars + a future ad-hoc <div class="u-error-block"> for errors.

Stage 1.5 closes that loop. Four CSS-only utility surfaces become first-class Askama macros with proper props, so every panel template (and every Stage-3 plugin) reaches for the same four primitives instead of re-rolling the chrome. The four-state convention — every panel renders empty + loading + error + populated — is the deliverable; the macros are the mechanism.

Design

Decisions locked

  1. Same file as Stage 1. The four new macros land in services/canopy-web/templates/_primitives/orchard.html alongside the original 8. Grouping the design system in one file keeps the import surface tight (one {% import "_primitives/orchard.html" as o %} line per consumer) and matches Stage 1 Decision 2.

  2. Macros wrap existing classes; no class renames or deletions. o::empty_state emits <div class="card u-empty-state">…</div>. o::skeleton emits <div class="skeleton u-h-X u-w-Y">…</div>. o::error_block emits <div class="u-error-block">…</div>. The existing CSS classes stay in canopy-web.css as the macro’s implementation — they are not deprecated, not renamed, not removed. This is a continuation of Stage 1 Decision 4. The earlier issue body’s "deprecated and removed once all callers migrate (within this MR)" language was written before Stage 1 locked Decision 4 and is superseded by it.

  3. Skeleton sizing is a discrete enum, not parametric pixels. o::skeleton(h="md", w="full") maps internally to the existing .u-h-N + .u-w-N utility classes (h ∈ {"sm"→u-h-5, "md"→u-h-10}, w ∈ {"30","60","90","full"}). No inline style="..." attribute on any primitive — canopy-web ships strict CSP style-src 'self' with no 'unsafe-inline' (services/canopy-web/src/csp.rs:27-35). Same constraint that drove Stage 1’s gold_rule enum.

  4. o::skeleton_row(columns) composes o::skeletoncolumns ∈ {2, 3, 4, 5, 6} (default 4). The macro emits a horizontal u-flex row with one .skeleton bar per column. Replaces both _skeleton.html (vertical stack of 4 bars) and cases/search.html lines 34-40 (3 horizontal bars). Implemented as five explicit {% if columns == N %} branches (no range() filter, no integer iteration) — keeps the macro template Askama-version-portable and CSP-clean. The originally-planned avatar: bool parameter was dropped — no consumer needs avatars in v1, and adding one would require a new .skeleton--avatar CSS class (violates Decision 2’s "no CSS changes" stance).

  5. o::empty_state(title="", body="", cta_label="", cta_href="") with body via {{ caller() }}. title and body are convenience args for the common case (one heading + one paragraph). {{ caller() }} is the escape hatch for richer empty states — caller can compose o::leaf_glyph or arbitrary HTML in the body slot. CTA pair (cta_label, cta_href) renders a <a class="u-link-primary"> link when both are present. Internal-only links (no external href validation needed — Askama HTML-escapes by default). The originally-planned icon enum was dropped — the caller-body escape hatch already covers the icon case without bundling folder/search SVGs in v1.

  6. o::error_block(title, body, last_known_at="", retry_url="", retry_target="", status_href=""). Required title + body strings. Optional last_known_at renders as muted timestamp text (the caller formats the timestamp server-side — the macro takes a pre-formatted &str). Optional retry_url + retry_target pair emits an htmx button with hx-get + hx-target; renders only when both are non-empty. Optional status_href renders a "Service status" link.

  7. Smoke fixture extends, doesn’t replace. _smoke.html gains four new data-smoke-block sections for the new macros. The Stage 1 contract (every macro + variant has a data-smoke-block) carries forward. primitives_test.rs::smoke_emits_no_inline_style_attributes is the CSP guard.

  8. Validating panel: cases/search.html + cases/results.html + new cases/_results_error.html. This surface already has loading + empty + populated wired (htmx-indicator block + u-empty-state block + table). Stage 1.5 migrates those three to macros and adds a fourth — _results_error.html with o::error_block(retry_url="/cases/search?q=…​", retry_target="#search-results"). Handler returns the error template on Err() from the upstream canopy-persons call. Server-side branching (handler matches Ok vs Err and renders the appropriate template) with HTTP 200 on both arms — avoids needing the htmx-response-targets extension (not currently loaded in base.html) and avoids changing the global htmx responseHandling config (which would affect every htmx-driven route in canopy-web, including the 30 action handlers that may emit 4xx/5xx with non-fragment bodies). The semantic imperfection (200 OK with error content) is local to this surface; future MRs can introduce the extension if more error surfaces need it. Picking case-search avoids dashboard.spec.ts dependency (Stage 1 deferred dashboard.html rewrite for this reason).

  9. Convention rule = documented + tested, not auto-linted. The four-state rule (every panel renders all four states) lands in .claude/docs/coding-conventions.md as the Worker portal patterns subsection. Enforcement is primitives_test.rs test coverage (every macro × every variant) + Playwright panel-states.spec.ts (validates the in-tree validating surface). A future "every-panel auto-lint" is out of scope — Askama templates are not statically introspectable enough to enforce "this panel template wires all four states" without a runtime convention test, which would be brittle. The pragmatic rule is "human review + the validating surface is the worked example."

  10. axe-core stays at WCAG 2.1 AA. No new axe rule is added; the existing tests/e2e/specs/accessibility.spec.ts axe scan continues to cover the validating surface. New panel-states.spec.ts does NOT run axe — it asserts presence + structure of each state.

Macro contracts

Macro Parameters Notes

empty_state

title="", body="", cta_label="", cta_href=""

Caller body via {{ caller() }} always rendered (empty when not supplied). title + body are plain-text convenience args (HTML-escaped). CTA link renders only when both cta_label and cta_href are non-empty. Always wraps in <div class="card u-empty-state"> for visual parity with current consumers.

skeleton

h="md", w="full"

h ∈ {"sm","md"}u-h-5 (20px) / u-h-10 (40px). w ∈ {"30","60","90","full"}u-w-30 / u-w-60 / u-w-90 / u-w-full. Emits <div class="skeleton u-h-X u-w-Y" aria-hidden="true">. The aria-hidden is per WAI-ARIA — the visible loading status is announced by the containing region’s aria-busy="true" or role="status", not by individual bars.

skeleton_row

columns: u8 = 4

columns ∈ {2..6}. Body of macro emits <div class="u-flex u-flex-row u-gap-2" role="status" aria-busy="true"> + N <div class="skeleton u-h-10 u-flex-grow" aria-hidden="true"> bars + <span class="sr-only">Loading…</span>. Composes skeleton semantically without iterating the macro itself — the columns expansion is hand-written for each value 2..6 (5 {% if columns == N %} branches) so the macro template stays free of range() / loop-counter idioms.

error_block

title, body, last_known_at="", retry_url="", retry_target="", status_href=""

title + body required. Emits <div class="u-error-block" role="alert"> + <strong>{title}</strong> + <p>{body}</p> + optional <p class="u-text-muted u-text-md">Last updated {last_known_at}</p> + optional retry button (renders only when both retry_url and retry_target are non-empty) + optional status link. Buttons use <button type="button" class="btn-primary" hx-get="{retry_url}" hx-target="{retry_target}">.

Skeleton row template shape

Because Askama 0.15 macros do not support range() over integers in the macro body and we want to keep CSP discipline, skeleton_row(columns) is implemented as five explicit {% if columns == N %} branches (one per allowed value 2..6). Each branch emits N .skeleton bars. This is the same shape as Stage 1’s gold_rule(size) enum — six explicit width classes, no parameterization.

Migration scope — consumer-by-consumer

Migration is mechanical: each <div class="card u-empty-state">{text}</div> becomes {% call o::empty_state() %}{text}{% endcall %} (body via caller).

All 20 .u-empty-state consumers:

  • dashboard.html (1 instance)

  • applications/list.html (1)

  • appeals/list.html (1)

  • notices/list.html (1)

  • renewals/queue.html (1)

  • cases/_results.html (1)

  • cases/search.html (1)

  • cases/tab_activity.html (1)

  • cases/tab_appeals.html (1)

  • cases/tab_authorization.html (1)

  • cases/tab_categories.html (1)

  • cases/tab_determination.html (1)

  • cases/tab_determination_caps.html (1)

  • cases/tab_determination_medicaid.html (1)

  • cases/tab_determination_tanf.html (1)

  • cases/tab_determination_wic.html (1)

  • cases/tab_notices.html (1)

  • cases/tab_nutrition.html (1)

  • cases/tab_time_limits.html (1)

  • cases/tab_work_req.html (1)

Skeleton consumers (2 files):

  • _skeleton.html (4-bar vertical stack) — replace contents with {% call o::skeleton_row(columns=4, avatar=false) %}{% endcall %}

  • cases/search.html lines 33-40 — replace with same

Each migrated template imports the primitive set at the top: {% import "_primitives/orchard.html" as o %}. This is the same one-line boilerplate Stage 1 calls out as a known cost.

Files Touched

NEW (added by this MR):

  • services/canopy-web/templates/cases/_results_error.html — htmx error fragment, single o::error_block invocation

  • tests/e2e/specs/panel-states.spec.ts — Playwright spec exercising loading + empty + populated + error on case-search

  • docs/modules/ROOT/pages/plans/archive/worker-portal-redesign-stage1-5-panel-state-primitives.adoc (this file)

MODIFIED:

  • services/canopy-web/templates/_primitives/orchard.html — adds 4 macros (~120 lines)

  • services/canopy-web/templates/_primitives/_smoke.html — adds 4 new smoke blocks per macro × meaningful variants

  • services/canopy-web/tests/primitives_test.rs — adds wrapper templates + assertions for every new macro variant; the existing smoke_emits_no_inline_style_attributes continues to cover the new macros via the smoke fixture

  • services/canopy-web/templates/_skeleton.html — replaces 4-bar hand-rolled stack with o::skeleton_row invocation

  • services/canopy-web/templates/cases/search.html — adds {% import %}, migrates empty state + loading block to macros, adds hx-target-error="#search-results" attribute

  • services/canopy-web/templates/cases/_results.html — adds {% import %}, migrates empty state to macro

  • services/canopy-web/src/routes/cases.rs (or wherever the case-search handler lives) — returns results_error.html on Err() paths with appropriate HTTP status

  • The other 19 .u-empty-state consumers (above list) — each adds {% import %} line and replaces the single <div class="card u-empty-state"> block

  • docs/modules/ROOT/pages/plans/worker-portal-redesign.adoc — Stage 1.5 row description / acceptance / files-touched / Status; reconcile lines 22 / 26 / 264 / 333 / 369 (stale cy- refs, obsolete "rename usages" language); also update Stage 1 Status cell from — !XXX to — !349

  • .claude/docs/coding-conventions.md — "Panel state four-state convention" subsection under Worker portal patterns

  • CHANGELOG.adoc=== Changed entry under Unreleased

OUT OF SCOPE (deferred):

  • Dashboard.html rewrite to use the Orchard primitives (panel_frame, big_number, etc.) — that’s the Stage 5 dashboard rewrite, gated on dashboard.spec.ts selector update

  • Replacing .card / .u-label-sm / .u-stat* with panel_frame chrome on the 19 non-validating consumers — Stage 5-7 surface migrations

  • Auto-linting "every panel renders all four states" via template introspection — see Decision 9; the convention is human-reviewed

  • Adding new axe-core rules — existing rules cover

Verification

Per-step gates

  • Step 1: cargo nextest run -p canopy-web --test primitives_test clean — the Askama compile-time check (the test binary compilation includes the smoke fixture + every per-macro wrapper)

  • Step 2: cargo nextest run -p canopy-web clean — full canopy-web suite still green after consumer migrations

  • Step 3: cargo xtask validate full pipeline clean before push (fmt + clippy + nextest + check-docs + Playwright E2E). The new panel-states.spec.ts must pass; existing specs (dashboard.spec.ts, case-search.spec.ts, accessibility.spec.ts) must remain green

  • Step 4: asciidoctor + /home/bitskrieg/code/cargo-target/debug/asciidoctor-lint clean on the new Stage 1.5 plan + updated parent plan + CHANGELOG

Stage acceptance

  • 4 new macros (empty_state, skeleton, skeleton_row, error_block) added to _primitives/orchard.html

  • _smoke.html exercises every new macro × at least one non-default parameter variant

  • primitives_test.rs has at least one assertion per new macro variant; smoke_emits_no_inline_style_attributes still passes (CSP guard)

  • All 20 .u-empty-state consumers migrated to o::empty_state (verify via grep -r "u-empty-state" services/canopy-web/templates/ — expect zero hits in template body content, only in CSS file)

  • _skeleton.html + cases/search.html loading block both use o::skeleton_row

  • cases/search.html four-state validator wired: loading via skeleton_row, empty + populated via existing _results.html (migrated), error via new _results_error.html

  • panel-states.spec.ts passes — exercises each of the four states programmatically

  • accessibility.spec.ts continues to pass on case-search (axe-core WCAG 2.1 AA)

  • Zero new #[allow] / unwrap outside tests / unsafe / TODO / FIXME tokens

  • Parent plan Stage 1.5 row description, acceptance, and files-touched all updated; Stage 1 Status cell updated from placeholder to !349; lines 22 / 26 / 264 / 333 / 369 reconciled

What this MR does NOT gate

  • Dashboard rewrite to consume the panel primitives — Stage 5

  • Removal of .u-empty-state / .skeleton / .u-error-block CSS classes — never (they are the macro implementation; Decision 2)

  • Auto-lint for the four-state convention — see Decision 9

Risk + Rollback

  • Risk — htmx error wiring (hx-target-error) regresses case-search happy path. Mitigation: error template + handler Err(_) branch is gated by an explicit test in panel-states.spec.ts that forces an upstream error (e.g., mock canopy-persons returning 500). Happy-path case-search.spec.ts should be unaffected because the search.html template’s outer structure is unchanged.

  • Risk — o::skeleton_row(columns) enum branches surface a typo only at runtime. Mitigation: every value columns ∈ {2..6} is exercised in _smoke.html and asserted in primitives_test.rs. A typo in one branch fails the build at cargo nextest run.

  • Risk — empty-state migration drops the leading <div class="card …"> wrapper, breaking visual parity. Mitigation: o::empty_state is contractually defined (Decision 5) to always emit <div class="card u-empty-state"> — the migration is a wrapper substitution, not a structural change. Visual diff verified manually on at least 3 representative consumers (dashboard, applications/list, cases/tab_activity).

  • Risk — _skeleton.html partial has implicit ARIA semantics (aria-label="Loading" + role="status" + <span class="sr-only">) that skeleton_row must preserve. Mitigation: macro template explicitly emits the same trio — verified via primitives_test.rs HTML inspection.

  • Rollback: revert the MR. Macros removed from _primitives/orchard.html; consumer templates revert to hand-rolled chrome; new _results_error.html + panel-states.spec.ts removed; handler reverts. Existing classes, tests, dashboard, etc. unaffected through the round-trip.

Pre-commit Q1-Q8 expectations

  • Q1 — Step 1 + Step 3 add tests for every macro variant + the four-state validator surface (panel-states.spec.ts)

  • Q2 — no unwrap outside tests; no unsafe; no #[allow]

  • Q3 — no test deletions or weakened assertions

  • Q4 — no plan deviation expected; if material deviations surface, update the plan’s Design section + file separate design-iteration issues

  • Q5 — this MR closes #505; updates parent plan Status table + reconciles lines 22/26/264/333/369; no new issues filed unless deviations surface

  • Q6 — dashboard rewrite stays out of scope; panel primitives migration to non-validating consumers stays case-by-case (we migrate the empty-state surface but not the full panel_frame chrome migration); CSS class deletion never happens (Decision 2)

  • Q7 — CHANGELOG === Changed + parent plan updates + .claude/docs/coding-conventions.md subsection

  • Q8 — zero new TODO/FIXME tokens

References

Edit this page · default