Polish round: node-delete chrome + rename alignment

Owner polish dispatch (2026-09-23):

- Right-click no longer pins a node or opens the detail card: the
  canvas pointerdown/up handlers now gate on the primary button, so
  the context menu arrives alone.
- Node context menu: label is just "Delete" and the shell hugs its
  content (w-44 -> w-fit); the menu body keeps focus for arrow
  cycling but never shows the browser's default focus ring
  (outline-none on the MenuShell body - applies to every menu).
- Delete confirm: "Delete node?" title in CMU Serif, red
  (text-destructive); the node name sits below in JetBrains Mono
  inline-code; the Delete button carries the destructive treatment
  (border + tint, fill deepens on hover). Rest of the dialog stays
  neutral.
- Sidebar rename row: text and metadata stay at the idle row's x -
  the status spacer and the input's border/padding are gone; the
  underline is the only editing cue.
This commit is contained in:
amosblomqvist
2026-09-23 18:53:51 +03:00
parent 63b2756465
commit 976be50a81
7 changed files with 56 additions and 10 deletions
+17 -2
View File
@@ -79,7 +79,19 @@ export function DeleteNodeBody({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl" className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
> >
<h2 className="font-title text-2xl tracking-tight">Delete “{node.name}”?</h2> <h2 className="font-title text-2xl tracking-tight text-destructive">Delete node?</h2>
{/* The node name below the title in JetBrains Mono inline-code
(owner, polish round): topic names are short technical
handles — mono suits them; the serif title keeps the
family voice ("Delete node?", like "Delete "session"?"). */}
<div className="mt-1.5">
<code
data-testid="node-name-code"
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground"
>
{node.name}
</code>
</div>
{impact === "loading" && ( {impact === "loading" && (
<p className="mt-2 text-sm text-muted-foreground" data-testid="node-impact-loading"> <p className="mt-2 text-sm text-muted-foreground" data-testid="node-impact-loading">
Reading what this would remove… Reading what this would remove…
@@ -158,7 +170,10 @@ export function DeleteNodeBody({
data-testid="node-delete-confirm" data-testid="node-delete-confirm"
onClick={onRun} onClick={onRun}
disabled={busy} disabled={busy}
className="rounded-sm border border-border/60 px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-accent disabled:opacity-40" // Destructive red (owner, polish round) — the
// app's established destructive pair (chat's
// refusal banner): border + text, fill on hover.
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-40"
> >
{busy ? "Deleting…" : "Delete"} {busy ? "Deleting…" : "Delete"}
</button> </button>
+6
View File
@@ -868,6 +868,11 @@ export class GraphCanvasRenderer {
}; };
private onPointerDown = (e: PointerEvent) => { private onPointerDown = (e: PointerEvent) => {
// Primary button only (owner, polish round): a right-press must not
// pin the node — right-click belongs to the context menu alone, and
// the right-pointerup must never read as a click (the detail card
// used to open under the menu).
if (e.button !== 0) return;
const pos = this.eventPos(e); const pos = this.eventPos(e);
const node = this.pickAt(pos.x, pos.y); const node = this.pickAt(pos.x, pos.y);
if (node && this.sim) { if (node && this.sim) {
@@ -898,6 +903,7 @@ export class GraphCanvasRenderer {
}; };
private onPointerUp = (e: PointerEvent) => { private onPointerUp = (e: PointerEvent) => {
if (e.button !== 0) return; // right-click is context-menu territory
// Node press ending: click (no slop) → select; drag → release-and-relax. // Node press ending: click (no slop) → select; drag → release-and-relax.
if (this.dragId) { if (this.dragId) {
const id = this.dragId; const id = this.dragId;
+2 -2
View File
@@ -1241,7 +1241,7 @@ export function GraphView() {
anchorRef={ctxAnchorRef} anchorRef={ctxAnchorRef}
testid="node-context-menu" testid="node-context-menu"
preferredSide="down" preferredSide="down"
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl" menuClass="flex w-fit flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
> >
<button <button
type="button" type="button"
@@ -1252,7 +1252,7 @@ export function GraphView() {
}} }}
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground" className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
> >
Delete… Delete
</button> </button>
</MenuShell> </MenuShell>
)} )}
+6 -1
View File
@@ -246,7 +246,12 @@ export function MenuShell({
tabIndex={-1} tabIndex={-1}
onKeyDown={onBodyKeyDown} onKeyDown={onBodyKeyDown}
style={{ position: "fixed", zIndex: 60, ...placed.style, maxHeight: placed.maxHeight, overflowY: "auto" }} style={{ position: "fixed", zIndex: 60, ...placed.style, maxHeight: placed.maxHeight, overflowY: "auto" }}
className={menuClass} // The body is the arrow machine's focus home (input-less menus
// land focus here on open) — the focus STAYS, but the browser's
// default blue ring around it must not draw (owner, polish
// round). Arrow cycling moves DOM focus to the buttons, which
// carry their own hover/highlight; the ring is pure noise.
className={`${menuClass} outline-none`}
> >
{children} {children}
</div>, </div>,
+10 -4
View File
@@ -284,10 +284,16 @@ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; on
return ( return (
<div <div
data-testid="session-rename-row" data-testid="session-rename-row"
className="relative mb-0.5 flex w-full items-center gap-2 rounded-lg bg-accent px-3 py-2" className="relative mb-0.5 flex w-full items-center rounded-lg bg-accent px-3 py-2"
> >
<span aria-hidden className="w-3.5 shrink-0" /> {/* NOTHING MOVES (owner, polish round): an idle row renders no
<span className="flex min-w-0 flex-1 flex-col"> status spacer (SessionStatus is null when not busy), so the
title starts at the row's own px-3 — 12px in. The old rename
row always carried a 14px spacer + gap + the input's border
and padding, landing the text ~41px in (and the meta with
it). The input here is borderless and padding-free at the
exact title x; the underline is the only editing cue. */}
<span className="flex min-w-0 w-full flex-col">
<input <input
ref={inputRef} ref={inputRef}
value={draft} value={draft}
@@ -303,7 +309,7 @@ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; on
}} }}
aria-label="Rename session" aria-label="Rename session"
data-testid="session-rename-input" data-testid="session-rename-input"
className="lh-input w-full min-w-0 truncate rounded-sm border border-border bg-background px-1.5 font-prose text-(length:--lh-prose-size) text-foreground outline-none" className="lh-input w-full min-w-0 truncate border-0 border-b border-border bg-transparent px-0 text-foreground outline-none"
/> />
<span className="block truncate text-xs text-muted-foreground">{sessionMeta(chat)}</span> <span className="block truncate text-xs text-muted-foreground">{sessionMeta(chat)}</span>
</span> </span>
+7 -1
View File
@@ -73,7 +73,11 @@ describe("DeleteNodeDialog (thin SSR)", () => {
it("the body at loading: the frame + the pre-flight read pending (effects never fire in SSR — the wrapper's own state)", () => { 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"); const html = bodyAt("loading");
assert.match(html, /data-testid="delete-node-dialog"/); assert.match(html, /data-testid="delete-node-dialog"/);
assert.match(html, /Delete.*Kepler.*three laws.*\?/); // the title names the node // (owner, polish round) the serif title is just "Delete node?" in
// destructive red; the NAME rides below in JetBrains Mono code.
assert.match(html, /Delete node\?/);
assert.match(html, /text-destructive/); // the red title
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"/); assert.match(html, /data-testid="node-impact-loading"/);
}); });
@@ -94,6 +98,8 @@ describe("DeleteNodeDialog (thin SSR)", () => {
assert.match(html, /chat_ghost/); assert.match(html, /chat_ghost/);
assert.match(html, /cannot be undone/); assert.match(html, /cannot be undone/);
assert.match(html, /data-testid="node-delete-confirm"/); 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", () => { it("the body at failure: the 409 renders the activity-dock line with a Try-again", () => {
+8
View File
@@ -40,6 +40,14 @@ describe("RenameRow — the inline rename surface", () => {
assert.ok(html.includes(`value="${chat.title}"`)); assert.ok(html.includes(`value="${chat.title}"`));
// the meta line rides along (row never changes height mid-edit) // the meta line rides along (row never changes height mid-edit)
assert.ok(html.includes("session-rename-row")); assert.ok(html.includes("session-rename-row"));
// (owner, polish round) NOTHING MOVES: no status spacer, no input
// border/padding — the draft text sits at the idle row's title x,
// and the input is the ONLY shift-proof editing cue (underline).
assert.ok(!html.includes("w-3.5 shrink-0"), "no always-on spacer (idle rows have none)");
const input = html.split('data-testid="session-rename-input"')[1]?.split("/>")[0] ?? "";
assert.ok(input.includes("px-0"), "input carries no left padding");
assert.ok(input.includes("border-b"), "the underline is the editing cue");
assert.ok(!input.includes("bg-background"), "no filled field box — text stays put");
}); });
}); });