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

55 KiB
Raw Permalink Blame History

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/logout204.
  • GET /api/admin/me200 {id,email,role} (UserView); no session → 401.
  • GET /api/admin/objects?limit&offset200 {items:[AdminObjectView],total,limit,offset} (ViewInternal).
  • GET /api/admin/objects/{id}200 AdminObjectView | 404.
  • GET /api/admin/field-definitions200 [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:

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
{
  "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:

/// <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):

import "@testing-library/jest-dom/vitest";
  • Step 4: Write the failing test web/src/app.test.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:

export function App() {
  return <h1>Collection</h1>;
}

Replace web/src/main.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/
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

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:

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:

"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }

Create web/src/index.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
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
pnpm add -D eslint @eslint/js typescript-eslint eslint-plugin-react-hooks eslint-plugin-react-refresh globals

Create web/eslint.config.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
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

cd web
pnpm add openapi-fetch
pnpm add -D openapi-typescript

Start the backend in another terminal (see Prerequisites), then:

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
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:

/** 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:

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
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

cd web
pnpm add -D msw
  • Step 2: Create typed fixtures web/src/test/fixtures.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
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
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
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
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
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

cd web
pnpm add i18next react-i18next
  • Step 2: Translation resourcesweb/src/i18n/en.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):

{
  "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 i18nextweb/src/i18n/index.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:

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
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
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

cd web
pnpm add @tanstack/react-query react-router-dom
  • Step 2: Query hooksweb/src/api/queries.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 helperweb/src/test/render.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
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: Implementweb/src/auth/require-auth.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
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

cd web
pnpm dlx shadcn@latest add input label card
  • Step 2: Write the failing test web/src/auth/login-page.test.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: Implementweb/src/auth/login-page.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
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

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 switchweb/src/shell/lang-switch.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 shellweb/src/shell/app-shell.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
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

cd web
pnpm dlx shadcn@latest add badge skeleton
  • Step 2: Write the failing test web/src/objects/object-list.test.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 badgeweb/src/objects/visibility-badge.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 listweb/src/objects/object-list.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
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

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 detailweb/src/objects/object-detail.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
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 pageweb/src/objects/objects-page.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
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:

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:

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
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:

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:

[dependencies]
memory-serve = { workspace = true, optional = true }

[features]
embed-web = ["dep:memory-serve"]
  • Step 2: Create the assets modulecrates/server/src/web_assets.rs
//! 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:

#[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:

    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 testcrates/server/tests/embed.rs
//! 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):

#[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
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
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
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 scriptweb/scripts/check-bundle-size.mjs

// 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
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:
  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
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.