From 9e9b36f8f907f5af97fc7ac172e50cc1f9f2632b Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:25:03 +0100 Subject: [PATCH 1/5] Give phones a documents sheet and action sheets On a phone the top bar's Documents button opens a bottom sheet instead of the left Projects drawer: a project switcher, an inline search, recents derived from edit times, the document tree with 44px rows, Archived as a page pushed inside the sheet, the account row, and a pinned New document button. Base UI's drawer owns dragging, focus and dismissal, and the page beneath is inert. Document menus, in the top bar and on each row, render the same item list as an iOS-style action sheet on phones. Desktop and tablets are unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 1 + apps/web/src/action-sheet.tsx | 94 ++++ apps/web/src/document-actions-menu.tsx | 53 +- apps/web/src/documents-sheet-model.test.ts | 69 +++ apps/web/src/documents-sheet-model.ts | 68 +++ apps/web/src/documents-sheet.tsx | 465 +++++++++++++++++ apps/web/src/navigation-shell.tsx | 63 ++- apps/web/src/phone-header.tsx | 15 +- apps/web/src/phone-sheets.css | 577 +++++++++++++++++++++ apps/web/src/project-sidebar-chrome.tsx | 6 +- apps/web/src/project-sidebar.tsx | 22 +- bun.lock | 1 + e2e/documents-sheet.e2e.ts | 156 ++++++ e2e/phone-workspace.e2e.ts | 4 +- e2e/responsive-workspace.e2e.ts | 13 +- packages/visuals/theme.css | 11 + 16 files changed, 1576 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/action-sheet.tsx create mode 100644 apps/web/src/documents-sheet-model.test.ts create mode 100644 apps/web/src/documents-sheet-model.ts create mode 100644 apps/web/src/documents-sheet.tsx create mode 100644 apps/web/src/phone-sheets.css create mode 100644 e2e/documents-sheet.e2e.ts diff --git a/apps/web/package.json b/apps/web/package.json index 8a1ee360d..65b683d9f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "types": "tsgo --noEmit --skipLibCheck" }, "dependencies": { + "@base-ui/react": "1.7.0", "@chopin/dialect": "workspace:*", "@chopin/diagrams": "workspace:*", "@chopin/draft": "workspace:*", diff --git a/apps/web/src/action-sheet.tsx b/apps/web/src/action-sheet.tsx new file mode 100644 index 000000000..9ea058875 --- /dev/null +++ b/apps/web/src/action-sheet.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef, useState } from "react"; +import { Drawer } from "@base-ui/react/drawer"; + +import "./phone-sheets.css"; + +import type { IconProps } from "@chopin/icons"; +import type { ReactNode } from "react"; + +export type ActionSheetItem = { + action: Action; + label: string; + icon?: (props: IconProps) => ReactNode; + tone?: "danger" | "normal"; +}; + +/** + * A phone's menu: a grouped list rising from the bottom edge with a separate Cancel, the way iOS + * presents a choice of actions. It renders the same item list a desktop popover does. + * + * Copying runs on the tap itself so the clipboard keeps the gesture. Every other choice waits + * for the sheet to leave, so focus, dialogs and inline fields never contend with its focus trap. + */ +export function ActionSheet( + { + immediate = [], + items, + label, + onClose, + onSelect, + title, + }: { + immediate?: Action[]; + items: ActionSheetItem[]; + label: string; + onClose: () => void; + onSelect: (action: Action) => void; + title?: string; + }, +) { + let [open, setOpen] = useState(false); + let chosen = useRef(undefined); + useEffect(() => { + let frame = requestAnimationFrame(() => setOpen(true)); + return () => cancelAnimationFrame(frame); + }, []); + let choose = (action: Action) => { + if (immediate.includes(action)) onSelect(action); + else chosen.current = action; + setOpen(false); + }; + + return ( + { + if (next) return; + onClose(); + // Kept rather than cleared: Base UI may restore focus after this callback. + if (chosen.current) onSelect(chosen.current); + }} + open={open} + > + + + + chosen.current === undefined} + > + {label} +
+ {title &&

{title}

} + {items.map(item => ( + + ))} +
+ Cancel +
+
+
+
+ ); +} diff --git a/apps/web/src/document-actions-menu.tsx b/apps/web/src/document-actions-menu.tsx index 3e32d5f69..1b2743888 100644 --- a/apps/web/src/document-actions-menu.tsx +++ b/apps/web/src/document-actions-menu.tsx @@ -1,5 +1,15 @@ import { createPortal } from "react-dom"; -import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { usePhone } from "@chopin/editor/pointer"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; import { ArchiveIcon, ArrowUpIcon, CloseIcon, LinkIcon, PencilIcon } from "@chopin/icons"; @@ -13,6 +23,10 @@ import type { IconProps } from "@chopin/icons"; import type * as Api from "./api"; import type { CSSProperties, KeyboardEvent, ReactNode } from "react"; +let ActionSheet = lazy(() => + import("./action-sheet").then(module => ({ default: module.ActionSheet })) +); + export type DocumentAction = "copy-link" | "rename" | "archive" | "restore" | "delete"; export type DocumentMenuItem = { @@ -35,6 +49,16 @@ export function documentMenuItems(channel: Pick): Doc ]; } +/** On a phone, archiving reads as the menu's consequential choice, as deleting does everywhere. */ +export function documentSheetItems(channel: Pick) { + return documentMenuItems(channel).map(item => ({ + action: item.action, + icon: item.icon, + label: item.label, + tone: item.destructive || item.action === "archive" ? "danger" as const : "normal" as const, + })); +} + export type DocumentMenuKeyAction = "close" | "first" | "last" | "next" | "previous"; export function documentMenuKeyAction(key: string): DocumentMenuKeyAction | undefined { @@ -67,7 +91,9 @@ export function DocumentActionsMenu( let button = useRef(null); let panel = useRef(null); let initialItem = useRef(0); + let phone = usePhone(); let [open, setOpen] = useState(false); + let [sheetOpen, setSheetOpen] = useState(false); let [position, setPosition] = useState(); let items = documentMenuItems(channel); let immediately = motionImmediately(); @@ -181,8 +207,13 @@ export function DocumentActionsMenu( : (current - 1 + controls.length) % controls.length; controls[next]?.focus(); }; - let toggleMenu = () => open ? closeMenu() : openAt(0); + let toggleMenu = () => { + if (phone) setSheetOpen(true); + else if (open) closeMenu(); + else openAt(0); + }; let triggerKeyDown = (event: KeyboardEvent) => { + if (phone) return; if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; event.preventDefault(); openAt(event.key === "ArrowUp" ? items.length - 1 : 0); @@ -194,7 +225,7 @@ export function DocumentActionsMenu( ? ( )} + {sheetOpen && ( + + setSheetOpen(false)} + onSelect={action => onAction(action as DocumentAction)} + title={channel.title} + /> + + )} {mounted && createPortal(
{ + let channel = (id: string, title: string, updatedAt: string, parentChannelId?: string) => + ({ + id, + title, + updatedAt, + parentChannelId, + repositoryId: "R", + repositoryOwner: "o", + repositoryName: "r", + slug: id, + createdBy: "u", + revision: 1, + createdAt: updatedAt, + descriptionRevision: 0, + }) satisfies Api.Channel; + + let offline = channel("a", "Offline sync", "2026-10-11T10:00:00Z"); + let release = channel("b", "Release checklist", "2026-10-10T10:00:00Z"); + let eviction = channel("c", "IndexedDB eviction", "2026-10-09T10:00:00Z", "a"); + let groups = documentGroups([offline, release, eviction], false); + + it("filters titles case-insensitively and keeps parents for matching children", () => { + expect(filterDocumentGroups(groups, " ")).toBe(groups); + expect(filterDocumentGroups(groups, "RELEASE")).toEqual([{ parent: release, children: [] }]); + expect(filterDocumentGroups(groups, "eviction")).toEqual([ + { parent: offline, children: [eviction] }, + ]); + expect(filterDocumentGroups(groups, "offline")).toEqual([ + { parent: offline, children: [eviction] }, + ]); + expect(filterDocumentGroups(groups, "nothing")).toEqual([]); + }); + + it("lists recents newest first only when the tree holds more than they repeat", () => { + expect(recentDocuments([offline, release], 3)).toEqual([]); + let more = [ + release, + offline, + channel("d", "D", "2026-10-01T00:00:00Z"), + channel("e", "E", "2026-10-11T11:00:00Z"), + channel("f", "F", "2026-09-01T00:00:00Z"), + ]; + expect(recentDocuments(more, 3).map(value => value.id)).toEqual(["e", "a", "b"]); + }); + + it("says when a document was edited", () => { + let now = Date.parse("2026-10-11T12:00:00"); + let at = (value: string) => new Date(value).toISOString(); + expect(editedLabel(at("2026-10-11T11:59:40"), now)).toBe("Edited just now"); + expect(editedLabel(at("2026-10-11T11:58:00"), now)).toBe("Edited 2 min ago"); + expect(editedLabel(at("2026-10-11T09:00:00"), now)).toBe("Edited 3 hr ago"); + expect(editedLabel(at("2026-10-10T08:00:00"), now)).toBe("Edited yesterday"); + expect(editedLabel(at("2026-10-07T08:00:00"), now, "en-US")).toBe("Edited Wednesday"); + expect(editedLabel(at("2026-09-20T08:00:00"), now, "en-US")).toBe("Edited Sep 20"); + expect(editedLabel("not a date", now)).toBe(""); + }); +}); diff --git a/apps/web/src/documents-sheet-model.ts b/apps/web/src/documents-sheet-model.ts new file mode 100644 index 000000000..b1d67719e --- /dev/null +++ b/apps/web/src/documents-sheet-model.ts @@ -0,0 +1,68 @@ +import type { Channel } from "./api"; + +export function documentGroups( + channels: Channel[], + archiveMode: boolean, +): Array<{ parent: Channel; children: Channel[] }> { + let parents = channels.filter(channel => + channel.parentChannelId === undefined + && (archiveMode ? channel.archivedAt !== undefined : channel.archivedAt === undefined) + ); + let parentIds = new Set(parents.map(channel => channel.id)); + let children = new Map(); + for (let channel of channels) { + if (!channel.parentChannelId || !parentIds.has(channel.parentChannelId)) continue; + let nested = children.get(channel.parentChannelId) ?? []; + nested.push(channel); + children.set(channel.parentChannelId, nested); + } + return parents.map(parent => ({ parent, children: children.get(parent.id) ?? [] })); +} + +export type DocumentGroup = ReturnType[number]; + +/** + * Narrows a tree to documents whose titles contain the query. A matching parent keeps its whole + * group; otherwise a parent stays only to hold the children that match. + */ +export function filterDocumentGroups(groups: DocumentGroup[], query: string): DocumentGroup[] { + let needle = query.trim().toLocaleLowerCase(); + if (!needle) return groups; + let matches = (channel: Channel) => channel.title.toLocaleLowerCase().includes(needle); + return groups.flatMap(group => { + if (matches(group.parent)) return [group]; + let children = group.children.filter(matches); + return children.length > 0 ? [{ parent: group.parent, children }] : []; + }); +} + +/** Recents earn their place only once the tree holds more than they would repeat. */ +export const RECENT_DOCUMENTS = 3; + +export function recentDocuments(parents: Channel[], limit = RECENT_DOCUMENTS): Channel[] { + if (parents.length <= limit + 1) return []; + return [...parents] + .sort((first, second) => Date.parse(second.updatedAt) - Date.parse(first.updatedAt)) + .slice(0, limit); +} + +export function editedLabel(updatedAt: string, now: number, locale?: string): string { + let then = Date.parse(updatedAt); + if (!Number.isFinite(then)) return ""; + let minutes = Math.floor(Math.max(0, now - then) / 60_000); + if (minutes < 1) return "Edited just now"; + if (minutes < 60) return `Edited ${minutes} min ago`; + let hours = Math.floor(minutes / 60); + if (hours < 24) return `Edited ${hours} hr ago`; + let date = new Date(then); + let today = new Date(now); + let days = Math.round( + (new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime() + - new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()) / 86_400_000, + ); + if (days <= 1) return "Edited yesterday"; + if (days < 7) { + return `Edited ${date.toLocaleDateString(locale, { weekday: "long" })}`; + } + return `Edited ${date.toLocaleDateString(locale, { month: "short", day: "numeric" })}`; +} diff --git a/apps/web/src/documents-sheet.tsx b/apps/web/src/documents-sheet.tsx new file mode 100644 index 000000000..6eb222d23 --- /dev/null +++ b/apps/web/src/documents-sheet.tsx @@ -0,0 +1,465 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { Drawer } from "@base-ui/react/drawer"; +import { Face } from "@chopin/editor"; +import { childDocumentPath, documentPath } from "@chopin/protocol/document-url"; +import { + ArchiveIcon, + CheckIcon, + ChevronIcon, + CloseIcon, + DocumentIcon, + EllipsisIcon, + LockIcon, + PlusIcon, + SearchIcon, + SignInIcon, +} from "@chopin/icons"; + +import { ActionSheet } from "./action-sheet"; +import { unansweredDecisionsLabel } from "./decision-view-control"; +import { DocumentActionsMenu } from "./document-actions-menu"; +import { + documentGroups, + editedLabel, + filterDocumentGroups, + recentDocuments, +} from "./documents-sheet-model"; +import { canManageProject } from "./navigation-model"; + +import "./phone-sheets.css"; + +import type { MouseEvent } from "react"; +import type * as Api from "./api"; +import type { ProjectDocuments } from "./document-actions"; +import type { DocumentAction } from "./document-actions-menu"; +import type { DocumentCreationPhase } from "./use-document-creation"; + +type Page = "documents" | "archived"; + +/** + * Phones navigate documents from a bottom sheet rather than the desktop's left drawer: one + * project at a time, filtered inline, with archived documents a page deeper inside the sheet. + * It reads the same catalogue as the Projects sidebar. + */ +export function DocumentsSheet( + { + activeRepositoryId, + catalogueMode, + currentDocumentId, + onAddProject, + onCatalogueModeChange, + onCreateDocument, + onDocumentAction, + onLoadMore, + onClosed, + onNavigate, + onOpenChange, + onSignOut, + open, + pendingCreations, + projects, + user, + }: { + activeRepositoryId?: string; + catalogueMode: "active" | "archived"; + currentDocumentId?: string; + onAddProject: () => void; + onCatalogueModeChange: (mode: "active" | "archived") => void; + onCreateDocument: (project: Api.NavigationProject) => void; + onDocumentAction: (channel: Api.Channel, action: DocumentAction) => void; + onLoadMore: (entry: ProjectDocuments) => void; + onClosed: () => void; + onNavigate: (documentId: string, path: string) => void; + onOpenChange: (open: boolean) => void; + onSignOut: () => void; + open: boolean; + pendingCreations: ReadonlyMap; + projects: ProjectDocuments[]; + user: Api.User; + }, +) { + // Mounting closed and opening a frame later lets the sheet rise rather than appear. + let [ready, setReady] = useState(false); + useEffect(() => { + let frame = requestAnimationFrame(() => setReady(true)); + return () => cancelAnimationFrame(frame); + }, []); + let sorted = [...projects].sort((first, second) => + first.project.position - second.project.position + ); + let [chosenId, setChosenId] = useState(); + let [switching, setSwitching] = useState(false); + let [query, setQuery] = useState(""); + let [page, setPage] = useState("documents"); + let [account, setAccount] = useState(false); + let close = useRef(null); + let back = useRef(null); + let archivedRow = useRef(null); + let projectListId = useId(); + let entry = sorted.find(value => value.project.repositoryId === chosenId) + ?? sorted.find(value => value.project.repositoryId === activeRepositoryId) + ?? sorted.find(value => value.project.available) + ?? sorted[0]; + + let showPage = (next: Page) => { + setPage(next); + onCatalogueModeChange(next === "archived" ? "archived" : "active"); + requestAnimationFrame(() => + (next === "archived" ? back : archivedRow).current?.focus({ preventScroll: true }) + ); + }; + let reset = () => { + setChosenId(undefined); + setSwitching(false); + setQuery(""); + setPage("documents"); + if (catalogueMode === "archived") onCatalogueModeChange("active"); + }; + let follow = (documentId: string, path: string) => (event: MouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + return; + } + event.preventDefault(); + onNavigate(documentId, path); + }; + + let project = entry?.project; + let label = project ? `${project.repositoryOwner}/${project.repositoryName}` : "Documents"; + let canManage = project ? project.available && canManageProject(project) : false; + let phase = project ? pendingCreations.get(project.repositoryId) : undefined; + + let tree = (archived: boolean) => { + if (!entry) return null; + let { documents } = entry; + let groups = documentGroups(documents.channels, archived); + let shown = archived ? groups : filterDocumentGroups(groups, query); + let unanswered = (channel: Api.Channel) => archived ? 0 : channel.unansweredDecisions ?? 0; + let row = (channel: Api.Channel, path: string, child: boolean) => { + let count = unanswered(channel); + let current = channel.id === currentDocumentId; + return ( +
  • + 0 ? unansweredDecisionsLabel(channel.title, count) : undefined} + className="documents-sheet-link" + href={path} + onClick={follow(channel.id, path)} + > + {channel.title} + {count > 0 && } + + {canManageProject(entry.project) && ( + onDocumentAction(channel, action)} + trigger={
  • + ); + }; + return ( + <> + {documents.status === "unavailable" && ( +

    Access unavailable

    + )} + {documents.status === "error" && ( +

    {documents.message}

    + )} + {documents.status === "loading" && groups.length === 0 && ( +

    Loading documents…

    + )} + {documents.status === "ready" && shown.length === 0 && !documents.nextCursor && ( +

    + {query && !archived + ? `No documents match “${query.trim()}”` + : archived + ? "No archived documents" + : "No documents"} +

    + )} + {shown.length > 0 && ( +
      + {shown.flatMap(({ children, parent }) => { + let parentPath = documentPath( + parent.repositoryOwner, + parent.repositoryName, + parent.slug, + ); + return [ + row(parent, parentPath, false), + ...children.map(child => + row( + child, + childDocumentPath( + parent.repositoryOwner, + parent.repositoryName, + parent.slug, + child.slug, + ), + true, + ) + ), + ]; + })} +
    + )} + {documents.status === "ready" && documents.nextCursor && ( + + )} + + ); + }; + + let recents = entry && page === "documents" && !query.trim() + ? recentDocuments(documentGroups(entry.documents.channels, false).map(group => group.parent)) + : []; + let now = Date.now(); + + return ( + { + if (next) return; + reset(); + onClosed(); + }} + open={ready && open} + > + + + + + + + + + ); +} diff --git a/apps/web/src/navigation-shell.tsx b/apps/web/src/navigation-shell.tsx index cb4c32435..3e9946aa8 100644 --- a/apps/web/src/navigation-shell.tsx +++ b/apps/web/src/navigation-shell.tsx @@ -13,6 +13,7 @@ import { useState, useSyncExternalStore, } from "react"; +import { usePhone } from "@chopin/editor/pointer"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; import { documentPath } from "@chopin/protocol/document-url"; @@ -84,6 +85,9 @@ class LazyDialogBoundary extends Component< } let NavigationNotice = lazy(() => import("./navigation-notice-view")); +let DocumentsSheet = lazy(() => + import("./documents-sheet").then(module => ({ default: module.DocumentsSheet })) +); let NavigationDrawer = lazy(() => import("./navigation-drawer").then(module => ({ default: module.NavigationDrawer })) ); @@ -235,6 +239,14 @@ export function NavigationShell( ); let [drawerOpen, setDrawerOpen] = useState(false); let drawerOpener = useRef(null); + // The phone bar and the shell can each host the trigger; one leaving must not clear the other. + let openerRef = useCallback((node: HTMLButtonElement | null) => { + if (!node) return; + drawerOpener.current = node; + return () => { + if (drawerOpener.current === node) drawerOpener.current = null; + }; + }, []); let [catalogueMode, setCatalogueMode] = useState<"active" | "archived">("active"); let [dialog, setDialog] = useState< | "add" @@ -251,6 +263,13 @@ export function NavigationShell( let [reveal, setReveal] = useState<{ id: string; nonce: number }>(); let [width, resize] = useSidebarWidth(); let mode = useNavigationMode(); + let phone = usePhone(); + // Phones navigate from a bottom sheet; it stays mounted once loaded so it can animate out. + let [sheetLoaded, setSheetLoaded] = useState(false); + let sheetDismissed = useRef(false); + useEffect(() => { + if (phone && drawerOpen) setSheetLoaded(true); + }, [drawerOpen, phone]); let immediateMotion = motionImmediately(); let previousMode = useRef(mode); let modeChanged = previousMode.current !== mode; @@ -265,7 +284,7 @@ export function NavigationShell( immediateMotion || modeChanged, ); let drawerPresence = useTransitionPresence( - mode === "drawer" && drawerOpen ? true : undefined, + mode === "drawer" && drawerOpen && !phone ? true : undefined, 180, immediateMotion, ); @@ -767,11 +786,11 @@ export function NavigationShell( let dismissDialog = () => setDialog(undefined); let [sidebarShortcut, setSidebarShortcut] = useState(); let sidebarTrigger = useMemo(() => ({ - buttonRef: drawerOpener, + buttonRef: openerRef, claim: claimTrigger, onExpand: expandSidebar, shortcut: sidebarShortcut, - }), [claimTrigger, expandSidebar, sidebarShortcut]); + }), [claimTrigger, expandSidebar, openerRef, sidebarShortcut]); let shortcutActions = useRef({}); shortcutActions.current = { search: () => showDialog("search"), @@ -962,6 +981,8 @@ export function NavigationShell(
    {sidebarPresence.phase !== "closed" && ( @@ -979,11 +1000,45 @@ export function NavigationShell( )} {triggerVisible && ( )} + {phone && (sheetLoaded || drawerOpen) && ( + { + setDrawerOpen(false); + setDialogLoadFailed(true); + }} + > + showDialog("add")} + onCatalogueModeChange={setCatalogueMode} + onCreateDocument={project => void createDocument(project)} + onDocumentAction={documentAction} + onLoadMore={loadMore} + onNavigate={navigateToDocument} + onClosed={() => { + // A dismissal hands focus back once the sheet, and any field in it, has gone. + if (sheetDismissed.current) drawerOpener.current?.focus({ preventScroll: true }); + sheetDismissed.current = false; + }} + onOpenChange={open => { + sheetDismissed.current = !open; + setDrawerOpen(open); + }} + onSignOut={() => void signOut()} + open={drawerOpen} + pendingCreations={creation.pending} + projects={projects} + user={user} + /> + + )} {drawerPresence.phase !== "closed" && ( { diff --git a/apps/web/src/phone-header.tsx b/apps/web/src/phone-header.tsx index e0dee9b56..c5988cc5e 100644 --- a/apps/web/src/phone-header.tsx +++ b/apps/web/src/phone-header.tsx @@ -1,11 +1,11 @@ /** 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 { DocumentIcon, EllipsisIcon } from "@chopin/icons"; import { DocumentActionsMenu } from "./document-actions-menu"; import { DocumentRename } from "./document-rename"; -import { ProjectSidebarExpandButton, SidebarTrigger } from "./project-sidebar-chrome"; +import { SidebarTrigger } from "./project-sidebar-chrome"; import type { DocumentMetadata } from "./document-actions"; import type { DocumentAction } from "./document-actions-menu"; @@ -56,7 +56,16 @@ export function PhoneHeader(
    {hostsTrigger && trigger && ( - + )}
    diff --git a/apps/web/src/phone-sheets.css b/apps/web/src/phone-sheets.css new file mode 100644 index 000000000..59e786dec --- /dev/null +++ b/apps/web/src/phone-sheets.css @@ -0,0 +1,577 @@ +/* + * Phone sheets: the documents navigator and action sheets rise from the bottom edge over a dim, + * inert page. Base UI's drawer owns dragging, focus and dismissal; these rules own the surfaces. + */ + +.phone-sheet-backdrop { + position: fixed; + inset: 0; + 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); +} + +.phone-sheet-backdrop[data-starting-style], +.phone-sheet-backdrop[data-ending-style] { + opacity: 0; +} + +.phone-sheet-backdrop[data-ending-style] { + transition-duration: var(--sheet-fall-dur); +} + +.phone-sheet-viewport { + pointer-events: none; + position: fixed; + inset: 0; + z-index: 71; + display: flex; + align-items: flex-end; + justify-content: center; +} + +:is(.documents-sheet, .action-sheet) { + pointer-events: auto; + overscroll-behavior: contain; + transform: translateY(var(--drawer-swipe-movement-y, 0px)); + transition: transform var(--sheet-rise-dur) var(--tab-slide-ease); +} + +:is(.documents-sheet, .action-sheet)[data-swiping] { + transition: none; +} + +:is(.documents-sheet, .action-sheet)[data-starting-style], +:is(.documents-sheet, .action-sheet)[data-ending-style] { + transform: translateY(100%); +} + +:is(.documents-sheet, .action-sheet)[data-ending-style] { + transition-duration: var(--sheet-fall-dur); +} + +.phone-sheet-grabber { + display: block; + width: calc(var(--spacing) * 9); + height: var(--spacing); + flex: none; + margin: calc(var(--spacing) * 1.5) auto 0; + border-radius: var(--radius-full); + background: var(--color-gray-300); +} + +.phone-sheet-close { + display: grid; + width: var(--control-target-coarse); + height: var(--control-target-coarse); + flex: none; + place-items: center; + border-radius: var(--radius-full); + color: var(--color-text-secondary); + -webkit-tap-highlight-color: transparent; +} + +.phone-sheet-close:active { + background: var(--color-hover); +} + +/* Documents ------------------------------------------------------------ */ + +.documents-sheet { + position: relative; + display: flex; + width: 100%; + height: min( + calc(100dvh * var(--sheet-large-detent)), + calc(100dvh - env(safe-area-inset-top) - var(--sheet-gap)) + ); + flex-direction: column; + overflow: hidden; + border-radius: var(--radius-sheet) var(--radius-sheet) 0 0; + background: var(--color-page); + box-shadow: var(--shadow-overlay); + font-size: var(--text-base); + line-height: var(--text-base--line-height); + color: var(--color-text-primary); +} + +/* A nested action sheet dims the documents beneath it, as its own backdrop would. */ +.documents-sheet::after { + pointer-events: none; + content: ""; + position: absolute; + inset: 0; + background: var(--color-scrim); + opacity: 0; + transition: opacity var(--sheet-rise-dur) var(--tab-slide-ease); +} + +.documents-sheet[data-nested-drawer-open]::after { + opacity: 1; +} + +.documents-sheet-pages { + position: relative; + min-height: 0; + flex: 1; + overflow: hidden; +} + +/* Archived pushes in from the right; the documents page eases a little way left beneath it. */ +.documents-sheet-page { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + background: var(--color-page); + transition: transform var(--sheet-page-dur) var(--tab-slide-ease); +} + +.documents-sheet[data-page="archived"] [data-sheet-page="documents"] { + transform: translateX(-30%); +} + +.documents-sheet [data-sheet-page="archived"] { + transform: translateX(100%); +} + +.documents-sheet[data-page="archived"] [data-sheet-page="archived"] { + transform: none; + box-shadow: var(--shadow-overlay); +} + +.documents-sheet-head { + display: flex; + min-height: var(--control-target-coarse); + flex: none; + align-items: center; + gap: var(--spacing); + padding: var(--spacing) calc(var(--spacing) * 2 + env(safe-area-inset-right)) 0 + calc(var(--spacing) * 2 + env(safe-area-inset-left)); +} + +.documents-sheet-head > .phone-sheet-close { + margin-left: auto; +} + +.documents-sheet-project, +.documents-sheet-back { + display: flex; + min-width: 0; + height: var(--control-target-coarse); + align-items: center; + gap: calc(var(--spacing) * 2); + padding-inline: calc(var(--spacing) * 2); + border-radius: var(--radius-lg); + font-weight: var(--font-weight-semibold); + -webkit-tap-highlight-color: transparent; +} + +.documents-sheet-project { + font-size: var(--text-lg); +} + +.documents-sheet-back { + gap: var(--spacing); + padding-inline: var(--spacing) calc(var(--spacing) * 2); + font-weight: var(--font-weight-medium); + color: var(--color-brand-ink); +} + +.documents-sheet-project:active, +.documents-sheet-back:active { + background: var(--color-hover); +} + +.documents-sheet-project-chevron { + flex: none; + color: var(--color-text-tertiary); + transform: rotate(90deg); + transition: transform var(--duration-base) var(--ease-out); +} + +.documents-sheet-project-chevron[data-open] { + transform: rotate(-90deg); +} + +.documents-sheet-page-title { + position: absolute; + left: 50%; + margin: 0; + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); + transform: translateX(-50%); +} + +.documents-sheet-projects { + display: grid; + flex: none; + margin: var(--spacing) calc(var(--spacing) * 4) 0; + overflow: hidden; + border-radius: var(--radius-lg); + background: var(--color-inset); +} + +.documents-sheet-projects > li + li { + border-top: var(--edge-width) solid var(--color-edge); +} + +.documents-sheet-option { + display: flex; + width: 100%; + min-height: var(--control-target-coarse); + align-items: center; + gap: calc(var(--spacing) * 2); + padding-inline: calc(var(--spacing) * 3); + text-align: left; + -webkit-tap-highlight-color: transparent; +} + +.documents-sheet-option > svg:last-child { + margin-left: auto; + color: var(--color-brand); +} + +.documents-sheet-option:active { + background: var(--color-hover); +} + +/* The field fills the row, so the shared focus ring outlines the whole search box. */ +.documents-sheet-search { + position: relative; + display: block; + flex: none; + margin: calc(var(--spacing) * 2) calc(var(--spacing) * 4 + env(safe-area-inset-right)) + calc(var(--spacing) * 2) calc(var(--spacing) * 4 + env(safe-area-inset-left)); + color: var(--color-text-tertiary); +} + +.documents-sheet-search > svg { + pointer-events: none; + position: absolute; + top: 50%; + left: calc(var(--spacing) * 3); + transform: translateY(-50%); +} + +.documents-sheet-search input { + width: 100%; + height: var(--control-target-coarse); + padding-inline: calc(var(--spacing) * 9) calc(var(--spacing) * 3); + border-radius: var(--radius-lg); + background: var(--color-inset); + color: var(--color-text-primary); + -webkit-appearance: none; + appearance: none; +} + +.documents-sheet-search input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; +} + +.documents-sheet-search input::placeholder { + color: var(--color-text-tertiary); +} + +.documents-sheet-scroll { + min-height: 0; + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0 env(safe-area-inset-right) calc(var(--spacing) * 3) env(safe-area-inset-left); + -webkit-overflow-scrolling: touch; +} + +[data-sheet-page="archived"] .documents-sheet-scroll { + padding-top: calc(var(--spacing) * 2); + padding-bottom: calc(var(--spacing) * 3 + env(safe-area-inset-bottom)); +} + +.documents-sheet-section + .documents-sheet-section { + margin-top: calc(var(--spacing) * 3); +} + +.documents-sheet-heading { + margin: calc(var(--spacing) * 2) calc(var(--spacing) * 5) var(--spacing); + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-tertiary); +} + +.documents-sheet-recents { + display: flex; + gap: calc(var(--spacing) * 2); + overflow-x: auto; + overscroll-behavior-x: contain; + padding: var(--spacing) calc(var(--spacing) * 4); + scroll-padding-inline: calc(var(--spacing) * 4); + scroll-snap-type: x mandatory; + scrollbar-width: none; +} + +.documents-sheet-recents > li { + flex: none; + width: min(42%, calc(var(--spacing) * 44)); + scroll-snap-align: start; +} + +.documents-sheet-recent { + display: flex; + height: 100%; + min-height: calc(var(--spacing) * 20); + flex-direction: column; + justify-content: space-between; + gap: calc(var(--spacing) * 2); + padding: calc(var(--spacing) * 3); + border-radius: var(--radius-lg); + background: var(--color-inset); + -webkit-tap-highlight-color: transparent; +} + +.documents-sheet-recent[aria-current="page"] { + outline: var(--focus-ring-width) solid var(--color-brand); + outline-offset: calc(var(--focus-ring-width) * -1); +} + +.documents-sheet-recent:active { + background: var(--color-selected); +} + +.documents-sheet-recent-title { + display: -webkit-box; + overflow: hidden; + font-weight: var(--font-weight-medium); + line-height: 1.3; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.documents-sheet-recent-meta { + font-size: var(--text-sm); + color: var(--color-text-tertiary); +} + +.documents-sheet-list { + display: grid; + padding-inline: calc(var(--spacing) * 2); +} + +.documents-sheet-row { + display: flex; + min-width: 0; + align-items: center; + border-radius: var(--radius-lg); +} + +.documents-sheet-row[data-current] { + background: var(--color-selected); +} + +.documents-sheet-link { + display: flex; + min-width: 0; + min-height: var(--control-target-coarse); + flex: 1; + align-items: center; + gap: calc(var(--spacing) * 3); + padding-inline: calc(var(--spacing) * 3); + border-radius: var(--radius-lg); + text-align: left; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; +} + +.documents-sheet-row[data-child] .documents-sheet-link { + padding-left: calc(var(--spacing) * 8); + color: var(--color-text-secondary); +} + +.documents-sheet-row[data-current] .documents-sheet-link { + font-weight: var(--font-weight-medium); + color: var(--color-brand-ink); +} + +.documents-sheet-link:active { + background: var(--color-hover); +} + +.documents-sheet-row-icon { + flex: none; + color: var(--color-text-tertiary); +} + +.documents-sheet-count { + display: grid; + min-width: calc(var(--spacing) * 5); + height: calc(var(--spacing) * 5); + flex: none; + margin-left: auto; + place-items: center; + padding-inline: calc(var(--spacing) * 1.5); + border-radius: var(--radius-full); + background: var(--color-brand); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + font-variant-numeric: tabular-nums; + color: var(--color-page); +} + +.documents-sheet-more { + display: grid; + width: var(--control-target-coarse); + height: var(--control-target-coarse); + flex: none; + place-items: center; + border-radius: var(--radius-full); + color: var(--color-text-tertiary); + -webkit-tap-highlight-color: transparent; +} + +.documents-sheet-more:active { + background: var(--color-hover); +} + +.documents-sheet-footer-list { + margin-top: calc(var(--spacing) * 3); + padding-top: calc(var(--spacing) * 3); + border-top: var(--edge-width) solid var(--color-edge); +} + +.documents-sheet-status { + margin: calc(var(--spacing) * 2) calc(var(--spacing) * 5); + color: var(--color-text-tertiary); +} + +.documents-sheet-load-more { + min-height: var(--control-target-coarse); + margin-inline: calc(var(--spacing) * 2); + padding-inline: calc(var(--spacing) * 3); + color: var(--color-brand-ink); +} + +.documents-sheet-actions { + flex: none; + padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4 + env(safe-area-inset-right)) + max(calc(var(--spacing) * 3), env(safe-area-inset-bottom)) + calc(var(--spacing) * 4 + env(safe-area-inset-left)); + border-top: var(--edge-width) solid var(--color-edge); + background: var(--color-page); +} + +.documents-sheet-new.btn { + width: 100%; + height: calc(var(--spacing) * 12); + gap: calc(var(--spacing) * 2); + border-radius: var(--radius-sheet); + font-size: var(--text-base); +} + +/* A landscape phone has height only for the tree. */ +@media (max-height: 500px) { + .documents-sheet-section[data-recent] { + display: none; + } + + .documents-sheet-section + .documents-sheet-section { + margin-top: 0; + } +} + +/* Action sheet --------------------------------------------------------- */ + +.action-sheet { + display: grid; + width: 100%; + max-width: var(--phone-tab-row-max); + gap: var(--sheet-gap); + padding: 0 calc(var(--sheet-gap) + env(safe-area-inset-right)) + max(var(--sheet-gap), env(safe-area-inset-bottom)) + calc(var(--sheet-gap) + env(safe-area-inset-left)); +} + +.action-sheet-group { + display: grid; + overflow: hidden; + border-radius: var(--radius-sheet); + background: var(--color-glass); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); + box-shadow: var(--shadow-overlay); +} + +.action-sheet-group > * + * { + border-top: var(--edge-width) solid var(--color-edge); +} + +.action-sheet-title { + padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4); + overflow: hidden; + font-size: var(--text-sm); + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text-tertiary); +} + +.action-sheet-item, +.action-sheet-cancel { + display: flex; + min-height: var(--action-sheet-row-height); + align-items: center; + justify-content: center; + gap: calc(var(--spacing) * 2); + padding-inline: calc(var(--spacing) * 4); + font-size: var(--text-lg); + color: var(--color-brand-ink); + -webkit-tap-highlight-color: transparent; + -webkit-user-select: none; + user-select: none; +} + +.action-sheet-item[data-tone="danger"] { + color: var(--color-destructive-ink); +} + +.action-sheet-item:active { + background: var(--color-hover); +} + +.action-sheet-cancel { + border-radius: var(--radius-sheet); + background: var(--color-page); + box-shadow: var(--shadow-overlay); + font-weight: var(--font-weight-semibold); +} + +.action-sheet-cancel:active { + background: var(--color-inset); +} + +@media (prefers-reduced-motion: reduce) { + :is(.documents-sheet, .action-sheet), + :is(.documents-sheet, .action-sheet)[data-ending-style] { + transition: opacity var(--duration-base) var(--ease-out); + } + + :is(.documents-sheet, .action-sheet)[data-starting-style], + :is(.documents-sheet, .action-sheet)[data-ending-style] { + opacity: 0; + transform: translateY(var(--drawer-swipe-movement-y, 0px)); + } + + .documents-sheet-page { + transition: opacity var(--duration-base) var(--ease-out); + } + + .documents-sheet[data-page="archived"] [data-sheet-page="documents"], + .documents-sheet [data-sheet-page="archived"] { + opacity: 0; + transform: none; + } + + .documents-sheet[data-page="archived"] [data-sheet-page="archived"] { + opacity: 1; + } +} diff --git a/apps/web/src/project-sidebar-chrome.tsx b/apps/web/src/project-sidebar-chrome.tsx index 4bad81ca3..97ffc074c 100644 --- a/apps/web/src/project-sidebar-chrome.tsx +++ b/apps/web/src/project-sidebar-chrome.tsx @@ -3,7 +3,7 @@ import { createContext } from "react"; import panelIcon from "./assets/icons/panel-close.svg"; import { IconButton } from "./icon-button"; -import type { RefObject } from "react"; +import type { Ref } from "react"; import type * as Api from "./api"; export const SIDEBAR_MIN = 250; @@ -92,7 +92,7 @@ export function ProjectSidebarLoading( * its own floating trigger, so the button scrolls away with the bar instead of over it. */ export type SidebarTriggerSlot = { - buttonRef: RefObject; + buttonRef: Ref; claim: () => () => void; onExpand: () => void; shortcut?: string; @@ -106,7 +106,7 @@ export function ProjectSidebarExpandButton( onExpand, shortcut, }: { - buttonRef?: RefObject; + buttonRef?: Ref; onExpand: () => void; shortcut?: string; }, diff --git a/apps/web/src/project-sidebar.tsx b/apps/web/src/project-sidebar.tsx index 189801f46..31898ec58 100644 --- a/apps/web/src/project-sidebar.tsx +++ b/apps/web/src/project-sidebar.tsx @@ -8,6 +8,7 @@ import { MenuItem, MenuSeparator } from "./menu-item"; import { ProjectSidebarSkeleton } from "./project-sidebar-chrome"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; +import { documentGroups } from "./documents-sheet-model"; import { canManageProject } from "./navigation-model"; import { currentShortcutPlatform, shortcutLabel } from "./shortcuts"; import { Count, Face, MotionDisclosure, MotionDisclosureIcon } from "@chopin/editor"; @@ -26,6 +27,8 @@ import { SignInIcon, } from "@chopin/icons"; import type * as Api from "./api"; + +export { documentGroups } from "./documents-sheet-model"; import type { DocumentAction } from "./document-actions-menu"; import type { ProjectDocuments } from "./document-actions"; import type { DocumentCreationPhase } from "./use-document-creation"; @@ -47,25 +50,6 @@ export function toggleCollapsedProjectIds( return next; } -export function documentGroups( - channels: Api.Channel[], - archiveMode: boolean, -): Array<{ parent: Api.Channel; children: Api.Channel[] }> { - let parents = channels.filter(channel => - channel.parentChannelId === undefined - && (archiveMode ? channel.archivedAt !== undefined : channel.archivedAt === undefined) - ); - let parentIds = new Set(parents.map(channel => channel.id)); - let children = new Map(); - for (let channel of channels) { - if (!channel.parentChannelId || !parentIds.has(channel.parentChannelId)) continue; - let nested = children.get(channel.parentChannelId) ?? []; - nested.push(channel); - children.set(channel.parentChannelId, nested); - } - return parents.map(parent => ({ parent, children: children.get(parent.id) ?? [] })); -} - function UnansweredCount({ unanswered }: { unanswered: number }) { let attention = useDecisionAttention(unanswered); if (unanswered <= 0) return null; diff --git a/bun.lock b/bun.lock index 85aeace52..b2c802fd7 100644 --- a/bun.lock +++ b/bun.lock @@ -69,6 +69,7 @@ "name": "@chopin/web", "version": "0.0.0", "dependencies": { + "@base-ui/react": "1.7.0", "@chopin/diagrams": "workspace:*", "@chopin/dialect": "workspace:*", "@chopin/draft": "workspace:*", diff --git a/e2e/documents-sheet.e2e.ts b/e2e/documents-sheet.e2e.ts new file mode 100644 index 000000000..93b4a9a5e --- /dev/null +++ b/e2e/documents-sheet.e2e.ts @@ -0,0 +1,156 @@ +import { expect, test } from "./room"; +import { createChannel } from "./database"; + +import type { Page } from "@playwright/test"; + +const PHONE = { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } }; + +function title(id: string) { + return `Test ${id.slice(0, 8)}`; +} + +function documentsButton(page: Page) { + return page.locator(".phone-top-bar").getByRole("button", { name: "Documents", exact: true }); +} + +function sheet(page: Page) { + return page.getByRole("dialog", { name: "Documents" }); +} + +async function otherDocument(baseURL: string): Promise { + let id = crypto.randomUUID(); + await createChannel(Number(new URL(baseURL).port), id); + return id; +} + +test("a phone opens its documents in a bottom sheet over an inert page", async ({ join }) => { + let page = await join("ana", PHONE); + let opener = documentsButton(page); + let before = await page.getByRole("banner").boundingBox(); + await opener.click(); + await expect(sheet(page)).toBeVisible(); + await expect(page.getByRole("dialog", { name: "Projects" })).toHaveCount(0); + await expect(sheet(page).getByRole("button", { name: "Close documents" }).first()).toBeFocused(); + await expect(page.locator(".navigation-shell")).toHaveAttribute("inert", ""); + expect(await page.getByRole("banner", { includeHidden: true }).boundingBox()).toEqual(before); + await expect(sheet(page).getByRole("button", { name: "Keyboard shortcuts" })).toHaveCount(0); + let search = sheet(page).getByRole("searchbox", { name: "Search documents" }); + expect(await search.evaluate(element => parseFloat(getComputedStyle(element).fontSize))) + .toBeGreaterThanOrEqual(16); + let row = sheet(page).locator(".documents-sheet-link").first(); + expect((await row.boundingBox())!.height).toBeGreaterThanOrEqual(44); + await expect(sheet(page).getByRole("button", { name: "New document" })).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(sheet(page)).toHaveCount(0); + await expect(opener).toBeFocused(); + await expect(page.locator(".navigation-shell")).not.toHaveAttribute("inert", ""); + + await opener.click(); + await expect(sheet(page)).toBeVisible(); + let box = (await sheet(page).boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + 12); + await page.mouse.down(); + for (let step = 1; step <= 12; step++) { + await page.mouse.move(box.x + box.width / 2, box.y + 12 + step * 40); + } + await page.mouse.up(); + await expect(sheet(page)).toHaveCount(0); +}); + +test("the documents sheet filters inline and navigates to a document", async ({ baseURL, join, room }) => { + let other = await otherDocument(baseURL!); + let page = await join("ana", PHONE); + await documentsButton(page).click(); + let documents = sheet(page).getByRole("region", { name: "Documents" }); + await expect(documents.getByRole("link", { name: title(room) })).toBeVisible(); + + await sheet(page).getByRole("searchbox", { name: "Search documents" }).fill( + title(other).toUpperCase(), + ); + await expect(documents.getByRole("link")).toHaveCount(1); + await expect(documents.getByRole("link", { name: title(other) })).toBeVisible(); + + await documents.getByRole("link", { name: title(other) }).click(); + await expect(sheet(page)).toHaveCount(0); + await expect(page.locator(".phone-top-bar")).toContainText(title(other)); +}); + +test("archived documents push a page inside the sheet and come back", async ({ join }) => { + let page = await join("ana", PHONE); + await documentsButton(page).click(); + let archivedPage = page.locator('[data-sheet-page="archived"]'); + await expect(archivedPage).toHaveAttribute("inert", ""); + await sheet(page).getByRole("button", { name: "Archived", exact: true }).click(); + await expect(archivedPage).not.toHaveAttribute("inert", ""); + await expect(page.locator('[data-sheet-page="documents"]')).toHaveAttribute("inert", ""); + let back = sheet(page).getByRole("button", { name: "Back to documents" }); + await expect(back).toBeFocused(); + await expect(sheet(page).getByRole("heading", { name: "Archived" })).toBeVisible(); + + await back.click(); + await expect(archivedPage).toHaveAttribute("inert", ""); + await expect(sheet(page).getByRole("button", { name: "Archived", exact: true })).toBeFocused(); + await expect(sheet(page)).toBeVisible(); +}); + +test("document menus open an action sheet on a phone", async ({ baseURL, join, room }) => { + let other = await otherDocument(baseURL!); + let page = await join("ana", PHONE); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + let more = page.locator(".phone-top-bar").getByRole("button", { + name: `Actions for ${title(room)}`, + }); + + await more.click(); + let actions = page.getByRole("dialog", { name: `Actions for ${title(room)}` }); + await expect(actions).toBeVisible(); + await expect(page.getByRole("menu")).toHaveCount(0); + for (let label of ["Copy link", "Rename", "Archive", "Cancel"]) { + let item = actions.getByRole("button", { name: label, exact: true }); + await expect(item).toBeVisible(); + expect((await item.boundingBox())!.height).toBeGreaterThanOrEqual(56); + } + await actions.getByRole("button", { name: "Cancel" }).click(); + await expect(actions).toHaveCount(0); + + await more.click(); + await actions.getByRole("button", { name: "Copy link", exact: true }).click(); + await expect(actions).toHaveCount(0); + await expect(page.getByText("Link copied")).toBeVisible(); + + // Tapping the title reads it; renaming is an action. + await page.locator(".phone-top-bar-title").click(); + await expect(page.getByRole("textbox", { name: "Document title" })).toHaveCount(0); + await more.click(); + await actions.getByRole("button", { name: "Rename", exact: true }).click(); + let field = page.getByRole("textbox", { name: "Document title" }); + await expect(field).toBeFocused(); + expect(await field.evaluate(element => parseFloat(getComputedStyle(element).fontSize))) + .toBeGreaterThanOrEqual(16); + await field.press("Escape"); + await expect(field).toHaveCount(0); + + await documentsButton(page).click(); + await sheet(page).getByRole("button", { name: `Actions for ${title(other)}` }).click(); + let rowActions = page.getByRole("dialog", { name: `Actions for ${title(other)}` }); + await rowActions.getByRole("button", { name: "Archive", exact: true }).click(); + await expect(rowActions).toHaveCount(0); + await expect(page.getByText(`Archived ${title(other)}`)).toBeVisible(); + await expect(page.getByRole("button", { name: "Undo" })).toBeVisible(); +}); + +test("desktop keeps the Projects sidebar and popover menus", async ({ join, room }) => { + let page = await join("ana", { viewport: { width: 1440, height: 900 } }); + await expect(page.getByRole("complementary", { name: "Projects" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Documents", exact: true })).toHaveCount(0); + await page.getByRole("banner").getByRole("button", { name: `Actions for ${title(room)}` }) + .click(); + await expect(page.getByRole("menu", { name: `Actions for ${title(room)}` })).toBeVisible(); + await expect(page.getByRole("dialog", { name: /^Actions for / })).toHaveCount(0); + + let narrow = await join("ana", { viewport: { width: 900, height: 900 } }); + await narrow.getByRole("button", { name: "Show sidebar" }).click(); + await expect(narrow.getByRole("dialog", { name: "Projects" })).toBeVisible(); + await expect(narrow.getByRole("dialog", { name: "Documents" })).toHaveCount(0); +}); diff --git a/e2e/phone-workspace.e2e.ts b/e2e/phone-workspace.e2e.ts index 3bda62357..65907e40f 100644 --- a/e2e/phone-workspace.e2e.ts +++ b/e2e/phone-workspace.e2e.ts @@ -41,8 +41,8 @@ test("a phone gets full-bleed panes under a top bar and a bottom tab bar", async 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(page.getByRole("button", { name: "Show sidebar" })).toHaveCount(0); + await expect(topBar(page).getByRole("button", { name: "Documents", exact: true })).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(); diff --git a/e2e/responsive-workspace.e2e.ts b/e2e/responsive-workspace.e2e.ts index 449c03b42..77cbbd160 100644 --- a/e2e/responsive-workspace.e2e.ts +++ b/e2e/responsive-workspace.e2e.ts @@ -31,7 +31,7 @@ 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"); - let projects = page.getByRole("button", { name: "Show sidebar" }); + let projects = header.getByRole("button", { name: "Documents", exact: true }); let document = header.getByRole("button", { name: /^Actions for / }); let destinations = nav.getByRole("button"); @@ -94,9 +94,9 @@ test("a representative compact phone exposes one mounted destination at a time", let page = await join("ana", { hasTouch: true, viewport: { width: 390, height: 844 } }); let nav = page.getByRole("navigation", { name: "Workspace view" }); await expectPhoneWorkspaceChrome(page); - let projects = page.getByRole("button", { name: "Show sidebar" }); + let projects = page.getByRole("button", { name: "Documents", exact: true }); await projects.click(); - let drawer = page.getByRole("dialog", { name: "Projects" }); + let drawer = page.getByRole("dialog", { name: "Documents" }); await expect(drawer).toBeVisible(); await page.keyboard.press("Escape"); await expect(drawer).toBeHidden(); @@ -305,7 +305,8 @@ test("content swaps retain one interactive destination across pointer and immedi test("a pointer-dismissed Projects drawer becomes inert while it exits", async ({ join, seed }) => { await seed(RESPONSIVE_SOURCE); - let page = await join("ana", { hasTouch: true, viewport: { width: 390, height: 844 } }); + // A narrow window without touch keeps the drawer; phones open the documents sheet instead. + let page = await join("ana", { viewport: { width: 390, height: 844 } }); let opener = page.getByRole("button", { name: "Show sidebar" }); await opener.click(); let drawer = page.getByRole("dialog", { includeHidden: true, name: "Projects" }).locator("../.."); @@ -321,7 +322,7 @@ test("a pointer-dismissed Projects drawer becomes inert while it exits", async ( test("enabling reduced motion settles an active drawer exit", async ({ join, seed }) => { await seed(RESPONSIVE_SOURCE); - let page = await join("ana", { hasTouch: true, viewport: { width: 390, height: 844 } }); + let page = await join("ana", { viewport: { width: 390, height: 844 } }); await page.emulateMedia({ reducedMotion: "no-preference" }); let time = new Date("2026-01-01T00:00:00Z"); await page.clock.install({ time }); @@ -370,7 +371,7 @@ test("a shifted visual viewport keeps workspace controls in the exposed rectangl width: 320, }); - let sidebarButton = page.getByRole("button", { name: "Show sidebar" }); + let sidebarButton = page.getByRole("button", { name: "Documents", exact: true }); await expect(sidebarButton).toBeVisible(); await expectInsideViewport(sidebarButton); // A keyboard-sized cover hides the phone tab bar rather than squeezing it above the keys. diff --git a/packages/visuals/theme.css b/packages/visuals/theme.css index fa1a44e23..05f926c0e 100644 --- a/packages/visuals/theme.css +++ b/packages/visuals/theme.css @@ -94,6 +94,8 @@ --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); + /* Dims the page beneath a phone sheet. */ + --color-scrim: oklch(var(--shadow-color) / 32%); /* Petrol is the only accent: action, focus, presence and links all agree. */ --color-brand: oklch(0.50006 0.08514 210.06); /* #06707e */ @@ -253,6 +255,10 @@ --phone-tab-bar-gap: calc(var(--spacing) * 2.5); --phone-tab-row-max: 30rem; --glass-filter: blur(20px) saturate(180%); + /* Phone sheets: documents stand at a large detent; menus become grouped action sheets. */ + --sheet-large-detent: 0.92; + --sheet-gap: calc(var(--spacing) * 2); + --action-sheet-row-height: calc(var(--spacing) * 14); /* Empty states share one illustration and copy rhythm. */ --empty-state-icon-frame-size: calc(var(--spacing) * 14); @@ -275,6 +281,7 @@ --radius-md: 0.375rem; /* 6px — cards, inputs, callouts, code blocks */ --radius-lg: 0.5rem; /* 8px — popovers and panels */ --radius-xl: 0.625rem; /* 10px — the document page */ + --radius-sheet: 0.875rem; /* 14px — phone sheets and action sheet groups */ --radius-full: 9999px; /* saturated pill rounding */ --radius-composer-pill: calc(var(--spacing) * 5.5); /* 22px — half the phone composer at rest */ @@ -366,6 +373,10 @@ --tab-slide-dur: 380ms; --tab-slide-ease: cubic-bezier(0.32, 0.72, 0, 1); --phone-bar-dur: 250ms; + /* Sheets rise on the same spring-like curve and leave faster than they arrive. */ + --sheet-rise-dur: 420ms; + --sheet-fall-dur: 260ms; + --sheet-page-dur: 350ms; --route-swap-out-dur: 80ms; --route-swap-in-dur: 150ms; --icon-swap-dur: 250ms; From 400575bea26f4dc169f24cba284278081438a01f Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:45:37 +0100 Subject: [PATCH 2/5] Show the title while renaming and base Recent on opened documents Renaming forces the phone top bar's title visible. Recent lists the documents this person opened in the project, kept per user in local storage, without the one on screen, and only from two. The documents sheet is prefetched on phones, copying starts its clipboard write inside the tap, and the sidebar and sheet share one catalogue props object to recover initial-bundle bytes. Archive uses normal ink; action sheet groups are opaque; sheets cap their width in landscape. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/action-sheet.tsx | 5 +- apps/web/src/copy-link.ts | 8 +- apps/web/src/document-actions-menu.tsx | 25 +++--- apps/web/src/documents-sheet-model.test.ts | 36 ++++++--- apps/web/src/documents-sheet-model.ts | 68 ++++++++++++++-- apps/web/src/documents-sheet.tsx | 37 +++++---- apps/web/src/navigation-shell.tsx | 91 +++++++++------------- apps/web/src/phone-header.tsx | 19 ++++- apps/web/src/phone-sheets.css | 47 ++++++++--- apps/web/src/project-sidebar.tsx | 1 - apps/web/src/research-sidebar.test.ts | 3 +- apps/web/src/room-workspace.tsx | 5 ++ e2e/documents-sheet.e2e.ts | 20 +++++ packages/icons/src/documents.tsx | 13 ++++ packages/icons/src/index.ts | 1 + packages/visuals/theme.css | 1 + 16 files changed, 263 insertions(+), 117 deletions(-) create mode 100644 packages/icons/src/documents.tsx diff --git a/apps/web/src/action-sheet.tsx b/apps/web/src/action-sheet.tsx index 9ea058875..78afb6f42 100644 --- a/apps/web/src/action-sheet.tsx +++ b/apps/web/src/action-sheet.tsx @@ -10,7 +10,8 @@ export type ActionSheetItem = { action: Action; label: string; icon?: (props: IconProps) => ReactNode; - tone?: "danger" | "normal"; + /** Only for what cannot be undone; an archive has Undo, so it reads as ordinary. */ + destructive?: boolean; }; /** @@ -75,7 +76,7 @@ export function ActionSheet( {items.map(item => ( )}
    diff --git a/apps/web/src/phone-sheets.css b/apps/web/src/phone-sheets.css index 59e786dec..4675c9873 100644 --- a/apps/web/src/phone-sheets.css +++ b/apps/web/src/phone-sheets.css @@ -61,7 +61,9 @@ background: var(--color-gray-300); } +/* A 44px target around the small filled circle iOS sheets close with. */ .phone-sheet-close { + position: relative; display: grid; width: var(--control-target-coarse); height: var(--control-target-coarse); @@ -72,8 +74,21 @@ -webkit-tap-highlight-color: transparent; } -.phone-sheet-close:active { - background: var(--color-hover); +.phone-sheet-close::before { + content: ""; + position: absolute; + width: calc(var(--spacing) * 7.5); + height: calc(var(--spacing) * 7.5); + border-radius: var(--radius-full); + background: var(--color-inset); +} + +.phone-sheet-close:active::before { + background: var(--color-selected); +} + +.phone-sheet-close > svg { + position: relative; } /* Documents ------------------------------------------------------------ */ @@ -82,6 +97,7 @@ position: relative; display: flex; width: 100%; + max-width: var(--sheet-max-width); height: min( calc(100dvh * var(--sheet-large-detent)), calc(100dvh - env(safe-area-inset-top) - var(--sheet-gap)) @@ -286,6 +302,10 @@ [data-sheet-page="archived"] .documents-sheet-scroll { padding-top: calc(var(--spacing) * 2); +} + +/* Without the New document footer, the list itself keeps clear of the home indicator. */ +.documents-sheet-scroll:last-child { padding-bottom: calc(var(--spacing) * 3 + env(safe-area-inset-bottom)); } @@ -330,11 +350,6 @@ -webkit-tap-highlight-color: transparent; } -.documents-sheet-recent[aria-current="page"] { - outline: var(--focus-ring-width) solid var(--color-brand); - outline-offset: calc(var(--focus-ring-width) * -1); -} - .documents-sheet-recent:active { background: var(--color-selected); } @@ -479,12 +494,24 @@ } } +/* Renaming shows the title even where the bar keeps it hidden until the page scrolls. */ +.workspace-root[data-workspace-title] .phone-top-bar-title:has(input) { + opacity: 1; +} + +/* A tap leaves :hover behind on touch screens; a trigger shouldn't stay filled under its sheet. */ +@media (hover: none) { + .phone-top-bar-slot .btn-ghost:hover:not(:active) { + background-color: transparent; + } +} + /* Action sheet --------------------------------------------------------- */ .action-sheet { display: grid; width: 100%; - max-width: var(--phone-tab-row-max); + max-width: var(--sheet-max-width); gap: var(--sheet-gap); padding: 0 calc(var(--sheet-gap) + env(safe-area-inset-right)) max(var(--sheet-gap), env(safe-area-inset-bottom)) @@ -495,9 +522,7 @@ display: grid; overflow: hidden; border-radius: var(--radius-sheet); - background: var(--color-glass); - -webkit-backdrop-filter: var(--glass-filter); - backdrop-filter: var(--glass-filter); + background: var(--color-page); box-shadow: var(--shadow-overlay); } diff --git a/apps/web/src/project-sidebar.tsx b/apps/web/src/project-sidebar.tsx index 31898ec58..d36f94879 100644 --- a/apps/web/src/project-sidebar.tsx +++ b/apps/web/src/project-sidebar.tsx @@ -28,7 +28,6 @@ import { } from "@chopin/icons"; import type * as Api from "./api"; -export { documentGroups } from "./documents-sheet-model"; import type { DocumentAction } from "./document-actions-menu"; import type { ProjectDocuments } from "./document-actions"; import type { DocumentCreationPhase } from "./use-document-creation"; diff --git a/apps/web/src/research-sidebar.test.ts b/apps/web/src/research-sidebar.test.ts index 4edda7ceb..3aeca60bc 100644 --- a/apps/web/src/research-sidebar.test.ts +++ b/apps/web/src/research-sidebar.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "bun:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { documentGroups, ProjectSidebar } from "./project-sidebar"; +import { documentGroups } from "./documents-sheet-model"; +import { ProjectSidebar } from "./project-sidebar"; import type { ComponentProps } from "react"; import type * as Api from "./api"; diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index d0db7bf2a..b9c0c4159 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 { browserStorage, rememberOpened } from "./documents-sheet-model"; import { PhoneHeader } from "./phone-header"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; @@ -317,6 +318,10 @@ export function RoomWorkspace( onResearchChildOpen, onResearchChildPublished, } = useNavigationDocument(); + // Phones list a project's recently opened documents in the documents sheet. + useEffect(() => { + if (!archivedAt) rememberOpened(browserStorage(), userId, repository.id, room); + }, [archivedAt, repository.id, room, userId]); let [status, setStatus] = useState("connecting"); let [planState, setPlanState] = useState({ synced: false }); // Claimed while rendering, so a new document's title takes focus as soon as it mounts. diff --git a/e2e/documents-sheet.e2e.ts b/e2e/documents-sheet.e2e.ts index 93b4a9a5e..d6c7ff981 100644 --- a/e2e/documents-sheet.e2e.ts +++ b/e2e/documents-sheet.e2e.ts @@ -76,6 +76,24 @@ test("the documents sheet filters inline and navigates to a document", async ({ await expect(page.locator(".phone-top-bar")).toContainText(title(other)); }); +test("Recent lists the documents this person opened, not the one on screen", async ({ baseURL, join, room }) => { + let first = await otherDocument(baseURL!); + let second = await otherDocument(baseURL!); + let page = await join("ana", PHONE); + let recent = sheet(page).getByRole("region", { name: "Recent" }); + for (let id of [first, second]) { + await documentsButton(page).click(); + await sheet(page).getByRole("region", { name: "Documents" }) + .getByRole("link", { name: title(id) }).click(); + await expect(page.locator(".phone-top-bar")).toContainText(title(id)); + } + await documentsButton(page).click(); + await expect(recent.getByRole("link")).toHaveCount(2); + await expect(recent.getByRole("link").nth(0)).toContainText(title(first)); + await expect(recent.getByRole("link").nth(1)).toContainText(title(room)); + await expect(recent.getByRole("link", { name: new RegExp(title(second)) })).toHaveCount(0); +}); + test("archived documents push a page inside the sheet and come back", async ({ join }) => { let page = await join("ana", PHONE); await documentsButton(page).click(); @@ -126,6 +144,8 @@ test("document menus open an action sheet on a phone", async ({ baseURL, join, r await actions.getByRole("button", { name: "Rename", exact: true }).click(); let field = page.getByRole("textbox", { name: "Document title" }); await expect(field).toBeFocused(); + // The bar hides its title until the page scrolls; renaming must show it regardless. + await expect(page.locator(".phone-top-bar-title")).toHaveCSS("opacity", "1"); expect(await field.evaluate(element => parseFloat(getComputedStyle(element).fontSize))) .toBeGreaterThanOrEqual(16); await field.press("Escape"); diff --git a/packages/icons/src/documents.tsx b/packages/icons/src/documents.tsx new file mode 100644 index 000000000..176009b31 --- /dev/null +++ b/packages/icons/src/documents.tsx @@ -0,0 +1,13 @@ +import { LineIcon } from "./icon"; + +/** A page with another behind it: the documents list. Sized for the phone bar, so it takes no props. */ +export function DocumentsIcon() { + return ( + + + + + + + ); +} diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 1dc40426b..f4019aebc 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -1,5 +1,6 @@ export { GitHubIcon } from "./brand"; export { CopyIcon } from "./copy"; +export { DocumentsIcon } from "./documents"; export { ChopinIcon } from "./filled"; export { IssueCompletedIcon, diff --git a/packages/visuals/theme.css b/packages/visuals/theme.css index 05f926c0e..2d44fdabe 100644 --- a/packages/visuals/theme.css +++ b/packages/visuals/theme.css @@ -258,6 +258,7 @@ /* Phone sheets: documents stand at a large detent; menus become grouped action sheets. */ --sheet-large-detent: 0.92; --sheet-gap: calc(var(--spacing) * 2); + --sheet-max-width: 35rem; --action-sheet-row-height: calc(var(--spacing) * 14); /* Empty states share one illustration and copy rhythm. */ From 47e674afe89bee9a30cdad9dc6e2fd85d18cb107 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:00:42 +0100 Subject: [PATCH 3/5] Open prefetched phone sheets on the first tap and drop edit times from Recent lazy() suspends a component's first render even when its module is already loaded, and React holds the reveal, so the first tap took about 300ms. The shell and document menus keep the module their prefetch resolves and render the sheet directly, choosing the component once per open so a late load never remounts it. Phones prefetch the action sheet on idle too. Recent is ordered by when you opened a document, so its cards no longer claim an edit time. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/document-actions-menu.tsx | 23 ++++++++--- apps/web/src/documents-sheet-model.test.ts | 13 ------ apps/web/src/documents-sheet-model.ts | 21 ---------- apps/web/src/documents-sheet.tsx | 5 --- apps/web/src/navigation-shell.tsx | 47 +++++++++++++++------- apps/web/src/phone-header.tsx | 12 +++--- apps/web/src/phone-sheets.css | 9 +---- 7 files changed, 57 insertions(+), 73 deletions(-) diff --git a/apps/web/src/document-actions-menu.tsx b/apps/web/src/document-actions-menu.tsx index f7bb9375e..c8e654baf 100644 --- a/apps/web/src/document-actions-menu.tsx +++ b/apps/web/src/document-actions-menu.tsx @@ -21,10 +21,14 @@ import { IconButton } from "./icon-button"; import type { IconProps } from "@chopin/icons"; import type * as Api from "./api"; -import type { CSSProperties, KeyboardEvent, ReactNode } from "react"; +import type { ComponentProps, ComponentType, CSSProperties, KeyboardEvent, ReactNode } from "react"; -let loadActionSheet = () => import("./action-sheet"); -let ActionSheet = lazy(() => loadActionSheet().then(module => ({ default: module.ActionSheet }))); +// Once prefetched, the sheet renders directly: lazy() would still suspend its first render. +let actionSheet: typeof import("./action-sheet") | undefined; +export let loadActionSheet = () => import("./action-sheet").then(module => (actionSheet = module)); +let LazyActionSheet = lazy(() => + loadActionSheet().then(module => ({ default: module.ActionSheet })) +); export type DocumentAction = "copy-link" | "rename" | "archive" | "restore" | "delete"; @@ -81,6 +85,11 @@ export function DocumentActionsMenu( let panel = useRef(null); let initialItem = useRef(0); let phone = usePhone(); + // Chosen when opened, so a load finishing later never remounts an open sheet. + let sheet = useRef< + ComponentType> + >(LazyActionSheet); + let Sheet = sheet.current; let [open, setOpen] = useState(false); let [sheetOpen, setSheetOpen] = useState(false); let [position, setPosition] = useState(); @@ -197,8 +206,10 @@ export function DocumentActionsMenu( controls[next]?.focus(); }; let toggleMenu = () => { - if (phone) setSheetOpen(true); - else if (open) closeMenu(); + if (phone) { + sheet.current = actionSheet?.ActionSheet ?? LazyActionSheet; + setSheetOpen(true); + } else if (open) closeMenu(); else openAt(0); }; // A phone's first tap should open the sheet, not wait for its code. @@ -254,7 +265,7 @@ export function DocumentActionsMenu( )} {sheetOpen && ( - { expect(readRecentIds(broken, "U", "R")).toEqual([]); expect(() => rememberOpened(broken, "U", "R", "a")).not.toThrow(); }); - - it("says when a document was edited", () => { - let now = Date.parse("2026-10-11T12:00:00"); - let at = (value: string) => new Date(value).toISOString(); - expect(editedLabel(at("2026-10-11T11:59:40"), now)).toBe("Edited just now"); - expect(editedLabel(at("2026-10-11T11:58:00"), now)).toBe("Edited 2 min ago"); - expect(editedLabel(at("2026-10-11T09:00:00"), now)).toBe("Edited 3 hr ago"); - expect(editedLabel(at("2026-10-10T08:00:00"), now)).toBe("Edited yesterday"); - expect(editedLabel(at("2026-10-07T08:00:00"), now, "en-US")).toBe("Edited Wednesday"); - expect(editedLabel(at("2026-09-20T08:00:00"), now, "en-US")).toBe("Edited Sep 20"); - expect(editedLabel("not a date", now)).toBe(""); - }); }); diff --git a/apps/web/src/documents-sheet-model.ts b/apps/web/src/documents-sheet-model.ts index c149ee0f0..443820161 100644 --- a/apps/web/src/documents-sheet-model.ts +++ b/apps/web/src/documents-sheet-model.ts @@ -101,24 +101,3 @@ export function recentDocuments( .slice(0, limit); return recent.length >= 2 ? recent : []; } - -export function editedLabel(updatedAt: string, now: number, locale?: string): string { - let then = Date.parse(updatedAt); - if (!Number.isFinite(then)) return ""; - let minutes = Math.floor(Math.max(0, now - then) / 60_000); - if (minutes < 1) return "Edited just now"; - if (minutes < 60) return `Edited ${minutes} min ago`; - let hours = Math.floor(minutes / 60); - if (hours < 24) return `Edited ${hours} hr ago`; - let date = new Date(then); - let today = new Date(now); - let days = Math.round( - (new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime() - - new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()) / 86_400_000, - ); - if (days <= 1) return "Edited yesterday"; - if (days < 7) { - return `Edited ${date.toLocaleDateString(locale, { weekday: "long" })}`; - } - return `Edited ${date.toLocaleDateString(locale, { month: "short", day: "numeric" })}`; -} diff --git a/apps/web/src/documents-sheet.tsx b/apps/web/src/documents-sheet.tsx index 9d4250124..42ee4b0a9 100644 --- a/apps/web/src/documents-sheet.tsx +++ b/apps/web/src/documents-sheet.tsx @@ -21,7 +21,6 @@ import { DocumentActionsMenu } from "./document-actions-menu"; import { browserStorage, documentGroups, - editedLabel, filterDocumentGroups, readRecentIds, recentDocuments, @@ -235,7 +234,6 @@ export function DocumentsSheet( currentDocumentId, ) : []; - let now = Date.now(); return ( {channel.title} - - {editedLabel(channel.updatedAt, now)} - ); diff --git a/apps/web/src/navigation-shell.tsx b/apps/web/src/navigation-shell.tsx index 5bab3bf01..980032637 100644 --- a/apps/web/src/navigation-shell.tsx +++ b/apps/web/src/navigation-shell.tsx @@ -53,7 +53,13 @@ import { requestTitleEdit } from "./title-edit"; import type { Research } from "@chopin/protocol"; import type { ResearchOpener } from "@chopin/editor"; -import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import type { + ComponentProps, + ComponentType, + CSSProperties, + MouseEvent as ReactMouseEvent, + ReactNode, +} from "react"; import type { DocumentMetadata } from "./document-actions"; import type { DocumentRouteIdentity } from "./document-route-swap"; import type { ShortcutActions } from "./global-shortcuts"; @@ -85,8 +91,12 @@ class LazyDialogBoundary extends Component< } let NavigationNotice = lazy(() => import("./navigation-notice-view")); -let DocumentsSheet = lazy(() => - import("./documents-sheet").then(module => ({ default: module.DocumentsSheet })) +// Once prefetched, the sheet renders directly: lazy() would still suspend its first render. +let documentsSheet: typeof import("./documents-sheet") | undefined; +export let loadDocumentsSheet = () => + import("./documents-sheet").then(module => (documentsSheet = module)); +let LazyDocumentsSheet = lazy(() => + loadDocumentsSheet().then(module => ({ default: module.DocumentsSheet })) ); let NavigationDrawer = lazy(() => import("./navigation-drawer").then(module => ({ default: module.NavigationDrawer })) @@ -265,8 +275,13 @@ export function NavigationShell( let mode = useNavigationMode(); let phone = usePhone(); // Phones navigate from a bottom sheet; it stays mounted once loaded so it can animate out. - let [sheetLoaded, setSheetLoaded] = useState(false); - if (phone && drawerOpen && !sheetLoaded) setSheetLoaded(true); + // Chosen once when first opened, so a load finishing later never remounts an open sheet. + let [Sheet, setSheet] = useState< + ComponentType> + >(); + if (phone && drawerOpen && !Sheet) { + setSheet(() => documentsSheet?.DocumentsSheet ?? LazyDocumentsSheet); + } let immediateMotion = motionImmediately(); let previousMode = useRef(mode); let modeChanged = previousMode.current !== mode; @@ -1007,7 +1022,7 @@ export function NavigationShell( shortcut={sidebarShortcut} /> )} - {(phone ? sheetLoaded : drawerPresence.phase !== "closed") && ( + {(phone ? Sheet : drawerPresence.phase !== "closed") && ( { dismissDrawer(); @@ -1016,15 +1031,17 @@ export function NavigationShell( > {phone ? ( - + Sheet && ( + + ) ) : ( void) => window.setTimeout(task, 1500)); let cancel = window.cancelIdleCallback ?? window.clearTimeout; - let handle = idle(loadDocumentsSheet); + let handle = idle(prefetchDocumentsSheet); return () => cancel(handle); }, [hostsTrigger]); @@ -73,7 +75,7 @@ export function PhoneHeader( aria-label="Documents" className="btn btn-icon btn-ghost" onClick={trigger.onExpand} - onPointerDown={loadDocumentsSheet} + onPointerDown={prefetchDocumentsSheet} ref={trigger.buttonRef} type="button" > diff --git a/apps/web/src/phone-sheets.css b/apps/web/src/phone-sheets.css index 4675c9873..37ec6996d 100644 --- a/apps/web/src/phone-sheets.css +++ b/apps/web/src/phone-sheets.css @@ -340,10 +340,8 @@ .documents-sheet-recent { display: flex; height: 100%; - min-height: calc(var(--spacing) * 20); + min-height: calc(var(--spacing) * 16); flex-direction: column; - justify-content: space-between; - gap: calc(var(--spacing) * 2); padding: calc(var(--spacing) * 3); border-radius: var(--radius-lg); background: var(--color-inset); @@ -363,11 +361,6 @@ -webkit-line-clamp: 3; } -.documents-sheet-recent-meta { - font-size: var(--text-sm); - color: var(--color-text-tertiary); -} - .documents-sheet-list { display: grid; padding-inline: calc(var(--spacing) * 2); From 19b1a8c27a2878279d9a72f793fac7e4a713ad67 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:05:59 +0100 Subject: [PATCH 4/5] Renew design-contract review hashes for project-sidebar.tsx, document-actions-menu.tsx and navigation-shell.tsx - apps/web/src/project-sidebar.tsx: documentGroups moved to the documents sheet model; the reviewed class and style sites are unchanged. - apps/web/src/document-actions-menu.tsx: phones open a lazily loaded action sheet instead of the menu; no new class or style expressions. - apps/web/src/navigation-shell.tsx: phones render the documents sheet in place of the drawer; the drawer keeps the same presence motion. Co-Authored-By: Claude Opus 5.5 --- scripts/design-contract/exceptions/dynamic-web.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index f8e6ac6cc..695248b9c 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -101,7 +101,7 @@ 1 ] ], - "sourceHash": "cf9223a300a5e36ab22e6394d5c344a6eab34736178d197ae6c6d2d8b7bfd1dc" + "sourceHash": "dd6b04ffeeb0a69121c30e09a20a5328d95eeed5aedb8e74c67d05500e015eac" }, { "file": "apps/web/src/project-sidebar.tsx", @@ -115,7 +115,7 @@ 1 ] ], - "sourceHash": "cf9223a300a5e36ab22e6394d5c344a6eab34736178d197ae6c6d2d8b7bfd1dc" + "sourceHash": "dd6b04ffeeb0a69121c30e09a20a5328d95eeed5aedb8e74c67d05500e015eac" }, { "file": "apps/web/src/design-audit/icons.tsx", @@ -157,7 +157,7 @@ 1 ] ], - "sourceHash": "895d89913307fd6884c56a0f7ccf8e698988a4546ca525465105b69c365a5db7" + "sourceHash": "6a831eddad9bc4aa3ee4e0d0be0b76611110e72c36dda112fdc1650bc4f1719a" }, { "file": "apps/web/src/document-actions-menu.tsx", @@ -171,7 +171,7 @@ 1 ] ], - "sourceHash": "895d89913307fd6884c56a0f7ccf8e698988a4546ca525465105b69c365a5db7" + "sourceHash": "6a831eddad9bc4aa3ee4e0d0be0b76611110e72c36dda112fdc1650bc4f1719a" }, { "file": "apps/web/src/document-actions-menu.tsx", @@ -192,7 +192,7 @@ 1 ] ], - "sourceHash": "895d89913307fd6884c56a0f7ccf8e698988a4546ca525465105b69c365a5db7" + "sourceHash": "6a831eddad9bc4aa3ee4e0d0be0b76611110e72c36dda112fdc1650bc4f1719a" }, { "file": "apps/web/src/terminal-alert.tsx", @@ -227,7 +227,7 @@ 1 ] ], - "sourceHash": "2d5faf85305b49b9e298aca9a1ade41909561175dc0d4d44fbda7471793ad4c3" + "sourceHash": "085a1e93a556df3e2e0374330c3272badfbb1d53e5cbd748e71a8a4b4c7e69be" }, { "file": "apps/web/src/navigation-drawer.tsx", From bea129428f7fc7ee041a1af917e7a6e08c08835d Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:33:00 +0100 Subject: [PATCH 5/5] Fit the documents sheet to the merged phone shell and settle its specs - The phone top bar's rename field takes the base text step, so the touch field floor (now in the base layer, from #470) reaches it and iOS does not zoom on focus; the bar's inherited small step had overridden it. - pwa: phones now reach Documents from the top bar rather than a floating sidebar button, so the safe-area check follows that button. - document-activity: the Document tab's name no longer matches the top bar's Documents button. - documents-sheet: wait for the outgoing document's bar to leave after a route swap, and measure action-sheet rows once the entrance settles. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-workspace.css | 2 ++ e2e/document-activity.e2e.ts | 3 ++- e2e/documents-sheet.e2e.ts | 6 ++++++ e2e/pwa.e2e.ts | 4 ++-- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index e7cfdd908..a12520b94 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -186,6 +186,8 @@ .phone-top-bar-title input { width: 100%; + /* The title control inherits the bar's small type, which would let iOS zoom in on focus. */ + font-size: var(--text-base); text-align: center; } diff --git a/e2e/document-activity.e2e.ts b/e2e/document-activity.e2e.ts index 54cc734d2..c5ed8ed4b 100644 --- a/e2e/document-activity.e2e.ts +++ b/e2e/document-activity.e2e.ts @@ -47,7 +47,8 @@ for ( await expect(content(page)).toContainText("Original paragraph."); await showDecisions(page); - let documentTab = page.getByRole("button", { name: /^Document/ }); + // Not the top bar's Documents button. + let documentTab = page.getByRole("button", { name: /^Document(?!s)/ }); await expect(documentTab).toHaveAccessibleName("Document"); await rewrite(baseURL!, room, "# Title\n\nRevised paragraph.\n"); diff --git a/e2e/documents-sheet.e2e.ts b/e2e/documents-sheet.e2e.ts index d6c7ff981..ea0af78cc 100644 --- a/e2e/documents-sheet.e2e.ts +++ b/e2e/documents-sheet.e2e.ts @@ -85,6 +85,8 @@ test("Recent lists the documents this person opened, not the one on screen", asy await documentsButton(page).click(); await sheet(page).getByRole("region", { name: "Documents" }) .getByRole("link", { name: title(id) }).click(); + // The outgoing document's bar stays mounted while the route swaps. + await expect(page.locator(".phone-top-bar")).toHaveCount(1); await expect(page.locator(".phone-top-bar")).toContainText(title(id)); } await documentsButton(page).click(); @@ -123,6 +125,10 @@ test("document menus open an action sheet on a phone", async ({ baseURL, join, r await more.click(); let actions = page.getByRole("dialog", { name: `Actions for ${title(room)}` }); await expect(actions).toBeVisible(); + // Measure once the sheet's entrance has settled. + await actions.evaluate(element => + Promise.all(element.getAnimations({ subtree: true }).map(item => item.finished)) + ); await expect(page.getByRole("menu")).toHaveCount(0); for (let label of ["Copy link", "Rename", "Archive", "Cancel"]) { let item = actions.getByRole("button", { name: label, exact: true }); diff --git a/e2e/pwa.e2e.ts b/e2e/pwa.e2e.ts index be696c979..556e5fca4 100644 --- a/e2e/pwa.e2e.ts +++ b/e2e/pwa.e2e.ts @@ -61,9 +61,9 @@ test("touch text fields stay at least 16px while desktop keeps its sizes", async .toBeGreaterThanOrEqual(16); }); -test("the floating sidebar button clears the top safe area", async ({ join }) => { +test("the phone top bar's Documents button clears the top safe area", async ({ join }) => { let page = await join("ana", PHONE); - let button = page.getByRole("button", { name: "Show sidebar", exact: true }); + let button = page.getByRole("button", { name: "Documents", exact: true }); await expect(button).toBeVisible(); let resting = (await button.boundingBox())!.y; // Newer than Playwright's protocol types; Chromium reports env(safe-area-inset-*) from it.