cmux-next: smoother dialogs, AA contrast for every dialog button (macOS, web, iOS)

Merged
AZ
6d ago
+849-60

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.set committed 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 danger is 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 of CmuxDialogLightContrastTests.

Changes

  • Motion (WindowOverlayHost+Present): the card grows from Motion.panelOpenScale on the appear spring and shrinks toward panelCloseScale on fadeOut, 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 the scrim token, the one the inactive-window tint uses. Card and scrim are transparent before the panel attaches.
  • Motion gate (Motion+AppKit): every spring, fade and timed set commits 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 as CmuxDialogButtonLook(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.
  • 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's data-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.
  • 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

metricbeforeafterdeltaharness or run link
Main thread to present, median (Close Workspace)17.0 ms26.0 ms+9.0 msdebug.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 ms33.8 ms+6.8 msdebug.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 ms23.9 ms+10.7 msdebug.dialog_strip, tagged DEBUG builds alertbase vs alertpolish, same window and display, 10 dark runs per dialog
Open settles, median (Close Workspace)131.5 ms149.0 ms+17.5 msdebug.dialog_strip, tagged DEBUG builds alertbase vs alertpolish, same window and display, 10 dark runs per dialog
Close settles, median (Close Workspace)134.5 ms124.5 ms-10.0 msdebug.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):

CommitSuiteRed at its test commitGreen at head
test(dialogs): destructive and key-hint ink, distinct default, grow-in, key-window glassDialogFadeTests2 failures: no grow spring; card glass keepsActiveLook falsepassed
sameCmuxDialogLightContrastTests7 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:1passed
test(dialogs): panel attaches with the dialog transparentDialogFadeTestsopacity 1 at attachpassed
test(dialogs): focused button draws an unclipped ringCmuxDialogLightContrastTestsclipsToBounds truepassed
test(overlays): panel leaves only after its empty frame; re-attach cancelsDialogFadeTeststhe panel left in the same turn its content didpassed

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:

BaseBranch
Opens with a full frame before the fade5 of 300 of 28
Dark closes where the card turns grey19 of 200 of 28
Largest close overshoot (dark)1.651.02
Destructive title contrast, light / dark (captured)3.81 / 2.925.63 / 5.64

Observed / Not run

Observed:

  • Red/green fleet runs (above), prepush-gate.py clean, scripts/web-lint.sh clean.
  • Tagged builds alertbase and alertpolish, 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 build at 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 for scale). 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