cmux NEXT menus: one row grid for every menu, and a lint for menus with their own paddings

Merged
TE
7d ago
+605-12

Leo, 2026-10-21, forwarding two screenshots from Lawrence: "lawrence sent this... idk if we genearlized the left alignment across all stuff yet". Then, on the sidebar's profile menu: "left alignment on the profile settings dropdown/pop i think is similar...".

What the screenshots show (described here; the images stay private):

  • Sidebar group ⋮ menu (New Workspace in Group, Pin Group, Options, Collapse Group, Move to Top, Move, Ungroup, Close All in Group, Delete Group, Close Other Groups): the hover highlight and the separators look flush to the menu's left edge.
  • Pane [+] menu (New Terminal Tab … Split Right, Toggle Pane Zoom, Search Tabs…, Show in Tab Bar ›): the highlight and separators are inset, and "Show in Tab Bar" has no icon but its text lines up with the iconed rows.
  • Profile menu (sidebar footer avatar): the current profile's small avatar is the only image in the menu, and AppKit pushed that row's "Default" to the right of every other row's text.

cmux NEXT had no shared menu grid. A survey found nine menu surfaces using about seven different grids:

  • highlight insets 4, 5, 6, 8 and 12 pt;
  • text columns anywhere from 12 to 44 pt;
  • separators at 0, 6, 8, 10, 12, 14 pt or AppKit's own.

Nothing checked them.

