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..78afb6f42 --- /dev/null +++ b/apps/web/src/action-sheet.tsx @@ -0,0 +1,95 @@ +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; + /** Only for what cannot be undone; an archive has Undo, so it reads as ordinary. */ + destructive?: boolean; +}; + +/** + * 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/copy-link.ts b/apps/web/src/copy-link.ts index 725af96d9..232836c1c 100644 --- a/apps/web/src/copy-link.ts +++ b/apps/web/src/copy-link.ts @@ -1,8 +1,12 @@ import type { NoticeOptions } from "./navigation-notice"; -export async function copyLink(href: string, notify: (notice: NoticeOptions) => void) { +/** + * Reports a clipboard write. The caller starts the write itself, inside the tap or click, + * because iOS Safari refuses one that begins after an asynchronous module load. + */ +export async function copyLink(written: Promise, notify: (notice: NoticeOptions) => void) { try { - await navigator.clipboard.writeText(href); + await written; notify({ message: "Link copied" }); } catch { notify({ message: "Could not copy link" }); diff --git a/apps/web/src/document-actions-menu.tsx b/apps/web/src/document-actions-menu.tsx index 3e32d5f69..c8e654baf 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"; @@ -11,7 +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"; + +// 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"; @@ -67,7 +84,14 @@ export function DocumentActionsMenu( let button = useRef(null); 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(); let items = documentMenuItems(channel); let immediately = motionImmediately(); @@ -181,8 +205,22 @@ export function DocumentActionsMenu( : (current - 1 + controls.length) % controls.length; controls[next]?.focus(); }; - let toggleMenu = () => open ? closeMenu() : openAt(0); + let toggleMenu = () => { + 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. + let preload = phone + ? () => { + void loadActionSheet(); + void import("./copy-link"); + } + : undefined; 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 +232,7 @@ export function DocumentActionsMenu( ? ( {trigger} )} + {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("remembers opened documents per project and shows two or more besides the current", () => { + let values = new Map(); + let storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + }; + for (let id of ["a", "b", "a", "c"]) rememberOpened(storage, "U", "R", id); + rememberOpened(storage, "U", "Other", "z"); + expect(readRecentIds(storage, "U", "R")).toEqual(["c", "a", "b"]); + expect(readRecentIds(storage, "U", "Other")).toEqual(["z"]); + expect(readRecentIds(storage, "V", "R")).toEqual([]); + let archived = { ...release, archivedAt: "2026-10-11T00:00:00Z" }; + expect(recentDocuments(["c", "a", "b"], [offline, archived, eviction], "c")).toEqual([]); + expect(recentDocuments(["c", "a", "b"], [offline, release, eviction], "c")).toEqual([ + offline, + release, + ]); + expect(recentDocuments(["gone", "a"], [offline], undefined)).toEqual([]); + let broken = { + getItem: () => "{not json", + setItem: () => { + throw new Error("full"); + }, + }; + expect(readRecentIds(broken, "U", "R")).toEqual([]); + expect(() => rememberOpened(broken, "U", "R", "a")).not.toThrow(); + }); +}); diff --git a/apps/web/src/documents-sheet-model.ts b/apps/web/src/documents-sheet-model.ts new file mode 100644 index 000000000..443820161 --- /dev/null +++ b/apps/web/src/documents-sheet-model.ts @@ -0,0 +1,103 @@ +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 }] : []; + }); +} + +const RECENT_LIMIT = 8; + +/** Storage access itself throws where site data is blocked. */ +export function browserStorage(): Storage | undefined { + try { + return localStorage; + } catch { + return undefined; + } +} + +function recentKey(userId: string): string { + return `chopin:recent-documents:${userId}`; +} + +/** Each project's documents this browser's user opened, newest first. */ +export function readRecentIds( + storage: Pick | undefined, + userId: string, + repositoryId: string, +): string[] { + try { + let value: unknown = JSON.parse(storage?.getItem(recentKey(userId)) ?? "{}"); + let ids = value && typeof value === "object" + ? (value as Record)[repositoryId] + : undefined; + return Array.isArray(ids) ? ids.filter(id => typeof id === "string") : []; + } catch { + return []; + } +} + +export function rememberOpened( + storage: Pick | undefined, + userId: string, + repositoryId: string, + documentId: string, +): void { + try { + let all: Record = JSON.parse(storage?.getItem(recentKey(userId)) ?? "{}"); + let ids = readRecentIds(storage, userId, repositoryId).filter(id => id !== documentId); + all[repositoryId] = [documentId, ...ids].slice(0, RECENT_LIMIT); + storage?.setItem(recentKey(userId), JSON.stringify(all)); + } catch { + // Recents are a convenience; private modes and full storage simply go without. + } +} + +/** + * Recently opened documents other than the one on screen, still active in the catalogue. + * One alone would only repeat the tree, so recents show from two. + */ +export function recentDocuments( + ids: string[], + channels: Channel[], + currentId: string | undefined, + limit = 3, +): Channel[] { + let known = new Map(channels.map(channel => [channel.id, channel])); + let recent = ids.filter(id => id !== currentId) + .map(id => known.get(id)) + .filter((channel): channel is Channel => channel !== undefined && !channel.archivedAt) + .slice(0, limit); + return recent.length >= 2 ? recent : []; +} diff --git a/apps/web/src/documents-sheet.tsx b/apps/web/src/documents-sheet.tsx new file mode 100644 index 000000000..42ee4b0a9 --- /dev/null +++ b/apps/web/src/documents-sheet.tsx @@ -0,0 +1,467 @@ +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 { + browserStorage, + documentGroups, + filterDocumentGroups, + readRecentIds, + recentDocuments, +} from "./documents-sheet-model"; +import { canManageProject, documentDestination } from "./navigation-model"; + +import "./phone-sheets.css"; + +import type { MouseEvent, RefObject } 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, + onNavigate, + onOpenChange, + onSignOut, + open, + opener, + 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; + onNavigate: (documentId: string, path: string) => void; + onOpenChange: (open: boolean) => void; + onSignOut: () => void; + open: boolean; + opener: RefObject; + 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 dismissed = useRef(false); + 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 && open && page === "documents" && !query.trim() + ? recentDocuments( + readRecentIds(browserStorage(), user.id, entry.project.repositoryId), + entry.documents.channels, + currentDocumentId, + ) + : []; + + return ( + { + dismissed.current = !next; + onOpenChange(next); + }} + onOpenChangeComplete={next => { + if (next) return; + reset(); + // A dismissal hands focus back once the sheet, and any field in it, has gone. + if (dismissed.current) opener.current?.focus({ preventScroll: true }); + dismissed.current = false; + }} + open={ready && open} + > + + + + + + + + + ); +} diff --git a/apps/web/src/navigation-shell.tsx b/apps/web/src/navigation-shell.tsx index cb4c32435..980032637 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"; @@ -52,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"; @@ -84,6 +91,13 @@ class LazyDialogBoundary extends Component< } let NavigationNotice = lazy(() => import("./navigation-notice-view")); +// 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 })) ); @@ -235,6 +249,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 +273,15 @@ 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. + // 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; @@ -265,7 +296,7 @@ export function NavigationShell( immediateMotion || modeChanged, ); let drawerPresence = useTransitionPresence( - mode === "drawer" && drawerOpen ? true : undefined, + mode === "drawer" && drawerOpen && !phone ? true : undefined, 180, immediateMotion, ); @@ -631,7 +662,8 @@ export function NavigationShell( if (action === "copy-link") { let href = new URL(documentDestination(projectsRef.current, channel.id), location.origin).href; - void import("./copy-link").then(module => module.copyLink(href, showNotice)); + let written = (async () => navigator.clipboard.writeText(href))(); + void import("./copy-link").then(module => module.copyLink(written, showNotice)); return; } if (action === "rename") { @@ -767,11 +799,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"), @@ -826,9 +858,23 @@ export function NavigationShell( navigate(`${destination.pathname}${destination.search}${destination.hash}`); }; let sidebarLoading = ; + // What the Projects sidebar and the phone documents sheet both read and do. + let catalogue = { + catalogueMode, + currentDocumentId, + onAddProject: () => showDialog("add"), + onCatalogueModeChange: setCatalogueMode, + onCreateDocument: (project: Api.NavigationProject) => void createDocument(project), + onDocumentAction: documentAction, + onLoadMore: loadMore, + pendingCreations: creation.pending, + projects, + user, + }; let sidebar = ( setAccountOpen(open => !open)} focusProjectId={focusProjectId} - onAddProject={() => showDialog("add")} onFocusedProject={() => setFocusProjectId(undefined)} onRevealed={() => setReveal(undefined)} reveal={reveal} onCollapse={collapseSidebar} - onCreateDocument={project => void createDocument(project)} - onDocumentAction={documentAction} - onLoadMore={loadMore} onNewDocument={newDocument} onSearch={() => showDialog("search")} - onCatalogueModeChange={setCatalogueMode} - projects={projects} - catalogueMode={catalogueMode} - user={user} /> ); @@ -962,6 +998,8 @@ export function NavigationShell(
    {sidebarPresence.phase !== "closed" && ( @@ -979,21 +1017,40 @@ export function NavigationShell( )} {triggerVisible && ( )} - {drawerPresence.phase !== "closed" && ( + {(phone ? Sheet : drawerPresence.phase !== "closed") && ( { dismissDrawer(); setDialogLoadFailed(true); }} > - - {sidebar} - + {phone + ? ( + Sheet && ( + + ) + ) + : ( + } + onDismiss={dismissDrawer} + > + {sidebar} + + )} )} {children === undefined diff --git a/apps/web/src/phone-header.tsx b/apps/web/src/phone-header.tsx index e0dee9b56..b0fd3baa3 100644 --- a/apps/web/src/phone-header.tsx +++ b/apps/web/src/phone-header.tsx @@ -1,17 +1,24 @@ /** 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 { useContext, useEffect, useLayoutEffect, useRef } from "react"; +import { DocumentsIcon, EllipsisIcon } from "@chopin/icons"; -import { DocumentActionsMenu } from "./document-actions-menu"; +import { DocumentActionsMenu, loadActionSheet } from "./document-actions-menu"; import { DocumentRename } from "./document-rename"; -import { ProjectSidebarExpandButton, SidebarTrigger } from "./project-sidebar-chrome"; +import { loadDocumentsSheet } from "./navigation-shell"; +import { 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"; +// Warmed before the first tap, so opening the sheet never waits on its code. +function prefetchDocumentsSheet() { + void loadDocumentsSheet(); + void loadActionSheet(); +} + export function PhoneHeader( { archivedAt, @@ -41,6 +48,13 @@ export function PhoneHeader( let hostsTrigger = presentation.type !== "child" && trigger !== undefined; let claim = trigger?.claim; useLayoutEffect(() => hostsTrigger ? claim?.() : undefined, [claim, hostsTrigger]); + useEffect(() => { + if (!hostsTrigger) return; + let idle = window.requestIdleCallback ?? ((task: () => void) => window.setTimeout(task, 1500)); + let cancel = window.cancelIdleCallback ?? window.clearTimeout; + let handle = idle(prefetchDocumentsSheet); + return () => cancel(handle); + }, [hostsTrigger]); let finishEdit = () => { // Naming a new document leads into writing it, unless the user already tapped elsewhere. @@ -56,7 +70,17 @@ 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..37ec6996d --- /dev/null +++ b/apps/web/src/phone-sheets.css @@ -0,0 +1,595 @@ +/* + * 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); +} + +/* 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); + flex: none; + place-items: center; + border-radius: var(--radius-full); + color: var(--color-text-secondary); + -webkit-tap-highlight-color: transparent; +} + +.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 ------------------------------------------------------------ */ + +.documents-sheet { + 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)) + ); + 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); +} + +/* 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)); +} + +.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) * 16); + flex-direction: column; + padding: calc(var(--spacing) * 3); + border-radius: var(--radius-lg); + background: var(--color-inset); + -webkit-tap-highlight-color: transparent; +} + +.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-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; + } +} + +/* 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(--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)) + calc(var(--sheet-gap) + env(safe-area-inset-left)); +} + +.action-sheet-group { + display: grid; + overflow: hidden; + border-radius: var(--radius-sheet); + background: var(--color-page); + 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/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/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..d36f94879 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,7 @@ import { SignInIcon, } from "@chopin/icons"; import type * as Api from "./api"; + import type { DocumentAction } from "./document-actions-menu"; import type { ProjectDocuments } from "./document-actions"; import type { DocumentCreationPhase } from "./use-document-creation"; @@ -47,25 +49,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/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/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/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 new file mode 100644 index 000000000..ea0af78cc --- /dev/null +++ b/e2e/documents-sheet.e2e.ts @@ -0,0 +1,182 @@ +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("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(); + // 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(); + 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(); + 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(); + // 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 }); + 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(); + // 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"); + 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/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. 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/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 fa1a44e23..2d44fdabe 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,11 @@ --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); + --sheet-max-width: 35rem; + --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 +282,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 +374,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; 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",