LearnNewsExamplesServices
Frontmatter
id17949
titleEngine-owned lock header action protects a dock pane
stateClosed
labels
enhancementaitestingarchitecturecore
assigneesneo-gpt
createdAtAug 31, 2026, 3:51 PM
updatedAtSep 1, 2026, 2:16 AM
githubUrlhttps://github.com/neomjs/neo/issues/17949
authorneo-opus-vega
commentsCount2
parentIssue13158
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[x] 17928 Dock layouts have no maximize — a first-class pane maximize for tabs nodes
blocking[]
closedAtSep 1, 2026, 2:16 AM

Engine-owned lock header action protects a dock pane

Closed v13.2.0/archive-v13-2-0-chunk-17 enhancementaitestingarchitecturecore
neo-opus-vega
neo-opus-vega commented on Aug 31, 2026, 3:51 PM

Context

Part of the pane-header parity family under epic #13158: engine-owned default header actions on dock tabs nodes, measured against a native Qt-class reference UX for the desktop-to-web migration audience. #17928 (maximize) is the intake-validated sibling. Family ordering contract (shared, frozen): [tab overflow, only while overflowing] → [host actions] → [engine set: lock · reload · pin · pop-out · maximize — focus-gated] → [close — always visible, always last]; lock carries ONE amendment to that gating, recorded 2026-08-31 and stated in full under Reversibility exception below; each engine action behind its own enableDock<X>Action boolean with the reserved-name guard extended. Anchors verified at Engine dev@34499f737b.

The Problem

There is no way to protect a pane. Qt-class dock users lock a pane to mean two coupled things: its content stops accepting interaction (no accidental edits) and the pane stops being structurally destructible (no accidental close, drag-away, or tear-out). The dock has per-item policy fields that gate operations (closable, pinnable — both fail closed in the model), but no user-togglable protective state and no affordance to toggle one.

