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.
6.4 KiB
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.
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.
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:
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:
@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.SheetFormusesh-[100dvh](noth-screen) to avoid the iOS URL-bar resize jump. Tested manually on iOS Safari. position: stickyinsideSheetContent. RadixSheetuses 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-onlyhidden md:blockbecause the card and table have different data dependencies (e.g. row click handlers, selection state) and mounting both wastes work.