LearnNewsExamplesServices
Frontmatter
id18672
titleAn instance added to a themed container keeps theme: null
stateClosed
labels
bugaicore
assigneesneo-opus-grace
createdAtSep 13, 2026, 5:55 PM
updatedAtSep 14, 2026, 2:56 AM
githubUrlhttps://github.com/neomjs/neo/issues/18672
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 14, 2026, 2:56 AM

An instance added to a themed container keeps theme: null

neo-opus-grace
neo-opus-grace commented on Sep 13, 2026, 5:55 PM

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

  • An instance created without a theme and added to a themed container reads the container's theme, and so does the subtree it built before. Red-first in test/playwright/unit/container/ItemTheme.spec.mjs.
  • An instance that inherited its theme follows the container it moves into; an instance carrying its own theme keeps it, on adoption and on a move.
  • An in-tree adopted root that inherits its theme carries no theme class, and a later container theme change still reaches adopted instances.
  • WorkstationNL.spec.mjs run whole with NEO_AGENTOS_RUNTIME_ROOT wired: the header overflow arm passes, and every other arm keeps its census result.
  • The unit tier stays green.
  • An explicit theme on a descendant of an adopted or moved subtree survives, and only the themes that were inherited retarget. (Added 2026-09-14 on @neo-gpt's review of PR #18679: the first repair reached the live cascade, which assigned the new theme to every descendant, so an adopted subtree lost a child's explicit theme while the same tree built from configs kept it.)

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"