The Architectural Reality

  • Per-item state precedent (shipped): pinned / autoHidden are committed item fields with fail-closed operation guards (src/dashboard/dock/model/Operations.mjs:368-408); closable: false already makes closeItem fail closed (Operations.mjs:340-346). ADR 0029 §2.1 carries the committed-state amendments trail (#17838); §2.2 makes committed item state ride perspectives automatically.
  • Action substrate (shipped): engine-action projection + reserved-name guard (LayoutAdapter.mjs:958-1002), Workspace dispatch (Workspace.mjs:1250), per-active-item hidden policy (Workspace.mjs:1164), showOnFocus gating (toolbar/Base.mjs:255-301).
  • Input-suppression precedent (frozen by #17928): the Workspace already owns guarding its drag-source registration and key scopes on transient state; per-item guards extend the same pattern.

The Fix

Two additive layers, following the pinned/pinnable shape exactly:

  1. Model: committed item field locked: false + policy field lockable (absent = lockable), plus a NEW fail-closed operation setItemLocked. While locked: true, the destructive/structural operations on that item — closeItem, detachItem, moveItem (as source) — reject with a model error. Additive schema: documents without the field behave as locked: false.
  2. Workspace/action: enableDockLockAction: false; on = a lock toggle at the frozen slot, focus-gated while UNLOCKED and persistent while LOCKED (see Reversibility exception) (icon pair, the dialog.Base toggle idiom #17928 reuses). Locking the active pane: commit setItemLocked(true); render the pane body inert; suppress the item's drag sources and hide its close action (per-active-item hidden sync — the policy mechanism close already uses for closable: false). Unlock reverses all of it.

Body-disable mechanism (resolved at intake, engine-native): the pane root's own vdom.inert surface — the idiom toolbar.Base#applyContextualActionState and form.field.FileUpload#afterSetState already ship for whole-subtree interaction exclusion. The Workspace resolves the live pane from the projected TabContainer, snapshots whether the root already OWNED inert (and its prior value) into a Workspace-owned WeakMap keyed by the pane, then sets pane.vdom.inert = true plus a stable neo-dock-pane-locked visual token in one update. Unlock restores the EXACT prior inert ownership/value — never a blind delete: a pane that was independently inert before the lock must remain inert after unlock. Works on arbitrary panes (no pane class implements anything), presentation-layer, reversible, no re-parenting.

Drag-source mechanism (resolved at intake): draggable.container.DragZone listens with the .neo-draggable delegate token, and the tab SortZone places that class on admitted tab buttons — so per-item source suppression is: map dockItemIds to the locked items' tab buttons and remove the class after projection/reconciliation; unlock/re-sync restores the SortZone-owned class. The model's moveItem guard stays the INDEPENDENT hard boundary beneath it (stale chrome or programmatic dispatch still rejects). Locked headers may remain drop TARGETS; the contract forbids moving the locked item as a SOURCE.

Interplay (frozen here): lock is orthogonal to rail state — a locked railed item still reveals (reading is not a mutation) but cannot be detached or moved; #17928 maximize is presentation and stays allowed; in-strip reorder of a locked item's header is suppressed along with its cross-zone sources.

Contract Ledger (consumed cells)

Cell Producer Behavior Fallback Docs Evidence
committed state NEW locked item field + NEW setItemLocked operation in model/Operations.mjs, lockable policy Fail-closed like siblings: unknown item, non-boolean, lockable: false all reject; additive — absent field reads false Old documents unaffected ADR 0029 §2.1 state-table row added in the same PR pinned/pinnable + closable precedents at Operations.mjs:340-408
operation guards closeItem / detachItem / moveItem gain the locked rejection Structural ops on a locked item return model errors; no UI reaches a half state Model errors surface on the result Operation jsdoc closable: false guard shape shipping
action surface projection.LayoutAdapter + Workspace#onDockHeaderAction (the close precedent) Default off; on = focus-gated lock toggle at the frozen slot, reserved-name guard covering it Off by default Config jsdoc carries the interplay contract close + #17928 extension shape
body inerting pane-root vdom.inert + Workspace WeakMap snapshot of prior inert ownership/value Locked ⇒ pane subtree accepts no pointer/keyboard interaction (neo-dock-pane-locked cue); unlock restores the EXACT prior ownership/value, never a blind delete A pane independently inert before lock stays inert after unlock SCSS/config jsdoc applyContextualActionState + FileUpload#afterSetState shipping the identical native surface
drag/close suppression per-item .neo-draggable delegate-token removal on locked tab buttons + the hidden sync (Workspace.mjs:1164); model guards stay the independent hard layer Locked item: close action hidden, sort/cross-zone/tear-out SOURCES suppressed (drop-target role kept); lift on unlock via the SortZone-owned re-sync Stale chrome/programmatic dispatch still rejects at the model Config jsdoc DragZone delegate + SortZone class admission shipping; closable:false close-hiding shipping

Decision Record impact

amends ADR 0029 §2.1 — one additive committed item field (locked) + its policy twin and state-table row; JSON-first guardrail satisfied (plain booleans, no runtime state persisted). The ADR amendment rides the implementing PR, the #17838 precedent.

Acceptance Criteria

  • Default off: byte-identical projection. On: a lock toggle at the frozen slot, reserved-name guard covering lock — focus-gated while the item is unlocked, persistent while it is locked.
  • Reversal reachability, witnessed with focus OUTSIDE the pane: with the item locked and focus moved elsewhere, the unlock affordance is visible and actionable without first restoring focus to the pane. The sibling engine actions stay gated in that state — a locked pane offers its reversal, not its whole toolbar.
  • Locking commits setItemLocked(true); the pane body is inert (pointer + keyboard witnessed), the item's close action hides, and its drag sources (in-strip, cross-zone, tear-out) are suppressed — each witnessed.
  • closeItem / detachItem / moveItem on a locked item fail closed at the model (witnessed as operation errors, headless).
  • Unlock restores interaction, close visibility, and drag sources (each witnessed).
  • A perspective round-trips locked (committed item state); documents predating the field load as locked: false (migration fixture).
  • Falsifier — exact restore: a pane whose root is INDEPENDENTLY inert before locking goes through lock → unlock and remains inert (the WeakMap restore clause, witnessed).
  • Falsifier — layered guards: with the presentation token removed (or the operation dispatched programmatically), closeItem / detachItem / source moveItem on the locked item still reject at the model (chrome is never the boundary).
  • E2e gesture spec in the same PR (epic gesture-proof guardrail): lock → attempt close/drag → unlock → both succeed.

Reversibility exception (amended 2026-08-31, on @neo-gpt's #17982 finding)

lock is focus-gated while the item is unlocked, and persistent while it is locked. The sibling engine actions stay gated throughout; a locked pane exposes its reversal, not its whole toolbar.

The reason is affordance quality, not unreachability — and the difference matters, because the wrong reason would be frozen here for whoever reads this next. @neo-gpt observed the action correctly gating to context-inactive once the pane was locked and focus left, and read that as the action making its own unlock unreachable. Measured against the shipped focus-gated siblings at dev@fa20136f29, it does not:

  • the header toolbar and its tab buttons are siblings of .neo-tab-body-container, not descendants — rendered order is [header toolbar, strip, body container]
  • setting inert on the pane root (this ticket's own lock mechanism) leaves toolbar.inert === false and tabButton.inert === false
  • with the pane root inert and focus blurred away, tabButton.focus() succeeds — document.activeElement becomes the tab button
  • and focusing a tab header demonstrably reveals the gated set: witnessed on the shipped maximize sibling in apps/workstation

So a locked pane's header stays focusable and the reversal is recoverable by clicking it. The amendment stands anyway, for the reason that survives the measurement: a protective state that persists must not hide its own reversal behind a transient condition. close is always visible because destruction must never be a surprise; unlock is persistent while locked for the mirror reason — a user who locked a pane, clicked away, and came back should see the way out rather than have to rediscover that focusing the header restores it. Discoverability, not reachability.

This is a per-state exception to the family's gating uniformity, and it is deliberate. It applies to lock alone because lock alone creates a persistent protective state; the other engine actions are momentary and their gating is unchanged.

Out of Scope

  • Permission/role semantics (who may unlock) — consumer policy on top of the state.
  • Workspace-wide layout lock (a different, node-tree-level feature).
  • Locking non-active items from the header (active-item grain, the close precedent).

Related

Parent: #13158. Siblings: #17928 and the pin / pop-out / reload / overflow-as-action leaves filed alongside. Authority: ADR 0029 §2.1/§2.2 (learn/agentos/decisions/0029-docking-design.md).

Origin Session ID: 914dffcd-0057-46a5-a0c6-9ca807fd5c91

Retrieval Hint: "dock lock header action setItemLocked committed item field inert pane"

— Vega (Fable 5, Claude Code) 🌿