deletion screen minimal + 'node' as THE UI word (owner ruling)

Owner 2026-09-30: the confirm dialog's pre-flight impact read had to
go — what full death takes is obvious, the per-open fetch flickered,
and richer facts would only confuse as deletion semantics evolve
(rewiring is on the table). The screen is now: Delete node? / Delete
N nodes?, the names in the mono block, one permanence line, Cancel /
Delete. No loading state, no gone state (a 404 mid-run lands in the
honest failure line), no sessions join — the joins prop died too.

The dead path is deleted end to end: GET /api/graph/nodes/[id],
graphNodeImpact (server), nodeDeletionImpact (core) and their tests.

WORDING: node/node(s) is THE UI word for graph objects, standardized
across every surface's copy (owner, same ruling): the dialog titles,
the chain table's counts/search/summary/delete aria-labels, the
practice dialog's cluster counts, selection line, and empty-cluster
hint. Internal identifiers (selection.propositions) unchanged.

Rewritten dialog pins (tests/delete-node-dialog.test.tsx): minimal
contract — no facts/loading/fetch copy, singular/plural titles, the
cap, 409 dock phrasing, busy states, no 'proposition' left in the
dialog.
This commit is contained in:
amosblomqvist
2026-09-30 01:19:03 +03:00
parent 40dc1b7f85
commit e7f4923159
13 changed files with 179 additions and 401 deletions
+88 -126
View File
@@ -4,33 +4,29 @@ import { renderToStaticMarkup } from "react-dom/server";
import { createElement } from "react";
/**
* The node-deletion affordance (node interlude, Platform's 50bfcba
* engine): render pins for the full-death confirm dialog — the copy
* and fact-joining are design, not implementation. The delete path
* itself is live-verified against the real API (dev-verification
* notes); SSR here pins what static rendering CAN pin.
* The node-deletion confirm dialog (node interlude, Platform's 50bfcba
* engine; multi-node extension + MINIMAL REWRITE, owner 2026-09-30):
* render pins for the copy — the copy is design, not implementation.
* The delete path itself is live-verified against the real API
* (dev-verification notes); SSR here pins what static rendering CAN
* pin.
*
* The minimal contract (owner ruling, 2026-09-30): NO pre-flight
* impact read — what full death takes is obvious, the fetch
* flickered, and richer facts would only confuse as deletion
* semantics evolve (rewiring talk is on the table). Title + names +
* one permanence line + Cancel/Delete. And "node"/"nodes" is THE UI
* word for graph objects (standardized everywhere, same ruling).
*/
import {
DeleteNodeBody,
DeleteNodeDialog,
EXTRACTION_RETRY_LINE,
countLine,
sessionNameOf,
type NodeImpact,
} from "../components/delete-node-dialog.tsx";
import { DeleteNodeBody, DeleteNodeDialog, EXTRACTION_RETRY_LINE } from "../components/delete-node-dialog.tsx";
const JOINS = { sessions: { seedplan: "Orbital mechanics — plan phase" }, fileSources: { src_plato: "/notes/plato.md" } };
/** The body at a given impact state — the wrapper owns effects, the body
* is pure, so SSR pins everything the live dialog shows. */
function bodyAt(impact: NodeImpact | "loading" | "gone", extra?: { failure?: { code: string; error: string } | null }) {
/** The body — pure, so SSR pins everything the live dialog shows. */
function bodyAt(nodes: { id: string; name: string }[], extra?: { failure?: { code: string; error: string } | null; busy?: boolean }) {
return renderToStaticMarkup(
createElement(DeleteNodeBody, {
nodes: [{ id: "kepler", name: "Kepler's three laws" }],
joins: JOINS,
impact,
busy: false,
nodes,
busy: extra?.busy ?? false,
failure: extra?.failure ?? null,
onClose: () => {},
onRun: () => {},
@@ -38,24 +34,6 @@ function bodyAt(impact: NodeImpact | "loading" | "gone", extra?: { failure?: { c
);
}
describe("sessionNameOf (the client-side join)", () => {
it("chat titles from sessions, imported-notes paths from fileSources, unknown ids honestly", () => {
const sessions = { chat_a1: "Orbital mechanics" };
const fileSources = { src_notes1: "/users/amos/notes/plato.md" };
assert.equal(sessionNameOf("chat_a1", sessions, fileSources), "Orbital mechanics");
assert.equal(sessionNameOf("src_notes1", sessions, fileSources), "/users/amos/notes/plato.md");
assert.equal(sessionNameOf("chat_gone", sessions, fileSources), "chat_gone");
});
});
describe("countLine (honest plurals)", () => {
it("1 reads singular, 0 and n read plural — zeros stay visible", () => {
assert.equal(countLine(1, "link to another topic", "links to other topics"), "1 link to another topic");
assert.equal(countLine(0, "quiz answer", "quiz answers"), "0 quiz answers");
assert.equal(countLine(3, "session note", "session notes"), "3 session notes");
});
});
describe("EXTRACTION_RETRY_LINE (the 409 phrasing, owner-locked)", () => {
it("names the activity dock — the surface the user watches drain", () => {
assert.equal(EXTRACTION_RETRY_LINE, "An extraction is running — try again when the activity dock drains.");
@@ -63,101 +41,85 @@ describe("EXTRACTION_RETRY_LINE (the 409 phrasing, owner-locked)", () => {
});
describe("DeleteNodeDialog (thin SSR)", () => {
it("null node renders nothing", () => {
const html = renderToStaticMarkup(
createElement(DeleteNodeDialog, { nodes: null, joins: { sessions: {}, fileSources: {} }, onClose: () => {}, onDeleted: () => {} }),
);
assert.equal(html, "");
it("null and empty nodes both render nothing — closed either way", () => {
for (const nodes of [null, []] as ({ id: string; name: string }[] | null)[]) {
const html = renderToStaticMarkup(
createElement(DeleteNodeDialog, { nodes, onClose: () => {}, onDeleted: () => {} }),
);
assert.equal(html, "");
}
});
});
it("empty nodes array also renders nothing (null and [] are both closed)", () => {
const html = renderToStaticMarkup(
createElement(DeleteNodeDialog, { nodes: [], joins: { sessions: {}, fileSources: {} }, onClose: () => {}, onDeleted: () => {} }),
);
assert.equal(html, "");
});
it("the body at loading: the frame + the pre-flight read pending (effects never fire in SSR — the wrapper's own state)", () => {
const html = bodyAt("loading");
describe("DeleteNodeBody — the minimal screen (owner, 2026-09-30)", () => {
it("single: 'Delete node?' title, the name alone in mono, the one permanence line", () => {
const html = bodyAt([{ id: "kepler", name: "Kepler's three laws" }]);
assert.match(html, /data-testid="delete-node-dialog"/);
// (owner, 2026-09-24) the serif title is just "Delete node?" in
// destructive red; the NAME rides below in JetBrains Mono, sized up
// (text-lg), NO code-block chrome — same shape as the session dialog.
// (owner, 2026-09-24) the serif title in destructive red; the
// NAME rides below in JetBrains Mono, text-lg, NO code-block
// chrome — same shape as the session dialog.
assert.match(html, /Delete node\?/);
assert.match(html, /text-destructive/); // the red title
assert.match(html, /text-destructive/);
const name = html.match(/data-testid="node-name-code"[^>]*>/)?.[0] ?? "";
assert.match(name, /font-mono text-lg/); // bigger, mono
assert.ok(!name.includes("bg-muted"), "no code-block chrome — the font already says code")
assert.match(html, /data-testid="node-name-code"[^>]*>Kepler&#x27;s three laws</); // the name in mono
assert.match(html, /data-testid="node-impact-loading"/);
});
it("multi-selection: plural title, the names listed in mono, capped at 8 + and-K-more", () => {
const three = renderToStaticMarkup(
createElement(DeleteNodeBody, {
nodes: [
{ id: "kepler", name: "Kepler's three laws" },
{ id: "newton", name: "Newton's second law" },
{ id: "hook", name: "Hooke's law" },
],
joins: JOINS,
impact: "loading",
busy: false,
failure: null,
onClose: () => {},
onRun: () => {},
}),
);
assert.match(three, /Delete 3 propositions\?/); // plural title
assert.match(three, /data-testid="node-names-code"/); // the names block
assert.match(three, /Kepler&#x27;s three laws, Newton&#x27;s second law, Hooke&#x27;s law/);
assert.ok(!three.includes('data-testid="node-name-code"'), "the single-name testid is gone in multi");
// the cap: 10 names render 8 + ", and 2 more"
const ten = renderToStaticMarkup(
createElement(DeleteNodeBody, {
nodes: Array.from({ length: 10 }, (_, i) => ({ id: `n${i}`, name: `Prop ${i}` })),
joins: JOINS,
impact: "loading",
busy: false,
failure: null,
onClose: () => {},
onRun: () => {},
}),
);
assert.match(ten, /Delete 10 propositions\?/);
assert.match(ten, /Prop 7/); // the 8th name still listed
assert.doesNotMatch(ten, /Prop 8</); // the 9th is not
assert.match(ten, /, and 2 more/);
});
it("the body at gone: honest already-removed state, a Close that rides the same refresh", () => {
const html = bodyAt("gone");
assert.match(html, /no longer in the graph/);
assert.match(html, /data-testid="node-delete-vanished-close"/);
});
it("the body at resolved impact: plain fact lines, joined names (chats + notes files), the cannot-be-undoned copy", () => {
// (drift fix 2026-09-30: quizEvents/observations died with the
// quiz-events ledger — the impact carries links + sessions only)
const html = bodyAt({ edges: 2, sessions: ["seedplan", "src_plato", "chat_ghost"] });
assert.match(html, /2 links to other topics/);
assert.ok(!html.includes("quiz answer"), "the dead quiz fact is gone");
// joined display names: chat title, notes file path, unknown id honestly itself
assert.match(html, /Orbital mechanics — plan phase/);
assert.match(html, /\/notes\/plato\.md/);
assert.match(html, /chat_ghost/);
assert.match(html, /cannot be undone/);
assert.match(name, /font-mono text-lg/);
assert.ok(!name.includes("bg-muted"), "no code-block chrome — the font already says code");
assert.match(html, /data-testid="node-name-code"[^>]*>Kepler&#x27;s three laws</);
assert.match(html, /data-testid="node-permanence-line"[^>]*>This permanently deletes the node — this cannot be undone\./);
assert.match(html, /data-testid="node-delete-cancel"/);
assert.match(html, /data-testid="node-delete-confirm"/);
// the Delete button carries the destructive red (owner, polish round)
assert.match(html, /border-destructive\/50[^>]*text-destructive/);
});
it("the body at failure: the 409 renders the activity-dock line with a Try-again", () => {
const html = bodyAt({ edges: 0, sessions: [] }, { failure: { code: "extraction_running", error: "server line" } });
assert.match(html, /data-testid="node-delete-failure"/);
assert.match(html, new RegExp(EXTRACTION_RETRY_LINE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.doesNotMatch(html, /server line/); // the dock phrasing REPLACES the raw line for the known code
assert.match(html, /data-testid="node-delete-retry"/);
it("NO impact facts, NO loading line, NO sessions join — the pre-flight read is dead", () => {
const html = bodyAt([{ id: "kepler", name: "Kepler's three laws" }]);
assert.ok(!html.includes("node-impact-facts"), "no facts list");
assert.ok(!html.includes("node-impact-loading"), "no loading line");
assert.ok(!html.includes("Reading what this would remove"), "no fetch copy");
assert.ok(!html.includes("Referenced by"), "no sessions join");
assert.ok(!html.includes("links to other topics"), "no edge count — it is obvious");
});
it("multi: 'Delete N nodes?' (the UI word is node, owner 2026-09-30), names capped at 8 + and-K-more", () => {
const three = bodyAt([
{ id: "kepler", name: "Kepler's three laws" },
{ id: "newton", name: "Newton's second law" },
{ id: "hook", name: "Hooke's law" },
]);
assert.match(three, /Delete 3 nodes\?/); // plural title, NODES
assert.match(three, /data-testid="node-names-code"/);
assert.match(three, /Kepler&#x27;s three laws, Newton&#x27;s second law, Hooke&#x27;s law/);
assert.ok(!three.includes('data-testid="node-name-code"'), "the single-name testid is gone in multi");
assert.match(three, /This permanently deletes all 3 nodes — this cannot be undone\./);
assert.ok(!three.includes("proposition"), "the old word is gone from the dialog");
// the cap: 10 names render 8 + ", and 2 more"
const ten = bodyAt(Array.from({ length: 10 }, (_, i) => ({ id: `n${i}`, name: `Prop ${i}` })));
assert.match(ten, /Delete 10 nodes\?/);
assert.match(ten, /Prop 7/); // the 8th name still listed
assert.doesNotMatch(ten, /Prop 8</); // the 9th is not
assert.match(ten, /, and 2 more/);
});
it("failure: the 409 renders the activity-dock line with a Try-again; other codes state themselves", () => {
const conflict = bodyAt([{ id: "kepler", name: "Kepler's three laws" }], {
failure: { code: "extraction_running", error: "server line" },
});
assert.match(conflict, /data-testid="node-delete-failure"/);
assert.match(conflict, new RegExp(EXTRACTION_RETRY_LINE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.doesNotMatch(conflict, /server line/); // the dock phrasing REPLACES the raw line for the known code
assert.match(conflict, /data-testid="node-delete-retry"/);
const plain = bodyAt([{ id: "kepler", name: "Kepler's three laws" }], {
failure: { code: "not_found", error: "No such node" },
});
assert.match(plain, /No such node/); // honest raw line otherwise
});
it("busy: the confirm reads 'Deleting…' and both buttons disable", () => {
const html = bodyAt([{ id: "kepler", name: "Kepler's three laws" }], { busy: true });
assert.match(html, /Deleting…</);
assert.match(html, /data-testid="node-delete-cancel"[^>]*disabled/);
assert.match(html, /data-testid="node-delete-confirm"[^>]*disabled/);
});
});
-13
View File
@@ -591,7 +591,6 @@ import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
import { jobStore } from "../src/store/jobs.ts";
import {
deleteNodeTraces,
nodeDeletionImpact,
type NodeDeletionStats,
} from "../src/core/graph-deletion.ts";
import { deleteGraphNode, graphRevision } from "../src/server/graph.ts";
@@ -810,18 +809,6 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 } });
}
it("nodeDeletionImpact: the confirm dialog's pre-flight facts", () => {
buildNodeScenario();
assert.deepEqual(nodeDeletionImpact(graphStore, "n_dead"), {
edges: 2,
// The re-encounter — the only session provenance can name (the
// legacy empty-provenance line names none).
sessions: ["chat_node_a"],
});
// Unknown node: null (the route's 404).
assert.equal(nodeDeletionImpact(graphStore, "n_missing"), null);
});
it("deleteNodeTraces: FULL DEATH — the node, its edges, its evidence, its anchors, its position", () => {
buildNodeScenario();
const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep"));
+5 -5
View File
@@ -256,11 +256,11 @@ describe("ChainTableBody — the floating selection bar (never shifts layout)",
assert.ok(!html.includes('data-testid="chain-selection-bar"'));
});
it("appears with the derived N propositions → M chains + Clear + Generate + the FENCED Delete", () => {
it("appears with the derived N nodes → M chains + Clear + Generate + the FENCED Delete", () => {
const html = render({ selection: new Set(["a", "b", "c1"]) });
assert.ok(html.includes('data-testid="chain-selection-bar"'));
assert.ok(html.includes('data-testid="chain-selected-summary"'));
assert.ok(html.includes("3 propositions → 1 chain"));
assert.ok(html.includes("3 nodes → 1 chain"));
assert.ok(html.includes('data-testid="chain-clear"'));
assert.ok(html.includes('data-testid="chain-generate"'));
// The danger fence (owner, 2026-09-29 night): a divider quarantines
@@ -269,7 +269,7 @@ describe("ChainTableBody — the floating selection bar (never shifts layout)",
assert.ok(html.includes('data-testid="chain-danger-divider"'));
const del = html.match(/<button[^>]*data-testid="chain-delete"[^>]*>/)?.[0] ?? "";
assert.match(del, /border-destructive\/50/); // the destructive pair
assert.match(del, /aria-label="Delete 3 selected propositions"/);
assert.match(del, /aria-label="Delete 3 selected nodes"/);
assert.ok(html.includes("lucide-trash-2"), "the trash icon");
// the fence is AFTER generate in the DOM (right end of the bar)
assert.ok(html.indexOf('chain-generate"') < html.indexOf('chain-danger-divider"'));
@@ -284,7 +284,7 @@ describe("ChainTableBody — the floating selection bar (never shifts layout)",
it("a premise WITHOUT its conclusion derives as the singleton it becomes", () => {
const html = render({ selection: new Set(["a", "d"]) });
assert.ok(html.includes("2 propositions → 2 chains"), "no follows-edge survives inside the selection");
assert.ok(html.includes("2 nodes → 2 chains"), "no follows-edge survives inside the selection");
});
});
@@ -303,7 +303,7 @@ describe("ChainTableBody — the fuzzy search (grouped + auto-expand)", () => {
assert.ok(!html.includes("cluster-group-notes1") === false, "notes1 still renders (it has a match)");
assert.ok(!html.includes("Ethics lesson") === false, "chat1 still renders (it has a match)");
// the floating bar is never search-filtered
assert.ok(html.includes("3 propositions → 1 chain"));
assert.ok(html.includes("3 nodes → 1 chain"));
});
it("no matches: the honest empty line", () => {
+7 -7
View File
@@ -144,8 +144,8 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
assert.ok(html.includes('data-testid="practice-dialog-clusters"'));
assert.ok(html.includes('data-testid="practice-cluster-chat_1"'));
assert.ok(html.includes("Ethics lesson"));
assert.match(html, /notes · <!-- -->3 propositions|notes · 3 propositions/);
assert.ok(html.includes("4 propositions"), "chat cluster shows its count");
assert.match(html, /notes · <!-- -->3 nodes|notes · 3 nodes/);
assert.ok(html.includes("4 nodes"), "chat cluster shows its count");
assert.ok(html.includes('disabled=""'), "no cluster picked — Create disabled");
const picked = renderToStaticMarkup(<PracticeDialogBody {...base} selectedKey="src_ab12" />);
@@ -161,15 +161,15 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
it("an empty graph is an honest empty list", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} clusters={[]} />);
assert.ok(html.includes("No clusters yet"));
assert.ok(html.includes("once a session births its first proposition"), "no 3+ mechanics in copy");
assert.ok(html.includes("once a session births its first node"), "no 3+ mechanics in copy");
});
it("1- and 2-proposition groups appear — no hull threshold in the picker", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} clusters={smallClusters()} />);
assert.ok(html.includes('data-testid="practice-cluster-chat_2"'), "singleton group listed");
assert.ok(html.includes("1 propositions"));
assert.ok(html.includes("1 node"));
assert.ok(html.includes('data-testid="practice-cluster-chat_3"'), "pair group listed");
assert.ok(html.includes("2 propositions"));
assert.ok(html.includes("2 nodes"));
});
it("empty selection (vanished nodes) renders the honest inline line", () => {
@@ -217,7 +217,7 @@ describe("PracticeDialogBody — creation (CUSTOM mode, the chains overlay's han
assert.ok(html.includes('data-testid="practice-dialog-selection"'));
assert.match(html, /Practice — (<!-- -->)?\d{4}-\d{2}-\d{2}/, "the dated default title (the pipeline's own stamp)");
assert.ok(html.includes('data-testid="practice-dialog-selection-line"'));
assert.ok(html.includes("4 propositions"));
assert.ok(html.includes("4 nodes"));
assert.ok(html.includes("2 chains"));
assert.ok(!html.includes('disabled=""'), "a non-empty selection enables Create immediately");
});
@@ -228,7 +228,7 @@ describe("PracticeDialogBody — creation (CUSTOM mode, the chains overlay's han
);
assert.ok(html.includes('data-testid="param-ladder"'));
assert.ok(html.includes('data-testid="practice-note"'));
assert.ok(html.includes("1 proposition →"));
assert.ok(html.includes("1 node →"));
});
});