From 81be55f35ca795fe01e65d29d064524901b163b4 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 25 Sep 2026 01:26:42 +0300 Subject: [PATCH] Empty-graph chrome: Import Notes stays, search + settings wait for nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner ruling on the empty graph (honesty principle — dead controls are lies): the top-chrome actions row now gates per control. - Import Notes renders ALWAYS (graph enabled) — the one action that works on an empty graph, the entry point itself. It used to hide with everything else, leaving the empty state a dead end with no entry. - Search + settings render only when the snapshot has nodes, and their appear-once mount carries a deliberate 200ms fade (graph-chrome-fade-in, globals.css — the settings-pop register, reduced-motion appears instantly). Computed animationName verified live. - The row's body is now the exported pure GraphChromeRow (the DeleteNodeBody pattern) — the no-match note's truth (visibleIds + snapshot) stays upstream, computed by the wrapper. - The empty-state placeholder copy is LEFT AS-IS (SCOPE rule: it already has copy — icon, title, and the session-mapping paragraph). Pins (tests/render-graph-chrome.test.tsx): empty → Import Notes present, search/settings absent, no fade carriers; populated → all three, exactly two fade carriers; the no-match note follows its upstream truth. SSR of GraphChromeRow both ways. Live-verified on :3001 with scratch data: empty graph → Import Notes only + placeholder intact; a real import through the button (file input via DataTransfer) → nodes land, search + settings fade in (computed chrome-fade-in on both carriers); wipe → reload → back to Import Notes only (the reverse path). Scratch graph restored byte-identical. 932/932, tsc/lint/build green. --- app/globals.css | 24 +++++ components/graph-view.tsx | 164 +++++++++++++++++++---------- tests/render-graph-chrome.test.tsx | 65 ++++++++++++ 3 files changed, 196 insertions(+), 57 deletions(-) create mode 100644 tests/render-graph-chrome.test.tsx diff --git a/app/globals.css b/app/globals.css index c1c9c7c..36c7407 100644 --- a/app/globals.css +++ b/app/globals.css @@ -438,6 +438,30 @@ body { } } +/* Graph chrome fade-in (empty-graph chrome wave, owner 2026-09-25): the + search + settings controls appear ONCE, as the first nodes land — + Import Notes always renders (the empty graph's entry point), the other + two mount only when the snapshot has nodes. Same understated register + as settings-pop (a plain fade); reduced-motion appears instantly. */ +@keyframes chrome-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.graph-chrome-fade-in { + animation: chrome-fade-in 200ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .graph-chrome-fade-in { + animation: none; + } +} + /* ───────────────────────────────────────────────────────────────────────── Electron merged chrome (owner, 2026-09-17). The system title bar is hidden (titleBarStyle: "hiddenInset" in electron/main.cjs); the app's diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 10616fe..1771e47 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -798,6 +798,98 @@ function GraphSettingsControl({ ); } +/** The top-chrome actions row's body (empty-graph chrome wave, owner + * 2026-09-25): Import Notes renders ALWAYS (graph enabled) — it's the + * one action that works on an empty graph, the entry point itself; + * search + settings render only when the snapshot has nodes (dead + * controls are lies — the honesty principle). The two gated controls + * fade in on their mount (graph-chrome-fade-in, globals.css — the + * appear-once transition as the first nodes land). Exported pure for + * the empty/populated SSR pins (the DeleteNodeBody pattern). */ +export function GraphChromeRow({ + isEmpty, + query, + onQuery, + noMatch, + importing, + searchInputRef, + fileInputRef, + onImportFiles, + settings, + changeSettings, +}: { + isEmpty: boolean; + query: string; + onQuery: (v: string) => void; + /** The search panel's "No matching nodes." note (computed by the + * wrapper — it owns visibleIds + the snapshot). */ + noMatch: boolean; + importing: boolean; + searchInputRef: React.RefObject; + fileInputRef: React.RefObject; + onImportFiles: (files: FileList | null) => void; + settings: GraphRenderSettings; + changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void; +}) { + return ( + <> + {!isEmpty && ( +
+
+ + onQuery(e.target.value)} + placeholder="Search nodes…" + spellCheck={false} + className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" + /> +
+ {noMatch && ( +
No matching nodes.
+ )} +
+ )} + {/* Import notes — its own container (36px, header band); ALWAYS + rendered (the empty-graph ruling above — the entry point). */} +
+ { + onImportFiles(e.target.files); + e.target.value = ""; // allow re-picking the same file + }} + /> + +
+ {!isEmpty && ( + // Rendering settings — own component; the gear toggles it. The + // gear sits at the far right (owner, round 3) so its panel drops + // at the very right edge, over less of the graph. The fade wrapper + // is a plain shrink-0 flex child — the panel itself positions + // within GraphSettingsControl's own relative root, unaffected. +
+ +
+ )} + + ); +} + export function GraphView() { const containerRef = useRef(null); const canvasRef = useRef(null); @@ -1394,63 +1486,21 @@ export function GraphView() { and clickable on top of them. The row is a stacking context, so its open dropdowns cover the number; clicks land on the panel. */}
- {!isEmpty && ( - <> - {/* Search — its own container (icon + input + no-match note). - The header band's flexible element: w-56 normally, shrinks - (min-w-0) when the column's width cap binds in narrow - windows — the row's other controls are shrink-0. */} -
-
- - setQuery(e.target.value)} - placeholder="Search nodes…" - spellCheck={false} - className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" - /> -
- {snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && ( -
- No matching nodes. -
- )} -
- - )} - {!isEmpty && ( - <> - {/* Import notes — its own container (36px, header band). */} -
- { - onImportFiles(e.target.files); - e.target.value = ""; // allow re-picking the same file - }} - /> - -
- {/* Rendering settings — own component; the gear toggles it. The - gear sits at the far right (owner, round 3) so its panel - drops at the very right edge, over less of the graph. */} - - - )} + {/* The actions row (empty-graph chrome wave, owner 2026-09-25): + Import Notes always — the empty graph's entry point; search + + settings only when nodes exist, fading in on their mount. */} +
{/* Top-right tail (owner, 2026-09-20): the pipeline cost number — diff --git a/tests/render-graph-chrome.test.tsx b/tests/render-graph-chrome.test.tsx new file mode 100644 index 0000000..c674aa0 --- /dev/null +++ b/tests/render-graph-chrome.test.tsx @@ -0,0 +1,65 @@ +/** + * Empty-graph chrome pins (empty-graph chrome wave, owner 2026-09-25): + * Import Notes renders ALWAYS — the one action that works on an empty + * graph, the entry point itself; search + settings render only when the + * snapshot has nodes (dead controls are lies — the honesty principle). + * GraphChromeRow is the pure seam (the DeleteNodeBody pattern): SSR of + * both states pins the ruling. Fetches don't run under renderToStaticMarkup + * (effects never fire), so these pins exercise the component directly. + */ + +import React from "react"; +(globalThis as Record).React = React; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { GraphChromeRow } from "../components/graph-view.tsx"; +import type { GraphRenderSettings } from "../components/graph-canvas.ts"; + +/** Ref doubles — the SSR harness never attaches them. */ +const ref = { current: null }; + +const PROPS = { + query: "", + onQuery: () => {}, + noMatch: false, + importing: false, + searchInputRef: ref, + fileInputRef: ref, + onImportFiles: () => {}, + settings: {} as GraphRenderSettings, + changeSettings: () => {}, +}; + +describe("GraphChromeRow — the empty-graph ruling (owner, 2026-09-25)", () => { + it("EMPTY graph: Import Notes renders (the entry point), search + settings absent", () => { + const html = renderToStaticMarkup(createElement(GraphChromeRow, { ...PROPS, isEmpty: true })); + assert.ok(html.includes('data-testid="import-notes"')); + assert.ok(html.includes("Import notes…")); + assert.ok(!html.includes("Search nodes…"), "search is a lie on an empty graph"); + assert.ok(!html.includes('title="Rendering settings"'), "settings is a lie on an empty graph"); + assert.ok(!html.includes("graph-chrome-fade-in"), "nothing gated = nothing fading in"); + }); + + it("POPULATED graph: all three render, the gated pair carries the fade", () => { + const html = renderToStaticMarkup(createElement(GraphChromeRow, { ...PROPS, isEmpty: false })); + assert.ok(html.includes('data-testid="import-notes"')); + assert.ok(html.includes("Search nodes…")); + assert.ok(html.includes('title="Rendering settings"')); + // the appear-once fade rides exactly the two gated controls, never Import Notes + assert.equal(html.split("graph-chrome-fade-in").length - 1, 2, "two fade carriers: search + settings"); + }); + + it("the no-match note renders only when the wrapper says so (its truth stays upstream)", () => { + const html = renderToStaticMarkup( + createElement(GraphChromeRow, { ...PROPS, isEmpty: false, query: "zzz", noMatch: true }), + ); + assert.ok(html.includes("No matching nodes.")); + const quiet = renderToStaticMarkup( + createElement(GraphChromeRow, { ...PROPS, isEmpty: false, query: "zzz", noMatch: false }), + ); + assert.ok(!quiet.includes("No matching nodes.")); + }); +}); \ No newline at end of file