diff --git a/backend/api/intents_grid.py b/backend/api/intents_grid.py
index 81d847c..e7ed24a 100644
--- a/backend/api/intents_grid.py
+++ b/backend/api/intents_grid.py
@@ -23,7 +23,10 @@ def register_grid_intents(bus: SessionBus, document: SceneDocument) -> None:
publish_grid = make_publish_grid(bus)
async def set_grid_size(size):
- document.grid.grid_size = int(size)
+ # Clamped: 0/negative would blank or invert the background pattern,
+ # and the View popover's spacing slider (4-120) relies on the same
+ # floor being enforced where the value actually lands.
+ document.grid.grid_size = max(4, min(400, int(size)))
await publish_grid()
async def set_grid_opacity_percent(percent):
diff --git a/backend/domain/model.py b/backend/domain/model.py
index 874eb22..cc1e7a1 100644
--- a/backend/domain/model.py
+++ b/backend/domain/model.py
@@ -14,11 +14,17 @@
from backend.domain.node_states import NodeState
-# Dark-theme grid swatches. The Qt bridge derived 3 of 5 from the live
-# QPalette; the backend is Qt-free by law (test_no_qt_anywhere.py), so until
-# the R2 theme service exists these are the dark theme's actual values,
-# frozen here as data, not styling.
-GRID_COLOR_PRESETS = ["#404040", "#555555", "#4a90d9", "#2f5b3c", "#5b2f4f"]
+# Grid swatches. The first three neutrals cover the subtle-texture case at
+# increasing prominence; the five hues are tuned to read on the dark canvas
+# without shouting, and to survive the light theme. The old set was frozen
+# verbatim from the deleted Qt bridge's live-palette derivation (2 neutrals
+# + 3 dark muddy hues that were nearly invisible against the canvas) - kept
+# values #404040/#555555/#4a90d9 remain so an existing session's saved
+# color still matches a swatch.
+GRID_COLOR_PRESETS = [
+ "#404040", "#555555", "#6E6E6E",
+ "#4a90d9", "#3FA37E", "#C9A227", "#C96A6A", "#8A63C9",
+]
DRAG_FACTOR_MIN = 0.05
DRAG_FACTOR_MAX = 1.0
@@ -35,7 +41,14 @@
"Courier New", "Times New Roman", "Georgia", "System UI",
"DejaVu Sans", "Segoe UI Variable", "Arial Rounded MT Bold",
]
-FONT_COLOR_PRESETS = ["#F0F0F0", "#C7C7C7", "#949494", "#818181"]
+# Node-text swatches: three neutral steps plus four soft tints that stay
+# readable on the node-card surfaces in both themes. The old set was four
+# barely-distinguishable grays (two of them 19 units apart) carried over
+# verbatim from the deleted Qt bridge.
+FONT_COLOR_PRESETS = [
+ "#F0F0F0", "#C7C7C7", "#949494",
+ "#9EC1E8", "#9FD0B5", "#E3C577", "#E0A3A3",
+]
FONT_SIZE_MIN = 8
FONT_SIZE_MAX = 16
diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py
index 1b6dcbb..2c4b86e 100644
--- a/backend/tests/test_canvas.py
+++ b/backend/tests/test_canvas.py
@@ -2080,7 +2080,12 @@ def test_grid_payload_matches_generated_validator_shape():
"colorPresets",
}
assert isinstance(payload["gridOpacityPercent"], int)
- assert len(payload["colorPresets"]) == 5
+ # Pinned to "a non-empty list of hex strings" rather than an exact
+ # count: the palette is a curated data set that legitimately grows
+ # (it did, when the Qt-frozen 5-swatch set was upgraded), while the
+ # SHAPE - what the generated validator checks - is the contract.
+ assert payload["colorPresets"]
+ assert all(c.startswith("#") and len(c) == 7 for c in payload["colorPresets"])
# -- intent surface over the bus --------------------------------------------
diff --git a/web_ui/src/app/canvas/ChartNodeView.tsx b/web_ui/src/app/canvas/ChartNodeView.tsx
index 202a657..cb8381e 100644
--- a/web_ui/src/app/canvas/ChartNodeView.tsx
+++ b/web_ui/src/app/canvas/ChartNodeView.tsx
@@ -106,7 +106,7 @@ function chartTypeBadgeLabel(chartType: string): string {
/** The debounce wrapper described in this file's own module doc, exported
* standalone for direct unit testing without mounting at all -
- * same posture as SceneCanvas.tsx's scaleDragPosition/applyGroupDragDelta.
+ * same posture as SceneCanvas.tsx's applyGroupDragDelta.
* `timerRef` is the caller's own mutable box (a component instance's
* useRef), so this stays a plain function rather than owning any React state
* itself; calling the returned function again before `debounceMs` elapses
diff --git a/web_ui/src/app/canvas/SceneCanvas.test.tsx b/web_ui/src/app/canvas/SceneCanvas.test.tsx
index ac49138..4d82168 100644
--- a/web_ui/src/app/canvas/SceneCanvas.test.tsx
+++ b/web_ui/src/app/canvas/SceneCanvas.test.tsx
@@ -31,7 +31,7 @@ import type { SceneNodeRow, SceneState } from "../../lib/bridge-core/generated/s
// toFlowNodes is exported standalone specifically so this doesn't need a
// full mount (same reasoning as sceneStore.test.ts's direct
-// scaleDragPosition coverage) - see SceneCanvas.tsx's own comment on the
+// drag coverage) - see SceneCanvas.tsx's own comment on the
// export.
function makeStore(): SceneStore {
diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx
index 0235e75..f7a3697 100644
--- a/web_ui/src/app/canvas/SceneCanvas.tsx
+++ b/web_ui/src/app/canvas/SceneCanvas.tsx
@@ -45,7 +45,7 @@ import {
VIEWPORT_REPORT_DEBOUNCE_MS,
} from "./canvasConstants";
import { handleKeyboardContextMenu } from "./keyboardContextMenu";
-import { SceneStore, scaleDragPosition } from "./sceneStore";
+import { SceneStore } from "./sceneStore";
import { computeSmartGuideSnap, type GuideLine, type Rect } from "./smartGuides";
import { ConnectionCanvas, type ConnectionSpec } from "./connections/ConnectionCanvas";
import type { ConnectionPath } from "./connections/connectionGeometry";
@@ -958,7 +958,7 @@ function makeChartFns(id: string, liveRef: { current: DispatcherLive }) {
}
// Exported standalone for direct unit testing (same posture as
-// scaleDragPosition in sceneStore.ts) - covers the parentChatNodeId
+// toFlowEdges below) - covers the parentChatNodeId
// derivation below without needing a full mount.
export function toFlowNodes(
scene: SceneState,
@@ -1821,7 +1821,7 @@ export function toFlowNodes(
}
// R5.1: the onSelectionChange callback's actual logic, pulled out standalone
-// for direct unit testing (same posture as toFlowNodes/scaleDragPosition
+// for direct unit testing (same posture as toFlowNodes
// above) - a full mount's own drag-select interaction isn't
// something this codebase drives in tests anywhere else, so this is what
// gets covered instead of the mount.
@@ -1834,7 +1834,7 @@ export function handleSelectionChange(store: SceneStore, nodes: { id: string }[]
// LOCKED frame; false for everything else (including an unlocked frame,
// which is non-draggable anyway - see toFlowNodes' draggable: setting
// above). Exported standalone, same testability convention as
-// scaleDragPosition/toFlowNodes/handleSelectionChange above.
+// toFlowNodes/handleSelectionChange above.
export function groupDragKindOf(node: SceneFlowNode | undefined): "frame" | "container" | null {
if (!node) return null;
if (node.type === "container") return "container";
@@ -2186,7 +2186,7 @@ export function toFlowEdges(scene: SceneState, hoveredEdgeId: string | null): Ed
// survives across repeated calls without this function owning any React
// state itself), exported standalone for direct unit testing without
// mounting a real pan/zoom gesture - the same testability
-// posture as scaleDragPosition/toFlowNodes/handleSelectionChange above.
+// posture as toFlowNodes/handleSelectionChange above.
export function makeDebouncedViewportReport(
timerRef: { current: ReturnType | null },
onReport: (zoomFactor: number, scrollX: number, scrollY: number) => void,
@@ -2230,6 +2230,12 @@ function CanvasInner({
getComposerRoute: () => { provider: string; modelId: string };
}) {
const scene = useSyncExternalStore(store.subscribe, store.getScene);
+ // Live mirror for event handlers that must read current scene values
+ // (the pan handler's drag factor) without re-registering per publish.
+ const sceneRef = useRef(scene);
+ useEffect(() => {
+ sceneRef.current = scene;
+ }, [scene]);
const grid = useSyncExternalStore(store.subscribe, store.getGrid);
// ADR-002 Workstream 1 ("Branch status and lifecycle") - "Focus Accepted
// Paths", toggled from ViewPopover.tsx's own checkbox (a sibling
@@ -2269,7 +2275,7 @@ function CanvasInner({
// Local node state exists so dragging is fluid; backend snapshots are the
// truth and reconcile in whenever nothing is being dragged. dragStartRef
- // powers the drag-speed scaling contract (see scaleDragPosition).
+ // records where each gesture began (see the middleware's bookkeeping).
// React Flow owns the rendered node collection (see the
// element's own defaultNodes comment). This component keeps only a mirror
// ref for its own logic - drag corrections, delete routing, scene merge -
@@ -2544,7 +2550,7 @@ function CanvasInner({
/**
* DRAG-SYNC REBUILD: the drag-position corrections this canvas applies -
- * the drag-speed factor (scaleDragPosition), smart-guide snapping, and
+ * smart-guide snapping and
* the group-member cascade - now run as a React Flow CHANGE MIDDLEWARE
* (experimental_useOnNodesChangeMiddleware), i.e. INSIDE React Flow's own
* updateNodePositions, before it commits anything.
@@ -2605,13 +2611,20 @@ function CanvasInner({
// its corrected position and then reconcile after the backend echo.
if (!change.dragging && !dragStartRef.current.has(change.id)) return change;
sawGestureFrame = true;
- let start = dragStartRef.current.get(change.id);
- if (!start) {
+ // Gesture membership bookkeeping only: recording the start is what
+ // lets the drag-STOP change (which arrives without the dragging
+ // flag) be recognised above. The drag-speed factor deliberately
+ // does NOT touch node motion any more - the legacy feature it
+ // ports scaled canvas PANNING, never item movement
+ // (graphlink_view.py:72 "For controlling pan speed"; its pan
+ // handler multiplied each mouse delta by the factor). The straight
+ // port mis-wired it to node dragging; the factor now applies in
+ // the wrapper's own pan handler below.
+ if (!dragStartRef.current.has(change.id)) {
const node = currentNodes.find((n) => n.id === change.id);
- start = node ? { ...node.position } : { ...change.position };
- dragStartRef.current.set(change.id, start);
+ dragStartRef.current.set(change.id, node ? { ...node.position } : { ...change.position });
}
- let finalPosition = scaleDragPosition(start, change.position, scene.dragFactor);
+ let finalPosition = { ...change.position };
// 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
@@ -2803,16 +2816,61 @@ function CanvasInner({
[connectionAt, scene.fadeConnectionsEnabled],
);
- // Selecting a connection: only when the press did not land on a node card,
- // so this can never steal a drag from a node.
+ // Factor-scaled canvas panning - the drag-speed setting's REAL job. The
+ // legacy view multiplied each pan delta by the factor
+ // (graphlink_view.py: "self._drag_factor = 1.0 # For controlling pan
+ // speed." and `delta *= self._drag_factor` in its pan handler); the
+ // straight port mis-wired the factor to node motion instead, which read
+ // as the setting doing nothing. React Flow's own panOnDrag has no speed
+ // input, so it is disabled on the element below and the gesture is owned
+ // here, applying the exact legacy contract: viewport moves by
+ // pointer-delta times factor, incrementally per event.
+ const panStateRef = useRef<{ lastX: number; lastY: number } | null>(null);
const onCanvasMouseDown = useCallback(
(event: MouseEvent) => {
if ((event.target as HTMLElement).closest(".react-flow__node")) return;
const id = connectionAt(event.clientX, event.clientY);
setSelectedConnectionId(id);
+ // Begin a pan on a background press (left or middle button), unless
+ // Shift is held - that remains React Flow's selection-box gesture.
+ const onPane = (event.target as HTMLElement).closest(".react-flow__pane");
+ if (onPane && !event.shiftKey && (event.button === 0 || event.button === 1) && id === null) {
+ panStateRef.current = { lastX: event.clientX, lastY: event.clientY };
+ canvasWrapperRef.current?.classList.add("panning");
+ }
},
[connectionAt],
);
+ useEffect(() => {
+ const onWindowMouseMove = (event: MouseEvent) => {
+ const pan = panStateRef.current;
+ if (!pan) return;
+ const factor = sceneRef.current.dragFactor;
+ const dx = (event.clientX - pan.lastX) * factor;
+ const dy = (event.clientY - pan.lastY) * factor;
+ pan.lastX = event.clientX;
+ pan.lastY = event.clientY;
+ const { transform } = storeApi.getState();
+ reactFlow.setViewport({ x: transform[0] + dx, y: transform[1] + dy, zoom: transform[2] });
+ };
+ const onWindowMouseUp = () => {
+ if (!panStateRef.current) return;
+ panStateRef.current = null;
+ canvasWrapperRef.current?.classList.remove("panning");
+ // Persist the settled viewport the same way onMove does for zooming -
+ // programmatic setViewport does not raise React Flow's own onMove.
+ const [x, y, zoom] = storeApi.getState().transform;
+ makeDebouncedViewportReport(viewportTimerRef, (zoomFactor, scrollX, scrollY) =>
+ store.setViewState(zoomFactor, scrollX, scrollY),
+ )(zoom, x, y);
+ };
+ window.addEventListener("mousemove", onWindowMouseMove);
+ window.addEventListener("mouseup", onWindowMouseUp);
+ return () => {
+ window.removeEventListener("mousemove", onWindowMouseMove);
+ window.removeEventListener("mouseup", onWindowMouseUp);
+ };
+ }, [reactFlow, store, storeApi]);
// Delete removes the selected connection. React Flow used to report edge
// deletions through onDelete; it no longer renders connections, so this
@@ -2898,6 +2956,11 @@ function CanvasInner({
// PluginPicker can attach "which node was selected" to executePlugin
// without either component reaching into the other's internals.
onSelectionChange={onSelectionChange}
+ /* Pan is owned by the wrapper's factor-scaled handler (see
+ onCanvasMouseDown) - React Flow's own panOnDrag has no speed
+ input, which is how the drag-speed setting lost its meaning in
+ the straight port. */
+ panOnDrag={false}
snapToGrid={scene.snapToGrid}
snapGrid={snapGrid}
// 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 e0dc311..40b7ce9 100644
--- a/web_ui/src/app/canvas/sceneStore.test.ts
+++ b/web_ui/src/app/canvas/sceneStore.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
-import { SceneStore, initialSceneState, scaleDragPosition } from "./sceneStore";
+import { SceneStore, initialSceneState } from "./sceneStore";
import type { ScenePatch, WsTransport } from "../../lib/ws/transport";
import type { BridgeRejection } from "../../lib/bridge-core/islandState";
@@ -1642,16 +1642,3 @@ describe("SceneStore", () => {
});
});
-describe("scaleDragPosition (the drag-speed contract)", () => {
- it("factor 1 leaves motion unscaled", () => {
- expect(scaleDragPosition({ x: 0, y: 0 }, { x: 100, y: 40 }, 1)).toEqual({ x: 100, y: 40 });
- });
-
- it("factor 0.5 halves the delta from the drag start", () => {
- expect(scaleDragPosition({ x: 10, y: 10 }, { x: 110, y: 50 }, 0.5)).toEqual({ x: 60, y: 30 });
- });
-
- it("scales relative to the start, not the origin", () => {
- expect(scaleDragPosition({ x: -20, y: 8 }, { x: -20, y: 8 }, 0.25)).toEqual({ x: -20, y: 8 });
- });
-});
diff --git a/web_ui/src/app/canvas/sceneStore.ts b/web_ui/src/app/canvas/sceneStore.ts
index ffc6791..ac5dd9e 100644
--- a/web_ui/src/app/canvas/sceneStore.ts
+++ b/web_ui/src/app/canvas/sceneStore.ts
@@ -608,6 +608,16 @@ export class SceneStore {
this.emit();
}
+ // Drops both filter axes at once - the View popover's Clear affordance.
+ // No-op (no emit) when nothing is filtered, matching every other
+ // guard-before-emit setter on this store.
+ clearFilters(): void {
+ if (this.filterKinds.size === 0 && this.filterStatuses.size === 0) return;
+ this.filterKinds = new Set();
+ this.filterStatuses = new Set();
+ this.emit();
+ }
+
setExportInProgress(value: boolean): void {
if (value === this.exportInProgress) return;
this.exportInProgress = value;
@@ -1411,16 +1421,4 @@ export class SceneStore {
}
}
-/** start + (proposed - start) * factor: the drag-speed contract carried over
- * from the Qt canvas (ChatView's drag factor scaled item motion the same
- * way). Exported standalone for direct unit testing. */
-export function scaleDragPosition(
- start: { x: number; y: number },
- proposed: { x: number; y: number },
- factor: number,
-): { x: number; y: number } {
- return {
- x: start.x + (proposed.x - start.x) * factor,
- y: start.y + (proposed.y - start.y) * factor,
- };
-}
+
diff --git a/web_ui/src/app/chrome/ChatLibraryDialog.tsx b/web_ui/src/app/chrome/ChatLibraryDialog.tsx
index cc2d576..05a9932 100644
--- a/web_ui/src/app/chrome/ChatLibraryDialog.tsx
+++ b/web_ui/src/app/chrome/ChatLibraryDialog.tsx
@@ -608,7 +608,7 @@ export function ChatLibraryDialog({ transport }: { transport: WsTransport }) {