@@ -722,6 +722,128 @@ class DockZoneModel extends Base {
722722 return errors
723723 }
724724
725+ /**
726+ * @summary Computes the shape-only fingerprint of a dock-zone document.
727+ *
728+ * The fingerprint describes topology SHAPE — node types, nesting, child arity, zone
729+ * occupancy — and deliberately contains no node ids, item ids, sizes, titles or window
730+ * identity, so two structurally identical layouts fingerprint identically regardless of
731+ * where or when they were captured (the persistence guardrail for `windowFingerprint`).
732+ * Deterministic by construction: child arrays keep document order, edge zones walk in the
733+ * fixed {@link #dockZoneEdgeKeys} order.
734+ * @param {Object } document The committed dock-zone document.
735+ * @returns {{fingerprint:(Object|null), errors:String[]} }
736+ * @static
737+ */
738+ static computeShapeFingerprint ( document ) {
739+ let errors = [ ] ;
740+
741+ if ( ! DockZoneModel . isJsonRecord ( document ) || ! DockZoneModel . isJsonRecord ( document . nodes ) ) {
742+ return { fingerprint : null , errors : [ 'fingerprint requires a document with a nodes record' ] }
743+ }
744+
745+ const counts = { 'edge-zone' : 0 , split : 0 , tabs : 0 } ,
746+ visited = new Set ( ) ;
747+
748+ const walk = nodeId => {
749+ const node = document . nodes [ nodeId ] ;
750+
751+ if ( ! node ) {
752+ errors . push ( `fingerprint walk found no node for id "${ nodeId } "` ) ;
753+ return '?'
754+ }
755+
756+ // cycle guard: a node graph that references an ancestor would recurse forever —
757+ // fail closed through the errors path, never a RangeError out of the public API
758+ if ( visited . has ( nodeId ) ) {
759+ errors . push ( `fingerprint walk detected a cycle at node "${ nodeId } "` ) ;
760+ return '?'
761+ }
762+
763+ visited . add ( nodeId ) ;
764+
765+ counts [ node . type ] = ( counts [ node . type ] || 0 ) + 1 ;
766+
767+ switch ( node . type ) {
768+ case 'split' :
769+ return `${ node . orientation === 'horizontal' ? 'h' : 'v' } (${ ( node . children || [ ] ) . map ( walk ) . join ( ',' ) } )` ;
770+ case 'tabs' :
771+ return `t${ node . items ?. length || 0 } ` ;
772+ case 'edge-zone' :
773+ return `e{${ [ ...DockZoneModel . dockZoneEdgeKeys ]
774+ . map ( zone => node . zones ?. [ zone ] ? `${ zone } :${ walk ( node . zones [ zone ] ) } ` : '' )
775+ . filter ( Boolean ) . join ( ',' ) } }`;
776+ default :
777+ errors . push ( `fingerprint walk found unsupported node type "${ node . type } "` ) ;
778+ return '?'
779+ }
780+ } ;
781+
782+ const shape = walk ( document . root ) ;
783+
784+ if ( errors . length ) {
785+ return { fingerprint : null , errors}
786+ }
787+
788+ return {
789+ fingerprint : {
790+ schema : 'neo.harness.dockShape.v1' ,
791+ shape,
792+ nodeCounts : counts ,
793+ itemCount : Object . keys ( document . items || { } ) . length
794+ } ,
795+ errors
796+ }
797+ }
798+
799+ /**
800+ * @summary Captures the current window's dock document as a v2 saved-layout perspective.
801+ *
802+ * The single-window capture scope: layout truth only enters the record — the committed
803+ * document tree — never render projections, runtime handles or pane-internal state (panes
804+ * are layout-blind, so their internals are not the layout's to save).
805+ *
806+ * Fingerprint-coherence by construction: the wrapper is written FIRST (validate + normalize
807+ * through the one writer path), and the fingerprint is computed from the PERSISTED
808+ * `layout.dockZone` — never the raw input — so the stored fingerprint cannot describe a
809+ * tree the record does not contain (normalization collapses e.g. a single-child split to
810+ * its child; a pre-normalization fingerprint would immortalize the collapsed wrapper).
811+ * @param {Object } document The committed dock-zone document to capture.
812+ * @param {Object } [metadata={}] {layoutId, title, revision, metadata, perspectiveName}
813+ * @returns {{layout:(Object|null), errors:String[]} }
814+ * @static
815+ */
816+ static capturePerspective ( document , metadata = { } ) {
817+ // pre-probe the RAW input purely as the cycle/shape gate: the writer's normalize pass
818+ // recurses and must never see a cyclic graph; the probe's fingerprint is DISCARDED so
819+ // coherence with the persisted tree is never at risk
820+ const probe = DockZoneModel . computeShapeFingerprint ( document ) ;
821+
822+ if ( probe . errors . length ) {
823+ return { layout : null , errors : probe . errors }
824+ }
825+
826+ const written = DockZoneModel . createSavedLayout ( document , {
827+ ...metadata ,
828+ captureScope : 'window' ,
829+ windowFingerprint : null
830+ } ) ;
831+
832+ if ( written . errors . length ) {
833+ return written
834+ }
835+
836+ const { fingerprint, errors} = DockZoneModel . computeShapeFingerprint ( written . layout . dockZone ) ;
837+
838+ if ( errors . length ) {
839+ return { layout : null , errors}
840+ }
841+
842+ written . layout . windowFingerprint = fingerprint ;
843+
844+ return written
845+ }
846+
725847 /**
726848 * @summary Wraps a valid committed dock-zone document in a JSON-only saved-layout envelope.
727849 *
0 commit comments