Files
biggus-dickus/docs/superpowers/plans/2026-06-03-frontend-spa-milestone-1.md
T
2026-06-03 21:56:54 +02:00

1676 lines
55 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.
# 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 masterdetail), 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
/// <reference types="vitest/config" />
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(<App />);
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 <h1>Collection</h1>;
}
```
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(
<StrictMode>
<App />
</StrictMode>,
);
```
Set `web/index.html` `<title>` to `Collection` and keep `<div id="root"></div>` + 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<paths>({ 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 (
<div>
<span data-testid="title">{t("objects.title")}</span>
<button onClick={() => setLocale("sv")}>sv</button>
</div>
);
}
test("switches language at runtime", async () => {
render(<Probe />);
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<UserView | null> => {
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(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
```
- [ ] **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 (
<Routes>
<Route path="/login" element={<div>login page</div>} />
<Route element={<RequireAuth />}>
<Route path="/objects" element={<div>secret objects</div>} />
</Route>
</Routes>
);
}
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 <div role="status" aria-label="loading" />;
if (!user) return <Navigate to="/login" replace />;
return <Outlet />;
}
```
- [ ] **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 (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/objects" element={<div>objects landing</div>} />
</Routes>
);
}
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 (
<div className="flex min-h-screen items-center justify-center p-4">
<form onSubmit={onSubmit} className="w-full max-w-sm space-y-4">
<h1 className="text-2xl font-semibold">{t("app.name")}</h1>
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input id="email" type="email" value={email}
onChange={(e) => setEmail(e.target.value)} autoComplete="username" />
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("auth.password")}</Label>
<Input id="password" type="password" value={password}
onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
{errorKey && <p role="alert" className="text-sm text-red-600">{t(errorKey)}</p>}
<Button type="submit" className="w-full" disabled={login.isPending}>
{t("auth.signIn")}
</Button>
</form>
</div>
);
}
```
- [ ] **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 (
<Routes>
<Route element={<AppShell />}>
<Route path="/objects" element={<div>objects outlet</div>} />
</Route>
<Route path="/login" element={<div>login page</div>} />
</Routes>
);
}
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 (
<div className="flex gap-1 text-xs">
{(["sv", "en"] as const).map((lng) => (
<button key={lng} onClick={() => setLocale(lng)}
aria-pressed={base === lng}
className={base === lng ? "font-bold" : "text-neutral-400"}>
{lng.toUpperCase()}
</button>
))}
</div>
);
}
```
- [ ] **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 (
<div className="flex min-h-screen">
<aside className="w-44 shrink-0 border-r bg-neutral-50 p-3">
<div className="mb-4 font-semibold">{t("app.name")}</div>
<nav className="space-y-1 text-sm">
<NavLink to="/objects"
className={({ isActive }) =>
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`}>
{t("nav.objects")}
</NavLink>
{FUTURE.map((key) => (
<button key={key} disabled title={t("nav.soon")}
className="block w-full cursor-not-allowed rounded px-2 py-1 text-left text-neutral-400">
{t(`nav.${key}`)}
</button>
))}
</nav>
</aside>
<div className="flex flex-1 flex-col">
<header className="flex items-center gap-4 border-b px-4 py-2">
<div className="flex-1" />
<LangSwitch />
<Button variant="ghost" size="sm" onClick={onSignOut}>
{t("auth.signOut")}
</Button>
</header>
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>
</div>
);
}
```
- [ ] **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 (
<Routes>
<Route path="/objects" element={<ObjectList />} />
<Route path="/objects/:id" element={<ObjectList />} />
</Routes>
);
}
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<string, string> = {
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 (
<Badge variant="outline" className={STYLES[visibility] ?? ""}>
{t(`visibility.${visibility}`)}
</Badge>
);
}
```
- [ ] **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 (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
</div>
);
}
if (isError) return <p className="p-4 text-sm text-red-600">{t("objects.loadError")}</p>;
if (!data || data.items.length === 0) {
return <p className="p-4 text-sm text-neutral-500">{t("objects.empty")}</p>;
}
const from = data.total === 0 ? 0 : offset + 1;
const to = Math.min(offset + LIMIT, data.total);
return (
<div className="flex h-full flex-col">
<ul className="flex-1 overflow-auto">
{data.items.map((object) => (
<li key={object.id}>
<NavLink to={`/objects/${object.id}`}
className={`flex items-center justify-between gap-2 border-b px-3 py-2 text-sm ${
object.id === selectedId ? "bg-indigo-50" : "hover:bg-neutral-50"}`}>
<span className="truncate">
<span className="text-neutral-500">{object.object_number}</span>{" "}
{object.object_name}
</span>
<VisibilityBadge visibility={object.visibility} />
</NavLink>
</li>
))}
</ul>
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-neutral-500">
<span>{from}{to} {t("objects.of")} {data.total}</span>
<span className="flex gap-2">
<Button variant="ghost" size="sm" disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - LIMIT))}>
{t("objects.prev")}
</Button>
<Button variant="ghost" size="sm" disabled={to >= data.total}
onClick={() => setOffset(offset + LIMIT)}>
{t("objects.next")}
</Button>
</span>
</div>
</div>
);
}
```
- [ ] **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 (
<Routes>
<Route path="/objects/:id" element={<ObjectDetail />} />
</Routes>
);
}
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 (
<div className="border-b py-2">
<div className="text-xs uppercase tracking-wide text-neutral-400">{label}</div>
<div className="text-sm text-neutral-900">{value}</div>
</div>
);
}
export function ObjectDetail() {
const { t } = useTranslation();
const { id } = useParams();
const { data: object, isLoading, isError } = useObject(id!);
const { data: definitions } = useFieldDefinitions();
if (isLoading) return <div className="p-4"><Skeleton className="h-40 w-full" /></div>;
if (isError) return <p className="p-4 text-sm text-red-600">{t("objects.loadError")}</p>;
if (!object) return <p className="p-4 text-sm text-neutral-500">{t("objects.notFound")}</p>;
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 (
<div className="overflow-auto p-4">
<div className="mb-4 flex items-center gap-3">
<h2 className="text-xl font-semibold">{object.object_name}</h2>
<VisibilityBadge visibility={object.visibility} />
</div>
<Field label={t("fieldsLabels.objectNumber")} value={object.object_number} />
<Field label={t("fieldsLabels.count")} value={object.number_of_objects} />
<Field label={t("fieldsLabels.briefDescription")} value={object.brief_description} />
<Field label={t("fieldsLabels.currentLocation")} value={object.current_location} />
<Field label={t("fieldsLabels.currentOwner")} value={object.current_owner} />
<Field label={t("fieldsLabels.recorder")} value={object.recorder} />
<Field label={t("fieldsLabels.recordingDate")} value={object.recording_date} />
{flexible.length > 0 && (
<div className="mt-4">
<div className="mb-1 text-xs font-medium uppercase text-neutral-500">
{t("fieldsLabels.flexible")}
</div>
{flexible.map(([key, value]) => (
<Field key={key} label={labelFor(key)}
value={typeof value === "object" ? JSON.stringify(value) : String(value)} />
))}
</div>
)}
</div>
);
}
```
- [ ] **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 (
<Routes>
<Route path="/objects" element={<ObjectsPage />} />
<Route path="/objects/:id" element={<ObjectsPage />} />
</Routes>
);
}
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 (
<div className="grid h-full grid-cols-[20rem_1fr]">
<div className="overflow-hidden border-r">
<ObjectList />
</div>
<div className="overflow-hidden">
{id ? (
<ObjectDetail />
) : (
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
{t("objects.selectPrompt")}
</div>
)}
</div>
</div>
);
}
```
- [ ] **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 (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route element={<AppShell />}>
<Route path="/objects" element={<ObjectsPage />} />
<Route path="/objects/:id" element={<ObjectsPage />} />
<Route path="/" element={<Navigate to="/objects" replace />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/objects" replace />} />
</Routes>
</BrowserRouter>
);
}
```
Update `web/src/main.tsx` to wrap `<App/>` 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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);
```
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(<App />);
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("<div id=\"root\">"));
// 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/<existing CI workflow>.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 410. ✓
- 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 710; `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.