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 }) {
+ ); +} + /** * 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 40f29bb..943c8f3 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. */