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);