Files
biggus-dickus/docs/superpowers/specs/2026-06-08-loading-skeletons-design.md
T

116 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.