From effd991c31da598450c45aab889f08e8f8e97296 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 27 Jul 2026 22:24:26 +0100 Subject: [PATCH] docs: mobile responsive design spec and implementation plan for web/ --- .../plans/2026-07-27-web-mobile-responsive.md | 928 ++++++++++++++++++ ...2026-07-27-web-mobile-responsive-design.md | 177 ++++ 2 files changed, 1105 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-web-mobile-responsive.md create mode 100644 docs/superpowers/specs/2026-07-27-web-mobile-responsive-design.md diff --git a/docs/superpowers/plans/2026-07-27-web-mobile-responsive.md b/docs/superpowers/plans/2026-07-27-web-mobile-responsive.md new file mode 100644 index 0000000..0095bcf --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-web-mobile-responsive.md @@ -0,0 +1,928 @@ +# Control plane mobile responsiveness — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `web/` (the Vantage control plane UI) usable on a phone — the sidebar becomes a hamburger-driven offcanvas below 1024px, tables become card stacks below 640px, and every fixed desktop layout collapses. + +**Architecture:** A new client component `AppShell` owns the responsive chrome so `app/(app)/layout.tsx` stays a server component. `Sidebar.tsx` splits into a shared `SidebarContent` plus two containers (permanent aside, offcanvas drawer) so the nav exists in exactly one copy. The table card-stack lives in the `ui/Table.tsx` primitives via Tailwind `max-sm:` variants, so pages keep one markup tree and opt in with a `label` prop per cell. + +**Tech Stack:** Next.js 16 (App Router), React 18, Tailwind 3.4, `clsx`. **No new dependencies.** + +## Global Constraints + +- **Scope is `web/` only.** Do not touch `site/`, `adminsite/`, `server/`, `admin/` or any Go code. +- **No hex colours anywhere.** Tailwind maps `var(--…)` tokens only. Use `bg-surface`, `border-border`, `text-text-secondary` etc. A literal `#` in a class is a defect. (`bg-black/60` is the one existing exception, already used by `Modal.tsx` for its backdrop — reuse it, do not introduce others.) +- **Breakpoints:** sidebar collapses below `lg` (1024px). Tables card-stack below `sm` (640px). Do not invent other breakpoints. +- **No new dependencies.** No headless-ui, no framer-motion. +- **Presentation only.** No API, route, query-key or data-shape changes. +- **Radius:** `rounded`, `rounded-lg`, `rounded-md` and `rounded-xl` all resolve to 4–6px via `tailwind.config.ts`. Prefer `rounded` in new code. +- Use `dvh`, not `vh`, for any new viewport-height value — mobile browser chrome makes `vh` overshoot. +- Indentation follows the file you are editing. `web/` is mixed: some files use 4 spaces (`Sidebar.tsx`, `keys/page.tsx`), others 2 (`servers/page.tsx`, `ui/*`). Match the file, do not reformat it. +- **There is no test framework in this repo.** No jest, no vitest, no playwright. Verification is `npx next lint`, `npx next build`, and targeted `grep` audits. Do not add a test framework. +- Run all commands from `d:\Development\Websites\vantage\web`. + +--- + +### Task 1: Responsive table primitives + +The card stack goes in the primitives, not the pages. Six pages render tables; giving each one a second markup tree would double the markup and drift on the first edit. + +**Files:** +- Modify: `web/components/ui/Table.tsx` (whole file) + +**Interfaces:** +- Consumes: nothing. +- Produces: `Td` gains an optional prop `label?: string`. Below `sm`, a `Td` with a `label` renders `{label}` before its children; a `Td` without one renders children alone, right-aligned. `Table`, `Thead`, `Tbody`, `Tr`, `Th` keep their existing signatures. Task 4 consumes `label`. + +- [ ] **Step 1: Rewrite `web/components/ui/Table.tsx`** + +Replace the entire file with: + +```tsx +import { clsx } from "clsx"; +import { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react"; + +/* + * Below sm the table stops being a table: the head is hidden, each row becomes + * a bordered card and each cell becomes a label/value pair. That lives here + * rather than in the six pages that render tables — two markup trees per page + * would drift apart on the first edit, and every one of those trees would mean + * the same thing. + * + * The mobile label uses Th's exact keyed-label idiom (mono, small, widely + * tracked, dimmed) because a key beside a value on a phone is the same device + * as a column head above it on a desktop. + */ + +export function Table({ className, children, ...props }: HTMLAttributes) { + return ( +
+ + {children} +
+
+ ); +} + +export function Thead({ className, children, ...props }: HTMLAttributes) { + return ( + + {children} + + ); +} + +export function Tbody({ className, children, ...props }: HTMLAttributes) { + return ( + + {children} + + ); +} + +export function Tr({ className, children, ...props }: HTMLAttributes) { + return ( + + {children} + + ); +} + +export function Th({ className, children, ...props }: ThHTMLAttributes) { + return ( + + {children} + + ); +} + +interface TdProps extends TdHTMLAttributes { + /** + * The column head this cell belongs to, shown beside the value below sm + * where the real head is hidden. Omit on a trailing action cell — an action + * needs no key, and the button then sits alone on its own row in the card. + */ + label?: string; +} + +export function Td({ className, label, children, ...props }: TdProps) { + return ( + + {label && ( + + {label} + + )} + {children} + + ); +} +``` + +- [ ] **Step 2: Verify it compiles and lints** + +```bash +npx tsc --noEmit +npx next lint +``` + +Expected: both clean. `tsc` may take ~30s. If `tsc --noEmit` errors on pre-existing issues unrelated to `Table.tsx`, note them and move on — only new errors matter. + +- [ ] **Step 3: Commit** + +```bash +git add web/components/ui/Table.tsx +git commit -m "feat(web): card-stack tables below sm" +``` + +--- + +### Task 2: Offcanvas sidebar + +**Files:** +- Modify: `web/components/Sidebar.tsx` (whole file) +- Create: `web/components/AppShell.tsx` +- Modify: `web/app/(app)/layout.tsx` (whole file) + +**Interfaces:** +- Consumes: `useAuth()` from `@/components/AuthProvider` returning `{ user, instance, isAdmin }`; `auth.logout()` from `@/lib/api`; `Logo` from `@/components/Logo`. +- Produces: + - `Sidebar.tsx` exports `SidebarContent({ onNavigate }: { onNavigate?: () => void })`, `Sidebar()` (permanent aside) and `SidebarDrawer({ open, onClose }: { open: boolean; onClose: () => void })`. + - `AppShell.tsx` exports `AppShell({ children }: { children: React.ReactNode })`. + - No later task depends on these names. + +- [ ] **Step 1: Rewrite `web/components/Sidebar.tsx`** + +Keep every icon component and the `navItems` array **exactly as they are** — do not retype the SVG path data, it is long and easy to corrupt. Change only from `export function Sidebar()` (line 135) to the end of the file, replacing it with the following. The file uses 4-space indentation. + +```tsx +/** Shared by the permanent aside and the offcanvas drawer — one copy of the nav. */ +export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) { + const pathname = usePathname(); + const { user, instance, isAdmin } = useAuth(); + + const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin); + + const activeHref = visibleItems.reduce((best, item) => { + const matches = pathname === item.href || pathname.startsWith(item.href + "/"); + if (!matches) return best; + return best === null || item.href.length > best.length ? item.href : best; + }, null); + + async function handleLogout() { + try { + await auth.logout(); + } catch {} + window.location.href = "/login"; + } + + return ( + <> +
+ +
+ Vantage + {instance && ( + {instance.name} + )} +
+
+ + + +
+ {user && ( +
+

{user.name || user.email}

+

+ {user.email} + {user.role && · {user.role}} +

+
+ )} +
+

Vantage v1.0

+ {user && ( + + )} +
+
+ + ); +} + +/** The permanent sidebar. Below lg the drawer takes over. */ +export function Sidebar() { + return ( + + ); +} + +/** + * The offcanvas below lg. Always mounted so the slide runs in both directions; + * closed it is inert (invisible + pointer-events-none) rather than unmounted. + */ +export function SidebarDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { + const panelRef = useRef(null); + + useEffect(() => { + if (!open) return; + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + panelRef.current?.focus(); + + return () => { + window.removeEventListener("keydown", onKey); + document.body.style.overflow = previousOverflow; + }; + }, [open, onClose]); + + return ( +
+ + ); +} +``` + +Then update the import line at the top of the file (currently line 4) so `useEffect` and `useRef` are available: + +```tsx +import { usePathname } from "next/navigation"; +import { useEffect, useRef } from "react"; +``` + +- [ ] **Step 2: Create `web/components/AppShell.tsx`** + +```tsx +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { usePathname } from "next/navigation"; +import { LicenseBanner } from "@/components/LicenseBanner"; +import { Logo } from "@/components/Logo"; +import { Sidebar, SidebarDrawer } from "@/components/Sidebar"; +import { useAuth } from "@/components/AuthProvider"; + +function MenuIcon() { + return ( + + + + ); +} + +/** + * Owns the responsive chrome so app/(app)/layout.tsx can stay a server + * component. Above lg this is the layout it always was; below lg the sidebar + * becomes an offcanvas behind the top bar's hamburger. + */ +export function AppShell({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false); + const pathname = usePathname(); + const buttonRef = useRef(null); + const { instance } = useAuth(); + + // A drawer that survives navigation would cover the page you just asked for. + useEffect(() => { + setOpen(false); + }, [pathname]); + + function close() { + setOpen(false); + buttonRef.current?.focus(); + } + + return ( +
+ + + +
+
+ + +
+ Vantage + {instance && ( + {instance.name} + )} +
+
+ +
+ + {children} +
+
+
+ ); +} +``` + +- [ ] **Step 3: Rewrite `web/app/(app)/layout.tsx`** + +```tsx +import { AuthProvider } from "@/components/AuthProvider"; +import { AppShell } from "@/components/AppShell"; + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} +``` + +`LicenseBanner` and `Sidebar` are no longer imported here — `AppShell` renders both. + +- [ ] **Step 4: Verify** + +```bash +npx tsc --noEmit +npx next lint +npx next build +``` + +Expected: all three succeed. `next build` is the one that matters — it catches a client component imported into a server component boundary. + +- [ ] **Step 5: Sanity-check the scroll container** + +Read `web/app/(app)/servers/[id]/console/page.tsx` around line 153 and 168. It uses `h-full`, which now resolves against `
` rather than the old `
`. Confirm the console page still has a height to fill; if `h-full` no longer resolves, change those two wrappers to `flex-1` instead. Task 7 revisits this file, so a note is acceptable here if you prefer to fix it there — but write the note down. + +- [ ] **Step 6: Commit** + +```bash +git add web/components/Sidebar.tsx web/components/AppShell.tsx "web/app/(app)/layout.tsx" +git commit -m "feat(web): offcanvas sidebar with hamburger below lg" +``` + +--- + +### Task 3: Page padding and header rows + +**Files:** +- Modify: all 21 files under `web/app` and `web/components` containing `p-8` +- Modify: the title-plus-action header rows listed below + +**Interfaces:** +- Consumes: nothing. Produces: nothing. Pure class edits. + +- [ ] **Step 1: List every occurrence** + +```bash +cd web && grep -rn "p-8" app components +``` + +Expected: 30 occurrences across 21 files. + +- [ ] **Step 2: Replace each page-level `p-8` with `p-4 sm:p-6 lg:p-8`** + +Apply to every occurrence **except** these two, which Task 6 and Task 7 handle and which need different values: + +- `app/(app)/workflows/[id]/page.tsx:331` (the canvas `
`) — leave for Task 6. +- `app/(app)/servers/[id]/console/page.tsx:168` — leave for Task 7. + +The inline loading states (`
Loading…
`) get the same treatment: `className="p-4 text-text-secondary sm:p-6 lg:p-8"`. + +Do this file by file with `Edit`. A blind `sed` would also hit `p-8` inside strings or unrelated contexts — check each match. + +- [ ] **Step 3: Make title-plus-action header rows stack** + +In each of these, change `className="mb-6 flex items-center justify-between"` to +`className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"`: + +- `app/(app)/servers/page.tsx:75` +- `app/(app)/keys/page.tsx:112` +- `app/(app)/monitors/page.tsx:35` +- `app/(app)/workflows/page.tsx:32` +- `app/(app)/secrets/page.tsx:105` +- `app/(app)/secrets/[group]/page.tsx:251` +- `app/(app)/settings/notifications/page.tsx:153` + +Leave `flex items-center justify-between` rows that are *inside* a card header or a table cell — those hold two small items and are fine at 390px. Only the page-top title/action rows change. + +- [ ] **Step 4: Verify no unprefixed `p-8` survives** + +```bash +cd web && grep -rn 'className="[^"]*\bp-8\b' app components | grep -v "sm:p-8\|lg:p-8" +``` + +Expected: exactly two lines — the two deferred to Tasks 6 and 7. + +- [ ] **Step 5: Verify** + +```bash +npx next lint && npx next build +``` + +Expected: both succeed. + +- [ ] **Step 6: Commit** + +```bash +git add web/app web/components +git commit -m "feat(web): responsive page padding and stacking page headers" +``` + +--- + +### Task 4: Label every table cell + +**Files:** +- Modify: `web/app/(app)/servers/page.tsx:116-145` +- Modify: `web/app/(app)/keys/page.tsx:149-169` +- Modify: `web/app/(app)/monitors/page.tsx:79-98` +- Modify: `web/app/(app)/secrets/page.tsx:142-157` +- Modify: `web/app/(app)/secrets/[group]/page.tsx:141-150` +- Modify: `web/app/(app)/workflows/page.tsx:73-86` +- Modify: `web/app/(app)/workflows/[id]/runs/page.tsx:54-68` +- Modify: `web/app/(app)/audit/page.tsx:80-93` +- Modify: `web/app/(app)/keys/[id]/page.tsx:388-420` +- Modify: `web/app/(app)/servers/[id]/page.tsx:272-280` and `:588-605` +- Modify: `web/app/(app)/monitors/[id]/page.tsx:183-195` +- Modify: `web/components/settings/MembersCard.tsx:118-145` + +**Interfaces:** +- Consumes: `Td`'s `label?: string` prop from Task 1. +- Produces: nothing. + +- [ ] **Step 1: Add `label` to each `Td`, matching its `Th`** + +For every table, the Nth `` in a `` takes the text of the Nth ``. Where the `Th` is empty (`` — the trailing action column), the matching `Td` gets **no** `label`. + +The mapping, `Th` order per file: + +| File | Column labels, in order | +| --- | --- | +| `servers/page.tsx` | Hostname · IP Address · OS · Status · Last Seen · *(none)* | +| `keys/page.tsx` | Label · Fingerprint · Source · Assignments · Created · *(none)* | +| `monitors/page.tsx` | Name · Type · Target · Status · Latency · Last check | +| `secrets/page.tsx` | Group · Keys · Last Updated · *(none)* | +| `secrets/[group]/page.tsx` | Key · Value · Updated · *(none)* | +| `workflows/page.tsx` | Name · Targets · Steps · *(none)* | +| `workflows/[id]/runs/page.tsx` | Run · Status · Started · By · Servers | +| `audit/page.tsx` | Time · Event · Actor · Details | +| `keys/[id]/page.tsx` | Server · IP Address · Status · Assigned · Revoked · *(none)* | +| `servers/[id]/page.tsx` (updates table) | Package · Current · Available | +| `servers/[id]/page.tsx` (keys table) | Label · Fingerprint · Source · Status · Assigned · *(none)* | +| `monitors/[id]/page.tsx` | Started · Resolved · Cause | +| `MembersCard.tsx` | Email · Role · Sign-in · Last login · Actions | + +Worked example — `servers/page.tsx` lines 116–145 become: + +```tsx + + + {server.hostname} + + + + + {server.ip_address} + + + + {server.os_info} + + + + + + + {server.last_seen + ? formatLastSeen(server.last_seen) + : "Never"} + + + + + + + +``` + +Note the last `Td` is unchanged — no `label`, so the "View →" button sits alone on its own row at the bottom of the card. + +Second worked example — `MembersCard.tsx` line 142–143, where `Td` already carries a `className`. Both props coexist: + +```tsx + {u.last_login ? new Date(u.last_login).toLocaleString() : "Never"} + +``` + +`MembersCard`'s last column has a real `Th` ("Actions"), so unlike the others it **does** take a label. + +- [ ] **Step 2: Verify no `Td` was missed** + +```bash +cd web && grep -rn " +
+
+``` + +The `max-w-*` gains an `sm:` prefix so the sheet is full-width on a phone. `dvh` rather than `vh` because mobile browser chrome makes `vh` overshoot. + +- [ ] **Step 2: Collapse the grids in `MonitorForm.tsx`** + +- Line 76: `grid grid-cols-4 gap-2` → `grid grid-cols-2 gap-2 sm:grid-cols-4` +- Lines 98, 123, 144: `grid grid-cols-2 gap-4` → `grid grid-cols-1 gap-4 sm:grid-cols-2` + +- [ ] **Step 3: Collapse the grids in `StepPickerModal.tsx`** + +Lines 132 and 168: `grid grid-cols-2 gap-2.5` → `grid grid-cols-1 gap-2.5 sm:grid-cols-2` + +- [ ] **Step 4: Let `CardHeader` wrap** + +`web/components/ui/Card.tsx` line 27: `"mb-4 flex items-center justify-between"` → `"mb-4 flex flex-wrap items-center justify-between gap-2"`. Card headers hold a title and an action; at 390px they need to be allowed to wrap rather than crush the title. + +- [ ] **Step 5: Verify** + +```bash +npx next lint && npx next build +``` + +Expected: both succeed. + +- [ ] **Step 6: Commit** + +```bash +git add web/components/ui/Modal.tsx web/components/ui/Card.tsx web/components/monitors/MonitorForm.tsx web/components/workflows/StepPickerModal.tsx +git commit -m "feat(web): bottom-sheet modals and collapsing component grids" +``` + +--- + +### Task 6: Workflow builder + +**Files:** +- Modify: `web/app/(app)/workflows/[id]/page.tsx:305-324` (header), `:329` (grid), `:331` (canvas), `:340` (column), `:372` (node), `:403` (inspector) + +**Interfaces:** +- Consumes: nothing. Produces: nothing. + +Below `lg` the fixed-height two-column grid is dropped entirely: single column, natural page flow. The `100dvh` arithmetic only makes sense at `lg`, where there is no mobile top bar above it. + +- [ ] **Step 1: Let the header wrap (line 305)** + +```tsx +
+``` + +and on line 312 change `className="ml-auto flex items-center gap-2"` to +`className="ml-auto flex flex-wrap items-center gap-2"`. + +- [ ] **Step 2: Make the shell single-column below lg (line 329)** + +```tsx +
+``` + +`h-[calc(100vh-53px)]` becomes `lg:h-[calc(100dvh-53px)]` — `lg:` because the mobile top bar changes the arithmetic, and `dvh` because `vh` overshoots on mobile. + +- [ ] **Step 3: Canvas padding (line 331)** + +```tsx +
+``` + +- [ ] **Step 4: Let the node column and nodes be fluid (lines 340 and 372)** + +Line 340: + +```tsx +
+``` + +Line 372 — the node itself. The wrapping `
` on line 349 already constrains it, so the node just fills: + +```tsx + className={`w-full cursor-pointer rounded border bg-surface p-3 ${isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"}`} +``` + +- [ ] **Step 5: Turn the inspector into a bottom panel below lg (line 403)** + +```tsx +