The Four-State Panel Contract
On this page
Rule
Every panel must present all four states to the user. The states are:
-
Data — the real loaded content
-
Loading — fetching
-
Empty — nothing to show
-
Error — service unreachable / failed
As built, the states split by ownership (ADR-033 §6): the composition shell owns loading — it renders the htmx placeholder before a plugin’s synchronous render function ever runs — and the plugin owns data, empty, and error. The user still sees all four; a plugin author writes three, and the blocking lint (§ PR enforcement below) rejects a plugin template that dispatches on state but omits one of its three.
This rule applies to:
-
Every core panel CRAIG ships
-
Every jurisdiction plugin (see Contract 1)
-
Every dashboard composition target (see Contract 3)
The four states, specified
1. Data
The panel doing its actual job, with real data loaded.
-
Real content (no Lorem Ipsum, no placeholder labels in production)
-
Numbers in mono with tabular figures (the design face is JetBrains Mono; see Brand Identity, Palettes, and Typography § Typography for the shipped fallback-stack state)
-
Risk + status conveyed via color and label and icon — never color alone (see Accessibility Playbook)
-
Empty fields render explicitly (em-dash or "—" or "Not yet recorded"), never as blank cells
2. Loading
Fetching. The panel is alive, knows it’s waiting, and won’t reflow when content arrives.
-
Skeletons matched to content shape. A worklist skeleton looks like worklist rows; a panel header skeleton matches the eventual header height.
-
Constant height. The panel reserves the same vertical space as the loaded panel — when data arrives, the swap is in-place, not a reflow that pushes other panels down.
-
1.6 s ease-in-out pulse on skeletons (subtle, not aggressive).
-
Honors
prefers-reduced-motion— the pulse animation MUST be disabled when the OS preference is set; the skeleton stays static. -
No "spinner-only" loading. A bare spinner is rejected — it doesn’t tell the user what shape the data will be.
3. Empty
Nothing to show. The panel is calm about it.
-
Editorial copy, never chirpy. "Nothing in your queue. Everything assigned to you is current." — NOT "Oops! It’s empty 😊" or "No data found." or any variation with emoji.
-
State the fact. First sentence is the fact. ("Nothing in your queue.")
-
Offer context. Second sentence explains why the panel is empty + when it would have content. ("New work shows up here as it arrives.")
-
CTA only if there’s a next step. A quiet, neutral CTA when applicable ("Browse all cases →"). No CTA if there’s nothing the user can do.
-
No decorative empty-state illustrations. The empty state is text + maybe one quiet CTA. Illustrations cost reading-cognition for no information gain.
4. Error
The service is unreachable, the request failed, the data didn’t load.
-
Name the failing service. "craig-cases didn’t respond" — not "An error occurred" or "Something went wrong."
-
Show last-known sync time. "Last synced 7 m ago" reassures the user that their work is not lost.
-
Provide Retry + Status. Two actions: Retry (re-fetch) + Status (link to status page).
-
Reassure that data is safe. "Your work is safe — nothing was lost." Critical for caseworker confidence — a failed dashboard panel does not mean a failed case record.
-
Never blame the worker. Errors are infrastructure failures, not user failures. No "Your session expired — please log in again" framing in a panel-load failure.
-
Never show raw stack traces. Operational telemetry captures the trace; the user sees a human sentence.
Why all four — not "loading + data only"
A panel that ships only "loading + data" lies to users:
-
When the service is unreachable, the user sees an infinite skeleton + no indication something is wrong. They learn (the hard way) that "spinner = broken" — degrading trust in every loading state across the app.
-
When the panel has no data legitimately (new caseworker, empty unit, fresh deploy), the user sees an infinite skeleton + concludes "the app is broken." They file a ticket. Engineering investigates. The answer is "it’s empty."
The four-state contract makes both above failures impossible: the empty state tells the truth (nothing here) and the error state tells the truth (service is down). The user always knows where they stand.
PR enforcement
Every plugin manifest (see Contract 1) declares:
states_required = ["data", "loading", "empty", "error"] # machine-validated at load
Enforcement landed twice over (Plan W Step 4, ADR-033 §6):
-
The manifest parser validates every
states_requiredvalue against the four-state set when the plugin loads. -
The blocking
cargo xtask lints four-state-contractlint (run insidecargo xtask validate) scans every plugin template that uses the state-dispatch idiom and rejects one that omits any plugin-owned arm (data/empty/error;loadingis the shell’s htmx placeholder, so the plugin never renders it). It deliberately catches the realistic "forgot theerrorarm" mistake; a template with no dispatch at all is backstopped by the reference plugin + its end-to-end test.
Two checks from the original design remain PR-review responsibilities, not lint checks: a loading state that uses a bare spinner without skeletons (the shell’s placeholder makes this moot for plugins), and an error state that leaks a stack trace or raw error message.
Mechanically enforceable subset
Enforced by machine today:
-
Every plugin-owned state arm exists in a dispatching template (the blocking lint above)
-
states_requiredvalues are members of the four-state set (manifest parser)
The following are PR-review concerns (NOT lint-enforced today; skeleton-shape and reduced-motion checks would join the lint if loading rendering ever moves plugin-side):
-
Loading skeletons match content shape;
prefers-reduced-motionqueries accompany any loading animation -
Error state names a service + provides Retry + Status actions
-
Empty-state copy is calm + editorial
-
Error-state copy reassures + names the service correctly
-
Data-state numbers are mono + tabular-aligned
-
Color is never the only signal (cross-references Accessibility Playbook)
Where this contract lands in code
-
Plugin manifests declare
states_required(see Contract 1); the parser validates the values at load. -
The composed dashboard grid (ADR-035) is the shipped composition surface; the reference-grade implementations for plugin authors are the two in-tree plugins (
plugins/exampleandplugins/ssa-screening), each dispatching the three plugin-owned states with the shell renderingloading. -
The originally-sketched
craig-test-libfour_state_asserthelper was not built; enforcement landed as the blocking lint plus the reference plugin + its end-to-end test as backstop. -
Build-time lint at
cargo xtask lints four-state-contract— shipped, and blocking insidecargo xtask validate.
Source + provenance
-
External design team, settled 2026-06-08
-
Reference implementation:
docs/handoffs/2026-06-08-design-response/mockups/CRAIG Panel States - Four-State Contract.html(interactive demo of all four states for the worklist panel) -
Engagement-archive bundle:
docs/handoffs/2026-06-08-design-response/ -
Related: Contract 1: Plugin manifest + render contract, Accessibility Playbook