diff --git a/backend/canvas.py b/backend/canvas.py
index 9b7b52e..3456ffe 100644
--- a/backend/canvas.py
+++ b/backend/canvas.py
@@ -702,6 +702,12 @@ class SceneDocument:
# legacy scene's orthogonal_routing bool, same bare-bool/"scene"-topic
# shape as fade_connections_enabled above.
orthogonal_routing: bool = False
+ # R7.5b-3: Qt-removal plan R7.5's third and final canvas-visual parity
+ # fix - the legacy scene's smart_guides bool, same bare-bool/"scene"-topic
+ # shape as the two toggles above. The snap math itself is 100% client-side
+ # (web_ui/src/app/canvas/smartGuides.ts), matching the legacy split where
+ # ChatScene owned only the flag and the geometry ran in the view layer.
+ smart_guides: bool = False
drag_factor: float = 1.0
# Canvas font (ChatScene's setFontFamily/-Size/-Color state, R2): defaults
# match the legacy scene's own construction-time values.
@@ -2800,6 +2806,7 @@ def scene_payload(self) -> dict[str, Any]:
"snapToGrid": self.snap_to_grid,
"fadeConnectionsEnabled": self.fade_connections_enabled,
"orthogonalRouting": self.orthogonal_routing,
+ "smartGuides": self.smart_guides,
"dragFactor": self.drag_factor,
"fontFamily": self.font_family,
"fontSizePt": self.font_size_pt,
@@ -4001,6 +4008,10 @@ async def set_orthogonal_routing(enabled):
document.orthogonal_routing = bool(enabled)
await publish_scene()
+ async def set_smart_guides(enabled):
+ document.smart_guides = bool(enabled)
+ await publish_scene()
+
async def set_drag_factor(factor):
document.set_drag_factor(factor)
await publish_scene()
@@ -4067,6 +4078,7 @@ async def set_view_state(zoom_factor, scroll_x, scroll_y):
# setSnapToGrid/setFadeConnections above - the Python function name above
# doesn't need to match.
bus.register_intent("scene", "setOrthogonalConnections", set_orthogonal_routing)
+ bus.register_intent("scene", "setSmartGuides", set_smart_guides)
bus.register_intent("scene", "setDragFactor", set_drag_factor)
bus.register_intent("scene", "setViewState", set_view_state)
# R4.3: per-node cancel for a ConversationNode's in-flight reply. Reuses
diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py
index 4e6233c..5000202 100644
--- a/backend/tests/test_canvas.py
+++ b/backend/tests/test_canvas.py
@@ -2186,6 +2186,24 @@ async def run():
asyncio.run(run())
+def test_smart_guides_defaults_false_and_the_setter_publishes_scene():
+ # R7.5b-3: the third and final canvas-visual parity fix - the snap math
+ # itself is frontend-only (smartGuides.ts); the backend owns just the
+ # toggle, matching legacy's ChatScene-owns-the-flag split.
+ async def run():
+ bus, document, recorder = make_bus()
+ assert document.scene_payload()["smartGuides"] is False
+
+ await bus.dispatch_intent("scene", "setSmartGuides", [True])
+ assert document.scene_payload()["smartGuides"] is True
+ assert recorder.topics_seen()[-1] == "scene"
+
+ await bus.dispatch_intent("scene", "setSmartGuides", [False])
+ assert document.scene_payload()["smartGuides"] is False
+
+ asyncio.run(run())
+
+
# -- R4.4a: Generate/Regenerate Image - domain-level resolvers ---------------
diff --git a/graphlink_app/graphlink_scene_payload.py b/graphlink_app/graphlink_scene_payload.py
index 102718f..6cbc2b7 100644
--- a/graphlink_app/graphlink_scene_payload.py
+++ b/graphlink_app/graphlink_scene_payload.py
@@ -327,6 +327,9 @@ class SceneStatePayload:
# R7.5b-2: Qt-removal plan R7.5's second canvas-visual parity fix -
# populated 1:1 from backend/canvas.py's SceneDocument.orthogonal_routing.
orthogonalRouting: bool
+ # R7.5b-3: the third and final canvas-visual parity fix - populated 1:1
+ # from backend/canvas.py's SceneDocument.smart_guides.
+ smartGuides: bool
dragFactor: float
fontFamily: str
fontSizePt: int
diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx
index 0cf29bb..d808e39 100644
--- a/web_ui/src/app/canvas/SceneCanvas.tsx
+++ b/web_ui/src/app/canvas/SceneCanvas.tsx
@@ -5,6 +5,7 @@ import {
MiniMap,
Position,
ReactFlow,
+ ViewportPortal,
useReactFlow,
useStore,
type Connection,
@@ -42,6 +43,7 @@ import {
VIEWPORT_REPORT_DEBOUNCE_MS,
} from "./canvasConstants";
import { SceneStore, scaleDragPosition } from "./sceneStore";
+import { computeSmartGuideSnap, type GuideLine, type Rect } from "./smartGuides";
// R6.1: Notes/Frames/Containers - the generated SceneNodeRow type (codegen
// source: backend/canvas.py's scene_payload()) has not been regenerated yet
@@ -839,6 +841,20 @@ export function isOrthogonalEligible(sourceKind: string | undefined, targetKind:
return ORTHO_ELIGIBLE_TARGET_KINDS.has(targetKind);
}
+// R7.5b-3: one node's current rendered size in flow units, for smart-guide
+// rects - see the comment on CanvasInner's reactFlow hoist for why the
+// internal store alone is not enough and the DOM is the reliable fallback.
+type MeasuredSizeSource = { getInternalNode: (id: string) => { measured?: { width?: number; height?: number } } | undefined };
+function measuredNodeSize(reactFlow: MeasuredSizeSource, id: string): { width: number; height: number } | null {
+ const measured = reactFlow.getInternalNode(id)?.measured;
+ if (measured?.width !== undefined && measured?.height !== undefined) {
+ return { width: measured.width, height: measured.height };
+ }
+ const el = document.querySelector(`.react-flow__node[data-id="${CSS.escape(id)}"]`);
+ if (!(el instanceof HTMLElement) || el.offsetWidth === 0) return null;
+ return { width: el.offsetWidth, height: el.offsetHeight };
+}
+
// Exported standalone for direct unit testing, same posture as toFlowNodes
// above - R7.5b-1's fade-connections opacity logic doesn't need a mounted
// to verify.
@@ -887,6 +903,19 @@ export function makeDebouncedViewportReport(
function CanvasInner({ store }: { store: SceneStore }) {
const scene = useSyncExternalStore(store.subscribe, store.getScene);
const grid = useSyncExternalStore(store.subscribe, store.getGrid);
+ // Hoisted above onNodesChange: smart guides (R7.5b-3) need node
+ // dimensions. Neither the local `nodes` array NOR React Flow's internal
+ // store reliably has them here: toFlowNodes rebuilds the array from every
+ // scene snapshot with fresh objects (no `measured`), and RF's own
+ // adoptUserNodes then rebuilds its internal `measured` FROM those user
+ // objects (verified against the installed @xyflow/system source, and
+ // confirmed live: `getInternalNode(...).measured` was {} at drag time), so
+ // both copies get wiped on every publish. measuredNodeSize below prefers
+ // the internal store when populated and falls back to the live DOM
+ // element's layout size - offsetWidth/Height are pre-transform layout px
+ // (flow units at any zoom), and reading the live rendered rect is also the
+ // most faithful translation of legacy's own sceneBoundingRect() reads.
+ const reactFlow = useReactFlow();
// Local node state exists so dragging is fluid; backend snapshots are the
// truth and reconcile in whenever nothing is being dragged. dragStartRef
@@ -901,6 +930,14 @@ function CanvasInner({ store }: { store: SceneStore }) {
// flag.
const [hoveredEdgeId, setHoveredEdgeId] = useState(null);
+ // R7.5b-3: the smart-guide lines currently visible during a drag - local
+ // component state only, never scene state, matching legacy's non-persisted
+ // ChatScene.smart_guide_lines. Set per drag frame in onNodesChange, cleared
+ // on drag end; the render-time gate below (not an effect) hides any stale
+ // lines instantly if the toggle flips off mid-drag.
+ const [smartGuideLines, setSmartGuideLines] = useState([]);
+ const visibleGuideLines = scene.smartGuides ? smartGuideLines : [];
+
// R6.3: viewport (pan/zoom) reporting - see makeDebouncedViewportReport's
// own doc above. onMove fires on every frame of a pan/zoom gesture (never
// just once at the end, unlike NodeResizer's onResizeEnd), so the debounce
@@ -937,27 +974,78 @@ function CanvasInner({ store }: { store: SceneStore }) {
// drag frame.
const memberChanges: NodeChange[] = [];
const memberMoveIntents: Array<{ id: string; x: number; y: number }> = [];
+ // R7.5b-3: guides produced by this frame's drag changes - applied via
+ // one setSmartGuideLines call after the map, cleared on drag end.
+ // DELIBERATE deviation for multi-select drags (review-confirmed):
+ // legacy cleared guides per-item, so only the LAST-processed item's
+ // guides survived each frame - an artifact of Qt's per-item itemChange
+ // ordering, not a design choice. Accumulating every co-mover's guides
+ // shows all live alignments instead of an arbitrary one.
+ const frameGuides: GuideLine[] = [];
+ let sawDragging = false;
+ let sawDragEnd = false;
const scaled = changes.map((change) => {
if (change.type !== "position" || !change.position) return change;
if (change.dragging) {
draggingRef.current = true;
+ sawDragging = true;
let start = dragStartRef.current.get(change.id);
if (!start) {
const node = nodes.find((n) => n.id === change.id);
start = node ? { ...node.position } : { ...change.position };
dragStartRef.current.set(change.id, start);
}
- const scaledPosition = scaleDragPosition(start, change.position, scene.dragFactor);
- memberChanges.push(...applyGroupDragDelta(nodes, change.id, scaledPosition));
+ let finalPosition = scaleDragPosition(start, change.position, scene.dragFactor);
+ // R7.5b-3: smart-guide snap, as a LAYERED PASS on top of React
+ // Flow's native grid-snap (which, when enabled, already ran inside
+ // RF before this change was emitted) - the recorded design
+ // decision, chosen over re-implementing grid-snap manually. Smart
+ // guides win per-axis when both would apply, reproducing legacy
+ // snap_position's own per-axis priority. Runs BEFORE
+ // applyGroupDragDelta below so carried group members ride the
+ // corrected delta. Legacy's !isSelected() candidate exclusion is
+ // translated as !n.selected (every co-mover in a multi-select drag
+ // reports its own dragging change with selected=true); a dragged
+ // group's own members are additionally excluded - they move in
+ // lockstep with the group, so "aligning" against them is always
+ // trivially true and would freeze the drag (a gap legacy never had
+ // to answer: this canvas carries members via synthetic deltas, not
+ // Qt child-item parenting - per the recorded design decision, only
+ // the group's own rect snaps and members ride the delta).
+ if (scene.smartGuides) {
+ const moving = nodes.find((n) => n.id === change.id);
+ const movingSize = measuredNodeSize(reactFlow, change.id);
+ if (moving && movingSize) {
+ const memberIds = groupDragKindOf(moving)
+ ? new Set((moving.data as { itemIds?: string[] }).itemIds ?? [])
+ : new Set();
+ const candidates: Rect[] = [];
+ for (const n of nodes) {
+ if (n.id === change.id || n.selected || memberIds.has(n.id)) continue;
+ const size = measuredNodeSize(reactFlow, n.id);
+ if (!size) continue;
+ candidates.push({ x: n.position.x, y: n.position.y, width: size.width, height: size.height });
+ }
+ const snap = computeSmartGuideSnap(
+ { x: finalPosition.x, y: finalPosition.y, width: movingSize.width, height: movingSize.height },
+ candidates,
+ );
+ finalPosition = { x: snap.x, y: snap.y };
+ frameGuides.push(...snap.guides);
+ }
+ }
+ memberChanges.push(...applyGroupDragDelta(nodes, change.id, finalPosition));
return {
...change,
- position: scaledPosition,
+ position: finalPosition,
};
}
// Drag end: commit the node's final (already-scaled) position.
draggingRef.current = false;
+ sawDragEnd = true;
const settled = nodes.find((n) => n.id === change.id);
+ dragStartRef.current.delete(change.id);
if (settled) {
store.moveNode(change.id, settled.position.x, settled.position.y);
if (groupDragKindOf(settled)) {
@@ -967,17 +1055,30 @@ function CanvasInner({ store }: { store: SceneStore }) {
if (member) memberMoveIntents.push({ id: memberId, x: member.position.x, y: member.position.y });
}
}
+ // R7.5b-3 review fix: RF's drag-stop change carries its own
+ // internal RAW pointer-derived position - the drag-factor/smart-
+ // guide corrections applied per frame above never feed back into
+ // RF's drag state. Passing the raw change through here made the
+ // node visibly bounce off its corrected position on release, then
+ // reconcile after the backend echo. Return the settled (last
+ // corrected frame's) position instead, which is also exactly what
+ // moveNode just committed - local state and the wire now agree at
+ // the instant of release.
+ return { ...change, position: { ...settled.position } };
}
- dragStartRef.current.delete(change.id);
return change;
});
// Persist each carried-along member's settled position too - the same
// moveNode call site the group's own commit above uses, fired after
// the map so every real change's own drag-end has already run.
for (const move of memberMoveIntents) store.moveNode(move.id, move.x, move.y);
+ // Guides re-derive every drag frame (legacy cleared + re-added its
+ // QGraphicsLineItems per recompute); drag end always clears.
+ if (sawDragging) setSmartGuideLines(frameGuides);
+ else if (sawDragEnd) setSmartGuideLines([]);
setNodes((current) => applyNodeChanges([...scaled, ...memberChanges], current));
},
- [nodes, scene.dragFactor, store],
+ [nodes, scene.dragFactor, scene.smartGuides, reactFlow, store],
);
const onConnect = useCallback(
@@ -1014,7 +1115,7 @@ function CanvasInner({ store }: { store: SceneStore }) {
[store, nodes],
);
- const { screenToFlowPosition } = useReactFlow();
+ const { screenToFlowPosition } = reactFlow;
const onDoubleClick = useCallback(
(event: React.MouseEvent) => {
// Double-click on empty canvas creates a node there - the R1 stand-in
@@ -1064,6 +1165,44 @@ function CanvasInner({ store }: { store: SceneStore }) {
style={{ opacity: grid.gridOpacityPercent / 100 }}
/>
+ {/* R7.5b-3: smart-guide lines. Legacy's guides were QGraphicsLineItems
+ in the same unified scene as the nodes, panning/zooming for free -
+ ViewportPortal is the direct React Flow analog (children render
+ inside the same CSS-transformed layer, in flow coordinates). The
+ dash color is Qt's QColor(128, 128, 128, 200) ported exactly
+ (200/255 = 0.784 alpha). pointerEvents none so a guide can never
+ swallow the drag it is annotating. */}
+ {visibleGuideLines.length > 0 && (
+
+ {visibleGuideLines.map((guide, index) => (
+
+ ))}
+
+ )}
);
diff --git a/web_ui/src/app/canvas/sceneStore.test.ts b/web_ui/src/app/canvas/sceneStore.test.ts
index afa544a..6b4ccf1 100644
--- a/web_ui/src/app/canvas/sceneStore.test.ts
+++ b/web_ui/src/app/canvas/sceneStore.test.ts
@@ -98,6 +98,7 @@ function validScenePayload(overrides: Record = {}) {
snapToGrid: true,
fadeConnectionsEnabled: false,
orthogonalRouting: false,
+ smartGuides: false,
dragFactor: 0.5,
fontFamily: "Segoe UI",
fontSizePt: 9,
diff --git a/web_ui/src/app/canvas/sceneStore.ts b/web_ui/src/app/canvas/sceneStore.ts
index b879246..eb235d5 100644
--- a/web_ui/src/app/canvas/sceneStore.ts
+++ b/web_ui/src/app/canvas/sceneStore.ts
@@ -25,6 +25,7 @@ export const initialSceneState: SceneState = {
snapToGrid: false,
fadeConnectionsEnabled: false,
orthogonalRouting: false,
+ smartGuides: false,
dragFactor: 1,
fontFamily: "Segoe UI",
fontSizePt: 9,
@@ -541,6 +542,11 @@ export class SceneStore {
this.transport.intent("scene", "setOrthogonalConnections", [enabled]);
}
+ // R7.5b-3: the fourth and final legacy grid-control toggle.
+ setSmartGuides(enabled: boolean): void {
+ this.transport.intent("scene", "setSmartGuides", [enabled]);
+ }
+
setDragFactor(factor: number): void {
this.transport.intent("scene", "setDragFactor", [factor]);
}
diff --git a/web_ui/src/app/canvas/smartGuides.test.ts b/web_ui/src/app/canvas/smartGuides.test.ts
new file mode 100644
index 0000000..6b56098
--- /dev/null
+++ b/web_ui/src/app/canvas/smartGuides.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it } from "vitest";
+import { ALIGNMENT_TOLERANCE_PX, computeSmartGuideSnap, type Rect } from "./smartGuides";
+
+// Direct unit coverage of the legacy _calculate_smart_guide_snap port - every
+// documented semantic (same-key matching, key order, first-candidate-wins,
+// strict tolerance, per-axis independence, early exit) gets its own test so a
+// regression in any one rule fails loudly by name.
+
+function rect(x: number, y: number, width = 100, height = 50): Rect {
+ return { x, y, width, height };
+}
+
+describe("computeSmartGuideSnap", () => {
+ it("exports legacy's exact ALIGNMENT_TOLERANCE of 5px as the default", () => {
+ expect(ALIGNMENT_TOLERANCE_PX).toBe(5);
+ });
+
+ it("returns the position unchanged with no candidates", () => {
+ const result = computeSmartGuideSnap(rect(203, 400), []);
+ expect(result).toEqual({ x: 203, y: 400, guides: [] });
+ });
+
+ it("returns the position unchanged when nothing aligns", () => {
+ const result = computeSmartGuideSnap(rect(0, 0), [rect(500, 500)]);
+ expect(result).toEqual({ x: 0, y: 0, guides: [] });
+ });
+
+ it("snaps left-to-left and draws a vertical guide spanning the union of both rects", () => {
+ const result = computeSmartGuideSnap(rect(203, 400), [rect(200, 100, 80, 40)]);
+ expect(result.x).toBe(200);
+ expect(result.y).toBe(400);
+ expect(result.guides).toEqual([
+ // position = the candidate's key value; span = min(tops)..max(bottoms).
+ { orientation: "vertical", position: 200, start: 100, end: 450 },
+ ]);
+ });
+
+ it("snaps center-to-center, back-solving x from the moving rect's half-width", () => {
+ // moving center = 0 + 100/2 = 50; candidate center = 30 + 44/2 = 52.
+ const result = computeSmartGuideSnap(rect(0, 400), [rect(30, 100, 44, 40)]);
+ expect(result.x).toBe(2); // 52 - 100/2
+ });
+
+ it("snaps right-to-right, back-solving x from the moving rect's full width", () => {
+ // moving right = 100; candidate right = 52 + 50 = 102.
+ const result = computeSmartGuideSnap(rect(0, 400), [rect(52, 100, 50, 40)]);
+ expect(result.x).toBe(2); // 102 - 100
+ });
+
+ it("snaps top-to-top and draws a horizontal guide", () => {
+ const result = computeSmartGuideSnap(rect(400, 103), [rect(100, 100, 80, 40)]);
+ expect(result.y).toBe(100);
+ expect(result.guides).toEqual([
+ { orientation: "horizontal", position: 100, start: 100, end: 500 },
+ ]);
+ });
+
+ it("snaps middle-to-middle, back-solving y from the moving rect's half-height", () => {
+ // moving middle = 25; candidate middle = -100 + 254/2 = 27; candidate's
+ // top (-100) and bottom (154) are both far outside tolerance, isolating
+ // the middle key.
+ const result = computeSmartGuideSnap(rect(400, 0), [rect(100, -100, 80, 254)]);
+ expect(result.y).toBe(2); // 27 - 50/2
+ });
+
+ it("snaps bottom-to-bottom, back-solving y from the moving rect's full height", () => {
+ // moving bottom = 50; candidate bottom = -200 + 252 = 52.
+ const result = computeSmartGuideSnap(rect(400, 0), [rect(100, -200, 80, 252)]);
+ expect(result.y).toBe(2); // 52 - 50
+ });
+
+ it("uses a STRICT less-than tolerance (a 5px offset does not snap, 4.9px does)", () => {
+ expect(computeSmartGuideSnap(rect(205, 400), [rect(200, 100)]).x).toBe(205);
+ expect(computeSmartGuideSnap(rect(204.9, 400), [rect(200, 100)]).x).toBe(200);
+ });
+
+ it("tries keys in left/center/right order and takes the FIRST hit, not the closest", () => {
+ // moving left = 203 (3px from candidate left 200); moving center = 253
+ // (1px from candidate center 252 with candidate width 104). Center is
+ // closer, but left is tested first - legacy took the first hit.
+ const result = computeSmartGuideSnap(rect(203, 400), [rect(200, 100, 104, 40)]);
+ expect(result.x).toBe(200);
+ });
+
+ it("scans candidates in array order and takes the first aligning one", () => {
+ const result = computeSmartGuideSnap(rect(203, 400), [
+ rect(200, 100),
+ rect(201, 200),
+ ]);
+ expect(result.x).toBe(200);
+ expect(result.guides).toHaveLength(1);
+ expect(result.guides[0].position).toBe(200);
+ });
+
+ it("can snap X and Y against two DIFFERENT candidates in one call", () => {
+ const result = computeSmartGuideSnap(rect(203, 402), [
+ rect(200, 900), // aligns X only (left 200 vs 203)
+ rect(900, 400), // aligns Y only (top 400 vs 402)
+ ]);
+ expect(result.x).toBe(200);
+ expect(result.y).toBe(400);
+ expect(result.guides).toHaveLength(2);
+ expect(result.guides.map((g) => g.orientation).sort()).toEqual(["horizontal", "vertical"]);
+ });
+
+ it("stops scanning once both axes are snapped - at most one guide per axis", () => {
+ const result = computeSmartGuideSnap(rect(203, 402), [
+ rect(200, 400), // aligns both axes at once
+ rect(201, 401), // would also align both - must contribute nothing
+ ]);
+ expect(result.x).toBe(200);
+ expect(result.y).toBe(400);
+ expect(result.guides).toHaveLength(2);
+ expect(result.guides.every((g) => g.position === 200 || g.position === 400)).toBe(true);
+ });
+
+ it("an already-snapped axis is not re-tested against later candidates", () => {
+ // First candidate snaps X; second would snap X differently but only its
+ // Y alignment may land.
+ const result = computeSmartGuideSnap(rect(203, 402), [
+ rect(200, 900), // X -> 200
+ rect(204, 400), // X would -> 204, but X is taken; Y -> 400
+ ]);
+ expect(result.x).toBe(200);
+ expect(result.y).toBe(400);
+ });
+});
diff --git a/web_ui/src/app/canvas/smartGuides.ts b/web_ui/src/app/canvas/smartGuides.ts
new file mode 100644
index 0000000..f3aa5de
--- /dev/null
+++ b/web_ui/src/app/canvas/smartGuides.ts
@@ -0,0 +1,120 @@
+/**
+ * Smart-guide alignment snapping (Qt-removal plan R7.5b-3) - a pure, direct
+ * translation of the legacy ChatScene._calculate_smart_guide_snap
+ * (graphlink_scene.py), deliberately framework-free so the whole algorithm is
+ * unit-testable without mounting a canvas (the scaleDragPosition/toFlowNodes
+ * precedent). SceneCanvas.tsx calls this per drag frame and renders the
+ * returned guide lines through React Flow's .
+ *
+ * Ported semantics, all confirmed against the legacy source by recon:
+ * - 3 alignment keys per axis, matched SAME-KEY-ONLY (moving-left vs
+ * candidate-left, center vs center, ... - never left-to-center).
+ * - Candidates scanned in array order; per candidate, X keys are tried in
+ * left/center/right order and Y keys in top/middle/bottom order, first hit
+ * within tolerance wins that axis. X and Y may therefore snap against two
+ * DIFFERENT candidates in one call.
+ * - The scan stops the moment both axes are snapped - it does not keep
+ * looking for a "closer" alignment. At most one guide line per axis.
+ * - Tolerance is a strict less-than (legacy: abs(m - s) < ALIGNMENT_TOLERANCE).
+ * - A guide line sits at the CANDIDATE's key value and spans the union of
+ * the two rects along the perpendicular axis.
+ */
+
+// Legacy's ALIGNMENT_TOLERANCE = 5 (scene px), verbatim. One constant gates
+// both "show the guide" and "snap the position" - legacy had no separate
+// show-but-don't-snap zone, so neither does this.
+export const ALIGNMENT_TOLERANCE_PX = 5;
+
+export interface Rect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface GuideLine {
+ orientation: "vertical" | "horizontal";
+ /** The aligned coordinate: x for vertical guides, y for horizontal. */
+ position: number;
+ /** Span along the perpendicular axis (y-range for vertical, x-range for
+ * horizontal) - the union of the moving and candidate rects, matching
+ * legacy's min(top,top)..max(bottom,bottom) segments (never full-canvas
+ * lines). */
+ start: number;
+ end: number;
+}
+
+export interface SmartGuideSnapResult {
+ x: number;
+ y: number;
+ guides: GuideLine[];
+}
+
+// Key order matters: legacy tested left/center/right (and top/middle/bottom)
+// in this exact order and took the FIRST within-tolerance hit, not the
+// closest one.
+const VERTICAL_KEYS = ["left", "center", "right"] as const;
+const HORIZONTAL_KEYS = ["top", "middle", "bottom"] as const;
+
+function verticalValues(r: Rect): Record<(typeof VERTICAL_KEYS)[number], number> {
+ return { left: r.x, center: r.x + r.width / 2, right: r.x + r.width };
+}
+
+function horizontalValues(r: Rect): Record<(typeof HORIZONTAL_KEYS)[number], number> {
+ return { top: r.y, middle: r.y + r.height / 2, bottom: r.y + r.height };
+}
+
+export function computeSmartGuideSnap(
+ movingRect: Rect,
+ candidates: Rect[],
+ tolerance: number = ALIGNMENT_TOLERANCE_PX,
+): SmartGuideSnapResult {
+ let snappedX: number | null = null;
+ let snappedY: number | null = null;
+ const guides: GuideLine[] = [];
+
+ const movingV = verticalValues(movingRect);
+ const movingH = horizontalValues(movingRect);
+
+ for (const candidate of candidates) {
+ if (snappedX === null) {
+ const candV = verticalValues(candidate);
+ for (const key of VERTICAL_KEYS) {
+ if (Math.abs(movingV[key] - candV[key]) < tolerance) {
+ const offset = key === "left" ? 0 : key === "center" ? movingRect.width / 2 : movingRect.width;
+ snappedX = candV[key] - offset;
+ guides.push({
+ orientation: "vertical",
+ position: candV[key],
+ start: Math.min(movingRect.y, candidate.y),
+ end: Math.max(movingRect.y + movingRect.height, candidate.y + candidate.height),
+ });
+ break;
+ }
+ }
+ }
+ if (snappedY === null) {
+ const candH = horizontalValues(candidate);
+ for (const key of HORIZONTAL_KEYS) {
+ if (Math.abs(movingH[key] - candH[key]) < tolerance) {
+ const offset = key === "top" ? 0 : key === "middle" ? movingRect.height / 2 : movingRect.height;
+ snappedY = candH[key] - offset;
+ guides.push({
+ orientation: "horizontal",
+ position: candH[key],
+ start: Math.min(movingRect.x, candidate.x),
+ end: Math.max(movingRect.x + movingRect.width, candidate.x + candidate.width),
+ });
+ break;
+ }
+ }
+ }
+ if (snappedX !== null && snappedY !== null) break;
+ }
+
+ return {
+ x: snappedX ?? movingRect.x,
+ y: snappedY ?? movingRect.y,
+ guides,
+ };
+}
diff --git a/web_ui/src/app/chrome/ViewPopover.test.tsx b/web_ui/src/app/chrome/ViewPopover.test.tsx
index 94b6a0c..4b0985e 100644
--- a/web_ui/src/app/chrome/ViewPopover.test.tsx
+++ b/web_ui/src/app/chrome/ViewPopover.test.tsx
@@ -25,6 +25,7 @@ function makeStore(sceneOverrides: Partial = {}) {
const setFadeConnections = vi.fn();
const setSnapToGrid = vi.fn();
const setOrthogonalConnections = vi.fn();
+ const setSmartGuides = vi.fn();
const store = {
subscribe: (l: () => void) => {
listeners.add(l);
@@ -42,11 +43,12 @@ function makeStore(sceneOverrides: Partial = {}) {
setSnapToGrid,
setFadeConnections,
setOrthogonalConnections,
+ setSmartGuides,
setFontFamily: vi.fn(),
setFontSize: vi.fn(),
setFontColor: vi.fn(),
};
- return { store, setFadeConnections, setSnapToGrid, setOrthogonalConnections };
+ return { store, setFadeConnections, setSnapToGrid, setOrthogonalConnections, setSmartGuides };
}
function renderOpen(store: unknown) {
@@ -115,3 +117,29 @@ describe("ViewPopover (R7.5b-2 Orthogonal Routing checkbox)", () => {
expect(setFadeConnections).not.toHaveBeenCalled();
});
});
+
+// R7.5b-3: same posture again - only the new control is covered.
+describe("ViewPopover (R7.5b-3 Smart Guides checkbox)", () => {
+ it("reflects smartGuides=false as unchecked and smartGuides=true as checked", () => {
+ const off = makeStore({ smartGuides: false });
+ const { unmount } = renderOpen(off.store);
+ expect(screen.getByRole("checkbox", { name: "Smart Guides" })).not.toBeChecked();
+ unmount();
+
+ const on = makeStore({ smartGuides: true });
+ renderOpen(on.store);
+ expect(screen.getByRole("checkbox", { name: "Smart Guides" })).toBeChecked();
+ });
+
+ it("calls store.setSmartGuides with the new value on toggle, independent of the other toggles", async () => {
+ const user = userEvent.setup();
+ const { store, setSmartGuides, setOrthogonalConnections, setFadeConnections } = makeStore({ smartGuides: false });
+ renderOpen(store);
+
+ await user.click(screen.getByRole("checkbox", { name: "Smart Guides" }));
+
+ expect(setSmartGuides).toHaveBeenCalledWith(true);
+ expect(setOrthogonalConnections).not.toHaveBeenCalled();
+ expect(setFadeConnections).not.toHaveBeenCalled();
+ });
+});
diff --git a/web_ui/src/app/chrome/ViewPopover.tsx b/web_ui/src/app/chrome/ViewPopover.tsx
index dbe16fb..e748b93 100644
--- a/web_ui/src/app/chrome/ViewPopover.tsx
+++ b/web_ui/src/app/chrome/ViewPopover.tsx
@@ -117,6 +117,15 @@ export function ViewPopover({ store }: { store: SceneStore }) {
/>
Orthogonal Routing
+ {/* R7.5b-3: the fourth and final legacy grid-control toggle. */}
+
diff --git a/web_ui/src/app/chrome/help-data/sections.ts b/web_ui/src/app/chrome/help-data/sections.ts
index 3d23dfa..081e18f 100644
--- a/web_ui/src/app/chrome/help-data/sections.ts
+++ b/web_ui/src/app/chrome/help-data/sections.ts
@@ -279,7 +279,7 @@ export const HELP_SECTIONS: HelpSection[] = [
},
{
"action": "Grid and Guide Controls",
- "description": "The controls overlay can enable snap-to-grid, font controls, faded connections, and orthogonal connection routing. Smart guides are not available yet in this build. These tools are useful when a canvas needs visual cleanup rather than new AI output."
+ "description": "The controls overlay can enable snap-to-grid, smart guides, orthogonal routing, font controls, and faded connections. These tools are useful when a canvas needs visual cleanup rather than new AI output."
},
{
"action": "Reduce Visual Noise",
diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json
index 9884cac..6cc02d0 100644
--- a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json
+++ b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json
@@ -503,6 +503,9 @@
"schemaVersion": {
"type": "integer"
},
+ "smartGuides": {
+ "type": "boolean"
+ },
"snapToGrid": {
"type": "boolean"
}
@@ -516,6 +519,7 @@
"snapToGrid",
"fadeConnectionsEnabled",
"orthogonalRouting",
+ "smartGuides",
"dragFactor",
"fontFamily",
"fontSizePt",
diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.ts b/web_ui/src/lib/bridge-core/generated/scene-state.ts
index 479e761..02d26f6 100644
--- a/web_ui/src/lib/bridge-core/generated/scene-state.ts
+++ b/web_ui/src/lib/bridge-core/generated/scene-state.ts
@@ -133,6 +133,7 @@ export interface SceneState {
snapToGrid: boolean;
fadeConnectionsEnabled: boolean;
orthogonalRouting: boolean;
+ smartGuides: boolean;
dragFactor: number;
fontFamily: string;
fontSizePt: number;
@@ -724,6 +725,11 @@ function checkSceneState(value: unknown, path: string, errors: string[]): void {
if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.orthogonalRouting: missing required field`);
else { if (typeof fieldValue !== "boolean") errors.push(`${path}.orthogonalRouting` + ": expected boolean"); }
}
+ {
+ const fieldValue = value["smartGuides"];
+ if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.smartGuides: missing required field`);
+ else { if (typeof fieldValue !== "boolean") errors.push(`${path}.smartGuides` + ": expected boolean"); }
+ }
{
const fieldValue = value["dragFactor"];
if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.dragFactor: missing required field`);