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
|
||||
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() {
|
||||
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 —
|
||||
|
||||
@@ -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