LearnNewsExamplesServices
Frontmatter
id18241
titleToolbar actions have no class, so each one''s presentation is derived twice
stateClosed
labels
enhancementaiarchitecturecoretech-debt
assigneesneo-opus-vega
createdAtSep 4, 2026, 2:04 AM
updatedAtSep 4, 2026, 10:34 AM
githubUrlhttps://github.com/neomjs/neo/issues/18241
authorneo-opus-vega
commentsCount1
parentIssue18151
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 4, 2026, 10:34 AM

Toolbar actions have no class, so each one's presentation is derived twice

Closed v13.2.0/archive-v13-2-0-chunk-18 enhancementaiarchitecturecoretech-debt
neo-opus-vega
neo-opus-vega commented on Sep 4, 2026, 2:04 AM

Sub of #18151. Filed from a sighting during #18240 rather than fixed there — it is a design change, not a relocation.

Context

src/toolbar has no action class. An action is a plain config object stamped isToolbarAction: true (toolbar/Base.mjs:411-450), identified by a .action string, instantiated as whatever module its config names. actionMap defaults to null and nothing in src/ populates it. Membership is a boolean flag; there is no type.

So "what does locked mean for this action" has nowhere to live, and each action's state→presentation derivation — icon, accessible name, tooltip, visibility — is written outside the action, by every layer that needs it.

The Problem

The derivation is duplicated by construction, not by carelessness: once in the projection for the boot config, once in the runtime sync, in different layers.

projection/LayoutAdapter.mjs:1120-1131 (boot):

iconCls    : context.items[activeItemId]?.locked === true ? context.dockUnlockIconCls : context.dockLockIconCls,
showOnFocus: context.items[activeItemId]?.locked !== true,
vdom       : {'aria-label': context.items[activeItemId]?.locked === true ? 'unlock' : 'lock'},
...tip(context.items[activeItemId]?.locked === true ? 'unlock' : 'lock')

dock/plugin/Lock.mjs#syncAction (runtime) computes the same four values from the same predicate.

Measured at origin/dev@274b63beac, each with the command that produced it:

Measure Command Count
?.locked === true in src/dashboard/dock/ git grep -o "?\.locked === true" 274b63beac -- src/dashboard/dock/ | wc -l 11
any locked === true in src/dashboard/dock/ git grep -o "locked === true" 274b63beac -- src/dashboard/dock/ | wc -l 14
'unlock' : 'lock' derivations in src/dashboard/ git grep -c "'unlock' : 'lock'" 274b63beac -- src/dashboard/ 4
'restore' : 'maximize' derivations in src/dashboard/ git grep -c "'restore' : 'maximize'" 274b63beac -- src/dashboard/ 2

Six independent derivations of two toggle mappings, and nothing structural holding any pair equal. One reader cannot be promised; it has to be a type.

The Architectural Reality

This is the generator behind #18151's measured clusters rather than another instance of them. Because an action cannot own its own derivation:

  • the projection writes it (boot),
  • every host sync writes it again (runtime),
  • and a host that wants a different action writes both again.

That is the syncDock*Action cluster (761 LOC / 16 methods in a 4,317-line class), and it is why apps/workstation carries 1,022 lines of tab-header chrome — 23.7% of a file nobody can navigate. The missing type is the multiplier.

The Fix

An action class that owns its state→presentation mapping. The projection emits identity plus state — {action: 'lock', pressed} — and the action derives icon, accessible name and tooltip in its own afterSet; the focus gate derives from pressed at the toolbar, which owns contextual presence. Boot and runtime become one code path, so drift stops being possible instead of being documented as forbidden.

Placement follows the in-package precedent (tab/plugin/Overflow.mjs beside the class it extends); toolbar/ActionButton.mjs extending button/Base.mjs is the shape to argue about, and the argument belongs in D#18150 alongside the vehicle question.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
toolbar action config toolbar/Base.mjs:411 gains a class that owns derivation plain-config actions keep working class JSDoc no class exists today
toolbar/Base#isFocusGatedAction :331 derives the effective gate from pressed; the declared showOnFocus is never rewritten ungated actions unchanged method JSDoc the projection derived the gate from item state
LayoutAdapter header-action configs :1120-1131 emit identity + state, not derived presentation — — the four-read block above
dock/plugin/Lock.mjs#syncAction #18240 writes one state config — — the same quad, second copy
Workspace#syncDockMaximizeActionPresentation Workspace.mjs same collapse — — second 'restore' : 'maximize' derivation

Decision Record impact

aligned-with ADR 0029. No new docking concept — this gives an existing one an owner.

Acceptance Criteria

  • AC-1 — An action derives its own icon, accessible name and tooltip from one state config, and its effective focus gate follows that same flag. One code path serves boot and runtime.
  • AC-2 — LayoutAdapter's header-action configs carry state, not derived presentation. The four-read block at :1120-1131 is gone.
  • AC-3 — ?.locked === true reads in src/dashboard/dock/ drop from 11, and any-form locked === true from 14; the PR body states both numbers with the commands that produced them.
  • AC-4 — Both toggle mappings exist once: 'unlock' : 'lock' drops from 4 to 0 and 'restore' : 'maximize' from 2 to 0 in src/dashboard/, so no comment is left promising two copies stay equal — there is one copy.
  • AC-5 — Existing action arms pass; any arm that must change is one asserting a mechanism this ticket relocates, never a property, and the PR body names each such edit and why.

Out of Scope

  • The vehicle question (B vs E) — D#18150's, and orthogonal: this is what an action owns, not where the concern lives.
  • Migrating hosts that supply their own actions. The plain-config path stays.
  • apps/workstation's 1,022 lines. They shrink as a consequence, in their own ticket.
  • Pop-out. It is not a toggle: its two expressions mirror a visibility predicate (dockPopOutActionActive), which an action class does not address. Its cannot drift comment at Workspace.mjs:1763 therefore stays, and so does model/Document.mjs:68, whose subject is the persistence-vs-projection rejection boundary.

Avoided Traps

  • Fixing the duplication by deleting one copy. Both exist for a reason — boot needs a config, runtime needs a write. Removing either breaks the other; only an owner collapses them.
  • Reading this as a lint. ?.locked === true eleven times is the symptom. Counting it without giving the action an owner produces an eleventh-and-a-half copy.
  • Treating it as dock-specific. toolbar/Base is engine-wide; the dock is where the absence became measurable.
  • Counting a grep instead of reading its hits. This ticket's first AC-4 asked for three cannot drift comments to be deleted, on the assumption that all three promised a toggle's copies equal. None of them does. See the correction comment below.

Related

  • Parent: #18151 — this is the generator behind its measured clusters.
  • #18240 — the PR that surfaced it; it relocates one copy and leaves the duplication intact, by design.
  • D#18150 — the 4,317-line decomposition; this changes what its clusters weigh.
  • defect-note captured 2026-09-04T00:04Z under ticket-create §1e.

Live latest-open sweep: latest 20 open issues at 2026-09-04T00:05Z; no equivalent. A2A sweep: no competing claim.

Origin Session ID: e8ea32a2-b9a8-43ea-84ba-b83081ec4b3d

Retrieval Hint: "toolbar action has no class derivation duplicated projection and sync cannot drift comment locked === true focus gate derives from pressed"