Every state surface — alert, toast, status mark, loading, empty, error — speaking one language of five tones.
Cross-cutting laws·docs/feedback.md·180 lines
Kit src/components/feedback/Living example · Feedback
How the app talks back — every state surface (alert, toast, status mark, loading, empty, error) speaks one language. This is a cross-cutting law like card-design-language.md, not a page class: it applies inside every page class. The kit is src/components/feedback/; the living example is /labs/components/feedback.
01
One tone vocabulary, many surfaces. Five tones — success / warning / error / info / neutral — are pinned once in feedback/tone.ts as a complete recipe (Solar glyph, foreground pair, soft wash, tinted border, solid dot). Every status-bearing component composes those strings; none restates a green-/amber-/red-/sky- literal. Before this law, "failing" was red-500 on dashboards, rose-600 on detail pages and red-600 on KPI strips — the vocabulary exists so that drift is structurally impossible.
The families: green = good, amber = caution, red = failure, sky = informational. Good and bad were emerald and rose until v0.85; on a glowing tick meter rose reads pink and emerald reads teal, and the Opt-in rate spectrum's pure red→green became the reference the vocabulary moved to — one edit in tone.ts, every surface followed, which is the point of having a vocabulary. neutral keeps tone props total and renders in the muted greys.
02
They are the same surface at two lifetimes; both render the tone recipe.
feedback/alert.tsx) is persistent: for states the user must be able to re-read — a degraded integration, an expiring trial, a failed payment. It lives in the page, at the top of the region it describes. Tone colors the frame (glyph, wash, border); body copy stays muted grey.ui/toast seam, fired with toast.*) is an alert that walks away: it confirms an action the user just took. If the user would need to read it twice, it must not be a toast.Toast semantics (extends the cross-class "mutations toast" law):
toast.success on completion, toast.error with an action: {label: "Retry"} on failure.toast.promise so the pending state is visible.@/components/ui/toast seam (component-engine doctrine, AGENTS.md): stacking with expand-on-hover, swipe-dismiss, countdown progress, pause-on-hover and live regions live in the engine; the seam owns the firing API (sonner-shaped toast.*) and the tone glyphs. Call sites import ONLY the seam — never a toast package — and nobody adds a second toast library or hand-rolls one.03
StatusDot (glanceable "this has a state") and StatusBadge (labelled mono pill for table cells and card corners), both in feedback/status.tsx. pulse means live/ongoing; settled states hold still. Tone is earned, not decorative — a badge that is always success should be neutral.
Meter (feedback/meter.tsx) is the third mark: a segmented tick bar that shows where a reading sits in a known range — usage against a quota, a score out of 100, a rate on a poor→perfect scale, health across a fleet. That is the ARIA meter role, and it is the line between this part and ui/progress (progressbar): progress is a task getting done and stops mattering at 100%; a meter is a reading the viewer judges. The anatomy is the Opt-in rate card's — tall thin ticks, an unlit muted-foreground/20 track, every lit tick glowing in its own colour (never a blanket glow over the bar) — with two marks: a pointer above the bar for "you are here" (the value) and a notch through it for "you should be here" (a target, the calendar's position). The fill names an intent, never a class: a Tone (same green/amber/red as every dot), primary for a brand-neutral quantity, current for a hue the host binds (a metric's own colour), spectrum for the one graded poor→perfect scale (pinned once, inside the part), or a per-tick function for bars whose ticks carry individual states. value decides how many ticks are lit; fill decides what a lit tick wears; the track is not configurable. pulse is the same word as on StatusDot — live readings breathe, a period meter holds still. Gallery history: nine cards hand-rolled this bar before it was legislated, and five of them carried a [filter:none] override against the shared primitive's default glow — a repeated override is the signal that the default is wrong.
Ring (feedback/ring.tsx) is the meter's round sibling: the same reading drawn as one arc over a muted track for a SQUARE slot — a row of gauges, a card's centrepiece — with the figure in the hole and the unit on the label. Same meter role, same ink vocabulary, the same notch for the target. Its ink is the caller's ruling (a gauge rests muted and earns a tone by threshold) and its light follows the ink: a muted ring never glows. It was extracted from three hand-drawn copies under the chart law's three-times rule (docs/charts.md).
04
Skeletons in that shape. The page keeps its bones and nothing jumps on arrival. This is the default.LoadingState (feedback/loading.tsx): centered spinner + mono caption. Legitimate at route level (loading.tsx) and in freeform regions only.05
EmptyState (feedback/empty.tsx): a glyph that names the situation, a title that states it, a description that says what to do about it (never a restatement of the title). Two species:
DataViewEmpty is this species.06
AsyncBoundary (feedback/async-boundary.tsx): Suspense for pending, ErrorState with a working retry for failure. React-query's error reset is pre-wired, so retry refetches instead of re-throwing the cached error.src/app/(app)/error.tsx / loading.tsx — the shell survives, the page area fails in the same ErrorState voice.ErrorState (feedback/error-state.tsx) is the one failure face: error-toned empty + retry. Don't invent per-module error cards.07
Which floating surface a task gets follows from the task, not taste:
| Surface | Use when |
|---|---|
Popover | Lightweight controls anchored to a trigger (view options, pickers). Dismiss = click away; nothing is lost. |
DropdownMenu | Choosing one action/option from a list. |
Dialog | A focused sub-task that blocks the page and ends in confirm/cancel (edit one record, compose one thing). |
AlertDialog | Confirmation only — destructive or irreversible actions (docs/settings.md: typed confirm for delete-grade). |
Sheet | A side workspace too big for a dialog that still returns to the page (inspect/edit panel). |
Drawer | The mobile-ergonomics variant of a sheet, bottom-anchored. |
One rule across all: floating surfaces carry --shadow-overlay / --overlay-border (docs/theming.md) — never a page-level shadow.
Dialog anatomy. A dialog has three fixed slots: DialogHeader (title band), DialogBody (the ONLY scroll region), DialogFooter (actions). Header and footer are pinned — long content scrolls between them, never under them — and both separate from the body with dashed seams, the same hairline language as cards. Forms inside a dialog wrap body + footer (<form className="flex min-h-0 flex-1 flex-col">) so the submit lives in the pinned footer. Content placed directly in DialogContent without DialogBody is the anti-pattern that breaks the pinning. Floating surfaces share the card family's face: rounded-xl, overlay tokens — toasts included.
08
Dates and numbers render through src/lib/format.ts (fmtDate, fmtDateTime, fmtRelative, fmtInt, fmtCompact, fmtPercent, fmtCurrency) — locale pinned for SSR determinism. Numeric columns pair with tabular-nums. New modules don't hand-roll toLocaleString calls or invent date patterns.
09
green-, red-, …) on a status surface instead of the tone vocabulary. (Palette literals stay legitimate as data-differentiating accents in charts/demo blocks — that's the card language's rule, unchanged.)progressbar for a reading, or a Meter for a task: if the viewer asks "is this good?" it is a meter; if it will hit 100% and stop mattering it is progress.Meter, and let each lit tick cast its own light.