Skip to content

Zoom effects: elevate and animate the zoomed pane#265

Merged
nedtwigg merged 11 commits into
mainfrom
zoom-effects
Jul 15, 2026
Merged

Zoom effects: elevate and animate the zoomed pane#265
nedtwigg merged 11 commits into
mainfrom
zoom-effects

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Makes pane zoom a real presentation layer instead of a geometry special case, and couples it to passthrough focus.

What changed

  • Zoom is an animated, elevated layer. The zoomed leaf tweens into a wall-sized rect inset by half the pane-header height (15px), riding LATH_LAYER_ELEVATED — one of three layer bands (TILED / DYING / ELEVATED) the animator now threads through to the host. This replaces the old zoomedIdRef special case that skipped geometry for one leaf. The exposed perimeter plus a blurred app-background shadow make the stacking relationship visible.
  • Zoom follows passthrough focus. Any focus transition off the zoom owner starts its return to the tiled layout; the zoomed pane's unzoom control is highlighted.
  • New panes get focus after a manual split.
  • Browser hover feedback. The agent-browser stream frame paints provisionally (CSS-resolution, immediate) while the crisp device-resolution host screenshot replaces it, so hover/animation feels live instead of stepping at capture rate.
  • L-shaped desktop tutorial layout, seeded deterministically by the playground route.

Notes for review

The last commit is a /simplify pass over the branch (no behavior change) — dead ZoomedContext removal, one releaseZoomExcept helper in place of four copied guards, a single emitFrame path, and a dedup hash that stops copying ~100KB of base64 per frame.

Specs updated alongside: tiling-engine.md (layer bands, zoom geometry), layout.md (zoom/focus coupling), dor-browser.md (provisional paint), tutorial.md.

Test plan

pnpm test green: spec-lint (23 specs), lib 1063, website 51, standalone 47, plus dor/server.

Known follow-up, deliberately not in scope: during sustained pointer input the crisp screenshot loop starts captures whose results are then dropped as stale against newer provisional paints. Fixing it means gating take() on the provisional window plus an explicit trailing flush — a pacing change that needs its own verification, since a naive gate would strand the surface on the provisional frame when the stream goes quiet.

🤖 Generated with Claude Code

nedtwigg and others added 8 commits July 10, 2026 13:44
Cleanup pass over the zoom-effects branch; no behavior change.

- Delete ZoomedContext. Both pane headers now read ZoomedIdContext, so the
  boolean projection had no readers left — only two providers and a public
  re-export that did nothing for embedders.
- Collapse the four hand-copied zoom-release guards into releaseZoomExcept,
  and route enterTerminalMode through selectPane instead of re-implementing
  its body (which is why the guard had to be copied at all). setZoomed
  already no-ops on an unchanged id, so the !== null reads guarded nothing.
- Make emitFrame the single frame-emission point (withData), with one
  frameMetadata reader for both the large and small paths — they had drifted
  to two narrowing styles for one shape. Mix device dims into the dedup hash
  numerically: the `${data}@WxH` key copied ~100KB of base64 per frame at
  ~20Hz just to hash it. AgentBrowserFramePulse now requires both dims,
  matching what every construction and consumption site already assumed.
- Drop two provisional-frame rechecks the sink/data guards already cover.
- Use the layers map for the pre-tween zIndex so stacking has one source of
  truth with the animator's bands.
- Reuse terminalLeafMeta + LATH_LAYOUT_OPTS in the playground layout; the
  hand-written meta could drift silently (LeafMeta fields are typed string)
  and the copied opts would assert a stale gap.
- tiling-engine.md row 9: zoom insets by 15px, not the 6px layout gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 15, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: fa18c02
Status: ✅  Deploy successful!
Preview URL: https://ff931b72.mouseterm.pages.dev
Branch Preview URL: https://zoom-effects.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One correctness finding on the two-stage browser paint; the zoom-as-layer rework and the focus↔zoom coupling both trace cleanly and the specs are accurate.

Provisional paint can strand the pane on the blurry frame at rest. The screenshot loop skips a decode when a capture's bytes match lastDrawnKey (agent-browser-screenshot-loop.ts display), but lastDrawnKey only ever updates on a crisp draw (line 83). drawProvisionalFramepaintBitmap overwrites the canvas with the blurry CSS-resolution frame without touching lastDrawnKey, so the loop's dedup no longer reflects what's on screen.

Repro (HiDPI, host has agentBrowserScreenshot):

  1. Crisp frame C is drawn — lastDrawnKey = C, canvas is sharp.
  2. Hover a control → page changes → provisional blurry paint; a crisp capture starts.
  3. Continued pointer jitter bumps provisionalPaintGeneration, so that in-flight crisp is dropped as stale (the mechanism you flagged in the follow-up note). lastDrawnKey stays C.
  4. Un-hover → resting pixels equal C again → provisional paints blurry C; the coalesced crisp captures C.
  5. That crisp is not stale, but display computes key(C) === lastDrawnKey and returns early — no draw. The stream is quiet, so the pane stays on the blurry provisional C indefinitely.

This is distinct from the acknowledged pacing follow-up, and would defeat its planned fix: even an explicit trailing crisp flush is swallowed by this byte-dedup when the resting frame matches the last crisp draw. The existing drawGeneration mechanism (bumped on re-attach to force a repaint of a blank canvas over identical bytes) is exactly the right tool here — a provisional paint is the same situation, so bumping it when a provisional lands makes the next crisp always repaint over the blur. Inline suggestion below.

