layered Esc: an open overlay claims Esc before the abort chain (owner 2026-10-04)
Esc over a mermaid focus, the plan popup, or the setup dialog opened the question-dismiss confirm underneath — KeyboardShortcuts' window listener registers first, so the overlays' own self-closing Esc listeners could never outrun the lh:kbd-abort-turn dispatch. The state gate in ChatView is the defense (the architecture's own pattern from the abort dialog): escAbortTarget, exported pure, rules priority by stacking order — abort confirm (claimed by its dialog) > mermaid (paints above plan) > plan > setup (self-closing) > question > turn. The gate closes the overlay; the overlays' own listeners stay (idempotent). Pinned in esc-layering.test.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { escAbortTarget } from "../components/chat.tsx";
|
||||
|
||||
/**
|
||||
* THE LAYERED ESC (owner report, 2026-10-04): Esc over an open overlay —
|
||||
* a mermaid focus, the plan popup, the setup dialog — must CLOSE THE
|
||||
* OVERLAY, never dismiss a pending question (or abort a turn) underneath.
|
||||
* KeyboardShortcuts dispatches lh:kbd-abort-turn on every unclaimed Esc
|
||||
* and its window listener registers FIRST, so the overlays' own listeners
|
||||
* can never outrun it — the state gate in ChatView (this ruling, exported
|
||||
* pure) is the defense. Priority = stacking order.
|
||||
*/
|
||||
describe("escAbortTarget (the layered-Esc ruling)", () => {
|
||||
const none = { abortAsk: false, mermaidFocus: false, planOpen: false, setupOpen: false, pending: false, busy: false };
|
||||
|
||||
it("no overlays: the 2026-10-02 chain stands (question before turn)", () => {
|
||||
assert.equal(escAbortTarget({ ...none, pending: true }), "question");
|
||||
assert.equal(escAbortTarget({ ...none, busy: true }), "turn");
|
||||
assert.equal(escAbortTarget(none), null);
|
||||
assert.equal(escAbortTarget({ ...none, pending: true, busy: true }), "question", "a pending question outranks the turn");
|
||||
});
|
||||
|
||||
it("an open overlay claims Esc over a pending question — the owner's report", () => {
|
||||
for (const overlay of ["mermaidFocus", "planOpen", "setupOpen"] as const) {
|
||||
const target = escAbortTarget({ ...none, [overlay]: true, pending: true });
|
||||
assert.notEqual(target, "question", `${overlay} open must not dismiss the question`);
|
||||
assert.notEqual(target, "turn", `${overlay} open must not abort the turn`);
|
||||
}
|
||||
assert.equal(escAbortTarget({ ...none, mermaidFocus: true, pending: true }), "mermaid");
|
||||
assert.equal(escAbortTarget({ ...none, planOpen: true, pending: true }), "plan");
|
||||
assert.equal(escAbortTarget({ ...none, setupOpen: true, pending: true }), "setup");
|
||||
});
|
||||
|
||||
it("the abort confirm is topmost — its own Esc is claimed by the dialog", () => {
|
||||
assert.equal(
|
||||
escAbortTarget({ ...none, abortAsk: true, mermaidFocus: true, pending: true }),
|
||||
null,
|
||||
"never chain: the dialog owns that Esc",
|
||||
);
|
||||
});
|
||||
|
||||
it("stacking order among overlays: mermaid paints above plan (later in DOM)", () => {
|
||||
assert.equal(escAbortTarget({ ...none, mermaidFocus: true, planOpen: true, pending: true }), "mermaid");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user