diff --git a/docs/superpowers/plans/2026-06-03-frontend-spa-milestone-1.md b/docs/superpowers/plans/2026-06-03-frontend-spa-milestone-1.md
new file mode 100644
index 0000000..54817c7
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-03-frontend-spa-milestone-1.md
@@ -0,0 +1,1675 @@
+# Frontend SPA — Milestone 1 (Foundation Slice) 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:** A working `web/` React SPA that lets an admin log in, browse a paginated object list, read a record (two-pane master–detail), switch sv/en, and log out — embeddable into the `server` binary via `memory-serve`.
+
+**Architecture:** Vite + React + TypeScript SPA in `web/`, consuming the existing utoipa OpenAPI through a generated typed client (`openapi-typescript` types + `openapi-fetch`). Server state via TanStack Query; UI via shadcn/ui (Tailwind); i18n via react-i18next. Tests run in Vitest with React Testing Library and MSW (typed mock handlers). In dev, Vite proxies `/api` to the Rust server; in release, `pnpm build` output is embedded into the `server` binary behind a `embed-web` cargo feature and served at `/` with SPA fallback.
+
+**Tech Stack:** pnpm, Vite 6, React 19, TypeScript 5, Tailwind 4 + shadcn/ui, @tanstack/react-query 5, openapi-typescript + openapi-fetch, react-i18next, Vitest + @testing-library/react + msw, Rust `memory-serve` (server crate).
+
+**Reference spec:** `docs/superpowers/specs/2026-06-03-frontend-spa-milestone-1-design.md`
+
+**Backend contract (verified, already shipped):**
+- `POST /api/admin/login` body `{email,password}` → `204` + session cookie; bad creds → `401`.
+- `POST /api/admin/logout` → `204`.
+- `GET /api/admin/me` → `200 {id,email,role}` (`UserView`); no session → `401`.
+- `GET /api/admin/objects?limit&offset` → `200 {items:[AdminObjectView],total,limit,offset}` (`ViewInternal`).
+- `GET /api/admin/objects/{id}` → `200 AdminObjectView` | `404`.
+- `GET /api/admin/field-definitions` → `200 [FieldDefinitionView]` (`{key,data_type,vocabulary_id?,authority_kind?,required,group?,labels:[{lang,label}]}`).
+- `AdminObjectView` = `{id, object_number, object_name, number_of_objects, brief_description?, current_location?, current_owner?, recorder?, recording_date?, visibility:"draft"|"internal"|"public", fields: object}`.
+- OpenAPI JSON served at `GET /api-docs/openapi.json`.
+
+**Prerequisites for the implementer:**
+- A running backend for `gen:api` and manual checks: `DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev cargo run -p server` (server on `:8080`). Create a test admin: `BOOTSTRAP_PASSWORD=pw-admin-123 cargo run -p server -- create-user --email admin@example.com --role admin`.
+- Node ≥ 20, `pnpm` ≥ 9. All web commands run from `web/`.
+- The codename "biggus"/"dickus" must appear NOWHERE in code, config, or text.
+
+---
+
+## Task 1: Scaffold `web/` (Vite + React + TS) and a green test runner
+
+**Files:**
+- Create: `web/package.json`, `web/pnpm-lock.yaml`, `web/tsconfig.json`, `web/tsconfig.node.json`, `web/vite.config.ts`, `web/index.html`, `web/src/main.tsx`, `web/src/app.tsx`, `web/src/vite-env.d.ts`
+- Create test: `web/src/app.test.tsx`
+- Modify: `.gitignore` (add `web/node_modules`, `web/dist`)
+
+- [ ] **Step 1: Scaffold with Vite (non-interactive), then pin scripts**
+
+Run from repo root:
+```bash
+pnpm create vite@latest web -- --template react-ts
+cd web
+pnpm install
+pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
+```
+
+- [ ] **Step 2: Replace `web/package.json` scripts** so the toolchain is explicit
+
+```json
+{
+ "name": "web",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc -b --noEmit",
+ "lint": "eslint .",
+ "gen:api": "openapi-typescript http://localhost:8080/api-docs/openapi.json -o src/api/schema.d.ts"
+ }
+}
+```
+(Keep the `dependencies`/`devDependencies` blocks Vite generated; later tasks add to them.)
+
+- [ ] **Step 3: Configure Vite — dev proxy + Vitest**
+
+Replace `web/vite.config.ts`:
+```ts
+///
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ "/api": "http://localhost:8080",
+ "/api-docs": "http://localhost:8080",
+ "/health": "http://localhost:8080",
+ },
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./src/test/setup.ts"],
+ },
+});
+```
+Create `web/src/test/setup.ts` (filled out in Task 4; for now just jest-dom):
+```ts
+import "@testing-library/jest-dom/vitest";
+```
+
+- [ ] **Step 4: Write the failing test** `web/src/app.test.tsx`
+
+```tsx
+import { render, screen } from "@testing-library/react";
+import { App } from "./app";
+
+test("renders the app placeholder", () => {
+ render();
+ expect(screen.getByRole("heading", { name: /collection/i })).toBeInTheDocument();
+});
+```
+
+- [ ] **Step 5: Run the test to verify it fails**
+
+Run: `pnpm test`
+Expected: FAIL — `./app` has no `App` export (Vite scaffolded `App.tsx`, not `app.tsx`).
+
+- [ ] **Step 6: Replace the scaffold with a minimal app**
+
+Delete `web/src/App.tsx`, `web/src/App.css`, `web/src/index.css`, `web/src/assets` (Vite demo cruft).
+
+Create `web/src/app.tsx`:
+```tsx
+export function App() {
+ return
Collection
;
+}
+```
+Replace `web/src/main.tsx`:
+```tsx
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./app";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
+```
+Set `web/index.html` `` to `Collection` and keep `` + the `main.tsx` module script.
+
+- [ ] **Step 7: Run the test to verify it passes**
+
+Run: `pnpm test`
+Expected: PASS (1 test).
+Run: `pnpm build`
+Expected: typechecks and builds to `web/dist`.
+
+- [ ] **Step 8: Ignore build artifacts and commit**
+
+Append to repo-root `.gitignore`:
+```
+web/node_modules/
+web/dist/
+```
+```bash
+cd ..
+git add web .gitignore
+git commit -m "feat(web): scaffold Vite + React + TS SPA with Vitest"
+```
+
+---
+
+## Task 2: Tailwind 4 + shadcn/ui + ESLint
+
+**Files:**
+- Create: `web/src/index.css`, `web/components.json`, `web/eslint.config.js`, `web/src/lib/utils.ts`, `web/src/components/ui/button.tsx`
+- Modify: `web/vite.config.ts`, `web/src/main.tsx`, `web/tsconfig.json`, `web/package.json`
+
+- [ ] **Step 1: Install Tailwind 4 + the Vite plugin + path alias tooling**
+
+```bash
+cd web
+pnpm add -D tailwindcss @tailwindcss/vite @types/node
+pnpm add class-variance-authority clsx tailwind-merge lucide-react
+```
+
+- [ ] **Step 2: Wire Tailwind into Vite and add the `@` path alias**
+
+In `web/vite.config.ts` add the Tailwind plugin and resolve alias:
+```ts
+import tailwindcss from "@tailwindcss/vite";
+import path from "node:path";
+// plugins: [react(), tailwindcss()],
+// add after plugins:
+// resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
+```
+In `web/tsconfig.json` add under `compilerOptions`:
+```json
+"baseUrl": ".",
+"paths": { "@/*": ["./src/*"] }
+```
+Create `web/src/index.css`:
+```css
+@import "tailwindcss";
+```
+Import it in `web/src/main.tsx` (add `import "./index.css";` at the top).
+
+- [ ] **Step 3: Initialise shadcn/ui (non-interactive) and add Button**
+
+```bash
+pnpm dlx shadcn@latest init -d -b neutral
+pnpm dlx shadcn@latest add button
+```
+This creates `components.json`, `src/lib/utils.ts`, and `src/components/ui/button.tsx`. If `init` prompts despite `-d`, accept: style "new-york", base color "neutral", CSS variables yes.
+
+- [ ] **Step 4: Add ESLint flat config**
+
+```bash
+pnpm add -D eslint @eslint/js typescript-eslint eslint-plugin-react-hooks eslint-plugin-react-refresh globals
+```
+Create `web/eslint.config.js`:
+```js
+import js from "@eslint/js";
+import globals from "globals";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ { ignores: ["dist", "src/components/ui", "src/api/schema.d.ts"] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ["**/*.{ts,tsx}"],
+ languageOptions: { ecmaVersion: 2022, globals: globals.browser },
+ plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh },
+ rules: { ...reactHooks.configs.recommended.rules },
+ },
+);
+```
+
+- [ ] **Step 5: Verify build, lint, and the Task 1 test still pass**
+
+Run: `pnpm lint` → Expected: no errors.
+Run: `pnpm test` → Expected: PASS.
+Run: `pnpm build` → Expected: builds (Tailwind processed).
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd ..
+git add web
+git commit -m "feat(web): Tailwind 4 + shadcn/ui + ESLint"
+```
+
+---
+
+## Task 3: Generated OpenAPI types + typed `openapi-fetch` client (401 redirect)
+
+**Files:**
+- Create: `web/src/api/schema.d.ts` (generated, committed), `web/src/api/client.ts`, `web/src/api/auth-redirect.ts`
+- Test: `web/src/api/client.test.ts`
+- Modify: `web/package.json`
+
+- [ ] **Step 1: Install the client libraries and generate types from the running server**
+
+```bash
+cd web
+pnpm add openapi-fetch
+pnpm add -D openapi-typescript
+```
+Start the backend in another terminal (see Prerequisites), then:
+```bash
+pnpm gen:api
+```
+Expected: `src/api/schema.d.ts` is written with `paths` including `/api/admin/objects`, `/api/admin/me`, etc. Commit this generated file (do not gitignore it).
+
+- [ ] **Step 2: Write the failing test** `web/src/api/client.test.ts`
+
+```ts
+import { describe, expect, test, vi, beforeEach } from "vitest";
+import { server } from "../test/server";
+import { http, HttpResponse } from "msw";
+import { api } from "./client";
+import * as redirect from "./auth-redirect";
+
+describe("api client", () => {
+ beforeEach(() => server.resetHandlers());
+
+ test("returns typed data on success", async () => {
+ server.use(
+ http.get("/api/admin/me", () =>
+ HttpResponse.json({ id: "u1", email: "a@b.se", role: "admin" }),
+ ),
+ );
+ const { data, error } = await api.GET("/api/admin/me");
+ expect(error).toBeUndefined();
+ expect(data?.email).toBe("a@b.se");
+ });
+
+ test("a 401 triggers the auth redirect", async () => {
+ const spy = vi.spyOn(redirect, "redirectToLogin").mockImplementation(() => {});
+ server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 })));
+ await api.GET("/api/admin/me");
+ expect(spy).toHaveBeenCalledOnce();
+ });
+});
+```
+(`../test/server` is created in Task 4; this test will not run until then.)
+
+- [ ] **Step 3: Implement the client + redirect helper**
+
+Create `web/src/api/auth-redirect.ts`:
+```ts
+/** Hard-navigate to login. Isolated so it can be spied/mocked in tests and swapped
+ * for a router navigation if needed. */
+export function redirectToLogin(): void {
+ if (window.location.pathname !== "/login") {
+ window.location.assign("/login");
+ }
+}
+```
+Create `web/src/api/client.ts`:
+```ts
+import createClient, { type Middleware } from "openapi-fetch";
+import type { paths } from "./schema";
+import { redirectToLogin } from "./auth-redirect";
+
+const onUnauthorized: Middleware = {
+ async onResponse({ response }) {
+ if (response.status === 401) {
+ redirectToLogin();
+ }
+ return response;
+ },
+};
+
+export const api = createClient({ credentials: "include" });
+api.use(onUnauthorized);
+```
+
+- [ ] **Step 4: Run the test (after Task 4 wires MSW)**
+
+This task's test depends on Task 4's `src/test/server.ts` + setup. Proceed to Task 4, then run:
+Run: `pnpm test src/api/client.test.ts`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd ..
+git add web
+git commit -m "feat(web): generated OpenAPI types + typed openapi-fetch client with 401 redirect"
+```
+
+---
+
+## Task 4: MSW test harness (typed mock handlers)
+
+**Files:**
+- Create: `web/src/test/server.ts`, `web/src/test/handlers.ts`, `web/src/test/fixtures.ts`
+- Modify: `web/src/test/setup.ts`
+- Test: this task makes Task 3's `client.test.ts` runnable and adds a handler smoke test in `web/src/test/handlers.test.ts`
+
+- [ ] **Step 1: Install MSW**
+
+```bash
+cd web
+pnpm add -D msw
+```
+
+- [ ] **Step 2: Create typed fixtures** `web/src/test/fixtures.ts`
+
+```ts
+import type { components } from "../api/schema";
+
+export type AdminObjectView = components["schemas"]["AdminObjectView"];
+export type AdminObjectPage = components["schemas"]["AdminObjectPage"];
+
+export const amphora: AdminObjectView = {
+ id: "11111111-1111-1111-1111-111111111111",
+ object_number: "LM-0042",
+ object_name: "Amphora",
+ number_of_objects: 1,
+ brief_description: "Storage jar",
+ current_location: "Vault 3",
+ current_owner: null,
+ recorder: null,
+ recording_date: null,
+ visibility: "public",
+ fields: { material: "Bronze" },
+};
+
+export const fibula: AdminObjectView = {
+ ...amphora,
+ id: "22222222-2222-2222-2222-222222222222",
+ object_number: "LM-0043",
+ object_name: "Bronze fibula",
+ visibility: "internal",
+ fields: {},
+};
+
+export const objectsPage: AdminObjectPage = {
+ items: [amphora, fibula],
+ total: 2,
+ limit: 50,
+ offset: 0,
+};
+```
+(If `components["schemas"]["AdminObjectView"]` is absent, open `schema.d.ts` and use the exact generated schema name; utoipa names schemas after the Rust type, so `AdminObjectView`/`AdminObjectPage` should exist.)
+
+- [ ] **Step 3: Create default handlers** `web/src/test/handlers.ts`
+
+```ts
+import { http, HttpResponse } from "msw";
+import { amphora, fibula, objectsPage } from "./fixtures";
+
+export const handlers = [
+ http.get("/api/admin/me", () =>
+ HttpResponse.json({ id: "u1", email: "editor@example.com", role: "editor" }),
+ ),
+ http.get("/api/admin/objects", () => HttpResponse.json(objectsPage)),
+ http.get("/api/admin/objects/:id", ({ params }) => {
+ const found = [amphora, fibula].find((o) => o.id === params.id);
+ return found ? HttpResponse.json(found) : new HttpResponse(null, { status: 404 });
+ }),
+ http.get("/api/admin/field-definitions", () =>
+ HttpResponse.json([
+ {
+ key: "material",
+ data_type: "term",
+ vocabulary_id: "v1",
+ authority_kind: null,
+ required: false,
+ group: null,
+ labels: [{ lang: "en", label: "Material" }],
+ },
+ ]),
+ ),
+ http.post("/api/admin/login", () => new HttpResponse(null, { status: 204 })),
+ http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })),
+];
+```
+
+- [ ] **Step 4: Create the server** `web/src/test/server.ts`
+
+```ts
+import { setupServer } from "msw/node";
+import { handlers } from "./handlers";
+
+export const server = setupServer(...handlers);
+```
+
+- [ ] **Step 5: Wire MSW lifecycle into the global setup** — replace `web/src/test/setup.ts`
+
+```ts
+import "@testing-library/jest-dom/vitest";
+import { afterAll, afterEach, beforeAll } from "vitest";
+import { server } from "./server";
+
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+```
+
+- [ ] **Step 6: Add a handler smoke test** `web/src/test/handlers.test.ts`
+
+```ts
+import { expect, test } from "vitest";
+import { api } from "../api/client";
+
+test("default handler serves the objects page", async () => {
+ const { data } = await api.GET("/api/admin/objects", { params: { query: {} } });
+ expect(data?.total).toBe(2);
+ expect(data?.items[0].object_number).toBe("LM-0042");
+});
+```
+
+- [ ] **Step 7: Run all tests (now Task 3's client test runs too)**
+
+Run: `pnpm test`
+Expected: PASS — `client.test.ts` (2) + `handlers.test.ts` (1) + `app.test.tsx` (1).
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd ..
+git add web
+git commit -m "test(web): MSW harness with typed handlers and fixtures"
+```
+
+---
+
+## Task 5: i18n (react-i18next, sv/en)
+
+**Files:**
+- Create: `web/src/i18n/index.ts`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/i18n/use-locale.ts`
+- Test: `web/src/i18n/i18n.test.tsx`
+- Modify: `web/src/main.tsx`
+
+- [ ] **Step 1: Install**
+
+```bash
+cd web
+pnpm add i18next react-i18next
+```
+
+- [ ] **Step 2: Translation resources** — `web/src/i18n/en.json`
+
+```json
+{
+ "app": { "name": "Collection" },
+ "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "soon": "Coming soon" },
+ "auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
+ "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of" },
+ "fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" },
+ "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }
+}
+```
+`web/src/i18n/sv.json` (same keys, Swedish):
+```json
+{
+ "app": { "name": "Samling" },
+ "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "soon": "Kommer snart" },
+ "auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
+ "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av" },
+ "fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" },
+ "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }
+}
+```
+
+- [ ] **Step 3: Init i18next** — `web/src/i18n/index.ts`
+
+```ts
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import en from "./en.json";
+import sv from "./sv.json";
+
+export const LOCALE_KEY = "locale";
+const stored = localStorage.getItem(LOCALE_KEY);
+const fallback = "en";
+
+void i18n.use(initReactI18next).init({
+ resources: { en: { translation: en }, sv: { translation: sv } },
+ lng: stored ?? fallback,
+ fallbackLng: fallback,
+ interpolation: { escapeValue: false },
+});
+
+export default i18n;
+```
+`web/src/i18n/use-locale.ts`:
+```ts
+import { useTranslation } from "react-i18next";
+import i18n, { LOCALE_KEY } from "./index";
+
+export function useLocale() {
+ const { i18n: instance } = useTranslation();
+ const setLocale = (lng: "en" | "sv") => {
+ localStorage.setItem(LOCALE_KEY, lng);
+ void i18n.changeLanguage(lng);
+ };
+ return { locale: instance.language, setLocale };
+}
+```
+Add `import "./i18n";` to `web/src/main.tsx` (top, after `index.css`).
+
+- [ ] **Step 4: Write the failing test** `web/src/i18n/i18n.test.tsx`
+
+```tsx
+import { expect, test } from "vitest";
+import { render, screen, act } from "@testing-library/react";
+import { useTranslation } from "react-i18next";
+import "./index";
+import { useLocale } from "./use-locale";
+
+function Probe() {
+ const { t } = useTranslation();
+ const { setLocale } = useLocale();
+ return (
+
+ {t("objects.title")}
+
+
+ );
+}
+
+test("switches language at runtime", async () => {
+ render();
+ expect(screen.getByTestId("title")).toHaveTextContent("Objects");
+ await act(async () => {
+ screen.getByRole("button", { name: "sv" }).click();
+ });
+ expect(screen.getByTestId("title")).toHaveTextContent("Föremål");
+});
+```
+
+- [ ] **Step 5: Run → fail → (already implemented in Step 3) → pass**
+
+Run: `pnpm test src/i18n/i18n.test.tsx`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd ..
+git add web
+git commit -m "feat(web): i18n with react-i18next (sv/en)"
+```
+
+---
+
+## Task 6: TanStack Query + `useMe` + session guard
+
+**Files:**
+- Create: `web/src/api/queries.ts`, `web/src/auth/require-auth.tsx`, `web/src/test/render.tsx`
+- Test: `web/src/auth/require-auth.test.tsx`
+- Modify: `web/src/main.tsx`
+
+- [ ] **Step 1: Install TanStack Query + the router**
+
+```bash
+cd web
+pnpm add @tanstack/react-query react-router-dom
+```
+
+- [ ] **Step 2: Query hooks** — `web/src/api/queries.ts`
+
+```ts
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { api } from "./client";
+import type { components } from "./schema";
+
+type UserView = components["schemas"]["UserView"];
+
+export function useMe() {
+ return useQuery({
+ queryKey: ["me"],
+ queryFn: async (): Promise => {
+ const { data, response } = await api.GET("/api/admin/me");
+ if (response.status === 401) return null;
+ if (!data) throw new Error("failed to load session");
+ return data;
+ },
+ retry: false,
+ });
+}
+
+export function useObjectsPage(limit: number, offset: number) {
+ return useQuery({
+ queryKey: ["objects", { limit, offset }],
+ queryFn: async () => {
+ const { data, error } = await api.GET("/api/admin/objects", {
+ params: { query: { limit, offset } },
+ });
+ if (error || !data) throw new Error("failed to load objects");
+ return data;
+ },
+ });
+}
+
+export function useObject(id: string) {
+ return useQuery({
+ queryKey: ["object", id],
+ queryFn: async () => {
+ const { data, response } = await api.GET("/api/admin/objects/{id}", {
+ params: { path: { id } },
+ });
+ if (response.status === 404) return null;
+ if (!data) throw new Error("failed to load object");
+ return data;
+ },
+ });
+}
+
+export function useFieldDefinitions() {
+ return useQuery({
+ queryKey: ["field-definitions"],
+ queryFn: async () => {
+ const { data, error } = await api.GET("/api/admin/field-definitions");
+ if (error || !data) throw new Error("failed to load field definitions");
+ return data;
+ },
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useLogin() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (body: { email: string; password: string }) => {
+ const { response } = await api.POST("/api/admin/login", { body });
+ if (response.status !== 204) {
+ throw new Error(response.status === 401 ? "invalid" : "network");
+ }
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }),
+ });
+}
+
+export function useLogout() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async () => {
+ await api.POST("/api/admin/logout");
+ },
+ onSuccess: () => qc.setQueryData(["me"], null),
+ });
+}
+```
+(Confirm the path-param style `/api/admin/objects/{id}` matches the generated `paths` keys in `schema.d.ts`; openapi-typescript keeps the `{id}` template literally.)
+
+- [ ] **Step 3: A test render helper** — `web/src/test/render.tsx`
+
+```tsx
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter } from "react-router-dom";
+import { render } from "@testing-library/react";
+import type { ReactElement } from "react";
+import "../i18n";
+
+export function renderApp(ui: ReactElement, { route = "/" } = {}) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+ {ui}
+ ,
+ );
+}
+```
+
+- [ ] **Step 4: Write the failing test** `web/src/auth/require-auth.test.tsx`
+
+```tsx
+import { expect, test } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { Routes, Route } from "react-router-dom";
+import { server } from "../test/server";
+import { renderApp } from "../test/render";
+import { RequireAuth } from "./require-auth";
+
+function tree() {
+ return (
+
+ login page} />
+ }>
+ secret objects} />
+
+
+ );
+}
+
+test("renders children when authenticated", async () => {
+ renderApp(tree(), { route: "/objects" });
+ expect(await screen.findByText("secret objects")).toBeInTheDocument();
+});
+
+test("redirects to /login when unauthenticated", async () => {
+ server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 })));
+ renderApp(tree(), { route: "/objects" });
+ await waitFor(() => expect(screen.getByText("login page")).toBeInTheDocument());
+});
+```
+
+- [ ] **Step 5: Implement** — `web/src/auth/require-auth.tsx`
+
+```tsx
+import { Navigate, Outlet } from "react-router-dom";
+import { useMe } from "../api/queries";
+
+export function RequireAuth() {
+ const { data: user, isLoading } = useMe();
+ if (isLoading) return ;
+ if (!user) return ;
+ return ;
+}
+```
+
+- [ ] **Step 6: Run the test**
+
+Run: `pnpm test src/auth/require-auth.test.tsx`
+Expected: PASS (2 tests).
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd ..
+git add web
+git commit -m "feat(web): TanStack Query hooks + session-guarded routes"
+```
+
+---
+
+## Task 7: Login page (sign in / sign out)
+
+**Files:**
+- Create: `web/src/auth/login-page.tsx`
+- Test: `web/src/auth/login-page.test.tsx`
+- May add: shadcn `input`, `label`, `card` (`pnpm dlx shadcn@latest add input label card`)
+
+- [ ] **Step 1: Add the shadcn primitives the form uses**
+
+```bash
+cd web
+pnpm dlx shadcn@latest add input label card
+```
+
+- [ ] **Step 2: Write the failing test** `web/src/auth/login-page.test.tsx`
+
+```tsx
+import { expect, test, vi } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse } from "msw";
+import { Routes, Route } from "react-router-dom";
+import { server } from "../test/server";
+import { renderApp } from "../test/render";
+import { LoginPage } from "./login-page";
+
+function tree() {
+ return (
+
+ } />
+ objects landing} />
+
+ );
+}
+
+test("successful login navigates to /objects", async () => {
+ renderApp(tree(), { route: "/login" });
+ await userEvent.type(screen.getByLabelText(/email/i), "editor@example.com");
+ await userEvent.type(screen.getByLabelText(/password/i), "pw-editor-123");
+ await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
+ expect(await screen.findByText("objects landing")).toBeInTheDocument();
+});
+
+test("invalid credentials show an inline error", async () => {
+ server.use(http.post("/api/admin/login", () => new HttpResponse(null, { status: 401 })));
+ renderApp(tree(), { route: "/login" });
+ await userEvent.type(screen.getByLabelText(/email/i), "x@y.se");
+ await userEvent.type(screen.getByLabelText(/password/i), "wrong");
+ await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
+ await waitFor(() =>
+ expect(screen.getByText(/invalid email or password/i)).toBeInTheDocument(),
+ );
+});
+```
+
+- [ ] **Step 3: Implement** — `web/src/auth/login-page.tsx`
+
+```tsx
+import { useState, type FormEvent } from "react";
+import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { useLogin } from "../api/queries";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+export function LoginPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const login = useLogin();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+
+ const onSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ login.mutate(
+ { email, password },
+ { onSuccess: () => navigate("/objects", { replace: true }) },
+ );
+ };
+
+ const errorKey = login.error
+ ? login.error.message === "invalid"
+ ? "auth.invalid"
+ : "auth.networkError"
+ : null;
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `pnpm test src/auth/login-page.test.tsx`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd ..
+git add web
+git commit -m "feat(web): login page with inline error handling"
+```
+
+---
+
+## Task 8: App shell (sidebar + top bar + language/logout)
+
+**Files:**
+- Create: `web/src/shell/app-shell.tsx`, `web/src/shell/lang-switch.tsx`
+- Test: `web/src/shell/app-shell.test.tsx`
+
+- [ ] **Step 1: Write the failing test** `web/src/shell/app-shell.test.tsx`
+
+```tsx
+import { expect, test } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Routes, Route } from "react-router-dom";
+import { renderApp } from "../test/render";
+import { AppShell } from "./app-shell";
+
+function tree() {
+ return (
+
+ }>
+ objects outlet} />
+
+ login page} />
+
+ );
+}
+
+test("shows active and disabled nav and renders the outlet", async () => {
+ renderApp(tree(), { route: "/objects" });
+ expect(await screen.findByText("objects outlet")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument();
+ // later milestones are present but disabled
+ expect(screen.getByRole("button", { name: /search/i })).toBeDisabled();
+});
+
+test("language switch toggles to Swedish", async () => {
+ renderApp(tree(), { route: "/objects" });
+ await userEvent.click(await screen.findByRole("button", { name: "SV" }));
+ await waitFor(() => expect(screen.getByText("Föremål")).toBeInTheDocument());
+});
+```
+
+- [ ] **Step 2: Implement the language switch** — `web/src/shell/lang-switch.tsx`
+
+```tsx
+import { useLocale } from "../i18n/use-locale";
+
+export function LangSwitch() {
+ const { locale, setLocale } = useLocale();
+ const base = locale.startsWith("sv") ? "sv" : "en";
+ return (
+
+ {(["sv", "en"] as const).map((lng) => (
+
+ ))}
+
+ );
+}
+```
+
+- [ ] **Step 3: Implement the shell** — `web/src/shell/app-shell.tsx`
+
+```tsx
+import { NavLink, Outlet, useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { useLogout } from "../api/queries";
+import { LangSwitch } from "./lang-switch";
+import { Button } from "@/components/ui/button";
+
+const FUTURE = ["vocabularies", "authorities", "fields", "search"] as const;
+
+export function AppShell() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const logout = useLogout();
+
+ const onSignOut = () =>
+ logout.mutate(undefined, { onSuccess: () => navigate("/login", { replace: true }) });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `pnpm test src/shell/app-shell.test.tsx`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd ..
+git add web
+git commit -m "feat(web): app shell with sidebar nav, language switch, sign out"
+```
+
+---
+
+## Task 9: Objects list (left pane) + visibility badge
+
+**Files:**
+- Create: `web/src/objects/visibility-badge.tsx`, `web/src/objects/object-list.tsx`
+- Test: `web/src/objects/object-list.test.tsx`
+- May add shadcn `badge`, `skeleton` (`pnpm dlx shadcn@latest add badge skeleton`)
+
+- [ ] **Step 1: Add primitives**
+
+```bash
+cd web
+pnpm dlx shadcn@latest add badge skeleton
+```
+
+- [ ] **Step 2: Write the failing test** `web/src/objects/object-list.test.tsx`
+
+```tsx
+import { expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { Routes, Route } from "react-router-dom";
+import { server } from "../test/server";
+import { renderApp } from "../test/render";
+import { ObjectList } from "./object-list";
+
+function tree() {
+ return (
+
+ } />
+ } />
+
+ );
+}
+
+test("renders object rows with number, name and visibility", async () => {
+ renderApp(tree(), { route: "/objects" });
+ expect(await screen.findByText("LM-0042")).toBeInTheDocument();
+ expect(screen.getByText("Amphora")).toBeInTheDocument();
+ expect(screen.getByText("Public")).toBeInTheDocument();
+});
+
+test("shows an empty state when there are no objects", async () => {
+ server.use(
+ http.get("/api/admin/objects", () =>
+ HttpResponse.json({ items: [], total: 0, limit: 50, offset: 0 }),
+ ),
+ );
+ renderApp(tree(), { route: "/objects" });
+ expect(await screen.findByText(/no objects yet/i)).toBeInTheDocument();
+});
+
+test("shows an error state on failure", async () => {
+ server.use(
+ http.get("/api/admin/objects", () => new HttpResponse(null, { status: 500 })),
+ );
+ renderApp(tree(), { route: "/objects" });
+ expect(await screen.findByText(/could not load objects/i)).toBeInTheDocument();
+});
+```
+
+- [ ] **Step 3: Implement the badge** — `web/src/objects/visibility-badge.tsx`
+
+```tsx
+import { useTranslation } from "react-i18next";
+import { Badge } from "@/components/ui/badge";
+
+const STYLES: Record = {
+ draft: "bg-neutral-100 text-neutral-600",
+ internal: "bg-amber-100 text-amber-800",
+ public: "bg-green-100 text-green-800",
+};
+
+export function VisibilityBadge({ visibility }: { visibility: string }) {
+ const { t } = useTranslation();
+ return (
+
+ {t(`visibility.${visibility}`)}
+
+ );
+}
+```
+
+- [ ] **Step 4: Implement the list** — `web/src/objects/object-list.tsx`
+
+```tsx
+import { NavLink, useParams } from "react-router-dom";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useObjectsPage } from "../api/queries";
+import { VisibilityBadge } from "./visibility-badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Button } from "@/components/ui/button";
+
+const LIMIT = 50;
+
+export function ObjectList() {
+ const { t } = useTranslation();
+ const { id: selectedId } = useParams();
+ const [offset, setOffset] = useState(0);
+ const { data, isLoading, isError } = useObjectsPage(LIMIT, offset);
+
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 6 }).map((_, i) => )}
+
+ );
+ }
+ if (isError) return {t("objects.loadError")}
;
+ if (!data || data.items.length === 0) {
+ return {t("objects.empty")}
;
+ }
+
+ const from = data.total === 0 ? 0 : offset + 1;
+ const to = Math.min(offset + LIMIT, data.total);
+
+ return (
+
+
+ {data.items.map((object) => (
+ -
+
+
+ {object.object_number}{" "}
+ {object.object_name}
+
+
+
+
+ ))}
+
+
+ {from}–{to} {t("objects.of")} {data.total}
+
+
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 5: Run the test**
+
+Run: `pnpm test src/objects/object-list.test.tsx`
+Expected: PASS (3 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd ..
+git add web
+git commit -m "feat(web): paginated object list with visibility badges and states"
+```
+
+---
+
+## Task 10: Object detail (right pane) + two-pane page + routing
+
+**Files:**
+- Create: `web/src/objects/object-detail.tsx`, `web/src/objects/objects-page.tsx`
+- Test: `web/src/objects/object-detail.test.tsx`, `web/src/objects/objects-page.test.tsx`
+
+- [ ] **Step 1: Write the failing detail test** `web/src/objects/object-detail.test.tsx`
+
+```tsx
+import { expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { Routes, Route } from "react-router-dom";
+import { server } from "../test/server";
+import { renderApp } from "../test/render";
+import { ObjectDetail } from "./object-detail";
+
+function tree() {
+ return (
+
+ } />
+
+ );
+}
+
+test("renders inventory-minimum fields, flexible values and visibility", async () => {
+ renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" });
+ expect(await screen.findByText("Amphora")).toBeInTheDocument();
+ expect(screen.getByText("Vault 3")).toBeInTheDocument();
+ expect(screen.getByText("Bronze")).toBeInTheDocument(); // flexible field value
+ expect(screen.getByText("Public")).toBeInTheDocument();
+});
+
+test("shows a not-found state for a missing object", async () => {
+ server.use(http.get("/api/admin/objects/:id", () => new HttpResponse(null, { status: 404 })));
+ renderApp(tree(), { route: "/objects/does-not-exist" });
+ expect(await screen.findByText(/object not found/i)).toBeInTheDocument();
+});
+```
+
+- [ ] **Step 2: Implement the detail** — `web/src/objects/object-detail.tsx`
+
+```tsx
+import { useParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { useObject, useFieldDefinitions } from "../api/queries";
+import { VisibilityBadge } from "./visibility-badge";
+import { Skeleton } from "@/components/ui/skeleton";
+
+function Field({ label, value }: { label: string; value: string | number | null | undefined }) {
+ if (value === null || value === undefined || value === "") return null;
+ return (
+
+ );
+}
+
+export function ObjectDetail() {
+ const { t } = useTranslation();
+ const { id } = useParams();
+ const { data: object, isLoading, isError } = useObject(id!);
+ const { data: definitions } = useFieldDefinitions();
+
+ if (isLoading) return
;
+ if (isError) return {t("objects.loadError")}
;
+ if (!object) return {t("objects.notFound")}
;
+
+ const labelFor = (key: string) =>
+ definitions?.find((d) => d.key === key)?.labels.find((l) => l.lang === "en")?.label ?? key;
+ const flexible = Object.entries(object.fields ?? {});
+
+ return (
+
+
+
{object.object_name}
+
+
+
+
+
+
+
+
+
+ {flexible.length > 0 && (
+
+
+ {t("fieldsLabels.flexible")}
+
+ {flexible.map(([key, value]) => (
+
+ ))}
+
+ )}
+
+ );
+}
+```
+
+- [ ] **Step 3: Write the failing two-pane test** `web/src/objects/objects-page.test.tsx`
+
+```tsx
+import { expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Routes, Route } from "react-router-dom";
+import { renderApp } from "../test/render";
+import { ObjectsPage } from "./objects-page";
+
+function tree() {
+ return (
+
+ } />
+ } />
+
+ );
+}
+
+test("selecting a row shows its detail in the right pane", async () => {
+ renderApp(tree(), { route: "/objects" });
+ expect(await screen.findByText(/select an object/i)).toBeInTheDocument();
+ await userEvent.click(screen.getByText("Amphora"));
+ expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument();
+});
+```
+
+- [ ] **Step 4: Implement the two-pane page** — `web/src/objects/objects-page.tsx`
+
+```tsx
+import { useParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { ObjectList } from "./object-list";
+import { ObjectDetail } from "./object-detail";
+
+export function ObjectsPage() {
+ const { t } = useTranslation();
+ const { id } = useParams();
+ return (
+
+
+
+
+
+ {id ? (
+
+ ) : (
+
+ {t("objects.selectPrompt")}
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 5: Wire the router** — replace `web/src/app.tsx`
+
+```tsx
+import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
+import { RequireAuth } from "./auth/require-auth";
+import { LoginPage } from "./auth/login-page";
+import { AppShell } from "./shell/app-shell";
+import { ObjectsPage } from "./objects/objects-page";
+
+export function App() {
+ return (
+
+
+ } />
+ }>
+ }>
+ } />
+ } />
+ } />
+
+
+ } />
+
+
+ );
+}
+```
+Update `web/src/main.tsx` to wrap `` in `QueryClientProvider`:
+```tsx
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { App } from "./app";
+import "./index.css";
+import "./i18n";
+
+const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
+});
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
+```
+Update `web/src/app.test.tsx` (the Task 1 smoke test) — `App` now renders the router and immediately fetches `/me`; assert the login or objects landing instead:
+```tsx
+import { render, screen } from "@testing-library/react";
+import { App } from "./app";
+import "./i18n";
+
+test("mounts and routes to a known screen", async () => {
+ render();
+ expect(await screen.findByText(/object|föremål|sign in|logga in/i)).toBeInTheDocument();
+});
+```
+
+- [ ] **Step 6: Run the full suite + typecheck + lint + build**
+
+Run: `pnpm test` → Expected: all PASS.
+Run: `pnpm typecheck` → Expected: clean.
+Run: `pnpm lint` → Expected: clean.
+Run: `pnpm build` → Expected: builds to `web/dist`.
+
+- [ ] **Step 7: Manual smoke against the real server (optional but recommended)**
+
+With the backend running and a user created, run `pnpm dev`, open `http://localhost:5173`, log in, browse, open a record, switch language, sign out.
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd ..
+git add web
+git commit -m "feat(web): object detail + two-pane page + app routing"
+```
+
+---
+
+## Task 11: Embed the SPA into the server binary (`memory-serve`, feature-gated)
+
+**Files:**
+- Modify: `crates/server/Cargo.toml`, `crates/server/src/lib.rs`, `Cargo.toml` (workspace dep)
+- Create: `crates/server/src/web_assets.rs`
+- Test: `crates/server/tests/embed.rs`
+
+- [ ] **Step 1: Add `memory-serve` as an optional workspace dependency**
+
+In root `Cargo.toml` `[workspace.dependencies]` add:
+```toml
+memory-serve = "1"
+```
+(Use `gateway_invoke(server="cargo-mcp", ...)`/`cratesio` to confirm the current major version and the exact `MemoryServe`/`load_assets!` API before pinning; adapt the code below to the resolved version.)
+
+In `crates/server/Cargo.toml`:
+```toml
+[dependencies]
+memory-serve = { workspace = true, optional = true }
+
+[features]
+embed-web = ["dep:memory-serve"]
+```
+
+- [ ] **Step 2: Create the assets module** — `crates/server/src/web_assets.rs`
+
+```rust
+//! Serves the embedded SPA (built `web/dist`) at `/` with a client-side-routing
+//! fallback. Compiled only with the `embed-web` feature; in dev the SPA is served by
+//! Vite (which proxies `/api` to this server), so this module is absent.
+
+use axum::Router;
+use memory_serve::{load_assets, MemoryServe};
+
+/// A router that serves the embedded `web/dist` assets, falling back to `index.html`
+/// for unknown paths so the SPA can own client-side routes.
+pub fn routes() -> Router {
+ MemoryServe::new(load_assets!("../../web/dist"))
+ .index_file(Some("/index.html"))
+ .fallback(Some("/index.html"))
+ .into_router()
+}
+```
+(`load_assets!` resolves its path relative to `CARGO_MANIFEST_DIR` = `crates/server`; `../../web/dist` points at the repo-root build output. Confirm the builder method names against the resolved `memory-serve` version and adjust.)
+
+- [ ] **Step 3: Merge the assets router when the feature is on** — in `crates/server/src/lib.rs`
+
+Add near the top:
+```rust
+#[cfg(feature = "embed-web")]
+mod web_assets;
+```
+In `serve`, after `let app = build_app(state);`, merge the SPA routes (feature-gated) so API routes take precedence and everything else falls through to the SPA:
+```rust
+ let app = build_app(state);
+
+ #[cfg(feature = "embed-web")]
+ let app = app.merge(web_assets::routes());
+```
+(Keep the rest of `serve` unchanged.)
+
+- [ ] **Step 4: Write the failing test** — `crates/server/tests/embed.rs`
+
+```rust
+//! Only meaningful with `--features embed-web` and a built `web/dist`. Skips itself
+//! (passes trivially) when the feature is off.
+#![cfg(feature = "embed-web")]
+
+use axum::body::Body;
+use axum::http::{Request, StatusCode};
+use http_body_util::BodyExt;
+use tower::ServiceExt;
+
+#[tokio::test]
+async fn serves_index_at_root_and_spa_fallback() {
+ let app = server::test_support::web_router();
+
+ let root = app
+ .clone()
+ .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
+ .await
+ .unwrap();
+ assert_eq!(root.status(), StatusCode::OK);
+ let body = root.into_body().collect().await.unwrap().to_bytes();
+ assert!(String::from_utf8_lossy(&body).contains(""));
+
+ // unknown client route falls back to index.html (not 404)
+ let deep = app
+ .oneshot(Request::builder().uri("/objects/123").body(Body::empty()).unwrap())
+ .await
+ .unwrap();
+ assert_eq!(deep.status(), StatusCode::OK);
+}
+```
+Expose a tiny test hook in `crates/server/src/lib.rs` (feature-gated):
+```rust
+#[cfg(feature = "embed-web")]
+pub mod test_support {
+ /// The SPA-asset router, for tests.
+ pub fn web_router() -> axum::Router {
+ super::web_assets::routes()
+ }
+}
+```
+
+- [ ] **Step 5: Build the SPA, then run the feature-gated test**
+
+```bash
+cd web && pnpm install && pnpm build && cd ..
+DATABASE_URL='postgres://postgres:postgres@localhost:5433/cms_dev' \
+ cargo test -p server --features embed-web --test embed
+```
+Expected: PASS — index served at `/`, `/objects/123` falls back to index.
+
+- [ ] **Step 6: Confirm the default (no-feature) build/test is unaffected**
+
+```bash
+DATABASE_URL='postgres://postgres:postgres@localhost:5433/cms_dev' cargo test -p server
+```
+Expected: PASS, and compiles without `web/dist` present (the module + test are `#[cfg(feature = "embed-web")]`).
+
+- [ ] **Step 7: fmt, clippy, commit**
+
+```bash
+cargo +nightly fmt
+DATABASE_URL='postgres://postgres:postgres@localhost:5433/cms_dev' \
+ cargo clippy -p server --features embed-web --all-targets -- -D warnings
+git add crates/server Cargo.toml Cargo.lock
+git commit -m "feat(server): embed SPA via memory-serve behind embed-web feature"
+```
+
+---
+
+## Task 12: Web CI job + bundle-size budget
+
+**Files:**
+- Create: `web/scripts/check-bundle-size.mjs`
+- Modify: `.gitea/workflows/.yaml` (add a `web` job; inspect the existing workflow filename first)
+
+- [ ] **Step 1: Bundle-size gate script** — `web/scripts/check-bundle-size.mjs`
+
+```js
+// Fails if the largest built JS entry chunk exceeds the gzipped budget.
+import { readdirSync, readFileSync } from "node:fs";
+import { gzipSync } from "node:zlib";
+import { join } from "node:path";
+
+const BUDGET_KB = 150;
+const dir = "dist/assets";
+const jsFiles = readdirSync(dir).filter((f) => f.endsWith(".js"));
+let largest = 0;
+let largestName = "";
+for (const file of jsFiles) {
+ const gz = gzipSync(readFileSync(join(dir, file))).length;
+ if (gz > largest) { largest = gz; largestName = file; }
+}
+const kb = (largest / 1024).toFixed(1);
+console.log(`largest JS chunk: ${largestName} = ${kb} KB gz (budget ${BUDGET_KB} KB)`);
+if (largest > BUDGET_KB * 1024) {
+ console.error(`bundle-size budget exceeded: ${kb} KB > ${BUDGET_KB} KB`);
+ process.exit(1);
+}
+```
+Add to `web/package.json` scripts: `"check:size": "node scripts/check-bundle-size.mjs"`.
+
+- [ ] **Step 2: Run it locally after a build**
+
+```bash
+cd web && pnpm build && pnpm check:size
+```
+Expected: prints the chunk size and exits 0 (under 150 KB). If over, code-split (lazy-load routes via `React.lazy`) until under budget, then re-run.
+
+- [ ] **Step 3: Add the CI job** — in the existing `.gitea/workflows` CI file, add:
+
+```yaml
+ web:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: web
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with: { version: 9 }
+ - uses: actions/setup-node@v4
+ with: { node-version: 20, cache: pnpm, cache-dependency-path: web/pnpm-lock.yaml }
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm typecheck
+ - run: pnpm lint
+ - run: pnpm test
+ - run: pnpm build
+ - run: pnpm check:size
+```
+(Match the existing workflow's runner/style; first read the current `.gitea/workflows/*.yaml` to follow its conventions. `gen:api` is NOT run in CI — the committed `schema.d.ts` is the source of truth there.)
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd ..
+git add web .gitea
+git commit -m "ci(web): typecheck/lint/test/build + bundle-size budget"
+```
+
+---
+
+## Self-Review (completed)
+
+**Spec coverage:**
+- Scaffold + build/dev/serve → Tasks 1, 2, 11. ✓
+- Typed client (openapi-typescript + openapi-fetch, 401 redirect) → Task 3. ✓
+- TanStack Query hooks (me/objects/object/field-defs/login/logout) → Task 6. ✓
+- App shell (icon/sidebar nav, disabled future items, sv/en switch, logout) → Task 8. ✓
+- Login + session guard → Tasks 6, 7. ✓
+- Two-pane Objects (paginated list + read-only detail + visibility badge) → Tasks 9, 10. ✓
+- i18n sv/en → Task 5. ✓
+- States/errors (loading/empty/error/404/invalid-creds) → Tasks 7, 9, 10. ✓
+- Testing Vitest+RTL+MSW → Tasks 4–10. ✓
+- memory-serve embed behind `embed-web`, backend tests independent of build → Task 11. ✓
+- CI + bundle budget (≤150 KB gz) → Task 12. ✓
+
+**Placeholder scan:** none — every code step shows complete code; the two "confirm against resolved version" notes (memory-serve API, generated schema names) are verification instructions, not deferred work, with concrete adapt-to-reality guidance.
+
+**Type consistency:** `api` client + `paths`/`components` from `schema.d.ts` used uniformly; hooks `useMe`/`useObjectsPage`/`useObject`/`useFieldDefinitions`/`useLogin`/`useLogout` defined in Task 6 and consumed in 7–10; `VisibilityBadge`, `renderApp`, `server` (MSW) consistent across tasks; route shape `/objects` + `/objects/:id` consistent in Tasks 9, 10.
+
+## Notes for follow-on milestones
+- Object create/edit/delete + dynamic flexible-field forms (Milestone 2) — reuse the typed client, query hooks, and shadcn form primitives.
+- Publishing workflow surfacing the required-field 422 (Milestone 3).
+- Playwright e2e smoke once a `--seed` dataset exists (relates to issue #14).
+- Detail currently renders flexible object/array values as JSON; typed rendering (term/authority label resolution) lands with the authoring/forms milestone.