Plan: Worker Portal Redesign — Stage 5 MR4: Case Detail Composition

On this page

Status

Step Status Notes

MR4a — Composition pipeline + per-role TOML force-migrate

In progress

Branch feat/worker-portal-redesign-stage5-case-detail-mr4a. Replaces hardcoded Program::tabs() dispatch with load_composition(CaseDetail, role, …​). Loader extension: new shell_per_role field + per-surface branching. Georgia case_detail.toml force-migrated to per-role table form with all 4 roles on tabs. Zero UX regression.

MR4b — Scroll shell + 20 section plugins

Not started

Branch feat/worker-portal-redesign-stage5-case-detail-mr4b. 13 design-registry + 7 stub section plugins. ScrollShellTemplate. Georgia supervisor → scroll. Compat shim from MR4a deleted.

MR4c — Card grid + 27-action shell-aware redirect

Not started

Branch feat/worker-portal-redesign-stage5-case-detail-mr4c. CardGridShellTemplate; 27 action handlers + 23 existing forms + 4 new SNAP form partials add target_section; Alpine caseDetailFocus; Georgia analyst → card_grid.

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.

Epic: &51
Tracking issue: #497

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 = "…​") for case_detail only; migrate Georgia immediately, all 4 roles on tabs at 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 scalar shell: String (untouched).

  • All 13 design SECTION_REGISTRY sections 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 + retained TabsShellTemplate (existing detail.html restructured).

  • 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 27 Redirect::to calls 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_section query param + pre-selects active_section (tabs) OR emits Alpine caseDetailFocus directive (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 + -dark projects.

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_screener role — doesn’t exist in WorkerRole enum (#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 tabs shell — MR4 keeps get_tab handler (still serves rendered section partials internally).

Design decisions resolved

  1. 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.

  2. 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 on RawComposition; loader’s parse_case_detail_shell picks the entry matching the request’s role. Section list at root sections = […​] shared across all shells. Dashboards keep scalar shell (untouched). No back-compat.

  3. 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-selects active_section for tabs OR emits Alpine caseDetailFocus directive for scroll/card_grid.

  4. Slug shape convention. Composition item slugs are FULL (case-detail-notices-section). URL query parameters, DOM id="sec-{X}", htmx tab dispatch URL components, and handler target_section values use SHORT slugs (notices). A pure helper short_section_slug(full: &str) → &str converts; RenderedSection carries both forms precomputed.

  5. 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 renders CaseIdentityHero to identity_hero_html: String and the shell template embeds via {{ identity_hero_html|safe }}. Mirrors RenderedPanel pattern from Stage 5 MR1.

  6. Body duplicated across {% block content %} and {% block topbar_content %} — base.html renders ONE of the two per is_sidebar. Each shell template uses an shell*_body.html include from both blocks (mirrors MR3 customize.html pattern).

Approach — MR-splitting strategy

MR Branch Net change UX at end-of-MR

MR4a

feat/worker-portal-redesign-stage5-case-detail-mr4a

Composition pipeline wired. Loader supports per-role shell_per_role schema for CaseDetail only. Georgia TOML force-migrated to 4-role per-role form (all tabs). 0 new section plugins; loader allows empty sections for compat. get_case_detail calls load_composition; if sections is empty, falls through to existing Program::tabs() dispatch (compat shim — deleted in MR4b).

Georgia all 4 roles render existing 6-tab UX. Zero visual regression.

MR4b

feat/worker-portal-redesign-stage5-case-detail-mr4b

13 + 7 = 20 section plugins (#[canopy_plugin] against [case_sections.*]). ScrollShellTemplate lands. Georgia sections = […​] populated with all 20 slugs. Georgia supervisor → strategy = "scroll"; eligibility_worker stays tabs. Compat shim from MR4a deleted; empty sections becomes a hard render error.

Caseworker UX unchanged (still tabs). Supervisor sees the new scroll shell with anchor-nav + 20 sections stacked.

MR4c

feat/worker-portal-redesign-stage5-case-detail-mr4c

CardGridShellTemplate lands. 27 caseworker action handlers extended with target_section: Option<String>; all 27 redirects rewritten to ?focus_section={short_slug}. Case-detail handler reads focus_section query param + dispatches per shell strategy. Alpine caseDetailFocus component (CSP-safe). Georgia analyst → card_grid.

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

ComposableSurface::CaseDetail enum variant + as_snake_case() == "case_detail"

crates/canopy-composition/src/types.rs:18 + :30

ShellSpec::CaseDetail { shell: CaseDetailShell } typed enum

crates/canopy-composition/src/types.rs:47

CaseDetailShell::{Scroll, CardGrid, Tabs} enum

crates/canopy-composition/src/types.rs:60

PluginSource::find_case_section returns Option<(&dyn Plugin, &CaseSectionDef)>

crates/canopy-composition/src/source.rs:81

CaseSectionDef schema fields (display_name_key, icon, programs, default_span, allowed_spans, required_states)

crates/canopy-composition/src/manifest.rs:57

Manifest validator (KNOWN_PROGRAMS) accepts only snap/tanf/medicaid/caps/wic (no "all" token)

crates/canopy-composition/src/manifest.rs:109

required_roles lives under [permissions] (PermissionsMeta) at plugin-manifest top level, NOT inside [case_sections.*]

crates/canopy-composition/src/manifest.rs:84

Loader’s parse_case_detail_shell already wired for case-detail surface

crates/canopy-composition/src/loader.rs:388-394

Loader Step 9 + 11 already dispatch find_case_section for CaseDetail

loader.rs:232-238 + :285-292

role_filter::filter_items_by_role accepts &mut Vec<ComposedItem> (no signature change for sections)

crates/canopy-composition/src/role_filter.rs:22-54

Georgia case_detail.toml is a stub (shell = "tabs", items = [])

rulesets/georgia/composition/case_detail.toml:5-6

Georgia idp.toml declares 4 roles: eligibility_worker, supervisor, analyst, jurisdiction_admin

rulesets/georgia/idp.toml:8-22

Case detail handler is hardcoded — does NOT call load_composition

services/canopy-web/src/api/case_detail.rs:570-702

htmx tab swap handler get_tab: 13 hardcoded match arms (determination fans out to 4 program-subvariants internally)

services/canopy-web/src/api/case_detail.rs:861-900

16 tab partials exist in templates/cases/: tab_household, tab_income, tab_determination(_caps/_medicaid/_tanf/_wic), tab_notices, tab_appeals, tab_activity, tab_categories, tab_guidance, tab_authorization, tab_nutrition, tab_time_limits, tab_work_req (no tab_abawd — abawd dispatches through render_program_tab SNAP variant)

services/canopy-web/templates/cases/

24 caseworker action handlers redirect to /cases/{id} (4+5+5+5+5 across actions{,_tanf,_medicaid,_caps,_wic}.rs) + 3 in income.rs = 27

grep results

23 form templates currently exist in templates/cases/; 4 SNAP-default actions (record_interim_contact, submit_change_report, record_abawd_activity, resolve_discrepancy) have NO form templates today

grep templates/cases/

get_dashboard shape: 7 extractors + surface_for_role + role_slug_for_worker + load_composition + per-item dispatch

services/canopy-web/src/api/dashboard.rs:96-212

role_slug_for_worker maps 5 WorkerRole variants → 4 RoleSlug values

services/canopy-web/src/dashboard/role_map.rs:20-29

presentational_case_number(household_id) helper (shipped !361)

services/canopy-web/src/dashboard/util.rs:86

parse_user_id (private to dashboard.rs today — promoted to pub(crate) in MR4a Step 6)

services/canopy-web/src/api/dashboard.rs:219

Program::slug() → &'static str (no as_str method)

services/canopy-web/src/api/case_detail.rs:44

CASE_DETAIL_DEFAULTS JSON is empty-items + shell = "tabs" for Georgia compat

defaults/case_detail.json (per defaults.rs:36-39 + :105-108)

#[canopy_plugin] macro registers via linkme CANOPY_PLUGINS slice at compile time

crates/canopy-composition/src/source.rs:CANOPY_PLUGINS

RenderedPanel pattern (slug + row + span + pre-rendered html String); outer template embeds via {{ panel.html|safe }}

services/canopy-web/src/dashboard/panels/mod.rs:55-62

finalize<T: Template> wrapper for render-failure → unknown_panel fallback

services/canopy-web/src/dashboard/panels/mod.rs:68-80

Action redirect convention: Ok(Redirect::to(&format!("/cases/{}", form.household_id))) × 27

grep results above

HANDOFF.md color tokens fully shipped (23 semantic tokens) per MR3 step 4

theme.toml / theme.rs / css_variables()

Alpine CSP build: @event handlers require bare method refs; :attr reactive bindings can carry expressions

static/vendor/vendor.toml (per MR3 D4)

base.html carries h1 tabindex="-1" at lines 64 + 120

services/canopy-web/templates/base.html

o::gold_rule(size="lg") macro signature (parameter is size, not width)

services/canopy-web/templates/_primitives/orchard.html:38

tab_income.html Askama Option<T> pattern (.is_some() / .as_deref().unwrap_or(""))

services/canopy-web/templates/cases/tab_income.html:54

Existing tabs hx-get uses {{ household_id }} + preserves ?program= switcher

services/canopy-web/templates/cases/detail.html:22,66

base.html <title> block already appends — {{ branding.agency_short }} (don’t duplicate in shell templates)

services/canopy-web/templates/base.html:6

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. RawComposition now carries both items and sections; the union deserializes cleanly.

  • Step 9 (export resolution) — for CaseDetail, iterate raw.sections instead of raw.items (existing find_case_section lookup 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.sections for 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.*]. The CaseSectionDef schema has no required_roles field; attempting to put it there fails the deny_unknown_fields deserialize.

The 13 design SECTION_REGISTRY sections (sections.jsx:308-322):

Composition slug Short slug programs = […​] Span Source partial today

case-detail-household-section

household

5-program

12

tab_household.html

case-detail-income-section

income

5-program

12

tab_income.html

case-detail-determination-section

determination

5-program

12

tab_determination{,_caps,_medicaid,_tanf,_wic}.html (5 templates dispatched by program)

case-detail-notices-section

notices

5-program

6

tab_notices.html

case-detail-appeals-section

appeals

5-program

6

tab_appeals.html

case-detail-activity-section

activity

5-program

12

tab_activity.html

case-detail-abawd-section

abawd

["snap"]

12

Inline in render_program_tab(Program::Snap, "abawd", …​) — extract to abawd.html

case-detail-work-req-section

work-req

["tanf"]

6

tab_work_req.html

case-detail-time-limits-section

time-limits

["tanf"]

6

tab_time_limits.html

case-detail-categories-section

categories

["medicaid"]

12

tab_categories.html

case-detail-authorization-section

authorization

["caps"]

12

tab_authorization.html

case-detail-nutrition-section

nutrition

["wic"]

12

tab_nutrition.html

case-detail-guidance-section

guidance

5-program

12

tab_guidance.html

("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 (not case_id) matches the route /cases/{household_id} and existing detail.html. Shell template structs carry household_id: String + case_number: String (the latter is the display-version from presentational_case_number).

  • base.html already appends — {{ branding.agency_short }} to the <title> (base.html:6) — shell templates set {% block title %} to Case {{ case_number }} ONLY, no duplicated suffix.

  • Optional fields are pre-formatted on RenderedSection to avoid Askama Option<T> templating gymnastics. Pattern follows tab_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: @event handlers must be bare method refs (@click="focusSection"), but :attr reactive 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(&sections_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

record_interim_contact

actions.rs

notices

2

submit_change_report

actions.rs

household

3

record_abawd_activity

actions.rs

abawd

4

resolve_discrepancy

actions.rs

income

5

file_appeal_tanf

actions_tanf.rs

appeals

6

record_interim_contact_tanf

actions_tanf.rs

notices

7

submit_change_report_tanf

actions_tanf.rs

household

8

record_work_activity_tanf

actions_tanf.rs

work-req

9

resolve_discrepancy_tanf

actions_tanf.rs

income

10

file_appeal_medicaid

actions_medicaid.rs

appeals

11

record_interim_contact_medicaid

actions_medicaid.rs

notices

12

submit_change_report_medicaid

actions_medicaid.rs

household

13

ingest_cmd_update_medicaid

actions_medicaid.rs

categories

14

resolve_quarantined_determination_medicaid

actions_medicaid.rs

determination

15

file_appeal_caps

actions_caps.rs

appeals

16

record_interim_contact_caps

actions_caps.rs

notices

17

submit_change_report_caps

actions_caps.rs

household

18

update_authorization_caps

actions_caps.rs

authorization

19

switch_provider_caps

actions_caps.rs

authorization

20

file_appeal_wic

actions_wic.rs

appeals

21

record_interim_contact_wic

actions_wic.rs

notices

22

submit_change_report_wic

actions_wic.rs

household

23

schedule_certification_appointment_wic

actions_wic.rs

nutrition

24

record_nutritional_risk_wic

actions_wic.rs

nutrition

25

add_income

income.rs

income

26

edit_income

income.rs

income

27

remove_income

income.rs

income

Form-template inventory note: 27 handlers don’t map 1:1 to 27 form files. Actual count:

  • income.rs × 3 → 3 forms in tab_income.html

  • actions_tanf.rs × 5 → 5 forms in tab_determination_tanf.html

  • actions_medicaid.rs × 5 → 5 forms in tab_determination_medicaid.html

  • actions_caps.rs × 5 → 3 in tab_determination_caps.html + 2 in tab_authorization.html

  • actions_wic.rs × 5 → 4 in tab_determination_wic.html + 1 in tab_nutrition.html

  • actions.rs (4 SNAP defaults) → NOT in templates/cases/ today. MR4c adds 4 new form partials at templates/cases/action_form{interim_contact,change_report,abawd_activity,resolve_discrepancy}.html and 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

  1. File the 6 follow-up GitLab issues per §Follow-ups. Done 2026-05-24 — issues #561..#566 filed.

  2. Link each new issue to epic &51 via the global-id pattern. Done 2026-05-24.

  3. Port this plan into this .adoc. This file.

  4. Substitute every FU- placeholder with the real #NNN. *Done — see Follow-ups section.

  5. Vendor design source to docs/modules/ROOT/attachments/design/case-comp-{compositions,sections,shells}-jsx.txt. Done.

  6. Add nav link in docs/modules/ROOT/nav.adoc.

  7. Update local agent memory (MEMORY.md epic-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]; returns CompositionLoadError::ShellNotConfiguredForRole on 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_dispatch compat shim from Step 6.

  • Add the match on composed.shell → render ScrollShellTemplate for scroll arm (tabs continues through shell_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.

Step 14 — CSS

Append D11 scroll shell + identity hero sections to canopy-web.css.

Step 15 — Playwright case-detail-scroll project

Add to playwright.config.ts. New spec case-detail-scroll.spec.ts with 6 tests: page loads, anchor nav appears with 20 sections, click anchor scrolls, anchor focus state, axe-core wcag2aa ['critical','serious'] = 0, dark scheme.

Step 16 — MR4b verification + commit

Run full test suite. Browser verify supervisor sees scroll shell; caseworker still tabs. Commit, push -o ci.skip, auto-merge.

MR4c — Card grid + 27-action shell-aware redirect

Step 17 — CardGridShellTemplate

Mirror Step 11 against shell_card_grid.html per D5.

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=notices scrolls + focuses; pre-selects active tab for tabs shell; programmatic toggle; missing focus_section defaults to determination.

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.rsRawComposition extension, RoleShellEntry, CompositionLoadError::ShellNotConfiguredForRole

  • crates/canopy-composition/src/loader.rs — Step 9/10/11/12 per-surface branches (calls existing filter_items_by_role with &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.rsparse_user_id visibility promoted to pub(crate)

  • services/canopy-web/src/api/case_detail.rs — handler rewrite + get_tab dispatch + Program::parse_slug helper + safe_focus_section

  • services/canopy-web/src/api/{actions,actions_tanf,actions_medicaid,actions_caps,actions_wic,income}.rs — 27 handlers gain target_section field

  • services/canopy-web/templates/cases/*.html — 23 existing forms gain hidden input; detail.htmlshell_tabs.html

  • services/canopy-web/static/js/canopy-web.jscaseDetailFocus Alpine 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_number at services/canopy-web/src/dashboard/util.rs:86 (UUID v7 tail truncation per !361)

  • RenderedPanel / finalize<T: Template> at services/canopy-web/src/dashboard/panels/mod.rs:55,68 (pattern adopt → RenderedSection / finalize_section)

  • Plugin.toml schema at crates/canopy-composition/src/manifest.rs (PanelDef for dashboards → CaseSectionDef for case_detail)

  • worker_role_display + role_slug_for_worker at services/canopy-web/src/dashboard/role_map.rs

  • parse_user_id at services/canopy-web/src/api/dashboard.rs:219 (promoted to pub(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 is size not width

Follow-ups (filed at Step 1)

Issue Title Labels

#561

feat: case_detail user-layer customization (user_delta_v2 superset)

type::feature, priority::low, program::infrastructure, service::web, workflow::needs-spec

#562

feat: real data for 7 case_detail stub sections (persons, assets, expenses, verifications, audit, cross_program, documents)

type::feature, priority::medium, program::cross-program, service::web, workflow::needs-spec

#563

feat: Studio writing UI for case_detail compositions

type::feature, priority::low, program::infrastructure, service::web, workflow::needs-spec

#564

chore: add intake_screener role to WorkerRole + role_map + idp.toml

type::chore, priority::low, program::infrastructure, service::web, workflow::needs-spec

#565

chore: render-time program filter for case_detail sections (move section_applies_to_program to canopy-composition per ADR-007)

type::chore, priority::low, program::infrastructure, service::web, workflow::needs-spec

#566

feat: live cell previews in case_detail Studio composer

type::feature, priority::low, program::infrastructure, service::web, workflow::needs-spec

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 at cargo xtask dev refresh + /cases/{id} for jane.caseworker — byte-stable 6-tab UX vs pre-MR4a baseline.

  • MR4b: Above + new Playwright case-detail-scroll project (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=notices and 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

NOTE
This is the plan’s own pre-merge self-audit. Commit-time Q1-Q8 goes to the user inline — not here.
  • 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_fetch falls through to unknown_section::render_error on dispatch miss (mirrors unknown_panel); caseDetailFocus bails silently on missing init JSON (progressive enhancement — no surfaced error).

  • SPDX: All new .rs files start with // SPDX-License-Identifier: AGPL-3.0-or-later. .html files carry the SPDX comment-block header (existing convention in case_detail/ + dashboard/ trees).

Edit this page · default