From 62059eedac0808ba3ce52eef0671491a29ea7f95 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:54:27 +0100 Subject: [PATCH 1/6] Give the phone document header the project, build status and people here The phone top bar now matches the desktop two-bar header's vocabulary: the title sits over a subtitle naming the project and the build (the capsule's glyph and label, or Build plan, as links), with up to two faces beside the actions. An open child takes the bar instead of adding a stack bar: Back on the left, the child's own title, people and actions, and "in " beneath it. The build's state keeps 12px or more from the faces. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/build-plan-button.css | 61 +++++++++- apps/web/src/build-plan-button.tsx | 32 +++-- apps/web/src/child-title.ts | 6 +- apps/web/src/phone-header.tsx | 189 +++++++++++++++++++---------- apps/web/src/phone-workspace.css | 107 +++++++++------- apps/web/src/room-workspace.tsx | 76 +++++++++--- apps/web/src/workspace.tsx | 5 +- e2e/phone-workspace.e2e.ts | 63 ++++++++++ e2e/responsive-child-phone.e2e.ts | 42 +++++++ 9 files changed, 439 insertions(+), 142 deletions(-) diff --git a/apps/web/src/build-plan-button.css b/apps/web/src/build-plan-button.css index 2a26c63ab..ee64870a9 100644 --- a/apps/web/src/build-plan-button.css +++ b/apps/web/src/build-plan-button.css @@ -53,7 +53,66 @@ transform: translate(-50%, -50%); } -/* Phone: the glyph alone, without the pill, so it reads as status rather than a control. */ +/* Phone: the build's state and its action read as links in the top bar's subtitle. */ +.build-status { + display: inline-flex; + min-width: 0; + align-items: center; + gap: var(--spacing); +} + +.build-status-link { + position: relative; + display: inline-flex; + min-width: 0; + align-items: center; + gap: var(--spacing); + color: inherit; + font: inherit; + font-variant-numeric: tabular-nums; + white-space: nowrap; + touch-action: manipulation; + user-select: none; + -webkit-user-select: none; +} + +/* A thumb-sized target around the small text, as the capsule has. */ +.build-status-link::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: max(100%, var(--control-target-coarse)); + height: var(--control-target-coarse); + transform: translate(-50%, -50%); +} + +.build-status-link:active { + opacity: 0.6; +} + +/* Build plan and the action are things to do, so they take the brand's ink. */ +.build-status-link:not(:has(.build-capsule-label)) { + color: var(--color-brand-ink); + font-weight: var(--font-weight-medium); +} + +.build-status-link[aria-disabled="true"] { + color: inherit; + font-weight: inherit; +} + +.build-status-link .build-capsule-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.build-status-link .build-glyph { + width: calc(var(--spacing) * 3); + height: calc(var(--spacing) * 3); +} + +/* Compact: the glyph alone, without the pill, so it reads as status rather than a control. */ .build-capsule[data-compact] { justify-content: center; width: var(--control-height-compact); diff --git a/apps/web/src/build-plan-button.tsx b/apps/web/src/build-plan-button.tsx index bc203acaf..c62735138 100644 --- a/apps/web/src/build-plan-button.tsx +++ b/apps/web/src/build-plan-button.tsx @@ -1,4 +1,4 @@ -import { ClockIcon, LoaderIcon, ProgressRingIcon, WarningIcon, WrenchIcon } from "@chopin/icons"; +import { ClockIcon, LoaderIcon, ProgressRingIcon, WarningIcon } from "@chopin/icons"; import { useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from "react"; import { @@ -342,7 +342,7 @@ export function BuildPlanButton( onOpen: () => void; /** Phone: touch has no tooltip, so a tap that cannot act shows this reason instead. */ onExplain?: (reason: string) => void; - /** A phone's top bar: Build plan shows as its glyph. */ + /** A phone's top bar: the status and its action read as links in the title's subtitle. */ phone?: boolean; /** Build can open; until then a phone tap on the glyph explains instead. */ tabReady?: boolean; @@ -394,9 +394,8 @@ export function BuildPlanButton( {blocked && {blocked}} {live} @@ -426,18 +424,18 @@ export function BuildPlanButton( : look.kind === "failed" && look.retry && canEdit ? { name: "Retry", run: build.rebuild } : look.kind === "stale" && look.build && canEdit - // On a phone the Build tab sits just below, so the action names what it builds. - ? { name: compact ? "Build changes" : "Build", run: build.rebuild } + // Compact names what it builds; a phone's subtitle follows the status, which already says. + ? { name: compact && !phone ? "Build changes" : "Build", run: build.rebuild } : undefined; let refused = action?.run === build.rebuild ? build.rebuildError : undefined; let busy = build.busy || build.rebuilding; return ( <> - + {look.tip && {look.tip}} {action && ( <> + {phone && } {/* Stays focusable while blocked or busy; aria-disabled and aria-busy say why. */} + ) + : hostsTrigger && trigger && ( + + )} + + {child ?
: children} + + ); +} + +/** + * A document's title, subtitle and trailing controls in the phone bar. An open child renders its + * own into the parent's bar, with its parent as the subtitle. + */ +export function PhoneHeaderContent( + { + archivedAt, + canManage, + comments = 0, + context, + editing, + editingDocument, + label, + members, + onAction, + onComments, + onEditingChange, + onRenamed, + project, + room, + }: PhoneHeaderDocument, +) { + let center = useRef(null); // Also runs as a save starts, not after it: keys typed while the rename travels would // otherwise land in a field that is about to close. let handOff = () => { // Naming a new document leads into writing it, unless the user already tapped elsewhere. - if (editing === "new" && header.current?.contains(document.activeElement)) { - header.current.closest(".workspace-root") + if (editing === "new" && center.current?.contains(document.activeElement)) { + center.current.closest(".workspace-root") ?.querySelector(".plan-content[contenteditable='true']") ?.focus(); } @@ -85,25 +141,16 @@ export function PhoneHeader( handOff(); onEditingChange(); }; + let status = archivedAt + ? Archived + : editingDocument + ? Editing + : context; + let people = peopleHere(members); return ( -
-
- {hostsTrigger && trigger && ( - - )} -
-
+ <> +
{editing && canManage && !archivedAt ? ( @@ -114,7 +161,7 @@ export function PhoneHeader( // The body took focus as the save started; a refusal needs the field back. onErrorChange={error => error !== undefined - && header.current?.querySelector(".document-title-input") + && center.current?.querySelector(".document-title-input") ?.focus()} onSavingChange={saving => saving && handOff()} replay={editing === "new"} @@ -125,17 +172,37 @@ export function PhoneHeader( /> ) : {label}} - {archivedAt - ? Archived - : editingDocument && Editing}
- {/* The title waits for the heading to scroll away; the mode must show regardless. */} - {editingDocument && !archivedAt && ( - + {(project || status) && ( +
+ {project && {project}} + {project && status && } + {status} +
)}
- {build} + {people.length > 0 && ( +
+ {people.slice(0, 2).map(handle => ( + + ))} + {people.length > 2 && ( + + )} +
+ )} {comments > 0 && onComments && (
-
+ ); } diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index dd37122b3..df4a4e213 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -42,34 +42,11 @@ padding-inline: env(safe-area-inset-left) env(safe-area-inset-right); } -/* - * A child's stack bar sits under the top bar and tucks away with it, leaving the screen entirely. - * The child card runs from the top edge like any phone pane, so nothing shows a second band - * through the glass, and its document starts below both bars. - */ -.workspace-root[data-workspace-phone] .workspace-stack-bar { - z-index: 21; - top: var(--phone-top-chrome); - transition: - opacity var(--child-close-dur) var(--child-ease), - transform var(--phone-bar-dur) var(--ease-out), - visibility 0s; -} - -.workspace-root[data-workspace-phone][data-workspace-bar="hidden"] .workspace-stack-bar { - visibility: hidden; - opacity: 0; - transform: translateY(calc(-1 * (var(--phone-top-chrome) + var(--child-bar-height)))); - transition: - opacity var(--phone-bar-dur) var(--ease-out), - transform var(--phone-bar-dur) var(--ease-out), - visibility 0s linear var(--phone-bar-dur); -} - +/* The child card runs from the top edge like any phone pane, under the bar it has taken. */ .workspace-root[data-workspace-phone][data-workspace-mode] .workspace-document-stack .anchored-child-card { - --document-top-inset: calc(var(--phone-pane-top) + var(--child-bar-height)); + --document-top-inset: var(--phone-pane-top); inset: 0; } @@ -156,40 +133,40 @@ pointer-events: none; } -/* The small title waits until the document's own title has scrolled away. */ +/* + * The title waits until the document's own title has scrolled away; meanwhile the subtitle, + * which names the project and the build, settles onto the bar's centre line on its own. + */ .phone-top-bar-center { - display: grid; + display: flex; min-width: 0; - place-items: center; + flex-direction: column; + align-items: center; + transition: transform var(--duration-base) var(--ease-out); } .phone-top-bar-center > * { - grid-area: 1 / 1; + max-width: 100%; } -.phone-top-bar-title, -.phone-top-bar-mode { +.phone-top-bar-title { transition: opacity var(--duration-base) var(--ease-out); } -.workspace-root[data-workspace-title="hidden"] .phone-top-bar-title { - opacity: 0; +.workspace-root[data-workspace-title="hidden"] .phone-top-bar:not([data-phone-child]) { + .phone-top-bar-title:not(:has(input)) { + opacity: 0; + } + + .phone-top-bar-center:not(:has(input)) { + transform: translateY(calc(var(--text-sm) * -0.6)); + } } -/* Stands in for the hidden title while editing, so the mode is always on screen. */ +/* Editing names itself in the subtitle, so the mode is always on screen. */ .phone-top-bar-mode { - padding: calc(var(--spacing) / 2) calc(var(--spacing) * 2); - border-radius: var(--radius-full); - background: var(--color-brand-wash); color: var(--color-brand-ink); - font-size: var(--text-2xs); font-weight: var(--font-weight-medium); - opacity: 0; - pointer-events: none; -} - -.workspace-root[data-workspace-title="hidden"] .phone-top-bar-mode { - opacity: 1; } .phone-top-bar-slot { @@ -230,11 +207,50 @@ } .phone-top-bar-subtitle { + display: flex; + max-width: 100%; + min-width: 0; + align-items: center; + gap: var(--spacing); + font-size: var(--text-2xs); + line-height: var(--text-2xs--line-height); + white-space: nowrap; + color: var(--color-text-tertiary); +} + +.phone-top-bar-subtitle > .truncate { + min-width: 0; +} + +/* Links in the subtitle reach a thumb through their own invisible target, not their line box. */ +.room-header.phone-top-bar .phone-top-bar-subtitle button { + min-width: 0; + min-height: 0; +} + +/* The project gives way first; the build's state is what changes. */ +.phone-top-bar-project { + flex: 0 1 auto; +} + +/* With the bar's gap, the build's state in the subtitle keeps more than 12px clear of the faces. */ +.phone-top-bar-people { + display: flex; + flex: none; + align-items: center; + padding-inline: calc(var(--spacing) * 2.5) var(--spacing); font-size: var(--text-2xs); - font-weight: var(--font-weight-normal); color: var(--color-text-tertiary); } +.phone-top-bar-people > * + * { + margin-left: calc(var(--spacing) * -1.5); +} + +.phone-top-bar-people > .phone-top-bar-more { + margin-left: var(--spacing); +} + /* Bottom tab bar ------------------------------------------------------- */ .phone-tab-row { @@ -651,7 +667,6 @@ } .workspace-root[data-workspace-phone] > .phone-top-bar, - .workspace-root[data-workspace-phone] .workspace-stack-bar, .phone-tab-indicator, .phone-tab-row, .phone-tab-bar, diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 23a4bceed..8f017478c 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -81,7 +81,7 @@ import { newestDocumentMetadata } from "./document-actions"; import { DocumentActionsMenu } from "./document-actions-menu"; import { DocumentRename } from "./document-rename"; import { browserStorage, rememberOpened } from "./documents-sheet-model"; -import { PhoneHeader } from "./phone-header"; +import { PhoneHeader, PhoneHeaderContent } from "./phone-header"; import { DocumentSkeleton } from "./phone-skeleton"; import { useEditorFocus, useKeyboardOpen } from "./keyboard-open"; import { motionContract } from "./motion-contract"; @@ -1427,7 +1427,33 @@ export function RoomWorkspace(

{announcement.text}

- {childTitle?.slot && createPortal(childTitleControl, childTitle.slot)} + {childTitle?.slot && (childTitle.phone + ? createPortal( + onDocumentAction(room, action)} + onComments={() => { + childChrome?.showDocument(); + openCommentList(room); + }} + onEditingChange={setTitleEdit} + onRenamed={updateMetadata} + context={ + + in {presentation.parent?.label ?? childTitle.parent} + + } + room={room} + />, + childTitle.slot, + ) + : createPortal(childTitleControl, childTitle.slot))} {childTitle?.motion === "lift" && (
onDocumentAction(room, action)} - onComments={() => { - // The list stands over the document, so it opens from any tab onto it. - selectDestination("plan"); - openCommentList(room); - }} - onEditingChange={setTitleEdit} - onRenamed={updateMetadata} presentation={presentation} - room={room} - build={buildSlot} - /> + > + onDocumentAction(room, action)} + onComments={() => { + // The list stands over the document, so it opens from any tab onto it. + selectDestination("plan"); + openCommentList(room); + }} + onEditingChange={setTitleEdit} + onRenamed={updateMetadata} + project={repository.name} + room={room} + /> + ) :
} title={ diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index 716ded4c1..6364048a0 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -303,9 +303,10 @@ export function Workspace( let liftCovered = lift && !childClosing; // Split Zoom puts the child at the end of the title bar's path and folds the views away, since // a child has none. Compact has no title bar, so it keeps the stack bar, on every view, so a - // child left open under Decisions or Build is still in sight and one click away. + // child left open under Decisions or Build is still in sight and one click away. A phone's top + // bar becomes the child's, with Back, so it needs no stack bar. let zoom = !!nested && !lift; - let stackBar = zoom && mode !== "split"; + let stackBar = zoom && mode !== "split" && !phone; let planCovered = zoom && view === "plan"; let viewsFolded = zoom && mode === "split" && view === "plan" && !childClosing; // Phones crossfade rather than cut under reduced motion, so only keyboard input is immediate. diff --git a/e2e/phone-workspace.e2e.ts b/e2e/phone-workspace.e2e.ts index ca14da5fd..14f0abe68 100644 --- a/e2e/phone-workspace.e2e.ts +++ b/e2e/phone-workspace.e2e.ts @@ -1,3 +1,4 @@ +import { titleChannel } from "./database"; import { expect, openIsolatedRoom, test } from "./room"; import { expectNoHorizontalOverflow, RESPONSIVE_SOURCE } from "./responsive"; import { installVisualViewport, setVisualViewport } from "./visual-viewport"; @@ -147,6 +148,68 @@ test("the top bar hides while reading down and returns on scroll up", async ({ j await expect(page.locator(".workspace-root")).not.toHaveAttribute("data-workspace-bar", "hidden"); }); +/** Horizontal space from the subtitle's last build control to the first face beside it. */ +function buildToFaces(page: Page): Promise { + return topBar(page).evaluate(bar => { + let links = [...bar.querySelectorAll(".phone-top-bar-subtitle .build-status-link")]; + let face = bar.querySelector(".phone-top-bar-people > *")!.getBoundingClientRect(); + return face.left - Math.max(...links.map(link => link.getBoundingClientRect().right)); + }); +} + +for (let width of [390, 430]) { + test(`at ${width} the top bar names the project and build beside who is here`, async ({ baseURL, join, room, seed }) => { + await seed(RESPONSIVE_SOURCE); + let viewport = { width, height: width === 390 ? 844 : 932 }; + let page = await join("ana", { ...PHONE, viewport }); + await join("bo", { viewport: { width: 1440, height: 900 } }); + let bar = topBar(page); + let people = bar.getByRole("group", { name: /^People here: / }); + await expect(people).toHaveAccessibleName(/\bana\b/); + await expect(people).toHaveAccessibleName(/\bbo\b/); + let subtitle = bar.locator(".phone-top-bar-subtitle"); + await expect(subtitle).toContainText("score"); + let plan = subtitle.getByRole("button", { name: "Build plan", exact: true }); + await expect(plan).toBeVisible(); + // The text is small; its invisible target is a thumb's. + expect(await plan.evaluate(element => getComputedStyle(element, "::after").height)) + .toBe("44px"); + + // At the top the title waits, and the subtitle alone sits on the bar's centre line. + await expect(bar.locator(".phone-top-bar-title")).toHaveCSS("opacity", "0"); + await expect.poll(async () => { + let line = (await subtitle.boundingBox())!; + let box = (await bar.boundingBox())!; + return Math.abs(line.y + line.height / 2 - (box.y + box.height / 2)); + }).toBeLessThanOrEqual(2); + expect(await buildToFaces(page)).toBeGreaterThanOrEqual(12); + + // A long title truncates short of the faces, which keep their 12px from the build. + let long = `A very long document title that cannot fit beside the people ${room.slice(0, 6)}`; + await titleChannel(Number(new URL(baseURL!).port), room, long); + await page.reload(); + await expect(bar).toContainText(long); + await page.locator("[data-plan-scroll]").evaluate(element => element.scrollTo({ top: 400 })); + await page.locator("[data-plan-scroll]").evaluate(element => element.scrollTo({ top: 300 })); + let title = bar.locator(".phone-top-bar-title > .truncate"); + await expect(bar.locator(".phone-top-bar-title")).toHaveCSS("opacity", "1"); + expect(await title.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true); + let titleBox = (await title.boundingBox())!; + let face = (await people.boundingBox())!; + expect(titleBox.x + titleBox.width).toBeLessThanOrEqual(face.x); + expect(await buildToFaces(page)).toBeGreaterThanOrEqual(12); + for ( + let control of [ + bar.getByRole("button", { name: "Documents", exact: true }), + bar.getByRole("button", { name: /^Actions for / }), + ] + ) { + let box = (await control.boundingBox())!; + expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(44); + } + }); +} + /** Space between the current tab's label and its pill's rounded edge, at the label's lower part. */ async function labelClearance(page: Page): Promise { return page.locator(".phone-tab-bar").evaluate(bar => { diff --git a/e2e/responsive-child-phone.e2e.ts b/e2e/responsive-child-phone.e2e.ts index 091ea31a4..31ddba3c3 100644 --- a/e2e/responsive-child-phone.e2e.ts +++ b/e2e/responsive-child-phone.e2e.ts @@ -155,6 +155,48 @@ test("a child opens for reading on a phone and the tab bar's pencil edits it", a } }); +test("a child takes the phone's top bar: Back, its own title and actions, and its parent", async ({ baseURL, browser, room, seed }) => { + await seed("# Parent\n\nThe parent stays put beneath its child.\n"); + let { context, page, surface } = await openChild(browser, baseURL!, room, "ana", PHONE); + try { + let parent = `Test ${room.slice(0, 8)}`; + let child = (await surface.getAttribute("aria-label"))!.replace("Child document: ", ""); + let bar = page.locator(".phone-top-bar"); + await expect(bar).toHaveCount(1); + // The bar replaces the stack bar the compact layout keeps. + await expect(page.getByRole("navigation", { name: "Document stack" })).toHaveCount(0); + await expect(bar.getByRole("button", { name: "Documents", exact: true })).toHaveCount(0); + await expect(bar.locator(".phone-top-bar-title")).toHaveText(child); + await expect(bar.locator(".phone-top-bar-title")).toHaveCSS("opacity", "1"); + await expect(bar.locator(".phone-top-bar-subtitle")).toHaveText(`in ${parent}`); + await expect(bar.getByRole("group", { name: /^People here: .*\bana\b/ })).toBeVisible(); + // The document under the bar starts below it. + let barBox = (await bar.boundingBox())!; + let heading = (await surface.getByRole("heading", { level: 1 }).boundingBox())!; + expect(heading.y).toBeGreaterThanOrEqual(barBox.y + barBox.height); + + // Its actions are the child's. + await bar.getByRole("button", { name: `Actions for ${child}` }).tap(); + await page.getByRole("button", { name: "Rename", exact: true }).tap(); + let field = page.getByRole("textbox", { name: "Document title" }); + await expect(field).toBeFocused(); + await expect(field).toHaveValue(child); + await field.press("Escape"); + await expect(field).toHaveCount(0); + + let back = bar.getByRole("button", { name: `Back to ${parent}` }); + let backBox = (await back.boundingBox())!; + expect(Math.min(backBox.width, backBox.height)).toBeGreaterThanOrEqual(44); + await back.tap(); + await expect(surface).toHaveCount(0); + await expect(page).toHaveURL(url => url.pathname.endsWith(`/test-${room.slice(0, 8)}`)); + await expect(bar.getByRole("button", { name: "Documents", exact: true })).toBeVisible(); + await expect(bar.locator(".phone-top-bar-subtitle")).toContainText("score"); + } finally { + await context.close(); + } +}); + test("a child's formatting docks above the keyboard in the parent's bar", async ({ baseURL, browser, room, seed }) => { await seed("# Parent\n\nThe parent stays put beneath its child.\n"); let { context, page, surface } = await openChild( From 07f1938df3908cf45e95f5668b98ba64d8ebf563 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:54:50 +0100 Subject: [PATCH 2/6] Renew design-contract review for the phone header's workspace change workspace.tsx only stops drawing the stack bar on phones; the reviewed feedback and chatPresence class boundaries are unchanged. Co-Authored-By: Claude Opus 5.5 --- scripts/design-contract/exceptions/dynamic-web.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index bb2524139..e1fe8f449 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -276,7 +276,7 @@ 1 ] ], - "sourceHash": "6dcdd2c5d6243bdc297c311742fc47e8e5da433d201b6e1b728c42c714d9a9c4" + "sourceHash": "443502c52a0e791e696f3624e02ab96a4a85891cf3b3567ec42957dd1407de57" }, { "file": "apps/web/src/navigation-dialog.tsx", From 0c8ae33d631b3153a15c894e759576df64b1f180 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:19:15 +0100 Subject: [PATCH 3/6] Measure the phone bar's subtitle links by their invisible target Co-Authored-By: Claude Opus 5.5 --- e2e/phone-workspace.e2e.ts | 16 ++++++++++++---- e2e/responsive-child-phone.e2e.ts | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/e2e/phone-workspace.e2e.ts b/e2e/phone-workspace.e2e.ts index 14f0abe68..f5fabd6dd 100644 --- a/e2e/phone-workspace.e2e.ts +++ b/e2e/phone-workspace.e2e.ts @@ -45,10 +45,18 @@ test("a phone gets full-bleed panes under a top bar and a bottom tab bar", async await expect(page.getByRole("button", { name: "Show sidebar" })).toHaveCount(0); await expect(topBar(page).getByRole("button", { name: "Documents", exact: true })).toBeVisible(); await expect(topBar(page).getByRole("button", { name: /^Actions for / })).toBeVisible(); - for (let control of await topBar(page).getByRole("button").all()) { + // The subtitle's build links are small text with a thumb-sized invisible target of their own. + for ( + let control of await topBar(page).getByRole("button") + .and(page.locator(":not(.phone-top-bar-subtitle *)")).all() + ) { let box = await control.boundingBox(); - expect(box!.height).toBeGreaterThanOrEqual(44); - expect(box!.width).toBeGreaterThanOrEqual(44); + expect(box!.height).toBeGreaterThanOrEqual(43.5); + expect(box!.width).toBeGreaterThanOrEqual(43.5); + } + for (let link of await topBar(page).locator(".phone-top-bar-subtitle button").all()) { + expect(await link.evaluate(element => getComputedStyle(element, "::after").height)) + .toBe("44px"); } await expectNoHorizontalOverflow(page); }); @@ -205,7 +213,7 @@ for (let width of [390, 430]) { ] ) { let box = (await control.boundingBox())!; - expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(44); + expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(43.5); } }); } diff --git a/e2e/responsive-child-phone.e2e.ts b/e2e/responsive-child-phone.e2e.ts index 31ddba3c3..80aa8c6f6 100644 --- a/e2e/responsive-child-phone.e2e.ts +++ b/e2e/responsive-child-phone.e2e.ts @@ -186,7 +186,7 @@ test("a child takes the phone's top bar: Back, its own title and actions, and it let back = bar.getByRole("button", { name: `Back to ${parent}` }); let backBox = (await back.boundingBox())!; - expect(Math.min(backBox.width, backBox.height)).toBeGreaterThanOrEqual(44); + expect(Math.min(backBox.width, backBox.height)).toBeGreaterThanOrEqual(43.5); await back.tap(); await expect(surface).toHaveCount(0); await expect(page).toHaveURL(url => url.pathname.endsWith(`/test-${room.slice(0, 8)}`)); From 4b4ff37c5505d25f67d7f9ec8ed574cfcfdeedb7 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:00:09 +0100 Subject: [PATCH 4/6] Keep the child's place beside Editing, settle the subtitle without motion, and size phone faces to 20px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduced motion drops the subtitle's slide. A child's subtitle reads "in · Editing" while editing rather than losing its parent. Faces are 20px, as in the jig; they are not controls, so they need no target of their own. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-header.tsx | 15 ++++++++------- apps/web/src/phone-workspace.css | 5 +++-- apps/web/src/room-workspace.tsx | 8 ++------ e2e/responsive-child-phone.e2e.ts | 4 ++++ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/web/src/phone-header.tsx b/apps/web/src/phone-header.tsx index d988968cd..c33eeb05f 100644 --- a/apps/web/src/phone-header.tsx +++ b/apps/web/src/phone-header.tsx @@ -43,9 +43,10 @@ type PhoneHeaderDocument = { onEditingChange: (editing?: TitleEdit) => void; onRenamed: (channel: DocumentMetadata) => void; room: string; - /** What the subtitle says after the project: the build's status, or the child's parent. */ + /** What the subtitle says after its lead: the build's status. Editing or Archived replace it. */ context?: ReactNode; - project?: string; + /** The subtitle's lead, which always shows: the project, or for a child, where it sits. */ + lead?: string; }; export function PhoneHeader( @@ -122,7 +123,7 @@ export function PhoneHeaderContent( onComments, onEditingChange, onRenamed, - project, + lead, room, }: PhoneHeaderDocument, ) { @@ -173,10 +174,10 @@ export function PhoneHeaderContent( ) : {label}}
- {(project || status) && ( + {(lead || status) && (
- {project && {project}} - {project && status && } + {lead && {lead}} + {lead && status && } {status}
)} @@ -193,7 +194,7 @@ export function PhoneHeaderContent( handle={handle} key={handle.toLowerCase()} ring="page" - size={24} + size={20} textSize="2xs" titled={false} /> diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index df4a4e213..9101a7e6b 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -228,8 +228,8 @@ min-height: 0; } -/* The project gives way first; the build's state is what changes. */ -.phone-top-bar-project { +/* The lead gives way first; the build's state is what changes. */ +.phone-top-bar-lead { flex: 0 1 auto; } @@ -667,6 +667,7 @@ } .workspace-root[data-workspace-phone] > .phone-top-bar, + .phone-top-bar-center, .phone-tab-indicator, .phone-tab-row, .phone-tab-bar, diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 8f017478c..0c937ad74 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -1444,11 +1444,7 @@ export function RoomWorkspace( }} onEditingChange={setTitleEdit} onRenamed={updateMetadata} - context={ - - in {presentation.parent?.label ?? childTitle.parent} - - } + lead={`in ${presentation.parent?.label ?? childTitle.parent}`} room={room} />, childTitle.slot, @@ -1643,7 +1639,7 @@ export function RoomWorkspace( }} onEditingChange={setTitleEdit} onRenamed={updateMetadata} - project={repository.name} + lead={repository.name} room={room} /> diff --git a/e2e/responsive-child-phone.e2e.ts b/e2e/responsive-child-phone.e2e.ts index 80aa8c6f6..925a0105f 100644 --- a/e2e/responsive-child-phone.e2e.ts +++ b/e2e/responsive-child-phone.e2e.ts @@ -136,6 +136,10 @@ test("a child opens for reading on a phone and the tab bar's pencil edits it", a await expect(childText(surface)).toBeFocused(); await expect(done(page)).toBeVisible(); await expect(page.locator(".phone-top-bar-mode")).toHaveText("Editing"); + // Editing joins the child's place rather than replacing it. + await expect(page.locator(".phone-top-bar-subtitle")).toHaveText( + new RegExp(`^in Test ${room.slice(0, 8)}\\s*·\\s*Editing$`), + ); // The parent beneath keeps reading. await expect(parentText(page)).toHaveAttribute("contenteditable", "false"); await page.keyboard.type("Typed "); From 00da43a35fc7f9242dbb5cebd27a1e43733f80c7 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:49:00 +0100 Subject: [PATCH 5/6] Give a crowded phone title room from the faces first When the title truncates beside two faces, the bar shows one face and counts the rest before anything else gives way. It returns to two only once the title would still fit. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-header.tsx | 35 +++++++++++++++++++++++++++++++---- e2e/phone-workspace.e2e.ts | 6 ++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/web/src/phone-header.tsx b/apps/web/src/phone-header.tsx index c33eeb05f..4c3167a0d 100644 --- a/apps/web/src/phone-header.tsx +++ b/apps/web/src/phone-header.tsx @@ -4,7 +4,7 @@ * Back on the left, and the child room renders its own title and trailing controls into it. */ -import { useContext, useEffect, useLayoutEffect, useRef } from "react"; +import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Face } from "@chopin/editor"; import { ArrowLeftIcon, CommentIcon, DocumentsIcon, EllipsisIcon } from "@chopin/icons"; import type { ReactNode } from "react"; @@ -21,6 +21,12 @@ import type { DocumentAction } from "./document-actions-menu"; import type { TitleEdit } from "./title-edit"; import type { WorkspacePresentation } from "./workspace-model"; +/** + * What a second face can give back to a centred title: its width less the overlap, from each + * side, since the bar keeps its sides equal while it can. + */ +const FACE_ROOM = 28; + // Warmed before the first tap, so opening the sheet never waits on its code. function prefetchDocumentsSheet() { void loadDocumentsSheet(); @@ -148,6 +154,24 @@ export function PhoneHeaderContent( ? Editing : context; let people = peopleHere(members); + // A title that needs room takes it from the faces first: one face, and the rest counted. + let [oneFace, setOneFace] = useState(false); + let crowd = people.length > 1; + useLayoutEffect(() => { + let title = center.current?.querySelector(".phone-top-bar-title > .truncate"); + if (!title || !crowd) { + setOneFace(false); + return; + } + // Back to two only once the title would still fit beside them, so it never flickers. + let measure = () => + setOneFace(one => title.scrollWidth > title.clientWidth - (one ? FACE_ROOM : 0)); + measure(); + let observer = new ResizeObserver(measure); + observer.observe(title); + return () => observer.disconnect(); + }, [crowd, editing, label]); + let shown = oneFace ? 1 : 2; return ( <> @@ -187,9 +211,10 @@ export function PhoneHeaderContent(
- {people.slice(0, 2).map(handle => ( + {people.slice(0, shown).map(handle => ( ))} - {people.length > 2 && ( - + {people.length > shown && ( + )}
)} diff --git a/e2e/phone-workspace.e2e.ts b/e2e/phone-workspace.e2e.ts index f5fabd6dd..160525033 100644 --- a/e2e/phone-workspace.e2e.ts +++ b/e2e/phone-workspace.e2e.ts @@ -191,6 +191,8 @@ for (let width of [390, 430]) { return Math.abs(line.y + line.height / 2 - (box.y + box.height / 2)); }).toBeLessThanOrEqual(2); expect(await buildToFaces(page)).toBeGreaterThanOrEqual(12); + let faces = people.locator(":scope > :not(.phone-top-bar-more)"); + await expect(faces).toHaveCount(2); // A long title truncates short of the faces, which keep their 12px from the build. let long = `A very long document title that cannot fit beside the people ${room.slice(0, 6)}`; @@ -205,6 +207,10 @@ for (let width of [390, 430]) { let titleBox = (await title.boundingBox())!; let face = (await people.boundingBox())!; expect(titleBox.x + titleBox.width).toBeLessThanOrEqual(face.x); + // Needing room, the title takes it from the faces first: one face, the other counted. + await expect(faces).toHaveCount(1); + await expect(people.locator(".phone-top-bar-more")).toHaveText("+1"); + await expect(people).toHaveAccessibleName(/\bbo\b/); expect(await buildToFaces(page)).toBeGreaterThanOrEqual(12); for ( let control of [ From de8a9720023f9f09de86c60bbc4ebcbd661bcba9 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:49:00 +0100 Subject: [PATCH 6/6] Show an open child's phone bar only on the Document tab Chat, Decisions and Build belong to the root, so off Document the bar is the root's again, with its Documents button, title, build status and actions. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/room-workspace.tsx | 34 ++++++++++++++++--------------- e2e/responsive-child-phone.e2e.ts | 15 ++++++++++++++ 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 0c937ad74..b1f02cbb1 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -1509,20 +1509,21 @@ export function RoomWorkspace( ? { motion: presentation.motion, slot: zoomChild ? childTitleSlot : null } : undefined; // Build plan or the build capsule sits beside the people here; a child open beside its parent - // has no build of its own. - let buildSlot = profile.implementation && presentation.type !== "parent-with-child" && ( - setBuildHover(on ? "capsule" : undefined)} - onOpen={() => selectDestination("build")} - tabReady={build.tabReady} - /> - ); + // has no build of its own. A phone shows this room's bar only off Document, so it keeps it. + let buildSlot = profile.implementation && (presentation.type !== "parent-with-child" || phone) + && ( + setBuildHover(on ? "capsule" : undefined)} + onOpen={() => selectDestination("build")} + tabReady={build.tabReady} + /> + ); // The tab bar's pencil edits the document on screen: an open child, or else this one. let barEdit = childOpen @@ -1616,7 +1617,8 @@ export function RoomWorkspace( header={phone ? ( onDocumentAction(room, action)} diff --git a/e2e/responsive-child-phone.e2e.ts b/e2e/responsive-child-phone.e2e.ts index 925a0105f..ec1dca950 100644 --- a/e2e/responsive-child-phone.e2e.ts +++ b/e2e/responsive-child-phone.e2e.ts @@ -188,6 +188,21 @@ test("a child takes the phone's top bar: Back, its own title and actions, and it await field.press("Escape"); await expect(field).toHaveCount(0); + // Off Document the bar is the root's again: its Documents, title and build. + let tabs = page.getByRole("navigation", { name: "Workspace view" }); + for (let tab of [/^Chat/, /^Decisions/]) { + await tabs.getByRole("button", { name: tab }).tap(); + await expect(bar.getByRole("button", { name: "Documents", exact: true })).toBeVisible(); + await expect(bar.getByRole("button", { name: /^Back to / })).toHaveCount(0); + await expect(bar.locator(".phone-top-bar-title")).toHaveText(parent); + await expect(bar.locator(".phone-top-bar-subtitle")).toContainText("score"); + await expect(bar.getByRole("button", { name: `Actions for ${parent}` })).toBeVisible(); + } + await tabs.getByRole("button", { name: "Document", exact: true }).tap(); + await expect(surface).toBeVisible(); + await expect(bar.locator(".phone-top-bar-title")).toHaveText(child); + await expect(bar.locator(".phone-top-bar-subtitle")).toHaveText(`in ${parent}`); + let back = bar.getByRole("button", { name: `Back to ${parent}` }); let backBox = (await back.boundingBox())!; expect(Math.min(backBox.width, backBox.height)).toBeGreaterThanOrEqual(43.5);