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

7.5 KiB
Raw Blame History

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, ObjectEditFormfallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>} (mirrors the new/edit page container mx-auto max-w-2xl).
  • FieldsPagefallback={<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 HydrateFallbacks.