Plan: Worker Portal Redesign — Stage 5 MR3: Customize My Dashboard

On this page

Status

Step Status Notes

Step 1 — Write the ADR

Done (2026-05-23)

adr-024 user-layer semantic delta schema; committed as 5868cd2 on this branch.

Step 2 — canopy-composition: user_delta module + loader post-role-filter branch

Done (2026-05-24)

Implementation complete in working tree (user_delta.rs + loader.rs + tests/user_delta_test.rs + lib.rs re-exports); 103/103 unit tests green; uncommitted.

Step 2.5 — Vendor design source under docs/modules/ROOT/attachments/design/

Done (2026-05-24)

customize-jsx.txt + HANDOFF.md copied; uncommitted (untracked directory).

Step 3 — Composition API: extractor + delete_user_me + 422 validation + openapi.rs

Done (2026-05-24)

composition.rs / composition_errors.rs / openapi.rs / composition_api_test.rs updated in working tree; 44/44 composition_api integration tests green; uncommitted.

Step 4 — CSS for customize chrome + DnD visual states + .page-title:focus-visible

Done (2026-05-24)

Customize chrome + DnD states + hero + affordances card + cell placeholder upgrade; design-token palette expanded with semantic -bg/-text triples, text-body, border-soft, nav-bg/muted/muted-dim per HANDOFF.md §3.1.

Step 5 — Alpine dashboardCustomizer + base.html h1 tabindex="-1" + focus-after-redirect listener

Done (2026-05-24)

canopy-web.js factory + listener present; base.html:63 + base.html:118 h1 carry tabindex="-1"; uncommitted.

Step 6 — Customize handler + templates + api/mod.rs + nav link + browser verify

Done (2026-05-24)

Handler / template / mod customize; registration / nav link in base.html sidebar + topbar all in place; browser verified via one-shot Playwright probe (12 baseline panels rendered + nav link active + init JSON parseable).

Step 7 — Playwright customize + customize-dark projects + helpers + 12 specs

Done (2026-05-24)

12 specs × 2 color schemes (28/28 passing including auth-setup). Customize-dark + light both at 0 critical + serious axe violations after the design-token migration.

Step 8 — CHANGELOG + docs updates

Done (2026-05-24)

Plan ported to this .adoc; CHANGELOG Added entry added; canopy-web API page documents GET /dashboard/customize + DELETE /v1/composition/{surface}/user/me + the user_delta_v1 shape; services.md + CLAUDE.md updated. Plan-status flips to Done at commit.

Step 9 — File 10 follow-up issues (FU-50/51/55/56/57/58/59 + FU-LIVE-PREVIEW + FU-PREVIEW-AS-WORKER + FU-LOCKED-TOOLTIP)

Done (2026-05-24)

File via glab issue create BEFORE commit per deferral-accountability discipline. Three new FUs added during design-mock review: live cell previews (slug placeholder is intentional for v1), "Preview as worker" button opening the resolved dashboard in a new tab, and locked-item tooltip on the "YOU CAN CHANGE" card.

Step 10 — Pre-commit Q1-Q8 + cargo xtask validate + push -o ci.skip + glab mr create + force-merge

Done (2026-05-24)

Final ritual; verify hooksPath is .githooks before commit.

Design-mock review (2026-05-23)

Mid-implementation, the in-flight customize page was screenshotted via Playwright and visually diffed against the design package mock at link:_attachments/design/Customize Dashboard.html[Customize Dashboard.html]. Deviations + design-team answers below — all reviewed inline rather than deferred per project convention.

  • Semantic color tokens missing from theme system. HANDOFF.md §3.1 declares --orchard-success-bg / -text, --orchard-warning-bg / -text, --orchard-error-bg / -text, --orchard-info-bg / -text, plus --orchard-text-body and --orchard-border-soft. Theme system shipped without them; customize chrome silently fell back to surface-raised via var(…​ , fallback) chains. Fixed inline: extended ColorPalette + theme.toml + css_variables() to emit all 23 canonical tokens (design-team confirmed: explicit hex triples in TOML, not algorithmically derived — light + dark pairs are hand-tuned for warmth per palette).

  • text-body not surfaced as a distinct level. Three text levels per design: text (headings + meaningful cell values), text-body (running prose / <p>), text-muted (labels / captions / framing). Fixed inline: added text-body to the palette; customize-hero p migrated from text-muted to text-body.

  • Hero card missing. Mock shows PERSONAL LAYOUT overline + h2 Customize your dashboard title + descriptive body, with a right-side YOU CAN CHANGE affordances card listing 5 affordances (3 enabled, 2 locked). Fixed inline: hero card with overline, h2, body, and the affordances card with always-visible strikethrough on locked items per design-team direction (the card’s purpose is to communicate the boundary up front, not on hover).

  • Cell placeholder showed only the slug. Design team confirmed slug placeholder is acceptable for v1 (live previews would burden the composer with N panel HTTP requests) but the placeholder should be informative. Fixed inline: each cell now renders the slug + a Rendering as: span N of 12 subtitle. Live previews tracked as FU-LIVE-PREVIEW; "Preview as worker" button (opens resolved dashboard in a new tab) as FU-PREVIEW-AS-WORKER.

  • Orphan cy-btn-- classes had no CSS rules.* The customize page used cy-btn cy-btn—​primary / cy-btn—​outline-primary / cy-btn—​outline-secondary everywhere but the only matching CSS was a single padding rule under .customize-cell__chrome .cy-btn. Buttons rendered with browser defaults. Fixed inline: switched to the established btn btn-primary / btn btn-ghost convention that the rest of the worker portal already uses.

  • Auto-theme (data-theme="system") rendered a broken half-state in OS dark mode. Surface tokens flipped to dark via the @media (prefers-color-scheme: dark) { [data-theme="system"] { …​ } } block, but my recent dark-chrome overrides (which used [data-theme="dark"] .sidebar selectors) didn’t trigger, leaving the sidebar with light chrome over dark surfaces. Fixed inline: promoted nav_bg / nav_muted / nav_muted_dim to first-class palette tokens (per case-detail/shared.jsx precedent) so they participate in the @media → system block automatically. Removed the [data-theme="dark"]-scoped overrides; chrome now consumes var(--orchard-nav-bg) etc. and resolves correctly in light / dark / auto-light / auto-dark.

  • Dark-theme nav chrome failed WCAG AA contrast. Pre-existing on main: .nav-item text #a8d5c8 on --orchard-primary #3a9080 clears 2.37:1. Fixed inline: dark navBg set to #0e1612 (matches surface-sunken); dark navMuted #7faa9a clears ~7.5:1. Light theme untouched — its #a8d5c8 text on #1e5146 already cleared AA.

