From 044b0f958133c064e4d5521c1a6509d09b298f3b Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:46:46 +0100 Subject: [PATCH 1/6] Resize the phone tab bar and minimise it while reading The phone tab bar takes iOS 26 proportions: a 62px bar with 10px semibold labels, a 54px action circle, and a selection pill whose corners are concentric with the bar (pill radius = bar radius - inset), so labels clear the pill's curve at every phone width. Reading down a pane shrinks the bar to one circle showing the current tab, on the same scroll signal that tucks the top bar away; scrolling up, focus, or a tap restores it. Reduced motion keeps the full bar. The first-paint skeleton follows the new geometry. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-skeleton.css | 33 +++-- apps/web/src/phone-workspace.css | 124 ++++++++++++++---- apps/web/src/phone-workspace.tsx | 63 ++++++++- apps/web/src/workspace.tsx | 2 + e2e/phone-first-load.e2e.ts | 69 +++++----- e2e/phone-workspace.e2e.ts | 109 +++++++++++++++ packages/visuals/theme.css | 27 +++- .../exceptions/preserved-values.json | 13 ++ 8 files changed, 363 insertions(+), 77 deletions(-) diff --git a/apps/web/src/phone-skeleton.css b/apps/web/src/phone-skeleton.css index 2e89bb15f..a0af0306f 100644 --- a/apps/web/src/phone-skeleton.css +++ b/apps/web/src/phone-skeleton.css @@ -121,13 +121,14 @@ var(--phone-tab-row-max) ); margin-inline: auto; - gap: var(--phone-tab-bar-gap); + align-items: center; + gap: var(--phone-tab-action-gap); } .phone-skeleton-bar, .phone-skeleton-action { height: var(--phone-tab-bar-height); - border-radius: var(--radius-full); + border-radius: var(--radius-phone-tab-bar); background: var(--color-glass); -webkit-backdrop-filter: var(--glass-filter); backdrop-filter: var(--glass-filter); @@ -140,7 +141,7 @@ display: grid; flex: 1; grid-template-columns: repeat(4, minmax(0, 1fr)); - padding: var(--spacing); + padding: var(--phone-tab-bar-inset); /* Each tab: an icon-sized mark over a short label. */ & > span { @@ -148,7 +149,7 @@ flex-direction: column; align-items: center; justify-content: center; - gap: calc(var(--spacing) * 1.5); + gap: calc(var(--spacing) * 0.75); } & > span::before, @@ -167,18 +168,24 @@ & > span::after { width: calc(var(--spacing) * 9); - height: calc(var(--spacing) * 2); + height: var(--phone-tab-label-line); } } .phone-skeleton-action { flex: none; - width: var(--phone-tab-bar-height); + width: var(--phone-tab-action-size); + height: var(--phone-tab-action-size); + border-radius: var(--radius-full); } @media (max-width: 359px) { + .phone-skeleton { + --phone-tab-action-gap: calc(var(--spacing) * 1.5); + --phone-tab-action-size: calc(var(--spacing) * 12); + } + .phone-skeleton-tabs { - gap: calc(var(--spacing) * 1.5); width: calc(100% - var(--spacing) * 4); } } @@ -188,15 +195,15 @@ .phone-skeleton { --phone-bar-height: calc(var(--spacing) * 9); --phone-tab-bar-height: calc(var(--control-target-coarse) + var(--spacing)); + --phone-tab-bar-inset: var(--phone-tab-bar-inset-compact); --phone-tab-bar-gap: calc(var(--spacing) * 1.5); + --phone-tab-action-gap: calc(var(--spacing) * 1.5); + --phone-tab-action-size: var(--phone-tab-bar-height); } - .phone-skeleton-bar { - padding: calc(var(--spacing) / 2); - - & > span { - flex-direction: row; - } + .phone-skeleton-bar > span { + flex-direction: row; + gap: calc(var(--spacing) * 1.5); } } diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index 97369a04e..29cc2779a 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -251,7 +251,8 @@ margin-inline: auto; display: flex; align-items: center; - gap: var(--phone-tab-bar-gap); + justify-content: space-between; + gap: var(--phone-tab-action-gap); transition: transform var(--tab-slide-dur) var(--tab-slide-ease), visibility 0s; } @@ -277,11 +278,62 @@ .phone-tab-bar { position: relative; display: grid; - flex: 1; + flex: none; grid-template-columns: repeat(var(--phone-tab-count, 3), minmax(0, 1fr)); + width: 100%; height: var(--phone-tab-bar-height); - padding: var(--spacing); - border-radius: var(--radius-full); + padding: var(--phone-tab-bar-inset); + overflow: hidden; + border-radius: var(--radius-phone-tab-bar); + transition: + width var(--tab-slide-dur) var(--tab-slide-ease), + height var(--tab-slide-dur) var(--tab-slide-ease), + border-radius var(--tab-slide-dur) var(--tab-slide-ease); +} + +/* A width rather than flex growth, so minimising can animate it. */ +.phone-tab-row:has(> .phone-tab-action) > .phone-tab-bar { + width: calc(100% - var(--phone-tab-action-size) - var(--phone-tab-action-gap)); +} + +/* + * Reading down a pane minimises the bar to one circle that shows the current tab, as the top + * bar tucks away on the same scroll. Scrolling up, focus, or a tap on the circle restores it. + */ +.phone-tab-row[data-phone-tab-minimised] > .phone-tab-bar { + width: var(--phone-tab-bar-minimised); + height: var(--phone-tab-bar-minimised); + border-radius: var(--radius-phone-tab-minimised); +} + +.phone-tab-row[data-phone-tab-minimised] > .phone-tab-action { + width: var(--phone-tab-bar-minimised); + height: var(--phone-tab-bar-minimised); +} + +.phone-tab-row[data-phone-tab-minimised] :is(.phone-tab, .phone-tab-indicator) { + opacity: 0; + pointer-events: none; +} + +.phone-tab-mini { + position: absolute; + z-index: 2; + inset: 0; + display: grid; + place-items: center; + color: var(--color-brand-ink); + opacity: 0; + pointer-events: none; + touch-action: manipulation; + transition: opacity var(--duration-fast) var(--ease-out); +} + +.phone-tab-row[data-phone-tab-minimised] .phone-tab-mini { + opacity: 1; + pointer-events: auto; + /* The circle's icon arrives once the tabs have faded, not over them. */ + transition-delay: var(--duration-fast); } .phone-tab-bar[hidden] { @@ -352,13 +404,15 @@ .phone-tab-indicator { position: absolute; - inset-block: var(--spacing); - left: var(--spacing); - width: calc((100% - var(--spacing) * 2) / var(--phone-tab-count, 3)); - border-radius: var(--radius-full); + inset-block: var(--phone-tab-bar-inset); + left: var(--phone-tab-bar-inset); + width: calc((100% - var(--phone-tab-bar-inset) * 2) / var(--phone-tab-count, 3)); + border-radius: var(--radius-phone-tab-pill); background: var(--color-selected); transform: translateX(calc(var(--phone-tab-index, 0) * 100%)); - transition: transform var(--tab-slide-dur) var(--tab-slide-ease); + transition: + transform var(--tab-slide-dur) var(--tab-slide-ease), + opacity var(--duration-fast) var(--ease-out); } .phone-tab { @@ -369,16 +423,20 @@ flex-direction: column; align-items: center; justify-content: center; - gap: calc(var(--spacing) / 2); - border-radius: var(--radius-full); - color: var(--color-text-tertiary); - font-size: var(--text-2xs); - font-weight: var(--font-weight-medium); + gap: calc(var(--spacing) * 0.75); + border-radius: var(--radius-phone-tab-pill); + color: var(--color-text-secondary); + font-size: var(--phone-tab-label-size); + font-weight: var(--font-weight-semibold); + line-height: var(--phone-tab-label-line); + letter-spacing: 0.01em; touch-action: manipulation; user-select: none; -webkit-touch-callout: none; -webkit-user-select: none; - transition: color var(--duration-base) var(--ease-out); + transition: + color var(--duration-base) var(--ease-out), + opacity var(--duration-fast) var(--ease-out); } .phone-tab[aria-current="page"] { @@ -389,15 +447,19 @@ opacity: var(--opacity-quiet); } -/* The narrowest phones give up gaps and label tracking, never target size. */ +/* The narrowest phones give up gaps, label tracking and some of the action, never target size. */ @media (max-width: 359px) { + .workspace-root[data-workspace-phone] { + --phone-tab-action-gap: calc(var(--spacing) * 1.5); + --phone-tab-action-size: calc(var(--spacing) * 12); + } + .phone-tab-row { - gap: calc(var(--spacing) * 1.5); width: calc(100% - var(--spacing) * 4); } .phone-tab-label { - letter-spacing: -0.02em; + letter-spacing: normal; } } @@ -445,11 +507,14 @@ display: grid; flex: none; place-items: center; - width: var(--phone-tab-bar-height); - height: var(--phone-tab-bar-height); + width: var(--phone-tab-action-size); + height: var(--phone-tab-action-size); border-radius: var(--radius-full); color: var(--color-text-primary); touch-action: manipulation; + transition: + width var(--tab-slide-dur) var(--tab-slide-ease), + height var(--tab-slide-dur) var(--tab-slide-ease); } /* Landscape: a short window keeps chrome low, with labels beside their icons. */ @@ -458,22 +523,23 @@ --phone-bar-height: calc(var(--spacing) * 9); /* Tabs keep the full 44px target inside the bar's 2px inset. */ --phone-tab-bar-height: calc(var(--control-target-coarse) + var(--spacing)); + --phone-tab-bar-inset: var(--phone-tab-bar-inset-compact); --phone-tab-bar-gap: calc(var(--spacing) * 1.5); + --phone-tab-action-gap: calc(var(--spacing) * 1.5); + --phone-tab-action-size: var(--phone-tab-bar-height); + --phone-tab-bar-minimised: var(--phone-tab-bar-height); } - .phone-tab-bar { - padding: calc(var(--spacing) / 2); - } - - .phone-tab-indicator { - inset-block: calc(var(--spacing) / 2); - left: calc(var(--spacing) / 2); - width: calc((100% - var(--spacing)) / var(--phone-tab-count, 3)); + /* The bar is a capsule at this height, and the pill stays concentric inside the narrower inset. */ + :is(.phone-tab-indicator, .phone-tab) { + border-radius: var(--radius-phone-tab-pill-compact); } .phone-tab { flex-direction: row; gap: calc(var(--spacing) * 1.5); + font-size: var(--text-2xs); + letter-spacing: normal; } .phone-tab-icon [data-nucleo-icon] { @@ -562,6 +628,8 @@ .workspace-root[data-workspace-phone] .workspace-stack-bar, .phone-tab-indicator, .phone-tab-row, + .phone-tab-bar, + .phone-tab-action, .phone-tab-action > svg { transition: none; } diff --git a/apps/web/src/phone-workspace.tsx b/apps/web/src/phone-workspace.tsx index 1f4066fa4..c39c48f0b 100644 --- a/apps/web/src/phone-workspace.tsx +++ b/apps/web/src/phone-workspace.tsx @@ -1,6 +1,6 @@ /** Phone chrome: a floating bottom tab bar and a top bar that tucks away while reading. */ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Count } from "@chopin/editor/count"; import { DecisionIcon, DocumentIcon, MessageIcon, WrenchIcon } from "@chopin/icons"; @@ -31,6 +31,8 @@ export function PhoneTabBar( destinations, documentActivity, labels, + minimised = false, + onRestore, onSelect, toolbar, unanswered, @@ -44,6 +46,9 @@ export function PhoneTabBar( destinations: WorkspaceDestination[]; documentActivity?: DocumentActivity; labels: Record; + /** Shrinks the tabs to one circle showing the current tab while reading down a pane. */ + minimised?: boolean; + onRestore?: () => void; onSelect: (destination: WorkspaceDestination, current: boolean, source: HTMLElement) => void; /** Takes the tabs' place while set, such as formatting while editing; stays above the keyboard. */ toolbar?: ReactNode; @@ -51,9 +56,29 @@ export function PhoneTabBar( }, ) { let index = destinations.indexOf(active); + let small = minimised && !toolbar; + let nav = useRef(null); + let restoring = useRef(false); + // Restoring from the circle hands focus to the current tab, since the circle itself goes inert. + useLayoutEffect(() => { + if (small || !restoring.current) return; + restoring.current = false; + nav.current?.querySelector('.phone-tab[aria-current="page"]')?.focus({ + preventScroll: true, + }); + }, [small]); + let restore = (focus: boolean) => { + restoring.current = focus; + onRestore?.(); + }; + let ActiveIcon = index < 0 ? undefined : ICONS[active]; + // The circle shows one icon, so it marks unseen activity on any other tab with a dot. + let elsewhere = (active !== "decisions" && unanswered > 0) + || (active !== "chat" && (chatActivity.busy || chatActivity.unread > 0)); return (
@@ -62,6 +87,7 @@ export function PhoneTabBar( hidden={!!toolbar} aria-label="Workspace view" className="phone-tab-bar" + ref={nav} style={{ "--phone-tab-count": destinations.length } as CSSProperties} > { if (!unavailable) onSelect(destination, current, event.currentTarget); @@ -116,6 +143,28 @@ export function PhoneTabBar( ); })} + {ActiveIcon && ( + + )} {action}
@@ -128,8 +177,8 @@ export function phonePaneScroller(pane: Element | null | undefined): HTMLElement } /** - * Hides the top bar while reading down the document, Decisions or Build and brings it back on - * any scroll up, or when focus moves into it. Chat keeps its bar, because new messages scroll + * Hides the top bar and minimises the tab bar while reading down the document, Decisions or Build, + * and brings both back on any scroll up, or when focus moves into either. Chat keeps its bar, because new messages scroll * the transcript on their own. The bar's small title waits until the document's own heading * has scrolled under the bar; other destinations have no heading, so it always shows there. */ @@ -137,7 +186,7 @@ export function usePhoneTopBar( root: RefObject, enabled: boolean, destination: WorkspaceDestination, -): { hidden: boolean; titleHidden: boolean } { +): { hidden: boolean; show: () => void; titleHidden: boolean } { let [hidden, setHidden] = useState(false); let [headingVisible, setHeadingVisible] = useState(true); useEffect(() => setHidden(false), [destination, enabled]); @@ -181,7 +230,9 @@ export function usePhoneTopBar( else if (y - previous > TOP_BAR_SLOP) setHidden(true); }; let focus = (event: FocusEvent) => { - if (event.target instanceof Element && event.target.closest(".phone-top-bar")) { + if ( + event.target instanceof Element && event.target.closest(".phone-top-bar, .phone-tab-row") + ) { setHidden(false); } }; @@ -202,8 +253,10 @@ export function usePhoneTopBar( element.removeEventListener("focusin", focus); }; }, [enabled, root]); + let show = useCallback(() => setHidden(false), []); return { hidden: enabled && hidden, + show, titleHidden: enabled && destination === "plan" && headingVisible, }; } diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index e774ccb93..89076aeea 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -788,6 +788,8 @@ export function Workspace( destination === "plan" ? undefined : documentActivity, ), }} + minimised={topBar.hidden} + onRestore={topBar.show} onSelect={selectPhoneTab} toolbar={phoneToolbar} unanswered={unanswered} diff --git a/e2e/phone-first-load.e2e.ts b/e2e/phone-first-load.e2e.ts index abb6b852a..5fcb8aec8 100644 --- a/e2e/phone-first-load.e2e.ts +++ b/e2e/phone-first-load.e2e.ts @@ -48,38 +48,47 @@ async function open( return { context, page }; } -test("a phone paints the workspace shape before its JavaScript and keeps the bars still", async ({ baseURL, browser, room, seed }) => { - await seed(RESPONSIVE_SOURCE); - let { context, page } = await open(browser, baseURL!, PHONE); - try { - let release = await holdScripts(context); - await page.goto(roomPath(room), { waitUntil: "commit" }); +for ( + let [name, device] of [ + ["a phone", PHONE], + ["the narrowest phone", { ...PHONE, viewport: { width: 320, height: 568 } }], + ] as const +) { + test(`${name} paints the workspace shape before its JavaScript and keeps the bars still`, async ({ baseURL, browser, room, seed }) => { + await seed(RESPONSIVE_SOURCE); + let { context, page } = await open(browser, baseURL!, device); + try { + let release = await holdScripts(context); + await page.goto(roomPath(room), { waitUntil: "commit" }); - let skeleton = page.locator(".boot-skeleton .phone-skeleton"); - await expect(skeleton).toBeVisible(); - await expect(skeleton.locator(".phone-skeleton-heading")).toBeVisible(); - await expect(skeleton.getByRole("status")).toHaveText("Opening document…"); - let top = await box(skeleton.locator(".phone-skeleton-top")); - let bar = await box(skeleton.locator(".phone-skeleton-bar")); - let action = await box(skeleton.locator(".phone-skeleton-action")); - let trigger = await box(skeleton.locator(".phone-skeleton-slot").first()); - expect(top.y).toBe(0); - expect(top.height).toBe(44); + let skeleton = page.locator(".boot-skeleton .phone-skeleton"); + await expect(skeleton).toBeVisible(); + await expect(skeleton.locator(".phone-skeleton-heading")).toBeVisible(); + await expect(skeleton.getByRole("status")).toHaveText("Opening document…"); + let top = await box(skeleton.locator(".phone-skeleton-top")); + let bar = await box(skeleton.locator(".phone-skeleton-bar")); + let action = await box(skeleton.locator(".phone-skeleton-action")); + let trigger = await box(skeleton.locator(".phone-skeleton-slot").first()); + expect(top.y).toBe(0); + expect(top.height).toBe(44); + expect(bar.height).toBe(62); + expect(action.height).toBe(device.viewport.width < 360 ? 48 : 54); - release(); - await ready(page); - await expect(page.locator(".phone-skeleton")).toHaveCount(0); - expectSameBox(await box(page.locator(".phone-top-bar")), top); - expectSameBox(await box(page.locator(".phone-tab-bar")), bar); - expectSameBox(await box(page.getByRole("button", { name: "Edit document" })), action); - expectSameBox( - await box(page.locator(".phone-top-bar").getByRole("button", { name: "Documents" })), - trigger, - ); - } finally { - await context.close(); - } -}); + release(); + await ready(page); + await expect(page.locator(".phone-skeleton")).toHaveCount(0); + expectSameBox(await box(page.locator(".phone-top-bar")), top); + expectSameBox(await box(page.locator(".phone-tab-bar")), bar); + expectSameBox(await box(page.getByRole("button", { name: "Edit document" })), action); + expectSameBox( + await box(page.locator(".phone-top-bar").getByRole("button", { name: "Documents" })), + trigger, + ); + } finally { + await context.close(); + } + }); +} test("a phone keeps the skeleton in the document column until the document syncs", async ({ baseURL, browser, room, seed }) => { await seed(RESPONSIVE_SOURCE); diff --git a/e2e/phone-workspace.e2e.ts b/e2e/phone-workspace.e2e.ts index 65907e40f..a2be20a9b 100644 --- a/e2e/phone-workspace.e2e.ts +++ b/e2e/phone-workspace.e2e.ts @@ -70,7 +70,10 @@ test("the phone tab bar switches destinations in one bar and returns a tab to it } let scroller = page.locator("[data-plan-scroll]"); + await scrollPane(scroller, 300); await scrollPane(scroller, 700); + // Reading down minimised the bar to the current tab; a tap brings the tabs back. + await nav.getByRole("button", { name: /^Show tabs/ }).click(); await tabs.nth(0).click(); await expect(page.getByRole("complementary", { name: "Chat" })).toBeVisible(); await expect(tabs.nth(0)).toHaveAttribute("aria-current", "page"); @@ -142,6 +145,112 @@ 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"); }); +/** 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 => { + let pill = bar.querySelector(".phone-tab-indicator")!; + let bounds = pill.getBoundingClientRect(); + let label = document.createRange(); + label.selectNodeContents( + bar.querySelector('.phone-tab[aria-current="page"] .phone-tab-label')!, + ); + let text = label.getBoundingClientRect(); + let radius = Math.min( + bounds.height / 2, + parseFloat(getComputedStyle(pill).borderTopLeftRadius), + ); + let rise = Math.max(0, text.bottom - text.height * 0.2 - (bounds.bottom - radius)); + let curve = radius - Math.sqrt(Math.max(0, radius * radius - rise * rise)); + return Math.min(text.left - bounds.left, bounds.right - text.right) - curve; + }); +} + +test("the tab bar's pill is concentric with the bar and clears its labels at 320", async ({ join, seed }) => { + await seed(RESPONSIVE_SOURCE); + let page = await join("ana", { ...PHONE, viewport: { width: 320, height: 568 } }); + let nav = page.getByRole("navigation", { name: "Workspace view" }); + await nav.getByRole("button", { name: /^Decisions/ }).click(); + await expect(page.locator('[data-document-view="decisions"]')).toBeVisible(); + for ( + let control of [ + ...await nav.getByRole("button").all(), + page.getByRole("button", { name: "Edit document" }), + ] + ) { + let box = (await control.boundingBox())!; + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(44); + } + await expect.poll(() => labelClearance(page)).toBeGreaterThanOrEqual(3); + let corners = await page.locator(".phone-tab-bar").evaluate(bar => { + let pill = bar.querySelector(".phone-tab-indicator")!; + return { + bar: parseFloat(getComputedStyle(bar).borderTopLeftRadius), + inset: pill.getBoundingClientRect().top - bar.getBoundingClientRect().top, + pill: parseFloat(getComputedStyle(pill).borderTopLeftRadius), + }; + }); + expect(corners.pill).toBeCloseTo(corners.bar - corners.inset, 1); + await expectNoHorizontalOverflow(page); +}); + +test("the tab bar minimises while reading down and returns on scroll up, a tap or focus", async ({ join, seed }) => { + await seed(RESPONSIVE_SOURCE); + let page = await join("ana", PHONE); + let scroller = page.locator("[data-plan-scroll]"); + let row = page.locator("[data-phone-tab-row]"); + let nav = page.getByRole("navigation", { name: "Workspace view" }); + let show = nav.getByRole("button", { name: /^Show tabs/ }); + let width = () => nav.evaluate(element => element.getBoundingClientRect().width); + await expect(show).toHaveCount(0); + let full = await width(); + + await scrollPane(scroller, 200); + await scrollPane(scroller, 600); + await expect(row).toHaveAttribute("data-phone-tab-minimised", ""); + // The top bar tucks away on the same scroll. + await expect(page.locator(".workspace-root")).toHaveAttribute("data-workspace-bar", "hidden"); + await expect(show).toHaveAccessibleName("Show tabs, Document"); + await expect(nav.getByRole("button")).toHaveCount(1); + await expect.poll(width).toBeLessThan(60); + let circle = (await show.boundingBox())!; + expect(circle.width).toBeGreaterThanOrEqual(44); + expect(circle.height).toBeGreaterThanOrEqual(44); + + await scrollPane(scroller, 500); + await expect(row).not.toHaveAttribute("data-phone-tab-minimised"); + await expect.poll(width).toBeCloseTo(full, 0); + + await scrollPane(scroller, 900); + await expect(row).toHaveAttribute("data-phone-tab-minimised", ""); + await show.click(); + await expect(row).not.toHaveAttribute("data-phone-tab-minimised"); + await expect(page.locator(".workspace-root")).not.toHaveAttribute("data-workspace-bar", "hidden"); + await expect(nav.getByRole("button", { name: "Document" })).toBeFocused(); + + // Keyboard focus reaching the circle restores the tabs and lands on the current one. + await scrollPane(scroller, 1300); + await expect(row).toHaveAttribute("data-phone-tab-minimised", ""); + await show.focus(); + await expect(row).not.toHaveAttribute("data-phone-tab-minimised"); + await expect(nav.getByRole("button", { name: "Document" })).toBeFocused(); +}); + +test("reduced motion keeps the full tab bar while reading", async ({ join, seed }) => { + await seed(RESPONSIVE_SOURCE); + let page = await join("ana", PHONE); + await page.emulateMedia({ reducedMotion: "reduce" }); + let scroller = page.locator("[data-plan-scroll]"); + await scrollPane(scroller, 200); + await scrollPane(scroller, 600); + await scrollPane(scroller, 1000); + await expect(page.locator("[data-phone-tab-row]")).not.toHaveAttribute( + "data-phone-tab-minimised", + ); + await expect(page.getByRole("navigation", { name: "Workspace view" }).getByRole("button")) + .toHaveCount(4); +}); + test("the software keyboard hides the bottom chrome", async ({ baseURL, browser, room, seed }) => { await seed(RESPONSIVE_SOURCE); let { close, page } = await openIsolatedRoom( diff --git a/packages/visuals/theme.css b/packages/visuals/theme.css index 9641014d4..25a3056c6 100644 --- a/packages/visuals/theme.css +++ b/packages/visuals/theme.css @@ -262,8 +262,22 @@ /* Phone chrome: a collapsing top bar and a floating bottom tab bar in the thumb zone. */ --phone-bar-height: calc(var(--spacing) * 11); - --phone-tab-bar-height: calc(var(--spacing) * 15); + /* + * The tab bar follows iOS 26 proportions: a 62px bar holding a selection pill inset by 5px, + * a 54px action circle beside it, and a 52px circle when it minimises while reading. The + * bar's edge offset (`--phone-tab-bar-gap`) is separate from the gap to its action. + */ + --phone-tab-bar-height: calc(var(--spacing) * 15.5); + --phone-tab-bar-inset: calc(var(--spacing) * 1.25); + /* Landscape's one-line bar keeps a full 44px tab inside a 2px inset. */ + --phone-tab-bar-inset-compact: calc(var(--spacing) / 2); --phone-tab-bar-gap: calc(var(--spacing) * 2.5); + --phone-tab-action-gap: calc(var(--spacing) * 2); + --phone-tab-action-size: calc(var(--spacing) * 13.5); + --phone-tab-bar-minimised: calc(var(--spacing) * 13); + /* Apple's 10pt semibold tab label; the type scale's smallest step is too wide for four tabs. */ + --phone-tab-label-size: 0.625rem; + --phone-tab-label-line: calc(var(--spacing) * 3); --phone-tab-row-max: 30rem; /* Editing on a phone: formatting rides the keyboard as one bar, and its panels stand in for it. */ --phone-dock-height: calc(var(--control-target-coarse) + var(--spacing)); @@ -309,6 +323,17 @@ --scrollbar-end-margin: calc(var(--spacing) * 1.5); --scrollbar-thumb-min: calc(var(--spacing) * 8); --radius-composer-pill: calc(var(--spacing) * 5.5); /* 22px — half the phone composer at rest */ + /* + * Concentric corners: the selection pill's radius is the bar's radius less the inset between + * them, so both curves stay parallel at the end tabs. The bar is a rounded rectangle rather + * than a capsule because a capsule would force a near-capsule pill that crowds its label. + */ + --radius-phone-tab-bar: calc(var(--spacing) * 6); /* 24px */ + --radius-phone-tab-pill: calc(var(--radius-phone-tab-bar) - var(--phone-tab-bar-inset)); + --radius-phone-tab-pill-compact: calc( + var(--radius-phone-tab-bar) - var(--phone-tab-bar-inset-compact) + ); + --radius-phone-tab-minimised: calc(var(--phone-tab-bar-minimised) / 2); /* * Warm-tinted depth, copied from the settled Figma shadow table. diff --git a/scripts/design-contract/exceptions/preserved-values.json b/scripts/design-contract/exceptions/preserved-values.json index 7b3ff29f8..263e43363 100644 --- a/scripts/design-contract/exceptions/preserved-values.json +++ b/scripts/design-contract/exceptions/preserved-values.json @@ -461,5 +461,18 @@ 1 ] ] + }, + { + "file": "apps/web/src/phone-workspace.css", + "reason": "The phone tab bar uses Apple's 10pt semibold tab label so four labels fit their pills with clearance at 320px; the fluid scale's smallest step is too wide for that column.", + "cases": [ + [ + "typography", + "font-size", + "var(--phone-tab-label-size)", + "CSS > .phone-tab", + 1 + ] + ] } ] From e1f05ff910dd0edabf073d785e73276023da3e35 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:11:09 +0100 Subject: [PATCH 2/6] Hide minimised tabs from assistive technology and widen label clearance A minimised bar's inert tabs are also aria-hidden, so only the Show tabs circle is exposed. The bar's corners drop to 22px (pill 17px, still concentric with the 5px inset), which keeps "Decisions" clear of the pill's curve at 320 under Linux font rendering too. Tests that read down a pane and then switch tabs restore the bar first. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-workspace.css | 2 +- apps/web/src/phone-workspace.tsx | 1 + e2e/responsive-decisions.e2e.ts | 5 ++++- e2e/sidecar.e2e.ts | 7 +++++++ packages/visuals/theme.css | 2 +- 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index 29cc2779a..25d76a0b5 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -530,7 +530,7 @@ --phone-tab-bar-minimised: var(--phone-tab-bar-height); } - /* The bar is a capsule at this height, and the pill stays concentric inside the narrower inset. */ + /* The bar is near a capsule at this height, and the pill stays concentric inside the narrower inset. */ :is(.phone-tab-indicator, .phone-tab) { border-radius: var(--radius-phone-tab-pill-compact); } diff --git a/apps/web/src/phone-workspace.tsx b/apps/web/src/phone-workspace.tsx index c39c48f0b..e3f0725ba 100644 --- a/apps/web/src/phone-workspace.tsx +++ b/apps/web/src/phone-workspace.tsx @@ -107,6 +107,7 @@ export function PhoneTabBar( aria-disabled={unavailable || undefined} aria-label={labels[destination]} className="phone-tab" + aria-hidden={small || undefined} data-press="small" inert={small} key={destination} diff --git a/e2e/responsive-decisions.e2e.ts b/e2e/responsive-decisions.e2e.ts index 25cd4e3b3..967bf3bf7 100644 --- a/e2e/responsive-decisions.e2e.ts +++ b/e2e/responsive-decisions.e2e.ts @@ -147,7 +147,10 @@ for (let viewport of [{ width: 320, height: 568 }, { width: 390, height: 844 }]) } await expect( page.getByRole("navigation", { name: "Workspace view" }) - .getByRole("button", { name: `Decisions, ${LONG_QUESTIONS.length} unanswered` }), + // The full bar's tab, or the minimised bar's circle while reading down. + .getByRole("button", { + name: new RegExp(`Decisions, ${LONG_QUESTIONS.length} unanswered$`), + }), ).toBeVisible(); let addRow = card.getByRole("button", { name: "Add an option" }); diff --git a/e2e/sidecar.e2e.ts b/e2e/sidecar.e2e.ts index a3f9af4c4..b948988c4 100644 --- a/e2e/sidecar.e2e.ts +++ b/e2e/sidecar.e2e.ts @@ -385,6 +385,12 @@ async function expectCompactDestinationStatePreserved(page: Page): Promise tracker; }); + // Reading down minimises the tab bar to the current tab; a tap brings the tabs back. + let showTabs = async () => { + let show = nav.getByRole("button", { name: /^Show tabs/ }); + if (await show.isVisible()) await show.click(); + }; + await showTabs(); await nav.getByRole("button", { name: /Chat/ }).click(); await draft.fill("unfinished compact thought"); @@ -395,6 +401,7 @@ async function expectCompactDestinationStatePreserved(page: Page): Promise element.dispatchEvent(new Event("scroll")); }); let decisionScroll = await decisionScroller.evaluate(element => element.scrollTop); + await showTabs(); await nav.getByRole("button", { name: "Document" }).click(); await expectChatValue(draft, "unfinished compact thought"); diff --git a/packages/visuals/theme.css b/packages/visuals/theme.css index 25a3056c6..1552f8fb3 100644 --- a/packages/visuals/theme.css +++ b/packages/visuals/theme.css @@ -328,7 +328,7 @@ * them, so both curves stay parallel at the end tabs. The bar is a rounded rectangle rather * than a capsule because a capsule would force a near-capsule pill that crowds its label. */ - --radius-phone-tab-bar: calc(var(--spacing) * 6); /* 24px */ + --radius-phone-tab-bar: calc(var(--spacing) * 5.5); /* 22px */ --radius-phone-tab-pill: calc(var(--radius-phone-tab-bar) - var(--phone-tab-bar-inset)); --radius-phone-tab-pill-compact: calc( var(--radius-phone-tab-bar) - var(--phone-tab-bar-inset-compact) From d61c20cceef5302c182e0b5a9e88b008a0eb29b8 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:31:13 +0100 Subject: [PATCH 3/6] Move focus to the current tab as soon as a tap restores the tab bar Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-workspace.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/web/src/phone-workspace.tsx b/apps/web/src/phone-workspace.tsx index e3f0725ba..2bb8be505 100644 --- a/apps/web/src/phone-workspace.tsx +++ b/apps/web/src/phone-workspace.tsx @@ -1,6 +1,7 @@ /** Phone chrome: a floating bottom tab bar and a top bar that tucks away while reading. */ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { Count } from "@chopin/editor/count"; import { DecisionIcon, DocumentIcon, MessageIcon, WrenchIcon } from "@chopin/icons"; @@ -58,18 +59,12 @@ export function PhoneTabBar( let index = destinations.indexOf(active); let small = minimised && !toolbar; let nav = useRef(null); - let restoring = useRef(false); // Restoring from the circle hands focus to the current tab, since the circle itself goes inert. - useLayoutEffect(() => { - if (small || !restoring.current) return; - restoring.current = false; + let restore = () => { + flushSync(() => onRestore?.()); nav.current?.querySelector('.phone-tab[aria-current="page"]')?.focus({ preventScroll: true, }); - }, [small]); - let restore = (focus: boolean) => { - restoring.current = focus; - onRestore?.(); }; let ActiveIcon = index < 0 ? undefined : ICONS[active]; // The circle shows one icon, so it marks unseen activity on any other tab with a dot. @@ -151,8 +146,8 @@ export function PhoneTabBar( className="phone-tab-mini" data-press="small" inert={!small} - onClick={() => restore(true)} - onFocus={() => restore(true)} + onClick={restore} + onFocus={restore} type="button" > From 453055594199bcb5c60d21030be4a7c0a2a67e21 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:49:55 +0100 Subject: [PATCH 4/6] Address tab bar review: badges, landscape grouping and slow drags - The minimised circle's icon takes brand ink, and its count or activity dot sits at the circle's corner instead of over the icon, landscape too. - Landscape shrinks the bar toward its action, so the circle and pencil stay together. - Scroll direction accumulates travel since the last change of direction, so slow drags still tuck and restore both bars. - The pencil's bottom edge stays level with the bar while both shrink. - Corners drop to 20px (pill 15px, still concentric), and the narrowest phones use a 44px action and tighter tracking, so even "Document" keeps label clearance at 320 under Linux font metrics. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-skeleton.css | 4 ++-- apps/web/src/phone-workspace.css | 30 ++++++++++++++++++++++++---- apps/web/src/phone-workspace.tsx | 34 ++++++++++++++++++-------------- e2e/phone-first-load.e2e.ts | 2 +- e2e/phone-workspace.e2e.ts | 16 ++++++++++++++- packages/visuals/theme.css | 2 +- 6 files changed, 64 insertions(+), 24 deletions(-) diff --git a/apps/web/src/phone-skeleton.css b/apps/web/src/phone-skeleton.css index a0af0306f..64527dddf 100644 --- a/apps/web/src/phone-skeleton.css +++ b/apps/web/src/phone-skeleton.css @@ -121,7 +121,7 @@ var(--phone-tab-row-max) ); margin-inline: auto; - align-items: center; + align-items: flex-end; gap: var(--phone-tab-action-gap); } @@ -182,7 +182,7 @@ @media (max-width: 359px) { .phone-skeleton { --phone-tab-action-gap: calc(var(--spacing) * 1.5); - --phone-tab-action-size: calc(var(--spacing) * 12); + --phone-tab-action-size: var(--control-target-coarse); } .phone-skeleton-tabs { diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index 25d76a0b5..7a6400316 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -250,7 +250,8 @@ ); margin-inline: auto; display: flex; - align-items: center; + /* Bottom edges stay level while the bar and its action shrink toward the minimised circle. */ + align-items: flex-end; justify-content: space-between; gap: var(--phone-tab-action-gap); transition: transform var(--tab-slide-dur) var(--tab-slide-ease), visibility 0s; @@ -283,7 +284,6 @@ width: 100%; height: var(--phone-tab-bar-height); padding: var(--phone-tab-bar-inset); - overflow: hidden; border-radius: var(--radius-phone-tab-bar); transition: width var(--tab-slide-dur) var(--tab-slide-ease), @@ -329,6 +329,18 @@ transition: opacity var(--duration-fast) var(--ease-out); } +.phone-tab-mini > .phone-tab-badge { + top: calc(var(--spacing) * -1); + right: calc(var(--spacing) * -1); + left: auto; +} + +/* Inset far enough that the dot sits inside the circle's curve, not on its edge. */ +.phone-tab-mini > .phone-tab-dot { + top: calc(var(--spacing) * 2); + right: calc(var(--spacing) * 2); +} + .phone-tab-row[data-phone-tab-minimised] .phone-tab-mini { opacity: 1; pointer-events: auto; @@ -451,7 +463,7 @@ @media (max-width: 359px) { .workspace-root[data-workspace-phone] { --phone-tab-action-gap: calc(var(--spacing) * 1.5); - --phone-tab-action-size: calc(var(--spacing) * 12); + --phone-tab-action-size: var(--control-target-coarse); } .phone-tab-row { @@ -459,11 +471,12 @@ } .phone-tab-label { - letter-spacing: normal; + letter-spacing: -0.01em; } } .phone-tab [data-nucleo-icon], +.phone-tab-mini [data-nucleo-icon], .phone-tab-action [data-nucleo-icon] { color: inherit; } @@ -552,6 +565,15 @@ display: none; } + .phone-tab-mini > .phone-tab-badge { + display: block; + } + + /* The wide bar shrinks toward its action, so the circle and the action stay together. */ + .phone-tab-row:has(> .phone-tab-action) > .phone-tab-bar { + margin-inline-start: auto; + } + .phone-tab-badge-inline { display: inline-flex; } diff --git a/apps/web/src/phone-workspace.tsx b/apps/web/src/phone-workspace.tsx index 2bb8be505..31738012e 100644 --- a/apps/web/src/phone-workspace.tsx +++ b/apps/web/src/phone-workspace.tsx @@ -14,7 +14,7 @@ import type { WorkspaceDestination } from "./workspace-model"; /** Scrolled less than this from the top, the top bar always shows. */ const TOP_BAR_REVEAL = 60; -/** Ignore scroll jitter smaller than this before hiding or showing the top bar. */ +/** Travel in one direction needed before the bars hide or show, so jitter never toggles them. */ const TOP_BAR_SLOP = 6; const SCROLLERS = @@ -150,15 +150,14 @@ export function PhoneTabBar( onFocus={restore} type="button" > - -