Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/superpowers/plans/2026-09-19-remote-phone-gutter.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 13 additions & 5 deletions src/remote-client/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -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-
Expand Down Expand Up @@ -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;
}

Expand Down
79 changes: 79 additions & 0 deletions src/remote-client/src/ui/gutterContract.test.ts
Original file line number Diff line number Diff line change
@@ -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')
},
)
})
Loading