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