Sidebar polish round. Menus. `dropdown-menu` and `context-menu` were two copies of one engine that differed only in how the trigger fires — identical from `computePosition` down — and neither could nest. Both are now thin wrappers over `components/ui/menu/`, which owns anchoring, a pure page-stack navigation model, and one presentation fork: flyout on pointer, pushed pages in a sheet on compact. All 31 call sites are unchanged. Items got a rounded inset fill, a visible separator, and `selected` (a check) split from `active` (a fill). Sidebar display preferences. The flat wall of four sections becomes one row per decision with its value, and per-feature toggles over what a workspace row shows: host, pull request, checks, scripts. Each is independent — a ticked toggle that draws nothing is a lie. `display-preferences/` hides that grouping lives in zustand while title source and row items are app settings. Timestamps. "Last activity" now ticks itself. The label rounds to the minute and never shows seconds; past a week it becomes a date. The timer lives in the leaf `<Text>`, at most one per resolution tier shared by every row, and a tick only re-renders when the string actually changed — so a memoized row that legitimately never re-renders still stays current. Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as the muted tier, lightness and hue untouched. File icons are pulled toward neutral in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a tree where the icon is the only colour on the row, and ours is not. New/deleted files use the forge square-plus/square-minus glyphs at muted weight instead of tinted pills. Project icons round a little more; uploaded icons never do. The project status badge is a knockout again: it fills with the colour of the row behind it, so the ring reads as a gap in the icon rather than a white halo. File rows. The always-visible kebab is gone from the file explorer and diff tree in favour of right-click, which also reclaims the reserved action slot.
6.8 KiB
Menus
One engine, in packages/app/src/components/ui/menu/. dropdown-menu.tsx and context-menu.tsx
are wrappers over it and differ only in what opens them — a press, or a long press / right click.
Import the DropdownMenu* or ContextMenu* names as before; reach for @/components/ui/menu
directly only when you are building a third trigger shape.
Do not add a third menu implementation. The two that existed were byte-identical from
computePosition down, and the copies drifted: only one of them ever grew a sheet.
Two presentations
MenuRoot picks one from form factor, never from platform — a tablet in a narrow split view
sheets the same way a phone does.
| Screen | Surface | Submenus |
|---|---|---|
| Wide | Popover anchored to the trigger | Flyout overlapping the row, opened by hover intent |
Compact, compactMode="sheet" |
Bottom sheet | The page is replaced in place, with a back header |
| Compact, default | Popover | Same as wide |
compactMode defaults to "popover", so adopting the sheet is per-menu. That is deliberate:
flipping every menu in the app to sheets at once is not a change anyone can review. Opt a menu in
when you have actually looked at it on a phone.
Pages
A submenu is a page, declared as data on the surface and reached by a MenuSubTrigger whose id
matches:
<MenuSurface pages={pages} sheetTitle="Display">
<MenuSubTrigger id="grouping" value="Project">
Grouping
</MenuSubTrigger>
</MenuSurface>
Pages are data rather than nested children because the popover renders them as siblings of the
root surface — a flyout nested inside the root's box would be clipped by its overflow: hidden.
Declaring them separately is what lets one model drive both presentations.
menu-navigation.ts holds that model, and it is pure: the open flyout chain and the mobile push
stack are the same path, so the popover renders every entry in it and the sheet renders only the
last. Nothing else differs between the two.
Opening a submenu truncates the path to the depth of the trigger that opened it. Without that, sliding the pointer across a row of triggers would stack up every flyout it passed instead of swapping between them.
Hover intent
A flyout overlaps its parent by 5pt rather than sitting beside it. With a gap there is a strip of backdrop between the two that belongs to neither surface, and every pixel of it is a chance to dismiss the menu you are reaching for. Overlapping deletes the strip; don't reintroduce the gap and try to cover it with a longer timer.
On top of that, a flyout opens after the pointer rests ~90ms, closes ~260ms after it leaves, and cancels its own pending close while the pointer is inside it. The grace still matters because the pointer crosses sibling rows on the way down into the flyout.
Hover lives on a plain View, never on a Pressable — see hover.md, which owns that
rule. Hover only fires on web, which is exactly where flyouts exist; everywhere else the page
opens on press.
Item states
selected and active are different questions and must not be merged.
| Prop | Means | Draws |
|---|---|---|
selected |
This is the chosen value | A check, and nothing else |
active |
This row's submenu is open | The fill, and no check |
A selected row does not get a background. A check and a fill are two separate claims about the
same state, and showing both makes a chosen row compete with the row the pointer is actually on.
showSelectedCheck moves the check to a reserved leading column when a group needs to stay
aligned whether ticked or not; otherwise it sits at the trailing edge and the leading slot is free
for the option's icon.
Give options icons; leave the root rows without them. The root is labels and their current values, and a column of icons there is decoration competing with the values you actually came to read.
Surface details
- The hover fill is inset by the same amount on every side and rounded — a chip inside the menu, not a band across it. The inset is taken out of the row, never added to it: horizontally padding gives up what margin takes so labels don't move, and vertically the fill is shorter by the same amount so the row pitch is unchanged. Insetting by growing the row is how the menu ends up taller than it started.
- The fill's height only holds because the label's
lineHeightis pinned. Leave it to the platform and content outgrowsminHeight, which then does nothing. - The item's inset is the menu's only spacing. Rows sit apart because each holds itself in, and a separator gets clearance from its neighbours for free. Don't give the separator a margin of its own — that is two numbers controlling one gap, and they will disagree.
- Separators use
borderAccent, the same colour the surface outlines itself with.bordersits betweensurface1andsurface2, which put it within a hair of the hover fill and made separators disappear against a hovered row.
When a decision earns a submenu
Put a decision behind a submenu when its options are not the point — the current value is. The
root row then reads as the answer (Grouping Project ›) and costs one line instead of one line
per option. A menu whose every option is on screen at once does not survive its third decision.
Independent toggles stay on their page as a checkmark list. A pick-one group can share that page
below a MenuSeparator; make selecting the checked row clear it, so "none" doesn't need a row of
its own.
Gotchas
- Released height. Reanimated's web entering animation leaves an inline height snapshot on
the surface.
AnchoredSurfaceclears it, and arevisionprop re-clears it when content identity changes — a pushed page taller than the one it replaced is clipped without that. - Sheets size to content.
enableDynamicSizing, not fixed snap points. A pushed page is rarely the height of the page before it. - One overlay per menu. Submenus render inside their parent's layer and paint no second
backdrop, so there is exactly one
Modalon native no matter how deep the menu goes. - Anchoring, flipping, and edge clamping live in
menu-anchor.tsand are unit-tested. Fix positioning bugs there, not at a call site. - Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates, status-bar offset, the open flash — is in floating-panels.md.