The Four-State Panel Contract

On this page

Rule

Every panel must present all four states to the user. The states are:

  1. Data — the real loaded content

  2. Loading — fetching

  3. Empty — nothing to show

  4. 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_required value against the four-state set when the plugin loads.

  • The blocking cargo xtask lints four-state-contract lint (run inside cargo xtask validate) scans every plugin template that uses the state-dispatch idiom and rejects one that omits any plugin-owned arm (data / empty / error; loading is the shell’s htmx placeholder, so the plugin never renders it). It deliberately catches the realistic "forgot the error arm" 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_required values 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-motion queries 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/example and plugins/ssa-screening), each dispatching the three plugin-owned states with the shell rendering loading.

  • The originally-sketched craig-test-lib four_state_assert helper 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 inside cargo 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

Edit this page · latest