cmux NEXT: the New Tab page keeps up with a resize, and animations share one short scale

Merged
TE
15d ago
+1442-196

When the window or a pane resizes, the New Tab page now lays itself out in the same frame with CSS alone, and doesn't jump. Every animation duration in the app and its webviews now comes from one short scale in one token file, and nothing runs past 240 ms.

Before / after

Recorded on a fleet mini (debug.window_record, 1400x800, 60 fps, clean app state, New Tab "Threads" template). Each take has a 6 s live resize (1400x800 → 760x420 → back, 1.5 s each way), then a split, a pane close and a popup.

No capture: the native change is animation timing (fade durations and springs), which light and dark stills can't show. The two videos above are the capture: real fleet recordings of the app at base and head.

Live resizeBeforeAfter
Composer jump128→72 px in one frame shrinking past ~500 px tall, 72→129 px growing backnone; largest step 5 px
Frames recorded during the resize114 (31 fps)153 (42 fps)
Holds ≥ 50 ms (ffmpeg freezedetect)31, longest 0.37 s3, longest 0.35 s
Frames where the page trails its pane by > 4 px66%44%

The jump showed in every take. The fps and hold numbers are rougher: the mini was busier during the before take, and other before takes ran 31–50 fps. The default Cards template has its own 8vh padding and never jumped. Threads and Console use the page's base padding, which did.

Animation (timed from the moving pane edge)BeforeAfter
Split (Cmd-D), settled333 ms (half travel at 99 ms)283 ms (83 ms)
Pane close, settled400 ms (half travel at 117 ms)300 ms (83 ms)

New Tab resize

  • The field sizes itself with CSS. It uses field-sizing: content. Before, a JS fit wrote height: 0, read scrollHeight and wrote it back from a ResizeObserver, forcing two sync layouts on every resize tick. That fit now runs only in an engine without field-sizing. In WebKit, a draft taller than the field's cap stays scrolled where it was, so a draft change with the caret at the end scrolls the caret into view. A resize runs no script.
  • The top padding is one continuous curve in the pane's height. Before, @media (max-height:480px) dropped it from 67 px to 12 px, so the composer jumped 55 px when a resize crossed 480 px tall.
  • listMotion no longer reads rows during a resize. It drives chat cards, open tabs and recently closed. Before, its ResizeObserver read every row's rect and transform on every frame of a resize. Now it marks the places stale and reads them once, after the size has held still for 120 ms. A list change that lands mid-resize takes its places at once. A row arriving resizes the list itself; the observer recognises that size as the one it just measured, so the next change right after still animates.

Motion scale

schemas/motion-scale/motion-scale.json is the one token file. scripts/cmux-next/gen-motion-scale.py writes the Swift MotionScale, the CSS --cmux-motion-* block in desktop.css and the TypeScript MOTION_MS from it. CI ("Motion scale") and the webviews check run the generator with --check.

StepmsFor
quick80hover and pressed fills, focus rings, anything leaving
micro120small things arriving: a fade in, a popup opening, a chip, a tooltip
standard180structural changes: a row or card moving, a fold opening, a scrim
large240the largest moves, and the ceiling: split, zoom, window close

Anything entering eases out (cubic-bezier(0, 0, 0.58, 1)). Each off-scale duration moved to the nearest step that isn't longer. Every spring now ends visibly, within 0.5% of its travel, by 240 ms. None of these blocks input: they are all transforms and fades on views that already take events.

SpringResponse before → afterVisibly done before → after
split0.28 → 0.23287 → 235 ms
zoom0.30 → 0.2354 → 235 ms
window exit0.24 → 0.2283 → 235 ms
popover open / close0.25 / 0.16 → 0.24 / 0.15248 / 188 → 238 / 177 ms
hover card (panel)0.18 → 0.165194 → 177 ms
tab strip scroll, screen switch0.22 → 0.2190 → 173 ms
sidebar pin drag0.351 → 0.215→ 238 ms (about 1.6 pt overshoot on 200 pt)
Fade (Swift MotionTunables.fades, webview MOTION_FADES)BeforeAfter
pane focus ring, crossfade / Reduce Motion cap100 ms80 ms
pane dim150 ms120 ms
theme switch160 ms120 ms
New Tab move / leave fallbacks (--agent-motion-move, --agent-motion-out)190 / 160 ms180 / 80 ms

About 35 webview files moved their literals onto MOTION_MS or --cmux-motion-*, along with the Swift fades and springs. Two scanners fail on any new off-scale duration: webviews/test/motion-scale.test.ts and MotionScaleTests for Swift. Loops (spinners, breathing), hover-intent delays and hold timers like FOLD_SETTLE_MS aren't transitions. They are exempt by name or a motion-allow: comment.

