Serves: #18456 — Workstation as a compact, idiomatic DockLayouts consumer. Persistence now ships; its user-facing surface does not.
The Problem
apps/workstation now saves its topology to IndexedDB automatically. Reload and you get your last state back — which is the right default, and it arrives with no way to tell that this is not the app's default layout, and no way back to it.
A user drags three panes around, reloads a week later, and sees a workspace that looks like the product. Nothing distinguishes "the app ships like this" from "you made it like this". There is no reset.
Adjacent, same root: the undo and redo buttons say only whether they are enabled, never how far they reach. A control that can be pressed but gives no sense of remaining depth is a control you press hopefully.
All three are one thing: the workstation persists and mutates state, and exposes none of that state to the person it belongs to.
The Architectural Reality — most of this already exists
⚠️ Read this section before estimating. Two of the three items are readouts of data the engine already publishes, not new machinery.
Undo/redo depth is already published. Neo.manager.Transaction (:526) publishes to every window bound into the Group:
canUndo · canRedo · historyCursor · historyDepth · historyLength
The topology bar's buttons currently bind only disabled:
bind: {disabled: () => TransactionManager.getProvider(me.topologyGroupId)?.getData('canUndo') !== true}So a depth affordance is another bind on data already flowing. ⚠️ Do not mirror it — that provider's own contract comment is explicit that getData resolves to the leaf's core.Config and re-runs on change, "no mirror to keep in step with setHistoryDepth, which publishes without a commit."
The default topology is addressable. apps/workstation/tour/denseWorkstation.mjs exports initialDocument as a frozen constant. Reset has a real target; it does not need to be reconstructed.
"Am I on the default?" is a comparison, not a heuristic — but ⚠️ NOT the one this ticket originally prescribed. An earlier revision of this body named topologyFingerprint (Persistence.mjs:208, schema neo.dock.topologyShape.v2) as the instrument. That was my assertion and it is wrong. WorkspaceDocument.computeShapeFingerprint's own docblock states it "deliberately contains no node ids, item ids, sizes, titles or window identity" — it is shape-only by contract, because it is the guardrail that must let two structurally identical layouts compare equal.
Measured at dev@2fdc57af69 against this app's real initialDocument, applying real Operations:
| user operation |
shape fingerprint |
TopologyDiff.diffDockDocuments |
| drag the main split 0.6/0.4 → 0.25/0.75 |
unchanged |
resizes |
| drag the left rail 0.11 → 0.4 |
unchanged |
NOTHING (#18579) |
| switch the active tab |
unchanged |
activeItemChanges |
| reorder a tab in place |
unchanged |
tabReorders |
| relocate a pane across nodes |
changed |
moves |
| close a pane |
changed |
removes |
So a fingerprint indicator would tell a user who dragged every boundary and reordered every tab that they are still on the default. The instrument is TopologyDiff.diffDockDocuments(initialDocument, live) — the engine's own semantic before/after compare, whose docblock argues explicitly against both raw equality (too strict) and the shape gate (too loose). Modified iff any mutation category is non-empty, recomputed from the live document, so undo-to-start clears it for free.
⚠️ THE INSTRUMENT IS SETTLED; THE REACTIVITY IS NOT — measured at dev@1c1ac5769e
The differ is the right instrument (below), but there is nothing to bind it to. dockModel is a plain class field — src/dashboard/dock/Workspace.mjs:411, dockModel = null — not a reactive config. A bind formatter reading me.dockModel never re-runs, so "derived, not a flag" has no reactive source and the indicator must be derived and published rather than bound.
The obvious hook is wrong, and it would ship a stale indicator. onDockZoneDocumentChange looks like the universal hook and the engine docblock calls it the advance path — but it has two branches (src/dashboard/dock/Workspace.mjs:2299), and the Group-managed one, which is the Workstation's actual path, returns without ever assigning me.dockModel:
if (set && me.topologyGroupId && document !== me.dockModel) {
…
return pending // <- no me.dockModel assignment on this branch
}
const projection = me.projectDockZoneDocument(…);
me.dockModel = document; // <- only the direct branch writes it
An override that recomputed there would read the previous document and publish the wrong answer on every Group commit.
The single writer for the main workspace is registerMainWorkspace()'s setter — apps/workstation/view/Workspace.mjs:788, setDocument: document => me.dockModel = document. Every Group-managed commit routes through it, undo and redo included, which is what makes the ticket's "undo back to the start clears it" requirement fall out for free rather than needing its own path.
So AC-2's mechanism is: compute the diff, publish to the view's own stateProvider, from exactly two call sites — that setter (every committed change) and construct() (boot, where :479 seeds dockModel from a persisted initialTopology that may already be modified). Missing the second ships an indicator that reads "default" on a cold-hydrated custom layout, which is the ticket's whole scenario.
⚠️ This is a published derivation, NOT the mirror the Avoided Traps warn against. That warning is about historyDepth, which the Transaction manager already publishes reactively; nothing publishes "is this the default arrangement", so deriving it is not duplicating a publisher.
The engine question, raised and deliberately not assumed: whether dockModel should be a reactive config at all. It would let every consumer bind topology directly instead of each one wiring its own publisher. That is src/dashboard/dock/Workspace.mjs — a shared base class with Demo A, Demo B and Workstation on it — so it is higher blast radius than this consumer ticket and belongs to whoever owns that surface, not to #18553.
⚠️ One category is missing and it is filed as #18579: an edge-zone extent change is invisible to the differ too, so a rail drag reads as unmodified. That is an engine defect with its own blast radius (RestorePlanner plans an empty restore; the diff_dock_topology MCP tool reports no change), not a consumer workaround — this ticket's AC-2 depends on it and must not hand-write an extent comparison here.
A fresh-root path exists and is the wrong one. Viewport.showRootFailure() already renders "Start a new workspace" → startBlankRoot(). But it is reachable only on a load failure, and it admits {topologyIdentity: {}} — blank, not default. Reset must not route through it.
The Fix
Surface the state that is already there:
Depth on the controls — undo/redo show how many steps remain, from historyCursor / historyLength. Split to #18558.
- A modified-from-default indicator — derived from the fingerprint comparison, not a flag.
- A reset that restores
initialDocument, reachable in normal use rather than only after a failure.
AC
AC-1 and AC-1b are split out to #18558 and are struck through below — they were marked independently shippable, they carry their own ACs there, and a split replaces scope rather than adding to it. What remains here is the default/reset half: AC-2 through AC-6.
AC-1 ⚠️ Independently shippable, and should land first. Undo and redo expose remaining depth as button badges — button.Base already ships badgeText_ and badgePosition_ (badgePositions: ['bottom-left','bottom-right','top-left','top-right']) rendering a .neo-button-badge node. So this is bind: {badgeText: …} over the already-published historyCursor / historyLength / historyDepth: no mirrored state, no new publisher, and no new affordance invented. A control at depth 0 stays disabled as it does today. (@tobiu's mechanism, not a substitute for it.)
~~AC-1b ⚠️ The topology bar's buttons are under-tokenised, and it is one omission producing two complaints. resources/scss/src/apps/workstation/Workspace.scss:137 sets only --button-background-color, --button-background-color-hover, --button-border, --button-height, --button-text-color. It sets no glyph token and no disabled token, so:
- icons carry no themed colour —
--button-glyph-color / -hover / -disabled are all unset;
- the disabled state falls to the UA cascade.
button/Base.scss:367 documents exactly this: "Projecting the native disabled attribute activates the UA button:disabled cascade… Chromium exposed both sides of the gap: native-disabled painted GrayText… color inherits because the child glyph/text nodes own themed colors." The engine's contract is that the consumer supplies those child colours; Workstation does not, so the reader gets GrayText.
The bar therefore needs, on .workstation-topologybar .neo-button: --button-glyph-color, --button-glyph-color-hover, --button-glyph-color-disabled, --button-text-color-disabled, --button-background-color-disabled, --button-border-disabled, --button-opacity-disabled, plus --button-badge-background-color and --button-badge-color for AC-1. All exist already — none of this is new engine surface.~~
AC-2 The workstation shows whether its live topology differs from initialDocument, derived from TopologyDiff.diffDockDocuments rather than a dirty flag or the shape fingerprint. ⚠️ Blocked on #18579 for edge-zone extents: until that lands the differ cannot see a rail drag, and shipping the indicator before it means shipping one that lies on the flagship's own three resizable edges.
AC-2b ⚠️ Both blind spots are witnessed, not assumed. An arm drives one split resize AND one rail drag and asserts the indicator turns on for each. The rail arm is the one that fails today; without it, AC-2 passes on a mechanism that is still half-blind.
⚠️ RESET: THREE MEASURED TRAPS — read before writing it, measured at dev@1c1ac5769e
Persistence is commit-driven and automatic: TopologyLibrary.attachGroup registers
commit: data => this.persistCurrent(), so every Group commit captures the keyed topology and
writes it to IndexedDB under layoutId — no explicit save needed. Three consequences, each of
which produces a wrong reset if missed:
1. layoutId: 'default' is a NAME, not a claim. The auto-saved working record is stored under
that id. There is no stored copy of the shipped arrangement — it exists only as
initialDocument in code. Anything that reads the persisted record to decide "am I on the
default?" is reading the user's own layout back to them.
2. The most copyable precedent does not persist. TourController resets with
workspace.dockModel = WorkspaceDocument.clone(initialDocument) (:398, :485). That is a direct
field assignment: it bypasses the Group, no commit event fires, so auto-save never runs. A
reset built that way leaves the OLD topology in IndexedDB and it returns on the next reload —
exactly the failure AC-5 was written to catch, sitting in this repo as the nearest example.
3. Reset cannot be a single write once a second window exists.
TopologySeams.commitDockTopologyWorkspaces refuses unless the commit names exactly the
registered keys:
if (Object.keys(workspaces).length !== keys.length || keys.some(key => !Object.hasOwn(workspaces, key)))
return {errors: ['the topology must name exactly the registered workspace keys']}
So {main: initialDocument} is rejected while a popup is registered. The removal path is
WorkspaceSet.unregister(workspaceId) ("explicitly retires a participant"), so reset must retire
the popups first and commit last. The ordering is load-bearing.
⚠️ And that combination is the real hazard. Because auto-save fires on every commit, a
multi-step reset writes its intermediate states to IndexedDB as it goes. If it fails partway — a
window refuses to close, a commit rolls back — the user is left with a persisted partial reset,
and their original arrangement was already overwritten by the auto-save on step one. Reset has to
be recoverable by construction, not merely correct on the happy path. AC-4's confirmation is not
sufficient cover for this: the user consented to a reset, not to a half-one they cannot undo.
- AC-3 A reset restores
initialDocument and is reachable in normal use. It does not go through startBlankRoot(), which produces a blank workspace and exists for the load-failure path. ⚠️ It must commit through the Group (trap 2) and retire non-main workspaces before committing (trap 3).
Clarified 2026-09-12, after TWO corrections. First: restoring initialDocument beside an untouched popup leaves a torn-out pane owned twice — invalid topology, refused capture, a reset that reports success while being unpersistable (@neo-gpt-emmy). I then implemented trap 3 literally, retiring the other participants before committing, and justified it with a claim that a workspace handing back its only pane has no valid document. That claim was false — I had established it by pruning the last tabs node, which leaves root dangling, and generalised from one way of emptying a document to all of them. An edge root with no zones validates and projects.
And retirement was actively worse: unregistering happens outside the transaction, so undo cannot restore it — the commit rolls main back to a document whose pane lives in a workspace that no longer exists, leaving the pane owned by nobody. Trap 3's prescription is therefore superseded on this point: the panes are reclaimed into the SAME write that restores main, every participant is retained, and a workspace left owning nothing takes the empty edge-root document. One commit, one history row, undo returns the pane to its window. Window lifetime stays out of this AC's scope — the native window is never closed by a reset, and now never unregistered either.
- AC-4 ⚠️ REVISED — I measured this AC's own premise and it is false for the shape that landed. It read: "Reset is destructive and confirms. It discards a user's arrangement, which is exactly the state this ticket exists to make visible. A one-click reset beside an undo control that no longer reaches past it is a trap." That assumed reset would be history-clearing. Measured at
dev@434480f174: WorkspaceSet.write defaults to cursorAction: 'append' and commitDockTopologyWorkspaces does not override it, so a reset lands as one ordinary history row and undo reverses it exactly as it reverses a dragged splitter. The arm drives a real committed mutation, resets, then undoes past the reset and asserts the replaced arrangement returns. So the trap does not exist, and a confirmation dialog would guard nothing — it would also be inconsistent with the rest of this workspace, where closing a pane is equally destructive-on-reload and confirms nothing. What discharges this AC is the undoability, asserted — not a dialog. The residual it honestly does NOT cover: reset followed by a reload loses the arrangement, because auto-save has already written it. That is true of every committed operation here, not of reset specifically, and it belongs to the auto-save design rather than to this AC.
- AC-5 State what reset does to the persisted record, not only the live one: re-seeded, cleared-and-reseeded, or a new layout id. A reset that leaves the old topology in IndexedDB reappears on the next reload.
- AC-6 ⚠️ Both directions witnessed. The indicator must be absent on a freshly-seeded default and present after one committed operation — an indicator that is always on is indistinguishable from one that works, and this ticket's whole point is a state readout being trustworthy.
Out of Scope
Named perspectives and the perspective library, which already exist and are a different feature — this is about the implicit auto-saved state, not saved-and-named ones. Cross-window topology semantics. Whether auto-save should be the default at all: it is, and this makes it legible rather than reversing it. The dock engine's own persistence contract.
Avoided Traps
⚠️ Do not restyle by overriding .neo-button paint directly in the workstation sheet — the disabled state is governed by the token set the engine already exposes, and button/Base.scss explains why color inherits rather than being set at the root. Reaching past the tokens re-opens the UA-cascade gap that comment was written to close. Do not invent a step-count affordance: badgeText exists. Do not add a historyDepth mirror on the workspace — it is published reactively and a mirror is the drift setHistoryDepth's own comment warns about. Do not implement "modified" as a boolean set on the first operation: undo back to the start should clear it, and a flag will not, whereas a recomputed comparison does for free. ⚠️ Do not use computeShapeFingerprint for that comparison — measured above, it is shape-only by contract and misses every geometry and in-place ordering change. Do not work around #18579 by hand-writing an extent comparison in this app: three call sites read that differ, and a consumer-side patch leaves the restore planner and the MCP tool exactly as wrong. Do not reuse startBlankRoot() for reset — blank is not default, and conflating them ships a reset that deletes the product's own layout. Do not fold this into the perspective library: a user who never names a perspective still needs a way home.
Decision Record impact
none — consumer-side surfacing of published state. If reset turns out to need an engine-side seam, that is a finding worth raising rather than a change to assume.
Sweeps and ownership
Live latest-open sweep plus all-state searches on reset / perspective / default / undo-depth / persistence: nothing equivalent open or closed. Parent epic #18456 confirmed epic-labelled.
Raised by @tobiu after the auto-save landed — "there is no info that this is not the 'default state' (perspective) of the app, and there is no reset button… the undo and redo buttons should indicate how many steps there are." Filed by @neo-opus-grace with the existing-machinery survey above, so the implementer starts from what is already published rather than building it twice.
Retrieval Hint: workstation IndexedDB auto-save reset default initialDocument topologyFingerprint undo redo historyDepth historyCursor
Origin Session ID: c4dc8abc-31fa-4ecd-9aef-5209ccfd16c0
Serves:#18456 — Workstation as a compact, idiomatic DockLayouts consumer. Persistence now ships; its user-facing surface does not.The Problem
apps/workstationnow saves its topology to IndexedDB automatically. Reload and you get your last state back — which is the right default, and it arrives with no way to tell that this is not the app's default layout, and no way back to it.A user drags three panes around, reloads a week later, and sees a workspace that looks like the product. Nothing distinguishes "the app ships like this" from "you made it like this". There is no reset.
Adjacent, same root: the undo and redo buttons say only whether they are enabled, never how far they reach. A control that can be pressed but gives no sense of remaining depth is a control you press hopefully.
All three are one thing: the workstation persists and mutates state, and exposes none of that state to the person it belongs to.
The Architectural Reality — most of this already exists
⚠️ Read this section before estimating. Two of the three items are readouts of data the engine already publishes, not new machinery.
Undo/redo depth is already published.
Neo.manager.Transaction(:526) publishes to every window bound into the Group:The topology bar's buttons currently bind only
disabled:// apps/workstation/view/Workspace.mjs — createTopologyBar() bind: {disabled: () => TransactionManager.getProvider(me.topologyGroupId)?.getData('canUndo') !== true}So a depth affordance is another
bindon data already flowing. ⚠️ Do not mirror it — that provider's own contract comment is explicit thatgetDataresolves to the leaf'score.Configand re-runs on change, "no mirror to keep in step withsetHistoryDepth, which publishes without a commit."The default topology is addressable.
apps/workstation/tour/denseWorkstation.mjsexportsinitialDocumentas a frozen constant. Reset has a real target; it does not need to be reconstructed."Am I on the default?" is a comparison, not a heuristic — but ⚠️ NOT the one this ticket originally prescribed. An earlier revision of this body named
topologyFingerprint(Persistence.mjs:208, schemaneo.dock.topologyShape.v2) as the instrument. That was my assertion and it is wrong.WorkspaceDocument.computeShapeFingerprint's own docblock states it "deliberately contains no node ids, item ids, sizes, titles or window identity" — it is shape-only by contract, because it is the guardrail that must let two structurally identical layouts compare equal.Measured at
dev@2fdc57af69against this app's realinitialDocument, applying realOperations:TopologyDiff.diffDockDocumentsresizesactiveItemChangestabReordersmovesremovesSo a fingerprint indicator would tell a user who dragged every boundary and reordered every tab that they are still on the default. The instrument is
TopologyDiff.diffDockDocuments(initialDocument, live)— the engine's own semantic before/after compare, whose docblock argues explicitly against both raw equality (too strict) and the shape gate (too loose). Modified iff any mutation category is non-empty, recomputed from the live document, so undo-to-start clears it for free.⚠️ One category is missing and it is filed as #18579: an edge-zone
extentchange is invisible to the differ too, so a rail drag reads as unmodified. That is an engine defect with its own blast radius (RestorePlannerplans an empty restore; thediff_dock_topologyMCP tool reports no change), not a consumer workaround — this ticket's AC-2 depends on it and must not hand-write an extent comparison here.A fresh-root path exists and is the wrong one.
Viewport.showRootFailure()already renders "Start a new workspace" →startBlankRoot(). But it is reachable only on a load failure, and it admits{topologyIdentity: {}}— blank, not default. Reset must not route through it.The Fix
Surface the state that is already there:
Depth on the controls — undo/redo show how many steps remain, fromSplit to #18558.historyCursor/historyLength.initialDocument, reachable in normal use rather than only after a failure.AC
AC-1 ⚠️ Independently shippable, and should land first. Undo and redo expose remaining depth as button badges —button.Basealready shipsbadgeText_andbadgePosition_(badgePositions: ['bottom-left','bottom-right','top-left','top-right']) rendering a.neo-button-badgenode. So this isbind: {badgeText: …}over the already-publishedhistoryCursor/historyLength/historyDepth: no mirrored state, no new publisher, and no new affordance invented. A control at depth 0 stays disabled as it does today. (@tobiu's mechanism, not a substitute for it.)~~AC-1b ⚠️ The topology bar's buttons are under-tokenised, and it is one omission producing two complaints.
resources/scss/src/apps/workstation/Workspace.scss:137sets only--button-background-color,--button-background-color-hover,--button-border,--button-height,--button-text-color. It sets no glyph token and no disabled token, so:--button-glyph-color/-hover/-disabledare all unset;button/Base.scss:367documents exactly this: "Projecting the native disabled attribute activates the UAbutton:disabledcascade… Chromium exposed both sides of the gap: native-disabled painted GrayText… color inherits because the child glyph/text nodes own themed colors." The engine's contract is that the consumer supplies those child colours; Workstation does not, so the reader getsGrayText.The bar therefore needs, on
.workstation-topologybar .neo-button:--button-glyph-color,--button-glyph-color-hover,--button-glyph-color-disabled,--button-text-color-disabled,--button-background-color-disabled,--button-border-disabled,--button-opacity-disabled, plus--button-badge-background-colorand--button-badge-colorfor AC-1. All exist already — none of this is new engine surface.~~AC-2 The workstation shows whether its live topology differs from
initialDocument, derived fromTopologyDiff.diffDockDocumentsrather than a dirty flag or the shape fingerprint. ⚠️ Blocked on #18579 for edge-zone extents: until that lands the differ cannot see a rail drag, and shipping the indicator before it means shipping one that lies on the flagship's own three resizable edges.AC-2b ⚠️ Both blind spots are witnessed, not assumed. An arm drives one split resize AND one rail drag and asserts the indicator turns on for each. The rail arm is the one that fails today; without it, AC-2 passes on a mechanism that is still half-blind.
initialDocumentand is reachable in normal use. It does not go throughstartBlankRoot(), which produces a blank workspace and exists for the load-failure path. ⚠️ It must commit through the Group (trap 2) and retire non-main workspaces before committing (trap 3).dev@434480f174:WorkspaceSet.writedefaults tocursorAction: 'append'andcommitDockTopologyWorkspacesdoes not override it, so a reset lands as one ordinary history row andundoreverses it exactly as it reverses a dragged splitter. The arm drives a real committed mutation, resets, then undoes past the reset and asserts the replaced arrangement returns. So the trap does not exist, and a confirmation dialog would guard nothing — it would also be inconsistent with the rest of this workspace, where closing a pane is equally destructive-on-reload and confirms nothing. What discharges this AC is the undoability, asserted — not a dialog. The residual it honestly does NOT cover: reset followed by a reload loses the arrangement, because auto-save has already written it. That is true of every committed operation here, not of reset specifically, and it belongs to the auto-save design rather than to this AC.Out of Scope
Named perspectives and the perspective library, which already exist and are a different feature — this is about the implicit auto-saved state, not saved-and-named ones. Cross-window topology semantics. Whether auto-save should be the default at all: it is, and this makes it legible rather than reversing it. The dock engine's own persistence contract.
Avoided Traps
⚠️ Do not restyle by overriding
.neo-buttonpaint directly in the workstation sheet — the disabled state is governed by the token set the engine already exposes, andbutton/Base.scssexplains whycolorinherits rather than being set at the root. Reaching past the tokens re-opens the UA-cascade gap that comment was written to close. Do not invent a step-count affordance:badgeTextexists. Do not add ahistoryDepthmirror on the workspace — it is published reactively and a mirror is the driftsetHistoryDepth's own comment warns about. Do not implement "modified" as a boolean set on the first operation: undo back to the start should clear it, and a flag will not, whereas a recomputed comparison does for free. ⚠️ Do not usecomputeShapeFingerprintfor that comparison — measured above, it is shape-only by contract and misses every geometry and in-place ordering change. Do not work around #18579 by hand-writing an extent comparison in this app: three call sites read that differ, and a consumer-side patch leaves the restore planner and the MCP tool exactly as wrong. Do not reusestartBlankRoot()for reset — blank is not default, and conflating them ships a reset that deletes the product's own layout. Do not fold this into the perspective library: a user who never names a perspective still needs a way home.Decision Record impact
none— consumer-side surfacing of published state. If reset turns out to need an engine-side seam, that is a finding worth raising rather than a change to assume.Sweeps and ownership
Live latest-open sweep plus all-state searches on reset / perspective / default / undo-depth / persistence: nothing equivalent open or closed. Parent epic #18456 confirmed
epic-labelled.Raised by @tobiu after the auto-save landed — "there is no info that this is not the 'default state' (perspective) of the app, and there is no reset button… the undo and redo buttons should indicate how many steps there are." Filed by @neo-opus-grace with the existing-machinery survey above, so the implementer starts from what is already published rather than building it twice.
Retrieval Hint:
workstation IndexedDB auto-save reset default initialDocument topologyFingerprint undo redo historyDepth historyCursorOrigin Session ID: c4dc8abc-31fa-4ecd-9aef-5209ccfd16c0