diff --git a/docs/superpowers/plans/2026-09-19-remote-phone-gutter.md b/docs/superpowers/plans/2026-09-19-remote-phone-gutter.md new file mode 100644 index 000000000..01a7fe1d6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-remote-phone-gutter.md @@ -0,0 +1,29 @@ +# Remote phone gutter: plan + +**Branch:** `fix/remote-phone-gutter` from `origin/main`. It carries work finished by +an earlier agent session (the phone gutter thread), which was left uncommitted in +the main checkout. Release-readiness ledger thread T3. + +## Problem + +The owner reported that margin and padding on mobile are broken. On the phone +(remote-client) shell, the v1 chrome bands used a 14px horizontal gutter, while the +mounted desktop feed, the composer and the v2 chrome all use 12px. Adjacent bands +sat 2px apart, which on a monospace grid reads as broken padding. + +## Change + +- One gutter, 12px, on `.topbar`, `.section-label`, `.session-row`, `.terminal` + and `.working`. +- A "gutter contract" header comment in `src/remote-client/src/styles.css`. +- `src/remote-client/src/ui/gutterContract.test.ts` asserts the stylesheet text, + which is the file the phone actually loads. jsdom never evaluates this CSS, so + computed-style assertions could not see it; this is the same approach as + `nordTheme.test.ts`. + +## Verification + +- Against main's stylesheet the contract fails on 4 bands. +- With the fix, the remote-client unit suite passes 33/33. +- There is no visual check (the app is never launched), so the owner should look + on the phone after the release. diff --git a/src/remote-client/src/styles.css b/src/remote-client/src/styles.css index 75836a5b1..92de99745 100644 --- a/src/remote-client/src/styles.css +++ b/src/remote-client/src/styles.css @@ -2,6 +2,14 @@ * composer, condition tap bar). The feed itself renders with the desktop's * own components + @renderer/styles.css, imported before this file. * + * THE GUTTER CONTRACT: every chrome band uses ONE horizontal gutter — 12px, + * matching the mounted desktop feed column (px-3 base tier; --feed-gutter + * is 12px at the same tier in @renderer/styles.css). The v1 chrome below + * was born at 14px while the feed was born at 12px, and that 2px shear + * between adjacent bands was the long-standing "mobile padding is broken" + * complaint — pinned by gutterContract.test.ts. Any new band joins the + * 12px scale; a second gutter value is the bug, not a styling choice. + * * THE AESTHETIC CONTRACT (same hard rules as @renderer/styles.css, which * this file must never fight): * 1. Radius marks detachment, and the phone SHELL has nothing detached: @@ -82,7 +90,7 @@ html, body, #root { .topbar { display: flex; align-items: center; gap: 10px; - padding: calc(env(safe-area-inset-top, 0px) + 10px) 14px 10px; + padding: calc(env(safe-area-inset-top, 0px) + 10px) 12px 10px; border-bottom: 1px solid var(--border); background: var(--surface); flex-shrink: 0; @@ -134,13 +142,13 @@ html, body, #root { /* --- session picker --- */ .session-list { display: flex; flex-direction: column; } .section-label { - padding: 10px 14px 4px; + padding: 10px 12px 4px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.16em; color: var(--muted); } .session-row { display: flex; align-items: center; gap: 10px; - padding: 12px 14px; border: none; border-bottom: 1px solid var(--border); + padding: 12px 12px; border: none; border-bottom: 1px solid var(--border); background: none; color: var(--ink); text-align: left; width: 100%; font-size: 13px; } .session-row:active { background: var(--surface-hi); } @@ -169,7 +177,7 @@ html, body, #root { /* --- session view --- */ .terminal { - padding: 12px 14px; white-space: pre-wrap; word-break: break-word; + padding: 12px 12px; white-space: pre-wrap; word-break: break-word; font-size: 11px; line-height: 1.45; color: var(--ink); } /* Live conditions now render via the real desktop ConditionOutlet (Tailwind- @@ -231,7 +239,7 @@ html, body, #root { } .working { - padding: 6px 14px; font-size: 11px; color: var(--accent); + padding: 6px 12px; font-size: 11px; color: var(--accent); border-top: 1px solid var(--border); flex-shrink: 0; } diff --git a/src/remote-client/src/ui/gutterContract.test.ts b/src/remote-client/src/ui/gutterContract.test.ts new file mode 100644 index 000000000..4cad82387 --- /dev/null +++ b/src/remote-client/src/ui/gutterContract.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// The phone shell's ONE-GUTTER CONTRACT: every chrome band uses a 12px +// horizontal gutter, matching the mounted desktop feed column (px-3 base +// tier, --feed-gutter: 12px in @renderer/styles.css). +// +// WHY a text-parsing test instead of jsdom: the gutters live in plain CSS +// (styles.css) that jsdom never evaluates, so computed-style assertions +// cannot see them — the same honesty rule as primitives.renderer.test.tsx +// and nordTheme.test.ts, which assert against the stylesheet text itself. +// +// The bug this pins: the v1 chrome was born with 14px gutters and the feed +// was born with 12px, so for the entire v1 era every fleet-screen band sat +// 2px proud of every session-screen band (and the .working/.terminal +// fallback strips sat 2px off the header/feed around them). A 2px shear on +// a monospace grid reads as "the padding is broken" — exactly the +// long-standing mobile complaint. If this test fails, someone reintroduced +// a second gutter scale; unify on 12px rather than adding a third value. + +const css = readFileSync(resolve(__dirname, '../styles.css'), 'utf8') + +/** Extract one rule block for a single-class selector from the stylesheet. + * Anchored to column 0 so compound rules like `.session-row + * .marker.working { … }` cannot shadow the plain `.working { … }` band + * rule — first-match wins, and the compound appears earlier in the file. */ +function ruleBlock(selector: string): string { + const match = css.match(new RegExp(`^\\.${selector}\\s*\\{([^}]*)\\}`, 'm')) + if (!match) throw new Error(`selector .${selector} not found in styles.css`) + return match[1] +} + +/** Split a shorthand into top-level values, ignoring whitespace inside + * parens — safe-area/calc vertical paddings must not shatter into fake + * tokens (the naive whitespace split turned `0px)` into a "value"). */ +function topLevelValues(shorthand: string): string[] { + const values: string[] = [] + let depth = 0 + let current = '' + for (const ch of shorthand.trim()) { + if (ch === '(') depth++ + if (ch === ')') depth-- + if (depth === 0 && /\s/.test(ch)) { + if (current) values.push(current) + current = '' + } else current += ch + } + if (current) values.push(current) + return values +} + +/** Horizontal component of a padding shorthand (2nd value, or the single + * value; vertical-only differences are intentional and out of scope). */ +function horizontalGutter(selector: string): string { + const padding = ruleBlock(selector).match(/padding:\s*([^;]+);/)?.[1] + if (!padding) throw new Error(`.${selector} has no padding declaration`) + const values = topLevelValues(padding) + return values[1] ?? values[0] +} + +describe('phone shell gutter contract', () => { + // v1 chrome that survived into v2 (fleet screen + session fallback bands) + // alongside the 12px v2/session chrome. These five are the historical + // offenders; the v2 selectors below never drifted. + it.each(['topbar', 'section-label', 'session-row', 'terminal', 'working'])( + '.%s uses the 12px gutter', + selector => { + expect(horizontalGutter(selector)).toBe('12px') + }, + ) + + it.each(['session-header', 'composer-actions', 'reader-host'])( + '.%s keeps the 12px gutter', + selector => { + expect(horizontalGutter(selector)).toBe('12px') + }, + ) +})