Worker Portal Design Reference
On this page
This page is the reference distillation of the worker-portal design handoff
(design/canopy-web/). It records the architectural decisions the
design depends on, the schemas the composition runtime must load and validate,
and the contract plugin authors work against.
PanelFrame, Overline, GoldRule, BigNumber, HeroStrip, …) — lives on
its own page. See Orchard Design System. For
the per-page layout reference of the production surfaces as they exist today,
see Worker Portal Mockups.
The design artifacts
The worker-portal design package is a set of self-contained HTML design canvases (open in a browser to pan, zoom, and focus per artboard) backed by JSX component sources. Together they cover the full caseworker surface: the 12-panel dashboard kit and three jurisdiction compositions, case-detail section composability across three shell strategies, the IDP-aware sign-in, Jurisdiction Studio (onboarding wizard, live mode, promote-PR, plugin dev), worker-level dashboard customization, a full dark-mode sweep, empty/loading/ error panel states, the ⌘K command palette, the audit log (system + case scopes), the plugin marketplace, and first-impression states (splash, maintenance, expired, 404).
The package is the application of the design system, not a redefinition of it. The design system is the contract; everything else applies it.
Architectural decisions made during design
These decisions shaped the design and must hold for the design to work. Each is summarized here for context; the authoritative rationale lives in the ratifying ADR, which is xref’d rather than duplicated.
2.1 Composability is the core thesis
A jurisdiction should never fork canopy to make the portal theirs — it edits
TOML in rulesets/{jurisdiction}/. The design enforces composability at three
layers:
-
Dashboard composition — which panels appear, in what rows, at what spans.
-
Case-detail composition — which sections appear, in what order, with which shell strategy (scroll / card-grid / tabs).
-
Identity composition — which IDP(s), in what order, with what label and color.
Every plugin (panel or section) declares its slug, allowed programs, default span, permissions, and i18n catalogs in its manifest. The runtime renders whatever the jurisdiction’s TOML references; plugins not opted-in stay invisible.
Ratified by ADR-021
(compile-time-linked Askama plugins registered via #[canopy_plugin]
linkme, with a PluginSource trait so v2 WASM federation is additive).
2.2 Storage is hybrid-layered
Composition is resolved from layered overrides; the top layer wins.
| Layer | Storage | Edited via | Audience |
|---|---|---|---|
User personal layout |
DB (small JSONB delta) |
"Customize my dashboard" view |
Each worker |
Role overrides |
DB |
Studio (live mode) |
Jurisdiction admin |
Jurisdiction live overrides |
DB |
Studio (live mode) |
Jurisdiction admin |
Jurisdiction baseline |
TOML on disk, in git |
Studio promote → PR |
Maintainer via PR |
System defaults |
Bundled with binary |
canopy core PRs |
canopy team |
Live → baseline promotion is a real PR (the Promote modal in Jurisdiction Studio). On merge, the live override clears because the baseline now equals what was live.
User overrides are a delta, not a full config —
{ hidden: […], pinned: […], rowOrder: […] }. Reset-to-default deletes the
row.
Required panels — a jurisdiction admin can mark a panel required = true in
TOML; the user customize UI shows a lock badge and disables the hide button.
Ratified by ADR-022
(the DB-backed layers share one composition_documents table keyed by
(jurisdiction_id, layer, scope_key, surface); override bodies replay against
the TOML baseline in jurisdiction_live → role → user order). The user-layer
delta shape is further specified by
ADR-024 (the
user_delta_v1 envelope of hidden_slugs / span_overrides / slug_order for
dashboard surfaces).
2.3 Generic IDP interface
The IDP-aware sign-in is the canonical surface. Key behaviors:
-
Email-first discovery is the universal primary affordance. The user types an email; canopy routes to the matching IDP by domain or claim mapping.
-
N configured IDPs render as a list. Each entry carries a slug, label, provider type (Keycloak / SAML / OIDC / etc.), host (shown in mono as proof of inspectability), a chip color + 2-letter chip text, and an optional
primary = true(gold "PRIMARY" badge + green border). -
Local accounts is a toggle, not a default. If
local_accounts = false, the "or local account" section vanishes entirely. -
Zero-IDP state — with no IDPs configured the card shows a calm "Not yet configured" panel pointing at Studio → Identity. Never a broken page.
Ratified by ADR-019 (service identity, IDP integration, and on-behalf-of token flow).
2.4 Audit is JWS-signed and exportable
Every system action emits a signed event. Two scopes share one primitive:
-
System audit — admin-facing, jurisdiction-wide, filterable, exportable to CSV / signed PDF / compliance report.
-
Case history — the same data structure, filtered to one household, exportable as "Cite for hearing" → signed PDF.
The event shape the design assumes:
struct AuditEvent {
id: String, // evt_abc123
ts: DateTime<Utc>,
category: Category, // case | config | auth | service | plugin | ievs | notice
action: String, // dotted: "dashboard.live_changed", "income.edited"
actor: Actor, // { kind: worker|system|applicant, name, role?, initials }
target: Option<Target>, // { type, label, id, link? }
summary: String, // human-readable
meta: serde_json::Value, // structured detail (diffs, before/after)
signature: Vec<u8>, // ed25519 over a canonical encoding
}
Ratified by ADR-014 (the JWS-signed audit hash chain; chain integrity extends across composition mutations and FTI events).
2.5 Empty / loading / error states are first-class
Every panel renders all four states (data plus three):
-
Empty is editorial and confident ("Nothing in your queue."), never chirpy. Sometimes a tiny CTA (
Open Studio →). -
Loading uses pulsing skeletons (the
cy-pulsekeyframe, 1.6s ease-in-out) shaped to the eventual content. Panel height stays constant so the data swap doesn’t reflow layout. -
Error always names the failing service, shows the last-known sync timestamp when useful, and offers Retry + "Status page". It never blames the worker.
2.6 The ⌘K palette is one keystroke from anywhere
Cross-entity search by default. Prefixes narrow scope:
| Prefix | Scope |
|---|---|
|
Households, people, notices, appeals, activity |
|
Command mode (run actions: "Re-run IEVS sync for current case", "Reset my layout", "Open Studio") |
|
People only |
|
Cases only |
RBAC filters at query time — a worker sees the commands they can run, a supervisor sees their team, an admin sees everything. The palette is treated as a panel: same overline + gold rule + ID-mono language as everything else.
2.7 The accent gold has strict scope
#ecbf44 Orchard Gold is for brand and decoration only:
-
The center leaf of the canopy mark.
-
Section rule accents (the 14–40px gold lines).
-
Selected-row indicators (left rail + soft tint).
-
Overline labels (sparingly).
Gold is never used for functional warning UI (use warning amber #e08a2b),
status pills, body text (fails contrast on white at any size), or new decorative
ideas without design review.
#D0AE56, "DHS Logo Gold") exists only for rendering the
actual Georgia DHS agency seal in co-branded materials. Treat it as out of scope
for general UI.
Schemas implied by the design
These are the contracts the composition runtime loads and validates. The TOML blocks below are reproduced faithfully from the design handoff.
4.1 rulesets/{jurisdiction}/theme.toml
Branding plus layout plus the light/dark palettes. The full reference file ships
with the design package (canopy-handoff/georgia-theme.toml); the live implementation is rulesets/georgia/theme.toml + src/theme.rs, with the token contract in the design system reference.
4.2 rulesets/{jurisdiction}/dashboards.toml
# Defines dashboards available in this jurisdiction.
# Multiple dashboards per role allowed; the user picks via a dashboard selector.
[dashboard.my_day]
title = "My day"
roles = ["eligibility_worker"]
default_for_role = true
[[dashboard.my_day.row]]
panels = [
{ type = "kpi_strip", span = 12 },
]
[[dashboard.my_day.row]]
panels = [
{ type = "my_worklist", span = 8 },
{ type = "quick_search", span = 4 },
]
[[dashboard.my_day.row]]
panels = [
{ type = "pending_verifications", span = 6, required = true },
{ type = "renewals_due", span = 6 },
]
[[dashboard.my_day.row]]
panels = [
{ type = "time_pressured", span = 5 },
{ type = "recent_activity", span = 7 },
]
Constraints:
-
Panel
typemust exist in the registry (catalog + installed plugins). -
The sum of spans in a row must equal 12.
-
required = truedisables user-level hiding. -
Any role mentioned in
rolesmust exist inroles.toml.
4.3 rulesets/{jurisdiction}/case-detail.toml
# Shell + section composition for case detail.
# Per-role shells allowed; default is "scroll".
[shell.eligibility_worker]
strategy = "scroll" # scroll | card_grid | tabs
[shell.intake_screener]
strategy = "card_grid"
[shell.snap_only_worker]
strategy = "tabs"
[[section]]
slug = "household"
roles = ["*"] # all roles see it
required = true
[[section]]
slug = "income"
roles = ["*"]
required = true
[[section]]
slug = "determination"
roles = ["*"]
required = true
[[section]]
slug = "abawd"
roles = ["eligibility_worker"]
programs = ["snap"] # only renders for SNAP cases
[[section]]
slug = "notices"
roles = ["*"]
[[section]]
slug = "appeals"
roles = ["*"]
[[section]]
slug = "activity"
roles = ["eligibility_worker", "supervisor"]
4.4 rulesets/{jurisdiction}/idp.toml
# Identity providers configured for this jurisdiction.
# Generic interface — anyone can use kanidm / authentik / keycloak / SAML / etc.
[options]
email_discovery = true # show email-first input
local_accounts = false # disable local accounts entirely
local_accounts_collapsed = true # if enabled, render under an expander
[[idp]]
slug = "state-sso"
label = "State SSO"
provider = "keycloak" # informational; the actual flow is OIDC
host = "auth.dhs.ga.gov"
chip_text = "KC"
chip_color = "#1e5146"
primary = true
[idp.config]
type = "oidc"
client_id = "canopy-ga"
discovery_url = "https://auth.dhs.ga.gov/realms/ga-dhs/.well-known/openid-configuration"
scopes = ["openid", "profile", "email", "groups"]
[[idp]]
slug = "ad-saml"
label = "Active Directory"
provider = "saml2"
host = "fed.fulton.gov"
chip_text = "AD"
chip_color = "#275f63"
[idp.config]
type = "saml2"
entity_id = "https://fed.fulton.gov/idp"
sso_url = "https://fed.fulton.gov/idp/SSO"
cert_path = "secrets/fulton-saml.pem"
# email_routing maps domain -> idp slug for the discovery flow
[email_routing]
"dhs.ga.gov" = "state-sso"
"fulton.gov" = "ad-saml"
4.5 Plugin manifest (Plugin.toml)
[plugin]
slug = "child_support"
name = "Child Support Enforcement"
version = "0.2.1"
author = "Georgia DHS · OCSE Integration Team"
license = "AGPL-3.0-or-later"
canopy_min = "1.4.0"
[plugin.exports]
panels = ["child_support"]
case_sections = ["child_support"]
[panel.child_support]
display_name = "Child support"
icon = "scales"
description = "OCSE enforcement actions, arrears balances, payment status."
programs = ["tanf", "medicaid"]
default_span = 6
allowed_spans = [4, 6, 8, 12]
[data]
# Implemented schema (ADR-021; drifted illustration corrected by #1218):
# `source` is a canopy service slug, `auth` ∈ {none, service_class, user_jwt},
# TTL/timeout are integer seconds/ms, and `endpoints` is a flat allow-list
# (EMPTY is legal for plugins that perform no upstream fetch). The manifest
# TTL is the plugin author's DEFAULT; deployments override per item via the
# composition layers, and cache_ttl_seconds = 0 disables caching.
source = "canopy-exchange"
auth = "service_class"
cache_ttl_seconds = 300
timeout_ms = 2000
endpoints = [
"/v1/child-support/balances/{household_id}",
"/v1/child-support/actions?household_id={household_id}&limit=10",
]
[permissions]
required_roles = ["eligibility_worker", "case_manager", "supervisor"]
audit = "ocse_read"
[i18n]
default = "en-US"
catalogs = ["en-US", "es-US"]
Validation rules: the slug is unique in the catalog, the version is semver, the
programs are known program slugs, allowed_spans ⊆ {1..12}, and every required
role exists.
Hard rules for plugin authors
-
Use
--orchard-*CSS variables. Never hardcode hex. -
No new fonts. Inherit Montserrat + JetBrains Mono.
-
Wrap content in
PanelFramewith an overline label. Don’t add chrome at the panel-frame level. -
Declare programs + permissions in the manifest. The runtime enforces; don’t gate inside the plugin.
-
Implement all four states (data, empty, loading, error). PR review will reject plugins that don’t.
-
Ship en-US + at least one Spanish catalog. Required for publish to the marketplace.
-
No images. Use the leaf glyph or text-only treatments. Plugins should add < 100KB.
-
All write actions are CSRF-protected + audited. Use the canopy CSRF + audit helpers; don’t roll your own.
The CSRF + audit requirement in rule 8, and the manifest-declared permission enforcement in rule 4, follow the CLI/API/UI parity discipline in ADR-007 — every plugin action is a first-class, auditable API operation rather than UI-only behavior.
Open questions — resolution status
The handoff closed with five open questions for implementation to resolve. Most are now settled by the composition ADRs; the remainder are deferred post-UAT or marketplace-federation concerns.
| Question | Status | Notes |
|---|---|---|
DB override layers — table-per-layer or one |
Resolved |
One shared |
Where does git-host API access live for the promote-PR flow? |
Open |
Studio promote-merge lifecycle is explicit-archive (no canopy-core-repo watcher in v1) per ADR-022; the hosting boundary for the PR-creation call is still open. |
Cross-jurisdiction directory service for federated workers (IDP |
Open |
Handoff leaned "probably no for v1"; |
Plugin marketplace |
Open |
A federation question, deferred with the marketplace surface itself. |
Audit retention policy — per-jurisdiction override? |
Partially resolved |
ADR-022 fixes uniform 1-year retention for override-layer audit events; the broader 7-year case-action vs. 1-year config/auth split and any per-jurisdiction override remain a policy decision. |