From 8c37b4ec4470f910667466198de35ce3ee1ef1a5 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:37:21 +0100 Subject: [PATCH 01/10] Give phones a bottom tab bar and a collapsing top bar Phone class (primary coarse pointer, width <= 600 or height <= 500) now keeps the compact layout in landscape and replaces the top segmented tabs with one floating bottom bar: Chat, Document, Decisions, Build. Panes run full-bleed under a translucent 44px top bar that holds the sidebar trigger, title and document actions, and tucks away while reading down. Destinations slide by tab order, crossfade under reduced motion, and the bottom bar steps aside for the software keyboard. Desktop and tablets are unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/web/DESIGN.md | 2 +- apps/web/src/chat/transcript.tsx | 1 + apps/web/src/motion-contract.ts | 5 +- apps/web/src/navigation-shell.tsx | 275 ++++++++++---------- apps/web/src/phone-header.tsx | 106 ++++++++ apps/web/src/phone-workspace.css | 323 ++++++++++++++++++++++++ apps/web/src/phone-workspace.tsx | 145 +++++++++++ apps/web/src/project-sidebar-chrome.tsx | 15 ++ apps/web/src/room-workspace.tsx | 46 ++-- apps/web/src/viewport.test.ts | 7 +- apps/web/src/viewport.ts | 8 + apps/web/src/workspace.tsx | 91 ++++++- e2e/phone-workspace.e2e.ts | 156 ++++++++++++ e2e/responsive-workspace.e2e.ts | 24 +- packages/editor/src/index.ts | 2 +- packages/editor/src/pointer.test.ts | 20 +- packages/editor/src/pointer.ts | 41 ++- packages/visuals/theme.css | 14 + 18 files changed, 1105 insertions(+), 176 deletions(-) create mode 100644 apps/web/src/phone-header.tsx create mode 100644 apps/web/src/phone-workspace.css create mode 100644 apps/web/src/phone-workspace.tsx create mode 100644 e2e/phone-workspace.e2e.ts diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 70dd3f143..914fe5452 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -149,7 +149,7 @@ text only when people need it to act or recover. ## Layout -The workspace places Chat on the left and the document on the right when space permits. Chat and its top bar (breadcrumb and document title) sit directly on the ground; the document is a raised sheet that rises to the top margin, and its tab row holds the expand control, the Document/Decisions/Build views and presence. Expanding the document hides Chat and the top bar; a Chat button in the same place restores them. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). Images and diagrams can extend beyond the prose measure within those gutters; tables can grow toward the trailing gutter and retain their own scroll lane. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes. +The workspace places Chat on the left and the document on the right when space permits. Chat and its top bar (breadcrumb and document title) sit directly on the ground; the document is a raised sheet that rises to the top margin, and its tab row holds the expand control, the Document/Decisions/Build views and presence. Expanding the document hides Chat and the top bar; a Chat button in the same place restores them. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). On a phone (a primary touch pointer with a width of 600px or less, or a height of 500px or less, so landscape phones count), the workspace drops its card and the raised sheet: panes run full-bleed with 1.25rem (20px) gutters under a translucent top bar that tucks away while reading down, above a floating bottom tab bar in the thumb zone. Destinations slide sideways in tab order, and crossfade under reduced motion. Images and diagrams can extend beyond the prose measure within those gutters; tables can grow toward the trailing gutter and retain their own scroll lane. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes. Spacing starts from a 4px unit and uses the measured steps shown in the frontmatter. App chrome is dense; prose has larger margins and trailing space so the caret remains comfortable near the end of a document. Layout responds to both viewport and document container width. Safe-area insets and larger coarse-pointer targets are accounted for in the web styles. diff --git a/apps/web/src/chat/transcript.tsx b/apps/web/src/chat/transcript.tsx index 3efdb7ee3..062e1ae7e 100644 --- a/apps/web/src/chat/transcript.tsx +++ b/apps/web/src/chat/transcript.tsx @@ -605,6 +605,7 @@ export function Transcript( return (
{ diff --git a/apps/web/src/motion-contract.ts b/apps/web/src/motion-contract.ts index 823454ba0..5f150fc3f 100644 --- a/apps/web/src/motion-contract.ts +++ b/apps/web/src/motion-contract.ts @@ -4,7 +4,8 @@ export type MotionKind = | "feedback" | "popover" | "route-swap" - | "sidebar"; + | "sidebar" + | "tab-slide"; export const MOTION_STATES = ["", "is-open", "is-closing"] as const; @@ -32,6 +33,8 @@ let contracts = { // `closeDelay` adds 50ms, so the incoming route starts as `--route-swap-out-dur` ends. "route-swap": { className: "motion-route-swap", closeDuration: 30, states: MOTION_STATES }, sidebar: { className: "motion-sidebar", closeDuration: 180, states: MOTION_STATES }, + // Phone destinations: matches `--tab-slide-dur`. + "tab-slide": { className: "motion-tab-slide", closeDuration: 380, states: MOTION_STATES }, } as const satisfies Record; export function motionContract(kind: Kind): (typeof contracts)[Kind] { diff --git a/apps/web/src/navigation-shell.tsx b/apps/web/src/navigation-shell.tsx index 8d64cd873..cb4c32435 100644 --- a/apps/web/src/navigation-shell.tsx +++ b/apps/web/src/navigation-shell.tsx @@ -42,6 +42,7 @@ import { SIDEBAR_MAX, SIDEBAR_MIN, SIDEBAR_STORAGE_KEY, + SidebarTrigger, } from "./project-sidebar-chrome"; import { clearRepositoryCache } from "./repository-cache"; import { NavigationError } from "./navigation-error"; @@ -291,7 +292,12 @@ export function NavigationShell( ); let dialogMotion = dialogPresence.phase === "closed" ? undefined : dialogPresence; let presentedDialog = dialogMotion?.value; - let triggerVisible = !sidebarVisible && !drawerOpen; + let [triggerClaims, setTriggerClaims] = useState(0); + let claimTrigger = useCallback(() => { + setTriggerClaims(count => count + 1); + return () => setTriggerClaims(count => count - 1); + }, []); + let triggerVisible = !sidebarVisible && !drawerOpen && triggerClaims === 0; let resyncDecisions = useRef<(repositoryId: string) => void>(() => {}); let { beginTotalRequest, @@ -733,6 +739,11 @@ export function NavigationShell( workspaceDocumentAction, ]); + let expandSidebar = useCallback( + () => mode === "drawer" ? setDrawerOpen(true) : setCollapsed(false), + [mode], + ); + let signOut = async () => { try { await Api.logout(); @@ -755,6 +766,12 @@ export function NavigationShell( }; let dismissDialog = () => setDialog(undefined); let [sidebarShortcut, setSidebarShortcut] = useState(); + let sidebarTrigger = useMemo(() => ({ + buttonRef: drawerOpener, + claim: claimTrigger, + onExpand: expandSidebar, + shortcut: sidebarShortcut, + }), [claimTrigger, expandSidebar, sidebarShortcut]); let shortcutActions = useRef({}); shortcutActions.current = { search: () => showDialog("search"), @@ -928,145 +945,147 @@ export function NavigationShell( return ( - {countsReady && ( - - - - )} -
- {sidebarPresence.phase !== "closed" && ( -
-
{sidebar}
- - - -
- )} - {triggerVisible && ( - mode === "drawer" ? setDrawerOpen(true) : setCollapsed(false)} - shortcut={sidebarShortcut} - /> - )} - {drawerPresence.phase !== "closed" && ( - { - dismissDrawer(); - setDialogLoadFailed(true); - }} - > - - {sidebar} - - + + {countsReady && ( + + + )} - {children === undefined - ? ( -
- {content} -
- ) - : ( +
+ {sidebarPresence.phase !== "closed" && (
- {content} +
{sidebar}
+ + +
)} - {dialogMotion && presentedDialog === "add" && ( - setDialogLoadFailed(true)}> - { - catalogueRefreshes.current.set(project.repositoryId, Date.now()); - setFocusProjectId(project.repositoryId); - setCollapsed(false); - if (mode === "drawer") { - setDrawerOpen(true); - } - void refresh(); - }} - onDismiss={dismissDialog} - userId={user.id} + {triggerVisible && ( + - - )} - {dialogMotion && presentedDialog === "new" && ( - setDialogLoadFailed(true)}> - - { + dismissDrawer(); + setDialogLoadFailed(true); + }} + > + + {sidebar} + + + )} + {children === undefined + ? ( +
+ {content} +
+ ) + : ( +
+ {content} +
+ )} + {dialogMotion && presentedDialog === "add" && ( + setDialogLoadFailed(true)}> + - showDialog("add")} - onCreate={createDocument} - onDismiss={() => { - setDialog(undefined); + onAdded={project => { + catalogueRefreshes.current.set(project.repositoryId, Date.now()); + setFocusProjectId(project.repositoryId); + setCollapsed(false); if (mode === "drawer") { - requestAnimationFrame(() => drawerOpener.current?.focus()); + setDrawerOpen(true); } + void refresh(); }} - onRetry={retryProject ? () => createDocument(retryProject) : undefined} - pending={creation.pending} + onDismiss={dismissDialog} + userId={user.id} + /> + + )} + {dialogMotion && presentedDialog === "new" && ( + setDialogLoadFailed(true)}> + + + showDialog("add")} + onCreate={createDocument} + onDismiss={() => { + setDialog(undefined); + if (mode === "drawer") { + requestAnimationFrame(() => drawerOpener.current?.focus()); + } + }} + onRetry={retryProject ? () => createDocument(retryProject) : undefined} + pending={creation.pending} + projects={navigation?.projects ?? []} + /> + + + )} + {dialogMotion && presentedDialog === "search" && ( + setDialogLoadFailed(true)}> + - - - )} - {dialogMotion && presentedDialog === "search" && ( - setDialogLoadFailed(true)}> - - - )} - {dialogMotion && presentedDialog === "shortcuts" && ( - setDialogLoadFailed(true)}> - - - )} - {dialogMotion && typeof presentedDialog === "object" - && presentedDialog.type === "delete" && ( - setDialogLoadFailed(true)}> - - documentDeleted(presentedDialog.channel.id)} - onDismiss={dismissDialog} - /> - - )} -
+ + )} + {dialogMotion && presentedDialog === "shortcuts" && ( + setDialogLoadFailed(true)}> + + + )} + {dialogMotion && typeof presentedDialog === "object" + && presentedDialog.type === "delete" && ( + setDialogLoadFailed(true)}> + + documentDeleted(presentedDialog.channel.id)} + onDismiss={dismissDialog} + /> + + )} +
+
); } diff --git a/apps/web/src/phone-header.tsx b/apps/web/src/phone-header.tsx new file mode 100644 index 000000000..c4c8e179c --- /dev/null +++ b/apps/web/src/phone-header.tsx @@ -0,0 +1,106 @@ +/** The phone top bar: Documents on the left, a small centred title, and actions on the right. */ + +import { useContext, useLayoutEffect, useRef } from "react"; +import { ArrowLeftIcon, EllipsisIcon } from "@chopin/icons"; + +import { DocumentActionsMenu } from "./document-actions-menu"; +import { DocumentRename } from "./document-rename"; +import { ProjectSidebarExpandButton, SidebarTrigger } from "./project-sidebar-chrome"; + +import type { DocumentMetadata } from "./document-actions"; +import type { DocumentAction } from "./document-actions-menu"; +import type { TitleEdit } from "./title-edit"; +import type { WorkspacePresentation } from "./workspace-model"; + +export function PhoneHeader( + { + archivedAt, + canManage, + editing, + label, + onAction, + onEditingChange, + onRenamed, + presentation, + room, + }: { + archivedAt?: string; + canManage: boolean; + editing?: TitleEdit; + label: string; + onAction: (action: DocumentAction) => void; + onEditingChange: (editing?: TitleEdit) => void; + onRenamed: (channel: DocumentMetadata) => void; + presentation: WorkspacePresentation; + room: string; + }, +) { + let header = useRef(null); + let trigger = useContext(SidebarTrigger); + let hostsTrigger = presentation.type === "document" && trigger !== undefined; + let claim = trigger?.claim; + useLayoutEffect(() => hostsTrigger ? claim?.() : undefined, [claim, hostsTrigger]); + + let finishEdit = () => { + // 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") + ?.querySelector(".plan-content[contenteditable='true']") + ?.focus(); + } + onEditingChange(); + }; + let title = presentation.type === "parent-with-child" ? presentation.childLabel : label; + + return ( +
+
+ {presentation.type === "parent-with-child" + ? ( + + ) + : hostsTrigger && trigger && ( + + )} +
+
+ {editing && canManage && !archivedAt && presentation.type !== "parent-with-child" + ? ( + { + onRenamed(detail.channel); + finishEdit(); + }} + /> + ) + : {title}} + {archivedAt && Archived} +
+
+ {canManage && presentation.type !== "parent-with-child" && ( + action === "rename" ? onEditingChange("rename") : onAction(action)} + trigger={
+
+ ); +} diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css new file mode 100644 index 000000000..e252d06be --- /dev/null +++ b/apps/web/src/phone-workspace.css @@ -0,0 +1,323 @@ +/* + * Phone class (`data-workspace-phone`): panes run full-bleed under a translucent top bar and a + * floating bottom tab bar. Each pane keeps its own scroller, padded so content starts below the + * top bar and can scroll beneath both bars. + */ +.workspace-root[data-workspace-phone] { + --phone-top-chrome: calc(var(--phone-bar-height) + env(safe-area-inset-top)); + --phone-tab-bar-offset: max( + calc(env(safe-area-inset-bottom) - var(--spacing) * 3), + var(--phone-tab-bar-gap) + ); + --phone-bottom-chrome: calc( + var(--phone-tab-bar-height) + var(--phone-tab-bar-offset) + var(--spacing) * 3 + ); + --document-top-inset: calc(var(--phone-top-chrome) + var(--spacing) * 4); + --document-decisions-top-inset: var(--document-top-inset); + position: relative; +} + +:root[data-keyboard-open] .workspace-root[data-workspace-phone] { + --phone-bottom-chrome: 0px; +} + +.workspace-root[data-workspace-phone] :is(.plan, .plan-decisions) { + --plan-gutter: var(--document-gutter-phone); +} + +.workspace-root[data-workspace-phone] .build-view { + --build-gutter: var(--document-gutter-phone); +} + +.workspace-root[data-workspace-phone] .workspace-frame { + margin: 0; + padding-inline: env(safe-area-inset-left) env(safe-area-inset-right); +} + +.workspace-root[data-workspace-phone] .plan [data-plan-scroll] { + scroll-padding-block: calc(var(--phone-top-chrome) + var(--spacing) * 4) 30%; +} + +.workspace-root[data-workspace-phone] .workspace-chat-panel { + padding-bottom: var(--phone-bottom-chrome); +} + +.workspace-root[data-workspace-phone] [data-chat-scroll] { + padding-top: calc(var(--phone-top-chrome) + var(--spacing) * 3); +} + +.workspace-root[data-workspace-phone] .workspace-status-row { + inset-block-start: var(--phone-top-chrome); +} + +/* Top bar -------------------------------------------------------------- */ + +.workspace-root[data-workspace-phone] > .phone-top-bar { + position: absolute; + z-index: 30; + inset-inline: 0; + top: 0; + display: grid; + grid-template-columns: var(--control-target-coarse) minmax(0, 1fr) var(--control-target-coarse); + align-items: center; + gap: var(--spacing); + height: var(--phone-top-chrome); + min-height: 0; + padding: env(safe-area-inset-top) calc(var(--spacing) + env(safe-area-inset-right)) 0 + calc(var(--spacing) + env(safe-area-inset-left)); + background: var(--color-glass); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); + border-bottom: var(--edge-width) solid var(--color-edge); + transition: transform var(--phone-bar-dur) var(--ease-out); +} + +/* Only the bar tucks away; its safe-area band stays as the status bar's ground. */ +.workspace-root[data-workspace-bar="hidden"] > .phone-top-bar { + transform: translateY(calc(-1 * var(--phone-bar-height))); +} + +.phone-top-bar-slot { + display: grid; + place-items: center; +} + +.phone-top-bar-slot .btn-icon { + width: var(--control-target-coarse); + height: var(--control-target-coarse); + margin: 0; + border-radius: var(--radius-full); + color: var(--color-text-secondary); +} + +.phone-top-bar-title { + display: flex; + min-width: 0; + flex-direction: column; + align-items: center; + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + line-height: 1.2; + color: var(--color-text-primary); +} + +.phone-top-bar-title > .truncate { + max-width: 100%; +} + +.phone-top-bar-title input { + width: 100%; + text-align: center; +} + +.phone-top-bar-subtitle { + font-size: var(--text-2xs); + font-weight: var(--font-weight-normal); + color: var(--color-text-tertiary); +} + +/* Bottom tab bar ------------------------------------------------------- */ + +.phone-tab-row { + position: absolute; + z-index: 40; + right: 0; + bottom: var(--phone-tab-bar-offset); + left: 0; + /* Landscape keeps a thumb-sized bar centred rather than stretched across the screen. */ + width: min( + calc(100% - var(--spacing) * 8 - env(safe-area-inset-left) - env(safe-area-inset-right)), + var(--phone-tab-row-max) + ); + margin-inline: auto; + display: flex; + align-items: center; + gap: var(--phone-tab-bar-gap); + transition: transform var(--tab-slide-dur) var(--tab-slide-ease), visibility 0s; +} + +/* The keyboard owns the bottom edge while it is up. */ +:root[data-keyboard-open] .phone-tab-row { + visibility: hidden; + transform: translateY(calc(100% + var(--phone-tab-bar-offset) + var(--spacing) * 5)); + transition: + transform var(--tab-slide-dur) var(--tab-slide-ease), + visibility 0s linear var(--tab-slide-dur); +} + +.phone-tab-bar, +.phone-tab-action { + background: var(--color-glass); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); + outline: var(--edge-width) solid var(--color-edge); + outline-offset: calc(-1 * var(--edge-width)); + box-shadow: var(--shadow-overlay); +} + +.phone-tab-bar { + position: relative; + display: grid; + flex: 1; + grid-template-columns: repeat(var(--phone-tab-count, 3), minmax(0, 1fr)); + height: var(--phone-tab-bar-height); + padding: var(--spacing); + border-radius: var(--radius-full); +} + +.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); + background: var(--color-selected); + transform: translateX(calc(var(--phone-tab-index, 0) * 100%)); + transition: transform var(--tab-slide-dur) var(--tab-slide-ease); +} + +.phone-tab { + position: relative; + z-index: 1; + display: flex; + min-width: 0; + 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); + touch-action: manipulation; + user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + transition: + color var(--duration-base) var(--ease-out), + transform var(--duration-fast) var(--ease-out); +} + +.phone-tab:active { + transform: scale(0.94); +} + +.phone-tab[aria-current="page"] { + color: var(--color-brand-ink); +} + +.phone-tab[aria-disabled="true"] { + opacity: var(--opacity-quiet); +} + +/* The narrowest phones give up gaps and label tracking, never target size. */ +@media (max-width: 359px) { + .phone-tab-row { + gap: calc(var(--spacing) * 1.5); + width: calc(100% - var(--spacing) * 4); + } + + .phone-tab-label { + letter-spacing: -0.02em; + } +} + +.phone-tab [data-nucleo-icon] { + color: inherit; +} + +.phone-tab-icon { + position: relative; + display: grid; + place-items: center; +} + +.phone-tab-label { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.phone-tab-badge { + position: absolute; + top: calc(var(--spacing) * -1.5); + left: calc(100% - var(--spacing) * 2); +} + +.phone-tab-dot { + position: absolute; + top: 0; + right: calc(var(--spacing) * -0.5); + width: calc(var(--spacing) * 2); + height: calc(var(--spacing) * 2); + border: 2px solid var(--color-page); + border-radius: var(--radius-full); + background: var(--color-brand); +} + +/* A single circular control at the end of the bar (`phoneAction`); slices add its contents. */ +.phone-tab-action { + display: grid; + flex: none; + place-items: center; + width: var(--phone-tab-bar-height); + height: var(--phone-tab-bar-height); + border-radius: var(--radius-full); + color: var(--color-text-primary); + touch-action: manipulation; + transition: transform var(--duration-fast) var(--ease-out); +} + +.phone-tab-action:active { + transform: scale(0.96); +} + +/* Destinations ---------------------------------------------------------- */ + +/* Sibling tabs glide in from the side their tab lies on and push the old one out. */ +.workspace-root[data-workspace-phone] .content-swap-stack > .motion-tab-slide { + grid-area: 1 / 1; + min-width: 0; +} + +.motion-tab-slide { + transform: translateX(calc(100% * var(--swap-direction, 1))); + transition: transform var(--tab-slide-dur) var(--tab-slide-ease); + will-change: transform; +} + +.motion-tab-slide.is-open { + transform: none; + will-change: auto; +} + +.motion-tab-slide.is-closing { + pointer-events: none; + transform: translateX(calc(-100% * var(--swap-direction, 1))); +} + +.workspace-root[data-workspace-phone] .workspace-chat-panel.motion-tab-slide { + position: absolute; + inset: 0; + z-index: 10; +} + +@media (prefers-reduced-motion: reduce) { + .motion-tab-slide, + .motion-tab-slide.is-closing { + opacity: 0; + transform: none; + transition: opacity var(--duration-base) var(--ease-out); + } + + .motion-tab-slide.is-open { + opacity: 1; + } + + .workspace-root[data-workspace-phone] > .phone-top-bar, + .phone-tab-indicator, + .phone-tab-row { + transition: none; + } +} diff --git a/apps/web/src/phone-workspace.tsx b/apps/web/src/phone-workspace.tsx new file mode 100644 index 000000000..bfe0b8c2b --- /dev/null +++ b/apps/web/src/phone-workspace.tsx @@ -0,0 +1,145 @@ +/** Phone chrome: a floating bottom tab bar and a top bar that tucks away while reading. */ + +import { useEffect, useState } from "react"; +import { Count } from "@chopin/editor/count"; +import { DecisionIcon, DocumentIcon, MessageIcon, WrenchIcon } from "@chopin/icons"; + +import { DocumentActivityDot } from "./document-activity"; +import "./phone-workspace.css"; + +import type { CSSProperties, ReactNode, RefObject } from "react"; +import type { DocumentActivity } from "./document-activity"; +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. */ +const TOP_BAR_SLOP = 6; + +const SCROLLERS = + "[data-plan-scroll], [data-plan-decisions-scroll], [data-build-view-scroll], [data-chat-scroll]"; + +const ICONS = { build: WrenchIcon, chat: MessageIcon, decisions: DecisionIcon, plan: DocumentIcon }; +const NAMES = { build: "Build", chat: "Chat", decisions: "Decisions", plan: "Document" }; + +export function PhoneTabBar( + { + action, + active, + buildReason, + chatActivity, + destinations, + documentActivity, + labels, + onSelect, + unanswered, + }: { + /** The circular control at the bar's trailing end, such as an edit toggle. */ + action?: ReactNode; + active: WorkspaceDestination; + /** Set while Build cannot open yet: the id of the text that says why. */ + buildReason?: string; + chatActivity: { unread: number; busy: boolean }; + destinations: WorkspaceDestination[]; + documentActivity?: DocumentActivity; + labels: Record; + onSelect: (destination: WorkspaceDestination, current: boolean, source: HTMLElement) => void; + unanswered: number; + }, +) { + let index = destinations.indexOf(active); + return ( +
+ + {action} +
+ ); +} + +/** The active pane's own scroller, so tapping its tab again can return to the top. */ +export function phonePaneScroller(pane: Element | null | undefined): HTMLElement | undefined { + return pane?.querySelector(SCROLLERS) ?? undefined; +} + +/** + * Hides the top bar while reading down the document or Decisions and brings it back on any + * scroll up. Chat keeps its bar, because new messages scroll the transcript on their own. + */ +export function usePhoneTopBar( + frame: RefObject, + enabled: boolean, + destination: WorkspaceDestination, +): boolean { + let [hidden, setHidden] = useState(false); + useEffect(() => setHidden(false), [destination, enabled]); + useEffect(() => { + let element = frame.current; + if (!enabled || !element) return; + let reduced = matchMedia("(prefers-reduced-motion: reduce)"); + let last = new WeakMap(); + let scroll = (event: Event) => { + let target = event.target; + if (!(target instanceof HTMLElement) || !target.matches(SCROLLERS)) return; + let y = target.scrollTop; + let previous = last.get(target) ?? y; + last.set(target, 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); + }; + element.addEventListener("scroll", scroll, { capture: true, passive: true }); + return () => element.removeEventListener("scroll", scroll, { capture: true }); + }, [enabled, frame]); + return enabled && hidden; +} diff --git a/apps/web/src/project-sidebar-chrome.tsx b/apps/web/src/project-sidebar-chrome.tsx index c2ea80a52..4bad81ca3 100644 --- a/apps/web/src/project-sidebar-chrome.tsx +++ b/apps/web/src/project-sidebar-chrome.tsx @@ -1,3 +1,5 @@ +import { createContext } from "react"; + import panelIcon from "./assets/icons/panel-close.svg"; import { IconButton } from "./icon-button"; @@ -85,6 +87,19 @@ export function ProjectSidebarLoading( ); } +/** + * Lets a phone top bar host the sidebar trigger. While a bar holds a claim, the shell drops + * its own floating trigger, so the button scrolls away with the bar instead of over it. + */ +export type SidebarTriggerSlot = { + buttonRef: RefObject; + claim: () => () => void; + onExpand: () => void; + shortcut?: string; +}; + +export const SidebarTrigger = createContext(undefined); + export function ProjectSidebarExpandButton( { buttonRef, diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 2ce39692c..d0db7bf2a 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -49,6 +49,7 @@ import { import { newestDocumentMetadata } from "./document-actions"; import { DocumentActionsMenu } from "./document-actions-menu"; import { DocumentRename } from "./document-rename"; +import { PhoneHeader } from "./phone-header"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; import { useNavigationDocument } from "./navigation-shell"; @@ -359,7 +360,7 @@ export function RoomWorkspace( let repositoryRef = useRef(repository); repositoryRef.current = repository; let user = useMemo(() => cursor(handle), [handle]); - let { available, frame, mode } = useWorkspaceLayout(); + let { available, frame, mode, phone } = useWorkspaceLayout(); let workspaceIds = useWorkspaceIds(); let profile = workspaceProfile(presentation); let [implementation, setImplementation] = useState({ @@ -1150,20 +1151,34 @@ export function RoomWorkspace( } chatActivity={chatActivity} documentActivity={documentActivity(documentWatch, chatActivity.busy)} - header={ -
onDocumentAction(room, action)} - onEditingChange={setTitleEdit} - onRenamed={updateMetadata} - project={{ id: repository.id, name: repository.name }} - room={room} - /> - } + header={phone + ? ( + onDocumentAction(room, action)} + onEditingChange={setTitleEdit} + onRenamed={updateMetadata} + presentation={presentation} + room={room} + /> + ) + : ( +
onDocumentAction(room, action)} + onEditingChange={setTitleEdit} + onRenamed={updateMetadata} + project={{ id: repository.id, name: repository.name }} + room={room} + /> + )} controls={ <> } + phone={phone} onDesktopChatOpen={setDesktopChatOpen} onChatOpen={open => dispatch({ type: "set-chat", open })} onDestination={selectDestination} diff --git a/apps/web/src/viewport.test.ts b/apps/web/src/viewport.test.ts index 33ccc247b..0e3eb87e8 100644 --- a/apps/web/src/viewport.test.ts +++ b/apps/web/src/viewport.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { viewportVars } from "./viewport"; +import { keyboardOpen, viewportVars } from "./viewport"; describe("viewport variables", () => { it("uses the visible viewport and reports the covered keyboard area", () => { @@ -56,4 +56,9 @@ describe("viewport variables", () => { "--keyboard-inset": "316px", }); }); + + it("treats only a keyboard-sized cover as an open keyboard", () => { + expect(keyboardOpen(viewportVars(844, { height: 506, top: 22 }, 1))).toBe(true); + expect(keyboardOpen(viewportVars(844, { height: 790, top: 0 }, 1))).toBe(false); + }); }); diff --git a/apps/web/src/viewport.ts b/apps/web/src/viewport.ts index 47023c295..529e040d1 100644 --- a/apps/web/src/viewport.ts +++ b/apps/web/src/viewport.ts @@ -33,6 +33,13 @@ export function viewportVars( }; } +/** Taller than any browser toolbar, shorter than any software keyboard. */ +export const KEYBOARD_OPEN_MIN = 120; + +export function keyboardOpen(variables: ViewportVariables): boolean { + return parseFloat(variables["--keyboard-inset"]) >= KEYBOARD_OPEN_MIN; +} + /** Keeps the mounted workspace sized to the pixels the browser currently exposes. */ export function useVisualViewport(): void { useEffect(() => { @@ -45,6 +52,7 @@ export function useVisualViewport(): void { for (let [name, value] of Object.entries(variables)) { document.documentElement.style.setProperty(name, value); } + document.documentElement.toggleAttribute("data-keyboard-open", keyboardOpen(variables)); }; update(); diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index 9d31a9692..d7b567ef7 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -5,6 +5,7 @@ import { Count } from "@chopin/editor/count"; import { ContentSwapLayer } from "@chopin/editor/content-swap"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; import { ArrowLeftIcon, ChevronIcon, CollapseIcon, ExpandIcon } from "@chopin/icons"; +import { usePhone } from "@chopin/editor/pointer"; import { CHAT_CHOICE_STORAGE_KEY, @@ -26,6 +27,7 @@ import { listenForShortcuts } from "./global-shortcuts"; import { currentShortcutPlatform, shortcutLabel } from "./shortcuts"; import { DocumentActivityDot, documentActivityLabel } from "./document-activity"; import { BUILD_UNAVAILABLE } from "./decision-view-control"; +import { phonePaneScroller, PhoneTabBar, usePhoneTopBar } from "./phone-workspace"; import type { CSSProperties, Dispatch, ReactNode, RefObject } from "react"; import type { DocumentActivity } from "./document-activity"; @@ -86,7 +88,14 @@ export function useWorkspaceLayout() { document.removeEventListener("transitioncancel", measure); }; }, []); - return { available, frame, mode: workspaceSizing(available, 500).mode }; + // A phone keeps one destination at a time even when landscape gives it a wide window. + let phone = usePhone(); + return { + available, + frame, + mode: phone ? "compact" as const : workspaceSizing(available, 500).mode, + phone, + }; } /** Only the desktop Chat preference crosses a page load. */ @@ -135,6 +144,10 @@ export type WorkspaceProps = { onChatOpen: (open: boolean) => void; onDesktopChatOpen: (open: boolean) => void; onDestination: (destination: WorkspaceDocumentView) => void; + /** Phone class: bottom tabs, a collapsing top bar, and full-bleed panes. */ + phone?: boolean; + /** Extension point for a circular control at the end of the phone tab bar. */ + phoneAction?: ReactNode; unanswered: number; chatActivity: { unread: number; busy: boolean }; documentActivity?: DocumentActivity; @@ -236,6 +249,8 @@ export function Workspace( onChatOpen, onDesktopChatOpen, onDestination, + phone = false, + phoneAction, plan, presence, presentation: workspacePresentation, @@ -271,11 +286,12 @@ export function Workspace( // view, so a child left open under Decisions or Build is still in sight and one click away. let stackBar = !!nested && !lift; let planCovered = stackBar && view === "plan"; - let immediately = motionImmediately(); - let contentSwapMotion = motionContract("content-swap"); + // Phones crossfade rather than cut under reduced motion, so only keyboard input is immediate. + let immediately = phone ? motionImmediately(undefined, false) : motionImmediately(); + let contentSwapMotion = phone ? motionContract("tab-slide") : motionContract("content-swap"); let chatPresence = useTransitionPresence( presentation.chatVisible ? true : undefined, - 220, + phone ? contentSwapMotion.closeDuration : 220, immediately, ); let chatTrack = usePaneMotion(chatPresence.phase); @@ -371,6 +387,18 @@ export function Workspace( if (mode !== "split" || stranded) dismissChat(); else onDesktopChatOpen(false); }; + let topBarHidden = usePhoneTopBar(root, phone, destination); + let selectPhoneTab = (next: WorkspaceDestination, current: boolean, source: HTMLElement) => { + if (!current) return navigate(next, source); + let pane = root.current?.querySelector( + next === "chat" ? `#${CSS.escape(ids.pane.chat)}` : `[data-document-view="${next}"]`, + ); + phonePaneScroller(pane)?.scrollTo({ + behavior: motionImmediately() ? "auto" : "smooth", + top: 0, + }); + }; + let chatShortcutEnabled = !!chat; useEffect(() => { if (!chatShortcutEnabled) return; @@ -382,22 +410,27 @@ export function Workspace( className="workspace-root flex h-full flex-col overflow-hidden bg-ground" data-document-expanded={presentation.documentExpanded || undefined} data-header-hidden={headerHidden || undefined} + data-workspace-bar={topBarHidden ? "hidden" : undefined} data-workspace-mode={mode} + data-workspace-phone={phone || undefined} data-workspace-room={identity} data-workspace-surface={profile.surface} data-workspace-travel={travel.back ? "back" : undefined} ref={root} style={mode === "split" ? { "--chat-width": `${chatWidth}px` } as CSSProperties : undefined} > -
- {header} -
+ {/* The phone top bar is positioned against the workspace root, so it is not slotted. */} + {phone ? header : ( +
+ {header} +
+ )} - {mode !== "split" && ( + {mode !== "split" && !phone && (
+ {phone && profile.implementation && !buildEnabled && ( + {BUILD_UNAVAILABLE} + )} + {phone && ( + + )} ); } diff --git a/e2e/phone-workspace.e2e.ts b/e2e/phone-workspace.e2e.ts new file mode 100644 index 000000000..1fcba706f --- /dev/null +++ b/e2e/phone-workspace.e2e.ts @@ -0,0 +1,156 @@ +import { expect, openIsolatedRoom, test } from "./room"; +import { expectNoHorizontalOverflow, RESPONSIVE_SOURCE } from "./responsive"; +import { installVisualViewport, setVisualViewport } from "./visual-viewport"; + +import type { Page } from "@playwright/test"; + +const PHONE = { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } }; + +function topBar(page: Page) { + return page.locator(".phone-top-bar"); +} + +async function topOffset(page: Page): Promise { + return topBar(page).evaluate(element => element.getBoundingClientRect().top); +} + +test("a phone gets full-bleed panes under a top bar and a bottom tab bar", async ({ join, seed }) => { + await seed(RESPONSIVE_SOURCE); + let page = await join("ana", PHONE); + let root = page.locator(".workspace-root"); + await expect(root).toHaveAttribute("data-workspace-phone", ""); + await expect(root).toHaveAttribute("data-workspace-mode", "compact"); + await expect(page.locator(".phone-tab-row")).toBeVisible(); + + let frame = await page.locator(".workspace-frame").evaluate(element => { + let bounds = element.getBoundingClientRect(); + return { + left: bounds.left, + right: innerWidth - bounds.right, + radius: getComputedStyle(element).borderRadius, + }; + }); + expect(frame).toEqual({ left: 0, right: 0, radius: "0px" }); + + // The sidebar trigger lives in the bar, so it is the only one. + await expect(page.getByRole("button", { name: "Show sidebar" })).toHaveCount(1); + await expect(topBar(page).getByRole("button", { name: "Show sidebar" })).toBeVisible(); + await expect(topBar(page).getByRole("button", { name: /^Actions for / })).toBeVisible(); + for (let control of await topBar(page).getByRole("button").all()) { + let box = await control.boundingBox(); + expect(box!.height).toBeGreaterThanOrEqual(44); + expect(box!.width).toBeGreaterThanOrEqual(44); + } + await expectNoHorizontalOverflow(page); +}); + +test("the phone tab bar switches destinations in one bar and returns a tab to its top", async ({ join, seed }) => { + await seed(RESPONSIVE_SOURCE); + let page = await join("ana", PHONE); + let nav = page.getByRole("navigation", { name: "Workspace view" }); + let tabs = nav.getByRole("button"); + await expect(tabs).toHaveCount(4); + await expect(tabs.nth(0)).toHaveAccessibleName(/^Chat/); + await expect(tabs.nth(1)).toHaveAccessibleName("Document"); + await expect(tabs.nth(2)).toHaveAccessibleName(/^Decisions/); + await expect(tabs.nth(3)).toHaveAccessibleName("Build"); + await expect(tabs.nth(1)).toHaveAttribute("aria-current", "page"); + for (let tab of await tabs.all()) { + let box = (await tab.boundingBox())!; + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(44); + } + + let scroller = page.locator("[data-plan-scroll]"); + await scroller.evaluate(element => element.scrollTo({ top: 700 })); + await tabs.nth(0).click(); + await expect(page.getByRole("complementary", { name: "Chat" })).toBeVisible(); + await expect(tabs.nth(0)).toHaveAttribute("aria-current", "page"); + await tabs.nth(2).click(); + await expect(page.locator('[data-document-view="decisions"]')).toBeVisible(); + await tabs.nth(3).click(); + await expect(page.getByRole("region", { name: "Build", exact: true })).toBeVisible(); + await tabs.nth(1).click(); + await expect(page.locator('[data-document-view="plan"]')).toBeVisible(); + // Each pane keeps its place, and tapping the current tab again goes back to the top. + await expect.poll(() => scroller.evaluate(element => element.scrollTop)).toBe(700); + await tabs.nth(1).click(); + await expect.poll(() => scroller.evaluate(element => element.scrollTop)).toBe(0); +}); + +test("a landscape phone keeps the compact phone layout", async ({ join, seed }) => { + await seed(RESPONSIVE_SOURCE); + let page = await join("ana", { ...PHONE, viewport: { width: 734, height: 343 } }); + let root = page.locator(".workspace-root"); + await expect(root).toHaveAttribute("data-workspace-phone", ""); + await expect(root).toHaveAttribute("data-workspace-mode", "compact"); + await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0); + await expect(page.locator(".phone-tab-row")).toBeVisible(); +}); + +test("the top bar hides while reading down and returns on scroll up", async ({ join, seed }) => { + await seed(RESPONSIVE_SOURCE); + let page = await join("ana", PHONE); + let scroller = page.locator("[data-plan-scroll]"); + await expect(topBar(page)).toBeVisible(); + expect(await topOffset(page)).toBe(0); + + await scroller.evaluate(element => element.scrollTo({ top: 200 })); + await scroller.evaluate(element => element.scrollTo({ top: 600 })); + await expect(page.locator(".workspace-root")).toHaveAttribute("data-workspace-bar", "hidden"); + await expect.poll(() => topOffset(page)).toBeLessThan(-40); + + await scroller.evaluate(element => element.scrollTo({ top: 500 })); + await expect(page.locator(".workspace-root")).not.toHaveAttribute("data-workspace-bar", "hidden"); + await expect.poll(() => topOffset(page)).toBe(0); + + // Reduced motion never hides it. + await page.emulateMedia({ reducedMotion: "reduce" }); + await scroller.evaluate(element => element.scrollTo({ top: 1200 })); + await scroller.evaluate(element => element.scrollTo({ top: 1600 })); + await expect(page.locator(".workspace-root")).not.toHaveAttribute("data-workspace-bar", "hidden"); +}); + +test("the software keyboard hides the bottom chrome", async ({ baseURL, browser, room, seed }) => { + await seed(RESPONSIVE_SOURCE); + let { close, page } = await openIsolatedRoom( + browser, + baseURL!, + room, + "ana", + PHONE, + context => + installVisualViewport(context, { + height: 844, + offsetLeft: 0, + offsetTop: 0, + pageLeft: 0, + pageTop: 0, + scale: 1, + width: 390, + }), + ); + try { + let row = page.locator(".phone-tab-row"); + await expect(row).toBeVisible(); + await setVisualViewport(page, { event: "resize", height: 500 }); + await expect(page.locator("html")).toHaveAttribute("data-keyboard-open", ""); + await expect(row).toBeHidden(); + await setVisualViewport(page, { event: "resize", height: 844 }); + await expect(row).toBeVisible(); + } finally { + await close(); + } +}); + +test("desktop and touch tablets keep their layouts", async ({ join, seed }) => { + await seed(RESPONSIVE_SOURCE); + let desktop = await join("ana", { viewport: { width: 1440, height: 900 } }); + await expect(desktop.locator(".workspace-root")).not.toHaveAttribute("data-workspace-phone", ""); + await expect(desktop.locator(".phone-tab-row")).toHaveCount(0); + await desktop.context().close(); + + let tablet = await join("ana", { hasTouch: true, viewport: { width: 820, height: 1180 } }); + await expect(tablet.locator(".workspace-root")).not.toHaveAttribute("data-workspace-phone", ""); + await expect(tablet.locator(".phone-tab-row")).toHaveCount(0); +}); diff --git a/e2e/responsive-workspace.e2e.ts b/e2e/responsive-workspace.e2e.ts index 5847d818b..0f632038d 100644 --- a/e2e/responsive-workspace.e2e.ts +++ b/e2e/responsive-workspace.e2e.ts @@ -26,7 +26,8 @@ async function emulatedVisualViewportPage( })); } -async function expectCompactWorkspaceChrome(page: Page): Promise { +/** A touch phone: full-bleed panes between a top bar and a bottom tab bar. */ +async function expectPhoneWorkspaceChrome(page: Page): Promise { let header = page.getByRole("banner"); let nav = page.getByRole("navigation", { name: "Workspace view" }); let frame = page.locator(".workspace-frame"); @@ -58,11 +59,14 @@ async function expectCompactWorkspaceChrome(page: Page): Promise { shadow: style.boxShadow, }; }); - expect(surface.left).toBe(12); - expect(surface.right).toBe(12); - expect(surface.radius).toBe("12px"); + expect(surface.left).toBe(0); + expect(surface.right).toBe(0); + expect(surface.radius).toBe("0px"); expect(surface.overflow).toBe("hidden"); - expect(surface.shadow).not.toBe("none"); + let [navBox, frameBox] = await Promise.all([nav.boundingBox(), frame.boundingBox()]); + // The tab bar floats over the bottom of the panes, in the thumb zone. + expect(navBox!.y + navBox!.height).toBeLessThanOrEqual(frameBox!.y + frameBox!.height); + expect(navBox!.y).toBeGreaterThan(frameBox!.y + frameBox!.height / 2); let heights = await destinations.evaluateAll(buttons => buttons.map(button => button.getBoundingClientRect().height) @@ -89,7 +93,7 @@ test("a representative compact phone exposes one mounted destination at a time", await seed(RESPONSIVE_SOURCE); let page = await join("ana", { hasTouch: true, viewport: { width: 390, height: 844 } }); let nav = page.getByRole("navigation", { name: "Workspace view" }); - await expectCompactWorkspaceChrome(page); + await expectPhoneWorkspaceChrome(page); let projects = page.getByRole("button", { name: "Show sidebar" }); await projects.click(); let drawer = page.getByRole("dialog", { name: "Projects" }); @@ -369,9 +373,8 @@ test("a shifted visual viewport keeps workspace controls in the exposed rectangl let sidebarButton = page.getByRole("button", { name: "Show sidebar" }); await expect(sidebarButton).toBeVisible(); await expectInsideViewport(sidebarButton); - let workspaceNavigation = page.getByRole("navigation", { name: "Workspace view" }); - await expect(workspaceNavigation).toBeVisible(); - await expectInsideViewport(workspaceNavigation); + // A keyboard-sized cover hides the phone tab bar rather than squeezing it above the keys. + await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeHidden(); } finally { await context.close(); } @@ -619,7 +622,8 @@ test("Chromium visual viewport emulation keeps document editing above the keyboa offsetTop: 0, }); await expectInsideViewport(editor.locator(":scope > p").first()); - await expectInsideViewport(emulation.page.getByRole("navigation", { name: "Workspace view" })); + // A phone gives the keyboard the bottom edge: its tab bar steps aside. + await expect(emulation.page.getByRole("navigation", { name: "Workspace view" })).toBeHidden(); } finally { await emulation.close(); } diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index eba856cb1..9c7d58900 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -37,7 +37,7 @@ export { GitHubReferencesProvider } from "./github-references"; export type { Refusal } from "./history"; export { PlanEditor } from "./plan-editor"; export type { PlanEditorProps, PlanState } from "./plan-editor"; -export { usePointerCapabilities } from "./pointer"; +export { isPhone, usePhone, usePointerCapabilities } from "./pointer"; export { usePopoverDismissal } from "./popover-dismissal"; export { PlanProvider } from "./provider"; export type { PlanProviderOptions } from "./provider"; diff --git a/packages/editor/src/pointer.test.ts b/packages/editor/src/pointer.test.ts index 103d0700c..4b8ba371a 100644 --- a/packages/editor/src/pointer.test.ts +++ b/packages/editor/src/pointer.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { hasCoarsePointer } from "./pointer"; +import { + hasCoarsePointer, + isPhone, + PHONE_SIZE_QUERY, + PRIMARY_COARSE_POINTER_QUERY, +} from "./pointer"; function hybridMedia(query: string) { return { matches: query === "(any-pointer: coarse)" }; @@ -19,3 +24,16 @@ describe("coarse pointer availability", () => { expect(hasCoarsePointer(fineOnlyMedia)).toBe(false); }); }); + +describe("phone class", () => { + let media = (matches: string[]) => (query: string) => ({ matches: matches.includes(query) }); + + it("is a primary touch pointer on a narrow or short screen", () => { + expect(isPhone(media([PRIMARY_COARSE_POINTER_QUERY, PHONE_SIZE_QUERY]))).toBe(true); + }); + + it("leaves touch tablets and small fine-pointer windows out", () => { + expect(isPhone(media([PRIMARY_COARSE_POINTER_QUERY]))).toBe(false); + expect(isPhone(media([PHONE_SIZE_QUERY, "(any-pointer: coarse)"]))).toBe(false); + }); +}); diff --git a/packages/editor/src/pointer.ts b/packages/editor/src/pointer.ts index 684a80d88..7f73cd64f 100644 --- a/packages/editor/src/pointer.ts +++ b/packages/editor/src/pointer.ts @@ -1,16 +1,49 @@ -import { useEffect } from "react"; +import { useEffect, useLayoutEffect, useSyncExternalStore } from "react"; export const COARSE_POINTER_QUERY = "(any-pointer: coarse)"; export const PRIMARY_COARSE_POINTER_QUERY = "(pointer: coarse)"; +/** A phone is narrow in portrait or short in landscape. */ +export const PHONE_SIZE_QUERY = "(max-width: 600px), (max-height: 500px)"; -export function hasCoarsePointer( - media: (query: string) => Pick = matchMedia, -): boolean { +type Media = (query: string) => Pick; + +export function hasCoarsePointer(media: Media = matchMedia): boolean { return media(COARSE_POINTER_QUERY).matches; } +/** + * The phone class: a primary touch pointer on a small screen. Landscape phones stay phones, + * while tablets and touch laptops keep the wider layouts. Mirrored as `:root[data-phone]`. + */ +export function isPhone(media: Media = matchMedia): boolean { + return media(PRIMARY_COARSE_POINTER_QUERY).matches && media(PHONE_SIZE_QUERY).matches; +} + +function subscribePhone(notify: () => void): () => void { + let queries = [PRIMARY_COARSE_POINTER_QUERY, PHONE_SIZE_QUERY].map(query => matchMedia(query)); + for (let query of queries) query.addEventListener?.("change", notify); + // Size changes are also observed directly: emulated media lists may never fire `change`. + window.addEventListener("resize", notify); + return () => { + for (let query of queries) query.removeEventListener?.("change", notify); + window.removeEventListener("resize", notify); + }; +} + +function currentPhone(): boolean { + return isPhone(); +} + +export function usePhone(): boolean { + return useSyncExternalStore(subscribePhone, currentPhone, () => false); +} + /** Mirrors browser pointer capability into CSS so JS and touch sizing agree. */ export function usePointerCapabilities(): void { + let phone = usePhone(); + useLayoutEffect(() => { + document.documentElement.toggleAttribute("data-phone", phone); + }, [phone]); useEffect(() => { let coarse = matchMedia(COARSE_POINTER_QUERY); let primary = matchMedia(PRIMARY_COARSE_POINTER_QUERY); diff --git a/packages/visuals/theme.css b/packages/visuals/theme.css index 753a19ef0..dcc31d71b 100644 --- a/packages/visuals/theme.css +++ b/packages/visuals/theme.css @@ -92,6 +92,8 @@ --color-icon: var(--color-gray-500); --color-chat-body: oklch(0.38064 0.01903 136.21); --color-chat-pane: color-mix(in srgb, var(--color-ground) 30%, var(--color-page)); + /* Phone chrome floats over content: a translucent page that blurs what scrolls beneath. */ + --color-glass: color-mix(in oklch, var(--color-page) 84%, transparent); /* Petrol is the only accent: action, focus, presence and links all agree. */ --color-brand: oklch(0.50006 0.08514 210.06); /* #06707e */ @@ -240,11 +242,19 @@ --document-decisions-top-inset: calc(var(--spacing) * 12); --document-gutter: calc(var(--spacing) * 14); --document-gutter-compact: calc(var(--spacing) * 6); + --document-gutter-phone: calc(var(--spacing) * 5); --document-trailing-space: 60vh; --document-line-height: 1.6; --decision-marker-size: calc(var(--spacing) * 6); --choice-content-gap: calc(var(--spacing) * 3); + /* 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); + --phone-tab-bar-gap: calc(var(--spacing) * 2.5); + --phone-tab-row-max: 30rem; + --glass-filter: blur(20px) saturate(180%); + /* Empty states share one illustration and copy rhythm. */ --empty-state-icon-frame-size: calc(var(--spacing) * 14); --empty-state-icon-size: calc(var(--spacing) * 6); @@ -353,6 +363,10 @@ --child-scrim-opacity: 0.45; --child-zoom-push: 1.06; --child-bar-height: calc(var(--spacing) * 9); + /* Phone destinations glide sideways by tab order; the top bar tucks away while reading. */ + --tab-slide-dur: 380ms; + --tab-slide-ease: cubic-bezier(0.32, 0.72, 0, 1); + --phone-bar-dur: 250ms; --route-swap-out-dur: 80ms; --route-swap-in-dur: 150ms; --icon-swap-dur: 250ms; From 16da8abb7f05ba32086f1561a0efff531bcced27 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:04:44 +0100 Subject: [PATCH 02/10] Tuck the whole phone top bar away and fit chrome to landscape The hidden top bar now leaves the screen entirely, with a glass strip kept under the status bar, and comes back when focus moves into it. Its small title fades in only once the document's heading has scrolled under it. Landscape phones get a shorter top bar and a one-line tab bar that keep 44px targets. Child documents keep the sidebar trigger in the parent's bar, tapping Chat again returns to the newest message, rubber-banding no longer toggles the bar, and tabs use the shared press scale. Phones keep the 24px document gutter so margin comment markers keep their lane. Co-Authored-By: Claude Opus 5.5 --- apps/web/DESIGN.md | 2 +- apps/web/src/phone-header.tsx | 36 ++++++------ apps/web/src/phone-workspace.css | 92 +++++++++++++++++++++++++------ apps/web/src/phone-workspace.tsx | 59 +++++++++++++++++--- apps/web/src/workspace.tsx | 11 ++-- e2e/phone-workspace.e2e.ts | 31 +++++++++-- e2e/research-child-surface.e2e.ts | 3 +- e2e/responsive-comments.e2e.ts | 4 +- e2e/toolbar.e2e.ts | 7 +++ packages/visuals/theme.css | 3 +- 10 files changed, 189 insertions(+), 59 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 914fe5452..4f8e420a7 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -149,7 +149,7 @@ text only when people need it to act or recover. ## Layout -The workspace places Chat on the left and the document on the right when space permits. Chat and its top bar (breadcrumb and document title) sit directly on the ground; the document is a raised sheet that rises to the top margin, and its tab row holds the expand control, the Document/Decisions/Build views and presence. Expanding the document hides Chat and the top bar; a Chat button in the same place restores them. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). On a phone (a primary touch pointer with a width of 600px or less, or a height of 500px or less, so landscape phones count), the workspace drops its card and the raised sheet: panes run full-bleed with 1.25rem (20px) gutters under a translucent top bar that tucks away while reading down, above a floating bottom tab bar in the thumb zone. Destinations slide sideways in tab order, and crossfade under reduced motion. Images and diagrams can extend beyond the prose measure within those gutters; tables can grow toward the trailing gutter and retain their own scroll lane. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes. +The workspace places Chat on the left and the document on the right when space permits. Chat and its top bar (breadcrumb and document title) sit directly on the ground; the document is a raised sheet that rises to the top margin, and its tab row holds the expand control, the Document/Decisions/Build views and presence. Expanding the document hides Chat and the top bar; a Chat button in the same place restores them. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). On a phone (a primary touch pointer with a width of 600px or less, or a height of 500px or less, so landscape phones count), the workspace drops its card and the raised sheet: panes run full-bleed with the same 1.5rem (24px) gutters, so margin comment markers keep their lane, under a translucent top bar that tucks away while reading down, above a floating bottom tab bar in the thumb zone. Landscape phones use a shorter top bar and a one-line tab bar. Destinations slide sideways in tab order, and crossfade under reduced motion. Images and diagrams can extend beyond the prose measure within those gutters; tables can grow toward the trailing gutter and retain their own scroll lane. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes. Spacing starts from a 4px unit and uses the measured steps shown in the frontmatter. App chrome is dense; prose has larger margins and trailing space so the caret remains comfortable near the end of a document. Layout responds to both viewport and document container width. Safe-area insets and larger coarse-pointer targets are accounted for in the web styles. diff --git a/apps/web/src/phone-header.tsx b/apps/web/src/phone-header.tsx index c4c8e179c..7260d9df2 100644 --- a/apps/web/src/phone-header.tsx +++ b/apps/web/src/phone-header.tsx @@ -37,7 +37,8 @@ export function PhoneHeader( ) { let header = useRef(null); let trigger = useContext(SidebarTrigger); - let hostsTrigger = presentation.type === "document" && trigger !== undefined; + // A child's own header is never shown: its parent's bar, with the way back, hosts the trigger. + let hostsTrigger = presentation.type !== "child" && trigger !== undefined; let claim = trigger?.claim; useLayoutEffect(() => hostsTrigger ? claim?.() : undefined, [claim, hostsTrigger]); @@ -55,26 +56,21 @@ export function PhoneHeader( return (
- {presentation.type === "parent-with-child" - ? ( - - ) - : hostsTrigger && trigger && ( - - )} + {hostsTrigger && trigger && ( + + )} + {presentation.type === "parent-with-child" && ( + + )}
-
+
{editing && canManage && !archivedAt && presentation.type !== "parent-with-child" ? ( .phone-top-bar { - transform: translateY(calc(-1 * var(--phone-bar-height))); + transform: translateY(-100%); +} + +.workspace-root[data-workspace-phone]::before { + position: absolute; + z-index: 31; + inset-inline: 0; + top: 0; + height: env(safe-area-inset-top); + background: var(--color-glass); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); + content: ""; + pointer-events: none; +} + +/* The small title waits until the document's own title has scrolled away. */ +.phone-top-bar-title { + transition: opacity var(--duration-base) var(--ease-out); +} + +.workspace-root[data-workspace-title="hidden"] .phone-top-bar-title { + opacity: 0; } .phone-top-bar-slot { - display: grid; - place-items: center; + display: flex; + align-items: center; + justify-content: center; } .phone-top-bar-slot .btn-icon { @@ -193,13 +225,7 @@ user-select: none; -webkit-touch-callout: none; -webkit-user-select: none; - transition: - color var(--duration-base) var(--ease-out), - transform var(--duration-fast) var(--ease-out); -} - -.phone-tab:active { - transform: scale(0.94); + transition: color var(--duration-base) var(--ease-out); } .phone-tab[aria-current="page"] { @@ -266,11 +292,43 @@ border-radius: var(--radius-full); color: var(--color-text-primary); touch-action: manipulation; - transition: transform var(--duration-fast) var(--ease-out); } -.phone-tab-action:active { - transform: scale(0.96); +/* Landscape: a short window keeps chrome low, with labels beside their icons. */ +@media (max-height: 500px) { + .workspace-root[data-workspace-phone] { + --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-gap: calc(var(--spacing) * 1.5); + } + + .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)); + } + + .phone-tab { + flex-direction: row; + gap: calc(var(--spacing) * 1.5); + } + + .phone-tab-icon [data-nucleo-icon] { + width: calc(var(--spacing) * 4.5); + height: calc(var(--spacing) * 4.5); + } + + /* The label sits to the right now, so the count moves to the icon's leading corner. */ + .phone-tab-badge { + top: calc(var(--spacing) * -2.5); + right: calc(100% - var(--spacing) * 2); + left: auto; + } } /* Destinations ---------------------------------------------------------- */ diff --git a/apps/web/src/phone-workspace.tsx b/apps/web/src/phone-workspace.tsx index bfe0b8c2b..a25bf8930 100644 --- a/apps/web/src/phone-workspace.tsx +++ b/apps/web/src/phone-workspace.tsx @@ -71,7 +71,9 @@ export function PhoneTabBar( aria-describedby={unavailable ? buildReason : undefined} aria-disabled={unavailable || undefined} aria-label={labels[destination]} + aria-pressed={current} className="phone-tab" + data-press="small" key={destination} onClick={event => { if (!unavailable) onSelect(destination, current, event.currentTarget); @@ -113,33 +115,72 @@ export function phonePaneScroller(pane: Element | null | undefined): HTMLElement } /** - * Hides the top bar while reading down the document or Decisions and brings it back on any - * scroll up. Chat keeps its bar, because new messages scroll the transcript on their own. + * 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 + * 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. */ export function usePhoneTopBar( - frame: RefObject, + root: RefObject, enabled: boolean, destination: WorkspaceDestination, -): boolean { +): { hidden: boolean; titleHidden: boolean } { let [hidden, setHidden] = useState(false); + let [headingVisible, setHeadingVisible] = useState(true); useEffect(() => setHidden(false), [destination, enabled]); useEffect(() => { - let element = frame.current; + let element = root.current; if (!enabled || !element) return; let reduced = matchMedia("(prefers-reduced-motion: reduce)"); let last = new WeakMap(); + let measure = () => { + let scroller = element.querySelector( + '[data-document-view="plan"] [data-plan-scroll]', + ); + let heading = scroller?.querySelector(".plan-content h1"); + let bar = element.querySelector(".phone-top-bar"); + if (!scroller || !heading || !bar) return setHeadingVisible(false); + let barBottom = scroller.getBoundingClientRect().top + bar.offsetHeight; + setHeadingVisible(heading.getBoundingClientRect().bottom > barBottom); + }; let scroll = (event: Event) => { let target = event.target; if (!(target instanceof HTMLElement) || !target.matches(SCROLLERS)) return; - let y = target.scrollTop; + if (target.matches("[data-plan-scroll]")) measure(); + // Rubber-banding past either end is not a reading direction. + let y = Math.min( + Math.max(0, target.scrollTop), + Math.max(0, target.scrollHeight - target.clientHeight), + ); let previous = last.get(target) ?? y; last.set(target, 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); }; + let focus = (event: FocusEvent) => { + if (event.target instanceof Element && event.target.closest(".phone-top-bar")) { + setHidden(false); + } + }; + // The heading arrives with the document, after this effect first runs. + let content = new ResizeObserver(measure); + let observed = element.querySelector('[data-document-view="plan"]'); + if (observed) content.observe(observed); + let mutations = new MutationObserver(measure); + if (observed) mutations.observe(observed, { childList: true, subtree: true }); + measure(); element.addEventListener("scroll", scroll, { capture: true, passive: true }); - return () => element.removeEventListener("scroll", scroll, { capture: true }); - }, [enabled, frame]); - return enabled && hidden; + element.addEventListener("focusin", focus); + return () => { + content.disconnect(); + mutations.disconnect(); + element.removeEventListener("scroll", scroll, { capture: true }); + element.removeEventListener("focusin", focus); + }; + }, [enabled, root]); + return { + hidden: enabled && hidden, + titleHidden: enabled && destination === "plan" && headingVisible, + }; } diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index d7b567ef7..a8929d33a 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -387,15 +387,17 @@ export function Workspace( if (mode !== "split" || stranded) dismissChat(); else onDesktopChatOpen(false); }; - let topBarHidden = usePhoneTopBar(root, phone, destination); + let topBar = usePhoneTopBar(root, phone, destination); let selectPhoneTab = (next: WorkspaceDestination, current: boolean, source: HTMLElement) => { if (!current) return navigate(next, source); let pane = root.current?.querySelector( next === "chat" ? `#${CSS.escape(ids.pane.chat)}` : `[data-document-view="${next}"]`, ); - phonePaneScroller(pane)?.scrollTo({ + let scroller = phonePaneScroller(pane); + // Chat returns to its newest message; every other pane returns to its top. + scroller?.scrollTo({ behavior: motionImmediately() ? "auto" : "smooth", - top: 0, + top: next === "chat" ? scroller.scrollHeight : 0, }); }; @@ -410,11 +412,12 @@ export function Workspace( className="workspace-root flex h-full flex-col overflow-hidden bg-ground" data-document-expanded={presentation.documentExpanded || undefined} data-header-hidden={headerHidden || undefined} - data-workspace-bar={topBarHidden ? "hidden" : undefined} + data-workspace-bar={topBar.hidden ? "hidden" : undefined} data-workspace-mode={mode} data-workspace-phone={phone || undefined} data-workspace-room={identity} data-workspace-surface={profile.surface} + data-workspace-title={topBar.titleHidden ? "hidden" : undefined} data-workspace-travel={travel.back ? "back" : undefined} ref={root} style={mode === "split" ? { "--chat-width": `${chatWidth}px` } as CSSProperties : undefined} diff --git a/e2e/phone-workspace.e2e.ts b/e2e/phone-workspace.e2e.ts index 1fcba706f..cc6f1a395 100644 --- a/e2e/phone-workspace.e2e.ts +++ b/e2e/phone-workspace.e2e.ts @@ -18,7 +18,7 @@ test("a phone gets full-bleed panes under a top bar and a bottom tab bar", async await seed(RESPONSIVE_SOURCE); let page = await join("ana", PHONE); let root = page.locator(".workspace-root"); - await expect(root).toHaveAttribute("data-workspace-phone", ""); + await expect(root).toHaveAttribute("data-workspace-phone"); await expect(root).toHaveAttribute("data-workspace-mode", "compact"); await expect(page.locator(".phone-tab-row")).toBeVisible(); @@ -82,10 +82,22 @@ test("a landscape phone keeps the compact phone layout", async ({ join, seed }) await seed(RESPONSIVE_SOURCE); let page = await join("ana", { ...PHONE, viewport: { width: 734, height: 343 } }); let root = page.locator(".workspace-root"); - await expect(root).toHaveAttribute("data-workspace-phone", ""); + await expect(root).toHaveAttribute("data-workspace-phone"); await expect(root).toHaveAttribute("data-workspace-mode", "compact"); await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0); await expect(page.locator(".phone-tab-row")).toBeVisible(); + // Landscape chrome stays low: a short top bar and a one-line tab bar. + let [bar, tabs] = await Promise.all([ + topBar(page).boundingBox(), + page.getByRole("navigation", { name: "Workspace view" }).boundingBox(), + ]); + expect(bar!.height + tabs!.height).toBeLessThanOrEqual(90); + for ( + let tab of await page.getByRole("navigation", { name: "Workspace view" }).getByRole("button") + .all() + ) { + expect((await tab.boundingBox())!.height).toBeGreaterThanOrEqual(44); + } }); test("the top bar hides while reading down and returns on scroll up", async ({ join, seed }) => { @@ -94,16 +106,27 @@ test("the top bar hides while reading down and returns on scroll up", async ({ j let scroller = page.locator("[data-plan-scroll]"); await expect(topBar(page)).toBeVisible(); expect(await topOffset(page)).toBe(0); + // The small title waits for the document's own heading to scroll away. + let title = topBar(page).locator(".phone-top-bar-title"); + await expect(title).toHaveCSS("opacity", "0"); await scroller.evaluate(element => element.scrollTo({ top: 200 })); await scroller.evaluate(element => element.scrollTo({ top: 600 })); await expect(page.locator(".workspace-root")).toHaveAttribute("data-workspace-bar", "hidden"); await expect.poll(() => topOffset(page)).toBeLessThan(-40); + await expect(title).toHaveCSS("opacity", "1"); await scroller.evaluate(element => element.scrollTo({ top: 500 })); await expect(page.locator(".workspace-root")).not.toHaveAttribute("data-workspace-bar", "hidden"); await expect.poll(() => topOffset(page)).toBe(0); + // Focus moving into a hidden bar brings it back. + await scroller.evaluate(element => element.scrollTo({ top: 900 })); + await expect(page.locator(".workspace-root")).toHaveAttribute("data-workspace-bar", "hidden"); + await topBar(page).getByRole("button", { name: /^Actions for / }).focus(); + await expect(page.locator(".workspace-root")).not.toHaveAttribute("data-workspace-bar", "hidden"); + await page.keyboard.press("Escape"); + // Reduced motion never hides it. await page.emulateMedia({ reducedMotion: "reduce" }); await scroller.evaluate(element => element.scrollTo({ top: 1200 })); @@ -146,11 +169,11 @@ test("the software keyboard hides the bottom chrome", async ({ baseURL, browser, test("desktop and touch tablets keep their layouts", async ({ join, seed }) => { await seed(RESPONSIVE_SOURCE); let desktop = await join("ana", { viewport: { width: 1440, height: 900 } }); - await expect(desktop.locator(".workspace-root")).not.toHaveAttribute("data-workspace-phone", ""); + await expect(desktop.locator(".workspace-root")).not.toHaveAttribute("data-workspace-phone"); await expect(desktop.locator(".phone-tab-row")).toHaveCount(0); await desktop.context().close(); let tablet = await join("ana", { hasTouch: true, viewport: { width: 820, height: 1180 } }); - await expect(tablet.locator(".workspace-root")).not.toHaveAttribute("data-workspace-phone", ""); + await expect(tablet.locator(".workspace-root")).not.toHaveAttribute("data-workspace-phone"); await expect(tablet.locator(".phone-tab-row")).toHaveCount(0); }); diff --git a/e2e/research-child-surface.e2e.ts b/e2e/research-child-surface.e2e.ts index 08d1c4a56..add7bb7dc 100644 --- a/e2e/research-child-surface.e2e.ts +++ b/e2e/research-child-surface.e2e.ts @@ -841,7 +841,8 @@ test("a direct compact child fills the canvas and reduces motion to a crossfade" ); await page.setViewportSize({ width: 390, height: 844 }); await page.emulateMedia({ reducedMotion: "reduce" }); - await installPointerMedia(page, { coarse: true, primaryCoarse: true }); + // A narrow touch-capable window with a fine primary pointer: the compact, non-phone chrome. + await installPointerMedia(page, { coarse: true, primaryCoarse: false }); await authenticate(page, "ana", baseURL!); await page.goto(child.path); diff --git a/e2e/responsive-comments.e2e.ts b/e2e/responsive-comments.e2e.ts index 0b05f7bf2..e77821eac 100644 --- a/e2e/responsive-comments.e2e.ts +++ b/e2e/responsive-comments.e2e.ts @@ -148,7 +148,9 @@ test("a representative compact viewport keeps a passage above the sheet and rest expect(navBox).not.toBeNull(); expect( await page.evaluate(({ x, y }) => { - return !!document.elementFromPoint(x, y)?.closest("[data-plan-comment-sheet-backdrop]"); + // The phone tab bar sits at the bottom, under the sheet or its backdrop. + return !!document.elementFromPoint(x, y) + ?.closest("[data-plan-comment-sheet-backdrop], [data-plan-comment-sheet]"); }, { x: navBox!.x + navBox!.width / 2, y: navBox!.y + navBox!.height / 2, diff --git a/e2e/toolbar.e2e.ts b/e2e/toolbar.e2e.ts index 9fd9ad68c..098a778dc 100644 --- a/e2e/toolbar.e2e.ts +++ b/e2e/toolbar.e2e.ts @@ -636,6 +636,13 @@ test("an open slash menu follows its caret while the editor scrolls", async ({ b try { let block = content(emulation.page).getByText("Paragraph 9.", { exact: true }); await block.scrollIntoViewIfNeeded(); + // Start in the upper third, so the menu opens below the caret and has room to stay there: + // this test is about following a scroll, not about flipping to fit. + await block.evaluate(element => { + let scroller = element.closest("[data-plan-scroll]")!; + let offset = element.getBoundingClientRect().top - scroller.getBoundingClientRect().top; + scroller.scrollTop += offset - scroller.clientHeight / 3; + }); await block.selectText(); await emulation.page.keyboard.type("/"); let menu = emulation.page.getByRole("listbox", MENU); diff --git a/packages/visuals/theme.css b/packages/visuals/theme.css index dcc31d71b..fa1a44e23 100644 --- a/packages/visuals/theme.css +++ b/packages/visuals/theme.css @@ -93,7 +93,7 @@ --color-chat-body: oklch(0.38064 0.01903 136.21); --color-chat-pane: color-mix(in srgb, var(--color-ground) 30%, var(--color-page)); /* Phone chrome floats over content: a translucent page that blurs what scrolls beneath. */ - --color-glass: color-mix(in oklch, var(--color-page) 84%, transparent); + --color-glass: color-mix(in oklch, var(--color-page) 72%, transparent); /* Petrol is the only accent: action, focus, presence and links all agree. */ --color-brand: oklch(0.50006 0.08514 210.06); /* #06707e */ @@ -242,7 +242,6 @@ --document-decisions-top-inset: calc(var(--spacing) * 12); --document-gutter: calc(var(--spacing) * 14); --document-gutter-compact: calc(var(--spacing) * 6); - --document-gutter-phone: calc(var(--spacing) * 5); --document-trailing-space: 60vh; --document-line-height: 1.6; --decision-marker-size: calc(var(--spacing) * 6); From f6f18067c81b10c31410c3b4cf5f898d810e1728 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:21:09 +0100 Subject: [PATCH 03/10] Keep phone focus scrolling below the top bar and settle e2e timing Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-workspace.css | 6 ++++++ apps/web/src/phone-workspace.tsx | 3 ++- e2e/phone-workspace.e2e.ts | 24 ++++++++++++++++-------- e2e/responsive-workspace.e2e.ts | 4 ++++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index b98de2073..333bb0051 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -43,6 +43,12 @@ scroll-padding-block: calc(var(--phone-top-chrome) + var(--spacing) * 4) 30%; } +/* Focus and find-in-page scrolling stop below the top bar, never under it. */ +.workspace-root[data-workspace-phone] + :is([data-plan-decisions-scroll], [data-build-view-scroll], [data-chat-scroll]) { + scroll-padding-top: calc(var(--phone-top-chrome) + var(--spacing) * 2); +} + .workspace-root[data-workspace-phone] .workspace-chat-panel { padding-bottom: var(--phone-bottom-chrome); } diff --git a/apps/web/src/phone-workspace.tsx b/apps/web/src/phone-workspace.tsx index a25bf8930..d9d395ace 100644 --- a/apps/web/src/phone-workspace.tsx +++ b/apps/web/src/phone-workspace.tsx @@ -152,7 +152,8 @@ export function usePhoneTopBar( Math.max(0, target.scrollTop), Math.max(0, target.scrollHeight - target.clientHeight), ); - let previous = last.get(target) ?? y; + // Panes open at their top, so a first scroll event already has a direction. + let previous = last.get(target) ?? 0; last.set(target, y); if (reduced.matches || target.matches("[data-chat-scroll]")) return; if (y < TOP_BAR_REVEAL || previous - y > TOP_BAR_SLOP) setHidden(false); diff --git a/e2e/phone-workspace.e2e.ts b/e2e/phone-workspace.e2e.ts index cc6f1a395..3bda62357 100644 --- a/e2e/phone-workspace.e2e.ts +++ b/e2e/phone-workspace.e2e.ts @@ -2,7 +2,7 @@ import { expect, openIsolatedRoom, test } from "./room"; import { expectNoHorizontalOverflow, RESPONSIVE_SOURCE } from "./responsive"; import { installVisualViewport, setVisualViewport } from "./visual-viewport"; -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; const PHONE = { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } }; @@ -10,6 +10,14 @@ function topBar(page: Page) { return page.locator(".phone-top-bar"); } +/** Scroll a pane and let its scroll event land before the next step. */ +async function scrollPane(scroller: Locator, top: number): Promise { + await scroller.evaluate(async (element, value) => { + element.scrollTo({ top: value }); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }, top); +} + async function topOffset(page: Page): Promise { return topBar(page).evaluate(element => element.getBoundingClientRect().top); } @@ -62,7 +70,7 @@ test("the phone tab bar switches destinations in one bar and returns a tab to it } let scroller = page.locator("[data-plan-scroll]"); - await scroller.evaluate(element => element.scrollTo({ top: 700 })); + await scrollPane(scroller, 700); await tabs.nth(0).click(); await expect(page.getByRole("complementary", { name: "Chat" })).toBeVisible(); await expect(tabs.nth(0)).toHaveAttribute("aria-current", "page"); @@ -110,18 +118,18 @@ test("the top bar hides while reading down and returns on scroll up", async ({ j let title = topBar(page).locator(".phone-top-bar-title"); await expect(title).toHaveCSS("opacity", "0"); - await scroller.evaluate(element => element.scrollTo({ top: 200 })); - await scroller.evaluate(element => element.scrollTo({ top: 600 })); + await scrollPane(scroller, 200); + await scrollPane(scroller, 600); await expect(page.locator(".workspace-root")).toHaveAttribute("data-workspace-bar", "hidden"); await expect.poll(() => topOffset(page)).toBeLessThan(-40); await expect(title).toHaveCSS("opacity", "1"); - await scroller.evaluate(element => element.scrollTo({ top: 500 })); + await scrollPane(scroller, 500); await expect(page.locator(".workspace-root")).not.toHaveAttribute("data-workspace-bar", "hidden"); await expect.poll(() => topOffset(page)).toBe(0); // Focus moving into a hidden bar brings it back. - await scroller.evaluate(element => element.scrollTo({ top: 900 })); + await scrollPane(scroller, 900); await expect(page.locator(".workspace-root")).toHaveAttribute("data-workspace-bar", "hidden"); await topBar(page).getByRole("button", { name: /^Actions for / }).focus(); await expect(page.locator(".workspace-root")).not.toHaveAttribute("data-workspace-bar", "hidden"); @@ -129,8 +137,8 @@ test("the top bar hides while reading down and returns on scroll up", async ({ j // Reduced motion never hides it. await page.emulateMedia({ reducedMotion: "reduce" }); - await scroller.evaluate(element => element.scrollTo({ top: 1200 })); - await scroller.evaluate(element => element.scrollTo({ top: 1600 })); + await scrollPane(scroller, 1200); + await scrollPane(scroller, 1600); await expect(page.locator(".workspace-root")).not.toHaveAttribute("data-workspace-bar", "hidden"); }); diff --git a/e2e/responsive-workspace.e2e.ts b/e2e/responsive-workspace.e2e.ts index 0f632038d..449c03b42 100644 --- a/e2e/responsive-workspace.e2e.ts +++ b/e2e/responsive-workspace.e2e.ts @@ -594,6 +594,10 @@ test("Chromium visual viewport emulation keeps Chat controls above the keyboard" let nav = emulation.page.getByRole("navigation", { name: "Workspace view" }); await nav.getByRole("button", { name: /Chat/ }).click(); let chat = emulation.page.getByRole("complementary"); + // Phone destinations slide in; measure once the pane has arrived. + await chat.evaluate(element => + Promise.all(element.getAnimations().map(animation => animation.finished)) + ); let textarea = chatInput(chat); await textarea.focus(); await setVisualViewport(emulation.page, { From 6c206163af79f176388586cd07c5cb0776f5e43e Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:36:27 +0100 Subject: [PATCH 04/10] Do not count the keyboard twice when a phone reveals a decision field Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-workspace.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index 333bb0051..c7f66aba4 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -49,6 +49,14 @@ scroll-padding-top: calc(var(--phone-top-chrome) + var(--spacing) * 2); } +/* + * The phone workspace already shrinks to the visual viewport above the keyboard, so a field + * only needs breathing room, not the keyboard's height again, below it. + */ +.workspace-root[data-workspace-phone] .question-field { + scroll-margin-block-end: calc(var(--spacing) * 4); +} + .workspace-root[data-workspace-phone] .workspace-chat-panel { padding-bottom: var(--phone-bottom-chrome); } From 79591da1009afe14d00586a6ab1580939fc1e9a0 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:02:45 +0100 Subject: [PATCH 05/10] Settle phone chrome review details Measure the top bar title at most once a frame, mark the current tab with aria-current alone, keep the landscape Decisions count beside its label, and show the status-bar glass strip only while the top bar is tucked away. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-workspace.css | 18 ++++++++++++------ apps/web/src/phone-workspace.tsx | 13 ++++++++++++- e2e/discarded-mobile-geometry.e2e.ts | 2 +- e2e/document-activity.e2e.ts | 4 ++-- e2e/implementation-launcher.e2e.ts | 2 +- e2e/sidecar.e2e.ts | 2 +- 6 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index c7f66aba4..94faaee1b 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -95,12 +95,12 @@ transition: transform var(--phone-bar-dur) var(--ease-out); } -/* The whole bar tucks away; a glass strip stays under the status bar so prose never shows there. */ +/* The whole bar tucks away; a glass strip then takes its place under the status bar. */ .workspace-root[data-workspace-bar="hidden"] > .phone-top-bar { transform: translateY(-100%); } -.workspace-root[data-workspace-phone]::before { +.workspace-root[data-workspace-bar="hidden"]::before { position: absolute; z-index: 31; inset-inline: 0; @@ -279,6 +279,10 @@ white-space: nowrap; } +.phone-tab-badge-inline { + display: none; +} + .phone-tab-badge { position: absolute; top: calc(var(--spacing) * -1.5); @@ -337,11 +341,13 @@ height: calc(var(--spacing) * 4.5); } - /* The label sits to the right now, so the count moves to the icon's leading corner. */ + /* The label sits beside the icon, so the count follows the label inside the tab. */ .phone-tab-badge { - top: calc(var(--spacing) * -2.5); - right: calc(100% - var(--spacing) * 2); - left: auto; + display: none; + } + + .phone-tab-badge-inline { + display: inline-flex; } } diff --git a/apps/web/src/phone-workspace.tsx b/apps/web/src/phone-workspace.tsx index d9d395ace..187e7f583 100644 --- a/apps/web/src/phone-workspace.tsx +++ b/apps/web/src/phone-workspace.tsx @@ -71,7 +71,6 @@ export function PhoneTabBar( aria-describedby={unavailable ? buildReason : undefined} aria-disabled={unavailable || undefined} aria-label={labels[destination]} - aria-pressed={current} className="phone-tab" data-press="small" key={destination} @@ -100,6 +99,11 @@ export function PhoneTabBar( )} + {destination === "decisions" && unanswered > 0 && ( + + )} ); })} @@ -133,7 +137,13 @@ export function usePhoneTopBar( if (!enabled || !element) return; let reduced = matchMedia("(prefers-reduced-motion: reduce)"); let last = new WeakMap(); + let frame = 0; + // Collaborative edits mutate the document often; measure at most once a frame. let measure = () => { + if (!frame) frame = requestAnimationFrame(measureNow); + }; + let measureNow = () => { + frame = 0; let scroller = element.querySelector( '[data-document-view="plan"] [data-plan-scroll]', ); @@ -174,6 +184,7 @@ export function usePhoneTopBar( element.addEventListener("scroll", scroll, { capture: true, passive: true }); element.addEventListener("focusin", focus); return () => { + cancelAnimationFrame(frame); content.disconnect(); mutations.disconnect(); element.removeEventListener("scroll", scroll, { capture: true }); diff --git a/e2e/discarded-mobile-geometry.e2e.ts b/e2e/discarded-mobile-geometry.e2e.ts index edbe75d09..5c59202e3 100644 --- a/e2e/discarded-mobile-geometry.e2e.ts +++ b/e2e/discarded-mobile-geometry.e2e.ts @@ -14,7 +14,7 @@ test("compact Decisions keeps a discarded decision reachable in resolved history viewport: { width: 390, height: 844 }, }); let decisions = page.getByRole("button", { name: /^Decisions/ }); - if (await decisions.getAttribute("aria-pressed") !== "true") await decisions.click(); + if (await decisions.getAttribute("aria-current") !== "page") await decisions.click(); let card = questionnaire(page).filter({ has: page.getByRole("heading", { name: "Where should room state live?" }), }); diff --git a/e2e/document-activity.e2e.ts b/e2e/document-activity.e2e.ts index 2e093ed84..54cc734d2 100644 --- a/e2e/document-activity.e2e.ts +++ b/e2e/document-activity.e2e.ts @@ -31,8 +31,8 @@ async function rewrite(baseURL: string, room: string, plan: string): Promise { let decisions = page.getByRole("button", { name: /^Decisions/ }); - if (await decisions.getAttribute("aria-pressed") !== "true") await decisions.click(); - await expect(decisions).toHaveAttribute("aria-pressed", "true"); + if (await decisions.getAttribute("aria-current") !== "page") await decisions.click(); + await expect(decisions).toHaveAttribute("aria-current", "page"); } for ( diff --git a/e2e/implementation-launcher.e2e.ts b/e2e/implementation-launcher.e2e.ts index ae021ecee..254b72afb 100644 --- a/e2e/implementation-launcher.e2e.ts +++ b/e2e/implementation-launcher.e2e.ts @@ -57,7 +57,7 @@ test("Build is a compact workspace view after Decisions", async ({ seed, join: e "Build", ]); await build.tap(); - await expect(build).toHaveAttribute("aria-pressed", "true"); + await expect(build).toHaveAttribute("aria-current", "page"); await expect(view).toBeVisible(); await expect(content(page)).not.toBeVisible(); await expect(view.getByText("No tasks yet.", { exact: true })).toBeVisible(); diff --git a/e2e/sidecar.e2e.ts b/e2e/sidecar.e2e.ts index 31738eda6..58d3c4c57 100644 --- a/e2e/sidecar.e2e.ts +++ b/e2e/sidecar.e2e.ts @@ -1031,7 +1031,7 @@ test("a compact new-comment sheet blocks navigation and restores editor focus", ).toBe(true); await page.mouse.click(destinationPoint.x, destinationPoint.y); await expect(sheet).toHaveCount(0); - await expect(plan).toHaveAttribute("aria-pressed", "true"); + await expect(plan).toHaveAttribute("aria-current", "page"); await expect(editor).toBeFocused(); sheet = await openDraft(); await page.locator("[data-plan-comment-sheet-backdrop]").click({ From 1ccf11cbe5d77d997c13d5f7299b9afbbb0f5f7b Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:49:55 +0100 Subject: [PATCH 06/10] Renew design-contract review hashes for navigation-shell.tsx, viewport.ts and workspace.tsx - apps/web/src/navigation-shell.tsx: wraps the shell in the sidebar trigger claim context; the sidebar motion classes and width style are unchanged, only re-indented. - apps/web/src/viewport.ts: adds a boolean data-keyboard-open attribute; the --app-* property writes are unchanged. - apps/web/src/workspace.tsx: the phone branch chooses between literal frame and motion classes and the tab-slide motion contract; the paper layout's expand control and other reviewed sites are main's, unchanged. Co-Authored-By: Claude Opus 5.5 --- scripts/design-contract/exceptions/dynamic-web.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index c2959a07c..f8e6ac6cc 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -227,7 +227,7 @@ 1 ] ], - "sourceHash": "d8418a3c8293af8f7fb70bb4ec3210d8d442419301b84cd110e68de5c8b51726" + "sourceHash": "2d5faf85305b49b9e298aca9a1ade41909561175dc0d4d44fbda7471793ad4c3" }, { "file": "apps/web/src/navigation-drawer.tsx", @@ -255,7 +255,7 @@ 1 ] ], - "sourceHash": "7a500f7e32a5c4f8db23dfd0a11f66cb164a0ce0676f5ce1b0ff4df2607a410d" + "sourceHash": "118bf3770755db660ca06af9f24cb03dabe49a901f453b5bdfcbccb12c90a1db" }, { "file": "apps/web/src/workspace.tsx", @@ -276,7 +276,7 @@ 1 ] ], - "sourceHash": "f271af3baeb3b11d21422e597dea3da8a51f7a5e445327bd06323d10a2c93af7" + "sourceHash": "4ec004501afae96a55f8f9c90b925bd00077b25c56a4cd3e0cedf45e70b9df4a" }, { "file": "apps/web/src/navigation-dialog.tsx", From 9f6dcf82095733821c511e6be17cd209671fc2a1 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:20:01 +0100 Subject: [PATCH 07/10] Fit the phone specs from the merged mobile slices to the bottom tab bar - chat-phone: the phone top bar carries no presence, so the picker's own wait for cy replaces the presence-group check. - comment-sheet: the selection bar docks just above the tab bar (it already reads --phone-bottom-chrome), so measure against the tab bar, and seat the low passage where the bar now docks. - decisions-phone: phone tabs mark the current view with aria-current. - sidecar: poll the reply frame until it has grown around Send, instead of measuring once mid-growth (flaky in CI). Co-Authored-By: Claude Opus 5.5 --- e2e/chat-phone.e2e.ts | 2 +- e2e/comment-sheet.e2e.ts | 20 +++++++++++--------- e2e/decisions-phone.e2e.ts | 2 +- e2e/sidecar.e2e.ts | 16 +++++++++------- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/e2e/chat-phone.e2e.ts b/e2e/chat-phone.e2e.ts index 825acc2fa..112310207 100644 --- a/e2e/chat-phone.e2e.ts +++ b/e2e/chat-phone.e2e.ts @@ -63,7 +63,7 @@ test("a phone composer rests as one pill with a reference button, draft and send test("phone mention and reference pickers rest on the composer as a full-width list", async ({ join }) => { let page = await join("ana", PHONE); await join("cy"); - await expect(page.getByRole("group", { name: /People here: ana, cy$/ })).toBeVisible(); + // The phone top bar carries no presence; the picker below waits for cy instead. await openPhoneChat(page); let chat = chatPane(page); let draft = chatInput(chat); diff --git a/e2e/comment-sheet.e2e.ts b/e2e/comment-sheet.e2e.ts index ee62399ee..5fd35e49b 100644 --- a/e2e/comment-sheet.e2e.ts +++ b/e2e/comment-sheet.e2e.ts @@ -362,10 +362,11 @@ test("a phone selection docks its actions at the foot of the screen instead of a await expect(page.getByRole("toolbar", { name: "Text formatting" })).toHaveCount(0); await expect(bar.getByRole("button", { name: "Bold" })).toHaveCount(0); - // Docked near the bottom edge, never over the selection or the lines just under it. + // Docked just above the tab bar, never over the selection or the lines just under it. let barBox = await size(bar); - expect(barBox.y + barBox.height).toBeGreaterThan(844 - 48); - expect(barBox.y + barBox.height).toBeLessThanOrEqual(844); + let tabs = await size(page.getByRole("navigation", { name: "Workspace view" })); + expect(barBox.y + barBox.height).toBeGreaterThan(tabs.y - 48); + expect(barBox.y + barBox.height).toBeLessThanOrEqual(tabs.y); let selection = await page.evaluate(() => getSelection()!.getRangeAt(0).getBoundingClientRect().bottom ); @@ -527,13 +528,14 @@ test("paging between short and long threads keeps the medium detent and its back test("a selection low on a phone screen scrolls clear of the docked bar", async ({ join, seed }) => { await seed(SOURCE); let page = await join("ana", IPHONE); - let passage = content(page).locator("p").filter({ hasText: "Padding paragraph." }).nth(12); - // Put the passage on the last lines of the screen, where the bar will dock. - await passage.evaluate(element => { + let passage = content(page).locator("p").filter({ hasText: "Padding paragraph." }).nth(20); + // Put the passage on the last lines above the tab bar, where the selection bar will dock. + let tabs = await size(page.getByRole("navigation", { name: "Workspace view" })); + await passage.evaluate((element, dock) => { let scroller = element.closest("[data-plan-scroll]")!; - scroller.scrollTop += element.getBoundingClientRect().bottom - (innerHeight - 12); - }); - expect((await size(passage)).y).toBeGreaterThan(844 - 80); + scroller.scrollTop += element.getBoundingClientRect().bottom - (dock - 12); + }, tabs.y); + expect((await size(passage)).y).toBeGreaterThan(tabs.y - 80); await passage.selectText(); let bar = page.getByRole("toolbar", { name: "Selection actions" }); await expect(bar).toBeVisible(); diff --git a/e2e/decisions-phone.e2e.ts b/e2e/decisions-phone.e2e.ts index dde0fcfaa..ea4650184 100644 --- a/e2e/decisions-phone.e2e.ts +++ b/e2e/decisions-phone.e2e.ts @@ -200,7 +200,7 @@ test("phone: an open decision in the document is a chip that opens it in Decisio await expect( page.getByRole("navigation", { name: "Workspace view" }) .getByRole("button", { name: /^Decisions/ }), - ).toHaveAttribute("aria-pressed", "true"); + ).toHaveAttribute("aria-current", "page"); await expect( page.locator(`[data-document-view="decisions"] [data-plan-sidecar-questionnaire="${id}"]`), ).toBeFocused(); diff --git a/e2e/sidecar.e2e.ts b/e2e/sidecar.e2e.ts index 58d3c4c57..2df9fecbe 100644 --- a/e2e/sidecar.e2e.ts +++ b/e2e/sidecar.e2e.ts @@ -1385,13 +1385,15 @@ test("the reply field is one line until focused, then grows and reveals Send to initial.height, ); await expect(send).toBeEnabled(); - let contained = await Promise.all([frame.boundingBox(), send.boundingBox()]).then( - ([outer, button]) => - !!outer && !!button && button.x >= outer.x && button.y >= outer.y - && button.x + button.width <= outer.x + outer.width - && button.y + button.height <= outer.y + outer.height, - ); - expect(contained).toBe(true); + // The frame grows with the field; measure once it has caught up. + await expect.poll(() => + Promise.all([frame.boundingBox(), send.boundingBox()]).then( + ([outer, button]) => + !!outer && !!button && button.x >= outer.x && button.y >= outer.y + && button.x + button.width <= outer.x + outer.width + && button.y + button.height <= outer.y + outer.height, + ) + ).toBe(true); }); test("a reply joins the thread without a duplicate reply count", async ({ join, seed }) => { From b6d57bd0b831ece160dc0255305fb1a1339988e6 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:22:49 +0100 Subject: [PATCH 08/10] Keep the root's title and actions in the phone top bar while a child is open Nested documents now open as cards on the root sheet, with the stack bar carrying the way back, so the phone top bar no longer swaps in the child's title and a Return button. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-header.tsx | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/apps/web/src/phone-header.tsx b/apps/web/src/phone-header.tsx index 7260d9df2..e0dee9b56 100644 --- a/apps/web/src/phone-header.tsx +++ b/apps/web/src/phone-header.tsx @@ -1,7 +1,7 @@ /** The phone top bar: Documents on the left, a small centred title, and actions on the right. */ import { useContext, useLayoutEffect, useRef } from "react"; -import { ArrowLeftIcon, EllipsisIcon } from "@chopin/icons"; +import { EllipsisIcon } from "@chopin/icons"; import { DocumentActionsMenu } from "./document-actions-menu"; import { DocumentRename } from "./document-rename"; @@ -37,7 +37,7 @@ export function PhoneHeader( ) { let header = useRef(null); let trigger = useContext(SidebarTrigger); - // A child's own header is never shown: its parent's bar, with the way back, hosts the trigger. + // A child room renders its document alone; the root's bar keeps the trigger while a child is open. let hostsTrigger = presentation.type !== "child" && trigger !== undefined; let claim = trigger?.claim; useLayoutEffect(() => hostsTrigger ? claim?.() : undefined, [claim, hostsTrigger]); @@ -51,7 +51,6 @@ export function PhoneHeader( } onEditingChange(); }; - let title = presentation.type === "parent-with-child" ? presentation.childLabel : label; return (
@@ -59,19 +58,9 @@ export function PhoneHeader( {hostsTrigger && trigger && ( )} - {presentation.type === "parent-with-child" && ( - - )}
- {editing && canManage && !archivedAt && presentation.type !== "parent-with-child" + {editing && canManage && !archivedAt ? ( ) - : {title}} + : {label}} {archivedAt && Archived}
- {canManage && presentation.type !== "parent-with-child" && ( + {canManage && ( Date: Sun, 11 Oct 2026 11:26:40 +0100 Subject: [PATCH 09/10] Seat a child's stack bar under the phone top bar Nested documents put a stack bar with the way back at the top of the document panel; on phones that is under the translucent top bar, and the child card covered it. Place it below the top bar, above the card, and let it follow the bar when it tucks away. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-workspace.css | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index 94faaee1b..5ab84ba39 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -39,6 +39,22 @@ padding-inline: env(safe-area-inset-left) env(safe-area-inset-right); } +/* + * A child's stack bar sits under the top bar, and follows it when it tucks away. The child card + * starts higher, so its padding runs under the bar, and the bar stays above it. + */ +.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); +} + +.workspace-root[data-workspace-phone][data-workspace-bar="hidden"] .workspace-stack-bar { + transform: translateY(calc(-1 * var(--phone-bar-height))); +} + .workspace-root[data-workspace-phone] .plan [data-plan-scroll] { scroll-padding-block: calc(var(--phone-top-chrome) + var(--spacing) * 4) 30%; } @@ -394,6 +410,7 @@ } .workspace-root[data-workspace-phone] > .phone-top-bar, + .workspace-root[data-workspace-phone] .workspace-stack-bar, .phone-tab-indicator, .phone-tab-row { transition: none; From a96ebe64759f0a4dbe892964a8fd63f3ea127cdf Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:38:26 +0100 Subject: [PATCH 10/10] Hide a tucked child stack bar and run the phone child card full height The tucked stack bar now leaves the screen and hides, so nothing shows through the glass top bar, and the child card starts at the top edge like other phone panes, with its document inset below both bars, so no second band shows between them. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-workspace.css | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index 5ab84ba39..e7cfdd908 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -40,19 +40,36 @@ } /* - * A child's stack bar sits under the top bar, and follows it when it tucks away. The child card - * starts higher, so its padding runs under the bar, and the bar stays above it. + * 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); + transform var(--phone-bar-dur) var(--ease-out), + visibility 0s; } .workspace-root[data-workspace-phone][data-workspace-bar="hidden"] .workspace-stack-bar { - transform: translateY(calc(-1 * var(--phone-bar-height))); + 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); +} + +.workspace-root[data-workspace-phone][data-workspace-mode] + .workspace-document-stack + .anchored-child-card { + --document-top-inset: calc( + var(--phone-top-chrome) + var(--child-bar-height) + var(--spacing) * 4 + ); + inset: 0; } .workspace-root[data-workspace-phone] .plan [data-plan-scroll] {