cmux-next: smoother dialogs, AA contrast for every dialog button (macOS, web, iOS)
Summary
Polishes every cmux-next confirmation dialog (macOS native, about 100 call sites through CmuxDialogCenter), the web pages' ui/Dialog, and the iOS alerts: smoother open and close, and every dialog button at WCAG AA.
Measured with a new DEBUG tool, debug.dialog_strip: the window and its overlay panel read back to back from the window server (about 21 ms apart) while a dialog opens and closes, with per-frame card and scrim progress.
Found on the base build (5 runs per fixture, dark; 3 per fixture, light):
- Close flash: the card lit up a lighter grey for the whole fade, because its overlay panel gives key back as the fade starts and the Liquid Glass took its inactive frost. The close's first frame was 1.2 to 1.7 times further from the open dialog than the closed state is. Seen in 9 of 10 dark closes.
- Entrance flash: one frame of the whole dialog and scrim at full opacity, then the fade from zero. Seen in 7 of 10 dark opens. There were two causes: attaching the panel displayed its layers at full opacity, and
Motion.setcommitted the model value and its animation in separate transactions. - Flat motion: the card only faded, while the palette grows in. The scrim shared the card's 120 ms fade, then the inactive-window tint dimmed again on its own 180 ms token, so the window darkened in two steps.
- Contrast: destructive titles were 3.36:1 (light) and 3.11:1 (dark) on the opaque card, and 2.1:1 over the glass; the theme's
dangeris fit only to 3:1. Key hints were 4.13:1. The default button's fill differed from Cancel's by 1.06:1 (light) and 1.12:1 (dark): Return's button did not stand out. These are the base readings ofCmuxDialogLightContrastTests.
Changes
- Motion (
WindowOverlayHost+Present): the card grows fromMotion.panelOpenScaleon theappearspring and shrinks towardpanelCloseScaleonfadeOut, as the palette does. Under Reduce Motion only the fades remain; with animation off the dialog shows in one frame and completions still run. The scrim eases on thescrimtoken, the one the inactive-window tint uses. Card and scrim are transparent before the panel attaches. - Motion gate (
Motion+AppKit): every spring, fade and timedsetcommits its model value and its animation in one transaction. This fixes the one-frame flash for every gated layer animation, not only dialogs. - Glass: the dialog card keeps the key-window glass (
keepsActiveLook, as the window backdrop does). - Buttons (
CmuxDialogButtonLook):- Default (Return): the solid accent fill with on-accent ink, as the web's
.primary. - Destructive: a solid danger fill with ink fit to 4.5:1, as the web's
.danger. The tinted alternative (red text on a faint red wash, also fit to 4.5:1) stays in code asCmuxDialogButtonLook(filledDestructive: false)for the design call; there is no Debug toggle, so the defaults guard sees no new default. - Cancel and normal: the selection gray.
- Every title and key hint is fit to 4.5:1 over both card surfaces (glass over the scrimmed window, and the Reduce Transparency surface).
- The focus ring moves just outside the button so it shows on a solid fill. It had been clipped away:
clipsToBounds.
- Default (Return): the solid accent fill with on-accent ink, as the web's
- Web (
ui/popupSurface.css): the dialog grows in and shrinks out with the same scales; the backdrop eases on the standard (scrim) step; Reduce Motion (system or the app'sdata-cmux-motion=reduced) keeps only the fades. - Overlay panel detach (
WindowOverlayHost): the panel leaves its window only after the frame that draws it empty has committed (AfterFrameCommit; a re-attach in between cancels it). Before, it was ordered out with its last dialog still on its surface, and the next attach showed that for a frame (2 in 10 opens when the panel attached fresh; none while it stayed attached). An attach is never hidden, so page planes, the agent cursor and first overlays show at once. - iOS (HIG Alerts, checked: "Always place the default button on the trailing side…"; "Use the destructive style to identify a button that performs a destructive action people didn't deliberately choose"):
- Text-entry alerts (Rename, Rename Pane, Rename Screen, Rename Tab Group, Set Tab Group Icon, New Group, Create Team, Join, Send, Set, Open, Save, Create) had Cancel drawn bold. Their confirm is now the preferred action (
.keyboardShortcut(.defaultAction): bold, Return), as is Message Not Unsent's Try Again. - Destructive confirmations keep their role and are never the default.
- iOS alerts are system alerts, so their animation and colors stay the system's.
- Text-entry alerts (Rename, Rename Pane, Rename Screen, Rename Tab Group, Set Tab Group Icon, New Group, Create Team, Join, Send, Set, Open, Save, Create) had Cancel drawn bold. Their confirm is now the preferred action (
- DEBUG tool:
debug.dialog_strip {open, close, hold_ms, scale, dir}.
Capture
Viewed after image: yes
Visual verdict: Yes, the after stills read as a clean native dialog next to ChatGPT and Claude, with a solid default button, a clearly destructive red button, every label at 4.5:1 or better and a focus ring that clears its neighbors.
No capture: cmux-ci capture for this PR built the head (jobs f369165f4e5b and 8fc7292a788f), but no capture mini could be leased. cmux14, cmuxs-mac-mini-3, cmux9s and cmux10s rejected SSH authentication ("verify the fleet key and owner"), the others stayed busy, and the Blacksmith fallback found no runner (manaflow-ai/cmuxterm-hq run 41141384309).
Real-app captures from the tagged DEV builds are in the PR comments and the visual reviews: before/after stills of both dialogs in light and dark, the focus ring, and frame-by-frame open/close strips from debug.dialog_strip. The last real-app stills are from 1dcfb3faa718. Since then only the focus ring color has changed, now fit to 3:1 on the card as it renders, plus non-visual code. CmuxDialogLightContrastTests checks that against the light card color measured in that capture, rgb(223,223,223); it is red at 44f77e2a0601 (2.63:1) and green at the head.
Performance
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
| Main thread to present, median (Close Workspace) | 17.0 ms | 26.0 ms | +9.0 ms | debug.dialog_strip, tagged DEBUG builds alertbase vs alertpolish, same window and display, 10 dark runs per dialog |
| Main thread to present, max (Close Workspace) | 27.0 ms | 33.8 ms | +6.8 ms | debug.dialog_strip, tagged DEBUG builds alertbase vs alertpolish, same window and display, 10 dark runs per dialog |
| Main thread to close, median (Close Workspace) | 13.2 ms | 23.9 ms | +10.7 ms | debug.dialog_strip, tagged DEBUG builds alertbase vs alertpolish, same window and display, 10 dark runs per dialog |
| Open settles, median (Close Workspace) | 131.5 ms | 149.0 ms | +17.5 ms | debug.dialog_strip, tagged DEBUG builds alertbase vs alertpolish, same window and display, 10 dark runs per dialog |
| Close settles, median (Close Workspace) | 134.5 ms | 124.5 ms | -10.0 ms | debug.dialog_strip, tagged DEBUG builds alertbase vs alertpolish, same window and display, 10 dark runs per dialog |
These laptop numbers are not a controlled A/B; read them as unresolved. The branch app's state drifted from the base app's: it gained a browser workspace with a live page and a second window, which the base app never had. Across the branch's own batches the median present time ranged from 8.5 ms (an earlier build) to 26 ms (the later ones, in the drifted app), against 11.5 to 17 ms on base. DEBUG builds with the recorder running are also not budget figures. The controlled measurement is the fleet run of CmuxDialogLatencyBench (dialog build and layout, where this PR adds CPU: the per-button color fit) at the merge base and at the head; results below. No fleet STRESS bench of the present path was run.
Not finished at merge: the CmuxDialogLatencyBench runs (CMUX_DIALOG_BENCH=1, merge base vs head) were still running on the fleet when this merged. Their numbers will be added as a PR comment. Until then there is no controlled perf evidence either way.
Owners
PL-motion-cef (Overlay/WindowOverlayHost*.swift) and the Performance Lead (Motion/): Motion.set now commits its model value and animation in one transaction, and the overlay panel detaches one commit later, after it has drawn empty.
Design decision for Lawrence (#13742)
Filled (shipped) vs tinted (in code, not exposed). Posted with a recommendation for filled on #13742.
Changelog
- Changed: Dialogs open and close more smoothly, and their buttons are easier to read: the button Return presses is filled, and destructive buttons are a solid red.
Verification
Regression tests, red then green (cmux-ci run --class light --script scripts/ci/package-test-lane.sh --arg=suite --arg=Packages/macOS/CmuxNext --arg=<suites>, Xcode 26.6):
| Commit | Suite | Red at its test commit | Green at head |
|---|---|---|---|
| test(dialogs): destructive and key-hint ink, distinct default, grow-in, key-window glass | DialogFadeTests | 2 failures: no grow spring; card glass keepsActiveLook false | passed |
| same | CmuxDialogLightContrastTests | 7 failures: destructive title 3.36:1 light, 3.11:1 dark; key hint 4.13:1; default vs Cancel fill 1.06:1 / 1.12:1 | passed |
| test(dialogs): panel attaches with the dialog transparent | DialogFadeTests | opacity 1 at attach | passed |
| test(dialogs): focused button draws an unclipped ring | CmuxDialogLightContrastTests | clipsToBounds true | passed |
| test(overlays): panel leaves only after its empty frame; re-attach cancels | DialogFadeTests | the panel left in the same turn its content did | passed |
At the head (a56a7770728d), 22 suites pass: WindowOverlayHostClose, AgentCursorGlassBlur, AgentCursorTagClick, FullScreenTitlebarChild, DialogFade, CmuxDialogLightContrast, FloatingGlassStyle, FloatingSurfacesStayGlass, DialogKeyHint, HoverCardPanelMotion, MotionCategoryPolicy, MotionCommitProbe, MotionMasterSwitchSurface, MotionPathValueKind, MotionPathValues, MotionScale, MotionScreen, PagePopupMotion, PopupGrowMotion, PopupReduceMotion, ThinScrollerFade and CmuxToastSurface. Motion.set and the panel detach affect every gated layer animation and every overlay, so the motion, popup, hover card, toast, agent cursor and full-screen titlebar suites are included. App overlay suites also pass: FullScreenTitlebarPageMode, PageGeometryPost, StripScrollbarAbovePage and PopupSurface. AgentCursorGlassBlurTests caught a regression in an earlier detach version, fixed in 5007c0265e9e.
Live measurement, debug.dialog_strip on two tagged builds (base alertbase, branch alertpolish), same window on the same display:
| Base | Branch | |
|---|---|---|
| Opens with a full frame before the fade | 5 of 30 | 0 of 28 |
| Dark closes where the card turns grey | 19 of 20 | 0 of 28 |
| Largest close overshoot (dark) | 1.65 | 1.02 |
| Destructive title contrast, light / dark (captured) | 3.81 / 2.92 | 5.63 / 5.64 |
Observed / Not run
Observed:
- Red/green fleet runs (above),
prepush-gate.pyclean,scripts/web-lint.shclean. - Tagged builds
alertbaseandalertpolish, driven through the debug socket on the agent display only, never focused. 70 recorded opens and closes, light and dark, plus full-resolution stills. - iOS:
cmux-next-ios-sim.sh buildat the head built, installed and launched in an iPhone 17 Pro simulator (iOS 26.5). - Two correctness-first review subagents ran (see the PR comments). The first It found 3 web CSS defects (the backdrop close fade was cut off; the Home palette slid sideways under reduced motion because of
transform; sheets snapped to a scale) and 4 minor native ones (debug timer lifetime, Cancel's hover hue in accent themes, the focus-ring gap, multi-surface ink fit), all fixed. The second reviewed those fixes and the base merge. It found that hiding a freshly attached panel also hid page planes and delayed first overlays, so the fix moved to the detach (2ede0f58a05b, 5007c0265e9e).
Not run:
- The web browser test
popup-reduce-motion(updated forscale). Freestyle needs a key this Mac doesn't have, the receipts and gallery hosts don't resolve from here, and the fleet only runs the full webview suite. - An iOS alert on screen: no UI test reaches one. The bold preferred action is SwiftUI's documented behavior.
- No fleet perf STRESS bench. Present and close main-thread times in DEBUG are in the same range as base.
- No Cloud path: this change touches no Cloud code, so the tag uses the local backend.
- The panel-detach path live: in the tagged builds every window keeps a visible Chromium page, so the panel never detached during the strips. The detach is covered by its regression test only.
🤖 Generated with Claude Code