# Standardize Loading States on Skeleton — Design

**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #53.

## Context

Loading is rendered three incompatible ways: good `Skeleton` (objects-table, object-detail,
search-panel, field-list); bare "…" text in a `<li>` (vocabulary-list, vocabulary-terms,
authorities-page) — looks half-rendered; and empty `role="status"` divs (require-auth → blank app on
first load; object-edit-form → blank form pane). Plus the lazy-route `FormFallback` renders full-pane
"Loading…" text → flash + layout shift on first nav to `/objects/new`, `/objects/:id/edit`, `/fields`.

`Skeleton` (`ui/skeleton.tsx`) is a simple `animate-pulse rounded-md bg-muted` div. No shared
skeleton recipes or Spinner exist. No tests assert loading markup (they `findBy` content), so retiring
the placeholders won't break tests. The three "…" sites render a `<li>` inside a `<ul>` (so a
`<div>`-based recipe must replace the `<ul>` when loading, not nest inside it). `AppShell` layout:
`<div flex min-h-screen><aside w-44 border-r>…nav…</aside><div flex-1 flex-col><header border-b px-4
py-2/><main flex-1><Outlet/></main></div></div>`.

### Decisions (from brainstorming)
1. **Shared recipes** `ListSkeleton` + `FormSkeleton` (+ `AppShellSkeleton` for boot), built on
   `Skeleton`, each an `aria` live region.
2. **require-auth → an app-shell-shaped skeleton** (no blank flash, no shift when the real shell mounts).
3. Skeleton-only (no Spinner primitive).

## Components

### `web/src/components/ui/skeletons.tsx` (new — `ui/*` no-semicolon style)
All recipes wrap content in a status region: `<div role="status" aria-label={t("common.loading")}
aria-busy="true" className={…}>`. New i18n `common.loading` (en "Loading", sv "Laddar").
- **`ListSkeleton({ rows = 6, rowClassName = "h-9 w-full", className })`** — `space-y-2 p-3` (+ `className`)
  with `rows` × `<Skeleton className={rowClassName} />`.
- **`FormSkeleton({ fields = 5, className })`** — `space-y-4 p-4` (+ `className`); `fields` × a
  `space-y-1` group (`<Skeleton className="h-3 w-24" />` label + `<Skeleton className="h-8 w-full" />`
  input) + a trailing `<Skeleton className="h-8 w-28" />` (button). Mirrors the object form to avoid shift.
- **`AppShellSkeleton`** — mirrors `AppShell`: `<div className="flex min-h-screen">` → an `aside
  w-44 border-r p-3 space-y-2` with ~5 `<Skeleton className="h-8 w-full" />` nav rows, and a
  `flex-1 flex-col` with a `header border-b px-4 py-2` containing a `<Skeleton className="h-6 w-40" />`
  and a `<main className="flex-1">` containing `<ListSkeleton />`. Single top-level `role="status"`
  on the outer div (one live region for the whole boot screen).

A `skeletons.stories.tsx` renders the three (visual check + a smoke `play` asserting a `status` region).

### Apply across sites

