From eec3a261b4f1066362a4eb085ee004ae2f2b6728 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 9 Jun 2026 14:11:14 +0200 Subject: [PATCH 1/6] docs(specs): responsive master/detail for vocab/search/fields (#58) --- ...6-06-09-responsive-master-detail-design.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-responsive-master-detail-design.md diff --git a/docs/superpowers/specs/2026-06-09-responsive-master-detail-design.md b/docs/superpowers/specs/2026-06-09-responsive-master-detail-design.md new file mode 100644 index 0000000..696c1c7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-responsive-master-detail-design.md @@ -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** (``, 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 +``). 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 ( + { if (!next) onClose(); }} swipeDirection="right"> + +
+ }> + +
+
{children}
+
+
+ ); +} +``` +- 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 `` inline (prompt when no `:id`, detail when selected) — + unchanged. +- **Narrow:** the master full-width (`VocabularyList` / `SearchPanel` under the existing `PageTitle`), plus + `{open && }`. 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 `` 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 + `` with a direct `DetailDrawer` import; in the narrow +branch render `{open && }`. +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 && }` 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). From b3f061ced727c6b80f9931f4490b25da29dd8f79 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 9 Jun 2026 15:06:42 +0200 Subject: [PATCH 2/6] =?UTF-8?q?docs(plans):=20responsive=20master/detail?= =?UTF-8?q?=20=E2=80=94=204-task=20plan=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-09-responsive-master-detail.md | 425 ++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-responsive-master-detail.md diff --git a/docs/superpowers/plans/2026-06-09-responsive-master-detail.md b/docs/superpowers/plans/2026-06-09-responsive-master-detail.md new file mode 100644 index 0000000..3aeb37b --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-responsive-master-detail.md @@ -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: `{if(!n)onClose()}} swipeDirection="right">
}>
`. `Drawer`/`DrawerClose`/`DrawerContent` from `@/components/ui/drawer`. +- `objects/objects-page.tsx` currently `lazy()`-loads `ObjectDetailDrawer` + wraps in ``; narrow branch renders `{open && }`. The WIDE pane has its own close `` — keep it (`X` import stays). +- Existing i18n (no new keys): `objects.detailTitle` ("Object detail"), `vocab.terms` ("Terms"), `actions.closeDetail` ("Close detail"). +- `vocabularies-page.tsx`: `
`. Routes: `/vocabularies` (index `SelectVocabularyPrompt`) + `/vocabularies/:id` (`VocabularyTerms`). +- `search-page.tsx`: same shape, `grid-cols-[24rem_1fr]`, ``, routes `/search` (index `SelectSearchPrompt`) + `/search/:id` (`ObjectDetail`). +- `fields-page.tsx`: `useState`-driven; `
`. 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 ( + { + if (!next) onClose(); + }} + swipeDirection="right" + > + +
+ } + > + +
+
{children}
+
+
+ ); +} +``` + +- [ ] **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( + +

detail body

+
, + ); + + 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 ( +
+ {table} + {open && ( + + + + )} +
+ ); +``` +(Keep everything else — the wide grid branch with its own close `` 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 ( +
+ {t("nav.vocabularies")} + {isWide ? ( +
+
+ +
+
+ +
+
+ ) : ( +
+ +
+ )} + {!isWide && open && ( + + + + )} +
+ ); +} +``` +(The `` 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 ( + + }> + } /> + } /> + + + ); +} + +test("narrow: a selected vocabulary's detail renders in a portaled drawer", async () => { + setViewport(false); + renderApp(tree(), { route: `/vocabularies/` }); + + 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/` }); + + // 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 `` 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()`) 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 ( +
+ {t("nav.search")} + {isWide ? ( +
+
+ +
+
+ +
+
+ ) : ( +
+ +
+ )} + {!isWide && open && ( + + + + )} +
+ ); +} +``` + +- [ ] **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: `}>}/>}/>`. Deep-link `/search/` (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 +
+
+ +
+
+ setSelected(null)} + /> +
+
+``` +(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(); + + // 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 ``/`` 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("//:id")` + `navigate("/")` 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 `` 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. From b5756e16b50f5743402815679c268e67a9dd5244 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 9 Jun 2026 15:09:37 +0200 Subject: [PATCH 3/6] refactor(web): shared DetailDrawer; objects-page uses it (#58) --- web/src/components/detail-drawer.test.tsx | 20 ++++++++++++++++++ .../detail-drawer.tsx} | 21 +++++++++---------- web/src/objects/objects-page.tsx | 15 +++++-------- 3 files changed, 35 insertions(+), 21 deletions(-) create mode 100644 web/src/components/detail-drawer.test.tsx rename web/src/{objects/object-detail-drawer.tsx => components/detail-drawer.tsx} (62%) diff --git a/web/src/components/detail-drawer.test.tsx b/web/src/components/detail-drawer.test.tsx new file mode 100644 index 0000000..193032a --- /dev/null +++ b/web/src/components/detail-drawer.test.tsx @@ -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( + +

detail body

+
, + ); + + 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(); +}); diff --git a/web/src/objects/object-detail-drawer.tsx b/web/src/components/detail-drawer.tsx similarity index 62% rename from web/src/objects/object-detail-drawer.tsx rename to web/src/components/detail-drawer.tsx index 84cc341..6932f8b 100644 --- a/web/src/objects/object-detail-drawer.tsx +++ b/web/src/components/detail-drawer.tsx @@ -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 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" > - +
-
- -
+
{children}
); diff --git a/web/src/objects/objects-page.tsx b/web/src/objects/objects-page.tsx index ad823e6..0976942 100644 --- a/web/src/objects/objects-page.tsx +++ b/web/src/objects/objects-page.tsx @@ -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 (
{table} {open && ( - - - + + + )}
); From 80c2aad298cfb67023512ac25e08bfb365995c2a Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 9 Jun 2026 15:12:45 +0200 Subject: [PATCH 4/6] feat(web): responsive Vocabularies master/detail (drawer on narrow) (#58) --- web/src/vocab/vocabularies-page.test.tsx | 57 ++++++++++++++++++++++++ web/src/vocab/vocabularies-page.tsx | 31 ++++++++++--- 2 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 web/src/vocab/vocabularies-page.test.tsx diff --git a/web/src/vocab/vocabularies-page.test.tsx b/web/src/vocab/vocabularies-page.test.tsx new file mode 100644 index 0000000..67894d2 --- /dev/null +++ b/web/src/vocab/vocabularies-page.test.tsx @@ -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 ( + + }> + } /> + } /> + + + ); +} + +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(); +}); diff --git a/web/src/vocab/vocabularies-page.tsx b/web/src/vocab/vocabularies-page.tsx index a82c8d1..2dfd617 100644 --- a/web/src/vocab/vocabularies-page.tsx +++ b/web/src/vocab/vocabularies-page.tsx @@ -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 (
{t("nav.vocabularies")} -
-
+ {isWide ? ( +
+
+ +
+
+ +
+
+ ) : ( +
-
+ )} + {!isWide && open && ( + -
-
+ + )}
); } From b83149e0bb4b074ca75e54b57777be17390b39c0 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 9 Jun 2026 15:15:44 +0200 Subject: [PATCH 5/6] feat(web): responsive Search master/detail (drawer on narrow) (#58) --- web/src/search/search-page.test.tsx | 56 +++++++++++++++++++++++++++++ web/src/search/search-page.tsx | 31 ++++++++++++---- 2 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 web/src/search/search-page.test.tsx diff --git a/web/src/search/search-page.test.tsx b/web/src/search/search-page.test.tsx new file mode 100644 index 0000000..d599d9b --- /dev/null +++ b/web/src/search/search-page.test.tsx @@ -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 ( + + }> + } /> + } /> + + + ); +} + +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(); +}); diff --git a/web/src/search/search-page.tsx b/web/src/search/search-page.tsx index 4084b13..7de97a2 100644 --- a/web/src/search/search-page.tsx +++ b/web/src/search/search-page.tsx @@ -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 (
{t("nav.search")} -
-
+ {isWide ? ( +
+
+ +
+
+ +
+
+ ) : ( +
-
+ )} + {!isWide && open && ( + -
-
+ + )}
); } From 7f9cf9fe603fb154e0d5442d32f2d194646b00d2 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 9 Jun 2026 15:18:39 +0200 Subject: [PATCH 6/6] feat(web): responsive Fields page (stacks on narrow) (#58) --- web/src/fields/fields-page.test.tsx | 17 +++++++++++++++++ web/src/fields/fields-page.tsx | 4 ++-- 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 web/src/fields/fields-page.test.tsx diff --git a/web/src/fields/fields-page.test.tsx b/web/src/fields/fields-page.test.tsx new file mode 100644 index 0000000..21f10a4 --- /dev/null +++ b/web/src/fields/fields-page.test.tsx @@ -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(); + + // 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]"); +}); diff --git a/web/src/fields/fields-page.tsx b/web/src/fields/fields-page.tsx index 47adb86..3da3f6c 100644 --- a/web/src/fields/fields-page.tsx +++ b/web/src/fields/fields-page.tsx @@ -20,8 +20,8 @@ export function FieldsPage() { return (
{t("fields.title")} -
-
+
+