# 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<T>` — 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<T> {
  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<T> {
  rows: TData[];
  fields: MobileCardField<T>[];
  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 `<MobileCardRow>` below `md` and the existing
`<DataTable>` at/above `md`. The page wires up the field list. Example for
Media:

```tsx
const isMobile = useIsMobile();
const fields: MobileCardField<MediaItem>[] = [
  { 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
  ? <MobileCardRow rows={rows} fields={fields} onRowClick={onRowClick} actions={(r) => <EditActionButton onClick={...} />} />
  : <DataTable columns={columns} data={rows} /* ...existing props */ />;
```

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