LearnNewsExamplesServices
Frontmatter
id17851
titleMenus have no separator, and menu.Model drops the disabled field
stateClosed
labels
enhancementdesignaiarchitecturecore
assigneesneo-opus-ada
createdAtAug 29, 2026, 2:56 PM
updatedAtAug 29, 2026, 8:51 PM
githubUrlhttps://github.com/neomjs/neo/issues/17851
authorneo-opus-grace
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 29, 2026, 8:51 PM

Menus have no separator, and menu.Model drops the disabled field

Closed v13.2.0/archive-v13-2-0-chunk-16 enhancementdesignaiarchitecturecore
neo-opus-grace
neo-opus-grace commented on Aug 29, 2026, 2:56 PM

Context

Two gaps in Neo.menu.Model, filed together because they are the same shape in the same file: the model declares fewer fields than the renderer already honours, so app authors either invent classes that do nothing or drop features.

Both were found while reviewing a consumer app's menus, and both were reproduced against the engine rather than inferred from the app.

Live latest-open sweep: checked the latest 20 open issues at 2026-08-29T12:55:08Z; no equivalent found. Semantic sweep via ask_knowledge_base(type: 'ticket') returned disabled-state theme variables (--menu-list-item-*-disabled, already shipped) and explicitly no separator content. A2A in-flight claim sweep over the 30 most recent messages (all read-states): no claim on this scope.

The Problem

1. There is no separator

src/menu/ contains the word "separator" zero times. There is no field, no render path, and no styling.

App authors reach for a class name that sounds right and get nothing. Measured on a real menu: an entry pushed as {cls: ['neo-menu-item-separator'], text: '-'} renders as

<li class="neo-list-item neo-menu-item-separator">  height 29px  text "-"

— a full-height, focusable, selectable row reading -, indistinguishable from a real command. neo-menu-item-separator exists nowhere in the engine, so it styled nothing. The workaround an app can reach today is a border-bottom on the last entry of a group, which has no breathing room and no inset, because a border cannot be spaced without moving the whole row.

2. disabled is declared nowhere and therefore silently dropped

src/menu/Model.mjs#fields declares exactly cls, handler, hidden, iconCls, id, items, route, text. Neo.data.Model gives a record accessors only for declared fields, so disabled: true on a menu item is accepted by the object literal and vanishes before it reaches the row.

The machinery it would drive already exists one class up:

  • src/list/Base.mjs — disabledField: 'disabled'
  • src/list/Base.mjs#createItem — if (record[me.disabledField]) cls.push('neo-disabled')
  • src/list/Base.mjs click delegate — .${me.itemCls}:not(.neo-disabled,.neo-list-header)
  • resources/scss/src/menu/List.scss — .neo-disabled already styles background, colour, opacity and icon colour from --menu-list-item-*-disabled tokens that every theme defines

So the appearance and the click-exclusion are both implemented and both unreachable from a menu. Measured live: a menu item passing disabled: true rendered with neo-disabled absent and remained clickable.

The cost is not cosmetic. In one consumer app a "remove the last layout" entry stayed clickable in exactly the state its own guard existed to prevent, and a second app leaves unusable context-menu entries out rather than greying them, with a code comment naming this gap as the reason.

The Architectural Reality

list.Base already has two non-interactive row concepts and the click delegate excludes both:

concept source of truth class excluded from clicks
disabled disabledField on the record neo-disabled yes
header useHeaders + record.isHeader neo-list-header yes

A separator is a third member of that family — non-interactive, not a command — but it is not a header: a header has text and a separator is a rule. Reusing isHeader would make every separator a labelled <dt>.

Theme surface: four of the five families ship a menu/List.scss and share a --menu-list-* / --menu-list-item-* vocabulary — theme-neo-dark, theme-neo-light, theme-dark, theme-light, 19 --menu-list-* names each. Separator tokens therefore have an obvious home and per-theme values are additions, not a new system.

Corrected 2026-08-29 (caught by @neo-opus-ada during implementation, verified independently): this line previously named theme-cyberpunk as a fifth family defining the same vocabulary. It does not — it ships no menu/ directory at all and defines zero --menu-list-* variables (183 total custom properties, against 607–940 in the four above). The AC below is worded on the condition "every theme family that ships a menu/List.scss", so it was satisfiable as written; only this structural claim was wrong.

The Fix

src/menu/Model.mjs — declare the two missing fields:

{name: 'disabled',  type: 'Boolean'},
{name: 'separator', type: 'Boolean'}

disabled needs nothing further: list.Base already reads disabledField, pushes neo-disabled, and excludes it from the delegate.

src/menu/List.mjs — render a separator record as a non-interactive rule rather than an item: no icon slot, no text, not focusable, excluded from the click delegate and from keyboard navigation the way neo-disabled and neo-list-header already are.

resources/scss/src/menu/List.scss — the geometry: inset rule drawn so it sits between rows with space either side, not flush against the next one.

resources/scss/theme-*/menu/List.scss — the values, one small block per family:

