From 0daa65c3e7d95071478e29dcc63db7fcdee09beb Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Sat, 15 Aug 2026 07:11:28 -0400 Subject: [PATCH] Redesign the View panel and restore drag speed's real meaning: pan sensitivity The View popover was a prototype-level straight port: unlabelled sliders with no value readouts, a bare OS select for the font family while the app's own styled dropdown existed for exactly that purpose, connection toggles filed under GRID because that is which Qt bridge owned their checkboxes, colour palettes frozen verbatim from the deleted Qt code (three grid swatches nearly invisible against the canvas, two font grays 19 units apart), and a grid control reduced from the legacy spinbox to four fixed presets. The redesign, section by section: every slider now carries a label and a live value readout; grid spacing gained a real slider (4-120px, clamped server-side too, where the old handler accepted 0 and would have blanked the canvas); grid style and the preset rows are segmented controls; connection toggles moved to their own CONNECTIONS section; every toggle explains itself with a one-line hint; the font family uses the app's CustomSelect with a live preview of the resulting node typography on a real card background; both colour rows gained a free-choice picker (the backend always accepted any hex - only the UI didn't offer it) and upgraded palettes; the filter section gained group labels, pill-shaped chips distinct from the segmented controls, and a Clear button; and a footer resets everything to the documented defaults. The Chat Library dialog's workspace tabs and tag chips shared the old chip class and move to the new one with it. The panel's DRAG setting is also rewired to what it always meant. In the legacy app the factor was commented "For controlling pan speed." and multiplied each mouse delta while panning the view; the port applied it to node movement instead, so the setting users knew - how fast you move around the graph - did not exist in this codebase. React Flow's built-in pan has no speed input, which is presumably why the substitution happened; it is now disabled and the canvas owns the pan gesture, applying the factor to every delta exactly as the legacy view did. Verified: at 100% the viewport tracks the pointer 1:1, at 5% it moves exactly 5px per 100px of mouse travel, and node dragging is unaffected. Node-motion scaling is removed along with its now-dead helper; the section is relabelled Navigation / "Canvas pan speed" so it says what it does. Co-Authored-By: Claude Fable 5 --- backend/api/intents_grid.py | 5 +- backend/domain/model.py | 25 +- backend/tests/test_canvas.py | 7 +- web_ui/src/app/canvas/ChartNodeView.tsx | 2 +- web_ui/src/app/canvas/SceneCanvas.test.tsx | 2 +- web_ui/src/app/canvas/SceneCanvas.tsx | 91 ++++- web_ui/src/app/canvas/sceneStore.test.ts | 15 +- web_ui/src/app/canvas/sceneStore.ts | 24 +- web_ui/src/app/chrome/ChatLibraryDialog.tsx | 8 +- web_ui/src/app/chrome/ViewPopover.tsx | 375 ++++++++++++++------ web_ui/src/app/styles.css | 294 +++++++++++++-- 11 files changed, 658 insertions(+), 190 deletions(-) diff --git a/backend/api/intents_grid.py b/backend/api/intents_grid.py index 81d847c3..e7ed24a4 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 874eb224..cc1e7a19 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 1b6dcbb8..2c4b86e7 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 202a6578..cb8381e5 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 ac491382..4d821689 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 0235e75f..f7a3697f 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 e0dc3119..40b7ce95 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 ffc67916..ac5dd9ec 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 cc2d576c..05a99320 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 }) {
+ ); +} + /** * The View popover (Qt-removal plan R2, audit P5): ONE surface consolidating * the drag-speed, grid-control, and font-control islands - their controls, * their presets (published by the backend), their intent names - instead of * three separately-positioned popover cards. + * + * Redesigned past the literal port: every slider carries a label and a live + * value readout; grid style is a segmented control; the font family uses + * the app's own CustomSelect (the component built precisely because bare + * store.setDragFactor(Number(e.target.value) / 100)} /> -
+
{dragConfig.percentPresets.map((percent) => ( ))}
-
+ + store.setGridOpacityPercent(Number(e.target.value))} + /> + +
{grid.stylePresets.map((style) => ( ))}
-
- {grid.colorPresets.map((color) => ( -
- - {/* R7.5b-1: same view-check-row pattern as Snap to Grid above. */} - - {/* R7.5b-2: same view-check-row pattern again. */} - - {/* R7.5b-3: the fourth and final legacy grid-control toggle. */} - + + store.setGridColor(color)} + /> + store.setSnapToGrid(v)} + /> + store.setSmartGuides(v)} + /> + + + {/* Fade/orthogonal lived under GRID in the straight port only because + the legacy grid-control bridge happened to own their checkboxes; + they configure connections, so they get a section that says so. */} +
+

