Empty-graph chrome: Import Notes stays, search + settings wait for nodes

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.
This commit is contained in:
amosblomqvist
2026-09-25 01:26:42 +03:00
parent 66090ed769
commit 81be55f35c
3 changed files with 196 additions and 57 deletions
+24
View File
@@ -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
+107 -57
View File
@@ -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<HTMLInputElement | null>;
fileInputRef: React.RefObject<HTMLInputElement | null>;
onImportFiles: (files: FileList | null) => void;
settings: GraphRenderSettings;
changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void;
}) {
return (
<>
{!isEmpty && (
<div className="graph-chrome-fade-in w-56 min-w-0 shrink rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
ref={searchInputRef}
value={query}
onChange={(e) => 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"
/>
</div>
{noMatch && (
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">No matching nodes.</div>
)}
</div>
)}
{/* Import notes — its own container (36px, header band); ALWAYS
rendered (the empty-graph ruling above — the entry point). */}
<div className="shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<input
ref={fileInputRef}
type="file"
multiple
accept=".md,.markdown,.txt"
className="hidden"
onChange={(e) => {
onImportFiles(e.target.files);
e.target.value = ""; // allow re-picking the same file
}}
/>
<button
type="button"
disabled={importing}
onClick={() => fileInputRef.current?.click()}
data-testid="import-notes"
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
<UploadIcon className="size-3" />
{importing ? "Importing…" : "Import notes…"}
</button>
</div>
{!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.
<div className="graph-chrome-fade-in shrink-0">
<GraphSettingsControl settings={settings} changeSettings={changeSettings} />
</div>
)}
</>
);
}
export function GraphView() {
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(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. */}
<div className="relative z-20 flex min-w-0 items-start gap-2">
{!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. */}
<div className="w-56 min-w-0 shrink rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
ref={searchInputRef}
value={query}
onChange={(e) => 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"
/>
</div>
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">
No matching nodes.
</div>
)}
</div>
</>
)}
{!isEmpty && (
<>
{/* Import notes — its own container (36px, header band). */}
<div className="shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<input
ref={fileInputRef}
type="file"
multiple
accept=".md,.markdown,.txt"
className="hidden"
onChange={(e) => {
onImportFiles(e.target.files);
e.target.value = ""; // allow re-picking the same file
}}
/>
<button
type="button"
disabled={importing}
onClick={() => fileInputRef.current?.click()}
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
<UploadIcon className="size-3" />
{importing ? "Importing…" : "Import notes…"}
</button>
</div>
{/* 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. */}
<GraphSettingsControl settings={settings} changeSettings={changeSettings} />
</>
)}
{/* 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. */}
<GraphChromeRow
isEmpty={isEmpty}
query={query}
onQuery={setQuery}
noMatch={snapshot !== null && visibleIds.size === 0 && query.trim() !== ""}
importing={importing}
searchInputRef={searchInputRef}
fileInputRef={fileInputRef}
onImportFiles={onImportFiles}
settings={settings}
changeSettings={changeSettings}
/>
</div>
{/* Top-right tail (owner, 2026-09-20): the pipeline cost number —
+65
View File
@@ -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<string, unknown>).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."));
});
});