Plan mobile responsive parity (OpenSpec change)

Add proposal/spec/design/tasks for full mobile parity across all 9 routes.
Decisions: hybrid tables (cards below md for big four), Sheet-based forms,
always-visible edit affordance, 44px touch targets, single-column dashboard
with anchors, responsive web only (no PWA), phone portrait at md:768px cut.
Polling unchanged (risk flagged). Delivery: 10 chained PRs, primitives first.
This commit is contained in:
Developer
2026-06-26 11:49:47 +00:00
parent 3d331e4c72
commit 18ee77a4e4
4 changed files with 644 additions and 0 deletions
@@ -0,0 +1,167 @@
# 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 29)
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.