The map — one pattern per situation, never mixed
- Content is loading (first paint of a card/area) → placeholder lines, demoed below.
- An action is running (save, delete, export) → spinner INSIDE the button +
disabled, demoed below. - The action finished → a toast:
g.success/g.error/g.warning. - Content failed to load → the in-page error state with Retry, demoed below — never a toast (it disappears) and never a blank area.
- There is nothing to show → the empty state, with the create-CTA when the user can fix the emptiness.
- Areas owned by a lib show that lib's built-ins: lister renders skeletons/empty/error itself, former disables its own submit — do not rebuild them.
Empty — icon, one line, the fixing action
No invoices yet
Invoices you issue will show up here.
- Shape: centered,
py-5, a muted 3rem icon (ri-inbox-2-lineunless a more specific one fits), a short heading, ONE explaining line. - The CTA appears only when the user can act on the emptiness (create the first item); filtered-to-nothing results get "Clear filters" instead.
- The icon is muted (
text-body-secondary) — an empty state is calm, not a call for attention.
Error + retry — content that failed to load
Could not load the invoices
Something went wrong on our side. Try again in a moment.
- Same calm shape as empty (this mirrors the lister built-in:
ri-error-warning-line+ text + Retry as.btn-sm .btn-secondary). - The message never surfaces raw error internals — a human sentence; details are in the log (
g.fetchreports client errors automatically). - Retry re-runs the SAME load; while it runs, the button takes the spinner treatment (click the demo button).
Loading content — Bootstrap placeholders, glow
- First paint of a custom async area =
.placeholder-glowwith.placeholderlines roughly matching the real content's shape. - Replace the WHOLE placeholder block with the real markup when data lands — never morph it piece by piece.
- Lister areas skip this — the lib renders its own skeletons.
Action in flight — spinner in the button (live demo)
- While the request runs:
disabled+ the label's icon swaps for.spinner-border.spinner-border-sm— the button keeps its size and label. - Always re-enable in
finally— a failed request must not leave a dead button. - On completion the result is a toast; the button never turns green/red itself.