Epic: &51 (#460)
Issue: #498
Branch: feat/worker-portal-redesign-stage5-customize-my-dashboard
ADR: ADR-024: User-layer semantic delta schema for dashboard composition (amends ADR-022)
Design source: customize.jsx + worker portal design reference

Context

Stage 5 MR1 (#495) and MR2 (#496, merged as fbc5e29) shipped composition-driven dashboard surfaces for worker / supervisor / analyst. MR2.1 (!357, merged as a24856f) fixed the grid CSS. Stage 3 MR2 (#491) exposed GET / PUT / PATCH /v1/composition/{surface}/user/me.

MR3 lands the customize UI — the screen complement to the user-delta APIs. Per design package + HANDOFF.md, the worker drives the layout (hide / reorder / resize) and the BFF persists the result.

The user_delta_v1 semantic schema deviates from ADR-022’s RFC 6902 ops for dashboard surfaces only. ADR-024 ratifies the scope and the surface-aware shape detection at loader boundaries.

Scope

In scope:

  • GET /dashboard/customize route + Askama template

  • Alpine dashboardCustomizer component (CSP-safe bare method refs)

  • HTML5 native drag-and-drop + keyboard pickup (Space-then-arrow)

  • DELETE /v1/composition/{surface}/user/me handler + route

  • user_delta_v1 semantic schema (hidden_slugs / span_overrides / slug_order) for dashboard surfaces

  • Loader post-role-filter branch (shape-detect: user_delta_v1 → semantic apply, else RFC 6902 fallback)

  • Playwright customize + customize-dark projects + 12 specs + axe critical + serious AA

  • ADR-024 amending ADR-022

Out of scope:

  • Pin / unpin — FU-50

  • Required-panel baseline plumbing — FU-51 (this MR ships CSS-ready badge only)

  • Touch-event DnD on mobile / tablet — FU-55

  • CSRF token rotation on login — FU-56

  • Stale-user-row cleanup on role switch — FU-57

  • Global csrf meta tag + htmx listener — FU-58

  • Localized panel titles via Fluent (resolves display_name_key) — FU-59

  • Row reordering across baseline row boundaries — single-axis reorder only (out of MR3)

Acceptance criteria (#498)

  • Template at services/canopy-web/templates/dashboard/customize.html.

  • Worker can: reorder panels (DnD + keyboard pickup), hide / show, resize within allowed_spans.

  • Worker cannot: add panels not in their role’s baseline. Required-panel removal lockout deferred to FU-51 (this MR ships CSS-ready badge only).

  • Save persists via PUT /v1/composition/{surface}/user/me with user_delta_v1 body for dashboard surfaces. Other surfaces continue using RFC 6902.

  • Reset reverts via DELETE /v1/composition/{surface}/user/me. NEW endpoint.

  • Composition loader picks up delta on next dashboard render via the new post-role-filter branch.

  • Audit emitted on PUT (already wired) + DELETE (new). Validation failures + no-op DELETEs emit NO audit (tests assert).

  • Playwright E2E: 12 specs covering hide, drag, keyboard, resize, reset, cancel, 412, 428, network, required-placeholder, axe (light + dark).

  • axe-core WCAG 2.1 AA: 0 critical + 0 serious violations.

  • CHANGELOG entry under === Added.

  • ADR-024 amending ADR-022.

Prerequisites (verified)

Fact Reference

MR2 + MR2.1 on main

git log: fbc5e29, a24856f

/v1/composition/{surface}/user/me GET + PUT + PATCH exist

composition.rs:743,832,908

Route is generic over surface; case_detail uses it too

composition.rs:991; tests/e2e/specs/composition-api.spec.ts:46

/v1/composition bypasses CSRF middleware (SameSite=Strict defends)

main.rs:133-138, 211-223

delete_live is the mirror pattern for delete_user_me

composition.rs:402-462

PUT audit envelope pattern (composition.user.put)

composition.rs:874-889

canopy_composition::delete_document returns WriteError::NotFound on missing row

crates/canopy-composition/src/db.rs:295-318

composition_loader.plugins() accessor

loader.rs:59

find_panel(&ItemSlug) returns Option<(&dyn Plugin, &PanelDef)>

source.rs:97

ItemSlug(pub String) — use .0.clone() to extract String

types.rs:120

Role filter applied at loader.rs:230; defined in role_filter.rs

loader.rs:230

Validation is inline in load_composition steps 7-11 (no validate_post_merge helper)

loader.rs:192-274

dashboard.rs:147-149 renders unknown_panel::diagnostic_panel() when items is empty

dashboard.rs:147-149

Only applications/process.html carries csrf_token at base-template level

grep -l 'extends "base.html"'

templates/error.html exists (singular)

templates/error.html; errors.rs:15

dashboard-supervisor.spec.ts + dashboard-analyst.spec.ts set the Playwright project convention

tests/e2e/playwright.config.ts:46-76

dark-theme Playwright project uses colorScheme: 'dark'

playwright.config.ts:46-52

ComposableSurface serializes as snake_case (worker_dashboard) via serde rename_all

types.rs:13; composition.rs:67-76

surface_snake_case() helper returns worker_dashboard etc.

composition.rs:67-76

get_dashboard handler shape — exact signature + extractor set

dashboard.rs:96-104

worker.html defines both {% block content %} and {% block topbar_content %}

templates/dashboard/worker.html:22-23

Alpine CSP build accepts ONLY bare method refs in directives

static/vendor/vendor.toml:44; existing pattern applications/process.html:90,116

alpine:init hook for component registration

static/js/canopy-web.js:72-85

openapi.rs lists handler paths() + schemas

services/canopy-web/src/openapi.rs:15-30

PanelDef.display_name_key: String is an i18n catalog key (NOT a localized title)

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

RoleSlug is a typed newtype (not &str)

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

role_slug_for_worker(&session.role) is the REAL helper

services/canopy-web/src/role_map.rs:20-35

h1.page-title lives in base.html, NOT dashboard sub-templates

base.html:63 (sidebar), base.html:118 (topbar)

Design

D1 — URL + handler shape

GET /dashboard/customize — render customize.html. New handler get_customize in services/canopy-web/src/api/customize.rs.

Signature mirrors get_dashboard exactly (dashboard.rs:96-104):

pub async fn get_customize(
    AuthenticatedWorker(session): AuthenticatedWorker,
    Extension(comp): Extension<Arc<CompositionState>>,
    Extension(theme): Extension<Arc<ThemeConfig>>,
    Extension(db): Extension<DbPool>,            // matches dashboard.rs:101 — NOT State<PgPool>
    Extension(web_config): Extension<Arc<WebConfig>>, // for jurisdiction lookup
) -> Result<Html<String>, StatusCode> { ... }

The side-channel user-row fetch uses db.inner() (per dashboard.rs:139 pattern) to get the &PgPool.

Resolves surface via surface_for_role(&session.role). Renders panel slots as placeholders (no upstream data fetch). No tower_sessions::Session parameter — CSRF plumbing dropped from MR3 scope and routed to FU-58.

D2 — DELETE /v1/composition/{surface}/user/me

New handler delete_user_me + delete_user_me_inner in services/canopy-web/src/api/composition.rs. Mirror delete_live (composition.rs:402-462):

  • Body: none. Auth: JsonAuthenticatedWorker.

  • Read before_etag via SELECT id, updated_at FROM composition_documents WHERE … FOR UPDATE inside a transaction so DELETE + audit emit are atomic.

  • Call canopy_composition::delete_document(&mut tx, jurisdiction, "user", scope_key, surface).

  • On WriteError::NotFound: explicitly return 204 + skip audit (idempotent). Asserted via test.

  • On success: build EventEnvelope::new("canopy-web", "composition.user.delete", json!({…})) per D10; emit state.publisher.publish_tx(&mut tx, &envelope).await? BEFORE commit.

  • After commit: state.composition_cache.invalidate_user(&juris, &UserId::from(parse_worker_uuid(&session.worker_id)?)).

  • Response: 204 No Content.

  • Route: modify the existing chained route at composition.rs:991:

// BEFORE
.route(
    "/v1/composition/{surface}/user/me",
    get(get_user_me).put(put_user_me).patch(patch_user_me),
)
// AFTER
.route(
    "/v1/composition/{surface}/user/me",
    get(get_user_me).put(put_user_me).patch(patch_user_me).delete(delete_user_me),
)

D3 — Semantic user-delta schema (user_delta_v1) — dashboard surfaces only

New crates/canopy-composition/src/user_delta.rs:

#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(tag = "type")]
pub enum UserDelta {
    #[serde(rename = "user_delta_v1")]
    V1 {
        hidden_slugs: Vec<String>,
        span_overrides: std::collections::HashMap<String, u8>,
        slug_order: Vec<String>,
    },
}

Field semantics:

  • hidden_slugs — opaque list; loader drops items where slug ∈ list. Unknown slugs silent-drop.

  • span_overrides — slug → span. Loader sets item.span = override where slug matches. Validation rejects out-of-allowed_spans at write.

  • slug_order — explicit ordering. Loader sorts items by position; items NOT in slug_order keep baseline order at the end (forward-compat). Row indices preserved from baseline.

Surface scope: this schema applies ONLY to user-layer rows where surface is worker_dashboard | supervisor_dashboard | analyst_dashboard. case_detail + sign_in user rows continue using RFC 6902 (existing composition-api.spec.ts untouched).

PATCH semantics for dashboard surfaces: not supported. PATCH /user/me with a dashboard surface returns 415 Unsupported Media Type with body {"error":"PATCH not supported for dashboard surfaces; use PUT with user_delta_v1 body"}. Non-dashboard surfaces' PATCH stays RFC 6902.

Wire format: PUT body for dashboard surfaces is the JSON object above. GET returns {patch_ops: <stored body>, etag, updated_at} where patch_ops is the user_delta_v1 object for dashboard surfaces, RFC 6902 ops for others. OpenAPI schema becomes oneOf for the body type — registered in services/canopy-web/src/openapi.rs.

D4 — Draft state: Alpine.js component (CSP-safe init)

CSP-compatible init pattern (no x-data args):

<script type="application/json" id="customize-init">
{
  "worker_id": "...",
  "surface_slug": "worker_dashboard",
  "baseline_items": [{"slug":"...","span":12,"row":0}, ...],
  "allowed_spans_by_slug": {"worker-dashboard-my-queue-panel":[3,4,6,12], ...},
  "panel_titles": {"worker-dashboard-my-queue-panel": "My queue", ...},
  "required_panels": [],
  "current_delta": null,
  "etag": ""
}
</script>
<div x-data="dashboardCustomizer">...</div>

surface_slug is snake_case. panel_titles is slug-keyed; in v1 every entry’s value is the slug itself (per ADR-024 + FU-59 deferral — display_name_key is a Fluent catalog key, not a localized string).

Alpine.data('dashboardCustomizer', () ⇒ ({…​})) exposes:

  • Reactive state: items, hidden, pickedUp, saveError.

  • Derived getters: pendingCount, hiddenCount, reorderCount.

  • Methods (bare refs only, Alpine CSP build): hide, show, resize, dragStart, dragOver, dragLeave, drop, cellKeydown, save, reset, cancel.

  • Slug + idx are read from data-* attrs on event.currentTarget (not from method args — CSP build constraint).

  • cellKeydown first-line guard: if (ev.target !== ev.currentTarget) return; — ignore bubbled keydown from child buttons.

  • sessionStorage scoping per-worker + per-surface: key is customize-draft:{worker_id}:{surface_slug}.

  • On init(), restore session draft only if both worker_id AND surface match AND etag matches the current value.

save() happy path:

  1. Validate items.length > 0 (else saveError banner: "Keep at least one panel visible.").

  2. Build body {type:'user_delta_v1', hidden_slugs, span_overrides:_computeSpanOverrides(), slug_order:items.map(i⇒i.slug)}.

  3. Headers: Content-Type: application/json; If-Match: <etag> if etag known else If-None-Match: *.

  4. fetch(PUT /v1/composition/{surface_slug}/user/me, …​).

  5. On 2xx: clear sessionStorage; navigate to /?focus=h1.

  6. On 412: persist draft + banner "Your layout changed in another tab — reload to keep editing."

  7. On 422: parse JSON {error} + banner "Invalid layout: …​"

  8. On network error: persist draft + banner.

reset() calls DELETE; treats 2xx + 404 as success; clears sessionStorage; navigates to /?focus=h1.

cancel() clears sessionStorage + navigates to /?focus=h1.

D5 — (CSRF section dropped)

This MR does NOT add the CSRF meta tag or the global htmx listener. Customize save / reset are bypass-mode fetch() against /v1/composition (SameSite=Strict defends). Filed FU-58 feat: global csrf meta tag + htmx listener for future htmx writes.

D6 — DnD + keyboard pickup

Chrome buttons per cell:

  • [✕ Hide]aria-label="Hide '{slug}' panel". disabled HTML attr + aria-disabled="true" when slug ∈ required_panels. Handler also guards: if (this.requiredPanels.includes(slug)) return; (belt-and-suspenders).

  • [⋮⋮ Drag]aria-label="Drag '{slug}' to reorder", cursor: grab.

  • Span control: <div role="group" aria-label="Span"> containing <button aria-pressed="…​" data-target-span="N">N</button> per N in allowedSpansBySlug[slug] (dynamic per allowed_spans, NOT hardcoded Half/Full).

Mouse DnD: HTML5 native. Cell carries draggable="true" + handlers. if (src === dst) return; no-op for drag-onto-self. Visual states: .is-dragging (opacity 0.4); .is-drop-target (2px gold border-top).

Keyboard pickup: Space-then-arrow on the cell. Space → toggle pickup; ArrowUp / ArrowDown → swap with neighbor; Home / End → move to first / last; Escape → return to original; Space → drop. Announce each transition via <div id="dnd-announcer" aria-live="polite" class="sr-only">.

<div id="kb-hint" class="sr-only">Keyboard: Space to pick up, arrows to move, Space to drop, Escape to cancel.</div> is referenced by aria-describedby="kb-hint" on each cell.

Focus management:

  • After Hide: focus moves to ↩ Restore button on the placeholder.

  • After Save / Reset / Cancel: navigate to /?focus=h1. Dashboard h1.page-title needs tabindex="-1"; init JS on / reads URLSearchParams, calls .focus() on the page-title.

  • H1 lives in base.html, NOT dashboard sub-templates. Add tabindex="-1" to the .page-title h1 at base.html:63 (sidebar layout) AND base.html:118 (topbar layout). Two single-line additions, one file.

D7 — Hidden-panel placeholder + Hide / Show

<div class="worker-dashboard-cell customize-cell--hidden" data-panel-slug="{{ slug }}">
  {% call o::overline() %}Hidden · <code class="cy-mono">{{ slug }}</code>{% endcall %}
  <button type="button"
          class="cy-btn cy-btn--outline-primary"
          data-panel-slug="{{ slug }}"
          @click="show">↩ Restore</button>
</div>

Save button shows the saveError banner if items.length === 0 (UI does not strictly prevent the final-hide click; Save guards instead). Reset is always allowed.

D8 — Page chrome

customize.html defines BOTH blocks (matches worker.html at templates/dashboard/worker.html:22-23):

{% extends "base.html" %}
{% block content %}
  ... topbar + hero + grid + actionbar + save-error banner + script init JSON + dnd-announcer + kb-hint ...
{% endblock %}
{% block topbar_content %}
  ... topbar + hero + grid + actionbar (mirrored) ...
{% endblock %}

Hero subtitle copy:

"Hide panels you don’t use. Reorder rows. Your changes only affect your account — they don’t change anything for the rest of {{ branding.agency_short }}'s workers."

(v3’s "Pin the ones you live in" clause removed; FU-50 restores it when pinning ships.)

Save-error banner markup (single line):

<div role="alert" x-show="saveError" x-text="saveError" class="customize-banner"></div>

(.customize-banner CSS uses --orchard-error + .cy-mono treatment — already shipped in step-4.)

Action bar copy + EDIT MODE chip with 6px dot per design package.

D9 — Composition document fetch + ETag

Handler get_customize:

  1. Resolve surface via surface_for_role(&session.role).

  2. Call comp.composition_loader.load_composition(juris, role, user_id = None, surface, idp = …​) → role-filtered baseline WITHOUT user delta. Customize page works in baseline-coords + delta as two separate fields so the worker can see hidden panels to restore them.

  3. Side-channel: factor a private helper fetch_user_layer_raw(state, juris_id, scope_key, surface) → Option<(Value, DateTime<Utc>)> from the body of fetch_and_render (composition.rs:787-819). get_customize calls this for the user-layer row + ETag — single source of truth for SQL + ETag format.

async fn fetch_user_layer_raw(
    state: &CompositionState,
    jurisdiction_id: uuid::Uuid,
    scope_key: &str,
    surface: ComposableSurface,
) -> Result<Option<(serde_json::Value, chrono::DateTime<chrono::Utc>)>, CompositionApiError> {
    let row = sqlx::query_as::<_, (serde_json::Value, chrono::DateTime<chrono::Utc>)>(
        r#"SELECT patch_ops, updated_at FROM composition_documents
           WHERE jurisdiction_id = $1 AND layer = $2 AND scope_key = $3 AND surface = $4"#,
    )
    .bind(jurisdiction_id)
    .bind(CompositionLayer::User)
    .bind(scope_key)
    .bind(surface)
    .fetch_optional(state.db.inner())
    .await?;
    Ok(row)
}
  1. Hardcode required_panels = vec![] (FU-51 wires real plumbing).

  2. Compute allowed_spans_by_slug:

let plugins = comp.composition_loader.plugins();
let mut allowed_spans_by_slug: std::collections::HashMap<String, Vec<u8>> = Default::default();
for item in &baseline.items {
    if let Some((_plugin, def)) = plugins.find_panel(&item.item) {
        allowed_spans_by_slug.insert(item.item.0.clone(), def.allowed_spans.clone());
    }
}
  1. Build CustomizeTemplate with fields including baseline_items_json, allowed_spans_by_slug_json, current_delta_json, required_panels_json, etag, surface_slug (snake_case), worker_id, worker_name, worker_role, worker_role_slug, branding, is_sidebar. Serialize maps to JSON strings in the handler.

NOTE
PanelDef.display_name_key: String is an i18n catalog key, not a localized title. v1 customize aria-labels use slug strings directly: aria-label="Move panel ${slug}". Accept the UX cost; FU-59 restores localized titles via Fluent.

D10 — Audit envelope (DELETE)

delete_user_me_inner builds (matching the PUT pattern byte-for-byte except event_type + action):

let envelope = canopy_mq::EventEnvelope::new(
    "canopy-web",
    "composition.user.delete",
    serde_json::json!({
        "jurisdiction": juris.to_string(),
        "actor_role": format!("{:?}", session.role),
        "actor_user_id": session.worker_id,
        "layer": "user",
        "target_scope_key": session.worker_id,
        "surface": surface_str,
        "before_etag": before_etag, // Some when row existed; None branch skips audit emit entirely
        "after_etag": serde_json::Value::Null,
        "action": "user.delete",
    }),
);
state.publisher.publish_tx(&mut tx, &envelope).await?;

Validation failures + no-op deletes emit NO audit. Tests assert via a publisher spy.

D11 — canopy-composition loader extension for user_delta_v1

Apply order (post-role-filter, replacing the user-layer apply from the DB patch loop):

  1. defaults

  2. baseline merge (RFC 7396)

  3. DB jurisdiction_live + role layers — RFC 6902 (loop SKIPS user rows)

  4. role filter (loader.rs:230)

  5. DB user row — NEW: shape-detect, apply user_delta_v1 OR RFC 6902 fallback (legacy)

  6. inline validation (spans + row sums)

Pseudo-code at the new step 5:

if let Some(user_row) = user_layer_row {
    if let Ok(delta) = serde_json::from_value::<UserDelta>(user_row.patch_ops.clone()) {
        apply_user_delta(&mut working, &delta)?;
    } else {
        apply_json_patch_6902(&mut working, &user_row.patch_ops)?;
    }
}

crates/canopy-composition/src/user_delta.rs exports:

  • UserDelta enum per D3.

  • apply_user_delta(&mut Value, &UserDelta) → Result<()> — drops hidden_slugs, applies span_overrides, sorts by slug_order (preserving baseline row).

  • validate_user_delta(delta: &UserDelta, baseline_post_role_filter: &[ComposedItem], role: &RoleSlug, plugins: &dyn PluginSource, surface: ComposableSurface) → Result<(), UserDeltaError>. role: &RoleSlug matches role_filter::filter_items_by_role(…​ role: &RoleSlug, …​) precedent at role_filter.rs:22-27. canopy-web caller does let role = role_slug_for_worker(&session.role); validate_user_delta(…​, &role, …​).

Validation steps:

  1. Schema: body deserializes to UserDelta::V1. Else 422.

  2. Baseline membership: every slug ∈ (hidden_slugsspan_overrides.keys()slug_order) MUST appear in baseline_post_role_filter.items. Else 422 SlugNotInBaseline.

  3. Role permitted (defense-in-depth): every referenced slug must pass role_filter::filter_items_by_role for role_slug. Else 422 RoleNotPermitted.

  4. Span ∈ allowed_spans: each (slug, span) in span_overridesspan MUST ∈ plugins.find_panel(&ItemSlug(slug)).map(|(_, def)| &def.allowed_spans). Else 422 SpanOutOfRange.

  5. Dry-run row sums: extract validate_resolved_items(items: &[ComposedItem], plugins: &dyn PluginSource) → Result<(), CompositionError> as a pub(crate) helper in loader.rs (lifted from inline validation at lines 255-274). validate_user_delta clones baseline_post_role_filter, applies the proposed delta via apply_user_delta(&mut cloned_baseline, delta), then calls validate_resolved_items(&cloned_baseline, plugins). Returns 422 on RowOverflow{row, total} / SpanOutOfRange{slug, requested, allowed} etc.

Server-side validation flow — body extractor:

put_user_me and patch_user_me currently extract PutPatchOps(Vec<PatchOperation>). For dashboard surfaces this MUST instead extract UserDeltaOrPatchOps — a new extractor that:

pub struct UserDeltaOrPatchOps {
    pub kind: BodyKind, // Semantic(UserDelta) | Ops(Vec<PatchOperation>)
}

impl<S> FromRequest<S> for UserDeltaOrPatchOps {
    async fn from_request(req: Request, _: &S) -> Result<Self, ...> {
        let body = Bytes::from_request(req, _).await?;
        if let Ok(delta) = serde_json::from_slice::<UserDelta>(&body) {
            return Ok(UserDeltaOrPatchOps { kind: BodyKind::Semantic(delta) });
        }
        if let Ok(ops) = serde_json::from_slice::<Vec<PatchOperation>>(&body) {
            return Ok(UserDeltaOrPatchOps { kind: BodyKind::Ops(ops) });
        }
        Err(CompositionApiError::InvalidPatch("body is neither user_delta_v1 nor RFC 6902 ops".into()))
    }
}

put_user_me handler:

  1. Surface parse.

  2. Body extracted via UserDeltaOrPatchOps.

  3. Surface-aware dispatch:

    • Dashboard surface + BodyKind::Semantic(delta)validate_user_delta(…​) → 422 on fail.

    • Dashboard surface + BodyKind::Ops(_) → accept (legacy back-compat; customize UI never emits this).

    • Non-dashboard surface + BodyKind::Semantic(_) → 415 (semantic schema not supported here).

    • Non-dashboard surface + BodyKind::Ops(ops) → existing flow.

  4. Persist + audit.

patch_user_me returns 415 for dashboard surfaces immediately. Non-dashboard PATCH unchanged.

New error variant: CompositionApiError::ValidationFailed(UserDeltaError) → 422 with structured body. UserDeltaError enum in user_delta.rs (SlugNotInBaseline, RoleNotPermitted, SpanOutOfRange, RowOverflow).

Authorization context: canopy-web’s composition runtime is the permission gate for which panels exist on a worker’s dashboard (required_roles in each Plugin.toml, applied at loader.rs:230). Program services are NOT the auth source for visibility — they trust the service-class token canopy-web fans out with (ADR-019). The customize UI’s "you can only edit panels you can see" rule is enforced at the BFF — step 2 of validate_user_delta is that enforcement (strengthened to require baseline membership, not just role permission).

Steps

Step 1 — Write the ADR

Claimed adr-024-user-layer-semantic-delta-schema.adoc via ls docs/modules/ROOT/pages/adrs/. Mirror existing ADR structure (e.g., adr-021-composability-runtime-and-plugin-model.adoc). Add to docs/modules/ROOT/nav.adoc ADRs section.

Step 2 — canopy-composition: user_delta module + loader branch

Modify crates/canopy-composition/src/loader.rs:

  • Restructure DB-layers loop (lines 179-190) to SKIP user-layer rows; remember the user_layer_row + body for later.

  • After role filter (loader.rs:230), insert NEW step 5: shape-detect + apply user_delta or RFC 6902 fallback (per D11).

  • Extract validate_resolved_items as pub(crate) from the inline validation at lines 255-274.

New crates/canopy-composition/src/user_delta.rs:

  • UserDelta enum + UserDeltaError enum + apply_user_delta + validate_user_delta.

New crates/canopy-composition/tests/user_delta_test.rs:

  • 8 unit tests: apply (basic, unknown-slug-silent-drop, baseline-panel-appended-at-end), validate (rejects each error class), serde roundtrip.

Modify crates/canopy-composition/src/lib.rs:

  • pub mod user_delta;

  • pub use user_delta::{UserDelta, UserDeltaError, apply_user_delta, validate_user_delta};

Step 2.5 — Vendor design source

mkdir -p docs/modules/ROOT/attachments/design
cp /tmp/canopy-design-extract/dashboard/customize.jsx \
   docs/modules/ROOT/attachments/design/customize-jsx.txt
cp /tmp/canopy-design-extract/HANDOFF.md \
   docs/modules/ROOT/attachments/design/HANDOFF.md

ADR + this plan reference via _attachments/design/customize-jsx.txt.

Step 3 — Composition API: extractor + delete_user_me + openapi.rs

Modify services/canopy-web/src/api/composition.rs:

  • New extractor UserDeltaOrPatchOps per D11.

  • put_user_me: change body extraction to UserDeltaOrPatchOps. Surface-aware dispatch in put_user_me_inner.

  • patch_user_me: keep existing extractor; dashboard surface returns 415 immediately.

  • New delete_user_me + delete_user_me_inner per D2. Mirror delete_live (composition.rs:402-462) line-by-line.

  • Modify the route chain at composition.rs:991 to add .delete(delete_user_me).

Modify services/canopy-web/src/api/composition_errors.rs:

  • Add ValidationFailed(UserDeltaError) variant; status 422; structured body.

Modify services/canopy-web/src/openapi.rs:

  • Add delete_user_me to paths() list.

  • Add UserDelta, UserDeltaError to components() schemas.

Modify services/canopy-web/tests/composition_api_test.rs:

  • delete_user_me_round_trip — PUT user_delta_v1, DELETE → 204, GET → 404.

  • delete_user_me_idempotent — DELETE on no-row → 204.

  • noop_delete_emits_no_audit — publisher spy.

  • validation_failure_emits_no_audit — publisher spy.

  • put_user_delta_v1_round_trips_through_loader — integration round-trip.

  • patch_user_me_dashboard_returns_415 — PATCH worker_dashboard with any body → 415.

Step 4 — CSS for customize chrome + DnD visual states

Modify services/canopy-web/static/css/canopy-web.css:

  • Customize page chrome (topbar, hero, permissions, action bar, chrome buttons, span-control, hidden / required cells).

  • DnD interaction states (.is-dragging, .is-drop-target, .is-picked-up).

  • All via --orchard- + --sp- + --r-*.

  • .page-title:focus-visible style for the focus-after-redirect target.

Step 5 — Alpine dashboardCustomizer + h1 focus listener + base.html h1 tabindex

Append to services/canopy-web/static/js/canopy-web.js:

  • Alpine.data('dashboardCustomizer', factory) per D4 — bare method refs only.

  • init() parses <script id="customize-init">.

  • DnD + keyboard + sessionStorage (scoped key) + save / reset / cancel persistence.

  • aria-live polite announcer.

  • DOMContentLoaded init listener: if URLSearchParams(location.search).get('focus') === 'h1', find .page-title and call .focus().

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

  • Add tabindex="-1" to the .page-title h1 at base.html:63 (sidebar layout).

  • Add tabindex="-1" to the .page-title h1 at base.html:118 (topbar layout).

New services/canopy-web/src/api/customize.rs:

  • pub async fn get_customize(…​) per D1 + D9.

  • pub struct CustomizeTemplate { …​ } with #[derive(Template)] and ~13 fields including panel_titles_json, baseline_items_json, etc.

  • 6 unit tests: render empty / full / with hidden / with span override / with stored delta loaded / with stale slugs in stored delta.

New services/canopy-web/templates/dashboard/customize.html:

  • Extends base.html. Defines BOTH {% block content %} and {% block topbar_content %}.

  • Top bar (breadcrumb + EDIT MODE chip with 6px dot), hero card, grid, sticky action bar, save-error banner.

  • <script type="application/json" id="customize-init">{{ init_json|safe }}</script> — server-rendered JSON merging all maps.

  • <div id="dnd-announcer" aria-live="polite" class="sr-only">.

  • <div id="kb-hint" class="sr-only">Keyboard: Space to pick up, arrows to move, Space to drop, Escape to cancel.</div>.

(Per-cell partial _customize_panel.html was folded inline into customize.html during implementation — the partial split was a v3-era convenience that the implementer elided when both blocks already share the same cell markup.)

Modify services/canopy-web/src/api/mod.rs:

  • Add mod customize; after mod composition;.

  • Register .route("/dashboard/customize", get(customize::get_customize)) in routes().

Modify services/canopy-web/templates/base.html nav block:

  • Add nav link ⚙ Customize my dashboard under Dashboard in BOTH the sidebar and topbar nav (matches the tabindex story — chrome is duplicated across two layouts).

Browser verification (cargo xtask dev refresh, then visit the canopy-web URL from cargo xtask dev status):

  • jane.caseworker / password → /dashboard/customize renders 12 panels with edit chrome.

  • Hide a panel → Save → / shows 11 panels.

  • Mouse-drag panel → Save → / shows new order.

  • Tab to cell, Space (pickup), Down (move), Space (drop) → Save → / shows new order.

  • Resize span 6 → 4 on the worker my-queue panel (allowed_spans=[3,4,6,12]) → Save → / shows new span.

  • Reset → / baseline restored.

  • Cancel → / unchanged.

Step 7 — Playwright customize + customize-dark projects + 12 specs

Modify tests/e2e/lib/helpers.ts:

  • dragPanel(page, srcSlug, dstSlug)locator(srcSelector).dragTo(locator(dstSelector)).

  • keyboardPickupMove(page, slug, direction, count) — focus cell, Space, repeat arrow N times, Space.

  • expectPanelOrder(page, expectedSlugs).

Modify tests/e2e/playwright.config.ts:

  • New project customize after analyst, deps: ['auth-setup'], storageState: auth/caseworker.json, testMatch: /specs\/dashboard-customize\.spec\.ts/.

  • New project customize-dark with colorScheme: 'dark' + same testMatch.

  • Update caseworker testMatch regex to exclude dashboard-customize.

New tests/e2e/specs/dashboard-customize.spec.ts — 12 tests:

  1. customize page renders topbar + hero + grid + action bar.

  2. hide panel → save → reload / → assert hidden.

  3. mouse drag panel → save → reload / → assert new order.

  4. keyboard Space + Arrow + Space → save → reload / → assert new order.

  5. resize span 6 → 4 → save → reload / → assert span on /.

  6. reset → DELETE → reload / → assert baseline restored.

  7. cancel → no persistence.

  8. 412 conflict (two-tab save) → second tab shows in-page banner + Reload button.

  9. 428 missing-precondition (handcraft PUT without headers) → 428.

  10. network-error (route abort on PUT) → banner.

  11. axe-core wcag2a + wcag2aa + section508 — filter ['critical', 'serious'].

  12. required-panel placeholder spec: when required_panels contains a slug, the Hide button is disabled AND aria-disabled (assertion currently relies on the empty vec returning empty; spec is the FU-51 placeholder).

Step 8 — CHANGELOG + docs

  • (FIRST sub-step — done as part of writing this .adoc.) Port plan.md to docs/modules/ROOT/pages/plans/archive/worker-portal-redesign-stage5-customize-my-dashboard.adoc.

  • CHANGELOG.adoc under === Added: one terse Keep-a-Changelog entry.

  • docs/modules/ROOT/pages/api/canopy-web.adoc: document GET /dashboard/customize + DELETE /v1/composition/{surface}/user/me + user_delta_v1 schema (dashboard surfaces only).

  • docs/modules/ROOT/pages/plans/worker-portal-redesign.adoc: MR3 row → Done (<DATE>) — !<MR#> merged to main as <merge-commit>.

  • docs/modules/ROOT/nav.adoc: add link to this plan adoc + ADR-024.

  • .claude/docs/services.md: canopy-web row: +1 route + user_delta_v1 schema mention.

  • .claude/CLAUDE.md: ADR list (add ADR-024); Feature Status canopy-web row.

  • Memory: MEMORY.md epic-51 line bump; project_epic_51_in_progress.md MR3 row.

Step 9 — File 7 follow-up issues

FU Title Labels

FU-50

feat: add pinned: bool to ComposedItem + customize Pin button

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

FU-51

feat: required-panel baseline declaration + customize lock-out

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

FU-55

feat: touch-event DnD for customize on mobile / tablet

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

FU-56

sec: rotate CSRF token + cycle session ID on auth success

type::security, priority::high, program::infrastructure, service::web, workflow::needs-spec, compliance::pub-1075

FU-57

chore: cleanup orphaned user composition rows on role change

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

FU-58

feat: global csrf meta tag + htmx listener for future htmx writes

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

FU-59

feat: localized panel titles via Fluent catalogs (resolve display_name_key)

type::feature, priority::low, program::infrastructure, service::web, workflow::needs-spec, compliance::wcag-21-aa

File via glab issue create BEFORE commit (per memory: deferral accountability — file durable + honest).

Step 10 — Pre-commit + push + merge

  1. Verify git config core.hooksPath is .githooks (per memory: hooks path can silently reset).

  2. cargo xtask validate.

  3. Pre-commit hook fires Q1-Q8 → spawn fresh Explore subagent against staged diff; emit Q1-Q8 inline; retry with PRECOMMIT_TOKEN=<token>.

  4. Push with -o ci.skip.

  5. glab mr create + force-merge via MR2.1 pattern (cancel_merge_when_pipeline_succeeds + direct PUT) if pipeline gates.

  6. Update parent plan adoc, memory MEMORY.md + project_epic_51_in_progress.md at commit time.

Files Touched

New

docs/modules/ROOT/pages/plans/archive/worker-portal-redesign-stage5-customize-my-dashboard.adoc

docs/modules/ROOT/pages/adrs/adr-024-user-layer-semantic-delta-schema.adoc

docs/modules/ROOT/attachments/design/customize-jsx.txt

docs/modules/ROOT/attachments/design/HANDOFF.md

crates/canopy-composition/src/user_delta.rs

crates/canopy-composition/tests/user_delta_test.rs

services/canopy-web/src/api/customize.rs

services/canopy-web/templates/dashboard/customize.html

tests/e2e/specs/dashboard-customize.spec.ts

Modified

File Change

crates/canopy-composition/src/loader.rs

User-layer apply moved post-role-filter; validate_resolved_items extracted.

crates/canopy-composition/src/lib.rs

Re-export user_delta module + types.

services/canopy-web/src/api/composition.rs

delete_user_me + UserDeltaOrPatchOps extractor + surface-aware dispatch + route chain.

services/canopy-web/src/api/composition_errors.rs

ValidationFailed(UserDeltaError) variant; 422 mapping.

services/canopy-web/src/api/mod.rs

mod customize; + /dashboard/customize route.

services/canopy-web/src/openapi.rs

delete_user_me + UserDelta / UserDeltaError schemas.

services/canopy-web/templates/base.html

tabindex="-1" on .page-title h1 at lines 63 + 118; customize nav link in sidebar + topbar.

services/canopy-web/static/js/canopy-web.js

dashboardCustomizer Alpine component + h1 focus listener.

services/canopy-web/static/css/canopy-web.css

Customize chrome + DnD visual states + .page-title:focus-visible.

services/canopy-web/tests/composition_api_test.rs

DELETE round-trip + UserDeltaV1 422 paths + integration + audit-not-emitted assertions.

tests/e2e/lib/helpers.ts

drag / keyboard pickup helpers.

tests/e2e/playwright.config.ts

customize + customize-dark projects.

CHANGELOG.adoc

Entry under === Added.

docs/modules/ROOT/pages/api/canopy-web.adoc

New routes + schema doc.

docs/modules/ROOT/pages/plans/worker-portal-redesign.adoc

MR3 row → Done.

docs/modules/ROOT/nav.adoc

Add plan + ADR links.

.claude/docs/services.md

canopy-web row.

.claude/CLAUDE.md

ADR list + canopy-web row.

~/.claude/projects/-home-bitskrieg-code-canopy/memory/MEMORY.md

Epic-51 line bump.

~/.claude/projects/-home-bitskrieg-code-canopy/memory/project_epic_51_in_progress.md

MR3 row.

Verification

cargo nextest run -p canopy-web -p canopy-composition
cargo xtask validate
cargo xtask dev refresh
cargo xtask e2e --no-refresh -- --project=customize --project=customize-dark

Browser checks at the canopy-web URL reported by cargo xtask dev status (or .devstack/.ports.env) — NEVER raw docker compose per feedback_xtask_not_docker_compose:

  • jane.caseworker / password → /dashboard/customize renders 12 panels with edit chrome.

  • Hide a panel → Save → / shows 11 panels.

  • Mouse-drag panel to position 2 → Save → / shows new order.

  • Tab to cell, Space (pickup), Down (move), Space (drop) → Save → / shows new order.

  • Resize span 6 → 4 (worker my-queue, allowed_spans=[3,4,6,12]) → Save → / shows new span.

  • Reset → / baseline restored.

  • Cancel → / unchanged.

  • Two tabs of /customize → Save in one, Save in second → second tab inline banner + Reload button.

Expect: 12/12 customize specs pass × 2 color schemes = 24 test invocations. 0 critical + 0 serious axe violations.

Documentation Updates

  • ADR-024 written + linked from nav.adoc.

  • This plan ported from markdown to durable .adoc.

  • CHANGELOG.adoc — entry under === Added.

  • docs/modules/ROOT/pages/api/canopy-web.adoc — new routes + user_delta_v1 schema.

  • docs/modules/ROOT/pages/plans/worker-portal-redesign.adoc — MR3 row.

  • docs/modules/ROOT/nav.adoc — add link to this plan.

  • .claude/docs/services.md — canopy-web row.

  • .claude/CLAUDE.md — ADR list + canopy-web row.

Implementer notes (Q1-Q8 guidance — not a substitute for the commit-time hook)

  • Q1 (tests): delete_user_me → 2 unit + 4 integration (round-trip, idempotent, audit assertions, dashboard-PATCH-415). UserDelta apply + validate → 8 unit + 1 integration round-trip. Customize handler → 6 unit. Alpine dashboardCustomizer → 12 Playwright specs × 2 color schemes.

  • Q2 (hacks / bypasses): required_panels = vec![] is tracked in FU-51; CSS-ready badge ships now. Pin NOT rendered in v1 (FU-50). CSRF plumbing dropped + filed as FU-58. All explicit, no stubs.

  • Q3 (test weakening): None.

  • Q4 (plan deviations): All descopes reflected in §Scope + the FU table.

  • Q5 (services.md / CLAUDE.md / openapi.rs drift): Both .claude files updated in step-8; openapi.rs in step-3.

  • Q6 (TODO / FIXME / stub): required_panels = vec![] linked to FU-51. No other stubs.

  • Q7 (silent error discard): save / reset / cancel handle 412 / 422 / network with explicit banners; no silent discards.

  • Q8 (SPDX headers): All new .rs start with // SPDX-License-Identifier: AGPL-3.0-or-later; all new .html with the SPDX comment block; the ADR adoc carries the standard ADR header.

Edit this page · default