From 9bd51f1d542df66fc3134651d25af63940eea2dc Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Tue, 28 Jul 2026 15:00:11 -0400 Subject: [PATCH] R8a: unify canvas chrome spacing, give the minimap real colors, hide the token counter by default Three related polish fixes to the floating chrome around the canvas: - One canonical edge inset (--gl-chrome-inset, 16px) for every piece of canvas chrome - pins/search/view-popover/plugin-picker were anchored at an inconsistent 8px while the token counter/notification used 16px, and the minimap used React Flow's own baked-in 15px Panel margin, entirely outside this app's spacing system. All of it now shares one value, the same way the z-index scale already gave stacking order one source of truth. Also fixed a stale comment on that z-index scale (claimed pins used the canvas-chrome tier; the real code has used the app-layer tier for a while) and removed .scene-pins, a fully dead CSS block left over from a pre-Popover-refactor pins implementation with zero remaining JSX references. - The minimap rendered every node as React Flow's own default plain rectangle - flat, undifferentiated boxes with no relationship to what's actually on the canvas. It now reflects real color: note/frame/container nodes show their actual assigned color (the same palette the color picker writes), every other kind gets one deliberate neutral tone instead of the default, and the currently selected node is highlighted. Colors are read from the real design tokens at render time, not hardcoded hex. - The token counter overlay is now off by default (fresh installs and any settings file missing the key) rather than on - it was already a fully real, working, persisted setting with a wired Settings checkbox; this only flips which state is the starting one. Existing users who already have it explicitly enabled are unaffected. --- backend/tests/test_settings.py | 10 +- graphlink_licensing.py | 7 +- web_ui/src/app/App.tsx | 9 +- web_ui/src/app/canvas/SceneCanvas.tsx | 51 +++++++++- web_ui/src/app/styles.css | 138 ++++++-------------------- 5 files changed, 97 insertions(+), 118 deletions(-) diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index bbcda7a..35fb185 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -43,7 +43,8 @@ def test_settings_payload_shape_matches_generated_validator_shape(manager): def test_settings_payload_reflects_real_manager_defaults(manager): payload = settings_payload(manager) assert payload["theme"] == "dark" - assert payload["showTokenCounter"] is True + # R8a: the token counter overlay is off by default - opt-in, not opt-out. + assert payload["showTokenCounter"] is False assert payload["enableSystemPrompt"] is True assert payload["githubTokenConfigured"] is False assert set(payload["notificationPreferences"]) == set(SettingsManager.NOTIFICATION_TYPES) @@ -109,6 +110,13 @@ def test_set_show_token_counter_intent(manager): bus = SessionBus("settings-token-counter-test") register_settings(bus, manager) + # Default is now False - assert the intent actually flips state (True is + # the meaningful direction to prove; False-on-False would be a no-op + # that could pass even if the setter never ran at all). + assert manager.get_show_token_counter() is False + asyncio.run(bus.dispatch_intent("app-settings", "setShowTokenCounter", [True])) + assert manager.get_show_token_counter() is True + asyncio.run(bus.dispatch_intent("app-settings", "setShowTokenCounter", [False])) assert manager.get_show_token_counter() is False diff --git a/graphlink_licensing.py b/graphlink_licensing.py index f1f59c7..d6a1fab 100644 --- a/graphlink_licensing.py +++ b/graphlink_licensing.py @@ -81,7 +81,7 @@ def _load_state(self): if 'theme' not in state: state['theme'] = 'dark' if 'show_token_counter' not in state: - state['show_token_counter'] = True + state['show_token_counter'] = False state_changed = False if 'ollama_chat_model' not in state: state['ollama_chat_model'] = '' @@ -207,7 +207,7 @@ def _create_initial_state(self): state = { "schema_version": self.CURRENT_SCHEMA_VERSION, "theme": "dark", - "show_token_counter": True, + "show_token_counter": False, "ollama_chat_model": "", "ollama_title_model": "", "ollama_chart_model": "", @@ -346,7 +346,8 @@ def set_theme(self, theme_name): self._save_state() def get_show_token_counter(self): - return self.state.get("show_token_counter", True) + # R8a: off by default - the overlay is opt-in now, not opt-out. + return self.state.get("show_token_counter", False) def set_show_token_counter(self, show: bool): self.state['show_token_counter'] = show diff --git a/web_ui/src/app/App.tsx b/web_ui/src/app/App.tsx index ede9d6f..1de1fc2 100644 --- a/web_ui/src/app/App.tsx +++ b/web_ui/src/app/App.tsx @@ -158,10 +158,11 @@ function GlobalShortcuts({ store }: { store: SceneStore }) { function App() { const [status, setStatus] = useState("closed"); const [system, setSystem] = useState({}); - // showTokenCounter defaults true (matches AppSettingsStatePayload's - // default and the legacy AppearanceSettingsWidget's own initial state) - // until the real snapshot arrives, so the overlay doesn't flash hidden. - const [settingsVisibility, setSettingsVisibility] = useState({ showTokenCounter: true }); + // showTokenCounter defaults false (R8a: off by default, matching + // SettingsManager.get_show_token_counter's own default) until the real + // snapshot arrives, so the overlay doesn't flash visible for a user who + // has it off. + const [settingsVisibility, setSettingsVisibility] = useState({ showTokenCounter: false }); const transport = useMemo(() => new WsTransport(defaultWsUrl()), []); const sceneStore = useMemo(() => new SceneStore(transport), [transport]); diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index b8b0dd6..9d1bba3 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -930,6 +930,23 @@ export function makeDebouncedViewportReport( }; } +// R8a: reads a real design-token value at render time rather than +// hardcoding a hex literal - needed anywhere a color has to be a plain JS +// string (an SVG-attribute-producing prop like MiniMap's nodeColor/ +// nodeStrokeColor), not a CSS declaration value or a style={{}} block, so +// it falls outside what the no-raw-colors lint gate can enforce for us. +function useCssVar(name: string, fallback: string): string { + // A lazy initializer, not an effect: the token's value is static for the + // component's lifetime (no live theme-switching exists yet), so reading + // it once during the initial render avoids both a spurious extra render + // AND a one-frame flash of `fallback` before the real value lands. + const [value] = useState(() => { + const computed = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return computed || fallback; + }); + return value; +} + function CanvasInner({ store }: { store: SceneStore }) { const scene = useSyncExternalStore(store.subscribe, store.getScene); const grid = useSyncExternalStore(store.subscribe, store.getGrid); @@ -996,6 +1013,31 @@ function CanvasInner({ store }: { store: SceneStore }) { const edges = useMemo(() => toFlowEdges(scene, hoveredEdgeId), [scene, hoveredEdgeId]); + // R8a: the minimap used to render every node as React Flow's own default + // plain rectangle (no nodeColor/nodeStrokeColor was ever passed), which + // reads as flat, undifferentiated "white boxes" against this app's dark + // theme. note/frame/container are the only kinds with a real, user- + // assigned color (the same palette GroupColorPicker writes) - reflect it + // here so a colored group/note actually stands out on the minimap the + // way it does on the canvas. Every other kind (and any uncolored group/ + // note) gets one deliberate, visible neutral instead of RF's default; + // the currently selected node gets the brightest tone so selection state + // reads on the minimap too. Colors are read from the real design tokens + // (not hardcoded hex) so this stays theme-driven. + const minimapNodeColor = useCssVar("--gl-surface-handle-hover", "#6A6A6A"); + const minimapStrokeColor = useCssVar("--gl-surface-border-strong", "#505050"); + const minimapSelectedColor = useCssVar("--gl-surface-text-bright", "#FFFFFF"); + const getMinimapNodeColor = useCallback( + (node: SceneFlowNode) => { + if (node.selected) return minimapSelectedColor; + if ((node.type === "note" || node.type === "frame" || node.type === "container") && node.data.color) { + return node.data.color; + } + return minimapNodeColor; + }, + [minimapNodeColor, minimapSelectedColor], + ); + const onNodesChange = useCallback( (changes: NodeChange[]) => { // Synthetic member-position changes generated below (via @@ -1195,7 +1237,14 @@ function CanvasInner({ store }: { store: SceneStore }) { color={grid.gridColor} style={{ opacity: grid.gridOpacityPercent / 100 }} /> - + {/* R7.5b-3: smart-guide lines. Legacy's guides were QGraphicsLineItems in the same unified scene as the nodes, panning/zooming for free - ViewportPortal is the direct React Flow analog (children render diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css index 610ba2c..2738751 100644 --- a/web_ui/src/app/styles.css +++ b/web_ui/src/app/styles.css @@ -15,13 +15,26 @@ * Everything that floats above the canvas must take a value from here. Numbers * are spaced so a tier can be inserted without renumbering its neighbours. */ :root { - --gl-z-canvas-chrome: 5; /* pins/token counter drawn over the canvas */ + --gl-z-canvas-chrome: 5; /* persistent canvas HUD chrome (token counter) */ --gl-z-app-layer: 20; /* app-bar popovers, plugin picker, pin overlay */ --gl-z-search: 22; /* search overlay, above the other app layers */ --gl-z-notification: 30; /* banner must beat app layers, not dialogs */ --gl-z-scrim: 40; /* modal scrim + the dialog it dims for */ --gl-z-node-menu: 60; /* PORTALED node context menus (NodeMenu.tsx) */ --gl-z-approval: 80; /* blocking code-execution prompt - outranks all */ + + /* R8a: ONE canonical edge inset for every piece of chrome anchored to a + corner/edge of the canvas (pins, search, view/plugin popovers, token + counter, minimap). Before this, the SPA mixed 8px (pins/popovers/ + search), 16px (token counter/notification), and React Flow's own + baked-in 15px (the minimap's default Panel margin, entirely outside + this app's own spacing system) - three near-identical values with no + stated relationship, which is exactly the kind of ad-hoc drift the + z-index scale above already fixed for stacking order. The composer + island's own bottom offset stays a distinct, separately-justified + value (verified against the token counter's real measured footprint), + not this token - it is not a plain edge inset. */ + --gl-chrome-inset: 16px; } html, @@ -220,6 +233,11 @@ body, } .scene-minimap { + /* Overrides React Flow's own baked-in Panel margin (15px, from its + bottom-right default position) so the minimap uses the SAME edge + inset as every other piece of canvas chrome, rather than a value + that happened to be close but came from a different system. */ + margin: var(--gl-chrome-inset) !important; background-color: var(--gl-surface-node-body); border: 1px solid var(--gl-surface-border); border-radius: 8px; @@ -230,104 +248,6 @@ body, fill-opacity: 0.55; } -.scene-pins { - position: absolute; - left: 16px; - top: 16px; - width: 190px; - padding: 10px 12px; - background-color: var(--gl-surface-node-body); - border: 1px solid var(--gl-surface-border); - border-radius: 10px; - z-index: var(--gl-z-canvas-chrome); -} - -.scene-pins-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 6px; -} - -.scene-pins-title { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.14em; - color: var(--gl-surface-text-muted); -} - -.scene-pins-add { - font-size: 10px; - font-weight: 600; - font-family: inherit; - padding: 3px 8px; - color: var(--gl-surface-text); - background-color: var(--gl-neutral-button-background); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 6px; - cursor: pointer; -} - -.scene-pins-add:hover { - background-color: var(--gl-neutral-button-hover); -} - -.scene-pins-empty { - margin: 4px 0 0; - font-size: 11px; - color: var(--gl-surface-text-muted); -} - -.scene-pins-list { - margin: 0; - padding: 0; - list-style: none; -} - -.scene-pins-row { - display: flex; - align-items: center; - gap: 6px; - padding: 2px 0; -} - -.scene-pins-jump { - flex: 1; - text-align: left; - font-size: 11px; - font-family: inherit; - padding: 4px 6px; - color: var(--gl-surface-text); - background: transparent; - border: none; - border-radius: 5px; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.scene-pins-jump:hover { - background-color: var(--gl-neutral-button-hover); -} - -.scene-pins-remove { - font-size: 12px; - font-family: inherit; - line-height: 1; - padding: 2px 6px; - color: var(--gl-surface-text-muted); - background: transparent; - border: none; - border-radius: 5px; - cursor: pointer; -} - -.scene-pins-remove:hover { - color: var(--gl-surface-text); - background-color: var(--gl-neutral-button-hover); -} - .scene-node-handle { width: 8px; height: 8px; @@ -1045,15 +965,15 @@ body, .app-popover-layer { position: absolute; - top: 8px; - right: 16px; + top: var(--gl-chrome-inset); + right: var(--gl-chrome-inset); z-index: var(--gl-z-app-layer); } .app-plugins-layer { position: absolute; - top: 8px; - right: 16px; + top: var(--gl-chrome-inset); + right: var(--gl-chrome-inset); z-index: var(--gl-z-app-layer); } @@ -1566,8 +1486,8 @@ body, .app-token-counter-layer { position: absolute; - bottom: 16px; - left: 16px; + bottom: var(--gl-chrome-inset); + left: var(--gl-chrome-inset); z-index: var(--gl-z-canvas-chrome); } @@ -1607,7 +1527,7 @@ body, .app-notification-layer { position: absolute; - top: 16px; + top: var(--gl-chrome-inset); left: 50%; transform: translateX(-50%); z-index: var(--gl-z-notification); @@ -1647,8 +1567,8 @@ body, .app-pins-layer { position: absolute; - top: 8px; - left: 16px; + top: var(--gl-chrome-inset); + left: var(--gl-chrome-inset); z-index: var(--gl-z-app-layer); } @@ -1878,7 +1798,7 @@ body, .app-search-layer { position: absolute; - top: 8px; + top: var(--gl-chrome-inset); left: 50%; transform: translateX(-50%); z-index: var(--gl-z-search);