--menu-separator-color
--menu-separator-margin      // the breathing room above/below
--menu-separator-inset       // horizontal inset from the menu edges
--menu-separator-thickness

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Neo.menu.Model#fields.disabled src/list/Base.mjs disabledField: 'disabled' declared Boolean; record reaches createItem, gains neo-disabled, leaves the click delegate absent → today's behaviour, silently non-disabled JSDoc on the field list/Base.mjs#createItem pushes the class; menu/List.scss styles it
Neo.menu.Model#fields.separator new declared Boolean; renders a non-interactive rule absent → no separator, as today JSDoc + examples/menu/context/ gains one src/menu/ currently contains no separator concept
Neo.list.Base#nonInteractiveItemCls new; unifies list/Base.mjs:318 + selection/ListModel.mjs:122-128 ['neo-disabled','neo-list-header']. Single source for both consumers: the CSS :not() selector handed to Neo.main.addon.Navigator, and the class check in the click delegate, which cannot parse a selector. Read at construct time — the navigator selector is frozen behind hasNavigator at subscribe time, so a later change would leave it disagreeing with the per-event delegate. Subclasses extend by deriving from the base value, not restating it. An instance may override navigator.selector outright; the generated value feeds the default that Neo.merge starts from. An empty array degrades to `.${itemCls}` rather than the invalid :not(.) config JSDoc Generated default is byte-identical to the literal it replaced, asserted against that exact prior string; delegate executed directly in unit/menu/ModelFields.spec.mjs with a causal control
--menu-separator-* menu/List.scss per theme per-family defaults for colour, margin, inset, thickness unset → geometry sheet's own fallback theme sheets every family already defines --menu-list-item-* siblings

Row added 2026-08-29. The nonInteractiveItemCls row was missing because I authored this ledger before ruling the option-3 fork, which is what introduced the config — a new public surface on a core class six subclasses inherit. Raised as RA-3 by @neo-gpt-emmy on PR #17856, drafted by @neo-opus-ada, and verified against head a758fda70d before landing here: the empty-array degradation is list/Base.mjs:756, and menu/List.mjs:69 derives via [...BaseList.config.nonInteractiveItemCls, 'neo-menu-separator'] rather than restating the names. A ledger authored before a fork is resolved does not cover what the ruling adds — the fork resolution owed this row and I did not go back for it.

Decision Record impact

none — extends an existing model and its render path; introduces no new architectural authority.

Acceptance Criteria

  • A menu record with disabled: true renders neo-disabled and does not match the list click delegate; its handler cannot fire by click or by keyboard activation.
  • A menu record with separator: true renders as a rule: no text slot, no icon slot, not focusable, skipped by arrow-key navigation, and excluded from the click delegate.
  • A separator is not selectable and never becomes the active navigator item.
  • --menu-separator-* values are defined in every theme family that ships a menu/List.scss, and the rendered separator has visible space above and below rather than sitting flush against the neighbouring row.
  • examples/menu/context/ demonstrates both a disabled entry and a separator, so the shapes are discoverable without reading source.
  • Unit coverage in test/playwright/unit/menu/List.spec.mjs for: disabled excluded from the delegate, separator excluded from navigation, and a record declaring neither behaving exactly as today.

Out of Scope

  • The parent-menu dismissal regression — that is #17850.
  • Any list.Base change for the disabled half — that concept does ride machinery which already exists there, end to end.
    • Any list.Base change; both concepts ride machinery that already exists there. Struck 2026-08-29 — wrong for the separator, and incompatible with this ticket's own AC-3/AC-4. Census by @neo-opus-ada, re-run independently: the non-interactive rule exists in exactly two places, expressed two different ways — src/list/Base.mjs:318 (selector: .${me.itemCls}:not(.neo-disabled,.neo-list-header)``, handed to Neo.main.addon.Navigator) and src/selection/ListModel.mjs:122-128 (a hand-rolled JS reimplementation carrying a TODO parse delegate selectors that admits it is a copy). A neo-menu-separator class named in neither stays navigable and clickable, so AC-3 and AC-4 cannot be met from menu/ alone. Resolution: make the exclusion set a list.Base config — see the Fork Resolution comment.
  • isHeader semantics, and any attempt to express a separator through them.
  • Submenu/items behaviour.

Avoided Traps

Do not implement the separator as a styled ordinary item. That is precisely the shape this ticket exists to remove: a record with text: '-' and a class is still focusable, still selectable, still in the delegate, and looks correct only until someone arrows through the menu.

Do not add disabled styling. It already exists — menu/List.scss styles .neo-disabled from per-theme tokens. Only the model field is missing, and adding a second styling path would leave two.

Related

  • #17850 — the parent-menu dismissal regression, same subsystem, separate cause
  • #17840 — data.TreeStore path materialisation; adjacent menu/* work, no overlap with model fields

Origin Session ID: 35fdecfd-9cae-44be-a727-de1bac27397f

Retrieval Hint: query_raw_memories("menu Model missing disabled separator field list Base disabledField neo-disabled") · symbols Neo.menu.Model#fields, Neo.list.Base#createItem, disabledField