`/`` 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 `` inside ` ` — render the list skeleton in place of the ``.
+- `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.
diff --git a/web/src/app.tsx b/web/src/app.tsx
index c8bd44d..907a7cc 100644
--- a/web/src/app.tsx
+++ b/web/src/app.tsx
@@ -12,6 +12,7 @@ import { VocabulariesPage } from "./vocab/vocabularies-page";
import { VocabularyTerms } from "./vocab/vocabulary-terms";
import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt";
import { AuthoritiesPage } from "./authorities/authorities-page";
+import { FormSkeleton, ListSkeleton } from "@/components/ui/skeletons";
const ObjectNewPage = lazy(() =>
import("./objects/object-new-page").then((m) => ({ default: m.ObjectNewPage })),
@@ -25,10 +26,6 @@ const FieldsPage = lazy(() =>
import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })),
);
-function FormFallback() {
- return Loading… ;
-}
-
const router = createBrowserRouter(
createRoutesFromElements(
<>
@@ -38,7 +35,7 @@ const router = createBrowserRouter(
}>
+ }>
}
@@ -48,7 +45,7 @@ const router = createBrowserRouter(
}>
+ }>
}
@@ -67,7 +64,7 @@ const router = createBrowserRouter(
}>
+ }>
}
diff --git a/web/src/auth/require-auth.tsx b/web/src/auth/require-auth.tsx
index cc60e88..23876f3 100644
--- a/web/src/auth/require-auth.tsx
+++ b/web/src/auth/require-auth.tsx
@@ -1,11 +1,12 @@
import { Navigate, Outlet } from "react-router-dom";
import { useMe } from "../api/queries";
+import { AppShellSkeleton } from "@/components/ui/skeletons";
export function RequireAuth() {
const { data: user, isLoading } = useMe();
- if (isLoading) return ;
+ if (isLoading) return ;
if (!user) return ;
diff --git a/web/src/authorities/authorities-page.tsx b/web/src/authorities/authorities-page.tsx
index ad821ac..19bd9f8 100644
--- a/web/src/authorities/authorities-page.tsx
+++ b/web/src/authorities/authorities-page.tsx
@@ -7,6 +7,7 @@ import { useAuthorities, useCreateAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button";
import { PageTitle } from "@/components/ui/page-title";
+import { ListSkeleton } from "@/components/ui/skeletons";
import { AuthorityRow } from "./authority-row";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
@@ -68,20 +69,21 @@ export function AuthoritiesPage() {
))}
-
- {isLoading && (
- - …
- )}
- {isError && (
- - {t("authorities.loadError")}
- )}
- {!isLoading && !isError && authorities?.length === 0 && (
- - {t("authorities.empty")}
- )}
- {authorities?.map((a) => (
-
- ))}
-
+ {isLoading ? (
+
+ ) : (
+
+ {isError && (
+ - {t("authorities.loadError")}
+ )}
+ {!isError && authorities?.length === 0 && (
+ - {t("authorities.empty")}
+ )}
+ {authorities?.map((a) => (
+
+ ))}
+
+ )}
|