This PR, the first of three:

  • One source: schemas/menu-metrics/menu-metrics.json is the row grid of every menu: highlight inset and radius, separator inset, state, icon and text columns, and the shortcut column. scripts/cmux-next/gen-menu-metrics.py writes it into MenuMetrics.generated.swift and the --cmux-menu-* CSS tokens in desktop.css (the same pattern as the motion and type scales). --check runs in the cmux-next checks job.

  • The values are native macOS menu metrics (the same values #24036 adopted for themed rows, Leo on the chat menu: "so much ... padding ... all over the place"):

    • a row's icon, or its title in a menu without icons, starts at 15 pt, where AppKit's separators start;
    • 14 pt icons, then the title at 35;
    • a state column only in a menu with a checkmark (mark at 10, icon at 22);
    • the highlight 5 pt in, radius 5;
    • shortcuts and chevrons right-aligned 12 pt from the edge.
  • Every themed NSMenu (right-click, ⋮, the pane [+], the toolbar menus, the palette's NSMenus) reads it through ThemedMenuRowView. Icon-less rows' text sits on the text column whenever any row of the menu has an icon.

  • The name and color header (workspace and group menus) sits on the grid: the field's box on the rows' highlight edge, the first swatch on the icon column.

  • The profile menu is themed in the sidebar's scope like the other sidebar menus, so the avatar sits on the icon column and every row's text starts at one x.

  • A lint: scripts/cmux-next/check-menu-metrics.py fails any menu surface (a Swift menu, popup or picker row, item, header, strip or style type, or an editor row) that declares its own horizontal metric, draws its highlight at its own inset, or places a row part at its own x. The surfaces not on the grid yet are listed in its DEBT set, which only shrinks:

    • the sidebar and tab group editors;
    • the palette actions menu;
    • native page popups (PopupRowView, WebPopupHost, PopupStyle);
    • the extensions bubble.

    The second PR (cmux/menu-metrics-panels) moves those and empties the list. The third (cmux/menu-metrics-web) moves the webviews' menus (ui/Menu, the agent pane's context and project menus, the composer menus) onto the CSS tokens and adds the CSS half of the lint. The composer's quick model menu (QuickModelMenu.tsx, modelPicker.css) belongs to another lane and is left out.

Icon and text x, before and after (pt from the menu's edge)

Measured on the captures below (first ink pixel from the menu's border; glyph side bearing adds about 1-2 pt to the layout x), and on the layout by MenuGridTests:

SurfaceBefore: icon / textAfter: icon / text
Sidebar group menu (right-click or ⋮)ink 24 / 45ink 17 / 36 (layout 15 / 35)
Pane [+] (new tab) menuink 23 / 45ink 16 / 36 (layout 15 / 35)
Empty tab-strip menu (Split Right, Search Tabs…, Show in Tab Bar ›)ink 23-24 / 45ink 16 / 36 (layout 15 / 35)
All other themed NSMenus (registry menus)layout 22 / 44layout 15 / 35; 22 / 42 with a checkmark
Name + color headerfield 12, first swatch 15field 5 (highlight edge), first swatch 15 (icon column)
Profile menuAppKit's own menu: "Default" text at 36, other rows at 18 (no icons)avatar ink 16, every row's text at ink 37
Highlight inset / separators (themed NSMenus)5 / 155 / 15 (unchanged; now read from the grid)
Sidebar group editor (PR 2)14 / 34-3615 / 35
Tab group editor (PR 2)– / 20– / 15
Palette actions menu (PR 2)12 / 38-4015 / 35
Page popups (PR 2)check 18.5 / 2610 / 22
Web ui/Menu, agent pane menus (PR 3)12 / 12-3015 / 35

The highlight and separators of the themed NSMenus already sat at 5 and 15 pt, so in these captures only the icon and text columns move. The profile menu before is AppKit's own menu, so that pair also differs in glass and row height; after, it is themed like every other sidebar menu. The glass is unchanged.

Capture: fleet builds of the head fbe2687820a8 (job c6cee7ed4d465766e881f5b6) and of the base 139966bc8efe (job c1bad6975a96), driven on a capture mini. Light appearance, with the menus' glass as drawn. The sidebar, pane [+] and tab-strip menus are cmux-ci capture window snapshots, and the mini's text check found nothing private in them. The profile menu blocks the app's snapshot call while it is open, so its two stills are screen grabs from a capture slot, cropped to the app's window.

Sidebar group menu (right-click on the "cmux-next app" header)

BeforeAfter
group menu beforegroup menu after

Sidebar group menu, highlight on the first row (Down once)

BeforeAfter
group menu highlight beforegroup menu highlight after

Pane [+] (new tab) menu

BeforeAfter
plus menu beforeplus menu after

Empty tab strip menu (Split Right, Search Tabs)

BeforeAfter
strip menu beforestrip menu after

Sidebar footer profile menu

BeforeAfter
profile menu beforeprofile menu after

Leo-ask: ask-8c953bd096
Leo-ask: ask-63bba70b57 (the menu half; the git repo detection half is with agent-message-style)

Changelog

Changed: Menus line up the same way everywhere: icons in one column, names in the next, shortcuts on the right.

Observed / Not run

  • Observed:
    • gen-menu-metrics.py --check, check-menu-metrics.py (passes; fails on a scratch fixture with a menu row's own titleX, NSRect(x: 3 and insetBy(dx: 5), and prepush-gate.py (clean).
    • Before/after captures of the group, group-highlight, [+], empty-strip and profile menus from fleet builds of the base 139966bc8efe and fbe2687820a8 (the head before the lint-only commit fe42235c), measured above.
    • check-menu-metrics.py self-test (4 must-fail, 4 must-pass examples, including multiline NSRect, constraint and insetBy cases) and the full run: 141 menu surface files on the grid, 7 in DEBT.
    • Codex (xhigh) review: FIX at 47fcda49 (surface discovery and one-line scanning in the lint), fixed in fe42235c.
  • Not run here: the fleet run of MenuGridTests, ThemedMenuTests, MenuIdentityHeaderTests, SidebarMenuLookTests, ActionSurfaceParityTests and ProfileMenuTests is queued; the PR's own CI runs the CmuxNext suites.
  • I could not reproduce Lawrence's flush highlight on the group menu in a real build: at base it already sat 5 pt in.

Follow-up at fec559a1 (review BLOCK at 8591d004)

Right-click menus went native on the base since the captures above (e01bdd4694c, Lawrence: "make sure our right click stuff context menu is standard macos ... we don't want ANYTHING custom besides the icons"), so cmux no longer draws its own menu rows. This head keeps rows native and puts them on the grid through their icons:

  • NSMenu.themeRows(in:) fits every ordinary row's image into the grid's square icon box (MenuMetrics.iconSize, 14 pt, shape kept, template and VoiceOver description kept, a second theming changes nothing). AppKit's one image column is then the grid's in every menu, and the profile menu's larger avatar no longer pushes the text column right.
  • MenuGridTests measures native rows (no custom row view, every icon in the grid's box) instead of the removed ThemedMenuRowView; a new test covers the fit.
  • "Menu metrics" joins the workflow's STYLE_CHECKS, so a red lint warns like the other style guards.
  • check-menu-metrics.py no longer flags the menu bar status icon (MenuBarIcon) or the Mac screen switch's knob inset, both new on the base and not menu rows.
  • Observed: MenuGridTests (7) and NativeContextMenuTests (5) pass on a fleet Mac at fec559a1 (cmux-ci step a9baf938903d53df5c712eeb); both lint scripts pass.

No capture: the captures above predate the native-menu switch on the base; at this head the only visible change is that a menu icon larger than 14 pt (the profile avatar) is drawn in the 14 pt box.

Agent session: agent session

🤖 Generated with Claude Code