States

Empty, loading, error — handled the same way everywhere

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-line unless 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.fetch reports 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-glow with .placeholder lines 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.