Tests

  • webviews/test/new-tab-live-resize.test.ts (Chromium) sweeps the New Tab page from 1100x760 to 480x400 and back, one size per frame. It fails on a composer jump, an inline field height, a frame over budget or a ResizeObserver call over budget.
  • webviews/test/new-tab-list-motion-render.test.tsx: a live resize reads no list row on its frames, then reads once when the size settles. The list's own size change from a row arriving leaves the next change animated.
  • MotionScaleTests checks that every spring ends by the ceiling and every fade is a step. PaneCloseMotionTests and SplitSpringMotionTests now use the 0.23 split spring.

Regression (red on base, green on head):

cd webviews && bun test test/new-tab-list-motion-render.test.tsx

At 7b6aebb4b1f (the test alone, before the fix): a live resize reads no row on its frames fails, reading every row on every frame ([1,1,1] expected, [32,32,32] received). It passes at the head.

Observed / Not run

  • Observed: the macOS app compiles (cmux-ci compile-check --app) at the head, 579be71e66c. The Swift suites MotionScale, SubtlePaneDim, MotionCategoryPolicy, PopupGrowMotion, BlinkRateCap, SplitSpringMotion, PaneCloseMotion, PaneZoomMotion, AgentCursorGlassDart and AgentCursorIdleHide pass on the fleet at 0110ef5. new-tab-live-resize passes on the fleet. In the base live-resize run, the field had an inline height in 8/8 cases and the card jumped 55.8 px at 480 px; Chromium p95 frame time was 16.7 ms.
  • Observed: the full webview suite on the fleet at 213b8d4c5ac (scripts/ci/run-webviews-tests.sh): 638 files pass, 19 fail. 17 of the 19 fail the same way on the base tree, run the same way. gallery-coverage is red on base CI. coderouter-row-heights is a Chromium test, and that fleet host can't launch Chromium (the base-tree run failed the other Chromium files the same way). Three files that fail on the base tree pass at the head, including new-tab-list-motion-render.
  • Not run: the command palette open/close, which debug.window_record leaves out of its frames on this build. The recording uses the notifications popup, which runs on the same popover springs. Its whole-frame timing (217 → 200 ms close) is too coarse to show the change.
  • Reds also red on base: generated files (actions.md and action-surfaces.json are stale from base's new actions; this diff touches no action source) and the crash-safety rule super.viewDidHide in BrowserToolbarButtonsView.swift. The webview files agent-pane-ctrl-b-background, agent-pane-peer-chat, agent-pane-queue-steer, agent-pane-working-counts, composer-history, composer-location-strip, composer-mention, composer-suggested-prompt, composer-type-scale, quiet-access-chip, gallery-coverage (SubagentPaneFooter) and diff-toolbar-narrow (flaky) fail the same way on base runs 39562296032 and 39562160925.
  • Also red on base at e759ff8c75c: the perf counter gate (the split/close pair's 3 workspace applies and topology sends against a budget of 2, pane_snapshots on relaunch, and the source guard in files this diff doesn't touch; the same 7 failures on unrelated dispatch runs 39583820666, 39583574839 and 39583117238) and agent-pane-scrollbar-full-height's grow-and-shrink tests (the same 20 px on base push run 39587124745). pane-english and home-quote-selection / home-composer-menu failed on base commits that later base commits fixed (#23301, #23298). agent-pane-math's first lazy-KaTeX case failed once, and it passed on this PR's earlier heads.
  • Also red on base d5cbf1f0574 at the head: check:browser-lanes flags base's own gallery-agent-pane-entries.test.ts (later base commits move it to galleryFrame), the keyboard shortcuts docs freshness test (this diff touches no shortcut or catalog), and harness-health-notice#dismissed's 0.0019 layout shift. The notice moved Not Now and its words when its phase changed; its restart-after-turn variant fails 3/3 at d5cbf1f0574 with no motion involved, and #23335 fixed it on base.
  • Also red on base 0b1a93e9c08: defaults-snapshot's menu-row check flags an inline row in FileExplorerSectionView.swift from base's 56d448575ce5; this diff doesn't touch that file.

Follow-ups

  • The color-change setting's own duration is still 200 ms.
  • The focus-move flash (focusFlash, from base) stays at its tested 0.5 s, listed as an ambient fade like the Settings highlight: it is a signal that fades out, and nothing waits on it.
  • The duration tables in plans/cmux-next/motion.md and the split spring's DESIGN.md still show the old values. The scale file is the source.
  • The glideMax debug slider's range still goes past the ceiling.
  • RevealedMarkdown.tsx's comment says --agent-motion-in is 180 ms "at the default speed"; the default speed is fast, which gives 120 ms.
  • The parity dev page falls back to the old literals when the scale CSS is missing.
  • diff-toolbar-narrow is flaky on base.
  • Base's new popup presence (popupSurface.css) scales the enter and leave durations by 1.25 (150 / 100 ms), off the scale; the scanner doesn't read calc().
  • thread-minimap-feel.test.ts's comment still says "46 px of 137 at 200 ms"; with the 180 ms shift it is 51 px.

Changelog

  • Changed: The New Tab page keeps up with a live window or pane resize without jumping, and animations across the app are shorter and consistent.

Agent session: cosmic-gray

🤖 Generated with Claude Code