Worker portal redesign — Stage 1.5 panel-state primitives upgrade
On this page
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 |
Done (2026-05-21) |
2 |
Migrate consumers. Rewrite all 20 in-tree |
Done (2026-05-21) |
3 |
Validating surface + error path. |
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 |
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
-
Same file as Stage 1. The four new macros land in
services/canopy-web/templates/_primitives/orchard.htmlalongside 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. -
Macros wrap existing classes; no class renames or deletions.
o::empty_stateemits<div class="card u-empty-state">…</div>.o::skeletonemits<div class="skeleton u-h-X u-w-Y">…</div>.o::error_blockemits<div class="u-error-block">…</div>. The existing CSS classes stay incanopy-web.cssas 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. -
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-Nutility classes (h ∈ {"sm"→u-h-5, "md"→u-h-10},w ∈ {"30","60","90","full"}). No inlinestyle="..."attribute on any primitive — canopy-web ships strict CSPstyle-src 'self'with no'unsafe-inline'(services/canopy-web/src/csp.rs:27-35). Same constraint that drove Stage 1’sgold_ruleenum. -
o::skeleton_row(columns)composeso::skeleton—columns ∈ {2, 3, 4, 5, 6}(default4). The macro emits a horizontalu-flexrow with one.skeletonbar per column. Replaces both_skeleton.html(vertical stack of 4 bars) andcases/search.htmllines 34-40 (3 horizontal bars). Implemented as five explicit{% if columns == N %}branches (norange()filter, no integer iteration) — keeps the macro template Askama-version-portable and CSP-clean. The originally-plannedavatar: boolparameter was dropped — no consumer needs avatars in v1, and adding one would require a new.skeleton--avatarCSS class (violates Decision 2’s "no CSS changes" stance). -
o::empty_state(title="", body="", cta_label="", cta_href="")with body via{{ caller() }}.titleandbodyare convenience args for the common case (one heading + one paragraph).{{ caller() }}is the escape hatch for richer empty states — caller can composeo::leaf_glyphor 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-plannediconenum was dropped — the caller-body escape hatch already covers the icon case without bundling folder/search SVGs in v1. -
o::error_block(title, body, last_known_at="", retry_url="", retry_target="", status_href=""). Requiredtitle+bodystrings. Optionallast_known_atrenders as muted timestamp text (the caller formats the timestamp server-side — the macro takes a pre-formatted&str). Optionalretry_url+retry_targetpair emits an htmx button withhx-get+hx-target; renders only when both are non-empty. Optionalstatus_hrefrenders a "Service status" link. -
Smoke fixture extends, doesn’t replace.
_smoke.htmlgains four newdata-smoke-blocksections for the new macros. The Stage 1 contract (every macro + variant has adata-smoke-block) carries forward.primitives_test.rs::smoke_emits_no_inline_style_attributesis the CSP guard. -
Validating panel:
cases/search.html+cases/results.html+ newcases/_results_error.html. This surface already has loading + empty + populated wired (htmx-indicatorblock +u-empty-stateblock + table). Stage 1.5 migrates those three to macros and adds a fourth —_results_error.htmlwitho::error_block(retry_url="/cases/search?q=…", retry_target="#search-results"). Handler returns the error template onErr()from the upstreamcanopy-personscall. Server-side branching (handler matchesOkvsErrand renders the appropriate template) with HTTP 200 on both arms — avoids needing thehtmx-response-targetsextension (not currently loaded in base.html) and avoids changing the global htmxresponseHandlingconfig (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). -
Convention rule = documented + tested, not auto-linted. The four-state rule (every panel renders all four states) lands in
.claude/docs/coding-conventions.mdas the Worker portal patterns subsection. Enforcement isprimitives_test.rstest coverage (every macro × every variant) + Playwrightpanel-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." -
axe-core stays at WCAG 2.1 AA. No new axe rule is added; the existing
tests/e2e/specs/accessibility.spec.tsaxe scan continues to cover the validating surface. Newpanel-states.spec.tsdoes NOT run axe — it asserts presence + structure of each state.
Macro contracts
| Macro | Parameters | Notes |
|---|---|---|
|
|
Caller body via |
|
|
|
|
|
|
|
|
|
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.htmllines 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, singleo::error_blockinvocation -
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 existingsmoke_emits_no_inline_style_attributescontinues to cover the new macros via the smoke fixture -
services/canopy-web/templates/_skeleton.html— replaces 4-bar hand-rolled stack witho::skeleton_rowinvocation -
services/canopy-web/templates/cases/search.html— adds{% import %}, migrates empty state + loading block to macros, addshx-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) — returnsresults_error.htmlonErr()paths with appropriate HTTP status -
The other 19
.u-empty-stateconsumers (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 (stalecy-refs, obsolete "rename usages" language); also update Stage 1 Status cell from— !XXXto— !349 -
.claude/docs/coding-conventions.md— "Panel state four-state convention" subsection under Worker portal patterns -
CHANGELOG.adoc—=== Changedentry 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*withpanel_framechrome 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_testclean — the Askama compile-time check (the test binary compilation includes the smoke fixture + every per-macro wrapper) -
Step 2:
cargo nextest run -p canopy-webclean — full canopy-web suite still green after consumer migrations -
Step 3:
cargo xtask validatefull pipeline clean before push (fmt + clippy + nextest + check-docs + Playwright E2E). The newpanel-states.spec.tsmust 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-lintclean 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.htmlexercises every new macro × at least one non-default parameter variant -
primitives_test.rshas at least one assertion per new macro variant;smoke_emits_no_inline_style_attributesstill passes (CSP guard) -
All 20
.u-empty-stateconsumers migrated too::empty_state(verify viagrep -r "u-empty-state" services/canopy-web/templates/— expect zero hits in template body content, only in CSS file) -
_skeleton.html+cases/search.htmlloading block both useo::skeleton_row -
cases/search.htmlfour-state validator wired: loading via skeleton_row, empty + populated via existing _results.html (migrated), error via new _results_error.html -
panel-states.spec.tspasses — exercises each of the four states programmatically -
accessibility.spec.tscontinues to pass on case-search (axe-core WCAG 2.1 AA) -
Zero new
#[allow]/unwrapoutside 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
Risk + Rollback
-
Risk — htmx error wiring (
hx-target-error) regresses case-search happy path. Mitigation: error template + handlerErr(_)branch is gated by an explicit test inpanel-states.spec.tsthat forces an upstream error (e.g., mockcanopy-personsreturning 500). Happy-pathcase-search.spec.tsshould 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 valuecolumns ∈ {2..6}is exercised in_smoke.htmland asserted inprimitives_test.rs. A typo in one branch fails the build atcargo nextest run. -
Risk — empty-state migration drops the leading
<div class="card …">wrapper, breaking visual parity. Mitigation:o::empty_stateis 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.htmlpartial has implicit ARIA semantics (aria-label="Loading"+role="status"+<span class="sr-only">) thatskeleton_rowmust preserve. Mitigation: macro template explicitly emits the same trio — verified viaprimitives_test.rsHTML 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.tsremoved; 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
unwrapoutside tests; nounsafe; 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_framechrome migration); CSS class deletion never happens (Decision 2) -
Q7 — CHANGELOG
=== Changed+ parent plan updates +.claude/docs/coding-conventions.mdsubsection -
Q8 — zero new TODO/FIXME tokens
References
-
Issue: #505
-
Parent plan: Worker portal redesign
-
Stage 1 plan — the Decision 2/4/6 lineage Stage 1.5 continues
-
ADR-021: Composability runtime + plugin model (Stage 3 consumes these macros)
-
CSP source-of-truth:
services/canopy-web/src/csp.rs:27-35(style-src 'self'— no'unsafe-inline') -
Existing convention:
_skeleton.htmlis the current hand-rolled loading partial; this MR upgrades it to use the new macro