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:
@@ -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
|
Electron merged chrome (owner, 2026-09-17). The system title bar is
|
||||||
hidden (titleBarStyle: "hiddenInset" in electron/main.cjs); the app's
|
hidden (titleBarStyle: "hiddenInset" in electron/main.cjs); the app's
|
||||||
|
|||||||
+107
-57
@@ -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() {
|
export function GraphView() {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement | 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
|
and clickable on top of them. The row is a stacking context, so
|
||||||
its open dropdowns cover the number; clicks land on the panel. */}
|
its open dropdowns cover the number; clicks land on the panel. */}
|
||||||
<div className="relative z-20 flex min-w-0 items-start gap-2">
|
<div className="relative z-20 flex min-w-0 items-start gap-2">
|
||||||
{!isEmpty && (
|
{/* The actions row (empty-graph chrome wave, owner 2026-09-25):
|
||||||
<>
|
Import Notes always — the empty graph's entry point; search +
|
||||||
{/* Search — its own container (icon + input + no-match note).
|
settings only when nodes exist, fading in on their mount. */}
|
||||||
The header band's flexible element: w-56 normally, shrinks
|
<GraphChromeRow
|
||||||
(min-w-0) when the column's width cap binds in narrow
|
isEmpty={isEmpty}
|
||||||
windows — the row's other controls are shrink-0. */}
|
query={query}
|
||||||
<div className="w-56 min-w-0 shrink rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
onQuery={setQuery}
|
||||||
<div className="relative">
|
noMatch={snapshot !== null && visibleIds.size === 0 && query.trim() !== ""}
|
||||||
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
importing={importing}
|
||||||
<input
|
searchInputRef={searchInputRef}
|
||||||
ref={searchInputRef}
|
fileInputRef={fileInputRef}
|
||||||
value={query}
|
onImportFiles={onImportFiles}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
settings={settings}
|
||||||
placeholder="Search nodes…"
|
changeSettings={changeSettings}
|
||||||
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} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top-right tail (owner, 2026-09-20): the pipeline cost number —
|
{/* Top-right tail (owner, 2026-09-20): the pipeline cost number —
|
||||||
|
|||||||
@@ -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."));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user