diff --git a/docs/ZoneEntityVisualization.md b/docs/ZoneEntityVisualization.md index 2afcf17..66f055d 100644 --- a/docs/ZoneEntityVisualization.md +++ b/docs/ZoneEntityVisualization.md @@ -203,6 +203,31 @@ pieces. A follow-up should introduce a zone container placement phase before attempting per-zone node layout. That follow-up can keep Cytoscape as the final renderer while moving layout decisions into a Fabric-owned view model. +### Stable Zone Containers + +The first container implementation keeps zone surfaces as view state keyed by +stable zone id. When a zone first appears, the global graph layout supplies its +initial center. Once created, the container owns the zone surface position while +the global layout continues to arrange the base canvas and unzoned nodes. + +Dragging a zone moves the container and its currently assigned visible member +nodes together. Rerunning layout or switching the layout algorithm should keep +the container in its stored graph coordinates and then project the zone's +visible subgraph back into that container. + +Container state belongs in saved or copied graph view state, not in the Fabric +payload. It is an operator workspace preference, similar to manual visibility +overrides. + +### Context Edges + +Display-only context edges are not zone connectivity. Repository `declares` +edges, for example, show which repository declared a node, but they should not +create boundary diagnostics, attraction paths, or collapsed-zone boundary +edges. A host can still show them as explanatory evidence in details, but a +zone boundary should only react to canonical or host-promoted graph +relationships. + ## Layer Height And Overlap Zone presentation includes a height. Height is a visual stacking concept, not a diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md index e67a6f5..1e45d54 100644 --- a/docs/graph-explorer-contract.md +++ b/docs/graph-explorer-contract.md @@ -192,13 +192,27 @@ initial diagnostic set includes empty zone seed sets, visible nodes matched by multiple zone definitions, and edges crossing zone boundaries. Attraction diagnostics such as multiple attraction candidates or depth-limit stops belong to the same resolver diagnostic channel when attraction rules are enabled. +Display-only context edges, such as repository `declares` edges, are evidence +for where declarations came from. They must not count as zone boundary +connectivity, attraction paths, or collapsed-zone boundary edges unless a host +explicitly promotes them to canonical graph relationships. Saved graph profiles should persist zone view state as an explicit nested `zone` object. The initial fields are `visible`, `grouping`, `definitionSet`, -and `presentation`. URL parameters may continue to expose compatibility aliases -such as `zoneBoundaries`, `zoneGrouping`, and `zoneDefinitionSet`, but saved -profiles should prefer the nested object so future zone definition sets and -presentation preferences can be restored without another state migration. +`presentation`, and `containers`. URL parameters may continue to expose +compatibility aliases such as `zoneBoundaries`, `zoneGrouping`, and +`zoneDefinitionSet`, but saved profiles should prefer the nested object so +future zone definition sets, presentation preferences, and operator-placed zone +surfaces can be restored without another state migration. + +Zone containers are view state, not fabric data. A container stores a stable +zone surface position and size in graph coordinates. Global graph layout may +place unzoned nodes and provide an initial center for new zones, but existing +zone containers should keep their operator-chosen positions when the layout +algorithm changes. After the global layout pass, each zone may project its +assigned visible nodes into local coordinates inside its container. The first +local layout may be a deterministic compact layout; later engines can replace +that with per-zone Cytoscape or engine-owned algorithms. Zone collapse is a view-only operation. A collapsed zone should hide its visible member nodes, replace them with a synthetic zone node, and draw synthetic diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index 65834ff..7a8d623 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -619,6 +619,7 @@ def graph_explorer_page() -> str: let selectedZoneId = ""; let zoneOverlayFrame = 0; let zoneSummaries = new Map(); + let zoneContainerState = new Map(); let zoneDragState = null; let suppressZoneClick = false; let collapsedZoneSnapshots = new Map(); @@ -1102,12 +1103,24 @@ def graph_explorer_page() -> str: return String(actual) === String(rule.value ?? ""); }; + const isTrueish = (value) => value === true || String(value).toLowerCase() === "true"; + + const isZoneContextOnlyEdge = (element) => { + const data = zoneElementData(element); + const edgeType = String(data.edgeType || data.dependencyType || "").trim(); + return isTrueish(data.displayOnly) || edgeType === "declares"; + }; + + const isZoneConnectivityEdge = (element) => + element && element.isEdge && element.isEdge() && !isZoneContextOnlyEdge(element); + const zoneDescriptorFromDefinition = (definition) => ({ field: definition.field, id: definition.id, label: definition.label || definition.id, value: definition.value || definition.label || definition.id, rank: definition.rank, + layout: definition.layout || {}, presentation: definition.presentation || {}, membership: definition.membership || {}, diagnostics: [], @@ -1225,15 +1238,18 @@ def graph_explorer_page() -> str: } }); elements.filter((element) => element.isEdge && element.isEdge()).forEach((edge) => { + const isConnectivity = isZoneConnectivityEdge(edge); const data = zoneElementData(edge); const zoneIds = new Set( - definitions - .filter((definition) => zoneRuleMatches(data, definition.membership)) - .map((definition) => definition.id) + isConnectivity + ? definitions + .filter((definition) => zoneRuleMatches(data, definition.membership)) + .map((definition) => definition.id) + : [] ); const sourceZoneId = assignments.get(edge.data("source")); const targetZoneId = assignments.get(edge.data("target")); - if ((sourceZoneId || targetZoneId) && sourceZoneId !== targetZoneId) { + if (isConnectivity && (sourceZoneId || targetZoneId) && sourceZoneId !== targetZoneId) { const diagnostic = { severity: "info", code: zoneDiagnosticCodes.edgeCrossesZoneBoundary, @@ -1243,8 +1259,8 @@ def graph_explorer_page() -> str: .filter(Boolean) .forEach((zoneId) => zones.get(zoneId)?.diagnostics.push(diagnostic)); } - if (sourceZoneId) zoneIds.add(sourceZoneId); - if (targetZoneId) zoneIds.add(targetZoneId); + if (isConnectivity && sourceZoneId) zoneIds.add(sourceZoneId); + if (isConnectivity && targetZoneId) zoneIds.add(targetZoneId); zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge)); }); return zones; @@ -1327,6 +1343,221 @@ def graph_explorer_page() -> str: return {left, top, width, height}; }; + const zoneNodeSortKey = (node) => + String(node.data("stableKey") || node.data("label") || node.id()); + + const zoneCenterForNodes = (nodes) => { + const positions = nodes + .map((node) => node.position()) + .filter((position) => Number.isFinite(position.x) && Number.isFinite(position.y)); + if (!positions.length) return null; + return { + x: positions.reduce((total, position) => total + position.x, 0) / positions.length, + y: positions.reduce((total, position) => total + position.y, 0) / positions.length, + }; + }; + + const zonePreferredSize = (zone) => { + const count = Math.max(1, (zone.nodes || []).length); + const columns = Math.max(1, Math.ceil(Math.sqrt(count * 1.35))); + const rows = Math.max(1, Math.ceil(count / columns)); + const cellWidth = Number(zone.layout?.options?.cellWidth) || 118; + const cellHeight = Number(zone.layout?.options?.cellHeight) || 94; + const padding = Number(zone.layout?.options?.padding) || 60; + return { + width: Math.max(180, columns * cellWidth + padding * 2), + height: Math.max(128, rows * cellHeight + padding * 2), + padding, + }; + }; + + const ensureZoneContainer = (zone) => { + if (!zone || !(zone.nodes || []).length) return null; + const preferred = zonePreferredSize(zone); + const existing = zoneContainerState.get(zone.id); + if (existing) { + existing.width = preferred.width; + existing.height = preferred.height; + existing.padding = preferred.padding; + return existing; + } + const center = zoneCenterForNodes(zone.nodes) || {x: 0, y: 0}; + const container = { + id: zone.id, + x: center.x, + y: center.y, + width: preferred.width, + height: preferred.height, + padding: preferred.padding, + userPlaced: false, + }; + zoneContainerState.set(zone.id, container); + return container; + }; + + const packZoneContainers = (zones) => { + const items = Array.from(zones.values()) + .filter((zone) => (zone.nodes || []).length > 0) + .map((zone) => ({zone, container: ensureZoneContainer(zone)})) + .filter((item) => item.container); + if (items.length < 2 || items.some((item) => item.container.userPlaced)) return; + items.sort((left, right) => + zoneRank(left.zone) - zoneRank(right.zone) || left.zone.label.localeCompare(right.zone.label) + ); + const gap = 42; + const start = Math.min(...items.map((item) => item.container.x - item.container.width / 2)); + const maxRowWidth = cy + ? Math.max(720, (cy.width() || 960) / Math.max(cy.zoom() || 1, 0.2) - 96) + : Infinity; + let cursor = start; + let top = Math.min(...items.map((item) => item.container.y - item.container.height / 2)); + let rowHeight = 0; + items.forEach(({container}) => { + if (cursor > start && cursor + container.width > start + maxRowWidth) { + cursor = start; + top += rowHeight + gap; + rowHeight = 0; + } + container.x = cursor + container.width / 2; + container.y = top + container.height / 2; + cursor += container.width + gap; + rowHeight = Math.max(rowHeight, container.height); + }); + }; + + const syncZoneContainers = (zones, options = {}) => { + let created = false; + zones.forEach((zone) => { + if ((zone.nodes || []).length > 0 && !zoneContainerState.has(zone.id)) created = true; + ensureZoneContainer(zone); + }); + if (options.packNew && created) packZoneContainers(zones); + }; + + const zoneContainerBounds = (container) => ({ + left: container.x - container.width / 2, + top: container.y - container.height / 2, + width: container.width, + height: container.height, + }); + + const zoneRenderedBoundsFromContainer = (container) => { + const bounds = zoneContainerBounds(container); + const zoom = cy ? cy.zoom() || 1 : 1; + const pan = cy ? cy.pan() || {x: 0, y: 0} : {x: 0, y: 0}; + return { + left: bounds.left * zoom + pan.x, + top: bounds.top * zoom + pan.y, + width: bounds.width * zoom, + height: bounds.height * zoom, + }; + }; + + const layoutZoneNodesInGrid = (nodes, container) => { + const padding = Number(container.padding) || 60; + const count = Math.max(1, nodes.length); + const columns = Math.max(1, Math.ceil(Math.sqrt(count * 1.35))); + const rows = Math.max(1, Math.ceil(count / columns)); + const innerWidth = Math.max(1, container.width - padding * 2); + const innerHeight = Math.max(1, container.height - padding * 2); + const cellWidth = innerWidth / columns; + const cellHeight = innerHeight / rows; + const left = container.x - container.width / 2 + padding; + const top = container.y - container.height / 2 + padding; + nodes.forEach((node, index) => { + const column = index % columns; + const row = Math.floor(index / columns); + node.position({ + x: left + cellWidth * (column + 0.5), + y: top + cellHeight * (row + 0.5), + }); + }); + }; + + const layoutZoneNodesInCircle = (nodes, container) => { + if (nodes.length === 1) { + nodes[0].position({x: container.x, y: container.y}); + return; + } + const padding = Number(container.padding) || 60; + const radius = Math.max(20, Math.min(container.width, container.height) / 2 - padding); + nodes.forEach((node, index) => { + const angle = -Math.PI / 2 + (Math.PI * 2 * index) / nodes.length; + node.position({ + x: container.x + Math.cos(angle) * radius, + y: container.y + Math.sin(angle) * radius, + }); + }); + }; + + const layoutZoneSubgraph = (zone, container) => { + const nodes = (zone.nodes || []) + .slice() + .sort((left, right) => zoneNodeSortKey(left).localeCompare(zoneNodeSortKey(right))); + if (!nodes.length || !container) return; + const algorithm = String(zone.layout?.algorithm || "compact-grid"); + if (algorithm === "circle") { + layoutZoneNodesInCircle(nodes, container); + return; + } + layoutZoneNodesInGrid(nodes, container); + }; + + const applyZoneContainerLayout = () => { + if (!cy || zoneDragState) return; + const zones = collectZoneSummaries(); + syncZoneContainers(zones, {packNew: true}); + zones.forEach((zone) => { + if (!(zone.nodes || []).length) return; + layoutZoneSubgraph(zone, ensureZoneContainer(zone)); + }); + zoneSummaries = zones; + updateSelectionAnchor(); + scheduleZoneOverlayUpdate(); + }; + + const fitVisibleGraph = () => { + if (!cy) return; + const visible = cy.elements().filter((element) => element.style("display") !== "none"); + if (visible.length) cy.fit(visible, 72); + }; + + const serializedZoneContainers = () => { + const entries = Array.from(zoneContainerState.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, container]) => [id, { + x: Math.round(container.x * 100) / 100, + y: Math.round(container.y * 100) / 100, + width: Math.round(container.width * 100) / 100, + height: Math.round(container.height * 100) / 100, + userPlaced: container.userPlaced === true, + }]); + return Object.fromEntries(entries); + }; + + const normalizedZoneContainers = (containers) => { + const normalized = new Map(); + if (!containers || typeof containers !== "object") return normalized; + Object.entries(containers).forEach(([id, value]) => { + if (!value || typeof value !== "object") return; + const x = Number(value.x); + const y = Number(value.y); + const width = Number(value.width); + const height = Number(value.height); + if (![x, y, width, height].every(Number.isFinite)) return; + normalized.set(id, { + id, + x, + y, + width: Math.max(1, width), + height: Math.max(1, height), + padding: Number(value.padding) || 60, + userPlaced: value.userPlaced === true, + }); + }); + return normalized; + }; + const collectZoneSummaries = () => { if (!cy) return new Map(); const visibleElements = cy.elements() @@ -1439,7 +1670,7 @@ def graph_explorer_page() -> str: const boundaryEdges = []; let internalEdgeCount = 0; (zone.elements || []) - .filter((element) => element.isEdge && element.isEdge()) + .filter(isZoneConnectivityEdge) .forEach((edge) => { const source = edge.data("source"); const target = edge.data("target"); @@ -1574,11 +1805,13 @@ def graph_explorer_page() -> str: return; } zoneSummaries = collectZoneSummaries(); + syncZoneContainers(zoneSummaries); const boundaries = Array.from(zoneSummaries.values()) .filter((zone) => zone.nodes.length > 0) .sort((left, right) => zoneRank(left) - zoneRank(right) || left.label.localeCompare(right.label)) .map((zone, index) => { - const bounds = zoneBoundsForNodes(zone.nodes); + const container = ensureZoneContainer(zone); + const bounds = container ? zoneRenderedBoundsFromContainer(container) : zoneBoundsForNodes(zone.nodes); if (!bounds) return ""; const style = zoneStyle(zone); const selectedClass = selectedZoneId === zone.id ? " selected" : ""; @@ -1600,6 +1833,8 @@ def graph_explorer_page() -> str: const startZoneDrag = (event, zoneId) => { const zone = zoneSummaries.get(zoneId); if (!cy || !zone || !(zone.nodes || []).length) return; + const container = ensureZoneContainer(zone); + if (!container) return; event.preventDefault(); event.stopPropagation(); selected = null; @@ -1614,6 +1849,7 @@ def graph_explorer_page() -> str: startY: event.clientY, moved: false, handle, + container: {...container}, nodes: zone.nodes.map((node) => ({ node, position: {...node.position()}, @@ -1636,6 +1872,12 @@ def graph_explorer_page() -> str: if (!node || !node.length) return; node.position({x: position.x + dx, y: position.y + dy}); }); + const container = zoneContainerState.get(zoneDragState.zoneId); + if (container && zoneDragState.container) { + container.x = zoneDragState.container.x + dx; + container.y = zoneDragState.container.y + dy; + container.userPlaced = true; + } updateSelectionAnchor(); scheduleZoneOverlayUpdate(); }; @@ -1652,6 +1894,8 @@ def graph_explorer_page() -> str: const zone = zoneSummaries.get(draggedZoneId); if (zone) renderZoneDetails(zone); updateProfileSummary(`Moved zone "${zone?.label || draggedZoneId}".`); + updateProfileControls(); + updateUrlState(); window.setTimeout(() => { suppressZoneClick = false; }, 0); } }; @@ -1798,6 +2042,7 @@ def graph_explorer_page() -> str: boundaries: visible, labels: true, }, + containers: serializedZoneContainers(), }; }; @@ -1894,6 +2139,9 @@ def graph_explorer_page() -> str: const presentation = nested.presentation && typeof nested.presentation === "object" ? nested.presentation : {}; + const containers = nested.containers && typeof nested.containers === "object" + ? nested.containers + : state.zoneContainers; const grouping = nested.grouping || state.zoneGrouping || "deploymentEnvironment"; const definitionSet = zoneDefinitionSets[nested.definitionSet || state.zoneDefinitionSet] ? nested.definitionSet || state.zoneDefinitionSet @@ -1911,6 +2159,7 @@ def graph_explorer_page() -> str: boundaries: "boundaries" in presentation ? presentation.boundaries !== false : visible, labels: "labels" in presentation ? presentation.labels !== false : true, }, + containers: normalizedZoneContainers(containers), }; }; @@ -1929,6 +2178,7 @@ def graph_explorer_page() -> str: activeZoneDefinitionSet = zone.definitionSet; if (zoneBoundaryToggle) zoneBoundaryToggle.checked = zone.visible; if (zoneGroupSelect) zoneGroupSelect.value = zone.grouping; + zoneContainerState = zone.containers; } if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") { manualOverrides = {...state.manualOverrides}; @@ -2478,6 +2728,8 @@ def graph_explorer_page() -> str: } : {name, padding: 48, animate: false}; layoutElements.layout(options).run(); + applyZoneContainerLayout(); + fitVisibleGraph(); updateSelectionAnchor(); scheduleZoneOverlayUpdate(); }; @@ -2614,7 +2866,12 @@ def graph_explorer_page() -> str: renderRules(); cy.on("tap", "node, edge", (event) => showDetails(event.target)); cy.on("tap", (event) => { if (event.target === cy) showDetails(null); }); - cy.on("pan zoom resize render layoutstop", () => { + cy.on("pan zoom resize render", () => { + updateSelectionAnchor(); + scheduleZoneOverlayUpdate(); + }); + cy.on("layoutstop", () => { + applyZoneContainerLayout(); updateSelectionAnchor(); scheduleZoneOverlayUpdate(); }); diff --git a/railiance_fabric/zone_view.py b/railiance_fabric/zone_view.py index a1fec95..679d079 100644 --- a/railiance_fabric/zone_view.py +++ b/railiance_fabric/zone_view.py @@ -401,6 +401,8 @@ def resolve_zones( internal_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list) boundary_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list) for edge in edge_records: + if _is_context_only_edge(edge): + continue source_zone_id = assignments.get(edge.source).zone_id if edge.source in assignments else None target_zone_id = assignments.get(edge.target).zone_id if edge.target in assignments else None if source_zone_id and source_zone_id == target_zone_id: @@ -484,6 +486,8 @@ def _attraction_candidates( nodes_by_id = {node.id: node for node in node_records} adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list) for edge in edge_records: + if _is_context_only_edge(edge): + continue adjacency[edge.source].append(edge) adjacency[edge.target].append(edge) @@ -619,6 +623,14 @@ def _edge_matches_attraction_rule(edge: _EdgeRecord, rule: ZoneAttractionRule) - return _rule_matches(edge.data, rule.edge_filter, empty_matches=True) +def _is_context_only_edge(edge: _EdgeRecord) -> bool: + return _trueish(edge.data.get("displayOnly", edge.data.get("display_only"))) or edge.edge_type == "declares" + + +def _trueish(value: Any) -> bool: + return value is True or str(value).lower() == "true" + + def _neighbor_for_direction(node_id: str, edge: _EdgeRecord, direction: str) -> str: if direction in {"out", "both"} and edge.source == node_id: return edge.target diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index b3efe60..dce0c82 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -422,11 +422,20 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None: assert "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES" in page assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in page assert "zone.diagnostics" in page + assert "isZoneContextOnlyEdge" in page + assert "isZoneConnectivityEdge" in page assert "currentZoneViewState" in page assert "normalizeZoneViewState" in page assert "zoneDefinitionSet" in page assert "zoneDefinitionSets" in page assert "fabric-default" in page + assert "zoneContainerState" in page + assert "ensureZoneContainer" in page + assert "packZoneContainers" in page + assert "applyZoneContainerLayout" in page + assert "layoutZoneSubgraph" in page + assert "serializedZoneContainers" in page + assert "normalizedZoneContainers" in page assert "collapsedZoneSnapshots" in page assert "zoneDragState" in page assert "startZoneDrag" in page diff --git a/tests/test_zone_view.py b/tests/test_zone_view.py index bdda5eb..e4085a0 100644 --- a/tests/test_zone_view.py +++ b/tests/test_zone_view.py @@ -96,6 +96,43 @@ def test_resolver_assigns_seed_nodes_and_boundary_edges() -> None: } +def test_resolver_ignores_context_only_edges_for_boundaries_and_attraction() -> None: + resolution = resolve_zones( + nodes=[ + _node("repo", kind="Repository"), + _node("svc.prod", deploymentEnvironment="prod"), + _node("svc.context", kind="service"), + ], + edges=[ + _edge("edge.repo-prod", "repo", "svc.prod", "declares", displayOnly=True), + _edge("edge.prod-context", "svc.prod", "svc.context", "declares", displayOnly=True), + ], + zone_definitions=[ + { + "id": "prod", + "label": "prod", + "membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"}, + "attraction": { + "rules": [ + { + "edge_type": "*", + "direction": "both", + "depth": 1, + } + ] + }, + }, + ], + ) + + assert resolution.zone_by_id("prod").boundary_edge_ids == () + assert resolution.zone_by_id("prod").internal_edge_ids == () + assert "svc.context" not in resolution.node_assignments + assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" not in { + diagnostic.code for diagnostic in resolution.diagnostics + } + + def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None: resolution = resolve_zones( nodes=[ diff --git a/workplans/RAIL-FAB-WP-0024-stable-zone-containers-and-layout.md b/workplans/RAIL-FAB-WP-0024-stable-zone-containers-and-layout.md new file mode 100644 index 0000000..8efdea2 --- /dev/null +++ b/workplans/RAIL-FAB-WP-0024-stable-zone-containers-and-layout.md @@ -0,0 +1,129 @@ +--- +id: RAIL-FAB-WP-0024 +type: workplan +title: "Stabilize zone containers and layout zone subgraphs" +domain: railiance +repo: railiance-fabric +status: finished +owner: codex +topic_slug: railiance-fabric +created: "2026-05-25" +updated: "2026-05-25" +--- + +# Stabilize Zone Containers And Layout Zone Subgraphs + +## Context + +RAIL-FAB-WP-0022 made zones first-class visualization entities and +RAIL-FAB-WP-0023 added plain labels plus view-only zone dragging. The current +prototype still derives a zone rectangle directly from the current positions of +its member nodes. When the operator changes the layout algorithm or reruns +layout, the zone surface moves with the global graph layout instead of staying +where the operator placed it. + +The current resolver also treats every edge attached to a zoned node as zone +context. That includes display-only repository declaration edges. Those edges +help explain where graph declarations came from, but they should not be read as +direct deployment or operational connections across a zone boundary. + +This workplan moves the graph explorer toward the intended model: a zone is a +stable drawing surface, and the assigned subgraph is laid out inside that +surface as a separate view concern. + +## Task 1: Separate Context Edges From Zone Boundary Connectivity + +```task +id: RAIL-FAB-WP-0024-T01 +status: done +priority: high +``` + +Exclude display-only/context edges, especially repository `declares` edges, +from zone boundary diagnostics and collapsed-zone boundary edges. + +Expected result: zone detail may still mention contextual evidence when useful, +but display-only declaration edges do not make it look as if every zoned node is +directly connected to an unzoned repository. + +Result: Display-only edges and repository `declares` edges are now treated as +context-only in both the shared zone resolver and the browser view. They do not +create boundary diagnostics, attraction paths, or collapsed-zone boundary +edges. + +## Task 2: Persist Stable Zone Container Positions + +```task +id: RAIL-FAB-WP-0024-T02 +status: done +priority: high +``` + +Introduce a view-level zone container state keyed by stable zone id. + +The implementation should: + +- remember zone container center and size after layout and drag; +- keep user-moved zone positions stable when the global layout algorithm + changes; +- keep saved/copied view state compatible with the nested `zone` object; +- avoid mutating the Fabric graph payload. + +Expected result: switching between layout algorithms keeps zone papers in the +same operator-chosen positions. + +Result: The graph explorer now stores stable zone containers in view state. +Containers remember graph-coordinate position and size, survive global layout +reruns, can be saved/copied through the nested `zone` state, and remain separate +from Fabric graph payload data. + +## Task 3: Layout Zone Subgraphs Inside Containers + +```task +id: RAIL-FAB-WP-0024-T03 +status: done +priority: high +``` + +Add a per-zone layout pass that positions assigned visible nodes inside their +zone container after the global layout places the surrounding graph. + +The first implementation may use a deterministic compact layout rather than a +full nested Cytoscape layout, as long as it: + +- only moves visible nodes assigned to the zone; +- keeps each node inside the zone's drawing surface; +- treats unzoned nodes as part of the base canvas; +- keeps edge routing intact through Cytoscape's normal renderer. + +Expected result: the visible subgraph inside each zone is arranged by the zone +view model, not merely enclosed after the global layout. + +Result: After global layout, each visible zone projects its assigned nodes into +a local compact layout inside its stable container. New, not-yet-moved zones are +packed into readable rows so the default deployment-environment papers start as +separate drawing surfaces. + +## Task 4: Verify And Document Zone Layout Semantics + +```task +id: RAIL-FAB-WP-0024-T04 +status: done +priority: medium +``` + +Update the graph explorer contract and run focused validation. + +Verification should cover: + +- static UI assertions for context-edge filtering and zone container helpers; +- JavaScript syntax validation; +- focused graph explorer and zone resolver tests; +- a visual smoke check of the graph explorer. + +Expected result: documentation and tests describe the new semantics, and the +running UI shows stable zone containers with locally arranged member nodes. + +Result: Updated the graph explorer contract and zone entity documentation. +Focused tests, generated JavaScript syntax validation, full tests, CLI +validation, and a headless Edge visual smoke check all passed.