From 73e44724b58cb3754ef1a276abb6ca908effac44 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:05:36 +0100 Subject: [PATCH 1/3] Polish phones: child reading mode, a cascade-proof 16px field floor and shared sheet tokens - Child documents on a phone open for reading; the parent's tab-bar pencil and docked formatting toolbar edit the open child instead of the hidden parent, and the child's decision cards save from the phone save bar. - The coarse-pointer 16px text-field floor is important, so unlayered `font: inherit` / `font-size: inherit` rules (question fields, the header rename) no longer drop fields below 16px and trigger iOS zoom. - Comment, documents and action sheets share one scrim, grabber and set of sheet durations and easing from the theme. The comment sheet's scrim was navy because it read an oklch triplet as rgb. - Wide-content and phone Chat markdown rules use :root[data-phone], which every route now sets. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/chat/markdown.css | 18 ++- apps/web/src/child-phone-chrome.ts | 29 ++++ apps/web/src/main.tsx | 8 +- apps/web/src/phone-sheets.css | 14 +- apps/web/src/room-workspace.tsx | 88 +++++++++--- apps/web/src/theme.css | 12 +- e2e/responsive-child-phone.e2e.ts | 212 +++++++++++++++++++++++++++++ e2e/responsive-touch-fields.e2e.ts | 137 +++++++++++++++++++ packages/editor/src/index.ts | 1 + packages/editor/src/save-bar.tsx | 42 +++++- packages/editor/src/styles.css | 153 ++++++++++++--------- packages/visuals/theme.css | 9 +- 12 files changed, 614 insertions(+), 109 deletions(-) create mode 100644 apps/web/src/child-phone-chrome.ts create mode 100644 e2e/responsive-child-phone.e2e.ts create mode 100644 e2e/responsive-touch-fields.e2e.ts diff --git a/apps/web/src/chat/markdown.css b/apps/web/src/chat/markdown.css index a2839539f..00a3693e1 100644 --- a/apps/web/src/chat/markdown.css +++ b/apps/web/src/chat/markdown.css @@ -95,14 +95,12 @@ } /* Phones read Chat at the document's prose size, and a code block wraps rather than scrolling sideways. */ -@media (pointer: coarse) and (max-width: 600px), (pointer: coarse) and (max-height: 500px) { - .chat-markdown { - font-size: var(--text-base); - line-height: var(--text-base--line-height); - } - - .chat-markdown pre { - overflow-x: visible; - white-space: pre-wrap; - } +:root[data-phone] .chat-markdown { + font-size: var(--text-base); + line-height: var(--text-base--line-height); +} + +:root[data-phone] .chat-markdown pre { + overflow-x: visible; + white-space: pre-wrap; } diff --git a/apps/web/src/child-phone-chrome.ts b/apps/web/src/child-phone-chrome.ts new file mode 100644 index 000000000..a22b0d163 --- /dev/null +++ b/apps/web/src/child-phone-chrome.ts @@ -0,0 +1,29 @@ +/** + * A child document on a phone borrows its parent's tab bar. + * + * The child opens over the parent's Document tab, so the bar's trailing control edits the + * child, not the parent hidden beneath it, and the child's formatting docks where the parent's + * would. The parent owns the bar; the child reports how it is held and how to toggle it. + */ + +import { createContext } from "react"; + +/** How an open child is held, for the parent's bar to show. */ +export type ChildEditState = { docked: boolean; editing: boolean }; + +export type ChildPhoneChrome = { + /** Host for the docked formatting toolbar, present while the child reports it docked. */ + formatDock: HTMLElement | null; + /** Undefined while the child has no reading mode, such as for a viewer. */ + report: (state: ChildEditState | undefined, toggle?: () => void) => void; + /** Bring the parent's Document tab, which holds the child, forward. */ + showDocument: () => void; + /** Whether the child is on screen: the Document tab is showing. */ + visible: boolean; +}; + +export const ChildPhoneChromeContext = createContext(undefined); + +export function sameEditState(a?: ChildEditState, b?: ChildEditState): boolean { + return a?.docked === b?.docked && a?.editing === b?.editing; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5ad3f0f97..a044ba804 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -26,11 +26,16 @@ if (!root) throw new Error("missing #root"); function Root() { useFocusInput(); useMotionInput(); - usePointerCapabilities(); useVisualViewport(); return ; } +/** Every route, development pages included, mirrors the phone class into CSS. */ +function PointerCapabilities() { + usePointerCapabilities(); + return null; +} + let content = isDesignAuditRoute(location.pathname, import.meta.env.DEV) ? import("./design-audit/page").then(({ DesignAuditPage }) => ) : isDiagramGalleryRoute(location.pathname, import.meta.env.DEV) @@ -47,6 +52,7 @@ void Promise.all([content, import("./icon-tooltip"), agentation]).then( ([value, { IconTooltip }, feedback]) => { createRoot(root).render( + {value} {feedback} diff --git a/apps/web/src/phone-sheets.css b/apps/web/src/phone-sheets.css index 37ec6996d..1db9f2431 100644 --- a/apps/web/src/phone-sheets.css +++ b/apps/web/src/phone-sheets.css @@ -9,7 +9,7 @@ z-index: 70; background: var(--color-scrim); opacity: calc(1 - var(--drawer-swipe-progress, 0)); - transition: opacity var(--sheet-rise-dur) var(--tab-slide-ease); + transition: opacity var(--sheet-rise-dur) var(--sheet-ease); } .phone-sheet-backdrop[data-starting-style], @@ -35,7 +35,7 @@ pointer-events: auto; overscroll-behavior: contain; transform: translateY(var(--drawer-swipe-movement-y, 0px)); - transition: transform var(--sheet-rise-dur) var(--tab-slide-ease); + transition: transform var(--sheet-rise-dur) var(--sheet-ease); } :is(.documents-sheet, .action-sheet)[data-swiping] { @@ -53,12 +53,12 @@ .phone-sheet-grabber { display: block; - width: calc(var(--spacing) * 9); - height: var(--spacing); + width: var(--sheet-grabber-width); + height: var(--sheet-grabber-height); flex: none; - margin: calc(var(--spacing) * 1.5) auto 0; + margin: var(--sheet-grabber-inset) auto 0; border-radius: var(--radius-full); - background: var(--color-gray-300); + background: var(--color-grabber); } /* A 44px target around the small filled circle iOS sheets close with. */ @@ -120,7 +120,7 @@ inset: 0; background: var(--color-scrim); opacity: 0; - transition: opacity var(--sheet-rise-dur) var(--tab-slide-ease); + transition: opacity var(--sheet-rise-dur) var(--sheet-ease); } .documents-sheet[data-nested-drawer-open]::after { diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 3199c7889..bf77d22e8 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { flushSync } from "react-dom"; import { documentPath } from "@chopin/protocol/document-url"; import { ArchiveIcon, CheckIcon, ChevronIcon, DocumentIcon, PencilIcon } from "@chopin/icons"; @@ -9,6 +18,7 @@ import { countUnanswered, cursor, Decisions, + DecisionSaveHost, documentEditMode, documentHasPlanningContent, Face, @@ -30,6 +40,7 @@ import { import { BuildView } from "./build-view"; import { Chat } from "./chat/chat"; +import { ChildPhoneChromeContext, sameEditState } from "./child-phone-chrome"; import { ChildProvenance } from "./child-provenance"; import { shouldShowResearchActionError, useResearchOfferLinks } from "./chat/research-offer"; import type { ResearchDraftController } from "./chat/research-draft-controller"; @@ -506,7 +517,9 @@ export function RoomWorkspace( [chatActive], ); let planVisible = workspacePresentation.documentVisible && view === "plan"; - let editorFocused = useEditorFocus(frame); + // A child is not inside a workspace frame of its own, so its own surface scopes its editor. + let childSurface = useRef(null); + let editorFocused = useEditorFocus(presentation.type === "child" ? childSurface : frame); let latestPlanVisible = useRef(planVisible); latestPlanVisible.current = planVisible; let latestBusy = useRef(chatActivity.busy); @@ -948,16 +961,52 @@ export function RoomWorkspace( arrived.current = true; if (opensEditing({ empty: planState.empty })) setEditing(true); }, [planState.synced, planState.empty]); + // A child on a phone borrows its parent's tab bar (see child-phone-chrome.ts). + let childChrome = useContext(ChildPhoneChromeContext); + let childRoom = presentation.type === "child"; + let childOpen = presentation.type === "parent-with-child"; + let [childEdit, setChildEdit] = useState<{ docked: boolean; editing: boolean }>(); + let childToggle = useRef<() => void>(undefined); + let reportChildEdit = useCallback( + (state?: { docked: boolean; editing: boolean }, toggle?: () => void) => { + childToggle.current = toggle; + setChildEdit(current => sameEditState(current, state) ? current : state); + }, + [], + ); + // What this room's own editor shows: a child is on screen only while its parent's Document is. + let shown = childRoom ? !!childChrome?.visible : planVisible && !childOpen; // Only the document's own keyboard gets formatting: never Chat, a comment reply, or the title. - let docked = editMode === "editing" && keyboardOpen && planVisible && editorFocused; + let docked = editMode === "editing" && keyboardOpen && shown && editorFocused; let toggleEditing = () => { // Synchronous, so the editor takes focus inside the tap and iOS raises the keyboard. flushSync(() => { setEditing(!editing); // Chat opens over the document without changing `view`, so ask what is on screen. - if (!editing && !planVisible) selectDestination("plan"); + if (editing || shown) return; + if (childRoom) childChrome?.showDocument(); + else selectDestination("plan"); }); }; + let latestToggle = useRef(toggleEditing); + latestToggle.current = toggleEditing; + let reportEdit = childChrome?.report; + useLayoutEffect(() => { + reportEdit?.( + editMode ? { docked, editing: editMode === "editing" } : undefined, + () => latestToggle.current(), + ); + }, [docked, editMode, reportEdit]); + useEffect(() => () => reportEdit?.(undefined), [reportEdit]); + let childChromeValue = useMemo( + () => ({ + formatDock, + report: reportChildEdit, + showDocument: () => latestSelectDestination.current("plan"), + visible: planVisible, + }), + [formatDock, planVisible, reportChildEdit], + ); let actOnResearchOffer = ( offerId: string, choice: "research" | "dismiss" | "resume", @@ -1085,8 +1134,6 @@ export function RoomWorkspace( /> ); - // A child room renders its document alone, with no tab bar for the edit toggle. - let childRoom = presentation.type === "child"; let plan = (

{announcement.text} @@ -1146,14 +1194,20 @@ export function RoomWorkspace( className="child-document-scroll min-h-0 flex-1" data-document-view="plan" > - {plan} + {/* With no Decisions pane, a phone saves the child's cards from a bar of its own. */} + {plan} ); } + // The tab bar's pencil edits the document on screen: an open child, or else this one. + let barEdit = childOpen + ? childEdit && { ...childEdit, toggle: () => childToggle.current?.() } + : editMode && { docked, editing: editMode === "editing", toggle: toggleEditing }; + return ( - <> +

{announcement.text}

@@ -1231,7 +1285,7 @@ export function RoomWorkspace( archivedAt={workspaceArchivedAt} canManage={effectiveCanManage} editing={titleEdit} - editingDocument={editMode === "editing"} + editingDocument={!!barEdit?.editing} label={metadata.title} onAction={action => onDocumentAction(room, action)} onEditingChange={setTitleEdit} @@ -1268,12 +1322,12 @@ export function RoomWorkspace( } status={statusNotices} ids={workspaceIds} - phoneAction={editMode && ( + phoneAction={barEdit && (