Connections

+ store.setFadeConnections(v)} + /> + store.setOrthogonalConnections(v)} + />
-

FONT

- + options={fontConfig.fontFamilies.map((family) => ({ id: family, label: family }))} + onChange={(family) => store.setFontFamily(family)} + ariaLabel="Font family" + /> + store.setFontSize(Number(e.target.value))} /> -
- {fontConfig.colorPresets.map((color) => ( -
-

BRANCHES

+

Branches

{/* ADR-002 Workstream 1 ("Branch status and lifecycle"): dims every node outside the accepted paths (rejected/superseded branches and their descendants, unless an explicit "accepted" override reactivates a sub-branch) - the whole-graph counterpart to a - single chat node's own "Hide Other Branches" menu action. Same - view-check-row pattern as every other toggle in this section, - but backed by sceneStore's own local focusAcceptedPaths field + single chat node's own "Hide Other Branches" menu action. + Backed by sceneStore's own local focusAcceptedPaths field rather than `scene` - see that field's own comment. */} - + store.setFocusAcceptedPaths(v)} + />
-

FILTER

- {/* ADR-012 stage 12.5: multi-select toggle chips, same view-row/ - view-preset-btn markup every other section's preset row already - uses - "active" here means "toggled into the filter set," not - mutual exclusion (unlike, say, the grid-size presets above, a - click here only ever flips ITS OWN membership in - sceneStore's filterKinds Set, see toggleFilterKind's own doc). - An empty set (no chip active) means no filter at all - every - node shows at full opacity, exactly as before this stage. */} +
+

Filter

+ {filterCount > 0 && ( + + )} +
+ {/* ADR-012 stage 12.5: multi-select toggle chips - "active" means + "toggled into the filter set," not mutual exclusion; a click + only ever flips ITS OWN membership in sceneStore's filterKinds/ + filterStatuses Sets. An empty set (no chip active) means no + filter at all - every node shows at full opacity. */} +

By kind

{FILTERABLE_NODE_KINDS.map((kind) => ( ))}
+

By branch status

{FILTER_STATUS_VALUES.map((status) => (
+ +
+ +
); } diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css index 40f29bb3..943c8f33 100644 --- a/web_ui/src/app/styles.css +++ b/web_ui/src/app/styles.css @@ -269,6 +269,17 @@ body, inset: 0; } +/* Factor-scaled panning is handled by the wrapper (React Flow's own pan is + disabled), so the wrapper also owns the grab cursors the library would + otherwise apply. */ +.scene-canvas .react-flow__pane { + cursor: grab; +} + +.scene-canvas.panning .react-flow__pane { + cursor: grabbing; +} + .scene-canvas .react-flow { background-color: var(--gl-surface-window); } @@ -1747,25 +1758,145 @@ body, /* -- View popover -------------------------------------------------------- */ .view-popover { - width: 250px; + width: 300px; + max-height: min(72vh, 720px); + overflow-y: auto; } .view-section + .view-section { - margin-top: 12px; - padding-top: 10px; + margin-top: 14px; + padding-top: 12px; border-top: 1px solid var(--gl-surface-border); } .view-section-title { - margin: 0 0 6px; + margin: 0 0 8px; font-size: 10px; font-weight: 700; letter-spacing: 0.14em; + text-transform: uppercase; color: var(--gl-surface-text-muted); } +/* Section header that also carries an action (the filter Clear button). */ +.view-section-head { + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.view-subsection-title { + margin: 10px 0 4px; + font-size: 10px; + font-weight: 600; + color: var(--gl-surface-text-label); +} + +/* Label + live value readout above every slider - the redesign's core + idiom: no control without a name and a current value. */ +.view-field-row { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-top: 10px; +} + +.view-field-row:first-of-type { + margin-top: 0; +} + +.view-field-label { + font-size: 11px; + color: var(--gl-surface-text-secondary); +} + +.view-field-value { + font-size: 10px; + font-weight: 600; + font-variant-numeric: tabular-nums; + padding: 1px 6px; + color: var(--gl-surface-text-primary); + background-color: var(--gl-surface-inset); + border: 1px solid var(--gl-surface-border); + border-radius: 999px; +} + +/* Sliders: styled track/thumb rather than the engine default, which reads + as unfinished against the dark chrome. WebView2 is Chromium, so the + -webkit pseudo-elements are the ones that apply. */ .view-slider { + -webkit-appearance: none; + appearance: none; width: 100%; + height: 18px; + margin: 4px 0 2px; + background: transparent; + cursor: pointer; +} + +.view-slider::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; + background-color: var(--gl-neutral-button-border); +} + +.view-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + margin-top: -5px; + border-radius: 50%; + background-color: var(--gl-surface-text-primary); + border: 1px solid var(--gl-surface-border-strong); + transition: transform var(--gl-motion-fast) var(--gl-motion-ease); +} + +.view-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); +} + +.view-slider:focus-visible { + outline: 2px solid var(--gl-focus-ring); + outline-offset: 2px; + border-radius: 4px; +} + +/* Segmented control: one connected group for mutually-exclusive choices + (grid style, spacing presets, drag presets), replacing loose buttons. */ +.view-segment { + display: flex; + margin-top: 6px; + border: 1px solid var(--gl-neutral-button-border); + border-radius: 6px; + overflow: hidden; +} + +.view-segment-btn { + flex: 1; + font-size: 10px; + font-weight: 600; + font-family: inherit; + padding: 5px 0; + color: var(--gl-surface-text-secondary); + background-color: var(--gl-neutral-button-background); + border: none; + border-left: 1px solid var(--gl-neutral-button-border); + cursor: pointer; +} + +.view-segment-btn:first-child { + border-left: none; +} + +.view-segment-btn:hover { + background-color: var(--gl-neutral-button-hover); + color: var(--gl-surface-text-primary); +} + +.view-segment-btn.active { + background-color: var(--gl-neutral-button-pressed, var(--gl-neutral-button-hover)); + color: var(--gl-surface-text-bright); } .view-row { @@ -1775,25 +1906,46 @@ body, flex-wrap: wrap; } -.view-preset-btn { +/* Filter chips: pill-shaped so multi-select membership reads differently + from the segmented single-choice controls above. */ +.view-chip { font-size: 10px; font-weight: 600; font-family: inherit; - padding: 4px 8px; - color: var(--gl-surface-text-primary); + padding: 3px 9px; + color: var(--gl-surface-text-secondary); background-color: var(--gl-neutral-button-background); border: 1px solid var(--gl-neutral-button-border); - border-radius: 6px; + border-radius: 999px; cursor: pointer; } -.view-preset-btn:hover { +.view-chip:hover { background-color: var(--gl-neutral-button-hover); + color: var(--gl-surface-text-primary); } -.view-preset-btn.active { +.view-chip.active { background-color: var(--gl-neutral-button-pressed, var(--gl-neutral-button-hover)); border-color: var(--gl-surface-text-muted); + color: var(--gl-surface-text-bright); +} + +.view-clear-btn { + font-size: 10px; + font-weight: 600; + font-family: inherit; + padding: 1px 8px; + color: var(--gl-surface-text-secondary); + background: none; + border: 1px solid var(--gl-neutral-button-border); + border-radius: 999px; + cursor: pointer; +} + +.view-clear-btn:hover { + color: var(--gl-surface-text-primary); + background-color: var(--gl-neutral-button-hover); } .view-color-swatch { @@ -1815,27 +1967,118 @@ body, .view-color-swatch.active { border-color: var(--gl-surface-text-primary); + box-shadow: 0 0 0 1px var(--gl-surface-text-primary); } -.view-check-row { - display: flex; +/* The free-choice picker at the end of each swatch row: a native color + input hidden inside a swatch-shaped label, so picking a custom value + looks and feels like picking a preset. The dashed border marks it as + "any color" until a custom value is active, at which point it carries + that value like any other swatch. */ +.view-color-custom { + position: relative; + display: inline-flex; align-items: center; - gap: 7px; - margin-top: 8px; + justify-content: center; + border-style: dashed; + background-color: var(--gl-surface-inset); +} + +.view-color-custom::after { + content: "+"; + font-size: 13px; + line-height: 1; + color: var(--gl-surface-text-muted); + pointer-events: none; +} + +.view-color-custom.active { + border-style: solid; +} + +.view-color-custom.active::after { + content: ""; +} + +.view-color-custom input[type="color"] { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + border: none; + padding: 0; +} + +/* Toggle rows: label plus a one-line hint, mirroring Settings' + checkbox-row idiom rather than a bare label+checkbox. */ +.view-toggle-row { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 10px; + cursor: pointer; +} + +.view-toggle-row input[type="checkbox"] { + margin: 1px 0 0; + accent-color: var(--gl-surface-text-primary); + cursor: pointer; +} + +.view-toggle-text { + display: flex; + flex-direction: column; + gap: 1px; +} + +.view-toggle-label { font-size: 11px; color: var(--gl-surface-text-primary); } -.view-select { +.view-toggle-hint { + font-size: 10px; + color: var(--gl-surface-text-muted); +} + +/* Live sample of the node typography the FONT controls produce. Colors are + the node card's own tokens so the sample sits on the same background the + text will actually be read against. */ +.view-font-preview { + margin-top: 8px; + padding: 8px 10px; + background-color: var(--gl-surface-node-body); + border: 1px solid var(--gl-surface-border); + border-radius: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.view-footer { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--gl-surface-border); +} + +.view-reset-btn { width: 100%; font-size: 11px; + font-weight: 600; font-family: inherit; - padding: 5px 8px; - margin-bottom: 6px; - color: var(--gl-surface-text-primary); + padding: 6px 0; + color: var(--gl-surface-text-secondary); background-color: var(--gl-neutral-button-background); border: 1px solid var(--gl-neutral-button-border); border-radius: 6px; + cursor: pointer; +} + +.view-reset-btn:hover { + background-color: var(--gl-neutral-button-hover); + color: var(--gl-surface-text-primary); } /* -- R2.3 composer + token counter + notification ------------------------ */ @@ -4671,13 +4914,12 @@ mark.document-view-search-match-current { } /* -- ADR-020 stage 20.2: workspace switcher + tag filter chips -------- - Both reuse .view-preset-btn/.view-row VERBATIM by className (that pair - is not scoped under .view-popover in this file's selectors - it is - already a globally-reusable pair, not a popover-local one) rather than - duplicating its padding/radius/font-size/color values under a new name - - see ViewPopover.tsx's own FILTER section for the identical multi-select - toggle-chip markup this dialog's tag row mirrors, and its grid-size - preset row for the single-select tab pattern the workspace row mirrors. + Both reuse .view-chip/.view-row VERBATIM by className (that pair is not + scoped under .view-popover in this file's selectors - it is already a + globally-reusable pair, not a popover-local one) rather than duplicating + its padding/radius/font-size/color values under a new name - see + ViewPopover.tsx's own FILTER section for the identical multi-select + toggle-chip markup this dialog's tag row mirrors. Only the ROW WRAPPERS below are new: .view-row/.overlay-popover's own ambient 12px/14px padding is what every OTHER .view-row consumer relies on for its horizontal gutter, and this dialog's .overlay-dialog-body is @@ -4734,7 +4976,7 @@ mark.document-view-search-match-current { background-color: var(--gl-surface-inset, var(--gl-surface-window)); } -/* Prevents the Archived toggle chip (a .view-preset-btn dropped directly +/* Prevents the Archived toggle chip (a .view-chip dropped directly into .library-header's flex row, next to the search box) from being squeezed by .library-search-wrap's own `flex: 1` - .library-new-chat- button already sets this for the same reason. */