cmux NEXT: the New Tab page keeps up with a resize, and animations share one short scale
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.
- Before (base 18611e5c15a):
- After (head 2602c0b7654):
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 resize | Before | After |
|---|---|---|
| Composer jump | 128→72 px in one frame shrinking past ~500 px tall, 72→129 px growing back | none; largest step 5 px |
| Frames recorded during the resize | 114 (31 fps) | 153 (42 fps) |
| Holds ≥ 50 ms (ffmpeg freezedetect) | 31, longest 0.37 s | 3, longest 0.35 s |
| Frames where the page trails its pane by > 4 px | 66% | 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) | Before | After |
|---|---|---|
| Split (Cmd-D), settled | 333 ms (half travel at 99 ms) | 283 ms (83 ms) |
| Pane close, settled | 400 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 wroteheight: 0, readscrollHeightand wrote it back from aResizeObserver, 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. listMotionno longer reads rows during a resize. It drives chat cards, open tabs and recently closed. Before, itsResizeObserverread 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.
| Step | ms | For |
|---|---|---|
| quick | 80 | hover and pressed fills, focus rings, anything leaving |
| micro | 120 | small things arriving: a fade in, a popup opening, a chip, a tooltip |
| standard | 180 | structural changes: a row or card moving, a fold opening, a scrim |
| large | 240 | the 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.
| Spring | Response before → after | Visibly done before → after |
|---|---|---|
| split | 0.28 → 0.23 | 287 → 235 ms |
| zoom | 0.30 → 0.2 | 354 → 235 ms |
| window exit | 0.24 → 0.2 | 283 → 235 ms |
| popover open / close | 0.25 / 0.16 → 0.24 / 0.15 | 248 / 188 → 238 / 177 ms |
| hover card (panel) | 0.18 → 0.165 | 194 → 177 ms |
| tab strip scroll, screen switch | 0.22 → 0.2 | 190 → 173 ms |
| sidebar pin drag | 0.351 → 0.215 | → 238 ms (about 1.6 pt overshoot on 200 pt) |
Fade (Swift MotionTunables.fades, webview MOTION_FADES) | Before | After |
|---|---|---|
| pane focus ring, crossfade / Reduce Motion cap | 100 ms | 80 ms |
| pane dim | 150 ms | 120 ms |
| theme switch | 160 ms | 120 ms |
New Tab move / leave fallbacks (--agent-motion-move, --agent-motion-out) | 190 / 160 ms | 180 / 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.MotionScaleTestschecks that every spring ends by the ceiling and every fade is a step.PaneCloseMotionTestsandSplitSpringMotionTestsnow 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_recordleaves 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 rulesuper.viewDidHideinBrowserToolbarButtonsView.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_snapshotson 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-lanesflags base's owngallery-agent-pane-entries.test.ts(later base commits move it togalleryFrame), the keyboard shortcuts docs freshness test (this diff touches no shortcut or catalog), andharness-health-notice#dismissed's 0.0019 layout shift. The notice moved Not Now and its words when its phase changed; itsrestart-after-turnvariant 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.swiftfrom 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.mdand the split spring's DESIGN.md still show the old values. The scale file is the source. - The
glideMaxdebug slider's range still goes past the ceiling. RevealedMarkdown.tsx's comment says--agent-motion-inis 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 readcalc(). 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