Compare commits

..

14 Commits

Author SHA1 Message Date
logaritmisk a0b7dcdc2d merge: responsive master/detail for vocabularies, search, fields (#58)
CI / web (push) Successful in 5m3s
2026-06-09 15:22:46 +02:00
logaritmisk 7f9cf9fe60 feat(web): responsive Fields page (stacks on narrow) (#58) 2026-06-09 15:18:39 +02:00
logaritmisk b83149e0bb feat(web): responsive Search master/detail (drawer on narrow) (#58) 2026-06-09 15:15:44 +02:00
logaritmisk 80c2aad298 feat(web): responsive Vocabularies master/detail (drawer on narrow) (#58) 2026-06-09 15:12:45 +02:00
logaritmisk b5756e16b5 refactor(web): shared DetailDrawer; objects-page uses it (#58) 2026-06-09 15:09:37 +02:00
logaritmisk b3f061ced7 docs(plans): responsive master/detail — 4-task plan (#58) 2026-06-09 15:06:42 +02:00
logaritmisk eec3a261b4 docs(specs): responsive master/detail for vocab/search/fields (#58) 2026-06-09 14:11:14 +02:00
logaritmisk 390f6897a8 merge: bundle vendor-split + test-gap fills (#67)
CI / web (push) Successful in 5m13s
2026-06-09 13:48:46 +02:00
logaritmisk 8b881f369b test(web): add a Storybook story for the combobox primitive (#67) 2026-06-09 12:32:06 +02:00
logaritmisk aef5000543 test(web): cover prune-fields, labels, format-date, delete-in-use dialog (#67) 2026-06-09 12:28:48 +02:00
logaritmisk 878db9a37b build(web): split framework deps into cache-stable vendor chunks (#67) 2026-06-09 12:24:47 +02:00
logaritmisk 0b44bc0855 docs(plans): bundle vendor-split + test gaps — 3-task plan (#67) 2026-06-09 12:16:23 +02:00
logaritmisk 79ee402b33 docs(specs): bundle vendor-split + test-gap fills (#67) 2026-06-09 12:09:02 +02:00
logaritmisk 64f35e5a57 merge: fix CI — Node 22, Playwright install, deterministic pending-state tests, testTimeout (#25)
CI / web (push) Successful in 7m32s
2026-06-09 11:57:32 +02:00
19 changed files with 1428 additions and 35 deletions
@@ -0,0 +1,338 @@
# Bundle Vendor-Split + Test-Gap Fills — 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:** Split framework deps into cache-stable vendor chunks, and fill the audit's unit-test (prune-fields, delete-confirm-dialog in-use, labels, format-date) + combobox-story gaps.
**Architecture:** Task 1 adds `build.rollupOptions.output.manualChunks` to `vite.config.ts` (production build only). Task 2 adds 4 jsdom unit tests for pure/critical units. Task 3 adds a Storybook story for the combobox primitive and runs the full gate. All additive/behavior-preserving.
**Tech Stack:** Vite 6 (Rollup), React 19 + TS + pnpm, Vitest 4 (jsdom + storybook-browser projects), Storybook, RTL + MSW.
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; app/test source double-quote+semicolon; **stories use single-quote + no-semicolon** (mirror existing `ui/*.stories.tsx`). Run a single test pass per task.
**Spec:** `docs/superpowers/specs/2026-06-09-bundle-and-tests-design.md`
**Key facts:**
- `scripts/check-bundle-size.mjs` fails if the **largest** `dist/assets/*.js` gzip exceeds 250 KB; today ~216.5 KB (one chunk).
- `vite.config.ts` exports `defineConfig({ plugins, resolve, server, test })` — no `build` block. Deps: `react`, `react-dom`, `react-router-dom` (v7, re-exports `react-router`), `@tanstack/react-query`, `@base-ui/react`, `i18next`, `react-i18next`.
- `objects/prune-fields.ts`: `pruneFields(fields: Record<string, unknown>, localizedTextKeys: Set<string>, defaultLang: string): Record<string, unknown>`.
- `lib/labels.ts`: `labelText(labels: LabelView[], lang: string): string` (lang match → `"en"` → first → `""`).
- `lib/format-date.ts`: `formatDate(value: unknown, lang: string): string` (non-string null→`"—"`, non-string→`String(value)`; invalid→returns value; valid `YYYY-MM-DD``Intl.DateTimeFormat(lang,{dateStyle:"medium"})`, parsed at local midnight).
- `components/delete-confirm-dialog.tsx`: `DeleteConfirmDialog({ description, onConfirm: () => Promise<void>, triggerLabel? })`. Trigger is a `Button` labelled `t("actions.delete")` ("Delete"); the confirm action is also labelled `t("actions.delete")`. On a thrown error it sets a message via `errorMessageKey(err)` and **returns without closing**; on success it closes. `actions.inUse` (en) = "Can't delete — used by {{count}} object(s). Clear those fields first." `InUseError` is exported from `../api/errors` (and re-exported by `../api/queries`).
- `components/ui/combobox.tsx` exports `ComboboxRoot<Value>`, `ComboboxInputGroup`, `ComboboxInput`, `ComboboxClear`, `ComboboxTrigger`, `ComboboxPopup`, `ComboboxList`, `ComboboxItem`, `ComboboxItemIndicator`, `ComboboxEmpty`. See `objects/options-combobox.tsx` for the composition (ComboboxRoot props: `items`, `value`, `onValueChange`, `itemToStringLabel`, `isItemEqualToValue`; ComboboxList takes a render-function child).
- Story format (from `components/ui/badge.stories.tsx`): `import type { Meta, StoryObj } from '@storybook/react-vite'`; `import { expect } from 'storybook/test'`; `const meta = { component, tags: ['ai-generated'] } satisfies Meta<typeof X>`; `export default meta`; `type Story = StoryObj<typeof meta>`; `export const Default: Story = { play: async ({ canvas }) => {...} }`.
---
# Task 1: Vendor split (`vite.config.ts`)
**Files:** Modify `web/vite.config.ts`.
- [ ] **Step 1: Add the `build` block.** In `web/vite.config.ts`, add a top-level `build` key to the `defineConfig({...})` object (sibling of `plugins`/`resolve`/`server`/`test`):
```ts
build: {
rollupOptions: {
output: {
manualChunks: {
react: ["react", "react-dom", "react-router", "react-router-dom"],
"base-ui": ["@base-ui/react"],
query: ["@tanstack/react-query"],
i18n: ["i18next", "react-i18next"],
},
},
},
},
```
- [ ] **Step 2: Build + verify chunk split + budget:**
```bash
cd web && pnpm build && pnpm check:size && ls -1 dist/assets/*.js
```
Expected: build succeeds; `check:size` prints `largest JS chunk: <name> = <kb> KB gz (budget 250 KB)` and passes; `dist/assets/` now contains separate `react-*.js`, `base-ui-*.js`, `query-*.js`, `i18n-*.js` chunks plus the app entry. Report the largest chunk size (should be a vendor chunk, well under 250). If the build warns about React being in multiple chunks or a chunk is oversized, confirm `react`+`react-dom`+`react-router`+`react-router-dom` are all in the one `react` group and adjust.
- [ ] **Step 3: typecheck + lint** (config change shouldn't affect them, but confirm):
```bash
cd web && pnpm typecheck && pnpm lint
```
- [ ] **Step 4: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/vite.config.ts
git commit -m "build(web): split framework deps into cache-stable vendor chunks (#67)"
```
---
# Task 2: Unit-test gaps (prune-fields, labels, format-date, delete-confirm-dialog)
**Files:** Create `web/src/objects/prune-fields.test.ts`, `web/src/lib/labels.test.ts`, `web/src/lib/format-date.test.ts`, `web/src/components/delete-confirm-dialog.test.tsx`.
- [ ] **Step 1: `web/src/objects/prune-fields.test.ts`:**
```ts
import { expect, test } from "vitest";
import { pruneFields } from "./prune-fields";
test("drops empty/null/undefined scalars, keeps real scalars", () => {
const out = pruneFields(
{ a: "x", b: "", c: null, d: undefined, e: 0, f: false },
new Set(),
"en",
);
expect(out).toEqual({ a: "x", e: 0, f: false });
});
test("a localized_text key keeps only the default-language entry", () => {
const out = pruneFields(
{ title: { en: "Bowl", sv: "Skål" } },
new Set(["title"]),
"sv",
);
expect(out).toEqual({ title: { sv: "Skål" } });
});
test("a non-localized object value keeps all non-empty entries", () => {
const out = pruneFields(
{ dims: { w: "10", h: "", d: "5" } },
new Set(),
"en",
);
expect(out).toEqual({ dims: { w: "10", d: "5" } });
});
test("an object value left with no entries is dropped entirely", () => {
const out = pruneFields(
{ title: { en: "Bowl" }, empty: { en: "", sv: "" } },
new Set(["title", "empty"]),
"sv",
);
// title has no `sv` entry → empty after filtering → dropped; empty → dropped
expect(out).toEqual({});
});
```
Run: `cd web && pnpm vitest run src/objects/prune-fields.test.ts`. (If a case's expectation mismatches the real semantics, re-read `prune-fields.ts` and correct the EXPECTATION to the true behavior — do not change the source.)
- [ ] **Step 2: `web/src/lib/labels.test.ts`:**
```ts
import { expect, test } from "vitest";
import { labelText } from "./labels";
const labels = [
{ lang: "en", label: "Bowl" },
{ lang: "sv", label: "Skål" },
];
test("returns the exact-language label when present", () => {
expect(labelText(labels, "sv")).toBe("Skål");
});
test("falls back to the English label when the requested language is missing", () => {
expect(labelText(labels, "de")).toBe("Bowl");
});
test("falls back to the first label when neither the language nor English is present", () => {
expect(labelText([{ lang: "fr", label: "Bol" }], "de")).toBe("Bol");
});
test("returns an empty string for no labels", () => {
expect(labelText([], "en")).toBe("");
});
```
Run: `cd web && pnpm vitest run src/lib/labels.test.ts`.
- [ ] **Step 3: `web/src/lib/format-date.test.ts`:**
```ts
import { expect, test } from "vitest";
import { formatDate } from "./format-date";
test("formats a date-only string in the locale without a timezone day-shift", () => {
// local-midnight parse must keep the calendar day (3), not shift to the 2nd
expect(formatDate("1962-04-03", "en")).toBe("Apr 3, 1962");
});
test("returns the em-dash placeholder for null", () => {
expect(formatDate(null, "en")).toBe("—");
});
test("returns an unparseable string unchanged", () => {
expect(formatDate("not-a-date", "en")).toBe("not-a-date");
});
test("stringifies a non-string, non-null value", () => {
expect(formatDate(42, "en")).toBe("42");
});
```
Run: `cd web && pnpm vitest run src/lib/format-date.test.ts`. (The CI/runtime is full-ICU Node 22, so en `dateStyle:"medium"``"Apr 3, 1962"`. If your local ICU formats slightly differently, assert the equivalent string but keep the day as `3` — the day-shift guard is the point.)
- [ ] **Step 4: `web/src/components/delete-confirm-dialog.test.tsx`:**
```tsx
import { expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { DeleteConfirmDialog } from "./delete-confirm-dialog";
import { InUseError } from "../api/errors";
test("delete-in-use shows the in-use count and keeps the dialog open", async () => {
const onConfirm = vi.fn<() => Promise<void>>().mockRejectedValue(new InUseError(3));
renderApp(<DeleteConfirmDialog description="Delete this term?" onConfirm={onConfirm} />);
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
const dialog = within(document.body);
const buttons = await dialog.findAllByRole("button", { name: /delete/i });
await userEvent.click(buttons[buttons.length - 1]); // the confirm action
expect(await dialog.findByText(/used by 3/i)).toBeInTheDocument();
// dialog stays open: its description is still shown
expect(dialog.getByText("Delete this term?")).toBeInTheDocument();
});
test("a clean confirm closes the dialog", async () => {
const onConfirm = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
renderApp(<DeleteConfirmDialog description="Delete this term?" onConfirm={onConfirm} />);
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
const dialog = within(document.body);
const buttons = await dialog.findAllByRole("button", { name: /delete/i });
await userEvent.click(buttons[buttons.length - 1]);
await waitFor(() => expect(dialog.queryByText("Delete this term?")).toBeNull());
expect(onConfirm).toHaveBeenCalledTimes(1);
});
```
Run: `cd web && pnpm vitest run src/components/delete-confirm-dialog.test.tsx`. (Before opening, only the trigger "Delete" button exists. After opening, the portal adds the action "Delete"; `findAllByRole` returns both, click the last. `actions.inUse` en contains "used by 3". If `vi.fn<() => Promise<void>>()` generic syntax trips the TS/vitest version, use `vi.fn(() => Promise.reject(new InUseError(3)))` / `vi.fn(() => Promise.resolve())`.)
- [ ] **Step 5: Verify all four (vitest ONCE) + typecheck + lint:**
```bash
cd web && pnpm vitest run src/objects/prune-fields.test.ts src/lib/labels.test.ts src/lib/format-date.test.ts src/components/delete-confirm-dialog.test.tsx && pnpm typecheck && pnpm lint
```
All green.
- [ ] **Step 6: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/objects/prune-fields.test.ts web/src/lib/labels.test.ts web/src/lib/format-date.test.ts web/src/components/delete-confirm-dialog.test.tsx
git commit -m "test(web): cover prune-fields, labels, format-date, delete-in-use dialog (#67)"
```
---
# Task 3: Combobox story + full gate
**Files:** Create `web/src/components/ui/combobox.stories.tsx`.
- [ ] **Step 1: Read `web/src/objects/options-combobox.tsx`** to mirror the exact `ComboboxRoot` composition (generic, props, the `ComboboxList` render-function child), then create `web/src/components/ui/combobox.stories.tsx` (single-quote + no-semicolon, matching `badge.stories.tsx`):
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { expect } from 'storybook/test'
import {
ComboboxRoot,
ComboboxInputGroup,
ComboboxInput,
ComboboxClear,
ComboboxTrigger,
ComboboxPopup,
ComboboxList,
ComboboxItem,
ComboboxItemIndicator,
ComboboxEmpty,
} from './combobox'
const fruits = ['Apple', 'Apricot', 'Banana', 'Cherry']
function ComboboxDemo() {
const [value, setValue] = useState<string | null>(null)
return (
<ComboboxRoot<string | null>
items={fruits}
value={value}
onValueChange={setValue}
itemToStringLabel={(item) => item ?? ''}
isItemEqualToValue={(a, b) => a === b}
>
<ComboboxInputGroup>
<ComboboxInput placeholder='Pick a fruit' />
<ComboboxClear aria-label='Clear' />
<ComboboxTrigger aria-label='Open' />
</ComboboxInputGroup>
<ComboboxPopup>
<ComboboxEmpty>No matches</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
<ComboboxItemIndicator className='text-primary'></ComboboxItemIndicator>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxPopup>
</ComboboxRoot>
)
}
const meta = {
component: ComboboxDemo,
tags: ['ai-generated'],
} satisfies Meta<typeof ComboboxDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
play: async ({ canvas }) => {
await expect(canvas.getByPlaceholderText('Pick a fruit')).toBeInTheDocument()
},
}
```
(Adjust the `ComboboxRoot` generic/props to match `options-combobox.tsx` exactly if they differ — the goal is a rendering `Default` story; keep the `play` minimal to stay stable in the browser project.)
- [ ] **Step 2: Run the storybook project for this story** (browser test):
```bash
cd web && pnpm vitest run src/components/ui/combobox.stories.tsx
```
Expected: the `Default` story passes (input renders). If the Base UI combobox API needs a different prop shape, fix the story (mirror `options-combobox.tsx`); do not weaken the assertion below a render check.
- [ ] **Step 3: FULL FRONTEND GATE (run tests EXACTLY ONCE):**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
All green. Report total test count (should be prior 268 + 4 unit + 1 story ≈ 273), the largest chunk (gz) from check:size (a vendor chunk, < 250), and the check:colors line.
- [ ] **Step 4: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```
Expected: no matches (`codename-exit=1`).
- [ ] **Step 5: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/components/ui/combobox.stories.tsx
git commit -m "test(web): add a Storybook story for the combobox primitive (#67)"
```
---
## Self-Review (completed)
**Spec coverage:** AC1 vendor split + verify (T1); AC2 the 4 unit tests (T2 S1-S4); AC3 combobox story (T3 S1-S2); AC4 full gate + codename (T3 S3-S4). ✓
**Placeholder scan:** every test/story is complete code with concrete assertions; manualChunks is exact; the format-date ICU note and the vi.fn generic note give concrete fallbacks, not vague TODOs. ✓
**Type/consistency:** `pruneFields(fields, Set, lang)`, `labelText(labels, lang)`, `formatDate(value, lang)`, `DeleteConfirmDialog({description,onConfirm})`, `InUseError(count)` from `../api/errors` — all match the spec's documented signatures; the combobox story imports the exact exports from `./combobox`. ✓
## Notes
- No new dependency, no new i18n keys. `build.rollupOptions` only affects `pnpm build`; the test projects are unaffected.
- The combobox story is the one item with a (small) CI cost (a browser test on the slow runner); it's isolated in its own task/commit so it can be dropped cleanly if undesired.
- `check:size` should report a smaller largest chunk after the split (a vendor chunk, not the combined app+vendor chunk).
@@ -0,0 +1,425 @@
# Responsive Master/Detail (vocab/search/fields) — 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:** Bring the Vocabularies, Search, and Fields master/detail screens to the responsive behavior the Objects screen already has — preserving wide side-by-side, collapsing to single-column + a slide-in Drawer (vocab/search) or a stack (fields) on narrow — via one shared `DetailDrawer`.
**Architecture:** Generalize the objects-specific drawer into a reusable `components/detail-drawer.tsx` and retrofit Objects onto it (Task 1). Make vocabularies + search responsive with `useMediaQuery("(min-width: 1024px)")` + `useMatch` + the shared drawer (Tasks 2-3). Make fields a pure-CSS responsive stack (Task 4) + full gate. Behavior-preserving on wide; only narrow changes.
**Tech Stack:** React 19 + TS + pnpm, React Router 7, Base UI Drawer, Tailwind v4, Vitest 4 (jsdom) + RTL + MSW.
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; double-quote+semicolon; token classes only. Breakpoint **1024px** (`useMediaQuery("(min-width: 1024px)")` / Tailwind `lg:`), matching Objects. Run a single test pass per task.
**Spec:** `docs/superpowers/specs/2026-06-09-responsive-master-detail-design.md`
**Key facts:**
- `lib/use-media-query.ts`: `useMediaQuery(query): boolean` (SSR-safe matchMedia).
- `objects/object-detail-drawer.tsx` (to be generalized + deleted) is: `<Drawer open onOpenChange={(n)=>{if(!n)onClose()}} swipeDirection="right"><DrawerContent aria-label={t("objects.detailTitle")}><div className="flex justify-end border-b p-2"><DrawerClose aria-label={t("actions.closeDetail")} render={<Button variant="ghost" size="icon-sm" />}><X className="size-4" aria-hidden="true" /></DrawerClose></div><div className="flex-1 overflow-auto"><Outlet/></div></DrawerContent></Drawer>`. `Drawer`/`DrawerClose`/`DrawerContent` from `@/components/ui/drawer`.
- `objects/objects-page.tsx` currently `lazy()`-loads `ObjectDetailDrawer` + wraps in `<Suspense fallback={null}>`; narrow branch renders `{open && <Suspense><ObjectDetailDrawer open={open} onClose={closeDetail} /></Suspense>}`. The WIDE pane has its own close `<Button … aria-label={t("actions.closeDetail")}><X/></Button>` — keep it (`X` import stays).
- Existing i18n (no new keys): `objects.detailTitle` ("Object detail"), `vocab.terms` ("Terms"), `actions.closeDetail` ("Close detail").
- `vocabularies-page.tsx`: `<div flex h-full flex-col><PageTitle>…</PageTitle><div grid grid-cols-[20rem_1fr]><div border-r><VocabularyList/></div><div><Outlet/></div></div></div>`. Routes: `/vocabularies` (index `SelectVocabularyPrompt`) + `/vocabularies/:id` (`VocabularyTerms`).
- `search-page.tsx`: same shape, `grid-cols-[24rem_1fr]`, `<SearchPanel/>`, routes `/search` (index `SelectSearchPrompt`) + `/search/:id` (`ObjectDetail`).
- `fields-page.tsx`: `useState`-driven; `<div grid grid-cols-[20rem_1fr]><div border-r><FieldList selectedKey onSelect/></div><div><FieldForm key editing onDone/></div></div>`. No routes.
- Test harness: `objects-page.test.tsx` has a `setViewport(wide: boolean)` helper that overrides `window.matchMedia` to match `(min-width: 1024px)` only when `wide`; default test setup is narrow (`matches:false`); `afterEach(() => vi.restoreAllMocks())`. Narrow-drawer assertion pattern: deep-link to `:id`, then `within(document.body).findByRole(...)` for the portaled drawer + assert the `/close detail/i` button. No `vocabularies-page`/`search-page`/`fields-page` test files exist yet.
---
# Task 1: Shared `DetailDrawer` + retrofit Objects
**Files:** Create `web/src/components/detail-drawer.tsx`, `web/src/components/detail-drawer.test.tsx`; Modify `web/src/objects/objects-page.tsx`; Delete `web/src/objects/object-detail-drawer.tsx`.
- [ ] **Step 1: Create `web/src/components/detail-drawer.tsx`:**
```tsx
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
/** A right-sliding Base UI Drawer for a master/detail "detail" on narrow viewports.
* Provides the close affordance + an accessible dialog name; the caller supplies the content. */
export function DetailDrawer({
open,
onClose,
ariaLabel,
children,
}: {
open: boolean;
onClose: () => void;
ariaLabel: string;
children: ReactNode;
}) {
const { t } = useTranslation();
return (
<Drawer
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
swipeDirection="right"
>
<DrawerContent aria-label={ariaLabel}>
<div className="flex justify-end border-b p-2">
<DrawerClose
aria-label={t("actions.closeDetail")}
render={<Button variant="ghost" size="icon-sm" />}
>
<X className="size-4" aria-hidden="true" />
</DrawerClose>
</div>
<div className="flex-1 overflow-auto">{children}</div>
</DrawerContent>
</Drawer>
);
}
```
- [ ] **Step 2: Create `web/src/components/detail-drawer.test.tsx`** (write + run):
```tsx
import { expect, test, vi } from "vitest";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { DetailDrawer } from "./detail-drawer";
test("renders children in a named drawer and closes via the close button", async () => {
const onClose = vi.fn();
renderApp(
<DetailDrawer open onClose={onClose} ariaLabel="Object detail">
<p>detail body</p>
</DetailDrawer>,
);
const body = within(document.body);
expect(await body.findByText("detail body")).toBeInTheDocument();
await userEvent.click(body.getByRole("button", { name: /close detail/i }));
expect(onClose).toHaveBeenCalled();
});
```
Run: `cd web && pnpm vitest run src/components/detail-drawer.test.tsx`. (If Base UI requires `open` to mount the portal, it's set; the content is portaled to `document.body`.)
- [ ] **Step 3: Retrofit `web/src/objects/objects-page.tsx`.** Remove `import { lazy, Suspense } from "react";` and the `const ObjectDetailDrawer = lazy(...)` block; add `import { DetailDrawer } from "../components/detail-drawer";`. Replace the narrow `return` block's drawer with:
```tsx
return (
<div className="h-full">
{table}
{open && (
<DetailDrawer open={open} onClose={closeDetail} ariaLabel={t("objects.detailTitle")}>
<Outlet />
</DetailDrawer>
)}
</div>
);
```
(Keep everything else — the wide grid branch with its own close `<Button><X/></Button>` is unchanged, so the `X` and `Button` imports stay.)
- [ ] **Step 4: Delete the old drawer:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git rm web/src/objects/object-detail-drawer.tsx
```
(Confirm no other importer: `git grep -n object-detail-drawer web/src` → only objects-page, now changed.)
- [ ] **Step 5: Verify (vitest ONCE) + typecheck + lint:**
```bash
cd web && pnpm vitest run src/components/detail-drawer.test.tsx src/objects/objects-page.test.tsx && pnpm typecheck && pnpm lint
```
Expected: green. The objects-page narrow + wide tests must pass unchanged (the shared `DetailDrawer` renders the same drawer + `/close detail/i` button).
- [ ] **Step 6: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/components/detail-drawer.tsx web/src/components/detail-drawer.test.tsx web/src/objects/objects-page.tsx
git rm -q web/src/objects/object-detail-drawer.tsx 2>/dev/null; git add -A web/src/objects
git commit -m "refactor(web): shared DetailDrawer; objects-page uses it (#58)"
```
---
# Task 2: Responsive Vocabularies page
**Files:** Modify `web/src/vocab/vocabularies-page.tsx`; Create `web/src/vocab/vocabularies-page.test.tsx`.
- [ ] **Step 1: Rewrite `web/src/vocab/vocabularies-page.tsx`:**
```tsx
import { Outlet, useMatch, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { VocabularyList } from "./vocabulary-list";
import { DetailDrawer } from "../components/detail-drawer";
import { useMediaQuery } from "../lib/use-media-query";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
export function VocabulariesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const detailMatch = useMatch("/vocabularies/:id");
const open = Boolean(detailMatch);
const isWide = useMediaQuery("(min-width: 1024px)");
useDocumentTitle(t("nav.vocabularies"));
useBreadcrumb([{ label: t("nav.vocabularies") }]);
const close = () => navigate("/vocabularies");
return (
<div className="flex h-full flex-col">
<PageTitle className="px-4 pt-4 pb-2">{t("nav.vocabularies")}</PageTitle>
{isWide ? (
<div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden">
<div className="overflow-hidden border-r">
<VocabularyList />
</div>
<div className="overflow-hidden">
<Outlet />
</div>
</div>
) : (
<div className="flex-1 overflow-hidden">
<VocabularyList />
</div>
)}
{!isWide && open && (
<DetailDrawer open={open} onClose={close} ariaLabel={t("vocab.terms")}>
<Outlet />
</DetailDrawer>
)}
</div>
);
}
```
(The `<Outlet/>` is rendered in exactly one place: the wide grid, OR the narrow drawer when `open`. On narrow with no `:id`, neither renders the Outlet — just the list.)
- [ ] **Step 2: Create `web/src/vocab/vocabularies-page.test.tsx`.** Read `web/src/test/fixtures.ts` + `web/src/test/handlers.ts` for the vocabularies list + terms handlers and a real vocabulary id. Mirror the `objects-page.test.tsx` `setViewport` harness:
```tsx
import { afterEach, expect, test, vi } from "vitest";
import { screen, within } from "@testing-library/react";
import { Route, Routes } from "react-router-dom";
import { renderApp } from "../test/render";
import { VocabulariesPage } from "./vocabularies-page";
import { VocabularyTerms } from "./vocabulary-terms";
import { SelectVocabularyPrompt } from "./select-vocabulary-prompt";
function setViewport(wide: boolean) {
Object.defineProperty(window, "matchMedia", {
value: (query: string): MediaQueryList =>
({
matches: wide && query === "(min-width: 1024px)",
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList,
writable: true,
});
}
afterEach(() => vi.restoreAllMocks());
function tree() {
return (
<Routes>
<Route path="/vocabularies" element={<VocabulariesPage />}>
<Route index element={<SelectVocabularyPrompt />} />
<Route path=":id" element={<VocabularyTerms />} />
</Route>
</Routes>
);
}
test("narrow: a selected vocabulary's detail renders in a portaled drawer", async () => {
setViewport(false);
renderApp(tree(), { route: `/vocabularies/<VOCAB_ID>` });
const body = within(document.body);
expect(
await body.findByRole("button", { name: /close detail/i }, { timeout: 5000 }),
).toBeInTheDocument();
});
test("wide: a selected vocabulary renders inline (no detail drawer)", async () => {
setViewport(true);
renderApp(tree(), { route: `/vocabularies/<VOCAB_ID>` });
// the list (master) is present and there is NO close-detail button (inline pane, not a drawer)
expect(await screen.findByRole("button", { name: /close detail/i }).catch(() => null)).toBeNull();
});
```
Replace `<VOCAB_ID>` with a real id from `fixtures.ts`. The narrow test asserts the drawer is present (the `/close detail/i` button only exists inside `DetailDrawer`). For the wide test, prefer a positive assertion that the master + inline detail both render (e.g. `await screen.findByText(<a stable vocab list item or the vocab.terms caption>)`) AND `screen.queryByRole("button", { name: /close detail/i })` is null. Adjust the queries to the fixtures' actual rendered text; the load-bearing checks are: **narrow → close-detail button present (drawer); wide → close-detail button absent (inline)**. Reuse the default MSW handlers (don't add new ones unless a handler is missing).
- [ ] **Step 3: Verify (vitest ONCE) + typecheck + lint:**
```bash
cd web && pnpm vitest run src/vocab/vocabularies-page.test.tsx src/vocab && pnpm typecheck && pnpm lint
```
Green. (Existing vocab tests stay green.)
- [ ] **Step 4: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/vocab/vocabularies-page.tsx web/src/vocab/vocabularies-page.test.tsx
git commit -m "feat(web): responsive Vocabularies master/detail (drawer on narrow) (#58)"
```
---
# Task 3: Responsive Search page
**Files:** Modify `web/src/search/search-page.tsx`; Create `web/src/search/search-page.test.tsx`.
- [ ] **Step 1: Rewrite `web/src/search/search-page.tsx`** (same pattern as vocab; `24rem` master, `SearchPanel`, route `"/search/:id"`, close `"/search"`, drawer ariaLabel `t("objects.detailTitle")` since the search detail is an object):
```tsx
import { Outlet, useMatch, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SearchPanel } from "./search-panel";
import { DetailDrawer } from "../components/detail-drawer";
import { useMediaQuery } from "../lib/use-media-query";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
export function SearchPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const detailMatch = useMatch("/search/:id");
const open = Boolean(detailMatch);
const isWide = useMediaQuery("(min-width: 1024px)");
useDocumentTitle(t("nav.search"));
useBreadcrumb([{ label: t("nav.search") }]);
const close = () => navigate("/search");
return (
<div className="flex h-full flex-col">
<PageTitle className="px-4 pt-4 pb-2">{t("nav.search")}</PageTitle>
{isWide ? (
<div className="grid flex-1 grid-cols-[24rem_1fr] overflow-hidden">
<div className="overflow-hidden border-r">
<SearchPanel />
</div>
<div className="overflow-hidden">
<Outlet />
</div>
</div>
) : (
<div className="flex-1 overflow-hidden">
<SearchPanel />
</div>
)}
{!isWide && open && (
<DetailDrawer open={open} onClose={close} ariaLabel={t("objects.detailTitle")}>
<Outlet />
</DetailDrawer>
)}
</div>
);
}
```
- [ ] **Step 2: Create `web/src/search/search-page.test.tsx`** mirroring the vocab test (the `setViewport` helper, the same narrow→close-detail-present / wide→absent discriminator). Tree: `<Route path="/search" element={<SearchPage/>}><Route index element={<SelectSearchPrompt/>}/><Route path=":id" element={<ObjectDetail/>}/></Route>`. Deep-link `/search/<OBJECT_ID>` (use a real object id from `fixtures.ts`; the search detail loads the object via the same `/api/admin/objects/{id}` handler the objects tests use). Narrow → `within(document.body).findByRole("button", { name: /close detail/i })` present; wide → absent + the object detail renders inline. Reuse the default MSW handlers.
- [ ] **Step 3: Verify (vitest ONCE) + typecheck + lint:**
```bash
cd web && pnpm vitest run src/search/search-page.test.tsx src/search && pnpm typecheck && pnpm lint
```
Green.
- [ ] **Step 4: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/search/search-page.tsx web/src/search/search-page.test.tsx
git commit -m "feat(web): responsive Search master/detail (drawer on narrow) (#58)"
```
---
# Task 4: Responsive Fields page (CSS stack) + full gate
**Files:** Modify `web/src/fields/fields-page.tsx`; Create `web/src/fields/fields-page.test.tsx`.
- [ ] **Step 1: Make `fields-page.tsx` a responsive stack.** Change the grid container + the list pane's border so it stacks on narrow and is side-by-side on `lg`:
```tsx
<div className="grid flex-1 grid-cols-1 overflow-auto lg:grid-cols-[20rem_1fr] lg:overflow-hidden">
<div className="overflow-hidden border-b lg:border-r lg:border-b-0">
<FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} />
</div>
<div className="overflow-hidden">
<FieldForm
key={selected?.key ?? "create"}
editing={selected}
onDone={() => setSelected(null)}
/>
</div>
</div>
```
(On narrow: single column — list then form, the grid container scrolls (`overflow-auto`), the list gets a bottom divider. On `lg`: the two-column grid with the list's right border, clipped overflow as before. If the stacked panes still clip awkwardly in a manual smoke, adjust the narrow pane `overflow` — keep `lg:` behavior identical to today.)
- [ ] **Step 2: Create `web/src/fields/fields-page.test.tsx`:**
```tsx
import { expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { renderApp } from "../test/render";
import { FieldsPage } from "./fields-page";
test("renders the field list and the field form, in a responsive grid", async () => {
const { container } = renderApp(<FieldsPage />);
// both panes present (master + detail)
expect(await screen.findByText(/fields/i)).toBeInTheDocument();
// the responsive grid: single-column by default, two-column at lg
const grid = container.querySelector("div.grid");
expect(grid?.className).toContain("grid-cols-1");
expect(grid?.className).toContain("lg:grid-cols-[20rem_1fr]");
});
```
Run: `cd web && pnpm vitest run src/fields/fields-page.test.tsx`. (Adjust the `findByText` to a stable rendered string — the `fields.title` PageTitle, the field-list, or the field-form's "Key" label. jsdom can't measure layout, so the class assertion is the responsive guard.)
- [ ] **Step 3: FULL FRONTEND GATE (run tests EXACTLY ONCE):**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
All green. Report total test count, largest chunk (gz), the check:colors line. (`check:size` should be unchanged-or-smaller — the objects drawer's separate lazy chunk folds into `base-ui`.)
- [ ] **Step 4: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```
Expected: no matches (`codename-exit=1`).
- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`, narrow the window (<1024px): the sidebar is an icon rail; Vocabularies/Search show the list/panel full-width and selecting an item slides in the detail Drawer (close returns to the index); Fields stacks the list above the form (both scrollable). Widen (≥1024): all three return to side-by-side; Objects unchanged.
- [ ] **Step 6: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/fields/fields-page.tsx web/src/fields/fields-page.test.tsx
git commit -m "feat(web): responsive Fields page (stacks on narrow) (#58)"
```
---
## Self-Review (completed)
**Spec coverage:** AC1 `DetailDrawer` + objects retrofit + delete old (T1); AC2 vocab + search responsive drawer (T2-T3); AC3 fields responsive grid (T4 S1); AC4 new tests for drawer/vocab/search/fields + existing green (T1-T4 tests); AC5 gate/codename/no-new-keys (T4 S3-S4). ✓
**Placeholder scan:** full code for `DetailDrawer` + all three pages; tests give the exact `setViewport` harness + the narrow/wide discriminator; the `<VOCAB_ID>`/`<OBJECT_ID>` and `findByText` adjustments are explicit "read fixtures" instructions with a stated load-bearing assertion, not vague TODOs. ✓
**Type/consistency:** `DetailDrawer({ open, onClose, ariaLabel, children })` (T1) is consumed with those exact props in objects/vocab/search (T1-T3); `useMediaQuery("(min-width: 1024px)")` + `useMatch("/<x>/:id")` + `navigate("/<x>")` consistent across vocab/search; ariaLabels use existing keys (`objects.detailTitle`, `vocab.terms`). ✓
## Notes
- No new dependency; no new i18n keys (`objects.detailTitle`, `vocab.terms`, `actions.closeDetail` all exist). `components/ui/*` untouched (drawer/button wrappers unchanged; only a new app-level `components/detail-drawer.tsx`).
- The `<Outlet/>` per page is rendered in exactly one place per `isWide` branch — no double-mount.
- Fields stays `useState`-driven + stacked (no routing change, no "New field" trigger needed); the resizable splitter is deferred.
- Breakpoint 1024px is consistent with the existing Objects screen.
@@ -0,0 +1,111 @@
# Bundle Vendor-Split + Test-Gap Fills — Design
**Date:** 2026-06-09
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #67 (vendor-split the bundle for cache stability/headroom; fill the audit's unit-test + story gaps).
## Context
The production build emits a single ~216 KB-gz entry chunk (the largest, measured by
`scripts/check-bundle-size.mjs`, budget 250 KB) that bundles the framework deps (react-dom, react-router 7,
@tanstack/react-query, @base-ui/react, i18next) together with app code — so every app edit invalidates the
whole chunk's cache. `vite.config.ts` has no `build.rollupOptions`. Separately, four well-isolated units
have no direct tests, and the composed combobox primitive has no story.
All changes are additive/low-risk; `check:size`/`check:colors`/the existing tests are the guards.
## Components
### 1. Vendor split — `vite.config.ts`
Add a top-level `build` block (sibling of `plugins`/`resolve`/`server`/`test`) with `manualChunks`:
```ts
build: {
rollupOptions: {
output: {
manualChunks: {
react: ["react", "react-dom", "react-router", "react-router-dom"],
"base-ui": ["@base-ui/react"],
query: ["@tanstack/react-query"],
i18n: ["i18next", "react-i18next"],
},
},
},
},
```
- The app entry chunk then carries only app code; the listed deps land in cache-stable vendor chunks
(`react-*.js`, `base-ui-*.js`, `query-*.js`, `i18n-*.js`). `react-router` **and** `react-router-dom` are
both listed so router code (v7 re-exports `react-router` internally) stays in one chunk and React isn't
duplicated.
- Only affects `pnpm build` (production); the Storybook/vitest browser builder is independent, so the
test suite is unaffected.
- `check:size` measures the largest emitted chunk; after the split the largest is a vendor chunk well under
the 250 KB budget (verified in implementation by running `pnpm build` + `pnpm check:size` and reporting
the chunk sizes). If a chunk is unexpectedly oversized or React duplicates, adjust the groups.
### 2. Test-gap fills (4 unit tests)
**`objects/prune-fields.test.ts`** (new) — `pruneFields(fields, localizedTextKeys: Set<string>, defaultLang)`:
- scalars pass through; `undefined`/`null`/`""` top-level values are dropped.
- a non-array object value whose key is in `localizedTextKeys` keeps **only** the `defaultLang` entry;
other-language entries are dropped.
- a non-array object value whose key is **not** in `localizedTextKeys` keeps all (non-empty) language
entries.
- empty inner entries (`""`/`null`/`undefined`) are filtered, and a map left with zero entries is dropped
entirely.
**`components/delete-confirm-dialog.test.tsx`** (new) — the delete-in-use flow:
- render `<DeleteConfirmDialog description=… onConfirm=… />`, open it (click the trigger), click the
confirm action.
- when `onConfirm` rejects with `InUseError(3)`, the dialog shows the `actions.inUse` message (containing
the count `3`) and **stays open** (the confirm action is still present / the dialog isn't dismissed).
- when `onConfirm` resolves, the dialog closes.
(`DeleteConfirmDialog` routes its catch through `errorMessageKey`, which maps `InUseError`
`actions.inUse` with the count — so this also exercises the shared error mapping.)
**`lib/labels.test.ts`** (new) — `labelText(labels, lang)`:
- exact `lang` match wins; else falls back to the `"en"` label; else the first label; else `""` for an
empty array.
**`lib/format-date.test.ts`** (new) — `formatDate(value, lang)`:
- a valid `"YYYY-MM-DD"` formats via `Intl.DateTimeFormat(lang, { dateStyle: "medium" })` and is **not**
shifted off its calendar day (assert the rendered string contains the expected day number for a fixed
date + a fixed `lang` like `"en"`).
- `null``"—"`; a non-date string (e.g. `"not-a-date"`) is returned unchanged; a non-string non-null
(e.g. a number) is `String()`-ified.
### 3. Combobox story — `components/ui/combobox.stories.tsx` (new)
A visual/interactive story for the composed combobox primitive, mirroring the existing `ui/*` story format
(`@storybook/react-vite` `Meta`/`StoryObj`, single-quote + no-semicolon, `tags: ['ai-generated']`, a `play`
using `canvas` + `storybook/test`). A small controlled wrapper renders `ComboboxRoot` with a handful of
string options (`ComboboxInput`/`ComboboxClear`/`ComboboxTrigger`/`ComboboxPopup`/`ComboboxList`/
`ComboboxItem`/`ComboboxEmpty`), matching how `OptionsCombobox` composes them. A `Default` story; the
`play` asserts the input renders and (optionally) typing filters the list. (Runs as a browser test in the
Storybook vitest project — a few seconds in CI.)
## Error handling / edges
- `manualChunks`: Rollup orders chunks by the import graph, so vendor chunks load before the app chunk that
imports them — no load-order/duplicate-React risk when react+react-dom+router share one named chunk.
- `formatDate` parses `\`${value}T00:00:00\`` (local midnight) to avoid a UTC day-shift; the test pins a
fixed `lang` to keep `Intl` output deterministic (assert a substring like the day number, not the full
locale string, to stay robust across ICU versions).
- `delete-confirm-dialog` test drives the Base UI AlertDialog in its portal (`within(document.body)` for the
confirm action), mirroring existing portal-aware tests.
## Testing
- The 4 unit tests run in the jsdom project; the story adds one Storybook browser test.
- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; `check:size` reports the
new largest (vendor) chunk under budget; no new dependency; no new i18n keys; no codename. en/sv parity
unaffected.
## Acceptance criteria
1. `vite.config.ts` has `build.rollupOptions.output.manualChunks` splitting react/base-ui/query/i18n into
their own chunks; `pnpm build` succeeds and `check:size` passes (largest chunk reported, < 250 KB gz, no
React duplication).
2. New tests exist and pass: `prune-fields.test.ts`, `delete-confirm-dialog.test.tsx` (InUseError →
`actions.inUse`, stays open), `labels.test.ts`, `format-date.test.ts`.
3. `components/ui/combobox.stories.tsx` exists with a working `Default` story (browser-test green).
4. Full gate green; no new dependency; no new i18n keys; no codename; existing tests unchanged.
## Out of scope → follow-ups
- The buildkit/Dockerfile CI migration (the robust fix for the slow native runner; overlaps #25).
- Deeper treeshaking/bundle analysis; route-level code-split changes beyond the existing `lazy()` boundaries.
@@ -0,0 +1,137 @@
# Responsive Master/Detail for Vocabularies, Search, Fields — Design
**Date:** 2026-06-09
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #58 (master/detail + sidebar layout has no responsive/small-screen handling — remaining half).
## Context
#58 is partially done (commit `0a88a86`, #44): the sidebar collapses to an icon rail, the **Objects** master/detail
is responsive (wide right-pane / narrow Base UI `Drawer`), and a reusable `lib/use-media-query.ts` exists. The
**remaining** master/detail screens still use fixed `grid-cols-[20rem_1fr]` / `[24rem_1fr]` with no breakpoints —
`vocabularies-page.tsx`, `search-page.tsx`, `fields-page.tsx` — so on a small laptop / tablet / split window the
fixed list + sidebar leave a cramped detail pane, and below ~640px the panes can't coexist.
**Decision (brainstorming): keep the wide side-by-side layout (it's useful for curators); fix only narrow.** Reuse
one shared drawer + the existing `useMediaQuery` hook. Breakpoint **1024px (`lg`)**, matching Objects. Authorities
is single-pane → no change. The "resizable splitter" the issue *suggests considering* is out of scope.
The three pages differ: vocabularies + search are **route-driven** (`<Outlet/>`, an index-prompt route + `:id`);
fields is **`useState`-driven** (FieldList → FieldForm, with the form always present for "create").
## Components
### 1. `components/detail-drawer.tsx` (new) — generalize the Objects drawer
Today `objects/object-detail-drawer.tsx` is objects-specific (Base UI `Drawer` + close button + a hardcoded
`<Outlet/>`). Generalize to a reusable component taking `children` + `ariaLabel`:
```tsx
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
/** A right-sliding Base UI Drawer for a master/detail "detail" on narrow viewports. */
export function DetailDrawer({
open,
onClose,
ariaLabel,
children,
}: {
open: boolean;
onClose: () => void;
ariaLabel: string;
children: ReactNode;
}) {
const { t } = useTranslation();
return (
<Drawer open={open} onOpenChange={(next) => { if (!next) onClose(); }} swipeDirection="right">
<DrawerContent aria-label={ariaLabel}>
<div className="flex justify-end border-b p-2">
<DrawerClose aria-label={t("actions.closeDetail")} render={<Button variant="ghost" size="icon-sm" />}>
<X className="size-4" aria-hidden="true" />
</DrawerClose>
</div>
<div className="flex-1 overflow-auto">{children}</div>
</DrawerContent>
</Drawer>
);
}
```
- Delete `objects/object-detail-drawer.tsx`.
- **Drop the `lazy()`/`Suspense`** that objects-page used to wrap the drawer: it kept Base UI's drawer code out of
the entry chunk, but #67 already split `@base-ui/react` into its own `base-ui` vendor chunk (loaded app-wide via
the menu/select/etc.), so the lazy boundary no longer saves anything. `DetailDrawer` is a normal import.
- The close-button label keeps the existing generic `actions.closeDetail` key.
### 2. `vocab/vocabularies-page.tsx` + `search/search-page.tsx` — `useMediaQuery` + route drawer
Mirror the Objects pattern. Add `useMediaQuery("(min-width: 1024px)")` (`isWide`) and a `useMatch` for the detail
route (`"/vocabularies/:id"` / `"/search/:id"`), `open = Boolean(match)`, `close = () => navigate("/vocabularies")`
(resp. `"/search"`).
- **Wide:** the current side-by-side grid with `<Outlet/>` inline (prompt when no `:id`, detail when selected) —
unchanged.
- **Narrow:** the master full-width (`VocabularyList` / `SearchPanel` under the existing `PageTitle`), plus
`{open && <DetailDrawer open={open} onClose={close} ariaLabel={…}><Outlet/></DetailDrawer>}`. With no `:id`, just
the master (the index-prompt is a wide-only affordance).
- Drawer `ariaLabel`: vocabularies → `t("vocab.terms")` ("Terms"); search → `t("objects.detailTitle")` ("Object
detail", since a search result's detail IS an object). **No new i18n keys.**
- The `<Outlet/>` is rendered in exactly one place per branch (the `isWide` ternary), so no double-mount.
### 3. `fields/fields-page.tsx` — pure-CSS responsive stack
`fields-page` is `useState`-driven and its right pane is *always* a form (create when nothing selected), so a
drawer would need a new "New field" trigger. Instead make it a responsive **stack** (the issue comment explicitly
allows "or stack"): change the grid container from `grid grid-cols-[20rem_1fr]` to
`grid grid-cols-1 lg:grid-cols-[20rem_1fr]`, and give the form pane a top border that only shows when stacked
(`border-t lg:border-t-0`) while the list keeps its `border-r` (which reads as a bottom divider when stacked — or
add `border-b lg:border-b-0 lg:border-r` for a clean stacked divider). No JS, no drawer, no new trigger, no
element duplication — the same `FieldForm` reflows from below the list (narrow) to beside it (wide).
### 4. `objects/objects-page.tsx` — retrofit onto `DetailDrawer`
Replace the `lazy(ObjectDetailDrawer)` import + `<Suspense>` with a direct `DetailDrawer` import; in the narrow
branch render `{open && <DetailDrawer open={open} onClose={closeDetail} ariaLabel={t("objects.detailTitle")}><Outlet/></DetailDrawer>}`.
Behavior-preserving — its existing "narrow: detail renders inside a portaled drawer" test stays green.
## Data flow / behaviour
No data/routing changes. Each page picks layout via `useMediaQuery("(min-width:1024px)")` (vocab/search/objects) or
pure CSS (fields). The detail content is identical to today; only its container (inline pane vs Drawer) changes by
width. The detail route/state, breadcrumbs, and titles are unchanged.
## Error handling / edges
- `useMediaQuery` is SSR-safe (returns `false` server-side / pre-mount → narrow-first, then corrects on mount).
- Drawer `open` is derived from the route match (`:id`) / nothing on the index, so the Outlet only has content when
open; rendering `{open && <DetailDrawer …>}` mounts it only when active (matches the current objects behaviour).
- Fields stack: `FieldList` is `overflow-hidden` in a `grid-cols-1` row — ensure the stacked list has a sensible
height (it's in a flex/grid row that can scroll); the form below scrolls in its own pane. Keep each pane's
`overflow` as today.
- Drawer accessible name comes from `ariaLabel` (required prop) so every detail drawer is a named dialog (the #62
a11y fix, preserved + generalized).
## Testing
- **`components/detail-drawer.test.tsx`** (new): with `open`, the children render inside a dialog whose accessible
name is the `ariaLabel`; clicking the close button (labelled `actions.closeDetail`) calls `onClose`.
- **`vocab/vocabularies-page` + `search/search-page` tests** (new or extended): reuse the `setViewport(wide)`
matchMedia mock from `objects-page.test.tsx`. Narrow + a `:id` route → the detail renders in a portaled drawer
(`getByRole("dialog", { name })` within `document.body`); wide + `:id` → the detail is the inline pane (no
dialog). Closing the drawer navigates back to the index route.
- **`fields/fields-page` test**: the grid container carries the responsive classes (`grid-cols-1` +
`lg:grid-cols-[20rem_1fr]`); both the list and the form render (jsdom can't measure layout, so assert structure).
- **`objects/objects-page` tests**: stay green unchanged (the drawer is now the shared `DetailDrawer`).
- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no new dependency; no new i18n
keys; no codename; en/sv parity unaffected. `check:size` unchanged-or-smaller (dropping the objects drawer's
separate lazy chunk folds it into base-ui).
## Acceptance criteria
1. `components/detail-drawer.tsx` exists (Base UI drawer + close button + `children`/`ariaLabel`); `object-detail-drawer.tsx`
is deleted; Objects uses the shared `DetailDrawer` (no `lazy`/`Suspense`); its tests stay green.
2. Vocabularies + Search: wide = current side-by-side (unchanged); narrow (<1024) = master full-width + the detail
in a `DetailDrawer` when a `:id` is active; close returns to the index route.
3. Fields: responsive grid (`grid-cols-1 lg:grid-cols-[20rem_1fr]`) — stacked on narrow, side-by-side on wide.
4. New tests for `DetailDrawer`, vocabularies-page, search-page (narrow drawer + wide pane), fields-page (responsive
structure); all existing tests pass unchanged.
5. `typecheck`/`lint`/`build`/`check:colors` green; `check:size` reported (unchanged-or-smaller); no new
dependency; no new i18n keys; no codename.
## Out of scope → follow-ups
- A resizable master/detail splitter (issue "consider"); a per-user pane-width preference.
- Converting `fields` to a route-driven master/detail (it stays `useState`-driven + stacked).
@@ -0,0 +1,35 @@
import { expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { DeleteConfirmDialog } from "./delete-confirm-dialog";
import { InUseError } from "../api/errors";
test("delete-in-use shows the in-use count and keeps the dialog open", async () => {
const onConfirm = vi.fn(() => Promise.reject(new InUseError(3)));
renderApp(<DeleteConfirmDialog description="Delete this term?" onConfirm={onConfirm} />);
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
const dialog = within(document.body);
const buttons = await dialog.findAllByRole("button", { name: /delete/i });
await userEvent.click(buttons[buttons.length - 1]);
expect(await dialog.findByText(/used by 3/i)).toBeInTheDocument();
expect(dialog.getByText("Delete this term?")).toBeInTheDocument();
});
test("a clean confirm closes the dialog", async () => {
const onConfirm = vi.fn(() => Promise.resolve());
renderApp(<DeleteConfirmDialog description="Delete this term?" onConfirm={onConfirm} />);
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
const dialog = within(document.body);
const buttons = await dialog.findAllByRole("button", { name: /delete/i });
await userEvent.click(buttons[buttons.length - 1]);
await waitFor(() => expect(dialog.queryByText("Delete this term?")).toBeNull());
expect(onConfirm).toHaveBeenCalledTimes(1);
});
+20
View File
@@ -0,0 +1,20 @@
import { expect, test, vi } from "vitest";
import { within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { DetailDrawer } from "./detail-drawer";
test("renders children in a named drawer and closes via the close button", async () => {
const onClose = vi.fn();
renderApp(
<DetailDrawer open onClose={onClose} ariaLabel="Object detail">
<p>detail body</p>
</DetailDrawer>,
);
const body = within(document.body);
expect(await body.findByText("detail body")).toBeInTheDocument();
await userEvent.click(body.getByRole("button", { name: /close detail/i }));
expect(onClose).toHaveBeenCalled();
});
@@ -1,21 +1,22 @@
import { Outlet } from "react-router-dom";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
/**
* Narrow-viewport object detail: the nested <Outlet/> inside a Base UI Drawer that
* slides from the right. Lazy-loaded so Base UI's drawer code (swipe/snap machinery)
* splits out of the main entry chunk the wide pane path never pays for it.
*/
export function ObjectDetailDrawer({
/** A right-sliding Base UI Drawer for a master/detail "detail" on narrow viewports.
* Provides the close affordance + an accessible dialog name; the caller supplies the content. */
export function DetailDrawer({
open,
onClose,
ariaLabel,
children,
}: {
open: boolean;
onClose: () => void;
ariaLabel: string;
children: ReactNode;
}) {
const { t } = useTranslation();
@@ -27,7 +28,7 @@ export function ObjectDetailDrawer({
}}
swipeDirection="right"
>
<DrawerContent aria-label={t("objects.detailTitle")}>
<DrawerContent aria-label={ariaLabel}>
<div className="flex justify-end border-b p-2">
<DrawerClose
aria-label={t("actions.closeDetail")}
@@ -36,9 +37,7 @@ export function ObjectDetailDrawer({
<X className="size-4" aria-hidden="true" />
</DrawerClose>
</div>
<div className="flex-1 overflow-auto">
<Outlet />
</div>
<div className="flex-1 overflow-auto">{children}</div>
</DrawerContent>
</Drawer>
);
@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { expect } from 'storybook/test'
import {
ComboboxRoot,
ComboboxInputGroup,
ComboboxInput,
ComboboxClear,
ComboboxTrigger,
ComboboxPopup,
ComboboxList,
ComboboxItem,
ComboboxItemIndicator,
ComboboxEmpty,
} from './combobox'
const fruits = ['Apple', 'Apricot', 'Banana', 'Cherry']
function ComboboxDemo() {
const [value, setValue] = useState<string | null>(null)
return (
<ComboboxRoot<string | null>
items={fruits}
value={value}
onValueChange={setValue}
itemToStringLabel={(item) => item ?? ''}
isItemEqualToValue={(a, b) => a === b}
>
<ComboboxInputGroup>
<ComboboxInput placeholder='Pick a fruit' />
<ComboboxClear aria-label='Clear' />
<ComboboxTrigger aria-label='Open' />
</ComboboxInputGroup>
<ComboboxPopup>
<ComboboxEmpty>No matches</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
<ComboboxItemIndicator className='text-primary'></ComboboxItemIndicator>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxPopup>
</ComboboxRoot>
)
}
const meta = {
component: ComboboxDemo,
tags: ['ai-generated'],
} satisfies Meta<typeof ComboboxDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
play: async ({ canvas }) => {
await expect(canvas.getByPlaceholderText('Pick a fruit')).toBeInTheDocument()
},
}
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { renderApp } from "../test/render";
import { FieldsPage } from "./fields-page";
test("renders the field list and form in a responsive grid", async () => {
const { container } = renderApp(<FieldsPage />);
// both panes are present (master list + detail form)
expect(await screen.findByText(/fields/i)).toBeInTheDocument();
// responsive: single-column by default, two-column at lg
const grid = container.querySelector("div.grid");
expect(grid?.className).toContain("grid-cols-1");
expect(grid?.className).toContain("lg:grid-cols-[20rem_1fr]");
});
+2 -2
View File
@@ -20,8 +20,8 @@ export function FieldsPage() {
return (
<div className="flex h-full flex-col">
<PageTitle className="px-4 pt-4 pb-2">{t("fields.title")}</PageTitle>
<div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden">
<div className="overflow-hidden border-r">
<div className="grid flex-1 grid-cols-1 overflow-auto lg:grid-cols-[20rem_1fr] lg:overflow-hidden">
<div className="overflow-hidden border-b lg:border-r lg:border-b-0">
<FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} />
</div>
<div className="overflow-hidden">
+19
View File
@@ -0,0 +1,19 @@
import { expect, test } from "vitest";
import { formatDate } from "./format-date";
test("formats a date-only string in the locale without a timezone day-shift", () => {
expect(formatDate("1962-04-03", "en")).toBe("Apr 3, 1962");
});
test("returns the em-dash placeholder for null", () => {
expect(formatDate(null, "en")).toBe("—");
});
test("returns an unparseable string unchanged", () => {
expect(formatDate("not-a-date", "en")).toBe("not-a-date");
});
test("stringifies a non-string, non-null value", () => {
expect(formatDate(42, "en")).toBe("42");
});
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from "vitest";
import { labelText } from "./labels";
const labels = [
{ lang: "en", label: "Bowl" },
{ lang: "sv", label: "Skål" },
];
test("returns the exact-language label when present", () => {
expect(labelText(labels, "sv")).toBe("Skål");
});
test("falls back to the English label when the requested language is missing", () => {
expect(labelText(labels, "de")).toBe("Bowl");
});
test("falls back to the first label when neither the language nor English is present", () => {
expect(labelText([{ lang: "fr", label: "Bol" }], "de")).toBe("Bol");
});
test("returns an empty string for no labels", () => {
expect(labelText([], "en")).toBe("");
});
+5 -10
View File
@@ -1,19 +1,15 @@
import { lazy, Suspense } from "react";
import { Outlet, useMatch, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { ObjectsTable } from "./objects-table";
import { DetailDrawer } from "../components/detail-drawer";
import { useMediaQuery } from "../lib/use-media-query";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { Button } from "@/components/ui/button";
import { PageTitle } from "@/components/ui/page-title";
const ObjectDetailDrawer = lazy(() =>
import("./object-detail-drawer").then((m) => ({ default: m.ObjectDetailDrawer })),
);
export function ObjectsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -66,15 +62,14 @@ export function ObjectsPage() {
);
}
// Narrow: the detail lives in a Drawer, lazy-loaded so Base UI's drawer code stays
// out of the main entry chunk.
// Narrow: the detail lives in a Drawer sliding from the right.
return (
<div className="h-full">
{table}
{open && (
<Suspense fallback={null}>
<ObjectDetailDrawer open={open} onClose={closeDetail} />
</Suspense>
<DetailDrawer open={open} onClose={closeDetail} ariaLabel={t("objects.detailTitle")}>
<Outlet />
</DetailDrawer>
)}
</div>
);
+39
View File
@@ -0,0 +1,39 @@
import { expect, test } from "vitest";
import { pruneFields } from "./prune-fields";
test("drops empty/null/undefined scalars, keeps real scalars", () => {
const out = pruneFields(
{ a: "x", b: "", c: null, d: undefined, e: 0, f: false },
new Set(),
"en",
);
expect(out).toEqual({ a: "x", e: 0, f: false });
});
test("a localized_text key keeps only the default-language entry", () => {
const out = pruneFields(
{ title: { en: "Bowl", sv: "Skål" } },
new Set(["title"]),
"sv",
);
expect(out).toEqual({ title: { sv: "Skål" } });
});
test("a non-localized object value keeps all non-empty entries", () => {
const out = pruneFields(
{ dims: { w: "10", h: "", d: "5" } },
new Set(),
"en",
);
expect(out).toEqual({ dims: { w: "10", d: "5" } });
});
test("an object value left with no entries is dropped entirely", () => {
const out = pruneFields(
{ title: { en: "Bowl" }, empty: { en: "", sv: "" } },
new Set(["title", "empty"]),
"sv",
);
expect(out).toEqual({});
});
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, expect, test, vi } from "vitest";
import { screen, within } from "@testing-library/react";
import { Route, Routes } from "react-router-dom";
import { renderApp } from "../test/render";
import { SearchPage } from "./search-page";
import { ObjectDetail } from "../objects/object-detail";
import { SelectSearchPrompt } from "./select-search-prompt";
function setViewport(wide: boolean) {
Object.defineProperty(window, "matchMedia", {
value: (query: string): MediaQueryList =>
({
matches: wide && query === "(min-width: 1024px)",
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList,
writable: true,
});
}
afterEach(() => vi.restoreAllMocks());
function tree() {
return (
<Routes>
<Route path="/search" element={<SearchPage />}>
<Route index element={<SelectSearchPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
</Routes>
);
}
test("narrow: a selected result's detail renders in a portaled drawer", async () => {
setViewport(false);
renderApp(tree(), { route: "/search/11111111-1111-1111-1111-111111111111" });
const body = within(document.body);
expect(
await body.findByRole("button", { name: /close detail/i }, { timeout: 5000 }),
).toBeInTheDocument();
});
test("wide: a selected result renders inline, with no detail drawer", async () => {
setViewport(true);
renderApp(tree(), { route: "/search/11111111-1111-1111-1111-111111111111" });
expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /close detail/i })).toBeNull();
});
+25 -6
View File
@@ -1,28 +1,47 @@
import { Outlet } from "react-router-dom";
import { Outlet, useMatch, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SearchPanel } from "./search-panel";
import { DetailDrawer } from "../components/detail-drawer";
import { useMediaQuery } from "../lib/use-media-query";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
export function SearchPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const detailMatch = useMatch("/search/:id");
const open = Boolean(detailMatch);
const isWide = useMediaQuery("(min-width: 1024px)");
useDocumentTitle(t("nav.search"));
useBreadcrumb([{ label: t("nav.search") }]);
const close = () => navigate("/search");
return (
<div className="flex h-full flex-col">
<PageTitle className="px-4 pt-4 pb-2">{t("nav.search")}</PageTitle>
<div className="grid flex-1 grid-cols-[24rem_1fr] overflow-hidden">
<div className="overflow-hidden border-r">
{isWide ? (
<div className="grid flex-1 grid-cols-[24rem_1fr] overflow-hidden">
<div className="overflow-hidden border-r">
<SearchPanel />
</div>
<div className="overflow-hidden">
<Outlet />
</div>
</div>
) : (
<div className="flex-1 overflow-hidden">
<SearchPanel />
</div>
<div className="overflow-hidden">
)}
{!isWide && open && (
<DetailDrawer open={open} onClose={close} ariaLabel={t("objects.detailTitle")}>
<Outlet />
</div>
</div>
</DetailDrawer>
)}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, expect, test, vi } from "vitest";
import { screen, within } from "@testing-library/react";
import { Route, Routes } from "react-router-dom";
import { renderApp } from "../test/render";
import { VocabulariesPage } from "./vocabularies-page";
import { VocabularyTerms } from "./vocabulary-terms";
import { SelectVocabularyPrompt } from "./select-vocabulary-prompt";
function setViewport(wide: boolean) {
Object.defineProperty(window, "matchMedia", {
value: (query: string): MediaQueryList =>
({
matches: wide && query === "(min-width: 1024px)",
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList,
writable: true,
});
}
afterEach(() => vi.restoreAllMocks());
function tree() {
return (
<Routes>
<Route path="/vocabularies" element={<VocabulariesPage />}>
<Route index element={<SelectVocabularyPrompt />} />
<Route path=":id" element={<VocabularyTerms />} />
</Route>
</Routes>
);
}
test("narrow: a selected vocabulary's detail renders in a portaled drawer", async () => {
setViewport(false);
renderApp(tree(), { route: "/vocabularies/v-material" });
const body = within(document.body);
expect(
await body.findByRole("button", { name: /close detail/i }, { timeout: 5000 }),
).toBeInTheDocument();
});
test("wide: a selected vocabulary renders inline, with no detail drawer", async () => {
setViewport(true);
renderApp(tree(), { route: "/vocabularies/v-material" });
// VocabularyTerms renders its "Terms" caption inline in the right pane.
await screen.findByText(/terms/i);
expect(screen.queryByRole("button", { name: /close detail/i })).toBeNull();
});
+25 -6
View File
@@ -1,28 +1,47 @@
import { Outlet } from "react-router-dom";
import { Outlet, useMatch, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { VocabularyList } from "./vocabulary-list";
import { DetailDrawer } from "../components/detail-drawer";
import { useMediaQuery } from "../lib/use-media-query";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
export function VocabulariesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const detailMatch = useMatch("/vocabularies/:id");
const open = Boolean(detailMatch);
const isWide = useMediaQuery("(min-width: 1024px)");
useDocumentTitle(t("nav.vocabularies"));
useBreadcrumb([{ label: t("nav.vocabularies") }]);
const close = () => navigate("/vocabularies");
return (
<div className="flex h-full flex-col">
<PageTitle className="px-4 pt-4 pb-2">{t("nav.vocabularies")}</PageTitle>
<div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden">
<div className="overflow-hidden border-r">
{isWide ? (
<div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden">
<div className="overflow-hidden border-r">
<VocabularyList />
</div>
<div className="overflow-hidden">
<Outlet />
</div>
</div>
) : (
<div className="flex-1 overflow-hidden">
<VocabularyList />
</div>
<div className="overflow-hidden">
)}
{!isWide && open && (
<DetailDrawer open={open} onClose={close} ariaLabel={t("vocab.terms")}>
<Outlet />
</div>
</div>
</DetailDrawer>
)}
</div>
);
}
+20
View File
@@ -16,6 +16,26 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src")
}
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (/[\\/]node_modules[\\/]\.pnpm[\\/](react|react-dom|react-router|react-router-dom)@/.test(id)) {
return "react";
}
if (/[\\/]node_modules[\\/]\.pnpm[\\/]@base-ui\+react@/.test(id)) {
return "base-ui";
}
if (/[\\/]node_modules[\\/]\.pnpm[\\/]@tanstack\+react-query@/.test(id)) {
return "query";
}
if (/[\\/]node_modules[\\/]\.pnpm[\\/](i18next|react-i18next)@/.test(id)) {
return "i18n";
}
}
}
}
},
server: {
proxy: {
"/api": "http://localhost:8080",