18ee77a4e4
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.
168 lines
6.4 KiB
Markdown
168 lines
6.4 KiB
Markdown
# 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.
|