Skip to content

Dock phone formatting to the keyboard as one contextual bar - #498

Merged
MaggieAppleton merged 4 commits into
mainfrom
mobile/editing-toolbar-v2
Oct 12, 2026
Merged

MaggieAppleton merged 4 commits into
mainfrom
mobile/editing-toolbar-v2

Conversation

@MaggieAppleton

@MaggieAppleton MaggieAppleton commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #477 and #484 from the Mobbin comparison (gaps 1–3): the phone editing toolbar now behaves like a native keyboard bar.

Verification against current main

I checked each gap on current main (9459feb) with iPhone 15 Pro (390×844) and iPhone SE (375×667), using the fake keyboard from e2e/visual-viewport.ts. All three were still present.

  1. The bar floats instead of attaching to the keyboard. Still present. The dock is a rounded glass pill with a floating Done circle beside it, sitting a gap above the keyboard, and prose shows through and around it.
  2. The contents are thin and not contextual. Still present, partly. Open documents for reading on phones, with an edit toggle #477 and Polish phones: child reading mode, a 16px field floor on touch and shared sheet tokens #484 added ListIcon, Hide keyboard and Comment on a selection, but the row is still B · I · H · List · Link · (Comment) · + regardless of selection. There is no Undo or Redo. On both phones the row overflows, so Insert (and Comment when shown) sits off-screen behind the fade.
  3. Insert opens a small popover. Still present. Insert types / and opens the slash listbox above the dock: a 5-row popover at the top left, far from the thumb. Dismissing it leaves the / paragraph in the document (visible in the BEFORE shots).

What changed

  • One flush bar. While editing with the keyboard up, the tab row turns into a full-width bar on top of the keyboard: edge to edge, with a hairline top border, an opaque page background and no shadow. Done becomes a compact filled check at the bar's trailing end. Scroll padding now reserves the bar's height (--phone-dock-height, 48px) so the caret stays above it. The commands sit at the start with an even 2px gap between 44px targets; Hide keyboard and Done stay at the end at any width, including landscape.
  • Contextual contents (packages/editor/src/toolbar/dock.tsx)
    • With a caret: Insert (+), Text style (Aa), Undo, Redo, and Edit link when the caret is inside a link.
    • With a selection: Comment, Bold, Italic, Strikethrough, Inline code, Link. Comment leads so it never scrolls out of reach.
    • Hide keyboard stays pinned in both states.
    • Every target is 44px and none of them takes focus, so the keyboard stays up.
    • Undo and Redo dispatch UNDO_COMMAND/REDO_COMMAND. They are disabled when the Yjs history stack is empty, using a new listenToHistory() in history.ts. A listener added before the history registers reports both disabled and picks the history up when it arrives.
  • Insert and Text style panels (dock-panel.tsx) stand in for the keyboard at its measured height. The fallback height is the --phone-format-panel-height token. Each panel is a 2-column grid of 48px tiles.
    • Insert lists the slash menu's blocks (Task list is left to Text style), plus Research where the document offers it.
    • Text style lists Text, H1–H3, Quote, Bulleted, Numbered and Task list, and marks the current style.
  • New icons: UndoIcon and RedoIcon (undo.tsx) and TextStyleIcon (text-style.tsx), all on the 18-grid in line style.
  • Desktop is unchanged. The dock only exists on phones, and the desktop selection bubble e2e still passes.

Why the panel works this way

A web page cannot replace the iOS keyboard. Instead, tapping Insert or Aa moves focus from the editor to the panel, inside the tap. iOS lowers the keyboard and the panel appears at the height the keyboard covered, measured from visualViewport just before.

While the keyboard moves, the panel is only as tall as the space the keyboard has uncovered, measured on every viewport change (uncovered(panel, cover)). The bar therefore never leaves its resting position, whether the browser reports the keyboard once (iOS) or every frame (Android Chrome). On the way back the panel gives up its space only once the keyboard is fully up, or after 1s.

The workspace keeps the dock up while the panel is open (onFormatPanel → docked).

Choosing a tile works like this:

  1. The editor root is focused synchronously inside the tap, so iOS raises the keyboard again.
  2. The saved Lexical selection is restored.
  3. The command runs.

The panel keeps its space until the keyboard is back (or 800ms) so the bar doesn't drop and jump. Tapping the same button again or pressing Escape returns to the keyboard unchanged. A tap in the prose takes the same return path, so there is no tab-bar flash while the keyboard comes back. Tapping elsewhere or Hide keyboard closes it.

