Files
biggus-dickus/docs/superpowers/specs/2026-06-09-responsive-master-detail-design.md
T

8.7 KiB

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:

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.tsxuseMediaQuery + 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).