# 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 `<span>{label}</span>` 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<HTMLTableElement>) {
  return (
    <div className="overflow-x-auto">
      <table
        className={clsx("w-full border-collapse text-sm max-sm:block", className)}
        {...props}
      >
        {children}
      </table>
    </div>
  );
}

export function Thead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
  return (
    <thead className={clsx("border-b border-border max-sm:hidden", className)} {...props}>
      {children}
    </thead>
  );
}

export function Tbody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
  return (
    <tbody
      className={clsx(
        "divide-y divide-border",
        "max-sm:block max-sm:space-y-3 max-sm:divide-y-0 max-sm:p-3",
        className
      )}
      {...props}
    >
      {children}
    </tbody>
  );
}

export function Tr({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
  return (
    <tr
      className={clsx(
        "transition-colors hover:bg-surface-2/50",
        "max-sm:block max-sm:rounded max-sm:border max-sm:border-border max-sm:bg-surface-2/40 max-sm:p-3",
        className
      )}
      {...props}
    >
      {children}
    </tr>
  );
}

export function Th({ className, children, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
  return (
    <th
      className={clsx(
        // site/'s keyed-label idiom: mono, small, widely tracked, dimmed.
        // A column head is a key, not prose.
        // text-secondary, not tertiary: a column head is how you navigate the
        // table, and tertiary lands under 4.5:1 at this size.
        "px-4 py-3 text-left font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary",
        className
      )}
      {...props}
    >
      {children}
    </th>
  );
}

interface TdProps extends TdHTMLAttributes<HTMLTableCellElement> {
  /**
   * 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 (
    <td
      className={clsx(
        "px-4 py-3 text-text-primary",
        "max-sm:flex max-sm:items-start max-sm:gap-4 max-sm:px-0 max-sm:py-1.5",
        // Exactly one justify class — clsx picks it. Emitting both and relying
        // on string order would not work: Tailwind's output order decides which
        // of two same-property utilities wins, not the order in this array.
        label ? "max-sm:justify-between" : "max-sm:justify-end max-sm:pt-2.5",
        className
      )}
      {...props}
    >
      {label && (
        <span className="hidden font-mono text-[0.68rem] uppercase leading-5 tracking-[0.13em] text-text-secondary max-sm:inline">
          {label}
        </span>
      )}
      {children}
    </td>
  );
}
```

- [ ] **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<string | null>((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 (
        <>
            <div className="flex h-16 shrink-0 items-center gap-3 border-b border-border px-5">
                <Logo className="h-8 w-8 text-logo" />
                <div className="min-w-0">
                    <span className="block text-base font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
                    {instance && (
                        <span className="block truncate font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>
                    )}
                </div>
            </div>

            <nav className="flex-1 overflow-y-auto px-3 py-4">
                <ul className="space-y-1">
                    {visibleItems.map((item) => {
                        const isActive = activeHref === item.href;
                        return (
                            <li key={item.href}>
                                <Link
                                    href={item.href}
                                    onClick={onNavigate}
                                    // The active marker is an accent bar, the same device
                                    // site/ uses to mark the chosen plan. A filled pill
                                    // reads as a button you can press again.
                                    className={clsx(
                                        "relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
                                        isActive
                                            ? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
                                            : "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
                                    )}
                                >
                                    {item.icon}
                                    {item.label}
                                </Link>
                            </li>
                        );
                    })}
                </ul>
            </nav>

            <div className="shrink-0 border-t border-border px-4 py-3">
                {user && (
                    <div className="mb-3">
                        <p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
                        <p className="truncate text-xs text-text-secondary">
                            {user.email}
                            {user.role && <span className="ml-1 text-text-tertiary">· {user.role}</span>}
                        </p>
                    </div>
                )}
                <div className="flex items-center justify-between">
                    <p className="font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">Vantage v1.0</p>
                    {user && (
                        <button type="button" onClick={handleLogout} className="text-xs text-text-secondary transition-colors hover:text-danger">
                            Logout
                        </button>
                    )}
                </div>
            </div>
        </>
    );
}

/** The permanent sidebar. Below lg the drawer takes over. */
export function Sidebar() {
    return (
        <aside className="hidden h-screen w-60 shrink-0 flex-col border-r border-border bg-surface lg:flex">
            <SidebarContent />
        </aside>
    );
}

/**
 * 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<HTMLDivElement>(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 (
        <div
            className={clsx(
                "fixed inset-0 z-50 lg:hidden",
                open ? "visible" : "invisible pointer-events-none",
            )}
        >
            <div
                aria-hidden="true"
                onClick={onClose}
                className={clsx(
                    "absolute inset-0 bg-black/60 transition-opacity duration-200",
                    open ? "opacity-100" : "opacity-0",
                )}
            />
            <div
                ref={panelRef}
                id="app-sidebar-drawer"
                role="dialog"
                aria-modal="true"
                aria-label="Navigation"
                tabIndex={-1}
                className={clsx(
                    "absolute inset-y-0 left-0 flex w-72 max-w-[85%] flex-col border-r border-border bg-surface outline-none transition-transform duration-200 ease-out",
                    open ? "translate-x-0" : "-translate-x-full",
                )}
            >
                <SidebarContent onNavigate={onClose} />
            </div>
        </div>
    );
}
```

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 (
        <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
            <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
        </svg>
    );
}

/**
 * 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<HTMLButtonElement>(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 (
        <div className="flex h-screen overflow-hidden">
            <Sidebar />
            <SidebarDrawer open={open} onClose={close} />

            <div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
                <header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-surface px-3 lg:hidden">
                    <button
                        ref={buttonRef}
                        type="button"
                        onClick={() => setOpen(true)}
                        aria-label="Open navigation"
                        aria-expanded={open}
                        aria-controls="app-sidebar-drawer"
                        className="-ml-1 rounded p-2 text-text-secondary transition-colors hover:bg-surface-2 hover:text-text-primary"
                    >
                        <MenuIcon />
                    </button>
                    <Logo className="h-7 w-7 shrink-0 text-logo" />
                    <div className="min-w-0">
                        <span className="block text-sm font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
                        {instance && (
                            <span className="block truncate font-mono text-[0.62rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>
                        )}
                    </div>
                </header>

                <main className="flex min-w-0 flex-1 flex-col">
                    <LicenseBanner />
                    {children}
                </main>
            </div>
        </div>
    );
}
```

- [ ] **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 (
    <AuthProvider>
      <AppShell>{children}</AppShell>
    </AuthProvider>
  );
}
```

`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 `<main class="flex min-w-0 flex-1 flex-col">` rather than the old `<main class="flex-1 overflow-y-auto">`. 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 `<main>`) — leave for Task 6.
- `app/(app)/servers/[id]/console/page.tsx:168` — leave for Task 7.

The inline loading states (`<div className="p-8 text-text-secondary">Loading…</div>`) 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 `<Td>` in a `<Tr>` takes the text of the Nth `<Th>`. Where the `Th` is empty (`<Th />` — 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
                  <Td label="Hostname">
                    <span className="font-medium text-text-primary">
                      {server.hostname}
                    </span>
                  </Td>
                  <Td label="IP Address">
                    <span className="font-mono text-text-secondary">
                      {server.ip_address}
                    </span>
                  </Td>
                  <Td label="OS">
                    <span className="text-text-secondary">{server.os_info}</span>
                  </Td>
                  <Td label="Status">
                    <StatusDot status={resolveStatus(server, latestVersion)} />
                  </Td>
                  <Td label="Last Seen">
                    <span className="text-text-secondary">
                      {server.last_seen
                        ? formatLastSeen(server.last_seen)
                        : "Never"}
                    </span>
                  </Td>
                  <Td>
                    <Link href={`/servers/${server.server_id}`}>
                      <Button variant="ghost" size="sm">
                        View →
                      </Button>
                    </Link>
                  </Td>
```

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
                                    <Td label="Last login" className="text-text-secondary">{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}</Td>
                                    <Td label="Actions" className="text-right">
```

`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 "<Td" app components | grep -v "label=" 
```

Expected: only the trailing action cells listed as *(none)* above — 7 of them (`servers`, `keys`, `secrets`, `secrets/[group]`, `workflows`, `keys/[id]`, `servers/[id]` keys table). Any other bare `<Td` is a miss.

- [ ] **Step 3: Verify**

```bash
npx next lint && npx next build
```

Expected: both succeed.

- [ ] **Step 4: Commit**

```bash
git add web/app web/components
git commit -m "feat(web): label table cells for the mobile card stack"
```

---

### Task 5: Modal bottom sheet and shared-component grids

**Files:**
- Modify: `web/components/ui/Modal.tsx:28-31`
- Modify: `web/components/monitors/MonitorForm.tsx:76,98,123,144`
- Modify: `web/components/workflows/StepPickerModal.tsx:132,168`
- Modify: `web/components/ui/Card.tsx:27`

**Interfaces:**
- Consumes: nothing. Produces: nothing.

- [ ] **Step 1: Make `Modal` a bottom sheet below `sm`**

In `web/components/ui/Modal.tsx`, replace lines 28–34:

```tsx
    <div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
      <div className="absolute inset-0 bg-black/60" onClick={onClose} />
      <div
        className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel sm:rounded sm:border-b`}
        role="dialog"
        aria-modal="true"
      >
```

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
            <div className="flex flex-wrap items-center gap-3 border-b border-border bg-surface px-4 py-3">
```

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
            <div className="flex flex-1 flex-col lg:grid lg:h-[calc(100dvh-53px)] lg:grid-cols-[1fr_320px]">
```

`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
                <main className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-4 sm:p-6 lg:p-8">
```

- [ ] **Step 4: Let the node column and nodes be fluid (lines 340 and 372)**

Line 340:

```tsx
                    <div className="mx-auto flex w-full max-w-[340px] flex-col items-center">
```

Line 372 — the node itself. The wrapping `<div key={wfIdx} className="w-full">` 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
                <aside
                    className={`overflow-auto border-border bg-surface p-4 lg:block lg:border-l ${
                        selected === null || !selectedRef ? "hidden" : "block border-t max-lg:max-h-[60dvh]"
                    }`}
                >
```

Below `lg` the inspector is hidden until a step is selected — an empty "Select a step to configure it" panel is noise on a phone — and when shown it sits under the canvas with a top border and a capped height. Above `lg` it is the left-bordered right rail it always was, always visible.

- [ ] **Step 6: Verify**

```bash
npx next lint && npx next build
```

Expected: both succeed.

- [ ] **Step 7: Commit**

```bash
git add "web/app/(app)/workflows/[id]/page.tsx"
git commit -m "feat(web): single-column workflow builder below lg"
```

---

### Task 7: Remaining fixed layouts

**Files:**
- Modify: `web/app/(app)/servers/[id]/page.tsx:164,495`
- Modify: `web/app/(app)/secrets/page.tsx:53`
- Modify: `web/app/(app)/workflows/[id]/runs/[runId]/page.tsx:~250`
- Modify: `web/app/(app)/servers/[id]/console/page.tsx:168` and its header rows

**Interfaces:**
- Consumes: nothing. Produces: nothing.

- [ ] **Step 1: `servers/[id]/page.tsx` line 164 — inventory grid**

`className="grid grid-cols-3 gap-2"` → `className="grid grid-cols-2 gap-2 sm:grid-cols-3"`

- [ ] **Step 2: `servers/[id]/page.tsx` line 495 — install one-liner**

`className="relative flex-1 min-w-64 rounded-lg border border-border bg-well px-4 py-2.5 font-mono text-sm"` → replace `min-w-64` with `min-w-0 overflow-x-auto`.

`min-w-64` is 256px of floor on a flex child; combined with a sibling copy button it pushes the row past a 390px viewport and scrolls the whole page sideways. `min-w-0` lets the box shrink and scroll its own content instead. Also check the parent flex row a few lines above and give it `flex-wrap` if the copy button ends up crushed.

- [ ] **Step 3: `secrets/page.tsx` line 53**

`className="grid grid-cols-2 gap-3"` → `className="grid grid-cols-1 gap-3 sm:grid-cols-2"`

- [ ] **Step 4: `workflows/[id]/runs/[runId]/page.tsx` — the step matrix**

Read the file around lines 240–290. The matrix `<table>` has a `<th className="min-w-[240px] …">`. It is a genuine two-dimensional matrix (steps × servers) and must keep scrolling horizontally rather than stacking — stacking would destroy the information.

Confirm the `<table>` sits inside a wrapper with `overflow-x-auto`. If it does not, wrap it:

```tsx
<div className="overflow-x-auto">
  <table …>
    …
  </table>
</div>
```

If a wrapper already exists, leave it alone and note that in the commit body.

- [ ] **Step 5: `servers/[id]/console/page.tsx`**

Line 168: `className="flex h-full flex-col p-8"` → `className="flex h-full min-h-0 flex-1 flex-col p-4 sm:p-6 lg:p-8"`.

`flex-1` is added because Task 2 changed the parent `<main>` from `flex-1 overflow-y-auto` to `flex min-w-0 flex-1 flex-col`, so `h-full` alone may no longer resolve to anything. If Task 2 Step 5 recorded a note about this file, resolve it here.

Line 161's error state also has a bare `p-8` — Task 3 should already have handled it. Confirm it reads `p-4 sm:p-6 lg:p-8`.

Then read the connected-state toolbar below line 220 and add `flex-wrap` to any `flex items-center` row that holds three or more controls, so the console's chrome wraps instead of overflowing.

- [ ] **Step 6: Verify**

```bash
npx next lint && npx next build
```

Expected: both succeed.

- [ ] **Step 7: Commit**

```bash
git add web/app
git commit -m "feat(web): collapse remaining fixed layouts on small screens"
```

---

### Task 8: Final audit

**Files:** none modified unless the audit finds a miss.

- [ ] **Step 1: No unprefixed `p-8` remains**

```bash
cd web && grep -rn 'className="[^"]*\bp-8\b' app components | grep -v "sm:p-8\|lg:p-8"
```

Expected: no output.

- [ ] **Step 2: No unprefixed multi-column grid remains**

```bash
cd web && grep -rnoE '(class|className)="[^"]*(^|[" ])grid-cols-[2-9]' app components
```

Every hit must be a `grid-cols-2` that is genuinely fine at 390px (two short items side by side). Check each one and note the justification. Anything holding form controls or long text must gain a `grid-cols-1 sm:` prefix.

- [ ] **Step 3: No fixed pixel width escapes a breakpoint prefix**

```bash
cd web && grep -rnoE '(^|[" ])(w|min-w|max-w)-\[[0-9]{3,}px\]' app components
```

Expected: only `lg:`-prefixed hits, plus `max-w-[340px]` and `max-w-[1180px]` and `max-w-[300px]`, which are all *maximums* and shrink freely. A bare `w-[NNNpx]` or `min-w-[NNNpx]` without a prefix is a defect — except `min-w-[240px]` in the run-detail matrix, which is deliberate (Task 7 Step 4).

- [ ] **Step 4: No hex colours were introduced**

```bash
cd web && git diff main --stat && git diff main -- app components | grep -nE '^\+.*#[0-9a-fA-F]{3,8}\b'
```

Expected: no output from the grep. Tailwind in this app maps `var(--…)` tokens only.

- [ ] **Step 5: Full build and lint**

```bash
npx next lint
npx next build
```

Expected: both succeed with no new warnings.

- [ ] **Step 6: Read the diff end to end**

```bash
git diff main -- web/
```

Check for: an accidentally deleted SVG path, a `Td` whose `label` does not match its `Th`, indentation reformatted in a file that used the other convention.

- [ ] **Step 7: Commit any fixes**

```bash
git add web
git commit -m "fix(web): mobile audit corrections"
```

If the audit found nothing, skip this step — do not create an empty commit.

---

## Self-review notes

**Spec coverage:** shell → Task 2; tables → Tasks 1 and 4; padding and headers → Task 3; modal → Task 5; workflow builder → Task 6; remaining fixed layouts → Task 7; verification → Task 8 plus a verify step in every task.

**Known limitation:** there is no test framework and no running backend in this environment, so no task can prove a page *looks* right — only that it compiles, lints, and contains no pattern known to break at 390px. The first person to open this on a phone should expect to find something. That is a property of the verification approach chosen in the spec, not a gap in the plan.
