Skip to content

Mobile editing: app-shell layout with reliable caret visibility (WIP)#352

Draft
johannesmutter wants to merge 5 commits into
mainfrom
mobile-editing-shell
Draft

Mobile editing: app-shell layout with reliable caret visibility (WIP)#352
johannesmutter wants to merge 5 commits into
mainfrom
mobile-editing-shell

Conversation

@johannesmutter

@johannesmutter johannesmutter commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Draft — up for review and experimentation (see #349 discussion). Based on #350: its commit is included here and drops out on rebase once #350 lands.

The product bet

On phones, formatting tools (bold, undo/redo, save) must be reachable with the thumb — so the toolbar docks at the bottom, above the software keyboard. That is the expensive choice on iOS Safari, and this PR is the price, paid deliberately and paid once: the reference editors avoid the problem rather than solve it (wordgard uses a sticky top menubar that a keyboard can never occlude; tldraw hides its toolbar entirely while editing on mobile; ProseMirror/CodeMirror ship no keyboard-docked chrome at all). Svedit keeps the bottom toolbar and makes it work.

What this does

While editable is on, the demo becomes an app shell; read mode stays a normal scrolling web page (browser chrome minimizes, content shows behind the translucent bars — important for what visitors of a Svedit-built site experience).

  • The mode is server-rendered markup (data-editing on the shell, consumed via :has()), so shell layout applies at first paint — no hydration flash. The only mode JS is the scroll handoff between document and shell scroller ($effect.pre capture, post-effect apply) and a 3-line window pin.
  • The document never scrolls in edit mode; the canvas scrolls in a nested full-height container, so content stays visible behind the translucent keyboard. The toolbar is a grid layer pinned above the keyboard via the Svedit-published --svedit-keyboard-inset — position is layout, not JS tracking, so it is pixel-stable at any scroll speed. Its height is measured (ResizeObserver--toolbar-height) and reserved via scroll-padding-bottom, so it can grow to multiple rows.
  • Svedit owns all keyboard geometry with a single visualViewport listener: it learns the keyboard size, publishes the inset for pure-CSS consumers, and updates it predictively at text-selection commit and blur so the toolbar glides with the keyboard (iOS-spring-like transition) instead of hanging on the OS's late geometry reports.
  • Tapping text scrolls the caret to the center of the predicted visible band, synchronously at the selectionchange that commits the caret — one owned scroll; visible carets never move. An 800ms scroll guard re-asserts the position against iOS's mid-presentation stomps (observed up to ~2900px) and yields instantly to user input.
  • .svedit is the containing block (plus isolation) for anchor-positioned markers/overlays, so they scroll on the compositor with the content instead of lagging a frame behind in nested scrollers.
  • inputmode defaults to text when no selection exists, so taps during hydration still summon the keyboard.

All iOS-specific behavior is gated on an is_ios check (like every editor library does for these quirks); Android and desktop never see the workarounds. The band math has a single definition (__get_reveal_bounds), shared by the tap preempt and all model-driven reveals, clamped by the live visual viewport and honoring the scroller's declared scroll-padding.

Device-verified platform findings (iOS 26)

These cost real debugging time and are documented in code comments where relevant:

  1. interactive-widget=resizes-content is no longer ignored by iOS — it half-engages, resizing the layout viewport mid-gesture on refocus taps (blurring the canvas, closing the keyboard). Removed; must not come back.
  2. There is no CSS keyboard geometry on iOS through Safari 26.5 (no VirtualKeyboard API, no env(keyboard-inset-height)) — one visualViewport listener is the floor for a keyboard-docked toolbar.
  3. WebKit does not reveal carets into nested scroll containers, and scrolls issued at click time corrupt tap placement (iOS commits the caret after click by re-hit-testing); delayed/debounced scrolls lose to iOS's own reveal or kill the keyboard. The selectionchange commit is the one safe moment to scroll.
  4. iOS's own reveal can run against transient viewport geometry (panned visual viewport, briefly shrunken innerHeight) that snaps back — hence the scroll guard.
  5. Safari chrome minimization is triggered only by root-document scrolling, and Safari composites only root-document overflow behind its floating chrome: viewport-fit=cover does not extend the in-browser layout viewport, and fixed boxes overdrawing it are not painted there. A locked shell can therefore only tint those zones (--app-chrome-zone-fill); true content behind the bars while editing needs standalone/PWA display.
  6. Safari can report empty rects for collapsed ranges — caret geometry goes through a helper that measures the adjacent character when needed.

Open items

  • Typing near the bottom edge (model-driven reveal with the keyboard open) needs a dedicated device pass.
  • Rotation + tap, and pinch-zoom + tap paths changed late — need one device pass (stale learned inset now degrades via clamping instead of an orientation listener).
  • Desktop node-selection reveals still use the window-based visibility check, not the shared band.
  • Android pass pending (is_ios gating should make it inert there; unverified on hardware).
  • Chrome-zone tint (--app-chrome-zone-fill) currently resolves to white — color choice open, as is the standalone/PWA question for true edge-to-edge editing.
  • Embedded hosts (nested scroller inside a scrollable page) are deliberately out of scope for the reveal's residual handling — the preempt disables itself when the document can scroll.

🤖 Generated with Claude Code

johannesmutter and others added 2 commits July 22, 2026 02:01
- App shell while editing: locked document, nested scroller, toolbar as
  grid layer pinned above the keyboard (stable, no JS position tracking)
- Tap caret preempt at the selectionchange commit: lands the caret
  centered in the predicted visible band; a short scroll guard
  re-asserts against iOS reveals computed from transient viewport
  geometry
- Remove interactive-widget=resizes-content: iOS 26 half-engages it and
  resizes the layout viewport mid-gesture; layout-resizing platforms
  are detected at runtime instead and left to the OS
- inputmode defaults to text when no selection exists, so taps during
  hydration still summon the keyboard
- .svedit is the containing block for markers and overlays, so they
  scroll on the compositor with the content instead of lagging a frame
  behind in nested scrollers
- Cursor reveals are scroller-aware and honor scroll-padding; touch
  node selections fade ::selection to a faint neutral where supported

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
svedit Ready Ready Preview, Comment Jul 22, 2026 3:01am

…geometry, embed hardening

- Edit mode is server-rendered markup (data-editing on the shell,
  consumed via :has()) instead of a JS-toggled class, so the shell
  layout applies at first paint — kills the load flashes (toolbar
  bottom-right, full-bleed page visibly cropping once hydration
  landed). The scroll handoff splits into an $effect.pre capture
  before the DOM flips and a post-effect apply.
- Svedit owns keyboard geometry: its single visualViewport listener
  learns the keyboard size (pinch-zoom guarded, rotation resets),
  detects layout-resizing platforms via a persistence latch with the
  resizes-content signature, and publishes --svedit-keyboard-inset
  for pure-CSS consumers. Predictive updates at text-selection commit
  and blur let the toolbar glide with the keyboard (iOS spring-like
  easing) instead of hanging mid-screen until the OS reports late.
- Embed hardening: the tap preempt only engages when the document
  itself is unscrollable (app-shell signature) and never in iframes
  without a learned inset; the band clamps to the container box; the
  scroll-container walk is vertical-axis-aware; the stomp guard
  yields to user input; percentage scroll-padding resolves against
  the scrollport; .svedit isolates marker z-indices; a limit-clamped
  container hands residual reveal delta to the window.
- Edit-mode chrome zones are painted by a fixed backdrop (escapes the
  overflow clip without creating scrollability). Percentage height
  chain replaces 100dvh (Safari 26 dvh mis-sizing). viewport-fit=cover
  tried and reverted: device-verified that it does not extend the
  in-browser layout viewport on iOS 26.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hook

- Remove the fixed-overdraw experiment: device-verified that Safari
  composites only root-document overflow behind its floating chrome —
  fixed boxes beyond the layout viewport (backdrop or overdrawing
  shell) never paint there. A locked document can only tint the zones
  via the page background; real content behind the bars while editing
  requires standalone/PWA display. Findings documented in app.html and
  reset.css where the next attempt would start.
- Edit-mode chrome zones tint via --app-chrome-zone-fill (defaults to
  the app canvas fill).
- Reveal-bounds padding stays anchored to the visible edges (one rule,
  errs toward visibility); scroll-padding reads factored into named
  pad_* values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@michael
michael marked this pull request as ready for review July 22, 2026 10:27
@michael
michael marked this pull request as draft July 22, 2026 10:28
@michael

michael commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Thanks for the exploration!

Tried it out and some things I noticed:

  • feels indeed very nice to have the toolbar positioned stable like this.
  • transition of keyboard-closed to keyboard open is hard. The cursor still in many cases lands in an off-screen area. But generally it's a quite hard transition. In Svedit 0.12.0 (when we were not doing anything special about the toolbar) this transition feels natural (since the toolbar refactor not anymore)
  • noticed "selection flickering" when you scroll up/down fast (this doesn't happen in main)
  • also pinch zoom while in editing feels flaky, sometimes doesn't react. Probably a result of the custom handling of touch events

Here's a screen cap of that selection flickering issue:

ScreenRecording_07-22-2026.13-52-46_1.MP4

Some questions for understanding:

  • If interactive-widget=resizes-content were supported in Safari, we wouldn't need to do anything of this app-shell treatment? it would just naturally work, right?
  • Should I push on this on next W3C meeting?

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.

2 participants