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()}
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" && (
<p className="mt-2 text-sm text-muted-foreground" data-testid="node-impact-loading">
Reading what this would remove…
@@ -158,7 +170,10 @@ export function DeleteNodeBody({
data-testid="node-delete-confirm"
onClick={onRun}
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"}
</button>
+6
View File
@@ -868,6 +868,11 @@ export class GraphCanvasRenderer {
};
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 node = this.pickAt(pos.x, pos.y);
if (node && this.sim) {
@@ -898,6 +903,7 @@ export class GraphCanvasRenderer {
};
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.
if (this.dragId) {
const id = this.dragId;
+2 -2
View File
@@ -1241,7 +1241,7 @@ export function GraphView() {
anchorRef={ctxAnchorRef}
testid="node-context-menu"
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
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"
>
Delete…
Delete
</button>
</MenuShell>
)}
+6 -1
View File
@@ -246,7 +246,12 @@ export function MenuShell({
tabIndex={-1}
onKeyDown={onBodyKeyDown}
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}
</div>,
+10 -4
View File
@@ -284,10 +284,16 @@ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; on
return (
<div
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" />
<span className="flex min-w-0 flex-1 flex-col">
{/* NOTHING MOVES (owner, polish round): an idle row renders no
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
ref={inputRef}
value={draft}
@@ -303,7 +309,7 @@ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; on
}}
aria-label="Rename session"
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>
+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)", () => {
const html = bodyAt("loading");
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"/);
});
@@ -94,6 +98,8 @@ describe("DeleteNodeDialog (thin SSR)", () => {
assert.match(html, /chat_ghost/);
assert.match(html, /cannot be undone/);
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", () => {
+8
View File
@@ -40,6 +40,14 @@ describe("RenameRow — the inline rename surface", () => {
assert.ok(html.includes(`value="${chat.title}"`));
// the meta line rides along (row never changes height mid-edit)
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");
});
});