Plan: Worker Portal Redesign — Stage 5 MR4: Case Detail Composition
On this page
- Status
- Context
- Scope
- Design decisions resolved
- Approach — MR-splitting strategy
- Prerequisites (verified)
- Design
- D1 — Per-role case-detail TOML shell schema
- D2 —
RawCompositionextension + loader branch - D3 — System defaults
- D4 — 20 section plugin registrations
- D5 — Shell templates
- D6 — Handler refactor (
get_case_detail) - D7 —
section_applies_to_programhelper + dispatch_fetch wiring - D8 — Section short-slug helper + display_name source
- D9 — Shell-aware action redirect (27 handlers)
- D10 — Alpine
caseDetailFocuscomponent (scroll + card_grid) - D11 — CSS
- D12 —
CaseIdentityHero(pre-rendered)
- Steps
- Critical files
- Existing utilities to reuse
- Follow-ups (filed at Step 1)
- Verification
- Pre-implementation checklist
Status
| Step | Status | Notes |
|---|---|---|
MR4a — Composition pipeline + per-role TOML force-migrate |
In progress |
Branch |
MR4b — Scroll shell + 20 section plugins |
Not started |
Branch |
MR4c — Card grid + 27-action shell-aware redirect |
Not started |
Branch |
Cross-cutting — File 6 FU GitLab issues |
Done (2026-05-24) |
Issues #561, #562, #563, #564, #565, #566 filed + linked to epic &51 before this .adoc was ported. |
Context
Stage 5 MR1 (#495), MR2 (#496), MR2.1 (!357), and MR3 (#498, ADR-024) shipped composition-driven worker / supervisor / analyst dashboards plus the customize UI. The Stage 3 composition runtime already supports ComposableSurface::CaseDetail end-to-end: loader.rs::load_composition calls find_case_section per ItemSlug, validates allowed_spans, runs the role filter, and maps raw.shell to CaseDetailShell::{Scroll, CardGrid, Tabs} via parse_case_detail_shell. The Georgia baseline TOML at rulesets/georgia/composition/case_detail.toml is a stub (shell = "tabs", items = []); case_detail user-layer rows continue using RFC 6902 per ADR-024 §scope.
Today, services/canopy-web/src/api/case_detail.rs::get_case_detail is hardcoded — it does NOT call load_composition. It dispatches to Program::tabs() to build a tab list and renders one of 16 tab_*.html partials via the get_tab htmx swap handler. 24 caseworker action handlers across actions{,_tanf,_medicaid,_caps,_wic}.rs plus 3 in income.rs POST to /actions/… and redirect to /cases/{household_id} with no section targeting.
MR4 wires composition through case-detail end-to-end, splits across three atomic MRs (each independently revertible, each fits one branch per project convention), and unblocks Stage 6 (Studio). This is the last Stage 5 surface.
Scope
In scope (MR4a + MR4b + MR4c combined):
-
Replace hardcoded
Program::tabs()dispatch with the composition runtime for the case-detail surface. -
New per-role TOML shell schema (
[shell_per_role.<role>] strategy = "…") forcase_detailonly; migrate Georgia immediately, all 4 roles ontabsat MR4a end. -
Loader extension:
RawComposition.shell_per_role: HashMap<String, RoleShellEntry>for CaseDetail; loader picks the request’s role entry. Non-case-detail surfaces keep scalarshell: String(untouched). -
All 13 design
SECTION_REGISTRYsections registered as#[canopy_plugin][case_sections.*](household, income, determination, notices, appeals, activity, abawd, work_req, time_limits, categories, authorization, nutrition, guidance). -
7 issue-only stub sections registered as
#[canopy_plugin](persons, assets, expenses, verifications, audit, cross_program, documents) wired to a shared "coming soon" placeholder. -
Total 20 section plugins under
services/canopy-web/src/case_detail/sections/. -
ScrollShellTemplate+CardGridShellTemplate+ retainedTabsShellTemplate(existingdetail.htmlrestructured). -
CaseIdentityHero— single shell-agnostic hero pre-rendered to String by the handler; shell templates embed via|safe. -
target_section: Option<String>field added to all 27 caseworker action form structs; all 27Redirect::tocalls migrate to/cases/{household_id}?focus_section={short_slug}(with server-side allowlist validation against the 20 known short_slugs). -
Case-detail handler reads
focus_sectionquery param + pre-selectsactive_section(tabs) OR emits AlpinecaseDetailFocusdirective (scroll, card_grid). -
axe-core WCAG 2.1 AA: 0 critical + 0 serious violations across 3 shells × 2 color schemes.
-
Playwright
case-detail-{tabs,scroll,card-grid}+-focus+-darkprojects.
Out of scope (filed as FUs per §"Follow-ups"):
-
User-layer overrides for case-detail (#561 — ADR-024 explicitly excludes case_detail from
user_delta_v1). -
Real data wiring for the 7 stub sections (#562).
-
Studio writing UI for case_detail compositions (#563).
-
intake_screenerrole — doesn’t exist inWorkerRoleenum (#564). MR4 ships the 4 real roles only. -
Cross-program summary view (
?program=all) — keeps existing template; not section-driven. -
htmx tab-swap removal for
tabsshell — MR4 keepsget_tabhandler (still serves rendered section partials internally).
Design decisions resolved
-
Union of both section lists. Deliver all 13 design SECTION_REGISTRY sections PLUS 7 stubs for issue-only sections wired to placeholder "coming soon" framing. Total 20 section partials.
-
Force per-role TOML schema for case-detail only. TOML key matches the Rust field name byte-for-byte:
[shell_per_role.X]↔RawComposition.shell_per_role[X]. Loader extension onRawComposition; loader’sparse_case_detail_shellpicks the entry matching the request’s role. Section list at rootsections = […]shared across all shells. Dashboards keep scalar shell (untouched). No back-compat. -
Shell-aware action redirect across 27 handlers (4 SNAP + 5 TANF + 5 Medicaid + 5 CAPS + 5 WIC + 3 income). Each handler form gains
target_section: Option<String>. Redirect:/cases/{id}?focus_section={short_slug}validated against server-side allowlist. Case-detail handler pre-selectsactive_sectionfor tabs OR emits AlpinecaseDetailFocusdirective for scroll/card_grid. -
Slug shape convention. Composition
itemslugs are FULL (case-detail-notices-section). URL query parameters, DOMid="sec-{X}", htmx tab dispatch URL components, and handlertarget_sectionvalues use SHORT slugs (notices). A pure helpershort_section_slug(full: &str) → &strconverts;RenderedSectioncarries both forms precomputed. -
Hero is pre-rendered, not included. Askama includes render in the parent context, so the hero’s struct fields wouldn’t be visible to an
{% include %}site. Handler rendersCaseIdentityHerotoidentity_hero_html: Stringand the shell template embeds via{{ identity_hero_html|safe }}. MirrorsRenderedPanelpattern from Stage 5 MR1. -
Body duplicated across
{% block content %}and{% block topbar_content %}— base.html renders ONE of the two peris_sidebar. Each shell template uses anshell*_body.htmlinclude from both blocks (mirrors MR3customize.htmlpattern).
Approach — MR-splitting strategy
| MR | Branch | Net change | UX at end-of-MR |
|---|---|---|---|
MR4a |
|
Composition pipeline wired. Loader supports per-role |
Georgia all 4 roles render existing 6-tab UX. Zero visual regression. |
MR4b |
|
13 + 7 = 20 section plugins ( |
Caseworker UX unchanged (still tabs). Supervisor sees the new scroll shell with anchor-nav + 20 sections stacked. |
MR4c |
|
|
Caseworker after recording an interim contact lands back on the case detail page with the appropriate tab pre-selected (tabs) or with the relevant section scrolled-into-view + focused (scroll, card_grid). |
A single mega-MR (~3000 LOC) would be too large for review, would require atomic test execution across all three shells, and would risk merge conflicts with parallel Stage 6 prep work.
Prerequisites (verified)
| Fact | Reference |
|---|---|
|
|
|
|
|
|
|
|
|
|
Manifest validator ( |
|
|
|
Loader’s |
|
Loader Step 9 + 11 already dispatch |
|
|
|
Georgia |
|
Georgia |
|
Case detail handler is hardcoded — does NOT call |
|
htmx tab swap handler |
|
16 tab partials exist in |
|
24 caseworker action handlers redirect to |
grep results |
23 form templates currently exist in |
grep |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Action redirect convention: |
grep results above |
HANDOFF.md color tokens fully shipped (23 semantic tokens) per MR3 step 4 |
|
Alpine CSP build: |
|
|
|
|
|
|
|
Existing tabs |
|
|
|
Design
D1 — Per-role case-detail TOML shell schema
# rulesets/georgia/composition/case_detail.toml
# TOML keys match Rust field names: shell_per_role ↔
# RawComposition.shell_per_role.
[shell_per_role.eligibility_worker]
strategy = "tabs"
[shell_per_role.supervisor]
strategy = "scroll"
[shell_per_role.analyst]
strategy = "card_grid"
[shell_per_role.jurisdiction_admin]
strategy = "tabs"
[[sections]]
item = "case-detail-household-section"
row = 0
span = 12
# ... 19 more (full TOML in D6 below)
Section list uses the same -style array-of-tables shape as dashboard TOML (deserializes through the same ComposedItem struct; loader’s existing path works unchanged for the slug+row+span shape). Section list is shared across all four [shell_per_role.<role>] strategies (one list, four shells).
The per-role table form replaces the existing scalar shell = "tabs" for case_detail ONLY. Dashboard TOMLs (worker_dashboard.toml, supervisor_dashboard.toml, analyst_dashboard.toml) keep scalar shell (untouched). No back-compat shim.
D2 — RawComposition extension + loader branch
// crates/canopy-composition/src/types.rs
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RawComposition {
/// Dashboard / SignIn shells use this scalar form. CaseDetail
/// IGNORES this field — see `shell_per_role`.
#[serde(default)]
pub shell: String,
/// CaseDetail-only per-role shell strategy table. Map from
/// `role_slug` to `RoleShellEntry`. Loader picks the entry
/// matching the request role.
#[serde(default)]
pub shell_per_role: HashMap<String, RoleShellEntry>,
/// Dashboard surfaces use `items`. CaseDetail uses `sections`.
/// Both deserialize via `Vec<ComposedItem>`; loader picks the
/// right one per surface.
#[serde(default)]
pub items: Vec<ComposedItem>,
#[serde(default)]
pub sections: Vec<ComposedItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RoleShellEntry {
/// `"tabs"` | `"scroll"` | `"card_grid"`.
pub strategy: String,
}
Loader change in loader.rs::load_composition:
-
Step 7 (post-merge deserialize) — unchanged.
RawCompositionnow carries bothitemsandsections; the union deserializes cleanly. -
Step 9 (export resolution) — for
CaseDetail, iterateraw.sectionsinstead ofraw.items(existingfind_case_sectionlookup unchanged). -
Step 10 (role filter) — call existing
filter_items_by_role(&mut raw.sections, …)for CaseDetail. The function already accepts&mut Vec<ComposedItem>so NO signature change required. -
Step 11 (span/row validation) — iterate
raw.sectionsfor CaseDetail. -
Step 12 (shell mapping) — NEW: case-detail branch reads
raw.shell_per_role[&role.0]:
ComposableSurface::CaseDetail => {
let entry = raw.shell_per_role.get(&role.0).ok_or_else(|| {
CompositionLoadError::ShellNotConfiguredForRole {
role: role.0.clone(),
}
})?;
ShellSpec::CaseDetail {
shell: parse_case_detail_shell(&entry.strategy),
}
}
New error variant on CompositionLoadError: ShellNotConfiguredForRole { role: String } → HTTP 500 in canopy-web.
D3 — System defaults
crates/canopy-composition/defaults/case_detail.json:
{
"shell_per_role": {
"eligibility_worker": { "strategy": "tabs" },
"supervisor": { "strategy": "tabs" },
"analyst": { "strategy": "tabs" },
"jurisdiction_admin": { "strategy": "tabs" }
},
"sections": []
}
Default = all 4 roles on tabs; sections empty (jurisdiction baseline TOMLs overlay their full section list per RFC 7396).
D4 — 20 section plugin registrations
Each registers via #[canopy_plugin] macro. Plugin slug = full composition slug; short slug derived by helper.
Schema notes:
-
Manifest validator (
manifest.rs::KNOWN_PROGRAMS) accepts only the 5 explicit programs:snap,tanf,medicaid,caps,wic. There is NO"all"token. Cross-program sections list all 5 explicitly:programs = ["snap", "tanf", "medicaid", "caps", "wic"]. -
Role gating lives in
[permissions](PermissionsMeta) at the plugin-manifest top level, NOT inside[case_sections.*]. TheCaseSectionDefschema has norequired_rolesfield; attempting to put it there fails thedeny_unknown_fieldsdeserialize.
The 13 design SECTION_REGISTRY sections (sections.jsx:308-322):
| Composition slug | Short slug | programs = […] |
Span | Source partial today |
|---|---|---|---|---|
|
|
5-program |
12 |
|
|
|
5-program |
12 |
|
|
|
5-program |
12 |
|
|
|
5-program |
6 |
|
|
|
5-program |
6 |
|
|
|
5-program |
12 |
|
|
|
|
12 |
Inline in |
|
|
|
6 |
|
|
|
|
6 |
|
|
|
|
12 |
|
|
|
|
12 |
|
|
|
|
12 |
|
|
|
5-program |
12 |
|
("5-program" = ["snap", "tanf", "medicaid", "caps", "wic"].)
The 7 issue-only stubs — all wire to a shared "coming soon" partial. case-detail-{persons,assets,expenses,verifications,audit,cross-program,documents}-section each programs = ["snap", "tanf", "medicaid", "caps", "wic"] (truly cross-program). default_span=6 or 12 per TOML in D1.
Determination cross-program dispatch
The 5 existing tab_determination*.html partials collapse into ONE section plugin case-detail-determination-section. The handler reads ?program= query (or household’s primary program via Program::tabs()) and dispatches at fetch time:
// services/canopy-web/src/case_detail/sections/determination.rs
pub async fn fetch(
clients: &ServiceClients,
household_id: &str,
program: Program,
) -> RenderedSection {
match program {
Program::Snap => render_snap_determination(clients, household_id).await,
Program::Tanf => render_tanf_determination(clients, household_id).await,
Program::Medicaid => render_medicaid_determination(clients, household_id).await,
Program::Caps => render_caps_determination(clients, household_id).await,
Program::Wic => render_wic_determination(clients, household_id).await,
}
}
5 Askama templates land at templates/case_detail/sections/determination_{snap,tanf,medicaid,caps,wic}.html extracted from the existing tab_determination*.html files.
abawd section source resolution
There is no tab_abawd.html. The existing render_program_tab(clients, household_id, Program::Snap, "abawd", csrf_token) helper fetches /v1/abawd/tracking?household_id={id} + renders inline HTML. MR4b extracts to a dedicated section plugin.
Plugin.toml for the abawd plugin (required_roles goes under [permissions], not [case_sections.*]):
# services/canopy-web/src/case_detail/sections/abawd/Plugin.toml
[plugin]
slug = "case-detail-abawd"
name = "Case detail ABAWD section"
version = "1.0.0"
author = "canopy"
license = "AGPL-3.0-or-later"
canopy_min = "0.1.0"
[plugin.exports]
case_sections = ["case-detail-abawd-section"]
[case_sections.case-detail-abawd-section]
display_name_key = "case_detail.sections.abawd"
icon = "🕒"
programs = ["snap"]
default_span = 12
allowed_spans = [12]
required_states = ["empty", "loading", "error", "populated"]
[data]
source = "canopy-snap"
auth = "service_class"
cache_ttl_seconds = 30
timeout_ms = 5000
endpoints = ["/v1/abawd/tracking"]
[permissions]
required_roles = ["eligibility_worker", "supervisor", "analyst", "jurisdiction_admin"]
audit = "read"
[i18n]
default = "en"
catalogs = ["en"]
Render-time program filter (see D7) renders an "Applies to: SNAP" placeholder for non-SNAP households.
D5 — Shell templates
Three shell templates land in services/canopy-web/templates/case_detail/. Each duplicates body across {% block content %} and {% block topbar_content %} via an shell*_body.html include.
Template conventions:
-
Field name
household_id(notcase_id) matches the route/cases/{household_id}and existingdetail.html. Shell template structs carryhousehold_id: String+case_number: String(the latter is the display-version frompresentational_case_number). -
base.html already appends
— {{ branding.agency_short }}to the<title>(base.html:6) — shell templates set{% block title %}toCase {{ case_number }}ONLY, no duplicated suffix. -
Optional fields are pre-formatted on
RenderedSectionto avoid AskamaOption<T>templating gymnastics. Pattern followstab_income.html’s `.is_some()/.as_deref().unwrap_or("")but cleaner:has_badge: bool+badge_label: String(empty when no badge) +flag_kind: String(e.g."neutral"/"error"). -
Alpine CSP build:
@eventhandlers must be bare method refs (@click="focusSection"), but:attrreactive bindings CAN carry expressions (:class="focusedSection === 'X' ? 'is-focused' : ''"is fine — MR3 ships this same pattern).
Shared RenderedSection fields (consumed by all 3 shells):
pub struct RenderedSection {
pub slug: String, // "case-detail-notices-section"
pub short_slug: String, // "notices"
pub display_name: String, // "Notices"
pub span: u8,
pub row: u8,
pub has_badge: bool,
pub badge_label: String, // e.g. "4", empty when has_badge=false
pub flag_kind: String, // "neutral" | "error" | "warning"
pub html: String,
}
shell_scroll.html
Modeled on case-comp/shells.jsx:81-162 (ScrollShell) — two-column 220px / 1fr grid, anchor nav left, stacked sections right.
{# SPDX-License-Identifier: AGPL-3.0-or-later
Stage 5 MR4 case-detail scroll shell. Receives a Vec<RenderedSection>
from the handler. Pre-rendered HTML embedded via `|safe`.
base.html selects ONE of content/topbar_content per is_sidebar;
body is duplicated via _shell_scroll_body.html include. #}
{% extends "base.html" %}
{% block title %}Case {{ case_number }}{% endblock %}
{% block page_title %}Case {{ case_number }}{% endblock %}
{% block breadcrumb %}<a href="/cases">Cases</a> / {{ case_number }}{% endblock %}
{% block topbar_page_title %}Case {{ case_number }}{% endblock %}
{% block topbar_breadcrumb %}<a href="/cases">Cases</a> / {{ case_number }}{% endblock %}
{% block content %}{% include "case_detail/_shell_scroll_body.html" %}{% endblock %}
{% block topbar_content %}{% include "case_detail/_shell_scroll_body.html" %}{% endblock %}
_shell_scroll_body.html:
{# SPDX-License-Identifier: AGPL-3.0-or-later
Scroll-shell body. Included by shell_scroll.html under both blocks.
Parent template fields visible directly. #}
{% import "_primitives/orchard.html" as o %}
{{ identity_hero_html|safe }}
<div class="case-detail-scroll" x-data="caseDetailFocus">
<aside class="case-detail-scroll__nav" aria-label="On this case">
{% call o::overline() %}On this case{% endcall %}
<nav>
{% for section in sections %}
<a href="#sec-{{ section.short_slug }}"
class="case-detail-scroll__nav-link"
:class="focusedSection === '{{ section.short_slug }}' ? 'is-focused' : ''"
data-section-slug="{{ section.short_slug }}"
@click="focusSection">
<span class="case-detail-scroll__nav-label">{{ section.display_name }}</span>
{% if section.has_badge %}
<span class="case-detail-scroll__nav-count cy-mono"
data-flag="{{ section.flag_kind }}">{{ section.badge_label }}</span>
{% endif %}
</a>
{% endfor %}
</nav>
</aside>
<main class="case-detail-scroll__sections" id="case-detail-sections">
{% for section in sections %}
<section id="sec-{{ section.short_slug }}"
class="case-detail-section"
:class="focusedSection === '{{ section.short_slug }}' ? 'is-focused' : ''"
tabindex="-1"
aria-labelledby="sec-{{ section.short_slug }}-title">
<header class="case-detail-section__header">
<h2 id="sec-{{ section.short_slug }}-title">{{ section.display_name }}</h2>
{% if section.has_badge %}
<span class="case-detail-section__count cy-mono"
data-flag="{{ section.flag_kind }}">{{ section.badge_label }}</span>
{% endif %}
{% call o::gold_rule(size="sm") %}{% endcall %}
</header>
<div class="case-detail-section__body">{{ section.html|safe }}</div>
</section>
{% endfor %}
</main>
</div>
<script type="application/json" id="case-detail-init">{{ init_json|safe }}</script>
shell_card_grid.html + _shell_card_grid_body.html
Modeled on shells.jsx:164-254. Auto-flowing 320px minmax grid; sections as tiles. Same template + body include pattern as scroll. Body content per the elegant-tinkering-pudding plan file body.
shell_tabs.html + _shell_tabs_body.html
Restructured detail.html. Retains htmx tab swap UX; rewrites the tab list source from Program::tabs() hardcoded list → composition section list. Tabs hx-get uses {{ household_id }} (route param) + preserves ?program= so the program switcher continues to work (existing behavior at detail.html:66). Body content per the elegant-tinkering-pudding plan file body.
D6 — Handler refactor (get_case_detail)
pub async fn get_case_detail(
AuthenticatedWorker(session): AuthenticatedWorker,
Extension(theme): Extension<Arc<ThemeConfig>>,
Extension(clients): Extension<Arc<ServiceClients>>,
Extension(svc_token): Extension<canopy_auth::ServiceTokenSource>,
Extension(db): Extension<DbPool>,
Extension(comp): Extension<Arc<CompositionState>>,
Extension(web_config): Extension<Arc<WebConfig>>,
Path(household_id): Path<String>,
Query(query): Query<CaseDetailQuery>,
) -> Result<Html<String>, StatusCode> {
let surface = ComposableSurface::CaseDetail;
let juris = JurisdictionSlug(web_config.jurisdiction.clone());
let idp = comp.composition_loader.idp_for(&juris).await.map_err(|e| {
tracing::error!(error = %e, "case_detail idp_for failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let role = role_slug_for_worker(&session.role);
let user_id = crate::api::dashboard::parse_user_id(&session.worker_id);
let composed = comp
.composition_loader
.load_composition(db.inner(), &juris, &role, user_id.as_ref(), surface, &idp)
.await
.map_err(|e| {
tracing::error!(error = %e, "case_detail load_composition failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
// MR4a compat shim: empty composition → legacy Program::tabs() rendering.
if composed.items.is_empty() {
return legacy_render_tabs_dispatch(/* … */).await;
}
// Resolve active program — explicit query param wins; otherwise primary.
let active_program: Program = query
.program
.as_deref()
.and_then(Program::parse_slug)
.unwrap_or_else(|| pick_primary_program_blocking(&household_id, &clients));
// Fan out section fetches in parallel. dispatch_fetch applies the
// section_applies_to_program filter (D7).
let fetches = composed.items.iter().map(|item| {
sections::dispatch_fetch(
item,
active_program,
comp.composition_loader.plugins(),
&clients,
&household_id,
&session,
)
});
let sections_vec: Vec<RenderedSection> = futures::future::join_all(fetches).await;
// Render hero to String (pre-rendered embed pattern).
let hero = build_case_identity_hero(&clients, &household_id, active_program, &session).await;
let identity_hero_html = hero.render().map_err(|e| {
tracing::error!(error = %e, "case identity hero render failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let case_number = presentational_case_number(&household_id);
// target_section allowlist guard — prevent attacker-controlled values
// flowing into Location header / init_json.
let allowed_focus: std::collections::HashSet<&str> =
sections_vec.iter().map(|s| s.short_slug.as_str()).collect();
let active_section: String = query
.focus_section
.as_deref()
.filter(|s| allowed_focus.contains(s))
.unwrap_or("determination")
.to_string();
let active_section_html = pick_active_section_html(§ions_vec, &active_section);
let init_json = serde_json::to_string(&serde_json::json!({
"focus_section": active_section.clone(),
"household_id": household_id,
}))
.expect("init_json serialize");
let shell_strategy = match &composed.shell {
ShellSpec::CaseDetail { shell } => *shell,
other => {
tracing::error!(?other, "get_case_detail produced non-CaseDetail surface");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let rendered = match shell_strategy {
CaseDetailShell::Scroll => ScrollShellTemplate { /* … */ }.render(),
CaseDetailShell::CardGrid => CardGridShellTemplate { /* … */ }.render(),
CaseDetailShell::Tabs => TabsShellTemplate { /* … */ }.render(),
};
rendered.map(Html).map_err(|e| {
tracing::error!(error = %e, "case detail shell render failed");
StatusCode::INTERNAL_SERVER_ERROR
})
}
#[derive(Debug, Deserialize)]
pub struct CaseDetailQuery {
pub focus_section: Option<String>,
pub program: Option<String>,
}
legacy_render_tabs_dispatch is the existing inline get_case_detail body, factored out and called from the empty-sections branch (compat shim for MR4a only; MR4b Step 12 deletes the call site AND the function).
pub(crate) fn parse_user_id in dashboard.rs — visibility promoted in MR4a Step 6 so case_detail.rs can call it. Pure refactor.
Program::parse_slug(s: &str) → Option<Program> — small helper added to case_detail.rs impl-block. Inverse of slug(); returns None for unknown strings (defensive against ?program=foo query injection).
pick_primary_program_blocking(household_id, clients) v1: returns Program::Snap (existing Program::tabs() default). Richer DB-driven async variant ships post-MR4.
pick_active_section_html(sections_vec, active_section) scans for matching short_slug and returns its pre-rendered .html String. Defined in case_detail/templates.rs.
D7 — section_applies_to_program helper + dispatch_fetch wiring
// services/canopy-web/src/case_detail/util.rs
use canopy_composition::CaseSectionDef;
use crate::api::case_detail::Program;
/// True if the section's manifest `programs = [...]` includes the
/// current program. Manifest validator only accepts the 5 canonical
/// program slugs (no "all" token — cross-program sections enumerate
/// all 5 explicitly per D4).
pub fn section_applies_to_program(def: &CaseSectionDef, program: Program) -> bool {
def.programs.iter().any(|p| p == program.slug())
}
dispatch_fetch MUST consult this filter:
pub async fn dispatch_fetch(
item: &ComposedItem,
active_program: Program,
plugins: &dyn PluginSource,
clients: &ServiceClients,
household_id: &str,
session: &SessionData,
) -> RenderedSection {
let (_plugin, def) = match plugins.find_case_section(&item.item) {
Some(pair) => pair,
None => return unknown_section::render_error(&item.item.0),
};
// Apply program filter — placeholder for non-applicable sections.
if !section_applies_to_program(def, active_program) {
return placeholder_for_program(item, def, active_program);
}
match item.item.0.as_str() {
"case-detail-household-section" => household::fetch(clients, household_id, session).await,
"case-detail-income-section" => income::fetch(clients, household_id, session).await,
"case-detail-determination-section" => determination::fetch(clients, household_id, active_program).await,
"case-detail-notices-section" => notices::fetch(clients, household_id, session).await,
// ... 16 more slugs (9 real + 7 stubs)
_ => unknown_section::render_error(&item.item.0),
}
}
Render-time filter (NOT composition-time) keeps the section visible in the layout, swapping body to a placeholder.
D8 — Section short-slug helper + display_name source
// services/canopy-web/src/case_detail/util.rs
/// Strip the `case-detail-` prefix AND `-section` suffix from a
/// composition item slug. Each removed exactly once via strip_prefix
/// / strip_suffix (not trim_*_matches).
pub fn short_section_slug(slug: &str) -> &str {
let after_prefix = slug.strip_prefix("case-detail-").unwrap_or(slug);
after_prefix.strip_suffix("-section").unwrap_or(after_prefix)
}
display_name source — the design SECTION_REGISTRY (sections.jsx:308-322) provides the canonical label for each section. Until Fluent i18n catalogs ship (#553), each section plugin module declares its display_name as a const pub const DISPLAY_NAME: &str = "Notices"; and the finalize_section wrapper copies it onto RenderedSection.
finalize_section<T: Template>(slug: &str, display_name: &str, template: T, …) mirrors finalize<T: Template> from panels/mod.rs. It precomputes short_slug + display_name + has_badge + badge_label + flag_kind on the returned RenderedSection.
D9 — Shell-aware action redirect (27 handlers)
Each handler form gains pub target_section: Option<String>. Each writeable case-detail form gains a hidden input <input type="hidden" name="target_section" value="{short_slug}">.
Security note: hidden input is attacker-controlled. The redirect helper validates against a server-owned allowlist BEFORE interpolation:
// services/canopy-web/src/api/case_detail.rs (helper)
const ALLOWED_FOCUS_SECTIONS: &[&str] = &[
"household", "income", "determination", "notices", "appeals",
"activity", "abawd", "work-req", "time-limits", "categories",
"authorization", "nutrition", "guidance",
"persons", "assets", "expenses", "verifications", "audit",
"cross-program", "documents",
];
pub fn safe_focus_section(input: Option<&str>) -> &'static str {
input
.and_then(|s| ALLOWED_FOCUS_SECTIONS.iter().find(|&&allowed| allowed == s).copied())
.unwrap_or("determination")
}
Redirect call in each handler:
let qs = safe_focus_section(form.target_section.as_deref());
Ok(Redirect::to(&format!(
"/cases/{}?focus_section={}",
form.household_id, qs,
)))
The 27 handlers and chosen target_section short slugs:
| # | Handler | File | target_section |
|---|---|---|---|
1 |
|
actions.rs |
|
2 |
|
actions.rs |
|
3 |
|
actions.rs |
|
4 |
|
actions.rs |
|
5 |
|
actions_tanf.rs |
|
6 |
|
actions_tanf.rs |
|
7 |
|
actions_tanf.rs |
|
8 |
|
actions_tanf.rs |
|
9 |
|
actions_tanf.rs |
|
10 |
|
actions_medicaid.rs |
|
11 |
|
actions_medicaid.rs |
|
12 |
|
actions_medicaid.rs |
|
13 |
|
actions_medicaid.rs |
|
14 |
|
actions_medicaid.rs |
|
15 |
|
actions_caps.rs |
|
16 |
|
actions_caps.rs |
|
17 |
|
actions_caps.rs |
|
18 |
|
actions_caps.rs |
|
19 |
|
actions_caps.rs |
|
20 |
|
actions_wic.rs |
|
21 |
|
actions_wic.rs |
|
22 |
|
actions_wic.rs |
|
23 |
|
actions_wic.rs |
|
24 |
|
actions_wic.rs |
|
25 |
|
income.rs |
|
26 |
|
income.rs |
|
27 |
|
income.rs |
|
Form-template inventory note: 27 handlers don’t map 1:1 to 27 form files. Actual count:
-
income.rs× 3 → 3 forms intab_income.html -
actions_tanf.rs× 5 → 5 forms intab_determination_tanf.html -
actions_medicaid.rs× 5 → 5 forms intab_determination_medicaid.html -
actions_caps.rs× 5 → 3 intab_determination_caps.html+ 2 intab_authorization.html -
actions_wic.rs× 5 → 4 intab_determination_wic.html+ 1 intab_nutrition.html -
actions.rs(4 SNAP defaults) → NOT in templates/cases/ today. MR4c adds 4 new form partials attemplates/cases/action_form{interim_contact,change_report,abawd_activity,resolve_discrepancy}.htmland wires them under the appropriate sections.
MR4c scope on templates: 23 existing forms gain the hidden input; 4 new SNAP form partials land under templates/cases/action_form*.html with the hidden input baked in.
D10 — Alpine caseDetailFocus component (scroll + card_grid)
Slots into services/canopy-web/static/js/canopy-web.js alongside dashboardCustomizer per MR3’s pattern. Bare method refs only.
// services/canopy-web/static/js/canopy-web.js (inside the existing
// `alpine:init` listener, around line 75).
Alpine.data('caseDetailFocus', () => ({
_cfg: null,
focusedSection: '',
init() {
const node = document.getElementById('case-detail-init');
if (!node) return;
try {
this._cfg = JSON.parse(node.textContent);
} catch (e) { return; }
const params = new URLSearchParams(window.location.search);
this.focusedSection = params.get('focus_section') || this._cfg.focus_section || '';
if (this.focusedSection) this._scrollAndFocus(this.focusedSection);
},
focusSection(ev) {
const slug = ev.currentTarget.dataset.sectionSlug;
if (!slug) return;
this.focusedSection = slug;
this._scrollAndFocus(slug);
},
_scrollAndFocus(slug) {
const target = document.getElementById('sec-' + slug);
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
setTimeout(() => { target.focus({ preventScroll: true }); }, 350);
},
}));
D11 — CSS
Appended to services/canopy-web/static/css/canopy-web.css. Colors, spacing, and radii use design tokens (--orchard- / --sp- / --r-*). Layout dimensions (fixed grid widths, font sizes, minmax breakpoints) use raw values per HANDOFF.md typography + layout scale — the project doesn’t ship dimension tokens for these. Full CSS bodies (scroll/card_grid/tabs/hero) per the elegant-tinkering-pudding plan file body.
D12 — CaseIdentityHero (pre-rendered)
Extends existing summary-bar facts from detail.html:33-55 — benefit amount, certification period, household size, and per-program status.
#[derive(Template)]
#[template(path = "case_detail/_identity_hero.html")]
pub struct CaseIdentityHero {
pub case_number: String,
pub head_of_household_name: String,
pub county: String,
pub member_summary: String, // "4 members"
pub current_benefit: String, // "$540 / month" or empty
pub certification_period: String, // "Through 2026-09-30" or empty
pub program_chips: Vec<ProgramChip>,
}
#[derive(Serialize)]
pub struct ProgramChip {
pub slug: String,
pub label: String,
pub status: String,
pub status_class: String,
}
Template body uses {% if !current_benefit.is_empty() %} / {% if !certification_period.is_empty() %} for optional facts and renders <span class="status-pill u-status-{{ chip.status_class }}">{{ chip.status }}</span> alongside each program tag.
Steps
MR4a — Composition pipeline + per-role TOML force-migrate
Step 1 — File FU issues FIRST, then port plan to AsciiDoc
-
File the 6 follow-up GitLab issues per §Follow-ups. Done 2026-05-24 — issues #561..#566 filed.
-
Link each new issue to epic &51 via the global-id pattern. Done 2026-05-24.
-
Port this plan into this .adoc. This file.
-
Substitute every
FU-placeholder with the real#NNN. *Done — see Follow-ups section. -
Vendor design source to
docs/modules/ROOT/attachments/design/case-comp-{compositions,sections,shells}-jsx.txt. Done. -
Add nav link in
docs/modules/ROOT/nav.adoc. -
Update local agent memory (
MEMORY.mdepic-51 row).
Step 2 — Extend RawComposition + RoleShellEntry
Modify crates/canopy-composition/src/types.rs per D2. Add 3 unit tests:
-
raw_composition_default_has_empty_per_role_map -
role_shell_entry_deserializes_from_toml -
raw_composition_round_trips_per_role_table
Step 3 — Loader extension
Modify crates/canopy-composition/src/loader.rs::load_composition:
-
Step 9: case_detail branch iterates
raw.sections. -
Step 10: call existing
filter_items_by_role(&mut raw.sections, …)— NO signature change. -
Step 11: case_detail branch iterates
raw.sections. -
Step 12: case_detail branch reads
raw.shell_per_role[&role.0]; returnsCompositionLoadError::ShellNotConfiguredForRoleon miss.
Add 4 tests:
-
loader_case_detail_per_role_shell_selects_scroll_for_supervisor -
loader_case_detail_missing_role_returns_shell_not_configured -
loader_case_detail_sections_array_role_filters_correctly -
loader_case_detail_empty_sections_still_resolves(compat for MR4a)
Step 4 — Update system defaults JSON
Modify crates/canopy-composition/defaults/case_detail.json per D3.
Step 5 — Force-migrate Georgia TOML
Replace rulesets/georgia/composition/case_detail.toml with the per-role form (4 roles on tabs; sections list empty until MR4b).
Step 6 — Wire get_case_detail to composition (compat shim)
Modify services/canopy-web/src/api/case_detail.rs::get_case_detail per D6. Promote parse_user_id to pub(crate) in dashboard.rs. Add Program::parse_slug helper. Compat shim path: when composed.items.is_empty(), call legacy_render_tabs_dispatch(…) which holds the existing handler’s inline body.
Add 2 handler integration tests:
-
case_detail_loads_composition_for_caseworker_falls_through_to_tabs(compat shim active) -
case_detail_returns_500_when_role_not_in_shell_per_role
Step 7 — MR4a verification + commit
Add CHANGELOG entry under === Added (terse, 1-2 sentences). Run cargo nextest run -p canopy-composition -p canopy-web (expect 105+ + 350+ tests green). Run cargo xtask validate. Browser verify at cargo xtask dev refresh + /cases/{id} for jane.caseworker — 6-tab UX byte-stable.
Commit, push with -o ci.skip, glab mr create with auto-merge.
MR4b — Scroll shell + 20 section partials
Step 8 — Module scaffold
Create services/canopy-web/src/case_detail/ with mod.rs, sections/mod.rs, templates.rs, util.rs.
Step 9 — Extract 13 design-registry sections
Per D4. Each section is a #[canopy_plugin] with Plugin.toml, a Rust module with fetch(), and an Askama template at templates/case_detail/sections/<slug>.html. The abawd section extracts inline ABAWD HTML from render_program_tab. The determination section’s 5 program templates land per D4.
Step 10 — Stub 7 issue-only sections
Each registers as a #[canopy_plugin] with a shared _section_coming_soon.html partial ("Section in design — implementation tracked at #562"). programs = ["snap", "tanf", "medicaid", "caps", "wic"].
Step 11 — ScrollShellTemplate
Create case_detail/templates.rs::ScrollShellTemplate with #[derive(Template)] against case_detail/shell_scroll.html per D5. Fields: household_id, case_number, branding, identity_hero_html, sections: Vec<RenderedSection>, init_json, active_program.
Step 12 — Dispatcher integration in handler
Modify get_case_detail:
-
Delete the
legacy_render_tabs_dispatchcompat shim from Step 6. -
Add the match on
composed.shell→ renderScrollShellTemplatefor scroll arm (tabs continues throughshell_tabs.html; card_grid lands in MR4c). -
Empty-sections becomes hard 500.
Rewrite get_tab to dispatch on short slug — no hardcoded match arms.
Step 13 — Move Georgia supervisor to scroll + populate sections
Update rulesets/georgia/composition/case_detail.toml: full 20 sections + supervisor moves to scroll.
MR4c — Card grid + 27-action shell-aware redirect
Step 18 — Add target_section to 27 action handlers + forms
Per D9 table. For each handler: add pub target_section: Option<String> to form struct; update Redirect::to to use safe_focus_section; update form template (or create new SNAP form partial). Add 27 redirect-fixture tests parameterized over a [handler_route, form_body, expected_redirect_query] table.
Step 19 — Move Georgia analyst to card_grid
Update rulesets/georgia/composition/case_detail.toml — [shell_per_role.analyst] strategy = "card_grid".
Step 20 — Playwright case-detail-card-grid + case-detail-focus projects
Add 2 projects. Spec files:
-
case-detail-card-grid.spec.ts(5 tests): page loads, sections flow as tiles, span="12" full-width, span="6" half-width, axe. -
case-detail-focus.spec.ts(4 tests):?focus_section=noticesscrolls + focuses; pre-selects active tab for tabs shell; programmatic toggle; missingfocus_sectiondefaults todetermination.
Step 21 — Verify FU IDs from Step 1 propagated
Grep the .adoc for FU- — should return zero matches. Cross-reference each issue against epic &51 linkage.
Step 22 — MR4c verification + CHANGELOG + parent-plan flip + plan archive
Run full test suite. Browser verify analyst sees card_grid; redirect after action focuses correct section.
Update CHANGELOG.adoc. Update docs/modules/ROOT/pages/plans/worker-portal-redesign.adoc Stage 5 row: Done (YYYY-MM-DD) — MR !N1 + MR !N2 + MR !N3 merged to main.
Plan archive move (per ADR-013): Move this .adoc to docs/modules/ROOT/pages/plans/archive/. Update docs/modules/ROOT/nav.adoc — remove from active plans nav, add to archive nav.
Update .claude/CLAUDE.md Feature Status canopy-web row + .claude/docs/services.md.
Commit, push -o ci.skip, auto-merge.
Critical files
New:
-
services/canopy-web/src/case_detail/(mod.rs, sections/, templates.rs, util.rs) -
services/canopy-web/src/case_detail/sections/{20 plugin modules} -
services/canopy-web/templates/case_detail/shell_{scroll,card_grid,tabs}.html -
services/canopy-web/templates/case_detail/shell{scroll,card_grid,tabs}_body.html -
services/canopy-web/templates/case_detail/_identity_hero.html -
services/canopy-web/templates/case_detail/_section_coming_soon.html -
services/canopy-web/templates/case_detail/sections/{20 partials}.html -
services/canopy-web/templates/cases/action_form{interim_contact,change_report,abawd_activity,resolve_discrepancy}.html(4 new SNAP form partials) -
services/canopy-web/tests/redirect_focus_section.rs -
tests/e2e/specs/case-detail-{scroll,card-grid,focus}.spec.ts -
docs/modules/ROOT/attachments/design/case-comp-{compositions,sections,shells}-jsx.txt
Modified:
-
crates/canopy-composition/src/types.rs—RawCompositionextension,RoleShellEntry,CompositionLoadError::ShellNotConfiguredForRole -
crates/canopy-composition/src/loader.rs— Step 9/10/11/12 per-surface branches (calls existingfilter_items_by_rolewith&mut raw.sections— no signature change) -
crates/canopy-composition/defaults/case_detail.json— schema migration -
rulesets/georgia/composition/case_detail.toml— full per-role + 20 sections -
services/canopy-web/src/api/dashboard.rs—parse_user_idvisibility promoted topub(crate) -
services/canopy-web/src/api/case_detail.rs— handler rewrite +get_tabdispatch +Program::parse_slughelper +safe_focus_section -
services/canopy-web/src/api/{actions,actions_tanf,actions_medicaid,actions_caps,actions_wic,income}.rs— 27 handlers gaintarget_sectionfield -
services/canopy-web/templates/cases/*.html— 23 existing forms gain hidden input;detail.html→shell_tabs.html -
services/canopy-web/static/js/canopy-web.js—caseDetailFocusAlpine data -
services/canopy-web/static/css/canopy-web.css— 3 shells + identity hero -
tests/e2e/playwright.config.ts— 3 new projects -
CHANGELOG.adoc— 3 entries -
docs/modules/ROOT/pages/plans/worker-portal-redesign.adoc— Stage 5 row -
docs/modules/ROOT/nav.adoc— plan link + post-MR4c archive move -
.claude/CLAUDE.md+.claude/docs/services.md— canopy-web row updates
Existing utilities to reuse
-
presentational_case_numberatservices/canopy-web/src/dashboard/util.rs:86(UUID v7 tail truncation per !361) -
RenderedPanel/finalize<T: Template>atservices/canopy-web/src/dashboard/panels/mod.rs:55,68(pattern adopt →RenderedSection/finalize_section) -
Plugin.tomlschema atcrates/canopy-composition/src/manifest.rs(PanelDeffor dashboards →CaseSectionDeffor case_detail) -
worker_role_display+role_slug_for_workeratservices/canopy-web/src/dashboard/role_map.rs -
parse_user_idatservices/canopy-web/src/api/dashboard.rs:219(promoted topub(crate)in MR4a Step 6) -
Orchard primitives at
services/canopy-web/templates/_primitives/orchard.html(overline, gold_rule, big_number, program_tag, status_pill) —gold_rule(size="…")parameter issizenotwidth
Follow-ups (filed at Step 1)
| Issue | Title | Labels |
|---|---|---|
feat: case_detail user-layer customization (user_delta_v2 superset) |
|
|
feat: real data for 7 case_detail stub sections (persons, assets, expenses, verifications, audit, cross_program, documents) |
|
|
feat: Studio writing UI for case_detail compositions |
|
|
chore: add intake_screener role to WorkerRole + role_map + idp.toml |
|
|
chore: render-time program filter for case_detail sections (move section_applies_to_program to canopy-composition per ADR-007) |
|
|
feat: live cell previews in case_detail Studio composer |
|
Verification
Per-MR:
-
MR4a:
cargo nextest run -p canopy-composition -p canopy-web(expect 105+ canopy-composition + 350+ canopy-web tests green).cargo xtask validate. Browser verify atcargo xtask dev refresh+/cases/{id}for jane.caseworker — byte-stable 6-tab UX vs pre-MR4a baseline. -
MR4b: Above + new Playwright
case-detail-scrollproject (6+ specs). Supervisor sees scroll shell; caseworker still sees tabs. axe-core wcag2aa filter['critical','serious']= 0 violations. -
MR4c: Above + Playwright
case-detail-card-grid(5 specs) +case-detail-focus(4 specs). Analyst sees card-grid. After interim-contact, redirect lands at/cases/{id}?focus_section=noticesand the notices section scrolls-into-view + focuses.
Plan-quality acceptance (before MR4a branch lands):
After porting this plan to .adoc, dispatch a fresh contextless reviewer subagent against the .adoc and confirm zero new blockers.
Pre-implementation checklist
-
Test coverage: MR4a 5 unit + 2 handler integration. MR4b 20 section render + 4 handler integration + 6 Playwright. MR4c 27 redirect fixture + 5 card-grid Playwright + 4 focus Playwright.
-
Hacks / bypasses: MR4a compat shim is the only interim hack; deleted in MR4b Step 12. 7 stub sections linked to #562. No silent stubs.
-
Test weakening: None.
-
Plan deviations: Resolved-up-front decisions in §"Design decisions resolved"; FUs cover everything descoped.
-
services.md / CLAUDE.md / openapi.rs drift: services.md + CLAUDE.md updated at MR4c Step 22; openapi.rs untouched (handler-internal refactor, no new HTTP endpoints).
-
TODO / FIXME / stub: 7 stubs + 1 compat shim, both linked to FUs. No others.
-
Silent error discard:
dispatch_section_fetchfalls through tounknown_section::render_erroron dispatch miss (mirrorsunknown_panel);caseDetailFocusbails silently on missing init JSON (progressive enhancement — no surfaced error). -
SPDX: All new
.rsfiles start with// SPDX-License-Identifier: AGPL-3.0-or-later..htmlfiles carry the SPDX comment-block header (existing convention in case_detail/ + dashboard/ trees).