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..c1602d180 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -250,8 +250,10 @@ ); margin-inline: auto; display: flex; + /* The action sits centred beside the bar; both shrink to the same circle size when minimised. */ 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 +279,73 @@ .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); + 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-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; + /* The circle's icon arrives once the tabs have faded, not over them. */ + transition-delay: var(--duration-fast); } .phone-tab-bar[hidden] { @@ -352,13 +416,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 +435,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,19 +459,24 @@ 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: -0.01em; } } .phone-tab [data-nucleo-icon], +.phone-tab-mini [data-nucleo-icon], .phone-tab-action [data-nucleo-icon] { color: inherit; } @@ -445,11 +520,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 +536,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 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); } .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] { @@ -486,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; } @@ -562,6 +650,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..31738012e 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 { useEffect, 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"; @@ -13,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 = @@ -31,6 +32,8 @@ export function PhoneTabBar( destinations, documentActivity, labels, + minimised = false, + onRestore, onSelect, toolbar, unanswered, @@ -44,6 +47,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 +57,23 @@ export function PhoneTabBar( }, ) { let index = destinations.indexOf(active); + let small = minimised && !toolbar; + let nav = useRef(null); + // Restoring from the circle hands focus to the current tab, since the circle itself goes inert. + let restore = () => { + flushSync(() => onRestore?.()); + nav.current?.querySelector('.phone-tab[aria-current="page"]')?.focus({ + preventScroll: true, + }); + }; + 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 +82,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 +139,27 @@ export function PhoneTabBar( ); })} + {ActiveIcon && ( + + )} {action}
@@ -128,8 +172,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 +181,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]); @@ -145,7 +189,9 @@ export function usePhoneTopBar( let element = root.current; if (!enabled || !element) return; let reduced = matchMedia("(prefers-reduced-motion: reduce)"); - let last = new WeakMap(); + // Each pane's last position, direction and distance travelled since that direction began, + // so a slow drag of a few pixels a frame still adds up to a reading direction. + let last = new WeakMap(); let frame = 0; // Collaborative edits mutate the document often; measure at most once a frame. let measure = () => { @@ -174,14 +220,19 @@ export function usePhoneTopBar( Math.max(0, target.scrollHeight - target.clientHeight), ); // Panes open at their top, so a first scroll event already has a direction. - let previous = last.get(target) ?? 0; - last.set(target, y); + let previous = last.get(target) ?? { direction: 0, travel: 0, y: 0 }; + let delta = y - previous.y; + let direction = Math.sign(delta) || previous.direction; + let travel = (direction === previous.direction ? previous.travel : 0) + Math.abs(delta); + last.set(target, { direction, travel, y }); if (reduced.matches || target.matches("[data-chat-scroll]")) return; - if (y < TOP_BAR_REVEAL || previous - y > TOP_BAR_SLOP) setHidden(false); - else if (y - previous > TOP_BAR_SLOP) setHidden(true); + if (y < TOP_BAR_REVEAL || (direction < 0 && travel > TOP_BAR_SLOP)) setHidden(false); + else if (direction > 0 && travel > 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..c80374d6c 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,126 @@ 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 even its widest label 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" }); + // Document has the widest label, so it is the tightest fit. + await expect(nav.getByRole("button", { name: "Document" })).toHaveAttribute( + "aria-current", + "page", + ); + await expect.poll(() => labelClearance(page)).toBeGreaterThanOrEqual(3); + 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(); + + // A slow drag of a few pixels a frame still adds up to a reading direction, both ways. + for (let top = 903; top <= 960; top += 3) await scrollPane(scroller, top); + await expect(row).toHaveAttribute("data-phone-tab-minimised", ""); + await expect(page.locator(".workspace-root")).toHaveAttribute("data-workspace-bar", "hidden"); + for (let top = 957; top >= 900; top -= 3) await scrollPane(scroller, top); + await expect(row).not.toHaveAttribute("data-phone-tab-minimised"); + await expect(page.locator(".workspace-root")).not.toHaveAttribute("data-workspace-bar", "hidden"); + + // 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/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 9641014d4..0e261de1e 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) * 5); /* 20px */ + --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/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index 47bf3b44e..449350bc9 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -276,7 +276,7 @@ 1 ] ], - "sourceHash": "6c383a9a8784b8e2706055c55f5799e3e10621a105ed4ae190710ec206d1f92f" + "sourceHash": "332ae71c3fca3a39c97a1a4314c6315b0c6e296cb29d9c9f8d60ba5b8290909c" }, { "file": "apps/web/src/navigation-dialog.tsx", 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 + ] + ] } ]