From 6ca2dbe8030f87f16efa4e814163d31d6ee90b49 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:21:42 +0100 Subject: [PATCH 01/10] Open documents for reading on phones, with an edit toggle Phones now open a document in a local reading mode: the editor is not editable, so tapping prose places no caret and raises no keyboard, links follow on tap, and editing-only chrome (Show source, code language menus, image editing, tab add/remove, table rails, slash menu) stays hidden. Selection and commenting keep working, because reading is separate from the permission-based readOnly lock, which still wins. A pencil in the phone tab bar switches to editing inside the tap, placing the caret where the reader last tapped or at the first block in view. While the keyboard is up, a docked formatting toolbar (bold, italic, heading, list, link, insert, hide keyboard) replaces the tabs and the selection bubble. Empty and newly created documents open ready to write, and a new document's title hands focus to the body as its rename starts, so keys typed meanwhile are kept. The mode lasts across tab changes and reconnects and resets per document. Desktop and viewers are unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/keyboard-open.ts | 19 ++ apps/web/src/phone-header.tsx | 8 +- apps/web/src/phone-workspace.css | 62 +++++- apps/web/src/phone-workspace.tsx | 11 +- apps/web/src/room-workspace.tsx | 59 ++++- apps/web/src/workspace.tsx | 4 + e2e/phone-read-mode.e2e.ts | 206 ++++++++++++++++++ e2e/responsive-workspace.e2e.ts | 6 +- e2e/room.ts | 16 +- e2e/sidecar.e2e.ts | 7 +- e2e/tab-authoring.e2e.ts | 3 +- e2e/table.e2e.ts | 3 +- e2e/toolbar.e2e.ts | 18 +- packages/editor/src/edit-mode.test.ts | 46 ++++ packages/editor/src/edit-mode.ts | 42 ++++ packages/editor/src/index.ts | 2 + packages/editor/src/plan-editor.tsx | 141 +++++++++++- packages/editor/src/styles.css | 47 ++++ packages/editor/src/toolbar/bubble.tsx | 97 +++++---- packages/editor/src/toolbar/dock.tsx | 150 +++++++++++++ packages/editor/src/toolbar/index.tsx | 30 ++- packages/editor/src/toolbar/link.tsx | 13 +- packages/editor/src/toolbar/slash.tsx | 30 ++- packages/editor/src/widget-options.ts | 7 + packages/editor/src/widgets/render-blocks.tsx | 11 +- 25 files changed, 968 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/keyboard-open.ts create mode 100644 e2e/phone-read-mode.e2e.ts create mode 100644 packages/editor/src/edit-mode.test.ts create mode 100644 packages/editor/src/edit-mode.ts create mode 100644 packages/editor/src/toolbar/dock.tsx diff --git a/apps/web/src/keyboard-open.ts b/apps/web/src/keyboard-open.ts new file mode 100644 index 000000000..1d6d4b1d7 --- /dev/null +++ b/apps/web/src/keyboard-open.ts @@ -0,0 +1,19 @@ +import { useSyncExternalStore } from "react"; + +/** Follows `:root[data-keyboard-open]`, which the visual-viewport adapter keeps current. */ +export function useKeyboardOpen(): boolean { + return useSyncExternalStore(subscribe, current, () => false); +} + +function subscribe(notify: () => void): () => void { + let observer = new MutationObserver(notify); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-keyboard-open"], + }); + return () => observer.disconnect(); +} + +function current(): boolean { + return document.documentElement.hasAttribute("data-keyboard-open"); +} diff --git a/apps/web/src/phone-header.tsx b/apps/web/src/phone-header.tsx index b0fd3baa3..b887c16ce 100644 --- a/apps/web/src/phone-header.tsx +++ b/apps/web/src/phone-header.tsx @@ -56,13 +56,18 @@ export function PhoneHeader( return () => cancel(handle); }, [hostsTrigger]); - let finishEdit = () => { + // Also runs as a save starts, not after it: keys typed while the rename travels would + // otherwise land in a field that is about to close. + let handOff = () => { // Naming a new document leads into writing it, unless the user already tapped elsewhere. if (editing === "new" && header.current?.contains(document.activeElement)) { header.current.closest(".workspace-root") ?.querySelector(".plan-content[contenteditable='true']") ?.focus(); } + }; + let finishEdit = () => { + handOff(); onEditingChange(); }; @@ -90,6 +95,7 @@ export function PhoneHeader( channel={{ id: room, title: label }} inline onCancel={finishEdit} + onSavingChange={saving => saving && handOff()} replay={editing === "new"} onRenamed={detail => { onRenamed(detail.channel); diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index a12520b94..dfb73031c 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -227,6 +227,7 @@ } .phone-tab-bar, +.phone-tab-toolbar, .phone-tab-action { background: var(--color-glass); -webkit-backdrop-filter: var(--glass-filter); @@ -246,6 +247,32 @@ border-radius: var(--radius-full); } +.phone-tab-bar[hidden] { + display: none; +} + +/* Editing on a phone: formatting takes the tabs' place and rides above the keyboard. */ +:root[data-keyboard-open] .phone-tab-row[data-phone-tab-toolbar] { + visibility: visible; + transform: none; +} + +.phone-tab-toolbar { + flex: 1; + min-width: 0; + height: var(--phone-tab-bar-height); + border-radius: var(--radius-full); + overflow: hidden; + animation: phone-tab-toolbar-in var(--tab-slide-dur) var(--tab-slide-ease); +} + +@keyframes phone-tab-toolbar-in { + from { + opacity: 0; + transform: translateY(calc(var(--spacing) * 3)) scale(0.98); + } +} + .phone-tab-indicator { position: absolute; inset-block: var(--spacing); @@ -297,7 +324,8 @@ } } -.phone-tab [data-nucleo-icon] { +.phone-tab [data-nucleo-icon], +.phone-tab-action [data-nucleo-icon] { color: inherit; } @@ -386,6 +414,31 @@ } } +/* The edit toggle turns into a filled Done button while editing. */ +.phone-tab-action[data-editing] { + background: var(--color-brand); + color: var(--color-page); + outline-color: var(--color-brand); +} + +.phone-tab-action > svg { + grid-area: 1 / 1; + transition: + opacity var(--duration-fast) var(--ease-out), + transform var(--tab-slide-dur) var(--tab-slide-ease); +} + +.phone-tab-action > [data-edit-icon="done"], +.phone-tab-action[data-editing] > [data-edit-icon="edit"] { + opacity: 0; + transform: scale(0.4) rotate(45deg); +} + +.phone-tab-action[data-editing] > [data-edit-icon="done"] { + opacity: 1; + transform: none; +} + /* Destinations ---------------------------------------------------------- */ /* Sibling tabs glide in from the side their tab lies on and push the old one out. */ @@ -431,7 +484,12 @@ .workspace-root[data-workspace-phone] > .phone-top-bar, .workspace-root[data-workspace-phone] .workspace-stack-bar, .phone-tab-indicator, - .phone-tab-row { + .phone-tab-row, + .phone-tab-action > svg { transition: none; } + + .phone-tab-toolbar { + animation: none; + } } diff --git a/apps/web/src/phone-workspace.tsx b/apps/web/src/phone-workspace.tsx index 187e7f583..82d6ab231 100644 --- a/apps/web/src/phone-workspace.tsx +++ b/apps/web/src/phone-workspace.tsx @@ -32,6 +32,7 @@ export function PhoneTabBar( documentActivity, labels, onSelect, + toolbar, unanswered, }: { /** The circular control at the bar's trailing end, such as an edit toggle. */ @@ -44,13 +45,21 @@ export function PhoneTabBar( documentActivity?: DocumentActivity; labels: Record; onSelect: (destination: WorkspaceDestination, current: boolean, source: HTMLElement) => void; + /** Takes the tabs' place while set, such as formatting while editing; stays above the keyboard. */ + toolbar?: ReactNode; unanswered: number; }, ) { let index = destinations.indexOf(active); return ( -
+
+ {toolbar &&
{toolbar}
}