From a1292dc30047a32b81b618e643c2294356454af9 Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Sun, 26 Jul 2026 13:52:27 -0400 Subject: [PATCH] Qt-removal R7.5b-1: Faded Connections canvas visual Ports the legacy scene's faded-connections toggle into the React Flow canvas - the first (smallest) of R7.5's 3 remaining canvas-visual parity gaps. When enabled, every connection dims to the legacy's exact 0.08 opacity except the one currently under the mouse. Same bare-bool/"scene"-topic shape as the existing snap-to-grid setting; the intent name matches the legacy GridControlBridge's own Slot name 1:1. Legacy's dead is_selected stay-bright branch (confirmed never set True anywhere in the Qt codebase) is deliberately not ported - hover is the only exemption. --- backend/canvas.py | 11 +++ backend/tests/test_canvas.py | 17 ++++ graphlink_app/graphlink_scene_payload.py | 3 + web_ui/src/app/canvas/SceneCanvas.test.tsx | 52 +++++++++++ web_ui/src/app/canvas/SceneCanvas.tsx | 31 ++++++- web_ui/src/app/canvas/sceneStore.test.ts | 1 + web_ui/src/app/canvas/sceneStore.ts | 6 ++ web_ui/src/app/chrome/ViewPopover.test.tsx | 88 +++++++++++++++++++ web_ui/src/app/chrome/ViewPopover.tsx | 9 ++ web_ui/src/app/chrome/help-data/sections.ts | 2 +- .../generated/scene-state.schema.json | 4 + .../lib/bridge-core/generated/scene-state.ts | 6 ++ 12 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 web_ui/src/app/chrome/ViewPopover.test.tsx diff --git a/backend/canvas.py b/backend/canvas.py index 743dd78f..9fe019be 100644 --- a/backend/canvas.py +++ b/backend/canvas.py @@ -693,6 +693,11 @@ class SceneDocument: pins: NavigationPinStore = field(default_factory=NavigationPinStore) grid: GridViewSettings = field(default_factory=GridViewSettings) snap_to_grid: bool = False + # R7.5b-1: Qt-removal plan R7.5's first canvas-visual parity fix - the + # legacy scene's fade_connections_enabled bool (graphlink_scene.py), + # ported 1:1 in shape to snap_to_grid above (bare bool, "scene" topic, + # dedicated setFadeConnections intent - see register_canvas below). + fade_connections_enabled: 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. @@ -2789,6 +2794,7 @@ def scene_payload(self) -> dict[str, Any]: for p in self.pins.records ], "snapToGrid": self.snap_to_grid, + "fadeConnectionsEnabled": self.fade_connections_enabled, "dragFactor": self.drag_factor, "fontFamily": self.font_family, "fontSizePt": self.font_size_pt, @@ -3982,6 +3988,10 @@ async def set_snap_to_grid(enabled): document.snap_to_grid = bool(enabled) await publish_scene() + async def set_fade_connections(enabled): + document.fade_connections_enabled = bool(enabled) + await publish_scene() + async def set_drag_factor(factor): document.set_drag_factor(factor) await publish_scene() @@ -4042,6 +4052,7 @@ async def set_view_state(zoom_factor, scroll_x, scroll_y): bus.register_intent("scene", "removePin", remove_pin) bus.register_intent("scene", "updatePin", update_pin) bus.register_intent("scene", "setSnapToGrid", set_snap_to_grid) + bus.register_intent("scene", "setFadeConnections", set_fade_connections) 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 cec0ae3c..253faa36 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -2151,6 +2151,23 @@ async def run(): asyncio.run(run()) +def test_fade_connections_enabled_defaults_false_and_the_setter_publishes_scene(): + # R7.5b-1: Qt-removal plan R7.5's first canvas-visual parity fix - same + # bare-bool/"scene"-topic shape as setSnapToGrid above. + async def run(): + bus, document, recorder = make_bus() + assert document.scene_payload()["fadeConnectionsEnabled"] is False + + await bus.dispatch_intent("scene", "setFadeConnections", [True]) + assert document.scene_payload()["fadeConnectionsEnabled"] is True + assert recorder.topics_seen()[-1] == "scene" + + await bus.dispatch_intent("scene", "setFadeConnections", [False]) + assert document.scene_payload()["fadeConnectionsEnabled"] 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 493cec51..bebe6a78 100644 --- a/graphlink_app/graphlink_scene_payload.py +++ b/graphlink_app/graphlink_scene_payload.py @@ -321,6 +321,9 @@ class SceneStatePayload: edges: list[SceneEdgeRow] pins: list[ScenePinRow] snapToGrid: bool + # R7.5b-1: Qt-removal plan R7.5's first canvas-visual parity fix - + # populated 1:1 from backend/canvas.py's SceneDocument.fade_connections_enabled. + fadeConnectionsEnabled: bool dragFactor: float fontFamily: str fontSizePt: int diff --git a/web_ui/src/app/canvas/SceneCanvas.test.tsx b/web_ui/src/app/canvas/SceneCanvas.test.tsx index 4b1a782f..b258211f 100644 --- a/web_ui/src/app/canvas/SceneCanvas.test.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.test.tsx @@ -4,6 +4,7 @@ import { groupDragKindOf, handleSelectionChange, makeDebouncedViewportReport, + toFlowEdges, toFlowNodes, type SceneFlowNode, } from "./SceneCanvas"; @@ -1483,3 +1484,54 @@ describe("applyGroupDragDelta (R6.1 group-drag)", () => { expect(applyGroupDragDelta(nodes, "frame-1", { x: 10, y: 10 })).toEqual([]); }); }); + +describe("toFlowEdges (R7.5b-1 faded connections)", () => { + const scene = baseScene({ + nodes: [baseNode({ id: "a" }), baseNode({ id: "b" }), baseNode({ id: "c" })], + edges: [ + { id: "e1", source: "a", target: "b" }, + { id: "e2", source: "a", target: "c" }, + ], + }); + + it("applies no opacity style to any edge when fadeConnectionsEnabled is false, regardless of hover", () => { + const off = baseScene({ ...scene, fadeConnectionsEnabled: false }); + expect(toFlowEdges(off, null)).toEqual([ + { id: "e1", source: "a", target: "b" }, + { id: "e2", source: "a", target: "c" }, + ]); + expect(toFlowEdges(off, "e1")).toEqual([ + { id: "e1", source: "a", target: "b" }, + { id: "e2", source: "a", target: "c" }, + ]); + }); + + it("fades every edge except the hovered one when fadeConnectionsEnabled is true", () => { + const on = baseScene({ ...scene, fadeConnectionsEnabled: true }); + const edges = toFlowEdges(on, "e1"); + expect(edges.find((e) => e.id === "e1")).toEqual({ id: "e1", source: "a", target: "b" }); + expect(edges.find((e) => e.id === "e2")).toEqual({ + id: "e2", + source: "a", + target: "c", + style: { opacity: 0.08 }, + }); + }); + + it("fades every edge when nothing is hovered", () => { + const on = baseScene({ ...scene, fadeConnectionsEnabled: true }); + const edges = toFlowEdges(on, null); + for (const edge of edges) { + expect(edge.style).toEqual({ opacity: 0.08 }); + } + }); + + it("still suppresses an edge pointing at a docked node, independent of fade state", () => { + const docked = baseScene({ + nodes: [baseNode({ id: "a" }), baseNode({ id: "b", isDocked: true })], + edges: [{ id: "e1", source: "a", target: "b" }], + fadeConnectionsEnabled: true, + }); + expect(toFlowEdges(docked, null)).toEqual([]); + }); +}); diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index 3265e6f4..c5984a75 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -794,13 +794,30 @@ export function applyGroupDragDelta( return memberChanges; } -function toFlowEdges(scene: SceneState): Edge[] { +// R7.5b-1: legacy's exact faded-connections opacity (graphlink_connections.py's +// _sync_connection_visibility_mode) - every connection sits at this opacity +// except the one under the mouse, once the feature is toggled on. Legacy also +// had a `is_selected` stay-bright branch, but the recon confirmed it's dead +// code (never set True anywhere in the Qt codebase) - deliberately not ported. +const FADED_CONNECTION_OPACITY = 0.08; + +// 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. +export function toFlowEdges(scene: SceneState, hoveredEdgeId: string | null): Edge[] { // An edge pointing at a docked node must not render either - mirrors the // legacy connection-item self-suppression when its end node is docked. const dockedNodeIds = new Set(scene.nodes.filter((n) => n.isDocked).map((n) => n.id)); return scene.edges .filter((e) => !dockedNodeIds.has(e.target)) - .map((e) => ({ id: e.id, source: e.source, target: e.target })); + .map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + ...(scene.fadeConnectionsEnabled && e.id !== hoveredEdgeId + ? { style: { opacity: FADED_CONNECTION_OPACITY } } + : {}), + })); } // R6.3: the debounce wrapper for viewport (pan/zoom) reporting - same posture @@ -835,6 +852,12 @@ function CanvasInner({ store }: { store: SceneStore }) { const dragStartRef = useRef>(new Map()); const draggingRef = useRef(false); + // R7.5b-1: which edge (if any) is under the mouse right now - the one + // exemption from faded-connections' blanket low-opacity effect. Local-only + // (never scene state), same posture as legacy's purely presentational hover + // flag. + const [hoveredEdgeId, setHoveredEdgeId] = useState(null); + // 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 @@ -860,7 +883,7 @@ function CanvasInner({ store }: { store: SceneStore }) { if (!draggingRef.current) setNodes(toFlowNodes(scene, store)); }, [scene, store]); - const edges = useMemo(() => toFlowEdges(scene), [scene]); + const edges = useMemo(() => toFlowEdges(scene, hoveredEdgeId), [scene, hoveredEdgeId]); const onNodesChange = useCallback( (changes: NodeChange[]) => { @@ -976,6 +999,8 @@ function CanvasInner({ store }: { store: SceneStore }) { // PluginPicker can attach "which node was selected" to executePlugin // without either component reaching into the other's internals. onSelectionChange={({ nodes: sel }) => handleSelectionChange(store, sel)} + onEdgeMouseEnter={(_event, edge) => setHoveredEdgeId(edge.id)} + onEdgeMouseLeave={() => setHoveredEdgeId(null)} snapToGrid={scene.snapToGrid} snapGrid={[grid.gridSize, grid.gridSize]} // Double-click is the R1 create-node gesture (wrapper onDoubleClick); diff --git a/web_ui/src/app/canvas/sceneStore.test.ts b/web_ui/src/app/canvas/sceneStore.test.ts index 43df4825..c109e397 100644 --- a/web_ui/src/app/canvas/sceneStore.test.ts +++ b/web_ui/src/app/canvas/sceneStore.test.ts @@ -96,6 +96,7 @@ function validScenePayload(overrides: Record = {}) { edges: [], pins: [], snapToGrid: true, + fadeConnectionsEnabled: 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 5695f311..a6a8eec1 100644 --- a/web_ui/src/app/canvas/sceneStore.ts +++ b/web_ui/src/app/canvas/sceneStore.ts @@ -23,6 +23,7 @@ export const initialSceneState: SceneState = { edges: [], pins: [], snapToGrid: false, + fadeConnectionsEnabled: false, dragFactor: 1, fontFamily: "Segoe UI", fontSizePt: 9, @@ -528,6 +529,11 @@ export class SceneStore { this.transport.intent("scene", "setSnapToGrid", [enabled]); } + // R7.5b-1: same bare-bool/"scene"-topic shape as setSnapToGrid above. + setFadeConnections(enabled: boolean): void { + this.transport.intent("scene", "setFadeConnections", [enabled]); + } + setDragFactor(factor: number): void { this.transport.intent("scene", "setDragFactor", [factor]); } diff --git a/web_ui/src/app/chrome/ViewPopover.test.tsx b/web_ui/src/app/chrome/ViewPopover.test.tsx new file mode 100644 index 00000000..d0b040fb --- /dev/null +++ b/web_ui/src/app/chrome/ViewPopover.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ViewPopover } from "./ViewPopover"; +import { + initialDragSpeedState, + initialFontControlState, + initialGridState, + initialSceneState, +} from "../canvas/sceneStore"; +import { OverlayProvider, useOverlays } from "../overlays/overlays"; + +// ViewPopover renders via , which only mounts content +// while open - force it open through the real overlay context (same pattern +// PinOverlay.test.tsx already established), not a bypass. +function OpenViewOnMount({ children }: { children: React.ReactNode }) { + const overlays = useOverlays(); + if (!overlays.isOpen("view")) overlays.open("view", "popover"); + return <>{children}; +} + +function makeStore(sceneOverrides: Partial = {}) { + const listeners = new Set<() => void>(); + const scene = { ...initialSceneState, ...sceneOverrides }; + const setFadeConnections = vi.fn(); + const setSnapToGrid = vi.fn(); + const store = { + subscribe: (l: () => void) => { + listeners.add(l); + return () => listeners.delete(l); + }, + getScene: () => scene, + getGrid: () => initialGridState, + getDragConfig: () => initialDragSpeedState, + getFontConfig: () => initialFontControlState, + setDragFactor: vi.fn(), + setGridSize: vi.fn(), + setGridOpacityPercent: vi.fn(), + setGridStyle: vi.fn(), + setGridColor: vi.fn(), + setSnapToGrid, + setFadeConnections, + setFontFamily: vi.fn(), + setFontSize: vi.fn(), + setFontColor: vi.fn(), + }; + return { store, setFadeConnections, setSnapToGrid }; +} + +function renderOpen(store: unknown) { + return render( + + + {/* @ts-expect-error - test double */} + + + , + ); +} + +// R7.5b-1: only the new Fade Connections checkbox is covered here - +// ViewPopover.tsx otherwise has no prior test coverage (a pre-existing gap, +// not introduced by this increment) and backfilling the rest is out of scope +// for a canvas-visuals increment. +describe("ViewPopover (R7.5b-1 Fade Connections checkbox)", () => { + it("reflects fadeConnectionsEnabled=false as unchecked", () => { + const { store } = makeStore({ fadeConnectionsEnabled: false }); + renderOpen(store); + expect(screen.getByRole("checkbox", { name: "Fade Connections" })).not.toBeChecked(); + }); + + it("reflects fadeConnectionsEnabled=true as checked", () => { + const { store } = makeStore({ fadeConnectionsEnabled: true }); + renderOpen(store); + expect(screen.getByRole("checkbox", { name: "Fade Connections" })).toBeChecked(); + }); + + it("calls store.setFadeConnections with the new value on toggle, independent of Snap to Grid", async () => { + const user = userEvent.setup(); + const { store, setFadeConnections, setSnapToGrid } = makeStore({ fadeConnectionsEnabled: false }); + renderOpen(store); + + await user.click(screen.getByRole("checkbox", { name: "Fade Connections" })); + + expect(setFadeConnections).toHaveBeenCalledWith(true); + expect(setSnapToGrid).not.toHaveBeenCalled(); + }); +}); diff --git a/web_ui/src/app/chrome/ViewPopover.tsx b/web_ui/src/app/chrome/ViewPopover.tsx index 1bfa844a..263cd6c6 100644 --- a/web_ui/src/app/chrome/ViewPopover.tsx +++ b/web_ui/src/app/chrome/ViewPopover.tsx @@ -99,6 +99,15 @@ export function ViewPopover({ store }: { store: SceneStore }) { /> Snap to Grid + {/* R7.5b-1: same view-check-row pattern as Snap to Grid above. */} +
diff --git a/web_ui/src/app/chrome/help-data/sections.ts b/web_ui/src/app/chrome/help-data/sections.ts index e6b2d30a..e053fe83 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 and font controls. Smart guides, orthogonal routing, and faded connections 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, font controls, and faded connections. Smart guides and orthogonal routing are not available yet in this build. 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 46e9742a..0093d0f0 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 @@ -28,6 +28,9 @@ }, "type": "array" }, + "fadeConnectionsEnabled": { + "type": "boolean" + }, "fontColor": { "type": "string" }, @@ -508,6 +511,7 @@ "edges", "pins", "snapToGrid", + "fadeConnectionsEnabled", "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 94f3d74d..ec51c03b 100644 --- a/web_ui/src/lib/bridge-core/generated/scene-state.ts +++ b/web_ui/src/lib/bridge-core/generated/scene-state.ts @@ -131,6 +131,7 @@ export interface SceneState { edges: SceneEdgeRow[]; pins: ScenePinRow[]; snapToGrid: boolean; + fadeConnectionsEnabled: boolean; dragFactor: number; fontFamily: string; fontSizePt: number; @@ -712,6 +713,11 @@ function checkSceneState(value: unknown, path: string, errors: string[]): void { if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.snapToGrid: missing required field`); else { if (typeof fieldValue !== "boolean") errors.push(`${path}.snapToGrid` + ": expected boolean"); } } + { + const fieldValue = value["fadeConnectionsEnabled"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.fadeConnectionsEnabled: missing required field`); + else { if (typeof fieldValue !== "boolean") errors.push(`${path}.fadeConnectionsEnabled` + ": expected boolean"); } + } { const fieldValue = value["dragFactor"]; if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.dragFactor: missing required field`);