docs: mobile responsive design spec and implementation plan for web/
This commit is contained in:
@@ -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 `<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.
|
||||
Reference in New Issue
Block a user