Files
manage/openspec/changes/mobile-responsive-parity/design.md
T
Developer 18ee77a4e4 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.
2026-06-26 11:49:47 +00:00

6.4 KiB
Raw Blame History

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 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:

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