diff --git a/apps/web/src/chat/composer.css b/apps/web/src/chat/composer.css index 94b4aa178..16e58b690 100644 --- a/apps/web/src/chat/composer.css +++ b/apps/web/src/chat/composer.css @@ -287,8 +287,8 @@ * transcript keeps the space above the keyboard. Every phone-only rule lives * in this block, behind this one condition. */ -/* Landscape phones too; the shared `:root[data-phone]` hook can replace this condition. */ -@media (pointer: coarse) and (max-width: 600px), (pointer: coarse) and (max-height: 500px) { +/* The phone class, landscape phones too. */ +:root[data-phone] { .chat-composer { padding-inline: 0.5rem; /* The home indicator's inset, but not while the keyboard covers it. */ @@ -343,7 +343,7 @@ .chat-composer[data-planner="off"] .composer-mode-trigger { display: none; } - :root[data-plan-coarse-pointer] .chat-composer .composer-surface button:not([role="option"]) { + &[data-plan-coarse-pointer] .chat-composer .composer-surface button:not([role="option"]) { min-width: var(--composer-pill-control); min-height: var(--composer-pill-control); } 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/phone-workspace.css b/apps/web/src/phone-workspace.css index 606e24ce8..140ec09b5 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -76,6 +76,18 @@ scroll-padding-block: calc(var(--phone-top-chrome) + var(--spacing) * 4) 30%; } +/* + * While formatting docks above the keyboard, the caret's block stops short of the dock. The + * workspace already ends at the keyboard (`--app-height`), so only the dock's row is added. + */ +.workspace-root[data-workspace-phone]:has(.phone-tab-row[data-phone-tab-toolbar]) + .plan + [data-plan-scroll] { + scroll-padding-bottom: calc( + var(--phone-tab-bar-height) + var(--phone-tab-bar-offset) + var(--spacing) * 3 + ); +} + /* 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]) { diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 3199c7889..563986457 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,56 @@ 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"; + // A child opening over this document takes the pencil, so this one goes back to reading. + useEffect(() => { + if (childOpen) setEditing(false); + }, [childOpen]); + 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 +1138,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 +1198,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 +1289,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 +1326,12 @@ export function RoomWorkspace( } status={statusNotices} ids={workspaceIds} - phoneAction={editMode && ( + phoneAction={barEdit && (