Files
biggus-dickus/docs/superpowers/plans/2026-06-08-loading-skeletons.md

248 lines
13 KiB
Markdown
Raw Permalink 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 — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the "…" text and empty `role="status"` divs with shared `Skeleton`-based loading recipes that mirror the loaded layout and announce loading to screen readers.
**Architecture:** A new `ui/skeletons.tsx` exports `ListSkeleton`, `FormSkeleton`, `AppShellSkeleton` (each a `role="status" aria-label={t("common.loading")}` live region built on the existing `Skeleton`). Apply them at every inconsistent loading site; retrofit the two good list-like skeletons to `ListSkeleton`.
**Tech Stack:** React 19 + TS + pnpm, react-i18next, Vitest + RTL + Storybook. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity (one new key); **ui/ files = no-semicolon** (match `ui/skeleton.tsx`); app source = double-quote+semicolon; stories single-quote/no-semicolon; token classes only; never nest `<div>` inside `<ul>`.
**Spec:** `docs/superpowers/specs/2026-06-08-loading-skeletons-design.md`
**Key facts:**
- `ui/skeleton.tsx`: `function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-muted", className)} {...props} /> }` (no-semicolon).
- "…" sites render `<li>…</li>` inside a `<ul>``vocabulary-list.tsx` (`<ul className="flex-1 overflow-auto">`, loading `<li className="p-3 text-sm text-muted-foreground">…</li>`), `vocabulary-terms.tsx` (`<ul className="mb-4">`), `authorities-page.tsx` (`<ul className="mb-4">`).
- empty status divs: `require-auth.tsx` `if (isLoading) return <div role="status" aria-label="loading" />;` (pre-shell); `object-edit-form.tsx` `if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;`.
- `app.tsx`: `function FormFallback() { return <div role="status" className="p-4 text-sm text-muted-foreground">Loading…</div> }` used in 3 Suspense fallbacks (ObjectNewPage, ObjectEditForm, FieldsPage).
- retrofits: `field-list.tsx` (`space-y-2 p-3` + 6 × `<Skeleton className="h-9 w-full" />`), `search-panel.tsx` (`space-y-2 p-3` + 5 × `<Skeleton className="h-12 w-full" />`).
- i18n `common` namespace exists in both locales: `{ "yes", "no", "close" }` — add `"loading"`.
---
# Task 1: Shared skeleton recipes + i18n + story
**Files:** `web/src/components/ui/skeletons.tsx` (new), `web/src/components/ui/skeletons.stories.tsx` (new), `web/src/i18n/en.json`, `web/src/i18n/sv.json`.
- [ ] **Step 1: i18n** — add `"loading"` to the `common` namespace in BOTH locales (keep parity):
- en: `"common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading" },`
- sv: `"common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar" },`
- [ ] **Step 2: Implement `web/src/components/ui/skeletons.tsx`** (no-semicolon, ui/* style):
```tsx
import { useTranslation } from "react-i18next"
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
function ListSkeleton({
rows = 6,
rowClassName = "h-9 w-full",
className,
}: {
rows?: number
rowClassName?: string
className?: string
}) {
const { t } = useTranslation()
return (
<div role="status" aria-busy="true" aria-label={t("common.loading")} className={cn("space-y-2 p-3", className)}>
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className={rowClassName} />
))}
</div>
)
}
function FormSkeleton({ fields = 5, className }: { fields?: number; className?: string }) {
const { t } = useTranslation()
return (
<div role="status" aria-busy="true" aria-label={t("common.loading")} className={cn("space-y-4 p-4", className)}>
{Array.from({ length: fields }).map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-8 w-full" />
</div>
))}
<Skeleton className="h-8 w-28" />
</div>
)
}
function AppShellSkeleton() {
const { t } = useTranslation()
return (
<div role="status" aria-busy="true" aria-label={t("common.loading")} className="flex min-h-screen">
<aside className="w-44 space-y-2 border-r p-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</aside>
<div className="flex flex-1 flex-col">
<div className="flex items-center border-b px-4 py-2">
<Skeleton className="h-6 w-40" />
</div>
<div className="flex-1 space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
</div>
</div>
)
}
export { ListSkeleton, FormSkeleton, AppShellSkeleton }
```
(`AppShellSkeleton` inlines its content rows rather than nesting `ListSkeleton`, so there's ONE `role="status"` for the whole boot screen. Token classes only.)
- [ ] **Step 3: Story `web/src/components/ui/skeletons.stories.tsx`** (single-quote, no-semicolon; match an existing story, e.g. `menu.stories.tsx`/`page-title.stories.tsx`). Export three stories (`List`, `Form`, `AppShellLoading`) each rendering the respective recipe; one `play` test (on `List`) asserting a `role="status"` region is present:
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect } from 'storybook/test'
import { AppShellSkeleton, FormSkeleton, ListSkeleton } from './skeletons'
const meta = { title: 'ui/Skeletons', tags: ['ai-generated'] } satisfies Meta
export default meta
type Story = StoryObj
export const List: Story = {
render: () => <ListSkeleton rows={4} />,
play: async ({ canvas }) => {
await expect(canvas.getByRole('status')).toBeInTheDocument()
},
}
export const Form: Story = { render: () => <FormSkeleton /> }
export const AppShellLoading: Story = { render: () => <AppShellSkeleton /> }
```
(Adjust the `Meta`/`StoryObj` typing to the house pattern if `satisfies Meta` without a component arg complains — these are render-only stories; mirror how an existing component-less story is typed, or pass `component: ListSkeleton`.)
- [ ] **Step 4: Verify (vitest ONCE):**
`cd web && pnpm vitest run src/components/ui/skeletons.stories.tsx && pnpm typecheck && pnpm lint`
Expected: PASS, clean. (If a storybook cache flake appears — `Cannot read properties of null (reading 'useEffect')``rm -rf node_modules/.cache/storybook node_modules/.vite` and re-run ONCE.)
- [ ] **Step 5: Commit**
```bash
git add web/src/components/ui/skeletons.tsx web/src/components/ui/skeletons.stories.tsx web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): shared loading skeleton recipes (List/Form/AppShell) + common.loading (#53)"
```
---
# Task 2: Apply skeletons across all loading sites + gate
**Files (modify):** `web/src/vocab/vocabulary-list.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/auth/require-auth.tsx`, `web/src/app.tsx`, `web/src/fields/field-list.tsx`, `web/src/search/search-panel.tsx`.
Add `import { ListSkeleton } from "@/components/ui/skeletons";` (and `FormSkeleton`/`AppShellSkeleton` where used) to each. NEVER nest a `<div>` (the recipe) inside a `<ul>` — render the skeleton in place of the `<ul>`.
- [ ] **Step 1: vocabulary-list.tsx.** Read the loading region. The list is `<ul className="flex-1 overflow-auto">` with a loading `<li>…</li>`. Render the skeleton in place of the `<ul>` during load:
```tsx
{isLoading ? (
<ListSkeleton className="flex-1 overflow-auto" />
) : (
<ul className="flex-1 overflow-auto">
{isError && (<li className="p-3 text-sm text-destructive">{t("vocab.loadError")}</li>)}
{data?.length === 0 && (/* keep existing empty state */)}
{data?.map(/* keep existing rows */)}
</ul>
)}
```
Keep the `isError`/empty/rows branches exactly as they are now (just move them under the `!isLoading` `<ul>`; remove the loading `<li>`). Preserve the `flex-1 overflow-auto` layout via the skeleton's `className`.
- [ ] **Step 2: vocabulary-terms.tsx.** The `<ul className="mb-4">` has a loading `<li>`. Same approach:
```tsx
{isLoading ? (
<ListSkeleton className="mb-4" rows={5} />
) : (
<ul className="mb-4">
{isError && (/* keep */)}
{terms?.length === 0 && (/* keep */)}
{terms?.map(/* keep TermRow */)}
</ul>
)}
```
- [ ] **Step 3: authorities-page.tsx.** The `<ul className="mb-4">` has a loading `<li>`. Same:
```tsx
{isLoading ? (
<ListSkeleton className="mb-4" rows={5} />
) : (
<ul className="mb-4">
{isError && (/* keep */)}
{authorities?.length === 0 && (/* keep */)}
{authorities?.map(/* keep AuthorityRow */)}
</ul>
)}
```
- [ ] **Step 4: object-edit-form.tsx.** Replace the outer loading branch:
`if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;`
`if (isLoading) return <FormSkeleton />;`
(Import `FormSkeleton`. The not-found branch stays unchanged.)
- [ ] **Step 5: require-auth.tsx.** Replace:
`if (isLoading) return <div role="status" aria-label="loading" />;`
`if (isLoading) return <AppShellSkeleton />;`
(Import `AppShellSkeleton`. It uses only `useTranslation` — safe pre-shell.)
- [ ] **Step 6: app.tsx lazy fallbacks.** Remove the `FormFallback` function. Import `FormSkeleton` and `ListSkeleton` from `@/components/ui/skeletons`. Replace the three Suspense fallbacks:
- ObjectNewPage: `fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}`
- ObjectEditForm: `fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}`
- FieldsPage: `fallback={<ListSkeleton />}`
Keep the `<Suspense>` wrappers + lazy imports; only the `fallback` prop changes (and `FormFallback` is deleted).
- [ ] **Step 7: field-list.tsx (retrofit).** Replace the inline `isLoading` block:
`return (<div className="space-y-2 p-3">{Array.from({length:6}).map(... <Skeleton h-9 w-full/>)}</div>)`
`return <ListSkeleton rows={6} />;`
Remove the now-unused `Skeleton` import if nothing else uses it (check).
- [ ] **Step 8: search-panel.tsx (retrofit).** Replace the `hasQuery && search.isLoading` block's inline skeleton:
`<div className="space-y-2 p-3">{Array.from({length:5}).map(... <Skeleton h-12 w-full/>)}</div>`
`<ListSkeleton rows={5} rowClassName="h-12 w-full" />`
Remove the now-unused `Skeleton` import if nothing else uses it (check).
- [ ] **Step 9: FULL GATE (run tests EXACTLY ONCE):**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
All green. The existing suite must stay green (no test asserts the old "…"/empty-div markup; tests `findBy` content). If a test fails because it queried `getByRole("status")` and now finds a labelled region (or multiple), update it minimally without weakening. Report test totals, largest chunk (KB gz), check:colors line.
- [ ] **Step 10: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```
- [ ] **Step 11: Manual smoke (recommended).** `pnpm dev`: first load shows the app-shell skeleton (no blank flash); navigating to /vocabularies, /authorities, /search, /fields shows list skeletons (no "…"); opening /objects/:id/edit and /objects/new shows a form skeleton (no full-pane "Loading…"); all transition into content without an obvious jump.
- [ ] **Step 12: Commit**
```bash
git add web/src/vocab/vocabulary-list.tsx web/src/vocab/vocabulary-terms.tsx web/src/authorities/authorities-page.tsx web/src/objects/object-edit-form.tsx web/src/auth/require-auth.tsx web/src/app.tsx web/src/fields/field-list.tsx web/src/search/search-panel.tsx
git commit -m "feat(web): standardize loading on shared skeleton recipes; retire '…' + empty status divs (#53)"
```
---
## Self-Review (completed)
**Spec coverage:** recipes List/Form/AppShell as `role="status"` live regions + `common.loading` + story (T1); three "…" → ListSkeleton, object-edit-form → FormSkeleton, require-auth → AppShellSkeleton, per-route lazy fallbacks replacing FormFallback (T2 S1S6); field-list + search-panel retrofit (T2 S7S8); gate (T2 S9). Acceptance criteria 15 mapped. ✓
**Placeholder scan:** the per-site replacements show the exact before→after and say "keep the existing error/empty/row branches" with the files/lines named — concrete, not vague. The story typing has an explicit fallback note. No TODOs. ✓
**Type/consistency:** `ListSkeleton({rows,rowClassName,className})`, `FormSkeleton({fields,className})`, `AppShellSkeleton()` defined in T1, consumed with matching props in T2; import path `@/components/ui/skeletons` uniform. ✓
## Notes
- No new dependency; one new i18n key (`common.loading`, en+sv).
- HTML validity: list skeletons replace the `<ul>` during load (never `<div>`-in-`<ul>`).
- `check:size` ≈ unchanged (small components) — report it.
- Retrofitting field-list/search-panel may leave an unused `Skeleton` import — remove it (lint will catch).