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
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
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— a full-height, focusable, selectable row reading
-, indistinguishable from a real command.neo-menu-item-separatorexists nowhere in the engine, so it styled nothing. The workaround an app can reach today is aborder-bottomon 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.
disabledis declared nowhere and therefore silently droppedsrc/menu/Model.mjs#fieldsdeclares exactlycls, handler, hidden, iconCls, id, items, route, text.Neo.data.Modelgives a record accessors only for declared fields, sodisabled: trueon 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.mjsclick delegate —.${me.itemCls}:not(.neo-disabled,.neo-list-header)resources/scss/src/menu/List.scss—.neo-disabledalready styles background, colour, opacity and icon colour from--menu-list-item-*-disabledtokens that every theme definesSo the appearance and the click-exclusion are both implemented and both unreachable from a menu. Measured live: a menu item passing
disabled: truerendered withneo-disabledabsent 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.Basealready has two non-interactive row concepts and the click delegate excludes both:disabledFieldon the recordneo-disableduseHeaders+record.isHeaderneo-list-headerA 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
isHeaderwould make every separator a labelled<dt>.Theme surface: four of the five families ship a
menu/List.scssand 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.The Fix
src/menu/Model.mjs— declare the two missing fields:{name: 'disabled', type: 'Boolean'}, {name: 'separator', type: 'Boolean'}disabledneeds nothing further:list.Basealready readsdisabledField, pushesneo-disabled, and excludes it from the delegate.src/menu/List.mjs— render aseparatorrecord 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 wayneo-disabledandneo-list-headeralready 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:Contract Ledger Matrix
Neo.menu.Model#fields.disabledsrc/list/Base.mjsdisabledField: 'disabled'createItem, gainsneo-disabled, leaves the click delegatelist/Base.mjs#createItempushes the class;menu/List.scssstyles itNeo.menu.Model#fields.separatorexamples/menu/context/gains onesrc/menu/currently contains no separator conceptNeo.list.Base#nonInteractiveItemClslist/Base.mjs:318+selection/ListModel.mjs:122-128['neo-disabled','neo-list-header']. Single source for both consumers: the CSS:not()selector handed toNeo.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 behindhasNavigatorat 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.navigator.selectoroutright; the generated value feeds the default thatNeo.mergestarts from. An empty array degrades to`.${itemCls}`rather than the invalid:not(.)unit/menu/ModelFields.spec.mjswith a causal control--menu-separator-*menu/List.scssper theme--menu-list-item-*siblingsDecision Record impact
none— extends an existing model and its render path; introduces no new architectural authority.Acceptance Criteria
disabled: truerendersneo-disabledand does not match the list click delegate; its handler cannot fire by click or by keyboard activation.separator: truerenders as a rule: no text slot, no icon slot, not focusable, skipped by arrow-key navigation, and excluded from the click delegate.--menu-separator-*values are defined in every theme family that ships amenu/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.test/playwright/unit/menu/List.spec.mjsfor: disabled excluded from the delegate, separator excluded from navigation, and a record declaring neither behaving exactly as today.Out of Scope
list.Basechange for thedisabledhalf — that concept does ride machinery which already exists there, end to end.AnyStruck 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 —list.Basechange; both concepts ride machinery that already exists there.src/list/Base.mjs:318(selector:.${me.itemCls}:not(.neo-disabled,.neo-list-header)``, handed toNeo.main.addon.Navigator) andsrc/selection/ListModel.mjs:122-128(a hand-rolled JS reimplementation carrying aTODO parse delegate selectorsthat admits it is a copy). Aneo-menu-separatorclass named in neither stays navigable and clickable, so AC-3 and AC-4 cannot be met frommenu/alone. Resolution: make the exclusion set alist.Baseconfig — see the Fork Resolution comment.isHeadersemantics, and any attempt to express a separator through them.itemsbehaviour.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
disabledstyling. It already exists —menu/List.scssstyles.neo-disabledfrom per-theme tokens. Only the model field is missing, and adding a second styling path would leave two.Related
data.TreeStorepath materialisation; adjacentmenu/*work, no overlap with model fieldsOrigin Session ID: 35fdecfd-9cae-44be-a727-de1bac27397f
Retrieval Hint:
query_raw_memories("menu Model missing disabled separator field list Base disabledField neo-disabled")· symbolsNeo.menu.Model#fields,Neo.list.Base#createItem,disabledField