21 KiB
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/DrawerContentfrom@/components/ui/drawer.objects/objects-page.tsxcurrentlylazy()-loadsObjectDetailDrawer+ 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 (Ximport 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(indexSelectVocabularyPrompt) +/vocabularies/:id(VocabularyTerms).search-page.tsx: same shape,grid-cols-[24rem_1fr],<SearchPanel/>, routes/search(indexSelectSearchPrompt) +/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.tsxhas asetViewport(wide: boolean)helper that overrideswindow.matchMediato match(min-width: 1024px)only whenwide; default test setup is narrow (matches:false);afterEach(() => vi.restoreAllMocks()). Narrow-drawer assertion pattern: deep-link to:id, thenwithin(document.body).findByRole(...)for the portaled drawer + assert the/close detail/ibutton. Novocabularies-page/search-page/fields-pagetest 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:
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):
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. Removeimport { lazy, Suspense } from "react";and theconst ObjectDetailDrawer = lazy(...)block; addimport { DetailDrawer } from "../components/detail-drawer";. Replace the narrowreturnblock's drawer with:
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:
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:
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
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:
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. Readweb/src/test/fixtures.ts+web/src/test/handlers.tsfor the vocabularies list + terms handlers and a real vocabulary id. Mirror theobjects-page.test.tsxsetViewportharness:
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:
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
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;24remmaster,SearchPanel, route"/search/:id", close"/search", drawer ariaLabelt("objects.detailTitle")since the search detail is an object):
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.tsxmirroring the vocab test (thesetViewporthelper, 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 fromfixtures.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:
cd web && pnpm vitest run src/search/search-page.test.tsx src/search && pnpm typecheck && pnpm lint
Green.
- Step 4: Commit
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.tsxa responsive stack. Change the grid container + the list pane's border so it stacks on narrow and is side-by-side onlg:
<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:
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):
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:
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
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.closeDetailall exist).components/ui/*untouched (drawer/button wrappers unchanged; only a new app-levelcomponents/detail-drawer.tsx). - The
<Outlet/>per page is rendered in exactly one place perisWidebranch — 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.