**Retire "…" (render `ListSkeleton` in place of the `<ul>` when loading — valid HTML):**
- `vocabulary-list.tsx`: replace the loading `<li>…</li>`. The list is `<ul className="flex-1
  overflow-auto">`; render `{isLoading ? <ListSkeleton className="flex-1" /> : null}` and keep the
  `isError`/empty/data branches in the `<ul>` (rendered when not loading). (Keep the column layout —
  the skeleton takes the `<ul>`'s place during load.)
- `vocabulary-terms.tsx`: replace the loading `<li>` — render `<ListSkeleton />` when `isLoading`
  instead of the loading `<li>` (keep the `<ul>` for the loaded/empty/error branches).
- `authorities-page.tsx`: same — `<ListSkeleton />` when `isLoading`.
(Concretely: `{isLoading ? <ListSkeleton/> : <ul>…error/empty/rows…</ul>}`, or render the skeleton as a
sibling and gate the `<ul>` on `!isLoading`. Implementer picks the cleaner of the two; do not nest a
`<div>` inside `<ul>`.)

**Retire empty `role="status"` divs:**
- `object-edit-form.tsx` (outer `ObjectEditForm`, `isLoading` branch): `return <FormSkeleton />` (wrap to
  match the edit form's container if needed — it renders inside the objects edit pane).
- `require-auth.tsx` (`isLoading`): `return <AppShellSkeleton />`.

**`app.tsx` lazy fallbacks** — remove `FormFallback`; give each `Suspense` a tailored fallback:
- `ObjectNewPage`, `ObjectEditForm` → `fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}`
  (mirrors the new/edit page container `mx-auto max-w-2xl`).
- `FieldsPage` → `fallback={<ListSkeleton />}` (a simple page skeleton; FieldsPage is a 2-col page, but a
  list skeleton in the pane is a fine, shift-light placeholder for the brief lazy load).

**Retrofit the good list-like skeletons to `ListSkeleton`** (consistency):
- `field-list.tsx`: `isLoading` → `<ListSkeleton rows={6} />` (was `space-y-2 p-3` + 6 × `h-9`). Identical output.
- `search-panel.tsx`: the `hasQuery && search.isLoading` block → `<ListSkeleton rows={5} rowClassName="h-12 w-full" />`.
- **Keep inline:** `objects-table.tsx` (a `<tbody>` of `<tr>`/`<td>` skeleton rows — table-specific) and
  `object-detail.tsx` (single `h-40` block) — both already fitting; not worth forcing into a recipe.

## Data flow / accessibility
Each recipe is a `role="status" aria-busy` live region labelled `common.loading` → screen readers
announce "Loading" (the empty `role="status"` divs announced nothing). Visual skeletons mirror the
loaded layout so there's no jump when content arrives.

## Error handling / edges
- Don't nest `<div>` inside `<ul>` — render the list skeleton in place of the `<ul>`.
- `AppShellSkeleton` is pre-shell (require-auth) — it must not import anything that assumes the shell/
  router context beyond `t()` (it only needs `useTranslation`).
- Multiple `role="status"` regions on one screen are acceptable; `AppShellSkeleton` uses ONE outer
  region (not one per nested skeleton) to avoid SR noise.
- `FormSkeleton` in the lazy fallback vs the object-edit-form loading branch: both render the same
  recipe, so the Suspense fallback → loaded-but-fetching → loaded transition stays visually stable.

## Testing
- `skeletons.stories.tsx`: render `ListSkeleton`/`FormSkeleton`/`AppShellSkeleton`; a `play` asserts a
  `role="status"` is present (smoke).
- Existing suite stays green: no test asserts the old "…"/empty-div markup; tests `findBy` content,
  which still resolves after loading. (If any test was implicitly relying on the empty `role="status"`
  via `getByRole("status")` — none found — update it.) 
- Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; en/sv parity (one new key
  `common.loading`); no codename; no new dependency.

## Acceptance criteria
1. `ListSkeleton`, `FormSkeleton`, `AppShellSkeleton` exist (built on `Skeleton`, each a
   `role="status" aria-label={t("common.loading")}` live region) with a story.
2. The three "…" placeholders are replaced by `ListSkeleton`; the two empty `role="status"` divs are
   replaced (object-edit-form → `FormSkeleton`, require-auth → `AppShellSkeleton`); the lazy
   `FormFallback` is replaced by per-route skeleton fallbacks (no full-pane "Loading…").
3. `field-list` + `search-panel` use the shared `ListSkeleton`; objects-table/object-detail keep their
   fitting inline skeletons.
4. Loading visuals mirror the loaded layout (no obvious shift); screen readers announce loading.
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity; no
   codename; no new dependency.

## Out of scope → follow-ups
- A `Spinner` primitive (Skeleton-only here).
- Reworking objects-table / object-detail inline skeletons.
- Additional route-level Suspense boundaries or data-router `HydrateFallback`s.
