diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 70dd3f143..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). 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/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..e0dee9b56 --- /dev/null +++ b/apps/web/src/phone-header.tsx @@ -0,0 +1,91 @@ +/** The phone top bar: Documents on the left, a small centred title, and actions on the right. */ + +import { useContext, useLayoutEffect, useRef } from "react"; +import { 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); + // 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]); + + 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(); + }; + + return ( +
+
+ {hostsTrigger && trigger && ( + + )} +
+
+ {editing && canManage && !archivedAt + ? ( + { + onRenamed(detail.channel); + finishEdit(); + }} + /> + ) + : {label}} + {archivedAt && Archived} +
+
+ {canManage && ( + 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..e7cfdd908 --- /dev/null +++ b/apps/web/src/phone-workspace.css @@ -0,0 +1,435 @@ +/* + * 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; +} + +/* Margin comment markers keep their lane, so phones keep the compact 24px gutter. */ +.workspace-root[data-workspace-phone] :is(.plan, .plan-decisions) { + --plan-gutter: var(--document-gutter-compact); +} + +.workspace-root[data-workspace-phone] .build-view { + --build-gutter: var(--document-gutter-compact); +} + +.workspace-root[data-workspace-phone] .plan-comment-orphans { + top: calc(var(--phone-top-chrome) + var(--spacing) * 2); +} + +.workspace-root[data-workspace-phone] .workspace-frame { + margin: 0; + padding-inline: env(safe-area-inset-left) env(safe-area-inset-right); +} + +/* + * 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), + visibility 0s; +} + +.workspace-root[data-workspace-phone][data-workspace-bar="hidden"] .workspace-stack-bar { + 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] { + 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); +} + +/* + * 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); +} + +.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: + minmax(var(--control-target-coarse), auto) minmax(0, 1fr) minmax( + var(--control-target-coarse), + auto + ); + 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); +} + +/* 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-bar="hidden"]::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: flex; + align-items: center; + justify-content: 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); +} + +.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-inline { + display: none; +} + +.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; +} + +/* 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 beside the icon, so the count follows the label inside the tab. */ + .phone-tab-badge { + display: none; + } + + .phone-tab-badge-inline { + display: inline-flex; + } +} + +/* 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, + .workspace-root[data-workspace-phone] .workspace-stack-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..187e7f583 --- /dev/null +++ b/apps/web/src/phone-workspace.tsx @@ -0,0 +1,198 @@ +/** 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, 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( + root: RefObject, + enabled: boolean, + destination: WorkspaceDestination, +): { hidden: boolean; titleHidden: boolean } { + let [hidden, setHidden] = useState(false); + let [headingVisible, setHeadingVisible] = useState(true); + useEffect(() => setHidden(false), [destination, enabled]); + useEffect(() => { + let element = root.current; + 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]', + ); + 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; + 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), + ); + // 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); + 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 }); + element.addEventListener("focusin", focus); + return () => { + cancelAnimationFrame(frame); + 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/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..a8929d33a 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,20 @@ export function Workspace( if (mode !== "split" || stranded) dismissChat(); else onDesktopChatOpen(false); }; + 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}"]`, + ); + let scroller = phonePaneScroller(pane); + // Chat returns to its newest message; every other pane returns to its top. + scroller?.scrollTo({ + behavior: motionImmediately() ? "auto" : "smooth", + top: next === "chat" ? scroller.scrollHeight : 0, + }); + }; + let chatShortcutEnabled = !!chat; useEffect(() => { if (!chatShortcutEnabled) return; @@ -382,22 +412,28 @@ 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={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} > -
- {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/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/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/phone-workspace.e2e.ts b/e2e/phone-workspace.e2e.ts new file mode 100644 index 000000000..3bda62357 --- /dev/null +++ b/e2e/phone-workspace.e2e.ts @@ -0,0 +1,187 @@ +import { expect, openIsolatedRoom, test } from "./room"; +import { expectNoHorizontalOverflow, RESPONSIVE_SOURCE } from "./responsive"; +import { installVisualViewport, setVisualViewport } from "./visual-viewport"; + +import type { Locator, 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"); +} + +/** 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); +} + +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 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"); + 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(); + // 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 }) => { + 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); + // 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 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 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 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"); + await page.keyboard.press("Escape"); + + // Reduced motion never hides it. + await page.emulateMedia({ reducedMotion: "reduce" }); + await scrollPane(scroller, 1200); + await scrollPane(scroller, 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/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/responsive-workspace.e2e.ts b/e2e/responsive-workspace.e2e.ts index 5847d818b..449c03b42 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(); } @@ -591,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, { @@ -619,7 +626,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/e2e/sidecar.e2e.ts b/e2e/sidecar.e2e.ts index 31738eda6..2df9fecbe 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({ @@ -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 }) => { 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/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..fa1a44e23 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) 72%, transparent); /* Petrol is the only accent: action, focus, presence and links all agree. */ --color-brand: oklch(0.50006 0.08514 210.06); /* #06707e */ @@ -245,6 +247,13 @@ --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 +362,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; 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",