Reviewed and clean
  • ZoomedContextZoomedIdContext rename: all consumers migrated; no surviving import of the removed symbol anywhere in the tree.
  • Animator layer bands (Math.max(from.layer, to.layer) during tween, to.layer when settled, framesClose now comparing layer, markDying using Math.max(cur.layer, LATH_LAYER_DYING)): consistent across rise / hold-during-lower / snap / reduced-motion / dying-while-elevated.
  • Focus↔zoom coupling: releaseZoomExcept correctly threaded through selectPane / selectDoor / enterTerminalMode / exitTerminalMode; re-selecting the zoom owner no-ops.
  • frameMetadata requiring both dims > 0, and the inline PANE_HEADER_STYLE replacing the removed .lath-leaf-header CSS: no consumer regressed.
  • The desktop-playground L-shape seed matches LathPersistedLayout and is validated by the new test; specs (toDevice, PANE_HEADER_HEIGHT_PX, LATH_ZOOM_*, presentationTargets, LATH_LAYER_*) point at symbols that exist.

Minor, non-blocking: the large-frame dedup hashes the full JSON envelope (isDuplicateFrame(raw)) on the idle path but the base64 body + dims (isDuplicateFrame(msg.data, metadata)) on the wantFrameData path, so a byte-identical heartbeat straddling a wantFrameData toggle costs one redundant crisp capture. Benign and self-correcting — there's no clean fix without parsing on the idle path, so probably not worth changing.

Comment thread lib/src/components/wall/agent-browser-surface-controller.ts
A provisional paint writes CSS-resolution pixels straight to the canvas, so
the screenshot loop's byte-dedup (`lastDrawnKey`) stopped describing what was
on screen: `paintBitmap` has two callers but only the crisp one records a key.

On a resting page the follow-up capture is byte-identical to the last crisp
draw, so `display` deduped it to a no-op and the pane stayed on the blurry
frame indefinitely — the stream is quiet at rest, so nothing came along to
correct it. That breaks the sharp-at-rest guarantee in dor-browser.md, and
would also have swallowed the trailing flush in the planned pacing fix.

`drawGeneration` already exists for exactly this ("the canvas changed without
the crisp loop drawing it" — bumped on re-attach so a blank canvas repaints
over identical bytes). A provisional paint is the same situation, so bump it
there too.

Found in review by dormouse-bot on #265.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/code-review over the branch. One correctness bug, plus two regressions
from my own earlier cleanup pass.

- onZoom: only the zoom owner's control toggles zoom off. The 15px inset
  newly exposes other panes' headers (a top-row header's band pokes above
  the zoomed rect, and the h-5 button sits inside it), and
  HeaderActionButton stops mousedown, so no selection runs first to release
  zoom. `currentZoom !== null` therefore made pane B's "Zoom" control unzoom
  pane A and stop — B never zoomed. Unreachable before this branch, when the
  zoomed pane covered the wall full-rect. Now a non-owner's control hands
  zoom over, focus included.

- playground-desktop-layout: drop the `terminalLeafMeta` import I added.
  This module is imported eagerly by the route, while PlaygroundDesktop
  deliberately defers the lib behind dynamic imports; the import put a
  static edge from the 8.4kB route chunk into terminal-registry (376kB /
  98kB gzip, its own chunk) to save three field names — downloaded even on
  a small screen, which never mounts the Wall. The test now pins the literal
  against the real `terminalLeafMeta`, so a rename still fails loudly.

- agent-browser-connection: ask `wantFrameData()` at the two frame paths
  again instead of once per message, so status/tabs stop paying for it.

- Assert `isLathPersistedLayout` on the playground seed: it is honored only
  while `version === 1`, and a bump would silently seed one generated pane
  (no tut-main, no runners) with every test still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A capture (~120ms) is slower than the stream (~20Hz), so every capture
started inside the provisional window is superseded before it resolves and
dropped as stale. During a hover or drag that was ~100% of them: a host
round trip (a child-process spawn) per pulse, bytes discarded. A test over
600ms of simulated hover measures 7 such shots.

Defer instead: while the window is open, leave the work dirty and take one
settled shot at its end. Continued pointer input pushes the window out, so
the timer re-enters `schedule` and re-arms rather than firing early. This
costs no pixels — the deferred shots never drew anything, and the frame that
lands is the first one started after the last provisional paint either way.

Deferring alone would strand the pane, so also: a capture dropped as stale
now leaves the loop dirty. Nothing else necessarily pulses it again — a
single pointer move over a static page pulses once, and that pulse is
consumed by the very capture the provisional supersedes, so `if (dirty)
schedule()` found nothing pending and the pane kept the blurry frame until
the page next changed. That is a second stranding bug, distinct from the
byte-dedup one fixed in 2783749: it strands with no capture attempted at
all, so the drawGeneration bump could not have caught it. Both are covered
by tests verified to fail without their fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nedtwigg
nedtwigg merged commit 34a7c96 into main Jul 15, 2026
9 checks passed
@nedtwigg
nedtwigg deleted the zoom-effects branch July 15, 2026 23:40
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