Context
WorkstationNL › the header overflow action opens a body-mounted menu with live Workstation skin (test/playwright/e2e/workstation/WorkstationNL.spec.mjs:2688) is one of the three unowned WorkstationNL reds on #17844. It fails at dev@cce07cdade and dev@3044d7f86b on its first theme assertion: the overflow control's theme is null, not neo-theme-neo-dark.
A Neural Link read of the control's parent chain on dev@3044d7f86b (dark boot) shows where the theme config stops:
| component |
className |
theme |
neo-button-38 (the overflow control) |
Neo.button.Base |
null |
neo-tab-header-toolbar-4 |
Neo.tab.header.Toolbar |
null |
neo-tab-container-3 |
Neo.dashboard.dock.interaction.TabContainer |
null |
| four layout containers |
Neo.container.Base |
null |
neo-dock-workspace-1 |
Workstation.view.Workspace |
null |
neo-viewport-1 |
Workstation.view.Viewport |
neo-theme-neo-dark |
getTheme() still resolves neo-theme-neo-dark at every level by walking up to the Viewport. The Viewport renders into the body, so today's paint is correct: a light-carried window (?theme=neo-theme-neo-light) paints its overflow menu light. What is missing is the config.
The Problem
component.Base#getTheme documents the contract: "The inherited theme travels as a config (container.Base#afterSetTheme propagates it), and a child inside a themed scope carries no theme class of its own by design, so the theme config is the first thing to read at every level." Two of the three ways an item reaches a container honor it:
- An item config resolves at creation: its own theme, then
itemDefaults, the class default, the container's (createItem's object branch, #18105).
- A live theme change reaches every instance item (
container.Base#afterSetTheme).
- An instance added through
add() / insert() takes createItem's NeoInstance branch, which runs item.set(config) and never reads me.theme. An instance created without a theme stays null, and every config item created beneath it inherits that null.
A unit probe on dev@3044d7f86b isolates the third path. Under a container with theme: 'neo-theme-neo-dark':
- A
{module: Container, items: [{module: Component}]} config reads dark for itself and its child.
- The same tree built with
Neo.create and then add()ed reads null for both, while its child's getTheme() resolves dark.
- After
container.theme = 'neo-theme-neo-light', the adopted instance and its child both read light.
The gap is at adoption only.
The Workstation moved onto that path in #18375 (2026-09-05). The Viewport's {module: Workspace} item became Neo.create(Workspace, …) followed by add(workspace), now in ViewportController (:210, :243) since #18596. The arm, added on 08-31 against the config path, is red in every title-keyed census since 22c183ccf9 (09-09).
Anything that reads the theme config under an adopted subtree is affected; the dock header's overflow control is the case the arm pins. In action mode (tab/plugin/Overflow.mjs createActionControl, through toolbar.Base#addActionContribution) the control inherits null. button.Base then hands that null to its body-mounted menuList (button/Base.mjs:350–360), which carries no theme class and paints whatever scope the body has. The floating mode sidesteps this by passing theme: owner.getTheme().
The Architectural Reality
container/Base.mjs createItem: the object branch resolves item.theme = item.theme || defaults.theme || module.config.theme || me.theme; the NeoInstance branch has no theme rung.
grid.Container passes theme: me.theme to each child it builds through beforeSetInstance (six sites). That is the per-site form of the same rung, for children a container constructs itself; an instance handed to add() has no such site.
component/Base.mjs afterSetTheme: a theme equal to the physical parent's carries no class (inheritsTheme); any other stamps its class. An adopted in-tree instance inheriting through the parent rung therefore adds no class to its root, which is what the arm expects (controlThemeClasses: []).
main/addon/Stylesheet.mjs:80 puts themes[0] on the body as the window's default scope. Nested scopes are CSS-variable scopes on top of it, by design.
The Fix
Recommended: give createItem's NeoInstance branch the object branch's last rung, reading the instance's theme before the move re-parents it. An adopted instance takes the container's theme when it carries none, or when the theme it carries is the one it inherited, meaning it equals its previous container's theme. A theme of its own, one that differs from its previous container's, stays, just as an item config's own theme does.
The comparison with the previous container is what keeps moves correct. Live panes move between containers instead of being recreated. A rung that only filled an unset theme would pin the first container's theme on a moved pane, and its descendants would resolve that stale scope through getTheme() in the next container. That is worse than today, where the unset config walks up to the current scope.
Alternatives considered:
- The Workstation adds its Workspace as a config, or passes the Viewport's theme. This fixes one root and leaves the gap for every other app that adopts instances. #18461 is reshaping that composition anyway.
- The overflow action control resolves
owner.getTheme() at creation, like the floating mode. This patches one consumer. It stamps a duplicate class on an in-tree control, which the arm rejects, and leaves every other theme-config reader under an adopted subtree as it is.
- Filling only an unset theme (
item.theme ??= me.theme). It turns :2688 green but pins the first container's theme on every instance that moves afterwards, as above.
Acceptance Criteria
Out of Scope
- An own theme that happens to equal its previous container's is indistinguishable from an inherited one, and follows the move. Telling them apart would need the config to record where its value came from.
- The overflow plugin's floating-mode theme wiring, which already resolves its scope.
Decision Record impact
none: aligned with the travelling-theme contract component.Base#getTheme documents (#18106) and the creation rungs of #18105.
Related
- #17844: census owner;
:2688 is one of its unowned reds.
- #18105 / #18106: the item-theme precedence and the
getTheme walk.
- #16119 / PR #16129: the overflow menu's earlier body-mounted theme fix, since which the floating control resolves
owner.getTheme().
- #18571: the Viewport keeps the body's theme class in step with its own.
- #18375: the Workstation composition change onto the adoption path; #18596 relocated it.
- #18461: the Workstation root composition in flight.
- #17977: introduced the arm.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-13T15:53:08Z; no equivalent found. Title searches for "theme instance add container" and "overflow theme action" across all states returned nothing.
A2A in-flight sweep: the 30 most recent messages at 15:53Z, all read-states; no claim on this gap or the arm.
MC sweep: "an instance added to a themed container keeps theme null, the header overflow menu under the Workstation has no theme config, the theme does not travel below the viewport", 6 results. The on-topic hits are the #16129 review (two theme accessors; the out-of-tree lineage #15098 → #15108 → #16119), #18571's body-theme sync, and the grid's per-site theme passing. None decides the adoption rung.
Own-assignment sweep: 12 open, none overlapping.
Origin Session ID: d010413d-0559-4755-b8f7-34cbcdc8d0b3
Retrieval Hint: "adopted instance theme null container add NeoInstance branch createItem theme rung overflow menu"
Context
WorkstationNL › the header overflow action opens a body-mounted menu with live Workstation skin(test/playwright/e2e/workstation/WorkstationNL.spec.mjs:2688) is one of the three unownedWorkstationNLreds on #17844. It fails atdev@cce07cdadeanddev@3044d7f86bon its first theme assertion: the overflow control'sthemeisnull, notneo-theme-neo-dark.A Neural Link read of the control's parent chain on
dev@3044d7f86b(dark boot) shows where the theme config stops:themeneo-button-38(the overflow control)Neo.button.Basenullneo-tab-header-toolbar-4Neo.tab.header.Toolbarnullneo-tab-container-3Neo.dashboard.dock.interaction.TabContainernullNeo.container.Basenullneo-dock-workspace-1Workstation.view.Workspacenullneo-viewport-1Workstation.view.Viewportneo-theme-neo-darkgetTheme()still resolvesneo-theme-neo-darkat every level by walking up to the Viewport. The Viewport renders into the body, so today's paint is correct: a light-carried window (?theme=neo-theme-neo-light) paints its overflow menu light. What is missing is the config.The Problem
component.Base#getThemedocuments the contract: "The inherited theme travels as a config (container.Base#afterSetThemepropagates it), and a child inside a themed scope carries no theme class of its own by design, so the theme config is the first thing to read at every level." Two of the three ways an item reaches a container honor it:itemDefaults, the class default, the container's (createItem's object branch, #18105).container.Base#afterSetTheme).add()/insert()takescreateItem'sNeoInstancebranch, which runsitem.set(config)and never readsme.theme. An instance created without a theme staysnull, and every config item created beneath it inherits thatnull.A unit probe on
dev@3044d7f86bisolates the third path. Under a container withtheme: 'neo-theme-neo-dark':{module: Container, items: [{module: Component}]}config readsdarkfor itself and its child.Neo.createand thenadd()ed readsnullfor both, while its child'sgetTheme()resolvesdark.container.theme = 'neo-theme-neo-light', the adopted instance and its child both readlight.The gap is at adoption only.
The Workstation moved onto that path in #18375 (2026-09-05). The Viewport's
{module: Workspace}item becameNeo.create(Workspace, …)followed byadd(workspace), now inViewportController(:210,:243) since #18596. The arm, added on 08-31 against the config path, is red in every title-keyed census since22c183ccf9(09-09).Anything that reads the theme config under an adopted subtree is affected; the dock header's overflow control is the case the arm pins. In action mode (
tab/plugin/Overflow.mjscreateActionControl, throughtoolbar.Base#addActionContribution) the control inheritsnull.button.Basethen hands thatnullto its body-mountedmenuList(button/Base.mjs:350–360), which carries no theme class and paints whatever scope the body has. The floating mode sidesteps this by passingtheme: owner.getTheme().The Architectural Reality
container/Base.mjscreateItem: the object branch resolvesitem.theme = item.theme || defaults.theme || module.config.theme || me.theme; theNeoInstancebranch has no theme rung.grid.Containerpassestheme: me.themeto each child it builds throughbeforeSetInstance(six sites). That is the per-site form of the same rung, for children a container constructs itself; an instance handed toadd()has no such site.component/Base.mjsafterSetTheme: a theme equal to the physical parent's carries no class (inheritsTheme); any other stamps its class. An adopted in-tree instance inheriting through the parent rung therefore adds no class to its root, which is what the arm expects (controlThemeClasses: []).main/addon/Stylesheet.mjs:80putsthemes[0]on the body as the window's default scope. Nested scopes are CSS-variable scopes on top of it, by design.The Fix
Recommended: give
createItem'sNeoInstancebranch the object branch's last rung, reading the instance's theme before the move re-parents it. An adopted instance takes the container's theme when it carries none, or when the theme it carries is the one it inherited, meaning it equals its previous container's theme. A theme of its own, one that differs from its previous container's, stays, just as an item config's own theme does.The comparison with the previous container is what keeps moves correct. Live panes move between containers instead of being recreated. A rung that only filled an unset theme would pin the first container's theme on a moved pane, and its descendants would resolve that stale scope through
getTheme()in the next container. That is worse than today, where the unset config walks up to the current scope.Alternatives considered:
owner.getTheme()at creation, like the floating mode. This patches one consumer. It stamps a duplicate class on an in-tree control, which the arm rejects, and leaves every other theme-config reader under an adopted subtree as it is.item.theme ??= me.theme). It turns:2688green but pins the first container's theme on every instance that moves afterwards, as above.Acceptance Criteria
test/playwright/unit/container/ItemTheme.spec.mjs.WorkstationNL.spec.mjsrun whole withNEO_AGENTOS_RUNTIME_ROOTwired: the header overflow arm passes, and every other arm keeps its census result.Out of Scope
Decision Record impact
none: aligned with the travelling-theme contract
component.Base#getThemedocuments (#18106) and the creation rungs of #18105.Related
:2688is one of its unowned reds.getThemewalk.owner.getTheme().Live latest-open sweep: checked the latest 20 open issues at 2026-09-13T15:53:08Z; no equivalent found. Title searches for "theme instance add container" and "overflow theme action" across all states returned nothing. A2A in-flight sweep: the 30 most recent messages at 15:53Z, all read-states; no claim on this gap or the arm. MC sweep: "an instance added to a themed container keeps theme null, the header overflow menu under the Workstation has no theme config, the theme does not travel below the viewport", 6 results. The on-topic hits are the #16129 review (two theme accessors; the out-of-tree lineage #15098 → #15108 → #16119), #18571's body-theme sync, and the grid's per-site theme passing. None decides the adoption rung. Own-assignment sweep: 12 open, none overlapping.
Origin Session ID: d010413d-0559-4755-b8f7-34cbcdc8d0b3 Retrieval Hint: "adopted instance theme null container add NeoInstance branch createItem theme rung overflow menu"