A table's action sheet now places itself above the bar (aboveDock), so Undo stays reachable straight after inserting a table.

I chose this over a sheet floating above the dock with the keyboard still up because, on an SE, that would leave about 200px for both the sheet and the caret line.

Screenshots

BEFORE: iPhone 15 Pro. Caret, selection, Insert.
before 15 Pro

AFTER: iPhone 15 Pro. Caret, selection, Insert just tapped (keyboard still up: the bar stays put), Insert panel (keyboard lowered), Text style panel.
after 15 Pro

BEFORE: iPhone SE (375×667)
before SE

AFTER: iPhone SE (375×667)
after SE

Landscape, before (top) and after (bottom)
landscape

The grey block is the emulated keyboard area.

Testing

  • bun run e2e e2e/phone-read-mode.e2e.ts e2e/responsive-child-phone.e2e.ts e2e/toolbar.e2e.ts e2e/comment-sheet.e2e.ts: all passed after the review fixes. I ran these locally with a config that skipped the harness server, because another process held port 8797.
    • New phone-read-mode coverage:
      • The bar is flush and full width, with Done at its trailing end.
      • The caret and selection states show the right controls.
      • Undo and Redo work and keep the editor focused.
      • Right after tapping Insert, before the keyboard resize, the bar has not moved.
      • With keyboard resizes replayed frame by frame on open and on close, the bar's top never moves more than 1px.
      • Insert stands in for the keyboard at its height, keeps the caret's paragraph in view, and inserting a table returns focus; the table's actions sit above the bar.
      • A tap in the prose with a panel open keeps the bar up (no tab bar) until the keyboard returns.
      • Text style marks the current style, Escape returns, and Heading 2 applies.
      • Hide keyboard closes the panel.
    • The existing dock tests now select text before tapping Bold, because Bold no longer shows with a bare caret.
  • Also ran responsive-touch-fields, editing and discarded-mobile-geometry. The only failure was editing.e2e.ts › Tab on a first list item, which presses End and fails locally on macOS. It's unrelated to this change; CI runs on Linux.
  • bun test in packages/editor passes, including the new dock-panel.test.ts and a listenToHistory test for late registration; so does packages/icons. bun run types and oxlint are clean.
  • bun run ci fails locally only on an ignored local design-jig file that is not in this branch, plus the design-contract items below.

Needs a design-contract review

bun scripts/check-design-contract.ts reports these. I did not edit any hashes.

File Exception file Finding New SHA-256
packages/icons/src/undo.tsx (new) scripts/design-contract/exceptions/dynamic-packages.json dynamic * props ×2 (UndoIcon, RedoIcon > <LineIcon>) d3011674899a2058c523a11a3846c8cb3dce65be5cf972c9cd75a2ef95ae088b
packages/icons/src/text-style.tsx (new) same dynamic * props ×1 (TextStyleIcon > <LineIcon>) 399f3d192bc61cae7904baf1d5d814ce711651efec54e691f73e85e72fe4cb5c
packages/editor/src/plan-editor.tsx dynamic owner manifest reviewed dynamic owner changed ec32a97839334779a7f7303aafd7248a5c15b5d4ce0d8f0070c14c08dfce7e8d

The icon files forward IconProps exactly as line.tsx and list.tsx already do. plan-editor.tsx only threads an onFormatPanel callback into the widget options; there is no new style or class logic.

Needs a real device

  • That iOS lowers the keyboard when focus moves to the panel, and raises it again when the editor root is focused inside the tile tap.
  • The measured keyboard height with the QuickType bar.
  • That the bar sits flush with no gap during the keyboard animation.

🤖 Generated with Claude Code

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Changes needed

I built this PR and origin/main (9459feb) side by side and drove both headless with the fake keyboard from e2e/visual-viewport.ts (iPhone 15 Pro, iPhone SE, 15 Pro landscape, desktop 1440×900). I also had two writers editing at once to check undo. Most of the PR holds up:

  • The bar sits on the keyboard with no gap (main leaves a 10px gap) and runs edge to edge.
  • Every target is 44px.
  • The contents switch the moment a selection starts or collapses, and no button takes focus.
  • Undo and redo are correct under collaboration. UNDO_COMMAND goes to registerPlanHistory's Y.UndoManager (trackedOrigins = binding). A remote writer's edit leaves this writer's Undo disabled. Undo removes only this writer's words and leaves the other writer's alone. The disabled states follow the stacks.
  • Insert → Table goes after the caret's paragraph, and no stray / paragraph is left behind. Escape and a second tap on the same button both return to the keyboard. Dismissing the panel without choosing leaves the document untouched.
  • Research shows in Insert where the host offers it.
  • Reduced motion turns off the bar animation. Desktop gets no dock.

