Skip to content

Mobile: a layout meant for a phone (#51)#52

Merged
dnviti merged 9 commits into
chore/version-bump-5.2.0from
feat/issue-51-mobile-layout
Jul 26, 2026
Merged

Mobile: a layout meant for a phone (#51)#52
dnviti merged 9 commits into
chore/version-bump-5.2.0from
feat/issue-51-mobile-layout

Conversation

@dnviti

@dnviti dnviti commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Closes #51. Targets chore/version-bump-5.2.0 (PR #50), not main.

What was wrong

The phone was running the desktop layout at the same size. The figures you
read mid-session — cost, model, state, whether approvals are bypassed — were
set at 10px, below the body text; controls were 16–34px with 2–7px between
them; and nothing that ran had a viewport small enough to notice. Every
browser check mounted at 900px or wider, so this shipped through four minor
versions.

What changed

  • src/client/ui/touch.ts is the one place the phone's rules live: the
    type steps, a 44px target floor, an 8px gap. A TypeScript module and not a
    stylesheet because this app's chrome is styled with inline
    React.CSSProperties — a media query would have applied to nothing. It
    publishes the phone answer through a context so a copy button five
    components deep gets the same answer as the header.
  • The session header is a separate layout on a phone, not the same row
    with smaller values. One wrapping row of information at a readable size,
    one row of controls a finger can hit. It drops the Beta badge and the
    terminal and theme toggles — the more sheet already has the last two.
  • The composer, turn strips, bubbles, ribbon, trace rail, turn index, tool
    cards and model list
    take the same scale, as do the bottom bar, its more
    sheet, the tab switcher and the dialogs a phone reaches.
  • Controls say what they are. panel-left does not mean "turn index" to
    anybody, and a touch screen has no hover to reveal the title that said so.
  • Button, Select and IconButton step up to the floor inside a phone
    context
    , so a dialog gets it without knowing it is on a phone.

One fix that is not about size

The transcript kept min-height: 160px. A flex item cannot shrink below its
min-height, so on a screen with less than that to spare the floor did not
reserve room — it overflowed the column and painted the conversation over the
live ribbon and the composer beneath it. A phone in landscape has about 160px
for the conversation once the header, ribbon and composer have taken theirs,
which is where it showed first.

Verification

npm test (1359), npm run typecheck, npm run test:browser (237 checks,
up from 81) all pass. The browser checks now run at three phone viewports —
portrait, keyboard-open and landscape — with each of the phone's own sheets
open in turn, and assert geometry rather than intent: target size, neighbour
spacing, type size, that the named live figures are legible, that every
control is identifiable without pressing it, and that no region is drawn over
another. The shell's own sheets are measured in an iframe, because a Dialog
portals to document.body and positions against the viewport — in the main
page it would have been 800px wide whatever the host div said.

test/browser/page.html now loads the app's own stylesheets. Without them
every var(--text-2xs) resolved to nothing and inherited 16px, so a check
measuring type size would have read 16px for text that ships at 10.

The desktop layout is unchanged, confirmed by screenshot against the same
fixture as well as by the existing desktop checks.

Worth knowing

  • Tablets. detectMobile() is untouched, so which devices take the
    mobile path is exactly as before — but a tablet in portrait was already on
    that path, so it now gets the phone type scale and touch targets too. The
    issue lists tablet changes as a non-goal and also asks the question openly;
    this is the answer the existing detection already implied.
  • The terminal's key strip is deliberately untouched and left out of the
    fixture: the issue lists the terminal's own on-screen controls as a
    non-goal.

🤖 Generated with Claude Code

dnviti and others added 5 commits July 26, 2026 19:23
The phone layout was the desktop layout, smaller. Nothing that ran had a
viewport small enough to see it: every browser check mounted at 900px or
wider, so four minor versions shipped with 10px cost figures and 16px
controls two pixels apart.

- ui/touch.ts holds the phone scale — type steps, a 44px target floor, an
  8px gap — in one place, and publishes it through a context so a copy
  button five components deep gets the same answer as the header.
- The session header is a separate layout on a phone rather than the same
  row with smaller values: one wrapping row of information at a readable
  size, one row of controls a finger can hit. It drops the Beta badge and
  the terminal and theme toggles, all of which the more sheet already has.
- The composer, the turn strips, the message bubbles and the stream ribbon
  follow the same scale. The composer no longer detects "phone" itself —
  it takes the surface's answer, which is what made it impossible to
  examine at the size it ships at.
- test/browser covers three phone viewports (portrait, short, landscape)
  and asserts geometry rather than intent: target size, neighbour spacing,
  type size, the named live figures, and nothing pushed off the side.
- test/browser/page.html now loads the app's own stylesheets. Without them
  every `var(--text-2xs)` resolved to nothing and inherited 16px, so a
  check measuring type size would have read 16px for text shipping at 10.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first pass fixed what a closed phone surface shows. This opens the
three sheets a phone actually reaches — the trace rail, the turn index and
the model list — and applies the same rules there, because a check only
covers what its fixture opens.

- Workspace tabs, the activity filter row and its event rows, the turn
  index and its rows, the tool-call headers and the model list all take
  the phone type scale and the 44px floor.
- The model list anchors to the composer rather than to its chip on a
  phone: a 200px list hanging off a chip near the right edge of a 390px
  screen was drawn half off-screen.
- Its custom-name field is set at 16px, not 15 — below that, iOS Safari
  zooms the page as soon as the field takes focus and does not zoom back.

The check mounts fresh per state rather than toggling: a second click on
the opener left the sheet up and blamed it for every state after.
Sideways scrollers and sheet scrims are recognised for what they are — a
tab strip that scrolls has a way back to what it hides, and a scrim abuts
the sheet on purpose — so neither is reported as crowding.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bottom bar, its more sheet and the tab switcher are the app shell's,
not the chat surface's, so a ChatView fixture never reaches them. They get
their own mount, in an iframe: two of the three are Dialogs, and a Dialog
portals to document.body and positions against the viewport — measured in
the main page it would be 800px wide whatever the host div said, so the
one question being asked would have been answered about a desktop.

- The bar's labels were a 10px caption under a 20px glyph; they are now
  the phone's meta size. It exists only on a phone, so it takes the scale
  outright rather than asking whether it is on one.
- A bottom-placed Dialog is the phone form of a dialog, so its close
  control is sized for a thumb rather than a cursor — 17x19px of glyph in
  the corner of the screen was what a finger had to find.
- The tab switcher's rows, its per-tab close and its two footer buttons
  all clear the floor.

The terminal's key strip is deliberately untouched and out of the fixture:
issue #51 lists the terminal's own on-screen controls as a non-goal.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rflowing

Two things the geometry checks could not see, found by rendering the phone
layout and looking at it.

The controls were the right size and still unidentifiable: a `panel-left`
glyph does not say "turn index" to anybody, and on a touch screen there is
no hover to reveal the title that said so — pressing it and watching what
happened was the only way to find out. The header's four controls and the
composer's now carry the word as well as the glyph, and a check asserts
it, because `title` and `aria-label` both answer the question for nobody
holding a phone.

And the transcript kept `min-height: 160px`. A flex item cannot shrink
below its min-height, so on a screen with less than that to spare the
floor did not reserve room — it overflowed the column and painted the
conversation over the live ribbon and the composer. A phone in landscape
has about 160px for the conversation once the header, ribbon and composer
have taken theirs, which is where it showed first.

Also here: the message actions move to their own line on a phone (beside
the text they were a 44px column that made a two-line message four lines
tall), the composer's action row flows as one wrapping row instead of
leaving Send alone on a line, and the phone hint line goes — at the phone
type size it truncated to half a sentence, and what it said is now written
on the two buttons above it.

The check learned two things while finding these: a control clipped by a
scroller has a box that says nothing about where it is painted, and the
model chip falls back to the word "model", so a fixture that never
delivered one was measuring the placeholder and passing.

Closes #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial pass. Three things it turned up.

The dialogs a phone reaches — chat display settings, the session list —
were still drawn for a cursor: 16x19px close, 27px selects, 29px icon
buttons. Fixed in `Button`, `Select` and `IconButton` rather than in each
dialog, which is what a design system is for: a surface reached from the
bottom bar gets the floor without knowing it is on a phone. And the shell
publishes the phone answer at its root, so those dialogs — which render
beside every conversation rather than inside one — can read it at all.

`ui/touch.ts` had three exported helpers and two constants nothing used.
A module whose job is to be the one place should hold the rules in force,
not a wishlist.

`UsageMeter`'s new name was on a plain div, where most screen readers
ignore it. It has a role now, so the name lands.

The check learned that `getBoundingClientRect()` measures the painted box:
a dialog's entrance animation scales it, so a 44px button measured 43.12
for as long as the animation ran — and headless Chrome does not reliably
finish one. How big a control is, is a layout question.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 26, 2026 17:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a phone-specific chat/shell layout (issue #51) by centralizing touch sizing rules in a shared UI module and applying them across the chat surface and related sheets/dialogs. It also significantly expands browser checks to validate phone viewport geometry (text legibility, target sizes, spacing, and non-overlap) and ensures the test harness loads the app’s real stylesheets.

Changes:

  • Introduces a shared PhoneContext and phone sizing constants (PHONE_TEXT, TOUCH_TARGET, TOUCH_GAP) and applies them across key UI components (header, composer, ribbons, panels, cards, dialogs).
  • Adds robust phone-viewport browser checks (multiple viewports + overlay states) and updates the browser test page to load the production CSS.
  • Fixes a flexbox min-height issue that caused the transcript to overlap the ribbon/composer on short screens.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/browser/page.html Loads the app’s compiled Relay + main CSS for accurate font/token measurements in browser checks.
test/browser/checks.ts Adds phone viewport geometry checks for both chat surface and shell-mounted surfaces (dialogs/sheets).
src/client/ui/touch.ts Centralizes phone type scale, touch target/gap constants, and provides PhoneContext + usePhone().
src/client/ui/relay/Select.tsx Adapts select height/font sizing for phone contexts to meet touch/legibility requirements.
src/client/ui/relay/IconButton.tsx Ensures icon buttons meet the touch target floor on phone via usePhone().
src/client/ui/relay/Dialog.tsx Sizes the dialog close control for thumb use when rendered as a bottom sheet or on phone.
src/client/ui/relay/Button.tsx Steps all buttons up to the phone touch/typography floor inside a phone context.
src/client/ui/relay/Badge.tsx Allows stable aria-label naming for variable-width badge text (used in phone header checks/a11y).
src/client/shell/TabSwitcherSheet.tsx Ensures tab rows and controls meet touch target sizing within the phone-only sheet.
src/client/shell/MobileBar.tsx Increases mobile bar label type size to the phone scale.
src/client/shell/dialogs/SessionsDialog.tsx Adjusts secondary text/chip font sizes for phone readability via usePhone().
src/client/shell/dialogs/ChatSettingsDialog.tsx Adjusts section label sizing for phone readability via usePhone().
src/client/shell/chat/WorkspacePanel.tsx Enlarges workspace rail header/tabs controls and spacing for phone touch targets.
src/client/shell/chat/UsageMeter.tsx Adds a phone sizing mode and stable accessible naming for session usage readout.
src/client/shell/chat/TurnStrip.tsx Makes the strip wrap and increases control sizing/type for phone legibility and reachability.
src/client/shell/chat/TurnIndex.tsx Enlarges index controls and row sizing/type for phone touch targets.
src/client/shell/chat/ToolCallCard.tsx Increases tool card header sizing and type scale for phone.
src/client/shell/chat/StreamRibbon.tsx Makes ribbon wrap and enlarges controls/type for phone; adjusts “stop” labeling for touch.
src/client/shell/chat/SessionHeader.tsx Splits out a dedicated phone header layout with labeled controls and legible live figures.
src/client/shell/chat/MessageList.tsx Fixes min-height behavior so the transcript can shrink without overlapping other regions.
src/client/shell/chat/MessageBubble.tsx Moves message actions to a dedicated row and increases touch targets/type for phone.
src/client/shell/chat/Composer.tsx Publishes phone context for composer subtree; wraps rows on phone; increases target sizes and adds labels.
src/client/shell/chat/ChatView.tsx Publishes phone context once for the whole chat surface to ensure consistent sizing down-tree.
src/client/shell/chat/ActivityTimeline.tsx Enlarges timeline chips/rows/type and enables wrapping for phone.
src/client/shell/AppShell.tsx Publishes phone context at the shell level so dialogs/sheets also render with phone sizing.
CHANGELOG.md Adds a 5.2.0 entry describing the phone layout rework, overflow fix, and expanded browser checks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread test/browser/checks.ts
Comment on lines +764 to +768
const box = node.getBoundingClientRect();
if (box.width <= 2 || box.height <= 2) return false;
if (node.closest('[aria-hidden="true"]')) return false;
const styles = viewOf(node).getComputedStyle(node);
return styles.visibility !== 'hidden' && styles.display !== 'none' && styles.opacity !== '0';
dnviti and others added 4 commits July 26, 2026 20:43
…n back

Reworked after seeing the first pass on a real phone. Every rule from it
still holds; what changed is how much of the screen the chrome is allowed
to hold while holding them.

- The bottom bar is gone. Five slots along the bottom edge cost 56px plus
  the safe-area inset of a surface whose whole point is what is above them,
  and could only ever show five things — so everything else lived in a
  sheet reached through it anyway. `FloatingMenu` is one 56px square in the
  bottom right and a labelled list behind it. The header's four controls
  join it there, so it is one menu rather than two.
- The session header is a strip: what it is doing, what it has cost. Tap it
  for the runtime, the folder, the branch, the tokens, the context meter
  and the approvals state.
- The composer is the field, More, and send — on one line. The rest is
  behind More. Its stop is gone from a phone: stopping is possible exactly
  while the ribbon above it is on screen, and the ribbon has a labelled one.
- The ribbon holds one line and drops the elapsed/token figures rather than
  wrapping to two.

The conversation went from 43% of a 390x844 screen to 79%, and the chrome
from 215px to 159px at every viewport — asserted, because nothing else here
would have noticed it drifting back: every other rule is about the chrome
being big enough, which pushes the other way.

Three things this turned up that were not about the redesign:

- The menu animated in from `opacity: 0`, and headless Chrome never
  advanced the frame — so every rule silently skipped the panel as
  invisible and reported the state clean. It rises without fading now,
  which also means it is there under reduced motion and on a dropped first
  frame. The check asserts it is actually on screen.
- The menu button and its own scrim were both at `--z-overlay`, so which
  one a tap reached came down to document order. Said outright now, and
  asserted by what `elementFromPoint` returns at the button's centre.
- The browser checks ran in an 800x600 window while mounting a 390x740
  fixture, so a third of it was off-viewport and anything asking the
  viewport a question got the wrong answer.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The redesign superseded half of what the first entry described — the
header's control row and the bottom bar it referred to are both gone — so
the section described two layouts, neither of which shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bar the redesign removed held Sessions, New, Panel, Image and More —
a place to go, a thing to make, a panel to toggle, a file to attach and a
sheet of everything left over. Five slots of the most valuable chrome in
the app, spent on a list with no idea in it. Removing it was the wrong
answer to that; rethinking what it is for is the right one.

What this app does is run agent sessions, and inside one there are four
places worth being — the conversation, what the agent did about it, the
files it did that to, and a shell in the same directory — plus the other
sessions. `BottomNav` is those five, it paints which one you are on with
`aria-current="page"` and a rule that survives monochrome, and pressing one
goes there. The verbs stay on the floating button: a control that changes
where you are and a control that does something to where you are do not
belong in the same row.

It hides while the on-screen keyboard is up — the keyboard takes half the
screen and the half it leaves is the half being typed into, which is the
one moment nobody is navigating.

Making the bar able to say where you are meant one answer for the rail
rather than two, and that surfaced a real bug behind the local state that
had been hiding it: `panelOpen` is persisted, and on a phone the rail
*replaces* the conversation, so the stored preference opened every
conversation behind a panel. My first fix cleared it on mount — which
writes the phone's answer into a shared setting and closes the rail on the
desktop that set it open. It is session state held by the shell now, and
the preference is not touched.

The chrome costs 216px with the bar, against 159 without and 215 before any
of this — so the conversation keeps about seven tenths of a phone screen
rather than eight, and the bar is worth that.

Two more fixture lessons, both the same shape as the last: the browser
fixture has to compose the surface the way the shell does, or it measures
something the app never renders. It passed `DEFAULT_CHAT_VIEW` straight
through with a no-op setter and spent a whole run measuring the workspace
panel instead of the conversation; and the shell's own tests shared a store
without resetting `chat`, so a case that put a conversation on screen broke
the key-strip case three tests later.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…loor

CI caught it on Node 22 and not on Node 24, which is the tell: the pill
only exists while the transcript is *not* pinned to the bottom, so whether
a run saw it came down to timing. The check now scrolls the conversation
back on purpose, which is the state that control lives in.

Two ways to defeat a primitive's touch floor from a call site, and this hit
both. `height: 34` overrode it outright. Replacing that with
`height: isPhone ? undefined : 34` was worse: the style object is spread
over the primitive's own, so a present key wins whatever its value is —
React drops an undefined one and the pill came out 17px tall. The key is
omitted on a phone now.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dnviti
dnviti merged commit d0bcffc into chore/version-bump-5.2.0 Jul 26, 2026
4 checks passed
@dnviti
dnviti deleted the feat/issue-51-mobile-layout branch July 26, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants