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 (
+
+ );
+}
+
+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 (
+ <>
+
+ >
+ );
+}
+
+/** 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 (
+
+ );
+}
+```
+
+- [ ] **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
+
+```
+
+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
+