One blocker:

Must fix

  1. The bar jumps to the top of the screen when Insert or Aa opens (packages/editor/src/toolbar/dock.tsx:192). flushSync(() => setPanel({ kind, block })) puts the 336px panel into the bar's grid while the keyboard is still up. --app-height still ends at the keyboard, so the bar and panel grow upward from the keyboard's top edge until the viewport reports that the keyboard has gone. Measured: the row's top goes from y=274 to y=−62 on the 15 Pro and y=−153 on the SE. The bar and panel cover the whole document and the top bar, then drop back once the resize arrives. On iOS the keyboard takes about 250ms to go down, and the visualViewport resize can come late, so people will see the panel fly up and fall back on every open.
    • Fix: do what returning already does on the way back, but on the way in. Keep the panel out of the layout (or height: 0 / visibility: hidden) while keyboardCover(...) still reports a keyboard. Give it its height on the first viewport change that shows the keyboard down, with a timeout fallback.
    • Test: extend the Insert e2e to assert the row's y straight after the tap, before setVisualViewport(... 844). The current test only measures after the resize, so it can't catch this.

Nice to have

  1. Comment is hidden on the SE. With a selection, the SE row holds B · I · S · Code · Link · Comment in 228px. Comment sits at x=220–264, so about 8px of it shows, under the end fade. Comment is Chopin's main action on a selection, and Notion puts it in the first group. Move Comment to the front of the selection row, or drop Inline code from the row on narrow widths.
  2. The spacing is uneven, and landscape strews the buttons (packages/editor/src/styles.css:5567). space-between inside a scroller capped at --phone-tab-row-max puts about 42px between +, Aa, Undo and Redo, then 0px between Redo and Hide keyboard. In landscape the four commands sit about 100px apart across 480px, which isn't "grouped at the start". A fixed gap with justify-content: flex-start would match Bear, Craft and Notion and give an even rhythm.
  3. Tapping the page while a panel is open flickers (dock.tsx:359–364). onBlur drops the panel straight away. If the tap lands in the prose, focus returns to the editor and iOS raises the keyboard. In between, docked goes false, so the sequence is panel → floating tab bar (row y=589) → bar on the keyboard. When focus moves into this editor's root, treat it as returning so the space holds until the keyboard is back.
  4. The history fallback never upgrades (packages/editor/src/history.ts:311). If the dock mounts before registerPlanHistory has run for that editor, it reports {undo: true, redo: true} and never subscribes again, so both buttons stay enabled for the session. It can't happen today, because the dock mounts only after the editor has focus, but it's a quiet trap. Either subscribe lazily (keep a pending listener set per editor that registerPlanHistory drains), or report both as disabled.
  5. A table's action sheet covers the dock. After Insert → Table, the caret lands in a cell. The table's Add/Remove/Move/Align sheet then sits over the bar, and elementFromPoint at the centre of Undo hits "Add column before". So straight after inserting a table, Undo can't be tapped. This probably predates the PR, but the flush bar makes it more visible.
  6. Task list appears in both Insert and Text style. That's harmless, but Bear and Craft keep list types only under style.

Checked and fine: the toolbar role and name, aria-pressed on marks and on the current style, aria-expanded/aria-controls on Insert and Aa, a group role and name on the panel, the bar animation off under reduced motion, and dock-panel.test.ts plus the history tests (29 pass).

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Looks good

I re-checked 2e3c82a in a fresh worktree with the fake keyboard (port 8847). During every open and close of Insert and Aa I sampled the bar's position on every animation frame: on iPhone 15 Pro, iPhone SE and 15 Pro landscape, through choose → return, prose tap → return, and Escape.

The jump is gone on iOS. I first replayed the keyboard the way iOS Safari reports it: about 250ms with no viewport events, then one resize. In that case the bar never leaves its resting position on any phone or path (15 Pro y=275, SE 260, landscape 95), across 28–53 frames per path. The panel lands exactly where the keyboard was (15 Pro 323/336, SE 308/260, landscape 143/200). After a choice the editor has focus again and the panel is gone.

