# Design — Mobile responsive parity **Change:** `mobile-responsive-parity` **Phase:** design **Date:** 2026-06-26 ## Context Frontend stack recap: React 18 + Vite + TanStack Query + TanStack Table + Tailwind v4 (CSS `@theme` in `src/index.css`) + shadcn/ui (Radix primitives) + lucide-react + react-router-dom + react-oidc-context. The app shell (`App.tsx`) is already responsive via a `md:` (768px) cut and a `MobileDrawer` `Sheet`. The content layer is not. This design adds four **shared primitives** and applies them per-page. It does not introduce new libraries. ## Architecture ### Shared primitives (PR 1) #### 1. `MobileCardRow` — card renderer for TanStack Table rows Lives in `src/components/ui/mobile-card.tsx` (new). Generic over the row data type. Reused by the four wide tables. ```tsx export interface MobileCardField { key: string; label: string; render: (row: T) => React.ReactNode; /** When true, render as the card title (bold, larger). Exactly one per card. */ primary?: boolean; } export interface MobileCardRowProps { rows: TData[]; fields: MobileCardField[]; onRowClick?: (row: T) => void; /** Optional right-aligned action slot (edit/delete icon buttons). */ actions?: (row: T) => React.ReactNode; } ``` Renders a vertical list of cards. Each card shows the `primary` field as the title and the remaining fields as a key/value stack. The whole card is a button when `onRowClick` is set (44px min height). The consuming page decides which fields to show — this primitive does not pick them. #### 2. `useIsMobile()` — single source of truth for the breakpoint Lives in `src/hooks/useIsMobile.ts` (new). Wraps `matchMedia("(max-width: 768px)")`, SSR-safe, returns a boolean. Replaces the inline `window.matchMedia` reads in `App.tsx` and the ad-hoc `usePrefersSmallScreen` usage in `Media.tsx`. One breakpoint, one hook. ```ts export function useIsMobile(): boolean { const [isMobile, setIsMobile] = useState(() => typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches ); useEffect(() => { const mql = window.matchMedia("(max-width: 768px)"); const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); mql.addEventListener("change", handler); return () => mql.removeEventListener("change", handler); }, []); return isMobile; } ``` #### 3. `SheetForm` — full-height form host Lives in `src/components/ui/sheet-form.tsx` (new). Wraps the shadcn `Sheet` primitive. Props: `open`, `onOpenChange`, `title`, `onSave`, `onCancel`, `isPending`, `children`. Renders sticky header (`title` + `X`) and sticky footer (`Cancel` / `Save`). Body scrolls. Below `md`, used by ServicePage, Settings, message compose, WidgetConfigDialog. At `md:` and above, the existing `Dialog` is used unchanged. The choice is made in the consumer with `useIsMobile()`, not inside `SheetForm`, so the same form body can be reused across both hosts. #### 4. `EditActionButton` — touch-aware edit affordance Replaces `HoverEditButton`'s role (not its file — we extend the existing component). Add a `mobile="always"` prop (default). Below `md`, the button is always visible (no hover-gated opacity). At `md:` and above, current hover-reveal behavior is preserved. Implementation: a `md:opacity-0 md:group-hover:opacity-100` Tailwind stack, i.e. always visible by default, hidden-then-revealed on hover at `md:` and up. ### Per-page application (PRs 2–9) Each wide-table page renders `` below `md` and the existing `` at/above `md`. The page wires up the field list. Example for Media: ```tsx const isMobile = useIsMobile(); const fields: MobileCardField[] = [ { key: "title", label: "Title", render: (r) => r.title, primary: true }, { key: "size", label: "Size", render: (r) => r.size_display }, { key: "hdr", label: "HDR", render: (r) => (r.is_hdr ? "HDR" : "") }, { key: "library", label: "Library", render: (r) => r.library_name }, ]; return isMobile ? } /> : ; ``` ### Touch-target audit (PR 1, applied throughout) A single `min-h-11 min-w-11` (44px) utility class is applied to interactive shadcn primitives below `md`. Applied via a `mobile-touch-target` Tailwind utility class registered in `tailwind.config.cjs` (or as a Tailwind v4 CSS utility in `src/index.css`). The class adds `min-height: 44px; min-width: 44px` only below `md`: ```css @media (max-width: 767px) { .mobile-touch-target, .mobile-touch-target::before { min-height: 44px; min-width: 44px; } } ``` Pages add the class to icon buttons, checkboxes, switches, and row taps during their per-page PR. ## Breakpoints - `< 768px` (`isMobile === true`): mobile layout — cards, Sheet forms, always- visible edit, single-column dashboard, anchor bar. - `≥ 768px`: existing desktop layout, unchanged. No `sm:` cut. No `lg:` cut. ## Key technical risks & mitigations - **TanStack column defs vs. card fields drift.** Each page that renders a card must declare its mobile fields in one place; tests assert the card shows the primary field at 375px. If a column is renamed, the card test fails. - **iOS Safari `100dvh`.** `SheetForm` uses `h-[100dvh]` (not `h-screen`) to avoid the iOS URL-bar resize jump. Tested manually on iOS Safari. - **`position: sticky` inside `SheetContent`.** Radix `Sheet` uses transforms; sticky must be relative to the scroll container inside the sheet body, not the sheet itself. The sticky header/footer are siblings of the scrolling body inside a flex column, not sticky-positioned. - **OIDC redirect after login.** No change: responsive web only, OIDC continues to redirect within the same browser tab. ## Trade-offs - **Card layouts duplicate field definitions** (once as TanStack columns, once as `MobileCardField[]`). Accepted: the alternative (auto-deriving cards from column defs) produces bad mobile UX because column defs are not ordered by mobile importance. - **44px touch targets** slightly increase mobile visual density compared to a 32px design, but meet WCAG 2.5.5. Accepted. - **`useIsMobile()` per-page render branching** is preferred over CSS-only `hidden md:block` because the card and table have different data dependencies (e.g. row click handlers, selection state) and mounting both wastes work.