diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md index 90d9af1..e67a6f5 100644 --- a/docs/graph-explorer-contract.md +++ b/docs/graph-explorer-contract.md @@ -173,6 +173,11 @@ When access-zone grouping is selected, boundaries use `accessZone` values such as `private-dev`, `collaborator-test`, `production-public`, or `production-admin`. +Zone labels should be rendered as plain text in the upper-left corner of the +zone rectangle, without badge frames or white backgrounds. The label may still +act as the zone's focus/drag handle as long as it visually reads as text drawn +on the zone surface. + Useful warnings for the graph explorer include: - control surfaces in user-facing access zones; @@ -202,6 +207,11 @@ are summarized on the zone node rather than rendered. Expanding the zone removes the synthetic elements and restores the original graph elements without changing the underlying payload. +Zone dragging is also view-only. Dragging a zone handle should translate the +currently assigned visible member nodes by the same delta and then recompute the +overlay bounds from the new node positions. The operation updates Cytoscape view +coordinates only; it does not change Fabric graph data. + ## Repo-Scoping Compatibility Repo-scoping can adapt without a rewrite because its current graph payload diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index cf6e3d9..65834ff 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -65,17 +65,20 @@ def graph_explorer_page() -> str: z-index: 1; min-height: 26px; max-width: calc(100% - 20px); - border-color: var(--zone-color, #2563eb); + border: 0; color: var(--zone-color, #2563eb); - background: rgba(255, 255, 255, .96); - box-shadow: 0 8px 18px rgba(23, 32, 51, .1); + background: transparent; + box-shadow: none; + cursor: grab; font-size: 12px; font-weight: 700; overflow: hidden; - padding: 3px 8px; + padding: 0; pointer-events: auto; text-overflow: ellipsis; + text-shadow: 0 1px 0 rgba(255, 255, 255, .78), 0 0 8px rgba(255, 255, 255, .72); } + .zone-label.dragging { cursor: grabbing; } .zone-label:focus-visible { outline: 2px solid var(--zone-color, #2563eb); outline-offset: 2px; @@ -616,6 +619,8 @@ def graph_explorer_page() -> str: let selectedZoneId = ""; let zoneOverlayFrame = 0; let zoneSummaries = new Map(); + let zoneDragState = null; + let suppressZoneClick = false; let collapsedZoneSnapshots = new Map(); const zoneCollapsePrefix = "zone-collapse:"; @@ -1266,8 +1271,7 @@ def graph_explorer_page() -> str: }; const zoneLabelTop = (zone, bounds, index) => { - const rank = zone.field === "deploymentEnvironment" ? zoneLabelRank(zone, index) : index % 4; - return Math.min(8 + rank * 28, Math.max(8, Math.round(bounds.height - 32))); + return 8; }; const zoneForData = (data, grouping = activeZoneGrouping) => { @@ -1593,6 +1597,65 @@ def graph_explorer_page() -> str: } }; + const startZoneDrag = (event, zoneId) => { + const zone = zoneSummaries.get(zoneId); + if (!cy || !zone || !(zone.nodes || []).length) return; + event.preventDefault(); + event.stopPropagation(); + selected = null; + selectedZoneId = zone.id; + const handle = event.target.closest(".zone-label"); + handle?.classList.add("dragging"); + handle?.setPointerCapture?.(event.pointerId); + zoneDragState = { + zoneId: zone.id, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + moved: false, + handle, + nodes: zone.nodes.map((node) => ({ + node, + position: {...node.position()}, + })), + }; + renderZoneDetails(zone); + }; + + const moveZoneDrag = (event) => { + if (!zoneDragState || event.pointerId !== zoneDragState.pointerId || !cy) return; + event.preventDefault(); + const zoom = cy.zoom() || 1; + const dx = (event.clientX - zoneDragState.startX) / zoom; + const dy = (event.clientY - zoneDragState.startY) / zoom; + if (Math.abs(event.clientX - zoneDragState.startX) + Math.abs(event.clientY - zoneDragState.startY) > 3) { + zoneDragState.moved = true; + suppressZoneClick = true; + } + zoneDragState.nodes.forEach(({node, position}) => { + if (!node || !node.length) return; + node.position({x: position.x + dx, y: position.y + dy}); + }); + updateSelectionAnchor(); + scheduleZoneOverlayUpdate(); + }; + + const finishZoneDrag = (event) => { + if (!zoneDragState || event.pointerId !== zoneDragState.pointerId) return; + zoneDragState.handle?.classList.remove("dragging"); + zoneDragState.handle?.releasePointerCapture?.(event.pointerId); + const draggedZoneId = zoneDragState.zoneId; + const moved = zoneDragState.moved; + zoneDragState = null; + renderZoneOverlay(); + if (moved) { + const zone = zoneSummaries.get(draggedZoneId); + if (zone) renderZoneDetails(zone); + updateProfileSummary(`Moved zone "${zone?.label || draggedZoneId}".`); + window.setTimeout(() => { suppressZoneClick = false; }, 0); + } + }; + const scheduleZoneOverlayUpdate = () => { if (!zoneOverlay || zoneOverlayFrame) return; zoneOverlayFrame = window.requestAnimationFrame(() => { @@ -2649,6 +2712,10 @@ def graph_explorer_page() -> str: updateUrlState(); }); zoneOverlay.addEventListener("click", (event) => { + if (suppressZoneClick) { + suppressZoneClick = false; + return; + } const button = event.target.closest("[data-zone-id]"); if (!button) return; const zone = zoneSummaries.get(button.dataset.zoneId); @@ -2657,6 +2724,14 @@ def graph_explorer_page() -> str: selectedZoneId = zone.id; renderZoneOverlay(); }); + zoneOverlay.addEventListener("pointerdown", (event) => { + const handle = event.target.closest(".zone-label[data-zone-id]"); + if (!handle || event.button !== 0) return; + startZoneDrag(event, handle.dataset.zoneId); + }); + window.addEventListener("pointermove", moveZoneDrag); + window.addEventListener("pointerup", finishZoneDrag); + window.addEventListener("pointercancel", finishZoneDrag); ruleTarget.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value})); ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value})); ruleAttribute.addEventListener("input", () => refreshRuleBuilder({ diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index 5fc7f26..b3efe60 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -428,6 +428,14 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None: assert "zoneDefinitionSets" in page assert "fabric-default" in page assert "collapsedZoneSnapshots" in page + assert "zoneDragState" in page + assert "startZoneDrag" in page + assert "moveZoneDrag" in page + assert "finishZoneDrag" in page + assert "Moved zone" in page + assert "cursor: grab" in page + assert "background: transparent" in page + assert "box-shadow: none" in page assert "collapseZone" in page assert "expandZone" in page assert "zoneCollapseNodeId" in page diff --git a/workplans/RAIL-FAB-WP-0023-zone-drag-interaction.md b/workplans/RAIL-FAB-WP-0023-zone-drag-interaction.md new file mode 100644 index 0000000..5a887af --- /dev/null +++ b/workplans/RAIL-FAB-WP-0023-zone-drag-interaction.md @@ -0,0 +1,110 @@ +--- +id: RAIL-FAB-WP-0023 +type: workplan +title: "Improve zone labels and dragging" +domain: railiance +repo: railiance-fabric +status: active +owner: codex +topic_slug: railiance-fabric +created: "2026-05-25" +updated: "2026-05-25" +--- + +# Improve Zone Labels And Dragging + +## Context + +RAIL-FAB-WP-0021 introduced zone boundary rectangles and RAIL-FAB-WP-0022 +promoted zones to first-class graph explorer view entities. The current zone +labels still look like small framed buttons with a white background. That makes +the overlay read more like controls than like labels printed on a drawing +surface. + +The next interaction step is to let the operator grab a zone and move it around, +dragging all currently attached visible nodes with it. This makes zones behave +more like the intended "piece of paper" view entity while keeping the underlying +Fabric graph unchanged. + +## Task 1: Simplify Zone Labels + +```task +id: RAIL-FAB-WP-0023-T01 +status: done +priority: high +``` + +Change zone labels from framed button badges to plain text in the upper-left +corner of the zone rectangle. + +The label should: + +- have no frame; +- have no white background; +- remain readable against the zone fill; +- still be keyboard-focusable/clickable enough to open zone details; +- avoid adding visual clutter to dense graph views. + +Expected result: graph explorer zone labels look like text drawn on the zone +surface rather than separate UI controls. + +Result: Updated graph explorer zone labels to render as transparent, unframed +text in the upper-left corner of each zone rectangle. Labels remain +keyboard-focusable and clickable, and the visual smoke screenshot confirms the +labels read as text on the zone surface. + +## Task 2: Drag Zones With Member Nodes + +```task +id: RAIL-FAB-WP-0023-T02 +status: done +priority: high +``` + +Add a zone drag interaction that moves all currently attached visible member +nodes with the zone. + +The interaction should: + +- start from the zone label or zone boundary affordance; +- move assigned visible nodes by the drag delta; +- keep Cytoscape node positions and overlay bounds in sync; +- update zone details and labels during/after the drag; +- avoid mutating the underlying Fabric payload; +- not interfere with normal node dragging more than necessary. + +Expected result: grabbing a zone lets the operator reposition the visible zone +subgraph as a unit. + +Result: Added a view-only zone drag interaction using the plain zone label as +the grab handle. Dragging translates the currently assigned visible zone member +nodes by the pointer delta, recomputes overlay bounds, refreshes zone details, +and leaves the Fabric graph payload unchanged. + +## Task 3: Verify Interaction Behavior + +```task +id: RAIL-FAB-WP-0023-T03 +status: blocked +priority: medium +``` + +Verify the visual and interaction behavior with focused tests and a browser +smoke check. + +The verification should cover: + +- static UI test assertions for the new label/drag helpers; +- JavaScript syntax validation; +- graph explorer focused tests; +- a visual smoke screenshot showing plain labels; +- manual or scripted confirmation that zone dragging moves member nodes. + +Expected result: the UI renders clean labels and the zone drag interaction works +without breaking existing graph explorer behavior. + +Result: Static UI assertions, JavaScript syntax validation, focused graph tests, +full test suite, Fabric CLI validation, and a headless Edge screenshot pass. +Automated drag smoke verification is blocked because Edge's remote debugging +endpoint was not reachable from WSL in this session. Manual verification is +needed by grabbing a zone label in the running graph explorer.