The other six points are fixed too:

  • Comment now leads the selection row and is fully visible on the SE. Link is now the one under the end fade there, which is fine.
  • Spacing: the rhythm is an even 46px from the start. Hide keyboard and Done sit at the end in both portrait and landscape.
  • Tapping prose while a panel is open now uses the return path: the bar holds its position on every frame, with no flash of the tab bar.
  • History: Undo and Redo start disabled, and a listener registered early hears when the history arrives.
  • Table sheet: after Insert → Table, the sheet sits above the dock. The centre of Undo hits Undo, and one tap removes the table.
  • Task list is only in Text style.

CI: format, lint, types, tests fails only on the design-contract findings that the PR body lists (undo.tsx, text-style.tsx, plan-editor.tsx), which is acceptable. Because that check stops the job, I ran the rest locally: bun run types is clean and the editor tests pass (768). Container passes. e2e was still running when I posted this.

Not blocking

  • Android-style keyboards still overshoot briefly (dock.tsx, the opening/returning settle). I also replayed the keyboard the way Android Chrome reports it, one resize per frame. On the way down, the bar rides the keyboard (good). But the panel switches to open as soon as the keyboard covers less than KEYBOARD_MIN (120px), while it still covers about 112px. The bar then sits above its rest for about 4 frames (15 Pro: 163 vs 275; landscape: −22 vs 95) before settling. The return path does the mirror image. One fix: while opening, size the panel to the part the keyboard has uncovered, panelHeight − keyboardCover(...), so the bar stays put while the keyboard slides away. Alternatively, settle only when nothing is covered at all. That's worth doing before Android matters; on iOS it doesn't show.
  • Real-device checks are still needed, as the PR body says: focus moving to the panel lowers the keyboard, the tile tap raises it again, and the panel's height with the QuickType bar.

@MaggieAppleton
MaggieAppleton force-pushed the mobile/editing-toolbar-v2 branch from 495d2bc to f78d49c Compare October 12, 2026 09:07
@MaggieAppleton
MaggieAppleton enabled auto-merge (rebase) October 12, 2026 09:07
@MaggieAppleton
MaggieAppleton force-pushed the mobile/editing-toolbar-v2 branch from f78d49c to 28b59d0 Compare October 12, 2026 09:26
MaggieAppleton and others added 4 commits October 12, 2026 10:40
On a phone the formatting dock was a floating pill beside a floating Done
button, with its last commands scrolled out of sight and Insert opening a
small popover far from the thumb. It now runs edge to edge across the top of
the keyboard, with Done at its trailing end, and follows what the writer is
doing: a caret gets Insert, Text style, Undo and Redo; a selection gets Bold,
Italic, Strikethrough, Inline code, Link and Comment. Hide keyboard stays
pinned.

Insert and Text style let the keyboard go on purpose and show their choices
at its height, then give the caret back where it was, so the keyboard
returns. Undo and Redo follow the shared history's stacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening Insert or Text style put the panel into the bar while the keyboard
was still up, so the bar grew up the screen and fell back once the keyboard
went down. The panel now takes no space until the viewport reports the
keyboard gone, mirroring the return path, and a tap in the prose holds the
space until the keyboard is back instead of flashing the tab bar.

Review follow-ups: Comment leads the selection row so the narrowest phone
keeps it in reach; the commands keep an even gap from the start; undo and
redo start disabled and follow a history that registers later; a table's
actions sit above the bar; and Task list is offered only as a text style.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Browsers that report the keyboard's travel every frame, Android Chrome
among them, showed the whole panel while the keyboard still covered part
of it, so the bar overshot for a few frames on open and close. The panel
now takes only the space the keyboard has uncovered, measured each frame,
and gives its space back only once the keyboard is fully up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- packages/icons/src/undo.tsx, packages/icons/src/text-style.tsx: new icons
  forward IconProps to LineIcon exactly like list.tsx; static SVG paths only,
  so they get the same reviewed props-forwarding exception.
- packages/editor/src/plan-editor.tsx: only adds an optional onFormatPanel
  callback prop and passes it through to the toolbar options and memo deps;
  the reviewed className composition is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaggieAppleton
MaggieAppleton force-pushed the mobile/editing-toolbar-v2 branch from 28b59d0 to 16fb436 Compare October 12, 2026 09:40
@MaggieAppleton
MaggieAppleton merged commit a855bf3 into main Oct 12, 2026
3 checks passed
@MaggieAppleton
MaggieAppleton deleted the mobile/editing-toolbar-v2 branch October 12, 2026 10:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant