Context
The operator's framing while scoping the menu stack, and the piece deliberately left out of #18759 and #18760 so each could be reviewed on its own:
to SHOW sub-lists, focus and hover could be the better UX. also meaning: arrow up or down, when a child list is shown for the current item should hide it again.
Both halves are now measurable on dev (17f5a83b95, with #18761 / #18765 / #18766 merged). Two witness arms, run in the component harness against a three-item menu whose first item has children:
| witness |
driven |
result |
| A — a hovered submenu survives the focus moving away |
hover Share → 2 menus → focus the first item → ArrowDown (focus on Rename) → wait out the 200 ms rest |
2 menus. Share's submenu is still showing under an item that no longer has focus |
| B — focus alone opens nothing |
focus Share, wait out the rest delay |
1 menu. A pointer rest shows the submenu; a keyboard rest on the same item does not |
Witness A is a defect: a submenu is a child of the item it belongs to, and the item lost focus. Witness B is the missing half of #18759 — the config is spelled showSubMenuOnHover, and the implementation took "hover" literally.
The Problem
showSubMenu() has had a non-focusing preview mode since #18759 (focus=false), so the mechanism the keyboard needs already exists — what is missing is a caller and a symmetric hide.
- Nothing binds focus to a rest.
menu/List.mjs#onItemMouseEnter is wired to mouseenter only (construct), and it is the only path into onItemRest().
- Nothing hides on the way out.
selection/ListModel#onKeyDownUp and #onKeyDownDown are empty placeholders (src/selection/ListModel.mjs:40, :64), and selection/menu/ListModel does not override them — arrow movement is the main thread's Navigator moving focus, which the App Worker never sees as a menu event. hideSubMenu() is called from a rest on a leaf, from onSelect, from leaveSubMenu() and from afterSetMenuFocus; nothing calls it when focus moves between siblings.
The trap this leaf has to get right: ArrowRight moves focus INTO the submenu. A naive "focus left the parent → hide" rule closes the submenu the same keystroke just opened. The hide must fire when focus lands on a sibling of the parent, not on a descendant of the submenu.
The Architectural Reality
src/menu/List.mjs#onItemRest(nodeId) — the preview entry point, already liveness-guarded, already exclusive through showSubMenu().
src/menu/List.mjs#showSubMenu(nodeId, record, focus=false) — shows without selecting and without moving focus, via component.Base#focusOnMount.
src/menu/List.mjs#onFocusEnter / #onFocusLeave — the menu already observes focus at the LIST level (menuFocus), including isInteractionPath(data.oldPath), which is the discriminator the sibling-vs-descendant rule needs. The item-level focus signal is component.Base's focus events with their path.
src/menu/List.mjs configs showSubMenuOnHover and subMenuHoverDelay, read from getRootMenu().
The Fix
- A focus landing on an interactive item is a rest, on the same
showSubMenuOnHover / subMenuHoverDelay contract as a pointer rest, and takes the same non-focusing showSubMenu(..., false) path. The submenu appears; focus stays on the parent until the user presses ArrowRight.
- A focus landing on an item whose sibling has a submenu showing hides that submenu — and a focus moving into the submenu does not.
Naming fork, recommendation first. showSubMenuOnHover ships on dev as of this morning and is unreleased.
| option |
for |
against |
| keep the name (recommended) |
zero churn; the docblock can say the switch covers both rests |
the word "hover" under-describes the keyboard half |
rename to showSubMenuOnRest, no alias |
the name matches the behaviour while the cost is at its lowest |
a public config renamed inside a day, and the Contract Ledger carries both spellings |
Recommendation is the first row, decided here rather than escalated (reversible in one line). Reviewers who disagree can overturn it in a round.
Acceptance Criteria
Out of Scope
- Selection on navigate. The focus model is the settled outcome of #18760: arrows move focus, Enter / Space / click activate.
- Any change to
ArrowRight / ArrowLeft / Escape semantics beyond AC-3's guard.
- The
showSubMenuOnHover rename — the fork is recorded above; taking the second row is a one-line follow-up, not this leaf's job.
menu/List.spec.mjs's pointer-rest measurement flake — #18772.
Avoided Traps
- Hiding on any focus change.
ArrowRight moves focus into the submenu; a rule that does not distinguish descendant from sibling closes what it just opened. AC-3 exists to make that failure visible instead of plausible.
- Re-deriving the rest through a second timer.
onItemRest and cancelRest already own the delay, its cancellation and the liveness check; a focus path with its own timer would be a second source of truth for the same UX.
- Taking focus into the submenu on a focus rest. That is the
focus=true path and belongs to ArrowRight; a preview that steals focus makes ArrowDown unusable.
- Asserting "a submenu is open" by counting levels. #18759 measured that a close and a reopen inside one worker task are invisible to a level count; where a transition is the subject, count removals.
Related
- #18758 / #18759 / #18760 — the three merged leaves this completes. #18765's
component.Base#focusOnMount is the mechanism AC-1 reuses.
- #5123 — the Navigator change that moved menus to focus-driven keynav and left these two gaps.
- #2585 — the 2021 hover-expand request, stale-closed.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-16T10:36:56Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages (all read-states): no [lane-claim] on menu/; the live claims are @neo-opus-grace's grid and unit-isolation lanes and @neo-opus-vega's showcase lane. Memory Core rationale sweep on the symptom nouns: surfaced my own scoping note from this morning recording this as the deliberate follow-up, and no prior decision against it. Own-assignment sweep: my open items are #18772 and this; no same-surface hit. Structure-map gate: N/A — no .mjs created or relocated, nothing under ai/, Agent OS, MCP, Memory Core, orchestration or .agents/skills.
Decision Record impact: none.
Origin Session ID: cd67869d-cec2-4c36-99a6-aa521a73fddb
Retrieval Hint: "menu focus rest shows submenu, arrow off parent hides it" · query_raw_memories on "focus and hover are two rests on the same contract"
Context
The operator's framing while scoping the menu stack, and the piece deliberately left out of #18759 and #18760 so each could be reviewed on its own:
Both halves are now measurable on
dev(17f5a83b95, with #18761 / #18765 / #18766 merged). Two witness arms, run in the component harness against a three-item menu whose first item has children:Share→ 2 menus → focus the first item →ArrowDown(focus onRename) → wait out the 200 ms restShare's submenu is still showing under an item that no longer has focusShare, wait out the rest delayWitness A is a defect: a submenu is a child of the item it belongs to, and the item lost focus. Witness B is the missing half of #18759 — the config is spelled
showSubMenuOnHover, and the implementation took "hover" literally.The Problem
showSubMenu()has had a non-focusing preview mode since #18759 (focus=false), so the mechanism the keyboard needs already exists — what is missing is a caller and a symmetric hide.menu/List.mjs#onItemMouseEnteris wired tomouseenteronly (construct), and it is the only path intoonItemRest().selection/ListModel#onKeyDownUpand#onKeyDownDownare empty placeholders (src/selection/ListModel.mjs:40,:64), andselection/menu/ListModeldoes not override them — arrow movement is the main thread's Navigator moving focus, which the App Worker never sees as a menu event.hideSubMenu()is called from a rest on a leaf, fromonSelect, fromleaveSubMenu()and fromafterSetMenuFocus; nothing calls it when focus moves between siblings.The trap this leaf has to get right:
ArrowRightmoves focus INTO the submenu. A naive "focus left the parent → hide" rule closes the submenu the same keystroke just opened. The hide must fire when focus lands on a sibling of the parent, not on a descendant of the submenu.The Architectural Reality
src/menu/List.mjs#onItemRest(nodeId)— the preview entry point, already liveness-guarded, already exclusive throughshowSubMenu().src/menu/List.mjs#showSubMenu(nodeId, record, focus=false)— shows without selecting and without moving focus, viacomponent.Base#focusOnMount.src/menu/List.mjs#onFocusEnter/#onFocusLeave— the menu already observes focus at the LIST level (menuFocus), includingisInteractionPath(data.oldPath), which is the discriminator the sibling-vs-descendant rule needs. The item-level focus signal iscomponent.Base's focus events with theirpath.src/menu/List.mjsconfigsshowSubMenuOnHoverandsubMenuHoverDelay, read fromgetRootMenu().The Fix
showSubMenuOnHover/subMenuHoverDelaycontract as a pointer rest, and takes the same non-focusingshowSubMenu(..., false)path. The submenu appears; focus stays on the parent until the user pressesArrowRight.Naming fork, recommendation first.
showSubMenuOnHoverships ondevas of this morning and is unreleased.showSubMenuOnRest, no aliasRecommendation is the first row, decided here rather than escalated (reversible in one line). Reviewers who disagree can overturn it in a round.
Acceptance Criteria
showSubMenuOnHoverand delayed bysubMenuHoverDelay.ArrowRightstill opens and enters the submenu: focus moving into a descendant must not hide it. A CONTROL arm that would pass under a naive rule and fail under the correct one.dev.showSubMenuOnHover: falsesuppresses the focus rest exactly as it suppresses the pointer rest.Out of Scope
ArrowRight/ArrowLeft/Escapesemantics beyond AC-3's guard.showSubMenuOnHoverrename — the fork is recorded above; taking the second row is a one-line follow-up, not this leaf's job.menu/List.spec.mjs's pointer-rest measurement flake — #18772.Avoided Traps
ArrowRightmoves focus into the submenu; a rule that does not distinguish descendant from sibling closes what it just opened. AC-3 exists to make that failure visible instead of plausible.onItemRestandcancelRestalready own the delay, its cancellation and the liveness check; a focus path with its own timer would be a second source of truth for the same UX.focus=truepath and belongs toArrowRight; a preview that steals focus makesArrowDownunusable.Related
component.Base#focusOnMountis the mechanism AC-1 reuses.Live latest-open sweep: checked the latest 20 open issues at 2026-09-16T10:36:56Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages (all read-states): no
[lane-claim]onmenu/; the live claims are @neo-opus-grace's grid and unit-isolation lanes and @neo-opus-vega's showcase lane. Memory Core rationale sweep on the symptom nouns: surfaced my own scoping note from this morning recording this as the deliberate follow-up, and no prior decision against it. Own-assignment sweep: my open items are #18772 and this; no same-surface hit. Structure-map gate: N/A — no.mjscreated or relocated, nothing underai/, Agent OS, MCP, Memory Core, orchestration or.agents/skills.Decision Record impact: none.
Origin Session ID: cd67869d-cec2-4c36-99a6-aa521a73fddb
Retrieval Hint: "menu focus rest shows submenu, arrow off parent hides it" ·
query_raw_memorieson "focus and hover are two rests on the same contract"