diff --git a/apps/workstation/tour/denseWorkstation.mjs b/apps/workstation/tour/denseWorkstation.mjs index e13c42fdb6..4cc9a036ac 100644 --- a/apps/workstation/tour/denseWorkstation.mjs +++ b/apps/workstation/tour/denseWorkstation.mjs @@ -14,7 +14,7 @@ * @type {Object} */ export const initialDocument = Object.freeze({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { scale : {componentRef: 'Scale', title: '100k Operations Matrix', kind: 'grid'}, diff --git a/apps/workstation/view/Workspace.mjs b/apps/workstation/view/Workspace.mjs index dd8403467f..51c2c0c2d5 100644 --- a/apps/workstation/view/Workspace.mjs +++ b/apps/workstation/view/Workspace.mjs @@ -1,29 +1,30 @@ import Component from '../../../src/component/Base.mjs'; import Container from '../../../src/container/Base.mjs'; -import DockWorkspace from '../../../src/dashboard/DockWorkspace.mjs'; +import DockWorkspace from '../../../src/dashboard/dock/Workspace.mjs'; import Feed from '../store/Feed.mjs'; import FeedPane from './FeedPane.mjs'; import Scale from '../store/Scale.mjs'; import ScalePane from './ScalePane.mjs'; -import DockDragAffordances from '../../../src/dashboard/DockDragAffordances.mjs'; -import DockDropIndicators from '../../../src/dashboard/DockDropIndicators.mjs'; -import DockLayoutAdapter from '../../../src/dashboard/DockLayoutAdapter.mjs'; -import DockPerspectiveStore from '../../../src/dashboard/DockPerspectiveStore.mjs'; -import DockPreview from '../../../src/dashboard/DockPreview.mjs'; -import DockProjectionReconciler from '../../../src/dashboard/DockProjectionReconciler.mjs'; +import DockDragAffordances from '../../../src/dashboard/dock/interaction/DragAffordances.mjs'; +import DockDropIndicators from '../../../src/dashboard/dock/interaction/DropIndicators.mjs'; +import DockLayoutAdapter from '../../../src/dashboard/dock/projection/LayoutAdapter.mjs'; +import PerspectiveLibrary from '../../../src/dashboard/dock/persistence/PerspectiveLibrary.mjs'; +import DockPreview from '../../../src/dashboard/dock/interaction/Preview.mjs'; +import DockProjectionReconciler from '../../../src/dashboard/dock/projection/Reconciler.mjs'; import DockService from '../../../src/ai/client/DockService.mjs'; -import DockZoneModel from '../../../src/dashboard/DockZoneModel.mjs'; +import Document from '../../../src/dashboard/dock/model/Document.mjs'; +import Operations from '../../../src/dashboard/dock/model/Operations.mjs'; import InteractionService from '../../../src/ai/client/InteractionService.mjs'; import StateProvider from '../../../src/state/Provider.mjs'; import TourRunner from '../../../src/ai/client/TourRunner.mjs'; -import {createDockTearOutHandlers} from '../../../src/dashboard/DockTearOut.mjs'; +import {createDockTearOutHandlers} from '../../../src/dashboard/dock/window/TearOut.mjs'; import { createDockVesselEmbodiment, createDockVesselProxyEmbodiment -} from '../../../src/dashboard/DockVesselEmbodiment.mjs'; -import {createDockWorkspaceSet} from '../../../src/dashboard/DockWorkspaceSet.mjs'; -import {createVesselParkHandlers} from '../../../src/dashboard/DockVesselPark.mjs'; -import {previewToOperation} from '../../../src/dashboard/dockPreviewContract.mjs'; +} from '../../../src/dashboard/dock/window/VesselEmbodiment.mjs'; +import {createDockWorkspaceSet} from '../../../src/dashboard/dock/window/WorkspaceSet.mjs'; +import {createVesselParkHandlers} from '../../../src/dashboard/dock/window/VesselPark.mjs'; +import {previewToOperation} from '../../../src/dashboard/dock/model/PreviewContract.mjs'; import {workstationTourScript, initialDocument} from '../tour/denseWorkstation.mjs'; import '../../../src/button/Base.mjs'; import '../../../src/tab/Container.mjs'; @@ -65,7 +66,7 @@ const paneStories = Object.freeze({ * hydration, and OffscreenCanvas registration; this class owns only composition and story. * * @class Workstation.view.Workspace - * @extends Neo.dashboard.DockWorkspace + * @extends Neo.dashboard.dock.Workspace */ class Workspace extends DockWorkspace { /** @@ -186,7 +187,7 @@ class Workspace extends DockWorkspace { * list, and restore against this workspace with no service-side change. Restore rides the * store's migration-honest `loadPerspective` plus this view's `onDockZoneDocumentChange` * commit seam — the same path `execute_dock_operation` commits through. - * @member {Neo.dashboard.DockPerspectiveStore|null} perspectiveStore=null + * @member {Neo.dashboard.dock.persistence.PerspectiveLibrary|null} perspectiveStore=null */ perspectiveStore = null /** @@ -196,7 +197,7 @@ class Workspace extends DockWorkspace { /** * The shared drag-affordance gesture controller (producer lifecycle, memoized geometry, * release-truth drop, generation guards) — composed at construct, destroyed with the view. - * @member {Neo.dashboard.DockDragAffordances|null} dragAffordances=null + * @member {Neo.dashboard.dock.interaction.DragAffordances|null} dragAffordances=null */ dragAffordances = null /** @@ -294,14 +295,14 @@ class Workspace extends DockWorkspace { /** * Target-side adapters keyed by stable workspace identity. The main workspace registers during * construction; vessel targets register only after their exact child window joins. - * @member {Map} crossWindowParticipations + * @member {Map} crossWindowParticipations * @protected */ crossWindowParticipations = new Map() /** * Readiness of the main workspace's late-bound participation. The dynamic import keeps the * manager.Window singleton behind the app/harness construction boundary. - * @member {Promise} crossWindowParticipationPromise + * @member {Promise} crossWindowParticipationPromise * @protected */ crossWindowParticipationPromise = null @@ -421,9 +422,9 @@ class Workspace extends DockWorkspace { let me = this; - me.dockModel = DockZoneModel.clone(initialDocument); + me.dockModel = Document.clone(initialDocument); me.dockService = Neo.create(DockService, {}); - me.perspectiveStore = Neo.create(DockPerspectiveStore, {}); + me.perspectiveStore = Neo.create(PerspectiveLibrary, {}); // Cross-window hit testing reads manager.Window as its one geometry authority. Movement // snapshots alone go stale after a main-window resize, so this render target publishes @@ -1003,13 +1004,13 @@ class Workspace extends DockWorkspace { * @param {Object} data * @param {String|Number} data.windowId * @param {String} data.workspaceId - * @returns {Promise} + * @returns {Promise} * @protected */ async createCrossWindowParticipation({windowId, workspaceId}) { let me = this, isMain = workspaceId === Workspace.MAIN_WORKSPACE_ID, - Participation = (await import('../../../src/dashboard/DockCrossWindowParticipation.mjs')).default; + Participation = (await import('../../../src/dashboard/dock/window/Participation.mjs')).default; if (me.isDestroyed) return null; @@ -1133,7 +1134,7 @@ class Workspace extends DockWorkspace { !pane || pane.isDestroyed || !me.dockModel?.items?.[itemId] || - DockZoneModel.findContainingTabsId(me.dockModel, itemId) + Document.findContainingTabsId(me.dockModel, itemId) ) { continue } @@ -1158,7 +1159,7 @@ class Workspace extends DockWorkspace { * stable workspace target share a per-window coordinator slot, so the stable target must win * the final registration write after every projection. * @param {String} workspaceId - * @returns {Promise} + * @returns {Promise} * @protected */ async refreshCrossWindowParticipation(workspaceId) { @@ -1310,7 +1311,7 @@ class Workspace extends DockWorkspace { if (!item || !tabsNodeId) return null; return { - schema: DockZoneModel.SCHEMA, + schema: Document.SCHEMA, root : `workstation-vessel-root:${itemId}`, items : {}, nodes : { @@ -1354,7 +1355,7 @@ class Workspace extends DockWorkspace { * @summary Keeps semantic node identity paired with its actual rendered component. * @param {String} workspaceId * @param {String} targetNodeId - * @returns {{host: Neo.component.Base, renderer: Neo.dashboard.DockPreview, + * @returns {{host: Neo.component.Base, renderer: Neo.dashboard.dock.interaction.Preview, * target: Neo.component.Base, windowId: (String|Number)}|null} * @protected */ @@ -1602,7 +1603,7 @@ class Workspace extends DockWorkspace { document = workspaceId === Workspace.MAIN_WORKSPACE_ID || state?.committed ? me.getWorkspaceDocument(workspaceId) : null, - result = document && DockZoneModel.applyOperation(document, descriptor); + result = document && Operations.applyOperation(document, descriptor); if (!result || result.errors.length) return result; @@ -1645,7 +1646,7 @@ class Workspace extends DockWorkspace { if (mainToVessel && !targetState.committed) { if (descriptor.operation !== 'transferItem' || me.workspaceSet.has(targetWorkspaceId)) return false; - const ownerTransfer = DockZoneModel.transferItem(sourceDocument, targetDocument, { + const ownerTransfer = Operations.transferItem(sourceDocument, targetDocument, { itemId: targetState.itemId, sourceWorkspaceId, targetWorkspaceId, @@ -1700,7 +1701,7 @@ class Workspace extends DockWorkspace { const receipt = me.lastCrossWindowTransfer = { applied : true, closeRequested: false, - descriptor : DockZoneModel.clone(descriptor), + descriptor : Document.clone(descriptor), phases : ['documents-adopted'], reconciled : false, sourceWorkspaceId, @@ -1903,7 +1904,7 @@ class Workspace extends DockWorkspace { : null, targetNodeId = storedHome || Object.entries(me.dockModel.nodes || {}).find(([, node]) => node.type === 'tabs')?.[0], - nodeId = DockZoneModel.resolveStackRoot(state.document); + nodeId = Document.resolveStackRoot(state.document); if (!nodeId || !targetNodeId) { me.lastCrossWindowTransfer = { @@ -1920,7 +1921,7 @@ class Workspace extends DockWorkspace { targetWorkspaceId: Workspace.MAIN_WORKSPACE_ID, target : {targetNodeId, placement: {kind: 'tab-into'}} }, - result = DockZoneModel.transferNode(state.document, me.dockModel, descriptor); + result = Operations.transferNode(state.document, me.dockModel, descriptor); if (result.errors.length) { me.lastCrossWindowTransfer = {applied: false, errors: result.errors}; @@ -1947,7 +1948,7 @@ class Workspace extends DockWorkspace { me.lastCrossWindowTransfer = { applied : true, - descriptor : DockZoneModel.clone(descriptor), + descriptor : Document.clone(descriptor), recoveredOnDisconnect: true, sourceWorkspaceId : workspaceId, targetWorkspaceId : Workspace.MAIN_WORKSPACE_ID, @@ -2340,7 +2341,7 @@ class Workspace extends DockWorkspace { out = null; try { - me.dockModel = DockZoneModel.clone(initialDocument); + me.dockModel = Document.clone(initialDocument); await me.refreshDockWorkspace(null, me.dockModel, {geometryOnly: true}); // The entry projection is finished and the replay has not begun. Published because a @@ -2354,7 +2355,7 @@ class Workspace extends DockWorkspace { await me.refreshPromise; - out = {...result, document: DockZoneModel.clone(me.dockModel), phases: {entryCompletedAt}}; + out = {...result, document: Document.clone(me.dockModel), phases: {entryCompletedAt}}; // A structured runner failure is a primary outcome the caller must receive intact — // only a genuinely clean replay may let a restore failure replace the return. @@ -2512,7 +2513,7 @@ class Workspace extends DockWorkspace { me.cueSettlements.clear(); me.lastTourReceipt = null; me.progressPromise = Promise.resolve(); - me.dockModel = DockZoneModel.clone(initialDocument); + me.dockModel = Document.clone(initialDocument); await me.setPipProgress(0); await me.refreshDockWorkspace(null, me.dockModel, {geometryOnly: true}); @@ -2556,7 +2557,7 @@ class Workspace extends DockWorkspace { receipt = { completed : runnerResult.completed && errors.length === 0, cueReceipts: me.cueReceipts.map(entry => ({cue: {...entry.cue}, receipt: entry.receipt})), - document : DockZoneModel.clone(me.dockModel), + document : Document.clone(me.dockModel), elapsedMs, errors, feed : { @@ -2666,7 +2667,7 @@ class Workspace extends DockWorkspace { * @summary Opens one theme-correct vessel window for a mid-gesture boundary exit. * * Reuses the workstation viewport's `?popout=` pure-pane-host mode. The granted child - * immediately carries the same live pane through {@link Neo.dashboard.DockVesselEmbodiment}; + * immediately carries the same live pane through {@link Neo.dashboard.dock.window.VesselEmbodiment}; * it owns no workspace document. Fail-closed per the admission contract: `windowOpen` returns * a BOOLEAN (a blocked popup never throws), and any falsy/throwing acquisition returns `null` * so the gesture degrades to its in-window fallback. The theme bootstrap is part of that @@ -2833,7 +2834,7 @@ class Workspace extends DockWorkspace { admission && (admission.invalidated = true); if (embodiedWindowId && me.tearOutEmbodiment.isStaged(itemId)) { - const sourceOwns = Boolean(DockZoneModel.findContainingTabsId(me.dockModel, itemId)), + const sourceOwns = Boolean(Document.findContainingTabsId(me.dockModel, itemId)), settled = me.tearOutEmbodiment[sourceOwns ? 'restore' : 'promote']({ itemId, windowId: embodiedWindowId }); @@ -3280,7 +3281,7 @@ class Workspace extends DockWorkspace { applyTearOutOperation(descriptor) { let me = this, isDetach = descriptor?.operation === 'detachItem', - captured = isDetach ? DockZoneModel.captureItemPlacement(me.dockModel, descriptor.itemId) : null, + captured = isDetach ? Document.captureItemPlacement(me.dockModel, descriptor.itemId) : null, result; captured && (me.tearOutPlacements[descriptor.itemId] = captured); @@ -3425,7 +3426,7 @@ class Workspace extends DockWorkspace { delete me.tearOutPlacements[itemId]; - if (!doc.items?.[itemId] || !fallback || DockZoneModel.findContainingTabsId(doc, itemId)) { + if (!doc.items?.[itemId] || !fallback || Document.findContainingTabsId(doc, itemId)) { return } @@ -3605,7 +3606,7 @@ class Workspace extends DockWorkspace { me.tearOutRetirements.add(itemId); if (me.tearOutEmbodiment.isStaged(itemId)) { - const sourceOwns = Boolean(DockZoneModel.findContainingTabsId(me.dockModel, itemId)); + const sourceOwns = Boolean(Document.findContainingTabsId(me.dockModel, itemId)); me.tearOutEmbodiment[sourceOwns ? 'restore' : 'promote']({itemId, windowId: entry.windowId}) } @@ -3835,7 +3836,7 @@ class Workspace extends DockWorkspace { // terminal. The window inset below remains a visual-stage safety rule. sortZone.enableProxyToPopup = false; - let documentBefore = DockZoneModel.clone(document), + let documentBefore = Document.clone(document), startX = buttonRect.x + buttonRect.width / 2, startY = buttonRect.y + buttonRect.height / 2, directionX = startX > window.innerRect.width / 2 ? -1 : 1, @@ -4040,7 +4041,7 @@ class Workspace extends DockWorkspace { {sortZone, targetId: 'document.body'} ), retired = await waitUntil(overlaysRetired), - documentAfter = DockZoneModel.clone(me.dockModel), + documentAfter = Document.clone(me.dockModel), unchanged = JSON.stringify(documentAfter) === JSON.stringify(documentBefore), popupConfig = restoreProxyPopupConfig(); @@ -4063,8 +4064,8 @@ class Workspace extends DockWorkspace { } let descriptor = previewToOperation(finalPreview), - expectedResult = descriptor && DockZoneModel.applyOperation( - DockZoneModel.clone(documentBefore), + expectedResult = descriptor && Operations.applyOperation( + Document.clone(documentBefore), descriptor ); @@ -4072,7 +4073,7 @@ class Workspace extends DockWorkspace { throw new Error('final active preview did not resolve to one valid document operation') } - let expectedDocument = DockZoneModel.clone(expectedResult.document), + let expectedDocument = Document.clone(expectedResult.document), expectedSerialized = JSON.stringify(expectedDocument); await me.interactionService.simulateEvent({events: [{ @@ -4086,7 +4087,7 @@ class Workspace extends DockWorkspace { settled && await me.refreshPromise; - let documentAfter = DockZoneModel.clone(me.dockModel), + let documentAfter = Document.clone(me.dockModel), documentMatchesPreview = JSON.stringify(documentAfter) === expectedSerialized, retired = await waitUntil(overlaysRetired), popupConfig = restoreProxyPopupConfig(), @@ -4139,7 +4140,7 @@ class Workspace extends DockWorkspace { * `parkedItemId`, which additionally requires the exact source vessel to be strictly parked. * @param {Object} context * @param {String|null} [context.parkedItemId=null] - * @param {Neo.dashboard.DockTabSortZone|null} [context.sourceZone=null] + * @param {Neo.dashboard.dock.interaction.TabSortZone|null} [context.sourceZone=null] * @param {String|null} [context.sourceZoneId=null] Clone-safe Neural Link alternative. * @param {String} context.targetWorkspaceId * @returns {Object} @@ -4177,16 +4178,16 @@ class Workspace extends DockWorkspace { } : null, parkedItemId: parkedVessel?.itemId ?? null, parkReceipt : me.lastVesselParkReceipt - ? DockZoneModel.clone(me.lastVesselParkReceipt) + ? Document.clone(me.lastVesselParkReceipt) : null, - preview : semantic ? DockZoneModel.clone(semantic) : null, - rendered : rendered ? DockZoneModel.clone(rendered) : null, + preview : semantic ? Document.clone(semantic) : null, + rendered : rendered ? Document.clone(rendered) : null, sourceVesselConnected: Boolean( sourceVessel?.windowId && Neo.manager?.Window?.get(sourceVessel.windowId) ), sourceVesselWindowId: sourceVessel?.windowId ?? null, restoreReceipt : me.lastVesselRestoreReceipt - ? DockZoneModel.clone(me.lastVesselRestoreReceipt) + ? Document.clone(me.lastVesselRestoreReceipt) : null, targetProxy, targetWorkspaceId, @@ -4477,12 +4478,12 @@ class Workspace extends DockWorkspace { sourceWorkspaceId: Workspace.MAIN_WORKSPACE_ID, targetWorkspaceId }, {attempts}), - sourceAfter = DockZoneModel.clone(me.dockModel), - targetAfter = DockZoneModel.clone(me.vesselWorkspaces.get(targetWorkspaceId)?.document), + sourceAfter = Document.clone(me.dockModel), + targetAfter = Document.clone(me.vesselWorkspaces.get(targetWorkspaceId)?.document), retired = await me.waitForTearOutVesselRetired(itemId, {attempts}), targetItems = targetAfter?.nodes?.[Workspace.vesselTabsNodeId(targetItemId)]?.items || [], - sourceOwns = DockZoneModel.findContainingTabsId(sourceAfter, itemId) != null - || DockZoneModel.findContainingTabsId(sourceAfter, targetItemId) != null, + sourceOwns = Document.findContainingTabsId(sourceAfter, itemId) != null + || Document.findContainingTabsId(sourceAfter, targetItemId) != null, applied = transfer?.reconciled === true && retired && !sourceOwns && targetItems.length === 2 && targetItems[0] === targetItemId @@ -4496,7 +4497,7 @@ class Workspace extends DockWorkspace { sourceDocument : sourceAfter, sourceVesselRetired: retired, targetDocument : targetAfter, - transfer : transfer ? DockZoneModel.clone(transfer) : null + transfer : transfer ? Document.clone(transfer) : null } } } catch (error) { @@ -4514,7 +4515,7 @@ class Workspace extends DockWorkspace { * physical topology exit. * * The pointer starts on the actual nested `.neo-dock-stack-handle`, so - * {@link Neo.dashboard.DockTabSortZone} authors the group payload. The executor never invokes + * {@link Neo.dashboard.dock.interaction.TabSortZone} authors the group payload. The executor never invokes * `transferNode` itself; it withholds mouseup until the main target's one semantic + rendered * claim settles, then observes the resulting receipt through window disconnect. * @param {Object} step @@ -4545,7 +4546,7 @@ class Workspace extends DockWorkspace { await me.refreshPromise; await me.crossWindowParticipationPromise; - let nodeId = DockZoneModel.resolveStackRoot(state.document), + let nodeId = Document.resolveStackRoot(state.document), tabsNodeId = Workspace.vesselTabsNodeId(ownerItemId), tabsNode = state.document.nodes?.[tabsNodeId], activeItemId = tabsNode?.activeItemId ?? tabsNode?.items?.[0], @@ -4727,7 +4728,7 @@ class Workspace extends DockWorkspace { attempt < attempts && await me.timeout(16) } - let mainAfter = DockZoneModel.clone(me.dockModel), + let mainAfter = Document.clone(me.dockModel), targetNodeId = remoteSnapshot.preview?.target?.nodeId, returnedItems = mainAfter.nodes?.[targetNodeId]?.items || [], requiredPhases = ['documents-adopted', 'main-projected', 'close-dispatched', 'topology-exited'], @@ -4744,14 +4745,14 @@ class Workspace extends DockWorkspace { applied, errors: applied ? [] : ['whole-stack return did not settle through model-before-close topology exit'], proof : { - closeReceipt: me.lastTearOutClose ? DockZoneModel.clone(me.lastTearOutClose) : null, + closeReceipt: me.lastTearOutClose ? Document.clone(me.lastTearOutClose) : null, mainDocument: mainAfter, phaseOrder, remoteSnapshot, sourceItemIds, sourceWindowGone, sourceWindowId, - transfer : transfer ? DockZoneModel.clone(transfer) : null + transfer : transfer ? Document.clone(transfer) : null } } } catch (error) { @@ -4767,7 +4768,7 @@ class Workspace extends DockWorkspace { * @summary The app-owned tear-out journey executor — scene 2's real-pointer drive. * * Arms a tab drag, flings the proxy past the window boundary so - * {@link Neo.dashboard.DockTabSortZone} fires `dockTearOutExit`, the host opens a `?popout=` + * {@link Neo.dashboard.dock.interaction.TabSortZone} fires `dockTearOutExit`, the host opens a `?popout=` * vessel, then — gated on that vessel's ACTUAL birth ({@link #onWindowConnect}) — survives * deliberate post-birth moves and settles one of three terminals: release while detached * (`dockTearOutTerminal` → the `detachItem` commit + adoption), Escape-cancel (zero-mutation @@ -4836,7 +4837,7 @@ class Workspace extends DockWorkspace { // The committed document BEFORE the gesture — the zero-mutation (cancel/reenter) and // detach-commit (terminal) proofs both compare against this snapshot. - let documentBefore = DockZoneModel.clone(document), + let documentBefore = Document.clone(document), catalogBefore = Object.keys(documentBefore.items); let startX = buttonRect.x + buttonRect.width / 2, @@ -4995,7 +4996,7 @@ class Workspace extends DockWorkspace { let cancellation = await me.cancelTearOutGesture(button, {clientX: inX, clientY: inY, screenX: window.innerRect.x + inX, screenY: window.innerRect.y + inY}), - documentAfter = DockZoneModel.clone(me.dockModel), + documentAfter = Document.clone(me.dockModel), windowGone = !vesselWindowId || !WindowManager.get(vesselWindowId); return { @@ -5020,7 +5021,7 @@ class Workspace extends DockWorkspace { // Escape while detached → dockTearOutCancel → the host closes its vessel. The // committed document must be byte-identical — the zero-mutation invariant. let cancellation = await me.cancelTearOutGesture(button, release), - documentAfter = DockZoneModel.clone(me.dockModel); + documentAfter = Document.clone(me.dockModel); return { applied : false, @@ -5050,7 +5051,7 @@ class Workspace extends DockWorkspace { committed && await me.refreshPromise; let - documentAfter = DockZoneModel.clone(me.dockModel), + documentAfter = Document.clone(me.dockModel), absentFromTree = !Object.values(documentAfter.nodes).some(zoneNode => zoneNode.items?.includes(itemId)), keptInCatalog = Boolean(documentAfter.items?.[itemId]); diff --git a/docs/output/class-hierarchy.json b/docs/output/class-hierarchy.json index 677ffd3fe2..a5bbd311d2 100644 --- a/docs/output/class-hierarchy.json +++ b/docs/output/class-hierarchy.json @@ -1 +1 @@ -{"Colors.childapps.widget.view.Viewport":"Neo.container.Viewport","Colors.model.Color":"Neo.data.Model","Colors.store.Colors":"Neo.data.Store","Colors.view.BarChartComponent":"Neo.component.wrapper.AmChart","Colors.view.GridContainer":"Neo.grid.Container","Colors.view.HeaderToolbar":"Neo.toolbar.Base","Colors.view.PieChartComponent":"Neo.component.wrapper.AmChart","Colors.view.Viewport":"Neo.container.Viewport","Colors.view.ViewportController":"Neo.controller.Component","Colors.view.ViewportStateProvider":"Neo.state.Provider","Covid.Util":"Neo.core.Base","Covid.model.Country":"Neo.data.Model","Covid.model.HistoricalData":"Neo.data.Model","Covid.store.Countries":"Neo.data.Store","Covid.store.HistoricalData":"Neo.data.Store","Covid.view.AttributionComponent":"Neo.component.Base","Covid.view.FooterContainer":"Neo.container.Base","Covid.view.GalleryContainer":"Neo.container.Base","Covid.view.GalleryContainerController":"Neo.controller.Component","Covid.view.HeaderContainer":"Neo.container.Base","Covid.view.HelixContainer":"Neo.container.Base","Covid.view.HelixContainerController":"Neo.controller.Component","Covid.view.MainContainer":"Neo.container.Viewport","Covid.view.MainContainerController":"Neo.controller.Component","Covid.view.MainContainerStateProvider":"Neo.state.Provider","Covid.view.TableContainer":"Neo.container.Base","Covid.view.TableContainerController":"Neo.controller.Component","Covid.view.WorldMapComponent":"Neo.component.wrapper.AmChart","Covid.view.WorldMapContainer":"Neo.container.Base","Covid.view.WorldMapContainerController":"Neo.controller.Component","Covid.view.country.Gallery":"Neo.component.Gallery","Covid.view.country.Helix":"Neo.component.Helix","Covid.view.country.HistoricalDataTable":"Neo.table.Container","Covid.view.country.LineChartComponent":"Neo.component.wrapper.AmChart","Covid.view.country.Table":"Neo.table.Container","Covid.view.mapboxGl.Component":"Neo.component.wrapper.MapboxGL","Covid.view.mapboxGl.Container":"Neo.container.Base","Covid.view.mapboxGl.ContainerController":"Neo.controller.Component","Docs.app.model.Api":"Neo.data.Model","Docs.app.model.Example":"Neo.data.Model","Docs.app.store.Api":"Neo.data.Store","Docs.app.store.Examples":"Neo.data.Store","Docs.app.view.ApiTreeList":"Neo.tree.List","Docs.app.view.ContentTabContainer":"Neo.tab.Container","Docs.app.view.ExamplesTreeList":"Neo.tree.List","Docs.app.view.HeaderContainer":"Neo.container.Base","Docs.app.view.MainContainer":"Neo.container.Viewport","Docs.app.view.MainContainerController":"Neo.controller.Component","Docs.app.view.classdetails.HeaderComponent":"Neo.component.Base","Docs.app.view.classdetails.HierarchyTreeList":"Neo.tree.List","Docs.app.view.classdetails.MainContainer":"Neo.container.Base","Docs.app.view.classdetails.MainContainerController":"Neo.controller.Component","Docs.app.view.classdetails.MembersList":"Neo.list.Base","Docs.app.view.classdetails.SourceViewComponent":"Neo.component.Base","Docs.app.view.classdetails.TutorialComponent":"Neo.component.Base","Email.model.Email":"Neo.data.Model","Email.store.Emails":"Neo.data.Store","Email.view.Viewport":"Neo.container.Viewport","Email.view.ViewportStateProvider":"Neo.state.Provider","Finance.model.Company":"Neo.data.Model","Finance.store.Companies":"Neo.data.Store","Finance.view.GridContainer":"Neo.table.Container","Finance.view.Viewport":"Neo.container.Viewport","Finance.view.ViewportController":"Neo.controller.Component","Finance.view.ViewportStateProvider":"Neo.state.Provider","Form.model.SideNav":"Neo.data.Model","Form.store.SideNav":"Neo.data.Store","Form.view.FormContainer":"Neo.form.Container","Form.view.FormContainerController":"Neo.controller.Component","Form.view.FormPageContainer":"Neo.form.Container","Form.view.SideNavList":"Neo.list.Base","Form.view.Viewport":"Neo.container.Viewport","Form.view.ViewportController":"Neo.controller.Component","Form.view.ViewportStateProvider":"Neo.state.Provider","Form.view.pages.Page1":"Form.view.FormPageContainer","Form.view.pages.Page10":"Form.view.FormPageContainer","Form.view.pages.Page11":"Form.view.FormPageContainer","Form.view.pages.Page12":"Form.view.FormPageContainer","Form.view.pages.Page13":"Form.view.FormPageContainer","Form.view.pages.Page14":"Form.view.FormPageContainer","Form.view.pages.Page15":"Form.view.FormPageContainer","Form.view.pages.Page2":"Form.view.FormPageContainer","Form.view.pages.Page3":"Form.view.FormPageContainer","Form.view.pages.Page4":"Form.view.FormPageContainer","Form.view.pages.Page5":"Form.view.FormPageContainer","Form.view.pages.Page6":"Form.view.FormPageContainer","Form.view.pages.Page7":"Form.view.FormPageContainer","Form.view.pages.Page8":"Form.view.FormPageContainer","Form.view.pages.Page9":"Form.view.FormPageContainer","Legit.childapps.preview.MainContainer":"Neo.container.Viewport","Legit.model.Commit":"Neo.data.Model","Legit.model.File":"Neo.data.Model","Legit.service.Legit":"Neo.core.Base","Legit.store.Commits":"Neo.data.Store","Legit.store.Files":"Neo.data.Store","Legit.view.AddFileDialog":"Neo.dialog.Base","Legit.view.CommitGrid":"Neo.grid.Container","Legit.view.Viewport":"Neo.container.Viewport","Legit.view.ViewportController":"Neo.controller.Component","Legit.view.ViewportStateProvider":"Neo.state.Provider","Neo.Fetch":"Neo.data.connection.Fetch","Neo.Main":"Neo.core.Base","Neo.Xhr":"Neo.data.connection.Xhr","Neo.ai.Client":"Neo.core.Base","Neo.ai.LockRegistry":"Neo.core.Base","Neo.ai.TransactionService":"Neo.core.Base","Neo.ai.WriteGuard":"Neo.core.Base","Neo.ai.client.ComponentService":"Neo.ai.client.Service","Neo.ai.client.DataService":"Neo.ai.client.Service","Neo.ai.client.DockService":"Neo.ai.client.Service","Neo.ai.client.InstanceService":"Neo.ai.client.Service","Neo.ai.client.InteractionService":"Neo.ai.client.Service","Neo.ai.client.RuntimeService":"Neo.ai.client.Service","Neo.ai.client.Service":"Neo.core.Base","Neo.ai.client.TourRunner":"Neo.core.Base","Neo.app.SharedCanvas":"Neo.component.Canvas","Neo.app.content.Component":"Neo.component.Markdown","Neo.app.content.Container":"Neo.container.Base","Neo.app.content.PageContainer":"Neo.container.Base","Neo.app.content.SectionsContainer":"Neo.container.Base","Neo.app.content.SectionsList":"Neo.list.Base","Neo.app.content.TreeList":"Neo.tree.List","Neo.app.header.Canvas":"Neo.app.SharedCanvas","Neo.app.header.Toolbar":"Neo.toolbar.Base","Neo.app.header.ToolbarController":"Neo.controller.Component","Neo.button.Base":"Neo.component.Base","Neo.button.Effect":"Neo.component.Base","Neo.button.Menu":"Neo.button.Split","Neo.button.Split":"Neo.button.Base","Neo.calendar.model.Calendar":"Neo.data.Model","Neo.calendar.model.Color":"Neo.data.Model","Neo.calendar.model.Event":"Neo.data.Model","Neo.calendar.store.Calendars":"Neo.data.Store","Neo.calendar.store.Colors":"Neo.data.Store","Neo.calendar.store.Events":"Neo.data.Store","Neo.calendar.view.DayComponent":"Neo.calendar.view.week.Component","Neo.calendar.view.EditEventContainer":"Neo.form.Container","Neo.calendar.view.MainContainer":"Neo.container.Base","Neo.calendar.view.MainContainerStateProvider":"Neo.state.Provider","Neo.calendar.view.SettingsContainer":"Neo.container.Base","Neo.calendar.view.YearComponent":"Neo.component.Base","Neo.calendar.view.calendars.ColorsList":"Neo.list.Base","Neo.calendar.view.calendars.Container":"Neo.container.Base","Neo.calendar.view.calendars.EditContainer":"Neo.form.Container","Neo.calendar.view.calendars.List":"Neo.list.Component","Neo.calendar.view.month.Component":"Neo.component.Base","Neo.calendar.view.settings.GeneralContainer":"Neo.container.Base","Neo.calendar.view.settings.MonthContainer":"Neo.container.Base","Neo.calendar.view.settings.WeekContainer":"Neo.container.Base","Neo.calendar.view.settings.YearContainer":"Neo.container.Base","Neo.calendar.view.week.Component":"Neo.component.Base","Neo.calendar.view.week.EventDragZone":"Neo.draggable.DragZone","Neo.calendar.view.week.TimeAxisComponent":"Neo.container.Base","Neo.calendar.view.week.plugin.DragDrop":"Neo.plugin.Base","Neo.calendar.view.week.plugin.EventResizable":"Neo.plugin.Resizable","Neo.canvas.Base":"Neo.core.Base","Neo.canvas.Header":"Neo.canvas.Base","Neo.canvas.Sparkline":"Neo.core.Base","Neo.code.LivePreview":"Neo.container.Base","Neo.code.executor.Neo":"Neo.core.Base","Neo.collection.Base":"Neo.core.Base","Neo.collection.Filter":"Neo.core.Base","Neo.collection.Sorter":"Neo.core.Base","Neo.component.Abstract":"Neo.core.Base","Neo.component.Base":"Neo.component.Abstract","Neo.component.BoxLabel":"Neo.component.Label","Neo.component.Canvas":"Neo.component.Base","Neo.component.Carousel":"Neo.component.Base","Neo.component.Chip":"Neo.component.Base","Neo.component.Circle":"Neo.component.Base","Neo.component.Clock":"Neo.component.Base","Neo.component.CountryFlag":"Neo.component.Base","Neo.component.DateSelector":"Neo.component.Base","Neo.component.Gallery":"Neo.component.Base","Neo.component.GitHubOrgs":"Neo.component.Base","Neo.component.GitHubUser":"Neo.component.Base","Neo.component.Helix":"Neo.component.Base","Neo.component.Icon":"Neo.component.Base","Neo.component.IconLink":"Neo.component.Base","Neo.component.Iframe":"Neo.component.Base","Neo.component.Image":"Neo.component.Base","Neo.component.Label":"Neo.component.Base","Neo.component.Legend":"Neo.component.Base","Neo.component.MagicMoveText":"Neo.component.Base","Neo.component.Markdown":"Neo.component.Base","Neo.component.Process":"Neo.component.Base","Neo.component.Progress":"Neo.component.Base","Neo.component.Sparkline":"Neo.component.Canvas","Neo.component.Splitter":"Neo.component.Base","Neo.component.StatusBadge":"Neo.component.Base","Neo.component.Timer":"Neo.component.Base","Neo.component.Toast":"Neo.component.Base","Neo.component.Video":"Neo.component.Base","Neo.component.markdown.Component":"Neo.component.Base","Neo.component.markdown.Parser":null,"Neo.component.mwc.Button":"Neo.component.Base","Neo.component.mwc.TextField":"Neo.component.Base","Neo.component.wrapper.AmChart":"Neo.component.Base","Neo.component.wrapper.CesiumJS":"Neo.component.Base","Neo.component.wrapper.GoogleMaps":"Neo.component.Base","Neo.component.wrapper.MapboxGL":"Neo.component.Base","Neo.component.wrapper.Mermaid":"Neo.component.Base","Neo.component.wrapper.MonacoEditor":"Neo.component.Base","Neo.component.wrapper.OpenStreetMaps":"Neo.component.Base","Neo.container.Accordion":"Neo.container.Panel","Neo.container.AccordionItem":"Neo.container.Base","Neo.container.Base":"Neo.component.Base","Neo.container.Fragment":"Neo.container.Base","Neo.container.Panel":"Neo.container.Base","Neo.container.Viewport":"Neo.container.Base","Neo.controller.Application":"Neo.controller.Base","Neo.controller.Base":"Neo.core.Base","Neo.controller.Component":"Neo.controller.Base","Neo.core.Base":null,"Neo.core.Compare":null,"Neo.core.Config":null,"Neo.core.Effect":null,"Neo.core.EffectManager":null,"Neo.core.Observable":"Neo.core.Base","Neo.core.Util":null,"Neo.dashboard.Container":"Neo.container.Base","Neo.dashboard.CrossWindowDragTarget":"Neo.core.Base","Neo.dashboard.DockCrossWindowParticipation":"Neo.core.Base","Neo.dashboard.DockDragAffordances":"Neo.core.Base","Neo.dashboard.DockDropIndicators":"Neo.container.Base","Neo.dashboard.DockLayoutAdapter":"Neo.core.Base","Neo.dashboard.DockMotionSignal":"Neo.core.Base","Neo.dashboard.DockPerspectiveStore":"Neo.core.Base","Neo.dashboard.DockPreview":"Neo.component.Base","Neo.dashboard.DockPreviewProducer":"Neo.core.Base","Neo.dashboard.DockProjectionReconciler":"Neo.core.Base","Neo.dashboard.DockRail":"Neo.container.Base","Neo.dashboard.DockRestorePlanner":"Neo.core.Base","Neo.dashboard.DockRevealOverlay":"Neo.container.Base","Neo.dashboard.DockRevealStateMachine":null,"Neo.dashboard.DockSplitter":"Neo.component.Base","Neo.dashboard.DockTabEnterButton":"Neo.tab.header.Button","Neo.dashboard.DockTabSortZone":"Neo.draggable.tab.header.toolbar.SortZone","Neo.dashboard.DockTopologyDiff":"Neo.core.Base","Neo.dashboard.DockTopologyReconciler":"Neo.core.Base","Neo.dashboard.DockWorkspace":"Neo.container.Base","Neo.dashboard.DockZoneModel":"Neo.core.Base","Neo.dashboard.Panel":"Neo.container.Panel","Neo.data.Model":"Neo.core.Base","Neo.data.Pipeline":"Neo.core.Base","Neo.data.RecordFactory":"Neo.core.Base","Neo.data.Store":"Neo.collection.Base","Neo.data.TreeModel":"Neo.data.Model","Neo.data.TreeStore":"Neo.data.Store","Neo.data.connection.Base":"Neo.core.Base","Neo.data.connection.Fetch":"Neo.data.connection.Base","Neo.data.connection.Rpc":"Neo.data.connection.Base","Neo.data.connection.Stream":"Neo.data.connection.Base","Neo.data.connection.WebSocket":"Neo.data.connection.Base","Neo.data.connection.Xhr":"Neo.data.connection.Base","Neo.data.normalizer.Base":"Neo.core.Base","Neo.data.normalizer.Tree":"Neo.data.normalizer.Base","Neo.data.parser.Base":"Neo.core.Base","Neo.data.parser.Stream":"Neo.data.parser.Base","Neo.date.DayViewComponent":"Neo.component.Base","Neo.date.SelectorContainer":"Neo.container.Base","Neo.date.SelectorContainerStateProvider":"Neo.state.Provider","Neo.dialog.Base":"Neo.container.Panel","Neo.dialog.header.Toolbar":"Neo.toolbar.Base","Neo.draggable.DragProxyComponent":"Neo.component.Base","Neo.draggable.DragProxyContainer":"Neo.container.Base","Neo.draggable.DragZone":"Neo.core.Base","Neo.draggable.DropZone":"Neo.core.Base","Neo.draggable.container.DragZone":"Neo.draggable.DragZone","Neo.draggable.container.SortZone":"Neo.draggable.container.DragZone","Neo.draggable.dashboard.SortZone":"Neo.draggable.container.SortZone","Neo.draggable.grid.header.toolbar.SortZone":"Neo.draggable.container.SortZone","Neo.draggable.list.DragZone":"Neo.draggable.DragZone","Neo.draggable.list.SortZone":"Neo.draggable.list.DragZone","Neo.draggable.tab.header.toolbar.SortZone":"Neo.draggable.container.SortZone","Neo.draggable.table.header.toolbar.SortZone":"Neo.draggable.container.SortZone","Neo.draggable.tree.DragZone":"Neo.draggable.list.DragZone","Neo.draggable.tree.SortZone":"Neo.draggable.tree.DragZone","Neo.filter.BooleanContainer":"Neo.container.Base","Neo.filter.DateContainer":"Neo.filter.NumberContainer","Neo.filter.NumberContainer":"Neo.container.Base","Neo.filter.ToggleOperatorsButton":"Neo.button.Base","Neo.form.Container":"Neo.container.Base","Neo.form.Fieldset":"Neo.form.Container","Neo.form.field.Base":"Neo.component.Base","Neo.form.field.CheckBox":"Neo.form.field.Base","Neo.form.field.Chip":"Neo.form.field.ComboBox","Neo.form.field.Color":"Neo.form.field.ComboBox","Neo.form.field.ComboBox":"Neo.form.field.Picker","Neo.form.field.Country":"Neo.form.field.ComboBox","Neo.form.field.Currency":"Neo.form.field.Number","Neo.form.field.Date":"Neo.form.field.Picker","Neo.form.field.Display":"Neo.form.field.Text","Neo.form.field.Email":"Neo.form.field.Text","Neo.form.field.FileUpload":"Neo.form.field.Base","Neo.form.field.Hidden":"Neo.form.field.Base","Neo.form.field.Number":"Neo.form.field.Text","Neo.form.field.Password":"Neo.form.field.Text","Neo.form.field.Phone":"Neo.form.field.Text","Neo.form.field.Picker":"Neo.form.field.Text","Neo.form.field.Radio":"Neo.form.field.CheckBox","Neo.form.field.Range":"Neo.form.field.Number","Neo.form.field.Search":"Neo.form.field.Text","Neo.form.field.Switch":"Neo.form.field.CheckBox","Neo.form.field.Text":"Neo.form.field.Base","Neo.form.field.TextArea":"Neo.form.field.Text","Neo.form.field.Time":"Neo.form.field.Picker","Neo.form.field.Url":"Neo.form.field.Text","Neo.form.field.ZipCode":"Neo.form.field.Text","Neo.form.field.chip.ValueList":"Neo.list.Chip","Neo.form.field.fileUpload.Transport":"Neo.core.Base","Neo.form.field.fileUpload.Xhr":"Neo.form.field.fileUpload.Transport","Neo.form.field.trigger.Base":"Neo.component.Base","Neo.form.field.trigger.Clear":"Neo.form.field.trigger.Base","Neo.form.field.trigger.CopyToClipboard":"Neo.form.field.trigger.Base","Neo.form.field.trigger.Date":"Neo.form.field.trigger.Picker","Neo.form.field.trigger.Picker":"Neo.form.field.trigger.Base","Neo.form.field.trigger.Search":"Neo.form.field.trigger.Picker","Neo.form.field.trigger.SpinDown":"Neo.form.field.trigger.Base","Neo.form.field.trigger.SpinUp":"Neo.form.field.trigger.Base","Neo.form.field.trigger.SpinUpDown":"Neo.form.field.trigger.Base","Neo.form.field.trigger.Time":"Neo.form.field.trigger.Picker","Neo.functional.button.Base":"Neo.functional.component.Base","Neo.functional.component.Base":"Neo.component.Abstract","Neo.functional.util.HtmlTemplateProcessor":"Neo.core.Base","Neo.functional.util.html":null,"Neo.grid.Body":"Neo.component.Base","Neo.grid.Container":"Neo.container.Base","Neo.grid.HorizontalScrollbar":"Neo.component.Base","Neo.grid.Row":"Neo.component.Base","Neo.grid.ScrollManager":"Neo.core.Base","Neo.grid.VerticalScrollbar":"Neo.component.Base","Neo.grid.View":"Neo.container.Base","Neo.grid.column.AnimatedChange":"Neo.grid.column.Base","Neo.grid.column.AnimatedCurrency":"Neo.grid.column.AnimatedChange","Neo.grid.column.Base":"Neo.core.Base","Neo.grid.column.Component":"Neo.grid.column.Base","Neo.grid.column.CountryFlag":"Neo.grid.column.Component","Neo.grid.column.Currency":"Neo.grid.column.Base","Neo.grid.column.GitHubOrgs":"Neo.grid.column.Component","Neo.grid.column.GitHubUser":"Neo.grid.column.Component","Neo.grid.column.Icon":"Neo.grid.column.Component","Neo.grid.column.IconLink":"Neo.grid.column.Component","Neo.grid.column.Index":"Neo.grid.column.Base","Neo.grid.column.LinkedIn":"Neo.grid.column.Component","Neo.grid.column.Progress":"Neo.grid.column.Component","Neo.grid.column.Sparkline":"Neo.grid.column.Component","Neo.grid.column.Tree":"Neo.grid.column.Component","Neo.grid.column.component.Tree":"Neo.component.Base","Neo.grid.footer.Toolbar":"Neo.toolbar.Base","Neo.grid.header.Button":"Neo.button.Base","Neo.grid.header.Toolbar":"Neo.toolbar.Base","Neo.grid.header.Wrapper":"Neo.container.Base","Neo.grid.header.plugin.Resizable":"Neo.plugin.Resizable","Neo.grid.plugin.AnimateRows":"Neo.plugin.Base","Neo.grid.plugin.CellEditing":"Neo.table.plugin.CellEditing","Neo.layout.Base":"Neo.core.Base","Neo.layout.Card":"Neo.layout.Base","Neo.layout.Cube":"Neo.layout.Card","Neo.layout.Fit":"Neo.layout.Base","Neo.layout.Flexbox":"Neo.layout.Base","Neo.layout.Form":"Neo.layout.Base","Neo.layout.Grid":"Neo.layout.Base","Neo.layout.HBox":"Neo.layout.Flexbox","Neo.layout.VBox":"Neo.layout.Flexbox","Neo.list.Base":"Neo.component.Base","Neo.list.Buffered":"Neo.list.Component","Neo.list.Chip":"Neo.list.Component","Neo.list.Circle":"Neo.list.Component","Neo.list.Color":"Neo.list.Base","Neo.list.Component":"Neo.list.Base","Neo.list.Country":"Neo.list.Base","Neo.list.plugin.Animate":"Neo.plugin.Base","Neo.main.DeltaUpdates":"Neo.core.Base","Neo.main.DomAccess":"Neo.core.Base","Neo.main.DomEvents":"Neo.core.Base","Neo.main.DomUtils":"Neo.core.Base","Neo.main.addon.AmCharts":"Neo.main.addon.Base","Neo.main.addon.AnalyticsByGoogle":"Neo.main.addon.Base","Neo.main.addon.Base":"Neo.core.Base","Neo.main.addon.CesiumJS":"Neo.main.addon.Base","Neo.main.addon.CloneNode":"Neo.main.addon.Base","Neo.main.addon.Cookie":"Neo.main.addon.Base","Neo.main.addon.DockFlip":"Neo.main.addon.Base","Neo.main.addon.DocumentHead":"Neo.main.addon.Base","Neo.main.addon.DragDrop":"Neo.main.addon.Base","Neo.main.addon.EventSimulator":"Neo.main.addon.Base","Neo.main.addon.FileSystemAccess":"Neo.main.addon.Base","Neo.main.addon.GoogleMaps":"Neo.main.addon.Base","Neo.main.addon.GridDragScroll":"Neo.main.addon.Base","Neo.main.addon.GridHorizontalScrollSync":"Neo.main.addon.Base","Neo.main.addon.GridRowHoverSync":"Neo.main.addon.Base","Neo.main.addon.GridRowScrollPinning":"Neo.main.addon.Base","Neo.main.addon.HighlightJS":"Neo.main.addon.Base","Neo.main.addon.InputModality":"Neo.main.addon.Base","Neo.main.addon.IntersectionObserver":"Neo.main.addon.Base","Neo.main.addon.LocalStorage":"Neo.main.addon.Base","Neo.main.addon.MapboxGL":"Neo.main.addon.Base","Neo.main.addon.Markdown":"Neo.main.addon.Base","Neo.main.addon.Mermaid":"Neo.main.addon.Base","Neo.main.addon.MonacoEditor":"Neo.main.addon.Base","Neo.main.addon.Mwc":"Neo.main.addon.Base","Neo.main.addon.Navigator":"Neo.main.addon.Base","Neo.main.addon.OpenStreetMaps":"Neo.main.addon.Base","Neo.main.addon.Popover":"Neo.main.addon.Base","Neo.main.addon.PrefixField":"Neo.main.addon.Base","Neo.main.addon.ResizeObserver":"Neo.main.addon.Base","Neo.main.addon.ScrollSync":"Neo.main.addon.Base","Neo.main.addon.ServerSideRendering":"Neo.main.addon.Base","Neo.main.addon.ServiceWorker":"Neo.main.addon.Base","Neo.main.addon.Stylesheet":"Neo.main.addon.Base","Neo.main.addon.WebComponent":"Neo.main.addon.Base","Neo.main.addon.WindowPosition":"Neo.main.addon.Base","Neo.main.draggable.Resize":null,"Neo.main.draggable.sensor.Base":"Neo.core.Base","Neo.main.draggable.sensor.Mouse":"Neo.main.draggable.sensor.Base","Neo.main.draggable.sensor.Touch":"Neo.main.draggable.sensor.Base","Neo.main.mixin.TouchDomEvents":"Neo.core.Base","Neo.manager.Base":"Neo.collection.Base","Neo.manager.Component":"Neo.manager.Base","Neo.manager.DomEvent":"Neo.core.Base","Neo.manager.DragCoordinator":"Neo.manager.Base","Neo.manager.Focus":"Neo.core.Base","Neo.manager.Instance":"Neo.manager.Base","Neo.manager.Store":"Neo.manager.Base","Neo.manager.Task":"Neo.manager.Base","Neo.manager.Toast":"Neo.manager.Base","Neo.manager.VDomUpdate":"Neo.collection.Base","Neo.manager.Window":"Neo.manager.Base","Neo.manager.rpc.Api":"Neo.manager.Base","Neo.manager.rpc.Message":"Neo.manager.Base","Neo.menu.List":"Neo.list.Base","Neo.menu.Model":"Neo.data.Model","Neo.menu.Panel":"Neo.container.Panel","Neo.menu.Store":"Neo.data.Store","Neo.mixin.DomEvents":"Neo.core.Base","Neo.mixin.VdomLifecycle":"Neo.core.Base","Neo.plugin.Base":"Neo.core.Base","Neo.plugin.Popover":"Neo.plugin.Base","Neo.plugin.PrefixField":"Neo.plugin.Base","Neo.plugin.Resizable":"Neo.plugin.Base","Neo.plugin.Responsive":"Neo.plugin.Base","Neo.remotes.Api":"Neo.core.Base","Neo.selection.CircleModel":"Neo.selection.Model","Neo.selection.DateSelectorModel":"Neo.selection.Model","Neo.selection.GalleryModel":"Neo.selection.Model","Neo.selection.HelixModel":"Neo.selection.Model","Neo.selection.ListModel":"Neo.selection.Model","Neo.selection.Model":"Neo.core.Base","Neo.selection.TreeAccordionModel":"Neo.selection.TreeModel","Neo.selection.TreeModel":"Neo.selection.ListModel","Neo.selection.grid.BaseModel":"Neo.selection.Model","Neo.selection.grid.CellColumnModel":"Neo.selection.grid.CellModel","Neo.selection.grid.CellColumnRowModel":"Neo.selection.grid.CellRowModel","Neo.selection.grid.CellModel":"Neo.selection.grid.BaseModel","Neo.selection.grid.CellRowModel":"Neo.selection.grid.CellModel","Neo.selection.grid.ColumnModel":"Neo.selection.grid.BaseModel","Neo.selection.grid.RowModel":"Neo.selection.grid.BaseModel","Neo.selection.menu.ListModel":"Neo.selection.ListModel","Neo.selection.table.BaseModel":"Neo.selection.Model","Neo.selection.table.CellColumnModel":"Neo.selection.table.CellModel","Neo.selection.table.CellColumnRowModel":"Neo.selection.table.CellRowModel","Neo.selection.table.CellModel":"Neo.selection.table.BaseModel","Neo.selection.table.CellRowModel":"Neo.selection.table.CellModel","Neo.selection.table.ColumnModel":"Neo.selection.table.BaseModel","Neo.selection.table.RowModel":"Neo.selection.table.BaseModel","Neo.sitemap.Component":"Neo.component.Base","Neo.sitemap.Model":"Neo.data.Model","Neo.sitemap.Store":"Neo.data.Store","Neo.state.Provider":"Neo.core.Base","Neo.tab.BodyContainer":"Neo.container.Base","Neo.tab.Container":"Neo.container.Base","Neo.tab.Strip":"Neo.component.Base","Neo.tab.header.Button":"Neo.button.Base","Neo.tab.header.EffectButton":"Neo.button.Effect","Neo.tab.header.Toolbar":"Neo.toolbar.Base","Neo.tab.plugin.Overflow":"Neo.plugin.Base","Neo.table.Body":"Neo.component.Base","Neo.table.Container":"Neo.container.Base","Neo.table.header.Button":"Neo.button.Base","Neo.table.header.Toolbar":"Neo.toolbar.Base","Neo.table.plugin.CellEditing":"Neo.plugin.Base","Neo.toolbar.Base":"Neo.container.Base","Neo.toolbar.Breadcrumb":"Neo.toolbar.Base","Neo.toolbar.Paging":"Neo.toolbar.Base","Neo.tooltip.Base":"Neo.container.Base","Neo.tree.Accordion":"Neo.tree.List","Neo.tree.List":"Neo.list.Base","Neo.util.Array":"Neo.core.Base","Neo.util.ClassSystem":"Neo.core.Base","Neo.util.CountryFlags":"Neo.core.Base","Neo.util.Css":"Neo.core.Base","Neo.util.Date":"Neo.core.Base","Neo.util.HashHistory":"Neo.core.Base","Neo.util.HighlightJs":"Neo.core.Base","Neo.util.HighlightJsLineNumbers":"Neo.core.Base","Neo.util.Json":"Neo.core.Base","Neo.util.KeyNavigation":"Neo.core.Base","Neo.util.Logger":"Neo.core.Base","Neo.util.Matrix":"Neo.core.Base","Neo.util.Performance":"Neo.core.Base","Neo.util.Rectangle":"DOMRect","Neo.util.String":"Neo.core.Base","Neo.util.Style":"Neo.core.Base","Neo.util.VDom":"Neo.core.Base","Neo.util.VNode":"Neo.core.Base","Neo.util.vdom.TreeBuilder":"Neo.core.Base","Neo.vdom.Helper":"Neo.core.Base","Neo.vdom.VNode":null,"Neo.vdom.util.StringFromVnode":null,"Neo.worker.App":"Neo.worker.Base","Neo.worker.Base":"Neo.core.Base","Neo.worker.Canvas":"Neo.worker.Base","Neo.worker.Data":"Neo.worker.Base","Neo.worker.Manager":"Neo.core.Base","Neo.worker.Message":null,"Neo.worker.ServiceBase":"Neo.core.Base","Neo.worker.Task":"Neo.worker.Base","Neo.worker.VDom":"Neo.worker.Base","Neo.worker.mixin.RemoteMethodAccess":"Neo.core.Base","Portal.canvas.FooterCanvas":"Portal.canvas.Base","Portal.canvas.Helper":"Neo.core.Base","Portal.canvas.HomeCanvas":"Portal.canvas.Base","Portal.canvas.ServicesCanvas":"Portal.canvas.Base","Portal.canvas.TimelineCanvas":"Portal.canvas.Base","Portal.childapps.preview.MainContainer":"Neo.container.Viewport","Portal.model.BlogMedium":"Neo.data.Model","Portal.model.BlogNeo":"Neo.data.Model","Portal.model.Content":"Neo.data.Model","Portal.model.ContentSection":"Neo.data.Model","Portal.model.Discussion":"Neo.data.Model","Portal.model.Example":"Neo.data.Model","Portal.model.Pull":"Neo.data.Model","Portal.model.Release":"Neo.data.Model","Portal.model.Ticket":"Neo.data.Model","Portal.model.TicketLabel":"Neo.data.Model","Portal.model.TimelineSection":"Portal.model.ContentSection","Portal.service.Seo":"Neo.core.Base","Portal.store.BlogMedium":"Neo.data.Store","Portal.store.BlogNeo":"Neo.data.Store","Portal.store.Content":"Neo.data.Store","Portal.store.ContentSections":"Neo.data.Store","Portal.store.Discussions":"Neo.data.Store","Portal.store.Examples":"Neo.data.Store","Portal.store.Pulls":"Neo.data.Store","Portal.store.Releases":"Neo.data.Store","Portal.store.TicketLabels":"Neo.data.Store","Portal.store.Tickets":"Neo.data.Store","Portal.store.TimelineSections":"Portal.store.ContentSections","Portal.view.HeaderToolbar":"Neo.app.header.Toolbar","Portal.view.Viewport":"Neo.container.Viewport","Portal.view.ViewportController":"Neo.controller.Component","Portal.view.ViewportStateProvider":"Neo.state.Provider","Portal.view.about.Container":"Neo.container.Base","Portal.view.about.MemberContainer":"Neo.container.Base","Portal.view.content.CanvasWrapper":"Neo.container.Base","Portal.view.content.Component":"Neo.app.content.Component","Portal.view.content.TimelineCanvas":"Neo.app.SharedCanvas","Portal.view.examples.List":"Neo.list.Base","Portal.view.examples.TabContainer":"Portal.view.shared.TabContainer","Portal.view.examples.TabContainerController":"Neo.controller.Component","Portal.view.home.ContentBox":"Neo.component.Base","Portal.view.home.FeatureSection":"Neo.container.Base","Portal.view.home.FooterCanvas":"Neo.app.SharedCanvas","Portal.view.home.FooterContainer":"Neo.container.Base","Portal.view.home.FooterContainerController":"Neo.controller.Component","Portal.view.home.MainContainer":"Neo.container.Base","Portal.view.home.parts.AiToolchain":"Portal.view.home.parts.BaseContainer","Portal.view.home.parts.BaseContainer":"Neo.container.Base","Portal.view.home.parts.Colors":"Portal.view.home.FeatureSection","Portal.view.home.parts.Features":"Portal.view.home.parts.BaseContainer","Portal.view.home.parts.Helix":"Portal.view.home.FeatureSection","Portal.view.home.parts.How":"Portal.view.home.FeatureSection","Portal.view.home.parts.References":"Portal.view.home.parts.BaseContainer","Portal.view.home.parts.hero.Canvas":"Neo.app.SharedCanvas","Portal.view.home.parts.hero.Container":"Portal.view.home.parts.BaseContainer","Portal.view.home.parts.hero.Content":"Neo.container.Base","Portal.view.learn.Component":"Neo.app.content.Component","Portal.view.learn.CubeLayoutButton":"Neo.button.Base","Portal.view.learn.MainContainer":"Neo.app.content.Container","Portal.view.learn.MainContainerController":"Neo.controller.Component","Portal.view.learn.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.TabContainer":"Portal.view.shared.TabContainer","Portal.view.news.TabContainerController":"Neo.controller.Component","Portal.view.news.blog.Component":"Neo.app.content.Component","Portal.view.news.blog.MainContainer":"Neo.app.content.Container","Portal.view.news.blog.MainContainerController":"Neo.controller.Component","Portal.view.news.blog.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.discussions.Component":"Portal.view.content.Component","Portal.view.news.discussions.MainContainer":"Neo.app.content.Container","Portal.view.news.discussions.MainContainerController":"Neo.controller.Component","Portal.view.news.discussions.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.discussions.PageContainer":"Neo.app.content.PageContainer","Portal.view.news.medium.Container":"Neo.container.Base","Portal.view.news.medium.List":"Neo.list.Base","Portal.view.news.pulls.Component":"Neo.app.content.Component","Portal.view.news.pulls.MainContainer":"Neo.app.content.Container","Portal.view.news.pulls.MainContainerController":"Neo.controller.Component","Portal.view.news.pulls.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.pulls.PageContainer":"Neo.app.content.PageContainer","Portal.view.news.release.Component":"Neo.app.content.Component","Portal.view.news.release.MainContainer":"Neo.app.content.Container","Portal.view.news.release.MainContainerController":"Neo.controller.Component","Portal.view.news.release.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.tickets.Component":"Neo.app.content.Component","Portal.view.news.tickets.MainContainer":"Neo.app.content.Container","Portal.view.news.tickets.MainContainerController":"Neo.controller.Component","Portal.view.news.tickets.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.tickets.PageContainer":"Neo.app.content.PageContainer","Portal.view.services.Canvas":"Neo.app.SharedCanvas","Portal.view.services.Container":"Neo.container.Base","Portal.view.shared.TabContainer":"Neo.tab.Container","RealWorld.api.Article":"RealWorld.api.Base","RealWorld.api.Base":"Neo.core.Base","RealWorld.api.Favorite":"RealWorld.api.Base","RealWorld.api.Profile":"RealWorld.api.Base","RealWorld.api.Tag":"RealWorld.api.Base","RealWorld.api.User":"RealWorld.api.Base","RealWorld.view.FooterComponent":"Neo.component.Base","RealWorld.view.HeaderComponent":"Neo.component.Base","RealWorld.view.HomeComponent":"Neo.component.Base","RealWorld.view.MainContainer":"Neo.container.Viewport","RealWorld.view.MainContainerController":"Neo.controller.Component","RealWorld.view.article.CommentComponent":"Neo.component.Base","RealWorld.view.article.Component":"Neo.component.Base","RealWorld.view.article.CreateCommentComponent":"Neo.component.Base","RealWorld.view.article.CreateComponent":"Neo.component.Base","RealWorld.view.article.PreviewComponent":"Neo.component.Base","RealWorld.view.article.TagListComponent":"Neo.component.Base","RealWorld.view.user.ProfileComponent":"Neo.component.Base","RealWorld.view.user.SettingsComponent":"Neo.component.Base","RealWorld.view.user.SignUpComponent":"Neo.component.Base","RealWorld2.api.Article":"RealWorld2.api.Base","RealWorld2.api.Base":"Neo.core.Base","RealWorld2.api.Favorite":"RealWorld2.api.Base","RealWorld2.api.Profile":"RealWorld2.api.Base","RealWorld2.api.Tag":"RealWorld2.api.Base","RealWorld2.api.User":"RealWorld2.api.Base","RealWorld2.model.ArticlePreview":"Neo.data.Model","RealWorld2.store.ArticlePreviews":"Neo.data.Store","RealWorld2.view.FooterComponent":"Neo.component.Base","RealWorld2.view.HeaderToolbar":"Neo.toolbar.Base","RealWorld2.view.HeaderToolbarController":"Neo.controller.Component","RealWorld2.view.HomeContainer":"Neo.container.Base","RealWorld2.view.MainContainer":"Neo.container.Viewport","RealWorld2.view.MainContainerController":"Neo.controller.Component","RealWorld2.view.article.DetailsContainer":"Neo.form.Container","RealWorld2.view.article.FormContainer":"Neo.form.Container","RealWorld2.view.article.Gallery":"Neo.component.Gallery","RealWorld2.view.article.GalleryContainer":"Neo.examples.component.gallery.MainContainer","RealWorld2.view.article.Helix":"Neo.component.Helix","RealWorld2.view.article.HelixContainer":"Neo.examples.component.helix.Viewport","RealWorld2.view.article.PreviewComponent":"Neo.component.Base","RealWorld2.view.article.PreviewList":"Neo.list.Base","RealWorld2.view.article.TagListComponent":"Neo.component.Base","RealWorld2.view.user.LoginFormContainer":"Neo.form.Container","RealWorld2.view.user.ProfileContainer":"Neo.container.Base","RealWorld2.view.user.SettingsFormContainer":"Neo.form.Container","Route.view.ButtonBar":"Neo.container.Base","Route.view.CenterContainer":"Neo.container.Base","Route.view.FooterContainer":"Neo.container.Base","Route.view.HeaderContainer":"Neo.container.Base","Route.view.MainView":"Neo.container.Viewport","Route.view.MainViewController":"Neo.controller.Component","Route.view.MetaContainer":"Neo.container.Base","Route.view.center.CardAdministration":"Neo.container.Base","Route.view.center.CardAdministrationDenied":"Neo.container.Base","Route.view.center.CardContact":"Neo.container.Base","Route.view.center.CardHome":"Neo.container.Base","Route.view.center.CardSection1":"Neo.container.Base","Route.view.center.CardSection2":"Neo.container.Base","SharedCovid.Util":"Neo.core.Base","SharedCovid.childapps.sharedcovidchart.MainContainer":"Neo.container.Viewport","SharedCovid.childapps.sharedcovidgallery.MainContainer":"Neo.container.Viewport","SharedCovid.childapps.sharedcovidhelix.MainContainer":"Neo.container.Viewport","SharedCovid.childapps.sharedcovidmap.MainContainer":"Neo.container.Viewport","SharedCovid.model.Country":"Neo.data.Model","SharedCovid.model.HistoricalData":"Neo.data.Model","SharedCovid.store.Countries":"Neo.data.Store","SharedCovid.store.HistoricalData":"Neo.data.Store","SharedCovid.view.AttributionComponent":"Neo.component.Base","SharedCovid.view.FooterContainer":"Neo.container.Base","SharedCovid.view.GalleryContainer":"Neo.container.Base","SharedCovid.view.GalleryContainerController":"Neo.controller.Component","SharedCovid.view.HeaderContainer":"Neo.container.Base","SharedCovid.view.HelixContainer":"Neo.container.Base","SharedCovid.view.HelixContainerController":"Neo.controller.Component","SharedCovid.view.MainContainer":"Neo.container.Viewport","SharedCovid.view.MainContainerController":"Neo.controller.Component","SharedCovid.view.MainContainerStateProvider":"Neo.state.Provider","SharedCovid.view.TableContainer":"Neo.container.Base","SharedCovid.view.TableContainerController":"Neo.controller.Component","SharedCovid.view.WorldMapComponent":"Neo.component.wrapper.AmChart","SharedCovid.view.WorldMapContainer":"Neo.container.Base","SharedCovid.view.WorldMapContainerController":"Neo.controller.Component","SharedCovid.view.country.Gallery":"Neo.component.Gallery","SharedCovid.view.country.Helix":"Neo.component.Helix","SharedCovid.view.country.HistoricalDataTable":"Neo.table.Container","SharedCovid.view.country.LineChartComponent":"Neo.component.wrapper.AmChart","SharedCovid.view.country.Table":"Neo.table.Container","SharedCovid.view.mapboxGl.Component":"Neo.component.wrapper.MapboxGL","SharedCovid.view.mapboxGl.Container":"Neo.container.Base","SharedCovid.view.mapboxGl.ContainerController":"Neo.controller.Component","SharedDialog.childapps.shareddialog2.view.MainContainer":"Neo.container.Viewport","SharedDialog.childapps.shareddialog2.view.MainContainerController":"Neo.controller.Component","SharedDialog.view.DemoDialog":"Neo.dialog.Base","SharedDialog.view.MainContainer":"Neo.container.Viewport","SharedDialog.view.MainContainerController":"Neo.controller.Component"} \ No newline at end of file +{"Colors.childapps.widget.view.Viewport":"Neo.container.Viewport","Colors.model.Color":"Neo.data.Model","Colors.store.Colors":"Neo.data.Store","Colors.view.BarChartComponent":"Neo.component.wrapper.AmChart","Colors.view.GridContainer":"Neo.grid.Container","Colors.view.HeaderToolbar":"Neo.toolbar.Base","Colors.view.PieChartComponent":"Neo.component.wrapper.AmChart","Colors.view.Viewport":"Neo.container.Viewport","Colors.view.ViewportController":"Neo.controller.Component","Colors.view.ViewportStateProvider":"Neo.state.Provider","Covid.Util":"Neo.core.Base","Covid.model.Country":"Neo.data.Model","Covid.model.HistoricalData":"Neo.data.Model","Covid.store.Countries":"Neo.data.Store","Covid.store.HistoricalData":"Neo.data.Store","Covid.view.AttributionComponent":"Neo.component.Base","Covid.view.FooterContainer":"Neo.container.Base","Covid.view.GalleryContainer":"Neo.container.Base","Covid.view.GalleryContainerController":"Neo.controller.Component","Covid.view.HeaderContainer":"Neo.container.Base","Covid.view.HelixContainer":"Neo.container.Base","Covid.view.HelixContainerController":"Neo.controller.Component","Covid.view.MainContainer":"Neo.container.Viewport","Covid.view.MainContainerController":"Neo.controller.Component","Covid.view.MainContainerStateProvider":"Neo.state.Provider","Covid.view.TableContainer":"Neo.container.Base","Covid.view.TableContainerController":"Neo.controller.Component","Covid.view.WorldMapComponent":"Neo.component.wrapper.AmChart","Covid.view.WorldMapContainer":"Neo.container.Base","Covid.view.WorldMapContainerController":"Neo.controller.Component","Covid.view.country.Gallery":"Neo.component.Gallery","Covid.view.country.Helix":"Neo.component.Helix","Covid.view.country.HistoricalDataTable":"Neo.table.Container","Covid.view.country.LineChartComponent":"Neo.component.wrapper.AmChart","Covid.view.country.Table":"Neo.table.Container","Covid.view.mapboxGl.Component":"Neo.component.wrapper.MapboxGL","Covid.view.mapboxGl.Container":"Neo.container.Base","Covid.view.mapboxGl.ContainerController":"Neo.controller.Component","Docs.app.model.Api":"Neo.data.Model","Docs.app.model.Example":"Neo.data.Model","Docs.app.store.Api":"Neo.data.Store","Docs.app.store.Examples":"Neo.data.Store","Docs.app.view.ApiTreeList":"Neo.tree.List","Docs.app.view.ContentTabContainer":"Neo.tab.Container","Docs.app.view.ExamplesTreeList":"Neo.tree.List","Docs.app.view.HeaderContainer":"Neo.container.Base","Docs.app.view.MainContainer":"Neo.container.Viewport","Docs.app.view.MainContainerController":"Neo.controller.Component","Docs.app.view.classdetails.HeaderComponent":"Neo.component.Base","Docs.app.view.classdetails.HierarchyTreeList":"Neo.tree.List","Docs.app.view.classdetails.MainContainer":"Neo.container.Base","Docs.app.view.classdetails.MainContainerController":"Neo.controller.Component","Docs.app.view.classdetails.MembersList":"Neo.list.Base","Docs.app.view.classdetails.SourceViewComponent":"Neo.component.Base","Docs.app.view.classdetails.TutorialComponent":"Neo.component.Base","Email.model.Email":"Neo.data.Model","Email.store.Emails":"Neo.data.Store","Email.view.Viewport":"Neo.container.Viewport","Email.view.ViewportStateProvider":"Neo.state.Provider","Finance.model.Company":"Neo.data.Model","Finance.store.Companies":"Neo.data.Store","Finance.view.GridContainer":"Neo.table.Container","Finance.view.Viewport":"Neo.container.Viewport","Finance.view.ViewportController":"Neo.controller.Component","Finance.view.ViewportStateProvider":"Neo.state.Provider","Form.model.SideNav":"Neo.data.Model","Form.store.SideNav":"Neo.data.Store","Form.view.FormContainer":"Neo.form.Container","Form.view.FormContainerController":"Neo.controller.Component","Form.view.FormPageContainer":"Neo.form.Container","Form.view.SideNavList":"Neo.list.Base","Form.view.Viewport":"Neo.container.Viewport","Form.view.ViewportController":"Neo.controller.Component","Form.view.ViewportStateProvider":"Neo.state.Provider","Form.view.pages.Page1":"Form.view.FormPageContainer","Form.view.pages.Page10":"Form.view.FormPageContainer","Form.view.pages.Page11":"Form.view.FormPageContainer","Form.view.pages.Page12":"Form.view.FormPageContainer","Form.view.pages.Page13":"Form.view.FormPageContainer","Form.view.pages.Page14":"Form.view.FormPageContainer","Form.view.pages.Page15":"Form.view.FormPageContainer","Form.view.pages.Page2":"Form.view.FormPageContainer","Form.view.pages.Page3":"Form.view.FormPageContainer","Form.view.pages.Page4":"Form.view.FormPageContainer","Form.view.pages.Page5":"Form.view.FormPageContainer","Form.view.pages.Page6":"Form.view.FormPageContainer","Form.view.pages.Page7":"Form.view.FormPageContainer","Form.view.pages.Page8":"Form.view.FormPageContainer","Form.view.pages.Page9":"Form.view.FormPageContainer","Legit.childapps.preview.MainContainer":"Neo.container.Viewport","Legit.model.Commit":"Neo.data.Model","Legit.model.File":"Neo.data.Model","Legit.service.Legit":"Neo.core.Base","Legit.store.Commits":"Neo.data.Store","Legit.store.Files":"Neo.data.Store","Legit.view.AddFileDialog":"Neo.dialog.Base","Legit.view.CommitGrid":"Neo.grid.Container","Legit.view.Viewport":"Neo.container.Viewport","Legit.view.ViewportController":"Neo.controller.Component","Legit.view.ViewportStateProvider":"Neo.state.Provider","Neo.Fetch":"Neo.data.connection.Fetch","Neo.Main":"Neo.core.Base","Neo.Xhr":"Neo.data.connection.Xhr","Neo.ai.Client":"Neo.core.Base","Neo.ai.LockRegistry":"Neo.core.Base","Neo.ai.TransactionService":"Neo.core.Base","Neo.ai.WriteGuard":"Neo.core.Base","Neo.ai.client.ComponentService":"Neo.ai.client.Service","Neo.ai.client.DataService":"Neo.ai.client.Service","Neo.ai.client.DockService":"Neo.ai.client.Service","Neo.ai.client.InstanceService":"Neo.ai.client.Service","Neo.ai.client.InteractionService":"Neo.ai.client.Service","Neo.ai.client.RuntimeService":"Neo.ai.client.Service","Neo.ai.client.Service":"Neo.core.Base","Neo.ai.client.TourRunner":"Neo.core.Base","Neo.app.SharedCanvas":"Neo.component.Canvas","Neo.app.content.Component":"Neo.component.Markdown","Neo.app.content.Container":"Neo.container.Base","Neo.app.content.PageContainer":"Neo.container.Base","Neo.app.content.SectionsContainer":"Neo.container.Base","Neo.app.content.SectionsList":"Neo.list.Base","Neo.app.content.TreeList":"Neo.tree.List","Neo.app.header.Canvas":"Neo.app.SharedCanvas","Neo.app.header.Toolbar":"Neo.toolbar.Base","Neo.app.header.ToolbarController":"Neo.controller.Component","Neo.button.Base":"Neo.component.Base","Neo.button.Effect":"Neo.component.Base","Neo.button.Menu":"Neo.button.Split","Neo.button.Split":"Neo.button.Base","Neo.calendar.model.Calendar":"Neo.data.Model","Neo.calendar.model.Color":"Neo.data.Model","Neo.calendar.model.Event":"Neo.data.Model","Neo.calendar.store.Calendars":"Neo.data.Store","Neo.calendar.store.Colors":"Neo.data.Store","Neo.calendar.store.Events":"Neo.data.Store","Neo.calendar.view.DayComponent":"Neo.calendar.view.week.Component","Neo.calendar.view.EditEventContainer":"Neo.form.Container","Neo.calendar.view.MainContainer":"Neo.container.Base","Neo.calendar.view.MainContainerStateProvider":"Neo.state.Provider","Neo.calendar.view.SettingsContainer":"Neo.container.Base","Neo.calendar.view.YearComponent":"Neo.component.Base","Neo.calendar.view.calendars.ColorsList":"Neo.list.Base","Neo.calendar.view.calendars.Container":"Neo.container.Base","Neo.calendar.view.calendars.EditContainer":"Neo.form.Container","Neo.calendar.view.calendars.List":"Neo.list.Component","Neo.calendar.view.month.Component":"Neo.component.Base","Neo.calendar.view.settings.GeneralContainer":"Neo.container.Base","Neo.calendar.view.settings.MonthContainer":"Neo.container.Base","Neo.calendar.view.settings.WeekContainer":"Neo.container.Base","Neo.calendar.view.settings.YearContainer":"Neo.container.Base","Neo.calendar.view.week.Component":"Neo.component.Base","Neo.calendar.view.week.EventDragZone":"Neo.draggable.DragZone","Neo.calendar.view.week.TimeAxisComponent":"Neo.container.Base","Neo.calendar.view.week.plugin.DragDrop":"Neo.plugin.Base","Neo.calendar.view.week.plugin.EventResizable":"Neo.plugin.Resizable","Neo.canvas.Base":"Neo.core.Base","Neo.canvas.Header":"Neo.canvas.Base","Neo.canvas.Sparkline":"Neo.core.Base","Neo.code.LivePreview":"Neo.container.Base","Neo.code.executor.Neo":"Neo.core.Base","Neo.collection.Base":"Neo.core.Base","Neo.collection.Filter":"Neo.core.Base","Neo.collection.Sorter":"Neo.core.Base","Neo.component.Abstract":"Neo.core.Base","Neo.component.Base":"Neo.component.Abstract","Neo.component.BoxLabel":"Neo.component.Label","Neo.component.Canvas":"Neo.component.Base","Neo.component.Carousel":"Neo.component.Base","Neo.component.Chip":"Neo.component.Base","Neo.component.Circle":"Neo.component.Base","Neo.component.Clock":"Neo.component.Base","Neo.component.CountryFlag":"Neo.component.Base","Neo.component.DateSelector":"Neo.component.Base","Neo.component.Gallery":"Neo.component.Base","Neo.component.GitHubOrgs":"Neo.component.Base","Neo.component.GitHubUser":"Neo.component.Base","Neo.component.Helix":"Neo.component.Base","Neo.component.Icon":"Neo.component.Base","Neo.component.IconLink":"Neo.component.Base","Neo.component.Iframe":"Neo.component.Base","Neo.component.Image":"Neo.component.Base","Neo.component.Label":"Neo.component.Base","Neo.component.Legend":"Neo.component.Base","Neo.component.MagicMoveText":"Neo.component.Base","Neo.component.Markdown":"Neo.component.Base","Neo.component.Process":"Neo.component.Base","Neo.component.Progress":"Neo.component.Base","Neo.component.Sparkline":"Neo.component.Canvas","Neo.component.Splitter":"Neo.component.Base","Neo.component.StatusBadge":"Neo.component.Base","Neo.component.Timer":"Neo.component.Base","Neo.component.Toast":"Neo.component.Base","Neo.component.Video":"Neo.component.Base","Neo.component.markdown.Component":"Neo.component.Base","Neo.component.markdown.Parser":null,"Neo.component.mwc.Button":"Neo.component.Base","Neo.component.mwc.TextField":"Neo.component.Base","Neo.component.wrapper.AmChart":"Neo.component.Base","Neo.component.wrapper.CesiumJS":"Neo.component.Base","Neo.component.wrapper.GoogleMaps":"Neo.component.Base","Neo.component.wrapper.MapboxGL":"Neo.component.Base","Neo.component.wrapper.Mermaid":"Neo.component.Base","Neo.component.wrapper.MonacoEditor":"Neo.component.Base","Neo.component.wrapper.OpenStreetMaps":"Neo.component.Base","Neo.container.Accordion":"Neo.container.Panel","Neo.container.AccordionItem":"Neo.container.Base","Neo.container.Base":"Neo.component.Base","Neo.container.Fragment":"Neo.container.Base","Neo.container.Panel":"Neo.container.Base","Neo.container.Viewport":"Neo.container.Base","Neo.controller.Application":"Neo.controller.Base","Neo.controller.Base":"Neo.core.Base","Neo.controller.Component":"Neo.controller.Base","Neo.core.Base":null,"Neo.core.Compare":null,"Neo.core.Config":null,"Neo.core.Effect":null,"Neo.core.EffectManager":null,"Neo.core.Observable":"Neo.core.Base","Neo.core.Util":null,"Neo.dashboard.Container":"Neo.container.Base","Neo.dashboard.Panel":"Neo.container.Panel","Neo.dashboard.dock.Workspace":"Neo.container.Base","Neo.dashboard.dock.interaction.DockSplitter":"Neo.component.Splitter","Neo.dashboard.dock.interaction.DragAffordances":"Neo.core.Base","Neo.dashboard.dock.interaction.DropIndicators":"Neo.container.Base","Neo.dashboard.dock.interaction.Preview":"Neo.component.Base","Neo.dashboard.dock.interaction.PreviewProducer":"Neo.core.Base","Neo.dashboard.dock.interaction.Rail":"Neo.container.Base","Neo.dashboard.dock.interaction.RevealOverlay":"Neo.container.Base","Neo.dashboard.dock.interaction.RevealStateMachine":null,"Neo.dashboard.dock.interaction.TabEnterButton":"Neo.tab.header.Button","Neo.dashboard.dock.interaction.TabSortZone":"Neo.draggable.tab.header.toolbar.SortZone","Neo.dashboard.dock.model.Document":"Neo.core.Base","Neo.dashboard.dock.model.Operations":"Neo.core.Base","Neo.dashboard.dock.model.Persistence":"Neo.core.Base","Neo.dashboard.dock.model.TopologyDiff":"Neo.core.Base","Neo.dashboard.dock.model.TopologyReconciler":"Neo.core.Base","Neo.dashboard.dock.persistence.PerspectiveLibrary":"Neo.core.Base","Neo.dashboard.dock.persistence.RestorePlanner":"Neo.core.Base","Neo.dashboard.dock.projection.LayoutAdapter":"Neo.core.Base","Neo.dashboard.dock.projection.MotionSignal":"Neo.core.Base","Neo.dashboard.dock.projection.Reconciler":"Neo.core.Base","Neo.dashboard.dock.window.DragTarget":"Neo.core.Base","Neo.dashboard.dock.window.Participation":"Neo.core.Base","Neo.data.Model":"Neo.core.Base","Neo.data.Pipeline":"Neo.core.Base","Neo.data.RecordFactory":"Neo.core.Base","Neo.data.Store":"Neo.collection.Base","Neo.data.TreeModel":"Neo.data.Model","Neo.data.TreeStore":"Neo.data.Store","Neo.data.connection.Base":"Neo.core.Base","Neo.data.connection.Fetch":"Neo.data.connection.Base","Neo.data.connection.Rpc":"Neo.data.connection.Base","Neo.data.connection.Stream":"Neo.data.connection.Base","Neo.data.connection.WebSocket":"Neo.data.connection.Base","Neo.data.connection.Xhr":"Neo.data.connection.Base","Neo.data.normalizer.Base":"Neo.core.Base","Neo.data.normalizer.Tree":"Neo.data.normalizer.Base","Neo.data.parser.Base":"Neo.core.Base","Neo.data.parser.Stream":"Neo.data.parser.Base","Neo.date.DayViewComponent":"Neo.component.Base","Neo.date.SelectorContainer":"Neo.container.Base","Neo.date.SelectorContainerStateProvider":"Neo.state.Provider","Neo.dialog.Base":"Neo.container.Panel","Neo.dialog.header.Toolbar":"Neo.toolbar.Base","Neo.draggable.DragProxyComponent":"Neo.component.Base","Neo.draggable.DragProxyContainer":"Neo.container.Base","Neo.draggable.DragZone":"Neo.core.Base","Neo.draggable.DropZone":"Neo.core.Base","Neo.draggable.container.DragZone":"Neo.draggable.DragZone","Neo.draggable.container.SortZone":"Neo.draggable.container.DragZone","Neo.draggable.dashboard.SortZone":"Neo.draggable.container.SortZone","Neo.draggable.grid.header.toolbar.SortZone":"Neo.draggable.container.SortZone","Neo.draggable.list.DragZone":"Neo.draggable.DragZone","Neo.draggable.list.SortZone":"Neo.draggable.list.DragZone","Neo.draggable.tab.header.toolbar.SortZone":"Neo.draggable.container.SortZone","Neo.draggable.table.header.toolbar.SortZone":"Neo.draggable.container.SortZone","Neo.draggable.tree.DragZone":"Neo.draggable.list.DragZone","Neo.draggable.tree.SortZone":"Neo.draggable.tree.DragZone","Neo.filter.BooleanContainer":"Neo.container.Base","Neo.filter.DateContainer":"Neo.filter.NumberContainer","Neo.filter.NumberContainer":"Neo.container.Base","Neo.filter.ToggleOperatorsButton":"Neo.button.Base","Neo.form.Container":"Neo.container.Base","Neo.form.Fieldset":"Neo.form.Container","Neo.form.field.Base":"Neo.component.Base","Neo.form.field.CheckBox":"Neo.form.field.Base","Neo.form.field.Chip":"Neo.form.field.ComboBox","Neo.form.field.Color":"Neo.form.field.ComboBox","Neo.form.field.ComboBox":"Neo.form.field.Picker","Neo.form.field.Country":"Neo.form.field.ComboBox","Neo.form.field.Currency":"Neo.form.field.Number","Neo.form.field.Date":"Neo.form.field.Picker","Neo.form.field.Display":"Neo.form.field.Text","Neo.form.field.Email":"Neo.form.field.Text","Neo.form.field.FileUpload":"Neo.form.field.Base","Neo.form.field.Hidden":"Neo.form.field.Base","Neo.form.field.Number":"Neo.form.field.Text","Neo.form.field.Password":"Neo.form.field.Text","Neo.form.field.Phone":"Neo.form.field.Text","Neo.form.field.Picker":"Neo.form.field.Text","Neo.form.field.Radio":"Neo.form.field.CheckBox","Neo.form.field.Range":"Neo.form.field.Number","Neo.form.field.Search":"Neo.form.field.Text","Neo.form.field.Switch":"Neo.form.field.CheckBox","Neo.form.field.Text":"Neo.form.field.Base","Neo.form.field.TextArea":"Neo.form.field.Text","Neo.form.field.Time":"Neo.form.field.Picker","Neo.form.field.Url":"Neo.form.field.Text","Neo.form.field.ZipCode":"Neo.form.field.Text","Neo.form.field.chip.ValueList":"Neo.list.Chip","Neo.form.field.fileUpload.Transport":"Neo.core.Base","Neo.form.field.fileUpload.Xhr":"Neo.form.field.fileUpload.Transport","Neo.form.field.trigger.Base":"Neo.component.Base","Neo.form.field.trigger.Clear":"Neo.form.field.trigger.Base","Neo.form.field.trigger.CopyToClipboard":"Neo.form.field.trigger.Base","Neo.form.field.trigger.Date":"Neo.form.field.trigger.Picker","Neo.form.field.trigger.Picker":"Neo.form.field.trigger.Base","Neo.form.field.trigger.Search":"Neo.form.field.trigger.Picker","Neo.form.field.trigger.SpinDown":"Neo.form.field.trigger.Base","Neo.form.field.trigger.SpinUp":"Neo.form.field.trigger.Base","Neo.form.field.trigger.SpinUpDown":"Neo.form.field.trigger.Base","Neo.form.field.trigger.Time":"Neo.form.field.trigger.Picker","Neo.functional.button.Base":"Neo.functional.component.Base","Neo.functional.component.Base":"Neo.component.Abstract","Neo.functional.util.HtmlTemplateProcessor":"Neo.core.Base","Neo.functional.util.html":null,"Neo.grid.Body":"Neo.component.Base","Neo.grid.Container":"Neo.container.Base","Neo.grid.HorizontalScrollbar":"Neo.component.Base","Neo.grid.Row":"Neo.component.Base","Neo.grid.ScrollManager":"Neo.core.Base","Neo.grid.VerticalScrollbar":"Neo.component.Base","Neo.grid.View":"Neo.container.Base","Neo.grid.column.AnimatedChange":"Neo.grid.column.Base","Neo.grid.column.AnimatedCurrency":"Neo.grid.column.AnimatedChange","Neo.grid.column.Base":"Neo.core.Base","Neo.grid.column.Component":"Neo.grid.column.Base","Neo.grid.column.CountryFlag":"Neo.grid.column.Component","Neo.grid.column.Currency":"Neo.grid.column.Base","Neo.grid.column.GitHubOrgs":"Neo.grid.column.Component","Neo.grid.column.GitHubUser":"Neo.grid.column.Component","Neo.grid.column.Icon":"Neo.grid.column.Component","Neo.grid.column.IconLink":"Neo.grid.column.Component","Neo.grid.column.Index":"Neo.grid.column.Base","Neo.grid.column.LinkedIn":"Neo.grid.column.Component","Neo.grid.column.Progress":"Neo.grid.column.Component","Neo.grid.column.Sparkline":"Neo.grid.column.Component","Neo.grid.column.Tree":"Neo.grid.column.Component","Neo.grid.column.component.Tree":"Neo.component.Base","Neo.grid.footer.Toolbar":"Neo.toolbar.Base","Neo.grid.header.Button":"Neo.button.Base","Neo.grid.header.Toolbar":"Neo.toolbar.Base","Neo.grid.header.Wrapper":"Neo.container.Base","Neo.grid.header.plugin.Resizable":"Neo.plugin.Resizable","Neo.grid.plugin.AnimateRows":"Neo.plugin.Base","Neo.grid.plugin.CellEditing":"Neo.table.plugin.CellEditing","Neo.layout.Base":"Neo.core.Base","Neo.layout.Card":"Neo.layout.Base","Neo.layout.Cube":"Neo.layout.Card","Neo.layout.Fit":"Neo.layout.Base","Neo.layout.Flexbox":"Neo.layout.Base","Neo.layout.Form":"Neo.layout.Base","Neo.layout.Grid":"Neo.layout.Base","Neo.layout.HBox":"Neo.layout.Flexbox","Neo.layout.VBox":"Neo.layout.Flexbox","Neo.list.Base":"Neo.component.Base","Neo.list.Buffered":"Neo.list.Component","Neo.list.Chip":"Neo.list.Component","Neo.list.Circle":"Neo.list.Component","Neo.list.Color":"Neo.list.Base","Neo.list.Component":"Neo.list.Base","Neo.list.Country":"Neo.list.Base","Neo.list.plugin.Animate":"Neo.plugin.Base","Neo.main.DeltaUpdates":"Neo.core.Base","Neo.main.DomAccess":"Neo.core.Base","Neo.main.DomEvents":"Neo.core.Base","Neo.main.DomUtils":"Neo.core.Base","Neo.main.addon.AmCharts":"Neo.main.addon.Base","Neo.main.addon.AnalyticsByGoogle":"Neo.main.addon.Base","Neo.main.addon.Base":"Neo.core.Base","Neo.main.addon.CesiumJS":"Neo.main.addon.Base","Neo.main.addon.CloneNode":"Neo.main.addon.Base","Neo.main.addon.Cookie":"Neo.main.addon.Base","Neo.main.addon.DockFlip":"Neo.main.addon.Base","Neo.main.addon.DocumentHead":"Neo.main.addon.Base","Neo.main.addon.DragDrop":"Neo.main.addon.Base","Neo.main.addon.EventSimulator":"Neo.main.addon.Base","Neo.main.addon.FileSystemAccess":"Neo.main.addon.Base","Neo.main.addon.GoogleMaps":"Neo.main.addon.Base","Neo.main.addon.GridDragScroll":"Neo.main.addon.Base","Neo.main.addon.GridHorizontalScrollSync":"Neo.main.addon.Base","Neo.main.addon.GridRowHoverSync":"Neo.main.addon.Base","Neo.main.addon.GridRowScrollPinning":"Neo.main.addon.Base","Neo.main.addon.HighlightJS":"Neo.main.addon.Base","Neo.main.addon.InputModality":"Neo.main.addon.Base","Neo.main.addon.IntersectionObserver":"Neo.main.addon.Base","Neo.main.addon.LocalStorage":"Neo.main.addon.Base","Neo.main.addon.MapboxGL":"Neo.main.addon.Base","Neo.main.addon.Markdown":"Neo.main.addon.Base","Neo.main.addon.Mermaid":"Neo.main.addon.Base","Neo.main.addon.MonacoEditor":"Neo.main.addon.Base","Neo.main.addon.Mwc":"Neo.main.addon.Base","Neo.main.addon.Navigator":"Neo.main.addon.Base","Neo.main.addon.OpenStreetMaps":"Neo.main.addon.Base","Neo.main.addon.Popover":"Neo.main.addon.Base","Neo.main.addon.PrefixField":"Neo.main.addon.Base","Neo.main.addon.ResizeObserver":"Neo.main.addon.Base","Neo.main.addon.ScrollSync":"Neo.main.addon.Base","Neo.main.addon.ServerSideRendering":"Neo.main.addon.Base","Neo.main.addon.ServiceWorker":"Neo.main.addon.Base","Neo.main.addon.Stylesheet":"Neo.main.addon.Base","Neo.main.addon.WebComponent":"Neo.main.addon.Base","Neo.main.addon.WindowPosition":"Neo.main.addon.Base","Neo.main.draggable.Resize":null,"Neo.main.draggable.sensor.Base":"Neo.core.Base","Neo.main.draggable.sensor.Mouse":"Neo.main.draggable.sensor.Base","Neo.main.draggable.sensor.Touch":"Neo.main.draggable.sensor.Base","Neo.main.mixin.TouchDomEvents":"Neo.core.Base","Neo.manager.Base":"Neo.collection.Base","Neo.manager.Component":"Neo.manager.Base","Neo.manager.DomEvent":"Neo.core.Base","Neo.manager.DragCoordinator":"Neo.manager.Base","Neo.manager.Focus":"Neo.core.Base","Neo.manager.Instance":"Neo.manager.Base","Neo.manager.Store":"Neo.manager.Base","Neo.manager.Task":"Neo.manager.Base","Neo.manager.Toast":"Neo.manager.Base","Neo.manager.VDomUpdate":"Neo.collection.Base","Neo.manager.Window":"Neo.manager.Base","Neo.manager.rpc.Api":"Neo.manager.Base","Neo.manager.rpc.Message":"Neo.manager.Base","Neo.menu.List":"Neo.list.Base","Neo.menu.Model":"Neo.data.Model","Neo.menu.Panel":"Neo.container.Panel","Neo.menu.Store":"Neo.data.Store","Neo.mixin.DomEvents":"Neo.core.Base","Neo.mixin.VdomLifecycle":"Neo.core.Base","Neo.plugin.Base":"Neo.core.Base","Neo.plugin.Popover":"Neo.plugin.Base","Neo.plugin.PrefixField":"Neo.plugin.Base","Neo.plugin.Resizable":"Neo.plugin.Base","Neo.plugin.Responsive":"Neo.plugin.Base","Neo.remotes.Api":"Neo.core.Base","Neo.selection.CircleModel":"Neo.selection.Model","Neo.selection.DateSelectorModel":"Neo.selection.Model","Neo.selection.GalleryModel":"Neo.selection.Model","Neo.selection.HelixModel":"Neo.selection.Model","Neo.selection.ListModel":"Neo.selection.Model","Neo.selection.Model":"Neo.core.Base","Neo.selection.TreeAccordionModel":"Neo.selection.TreeModel","Neo.selection.TreeModel":"Neo.selection.ListModel","Neo.selection.grid.BaseModel":"Neo.selection.Model","Neo.selection.grid.CellColumnModel":"Neo.selection.grid.CellModel","Neo.selection.grid.CellColumnRowModel":"Neo.selection.grid.CellRowModel","Neo.selection.grid.CellModel":"Neo.selection.grid.BaseModel","Neo.selection.grid.CellRowModel":"Neo.selection.grid.CellModel","Neo.selection.grid.ColumnModel":"Neo.selection.grid.BaseModel","Neo.selection.grid.RowModel":"Neo.selection.grid.BaseModel","Neo.selection.menu.ListModel":"Neo.selection.ListModel","Neo.selection.table.BaseModel":"Neo.selection.Model","Neo.selection.table.CellColumnModel":"Neo.selection.table.CellModel","Neo.selection.table.CellColumnRowModel":"Neo.selection.table.CellRowModel","Neo.selection.table.CellModel":"Neo.selection.table.BaseModel","Neo.selection.table.CellRowModel":"Neo.selection.table.CellModel","Neo.selection.table.ColumnModel":"Neo.selection.table.BaseModel","Neo.selection.table.RowModel":"Neo.selection.table.BaseModel","Neo.sitemap.Component":"Neo.component.Base","Neo.sitemap.Model":"Neo.data.Model","Neo.sitemap.Store":"Neo.data.Store","Neo.state.Provider":"Neo.core.Base","Neo.tab.BodyContainer":"Neo.container.Base","Neo.tab.Container":"Neo.container.Base","Neo.tab.Strip":"Neo.component.Base","Neo.tab.header.Button":"Neo.button.Base","Neo.tab.header.EffectButton":"Neo.button.Effect","Neo.tab.header.Toolbar":"Neo.toolbar.Base","Neo.tab.plugin.Overflow":"Neo.plugin.Base","Neo.table.Body":"Neo.component.Base","Neo.table.Container":"Neo.container.Base","Neo.table.header.Button":"Neo.button.Base","Neo.table.header.Toolbar":"Neo.toolbar.Base","Neo.table.plugin.CellEditing":"Neo.plugin.Base","Neo.toolbar.Base":"Neo.container.Base","Neo.toolbar.Breadcrumb":"Neo.toolbar.Base","Neo.toolbar.Paging":"Neo.toolbar.Base","Neo.tooltip.Base":"Neo.container.Base","Neo.tree.Accordion":"Neo.tree.List","Neo.tree.List":"Neo.list.Base","Neo.util.Array":"Neo.core.Base","Neo.util.ClassSystem":"Neo.core.Base","Neo.util.CountryFlags":"Neo.core.Base","Neo.util.Css":"Neo.core.Base","Neo.util.Date":"Neo.core.Base","Neo.util.HashHistory":"Neo.core.Base","Neo.util.HighlightJs":"Neo.core.Base","Neo.util.HighlightJsLineNumbers":"Neo.core.Base","Neo.util.Json":"Neo.core.Base","Neo.util.KeyNavigation":"Neo.core.Base","Neo.util.Logger":"Neo.core.Base","Neo.util.Matrix":"Neo.core.Base","Neo.util.Performance":"Neo.core.Base","Neo.util.Rectangle":"DOMRect","Neo.util.String":"Neo.core.Base","Neo.util.Style":"Neo.core.Base","Neo.util.VDom":"Neo.core.Base","Neo.util.VNode":"Neo.core.Base","Neo.util.vdom.TreeBuilder":"Neo.core.Base","Neo.vdom.Helper":"Neo.core.Base","Neo.vdom.VNode":null,"Neo.vdom.util.StringFromVnode":null,"Neo.worker.App":"Neo.worker.Base","Neo.worker.Base":"Neo.core.Base","Neo.worker.Canvas":"Neo.worker.Base","Neo.worker.Data":"Neo.worker.Base","Neo.worker.Manager":"Neo.core.Base","Neo.worker.Message":null,"Neo.worker.ServiceBase":"Neo.core.Base","Neo.worker.Task":"Neo.worker.Base","Neo.worker.VDom":"Neo.worker.Base","Neo.worker.mixin.RemoteMethodAccess":"Neo.core.Base","Portal.canvas.FooterCanvas":"Portal.canvas.Base","Portal.canvas.Helper":"Neo.core.Base","Portal.canvas.HomeCanvas":"Portal.canvas.Base","Portal.canvas.ServicesCanvas":"Portal.canvas.Base","Portal.canvas.TimelineCanvas":"Portal.canvas.Base","Portal.childapps.preview.MainContainer":"Neo.container.Viewport","Portal.model.BlogMedium":"Neo.data.Model","Portal.model.BlogNeo":"Neo.data.Model","Portal.model.Content":"Neo.data.Model","Portal.model.ContentSection":"Neo.data.Model","Portal.model.Discussion":"Neo.data.Model","Portal.model.Example":"Neo.data.Model","Portal.model.Pull":"Neo.data.Model","Portal.model.Release":"Neo.data.Model","Portal.model.Ticket":"Neo.data.Model","Portal.model.TicketLabel":"Neo.data.Model","Portal.model.TimelineSection":"Portal.model.ContentSection","Portal.service.Seo":"Neo.core.Base","Portal.store.BlogMedium":"Neo.data.Store","Portal.store.BlogNeo":"Neo.data.Store","Portal.store.Content":"Neo.data.Store","Portal.store.ContentSections":"Neo.data.Store","Portal.store.Discussions":"Neo.data.Store","Portal.store.Examples":"Neo.data.Store","Portal.store.Pulls":"Neo.data.Store","Portal.store.Releases":"Neo.data.Store","Portal.store.TicketLabels":"Neo.data.Store","Portal.store.Tickets":"Neo.data.Store","Portal.store.TimelineSections":"Portal.store.ContentSections","Portal.view.HeaderToolbar":"Neo.app.header.Toolbar","Portal.view.Viewport":"Neo.container.Viewport","Portal.view.ViewportController":"Neo.controller.Component","Portal.view.ViewportStateProvider":"Neo.state.Provider","Portal.view.about.Container":"Neo.container.Base","Portal.view.about.MemberContainer":"Neo.container.Base","Portal.view.content.CanvasWrapper":"Neo.container.Base","Portal.view.content.Component":"Neo.app.content.Component","Portal.view.content.TimelineCanvas":"Neo.app.SharedCanvas","Portal.view.examples.List":"Neo.list.Base","Portal.view.examples.TabContainer":"Portal.view.shared.TabContainer","Portal.view.examples.TabContainerController":"Neo.controller.Component","Portal.view.home.ContentBox":"Neo.component.Base","Portal.view.home.FeatureSection":"Neo.container.Base","Portal.view.home.FooterCanvas":"Neo.app.SharedCanvas","Portal.view.home.FooterContainer":"Neo.container.Base","Portal.view.home.FooterContainerController":"Neo.controller.Component","Portal.view.home.MainContainer":"Neo.container.Base","Portal.view.home.parts.AiToolchain":"Portal.view.home.parts.BaseContainer","Portal.view.home.parts.BaseContainer":"Neo.container.Base","Portal.view.home.parts.Colors":"Portal.view.home.FeatureSection","Portal.view.home.parts.Features":"Portal.view.home.parts.BaseContainer","Portal.view.home.parts.Helix":"Portal.view.home.FeatureSection","Portal.view.home.parts.How":"Portal.view.home.FeatureSection","Portal.view.home.parts.References":"Portal.view.home.parts.BaseContainer","Portal.view.home.parts.hero.Canvas":"Neo.app.SharedCanvas","Portal.view.home.parts.hero.Container":"Portal.view.home.parts.BaseContainer","Portal.view.home.parts.hero.Content":"Neo.container.Base","Portal.view.learn.Component":"Neo.app.content.Component","Portal.view.learn.CubeLayoutButton":"Neo.button.Base","Portal.view.learn.MainContainer":"Neo.app.content.Container","Portal.view.learn.MainContainerController":"Neo.controller.Component","Portal.view.learn.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.TabContainer":"Portal.view.shared.TabContainer","Portal.view.news.TabContainerController":"Neo.controller.Component","Portal.view.news.blog.Component":"Neo.app.content.Component","Portal.view.news.blog.MainContainer":"Neo.app.content.Container","Portal.view.news.blog.MainContainerController":"Neo.controller.Component","Portal.view.news.blog.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.discussions.Component":"Portal.view.content.Component","Portal.view.news.discussions.MainContainer":"Neo.app.content.Container","Portal.view.news.discussions.MainContainerController":"Neo.controller.Component","Portal.view.news.discussions.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.discussions.PageContainer":"Neo.app.content.PageContainer","Portal.view.news.medium.Container":"Neo.container.Base","Portal.view.news.medium.List":"Neo.list.Base","Portal.view.news.pulls.Component":"Neo.app.content.Component","Portal.view.news.pulls.MainContainer":"Neo.app.content.Container","Portal.view.news.pulls.MainContainerController":"Neo.controller.Component","Portal.view.news.pulls.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.pulls.PageContainer":"Neo.app.content.PageContainer","Portal.view.news.release.Component":"Neo.app.content.Component","Portal.view.news.release.MainContainer":"Neo.app.content.Container","Portal.view.news.release.MainContainerController":"Neo.controller.Component","Portal.view.news.release.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.tickets.Component":"Neo.app.content.Component","Portal.view.news.tickets.MainContainer":"Neo.app.content.Container","Portal.view.news.tickets.MainContainerController":"Neo.controller.Component","Portal.view.news.tickets.MainContainerStateProvider":"Neo.state.Provider","Portal.view.news.tickets.PageContainer":"Neo.app.content.PageContainer","Portal.view.services.Canvas":"Neo.app.SharedCanvas","Portal.view.services.Container":"Neo.container.Base","Portal.view.shared.TabContainer":"Neo.tab.Container","RealWorld.api.Article":"RealWorld.api.Base","RealWorld.api.Base":"Neo.core.Base","RealWorld.api.Favorite":"RealWorld.api.Base","RealWorld.api.Profile":"RealWorld.api.Base","RealWorld.api.Tag":"RealWorld.api.Base","RealWorld.api.User":"RealWorld.api.Base","RealWorld.view.FooterComponent":"Neo.component.Base","RealWorld.view.HeaderComponent":"Neo.component.Base","RealWorld.view.HomeComponent":"Neo.component.Base","RealWorld.view.MainContainer":"Neo.container.Viewport","RealWorld.view.MainContainerController":"Neo.controller.Component","RealWorld.view.article.CommentComponent":"Neo.component.Base","RealWorld.view.article.Component":"Neo.component.Base","RealWorld.view.article.CreateCommentComponent":"Neo.component.Base","RealWorld.view.article.CreateComponent":"Neo.component.Base","RealWorld.view.article.PreviewComponent":"Neo.component.Base","RealWorld.view.article.TagListComponent":"Neo.component.Base","RealWorld.view.user.ProfileComponent":"Neo.component.Base","RealWorld.view.user.SettingsComponent":"Neo.component.Base","RealWorld.view.user.SignUpComponent":"Neo.component.Base","RealWorld2.api.Article":"RealWorld2.api.Base","RealWorld2.api.Base":"Neo.core.Base","RealWorld2.api.Favorite":"RealWorld2.api.Base","RealWorld2.api.Profile":"RealWorld2.api.Base","RealWorld2.api.Tag":"RealWorld2.api.Base","RealWorld2.api.User":"RealWorld2.api.Base","RealWorld2.model.ArticlePreview":"Neo.data.Model","RealWorld2.store.ArticlePreviews":"Neo.data.Store","RealWorld2.view.FooterComponent":"Neo.component.Base","RealWorld2.view.HeaderToolbar":"Neo.toolbar.Base","RealWorld2.view.HeaderToolbarController":"Neo.controller.Component","RealWorld2.view.HomeContainer":"Neo.container.Base","RealWorld2.view.MainContainer":"Neo.container.Viewport","RealWorld2.view.MainContainerController":"Neo.controller.Component","RealWorld2.view.article.DetailsContainer":"Neo.form.Container","RealWorld2.view.article.FormContainer":"Neo.form.Container","RealWorld2.view.article.Gallery":"Neo.component.Gallery","RealWorld2.view.article.GalleryContainer":"Neo.examples.component.gallery.MainContainer","RealWorld2.view.article.Helix":"Neo.component.Helix","RealWorld2.view.article.HelixContainer":"Neo.examples.component.helix.Viewport","RealWorld2.view.article.PreviewComponent":"Neo.component.Base","RealWorld2.view.article.PreviewList":"Neo.list.Base","RealWorld2.view.article.TagListComponent":"Neo.component.Base","RealWorld2.view.user.LoginFormContainer":"Neo.form.Container","RealWorld2.view.user.ProfileContainer":"Neo.container.Base","RealWorld2.view.user.SettingsFormContainer":"Neo.form.Container","Route.view.ButtonBar":"Neo.container.Base","Route.view.CenterContainer":"Neo.container.Base","Route.view.FooterContainer":"Neo.container.Base","Route.view.HeaderContainer":"Neo.container.Base","Route.view.MainView":"Neo.container.Viewport","Route.view.MainViewController":"Neo.controller.Component","Route.view.MetaContainer":"Neo.container.Base","Route.view.center.CardAdministration":"Neo.container.Base","Route.view.center.CardAdministrationDenied":"Neo.container.Base","Route.view.center.CardContact":"Neo.container.Base","Route.view.center.CardHome":"Neo.container.Base","Route.view.center.CardSection1":"Neo.container.Base","Route.view.center.CardSection2":"Neo.container.Base","SharedCovid.Util":"Neo.core.Base","SharedCovid.childapps.sharedcovidchart.MainContainer":"Neo.container.Viewport","SharedCovid.childapps.sharedcovidgallery.MainContainer":"Neo.container.Viewport","SharedCovid.childapps.sharedcovidhelix.MainContainer":"Neo.container.Viewport","SharedCovid.childapps.sharedcovidmap.MainContainer":"Neo.container.Viewport","SharedCovid.model.Country":"Neo.data.Model","SharedCovid.model.HistoricalData":"Neo.data.Model","SharedCovid.store.Countries":"Neo.data.Store","SharedCovid.store.HistoricalData":"Neo.data.Store","SharedCovid.view.AttributionComponent":"Neo.component.Base","SharedCovid.view.FooterContainer":"Neo.container.Base","SharedCovid.view.GalleryContainer":"Neo.container.Base","SharedCovid.view.GalleryContainerController":"Neo.controller.Component","SharedCovid.view.HeaderContainer":"Neo.container.Base","SharedCovid.view.HelixContainer":"Neo.container.Base","SharedCovid.view.HelixContainerController":"Neo.controller.Component","SharedCovid.view.MainContainer":"Neo.container.Viewport","SharedCovid.view.MainContainerController":"Neo.controller.Component","SharedCovid.view.MainContainerStateProvider":"Neo.state.Provider","SharedCovid.view.TableContainer":"Neo.container.Base","SharedCovid.view.TableContainerController":"Neo.controller.Component","SharedCovid.view.WorldMapComponent":"Neo.component.wrapper.AmChart","SharedCovid.view.WorldMapContainer":"Neo.container.Base","SharedCovid.view.WorldMapContainerController":"Neo.controller.Component","SharedCovid.view.country.Gallery":"Neo.component.Gallery","SharedCovid.view.country.Helix":"Neo.component.Helix","SharedCovid.view.country.HistoricalDataTable":"Neo.table.Container","SharedCovid.view.country.LineChartComponent":"Neo.component.wrapper.AmChart","SharedCovid.view.country.Table":"Neo.table.Container","SharedCovid.view.mapboxGl.Component":"Neo.component.wrapper.MapboxGL","SharedCovid.view.mapboxGl.Container":"Neo.container.Base","SharedCovid.view.mapboxGl.ContainerController":"Neo.controller.Component","SharedDialog.childapps.shareddialog2.view.MainContainer":"Neo.container.Viewport","SharedDialog.childapps.shareddialog2.view.MainContainerController":"Neo.controller.Component","SharedDialog.view.DemoDialog":"Neo.dialog.Base","SharedDialog.view.MainContainer":"Neo.container.Viewport","SharedDialog.view.MainContainerController":"Neo.controller.Component"} \ No newline at end of file diff --git a/examples/dashboard/choreography/DemoAWorkspace.mjs b/examples/dashboard/choreography/DemoAWorkspace.mjs index 892dd98d25..d91dcddcda 100644 --- a/examples/dashboard/choreography/DemoAWorkspace.mjs +++ b/examples/dashboard/choreography/DemoAWorkspace.mjs @@ -1,11 +1,11 @@ import ClockPane from './ClockPane.mjs'; import Container from '../../../src/container/Base.mjs'; -import DockDragAffordances from '../../../src/dashboard/DockDragAffordances.mjs'; -import DockDropIndicators from '../../../src/dashboard/DockDropIndicators.mjs'; -import DockPreview from '../../../src/dashboard/DockPreview.mjs'; +import DockDragAffordances from '../../../src/dashboard/dock/interaction/DragAffordances.mjs'; +import DockDropIndicators from '../../../src/dashboard/dock/interaction/DropIndicators.mjs'; +import DockPreview from '../../../src/dashboard/dock/interaction/Preview.mjs'; import DockService from '../../../src/ai/client/DockService.mjs'; -import DockWorkspace from '../../../src/dashboard/DockWorkspace.mjs'; -import DockZoneModel from '../../../src/dashboard/DockZoneModel.mjs'; +import DockWorkspace from '../../../src/dashboard/dock/Workspace.mjs'; +import Document from '../../../src/dashboard/dock/model/Document.mjs'; import TourRunner from '../../../src/ai/client/TourRunner.mjs'; import {demoATourScript, initialDocument} from './demoADockChoreography.mjs'; import '../../../src/button/Base.mjs'; // registers the `button` ntype the tour bar composes @@ -16,7 +16,7 @@ import '../../../src/toolbar/Base.mjs'; // registers the `toolbar` ntype the to * @summary The Demo-A showcase workspace: the reducer-container that hosts the dock * choreography and plays its screenplay through the tour runner. * - * The normative workspace host is the engine class {@link Neo.dashboard.DockWorkspace}, which the + * The normative workspace host is the engine class {@link Neo.dashboard.dock.Workspace}, which the * docking design record fixes as canonical; this class is one of its CONSUMERS, not the pattern * itself. Everything the holder contract requires — the committed dock-zone document as single * source of truth, the pure reducer, the read half Neural Link topology calls before any operation @@ -39,7 +39,7 @@ import '../../../src/toolbar/Base.mjs'; // registers the `toolbar` ntype the to * siblings. The `workspace` advisory block of the screenplay (hover-reveal opt-in) is threaded * into the projection options — inert until the rail interaction layer lands, correct afterwards. * @class Neo.examples.dashboard.choreography.DemoAWorkspace - * @extends Neo.dashboard.DockWorkspace + * @extends Neo.dashboard.dock.Workspace */ class DemoAWorkspace extends DockWorkspace { static config = { @@ -102,7 +102,7 @@ class DemoAWorkspace extends DockWorkspace { * The shared drag-affordance gesture controller (owner duck-type: this workspace). * Composed in {@link #construct} over the persistent overlay siblings; cleared on every * re-projection and destroyed with the workspace. - * @member {Neo.dashboard.DockDragAffordances|null} dragAffordances=null + * @member {Neo.dashboard.dock.interaction.DragAffordances|null} dragAffordances=null */ dragAffordances = null @@ -120,7 +120,7 @@ class DemoAWorkspace extends DockWorkspace { let me = this; - me.dockModel = DockZoneModel.clone(initialDocument); + me.dockModel = Document.clone(initialDocument); me.dockService = Neo.create(DockService, {}); me.tourRunner = Neo.create(TourRunner, { @@ -377,7 +377,7 @@ class DemoAWorkspace extends DockWorkspace { if (me.tourRunner.log.length) { // restart semantics: reset the stage to the opening document before replaying - me.onDockZoneDocumentChange(DockZoneModel.clone(initialDocument)); + me.onDockZoneDocumentChange(Document.clone(initialDocument)); await me.refreshPromise } diff --git a/examples/dashboard/choreography/demoADockChoreography.mjs b/examples/dashboard/choreography/demoADockChoreography.mjs index 9a6c1a8757..57b44cf2b0 100644 --- a/examples/dashboard/choreography/demoADockChoreography.mjs +++ b/examples/dashboard/choreography/demoADockChoreography.mjs @@ -54,7 +54,7 @@ * @type {Object} */ export const initialDocument = Object.freeze({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { editor : {componentRef: 'Editor', title: 'Editor', kind: 'panel'}, diff --git a/examples/dashboard/crossWindow/DemoBCrossWindowStage.mjs b/examples/dashboard/crossWindow/DemoBCrossWindowStage.mjs index 801d286fab..ad9d4621e1 100644 --- a/examples/dashboard/crossWindow/DemoBCrossWindowStage.mjs +++ b/examples/dashboard/crossWindow/DemoBCrossWindowStage.mjs @@ -1,8 +1,8 @@ import Container from '../../../src/container/Base.mjs'; -import DockDropIndicators from '../../../src/dashboard/DockDropIndicators.mjs'; -import DockPreview from '../../../src/dashboard/DockPreview.mjs'; -import DockZoneModel from '../../../src/dashboard/DockZoneModel.mjs'; -import {previewToOperation} from '../../../src/dashboard/dockPreviewContract.mjs'; +import DockDropIndicators from '../../../src/dashboard/dock/interaction/DropIndicators.mjs'; +import DockPreview from '../../../src/dashboard/dock/interaction/Preview.mjs'; +import Document from '../../../src/dashboard/dock/model/Document.mjs'; +import {previewToOperation} from '../../../src/dashboard/dock/model/PreviewContract.mjs'; /** * @module Neo.examples.dashboard.crossWindow.DemoBCrossWindowStage @@ -34,7 +34,7 @@ import {previewToOperation} from '../../../src/dashboard/dockPreviewContract.mjs * @param {Object} seams * @param {Object} seams.registries Stable host-owned collections, captured once: * `{hosts: Map, participations: Map, geometry: Map, projectionRequests: Map, detachedPanes: Object}`. - * @param {Neo.dashboard.DockWorkspaceSet} seams.workspaceSet The host's workspace-set registry. + * @param {Neo.dashboard.dock.window.WorkspaceSet} seams.workspaceSet The host's workspace-set registry. * @param {Object} seams.workspaceIds `{main, popup, popup2}` semantic workspace ids (`popup2` * optional — the stage parameterizes over every popup id the host registers). * @param {String} seams.sortGroup The shared cross-window coordinator sort group. @@ -152,10 +152,10 @@ export function createCrossWindowStage(seams) { * @param {String} windowId * @param {Neo.container.Base} host * @param {Number} generation - * @returns {Promise} + * @returns {Promise} */ async function createParticipation(workspaceId, windowId, host, generation) { - let Participation = (await import('../../../src/dashboard/DockCrossWindowParticipation.mjs')).default; + let Participation = (await import('../../../src/dashboard/dock/window/Participation.mjs')).default; if (!isTargetCurrent(workspaceId, windowId, host, generation)) { return null @@ -451,11 +451,11 @@ export function createCrossWindowStage(seams) { if (descriptor?.operation !== 'transferNode' || sourceWorkspaceId !== workspaceIds.popup || targetWorkspaceId !== workspaceIds.main - || DockZoneModel.resolveStackRoot(sourceBefore) !== descriptor.nodeId) { + || Document.resolveStackRoot(sourceBefore) !== descriptor.nodeId) { return false } - let nodeIds = DockZoneModel.reachableNodeIds({nodes: sourceBefore.nodes, root: descriptor.nodeId}), + let nodeIds = Document.reachableNodeIds({nodes: sourceBefore.nodes, root: descriptor.nodeId}), itemIds = [...new Set([...nodeIds].flatMap(nodeId => sourceBefore.nodes[nodeId]?.type === 'tabs' ? sourceBefore.nodes[nodeId].items || [] : [] ))]; diff --git a/examples/dashboard/crossWindow/DemoBWorkspace.mjs b/examples/dashboard/crossWindow/DemoBWorkspace.mjs index e94d07e8ca..3eabdcd5cf 100644 --- a/examples/dashboard/crossWindow/DemoBWorkspace.mjs +++ b/examples/dashboard/crossWindow/DemoBWorkspace.mjs @@ -2,24 +2,26 @@ import Component from '../../../src/component/Base.mj import Container from '../../../src/container/Base.mjs'; import CounterPane from './CounterPane.mjs'; import {createCrossWindowStage} from './DemoBCrossWindowStage.mjs'; -import DockDropIndicators from '../../../src/dashboard/DockDropIndicators.mjs'; -import DockLayoutAdapter from '../../../src/dashboard/DockLayoutAdapter.mjs'; -import DockMotionSignal from '../../../src/dashboard/DockMotionSignal.mjs'; -import DockPerspectiveStore from '../../../src/dashboard/DockPerspectiveStore.mjs'; -import DockPreview from '../../../src/dashboard/DockPreview.mjs'; -import DockPreviewProducer from '../../../src/dashboard/DockPreviewProducer.mjs'; -import DockProjectionReconciler from '../../../src/dashboard/DockProjectionReconciler.mjs'; +import DockDropIndicators from '../../../src/dashboard/dock/interaction/DropIndicators.mjs'; +import DockLayoutAdapter from '../../../src/dashboard/dock/projection/LayoutAdapter.mjs'; +import DockMotionSignal from '../../../src/dashboard/dock/projection/MotionSignal.mjs'; +import PerspectiveLibrary from '../../../src/dashboard/dock/persistence/PerspectiveLibrary.mjs'; +import DockPreview from '../../../src/dashboard/dock/interaction/Preview.mjs'; +import DockPreviewProducer from '../../../src/dashboard/dock/interaction/PreviewProducer.mjs'; +import DockProjectionReconciler from '../../../src/dashboard/dock/projection/Reconciler.mjs'; import DockService from '../../../src/ai/client/DockService.mjs'; -import DockTopologyReconciler from '../../../src/dashboard/DockTopologyReconciler.mjs'; -import DockZoneModel from '../../../src/dashboard/DockZoneModel.mjs'; +import DockTopologyReconciler from '../../../src/dashboard/dock/model/TopologyReconciler.mjs'; +import Document from '../../../src/dashboard/dock/model/Document.mjs'; +import Operations from '../../../src/dashboard/dock/model/Operations.mjs'; +import Persistence from '../../../src/dashboard/dock/model/Persistence.mjs'; import InteractionService from '../../../src/ai/client/InteractionService.mjs'; -import {createDockKeyboardCommands} from '../../../src/dashboard/DockKeyboardCommands.mjs'; -import {createDockTearOutHandlers} from '../../../src/dashboard/DockTearOut.mjs'; -import {createDockVesselEmbodiment} from '../../../src/dashboard/DockVesselEmbodiment.mjs'; -import {createDockWorkspaceSet} from '../../../src/dashboard/DockWorkspaceSet.mjs'; -import {createVesselParkHandlers} from '../../../src/dashboard/DockVesselPark.mjs'; +import {createDockKeyboardCommands} from '../../../src/dashboard/dock/interaction/KeyboardCommands.mjs'; +import {createDockTearOutHandlers} from '../../../src/dashboard/dock/window/TearOut.mjs'; +import {createDockVesselEmbodiment} from '../../../src/dashboard/dock/window/VesselEmbodiment.mjs'; +import {createDockWorkspaceSet} from '../../../src/dashboard/dock/window/WorkspaceSet.mjs'; +import {createVesselParkHandlers} from '../../../src/dashboard/dock/window/VesselPark.mjs'; import TourRunner from '../../../src/ai/client/TourRunner.mjs'; -import {PREVIEW_SCHEMA, previewToOperation} from '../../../src/dashboard/dockPreviewContract.mjs'; +import {PREVIEW_SCHEMA, previewToOperation} from '../../../src/dashboard/dock/model/PreviewContract.mjs'; import {demoBTourScript, initialDocument} from './demoBPerspectives.mjs'; import '../../../src/button/Base.mjs'; // registers the `button` ntype the bars compose import '../../../src/tab/Container.mjs'; // registers the `tab-container` ntype the projection emits @@ -30,7 +32,7 @@ import '../../../src/toolbar/Base.mjs'; // registers the `toolbar` ntype the ba * leaves for its own OS window and returns with its state unbroken — the only-Neo story. * * **This class hand-rolls a workspace host that the engine now owns, and is not the shape to - * copy.** {@link Neo.dashboard.DockWorkspace} is the normative host — it owns the committed + * copy.** {@link Neo.dashboard.dock.Workspace} is the normative host — it owns the committed * document, the reducer, the read half of the holder contract, the deferred view-sync and the * projection/FLIP loop — and the docking design record fixes it as canonical. Demo A already * consumes it; this workspace has not migrated yet because its cross-window tear-out half is @@ -43,10 +45,10 @@ import '../../../src/toolbar/Base.mjs'; // registers the `toolbar` ntype the ba * the single source of truth; the pure reducer + view-sync halves of the dock-holder * contract) carries the two capabilities this demo exists to show: * - * - **Perspectives** ride a {@link Neo.dashboard.DockPerspectiveStore}: ordinary views are + * - **Perspectives** ride a {@link Neo.dashboard.dock.persistence.PerspectiveLibrary}: ordinary views are * window-scoped; the detached view captures BOTH worker-owned workspace documents through * `captureTopologyPerspective`. Loading that record composes the real - * {@link Neo.dashboard.DockTopologyReconciler} and renders its structured remainder. + * {@link Neo.dashboard.dock.model.TopologyReconciler} and renders its structured remainder. * The switcher bar rebuilds from store lifecycle events — buttons are born from * `perspectiveSaved`, never hardcoded. * - **Pop-out** rides the shared-heap vessel: panes are INSTANCE-CACHED (created once, @@ -162,12 +164,12 @@ class DemoBWorkspace extends Container { interactionService = null /** * Runtime-only dock-preview producer shared by the two window surfaces. - * @member {Neo.dashboard.DockPreviewProducer|null} dockPreviewProducer=null + * @member {Neo.dashboard.dock.interaction.PreviewProducer|null} dockPreviewProducer=null */ dockPreviewProducer = null /** * The named-perspective home. Lifecycle events feed the switcher bar. - * @member {Neo.dashboard.DockPerspectiveStore|null} perspectiveStore=null + * @member {Neo.dashboard.dock.persistence.PerspectiveLibrary|null} perspectiveStore=null */ perspectiveStore = null /** @@ -195,7 +197,7 @@ class DemoBWorkspace extends Container { crossWindowHosts = new Map() /** * Registered target-side participation adapters keyed by workspace id. - * @member {Map} crossWindowParticipations + * @member {Map} crossWindowParticipations * @protected */ crossWindowParticipations = new Map() @@ -437,7 +439,7 @@ class DemoBWorkspace extends Container { let me = this; - me.dockModel = DockZoneModel.clone(initialDocument); + me.dockModel = Document.clone(initialDocument); me.popupDocument = DemoBWorkspace.createPopupDocument(); me.popup2Document = DemoBWorkspace.createPopupDocument(); @@ -468,7 +470,7 @@ class DemoBWorkspace extends Container { me.dockPreviewProducer = Neo.create(DockPreviewProducer); me.dockService = Neo.create(DockService, {}); me.interactionService = Neo.create(InteractionService, {}); - me.perspectiveStore = Neo.create(DockPerspectiveStore, {}); + me.perspectiveStore = Neo.create(PerspectiveLibrary, {}); // The cross-window stage choreography (decomposition Phase 1): a pure decision // machine over host-injected seams. Stage STATE stays host-owned by contract — the @@ -883,7 +885,7 @@ class DemoBWorkspace extends Container { let document = me.workspaceSet.getDocument(workspaceId), nodes = document?.nodes || {}, sourceTabsId = document?.items?.[itemId] - ? DockZoneModel.findContainingTabsId(document, itemId) + ? Document.findContainingTabsId(document, itemId) : null, tabsIds = Object.keys(nodes).filter(nodeId => nodes[nodeId].type === 'tabs' && nodeId !== sourceTabsId @@ -906,7 +908,7 @@ class DemoBWorkspace extends Container { * @summary Render (or clear) the keyboard cycle's current-candidate highlight through the * SHARED drag-affordance consumer: a hand-built `tab-into` dockPreview payload (the contract * module is the pure SSOT; the fail-closed renderer validates it) drives the target host's - * {@link Neo.dashboard.DockPreview} — the same overlay, geometry conversion, and skin the + * {@link Neo.dashboard.dock.interaction.Preview} — the same overlay, geometry conversion, and skin the * pointer hover renders through, so one affordance model serves both input paths. The * indicator MENU stays pointer-owned: its semantics are within-zone position choice, which * the keyboard cycle's zone-target grammar deliberately does not offer. @@ -956,7 +958,7 @@ class DemoBWorkspace extends Container { } /** - * @summary The keyboard transfer commit — `DockZoneModel.transferItem` produces the + * @summary The keyboard transfer commit — `Operations.transferItem` produces the * commit-or-neither document pair, then the shared two-phase core lands it: * {@link #adoptCommittedTransferPair} (both-or-neither adoption, first exit on refusal) and * {@link #reconcileTransferPair} (target-first, unguarded — a discrete command has no @@ -981,7 +983,7 @@ class DemoBWorkspace extends Container { return {errors: [`unknown item "${itemId}"`]} } - let {sourceDocument, targetDocument, errors} = DockZoneModel.transferItem( + let {sourceDocument, targetDocument, errors} = Operations.transferItem( me.workspaceSet.getDocument(sourceWorkspaceId), me.workspaceSet.getDocument(target.workspaceId), { @@ -1068,7 +1070,7 @@ class DemoBWorkspace extends Container { * @returns {{document: Object, errors: String[]}} */ applyDockZoneOperation(descriptor) { - return DockZoneModel.applyOperation(this.dockModel, descriptor) + return Operations.applyOperation(this.dockModel, descriptor) } /** @@ -1117,7 +1119,7 @@ class DemoBWorkspace extends Container { applyWorkspaceOperation(workspaceId, descriptor) { let document = this.getWorkspaceDocument(workspaceId); - return document ? DockZoneModel.applyOperation(document, descriptor) : null + return document ? Operations.applyOperation(document, descriptor) : null } /** @@ -1145,8 +1147,8 @@ class DemoBWorkspace extends Container { } created = scope === 'topology' - ? DockZoneModel.captureTopologyPerspective([me.dockModel, me.popupDocument], metadata) - : DockZoneModel.createSavedLayout(me.dockModel, metadata); + ? Persistence.captureTopologyPerspective([me.dockModel, me.popupDocument], metadata) + : Persistence.createSavedLayout(me.dockModel, metadata); if (created.errors.length) { return {errors: created.errors, saved: false} @@ -1305,7 +1307,7 @@ class DemoBWorkspace extends Container { hasLivePopup = Object.keys(me.detachedPanes).length > 0, liveDocuments = hasLivePopup ? [me.dockModel, me.popupDocument] : [me.dockModel], result = DockTopologyReconciler.reconcile(layout, liveDocuments), - report = DockZoneModel.clone({ + report = Document.clone({ applied : result.applied, displaced : result.displaced, errors : result.errors, @@ -1604,7 +1606,7 @@ class DemoBWorkspace extends Container { let pane = me.paneCache[itemId], framesAfter = pane?.frames ?? -1, - targetTabsId = DockZoneModel.findContainingTabsId(targetDocument, itemId), + targetTabsId = Document.findContainingTabsId(targetDocument, itemId), proof = { framesAfter, framesBefore : context?.frames ?? null, @@ -1615,7 +1617,7 @@ class DemoBWorkspace extends Container { remoteSnapshot : context?.remoteSnapshot ?? null, sameInstance : pane === context?.pane, sourceItemRemoved : !sourceDocument.items?.[itemId] - && DockZoneModel.findContainingTabsId(sourceDocument, itemId) === null, + && Document.findContainingTabsId(sourceDocument, itemId) === null, sourceSuppressionConsumed: sourceDecision.remoteDropOutFires === 1 && sourceDecision.localDropFires === 0, targetItemPlaced : !!targetDocument.items?.[itemId] @@ -1648,8 +1650,8 @@ class DemoBWorkspace extends Container { receipt = { applied : errors.length === 0, errors, - sourceDocument: DockZoneModel.clone(sourceDocument), - targetDocument: DockZoneModel.clone(targetDocument), + sourceDocument: Document.clone(sourceDocument), + targetDocument: Document.clone(targetDocument), witness : { instanceId: pane?.id ?? null, mountCount: pane?.mountCount ?? null @@ -1668,7 +1670,7 @@ class DemoBWorkspace extends Container { * @summary Installs a gesture-local witness around the source's remote-drop-out hook. * The hook itself stays authoritative and runs unchanged; this wrapper only counts how * often the coordinator selected that exact completion path before projection teardown. - * @param {Neo.dashboard.DockTabSortZone} sourceZone + * @param {Neo.dashboard.dock.interaction.TabSortZone} sourceZone * @returns {Object} * @protected */ @@ -1785,8 +1787,8 @@ class DemoBWorkspace extends Container { + (candidateSet?.root?.chips?.length ?? 0), schema : candidateSet?.schema ?? null }, - preview : preview ? DockZoneModel.clone(preview) : null, - rendered: rendered ? DockZoneModel.clone(rendered) : null, + preview : preview ? Document.clone(preview) : null, + rendered: rendered ? Document.clone(rendered) : null, targetNodeId }; @@ -2019,7 +2021,7 @@ class DemoBWorkspace extends Container { } let sourceDocument = me.getWorkspaceDocument(sourceWorkspaceId), - sourceNodeId = DockZoneModel.findContainingTabsId(sourceDocument, itemId), + sourceNodeId = Document.findContainingTabsId(sourceDocument, itemId), sourceItems = sourceDocument.nodes[sourceNodeId]?.items || [], sourceHost = me.crossWindowHosts.get(sourceWorkspaceId), sourceTabs = sourceHost?.down({dockNodeId: sourceNodeId}), @@ -2400,7 +2402,7 @@ class DemoBWorkspace extends Container { detached = { catalogRetained: !!sourceDocument?.items?.[itemId], entry : entry ? {...entry} : null, - itemAbsent : DockZoneModel.findContainingTabsId(sourceDocument, itemId) === null + itemAbsent : Document.findContainingTabsId(sourceDocument, itemId) === null }; if ( @@ -2414,8 +2416,8 @@ class DemoBWorkspace extends Container { await me.awaitProjectionIdle(); let terminalIdentity = identity(), - sourceAfter = DockZoneModel.clone(me.getWorkspaceDocument(sourceWorkspaceId)), - targetAfter = DockZoneModel.clone(me.getWorkspaceDocument(targetWorkspaceId)), + sourceAfter = Document.clone(me.getWorkspaceDocument(sourceWorkspaceId)), + targetAfter = Document.clone(me.getWorkspaceDocument(targetWorkspaceId)), proof = { acquisitionAttempts: { afterRestore : acquisitionsAfterRestore, @@ -2478,12 +2480,12 @@ class DemoBWorkspace extends Container { } if (cancelAtTarget) { - let sourceBefore = DockZoneModel.clone(me.getWorkspaceDocument(sourceWorkspaceId)), - targetBefore = DockZoneModel.clone(me.getWorkspaceDocument(targetWorkspaceId)), + let sourceBefore = Document.clone(me.getWorkspaceDocument(sourceWorkspaceId)), + targetBefore = Document.clone(me.getWorkspaceDocument(targetWorkspaceId)), cancellation = await me.cancelCrossWindowGesture(me.crossWindowGestureContext), cleanup = await me.waitForCrossWindowCancellation(me.crossWindowGestureContext), - sourceAfter = DockZoneModel.clone(me.getWorkspaceDocument(sourceWorkspaceId)), - targetAfter = DockZoneModel.clone(me.getWorkspaceDocument(targetWorkspaceId)), + sourceAfter = Document.clone(me.getWorkspaceDocument(sourceWorkspaceId)), + targetAfter = Document.clone(me.getWorkspaceDocument(targetWorkspaceId)), result = { applied : false, cancelled : true, @@ -2560,7 +2562,7 @@ class DemoBWorkspace extends Container { * Drives the REAL G1 dock tear-out gesture end-to-end for the e2e witness leg. Unlike * {@link #executeCrossWindowStep} (a two-window transfer over the coordinator), this is the * single-window boundary grammar: it arms a tab drag, flings the proxy past the window - * boundary so {@link Neo.dashboard.DockTabSortZone} re-fires `dockTearOutExit`, the host opens + * boundary so {@link Neo.dashboard.dock.interaction.TabSortZone} re-fires `dockTearOutExit`, the host opens * a `?popout=` vessel, then — gated on that vessel's ACTUAL birth * ({@link #onWindowConnect} → {@link #tearOutConnects}) — survives deliberate post-birth moves * (the reap-regression survival probe) and either releases while detached (`dockTearOutTerminal` @@ -2622,7 +2624,7 @@ class DemoBWorkspace extends Container { // The committed document BEFORE the gesture — both the zero-mutation (cancel) and the // detach-commit (terminal) proofs compare against this snapshot. - let documentBefore = DockZoneModel.clone(document), + let documentBefore = Document.clone(document), catalogBefore = Object.keys(documentBefore.items); let startX = buttonRect.x + buttonRect.width / 2, @@ -2725,7 +2727,7 @@ class DemoBWorkspace extends Container { // dockTearOutCancel → the host closes its vessel. Assert the committed document is // byte-identical — the zero-mutation invariant, proven from the third party (the doc). let cancellation = await me.cancelTearOutGesture(button, release), - documentAfter = DockZoneModel.clone(me.getWorkspaceDocument(workspaceId)); + documentAfter = Document.clone(me.getWorkspaceDocument(workspaceId)); return { applied : false, @@ -2751,7 +2753,7 @@ class DemoBWorkspace extends Container { }]}); let committed = await me.waitForTearOutCommit(itemId, sourceNodeId), - documentAfter = DockZoneModel.clone(me.getWorkspaceDocument(workspaceId)), + documentAfter = Document.clone(me.getWorkspaceDocument(workspaceId)), absentFromTree = !Object.values(documentAfter.nodes).some(zoneNode => zoneNode.items?.includes(itemId)), keptInCatalog = Boolean(documentAfter.items?.[itemId]); @@ -3207,7 +3209,7 @@ class DemoBWorkspace extends Container { me.tearOutRetirements.add(itemId); if (me.tearOutEmbodiment.isStaged(itemId)) { - const sourceOwns = Boolean(DockZoneModel.findContainingTabsId(me.dockModel, itemId)); + const sourceOwns = Boolean(Document.findContainingTabsId(me.dockModel, itemId)); me.tearOutEmbodiment[sourceOwns ? 'restore' : 'promote']({itemId, windowId: entry.windowId}) } @@ -3289,7 +3291,7 @@ class DemoBWorkspace extends Container { applyTearOutOperation(descriptor) { let me = this, isDetach = descriptor?.operation === 'detachItem', - captured = isDetach ? DockZoneModel.captureItemPlacement(me.dockModel, descriptor.itemId) : null, + captured = isDetach ? Document.captureItemPlacement(me.dockModel, descriptor.itemId) : null, result; captured && (me.tearOutPlacements[descriptor.itemId] = captured); @@ -3327,7 +3329,7 @@ class DemoBWorkspace extends Container { delete me.tearOutPlacements[itemId]; - if (!doc.items?.[itemId] || !fallback || DockZoneModel.findContainingTabsId(doc, itemId)) { + if (!doc.items?.[itemId] || !fallback || Document.findContainingTabsId(doc, itemId)) { return } @@ -3352,7 +3354,7 @@ class DemoBWorkspace extends Container { async popOutPane(itemId) { let me = this, pane = me.paneCache[itemId], - home = DockZoneModel.findContainingTabsId(me.dockModel, itemId); + home = Document.findContainingTabsId(me.dockModel, itemId); if (!pane || !home || me.detachedPanes[itemId]) { return {detached: false, errors: [`"${itemId}" is not a docked, cached, attached pane`]} @@ -3365,7 +3367,7 @@ class DemoBWorkspace extends Container { popup = Object.keys(me.popupDocument.items || {}).length ? me.popupDocument : DemoBWorkspace.createPopupDocument(), - result = DockZoneModel.transferItem(sourceBefore, popup, { + result = Operations.transferItem(sourceBefore, popup, { itemId, sourceWorkspaceId: 'main', targetWorkspaceId: 'popup', @@ -3503,7 +3505,7 @@ class DemoBWorkspace extends Container { itemId = data.itemId ?? draggedItem?.dockItemId, sourceWorkspaceId = draggedItem?.dockSourceWorkspaceId ?? workspaceId, sourceNodeId = data.sourceNodeId - ?? DockZoneModel.findContainingTabsId(me.getWorkspaceDocument(sourceWorkspaceId), itemId), + ?? Document.findContainingTabsId(me.getWorkspaceDocument(sourceWorkspaceId), itemId), pointer = {x: data.localX ?? data.clientX, y: data.localY ?? data.clientY}; if (!host || !geometry || !itemId || !Neo.isNumber(pointer.x) || !Neo.isNumber(pointer.y)) { @@ -3921,7 +3923,7 @@ class DemoBWorkspace extends Container { /** * The tear-out admission seam: opens the vessel window for a mid-gesture boundary exit. * Reuses the `?popout=` pure-pane-host viewport mode. The granted child immediately carries - * the same live pane through {@link Neo.dashboard.DockVesselEmbodiment}; it still owns no + * the same live pane through {@link Neo.dashboard.dock.window.VesselEmbodiment}; it still owns no * workspace document, and NOTHING is written to {@link #detachedPanes}, so click-pop-out model * machinery remains structurally absent. Fail-closed per the admission contract: `windowOpen` * returns a BOOLEAN (a blocked popup never throws), and any falsy/throwing acquisition returns @@ -4037,7 +4039,7 @@ class DemoBWorkspace extends Container { admission && (admission.invalidated = true); if (embodiedWindowId && me.tearOutEmbodiment.isStaged(itemId)) { - const sourceOwns = Boolean(DockZoneModel.findContainingTabsId(me.dockModel, itemId)), + const sourceOwns = Boolean(Document.findContainingTabsId(me.dockModel, itemId)), settled = me.tearOutEmbodiment[sourceOwns ? 'restore' : 'promote']({ itemId, windowId: embodiedWindowId }); @@ -4173,7 +4175,7 @@ class DemoBWorkspace extends Container { let home = me.dockModel.nodes[entry.tabsNodeId]?.type === 'tabs' ? entry.tabsNodeId : Object.keys(me.dockModel.nodes).find(id => me.dockModel.nodes[id].type === 'tabs'), - result = DockZoneModel.transferItem(me.popupDocument, me.dockModel, { + result = Operations.transferItem(me.popupDocument, me.dockModel, { itemId, sourceWorkspaceId: 'popup', targetWorkspaceId: 'main', @@ -4373,7 +4375,7 @@ class DemoBWorkspace extends Container { me.popupDocument = DemoBWorkspace.createPopupDocument(); await me.onWorkspaceDocumentChange( DemoBWorkspace.MAIN_WORKSPACE_ID, - DockZoneModel.clone(initialDocument) + Document.clone(initialDocument) ) } @@ -4433,7 +4435,7 @@ class DemoBWorkspace extends Container { */ static createPopupDocument() { return { - schema: DockZoneModel.SCHEMA, + schema: Document.SCHEMA, root : 'popup-root', items : {}, nodes : { diff --git a/examples/dashboard/crossWindow/demoBPerspectives.mjs b/examples/dashboard/crossWindow/demoBPerspectives.mjs index de6212c8eb..52b80fe3eb 100644 --- a/examples/dashboard/crossWindow/demoBPerspectives.mjs +++ b/examples/dashboard/crossWindow/demoBPerspectives.mjs @@ -25,7 +25,7 @@ * @type {Object} */ export const initialDocument = Object.freeze({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { workbench: {componentRef: 'Workbench', title: 'Workbench', kind: 'panel'}, diff --git a/examples/dashboard/crossWindowWitness/Harness.mjs b/examples/dashboard/crossWindowWitness/Harness.mjs index 45c4a6e6f1..f23b557676 100644 --- a/examples/dashboard/crossWindowWitness/Harness.mjs +++ b/examples/dashboard/crossWindowWitness/Harness.mjs @@ -76,16 +76,16 @@ class Harness extends Viewport { let me = this, Rectangle = (await import('../../../src/util/Rectangle.mjs')).default, WindowManager = (await import('../../../src/manager/Window.mjs')).default, - Participation = (await import('../../../src/dashboard/DockCrossWindowParticipation.mjs')).default, - DockTabSortZone = (await import('../../../src/dashboard/DockTabSortZone.mjs')).default; + Participation = (await import('../../../src/dashboard/dock/window/Participation.mjs')).default, + DockTabSortZone = (await import('../../../src/dashboard/dock/interaction/TabSortZone.mjs')).default; const sourceDoc = () => ({ - schema: 'neo.harness.dockZone.v1', root: 'root', + schema: 'neo.dock.zone.v1', root: 'root', items : {strategy: {componentRef: 'strategy', title: 'Strategy', kind: 'panel'}, terminal: {componentRef: 'terminal', title: 'Terminal', kind: 'terminal'}}, nodes : {root: {type: 'edge-zone', zones: {center: 'main-tabs', right: 'side-tabs'}}, 'main-tabs': {type: 'tabs', items: ['strategy'], activeItemId: 'strategy'}, 'side-tabs': {type: 'tabs', items: ['terminal'], activeItemId: 'terminal'}} }); const targetDoc = () => ({ - schema: 'neo.harness.dockZone.v1', root: 'root', + schema: 'neo.dock.zone.v1', root: 'root', items : {alpha: {componentRef: 'alpha', title: 'Alpha', kind: 'panel'}}, nodes : {root: {type: 'edge-zone', zones: {center: 'main-tabs'}}, 'main-tabs': {type: 'tabs', items: ['alpha'], activeItemId: 'alpha'}} }); diff --git a/examples/dashboard/dock/MainContainer.mjs b/examples/dashboard/dock/MainContainer.mjs index 77d127408d..97cbeb06a4 100644 --- a/examples/dashboard/dock/MainContainer.mjs +++ b/examples/dashboard/dock/MainContainer.mjs @@ -1,23 +1,25 @@ -import DockService from '../../../src/ai/client/DockService.mjs'; -import DockWorkspace from '../../../src/dashboard/DockWorkspace.mjs'; -import DockZoneModel from '../../../src/dashboard/DockZoneModel.mjs'; -import TourRunner from '../../../src/ai/client/TourRunner.mjs'; +import DockService from '../../../src/ai/client/DockService.mjs'; +import DockWorkspace from '../../../src/dashboard/dock/Workspace.mjs'; +import Document from '../../../src/dashboard/dock/model/Document.mjs'; +import Persistence from '../../../src/dashboard/dock/model/Persistence.mjs'; +import PerspectiveLibrary from '../../../src/dashboard/dock/persistence/PerspectiveLibrary.mjs'; +import TourRunner from '../../../src/ai/client/TourRunner.mjs'; import '../../../src/button/Base.mjs'; // registers the `button` ntype used by the perspective toolbar import '../../../src/tab/Container.mjs'; // registers the `tab-container` ntype the projection emits for tab zones import '../../../src/toolbar/Base.mjs'; // registers the `toolbar` ntype used by the perspective toolbar /** - * A representative dock-zone document (`neo.harness.dockZone.v1`): an edge-zone root whose center is a + * A representative dock-zone document (`neo.dock.zone.v1`): an edge-zone root whose center is a * horizontal split of a two-tab main zone and a vertical side-split of two single-tab zones, plus a * right edge band holding a single-tab inspector zone — the auto-hide surface (committing - * `setItemAutoHidden` on an edge-band item collapses it to a `Neo.dashboard.DockRail` edge tab). - * The shape `Neo.dashboard.DockLayoutAdapter.project` consumes — see its spec for the full contract. + * `setItemAutoHidden` on an edge-band item collapses it to a `Neo.dashboard.dock.interaction.Rail` edge tab). + * The shape `Neo.dashboard.dock.projection.LayoutAdapter.project` consumes — see its spec for the full contract. * Used as the example's INITIAL committed document; the live document advances on each commit * (see `MainContainer#dockModel`). * @type {Object} */ const initialDockModel = { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { strategy : {componentRef: 'Strategy', title: 'Strategy', kind: 'panel'}, @@ -42,7 +44,7 @@ const initialDockModel = { } }; -const reviewDockModel = DockZoneModel.clone(initialDockModel); +const reviewDockModel = Document.clone(initialDockModel); reviewDockModel.nodes['root-split'].sizes = [0.48, 0.52]; reviewDockModel.nodes['main-tabs'].activeItemId = 'swarm'; @@ -65,7 +67,7 @@ const seededPerspectives = [{ /** * @summary Standalone, interactive example for the dashboard dock-zone layout system — the minimal - * consumer of {@link Neo.dashboard.DockWorkspace}. + * consumer of {@link Neo.dashboard.dock.Workspace}. * * The engine class owns the whole host loop: the committed document ({@link #dockModel}), the pure * reducer (`applyDockZoneOperation`), the deferred, promise-chained re-projection through @@ -76,7 +78,7 @@ const seededPerspectives = [{ * * What the example owns is exactly what an adopting app owns: which pane renders a catalog item * ({@link #resolvePane}), and its own chrome — a perspective toolbar consuming the saved-layout - * collection helpers the model exposes (seed perspectives stored as `neo.harness.dockLayoutCollection.v1`, + * collection helpers the model exposes (seed perspectives stored as `neo.dock.layoutCollection.v1`, * selecting a perspective calls `restoreActiveSavedLayout()`, Save Current upserts the live committed * document, Delete Active keeps a valid replacement active) plus browser-local persistence through the * main-thread `LocalStorage` addon, so App-Worker code never reaches for `window.localStorage` directly. @@ -89,7 +91,7 @@ const seededPerspectives = [{ * See `learn/agentos/DockZoneModel.md` for the model/projection contract and * `learn/guides/uibuildingblocks/DockLayouts.md` for the adoption guide. * @class Neo.examples.dashboard.dock.MainContainer - * @extends Neo.dashboard.DockWorkspace + * @extends Neo.dashboard.dock.Workspace */ class MainContainer extends DockWorkspace { static config = { @@ -154,7 +156,7 @@ class MainContainer extends DockWorkspace { let me = this; me.layoutCollection = me.createDefaultLayoutCollection(); - me.dockModel = DockZoneModel.restoreActiveSavedLayout(me.layoutCollection).document || DockZoneModel.clone(initialDockModel); + me.dockModel = PerspectiveLibrary.restoreActiveSavedLayout(me.layoutCollection).document || Document.clone(initialDockModel); me.add(me.buildWorkspaceItems()); me.layoutCollectionLoadPromise = me.loadLayoutCollectionFromStorage() @@ -222,7 +224,7 @@ class MainContainer extends DockWorkspace { await me.refreshPromise; - return {...result, document: DockZoneModel.clone(me.dockModel)} + return {...result, document: Document.clone(me.dockModel)} } finally { runner.destroy(); dockService.destroy() @@ -235,7 +237,7 @@ class MainContainer extends DockWorkspace { */ createDefaultLayoutCollection() { let layouts = seededPerspectives.map(({document, layoutId, title}) => { - let {layout, errors} = DockZoneModel.createSavedLayout(document, { + let {layout, errors} = Persistence.createSavedLayout(document, { layoutId, title, metadata: { @@ -249,7 +251,7 @@ class MainContainer extends DockWorkspace { return layout }), - {collection, errors} = DockZoneModel.createSavedLayoutCollection(layouts, { + {collection, errors} = PerspectiveLibrary.createSavedLayoutCollection(layouts, { activeLayoutId: 'operator-default', metadata : { owner: 'examples/dashboard/dock' @@ -421,19 +423,19 @@ class MainContainer extends DockWorkspace { } parsed = JSON.parse(value); - errors = DockZoneModel.validateSavedLayoutCollection(parsed); + errors = PerspectiveLibrary.validateSavedLayoutCollection(parsed); if (errors.length) { return {collection: null, document: null, errors, loaded: false} } - restored = DockZoneModel.restoreActiveSavedLayout(parsed); + restored = PerspectiveLibrary.restoreActiveSavedLayout(parsed); if (restored.errors.length) { return {collection: null, document: null, errors: restored.errors, loaded: false} } - me.layoutCollection = DockZoneModel.clone(parsed); + me.layoutCollection = Document.clone(parsed); me.onDockZoneDocumentChange(restored.document); await me.refreshPromise; @@ -469,20 +471,20 @@ class MainContainer extends DockWorkspace { } /** - * Selects and restores a named perspective through `DockZoneModel.restoreActiveSavedLayout()`. + * Selects and restores a named perspective through `PerspectiveLibrary.restoreActiveSavedLayout()`. * @param {String} layoutId * @returns {{collection:Object, document:(Object|null), errors:String[]}} */ restorePerspective(layoutId) { let me = this, - selected = DockZoneModel.selectSavedLayout(me.layoutCollection, layoutId), + selected = PerspectiveLibrary.selectSavedLayout(me.layoutCollection, layoutId), restored; if (selected.errors.length) { return {collection: me.layoutCollection, document: null, errors: selected.errors} } - restored = DockZoneModel.restoreActiveSavedLayout(selected.collection); + restored = PerspectiveLibrary.restoreActiveSavedLayout(selected.collection); if (restored.errors.length) { return {collection: me.layoutCollection, document: null, errors: restored.errors} @@ -503,7 +505,7 @@ class MainContainer extends DockWorkspace { let me = this, layoutId = me.nextSavedPerspectiveId(), title = `Saved ${me.savedPerspectiveCount}`, - saved = DockZoneModel.createSavedLayout(me.dockModel, { + saved = Persistence.createSavedLayout(me.dockModel, { layoutId, title, metadata: { @@ -517,7 +519,7 @@ class MainContainer extends DockWorkspace { return {collection: me.layoutCollection, layout: null, errors: saved.errors} } - upserted = DockZoneModel.upsertSavedLayout(me.layoutCollection, saved.layout, {activate: true}); + upserted = PerspectiveLibrary.upsertSavedLayout(me.layoutCollection, saved.layout, {activate: true}); if (upserted.errors.length) { return {collection: me.layoutCollection, layout: null, errors: upserted.errors} @@ -546,7 +548,7 @@ class MainContainer extends DockWorkspace { return {collection, document: null, errors: ['at least one replacement perspective must remain']} } - removed = DockZoneModel.removeSavedLayout(collection, { + removed = PerspectiveLibrary.removeSavedLayout(collection, { layoutId : activeLayoutId, replacementLayoutId: replacementId }); @@ -555,7 +557,7 @@ class MainContainer extends DockWorkspace { return {collection, document: null, errors: removed.errors} } - restored = DockZoneModel.restoreActiveSavedLayout(removed.collection); + restored = PerspectiveLibrary.restoreActiveSavedLayout(removed.collection); if (restored.errors.length) { return {collection, document: null, errors: restored.errors} diff --git a/learn/agentos/DockZoneModel.md b/learn/agentos/DockZoneModel.md index ee75f1d08a..9f555db662 100644 --- a/learn/agentos/DockZoneModel.md +++ b/learn/agentos/DockZoneModel.md @@ -2,6 +2,13 @@ `@summary` Minimal model contract for Neo's docking subsystem: a serializable dock-zone tree that composes with Neo's existing dashboard, layout, JSON blueprint, and multi-window drag substrates without introducing a parallel docking engine. +**Code realization (v13.2 architecture).** The contract lives in the `Neo.dashboard.dock.model.*` tier: +`model.Document` owns the committed tree (schema keys, validation, normalization, tree helpers, +fingerprints, the fail-closed commit), `model.Operations` owns the semantic operation vocabulary and +dispatch, `model.Persistence` owns the saved-layout envelope (capture, wrapper validation, restore), and +`Neo.dashboard.dock.persistence.PerspectiveLibrary` is the sole named-collection/perspective authority. +Method references below name their owning module. + ## Scope This contract is the first concrete slice of the QT-grade docking line; the Agent Harness cockpit is one consumer among several (workstation, `examples/dashboard/*`). It defines the data model that rendering, preview, and persistence slices consume. @@ -18,11 +25,11 @@ The contract composes with these current Neo substrates: |---|---|---| | Declarative layouts | `learn/guides/uibuildingblocks/Layouts.md`, `src/layout/HBox.mjs`, `src/layout/VBox.mjs`, `src/layout/Card.mjs` | Dock splits map to `hbox` / `vbox`; tabbed slots map to a tab header plus `card`-style active content. | | JSON-first UI state | `learn/benefits/body/JSONFirstUIs.md`, `learn/gettingstarted/DescribingTheUI.md` | Persist only pure JSON. Runtime component instances, DOMRects, and window objects stay out of the serialized model. | -| Dashboard drag substrate | `src/dashboard/Container.mjs`, `src/draggable/dashboard/SortZone.mjs` | Future dock rendering should adapt this model into dashboard/sort-zone mechanics instead of forking drag handling. | +| Dashboard drag substrate | `src/dashboard/Container.mjs`, `src/draggable/dashboard/SortZone.mjs` | Dock rendering adapts this model into dashboard/sort-zone mechanics instead of forking drag handling. | | Cross-window geometry | `src/manager/Window.mjs`, `src/manager/DragCoordinator.mjs`, `src/main/addon/WindowPosition.mjs` | Dock drop targeting uses existing screen-coordinate and remote-drag authority. The model stores the accepted result, not transient geometry. | -| Harness self-use | `apps/agentos/view/Viewport.mjs`, ADR 0020 | The first independent use shape is the Agent Harness operator cockpit: strategy, swarm, intervention, terminal, transcript, and inspector panes arranged as a persistent workspace. | +| Flagship self-use | `apps/workstation/view/Workspace.mjs`, ADR 0020 | The flagship in-repo consumer is the Workstation operator workspace: terminal, transcript, preview, and inspector panes arranged as a persistent, perspective-switchable workspace. | -No dedicated dock manager exists today. ADR 0020 names QT-grade docking as a gap, and the KB/source sweep found dashboard drag and layout primitives but no generic `DockManager`. +The subsystem realizing this contract lives at `src/dashboard/dock/**` (`Neo.dashboard.dock.*`); ADR 0029's §2.9 amendment records the final package and wire family. ## Ownership Boundary @@ -30,7 +37,7 @@ The model is a generic dashboard-layer contract (`src/dashboard/`), not a new co Initial durable surface: this document. -**Resolved (operator, 2026-06-13):** the dock-zone subsystem lives in `src/dashboard/` — `Neo.dashboard.DockZoneModel` (the executor) co-located with `Neo.dashboard.DockLayoutAdapter` (the renderer) — reusable across apps. Dock zones are a Neo layout topic available to other apps, not a harness-app-private concern; only app-specific pane wiring / persistence glue stays in the harness app. The decision tree below is the rationale that led here — its conditional "harness app layer" model placement (option 1) and the "second independent in-repo consumer required before lifting" gate (option 2) are superseded by this decision. +**Resolved (operator, 2026-06-13):** the dock-zone subsystem lives in `src/dashboard/` — `Neo.dashboard.dock.model.Document` (the executor) co-located with `Neo.dashboard.dock.projection.LayoutAdapter` (the renderer) — reusable across apps. Dock zones are a Neo layout topic available to other apps, not a harness-app-private concern; only app-specific pane wiring / persistence glue stays in the harness app. The decision tree below is the rationale that led here — its conditional "harness app layer" model placement (option 1) and the "second independent in-repo consumer required before lifting" gate (option 2) are superseded by this decision. The rationale that resolved to the dashboard layer: @@ -51,7 +58,7 @@ The persisted document is a versioned JSON object: ```json { - "schema": "neo.harness.dockZone.v1", + "schema": "neo.dock.zone.v1", "root": "root", "items": { "strategy": { @@ -167,15 +174,15 @@ If a future slice needs to restore detached windows, it should persist semantic ## Named Layout Collections / Perspectives -Named perspectives collect multiple saved layouts without choosing a storage backend or rendering a switcher. Current writers place the `dockLayout.v2` envelope inside the unchanged collection shape: +Named perspectives collect multiple saved layouts without choosing a storage backend or rendering a switcher. Writers place the `neo.dock.layout.v1` envelope inside the unchanged collection shape: ```json { - "schema": "neo.harness.dockLayoutCollection.v1", + "schema": "neo.dock.layoutCollection.v1", "activeLayoutId": "operator-default", "layouts": { "operator-default": { - "schema": "neo.harness.dockLayout.v2", + "schema": "neo.dock.layout.v1", "layoutId": "operator-default", "title": "Operator Default", "dockZone": {}, @@ -199,11 +206,11 @@ Rules: Storage remains out of scope for this layer. Browser preferences, Memory Core persistence, import/export, and rendered layout switchers consume this collection contract later; they must not fork their own collection shape. -Legacy `dockLayout.v1` entries remain valid on the read path only. `migrateSavedLayout()` upgrades them to v2 with the honest defaults `captureScope: 'window'` and `windowFingerprint: null`; no writer emits v1. +`neo.dock.layout.v1` is the only accepted envelope. There is no migration reader: any other schema — a different version, or the retired pre-v13.2 family — is rejected fail-closed on every read path (`restoreSavedLayout`, collection validation, library load). ## Operations -Future implementations should mutate the model through semantic operations instead of direct tree surgery in UI handlers: +The model mutates only through semantic operations — `model.Operations.applyOperation()` is the single dispatch — never through direct tree surgery in UI handlers: | Operation | Inputs | Result | |---|---|---| @@ -240,7 +247,7 @@ Future drag-to-dock preview slices should listen to existing drag surfaces and p ```json { - "schema": "neo.harness.dockPreview.v1", + "schema": "neo.dock.preview.v1", "previewId": "preview:strategy:main-tabs:tab-after:1", "itemId": "strategy", "source": { @@ -276,7 +283,7 @@ Required fields: | Field | Meaning | Persistence | |---|---|---| -| `schema` | Preview payload version, initially `neo.harness.dockPreview.v1`. | Runtime only. | +| `schema` | Preview payload version, initially `neo.dock.preview.v1`. | Runtime only. | | `previewId` | Stable-enough id for one hover frame or dwell window; useful for renderer diffing. | Runtime only. | | `itemId` | Stable dock item id from `items`. | Serializable only after a drop commits an operation. | | `source.surface` | Existing producer surface, e.g. `dashboard-sort-zone`, `drag-coordinator`, or `window-geometry`. | Runtime only. | @@ -322,7 +329,7 @@ Consumer boundaries: ## Blueprint Compatibility -The contract is deliberately JSON-first. A future renderer can project the model into Neo configs without changing the persisted shape: +The contract is deliberately JSON-first. `projection.LayoutAdapter` projects the model into Neo configs without changing the persisted shape: - `split.orientation: horizontal` -> container `layout: {ntype: 'hbox', align: 'stretch'}` - `split.orientation: vertical` -> container `layout: {ntype: 'vbox', align: 'stretch'}` @@ -338,15 +345,15 @@ If neither a live component nor a valid `item.blueprint` exists, the adapter mus Layout persistence owns saved workspace documents, not drag-time state or component lifetime. -A persisted layout is a small versioned wrapper around the normalized dock-zone model. Current writers emit v2: +A persisted layout is a small versioned wrapper around the normalized dock-zone model. Writers emit `neo.dock.layout.v1`: ```json { - "schema": "neo.harness.dockLayout.v2", + "schema": "neo.dock.layout.v1", "layoutId": "operator-default", "title": "Operator Default", "dockZone": { - "schema": "neo.harness.dockZone.v1", + "schema": "neo.dock.zone.v1", "root": "root", "items": {}, "nodes": {} @@ -364,7 +371,7 @@ Required wrapper fields: - `schema`: saved-layout wrapper version. The inner dock-zone document keeps its own `schema`. - `layoutId`: stable user/workspace layout identity, distinct from dock item ids. - `title`: display label for layout pickers or recovery UIs. -- `dockZone`: a normalized `neo.harness.dockZone.v1` model after semantic operations have run. +- `dockZone`: a normalized `neo.dock.zone.v1` model after semantic operations have run. - `captureScope`: `window` for one document or `topology` for a multi-window capture. - `windowFingerprint`: JSON-only topology-shape evidence, or `null` when a legacy v1 record had no captured fingerprint. @@ -379,11 +386,10 @@ Schema-name row (the canonical vocabulary both tiers share — the design record | Schema | Role | Notes | |---|---|---| -| `neo.harness.dockLayout.v1` | legacy saved-layout wrapper | read-path only; migrates forward with honest defaults | -| `neo.harness.dockLayout.v2` | THE saved-layout AND perspective wrapper | adds `captureScope` (`window` \| `topology`), `windowFingerprint`, `perspectiveName`, `windowDocuments`; there is no separate perspective schema — the envelope carries the capability | -| `neo.harness.dockLayoutCollection.v1` | the one named-collection shape | perspective collections reuse it verbatim; no third collection shape exists | +| `neo.dock.layout.v1` | THE saved-layout AND perspective wrapper | carries `captureScope` (`window` \| `topology`), `windowFingerprint`, `perspectiveName`, `windowDocuments`; there is no separate perspective schema — the envelope carries the capability | +| `neo.dock.layoutCollection.v1` | the one named-collection shape | perspective collections reuse it verbatim; no third collection shape exists | -The `neo.harness.` string prefix in these identifiers is a **frozen legacy wire format** (ADR 0029 §2.9): the runtime keeps emitting and accepting it unchanged, and renaming it is a schema migration, never a text edit. +The `neo.dock.` prefix is the single greenfield wire family (ADR 0029 §2.9 amendment): readers fail closed on every other schema string — unsupported versions are proven rejected inside the family, the retired pre-release `neo.harness.` family is proven rejected as foreign, and no migration reader or alias exists. Persistence consumes only committed dock-zone state. It must not serialize `dockPreview`, hover rectangles, screen coordinates, `windowId`, `sourceSortZone`, `targetSortZone`, runtime hover/open state for auto-hidden panes, live components, event listeners, controllers, functions, or credential material. If a future detached-window slice needs restore hints, those hints must be separate semantic placement metadata; they must not turn the dock layout into an OS-window session dump. @@ -391,11 +397,11 @@ Restore must validate the wrapper schema, the inner dock-zone schema, and the no Component recovery remains the adapter's responsibility. A restored item with an unresolved `componentRef` follows the stale component reference policy above: preserve the item record and semantic placement long enough for validation, explicit recovery, placeholder rendering, or intentional removal. Persistence must not silently drop the item or rewrite the dock tree to hide the missing component. -Persistence ownership follows the landed placement: reusable import/export, validation, and storage projection logic lives in the dashboard layer (`src/dashboard/`, e.g. `DockPerspectiveStore`), per the 2026-06-13 operator resolution recorded in §Ownership Boundary. Only app-specific storage backends, pane registries, or preference wiring stay app-local — for any consumer, the harness cockpit included. +Persistence ownership follows the landed placement: reusable import/export, validation, and storage projection logic lives in the dashboard layer (`src/dashboard/`, e.g. `PerspectiveLibrary`), per the 2026-06-13 operator resolution recorded in §Ownership Boundary. Only app-specific storage backends, pane registries, or preference wiring stay app-local — for any consumer, the harness cockpit included. ## Split/Tab Adapter Boundary -The rendering boundary is an adapter/reconciler pair, not a new layout engine. `Neo.dashboard.DockLayoutAdapter` consumes the dock-zone model and emits ordinary Neo child configs; `Neo.dashboard.DockProjectionReconciler` hands surviving live components into that projection without changing their identity. Existing containers still own layout, tabs, and cards. +The rendering boundary is an adapter/reconciler pair, not a new layout engine. `Neo.dashboard.dock.projection.LayoutAdapter` consumes the dock-zone model and emits ordinary Neo child configs; `Neo.dashboard.dock.projection.Reconciler` hands surviving live components into that projection without changing their identity. Existing containers still own layout, tabs, and cards. Adapter, projection reconciler, and model live in the dashboard layer (`src/dashboard/`) — per the operator's 2026-06-13 placement decision (see §Ownership Boundary), the dock-zone subsystem is a reusable Neo layout topic, not harness-app-private. A *further* lift into a generic core layout primitive (beyond dashboard adaptation) still requires a second independent in-repo consumer and source evidence that the logic is reusable outside dashboard adaptation. @@ -430,7 +436,7 @@ The adapter must not read `DOMRect`, `windowId`, pointer coordinates, preview pl | `children` | projected child configs in listed order | Ordering is model-owned and serializable. | | `sizes` | child `flex` values when present | Normalize or ignore invalid ratios before projection. | -Resizable splitters are a later rendering affordance. When added, they should sit between projected children and write back semantic size changes through `resizeSplit`, not mutate persisted `sizes` directly from pointer handlers. +Resizable splitters (`interaction.DockSplitter`) sit between projected children and write back semantic size changes through exactly one `resizeSplit` commit at drag end — pointer handlers never mutate persisted `sizes`. ### Tab Projection @@ -456,7 +462,7 @@ The adapter must preserve the current `Neo.tab.Container` contract: tab headers This aligns the adapter with stale `componentRef` restore behavior: runtime component references are recoverable state, not a reason to corrupt the persisted dock tree. -Repeated projections add one ownership rule: the adapter remains pure and stateless, while `DockProjectionReconciler` keys surviving tab containers by `dockNodeId` and moves each pane/header-button pair before moving its retained tab-container ancestor. The reconciler commits those descendant and ancestor handoffs separately; app-local code owns only its pane resolver, animation, and app-specific menu readiness. Workstation and Dock Demo B exercise the same transaction with different pane policies, keeping the projection contract reusable without making `DockLayoutAdapter` stateful. +Repeated projections add one ownership rule: the adapter remains pure and stateless, while `projection.Reconciler` keys surviving tab containers by `dockNodeId` and moves each pane/header-button pair before moving its retained tab-container ancestor. The reconciler commits those descendant and ancestor handoffs separately; app-local code owns only its pane resolver, animation, and app-specific menu readiness. Workstation and Dock Demo B exercise the same transaction with different pane policies, keeping the projection contract reusable without making `projection.LayoutAdapter` stateful. The resolver may return either an existing live component or a materializable component config; the reconciler normalizes an inserted config to its one live instance. Once every projected tabs destination is known, a live pane/header-button pair absent from all of them is a **true projection retirement** and is destroyed exactly once. This cleanup cannot infer broader app ownership from a single committed document. A consumer that intentionally retains a pane outside the currently renderable projection — for example, during a popup handoff or as an unrestored `no-live-window` topology remainder — must park that live instance with a non-destroying removal before reconciliation. A cache guard that recreates an `isDestroyed` entry is recovery safety, not identity preservation. diff --git a/learn/agentos/decisions/0029-docking-design.md b/learn/agentos/decisions/0029-docking-design.md index 524c5972c4..3758454a7f 100644 --- a/learn/agentos/decisions/0029-docking-design.md +++ b/learn/agentos/decisions/0029-docking-design.md @@ -4,7 +4,7 @@ | Attribute | Value | |---|---| -| **Status** | Accepted — 2026-07-02 (#14423; PR #14425 merged to `dev`). **Re-homed** in the same PR from `learn/agentos/HarnessDockingDesign.md` (contract-doc tier) to decision-record tier after the ADR-0005 `ADR_REQUIRED` audit (operator-flagged, review cycle 3) — see §1 Context for why the authority belongs here. **Renamed** 2026-08-21 from `0029-harness-docking-design.md` (#17503; §2.9): the subsystem is a generic Body capability — the harness misnomer is retired, persisted schema strings stay frozen. **Amended** 2026-08-22 (`#17541`; §2.1): the normative workspace host becomes the engine class `Neo.dashboard.DockWorkspace`. | +| **Status** | Accepted — 2026-07-02 (#14423; PR #14425 merged to `dev`). **Re-homed** in the same PR from `learn/agentos/HarnessDockingDesign.md` (contract-doc tier) to decision-record tier after the ADR-0005 `ADR_REQUIRED` audit (operator-flagged, review cycle 3) — see §1 Context for why the authority belongs here. **Renamed** 2026-08-21 from `0029-harness-docking-design.md` (#17503; §2.9): the subsystem is a generic Body capability — the harness misnomer is retired, persisted schema strings stay frozen. **Amended** 2026-08-22 (`#17541`; §2.1): the normative workspace host becomes the engine class `Neo.dashboard.dock.Workspace`. | | **Author** | @neo-fable-clio (Clio, Claude Fable 5, Claude Code). The cross-window seam contract descends from Discussion #13370's graduated Option-4 convergence (cross-family); the §7 auto-hide contract was written implementation-sufficient for its claimed leaf owner (@neo-opus-grace, #13280). | | **Resolves** | #14423 — the #13158 design-gate sub: settle the seven shared design questions (layout model, perspectives, cross-window drag, grouped drag/overflow, core-lift disposition, container contract, auto-hide UI) before further implementation lands on the current base (operator direction, 2026-07-02). | | **Parent epic** | #13158 (*QT-parity docking polish*) under #13012 (Agent Harness). Operator re-ranked 2026-07-02 as an agent-harness cornerstone: the docking shell is the substrate the #13015 FM-UX and #13444 HOME surfaces stand on. | @@ -43,10 +43,10 @@ #### The reducer-container pattern (landed, normative — amended 2026-08-22, `#17541`) -The normative host is the engine class **`Neo.dashboard.DockWorkspace`** (`src/dashboard/DockWorkspace.mjs`); `examples/dashboard/dock/MainContainer.mjs` is its minimal consumer. New docking workspaces extend it; the three flagship hosts (workstation, dockdemo Demo B, fleet cockpit) still carry the hand-rolled loop and migrate as leaves of epic `#17539` (`#17546` first) — until each lands, its copy is consumer-owned, not normative. The class contract: +The normative host is the engine class **`Neo.dashboard.dock.Workspace`** (`src/dashboard/DockWorkspace.mjs`); `examples/dashboard/dock/MainContainer.mjs` is its minimal consumer. New docking workspaces extend it; the three flagship hosts (workstation, dockdemo Demo B, fleet cockpit) still carry the hand-rolled loop and migrate as leaves of epic `#17539` (`#17546` first) — until each lands, its copy is consumer-owned, not normative. The class contract: - **The workspace container** owns the committed dock-zone document (`dockModel`) and its saved-layout collection. It lives in the App Worker heap. -- **`applyDockZoneOperation(descriptor)`** is a pure reducer: `DockZoneModel.applyOperation` over the current document. No pointer handler, splitter, or drag surface mutates the document directly. +- **`applyDockZoneOperation(descriptor)`** is a pure reducer: `Operations.applyOperation` over the current document. No pointer handler, splitter, or drag surface mutates the document directly. - **`onDockZoneDocumentChange(document)`** is the view-sync: it stores the committed document and re-projects it through `DockLayoutAdapter.project()` — one atomic ownership transaction per commit, scheduled off the settled tail of the refresh chain and reconciled by `DockProjectionReconciler` so surviving panes keep their identity. A failed transaction stays observable on its own commit's promise and never suppresses a later one; a configured dock-host reference that resolves to no live host fails loudly. Interaction surfaces (splitters, drag previews, rail tabs, pin controls) emit **operation descriptors**; the reducer commits them; the view-sync re-projects. This is the only sanctioned mutation path. @@ -62,7 +62,7 @@ A **workspace** is one `dockZone.v1` document owned by one workspace container. A browser window — including the primary one — is a **render target**, never a state owner. The SharedWorker heap persists while at least one window remains connected; any single window (including the opener) can close or reload without destroying workspace truth. -#### The placement-hint layer — `neo.harness.windowPlacementHints.v1` +#### The placement-hint layer — `neo.dock.windowPlacementHints.v1` Resolved by Discussion #13370 (OQ1) and bound here: window placement intent is a **separate hint layer keyed by item id**, never fields inside the dock-zone tree. @@ -86,7 +86,7 @@ Every future docking leaf classifies each new piece of state into exactly one ro #### Splitter reconciliation (Discussion #13370 OQ3, dispositioned) -`Neo.dashboard.DockSplitter` is the dock-workspace resize affordance: it renders between projected split children and commits through the `resizeSplit` semantic operation (`DockLayoutAdapter.createResizeSplitOperation`). `Neo.component.Splitter` remains the general-purpose sibling-resize component for non-dock layouts. They stay separate: the dock splitter's contract obligation (semantic commit through the reducer, never direct mutation of persisted `sizes`) is dock-specific, and folding it into the general component would leak dock semantics into core. A future unification is possible only behind the §2.5 lift trigger, never before it. +`Neo.dashboard.dock.interaction.DockSplitter` is the dock-workspace resize affordance: it renders between projected split children and commits through the `resizeSplit` semantic operation (`DockLayoutAdapter.createResizeSplitOperation`). `Neo.component.Splitter` remains the general-purpose sibling-resize component for non-dock layouts. They stay separate: the dock splitter's contract obligation (semantic commit through the reducer, never direct mutation of persisted `sizes`) is dock-specific, and folding it into the general component would leak dock semantics into core. A future unification is possible only behind the §2.5 lift trigger, never before it. ### §2.2 Named Perspectives @@ -96,18 +96,18 @@ Single-workspace persistence is shipped and closed (fail-closed restore and no-s #### Capture scope (amendment, 2026-07-11 — #14773: the shipped envelope IS the perspective carrier) -**Why this amendment exists.** This section originally projected a NEW persisted schema (`neo.harness.dockPerspective.v1`) for topology-scope perspectives. The perspective substrate that actually landed ships `neo.harness.dockLayout.v2` — the EXISTING envelope family extended with the perspective fields (`captureScope`, `windowFingerprint`, `perspectiveName`, `windowDocuments`) — and it covers BOTH scopes. Minting a second wrapper name for the same capability territory would itself be the shape-proliferation this record's own anti-anchor forbids; the reconciliation therefore RETIRES the `dockPerspective.v1` name entirely: **`dockLayout.v2` is v2 of the ENVELOPE carrying v1 of the perspective CAPABILITY.** Vocabulary mapping, stated once: the capability scope this section calls `workspace` is the envelope value `captureScope: 'window'`. `workspace` is this record's PROSE label only — runtime and tool surfaces (the NL perspective tools included) speak the executable vocabulary `window | topology` (`DockZoneModel.CAPTURE_SCOPES`, the SSOT); minting `workspace` as a third runtime enum value is the drift this mapping exists to prevent. +**Why this amendment exists.** This section originally projected a NEW persisted schema (`neo.dock.perspective.v1`) for topology-scope perspectives. The perspective substrate that actually landed ships `neo.dock.layout.v1` — the EXISTING envelope family extended with the perspective fields (`captureScope`, `windowFingerprint`, `perspectiveName`, `windowDocuments`) — and it covers BOTH scopes. Minting a second wrapper name for the same capability territory would itself be the shape-proliferation this record's own anti-anchor forbids; the reconciliation therefore RETIRES the `dockPerspective.v1` name entirely: **`dockLayout.v2` is v2 of the ENVELOPE carrying v1 of the perspective CAPABILITY.** Vocabulary mapping, stated once: the capability scope this section calls `workspace` is the envelope value `captureScope: 'window'`. `workspace` is this record's PROSE label only — runtime and tool surfaces (the NL perspective tools included) speak the executable vocabulary `window | topology` (`Persistence.CAPTURE_SCOPES`, the SSOT); minting `workspace` as a third runtime enum value is the drift this mapping exists to prevent. Two scopes exist. A perspective declares which one it is; there is no implicit scope. | Scope | Captures | Wrapper | |---|---|---| -| `workspace` | one workspace document | `neo.harness.dockLayout.v2` with `captureScope: 'window'` (shipped) | -| `topology` | every workspace document in the workspace set **plus** the durable half of the placement-hint layer | `neo.harness.dockLayout.v2` with `captureScope: 'topology'` + `windowDocuments` (multi-document half shipped; hint layer pending, below) | +| `workspace` | one workspace document | `neo.dock.layout.v1` with `captureScope: 'window'` (shipped) | +| `topology` | every workspace document in the workspace set **plus** the durable half of the placement-hint layer | `neo.dock.layout.v1` with `captureScope: 'topology'` + `windowDocuments` (multi-document half shipped; hint layer pending, below) | ```json { - "schema": "neo.harness.dockLayout.v2", + "schema": "neo.dock.layout.v1", "layoutId": "operator-default", "perspectiveName": "Operator Default", "title": "Operator Default", @@ -457,6 +457,12 @@ Windows/Linux headed cells remain honestly owned by #15243. ### §2.9 Amendment — Identity and Schema-Prefix Disposition (2026-08-21, #17503) +> **Superseded 2026-08-29 by the §2.9 v13.2 greenfield amendment below.** Retained as history: its +> compatibility reasoning was correct for a shipped surface, and its own successor-family clause is the +> path the greenfield cut took. The empirical ground that reversed it: npm `13.1.0` shipped only the +> generic root primitives plus three experimental foundation files, so the present subsystem carried no +> deployed durable state and no external compatibility boundary. + The subsystem this record governs is a generic Body capability (`src/dashboard/`), consumed beyond the harness (workstation, `examples/dashboard/*`, portal-candidate consumers); the Agent Harness cockpit is one consumer among several. The record and the model contract doc therefore drop the `Harness` prefix (`0029-docking-design.md`, `DockZoneModel.md`); older prose and external links referring to "harness docking" read as historical. **Schema strings are wire format and do NOT follow the rename.** The shipped `neo.harness.*` vocabulary, derived from exact source at this amendment (35 occurrences, 8 unique identifiers), splits into two compatibility classes — both keep their names: @@ -464,7 +470,56 @@ The subsystem this record governs is a generic Body capability (`src/dashboard/` - **Persisted envelopes/models** — `dockZone.v1` · `dockLayout.v1` · `dockLayout.v2` · `dockLayoutCollection.v1`: bound by fail-closed restore compatibility. A bare string rename would reject every previously persisted layout and perspective, including deployed consumers'. Renaming happens ONLY inside a shape-changing envelope revision (`dockLayout.v3`+, or a successor family) that introduces `neo.dock.*` in that same change, WITH the documented migration the shipped `v1 → v2` precedent (`migrateSavedLayout`) sets. A find-replace of persisted schema strings outside such a revision is forbidden. - **Runtime-only contracts** — `dockPreview.v1` · `dockCandidates.v1` · `dockShape.v1` · `dockTopologyShape.v1`: never persisted (the JSON-First Guardrail forbids it), but pinned by cross-window participation, Neural Link, and test consumers. Their rename obligation is consumer-coordinated versioning in one change — lighter than a stored-data migration, still never a silent find-replace. -The §2.1 heading's `neo.harness.windowPlacementHints.v1` is a PROPOSED name only: it has zero runtime occurrences, and the §2.2 amendment supersedes it — the durable hint layer lands as additive fields on the existing envelope, never as a new schema name. Nothing is frozen there, because nothing shipped. +The §2.1 heading's `neo.dock.windowPlacementHints.v1` is a PROPOSED name only: it has zero runtime occurrences, and the §2.2 amendment supersedes it — the durable hint layer lands as additive fields on the existing envelope, never as a new schema name. Nothing is frozen there, because nothing shipped. + +### §2.9 Amendment — The v13.2 Greenfield Hard Cut (2026-08-29, Epic #17836 / Discussion #17818) + +The graduated v13.2 architecture executes the successor-family path the 2026-08-21 clause reserved, as a +**hard cut with no migration path**: the operator ruled the subsystem a greenfield product surface, and the +npm `13.1.0` boundary proves no deployed durable state existed to protect. + +**Final package and namespace.** The subsystem lives in `src/dashboard/dock/{model,projection,interaction,persistence,window}` +under `Neo.dashboard.dock.*`; folder, class namespace, JSDoc targets, theme identities, and SCSS mirrors +tell one story. Generic `Container`/`Panel` stay frozen at the package root. The former zone-model monolith +is dissolved: `model.Document` owns the committed-document contract (validation, normalization, tree +helpers, fingerprints, the fail-closed commit), `model.Operations` owns the semantic reducer vocabulary and +dispatch, `model.Persistence` owns saved-layout envelopes (capture, wrapper validation, restore), and +`persistence.PerspectiveLibrary` — the former perspective store merged with the collection statics — is the +**sole** collection/perspective authority. `interaction.DockSplitter` keeps its disambiguating name beside +generic `Neo.component.Splitter` and **subclasses it**, inheriting DragZone, live-resize, generation-fence, +and cancel mechanics; dock code owns only document descriptors and one terminal semantic commit, and a +prototype-census control fails if the generic machinery is ever re-implemented locally. + +**Final wire family.** One enumerated `neo.dock.*` set replaces `neo.harness.*` outright. The runtime +family is **exactly these seven** — every identity below exists in executable source, and no reserved, +retired, or proposed name belongs in this table: + +| Concept | Identity | +|---|---| +| committed dock document | `neo.dock.zone.v1` | +| drag preview | `neo.dock.preview.v1` | +| saved layout (former v1/v2 collapsed) | `neo.dock.layout.v1` | +| drop candidates | `neo.dock.candidates.v1` | +| saved-layout collection | `neo.dock.layoutCollection.v1` | +| per-window shape fingerprint | `neo.dock.shape.v1` | +| aggregate topology shape | `neo.dock.topologyShape.v1` | + +The §2.2 placement-hint obligation lands as **additive fields on `neo.dock.layout.v1`** when its leaf +files — never as a new schema name (the §2.2 amendment's own rule); the retired `perspective` wrapper +name exists only as §2.2 history. A future identity enters this table by amending this record, not by +reserving a row. + +The former layout v1/v2 wrapper split collapses into one `neo.dock.layout.v1` carrying the perspective +fields; the migration reader (`migrateSavedLayout`) and every v1-acceptance site are **deleted**, not +renamed. Negative controls moved into the new family (`neo.dock.layout.v2`/`.v999`, +`neo.dock.zone.v2`, `neo.dock.preview.v2`, `neo.dock.layoutCollection.v0`/`.v2`) so they prove +unsupported-**version** rejection, and dedicated controls prove the retired `neo.harness.*` **family** is +rejected fail-closed at both the envelope and collection tiers. No alias, dual parser, or compatibility +branch survives. + +Every other contract in this record — worker-owned document truth, per-window projection state, +runtime-only pixels, the dock-blind `DragCoordinator`, semantic-operation commits, JSON-first +persistence — remains in force unchanged. ## 3. Rejected Options @@ -524,7 +579,7 @@ Per the parent epic's discipline (one Contract-Ledgered leaf per capability), im | Topology perspectives: the hint layer on `dockLayout.v2` + switcher + restore reconciliation | §2.2 | envelope + model-level capture/collection substrate landed; NL capture/list/restore tools merged (#15019); the placement-hint layer + atomic multi-window restore remain | | Grouped drag (`moveNode`/`transferNode`) + tab overflow affordance | §2.4 | landed — #14770 (`moveNode`/`transferNode`) + #14850 (tab drag) + #15098 (`Neo.tab.plugin.Overflow`) | | Core lift to a non-dashboard namespace | §2.5 | **gated** — fires only on the named trigger | -| The engine-owned workspace host (`Neo.dashboard.DockWorkspace`) + per-host migration | §2.1 | class + example landed — `#17541`; the flagship host migrations are leaves of epic `#17539` | +| The engine-owned workspace host (`Neo.dashboard.dock.Workspace`) + per-host migration | §2.1 | class + example landed — `#17541`; the flagship host migrations are leaves of epic `#17539` | | The three-OS portability spike (matrix contract) | §2.8.1 (row-6 identity binding) + §2.8.3 (admission receipts) | #15243 open (epic #15239; Clio's lane per live assignee) | | Dock tear-out + acquisition contract | §2.8.2/§2.8.3 + the §2.3 participation contract | #15244 landed; #15245 open (epic #15239) | | Workspace-set composition + claim arbitration + remote preview | §2.8.1 + §2.1 workspace-set | #15246 landed (epic #15239) | @@ -537,7 +592,7 @@ Per the parent epic's discipline (one Contract-Ledgered leaf per capability), im Inherited unchanged from the model contract's §Serializable vs Runtime State and applied to every shape this record introduces: -- Perspective records (`neo.harness.dockLayout.v2`, §2.2 amendment) persist workspace documents, durable hints, titles, ids, revisions, JSON-only metadata. They MUST NOT contain `DOMRect`s, screen or monitor coordinates, `windowId`s, live components, functions, credentials, or any preview payload. +- Perspective records (`neo.dock.layout.v1`, §2.2 amendment) persist workspace documents, durable hints, titles, ids, revisions, JSON-only metadata. They MUST NOT contain `DOMRect`s, screen or monitor coordinates, `windowId`s, live components, functions, credentials, or any preview payload. - Durable placement hints persist intent and semantic targets only; every geometric or window-identity field is runtime-only (§2.1 hint table). - `dockPreview` (including the §2.4 `groupNodeId` field) remains runtime-only in its entirety. - Reveal/open state of auto-hidden panes is never serialized (§2.7). diff --git a/learn/agentos/tooling/NeuralLinkCapabilityMatrix.md b/learn/agentos/tooling/NeuralLinkCapabilityMatrix.md index b26faecc07..85f278d4be 100644 --- a/learn/agentos/tooling/NeuralLinkCapabilityMatrix.md +++ b/learn/agentos/tooling/NeuralLinkCapabilityMatrix.md @@ -51,14 +51,14 @@ has accepted the action. | `abort_transaction` | Discard the requester's open named transaction record; already-applied UI mutations remain. | `InstanceService` -> `Neo.ai.TransactionService` stack. | `write-locked` | Controls an open batch; not a rollback. | Recoverable `{aborted:false, reason}` for expected misses; schema/server errors use `{error}`. | Trusted controller/e2e only; never direct from model-generated payload. | Direct SDK/MCP only; no fixture wrapper. | | `begin_transaction` | Open a named transaction so later captured mutations undo as one intent. | `InstanceService` -> `Neo.ai.TransactionService` stack. | `write-locked` | Opens a batch for captured writes. | Recoverable `{opened:false, reason}` for expected misses; schema/server errors use `{error}`. | Trusted controller/e2e only; never direct from model-generated payload. | Direct SDK/MCP only; no fixture wrapper. | | `call_method` | Invoke an arbitrary method on a live instance. | `InstanceService` -> live instance method dispatch. | `admin` | Lock-enforced; generic calls are not undoable except server-stamped create/remove paths. | Throws missing instance/method or downstream errors as `{error}`. | Operator/admin only; never direct from model-generated payload. | Fixture wrapper: `callMethod`; use sparingly. | -| `capture_perspective` | Capture a dock workspace as a named saved-layout record — `window` scope through `DockZoneModel.capturePerspective()`, `topology` scope through `captureTopologyPerspective()` over the holder's `getDockTopologyDocuments()` seam — stored on the holder's perspective store when present. | `DockService` -> App Worker dock document holder / the `DockZoneModel` scope producers (`CAPTURE_SCOPES` SSOT) + the holder's perspective store. | `write-locked` | Dock commit path, not the transaction stack. | `{captured, stored, collision, errors, layout}`; scope/name refusals are structured errors, never crashes. | Trusted controller/e2e only; never direct from model-generated payload. | SDK export: `NeuralLink_DockService`; no fixture wrapper yet. | +| `capture_perspective` | Capture a dock workspace as a named saved-layout record — `window` scope through `Persistence.capturePerspective()`, `topology` scope through `captureTopologyPerspective()` over the holder's `getDockTopologyDocuments()` seam — stored on the holder's perspective store when present. | `DockService` -> App Worker dock document holder / the `DockZoneModel` scope producers (`CAPTURE_SCOPES` SSOT) + the holder's perspective store. | `write-locked` | Dock commit path, not the transaction stack. | `{captured, stored, collision, errors, layout}`; scope/name refusals are structured errors, never crashes. | Trusted controller/e2e only; never direct from model-generated payload. | SDK export: `NeuralLink_DockService`; no fixture wrapper yet. | | `check_namespace` | Check whether a namespace exists in the App Worker runtime. | `RuntimeService` -> namespace lookup. | `read` | None. | Boolean-style result or `{error}`. | Model-readable after caller validates the namespace query. | Fixture wrapper: `checkNamespace`. | | `close_window` | Close an owner-granted topology-known popup through its private native route and observe terminal disappearance. | `RuntimeService` -> window operation bridge. | `write-locked` | Runtime window mutation; not transaction-captured. | Unsupported without an owner grant; otherwise verified terminal result or `{error}`. | Trusted controller/e2e only; never direct from model-generated payload and never bypass a product semantic return/disposal contract. | Fixture wrapper: `closeWindow`. | | `commit_transaction` | Commit the requester's open named transaction into one undoable unit. | `InstanceService` -> `Neo.ai.TransactionService` stack. | `write-locked` | Closes an open batch. | Recoverable `{committed:false, reason}` for expected misses; schema/server errors use `{error}`. | Trusted controller/e2e only; never direct from model-generated payload. | Direct SDK/MCP only; no fixture wrapper. | | `create_component` | Add a component config to a target container through constrained `container.add`. | `ComponentService` -> App Worker `call_method` with server-stamped `undoKind`. | `write-locked` | Captured when Bridge-stamped; named-batch aware. | Fail-fast validation for missing target/config/class identity; downstream errors use `{error}`. | Trusted controller/e2e only; never direct from model-generated payload. | Fixture wrapper: `createComponent`; keeper parity gap filed as #14815. | | `create_instance` | Create any JSON-addressable Neo instance, optionally attaching to a parent container. | `InstanceService` -> `Neo.create` / `Neo.ntype` in the App Worker. | `write-locked` | Captured when Bridge-stamped; named-batch aware. | Fail-fast data-only validation and parent checks; downstream errors use `{error}`. | Trusted controller/e2e only; never direct from model-generated payload. | Fixture wrapper: `createInstance`; direct SDK specs exist. | | `diff_dock_topology` | Compare a supplied dockZone document with the live holder document and return semantic dock deltas. | `DockService` -> App Worker dock document holder / `DockTopologyDiff`. | `read` | None. | Diff result includes deterministic category arrays plus shape-gate `errors`; missing holder/session errors use `{error}`. | Model-readable after caller validates holder id and before-document provenance. | SDK export: `NeuralLink_DockService`; no Playwright fixture wrapper. | -| `execute_dock_operation` | Apply one semantic dock operation and return the post-operation document. | `DockService` -> App Worker dock document holder / `DockZoneModel.applyOperation`. | `write-locked` | Dock commit path, not the transaction stack. | Executor returns `{applied, document, errors}`; malformed holder/transport errors use `{error}`. | Operator/e2e-tier only; never direct from model-generated payload. | SDK export: `NeuralLink_DockService`; fixture wrapper: `executeDockOperation`. | +| `execute_dock_operation` | Apply one semantic dock operation and return the post-operation document. | `DockService` -> App Worker dock document holder / `Operations.applyOperation`. | `write-locked` | Dock commit path, not the transaction stack. | Executor returns `{applied, document, errors}`; malformed holder/transport errors use `{error}`. | Operator/e2e-tier only; never direct from model-generated payload. | SDK export: `NeuralLink_DockService`; fixture wrapper: `executeDockOperation`. | | `find_instances` | Find live instances by property selector. | `InstanceService` -> App Worker instance registry. | `read` | None. | Returns matching instances or `{error}`. | Model-readable after caller validates selector scope. | Fixture wrapper: `findInstances`. | | `focus_window` | Focus a known runtime window through its trusted native owner. | `RuntimeService` -> window operation bridge. | `write-locked` | None. | Verified focus result or `{error}`. | Trusted controller/e2e only; never direct from model-generated payload. | Fixture wrapper: `focusWindow`. | | `get_component_tree` | Read the live component tree from a root. | `ComponentService` -> component serialization / `toJSON`-style tree. | `read` | None. | Tree result or `{error}` for invalid root/session. | Model-readable after caller bounds depth/root. | Fixture wrapper: `getComponentTree`. | diff --git a/learn/benefits/ArchitectureOverview.md b/learn/benefits/ArchitectureOverview.md index 3e39f31314..817feebaba 100644 --- a/learn/benefits/ArchitectureOverview.md +++ b/learn/benefits/ArchitectureOverview.md @@ -435,6 +435,7 @@ complete organism where the codebase and the agent co-evolve. | `src/container/` | Layout containers | `Base`, `Viewport` | — | | `src/list/` | Store-bound semantic lists, including fixed-height buffered component pools | `Base`, `Component`, `Buffered` | — | | `src/grid/` | Buffered data grids | `Container`, `View` | — | +| `src/dashboard/` | Generic dashboard roots plus the DockLayouts domain package: committed dock documents, semantic operations, projection, interaction affordances, perspectives, and cross-window choreography under `dock/{model,projection,interaction,persistence,window}` | `Container`, `Panel`, `dock.Workspace`, `dock.model.Document`, `dock.model.Operations`, `dock.persistence.PerspectiveLibrary` | [0029](../agentos/decisions/0029-docking-design.md) | | `src/data/` | Data layer | `Store`, `Model`, `RecordFactory` | — | | `src/state/` | State management | `Provider` | — | | `src/worker/` | Thread management | `App`, `VDom`, `Data`, `Manager` | — | @@ -504,7 +505,7 @@ The map-as-pointer principle: the Structural Inventory above links each subsyste | [0026](https://github.com/neomjs/neo-agent-brain/blob/dev/learn/agentos/decisions/0026-recovery-actuator.md) | Orchestrator Recovery Actuator | `ai/daemons/orchestrator/services/`, `ai/deploy/` | Proposed (#13880) | | [0027](https://github.com/neomjs/neo-agent-brain/blob/dev/learn/agentos/decisions/0027-autonomous-data-recovery-actuator.md) | Autonomous Memory Core Data-Recovery Actuator | `ai/daemons/orchestrator/services/`, `ai/services/memory-core/` | Proposed (#14134) | | [0028](https://github.com/neomjs/neo-agent-brain/blob/dev/learn/agentos/decisions/0028-temporal-pyramid-summarization-substrate.md) | Temporal-Pyramid Summarization Substrate | `ai/services/memory-core/`, `ai/daemons/`, temporal summary consumers | Proposed (#14427; PR #14428) | -| [0029](../agentos/decisions/0029-docking-design.md) | Docking Design — multi-window layout model, topology perspectives, cross-window drag, container contract | `src/dashboard/`, `src/manager/` (`DragCoordinator` seam), `apps/agentos/` | Accepted — 2026-07-02 (#14423; PR #14425) | +| [0029](../agentos/decisions/0029-docking-design.md) | Docking Design — multi-window layout model, topology perspectives, cross-window drag, container contract; §2.9 superseded by the v13.2 `neo.dock.*` greenfield amendment | `src/dashboard/dock/**`, `src/manager/` (`DragCoordinator` seam) | Accepted — 2026-07-02 (#14423; PR #14425); amended 2026-08-29 | | [0030](https://github.com/neomjs/neo-agent-brain/blob/dev/learn/agentos/decisions/0030-work-graph-stall-inference.md) | Work-Graph Stall Inference — `STALL_*` finding schema, defer tuple, and consumer boundaries | `ai/services/graph/`, `ai/services/memory-core/`, `ai/daemons/`, hook/wake/FM consumers | Proposed (#14461) | | [0031](../agentos/decisions/0031-target-architecture-composition.md) | Target-Architecture Composition — the whole-organism seam table + trajectory invariants + id-based staleness guard | Organism-level: no single Structural Inventory row owns this seam (it composes ALL of them — the boundary is deliberate); guard: `ai/scripts/lint/` | Proposed (#14525; PR #14527) | | [0035](https://github.com/neomjs/neo-agent-brain/blob/dev/learn/agentos/decisions/0035-live-lane-awareness-composition.md) | Live Lane Awareness — typed route, lifecycle frontier, Bird-View references, and fenced hook projection | `ai/agent/`, `ai/services/graph/`, `ai/services/memory-core/`, `ai/daemons/`, Claude/Codex hook consumers | Proposed (#15101) | diff --git a/learn/guides/uibuildingblocks/DockLayouts.md b/learn/guides/uibuildingblocks/DockLayouts.md index 0c4208630d..c8392affe6 100644 --- a/learn/guides/uibuildingblocks/DockLayouts.md +++ b/learn/guides/uibuildingblocks/DockLayouts.md @@ -46,15 +46,15 @@ flowchart TD classDef inter fill:#2d1b4e,stroke:#9b59b6,stroke-width:1px,color:#eee classDef cross fill:#1a3c34,stroke:#2ecc71,stroke-width:1px,color:#eee - Document["The committed document
dockZone.v1 — persisted JSON tree
owned by ONE workspace container"]:::doc - Model["Neo.dashboard.DockZoneModel
the pure reducer: applyOperation"]:::doc - Adapter["DockLayoutAdapter.project()
document → ordinary Neo configs"]:::proj - Reconciler["DockProjectionReconciler
hands LIVE components across projections"]:::proj - Surfaces["Interaction surfaces
DockTabSortZone · DockSplitter · DockRail
DockPreviewProducer → DockPreview"]:::inter + Document["The committed document
neo.dock.zone.v1 — persisted JSON tree
owned by ONE workspace container"]:::doc + Model["Neo.dashboard.dock.model.Document
the pure reducer: applyOperation"]:::doc + Adapter["projection.LayoutAdapter.project()
document → ordinary Neo configs"]:::proj + Reconciler["projection.Reconciler
hands LIVE components across projections"]:::proj + Surfaces["Interaction surfaces
TabSortZone · DockSplitter · Rail
PreviewProducer → Preview"]:::inter Descriptors["operation descriptors
moveItem · splitNode · addTab · resizeSplit
detachItem · transferItem · moveNode"]:::inter Coordinator["Neo.manager.DragCoordinator
cross-window arbitration — dock-BLIND"]:::cross Arbiter["GestureClaimArbiter
one token per gesture, deterministic winner"]:::cross - Vessels["Vessel lifecycle
DockTearOut choreography · Embodiment
Conversion · Park"]:::cross + Vessels["Vessel lifecycle
window.TearOut choreography · VesselEmbodiment
VesselConversion · VesselPark"]:::cross Document --> Adapter Adapter --> Reconciler @@ -68,7 +68,7 @@ flowchart TD Vessels --> Descriptors ``` -Read the loop clockwise. The **document** is a serializable JSON tree (`neo.harness.dockZone.v1`): edge zones, nested +Read the loop clockwise. The **document** is a serializable JSON tree (`neo.dock.zone.v1`): edge zones, nested splits, tabbed slots, an item catalog. The **model** is a pure executor — `applyOperation(descriptor)` in, new normalized document out, invariants guaranteed. The **adapter** projects the committed document into ordinary engine configs — `hbox`/`vbox` splits, tab containers, splitter affordances; it invents no layout engine of its own. The @@ -143,7 +143,7 @@ before it lands ([ADR 0029 §2.1](../../agentos/decisions/0029-docking-design.md | If you are looking at… | It lives in… | Persisted? | |---|---|---| | the dock tree, item catalog, `sizes`, `pinned`/`autoHidden`, saved layouts and perspectives | **worker-owned shared truth** — the workspace container's committed documents | yes — serializable by contract | -| projected configs, edge rails, splitter affordances, tab headers | **per-window render projection** — `DockLayoutAdapter.project()` output | never — derived | +| projected configs, edge rails, splitter affordances, tab headers | **per-window render projection** — `projection.LayoutAdapter.project()` output | never — derived | | drag previews, hover state, reveal state of an auto-hidden pane, mid-drag splitter math | **per-window runtime interaction state** | never — dies with the gesture | | DOM nodes, `DOMRect`s, screen coordinates, native window geometry | **main-thread-only state** — addons and window managers | never — delivered upward as semantic events only | @@ -162,9 +162,9 @@ dock demos still carry hand-rolled copies that are migrating next. The checklist [Dock Layouts: Adopting in Your App](DockLayoutsAdoption.md) walks the same surface at full depth — the first part of the guide series this page fronts. Once you extend the class, the adoption surface is: -1. **Extend `Neo.dashboard.DockWorkspace`.** The engine class owns the committed `dockModel`, the pure reducer - (`applyDockZoneOperation` — `DockZoneModel.applyOperation` over the current document), the deferred, promise-chained - re-projection (`onDockZoneDocumentChange` → `DockLayoutAdapter` → `DockProjectionReconciler`, bracketed by FLIP +1. **Extend `Neo.dashboard.dock.Workspace`.** The engine class owns the committed `dockModel`, the pure reducer + (`applyDockZoneOperation` — `Operations.applyOperation` over the current document), the deferred, promise-chained + re-projection (`onDockZoneDocumentChange` → `projection.LayoutAdapter` → `projection.Reconciler`, bracketed by FLIP motion) and the in-window cross-zone drop path. Your subclass overrides `resolvePane(itemId, item)` and, when it has them, the handful of hooks for owner-preserved panes, chrome that syncs on every re-projection, and extra projection options. `examples/dashboard/dock/MainContainer.mjs` is the minimal consumer. @@ -183,34 +183,36 @@ of the guide series this page fronts. Once you extend the class, the adoption su declared forward contract whose close-routing enforcement has not landed yet; the [adoption guide](DockLayoutsAdoption.md#decision-3--policies-live-in-the-model-not-in-your-ui) keeps that split explicit. -4. **Give vessels a render target.** Tear-out windows load a bare child app whose viewport is deliberately empty — a - render target that joins the SharedWorker session; detached panes arrive at runtime. The agentos app's - `childapps/widget` viewport is the canonical example — a bare viewport class whose own JSDoc says it all: - "deliberately empty: detached panels arrive at runtime; nothing is declared here." +4. **Give vessels a render target.** Tear-out windows load a viewport that is deliberately empty — a render target + that joins the SharedWorker session; detached panes arrive at runtime. The canonical example is the cross-window + demo's `?popout` boot branch (`examples/dashboard/crossWindow/Viewport.mjs`), whose own JSDoc says it all: "This + window carries no workspace of its own; the opener's workspace reparents the live pane into it on connect." 5. **Persist through the wrappers, not by hand.** `createSavedLayout` / `restoreSavedLayout` and the - perspective-carrying `dockLayout.v2` envelope give you named, switchable, fail-closed-validated arrangements. + perspective-carrying `neo.dock.layout.v1` envelope give you named, switchable, fail-closed-validated arrangements. Restore refuses invalid documents wholesale — your users' layouts never half-restore. Styling arrives through the engine's token layer. The dock's visual language is being promoted from app stylesheets into `resources/scss/src/dashboard/` as neutral `--dock-*` tokens, so a consumer skins the affordances by overriding tokens rather than re-painting internals — the same discipline as every other engine surface. -## The wire vocabulary — and why it does not follow renames +## The wire vocabulary — one greenfield family -Eight schema identifiers ship under the `neo.harness.` prefix — a historical name from the subsystem's origin, -retired from every document title but deliberately **frozen on the wire** -([ADR 0029 §2.9](../../agentos/decisions/0029-docking-design.md)). They split into two compatibility classes: +Every schema identifier ships under the `neo.dock.` prefix — one coherent family selected in the v13.2 +greenfield cut ([ADR 0029 §2.9 amendment](../../agentos/decisions/0029-docking-design.md)). The subsystem's +pre-release history shipped no compatibility obligation, so the cut is total: readers fail closed on any +other family, and no migration reader, alias, or dual parser exists. -- **Persisted** — `dockZone.v1`, `dockLayout.v1`, `dockLayout.v2`, `dockLayoutCollection.v1`: these live in saved - layouts and perspectives, and restore validation is fail-closed by design. Renaming one outside a documented, - shape-changing migration would silently reject every layout your users ever saved. The shipped `v1 → v2` migration - is the only sanctioned precedent. -- **Runtime-only** — `dockPreview.v1`, `dockCandidates.v1`, `dockShape.v1`, `dockTopologyShape.v1`: never persisted, - but pinned by cross-window participation, Neural Link tooling, and the test suites. They version by coordinated - change, never by find-replace. +- **Persisted** — `neo.dock.zone.v1`, `neo.dock.layout.v1`, `neo.dock.layoutCollection.v1`: these live in + saved layouts and perspectives, and restore validation is fail-closed by design. The layout wrapper carries + the perspective fields (`captureScope`, `windowFingerprint`, `perspectiveName`, `windowDocuments`) — the + envelope IS the perspective capability, and there is exactly one revision of it. +- **Runtime-only** — `neo.dock.preview.v1`, `neo.dock.candidates.v1`, `neo.dock.shape.v1`, + `neo.dock.topologyShape.v1`: never persisted, but pinned by cross-window participation, Neural Link + tooling, and the test suites. They version by coordinated change, never by find-replace. -If you take one sentence from this section: **a schema string is an API to every byte your users ever stored** — -identity corrections rename documents and prose, never wire. +If you take one sentence from this section: **a schema string is an API to every byte your users will +store** — from here forward, identity lives in one family, unsupported versions are rejected inside it, and +the retired pre-release family is rejected as foreign. ## Common design constraints diff --git a/learn/guides/uibuildingblocks/DockLayoutsAdoption.md b/learn/guides/uibuildingblocks/DockLayoutsAdoption.md index 78fa627f27..771e31ba47 100644 --- a/learn/guides/uibuildingblocks/DockLayoutsAdoption.md +++ b/learn/guides/uibuildingblocks/DockLayoutsAdoption.md @@ -9,7 +9,7 @@ This guide answers that question in the order you will actually ask it. By the e in your own application, and — more useful — you will know exactly which decisions were yours to make, because there are only five of them. Everything else belongs to one engine class. -`Neo.dashboard.DockWorkspace` centralizes the host loop that connects a dock document to its live projection: +`Neo.dashboard.dock.Workspace` centralizes the host loop that connects a dock document to its live projection: refresh scheduling, reconciliation, motion and cross-zone drops. Both the minimal example and the workstation use that engine class today. Every snippet in this guide follows one of those live consumers, so you can compare the adoption pattern against a small workspace or a feature-rich one. @@ -21,7 +21,7 @@ flowchart TD classDef yours fill:#1a3c34,stroke:#2ecc71,stroke-width:2px,color:#eee classDef engine fill:#1b2e4e,stroke:#3498db,stroke-width:1px,color:#eee - Extend["extends Neo.dashboard.DockWorkspace"]:::yours + Extend["extends Neo.dashboard.dock.Workspace"]:::yours Seed["Decision 1 — seed + mount
your initial document, your shell placement"]:::yours Panes["Decision 2 — resolvePane
your components become panes"]:::yours Policy["Decision 3 — policies
pinnable · movable today
closable is a forward contract"]:::yours @@ -36,8 +36,8 @@ flowchart TD Your subclass makes five decisions. The class owns the loop those decisions plug into: the pure reducer (`applyDockZoneOperation`), the view-sync that stores each committed document and schedules exactly one atomic -re-projection (`onDockZoneDocumentChange`), the projection through `DockLayoutAdapter`, the identity-preserving -reconciliation through `DockProjectionReconciler`, the FLIP motion bracket, and the in-window cross-zone drop path. +re-projection (`onDockZoneDocumentChange`), the projection through `projection.LayoutAdapter`, the identity-preserving +reconciliation through `projection.Reconciler`, the FLIP motion bracket, and the in-window cross-zone drop path. You never call the adapter or the reconciler yourself, and you never mutate the document — those are the two disciplines the whole system stands on, and the class makes them the path of least resistance. @@ -53,11 +53,11 @@ The class owns the loop, not your boot state. Two responsibilities stay with you loudly — to guess either one: ```javascript readonly -import DockWorkspace from '../../../src/dashboard/DockWorkspace.mjs'; -import DockZoneModel from '../../../src/dashboard/DockZoneModel.mjs'; +import DockWorkspace from '../../../src/dashboard/dock/Workspace.mjs'; +import Document from '../../../src/dashboard/dock/model/Document.mjs'; const initialDockModel = { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { editor : {componentRef: 'Editor', title: 'Editor', kind: 'panel'}, @@ -80,7 +80,7 @@ class Workspace extends DockWorkspace { construct(config) { super.construct(config); - this.dockModel = DockZoneModel.clone(initialDockModel); + this.dockModel = Document.clone(initialDockModel); this.add(this.projectDockModel()) } } @@ -88,7 +88,7 @@ class Workspace extends DockWorkspace { That is a complete, working docking workspace: two tabbed zones in a resizable split, drag a tab across zones, done. The document is plain serializable JSON — an item **catalog** (what exists) and a **node tree** (where it lives) — -and `DockZoneModel.clone` gives your seed a private copy so later commits never mutate your constant. +and `Document.clone` gives your seed a private copy so later commits never mutate your constant. Two placement configs cover the layouts real apps actually have. The example app (`examples/dashboard/dock/MainContainer.mjs`) puts a perspective toolbar above its shell, so it declares @@ -106,7 +106,7 @@ root. Your standalone app still gets a real `Neo.container.Viewport`, and the do ```javascript readonly import Viewport from '../../src/container/Viewport.mjs'; -import MainContainer from './MainContainer.mjs'; // extends Neo.dashboard.DockWorkspace +import MainContainer from './MainContainer.mjs'; // extends Neo.dashboard.dock.Workspace Neo.app({ mainView: { @@ -216,8 +216,10 @@ Layouts persist as documents, and the model owns the envelope so you never hand- fail-closed result objects — gate on `errors` before you trust either: ```javascript readonly +import Persistence from '../../../src/dashboard/dock/model/Persistence.mjs'; + // save the live arrangement — an invalid document refuses to serialize: `layout` stays null -const {layout, errors} = DockZoneModel.createSavedLayout(this.getDockZoneDocument(), { +const {layout, errors} = Persistence.createSavedLayout(this.getDockZoneDocument(), { layoutId: 'review-setup', title : 'Review setup' }); @@ -228,7 +230,7 @@ if (!errors.length) { // later — restore takes the saved LAYOUT and is equally fail-closed: an invalid or // preview-contaminated envelope is refused WHOLE, `document` stays null, the errors say why -const restored = DockZoneModel.restoreSavedLayout(layout); +const restored = Persistence.restoreSavedLayout(layout); restored.document && this.onDockZoneDocumentChange(restored.document) ``` @@ -251,9 +253,11 @@ is a designed, still-open second leaf of the same program that produced the clas shrinks the way the holder loop already shrank. What is stable under any future shape is the adopter-side obligation this section exists to teach: **the render -target is yours** — a child app whose viewport is deliberately empty, because detached panes arrive at runtime. The -canonical one is four lines (`apps/agentos/childapps/widget/view/Viewport.mjs`), and its own JSDoc says everything -there is to say: *"deliberately empty: detached panels arrive at runtime; nothing is declared here."* +target is yours** — a viewport that boots deliberately empty, because detached panes arrive at runtime. The +canonical one is the cross-window demo's `?popout` boot branch (`examples/dashboard/crossWindow/Viewport.mjs`), +and its own JSDoc says everything there is to say: *"This window carries no workspace of its own; the opener's +workspace reparents the live pane into it on connect — the shared-heap contract, one App Worker, two render +targets."* The deeper mechanics of the journey (claims, vessels, conversion, reintegration) are Part 2's territory. If your app needs tear-out today, read the workstation's composition first. The pending engine leaf will shrink the generic diff --git a/resources/scss/src/dashboard/Container.scss b/resources/scss/src/dashboard/Container.scss index 508bc24e56..4c8488766a 100644 --- a/resources/scss/src/dashboard/Container.scss +++ b/resources/scss/src/dashboard/Container.scss @@ -3,7 +3,7 @@ // transitions — splitter ease, tab insert/remove morphing, auto-hide rail slide, and the // FLIP commit layer all read THESE (a call-site duration/easing literal is a contract // violation). Motion lifecycle is observable via the `neo-dashboard-dock-animating` class - // (present during motion, removed on settle — see Neo.dashboard.DockMotionSignal). + // (present during motion, removed on settle — see Neo.dashboard.dock.projection.MotionSignal). // // Layering: these are the DOCK-DOMAIN aliases of the product motion vocabulary (the // motion-standards tier owns the semantic set — panel-move duration, settle easing). Dock @@ -463,7 +463,7 @@ gap: 4px; } -// The drop-indicator menu layer (Neo.dashboard.DockDropIndicators): pointer-transparent by +// The drop-indicator menu layer (Neo.dashboard.dock.interaction.DropIndicators): pointer-transparent by // contract — the sort-zone drag lifecycle stays the single pointer owner; selection is the // workspace-threaded hit-test, never DOM hover. Sits above the reveal overlay (z 20). .neo-dashboard-dock-drop-indicators { @@ -591,7 +591,7 @@ // The drag-proxy edge-light — the third surface of the Signal language. The proxy mounts at // document.body (DragZone's proxyParentId default), OUTSIDE any dock host, so descendant // scoping cannot reach it — the embodiment CARRIES its scope instead: -// Neo.dashboard.DockTabSortZone#getDragProxyConfig stamps the dock-ownership marker, the +// Neo.dashboard.dock.interaction.TabSortZone#getDragProxyConfig stamps the dock-ownership marker, the // active theme cls (app theme files project the `--agent-dock-preview-*` aliases onto the // theme class, so the palette resolves ON the proxy at its body mount), and the host's // `neo-preview-lang-*` modifier. Generic `.neo-dragproxy` surfaces (grid/list/tree drags) diff --git a/resources/scss/src/dashboard/DockPreview.scss b/resources/scss/src/dashboard/dock/interaction/Preview.scss similarity index 100% rename from resources/scss/src/dashboard/DockPreview.scss rename to resources/scss/src/dashboard/dock/interaction/Preview.scss diff --git a/resources/scss/theme-cyberpunk/dashboard/DockPreview.scss b/resources/scss/theme-cyberpunk/dashboard/dock/interaction/Preview.scss similarity index 64% rename from resources/scss/theme-cyberpunk/dashboard/DockPreview.scss rename to resources/scss/theme-cyberpunk/dashboard/dock/interaction/Preview.scss index f46c7cd0cb..d6117ae116 100644 --- a/resources/scss/theme-cyberpunk/dashboard/DockPreview.scss +++ b/resources/scss/theme-cyberpunk/dashboard/dock/interaction/Preview.scss @@ -1,4 +1,4 @@ -@use 'preview-accents' as *; +@use '../../preview-accents' as *; :where(.neo-theme-cyberpunk) { @include preview-accents; diff --git a/resources/scss/theme-dark/dashboard/DockPreview.scss b/resources/scss/theme-dark/dashboard/dock/interaction/Preview.scss similarity index 62% rename from resources/scss/theme-dark/dashboard/DockPreview.scss rename to resources/scss/theme-dark/dashboard/dock/interaction/Preview.scss index ee0e1fdeb3..21470c687c 100644 --- a/resources/scss/theme-dark/dashboard/DockPreview.scss +++ b/resources/scss/theme-dark/dashboard/dock/interaction/Preview.scss @@ -1,4 +1,4 @@ -@use 'preview-accents' as *; +@use '../../preview-accents' as *; :where(.neo-theme-dark) { @include preview-accents; diff --git a/resources/scss/theme-light/dashboard/DockPreview.scss b/resources/scss/theme-light/dashboard/dock/interaction/Preview.scss similarity index 63% rename from resources/scss/theme-light/dashboard/DockPreview.scss rename to resources/scss/theme-light/dashboard/dock/interaction/Preview.scss index 9604db92ab..e0418a34bb 100644 --- a/resources/scss/theme-light/dashboard/DockPreview.scss +++ b/resources/scss/theme-light/dashboard/dock/interaction/Preview.scss @@ -1,4 +1,4 @@ -@use 'preview-accents' as *; +@use '../../preview-accents' as *; :where(.neo-theme-light) { @include preview-accents; diff --git a/resources/scss/theme-neo-dark/dashboard/DockPreview.scss b/resources/scss/theme-neo-dark/dashboard/dock/interaction/Preview.scss similarity index 88% rename from resources/scss/theme-neo-dark/dashboard/DockPreview.scss rename to resources/scss/theme-neo-dark/dashboard/dock/interaction/Preview.scss index 7a9bd446e3..6d91a6dd96 100644 --- a/resources/scss/theme-neo-dark/dashboard/DockPreview.scss +++ b/resources/scss/theme-neo-dark/dashboard/dock/interaction/Preview.scss @@ -1,5 +1,5 @@ // The dock VALUES layer (dark): colors for the preview-affordance token family that -// resources/scss/src/dashboard/DockPreview.scss reads. Structure owns the paint; this file +// resources/scss/src/dashboard/dock/interaction/Preview.scss reads. Structure owns the paint; this file // owns the values — the two-layer split every themed sibling package honours. // // `:where()` is deliberate and load-bearing: it contributes ZERO specificity, so these @@ -13,7 +13,7 @@ // project these exact values; the engine default codifies what shipped, rather than the // GitHub-dark literals the structure layer carries as last-resort fallbacks. The signal // teal is the preview design artifact's «Signal glow» identity. -@use 'preview-accents' as *; +@use '../../preview-accents' as *; :where(.neo-theme-neo-dark) { @include preview-accents; diff --git a/resources/scss/theme-neo-light/dashboard/DockPreview.scss b/resources/scss/theme-neo-light/dashboard/dock/interaction/Preview.scss similarity index 86% rename from resources/scss/theme-neo-light/dashboard/DockPreview.scss rename to resources/scss/theme-neo-light/dashboard/dock/interaction/Preview.scss index 3c3c9382fd..24d23b44ca 100644 --- a/resources/scss/theme-neo-light/dashboard/DockPreview.scss +++ b/resources/scss/theme-neo-light/dashboard/dock/interaction/Preview.scss @@ -1,5 +1,5 @@ // The dock VALUES layer (light): colors for the preview-affordance token family that -// resources/scss/src/dashboard/DockPreview.scss reads. Structure owns the paint; this file +// resources/scss/src/dashboard/dock/interaction/Preview.scss reads. Structure owns the paint; this file // owns the values — the two-layer split every themed sibling package honours. // // `:where()` is deliberate and load-bearing: it contributes ZERO specificity, so these @@ -11,7 +11,7 @@ // // Provenance: the converged application language. The light skin's deep pigment keeps the // identity legible on paper; light chips float, they do not glow. -@use 'preview-accents' as *; +@use '../../preview-accents' as *; :where(.neo-theme-neo-light) { @include preview-accents; diff --git a/src/ai/client/DockService.mjs b/src/ai/client/DockService.mjs index 164687f692..6fd16b1d24 100644 --- a/src/ai/client/DockService.mjs +++ b/src/ai/client/DockService.mjs @@ -1,6 +1,7 @@ -import DockTopologyDiff from '../../dashboard/DockTopologyDiff.mjs'; -import DockTopologyReconciler from '../../dashboard/DockTopologyReconciler.mjs'; -import DockZoneModel from '../../dashboard/DockZoneModel.mjs'; +import DockTopologyDiff from '../../dashboard/dock/model/TopologyDiff.mjs'; +import DockTopologyReconciler from '../../dashboard/dock/model/TopologyReconciler.mjs'; +import Operations from '../../dashboard/dock/model/Operations.mjs'; +import Persistence from '../../dashboard/dock/model/Persistence.mjs'; import Service from './Service.mjs'; import {deriveSubtreePath} from '../deriveSubtreePath.mjs'; @@ -11,14 +12,14 @@ import {deriveSubtreePath} from '../deriveSubtreePath.mjs'; * design tier built on it). * * The service never mutates layout state outside the landed commit path: operations dispatch - * through `DockZoneModel.applyOperation()` (or the holder's own `applyDockZoneOperation` + * through `Operations.applyOperation()` (or the holder's own `applyDockZoneOperation` * override when present), and successful documents commit back exactly the way * `DockSplitter.commitResizeSplit()` does — including the `onDockZoneDocumentChange` * notification hook. Policy rejections (e.g. `pinnable: false`) therefore surface as the * executor's structured `errors`, never get bypassed. * * Perspective verbs consume the executable substrate directly: capture scope validates against - * `DockZoneModel.CAPTURE_SCOPES` (the SSOT — never a hand-listed mirror), capture rides the + * `Persistence.CAPTURE_SCOPES` (the SSOT — never a hand-listed mirror), capture rides the * landed scope producers, and restore inspects the stored record's own `captureScope` BEFORE * any state moves, routing topology records through `DockTopologyReconciler` plus the holder's * atomic multi-document commit seam. @@ -41,7 +42,7 @@ class DockService extends Service { * @member {ReadonlyArray} operations * @static */ - static operations = DockZoneModel.operations + static operations = Operations.operations /** * Resolves a live dock-document holder — a component that carries a `dockZoneDocument`, @@ -110,7 +111,7 @@ class DockService extends Service { * @param {String} params.componentId The dock workspace / holder component id * @param {Object} params.beforeDocument The earlier dockZone.v1 document to compare against * @param {Number} [params.sizeEpsilon] Optional resize tolerance on split size fractions - * @returns {Object} The {@link Neo.dashboard.DockTopologyDiff#diffDockDocuments} result + * @returns {Object} The {@link Neo.dashboard.dock.model.TopologyDiff#diffDockDocuments} result */ async diffDockTopology({componentId, beforeDocument, sizeEpsilon}) { const holder = this.resolveHolder(componentId); @@ -124,11 +125,11 @@ class DockService extends Service { * verb of the perspective tool trio. * * `captureScope` validates against the executable SSOT - * ({@link Neo.dashboard.DockZoneModel#CAPTURE_SCOPES}), never a hand-listed mirror: + * ({@link Neo.dashboard.dock.model.Document#CAPTURE_SCOPES}), never a hand-listed mirror: * `window` (the default) captures the holder's own document through - * `DockZoneModel.capturePerspective()` (fingerprint-coherent by construction); `topology` + * `Persistence.capturePerspective()` (fingerprint-coherent by construction); `topology` * captures the whole multi-window workspace through - * `DockZoneModel.captureTopologyPerspective()` over the holder's topology read seam — + * `Persistence.captureTopologyPerspective()` over the holder's topology read seam — * `getDockTopologyDocuments()`, returning the ordered committed documents, primary first. * A holder without that seam refuses topology capture with the missing seam declared — * never a silent downgrade to window scope. @@ -142,11 +143,11 @@ class DockService extends Service { * @returns {Object} `{captured, stored, collision, errors, layout}` */ async capturePerspective({componentId, layoutId, perspectiveName, title, captureScope = 'window', replace = false}) { - if (!DockZoneModel.CAPTURE_SCOPES.includes(captureScope)) { + if (!Persistence.CAPTURE_SCOPES.includes(captureScope)) { return { captured : false, collision: null, - errors : [`unknown captureScope "${captureScope}" — the vocabulary is: ${DockZoneModel.CAPTURE_SCOPES.join(', ')}`], + errors : [`unknown captureScope "${captureScope}" — the vocabulary is: ${Persistence.CAPTURE_SCOPES.join(', ')}`], layout : null, stored : false } @@ -180,9 +181,9 @@ class DockService extends Service { } } - produced = DockZoneModel.captureTopologyPerspective(holder.getDockTopologyDocuments(), metadata) + produced = Persistence.captureTopologyPerspective(holder.getDockTopologyDocuments(), metadata) } else { - produced = DockZoneModel.capturePerspective(this.readDocument(holder), metadata) + produced = Persistence.capturePerspective(this.readDocument(holder), metadata) } if (produced.errors.length) { @@ -244,7 +245,7 @@ class DockService extends Service { * - **window** records prefer the holder's switch seam (`activatePerspective` — commit * loop, animation and error rendering included), falling back to the store's fail-closed * load plus the landed plain-holder commit semantics. - * - **topology** records route through {@link Neo.dashboard.DockTopologyReconciler} plus the + * - **topology** records route through {@link Neo.dashboard.dock.model.TopologyReconciler} plus the * holder's atomic multi-document commit seam — see * {@link #restoreTopologyPerspective}. `windowDocuments` are never dropped: a topology * record can never report `switched: true` off a single-document commit. @@ -331,7 +332,7 @@ class DockService extends Service { /** * The topology-scope restore branch: reconciles a multi-window record onto the live - * workspace through {@link Neo.dashboard.DockTopologyReconciler#reconcile} and commits + * workspace through {@link Neo.dashboard.dock.model.TopologyReconciler#reconcile} and commits * ALL result documents through the holder's atomic seam — all-or-nothing, by contract. * * The holder seam pair a topology-capable workspace exposes: @@ -349,7 +350,7 @@ class DockService extends Service { * @param {Neo.component.Base} config.holder The resolved dock-document holder * @param {String} config.name The perspective name being restored * @param {Object} config.record The stored topology-scope saved-layout record - * @param {Neo.dashboard.DockPerspectiveStore} config.store The holder's perspective store + * @param {Neo.dashboard.dock.persistence.PerspectiveLibrary} config.store The holder's perspective store * @returns {Object} `{switched, captureScope, errors, document, documents, restored, unrestored, displaced}` * @protected */ @@ -450,7 +451,7 @@ class DockService extends Service { * path and returns the post-operation state, so agents can verify without a second call. * @param {Object} params * @param {String} params.componentId The dock workspace / holder component id - * @param {Object} params.descriptor `{operation, ...}` — the `DockZoneModel.applyOperation()` shape + * @param {Object} params.descriptor `{operation, ...}` — the `Operations.applyOperation()` shape * @param {Object|null} [context] The Bridge-stamped agent writer pair (2nd dispatch arg); null/undefined = legacy. * @returns {Object} `{applied, errors, document}` — `applied: false` carries the executor's errors */ @@ -484,7 +485,7 @@ class DockService extends Service { if (typeof holder.applyDockZoneOperation === 'function') { result = holder.applyDockZoneOperation(descriptor, this) || null } else { - result = DockZoneModel.applyOperation(this.readDocument(holder), descriptor) + result = Operations.applyOperation(this.readDocument(holder), descriptor) } } catch (e) { // the reducer contract assumes a well-formed document; a malformed holder document diff --git a/src/ai/client/TourRunner.mjs b/src/ai/client/TourRunner.mjs index 955bdbb570..6c2c7d5b2f 100644 --- a/src/ai/client/TourRunner.mjs +++ b/src/ai/client/TourRunner.mjs @@ -161,7 +161,7 @@ class TourRunner extends Base { /** * The executable dock-operation vocabulary handed to the script validator. * `null` resolves to the injected service's exported SSOT - * (`DockService.operations`, itself read by reference from `DockZoneModel`). + * (`DockService.operations`, itself read by reference from `model.Operations`). * Override only in specs that fixture the seam. * @member {String[]|null} operations=null */ diff --git a/src/ai/client/tourScript.mjs b/src/ai/client/tourScript.mjs index 3b680d93f0..e1b6d4d708 100644 --- a/src/ai/client/tourScript.mjs +++ b/src/ai/client/tourScript.mjs @@ -460,7 +460,7 @@ export function validateTourScript(script, {crossWindowAvailable = false, operat if (step.type === 'op') { if (!isPlainObject(step.descriptor)) { - errors.push(`${stepPath}.descriptor: required object ({operation, …} — the DockZoneModel.applyOperation() shape)`) + errors.push(`${stepPath}.descriptor: required object ({operation, …} — the Operations.applyOperation() shape)`) } else if (operations.length < 1) { errors.push(`${stepPath}.descriptor.operation: no operation vocabulary supplied to the validator — op steps cannot validate fail-closed`) } else if (!operations.includes(step.descriptor.operation)) { diff --git a/src/dashboard/DockZoneModel.mjs b/src/dashboard/DockZoneModel.mjs deleted file mode 100644 index 764626cd63..0000000000 --- a/src/dashboard/DockZoneModel.mjs +++ /dev/null @@ -1,2179 +0,0 @@ -import Base from '../core/Base.mjs'; - -/** - * @class Neo.dashboard.DockZoneModel - * @extends Neo.core.Base - * - * @summary Executor for the dock-zone semantic operations (`neo.harness.dockZone.v1`). - * - * The "missing middle" of the docking line: `Neo.dashboard.DockPreview.previewToOperation()` - * produces an operation descriptor on drop, this executor applies it to mutate the persisted - * dock-zone tree, and `Neo.dashboard.DockLayoutAdapter` renders the committed result. The contract - * and data model are defined in `learn/agentos/DockZoneModel.md` (§Data Model + §Operations); - * this class is the code realization of §Operations. - * - * All operations are **pure static functions** over a `dockZone.v1` document: each deep-clones the - * input, applies the mutation, normalizes, then validates. They are **fail-closed** — an operation - * with an invalid reference or one that would violate an invariant returns the ORIGINAL document - * unchanged plus a non-empty `errors` array, never a partially-mutated tree. The model persists - * semantic splits/tabs/order only; runtime pixels, DOMRects and preview state never enter it. - * - * Saved-layout helpers wrap the same committed model in `neo.harness.dockLayout.v1` after enforcing - * the finite saved-layout schema and JSON-only values. They deliberately do not choose a storage backend. - * Named-layout collection helpers wrap multiple saved-layout envelopes in - * `neo.harness.dockLayoutCollection.v1` while keeping the active-layout choice pure and storage-free. - * - * Return shape for every operation: `{document, errors}` — `errors` empty means the operation - * committed; non-empty means it was rejected and `document` is the untouched input. - */ -class DockZoneModel extends Base { - /** - * The persisted dock-zone document schema this executor operates on. - * @member {String} SCHEMA='neo.harness.dockZone.v1' - * @static - */ - static SCHEMA = 'neo.harness.dockZone.v1' - - /** - * The saved layout wrapper schema around a normalized dock-zone document. v2 adds the - * perspective fields (`captureScope`, `windowFingerprint`, `perspectiveName`); writes always - * emit v2, while v1 records stay readable through {@link #migrateSavedLayout} (fail-open read - * with honest defaults — a legacy record never errors, and never silently re-persists as v1). - * @member {String} LAYOUT_SCHEMA='neo.harness.dockLayout.v2' - * @static - */ - static LAYOUT_SCHEMA = 'neo.harness.dockLayout.v2' - - /** - * The legacy saved-layout wrapper schema, accepted on read via {@link #migrateSavedLayout}. - * @member {String} LAYOUT_SCHEMA_V1='neo.harness.dockLayout.v1' - * @static - */ - static LAYOUT_SCHEMA_V1 = 'neo.harness.dockLayout.v1' - - /** - * The saved layout collection schema for named layout perspectives. The collection envelope - * stays v1: its `layouts` values migrate individually at restore time. - * @member {String} LAYOUT_COLLECTION_SCHEMA='neo.harness.dockLayoutCollection.v1' - * @static - */ - static LAYOUT_COLLECTION_SCHEMA = 'neo.harness.dockLayoutCollection.v1' - - /** - * The capture scopes a saved layout may declare: one window's dock document, or the whole - * multi-window topology. - * @member {String[]} CAPTURE_SCOPES - * @static - */ - static CAPTURE_SCOPES = ['window', 'topology'] - - /** - * Dispatch table for `applyOperation()` — operation name → executor. THE single source of - * the dockZone.v1 semantic vocabulary: `operations` derives from these keys, so an - * operation cannot exist in dispatch without being exported, nor be exported without - * dispatching — the two directions cannot diverge by construction. Handlers share the - * executor signature `(document, descriptor)` and the fail-closed `{document, errors}` - * result contract. The `addTab` entry carries the contract's "addTab or moveItem" - * downgrade: a `tab-*` descriptor dispatches as a move when its item already lives - * in the tree. - * @member {Object} operationHandlers - * @protected - * @static - */ - static operationHandlers = Object.freeze({ - addTab: (document, descriptor) => - DockZoneModel.findContainingTabsId(document, descriptor.itemId) - ? DockZoneModel.moveItem(document, {itemId: descriptor.itemId, targetNodeId: descriptor.tabsNodeId, index: descriptor.index}) - : DockZoneModel.addTab(document, descriptor), - applyDocument : (document, descriptor) => DockZoneModel.applyDocument(document, descriptor), - moveItem : (document, descriptor) => DockZoneModel.moveItem(document, descriptor), - splitNode : (document, descriptor) => DockZoneModel.splitNode(document, descriptor), - moveNode : (document, descriptor) => DockZoneModel.moveNode(document, descriptor), - resizeSplit : (document, descriptor) => DockZoneModel.resizeSplit(document, descriptor), - detachItem : (document, descriptor) => DockZoneModel.detachItem(document, descriptor), - closeItem : (document, descriptor) => DockZoneModel.closeItem(document, descriptor), - setItemPinned : (document, descriptor) => DockZoneModel.setItemPinned(document, descriptor), - setItemAutoHidden: (document, descriptor) => DockZoneModel.setItemAutoHidden(document, descriptor), - // transferItem / transferNode are TWO-document operations; their single-document dispatch is a - // fail-closed redirect so each still joins the derived `operations` vocabulary without a - // hand-listed entry. Execute them through the matching two-document DockZoneModel method. - transferItem: document => ({document, errors: ['transferItem is a two-document operation; call DockZoneModel.transferItem(sourceDocument, targetDocument, descriptor)']}), - transferNode: document => ({document, errors: ['transferNode is a two-document operation; call DockZoneModel.transferNode(sourceDocument, targetDocument, descriptor)']}) - }) - - /** - * The semantic operation vocabulary — derived from the dispatch table's keys, never - * hand-listed, so vocabulary and dispatch agree in both directions by construction. - * Consumers that enumerate, validate, or advertise executable operations read this - * export (the Neural Link service tier reads it by reference). Prose surfaces (e.g. - * OpenAPI tool descriptions) remain manual mirrors with NO mechanical guard — they - * update by review discipline. - * @member {ReadonlyArray} operations - * @static - */ - static operations = Object.freeze(Object.keys(DockZoneModel.operationHandlers)) - - /** - * Top-level fields allowed in a saved-layout wrapper. - * @member {Set} savedLayoutKeys - * @protected - * @static - */ - static savedLayoutKeys = new Set([ - 'schema', 'layoutId', 'title', 'dockZone', 'metadata', 'revision', - 'captureScope', 'windowFingerprint', 'perspectiveName', 'windowDocuments' - ]) - - /** - * Top-level fields allowed in a named saved-layout collection. - * @member {Set} savedLayoutCollectionKeys - * @protected - * @static - */ - static savedLayoutCollectionKeys = new Set(['schema', 'activeLayoutId', 'layouts', 'metadata', 'revision']) - - /** - * Top-level fields allowed in a persisted dock-zone document. - * @member {Set} dockZoneDocumentKeys - * @protected - * @static - */ - static dockZoneDocumentKeys = new Set(['schema', 'root', 'items', 'nodes']) - - /** - * Fields allowed on persisted dock-zone item records. - * @member {Set} dockZoneItemKeys - * @protected - * @static - */ - static dockZoneItemKeys = new Set(['componentRef', 'title', 'kind', 'blueprint', 'closable', 'pinnable', 'pinned', 'autoHidden', 'movable', 'metadata']) - - /** - * Fields allowed on persisted dock-zone nodes, keyed by node type. - * @member {Object>} dockZoneNodeKeys - * @protected - * @static - */ - static dockZoneNodeKeys = { - 'edge-zone': new Set(['type', 'zones']), - split : new Set(['type', 'orientation', 'children', 'sizes']), - tabs : new Set(['type', 'items', 'activeItemId']) - } - - /** - * Runtime-only preview / interaction keys that must never enter committed OR persisted dock-zone - * state (the JSON-first serialization contract). `validate` rejects a document carrying any of - * these ANYWHERE — including inside the opaque `metadata` channel — so they cannot be smuggled - * through a saved layout; `Neo.dashboard.DockLayoutAdapter` reads this same set at the projection - * boundary, so persistence-rejection and projection-rejection cannot drift. - * @member {Set} forbiddenPreviewKeys - * @protected - * @static - */ - static forbiddenPreviewKeys = new Set([ - 'appName', - 'currentIndex', - 'draggedItem', - 'dockPreview', - 'domRect', - 'DOMRect', - 'groupNodeId', - 'isWindowDragging', - 'placement', - 'pointer', - 'pointerX', - 'pointerY', - 'previewId', - 'sourceSortZone', - 'targetSortZone', - 'windowId' - ]) - - /** - * @summary Recursively finds the first runtime-only preview key ({@link #forbiddenPreviewKeys}) - * anywhere in an arbitrary JSON graph — including nested `metadata` — or null when the graph is - * clean. Both the persistence contract (`validate`) and the render boundary (adapter projection) - * scan through this one finder. - * @param {*} value - * @returns {String|null} - * @protected - * @static - */ - static findForbiddenPreviewKey(value) { - if (!value || typeof value !== 'object') { - return null - } - - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - let match = DockZoneModel.findForbiddenPreviewKey(value[i]); - - if (match) { - return match - } - } - - return null - } - - for (let key of Object.keys(value)) { - if (DockZoneModel.forbiddenPreviewKeys.has(key)) { - return key - } - - let match = DockZoneModel.findForbiddenPreviewKey(value[key]); - - if (match) { - return match - } - } - - return null - } - - /** - * Zone names allowed in an `edge-zone` node. - * @member {Set} dockZoneEdgeKeys - * @protected - * @static - */ - static dockZoneEdgeKeys = new Set(['top', 'right', 'bottom', 'left', 'center']) - - static config = { - /** - * @member {String} className='Neo.dashboard.DockZoneModel' - * @protected - */ - className: 'Neo.dashboard.DockZoneModel' - } - - /** - * @summary Type-aware deep clone of a dock-zone document. - * - * Uses `Neo.clone` (deep, ignoring Neo instances) rather than a `JSON.parse(JSON.stringify())` - * round-trip: the round-trip corrupts `Date` values into strings and silently drops `undefined`, - * functions, `Map`/`Set`, and symbol keys, whereas `Neo.clone`'s type map preserves them. - * @param {Object} document - * @returns {Object} - * @static - */ - static clone(document) { - return Neo.clone(document, true, true) - } - - /** - * @summary Returns true for JSON object records only. - * @param {*} value - * @returns {Boolean} - * @protected - * @static - */ - static isJsonRecord(value) { - return value !== null && - typeof value === 'object' && - (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) - } - - /** - * @summary Returns the first value that cannot round-trip as JSON. - * @param {*} value - * @param {String} [path='value'] - * @param {WeakSet} [seen=new WeakSet()] - * @returns {{path:String, reason:String}|null} - * @protected - * @static - */ - static findNonJsonValue(value, path='value', seen=new WeakSet()) { - if (value === null || typeof value === 'string' || typeof value === 'boolean') { - return null - } - - if (typeof value === 'number') { - return Number.isFinite(value) ? null : {path, reason: 'number must be finite'} - } - - if (typeof value !== 'object') { - return {path, reason: `${typeof value} is not JSON-serializable`} - } - - if (seen.has(value)) { - return {path, reason: 'cyclic object graph is not JSON-serializable'} - } - - seen.add(value); - - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - let match = DockZoneModel.findNonJsonValue(value[i], `${path}[${i}]`, seen); - - if (match) { - return match - } - } - - return null - } - - if (!DockZoneModel.isJsonRecord(value)) { - return {path, reason: `${value.constructor?.name || 'object'} is not a JSON record`} - } - - for (const key of Reflect.ownKeys(value)) { - if (typeof key === 'symbol') { - return {path: `${path}.${String(key)}`, reason: 'symbol keys are not JSON-serializable'} - } - - let match = DockZoneModel.findNonJsonValue(value[key], `${path}.${key}`, seen); - - if (match) { - return match - } - } - - return null - } - - /** - * @summary Returns the first own string key outside a finite schema allowlist. - * @param {Object} record - * @param {Set} allowedKeys - * @param {String} path - * @returns {{key:String, path:String, reason:String}|null} - * @protected - * @static - */ - static findUnexpectedKey(record, allowedKeys, path) { - for (const key of Object.keys(record)) { - if (!allowedKeys.has(key)) { - return {key, path: `${path}.${key}`, reason: 'field is outside the saved-layout schema'} - } - } - - return null - } - - /** - * @summary Returns true when a metadata key name is likely to carry credential material. - * @param {String} key - * @returns {Boolean} - * @protected - * @static - */ - static isSecretMetadataKey(key) { - let normalized = key - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[^a-z0-9]+/gi, '_') - .replace(/^_+|_+$/g, '') - .toLowerCase(); - - return /(^|_)(secret|secrets|token|tokens|credential|credentials|password|passwords|pat|pats)$/.test(normalized) || - /(^|_)(api|auth|session|access|refresh|bridge|github|private|personal_access)_?(key|token|secret|credential|password)$/.test(normalized) - } - - /** - * @summary Returns the first metadata key that looks like credential material. - * @param {*} value - * @param {String} [path='metadata'] - * @returns {{key:String, path:String, reason:String}|null} - * @protected - * @static - */ - static findSecretMetadataKey(value, path='metadata') { - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - let match = DockZoneModel.findSecretMetadataKey(value[i], `${path}[${i}]`); - - if (match) { - return match - } - } - - return null - } - - if (!DockZoneModel.isJsonRecord(value)) { - return null - } - - for (const [key, child] of Object.entries(value)) { - if (DockZoneModel.isSecretMetadataKey(key)) { - return {key, path: `${path}.${key}`, reason: 'metadata must not contain credentials or secrets'} - } - - let match = DockZoneModel.findSecretMetadataKey(child, `${path}.${key}`); - - if (match) { - return match - } - } - - return null - } - - /** - * @summary Returns the first field in a dock-zone document that is outside the persisted schema. - * - * `metadata` and `blueprint` are explicit opaque JSON-only extension points. They are caller-owned - * descriptive/config payloads and must not carry secrets or runtime authority; the helper enforces - * their JSON value shape, while this allowlist rejects runtime fields added beside the known model. - * @param {Object} document - * @param {String} [path='dockZone'] - * @returns {{key:String, path:String, reason:String}|null} - * @protected - * @static - */ - static findUnexpectedDockZoneKey(document, path='dockZone') { - if (!DockZoneModel.isJsonRecord(document)) { - return null - } - - let unexpected = DockZoneModel.findUnexpectedKey(document, DockZoneModel.dockZoneDocumentKeys, path); - - if (unexpected) { - return unexpected - } - - if (DockZoneModel.isJsonRecord(document.items)) { - for (const [itemId, item] of Object.entries(document.items)) { - if (!DockZoneModel.isJsonRecord(item)) { - return {key: itemId, path: `${path}.items.${itemId}`, reason: 'item record must be a JSON object'} - } - - unexpected = DockZoneModel.findUnexpectedKey(item, DockZoneModel.dockZoneItemKeys, `${path}.items.${itemId}`); - - if (unexpected) { - return unexpected - } - } - } - - if (DockZoneModel.isJsonRecord(document.nodes)) { - for (const [nodeId, node] of Object.entries(document.nodes)) { - if (!DockZoneModel.isJsonRecord(node)) { - return {key: nodeId, path: `${path}.nodes.${nodeId}`, reason: 'node record must be a JSON object'} - } - - let allowedNodeKeys = DockZoneModel.dockZoneNodeKeys[node.type]; - - if (!allowedNodeKeys) { - return {key: 'type', path: `${path}.nodes.${nodeId}.type`, reason: `unsupported dock-zone node type "${node.type}"`} - } - - unexpected = DockZoneModel.findUnexpectedKey(node, allowedNodeKeys, `${path}.nodes.${nodeId}`); - - if (unexpected) { - return unexpected - } - - if (node.type === 'edge-zone' && DockZoneModel.isJsonRecord(node.zones)) { - unexpected = DockZoneModel.findUnexpectedKey(node.zones, DockZoneModel.dockZoneEdgeKeys, `${path}.nodes.${nodeId}.zones`); - - if (unexpected) { - return unexpected - } - } - } - } - - return null - } - - /** - * @summary Mints a node id not yet present in the document. - * @param {Object} document - * @param {String} prefix - * @returns {String} - * @static - */ - static genId(document, prefix) { - let n = 0, - id; - - do { - id = `${prefix}-${n++}` - } while (document.nodes[id]); - - return id - } - - /** - * @summary Returns the id of the tabs node currently holding `itemId`, or null. - * @param {Object} document - * @param {String} itemId - * @returns {String|null} - * @static - */ - static findContainingTabsId(document, itemId) { - for (const [nodeId, node] of Object.entries(document.nodes)) { - if (node.type === 'tabs' && Array.isArray(node.items) && node.items.includes(itemId)) { - return nodeId - } - } - - return null - } - - /** - * @summary Captures an item's exact tree placement — the stored-position half of - * exact-position reintegration (docking design record §2.8, - * `learn/agentos/decisions/0029-docking-design.md`). - * - * `addTab` appends by default, so a detached item's way back to its ORIGINAL slot exists - * only if this pair was captured while the item was still in the tree — capture happens - * BEFORE the detach commit, restore passes the pair straight into `addTab`'s clamped - * `index`. Fail-closed: an item no tabs node currently holds captures `null` (catalog - * presence is not placement; there is nothing to restore to). - * @param {Object} document - * @param {String} itemId - * @returns {{tabsNodeId: String, index: Number}|null} - * @static - */ - static captureItemPlacement(document, itemId) { - let tabsNodeId = DockZoneModel.findContainingTabsId(document, itemId), - index = tabsNodeId ? document.nodes[tabsNodeId].items.indexOf(itemId) : -1; - - return index >= 0 ? {tabsNodeId, index} : null - } - - /** - * @summary Finds the parent node id + the slot key pointing at `nodeId`. - * - * For a `split` parent the slot is the child index (Number); for an `edge-zone` parent it is the - * zone key (String). Returns null when `nodeId` is the root or unreferenced. - * @param {Object} document - * @param {String} nodeId - * @returns {{parentId:String, slot:(Number|String)}|null} - * @static - */ - static findParentSlot(document, nodeId) { - for (const [parentId, node] of Object.entries(document.nodes)) { - if (node.type === 'split' && Array.isArray(node.children)) { - const index = node.children.indexOf(nodeId); - if (index > -1) return {parentId, slot: index} - } else if (node.type === 'edge-zone' && node.zones) { - for (const [zone, target] of Object.entries(node.zones)) { - if (target === nodeId) return {parentId, slot: zone} - } - } - } - - return null - } - - /** - * @summary Mutating helper: removes `itemId` from whatever tabs node holds it, fixing activeItemId. - * @param {Object} document the working (already-cloned) document - * @param {String} itemId - * @protected - * @static - */ - static detachFromTabs(document, itemId) { - let tabsId = DockZoneModel.findContainingTabsId(document, itemId); - - if (!tabsId) return; - - let node = document.nodes[tabsId]; - - node.items = node.items.filter(id => id !== itemId); - - if (node.activeItemId === itemId) { - node.activeItemId = node.items[0] ?? null - } - } - - /** - * @summary Set of node ids reachable from the document root. - * @param {Object} document - * @returns {Set} - * @static - */ - static reachableNodeIds(document) { - let seen = new Set(), - walk = nodeId => { - if (!nodeId || seen.has(nodeId)) return; - - let node = document.nodes[nodeId]; - - if (!node) return; - - seen.add(nodeId); - - if (node.type === 'split') { - (node.children || []).forEach(walk) - } else if (node.type === 'edge-zone') { - Object.values(node.zones || {}).forEach(walk) - } - }; - - walk(document.root); - - return seen - } - - /** - * @summary Mutating helper: unlinks the subtree rooted at `nodeId` from its parent, leaving the - * subtree's nodes in place (an unreferenced subtree the caller re-attaches, or `normalizeTree` - * prunes). A split parent has the child spliced out and its remaining sizes renormalized to sum 1 - * (preserving the survivors' relative ratios); an edge-zone parent has the zone deleted. - * @param {Object} document the working (already-cloned) document - * @param {String} nodeId - * @protected - * @static - */ - static detachNode(document, nodeId) { - let slot = DockZoneModel.findParentSlot(document, nodeId); - - if (!slot) return; - - let parent = document.nodes[slot.parentId]; - - if (typeof slot.slot === 'number') { - parent.children.splice(slot.slot, 1); - - if (Array.isArray(parent.sizes)) { - parent.sizes.splice(slot.slot, 1); - - let sum = parent.sizes.reduce((total, size) => total + size, 0); - - if (sum > 0) { - parent.sizes = parent.sizes.map(size => size / sum); - - // pin the last ratio to absorb float drift so the survivors sum to exactly 1 - let last = parent.sizes.length - 1; - - if (last > 0) { - parent.sizes[last] = 1 - parent.sizes.slice(0, last).reduce((total, size) => total + size, 0) - } - } - } - } else { - delete parent.zones[slot.slot] - } - } - - /** - * @summary Mutating helper: grafts an already-present subtree root `nodeId` into `document` at - * `targetNodeId` per `placement`. A `{kind: 'tab-into'}` placement merges the moved tabs node's - * items into the target tabs node in order then drops the emptied node; otherwise a split - * placement (`{orientation, position|edge, sizes}`) wraps the target + the moved subtree in a new - * split — the same parent-slot swap `splitNode` performs, generalized from a fresh pane to an - * existing subtree. Assumes `nodeId` is already detached and its nodes are present. Returns the - * (possibly empty) errors — empty means it mutated `document`. - * @param {Object} document the working (already-cloned) document - * @param {String} nodeId the subtree root to attach - * @param {String} targetNodeId the node the placement is relative to - * @param {Object} placement `{kind:'tab-into'}` or `{orientation, position, edge, sizes}` - * @returns {String[]} - * @protected - * @static - */ - static attachNode(document, nodeId, targetNodeId, placement = {}) { - let node = document.nodes[nodeId], - target = document.nodes[targetNodeId]; - - if (!node) return [`unknown node "${nodeId}"`]; - if (!target) return [`unknown target node "${targetNodeId}"`]; - - if (placement.kind === 'tab-into') { - if (node.type !== 'tabs' || target.type !== 'tabs') { - return ['tab-into placement requires both the moved node and the target to be tabs nodes'] - } - - target.items = [...(target.items || []), ...(node.items || [])]; - - if ((target.activeItemId === null || target.activeItemId === undefined) && target.items.length) { - target.activeItemId = target.items[0] - } - - delete document.nodes[nodeId]; - - return [] - } - - if (placement.orientation !== 'horizontal' && placement.orientation !== 'vertical') { - return [`invalid split orientation "${placement.orientation}"`] - } - - let {edge, orientation, position, sizes} = placement, - newSplitId = DockZoneModel.genId(document, `split-${targetNodeId}`), - ratio = (Array.isArray(sizes) && sizes.length === 2) ? sizes : [0.5, 0.5], - atPosition = position || ((edge === 'top' || edge === 'left') ? 'before' : 'after'), - // Resolve the target's parent BEFORE inserting the new split (which references the target). - parentSlot = DockZoneModel.findParentSlot(document, targetNodeId); - - document.nodes[newSplitId] = { - type : 'split', - orientation, - children: atPosition === 'before' ? [nodeId, targetNodeId] : [targetNodeId, nodeId], - sizes : ratio - }; - - if (!parentSlot) { - document.root = newSplitId - } else if (typeof parentSlot.slot === 'number') { - document.nodes[parentSlot.parentId].children[parentSlot.slot] = newSplitId - } else { - document.nodes[parentSlot.parentId].zones[parentSlot.slot] = newSplitId - } - - return [] - } - - /** - * @summary Validates a dock-zone document against the contract invariants. - * - * Checks: schema, root presence, reference integrity (split children / edge-zone zones / tabs - * items all resolve), each item appears at most once across the tree, split sizes match child - * count and sum to 1, and `tabs.activeItemId` is null or one of `tabs.items`. - * @param {Object} document - * @returns {String[]} the (possibly empty) list of invariant violations - * @static - */ - static validate(document) { - let errors = []; - - if (!document || typeof document !== 'object') return ['document is not an object']; - if (document.schema !== DockZoneModel.SCHEMA) errors.push(`schema must be ${DockZoneModel.SCHEMA}`); - if (!document.nodes || !document.nodes[document.root]) errors.push(`root node "${document.root}" is missing`); - - // Runtime-only preview state is invalid at the model boundary — not just at render projection. - // The scan reaches into the opaque `metadata` channel, so a preview key cannot ride a saved - // layout through createSavedLayout / restoreSavedLayout (both validate through here). - let previewKey = DockZoneModel.findForbiddenPreviewKey(document); - - if (previewKey) { - errors.push(`runtime-only preview field "${previewKey}" must not enter committed dock-zone state`) - } - - let items = document.items || {}, - nodes = document.nodes || {}, - itemUse = {}; - - for (const [itemId, item] of Object.entries(items)) { - if (DockZoneModel.isJsonRecord(item) && Object.hasOwn(item, 'pinned') && typeof item.pinned !== 'boolean') { - errors.push(`item "${itemId}" pinned must be a boolean`) - } - - if (DockZoneModel.isJsonRecord(item) && Object.hasOwn(item, 'autoHidden') && typeof item.autoHidden !== 'boolean') { - errors.push(`item "${itemId}" autoHidden must be a boolean`) - } - - if (DockZoneModel.isJsonRecord(item) && item.pinned === true && item.autoHidden === true) { - errors.push(`item "${itemId}" cannot be pinned and autoHidden at the same time`) - } - } - - for (const [nodeId, node] of Object.entries(nodes)) { - if (node.type === 'split') { - (node.children || []).forEach(childId => { - if (!nodes[childId]) errors.push(`split "${nodeId}" references missing node "${childId}"`) - }); - - let sizes = node.sizes || []; - - if (sizes.length !== (node.children || []).length) { - errors.push(`split "${nodeId}" sizes length ${sizes.length} != children length ${(node.children || []).length}`) - } else if (sizes.length && Math.abs(sizes.reduce((a, b) => a + b, 0) - 1) > 1e-6) { - errors.push(`split "${nodeId}" sizes do not sum to 1`) - } - } else if (node.type === 'edge-zone') { - Object.values(node.zones || {}).forEach(targetId => { - if (!nodes[targetId]) errors.push(`edge-zone "${nodeId}" references missing node "${targetId}"`) - }) - } else if (node.type === 'tabs') { - (node.items || []).forEach(itemId => { - if (!items[itemId]) errors.push(`tabs "${nodeId}" references missing item "${itemId}"`); - itemUse[itemId] = (itemUse[itemId] || 0) + 1 - }); - - if (node.activeItemId !== null && node.activeItemId !== undefined && !(node.items || []).includes(node.activeItemId)) { - errors.push(`tabs "${nodeId}" activeItemId "${node.activeItemId}" is not one of its items`) - } - } - } - - Object.entries(itemUse).forEach(([itemId, count]) => { - if (count > 1) errors.push(`item "${itemId}" appears ${count} times in the tree (must be at most once)`) - }); - - return errors - } - - /** - * @summary Normalizes a document: collapses empty/redundant structural nodes, repairs split - * sizes, prunes orphaned nodes, and repairs each `tabs.activeItemId`. - * - * An empty tabs or split node is removed from its parent; a split with a single child is replaced - * by that child; split sizes are evened when their count/sum is invalid; nodes unreachable from - * the root are dropped. - * @param {Object} document - * @returns {Object} a normalized clone - * @static - */ - static normalizeTree(document) { - let doc = DockZoneModel.clone(document); - - const collapse = nodeId => { - let node = doc.nodes[nodeId]; - - if (!node) return nodeId; - - if (node.type === 'split') { - node.children = (node.children || []).map(collapse).filter(id => doc.nodes[id]); - - if (node.children.length === 0) { delete doc.nodes[nodeId]; return null } - if (node.children.length === 1) { - let only = node.children[0]; - delete doc.nodes[nodeId]; - return only - } - - let count = node.children.length; - if (!Array.isArray(node.sizes) || node.sizes.length !== count || Math.abs(node.sizes.reduce((a, b) => a + b, 0) - 1) > 1e-6) { - node.sizes = node.children.map(() => 1 / count) - } - } else if (node.type === 'edge-zone') { - for (const [zone, target] of Object.entries(node.zones || {})) { - let resolved = collapse(target); - if (resolved && doc.nodes[resolved]) { node.zones[zone] = resolved } else { delete node.zones[zone] } - } - } else if (node.type === 'tabs') { - if (!node.items || node.items.length === 0) { delete doc.nodes[nodeId]; return null } - if (node.activeItemId === undefined || (node.activeItemId !== null && !node.items.includes(node.activeItemId))) { - node.activeItemId = node.items[0] - } - } - - return nodeId - }; - - let newRoot = collapse(doc.root); - doc.root = newRoot ?? doc.root; - - // prune nodes unreachable from the (possibly new) root - let reachable = DockZoneModel.reachableNodeIds(doc); - Object.keys(doc.nodes).forEach(nodeId => { - if (!reachable.has(nodeId)) delete doc.nodes[nodeId] - }); - - return doc - } - - /** - * @summary Normalizes + validates a mutated document; returns it only if valid (fail-closed). - * @param {Object} original the untouched input document - * @param {Object} mutated the working document after a mutation - * @returns {{document:Object, errors:String[]}} - * @protected - * @static - */ - static commit(original, mutated) { - let normalized = DockZoneModel.normalizeTree(mutated), - errors = DockZoneModel.validate(normalized); - - return errors.length ? {document: original, errors} : {document: normalized, errors: []} - } - - /** - * @summary Re-applies a whole candidate document through the shared fail-closed commit — the - * generic reverse of any forward operation: the document IS the state, so the honest inverse - * of a mutation (or a mutation burst) is the pre-mutation document, normalized + validated - * exactly like any forward commit. A missing candidate fails closed with the original returned - * untouched; a candidate failing validation never commits, per the `commit()` contract. - * @param {Object} document the committed dock-zone document - * @param {Object} descriptor {document: Object} the candidate document to commit - * @returns {{document:Object, errors:String[]}} - * @static - */ - static applyDocument(document, descriptor = {}) { - return descriptor.document - ? DockZoneModel.commit(document, descriptor.document) - : {document, errors: ['applyDocument requires a candidate document']} - } - - /** - * @summary Validates and normalizes split-size ratios to sum to 1. - * @param {Array} sizes - * @param {Number} count - * @param {String} splitNodeId - * @returns {{sizes:Number[], errors:String[]}} - * @protected - * @static - */ - static normalizeSplitSizes(sizes, count, splitNodeId) { - let errors = []; - - if (!Array.isArray(sizes)) { - return {sizes: [], errors: ['sizes must be an array']} - } - - if (sizes.length !== count) { - return {sizes: [], errors: [`split "${splitNodeId}" sizes length ${sizes.length} != children length ${count}`]} - } - - for (let i = 0; i < sizes.length; i++) { - let value = sizes[i]; - - if (typeof value !== 'number' || !Number.isFinite(value)) { - errors.push(`split "${splitNodeId}" size ${i} must be a finite number`) - } else if (value <= 0) { - errors.push(`split "${splitNodeId}" size ${i} must be greater than 0`) - } - } - - if (errors.length) { - return {sizes: [], errors} - } - - let total = sizes.reduce((sum, value) => sum + value, 0); - - if (!Number.isFinite(total) || total <= 0) { - return {sizes: [], errors: [`split "${splitNodeId}" sizes must sum to a finite positive value`]} - } - - let normalized = sizes.map(value => value / total); - - if (normalized.length > 1) { - normalized[normalized.length - 1] = 1 - normalized.slice(0, -1).reduce((sum, value) => sum + value, 0) - } - - return {sizes: normalized, errors: []} - } - - /** - * @summary Migrates a saved-layout record to the current wrapper schema, read-side and pure. - * - * A legacy v1 record gains the perspective fields with honest defaults (`captureScope: - * 'window'` — v1 could only ever capture one window's document — and `windowFingerprint: - * null`, since no fingerprint was recorded at capture time); `perspectiveName` stays absent - * because it is optional by contract. Idempotent: current-schema records pass through - * untouched, and unknown schemas pass through for the caller's validation to reject, so this - * never masks a genuinely foreign envelope. Writers never emit v1 again. - * @param {Object} savedLayout A saved-layout record of any known schema revision. - * @returns {Object} The record at the current schema revision (a shallow-cloned upgrade for v1). - * @static - */ - static migrateSavedLayout(savedLayout) { - if (savedLayout?.schema !== DockZoneModel.LAYOUT_SCHEMA_V1) { - return savedLayout - } - - return { - ...savedLayout, - schema : DockZoneModel.LAYOUT_SCHEMA, - captureScope : 'window', - windowFingerprint: null - } - } - - /** - * @summary Validates the perspective fields shared by the create and restore paths. - * - * `captureScope` must be one of {@link #CAPTURE_SCOPES}; `windowFingerprint` describes - * topology SHAPE only and must be a JSON object or null (never window ids or coordinates — - * the persistence guardrail); `perspectiveName`, when present, must be a non-empty string. - * @param {Object} layout The saved-layout record carrying the perspective fields. - * @returns {String[]} Validation errors, empty when the fields are contract-clean. - * @static - */ - static validatePerspectiveFields(layout) { - let errors = []; - - if (!DockZoneModel.CAPTURE_SCOPES.includes(layout.captureScope)) { - errors.push(`captureScope must be one of: ${DockZoneModel.CAPTURE_SCOPES.join(', ')}`) - } - - if (layout.windowFingerprint !== null && !DockZoneModel.isJsonRecord(layout.windowFingerprint)) { - errors.push('windowFingerprint must be a JSON object or null') - } - - if (Object.hasOwn(layout, 'perspectiveName') && - (typeof layout.perspectiveName !== 'string' || !layout.perspectiveName.trim()) - ) { - errors.push('perspectiveName must be a non-empty string when present') - } - - // windowDocuments carries the ADDITIONAL windows' trees (slots 1..N; slot 0 stays - // `dockZone`, so the degenerate single-window topology record equals a window-scope - // capture by construction). Topology-scope-only: a window-scope record carrying it - // fails closed; every slot tree passes the full dock-zone validation, offender indexed. - if (Object.hasOwn(layout, 'windowDocuments')) { - if (layout.captureScope !== 'topology') { - errors.push('windowDocuments is only valid on captureScope "topology" records') - } else if (!Array.isArray(layout.windowDocuments)) { - errors.push('windowDocuments must be an array of dock-zone documents') - } else { - layout.windowDocuments.forEach((tree, index) => { - const treeErrors = DockZoneModel.validate(tree); - - if (treeErrors.length) { - errors.push(`windowDocuments[${index}] is not a valid dock-zone document: ${treeErrors[0]}`) - } - - // The finite durable-field boundary applies to EVERY captured slot, not only - // the primary `dockZone` — runtime-bearing fields (window fingerprints, - // rects) must not ride an additional window document into persistence. - const unexpected = DockZoneModel.findUnexpectedDockZoneKey(tree, `windowDocuments[${index}]`); - - if (unexpected) { - errors.push(`windowDocuments[${index}] contains unexpected field "${unexpected.key}" at ${unexpected.path}: ${unexpected.reason}`) - } - }) - } - } - - return errors - } - - /** - * @summary Captures a whole multi-window topology as ONE v2 saved-layout perspective. - * - * Slot order is meaning: `documents[0]` becomes the primary `dockZone`, the remaining - * slots persist as `windowDocuments` (topology-scope-only), and `windowFingerprint` holds - * the composed topology term — so a single-document topology capture is structurally - * identical to a window-scope capture apart from its declared scope and composed - * fingerprint schema (the degenerate-case identity, asserted in the unit specs). - * - * Fingerprint-coherence by construction (same rule as {@link #capturePerspective}): raw - * inputs are fingerprint-PROBED first purely as the cycle/shape gate (results discarded — - * the writer's normalize pass must never see a cyclic graph), then the composed fingerprint - * derives exclusively from the PERSISTED trees, so it can never describe shapes the record - * does not contain. - * @param {Object[]} documents Ordered committed dock-zone documents, primary first. - * @param {Object} [metadata={}] {layoutId, title, revision, metadata, perspectiveName} - * @returns {{layout:(Object|null), errors:String[]}} - * @static - */ - static captureTopologyPerspective(documents, metadata={}) { - if (!Array.isArray(documents) || documents.length < 1) { - return {layout: null, errors: ['topology capture requires a non-empty ordered array of documents']} - } - - // probe every raw input first — the cycle/shape gate before any recursion-bearing pass - for (let i = 0; i < documents.length; i++) { - const probe = DockZoneModel.computeShapeFingerprint(documents[i]); - - if (probe.errors.length) { - return {layout: null, errors: probe.errors.map(error => `documents[${i}]: ${error}`)} - } - } - - const written = DockZoneModel.createSavedLayout(documents[0], { - ...metadata, - captureScope : 'topology', - windowFingerprint: null, - ...(documents.length > 1 && { - windowDocuments: documents.slice(1).map(DockZoneModel.normalizeTree) - }) - }); - - if (written.errors.length) { - return written - } - - // compose from the PERSISTED trees — the primary + the stored slots — never the raw inputs - const persisted = [written.layout.dockZone, ...(written.layout.windowDocuments || [])], - fingerprints = []; - - for (let i = 0; i < persisted.length; i++) { - const {fingerprint, errors} = DockZoneModel.computeShapeFingerprint(persisted[i]); - - if (errors.length) { - return {layout: null, errors: errors.map(error => `persisted[${i}]: ${error}`)} - } - - fingerprints.push(fingerprint) - } - - const composed = DockZoneModel.composeTopologyFingerprint(fingerprints); - - if (composed.errors.length) { - return {layout: null, errors: composed.errors} - } - - written.layout.windowFingerprint = composed.fingerprint; - - return written - } - - /** - * @summary Computes the shape-only fingerprint of a dock-zone document. - * - * The fingerprint describes topology SHAPE — node types, nesting, child arity, zone - * occupancy — and deliberately contains no node ids, item ids, sizes, titles or window - * identity, so two structurally identical layouts fingerprint identically regardless of - * where or when they were captured (the persistence guardrail for `windowFingerprint`). - * Deterministic by construction: child arrays keep document order, edge zones walk in the - * fixed {@link #dockZoneEdgeKeys} order. - * @param {Object} document The committed dock-zone document. - * @returns {{fingerprint:(Object|null), errors:String[]}} - * @static - */ - static computeShapeFingerprint(document) { - let errors = []; - - if (!DockZoneModel.isJsonRecord(document) || !DockZoneModel.isJsonRecord(document.nodes)) { - return {fingerprint: null, errors: ['fingerprint requires a document with a nodes record']} - } - - const counts = {'edge-zone': 0, split: 0, tabs: 0}, - visited = new Set(); - - const walk = nodeId => { - const node = document.nodes[nodeId]; - - if (!node) { - errors.push(`fingerprint walk found no node for id "${nodeId}"`); - return '?' - } - - // cycle guard: a node graph that references an ancestor would recurse forever — - // fail closed through the errors path, never a RangeError out of the public API - if (visited.has(nodeId)) { - errors.push(`fingerprint walk detected a cycle at node "${nodeId}"`); - return '?' - } - - visited.add(nodeId); - - counts[node.type] = (counts[node.type] || 0) + 1; - - switch (node.type) { - case 'split': - return `${node.orientation === 'horizontal' ? 'h' : 'v'}(${(node.children || []).map(walk).join(',')})`; - case 'tabs': - return `t${node.items?.length || 0}`; - case 'edge-zone': - return `e{${[...DockZoneModel.dockZoneEdgeKeys] - .map(zone => node.zones?.[zone] ? `${zone}:${walk(node.zones[zone])}` : '') - .filter(Boolean).join(',')}}`; - default: - errors.push(`fingerprint walk found unsupported node type "${node.type}"`); - return '?' - } - }; - - const shape = walk(document.root); - - if (errors.length) { - return {fingerprint: null, errors} - } - - return { - fingerprint: { - schema : 'neo.harness.dockShape.v1', - shape, - nodeCounts: counts, - itemCount : Object.keys(document.items || {}).length - }, - errors - } - } - - /** - * @summary Composes per-window shape fingerprints into one whole-topology fingerprint. - * - * Slot ORDER is meaning: the reconciliation of a restored topology maps captured slots onto - * live windows positionally-by-shape, so the composed term preserves input order verbatim. - * Envelope-agnostic by design — whichever record shape the topology capture persists, - * it carries this composition. Fails closed on an empty list, any entry that is not a - * window-shape fingerprint record, and any INCOMPLETE record: the composition consumes - * `itemCount`, and a window fingerprint always emits an integer count ≥ 0, so a missing or - * malformed count is rejected — never defaulted into a fake zero. - * @param {Object[]} windowFingerprints Ordered per-window records from {@link #computeShapeFingerprint}. - * @returns {{fingerprint:(Object|null), errors:String[]}} - * @static - */ - static composeTopologyFingerprint(windowFingerprints) { - let errors = []; - - if (!Array.isArray(windowFingerprints) || windowFingerprints.length < 1) { - return {fingerprint: null, errors: ['topology fingerprint requires a non-empty ordered array of window fingerprints']} - } - - windowFingerprints.forEach((entry, index) => { - if (entry?.schema !== 'neo.harness.dockShape.v1' || typeof entry.shape !== 'string') { - errors.push(`entry ${index} is not a window shape fingerprint record`) - } else if (!Number.isInteger(entry.itemCount) || entry.itemCount < 0) { - errors.push(`entry ${index} is an incomplete window fingerprint record: itemCount must be an integer >= 0`) - } - }); - - if (errors.length) { - return {fingerprint: null, errors} - } - - return { - fingerprint: { - schema : 'neo.harness.dockTopologyShape.v1', - windowCount: windowFingerprints.length, - shape : `w[${windowFingerprints.map(entry => entry.shape).join('|')}]`, - totalItems : windowFingerprints.reduce((sum, entry) => sum + entry.itemCount, 0) - }, - errors - } - } - - /** - * @summary Captures the current window's dock document as a v2 saved-layout perspective. - * - * The single-window capture scope: layout truth only enters the record — the committed - * document tree — never render projections, runtime handles or pane-internal state (panes - * are layout-blind, so their internals are not the layout's to save). - * - * Fingerprint-coherence by construction: the wrapper is written FIRST (validate + normalize - * through the one writer path), and the fingerprint is computed from the PERSISTED - * `layout.dockZone` — never the raw input — so the stored fingerprint cannot describe a - * tree the record does not contain (normalization collapses e.g. a single-child split to - * its child; a pre-normalization fingerprint would immortalize the collapsed wrapper). - * @param {Object} document The committed dock-zone document to capture. - * @param {Object} [metadata={}] {layoutId, title, revision, metadata, perspectiveName} - * @returns {{layout:(Object|null), errors:String[]}} - * @static - */ - static capturePerspective(document, metadata={}) { - // pre-probe the RAW input purely as the cycle/shape gate: the writer's normalize pass - // recurses and must never see a cyclic graph; the probe's fingerprint is DISCARDED so - // coherence with the persisted tree is never at risk - const probe = DockZoneModel.computeShapeFingerprint(document); - - if (probe.errors.length) { - return {layout: null, errors: probe.errors} - } - - const written = DockZoneModel.createSavedLayout(document, { - ...metadata, - captureScope : 'window', - windowFingerprint: null - }); - - if (written.errors.length) { - return written - } - - const {fingerprint, errors} = DockZoneModel.computeShapeFingerprint(written.layout.dockZone); - - if (errors.length) { - return {layout: null, errors} - } - - written.layout.windowFingerprint = fingerprint; - - return written - } - - /** - * @summary Wraps a valid committed dock-zone document in a JSON-only saved-layout envelope. - * - * The wrapper and dock-zone tree are finite-schema: unknown fields fail closed. The explicit - * `metadata` field is an opaque JSON-only non-secret annotation channel; callers must not place - * credentials or runtime authority inside it. - * @param {Object} document The committed dock-zone document to normalize and wrap. - * @param {Object} [metadata={}] {layoutId, title, revision, metadata, captureScope, windowFingerprint, perspectiveName} - * @returns {{layout:(Object|null), errors:String[]}} - * @static - */ - static createSavedLayout(document, metadata={}) { - if (!DockZoneModel.isJsonRecord(metadata)) { - return {layout: null, errors: ['metadata must be a JSON object']} - } - - let errors = DockZoneModel.validate(document); - - if (errors.length) { - return {layout: null, errors} - } - - let unexpectedKey = DockZoneModel.findUnexpectedDockZoneKey(document, 'document'); - - if (unexpectedKey) { - return { - layout: null, - errors: [`saved layout contains unexpected field "${unexpectedKey.key}" at ${unexpectedKey.path}: ${unexpectedKey.reason}`] - } - } - - let normalized = DockZoneModel.normalizeTree(document), - layoutId = Object.hasOwn(metadata, 'layoutId') ? metadata.layoutId : 'default', - title = Object.hasOwn(metadata, 'title') ? metadata.title : layoutId, - layout = { - schema : DockZoneModel.LAYOUT_SCHEMA, - layoutId, - title, - dockZone : normalized, - metadata : Object.hasOwn(metadata, 'metadata') ? metadata.metadata : {}, - captureScope : Object.hasOwn(metadata, 'captureScope') ? metadata.captureScope : 'window', - windowFingerprint: Object.hasOwn(metadata, 'windowFingerprint') ? metadata.windowFingerprint : null - }; - - if (Object.hasOwn(metadata, 'revision')) { - layout.revision = metadata.revision - } - - if (Object.hasOwn(metadata, 'perspectiveName')) { - layout.perspectiveName = metadata.perspectiveName - } - - if (Object.hasOwn(metadata, 'windowDocuments')) { - layout.windowDocuments = metadata.windowDocuments - } - - if (typeof layout.layoutId !== 'string' || !layout.layoutId.trim()) { - errors.push('layoutId must be a non-empty string') - } - - if (typeof layout.title !== 'string' || !layout.title.trim()) { - errors.push('title must be a non-empty string') - } - - errors.push(...DockZoneModel.validatePerspectiveFields(layout)) - - if (!DockZoneModel.isJsonRecord(layout.metadata)) { - errors.push('metadata must be a JSON object') - } - - let secretKey = DockZoneModel.findSecretMetadataKey(layout.metadata, 'savedLayout.metadata'); - - if (secretKey) { - errors.push(`saved layout metadata contains secret-like field "${secretKey.key}" at ${secretKey.path}: ${secretKey.reason}`) - } - - unexpectedKey = DockZoneModel.findUnexpectedKey(layout, DockZoneModel.savedLayoutKeys, 'savedLayout') || - DockZoneModel.findUnexpectedDockZoneKey(layout.dockZone, 'savedLayout.dockZone'); - - if (unexpectedKey) { - errors.push(`saved layout contains unexpected field "${unexpectedKey.key}" at ${unexpectedKey.path}: ${unexpectedKey.reason}`) - } - - let nonJson = DockZoneModel.findNonJsonValue(layout); - - if (nonJson) { - errors.push(`saved layout ${nonJson.path} is not JSON-only: ${nonJson.reason}`) - } - - return errors.length ? {layout: null, errors} : {layout: DockZoneModel.clone(layout), errors: []} - } - - /** - * @summary Restores a saved-layout wrapper into a validated dock-zone document. - * - * The wrapper and dock-zone tree must match the finite persisted schema. The explicit `metadata` - * and item `blueprint` fields are opaque JSON-only non-secret payloads; runtime fields beside the - * known model are rejected rather than filtered or repaired. - * @param {Object} savedLayout - * @returns {{document:(Object|null), errors:String[]}} - * @static - */ - static restoreSavedLayout(savedLayout) { - let errors = []; - - if (!DockZoneModel.isJsonRecord(savedLayout)) { - return {document: null, errors: ['saved layout must be a JSON object']} - } - - savedLayout = DockZoneModel.migrateSavedLayout(savedLayout); - - if (savedLayout.schema !== DockZoneModel.LAYOUT_SCHEMA) { - errors.push(`schema must be ${DockZoneModel.LAYOUT_SCHEMA}`) - } - - errors.push(...DockZoneModel.validatePerspectiveFields(savedLayout)); - - if (typeof savedLayout.layoutId !== 'string' || !savedLayout.layoutId.trim()) { - errors.push('layoutId must be a non-empty string') - } - - if (typeof savedLayout.title !== 'string' || !savedLayout.title.trim()) { - errors.push('title must be a non-empty string') - } - - if (!DockZoneModel.isJsonRecord(savedLayout.dockZone)) { - errors.push('dockZone must be a JSON object') - } - - if (Object.hasOwn(savedLayout, 'metadata') && !DockZoneModel.isJsonRecord(savedLayout.metadata)) { - errors.push('metadata must be a JSON object') - } - - let secretKey = Object.hasOwn(savedLayout, 'metadata') - ? DockZoneModel.findSecretMetadataKey(savedLayout.metadata, 'savedLayout.metadata') - : null; - - if (secretKey) { - errors.push(`saved layout metadata contains secret-like field "${secretKey.key}" at ${secretKey.path}: ${secretKey.reason}`) - } - - let unexpectedKey = DockZoneModel.findUnexpectedKey(savedLayout, DockZoneModel.savedLayoutKeys, 'savedLayout') || - DockZoneModel.findUnexpectedDockZoneKey(savedLayout.dockZone, 'savedLayout.dockZone'); - - if (unexpectedKey) { - errors.push(`saved layout contains unexpected field "${unexpectedKey.key}" at ${unexpectedKey.path}: ${unexpectedKey.reason}`) - } - - let nonJson = DockZoneModel.findNonJsonValue(savedLayout); - - if (nonJson) { - errors.push(`saved layout ${nonJson.path} is not JSON-only: ${nonJson.reason}`) - } - - if (!errors.length) { - errors.push(...DockZoneModel.validate(savedLayout.dockZone)); - } - - if (errors.length) { - return {document: null, errors} - } - - let normalized = DockZoneModel.normalizeTree(savedLayout.dockZone), - normalizedErrors = DockZoneModel.validate(normalized); - - return normalizedErrors.length - ? {document: null, errors: normalizedErrors} - : {document: DockZoneModel.clone(normalized), errors: []} - } - - /** - * @summary Validates a named saved-layout collection and each contained saved-layout wrapper. - * @param {Object} collection - * @returns {String[]} the (possibly empty) list of invariant violations - * @static - */ - static validateSavedLayoutCollection(collection) { - let errors = []; - - if (!DockZoneModel.isJsonRecord(collection)) { - return ['saved layout collection must be a JSON object'] - } - - if (collection.schema !== DockZoneModel.LAYOUT_COLLECTION_SCHEMA) { - errors.push(`schema must be ${DockZoneModel.LAYOUT_COLLECTION_SCHEMA}`) - } - - if (!Object.hasOwn(collection, 'activeLayoutId')) { - errors.push('activeLayoutId is required') - } else if (collection.activeLayoutId !== null && (typeof collection.activeLayoutId !== 'string' || !collection.activeLayoutId.trim())) { - errors.push('activeLayoutId must be a non-empty string or null') - } - - if (!DockZoneModel.isJsonRecord(collection.layouts)) { - errors.push('layouts must be a JSON object') - } - - if (Object.hasOwn(collection, 'metadata') && !DockZoneModel.isJsonRecord(collection.metadata)) { - errors.push('metadata must be a JSON object') - } - - let secretKey = Object.hasOwn(collection, 'metadata') - ? DockZoneModel.findSecretMetadataKey(collection.metadata, 'layoutCollection.metadata') - : null; - - if (secretKey) { - errors.push(`layout collection metadata contains secret-like field "${secretKey.key}" at ${secretKey.path}: ${secretKey.reason}`) - } - - let unexpectedKey = DockZoneModel.findUnexpectedKey(collection, DockZoneModel.savedLayoutCollectionKeys, 'layoutCollection'); - - if (unexpectedKey) { - errors.push(`layout collection contains unexpected field "${unexpectedKey.key}" at ${unexpectedKey.path}: ${unexpectedKey.reason}`) - } - - let nonJson = DockZoneModel.findNonJsonValue(collection, 'layoutCollection'); - - if (nonJson) { - errors.push(`layout collection ${nonJson.path} is not JSON-only: ${nonJson.reason}`) - } - - if (DockZoneModel.isJsonRecord(collection.layouts)) { - for (const [layoutId, savedLayout] of Object.entries(collection.layouts)) { - if (!layoutId.trim()) { - errors.push('layout keys must be non-empty strings'); - continue - } - - if (!DockZoneModel.isJsonRecord(savedLayout)) { - errors.push(`layout "${layoutId}" must be a JSON object`); - continue - } - - if (savedLayout.layoutId !== layoutId) { - errors.push(`layout key "${layoutId}" must match saved layout id "${savedLayout.layoutId}"`) - } - - let restored = DockZoneModel.restoreSavedLayout(savedLayout); - - if (restored.errors.length) { - errors.push(...restored.errors.map(error => `layout "${layoutId}": ${error}`)) - } - } - } - - let layoutCount = DockZoneModel.isJsonRecord(collection.layouts) ? Object.keys(collection.layouts).length : 0; - - if (collection.activeLayoutId === null && layoutCount > 0) { - errors.push('activeLayoutId must name an existing layout when layouts are present') - } else if (typeof collection.activeLayoutId === 'string' && DockZoneModel.isJsonRecord(collection.layouts) && !Object.hasOwn(collection.layouts, collection.activeLayoutId)) { - errors.push(`activeLayoutId "${collection.activeLayoutId}" does not exist`) - } - - return errors - } - - /** - * @summary Creates a storage-free collection of named saved-layout wrappers. - * @param {Array|Object} [layouts=[]] - * @param {Object} [options={}] {activeLayoutId, metadata, revision} - * @returns {{collection:(Object|null), errors:String[]}} - * @static - */ - static createSavedLayoutCollection(layouts=[], options={}) { - if (!Array.isArray(layouts) && !DockZoneModel.isJsonRecord(layouts)) { - return {collection: null, errors: ['layouts must be an array or JSON object']} - } - - if (!DockZoneModel.isJsonRecord(options)) { - return {collection: null, errors: ['options must be a JSON object']} - } - - let collection = { - schema : DockZoneModel.LAYOUT_COLLECTION_SCHEMA, - activeLayoutId: Object.hasOwn(options, 'activeLayoutId') ? options.activeLayoutId : null, - layouts : {}, - metadata : Object.hasOwn(options, 'metadata') ? options.metadata : {} - }, - entries = Array.isArray(layouts) - ? layouts.map((layout, index) => [DockZoneModel.isJsonRecord(layout) ? layout.layoutId : `index-${index}`, layout]) - : Object.entries(layouts), - errors = []; - - for (const [layoutId, savedLayout] of entries) { - if (typeof layoutId !== 'string' || !layoutId.trim()) { - errors.push('layoutId must be a non-empty string'); - continue - } - - collection.layouts[layoutId] = DockZoneModel.clone(savedLayout) - } - - if (!Object.hasOwn(options, 'activeLayoutId')) { - collection.activeLayoutId = Object.keys(collection.layouts)[0] ?? null - } - - if (Object.hasOwn(options, 'revision')) { - collection.revision = options.revision - } - - errors.push(...DockZoneModel.validateSavedLayoutCollection(collection)); - - return errors.length - ? {collection: null, errors} - : {collection: DockZoneModel.clone(collection), errors: []} - } - - /** - * @summary Adds or replaces a saved-layout wrapper in a collection. - * @param {Object} collection - * @param {Object} savedLayout - * @param {Object} [options={}] {activate} - * @returns {{collection:Object, errors:String[]}} - * @static - */ - static upsertSavedLayout(collection, savedLayout, options={}) { - let errors = DockZoneModel.validateSavedLayoutCollection(collection); - - if (errors.length) { - return {collection, errors} - } - - let restored = DockZoneModel.restoreSavedLayout(savedLayout); - - if (restored.errors.length) { - return {collection, errors: restored.errors} - } - - let doc = DockZoneModel.clone(collection), - layoutId = savedLayout.layoutId; - - doc.layouts[layoutId] = DockZoneModel.clone(savedLayout); - - if (options?.activate === true || doc.activeLayoutId === null) { - doc.activeLayoutId = layoutId - } - - errors = DockZoneModel.validateSavedLayoutCollection(doc); - - return errors.length ? {collection, errors} : {collection: DockZoneModel.clone(doc), errors: []} - } - - /** - * @summary Selects the active saved layout by id without restoring it. - * @param {Object} collection - * @param {String} layoutId - * @returns {{collection:Object, errors:String[]}} - * @static - */ - static selectSavedLayout(collection, layoutId) { - let errors = DockZoneModel.validateSavedLayoutCollection(collection); - - if (errors.length) { - return {collection, errors} - } - - if (typeof layoutId !== 'string' || !layoutId.trim()) { - return {collection, errors: ['layoutId must be a non-empty string']} - } - - if (!Object.hasOwn(collection.layouts, layoutId)) { - return {collection, errors: [`layoutId "${layoutId}" does not exist`]} - } - - let doc = DockZoneModel.clone(collection); - - doc.activeLayoutId = layoutId; - - return {collection: DockZoneModel.clone(doc), errors: []} - } - - /** - * @summary Removes a saved layout and requires an explicit replacement when removing the active one. - * @param {Object} collection - * @param {Object} args {layoutId, replacementLayoutId} - * @returns {{collection:Object, errors:String[]}} - * @static - */ - static removeSavedLayout(collection, {layoutId, replacementLayoutId} = {}) { - let errors = DockZoneModel.validateSavedLayoutCollection(collection); - - if (errors.length) { - return {collection, errors} - } - - if (typeof layoutId !== 'string' || !layoutId.trim()) { - return {collection, errors: ['layoutId must be a non-empty string']} - } - - if (!Object.hasOwn(collection.layouts, layoutId)) { - return {collection, errors: [`layoutId "${layoutId}" does not exist`]} - } - - let removingActive = collection.activeLayoutId === layoutId; - - if (removingActive) { - if (typeof replacementLayoutId !== 'string' || !replacementLayoutId.trim()) { - return {collection, errors: ['removing the active layout requires replacementLayoutId']} - } - - if (replacementLayoutId === layoutId) { - return {collection, errors: ['replacementLayoutId must differ from the removed layoutId']} - } - - if (!Object.hasOwn(collection.layouts, replacementLayoutId)) { - return {collection, errors: [`replacementLayoutId "${replacementLayoutId}" does not exist`]} - } - } - - let doc = DockZoneModel.clone(collection); - - delete doc.layouts[layoutId]; - - if (removingActive) { - doc.activeLayoutId = replacementLayoutId - } - - errors = DockZoneModel.validateSavedLayoutCollection(doc); - - return errors.length ? {collection, errors} : {collection: DockZoneModel.clone(doc), errors: []} - } - - /** - * @summary Restores the active saved-layout wrapper from a named layout collection. - * @param {Object} collection - * @returns {{document:(Object|null), errors:String[]}} - * @static - */ - static restoreActiveSavedLayout(collection) { - let errors = DockZoneModel.validateSavedLayoutCollection(collection); - - if (errors.length) { - return {document: null, errors} - } - - if (typeof collection.activeLayoutId !== 'string' || !Object.hasOwn(collection.layouts, collection.activeLayoutId)) { - return {document: null, errors: ['activeLayoutId must name an existing layout']} - } - - return DockZoneModel.restoreSavedLayout(collection.layouts[collection.activeLayoutId]) - } - - /** - * @summary Inserts `itemId` into the target tabs node at `index` (relocating it if already in - * the tree) and makes it the active tab. - * @param {Object} document - * @param {Object} args {itemId, tabsNodeId, index} - * @returns {{document:Object, errors:String[]}} - * @static - */ - static addTab(document, {itemId, tabsNodeId, index} = {}) { - if (!document.items?.[itemId]) return {document, errors: [`unknown item "${itemId}"`]}; - if (document.nodes?.[tabsNodeId]?.type !== 'tabs') return {document, errors: [`"${tabsNodeId}" is not a tabs node`]}; - - let doc = DockZoneModel.clone(document); - - DockZoneModel.detachFromTabs(doc, itemId); - - let node = doc.nodes[tabsNodeId], - at = Number.isInteger(index) ? Math.max(0, Math.min(index, node.items.length)) : node.items.length; - - node.items.splice(at, 0, itemId); - node.activeItemId = itemId; - - return DockZoneModel.commit(document, doc) - } - - /** - * @summary Relocates an in-tree `itemId` into the target tabs node at `index`. - * @param {Object} document - * @param {Object} args {itemId, targetNodeId, index} - * @returns {{document:Object, errors:String[]}} - * @static - */ - static moveItem(document, {itemId, targetNodeId, index} = {}) { - if (!DockZoneModel.findContainingTabsId(document, itemId)) { - return {document, errors: [`item "${itemId}" is not in the tree`]} - } - - return DockZoneModel.addTab(document, {itemId, tabsNodeId: targetNodeId, index}) - } - - /** - * @summary Splits `targetNodeId` against a new pane holding `itemId`. - * - * Wraps `itemId` in a fresh single-tab node and replaces `targetNodeId` in its parent with a new - * `split` whose children are `[new, target]` (leading) or `[target, new]` (trailing). When - * `targetNodeId` is the root, the new split becomes the root. - * - * The leading/trailing side comes from an explicit `position` (`before` / `after`) when given; - * otherwise it is derived from the descriptor's `edge` — `top` / `left` lead (before), `bottom` / - * `right` trail (after) — so a `DockPreview.previewToOperation()` edge descriptor places correctly. - * @param {Object} document - * @param {Object} args {itemId, targetNodeId, orientation, position, sizes, edge} - * @returns {{document:Object, errors:String[]}} - * @static - */ - static splitNode(document, {edge, itemId, orientation, position, sizes, targetNodeId} = {}) { - if (!document.items?.[itemId]) return {document, errors: [`unknown item "${itemId}"`]}; - if (!document.nodes?.[targetNodeId]) return {document, errors: [`unknown target node "${targetNodeId}"`]}; - if (orientation !== 'horizontal' && orientation !== 'vertical') { - return {document, errors: [`invalid split orientation "${orientation}"`]} - } - - let doc = DockZoneModel.clone(document); - - DockZoneModel.detachFromTabs(doc, itemId); - - let newTabsId = DockZoneModel.genId(doc, `tabs-${itemId}`), - newSplitId = DockZoneModel.genId(doc, `split-${targetNodeId}`), - ratio = (Array.isArray(sizes) && sizes.length === 2) ? sizes : [0.5, 0.5], - // Edge descriptors encode the side in `edge`, not `position`: top / left lead (before), - // bottom / right trail (after). An explicit `position` always wins. - atPosition = position || ((edge === 'top' || edge === 'left') ? 'before' : 'after'); - - // Resolve the target's parent BEFORE inserting the new split — otherwise the new split - // (which references the target) would be found as the target's own parent. - let parentSlot = DockZoneModel.findParentSlot(doc, targetNodeId); - - doc.nodes[newTabsId] = {type: 'tabs', items: [itemId], activeItemId: itemId}; - doc.nodes[newSplitId] = { - type : 'split', - orientation, - children: atPosition === 'before' ? [newTabsId, targetNodeId] : [targetNodeId, newTabsId], - // `sizes` maps positionally to `children` in their final order; the caller - // (DockPreview.previewToOperation) supplies them already in that order. - sizes : ratio - }; - - if (!parentSlot) { - doc.root = newSplitId - } else if (typeof parentSlot.slot === 'number') { - doc.nodes[parentSlot.parentId].children[parentSlot.slot] = newSplitId - } else { - doc.nodes[parentSlot.parentId].zones[parentSlot.slot] = newSplitId - } - - return DockZoneModel.commit(document, doc) - } - - /** - * @summary Updates an existing split node's normalized child sizes. - * - * Resizable splitter affordances can pass pixel-derived or ratio-derived positive values. This - * operation normalizes them to the persisted dock-zone ratio contract and commits through the - * same fail-closed path as the rest of the semantic model. - * @param {Object} document - * @param {Object} args {splitNodeId, sizes} - * @returns {{document:Object, errors:String[]}} - * @static - */ - static resizeSplit(document, {splitNodeId, sizes} = {}) { - let split = document.nodes?.[splitNodeId]; - - if (!split) { - return {document, errors: [`unknown split node "${splitNodeId}"`]} - } - - if (split.type !== 'split') { - return {document, errors: [`"${splitNodeId}" is not a split node`]} - } - - let normalized = DockZoneModel.normalizeSplitSizes(sizes, (split.children || []).length, splitNodeId); - - if (normalized.errors.length) { - return {document, errors: normalized.errors} - } - - let doc = DockZoneModel.clone(document); - - doc.nodes[splitNodeId].sizes = normalized.sizes; - - return DockZoneModel.commit(document, doc) - } - - /** - * @summary Removes `itemId` from the tree but preserves its catalog record (for popup/window - * ownership), per the contract's `detachItem`. - * @param {Object} document - * @param {Object} args {itemId} - * @returns {{document:Object, errors:String[]}} - * @static - */ - static detachItem(document, {itemId} = {}) { - if (!DockZoneModel.findContainingTabsId(document, itemId)) { - return {document, errors: [`item "${itemId}" is not in the tree`]} - } - - let doc = DockZoneModel.clone(document); - - DockZoneModel.detachFromTabs(doc, itemId); - - return DockZoneModel.commit(document, doc) - } - - /** - * @summary Removes a closeable `itemId` from the tree and catalog. When the item was active, - * activates the item at its former index or the preceding item; closing a non-active item - * preserves the surviving activation. An explicit `closable:false` fails closed. - * @param {Object} document - * @param {Object} args {itemId} - * @returns {{document:Object, errors:String[]}} - * @static - */ - static closeItem(document, {itemId} = {}) { - let item = document.items?.[itemId]; - - if (!item) return {document, errors: [`unknown item "${itemId}"`]}; - if (item.closable === false) return {document, errors: [`item "${itemId}" is not closable`]}; - - let tabsNodeId = DockZoneModel.findContainingTabsId(document, itemId), - closedIndex = tabsNodeId ? document.nodes[tabsNodeId].items.indexOf(itemId) : -1, - wasActive = tabsNodeId ? document.nodes[tabsNodeId].activeItemId === itemId : false, - doc = DockZoneModel.clone(document); - - DockZoneModel.detachFromTabs(doc, itemId); - - if (wasActive && tabsNodeId && doc.nodes[tabsNodeId]?.type === 'tabs') { - let node = doc.nodes[tabsNodeId]; - - // When the closed item owned activation, the item now occupying its slot wins; - // closing the last item falls back to its preceding sibling. A surviving active item - // is left untouched. This is semantic model policy, not a projected-index guess. - node.activeItemId = node.items[Math.min(closedIndex, node.items.length - 1)] ?? null - } - - delete doc.items[itemId]; - - return DockZoneModel.commit(document, doc) - } - - /** - * @summary Updates an item's persisted pin state when its policy permits pinning. - * @param {Object} document - * @param {Object} args {itemId, pinned} - * @returns {{document:Object, errors:String[]}} - * @static - */ - static setItemPinned(document, {itemId, pinned} = {}) { - let item = document.items?.[itemId]; - - if (!item) return {document, errors: [`unknown item "${itemId}"`]}; - if (typeof pinned !== 'boolean') return {document, errors: ['pinned must be a boolean']}; - if (item.pinnable === false) return {document, errors: [`item "${itemId}" is not pinnable`]}; - - let doc = DockZoneModel.clone(document); - - doc.items[itemId].pinned = pinned; - - if (pinned) { - doc.items[itemId].autoHidden = false - } - - return DockZoneModel.commit(document, doc) - } - - /** - * @summary Updates an item's persisted auto-hide/collapsed state when its policy permits it. - * @param {Object} document - * @param {Object} args {itemId, autoHidden} - * @returns {{document:Object, errors:String[]}} - * @static - */ - static setItemAutoHidden(document, {itemId, autoHidden} = {}) { - let item = document.items?.[itemId]; - - if (!item) return {document, errors: [`unknown item "${itemId}"`]}; - if (typeof autoHidden !== 'boolean') return {document, errors: ['autoHidden must be a boolean']}; - if (item.pinnable === false) return {document, errors: [`item "${itemId}" is not pinnable`]}; - if (autoHidden && item.pinned === true) return {document, errors: [`item "${itemId}" is pinned and cannot be autoHidden`]}; - - let doc = DockZoneModel.clone(document); - - doc.items[itemId].autoHidden = autoHidden; - - return DockZoneModel.commit(document, doc) - } - - /** - * @summary Applies an operation descriptor (the shape `DockPreview.previewToOperation()` emits) - * to the document, dispatching through {@link #operationHandlers} — the table whose keys ARE - * the exported vocabulary, so dispatch and `operations` cannot diverge. - * - * A `tab-*` descriptor (`operation: 'addTab'`) is dispatched as a move when its item already - * lives in the tree — the contract's "addTab or moveItem" downgrade, carried by the table's - * `addTab` entry. - * @param {Object} document - * @param {Object} descriptor {operation, ...} - * @returns {{document:Object, errors:String[]}} - * @static - */ - static applyOperation(document, descriptor = {}) { - // Own-key lookup only: inherited names ('constructor', '__proto__', …) must reject - // exactly like any unknown operation, never resolve to a prototype member. - const handler = Object.hasOwn(DockZoneModel.operationHandlers, descriptor.operation) - ? DockZoneModel.operationHandlers[descriptor.operation] - : null; - - return handler - ? handler(document, descriptor) - : {document, errors: [`unknown operation "${descriptor.operation}"`]} - } - - /** - * @summary Atomically transfers `itemId` out of `sourceDocument` and into `targetDocument` in one - * commit-or-neither step: the item is removed from the source tree + catalog and placed into the - * target through the nested `target` placement descriptor. The item record travels verbatim — no - * re-instantiation semantics enter the executor, which operates on documents only. - * - * Fail-closed and atomic: a validation error on EITHER document returns BOTH inputs untouched plus - * a non-empty `errors` array, so a half-transferred item — removed here but not placed there, the - * contract's named violation — can never commit. The nested `target` is dispatched through the - * landed single-document placement path (`addTab` / `splitNode` via {@link #applyOperation}), so - * no second placement grammar is introduced. - * - * The executor is document-centric: `sourceWorkspaceId` / `targetWorkspaceId` are the caller's - * (adapter-tier) resolution keys, used here only to reject a same-workspace transfer — that is a - * `moveItem`, not a transfer. - * @param {Object} sourceDocument the committed dock-zone document the item leaves - * @param {Object} targetDocument the committed dock-zone document the item joins - * @param {Object} descriptor {itemId, sourceWorkspaceId, targetWorkspaceId, target} - * @returns {{sourceDocument:Object, targetDocument:Object, errors:String[]}} - * @static - */ - static transferItem(sourceDocument, targetDocument, {itemId, sourceWorkspaceId, targetWorkspaceId, target} = {}) { - let fail = errors => ({sourceDocument, targetDocument, errors}), - record = sourceDocument?.items?.[itemId]; - - // Preconditions checked against BOTH documents before any mutation (fail-closed). - if (!record) return fail([`unknown item "${itemId}"`]); - if (record.movable === false) return fail([`item "${itemId}" is not movable`]); - if (targetDocument?.items?.[itemId]) return fail([`item "${itemId}" already exists in the target document`]); - if (sourceWorkspaceId !== undefined && sourceWorkspaceId === targetWorkspaceId) { - return fail(['transferItem requires distinct source and target workspaces']) - } - if (!target || (target.operation !== 'addTab' && target.operation !== 'splitNode')) { - return fail(['transferItem target must be an addTab or splitNode descriptor']) - } - - // Source side: drop from the tree (a no-op for an already-detached item) + catalog, then - // normalize + validate through the shared fail-closed commit. - let sourceWorking = DockZoneModel.clone(sourceDocument); - - DockZoneModel.detachFromTabs(sourceWorking, itemId); - delete sourceWorking.items[itemId]; - - let sourceResult = DockZoneModel.commit(sourceDocument, sourceWorking); - - // Target side: insert the verbatim record into the catalog, then place it through the landed - // single-document dispatch (which normalizes + validates the target tree). The transfer's - // `itemId` overrides any id the caller left in the nested descriptor. - let targetWorking = DockZoneModel.clone(targetDocument); - - targetWorking.items[itemId] = DockZoneModel.clone(record); - - let targetResult = DockZoneModel.applyOperation(targetWorking, {...target, itemId}), - errors = [...sourceResult.errors, ...targetResult.errors]; - - // Commit-or-neither: any error on either side rolls the whole transfer back to both inputs. - if (errors.length) { - return fail(errors) - } - - return {sourceDocument: sourceResult.document, targetDocument: targetResult.document, errors: []} - } - - /** - * @summary Re-parents the subtree rooted at `nodeId` to `targetNodeId` within one document — the - * grouped-drag move. The dock tree already models a group as a `tabs` node, so grouped drag moves - * a NODE, not N items. A `{kind: 'tab-into'}` placement merges the moved tabs node's items into the - * target tabs node in order; otherwise a split placement (`{orientation, position|edge, sizes}`) - * wraps the target + the subtree in a new split. `normalizeTree` restores invariants (collapsing - * the emptied source slot) afterward. - * - * Fail-closed: unknown node/target, moving the root, moving a node onto itself, an invalid - * placement, or moving a node into its OWN subtree (the cycle guard, via the reachable-set walk - * rooted at `nodeId`) all return the document untouched + errors. - * @param {Object} document - * @param {Object} args {nodeId, targetNodeId, placement} - * @returns {{document:Object, errors:String[]}} - * @static - */ - static moveNode(document, {nodeId, targetNodeId, placement = {}} = {}) { - let nodes = document?.nodes || {}; - - if (!nodes[nodeId]) return {document, errors: [`unknown node "${nodeId}"`]}; - if (!nodes[targetNodeId]) return {document, errors: [`unknown target node "${targetNodeId}"`]}; - if (nodeId === targetNodeId) return {document, errors: [`cannot move node "${nodeId}" onto itself`]}; - if (nodeId === document.root) return {document, errors: ['cannot move the root node']}; - - // cycle guard: the target must not live inside the moved subtree (walk rooted AT nodeId) - if (DockZoneModel.reachableNodeIds({nodes, root: nodeId}).has(targetNodeId)) { - return {document, errors: [`cannot move node "${nodeId}" into its own subtree`]} - } - - let doc = DockZoneModel.clone(document); - - DockZoneModel.detachNode(doc, nodeId); - - let errors = DockZoneModel.attachNode(doc, nodeId, targetNodeId, placement); - - return errors.length ? {document, errors} : DockZoneModel.commit(document, doc) - } - - /** - * @summary Resolves a workspace document's transferable STACK ROOT — the explicit source-side - * projection for whole-stack reintegration (docking design record §2.8, - * `learn/agentos/decisions/0029-docking-design.md`). - * - * The canonical vessel document shape is an `edge-zone` ROOT (window chrome) whose `center` - * zone names the subtree holding the vessel's content — so "the whole stack" is the root's - * center child, never the document root itself. Resolving it keeps `transferNode`'s root - * rejection byte-identical: whole-stack transfer is explicit resolution composed with the - * landed two-document executor, and an implicit root transfer stays impossible. - * - * Fail-closed: a missing document, a missing root node, a root that is not an `edge-zone`, - * or a center zone that is absent or names an unknown node all resolve `null` — a document - * that cannot prove its stack root never transfers. - * @param {Object} document a committed dock-zone document - * @returns {String|null} the stack-root node id, or null - * @static - */ - static resolveStackRoot(document) { - let root = document?.nodes?.[document?.root], - centerId; - - if (!root || root.type !== 'edge-zone') { - return null - } - - centerId = root.zones?.center; - - return centerId && document.nodes[centerId] ? centerId : null - } - - /** - * @summary Atomically transfers the subtree rooted at `nodeId` out of `sourceDocument` and into - * `targetDocument` in one commit-or-neither step — the cross-window grouped-drag transfer. It is - * the two-document sibling of `moveNode`: `transferItem` atomicity applied to a whole subtree. The - * subtree's nodes and all its member item records travel verbatim, and it re-homes at - * `target.targetNodeId` per `target.placement` (the `moveNode` attach grammar). Reuses the landed - * atomic path — no second atomicity implementation. - * - * Fail-closed and atomic: any error on either document returns BOTH inputs untouched + a non-empty - * `errors` array. A node-id or member-item-id already present in the target, an unmovable member, - * the root node, a same-workspace transfer, or a placement failure all reject with nothing committed. - * @param {Object} sourceDocument the committed dock-zone document the subtree leaves - * @param {Object} targetDocument the committed dock-zone document the subtree joins - * @param {Object} descriptor {nodeId, sourceWorkspaceId, targetWorkspaceId, target:{targetNodeId, placement}} - * @returns {{sourceDocument:Object, targetDocument:Object, errors:String[]}} - * @static - */ - static transferNode(sourceDocument, targetDocument, {nodeId, sourceWorkspaceId, targetWorkspaceId, target} = {}) { - let fail = errors => ({sourceDocument, targetDocument, errors}), - sourceNodes = sourceDocument?.nodes || {}; - - if (!sourceNodes[nodeId]) return fail([`unknown node "${nodeId}"`]); - if (nodeId === sourceDocument.root) return fail(['cannot transfer the root node']); - if (sourceWorkspaceId !== undefined && sourceWorkspaceId === targetWorkspaceId) { - return fail(['transferNode requires distinct source and target workspaces']) - } - if (!target || !targetDocument?.nodes?.[target.targetNodeId]) { - return fail(['transferNode target must name an existing target node']) - } - - // The subtree: its node ids + the member item ids its tabs nodes carry. - let subtreeNodeIds = DockZoneModel.reachableNodeIds({nodes: sourceNodes, root: nodeId}), - memberItemIds = []; - - subtreeNodeIds.forEach(id => { - if (sourceNodes[id].type === 'tabs') memberItemIds.push(...(sourceNodes[id].items || [])) - }); - - // Preconditions across BOTH documents before any mutation: no node-id or member-id may already - // exist in the target, and every member must be movable. - for (const id of subtreeNodeIds) { - if (targetDocument.nodes?.[id]) return fail([`node "${id}" already exists in the target document`]) - } - for (const itemId of memberItemIds) { - if (sourceDocument.items?.[itemId]?.movable === false) return fail([`item "${itemId}" is not movable`]); - if (targetDocument.items?.[itemId]) return fail([`item "${itemId}" already exists in the target document`]) - } - - // Source side: unlink the subtree, drop its nodes + member records, normalize + validate. - let sourceWorking = DockZoneModel.clone(sourceDocument); - - DockZoneModel.detachNode(sourceWorking, nodeId); - subtreeNodeIds.forEach(id => delete sourceWorking.nodes[id]); - memberItemIds.forEach(itemId => delete sourceWorking.items[itemId]); - - let sourceResult = DockZoneModel.commit(sourceDocument, sourceWorking); - - // Target side: graft the member records + subtree nodes verbatim, then attach the subtree root - // through the shared moveNode placement grammar; normalize + validate. - let targetWorking = DockZoneModel.clone(targetDocument); - - memberItemIds.forEach(itemId => targetWorking.items[itemId] = DockZoneModel.clone(sourceDocument.items[itemId])); - subtreeNodeIds.forEach(id => targetWorking.nodes[id] = DockZoneModel.clone(sourceDocument.nodes[id])); - - let attachErrors = DockZoneModel.attachNode(targetWorking, nodeId, target.targetNodeId, target.placement || {}), - targetResult = attachErrors.length - ? {document: targetDocument, errors: attachErrors} - : DockZoneModel.commit(targetDocument, targetWorking), - errors = [...sourceResult.errors, ...targetResult.errors]; - - // Commit-or-neither: any error on either side rolls the whole transfer back to both inputs. - if (errors.length) { - return fail(errors) - } - - return {sourceDocument: sourceResult.document, targetDocument: targetResult.document, errors: []} - } -} - -export default Neo.setupClass(DockZoneModel); diff --git a/src/dashboard/DockWorkspace.mjs b/src/dashboard/dock/Workspace.mjs similarity index 95% rename from src/dashboard/DockWorkspace.mjs rename to src/dashboard/dock/Workspace.mjs index 3ceca5e30a..deae0283f3 100644 --- a/src/dashboard/DockWorkspace.mjs +++ b/src/dashboard/dock/Workspace.mjs @@ -1,12 +1,13 @@ -import Component from '../component/Base.mjs'; -import Container from '../container/Base.mjs'; -import DockLayoutAdapter from './DockLayoutAdapter.mjs'; -import DockMotionSignal from './DockMotionSignal.mjs'; -import DockPreviewProducer from './DockPreviewProducer.mjs'; -import DockProjectionReconciler from './DockProjectionReconciler.mjs'; -import {createDockTearOutHandlers} from './DockTearOut.mjs'; -import DockZoneModel from './DockZoneModel.mjs'; -import {previewToOperation} from './dockPreviewContract.mjs'; +import Component from '../../component/Base.mjs'; +import Container from '../../container/Base.mjs'; +import LayoutAdapter from './projection/LayoutAdapter.mjs'; +import MotionSignal from './projection/MotionSignal.mjs'; +import PreviewProducer from './interaction/PreviewProducer.mjs'; +import Reconciler from './projection/Reconciler.mjs'; +import {createDockTearOutHandlers} from './window/TearOut.mjs'; +import Document from './model/Document.mjs'; +import Operations from './model/Operations.mjs'; +import {previewToOperation} from './model/PreviewContract.mjs'; /** * @summary The engine-owned dock workspace host: the reducer-container that owns one committed @@ -16,9 +17,9 @@ import {previewToOperation} from './dockPreviewContract.mjs'; * Every docking workspace needs the same loop: a pure reducer over the committed document * ({@link #applyDockZoneOperation}), a view-sync that stores the next document and re-projects it * ({@link #onDockZoneDocumentChange}), a projection of the document into ordinary container configs - * ({@link #projectDockModel} over {@link Neo.dashboard.DockLayoutAdapter}), and a reconciliation + * ({@link #projectDockModel} over {@link Neo.dashboard.dock.projection.LayoutAdapter}), and a reconciliation * that hands the surviving live panes into the next projection instead of recreating them - * ({@link #refreshDockWorkspace} over {@link Neo.dashboard.DockProjectionReconciler}, bracketed by + * ({@link #refreshDockWorkspace} over {@link Neo.dashboard.dock.projection.Reconciler}, bracketed by * the FLIP motion signal). Before this class, each consumer wrote that loop by hand; this class owns * it once, and a consumer contributes only what is genuinely its own through template hooks: * @@ -33,7 +34,7 @@ import {previewToOperation} from './dockPreviewContract.mjs'; * - {@link #getRefreshOptions} — the reconciler's geometry-only / retained-topology fast paths. * * With {@link #enableDockTearOutLifecycle}, the workspace also composes - * {@link Neo.dashboard.DockTearOut} and owns the cross-window half of the same truth: exact + * {@link Neo.dashboard.dock.window.TearOut} and owns the cross-window half of the same truth: exact * gesture admission, pre-terminal versus committed connection state, placement capture before * `detachItem`, same-instance semantic return after physical disconnect, and exact-once teardown. * Host/flow/admission-token checks run before the optional {@link #admitTearOutConnection} policy @@ -45,7 +46,7 @@ import {previewToOperation} from './dockPreviewContract.mjs'; * The class satisfies the dock-holder contract Neural Link tooling resolves against * (`getDockZoneDocument()` / `applyDockZoneOperation()` / `onDockZoneDocumentChange()`, see * `src/ai/client/DockService.mjs`) and the `owner` duck-type of - * {@link Neo.dashboard.DockDragAffordances} (`dockModel` plus the reducer and the view-sync), so an + * {@link Neo.dashboard.dock.interaction.DragAffordances} (`dockModel` plus the reducer and the view-sync), so an * agent, a splitter, a rail, a drag gesture and a tour runner all commit through one path. * * Two invariants every method here protects: the committed document advances ONLY inside @@ -71,21 +72,21 @@ import {previewToOperation} from './dockPreviewContract.mjs'; * as an additional theme dependency; a subclass that declares its own `additionalThemeFiles` * replaces the list and must keep that entry. * - * @class Neo.dashboard.DockWorkspace + * @class Neo.dashboard.dock.Workspace * @extends Neo.container.Base - * @see Neo.dashboard.DockLayoutAdapter - * @see Neo.dashboard.DockProjectionReconciler - * @see Neo.dashboard.DockZoneModel + * @see Neo.dashboard.dock.projection.LayoutAdapter + * @see Neo.dashboard.dock.projection.Reconciler + * @see Neo.dashboard.dock.model.Document * @see learn/agentos/DockZoneModel.md * @see learn/guides/uibuildingblocks/DockLayouts.md */ -class DockWorkspace extends Container { +class Workspace extends Container { static config = { /** - * @member {String} className='Neo.dashboard.DockWorkspace' + * @member {String} className='Neo.dashboard.dock.Workspace' * @protected */ - className: 'Neo.dashboard.DockWorkspace', + className: 'Neo.dashboard.dock.Workspace', /** * @member {String} ntype='dock-workspace' * @protected @@ -165,9 +166,9 @@ class DockWorkspace extends Container { /** * The placement producer behind the in-window cross-zone drop path. Created here, destroyed - * here; a consumer composing {@link Neo.dashboard.DockDragAffordances} routes its drop seam + * here; a consumer composing {@link Neo.dashboard.dock.interaction.DragAffordances} routes its drop seam * through that controller's own producer instead (see {@link #getDockProjectionOptions}). - * @member {Neo.dashboard.DockPreviewProducer|null} dockPreviewProducer=null + * @member {Neo.dashboard.dock.interaction.PreviewProducer|null} dockPreviewProducer=null * @protected */ dockPreviewProducer = null @@ -208,7 +209,7 @@ class DockWorkspace extends Container { tearOutConnects = {} /** - * The four gesture callbacks produced by {@link Neo.dashboard.DockTearOut} for this workspace. + * The four gesture callbacks produced by {@link Neo.dashboard.dock.window.TearOut} for this workspace. * @member {Object|null} tearOutHandlers=null * @protected */ @@ -264,7 +265,7 @@ class DockWorkspace extends Container { this.vdom.tabIndex = -1 } - this.dockPreviewProducer = Neo.create(DockPreviewProducer) + this.dockPreviewProducer = Neo.create(PreviewProducer) if (this.enableDockTearOutLifecycle) { this.tearOutHandlers = createDockTearOutHandlers({ @@ -284,14 +285,14 @@ class DockWorkspace extends Container { /** * The pure reducer of the holder contract: applies one semantic operation descriptor against - * the live committed document and returns `DockZoneModel`'s fail-closed `{document, errors}` + * the live committed document and returns `model.Operations`' fail-closed `{document, errors}` * result. Never mutates {@link #dockModel} — the view-sync {@link #onDockZoneDocumentChange} * is the only writer, called by the committing surface on success. * @param {Object} descriptor The semantic operation descriptor. * @returns {{document: Object, errors: String[]}} */ applyDockZoneOperation(descriptor) { - return DockZoneModel.applyOperation(this.dockModel, descriptor) + return Operations.applyOperation(this.dockModel, descriptor) } /** @@ -507,7 +508,7 @@ class DockWorkspace extends Container { /** * Hook: closes the consumer's platform-specific tear-out vessel. Explicit false retains retry - * authority; legacy void success remains admitted by {@link Neo.dashboard.DockTearOut}. + * authority; legacy void success remains admitted by {@link Neo.dashboard.dock.window.TearOut}. * @param {Object} vessel * @returns {Promise|Boolean|void} * @protected @@ -525,7 +526,7 @@ class DockWorkspace extends Container { applyTearOutOperation(descriptor) { let me = this, isDetach = descriptor?.operation === 'detachItem', - captured = isDetach ? DockZoneModel.captureItemPlacement(me.dockModel, descriptor.itemId) : null, + captured = isDetach ? Document.captureItemPlacement(me.dockModel, descriptor.itemId) : null, result; captured && (me.tearOutPlacements[descriptor.itemId] = captured); @@ -590,7 +591,7 @@ class DockWorkspace extends Container { if (!me.reparentTearOutPane(itemId, connection)) { me.compensateFailedTearOutAdoption(itemId, entry); - throw new Error(`DockWorkspace ${me.id}: tear-out pane "${itemId}" could not enter its admitted vessel`) + throw new Error(`Workspace ${me.id}: tear-out pane "${itemId}" could not enter its admitted vessel`) } } @@ -802,7 +803,7 @@ class DockWorkspace extends Container { if (me.tearOutPanes[itemId]) { if (!me.reparentTearOutPane(itemId, connection)) { me.compensateFailedTearOutAdoption(itemId, me.tearOutPanes[itemId]); - throw new Error(`DockWorkspace ${me.id}: tear-out pane "${itemId}" could not enter its admitted vessel`) + throw new Error(`Workspace ${me.id}: tear-out pane "${itemId}" could not enter its admitted vessel`) } me.clearTearOutAdmission(itemId, admission) @@ -869,7 +870,7 @@ class DockWorkspace extends Container { me.beforeTearOutPaneReturn({itemId, pane}); - if (DockZoneModel.findContainingTabsId(doc, itemId)) { + if (Document.findContainingTabsId(doc, itemId)) { me.onDockZoneDocumentChange(doc); try { @@ -1118,7 +1119,7 @@ class DockWorkspace extends Container { } /** - * Hook: extra options for every {@link Neo.dashboard.DockLayoutAdapter#project} call — the + * Hook: extra options for every {@link Neo.dashboard.dock.projection.LayoutAdapter#project} call — the * hover-reveal opt-in, a drag-affordance layer's `onDockCrossZoneDragMove` / * `onDockCrossZoneDragCancel` / `onDockCrossZoneDrop` seams, tear-out or conversion policy. * Returned keys override the default cross-zone drop seam; they never replace the reducer and @@ -1172,7 +1173,7 @@ class DockWorkspace extends Container { if (!projectedTabs) { let shell = this.getDockHost()?.items?.[this.dockShellIndex]; - projectedTabs = shell ? DockProjectionReconciler.collectProjectedTabs(shell) : new Map() + projectedTabs = shell ? Reconciler.collectProjectedTabs(shell) : new Map() } projectedTabs?.forEach?.(tab => this.syncDockCloseAction(tab)) @@ -1214,7 +1215,7 @@ class DockWorkspace extends Container { return {document: me.dockModel, errors: ['Dock close action requires a committed document']} } - let modelNodeId = DockZoneModel.findContainingTabsId(me.dockModel, itemId) || dockNodeId, + let modelNodeId = Document.findContainingTabsId(me.dockModel, itemId) || dockNodeId, descriptor = {operation: 'closeItem', itemId}, result = me.applyDockZoneOperation(descriptor); @@ -1326,7 +1327,7 @@ class DockWorkspace extends Container { /** * Returns the one-use projection correlation only when the committed descriptor creates a - * globally absent header. `DockZoneModel` downgrades `addTab` to `moveItem` whenever the item + * globally absent header. `model.Operations` downgrades `addTab` to `moveItem` whenever the item * already lives in any tabs node; those identity-preserving relocations use FLIP alone. A * same-node reorder, malformed descriptor, restore, and initial path fail closed to an instant * projection. @@ -1349,7 +1350,7 @@ class DockWorkspace extends Container { && typeof tabsNodeId === 'string' && Array.isArray(oldItems) && Array.isArray(newItems) - && !DockZoneModel.findContainingTabsId(this.dockModel, itemId) + && !Document.findContainingTabsId(this.dockModel, itemId) && !oldItems.includes(itemId) && newItems.includes(itemId) ? {itemId, operation: 'addTab', tabsNodeId} @@ -1358,7 +1359,7 @@ class DockWorkspace extends Container { /** * The default in-window cross-zone drop seam: a dock tab header released outside its own - * toolbar reports its release point here (via {@link Neo.dashboard.DockTabSortZone}). The + * toolbar reports its release point here (via {@link Neo.dashboard.dock.interaction.TabSortZone}). The * producer resolves the placement kind from the pointer and every other tabs zone's rect — a * tabs node's parent-split orientation lets it choose `split-*` over an edge band — * `previewToOperation` maps that `dockPreview.v1` to the semantic operation, and exactly one @@ -1466,7 +1467,7 @@ class DockWorkspace extends Container { if (!document) { config = {ntype: 'container', cls: ['neo-dashboard'], items: []} } else { - config = DockLayoutAdapter.project(document, { + config = LayoutAdapter.project(document, { onDockCrossZoneDrop: me.onDockCrossZoneDrop.bind(me), ...me.getDockProjectionOptions(), ...(me.enableDockCloseAction && { @@ -1524,7 +1525,7 @@ class DockWorkspace extends Container { {geometryOnly=false, retainTopology=false} = refreshOptions; if (!host) { - throw new Error(`DockWorkspace ${me.id}: dockHostReference "${me.dockHostReference}" resolved to no live dock host — the committed document is not rendered`) + throw new Error(`Workspace ${me.id}: dockHostReference "${me.dockHostReference}" resolved to no live dock host — the committed document is not rendered`) } try { @@ -1551,7 +1552,7 @@ class DockWorkspace extends Container { const {onProjectionStaged, retainTopology: forcedRetainTopology, waitForOverflowProjection} = me.getReconcileOptions(document, refreshOptions) || {}; - const result = await DockProjectionReconciler.reconcileProjection({ + const result = await Reconciler.reconcileProjection({ geometryOnly, host, nextConfig, @@ -1565,7 +1566,7 @@ class DockWorkspace extends Container { resolveItem : itemId => { const item = document?.items?.[itemId]; - return DockLayoutAdapter.decorateProjectedItem( + return LayoutAdapter.decorateProjectedItem( me.resolveProjectedPane(itemId, item), itemId, item, @@ -1592,7 +1593,7 @@ class DockWorkspace extends Container { if (typeof flip?.play === 'function' && !me.isDestroyed) { let rawPlayed; - DockMotionSignal.enter(me); + MotionSignal.enter(me); try { rawPlayed = flip.play({hostId: host.id, markerPrefix: flipMarkerPrefix, geometryOnly: result?.landedInPlace === true}) @@ -1601,7 +1602,7 @@ class DockWorkspace extends Container { } played = Promise.resolve(rawPlayed).catch(() => null); - played.finally(() => DockMotionSignal.leave(me)) + played.finally(() => MotionSignal.leave(me)) } if (!me.isDestroyed) { @@ -1662,4 +1663,4 @@ class DockWorkspace extends Container { } } -export default Neo.setupClass(DockWorkspace); +export default Neo.setupClass(Workspace); diff --git a/src/dashboard/DockSplitter.mjs b/src/dashboard/dock/interaction/DockSplitter.mjs similarity index 65% rename from src/dashboard/DockSplitter.mjs rename to src/dashboard/dock/interaction/DockSplitter.mjs index af937bf893..76ba3f6026 100644 --- a/src/dashboard/DockSplitter.mjs +++ b/src/dashboard/dock/interaction/DockSplitter.mjs @@ -1,25 +1,32 @@ -import Component from '../component/Base.mjs'; -import DragZone from '../draggable/DragZone.mjs'; -import DockZoneModel from './DockZoneModel.mjs'; -import NeoArray from '../util/Array.mjs'; +import Splitter from '../../../component/Splitter.mjs'; +import Operations from '../model/Operations.mjs'; +import NeoArray from '../../../util/Array.mjs'; /** - * @summary Runtime splitter affordance that converts drag completion into a `resizeSplit` operation. + * @summary Dock splitter affordance: generic Splitter mechanics, one dock-document semantic commit. * - * `Neo.component.Splitter` resizes sibling styles directly. The dock-zone model is persisted JSON, so - * this component keeps pointer geometry runtime-only and commits through `DockZoneModel.applyOperation()` - * or a supplied owning reducer callback. + * The generic parent owns every gesture mechanic — eager DragZone creation and registration, + * per-gesture refresh, proxy handling, generation fencing, Escape/cancel restoration, and + * teardown. This class adds ONLY the dock semantics on top: pointer geometry stays runtime-only, + * the terminal converts the captured adjacent-pair sizes into one `resizeSplit` descriptor, and + * the commit flows through `Operations.applyOperation()` or a supplied owning reducer callback. + * No main-thread resize registration exists here: the deferred proxy presentation is the dock + * default, and a live adjacent-pair preview is a separate feature seam consuming this class. * - * @class Neo.dashboard.DockSplitter - * @extends Neo.component.Base - * @see Neo.dashboard.DockLayoutAdapter - * @see Neo.dashboard.DockZoneModel + * The public split vocabulary stays dock-shaped: `orientation` describes the SPLIT NODE + * (`horizontal` = side-by-side children), which maps onto the generic parent's `direction` + * (the divider bar axis) as its inverse. + * + * @class Neo.dashboard.dock.interaction.DockSplitter + * @extends Neo.component.Splitter + * @see Neo.dashboard.dock.projection.LayoutAdapter + * @see Neo.dashboard.dock.model.Document * @see learn/agentos/DockZoneModel.md */ -class DockSplitter extends Component { +class DockSplitter extends Splitter { /** * @summary The `--dock-splitter-*` contract, projected onto the drag proxy by - * {@link Neo.dashboard.DockSplitter#projectProxyTokens}. + * {@link Neo.dashboard.dock.interaction.DockSplitter#projectProxyTokens}. * * The SSOT is the `.neo-dashboard` token block in `resources/scss/src/dashboard/Container.scss`; * this is a restatement, and a restatement drifts. The guard is the parity spec, which parses @@ -46,10 +53,10 @@ class DockSplitter extends Component { static config = { /** - * @member {String} className='Neo.dashboard.DockSplitter' + * @member {String} className='Neo.dashboard.dock.interaction.DockSplitter' * @protected */ - className: 'Neo.dashboard.DockSplitter', + className: 'Neo.dashboard.dock.interaction.DockSplitter', /** * @member {String} ntype='dashboard-dock-splitter' * @protected @@ -70,15 +77,6 @@ class DockSplitter extends Component { * @reactive */ boundaryIndex_: null, - /** - * @member {Neo.draggable.DragZone|null} dragZone=null - * @protected - */ - dragZone: null, - /** - * @member {Object|null} dragZoneConfig=null - */ - dragZoneConfig: null, /** * Current committed dock-zone document. Used when no reducer callback is supplied. * @member {Object|null} dockZoneDocument_=null @@ -92,16 +90,16 @@ class DockSplitter extends Component { onDockZoneDocumentChange: null, /** * Split orientation from the dock-zone model (`horizontal` means side-by-side children). + * Maps onto the generic `direction` config as its inverse. * @member {String} orientation_='horizontal' * @reactive */ orientation_: 'horizontal', /** - * Visual splitter extent in px. - * @member {Number} size_=6 - * @reactive + * Visual splitter extent in px (default override of the inherited reactive config). + * @member {Number} size=6 */ - size_: 6, + size: 6, /** * Dock-zone split node id. * @member {String|null} splitNodeId_=null @@ -116,40 +114,6 @@ class DockSplitter extends Component { */ dragStartState = null - /** - * @param {Object} config - */ - construct(config) { - super.construct(config); - - let me = this, - orientation = me.getValidatedOrientation(me.orientation), - vertical = orientation === 'vertical'; - - me.addDomListeners([ - {'drag:end' : me.onDragEnd, scope: me}, - {'drag:start': me.onDragStart, scope: me} - ]); - - // Create the drag zone EAGERLY (not on first drag:start): the zone registers itself - // with the main-thread DragDrop addon at construction, which is what lets the first - // drag:start of a boot already carry its dragZoneId — closing the cold-start window - // in which the Escape guard keyed on the still-null id. - me.dragZone = Neo.create({ - module : DragZone, - appName : me.appName, - bodyCursorStyle : me.getCursorStyle(), - boundaryContainerId: me.parent?.id, - dragElement : me.vdom, - moveHorizontal : !vertical, - moveVertical : vertical, - owner : me, - useProxyWrapper : false, - windowId : me.windowId, - ...me.dragZoneConfig - }) - } - /** * @summary Carries the splitter's resolved paint onto its drag proxy, which mounts outside the * cascade that produced it. @@ -157,16 +121,13 @@ class DockSplitter extends Component { * The proxy is a clone mounted at `document.body` ({@link Neo.draggable.DragZone#proxyParentId}), * so it keeps the splitter's classes and loses every ancestor. That breaks the paint twice over: * the engine declares its `--dock-splitter-*` defaults on `.neo-dashboard`, and each consumer - * declares its values as a DESCENDANT rule (`.fm-fleet-cockpit .neo-dashboard-dock-splitter`, - * `.workstation-workspace .neo-dashboard-dock-splitter`). Detached from both, every token - * resolves empty and the proxy renders transparent with a zero-sized handle — the affordance - * disappears at exactly the moment it is telling the user they are moving something. + * declares its values as a DESCENDANT rule. Detached from both, every token resolves empty and + * the proxy renders transparent with a zero-sized handle — the affordance disappears at exactly + * the moment it is telling the user they are moving something. * * Reading the SOURCE element's computed values is what makes this consumer-agnostic: the source * has already been through the real cascade, so the projection never needs to know which class - * carried a value or how deeply it was nested. A scope class cannot do this — it would restore - * the engine floor and leave every consumer value absent, which paints the proxy WRONG rather - * than not at all, and wrong is the harder failure to notice. + * carried a value or how deeply it was nested. * * Best-effort by contract: a failed read must never block a drag. Losing the paint costs an * affordance; throwing here would cost the gesture. @@ -219,6 +180,9 @@ class DockSplitter extends Component { } /** + * Maps the dock split orientation onto the generic divider direction (its inverse) and keeps + * the dock modifier class in sync. The parent's `afterSetDirection` then owns the axis + * dimension pair and the per-gesture DragZone refresh. * @param {String|null} value * @param {String|null} oldValue * @protected @@ -226,41 +190,15 @@ class DockSplitter extends Component { afterSetOrientation(value, oldValue) { let me = this, orientation = me.getValidatedOrientation(value), - cls = me.cls || [], - height = orientation === 'vertical' ? me.size : null, - width = orientation === 'vertical' ? null : me.size; + cls = me.cls || []; if (oldValue) { - NeoArray.remove(cls, `neo-dashboard-dock-splitter-${oldValue}`) + NeoArray.remove(cls, `neo-dashboard-dock-splitter-${oldValue}`); + me.cls = cls } - NeoArray.add(cls, `neo-dashboard-dock-splitter-${orientation}`); - - me.set({ - cls, - height, - minHeight: height, - minWidth : width, - width - }) - } - - /** - * @param {Number} value - * @param {Number} oldValue - * @protected - */ - afterSetSize(value, oldValue) { - let me = this, - height = me.getValidatedOrientation(me.orientation) === 'vertical' ? value : null, - width = height === null ? value : null; - - me.set({ - height, - minHeight: height, - minWidth : width, - width - }) + // dock 'horizontal' (side-by-side children) = a vertical divider bar + me.direction = orientation === 'vertical' ? 'horizontal' : 'vertical' } /** @@ -325,7 +263,7 @@ class DockSplitter extends Component { if (typeof me.applyDockZoneOperation === 'function') { result = me.applyDockZoneOperation(descriptor, me) || null } else if (me.dockZoneDocument) { - result = DockZoneModel.applyOperation(me.dockZoneDocument, descriptor) + result = Operations.applyOperation(me.dockZoneDocument, descriptor) } if (!result) { @@ -352,17 +290,18 @@ class DockSplitter extends Component { * @protected */ createResizeSplitDescriptor(data={}) { - return Neo.dashboard.DockLayoutAdapter.createResizeSplitOperation(this, this.resolveSizeVector(data)) + return Neo.dashboard.dock.projection.LayoutAdapter.createResizeSplitOperation(this, this.resolveSizeVector(data)) } /** - * @returns {String} + * The dock splitter registers no main-thread resize: the committed document is the sole size + * authority, so the deferred proxy presentation carries the gesture and the terminal commits + * semantically. A live adjacent-pair preview is a separate feature seam. + * @returns {Object|null} * @protected */ - getCursorStyle() { - return this.getValidatedOrientation(this.orientation) === 'vertical' - ? 'ns-resize !important' - : 'ew-resize !important' + getResizeConfig() { + return null } /** @@ -381,6 +320,20 @@ class DockSplitter extends Component { return (this.parent?.items || []).filter(item => item && item.dockNodeType !== 'splitter') } + /** + * The orientation modifier rides the read-time class union rather than a stored cls write: + * construct-order between this class's configs and the inherited direction processing must + * never decide whether the paint-bearing modifier exists. + * @returns {String[]} + */ + getBaseClass() { + const result = super.getBaseClass(); + + result.push(`neo-dashboard-dock-splitter-${this.getValidatedOrientation(this.orientation)}`); + + return result + } + /** * @param {String} orientation * @returns {String} @@ -391,29 +344,34 @@ class DockSplitter extends Component { } /** + * The dock terminal: generic teardown first (generation fence, presentation restore, zone + * end), then EXACTLY one semantic commit derived from the captured pair — never a sibling + * `wrapperStyle` write, which is the generic parent's terminal and stays overridden here. * @param {Object} data * @returns {Object} */ onDragEnd(data={}) { let me = this, - descriptor = me.createResizeSplitDescriptor(data), + hasCapture = Boolean(me.dragStartState) || Array.isArray(data.sizes), + descriptor = hasCapture ? me.createResizeSplitDescriptor(data) : null, result; - if (me.parent) { - me.parent.disabled = false - } + me.dragGeneration++; + me.cleanupResize(); + me.dragZone?.dragEnd(data); - if (me.dragZone) { - me.dragZone.dragEnd(data) + // The end-overtakes-start race: a real-pointer release can land while the async start + // path is still capturing. A terminal without capture state (and no explicit vector) is + // not a gesture — committing would write a zero-delta operation; reject loudly instead. + if (!hasCapture) { + result = { + document: me.dockZoneDocument ?? null, + errors : ['DockSplitter received a terminal without capture state; no resizeSplit was committed.'] + } + } else { + result = me.commitResizeSplit(descriptor) } - me.style = { - ...(me.style || {}), - opacity: 1 - }; - - result = me.commitResizeSplit(descriptor); - me.fire(result.errors?.length ? 'dockSplitterResizeRejected' : 'dockSplitterResize', { descriptor, result, @@ -426,34 +384,28 @@ class DockSplitter extends Component { } /** + * Captures the adjacent-pair geometry and projects the proxy paint BEFORE the generic parent + * refreshes the zone and starts the gesture (the proxy is created inside the parent's start). + * The armed generation fences those awaits: the parent arms its own fence only inside + * `super.onDragStart()`, so a cancel, destroy, terminal, or newer start landing during the + * capture/projection awaits must invalidate the pending start here, before the real gesture + * can open. Taking the increment (not a read) makes a superseded start bail without ever + * opening the zone. * @param {Object} data */ async onDragStart(data={}) { - let me = this, - orientation = me.getValidatedOrientation(me.orientation), - vertical = orientation === 'vertical'; - - if (me.parent) { - me.parent.disabled = true - } - - // The zone exists by construction — refresh the per-gesture facts that can drift - // (orientation-driven axes, cursor, the boundary container resolved once mounted). - me.dragZone.set({ - bodyCursorStyle : me.getCursorStyle(), - boundaryContainerId: me.parent?.id, - moveHorizontal : !vertical, - moveVertical : vertical - }); + let me = this, + generation = ++me.dragGeneration; await me.captureDragStart(data); await me.projectProxyTokens(); - await me.dragZone.dragStart(data); - me.style = { - ...(me.style || {}), - opacity: 0.5 + if (generation !== me.dragGeneration || me.isDestroyed) { + me.dragStartState = null; + return } + + await super.onDragStart(data) } /** diff --git a/src/dashboard/DockDragAffordances.mjs b/src/dashboard/dock/interaction/DragAffordances.mjs similarity index 92% rename from src/dashboard/DockDragAffordances.mjs rename to src/dashboard/dock/interaction/DragAffordances.mjs index 3a55b40fb8..4e48ba8ff7 100644 --- a/src/dashboard/DockDragAffordances.mjs +++ b/src/dashboard/dock/interaction/DragAffordances.mjs @@ -1,9 +1,9 @@ -import Base from '../core/Base.mjs'; -import DockPreviewProducer from './DockPreviewProducer.mjs'; -import {previewToOperation} from './dockPreviewContract.mjs'; +import Base from '../../../core/Base.mjs'; +import PreviewProducer from './PreviewProducer.mjs'; +import {previewToOperation} from '../model/PreviewContract.mjs'; /** - * @class Neo.dashboard.DockDragAffordances + * @class Neo.dashboard.dock.interaction.DragAffordances * @extends Neo.core.Base * * @summary The app-neutral drag-affordance gesture controller every docking workspace composes. @@ -31,13 +31,13 @@ import {previewToOperation} from './dockPreviewContract.mjs'; * `indicators`) and the dock `host` container are direct instance refs the consumer assigns * after composing them — no reference-name coupling, no app imports in this tier. */ -class DockDragAffordances extends Base { +class DragAffordances extends Base { static config = { /** - * @member {String} className='Neo.dashboard.DockDragAffordances' + * @member {String} className='Neo.dashboard.dock.interaction.DragAffordances' * @protected */ - className: 'Neo.dashboard.DockDragAffordances' + className: 'Neo.dashboard.dock.interaction.DragAffordances' } /** @@ -55,8 +55,8 @@ class DockDragAffordances extends Base { host = null /** - * The indicator-menu overlay instance (Neo.dashboard.DockDropIndicators). - * @member {Neo.dashboard.DockDropIndicators|null} indicators=null + * The indicator-menu overlay instance (Neo.dashboard.dock.interaction.DropIndicators). + * @member {Neo.dashboard.dock.interaction.DropIndicators|null} indicators=null */ indicators = null @@ -67,14 +67,14 @@ class DockDragAffordances extends Base { owner = null /** - * The preview renderer overlay instance (Neo.dashboard.DockPreview). - * @member {Neo.dashboard.DockPreview|null} preview=null + * The preview renderer overlay instance (Neo.dashboard.dock.interaction.Preview). + * @member {Neo.dashboard.dock.interaction.Preview|null} preview=null */ preview = null /** * The candidate producer — created here, destroyed here. - * @member {Neo.dashboard.DockPreviewProducer|null} producer=null + * @member {Neo.dashboard.dock.interaction.PreviewProducer|null} producer=null */ producer = null @@ -83,7 +83,7 @@ class DockDragAffordances extends Base { */ construct(config) { super.construct(config); - this.producer = Neo.create(DockPreviewProducer) + this.producer = Neo.create(PreviewProducer) } /** @@ -296,4 +296,4 @@ class DockDragAffordances extends Base { } } -export default Neo.setupClass(DockDragAffordances); +export default Neo.setupClass(DragAffordances); diff --git a/src/dashboard/DockDropIndicators.mjs b/src/dashboard/dock/interaction/DropIndicators.mjs similarity index 95% rename from src/dashboard/DockDropIndicators.mjs rename to src/dashboard/dock/interaction/DropIndicators.mjs index e84be4455b..8bf74a9552 100644 --- a/src/dashboard/DockDropIndicators.mjs +++ b/src/dashboard/dock/interaction/DropIndicators.mjs @@ -1,7 +1,7 @@ -import Component from '../component/Base.mjs'; -import Container from '../container/Base.mjs'; -import NeoArray from '../util/Array.mjs'; -import {isValidCandidateSet} from './dockPreviewContract.mjs'; +import Component from '../../../component/Base.mjs'; +import Container from '../../../container/Base.mjs'; +import NeoArray from '../../../util/Array.mjs'; +import {isValidCandidateSet} from '../model/PreviewContract.mjs'; /** * @summary The drag-time drop-indicator menu: renders every valid drop option simultaneously — @@ -11,7 +11,7 @@ import {isValidCandidateSet} from './dockPreviewContract.mjs'; * the PRIMARY drag affordance ("show the menu, never make them guess"); pointer-zone inference * demotes to the fallback tier. This component is that menu's render half: * - * - **Input** is a `neo.harness.dockCandidates.v1` payload ({@link Neo.dashboard.DockPreviewProducer#produceCandidates}) + * - **Input** is a `neo.dock.candidates.v1` payload ({@link Neo.dashboard.dock.interaction.PreviewProducer#produceCandidates}) * plus the positioning host's viewport rect. Both are transient geometry — nothing here reads * the DOM, touches a persisted document, or owns a pointer. * - **Selection is geometric, not DOM-eventing.** The drag proxy rides between the pointer and @@ -32,13 +32,13 @@ import {isValidCandidateSet} from './dockPreviewContract.mjs'; * - **Fail closed.** A malformed candidate set or a missing host rect hides the layer * ({@link module:dockPreviewContract.isValidCandidateSet}) rather than guessing coordinates. * - * @class Neo.dashboard.DockDropIndicators + * @class Neo.dashboard.dock.interaction.DropIndicators * @extends Neo.container.Base - * @see Neo.dashboard.DockPreviewProducer - * @see Neo.dashboard.DockTabSortZone + * @see Neo.dashboard.dock.interaction.PreviewProducer + * @see Neo.dashboard.dock.interaction.TabSortZone * @see learn/agentos/DockZoneModel.md */ -class DockDropIndicators extends Container { +class DropIndicators extends Container { static config = { /** * The overlay positioning, visibility, and complete indicator skin live in the shared @@ -48,10 +48,10 @@ class DockDropIndicators extends Container { */ additionalThemeFiles: ['Neo.dashboard.Container'], /** - * @member {String} className='Neo.dashboard.DockDropIndicators' + * @member {String} className='Neo.dashboard.dock.interaction.DropIndicators' * @protected */ - className: 'Neo.dashboard.DockDropIndicators', + className: 'Neo.dashboard.dock.interaction.DropIndicators', /** * @member {String} ntype='dashboard-dock-drop-indicators' * @protected @@ -71,7 +71,7 @@ class DockDropIndicators extends Container { */ activeCandidate_: null, /** - * The current candidate set (`neo.harness.dockCandidates.v1`) to render, or null to hide + * The current candidate set (`neo.dock.candidates.v1`) to render, or null to hide * the whole layer. Runtime-only drag state — never persisted. * @member {Object|null} candidateSet_=null * @reactive @@ -435,4 +435,4 @@ class DockDropIndicators extends Container { } } -export default Neo.setupClass(DockDropIndicators); +export default Neo.setupClass(DropIndicators); diff --git a/src/dashboard/DockKeyboardCommands.mjs b/src/dashboard/dock/interaction/KeyboardCommands.mjs similarity index 98% rename from src/dashboard/DockKeyboardCommands.mjs rename to src/dashboard/dock/interaction/KeyboardCommands.mjs index e0e019894f..fcf41670fd 100644 --- a/src/dashboard/DockKeyboardCommands.mjs +++ b/src/dashboard/dock/interaction/KeyboardCommands.mjs @@ -1,11 +1,11 @@ /** - * @module Neo.dashboard.DockKeyboardCommands + * @module Neo.dashboard.dock.interaction.KeyboardCommands * @summary The keyboard command surface for the multi-window docking choreography — the a11y * parity path, and the always-works acquisition fallback (a keystroke IS a user activation, so * the command path acquires popups by definition where a platform's boundary-acquisition fails). * - * The pointer path is a continuous gesture: {@link Neo.dashboard.DockTabSortZone} fires boundary - * hysteresis + detached terminals, and {@link Neo.dashboard.DockTearOut} choreographs admission + * The pointer path is a continuous gesture: {@link Neo.dashboard.dock.interaction.TabSortZone} fires boundary + * hysteresis + detached terminals, and {@link Neo.dashboard.dock.window.TearOut} choreographs admission * and the one model commit. A keyboard command is DISCRETE — no boundary hysteresis, no moving * embodiment — so the gesture phases collapse into admission-first → exactly-once model commit → * focus transfer, and EVERY terminal derives an announcement. This module owns exactly that @@ -47,7 +47,7 @@ * closes the OS window a refused commit leaves behind. Never called for a committed detach. * @param {Function} seams.commitTransfer Host transfer seam: * `({itemId, target: {workspaceId, tabsId}}) => {errors: String[]}|Promise<{errors: String[]}>` — - * the host runs `DockZoneModel.transferItem` (commit-or-neither document pair) and lands the + * the host runs `Operations.transferItem` (commit-or-neither document pair) and lands the * pair through its workspace set's both-or-neither adoption. Called exactly once per commit * and AWAITED to settlement — a rejection or a malformed result is treated as a refusal. * @param {Function} seams.enumerateTargets Host target enumeration: `({itemId}) => Object[]` — diff --git a/src/dashboard/DockPreview.mjs b/src/dashboard/dock/interaction/Preview.mjs similarity index 90% rename from src/dashboard/DockPreview.mjs rename to src/dashboard/dock/interaction/Preview.mjs index 2e0164291b..ef7f040a55 100644 --- a/src/dashboard/DockPreview.mjs +++ b/src/dashboard/dock/interaction/Preview.mjs @@ -1,14 +1,14 @@ -import Component from '../component/Base.mjs'; -import * as dockPreviewContract from './dockPreviewContract.mjs'; +import Component from '../../../component/Base.mjs'; +import * as dockPreviewContract from '../model/PreviewContract.mjs'; /** - * @class Neo.dashboard.DockPreview + * @class Neo.dashboard.dock.interaction.Preview * @extends Neo.component.Base * * @summary Drag-time dock preview renderer — the app-neutral overlay every docking workspace composes. * * Consumes the runtime-only `dockPreview` contract object - * (schema `neo.harness.dockPreview.v1`, specified in `learn/agentos/DockZoneModel.md`) + * (schema `neo.dock.preview.v1`, specified in `learn/agentos/DockZoneModel.md`) * and projects its candidate `placement` into a single transient visual affordance — an edge * band, a split guide, or a tab indicator — over the dock workspace while a pane is dragged. * @@ -17,23 +17,23 @@ import * as dockPreviewContract from './dockPreviewContract.mjs'; * - **Visual only.** This overlay never owns pointer events and never adds a parallel drag * system. The existing dashboard / sort-zone drag lifecycle is the single event source; the * overlay is purely reactive to a `dockPreview` produced upstream and to drag-lifecycle - * terminals (drag end / boundary exit) wired via {@link Neo.dashboard.DockPreview#bindDragSource}. + * terminals (drag end / boundary exit) wired via {@link Neo.dashboard.dock.interaction.Preview#bindDragSource}. * - **Runtime only.** `dockPreview` payloads, `DOMRect`s, screen coordinates and overlay nodes are * never written into the persisted dock-zone model. This component has no write path to a * persisted model — it reads a preview and emits transient VDOM plus operation descriptors. * - **Fail closed.** A missing, malformed, stale or `rejected`-placement preview clears the - * affordance and performs no model mutation ({@link Neo.dashboard.DockPreview.isValidPreview}). + * affordance and performs no model mutation ({@link Neo.dashboard.dock.interaction.Preview.isValidPreview}). * - **Semantic drop.** On an accepted drop the owning adapter converts the preview into a semantic - * operation (`moveItem` / `splitNode` / `addTab`); {@link Neo.dashboard.DockPreview.previewToOperation} + * operation (`moveItem` / `splitNode` / `addTab`); {@link Neo.dashboard.dock.interaction.Preview.previewToOperation} * yields that operation DESCRIPTOR — this overlay never mutates the dock tree itself. * * The producer (raw drag geometry -> `dockPreview`) and the operation executor (descriptor -> * persisted-tree mutation) are deliberately out of scope: they are separate docking leaves. */ -class DockPreview extends Component { +class Preview extends Component { /** * The dockPreview contract schema this renderer accepts. - * @member {String} PREVIEW_SCHEMA='neo.harness.dockPreview.v1' + * @member {String} PREVIEW_SCHEMA='neo.dock.preview.v1' * @static */ static PREVIEW_SCHEMA = dockPreviewContract.PREVIEW_SCHEMA @@ -61,10 +61,10 @@ class DockPreview extends Component { static config = { /** - * @member {String} className='Neo.dashboard.DockPreview' + * @member {String} className='Neo.dashboard.dock.interaction.Preview' * @protected */ - className: 'Neo.dashboard.DockPreview', + className: 'Neo.dashboard.dock.interaction.Preview', /** * @member {String} ntype='dock-preview' * @protected @@ -91,7 +91,7 @@ class DockPreview extends Component { edgeBandSize: 24, /** * Thickness in px of a split / tab guide line. Per-instance policy, same rationale as - * {@link Neo.dashboard.DockPreview#edgeBandSize}. + * {@link Neo.dashboard.dock.interaction.Preview#edgeBandSize}. * @member {Number} splitLineSize=6 */ splitLineSize: 6 @@ -99,7 +99,7 @@ class DockPreview extends Component { /** * The drag surface this overlay listens to for lifecycle-terminal cleanup. Wired via - * {@link Neo.dashboard.DockPreview#bindDragSource}; never a pointer-owning surface of our own. + * {@link Neo.dashboard.dock.interaction.Preview#bindDragSource}; never a pointer-owning surface of our own. * @member {Object|null} dragSource=null * @protected */ @@ -108,7 +108,7 @@ class DockPreview extends Component { /** * @summary Structural validity gate for a dockPreview object (fail-closed). * - * Returns true only for a well-formed `neo.harness.dockPreview.v1` payload that carries a + * Returns true only for a well-formed `neo.dock.preview.v1` payload that carries a * stable `itemId`, a `target.nodeId`, a known `placement.kind`, an accept/reject * `feedback.state`, and (for split placements) a valid `placement.orientation`. Anything * malformed, partial or unknown returns false so the renderer clears rather than guesses. @@ -246,7 +246,7 @@ class DockPreview extends Component { */ afterSetDockPreview(value, oldValue) { let me = this, - affordance = DockPreview.mapPreviewToAffordance(value); + affordance = Preview.mapPreviewToAffordance(value); me.vdom.cn = affordance ? [me.getAffordanceVdom(affordance)] : []; me.update() @@ -262,12 +262,12 @@ class DockPreview extends Component { */ applyTargetGeometry(targetRect) { let me = this, - affordance = DockPreview.mapPreviewToAffordance(me.dockPreview), + affordance = Preview.mapPreviewToAffordance(me.dockPreview), node = me.vdom.cn?.[0]; if (!affordance || !node) return; - let geo = DockPreview.affordanceGeometry(affordance, targetRect, { + let geo = Preview.affordanceGeometry(affordance, targetRect, { edgeBandSize : me.edgeBandSize, splitLineSize: me.splitLineSize }); @@ -293,7 +293,7 @@ class DockPreview extends Component { * surface and clears the transient overlay on either. This is the ONLY coupling to the drag * lifecycle: the overlay consumes existing signals and owns no pointer events of its own. * @param {Object|null} dragSource a Neo.draggable sort/drag zone (or any Observable) - * @returns {Neo.dashboard.DockPreview} this, for chaining + * @returns {Neo.dashboard.dock.interaction.Preview} this, for chaining */ bindDragSource(dragSource) { let me = this; @@ -332,7 +332,7 @@ class DockPreview extends Component { * * The node carries semantic classes (group + concrete kind + accept/reject) and the target * node id as a data attribute; positioning is applied separately via - * {@link Neo.dashboard.DockPreview#applyTargetGeometry}. The overlay is pointer-transparent so + * {@link Neo.dashboard.dock.interaction.Preview#applyTargetGeometry}. The overlay is pointer-transparent so * it never intercepts the live drag. * @param {Object} affordance * @returns {Object} VDOM node @@ -365,4 +365,4 @@ class DockPreview extends Component { } } -export default Neo.setupClass(DockPreview); +export default Neo.setupClass(Preview); diff --git a/src/dashboard/DockPreviewProducer.mjs b/src/dashboard/dock/interaction/PreviewProducer.mjs similarity index 93% rename from src/dashboard/DockPreviewProducer.mjs rename to src/dashboard/dock/interaction/PreviewProducer.mjs index 4a0dc6aa17..8d8c33b7a0 100644 --- a/src/dashboard/DockPreviewProducer.mjs +++ b/src/dashboard/dock/interaction/PreviewProducer.mjs @@ -1,14 +1,14 @@ -import Base from '../core/Base.mjs'; +import Base from '../../../core/Base.mjs'; /** - * @class Neo.dashboard.DockPreviewProducer + * @class Neo.dashboard.dock.interaction.PreviewProducer * @extends Neo.core.Base * * @summary Hit-test producer for the dock drag: maps a pointer plus the rendered dock-zone - * rects to a runtime-only `neo.harness.dockPreview.v1` payload — the COMPUTE half of the preview → + * rects to a runtime-only `neo.dock.preview.v1` payload — the COMPUTE half of the preview → * operation pipeline (`learn/agentos/decisions/0029-docking-design.md` §2.3, schema in * `learn/agentos/DockZoneModel.md`). It is the object an owning dock workspace wires into - * {@link Neo.dashboard.CrossWindowDragTarget#previewFor} (and, for the boolean claim, `hitTest`); + * {@link Neo.dashboard.dock.window.DragTarget#previewFor} (and, for the boolean claim, `hitTest`); * the same compute path serves in-window drags. * * **Instance + config, not statics (by design).** The geometry thresholds are **non-reactive @@ -17,17 +17,17 @@ import Base from '../core/Base.mjs'; * customization surface `core.Base` exists to provide. The hit-test methods are instance methods * for the same reason: a workspace with a different drop grammar subclasses and overrides * `resolvePlacementKind` rather than forking the payload assembly. Owners hold one producer instance - * (`Neo.create(DockPreviewProducer)`) and call `produce()` per hover frame. + * (`Neo.create(PreviewProducer)`) and call `produce()` per hover frame. * * Boundaries (binding, per the §2.3 / dock-zone-model contracts): * * - **Pure + runtime-only.** Input is transient geometry (rects, pointer); output is a transient * `dockPreview`. Nothing here is written into the persisted dock-zone model, and the methods have * no side effects — safe to call on every hover frame and inside a `hitTest`. - * - **Layer-blind.** This producer must not import the renderer (`Neo.dashboard.DockPreview`) + * - **Layer-blind.** This producer must not import the renderer (`Neo.dashboard.dock.interaction.Preview`) * renderer/validator. So this producer re-derives the schema's placement vocabulary locally; the * producer → consumer contract is PINNED in the unit test (which may import both layers) by - * asserting every produced payload satisfies `DockPreview.isValidPreview`. + * asserting every produced payload satisfies `Preview.isValidPreview`. * - **Fail closed.** A malformed rect, a pointer outside every zone, or a missing item id yields * `null` (no affordance) rather than a guess — mirroring the renderer's fail-closed clear. * @@ -36,13 +36,13 @@ import Base from '../core/Base.mjs'; * that existing split as a sibling, carrying its orientation). An optional split `ratio` is not * emitted yet — the consumer defaults an absent ratio to an even split. */ -class DockPreviewProducer extends Base { +class PreviewProducer extends Base { static config = { /** - * @member {String} className='Neo.dashboard.DockPreviewProducer' + * @member {String} className='Neo.dashboard.dock.interaction.PreviewProducer' * @protected */ - className: 'Neo.dashboard.DockPreviewProducer', + className: 'Neo.dashboard.dock.interaction.PreviewProducer', /** * @member {String} ntype='dock-preview-producer' * @protected @@ -52,15 +52,15 @@ class DockPreviewProducer extends Base { * The dockPreview contract schema this producer emits. A non-reactive config (kept in sync * with the consumer via the unit-test pin, since the app-layer validator cannot be imported * here) so a future schema revision is a config bump, not a class edit. - * @member {String} schema='neo.harness.dockPreview.v1' + * @member {String} schema='neo.dock.preview.v1' */ - schema: 'neo.harness.dockPreview.v1', + schema: 'neo.dock.preview.v1', /** * The candidate-set schema `produceCandidates()` emits — the full indicator-menu payload. * Same sync mechanism as `schema`: the unit-test pin asserts it against the contract module. - * @member {String} candidatesSchema='neo.harness.dockCandidates.v1' + * @member {String} candidatesSchema='neo.dock.candidates.v1' */ - candidatesSchema: 'neo.harness.dockCandidates.v1', + candidatesSchema: 'neo.dock.candidates.v1', /** * Fraction of a zone rect's SMALLER dimension treated as an edge band. Inside a band the * nearest edge wins (`edge-*` / `split-*`); the interior maps to `tab-into`. A non-reactive @@ -185,7 +185,7 @@ class DockPreviewProducer extends Base { * @param {String} [params.containerId] the hovered workspace/container id * @param {Object} [params.source] producer surface, e.g. {surface, sortZoneId} * @param {String} [params.sourceNodeId] the drag's origin dock node (used as sortZoneId fallback) - * @returns {Object|null} a `neo.harness.dockPreview.v1` payload, or null + * @returns {Object|null} a `neo.dock.preview.v1` payload, or null */ produce({pointer, zones, itemId, groupNodeId=null, containerId=null, source=null, sourceNodeId=null}={}) { if (typeof itemId !== 'string' || !itemId || @@ -214,7 +214,7 @@ class DockPreviewProducer extends Base { * @param {String} nodeId the target dock node * @param {String} kind a non-`rejected` placement kind * @param {String} [orientation] the target's parent-split orientation (required for `split-*` kinds) - * @returns {Object} a `neo.harness.dockPreview.v1` payload + * @returns {Object} a `neo.dock.preview.v1` payload * @protected */ buildPreview({containerId=null, groupNodeId=null, itemId, source=null, sourceNodeId=null}, nodeId, kind, orientation) { @@ -269,7 +269,7 @@ class DockPreviewProducer extends Base { /** * @summary Produces the full drop-candidate set for the zone under the pointer — the - * indicator-menu payload (`neo.harness.dockCandidates.v1`) — or null outside every zone. + * indicator-menu payload (`neo.dock.candidates.v1`) — or null outside every zone. * * The menu grammar (design authority: the dock-choreography artifact §06): a 5-position CROSS * on the hovered zone — center = tab-merge, the four directions = directional splits, each @@ -294,7 +294,7 @@ class DockPreviewProducer extends Base { * @param {Object} [params.source] producer surface, e.g. {surface, sortZoneId} * @param {String} [params.sourceNodeId] the drag's origin dock node * @param {Object} [params.root] {nodeId, rect} the document root + its measured rect (enables the edge chips) - * @returns {Object|null} a `neo.harness.dockCandidates.v1` payload, or null + * @returns {Object|null} a `neo.dock.candidates.v1` payload, or null */ produceCandidates({pointer, zones, itemId, groupNodeId=null, containerId=null, source=null, sourceNodeId=null, root=null}={}) { if (typeof itemId !== 'string' || !itemId || @@ -334,4 +334,4 @@ class DockPreviewProducer extends Base { } } -export default Neo.setupClass(DockPreviewProducer); +export default Neo.setupClass(PreviewProducer); diff --git a/src/dashboard/DockRail.mjs b/src/dashboard/dock/interaction/Rail.mjs similarity index 92% rename from src/dashboard/DockRail.mjs rename to src/dashboard/dock/interaction/Rail.mjs index c77dc3e4e2..9d3ca7a0c9 100644 --- a/src/dashboard/DockRail.mjs +++ b/src/dashboard/dock/interaction/Rail.mjs @@ -1,9 +1,9 @@ -import Button from '../button/Base.mjs'; -import Container from '../container/Base.mjs'; -import DockRevealOverlay from './DockRevealOverlay.mjs'; -import DockRevealStateMachine from './DockRevealStateMachine.mjs'; -import DockZoneModel from './DockZoneModel.mjs'; -import NeoArray from '../util/Array.mjs'; +import Button from '../../../button/Base.mjs'; +import Container from '../../../container/Base.mjs'; +import RevealOverlay from './RevealOverlay.mjs'; +import RevealStateMachine from './RevealStateMachine.mjs'; +import Operations from '../model/Operations.mjs'; +import NeoArray from '../../../util/Array.mjs'; /** * @summary Runtime edge-rail affordance rendering committed auto-hidden items as real button @@ -11,7 +11,7 @@ import NeoArray from '../util/Array.mjs'; * * The rail is pure render projection (per-window, derived, never persisted): WHICH items rail — and * on which edge — is committed `dockZone.v1` truth the adapter derives - * (`DockLayoutAdapter.collectAutoHiddenItems()`). Tabs are `Neo.button.Base` child components built + * (`LayoutAdapter.collectAutoHiddenItems()`). Tabs are `Neo.button.Base` child components built * from plain `railItems` metadata rather than from the pane components themselves, so the pane never * learns it is railed (pane-blindness) and a destroyed or unresolvable pane cannot break its recall * affordance. Composition over synthesis: clicks ride the button `handler` contract, hover intents @@ -28,31 +28,31 @@ import NeoArray from '../util/Array.mjs'; * (`autoHideRevealOnHover`; dwell-gated, never steals focus — hover reveals are an accessibility * hazard by default). The PERSIST path is the overlay's pin control: `setItemPinned(true)` committed * through the owning reducer callback (`applyDockZoneOperation`) or a local - * `DockZoneModel.applyOperation()` — never a parallel mutation path; the model clears `autoHidden` + * `Operations.applyOperation()` — never a parallel mutation path; the model clears `autoHidden` * itself. * * Reveal is policy-free: even a `pinnable: false` item (whose PIN the model would reject) must stay * reachable through reveal — anything else is item loss. The policy projection (`restorable`) * therefore gates the overlay's pin control, never the tab. * - * The reveal/dismiss timing brain lives in {@link DockRevealStateMachine} (documented state table); + * The reveal/dismiss timing brain lives in {@link RevealStateMachine} (documented state table); * this component owns composition, overlay binding and the executor commit path. * - * @class Neo.dashboard.DockRail + * @class Neo.dashboard.dock.interaction.Rail * @extends Neo.container.Base - * @see Neo.dashboard.DockLayoutAdapter - * @see Neo.dashboard.DockRevealOverlay - * @see Neo.dashboard.DockSplitter - * @see Neo.dashboard.DockZoneModel + * @see Neo.dashboard.dock.projection.LayoutAdapter + * @see Neo.dashboard.dock.interaction.RevealOverlay + * @see Neo.dashboard.dock.interaction.DockSplitter + * @see Neo.dashboard.dock.model.Document * @see learn/agentos/DockZoneModel.md */ -class DockRail extends Container { +class Rail extends Container { static config = { /** - * @member {String} className='Neo.dashboard.DockRail' + * @member {String} className='Neo.dashboard.dock.interaction.Rail' * @protected */ - className: 'Neo.dashboard.DockRail', + className: 'Neo.dashboard.dock.interaction.Rail', /** * @member {String} ntype='dashboard-dock-rail' * @protected @@ -105,7 +105,7 @@ class DockRail extends Container { onDockZoneDocumentChange: null, /** * Rail tab metadata, in document order: `[{dockEdge, dockItemId, restorable, title}]`. - * Projection input from `DockLayoutAdapter.createRailTab()` — model-derived, never persisted. + * Projection input from `LayoutAdapter.createRailTab()` — model-derived, never persisted. * @member {Object[]|null} railItems_=null * @reactive */ @@ -141,13 +141,13 @@ class DockRail extends Container { revealPaneCache = {} /** * The reveal/dismiss timing brain. Runtime-only; created per instance, torn down in `destroy()`. - * @member {DockRevealStateMachine|null} revealMachine=null + * @member {RevealStateMachine|null} revealMachine=null * @protected */ revealMachine = null /** - * The overlay bound via {@link Neo.dashboard.DockRail#bindRevealOverlay}, when one exists. - * @member {Neo.dashboard.DockRevealOverlay|null} revealOverlay=null + * The overlay bound via {@link Neo.dashboard.dock.interaction.Rail#bindRevealOverlay}, when one exists. + * @member {Neo.dashboard.dock.interaction.RevealOverlay|null} revealOverlay=null * @protected */ revealOverlay = null @@ -165,7 +165,7 @@ class DockRail extends Container { config.items = [ ...(config.railItems || []).map(railItem => this.createTabConfig(railItem, config.edge)), { - module: DockRevealOverlay, + module: RevealOverlay, edge : this.getValidatedEdge(config.edge) } ] @@ -175,7 +175,7 @@ class DockRail extends Container { let me = this; - me.revealMachine = new DockRevealStateMachine({ + me.revealMachine = new RevealStateMachine({ dwellMs : Number.isFinite(me.revealDwellMs) ? me.revealDwellMs : undefined, graceMs : Number.isFinite(me.revealDismissGraceMs) ? me.revealDismissGraceMs : undefined, onChange : me.onRevealStateChange.bind(me), @@ -260,8 +260,8 @@ class DockRail extends Container { * Binds a reveal overlay to this rail: overlay intents (pointer, focus, escape, pin) feed the * state machine, and machine state pushes back into the overlay — the full focus-hold loop * becomes testable without a live workspace. - * @param {Neo.dashboard.DockRevealOverlay} overlay - * @returns {Neo.dashboard.DockRevealOverlay} + * @param {Neo.dashboard.dock.interaction.RevealOverlay} overlay + * @returns {Neo.dashboard.dock.interaction.RevealOverlay} */ bindRevealOverlay(overlay) { let me = this; @@ -285,7 +285,7 @@ class DockRail extends Container { /** * Commits a dock-zone operation descriptor through the owning reducer callback, falling back to - * a local `DockZoneModel.applyOperation()` — identical commit contract to + * a local `Operations.applyOperation()` — identical commit contract to * `DockSplitter.commitResizeSplit()` so dashboard reducers handle every affordance with one * code path. The rail commits `setItemPinned` (the overlay pin escape); reveal/dismiss never * commit anything. @@ -300,7 +300,7 @@ class DockRail extends Container { if (typeof me.applyDockZoneOperation === 'function') { result = me.applyDockZoneOperation(descriptor, me) || null } else if (me.dockZoneDocument) { - result = DockZoneModel.applyOperation(me.dockZoneDocument, descriptor) + result = Operations.applyOperation(me.dockZoneDocument, descriptor) } if (!result) { @@ -540,7 +540,7 @@ class DockRail extends Container { /** * Button handler for rail tabs: feeds the reveal machine — click opens a focused transient * reveal, re-click dismisses. No operation is committed here; the persist path is the overlay - * pin ({@link Neo.dashboard.DockRail#onRevealPinRequested}). + * pin ({@link Neo.dashboard.dock.interaction.Rail#onRevealPinRequested}). * @param {Object} data The button click event data; `data.component` is the tab button. * @returns {{revealedItemId:(String|null), state:String}|null} Machine snapshot after the input. */ @@ -634,7 +634,7 @@ class DockRail extends Container { // Runtime namespace lookup avoids an import cycle (the adapter imports this class); // fail-soft to null — the overlay then uses its default fraction. - return document ? (Neo.dashboard?.DockLayoutAdapter?.resolveRevealExtent(document, itemId) ?? null) : null + return document ? (Neo.dashboard?.dock?.projection?.LayoutAdapter?.resolveRevealExtent(document, itemId) ?? null) : null } /** @@ -724,7 +724,7 @@ class DockRail extends Container { // Neither live instance nor blueprint resolves: recoverable placeholder, // never a silently empty overlay — the adapter's own policy. me.revealPaneCache[nextId] = slot.add( - Neo.dashboard?.DockLayoutAdapter?.createPlaceholder?.(nextId, item) ?? + Neo.dashboard?.dock?.projection?.LayoutAdapter?.createPlaceholder?.(nextId, item) ?? {cls: ['neo-dashboard-dock-placeholder'], dockItemId: nextId, ntype: 'component'} ) } @@ -735,4 +735,4 @@ class DockRail extends Container { } } -export default Neo.setupClass(DockRail); +export default Neo.setupClass(Rail); diff --git a/src/dashboard/DockRevealOverlay.mjs b/src/dashboard/dock/interaction/RevealOverlay.mjs similarity index 94% rename from src/dashboard/DockRevealOverlay.mjs rename to src/dashboard/dock/interaction/RevealOverlay.mjs index 989aa6ca61..aa9ea94b75 100644 --- a/src/dashboard/DockRevealOverlay.mjs +++ b/src/dashboard/dock/interaction/RevealOverlay.mjs @@ -1,15 +1,15 @@ -import Button from '../button/Base.mjs'; -import Container from '../container/Base.mjs'; -import DockMotionSignal from './DockMotionSignal.mjs'; -import Label from '../component/Label.mjs'; -import NeoArray from '../util/Array.mjs'; +import Button from '../../../button/Base.mjs'; +import Container from '../../../container/Base.mjs'; +import MotionSignal from '../projection/MotionSignal.mjs'; +import Label from '../../../component/Label.mjs'; +import NeoArray from '../../../util/Array.mjs'; /** * @summary Presentation host for the transient reveal of an auto-hidden dock item — an * edge-anchored overlay that renders OVER the committed projection, never re-layouts it. * * The overlay is pure per-window runtime state: it renders whatever reveal snapshot its owning - * rail pushes (via `DockRail.bindRevealOverlay()`) and translates DOM reality back into semantic + * rail pushes (via `Rail.bindRevealOverlay()`) and translates DOM reality back into semantic * INTENTS the rail's state machine consumes — `revealPointerEnter/Leave`, `revealFocusEnter/Leave`, * `revealEscape`, `revealPinRequested`. It decides nothing itself: timing, focus-hold and policy * all live upstream. It has no write path to any document. @@ -32,13 +32,13 @@ import NeoArray from '../util/Array.mjs'; * (`restorable: false`) — the one honest non-pinnable affordance state, while reveal itself stays * policy-free. * - * @class Neo.dashboard.DockRevealOverlay + * @class Neo.dashboard.dock.interaction.RevealOverlay * @extends Neo.container.Base - * @see Neo.dashboard.DockRail - * @see Neo.dashboard.DockRevealStateMachine + * @see Neo.dashboard.dock.interaction.Rail + * @see Neo.dashboard.dock.interaction.RevealStateMachine * @see learn/agentos/DockZoneModel.md */ -class DockRevealOverlay extends Container { +class RevealOverlay extends Container { /** * Reveal states in which the overlay renders visibly — `dismiss-pending` included: the grace * window is part of the shown lifecycle. @@ -49,10 +49,10 @@ class DockRevealOverlay extends Container { static config = { /** - * @member {String} className='Neo.dashboard.DockRevealOverlay' + * @member {String} className='Neo.dashboard.dock.interaction.RevealOverlay' * @protected */ - className: 'Neo.dashboard.DockRevealOverlay', + className: 'Neo.dashboard.dock.interaction.RevealOverlay', /** * @member {String} ntype='dashboard-dock-reveal-overlay' * @protected @@ -119,7 +119,7 @@ class DockRevealOverlay extends Container { */ revealPaneItemId = null /** - * Whether this overlay currently owns one reveal-animation entry in `DockMotionSignal`. + * Whether this overlay currently owns one reveal-animation entry in `MotionSignal`. * Keeps matching end events, early dismissal, rapid re-entry and teardown idempotent. * @member {Boolean} revealMotionActive=false * @protected @@ -189,7 +189,7 @@ class DockRevealOverlay extends Container { beginRevealMotion() { if (!this.revealMotionActive) { this.revealMotionActive = true; - DockMotionSignal.enter(this) + MotionSignal.enter(this) } } @@ -200,7 +200,7 @@ class DockRevealOverlay extends Container { finishRevealMotion() { if (this.revealMotionActive) { this.revealMotionActive = false; - DockMotionSignal.leave(this) + MotionSignal.leave(this) } } @@ -351,7 +351,7 @@ class DockRevealOverlay extends Container { * @returns {Boolean} */ get visible() { - return DockRevealOverlay.VISIBLE_STATES.has(this.revealState) && !!this.revealedItem + return RevealOverlay.VISIBLE_STATES.has(this.revealState) && !!this.revealedItem } /** @@ -466,4 +466,4 @@ class DockRevealOverlay extends Container { } } -export default Neo.setupClass(DockRevealOverlay); +export default Neo.setupClass(RevealOverlay); diff --git a/src/dashboard/DockRevealStateMachine.mjs b/src/dashboard/dock/interaction/RevealStateMachine.mjs similarity index 96% rename from src/dashboard/DockRevealStateMachine.mjs rename to src/dashboard/dock/interaction/RevealStateMachine.mjs index 08d810f4ae..b886bef582 100644 --- a/src/dashboard/DockRevealStateMachine.mjs +++ b/src/dashboard/dock/interaction/RevealStateMachine.mjs @@ -5,7 +5,7 @@ * This module is deliberately NOT a Neo class: it holds per-window runtime interaction state * (which item is transiently revealed, and why) that must never touch the persisted dock-zone * document. It has no write path to any document — its only outputs are `onChange` - * notifications the owning affordance (`Neo.dashboard.DockRail`) maps to overlay updates and, + * notifications the owning affordance (`Neo.dashboard.dock.interaction.Rail`) maps to overlay updates and, * for the pin escape, to an executor-routed operation OUTSIDE this machine. * * ## States @@ -47,7 +47,7 @@ * Dwell + grace are interaction timings owned here; reveal/dismiss slide durations are animation * timings and live in CSS, not in this machine. */ -class DockRevealStateMachine { +class RevealStateMachine { /** * Hover intent dwell before a reveal fires (opt-in hover mode only). * @member {Number} DWELL_MS=150 @@ -64,16 +64,16 @@ class DockRevealStateMachine { /** * @param {Object} config * @param {Function} [config.clearTimeoutFn=globalThis.clearTimeout] Injectable for fake-timer specs. - * @param {Number} [config.dwellMs=DockRevealStateMachine.DWELL_MS] - * @param {Number} [config.graceMs=DockRevealStateMachine.DISMISS_GRACE_MS] + * @param {Number} [config.dwellMs=RevealStateMachine.DWELL_MS] + * @param {Number} [config.graceMs=RevealStateMachine.DISMISS_GRACE_MS] * @param {Function|null} [config.onChange=null] Receives `(next, previous)` snapshots `{revealedItemId, state}`. * @param {Boolean} [config.revealOnHover=false] Workspace-level opt-in; hover inputs are ignored without it. * @param {Function} [config.setTimeoutFn=globalThis.setTimeout] Injectable for fake-timer specs. */ constructor({clearTimeoutFn, dwellMs, graceMs, onChange, revealOnHover, setTimeoutFn} = {}) { this.clearTimeoutFn = clearTimeoutFn || globalThis.clearTimeout.bind(globalThis); - this.dwellMs = Number.isFinite(dwellMs) ? dwellMs : DockRevealStateMachine.DWELL_MS; - this.graceMs = Number.isFinite(graceMs) ? graceMs : DockRevealStateMachine.DISMISS_GRACE_MS; + this.dwellMs = Number.isFinite(dwellMs) ? dwellMs : RevealStateMachine.DWELL_MS; + this.graceMs = Number.isFinite(graceMs) ? graceMs : RevealStateMachine.DISMISS_GRACE_MS; this.onChange = typeof onChange === 'function' ? onChange : null; this.pendingItemId = null; this.revealOnHover = revealOnHover === true; @@ -261,4 +261,4 @@ class DockRevealStateMachine { } } -export default DockRevealStateMachine; +export default RevealStateMachine; diff --git a/src/dashboard/DockTabEnterButton.mjs b/src/dashboard/dock/interaction/TabEnterButton.mjs similarity index 89% rename from src/dashboard/DockTabEnterButton.mjs rename to src/dashboard/dock/interaction/TabEnterButton.mjs index f551ae4457..b0380e77ed 100644 --- a/src/dashboard/DockTabEnterButton.mjs +++ b/src/dashboard/dock/interaction/TabEnterButton.mjs @@ -1,13 +1,13 @@ -import DockMotionSignal from './DockMotionSignal.mjs'; -import TabHeaderButton from '../tab/header/Button.mjs'; +import MotionSignal from '../projection/MotionSignal.mjs'; +import TabHeaderButton from '../../../tab/header/Button.mjs'; /** * @summary The operation-correlated tab header used for exactly one committed dock `addTab` - * projection. It brackets its CSS entry animation through {@link Neo.dashboard.DockMotionSignal} + * projection. It brackets its CSS entry animation through {@link Neo.dashboard.dock.projection.MotionSignal} * and settles on the root animation end/cancel or teardown. * * This component is intentionally dashboard-owned: the generic {@link Neo.tab.header.Button} - * stays unaware of dock documents and operations. {@link Neo.dashboard.DockLayoutAdapter} selects + * stays unaware of dock documents and operations. {@link Neo.dashboard.dock.projection.LayoutAdapter} selects * this subclass only for the header whose `itemId` + `tabsNodeId` match the transient descriptor * carried by the consuming projection. The correlation never enters the dock document; this * permanent header consumes its one-use classes when the rendered animation settles or collapses. @@ -17,14 +17,14 @@ import TabHeaderButton from '../tab/header/Button.mjs'; * exact non-zero tab-entry animation. The same authority awaits the physical `CSSAnimation` so a * newly born header cannot finish before its local `animationend` listener mounts. A token-collapsed * 0ms animation creates no false signal. End, cancellation, replacement, and destroy settle - * idempotently, with `DockMotionSignal`'s fail-safe remaining the final lost-event backstop. + * idempotently, with `MotionSignal`'s fail-safe remaining the final lost-event backstop. * - * @class Neo.dashboard.DockTabEnterButton + * @class Neo.dashboard.dock.interaction.TabEnterButton * @extends Neo.tab.header.Button - * @see Neo.dashboard.DockLayoutAdapter - * @see Neo.dashboard.DockMotionSignal + * @see Neo.dashboard.dock.projection.LayoutAdapter + * @see Neo.dashboard.dock.projection.MotionSignal */ -class DockTabEnterButton extends TabHeaderButton { +class TabEnterButton extends TabHeaderButton { /** * Whether the rendered style describes this producer's live, non-zero animation. CSS lists * repeat shorter duration lists across animation names; mirror that grammar without owning any @@ -50,10 +50,10 @@ class DockTabEnterButton extends TabHeaderButton { static config = { /** - * @member {String} className='Neo.dashboard.DockTabEnterButton' + * @member {String} className='Neo.dashboard.dock.interaction.TabEnterButton' * @protected */ - className: 'Neo.dashboard.DockTabEnterButton', + className: 'Neo.dashboard.dock.interaction.TabEnterButton', /** * @member {String} ntype='dashboard-dock-tab-enter-button' * @protected @@ -136,7 +136,7 @@ class DockTabEnterButton extends TabHeaderButton { beginTabEnterMotion() { if (!this.tabEnterMotionActive) { this.tabEnterMotionActive = true; - DockMotionSignal.enter(this) + MotionSignal.enter(this) } } @@ -147,7 +147,7 @@ class DockTabEnterButton extends TabHeaderButton { finishTabEnterMotion() { if (this.tabEnterMotionActive) { this.tabEnterMotionActive = false; - DockMotionSignal.leave(this) + MotionSignal.leave(this) } } @@ -176,7 +176,7 @@ class DockTabEnterButton extends TabHeaderButton { } if (me.mounted && !me.isDestroyed && !me.isDestroying && me.hasTabEnterDecoration()) { - if (DockTabEnterButton.hasRenderedTabEnterMotion(styles)) { + if (TabEnterButton.hasRenderedTabEnterMotion(styles)) { me.beginTabEnterMotion(); try { @@ -220,4 +220,4 @@ class DockTabEnterButton extends TabHeaderButton { } } -export default Neo.setupClass(DockTabEnterButton); +export default Neo.setupClass(TabEnterButton); diff --git a/src/dashboard/DockTabSortZone.mjs b/src/dashboard/dock/interaction/TabSortZone.mjs similarity index 98% rename from src/dashboard/DockTabSortZone.mjs rename to src/dashboard/dock/interaction/TabSortZone.mjs index b1703299cb..b765815288 100644 --- a/src/dashboard/DockTabSortZone.mjs +++ b/src/dashboard/dock/interaction/TabSortZone.mjs @@ -1,8 +1,8 @@ -import {createVesselConversionSensor} from './DockVesselConversion.mjs'; -import TabHeaderSortZone from '../draggable/tab/header/toolbar/SortZone.mjs'; +import {createVesselConversionSensor} from '../window/VesselConversion.mjs'; +import TabHeaderSortZone from '../../../draggable/tab/header/toolbar/SortZone.mjs'; /** - * @class Neo.dashboard.DockTabSortZone + * @class Neo.dashboard.dock.interaction.TabSortZone * @extends Neo.draggable.tab.header.toolbar.SortZone * * @summary Dock-aware tab-header SortZone — routes a cross-zone tab drop to a semantic `moveItem`. @@ -38,12 +38,12 @@ import TabHeaderSortZone from '../draggable/tab/header/toolbar/Sort * fully in-window with zero coordinator traffic. This class also implements the contract's three * mandatory source hooks per that same constraint and stamps the cross-window payload * identity at drag start: `dragComponent.dockItemId` + `dragComponent.dockSourceWorkspaceId` — what a - * receiving {@link Neo.dashboard.DockCrossWindowParticipation} needs to discriminate foreign drops + * receiving {@link Neo.dashboard.dock.window.Participation} needs to discriminate foreign drops * and compose `transferItem`. The source NEVER mutates documents on a remote drop: the target side * owns the atomic two-document commit (both workspace documents live on the one App-Worker heap); * this side only suppresses its own in-window drop event so the transfer cannot double-commit. */ -class DockTabSortZone extends TabHeaderSortZone { +class TabSortZone extends TabHeaderSortZone { static config = { /** * Dock header buttons leave normal flow during a gesture, so their measured viewport rects @@ -54,10 +54,10 @@ class DockTabSortZone extends TabHeaderSortZone { */ adjustItemRectsToParent: true, /** - * @member {String} className='Neo.dashboard.DockTabSortZone' + * @member {String} className='Neo.dashboard.dock.interaction.TabSortZone' * @protected */ - className: 'Neo.dashboard.DockTabSortZone', + className: 'Neo.dashboard.dock.interaction.TabSortZone', /** * @member {String} ntype='dock-tab-sortzone' * @protected @@ -113,7 +113,7 @@ class DockTabSortZone extends TabHeaderSortZone { * §2.3 registry identity for the SOURCE side: {@link Neo.manager.DragCoordinator} resolves * remote-target candidates from the source zone's `sortGroup` + the pointer's screen-space * window, so a `null` group short-circuits both coordinator feeds — the dock stays fully - * in-window. Threaded by {@link Neo.dashboard.DockLayoutAdapter} (`crossWindowSortGroup`). + * in-window. Threaded by {@link Neo.dashboard.dock.projection.LayoutAdapter} (`crossWindowSortGroup`). * @member {String|null} sortGroup=null */ sortGroup: null, @@ -623,7 +623,7 @@ class DockTabSortZone extends TabHeaderSortZone { /** * @summary Resolves one stable-claim frame into remote-preview and commit eligibility. * - * This is the production binding for {@link Neo.dashboard.DockVesselConversion}. The manager + * This is the production binding for {@link Neo.dashboard.dock.window.VesselConversion}. The manager * supplies the logical pointer-follow rect plus live target geometry after deterministic claim * arbitration; this dock-owned source resolves its exact live vessel rect, samples the pure * sensor, and returns a synchronous policy record. Raw pointer loss @@ -1367,7 +1367,7 @@ class DockTabSortZone extends TabHeaderSortZone { * @returns {Promise} */ resolveDragCoordinator() { - return this._dragCoordinatorPromise ??= import('../manager/DragCoordinator.mjs').then(module => this.dragCoordinator = module.default) + return this._dragCoordinatorPromise ??= import('../../../manager/DragCoordinator.mjs').then(module => this.dragCoordinator = module.default) } /** @@ -1380,4 +1380,4 @@ class DockTabSortZone extends TabHeaderSortZone { } } -export default Neo.setupClass(DockTabSortZone); +export default Neo.setupClass(TabSortZone); diff --git a/src/dashboard/dock/model/Document.mjs b/src/dashboard/dock/model/Document.mjs new file mode 100644 index 0000000000..d0535af755 --- /dev/null +++ b/src/dashboard/dock/model/Document.mjs @@ -0,0 +1,965 @@ +import Base from '../../../core/Base.mjs'; + +/** + * @class Neo.dashboard.dock.model.Document + * @extends Neo.core.Base + * + * @summary The committed dock-zone document contract: schema keys, validation, normalization, tree helpers, and the fail-closed commit. + * + * Split out of the former monolithic zone model per the graduated v13.2 DockLayouts + * architecture: `model.Document` owns the committed-document contract, `model.Operations` + * owns the semantic reducer vocabulary, `model.Persistence` owns saved-layout envelopes, + * and `persistence.PerspectiveLibrary` is the sole collection/perspective authority. + * Return shape for every operation and envelope helper: `{document|layout, errors}` — + * fail-closed, the input is never partially mutated. + */ +class Document extends Base { + static config = { + /** + * @member {String} className='Neo.dashboard.dock.model.Document' + * @protected + */ + className: 'Neo.dashboard.dock.model.Document' + } + + /** + * The persisted dock-zone document schema this executor operates on. + * @member {String} SCHEMA='neo.dock.zone.v1' + * @static + */ + static SCHEMA = 'neo.dock.zone.v1' + + /** + * Top-level fields allowed in a persisted dock-zone document. + * @member {Set} dockZoneDocumentKeys + * @protected + * @static + */ + static dockZoneDocumentKeys = new Set(['schema', 'root', 'items', 'nodes']) + + /** + * Fields allowed on persisted dock-zone item records. + * @member {Set} dockZoneItemKeys + * @protected + * @static + */ + static dockZoneItemKeys = new Set(['componentRef', 'title', 'kind', 'blueprint', 'closable', 'pinnable', 'pinned', 'autoHidden', 'movable', 'metadata']) + + /** + * Fields allowed on persisted dock-zone nodes, keyed by node type. + * @member {Object>} dockZoneNodeKeys + * @protected + * @static + */ + static dockZoneNodeKeys = { + 'edge-zone': new Set(['type', 'zones']), + split : new Set(['type', 'orientation', 'children', 'sizes']), + tabs : new Set(['type', 'items', 'activeItemId']) + } + + /** + * Runtime-only preview / interaction keys that must never enter committed OR persisted dock-zone + * state (the JSON-first serialization contract). `validate` rejects a document carrying any of + * these ANYWHERE — including inside the opaque `metadata` channel — so they cannot be smuggled + * through a saved layout; `Neo.dashboard.dock.projection.LayoutAdapter` reads this same set at the projection + * boundary, so persistence-rejection and projection-rejection cannot drift. + * @member {Set} forbiddenPreviewKeys + * @protected + * @static + */ + static forbiddenPreviewKeys = new Set([ + 'appName', + 'currentIndex', + 'draggedItem', + 'dockPreview', + 'domRect', + 'DOMRect', + 'groupNodeId', + 'isWindowDragging', + 'placement', + 'pointer', + 'pointerX', + 'pointerY', + 'previewId', + 'sourceSortZone', + 'targetSortZone', + 'windowId' + ]) + + /** + * @summary Recursively finds the first runtime-only preview key ({@link #forbiddenPreviewKeys}) + * anywhere in an arbitrary JSON graph — including nested `metadata` — or null when the graph is + * clean. Both the persistence contract (`validate`) and the render boundary (adapter projection) + * scan through this one finder. + * @param {*} value + * @returns {String|null} + * @protected + * @static + */ + static findForbiddenPreviewKey(value) { + if (!value || typeof value !== 'object') { + return null + } + + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + let match = Document.findForbiddenPreviewKey(value[i]); + + if (match) { + return match + } + } + + return null + } + + for (let key of Object.keys(value)) { + if (Document.forbiddenPreviewKeys.has(key)) { + return key + } + + let match = Document.findForbiddenPreviewKey(value[key]); + + if (match) { + return match + } + } + + return null + } + + /** + * Zone names allowed in an `edge-zone` node. + * @member {Set} dockZoneEdgeKeys + * @protected + * @static + */ + static dockZoneEdgeKeys = new Set(['top', 'right', 'bottom', 'left', 'center']) + + /** + * @summary Type-aware deep clone of a dock-zone document. + * + * Uses `Neo.clone` (deep, ignoring Neo instances) rather than a `JSON.parse(JSON.stringify())` + * round-trip: the round-trip corrupts `Date` values into strings and silently drops `undefined`, + * functions, `Map`/`Set`, and symbol keys, whereas `Neo.clone`'s type map preserves them. + * @param {Object} document + * @returns {Object} + * @static + */ + static clone(document) { + return Neo.clone(document, true, true) + } + + /** + * @summary Returns true for JSON object records only. + * @param {*} value + * @returns {Boolean} + * @protected + * @static + */ + static isJsonRecord(value) { + return value !== null && + typeof value === 'object' && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) + } + + /** + * @summary Returns the first value that cannot round-trip as JSON. + * @param {*} value + * @param {String} [path='value'] + * @param {WeakSet} [seen=new WeakSet()] + * @returns {{path:String, reason:String}|null} + * @protected + * @static + */ + static findNonJsonValue(value, path='value', seen=new WeakSet()) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return null + } + + if (typeof value === 'number') { + return Number.isFinite(value) ? null : {path, reason: 'number must be finite'} + } + + if (typeof value !== 'object') { + return {path, reason: `${typeof value} is not JSON-serializable`} + } + + if (seen.has(value)) { + return {path, reason: 'cyclic object graph is not JSON-serializable'} + } + + seen.add(value); + + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + let match = Document.findNonJsonValue(value[i], `${path}[${i}]`, seen); + + if (match) { + return match + } + } + + return null + } + + if (!Document.isJsonRecord(value)) { + return {path, reason: `${value.constructor?.name || 'object'} is not a JSON record`} + } + + for (const key of Reflect.ownKeys(value)) { + if (typeof key === 'symbol') { + return {path: `${path}.${String(key)}`, reason: 'symbol keys are not JSON-serializable'} + } + + let match = Document.findNonJsonValue(value[key], `${path}.${key}`, seen); + + if (match) { + return match + } + } + + return null + } + + /** + * @summary Returns the first own string key outside a finite schema allowlist. + * @param {Object} record + * @param {Set} allowedKeys + * @param {String} path + * @returns {{key:String, path:String, reason:String}|null} + * @protected + * @static + */ + static findUnexpectedKey(record, allowedKeys, path) { + for (const key of Object.keys(record)) { + if (!allowedKeys.has(key)) { + return {key, path: `${path}.${key}`, reason: 'field is outside the saved-layout schema'} + } + } + + return null + } + + /** + * @summary Returns true when a metadata key name is likely to carry credential material. + * @param {String} key + * @returns {Boolean} + * @protected + * @static + */ + static isSecretMetadataKey(key) { + let normalized = key + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[^a-z0-9]+/gi, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase(); + + return /(^|_)(secret|secrets|token|tokens|credential|credentials|password|passwords|pat|pats)$/.test(normalized) || + /(^|_)(api|auth|session|access|refresh|bridge|github|private|personal_access)_?(key|token|secret|credential|password)$/.test(normalized) + } + + /** + * @summary Returns the first metadata key that looks like credential material. + * @param {*} value + * @param {String} [path='metadata'] + * @returns {{key:String, path:String, reason:String}|null} + * @protected + * @static + */ + static findSecretMetadataKey(value, path='metadata') { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + let match = Document.findSecretMetadataKey(value[i], `${path}[${i}]`); + + if (match) { + return match + } + } + + return null + } + + if (!Document.isJsonRecord(value)) { + return null + } + + for (const [key, child] of Object.entries(value)) { + if (Document.isSecretMetadataKey(key)) { + return {key, path: `${path}.${key}`, reason: 'metadata must not contain credentials or secrets'} + } + + let match = Document.findSecretMetadataKey(child, `${path}.${key}`); + + if (match) { + return match + } + } + + return null + } + + /** + * @summary Returns the first field in a dock-zone document that is outside the persisted schema. + * + * `metadata` and `blueprint` are explicit opaque JSON-only extension points. They are caller-owned + * descriptive/config payloads and must not carry secrets or runtime authority; the helper enforces + * their JSON value shape, while this allowlist rejects runtime fields added beside the known model. + * @param {Object} document + * @param {String} [path='dockZone'] + * @returns {{key:String, path:String, reason:String}|null} + * @protected + * @static + */ + static findUnexpectedDockZoneKey(document, path='dockZone') { + if (!Document.isJsonRecord(document)) { + return null + } + + let unexpected = Document.findUnexpectedKey(document, Document.dockZoneDocumentKeys, path); + + if (unexpected) { + return unexpected + } + + if (Document.isJsonRecord(document.items)) { + for (const [itemId, item] of Object.entries(document.items)) { + if (!Document.isJsonRecord(item)) { + return {key: itemId, path: `${path}.items.${itemId}`, reason: 'item record must be a JSON object'} + } + + unexpected = Document.findUnexpectedKey(item, Document.dockZoneItemKeys, `${path}.items.${itemId}`); + + if (unexpected) { + return unexpected + } + } + } + + if (Document.isJsonRecord(document.nodes)) { + for (const [nodeId, node] of Object.entries(document.nodes)) { + if (!Document.isJsonRecord(node)) { + return {key: nodeId, path: `${path}.nodes.${nodeId}`, reason: 'node record must be a JSON object'} + } + + let allowedNodeKeys = Document.dockZoneNodeKeys[node.type]; + + if (!allowedNodeKeys) { + return {key: 'type', path: `${path}.nodes.${nodeId}.type`, reason: `unsupported dock-zone node type "${node.type}"`} + } + + unexpected = Document.findUnexpectedKey(node, allowedNodeKeys, `${path}.nodes.${nodeId}`); + + if (unexpected) { + return unexpected + } + + if (node.type === 'edge-zone' && Document.isJsonRecord(node.zones)) { + unexpected = Document.findUnexpectedKey(node.zones, Document.dockZoneEdgeKeys, `${path}.nodes.${nodeId}.zones`); + + if (unexpected) { + return unexpected + } + } + } + } + + return null + } + + /** + * @summary Mints a node id not yet present in the document. + * @param {Object} document + * @param {String} prefix + * @returns {String} + * @static + */ + static genId(document, prefix) { + let n = 0, + id; + + do { + id = `${prefix}-${n++}` + } while (document.nodes[id]); + + return id + } + + /** + * @summary Returns the id of the tabs node currently holding `itemId`, or null. + * @param {Object} document + * @param {String} itemId + * @returns {String|null} + * @static + */ + static findContainingTabsId(document, itemId) { + for (const [nodeId, node] of Object.entries(document.nodes)) { + if (node.type === 'tabs' && Array.isArray(node.items) && node.items.includes(itemId)) { + return nodeId + } + } + + return null + } + + /** + * @summary Captures an item's exact tree placement — the stored-position half of + * exact-position reintegration (docking design record §2.8, + * `learn/agentos/decisions/0029-docking-design.md`). + * + * `addTab` appends by default, so a detached item's way back to its ORIGINAL slot exists + * only if this pair was captured while the item was still in the tree — capture happens + * BEFORE the detach commit, restore passes the pair straight into `addTab`'s clamped + * `index`. Fail-closed: an item no tabs node currently holds captures `null` (catalog + * presence is not placement; there is nothing to restore to). + * @param {Object} document + * @param {String} itemId + * @returns {{tabsNodeId: String, index: Number}|null} + * @static + */ + static captureItemPlacement(document, itemId) { + let tabsNodeId = Document.findContainingTabsId(document, itemId), + index = tabsNodeId ? document.nodes[tabsNodeId].items.indexOf(itemId) : -1; + + return index >= 0 ? {tabsNodeId, index} : null + } + + /** + * @summary Finds the parent node id + the slot key pointing at `nodeId`. + * + * For a `split` parent the slot is the child index (Number); for an `edge-zone` parent it is the + * zone key (String). Returns null when `nodeId` is the root or unreferenced. + * @param {Object} document + * @param {String} nodeId + * @returns {{parentId:String, slot:(Number|String)}|null} + * @static + */ + static findParentSlot(document, nodeId) { + for (const [parentId, node] of Object.entries(document.nodes)) { + if (node.type === 'split' && Array.isArray(node.children)) { + const index = node.children.indexOf(nodeId); + if (index > -1) return {parentId, slot: index} + } else if (node.type === 'edge-zone' && node.zones) { + for (const [zone, target] of Object.entries(node.zones)) { + if (target === nodeId) return {parentId, slot: zone} + } + } + } + + return null + } + + /** + * @summary Mutating helper: removes `itemId` from whatever tabs node holds it, fixing activeItemId. + * @param {Object} document the working (already-cloned) document + * @param {String} itemId + * @protected + * @static + */ + static detachFromTabs(document, itemId) { + let tabsId = Document.findContainingTabsId(document, itemId); + + if (!tabsId) return; + + let node = document.nodes[tabsId]; + + node.items = node.items.filter(id => id !== itemId); + + if (node.activeItemId === itemId) { + node.activeItemId = node.items[0] ?? null + } + } + + /** + * @summary Set of node ids reachable from the document root. + * @param {Object} document + * @returns {Set} + * @static + */ + static reachableNodeIds(document) { + let seen = new Set(), + walk = nodeId => { + if (!nodeId || seen.has(nodeId)) return; + + let node = document.nodes[nodeId]; + + if (!node) return; + + seen.add(nodeId); + + if (node.type === 'split') { + (node.children || []).forEach(walk) + } else if (node.type === 'edge-zone') { + Object.values(node.zones || {}).forEach(walk) + } + }; + + walk(document.root); + + return seen + } + + /** + * @summary Mutating helper: unlinks the subtree rooted at `nodeId` from its parent, leaving the + * subtree's nodes in place (an unreferenced subtree the caller re-attaches, or `normalizeTree` + * prunes). A split parent has the child spliced out and its remaining sizes renormalized to sum 1 + * (preserving the survivors' relative ratios); an edge-zone parent has the zone deleted. + * @param {Object} document the working (already-cloned) document + * @param {String} nodeId + * @protected + * @static + */ + static detachNode(document, nodeId) { + let slot = Document.findParentSlot(document, nodeId); + + if (!slot) return; + + let parent = document.nodes[slot.parentId]; + + if (typeof slot.slot === 'number') { + parent.children.splice(slot.slot, 1); + + if (Array.isArray(parent.sizes)) { + parent.sizes.splice(slot.slot, 1); + + let sum = parent.sizes.reduce((total, size) => total + size, 0); + + if (sum > 0) { + parent.sizes = parent.sizes.map(size => size / sum); + + // pin the last ratio to absorb float drift so the survivors sum to exactly 1 + let last = parent.sizes.length - 1; + + if (last > 0) { + parent.sizes[last] = 1 - parent.sizes.slice(0, last).reduce((total, size) => total + size, 0) + } + } + } + } else { + delete parent.zones[slot.slot] + } + } + + /** + * @summary Mutating helper: grafts an already-present subtree root `nodeId` into `document` at + * `targetNodeId` per `placement`. A `{kind: 'tab-into'}` placement merges the moved tabs node's + * items into the target tabs node in order then drops the emptied node; otherwise a split + * placement (`{orientation, position|edge, sizes}`) wraps the target + the moved subtree in a new + * split — the same parent-slot swap `splitNode` performs, generalized from a fresh pane to an + * existing subtree. Assumes `nodeId` is already detached and its nodes are present. Returns the + * (possibly empty) errors — empty means it mutated `document`. + * @param {Object} document the working (already-cloned) document + * @param {String} nodeId the subtree root to attach + * @param {String} targetNodeId the node the placement is relative to + * @param {Object} placement `{kind:'tab-into'}` or `{orientation, position, edge, sizes}` + * @returns {String[]} + * @protected + * @static + */ + static attachNode(document, nodeId, targetNodeId, placement = {}) { + let node = document.nodes[nodeId], + target = document.nodes[targetNodeId]; + + if (!node) return [`unknown node "${nodeId}"`]; + if (!target) return [`unknown target node "${targetNodeId}"`]; + + if (placement.kind === 'tab-into') { + if (node.type !== 'tabs' || target.type !== 'tabs') { + return ['tab-into placement requires both the moved node and the target to be tabs nodes'] + } + + target.items = [...(target.items || []), ...(node.items || [])]; + + if ((target.activeItemId === null || target.activeItemId === undefined) && target.items.length) { + target.activeItemId = target.items[0] + } + + delete document.nodes[nodeId]; + + return [] + } + + if (placement.orientation !== 'horizontal' && placement.orientation !== 'vertical') { + return [`invalid split orientation "${placement.orientation}"`] + } + + let {edge, orientation, position, sizes} = placement, + newSplitId = Document.genId(document, `split-${targetNodeId}`), + ratio = (Array.isArray(sizes) && sizes.length === 2) ? sizes : [0.5, 0.5], + atPosition = position || ((edge === 'top' || edge === 'left') ? 'before' : 'after'), + // Resolve the target's parent BEFORE inserting the new split (which references the target). + parentSlot = Document.findParentSlot(document, targetNodeId); + + document.nodes[newSplitId] = { + type : 'split', + orientation, + children: atPosition === 'before' ? [nodeId, targetNodeId] : [targetNodeId, nodeId], + sizes : ratio + }; + + if (!parentSlot) { + document.root = newSplitId + } else if (typeof parentSlot.slot === 'number') { + document.nodes[parentSlot.parentId].children[parentSlot.slot] = newSplitId + } else { + document.nodes[parentSlot.parentId].zones[parentSlot.slot] = newSplitId + } + + return [] + } + + /** + * @summary Validates a dock-zone document against the contract invariants. + * + * Checks: schema, root presence, reference integrity (split children / edge-zone zones / tabs + * items all resolve), each item appears at most once across the tree, split sizes match child + * count and sum to 1, and `tabs.activeItemId` is null or one of `tabs.items`. + * @param {Object} document + * @returns {String[]} the (possibly empty) list of invariant violations + * @static + */ + static validate(document) { + let errors = []; + + if (!document || typeof document !== 'object') return ['document is not an object']; + if (document.schema !== Document.SCHEMA) errors.push(`schema must be ${Document.SCHEMA}`); + if (!document.nodes || !document.nodes[document.root]) errors.push(`root node "${document.root}" is missing`); + + // Runtime-only preview state is invalid at the model boundary — not just at render projection. + // The scan reaches into the opaque `metadata` channel, so a preview key cannot ride a saved + // layout through createSavedLayout / restoreSavedLayout (both validate through here). + let previewKey = Document.findForbiddenPreviewKey(document); + + if (previewKey) { + errors.push(`runtime-only preview field "${previewKey}" must not enter committed dock-zone state`) + } + + let items = document.items || {}, + nodes = document.nodes || {}, + itemUse = {}; + + for (const [itemId, item] of Object.entries(items)) { + if (Document.isJsonRecord(item) && Object.hasOwn(item, 'pinned') && typeof item.pinned !== 'boolean') { + errors.push(`item "${itemId}" pinned must be a boolean`) + } + + if (Document.isJsonRecord(item) && Object.hasOwn(item, 'autoHidden') && typeof item.autoHidden !== 'boolean') { + errors.push(`item "${itemId}" autoHidden must be a boolean`) + } + + if (Document.isJsonRecord(item) && item.pinned === true && item.autoHidden === true) { + errors.push(`item "${itemId}" cannot be pinned and autoHidden at the same time`) + } + } + + for (const [nodeId, node] of Object.entries(nodes)) { + if (node.type === 'split') { + (node.children || []).forEach(childId => { + if (!nodes[childId]) errors.push(`split "${nodeId}" references missing node "${childId}"`) + }); + + let sizes = node.sizes || []; + + if (sizes.length !== (node.children || []).length) { + errors.push(`split "${nodeId}" sizes length ${sizes.length} != children length ${(node.children || []).length}`) + } else if (sizes.length && Math.abs(sizes.reduce((a, b) => a + b, 0) - 1) > 1e-6) { + errors.push(`split "${nodeId}" sizes do not sum to 1`) + } + } else if (node.type === 'edge-zone') { + Object.values(node.zones || {}).forEach(targetId => { + if (!nodes[targetId]) errors.push(`edge-zone "${nodeId}" references missing node "${targetId}"`) + }) + } else if (node.type === 'tabs') { + (node.items || []).forEach(itemId => { + if (!items[itemId]) errors.push(`tabs "${nodeId}" references missing item "${itemId}"`); + itemUse[itemId] = (itemUse[itemId] || 0) + 1 + }); + + if (node.activeItemId !== null && node.activeItemId !== undefined && !(node.items || []).includes(node.activeItemId)) { + errors.push(`tabs "${nodeId}" activeItemId "${node.activeItemId}" is not one of its items`) + } + } + } + + Object.entries(itemUse).forEach(([itemId, count]) => { + if (count > 1) errors.push(`item "${itemId}" appears ${count} times in the tree (must be at most once)`) + }); + + return errors + } + + /** + * @summary Normalizes a document: collapses empty/redundant structural nodes, repairs split + * sizes, prunes orphaned nodes, and repairs each `tabs.activeItemId`. + * + * An empty tabs or split node is removed from its parent; a split with a single child is replaced + * by that child; split sizes are evened when their count/sum is invalid; nodes unreachable from + * the root are dropped. + * @param {Object} document + * @returns {Object} a normalized clone + * @static + */ + static normalizeTree(document) { + let doc = Document.clone(document); + + const collapse = nodeId => { + let node = doc.nodes[nodeId]; + + if (!node) return nodeId; + + if (node.type === 'split') { + node.children = (node.children || []).map(collapse).filter(id => doc.nodes[id]); + + if (node.children.length === 0) { delete doc.nodes[nodeId]; return null } + if (node.children.length === 1) { + let only = node.children[0]; + delete doc.nodes[nodeId]; + return only + } + + let count = node.children.length; + if (!Array.isArray(node.sizes) || node.sizes.length !== count || Math.abs(node.sizes.reduce((a, b) => a + b, 0) - 1) > 1e-6) { + node.sizes = node.children.map(() => 1 / count) + } + } else if (node.type === 'edge-zone') { + for (const [zone, target] of Object.entries(node.zones || {})) { + let resolved = collapse(target); + if (resolved && doc.nodes[resolved]) { node.zones[zone] = resolved } else { delete node.zones[zone] } + } + } else if (node.type === 'tabs') { + if (!node.items || node.items.length === 0) { delete doc.nodes[nodeId]; return null } + if (node.activeItemId === undefined || (node.activeItemId !== null && !node.items.includes(node.activeItemId))) { + node.activeItemId = node.items[0] + } + } + + return nodeId + }; + + let newRoot = collapse(doc.root); + doc.root = newRoot ?? doc.root; + + // prune nodes unreachable from the (possibly new) root + let reachable = Document.reachableNodeIds(doc); + Object.keys(doc.nodes).forEach(nodeId => { + if (!reachable.has(nodeId)) delete doc.nodes[nodeId] + }); + + return doc + } + + /** + * @summary Normalizes + validates a mutated document; returns it only if valid (fail-closed). + * @param {Object} original the untouched input document + * @param {Object} mutated the working document after a mutation + * @returns {{document:Object, errors:String[]}} + * @protected + * @static + */ + static commit(original, mutated) { + let normalized = Document.normalizeTree(mutated), + errors = Document.validate(normalized); + + return errors.length ? {document: original, errors} : {document: normalized, errors: []} + } + + /** + * @summary Validates and normalizes split-size ratios to sum to 1. + * @param {Array} sizes + * @param {Number} count + * @param {String} splitNodeId + * @returns {{sizes:Number[], errors:String[]}} + * @protected + * @static + */ + static normalizeSplitSizes(sizes, count, splitNodeId) { + let errors = []; + + if (!Array.isArray(sizes)) { + return {sizes: [], errors: ['sizes must be an array']} + } + + if (sizes.length !== count) { + return {sizes: [], errors: [`split "${splitNodeId}" sizes length ${sizes.length} != children length ${count}`]} + } + + for (let i = 0; i < sizes.length; i++) { + let value = sizes[i]; + + if (typeof value !== 'number' || !Number.isFinite(value)) { + errors.push(`split "${splitNodeId}" size ${i} must be a finite number`) + } else if (value <= 0) { + errors.push(`split "${splitNodeId}" size ${i} must be greater than 0`) + } + } + + if (errors.length) { + return {sizes: [], errors} + } + + let total = sizes.reduce((sum, value) => sum + value, 0); + + if (!Number.isFinite(total) || total <= 0) { + return {sizes: [], errors: [`split "${splitNodeId}" sizes must sum to a finite positive value`]} + } + + let normalized = sizes.map(value => value / total); + + if (normalized.length > 1) { + normalized[normalized.length - 1] = 1 - normalized.slice(0, -1).reduce((sum, value) => sum + value, 0) + } + + return {sizes: normalized, errors: []} + } + + /** + * @summary Computes the shape-only fingerprint of a dock-zone document. + * + * The fingerprint describes topology SHAPE — node types, nesting, child arity, zone + * occupancy — and deliberately contains no node ids, item ids, sizes, titles or window + * identity, so two structurally identical layouts fingerprint identically regardless of + * where or when they were captured (the persistence guardrail for `windowFingerprint`). + * Deterministic by construction: child arrays keep document order, edge zones walk in the + * fixed {@link #dockZoneEdgeKeys} order. + * @param {Object} document The committed dock-zone document. + * @returns {{fingerprint:(Object|null), errors:String[]}} + * @static + */ + static computeShapeFingerprint(document) { + let errors = []; + + if (!Document.isJsonRecord(document) || !Document.isJsonRecord(document.nodes)) { + return {fingerprint: null, errors: ['fingerprint requires a document with a nodes record']} + } + + const counts = {'edge-zone': 0, split: 0, tabs: 0}, + visited = new Set(); + + const walk = nodeId => { + const node = document.nodes[nodeId]; + + if (!node) { + errors.push(`fingerprint walk found no node for id "${nodeId}"`); + return '?' + } + + // cycle guard: a node graph that references an ancestor would recurse forever — + // fail closed through the errors path, never a RangeError out of the public API + if (visited.has(nodeId)) { + errors.push(`fingerprint walk detected a cycle at node "${nodeId}"`); + return '?' + } + + visited.add(nodeId); + + counts[node.type] = (counts[node.type] || 0) + 1; + + switch (node.type) { + case 'split': + return `${node.orientation === 'horizontal' ? 'h' : 'v'}(${(node.children || []).map(walk).join(',')})`; + case 'tabs': + return `t${node.items?.length || 0}`; + case 'edge-zone': + return `e{${[...Document.dockZoneEdgeKeys] + .map(zone => node.zones?.[zone] ? `${zone}:${walk(node.zones[zone])}` : '') + .filter(Boolean).join(',')}}`; + default: + errors.push(`fingerprint walk found unsupported node type "${node.type}"`); + return '?' + } + }; + + const shape = walk(document.root); + + if (errors.length) { + return {fingerprint: null, errors} + } + + return { + fingerprint: { + schema : 'neo.dock.shape.v1', + shape, + nodeCounts: counts, + itemCount : Object.keys(document.items || {}).length + }, + errors + } + } + + /** + * @summary Composes per-window shape fingerprints into one whole-topology fingerprint. + * + * Slot ORDER is meaning: the reconciliation of a restored topology maps captured slots onto + * live windows positionally-by-shape, so the composed term preserves input order verbatim. + * Envelope-agnostic by design — whichever record shape the topology capture persists, + * it carries this composition. Fails closed on an empty list, any entry that is not a + * window-shape fingerprint record, and any INCOMPLETE record: the composition consumes + * `itemCount`, and a window fingerprint always emits an integer count ≥ 0, so a missing or + * malformed count is rejected — never defaulted into a fake zero. + * @param {Object[]} windowFingerprints Ordered per-window records from {@link #computeShapeFingerprint}. + * @returns {{fingerprint:(Object|null), errors:String[]}} + * @static + */ + static composeTopologyFingerprint(windowFingerprints) { + let errors = []; + + if (!Array.isArray(windowFingerprints) || windowFingerprints.length < 1) { + return {fingerprint: null, errors: ['topology fingerprint requires a non-empty ordered array of window fingerprints']} + } + + windowFingerprints.forEach((entry, index) => { + if (entry?.schema !== 'neo.dock.shape.v1' || typeof entry.shape !== 'string') { + errors.push(`entry ${index} is not a window shape fingerprint record`) + } else if (!Number.isInteger(entry.itemCount) || entry.itemCount < 0) { + errors.push(`entry ${index} is an incomplete window fingerprint record: itemCount must be an integer >= 0`) + } + }); + + if (errors.length) { + return {fingerprint: null, errors} + } + + return { + fingerprint: { + schema : 'neo.dock.topologyShape.v1', + windowCount: windowFingerprints.length, + shape : `w[${windowFingerprints.map(entry => entry.shape).join('|')}]`, + totalItems : windowFingerprints.reduce((sum, entry) => sum + entry.itemCount, 0) + }, + errors + } + } + + /** + * @summary Resolves a workspace document's transferable STACK ROOT — the explicit source-side + * projection for whole-stack reintegration (docking design record §2.8, + * `learn/agentos/decisions/0029-docking-design.md`). + * + * The canonical vessel document shape is an `edge-zone` ROOT (window chrome) whose `center` + * zone names the subtree holding the vessel's content — so "the whole stack" is the root's + * center child, never the document root itself. Resolving it keeps `transferNode`'s root + * rejection byte-identical: whole-stack transfer is explicit resolution composed with the + * landed two-document executor, and an implicit root transfer stays impossible. + * + * Fail-closed: a missing document, a missing root node, a root that is not an `edge-zone`, + * or a center zone that is absent or names an unknown node all resolve `null` — a document + * that cannot prove its stack root never transfers. + * @param {Object} document a committed dock-zone document + * @returns {String|null} the stack-root node id, or null + * @static + */ + static resolveStackRoot(document) { + let root = document?.nodes?.[document?.root], + centerId; + + if (!root || root.type !== 'edge-zone') { + return null + } + + centerId = root.zones?.center; + + return centerId && document.nodes[centerId] ? centerId : null + } +} + +export default Neo.setupClass(Document); diff --git a/src/dashboard/dock/model/Operations.mjs b/src/dashboard/dock/model/Operations.mjs new file mode 100644 index 0000000000..9f313793e5 --- /dev/null +++ b/src/dashboard/dock/model/Operations.mjs @@ -0,0 +1,530 @@ +import Base from '../../../core/Base.mjs'; +import Document from './Document.mjs'; + +/** + * @class Neo.dashboard.dock.model.Operations + * @extends Neo.core.Base + * + * @summary The semantic operation vocabulary and reducer dispatch over committed dock-zone documents. + * + * Split out of the former monolithic zone model per the graduated v13.2 DockLayouts + * architecture: `model.Document` owns the committed-document contract, `model.Operations` + * owns the semantic reducer vocabulary, `model.Persistence` owns saved-layout envelopes, + * and `persistence.PerspectiveLibrary` is the sole collection/perspective authority. + * Return shape for every operation and envelope helper: `{document|layout, errors}` — + * fail-closed, the input is never partially mutated. + */ +class Operations extends Base { + static config = { + /** + * @member {String} className='Neo.dashboard.dock.model.Operations' + * @protected + */ + className: 'Neo.dashboard.dock.model.Operations' + } + + /** + * Dispatch table for `applyOperation()` — operation name → executor. THE single source of + * the dockZone.v1 semantic vocabulary: `operations` derives from these keys, so an + * operation cannot exist in dispatch without being exported, nor be exported without + * dispatching — the two directions cannot diverge by construction. Handlers share the + * executor signature `(document, descriptor)` and the fail-closed `{document, errors}` + * result contract. The `addTab` entry carries the contract's "addTab or moveItem" + * downgrade: a `tab-*` descriptor dispatches as a move when its item already lives + * in the tree. + * @member {Object} operationHandlers + * @protected + * @static + */ + static operationHandlers = Object.freeze({ + addTab: (document, descriptor) => + Document.findContainingTabsId(document, descriptor.itemId) + ? Operations.moveItem(document, {itemId: descriptor.itemId, targetNodeId: descriptor.tabsNodeId, index: descriptor.index}) + : Operations.addTab(document, descriptor), + applyDocument : (document, descriptor) => Operations.applyDocument(document, descriptor), + moveItem : (document, descriptor) => Operations.moveItem(document, descriptor), + splitNode : (document, descriptor) => Operations.splitNode(document, descriptor), + moveNode : (document, descriptor) => Operations.moveNode(document, descriptor), + resizeSplit : (document, descriptor) => Operations.resizeSplit(document, descriptor), + detachItem : (document, descriptor) => Operations.detachItem(document, descriptor), + closeItem : (document, descriptor) => Operations.closeItem(document, descriptor), + setItemPinned : (document, descriptor) => Operations.setItemPinned(document, descriptor), + setItemAutoHidden: (document, descriptor) => Operations.setItemAutoHidden(document, descriptor), + // transferItem / transferNode are TWO-document operations; their single-document dispatch is a + // fail-closed redirect so each still joins the derived `operations` vocabulary without a + // hand-listed entry. Execute them through the matching two-document Operations method. + transferItem: document => ({document, errors: ['transferItem is a two-document operation; call Operations.transferItem(sourceDocument, targetDocument, descriptor)']}), + transferNode: document => ({document, errors: ['transferNode is a two-document operation; call Operations.transferNode(sourceDocument, targetDocument, descriptor)']}) + }) + + /** + * The semantic operation vocabulary — derived from the dispatch table's keys, never + * hand-listed, so vocabulary and dispatch agree in both directions by construction. + * Consumers that enumerate, validate, or advertise executable operations read this + * export (the Neural Link service tier reads it by reference). Prose surfaces (e.g. + * OpenAPI tool descriptions) remain manual mirrors with NO mechanical guard — they + * update by review discipline. + * @member {ReadonlyArray} operations + * @static + */ + static operations = Object.freeze(Object.keys(Operations.operationHandlers)) + + /** + * @summary Re-applies a whole candidate document through the shared fail-closed commit — the + * generic reverse of any forward operation: the document IS the state, so the honest inverse + * of a mutation (or a mutation burst) is the pre-mutation document, normalized + validated + * exactly like any forward commit. A missing candidate fails closed with the original returned + * untouched; a candidate failing validation never commits, per the `commit()` contract. + * @param {Object} document the committed dock-zone document + * @param {Object} descriptor {document: Object} the candidate document to commit + * @returns {{document:Object, errors:String[]}} + * @static + */ + static applyDocument(document, descriptor = {}) { + return descriptor.document + ? Document.commit(document, descriptor.document) + : {document, errors: ['applyDocument requires a candidate document']} + } + + /** + * @summary Inserts `itemId` into the target tabs node at `index` (relocating it if already in + * the tree) and makes it the active tab. + * @param {Object} document + * @param {Object} args {itemId, tabsNodeId, index} + * @returns {{document:Object, errors:String[]}} + * @static + */ + static addTab(document, {itemId, tabsNodeId, index} = {}) { + if (!document.items?.[itemId]) return {document, errors: [`unknown item "${itemId}"`]}; + if (document.nodes?.[tabsNodeId]?.type !== 'tabs') return {document, errors: [`"${tabsNodeId}" is not a tabs node`]}; + + let doc = Document.clone(document); + + Document.detachFromTabs(doc, itemId); + + let node = doc.nodes[tabsNodeId], + at = Number.isInteger(index) ? Math.max(0, Math.min(index, node.items.length)) : node.items.length; + + node.items.splice(at, 0, itemId); + node.activeItemId = itemId; + + return Document.commit(document, doc) + } + + /** + * @summary Relocates an in-tree `itemId` into the target tabs node at `index`. + * @param {Object} document + * @param {Object} args {itemId, targetNodeId, index} + * @returns {{document:Object, errors:String[]}} + * @static + */ + static moveItem(document, {itemId, targetNodeId, index} = {}) { + if (!Document.findContainingTabsId(document, itemId)) { + return {document, errors: [`item "${itemId}" is not in the tree`]} + } + + return Operations.addTab(document, {itemId, tabsNodeId: targetNodeId, index}) + } + + /** + * @summary Splits `targetNodeId` against a new pane holding `itemId`. + * + * Wraps `itemId` in a fresh single-tab node and replaces `targetNodeId` in its parent with a new + * `split` whose children are `[new, target]` (leading) or `[target, new]` (trailing). When + * `targetNodeId` is the root, the new split becomes the root. + * + * The leading/trailing side comes from an explicit `position` (`before` / `after`) when given; + * otherwise it is derived from the descriptor's `edge` — `top` / `left` lead (before), `bottom` / + * `right` trail (after) — so a `Preview.previewToOperation()` edge descriptor places correctly. + * @param {Object} document + * @param {Object} args {itemId, targetNodeId, orientation, position, sizes, edge} + * @returns {{document:Object, errors:String[]}} + * @static + */ + static splitNode(document, {edge, itemId, orientation, position, sizes, targetNodeId} = {}) { + if (!document.items?.[itemId]) return {document, errors: [`unknown item "${itemId}"`]}; + if (!document.nodes?.[targetNodeId]) return {document, errors: [`unknown target node "${targetNodeId}"`]}; + if (orientation !== 'horizontal' && orientation !== 'vertical') { + return {document, errors: [`invalid split orientation "${orientation}"`]} + } + + let doc = Document.clone(document); + + Document.detachFromTabs(doc, itemId); + + let newTabsId = Document.genId(doc, `tabs-${itemId}`), + newSplitId = Document.genId(doc, `split-${targetNodeId}`), + ratio = (Array.isArray(sizes) && sizes.length === 2) ? sizes : [0.5, 0.5], + // Edge descriptors encode the side in `edge`, not `position`: top / left lead (before), + // bottom / right trail (after). An explicit `position` always wins. + atPosition = position || ((edge === 'top' || edge === 'left') ? 'before' : 'after'); + + // Resolve the target's parent BEFORE inserting the new split — otherwise the new split + // (which references the target) would be found as the target's own parent. + let parentSlot = Document.findParentSlot(doc, targetNodeId); + + doc.nodes[newTabsId] = {type: 'tabs', items: [itemId], activeItemId: itemId}; + doc.nodes[newSplitId] = { + type : 'split', + orientation, + children: atPosition === 'before' ? [newTabsId, targetNodeId] : [targetNodeId, newTabsId], + // `sizes` maps positionally to `children` in their final order; the caller + // (Preview.previewToOperation) supplies them already in that order. + sizes : ratio + }; + + if (!parentSlot) { + doc.root = newSplitId + } else if (typeof parentSlot.slot === 'number') { + doc.nodes[parentSlot.parentId].children[parentSlot.slot] = newSplitId + } else { + doc.nodes[parentSlot.parentId].zones[parentSlot.slot] = newSplitId + } + + return Document.commit(document, doc) + } + + /** + * @summary Updates an existing split node's normalized child sizes. + * + * Resizable splitter affordances can pass pixel-derived or ratio-derived positive values. This + * operation normalizes them to the persisted dock-zone ratio contract and commits through the + * same fail-closed path as the rest of the semantic model. + * @param {Object} document + * @param {Object} args {splitNodeId, sizes} + * @returns {{document:Object, errors:String[]}} + * @static + */ + static resizeSplit(document, {splitNodeId, sizes} = {}) { + let split = document.nodes?.[splitNodeId]; + + if (!split) { + return {document, errors: [`unknown split node "${splitNodeId}"`]} + } + + if (split.type !== 'split') { + return {document, errors: [`"${splitNodeId}" is not a split node`]} + } + + let normalized = Document.normalizeSplitSizes(sizes, (split.children || []).length, splitNodeId); + + if (normalized.errors.length) { + return {document, errors: normalized.errors} + } + + let doc = Document.clone(document); + + doc.nodes[splitNodeId].sizes = normalized.sizes; + + return Document.commit(document, doc) + } + + /** + * @summary Removes `itemId` from the tree but preserves its catalog record (for popup/window + * ownership), per the contract's `detachItem`. + * @param {Object} document + * @param {Object} args {itemId} + * @returns {{document:Object, errors:String[]}} + * @static + */ + static detachItem(document, {itemId} = {}) { + if (!Document.findContainingTabsId(document, itemId)) { + return {document, errors: [`item "${itemId}" is not in the tree`]} + } + + let doc = Document.clone(document); + + Document.detachFromTabs(doc, itemId); + + return Document.commit(document, doc) + } + + /** + * @summary Removes a closeable `itemId` from the tree and catalog. When the item was active, + * activates the item at its former index or the preceding item; closing a non-active item + * preserves the surviving activation. An explicit `closable:false` fails closed. + * @param {Object} document + * @param {Object} args {itemId} + * @returns {{document:Object, errors:String[]}} + * @static + */ + static closeItem(document, {itemId} = {}) { + let item = document.items?.[itemId]; + + if (!item) return {document, errors: [`unknown item "${itemId}"`]}; + if (item.closable === false) return {document, errors: [`item "${itemId}" is not closable`]}; + + let tabsNodeId = Document.findContainingTabsId(document, itemId), + closedIndex = tabsNodeId ? document.nodes[tabsNodeId].items.indexOf(itemId) : -1, + wasActive = tabsNodeId ? document.nodes[tabsNodeId].activeItemId === itemId : false, + doc = Document.clone(document); + + Document.detachFromTabs(doc, itemId); + + if (wasActive && tabsNodeId && doc.nodes[tabsNodeId]?.type === 'tabs') { + let node = doc.nodes[tabsNodeId]; + + // When the closed item owned activation, the item now occupying its slot wins; + // closing the last item falls back to its preceding sibling. A surviving active item + // is left untouched. This is semantic model policy, not a projected-index guess. + node.activeItemId = node.items[Math.min(closedIndex, node.items.length - 1)] ?? null + } + + delete doc.items[itemId]; + + return Document.commit(document, doc) + } + + /** + * @summary Updates an item's persisted pin state when its policy permits pinning. + * @param {Object} document + * @param {Object} args {itemId, pinned} + * @returns {{document:Object, errors:String[]}} + * @static + */ + static setItemPinned(document, {itemId, pinned} = {}) { + let item = document.items?.[itemId]; + + if (!item) return {document, errors: [`unknown item "${itemId}"`]}; + if (typeof pinned !== 'boolean') return {document, errors: ['pinned must be a boolean']}; + if (item.pinnable === false) return {document, errors: [`item "${itemId}" is not pinnable`]}; + + let doc = Document.clone(document); + + doc.items[itemId].pinned = pinned; + + if (pinned) { + doc.items[itemId].autoHidden = false + } + + return Document.commit(document, doc) + } + + /** + * @summary Updates an item's persisted auto-hide/collapsed state when its policy permits it. + * @param {Object} document + * @param {Object} args {itemId, autoHidden} + * @returns {{document:Object, errors:String[]}} + * @static + */ + static setItemAutoHidden(document, {itemId, autoHidden} = {}) { + let item = document.items?.[itemId]; + + if (!item) return {document, errors: [`unknown item "${itemId}"`]}; + if (typeof autoHidden !== 'boolean') return {document, errors: ['autoHidden must be a boolean']}; + if (item.pinnable === false) return {document, errors: [`item "${itemId}" is not pinnable`]}; + if (autoHidden && item.pinned === true) return {document, errors: [`item "${itemId}" is pinned and cannot be autoHidden`]}; + + let doc = Document.clone(document); + + doc.items[itemId].autoHidden = autoHidden; + + return Document.commit(document, doc) + } + + /** + * @summary Applies an operation descriptor (the shape `Preview.previewToOperation()` emits) + * to the document, dispatching through {@link #operationHandlers} — the table whose keys ARE + * the exported vocabulary, so dispatch and `operations` cannot diverge. + * + * A `tab-*` descriptor (`operation: 'addTab'`) is dispatched as a move when its item already + * lives in the tree — the contract's "addTab or moveItem" downgrade, carried by the table's + * `addTab` entry. + * @param {Object} document + * @param {Object} descriptor {operation, ...} + * @returns {{document:Object, errors:String[]}} + * @static + */ + static applyOperation(document, descriptor = {}) { + // Own-key lookup only: inherited names ('constructor', '__proto__', …) must reject + // exactly like any unknown operation, never resolve to a prototype member. + const handler = Object.hasOwn(Operations.operationHandlers, descriptor.operation) + ? Operations.operationHandlers[descriptor.operation] + : null; + + return handler + ? handler(document, descriptor) + : {document, errors: [`unknown operation "${descriptor.operation}"`]} + } + + /** + * @summary Atomically transfers `itemId` out of `sourceDocument` and into `targetDocument` in one + * commit-or-neither step: the item is removed from the source tree + catalog and placed into the + * target through the nested `target` placement descriptor. The item record travels verbatim — no + * re-instantiation semantics enter the executor, which operates on documents only. + * + * Fail-closed and atomic: a validation error on EITHER document returns BOTH inputs untouched plus + * a non-empty `errors` array, so a half-transferred item — removed here but not placed there, the + * contract's named violation — can never commit. The nested `target` is dispatched through the + * landed single-document placement path (`addTab` / `splitNode` via {@link #applyOperation}), so + * no second placement grammar is introduced. + * + * The executor is document-centric: `sourceWorkspaceId` / `targetWorkspaceId` are the caller's + * (adapter-tier) resolution keys, used here only to reject a same-workspace transfer — that is a + * `moveItem`, not a transfer. + * @param {Object} sourceDocument the committed dock-zone document the item leaves + * @param {Object} targetDocument the committed dock-zone document the item joins + * @param {Object} descriptor {itemId, sourceWorkspaceId, targetWorkspaceId, target} + * @returns {{sourceDocument:Object, targetDocument:Object, errors:String[]}} + * @static + */ + static transferItem(sourceDocument, targetDocument, {itemId, sourceWorkspaceId, targetWorkspaceId, target} = {}) { + let fail = errors => ({sourceDocument, targetDocument, errors}), + record = sourceDocument?.items?.[itemId]; + + // Preconditions checked against BOTH documents before any mutation (fail-closed). + if (!record) return fail([`unknown item "${itemId}"`]); + if (record.movable === false) return fail([`item "${itemId}" is not movable`]); + if (targetDocument?.items?.[itemId]) return fail([`item "${itemId}" already exists in the target document`]); + if (sourceWorkspaceId !== undefined && sourceWorkspaceId === targetWorkspaceId) { + return fail(['transferItem requires distinct source and target workspaces']) + } + if (!target || (target.operation !== 'addTab' && target.operation !== 'splitNode')) { + return fail(['transferItem target must be an addTab or splitNode descriptor']) + } + + // Source side: drop from the tree (a no-op for an already-detached item) + catalog, then + // normalize + validate through the shared fail-closed commit. + let sourceWorking = Document.clone(sourceDocument); + + Document.detachFromTabs(sourceWorking, itemId); + delete sourceWorking.items[itemId]; + + let sourceResult = Document.commit(sourceDocument, sourceWorking); + + // Target side: insert the verbatim record into the catalog, then place it through the landed + // single-document dispatch (which normalizes + validates the target tree). The transfer's + // `itemId` overrides any id the caller left in the nested descriptor. + let targetWorking = Document.clone(targetDocument); + + targetWorking.items[itemId] = Document.clone(record); + + let targetResult = Operations.applyOperation(targetWorking, {...target, itemId}), + errors = [...sourceResult.errors, ...targetResult.errors]; + + // Commit-or-neither: any error on either side rolls the whole transfer back to both inputs. + if (errors.length) { + return fail(errors) + } + + return {sourceDocument: sourceResult.document, targetDocument: targetResult.document, errors: []} + } + + /** + * @summary Re-parents the subtree rooted at `nodeId` to `targetNodeId` within one document — the + * grouped-drag move. The dock tree already models a group as a `tabs` node, so grouped drag moves + * a NODE, not N items. A `{kind: 'tab-into'}` placement merges the moved tabs node's items into the + * target tabs node in order; otherwise a split placement (`{orientation, position|edge, sizes}`) + * wraps the target + the subtree in a new split. `normalizeTree` restores invariants (collapsing + * the emptied source slot) afterward. + * + * Fail-closed: unknown node/target, moving the root, moving a node onto itself, an invalid + * placement, or moving a node into its OWN subtree (the cycle guard, via the reachable-set walk + * rooted at `nodeId`) all return the document untouched + errors. + * @param {Object} document + * @param {Object} args {nodeId, targetNodeId, placement} + * @returns {{document:Object, errors:String[]}} + * @static + */ + static moveNode(document, {nodeId, targetNodeId, placement = {}} = {}) { + let nodes = document?.nodes || {}; + + if (!nodes[nodeId]) return {document, errors: [`unknown node "${nodeId}"`]}; + if (!nodes[targetNodeId]) return {document, errors: [`unknown target node "${targetNodeId}"`]}; + if (nodeId === targetNodeId) return {document, errors: [`cannot move node "${nodeId}" onto itself`]}; + if (nodeId === document.root) return {document, errors: ['cannot move the root node']}; + + // cycle guard: the target must not live inside the moved subtree (walk rooted AT nodeId) + if (Document.reachableNodeIds({nodes, root: nodeId}).has(targetNodeId)) { + return {document, errors: [`cannot move node "${nodeId}" into its own subtree`]} + } + + let doc = Document.clone(document); + + Document.detachNode(doc, nodeId); + + let errors = Document.attachNode(doc, nodeId, targetNodeId, placement); + + return errors.length ? {document, errors} : Document.commit(document, doc) + } + + /** + * @summary Atomically transfers the subtree rooted at `nodeId` out of `sourceDocument` and into + * `targetDocument` in one commit-or-neither step — the cross-window grouped-drag transfer. It is + * the two-document sibling of `moveNode`: `transferItem` atomicity applied to a whole subtree. The + * subtree's nodes and all its member item records travel verbatim, and it re-homes at + * `target.targetNodeId` per `target.placement` (the `moveNode` attach grammar). Reuses the landed + * atomic path — no second atomicity implementation. + * + * Fail-closed and atomic: any error on either document returns BOTH inputs untouched + a non-empty + * `errors` array. A node-id or member-item-id already present in the target, an unmovable member, + * the root node, a same-workspace transfer, or a placement failure all reject with nothing committed. + * @param {Object} sourceDocument the committed dock-zone document the subtree leaves + * @param {Object} targetDocument the committed dock-zone document the subtree joins + * @param {Object} descriptor {nodeId, sourceWorkspaceId, targetWorkspaceId, target:{targetNodeId, placement}} + * @returns {{sourceDocument:Object, targetDocument:Object, errors:String[]}} + * @static + */ + static transferNode(sourceDocument, targetDocument, {nodeId, sourceWorkspaceId, targetWorkspaceId, target} = {}) { + let fail = errors => ({sourceDocument, targetDocument, errors}), + sourceNodes = sourceDocument?.nodes || {}; + + if (!sourceNodes[nodeId]) return fail([`unknown node "${nodeId}"`]); + if (nodeId === sourceDocument.root) return fail(['cannot transfer the root node']); + if (sourceWorkspaceId !== undefined && sourceWorkspaceId === targetWorkspaceId) { + return fail(['transferNode requires distinct source and target workspaces']) + } + if (!target || !targetDocument?.nodes?.[target.targetNodeId]) { + return fail(['transferNode target must name an existing target node']) + } + + // The subtree: its node ids + the member item ids its tabs nodes carry. + let subtreeNodeIds = Document.reachableNodeIds({nodes: sourceNodes, root: nodeId}), + memberItemIds = []; + + subtreeNodeIds.forEach(id => { + if (sourceNodes[id].type === 'tabs') memberItemIds.push(...(sourceNodes[id].items || [])) + }); + + // Preconditions across BOTH documents before any mutation: no node-id or member-id may already + // exist in the target, and every member must be movable. + for (const id of subtreeNodeIds) { + if (targetDocument.nodes?.[id]) return fail([`node "${id}" already exists in the target document`]) + } + for (const itemId of memberItemIds) { + if (sourceDocument.items?.[itemId]?.movable === false) return fail([`item "${itemId}" is not movable`]); + if (targetDocument.items?.[itemId]) return fail([`item "${itemId}" already exists in the target document`]) + } + + // Source side: unlink the subtree, drop its nodes + member records, normalize + validate. + let sourceWorking = Document.clone(sourceDocument); + + Document.detachNode(sourceWorking, nodeId); + subtreeNodeIds.forEach(id => delete sourceWorking.nodes[id]); + memberItemIds.forEach(itemId => delete sourceWorking.items[itemId]); + + let sourceResult = Document.commit(sourceDocument, sourceWorking); + + // Target side: graft the member records + subtree nodes verbatim, then attach the subtree root + // through the shared moveNode placement grammar; normalize + validate. + let targetWorking = Document.clone(targetDocument); + + memberItemIds.forEach(itemId => targetWorking.items[itemId] = Document.clone(sourceDocument.items[itemId])); + subtreeNodeIds.forEach(id => targetWorking.nodes[id] = Document.clone(sourceDocument.nodes[id])); + + let attachErrors = Document.attachNode(targetWorking, nodeId, target.targetNodeId, target.placement || {}), + targetResult = attachErrors.length + ? {document: targetDocument, errors: attachErrors} + : Document.commit(targetDocument, targetWorking), + errors = [...sourceResult.errors, ...targetResult.errors]; + + // Commit-or-neither: any error on either side rolls the whole transfer back to both inputs. + if (errors.length) { + return fail(errors) + } + + return {sourceDocument: sourceResult.document, targetDocument: targetResult.document, errors: []} + } +} + +export default Neo.setupClass(Operations); diff --git a/src/dashboard/dock/model/Persistence.mjs b/src/dashboard/dock/model/Persistence.mjs new file mode 100644 index 0000000000..bf69b934fe --- /dev/null +++ b/src/dashboard/dock/model/Persistence.mjs @@ -0,0 +1,402 @@ +import Base from '../../../core/Base.mjs'; +import Document from './Document.mjs'; + +/** + * @class Neo.dashboard.dock.model.Persistence + * @extends Neo.core.Base + * + * @summary Saved-layout envelope authority: perspective capture, wrapper validation, and restore for single layouts. + * + * Split out of the former monolithic zone model per the graduated v13.2 DockLayouts + * architecture: `model.Document` owns the committed-document contract, `model.Operations` + * owns the semantic reducer vocabulary, `model.Persistence` owns saved-layout envelopes, + * and `persistence.PerspectiveLibrary` is the sole collection/perspective authority. + * Return shape for every operation and envelope helper: `{document|layout, errors}` — + * fail-closed, the input is never partially mutated. + */ +class Persistence extends Base { + static config = { + /** + * @member {String} className='Neo.dashboard.dock.model.Persistence' + * @protected + */ + className: 'Neo.dashboard.dock.model.Persistence' + } + + /** + * The saved layout wrapper schema around a normalized dock-zone document, carrying the + * perspective fields (`captureScope`, `windowFingerprint`, `perspectiveName`). One greenfield + * revision: readers fail closed on every other schema string — no legacy family, no + * migration reader, no alias survives the v13.2 hard cut. + * @member {String} LAYOUT_SCHEMA='neo.dock.layout.v1' + * @static + */ + static LAYOUT_SCHEMA = 'neo.dock.layout.v1' + + /** + * The capture scopes a saved layout may declare: one window's dock document, or the whole + * multi-window topology. + * @member {String[]} CAPTURE_SCOPES + * @static + */ + static CAPTURE_SCOPES = ['window', 'topology'] + + /** + * Top-level fields allowed in a saved-layout wrapper. + * @member {Set} savedLayoutKeys + * @protected + * @static + */ + static savedLayoutKeys = new Set([ + 'schema', 'layoutId', 'title', 'dockZone', 'metadata', 'revision', + 'captureScope', 'windowFingerprint', 'perspectiveName', 'windowDocuments' + ]) + + /** + * @summary Validates the perspective fields shared by the create and restore paths. + * + * `captureScope` must be one of {@link #CAPTURE_SCOPES}; `windowFingerprint` describes + * topology SHAPE only and must be a JSON object or null (never window ids or coordinates — + * the persistence guardrail); `perspectiveName`, when present, must be a non-empty string. + * @param {Object} layout The saved-layout record carrying the perspective fields. + * @returns {String[]} Validation errors, empty when the fields are contract-clean. + * @static + */ + static validatePerspectiveFields(layout) { + let errors = []; + + if (!Persistence.CAPTURE_SCOPES.includes(layout.captureScope)) { + errors.push(`captureScope must be one of: ${Persistence.CAPTURE_SCOPES.join(', ')}`) + } + + if (layout.windowFingerprint !== null && !Document.isJsonRecord(layout.windowFingerprint)) { + errors.push('windowFingerprint must be a JSON object or null') + } + + if (Object.hasOwn(layout, 'perspectiveName') && + (typeof layout.perspectiveName !== 'string' || !layout.perspectiveName.trim()) + ) { + errors.push('perspectiveName must be a non-empty string when present') + } + + // windowDocuments carries the ADDITIONAL windows' trees (slots 1..N; slot 0 stays + // `dockZone`, so the degenerate single-window topology record equals a window-scope + // capture by construction). Topology-scope-only: a window-scope record carrying it + // fails closed; every slot tree passes the full dock-zone validation, offender indexed. + if (Object.hasOwn(layout, 'windowDocuments')) { + if (layout.captureScope !== 'topology') { + errors.push('windowDocuments is only valid on captureScope "topology" records') + } else if (!Array.isArray(layout.windowDocuments)) { + errors.push('windowDocuments must be an array of dock-zone documents') + } else { + layout.windowDocuments.forEach((tree, index) => { + const treeErrors = Document.validate(tree); + + if (treeErrors.length) { + errors.push(`windowDocuments[${index}] is not a valid dock-zone document: ${treeErrors[0]}`) + } + + // The finite durable-field boundary applies to EVERY captured slot, not only + // the primary `dockZone` — runtime-bearing fields (window fingerprints, + // rects) must not ride an additional window document into persistence. + const unexpected = Document.findUnexpectedDockZoneKey(tree, `windowDocuments[${index}]`); + + if (unexpected) { + errors.push(`windowDocuments[${index}] contains unexpected field "${unexpected.key}" at ${unexpected.path}: ${unexpected.reason}`) + } + }) + } + } + + return errors + } + + /** + * @summary Captures a whole multi-window topology as ONE v2 saved-layout perspective. + * + * Slot order is meaning: `documents[0]` becomes the primary `dockZone`, the remaining + * slots persist as `windowDocuments` (topology-scope-only), and `windowFingerprint` holds + * the composed topology term — so a single-document topology capture is structurally + * identical to a window-scope capture apart from its declared scope and composed + * fingerprint schema (the degenerate-case identity, asserted in the unit specs). + * + * Fingerprint-coherence by construction (same rule as {@link #capturePerspective}): raw + * inputs are fingerprint-PROBED first purely as the cycle/shape gate (results discarded — + * the writer's normalize pass must never see a cyclic graph), then the composed fingerprint + * derives exclusively from the PERSISTED trees, so it can never describe shapes the record + * does not contain. + * @param {Object[]} documents Ordered committed dock-zone documents, primary first. + * @param {Object} [metadata={}] {layoutId, title, revision, metadata, perspectiveName} + * @returns {{layout:(Object|null), errors:String[]}} + * @static + */ + static captureTopologyPerspective(documents, metadata={}) { + if (!Array.isArray(documents) || documents.length < 1) { + return {layout: null, errors: ['topology capture requires a non-empty ordered array of documents']} + } + + // probe every raw input first — the cycle/shape gate before any recursion-bearing pass + for (let i = 0; i < documents.length; i++) { + const probe = Document.computeShapeFingerprint(documents[i]); + + if (probe.errors.length) { + return {layout: null, errors: probe.errors.map(error => `documents[${i}]: ${error}`)} + } + } + + const written = Persistence.createSavedLayout(documents[0], { + ...metadata, + captureScope : 'topology', + windowFingerprint: null, + ...(documents.length > 1 && { + windowDocuments: documents.slice(1).map(Document.normalizeTree) + }) + }); + + if (written.errors.length) { + return written + } + + // compose from the PERSISTED trees — the primary + the stored slots — never the raw inputs + const persisted = [written.layout.dockZone, ...(written.layout.windowDocuments || [])], + fingerprints = []; + + for (let i = 0; i < persisted.length; i++) { + const {fingerprint, errors} = Document.computeShapeFingerprint(persisted[i]); + + if (errors.length) { + return {layout: null, errors: errors.map(error => `persisted[${i}]: ${error}`)} + } + + fingerprints.push(fingerprint) + } + + const composed = Document.composeTopologyFingerprint(fingerprints); + + if (composed.errors.length) { + return {layout: null, errors: composed.errors} + } + + written.layout.windowFingerprint = composed.fingerprint; + + return written + } + + /** + * @summary Captures the current window's dock document as a v2 saved-layout perspective. + * + * The single-window capture scope: layout truth only enters the record — the committed + * document tree — never render projections, runtime handles or pane-internal state (panes + * are layout-blind, so their internals are not the layout's to save). + * + * Fingerprint-coherence by construction: the wrapper is written FIRST (validate + normalize + * through the one writer path), and the fingerprint is computed from the PERSISTED + * `layout.dockZone` — never the raw input — so the stored fingerprint cannot describe a + * tree the record does not contain (normalization collapses e.g. a single-child split to + * its child; a pre-normalization fingerprint would immortalize the collapsed wrapper). + * @param {Object} document The committed dock-zone document to capture. + * @param {Object} [metadata={}] {layoutId, title, revision, metadata, perspectiveName} + * @returns {{layout:(Object|null), errors:String[]}} + * @static + */ + static capturePerspective(document, metadata={}) { + // pre-probe the RAW input purely as the cycle/shape gate: the writer's normalize pass + // recurses and must never see a cyclic graph; the probe's fingerprint is DISCARDED so + // coherence with the persisted tree is never at risk + const probe = Document.computeShapeFingerprint(document); + + if (probe.errors.length) { + return {layout: null, errors: probe.errors} + } + + const written = Persistence.createSavedLayout(document, { + ...metadata, + captureScope : 'window', + windowFingerprint: null + }); + + if (written.errors.length) { + return written + } + + const {fingerprint, errors} = Document.computeShapeFingerprint(written.layout.dockZone); + + if (errors.length) { + return {layout: null, errors} + } + + written.layout.windowFingerprint = fingerprint; + + return written + } + + /** + * @summary Wraps a valid committed dock-zone document in a JSON-only saved-layout envelope. + * + * The wrapper and dock-zone tree are finite-schema: unknown fields fail closed. The explicit + * `metadata` field is an opaque JSON-only non-secret annotation channel; callers must not place + * credentials or runtime authority inside it. + * @param {Object} document The committed dock-zone document to normalize and wrap. + * @param {Object} [metadata={}] {layoutId, title, revision, metadata, captureScope, windowFingerprint, perspectiveName} + * @returns {{layout:(Object|null), errors:String[]}} + * @static + */ + static createSavedLayout(document, metadata={}) { + if (!Document.isJsonRecord(metadata)) { + return {layout: null, errors: ['metadata must be a JSON object']} + } + + let errors = Document.validate(document); + + if (errors.length) { + return {layout: null, errors} + } + + let unexpectedKey = Document.findUnexpectedDockZoneKey(document, 'document'); + + if (unexpectedKey) { + return { + layout: null, + errors: [`saved layout contains unexpected field "${unexpectedKey.key}" at ${unexpectedKey.path}: ${unexpectedKey.reason}`] + } + } + + let normalized = Document.normalizeTree(document), + layoutId = Object.hasOwn(metadata, 'layoutId') ? metadata.layoutId : 'default', + title = Object.hasOwn(metadata, 'title') ? metadata.title : layoutId, + layout = { + schema : Persistence.LAYOUT_SCHEMA, + layoutId, + title, + dockZone : normalized, + metadata : Object.hasOwn(metadata, 'metadata') ? metadata.metadata : {}, + captureScope : Object.hasOwn(metadata, 'captureScope') ? metadata.captureScope : 'window', + windowFingerprint: Object.hasOwn(metadata, 'windowFingerprint') ? metadata.windowFingerprint : null + }; + + if (Object.hasOwn(metadata, 'revision')) { + layout.revision = metadata.revision + } + + if (Object.hasOwn(metadata, 'perspectiveName')) { + layout.perspectiveName = metadata.perspectiveName + } + + if (Object.hasOwn(metadata, 'windowDocuments')) { + layout.windowDocuments = metadata.windowDocuments + } + + if (typeof layout.layoutId !== 'string' || !layout.layoutId.trim()) { + errors.push('layoutId must be a non-empty string') + } + + if (typeof layout.title !== 'string' || !layout.title.trim()) { + errors.push('title must be a non-empty string') + } + + errors.push(...Persistence.validatePerspectiveFields(layout)) + + if (!Document.isJsonRecord(layout.metadata)) { + errors.push('metadata must be a JSON object') + } + + let secretKey = Document.findSecretMetadataKey(layout.metadata, 'savedLayout.metadata'); + + if (secretKey) { + errors.push(`saved layout metadata contains secret-like field "${secretKey.key}" at ${secretKey.path}: ${secretKey.reason}`) + } + + unexpectedKey = Document.findUnexpectedKey(layout, Persistence.savedLayoutKeys, 'savedLayout') || + Document.findUnexpectedDockZoneKey(layout.dockZone, 'savedLayout.dockZone'); + + if (unexpectedKey) { + errors.push(`saved layout contains unexpected field "${unexpectedKey.key}" at ${unexpectedKey.path}: ${unexpectedKey.reason}`) + } + + let nonJson = Document.findNonJsonValue(layout); + + if (nonJson) { + errors.push(`saved layout ${nonJson.path} is not JSON-only: ${nonJson.reason}`) + } + + return errors.length ? {layout: null, errors} : {layout: Document.clone(layout), errors: []} + } + + /** + * @summary Restores a saved-layout wrapper into a validated dock-zone document. + * + * The wrapper and dock-zone tree must match the finite persisted schema. The explicit `metadata` + * and item `blueprint` fields are opaque JSON-only non-secret payloads; runtime fields beside the + * known model are rejected rather than filtered or repaired. + * @param {Object} savedLayout + * @returns {{document:(Object|null), errors:String[]}} + * @static + */ + static restoreSavedLayout(savedLayout) { + let errors = []; + + if (!Document.isJsonRecord(savedLayout)) { + return {document: null, errors: ['saved layout must be a JSON object']} + } + + if (savedLayout.schema !== Persistence.LAYOUT_SCHEMA) { + errors.push(`schema must be ${Persistence.LAYOUT_SCHEMA}`) + } + + errors.push(...Persistence.validatePerspectiveFields(savedLayout)); + + if (typeof savedLayout.layoutId !== 'string' || !savedLayout.layoutId.trim()) { + errors.push('layoutId must be a non-empty string') + } + + if (typeof savedLayout.title !== 'string' || !savedLayout.title.trim()) { + errors.push('title must be a non-empty string') + } + + if (!Document.isJsonRecord(savedLayout.dockZone)) { + errors.push('dockZone must be a JSON object') + } + + if (Object.hasOwn(savedLayout, 'metadata') && !Document.isJsonRecord(savedLayout.metadata)) { + errors.push('metadata must be a JSON object') + } + + let secretKey = Object.hasOwn(savedLayout, 'metadata') + ? Document.findSecretMetadataKey(savedLayout.metadata, 'savedLayout.metadata') + : null; + + if (secretKey) { + errors.push(`saved layout metadata contains secret-like field "${secretKey.key}" at ${secretKey.path}: ${secretKey.reason}`) + } + + let unexpectedKey = Document.findUnexpectedKey(savedLayout, Persistence.savedLayoutKeys, 'savedLayout') || + Document.findUnexpectedDockZoneKey(savedLayout.dockZone, 'savedLayout.dockZone'); + + if (unexpectedKey) { + errors.push(`saved layout contains unexpected field "${unexpectedKey.key}" at ${unexpectedKey.path}: ${unexpectedKey.reason}`) + } + + let nonJson = Document.findNonJsonValue(savedLayout); + + if (nonJson) { + errors.push(`saved layout ${nonJson.path} is not JSON-only: ${nonJson.reason}`) + } + + if (!errors.length) { + errors.push(...Document.validate(savedLayout.dockZone)); + } + + if (errors.length) { + return {document: null, errors} + } + + let normalized = Document.normalizeTree(savedLayout.dockZone), + normalizedErrors = Document.validate(normalized); + + return normalizedErrors.length + ? {document: null, errors: normalizedErrors} + : {document: Document.clone(normalized), errors: []} + } +} + +export default Neo.setupClass(Persistence); diff --git a/src/dashboard/dockPreviewContract.mjs b/src/dashboard/dock/model/PreviewContract.mjs similarity index 92% rename from src/dashboard/dockPreviewContract.mjs rename to src/dashboard/dock/model/PreviewContract.mjs index 8a14f3ee56..6c1bdbcf1c 100644 --- a/src/dashboard/dockPreviewContract.mjs +++ b/src/dashboard/dock/model/PreviewContract.mjs @@ -1,10 +1,10 @@ /** - * @summary The pure, layer-neutral `neo.harness.dockPreview.v1` contract — the schema constant, the + * @summary The pure, layer-neutral `neo.dock.preview.v1` contract — the schema constant, the * placement-kind vocabulary, structural validation, the preview→operation conversion, and the * split-ratio normalizer. * - * Extracted from the preview renderer (now `Neo.dashboard.DockPreview`) so the three sides of the docking line share ONE source - * of truth without a layer inversion: the producer (`Neo.dashboard.DockPreviewProducer`, src), the + * Extracted from the preview renderer (now `Neo.dashboard.dock.interaction.Preview`) so the three sides of the docking line share ONE source + * of truth without a layer inversion: the producer (`Neo.dashboard.dock.interaction.PreviewProducer`, src), the * renderer (that app-layer view), AND the src-layer drop owners (`examples/dashboard/dock`, the FM * cockpit) — the src/example layers cannot import `apps/`, so the model-semantic half of the * contract had to move down here. Rendering + geometry stay in the view; only the pure half lives @@ -17,17 +17,17 @@ * The dockPreview contract schema every side of the line accepts / emits. * @type {String} */ -export const PREVIEW_SCHEMA = 'neo.harness.dockPreview.v1'; +export const PREVIEW_SCHEMA = 'neo.dock.preview.v1'; /** * The drop-candidate-set schema: the runtime-only payload the producer emits for the * indicator-overlay menu (the full valid-placement menu for one hovered zone plus the - * container edge chips), consumed by `Neo.dashboard.DockDropIndicators`. Every candidate - * wraps a complete, individually valid `neo.harness.dockPreview.v1` payload — a drop on an + * container edge chips), consumed by `Neo.dashboard.dock.interaction.DropIndicators`. Every candidate + * wraps a complete, individually valid `neo.dock.preview.v1` payload — a drop on an * indicator commits through `previewToOperation` exactly like a pointer-inferred preview. * @type {String} */ -export const CANDIDATES_SCHEMA = 'neo.harness.dockCandidates.v1'; +export const CANDIDATES_SCHEMA = 'neo.dock.candidates.v1'; /** * The five cross positions of the indicator menu, in render order. `center` maps to the @@ -56,7 +56,7 @@ export const VALID_PLACEMENT_KINDS = new Set([...EDGE_KINDS, ...SPLIT_KINDS, ... /** * @summary Structural validity gate for a dockPreview object (fail-closed). * - * Returns true only for a well-formed `neo.harness.dockPreview.v1` payload that carries a stable + * Returns true only for a well-formed `neo.dock.preview.v1` payload that carries a stable * `itemId`, a `target.nodeId`, a known `placement.kind`, an accept/reject `feedback.state`, and (for * split placements) a valid `placement.orientation`. A whole-stack gesture may additionally carry * a runtime-only `groupNodeId`; when present it must be a non-empty string. Anything malformed, @@ -128,7 +128,7 @@ function crossPlacementMatchesPosition(position, placement) { /** * @summary Structural validity gate for a dockCandidates set (fail-closed). * - * Returns true only for a COMPLETE, internally coherent `neo.harness.dockCandidates.v1` payload: + * Returns true only for a COMPLETE, internally coherent `neo.dock.candidates.v1` payload: * * - a stable `itemId`, and a hovered `zone` with a node id and a numeric rect; * - a `cross` of EXACTLY the five unique positions (`CROSS_POSITIONS`), every candidate wrapping @@ -214,7 +214,7 @@ export function ratioToSizes(ratio, position) { /** * @summary Converts an ACCEPTED drop preview into a semantic dock-zone operation descriptor, or null. * - * The one authored path from a hover preview to a `DockZoneModel` operation. Invalid previews, + * The one authored path from a hover preview to a `model.Operations` operation. Invalid previews, * `rejected` placements, and non-accepted feedback all yield null (no commit). Item previews emit * `addTab` / `splitNode` as before. A preview carrying `groupNodeId` instead emits one * `transferNode` descriptor whose nested target uses the same tab/split placement grammar — the diff --git a/src/dashboard/DockTopologyDiff.mjs b/src/dashboard/dock/model/TopologyDiff.mjs similarity index 89% rename from src/dashboard/DockTopologyDiff.mjs rename to src/dashboard/dock/model/TopologyDiff.mjs index e4be1a2603..7e3e8e218a 100644 --- a/src/dashboard/DockTopologyDiff.mjs +++ b/src/dashboard/dock/model/TopologyDiff.mjs @@ -1,11 +1,11 @@ -import Base from '../core/Base.mjs'; -import DockZoneModel from './DockZoneModel.mjs'; +import Base from '../../../core/Base.mjs'; +import Document from './Document.mjs'; /** - * @class Neo.dashboard.DockTopologyDiff + * @class Neo.dashboard.dock.model.TopologyDiff * @extends Neo.core.Base * - * @summary Semantic before/after compare for `neo.harness.dockZone.v1` documents. + * @summary Semantic before/after compare for `neo.dock.zone.v1` documents. * * Raw document equality is the wrong assertion tool twice over: too strict (irrelevant field * churn fails a comparison that should pass) and too loose (a moved item can leave two trees @@ -26,17 +26,17 @@ import DockZoneModel from './DockZoneModel.mjs'; * The output is JSON-first and snapshot-stable: every category array is sorted by its primary * key and the walk order is deterministic, so identical inputs produce byte-identical results. * Malformed inputs never throw and never half-diff: both documents pass through the landed - * fail-closed shape gate (`DockZoneModel.computeShapeFingerprint`, which also rejects cyclic + * fail-closed shape gate (`Document.computeShapeFingerprint`, which also rejects cyclic * trees) and any failure returns empty categories plus a non-empty `errors` array naming the * offending side. */ -class DockTopologyDiff extends Base { +class TopologyDiff extends Base { static config = { /** - * @member {String} className='Neo.dashboard.DockTopologyDiff' + * @member {String} className='Neo.dashboard.dock.model.TopologyDiff' * @protected */ - className: 'Neo.dashboard.DockTopologyDiff', + className: 'Neo.dashboard.dock.model.TopologyDiff', /** * @member {String} ntype='dock-topology-diff' * @protected @@ -96,18 +96,18 @@ class DockTopologyDiff extends Base { * @param {Object} before The earlier committed document * @param {Object} after The later committed document * @param {Object} [options] - * @param {Number} [options.sizeEpsilon=DockTopologyDiff.SIZE_EPSILON] Resize tolerance on size fractions + * @param {Number} [options.sizeEpsilon=TopologyDiff.SIZE_EPSILON] Resize tolerance on size fractions * @returns {{moves: Object[], adds: Object[], removes: Object[], resizes: Object[], tabReorders: Object[], autoHideFlips: Object[], unchanged: String[], errors: String[]}} * @static */ - static diffDockDocuments(before, after, {sizeEpsilon = DockTopologyDiff.SIZE_EPSILON} = {}) { + static diffDockDocuments(before, after, {sizeEpsilon = TopologyDiff.SIZE_EPSILON} = {}) { const empty = () => ({moves: [], adds: [], removes: [], resizes: [], tabReorders: [], autoHideFlips: [], unchanged: [], errors: []}), result = empty(), errors = []; [['before', before], ['after', after]].forEach(([side, document]) => { - const gate = DockZoneModel.computeShapeFingerprint(document || {}); + const gate = Document.computeShapeFingerprint(document || {}); gate.errors.forEach(error => { errors.push(`${side} document failed the shape gate: ${error}`) @@ -193,4 +193,4 @@ class DockTopologyDiff extends Base { } } -export default Neo.setupClass(DockTopologyDiff); +export default Neo.setupClass(TopologyDiff); diff --git a/src/dashboard/DockTopologyReconciler.mjs b/src/dashboard/dock/model/TopologyReconciler.mjs similarity index 96% rename from src/dashboard/DockTopologyReconciler.mjs rename to src/dashboard/dock/model/TopologyReconciler.mjs index 08b72cfd0d..c218fbe1ca 100644 --- a/src/dashboard/DockTopologyReconciler.mjs +++ b/src/dashboard/dock/model/TopologyReconciler.mjs @@ -1,6 +1,7 @@ -import Base from '../core/Base.mjs'; -import DockRestorePlanner from './DockRestorePlanner.mjs'; -import DockZoneModel from './DockZoneModel.mjs'; +import Base from '../../../core/Base.mjs'; +import RestorePlanner from '../persistence/RestorePlanner.mjs'; +import Document from './Document.mjs'; +import Persistence from './Persistence.mjs'; /** * @summary Reduces an exact assignment-score fraction to its canonical form. @@ -260,7 +261,7 @@ function createAffinityDetails(captured, live) { * cross-topology leaf owns that path") — this module is that leaf. Reconciliation semantics: * * - **Envelope authority first.** The saved-layout envelope validates through the landed - * `DockZoneModel.restoreSavedLayout()` (schema, `captureScope` ↔ `windowDocuments` coupling, + * `Persistence.restoreSavedLayout()` (schema, `captureScope` ↔ `windowDocuments` coupling, * slot-indexed document validation with the finite durable-field boundary applied to EVERY * slot, primary presence) plus slot-indexed live-document validation and live cross-window * item disjointness. Validation is total: a malformed envelope (e.g. a non-array @@ -294,19 +295,19 @@ function createAffinityDetails(captured, live) { * - **No window creation.** A restore MUST NOT depend on popup permission: this module exposes * no spawning path whatsoever; unmatched live windows keep reference-identical documents. * - * @class Neo.dashboard.DockTopologyReconciler + * @class Neo.dashboard.dock.model.TopologyReconciler * @extends Neo.core.Base - * @see Neo.dashboard.DockRestorePlanner - * @see Neo.dashboard.DockZoneModel + * @see Neo.dashboard.dock.persistence.RestorePlanner + * @see Neo.dashboard.dock.model.Document * @see learn/agentos/DockZoneModel.md */ -class DockTopologyReconciler extends Base { +class TopologyReconciler extends Base { static config = { /** - * @member {String} className='Neo.dashboard.DockTopologyReconciler' + * @member {String} className='Neo.dashboard.dock.model.TopologyReconciler' * @protected */ - className: 'Neo.dashboard.DockTopologyReconciler' + className: 'Neo.dashboard.dock.model.TopologyReconciler' } /** @@ -539,12 +540,12 @@ class DockTopologyReconciler extends Base { // Envelope authority: the landed restore validator owns the wrapper contract — schema, // captureScope ↔ windowDocuments coupling, slot-indexed tree validation, primary document. // Non-throwing by its own contract. - let envelope = DockZoneModel.restoreSavedLayout(savedLayout ?? {}); + let envelope = Persistence.restoreSavedLayout(savedLayout ?? {}); errors.push(...envelope.errors); liveDocuments.forEach((doc, index) => { - DockZoneModel.validate(doc).forEach(error => errors.push(`live document ${index}: ${error}`)) + Document.validate(doc).forEach(error => errors.push(`live document ${index}: ${error}`)) }); // Workspace-global uniqueness is only provable over disjoint live inputs: a duplicate @@ -602,7 +603,7 @@ class DockTopologyReconciler extends Base { let captured = slots[capturedIndex], capturedIds = Object.keys(captured.items || {}), live = liveDocuments[liveIndex], - result = DockRestorePlanner.restoreToward(live, captured), + result = RestorePlanner.restoreToward(live, captured), mode = (result.deferred && result.reason === 'topology-fingerprint-mismatch') ? 'adopt' : (result.deferred || result.errors.length) ? 'stay' : 'incremental'; @@ -686,7 +687,7 @@ class DockTopologyReconciler extends Base { capturedIdSet.has(itemId) || displaced.push({itemId, liveIndex}) }); - documents[liveIndex] = DockZoneModel.clone(captured); + documents[liveIndex] = Document.clone(captured); applied.push({affinity, applied: 0, capturedIndex, liveIndex, mode: 'adopt'}); restored.push(...capturedIds) } else if (mode === 'stay' && result.deferred) { @@ -708,4 +709,4 @@ class DockTopologyReconciler extends Base { } } -export default Neo.setupClass(DockTopologyReconciler); +export default Neo.setupClass(TopologyReconciler); diff --git a/src/dashboard/DockPerspectiveStore.mjs b/src/dashboard/dock/persistence/PerspectiveLibrary.mjs similarity index 60% rename from src/dashboard/DockPerspectiveStore.mjs rename to src/dashboard/dock/persistence/PerspectiveLibrary.mjs index d4e0d4c39a..c9c30c5d62 100644 --- a/src/dashboard/DockPerspectiveStore.mjs +++ b/src/dashboard/dock/persistence/PerspectiveLibrary.mjs @@ -1,6 +1,7 @@ -import Base from '../core/Base.mjs'; -import DockZoneModel from './DockZoneModel.mjs'; -import Observable from '../core/Observable.mjs'; +import Base from '../../../core/Base.mjs'; +import Document from '../model/Document.mjs'; +import Persistence from '../model/Persistence.mjs'; +import Observable from '../../../core/Observable.mjs'; // Prototype-shaped keys are rejected at the write boundary: `layouts[key]` assignment with // '__proto__' mutates the object's prototype instead of adding a record, and inherited @@ -14,7 +15,7 @@ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); * switchable state. * * The store deliberately introduces NO new persisted shape (the docking ADR's anti-anchor): it - * operates on the landed collection schema through `DockZoneModel`'s validators and constructors, + * operates on the landed collection schema through the model tier validators and constructors (Document, Persistence), * and every read or write crosses its boundary as plain JSON clones — no live component refs, no * functions, no window state can enter or leave (guardrail-specced). Mutations are atomic and * fail closed: the CANDIDATE collection validates as a whole before it replaces the current one, @@ -39,10 +40,9 @@ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); * successor is the caller's `replacementName` or the first remaining record (insertion * order), reusing the landed `removeSavedLayout` invariant; removing the last record clears * `activeLayoutId` to null. - * - **Loads migrate honestly.** `loadPerspective` runs the stored record through the landed - * `restoreSavedLayout` seam — legacy v1 records gain the perspective fields with honest - * defaults, invalid records fail closed with the validator's own errors, and the MIGRATED - * record is what the store hands back (and re-commits, so the collection converges forward). + * - **Loads validate fail-closed.** `loadPerspective` runs the stored record through the landed + * `restoreSavedLayout` seam — invalid or foreign-schema records fail closed with the + * validator's own errors; there is no migration reader, so only current-schema records load. * - **Persistence is a caller-injected seam.** The optional `persistenceAdapter` * (`{read(): Promise, write(collection): Promise}`) keeps storage tech app-side * (LocalStorage, files, remote) — the store guarantees only that plain validated JSON crosses @@ -54,19 +54,20 @@ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); * `perspectiveLoaded`, `perspectiveRemoved`, `perspectiveRenamed`, `collectionChange` — each * fires AFTER the atomic commit, carrying plain-JSON payloads only. * - * @class Neo.dashboard.DockPerspectiveStore + * @class Neo.dashboard.dock.persistence.PerspectiveLibrary * @extends Neo.core.Base * @mixes Neo.core.Observable - * @see Neo.dashboard.DockZoneModel + * @see Neo.dashboard.dock.model.Document + * @see Neo.dashboard.dock.model.Persistence * @see learn/agentos/DockZoneModel.md */ -class DockPerspectiveStore extends Base { +class PerspectiveLibrary extends Base { static config = { /** - * @member {String} className='Neo.dashboard.DockPerspectiveStore' + * @member {String} className='Neo.dashboard.dock.persistence.PerspectiveLibrary' * @protected */ - className: 'Neo.dashboard.DockPerspectiveStore', + className: 'Neo.dashboard.dock.persistence.PerspectiveLibrary', /** * @member {String} ntype='dock-perspective-store' * @protected @@ -92,6 +93,294 @@ class DockPerspectiveStore extends Base { */ persistenceAdapter: null } + /** + * The saved layout collection schema for named layout perspectives. One greenfield + * revision; contained layout records validate against the current wrapper schema only. + * @member {String} LAYOUT_COLLECTION_SCHEMA='neo.dock.layoutCollection.v1' + * @static + */ + static LAYOUT_COLLECTION_SCHEMA = 'neo.dock.layoutCollection.v1' + + /** + * Top-level fields allowed in a named saved-layout collection. + * @member {Set} savedLayoutCollectionKeys + * @protected + * @static + */ + static savedLayoutCollectionKeys = new Set(['schema', 'activeLayoutId', 'layouts', 'metadata', 'revision']) + + /** + * @summary Validates a named saved-layout collection and each contained saved-layout wrapper. + * @param {Object} collection + * @returns {String[]} the (possibly empty) list of invariant violations + * @static + */ + static validateSavedLayoutCollection(collection) { + let errors = []; + + if (!Document.isJsonRecord(collection)) { + return ['saved layout collection must be a JSON object'] + } + + if (collection.schema !== PerspectiveLibrary.LAYOUT_COLLECTION_SCHEMA) { + errors.push(`schema must be ${PerspectiveLibrary.LAYOUT_COLLECTION_SCHEMA}`) + } + + if (!Object.hasOwn(collection, 'activeLayoutId')) { + errors.push('activeLayoutId is required') + } else if (collection.activeLayoutId !== null && (typeof collection.activeLayoutId !== 'string' || !collection.activeLayoutId.trim())) { + errors.push('activeLayoutId must be a non-empty string or null') + } + + if (!Document.isJsonRecord(collection.layouts)) { + errors.push('layouts must be a JSON object') + } + + if (Object.hasOwn(collection, 'metadata') && !Document.isJsonRecord(collection.metadata)) { + errors.push('metadata must be a JSON object') + } + + let secretKey = Object.hasOwn(collection, 'metadata') + ? Document.findSecretMetadataKey(collection.metadata, 'layoutCollection.metadata') + : null; + + if (secretKey) { + errors.push(`layout collection metadata contains secret-like field "${secretKey.key}" at ${secretKey.path}: ${secretKey.reason}`) + } + + let unexpectedKey = Document.findUnexpectedKey(collection, PerspectiveLibrary.savedLayoutCollectionKeys, 'layoutCollection'); + + if (unexpectedKey) { + errors.push(`layout collection contains unexpected field "${unexpectedKey.key}" at ${unexpectedKey.path}: ${unexpectedKey.reason}`) + } + + let nonJson = Document.findNonJsonValue(collection, 'layoutCollection'); + + if (nonJson) { + errors.push(`layout collection ${nonJson.path} is not JSON-only: ${nonJson.reason}`) + } + + if (Document.isJsonRecord(collection.layouts)) { + for (const [layoutId, savedLayout] of Object.entries(collection.layouts)) { + if (!layoutId.trim()) { + errors.push('layout keys must be non-empty strings'); + continue + } + + if (!Document.isJsonRecord(savedLayout)) { + errors.push(`layout "${layoutId}" must be a JSON object`); + continue + } + + if (savedLayout.layoutId !== layoutId) { + errors.push(`layout key "${layoutId}" must match saved layout id "${savedLayout.layoutId}"`) + } + + let restored = Persistence.restoreSavedLayout(savedLayout); + + if (restored.errors.length) { + errors.push(...restored.errors.map(error => `layout "${layoutId}": ${error}`)) + } + } + } + + let layoutCount = Document.isJsonRecord(collection.layouts) ? Object.keys(collection.layouts).length : 0; + + if (collection.activeLayoutId === null && layoutCount > 0) { + errors.push('activeLayoutId must name an existing layout when layouts are present') + } else if (typeof collection.activeLayoutId === 'string' && Document.isJsonRecord(collection.layouts) && !Object.hasOwn(collection.layouts, collection.activeLayoutId)) { + errors.push(`activeLayoutId "${collection.activeLayoutId}" does not exist`) + } + + return errors + } + + /** + * @summary Creates a storage-free collection of named saved-layout wrappers. + * @param {Array|Object} [layouts=[]] + * @param {Object} [options={}] {activeLayoutId, metadata, revision} + * @returns {{collection:(Object|null), errors:String[]}} + * @static + */ + static createSavedLayoutCollection(layouts=[], options={}) { + if (!Array.isArray(layouts) && !Document.isJsonRecord(layouts)) { + return {collection: null, errors: ['layouts must be an array or JSON object']} + } + + if (!Document.isJsonRecord(options)) { + return {collection: null, errors: ['options must be a JSON object']} + } + + let collection = { + schema : PerspectiveLibrary.LAYOUT_COLLECTION_SCHEMA, + activeLayoutId: Object.hasOwn(options, 'activeLayoutId') ? options.activeLayoutId : null, + layouts : {}, + metadata : Object.hasOwn(options, 'metadata') ? options.metadata : {} + }, + entries = Array.isArray(layouts) + ? layouts.map((layout, index) => [Document.isJsonRecord(layout) ? layout.layoutId : `index-${index}`, layout]) + : Object.entries(layouts), + errors = []; + + for (const [layoutId, savedLayout] of entries) { + if (typeof layoutId !== 'string' || !layoutId.trim()) { + errors.push('layoutId must be a non-empty string'); + continue + } + + collection.layouts[layoutId] = Document.clone(savedLayout) + } + + if (!Object.hasOwn(options, 'activeLayoutId')) { + collection.activeLayoutId = Object.keys(collection.layouts)[0] ?? null + } + + if (Object.hasOwn(options, 'revision')) { + collection.revision = options.revision + } + + errors.push(...PerspectiveLibrary.validateSavedLayoutCollection(collection)); + + return errors.length + ? {collection: null, errors} + : {collection: Document.clone(collection), errors: []} + } + + /** + * @summary Adds or replaces a saved-layout wrapper in a collection. + * @param {Object} collection + * @param {Object} savedLayout + * @param {Object} [options={}] {activate} + * @returns {{collection:Object, errors:String[]}} + * @static + */ + static upsertSavedLayout(collection, savedLayout, options={}) { + let errors = PerspectiveLibrary.validateSavedLayoutCollection(collection); + + if (errors.length) { + return {collection, errors} + } + + let restored = Persistence.restoreSavedLayout(savedLayout); + + if (restored.errors.length) { + return {collection, errors: restored.errors} + } + + let doc = Document.clone(collection), + layoutId = savedLayout.layoutId; + + doc.layouts[layoutId] = Document.clone(savedLayout); + + if (options?.activate === true || doc.activeLayoutId === null) { + doc.activeLayoutId = layoutId + } + + errors = PerspectiveLibrary.validateSavedLayoutCollection(doc); + + return errors.length ? {collection, errors} : {collection: Document.clone(doc), errors: []} + } + + /** + * @summary Selects the active saved layout by id without restoring it. + * @param {Object} collection + * @param {String} layoutId + * @returns {{collection:Object, errors:String[]}} + * @static + */ + static selectSavedLayout(collection, layoutId) { + let errors = PerspectiveLibrary.validateSavedLayoutCollection(collection); + + if (errors.length) { + return {collection, errors} + } + + if (typeof layoutId !== 'string' || !layoutId.trim()) { + return {collection, errors: ['layoutId must be a non-empty string']} + } + + if (!Object.hasOwn(collection.layouts, layoutId)) { + return {collection, errors: [`layoutId "${layoutId}" does not exist`]} + } + + let doc = Document.clone(collection); + + doc.activeLayoutId = layoutId; + + return {collection: Document.clone(doc), errors: []} + } + + /** + * @summary Removes a saved layout and requires an explicit replacement when removing the active one. + * @param {Object} collection + * @param {Object} args {layoutId, replacementLayoutId} + * @returns {{collection:Object, errors:String[]}} + * @static + */ + static removeSavedLayout(collection, {layoutId, replacementLayoutId} = {}) { + let errors = PerspectiveLibrary.validateSavedLayoutCollection(collection); + + if (errors.length) { + return {collection, errors} + } + + if (typeof layoutId !== 'string' || !layoutId.trim()) { + return {collection, errors: ['layoutId must be a non-empty string']} + } + + if (!Object.hasOwn(collection.layouts, layoutId)) { + return {collection, errors: [`layoutId "${layoutId}" does not exist`]} + } + + let removingActive = collection.activeLayoutId === layoutId; + + if (removingActive) { + if (typeof replacementLayoutId !== 'string' || !replacementLayoutId.trim()) { + return {collection, errors: ['removing the active layout requires replacementLayoutId']} + } + + if (replacementLayoutId === layoutId) { + return {collection, errors: ['replacementLayoutId must differ from the removed layoutId']} + } + + if (!Object.hasOwn(collection.layouts, replacementLayoutId)) { + return {collection, errors: [`replacementLayoutId "${replacementLayoutId}" does not exist`]} + } + } + + let doc = Document.clone(collection); + + delete doc.layouts[layoutId]; + + if (removingActive) { + doc.activeLayoutId = replacementLayoutId + } + + errors = PerspectiveLibrary.validateSavedLayoutCollection(doc); + + return errors.length ? {collection, errors} : {collection: Document.clone(doc), errors: []} + } + + /** + * @summary Restores the active saved-layout wrapper from a named layout collection. + * @param {Object} collection + * @returns {{document:(Object|null), errors:String[]}} + * @static + */ + static restoreActiveSavedLayout(collection) { + let errors = PerspectiveLibrary.validateSavedLayoutCollection(collection); + + if (errors.length) { + return {document: null, errors} + } + + if (typeof collection.activeLayoutId !== 'string' || !Object.hasOwn(collection.layouts, collection.activeLayoutId)) { + return {document: null, errors: ['activeLayoutId must name an existing layout']} + } + + return Persistence.restoreSavedLayout(collection.layouts[collection.activeLayoutId]) + } + /** * Validator errors of the most recent rejected operation or assignment — empty after every @@ -115,7 +404,7 @@ class DockPerspectiveStore extends Base { return null } - let errors = DockZoneModel.validateSavedLayoutCollection(value); + let errors = PerspectiveLibrary.validateSavedLayoutCollection(value); if (errors.length) { this.lastErrors = errors; @@ -123,7 +412,7 @@ class DockPerspectiveStore extends Base { } this.lastErrors = []; - return DockZoneModel.clone(value) + return Document.clone(value) } /** @@ -132,7 +421,7 @@ class DockPerspectiveStore extends Base { * @protected */ afterSetCollection(value, oldValue) { - oldValue !== undefined && this.fire('collectionChange', {collection: DockZoneModel.clone(value)}) + oldValue !== undefined && this.fire('collectionChange', {collection: Document.clone(value)}) } /** @@ -144,7 +433,7 @@ class DockPerspectiveStore extends Base { * @protected */ beforeGetCollection(value) { - return value ? DockZoneModel.clone(value) : value + return value ? Document.clone(value) : value } /** @@ -185,7 +474,7 @@ class DockPerspectiveStore extends Base { getPerspective(name) { let entry = this.resolveEntry(name); - return entry ? {layout: DockZoneModel.clone(entry.layout), layoutId: entry.layoutId} : null + return entry ? {layout: Document.clone(entry.layout), layoutId: entry.layoutId} : null } /** @@ -216,14 +505,14 @@ class DockPerspectiveStore extends Base { */ savePerspective(layout, {replace = false, activate = true} = {}) { let me = this, - validated = DockZoneModel.restoreSavedLayout(layout); + validated = Persistence.restoreSavedLayout(layout); if (validated.errors.length) { me.lastErrors = validated.errors; return {collision: null, errors: validated.errors, layoutId: null, saved: false} } - let record = DockZoneModel.clone(layout), + let record = Document.clone(layout), unsafe = [record.layoutId, record.perspectiveName].filter(key => UNSAFE_KEYS.has(key)); if (unsafe.length) { @@ -258,8 +547,8 @@ class DockPerspectiveStore extends Base { } } - let base = me._collection ?? DockZoneModel.createSavedLayoutCollection([], {}).collection, - candidate = DockZoneModel.clone(base); + let base = me._collection ?? PerspectiveLibrary.createSavedLayoutCollection([], {}).collection, + candidate = Document.clone(base); // an explicit replace retires every previous holder — one name, one record, never two // entries answering to it (in either namespace) @@ -299,24 +588,24 @@ class DockPerspectiveStore extends Base { return {document: null, errors: [`no perspective named "${name}"`], layout: null} } - let restored = DockZoneModel.restoreSavedLayout(entry.layout); + let restored = Persistence.restoreSavedLayout(entry.layout); if (restored.errors.length) { me.lastErrors = restored.errors; return {document: null, errors: restored.errors, layout: null} } - let migrated = DockZoneModel.migrateSavedLayout(DockZoneModel.clone(entry.layout)), - candidate = DockZoneModel.clone(me._collection); + let stored = Document.clone(entry.layout), + candidate = Document.clone(me._collection); - candidate.layouts[entry.layoutId] = migrated; + candidate.layouts[entry.layoutId] = stored; candidate.activeLayoutId = entry.layoutId; if (!me.commit(candidate, 'perspectiveLoaded', {layoutId: entry.layoutId, name})) { return {document: null, errors: me.lastErrors, layout: null} } - return {document: restored.document, errors: [], layout: DockZoneModel.clone(migrated)} + return {document: restored.document, errors: [], layout: Document.clone(stored)} } /** @@ -359,7 +648,7 @@ class DockPerspectiveStore extends Base { } } - let candidate = DockZoneModel.clone(me._collection); + let candidate = Document.clone(me._collection); if (holder) { delete candidate.layouts[holder.layoutId]; @@ -418,7 +707,7 @@ class DockPerspectiveStore extends Base { if (removingActive && siblings.length) { let replacementLayoutId = successorId ?? siblings[0], - result = DockZoneModel.removeSavedLayout(me._collection, {layoutId: entry.layoutId, replacementLayoutId}); + result = PerspectiveLibrary.removeSavedLayout(me._collection, {layoutId: entry.layoutId, replacementLayoutId}); if (result.errors.length) { me.lastErrors = result.errors; @@ -430,7 +719,7 @@ class DockPerspectiveStore extends Base { {errors: me.lastErrors, removed: false} } - let candidate = DockZoneModel.clone(me._collection); + let candidate = Document.clone(me._collection); delete candidate.layouts[entry.layoutId]; @@ -461,7 +750,7 @@ class DockPerspectiveStore extends Base { return {errors: ['nothing to persist: the store holds no collection'], persisted: false} } - let errors = DockZoneModel.validateSavedLayoutCollection(me._collection); + let errors = PerspectiveLibrary.validateSavedLayoutCollection(me._collection); if (errors.length) { me.lastErrors = errors; @@ -469,7 +758,7 @@ class DockPerspectiveStore extends Base { } try { - await me.persistenceAdapter.write(DockZoneModel.clone(me._collection)); + await me.persistenceAdapter.write(Document.clone(me._collection)); return {errors: [], persisted: true} } catch (error) { return {errors: [error?.message || 'the persistence adapter rejected the write'], persisted: false} @@ -500,7 +789,7 @@ class DockPerspectiveStore extends Base { return {errors: [], hydrated: false} } - let errors = DockZoneModel.validateSavedLayoutCollection(payload); + let errors = PerspectiveLibrary.validateSavedLayoutCollection(payload); if (errors.length) { me.lastErrors = errors; @@ -522,7 +811,7 @@ class DockPerspectiveStore extends Base { */ commit(candidate, eventName, payload) { let me = this, - errors = DockZoneModel.validateSavedLayoutCollection(candidate); + errors = PerspectiveLibrary.validateSavedLayoutCollection(candidate); if (errors.length) { me.lastErrors = errors; @@ -535,4 +824,4 @@ class DockPerspectiveStore extends Base { } } -export default Neo.setupClass(DockPerspectiveStore); +export default Neo.setupClass(PerspectiveLibrary); diff --git a/src/dashboard/DockRestorePlanner.mjs b/src/dashboard/dock/persistence/RestorePlanner.mjs similarity index 85% rename from src/dashboard/DockRestorePlanner.mjs rename to src/dashboard/dock/persistence/RestorePlanner.mjs index 71f6248951..a79eb37851 100644 --- a/src/dashboard/DockRestorePlanner.mjs +++ b/src/dashboard/dock/persistence/RestorePlanner.mjs @@ -1,15 +1,16 @@ -import Base from '../core/Base.mjs'; -import DockTopologyDiff from './DockTopologyDiff.mjs'; -import DockZoneModel from './DockZoneModel.mjs'; +import Base from '../../../core/Base.mjs'; +import TopologyDiff from '../model/TopologyDiff.mjs'; +import Document from '../model/Document.mjs'; +import Operations from '../model/Operations.mjs'; /** - * @class Neo.dashboard.DockRestorePlanner + * @class Neo.dashboard.dock.persistence.RestorePlanner * @extends Neo.core.Base * * @summary Plans + applies a same-topology perspective RESTORE through semantic operations. * * Restore is where object permanence must hold: reaching a captured layout must happen via the - * {@link Neo.dashboard.DockZoneModel} executor (`moveItem` / `resizeSplit` / `setItemAutoHidden`), NEVER by + * {@link Neo.dashboard.dock.model.Document} executor (`moveItem` / `resizeSplit` / `setItemAutoHidden`), NEVER by * document replacement — a swap would remount every pane, violating the §2.6 reparent-never-recreate promise * (a restore that flickers every pane is a contract violation wearing a feature's name). * @@ -21,16 +22,16 @@ import DockZoneModel from './DockZoneModel.mjs'; * collapse-safely; the one residual a matching fingerprint cannot rule out — a cycle of single-item nodes * swapping, unsolvable by ordering under per-step normalization — defers structurally. * - * The planner is a PURE fold over `DockTopologyDiff.diffDockDocuments(current, captured)` (direction: + * The planner is a PURE fold over `TopologyDiff.diffDockDocuments(current, captured)` (direction: * current → captured, planning TOWARD the capture). Application is a sequential, fail-closed executor pass. */ -class DockRestorePlanner extends Base { +class RestorePlanner extends Base { static config = { /** - * @member {String} className='Neo.dashboard.DockRestorePlanner' + * @member {String} className='Neo.dashboard.dock.persistence.RestorePlanner' * @protected */ - className: 'Neo.dashboard.DockRestorePlanner' + className: 'Neo.dashboard.dock.persistence.RestorePlanner' } /** @@ -47,8 +48,8 @@ class DockRestorePlanner extends Base { * @static */ static planRestore(current, captured) { - let fpCurrent = DockZoneModel.computeShapeFingerprint(current), - fpCaptured = DockZoneModel.computeShapeFingerprint(captured), + let fpCurrent = Document.computeShapeFingerprint(current), + fpCaptured = Document.computeShapeFingerprint(captured), errors = [...fpCurrent.errors, ...fpCaptured.errors]; if (errors.length) { @@ -67,7 +68,7 @@ class DockRestorePlanner extends Base { } } - let diff = DockTopologyDiff.diffDockDocuments(current, captured); + let diff = TopologyDiff.diffDockDocuments(current, captured); if (diff.errors.length) { return {deferred: false, reason: null, plan: [], surplus: [], errors: diff.errors} @@ -130,7 +131,7 @@ class DockRestorePlanner extends Base { /** * @summary Applies a restore plan sequentially through the executor, fail-closed. * - * Each descriptor runs through {@link Neo.dashboard.DockZoneModel.applyOperation}; the first error stops + * Each descriptor runs through {@link Neo.dashboard.Operations.applyOperation}; the first error stops * application and returns the document as of the last successful step (partial application is visible, * never silent). An empty plan (incl. a deferred plan) is a clean no-op. * @param {Object} document The document to apply the plan onto (the live/current document). @@ -143,7 +144,7 @@ class DockRestorePlanner extends Base { applied = 0; for (const descriptor of plan) { - let result = DockZoneModel.applyOperation(doc, descriptor); + let result = Operations.applyOperation(doc, descriptor); if (result.errors?.length) { return {applied, plan, errors: result.errors, document: doc} @@ -164,16 +165,16 @@ class DockRestorePlanner extends Base { * @static */ static restoreToward(current, captured) { - let {deferred, reason, plan, surplus, errors} = DockRestorePlanner.planRestore(current, captured); + let {deferred, reason, plan, surplus, errors} = RestorePlanner.planRestore(current, captured); if (deferred || errors.length) { return {deferred, reason, applied: 0, plan, surplus, errors, document: current} } - let {applied, errors: applyErrors, document} = DockRestorePlanner.applyRestorePlan(current, plan); + let {applied, errors: applyErrors, document} = RestorePlanner.applyRestorePlan(current, plan); return {deferred: false, reason: null, applied, plan, surplus, errors: applyErrors, document} } } -export default Neo.setupClass(DockRestorePlanner); +export default Neo.setupClass(RestorePlanner); diff --git a/src/dashboard/DockLayoutAdapter.mjs b/src/dashboard/dock/projection/LayoutAdapter.mjs similarity index 96% rename from src/dashboard/DockLayoutAdapter.mjs rename to src/dashboard/dock/projection/LayoutAdapter.mjs index 28a6287b0f..98905aadd9 100644 --- a/src/dashboard/DockLayoutAdapter.mjs +++ b/src/dashboard/dock/projection/LayoutAdapter.mjs @@ -1,10 +1,10 @@ -import Base from '../core/Base.mjs'; -import DockRail from './DockRail.mjs'; -import DockSplitter from './DockSplitter.mjs'; -import DockTabEnterButton from './DockTabEnterButton.mjs'; -import DockTabSortZone from './DockTabSortZone.mjs'; -import DockZoneModel from './DockZoneModel.mjs'; -import TabOverflowPlugin from '../tab/plugin/Overflow.mjs'; +import Base from '../../../core/Base.mjs'; +import Rail from '../interaction/Rail.mjs'; +import DockSplitter from '../interaction/DockSplitter.mjs'; +import TabEnterButton from '../interaction/TabEnterButton.mjs'; +import TabSortZone from '../interaction/TabSortZone.mjs'; +import Document from '../model/Document.mjs'; +import TabOverflowPlugin from '../../../tab/plugin/Overflow.mjs'; // Private runtime restoration slot for live component instances projected through the popup // stack grip. Symbol-keyed so it can never collide with application config or persisted data. @@ -16,17 +16,17 @@ const stackHeaderSource = Symbol('dockStackHeaderSource'); * The adapter consumes committed dock-zone state only. Transient drag preview fields such as `dockPreview`, * pointer coordinates, window geometry, or DOM rectangles belong to the drag/drop pipeline and are rejected here. * - * @class Neo.dashboard.DockLayoutAdapter + * @class Neo.dashboard.dock.projection.LayoutAdapter * @extends Neo.core.Base * @see learn/agentos/DockZoneModel.md */ -class DockLayoutAdapter extends Base { +class LayoutAdapter extends Base { static config = { /** - * @member {String} className='Neo.dashboard.DockLayoutAdapter' + * @member {String} className='Neo.dashboard.dock.projection.LayoutAdapter' * @protected */ - className: 'Neo.dashboard.DockLayoutAdapter' + className: 'Neo.dashboard.dock.projection.LayoutAdapter' } /** @@ -178,7 +178,7 @@ class DockLayoutAdapter extends Base { 'neo-dashboard-dock-tab-enter', `dock-tab-enter-item-${encodeURIComponent(itemId)}` ])], - module: DockTabEnterButton + module: TabEnterButton } } @@ -225,7 +225,7 @@ class DockLayoutAdapter extends Base { * * The grip is runtime projection state, not document state. Its semantic item id makes the * address stable across re-projections so app-owned pointer choreography can target the real - * nested handle instead of bypassing {@link Neo.dashboard.DockTabSortZone#isStackHandleDrag}. + * nested handle instead of bypassing {@link Neo.dashboard.dock.interaction.TabSortZone#isStackHandleDrag}. * @param {String} itemId Stable item identity. * @returns {String|null} * @static @@ -367,10 +367,10 @@ class DockLayoutAdapter extends Base { * @static */ static project(model, options={}) { - let forbiddenKey = DockZoneModel.findForbiddenPreviewKey(model); + let forbiddenKey = Document.findForbiddenPreviewKey(model); if (forbiddenKey) { - throw new Error(`DockLayoutAdapter input must be committed dock-zone model; preview-only field "${forbiddenKey}" is not allowed.`) + throw new Error(`LayoutAdapter input must be committed dock-zone model; preview-only field "${forbiddenKey}" is not allowed.`) } if (!model?.nodes || !model.root) { @@ -378,7 +378,7 @@ class DockLayoutAdapter extends Base { } let stackDragNodeId = options.enableStackDrag === true - ? DockZoneModel.resolveStackRoot(model) + ? Document.resolveStackRoot(model) : null, config = this.projectNode(model.root, { applyDockZoneOperation: options.applyDockZoneOperation, @@ -475,7 +475,7 @@ class DockLayoutAdapter extends Base { } /** - * Projects one auto-hidden item id into the rail-tab metadata `DockRail` renders. + * Projects one auto-hidden item id into the rail-tab metadata `Rail` renders. * * The metadata carries stable `dockItemId` + `dockEdge` so the rail's click (and the follow-up * reveal/pin slice) can address the item semantically, plus the `restorable` policy projection @@ -502,7 +502,7 @@ class DockLayoutAdapter extends Base { } /** - * Projects a set of auto-hidden item ids into a `Neo.dashboard.DockRail` affordance for the + * Projects a set of auto-hidden item ids into a `Neo.dashboard.dock.interaction.Rail` affordance for the * owning edge. * * Mirrors `createSplitterAffordance()`: the reducer callbacks thread from projection context into @@ -526,7 +526,7 @@ class DockLayoutAdapter extends Base { dockZoneDocument : context.dockZoneDocument, edge, flex : 'none', - module : DockRail, + module : Rail, ntype : 'dashboard-dock-rail', onDockZoneDocumentChange: context.onDockZoneDocumentChange, railItems : itemIds.map(itemId => this.createRailTab(itemId, edge, context)), @@ -749,7 +749,7 @@ class DockLayoutAdapter extends Base { let node = context.nodes[nodeId]; if (!node) { - throw new Error(`DockLayoutAdapter could not find dock-zone node "${nodeId}".`) + throw new Error(`LayoutAdapter could not find dock-zone node "${nodeId}".`) } switch (node.type) { @@ -760,7 +760,7 @@ class DockLayoutAdapter extends Base { case 'tabs': return this.projectTabsNode(nodeId, node, context) default: - throw new Error(`DockLayoutAdapter does not support dock-zone node type "${node.type}".`) + throw new Error(`LayoutAdapter does not support dock-zone node type "${node.type}".`) } } @@ -883,7 +883,7 @@ class DockLayoutAdapter extends Base { // new model state: a menu selection routes through the tab.Container's existing activeIndex. plugins : [{module: TabOverflowPlugin}], sortZoneConfig: { - module: DockTabSortZone, + module: TabSortZone, // A dock host spans multiple tab strips. Its composition can therefore name // the app/window boundary whose EXIT means tear-out; retaining the toolbar // fallback keeps consumers that omit the option byte-identical. @@ -995,4 +995,4 @@ class DockLayoutAdapter extends Base { } } -export default Neo.setupClass(DockLayoutAdapter); +export default Neo.setupClass(LayoutAdapter); diff --git a/src/dashboard/DockMotionSignal.mjs b/src/dashboard/dock/projection/MotionSignal.mjs similarity index 95% rename from src/dashboard/DockMotionSignal.mjs rename to src/dashboard/dock/projection/MotionSignal.mjs index cf17160c9c..ec16be177f 100644 --- a/src/dashboard/DockMotionSignal.mjs +++ b/src/dashboard/dock/projection/MotionSignal.mjs @@ -1,4 +1,4 @@ -import Base from '../core/Base.mjs'; +import Base from '../../../core/Base.mjs'; /** * @summary The dock motion-observability signal: `neo-dashboard-dock-animating` lifecycle owner. @@ -32,19 +32,19 @@ import Base from '../core/Base.mjs'; * - **Presentation-only.** Nothing here reads or writes dock documents; the signal is pure * projection-tier state, per the JSON-first guardrail. * - * Timer functions are injectable for deterministic unit specs (the DockRevealStateMachine + * Timer functions are injectable for deterministic unit specs (the RevealStateMachine * precedent); production callers never pass them. * - * @class Neo.dashboard.DockMotionSignal + * @class Neo.dashboard.dock.projection.MotionSignal * @extends Neo.core.Base */ -class DockMotionSignal extends Base { +class MotionSignal extends Base { static config = { /** - * @member {String} className='Neo.dashboard.DockMotionSignal' + * @member {String} className='Neo.dashboard.dock.projection.MotionSignal' * @protected */ - className: 'Neo.dashboard.DockMotionSignal' + className: 'Neo.dashboard.dock.projection.MotionSignal' } /** @@ -154,4 +154,4 @@ class DockMotionSignal extends Base { } } -export default Neo.setupClass(DockMotionSignal); +export default Neo.setupClass(MotionSignal); diff --git a/src/dashboard/DockProjectionReconciler.mjs b/src/dashboard/dock/projection/Reconciler.mjs similarity index 97% rename from src/dashboard/DockProjectionReconciler.mjs rename to src/dashboard/dock/projection/Reconciler.mjs index ea5b329604..1959654d3f 100644 --- a/src/dashboard/DockProjectionReconciler.mjs +++ b/src/dashboard/dock/projection/Reconciler.mjs @@ -1,12 +1,12 @@ -import Component from '../component/Base.mjs'; -import Base from '../core/Base.mjs'; +import Component from '../../../component/Base.mjs'; +import Base from '../../../core/Base.mjs'; const projectionNodeTypes = new Set(['edge-zone', 'split', 'tabs']); /** * @summary Preserves live tab-chrome identity across dock-layout projections. * - * {@link Neo.dashboard.DockLayoutAdapter} deliberately remains a stateless document-to-config + * {@link Neo.dashboard.dock.projection.LayoutAdapter} deliberately remains a stateless document-to-config * projector. This class owns the complementary live-component handoff: it keys projected tab * containers by `dockNodeId`, stages retained ancestors behind geometry-equivalent placeholders, * and moves each pane/button pair before its owning tab container moves. Callers must commit those @@ -16,18 +16,18 @@ const projectionNodeTypes = new Set(['edge-zone', 'split', 'tabs']); * The reconciler owns no workspace document or pane cache. Each docking workspace supplies its * item resolver and controls its own animation and app-specific overflow/menu readiness. * - * @class Neo.dashboard.DockProjectionReconciler + * @class Neo.dashboard.dock.projection.Reconciler * @extends Neo.core.Base - * @see Neo.dashboard.DockLayoutAdapter + * @see Neo.dashboard.dock.projection.LayoutAdapter * @see learn/agentos/DockZoneModel.md */ -class DockProjectionReconciler extends Base { +class Reconciler extends Base { static config = { /** - * @member {String} className='Neo.dashboard.DockProjectionReconciler' + * @member {String} className='Neo.dashboard.dock.projection.Reconciler' * @protected */ - className: 'Neo.dashboard.DockProjectionReconciler' + className: 'Neo.dashboard.dock.projection.Reconciler' } /** @@ -281,7 +281,7 @@ class DockProjectionReconciler extends Base { * @param {Boolean} [options.geometryOnly=false] Explicitly admits strict in-place geometry reconciliation. * @param {Boolean} [options.retainTopology=false] Explicitly admits in-place item reconciliation * only when every structural dock node retains its identity, ancestry, order, and orientation. - * @param {*} options.nextConfig Fresh {@link Neo.dashboard.DockLayoutAdapter} projection. + * @param {*} options.nextConfig Fresh {@link Neo.dashboard.dock.projection.LayoutAdapter} projection. * @param {Map} options.placeholders Item placeholders created by the caller. * @param {Iterable} [options.preserveItemIds=[]] Owner-held panes which are absent * from this projection but must survive without their obsolete tab buttons. @@ -522,7 +522,7 @@ class DockProjectionReconciler extends Base { * * Both shells must be mounted. Cross-tab transfers move each pane and its existing header button * as a pair while their tab-container ancestors remain stationary. The caller commits this layer - * before invoking {@link Neo.dashboard.DockProjectionReconciler.moveRetainedTabChrome}. + * before invoking {@link Neo.dashboard.dock.projection.Reconciler.moveRetainedTabChrome}. * @param {Map} plans * @param {Map} placeholders * @param {Map} currentTabs @@ -758,4 +758,4 @@ class DockProjectionReconciler extends Base { } } -export default Neo.setupClass(DockProjectionReconciler); +export default Neo.setupClass(Reconciler); diff --git a/src/dashboard/CrossWindowDragTarget.mjs b/src/dashboard/dock/window/DragTarget.mjs similarity index 97% rename from src/dashboard/CrossWindowDragTarget.mjs rename to src/dashboard/dock/window/DragTarget.mjs index 8dda22683a..2bcb044ce4 100644 --- a/src/dashboard/CrossWindowDragTarget.mjs +++ b/src/dashboard/dock/window/DragTarget.mjs @@ -1,8 +1,8 @@ -import Base from '../core/Base.mjs'; -import DragCoordinator from '../manager/DragCoordinator.mjs'; +import Base from '../../../core/Base.mjs'; +import DragCoordinator from '../../../manager/DragCoordinator.mjs'; /** - * @class Neo.dashboard.CrossWindowDragTarget + * @class Neo.dashboard.dock.window.DragTarget * @extends Neo.core.Base * * @summary The receiving-window contract implementation for cross-window dock drags — §2.3 of @@ -31,13 +31,13 @@ import DragCoordinator from '../manager/DragCoordinator.mjs'; * job (tree line C3); this target hands the descriptor plus the coordinator's `draggedItem` * to `commitOperation` and stays agnostic of the mapping. */ -class CrossWindowDragTarget extends Base { +class DragTarget extends Base { static config = { /** - * @member {String} className='Neo.dashboard.CrossWindowDragTarget' + * @member {String} className='Neo.dashboard.dock.window.DragTarget' * @protected */ - className: 'Neo.dashboard.CrossWindowDragTarget', + className: 'Neo.dashboard.dock.window.DragTarget', /** * @member {String} ntype='crosswindow-drag-target' * @protected @@ -401,4 +401,4 @@ class CrossWindowDragTarget extends Base { } } -export default Neo.setupClass(CrossWindowDragTarget); +export default Neo.setupClass(DragTarget); diff --git a/src/dashboard/DockCrossWindowParticipation.mjs b/src/dashboard/dock/window/Participation.mjs similarity index 92% rename from src/dashboard/DockCrossWindowParticipation.mjs rename to src/dashboard/dock/window/Participation.mjs index 1095829754..80c2243cc9 100644 --- a/src/dashboard/DockCrossWindowParticipation.mjs +++ b/src/dashboard/dock/window/Participation.mjs @@ -1,9 +1,10 @@ -import Base from '../core/Base.mjs'; -import CrossWindowDragTarget from './CrossWindowDragTarget.mjs'; -import DockZoneModel from './DockZoneModel.mjs'; +import Base from '../../../core/Base.mjs'; +import DragTarget from './DragTarget.mjs'; +import Document from '../model/Document.mjs'; +import Operations from '../model/Operations.mjs'; /** - * @class Neo.dashboard.DockCrossWindowParticipation + * @class Neo.dashboard.dock.window.Participation * @extends Neo.core.Base * * @summary The adapter-tier composition that makes ONE dock workspace a cross-window drag @@ -11,7 +12,7 @@ import DockZoneModel from './DockZoneModel.mjs'; * `learn/agentos/decisions/0029-docking-design.md`) over exclusively LANDED machinery. * * It owns the target registration lifecycle (create on workspace mount, destroy on unmount) and - * binds the owner seams of {@link Neo.dashboard.CrossWindowDragTarget} to the workspace's landed + * binds the owner seams of {@link Neo.dashboard.dock.window.DragTarget} to the workspace's landed * pipeline, adding exactly ONE new decision: foreign-vs-local drop discrimination at commit time. * * - **Local drop** (the payload's source workspace IS this one — two windows may project the @@ -21,7 +22,7 @@ import DockZoneModel from './DockZoneModel.mjs'; * - **Foreign drop** (the item belongs to a sibling window's workspace on the same App-Worker * heap): the converted `addTab`/`splitNode` descriptor becomes the nested `target` of ONE * semantic `transferItem` operation, executed through the landed atomic two-document executor - * ({@link Neo.dashboard.DockZoneModel#transferItem}) — commit-or-neither, item record verbatim, + * ({@link Neo.dashboard.dock.model.Document#transferItem}) — commit-or-neither, item record verbatim, * live component instances move and are never re-instantiated (§2.6). The adapter publishes * those finite documents unchanged. Durable placement intent belongs to the separate topology * hint layer; once that layer exists, its workspace-set owner must join it to the document-pair @@ -32,7 +33,7 @@ import DockZoneModel from './DockZoneModel.mjs'; * preview visuals. The {@link Neo.manager.DragCoordinator} stays dock-blind: all dock semantics * live here and in the seams, never in the coordinator (§2.3 binding invariant). * - * The cross-window drag payload contract (stamped by {@link Neo.dashboard.DockTabSortZone} at + * The cross-window drag payload contract (stamped by {@link Neo.dashboard.dock.interaction.TabSortZone} at * drag start): `draggedItem.dockItemId` names the dock catalog item, and * `draggedItem.dockSourceWorkspaceId` names the workspace document it departs. A whole-stack * source additionally stamps `draggedItem.dockGroupNodeId`; only the source document's @@ -42,13 +43,13 @@ import DockZoneModel from './DockZoneModel.mjs'; * executor's fail-closed collision rejection, not commit the local record) — and a payload * missing either stamp fails closed (no signal-free guessing). */ -class DockCrossWindowParticipation extends Base { +class Participation extends Base { static config = { /** - * @member {String} className='Neo.dashboard.DockCrossWindowParticipation' + * @member {String} className='Neo.dashboard.dock.window.Participation' * @protected */ - className: 'Neo.dashboard.DockCrossWindowParticipation', + className: 'Neo.dashboard.dock.window.Participation', /** * @member {String} ntype='dock-crosswindow-participation' * @protected @@ -180,9 +181,9 @@ class DockCrossWindowParticipation extends Base { } /** - * The registered {@link Neo.dashboard.CrossWindowDragTarget} this participation owns. + * The registered {@link Neo.dashboard.dock.window.DragTarget} this participation owns. * Created in {@link #construct}, destroyed with this instance. - * @member {Neo.dashboard.CrossWindowDragTarget|null} target=null + * @member {Neo.dashboard.dock.window.DragTarget|null} target=null */ target = null @@ -195,7 +196,7 @@ class DockCrossWindowParticipation extends Base { let me = this; - me.target = Neo.create(CrossWindowDragTarget, { + me.target = Neo.create(DragTarget, { clearPreview : me.clearPreview, commitOperation : me.commitDrop.bind(me), dragCoordinator : me.dragCoordinator, @@ -259,7 +260,7 @@ class DockCrossWindowParticipation extends Base { if (!sourceDocument || !me.commitTransfer || operation.operation !== 'transferNode' || operation.nodeId !== groupNodeId || - DockZoneModel.resolveStackRoot(sourceDocument) !== groupNodeId) { + Document.resolveStackRoot(sourceDocument) !== groupNodeId) { return null } @@ -269,7 +270,7 @@ class DockCrossWindowParticipation extends Base { targetWorkspaceId: me.workspaceId }; - const result = DockZoneModel.transferNode(sourceDocument, document, descriptor); + const result = Operations.transferNode(sourceDocument, document, descriptor); if (result.errors.length) { return null @@ -309,7 +310,7 @@ class DockCrossWindowParticipation extends Base { target : operation }; - const result = DockZoneModel.transferItem(sourceDocument, document, descriptor); + const result = Operations.transferItem(sourceDocument, document, descriptor); if (result.errors.length) { return null @@ -338,4 +339,4 @@ class DockCrossWindowParticipation extends Base { } } -export default Neo.setupClass(DockCrossWindowParticipation); +export default Neo.setupClass(Participation); diff --git a/src/dashboard/DockTearOut.mjs b/src/dashboard/dock/window/TearOut.mjs similarity index 98% rename from src/dashboard/DockTearOut.mjs rename to src/dashboard/dock/window/TearOut.mjs index 8d92db3f5e..a2304da431 100644 --- a/src/dashboard/DockTearOut.mjs +++ b/src/dashboard/dock/window/TearOut.mjs @@ -1,9 +1,9 @@ /** - * @module Neo.dashboard.DockTearOut + * @module Neo.dashboard.dock.window.TearOut * @summary The tear-out gesture choreography a dock HOST composes — the admission chain and the * commit-at-terminal routing between the dock sort zone's gesture events and the host's own seams. * - * {@link Neo.dashboard.DockTabSortZone} fires four tear-out gesture events (re-fired boundary + * {@link Neo.dashboard.dock.interaction.TabSortZone} fires four tear-out gesture events (re-fired boundary * hysteresis + the two detached terminals) and owns the drag embodiment; the dock MODEL owns the * document. This module owns what sits between: WHEN a vessel may be acquired, WHEN the one model * commit happens, and WHEN a vessel retires — with every seam injected, so the choreography is a @@ -26,7 +26,7 @@ /** * Creates the four tear-out gesture handlers a dock composition threads into - * {@link Neo.dashboard.DockLayoutAdapter#project} (`onDockTearOutExit` / `Entry` / `Terminal` / + * {@link Neo.dashboard.dock.projection.LayoutAdapter#project} (`onDockTearOutExit` / `Entry` / `Terminal` / * `Cancel`), closed over one single-gesture vessel slot. One handler set serves one workspace * composition — a pointer drives at most one drag per window, so the slot never needs a map. * @param {Object} seams diff --git a/src/dashboard/DockVesselConversion.mjs b/src/dashboard/dock/window/VesselConversion.mjs similarity index 99% rename from src/dashboard/DockVesselConversion.mjs rename to src/dashboard/dock/window/VesselConversion.mjs index c0999eb26d..69a78d82e0 100644 --- a/src/dashboard/DockVesselConversion.mjs +++ b/src/dashboard/dock/window/VesselConversion.mjs @@ -1,5 +1,5 @@ /** - * @module Neo.dashboard.DockVesselConversion + * @module Neo.dashboard.dock.window.VesselConversion * @summary The dual-window conversion decision authority — the pure sensor deciding WHEN a dragged * popup converts into a semi-transparent drag proxy over a target vessel, and when it converts back. * diff --git a/src/dashboard/DockVesselEmbodiment.mjs b/src/dashboard/dock/window/VesselEmbodiment.mjs similarity index 99% rename from src/dashboard/DockVesselEmbodiment.mjs rename to src/dashboard/dock/window/VesselEmbodiment.mjs index 97eb80aed9..426b17d068 100644 --- a/src/dashboard/DockVesselEmbodiment.mjs +++ b/src/dashboard/dock/window/VesselEmbodiment.mjs @@ -1,8 +1,8 @@ -import Component from '../component/Base.mjs'; -import DragProxyContainer from '../draggable/DragProxyContainer.mjs'; +import Component from '../../../component/Base.mjs'; +import DragProxyContainer from '../../../draggable/DragProxyContainer.mjs'; /** - * @module Neo.dashboard.DockVesselEmbodiment + * @module Neo.dashboard.dock.window.VesselEmbodiment * @summary Moves one live dock pane into an admitted tear-out vessel while preserving the source * card slot until document truth decides the gesture terminal. * diff --git a/src/dashboard/DockVesselPark.mjs b/src/dashboard/dock/window/VesselPark.mjs similarity index 99% rename from src/dashboard/DockVesselPark.mjs rename to src/dashboard/dock/window/VesselPark.mjs index 8149a2f9ca..c80a3f9b94 100644 --- a/src/dashboard/DockVesselPark.mjs +++ b/src/dashboard/dock/window/VesselPark.mjs @@ -1,5 +1,5 @@ /** - * @module Neo.dashboard.DockVesselPark + * @module Neo.dashboard.dock.window.VesselPark * @summary The in-gesture vessel lifecycle authority — the pure choreography deciding what happens * to a dragged popup's REAL OS window between conversion and the gesture terminal: park it, never * close it; re-show the SAME window; dispose exactly once, on commit only. @@ -16,7 +16,7 @@ * * The choreography contract this implements (the docking design record, multi-window amendment): * - **The in-gesture segment only.** The conversion DECISION belongs to the companion sensor - * ({@link Neo.dashboard.DockVesselConversion}); the gesture terminals belong to the outcome + * ({@link Neo.dashboard.dock.window.VesselConversion}); the gesture terminals belong to the outcome * machine; the reintegration close POLICY belongs to the host behind the dispose seam. This * machine owns the ordering between them: convert-in → park; convert-out → re-show; terminal → * dispose (commit) or restore (everything else). diff --git a/src/dashboard/DockWorkspaceSet.mjs b/src/dashboard/dock/window/WorkspaceSet.mjs similarity index 100% rename from src/dashboard/DockWorkspaceSet.mjs rename to src/dashboard/dock/window/WorkspaceSet.mjs diff --git a/src/draggable/DragZone.mjs b/src/draggable/DragZone.mjs index 09f5cdf782..96b1c9bc62 100644 --- a/src/draggable/DragZone.mjs +++ b/src/draggable/DragZone.mjs @@ -329,7 +329,7 @@ class DragZone extends Base { config.cls = config.cls || []; // An explicit theme in the proxy config wins: subclasses can resolve a NEAREST-ancestor - // theme (see Neo.dashboard.DockTabSortZone#getDragProxyConfig) — `getTheme()` resolves the + // theme (see Neo.dashboard.dock.interaction.TabSortZone#getDragProxyConfig) — `getTheme()` resolves the // OUTER boot theme, which is wrong for apps that theme-swap an inner root while // `document.body` keeps the boot theme. Pushing both would leave the winner to stylesheet // load order. diff --git a/src/main/addon/DockFlip.mjs b/src/main/addon/DockFlip.mjs index f57069a627..8f957d3179 100644 --- a/src/main/addon/DockFlip.mjs +++ b/src/main/addon/DockFlip.mjs @@ -664,7 +664,7 @@ class DockFlip extends Base { } // the observability signal (`neo-dashboard-dock-animating`) is OWNED by the - // worker-side Neo.dashboard.DockMotionSignal (counted lifecycle) — consumers + // worker-side Neo.dashboard.dock.projection.MotionSignal (counted lifecycle) — consumers // bracket enter/leave around this awaited promise; the addon never toggles it // (cleanup still strips the legacy `dock-animating` class defensively) diff --git a/test/playwright/component/dashboard/DockRailTabPaint.spec.mjs b/test/playwright/component/dashboard/DockRailTabPaint.spec.mjs index 1e8c1411fe..24bd07e0ba 100644 --- a/test/playwright/component/dashboard/DockRailTabPaint.spec.mjs +++ b/test/playwright/component/dashboard/DockRailTabPaint.spec.mjs @@ -46,7 +46,7 @@ test.beforeEach(async ({page}) => { if (!dashboard.success) throw new Error(`dashboard: ${dashboard.error.message}`); const rail = await Neo.worker.App.createNeoInstance({ - importPath: '../dashboard/DockRail.mjs', + importPath: '../dashboard/dock/interaction/Rail.mjs', ntype : 'dashboard-dock-rail', edge : 'left', parentId : dashboard.id, @@ -83,7 +83,7 @@ const applyTheme = (page, theme) => page.evaluate(name => { return document.body.className }, theme); -test.describe('Neo.dashboard.DockRail — rendered rail-tab paint', () => { +test.describe('Neo.dashboard.dock.interaction.Rail — rendered rail-tab paint', () => { for (const theme of THEMES) { test(`the engine's min-width release survives the ${theme} button floor`, async ({page}) => { await applyTheme(page, theme); @@ -308,7 +308,7 @@ test.describe('Neo.dashboard.DockRail — rendered rail-tab paint', () => { } }); -test.describe('Neo.dashboard.DockRail — the revealed tab reads as revealed', () => { +test.describe('Neo.dashboard.dock.interaction.Rail — the revealed tab reads as revealed', () => { /** * The rail's job is answering "which pane is showing". Before this contract every tab painted * identically whether or not it was the one that opened the overlay, so the answer lived only diff --git a/test/playwright/component/dashboard/DockSplitterEquivalence.spec.mjs b/test/playwright/component/dashboard/DockSplitterEquivalence.spec.mjs new file mode 100644 index 0000000000..e4f05bbd0f --- /dev/null +++ b/test/playwright/component/dashboard/DockSplitterEquivalence.spec.mjs @@ -0,0 +1,92 @@ +import {test, expect} from '@playwright/test'; + +/** + * The DockSplitter parent-transition equivalence contract — rendered tier. + * + * Pins the projected config/class surface on a real mounted instance: orientation drives the + * modifier class, the axis dimension pair, and their live re-projection on config change. The + * behavior half of the equivalence contract (capture, vector, the single fail-closed semantic + * commit, terminal events, teardown) lives in the unit sibling + * `test/playwright/unit/dashboard/DockSplitterEquivalence.spec.mjs`, where the gesture handlers + * are driven synthetically with zero DOM-transport variables. Both files must pass UNCHANGED + * after DockSplitter adopts generic `Neo.component.Splitter` for its mechanics. + */ + +let containerId, splitterId; + +const mount = async (page, splitterConfig = {}) => { + const ids = await page.evaluate(async cfg => { + const container = await Neo.worker.App.createNeoInstance({ + importPath: '../container/Base.mjs', + ntype : 'container', + height : 300, + layout : {ntype: 'hbox', align: 'stretch'}, + parentId : 'dock-splitter-test-viewport', + width : 600 + }); + + if (!container.success) throw new Error(`container: ${container.error.message}`); + + const splitter = await Neo.worker.App.createNeoInstance({ + importPath: '../dashboard/dock/interaction/DockSplitter.mjs', + ntype : 'dashboard-dock-splitter', + id : 'equiv-splitter', + parentId : container.id, + ...cfg + }); + + if (!splitter.success) throw new Error(`splitter: ${splitter.error.message}`); + + return {containerId: container.id, splitterId: splitter.id} + }, splitterConfig); + + await page.waitForSelector('#equiv-splitter', {state: 'attached'}); + return ids +}; + +// worker getConfigs returns POSITIONAL values for array keys — zip them into a keyed map +const getConfigs = async (page, id, keys) => { + const values = await page.evaluate( + ({id, keys}) => Neo.worker.App.getConfigs({id, keys}), {id, keys}); + return Object.fromEntries(keys.map((key, index) => [key, values[index]])) +}; + +test.describe('Neo.dashboard.dock.interaction.DockSplitter — rendered equivalence', () => { + test.setTimeout(60000); + test.use({viewport: {height: 800, width: 1200}}); + + test.beforeEach(async ({page}) => { + await page.goto('test/playwright/component/apps/dock-splitter/index.html'); + await page.waitForSelector('#dock-splitter-test-viewport', {state: 'attached'}) + }); + + test.afterEach(async ({page}) => { + containerId && await page.evaluate(id => Neo.worker.App.destroyNeoInstance(id), containerId); + containerId = splitterId = null + }); + + test('config projection: orientation drives class, axis dims and their live re-projection', async ({page}) => { + ({containerId, splitterId} = await mount(page, {orientation: 'horizontal', size: 6})); + + let cfg = await getConfigs(page, splitterId, ['cls', 'width', 'minWidth', 'height']); + expect(cfg.cls).toContain('neo-dashboard-dock-splitter-horizontal'); + expect(cfg.width).toBe(6); + expect(cfg.minWidth).toBe(6); + expect(cfg.height).toBe(null); + + // the rendered node carries the same projection + const box = await page.locator('#equiv-splitter').boundingBox(); + expect(Math.round(box.width)).toBe(6); + + await page.evaluate(id => Neo.worker.App.setConfigs({id, orientation: 'vertical'}), splitterId); + + await expect.poll(async () => (await getConfigs(page, splitterId, ['cls'])).cls) + .toContain('neo-dashboard-dock-splitter-vertical'); + + cfg = await getConfigs(page, splitterId, ['cls', 'width', 'height', 'minHeight']); + expect(cfg.cls).not.toContain('neo-dashboard-dock-splitter-horizontal'); + expect(cfg.height).toBe(6); + expect(cfg.minHeight).toBe(6); + expect(cfg.width).toBe(null) + }); +}); diff --git a/test/playwright/component/dashboard/DockSplitterPaint.spec.mjs b/test/playwright/component/dashboard/DockSplitterPaint.spec.mjs index 2afaf81f21..9d19f80c0c 100644 --- a/test/playwright/component/dashboard/DockSplitterPaint.spec.mjs +++ b/test/playwright/component/dashboard/DockSplitterPaint.spec.mjs @@ -43,7 +43,7 @@ test.beforeEach(async ({page}) => { if (!dashboard.success) throw new Error(`dashboard: ${dashboard.error.message}`); const splitter = await Neo.worker.App.createNeoInstance({ - importPath : '../dashboard/DockSplitter.mjs', + importPath : '../dashboard/dock/interaction/DockSplitter.mjs', ntype : 'dashboard-dock-splitter', orientation: 'horizontal', parentId : dashboard.id @@ -76,7 +76,7 @@ const applyTheme = (page, theme) => page.evaluate(name => { return document.body.className }, theme); -test.describe('Neo.dashboard.DockSplitter — the rendered affordance floor', () => { +test.describe('Neo.dashboard.dock.interaction.DockSplitter — the rendered affordance floor', () => { for (const theme of THEMES) { test(`a consumer that sets NO tokens gets a findable splitter — ${theme}`, async ({page}) => { await applyTheme(page, theme); diff --git a/test/playwright/e2e/dashboard/DemoATourNL.spec.mjs b/test/playwright/e2e/dashboard/DemoATourNL.spec.mjs index 60153f46ac..54ece5a862 100644 --- a/test/playwright/e2e/dashboard/DemoATourNL.spec.mjs +++ b/test/playwright/e2e/dashboard/DemoATourNL.spec.mjs @@ -7,7 +7,7 @@ import {test, expect} from '../../fixtures.mjs'; * The product truths a unit spec cannot certify, proven here against the running childapp: * 1. the tour button drives the FULL screenplay through the real reducer seam — 18 beats, * deterministic, completing with the documented finale document in App Worker truth; - * 2. scene 3's tucks project REAL interactive rail tabs (`Neo.dashboard.DockRail` buttons in + * 2. scene 3's tucks project REAL interactive rail tabs (`Neo.dashboard.dock.interaction.Rail` buttons in * the DOM — the affordance tier, not just document flags); * 3. the reveal beat is EXECUTABLE — the scripted cue opens a genuine transient reveal * overlay mid-tour, and the rollback releases it (nothing persisted, nothing left over); diff --git a/test/playwright/e2e/dashboard/DemoBCrossWindowDragNL.spec.mjs b/test/playwright/e2e/dashboard/DemoBCrossWindowDragNL.spec.mjs index f633a5d4ba..ab5a7873f0 100644 --- a/test/playwright/e2e/dashboard/DemoBCrossWindowDragNL.spec.mjs +++ b/test/playwright/e2e/dashboard/DemoBCrossWindowDragNL.spec.mjs @@ -387,7 +387,7 @@ test.describe('Dashboard Demo B — real cross-window dock drag', () => { .toEqual({errors: [], saved: true}); const expectedSource = { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { inspector: {componentRef: 'Inspector', title: 'Inspector', kind: 'panel'}, @@ -402,7 +402,7 @@ test.describe('Dashboard Demo B — real cross-window dock drag', () => { } }, expectedTarget = { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'popup-root', items : { workbench: {componentRef: 'Workbench', title: 'Workbench', kind: 'panel'} diff --git a/test/playwright/e2e/dashboard/DemoBVesselConversionNL.spec.mjs b/test/playwright/e2e/dashboard/DemoBVesselConversionNL.spec.mjs index 7b4a7b60df..23c7225d2d 100644 --- a/test/playwright/e2e/dashboard/DemoBVesselConversionNL.spec.mjs +++ b/test/playwright/e2e/dashboard/DemoBVesselConversionNL.spec.mjs @@ -141,7 +141,7 @@ test.describe('Dashboard Demo B — vessel-conversion geometry readiness', () => }, ['id']), wsId = workspace.id, sourceZone = await findOne(app, { - className : 'Neo.dashboard.DockTabSortZone', + className : 'Neo.dashboard.dock.interaction.TabSortZone', dockSourceNodeId: 'workbench-tabs', dockWorkspaceId : 'demo-b-main' }, [ diff --git a/test/playwright/e2e/dashboard/DockAutoHideRevealNL.spec.mjs b/test/playwright/e2e/dashboard/DockAutoHideRevealNL.spec.mjs index e096557187..7c36f60628 100644 --- a/test/playwright/e2e/dashboard/DockAutoHideRevealNL.spec.mjs +++ b/test/playwright/e2e/dashboard/DockAutoHideRevealNL.spec.mjs @@ -80,7 +80,7 @@ const createFourEdgeRailDocument = () => { nodes.root.zones[edge] = `${edge}-tabs` } - return {schema: 'neo.harness.dockZone.v1', root: 'root', items, nodes} + return {schema: 'neo.dock.zone.v1', root: 'root', items, nodes} }; test.describe('Dock auto-hide reveal/pin journey (Neural Link)', () => { diff --git a/test/playwright/e2e/dashboard/DockCrossZoneDragNL.spec.mjs b/test/playwright/e2e/dashboard/DockCrossZoneDragNL.spec.mjs index 9cf10ac6cf..4053dc9fa1 100644 --- a/test/playwright/e2e/dashboard/DockCrossZoneDragNL.spec.mjs +++ b/test/playwright/e2e/dashboard/DockCrossZoneDragNL.spec.mjs @@ -5,7 +5,7 @@ import { test, expect } from '../../fixtures.mjs'; * within-container reorder. Dragging a tab header OUT of its tabs node and dropping it over a DIFFERENT * zone relocates the item in the committed dockZone.v1 document. * - * The gesture rides the existing tab-header drag lifecycle: `Neo.dashboard.DockTabSortZone` fires a + * The gesture rides the existing tab-header drag lifecycle: `Neo.dashboard.dock.interaction.TabSortZone` fires a * `dockCrossZoneDrop` on its tab.Container on drop; the owner routes the release point + dragged item * through the `dockPreview.v1` producer → `previewToOperation` → `applyDockZoneOperation` pipeline. * An interior drop resolves to `tab-into` (an `addTab` that downgrades to `moveItem` for an in-tree item); diff --git a/test/playwright/e2e/dashboard/DockOperationsNL.spec.mjs b/test/playwright/e2e/dashboard/DockOperationsNL.spec.mjs index f2884e218e..5682ce018e 100644 --- a/test/playwright/e2e/dashboard/DockOperationsNL.spec.mjs +++ b/test/playwright/e2e/dashboard/DockOperationsNL.spec.mjs @@ -48,7 +48,7 @@ test.describe('Dock semantic operations (Neural Link, structural)', () => { const res = await app.getDockTopology(holderId); const doc = res?.document ?? res; - expect(doc?.schema, 'get_dock_topology must return a dockZone.v1 document').toBe('neo.harness.dockZone.v1'); + expect(doc?.schema, 'get_dock_topology must return a dockZone.v1 document').toBe('neo.dock.zone.v1'); return doc; }; diff --git a/test/playwright/e2e/dashboard/DockSplitterProxyPaintNL.spec.mjs b/test/playwright/e2e/dashboard/DockSplitterProxyPaintNL.spec.mjs index 71f017b088..25891e879b 100644 --- a/test/playwright/e2e/dashboard/DockSplitterProxyPaintNL.spec.mjs +++ b/test/playwright/e2e/dashboard/DockSplitterProxyPaintNL.spec.mjs @@ -22,7 +22,7 @@ const HOSTS = [ {name: 'the example (engine floor, no app dock CSS)', url: '/examples/dashboard/dock/', ready: '.neo-dashboard-dock-splitter', handleDiscriminates: true} ]; -test.describe('Neo.dashboard.DockSplitter — the drag proxy carries its paint', () => { +test.describe('Neo.dashboard.dock.interaction.DockSplitter — the drag proxy carries its paint', () => { test.setTimeout(90000); for (const host of HOSTS) { diff --git a/test/playwright/e2e/dashboard/DockTourSmokeNL.spec.mjs b/test/playwright/e2e/dashboard/DockTourSmokeNL.spec.mjs index 7254bdf907..326ffa8b0a 100644 --- a/test/playwright/e2e/dashboard/DockTourSmokeNL.spec.mjs +++ b/test/playwright/e2e/dashboard/DockTourSmokeNL.spec.mjs @@ -56,7 +56,7 @@ const baselineScript = { scenes: [{ id : 'read', title: 'assert schema, mutate nothing', - steps: [{type: 'topology-assert', expect: [{path: 'schema', equals: 'neo.harness.dockZone.v1'}]}] + steps: [{type: 'topology-assert', expect: [{path: 'schema', equals: 'neo.dock.zone.v1'}]}] }] }; diff --git a/test/playwright/e2e/workstation/WorkstationDockPreviewSymmetryNL.spec.mjs b/test/playwright/e2e/workstation/WorkstationDockPreviewSymmetryNL.spec.mjs index fc1ecaeb34..b98e64d096 100644 --- a/test/playwright/e2e/workstation/WorkstationDockPreviewSymmetryNL.spec.mjs +++ b/test/playwright/e2e/workstation/WorkstationDockPreviewSymmetryNL.spec.mjs @@ -467,7 +467,7 @@ test.describe('Workstation dock-preview four-axis symmetry (Neural Link)', () => await expect.poll(async () => { const targets = await app.findInstances( - {className: 'Neo.dashboard.CrossWindowDragTarget'}, + {className: 'Neo.dashboard.dock.window.DragTarget'}, ['id', 'stableTargetId'] ); diff --git a/test/playwright/e2e/workstation/WorkstationDragAffordancesNL.spec.mjs b/test/playwright/e2e/workstation/WorkstationDragAffordancesNL.spec.mjs index 3eb3d8f799..c17b01471a 100644 --- a/test/playwright/e2e/workstation/WorkstationDragAffordancesNL.spec.mjs +++ b/test/playwright/e2e/workstation/WorkstationDragAffordancesNL.spec.mjs @@ -275,7 +275,7 @@ test.describe('Workstation drag affordances — the flagship journey (Neural Lin await parkAuditDrag(page, center); const - indicators = await app.findInstances({className: 'Neo.dashboard.DockDropIndicators'}, ['id']), + indicators = await app.findInstances({className: 'Neo.dashboard.dock.interaction.DropIndicators'}, ['id']), indId = (Array.isArray(indicators) ? indicators[0] : indicators)?.id, indState = await app.getComponent(indId, ['candidateSet', 'mounted']), geometry = await readParkedOverlayGeometry(page), @@ -679,7 +679,7 @@ test.describe('Workstation drag affordances — the flagship journey (Neural Lin // also witnesses the sensor's detached-node dispatch repair: the dense toolbar's // overflow re-collapse replaces the dragged button's node mid-gesture, and the move // stream must survive that (src/main/draggable/sensor/Base.mjs#trigger). - const indicators = await app.findInstances({className: 'Neo.dashboard.DockDropIndicators'}, ['id']); + const indicators = await app.findInstances({className: 'Neo.dashboard.dock.interaction.DropIndicators'}, ['id']); const indId = (Array.isArray(indicators) ? indicators[0] : indicators)?.id; const indState = await app.getComponent(indId, ['candidateSet', 'mounted']); diff --git a/test/playwright/e2e/workstation/WorkstationFiveBeatNL.spec.mjs b/test/playwright/e2e/workstation/WorkstationFiveBeatNL.spec.mjs index e61506a3e9..aa853ef50d 100644 --- a/test/playwright/e2e/workstation/WorkstationFiveBeatNL.spec.mjs +++ b/test/playwright/e2e/workstation/WorkstationFiveBeatNL.spec.mjs @@ -3,7 +3,7 @@ import {createHash} from 'node:c import path from 'node:path'; import {promisify} from 'node:util'; import fs from 'fs-extra'; -import {previewToOperation} from '../../../../src/dashboard/dockPreviewContract.mjs'; +import {previewToOperation} from '../../../../src/dashboard/dock/model/PreviewContract.mjs'; import {test, expect} from '../../fixtures.mjs'; import {assertPreviewZoneAlignment, readComponentRects} from '../utils/dockGeometry.mjs'; import {pinToCaptureDisplay, placeNativeWindow, readBrowserSurface} from '../utils/filmStage.mjs'; @@ -1882,7 +1882,7 @@ test.describe('Workstation — the five-beat multi-window journey', () => { expect(userAgent, 'a headless browser cannot witness an OS titlebar gesture') .not.toContain('HeadlessChrome'); - const {default: DockZoneModel} = await import('../../../../src/dashboard/DockZoneModel.mjs'); + const {default: Operations} = await import('../../../../src/dashboard/dock/model/Operations.mjs'); const {app, pageErrors, popupProbe, wsId} = await boot({page, neuralLink}), @@ -2259,7 +2259,7 @@ test.describe('Workstation — the five-beat multi-window journey', () => { const expectedOperation = previewToOperation(committedPreview), - expectedReturn = DockZoneModel.applyOperation(documentBeforeReturn, expectedOperation); + expectedReturn = Operations.applyOperation(documentBeforeReturn, expectedOperation); expect(expectedOperation, 'the retained accepted preview must convert through the production contract') .toBeTruthy(); diff --git a/test/playwright/e2e/workstation/WorkstationHumanPopupOverlapNL.spec.mjs b/test/playwright/e2e/workstation/WorkstationHumanPopupOverlapNL.spec.mjs index 67e78abadc..d6f80056bb 100644 --- a/test/playwright/e2e/workstation/WorkstationHumanPopupOverlapNL.spec.mjs +++ b/test/playwright/e2e/workstation/WorkstationHumanPopupOverlapNL.spec.mjs @@ -767,7 +767,7 @@ test.describe('Workstation — human popup-over-popup conversion (#16117)', () = const sourceWindowId = await awaitVesselWindowId(app, wsId, cell.itemId, false), sourceZone = await findOne(app, { - className : 'Neo.dashboard.DockTabSortZone', + className : 'Neo.dashboard.dock.interaction.TabSortZone', dockSourceNodeId: cell.sourceNodeId, dockWorkspaceId : 'workstation-main' }, ['id']), diff --git a/test/playwright/e2e/workstation/WorkstationPerspectivesNL.spec.mjs b/test/playwright/e2e/workstation/WorkstationPerspectivesNL.spec.mjs index 4f12ce1738..189940c380 100644 --- a/test/playwright/e2e/workstation/WorkstationPerspectivesNL.spec.mjs +++ b/test/playwright/e2e/workstation/WorkstationPerspectivesNL.spec.mjs @@ -5,12 +5,12 @@ const {NeuralLink_DockService} = await loadNeuralLinkModules(); /** * @summary Whitebox E2E witness for the Neural Link perspective path on the workstation Workspace. * - * The workstation Workspace carries a `DockPerspectiveStore` (holder-resolved by the client + * The workstation Workspace carries a `PerspectiveLibrary` (holder-resolved by the client * DockService), so the agent-driven perspective trio activates on the film's primary surface: * - * capture_perspective (stored through the holder's store) → list_perspectives + * capture_perspective (stored through the holder's library) → list_perspectives * → execute_dock_operation (disruption) → restore_perspective - * (exact baseline dockZone.v1 document through the store's migration-honest load + * (exact baseline neo.dock.zone.v1 document through the library's fail-closed load * plus the workspace's document-commit seam) * * All assertions read worker truth, never the DOM. The baseline is read live, so the spec diff --git a/test/playwright/unit/apps/workstation/Workspace.spec.mjs b/test/playwright/unit/apps/workstation/Workspace.spec.mjs index 552aa8b40e..2d07e8aab5 100644 --- a/test/playwright/unit/apps/workstation/Workspace.spec.mjs +++ b/test/playwright/unit/apps/workstation/Workspace.spec.mjs @@ -9,9 +9,10 @@ setup({ import {test, expect} from '@playwright/test'; import Neo from '../../../../../src/Neo.mjs'; import * as core from '../../../../../src/core/_export.mjs'; -import DockProjectionReconciler from '../../../../../src/dashboard/DockProjectionReconciler.mjs'; -import DockZoneModel from '../../../../../src/dashboard/DockZoneModel.mjs'; -import {previewToOperation} from '../../../../../src/dashboard/dockPreviewContract.mjs'; +import DockProjectionReconciler from '../../../../../src/dashboard/dock/projection/Reconciler.mjs'; +import Document from '../../../../../src/dashboard/dock/model/Document.mjs'; +import Operations from '../../../../../src/dashboard/dock/model/Operations.mjs'; +import {previewToOperation} from '../../../../../src/dashboard/dock/model/PreviewContract.mjs'; import '../../../../../src/manager/Instance.mjs'; import FeedPane from '../../../../../apps/workstation/view/FeedPane.mjs'; import ScalePane from '../../../../../apps/workstation/view/ScalePane.mjs'; @@ -57,7 +58,7 @@ const stageCommittedVessel = (workspace, ownerItemId='alerts', incomingItemId='s const workspaceId = Workspace.vesselWorkspaceId(ownerItemId), tabsNodeId = Workspace.vesselTabsNodeId(ownerItemId), - detached = DockZoneModel.applyOperation(workspace.dockModel, { + detached = Operations.applyOperation(workspace.dockModel, { operation: 'detachItem', itemId : ownerItemId }); @@ -68,13 +69,13 @@ const stageCommittedVessel = (workspace, ownerItemId='alerts', incomingItemId='s const provisional = workspace.createVesselWorkspaceDocument(ownerItemId), - incoming = DockZoneModel.transferItem(detached.document, provisional, { + incoming = Operations.transferItem(detached.document, provisional, { itemId : incomingItemId, sourceWorkspaceId: Workspace.MAIN_WORKSPACE_ID, targetWorkspaceId: workspaceId, target : {operation: 'addTab', tabsNodeId} }), - owner = DockZoneModel.transferItem(incoming.sourceDocument, incoming.targetDocument, { + owner = Operations.transferItem(incoming.sourceDocument, incoming.targetDocument, { itemId : ownerItemId, sourceWorkspaceId: Workspace.MAIN_WORKSPACE_ID, targetWorkspaceId: workspaceId, @@ -986,7 +987,7 @@ test.describe.serial('Workstation.view.Workspace', () => { const provisional = workspace.getWorkspaceDocument(workspaceId); - expect(DockZoneModel.validate(provisional)).toEqual([]); + expect(Document.validate(provisional)).toEqual([]); expect(provisional.items).toEqual({}); expect(provisional.nodes[Workspace.vesselTabsNodeId('alerts')]).toEqual({ activeItemId: null, @@ -1290,7 +1291,7 @@ test.describe.serial('Workstation.view.Workspace', () => { itemId = 'alerts', pane = workspace.paneCache[itemId], workspaceId = Workspace.vesselWorkspaceId(itemId), - detached = DockZoneModel.applyOperation(workspace.dockModel, { + detached = Operations.applyOperation(workspace.dockModel, { operation: 'detachItem', itemId }), @@ -1470,7 +1471,7 @@ test.describe.serial('Workstation.view.Workspace', () => { try { await workspace.refreshPromise; - const detached = DockZoneModel.applyOperation(workspace.dockModel, { + const detached = Operations.applyOperation(workspace.dockModel, { operation: 'detachItem', itemId : 'alerts' }); @@ -1507,7 +1508,7 @@ test.describe.serial('Workstation.view.Workspace', () => { return false }; - const transferIncoming = () => DockZoneModel.transferItem( + const transferIncoming = () => Operations.transferItem( workspace.dockModel, state.document, { @@ -1594,7 +1595,7 @@ test.describe.serial('Workstation.view.Workspace', () => { const returnDescriptor = { operation : 'transferNode', - nodeId : DockZoneModel.resolveStackRoot(state.document), + nodeId : Document.resolveStackRoot(state.document), sourceWorkspaceId: workspaceId, targetWorkspaceId: Workspace.MAIN_WORKSPACE_ID, target : { @@ -1602,7 +1603,7 @@ test.describe.serial('Workstation.view.Workspace', () => { placement : {kind: 'tab-into'} } }, - returned = DockZoneModel.transferNode( + returned = Operations.transferNode( state.document, workspace.dockModel, returnDescriptor @@ -1751,7 +1752,7 @@ test.describe.serial('Workstation.view.Workspace', () => { const descriptor = { operation : 'transferNode', - nodeId : DockZoneModel.resolveStackRoot(state.document), + nodeId : Document.resolveStackRoot(state.document), sourceWorkspaceId: workspaceId, targetWorkspaceId: Workspace.MAIN_WORKSPACE_ID, target : { @@ -1759,7 +1760,7 @@ test.describe.serial('Workstation.view.Workspace', () => { placement : {kind: 'tab-into'} } }, - returned = DockZoneModel.transferNode(state.document, workspace.dockModel, descriptor); + returned = Operations.transferNode(state.document, workspace.dockModel, descriptor); expect(returned.errors).toEqual([]); expect(workspace.workspaceSet.adoptTransfer({ @@ -1827,7 +1828,7 @@ test.describe.serial('Workstation.view.Workspace', () => { workspace = Neo.create(Workspace, {}), workspaceId = Workspace.vesselWorkspaceId('alerts'), provisional = workspace.createVesselWorkspaceDocument('alerts'), - moved = DockZoneModel.transferItem(workspace.dockModel, provisional, { + moved = Operations.transferItem(workspace.dockModel, provisional, { itemId : 'alerts', sourceWorkspaceId: Workspace.MAIN_WORKSPACE_ID, targetWorkspaceId: workspaceId, @@ -2352,7 +2353,7 @@ test.describe('replay probe transaction (prototype-call)', () => { // against the one being reset into place, so the stub would fail the diff's shape gate // and the derivation would fail closed — turning this assertion into a statement about // an unparseable fixture rather than about same-topology admission. - liveDocument = DockZoneModel.clone(initialDocument), + liveDocument = Document.clone(initialDocument), refreshCalls = [], host = { dockModel : liveDocument, diff --git a/test/playwright/unit/apps/workstation/tour/denseWorkstation.spec.mjs b/test/playwright/unit/apps/workstation/tour/denseWorkstation.spec.mjs index fb7b649569..86d6350c85 100644 --- a/test/playwright/unit/apps/workstation/tour/denseWorkstation.spec.mjs +++ b/test/playwright/unit/apps/workstation/tour/denseWorkstation.spec.mjs @@ -10,7 +10,8 @@ import {test, expect} from '@playwright/test'; import Neo from '../../../../../../src/Neo.mjs'; import * as core from '../../../../../../src/core/_export.mjs'; import DockService from '../../../../../../src/ai/client/DockService.mjs'; -import DockZoneModel from '../../../../../../src/dashboard/DockZoneModel.mjs'; +import Document from '../../../../../../src/dashboard/dock/model/Document.mjs'; +import Operations from '../../../../../../src/dashboard/dock/model/Operations.mjs'; import TourRunner from '../../../../../../src/ai/client/TourRunner.mjs'; import {validateTourScript} from '../../../../../../src/ai/client/tourScript.mjs'; @@ -28,7 +29,7 @@ test.describe.serial('apps/workstation/tour/denseWorkstation', () => { * @returns {Neo.ai.client.TourRunner} */ function createRunner() { - const holder = {dockZoneDocument: DockZoneModel.clone(initialDocument), id: 'workstation-stage'}; + const holder = {dockZoneDocument: Document.clone(initialDocument), id: 'workstation-stage'}; Neo.getComponent = () => holder; runner = Neo.create(TourRunner, { @@ -55,7 +56,7 @@ test.describe.serial('apps/workstation/tour/denseWorkstation', () => { test('the body is self-contained: 20 placed items, reviewed cues, no invented operation', () => { const - {valid, errors} = validateTourScript(workstationTourScript, {operations: DockZoneModel.operations}), + {valid, errors} = validateTourScript(workstationTourScript, {operations: Operations.operations}), placed = Object.values(initialDocument.nodes) .filter(node => node.type === 'tabs') .flatMap(node => node.items), @@ -94,7 +95,7 @@ test.describe.serial('apps/workstation/tour/denseWorkstation', () => { placementKind: 'tab-into' }]); expect(operations).toEqual(['resizeSplit', 'splitNode', 'addTab']); - operations.forEach(operation => expect(DockZoneModel.operations).toContain(operation)); + operations.forEach(operation => expect(Operations.operations).toContain(operation)); expect(operations).not.toContain('promote') }); diff --git a/test/playwright/unit/dashboard/CrossWindowDragTarget.spec.mjs b/test/playwright/unit/dashboard/CrossWindowDragTarget.spec.mjs index 12d2169976..70c93f0885 100644 --- a/test/playwright/unit/dashboard/CrossWindowDragTarget.spec.mjs +++ b/test/playwright/unit/dashboard/CrossWindowDragTarget.spec.mjs @@ -10,11 +10,11 @@ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -test.describe('Neo.dashboard.CrossWindowDragTarget (#14670 / ADR 0029 §2.3)', () => { +test.describe('Neo.dashboard.dock.window.DragTarget (#14670 / ADR 0029 §2.3)', () => { let CrossWindowDragTarget, DragCoordinator; test.beforeAll(async () => { - CrossWindowDragTarget = (await import('../../../../src/dashboard/CrossWindowDragTarget.mjs')).default; + CrossWindowDragTarget = (await import('../../../../src/dashboard/dock/window/DragTarget.mjs')).default; DragCoordinator = (await import('../../../../src/manager/DragCoordinator.mjs')).default; }); diff --git a/test/playwright/unit/dashboard/DockCrossWindowParticipation.spec.mjs b/test/playwright/unit/dashboard/DockCrossWindowParticipation.spec.mjs index 8bdce90a03..42c6987ee5 100644 --- a/test/playwright/unit/dashboard/DockCrossWindowParticipation.spec.mjs +++ b/test/playwright/unit/dashboard/DockCrossWindowParticipation.spec.mjs @@ -11,7 +11,7 @@ import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; /** - * @summary Tests for Neo.dashboard.DockCrossWindowParticipation — the adapter-tier composition + * @summary Tests for Neo.dashboard.dock.window.Participation — the adapter-tier composition * that wires ONE dock workspace into the §2.3 cross-window contract over landed machinery only: * target registration lifecycle, the owner seams, foreign-vs-local drop discrimination, the * atomic `transferItem` composition, and finite-schema publication of the executor's document @@ -21,7 +21,7 @@ import * as core from '../../../../src/core/_export.mjs'; /** A fresh source-workspace document ('A') — `terminal` is the item every transfer moves. */ function sourceDoc() { return { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { strategy: {componentRef: 'strategy', title: 'Strategy', kind: 'panel'}, @@ -38,7 +38,7 @@ function sourceDoc() { /** A fresh target-workspace document ('B') with a disjoint catalog. */ function targetDoc() { return { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : {alpha: {componentRef: 'alpha', title: 'Alpha', kind: 'panel'}}, nodes : { @@ -48,8 +48,8 @@ function targetDoc() { } } -test.describe('Neo.dashboard.DockCrossWindowParticipation (ADR 0029 §2.3 — workspace wiring)', () => { - let DockCrossWindowParticipation, DockTabSortZone, DockZoneModel, DragCoordinator, Rectangle, WindowManager; +test.describe('Neo.dashboard.dock.window.Participation (ADR 0029 §2.3 — workspace wiring)', () => { + let DockCrossWindowParticipation, DockTabSortZone, Document, DragCoordinator, Persistence, Rectangle, WindowManager; const createCoordinatorStub = calls => ({ register : zone => calls.push(['register', zone]), @@ -57,9 +57,10 @@ test.describe('Neo.dashboard.DockCrossWindowParticipation (ADR 0029 §2.3 — wo }); test.beforeAll(async () => { - DockCrossWindowParticipation = (await import('../../../../src/dashboard/DockCrossWindowParticipation.mjs')).default; - DockTabSortZone = (await import('../../../../src/dashboard/DockTabSortZone.mjs')).default; - DockZoneModel = (await import('../../../../src/dashboard/DockZoneModel.mjs')).default; + DockCrossWindowParticipation = (await import('../../../../src/dashboard/dock/window/Participation.mjs')).default; + DockTabSortZone = (await import('../../../../src/dashboard/dock/interaction/TabSortZone.mjs')).default; + Document = (await import('../../../../src/dashboard/dock/model/Document.mjs')).default; + Persistence = (await import('../../../../src/dashboard/dock/model/Persistence.mjs')).default; DragCoordinator = (await import('../../../../src/manager/DragCoordinator.mjs')).default; Rectangle = (await import('../../../../src/util/Rectangle.mjs')).default; WindowManager = (await import('../../../../src/manager/Window.mjs')).default @@ -323,12 +324,12 @@ test.describe('Neo.dashboard.DockCrossWindowParticipation (ADR 0029 §2.3 — wo expect(record).toEqual(sourceRecord); expect(record).not.toHaveProperty('owningWorkspaceId'); expect(record).not.toHaveProperty('fallbackTarget'); - expect(DockZoneModel.validate(sourceDocument)).toEqual([]); - expect(DockZoneModel.validate(targetDocument)).toEqual([]); + expect(Document.validate(sourceDocument)).toEqual([]); + expect(Document.validate(targetDocument)).toEqual([]); // The finite writer is the integration tripwire that the former loose fields failed. // Exercise the real two-document capture for BOTH placement shapes. - const captured = DockZoneModel.captureTopologyPerspective([sourceDocument, targetDocument], { + const captured = Persistence.captureTopologyPerspective([sourceDocument, targetDocument], { layoutId : `post-${operation.operation}`, perspectiveName: `Post ${operation.operation}`, revision : 1, diff --git a/test/playwright/unit/dashboard/DockDragAffordances.spec.mjs b/test/playwright/unit/dashboard/DockDragAffordances.spec.mjs index c8fd92655f..14a811fb59 100644 --- a/test/playwright/unit/dashboard/DockDragAffordances.spec.mjs +++ b/test/playwright/unit/dashboard/DockDragAffordances.spec.mjs @@ -10,10 +10,10 @@ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; import '../../../../src/manager/Instance.mjs'; -import DockDragAffordances from '../../../../src/dashboard/DockDragAffordances.mjs'; -import DockDropIndicators from '../../../../src/dashboard/DockDropIndicators.mjs'; -import DockPreview from '../../../../src/dashboard/DockPreview.mjs'; -import DockZoneModel from '../../../../src/dashboard/DockZoneModel.mjs'; +import DockDragAffordances from '../../../../src/dashboard/dock/interaction/DragAffordances.mjs'; +import DockDropIndicators from '../../../../src/dashboard/dock/interaction/DropIndicators.mjs'; +import DockPreview from '../../../../src/dashboard/dock/interaction/Preview.mjs'; +import Operations from '../../../../src/dashboard/dock/model/Operations.mjs'; /** * @summary The shared gesture controller's discrimination and generation witnesses. @@ -30,13 +30,13 @@ import DockZoneModel from '../../../../src/dashboard/DockZoneModel.mjs'; * The geometry tests drive the PRODUCTION `ensureGeometry` path through a stubbed host * transport (real measure → map → self-heal code), not only the injected-promise seam. */ -test.describe('Neo.dashboard.DockDragAffordances', () => { +test.describe('Neo.dashboard.dock.interaction.DragAffordances', () => { /** * A real two-zone document: `left-tabs` (alpha, beta) | `right-tabs` (gamma). * @returns {Object} */ const makeDocument = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'split-main', items : { alpha: {componentRef: 'ref-alpha', title: 'Alpha', kind: 'pane'}, @@ -72,7 +72,7 @@ test.describe('Neo.dashboard.DockDragAffordances', () => { owner = { dockModel: makeDocument(), applyDockZoneOperation(descriptor) { - return DockZoneModel.applyOperation(this.dockModel, descriptor) + return Operations.applyOperation(this.dockModel, descriptor) }, onDockZoneDocumentChange(document) { this.dockModel = document; diff --git a/test/playwright/unit/dashboard/DockDropIndicators.spec.mjs b/test/playwright/unit/dashboard/DockDropIndicators.spec.mjs index 111ae8b1fc..9dde0fd9c4 100644 --- a/test/playwright/unit/dashboard/DockDropIndicators.spec.mjs +++ b/test/playwright/unit/dashboard/DockDropIndicators.spec.mjs @@ -9,9 +9,9 @@ setup({ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -import DockDropIndicators from '../../../../src/dashboard/DockDropIndicators.mjs'; -import DockPreviewProducer from '../../../../src/dashboard/DockPreviewProducer.mjs'; -import {CANDIDATES_SCHEMA, PREVIEW_SCHEMA, previewToOperation} from '../../../../src/dashboard/dockPreviewContract.mjs'; +import DockDropIndicators from '../../../../src/dashboard/dock/interaction/DropIndicators.mjs'; +import DockPreviewProducer from '../../../../src/dashboard/dock/interaction/PreviewProducer.mjs'; +import {CANDIDATES_SCHEMA, PREVIEW_SCHEMA, previewToOperation} from '../../../../src/dashboard/dock/model/PreviewContract.mjs'; // Geometry fixture (viewport space): host at (100, 50); hovered zone centered at (400, 300). const HOST_RECT = {x: 100, y: 50, width: 800, height: 600}; @@ -55,7 +55,7 @@ const indicatorChildren = layer => (layer.items || []).filter(item => item.candi const childByKey = (layer, key) => layer.items.find(item => item.candidateKey === key); const isOff = child => child.cls.includes('neo-dashboard-dock-drop-indicator-off'); -test.describe('Neo.dashboard.DockDropIndicators (§06 — the indicator-overlay menu)', () => { +test.describe('Neo.dashboard.dock.interaction.DropIndicators (§06 — the indicator-overlay menu)', () => { let layer; test.afterEach(() => { diff --git a/test/playwright/unit/dashboard/DockFlip.spec.mjs b/test/playwright/unit/dashboard/DockFlip.spec.mjs index 6513baf353..d2e1169c0b 100644 --- a/test/playwright/unit/dashboard/DockFlip.spec.mjs +++ b/test/playwright/unit/dashboard/DockFlip.spec.mjs @@ -10,7 +10,7 @@ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; import DockFlip from '../../../../src/main/addon/DockFlip.mjs'; -import DockWorkspace from '../../../../src/dashboard/DockWorkspace.mjs'; +import DockWorkspace from '../../../../src/dashboard/dock/Workspace.mjs'; /** * Creates the iterable class-list surface consumed by DockFlip. @@ -86,7 +86,7 @@ test.describe('Neo.main.addon.DockFlip', () => { test('uses dock item ids rather than component refs for projection marker identity', () => { const model = { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root-tabs', items : { 'alpha pane': {componentRef: 'shared-ref', title: 'Alpha'}, diff --git a/test/playwright/unit/dashboard/DockKeyboardCommands.spec.mjs b/test/playwright/unit/dashboard/DockKeyboardCommands.spec.mjs index 94a82882e5..4ea8dc1348 100644 --- a/test/playwright/unit/dashboard/DockKeyboardCommands.spec.mjs +++ b/test/playwright/unit/dashboard/DockKeyboardCommands.spec.mjs @@ -12,7 +12,7 @@ import {test, expect} from '@playwright/test'; // sibling spec populated Neo first) and red run ALONE: a test-isolation leak, not a machine fact import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -import {createDockKeyboardCommands} from '../../../../src/dashboard/DockKeyboardCommands.mjs'; +import {createDockKeyboardCommands} from '../../../../src/dashboard/dock/interaction/KeyboardCommands.mjs'; /** * @summary The keyboard detach command machine, driven end-to-end through its injected seams — @@ -31,7 +31,7 @@ const CYCLE_TARGETS = [ {workspaceId: 'pop-2', tabsId: 'tabs-c', label: 'Popup two'} ]; -test.describe('Neo.dashboard.DockKeyboardCommands — createDockKeyboardCommands', () => { +test.describe('Neo.dashboard.dock.interaction.KeyboardCommands — createDockKeyboardCommands', () => { const harness = ({admit = true, commitErrors = [], commitThrows = false, focusGranted = true, targets = CYCLE_TARGETS} = {}) => { const calls = {announced: [], applied: [], closed: [], committed: [], focused: [], focusedWs: [], highlights: [], opened: [], synced: []}; diff --git a/test/playwright/unit/dashboard/DockLayoutAdapter.spec.mjs b/test/playwright/unit/dashboard/DockLayoutAdapter.spec.mjs index fbc47750b6..a4a42bc0fe 100644 --- a/test/playwright/unit/dashboard/DockLayoutAdapter.spec.mjs +++ b/test/playwright/unit/dashboard/DockLayoutAdapter.spec.mjs @@ -11,17 +11,17 @@ import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; import '../../../../src/manager/Instance.mjs'; // defines Neo.get — the container child-add path resolves parents through it import Component from '../../../../src/component/Base.mjs'; -import DockLayoutAdapter from '../../../../src/dashboard/DockLayoutAdapter.mjs'; -import DockRail from '../../../../src/dashboard/DockRail.mjs'; -import DockSplitter from '../../../../src/dashboard/DockSplitter.mjs'; -import DockTabEnterButton from '../../../../src/dashboard/DockTabEnterButton.mjs'; -import DockZoneModel from '../../../../src/dashboard/DockZoneModel.mjs'; +import DockLayoutAdapter from '../../../../src/dashboard/dock/projection/LayoutAdapter.mjs'; +import DockRail from '../../../../src/dashboard/dock/interaction/Rail.mjs'; +import DockSplitter from '../../../../src/dashboard/dock/interaction/DockSplitter.mjs'; +import DockTabEnterButton from '../../../../src/dashboard/dock/interaction/TabEnterButton.mjs'; +import Operations from '../../../../src/dashboard/dock/model/Operations.mjs'; import '../../../../src/dashboard/Panel.mjs'; // registers the `dashboard-panel` ntype the projected items use import TabContainer from '../../../../src/tab/Container.mjs'; import TabOverflowPlugin from '../../../../src/tab/plugin/Overflow.mjs'; const createModel = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { strategy: { @@ -77,7 +77,7 @@ const createModel = () => ({ }); const createEdgeZoneModel = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { strategy: { @@ -142,7 +142,7 @@ const getProjectedChildren = splitConfig => splitConfig.items.filter(item => ite * @returns {Object} */ const createTabsBandModel = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { strategy: {componentRef: 'strategy', title: 'Strategy', kind: 'panel'}, @@ -158,7 +158,7 @@ const createTabsBandModel = () => ({ const getProjectedSplitters = splitConfig => splitConfig.items.filter(item => item.dockNodeType === 'splitter'); -test.describe('Neo.dashboard.DockLayoutAdapter', () => { +test.describe('Neo.dashboard.dock.projection.LayoutAdapter', () => { test('projects split nodes to existing hbox and vbox layout primitives', () => { let model = createModel(), result = DockLayoutAdapter.project(model, { @@ -313,7 +313,7 @@ test.describe('Neo.dashboard.DockLayoutAdapter', () => { splitNodeId: 'root' }); - resized = DockZoneModel.applyOperation(model, descriptor); + resized = Operations.applyOperation(model, descriptor); expect(resized.errors).toEqual([]); expect(resized.document.nodes.root.sizes).toEqual([0.75, 0.25]); @@ -746,16 +746,16 @@ test.describe('Neo.dashboard.DockLayoutAdapter', () => { options = {resolveComponentRef: componentRef => ({ntype: 'dashboard-panel', reference: componentRef})}, findRail = result => result.items[0].items.find(item => item.dockNodeType === 'edge-rail'); - let hidden = DockZoneModel.applyOperation(model, {autoHidden: true, itemId: 'terminal', operation: 'setItemAutoHidden'}); + let hidden = Operations.applyOperation(model, {autoHidden: true, itemId: 'terminal', operation: 'setItemAutoHidden'}); expect(hidden.errors).toEqual([]); let railed = findRail(DockLayoutAdapter.project(hidden.document, options)); expect(railed.railItems.map(item => item.dockItemId)).toEqual(['terminal']); - let restored = DockZoneModel.applyOperation(hidden.document, {autoHidden: false, itemId: 'terminal', operation: 'setItemAutoHidden'}); + let restored = Operations.applyOperation(hidden.document, {autoHidden: false, itemId: 'terminal', operation: 'setItemAutoHidden'}); expect(findRail(DockLayoutAdapter.project(restored.document, options))).toBeUndefined(); - let rehidden = DockZoneModel.applyOperation(restored.document, {autoHidden: true, itemId: 'terminal', operation: 'setItemAutoHidden'}); + let rehidden = Operations.applyOperation(restored.document, {autoHidden: true, itemId: 'terminal', operation: 'setItemAutoHidden'}); expect(findRail(DockLayoutAdapter.project(rehidden.document, options)).railItems).toEqual(railed.railItems); }); diff --git a/test/playwright/unit/dashboard/DockMotionSignal.spec.mjs b/test/playwright/unit/dashboard/DockMotionSignal.spec.mjs index 5c1bdacd27..cf96b9e2ec 100644 --- a/test/playwright/unit/dashboard/DockMotionSignal.spec.mjs +++ b/test/playwright/unit/dashboard/DockMotionSignal.spec.mjs @@ -10,7 +10,7 @@ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -test.describe('Neo.dashboard.DockMotionSignal (the motion-contract observability signal)', () => { +test.describe('Neo.dashboard.dock.projection.MotionSignal (the motion-contract observability signal)', () => { let DockMotionSignal; // Deterministic timer doubles (the DockRevealStateMachine precedent): capture the fail-safe @@ -41,7 +41,7 @@ test.describe('Neo.dashboard.DockMotionSignal (the motion-contract observability }; test.beforeAll(async () => { - DockMotionSignal = (await import('../../../../src/dashboard/DockMotionSignal.mjs')).default + DockMotionSignal = (await import('../../../../src/dashboard/dock/projection/MotionSignal.mjs')).default }); test.afterEach(() => { diff --git a/test/playwright/unit/dashboard/DockPreview.spec.mjs b/test/playwright/unit/dashboard/DockPreview.spec.mjs index dfa47c49f9..ecaa489492 100644 --- a/test/playwright/unit/dashboard/DockPreview.spec.mjs +++ b/test/playwright/unit/dashboard/DockPreview.spec.mjs @@ -9,25 +9,25 @@ setup({ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -import DockPreview from '../../../../src/dashboard/DockPreview.mjs'; +import DockPreview from '../../../../src/dashboard/dock/interaction/Preview.mjs'; import fs from 'fs'; import path from 'path'; import {fileURLToPath} from 'url'; /** - * @summary Tests for Neo.dashboard.DockPreview — the drag-time dock preview renderer. + * @summary Tests for Neo.dashboard.dock.interaction.Preview — the drag-time dock preview renderer. * Covers the pure contract logic (validity / affordance mapping / semantic-drop conversion / * geometry) plus the component lifecycle (render, fail-closed cleanup, drag-source binding). */ /** - * Builds a well-formed `neo.harness.dockPreview.v1` object, with optional field overrides. + * Builds a well-formed `neo.dock.preview.v1` object, with optional field overrides. * @param {Object} [overrides] * @returns {Object} */ function preview(overrides = {}) { return { - schema : 'neo.harness.dockPreview.v1', + schema : 'neo.dock.preview.v1', previewId: 'preview:strategy:main-tabs:tab-after:1', itemId : 'strategy', source : {surface: 'dashboard-sort-zone', sortZoneId: 'left-workspace'}, @@ -43,10 +43,10 @@ const __dirname = path.dirname(__filename), repoRoot = path.resolve(__dirname, '../../../..'); -test.describe('Neo.dashboard.DockPreview', () => { +test.describe('Neo.dashboard.dock.interaction.Preview', () => { test.describe('stylesheet contract (visible affordances)', () => { test('the structural scss backs the emitted affordance classes with visible styling', () => { - const scss = fs.readFileSync(path.join(repoRoot, 'resources/scss/src/dashboard/DockPreview.scss'), 'utf8'); + const scss = fs.readFileSync(path.join(repoRoot, 'resources/scss/src/dashboard/dock/interaction/Preview.scss'), 'utf8'); expect(scss).toContain('.neo-dock-preview-affordance'); expect(scss).toContain('.neo-dock-preview-accepted'); @@ -58,7 +58,7 @@ test.describe('Neo.dashboard.DockPreview', () => { }); test('shared consumers stay on the declared DockPreview contract', () => { - const structural = fs.readFileSync(path.join(repoRoot, 'resources/scss/src/dashboard/DockPreview.scss'), 'utf8'), + const structural = fs.readFileSync(path.join(repoRoot, 'resources/scss/src/dashboard/dock/interaction/Preview.scss'), 'utf8'), consumers = [...new Set( [...structural.matchAll(/var\((--(?:fm|dock-transition)-[\w-]+)/g)].map(match => match[1]) )].sort(); @@ -76,7 +76,7 @@ test.describe('Neo.dashboard.DockPreview', () => { test('the proxy surface and signal language have no fallbacks and stay dock-owned', () => { const containerScss = fs.readFileSync(path.join(repoRoot, 'resources/scss/src/dashboard/Container.scss'), 'utf8'), - previewScss = fs.readFileSync(path.join(repoRoot, 'resources/scss/src/dashboard/DockPreview.scss'), 'utf8'), + previewScss = fs.readFileSync(path.join(repoRoot, 'resources/scss/src/dashboard/dock/interaction/Preview.scss'), 'utf8'), // strip line comments: the census asserts on SELECTORS and declarations, not prose uncomment = source => source.replace(/\/\/[^\n]*/g, ''), signalStart = containerScss.indexOf('.neo-preview-lang-signal'); @@ -153,7 +153,7 @@ test.describe('Neo.dashboard.DockPreview', () => { }); test('rejects a wrong or missing schema', () => { - expect(DockPreview.isValidPreview(preview({schema: 'neo.harness.dockPreview.v2'}))).toBe(false); + expect(DockPreview.isValidPreview(preview({schema: 'neo.dock.preview.v2'}))).toBe(false); expect(DockPreview.isValidPreview(preview({schema: undefined}))).toBe(false) }); diff --git a/test/playwright/unit/dashboard/DockPreviewProducer.spec.mjs b/test/playwright/unit/dashboard/DockPreviewProducer.spec.mjs index 65c563a35b..c3e5c31ac9 100644 --- a/test/playwright/unit/dashboard/DockPreviewProducer.spec.mjs +++ b/test/playwright/unit/dashboard/DockPreviewProducer.spec.mjs @@ -10,14 +10,14 @@ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -test.describe('Neo.dashboard.DockPreviewProducer (ADR 0029 §2.3 — the dock preview producer)', () => { +test.describe('Neo.dashboard.dock.interaction.PreviewProducer (ADR 0029 §2.3 — the dock preview producer)', () => { let DockPreviewProducer, DockPreview, producer; const RECT = {x: 0, y: 0, width: 100, height: 100}; // default band = 0.24 * 100 = 24 test.beforeAll(async () => { - DockPreviewProducer = (await import('../../../../src/dashboard/DockPreviewProducer.mjs')).default; - DockPreview = (await import('../../../../src/dashboard/DockPreview.mjs')).default; + DockPreviewProducer = (await import('../../../../src/dashboard/dock/interaction/PreviewProducer.mjs')).default; + DockPreview = (await import('../../../../src/dashboard/dock/interaction/Preview.mjs')).default; producer = Neo.create(DockPreviewProducer) }); @@ -148,7 +148,7 @@ test.describe('Neo.dashboard.DockPreviewProducer (ADR 0029 §2.3 — the dock pr for (const [x, y, kind] of [[200, 150, 'tab-into'], [200, 50, 'edge-top'], [390, 150, 'edge-right'], [10, 150, 'edge-left'], [200, 290, 'edge-bottom']]) { const preview = producer.produce({pointer: {x, y}, zones, itemId: 'strategy', containerId: 'workspace'}); - expect(preview.schema).toBe('neo.harness.dockPreview.v1'); + expect(preview.schema).toBe('neo.dock.preview.v1'); expect(preview.placement.kind).toBe(kind); expect(preview.target.nodeId).toBe('main-tabs'); expect(preview.feedback.state).toBe('accepted'); @@ -171,7 +171,7 @@ test.describe('Neo.dashboard.DockPreviewProducer (ADR 0029 §2.3 — the dock pr }); test('whole-stack production carries one coherent runtime group through previews and candidates', async () => { - const {isValidCandidateSet} = await import('../../../../src/dashboard/dockPreviewContract.mjs'); + const {isValidCandidateSet} = await import('../../../../src/dashboard/dock/model/PreviewContract.mjs'); const zones = [{nodeId: 'main-tabs', rect: RECT, orientation: 'vertical'}]; const params = { groupNodeId: 'popup-stack', @@ -194,11 +194,12 @@ test.describe('Neo.dashboard.DockPreviewProducer (ADR 0029 §2.3 — the dock pr }); test('the produce → previewToOperation → applyOperation pipeline SPLITS the target for an edge drop', async () => { - const DockZoneModel = (await import('../../../../src/dashboard/DockZoneModel.mjs')).default; + const Operations = (await import('../../../../src/dashboard/dock/model/Operations.mjs')).default, + Document = (await import('../../../../src/dashboard/dock/model/Document.mjs')).default; // a minimal dockZone.v1 doc: a vertical split of two single-tab zones const doc = { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : {a: {componentRef: 'A', title: 'A', kind: 'panel'}, b: {componentRef: 'B', title: 'B', kind: 'panel'}}, nodes : { @@ -222,9 +223,9 @@ test.describe('Neo.dashboard.DockPreviewProducer (ADR 0029 §2.3 — the dock pr expect(descriptor.targetNodeId).toBe('b-tabs'); // the reducer applies it — a NEW split node appears (an interior tab-into move would not add one) - const result = DockZoneModel.applyOperation(doc, descriptor); + const result = Operations.applyOperation(doc, descriptor); expect(result.errors ?? []).toEqual([]); - expect(DockZoneModel.validate(result.document)).toEqual([]); // the split produced a valid dockZone.v1 tree + expect(Document.validate(result.document)).toEqual([]); // the split produced a valid dockZone.v1 tree // edge-left → a NEW horizontal split now exists (there were none before) → the pipeline genuinely split the target expect(Object.values(result.document.nodes).some(n => n.type === 'split' && n.orientation === 'horizontal'), @@ -232,7 +233,7 @@ test.describe('Neo.dashboard.DockPreviewProducer (ADR 0029 §2.3 — the dock pr }); test('produceCandidates emits the full §06 menu — schema-pinned, every preview consumer-valid', async () => { - const contract = await import('../../../../src/dashboard/dockPreviewContract.mjs'); + const contract = await import('../../../../src/dashboard/dock/model/PreviewContract.mjs'); const TALL = {x: 100, y: 100, width: 400, height: 300}; const ROOT = {x: 0, y: 0, width: 800, height: 600}; @@ -303,7 +304,7 @@ test.describe('Neo.dashboard.DockPreviewProducer (ADR 0029 §2.3 — the dock pr }); test('isValidCandidateSet rejects partial, duplicated, mismatched and lying menus', async () => { - const {isValidCandidateSet} = await import('../../../../src/dashboard/dockPreviewContract.mjs'); + const {isValidCandidateSet} = await import('../../../../src/dashboard/dock/model/PreviewContract.mjs'); const TALL = {x: 0, y: 0, width: 400, height: 300}; const valid = (orientation='vertical') => producer.produceCandidates({ diff --git a/test/playwright/unit/dashboard/DockProjectionReconciler.spec.mjs b/test/playwright/unit/dashboard/DockProjectionReconciler.spec.mjs index 15bb4f87e9..7c652972e0 100644 --- a/test/playwright/unit/dashboard/DockProjectionReconciler.spec.mjs +++ b/test/playwright/unit/dashboard/DockProjectionReconciler.spec.mjs @@ -11,15 +11,15 @@ import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; import Component from '../../../../src/component/Base.mjs'; import Container from '../../../../src/container/Base.mjs'; -import DockLayoutAdapter from '../../../../src/dashboard/DockLayoutAdapter.mjs'; -import DockProjectionReconciler from '../../../../src/dashboard/DockProjectionReconciler.mjs'; +import DockLayoutAdapter from '../../../../src/dashboard/dock/projection/LayoutAdapter.mjs'; +import DockProjectionReconciler from '../../../../src/dashboard/dock/projection/Reconciler.mjs'; import '../../../../src/manager/Instance.mjs'; import '../../../../src/button/Base.mjs'; import '../../../../src/tab/Container.mjs'; import '../../../../src/toolbar/Base.mjs'; const createRootTabsModel = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root-tabs', items : { alpha: {componentRef: 'alpha', kind: 'panel', title: 'Alpha'} @@ -30,7 +30,7 @@ const createRootTabsModel = () => ({ }); const createSplitModel = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root-split', items : { alpha: {componentRef: 'alpha', kind: 'panel', title: 'Alpha'}, @@ -105,7 +105,7 @@ const reconcileModel = async (model, mutate, {geometryOnly=false, preserveItemId return {host, nextModel, oldShell, panes, result, stagedCount} }; -test.describe('Neo.dashboard.DockProjectionReconciler', () => { +test.describe('Neo.dashboard.dock.projection.Reconciler', () => { test('keys retained tab chrome and reserves only its projected destination', () => { const retainedTab = {dockNodeId: 'primary-tabs', dockNodeType: 'tabs'}, diff --git a/test/playwright/unit/dashboard/DockRail.spec.mjs b/test/playwright/unit/dashboard/DockRail.spec.mjs index bbabf9f835..4e35e9b995 100644 --- a/test/playwright/unit/dashboard/DockRail.spec.mjs +++ b/test/playwright/unit/dashboard/DockRail.spec.mjs @@ -10,12 +10,12 @@ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; import Button from '../../../../src/button/Base.mjs'; -import DockLayoutAdapter from '../../../../src/dashboard/DockLayoutAdapter.mjs'; -import DockRail from '../../../../src/dashboard/DockRail.mjs'; +import DockLayoutAdapter from '../../../../src/dashboard/dock/projection/LayoutAdapter.mjs'; +import DockRail from '../../../../src/dashboard/dock/interaction/Rail.mjs'; import Panel from '../../../../src/dashboard/Panel.mjs'; const createDocument = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { editor : {componentRef: 'editor', title: 'Editor'}, @@ -52,7 +52,7 @@ const createStubOverlay = () => ({ } }); -test.describe('Neo.dashboard.DockRail', () => { +test.describe('Neo.dashboard.dock.interaction.Rail', () => { let rail; test.afterEach(() => { @@ -409,6 +409,13 @@ test.describe('Neo.dashboard.DockRail', () => { expect(pane).toBeTruthy(); expect(pane.cls).toContain('neo-dashboard-dock-placeholder'); expect(pane.dockItemId).toBe('terminal'); + + // Adapter-only fields, absent from the inline factory fallback: these turn red if the + // runtime namespace lookup ever misses and the optional chain degrades the placeholder. + expect(pane.ntype).toBe('dashboard-panel'); + expect(pane.header).toMatchObject({dockItemId: 'terminal', text: 'Terminal'}); + // the `data` getter is the state-provider shortcut; the adapter's payload rides the raw config + expect(pane._data?.missingComponentRef).toBe(true); }); test('threads the workspace default reveal fraction into the bound overlay', () => { @@ -453,7 +460,7 @@ test.describe('Neo.dashboard.DockRail', () => { }); }); -test.describe('Neo.dashboard.DockRail — revealed-tab state projection', () => { +test.describe('Neo.dashboard.dock.interaction.Rail — revealed-tab state projection', () => { let rail; test.afterEach(() => { diff --git a/test/playwright/unit/dashboard/DockRestorePlanner.spec.mjs b/test/playwright/unit/dashboard/DockRestorePlanner.spec.mjs index 0bee3c0575..174133d6d1 100644 --- a/test/playwright/unit/dashboard/DockRestorePlanner.spec.mjs +++ b/test/playwright/unit/dashboard/DockRestorePlanner.spec.mjs @@ -9,12 +9,13 @@ setup({ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -import DockRestorePlanner from '../../../../src/dashboard/DockRestorePlanner.mjs'; -import DockTopologyDiff from '../../../../src/dashboard/DockTopologyDiff.mjs'; -import DockZoneModel from '../../../../src/dashboard/DockZoneModel.mjs'; +import DockRestorePlanner from '../../../../src/dashboard/dock/persistence/RestorePlanner.mjs'; +import DockTopologyDiff from '../../../../src/dashboard/dock/model/TopologyDiff.mjs'; +import Document from '../../../../src/dashboard/dock/model/Document.mjs'; +import Operations from '../../../../src/dashboard/dock/model/Operations.mjs'; /** - * @summary Tests for Neo.dashboard.DockRestorePlanner — same-topology perspective restore via semantic ops. + * @summary Tests for Neo.dashboard.dock.persistence.RestorePlanner — same-topology perspective restore via semantic ops. * Pure-JSON: fingerprint gate, deterministic diff→op planning, fail-closed sequential application, and the * capture → mutate → restore round-trip (fingerprint equality + empty diff + itemId continuity: no pane is * ever destroyed, the unit-level never-remounted assertion). @@ -23,7 +24,7 @@ import DockZoneModel from '../../../../src/dashboard/DockZoneModel.mjs'; /** A canonical split document: a horizontal split of a two-tab main zone and a single-tab side zone. */ function doc() { return { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { strategy: {componentRef: 'strategy', title: 'Strategy', kind: 'panel'}, @@ -38,7 +39,7 @@ function doc() { } } -const fp = d => DockZoneModel.computeShapeFingerprint(d).fingerprint?.shape; +const fp = d => Document.computeShapeFingerprint(d).fingerprint?.shape; const emptyDiff = d => { const r = DockTopologyDiff.diffDockDocuments(d.a, d.b); return {moves: r.moves, adds: r.adds, removes: r.removes, resizes: r.resizes, tabReorders: r.tabReorders, autoHideFlips: r.autoHideFlips} @@ -50,9 +51,9 @@ test.describe('DockRestorePlanner — same-topology restore', () => { const captured = doc(); // mutate the live document: shrink the split + reorder main-tabs to [swarm, strategy] - let m1 = DockZoneModel.applyOperation(doc(), {operation: 'resizeSplit', splitNodeId: 'root', sizes: [0.25, 0.75]}); + let m1 = Operations.applyOperation(doc(), {operation: 'resizeSplit', splitNodeId: 'root', sizes: [0.25, 0.75]}); expect(m1.errors).toEqual([]); - let m2 = DockZoneModel.applyOperation(m1.document, {operation: 'moveItem', itemId: 'swarm', targetNodeId: 'main-tabs', index: 0}); + let m2 = Operations.applyOperation(m1.document, {operation: 'moveItem', itemId: 'swarm', targetNodeId: 'main-tabs', index: 0}); expect(m2.errors).toEqual([]); const current = m2.document; expect(current.nodes['main-tabs'].items).toEqual(['swarm', 'strategy']); @@ -77,7 +78,7 @@ test.describe('DockRestorePlanner — same-topology restore', () => { test('auto-hide flip round-trip: restore toggles the flag back through setItemAutoHidden', () => { const captured = doc(); - let m = DockZoneModel.applyOperation(doc(), {operation: 'setItemAutoHidden', itemId: 'terminal', autoHidden: true}); + let m = Operations.applyOperation(doc(), {operation: 'setItemAutoHidden', itemId: 'terminal', autoHidden: true}); expect(m.errors).toEqual([]); const current = m.document; expect(current.items.terminal.autoHidden).toBe(true); @@ -92,7 +93,7 @@ test.describe('DockRestorePlanner — same-topology restore', () => { const captured = doc(); // main-tabs [strategy, swarm], side-tabs [terminal] // current EXCHANGES terminal ↔ strategy across the two zones — same counts (t2, t1), same shape. const current = { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { strategy: {componentRef: 'strategy', title: 'Strategy', kind: 'panel'}, @@ -120,7 +121,7 @@ test.describe('DockRestorePlanner — same-topology restore', () => { test('unsolvable single-item swap cycle defers structurally (never crashes)', () => { const mk = (a, b) => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : {alpha: {componentRef: 'a', title: 'A', kind: 'panel'}, beta: {componentRef: 'b', title: 'B', kind: 'panel'}}, nodes : { @@ -140,7 +141,7 @@ test.describe('DockRestorePlanner — same-topology restore', () => { test('fingerprint mismatch defers structurally (never a silent partial)', () => { // move terminal into main-tabs → side-tabs empties + collapses → a different shape - let mm = DockZoneModel.applyOperation(doc(), {operation: 'moveItem', itemId: 'terminal', targetNodeId: 'main-tabs', index: 2}); + let mm = Operations.applyOperation(doc(), {operation: 'moveItem', itemId: 'terminal', targetNodeId: 'main-tabs', index: 2}); expect(mm.errors).toEqual([]); expect(fp(mm.document)).not.toBe(fp(doc())); @@ -152,8 +153,8 @@ test.describe('DockRestorePlanner — same-topology restore', () => { }); test('planRestore is deterministic for identical inputs', () => { - let m1 = DockZoneModel.applyOperation(doc(), {operation: 'resizeSplit', splitNodeId: 'root', sizes: [0.25, 0.75]}), - m2 = DockZoneModel.applyOperation(m1.document, {operation: 'moveItem', itemId: 'swarm', targetNodeId: 'main-tabs', index: 0}), + let m1 = Operations.applyOperation(doc(), {operation: 'resizeSplit', splitNodeId: 'root', sizes: [0.25, 0.75]}), + m2 = Operations.applyOperation(m1.document, {operation: 'moveItem', itemId: 'swarm', targetNodeId: 'main-tabs', index: 0}), current = m2.document, captured = doc(); expect(DockRestorePlanner.planRestore(current, captured).plan) diff --git a/test/playwright/unit/dashboard/DockRevealOverlay.spec.mjs b/test/playwright/unit/dashboard/DockRevealOverlay.spec.mjs index 633d15bf1e..612b0f9ddb 100644 --- a/test/playwright/unit/dashboard/DockRevealOverlay.spec.mjs +++ b/test/playwright/unit/dashboard/DockRevealOverlay.spec.mjs @@ -9,8 +9,8 @@ setup({ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -import DockMotionSignal from '../../../../src/dashboard/DockMotionSignal.mjs'; -import DockRevealOverlay from '../../../../src/dashboard/DockRevealOverlay.mjs'; +import DockMotionSignal from '../../../../src/dashboard/dock/projection/MotionSignal.mjs'; +import DockRevealOverlay from '../../../../src/dashboard/dock/interaction/RevealOverlay.mjs'; const createItem = (config={}) => ({ dockEdge : 'right', @@ -37,7 +37,7 @@ const createSerializedAnimationEnd = (targetId, pathIds=[targetId], rawId=target value : undefined }); -test.describe('Neo.dashboard.DockRevealOverlay', () => { +test.describe('Neo.dashboard.dock.interaction.RevealOverlay', () => { let overlay; test.afterEach(() => { diff --git a/test/playwright/unit/dashboard/DockRevealStateMachine.spec.mjs b/test/playwright/unit/dashboard/DockRevealStateMachine.spec.mjs index 456a9ccb69..6a0e8fb339 100644 --- a/test/playwright/unit/dashboard/DockRevealStateMachine.spec.mjs +++ b/test/playwright/unit/dashboard/DockRevealStateMachine.spec.mjs @@ -1,5 +1,5 @@ import {test, expect} from '@playwright/test'; -import DockRevealStateMachine from '../../../../src/dashboard/DockRevealStateMachine.mjs'; +import DockRevealStateMachine from '../../../../src/dashboard/dock/interaction/RevealStateMachine.mjs'; const createFakeTimers = () => { let nextId = 1, diff --git a/test/playwright/unit/dashboard/DockSplitter.spec.mjs b/test/playwright/unit/dashboard/DockSplitter.spec.mjs index 21e8a8b237..f4d7c40eba 100644 --- a/test/playwright/unit/dashboard/DockSplitter.spec.mjs +++ b/test/playwright/unit/dashboard/DockSplitter.spec.mjs @@ -9,11 +9,11 @@ setup({ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -import DockLayoutAdapter from '../../../../src/dashboard/DockLayoutAdapter.mjs'; -import DockSplitter from '../../../../src/dashboard/DockSplitter.mjs'; +import DockLayoutAdapter from '../../../../src/dashboard/dock/projection/LayoutAdapter.mjs'; +import DockSplitter from '../../../../src/dashboard/dock/interaction/DockSplitter.mjs'; const createDocument = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { left : {componentRef: 'left', title: 'Left'}, @@ -62,7 +62,7 @@ const createParent = () => ({ } }); -test.describe('Neo.dashboard.DockSplitter', () => { +test.describe('Neo.dashboard.dock.interaction.DockSplitter', () => { let splitter; test.afterEach(() => { @@ -94,7 +94,7 @@ test.describe('Neo.dashboard.DockSplitter', () => { }); splitter.dragZone = { - dragEnd: () => {} + destroy: () => {}, dragEnd: () => {}, isDestroyed: false, registerZone: async () => {}, set: () => {} }; splitter.on('dockSplitterResize', data => events.push(data)); @@ -137,7 +137,7 @@ test.describe('Neo.dashboard.DockSplitter', () => { }); splitter.dragZone = { - dragEnd: () => {} + destroy: () => {}, dragEnd: () => {}, isDestroyed: false, registerZone: async () => {}, set: () => {} }; splitter.on('dockSplitterResizeRejected', data => rejected.push(data)); @@ -175,7 +175,7 @@ test.describe('Neo.dashboard.DockSplitter', () => { }); splitter.dragZone = { - dragEnd: () => {} + destroy: () => {}, dragEnd: () => {}, isDestroyed: false, registerZone: async () => {}, set: () => {} }; await splitter.captureDragStart({clientX: 100, clientY: 0}); @@ -219,7 +219,7 @@ test.describe('Neo.dashboard.DockSplitter', () => { }); splitter.dragZone = { - dragEnd: () => {} + destroy: () => {}, dragEnd: () => {}, isDestroyed: false, registerZone: async () => {}, set: () => {} }; splitter.on('dockSplitterResizeRejected', data => rejected.push(data)); @@ -234,7 +234,7 @@ test.describe('Neo.dashboard.DockSplitter', () => { }); }); -test.describe('Neo.dashboard.DockSplitter — drag-proxy token projection', () => { +test.describe('Neo.dashboard.dock.interaction.DockSplitter — drag-proxy token projection', () => { /** * The projection restates the `--dock-splitter-*` contract in JS, and a restatement drifts. * This is the guard that makes the restatement safe: it parses the engine's own `.neo-dashboard` diff --git a/test/playwright/unit/dashboard/DockSplitterEquivalence.spec.mjs b/test/playwright/unit/dashboard/DockSplitterEquivalence.spec.mjs new file mode 100644 index 0000000000..ffe2dfb7ea --- /dev/null +++ b/test/playwright/unit/dashboard/DockSplitterEquivalence.spec.mjs @@ -0,0 +1,342 @@ +import {setup} from '../../setup.mjs'; + +setup({ + appConfig: { + name: 'NeoDashboardDockSplitterEquivalenceTest' + } +}); + +import {test, expect} from '@playwright/test'; +import Neo from '../../../../src/Neo.mjs'; +import * as core from '../../../../src/core/_export.mjs'; + +/** + * The DockSplitter parent-transition equivalence contract — behavior tier. + * + * Written against the pre-re-parent implementation (`component.Base` + direct DragZone) and + * required to pass UNCHANGED after DockSplitter adopts generic `Neo.component.Splitter` for its + * DragZone / live-resize / generation mechanics. The drive is synthetic and worker-local: the + * gesture handlers receive exactly the payloads the DOM routing delivers in production, so the + * contract under test is the class behavior itself — capture, vector resolution, the single + * fail-closed semantic commit, terminal events, and teardown — with zero DOM-transport variables. + * (The sibling component spec pins the projected config/class contract on a rendered instance.) + */ +test.describe('Neo.dashboard.dock.interaction.DockSplitter — behavior equivalence', () => { + let Container, DockSplitter, LayoutAdapter, container, splitter; + + const DOC = () => ({ + schema: 'neo.dock.zone.v1', + root : 'split-1', + items : { + alpha: {componentRef: 'alpha', title: 'Alpha'}, + beta : {componentRef: 'beta', title: 'Beta'} + }, + nodes: { + 'split-1': {type: 'split', orientation: 'horizontal', children: ['zone-a', 'zone-b'], sizes: [0.5, 0.5]}, + 'zone-a' : {type: 'tabs', items: ['alpha'], activeItemId: 'alpha'}, + 'zone-b' : {type: 'tabs', items: ['beta'], activeItemId: 'beta'} + } + }); + + test.beforeAll(async () => { + Container = (await import('../../../../src/container/Base.mjs')).default; + DockSplitter = (await import('../../../../src/dashboard/dock/interaction/DockSplitter.mjs')).default; + LayoutAdapter = (await import('../../../../src/dashboard/dock/projection/LayoutAdapter.mjs')).default + }); + + const mount = (splitterConfig = {}) => { + container = Neo.create(Container, { + layout: {ntype: 'hbox', align: 'stretch'}, + items : [ + {ntype: 'component', flex: 1, id: 'equiv-pane-a'}, + { + module : DockSplitter, + id : 'equiv-splitter', + boundaryIndex: 0, + orientation : 'horizontal', + splitNodeId : 'split-1', + ...splitterConfig + }, + {ntype: 'component', flex: 2, id: 'equiv-pane-b'} + ] + }); + + splitter = container.items[1]; + // the adapter marks projected splitters so they never count among the split children + splitter.dockNodeType = 'splitter'; + // gesture-neutral zone stub: the real DragZone posts to main-thread surfaces the unit + // environment does not own; terminal semantics under test live in the splitter itself + splitter.dragZone = { + destroy() {}, dragEnd() {}, dragStart() {}, isDestroyed: false, + async registerZone() {}, set() {} + }; + return splitter + }; + + test.afterEach(() => { + container?.destroy(); + container = splitter = null + }); + + test('capture prefers real rects and falls back to model-order flex weights on failure', async () => { + mount(); + + expect(splitter.getSplitChildItems().map(item => item.id)).toEqual(['equiv-pane-a', 'equiv-pane-b']); + + // rect arm: deterministic child geometry, splitter excluded from the vector + container.getDomRect = async () => [{width: 600}, {width: 250}, {width: 350}]; + let state = await splitter.captureDragStart({clientX: 300, clientY: 150}); + expect(state.clientX).toBe(300); + expect(state.sizes).toEqual([250, 350]); + + // fallback arm: a failed rect read degrades to flex weights, never throws + container.getDomRect = async () => { throw new Error('detached') }; + state = await splitter.captureDragStart({clientX: 310, clientY: 150}); + expect(state.sizes).toEqual([1, 2]) + }); + + test('terminal drag commits EXACTLY one normalized resizeSplit through the document path', async () => { + mount({dockZoneDocument: DOC()}); + + const commits = []; + splitter.onDockZoneDocumentChange = (document, descriptor) => commits.push({document, descriptor}); + + await splitter.captureDragStart({clientX: 300, clientY: 150}); + + // synthetic rect-derived capture (the component tier owns real-rect parity) + splitter.dragStartState.sizes = [300, 300]; + + const result = splitter.onDragEnd({clientX: 400, clientY: 150}); + + expect(result.errors).toEqual([]); + expect(commits).toHaveLength(1); + expect(commits[0].descriptor).toMatchObject({operation: 'resizeSplit', splitNodeId: 'split-1'}); + + const sizes = splitter.dockZoneDocument.nodes['split-1'].sizes; + expect(sizes[0]).toBeCloseTo(400 / 600, 5); // 300+100 of 600 + expect(sizes[0] + sizes[1]).toBeCloseTo(1, 5); + expect(splitter.dragStartState).toBe(null) // terminal always clears the capture + }); + + test('the reducer-callback authority wins over the local document and receives the splitter', async () => { + const seen = []; + mount({ + applyDockZoneOperation: (descriptor, instance) => { + seen.push({descriptor, instance}); + return {document: {patched: true}, errors: []} + }, + dockZoneDocument: DOC() + }); + + await splitter.captureDragStart({clientX: 300, clientY: 150}); + splitter.dragStartState.sizes = [300, 300]; + + const result = splitter.onDragEnd({clientX: 360, clientY: 150}); + + expect(seen).toHaveLength(1); + expect(seen[0].instance).toBe(splitter); + expect(result.document).toEqual({patched: true}); + expect(splitter.dockZoneDocument).toEqual({patched: true}) + }); + + test('an invalid vector fails closed: no commit, rejection event payload, document untouched', async () => { + mount({boundaryIndex: 7, dockZoneDocument: DOC()}); // out-of-range boundary + + const events = []; + splitter.on('dockSplitterResizeRejected', payload => events.push(payload)); + + await splitter.captureDragStart({clientX: 300, clientY: 150}); + + const before = Neo.clone(splitter.dockZoneDocument, true), + result = splitter.onDragEnd({clientX: 400, clientY: 150}); + + expect(result.errors.length).toBeGreaterThan(0); + expect(events).toHaveLength(1); + expect(splitter.dockZoneDocument).toEqual(before) + }); + + test('a missing commit authority is a loud structured error, never a silent no-op', async () => { + mount(); // no document, no callback + + await splitter.captureDragStart({clientX: 300, clientY: 150}); + splitter.dragStartState.sizes = [300, 300]; + + const result = splitter.onDragEnd({clientX: 400, clientY: 150}); + + expect(result.errors.join(' ')).toContain('dockZoneDocument'); + expect(result.document).toBe(null) + }); + + test('destroy mid-gesture is safe and re-entrant: no commit, no throw, zone torn down once', async () => { + mount({dockZoneDocument: DOC()}); + + let zoneDestroyed = 0; + splitter.dragZone.destroy = () => zoneDestroyed++; + + await splitter.captureDragStart({clientX: 300, clientY: 150}); + + const before = Neo.clone(splitter.dockZoneDocument, true); + + splitter.destroy(); + + expect(zoneDestroyed).toBeLessThanOrEqual(1); + expect(before.nodes['split-1'].sizes).toEqual([0.5, 0.5]) + }); + + test('positive control: an uninterrupted start opens the zone exactly once', async () => { + mount(); + + const zoneStarts = []; + splitter.dragZone.dragStart = data => zoneStarts.push(data); + container.getDomRect = async () => [{width: 600}, {width: 300}, {width: 300}]; + + await splitter.onDragStart({clientX: 300, clientY: 150}); + + expect(zoneStarts).toHaveLength(1); + expect(splitter.dragStartState?.sizes).toEqual([300, 300]) + }); + + test('cancel during the capture awaits invalidates the pending start: zero zone starts, no throw', async () => { + mount(); + + let release; + const zoneStarts = []; + splitter.dragZone.dragStart = data => zoneStarts.push(data); + container.getDomRect = () => new Promise(resolve => { + release = () => resolve([{width: 600}, {width: 300}, {width: 300}]) + }); + + const pending = splitter.onDragStart({clientX: 300, clientY: 150}); + + splitter.onDragCancel({}); + release(); + await pending; + + expect(zoneStarts).toHaveLength(0); + expect(splitter.dragStartState).toBe(null) + }); + + test('destroy during the capture awaits invalidates the pending start: zero zone starts, no throw', async () => { + mount(); + + let release; + const zoneStarts = []; + splitter.dragZone.dragStart = data => zoneStarts.push(data); + container.getDomRect = () => new Promise(resolve => { + release = () => resolve([{width: 600}, {width: 300}, {width: 300}]) + }); + + const pending = splitter.onDragStart({clientX: 300, clientY: 150}); + + splitter.destroy(); + release(); + await pending; + + expect(zoneStarts).toHaveLength(0) + }); + + test('a second start during the first\'s capture supersedes it: only the newest opens the zone', async () => { + mount(); + + const releases = [], zoneStarts = []; + splitter.dragZone.dragStart = data => zoneStarts.push(data); + container.getDomRect = () => new Promise(resolve => { + releases.push(() => resolve([{width: 600}, {width: 300}, {width: 300}])) + }); + + const first = splitter.onDragStart({clientX: 300, clientY: 150}), + second = splitter.onDragStart({clientX: 310, clientY: 150}); + + releases.forEach(release => release()); + await Promise.all([first, second]); + + expect(zoneStarts).toHaveLength(1); + expect(zoneStarts[0]).toMatchObject({clientX: 310}) + }); + + test('a real-pointer release overtaking the start commits nothing and cancels the pending start', async () => { + mount({dockZoneDocument: DOC()}); + + let release; + const zoneStarts = [], rejected = [], commits = []; + splitter.onDockZoneDocumentChange = (document, descriptor) => commits.push(descriptor); + splitter.on('dockSplitterResizeRejected', payload => rejected.push(payload)); + splitter.dragZone.dragStart = data => zoneStarts.push(data); + container.getDomRect = () => new Promise(resolve => { + release = () => resolve([{width: 600}, {width: 300}, {width: 300}]) + }); + + const before = Neo.clone(splitter.dockZoneDocument, true), + pending = splitter.onDragStart({clientX: 300, clientY: 150}), + result = splitter.onDragEnd({clientX: 340, clientY: 150}); // release overtakes the capture + + release(); + await pending; + + expect(result.errors.join(' ')).toContain('without capture'); + expect(rejected).toHaveLength(1); + expect(commits).toHaveLength(0); + expect(zoneStarts).toHaveLength(0); + expect(splitter.dockZoneDocument).toEqual(before) + }); + + test('a generation bump while the zone opens cancels the gesture through the inherited fence', async () => { + mount(); + + const zoneEnds = []; + splitter.dragZone.dragStart = async () => { splitter.dragGeneration++ }; + splitter.dragZone.dragEnd = data => zoneEnds.push(data); + container.getDomRect = async () => [{width: 600}, {width: 300}, {width: 300}]; + + await splitter.onDragStart({clientX: 300, clientY: 150}); + + expect(zoneEnds).toHaveLength(1); + expect(zoneEnds[0]).toMatchObject({cancelled: true}) + }); + + test('the resize seam stays parked: no main-thread descriptor in either proxy mode', async () => { + mount(); + + // dock inherits the generic default: proxy presentation until the live-preview leaf lands + expect(splitter.liveResize).toBe(false); + expect(splitter.getResizeConfig()).toBe(null); + + const pushed = []; + splitter.dragZone.set = config => pushed.push(config); + + await splitter.refreshDragZone(); + splitter.liveResize = true; // afterSet re-drives the zone on its own + await splitter.refreshDragZone(); + + expect(pushed[0]).toMatchObject({resizeConfig: null, useProxy: true}); + expect(pushed.at(-1)).toMatchObject({resizeConfig: null, useProxy: false}) + }); + + test('the descriptor factory resolves identity from configs when no projected data exists', () => { + mount(); + + const descriptor = LayoutAdapter.createResizeSplitOperation(splitter, [0.7, 0.3]); + + expect(descriptor).toEqual({operation: 'resizeSplit', sizes: [0.7, 0.3], splitNodeId: 'split-1'}) + }); + + test('mechanism control: the generic gesture machinery is inherited, never duplicated', async () => { + const GenericSplitter = (await import('../../../../src/component/Splitter.mjs')).default; + + mount(); + + // positive control for the no-second-splitter-engine invariant: a bypass would have to + // re-implement one of these locally, and re-implementing any of them turns this red. + expect(splitter instanceof GenericSplitter).toBe(true); + + const ownMembers = Object.getOwnPropertyNames(Object.getPrototypeOf(splitter)); + + for (const inherited of ['createDragZone', 'refreshDragZone', 'onDragCancel', 'applyResize', 'construct', 'destroy']) { + expect(ownMembers, `${inherited} stays inherited from the generic Splitter`).not.toContain(inherited) + } + + // the dock terminal is the one deliberate override, and the generation fence exists + expect(ownMembers).toContain('onDragEnd'); + expect(Number.isInteger(splitter.dragGeneration)).toBe(true) + }); +}); diff --git a/test/playwright/unit/dashboard/DockTabEnterButton.spec.mjs b/test/playwright/unit/dashboard/DockTabEnterButton.spec.mjs index 6dbed64d01..340bbacae2 100644 --- a/test/playwright/unit/dashboard/DockTabEnterButton.spec.mjs +++ b/test/playwright/unit/dashboard/DockTabEnterButton.spec.mjs @@ -9,13 +9,13 @@ setup({ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -import DockMotionSignal from '../../../../src/dashboard/DockMotionSignal.mjs'; -import DockTabEnterButton from '../../../../src/dashboard/DockTabEnterButton.mjs'; -import DockZoneModel from '../../../../src/dashboard/DockZoneModel.mjs'; -import DockWorkspace from '../../../../src/dashboard/DockWorkspace.mjs'; +import DockMotionSignal from '../../../../src/dashboard/dock/projection/MotionSignal.mjs'; +import DockTabEnterButton from '../../../../src/dashboard/dock/interaction/TabEnterButton.mjs'; +import Operations from '../../../../src/dashboard/dock/model/Operations.mjs'; +import DockWorkspace from '../../../../src/dashboard/dock/Workspace.mjs'; const createModel = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { strategy: {componentRef: 'Strategy', title: 'Strategy'}, @@ -69,7 +69,7 @@ const findProjectedNode = (config, nodeId) => { return null }; -test.describe('Neo.dashboard.DockTabEnterButton', () => { +test.describe('Neo.dashboard.dock.interaction.TabEnterButton', () => { let buttons = []; test.afterEach(() => { @@ -162,9 +162,9 @@ test.describe('Neo.dashboard.DockTabEnterButton', () => { test('the refresh owner captures a globally absent addTab once; moves, reorders and later refreshes stay inert', async () => { let initial = createModel(), descriptor = {operation: 'addTab', itemId: 'terminal', tabsNodeId: 'main-tabs', index: 2}, - moved = DockZoneModel.applyOperation(initial, descriptor), - detached = DockZoneModel.applyOperation(initial, {operation: 'detachItem', itemId: 'terminal'}), - inserted = DockZoneModel.applyOperation(detached.document, descriptor), + moved = Operations.applyOperation(initial, descriptor), + detached = Operations.applyOperation(initial, {operation: 'detachItem', itemId: 'terminal'}), + inserted = Operations.applyOperation(detached.document, descriptor), refreshes = [], // a duck-typed refresh owner borrowing the engine class's commit loop: the members the // loop consults are supplied explicitly; the refresh spy records the one-use correlation @@ -211,7 +211,7 @@ test.describe('Neo.dashboard.DockTabEnterButton', () => { // Same-target addTab is a reorder: the header existed before the commit, so no insertion. let reorder = {operation: 'addTab', itemId: 'terminal', tabsNodeId: 'main-tabs', index: 0}, - reordered = DockZoneModel.applyOperation(context.dockModel, reorder); + reordered = Operations.applyOperation(context.dockModel, reorder); DockWorkspace.prototype.onDockZoneDocumentChange.call(context, reordered.document, reorder); await context.refreshPromise; diff --git a/test/playwright/unit/dashboard/DockTabSortZone.spec.mjs b/test/playwright/unit/dashboard/DockTabSortZone.spec.mjs index 0ca138d96d..44443974d2 100644 --- a/test/playwright/unit/dashboard/DockTabSortZone.spec.mjs +++ b/test/playwright/unit/dashboard/DockTabSortZone.spec.mjs @@ -9,7 +9,7 @@ setup({ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -import DockTabSortZone from '../../../../src/dashboard/DockTabSortZone.mjs'; +import DockTabSortZone from '../../../../src/dashboard/dock/interaction/TabSortZone.mjs'; import TabHeaderSortZone from '../../../../src/draggable/tab/header/toolbar/SortZone.mjs'; /** @@ -21,7 +21,7 @@ import TabHeaderSortZone from '../../../../src/draggable/tab/header/toolbar/Sort * minimal owner chain (the method reads only `owner.cls` / `owner.getTheme()` / the `parent` * walk); the rendered consequence rides the visual harness. */ -test.describe('Neo.dashboard.DockTabSortZone', () => { +test.describe('Neo.dashboard.dock.interaction.TabSortZone', () => { test('keeps dock headers parent-sized and toolbar-relative during a drag', () => { expect(DockTabSortZone.config.adjustItemRectsToParent).toBe(true); expect(DockTabSortZone.config.expandOwnerOnDrag).toBe(false); diff --git a/test/playwright/unit/dashboard/DockTearOut.spec.mjs b/test/playwright/unit/dashboard/DockTearOut.spec.mjs index a4be1083d3..9943a810d3 100644 --- a/test/playwright/unit/dashboard/DockTearOut.spec.mjs +++ b/test/playwright/unit/dashboard/DockTearOut.spec.mjs @@ -11,7 +11,7 @@ setup({ }); import {test, expect} from '@playwright/test'; -import {createDockTearOutHandlers} from '../../../../src/dashboard/DockTearOut.mjs'; +import {createDockTearOutHandlers} from '../../../../src/dashboard/dock/window/TearOut.mjs'; /** * @summary The tear-out admission machine, driven end-to-end through its injected seams. @@ -23,7 +23,7 @@ import {createDockTearOutHandlers} from '../../../../src/dashboard/DockTearOut.m * and a committed tear-out KEEPS its vessel while a refused commit retires it. The seams are the * assertion surface — the machine exposes nothing else. */ -test.describe('Neo.dashboard.DockTearOut — createDockTearOutHandlers', () => { +test.describe('Neo.dashboard.dock.window.TearOut — createDockTearOutHandlers', () => { const harness = ({admit = true, closeResult = true, commitErrors = [], commitThrows = false, openResult = null} = {}) => { const calls = {applied: [], closed: [], ended: 0, opened: [], started: [], synced: []}; diff --git a/test/playwright/unit/dashboard/DockTopologyDiff.spec.mjs b/test/playwright/unit/dashboard/DockTopologyDiff.spec.mjs index 4641a5b4ae..1e827c4016 100644 --- a/test/playwright/unit/dashboard/DockTopologyDiff.spec.mjs +++ b/test/playwright/unit/dashboard/DockTopologyDiff.spec.mjs @@ -9,7 +9,7 @@ setup({ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -import DockTopologyDiff from '../../../../src/dashboard/DockTopologyDiff.mjs'; +import DockTopologyDiff from '../../../../src/dashboard/dock/model/TopologyDiff.mjs'; /** * A fresh canonical dockZone.v1 document mirroring the executor spec's fixture: @@ -18,7 +18,7 @@ import DockTopologyDiff from '../../../../src/dashboard/DockTopologyDiff.mjs'; */ function doc() { return { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { strategy : {componentRef: 'strategy', title: 'Strategy', kind: 'panel'}, @@ -35,7 +35,7 @@ function doc() { } } -test.describe('Neo.dashboard.DockTopologyDiff (#14650)', () => { +test.describe('Neo.dashboard.dock.model.TopologyDiff (#14650)', () => { test('an identical pair yields only unchanged items — and catalog-only items stay invisible to topology', () => { const result = DockTopologyDiff.diffDockDocuments(doc(), doc()); diff --git a/test/playwright/unit/dashboard/DockTopologyReconciler.spec.mjs b/test/playwright/unit/dashboard/DockTopologyReconciler.spec.mjs index ad4f4952a0..46ebdf1807 100644 --- a/test/playwright/unit/dashboard/DockTopologyReconciler.spec.mjs +++ b/test/playwright/unit/dashboard/DockTopologyReconciler.spec.mjs @@ -9,12 +9,12 @@ setup({ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -import DockRestorePlanner from '../../../../src/dashboard/DockRestorePlanner.mjs'; -import DockTopologyReconciler from '../../../../src/dashboard/DockTopologyReconciler.mjs'; -import DockZoneModel from '../../../../src/dashboard/DockZoneModel.mjs'; +import DockRestorePlanner from '../../../../src/dashboard/dock/persistence/RestorePlanner.mjs'; +import DockTopologyReconciler from '../../../../src/dashboard/dock/model/TopologyReconciler.mjs'; +import Persistence from '../../../../src/dashboard/dock/model/Persistence.mjs'; const tabsDoc = ids => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'r', items : Object.fromEntries(ids.map(id => [id, {componentRef: id, title: id}])), nodes : { @@ -172,7 +172,7 @@ const randomAssignmentDoc = (random, salt) => { // Fixtures ride the REAL landed producer — hand-rolled envelopes only appear in the negative // cases that deliberately break the envelope contract. const capture = docs => { - let {layout, errors} = DockZoneModel.captureTopologyPerspective(docs, {layoutId: 'test-perspective', title: 'Test'}); + let {layout, errors} = Persistence.captureTopologyPerspective(docs, {layoutId: 'test-perspective', title: 'Test'}); if (errors.length) { throw new Error(`fixture capture failed: ${errors[0]}`) @@ -191,7 +191,7 @@ const expectConservation = (saved, result) => { expect(covered).toEqual(capturedIdsOf(saved).sort()) }; -test.describe('Neo.dashboard.DockTopologyReconciler', () => { +test.describe('Neo.dashboard.dock.model.TopologyReconciler', () => { test('polynomial solver is output-equivalent to the bounded exhaustive oracle on seeded rectangular matrices', () => { for (let seed = 1; seed <= 96; seed++) { let random = seededRandom(seed), @@ -443,7 +443,7 @@ test.describe('Neo.dashboard.DockTopologyReconciler', () => { let valid = capture([tabsDoc(['alpha']), tabsDoc(['beta'])]); // Foreign wrapper schema. - expectFailClosed({...valid, schema: 'neo.harness.dockLayout.v999'}, 'foreign schema'); + expectFailClosed({...valid, schema: 'neo.dock.layout.v999'}, 'foreign schema'); // Wrong scope smuggling windowDocuments. expectFailClosed({...valid, captureScope: 'window'}, 'window-scope + smuggled windowDocuments'); @@ -460,7 +460,7 @@ test.describe('Neo.dashboard.DockTopologyReconciler', () => { let badSlot = expectFailClosed({ ...valid, windowDocuments: [{ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'r', items : {}, nodes : {r: {type: 'tabs', items: ['ghost'], activeItemId: 'ghost'}} @@ -529,7 +529,7 @@ test.describe('Neo.dashboard.DockTopologyReconciler', () => { // slot 1 restores it in place, so slot 0's placement would IMPORT a second copy into // W0 — live ownership convicts the importer, and the id stays single in the output. let shared = { - schema : DockZoneModel.LAYOUT_SCHEMA, + schema : Persistence.LAYOUT_SCHEMA, layoutId : 'dup-test', title : 'Dup', captureScope : 'topology', diff --git a/test/playwright/unit/dashboard/DockVesselConversion.spec.mjs b/test/playwright/unit/dashboard/DockVesselConversion.spec.mjs index 9bcdec330f..4d629a4793 100644 --- a/test/playwright/unit/dashboard/DockVesselConversion.spec.mjs +++ b/test/playwright/unit/dashboard/DockVesselConversion.spec.mjs @@ -12,7 +12,7 @@ setup({ }); import {test, expect} from '@playwright/test'; -import {createVesselConversionSensor} from '../../../../src/dashboard/DockVesselConversion.mjs'; +import {createVesselConversionSensor} from '../../../../src/dashboard/dock/window/VesselConversion.mjs'; /** * @summary The dual-window conversion sensor, driven end-to-end through its injected seams. @@ -25,7 +25,7 @@ import {createVesselConversionSensor} from '../../../../src/dashboard/DockVessel * fails CLOSED — a converted sensor fed NaN reverts instead of freezing. The seams are the * decision surface; the returned sample record is the geometry surface. */ -test.describe('Neo.dashboard.DockVesselConversion — createVesselConversionSensor', () => { +test.describe('Neo.dashboard.dock.window.VesselConversion — createVesselConversionSensor', () => { const harness = (config = {}) => { const calls = {converted: [], reverted: []}; diff --git a/test/playwright/unit/dashboard/DockVesselEmbodiment.spec.mjs b/test/playwright/unit/dashboard/DockVesselEmbodiment.spec.mjs index 0b38ed1139..9796f8a16b 100644 --- a/test/playwright/unit/dashboard/DockVesselEmbodiment.spec.mjs +++ b/test/playwright/unit/dashboard/DockVesselEmbodiment.spec.mjs @@ -16,9 +16,9 @@ import Container from '../../../../src/container/Base.mjs'; import { createDockVesselEmbodiment, createDockVesselProxyEmbodiment -} from '../../../../src/dashboard/DockVesselEmbodiment.mjs'; +} from '../../../../src/dashboard/dock/window/VesselEmbodiment.mjs'; -test.describe('Neo.dashboard.DockVesselEmbodiment (#15396)', () => { +test.describe('Neo.dashboard.dock.window.VesselEmbodiment (#15396)', () => { let embodiment, pane, proxyEmbodiment, source, target; test.beforeEach(() => { diff --git a/test/playwright/unit/dashboard/DockVesselPark.spec.mjs b/test/playwright/unit/dashboard/DockVesselPark.spec.mjs index 4b6e30856a..37fdaa1b82 100644 --- a/test/playwright/unit/dashboard/DockVesselPark.spec.mjs +++ b/test/playwright/unit/dashboard/DockVesselPark.spec.mjs @@ -12,7 +12,7 @@ setup({ }); import {test, expect} from '@playwright/test'; -import {createVesselParkHandlers} from '../../../../src/dashboard/DockVesselPark.mjs'; +import {createVesselParkHandlers} from '../../../../src/dashboard/dock/window/VesselPark.mjs'; /** * @summary The in-gesture vessel park machine, driven end-to-end through its injected seams. @@ -24,7 +24,7 @@ import {createVesselParkHandlers} from '../../../../src/dashboard/DockVesselPark * every other outcome failing toward restore, and stale events (duplicate convert-in, slotless * out/terminal, mismatched itemId) are silent no-ops. The seams are the assertion surface. */ -test.describe('Neo.dashboard.DockVesselPark — createVesselParkHandlers', () => { +test.describe('Neo.dashboard.dock.window.VesselPark — createVesselParkHandlers', () => { const harness = () => { const calls = {disposed: [], parked: [], reshown: []}; diff --git a/test/playwright/unit/dashboard/DockWorkspace.spec.mjs b/test/playwright/unit/dashboard/DockWorkspace.spec.mjs index 9ec7917542..bae35a0fe8 100644 --- a/test/playwright/unit/dashboard/DockWorkspace.spec.mjs +++ b/test/playwright/unit/dashboard/DockWorkspace.spec.mjs @@ -12,13 +12,13 @@ import * as core from '../../../../src/core/_export.mjs'; import '../../../../src/manager/Instance.mjs'; // defines Neo.get — the container child-add path resolves parents through it import '../../../../src/tab/Container.mjs'; // registers the `tab-container` ntype the projection emits import Container from '../../../../src/container/Base.mjs'; -import DockLayoutAdapter from '../../../../src/dashboard/DockLayoutAdapter.mjs'; -import DockProjectionReconciler from '../../../../src/dashboard/DockProjectionReconciler.mjs'; +import DockLayoutAdapter from '../../../../src/dashboard/dock/projection/LayoutAdapter.mjs'; +import DockProjectionReconciler from '../../../../src/dashboard/dock/projection/Reconciler.mjs'; import DockService from '../../../../src/ai/client/DockService.mjs'; -import DockWorkspace from '../../../../src/dashboard/DockWorkspace.mjs'; +import DockWorkspace from '../../../../src/dashboard/dock/Workspace.mjs'; const createDocument = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { editor : {componentRef: 'Editor', title: 'Editor', kind: 'panel'}, @@ -213,7 +213,7 @@ const * single mutation path, the atomic deferred re-projection chain, the hooks a consumer owns, the * dock-host indirection, and teardown. */ -test.describe('Neo.dashboard.DockWorkspace', () => { +test.describe('Neo.dashboard.dock.Workspace', () => { let workspace; test.afterEach(() => { diff --git a/test/playwright/unit/dashboard/DockWorkspaceSet.spec.mjs b/test/playwright/unit/dashboard/DockWorkspaceSet.spec.mjs index f704e3baf2..8e966c09c0 100644 --- a/test/playwright/unit/dashboard/DockWorkspaceSet.spec.mjs +++ b/test/playwright/unit/dashboard/DockWorkspaceSet.spec.mjs @@ -9,7 +9,7 @@ setup({ }); import {test, expect} from '@playwright/test'; -import {createDockWorkspaceSet} from '../../../../src/dashboard/DockWorkspaceSet.mjs'; +import {createDockWorkspaceSet} from '../../../../src/dashboard/dock/window/WorkspaceSet.mjs'; /** * @summary The worker-owned `{workspaceId → document}` registry contract (docking design @@ -17,7 +17,7 @@ import {createDockWorkspaceSet} from '../../../../src/dashboard/DockWorkspaceSet * a resolution cannot be proven, and both-or-neither adoption for an atomic transfer's committed * pair. Retirement never happens implicitly — the registry outlives any render target. */ -test.describe('Neo.dashboard.DockWorkspaceSet — the workspace-set registry', () => { +test.describe('Neo.dashboard.dock.window.WorkspaceSet — the workspace-set registry', () => { let set; test.beforeEach(() => { diff --git a/test/playwright/unit/dashboard/DockZoneModel.spec.mjs b/test/playwright/unit/dashboard/DockZoneModel.spec.mjs index 8fc4ec3cc7..bb8e4e8dea 100644 --- a/test/playwright/unit/dashboard/DockZoneModel.spec.mjs +++ b/test/playwright/unit/dashboard/DockZoneModel.spec.mjs @@ -6,17 +6,20 @@ setup({ } }); -import {test, expect} from '@playwright/test'; -import Neo from '../../../../src/Neo.mjs'; -import * as core from '../../../../src/core/_export.mjs'; -import DockWorkspace from '../../../../src/dashboard/DockWorkspace.mjs'; -import DockZoneModel from '../../../../src/dashboard/DockZoneModel.mjs'; -import MainContainer from '../../../../examples/dashboard/dock/MainContainer.mjs'; -import Toolbar from '../../../../src/toolbar/Base.mjs'; +import {test, expect} from '@playwright/test'; +import Neo from '../../../../src/Neo.mjs'; +import * as core from '../../../../src/core/_export.mjs'; +import DockWorkspace from '../../../../src/dashboard/dock/Workspace.mjs'; +import Document from '../../../../src/dashboard/dock/model/Document.mjs'; +import Operations from '../../../../src/dashboard/dock/model/Operations.mjs'; +import Persistence from '../../../../src/dashboard/dock/model/Persistence.mjs'; +import PerspectiveLibrary from '../../../../src/dashboard/dock/persistence/PerspectiveLibrary.mjs'; +import MainContainer from '../../../../examples/dashboard/dock/MainContainer.mjs'; +import Toolbar from '../../../../src/toolbar/Base.mjs'; import '../../../../src/manager/Instance.mjs'; /** - * @summary Tests for Neo.dashboard.DockZoneModel — the dock-zone semantic operations executor. + * @summary Tests for Neo.dashboard.dock.model.Document — the dock-zone semantic operations executor. * Primarily pure JSON: validity invariants, each operation, fail-closed behavior, normalizeTree * collapse, and the previewToOperation descriptor seam. The standalone-example block additionally * mounts its persistent toolbar to pin identity reconciliation over collection mutations. @@ -29,7 +32,7 @@ import '../../../../src/manager/Instance.mjs'; */ function doc() { return { - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : { strategy : {componentRef: 'strategy', title: 'Strategy', kind: 'panel'}, @@ -68,7 +71,7 @@ function splitDoc(sizes=[0.5, 0.5]) { /** Collects the tabs node id holding an item, across a document. */ function tabsOf(document, itemId) { - return DockZoneModel.findContainingTabsId(document, itemId) + return Document.findContainingTabsId(document, itemId) } /** Creates a valid saved-layout wrapper for collection tests. */ @@ -77,41 +80,41 @@ function savedLayout(layoutId, title=layoutId, mutate=()=>{}) { mutate(d); - return DockZoneModel.createSavedLayout(d, { + return Persistence.createSavedLayout(d, { layoutId, title }).layout } -test.describe('Neo.dashboard.DockZoneModel', () => { +test.describe('Neo.dashboard.dock.model.Document', () => { test.describe('validate (invariants)', () => { test('accepts the canonical document', () => { - expect(DockZoneModel.validate(doc())).toEqual([]) + expect(Document.validate(doc())).toEqual([]) }); test('rejects a wrong schema', () => { const d = doc(); - d.schema = 'neo.harness.dockZone.v2'; - expect(DockZoneModel.validate(d).length).toBeGreaterThan(0) + d.schema = 'neo.dock.zone.v2'; + expect(Document.validate(d).length).toBeGreaterThan(0) }); test('rejects a missing root', () => { const d = doc(); d.root = 'ghost'; - expect(DockZoneModel.validate(d).length).toBeGreaterThan(0) + expect(Document.validate(d).length).toBeGreaterThan(0) }); test('rejects a dangling node reference', () => { const d = doc(); d.nodes.root.zones.center = 'does-not-exist'; - expect(DockZoneModel.validate(d).length).toBeGreaterThan(0) + expect(Document.validate(d).length).toBeGreaterThan(0) }); test('rejects an item used in two tabs nodes', () => { const d = doc(); d.nodes['side-tabs'].items.push('strategy'); // strategy now in main-tabs AND side-tabs - expect(DockZoneModel.validate(d).join(' ')).toContain('strategy'); - expect(DockZoneModel.validate(d).length).toBeGreaterThan(0) + expect(Document.validate(d).join(' ')).toContain('strategy'); + expect(Document.validate(d).length).toBeGreaterThan(0) }); test('rejects split sizes that mismatch or do not sum to 1', () => { @@ -119,22 +122,22 @@ test.describe('Neo.dashboard.DockZoneModel', () => { d.nodes.split = {type: 'split', orientation: 'horizontal', children: ['main-tabs', 'side-tabs'], sizes: [0.5]}; d.nodes.root.zones.center = 'split'; delete d.nodes.root.zones.right; - expect(DockZoneModel.validate(d).length).toBeGreaterThan(0); + expect(Document.validate(d).length).toBeGreaterThan(0); d.nodes.split.sizes = [0.5, 0.9]; - expect(DockZoneModel.validate(d).join(' ')).toContain('sum to 1') + expect(Document.validate(d).join(' ')).toContain('sum to 1') }); test('rejects an activeItemId not among the tab items', () => { const d = doc(); d.nodes['main-tabs'].activeItemId = 'terminal'; - expect(DockZoneModel.validate(d).length).toBeGreaterThan(0) + expect(Document.validate(d).length).toBeGreaterThan(0) }) }); test.describe('saved layout persistence', () => { test('creates and restores a versioned saved-layout wrapper', () => { - const {layout, errors} = DockZoneModel.createSavedLayout(doc(), { + const {layout, errors} = Persistence.createSavedLayout(doc(), { layoutId: 'operator-default', title : 'Operator Default', revision: 3, @@ -144,15 +147,15 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); expect(errors).toEqual([]); - expect(layout.schema).toBe(DockZoneModel.LAYOUT_SCHEMA); + expect(layout.schema).toBe(Persistence.LAYOUT_SCHEMA); expect(layout.layoutId).toBe('operator-default'); expect(layout.title).toBe('Operator Default'); expect(layout.revision).toBe(3); expect(layout.metadata.workspace).toBe('agent-harness'); - expect(layout.dockZone.schema).toBe(DockZoneModel.SCHEMA); - expect(DockZoneModel.validate(layout.dockZone)).toEqual([]); + expect(layout.dockZone.schema).toBe(Document.SCHEMA); + expect(Document.validate(layout.dockZone)).toEqual([]); - const restored = DockZoneModel.restoreSavedLayout(layout); + const restored = Persistence.restoreSavedLayout(layout); expect(restored.errors).toEqual([]); expect(restored.document).toEqual(layout.dockZone); @@ -160,53 +163,47 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('fails closed for unsupported wrapper schema', () => { - const {layout} = DockZoneModel.createSavedLayout(doc(), { + const {layout} = Persistence.createSavedLayout(doc(), { layoutId: 'operator-default', title : 'Operator Default' }); - layout.schema = 'neo.harness.dockLayout.v3'; + layout.schema = 'neo.dock.layout.v2'; - const {document, errors} = DockZoneModel.restoreSavedLayout(layout); + const {document, errors} = Persistence.restoreSavedLayout(layout); expect(document).toBe(null); - expect(errors.join(' ')).toContain(DockZoneModel.LAYOUT_SCHEMA) + expect(errors.join(' ')).toContain(Persistence.LAYOUT_SCHEMA) }); - test('reads a legacy v1 record fail-open with honest perspective defaults', () => { - const {layout} = DockZoneModel.createSavedLayout(doc(), { + test('the retired neo.harness.* family is rejected fail-closed — no migration reader survives', () => { + const {layout} = Persistence.createSavedLayout(doc(), { layoutId: 'legacy', title : 'Legacy Layout' }); - // shape a stored-era v1 record: old schema tag, no perspective fields - const v1 = {...layout, schema: DockZoneModel.LAYOUT_SCHEMA_V1}; - delete v1.captureScope; - delete v1.windowFingerprint; + // a stored-era old-family record: pre-greenfield schema tag, no perspective fields. + // This control proves FAMILY deletion, not just version rejection: the string is a + // well-formed old-family identity, and it must fail on schema — never fail-open + // through a compatibility parser. + // split literal on purpose: this is the ONE place the retired family name must keep + // existing verbatim, immune to any future rename sweep over whole schema strings. + const oldFamily = ['neo', 'harness', 'dockLayout', 'v1'].join('.'); + const legacy = {...layout, schema: oldFamily}; + delete legacy.captureScope; + delete legacy.windowFingerprint; - const restored = DockZoneModel.restoreSavedLayout(v1); + const restored = Persistence.restoreSavedLayout(legacy); - expect(restored.errors).toEqual([]); - expect(restored.document).toEqual(layout.dockZone); - // the input record is never mutated (pure migration) - expect(v1.schema).toBe(DockZoneModel.LAYOUT_SCHEMA_V1); - expect('captureScope' in v1).toBe(false) - }); - - test('migrateSavedLayout upgrades v1 with defaults and is idempotent on v2', () => { - const v1 = {schema: DockZoneModel.LAYOUT_SCHEMA_V1, layoutId: 'a', title: 'A', dockZone: {}}; - - const migrated = DockZoneModel.migrateSavedLayout(v1); - - expect(migrated.schema).toBe(DockZoneModel.LAYOUT_SCHEMA); - expect(migrated.captureScope).toBe('window'); - expect(migrated.windowFingerprint).toBe(null); - expect('perspectiveName' in migrated).toBe(false); - expect(DockZoneModel.migrateSavedLayout(migrated)).toBe(migrated) + expect(restored.document).toBe(null); + expect(restored.errors.join(' ')).toContain(Persistence.LAYOUT_SCHEMA); + // the input record is never mutated by rejection + expect(legacy.schema).toBe(oldFamily); + expect('captureScope' in legacy).toBe(false) }); test('round-trips the v2 perspective fields (topology scope, fingerprint, name)', () => { - const {layout, errors} = DockZoneModel.createSavedLayout(doc(), { + const {layout, errors} = Persistence.createSavedLayout(doc(), { layoutId : 'focus', title : 'Focus', captureScope : 'topology', @@ -218,24 +215,24 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(layout.captureScope).toBe('topology'); expect(layout.windowFingerprint).toEqual({windows: 2, splits: [2, 1]}); expect(layout.perspectiveName).toBe('Focus Mode'); - expect(DockZoneModel.restoreSavedLayout(layout).errors).toEqual([]) + expect(Persistence.restoreSavedLayout(layout).errors).toEqual([]) }); test('capturePerspective emits a v2 window-scope record with a shape-only fingerprint', () => { - const {layout, errors} = DockZoneModel.capturePerspective(doc(), { + const {layout, errors} = Persistence.capturePerspective(doc(), { layoutId : 'capture-1', title : 'Capture One', perspectiveName: 'Morning Focus' }); expect(errors).toEqual([]); - expect(layout.schema).toBe(DockZoneModel.LAYOUT_SCHEMA); + expect(layout.schema).toBe(Persistence.LAYOUT_SCHEMA); expect(layout.captureScope).toBe('window'); expect(layout.perspectiveName).toBe('Morning Focus'); - expect(layout.windowFingerprint.schema).toBe('neo.harness.dockShape.v1'); + expect(layout.windowFingerprint.schema).toBe('neo.dock.shape.v1'); expect(typeof layout.windowFingerprint.shape).toBe('string'); expect(layout.windowFingerprint.itemCount).toBeGreaterThan(0); - expect(DockZoneModel.restoreSavedLayout(layout).errors).toEqual([]) + expect(Persistence.restoreSavedLayout(layout).errors).toEqual([]) }); test('stored fingerprint matches the PERSISTED document, not the pre-normalized input (single-child split collapse)', () => { @@ -246,15 +243,15 @@ test.describe('Neo.dashboard.DockZoneModel', () => { delete d.nodes['side-tabs']; delete d.items.terminal; - const {layout, errors} = DockZoneModel.capturePerspective(d, {layoutId: 'c', title: 'C'}); + const {layout, errors} = Persistence.capturePerspective(d, {layoutId: 'c', title: 'C'}); expect(errors).toEqual([]); // the stored fingerprint must equal the persisted tree's fingerprint by construction… expect(layout.windowFingerprint) - .toEqual(DockZoneModel.computeShapeFingerprint(layout.dockZone).fingerprint); + .toEqual(Document.computeShapeFingerprint(layout.dockZone).fingerprint); // …and must NOT carry the collapsed split wrapper the raw input had expect(layout.windowFingerprint.shape) - .not.toBe(DockZoneModel.computeShapeFingerprint(d).fingerprint.shape); + .not.toBe(Document.computeShapeFingerprint(d).fingerprint.shape); expect(layout.windowFingerprint.shape).not.toContain('h(') }); @@ -263,38 +260,38 @@ test.describe('Neo.dashboard.DockZoneModel', () => { cyclic.nodes['loop-split'] = {type: 'split', orientation: 'horizontal', children: ['main-tabs', 'loop-split'], sizes: [0.5, 0.5]}; cyclic.nodes.root.zones.center = 'loop-split'; - const direct = DockZoneModel.computeShapeFingerprint(cyclic); + const direct = Document.computeShapeFingerprint(cyclic); expect(direct.fingerprint).toBe(null); expect(direct.errors.join(' ')).toContain('cycle'); - const captured = DockZoneModel.capturePerspective(cyclic, {layoutId: 'x', title: 'X'}); + const captured = Persistence.capturePerspective(cyclic, {layoutId: 'x', title: 'X'}); expect(captured.layout).toBe(null); expect(captured.errors.length).toBeGreaterThan(0) }); test('shape fingerprints are deterministic, id-free and shape-sensitive', () => { - const a = DockZoneModel.computeShapeFingerprint(doc()), - b = DockZoneModel.computeShapeFingerprint(doc()); + const a = Document.computeShapeFingerprint(doc()), + b = Document.computeShapeFingerprint(doc()); expect(a.errors).toEqual([]); expect(a.fingerprint).toEqual(b.fingerprint); // rename every node id — the shape must not change (id-freedom) const renamed = JSON.parse(JSON.stringify(doc()).replaceAll('main-tabs', 'renamed-tabs')); - expect(DockZoneModel.computeShapeFingerprint(renamed).fingerprint.shape).toBe(a.fingerprint.shape); + expect(Document.computeShapeFingerprint(renamed).fingerprint.shape).toBe(a.fingerprint.shape); // structural change → different shape term const mutated = doc(); mutated.nodes['main-tabs'].items.push('extra-item'); - expect(DockZoneModel.computeShapeFingerprint(mutated).fingerprint.shape).not.toBe(a.fingerprint.shape) + expect(Document.computeShapeFingerprint(mutated).fingerprint.shape).not.toBe(a.fingerprint.shape) }); test('topology fingerprints compose per-window terms in slot order and fail closed on bad input', () => { - const single = DockZoneModel.computeShapeFingerprint(doc()).fingerprint; + const single = Document.computeShapeFingerprint(doc()).fingerprint; - const two = DockZoneModel.composeTopologyFingerprint([single, single]); + const two = Document.composeTopologyFingerprint([single, single]); expect(two.errors).toEqual([]); - expect(two.fingerprint.schema).toBe('neo.harness.dockTopologyShape.v1'); + expect(two.fingerprint.schema).toBe('neo.dock.topologyShape.v1'); expect(two.fingerprint.windowCount).toBe(2); expect(two.fingerprint.shape).toBe(`w[${single.shape}|${single.shape}]`); expect(two.fingerprint.totalItems).toBe(single.itemCount * 2); @@ -302,32 +299,32 @@ test.describe('Neo.dashboard.DockZoneModel', () => { // slot order IS meaning: reversed input must produce a different term when shapes differ const mutated = doc(); mutated.nodes['main-tabs'].items.push('extra-item'); - const other = DockZoneModel.computeShapeFingerprint(mutated).fingerprint; - expect(DockZoneModel.composeTopologyFingerprint([single, other]).fingerprint.shape) - .not.toBe(DockZoneModel.composeTopologyFingerprint([other, single]).fingerprint.shape); + const other = Document.computeShapeFingerprint(mutated).fingerprint; + expect(Document.composeTopologyFingerprint([single, other]).fingerprint.shape) + .not.toBe(Document.composeTopologyFingerprint([other, single]).fingerprint.shape); // degenerate single-window composition wraps the same term - expect(DockZoneModel.composeTopologyFingerprint([single]).fingerprint.shape).toBe(`w[${single.shape}]`); + expect(Document.composeTopologyFingerprint([single]).fingerprint.shape).toBe(`w[${single.shape}]`); // fail-closed: empty list + non-fingerprint entry - expect(DockZoneModel.composeTopologyFingerprint([]).fingerprint).toBe(null); - const bad = DockZoneModel.composeTopologyFingerprint([single, {shape: 42}]); + expect(Document.composeTopologyFingerprint([]).fingerprint).toBe(null); + const bad = Document.composeTopologyFingerprint([single, {shape: 42}]); expect(bad.fingerprint).toBe(null); expect(bad.errors.join(' ')).toContain('entry 1') }); test('topology composition rejects incomplete window fingerprints — a missing itemCount never fakes a zero', () => { // right schema, right shape, NO itemCount: must fail closed, never compose totalItems: 0 - const incomplete = DockZoneModel.composeTopologyFingerprint([{schema: 'neo.harness.dockShape.v1', shape: 't1'}]); + const incomplete = Document.composeTopologyFingerprint([{schema: 'neo.dock.shape.v1', shape: 't1'}]); expect(incomplete.fingerprint).toBe(null); expect(incomplete.errors.join(' ')).toContain('entry 0'); expect(incomplete.errors.join(' ')).toContain('incomplete'); // malformed counts are rejected the same way, with the offending slot indexed - const single = DockZoneModel.computeShapeFingerprint(doc()).fingerprint; + const single = Document.computeShapeFingerprint(doc()).fingerprint; for (const itemCount of [NaN, -1, 1.5, '3']) { - const malformed = DockZoneModel.composeTopologyFingerprint([single, {...single, itemCount}]); + const malformed = Document.composeTopologyFingerprint([single, {...single, itemCount}]); expect(malformed.fingerprint).toBe(null); expect(malformed.errors.join(' ')).toContain('entry 1') } @@ -337,7 +334,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { const second = doc(); second.nodes['main-tabs'].items.push('inspector'); - const {layout, errors} = DockZoneModel.captureTopologyPerspective([doc(), second], { + const {layout, errors} = Persistence.captureTopologyPerspective([doc(), second], { layoutId : 'fleet', title : 'Fleet', perspectiveName: 'Fleet View' @@ -345,16 +342,16 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(errors).toEqual([]); expect(layout.captureScope).toBe('topology'); - expect(layout.windowFingerprint.schema).toBe('neo.harness.dockTopologyShape.v1'); + expect(layout.windowFingerprint.schema).toBe('neo.dock.topologyShape.v1'); expect(layout.windowFingerprint.windowCount).toBe(2); expect(layout.windowDocuments.length).toBe(1); - expect(DockZoneModel.validate(layout.windowDocuments[0])).toEqual([]); - expect(DockZoneModel.restoreSavedLayout(layout).errors).toEqual([]) + expect(Document.validate(layout.windowDocuments[0])).toEqual([]); + expect(Persistence.restoreSavedLayout(layout).errors).toEqual([]) }); test('degenerate single-document topology capture equals a window-scope capture modulo scope + fingerprint schema', () => { - const topo = DockZoneModel.captureTopologyPerspective([doc()], {layoutId: 'solo', title: 'Solo'}).layout, - win = DockZoneModel.capturePerspective(doc(), {layoutId: 'solo', title: 'Solo'}).layout; + const topo = Persistence.captureTopologyPerspective([doc()], {layoutId: 'solo', title: 'Solo'}).layout, + win = Persistence.capturePerspective(doc(), {layoutId: 'solo', title: 'Solo'}).layout; expect('windowDocuments' in topo).toBe(false); expect(topo.dockZone).toEqual(win.dockZone); @@ -372,44 +369,44 @@ test.describe('Neo.dashboard.DockZoneModel', () => { delete collapsing.nodes['side-tabs']; delete collapsing.items.terminal; - const {layout, errors} = DockZoneModel.captureTopologyPerspective([doc(), collapsing], {layoutId: 't', title: 'T'}); + const {layout, errors} = Persistence.captureTopologyPerspective([doc(), collapsing], {layoutId: 't', title: 'T'}); expect(errors).toEqual([]); - const slotTerm = DockZoneModel.computeShapeFingerprint(layout.windowDocuments[0]).fingerprint.shape; + const slotTerm = Document.computeShapeFingerprint(layout.windowDocuments[0]).fingerprint.shape; expect(layout.windowFingerprint.shape).toContain(slotTerm); expect(layout.windowFingerprint.shape).not.toContain('h('); expect(slotTerm.startsWith('h(')).toBe(false) }); test('windowDocuments fails closed on window-scope records and on invalid slot trees', () => { - const base = DockZoneModel.capturePerspective(doc(), {layoutId: 'x', title: 'X'}).layout; + const base = Persistence.capturePerspective(doc(), {layoutId: 'x', title: 'X'}).layout; const smuggled = {...base, windowDocuments: [doc()]}; - expect(DockZoneModel.restoreSavedLayout(smuggled).errors.join(' ')) + expect(Persistence.restoreSavedLayout(smuggled).errors.join(' ')) .toContain('only valid on captureScope "topology"'); const badTree = doc(); badTree.root = 'ghost'; - const {layout, errors} = DockZoneModel.captureTopologyPerspective([doc(), badTree], {layoutId: 'x', title: 'X'}); + const {layout, errors} = Persistence.captureTopologyPerspective([doc(), badTree], {layoutId: 'x', title: 'X'}); expect(layout).toBe(null); expect(errors.join(' ')).toContain('documents[1]') }); test('windowDocuments slots enforce the SAME finite durable-field boundary as the primary document', () => { - const {layout} = DockZoneModel.captureTopologyPerspective([doc(), doc()], {layoutId: 'x', title: 'X'}); + const {layout} = Persistence.captureTopologyPerspective([doc(), doc()], {layoutId: 'x', title: 'X'}); // A runtime-bearing field on an ADDITIONAL slot must fail exactly like it would on // `dockZone` — document-level and item-level offenders both, index preserved. const slot = layout.windowDocuments[0], poisoned = {...layout, windowDocuments: [{...slot, runtimeRect: {x: 0, y: 0}}]}, - topLevel = DockZoneModel.restoreSavedLayout(poisoned).errors.join(' '); + topLevel = Persistence.restoreSavedLayout(poisoned).errors.join(' '); expect(topLevel).toContain('windowDocuments[0]'); expect(topLevel).toContain('runtimeRect'); const [itemId] = Object.keys(slot.items), badItems = {...slot, items: {[itemId]: {...slot.items[itemId], windowId: 'w2'}}}, - itemLevel = DockZoneModel.restoreSavedLayout({...layout, windowDocuments: [badItems]}).errors.join(' '); + itemLevel = Persistence.restoreSavedLayout({...layout, windowDocuments: [badItems]}).errors.join(' '); expect(itemLevel).toContain(`windowDocuments[0].items.${itemId}`); expect(itemLevel).toContain('windowId') @@ -419,28 +416,28 @@ test.describe('Neo.dashboard.DockZoneModel', () => { const broken = doc(); broken.root = 'missing-node'; - const {fingerprint, errors} = DockZoneModel.computeShapeFingerprint(broken); + const {fingerprint, errors} = Document.computeShapeFingerprint(broken); expect(fingerprint).toBe(null); expect(errors.join(' ')).toContain('missing-node') }); test('fails closed on perspective-field contract violations', () => { - const badScope = DockZoneModel.createSavedLayout(doc(), {layoutId: 'x', title: 'X', captureScope: 'galaxy'}); + const badScope = Persistence.createSavedLayout(doc(), {layoutId: 'x', title: 'X', captureScope: 'galaxy'}); expect(badScope.layout).toBe(null); expect(badScope.errors.join(' ')).toContain('captureScope'); - const badPrint = DockZoneModel.createSavedLayout(doc(), {layoutId: 'x', title: 'X', windowFingerprint: 'w1'}); + const badPrint = Persistence.createSavedLayout(doc(), {layoutId: 'x', title: 'X', windowFingerprint: 'w1'}); expect(badPrint.layout).toBe(null); expect(badPrint.errors.join(' ')).toContain('windowFingerprint'); - const badName = DockZoneModel.createSavedLayout(doc(), {layoutId: 'x', title: 'X', perspectiveName: ' '}); + const badName = Persistence.createSavedLayout(doc(), {layoutId: 'x', title: 'X', perspectiveName: ' '}); expect(badName.layout).toBe(null); expect(badName.errors.join(' ')).toContain('perspectiveName') }); test('fails closed for malformed wrapper identity fields', () => { - const created = DockZoneModel.createSavedLayout(doc(), { + const created = Persistence.createSavedLayout(doc(), { layoutId: '', title : 'Operator Default' }); @@ -448,8 +445,8 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(created.layout).toBe(null); expect(created.errors.join(' ')).toContain('layoutId'); - const restored = DockZoneModel.restoreSavedLayout({ - schema : DockZoneModel.LAYOUT_SCHEMA, + const restored = Persistence.restoreSavedLayout({ + schema : Persistence.LAYOUT_SCHEMA, layoutId: 'operator-default', title : '', dockZone: doc(), @@ -462,14 +459,14 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('fails closed for an invalid dock-zone document', () => { - const {layout} = DockZoneModel.createSavedLayout(doc(), { + const {layout} = Persistence.createSavedLayout(doc(), { layoutId: 'operator-default', title : 'Operator Default' }); layout.dockZone.nodes.root.zones.center = 'missing-tabs'; - const {document, errors} = DockZoneModel.restoreSavedLayout(layout); + const {document, errors} = Persistence.restoreSavedLayout(layout); expect(document).toBe(null); expect(errors.join(' ')).toContain('missing-tabs') @@ -482,7 +479,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { dockPreview: {placement: 'split-after'} }; - const saved = DockZoneModel.createSavedLayout(input, { + const saved = Persistence.createSavedLayout(input, { layoutId: 'operator-default', title : 'Operator Default' }); @@ -490,14 +487,14 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(saved.layout).toBe(null); expect(saved.errors.join(' ')).toContain('dockPreview'); - const {layout} = DockZoneModel.createSavedLayout(doc(), { + const {layout} = Persistence.createSavedLayout(doc(), { layoutId: 'operator-default', title : 'Operator Default' }); layout.windowId = 7; - const restored = DockZoneModel.restoreSavedLayout(layout); + const restored = Persistence.restoreSavedLayout(layout); expect(restored.document).toBe(null); expect(restored.errors.join(' ')).toContain('windowId') @@ -515,7 +512,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }; input.items.strategy.pinned = true; - const {layout, errors} = DockZoneModel.createSavedLayout(input, { + const {layout, errors} = Persistence.createSavedLayout(input, { layoutId: 'operator-default', title : 'Operator Default', metadata: { @@ -531,7 +528,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { input.items.strategy.windowId = 42; - const rejected = DockZoneModel.createSavedLayout(input, { + const rejected = Persistence.createSavedLayout(input, { layoutId: 'operator-default', title : 'Operator Default' }); @@ -543,7 +540,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test('rejects secret-like saved-layout metadata keys on save or restore', () => { for (const key of ['apiKey', 'sessionKey', 'authKey']) { const metadata = {[key]: 'secret-value'}, - saved = DockZoneModel.createSavedLayout(doc(), { + saved = Persistence.createSavedLayout(doc(), { layoutId: 'operator-default', title : 'Operator Default', metadata @@ -554,7 +551,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(metadata[key]).toBe('secret-value') } - const nested = DockZoneModel.createSavedLayout(doc(), { + const nested = Persistence.createSavedLayout(doc(), { layoutId: 'operator-default', title : 'Operator Default', metadata: { @@ -567,7 +564,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(nested.layout).toBe(null); expect(nested.errors.join(' ')).toContain('apiToken'); - const {layout} = DockZoneModel.createSavedLayout(doc(), { + const {layout} = Persistence.createSavedLayout(doc(), { layoutId: 'operator-default', title : 'Operator Default', metadata: { @@ -581,7 +578,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { } }; - const restored = DockZoneModel.restoreSavedLayout(layout); + const restored = Persistence.restoreSavedLayout(layout); expect(restored.document).toBe(null); expect(restored.errors.join(' ')).toContain('authKey') @@ -593,7 +590,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { input.items.strategy.pinned = 'yes'; input.items.terminal.autoHidden = 'yes'; - const saved = DockZoneModel.createSavedLayout(input, { + const saved = Persistence.createSavedLayout(input, { layoutId: 'operator-default', title : 'Operator Default' }); @@ -603,7 +600,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(saved.errors.join(' ')).toContain('autoHidden'); const savedLayout = { - schema : DockZoneModel.LAYOUT_SCHEMA, + schema : Persistence.LAYOUT_SCHEMA, layoutId : 'operator-default', title : 'Operator Default', dockZone : doc(), @@ -615,7 +612,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { savedLayout.dockZone.items.strategy.pinned = 'yes'; savedLayout.dockZone.items.terminal.autoHidden = 'yes'; - const restored = DockZoneModel.restoreSavedLayout(savedLayout); + const restored = Persistence.restoreSavedLayout(savedLayout); expect(restored.document).toBe(null); expect(restored.errors.join(' ')).toContain('pinned'); @@ -628,7 +625,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { input.items.terminal.pinned = true; input.items.terminal.autoHidden = true; - const saved = DockZoneModel.createSavedLayout(input, { + const saved = Persistence.createSavedLayout(input, { layoutId: 'operator-default', title : 'Operator Default' }); @@ -637,7 +634,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(saved.errors.join(' ')).toContain('cannot be pinned and autoHidden'); const savedLayout = { - schema : DockZoneModel.LAYOUT_SCHEMA, + schema : Persistence.LAYOUT_SCHEMA, layoutId : 'operator-default', title : 'Operator Default', dockZone : doc(), @@ -649,14 +646,14 @@ test.describe('Neo.dashboard.DockZoneModel', () => { savedLayout.dockZone.items.terminal.pinned = true; savedLayout.dockZone.items.terminal.autoHidden = true; - const restored = DockZoneModel.restoreSavedLayout(savedLayout); + const restored = Persistence.restoreSavedLayout(savedLayout); expect(restored.document).toBe(null); expect(restored.errors.join(' ')).toContain('cannot be pinned and autoHidden') }); test('rejects non-JSON values in metadata and item blueprints', () => { - const badMetadata = DockZoneModel.createSavedLayout(doc(), { + const badMetadata = Persistence.createSavedLayout(doc(), { layoutId: 'operator-default', title : 'Operator Default', metadata: { @@ -676,7 +673,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { } }; - const badBlueprint = DockZoneModel.createSavedLayout(input, { + const badBlueprint = Persistence.createSavedLayout(input, { layoutId: 'operator-default', title : 'Operator Default' }); @@ -699,7 +696,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { delete input.nodes.root.zones.right; const snapshot = JSON.stringify(input), - {layout, errors} = DockZoneModel.createSavedLayout(input, { + {layout, errors} = Persistence.createSavedLayout(input, { layoutId: 'operator-default', title : 'Operator Default', metadata: meta @@ -716,7 +713,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(layout.metadata.workspace).toBe('mutated-layout'); expect(layout.dockZone.nodes.split.sizes[0]).toBe(0.4); - const {layout: freshLayout} = DockZoneModel.createSavedLayout(input, { + const {layout: freshLayout} = Persistence.createSavedLayout(input, { layoutId: 'operator-default', title : 'Operator Default', metadata: meta @@ -733,7 +730,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { review = savedLayout('review-layout', 'Review Layout', d => { d.nodes['main-tabs'].activeItemId = 'strategy' }), - {collection, errors} = DockZoneModel.createSavedLayoutCollection([operator, review], { + {collection, errors} = PerspectiveLibrary.createSavedLayoutCollection([operator, review], { activeLayoutId: 'review-layout', revision : 2, metadata : { @@ -742,12 +739,12 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); expect(errors).toEqual([]); - expect(collection.schema).toBe(DockZoneModel.LAYOUT_COLLECTION_SCHEMA); + expect(collection.schema).toBe(PerspectiveLibrary.LAYOUT_COLLECTION_SCHEMA); expect(collection.activeLayoutId).toBe('review-layout'); expect(collection.revision).toBe(2); expect(collection.metadata.workspace).toBe('agent-harness'); expect(Object.keys(collection.layouts)).toEqual(['operator-default', 'review-layout']); - expect(DockZoneModel.validateSavedLayoutCollection(collection)).toEqual([]); + expect(PerspectiveLibrary.validateSavedLayoutCollection(collection)).toEqual([]); operator.title = 'Mutated By Caller'; expect(collection.layouts['operator-default'].title).toBe('Operator Default'); @@ -756,31 +753,31 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test('rejects wrong collection schema and mismatched layout keys', () => { const operator = savedLayout('operator-default', 'Operator Default'), - created = DockZoneModel.createSavedLayoutCollection([operator]); + created = PerspectiveLibrary.createSavedLayoutCollection([operator]); expect(created.errors).toEqual([]); - const wrongSchema = DockZoneModel.clone(created.collection); + const wrongSchema = Document.clone(created.collection); - wrongSchema.schema = 'neo.harness.dockLayoutCollection.v2'; + wrongSchema.schema = 'neo.dock.layoutCollection.v2'; - expect(DockZoneModel.validateSavedLayoutCollection(wrongSchema).join(' ')).toContain(DockZoneModel.LAYOUT_COLLECTION_SCHEMA); - expect(DockZoneModel.restoreActiveSavedLayout(wrongSchema).document).toBe(null); + expect(PerspectiveLibrary.validateSavedLayoutCollection(wrongSchema).join(' ')).toContain(PerspectiveLibrary.LAYOUT_COLLECTION_SCHEMA); + expect(PerspectiveLibrary.restoreActiveSavedLayout(wrongSchema).document).toBe(null); - const mismatched = DockZoneModel.clone(created.collection); + const mismatched = Document.clone(created.collection); mismatched.layouts.alias = mismatched.layouts['operator-default']; delete mismatched.layouts['operator-default']; mismatched.activeLayoutId = 'alias'; - expect(DockZoneModel.validateSavedLayoutCollection(mismatched).join(' ')).toContain('must match') + expect(PerspectiveLibrary.validateSavedLayoutCollection(mismatched).join(' ')).toContain('must match') }); test('upserts layouts by layoutId, clones replacements, and optionally activates them', () => { const operator = savedLayout('operator-default', 'Operator Default'), review = savedLayout('review-layout', 'Review Layout'), - {collection} = DockZoneModel.createSavedLayoutCollection([operator]), - updated = DockZoneModel.upsertSavedLayout(collection, review, {activate: true}); + {collection} = PerspectiveLibrary.createSavedLayoutCollection([operator]), + updated = PerspectiveLibrary.upsertSavedLayout(collection, review, {activate: true}); expect(updated.errors).toEqual([]); expect(updated.collection.activeLayoutId).toBe('review-layout'); @@ -790,11 +787,11 @@ test.describe('Neo.dashboard.DockZoneModel', () => { review.title = 'Mutated Review'; expect(updated.collection.layouts['review-layout'].title).toBe('Review Layout'); - const invalid = DockZoneModel.clone(review); + const invalid = Document.clone(review); invalid.dockZone.nodes.root.zones.center = 'missing-tabs'; - const rejected = DockZoneModel.upsertSavedLayout(collection, invalid, {activate: true}); + const rejected = PerspectiveLibrary.upsertSavedLayout(collection, invalid, {activate: true}); expect(rejected.collection).toBe(collection); expect(rejected.errors.join(' ')).toContain('missing-tabs') @@ -803,14 +800,14 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test('selects an existing active layout and fails closed for missing ids', () => { const operator = savedLayout('operator-default', 'Operator Default'), review = savedLayout('review-layout', 'Review Layout'), - {collection} = DockZoneModel.createSavedLayoutCollection([operator, review]), - selected = DockZoneModel.selectSavedLayout(collection, 'review-layout'); + {collection} = PerspectiveLibrary.createSavedLayoutCollection([operator, review]), + selected = PerspectiveLibrary.selectSavedLayout(collection, 'review-layout'); expect(selected.errors).toEqual([]); expect(selected.collection.activeLayoutId).toBe('review-layout'); expect(collection.activeLayoutId).toBe('operator-default'); - const missing = DockZoneModel.selectSavedLayout(collection, 'ghost-layout'); + const missing = PerspectiveLibrary.selectSavedLayout(collection, 'ghost-layout'); expect(missing.collection).toBe(collection); expect(missing.errors.join(' ')).toContain('ghost-layout') @@ -819,13 +816,13 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test('removes layouts and requires an explicit replacement for the active layout', () => { const operator = savedLayout('operator-default', 'Operator Default'), review = savedLayout('review-layout', 'Review Layout'), - {collection} = DockZoneModel.createSavedLayoutCollection([operator, review]), - denied = DockZoneModel.removeSavedLayout(collection, {layoutId: 'operator-default'}); + {collection} = PerspectiveLibrary.createSavedLayoutCollection([operator, review]), + denied = PerspectiveLibrary.removeSavedLayout(collection, {layoutId: 'operator-default'}); expect(denied.collection).toBe(collection); expect(denied.errors.join(' ')).toContain('replacementLayoutId'); - const removedActive = DockZoneModel.removeSavedLayout(collection, { + const removedActive = PerspectiveLibrary.removeSavedLayout(collection, { layoutId : 'operator-default', replacementLayoutId: 'review-layout' }); @@ -834,7 +831,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(removedActive.collection.activeLayoutId).toBe('review-layout'); expect(removedActive.collection.layouts['operator-default']).toBeUndefined(); - const removedInactive = DockZoneModel.removeSavedLayout(collection, {layoutId: 'review-layout'}); + const removedInactive = PerspectiveLibrary.removeSavedLayout(collection, {layoutId: 'review-layout'}); expect(removedInactive.errors).toEqual([]); expect(removedInactive.collection.activeLayoutId).toBe('operator-default'); @@ -846,20 +843,20 @@ test.describe('Neo.dashboard.DockZoneModel', () => { review = savedLayout('review-layout', 'Review Layout', d => { d.nodes['main-tabs'].activeItemId = 'strategy' }), - {collection} = DockZoneModel.createSavedLayoutCollection([operator, review], { + {collection} = PerspectiveLibrary.createSavedLayoutCollection([operator, review], { activeLayoutId: 'review-layout' }), - restored = DockZoneModel.restoreActiveSavedLayout(collection); + restored = PerspectiveLibrary.restoreActiveSavedLayout(collection); expect(restored.errors).toEqual([]); expect(restored.document).toEqual(review.dockZone); expect(restored.document).not.toBe(review.dockZone); - const invalid = DockZoneModel.clone(collection); + const invalid = Document.clone(collection); invalid.activeLayoutId = 'ghost-layout'; - const rejected = DockZoneModel.restoreActiveSavedLayout(invalid); + const rejected = PerspectiveLibrary.restoreActiveSavedLayout(invalid); expect(rejected.document).toBe(null); expect(rejected.errors.join(' ')).toContain('ghost-layout') @@ -867,18 +864,18 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test('rejects invalid saved layouts and secret-like collection metadata', () => { const operator = savedLayout('operator-default', 'Operator Default'), - invalid = DockZoneModel.clone(operator); + invalid = Document.clone(operator); invalid.metadata = { authKey: 'secret-value' }; - const rejectedLayout = DockZoneModel.createSavedLayoutCollection([invalid]); + const rejectedLayout = PerspectiveLibrary.createSavedLayoutCollection([invalid]); expect(rejectedLayout.collection).toBe(null); expect(rejectedLayout.errors.join(' ')).toContain('authKey'); - const rejectedCollection = DockZoneModel.createSavedLayoutCollection([operator], { + const rejectedCollection = PerspectiveLibrary.createSavedLayoutCollection([operator], { metadata: { apiToken: 'secret-value' } @@ -921,7 +918,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }; example.layoutCollection = example.createDefaultLayoutCollection(); - example.dockModel = DockZoneModel.restoreActiveSavedLayout(example.layoutCollection).document; + example.dockModel = PerspectiveLibrary.restoreActiveSavedLayout(example.layoutCollection).document; return example } @@ -957,7 +954,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(toolbar.items[1].pressed).toBe(true); expect(toolbar.items[2].pressed).toBe(false); - const resized = DockZoneModel.applyOperation(example.dockModel, { + const resized = Operations.applyOperation(example.dockModel, { operation : 'resizeSplit', sizes : [0.4, 0.6], splitNodeId: 'root-split' @@ -1042,7 +1039,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { d.nodes['main-tabs'].activeItemId = 'strategy' }), persistedDefault = savedLayout('persisted-default', 'Persisted Default'), - {collection} = DockZoneModel.createSavedLayoutCollection([persistedDefault, persistedReview], { + {collection} = PerspectiveLibrary.createSavedLayoutCollection([persistedDefault, persistedReview], { activeLayoutId: 'persisted-review' }); @@ -1059,7 +1056,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(hydrated.dockModel).toEqual(persistedReview.dockZone); expect(hydrated.refreshCount).toBe(1); - readValue = JSON.stringify({schema: 'neo.harness.dockLayoutCollection.v0'}); + readValue = JSON.stringify({schema: 'neo.dock.layoutCollection.v0'}); const invalid = createExampleHarness(), invalidLoad = await invalid.loadLayoutCollectionFromStorage(); @@ -1086,16 +1083,16 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test('the vocabulary IS the dispatch table — derived keys, bidirectional by construction', () => { // one structure carries both: a handler cannot exist without being exported, // and an exported name cannot exist without its handler - expect(DockZoneModel.operations).toEqual(Object.keys(DockZoneModel.operationHandlers)); + expect(Operations.operations).toEqual(Object.keys(Operations.operationHandlers)); - for (const operation of DockZoneModel.operations) { - expect(typeof DockZoneModel.operationHandlers[operation]).toBe('function') + for (const operation of Operations.operations) { + expect(typeof Operations.operationHandlers[operation]).toBe('function') } }); test('every exported operation dispatches through the executor contract', () => { - for (const operation of DockZoneModel.operations) { - const {errors} = DockZoneModel.applyOperation(doc(), {operation}); + for (const operation of Operations.operations) { + const {errors} = Operations.applyOperation(doc(), {operation}); // per-operation validation errors are fine; the unknown-operation rejection // firing for an EXPORTED name means vocabulary and dispatch have drifted @@ -1105,7 +1102,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test('an unexported operation is rejected fail-closed with the document untouched', () => { const input = doc(); - const {document, errors} = DockZoneModel.applyOperation(input, {operation: 'renameItem'}); + const {document, errors} = Operations.applyOperation(input, {operation: 'renameItem'}); expect(errors).toEqual(['unknown operation "renameItem"']); expect(document).toEqual(input) @@ -1113,68 +1110,68 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test('inherited object keys never resolve to handlers — own-key dispatch only', () => { for (const hostile of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) { - const {errors} = DockZoneModel.applyOperation(doc(), {operation: hostile}); + const {errors} = Operations.applyOperation(doc(), {operation: hostile}); expect(errors).toEqual([`unknown operation "${hostile}"`]) } }); test('the vocabulary and the dispatch table are frozen against consumer mutation', () => { - expect(Object.isFrozen(DockZoneModel.operations)).toBe(true); - expect(Object.isFrozen(DockZoneModel.operationHandlers)).toBe(true); - expect(() => DockZoneModel.operations.push('rogueOp')).toThrow() + expect(Object.isFrozen(Operations.operations)).toBe(true); + expect(Object.isFrozen(Operations.operationHandlers)).toBe(true); + expect(() => Operations.operations.push('rogueOp')).toThrow() }); }); test.describe('addTab', () => { test('inserts a catalog-only item at index and makes it active', () => { - const {document, errors} = DockZoneModel.addTab(doc(), {itemId: 'inspector', tabsNodeId: 'main-tabs', index: 1}); + const {document, errors} = Operations.addTab(doc(), {itemId: 'inspector', tabsNodeId: 'main-tabs', index: 1}); expect(errors).toEqual([]); expect(document.nodes['main-tabs'].items).toEqual(['strategy', 'inspector', 'swarm']); expect(document.nodes['main-tabs'].activeItemId).toBe('inspector') }); test('relocates an item already in the tree without duplicating it', () => { - const {document, errors} = DockZoneModel.addTab(doc(), {itemId: 'terminal', tabsNodeId: 'main-tabs', index: 0}); + const {document, errors} = Operations.addTab(doc(), {itemId: 'terminal', tabsNodeId: 'main-tabs', index: 0}); expect(errors).toEqual([]); expect(document.nodes['main-tabs'].items).toEqual(['terminal', 'strategy', 'swarm']); // side-tabs emptied -> collapsed by normalizeTree, and its edge zone pruned expect(document.nodes['side-tabs']).toBeUndefined(); expect(document.nodes.root.zones.right).toBeUndefined(); // terminal appears exactly once - expect(DockZoneModel.validate(document)).toEqual([]) + expect(Document.validate(document)).toEqual([]) }); test('fails closed on an unknown item (document untouched)', () => { const input = doc(); - const {document, errors} = DockZoneModel.addTab(input, {itemId: 'ghost', tabsNodeId: 'main-tabs'}); + const {document, errors} = Operations.addTab(input, {itemId: 'ghost', tabsNodeId: 'main-tabs'}); expect(errors.length).toBeGreaterThan(0); expect(document).toBe(input) }); test('fails closed when the target is not a tabs node', () => { - const {errors} = DockZoneModel.addTab(doc(), {itemId: 'inspector', tabsNodeId: 'root'}); + const {errors} = Operations.addTab(doc(), {itemId: 'inspector', tabsNodeId: 'root'}); expect(errors.length).toBeGreaterThan(0) }) }); test.describe('moveItem', () => { test('relocates an in-tree item to another tabs node', () => { - const {document, errors} = DockZoneModel.moveItem(doc(), {itemId: 'strategy', targetNodeId: 'side-tabs', index: 0}); + const {document, errors} = Operations.moveItem(doc(), {itemId: 'strategy', targetNodeId: 'side-tabs', index: 0}); expect(errors).toEqual([]); expect(document.nodes['side-tabs'].items).toEqual(['strategy', 'terminal']); expect(document.nodes['main-tabs'].items).toEqual(['swarm']) }); test('fails closed when the item is not in the tree', () => { - const {errors} = DockZoneModel.moveItem(doc(), {itemId: 'inspector', targetNodeId: 'main-tabs'}); + const {errors} = Operations.moveItem(doc(), {itemId: 'inspector', targetNodeId: 'main-tabs'}); expect(errors.length).toBeGreaterThan(0) }) }); test.describe('splitNode', () => { test('splits a node after the target, wrapping the item in a new pane', () => { - const {document, errors} = DockZoneModel.splitNode(doc(), { + const {document, errors} = Operations.splitNode(doc(), { itemId: 'inspector', targetNodeId: 'main-tabs', orientation: 'horizontal', position: 'after', sizes: [0.6, 0.4] }); expect(errors).toEqual([]); @@ -1189,7 +1186,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('splits before the target (new pane first)', () => { - const {document, errors} = DockZoneModel.splitNode(doc(), { + const {document, errors} = Operations.splitNode(doc(), { itemId: 'inspector', targetNodeId: 'side-tabs', orientation: 'vertical', position: 'before', sizes: [0.3, 0.7] }); expect(errors).toEqual([]); @@ -1200,7 +1197,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('splitting the root makes the new split the root', () => { - const {document, errors} = DockZoneModel.splitNode(doc(), { + const {document, errors} = Operations.splitNode(doc(), { itemId: 'inspector', targetNodeId: 'root', orientation: 'vertical', position: 'after' }); expect(errors).toEqual([]); @@ -1209,18 +1206,18 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('detaches the item from its old location (no duplication)', () => { - const {document, errors} = DockZoneModel.splitNode(doc(), { + const {document, errors} = Operations.splitNode(doc(), { itemId: 'swarm', targetNodeId: 'side-tabs', orientation: 'vertical', position: 'after' }); expect(errors).toEqual([]); expect(document.nodes['main-tabs'].items).toEqual(['strategy']); - expect(DockZoneModel.validate(document)).toEqual([]) + expect(Document.validate(document)).toEqual([]) }); test('fails closed on an unknown item, unknown target, or bad orientation', () => { - expect(DockZoneModel.splitNode(doc(), {itemId: 'ghost', targetNodeId: 'main-tabs', orientation: 'horizontal'}).errors.length).toBeGreaterThan(0); - expect(DockZoneModel.splitNode(doc(), {itemId: 'inspector', targetNodeId: 'ghost', orientation: 'horizontal'}).errors.length).toBeGreaterThan(0); - expect(DockZoneModel.splitNode(doc(), {itemId: 'inspector', targetNodeId: 'main-tabs', orientation: 'diagonal'}).errors.length).toBeGreaterThan(0) + expect(Operations.splitNode(doc(), {itemId: 'ghost', targetNodeId: 'main-tabs', orientation: 'horizontal'}).errors.length).toBeGreaterThan(0); + expect(Operations.splitNode(doc(), {itemId: 'inspector', targetNodeId: 'ghost', orientation: 'horizontal'}).errors.length).toBeGreaterThan(0); + expect(Operations.splitNode(doc(), {itemId: 'inspector', targetNodeId: 'main-tabs', orientation: 'diagonal'}).errors.length).toBeGreaterThan(0) }) }); @@ -1229,7 +1226,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { const input = splitDoc(), snapshot = JSON.stringify(input), ratios = [3, 1], - {document, errors} = DockZoneModel.resizeSplit(input, { + {document, errors} = Operations.resizeSplit(input, { splitNodeId: 'main-split', sizes : ratios }); @@ -1237,7 +1234,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(errors).toEqual([]); expect(document).not.toBe(input); expect(document.nodes['main-split'].sizes).toEqual([0.75, 0.25]); - expect(DockZoneModel.validate(document)).toEqual([]); + expect(Document.validate(document)).toEqual([]); expect(JSON.stringify(input)).toBe(snapshot); ratios[0] = 1; @@ -1259,7 +1256,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { ]; for (const args of cases) { - const {document, errors} = DockZoneModel.resizeSplit(input, args); + const {document, errors} = Operations.resizeSplit(input, args); expect(errors.length).toBeGreaterThan(0); expect(document).toBe(input); @@ -1270,14 +1267,14 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test.describe('detachItem / closeItem', () => { test('detachItem removes from the tree but keeps the catalog record', () => { - const {document, errors} = DockZoneModel.detachItem(doc(), {itemId: 'terminal'}); + const {document, errors} = Operations.detachItem(doc(), {itemId: 'terminal'}); expect(errors).toEqual([]); - expect(DockZoneModel.findContainingTabsId(document, 'terminal')).toBe(null); + expect(Document.findContainingTabsId(document, 'terminal')).toBe(null); expect(document.items.terminal).toBeDefined() }); test('closeItem removes from both the tree and the catalog', () => { - const {document, errors} = DockZoneModel.closeItem(doc(), {itemId: 'terminal'}); + const {document, errors} = Operations.closeItem(doc(), {itemId: 'terminal'}); expect(errors).toEqual([]); expect(document.items.terminal).toBeUndefined() }); @@ -1288,7 +1285,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { input.items.swarm.closable = false; const snapshot = JSON.stringify(input), - {document, errors} = DockZoneModel.closeItem(input, {itemId: 'swarm'}); + {document, errors} = Operations.closeItem(input, {itemId: 'swarm'}); expect(errors).toEqual(['item "swarm" is not closable']); expect(document).toBe(input); @@ -1305,32 +1302,32 @@ test.describe('Neo.dashboard.DockZoneModel', () => { return input }; - const first = DockZoneModel.closeItem(createCloseDocument(), {itemId: 'strategy'}); + const first = Operations.closeItem(createCloseDocument(), {itemId: 'strategy'}); expect(first.errors).toEqual([]); expect(first.document.nodes['main-tabs'].items).toEqual(['swarm', 'inspector']); expect(first.document.nodes['main-tabs'].activeItemId).toBe('swarm'); - const middle = DockZoneModel.closeItem(createCloseDocument(), {itemId: 'swarm'}); + const middle = Operations.closeItem(createCloseDocument(), {itemId: 'swarm'}); expect(middle.errors).toEqual([]); expect(middle.document.nodes['main-tabs'].items).toEqual(['strategy', 'inspector']); expect(middle.document.nodes['main-tabs'].activeItemId).toBe('strategy'); - const last = DockZoneModel.closeItem(createCloseDocument(), {itemId: 'inspector'}); + const last = Operations.closeItem(createCloseDocument(), {itemId: 'inspector'}); expect(last.errors).toEqual([]); expect(last.document.nodes['main-tabs'].items).toEqual(['strategy', 'swarm']); expect(last.document.nodes['main-tabs'].activeItemId).toBe('strategy'); - const activeMiddle = DockZoneModel.closeItem(createCloseDocument('swarm'), {itemId: 'swarm'}); + const activeMiddle = Operations.closeItem(createCloseDocument('swarm'), {itemId: 'swarm'}); expect(activeMiddle.errors).toEqual([]); expect(activeMiddle.document.nodes['main-tabs'].items).toEqual(['strategy', 'inspector']); expect(activeMiddle.document.nodes['main-tabs'].activeItemId).toBe('inspector'); - const activeLast = DockZoneModel.closeItem(createCloseDocument('inspector'), {itemId: 'inspector'}); + const activeLast = Operations.closeItem(createCloseDocument('inspector'), {itemId: 'inspector'}); expect(activeLast.errors).toEqual([]); expect(activeLast.document.nodes['main-tabs'].items).toEqual(['strategy', 'swarm']); expect(activeLast.document.nodes['main-tabs'].activeItemId).toBe('swarm'); - const only = DockZoneModel.closeItem(doc(), {itemId: 'terminal'}); + const only = Operations.closeItem(doc(), {itemId: 'terminal'}); expect(only.errors).toEqual([]); expect(only.document.items.terminal).toBeUndefined(); expect(only.document.nodes['side-tabs']).toBeUndefined(); @@ -1345,14 +1342,14 @@ test.describe('Neo.dashboard.DockZoneModel', () => { input.items.terminal.pinnable = true; const snapshot = JSON.stringify(input), - pinned = DockZoneModel.setItemPinned(input, {itemId: 'terminal', pinned: true}); + pinned = Operations.setItemPinned(input, {itemId: 'terminal', pinned: true}); expect(pinned.errors).toEqual([]); expect(pinned.document.items.terminal.pinned).toBe(true); expect(JSON.stringify(input)).toBe(snapshot); expect(input.items.terminal.pinned).toBeUndefined(); - const unpinned = DockZoneModel.setItemPinned(pinned.document, {itemId: 'terminal', pinned: false}); + const unpinned = Operations.setItemPinned(pinned.document, {itemId: 'terminal', pinned: false}); expect(unpinned.errors).toEqual([]); expect(unpinned.document.items.terminal.pinned).toBe(false) @@ -1363,7 +1360,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { input.items.terminal.autoHidden = true; - const pinned = DockZoneModel.setItemPinned(input, {itemId: 'terminal', pinned: true}); + const pinned = Operations.setItemPinned(input, {itemId: 'terminal', pinned: true}); expect(pinned.errors).toEqual([]); expect(pinned.document.items.terminal.pinned).toBe(true); @@ -1376,15 +1373,15 @@ test.describe('Neo.dashboard.DockZoneModel', () => { input.items.terminal.pinnable = false; - const unknown = DockZoneModel.setItemPinned(input, {itemId: 'ghost', pinned: true}); + const unknown = Operations.setItemPinned(input, {itemId: 'ghost', pinned: true}); expect(unknown.document).toBe(input); expect(unknown.errors.join(' ')).toContain('ghost'); - const invalid = DockZoneModel.setItemPinned(input, {itemId: 'terminal', pinned: 'true'}); + const invalid = Operations.setItemPinned(input, {itemId: 'terminal', pinned: 'true'}); expect(invalid.document).toBe(input); expect(invalid.errors.join(' ')).toContain('boolean'); - const locked = DockZoneModel.setItemPinned(input, {itemId: 'terminal', pinned: true}); + const locked = Operations.setItemPinned(input, {itemId: 'terminal', pinned: true}); expect(locked.document).toBe(input); expect(locked.errors.join(' ')).toContain('not pinnable') }) @@ -1397,14 +1394,14 @@ test.describe('Neo.dashboard.DockZoneModel', () => { input.items.terminal.pinnable = true; const snapshot = JSON.stringify(input), - hidden = DockZoneModel.setItemAutoHidden(input, {itemId: 'terminal', autoHidden: true}); + hidden = Operations.setItemAutoHidden(input, {itemId: 'terminal', autoHidden: true}); expect(hidden.errors).toEqual([]); expect(hidden.document.items.terminal.autoHidden).toBe(true); expect(JSON.stringify(input)).toBe(snapshot); expect(input.items.terminal.autoHidden).toBeUndefined(); - const visible = DockZoneModel.applyOperation(hidden.document, { + const visible = Operations.applyOperation(hidden.document, { operation : 'setItemAutoHidden', itemId : 'terminal', autoHidden: false @@ -1419,22 +1416,22 @@ test.describe('Neo.dashboard.DockZoneModel', () => { input.items.terminal.pinnable = false; - const unknown = DockZoneModel.setItemAutoHidden(input, {itemId: 'ghost', autoHidden: true}); + const unknown = Operations.setItemAutoHidden(input, {itemId: 'ghost', autoHidden: true}); expect(unknown.document).toBe(input); expect(unknown.errors.join(' ')).toContain('ghost'); - const invalid = DockZoneModel.setItemAutoHidden(input, {itemId: 'terminal', autoHidden: 'true'}); + const invalid = Operations.setItemAutoHidden(input, {itemId: 'terminal', autoHidden: 'true'}); expect(invalid.document).toBe(input); expect(invalid.errors.join(' ')).toContain('boolean'); - const locked = DockZoneModel.setItemAutoHidden(input, {itemId: 'terminal', autoHidden: true}); + const locked = Operations.setItemAutoHidden(input, {itemId: 'terminal', autoHidden: true}); expect(locked.document).toBe(input); expect(locked.errors.join(' ')).toContain('not pinnable'); const pinnedInput = doc(); pinnedInput.items.terminal.pinned = true; - const pinned = DockZoneModel.setItemAutoHidden(pinnedInput, {itemId: 'terminal', autoHidden: true}); + const pinned = Operations.setItemAutoHidden(pinnedInput, {itemId: 'terminal', autoHidden: true}); expect(pinned.document).toBe(pinnedInput); expect(pinned.errors.join(' ')).toContain('pinned') }) @@ -1444,7 +1441,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test('collapses an emptied tabs node and prunes its edge zone', () => { const d = doc(); d.nodes['side-tabs'].items = []; - const out = DockZoneModel.normalizeTree(d); + const out = Document.normalizeTree(d); expect(out.nodes['side-tabs']).toBeUndefined(); expect(out.nodes.root.zones.right).toBeUndefined() }); @@ -1453,7 +1450,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { const d = doc(); d.nodes.split = {type: 'split', orientation: 'horizontal', children: ['main-tabs'], sizes: [1]}; d.nodes.root.zones.center = 'split'; - const out = DockZoneModel.normalizeTree(d); + const out = Document.normalizeTree(d); expect(out.nodes.split).toBeUndefined(); expect(out.nodes.root.zones.center).toBe('main-tabs') }); @@ -1461,28 +1458,28 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test('prunes nodes unreachable from the root', () => { const d = doc(); d.nodes.orphan = {type: 'tabs', items: ['strategy'], activeItemId: 'strategy'}; - const out = DockZoneModel.normalizeTree(d); + const out = Document.normalizeTree(d); expect(out.nodes.orphan).toBeUndefined() }) }); test.describe('applyOperation (DockPreview descriptor seam)', () => { test('dispatches addTab, downgrading to a move when the item is already in the tree', () => { - const {document, errors} = DockZoneModel.applyOperation(doc(), {operation: 'addTab', itemId: 'terminal', tabsNodeId: 'main-tabs', index: 0}); + const {document, errors} = Operations.applyOperation(doc(), {operation: 'addTab', itemId: 'terminal', tabsNodeId: 'main-tabs', index: 0}); expect(errors).toEqual([]); expect(document.nodes['main-tabs'].items).toEqual(['terminal', 'strategy', 'swarm']); - expect(DockZoneModel.validate(document)).toEqual([]) + expect(Document.validate(document)).toEqual([]) }); test('dispatches splitNode from a previewToOperation-shaped descriptor', () => { const descriptor = {operation: 'splitNode', itemId: 'inspector', targetNodeId: 'main-tabs', orientation: 'horizontal', position: 'after', sizes: [0.5, 0.5]}; - const {document, errors} = DockZoneModel.applyOperation(doc(), descriptor); + const {document, errors} = Operations.applyOperation(doc(), descriptor); expect(errors).toEqual([]); expect(document.nodes[document.nodes.root.zones.center].type).toBe('split') }); test('dispatches resizeSplit descriptors', () => { - const {document, errors} = DockZoneModel.applyOperation(splitDoc(), { + const {document, errors} = Operations.applyOperation(splitDoc(), { operation : 'resizeSplit', splitNodeId: 'main-split', sizes : [1, 3] @@ -1497,7 +1494,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { input.items.terminal.pinnable = true; - const {document, errors} = DockZoneModel.applyOperation(input, { + const {document, errors} = Operations.applyOperation(input, { operation: 'setItemPinned', itemId : 'terminal', pinned : true @@ -1508,7 +1505,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('rejects an unknown operation', () => { - expect(DockZoneModel.applyOperation(doc(), {operation: 'frobnicate'}).errors.length).toBeGreaterThan(0) + expect(Operations.applyOperation(doc(), {operation: 'frobnicate'}).errors.length).toBeGreaterThan(0) }) }); @@ -1524,7 +1521,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test(`edge-${c.edge} places the new pane ${c.lead ? 'before (leading)' : 'after (trailing)'}`, () => { // exact previewToOperation edge-descriptor shape: carries `edge`, no `position` const descriptor = {operation: 'splitNode', itemId: 'inspector', targetNodeId: c.target, edge: c.edge, orientation: c.orientation, sizes: [0.5, 0.5]}; - const {document, errors} = DockZoneModel.applyOperation(doc(), descriptor); + const {document, errors} = Operations.applyOperation(doc(), descriptor); expect(errors).toEqual([]); const split = document.nodes[document.nodes.root.zones[c.zone]], @@ -1535,27 +1532,27 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(split.orientation).toBe(c.orientation); expect(document.nodes[newPane].items).toEqual(['inspector']); expect(keep).toBe(c.target); - expect(DockZoneModel.validate(document)).toEqual([]) + expect(Document.validate(document)).toEqual([]) }) } }); test.describe('captureItemPlacement (exact-position return, stored half)', () => { test('captures the holding tabs node and the exact index', () => { - expect(DockZoneModel.captureItemPlacement(doc(), 'strategy')).toEqual({tabsNodeId: 'main-tabs', index: 0}); - expect(DockZoneModel.captureItemPlacement(doc(), 'swarm')).toEqual({tabsNodeId: 'main-tabs', index: 1}); - expect(DockZoneModel.captureItemPlacement(doc(), 'terminal')).toEqual({tabsNodeId: 'side-tabs', index: 0}) + expect(Document.captureItemPlacement(doc(), 'strategy')).toEqual({tabsNodeId: 'main-tabs', index: 0}); + expect(Document.captureItemPlacement(doc(), 'swarm')).toEqual({tabsNodeId: 'main-tabs', index: 1}); + expect(Document.captureItemPlacement(doc(), 'terminal')).toEqual({tabsNodeId: 'side-tabs', index: 0}) }); test('fails closed when no tabs node holds the item — catalog presence is not placement', () => { - expect(DockZoneModel.captureItemPlacement(doc(), 'ghost')).toBeNull(); + expect(Document.captureItemPlacement(doc(), 'ghost')).toBeNull(); // a DETACHED item stays in the catalog but has no placement to capture - const {document: detached, errors} = DockZoneModel.applyOperation(doc(), {operation: 'detachItem', itemId: 'strategy'}); + const {document: detached, errors} = Operations.applyOperation(doc(), {operation: 'detachItem', itemId: 'strategy'}); expect(errors).toEqual([]); expect(detached.items.strategy).toBeTruthy(); - expect(DockZoneModel.captureItemPlacement(detached, 'strategy')).toBeNull() + expect(Document.captureItemPlacement(detached, 'strategy')).toBeNull() }); test('the ROUND TRIP: capture → detach → addTab with the stored pair restores the ORIGINAL order, not append order', () => { @@ -1563,13 +1560,13 @@ test.describe('Neo.dashboard.DockZoneModel', () => { // 'strategy' sits at index 0 of ['strategy', 'swarm'] — the append default would // put it BACK at index 1, which is exactly the defect the stored pair compensates - const placement = DockZoneModel.captureItemPlacement(source, 'strategy'); + const placement = Document.captureItemPlacement(source, 'strategy'); expect(placement).toEqual({tabsNodeId: 'main-tabs', index: 0}); - const {document: detached} = DockZoneModel.applyOperation(source, {operation: 'detachItem', itemId: 'strategy'}); + const {document: detached} = Operations.applyOperation(source, {operation: 'detachItem', itemId: 'strategy'}); - const {document: restored, errors} = DockZoneModel.applyOperation(detached, { + const {document: restored, errors} = Operations.applyOperation(detached, { operation : 'addTab', itemId : 'strategy', tabsNodeId: placement.tabsNodeId, @@ -1581,7 +1578,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { // the control: WITHOUT the stored index the item lands at the tail — the append // default alone cannot deliver exact-position return - const {document: appended} = DockZoneModel.applyOperation(detached, { + const {document: appended} = Operations.applyOperation(detached, { operation: 'addTab', itemId: 'strategy', tabsNodeId: 'main-tabs' }); @@ -1593,7 +1590,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { // The canonical vessel document: an edge-zone ROOT (window chrome) whose center zone // holds the stack — so the transferable whole is the root's center child, never the root. const vessel = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'popup-root', items : { drill : {componentRef: 'drill', title: 'Drill', kind: 'panel'}, @@ -1606,24 +1603,24 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('resolves the canonical vessel shape: the root edge-zone\'s center child IS the stack', () => { - expect(DockZoneModel.resolveStackRoot(vessel())).toBe('popup-tabs'); + expect(Document.resolveStackRoot(vessel())).toBe('popup-tabs'); // the shared main-document fixture resolves too — the rule is the document shape, // not a vessel special case - expect(DockZoneModel.resolveStackRoot(doc())).toBe('main-tabs') + expect(Document.resolveStackRoot(doc())).toBe('main-tabs') }); test('fails closed on every unprovable shape', () => { - expect(DockZoneModel.resolveStackRoot(null)).toBeNull(); - expect(DockZoneModel.resolveStackRoot({})).toBeNull(); + expect(Document.resolveStackRoot(null)).toBeNull(); + expect(Document.resolveStackRoot({})).toBeNull(); const missingRoot = vessel(); delete missingRoot.nodes['popup-root']; - expect(DockZoneModel.resolveStackRoot(missingRoot)).toBeNull(); + expect(Document.resolveStackRoot(missingRoot)).toBeNull(); // a degenerate workspace whose root IS a tabs node has no projectable stack - expect(DockZoneModel.resolveStackRoot({ - schema: 'neo.harness.dockZone.v1', + expect(Document.resolveStackRoot({ + schema: 'neo.dock.zone.v1', root : 'only-tabs', items : {}, nodes : {'only-tabs': {type: 'tabs', items: [], activeItemId: null}} @@ -1631,17 +1628,17 @@ test.describe('Neo.dashboard.DockZoneModel', () => { const noCenter = vessel(); delete noCenter.nodes['popup-root'].zones.center; - expect(DockZoneModel.resolveStackRoot(noCenter)).toBeNull(); + expect(Document.resolveStackRoot(noCenter)).toBeNull(); const ghostCenter = vessel(); ghostCenter.nodes['popup-root'].zones.center = 'ghost'; - expect(DockZoneModel.resolveStackRoot(ghostCenter)).toBeNull() + expect(Document.resolveStackRoot(ghostCenter)).toBeNull() }); test('COMPOSES with transferNode: the resolved stack transfers whole and atomically — while the root door stays shut', () => { // the negative control first: the DOCUMENT ROOT still rejects — explicit resolution // is the only path to a whole-stack transfer - const rejected = DockZoneModel.transferNode(vessel(), doc(), { + const rejected = Operations.transferNode(vessel(), doc(), { nodeId : 'popup-root', sourceWorkspaceId: 'popup-1', targetWorkspaceId: 'main', @@ -1653,9 +1650,9 @@ test.describe('Neo.dashboard.DockZoneModel', () => { // the resolved stack: ONE atomic two-document transfer through the landed executor const source = vessel(), - stackRoot = DockZoneModel.resolveStackRoot(source); + stackRoot = Document.resolveStackRoot(source); - const {sourceDocument, targetDocument, errors} = DockZoneModel.transferNode(source, doc(), { + const {sourceDocument, targetDocument, errors} = Operations.transferNode(source, doc(), { nodeId : stackRoot, sourceWorkspaceId: 'popup-1', targetWorkspaceId: 'main', @@ -1674,7 +1671,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(sourceDocument.nodes['popup-tabs']).toBeUndefined(); expect(sourceDocument.items.drill).toBeUndefined(); expect(sourceDocument.items.stream).toBeUndefined(); - expect(DockZoneModel.validate(sourceDocument)).toEqual([]) + expect(Document.validate(sourceDocument)).toEqual([]) }) }); @@ -1682,7 +1679,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { // A second workspace document with a distinct catalog, so a transfer into it never // collides on item id with the source doc()'s 'terminal'. const target = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : {alpha: {componentRef: 'alpha', title: 'Alpha', kind: 'panel'}}, nodes : { @@ -1694,22 +1691,22 @@ test.describe('Neo.dashboard.DockZoneModel', () => { const addTabTarget = {operation: 'addTab', tabsNodeId: 'main-tabs'}; test('moves an item across documents: source loses it (tree + catalog), target gains it, both valid', () => { - const {sourceDocument, targetDocument, errors} = DockZoneModel.transferItem(doc(), target(), { + const {sourceDocument, targetDocument, errors} = Operations.transferItem(doc(), target(), { itemId: 'terminal', sourceWorkspaceId: 'A', targetWorkspaceId: 'B', target: addTabTarget }); expect(errors).toEqual([]); // source: terminal gone from catalog + tree; the emptied side-tabs collapsed + its edge zone pruned expect(sourceDocument.items.terminal).toBeUndefined(); - expect(DockZoneModel.findContainingTabsId(sourceDocument, 'terminal')).toBeNull(); + expect(Document.findContainingTabsId(sourceDocument, 'terminal')).toBeNull(); expect(sourceDocument.nodes['side-tabs']).toBeUndefined(); expect(sourceDocument.nodes.root.zones.right).toBeUndefined(); // target: terminal now in catalog + main-tabs tree expect(targetDocument.items.terminal).toBeDefined(); expect(targetDocument.nodes['main-tabs'].items).toContain('terminal'); // both documents remain contract-valid - expect(DockZoneModel.validate(sourceDocument)).toEqual([]); - expect(DockZoneModel.validate(targetDocument)).toEqual([]) + expect(Document.validate(sourceDocument)).toEqual([]); + expect(Document.validate(targetDocument)).toEqual([]) }); test('the item record travels verbatim — policy hints, metadata, and a railed autoHidden state intact', () => { @@ -1718,11 +1715,11 @@ test.describe('Neo.dashboard.DockZoneModel', () => { source.items.terminal = {...record}; - const {targetDocument, errors} = DockZoneModel.transferItem(source, target(), {itemId: 'terminal', target: addTabTarget}); + const {targetDocument, errors} = Operations.transferItem(source, target(), {itemId: 'terminal', target: addTabTarget}); expect(errors).toEqual([]); expect(targetDocument.items.terminal).toEqual(record); // verbatim, incl. autoHidden - expect(DockZoneModel.validate(targetDocument)).toEqual([]) // a railed item is a valid arrival + expect(Document.validate(targetDocument)).toEqual([]) // a railed item is a valid arrival }); test('atomic: a target-side placement failure leaves BOTH documents untouched (source byte-identical)', () => { @@ -1732,7 +1729,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { const tgtSnapshot = JSON.parse(JSON.stringify(tgt)); // the nested target points at a node that does not exist → placement fails - const {sourceDocument, targetDocument, errors} = DockZoneModel.transferItem(source, tgt, { + const {sourceDocument, targetDocument, errors} = Operations.transferItem(source, tgt, { itemId: 'terminal', target: {operation: 'addTab', tabsNodeId: 'ghost-tabs'} }); @@ -1747,7 +1744,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { source.items.terminal.movable = false; - const {sourceDocument, targetDocument, errors} = DockZoneModel.transferItem(source, target(), {itemId: 'terminal', target: addTabTarget}); + const {sourceDocument, targetDocument, errors} = Operations.transferItem(source, target(), {itemId: 'terminal', target: addTabTarget}); expect(errors.join(' ')).toContain('movable'); expect(sourceDocument.items.terminal).toBeDefined(); @@ -1755,7 +1752,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('rejects an unknown item fail-closed', () => { - const {errors} = DockZoneModel.transferItem(doc(), target(), {itemId: 'ghost', target: addTabTarget}); + const {errors} = Operations.transferItem(doc(), target(), {itemId: 'ghost', target: addTabTarget}); expect(errors.join(' ')).toContain('unknown item') }); @@ -1764,12 +1761,12 @@ test.describe('Neo.dashboard.DockZoneModel', () => { tgt.items.terminal = {componentRef: 'terminal', title: 'Terminal', kind: 'terminal'}; - const {errors} = DockZoneModel.transferItem(doc(), tgt, {itemId: 'terminal', target: addTabTarget}); + const {errors} = Operations.transferItem(doc(), tgt, {itemId: 'terminal', target: addTabTarget}); expect(errors.join(' ')).toContain('already exists in the target') }); test('rejects a same-workspace transfer (that is a moveItem, not a transfer)', () => { - const {errors} = DockZoneModel.transferItem(doc(), target(), { + const {errors} = Operations.transferItem(doc(), target(), { itemId: 'terminal', sourceWorkspaceId: 'A', targetWorkspaceId: 'A', target: addTabTarget }); @@ -1777,12 +1774,12 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('rejects a nested target that is not a placement descriptor (addTab / splitNode)', () => { - const {errors} = DockZoneModel.transferItem(doc(), target(), {itemId: 'terminal', target: {operation: 'closeItem'}}); + const {errors} = Operations.transferItem(doc(), target(), {itemId: 'terminal', target: {operation: 'closeItem'}}); expect(errors.join(' ')).toContain('addTab or splitNode') }); test('reuses the landed placement validation — a malformed nested target fails closed, source untouched', () => { - const {sourceDocument, errors} = DockZoneModel.transferItem(doc(), target(), { + const {sourceDocument, errors} = Operations.transferItem(doc(), target(), { itemId: 'terminal', target: {operation: 'splitNode', targetNodeId: 'main-tabs', orientation: 'sideways'} }); @@ -1791,33 +1788,33 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('places via a splitNode target descriptor', () => { - const {targetDocument, errors} = DockZoneModel.transferItem(doc(), target(), { + const {targetDocument, errors} = Operations.transferItem(doc(), target(), { itemId: 'terminal', target: {operation: 'splitNode', targetNodeId: 'main-tabs', orientation: 'vertical', position: 'after', sizes: [0.5, 0.5]} }); expect(errors).toEqual([]); expect(targetDocument.items.terminal).toBeDefined(); - expect(DockZoneModel.findContainingTabsId(targetDocument, 'terminal')).not.toBeNull(); - expect(DockZoneModel.validate(targetDocument)).toEqual([]) + expect(Document.findContainingTabsId(targetDocument, 'terminal')).not.toBeNull(); + expect(Document.validate(targetDocument)).toEqual([]) }); test('applyOperation redirects a single-document transferItem descriptor to the two-document method', () => { const input = doc(); - const {document, errors} = DockZoneModel.applyOperation(input, {operation: 'transferItem', itemId: 'terminal'}); + const {document, errors} = Operations.applyOperation(input, {operation: 'transferItem', itemId: 'terminal'}); expect(errors.join(' ')).toContain('two-document operation'); expect(document).toEqual(input) // untouched }); test('transferItem joins the exported operation vocabulary (SSOT)', () => { - expect(DockZoneModel.operations).toContain('transferItem') + expect(Operations.operations).toContain('transferItem') }) }); test.describe('moveNode (grouped-drag subtree re-parent)', () => { test('split placement wraps target + moved subtree in a new split; old slot pruned; subtree intact', () => { - const {document, errors} = DockZoneModel.moveNode(doc(), { + const {document, errors} = Operations.moveNode(doc(), { nodeId: 'side-tabs', targetNodeId: 'main-tabs', placement: {orientation: 'vertical', position: 'after'} }); @@ -1829,12 +1826,12 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(document.nodes[centerId].children).toContain('main-tabs'); expect(document.nodes[centerId].children).toContain('side-tabs'); expect(document.nodes.root.zones.right).toBeUndefined(); // moved out of its old edge slot - expect(DockZoneModel.findContainingTabsId(document, 'terminal')).toBe('side-tabs'); // the moved subtree is intact - expect(DockZoneModel.validate(document)).toEqual([]) + expect(Document.findContainingTabsId(document, 'terminal')).toBe('side-tabs'); // the moved subtree is intact + expect(Document.validate(document)).toEqual([]) }); test('tab-into placement merges the moved tabs items into the target in order, then drops the node', () => { - const {document, errors} = DockZoneModel.moveNode(doc(), { + const {document, errors} = Operations.moveNode(doc(), { nodeId: 'side-tabs', targetNodeId: 'main-tabs', placement: {kind: 'tab-into'} }); @@ -1842,12 +1839,12 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(document.nodes['main-tabs'].items).toEqual(['strategy', 'swarm', 'terminal']); expect(document.nodes['side-tabs']).toBeUndefined(); expect(document.nodes.root.zones.right).toBeUndefined(); - expect(DockZoneModel.validate(document)).toEqual([]) + expect(Document.validate(document)).toEqual([]) }); test('cycle guard: moving a node into its own subtree fails closed, document untouched', () => { const input = splitDoc(); - const {document, errors} = DockZoneModel.moveNode(input, { + const {document, errors} = Operations.moveNode(input, { nodeId: 'main-split', targetNodeId: 'main-tabs', placement: {orientation: 'vertical'} }); @@ -1856,7 +1853,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('fails closed on unknown node, unknown target, the root, or a self-move', () => { - const move = args => DockZoneModel.moveNode(doc(), {placement: {orientation: 'vertical'}, ...args}).errors.join(' '); + const move = args => Operations.moveNode(doc(), {placement: {orientation: 'vertical'}, ...args}).errors.join(' '); expect(move({nodeId: 'ghost', targetNodeId: 'main-tabs'})).toContain('unknown node'); expect(move({nodeId: 'side-tabs', targetNodeId: 'ghost'})).toContain('unknown target'); @@ -1865,8 +1862,8 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }); test('fails closed on a bad split orientation or a tab-into targeting a non-tabs node', () => { - expect(DockZoneModel.moveNode(doc(), {nodeId: 'side-tabs', targetNodeId: 'main-tabs', placement: {orientation: 'diagonal'}}).errors.join(' ')).toContain('orientation'); - expect(DockZoneModel.moveNode(doc(), {nodeId: 'side-tabs', targetNodeId: 'root', placement: {kind: 'tab-into'}}).errors.join(' ')).toContain('tabs nodes') + expect(Operations.moveNode(doc(), {nodeId: 'side-tabs', targetNodeId: 'main-tabs', placement: {orientation: 'diagonal'}}).errors.join(' ')).toContain('orientation'); + expect(Operations.moveNode(doc(), {nodeId: 'side-tabs', targetNodeId: 'root', placement: {kind: 'tab-into'}}).errors.join(' ')).toContain('tabs nodes') }); test('renormalizes surviving sizes when a node leaves a 3-child split (ratios preserved, not reset to equal)', () => { @@ -1881,30 +1878,30 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }; // move 'a' (0.2) as a tab into 'c'; surviving [b, c] sizes (0.3, 0.5) renormalize to (0.375, 0.625) - const {document, errors} = DockZoneModel.moveNode(d, {nodeId: 'a', targetNodeId: 'c', placement: {kind: 'tab-into'}}); + const {document, errors} = Operations.moveNode(d, {nodeId: 'a', targetNodeId: 'c', placement: {kind: 'tab-into'}}); expect(errors).toEqual([]); expect(document.nodes.tri.children).toEqual(['b', 'c']); expect(document.nodes.tri.sizes[0]).toBeCloseTo(0.375); expect(document.nodes.tri.sizes[1]).toBeCloseTo(0.625); - expect(DockZoneModel.validate(document)).toEqual([]) + expect(Document.validate(document)).toEqual([]) }); test('applyOperation dispatches moveNode; the op joins the exported vocabulary', () => { - const {document, errors} = DockZoneModel.applyOperation(doc(), { + const {document, errors} = Operations.applyOperation(doc(), { operation: 'moveNode', nodeId: 'side-tabs', targetNodeId: 'main-tabs', placement: {kind: 'tab-into'} }); expect(errors).toEqual([]); expect(document.nodes['main-tabs'].items).toContain('terminal'); - expect(DockZoneModel.operations).toContain('moveNode') + expect(Operations.operations).toContain('moveNode') }) }); test.describe('transferNode (atomic two-document subtree transfer)', () => { // A second workspace with a distinct catalog + a `main-tabs` to attach into. const target = () => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'root', items : {alpha: {componentRef: 'alpha', title: 'Alpha', kind: 'panel'}}, nodes : { @@ -1916,7 +1913,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { const splitInto = {targetNodeId: 'main-tabs', placement: {orientation: 'vertical', position: 'after'}}; test('transfers a subtree across documents: source loses it, target gains its nodes + member records, both valid', () => { - const {sourceDocument, targetDocument, errors} = DockZoneModel.transferNode(doc(), target(), { + const {sourceDocument, targetDocument, errors} = Operations.transferNode(doc(), target(), { nodeId: 'side-tabs', sourceWorkspaceId: 'A', targetWorkspaceId: 'B', target: splitInto }); @@ -1926,9 +1923,9 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(sourceDocument.nodes.root.zones.right).toBeUndefined(); expect(targetDocument.nodes['side-tabs']).toBeDefined(); expect(targetDocument.items.terminal).toBeDefined(); - expect(DockZoneModel.findContainingTabsId(targetDocument, 'terminal')).toBe('side-tabs'); - expect(DockZoneModel.validate(sourceDocument)).toEqual([]); - expect(DockZoneModel.validate(targetDocument)).toEqual([]) + expect(Document.findContainingTabsId(targetDocument, 'terminal')).toBe('side-tabs'); + expect(Document.validate(sourceDocument)).toEqual([]); + expect(Document.validate(targetDocument)).toEqual([]) }); test('a multi-node subtree travels whole — every member node and item re-homes verbatim', () => { @@ -1943,7 +1940,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { delete source.nodes['side-tabs']; delete source.items.terminal; - const {sourceDocument, targetDocument, errors} = DockZoneModel.transferNode(source, target(), { + const {sourceDocument, targetDocument, errors} = Operations.transferNode(source, target(), { nodeId: 'grp', target: {targetNodeId: 'main-tabs', placement: {orientation: 'vertical'}} }); @@ -1952,15 +1949,15 @@ test.describe('Neo.dashboard.DockZoneModel', () => { expect(sourceDocument.items.log).toBeUndefined(); ['grp', 'grp-a', 'grp-b'].forEach(id => expect(targetDocument.nodes[id]).toBeDefined()); expect(targetDocument.items.watch).toEqual(source.items.watch); // verbatim - expect(DockZoneModel.validate(sourceDocument)).toEqual([]); - expect(DockZoneModel.validate(targetDocument)).toEqual([]) + expect(Document.validate(sourceDocument)).toEqual([]); + expect(Document.validate(targetDocument)).toEqual([]) }); test('atomic: an attach failure after preconditions leaves BOTH documents untouched (source byte-identical)', () => { const source = doc(); const srcSnap = JSON.parse(JSON.stringify(source)); - const {sourceDocument, targetDocument, errors} = DockZoneModel.transferNode(source, target(), { + const {sourceDocument, targetDocument, errors} = Operations.transferNode(source, target(), { nodeId: 'side-tabs', target: {targetNodeId: 'main-tabs', placement: {orientation: 'diagonal'}} }); @@ -1974,7 +1971,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { tgt.nodes['side-tabs'] = {type: 'tabs', items: [], activeItemId: null}; - const {errors} = DockZoneModel.transferNode(doc(), tgt, {nodeId: 'side-tabs', target: splitInto}); + const {errors} = Operations.transferNode(doc(), tgt, {nodeId: 'side-tabs', target: splitInto}); expect(errors.join(' ')).toContain('node "side-tabs" already exists') }); @@ -1983,7 +1980,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { tgt.items.terminal = {componentRef: 'terminal', title: 'T', kind: 'terminal'}; - const {errors} = DockZoneModel.transferNode(doc(), tgt, {nodeId: 'side-tabs', target: splitInto}); + const {errors} = Operations.transferNode(doc(), tgt, {nodeId: 'side-tabs', target: splitInto}); expect(errors.join(' ')).toContain('item "terminal" already exists') }); @@ -1992,18 +1989,18 @@ test.describe('Neo.dashboard.DockZoneModel', () => { src.items.terminal.movable = false; - expect(DockZoneModel.transferNode(src, target(), {nodeId: 'side-tabs', target: splitInto}).errors.join(' ')).toContain('movable'); - expect(DockZoneModel.transferNode(doc(), target(), {nodeId: 'root', target: splitInto}).errors.join(' ')).toContain('root'); - expect(DockZoneModel.transferNode(doc(), target(), {nodeId: 'side-tabs', sourceWorkspaceId: 'X', targetWorkspaceId: 'X', target: splitInto}).errors.join(' ')).toContain('distinct source and target') + expect(Operations.transferNode(src, target(), {nodeId: 'side-tabs', target: splitInto}).errors.join(' ')).toContain('movable'); + expect(Operations.transferNode(doc(), target(), {nodeId: 'root', target: splitInto}).errors.join(' ')).toContain('root'); + expect(Operations.transferNode(doc(), target(), {nodeId: 'side-tabs', sourceWorkspaceId: 'X', targetWorkspaceId: 'X', target: splitInto}).errors.join(' ')).toContain('distinct source and target') }); test('applyOperation redirects transferNode to the two-document method; the op joins the vocabulary', () => { const input = doc(); - const {document, errors} = DockZoneModel.applyOperation(input, {operation: 'transferNode', nodeId: 'side-tabs'}); + const {document, errors} = Operations.applyOperation(input, {operation: 'transferNode', nodeId: 'side-tabs'}); expect(errors.join(' ')).toContain('two-document operation'); expect(document).toEqual(input); - expect(DockZoneModel.operations).toContain('transferNode') + expect(Operations.operations).toContain('transferNode') }) }); @@ -2018,11 +2015,11 @@ test.describe('Neo.dashboard.DockZoneModel', () => { }; test('validate rejects a forbidden preview key nested in item metadata', () => { - expect(DockZoneModel.validate(tainted()).join(' ')).toContain('runtime-only preview field "groupNodeId"') + expect(Document.validate(tainted()).join(' ')).toContain('runtime-only preview field "groupNodeId"') }); test('createSavedLayout refuses to persist a smuggled preview key (fail-closed, layout null)', () => { - const {layout, errors} = DockZoneModel.createSavedLayout(tainted(), {layoutId: 'x', title: 'X'}); + const {layout, errors} = Persistence.createSavedLayout(tainted(), {layoutId: 'x', title: 'X'}); expect(layout).toBeNull(); expect(errors.join(' ')).toContain('groupNodeId') @@ -2030,7 +2027,7 @@ test.describe('Neo.dashboard.DockZoneModel', () => { test('restoreSavedLayout rejects a saved layout whose dockZone carries a preview key', () => { const wrapper = { - schema : DockZoneModel.LAYOUT_SCHEMA, + schema : Persistence.LAYOUT_SCHEMA, layoutId : 'x', title : 'X', dockZone : tainted(), @@ -2039,23 +2036,23 @@ test.describe('Neo.dashboard.DockZoneModel', () => { windowFingerprint: null }; - const {document, errors} = DockZoneModel.restoreSavedLayout(wrapper); + const {document, errors} = Persistence.restoreSavedLayout(wrapper); expect(document).toBeNull(); expect(errors.join(' ')).toContain('groupNodeId') }); test('a clean document round-trips through save + restore unaffected (no false positive)', () => { - const {layout, errors} = DockZoneModel.createSavedLayout(doc(), {layoutId: 'x', title: 'X'}); + const {layout, errors} = Persistence.createSavedLayout(doc(), {layoutId: 'x', title: 'X'}); expect(errors).toEqual([]); expect(layout).not.toBeNull(); - expect(DockZoneModel.restoreSavedLayout(layout).errors).toEqual([]) + expect(Persistence.restoreSavedLayout(layout).errors).toEqual([]) }); test('the forbidden-preview-key set is the model-owned SSOT (adapter projection reads the same finder)', () => { - expect(DockZoneModel.forbiddenPreviewKeys.has('groupNodeId')).toBe(true); - expect(DockZoneModel.findForbiddenPreviewKey({items: {a: {metadata: {pointerX: 1}}}})).toBe('pointerX') + expect(Document.forbiddenPreviewKeys.has('groupNodeId')).toBe(true); + expect(Document.findForbiddenPreviewKey({items: {a: {metadata: {pointerX: 1}}}})).toBe('pointerX') }) }); }); diff --git a/test/playwright/unit/dashboard/DockPerspectiveStore.spec.mjs b/test/playwright/unit/dashboard/PerspectiveLibrary.spec.mjs similarity index 86% rename from test/playwright/unit/dashboard/DockPerspectiveStore.spec.mjs rename to test/playwright/unit/dashboard/PerspectiveLibrary.spec.mjs index 7408b5e0c1..b2a610f93c 100644 --- a/test/playwright/unit/dashboard/DockPerspectiveStore.spec.mjs +++ b/test/playwright/unit/dashboard/PerspectiveLibrary.spec.mjs @@ -2,7 +2,7 @@ import {setup} from '../../setup.mjs'; setup({ appConfig: { - name: 'DashboardDockPerspectiveStoreTest' + name: 'DashboardPerspectiveLibraryTest' } }); @@ -10,18 +10,18 @@ import {test, expect} from '@playwright/test'; import Neo from '../../../../src/Neo.mjs'; import * as core from '../../../../src/core/_export.mjs'; -test.describe('Neo.dashboard.DockPerspectiveStore (B6 — the named perspective store)', () => { - let DockPerspectiveStore, DockZoneModel, store; +test.describe('Neo.dashboard.dock.persistence.PerspectiveLibrary (B6 — the named perspective store)', () => { + let Document, Persistence, PerspectiveLibrary, store; const doc = ids => ({ - schema: 'neo.harness.dockZone.v1', + schema: 'neo.dock.zone.v1', root : 'r', items : Object.fromEntries(ids.map(id => [id, {componentRef: id, title: id}])), nodes : {r: {type: 'tabs', items: [...ids], activeItemId: ids[0]}} }); const makeLayout = (layoutId, name, ids = ['alpha']) => { - const {layout, errors} = DockZoneModel.createSavedLayout(doc(ids), { + const {layout, errors} = Persistence.createSavedLayout(doc(ids), { layoutId, perspectiveName: name, title : `${name} title` @@ -32,12 +32,13 @@ test.describe('Neo.dashboard.DockPerspectiveStore (B6 — the named perspective }; test.beforeAll(async () => { - DockPerspectiveStore = (await import('../../../../src/dashboard/DockPerspectiveStore.mjs')).default; - DockZoneModel = (await import('../../../../src/dashboard/DockZoneModel.mjs')).default + Document = (await import('../../../../src/dashboard/dock/model/Document.mjs')).default; + Persistence = (await import('../../../../src/dashboard/dock/model/Persistence.mjs')).default; + PerspectiveLibrary = (await import('../../../../src/dashboard/dock/persistence/PerspectiveLibrary.mjs')).default }); test.beforeEach(() => { - store = Neo.create(DockPerspectiveStore) + store = Neo.create(PerspectiveLibrary) }); test.afterEach(() => { @@ -68,7 +69,7 @@ test.describe('Neo.dashboard.DockPerspectiveStore (B6 — the named perspective const loaded = store.loadPerspective('Coding'); expect(loaded.errors).toEqual([]); expect(loaded.layout.perspectiveName).toBe('Coding'); - expect(DockZoneModel.validate(loaded.document)).toEqual([]); // a restorable primary document + expect(Document.validate(loaded.document)).toEqual([]); // a restorable primary document expect(store.collection.activeLayoutId).toBe('l-1'); const renamed = store.renamePerspective('Coding', 'Review'); @@ -114,28 +115,33 @@ test.describe('Neo.dashboard.DockPerspectiveStore (B6 — the named perspective expect(renameVerdict.collision).toMatchObject({holderLayoutId: 'l-2', name: 'Coding'}) }); - test('loads migrate legacy v1 records honestly and converge the stored record forward', () => { - // a legacy record: no perspective fields at all (pre-v2) - const legacy = { + test('legacy-shaped and old-family records are rejected at the collection boundary — no migration survives', () => { + // a legacy-SHAPED record: claims the current schema but misses the perspective fields. + // Under the greenfield cut there is no reader that back-fills defaults; the contract is + // whole or the record is out. + const legacyShaped = { dockZone: doc(['alpha']), layoutId: 'legacy-1', - schema : 'neo.harness.dockLayout.v1', + schema : 'neo.dock.layout.v1', title : 'Legacy' }; - // adopt a collection carrying the legacy record as-is - const {collection, errors} = DockZoneModel.createSavedLayoutCollection([legacy], {}); - expect(errors).toEqual([]); - store.collection = collection; - - const loaded = store.loadPerspective('legacy-1'); - expect(loaded.errors).toEqual([]); - // honest migration defaults: v1 could only capture one window's document - expect(loaded.layout.captureScope).toBe('window'); - expect(loaded.layout.windowFingerprint).toBeNull(); + const shaped = PerspectiveLibrary.createSavedLayoutCollection([legacyShaped], {}); + expect(shaped.collection).toBe(null); + expect(shaped.errors.join(' ')).toContain('captureScope'); + + // an old-FAMILY record fails on the schema string itself (split literal on purpose — + // the retired family name must survive rename sweeps only here, as the control). + const oldFamily = { + ...legacyShaped, + captureScope : 'window', + schema : ['neo', 'harness', 'dockLayout', 'v1'].join('.'), + windowFingerprint: null + }; - // the STORED record converged forward too — the migration is not a read-time illusion - expect(store.collection.layouts['legacy-1'].captureScope).toBe('window') + const family = PerspectiveLibrary.createSavedLayoutCollection([oldFamily], {}); + expect(family.collection).toBe(null); + expect(family.errors.join(' ')).toContain('schema') }); test('fail-closed everywhere: invalid saves, unknown loads, missing removes, corrupt collection assignments', () => { @@ -177,10 +183,10 @@ test.describe('Neo.dashboard.DockPerspectiveStore (B6 — the named perspective const payload = written[0]; expect(payload).not.toBe(store.collection); expect(JSON.stringify(payload)).toBe(JSON.stringify(store.collection)); - expect(DockZoneModel.findNonJsonValue(payload)).toBeNull(); + expect(Document.findNonJsonValue(payload)).toBeNull(); // hydrate round-trips... - const fresh = Neo.create(DockPerspectiveStore, {persistenceAdapter: store.persistenceAdapter}); + const fresh = Neo.create(PerspectiveLibrary, {persistenceAdapter: store.persistenceAdapter}); expect((await fresh.hydrate()).hydrated).toBe(true); expect(fresh.exists('Coding')).toBe(true); @@ -239,7 +245,7 @@ test.describe('Neo.dashboard.DockPerspectiveStore (B6 — the named perspective // derived successor: the first remaining record in insertion order expect(store.removePerspective('Coding')).toEqual({errors: [], removed: true}); expect(store.collection.activeLayoutId).toBe('l-2'); - expect(DockZoneModel.validateSavedLayoutCollection(store.collection)).toEqual([]); + expect(PerspectiveLibrary.validateSavedLayoutCollection(store.collection)).toEqual([]); // an explicit successor wins over derivation store.savePerspective(makeLayout('l-4', 'Deep', ['delta'])); @@ -341,7 +347,7 @@ test.describe('Neo.dashboard.DockPerspectiveStore (B6 — the named perspective expect(Object.keys(store.collection.layouts)).toEqual(['l-1']); expect(store.collection.layouts['l-1'].perspectiveName).toBe('Review'); expect(store.collection.activeLayoutId).toBe('l-1'); - expect(DockZoneModel.validateSavedLayoutCollection(store.collection)).toEqual([]); + expect(PerspectiveLibrary.validateSavedLayoutCollection(store.collection)).toEqual([]); // without replace, the same rename stays the structured verdict (contract unchanged) store.savePerspective(makeLayout('l-3', 'Scratch', ['gamma']), {activate: false}); diff --git a/test/playwright/unit/examples/dashboard/choreography/DemoAWorkspace.spec.mjs b/test/playwright/unit/examples/dashboard/choreography/DemoAWorkspace.spec.mjs index cfab972064..1b77123ff7 100644 --- a/test/playwright/unit/examples/dashboard/choreography/DemoAWorkspace.spec.mjs +++ b/test/playwright/unit/examples/dashboard/choreography/DemoAWorkspace.spec.mjs @@ -13,7 +13,7 @@ import '../../../../../../src/manager/Instance.mjs'; // defines Neo.get — the import Button from '../../../../../../src/button/Base.mjs'; import ClockPane from '../../../../../../examples/dashboard/choreography/ClockPane.mjs'; import DemoAWorkspace from '../../../../../../examples/dashboard/choreography/DemoAWorkspace.mjs'; -import DockProjectionReconciler from '../../../../../../src/dashboard/DockProjectionReconciler.mjs'; +import DockProjectionReconciler from '../../../../../../src/dashboard/dock/projection/Reconciler.mjs'; import {initialDocument} from '../../../../../../examples/dashboard/choreography/demoADockChoreography.mjs'; diff --git a/test/playwright/unit/examples/dashboard/choreography/demoADockChoreography.spec.mjs b/test/playwright/unit/examples/dashboard/choreography/demoADockChoreography.spec.mjs index 7450a6e4d5..6edb41e676 100644 --- a/test/playwright/unit/examples/dashboard/choreography/demoADockChoreography.spec.mjs +++ b/test/playwright/unit/examples/dashboard/choreography/demoADockChoreography.spec.mjs @@ -10,10 +10,11 @@ import {test, expect} from '@playwright/test'; import Neo from '../../../../../../src/Neo.mjs'; import * as core from '../../../../../../src/core/_export.mjs'; import DockService from '../../../../../../src/ai/client/DockService.mjs'; -import DockZoneModel from '../../../../../../src/dashboard/DockZoneModel.mjs'; +import Document from '../../../../../../src/dashboard/dock/model/Document.mjs'; +import Operations from '../../../../../../src/dashboard/dock/model/Operations.mjs'; import TourRunner from '../../../../../../src/ai/client/TourRunner.mjs'; -import DockLayoutAdapter from '../../../../../../src/dashboard/DockLayoutAdapter.mjs'; +import DockLayoutAdapter from '../../../../../../src/dashboard/dock/projection/LayoutAdapter.mjs'; import {validateTourScript} from '../../../../../../src/ai/client/tourScript.mjs'; import {demoATourScript, initialDocument} from '../../../../../../examples/dashboard/choreography/demoADockChoreography.mjs'; @@ -54,7 +55,7 @@ test.describe.serial('examples/dashboard/choreography/demoADockChoreography', () * @returns {Object} */ function createHolder() { - const holder = {dockZoneDocument: DockZoneModel.clone(initialDocument), id: 'demo-a-stage'}; + const holder = {dockZoneDocument: Document.clone(initialDocument), id: 'demo-a-stage'}; Neo.getComponent = () => holder; @@ -90,7 +91,7 @@ test.describe.serial('examples/dashboard/choreography/demoADockChoreography', () }); test('the screenplay validates fail-closed against the live executor vocabulary', () => { - const {valid, errors} = validateTourScript(demoATourScript, {operations: DockZoneModel.operations}); + const {valid, errors} = validateTourScript(demoATourScript, {operations: Operations.operations}); expect(errors).toEqual([]); expect(valid).toBe(true) @@ -142,7 +143,7 @@ test.describe.serial('examples/dashboard/choreography/demoADockChoreography', () // every op descriptor stays inside the executable vocabulary (no invented operations) demoATourScript.scenes.forEach(scene => scene.steps.filter(step => step.type === 'op').forEach(step => - expect(DockZoneModel.operations).toContain(step.descriptor.operation) + expect(Operations.operations).toContain(step.descriptor.operation) ) ) }); @@ -151,10 +152,10 @@ test.describe.serial('examples/dashboard/choreography/demoADockChoreography', () // fold the script's op descriptors through the reducer: S1 (2) + S2 (3) + the three tucks = 8 ops const opSteps = demoATourScript.scenes.flatMap(scene => scene.steps).filter(step => step.type === 'op'); - let document = DockZoneModel.clone(initialDocument); + let document = Document.clone(initialDocument); const apply = step => { - const result = DockZoneModel.applyOperation(document, step.descriptor); + const result = Operations.applyOperation(document, step.descriptor); expect(result.errors).toEqual([]); document = result.document @@ -184,7 +185,7 @@ test.describe.serial('examples/dashboard/choreography/demoADockChoreography', () expect(revealStep.type).toBe('pause'); expect(revealStep.cue).toEqual({type: 'reveal', itemId: 'preview'}); // and the cue rides the runner's beat payload untouched (data-only passthrough) - expect(validateTourScript(demoATourScript, {operations: DockZoneModel.operations}).valid).toBe(true) + expect(validateTourScript(demoATourScript, {operations: Operations.operations}).valid).toBe(true) }); test('the reveal-mode advisory rides the script: hover is an explicit workspace opt-in', () => { diff --git a/test/playwright/unit/examples/dashboard/crossWindow/DemoBWorkspace.spec.mjs b/test/playwright/unit/examples/dashboard/crossWindow/DemoBWorkspace.spec.mjs index ab524757df..0b591ff8c1 100644 --- a/test/playwright/unit/examples/dashboard/crossWindow/DemoBWorkspace.spec.mjs +++ b/test/playwright/unit/examples/dashboard/crossWindow/DemoBWorkspace.spec.mjs @@ -13,9 +13,10 @@ import '../../../../../../src/manager/Instance.mjs'; // defines Neo.get — the import Component from '../../../../../../src/component/Base.mjs'; import Container from '../../../../../../src/container/Base.mjs'; import DemoBWorkspace from '../../../../../../examples/dashboard/crossWindow/DemoBWorkspace.mjs'; -import DockPreview from '../../../../../../src/dashboard/DockPreview.mjs'; -import DockProjectionReconciler from '../../../../../../src/dashboard/DockProjectionReconciler.mjs'; -import DockZoneModel from '../../../../../../src/dashboard/DockZoneModel.mjs'; +import DockPreview from '../../../../../../src/dashboard/dock/interaction/Preview.mjs'; +import DockProjectionReconciler from '../../../../../../src/dashboard/dock/projection/Reconciler.mjs'; +import Document from '../../../../../../src/dashboard/dock/model/Document.mjs'; +import Operations from '../../../../../../src/dashboard/dock/model/Operations.mjs'; import {demoBTourScript, initialDocument} from '../../../../../../examples/dashboard/crossWindow/demoBPerspectives.mjs'; @@ -322,7 +323,7 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => test('a competing G1 child cannot steal a pane already owned by the workspace target', async () => { const harness = installWindowConnectHarness(workspace), pane = workspace.resolvePane('workbench', initialDocument.items.workbench), - moved = DockZoneModel.transferItem( + moved = Operations.transferItem( workspace.dockModel, DemoBWorkspace.createPopupDocument(), { @@ -588,12 +589,12 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => await expect(stageEntered).resolves.toEqual({itemId: 'timeline', windowId: 'tear-stage-committed'}); expect(workspace.tearOutHandlers.onDockTearOutTerminal({itemId: 'timeline', sortZone})).toBe(true); - expect(DockZoneModel.findContainingTabsId(workspace.getDockZoneDocument(), 'timeline')).toBeNull(); + expect(Document.findContainingTabsId(workspace.getDockZoneDocument(), 'timeline')).toBeNull(); expect(workspace.tearOutPanes.timeline.windowId).toBeNull(); workspace.onWindowDisconnect({windowId: 'tear-stage-committed'}); - expect(DockZoneModel.findContainingTabsId(workspace.getDockZoneDocument(), 'timeline')) + expect(Document.findContainingTabsId(workspace.getDockZoneDocument(), 'timeline')) .toBe('side-tabs'); expect(workspace.tearOutPanes.timeline).toBeUndefined(); expect(workspace.tearOutConnectAdmissions.has('timeline')).toBe(false); @@ -886,7 +887,7 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => // stage a REAL two-document transfer, then remember a home that no longer exists workspace.resolvePane('workbench', initialDocument.items.workbench); - const detached = DockZoneModel.transferItem( + const detached = Operations.transferItem( workspace.dockModel, DemoBWorkspace.createPopupDocument(), { @@ -917,7 +918,7 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => try { workspace.resolvePane('workbench', initialDocument.items.workbench); - const detached = DockZoneModel.transferItem( + const detached = Operations.transferItem( workspace.dockModel, DemoBWorkspace.createPopupDocument(), { @@ -946,7 +947,7 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => test('a manual cross-window close after transfer returns the live item to main ownership', () => { const pane = workspace.resolvePane('workbench', initialDocument.items.workbench), - detached = DockZoneModel.transferItem( + detached = Operations.transferItem( workspace.dockModel, DemoBWorkspace.createPopupDocument(), { @@ -975,7 +976,7 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => test('a popup close during projection settlement cannot strand committed popup ownership', async () => { const pane = workspace.resolvePane('workbench', initialDocument.items.workbench), - detached = DockZoneModel.transferItem( + detached = Operations.transferItem( workspace.dockModel, DemoBWorkspace.createPopupDocument(), { @@ -1040,7 +1041,7 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => }); test('whole-stack return commits synchronously, reconciles target-first, then unregisters the emptied popup', async () => { - const detached = DockZoneModel.transferItem( + const detached = Operations.transferItem( workspace.dockModel, DemoBWorkspace.createPopupDocument(), { @@ -1061,12 +1062,12 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => const descriptor = { operation : 'transferNode', - nodeId : DockZoneModel.resolveStackRoot(workspace.popupDocument), + nodeId : Document.resolveStackRoot(workspace.popupDocument), sourceWorkspaceId: DemoBWorkspace.POPUP_WORKSPACE_ID, targetWorkspaceId: DemoBWorkspace.MAIN_WORKSPACE_ID, target : {targetNodeId: 'side-tabs', placement: {kind: 'tab-into'}} }; - const returned = DockZoneModel.transferNode(workspace.popupDocument, workspace.dockModel, descriptor); + const returned = Operations.transferNode(workspace.popupDocument, workspace.dockModel, descriptor); expect(returned.errors).toEqual([]); @@ -1351,8 +1352,8 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => expect(vessel.openCount).toBe(1); expect(workspace.dockModel.items.workbench).toBeUndefined(); expect(workspace.popupDocument.items.workbench).toEqual(initialDocument.items.workbench); - expect(DockZoneModel.validate(workspace.dockModel)).toEqual([]); - expect(DockZoneModel.validate(workspace.popupDocument)).toEqual([]); + expect(Document.validate(workspace.dockModel)).toEqual([]); + expect(Document.validate(workspace.popupDocument)).toEqual([]); expect(workspace.capturePerspective('Detached', {scope: 'topology'}).saved).toBe(true); @@ -1367,8 +1368,8 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => expect(reattached).toEqual({errors: [], reattached: true}); expect(workspace.dockModel.items.workbench).toEqual(initialDocument.items.workbench); expect(workspace.popupDocument.items.workbench).toBeUndefined(); - expect(DockZoneModel.validate(workspace.dockModel)).toEqual([]); - expect(DockZoneModel.validate(workspace.popupDocument)).toEqual([]); + expect(Document.validate(workspace.dockModel)).toEqual([]); + expect(Document.validate(workspace.popupDocument)).toEqual([]); expect(workspace.resolvePane('workbench', initialDocument.items.workbench)).toBe(pane); const opensBeforeRestore = vessel.openCount, @@ -1417,7 +1418,7 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => expect(workspace.capturePerspective('Detached', {scope: 'topology'}).saved).toBe(true); const summary = workspace.perspectiveStore.list().find(entry => entry.perspectiveName === 'Detached'), - layout = DockZoneModel.clone(workspace.perspectiveStore.collection.layouts[summary.layoutId]), + layout = Document.clone(workspace.perspectiveStore.collection.layouts[summary.layoutId]), dockBefore = workspace.dockModel, popupBefore = workspace.popupDocument, dockSnapshot = JSON.stringify(dockBefore), @@ -1495,8 +1496,8 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => test('projection coalescing keeps the latest document and preservation policy atomic', async () => { const - topologyDocument = DockZoneModel.clone(initialDocument), - focusDocument = DockZoneModel.clone(initialDocument), + topologyDocument = Document.clone(initialDocument), + focusDocument = Document.clone(initialDocument), calls = []; delete topologyDocument.items.workbench; @@ -1650,7 +1651,7 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => // occurrence, in the node the other flow chose, placement record still consumed const doc = workspace.getDockZoneDocument(); - expect(DockZoneModel.findContainingTabsId(doc, 'timeline')).toBe('workbench-tabs'); + expect(Document.findContainingTabsId(doc, 'timeline')).toBe('workbench-tabs'); expect(doc.nodes['workbench-tabs'].items.filter(id => id === 'timeline')).toHaveLength(1); expect(workspace.tearOutPlacements.timeline).toBeUndefined() }); @@ -1712,7 +1713,7 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => test('resolveFocusedDockItem answers popup-origin identity from the POPUP workspace document', () => { // stage a real transfer so the popup document owns the workbench item - const detached = DockZoneModel.transferItem( + const detached = Operations.transferItem( workspace.dockModel, DemoBWorkspace.createPopupDocument(), { @@ -1843,7 +1844,7 @@ test.describe.serial('Neo.examples.dashboard.crossWindow.DemoBWorkspace', () => expect(renderer.dockPreview).toMatchObject({ itemId : 'workbench', placement: {kind: 'tab-into'}, - schema : 'neo.harness.dockPreview.v1', + schema : 'neo.dock.preview.v1', target : {nodeId: 'popup-tabs'} }); expect(DockPreview.isValidPreview(renderer.dockPreview)).toBe(true); diff --git a/test/playwright/visual/PreviewLanguageVisual.spec.mjs b/test/playwright/visual/PreviewLanguageVisual.spec.mjs index b7b5d20f93..7d4ab0c4e7 100644 --- a/test/playwright/visual/PreviewLanguageVisual.spec.mjs +++ b/test/playwright/visual/PreviewLanguageVisual.spec.mjs @@ -33,12 +33,12 @@ test.describe('Preview design language — the candidate specimen pair', () => { // that merges, its CSS map does not load the DockPreview skin — inject the compiled // sheet so the specimen renders the REAL zone/split treatments being compared. await page.evaluate(async () => { - if (![...document.styleSheets].some(sheet => sheet.href?.includes('dashboard/DockPreview.css'))) { + if (![...document.styleSheets].some(sheet => sheet.href?.includes('dashboard/dock/interaction/Preview.css'))) { await new Promise(resolve => { const link = document.createElement('link'); link.rel = 'stylesheet'; - link.href = '/dist/development/css/src/dashboard/DockPreview.css'; + link.href = '/dist/development/css/src/dashboard/dock/interaction/Preview.css'; link.onload = link.onerror = resolve; document.head.appendChild(link) })