Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/web/src/chat/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
}
Expand Down
18 changes: 8 additions & 10 deletions apps/web/src/chat/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
29 changes: 29 additions & 0 deletions apps/web/src/child-phone-chrome.ts
Original file line number Diff line number Diff line change
@@ -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<ChildPhoneChrome | undefined>(undefined);

export function sameEditState(a?: ChildEditState, b?: ChildEditState): boolean {
return a?.docked === b?.docked && a?.editing === b?.editing;
}
8 changes: 7 additions & 1 deletion apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,16 @@ if (!root) throw new Error("missing #root");
function Root() {
useFocusInput();
useMotionInput();
usePointerCapabilities();
useVisualViewport();
return <App />;
}

/** 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 }) => <DesignAuditPage />)
: isDiagramGalleryRoute(location.pathname, import.meta.env.DEV)
Expand All @@ -47,6 +52,7 @@ void Promise.all([content, import("./icon-tooltip"), agentation]).then(
([value, { IconTooltip }, feedback]) => {
createRoot(root).render(
<StrictMode>
<PointerCapabilities />
{value}
<IconTooltip />
{feedback}
Expand Down
14 changes: 7 additions & 7 deletions apps/web/src/phone-sheets.css
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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] {
Expand All @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/phone-workspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
92 changes: 75 additions & 17 deletions apps/web/src/room-workspace.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,6 +18,7 @@ import {
countUnanswered,
cursor,
Decisions,
DecisionSaveHost,
documentEditMode,
documentHasPlanningContent,
Face,
Expand All @@ -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";
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
let editorFocused = useEditorFocus(presentation.type === "child" ? childSurface : frame);
let latestPlanVisible = useRef(planVisible);
latestPlanVisible.current = planVisible;
let latestBusy = useRef(chatActivity.busy);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = (
<GitHubReferencesProvider value={githubReferences}>
<PlanEditor
Expand All @@ -1110,8 +1161,8 @@ export function RoomWorkspace(
connection={status === "deleted" ? "closed" : status}
key={workspaceArchivedAt ? "archived" : "active"}
disclosureMotion={motionContract("collapse")}
editMode={childRoom ? undefined : editMode}
formatDock={!childRoom && docked ? formatDock : undefined}
editMode={editMode}
formatDock={docked ? childRoom ? childChrome?.formatDock : formatDock : undefined}
motionImmediately={settleMotionImmediately}
onScrollTop={setPlanScrollTop}
onState={setPlanState}
Expand All @@ -1136,6 +1187,7 @@ export function RoomWorkspace(
className="child-document flex h-full min-h-0 flex-col bg-page"
data-workspace-room={room}
data-workspace-surface="child"
ref={childSurface}
>
<p aria-live="polite" className="sr-only" role="status">
<span key={announcement.sequence}>{announcement.text}</span>
Expand All @@ -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. */}
<DecisionSaveHost phone={phone}>{plan}</DecisionSaveHost>
</section>
</div>
);
}

// 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 (
<>
<ChildPhoneChromeContext.Provider value={childChromeValue}>
<p aria-live="polite" className="sr-only" role="status">
<span key={announcement.sequence}>{announcement.text}</span>
</p>
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -1268,12 +1326,12 @@ export function RoomWorkspace(
}
status={statusNotices}
ids={workspaceIds}
phoneAction={editMode && (
phoneAction={barEdit && (
<button
aria-label={editing ? "Done editing" : "Edit document"}
aria-label={barEdit.editing ? "Done editing" : "Edit document"}
className="phone-tab-action"
data-editing={editing || undefined}
onClick={toggleEditing}
data-editing={barEdit.editing || undefined}
onClick={barEdit.toggle}
type="button"
>
<PencilIcon aria-hidden="true" data-edit-icon="edit" size={24} />
Expand All @@ -1282,7 +1340,7 @@ export function RoomWorkspace(
)}
// Formatting rides above the keyboard while typing. With the keyboard down the tabs
// return, and a selection gets the ordinary bubble instead.
phoneToolbar={docked && <div className="h-full" ref={setFormatDock} />}
phoneToolbar={barEdit?.docked && <div className="h-full" ref={setFormatDock} />}
identity={room}
mode={mode}
presence={<RoomMembers members={members} ring="page" />}
Expand Down Expand Up @@ -1336,6 +1394,6 @@ export function RoomWorkspace(
unanswered={unanswered}
view={view}
/>
</>
</ChildPhoneChromeContext.Provider>
);
}
13 changes: 7 additions & 6 deletions apps/web/src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -1250,11 +1250,12 @@ button.chat-tool-call-toggle {

/*
* Text fields on touch never drop below the 16px that stops iOS zooming in on focus.
* Field classes outrank any element selector, so the small type steps are raised on
* the field itself: whichever rule sizes it resolves to 16px and larger steps keep
* their size. The document body is already 16px and keeps its smaller inline steps.
* In the base layer so the floor never outranks a field's own larger size, and
* specific enough to beat the preflight that makes fields inherit their container's.
* The small type steps are raised on the field itself, so a field sized by a token, and
* its placeholder, resolve to 16px. Important, because an unlayered `font: inherit` or
* `font-size: inherit` would otherwise beat any layered floor; `1em` is the size such a
* field inherits, so one set in larger type keeps it. The tradeoff: a field given a larger
* size by its own class would fall back to its inherited size here. The document body is
* already 16px and keeps its smaller inline steps.
*/
@layer base {
@media (pointer: coarse) {
Expand All @@ -1263,7 +1264,7 @@ button.chat-tool-call-toggle {
--text-xs: var(--field-text-min);
--text-sm: var(--field-text-min);
--text-base: var(--field-text-min);
font-size: var(--field-text-min);
font-size: max(var(--field-text-min), 1em) !important;
}
}
}
Expand Down
Loading
Loading