yukey

Merge branch 'move': the kit, testkit, and e2e suite carried in

commit 56b0416af1d1561582469fa446c07edaa5b7523a · parent 84d8916957 · parent 5c002c2529 · browse this tree
Ximianth, 2026-10-10 05:15:45 UTC

diff --git a/packages/e2e/harness.ts b/packages/e2e/harness.ts
new file mode 100644
index 0000000..2a50241
--- /dev/null
+++ b/packages/e2e/harness.ts
@@ -0,0 +1,194 @@
+// The e2e harness: serves the built gallery bundle statically and launches a
+// fresh headless Chrome for one suite run, and tears both down unconditionally.
+//
+// Scenarios know nothing about process management: they receive a connected
+// CDP client and the server's base URL, navigate themselves, and assert.
+// Everything here is per-run and ephemeral: fresh Chrome profile, ephemeral
+// port for the static server, no shared state with anything else.
+
+import { type ChildProcess, spawn } from "node:child_process";
+import { accessSync, constants, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { createServer, type Server } from "node:http";
+import { tmpdir } from "node:os";
+import { extname, join, normalize } from "node:path";
+import { Cdp } from "yukey-testkit/cdp";
+import { sleep } from "yukey-testkit/sleep";
+
+// The gallery's static dir, where `npm run build` in packages/yukey puts the bundle
+// beside gallery.html (resolved from this file's location, packages/e2e/harness.ts).
+const STATIC_DIR = join(import.meta.dirname, "..", "yukey", "static");
+
+// The Chrome binary: headless-shell by default (what this machine has),
+// overridable for other environments.
+const CHROME_BIN = process.env.YUKEY_E2E_CHROME ?? "/headless-shell/headless-shell";
+
+// The viewport for every scenario: realistic size, never a peephole.
+const WINDOW_SIZE = "2000,1100";
+
+const MIME: Record<string, string> = {
+	".html": "text/html; charset=utf-8",
+	".js": "text/javascript; charset=utf-8",
+};
+
+async function pollUntil(
+	what: string,
+	timeoutMs: number,
+	probe: () => Promise<boolean>,
+): Promise<void> {
+	const deadline = Date.now() + timeoutMs;
+	while (Date.now() < deadline) {
+		if (await probe()) return;
+		await sleep(100);
+	}
+	throw new Error(`timed out waiting for ${what}`);
+}
+
+// Serves STATIC_DIR read-only on an ephemeral port.  Paths are normalized and
+// pinned under STATIC_DIR; anything else is a 404 (a test server, not a product).
+function serveStatic(): Promise<{ baseUrl: string; server: Server }> {
+	const srv = createServer((req, res) => {
+		const rel = normalize(decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname));
+		const file = join(STATIC_DIR, rel === "/" ? "gallery.html" : rel);
+		if (!file.startsWith(STATIC_DIR)) {
+			res.writeHead(404).end();
+			return;
+		}
+		try {
+			const body = readFileSync(file);
+			res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
+			res.end(body);
+		} catch {
+			res.writeHead(404).end();
+		}
+	});
+	return new Promise((resolve, reject) => {
+		srv.listen(0, "127.0.0.1", () => {
+			const addr = srv.address();
+			if (addr === null || typeof addr === "string") {
+				reject(new Error("static server: unexpected address shape"));
+				return;
+			}
+			resolve({ baseUrl: `http://127.0.0.1:${addr.port}`, server: srv });
+		});
+		srv.on("error", reject);
+	});
+}
+
+export class Harness {
+	readonly baseUrl: string;
+	readonly cdp: Cdp;
+	private readonly server: Server;
+	private readonly chrome: ChildProcess;
+	private readonly profileDir: string;
+	private stopped = false;
+
+	private constructor(
+		baseUrl: string,
+		cdp: Cdp,
+		server: Server,
+		chrome: ChildProcess,
+		profileDir: string,
+	) {
+		this.baseUrl = baseUrl;
+		this.cdp = cdp;
+		this.server = server;
+		this.chrome = chrome;
+		this.profileDir = profileDir;
+	}
+
+	static async start(): Promise<Harness> {
+		// Preflight: the bundle and the browser must exist before anything is spawned.
+		try {
+			accessSync(join(STATIC_DIR, "gallery.js"), constants.R_OK);
+		} catch {
+			throw new Error(`gallery bundle missing at ${STATIC_DIR}; run \`make build\` first`);
+		}
+		try {
+			accessSync(CHROME_BIN, constants.X_OK);
+		} catch {
+			throw new Error(
+				`chrome binary missing at ${CHROME_BIN}; ` +
+					"install headless-shell there, or point YUKEY_E2E_CHROME at a chromium binary",
+			);
+		}
+
+		const { baseUrl, server } = await serveStatic();
+		const profileDir = mkdtempSync(join(tmpdir(), "yukey-e2e-chrome-"));
+		const chrome = spawn(
+			CHROME_BIN,
+			[
+				"--headless",
+				"--no-sandbox",
+				"--disable-dev-shm-usage",
+				`--window-size=${WINDOW_SIZE}`,
+				"--remote-debugging-port=0",
+				`--user-data-dir=${profileDir}`,
+				"about:blank",
+			],
+			// Its own process group, so teardown can kill the whole Chrome tree at once.
+			{ stdio: "ignore", detached: true },
+		);
+
+		const kill = () => {
+			server.close();
+			if (chrome.pid !== undefined) {
+				try {
+					process.kill(-chrome.pid, "SIGKILL");
+				} catch {}
+			}
+			rmSync(profileDir, { recursive: true, force: true });
+		};
+
+		try {
+			// Chrome publishes its chosen debugging port in the profile dir.
+			let debugPort = 0;
+			await pollUntil("chrome DevToolsActivePort", 15000, async () => {
+				try {
+					const first = readFileSync(join(profileDir, "DevToolsActivePort"), "utf8").split("\n")[0];
+					debugPort = Number(first);
+					return Number.isInteger(debugPort) && debugPort > 0;
+				} catch {
+					return false;
+				}
+			});
+
+			// Find the page target and connect.
+			let wsUrl = "";
+			await pollUntil("chrome page target", 15000, async () => {
+				try {
+					const r = await fetch(`http://127.0.0.1:${debugPort}/json`);
+					const targets = (await r.json()) as { type: string; webSocketDebuggerUrl?: string }[];
+					const page = targets.find((t) => t.type === "page");
+					if (page?.webSocketDebuggerUrl === undefined) return false;
+					wsUrl = page.webSocketDebuggerUrl;
+					return true;
+				} catch {
+					return false;
+				}
+			});
+
+			const cdp = await Cdp.connect(wsUrl);
+			return new Harness(baseUrl, cdp, server, chrome, profileDir);
+		} catch (err) {
+			kill();
+			throw err;
+		}
+	}
+
+	// Idempotent, unconditional teardown: close the socket, stop the server,
+	// kill the Chrome process tree, remove the profile dir.
+	stop(): void {
+		if (this.stopped) return;
+		this.stopped = true;
+		try {
+			this.cdp.close();
+		} catch {}
+		this.server.close();
+		if (this.chrome.pid !== undefined) {
+			try {
+				process.kill(-this.chrome.pid, "SIGKILL");
+			} catch {}
+		}
+		rmSync(this.profileDir, { recursive: true, force: true });
+	}
+}
diff --git a/packages/e2e/main.ts b/packages/e2e/main.ts
new file mode 100644
index 0000000..77a8c29
--- /dev/null
+++ b/packages/e2e/main.ts
@@ -0,0 +1,66 @@
+// The e2e suite entrypoint: `npm run test:e2e` (or `make check-e2e` from the root).
+//
+// One harness per run (the gallery bundle served statically + a fresh headless Chrome),
+// all scenarios against it, unconditional teardown, non-zero exit on any failure.
+// Scenario names as CLI args select a subset: `npm run test:e2e -- mc ip`.
+
+import { Checks, type Scenario } from "yukey-testkit/checks";
+import { Harness } from "./harness.ts";
+import { ipDrive } from "./scenarios/ip-drive.ts";
+import { lcDrive } from "./scenarios/lc-drive.ts";
+import { mcDrive } from "./scenarios/mc-drive.ts";
+import { swDrive } from "./scenarios/sw-drive.ts";
+
+const ALL: Scenario[] = [mcDrive, swDrive, ipDrive, lcDrive];
+
+async function main(): Promise<void> {
+	const wanted = process.argv.slice(2);
+	const unknown = wanted.filter((w) => !ALL.some((s) => s.name === w));
+	if (unknown.length > 0) {
+		console.error(
+			`unknown scenarios: ${unknown.join(", ")} (have: ${ALL.map((s) => s.name).join(", ")})`,
+		);
+		process.exit(2);
+	}
+	const chosen = wanted.length > 0 ? ALL.filter((s) => wanted.includes(s.name)) : ALL;
+
+	const harness = await Harness.start();
+	process.on("SIGINT", () => {
+		harness.stop();
+		process.exit(130);
+	});
+	process.on("SIGTERM", () => {
+		harness.stop();
+		process.exit(143);
+	});
+
+	let failedScenarios = 0;
+	const summary: string[] = [];
+	try {
+		for (const scenario of chosen) {
+			console.log(`\n=== ${scenario.name} ===`);
+			const checks = new Checks();
+			let aborted = false;
+			try {
+				await scenario.run({ cdp: harness.cdp, baseUrl: harness.baseUrl, checks });
+			} catch (err) {
+				console.log(`ABORT ${scenario.name}: ${err instanceof Error ? err.message : String(err)}`);
+				aborted = true;
+			}
+			console.log(`${scenario.name}: ${checks.passed}/${checks.total} checks passed`);
+			summary.push(
+				`${scenario.name} ${checks.passed}/${checks.total}${aborted ? " (aborted)" : ""}`,
+			);
+			if (aborted || checks.failed > 0) failedScenarios += 1;
+		}
+	} finally {
+		harness.stop();
+	}
+	console.log(`\ntotal: ${summary.join(", ")}`);
+	process.exit(failedScenarios > 0 ? 1 : 0);
+}
+
+main().catch((err) => {
+	console.error(err);
+	process.exit(1);
+});
diff --git a/packages/e2e/scenarios/ip-drive.ts b/packages/e2e/scenarios/ip-drive.ts
new file mode 100644
index 0000000..e3e93db
--- /dev/null
+++ b/packages/e2e/scenarios/ip-drive.ts
@@ -0,0 +1,322 @@
+// Drives the sw-infopop gallery entry with TRUSTED input via CDP: keys via
+// Input.dispatchKeyEvent and real mouse movement via Input.dispatchMouseEvent
+// (synthetic pointerover can't prove real hover).  Covers: keyboard i on every
+// row flavor (sw-button's own claim, the plain-row/slider/aux data-info
+// fallback), the i toggle (second i on the same anchor dismisses; scope is one
+// keypress), hover raising the SAME content, pin precedence over hover (incl.
+// i upgrading a hover popup to pinned), dismissal (nav keys close AND navigate,
+// pointerdown closes, no resurrect), aria reflection wiring, edge clamping,
+// and the bottom flip (short-viewport emulation).
+// Ported from the Python original (demoscene.meta screenshots, stage-infopop).
+
+import { type Scenario, type ScenarioContext, shot } from "yukey-testkit/checks";
+import { openGallery } from "yukey-testkit/gallery";
+import { sleep } from "yukey-testkit/sleep";
+
+// State through the shadow roots: the popup's mode/content/position, the demo
+// column's focused row and log, and the aria reflection on the current anchor.
+const STATE_JS = `
+(() => {
+  const shell = document.querySelector("sw-gallery").shadowRoot;
+  const pop = shell.querySelector("sw-infopop");
+  const col = shell.querySelector("swg-infopop-column")?.shadowRoot;
+  const rect = pop.getBoundingClientRect();
+  const anchorRect = pop.anchor?.getBoundingClientRect() ?? null;
+  return JSON.stringify({
+    mode: pop.mode,
+    text: pop.shadowRoot.textContent.trim(),
+    visible: pop.style.visibility === "visible",
+    ariaHidden: pop.getAttribute("aria-hidden"),
+    popRect: {x: rect.x, y: rect.y, w: rect.width, h: rect.height},
+    anchorRect: anchorRect ? {x: anchorRect.x, y: anchorRect.y, w: anchorRect.width, h: anchorRect.height} : null,
+    anchorAriaWired: pop.anchor ? (pop.anchor.ariaDescribedByElements?.[0] === pop) : null,
+    rowFocused: col?.querySelector(".mc-row-focused")?.dataset?.mcRow ?? null,
+    log: col?.querySelector(".log")?.textContent ?? "",
+    viewport: {w: window.innerWidth, h: window.innerHeight},
+  });
+})()
+`;
+
+interface Rect {
+	x: number;
+	y: number;
+	w: number;
+	h: number;
+}
+
+interface IpState {
+	mode: string;
+	text: string;
+	visible: boolean;
+	ariaHidden: string | null;
+	popRect: Rect;
+	anchorRect: Rect | null;
+	anchorAriaWired: boolean | null;
+	rowFocused: string | null;
+	log: string;
+	viewport: { w: number; h: number };
+}
+
+async function run(ctx: ScenarioContext): Promise<void> {
+	const { cdp, checks } = ctx;
+	const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail);
+	const st = async (): Promise<IpState> => JSON.parse((await cdp.js(STATE_JS)) as string);
+	const centerOf = async (jsElExpr: string): Promise<{ x: number; y: number }> => {
+		const box = await cdp.js(`
+		(() => {
+		  const el = ${jsElExpr};
+		  const r = el.getBoundingClientRect();
+		  return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2});
+		})()
+		`);
+		return JSON.parse(box as string);
+	};
+
+	const COL =
+		"document.querySelector('sw-gallery').shadowRoot.querySelector('swg-infopop-column').shadowRoot";
+
+	await openGallery(
+		ctx,
+		"#sw-infopop",
+		"document.readyState === 'complete' && !!document.querySelector('sw-gallery')?.shadowRoot?.querySelector('swg-infopop-column')",
+	);
+
+	// Park the mouse in dead space so no hover state lingers between phases.
+	await cdp.mouseMove(5, 5);
+
+	// --- keyboard: i on every row flavor ---
+	let landed = false;
+	for (let i = 0; i < 12; i++) {
+		await cdp.key("Tab");
+		const focused = await cdp.js(
+			`${COL}.querySelector('[data-mc-container]').classList.contains('mc-focus-within')`,
+		);
+		if (focused === true) {
+			landed = true;
+			break;
+		}
+	}
+	let s = await st();
+	check("tab-into-column", landed && s.rowFocused === "dock", JSON.stringify(s.rowFocused));
+
+	await cdp.key("i");
+	s = await st();
+	check("i-on-sw-button-pins", s.mode === "pinned" && s.visible, JSON.stringify(s));
+	check("i-on-sw-button-content", s.text.includes("an sw-button answers i itself"), s.text);
+	check("aria-reflection-wired", s.anchorAriaWired === true, JSON.stringify(s.anchorAriaWired));
+	check(
+		"popup-below-anchor",
+		s.anchorRect !== null && s.popRect.y > s.anchorRect.y,
+		JSON.stringify(s.popRect),
+	);
+	await shot(cdp, "kb-pinned.png");
+
+	// The toggle: i again on the same anchor dismisses (no blink-reopen)...
+	await cdp.key("i");
+	s = await st();
+	check("i-toggles-off-sw-button", s.mode === "closed", JSON.stringify(s.mode));
+	// ... and a fresh press raises it right back (suppression is one keypress).
+	await cdp.key("i");
+	s = await st();
+	check(
+		"i-reopens-sw-button",
+		s.mode === "pinned" && s.text.includes("an sw-button answers i itself"),
+		s.text,
+	);
+
+	// Navigation closes the popup AND still navigates (never eats keys).
+	await cdp.key("ArrowDown");
+	s = await st();
+	check("nav-key-closes", s.mode === "closed" && s.ariaHidden === "true", JSON.stringify(s.mode));
+	check("nav-key-still-navigates", s.rowFocused === "survey", JSON.stringify(s.rowFocused));
+
+	// Plain text row: the controller's data-info fallback answers.
+	await cdp.key("i");
+	s = await st();
+	check("i-on-plain-row", s.mode === "pinned" && s.text.includes("plain text row"), s.text);
+
+	// The toggle again, through the other producer (the controller fallback).
+	await cdp.key("i");
+	s = await st();
+	check("i-toggles-off-plain-row", s.mode === "closed", JSON.stringify(s.mode));
+	await cdp.key("i");
+	s = await st();
+	check("i-reopens-plain-row", s.mode === "pinned" && s.text.includes("plain text row"), s.text);
+
+	// One keypress closes the old popup and raises the next (i on the next row).
+	await cdp.key("ArrowDown");
+	await cdp.key("i");
+	s = await st();
+	check("i-moves-with-focus", s.mode === "pinned" && s.text.includes("slider row"), s.text);
+
+	// The slider still works under/after the popup: left/right adjust.
+	await cdp.key("ArrowRight");
+	s = await st();
+	check(
+		"slider-key-closes-and-acts",
+		s.mode === "closed" && s.log === "reactor power 45%",
+		JSON.stringify({ mode: s.mode, log: s.log }),
+	);
+
+	// The aux x carries its own story, distinct from its row's.
+	await cdp.key("ArrowDown");
+	await cdp.key("i");
+	s = await st();
+	check("i-on-two-control-main", s.text.includes("probe telemetry feed"), s.text);
+	await cdp.key("ArrowRight");
+	await cdp.key("i");
+	s = await st();
+	check("i-on-aux-x", s.text.includes("the x's own story"), s.text);
+
+	// Escape closes; nothing resurrects.
+	await cdp.key("Escape");
+	await sleep(800);
+	s = await st();
+	check("escape-closes-no-resurrect", s.mode === "closed", JSON.stringify(s.mode));
+
+	// --- hover: the same content, raised by the mouse ---
+	let pt = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`);
+	await cdp.mouseMove(pt.x, pt.y);
+	await sleep(150);
+	s = await st();
+	check("hover-waits-out-the-delay", s.mode === "closed", JSON.stringify(s.mode));
+	await sleep(500);
+	s = await st();
+	check(
+		"hover-raises-same-content",
+		s.mode === "hover" && s.text.includes("plain text row"),
+		s.text,
+	);
+	await shot(cdp, "hover-same-content.png");
+
+	// Hover follows to the aux x (nearest carrier wins over the row's).
+	pt = await centerOf(`${COL}.querySelector('[data-mc-aux]')`);
+	await cdp.mouseMove(pt.x, pt.y);
+	await sleep(500);
+	s = await st();
+	check(
+		"hover-nearest-carrier",
+		s.mode === "hover" && s.text.includes("the x's own story"),
+		s.text,
+	);
+
+	// Leaving all carriers closes.
+	await cdp.mouseMove(5, 5);
+	await sleep(100);
+	s = await st();
+	check("hover-leave-closes", s.mode === "closed", JSON.stringify(s.mode));
+
+	// --- precedence: a pinned popup outranks hover ---
+	// (Real focus is still on the column from the keyboard phase.)
+	await cdp.key("Home");
+	await cdp.key("i");
+	s = await st();
+	check(
+		"pin-for-precedence",
+		s.mode === "pinned" && s.text.includes("an sw-button answers i itself"),
+		s.text,
+	);
+	pt = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`);
+	await cdp.mouseMove(pt.x, pt.y);
+	await sleep(600);
+	s = await st();
+	check(
+		"hover-never-displaces-pin",
+		s.mode === "pinned" && s.text.includes("an sw-button answers i itself"),
+		s.text,
+	);
+	await shot(cdp, "pin-outranks-hover.png");
+	// After the pin closes, the parked hover does not resurrect...
+	await cdp.key("Escape");
+	await sleep(600);
+	s = await st();
+	check("no-hover-resurrect-after-pin", s.mode === "closed", JSON.stringify(s.mode));
+	// ... but a fresh pointer move re-arms normally.
+	await cdp.mouseMove(pt.x, pt.y + 1);
+	await cdp.mouseMove(5, 5);
+	await sleep(50);
+	const pt2 = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`);
+	await cdp.mouseMove(pt2.x, pt2.y);
+	await sleep(500);
+	s = await st();
+	check("fresh-hover-re-arms", s.mode === "hover", JSON.stringify(s.mode));
+
+	// i on the element the mouse is hovering PINS the hover popup (only pinned
+	// popups toggle off).  Focus must sit on the same row for i to answer.
+	await cdp.key("ArrowDown"); // focus the survey row; the keydown closes the hover popup
+	await cdp.mouseMove(5, 5);
+	await sleep(50);
+	await cdp.mouseMove(pt2.x, pt2.y); // a fresh move re-raises hover on survey
+	await sleep(500);
+	s = await st();
+	check("hover-up-for-upgrade", s.mode === "hover", JSON.stringify(s.mode));
+	await cdp.key("i");
+	s = await st();
+	check(
+		"i-upgrades-hover-to-pin",
+		s.mode === "pinned" && s.text.includes("plain text row"),
+		s.text,
+	);
+	// And the freshly pinned popup toggles off like any other; reopen so the
+	// pointerdown check below still closes something real.
+	await cdp.key("i");
+	s = await st();
+	check("upgraded-pin-toggles-off", s.mode === "closed", JSON.stringify(s.mode));
+	await cdp.key("i");
+	s = await st();
+	check("i-reopens-before-click", s.mode === "pinned", JSON.stringify(s.mode));
+
+	// Pointerdown closes (trusted click in dead space).
+	await cdp.click(5, 5);
+	s = await st();
+	check("pointerdown-closes", s.mode === "closed", JSON.stringify(s.mode));
+
+	// --- edges: clamping and the flip ---
+	const EDGES =
+		"document.querySelector('sw-gallery').shadowRoot.querySelector('swg-infopop-edges').shadowRoot";
+	pt = await centerOf(`${EDGES}.querySelectorAll('sw-button')[1]`); // far right
+	await cdp.mouseMove(pt.x, pt.y);
+	await sleep(500);
+	s = await st();
+	const anchor = s.anchorRect as Rect;
+	const anchorCenter = anchor.x + anchor.w / 2;
+	const centeredX = anchorCenter - s.popRect.w / 2;
+	check(
+		"right-edge-clamps",
+		s.mode === "hover" && s.popRect.x + s.popRect.w <= s.viewport.w && s.popRect.x < centeredX - 1,
+		JSON.stringify({ pop: s.popRect, viewport: s.viewport }),
+	);
+	await shot(cdp, "edge-clamp.png");
+	await cdp.mouseMove(5, 5);
+
+	// The flip: shrink the viewport so the edges row sits near the bottom,
+	// then hover it -- the popup must open ABOVE the anchor.
+	const targetH = await cdp.js(`
+	(() => {
+	  const el = ${EDGES}.querySelectorAll('sw-button')[0];
+	  return Math.round(el.getBoundingClientRect().bottom + 20);
+	})()
+	`);
+	await cdp.cmd("Emulation.setDeviceMetricsOverride", {
+		width: 1280,
+		height: Number(targetH),
+		deviceScaleFactor: 1,
+		mobile: false,
+	});
+	await sleep(200);
+	pt = await centerOf(`${EDGES}.querySelectorAll('sw-button')[0]`); // far left
+	await cdp.mouseMove(pt.x, pt.y);
+	await sleep(500);
+	s = await st();
+	check(
+		"bottom-edge-flips-above",
+		s.mode === "hover" && s.anchorRect !== null && s.popRect.y + s.popRect.h <= s.anchorRect.y,
+		JSON.stringify({ pop: s.popRect, anchor: s.anchorRect }),
+	);
+	check("left-edge-clamps", s.popRect.x >= 0, JSON.stringify(s.popRect));
+	await shot(cdp, "edge-flip.png");
+	await cdp.cmd("Emulation.clearDeviceMetricsOverride");
+	await cdp.mouseMove(5, 5);
+}
+
+export const ipDrive: Scenario = { name: "ip", run };
diff --git a/packages/e2e/scenarios/lc-drive.ts b/packages/e2e/scenarios/lc-drive.ts
new file mode 100644
index 0000000..fc4cbf4
--- /dev/null
+++ b/packages/e2e/scenarios/lc-drive.ts
@@ -0,0 +1,806 @@
+// Drives the sw-list gallery entry (the list region component composed into a
+// megacolumn console; sw-live-list under it) with TRUSTED input via CDP.
+//
+// The demos churn on their own 4s timers; the scenario STOPS those timers up
+// front and drives data churn via JS at chosen moments -- data churn is
+// programmatic in the real app too, and the thing under test is exactly the
+// race between that and trusted keyboard input.  Covers: navigation into and
+// through live-list rows, ghost navigability (Enter-on-ghost inspects; aux
+// gone), aux removal, x/Delete (each flashing the x as acknowledgment), the
+// key-activity pin (holds the FOCUSED ghost through a sub-200ms key burst;
+// decays keyPinMs=200 after the last key; an UNFOCUSED ghost collapses on its
+// own schedule despite the same typing), the pointerSafety split (click-guard,
+// the default: hovering defers nothing and a trusted click on a row displaced
+// by an in-flight collapse is swallowed while rows above the motion stay
+// clickable; freeze, the opt-in: the global hover pin, pending insertions, and
+// the pointer-leave release), the churn lockout (an Enter landing right after
+// a fallback move is DROPPED; flows again after the window), instant prepend
+// under key burst (p), focus memory across Tab-away, the drain-to-empty +
+// revive story, and the LIST NORMS, sw-list's own behavior: the header row
+// (a nav stop whose focus washes the whole list region) and the add-row
+// (a dives straight into text entry -- the direct marking on a text row;
+// Enter commits via the cancelable sw-list-add event; Escape abandons), and
+// the LIST-SCOPED KEYS: t (back to the list's header) and n (past the list),
+// claimed by the sw-list host element off the bubbling megacolumn-key dispatch
+// and answered with megacolumn-focus-request; outside the list's rows the
+// letters are nobody's.  Structural row ids are name-prefixed by sw-list
+// (name="queue": queue-header, queue-add).
+// Ported from the Python original (demoscene.meta screenshots, stage-livecolumn,
+// updated in place by stage-list-component).
+
+import { type Scenario, type ScenarioContext, shot } from "yukey-testkit/checks";
+import { openGallery } from "yukey-testkit/gallery";
+import { sleep } from "yukey-testkit/sleep";
+
+// demo(i): the i-th swg-livecolumn-demo (0 = refill, 1 = drain).
+const demo = (i: number) =>
+	`document.querySelector('sw-gallery').shadowRoot.querySelectorAll('swg-livecolumn-demo')[${i}]`;
+
+// Full state of one demo column: every rendered rowslot with its phase-visible
+// attributes, the machine-visible focus (ring classes + aria), and the log line.
+const stateJs = (demoExpr: string) => `
+(() => {
+  const demo = ${demoExpr};
+  const root = demo.shadowRoot;
+  const container = root.querySelector('[data-mc-container]');
+  const rows = [...root.querySelectorAll('[data-key]')].map(el => ({
+    key: el.dataset.key,
+    mcRow: el.dataset.mcRow ?? null,
+    disabled: el.getAttribute('aria-hidden') === 'true', // "closed": aria-hidden is the one signal now
+    hidden: el.getAttribute('aria-hidden'),
+    committed: el.querySelector('.task')?.classList.contains('committed') ?? false,
+    hasAux: el.querySelector('[data-mc-aux]') !== null,
+  }));
+  return JSON.stringify({
+    rows,
+    aria: container.getAttribute('aria-activedescendant'),
+    focusWithin: container.classList.contains('mc-focus-within'),
+    emptyFocus: container.classList.contains('mc-empty-focus'),
+    textMode: container.classList.contains('mc-text-mode'),
+    inputValue: root.querySelector('.sw-list-add [data-mc-input]')?.value ?? null,
+    inputFocused: root.activeElement === root.querySelector('.sw-list-add [data-mc-input]'),
+    // The header-focus region wash: a computed background on the group wrapper
+    // (driven by the :has() rule in the demo's stylesheet, nothing script-side).
+    groupWashed: (() => {
+      const g = root.querySelector('sw-list');
+      if (g === null) return null;
+      const bg = getComputedStyle(g).backgroundColor;
+      return bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
+    })(),
+    groupOutline: root.querySelector('sw-list') === null ? null
+      : getComputedStyle(root.querySelector('sw-list')).outlineStyle,
+    rowFocused: root.querySelector('.mc-row-focused')?.dataset?.mcRow
+      ?? root.querySelector('.mc-row-focused')?.closest('[data-mc-row]')?.dataset?.mcRow ?? null,
+    auxFocused: root.querySelector('[data-mc-aux].mc-focused') !== null,
+    log: root.querySelector('.log')?.textContent ?? '',
+    idle: root.querySelector('.idle')?.textContent ?? null,
+    dataIds: demo.tasks.map(t => t.id),
+  });
+})()
+`;
+
+interface LcRow {
+	key: string;
+	mcRow: string | null;
+	disabled: boolean;
+	hidden: string | null;
+	committed: boolean;
+	hasAux: boolean;
+}
+
+interface LcState {
+	rows: LcRow[];
+	aria: string | null;
+	focusWithin: boolean;
+	emptyFocus: boolean;
+	textMode: boolean;
+	inputValue: string | null;
+	inputFocused: boolean;
+	groupWashed: boolean | null;
+	groupOutline: string | null;
+	rowFocused: string | null;
+	auxFocused: boolean;
+	log: string;
+	idle: string | null;
+	dataIds: string[];
+}
+
+async function run(ctx: ScenarioContext): Promise<void> {
+	const { cdp, checks } = ctx;
+	const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail);
+
+	await openGallery(
+		ctx,
+		"#sw-list",
+		"document.readyState === 'complete' && document.querySelector('sw-gallery')?.shadowRoot?.querySelectorAll('swg-livecolumn-demo')?.length === 2",
+	);
+
+	// Determinism: stop both demos' own churn timers; the scenario churns by hand.
+	await cdp.js(`clearInterval(${demo(0)}.timer); clearInterval(${demo(1)}.timer)`);
+
+	const st = async (i = 0): Promise<LcState> =>
+		JSON.parse((await cdp.js(stateJs(demo(i)))) as string);
+	const row = (s: LcState, key: string): LcRow | null => s.rows.find((r) => r.key === key) ?? null;
+
+	// Data-side churn, like the real queue does between renders.
+	const dropHead = async (i = 0): Promise<void> => {
+		await cdp.js(`const d = ${demo(i)}; d.tasks = d.tasks.slice(1)`);
+	};
+
+	await cdp.mouseMove(5, 5); // park the mouse: no hover pin in play unless asked
+
+	// --- phase 1: composition basics -------------------------------------
+	let landed = false;
+	for (let i = 0; i < 12; i++) {
+		await cdp.key("Tab");
+		if ((await st()).focusWithin) {
+			landed = true;
+			break;
+		}
+	}
+	let s = await st();
+	check("tab-into-column", landed && s.rowFocused === "prepend", s.rowFocused ?? "");
+
+	const first = s.dataIds[0] as string;
+	// One down from the prepend row is the LIST HEADER ROW: a normal nav stop
+	// that speaks for the region.  While it holds focus the whole list washes
+	// (sw-list's own :has() rule); the focus treatment stays on the header
+	// itself -- the sw-list host draws no outline of its own.
+	await cdp.key("ArrowDown");
+	s = await st();
+	check(
+		"header-row-is-a-nav-stop",
+		s.rowFocused === "queue-header" && s.aria === "mc-queue-header",
+		JSON.stringify([s.rowFocused, s.aria]),
+	);
+	check("header-focus-washes-region", s.groupWashed === true, JSON.stringify(s.groupWashed));
+	check("no-perimeter-outline-on-group", s.groupOutline === "none", s.groupOutline ?? "");
+	const ring = await cdp.js(`
+	  (() => { const h = ${demo(0)}.shadowRoot.querySelector('[data-mc-row="queue-header"]');
+	     return getComputedStyle(h).outlineStyle; })()
+	`);
+	check("ring-stays-on-header-row", ring === "solid", String(ring));
+	await shot(cdp, "list-header-focused.png");
+
+	await cdp.key("ArrowDown");
+	s = await st();
+	check(
+		"arrow-into-live-row",
+		s.rowFocused === first && s.aria === `mc-${first}`,
+		JSON.stringify([s.rowFocused, s.aria]),
+	);
+	check("wash-follows-header-only", s.groupWashed === false, JSON.stringify(s.groupWashed));
+
+	await cdp.key("ArrowRight");
+	s = await st();
+	check("right-reaches-aux", s.auxFocused, JSON.stringify(s.rows));
+
+	// Enter on the aux removes the task; the row ghosts in place, sheds its aux,
+	// STAYS focused (identity survives), and position snaps back to main.
+	// The row also FLASHES (sw-pressed) as instant acknowledgment: the fade to
+	// ghost alone was too quiet to confirm the keystroke landed.  The flash is
+	// 140ms, so a MutationObserver armed before the keystroke catches it.
+	await cdp.js(`
+	  (() => {
+	    const root = ${demo(0)}.shadowRoot;
+	    window.__flashSeen = 0;
+	    window.__flashMo = new MutationObserver(muts => {
+	      for (const m of muts) if (m.target.classList.contains('sw-pressed')) window.__flashSeen += 1;
+	    });
+	    window.__flashMo.observe(root.querySelector('sw-live-list'),
+	      { attributes: true, attributeFilter: ['class'], subtree: true });
+	  })()
+	`);
+	await cdp.key("Enter");
+	s = await st();
+	let g = row(s, first);
+	const flashes = await cdp.js("window.__flashMo.disconnect(), window.__flashSeen");
+	check("aux-enter-removes", s.log.includes("removed") && !s.dataIds.includes(first), s.log);
+	check("removal-flashes-the-row", typeof flashes === "number" && flashes >= 1, String(flashes));
+	check(
+		"ghost-in-place",
+		g !== null && g.mcRow === first && g.committed && !g.disabled,
+		JSON.stringify(g),
+	);
+	check("ghost-sheds-aux", g !== null && !g.hasAux, JSON.stringify(g));
+	check(
+		"ghost-keeps-focus",
+		s.rowFocused === first && !s.auxFocused,
+		JSON.stringify([s.rowFocused, s.auxFocused]),
+	);
+	await shot(cdp, "ghost-focused.png");
+
+	// Enter on the ghost still means something: inspect, not error, not removal.
+	await cdp.key("Enter");
+	s = await st();
+	check("enter-on-ghost-inspects", s.log.includes("inspected a completed task"), s.log);
+
+	// --- phase 2: the key-activity pin ------------------------------------
+	// The pin defers ONLY the focused row's collapse (the keyboard aims by
+	// identity; only the aimed-at row needs holding still), and it decays
+	// keyPinMs=200 after the last key -- so holding the ghost takes a burst
+	// at sub-200ms cadence, and the moment the fingers stop, the world moves.
+	// Focus is on the ghost now; work IT (Enter inspects, focus never leaves)
+	// well past holdMs=1200 from the removal above.
+	for (let i = 0; i < 16; i++) {
+		await cdp.key("Enter");
+		await sleep(100);
+	}
+	s = await st();
+	g = row(s, first);
+	check("key-pin-holds-focused-ghost", g !== null && !g.disabled, JSON.stringify(g));
+	await shot(cdp, "pin-held.png");
+
+	// Stop typing: the pin decays 200ms after the last key, the deferred
+	// collapse plays (300ms), and the fallback moves focus off the dying row.
+	await sleep(900);
+	s = await st();
+	check("pin-decay-collapses-ghost", row(s, first) === null, JSON.stringify(s.rows));
+	check(
+		"fallback-moved-focus",
+		s.rowFocused !== null && s.rowFocused !== first,
+		s.rowFocused ?? "",
+	);
+	await shot(cdp, "post-fallback.png");
+
+	// An UNFOCUSED ghost collapses on its natural schedule despite continuous
+	// typing (the semantics the focused-only pin buys): x the focused row (the
+	// ghost keeps focus), prove it is still a nav stop (arrow off and back on),
+	// then move OFF it and keep pressing keys at pin-holding cadence on a
+	// neighbor -- left/right cycle within a two-control row, so row focus
+	// never returns to the ghost.  It must collapse ~holdMs after its removal
+	// even though the pin never once decayed.
+	s = await st();
+	const bkey = s.rowFocused as string;
+	await cdp.key("x");
+	s = await st();
+	check(
+		"x-ghost-keeps-focus",
+		!s.dataIds.includes(bkey) && s.rowFocused === bkey,
+		JSON.stringify([s.rowFocused, s.log]),
+	);
+	await cdp.key("ArrowDown");
+	await cdp.key("ArrowUp");
+	s = await st();
+	check("ghost-is-a-nav-stop", s.rowFocused === bkey, s.rowFocused ?? "");
+	await cdp.key("ArrowDown"); // leave the ghost; it is nobody's aim from here on
+	let aliveEarly: boolean | null = null;
+	for (let i = 0; i < 18; i++) {
+		// ~1.9s of sub-200ms keys, spanning the ghost's 1.2s schedule
+		await cdp.key(i % 2 === 0 ? "ArrowRight" : "ArrowLeft");
+		await sleep(100);
+		if (i === 6) {
+			// ~0.8s in: before the schedule lands, the ghost still holds
+			s = await st();
+			g = row(s, bkey);
+			aliveEarly = g !== null && !g.disabled;
+		}
+	}
+	s = await st();
+	g = row(s, bkey);
+	const goneLate = g === null || g.disabled;
+	check("unfocused-ghost-holds-until-due", aliveEarly === true, JSON.stringify(aliveEarly));
+	check("unfocused-ghost-collapses-mid-typing", goneLate, JSON.stringify(g));
+	await sleep(600); // let the collapse finish before the next phase
+
+	// --- phase 3: the churn lockout ---------------------------------------
+	// Arrange: focus a row, drop it from the data (ghost), then wait for the
+	// collapse to START (aria-hidden appears; no keys during the wait, so the
+	// pin decays and only holdMs gates it) and immediately fire a trusted
+	// Enter: the fallback just moved focus, so the decisive key must be DROPPED.
+	s = await st();
+	const victim = s.rowFocused as string;
+	const logBefore = s.log;
+	await dropHead(0); // victim is the head (fallback landed there after phase 2)
+	{
+		let began = false;
+		for (let i = 0; i < 60; i++) {
+			s = await st();
+			g = row(s, victim);
+			if (g?.disabled) {
+				began = true;
+				break;
+			}
+			await sleep(40);
+		}
+		if (!began) throw new Error("victim never began collapsing");
+	}
+	await cdp.key("Enter");
+	s = await st();
+	check("lockout-drops-decisive-key", s.log === logBefore, JSON.stringify([s.log, logBefore]));
+	check(
+		"lockout-fallback-landed",
+		s.rowFocused !== null && s.rowFocused !== victim,
+		s.rowFocused ?? "",
+	);
+	// ... and after the 350ms window, decisive keys flow again.
+	await sleep(500);
+	await cdp.key("Enter");
+	s = await st();
+	check("post-lockout-key-flows", s.log.includes("inspected") && s.log !== logBefore, s.log);
+
+	// --- phase 4: x / Delete / instant prepend under burst -----------------
+	s = await st();
+	const target = s.rowFocused as string;
+	await cdp.key("x");
+	s = await st();
+	check("x-removes-from-main", s.log.includes("removed") && !s.dataIds.includes(target), s.log);
+
+	// p is the prepend button's column-scoped hotkey, marked DIRECT (a prepend
+	// is cheaply undone, the see-before-commit exception): it fires from ANY
+	// row, focus follows to the orders row, and the new head appears instantly
+	// (visible, not pending) although the key burst above has the key pin
+	// armed right now.
+	await cdp.key("p");
+	s = await st();
+	const newest = s.dataIds[0] as string;
+	const fresh = row(s, newest);
+	check("p-prepends-from-anywhere", s.log.includes("prepended"), s.log);
+	check("p-direct-moves-focus-to-actor", s.rowFocused === "prepend", s.rowFocused ?? "");
+	check(
+		"prepend-visible-under-pin",
+		fresh !== null && fresh.hidden === null && !fresh.disabled,
+		JSON.stringify(fresh),
+	);
+
+	// Focus landed on the orders row with the direct p; move to the fresh live
+	// task for the Delete test (Home = prepend row, two down = past the header
+	// to the new data head).
+	await cdp.key("Home");
+	await cdp.key("ArrowDown");
+	await cdp.key("ArrowDown");
+	s = await st();
+	const deltarget = s.rowFocused as string;
+	await cdp.key("Delete");
+	s = await st();
+	check("delete-removes", s.log.includes("removed") && !s.dataIds.includes(deltarget), s.log);
+
+	// --- phase 5: click-guard, the default pointer story ---------------------
+	// Hovering defers NOTHING now: a ghost under the pointer collapses on its
+	// own schedule, and an insertion appears immediately with the pointer inside.
+	// The protection is target-scoped instead: a trusted click whose target row
+	// is displaced by an in-flight motion is swallowed; rows above the motion
+	// answer clicks throughout; the guard dies ~50ms after the motion ends.
+	s = await st();
+	const ghosts = s.rows.filter((r) => r.committed && !r.disabled).map((r) => r.key);
+	check("ghost-present-for-pointer-phase", ghosts.length > 0, JSON.stringify(s.rows));
+	const gkey = ghosts[0] as string;
+	const gbox = JSON.parse(
+		(await cdp.js(`
+	  (() => { const r = ${demo(0)}.shadowRoot.querySelector('[data-key="${gkey}"]').getBoundingClientRect();
+	     return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })()
+	`)) as string,
+	) as { x: number; y: number };
+	await cdp.mouseMove(gbox.x, gbox.y); // pointer parked ON the ghost, pre-expiry
+	await sleep(2200); // holdMs + collapse + slack, pointer inside throughout
+	s = await st();
+	check("hover-does-not-defer-collapse", row(s, gkey) === null, JSON.stringify(s.rows));
+	// An insertion under the hovering pointer: immediate, full-size, no pending
+	// phase.  Park the pointer at the LIST's own center first (the collapse may
+	// have shrunk the list away from where the ghost was).
+	const lbox = JSON.parse(
+		(await cdp.js(`
+	  (() => { const r = ${demo(0)}.shadowRoot.querySelector('sw-live-list').getBoundingClientRect();
+	     return JSON.stringify({x: r.x + r.width/2, y: r.y + Math.max(r.height/2, 4)}); })()
+	`)) as string,
+	) as { x: number; y: number };
+	await cdp.mouseMove(lbox.x, lbox.y);
+	await cdp.js(`${demo(0)}.prependTask()`);
+	await sleep(500); // entrance animation (300ms) settles
+	s = await st();
+	let pend = s.dataIds[0] as string;
+	let prow = row(s, pend);
+	let pheight = await cdp.js(`
+	  (() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${pend}"]');
+	     return el === null ? null : el.getBoundingClientRect().height; })()
+	`);
+	check(
+		"insertion-appears-under-pointer",
+		prow !== null && prow.hidden === null,
+		JSON.stringify(prow),
+	);
+	check(
+		"inserted-row-holds-full-space",
+		typeof pheight === "number" && pheight > 20,
+		JSON.stringify(pheight),
+	);
+
+	// The trusted-click guard.  Stock the queue so a mid-list collapse has rows
+	// both above and below it, and settle all entrances first.
+	await cdp.js(`${demo(0)}.prependTask(); ${demo(0)}.prependTask()`);
+	await sleep(600);
+	await cdp.key("Home"); // focus the prepend row: a stable not-a-task vantage
+	s = await st();
+	const ids = s.dataIds;
+	const [above, cgVictim, below] = [ids[0] as string, ids[1] as string, ids[2] as string];
+
+	const rowCenter = async (k: string): Promise<{ x: number; y: number } | null> => {
+		const v = await cdp.js(`
+		  (() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${k}"]');
+		     if (el === null) return null;
+		     const r = el.getBoundingClientRect();
+		     return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })()
+		`);
+		return v === null ? null : JSON.parse(v as string);
+	};
+
+	await cdp.js(
+		`(() => { const dd = ${demo(0)}; dd.tasks = dd.tasks.filter(t => t.id !== '${cgVictim}') })()`,
+	);
+	{
+		let began = false;
+		for (let i = 0; i < 60; i++) {
+			// wait out the hold; catch the collapse the frame it starts
+			s = await st();
+			g = row(s, cgVictim);
+			if (g?.disabled) {
+				began = true;
+				break;
+			}
+			await sleep(40);
+		}
+		if (!began) throw new Error("click-guard victim never began collapsing");
+	}
+	// Mid-collapse (300ms window): the row below the collapse is being pulled
+	// upward -- a trusted click on it must be swallowed (not even a focus sync);
+	// the row above has not moved and its click lands.
+	let pb = (await rowCenter(below)) as { x: number; y: number };
+	await cdp.click(pb.x, pb.y);
+	s = await st();
+	check("mid-collapse-click-below-swallowed", s.rowFocused === "prepend", s.rowFocused ?? "");
+	const pa = (await rowCenter(above)) as { x: number; y: number };
+	await cdp.click(pa.x, pa.y);
+	s = await st();
+	check("mid-collapse-click-above-lands", s.rowFocused === above, s.rowFocused ?? "");
+	// After the motion (plus the 50ms guard tail), the same below-row click lands.
+	await sleep(600);
+	pb = (await rowCenter(below)) as { x: number; y: number };
+	await cdp.click(pb.x, pb.y);
+	s = await st();
+	check("post-motion-click-lands", s.rowFocused === below, s.rowFocused ?? "");
+	await cdp.mouseMove(5, 5);
+
+	// --- phase 5f: freeze, the opt-in quiescence mode -----------------------
+	// pointerSafety is runtime-togglable; raise freeze on the live list and the
+	// old global hover pin story must hold: a hovered ghost defers past every
+	// timer, an insertion waits as a zero-height pending row, and pointer-leave
+	// releases both.
+	const LIST = `${demo(0)}.shadowRoot.querySelector('sw-live-list')`;
+	await cdp.js(`${LIST}.pointerSafety = 'freeze'`);
+	s = await st();
+	const fkey = s.dataIds[0] as string;
+	const fbox = (await rowCenter(fkey)) as { x: number; y: number };
+	await cdp.mouseMove(fbox.x, fbox.y); // pointer onto the head task row
+	await cdp.js(
+		`(() => { const dd = ${demo(0)}; dd.tasks = dd.tasks.filter(t => t.id !== '${fkey}') })()`,
+	);
+	await sleep(2200); // far past holdMs + collapse; the hover pin holds
+	s = await st();
+	g = row(s, fkey);
+	check("freeze-hover-pin-holds-ghost", g !== null && !g.disabled, JSON.stringify(s.rows));
+	// While the pointer pins the list, an insertion defers as a PENDING row --
+	// and a pending row must hold its display position at ZERO height.  This
+	// pins the phantom-space state (held-p prepends with the pointer inside
+	// painted a blank band at the list top: the demo's [data-mc-row]
+	// min-height was clamping closed rowslots open to 24px).
+	await cdp.js(`${demo(0)}.prependTask()`);
+	await sleep(300);
+	s = await st();
+	pend = s.dataIds[0] as string;
+	prow = row(s, pend);
+	pheight = await cdp.js(`
+	  (() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${pend}"]');
+	     return el === null ? null : el.getBoundingClientRect().height; })()
+	`);
+	check(
+		"freeze-hover-defers-insertion",
+		prow !== null && prow.hidden === "true",
+		JSON.stringify(prow),
+	);
+	check("freeze-pending-row-holds-no-space", pheight === 0, JSON.stringify(pheight));
+	await cdp.mouseMove(5, 5); // pointer leaves: deferred motion plays
+	await sleep(800);
+	s = await st();
+	check("freeze-pointer-leave-releases", row(s, fkey) === null, JSON.stringify(s.rows));
+	prow = row(s, pend);
+	check(
+		"freeze-pointer-leave-shows-pending",
+		prow !== null && prow.hidden === null,
+		JSON.stringify(prow),
+	);
+	await cdp.js(`${LIST}.pointerSafety = 'click-guard'`); // back to the default
+
+	// Re-seat DOM focus on the column container before the Tab phases: a real
+	// mouse click natively focuses the list's tabindex=-1 item container (the
+	// nearest click-focusable ancestor of a row), and Tab order from there is
+	// not what phase 6 is testing.  Arrangement, not assertion.
+	await cdp.js(`${demo(0)}.shadowRoot.querySelector('[data-mc-container]').focus()`);
+
+	// --- phase 6: focus memory across Tab-away ------------------------------
+	s = await st();
+	const resume = s.rowFocused;
+	await cdp.key("Tab"); // leaves the refill column (next stop: drain column)
+	s = await st();
+	check("tab-leaves-column", !s.focusWithin, JSON.stringify(s.focusWithin));
+	await cdp.key("Tab", { shift: true });
+	s = await st();
+	check(
+		"focus-memory-on-return",
+		s.focusWithin && s.rowFocused === resume,
+		JSON.stringify([s.rowFocused, resume]),
+	);
+
+	// --- phase 7: the drain column: empty state and revival -----------------
+	await cdp.key("Tab");
+	let s1 = await st(1);
+	check("tab-into-drain-column", s1.focusWithin, JSON.stringify(s1));
+	// The queue starts empty (renderEmpty shows), but the COLUMN is not: the
+	// prepend row is a focusable stop, so eager focus sits there.
+	check(
+		"drain-starts-empty",
+		s1.idle !== null && s1.rowFocused === "prepend" && s1.aria === "mc-prepend",
+		JSON.stringify([s1.idle, s1.rowFocused, s1.aria]),
+	);
+	await shot(cdp, "drain-empty.png");
+
+	// p prepends a task; focus STAYS on the prepend row (it survives churn;
+	// nothing involuntary happened), and the new row is immediately reachable
+	// (two downs now: the list header row sits between prepend and the list).
+	await cdp.key("p");
+	s1 = await st(1);
+	const drainNewest = s1.dataIds.length > 0 ? (s1.dataIds[0] as string) : null;
+	check(
+		"drain-revives",
+		drainNewest !== null && s1.rowFocused === "prepend",
+		JSON.stringify(s1.dataIds),
+	);
+	await cdp.key("ArrowDown");
+	await cdp.key("ArrowDown");
+	s1 = await st(1);
+	check(
+		"drain-new-row-reachable",
+		s1.rowFocused === drainNewest && s1.aria === `mc-${drainNewest}`,
+		JSON.stringify([s1.rowFocused, s1.aria]),
+	);
+	// Kill it with x and let it collapse: focus survives the LAST task dying,
+	// falling DOWN to the add-row (the fallback prefers downward, and the
+	// add-row is the list's permanent neighbor below); the empty state returns.
+	await cdp.key("x");
+	s1 = await st(1);
+	check(
+		"drain-x-removes",
+		s1.log.includes("removed") && s1.rowFocused === drainNewest,
+		JSON.stringify([s1.log, s1.rowFocused]),
+	);
+	await sleep(2200); // pin decays (200) + hold (1200) + collapse (300) + slack
+	s1 = await st(1);
+	check(
+		"drain-back-to-empty",
+		s1.idle !== null && s1.rowFocused === "queue-add",
+		JSON.stringify([s1.idle, s1.rowFocused]),
+	);
+	await cdp.key("p");
+	await cdp.key("ArrowDown");
+	await cdp.key("ArrowDown");
+	s1 = await st(1);
+	check(
+		"drain-revives-again",
+		s1.dataIds.length === 1 && s1.rowFocused === s1.dataIds[0],
+		JSON.stringify(s1.rowFocused),
+	);
+	await shot(cdp, "drain-revived.png");
+
+	// --- phase 8: the add-row (append as a nav stop; commit/abandon) --------
+	// Still in the drain column, one live task.  The add-row is the append
+	// norm, and its marked a is DIRECT: one keystroke dives straight into
+	// text entry (direct on a text row means what Enter means there), earned
+	// under the easy-undo doctrine -- Escape abandons, nothing touches the
+	// queue until the Enter commit.  Enter commits, Escape abandons.
+	s1 = await st(1);
+	const countBefore = s1.dataIds.length;
+	await cdp.key("a");
+	s1 = await st(1);
+	check(
+		"a-dives-into-add-row",
+		s1.rowFocused === "queue-add" && s1.aria === "mc-queue-add",
+		JSON.stringify([s1.rowFocused, s1.aria]),
+	);
+	check(
+		"a-dive-enters-text-mode",
+		s1.textMode && s1.inputFocused,
+		JSON.stringify([s1.textMode, s1.inputFocused]),
+	);
+	// The triggering a must not type itself into the input it focused
+	// (the dive preventDefaults the keydown, same as Enter's dive).
+	check("a-does-not-type-itself", s1.inputValue === "", JSON.stringify(s1.inputValue));
+	check("a-dive-appends-nothing", s1.dataIds.length === countBefore, JSON.stringify(s1.dataIds));
+
+	for (const ch of "calibrate the array") {
+		await cdp.key(ch);
+	}
+	s1 = await st(1);
+	check(
+		"typing-lands-in-add-input",
+		s1.inputValue === "calibrate the array",
+		JSON.stringify(s1.inputValue),
+	);
+	await shot(cdp, "add-row-typing.png");
+
+	// Escape ABANDONS: text mode exits, the draft clears, nothing appended.
+	// (The trap this pins: change fires on blur-with-changed-value, and the
+	// exit blurs the input -- a change-driven commit would append here.)
+	const logBefore8 = s1.log;
+	await cdp.key("Escape");
+	s1 = await st(1);
+	check(
+		"escape-abandons",
+		!s1.textMode && s1.inputValue === "" && s1.dataIds.length === countBefore,
+		JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds]),
+	);
+	check(
+		"escape-appends-nothing-to-log",
+		s1.log === logBefore8,
+		JSON.stringify([s1.log, logBefore8]),
+	);
+
+	// Row focus survived the exit on the add-row, so plain Enter re-dives
+	// (the non-marked path, unchanged by the direct marking).
+	await cdp.key("Enter");
+	s1 = await st(1);
+	check(
+		"enter-redives-from-row-focus",
+		s1.textMode && s1.inputFocused,
+		JSON.stringify([s1.textMode, s1.inputFocused]),
+	);
+
+	// Enter COMMITS: the task appends to the live data (animating in at the
+	// tail), the input clears, and row focus stayed on the add-row throughout.
+	for (const ch of "survey the relay") {
+		await cdp.key(ch);
+	}
+	await cdp.key("Enter");
+	s1 = await st(1);
+	check(
+		"enter-commits-once",
+		s1.dataIds.length === countBefore + 1 && s1.log.includes("survey the relay appended"),
+		JSON.stringify([s1.dataIds, s1.log]),
+	);
+	check(
+		"commit-clears-input",
+		s1.inputValue === "" && !s1.textMode,
+		JSON.stringify([s1.inputValue, s1.textMode]),
+	);
+	const newId = s1.dataIds[s1.dataIds.length - 1] as string;
+	const freshTail = row(s1, newId);
+	check(
+		"committed-row-visible-at-tail",
+		freshTail !== null && freshTail.hidden === null && !freshTail.disabled,
+		JSON.stringify(freshTail),
+	);
+	check("focus-stayed-on-add-row", s1.rowFocused === "queue-add", s1.rowFocused ?? "");
+	// ArrowUp from the add-row reaches the appended task: it landed at the tail.
+	await cdp.key("ArrowUp");
+	s1 = await st(1);
+	check("committed-row-reachable-above-add-row", s1.rowFocused === newId, s1.rowFocused ?? "");
+
+	// --- phase 9: the list-scoped keys (t and n) -----------------------------
+	// The t/n norm: sw-list wires it itself (one megacolumn-key listener on
+	// the host element answering with megacolumn-focus-request), deliberately
+	// NOT gold markings.  Scope is DOM containment: within the list's rows t
+	// returns to the header and n jumps past the list; outside it the letters
+	// are nobody's.  Focus is on the tail task from phase 8 -- deep in the list.
+	await cdp.key("t");
+	s1 = await st(1);
+	check(
+		"t-from-task-returns-to-header",
+		s1.rowFocused === "queue-header" && s1.aria === "mc-queue-header",
+		JSON.stringify([s1.rowFocused, s1.aria]),
+	);
+	check("t-landing-washes-region", s1.groupWashed === true, JSON.stringify(s1.groupWashed));
+	// n from the header (the header counts as within): past the whole list to
+	// the telemetry row, no arrowing through the tasks.
+	await cdp.key("n");
+	s1 = await st(1);
+	check(
+		"n-jumps-past-the-list",
+		s1.rowFocused === "status" && s1.aria === "mc-status",
+		JSON.stringify([s1.rowFocused, s1.aria]),
+	);
+	// The status row sits OUTSIDE the group: t there is unclaimed -- it falls
+	// through to normal resolution (no marking answers it) and nothing moves.
+	await cdp.key("t");
+	s1 = await st(1);
+	check("t-outside-group-unclaimed", s1.rowFocused === "status", s1.rowFocused ?? "");
+	// The add-row counts as within: t from it returns to the header too.
+	await cdp.key("ArrowUp");
+	s1 = await st(1);
+	check("arrowup-from-status-is-add-row", s1.rowFocused === "queue-add", s1.rowFocused ?? "");
+	await cdp.key("t");
+	s1 = await st(1);
+	check("t-from-add-row-returns-to-header", s1.rowFocused === "queue-header", s1.rowFocused ?? "");
+	// ... and n from a mid-list task row (not just the header) jumps past too.
+	await cdp.key("ArrowDown");
+	s1 = await st(1);
+	check("arrowdown-into-list", s1.dataIds.includes(s1.rowFocused ?? ""), s1.rowFocused ?? "");
+	await cdp.key("n");
+	s1 = await st(1);
+	check("n-from-task-jumps-past", s1.rowFocused === "status", s1.rowFocused ?? "");
+	// The prepend row is outside the group on the other side: t stays put.
+	await cdp.key("Home");
+	await cdp.key("t");
+	s1 = await st(1);
+	check("t-on-prepend-unclaimed", s1.rowFocused === "prepend", s1.rowFocused ?? "");
+	// Text mode dominates: while typing in the add-row, t and n are just letters.
+	await cdp.key("a"); // the direct marking dives straight into text entry
+	await cdp.key("t");
+	await cdp.key("n");
+	s1 = await st(1);
+	check(
+		"text-mode-t-n-type",
+		s1.textMode && s1.inputValue === "tn" && s1.rowFocused === "queue-add",
+		JSON.stringify([s1.textMode, s1.inputValue, s1.rowFocused]),
+	);
+	await cdp.key("Escape"); // abandon the probe draft
+	s1 = await st(1);
+	check(
+		"text-mode-probe-abandoned",
+		!s1.textMode && s1.inputValue === "",
+		JSON.stringify([s1.textMode, s1.inputValue]),
+	);
+
+	// --- phase 10: the sw-list component surface -----------------------------
+	// Newly reachable since the list norms became a component (the
+	// list-component stage): the group identity for AT, and the cancelable
+	// commit (an owner that preventDefaults sw-list-add refuses the commit;
+	// the draft and text mode must survive so the user can fix the text).
+	// The demo's own handler never refuses, and a listener on the same element
+	// would run after it -- so the refusal is arranged upstream: a one-shot
+	// capture listener on the container cancels AND stops propagation (from
+	// sw-list's side that IS a refusing owner: sw-list-add came back
+	// defaultPrevented and nobody appended).
+	await cdp.js(`
+	  ${demo(1)}.shadowRoot.querySelector('[data-mc-container]')
+	    .addEventListener('sw-list-add', e => { e.preventDefault(); e.stopPropagation(); },
+	                      { once: true, capture: true })
+	`);
+	const role = await cdp.js(`
+	  (() => { const l = ${demo(1)}.shadowRoot.querySelector('sw-list');
+	     return JSON.stringify([l.getAttribute('role'), l.getAttribute('aria-label')]); })()
+	`);
+	check(
+		"sw-list-group-identity",
+		JSON.stringify(JSON.parse(role as string)) === JSON.stringify(["group", "task queue (live)"]),
+		String(role),
+	);
+	const countBefore10 = (await st(1)).dataIds.length;
+	// Focus sits ON the add-row after the phase-9 probe, where its own marking
+	// is deliberately a no-op (double-tap must not out-power single-tap) --
+	// so step off it first, then dive by marking.  Once in text mode every
+	// letter (t and n included) types.
+	await cdp.key("Home");
+	await cdp.key("a");
+	for (const ch of "rejected draft") {
+		await cdp.key(ch);
+	}
+	await cdp.key("Enter");
+	s1 = await st(1);
+	check(
+		"refused-commit-keeps-draft-and-text-mode",
+		s1.textMode && s1.inputValue === "rejected draft" && s1.dataIds.length === countBefore10,
+		JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds.length]),
+	);
+	// The once-listener is spent: the same Enter now commits.
+	await cdp.key("Enter");
+	await sleep(100);
+	s1 = await st(1);
+	check(
+		"commit-flows-after-refusal",
+		!s1.textMode &&
+			s1.inputValue === "" &&
+			s1.dataIds.length === countBefore10 + 1 &&
+			s1.log.includes("rejected draft appended"),
+		JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds, s1.log]),
+	);
+}
+
+export const lcDrive: Scenario = { name: "lc", run };
diff --git a/packages/e2e/scenarios/mc-drive.ts b/packages/e2e/scenarios/mc-drive.ts
new file mode 100644
index 0000000..2776940
--- /dev/null
+++ b/packages/e2e/scenarios/mc-drive.ts
@@ -0,0 +1,421 @@
+// Drives the megacolumn gallery demo with TRUSTED key events via CDP,
+// reading focus/aria state between steps.  Synthetic JS KeyboardEvents can't
+// exercise real Tab traversal or real typing; these can.
+// Ported from the Python original (demoscene.meta screenshots, stage-focus-model).
+
+import type { Scenario, ScenarioContext } from "yukey-testkit/checks";
+import { openGallery } from "yukey-testkit/gallery";
+import { sleep } from "yukey-testkit/sleep";
+
+// Reads the interesting state through the shadow roots: which demo element holds
+// real focus, its aria-activedescendant, ring positions, text mode, and the log line.
+const STATE_JS = `
+(() => {
+  const shell = document.querySelector("sw-gallery").shadowRoot;
+  const demos = [...shell.querySelectorAll("swg-megacolumn-demo")];
+  const active = shell.activeElement;
+  const out = { realFocus: null, columns: [] };
+  for (const demo of demos) {
+    const sr = demo.shadowRoot;
+    const container = sr.querySelector("[data-mc-container]");
+    const label = demo.getAttribute("label");
+    if (demo === active) {
+      const inner = sr.activeElement;
+      out.realFocus = label + "/" + (inner === container ? "container" : (inner?.tagName ?? "host").toLowerCase());
+    }
+    out.columns.push({
+      label,
+      aria: container.getAttribute("aria-activedescendant"),
+      ringOn: sr.querySelector(".mc-focused")?.textContent?.trim()?.slice(0, 24) ?? null,
+      rowFocused: sr.querySelector(".mc-row-focused")?.dataset?.mcRow ?? null,
+      textMode: container.classList.contains("mc-text-mode"),
+      focusWithin: container.classList.contains("mc-focus-within"),
+      log: sr.querySelector(".log")?.textContent ?? "",
+      inputValue: sr.querySelector("[data-mc-input]")?.value ?? null,
+      cargoCount: sr.querySelectorAll("[data-mc-aux]").length,
+    });
+  }
+  return JSON.stringify(out);
+})()
+`;
+
+interface McColumn {
+	label: string;
+	aria: string | null;
+	ringOn: string | null;
+	rowFocused: string | null;
+	textMode: boolean;
+	focusWithin: boolean;
+	log: string;
+	inputValue: string | null;
+	cargoCount: number;
+}
+
+interface McState {
+	realFocus: string | null;
+	columns: McColumn[];
+}
+
+// State of the nested-scroller demo: the focused row, the box's own scroll
+// position, and the page scrollers that must NOT move.
+const SCROLL_JS = `
+(() => {
+  const shell = document.querySelector("sw-gallery").shadowRoot;
+  const demo = shell.querySelector("swg-megacolumn-scroll-demo");
+  const sr = demo.shadowRoot;
+  const box = sr.querySelector("[data-mc-container]");
+  const focused = sr.querySelector(".mc-row-focused");
+  const b = box.getBoundingClientRect();
+  const r = focused?.getBoundingClientRect();
+  return JSON.stringify({
+    hasFocus: shell.activeElement === demo,
+    rowFocused: focused?.dataset?.mcRow ?? null,
+    boxTop: box.scrollTop,
+    rowInBox: r ? r.top >= b.top - 1 && r.bottom <= b.bottom + 1 : null,
+    mainTop: shell.querySelector("main").scrollTop,
+    windowY: window.scrollY,
+    log: sr.querySelector(".log")?.textContent ?? "",
+  });
+})()
+`;
+
+interface McScrollState {
+	hasFocus: boolean;
+	rowFocused: string | null;
+	boxTop: number;
+	rowInBox: boolean | null;
+	mainTop: number;
+	windowY: number;
+	log: string;
+}
+
+async function run(ctx: ScenarioContext): Promise<void> {
+	const { cdp, checks } = ctx;
+	const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail);
+	const st = async (): Promise<McState> => JSON.parse((await cdp.js(STATE_JS)) as string);
+
+	// Fresh demo state every run: navigate to the entry, wait for the demo element.
+	await openGallery(
+		ctx,
+		"#megacolumn",
+		"document.readyState === 'complete' && !!document.querySelector('sw-gallery')?.shadowRoot?.querySelector('swg-megacolumn-demo')",
+	);
+
+	// The gallery seats initial focus in the entry's first tab stop on open:
+	// after a reload of #megacolumn that is the port container, no click, no Tab.
+	// (Seating happens a frame after render; poll briefly.)
+	let seated: string | null = null;
+	for (let i = 0; i < 20; i++) {
+		const s = await st();
+		seated = s.realFocus;
+		if (seated === "port console/container") break;
+		await sleep(50);
+	}
+	check(
+		"initial-focus-seated-on-open",
+		seated === "port console/container",
+		JSON.stringify(seated),
+	);
+
+	// Start clean: blur the DEEP active element (document.activeElement is just
+	// the shadow host; blurring the host does not drop inner focus), then Tab
+	// until real focus lands back in the first container.
+	await cdp.js(
+		"(() => { let a = document.activeElement; while (a && a.shadowRoot && a.shadowRoot.activeElement) a = a.shadowRoot.activeElement; if (a) a.blur(); window.scrollTo(0,0); })()",
+	);
+	let landed = false;
+	let s = await st();
+	// The cap must exceed the containment cycle's full length: every gallery
+	// nav link plus the demo's stops.  Hand-counting rotted once already (a cap
+	// of 12 = "six nav links" broke when the gallery grew); count the nav links
+	// live and add the demo's own stops with margin.
+	const navLinks = (await cdp.js(
+		"document.querySelector('sw-gallery').shadowRoot.querySelectorAll('nav a').length",
+	)) as number;
+	for (let i = 0; i < navLinks + 12; i++) {
+		await cdp.key("Tab");
+		s = await st();
+		if (s.realFocus === "port console/container") {
+			landed = true;
+			break;
+		}
+	}
+	check("tab-reaches-port-container", landed, JSON.stringify(s.realFocus));
+	let port = s.columns[0] as McColumn;
+	check(
+		"initial-focus-first-row",
+		port.rowFocused === "beacon" && port.aria === "mc-beacon",
+		JSON.stringify(port),
+	);
+
+	// Tab again: ONE tab stop per column -> straight to starboard.
+	await cdp.key("Tab");
+	s = await st();
+	check(
+		"tab-skips-rows-to-starboard",
+		s.realFocus === "starboard console/container",
+		JSON.stringify(s.realFocus),
+	);
+	check(
+		"port-memory-visible-when-blurred",
+		s.columns[0]?.rowFocused === "beacon" && s.columns[0]?.focusWithin === false,
+		JSON.stringify(s.columns[0]),
+	);
+
+	// Back to port; navigate: down x2 -> reactor slider.
+	await cdp.key("Tab", { shift: true });
+	await cdp.key("ArrowDown");
+	await cdp.key("ArrowDown");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("arrows-move-row-focus", port.rowFocused === "power", JSON.stringify(port));
+
+	// Slider consumes left/right.
+	await cdp.key("ArrowRight");
+	await cdp.key("ArrowRight");
+	await cdp.key("ArrowLeft");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("slider-eats-arrows", port.log.includes("reactor power 45%"), port.log);
+	check("slider-row-unmoved", port.rowFocused === "power", JSON.stringify(port));
+
+	// Down into cargo: two-control rows.  Right reaches the aux x; Enter jettisons.
+	await cdp.key("ArrowDown");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("gap-jump-into-cargo", port.rowFocused === "crate-ore", JSON.stringify(port));
+	await cdp.key("ArrowRight");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("right-reaches-aux", port.ringOn === "\u00d7", JSON.stringify(port));
+	await cdp.key("Enter");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check(
+		"enter-on-aux-removes",
+		port.log.includes("ore crate jettisoned") && port.cargoCount === 2,
+		JSON.stringify(port),
+	);
+	check("vanished-row-falls-down", port.rowFocused === "crate-struts", JSON.stringify(port));
+
+	// x hotkey removes from main position too.
+	await cdp.key("x");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check(
+		"x-hotkey-removes",
+		port.log.includes("strut bundle jettisoned") && port.cargoCount === 1,
+		JSON.stringify(port),
+	);
+
+	// Enter on main inspects, does NOT remove.
+	await cdp.key("Enter");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check(
+		"enter-on-main-inspects",
+		port.log.includes("fuel cells inspected") && port.cargoCount === 1,
+		JSON.stringify(port),
+	);
+
+	// Down to the hail row; Enter dives into the input; type; every key goes in.
+	await cdp.key("ArrowDown");
+	await cdp.key("Enter");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check(
+		"text-mode-entered",
+		port.textMode && s.realFocus === "port console/input",
+		JSON.stringify({ f: s.realFocus, tm: port.textMode }),
+	);
+	for (const ch of "sos x") {
+		await cdp.key(ch);
+	}
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("typing-goes-to-input", port.inputValue === "sos x", JSON.stringify(port.inputValue));
+	check("x-did-not-jettison-while-typing", port.cargoCount === 1, String(port.cargoCount));
+
+	// Escape exits back to the container; row focus stays on hail.
+	await cdp.key("Escape");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check(
+		"escape-exits-text",
+		!port.textMode && s.realFocus === "port console/container",
+		JSON.stringify({ f: s.realFocus, tm: port.textMode }),
+	);
+	check("row-focus-resumed-on-hail", port.rowFocused === "hail", JSON.stringify(port));
+
+	// Down reaches the offline encrypted row: visible-but-inactive rows are
+	// nav stops (i must be able to reach them; stride counts stay stable).
+	await cdp.key("ArrowDown");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("offline-row-is-a-stop", port.rowFocused === "encrypted", JSON.stringify(port));
+	// ... but activation is dead there: Enter changes nothing.
+	const beforeLog = port.log;
+	await cdp.key("Enter");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("offline-activation-dead", port.log === beforeLog, port.log);
+
+	// Home jumps to the first row.
+	await cdp.key("Home");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("home-jumps-to-first", port.rowFocused === "beacon", JSON.stringify(port));
+
+	// Enter activates the beacon row (plain activatable).
+	await cdp.key("Enter");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("enter-activates-row", port.log.includes("beacon cycled (1)"), port.log);
+	await cdp.key(" ");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("space-mirrors-enter", port.log.includes("beacon cycled (2)"), port.log);
+
+	// Hotkey jumps: the gold letters move focus, Enter commits (see-before-commit).
+	// g leaps from the top section to the bottom one; the log must NOT change on
+	// the jump itself.
+	await cdp.key("g");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("hotkey-jumps-to-gyros", port.rowFocused === "gyros", JSON.stringify(port));
+	check("jump-does-not-activate", port.log.includes("beacon cycled (2)"), port.log);
+	await cdp.key("Enter");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("enter-commits-after-jump", port.log.includes("gyros recalibrated"), port.log);
+	// The offline row is a jump target too: e lands there (i can ask why it's off).
+	await cdp.key("e");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("jump-reaches-offline-row", port.rowFocused === "encrypted", JSON.stringify(port));
+	// ... and b jumps back up to the beacon row for the phases below.
+	await cdp.key("b");
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("jump-back-to-beacon", port.rowFocused === "beacon", JSON.stringify(port));
+
+	// Tab away and back: focus memory.
+	await cdp.key("ArrowDown"); // -> dock, a memorable non-default row
+	await cdp.key("Tab");
+	await cdp.key("Tab", { shift: true });
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check(
+		"focus-memory-across-tab",
+		port.rowFocused === "dock" && s.realFocus === "port console/container",
+		JSON.stringify({ f: s.realFocus, r: port.rowFocused }),
+	);
+
+	// Starboard is untouched by all of this.
+	const sb = s.columns[1] as McColumn;
+	check(
+		"starboard-independent",
+		sb.rowFocused === "beacon" && sb.cargoCount === 3 && sb.log === "",
+		JSON.stringify(sb),
+	);
+
+	// --- pointer activation: a trusted CLICK is the same event as Enter ---
+	// Plain rows have no widget: their activation is the controller's synthesized
+	// megacolumn-key.  A tap produces a click, so this is also the touch story.
+	const portCenter = async (selector: string): Promise<{ x: number; y: number }> => {
+		const v = await cdp.js(`
+		(() => { const el = document.querySelector("sw-gallery").shadowRoot
+		     .querySelector("swg-megacolumn-demo").shadowRoot.querySelector('${selector}');
+		     el.scrollIntoView({block: "nearest", behavior: "instant"});
+		     const r = el.getBoundingClientRect();
+		     return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })()
+		`);
+		return JSON.parse(v as string);
+	};
+
+	// Click the beacon row (a plain activatable row: earlier Enter and Space
+	// left its counter at 2, so a working click reads 3).
+	let pt = await portCenter('[data-mc-row="beacon"]');
+	await cdp.click(pt.x, pt.y);
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check("click-activates-plain-row", port.log.includes("beacon cycled (3)"), port.log);
+	check("click-focuses-clicked-row", port.rowFocused === "beacon", JSON.stringify(port));
+
+	// Click the surviving crate's aux ×: the synthesis carries the aux position,
+	// so the click removes exactly as Enter-on-aux does.
+	pt = await portCenter('[data-mc-row="crate-cells"] [data-mc-aux]');
+	await cdp.click(pt.x, pt.y);
+	s = await st();
+	port = s.columns[0] as McColumn;
+	check(
+		"click-on-aux-removes",
+		port.log.includes("fuel cells jettisoned") && port.cargoCount === 0,
+		JSON.stringify(port),
+	);
+
+	// --- nested scroller: the relay-bank variant (28 static rows in a 240px
+	// overflow-y box).  The controller's scrollIntoView(block:"nearest") must
+	// scroll the BOX, never the page (the gallery's page scroller is the
+	// shell's <main>; window never scrolls in this layout, asserted anyway). ---
+	const sst = async (): Promise<McScrollState> => JSON.parse((await cdp.js(SCROLL_JS)) as string);
+
+	// Tab from port down to the relay bank (starboard and the ops console are
+	// tab stops on the way; hunt, don't count).  Tabbing in seats row focus on
+	// relay-01 and MAY scroll the page scroller to reveal the demo -- so the
+	// page-scroll baseline is recorded after landing, not before.
+	landed = false;
+	let ss = await sst();
+	for (let i = 0; i < 8; i++) {
+		await cdp.key("Tab");
+		ss = await sst();
+		if (ss.hasFocus) {
+			landed = true;
+			break;
+		}
+	}
+	check(
+		"nested-tab-reaches-relay-bank",
+		landed && ss.rowFocused === "relay-01" && ss.boxTop === 0,
+		JSON.stringify(ss),
+	);
+	const pageBefore = [ss.mainTop, ss.windowY] as const;
+	const pageSame = (v: McScrollState) => v.mainTop === pageBefore[0] && v.windowY === pageBefore[1];
+
+	// A hotkey jump across the scroller: 2 lands on relay-20, far off-screen
+	// from the top -- the box must scroll, the page must not.
+	await cdp.key("2");
+	ss = await sst();
+	check("nested-jump-lands-on-relay-20", ss.rowFocused === "relay-20", JSON.stringify(ss));
+	check("nested-jump-scrolls-the-box", ss.boxTop > 0, JSON.stringify(ss));
+	check(
+		"nested-jump-leaves-page-alone",
+		pageSame(ss),
+		JSON.stringify([pageBefore, ss.mainTop, ss.windowY]),
+	);
+	check("nested-jump-row-visible-in-box", ss.rowInBox === true, JSON.stringify(ss));
+
+	// End slams to the last relay; Home slams back and the box must return
+	// exactly to the top (relay-01 flush at scrollTop 0).
+	await cdp.key("End");
+	ss = await sst();
+	check(
+		"nested-end-reaches-last-in-view",
+		ss.rowFocused === "relay-28" && ss.rowInBox === true,
+		JSON.stringify(ss),
+	);
+	await cdp.key("Home");
+	ss = await sst();
+	check(
+		"nested-home-returns-box-to-top",
+		ss.rowFocused === "relay-01" && ss.boxTop === 0 && ss.rowInBox === true,
+		JSON.stringify(ss),
+	);
+	check(
+		"nested-slams-leave-page-alone",
+		pageSame(ss),
+		JSON.stringify([pageBefore, ss.mainTop, ss.windowY]),
+	);
+}
+
+export const mcDrive: Scenario = { name: "mc", run };
diff --git a/packages/e2e/scenarios/sw-drive.ts b/packages/e2e/scenarios/sw-drive.ts
new file mode 100644
index 0000000..0471c0f
--- /dev/null
+++ b/packages/e2e/scenarios/sw-drive.ts
@@ -0,0 +1,572 @@
+// Drives the sw-button gallery entry with TRUSTED key events via CDP,
+// reading state through the shadow roots between steps.  Covers: standalone
+// tab traversal + native activation + focused hotkey/i, the in-column claims
+// (Enter/Space, column-scoped hotkeys, x/Delete on aux, i, disabled
+// claim-and-decline), the two-column scoping proof, and activation feedback
+// (the sw-held / sw-pressed classes; the 120ms flash is caught with a
+// MutationObserver installed before the keystroke, not with a screenshot race).
+// Ported from the Python original (demoscene.meta screenshots, stage-button).
+
+import { type Scenario, type ScenarioContext, shot } from "yukey-testkit/checks";
+import { openGallery } from "yukey-testkit/gallery";
+import { sleep } from "yukey-testkit/sleep";
+
+// State through the shadow roots: which element holds real focus (host chain),
+// each demo's log line, and the columns' focused rows.
+const STATE_JS = `
+(() => {
+  const shell = document.querySelector("sw-gallery").shadowRoot;
+  const out = { realFocus: null, standalone: null, columns: [] };
+  const active = shell.activeElement;
+  const sa = shell.querySelector("swg-button-standalone");
+  if (sa) {
+    const sr = sa.shadowRoot;
+    out.standalone = {
+      log: sr.querySelector(".log")?.textContent ?? "",
+      logIsInfo: sr.querySelector(".log")?.classList.contains("info") ?? false,
+      focusedLabel: sr.activeElement?.label ?? null,
+    };
+    if (sa === active) out.realFocus = "standalone/" + (sr.activeElement?.label ?? "?");
+  }
+  for (const col of shell.querySelectorAll("swg-button-column")) {
+    const sr = col.shadowRoot;
+    const container = sr.querySelector("[data-mc-container]");
+    const label = col.getAttribute("label");
+    if (col === active) out.realFocus = label + "/container";
+    out.columns.push({
+      label,
+      rowFocused: sr.querySelector(".mc-row-focused")?.dataset?.mcRow ?? null,
+      focusWithin: container.classList.contains("mc-focus-within"),
+      log: sr.querySelector(".log")?.textContent ?? "",
+      logIsInfo: sr.querySelector(".log")?.classList.contains("info") ?? false,
+      cargoRows: sr.querySelectorAll("[data-mc-aux]").length,
+    });
+  }
+  return JSON.stringify(out);
+})()
+`;
+
+interface SwColumn {
+	label: string;
+	rowFocused: string | null;
+	focusWithin: boolean;
+	log: string;
+	logIsInfo: boolean;
+	cargoRows: number;
+}
+
+interface SwState {
+	realFocus: string | null;
+	standalone: { log: string; logIsInfo: boolean; focusedLabel: string | null };
+	columns: SwColumn[];
+}
+
+const READY_JS =
+	"document.readyState === 'complete' && !!document.querySelector('sw-gallery')?.shadowRoot?.querySelector('swg-button-standalone')";
+
+async function run(ctx: ScenarioContext): Promise<void> {
+	const { cdp, checks } = ctx;
+	const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail);
+	const st = async (): Promise<SwState> => JSON.parse((await cdp.js(STATE_JS)) as string);
+	const centerOf = async (jsElExpr: string): Promise<{ x: number; y: number }> => {
+		const box = await cdp.js(`
+		(() => {
+		  const r = (${jsElExpr}).getBoundingClientRect();
+		  return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2});
+		})()
+		`);
+		return JSON.parse(box as string);
+	};
+
+	await openGallery(ctx, "#sw-button", READY_JS);
+
+	// --- standalone variant ---
+	// The gallery seats initial focus in the entry's first tab stop on open:
+	// for #sw-button that is the first standalone button.  (Seating happens a
+	// frame after render; poll briefly.)
+	let landed = false;
+	let s = await st();
+	for (let i = 0; i < 20; i++) {
+		s = await st();
+		if (s.realFocus === "standalone/Dock at station") {
+			landed = true;
+			break;
+		}
+		await sleep(50);
+	}
+	check("initial-focus-seats-standalone-button", landed, JSON.stringify(s.realFocus));
+
+	// Native Enter activation.
+	await cdp.key("Enter");
+	s = await st();
+	check("standalone-enter-activates", s.standalone.log === "docked", s.standalone.log);
+
+	// The focused button's own hotkey.  (Tab to units first: its hotkey is 7,
+	// distinct from Enter's effect, so the log proves the hotkey path.)
+	await cdp.key("Tab");
+	s = await st();
+	check("tab-to-units", s.realFocus === "standalone/units", JSON.stringify(s.realFocus));
+	await cdp.key("7");
+	s = await st();
+	check("standalone-hotkey-activates", s.standalone.log === "units opened", s.standalone.log);
+
+	// i for info on the focused button.
+	await cdp.key("i");
+	s = await st();
+	check(
+		"standalone-i-informs",
+		s.standalone.log === "info: opens the units registry" && s.standalone.logIsInfo,
+		s.standalone.log,
+	);
+
+	// The hotkey answers only while ITS button is focused: units' 7 from dock does nothing.
+	await cdp.key("Tab", { shift: true }); // back to dock
+	await cdp.key("7");
+	s = await st();
+	check(
+		"standalone-hotkey-needs-focus",
+		s.standalone.log === "info: opens the units registry",
+		s.standalone.log,
+	);
+
+	// Disabled: still a tab stop, Enter dead, i answers.  (Four tabs: the
+	// standalone row gained the active 'cruise' example at stage-migrate-infocard:
+	// dock -> units -> transfer -> cruise -> self destruct.)
+	for (let i = 0; i < 4; i++) {
+		await cdp.key("Tab");
+	}
+	s = await st();
+	check(
+		"disabled-is-a-tab-stop",
+		s.realFocus === "standalone/self destruct",
+		JSON.stringify(s.realFocus),
+	);
+	await cdp.key("Enter");
+	s = await st();
+	check(
+		"disabled-enter-dead",
+		s.standalone.log === "info: opens the units registry",
+		s.standalone.log,
+	);
+	await cdp.key("i");
+	s = await st();
+	check(
+		"disabled-i-answers",
+		s.standalone.log === "info: offline: requires command authorization",
+		s.standalone.log,
+	);
+
+	// Mouse click on the dock button (trusted click via CDP).
+	const SA_ROOT =
+		'document.querySelector("sw-gallery").shadowRoot.querySelector("swg-button-standalone")';
+	let pt = await centerOf(
+		`[...${SA_ROOT}.shadowRoot.querySelectorAll("sw-button")].find(x => x.label === "Dock at station")`,
+	);
+	await cdp.click(pt.x, pt.y);
+	s = await st();
+	check("standalone-mouse-click-activates", s.standalone.log === "docked", s.standalone.log);
+
+	// --- ops console (single column) variant ---
+	// The click above moved real focus onto the dock button, so Tab from there
+	// walks the remaining standalone buttons before reaching the container.
+	landed = false;
+	for (let i = 0; i < 8; i++) {
+		await cdp.key("Tab");
+		s = await st();
+		if (s.realFocus === "ops console/container") {
+			landed = true;
+			break;
+		}
+	}
+	check("tab-into-ops-console", landed, JSON.stringify(s.realFocus));
+	const ops = (v: SwState): SwColumn => v.columns[0] as SwColumn;
+	check("ops-first-row-dock", ops(s).rowFocused === "dock", JSON.stringify(ops(s)));
+
+	// Enter on the focused button row.
+	await cdp.key("Enter");
+	s = await st();
+	check("row-enter-activates", ops(s).log === "docked", ops(s).log);
+
+	// Column-scoped hotkey: b (beacon) is a JUMP -- focus moves to the beacon
+	// row, nothing activates (see-before-commit: Enter is the commit).
+	await cdp.key("b");
+	s = await st();
+	check("column-hotkey-moves-focus", ops(s).rowFocused === "beacon", JSON.stringify(ops(s)));
+	check("column-hotkey-does-not-activate", ops(s).log === "docked", ops(s).log);
+	await cdp.key("Enter");
+	s = await st();
+	check("enter-after-jump-activates", ops(s).log === "beacon cycled", ops(s).log);
+
+	// ... and d jumps back; Space mirrors Enter as the commit.
+	await cdp.key("d");
+	s = await st();
+	check(
+		"jump-back-to-dock",
+		ops(s).rowFocused === "dock" && ops(s).log === "beacon cycled",
+		JSON.stringify(ops(s)),
+	);
+	await cdp.key(" ");
+	s = await st();
+	check("row-space-activates", ops(s).log === "docked", ops(s).log);
+
+	// i on the focused row's button.
+	await cdp.key("i");
+	s = await st();
+	check(
+		"row-i-informs",
+		ops(s).log === "info: requests docking clearance" && ops(s).logIsInfo,
+		ops(s).log,
+	);
+
+	// The disabled purge row: focusable, Space claims but declines.
+	await cdp.key("ArrowDown");
+	await cdp.key("ArrowDown");
+	s = await st();
+	check("disabled-row-focusable", ops(s).rowFocused === "purge", JSON.stringify(ops(s)));
+	const MAIN_SCROLL =
+		"document.querySelector('sw-gallery').shadowRoot.querySelector('main').scrollTop";
+	const before = await cdp.js(MAIN_SCROLL);
+	await cdp.key(" ");
+	s = await st();
+	const after = await cdp.js(MAIN_SCROLL);
+	check(
+		"disabled-space-claimed-not-acted",
+		ops(s).log === "info: requests docking clearance",
+		ops(s).log,
+	);
+	check("disabled-space-no-scroll", before === after, `${before} -> ${after}`);
+	await cdp.key("i");
+	s = await st();
+	check(
+		"disabled-row-i-answers",
+		ops(s).log === "info: offline: filters already clean",
+		ops(s).log,
+	);
+
+	// Cargo rows: x from main jettisons via the aux button.
+	await cdp.key("ArrowDown");
+	s = await st();
+	check("into-cargo", ops(s).rowFocused === "ore crate", JSON.stringify(ops(s)));
+	await cdp.key("Enter");
+	s = await st();
+	check("cargo-enter-inspects", ops(s).log === "ore crate inspected", ops(s).log);
+	await cdp.key("x");
+	s = await st();
+	check(
+		"x-jettisons-via-aux-button",
+		ops(s).log === "ore crate jettisoned" && ops(s).cargoRows === 1,
+		JSON.stringify(ops(s)),
+	);
+	// Delete works too; first arrow onto the surviving crate row's aux and use Enter instead.
+	s = await st();
+	check("focus-fell-to-next-crate", ops(s).rowFocused === "strut bundle", JSON.stringify(ops(s)));
+	await cdp.key("ArrowRight");
+	await cdp.key("i");
+	s = await st();
+	check("aux-i-informs", ops(s).log.includes("jettisons this crate"), ops(s).log);
+	await cdp.key("Enter");
+	s = await st();
+	check(
+		"enter-on-aux-jettisons",
+		ops(s).log === "strut bundle jettisoned" && ops(s).cargoRows === 0,
+		JSON.stringify(ops(s)),
+	);
+
+	// A trusted mouse click on a row-mode sw-button: exactly ONE activation.
+	// The controller synthesizes Enter from unclaimed row clicks (pointer
+	// activation for plain rows); sw-button claims its own clicks so the
+	// synthesis skips it -- a broken dedup would log two activations here.
+	const OPS_BEACON = `document.querySelector("sw-gallery").shadowRoot
+	    .querySelector("swg-button-column").shadowRoot
+	    .querySelector('[data-mc-row="beacon"] sw-button')`;
+	await cdp.js(
+		`window.__acts = 0; (${OPS_BEACON}).addEventListener('click', () => { window.__acts += 1 })`,
+	);
+	pt = await centerOf(OPS_BEACON);
+	await cdp.click(pt.x, pt.y);
+	s = await st();
+	check("row-button-click-activates", ops(s).log === "beacon cycled", ops(s).log);
+	const acts = await cdp.js("window.__acts");
+	check("row-button-click-activates-exactly-once", acts === 1, JSON.stringify(acts));
+
+	// --- scoping variant: two columns, both mark d ---
+	await cdp.key("Tab");
+	s = await st();
+	check("tab-into-port", s.realFocus === "port console/container", JSON.stringify(s.realFocus));
+	const port = (v: SwState): SwColumn => v.columns[1] as SwColumn;
+	const starboard = (v: SwState): SwColumn => v.columns[2] as SwColumn;
+	// The focused row's OWN hotkey does nothing: not a jump (already here),
+	// not an activation (double-tap must not out-power single-tap).
+	await cdp.key("d"); // focus sits on port's dock row
+	s = await st();
+	check(
+		"own-hotkey-does-nothing",
+		port(s).rowFocused === "dock" && port(s).log === "",
+		JSON.stringify(port(s)),
+	);
+	// From another row, d jumps only in the focused column.
+	await cdp.key("ArrowDown"); // -> beacon row
+	await cdp.key("d");
+	s = await st();
+	check(
+		"d-jumps-in-port",
+		port(s).rowFocused === "dock" && port(s).log === "",
+		JSON.stringify(port(s)),
+	);
+	check(
+		"starboard-never-hears-d",
+		starboard(s).rowFocused === "dock" && starboard(s).log === "",
+		JSON.stringify(starboard(s)),
+	);
+	await cdp.key("Enter");
+	s = await st();
+	check("port-enter-commits", port(s).log === "docked", port(s).log);
+	await cdp.key("Tab");
+	s = await st();
+	check(
+		"tab-into-starboard",
+		s.realFocus === "starboard console/container",
+		JSON.stringify(s.realFocus),
+	);
+	await cdp.key("c"); // starboard's beacon letter: a jump
+	s = await st();
+	check(
+		"starboard-own-letter-jumps",
+		starboard(s).rowFocused === "beacon",
+		JSON.stringify(starboard(s)),
+	);
+	await cdp.key("Enter");
+	s = await st();
+	check("starboard-enter-commits", starboard(s).log === "beacon cycled", starboard(s).log);
+	await cdp.key("d"); // jump back to starboard's dock
+	await cdp.key("Enter");
+	s = await st();
+	check(
+		"d-jumps-in-starboard",
+		starboard(s).rowFocused === "dock" && starboard(s).log === "docked",
+		JSON.stringify(starboard(s)),
+	);
+	check(
+		"port-log-unchanged",
+		port(s).log === "docked" && !port(s).focusWithin,
+		JSON.stringify(port(s)),
+	);
+
+	// --- scroll safety: a hotkey jump to an off-screen row scrolls it into view ---
+	// Browsers do not scroll aria-activedescendant targets on their own; the
+	// controller does, on every focus identity change.  Shrink the viewport so
+	// the page overflows its scroller, park the scroller at the bottom (the ops
+	// console's dock row far off-screen above), and jump to it with d.
+	await cdp.cmd("Emulation.setDeviceMetricsOverride", {
+		width: 1440,
+		height: 400,
+		deviceScaleFactor: 1,
+		mobile: false,
+	});
+	landed = false;
+	for (let i = 0; i < 16; i++) {
+		await cdp.key("Tab");
+		s = await st();
+		if (s.realFocus === "ops console/container") {
+			landed = true;
+			break;
+		}
+	}
+	check("scroll-tab-into-ops", landed, JSON.stringify(s.realFocus));
+	check("scroll-memory-not-on-dock", ops(s).rowFocused !== "dock", JSON.stringify(ops(s)));
+	const DOCK_VIS = `
+	(() => {
+	  const m = document.querySelector('sw-gallery').shadowRoot.querySelector('main');
+	  const row = document.querySelector('sw-gallery').shadowRoot
+	    .querySelector('swg-button-column').shadowRoot
+	    .querySelector('[data-mc-row="dock"]');
+	  const mr = m.getBoundingClientRect(), rr = row.getBoundingClientRect();
+	  return JSON.stringify({scrollTop: m.scrollTop,
+	    visible: rr.bottom > mr.top && rr.top < mr.bottom});
+	})()`;
+	await cdp.js(
+		"const m = document.querySelector('sw-gallery').shadowRoot.querySelector('main'); m.scrollTop = m.scrollHeight",
+	);
+	const visBefore = JSON.parse((await cdp.js(DOCK_VIS)) as string) as {
+		scrollTop: number;
+		visible: boolean;
+	};
+	check("scroll-setup-dock-off-screen", !visBefore.visible, JSON.stringify(visBefore));
+	await cdp.key("d"); // the jump: focus lands on dock, and the ring must be visible
+	s = await st();
+	const visAfter = JSON.parse((await cdp.js(DOCK_VIS)) as string) as {
+		scrollTop: number;
+		visible: boolean;
+	};
+	check("scroll-jump-landed-on-dock", ops(s).rowFocused === "dock", JSON.stringify(ops(s)));
+	check(
+		"scroll-jump-scrolled-into-view",
+		visAfter.visible && visAfter.scrollTop !== visBefore.scrollTop,
+		JSON.stringify([visBefore, visAfter]),
+	);
+	await shot(cdp, "hotkey-jump.png");
+	await cdp.cmd("Emulation.clearDeviceMetricsOverride");
+
+	// --- activation feedback: the sw-held / sw-pressed classes ---
+	// Reload to reset the demos; initial focus re-seats on the first standalone button.
+	await cdp.cmd("Page.reload", { ignoreCache: true });
+	for (let i = 0; i < 50; i++) {
+		if ((await cdp.js(READY_JS)) === true) break;
+		await sleep(100);
+	}
+	for (let i = 0; i < 20; i++) {
+		s = await st();
+		if (s.realFocus === "standalone/Dock at station") break;
+		await sleep(50);
+	}
+
+	// A recorder beats a screenshot race: the observer is installed before the
+	// keystroke, so a 120ms flash cannot slip between CDP roundtrips.
+	// Each arm disconnects the previous observer: stale observers otherwise
+	// increment whatever __flash is current when their callbacks run.
+	// find: a JS expression yielding the sw-button host to watch.
+	const armFlash = async (find: string): Promise<void> => {
+		await cdp.js(`
+		(() => {
+		  window.__flash?.obs.disconnect();
+		  const b = ${find};
+		  const obs = new MutationObserver(() => {
+		    if (b.classList.contains("sw-pressed")) window.__flash.count += 1;
+		  });
+		  obs.observe(b, { attributes: true, attributeFilter: ["class"] });
+		  window.__flash = { el: b, count: 0, obs };
+		})()
+		`);
+	};
+	const flashSeen = async (): Promise<boolean> => {
+		for (let i = 0; i < 30; i++) {
+			if ((await cdp.js("window.__flash.count > 0")) === true) return true;
+			await sleep(20);
+		}
+		return false;
+	};
+	const flashCleared = async (): Promise<boolean> => {
+		for (let i = 0; i < 30; i++) {
+			if ((await cdp.js("!window.__flash.el.classList.contains('sw-pressed')")) === true)
+				return true;
+			await sleep(20);
+		}
+		return false;
+	};
+
+	const SA_BUTTON = (label: string) =>
+		`[...document.querySelector("sw-gallery").shadowRoot
+		    .querySelector("swg-button-standalone").shadowRoot
+		    .querySelectorAll("sw-button")].find(x => x.label === "${label}")`;
+
+	// Keyboard activation flashes: trusted Enter on the focused standalone button.
+	await armFlash(SA_BUTTON("Dock at station"));
+	await cdp.key("Enter");
+	check("flash-on-trusted-enter", await flashSeen());
+	check("flash-clears-after-press-ms", await flashCleared());
+
+	// The focused button's hotkey flashes too (tab to units, hotkey 7).
+	await cdp.key("Tab");
+	await armFlash(SA_BUTTON("units"));
+	await cdp.key("7");
+	check("flash-on-trusted-hotkey", await flashSeen());
+	check("hotkey-flash-clears", await flashCleared());
+
+	// Held under a trusted pointer: press without release, look, release.
+	pt = await centerOf(SA_BUTTON("Dock at station"));
+	await cdp.mouseDown(pt.x, pt.y);
+	const held = await cdp.js(`(${SA_BUTTON("Dock at station")}).classList.contains('sw-held')`);
+	check("held-while-pointer-down", held === true);
+	// The held look IS the flash look, and it sits still: the one screenshot
+	// a 120ms transient can't reliably give us.
+	await shot(cdp, "press-flash.png");
+	await cdp.mouseUp(pt.x, pt.y);
+	const released = await cdp.js(`!(${SA_BUTTON("Dock at station")}).classList.contains('sw-held')`);
+	check("held-releases-on-pointer-up", released === true);
+	// The release click flashes Dock; let that settle before the disabled check
+	// so no mutation from it is in flight when the observer moves.
+	for (let i = 0; i < 30; i++) {
+		if (
+			(await cdp.js(`(${SA_BUTTON("Dock at station")}).classList.contains('sw-pressed')`)) !== true
+		)
+			break;
+		await sleep(20);
+	}
+
+	// Disabled: neither held nor flashed (nothing happened; nothing may claim otherwise).
+	pt = await centerOf(SA_BUTTON("self destruct"));
+	await armFlash(SA_BUTTON("self destruct"));
+	await cdp.mouseDown(pt.x, pt.y);
+	const heldDisabled = await cdp.js(
+		`(${SA_BUTTON("self destruct")}).classList.contains('sw-held')`,
+	);
+	await cdp.mouseUp(pt.x, pt.y);
+	check("disabled-never-held", heldDisabled !== true);
+	await sleep(300); // give a wrong flash time to have happened
+	check("disabled-never-flashed", (await cdp.js("window.__flash.count > 0")) !== true);
+
+	// Row mode: Enter on the focused row flashes its button; a column-scoped
+	// hotkey JUMP flashes nothing (a focus move is navigation -- ring movement
+	// and flash must stay distinct vocabularies), and the Enter that follows
+	// flashes the landed row's button.
+	landed = false;
+	for (let i = 0; i < 8; i++) {
+		await cdp.key("Tab");
+		s = await st();
+		if (s.realFocus === "ops console/container") {
+			landed = true;
+			break;
+		}
+	}
+	check("feedback-tab-into-ops", landed, JSON.stringify(s.realFocus));
+	const COL_BUTTON = (row: string) =>
+		`document.querySelector("sw-gallery").shadowRoot
+		    .querySelector("swg-button-column").shadowRoot
+		    .querySelector('[data-mc-row="${row}"] sw-button')`;
+	await armFlash(COL_BUTTON("dock"));
+	await cdp.key("Enter");
+	check("row-flash-on-trusted-enter", await flashSeen());
+	check("row-flash-clears", await flashCleared());
+	await armFlash(COL_BUTTON("beacon"));
+	await cdp.key("b"); // a jump: focus moves to beacon, nothing activates
+	await sleep(300); // give a wrong flash time to have happened
+	s = await st();
+	check("no-flash-on-hotkey-jump", (await cdp.js("window.__flash.count > 0")) !== true);
+	check("jump-landed-on-beacon", ops(s).rowFocused === "beacon", JSON.stringify(ops(s)));
+	await cdp.key("Enter"); // the commit: now the flash names the actor
+	check("flash-on-enter-after-jump", await flashSeen());
+
+	// Plain megacolumn rows (no widget): the demo host flashes the row itself.
+	await cdp.js("location.hash = '#megacolumn'");
+	await cdp.cmd("Page.reload", { ignoreCache: true });
+	for (let i = 0; i < 50; i++) {
+		if (
+			(await cdp.js(
+				"document.readyState === 'complete' && !!document.querySelector('sw-gallery')?.shadowRoot?.querySelector('swg-megacolumn-demo')",
+			)) === true
+		)
+			break;
+		await sleep(100);
+	}
+	landed = false;
+	for (let i = 0; i < 20; i++) {
+		if (
+			(await cdp.js(`document.querySelector("sw-gallery").shadowRoot
+				.querySelector("swg-megacolumn-demo").shadowRoot
+				.querySelector("[data-mc-container]").classList.contains("mc-focus-within")`)) === true
+		) {
+			landed = true;
+			break;
+		}
+		await sleep(50);
+	}
+	check("plain-rows-focus-seated", landed);
+	await armFlash(`document.querySelector("sw-gallery").shadowRoot
+	    .querySelector("swg-megacolumn-demo").shadowRoot
+	    .querySelector('[data-mc-row="beacon"]')`);
+	await cdp.key("Enter");
+	check("plain-row-flash-on-trusted-enter", await flashSeen());
+	check("plain-row-flash-clears", await flashCleared());
+}
+
+export const swDrive: Scenario = { name: "sw", run };
diff --git a/packages/yukey-testkit/README.md b/packages/yukey-testkit/README.md
new file mode 100644
index 0000000..5f83238
--- /dev/null
+++ b/packages/yukey-testkit/README.md
@@ -0,0 +1,36 @@
+yukey-testkit
+=============
+
+A dependency-free CDP client for driving a page with *trusted* input,
+and reading its state between steps.
+
+It exists because synthetic DOM events can't drive real focus traversal:
+a `KeyboardEvent` dispatched from JavaScript won't move focus on Tab,
+won't type into an input, and won't trigger hover.
+Trusted input (`Input.dispatchKeyEvent` and friends over the Chrome DevTools Protocol)
+is what a real keyboard and mouse produce, so a test built on it exercises the same paths a user does.
+That's the only kind of test that can prove a keyboard-first UI actually works from the keyboard.
+
+Node 22+, using its built-in `WebSocket`.  Nothing else.
+
+
+What's in it
+------------
+
+- `yukey-testkit/cdp`: the `Cdp` class.
+  `Cdp.connect(wsUrl)` to a page target; then `key`, `click`, `mouseMove`, `wheel` for trusted input,
+  `js(expr)` for state reads, `screenshot()`, and `cmd` for any other protocol method.
+- `yukey-testkit/checks`: the `Checks` collector (a PASS/FAIL line protocol with a summary),
+  the `Scenario` and `ScenarioContext` shapes a suite runner can share, and `shot` for optional screenshot evidence
+  (written only when `YUKEY_E2E_SHOTS_DIR` is set).
+- `yukey-testkit/gallery`: `openGallery`, the setup etiquette for landing on a yukey gallery entry in a fresh document.
+- `yukey-testkit/sleep`: a promise-returning sleep for polling loops.
+
+Launching Chrome and serving your page are yours to do; they vary too much per app to belong here.
+Yukey's own `packages/e2e/harness.ts` is one worked example (static files on an ephemeral port, a headless Chrome with a throwaway profile).
+
+
+License
+-------
+
+SPDX-License-Identifier: Apache-2.0 OR MIT
diff --git a/packages/yukey-testkit/src/cdp.ts b/packages/yukey-testkit/src/cdp.ts
new file mode 100644
index 0000000..8d3f5b6
--- /dev/null
+++ b/packages/yukey-testkit/src/cdp.ts
@@ -0,0 +1,203 @@
+// A small typed CDP client: JSON-RPC over a websocket to one page target.
+//
+// This is the shared core of the e2e scenarios: TRUSTED input dispatch
+// (Input.dispatchKeyEvent / Input.dispatchMouseEvent -- synthetic DOM events
+// cannot drive real focus traversal, real typing, or real hover) plus
+// Runtime.evaluate state reads between steps.
+// Runs on Node 22's built-in WebSocket; no dependencies.
+
+// Key definitions for the non-printable keys the scenarios use:
+// windowsVirtualKeyCode + code, exactly what a real keyboard produces.
+const KEYDEFS: Record<string, [number, string]> = {
+	Tab: [9, "Tab"],
+	Enter: [13, "Enter"],
+	Escape: [27, "Escape"],
+	ArrowUp: [38, "ArrowUp"],
+	ArrowDown: [40, "ArrowDown"],
+	ArrowLeft: [37, "ArrowLeft"],
+	ArrowRight: [39, "ArrowRight"],
+	Home: [36, "Home"],
+	End: [35, "End"],
+	Delete: [46, "Delete"],
+	" ": [32, "Space"],
+};
+
+interface PendingCommand {
+	resolve: (result: unknown) => void;
+	reject: (err: Error) => void;
+}
+
+// A protocol event (a message with a method and no id), e.g. Runtime.consoleAPICalled.
+export interface CdpEvent {
+	method: string;
+	params: unknown;
+}
+
+export class Cdp {
+	private readonly ws: WebSocket;
+	private msgid = 0;
+	private readonly pending = new Map<number, PendingCommand>();
+	// Every protocol event received on this connection, in arrival order.
+	// Domains emit nothing until enabled (e.g. Runtime.enable for console capture),
+	// so this stays empty unless a scenario asks for events.
+	readonly events: CdpEvent[] = [];
+
+	private constructor(ws: WebSocket) {
+		this.ws = ws;
+		ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
+	}
+
+	static async connect(wsUrl: string): Promise<Cdp> {
+		const ws = new WebSocket(wsUrl);
+		await new Promise<void>((resolve, reject) => {
+			ws.addEventListener("open", () => resolve(), { once: true });
+			ws.addEventListener("error", () => reject(new Error(`websocket connect failed: ${wsUrl}`)), {
+				once: true,
+			});
+		});
+		return new Cdp(ws);
+	}
+
+	close(): void {
+		this.ws.close();
+	}
+
+	private onMessage(raw: string): void {
+		const msg = JSON.parse(raw) as {
+			id?: number;
+			method?: string;
+			params?: unknown;
+			result?: unknown;
+			error?: unknown;
+		};
+		if (msg.method !== undefined) {
+			this.events.push({ method: msg.method, params: msg.params });
+			return;
+		}
+		if (msg.id === undefined) return;
+		const waiter = this.pending.get(msg.id);
+		if (waiter === undefined) return;
+		this.pending.delete(msg.id);
+		if (msg.error !== undefined) {
+			waiter.reject(new Error(`CDP error: ${JSON.stringify(msg.error)}`));
+		} else {
+			waiter.resolve(msg.result);
+		}
+	}
+
+	cmd(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
+		this.msgid += 1;
+		const id = this.msgid;
+		const sent = new Promise<unknown>((resolve, reject) => {
+			this.pending.set(id, { resolve, reject });
+		});
+		this.ws.send(JSON.stringify({ id, method, params }));
+		return sent;
+	}
+
+	// Dispatch a trusted key press (down + up).  Named keys come from KEYDEFS;
+	// anything else is treated as a printable character.
+	// Enter and Space carry their text payload on a full keyDown
+	// (rawKeyDown skips text insertion; native widgets need the text to act).
+	async key(key: string, opts: { shift?: boolean } = {}): Promise<void> {
+		const mods = opts.shift === true ? 8 : 0;
+		const def = KEYDEFS[key];
+		if (def !== undefined) {
+			const [vk, code] = def;
+			const text = key === " " ? " " : key === "Enter" ? "\r" : "";
+			const down: Record<string, unknown> = {
+				type: "rawKeyDown",
+				windowsVirtualKeyCode: vk,
+				code,
+				key,
+				modifiers: mods,
+			};
+			if (text !== "") {
+				down.type = "keyDown";
+				down.text = text;
+			}
+			await this.cmd("Input.dispatchKeyEvent", down);
+			await this.cmd("Input.dispatchKeyEvent", {
+				type: "keyUp",
+				windowsVirtualKeyCode: vk,
+				code,
+				key,
+				modifiers: mods,
+			});
+			return;
+		}
+		const vk = key.toUpperCase().charCodeAt(0);
+		const code = /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : "";
+		await this.cmd("Input.dispatchKeyEvent", {
+			type: "keyDown",
+			windowsVirtualKeyCode: vk,
+			code,
+			key,
+			text: key,
+			modifiers: mods,
+		});
+		await this.cmd("Input.dispatchKeyEvent", {
+			type: "keyUp",
+			windowsVirtualKeyCode: vk,
+			code,
+			key,
+			modifiers: mods,
+		});
+	}
+
+	async mouseMove(x: number, y: number): Promise<void> {
+		await this.cmd("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
+	}
+
+	async mouseDown(x: number, y: number, button = "left"): Promise<void> {
+		await this.cmd("Input.dispatchMouseEvent", {
+			type: "mousePressed",
+			x,
+			y,
+			button,
+			clickCount: 1,
+		});
+	}
+
+	async mouseUp(x: number, y: number, button = "left"): Promise<void> {
+		await this.cmd("Input.dispatchMouseEvent", {
+			type: "mouseReleased",
+			x,
+			y,
+			button,
+			clickCount: 1,
+		});
+	}
+
+	async click(x: number, y: number, button = "left"): Promise<void> {
+		await this.mouseDown(x, y, button);
+		await this.mouseUp(x, y, button);
+	}
+
+	// A trusted wheel tick at (x, y).  Positive deltaY scrolls down (the map
+	// reads that as zoom OUT); negative zooms in, anchored under the cursor.
+	async wheel(x: number, y: number, deltaY: number): Promise<void> {
+		await this.cmd("Input.dispatchMouseEvent", {
+			type: "mouseWheel",
+			x,
+			y,
+			deltaX: 0,
+			deltaY,
+		});
+	}
+
+	// Evaluate an expression in the page, returning its value by JSON.
+	// awaitPromise resolves a returned promise first (needed by probes that
+	// measure over a time window).
+	async js(expr: string, opts: { awaitPromise?: boolean } = {}): Promise<unknown> {
+		const params: Record<string, unknown> = { expression: expr, returnByValue: true };
+		if (opts.awaitPromise === true) params.awaitPromise = true;
+		const r = (await this.cmd("Runtime.evaluate", params)) as { result?: { value?: unknown } };
+		return r.result?.value;
+	}
+
+	async screenshot(): Promise<Buffer> {
+		const r = (await this.cmd("Page.captureScreenshot", { format: "png" })) as { data: string };
+		return Buffer.from(r.data, "base64");
+	}
+}
diff --git a/packages/yukey-testkit/src/checks.ts b/packages/yukey-testkit/src/checks.ts
new file mode 100644
index 0000000..261bb60
--- /dev/null
+++ b/packages/yukey-testkit/src/checks.ts
@@ -0,0 +1,53 @@
+// The check collector: the PASS/FAIL line protocol every scenario reports through.
+// Same shape as the drivers always had: each check prints immediately,
+// failures carry their detail, and the run summarizes at the end.
+
+import { mkdirSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import type { Cdp } from "./cdp.ts";
+
+export interface ScenarioContext {
+	cdp: Cdp;
+	baseUrl: string;
+	checks: Checks;
+}
+
+export interface Scenario {
+	// The short name used in run output and for selecting scenarios from the CLI.
+	name: string;
+	run(ctx: ScenarioContext): Promise<void>;
+}
+
+export class Checks {
+	private readonly results: { name: string; ok: boolean }[] = [];
+
+	check(name: string, cond: boolean, detail = ""): void {
+		this.results.push({ name, ok: cond });
+		console.log((cond ? "PASS " : "FAIL ") + name + (detail !== "" && !cond ? `  ${detail}` : ""));
+	}
+
+	skip(name: string, why: string): void {
+		console.log(`SKIP ${name} (${why})`);
+	}
+
+	get passed(): number {
+		return this.results.filter((r) => r.ok).length;
+	}
+
+	get total(): number {
+		return this.results.length;
+	}
+
+	get failed(): number {
+		return this.total - this.passed;
+	}
+}
+
+// Screenshots were stage evidence in the original drivers, never assertions:
+// they write only when YUKEY_E2E_SHOTS_DIR names a destination, and no-op otherwise.
+export async function shot(cdp: Cdp, name: string): Promise<void> {
+	const dir = process.env.YUKEY_E2E_SHOTS_DIR;
+	if (dir === undefined || dir === "") return;
+	mkdirSync(dir, { recursive: true });
+	writeFileSync(join(dir, name), await cdp.screenshot());
+}
diff --git a/packages/yukey-testkit/src/gallery.ts b/packages/yukey-testkit/src/gallery.ts
new file mode 100644
index 0000000..517708c
--- /dev/null
+++ b/packages/yukey-testkit/src/gallery.ts
@@ -0,0 +1,32 @@
+// Opening a gallery entry: park the hash, force a fresh document, wait for readiness.
+// This is the setup etiquette every gallery scenario shared as a long-lived-browser
+// convention; here it is explicit and self-contained per scenario.
+
+import type { ScenarioContext } from "./checks.ts";
+import { sleep } from "./sleep.ts";
+
+export async function openGallery(
+	ctx: ScenarioContext,
+	hash: string,
+	readyExpr: string,
+): Promise<void> {
+	const { cdp } = ctx;
+	// A fresh browser starts on about:blank: get onto gallery.html first.
+	// (Hash changes on the current document are synchronous; cross-document
+	// navigation needs the load wait before the reload below can be aimed.)
+	const onGallery = await cdp.js("location.pathname === '/gallery.html'");
+	if (onGallery !== true) {
+		await cdp.cmd("Page.navigate", { url: `${ctx.baseUrl}/gallery.html${hash}` });
+		for (let i = 0; i < 50; i++) {
+			if ((await cdp.js("document.readyState === 'complete'")) === true) break;
+			await sleep(100);
+		}
+	}
+	await cdp.js(`location.hash = ${JSON.stringify(hash)}`);
+	await cdp.cmd("Page.reload", { ignoreCache: true });
+	for (let i = 0; i < 50; i++) {
+		if ((await cdp.js(readyExpr)) === true) return;
+		await sleep(100);
+	}
+	throw new Error(`gallery entry ${hash} never became ready`);
+}
diff --git a/packages/yukey-testkit/src/sleep.ts b/packages/yukey-testkit/src/sleep.ts
new file mode 100644
index 0000000..227aeb9
--- /dev/null
+++ b/packages/yukey-testkit/src/sleep.ts
@@ -0,0 +1,3 @@
+// A promise that resolves after ms.  Scenarios use it to poll between steps;
+// the harness that launches Chrome uses it the same way.
+export const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
diff --git a/packages/yukey-testkit/tsconfig.json b/packages/yukey-testkit/tsconfig.json
index d991313..b97fee7 100644
--- a/packages/yukey-testkit/tsconfig.json
+++ b/packages/yukey-testkit/tsconfig.json
@@ -7,6 +7,7 @@
 		"types": ["node"],
 		"strict": true,
 		"noUncheckedIndexedAccess": true,
+		"rewriteRelativeImportExtensions": true,
 		"noEmit": true
 	},
 	"include": ["src"]
diff --git a/packages/yukey/README.md b/packages/yukey/README.md
new file mode 100644
index 0000000..3655aab
--- /dev/null
+++ b/packages/yukey/README.md
@@ -0,0 +1,42 @@
+yukey
+=====
+
+*a ui framework for the keyboard lover*
+
+Lit web components for application UIs where the keyboard is on par with the mouse, or better.
+The pitch, with a screenshot, is in the repository README;
+the precepts the kit follows are in its `docs/`.
+
+
+Install
+-------
+
+```
+npm install yukey lit
+```
+
+`lit` is a peer dependency: the components are plain Lit elements and share your app's copy.
+
+
+Use
+---
+
+Every module is importable by its file name:
+
+```ts
+import "yukey/sw-button";                                  // registers the custom element
+import { MegacolumnController } from "yukey/megacolumn-controller";
+import { installTokens } from "yukey/tokens";
+
+installTokens();                                           // once, at startup: the design tokens into :root
+```
+
+Components (`sw-button`, `sw-list`, `sw-live-list`, `sw-infopop`) register themselves on import.
+Mechanisms (`megacolumn-controller`, `tab-cycle`, `focus-stack`, `press-flash`, and so on) are plain exports.
+The gallery (`yukey/gallery/main`) is the living documentation: every component, every variant, driven by the real thing.
+
+
+License
+-------
+
+SPDX-License-Identifier: Apache-2.0 OR MIT
diff --git a/packages/yukey/package.json b/packages/yukey/package.json
index f6acb1d..705c093 100644
--- a/packages/yukey/package.json
+++ b/packages/yukey/package.json
@@ -3,7 +3,32 @@
 	"version": "0.0.0",
 	"description": "a ui framework for the keyboard lover: Lit web components where the keyboard is on par with the mouse",
 	"license": "Apache-2.0 OR MIT",
-	"sideEffects": true,
+	"sideEffects": [
+		"./src/sw-button.ts",
+		"./src/sw-infopop.ts",
+		"./src/sw-list.ts",
+		"./src/sw-live-list.ts",
+		"./src/gallery/sw-gallery.ts",
+		"./src/gallery/main.ts",
+		"./src/megacolumn.gallery.ts",
+		"./src/sw-button.gallery.ts",
+		"./src/sw-infopop.gallery.ts",
+		"./src/sw-list.gallery.ts",
+		"./src/sw-live-list.gallery.ts",
+		"./src/tokens.gallery.ts",
+		"./dist/sw-button.js",
+		"./dist/sw-infopop.js",
+		"./dist/sw-list.js",
+		"./dist/sw-live-list.js",
+		"./dist/gallery/sw-gallery.js",
+		"./dist/gallery/main.js",
+		"./dist/megacolumn.gallery.js",
+		"./dist/sw-button.gallery.js",
+		"./dist/sw-infopop.gallery.js",
+		"./dist/sw-list.gallery.js",
+		"./dist/sw-live-list.gallery.js",
+		"./dist/tokens.gallery.js"
+	],
 	"type": "module",
 	"exports": {
 		"./*": {
diff --git a/packages/yukey/src/adopt-into-root.ts b/packages/yukey/src/adopt-into-root.ts
new file mode 100644
index 0000000..2d05745
--- /dev/null
+++ b/packages/yukey/src/adopt-into-root.ts
@@ -0,0 +1,30 @@
+// Stylesheet adoption for components that render into their OWNER's tree.
+// Megacolumn-composed components (sw-live-list mega mode, sw-list) render light-DOM
+// rows -- the megacolumn scan and aria-activedescendant cannot cross shadow
+// boundaries -- so their styles must reach the owner's root, not a shadow root of
+// their own.  This appends a sheet to the root's adopted stylesheets, once per root.
+//
+// By hand, because lit's adoptStyles() REPLACES a root's sheets and the root here
+// belongs to the owner.  Where constructed stylesheets are unsupported (jsdom),
+// fall back to one marked <style> tag -- the same fallback shape lit itself uses;
+// `marker` is the attribute name that makes the fallback idempotent (one per
+// adopter class, e.g. "data-sw-live-list").
+
+import type { CSSResult } from "lit";
+
+export function adoptIntoRoot(root: Node, styles: CSSResult, marker: string): void {
+	if (!(root instanceof ShadowRoot || root instanceof Document))
+		throw new Error(`adopt-into-root (${marker}): not inside any document or shadow root`);
+	const sheet = styles.styleSheet;
+	if (sheet !== undefined) {
+		if (!root.adoptedStyleSheets.includes(sheet))
+			root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
+		return;
+	}
+	const holder = root instanceof Document ? root.head : root;
+	if (holder.querySelector(`style[${marker}]`) !== null) return;
+	const style = document.createElement("style");
+	style.setAttribute(marker, "");
+	style.textContent = styles.cssText;
+	holder.appendChild(style);
+}
diff --git a/packages/yukey/src/focus-stack.test.ts b/packages/yukey/src/focus-stack.test.ts
new file mode 100644
index 0000000..08a5368
--- /dev/null
+++ b/packages/yukey/src/focus-stack.test.ts
@@ -0,0 +1,292 @@
+// @vitest-environment jsdom
+//
+// Tests for the focus stack (focus-stack.ts): the journey semantics the
+// guidelines' "Focus is a stack" norm demands.  The rig registers plain-div
+// surfaces (host > tabbable inner), plus shadow-root surfaces where composed
+// containment is the thing under test; focus moves are real jsdom focus.
+
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { FocusStack, focusStackOf, installFocusStack } from "./focus-stack.js";
+
+afterEach(() => {
+	document.body.replaceChildren();
+});
+
+// A surface rig: a host div holding one tabbable inner div.
+interface Rig {
+	host: HTMLElement;
+	inner: HTMLElement;
+}
+
+function makeSurface(parent: ParentNode = document.body): Rig {
+	const host = document.createElement("div");
+	const inner = document.createElement("div");
+	inner.tabIndex = 0;
+	host.appendChild(inner);
+	parent.appendChild(host);
+	return { host, inner };
+}
+
+describe("push and pop", () => {
+	let stack: FocusStack;
+	beforeEach(() => {
+		stack = new FocusStack(document);
+	});
+
+	it("journey-select-escape: pop returns focus to the origin surface", () => {
+		const a = makeSurface();
+		const b = makeSurface();
+		stack.register({ id: "a", root: () => a.host });
+		stack.register({ id: "b", root: () => b.host });
+		a.inner.focus();
+		stack.entered("b");
+		b.inner.focus();
+		stack.closed("b", true);
+		expect(document.activeElement).toBe(a.inner);
+	});
+
+	it("journey-lateral-select: entered() on the top surface again keeps the origin", () => {
+		const a = makeSurface();
+		const b = makeSurface();
+		stack.register({ id: "a", root: () => a.host });
+		stack.register({ id: "b", root: () => b.host });
+		a.inner.focus();
+		stack.entered("b");
+		b.inner.focus();
+		// A lateral move: the surface re-targets and re-seats; no push.
+		stack.entered("b");
+		expect(stack.entries.length).toBe(1);
+		stack.closed("b", true);
+		expect(document.activeElement).toBe(a.inner);
+	});
+
+	it("journey-reopened-origin: origins are identity, resolved to the CURRENT element", () => {
+		const a = makeSurface();
+		const b = makeSurface();
+		let aNow = a;
+		stack.register({ id: "a", root: () => aNow.host });
+		stack.register({ id: "b", root: () => b.host });
+		a.inner.focus();
+		stack.entered("b");
+		b.inner.focus();
+		// The origin surface closes and reopens as a fresh element (a re-render).
+		a.host.remove();
+		aNow = makeSurface();
+		stack.closed("b", true);
+		expect(document.activeElement).toBe(aNow.inner);
+	});
+
+	it("journey-nested-origin: the origin is the INNERMOST containing surface", () => {
+		// An outer surface whose shadow root hosts an inner surface (the
+		// registry/filters composition): entering a third surface from inside
+		// the inner one records the inner one.
+		const outerHost = document.createElement("div");
+		document.body.appendChild(outerHost);
+		const shadow = outerHost.attachShadow({ mode: "open" });
+		const inner = makeSurface(shadow);
+		const c = makeSurface();
+		stack.register({ id: "outer", root: () => outerHost });
+		stack.register({ id: "inner", root: () => inner.host });
+		stack.register({ id: "c", root: () => c.host });
+		inner.inner.focus();
+		expect(stack.path()).toEqual(["outer", "inner"]);
+		stack.entered("c");
+		c.inner.focus();
+		stack.closed("c", true);
+		expect(shadow.activeElement).toBe(inner.inner);
+	});
+
+	it("a shadow-rooted surface pops to its first tab stop inside the shadow", () => {
+		const host = document.createElement("div");
+		document.body.appendChild(host);
+		const shadow = host.attachShadow({ mode: "open" });
+		const container = document.createElement("div");
+		container.tabIndex = 0;
+		shadow.appendChild(container);
+		const b = makeSurface();
+		stack.register({ id: "panel", root: () => host });
+		stack.register({ id: "b", root: () => b.host });
+		container.focus();
+		stack.entered("b");
+		b.inner.focus();
+		stack.closed("b", true);
+		expect(shadow.activeElement).toBe(container);
+	});
+
+	it("entered() from nowhere records a null origin", () => {
+		const b = makeSurface();
+		stack.register({ id: "b", root: () => b.host });
+		stack.entered("b");
+		expect(stack.entries).toEqual([{ entered: "b", origin: null }]);
+	});
+
+	it("entered() with focus already inside the surface degrades the origin to null", () => {
+		const b = makeSurface();
+		stack.register({ id: "b", root: () => b.host });
+		b.inner.focus();
+		stack.entered("b");
+		expect(stack.entries).toEqual([{ entered: "b", origin: null }]);
+	});
+
+	it("one-entry-per-id: entering a surface buried deeper retires its old entry", () => {
+		const a = makeSurface();
+		const b = makeSurface();
+		const c = makeSurface();
+		stack.register({ id: "a", root: () => a.host });
+		stack.register({ id: "b", root: () => b.host });
+		stack.register({ id: "c", root: () => c.host });
+		a.inner.focus();
+		stack.entered("b"); // {b <- a}
+		b.inner.focus();
+		stack.entered("c"); // {c <- b}
+		c.inner.focus();
+		stack.entered("b"); // b again: the old {b <- a} retires, {b <- c} pushes
+		expect(stack.entries).toEqual([
+			{ entered: "c", origin: "b" },
+			{ entered: "b", origin: "c" },
+		]);
+	});
+});
+
+describe("dead entries and routing", () => {
+	let stack: FocusStack;
+	beforeEach(() => {
+		stack = new FocusStack(document);
+	});
+
+	it("journey-dead-origin: a dead origin unwinds to the fallback", () => {
+		const a = makeSurface();
+		const b = makeSurface();
+		const fallback = makeSurface();
+		stack.setFallback(() => fallback.inner);
+		stack.register({ id: "a", root: () => a.host });
+		stack.register({ id: "b", root: () => b.host });
+		a.inner.focus();
+		stack.entered("b");
+		b.inner.focus();
+		a.host.remove(); // the origin died while b held focus
+		stack.closed("b", true);
+		expect(document.activeElement).toBe(fallback.inner);
+	});
+
+	it("unwinds past a dead origin to the innermost remaining open surface", () => {
+		const a = makeSurface();
+		const b = makeSurface();
+		const c = makeSurface();
+		stack.register({ id: "a", root: () => a.host });
+		stack.register({ id: "b", root: () => b.host });
+		stack.register({ id: "c", root: () => c.host });
+		stack.entered("a"); // {a <- null}
+		a.inner.focus();
+		stack.entered("b"); // {b <- a}
+		b.inner.focus();
+		stack.entered("c"); // {c <- b}
+		c.inner.focus();
+		b.host.remove(); // c's origin dies; its own entry is swept on close
+		stack.closed("c", true);
+		expect(document.activeElement).toBe(a.inner);
+		expect(stack.entries).toEqual([{ entered: "a", origin: null }]);
+	});
+
+	it("journey-overlay-collapse: closing a surface sweeps entries of surfaces that died with it", () => {
+		// The registry/filters shape: the inner overlay unmounts when the panel
+		// does, without its own closed() call.
+		const panel = makeSurface();
+		const overlay = makeSurface(panel.host);
+		const fallback = makeSurface();
+		stack.setFallback(() => fallback.inner);
+		stack.register({ id: "panel", root: () => panel.host });
+		stack.register({ id: "overlay", root: () => overlay.host });
+		stack.entered("panel");
+		panel.inner.focus();
+		stack.entered("overlay");
+		overlay.inner.focus();
+		panel.host.remove(); // takes the overlay with it
+		stack.closed("panel", true);
+		expect(stack.entries).toEqual([]);
+		expect(document.activeElement).toBe(fallback.inner);
+	});
+
+	it("a held close with no entry still routes (a passive open that focus wandered into)", () => {
+		const a = makeSurface();
+		const b = makeSurface();
+		stack.register({ id: "a", root: () => a.host });
+		stack.register({ id: "b", root: () => b.host });
+		stack.entered("a");
+		a.inner.focus();
+		// b opened passively (no entered), the user tabbed in, then it closed.
+		b.inner.focus();
+		stack.closed("b", true);
+		expect(document.activeElement).toBe(a.inner);
+	});
+
+	it("an unheld close retires the entry and moves nothing", () => {
+		const a = makeSurface();
+		const b = makeSurface();
+		stack.register({ id: "a", root: () => a.host });
+		stack.register({ id: "b", root: () => b.host });
+		a.inner.focus();
+		stack.entered("b");
+		// Focus never followed (or already left); the close must not yank it.
+		stack.closed("b", false);
+		expect(stack.entries).toEqual([]);
+		expect(document.activeElement).toBe(a.inner);
+	});
+
+	it("a live origin with no tab stop left is skipped like a dead one", () => {
+		const a = makeSurface();
+		const b = makeSurface();
+		const fallback = makeSurface();
+		stack.setFallback(() => fallback.inner);
+		stack.register({ id: "a", root: () => a.host });
+		stack.register({ id: "b", root: () => b.host });
+		a.inner.focus();
+		stack.entered("b");
+		b.inner.focus();
+		a.inner.remove(); // the origin survives but lost its one tab stop
+		stack.closed("b", true);
+		expect(document.activeElement).toBe(fallback.inner);
+	});
+
+	it("fail closed: routing with no live stop and no fallback throws", () => {
+		const b = makeSurface();
+		stack.register({ id: "b", root: () => b.host });
+		stack.entered("b");
+		b.inner.focus();
+		expect(() => stack.closed("b", true)).toThrow(/no live origin and no fallback/);
+	});
+});
+
+describe("registration and accessors", () => {
+	it("registering a duplicate id throws", () => {
+		const stack = new FocusStack(document);
+		const a = makeSurface();
+		stack.register({ id: "a", root: () => a.host });
+		expect(() => stack.register({ id: "a", root: () => a.host })).toThrow(/already registered/);
+	});
+
+	it("entered() for an unregistered surface throws", () => {
+		const stack = new FocusStack(document);
+		expect(() => stack.entered("ghost")).toThrow(/no such surface/);
+	});
+
+	it("clear() retires every entry and routes nothing", () => {
+		const stack = new FocusStack(document);
+		const a = makeSurface();
+		const b = makeSurface();
+		stack.register({ id: "a", root: () => a.host });
+		stack.register({ id: "b", root: () => b.host });
+		a.inner.focus();
+		stack.entered("b");
+		stack.clear();
+		expect(stack.entries).toEqual([]);
+		expect(document.activeElement).toBe(a.inner);
+	});
+
+	it("focusStackOf returns one stack per document; installFocusStack wires its fallback", () => {
+		const stack = focusStackOf(document);
+		expect(focusStackOf(document)).toBe(stack);
+		const fallback = makeSurface();
+		expect(installFocusStack(document, () => fallback.inner)).toBe(stack);
+	});
+});
diff --git a/packages/yukey/src/focus-stack.ts b/packages/yukey/src/focus-stack.ts
new file mode 100644
index 0000000..5f2fcc2
--- /dev/null
+++ b/packages/yukey/src/focus-stack.ts
@@ -0,0 +1,235 @@
+// The focus stack: the kit's answer to "where does focus go when this closes?"
+// (the guidelines' "Focus is a stack" norm).  Opening a component that contains
+// focus PUSHES the place focus came from; closing it POPS back there; dead
+// entries are skipped; the page shell's fallback is the end of the line.
+// Focus falling to <body> is always a bug, and this file is the one mechanism
+// that prevents it -- page shells must not grow bespoke relocation logic beside it.
+//
+// The shape mirrors tab containment (tab-cycle.ts): one stack per document,
+// installed by the page shell:
+//
+//   installFocusStack(document, () => <the fallback element>);
+//
+// and components participate through the same per-document accessor:
+//
+//   const stack = focusStackOf(this.ownerDocument);
+//
+// SURFACES are the stack's vocabulary: a surface is anything that can hold
+// focus and open/close (a panel, a card, an anchored overlay).  A component
+// registers itself as a surface while connected (and unregisters on
+// disconnect), naming itself with a stable id:
+//
+//   stack.register({ id: CARD_SURFACE, root: () => this });
+//
+// Everything is IDENTITY, never element references: entries record surface
+// ids, and root() is re-queried on every use -- a surface that closed and
+// reopened is the same journey stop, resolved to whatever element carries the
+// id now.  A root that is gone or disconnected reads as dead.
+//
+// The OWNER of a surface's open/close lifecycle makes two calls:
+//
+//   entered(id)      right before seating focus in a user-opened surface,
+//                    while focus still sits wherever the user came from.
+//                    Pushes { entered: id, origin: <the innermost registered
+//                    surface holding focus, or null> }.
+//   closed(id, held) when the surface closes (any reason: Escape, ×, deselect,
+//                    data death).  `held` is whether the surface contained the
+//                    focus as the close began -- the owner computes it BEFORE
+//                    the DOM changes (path() is the tool).  Retires the
+//                    surface's entry always; routes focus only when held.
+//
+// The journey rules these calls implement:
+//
+//   replace-not-push   entered(id) while id is already the top entry is a
+//                      no-op: lateral moves (selecting ship B while ship A's
+//                      card is open) re-target the surface without burying
+//                      the journey origin.
+//   one-entry-per-id   entered(id) with an older entry buried deeper retires
+//                      that entry first: a surface is one journey stop, and
+//                      the stack stays bounded.
+//   pop-skips-dead     a held close routes to the retired entry's origin if
+//                      it is alive and focusable; otherwise it unwinds to the
+//                      innermost remaining entry's surface (entries whose
+//                      surfaces died are swept first); otherwise the fallback.
+//   fail-closed        routing with nowhere to go throws.  The number of
+//                      places focus may land on "nothing" is between zero and
+//                      one in the whole UI, and that one is the page shell's
+//                      fallback surface -- never a silent drop to <body>.
+//
+// Popping focuses the surface's first tab stop (for a megacolumn host that is
+// its one [data-mc-container], whose machine's row memory then restores the
+// row -- the stack returns to the right CONTAINER; the machine takes it from
+// there).  The walk descends the surface's shadow root, so hosts register
+// themselves and nothing needs to export inner elements.
+
+import { deepActiveElement, deepTabbables } from "./tab-cycle.js";
+
+export interface FocusSurface {
+	// Stable identity, unique per document.  Components use their tag name.
+	id: string;
+	// The surface's live root element (usually the registering host itself).
+	// Re-queried on every use; null or a disconnected element reads as dead.
+	root(): HTMLElement | null;
+}
+
+interface StackEntry {
+	entered: string;
+	origin: string | null;
+}
+
+// Whether `node` sits inside `ancestor`, crossing shadow boundaries upward
+// (a shadow root's contents are composed-inside its host).
+function composedContains(ancestor: Node, node: Node | null): boolean {
+	let cur: Node | null = node;
+	while (cur !== null) {
+		if (cur === ancestor) return true;
+		cur = cur.parentNode ?? (cur instanceof ShadowRoot ? cur.host : null);
+	}
+	return false;
+}
+
+export class FocusStack {
+	#doc: Document;
+	#surfaces = new Map<string, FocusSurface>();
+	#entries: StackEntry[] = [];
+	#fallback: (() => HTMLElement | null) | undefined;
+
+	constructor(doc: Document) {
+		this.#doc = doc;
+	}
+
+	// The end of the line for pops: the page shell's one predictable focus home
+	// (the root surface -- sw-app points this at its map instance).
+	setFallback(fallback: () => HTMLElement | null): void {
+		this.#fallback = fallback;
+	}
+
+	// The entries, oldest first (introspection for tests and debugging; the
+	// stack's behavior is entirely through entered/closed).
+	get entries(): readonly StackEntry[] {
+		return this.#entries;
+	}
+
+	// Retire every entry, routing nothing: a fresh-world boundary.  Page shells
+	// never need this (their surfaces close through closed()); test rigs that
+	// bulldoze the DOM between cases do, because a per-document stack outlives
+	// a document.body.replaceChildren().
+	clear(): void {
+		this.#entries = [];
+	}
+
+	register(surface: FocusSurface): void {
+		if (this.#surfaces.has(surface.id))
+			throw new Error(`focus-stack: surface "${surface.id}" is already registered`);
+		this.#surfaces.set(surface.id, surface);
+	}
+
+	unregister(id: string): void {
+		this.#surfaces.delete(id);
+	}
+
+	// The registered surfaces currently containing the deep-active element,
+	// outermost first.  Owners snapshot this BEFORE a DOM change to compute
+	// `held` for closed() (containment is unknowable after the unmount).
+	path(): string[] {
+		const active = deepActiveElement(this.#doc);
+		if (active === null) return [];
+		const containing: { id: string; root: HTMLElement }[] = [];
+		for (const surface of this.#surfaces.values()) {
+			const root = this.#liveRoot(surface.id);
+			if (root !== null && composedContains(root, active))
+				containing.push({ id: surface.id, root });
+		}
+		// Surfaces containing one focus point form a containment chain, so this
+		// pairwise comparator is a total order on them.
+		containing.sort((a, b) => (a === b ? 0 : composedContains(a.root, b.root) ? -1 : 1));
+		return containing.map((c) => c.id);
+	}
+
+	// A user-opened surface is about to seat focus: record where focus came from.
+	// Call while focus still sits at the origin (before the seat moves it).
+	entered(id: string): void {
+		if (!this.#surfaces.has(id))
+			throw new Error(`focus-stack: entered("${id}") but no such surface is registered`);
+		const top = this.#entries[this.#entries.length - 1];
+		if (top?.entered === id) return; // replace-not-push: the journey origin holds
+		this.#entries = this.#entries.filter((e) => e.entered !== id); // one entry per id
+		const path = this.path();
+		// The innermost containing surface that isn't the entered one itself
+		// (a self-origin would be a pop to nowhere).
+		const origin = [...path].reverse().find((s) => s !== id) ?? null;
+		this.#entries.push({ entered: id, origin });
+	}
+
+	// A surface closed.  Retires its entry and sweeps entries whose surfaces
+	// died with it; when the surface held focus, routes focus to the journey's
+	// nearest live stop (see the header's pop-skips-dead rule).
+	closed(id: string, held: boolean): void {
+		let retired: StackEntry | undefined;
+		for (let i = this.#entries.length - 1; i >= 0; i--) {
+			if (this.#entries[i]?.entered === id) {
+				retired = this.#entries[i];
+				this.#entries.splice(i, 1);
+				break;
+			}
+		}
+		// Sweep: an entry whose surface is dead has nothing left to pop back to
+		// it (a closing panel takes its inner overlays with it).
+		this.#entries = this.#entries.filter((e) => this.#liveRoot(e.entered) !== null);
+		if (!held) return;
+		const candidates: (string | null)[] = [retired?.origin ?? null];
+		for (let i = this.#entries.length - 1; i >= 0; i--) {
+			candidates.push(this.#entries[i]?.entered ?? null);
+		}
+		for (const candidate of candidates) {
+			if (candidate === null) continue;
+			if (this.#focusSurface(candidate)) return;
+		}
+		const fallback = this.#fallback?.() ?? null;
+		if (fallback === null)
+			throw new Error(
+				`focus-stack: "${id}" closed holding focus with no live origin and no fallback`,
+			);
+		fallback.focus();
+	}
+
+	#liveRoot(id: string): HTMLElement | null {
+		const root = this.#surfaces.get(id)?.root();
+		return root?.isConnected ? root : null;
+	}
+
+	// Seat focus on a surface's first tab stop.  False when the surface is dead
+	// or has no stop to give (routing then unwinds past it).
+	#focusSurface(id: string): boolean {
+		const root = this.#liveRoot(id);
+		if (root === null) return false;
+		const base = root.shadowRoot ?? root;
+		const target = root.tabIndex >= 0 ? root : deepTabbables(base)[0];
+		if (target === undefined) return false;
+		target.focus();
+		return true;
+	}
+}
+
+// One stack per document, lazily (a WeakSet-of-documents shape, like tab
+// containment's): components can participate without caring whether a shell
+// installed a fallback yet, and tests get a real stack for free.
+const stacks = new WeakMap<Document, FocusStack>();
+
+export function focusStackOf(doc: Document): FocusStack {
+	let stack = stacks.get(doc);
+	if (stack === undefined) {
+		stack = new FocusStack(doc);
+		stacks.set(doc, stack);
+	}
+	return stack;
+}
+
+// The one call a page shell makes: the document's stack, with the shell's
+// fallback wired.  Idempotent in the same way tab containment is (repeat
+// installs just re-point the fallback).
+export function installFocusStack(doc: Document, fallback: () => HTMLElement | null): FocusStack {
+	const stack = focusStackOf(doc);
+	stack.setFallback(fallback);
+	return stack;
+}
diff --git a/packages/yukey/src/gallery/entry.ts b/packages/yukey/src/gallery/entry.ts
new file mode 100644
index 0000000..9933ab1
--- /dev/null
+++ b/packages/yukey/src/gallery/entry.ts
@@ -0,0 +1,34 @@
+// The gallery-entry convention (this file is its SSOT):
+//
+// Each component ships a gallery entry file beside itself -- `foo.gallery.ts` next to
+// `foo.ts` -- default-exporting a GalleryEntry.  Registration is one import line in
+// `registry.ts`.  The `.gallery.ts` suffix keeps entries out of the app bundle (only
+// the gallery entry point imports them) and out of the vitest net (`*.test.ts`).
+//
+// A variant is a caption plus a render function.  For anything stateful or
+// interactive, define a small demo custom element inside the .gallery.ts file and
+// have the variant render it: the GalleryEntry surface stays declarative, and
+// statefulness lives where we always put it -- in a Lit component.
+
+import type { TemplateResult } from "lit";
+
+export interface GalleryVariant {
+	// One or two sentences: what this variant shows, and what to try or watch for.
+	caption: string;
+	// Wide variants get the full content column instead of the default 560px stage
+	// (reference tables and ramps want room; component demos usually don't).
+	wide?: boolean;
+	// Called on each shell render; returning fresh templates means a demo restarts
+	// cleanly when navigated away from and back to.
+	render: () => TemplateResult;
+}
+
+export interface GalleryEntry {
+	// Hash-route slug; conventionally the element tag name (e.g. "sw-live-list").
+	id: string;
+	title: string;
+	// What the component is for, in a sentence or two.  This is living documentation:
+	// write it for the person deciding whether this component fits their screen.
+	description: string;
+	variants: GalleryVariant[];
+}
diff --git a/packages/yukey/src/gallery/main.ts b/packages/yukey/src/gallery/main.ts
new file mode 100644
index 0000000..5a08eb8
--- /dev/null
+++ b/packages/yukey/src/gallery/main.ts
@@ -0,0 +1,6 @@
+// Gallery entry point (esbuild bundles this to static/gallery.js; gallery.html hosts it).
+// Registering <sw-gallery> is all that's needed; it pulls the entry registry in turn.
+import { installTokens } from "../tokens.js";
+import "./sw-gallery.js";
+
+installTokens();
diff --git a/packages/yukey/src/gallery/registry.ts b/packages/yukey/src/gallery/registry.ts
new file mode 100644
index 0000000..72744a7
--- /dev/null
+++ b/packages/yukey/src/gallery/registry.ts
@@ -0,0 +1,20 @@
+// The one place gallery entries are registered: one import line per component.
+// Keep the list alphabetical by id so the nav order is deterministic and merges stay clean.
+// (See entry.ts for the convention itself.)
+
+import megacolumn from "../megacolumn.gallery.js";
+import swButton from "../sw-button.gallery.js";
+import swInfopop from "../sw-infopop.gallery.js";
+import swList from "../sw-list.gallery.js";
+import swLiveList from "../sw-live-list.gallery.js";
+import tokens from "../tokens.gallery.js";
+import type { GalleryEntry } from "./entry.js";
+
+export const entries: GalleryEntry[] = [
+	megacolumn,
+	swButton,
+	swInfopop,
+	swList,
+	swLiveList,
+	tokens,
+];
diff --git a/packages/yukey/src/gallery/sw-gallery.ts b/packages/yukey/src/gallery/sw-gallery.ts
new file mode 100644
index 0000000..af62b8a
--- /dev/null
+++ b/packages/yukey/src/gallery/sw-gallery.ts
@@ -0,0 +1,164 @@
+// The component gallery shell: nav of registered entries on the left, the selected
+// entry's variants on the right.  Hash routing (#sw-live-list) so entries are
+// linkable and reload survives.  Styled entirely from the design tokens: the shell
+// is the token vocabulary's first real consumer, and doubles as a proof of it.
+
+import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import "../sw-infopop.js";
+import { deepTabbables, installTabContainment } from "../tab-cycle.js";
+import type { GalleryEntry } from "./entry.js";
+import { entries } from "./registry.js";
+
+@customElement("sw-gallery")
+export class SwGallery extends LitElement {
+	static styles = css`
+		:host {
+			display: grid;
+			grid-template-columns: 220px 1fr;
+			height: 100%;
+			font: var(--sw-type-body) var(--sw-font);
+			color: var(--sw-text);
+			background: var(--sw-bg-void);
+		}
+		nav {
+			border-right: 1px solid var(--sw-border-dim);
+			padding: var(--sw-space-md) 0;
+			overflow-y: auto;
+		}
+		nav .head {
+			padding: 0 var(--sw-space-lg) var(--sw-space-md);
+			color: var(--sw-text-dim);
+			letter-spacing: var(--sw-tracking-wide);
+			text-transform: uppercase;
+			font-size: var(--sw-type-small);
+		}
+		nav a {
+			display: block;
+			min-height: var(--sw-hit-min-h);
+			box-sizing: border-box;
+			padding: var(--sw-space-xs) var(--sw-space-lg);
+			color: var(--sw-accent-bright);
+			text-decoration: none;
+		}
+		nav a:hover {
+			color: var(--sw-text-bright);
+			background: var(--sw-wash-hover);
+		}
+		nav a.current {
+			color: var(--sw-text-bright);
+			background: var(--sw-wash-selected);
+		}
+		nav a:focus-visible {
+			outline: var(--sw-focus-ring);
+			outline-offset: calc(-1 * var(--sw-focus-ring-width));
+		}
+		main {
+			padding: var(--sw-space-xl) 28px;
+			overflow-y: auto;
+		}
+		main h1 {
+			font-size: var(--sw-type-head);
+			color: var(--sw-text-bright);
+			letter-spacing: var(--sw-tracking);
+			margin: 0 0 var(--sw-space-sm);
+		}
+		.description {
+			max-width: 70ch;
+			color: var(--sw-text-hi);
+			margin-bottom: var(--sw-space-xl);
+		}
+		.variant {
+			margin-bottom: var(--sw-space-xxl);
+		}
+		.variant .caption {
+			max-width: 70ch;
+			color: var(--sw-text-dim);
+			margin-bottom: var(--sw-space-sm);
+		}
+		.variant .stage {
+			border: 1px solid var(--sw-border-dim);
+			padding: var(--sw-space-lg);
+			max-width: 560px;
+		}
+		.variant .stage.wide {
+			max-width: none;
+		}
+		.quiet {
+			color: var(--sw-text-faint);
+		}
+	`;
+
+	@state() private currentId = "";
+
+	// Hash -> selection.  Bound once (constructor, not connectedCallback) because the
+	// shell lives for the page's whole life; there's nothing to unbind.  Tab is
+	// contained per the kit default: the shell owns the page, so it installs
+	// the containment (see tab-cycle.ts).
+	constructor() {
+		super();
+		window.addEventListener("hashchange", () => this.readHash());
+		installTabContainment(document);
+		this.readHash();
+	}
+
+	private readHash(): void {
+		this.currentId = window.location.hash.replace(/^#/, "");
+	}
+
+	// Opening an entry seats keyboard focus in its content (first tab stop in main,
+	// typically a demo's megacolumn container), so a keyboard user starts interacting
+	// without a click.  The index page has no stops in main; the nav is the start
+	// there.  Deferred a frame: the entry's demo components render after this host,
+	// and their tab stops (tabindex on megacolumn containers) exist only post-render.
+	protected updated(changed: PropertyValues): void {
+		if (!changed.has("currentId")) return;
+		requestAnimationFrame(() => {
+			const region = this.renderRoot.querySelector("main");
+			if (region === null) return;
+			const stop = deepTabbables(region)[0] ?? this.renderRoot.querySelector("nav a");
+			if (stop instanceof HTMLElement) stop.focus();
+		});
+	}
+
+	render(): TemplateResult {
+		const current = entries.find((e) => e.id === this.currentId);
+		return html`
+			<nav>
+				<div class="head">starweave gallery</div>
+				${entries.map(
+					(e) => html`<a href=${`#${e.id}`} class=${e.id === this.currentId ? "current" : ""}
+						>${e.title}</a
+					>`,
+				)}
+			</nav>
+			<main>${current ? this.renderEntry(current) : this.renderIndex()}</main>
+			<sw-infopop></sw-infopop>
+		`;
+	}
+
+	private renderEntry(entry: GalleryEntry): TemplateResult {
+		return html`
+			<h1>${entry.title}</h1>
+			<div class="description">${entry.description}</div>
+			${entry.variants.map(
+				(v) => html`<div class="variant">
+					<div class="caption">${v.caption}</div>
+					<div class="stage ${v.wide ? "wide" : ""}">${v.render()}</div>
+				</div>`,
+			)}
+		`;
+	}
+
+	private renderIndex(): TemplateResult {
+		return html`
+			<h1>component gallery</h1>
+			<div class="description">
+				Living documentation and dev harness for the starweave component library.
+				Pick a component from the nav.  To add one: write a
+				<span class="quiet">foo.gallery.ts</span> beside the component and register it in
+				<span class="quiet">gallery/registry.ts</span>.
+			</div>
+		`;
+	}
+}
diff --git a/packages/yukey/src/infopop-place.test.ts b/packages/yukey/src/infopop-place.test.ts
new file mode 100644
index 0000000..51ad68c
--- /dev/null
+++ b/packages/yukey/src/infopop-place.test.ts
@@ -0,0 +1,90 @@
+// Tests for the pure placement rule (infopop-place.ts).  Plain node env: geometry in, geometry out.
+
+import { describe, expect, it } from "vitest";
+import {
+	ANCHOR_GAP,
+	type Placement,
+	placeInfopop,
+	type Rect,
+	type Size,
+	VIEWPORT_MARGIN,
+} from "./infopop-place.js";
+
+const viewport: Size = { w: 1000, h: 800 };
+const popup: Size = { w: 200, h: 60 };
+
+function anchorAt(x: number, y: number, w = 100, h = 24): Rect {
+	return { x, y, w, h };
+}
+
+function insideViewport(p: Placement): boolean {
+	return (
+		p.x >= VIEWPORT_MARGIN &&
+		p.y >= VIEWPORT_MARGIN &&
+		p.x + popup.w <= viewport.w - VIEWPORT_MARGIN &&
+		p.y + popup.h <= viewport.h - VIEWPORT_MARGIN
+	);
+}
+
+describe("preferred placement", () => {
+	it("goes below the anchor, horizontally centered", () => {
+		const anchor = anchorAt(400, 300);
+		const p = placeInfopop(anchor, popup, viewport);
+		expect(p.side).toBe("below");
+		expect(p.y).toBe(300 + 24 + ANCHOR_GAP);
+		expect(p.x).toBe(400 + 50 - 100); // anchor center minus half the popup width
+		expect(insideViewport(p)).toBe(true);
+	});
+
+	it("never covers the anchor when a side fits", () => {
+		const anchor = anchorAt(400, 300);
+		const p = placeInfopop(anchor, popup, viewport);
+		expect(p.y).toBeGreaterThanOrEqual(anchor.y + anchor.h);
+	});
+});
+
+describe("flipping", () => {
+	it("flips above when below would clip the viewport bottom", () => {
+		const anchor = anchorAt(400, 760); // 40px of room below: popup (60) cannot fit
+		const p = placeInfopop(anchor, popup, viewport);
+		expect(p.side).toBe("above");
+		expect(p.y).toBe(760 - ANCHOR_GAP - popup.h);
+		expect(p.y + popup.h).toBeLessThanOrEqual(anchor.y); // above means above
+		expect(insideViewport(p)).toBe(true);
+	});
+
+	it("stays below when both sides fit", () => {
+		const p = placeInfopop(anchorAt(400, 390), popup, viewport);
+		expect(p.side).toBe("below");
+	});
+
+	it("takes the roomier side when neither fits, clamped into the viewport", () => {
+		const shortViewport: Size = { w: 1000, h: 100 };
+		// Anchor near the top: below has more room (66 vs 10), popup (60+gap) fits neither.
+		const below = placeInfopop(anchorAt(400, 10), popup, shortViewport);
+		expect(below.side).toBe("below");
+		expect(below.y + popup.h).toBeLessThanOrEqual(shortViewport.h - VIEWPORT_MARGIN);
+		// Anchor near the bottom: above has more room.
+		const above = placeInfopop(anchorAt(400, 70), popup, shortViewport);
+		expect(above.side).toBe("above");
+		expect(above.y).toBe(VIEWPORT_MARGIN);
+	});
+});
+
+describe("horizontal clamping", () => {
+	it("clamps at the left edge", () => {
+		const p = placeInfopop(anchorAt(10, 300, 40), popup, viewport);
+		expect(p.x).toBe(VIEWPORT_MARGIN);
+	});
+
+	it("clamps at the right edge", () => {
+		const p = placeInfopop(anchorAt(950, 300, 40), popup, viewport);
+		expect(p.x).toBe(viewport.w - popup.w - VIEWPORT_MARGIN);
+	});
+
+	it("keeps a popup wider than the viewport pinned to the left margin", () => {
+		const wide: Size = { w: 2000, h: 60 };
+		const p = placeInfopop(anchorAt(400, 300), wide, viewport);
+		expect(p.x).toBe(VIEWPORT_MARGIN);
+	});
+});
diff --git a/packages/yukey/src/infopop-place.ts b/packages/yukey/src/infopop-place.ts
new file mode 100644
index 0000000..8170eb9
--- /dev/null
+++ b/packages/yukey/src/infopop-place.ts
@@ -0,0 +1,67 @@
+// The infopop placement rule: pure geometry, no DOM types, no measurement.
+// Given the anchor's rectangle, the popup's size, and the viewport size, decide
+// where the popup goes.  sw-infopop.ts feeds it measured values and applies the result.
+//
+// The rule (deterministic on purpose; simple beats clever for a transient surface):
+//
+//   - Preferred: below the anchor, horizontally centered on it.
+//   - If below would clip the viewport bottom: flip above.
+//   - If both sides clip (the popup is taller than either side's room): take the
+//     roomier side.  Only in this degenerate case may the popup overlap the anchor,
+//     because then there is nowhere it fits at all.
+//   - X is clamped so the popup stays inside the viewport (with a small margin).
+
+export interface Rect {
+	x: number;
+	y: number;
+	w: number;
+	h: number;
+}
+
+export interface Size {
+	w: number;
+	h: number;
+}
+
+export interface Placement {
+	x: number;
+	y: number;
+	side: "below" | "above";
+}
+
+// Gap between the anchor and the popup, and the minimum clearance kept to the
+// viewport edges.  Exported for the component's stylesheet-independent tests.
+export const ANCHOR_GAP = 6;
+export const VIEWPORT_MARGIN = 4;
+
+export function placeInfopop(anchor: Rect, popup: Size, viewport: Size): Placement {
+	const x = clamp(
+		anchor.x + anchor.w / 2 - popup.w / 2,
+		VIEWPORT_MARGIN,
+		Math.max(VIEWPORT_MARGIN, viewport.w - popup.w - VIEWPORT_MARGIN),
+	);
+
+	const belowY = anchor.y + anchor.h + ANCHOR_GAP;
+	const aboveY = anchor.y - ANCHOR_GAP - popup.h;
+	const fitsBelow = belowY + popup.h <= viewport.h - VIEWPORT_MARGIN;
+	const fitsAbove = aboveY >= VIEWPORT_MARGIN;
+
+	if (fitsBelow) return { x, y: belowY, side: "below" };
+	if (fitsAbove) return { x, y: aboveY, side: "above" };
+
+	// Degenerate: no side fits.  Take the roomier side, clamped into the viewport.
+	const roomBelow = viewport.h - (anchor.y + anchor.h);
+	const roomAbove = anchor.y;
+	if (roomBelow >= roomAbove) {
+		return {
+			x,
+			y: Math.max(VIEWPORT_MARGIN, viewport.h - popup.h - VIEWPORT_MARGIN),
+			side: "below",
+		};
+	}
+	return { x, y: VIEWPORT_MARGIN, side: "above" };
+}
+
+function clamp(v: number, lo: number, hi: number): number {
+	return Math.min(Math.max(v, lo), hi);
+}
diff --git a/packages/yukey/src/megacolumn-controller.test.ts b/packages/yukey/src/megacolumn-controller.test.ts
new file mode 100644
index 0000000..d8155d0
--- /dev/null
+++ b/packages/yukey/src/megacolumn-controller.test.ts
@@ -0,0 +1,1468 @@
+// @vitest-environment jsdom
+//
+// Tests for the megacolumn binding layer (megacolumn-controller.ts).
+// The controller only needs a ReactiveControllerHost with a renderRoot, so the
+// host here is a plain fake around a detached-then-attached DOM tree: no Lit
+// rendering, just the DOM contract (data-mc-* attributes) built with innerHTML
+// and hostUpdated() called the way Lit would after a render.
+
+import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
+import {
+	CLAIM_EXCUSE_MS,
+	FALLBACK_LOCKOUT_MS,
+	MegacolumnController,
+	type MegacolumnKeyDetail,
+} from "./megacolumn-controller.js";
+import type { SwInfoDetail } from "./sw-info.js";
+
+// jsdom has no layout and implements no scrollIntoView; the controller calls it
+// on every focus-identity change (logical focus must never sit off-screen).
+// A no-op stands in; the scroll-behavior tests below spy on it.
+Element.prototype.scrollIntoView = (): void => {};
+
+interface Rig {
+	ctl: MegacolumnController;
+	root: HTMLElement;
+	container: HTMLElement;
+	render: (innerHtml: string) => void;
+	key: (key: string, init?: KeyboardEventInit) => KeyboardEvent;
+}
+
+function makeRig(innerHtml: string): Rig {
+	const root = document.createElement("div");
+	document.body.replaceChildren(root);
+	const host = {
+		renderRoot: root,
+		addController(): void {},
+		removeController(): void {},
+		requestUpdate(): void {},
+		updateComplete: Promise.resolve(true),
+	};
+	const ctl = new MegacolumnController(host);
+	const render = (html: string): void => {
+		root.innerHTML = `<div data-mc-container>${html}</div>`;
+		ctl.hostUpdated();
+	};
+	render(innerHtml);
+	const container = root.querySelector<HTMLElement>("[data-mc-container]");
+	if (container === null) throw new Error("unreachable: rig just rendered a container");
+	const key = (k: string, init?: KeyboardEventInit): KeyboardEvent => {
+		const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...init });
+		container.dispatchEvent(e);
+		return e;
+	};
+	return { ctl, root, container, render, key };
+}
+
+const threeRows = `
+	<div data-mc-row="a">alpha</div>
+	<div data-mc-row="b">beta</div>
+	<div data-mc-row="c">gamma</div>
+`;
+
+describe("container wiring", () => {
+	it("makes the container the single real focusable and points aria at the focused row", () => {
+		const { container } = makeRig(threeRows);
+		expect(container.tabIndex).toBe(0);
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-a");
+	});
+
+	it("derives row ids for aria but respects author-set ids", () => {
+		const { container } = makeRig(`<div data-mc-row="a" id="custom">alpha</div>`);
+		expect(container.getAttribute("aria-activedescendant")).toBe("custom");
+	});
+
+	it("assigns listbox/option roles by default, respecting author overrides", () => {
+		const { container, root } = makeRig(
+			`<div data-mc-row="a">alpha</div><div data-mc-row="t" role="treeitem">tree</div>`,
+		);
+		expect(container.getAttribute("role")).toBe("listbox");
+		expect(root.querySelector(`[data-mc-row="a"]`)?.getAttribute("role")).toBe("option");
+		expect(root.querySelector(`[data-mc-row="t"]`)?.getAttribute("role")).toBe("treeitem");
+	});
+
+	it("re-derives stale derived ids when an element is reused for a new row identity", () => {
+		// Lit templates without repeat() reuse DOM nodes by position: the same element
+		// can reappear with a different data-mc-row.  The derived id must follow.
+		const { root, container, ctl } = makeRig(`<div data-mc-row="task-1">first</div>`);
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-task-1");
+		const el = root.querySelector<HTMLElement>("[data-mc-row]");
+		if (el === null) throw new Error("unreachable: row rendered above");
+		el.dataset.mcRow = "task-2"; // reuse: same element, new identity
+		el.textContent = "second";
+		ctl.hostUpdated();
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-task-2");
+		expect(el.id).toBe("mc-task-2");
+	});
+
+	it("throws when the renderRoot has no container", () => {
+		const { root, ctl } = makeRig(threeRows);
+		root.innerHTML = "<div>nothing here</div>";
+		expect(() => ctl.hostUpdated()).toThrow(/no \[data-mc-container\]/);
+	});
+
+	it("throws on rows containing stray native tab stops", () => {
+		expect(() => makeRig(`<div data-mc-row="a"><button>x</button></div>`)).toThrow(
+			/native tab stop \(button\)/,
+		);
+		// ... but tabindex=-1 marked widgets and data-mc-input are fine.
+		makeRig(`<div data-mc-row="a"><button tabindex="-1">x</button></div>`);
+		makeRig(`<div data-mc-row="a"><input data-mc-input></div>`);
+	});
+
+	it("clears aria-activedescendant when no row is focusable", () => {
+		const { container } = makeRig(`<div data-mc-row="a" aria-hidden="true">alpha</div>`);
+		expect(container.hasAttribute("aria-activedescendant")).toBe(false);
+	});
+});
+
+describe("closed rows (aria-hidden)", () => {
+	// aria-hidden="true" is the one closed-row signal: hidden-from-AT and
+	// skipped-by-navigation ride the same attribute, so the two views cannot
+	// disagree.  (The conditions that make the coupling sound are documented at
+	// closedToSight() in the controller.)
+	it("an aria-hidden row is registered but skipped by navigation", () => {
+		const { ctl, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="ghost" aria-hidden="true">collapsing</div>
+			<div data-mc-row="c">gamma</div>
+		`);
+		expect(ctl.nav.rows.find((r) => r.id === "ghost")).toMatchObject({ focusable: false });
+		key("ArrowDown"); // from a: the closed row is not a stop
+		expect(ctl.nav.focusedId).toBe("c");
+	});
+
+	it("a row inside an aria-hidden ancestor is skipped too", () => {
+		// A wrapper can close a whole subtree; a row inside it is exactly as gone
+		// as a row hidden directly (the hotkey scan already treats it that way).
+		const { ctl, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div aria-hidden="true"><div data-mc-row="wrapped">in a hidden wrapper</div></div>
+			<div data-mc-row="c">gamma</div>
+		`);
+		key("ArrowDown");
+		expect(ctl.nav.focusedId).toBe("c");
+	});
+
+	it('aria-hidden="false" does NOT close a row: matched by value, never presence', () => {
+		// aria-hidden="false" is known to behave inconsistently in browsers; a
+		// presence check would silently read it as hidden and make a visible row
+		// unreachable.  The scan matches the value "true" only.
+		const { ctl, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="b" aria-hidden="false">beta</div>
+		`);
+		expect(ctl.nav.rows.find((r) => r.id === "b")).toMatchObject({ focusable: true });
+		key("ArrowDown");
+		expect(ctl.nav.focusedId).toBe("b");
+	});
+
+	it("no data attribute can make a visible row skippable", () => {
+		// data-mc-disabled is deleted from the contract: a visible row misapplying
+		// it once became unreachable by keyboard while staying clickable and
+		// AT-visible.  The attribute now has no effect at all.
+		const { ctl, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="b" data-mc-disabled>beta</div>
+		`);
+		expect(ctl.nav.rows.find((r) => r.id === "b")).toMatchObject({ focusable: true });
+		key("ArrowDown");
+		expect(ctl.nav.focusedId).toBe("b");
+	});
+});
+
+describe("keyboard navigation in the DOM", () => {
+	it("ArrowDown moves the ring classes and aria; the event default is consumed", () => {
+		const { container, root, key } = makeRig(threeRows);
+		container.focus();
+		const e = key("ArrowDown");
+		expect(e.defaultPrevented).toBe(true);
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-b");
+		const b = root.querySelector(`[data-mc-row="b"]`);
+		expect(b?.classList.contains("mc-row-focused")).toBe(true);
+		expect(b?.classList.contains("mc-focused")).toBe(true); // row IS the main control here
+		expect(root.querySelector(`[data-mc-row="a"]`)?.classList.contains("mc-focused")).toBe(false);
+	});
+
+	it("the ring lands on [data-mc-main] when a row declares one", () => {
+		const { root } = makeRig(
+			`<div data-mc-row="a"><span>label</span><span data-mc-main>widget</span></div>`,
+		);
+		const main = root.querySelector("[data-mc-main]");
+		expect(main?.classList.contains("mc-focused")).toBe(true);
+	});
+
+	it("modifier chords are ignored entirely", () => {
+		const { container, key } = makeRig(threeRows);
+		const e = key("ArrowDown", { ctrlKey: true });
+		expect(e.defaultPrevented).toBe(false);
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-a");
+	});
+
+	it("ArrowRight moves the ring to the aux control and left brings it back", () => {
+		const { root, key } = makeRig(
+			`<div data-mc-row="a"><span data-mc-main>item</span><span data-mc-aux>x</span></div>`,
+		);
+		key("ArrowRight");
+		expect(root.querySelector("[data-mc-aux]")?.classList.contains("mc-focused")).toBe(true);
+		expect(root.querySelector("[data-mc-main]")?.classList.contains("mc-focused")).toBe(false);
+		key("ArrowLeft");
+		expect(root.querySelector("[data-mc-main]")?.classList.contains("mc-focused")).toBe(true);
+	});
+});
+
+describe("key forwarding", () => {
+	it("forwarded keys arrive as a megacolumn-key event on the focused control", () => {
+		const { root, key } = makeRig(threeRows);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+		});
+		key("ArrowDown");
+		key("Enter");
+		expect(got).toEqual([{ key: "Enter", rowId: "b", position: "main" }]);
+	});
+
+	it("a handled megacolumn-key consumes the original keydown's default", () => {
+		const { root, key } = makeRig(threeRows);
+		root.addEventListener("megacolumn-key", (e) => e.preventDefault());
+		const e = key(" ");
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("an unhandled megacolumn-key leaves the original keydown's default alone", () => {
+		const { key } = makeRig(threeRows);
+		const e = key("PageDown");
+		expect(e.defaultPrevented).toBe(false);
+	});
+});
+
+describe("text-entry mode in the DOM", () => {
+	const textColumn = `
+		<div data-mc-row="a">alpha</div>
+		<div data-mc-row="q"><input data-mc-input type="text"></div>
+	`;
+
+	it("Enter dives real focus into the input; Escape returns it to the container", () => {
+		const { container, root, key } = makeRig(textColumn);
+		container.focus();
+		key("ArrowDown");
+		const e = key("Enter");
+		expect(e.defaultPrevented).toBe(true);
+		const input = root.querySelector<HTMLInputElement>("[data-mc-input]");
+		expect(document.activeElement).toBe(input);
+		expect(container.classList.contains("mc-text-mode")).toBe(true);
+		// Keys now reach the input natively; the container listener passes them.
+		const typing = key("x");
+		expect(typing.defaultPrevented).toBe(false);
+		key("Escape");
+		expect(document.activeElement).toBe(container);
+		expect(container.classList.contains("mc-text-mode")).toBe(false);
+		// Row focus resumed on the text row, not reset.
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-q");
+	});
+
+	it("the input is taken out of the tab order", () => {
+		const { root } = makeRig(textColumn);
+		expect(root.querySelector<HTMLInputElement>("[data-mc-input]")?.tabIndex).toBe(-1);
+	});
+
+	it("clicking into the input syncs the machine (focusin path)", () => {
+		const { container, root, ctl } = makeRig(textColumn);
+		expect(ctl.nav.focusedId).toBe("a");
+		root.querySelector<HTMLInputElement>("[data-mc-input]")?.focus();
+		expect(ctl.nav.focusedId).toBe("q");
+		expect(ctl.nav.textMode).toBe(true);
+		expect(container.classList.contains("mc-text-mode")).toBe(true);
+	});
+
+	it("focus leaving the container mid-text-entry exits text mode", () => {
+		const { container, key } = makeRig(textColumn);
+		const outside = document.createElement("button");
+		document.body.append(outside);
+		container.focus();
+		key("ArrowDown");
+		key("Enter");
+		outside.focus();
+		expect(container.classList.contains("mc-text-mode")).toBe(false);
+		expect(container.classList.contains("mc-focus-within")).toBe(false);
+	});
+});
+
+describe("focus memory across container blur", () => {
+	it("keeps the row focus while unfocused and resumes on return", () => {
+		const { container, key, root } = makeRig(threeRows);
+		const outside = document.createElement("button");
+		document.body.append(outside);
+		container.focus();
+		key("ArrowDown");
+		expect(container.classList.contains("mc-focus-within")).toBe(true);
+		outside.focus();
+		expect(container.classList.contains("mc-focus-within")).toBe(false);
+		// The memory stays visible: row keeps its focused classes while blurred.
+		expect(root.querySelector(`[data-mc-row="b"]`)?.classList.contains("mc-row-focused")).toBe(
+			true,
+		);
+		container.focus();
+		key("ArrowDown");
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-c");
+	});
+});
+
+describe("mouse sync", () => {
+	it("mousedown on a row moves logical focus there", () => {
+		const { root, container, ctl } = makeRig(threeRows);
+		root
+			.querySelector(`[data-mc-row="c"]`)
+			?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+		expect(ctl.nav.focusedId).toBe("c");
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-c");
+	});
+
+	it("mousedown on an aux control focuses the aux position", () => {
+		const { root, ctl } = makeRig(
+			`<div data-mc-row="a"><span data-mc-main>m</span><span data-mc-aux>x</span></div>`,
+		);
+		root
+			.querySelector("[data-mc-aux]")
+			?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+		expect(ctl.nav.position).toBe("aux");
+	});
+
+	it("mousedown on a closed row is inert", () => {
+		const { root, ctl } = makeRig(
+			`<div data-mc-row="a">a</div><div data-mc-row="d" aria-hidden="true">d</div>`,
+		);
+		root
+			.querySelector(`[data-mc-row="d"]`)
+			?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+		expect(ctl.nav.focusedId).toBe("a");
+	});
+});
+
+describe("pointer activation (click synthesizes Enter)", () => {
+	// The activation story of the DOM contract: Enter, Space, and click are the
+	// same megacolumn-key event on the focused control.  A click on a row's
+	// control dispatches the keyboard path's Enter, so hosts serve pointer and
+	// keyboard through one handler -- and touch works for free (a tap clicks).
+
+	// Real input order: mousedown syncs focus first, then click activates.
+	const click = (el: Element): MouseEvent => {
+		el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, composed: true }));
+		const e = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true });
+		el.dispatchEvent(e);
+		return e;
+	};
+
+	it("a click on a plain row dispatches the same megacolumn-key as Enter", () => {
+		const { root, ctl } = makeRig(threeRows);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+		});
+		const row = root.querySelector(`[data-mc-row="c"]`);
+		if (row === null) throw new Error("row c missing");
+		click(row);
+		expect(got).toEqual([{ key: "Enter", rowId: "c", position: "main" }]);
+		expect(ctl.nav.focusedId).toBe("c");
+	});
+
+	it("the synthesized dispatch targets the row's control, not the click's inner target", () => {
+		// Same element the keyboard path dispatches on, so host acknowledgment
+		// code (flashPress on e.target) needs no pointer branch.
+		const { root } = makeRig(
+			`<div data-mc-row="a"><span data-mc-main><span class="deep">alpha</span></span></div>`,
+		);
+		const targets: EventTarget[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			if (e.target !== null) targets.push(e.target);
+		});
+		const deep = root.querySelector(".deep");
+		if (deep === null) throw new Error("inner span missing");
+		click(deep);
+		expect(targets).toEqual([root.querySelector("[data-mc-main]")]);
+	});
+
+	it("a click on the aux control synthesizes at the aux position", () => {
+		const { root } = makeRig(
+			`<div data-mc-row="a"><span data-mc-main>m</span><span data-mc-aux>x</span></div>`,
+		);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+		});
+		const aux = root.querySelector("[data-mc-aux]");
+		if (aux === null) throw new Error("aux missing");
+		click(aux);
+		expect(got).toEqual([{ key: "Enter", rowId: "a", position: "aux" }]);
+	});
+
+	it("by dispatch time the clicked row is focused (mousedown precedes click)", () => {
+		const { root, ctl } = makeRig(threeRows);
+		const focusAtDispatch: (string | null)[] = [];
+		root.addEventListener("megacolumn-key", () => {
+			focusAtDispatch.push(ctl.nav.focusedId);
+		});
+		expect(ctl.nav.focusedId).toBe("a"); // clicking a row that is NOT the focused one
+		const row = root.querySelector(`[data-mc-row="b"]`);
+		if (row === null) throw new Error("row b missing");
+		click(row);
+		expect(focusAtDispatch).toEqual(["b"]);
+	});
+
+	it("a claimed synthesis consumes the click; an unclaimed one leaves it alone", () => {
+		// The unclaimed case is the offline-row story: aria-disabled rows are
+		// focusable, the host declines their Enter, and a click behaves identically.
+		const { root } = makeRig(
+			`<div data-mc-row="a">alpha</div><div data-mc-row="off" aria-disabled="true">offline</div>`,
+		);
+		root.addEventListener("megacolumn-key", (e) => {
+			if ((e as CustomEvent<MegacolumnKeyDetail>).detail.rowId === "a") e.preventDefault();
+		});
+		const a = root.querySelector(`[data-mc-row="a"]`);
+		const off = root.querySelector(`[data-mc-row="off"]`);
+		if (a === null || off === null) throw new Error("rows missing");
+		expect(click(a).defaultPrevented).toBe(true);
+		expect(click(off).defaultPrevented).toBe(false); // declined, exactly like keyboard Enter
+	});
+
+	it("clicks on a text row's main never synthesize (the text dive owns activation there)", () => {
+		// Keyboard Enter at a text row's main routes to the text dive, never to a
+		// forward -- so a synthesized forward there would be an event no keyboard
+		// could produce.  The input's own click already dives via the focusin path.
+		const { root } = makeRig(
+			`<div data-mc-row="q"><span class="lbl">hail</span><input data-mc-input type="text"></div>`,
+		);
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		const input = root.querySelector("[data-mc-input]");
+		const label = root.querySelector(".lbl");
+		if (input === null || label === null) throw new Error("text row parts missing");
+		click(input);
+		click(label);
+		expect(forwards).toBe(0);
+	});
+
+	it("a defaultPrevented click never synthesizes (a widget already activated on it)", () => {
+		// The dedup rule: a widget that activates on its own native click claims the
+		// click with preventDefault, the same convention as claiming a forwarded key.
+		const { root } = makeRig(threeRows);
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		const row = root.querySelector(`[data-mc-row="a"]`);
+		if (row === null) throw new Error("row a missing");
+		row.addEventListener("click", (e) => e.preventDefault());
+		click(row);
+		expect(forwards).toBe(0);
+	});
+
+	it("a click on a closed row is fully inert (no focus, no synthesis)", () => {
+		const { root, ctl } = makeRig(
+			`<div data-mc-row="a">a</div><div data-mc-row="d" aria-hidden="true">d</div>`,
+		);
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		const row = root.querySelector(`[data-mc-row="d"]`);
+		if (row === null) throw new Error("row d missing");
+		click(row);
+		expect(forwards).toBe(0);
+		expect(ctl.nav.focusedId).toBe("a");
+	});
+
+	it("click synthesis ignores the churn lockout (a click names its own target)", () => {
+		// The lockout protects keystrokes already in flight when focus moved under
+		// them; a click carries its target in its coordinates and its mousedown just
+		// resolved the aim.  (Moving-target clicks are the geometry owner's problem:
+		// sw-live-list's guard stops them in the capture phase before this synthesis.)
+		const { root, render, ctl, key } = makeRig(threeRows);
+		key("ArrowDown"); // -> b
+		render(`<div data-mc-row="a">alpha</div><div data-mc-row="c">gamma</div>`); // b vanishes
+		expect(ctl.nav.focusedId).toBe("c"); // the involuntary fallback: the lockout is armed
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+		});
+		const row = root.querySelector(`[data-mc-row="a"]`);
+		if (row === null) throw new Error("row a missing");
+		click(row);
+		expect(got).toEqual([{ key: "Enter", rowId: "a", position: "main" }]);
+	});
+
+	it("a claimed click opens the claim excuse like a claimed key (commands chain)", () => {
+		// A click whose action removes its row is the same controlled moment as a
+		// claimed x: the vanish is the user's own doing, the next key must flow.
+		const { root, render, ctl, key } = makeRig(threeRows);
+		root.addEventListener("megacolumn-key", (e) => {
+			if ((e as CustomEvent<MegacolumnKeyDetail>).detail.rowId === "b") e.preventDefault();
+		});
+		const row = root.querySelector(`[data-mc-row="b"]`);
+		if (row === null) throw new Error("row b missing");
+		click(row); // the host "removes" b...
+		render(`<div data-mc-row="a">alpha</div><div data-mc-row="c">gamma</div>`); // ...consequence
+		expect(ctl.nav.focusedId).toBe("c");
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		key("Enter"); // chains immediately: no lockout stutter
+		expect(forwards).toBe(1);
+	});
+});
+
+describe("re-render reconciliation", () => {
+	it("row churn across renders follows the machine's fallback rules", () => {
+		const { render, ctl, key } = makeRig(threeRows);
+		key("ArrowDown"); // -> b
+		render(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="c">gamma</div>
+		`);
+		expect(ctl.nav.focusedId).toBe("c");
+	});
+});
+
+describe("scrolling the focused row into view", () => {
+	// Browsers do not scroll aria-activedescendant targets into view on their own,
+	// so the controller does it wherever focus state is applied: one place covers
+	// arrows, Home/End, hotkey jumps, and churn fallbacks alike.
+	let scrolls: MockInstance<Element["scrollIntoView"]>;
+	beforeEach(() => {
+		scrolls = vi.spyOn(Element.prototype, "scrollIntoView");
+	});
+	afterEach(() => {
+		scrolls.mockRestore();
+	});
+	const scrolledRows = (): unknown[] =>
+		scrolls.mock.contexts.map((el) => (el as HTMLElement).dataset?.mcRow);
+
+	it("scrolls the newly focused row on arrow movement, nearest-edge and instant", () => {
+		const { key } = makeRig(threeRows);
+		scrolls.mockClear(); // drop the initial eager-focus scroll
+		key("ArrowDown");
+		expect(scrolledRows()).toEqual(["b"]);
+		expect(scrolls.mock.calls[0]).toEqual([{ block: "nearest", behavior: "instant" }]);
+	});
+
+	it("does not re-scroll while the focused row stays the same", () => {
+		const { render, key } = makeRig(threeRows);
+		scrolls.mockClear();
+		key("z"); // an unclaimed forward: no focus change
+		render(threeRows); // a re-render that keeps the focused row
+		expect(scrolls).not.toHaveBeenCalled();
+	});
+
+	it("scrolls the fallback row when churn yanks the focused row", () => {
+		const { render, key } = makeRig(threeRows);
+		key("ArrowDown"); // -> b
+		scrolls.mockClear();
+		render(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="c">gamma</div>
+		`); // fallback -> c
+		expect(scrolledRows()).toEqual(["c"]);
+	});
+
+	it("scrolls eager focus into view when a row appears in an empty column", () => {
+		const { render } = makeRig(`<div data-mc-row="a" aria-hidden="true">alpha</div>`);
+		scrolls.mockClear();
+		render(`<div data-mc-row="a">alpha</div>`); // now focusable: eager focus lands
+		expect(scrolledRows()).toEqual(["a"]);
+	});
+});
+
+describe("the churn lockout (involuntary focus moves)", () => {
+	// UX-PRINCIPLES: lock out interaction briefly during and slightly after a move.
+	// When the machine's fallback moves focus (the row under the user vanished),
+	// decisive keys must not land on the surprise neighbor.
+	beforeEach(() => {
+		vi.useFakeTimers();
+	});
+	afterEach(() => {
+		vi.useRealTimers();
+	});
+
+	const churnAwayB = `
+		<div data-mc-row="a">alpha</div>
+		<div data-mc-row="c">gamma</div>
+	`;
+
+	it("drops decisive keys briefly after the fallback moves focus", () => {
+		const { render, ctl, root, key } = makeRig(threeRows);
+		key("ArrowDown"); // -> b
+		render(churnAwayB); // fallback -> c
+		expect(ctl.nav.focusedId).toBe("c");
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		const e = key("Enter");
+		expect(forwards).toBe(0);
+		expect(e.defaultPrevented).toBe(true); // consumed, not left to the browser
+	});
+
+	it("navigation keys stay live during the lockout (reorienting is the point)", () => {
+		const { render, ctl, key } = makeRig(threeRows);
+		key("ArrowDown"); // -> b
+		render(churnAwayB); // fallback -> c
+		key("ArrowUp");
+		expect(ctl.nav.focusedId).toBe("a");
+	});
+
+	it("hotkey focus moves work during the lockout (they are navigation)", () => {
+		const { render, ctl, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="b">beta</div>
+			<div data-mc-row="dock" data-mc-hotkey="d"><span data-mc-main>dock</span></div>
+		`);
+		key("ArrowDown"); // -> b
+		render(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="dock" data-mc-hotkey="d"><span data-mc-main>dock</span></div>
+		`); // fallback -> dock's neighbor... (b vanished; fallback -> dock)
+		key("Home"); // reorient to a, still locked out
+		expect(ctl.nav.focusedId).toBe("a");
+		const e = key("d"); // a hotkey jump IS reorientation: it must flow
+		expect(ctl.nav.focusedId).toBe("dock");
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("a hotkey jump opens no claim excuse: an ambient vanish after it still locks out", () => {
+		// A claim excuses the vanish it causes (an x removing its row); a jump is
+		// pure navigation and causes nothing, so a vanish right after one is ambient
+		// churn and the lockout must arm.
+		const { render, ctl, root, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="dock" data-mc-hotkey="d"><span data-mc-main>dock</span></div>
+			<div data-mc-row="c">gamma</div>
+		`);
+		key("d"); // jump a -> dock; consumed, but no claim
+		expect(ctl.nav.focusedId).toBe("dock");
+		render(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="c">gamma</div>
+		`); // ambient churn eats the landed row
+		expect(ctl.nav.focusedId).toBe("c");
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		const e = key("Enter");
+		expect(forwards).toBe(0); // locked out: the jump excused nothing
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("direct hotkey activations are swallowed by the lockout (decisive keys)", () => {
+		const { render, ctl, root, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="b">beta</div>
+			<div data-mc-row="dock" data-mc-hotkey="d" data-mc-hotkey-direct><span data-mc-main>dock</span></div>
+		`);
+		key("ArrowDown"); // -> b
+		render(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="dock" data-mc-hotkey="d" data-mc-hotkey-direct><span data-mc-main>dock</span></div>
+		`); // fallback -> dock; lockout armed
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		const e = key("d");
+		expect(forwards).toBe(0); // neither dispatch happened
+		expect(ctl.nav.focusedId).toBe("dock"); // unchanged (it was the fallback target already)
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("a direct text dive is swallowed by the lockout too (decisive keys)", () => {
+		// The dive rides the forward routing's direct-marking branch, so the
+		// lockout's forward swallow covers it: no text mode, no real focus move,
+		// and no jump either (direct markings are decisive, not navigation).
+		const { render, ctl, root, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="b">beta</div>
+			<div data-mc-row="c">gamma</div>
+			<div data-mc-row="q"><span data-mc-hotkey="h" data-mc-hotkey-direct>hail</span><input data-mc-input type="text"></div>
+		`);
+		key("ArrowDown"); // -> b
+		render(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="c">gamma</div>
+			<div data-mc-row="q"><span data-mc-hotkey="h" data-mc-hotkey-direct>hail</span><input data-mc-input type="text"></div>
+		`); // fallback -> c; lockout armed
+		expect(ctl.nav.focusedId).toBe("c");
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		const e = key("h");
+		expect(forwards).toBe(0); // not even the first-claim dispatch
+		expect(ctl.nav.focusedId).toBe("c"); // no jump
+		expect(ctl.nav.textMode).toBe(false);
+		expect(document.activeElement).not.toBe(root.querySelector("[data-mc-input]"));
+		expect(e.defaultPrevented).toBe(true);
+		// ... and after the window, the dive flows.
+		vi.advanceTimersByTime(FALLBACK_LOCKOUT_MS);
+		key("h");
+		expect(ctl.nav.textMode).toBe(true);
+		expect(document.activeElement).toBe(root.querySelector("[data-mc-input]"));
+	});
+
+	it("decisive keys flow again after the lockout window", () => {
+		const { render, root, key } = makeRig(threeRows);
+		key("ArrowDown"); // -> b
+		render(churnAwayB);
+		vi.advanceTimersByTime(FALLBACK_LOCKOUT_MS);
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		key("Enter");
+		expect(forwards).toBe(1);
+	});
+
+	it("a churn the focused row survives arms no lockout", () => {
+		const { render, root, key } = makeRig(threeRows);
+		key("ArrowDown"); // -> b
+		render(`
+			<div data-mc-row="b">beta</div>
+			<div data-mc-row="d">delta</div>
+		`);
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		key("Enter");
+		expect(forwards).toBe(1);
+	});
+
+	it("text-entry dives are decisive too: locked out alike", () => {
+		const withTextTail = `
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="b">beta</div>
+			<div data-mc-row="t"><input data-mc-input type="text" /></div>
+		`;
+		const { render, ctl, key, root } = makeRig(withTextTail);
+		key("ArrowDown"); // -> b
+		render(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="t"><input data-mc-input type="text" /></div>
+		`); // fallback falls downward to the surviving t
+		expect(ctl.nav.focusedId).toBe("t");
+		key("Enter");
+		expect(ctl.nav.textMode).toBe(false);
+		expect(root.querySelector("[data-mc-input]")).not.toBe(document.activeElement);
+	});
+
+	it("a vanish right after the user's own claimed key arms NO lockout (commands chain freely)", () => {
+		// The lockout protects against AMBIENT churn.  A removal that is the direct
+		// consequence of a claimed key (jettison, x) is a controlled moment: the
+		// user's next command must flow, not drop.  The controller cannot tell a
+		// consequence render from ambient churn, so the excuse is a short time window
+		// after any claim.
+		const { render, ctl, root, key } = makeRig(threeRows);
+		key("ArrowDown"); // -> b
+		root.addEventListener("megacolumn-key", (e) => {
+			if ((e as CustomEvent<MegacolumnKeyDetail>).detail.key === "x") e.preventDefault(); // claim: "removed"
+		});
+		key("x"); // the user removes b...
+		render(churnAwayB); // ...and THIS render is that removal's consequence
+		expect(ctl.nav.focusedId).toBe("c");
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		key("Enter"); // the chained next command flows immediately
+		expect(forwards).toBe(1);
+	});
+
+	it("a stale claim does not excuse a vanish (the excuse window closes)", () => {
+		const { render, ctl, root, key } = makeRig(threeRows);
+		key("ArrowDown"); // -> b
+		root.addEventListener("megacolumn-key", (e) => {
+			if ((e as CustomEvent<MegacolumnKeyDetail>).detail.key === "Enter") e.preventDefault(); // claim: "inspected"
+		});
+		key("Enter"); // claimed on b; nothing renders (host state unchanged)
+		vi.advanceTimersByTime(CLAIM_EXCUSE_MS + 1); // long past any consequence render
+		render(churnAwayB); // ambient churn, not the claim's consequence
+		expect(ctl.nav.focusedId).toBe("c");
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		key("x");
+		expect(forwards).toBe(0);
+	});
+});
+
+describe("the megacolumn-rescan event (out-of-render row changes)", () => {
+	it("re-scans the rows when a child announces DOM changes outside a host render", () => {
+		const { container, ctl } = makeRig(threeRows);
+		expect(ctl.nav.rows.length).toBe(3);
+		container.querySelector(`[data-mc-row="b"]`)?.remove();
+		expect(ctl.nav.rows.length).toBe(3); // no render happened; the machine is stale
+		container.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true }));
+		expect(ctl.nav.rows.map((r) => r.id)).toEqual(["a", "c"]);
+	});
+
+	it("a rescan that yanks the focused row arms the churn lockout", () => {
+		vi.useFakeTimers();
+		try {
+			const { container, ctl, root, key } = makeRig(threeRows);
+			key("ArrowDown"); // -> b
+			container.querySelector(`[data-mc-row="b"]`)?.remove();
+			container.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true }));
+			expect(ctl.nav.focusedId).toBe("c");
+			let forwards = 0;
+			root.addEventListener("megacolumn-key", () => {
+				forwards += 1;
+			});
+			const e = key("Enter");
+			expect(forwards).toBe(0);
+			expect(e.defaultPrevented).toBe(true);
+		} finally {
+			vi.useRealTimers();
+		}
+	});
+});
+
+describe("the megacolumn-focus-request event (programmatic focus moves)", () => {
+	// The public focus-move affordance: any element inside the container may ask
+	// for logical focus by row id, by dispatching this bubbling cancelable event.
+	// A request has a hotkey jump's nature exactly: pure navigation -- no
+	// activation, no claim, live during lockouts.  The intended consumer is a
+	// wrapper element scoping keys to a row region (a list-group resolving t/n
+	// it claimed off the bubbling megacolumn-key dispatch).
+	// dispatchEvent never rethrows a listener's exception to the dispatcher: it
+	// surfaces as a window error event instead.  The harmless-ignore tests assert
+	// on captured window errors, so a controller that throws on a stale request
+	// fails them with a clean assertion rather than an unhandled-error report.
+	const windowErrors: unknown[] = [];
+	const onWindowError = (e: Event): void => {
+		windowErrors.push(e);
+		e.preventDefault(); // handled here; keep the runner's unhandled-error net out of it
+	};
+	beforeEach(() => {
+		vi.useFakeTimers();
+		windowErrors.length = 0;
+		window.addEventListener("error", onWindowError);
+	});
+	afterEach(() => {
+		window.removeEventListener("error", onWindowError);
+		vi.useRealTimers();
+	});
+
+	const request = (from: Element, rowId: string): CustomEvent<{ rowId: string }> => {
+		const e = new CustomEvent("megacolumn-focus-request", {
+			detail: { rowId },
+			bubbles: true,
+			cancelable: true,
+		});
+		from.dispatchEvent(e);
+		return e;
+	};
+
+	it("moves logical focus to the named row and renders the move", () => {
+		const { ctl, root, container } = makeRig(threeRows);
+		const rowC = root.querySelector<HTMLElement>('[data-mc-row="c"]');
+		if (rowC === null) throw new Error("unreachable: rig just rendered row c");
+		const e = request(rowC, "c");
+		expect(ctl.nav.focusedId).toBe("c");
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-c");
+		expect(rowC.classList.contains("mc-row-focused")).toBe(true);
+		expect(e.defaultPrevented).toBe(true); // the answer signal: the request landed
+	});
+
+	it("an unknown rowId is ignored without error (stale requests are harmless)", () => {
+		// The requester composes against a live column where rows vanish between
+		// its look and its ask; a stale request must do nothing, loudly nothing.
+		// (A rowId belonging to a DIFFERENT container is this same case: the
+		// controller only knows its own nav's rows.)
+		const { ctl, container } = makeRig(threeRows);
+		const e = request(container, "long-gone");
+		expect(ctl.nav.focusedId).toBe("a");
+		expect(e.defaultPrevented).toBe(false); // unanswered
+		expect(windowErrors).toEqual([]); // ignored means NO throw, not a caught one
+	});
+
+	it("an unfocusable (closed) target is ignored", () => {
+		const { ctl, container } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="b" aria-hidden="true">beta</div>
+		`);
+		const e = request(container, "b");
+		expect(ctl.nav.focusedId).toBe("a");
+		expect(e.defaultPrevented).toBe(false);
+		expect(windowErrors).toEqual([]);
+	});
+
+	it("a request opens no claim excuse: an ambient vanish after it still locks out", () => {
+		// Same protection the hotkey jump pins: navigation causes no vanishes, so
+		// a vanish right after a request is ambient churn and the lockout must arm.
+		const { render, ctl, root, container, key } = makeRig(threeRows);
+		request(container, "c");
+		expect(ctl.nav.focusedId).toBe("c");
+		render(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="b">beta</div>
+		`); // ambient churn eats the landed row
+		expect(ctl.nav.focusedId).toBe("b");
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		const e = key("Enter");
+		expect(forwards).toBe(0); // locked out: the request excused nothing
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("resolves during the churn lockout (a request is not a key routing)", () => {
+		const { render, ctl, container, key } = makeRig(threeRows);
+		key("ArrowDown"); // -> b
+		render(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="c">gamma</div>
+		`); // fallback -> c, lockout armed
+		const e = request(container, "a");
+		expect(ctl.nav.focusedId).toBe("a"); // reorientation stays live
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("is ignored during text entry (text entry dominates)", () => {
+		const { ctl, container, key } = makeRig(`
+			<div data-mc-row="write"><input data-mc-input></div>
+			<div data-mc-row="b">beta</div>
+		`);
+		key("Enter"); // dive into text entry on the write row
+		expect(ctl.nav.textMode).toBe(true);
+		const e = request(container, "b");
+		expect(ctl.nav.focusedId).toBe("write");
+		expect(ctl.nav.textMode).toBe(true); // still typing; the ask changed nothing
+		expect(e.defaultPrevented).toBe(false);
+	});
+
+	it("dispatched outside any container it reaches nobody (harmless)", () => {
+		const { ctl } = makeRig(threeRows);
+		const e = request(document.body, "c");
+		expect(ctl.nav.focusedId).toBe("a");
+		expect(e.defaultPrevented).toBe(false);
+	});
+});
+
+describe("column-scoped hotkey markings (data-mc-hotkey)", () => {
+	const markedColumn = `
+		<div data-mc-row="a">alpha</div>
+		<div data-mc-row="dock" data-mc-hotkey="d"><span data-mc-main>dock</span></div>
+	`;
+
+	it("a marked key the focused row declines moves focus to the marked row", () => {
+		const { ctl, container, root, key } = makeRig(markedColumn);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+		});
+		const e = key("d"); // focus is on row a, which declines
+		expect(got).toEqual([{ key: "d", rowId: "a", position: "main" }]); // the first-claim offer only: no activation dispatch
+		expect(ctl.nav.focusedId).toBe("dock");
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-dock");
+		expect(root.querySelector(`[data-mc-row="dock"]`)?.classList.contains("mc-row-focused")).toBe(
+			true,
+		);
+		expect(e.defaultPrevented).toBe(true); // the jump consumed the keydown
+	});
+
+	it("the jump is a focus move, not an activation: Enter afterwards is what acts", () => {
+		// See-before-commit: the letter shows the user where they are; Enter commits.
+		const { root, key } = makeRig(markedColumn);
+		const acted: string[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			const detail = (e as CustomEvent<MegacolumnKeyDetail>).detail;
+			if (detail.key === "Enter" && detail.rowId === "dock") {
+				acted.push(detail.rowId);
+				e.preventDefault();
+			}
+		});
+		key("d");
+		expect(acted).toEqual([]);
+		key("Enter");
+		expect(acted).toEqual(["dock"]);
+	});
+
+	it("the focused row's claim outranks the marking (no jump)", () => {
+		const { ctl, root, key } = makeRig(markedColumn);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+			e.preventDefault(); // the focused row handles everything
+		});
+		key("d");
+		expect(got).toEqual([{ key: "d", rowId: "a", position: "main" }]);
+		expect(ctl.nav.focusedId).toBe("a"); // claimed: focus stays put
+	});
+
+	it("hotkey lookup is case-insensitive (shifted letters still jump)", () => {
+		const { ctl, key } = makeRig(markedColumn);
+		key("D");
+		expect(ctl.nav.focusedId).toBe("dock");
+	});
+
+	it("the focused row's own hotkey does nothing, consumed", () => {
+		// A jump to where you already are; and NOT an activation -- double-tap must
+		// not out-power single-tap (Enter/Space stay the commit).  Still consumed,
+		// like every marked key: the column advertises it, the browser never sees it.
+		const { ctl, root, key } = makeRig(`
+			<div data-mc-row="dock" data-mc-hotkey="d"><span data-mc-main>dock</span></div>
+			<div data-mc-row="b">beta</div>
+		`);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+		});
+		const e = key("d"); // focus starts on the dock row itself
+		expect(got).toEqual([{ key: "d", rowId: "dock", position: "main" }]); // the first-claim offer only
+		expect(ctl.nav.focusedId).toBe("dock");
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("unmarked keys keep their native default when nobody claims", () => {
+		const { key } = makeRig(markedColumn);
+		const e = key("z");
+		expect(e.defaultPrevented).toBe(false);
+	});
+
+	it("a direct marking dispatches the key on the marked element, with focus moved first", () => {
+		// data-mc-hotkey-direct: the opt-out of see-before-commit, for actions with
+		// an undo semantic.  Focus lands on the acted row BEFORE the dispatch: the
+		// activation flash must be scrolled into view WITH the ring (scrolling an
+		// unfocused row in would scroll the ring out), and Enter then repeats the act.
+		const { ctl, root, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="dock"><span data-mc-main data-mc-hotkey="d" data-mc-hotkey-direct>dock</span></div>
+		`);
+		const got: MegacolumnKeyDetail[] = [];
+		let target: EventTarget | null = null;
+		let focusedAtDispatch: string | null = null;
+		root.addEventListener("megacolumn-key", (e) => {
+			const detail = (e as CustomEvent<MegacolumnKeyDetail>).detail;
+			got.push(detail);
+			if (detail.rowId === "dock" && detail.key === "d") {
+				target = e.target;
+				focusedAtDispatch = ctl.nav.focusedId;
+				e.preventDefault();
+			}
+		});
+		const e = key("d"); // focus is on row a, which declines
+		expect(got).toEqual([
+			{ key: "d", rowId: "a", position: "main" },
+			{ key: "d", rowId: "dock", position: "main" },
+		]);
+		expect(target).toBe(root.querySelector("[data-mc-hotkey]"));
+		expect(focusedAtDispatch).toBe("dock");
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("a declined direct marking still consumes and still moves focus", () => {
+		// The focus move already happened (a real user-visible effect), so the key
+		// is spent even when no widget claims the dispatch.
+		const { ctl, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="dock"><span data-mc-main data-mc-hotkey="d" data-mc-hotkey-direct>dock</span></div>
+		`);
+		const e = key("d"); // nobody listens: both dispatches decline
+		expect(ctl.nav.focusedId).toBe("dock");
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("the focused row's own DIRECT marking gets no second dispatch (already offered)", () => {
+		// The first dispatch bubbled through the whole focused row, so every listener
+		// there has had its chance; a second dispatch would just repeat the question.
+		const { root, key } = makeRig(`
+			<div data-mc-row="dock" data-mc-hotkey="d" data-mc-hotkey-direct><span data-mc-main>dock</span></div>
+			<div data-mc-row="b">beta</div>
+		`);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+		});
+		const e = key("d"); // focus starts on the dock row itself
+		expect(got).toEqual([{ key: "d", rowId: "dock", position: "main" }]);
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("a marking on a visible inactive row jumps focus there like anywhere else", () => {
+		// Inactive is a styling state (class + aria-disabled), never a navigation
+		// state: the row stays reachable so i can ask why it is off, and its direct
+		// marking still dispatches -- the marked widget is the authority on its own
+		// disabledness (it can claim the key and decline to act).
+		const { ctl, root, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="off" aria-disabled="true" data-mc-hotkey="o" data-mc-hotkey-direct>offline thing</div>
+		`);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+		});
+		key("o");
+		expect(got.map((d) => d.rowId)).toEqual(["a", "off"]);
+		expect(ctl.nav.focusedId).toBe("off"); // focus follows the direct dispatch
+	});
+
+	it("a DIRECT marking on a text row dives straight into text entry (activation IS the dive)", () => {
+		// What direct-means-activate means for a text row: the same transition Enter
+		// produces there -- machine in text mode, real focus in the input -- in one
+		// keystroke.  No second megacolumn-key dispatch: there is no widget to
+		// activate; the dive is the activation.
+		const { ctl, root, container, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="q"><span data-mc-hotkey="h" data-mc-hotkey-direct>hail</span><input data-mc-input type="text"></div>
+		`);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+		});
+		const e = key("h"); // focus is on row a, which declines
+		expect(got).toEqual([{ key: "h", rowId: "a", position: "main" }]); // the first-claim offer only
+		expect(ctl.nav.focusedId).toBe("q");
+		expect(ctl.nav.textMode).toBe(true);
+		const input = root.querySelector<HTMLInputElement>("[data-mc-input]");
+		expect(document.activeElement).toBe(input);
+		// preventDefault is what keeps the triggering h out of the input that
+		// synchronously received focus (jsdom types nothing either way; the
+		// browser driver proves the same with trusted keys).
+		expect(e.defaultPrevented).toBe(true);
+		expect(input?.value).toBe("");
+		// The exits work as from any dive: Escape returns real focus to the
+		// container with row memory on the text row.
+		key("Escape");
+		expect(document.activeElement).toBe(container);
+		expect(ctl.nav.textMode).toBe(false);
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-q");
+	});
+
+	it("a NON-direct marking on a text row jumps only: focus lands, Enter then dives", () => {
+		const { ctl, root, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="q"><span data-mc-hotkey="h">hail</span><input data-mc-input type="text"></div>
+		`);
+		key("h");
+		expect(ctl.nav.focusedId).toBe("q");
+		expect(ctl.nav.textMode).toBe(false); // a jump, not a dive
+		expect(document.activeElement).not.toBe(root.querySelector("[data-mc-input]"));
+		key("Enter"); // the see-before-commit step
+		expect(ctl.nav.textMode).toBe(true);
+		expect(document.activeElement).toBe(root.querySelector("[data-mc-input]"));
+	});
+
+	it("the focused text row's own DIRECT marking does not dive (Enter is the activation)", () => {
+		// Same rule as every own-row marking: the first dispatch already offered
+		// the key to the whole row, and double-tap must not out-power single-tap.
+		const { ctl, key } = makeRig(`
+			<div data-mc-row="q"><span data-mc-hotkey="h" data-mc-hotkey-direct>hail</span><input data-mc-input type="text"></div>
+			<div data-mc-row="b">beta</div>
+		`);
+		const e = key("h"); // focus starts on q itself
+		expect(ctl.nav.textMode).toBe(false);
+		expect(e.defaultPrevented).toBe(true); // consumed: a visible marking always answers its key
+	});
+
+	it("markings inside aria-hidden rows are not collected (invisible keys answer nothing)", () => {
+		// A live list's pending/collapsing rows are zero-height and aria-hidden;
+		// a marking in one must neither answer keys nor trip the duplicate throw
+		// against its visible twin (a ghost and its pending replacement can briefly
+		// coexist with the same marking).
+		const { root, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div aria-hidden="true"><div data-mc-row="hid" data-mc-hotkey="h">hidden</div></div>
+		`);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+		});
+		const e = key("h");
+		expect(got.map((d) => d.rowId)).toEqual(["a"]); // the focused row's first claim only
+		expect(e.defaultPrevented).toBe(false);
+	});
+
+	it("a visible marking coexists with an aria-hidden duplicate (no duplicate throw)", () => {
+		const { ctl, key } = makeRig(`
+			<div data-mc-row="live-x" data-mc-hotkey="h"><span data-mc-main>visible</span></div>
+			<div aria-hidden="true"><div data-mc-row="ghost-x" data-mc-hotkey="h">ghost</div></div>
+			<div data-mc-row="b">beta</div>
+		`);
+		key("ArrowDown"); // -> b, so the marking resolution has to reach back up
+		key("h");
+		expect(ctl.nav.focusedId).toBe("live-x");
+	});
+
+	it("numeric markings work", () => {
+		const { ctl, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="preset" data-mc-hotkey="5">preset five</div>
+		`);
+		key("5");
+		expect(ctl.nav.focusedId).toBe("preset");
+	});
+
+	it("x and i markings are legal, and the focused row's own claim still outranks them", () => {
+		// The row norms answer x and i already, so marking them is a guideline
+		// violation rather than an error: the dispatch order keeps the norms true.
+		// The focused row's claim comes first; the marking is only consulted on decline.
+		const { root, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="wipe" data-mc-hotkey="x"><span data-mc-main>wipe all</span></div>
+		`);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			const detail = (e as CustomEvent<MegacolumnKeyDetail>).detail;
+			got.push(detail);
+			if (detail.rowId === "a") e.preventDefault(); // the row claims, as an aux widget would
+		});
+		key("x");
+		expect(got.map((d) => d.rowId)).toEqual(["a"]); // claimed at the row; the marking never asked
+	});
+
+	it("an x marking moves focus when the focused row declines x", () => {
+		const { ctl, root, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="wipe" data-mc-hotkey="x"><span data-mc-main>wipe all</span></div>
+		`);
+		const got: MegacolumnKeyDetail[] = [];
+		root.addEventListener("megacolumn-key", (e) => {
+			got.push((e as CustomEvent<MegacolumnKeyDetail>).detail);
+		});
+		key("x"); // row a has no aux and claims nothing: the marking answers as a jump
+		expect(got.map((d) => d.rowId)).toEqual(["a"]);
+		expect(ctl.nav.focusedId).toBe("wipe");
+	});
+
+	it("an i marking outranks the data-info fallback (the jump consumes i)", () => {
+		// The container's [data-info] answer is the last resort for a fully
+		// unclaimed i; a column i marking resolves (as a focus move) before it.
+		const { ctl, container, key } = makeRig(`
+			<div data-mc-row="a" data-info="alpha's own story">alpha</div>
+			<div data-mc-row="atlas" data-mc-hotkey="i"><span data-mc-main>atlas</span></div>
+		`);
+		let infos = 0;
+		container.addEventListener("sw-info", () => {
+			infos += 1;
+		});
+		key("i");
+		expect(ctl.nav.focusedId).toBe("atlas");
+		expect(infos).toBe(0); // the jump consumed i; the fallback never ran
+	});
+
+	it("a declined DIRECT i marking does not fall through to data-info (focus moved on)", () => {
+		// Once a marking matches, it resolves the key: the jump already landed focus
+		// on the marked row, and answering with the OLD row's data-info would narrate
+		// a row the ring has left.  The fallback only answers keys no marking matched.
+		const { ctl, container, key } = makeRig(`
+			<div data-mc-row="a" data-info="alpha's own story">alpha</div>
+			<div data-mc-row="atlas" data-mc-hotkey="i" data-mc-hotkey-direct><span data-mc-main>atlas</span></div>
+		`);
+		let infos = 0;
+		container.addEventListener("sw-info", () => {
+			infos += 1;
+		});
+		key("i"); // nobody claims either dispatch
+		expect(ctl.nav.focusedId).toBe("atlas");
+		expect(infos).toBe(0);
+	});
+
+	it("throws on duplicate markings in one column, case-insensitively", () => {
+		expect(() =>
+			makeRig(`
+				<div data-mc-row="a" data-mc-hotkey="d">a</div>
+				<div data-mc-row="b" data-mc-hotkey="d">b</div>
+			`),
+		).toThrow(/duplicate hotkey marking "d".*"a".*"b"/);
+		expect(() =>
+			makeRig(`
+				<div data-mc-row="a" data-mc-hotkey="d">a</div>
+				<div data-mc-row="b" data-mc-hotkey="D">b</div>
+			`),
+		).toThrow(/duplicate hotkey marking/);
+	});
+
+	it("throws on malformed markings", () => {
+		expect(() => makeRig(`<div data-mc-row="a" data-mc-hotkey="dd">a</div>`)).toThrow(
+			/single \[a-z0-9\] character/,
+		);
+		expect(() => makeRig(`<div data-mc-row="a" data-mc-hotkey="!">a</div>`)).toThrow(
+			/single \[a-z0-9\] character/,
+		);
+	});
+
+	it("throws on a marking outside any row", () => {
+		expect(() =>
+			makeRig(`<span data-mc-hotkey="d">stray</span><div data-mc-row="a">a</div>`),
+		).toThrow(/outside any row/);
+	});
+
+	it("throws on a marking on an aux control (x/Delete are the aux norms)", () => {
+		expect(() =>
+			makeRig(`
+				<div data-mc-row="a"><span data-mc-main>m</span><span data-mc-aux data-mc-hotkey="j">x</span></div>
+			`),
+		).toThrow(/aux/);
+	});
+});
+describe("the i fallback for [data-info] carriers", () => {
+	it("answers an unclaimed i with sw-info from the row's data-info", () => {
+		const { root, container, key } = makeRig(
+			`<div data-mc-row="a" data-info="alpha is a placeholder row">alpha</div>`,
+		);
+		const got: SwInfoDetail[] = [];
+		container.addEventListener("sw-info", (e) => {
+			got.push((e as CustomEvent<SwInfoDetail>).detail);
+		});
+		const e = key("i");
+		expect(got).toEqual([
+			{ content: "alpha is a placeholder row", source: root.querySelector(`[data-mc-row="a"]`) },
+		]);
+		expect(e.defaultPrevented).toBe(true); // answered means consumed
+	});
+
+	it("a widget's own i claim outranks the fallback", () => {
+		const { root, container, key } = makeRig(
+			`<div data-mc-row="a" data-info="row-level info">alpha</div>`,
+		);
+		root.addEventListener("megacolumn-key", (e) => {
+			if ((e as CustomEvent<MegacolumnKeyDetail>).detail.key === "i") e.preventDefault();
+		});
+		let infos = 0;
+		container.addEventListener("sw-info", () => {
+			infos += 1;
+		});
+		key("i");
+		expect(infos).toBe(0);
+	});
+
+	it("the nearest carrier to the focused control wins (aux over row)", () => {
+		const { root, container, key } = makeRig(`
+			<div data-mc-row="a" data-info="the row's story">
+				<span data-mc-main>item</span>
+				<span data-mc-aux data-info="the x's story">x</span>
+			</div>
+		`);
+		const got: SwInfoDetail[] = [];
+		container.addEventListener("sw-info", (e) => {
+			got.push((e as CustomEvent<SwInfoDetail>).detail);
+		});
+		key("ArrowRight"); // onto the aux control
+		key("i");
+		expect(got.map((d) => d.content)).toEqual(["the x's story"]);
+		expect(got[0]?.source).toBe(root.querySelector("[data-mc-aux]"));
+		key("ArrowLeft"); // back to main: the row's own info answers
+		key("i");
+		expect(got.map((d) => d.content)).toEqual(["the x's story", "the row's story"]);
+	});
+
+	it("shifted I answers too", () => {
+		const { container, key } = makeRig(`<div data-mc-row="a" data-info="story">alpha</div>`);
+		const got: string[] = [];
+		container.addEventListener("sw-info", (e) => {
+			got.push((e as CustomEvent<SwInfoDetail>).detail.content);
+		});
+		key("I", { shiftKey: true });
+		expect(got).toEqual(["story"]);
+	});
+
+	it("i on a row without data-info stays unconsumed and dispatches nothing", () => {
+		const { container, key } = makeRig(threeRows);
+		let infos = 0;
+		container.addEventListener("sw-info", () => {
+			infos += 1;
+		});
+		const e = key("i");
+		expect(infos).toBe(0);
+		expect(e.defaultPrevented).toBe(false);
+	});
+
+	it("carriers above the row do not answer (the fallback is row-scoped)", () => {
+		// data-info on the container (or beyond) belongs to hover, not to the
+		// column's i: a row must not inherit info it does not carry.
+		const { container, key } = makeRig(threeRows);
+		container.dataset.info = "the container's own story";
+		let infos = 0;
+		container.addEventListener("sw-info", () => {
+			infos += 1;
+		});
+		const e = key("i");
+		expect(infos).toBe(0);
+		expect(e.defaultPrevented).toBe(false);
+	});
+});
+
+describe("the megacolumn-focus-change event (focus identity announcements)", () => {
+	interface FocusChangeDetail {
+		rowId: string | null;
+	}
+
+	function listen(target: HTMLElement): FocusChangeDetail[] {
+		const seen: FocusChangeDetail[] = [];
+		target.addEventListener("megacolumn-focus-change", (e) => {
+			seen.push((e as CustomEvent<FocusChangeDetail>).detail);
+		});
+		return seen;
+	}
+
+	it("announces arrow-key focus moves with the new row id", () => {
+		const { container, key } = makeRig(threeRows);
+		const seen = listen(container);
+		key("ArrowDown");
+		expect(seen).toEqual([{ rowId: "b" }]);
+		key("ArrowDown");
+		expect(seen).toEqual([{ rowId: "b" }, { rowId: "c" }]);
+	});
+
+	it("announces a hotkey jump", () => {
+		const { container, key } = makeRig(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="z"><span data-mc-hotkey="z">omega</span></div>
+		`);
+		const seen = listen(container);
+		key("z");
+		expect(seen).toEqual([{ rowId: "z" }]);
+	});
+
+	it("announces a churn fallback (focused row vanished on re-render)", () => {
+		const { root, render, key } = makeRig(threeRows);
+		key("ArrowDown"); // focus b
+		// The event bubbles, so the listener sits on the render root: the rig's
+		// innerHTML render replaces the container element itself, as a real host's
+		// full re-render may.
+		const seen = listen(root);
+		render(`
+			<div data-mc-row="a">alpha</div>
+			<div data-mc-row="c">gamma</div>
+		`);
+		expect(seen).toEqual([{ rowId: "c" }]);
+	});
+
+	it("announces the focus emptying out (all rows gone) with null", () => {
+		const { root, render } = makeRig(threeRows);
+		const seen = listen(root);
+		render("");
+		expect(seen).toEqual([{ rowId: null }]);
+	});
+
+	it("stays quiet when a re-render leaves focus where it was", () => {
+		const { root, render } = makeRig(threeRows);
+		const seen = listen(root);
+		render(threeRows); // same rows, same focus
+		expect(seen).toEqual([]);
+	});
+
+	it("stays quiet on within-row moves (main to aux is the same row)", () => {
+		const { container, key } = makeRig(`
+			<div data-mc-row="a"><span data-mc-main>alpha</span><span data-mc-aux>x</span></div>
+		`);
+		const seen = listen(container);
+		key("ArrowRight");
+		expect(seen).toEqual([]);
+	});
+});
diff --git a/packages/yukey/src/megacolumn-controller.ts b/packages/yukey/src/megacolumn-controller.ts
new file mode 100644
index 0000000..6b578cb
--- /dev/null
+++ b/packages/yukey/src/megacolumn-controller.ts
@@ -0,0 +1,743 @@
+// The Lit binding layer for the megacolumn navigation model: connects the pure
+// state machine (megacolumn-nav.ts) to a component's rendered DOM.  This file owns
+// every DOM concern the machine deliberately doesn't: the single real focusable,
+// event wiring, aria-activedescendant, focus-ring classes, and forwarding keys to
+// row widgets.  It is a Lit ReactiveController: hostUpdated() re-scans the rendered
+// rows after every render, so registration IS rendering and can never drift from it.
+//
+// The DOM contract (data attributes; see megacolumn.gallery.ts for the
+// reference implementation):
+//
+//   [data-mc-container]        the megacolumn root, ONE per host component.
+//                              Becomes the single real focusable (tabindex=0 assigned).
+//                              Gets role="listbox" if the author set no role
+//                              (aria-activedescendant only means something on an
+//                              element with a composite role).
+//   [data-mc-row="<id>"]       a row, id stable and unique.  Scanned in DOM order;
+//                              DOM order is the display order the machine navigates.
+//                              Gets role="option" if the author set no role
+//                              (override for trees/menus: treeitem, menuitem...).
+//                              Section gaps are simply elements without this attribute.
+//   aria-hidden="true"         on a row (or a wrapper around rows): closed to
+//                              sight, and therefore registered but skipped by
+//                              navigation.  One attribute carries both meanings
+//                              so the AT view and the keyboard view cannot
+//                              disagree (see closedToSight below).
+//                              Only for rows that are actually invisible (the
+//                              zero-height churn phases of a live list), never
+//                              for rows that are merely inactive: a visible-but-
+//                              offline row stays a nav stop, so i can reach it
+//                              to ask why, and so the stride count between its
+//                              neighbors doesn't shift with activatability
+//                              (style those with class + aria-disabled instead).
+//   [data-mc-main]             the row's main control (optional; defaults to the row
+//                              element itself).  Gets the focus-ring class.
+//   [data-mc-aux]              the row's one aux control (the small-x norm).
+//                              Its presence makes ArrowRight/Left move within the row.
+//   [data-mc-input]            the row's text input.  Its presence makes Enter/Space
+//                              enter text-entry mode (real DOM focus dives in;
+//                              tabindex=-1 assigned so it is never a tab stop).
+//   [data-mc-hotkey="<char>"]  a column-scoped hotkey marking on a control inside a row
+//                              (a single a-z0-9 character, case-insensitive; aux controls
+//                              answer to x/Delete and cannot carry markings).  Pressing the
+//                              key, when the focused row declines it, MOVES LOGICAL FOCUS
+//                              to the marked row: a marking is a jump affordance, and the
+//                              user activates with Enter/Space having seen where they are
+//                              (the guidelines' see-before-commit flow).  At most one
+//                              marking per character per column; duplicates throw at scan
+//                              time.  Guideline: leave x and i unmarked -- the row norms
+//                              answer them (x removes via the aux control, i informs), and
+//                              because the focused row's claim always comes first, marking
+//                              them only catches declines from rows without those norms.
+//                              Legal, but do it only with a reason.
+//   [data-mc-hotkey-direct]    alongside data-mc-hotkey: this marking ACTIVATES on press
+//                              (dispatched to the marked control) instead of only moving
+//                              focus -- and focus also lands on the acted row, so the
+//                              activation flash is scrolled into view with the ring, and
+//                              Enter repeats the action.  On a text row, activation means
+//                              what Enter means there: the press dives straight into
+//                              text-entry mode (no second dispatch; the dive is the
+//                              activation).  The name is deliberately long:
+//                              skipping see-before-commit is legitimate where an undo
+//                              semantic exists (the guidelines' example: a map move order
+//                              is cheap to cancel, so just give the order; a text dive is
+//                              undone by Escape, nothing committed).  If reaching
+//                              for this makes you think twice, that is the design working.
+//   [data-info="<content>"]    the info norm's carrier attribute (SSOT: sw-info.ts).
+//                              When a forwarded i comes back unclaimed, the container
+//                              answers for the nearest carrier from the focused control
+//                              up to the row (inclusive), dispatching sw-info itself --
+//                              so plain rows get the i norm with no widget code.
+//
+// Same-shadow-tree note: aria-activedescendant can only reference an id in the same
+// tree as the container, and this binding enforces that shape by construction --
+// rows are found with querySelectorAll under the container, which cannot cross
+// shadow boundaries or reach slotted light-DOM content.  Render rows in the host's
+// own shadow root (render-props style, as sw-live-list does); slotting rows in from
+// outside is not supported and scans as an empty column.
+//
+// Keys the machine forwards are delivered as a cancelable `megacolumn-key`
+// CustomEvent on the focused row's control element (bubbling within the shadow
+// root, so a host may listen once on the container).  Synthetic KeyboardEvents
+// are useless here -- untrusted events don't drive native widget behavior -- so
+// the contract is explicit: row widgets act on megacolumn-key, and a listener that
+// handled the key calls preventDefault() on it to also swallow the original
+// keydown's default action.  The one place that needs REAL key events is text
+// entry, and that is exactly what text-entry mode's real focus dive provides.
+//
+// Pointer activation rides the same contract: a click on a row's control (main
+// or aux -- and any tap, since touch produces clicks) dispatches the same
+// megacolumn-key with key "Enter", so Enter, Space, and click are one event and
+// hosts serve every input modality through one handler.  A widget that already
+// activates on its own native click (sw-button in row mode) claims the click
+// with preventDefault -- the same claim idiom keys use -- and the synthesis
+// skips claimed clicks, so nothing double-fires.  Clicks on [data-mc-input]
+// never synthesize (the real-focus dive in onFocusin owns that story), and a
+// text row's main synthesizes nothing either: keyboard Enter there is the text
+// dive, not a forward, and the synthesis must not invent events no keyboard
+// could produce.
+//
+// Rows may change outside any host render (a live list advancing its own row
+// phases on timers, for example).  A component that does that dispatches a
+// bubbling `megacolumn-rescan` CustomEvent after updating its DOM, and the
+// controller re-scans; renders re-scan automatically via hostUpdated as always.
+//
+// Focus identity announcements: whenever the FOCUSED ROW's identity changes
+// (arrows, Home/End, hotkey jumps, churn fallbacks, granted focus requests --
+// every cause, because the dispatch lives in the one sink they all reach),
+// the controller dispatches a bubbling `megacolumn-focus-change` CustomEvent
+// on the container with detail { rowId } (null when the column emptied out).
+// Pure announcement, never cancelable: listeners that mirror focus into some
+// other surface (a preview pane, a map highlight) subscribe here instead of
+// polling.  Within-row moves (main to aux) are not identity changes and stay
+// silent.  Dispatched after the focused row is scrolled into view, so a
+// listener reading the row's geometry sees its settled position.
+//
+// Programmatic focus moves: any element inside the container may ask for
+// logical focus by dispatching a bubbling, cancelable `megacolumn-focus-request`
+// CustomEvent with detail { rowId }.  A granted request moves focus exactly as
+// a hotkey jump does, and shares a jump's whole nature: no activation, no claim
+// (navigation causes no vanishes, so it must not weaken the churn lockout), and
+// it resolves even DURING a lockout -- reorientation stays live.  The controller
+// answers a granted request with preventDefault on it, so the requester can see
+// it landed.  A request naming a row this column does not know, or a row that
+// is not focusable, or arriving during text entry, is ignored: requesters
+// compose against live columns where rows vanish between look and ask, so a
+// stale request must be harmless (and text entry dominates keys and focus both).
+// The intended consumer is a wrapper element scoping keys to a row region: it
+// hears the bubbling megacolumn-key dispatch from its own rows BEFORE any
+// container-level resolution (inner scope wins by dispatch order), claims the
+// keys it defines, and requests the focus move they mean.
+//
+// The churn lockout: when a scan finds the focused row gone and the machine's
+// fallback moves focus to a neighbor, that move was nobody's keystroke -- and a
+// decisive key already in flight must not land on the surprise neighbor
+// (the guidelines' no-race-conditions rule; the same reasoning as sw-live-list's
+// click guard, transposed to focus).  For FALLBACK_LOCKOUT_MS after an involuntary
+// focus move, `forward` and `enter-text` routings are consumed without dispatch;
+// navigation keys stay live, because reorienting is exactly what the user needs --
+// and that includes hotkey focus moves, which resolve even while locked out.
+// One exception: a vanish within CLAIM_EXCUSE_MS of a CLAIMED forwarded key is
+// treated as that key's own consequence (an x removing its row) -- a controlled
+// moment the user made happen, so the next command chains without a stutter.
+//
+// Column-scoped hotkeys ride the same contract: when the focused row declines a
+// forwarded key (nobody preventDefaulted the first dispatch) and the key matches a
+// [data-mc-hotkey] marking elsewhere in the column, the marking resolves -- a
+// plain marking as a focus move to the marked row, a direct marking as a second
+// dispatch of the same megacolumn-key on the marked element (detail names the
+// marked row) with focus following the claim, or, on a text row, as the text
+// dive itself (focus lands and real focus enters the input, exactly as Enter
+// there would).  The focused row always gets first
+// claim by construction; the focused column is the whole hotkey namespace, so a
+// user can hit a marked key without arrowing to its row first.  The focused row's
+// OWN marking resolves to nothing (a jump to where you already are), consumed:
+// double-tap must not out-power single-tap, and Enter/Space stays the activation.
+// Markings inside closed (aria-hidden) content are never collected (see
+// scanHotkeys), and every visible row is a nav stop -- so a collected marking
+// always has a reachable row to jump to.
+//
+// Fail closed: rows the machine knows but the DOM lacks (or vice versa mid-
+// dispatch), stray native tab stops inside rows, and duplicate row ids all throw.
+
+import type { ReactiveController, ReactiveControllerHost } from "lit";
+import { css } from "lit";
+import { MegacolumnNav, type NavRow, type RowPosition } from "./megacolumn-nav.js";
+import { dispatchSwInfo } from "./sw-info.js";
+
+export interface MegacolumnKeyDetail {
+	key: string;
+	rowId: string;
+	position: RowPosition;
+}
+
+// The megacolumn-focus-change detail: the row now holding logical focus
+// (null when the column emptied out).  See the header's event contract.
+export interface MegacolumnFocusChangeDetail {
+	rowId: string | null;
+}
+
+// How long decisive keys stay locked out after an involuntary focus move.
+// Long enough to swallow a keystroke already in flight when the row vanished;
+// short enough that a user who SAW the move barely notices.  A v0 feel constant.
+export const FALLBACK_LOCKOUT_MS = 350;
+
+// How long a claimed forwarded key excuses a subsequent vanish from the lockout
+// (the claim's own consequence arriving as a render).  Sized for render latency
+// only: in a live list the claimed removal ghosts first and collapses much later,
+// by which point the user is idle (the key pin decayed) and a lockout is harmless.
+export const CLAIM_EXCUSE_MS = 350;
+
+// The host shape we need: a Lit element (renderRoot + addController).
+type Host = ReactiveControllerHost & { renderRoot: DocumentFragment | HTMLElement };
+
+// Whether an element is closed to sight: aria-hidden="true" on it or an ancestor.
+// This is the ONE closed-row predicate -- scanRows keys navigation-skip off it and
+// scanHotkeys keys marking-collection off it, so the AT view and the keyboard view
+// cannot disagree.  Two conditions make the coupling sound:
+//   - The invariant: a megacolumn never aria-hides VISIBLE content.  Hidden-from-AT
+//     and skipped-from-keyboard are the same predicate only while that holds; an
+//     aria-hidden row must be invisible (a live list's zero-height churn phases).
+//   - Match by VALUE, never by attribute presence: aria-hidden="false" behaves
+//     inconsistently in browsers and must never read as hidden here.
+function closedToSight(el: HTMLElement): boolean {
+	return el.closest('[aria-hidden="true"]') !== null;
+}
+
+export class MegacolumnController implements ReactiveController {
+	readonly nav = new MegacolumnNav();
+	#host: Host;
+	#container: HTMLElement | null = null;
+	#ringed: Element[] = []; // elements currently carrying mc- focus classes
+	#hotkeys = new Map<string, HTMLElement>(); // lowercased marking char -> marked element
+	#scrolledTo: string | null = null; // last row id scrolled into view (scroll on identity change only)
+	#announcedFocus: string | null = null; // last focus identity announced via megacolumn-focus-change
+	// The churn lockout (see the header): armed by involuntary focus moves,
+	// disarmed by its own timer, excused by a recent claim (see armLockout).
+	#lockedOut = false;
+	#lockoutTimer: number | undefined;
+	#claimExcuse = false; // a forwarded key was claimed within CLAIM_EXCUSE_MS
+	#claimExcuseTimer: number | undefined;
+
+	constructor(host: Host) {
+		this.#host = host;
+		host.addController(this);
+	}
+
+	hostConnected(): void {}
+
+	hostDisconnected(): void {
+		// Listeners live on elements inside the host's shadow root and die with it;
+		// just drop our references.
+		this.#container = null;
+		this.#ringed = [];
+		this.#scrolledTo = null; // a re-attached tree may not be scrolled where we left it
+		this.#announcedFocus = null;
+		window.clearTimeout(this.#lockoutTimer);
+		this.#lockoutTimer = undefined;
+		this.#lockedOut = false;
+		window.clearTimeout(this.#claimExcuseTimer);
+		this.#claimExcuseTimer = undefined;
+		this.#claimExcuse = false;
+	}
+
+	hostUpdated(): void {
+		const container = this.#host.renderRoot.querySelector<HTMLElement>("[data-mc-container]");
+		if (container === null) throw new Error("megacolumn: no [data-mc-container] in renderRoot");
+		if (container !== this.#container) {
+			this.#container = container;
+			container.tabIndex = 0;
+			if (!container.hasAttribute("role")) container.setAttribute("role", "listbox");
+			container.addEventListener("keydown", (e) => this.onKeydown(e));
+			container.addEventListener("mousedown", (e) => this.onMousedown(e));
+			container.addEventListener("click", (e) => this.onClick(e));
+			container.addEventListener("focusin", (e) => this.onFocusin(e));
+			container.addEventListener("focusout", (e) => this.onFocusout(e));
+			// Components whose rows change outside host renders announce it with this
+			// event (see the header); a render-driven scan happens in hostUpdated as always.
+			container.addEventListener("megacolumn-rescan", () => {
+				this.scanRows(this.mustContainer());
+				this.applyFocusState();
+			});
+			container.addEventListener("megacolumn-focus-request", (e) => this.onFocusRequest(e));
+		}
+		this.scanRows(container);
+		this.applyFocusState();
+	}
+
+	// Read the rendered rows (DOM order = display order) into the machine.
+	private scanRows(container: HTMLElement): void {
+		const rows: NavRow[] = [];
+		for (const el of container.querySelectorAll<HTMLElement>("[data-mc-row]")) {
+			const id = el.dataset.mcRow ?? "";
+			const input = el.querySelector<HTMLElement>("[data-mc-input]");
+			if (input !== null) input.tabIndex = -1; // never a tab stop; text mode dives in
+			// The container must stay the only tab stop: a stray natively-focusable
+			// element inside a row would fracture the focus model.  Widgets that truly
+			// need real focus must be marked data-mc-input (text entry) instead.
+			for (const candidate of el.querySelectorAll(
+				"button, select, textarea, input, a[href], [tabindex]",
+			)) {
+				if (candidate.matches("[data-mc-input]")) continue;
+				if (candidate instanceof HTMLElement && candidate.tabIndex === -1) continue;
+				throw new Error(
+					`megacolumn: row "${id}" contains a native tab stop (${candidate.tagName.toLowerCase()}); ` +
+						"give it tabindex=-1 or mark it data-mc-input",
+				);
+			}
+			// aria-activedescendant needs a referencable id; derive one from the row id.
+			// The mc- prefix is reserved for this derivation: Lit reuses DOM nodes by
+			// position, so an element can reappear under a NEW row identity carrying its
+			// old derived id, which must be re-derived (author-set ids are left alone).
+			const derivedId = `mc-${id}`;
+			if (el.id === "" || (el.id.startsWith("mc-") && el.id !== derivedId)) el.id = derivedId;
+			// ... and a role for the relation to be meaningful to AT.
+			if (!el.hasAttribute("role")) el.setAttribute("role", "option");
+			rows.push({
+				id,
+				focusable: !closedToSight(el),
+				hasAux: el.querySelector("[data-mc-aux]") !== null,
+				textEntry: input !== null,
+			});
+		}
+		if (this.nav.setRows(rows)) this.armLockout();
+		this.scanHotkeys(container);
+	}
+
+	// Arm (or re-arm) the churn lockout: the machine's fallback just moved focus
+	// without a user keystroke.  Excused when a claimed forward landed within
+	// CLAIM_EXCUSE_MS: that vanish is the claim's own consequence (an x removing
+	// its row), a moment the user made happen, and the next command must chain.
+	private armLockout(): void {
+		if (this.#claimExcuse) return;
+		this.#lockedOut = true;
+		window.clearTimeout(this.#lockoutTimer);
+		this.#lockoutTimer = window.setTimeout(() => {
+			this.#lockedOut = false;
+			this.#lockoutTimer = undefined;
+		}, FALLBACK_LOCKOUT_MS);
+	}
+
+	// A forwarded key was claimed: open the excuse window (see armLockout).
+	private noteClaim(): void {
+		this.#claimExcuse = true;
+		window.clearTimeout(this.#claimExcuseTimer);
+		this.#claimExcuseTimer = window.setTimeout(() => {
+			this.#claimExcuse = false;
+			this.#claimExcuseTimer = undefined;
+		}, CLAIM_EXCUSE_MS);
+	}
+
+	// Move logical focus to a marking's row (the hotkey-jump resolution; see the
+	// header).  A collected marking always sits in a focusable row: closed content
+	// never yields a marking, and closed is the only thing unfocusable -- so
+	// focusRow's own fail-closed throws cover any state that violates that.
+	private jumpToMarking(marked: HTMLElement): void {
+		const markedRow = marked.closest<HTMLElement>("[data-mc-row]");
+		this.nav.focusRow(markedRow?.dataset.mcRow ?? "");
+	}
+
+	// Collect the column's [data-mc-hotkey] markings (see the DOM contract above).
+	// All the marking rules are enforced here, at scan time, so a bad marking fails
+	// on the render that introduces it rather than on the keypress that trips it.
+	// Markings inside closed (aria-hidden) content are not collected: an invisible
+	// control cannot advertise a key, so it must not answer one (and a hidden row's
+	// marking -- a collapsing live-list ghost, say -- must not collide with its
+	// visible twin).  Same predicate as the row scan: closedToSight.
+	private scanHotkeys(container: HTMLElement): void {
+		this.#hotkeys = new Map();
+		for (const el of container.querySelectorAll<HTMLElement>("[data-mc-hotkey]")) {
+			if (closedToSight(el)) continue;
+			const marking = el.dataset.mcHotkey ?? "";
+			if (!/^[a-z0-9]$/i.test(marking))
+				throw new Error(
+					`megacolumn: hotkey marking "${marking}" must be a single [a-z0-9] character`,
+				);
+			const char = marking.toLowerCase();
+			const row = el.closest<HTMLElement>("[data-mc-row]");
+			if (row === null)
+				throw new Error(`megacolumn: hotkey marking "${marking}" is outside any row`);
+			if (el.closest("[data-mc-aux]") !== null)
+				throw new Error(
+					`megacolumn: row "${row.dataset.mcRow}" marks its aux control with "${marking}"; ` +
+						"aux controls answer to x/Delete already",
+				);
+			const prior = this.#hotkeys.get(char);
+			if (prior !== undefined) {
+				const priorRow = prior.closest<HTMLElement>("[data-mc-row]");
+				throw new Error(
+					`megacolumn: duplicate hotkey marking "${char}" on rows ` +
+						`"${priorRow?.dataset.mcRow}" and "${row.dataset.mcRow}"`,
+				);
+			}
+			this.#hotkeys.set(char, el);
+		}
+	}
+
+	// Render the machine's state into the DOM: aria-activedescendant on the
+	// container, ring classes on the focused control, mode classes on the container.
+	// Called after every render and after every state-changing event; idempotent.
+	private applyFocusState(): void {
+		const container = this.mustContainer();
+		for (const el of this.#ringed) el.classList.remove("mc-focused", "mc-row-focused");
+		this.#ringed = [];
+
+		const focusedId = this.nav.focusedId;
+		if (focusedId === null) {
+			container.removeAttribute("aria-activedescendant");
+			this.#scrolledTo = null;
+		} else {
+			const row = this.rowElement(focusedId);
+			const control = this.controlElement(row, this.nav.position);
+			container.setAttribute("aria-activedescendant", row.id);
+			row.classList.add("mc-row-focused");
+			control.classList.add("mc-focused");
+			this.#ringed = [row, control];
+			// Logical focus must never sit off-screen, and browsers do NOT scroll
+			// aria-activedescendant targets into view on their own -- so every focus
+			// identity change scrolls here, covering arrows, Home/End, hotkey jumps,
+			// and churn fallbacks in one place.  Nearest-edge and instant: a smooth
+			// animation would misreport causality during churn.
+			if (this.#scrolledTo !== focusedId) {
+				this.#scrolledTo = focusedId;
+				row.scrollIntoView({ block: "nearest", behavior: "instant" });
+			}
+		}
+		container.classList.toggle("mc-text-mode", this.nav.textMode);
+		container.classList.toggle(
+			"mc-empty-focus",
+			focusedId === null && this.focusIsInside(container),
+		);
+		// Announce focus IDENTITY changes (see the header's megacolumn-focus-change
+		// contract).  Dispatched here, the one place every cause converges -- arrows,
+		// Home/End, hotkey jumps, churn fallbacks, focus requests -- and after the
+		// scroll above, so a listener reading anchor geometry sees the settled row.
+		if (focusedId !== this.#announcedFocus) {
+			this.#announcedFocus = focusedId;
+			container.dispatchEvent(
+				new CustomEvent<MegacolumnFocusChangeDetail>("megacolumn-focus-change", {
+					detail: { rowId: focusedId },
+					bubbles: true,
+				}),
+			);
+		}
+	}
+
+	private onKeydown(e: KeyboardEvent): void {
+		// Modifier chords are never ours: browser and OS shortcuts stay native.
+		if (e.ctrlKey || e.altKey || e.metaKey) return;
+		const routing = this.nav.handleKey(e.key);
+		// The churn lockout: decisive routings die here while armed (consumed, so the
+		// browser gets nothing either); navigation and mode exits stay live.  Hotkey
+		// focus moves are navigation too -- reorienting is exactly what the user
+		// needs -- so non-direct markings still resolve as jumps; direct markings
+		// are decisive and are swallowed like every other forward.
+		if (this.#lockedOut && (routing.kind === "forward" || routing.kind === "enter-text")) {
+			if (routing.kind === "enter-text") this.nav.exitTextMode(); // undo handleKey's entry
+			if (routing.kind === "forward") {
+				const marked = this.#hotkeys.get(e.key.toLowerCase());
+				if (marked !== undefined && !marked.hasAttribute("data-mc-hotkey-direct")) {
+					const row = this.rowElement(routing.rowId);
+					if (!row.contains(marked)) this.jumpToMarking(marked);
+				}
+			}
+			e.preventDefault();
+			this.applyFocusState();
+			return;
+		}
+		switch (routing.kind) {
+			case "nav":
+				e.preventDefault();
+				break;
+			case "forward": {
+				const row = this.rowElement(routing.rowId);
+				const control = this.controlElement(row, routing.position);
+				const forwarded = new CustomEvent<MegacolumnKeyDetail>("megacolumn-key", {
+					detail: { key: e.key, rowId: routing.rowId, position: routing.position },
+					bubbles: true,
+					cancelable: true,
+				});
+				// `claimed` opens the lockout's excuse window (a widget acted; whatever
+				// vanish follows is the user's own doing); `consumed` only swallows the
+				// keydown.  A hotkey jump is consumed but never claimed: navigation
+				// causes no vanishes, so it must not weaken the lockout's protection.
+				let claimed = !control.dispatchEvent(forwarded);
+				let consumed = claimed;
+				// The focused row declined; a column hotkey marking resolves the key.
+				// The marking's own row gets nothing beyond the consume: the first
+				// dispatch already bubbled through the whole focused row, and a jump to
+				// where you already are is no move (double-tap must not out-power
+				// single-tap; Enter/Space stay the activation).
+				const marked = this.#hotkeys.get(e.key.toLowerCase());
+				if (!claimed && marked !== undefined) {
+					consumed = true; // a visible marking always answers its key
+					if (!row.contains(marked)) {
+						this.jumpToMarking(marked);
+						if (marked.hasAttribute("data-mc-hotkey-direct")) {
+							const markedRow = marked.closest<HTMLElement>("[data-mc-row]");
+							const markedId = markedRow?.dataset.mcRow ?? "";
+							if (this.nav.rows.find((r) => r.id === markedId)?.textEntry === true) {
+								// On a text row, activation IS the text dive: the same transition
+								// Enter produces on a focused text row, in one keystroke.  No second
+								// megacolumn-key: there is no widget to activate.  Not claimed --
+								// like a jump, a dive causes no vanishes (nothing syncs to data
+								// until the input commits), so it must not weaken the lockout.
+								this.nav.noteTextEntered(markedId);
+								this.diveIntoText(markedId, e);
+							} else {
+								// Markings never sit on aux controls (the scan throws), so the position
+								// in the marked row is always main.
+								claimed = !marked.dispatchEvent(
+									new CustomEvent<MegacolumnKeyDetail>("megacolumn-key", {
+										detail: { key: e.key, rowId: markedId, position: "main" },
+										bubbles: true,
+										cancelable: true,
+									}),
+								);
+							}
+						}
+					}
+				}
+				// Still unresolved and the key is i: the container answers for plain
+				// [data-info] carriers (the info norm's "almost any element"; widgets
+				// with their own i handling, like sw-button, claimed above and never
+				// reach this).  Nearest carrier from the focused control up to the row,
+				// inclusive: a row must not inherit info from outside itself.
+				if (!consumed && e.key.toLowerCase() === "i") {
+					const carrier = this.infoCarrier(control, row);
+					if (carrier !== null) {
+						dispatchSwInfo(carrier, carrier.dataset.info ?? "");
+						claimed = true;
+						consumed = true;
+					}
+				}
+				// A resolved key is fully consumed; an unresolved one keeps its native
+				// default (PageDown may still scroll the column, etc).
+				if (claimed) this.noteClaim();
+				if (consumed) e.preventDefault();
+				break;
+			}
+			case "enter-text":
+				this.diveIntoText(routing.rowId, e);
+				break;
+			case "exit-text":
+				e.preventDefault();
+				this.mustContainer().focus();
+				break;
+			case "pass":
+				return; // not ours; no state to re-render
+		}
+		this.applyFocusState();
+	}
+
+	// Dive real DOM focus into a text row's input (the enter-text routing, and a
+	// direct marking resolving on a text row).  preventDefault keeps the triggering
+	// key from typing into the input that synchronously receives focus -- for the
+	// marked-key path that key is a letter, which WOULD otherwise land as text.
+	private diveIntoText(rowId: string, e: KeyboardEvent): void {
+		const input = this.rowElement(rowId).querySelector<HTMLElement>("[data-mc-input]");
+		if (input === null) throw new Error(`megacolumn: text row "${rowId}" lost its [data-mc-input]`);
+		e.preventDefault();
+		input.focus();
+	}
+
+	// The megacolumn-focus-request event (see the header): a programmatic focus
+	// move with a hotkey jump's nature -- pure navigation, no claim, live during
+	// lockouts.  Stale or malformed requests are ignored rather than thrown: the
+	// requester composes against a live column, and a row can vanish between its
+	// look and its ask.  Granted requests answer with preventDefault.
+	private onFocusRequest(e: Event): void {
+		const detail: unknown = (e as CustomEvent).detail;
+		if (typeof detail !== "object" || detail === null) return;
+		const rowId = (detail as { rowId?: unknown }).rowId;
+		if (typeof rowId !== "string") return;
+		if (this.nav.textMode) return; // text entry dominates; jumps do not exist there
+		const row = this.nav.rows.find((r) => r.id === rowId);
+		if (row === undefined || row.focusable === false) return;
+		this.nav.focusRow(rowId, "main");
+		e.preventDefault();
+		this.applyFocusState();
+	}
+
+	// Mouse clicks sync logical focus.  mousedown (not click) so the machine is
+	// already coherent when the browser's default focus movement fires focusin.
+	private onMousedown(e: MouseEvent): void {
+		const target = e.target;
+		if (!(target instanceof Element)) return;
+		const rowEl = target.closest("[data-mc-row]");
+		if (rowEl === null || !(rowEl instanceof HTMLElement)) return;
+		const id = rowEl.dataset.mcRow ?? "";
+		const row = this.nav.rows.find((r) => r.id === id);
+		if (row === undefined || row.focusable === false) return; // click on a disabled row: inert
+		const position: RowPosition = target.closest("[data-mc-aux]") !== null ? "aux" : "main";
+		this.nav.focusRow(id, position);
+		this.applyFocusState();
+	}
+
+	// Pointer activation (see the header): a click on a row's control dispatches
+	// the same megacolumn-key the keyboard's Enter dispatches -- one activation
+	// path for every input modality.  mousedown already moved logical focus
+	// (mousedown precedes click), so by dispatch time the machine agrees with
+	// what the user sees; the dispatch targets the clicked control regardless.
+	// The churn lockout is deliberately not consulted: it protects identity-aimed
+	// keystrokes from surprise focus moves, while a click names its target
+	// geometrically and its own mousedown just resolved the aim.  Moving-target
+	// clicks are the geometry owner's problem, stopped upstream in the capture
+	// phase (sw-live-list's click guard) before this bubble listener can hear them.
+	private onClick(e: MouseEvent): void {
+		if (e.defaultPrevented) return; // a widget activated on this click already and claimed it
+		const target = e.target;
+		if (!(target instanceof Element)) return;
+		if (target.closest("[data-mc-input]") !== null) return; // input clicks dive via focusin
+		const rowEl = target.closest("[data-mc-row]");
+		if (rowEl === null || !(rowEl instanceof HTMLElement)) return;
+		const id = rowEl.dataset.mcRow ?? "";
+		const row = this.nav.rows.find((r) => r.id === id);
+		if (row === undefined || row.focusable === false) return; // closed rows: inert, like the mousedown sync
+		const position: RowPosition = target.closest("[data-mc-aux]") !== null ? "aux" : "main";
+		if (row.textEntry === true && position === "main") return; // Enter there is the text dive, not a forward
+		const control = this.controlElement(rowEl, position);
+		const synthesized = new CustomEvent<MegacolumnKeyDetail>("megacolumn-key", {
+			detail: { key: "Enter", rowId: id, position },
+			bubbles: true,
+			cancelable: true,
+		});
+		// A claim opens the lockout's excuse window exactly as a claimed keydown
+		// forward does (a click-removal is the same controlled moment the user made
+		// happen), and marks the click consumed for any upstream listener.
+		if (!control.dispatchEvent(synthesized)) {
+			this.noteClaim();
+			e.preventDefault();
+		}
+	}
+
+	private onFocusin(e: FocusEvent): void {
+		const container = this.mustContainer();
+		container.classList.add("mc-focus-within");
+		// A click (or programmatic focus) landed directly in a text input:
+		// text entry dominates, sync the machine to reality.
+		const target = e.target;
+		if (target instanceof Element && target.matches("[data-mc-input]")) {
+			const rowEl = target.closest("[data-mc-row]");
+			if (rowEl instanceof HTMLElement) {
+				const id = rowEl.dataset.mcRow ?? "";
+				if (this.nav.focusedId !== id || !this.nav.textMode) this.nav.noteTextEntered(id);
+			}
+		}
+		this.applyFocusState();
+	}
+
+	private onFocusout(e: FocusEvent): void {
+		const container = this.mustContainer();
+		// Only react when focus leaves the container subtree entirely; movements
+		// within it (input <-> container) are handled by their own events.
+		const goingTo = e.relatedTarget;
+		if (goingTo instanceof Node && container.contains(goingTo)) return;
+		container.classList.remove("mc-focus-within");
+		if (this.nav.textMode) this.nav.exitTextMode();
+		this.applyFocusState();
+		// Row focus memory is intrinsic to the machine: focusedId survives untouched,
+		// so regaining DOM focus resumes exactly where the user left.
+	}
+
+	private mustContainer(): HTMLElement {
+		if (this.#container === null) throw new Error("megacolumn: container not wired yet");
+		return this.#container;
+	}
+
+	// The nearest [data-info] carrier from the focused control upward, stopping at
+	// the row (inclusive).  Empty data-info means "not a carrier", same as absent.
+	private infoCarrier(control: HTMLElement, row: HTMLElement): HTMLElement | null {
+		let el: HTMLElement | null = control;
+		while (el !== null) {
+			if ((el.dataset.info ?? "") !== "") return el;
+			if (el === row) return null;
+			el = el.parentElement;
+		}
+		return null;
+	}
+
+	private focusIsInside(container: HTMLElement): boolean {
+		const active = container.getRootNode() as Document | ShadowRoot;
+		return active.activeElement !== null && container.contains(active.activeElement);
+	}
+
+	private rowElement(id: string): HTMLElement {
+		const el = this.mustContainer().querySelector<HTMLElement>(`[data-mc-row="${CSS.escape(id)}"]`);
+		// The machine only names rows it was given by scanRows, so a miss here means
+		// the DOM changed without a render (which is where re-scans happen): a bug.
+		if (el === null) throw new Error(`megacolumn: row "${id}" vanished from the DOM mid-dispatch`);
+		return el;
+	}
+
+	private controlElement(row: HTMLElement, position: RowPosition): HTMLElement {
+		if (position === "aux") {
+			const aux = row.querySelector<HTMLElement>("[data-mc-aux]");
+			if (aux === null)
+				throw new Error(`megacolumn: row "${row.dataset.mcRow}" lost its [data-mc-aux]`);
+			return aux;
+		}
+		return row.querySelector<HTMLElement>("[data-mc-main]") ?? row;
+	}
+}
+
+// The focus-ring rendering, from the design tokens.  Import into any host's
+// static styles; the classes are maintained by the controller:
+//   mc-focus-within  on the container while real DOM focus is anywhere inside
+//   mc-focused       on the focused row's control (main or aux)
+//   mc-row-focused   on the focused row element (for whole-row washes)
+//   mc-text-mode     on the container during text entry
+//   mc-empty-focus   on the container when focused with no focusable rows
+//
+// The ring is drawn on the ROW, not the control: the container sizes the
+// focusable unit (the guidelines' "rows are rows" norm), so the ring draws the
+// same shape on every row in a region instead of struggle-hugging whatever
+// content the focused control happens to have.  It sits inset (negative
+// offset) so the shape is identical even for rows flush against the
+// container's scroll edges.  The aux position keeps its own hugging ring ON
+// TOP of the row ring: within-row movement must stay visible, and the aux is
+// a small fixed-size control, so its ring is consistent by construction.
+export const megacolumnStyles = css`
+	[data-mc-container] {
+		outline: none; /* the ring is drawn on the focused row instead */
+	}
+	/* Navigation scrolls (scrollIntoView from applyFocusState) land the focused row
+	   --sw-scroll-peek inside the scrollport edge, keeping about one neighbor row
+	   visible beyond it: the user sees the list continue (or truly end) past where
+	   they are.  scroll-margin only affects programmatic scrolls into view; free
+	   scrolling and PageUp/Down are untouched. */
+	[data-mc-row] {
+		scroll-margin-block: var(--sw-scroll-peek);
+	}
+	[data-mc-container].mc-empty-focus {
+		outline: var(--sw-focus-ring);
+		outline-offset: calc(-1 * var(--sw-focus-ring-width));
+	}
+	.mc-focus-within [data-mc-row].mc-row-focused {
+		outline: var(--sw-focus-ring);
+		outline-offset: calc(-1 * var(--sw-focus-ring-width));
+	}
+	.mc-focus-within [data-mc-aux].mc-focused {
+		outline: var(--sw-focus-ring);
+		outline-offset: calc(-1 * var(--sw-focus-ring-width));
+	}
+	/* Focus memory stays quietly visible while the container is unfocused,
+	   so the user can see where focus will resume before tabbing back in. */
+	.mc-row-focused {
+		background: var(--sw-wash-faint);
+	}
+	.mc-focus-within .mc-row-focused {
+		background: var(--sw-wash-selected);
+	}
+	/* Activation feedback for plain rows: a host that acts on a forwarded
+	   activation calls flashPress on the event's target -- the row's control
+	   element (the main control if the row has one, else the row itself).
+	   So a two-control row flashes its main, never the x: the flash answers
+	   "what did I just do", inside the row the ring outlines.  Equal specificity,
+	   later in the sheet: the flash outranks the focus washes above, while the
+	   ring (an outline) coexists untouched. */
+	[data-mc-row].sw-pressed,
+	[data-mc-row] [data-mc-main].sw-pressed {
+		background: var(--sw-wash-pressed);
+	}
+`;
diff --git a/packages/yukey/src/megacolumn-nav.test.ts b/packages/yukey/src/megacolumn-nav.test.ts
new file mode 100644
index 0000000..b7334aa
--- /dev/null
+++ b/packages/yukey/src/megacolumn-nav.test.ts
@@ -0,0 +1,454 @@
+// Tests for the megacolumn navigation state machine (megacolumn-nav.ts).
+// Pure logic: every test drives the machine with rows and key names and asserts
+// on (focusedId, position, textMode) plus the returned routing.  The edge-case
+// inventory comes from the guidelines' megacolumn norms: vanishing rows, gaps,
+// two-control rows at the column edges, text-entry dominance, empty columns.
+
+import { describe, expect, it } from "vitest";
+import { MegacolumnNav, type NavRow } from "./megacolumn-nav.js";
+
+// Row-list shorthand: "a b! c* d~" -> b is unfocusable, c has an aux control,
+// d is a text-entry row.  Keeps each test's column readable at a glance.
+function rows(spec: string): NavRow[] {
+	if (spec === "") return [];
+	return spec.split(" ").map((word) => {
+		const id = word.replace(/[!*~]/g, "");
+		return {
+			id,
+			focusable: !word.includes("!"),
+			hasAux: word.includes("*"),
+			textEntry: word.includes("~"),
+		};
+	});
+}
+
+function navWith(spec: string): MegacolumnNav {
+	const nav = new MegacolumnNav();
+	nav.setRows(rows(spec));
+	return nav;
+}
+
+describe("row registration", () => {
+	it("focuses the first focusable row eagerly", () => {
+		const nav = navWith("a b c");
+		expect(nav.focusedId).toBe("a");
+		expect(nav.position).toBe("main");
+	});
+
+	it("skips leading unfocusable rows for the initial focus", () => {
+		expect(navWith("a! b! c d").focusedId).toBe("c");
+	});
+
+	it("focuses nothing in an empty column", () => {
+		expect(navWith("").focusedId).toBeNull();
+	});
+
+	it("focuses nothing in an all-unfocusable column", () => {
+		expect(navWith("a! b!").focusedId).toBeNull();
+	});
+
+	it("rejects duplicate row ids loudly", () => {
+		const nav = new MegacolumnNav();
+		expect(() => nav.setRows(rows("a b a"))).toThrow(/duplicate.*: a/);
+	});
+
+	it("rejects empty row ids loudly", () => {
+		const nav = new MegacolumnNav();
+		expect(() => nav.setRows([{ id: "" }])).toThrow(/empty id/);
+	});
+
+	it("owns its copy of the row list (caller mutations after setRows are invisible)", () => {
+		const list = rows("a b");
+		const nav = new MegacolumnNav();
+		nav.setRows(list);
+		list.length = 0;
+		expect(nav.rows.length).toBe(2);
+	});
+
+	it("registration order IS display order: focus follows the list as given", () => {
+		// The machine imposes no sorting of its own; whoever calls setRows
+		// (the binding layer, during render) is stating the display order.
+		const nav = navWith("c a b");
+		expect(nav.focusedId).toBe("c");
+		nav.handleKey("ArrowDown");
+		expect(nav.focusedId).toBe("a");
+	});
+});
+
+describe("up/down movement", () => {
+	it("moves down and up between adjacent rows", () => {
+		const nav = navWith("a b c");
+		expect(nav.handleKey("ArrowDown")).toEqual({ kind: "nav" });
+		expect(nav.focusedId).toBe("b");
+		expect(nav.handleKey("ArrowUp")).toEqual({ kind: "nav" });
+		expect(nav.focusedId).toBe("a");
+	});
+
+	it("skips unfocusable rows in both directions (the gap jump)", () => {
+		const nav = navWith("a gap1! gap2! b");
+		nav.handleKey("ArrowDown");
+		expect(nav.focusedId).toBe("b");
+		nav.handleKey("ArrowUp");
+		expect(nav.focusedId).toBe("a");
+	});
+
+	it("consumes but does not wrap at the bottom edge", () => {
+		const nav = navWith("a b");
+		nav.handleKey("ArrowDown");
+		expect(nav.handleKey("ArrowDown")).toEqual({ kind: "nav" });
+		expect(nav.focusedId).toBe("b");
+	});
+
+	it("consumes but does not wrap at the top edge", () => {
+		const nav = navWith("a b");
+		expect(nav.handleKey("ArrowUp")).toEqual({ kind: "nav" });
+		expect(nav.focusedId).toBe("a");
+	});
+
+	it("stays put when trailing rows are all unfocusable", () => {
+		const nav = navWith("a b! c!");
+		expect(nav.handleKey("ArrowDown")).toEqual({ kind: "nav" });
+		expect(nav.focusedId).toBe("a");
+	});
+
+	it("single-row column: arrows consumed, focus stays", () => {
+		const nav = navWith("only");
+		expect(nav.handleKey("ArrowDown")).toEqual({ kind: "nav" });
+		expect(nav.handleKey("ArrowUp")).toEqual({ kind: "nav" });
+		expect(nav.focusedId).toBe("only");
+	});
+
+	it("Home and End jump to the first/last focusable row", () => {
+		const nav = navWith("gap1! a b c gap2!");
+		nav.handleKey("ArrowDown");
+		expect(nav.handleKey("End")).toEqual({ kind: "nav" });
+		expect(nav.focusedId).toBe("c");
+		expect(nav.handleKey("Home")).toEqual({ kind: "nav" });
+		expect(nav.focusedId).toBe("a");
+	});
+
+	it("empty column: navigation keys consumed (no page scroll), others pass", () => {
+		const nav = navWith("");
+		expect(nav.handleKey("ArrowDown")).toEqual({ kind: "nav" });
+		expect(nav.handleKey("Home")).toEqual({ kind: "nav" });
+		expect(nav.handleKey("Enter")).toEqual({ kind: "pass" });
+		expect(nav.handleKey("x")).toEqual({ kind: "pass" });
+	});
+});
+
+describe("the two-control row (main + aux)", () => {
+	it("ArrowRight reaches the aux control, ArrowLeft returns to main", () => {
+		const nav = navWith("a* b");
+		expect(nav.handleKey("ArrowRight")).toEqual({ kind: "nav" });
+		expect(nav.position).toBe("aux");
+		expect(nav.handleKey("ArrowLeft")).toEqual({ kind: "nav" });
+		expect(nav.position).toBe("main");
+	});
+
+	it("row changes land on the main control, from aux, in every direction", () => {
+		const nav = navWith("a* b* c*");
+		nav.handleKey("ArrowDown"); // -> b
+		nav.handleKey("ArrowRight"); // b/aux
+		nav.handleKey("ArrowDown"); // -> c
+		expect(nav.focusedId).toBe("c");
+		expect(nav.position).toBe("main");
+		nav.handleKey("ArrowRight"); // c/aux
+		nav.handleKey("ArrowUp"); // -> b
+		expect(nav.position).toBe("main");
+		nav.handleKey("ArrowRight"); // b/aux
+		nav.handleKey("Home"); // -> a
+		expect(nav.focusedId).toBe("a");
+		expect(nav.position).toBe("main");
+	});
+
+	it("aux works on the first and last rows of the column", () => {
+		const nav = navWith("a* b c*");
+		nav.handleKey("ArrowRight");
+		expect(nav.position).toBe("aux");
+		// At the top edge, ArrowUp from aux: no row change happens, and the machine
+		// treats it as consumed navigation -- position snaps home to main, because
+		// the user asked to go somewhere and "main of where you are" is the landing.
+		nav.handleKey("ArrowUp");
+		expect(nav.focusedId).toBe("a");
+		expect(nav.position).toBe("main");
+		nav.handleKey("End");
+		nav.handleKey("ArrowRight");
+		expect(nav.focusedId).toBe("c");
+		expect(nav.position).toBe("aux");
+		nav.handleKey("ArrowDown");
+		expect(nav.position).toBe("main");
+	});
+
+	it("ArrowRight twice stays on aux (one aux per row; nowhere further to go)", () => {
+		const nav = navWith("a*");
+		nav.handleKey("ArrowRight");
+		expect(nav.handleKey("ArrowRight")).toEqual({ kind: "nav" });
+		expect(nav.position).toBe("aux");
+	});
+
+	it("keys other than arrows forward to the aux control while it holds focus", () => {
+		const nav = navWith("a*");
+		nav.handleKey("ArrowRight");
+		expect(nav.handleKey("Enter")).toEqual({ kind: "forward", rowId: "a", position: "aux" });
+	});
+
+	it("rows without aux forward left/right instead (sliders eat arrows)", () => {
+		const nav = navWith("slider");
+		expect(nav.handleKey("ArrowRight")).toEqual({
+			kind: "forward",
+			rowId: "slider",
+			position: "main",
+		});
+		expect(nav.handleKey("ArrowLeft")).toEqual({
+			kind: "forward",
+			rowId: "slider",
+			position: "main",
+		});
+	});
+});
+
+describe("key routing to widgets", () => {
+	it("forwards non-navigation keys to the focused row's main control", () => {
+		const nav = navWith("a b");
+		for (const key of ["Enter", " ", "x", "Delete", "i", "PageDown"]) {
+			expect(nav.handleKey(key)).toEqual({ kind: "forward", rowId: "a", position: "main" });
+		}
+	});
+
+	it("Tab is never claimed: it passes in row-nav mode and in text mode", () => {
+		// Tab is the browser's container-to-container movement key; forwarding it to a
+		// row widget would let a sloppy widget break leaving the column entirely.
+		const nav = navWith("a query~");
+		expect(nav.handleKey("Tab")).toEqual({ kind: "pass" });
+		nav.handleKey("ArrowDown");
+		nav.handleKey("Enter"); // text mode
+		expect(nav.handleKey("Tab")).toEqual({ kind: "pass" });
+	});
+
+	it("throws if the focused row was yanked without a setRows", () => {
+		// The binding layer reconciles rows on every render, so a stale focused id
+		// reaching handleKey means registration is broken: fail loudly.
+		const list = rows("a b");
+		const nav = new MegacolumnNav();
+		nav.setRows(list);
+		const first = list[0];
+		if (first === undefined) throw new Error("unreachable: rows() made two rows");
+		first.id = "mutated"; // row objects are shared; ids must not be mutated in place
+		expect(() => nav.handleKey("ArrowDown")).toThrow(/vanished without setRows/);
+	});
+});
+
+describe("text-entry mode", () => {
+	it("Enter on a text row enters text mode; Space likewise", () => {
+		const nav = navWith("query~ b");
+		expect(nav.handleKey("Enter")).toEqual({ kind: "enter-text", rowId: "query" });
+		expect(nav.textMode).toBe(true);
+		const nav2 = navWith("query~ b");
+		expect(nav2.handleKey(" ")).toEqual({ kind: "enter-text", rowId: "query" });
+	});
+
+	it("text mode passes everything to the input, including arrows and space", () => {
+		const nav = navWith("query~ b");
+		nav.handleKey("Enter");
+		for (const key of ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", " ", "x", "Home"]) {
+			expect(nav.handleKey(key)).toEqual({ kind: "pass" });
+		}
+		expect(nav.focusedId).toBe("query"); // navigation did not move
+	});
+
+	it("Escape exits text mode and row focus resumes where it was", () => {
+		const nav = navWith("a query~ c");
+		nav.handleKey("ArrowDown");
+		nav.handleKey("Enter");
+		expect(nav.handleKey("Escape")).toEqual({ kind: "exit-text", rowId: "query" });
+		expect(nav.textMode).toBe(false);
+		expect(nav.focusedId).toBe("query");
+		nav.handleKey("ArrowDown");
+		expect(nav.focusedId).toBe("c");
+	});
+
+	it("Enter also exits text mode (commit-and-leave)", () => {
+		const nav = navWith("query~");
+		nav.handleKey("Enter");
+		expect(nav.handleKey("Enter")).toEqual({ kind: "exit-text", rowId: "query" });
+		expect(nav.textMode).toBe(false);
+	});
+
+	it("re-entry after exit works (enter, exit, navigate away and back, enter again)", () => {
+		const nav = navWith("query~ b");
+		nav.handleKey("Enter");
+		nav.handleKey("Escape");
+		nav.handleKey("ArrowDown");
+		nav.handleKey("ArrowUp");
+		expect(nav.handleKey("Enter")).toEqual({ kind: "enter-text", rowId: "query" });
+		expect(nav.textMode).toBe(true);
+	});
+
+	it("noteTextEntered syncs the machine when a mouse click lands in the input", () => {
+		const nav = navWith("a query~");
+		expect(nav.focusedId).toBe("a");
+		nav.noteTextEntered("query");
+		expect(nav.focusedId).toBe("query");
+		expect(nav.textMode).toBe(true);
+		expect(() => nav.noteTextEntered("a")).toThrow(/not a text row/);
+	});
+
+	it("exitTextMode syncs the machine when DOM focus leaves without an exit key", () => {
+		const nav = navWith("query~");
+		nav.handleKey("Enter");
+		nav.exitTextMode();
+		expect(nav.textMode).toBe(false);
+		expect(() => nav.exitTextMode()).toThrow(/not in text mode/);
+	});
+
+	it("a text row that stops being one while focused drops text mode on setRows", () => {
+		const nav = navWith("query~");
+		nav.handleKey("Enter");
+		nav.setRows(rows("query"));
+		expect(nav.textMode).toBe(false);
+		expect(nav.focusedId).toBe("query");
+	});
+});
+
+describe("focus memory and vanishing rows", () => {
+	it("the focused row keeps focus across setRows churn around it", () => {
+		const nav = navWith("a b c");
+		nav.handleKey("ArrowDown"); // -> b
+		nav.setRows(rows("x a b c y"));
+		expect(nav.focusedId).toBe("b");
+	});
+
+	it("the focused row keeps focus even if it moved in display order", () => {
+		const nav = navWith("a b c");
+		nav.handleKey("ArrowDown"); // -> b
+		nav.setRows(rows("b c a"));
+		expect(nav.focusedId).toBe("b");
+		nav.handleKey("ArrowDown");
+		expect(nav.focusedId).toBe("c");
+	});
+
+	it("a vanished focused row falls to the next row below (old display order)", () => {
+		const nav = navWith("a b c");
+		nav.handleKey("ArrowDown"); // -> b
+		nav.setRows(rows("a c"));
+		expect(nav.focusedId).toBe("c");
+	});
+
+	it("falls upward when nothing below survives", () => {
+		const nav = navWith("a b c");
+		nav.handleKey("End"); // -> c
+		nav.setRows(rows("a b"));
+		expect(nav.focusedId).toBe("b");
+	});
+
+	it("skips unfocusable survivors when falling", () => {
+		const nav = navWith("a b c d");
+		nav.handleKey("ArrowDown"); // -> b
+		nav.setRows(rows("a c! d"));
+		expect(nav.focusedId).toBe("d");
+	});
+
+	it("a focused row that became unfocusable is treated as vanished", () => {
+		const nav = navWith("a b c");
+		nav.handleKey("ArrowDown"); // -> b
+		nav.setRows(rows("a b! c"));
+		expect(nav.focusedId).toBe("c");
+	});
+
+	it("wholesale replacement falls to the new first focusable row", () => {
+		const nav = navWith("a b c");
+		nav.handleKey("ArrowDown");
+		nav.setRows(rows("x! y z"));
+		expect(nav.focusedId).toBe("y");
+	});
+
+	it("emptying the column clears focus; repopulating restores it eagerly", () => {
+		const nav = navWith("a b");
+		nav.setRows([]);
+		expect(nav.focusedId).toBeNull();
+		nav.setRows(rows("c d"));
+		expect(nav.focusedId).toBe("c");
+	});
+
+	it("vanishing resets position to main and drops text mode", () => {
+		const nav = navWith("a* b~");
+		nav.handleKey("ArrowRight"); // a/aux
+		nav.setRows(rows("b~ c"));
+		expect(nav.position).toBe("main");
+		const nav2 = navWith("q~ r");
+		nav2.handleKey("Enter"); // text mode in q
+		nav2.setRows(rows("r"));
+		expect(nav2.textMode).toBe(false);
+		expect(nav2.focusedId).toBe("r");
+	});
+
+	it("a focused row that loses its aux while focus sits on the aux snaps to main", () => {
+		const nav = navWith("a* b");
+		nav.handleKey("ArrowRight");
+		expect(nav.position).toBe("aux");
+		nav.setRows(rows("a b"));
+		expect(nav.focusedId).toBe("a");
+		expect(nav.position).toBe("main");
+	});
+});
+
+describe("setRows reports involuntary focus moves", () => {
+	// The return value feeds the binding's churn lockout: true means the fallback
+	// moved focus somewhere the user did not send it.
+	it("reports false when the focused row survives churn", () => {
+		const nav = navWith("a b c");
+		nav.handleKey("ArrowDown"); // -> b
+		expect(nav.setRows(rows("x a b c y"))).toBe(false);
+	});
+
+	it("reports true when the fallback picks a neighbor", () => {
+		const nav = navWith("a b c");
+		nav.handleKey("ArrowDown"); // -> b
+		expect(nav.setRows(rows("a c"))).toBe(true);
+		expect(nav.focusedId).toBe("c");
+	});
+
+	it("reports true when the focused row went unfocusable", () => {
+		const nav = navWith("a b");
+		expect(nav.setRows(rows("a! b"))).toBe(true);
+		expect(nav.focusedId).toBe("b");
+	});
+
+	it("reports false when the column empties (nothing to aim at, nothing to protect)", () => {
+		const nav = navWith("a");
+		expect(nav.setRows([])).toBe(false);
+		expect(nav.focusedId).toBeNull();
+	});
+
+	it("reports false on the eager first focus of a filling column", () => {
+		const nav = new MegacolumnNav();
+		expect(nav.setRows(rows("a b"))).toBe(false);
+		expect(nav.focusedId).toBe("a");
+	});
+});
+
+describe("direct focus (mouse clicks arrive here via the binding)", () => {
+	it("focusRow moves focus and lands on main by default", () => {
+		const nav = navWith("a b* c");
+		nav.focusRow("b");
+		expect(nav.focusedId).toBe("b");
+		expect(nav.position).toBe("main");
+		nav.focusRow("b", "aux");
+		expect(nav.position).toBe("aux");
+	});
+
+	it("focusRow rejects unknown, unfocusable, and aux-less targets loudly", () => {
+		const nav = navWith("a b! c");
+		expect(() => nav.focusRow("nope")).toThrow(/unknown row id/);
+		expect(() => nav.focusRow("b")).toThrow(/not focusable/);
+		expect(() => nav.focusRow("c", "aux")).toThrow(/no aux control/);
+	});
+
+	it("focusRow exits text mode (clicking another row while typing)", () => {
+		const nav = navWith("q~ b");
+		nav.handleKey("Enter");
+		nav.focusRow("b");
+		expect(nav.textMode).toBe(false);
+		expect(nav.focusedId).toBe("b");
+	});
+});
diff --git a/packages/yukey/src/megacolumn-nav.ts b/packages/yukey/src/megacolumn-nav.ts
new file mode 100644
index 0000000..0fc7dfd
--- /dev/null
+++ b/packages/yukey/src/megacolumn-nav.ts
@@ -0,0 +1,281 @@
+// The megacolumn navigation state machine: pure logic, no DOM, no timers, no async.
+// Events in, state out.  The Lit binding layer (megacolumn-controller.ts) feeds it
+// key names and row lists and acts on the routing decisions it returns; this file
+// never touches a DOM type, which is what makes it testable to exhaustion.
+//
+// The model it implements is the megacolumn from docs/precepts-we-love-the-keyboard.md:
+// focus granularity is the row; up/down move between focusable rows (skipping
+// unfocusable ones and jumping section gaps); a row may have a main control plus
+// one aux control (the small-x norm), reached with ArrowRight and left again with
+// ArrowLeft; row changes always land on the main control.  Keys the column does
+// not consume are forwarded to the focused row's widget.  Text entry dominates:
+// once entered, every key belongs to the input except the explicit exits.
+//
+// Key routing at a glance (row-nav mode):
+//   Tab                         -> pass, always (container-to-container is the browser's)
+//   ArrowUp/ArrowDown/Home/End  -> navigation, always consumed (no wrap at the ends)
+//   ArrowRight/ArrowLeft        -> within-row movement if the row has an aux control,
+//                                  otherwise forwarded (sliders consume them; a row
+//                                  therefore can't be both a slider and a two-control row)
+//   Enter/Space on a text row   -> enter text-entry mode (spacebar mirrors enter, per the guidelines)
+//   everything else             -> forwarded to the focused row's widget
+// and in text-entry mode:
+//   Tab                         -> pass, likewise
+//   Escape/Enter                -> exit text-entry mode
+//   everything else             -> passed untouched to the natively-focused input
+//
+// Rows are identified by stable ids, never indices: rows come and go (live lists
+// churn), and focus must follow identity.  setRows() reconciles the whole ordered
+// list per render; when the focused row vanishes, focus falls to the nearest
+// focusable row below it (in the old display order), else the nearest above,
+// else the first focusable in the new list.
+//
+// Invariant: focusedId is non-null exactly when at least one focusable row exists.
+// (Focus is eager: there is no "container focused but no row focused" limbo,
+// so aria-activedescendant always has a referent when it matters.)
+//
+// A future 2D-table variant would generalize the row list to rows-of-cells and
+// re-point ArrowLeft/Right at cell movement instead of within-row movement
+// (which is exactly why sliders are banned from tables in the guidelines);
+// the reconcile/fallback/memory core would carry over unchanged.
+
+export type RowPosition = "main" | "aux";
+
+export interface NavRow {
+	// Stable identity.  Must be unique within the column and non-empty; violations throw.
+	id: string;
+	// Focusable rows are navigation stops; unfocusable rows (rows closed to sight,
+	// registered headings) are skipped.  Visible-but-inactive rows stay focusable:
+	// the keyboard must reach them (for i, and for stride-count stability).
+	// Defaults to true.
+	focusable?: boolean;
+	// The two-control-row norm: a main control plus one aux control (the small-x).
+	hasAux?: boolean;
+	// The main control is a text input; Enter/Space on it enters text-entry mode.
+	textEntry?: boolean;
+}
+
+// What the caller should do with a key, decided by the machine.
+// "nav" means the machine consumed it (state may have changed): prevent the default.
+// "forward" means the key belongs to the focused row's widget.
+// "enter-text"/"exit-text" instruct the binding to move real DOM focus
+// (into the row's input / back to the container).
+// "pass" means the key is not ours: let the browser have it.
+export type KeyRouting =
+	| { kind: "nav" }
+	| { kind: "forward"; rowId: string; position: RowPosition }
+	| { kind: "enter-text"; rowId: string }
+	| { kind: "exit-text"; rowId: string }
+	| { kind: "pass" };
+
+function isFocusable(row: NavRow): boolean {
+	return row.focusable ?? true;
+}
+
+export class MegacolumnNav {
+	#rows: NavRow[] = [];
+	#focusedId: string | null = null;
+	#position: RowPosition = "main";
+	#textMode = false;
+
+	get rows(): readonly NavRow[] {
+		return this.#rows;
+	}
+	get focusedId(): string | null {
+		return this.#focusedId;
+	}
+	get position(): RowPosition {
+		return this.#position;
+	}
+	get textMode(): boolean {
+		return this.#textMode;
+	}
+
+	// Reconcile the whole ordered row list (display order).  Focus follows row id:
+	// a surviving focused row keeps focus even if it moved; a vanished (or newly
+	// unfocusable) one falls to the nearest focusable neighbor, preferring downward
+	// in the old display order.  Row changes of any kind land on the main control,
+	// and a focused row that lost its aux or textEntry nature sheds the dependent state.
+	// Returns true when the fallback moved focus involuntarily (a row the user was on
+	// gave way to a neighbor): the binding uses this to lock out decisive keys briefly.
+	// Focus appearing in a previously-empty column is not involuntary movement
+	// (the user was not aiming at any row), so that returns false.
+	setRows(rows: NavRow[]): boolean {
+		const seen = new Set<string>();
+		for (const row of rows) {
+			if (row.id === "") throw new Error("megacolumn row with empty id");
+			if (seen.has(row.id)) throw new Error(`duplicate megacolumn row id: ${row.id}`);
+			seen.add(row.id);
+		}
+		const oldRows = this.#rows;
+		this.#rows = rows.slice(); // own our copy; callers may mutate theirs later
+
+		const hadFocus = this.#focusedId !== null;
+		const focused = this.#focusedId === null ? undefined : this.rowById(this.#focusedId);
+		if (focused !== undefined && isFocusable(focused)) {
+			if (this.#position === "aux" && focused.hasAux !== true) this.#position = "main";
+			if (this.#textMode && focused.textEntry !== true) this.#textMode = false;
+			return false;
+		}
+		this.#textMode = false;
+		this.#position = "main";
+		this.#focusedId = this.fallbackFrom(oldRows);
+		return hadFocus && this.#focusedId !== null;
+	}
+
+	// Move focus to a row directly (the binding calls this for mouse clicks).
+	// Unknown ids, unfocusable rows, and aux positions on aux-less rows are caller
+	// bugs: fail loudly rather than limp.
+	focusRow(id: string, position: RowPosition = "main"): void {
+		const row = this.rowById(id);
+		if (row === undefined) throw new Error(`focusRow: unknown row id: ${id}`);
+		if (!isFocusable(row)) throw new Error(`focusRow: row is not focusable: ${id}`);
+		if (position === "aux" && row.hasAux !== true)
+			throw new Error(`focusRow: row has no aux control: ${id}`);
+		this.#focusedId = id;
+		this.#position = position;
+		this.#textMode = false;
+	}
+
+	// Real DOM focus landed in a text row's input without going through handleKey
+	// (a mouse click on the input).  Text entry dominates: sync the machine to reality.
+	noteTextEntered(id: string): void {
+		const row = this.rowById(id);
+		if (row === undefined) throw new Error(`noteTextEntered: unknown row id: ${id}`);
+		if (row.textEntry !== true) throw new Error(`noteTextEntered: not a text row: ${id}`);
+		if (!isFocusable(row)) throw new Error(`noteTextEntered: row is not focusable: ${id}`);
+		this.#focusedId = id;
+		this.#position = "main";
+		this.#textMode = true;
+	}
+
+	// Real DOM focus left the input without an exit key (the container blurred).
+	// Calling this outside text mode is a caller bug.
+	exitTextMode(): void {
+		if (!this.#textMode) throw new Error("exitTextMode: not in text mode");
+		this.#textMode = false;
+	}
+
+	// Route one key (a KeyboardEvent.key value; the binding filters out
+	// ctrl/alt/meta chords before asking).  See the routing table in the file header.
+	handleKey(key: string): KeyRouting {
+		// Tab is never ours: it is the browser's container-to-container movement,
+		// in every mode.  (Forwarding it to a widget could break leaving the column.)
+		if (key === "Tab") return { kind: "pass" };
+
+		if (this.#textMode) {
+			if (key === "Escape" || key === "Enter") {
+				const id = this.mustFocusedId("in text mode");
+				this.#textMode = false;
+				return { kind: "exit-text", rowId: id };
+			}
+			return { kind: "pass" };
+		}
+
+		// No focusable rows (empty or all-unfocusable column): navigation keys are
+		// still consumed -- the arrows belong to this column while it holds DOM focus,
+		// and letting them scroll the page would be surprising -- but nothing moves.
+		// Other keys pass: there is no row widget to forward to.
+		if (this.#focusedId === null) {
+			if (key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End")
+				return { kind: "nav" };
+			return { kind: "pass" };
+		}
+		const row = this.rowById(this.#focusedId);
+		if (row === undefined)
+			throw new Error(`focused row vanished without setRows: ${this.#focusedId}`);
+
+		switch (key) {
+			case "ArrowDown":
+				return this.moveFocus(+1);
+			case "ArrowUp":
+				return this.moveFocus(-1);
+			case "Home":
+				return this.focusEdge("first");
+			case "End":
+				return this.focusEdge("last");
+			case "ArrowRight":
+				if (row.hasAux === true) {
+					this.#position = "aux";
+					return { kind: "nav" };
+				}
+				return { kind: "forward", rowId: row.id, position: this.#position };
+			case "ArrowLeft":
+				if (row.hasAux === true) {
+					this.#position = "main";
+					return { kind: "nav" };
+				}
+				return { kind: "forward", rowId: row.id, position: this.#position };
+			case "Enter":
+			case " ":
+				if (row.textEntry === true && this.#position === "main") {
+					this.#textMode = true;
+					return { kind: "enter-text", rowId: row.id };
+				}
+				return { kind: "forward", rowId: row.id, position: this.#position };
+			default:
+				return { kind: "forward", rowId: row.id, position: this.#position };
+		}
+	}
+
+	private rowById(id: string): NavRow | undefined {
+		return this.#rows.find((r) => r.id === id);
+	}
+
+	private mustFocusedId(context: string): string {
+		if (this.#focusedId === null) throw new Error(`${context} with no focused row`);
+		return this.#focusedId;
+	}
+
+	// Step to the next focusable row in `delta` direction, skipping unfocusable rows.
+	// No wrap: at the ends the key is still consumed ("nav" with no change), so the
+	// page never scrolls out from under a keyboard user probing the column's bounds.
+	// Position snaps to main even when no move happens: after ANY up/down press the
+	// user is on a main control, never left lingering on an aux (delete) control.
+	private moveFocus(delta: 1 | -1): KeyRouting {
+		const focusedId = this.mustFocusedId("moveFocus");
+		this.#position = "main";
+		const start = this.#rows.findIndex((r) => r.id === focusedId);
+		if (start === -1) throw new Error(`focused row vanished without setRows: ${focusedId}`);
+		for (let i = start + delta; i >= 0 && i < this.#rows.length; i += delta) {
+			const candidate = this.#rows[i];
+			if (candidate !== undefined && isFocusable(candidate)) {
+				this.#focusedId = candidate.id;
+				return { kind: "nav" };
+			}
+		}
+		return { kind: "nav" };
+	}
+
+	private focusEdge(edge: "first" | "last"): KeyRouting {
+		const ordered = edge === "first" ? this.#rows : this.#rows.slice().reverse();
+		const target = ordered.find(isFocusable);
+		if (target !== undefined) {
+			this.#focusedId = target.id;
+			this.#position = "main";
+		}
+		return { kind: "nav" };
+	}
+
+	// The vanished-focus fallback: scan the OLD display order downward from where the
+	// focused row was, then upward, taking the first row that is present AND focusable
+	// in the NEW list; if the old position gives no anchor, take the new list's first
+	// focusable row; an all-unfocusable or empty column focuses nothing.
+	private fallbackFrom(oldRows: NavRow[]): string | null {
+		const oldIndex = oldRows.findIndex((r) => r.id === this.#focusedId);
+		if (oldIndex !== -1) {
+			const survives = (candidate: NavRow | undefined): boolean => {
+				if (candidate === undefined) return false;
+				const now = this.rowById(candidate.id);
+				return now !== undefined && isFocusable(now);
+			};
+			for (let i = oldIndex + 1; i < oldRows.length; i++) {
+				if (survives(oldRows[i])) return oldRows[i]?.id ?? null;
+			}
+			for (let i = oldIndex - 1; i >= 0; i--) {
+				if (survives(oldRows[i])) return oldRows[i]?.id ?? null;
+			}
+		}
+		return this.#rows.find(isFocusable)?.id ?? null;
+	}
+}
diff --git a/packages/yukey/src/megacolumn.gallery.ts b/packages/yukey/src/megacolumn.gallery.ts
new file mode 100644
index 0000000..506631d
--- /dev/null
+++ b/packages/yukey/src/megacolumn.gallery.ts
@@ -0,0 +1,512 @@
+// Gallery entry for the megacolumn focus model: MegacolumnNav + MegacolumnController.
+// This demo element is the reference implementation for the primitives wave:
+// it shows the full DOM contract (data-mc-* attributes), megacolumn-key handling
+// for every row flavor the guidelines name, and the focus-ring rendering.
+// Copy its shape when building megacolumn screens.
+
+import { css, html, LitElement, type TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+import type { GalleryEntry } from "./gallery/entry.js";
+import {
+	MegacolumnController,
+	type MegacolumnKeyDetail,
+	megacolumnStyles,
+} from "./megacolumn-controller.js";
+import { flashPress } from "./press-flash.js";
+
+interface CargoItem {
+	id: string;
+	label: string;
+}
+
+interface TaskItem {
+	id: string;
+	label: string;
+}
+
+// The ship-systems rows are uniform plain activatable rows: id -> acknowledgment.
+// They exist mostly to give the column vertical reach, so the hotkey jumps have
+// somewhere worth jumping from and to.
+const systemActions = new Map<string, string>([
+	["survey", "survey sweep started"],
+	["vent", "heat sinks vented"],
+	["antenna", "antenna array aligned"],
+	["gyros", "gyros recalibrated"],
+]);
+
+// One demo megacolumn: activatable rows, a slider row, a two-control cargo list,
+// a text-entry row, a disabled row, and a live-churning task queue -- with section
+// gaps between them that focus jumps across.
+@customElement("swg-megacolumn-demo")
+export class SwgMegacolumnDemo extends LitElement {
+	static styles = [
+		megacolumnStyles,
+		css`
+			:host {
+				display: block;
+			}
+			[data-mc-container] {
+				border: 1px solid var(--sw-border-dim);
+				padding: var(--sw-space-md);
+			}
+			.title {
+				color: var(--sw-text-dim);
+				font-size: var(--sw-type-small);
+				letter-spacing: var(--sw-tracking-wide);
+				text-transform: uppercase;
+				margin-bottom: var(--sw-space-sm);
+			}
+			.section {
+				color: var(--sw-text-faint);
+				font-size: var(--sw-type-micro);
+				letter-spacing: var(--sw-tracking-wide);
+				text-transform: uppercase;
+				margin: var(--sw-space-xl) 0 var(--sw-space-xs);
+			}
+			.section:first-of-type {
+				margin-top: 0;
+			}
+			[data-mc-row] {
+				display: flex;
+				align-items: center;
+				gap: var(--sw-space-sm);
+				min-height: var(--sw-hit-min-h);
+				padding: 2px var(--sw-space-sm);
+			}
+			[data-mc-row].offline {
+				color: var(--sw-text-faint);
+			}
+			.grow {
+				flex: 1;
+			}
+			.count {
+				color: var(--sw-text-faint);
+			}
+			/* The hotkey-marking norm's gold letter, marked by hand on these plain
+			   rows (sw-button renders its own; plain rows have no widget to do it). */
+			.hk {
+				color: var(--sw-hotkey-accent);
+			}
+			.meter {
+				flex: 1;
+				/* the slider's whole visible affordance: at least the indicator
+				   token tall (the ant rule covers the visible part of a control) */
+				height: var(--sw-hit-indicator);
+				border: 1px solid var(--sw-border-dim);
+				position: relative;
+			}
+			.meter .fill {
+				position: absolute;
+				inset: 0 auto 0 0;
+				background: var(--sw-accent);
+			}
+			.pct {
+				color: var(--sw-text-hi);
+				width: 4ch;
+				text-align: right;
+			}
+			[data-mc-aux] {
+				color: var(--sw-danger);
+				border: 1px solid transparent;
+				min-width: var(--sw-hit-min-w);
+				min-height: var(--sw-hit-min-h);
+				display: inline-flex;
+				align-items: center;
+				justify-content: center;
+				box-sizing: border-box;
+			}
+			input[data-mc-input] {
+				flex: 1;
+				background: var(--sw-bg-well);
+				border: 1px solid var(--sw-border-dim);
+				color: var(--sw-text-bright);
+				font: inherit;
+				min-height: var(--sw-hit-min-h);
+				box-sizing: border-box;
+				padding: 0 var(--sw-space-sm);
+			}
+			.mc-text-mode input[data-mc-input] {
+				border-color: var(--sw-accent);
+			}
+			.log {
+				margin-top: var(--sw-space-md);
+				color: var(--sw-good);
+				font-size: var(--sw-type-ui);
+				min-height: 1.4em;
+			}
+		`,
+	];
+
+	// biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController
+	private mc = new MegacolumnController(this);
+
+	@property() label = "megacolumn";
+	// The churn demo only runs when asked: two identical always-churning columns
+	// side by side would be visual noise.
+	@property({ type: Boolean }) churn = false;
+
+	@state() private beacons = 0;
+	@state() private power = 40;
+	@state() private cargo: CargoItem[] = [
+		{ id: "crate-ore", label: "ore crate" },
+		{ id: "crate-struts", label: "strut bundle" },
+		{ id: "crate-cells", label: "fuel cells" },
+	];
+	@state() private tasks: TaskItem[] = [];
+	@state() private lastAction = "";
+	private taskSeq = 0;
+	private timer: number | undefined;
+
+	connectedCallback(): void {
+		super.connectedCallback();
+		if (this.churn) {
+			this.tasks = [this.newTask(), this.newTask(), this.newTask()];
+			this.timer = window.setInterval(() => {
+				this.tasks = [...this.tasks.slice(1), this.newTask()];
+			}, 3000);
+		}
+	}
+
+	disconnectedCallback(): void {
+		super.disconnectedCallback();
+		window.clearInterval(this.timer);
+	}
+
+	private newTask(): TaskItem {
+		this.taskSeq += 1;
+		const chores = ["survey field", "haul ore", "refit dock", "scan anomaly", "plot course"];
+		return {
+			id: `task-${this.taskSeq}`,
+			label: `${chores[this.taskSeq % chores.length]} #${this.taskSeq}`,
+		};
+	}
+
+	// All forwarded keys arrive here (one listener on the container); the rowId
+	// names the row, the position says main vs aux.  preventDefault = handled.
+	private onMegacolumnKey(e: Event): void {
+		const { detail } = e as CustomEvent<MegacolumnKeyDetail>;
+		const activate = detail.key === "Enter" || detail.key === " ";
+		// Plain rows have no widget to flash for them: the host acknowledges every
+		// action it takes -- removals included (playtest verdict: a disappearance
+		// alone is too quiet to confirm the keystroke landed).
+		// The forwarded event's target IS the row's control element (its main, or
+		// the row itself when it has no main) -- inside the row the ring outlines,
+		// so acknowledgment geometry sits within focus geometry.
+		const flash = (): void => {
+			if (e.target instanceof HTMLElement) flashPress(e.target);
+		};
+
+		if (detail.rowId === "beacon" && activate) {
+			this.beacons += 1;
+			this.lastAction = `beacon cycled (${this.beacons})`;
+			e.preventDefault();
+			flash();
+			return;
+		}
+		if (detail.rowId === "dock" && activate) {
+			this.lastAction = "dock requested";
+			e.preventDefault();
+			flash();
+			return;
+		}
+		if (detail.rowId === "power") {
+			// A slider row: no aux control, so left/right forward here.
+			if (detail.key === "ArrowLeft" || detail.key === "ArrowRight") {
+				const delta = detail.key === "ArrowRight" ? 5 : -5;
+				this.power = Math.min(100, Math.max(0, this.power + delta));
+				this.lastAction = `reactor power ${this.power}%`;
+				e.preventDefault();
+			}
+			return;
+		}
+		const cargoItem = this.cargo.find((c) => c.id === detail.rowId);
+		if (cargoItem !== undefined) {
+			// The two-control norm: x/Delete remove from anywhere in the row (the
+			// hotkey guess the guidelines reward); Enter/Space only remove from the
+			// aux control -- on main they inspect (activation must not be destructive).
+			const remove =
+				detail.key === "x" || detail.key === "Delete" || (activate && detail.position === "aux");
+			if (remove) {
+				this.cargo = this.cargo.filter((c) => c.id !== cargoItem.id);
+				this.lastAction = `${cargoItem.label} jettisoned`;
+				e.preventDefault();
+				// Removals flash too: the disappearance alone is too quiet to confirm
+				// the keystroke landed.  On the ROW element -- these positionally-keyed
+				// rows persist while the list shifts up under them, so the flash marks
+				// the removal site (the control inside re-renders with the next crate).
+				const rowEl = e.target instanceof Element ? e.target.closest("[data-mc-row]") : null;
+				if (rowEl instanceof HTMLElement) flashPress(rowEl);
+			} else if (activate) {
+				this.lastAction = `${cargoItem.label} inspected`;
+				e.preventDefault();
+				flash();
+			}
+			return;
+		}
+		const sys = systemActions.get(detail.rowId);
+		if (sys !== undefined && activate) {
+			this.lastAction = sys;
+			e.preventDefault();
+			flash();
+			return;
+		}
+		const task = this.tasks.find((t) => t.id === detail.rowId);
+		if (task !== undefined && activate) {
+			this.lastAction = `${task.label} inspected`;
+			e.preventDefault();
+			flash();
+		}
+	}
+
+	// Send on Enter-keydown, deliberately not @change: both text-mode exits blur
+	// the input and change fires on blur-with-changed-value, so a change-driven
+	// send would also fire on Escape (and on any click-away).  Enter is the send.
+	private onQueryKeydown(e: KeyboardEvent): void {
+		if (e.key !== "Enter") return;
+		const input = e.currentTarget as HTMLInputElement;
+		if (input.value !== "") this.lastAction = `hail sent: "${input.value}"`;
+	}
+
+	render(): TemplateResult {
+		return html`
+			<div class="title">${this.label}</div>
+			<div
+				data-mc-container
+				aria-label=${this.label}
+				@megacolumn-key=${this.onMegacolumnKey}
+			>
+				<div class="section">actions</div>
+				<div data-mc-row="beacon" data-mc-hotkey="b" data-info="cycles the nav beacon (Enter/Space; b jumps here)">
+					<span class="grow">cycle <span class="hk">b</span>eacon</span>
+					<span class="count">${this.beacons === 0 ? "" : `×${this.beacons}`}</span>
+				</div>
+				<div data-mc-row="dock" data-mc-hotkey="d" data-info="asks the nearest station for a berth">
+					<span class="grow">request <span class="hk">d</span>ocking</span>
+				</div>
+				<div data-mc-row="power" data-mc-hotkey="r" data-info="reactor output; left/right arrows adjust in 5% steps">
+					<span><span class="hk">r</span>eactor</span>
+					<span class="meter"><span class="fill" style="width:${this.power}%"></span></span>
+					<span class="pct">${this.power}%</span>
+				</div>
+
+				<div class="section">cargo manifest</div>
+				${this.cargo.map(
+					// Cargo rows carry no hotkey markings on purpose: they churn, and a
+					// letter that vanishes with its row is a broken affordance.  Letters
+					// mark landmarks; lists are for arrows.
+					(item) => html`
+						<div data-mc-row=${item.id} data-info="a crate in the hold; Enter inspects it">
+							<span class="grow" data-mc-main>${item.label}</span>
+							<span data-mc-aux title="jettison" data-info="jettisons this crate (x or Delete from anywhere in the row)">×</span>
+						</div>
+					`,
+				)}
+
+				<div class="section">comms</div>
+				<div data-mc-row="hail" data-mc-hotkey="h">
+					<span><span class="hk">h</span>ail</span>
+					<input data-mc-input type="text" placeholder="enter to type…" @keydown=${this.onQueryKeydown} />
+				</div>
+				<div data-mc-row="encrypted" data-mc-hotkey="e" class="offline" aria-disabled="true" data-info="offline: needs a cipher core the station cannot fabricate yet">
+					<span class="grow"><span class="hk">e</span>ncrypted channel (offline)</span>
+				</div>
+
+				<div class="section">ship systems</div>
+				<div data-mc-row="survey" data-mc-hotkey="s" data-info="runs a full survey sweep of the sector">
+					<span class="grow">run <span class="hk">s</span>urvey sweep</span>
+				</div>
+				<div data-mc-row="vent" data-mc-hotkey="v" data-info="vents the heat sinks to space">
+					<span class="grow"><span class="hk">v</span>ent heat sinks</span>
+				</div>
+				<div data-mc-row="antenna" data-mc-hotkey="a" data-info="re-aims the antenna array at the relay">
+					<span class="grow"><span class="hk">a</span>lign antenna array</span>
+				</div>
+				<div data-mc-row="gyros" data-mc-hotkey="g" data-info="recalibrates the attitude gyros">
+					<span class="grow">recalibrate <span class="hk">g</span>yros</span>
+				</div>
+
+				${
+					this.churn
+						? html`
+							<div class="section">task queue (live)</div>
+							${this.tasks.map(
+								(t) => html`<div data-mc-row=${t.id}><span class="grow">${t.label}</span></div>`,
+							)}
+						`
+						: ""
+				}
+			</div>
+			<div class="log">${this.lastAction}</div>
+		`;
+	}
+}
+
+// The relay bank: a static column tall enough to overflow a bounded scrollbox.
+// It exists to test scroll containment calmly -- the controller scrolls the focused
+// row into view on every focus-identity change (block: nearest), and when the
+// column lives inside its own overflow-y scroller, that scroll must happen in the
+// box, not the page.  The live task-queue demo is the only other tall column, and
+// its churn makes scroll behavior impossible to observe; these rows never move.
+const RELAY_COUNT = 28;
+const relayIds = Array.from({ length: RELAY_COUNT }, (_, i) => String(i + 1).padStart(2, "0"));
+// Markings at strategic distances (first, ~third, ~two-thirds, last), so one
+// keystroke can jump across most of the scroller.  Digits, because the flat
+// "relay NN" labels contain no distinguishing letters to mark.
+const relayHotkeys = new Map<string, string>([
+	["01", "1"],
+	["10", "0"],
+	["20", "2"],
+	["28", "8"],
+]);
+const relayInfo = new Map<string, string>([
+	["01", "the bank's first relay; Home lands here from anywhere"],
+	["10", "a mid-bank relay; the gold 0 jumps here"],
+	["14", "an unmarked relay: arrows or a nearby jump reach it"],
+	["20", "a deep-bank relay; the gold 2 jumps here"],
+	["28", "the last relay; End lands here from anywhere"],
+]);
+
+// A relay's label with its marking character rendered gold in place (the
+// hotkey-marking norm, hand-marked as on the plain rows above).
+function relayLabel(nn: string): TemplateResult {
+	const label = `relay ${nn}`;
+	const hk = relayHotkeys.get(nn);
+	if (hk === undefined) return html`${label}`;
+	const at = label.indexOf(hk);
+	return html`${label.slice(0, at)}<span class="hk">${hk}</span>${label.slice(at + 1)}`;
+}
+
+@customElement("swg-megacolumn-scroll-demo")
+export class SwgMegacolumnScrollDemo extends LitElement {
+	static styles = [
+		megacolumnStyles,
+		css`
+			:host {
+				display: block;
+			}
+			.title {
+				color: var(--sw-text-dim);
+				font-size: var(--sw-type-small);
+				letter-spacing: var(--sw-tracking-wide);
+				text-transform: uppercase;
+				margin-bottom: var(--sw-space-sm);
+			}
+			/* The bounded scrollport under test: the container IS the scroller,
+			   so the focused element's own box answers native scrolling keys too.
+			   ~7 rows visible of 28; the border is deliberately the full-strength
+			   token so scroll containment is visually obvious (rows must move
+			   inside a box that does not). */
+			[data-mc-container] {
+				height: 240px;
+				overflow-y: auto;
+				border: 1px solid var(--sw-border);
+			}
+			[data-mc-row] {
+				display: flex;
+				align-items: center;
+				min-height: var(--sw-hit-min-h);
+				padding: 2px var(--sw-space-sm);
+			}
+			.hk {
+				color: var(--sw-hotkey-accent);
+			}
+			.log {
+				margin-top: var(--sw-space-md);
+				color: var(--sw-good);
+				font-size: var(--sw-type-ui);
+				min-height: 1.4em;
+			}
+		`,
+	];
+
+	// biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController
+	private mc = new MegacolumnController(this);
+
+	@state() private lastAction = "";
+
+	private onMegacolumnKey(e: Event): void {
+		const { detail } = e as CustomEvent<MegacolumnKeyDetail>;
+		if (detail.key !== "Enter" && detail.key !== " ") return;
+		this.lastAction = `${detail.rowId.replace("-", " ")} pinged`;
+		e.preventDefault();
+		if (e.target instanceof HTMLElement) flashPress(e.target);
+	}
+
+	render(): TemplateResult {
+		return html`
+			<div class="title">relay bank</div>
+			<div
+				data-mc-container
+				aria-label="relay bank"
+				@megacolumn-key=${this.onMegacolumnKey}
+			>
+				${relayIds.map(
+					(nn) => html`
+						<div
+							data-mc-row=${`relay-${nn}`}
+							data-mc-hotkey=${ifDefined(relayHotkeys.get(nn))}
+							data-info=${ifDefined(relayInfo.get(nn))}
+						>
+							<span>${relayLabel(nn)}</span>
+						</div>
+					`,
+				)}
+			</div>
+			<div class="log">${this.lastAction}</div>
+		`;
+	}
+}
+
+const entry: GalleryEntry = {
+	id: "megacolumn",
+	title: "megacolumn focus model",
+	description:
+		"The keyboard navigation machinery every megacolumn screen builds on: " +
+		"one real focusable per column, row focus as logical state (MegacolumnNav), " +
+		"rendered via aria-activedescendant and the focus-ring tokens (MegacolumnController).  " +
+		"This demo is the reference implementation of the data-mc-* DOM contract.",
+	variants: [
+		{
+			caption:
+				"Two independent columns: Tab moves between them (each column is ONE tab stop); " +
+				"up/down move the row focus inside, jumping the section gaps.  " +
+				"The gold letters are hotkey jumps: b/d/r/h/e/s/v/a/g move focus straight to their row " +
+				"(from anywhere in the column), then Enter/Space commits -- try g from the top, or b from the bottom.  " +
+				"Also try: Enter/Space on the action rows; left/right on the reactor slider; " +
+				"right-arrow onto a crate's × then Enter (or just x/Delete from anywhere in the row -- " +
+				"crates churn, so they get no letters: letters mark landmarks, arrows work lists); " +
+				"Enter on the hail row to type -- every key goes to the input, hotkeys included -- then Enter sends, Escape abandons; " +
+				"Home/End for the column edges.  Tab away and back: the row focus is where you left it.  " +
+				"Rows carry data-info, so i (or a lingering hover) explains any of them -- " +
+				"including the offline row, which stays a nav stop and a jump target (e lands there; " +
+				"i is how you ask why it's off) even though activation is dead there.",
+			wide: true,
+			render: () => html`
+				<div style="display:flex; gap:24px; align-items:flex-start;">
+					<swg-megacolumn-demo style="flex:1" label="port console"></swg-megacolumn-demo>
+					<swg-megacolumn-demo style="flex:1" label="starboard console"></swg-megacolumn-demo>
+				</div>
+			`,
+		},
+		{
+			caption:
+				"Focus memory under churn: the task queue loses its head and grows a tail every few seconds.  " +
+				"Focus a task row and watch focus fall to the nearest surviving neighbor when the row vanishes; " +
+				"the rest of the column is unbothered.",
+			render: () => html`<swg-megacolumn-demo label="ops console" churn></swg-megacolumn-demo>`,
+		},
+		{
+			caption:
+				"Scroll containment: 28 static relays in a 240px box -- the thing to WATCH is that the page never scrolls, only the box.  " +
+				"Home/End slam between the edges; arrows creep across the boundary rows and the box gives way one row at a time " +
+				"(nearest-edge scrolling: no centering, no glide); " +
+				"the gold digits jump -- 1/0/2/8 land on relays 01/10/20/28, crossing most of the scroller in one keystroke, " +
+				"the ring arriving in view every time.  " +
+				"Enter pings a relay; a few rows carry data-info, so i works mid-scroll.",
+			render: () => html`<swg-megacolumn-scroll-demo></swg-megacolumn-scroll-demo>`,
+		},
+	],
+};
+
+export default entry;
diff --git a/packages/yukey/src/press-flash.test.ts b/packages/yukey/src/press-flash.test.ts
new file mode 100644
index 0000000..14d9e94
--- /dev/null
+++ b/packages/yukey/src/press-flash.test.ts
@@ -0,0 +1,49 @@
+// @vitest-environment jsdom
+//
+// Tests for the shared activation-flash helper (press-flash.ts).  The full
+// consumer behavior (pointer-held state, disabled suppression, re-arm) is
+// exercised through sw-button in sw-button.test.ts; this file pins the helper's
+// own contract: cascade-resolved duration and the fail-closed resolution error.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { flashPress, pressMs } from "./press-flash.js";
+import { installTokens, tokenGroups } from "./tokens.js";
+
+installTokens();
+
+const PRESS_MS = ((): number => {
+	const t = tokenGroups.flatMap((g) => g.tokens).find((t) => t.name === "--sw-press-ms");
+	if (t === undefined) throw new Error("--sw-press-ms token missing");
+	return Number.parseFloat(t.value);
+})();
+
+describe("press-flash", () => {
+	beforeEach(() => {
+		vi.useFakeTimers();
+	});
+	afterEach(() => {
+		vi.useRealTimers();
+		document.body.replaceChildren();
+	});
+
+	it("flashes any element for the token's duration", () => {
+		const el = document.createElement("div");
+		document.body.append(el);
+		flashPress(el);
+		expect(el.classList.contains("sw-pressed")).toBe(true);
+		vi.advanceTimersByTime(PRESS_MS);
+		expect(el.classList.contains("sw-pressed")).toBe(false);
+	});
+
+	it("resolves seconds-denominated overrides too", () => {
+		const el = document.createElement("div");
+		el.style.setProperty("--sw-press-ms", "0.3s");
+		document.body.append(el);
+		expect(pressMs(el)).toBe(300);
+	});
+
+	it("fails loudly when the duration cannot resolve", () => {
+		const detached = document.createElement("div"); // no cascade to read from
+		expect(() => pressMs(detached)).toThrow(/--sw-press-ms did not resolve/);
+	});
+});
diff --git a/packages/yukey/src/press-flash.ts b/packages/yukey/src/press-flash.ts
new file mode 100644
index 0000000..08e1fd8
--- /dev/null
+++ b/packages/yukey/src/press-flash.ts
@@ -0,0 +1,47 @@
+// Activation feedback, shared across widgets: the "sw-pressed" flash class that
+// acknowledges a successful activation for --sw-press-ms, so keyboard triggers
+// (Enter/Space, hotkeys) are as visible as a mouse press.  The duration is one
+// system-wide design token, read from the live cascade at flash time -- so an
+// accessibility mode can slow every flash by restyling one custom property.
+//
+// Consumers and their class carriers:
+//   - sw-button flashes its host element (and manages its own pointer-held
+//     "sw-held" class beside it); its styles render both.
+//   - Megacolumn hosts with plain activatable rows call flashPress(rowElement)
+//     when they act on a forwarded activation; the row-flavored .sw-pressed
+//     rendering lives in megacolumnStyles, beside the focus washes it must outrank.
+
+export const PRESSED_CLASS = "sw-pressed";
+
+// The flash duration, resolved from the cascade at el.  Throws when the property
+// does not resolve (tokens not installed, or a detached element): a feedback
+// system that silently picks its own duration would hide exactly the
+// misconfiguration it exists to surface.
+export function pressMs(el: Element): number {
+	const raw = getComputedStyle(el).getPropertyValue("--sw-press-ms").trim();
+	const m = /^(\d+(?:\.\d+)?)(m?s)$/.exec(raw);
+	if (m === null)
+		throw new Error(
+			`press-flash: --sw-press-ms did not resolve on <${el.tagName.toLowerCase()}> ` +
+				`(got "${raw}"); are the design tokens installed?`,
+		);
+	const n = Number(m[1]);
+	return m[2] === "s" ? n * 1000 : n;
+}
+
+const timers = new WeakMap<HTMLElement, number>();
+
+// Show the pressed look on el for --sw-press-ms.  A re-flash while one is still
+// showing re-arms the timer, so rapid activations read as sustained activity
+// instead of getting cut short by the first flash's expiry.
+export function flashPress(el: HTMLElement): void {
+	el.classList.add(PRESSED_CLASS);
+	window.clearTimeout(timers.get(el));
+	timers.set(
+		el,
+		window.setTimeout(() => {
+			el.classList.remove(PRESSED_CLASS);
+			timers.delete(el);
+		}, pressMs(el)),
+	);
+}
diff --git a/packages/yukey/src/sw-button.gallery.ts b/packages/yukey/src/sw-button.gallery.ts
new file mode 100644
index 0000000..d03fd7a
--- /dev/null
+++ b/packages/yukey/src/sw-button.gallery.ts
@@ -0,0 +1,277 @@
+// Gallery entry for sw-button: every marking form, both contexts, the disabled
+// and info behaviors, and a two-column demo proving that hotkey scope is the
+// focused column.
+
+import { css, html, LitElement, type TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import type { GalleryEntry } from "./gallery/entry.js";
+import {
+	MegacolumnController,
+	type MegacolumnKeyDetail,
+	megacolumnStyles,
+} from "./megacolumn-controller.js";
+import "./sw-button.js";
+import { flashPress } from "./press-flash.js";
+import type { SwInfoDetail } from "./sw-info.js";
+
+// Shared demo chrome: a title, a log line that shows what fired.
+const demoStyles = css`
+	:host {
+		display: block;
+	}
+	.title {
+		color: var(--sw-text-dim);
+		font-size: var(--sw-type-small);
+		letter-spacing: var(--sw-tracking-wide);
+		text-transform: uppercase;
+		margin-bottom: var(--sw-space-sm);
+	}
+	.log {
+		margin-top: var(--sw-space-md);
+		color: var(--sw-good);
+		font-size: var(--sw-type-ui);
+		min-height: 1.4em;
+	}
+	.log.info {
+		color: var(--sw-hotkey-accent);
+	}
+`;
+
+// Standalone buttons: native tab stops, all marking forms side by side.
+@customElement("swg-button-standalone")
+export class SwgButtonStandalone extends LitElement {
+	static styles = [
+		demoStyles,
+		css`
+			.row {
+				display: flex;
+				gap: var(--sw-space-lg);
+				align-items: center;
+				flex-wrap: wrap;
+			}
+		`,
+	];
+
+	@state() private last = "";
+	@state() private lastWasInfo = false;
+
+	private act(what: string): void {
+		this.last = what;
+		this.lastWasInfo = false;
+	}
+
+	private onInfo(e: Event): void {
+		const { content } = (e as CustomEvent<SwInfoDetail>).detail;
+		this.last = `info: ${content}`;
+		this.lastWasInfo = true;
+	}
+
+	render(): TemplateResult {
+		return html`
+			<div class="row" @sw-info=${this.onInfo}>
+				<sw-button
+					label="Dock at station"
+					hotkey="d"
+					info="requests docking clearance at the selected station"
+					@click=${() => this.act("docked")}
+				></sw-button>
+				<sw-button
+					label="units"
+					hotkey="7"
+					info="opens the units registry"
+					@click=${() => this.act("units opened")}
+				></sw-button>
+				<sw-button
+					label="transfer"
+					hotkey="t"
+					icon="☰"
+					info="complex: several transfer modes live behind this one"
+					@click=${() => this.act("transfer menu")}
+				></sw-button>
+				<sw-button
+					label="cruise"
+					hotkey="c"
+					info="the in-effect option: active but still clickable (re-issuing is the owner's call)"
+					active
+					@click=${() => this.act("cruise re-issued (harmless)")}
+				></sw-button>
+				<sw-button
+					label="self destruct"
+					hotkey="s"
+					info="offline: requires command authorization"
+					disabled
+					@click=${() => this.act("BOOM (should never happen)")}
+				></sw-button>
+			</div>
+			<div class="log ${this.lastWasInfo ? "info" : ""}">${this.last}</div>
+		`;
+	}
+}
+
+// A megacolumn of sw-buttons: rows are the buttons, plus a two-control row
+// composing an sw-button into the aux (small-x) slot.
+@customElement("swg-button-column")
+export class SwgButtonColumn extends LitElement {
+	static styles = [
+		megacolumnStyles,
+		demoStyles,
+		css`
+			[data-mc-container] {
+				border: 1px solid var(--sw-border-dim);
+				padding: var(--sw-space-md);
+			}
+			[data-mc-row] {
+				display: flex;
+				align-items: center;
+				gap: var(--sw-space-sm);
+				min-height: var(--sw-hit-min-h);
+				padding: 2px var(--sw-space-sm);
+			}
+			[data-mc-row] sw-button:first-child,
+			[data-mc-row] > .grow {
+				flex: 1;
+			}
+		`,
+	];
+
+	// biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController
+	private mc = new MegacolumnController(this);
+
+	@property() label = "console";
+	// Hotkeys are per-instance so the scoping demo can give two columns
+	// overlapping letters without tripping the per-column duplicate throw.
+	@property() dockKey = "d";
+	@property() beaconKey = "b";
+
+	@state() private cargo = ["ore crate", "strut bundle"];
+	@state() private last = "";
+	@state() private lastWasInfo = false;
+
+	private act(what: string): void {
+		this.last = what;
+		this.lastWasInfo = false;
+	}
+
+	private onInfo(e: Event): void {
+		const { content } = (e as CustomEvent<SwInfoDetail>).detail;
+		this.last = `info: ${content}`;
+		this.lastWasInfo = true;
+	}
+
+	private onMegacolumnKey(e: Event): void {
+		// The buttons claim their own keys; this listener only backs the rows whose
+		// MAIN control is plain text (the cargo rows: Enter inspects).  Acting on a
+		// plain row includes acknowledging it: the flash a widget would give itself.
+		const { key, rowId, position } = (e as CustomEvent<MegacolumnKeyDetail>).detail;
+		const item = this.cargo.find((c) => c === rowId);
+		if (item !== undefined && (key === "Enter" || key === " ") && position === "main") {
+			this.act(`${item} inspected`);
+			e.preventDefault();
+			const row = (e.target as Element).closest<HTMLElement>("[data-mc-row]");
+			if (row !== null) flashPress(row);
+		}
+	}
+
+	render(): TemplateResult {
+		return html`
+			<div class="title">${this.label}</div>
+			<div
+				data-mc-container
+				aria-label=${this.label}
+				@megacolumn-key=${this.onMegacolumnKey}
+				@sw-info=${this.onInfo}
+			>
+				<div data-mc-row="dock">
+					<sw-button
+						label="Dock at station"
+						hotkey=${this.dockKey}
+						info="requests docking clearance"
+						@click=${() => this.act("docked")}
+					></sw-button>
+				</div>
+				<div data-mc-row="beacon">
+					<sw-button
+						label="cycle beacon"
+						hotkey=${this.beaconKey}
+						info="cycles the nav beacon"
+						@click=${() => this.act("beacon cycled")}
+					></sw-button>
+				</div>
+				<div data-mc-row="purge">
+					<sw-button
+						label="purge filters"
+						info="offline: filters already clean"
+						disabled
+						@click=${() => this.act("purged (should never happen)")}
+					></sw-button>
+				</div>
+				${this.cargo.map(
+					(item) => html`
+						<div data-mc-row=${item}>
+							<span class="grow" data-mc-main>${item}</span>
+							<span data-mc-aux>
+								<sw-button
+									label="×"
+									info="jettisons this crate (x or Delete from anywhere in the row)"
+									@click=${() => {
+										this.cargo = this.cargo.filter((c) => c !== item);
+										this.act(`${item} jettisoned`);
+									}}
+								></sw-button>
+							</span>
+						</div>
+					`,
+				)}
+			</div>
+			<div class="log ${this.lastWasInfo ? "info" : ""}">${this.last}</div>
+		`;
+	}
+}
+
+const entry: GalleryEntry = {
+	id: "sw-button",
+	title: "sw-button",
+	description:
+		"The button that makes the keyboard norms structural: hotkey marking " +
+		"(letter-in-label in --sw-hotkey-accent, else the 70%-offset slot, an icon for " +
+		"complex buttons), the i info hook (sw-info events, rendered by the shell's " +
+		"sw-infopop -- press i on anything here and watch), token hit targets, and " +
+		"dual-context behavior -- a native focusable standalone, a megacolumn-key " +
+		"consumer inside rows.",
+	variants: [
+		{
+			caption:
+				"Standalone: real tab stops with native Enter/Space.  All marking forms: " +
+				"letter-in-label (Dock's D), the 70% slot (units' 7), the complex-button icon " +
+				"(transfer), the active in-effect option (cruise: washed, aria-current, still " +
+				"clickable), and disabled (dimmed, unclickable, but i still answers while focused).  " +
+				"A button's hotkey and i work while it holds focus; watch the log.",
+			render: () => html`<swg-button-standalone></swg-button-standalone>`,
+		},
+		{
+			caption:
+				"Inside a megacolumn: one tab stop for the whole column, buttons as rows.  " +
+				"The marked letters are jumps: d or b from any row moves focus to that button " +
+				"(no arrowing needed), and Enter/Space then activates -- you see where you are " +
+				"before committing.  Try: d, then Enter; i for info on the focused control; " +
+				"on a crate row, x or Delete jettisons via the aux ×, or right-arrow onto it and Enter.  " +
+				"The disabled row still focuses and answers i, but nothing activates it.",
+			render: () => html`<swg-button-column label="ops console"></swg-button-column>`,
+		},
+		{
+			caption:
+				"Hotkey scope IS the focused column: both columns mark d for dock, and d jumps only " +
+				"in the column that holds focus.  Tab between them and try d then Enter in each; " +
+				"the other column never hears it.",
+			wide: true,
+			render: () => html`
+				<div style="display:flex; gap:24px; align-items:flex-start;">
+					<swg-button-column style="flex:1" label="port console" dockKey="d" beaconKey="b"></swg-button-column>
+					<swg-button-column style="flex:1" label="starboard console" dockKey="d" beaconKey="c"></swg-button-column>
+				</div>
+			`,
+		},
+	],
+};
+
+export default entry;
diff --git a/packages/yukey/src/sw-button.test.ts b/packages/yukey/src/sw-button.test.ts
new file mode 100644
index 0000000..241509d
--- /dev/null
+++ b/packages/yukey/src/sw-button.test.ts
@@ -0,0 +1,731 @@
+// @vitest-environment jsdom
+//
+// Tests for sw-button (sw-button.ts): the marking logic, standalone activation,
+// and row-mode claims driven through a real MegacolumnController rig -- the same
+// fake-host pattern as megacolumn-controller.test.ts, but with live Lit rendering
+// (sw-button's marking must be visible to the controller's scan synchronously).
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { MegacolumnController } from "./megacolumn-controller.js";
+import type { SwButton } from "./sw-button.js";
+import "./sw-button.js";
+import type { SwInfoDetail } from "./sw-info.js";
+import { installTokens, tokenGroups } from "./tokens.js";
+
+// jsdom implements no scrollIntoView; the controller scrolls the focused row
+// into view on every focus identity change.  A no-op stands in.
+Element.prototype.scrollIntoView = (): void => {};
+
+// The activation-feedback tests read the press duration from the cascade, the
+// same way the entry points provide it: tokens installed at :root.
+installTokens();
+
+// The token's defined default, parsed here rather than imported from
+// press-flash.ts: the tests must not derive expectations from the code under test.
+const PRESS_MS = ((): number => {
+	const t = tokenGroups.flatMap((g) => g.tokens).find((t) => t.name === "--sw-press-ms");
+	if (t === undefined) throw new Error("--sw-press-ms token missing");
+	return Number.parseFloat(t.value);
+})();
+
+function makeButton(attrs: Partial<SwButton>): SwButton {
+	const el = document.createElement("sw-button");
+	Object.assign(el, attrs);
+	return el;
+}
+
+async function standalone(attrs: Partial<SwButton>): Promise<SwButton> {
+	const el = makeButton(attrs);
+	document.body.replaceChildren(el);
+	await el.updateComplete;
+	return el;
+}
+
+function inner(el: SwButton): HTMLElement {
+	const btn = el.shadowRoot?.querySelector<HTMLElement>(".btn");
+	if (!btn) throw new Error("sw-button rendered no .btn");
+	return btn;
+}
+
+describe("hotkey marking", () => {
+	it("marks the first case-insensitive occurrence of the hotkey in the label", async () => {
+		const el = await standalone({ label: "Dock at station", hotkey: "d" });
+		const hk = inner(el).querySelector(".hk");
+		expect(hk?.textContent).toBe("D"); // the label's own case is preserved
+		expect(inner(el).textContent).toBe("Dock at station");
+		expect(inner(el).querySelector(".slot")).toBeNull();
+	});
+
+	it("marks only the first occurrence when the letter repeats", async () => {
+		const el = await standalone({ label: "add radar", hotkey: "a" });
+		expect(inner(el).querySelectorAll(".hk").length).toBe(1);
+		expect(inner(el).textContent).toBe("add radar");
+	});
+
+	it("prefers letter-in-label over the slot when the letter occurs", async () => {
+		// The tokens-stage mockup showed "units" with a slotted 'u' -- but 'u' occurs
+		// in "units", so per the norm this is a letter-in-label case, not a slot case.
+		const el = await standalone({ label: "units", hotkey: "u" });
+		expect(inner(el).querySelector(".hk")?.textContent).toBe("u");
+		expect(inner(el).querySelector(".slot")).toBeNull();
+	});
+
+	it("falls back to the slot for a truly absent letter", async () => {
+		const el = await standalone({ label: "engage", hotkey: "7" });
+		expect(inner(el).querySelector(".hk")).toBeNull();
+		expect(inner(el).querySelector(".slot")?.textContent).toBe("7");
+	});
+
+	it("an icon claims the slot and suppresses letter-in-label marking", async () => {
+		const el = await standalone({ label: "dock", hotkey: "d", icon: "☰" });
+		expect(inner(el).querySelector(".hk")).toBeNull();
+		expect(inner(el).querySelector(".slot")?.textContent).toBe("☰");
+	});
+
+	it("no hotkey, no marking, no slot", async () => {
+		const el = await standalone({ label: "plain" });
+		expect(inner(el).querySelector(".hk")).toBeNull();
+		expect(inner(el).querySelector(".slot")).toBeNull();
+	});
+
+	it("mirrors info onto data-info synchronously (the hover-trigger carrier contract)", () => {
+		// sw-infopop's hover trigger finds carriers by [data-info]; the mirror makes
+		// sw-buttons carriers without the popup knowing their class.
+		const el = makeButton({ info: "docking clearance" });
+		expect(el.dataset.info).toBe("docking clearance"); // before any render or connect
+		el.info = "";
+		expect("info" in el.dataset).toBe(false);
+	});
+
+	it("mirrors the hotkey onto data-mc-hotkey synchronously, normalized", () => {
+		const el = makeButton({ hotkey: "D" });
+		expect(el.dataset.mcHotkey).toBe("d"); // before any render or connect
+		el.hotkey = "";
+		expect("mcHotkey" in el.dataset).toBe(false);
+	});
+
+	it("mirrors hotkeyDirect onto data-mc-hotkey-direct synchronously", () => {
+		const el = makeButton({ hotkey: "d", hotkeyDirect: true });
+		expect("mcHotkeyDirect" in el.dataset).toBe(true); // before any render or connect
+		el.hotkeyDirect = false;
+		expect("mcHotkeyDirect" in el.dataset).toBe(false);
+	});
+
+	it("re-renders the marking when the hotkey changes after first render", async () => {
+		const el = await standalone({ label: "dock", hotkey: "d" });
+		expect(inner(el).querySelector(".hk")?.textContent).toBe("d");
+		el.hotkey = "o";
+		await el.updateComplete;
+		expect(inner(el).querySelector(".hk")?.textContent).toBe("o");
+	});
+
+	it("rejects malformed hotkeys", () => {
+		expect(() => makeButton({ hotkey: "dd" })).toThrow(/single \[a-z0-9\]/);
+		expect(() => makeButton({ hotkey: "!" })).toThrow(/single \[a-z0-9\]/);
+	});
+
+	it("x and i are legal markings (a guideline, not a rule: the row norms claim first)", () => {
+		expect(makeButton({ hotkey: "x" }).dataset.mcHotkey).toBe("x");
+		expect(makeButton({ hotkey: "I" }).dataset.mcHotkey).toBe("i");
+	});
+
+	it("sets aria-keyshortcuts when a hotkey is marked", async () => {
+		const el = await standalone({ label: "dock", hotkey: "d" });
+		expect(inner(el).getAttribute("aria-keyshortcuts")).toBe("d");
+	});
+});
+
+describe("standalone context", () => {
+	it("renders a real native button: a tab stop with native click activation", async () => {
+		const el = await standalone({ label: "go" });
+		const btn = inner(el);
+		expect(btn.tagName).toBe("BUTTON");
+		let clicks = 0;
+		el.addEventListener("click", () => {
+			clicks += 1;
+		});
+		btn.click(); // shadow retargeting presents this as the host's click
+		expect(clicks).toBe(1);
+	});
+
+	it("its hotkey activates while the button holds focus", async () => {
+		const el = await standalone({ label: "dock", hotkey: "d" });
+		let clicks = 0;
+		el.addEventListener("click", () => {
+			clicks += 1;
+		});
+		const e = new KeyboardEvent("keydown", {
+			key: "d",
+			bubbles: true,
+			composed: true,
+			cancelable: true,
+		});
+		inner(el).dispatchEvent(e);
+		expect(clicks).toBe(1);
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("modifier chords never trigger the hotkey", async () => {
+		const el = await standalone({ label: "dock", hotkey: "d" });
+		let clicks = 0;
+		el.addEventListener("click", () => {
+			clicks += 1;
+		});
+		inner(el).dispatchEvent(
+			new KeyboardEvent("keydown", {
+				key: "d",
+				ctrlKey: true,
+				bubbles: true,
+				composed: true,
+				cancelable: true,
+			}),
+		);
+		expect(clicks).toBe(0);
+	});
+
+	it("i dispatches sw-info with the content and the anchor element", async () => {
+		const el = await standalone({ label: "dock", info: "requests docking clearance" });
+		const got: SwInfoDetail[] = [];
+		document.body.addEventListener("sw-info", (e) => {
+			got.push((e as CustomEvent<SwInfoDetail>).detail);
+		});
+		inner(el).dispatchEvent(
+			new KeyboardEvent("keydown", { key: "i", bubbles: true, composed: true, cancelable: true }),
+		);
+		expect(got).toEqual([{ content: "requests docking clearance", source: el }]);
+	});
+
+	it("i without info does nothing", async () => {
+		const el = await standalone({ label: "dock" });
+		let infos = 0;
+		document.body.addEventListener("sw-info", () => {
+			infos += 1;
+		});
+		const e = new KeyboardEvent("keydown", {
+			key: "i",
+			bubbles: true,
+			composed: true,
+			cancelable: true,
+		});
+		inner(el).dispatchEvent(e);
+		expect(infos).toBe(0);
+		expect(e.defaultPrevented).toBe(false);
+	});
+});
+
+// Activation feedback: the held look under a pointer, and the flash that
+// acknowledges every successful activation for --sw-press-ms (press-flash.ts).
+// The classes land on the HOST element: jsdom computes custom properties on
+// light-DOM elements but not through shadow boundaries, and the host is also
+// where authors can observe the state from outside.
+describe("activation feedback", () => {
+	beforeEach(() => {
+		vi.useFakeTimers();
+	});
+	afterEach(() => {
+		vi.useRealTimers();
+	});
+
+	const pointer = (type: string, init: PointerEventInit = {}): PointerEvent =>
+		new PointerEvent(type, { bubbles: true, composed: true, button: 0, ...init });
+
+	it("flashes on activation and clears after --sw-press-ms", async () => {
+		const el = await standalone({ label: "go" });
+		el.click();
+		expect(el.classList.contains("sw-pressed")).toBe(true);
+		vi.advanceTimersByTime(PRESS_MS - 1);
+		expect(el.classList.contains("sw-pressed")).toBe(true);
+		vi.advanceTimersByTime(1);
+		expect(el.classList.contains("sw-pressed")).toBe(false);
+	});
+
+	it("re-arms the flash on rapid re-activation (no early cutoff)", async () => {
+		const el = await standalone({ label: "go" });
+		el.click();
+		vi.advanceTimersByTime(PRESS_MS / 2);
+		el.click(); // second activation, half way through the first flash
+		vi.advanceTimersByTime(PRESS_MS / 2); // the first flash's timer would have fired here
+		expect(el.classList.contains("sw-pressed")).toBe(true);
+		vi.advanceTimersByTime(PRESS_MS / 2);
+		expect(el.classList.contains("sw-pressed")).toBe(false);
+	});
+
+	it("reads the flash duration from the live cascade (the accessibility knob)", async () => {
+		const el = await standalone({ label: "go" });
+		el.style.setProperty("--sw-press-ms", "50ms");
+		el.click();
+		vi.advanceTimersByTime(49);
+		expect(el.classList.contains("sw-pressed")).toBe(true);
+		vi.advanceTimersByTime(1);
+		expect(el.classList.contains("sw-pressed")).toBe(false);
+	});
+
+	it("holds while the pointer is down, releases on pointerup", async () => {
+		const el = await standalone({ label: "go" });
+		inner(el).dispatchEvent(pointer("pointerdown"));
+		expect(el.classList.contains("sw-held")).toBe(true);
+		inner(el).dispatchEvent(pointer("pointerup"));
+		expect(el.classList.contains("sw-held")).toBe(false);
+	});
+
+	it("releases the hold when the pointer leaves or is cancelled", async () => {
+		const el = await standalone({ label: "go" });
+		inner(el).dispatchEvent(pointer("pointerdown"));
+		el.dispatchEvent(new PointerEvent("pointerleave")); // leave does not bubble; it fires on the host itself
+		expect(el.classList.contains("sw-held")).toBe(false);
+		inner(el).dispatchEvent(pointer("pointerdown"));
+		inner(el).dispatchEvent(pointer("pointercancel"));
+		expect(el.classList.contains("sw-held")).toBe(false);
+	});
+
+	it("only the primary button holds", async () => {
+		const el = await standalone({ label: "go" });
+		inner(el).dispatchEvent(pointer("pointerdown", { button: 2 }));
+		expect(el.classList.contains("sw-held")).toBe(false);
+	});
+
+	it("disabled: neither holds nor flashes (nothing happened; nothing must claim otherwise)", async () => {
+		const el = await standalone({ label: "go", disabled: true });
+		inner(el).dispatchEvent(pointer("pointerdown"));
+		expect(el.classList.contains("sw-held")).toBe(false);
+		inner(el).click(); // dies in the shadow-root suppressor
+		el.click(); // the direct host path the suppressor never sees
+		expect(el.classList.contains("sw-pressed")).toBe(false);
+	});
+});
+
+describe("active state", () => {
+	it("renders the active look and claims aria-current (still clickable)", async () => {
+		const el = await standalone({ label: "hold", active: true });
+		const btn = inner(el);
+		expect(btn.classList.contains("active")).toBe(true);
+		expect(btn.getAttribute("aria-current")).toBe("true");
+		let clicks = 0;
+		el.addEventListener("click", () => {
+			clicks += 1;
+		});
+		inner(el).click();
+		expect(clicks).toBe(1);
+	});
+
+	it("inactive: no active class, no aria-current claim", async () => {
+		const el = await standalone({ label: "hold" });
+		const btn = inner(el);
+		expect(btn.classList.contains("active")).toBe(false);
+		expect(btn.hasAttribute("aria-current")).toBe(false);
+	});
+
+	it("active is a live property: flips with the data", async () => {
+		const el = await standalone({ label: "hold", active: true });
+		el.active = false;
+		await el.updateComplete;
+		expect(inner(el).classList.contains("active")).toBe(false);
+		expect(inner(el).hasAttribute("aria-current")).toBe(false);
+	});
+});
+
+describe("disabled state", () => {
+	it("is aria-disabled and dimmed, never natively disabled (stays perceivable)", async () => {
+		const el = await standalone({ label: "go", disabled: true });
+		const btn = inner(el);
+		expect(btn.getAttribute("aria-disabled")).toBe("true");
+		expect(btn.hasAttribute("disabled")).toBe(false);
+		expect(btn.classList.contains("disabled")).toBe(true);
+	});
+
+	it("suppresses clicks before host listeners see them", async () => {
+		const el = await standalone({ label: "go", disabled: true });
+		let clicks = 0;
+		el.addEventListener("click", () => {
+			clicks += 1;
+		});
+		inner(el).click();
+		expect(clicks).toBe(0);
+	});
+
+	it("suppresses the hotkey but still answers i (disabled things stay discoverable)", async () => {
+		const el = await standalone({
+			label: "dock",
+			hotkey: "d",
+			info: "offline: no dock in range",
+			disabled: true,
+		});
+		let clicks = 0;
+		const infos: string[] = [];
+		el.addEventListener("click", () => {
+			clicks += 1;
+		});
+		document.body.addEventListener("sw-info", (e) => {
+			infos.push((e as CustomEvent<SwInfoDetail>).detail.content);
+		});
+		inner(el).dispatchEvent(
+			new KeyboardEvent("keydown", { key: "d", bubbles: true, composed: true, cancelable: true }),
+		);
+		inner(el).dispatchEvent(
+			new KeyboardEvent("keydown", { key: "i", bubbles: true, composed: true, cancelable: true }),
+		);
+		expect(clicks).toBe(0);
+		expect(infos).toEqual(["offline: no dock in range"]);
+	});
+});
+
+// Row mode, driven through a real controller: the same rig pattern as
+// megacolumn-controller.test.ts, except rows carry live sw-button elements.
+describe("inside a megacolumn row", () => {
+	interface Rig {
+		container: HTMLElement;
+		ctl: MegacolumnController;
+		key: (key: string) => KeyboardEvent;
+		button: (sel: string) => SwButton;
+	}
+
+	async function makeRig(innerHtml: string): Promise<Rig> {
+		const root = document.createElement("div");
+		document.body.replaceChildren(root);
+		root.innerHTML = `<div data-mc-container>${innerHtml}</div>`;
+		const container = root.querySelector<HTMLElement>("[data-mc-container]");
+		if (container === null) throw new Error("unreachable: just rendered");
+		// Let the sw-buttons complete their first render before the scan.
+		await Promise.all([...root.querySelectorAll("sw-button")].map((b) => b.updateComplete));
+		const ctl = new MegacolumnController({
+			renderRoot: root,
+			addController(): void {},
+			removeController(): void {},
+			requestUpdate(): void {},
+			updateComplete: Promise.resolve(true),
+		});
+		ctl.hostUpdated();
+		const key = (k: string): KeyboardEvent => {
+			const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true });
+			container.dispatchEvent(e);
+			return e;
+		};
+		const button = (sel: string): SwButton => {
+			const b = root.querySelector<SwButton>(sel);
+			if (b === null) throw new Error(`no sw-button at ${sel}`);
+			return b;
+		};
+		return { container, ctl, key, button };
+	}
+
+	it("renders non-focusable markup: the controller's tab-stop scan accepts it", async () => {
+		const { button } = await makeRig(`
+			<div data-mc-row="a"><sw-button label="alpha"></sw-button></div>
+		`);
+		expect(inner(button("sw-button")).tagName).toBe("DIV"); // no native button in row mode
+	});
+
+	it("throws when loose in a container outside any row", async () => {
+		// The throw happens in connectedCallback; custom-element reaction exceptions
+		// are reported to the window, not propagated to the DOM call that parsed the
+		// element in -- so observe the report.  Loud is the point: this misuse must
+		// not pass silently just because nothing upstream can catch it.
+		const reported: string[] = [];
+		const onError = (e: ErrorEvent): void => {
+			reported.push(e.message);
+			e.preventDefault();
+		};
+		window.addEventListener("error", onError);
+		const root = document.createElement("div");
+		document.body.replaceChildren(root);
+		root.innerHTML = `<div data-mc-container><sw-button label="stray"></sw-button></div>`;
+		window.removeEventListener("error", onError);
+		expect(reported.some((m) => /outside any row/.test(m))).toBe(true);
+	});
+
+	it("Enter and Space at its position activate as a click", async () => {
+		const { key, button } = await makeRig(`
+			<div data-mc-row="a"><sw-button label="alpha"></sw-button></div>
+		`);
+		let clicks = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		expect(key("Enter").defaultPrevented).toBe(true);
+		expect(key(" ").defaultPrevented).toBe(true);
+		expect(clicks).toBe(2);
+	});
+
+	it("a mouse click activates exactly once (the pointer-activation dedup)", async () => {
+		// The controller synthesizes a megacolumn-key Enter from unclaimed clicks on
+		// row controls; sw-button activates on its own native click and CLAIMS it
+		// (preventDefault, the same idiom as claiming a forwarded key), so the
+		// synthesis skips it and the author's @click fires once, not twice.
+		const { button } = await makeRig(`
+			<div data-mc-row="a"><sw-button label="alpha"></sw-button></div>
+		`);
+		let clicks = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		const e = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true });
+		inner(button("sw-button")).dispatchEvent(e);
+		expect(clicks).toBe(1);
+		expect(e.defaultPrevented).toBe(true); // claimed: the controller must not synthesize
+	});
+
+	it("standalone clicks stay unclaimed (the dedup mark is row-mode only)", async () => {
+		const el = await standalone({ label: "go" });
+		const e = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true });
+		inner(el).dispatchEvent(e);
+		expect(e.defaultPrevented).toBe(false); // no controller listening; native semantics stay native
+	});
+
+	it("keyboard Enter round-trips once: one click, one megacolumn-key (no synthesis echo)", async () => {
+		// The dedup's other direction: #claimActivation answers a forwarded Enter
+		// with this.click(), and THAT click bubbles past the controller too.  The
+		// row-mode claim marks it, so the controller must not synthesize a second
+		// Enter from the keyboard's own acknowledgment click.
+		const { container, button } = await makeRig(`
+			<div data-mc-row="a"><sw-button label="alpha"></sw-button></div>
+		`);
+		let clicks = 0;
+		let mcKeys = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		container.parentElement?.addEventListener("megacolumn-key", () => {
+			mcKeys += 1;
+		});
+		container.dispatchEvent(
+			new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
+		);
+		expect(clicks).toBe(1);
+		expect(mcKeys).toBe(1);
+	});
+
+	it("its hotkey from another focused row moves focus here; Enter then activates", async () => {
+		// The see-before-commit rhythm: the letter is a jump, Enter is the commit.
+		const { ctl, key, button } = await makeRig(`
+			<div data-mc-row="a">plain row</div>
+			<div data-mc-row="dock"><sw-button label="dock" hotkey="d"></sw-button></div>
+		`);
+		let clicks = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		const e = key("d"); // focus is on row a
+		expect(ctl.nav.focusedId).toBe("dock");
+		expect(clicks).toBe(0); // a jump, not an activation
+		expect(e.defaultPrevented).toBe(true);
+		key("Enter");
+		expect(clicks).toBe(1);
+	});
+
+	it("its own hotkey while focused does nothing (Enter/Space are the activation)", async () => {
+		// Double-tap must not out-power single-tap: the letter only ever jumps.
+		const { ctl, key, button } = await makeRig(`
+			<div data-mc-row="dock"><sw-button label="dock" hotkey="d"></sw-button></div>
+			<div data-mc-row="b">plain row</div>
+		`);
+		let clicks = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		const e = key("d"); // focus starts on the dock row itself
+		expect(clicks).toBe(0);
+		expect(ctl.nav.focusedId).toBe("dock");
+		expect(e.defaultPrevented).toBe(true); // consumed: the column advertises this key
+	});
+
+	it("hotkey-direct: the letter activates immediately, with focus moved to this row", async () => {
+		const { ctl, key, button } = await makeRig(`
+			<div data-mc-row="a">plain row</div>
+			<div data-mc-row="dock"><sw-button label="dock" hotkey="d" hotkey-direct></sw-button></div>
+		`);
+		let clicks = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		const e = key("d"); // focus is on row a
+		expect(clicks).toBe(1);
+		expect(ctl.nav.focusedId).toBe("dock"); // the acted row is the focused row: Enter repeats
+		expect(e.defaultPrevented).toBe(true);
+		key("Enter");
+		expect(clicks).toBe(2);
+	});
+
+	it("hotkey-direct: its own hotkey while focused activates too (direct means direct)", async () => {
+		// A direct letter has the same power from every row, its own included:
+		// there is no jump for it to mean instead.
+		const { key, button } = await makeRig(`
+			<div data-mc-row="dock"><sw-button label="dock" hotkey="d" hotkey-direct></sw-button></div>
+			<div data-mc-row="b">plain row</div>
+		`);
+		let clicks = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		expect(key("d").defaultPrevented).toBe(true); // focus starts on the dock row
+		expect(clicks).toBe(1);
+	});
+
+	it("an x-marked button yields to the focused row's aux norm (dispatch order, not a rule)", async () => {
+		const { ctl, key, button } = await makeRig(`
+			<div data-mc-row="crate">
+				<span data-mc-main>ore crate</span>
+				<span data-mc-aux><sw-button label="×"></sw-button></span>
+			</div>
+			<div data-mc-row="plain">plain row</div>
+			<div data-mc-row="wipe"><sw-button label="wipe all" hotkey="x"></sw-button></div>
+		`);
+		let auxClicks = 0;
+		button("[data-mc-aux] sw-button").addEventListener("click", () => {
+			auxClicks += 1;
+		});
+		key("x"); // focus on the crate row: its aux claims first, the marking never hears
+		expect(auxClicks).toBe(1);
+		expect(ctl.nav.focusedId).toBe("crate");
+		key("ArrowDown"); // -> the plain row, which has no aux and declines x
+		key("x"); // ... so the marking resolves: a jump to the wipe row
+		expect(ctl.nav.focusedId).toBe("wipe");
+		expect(auxClicks).toBe(1);
+	});
+
+	it("an i-marked button yields to a focused button's own info (the popup norm survives)", async () => {
+		const { ctl, key } = await makeRig(`
+			<div data-mc-row="a"><sw-button label="dock" info="requests clearance"></sw-button></div>
+			<div data-mc-row="plain">plain row</div>
+			<div data-mc-row="atlas"><sw-button label="atlas" hotkey="i"></sw-button></div>
+		`);
+		const infos: string[] = [];
+		document.body.addEventListener("sw-info", (e) => {
+			infos.push((e as CustomEvent<SwInfoDetail>).detail.content);
+		});
+		key("i"); // focus on the dock row: its own info claims i, the marking never hears
+		expect(infos).toEqual(["requests clearance"]);
+		expect(ctl.nav.focusedId).toBe("a");
+		key("ArrowDown"); // -> the plain row, which declines i
+		key("i"); // ... so the marking resolves: a jump to the atlas row
+		expect(ctl.nav.focusedId).toBe("atlas");
+		expect(infos).toEqual(["requests clearance"]); // a jump, not the info norm
+	});
+
+	it("duplicate sw-button hotkeys in one column throw at scan time", async () => {
+		await expect(
+			makeRig(`
+				<div data-mc-row="a"><sw-button label="dock" hotkey="d"></sw-button></div>
+				<div data-mc-row="b"><sw-button label="drift" hotkey="d"></sw-button></div>
+			`),
+		).rejects.toThrow(/duplicate hotkey marking "d"/);
+	});
+
+	it("an aux sw-button answers x and Delete from anywhere in the row", async () => {
+		const { key, button } = await makeRig(`
+			<div data-mc-row="crate">
+				<span data-mc-main>ore crate</span>
+				<span data-mc-aux><sw-button label="×"></sw-button></span>
+			</div>
+		`);
+		let clicks = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		expect(key("x").defaultPrevented).toBe(true); // focus is at main position
+		expect(key("Delete").defaultPrevented).toBe(true);
+		expect(clicks).toBe(2);
+	});
+
+	it("a main sw-button does NOT answer x (removal is the aux norm)", async () => {
+		const { key, button } = await makeRig(`
+			<div data-mc-row="a"><sw-button label="alpha"></sw-button></div>
+		`);
+		let clicks = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		key("x");
+		expect(clicks).toBe(0);
+	});
+
+	it("Enter at main does not activate the aux button (activation must not be destructive)", async () => {
+		const { key, button } = await makeRig(`
+			<div data-mc-row="crate">
+				<span data-mc-main>ore crate</span>
+				<span data-mc-aux><sw-button label="×"></sw-button></span>
+			</div>
+		`);
+		let clicks = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		key("Enter"); // main position: the aux button must stay quiet
+		expect(clicks).toBe(0);
+		key("ArrowRight"); // move to the aux control...
+		key("Enter"); // ...now Enter is at the aux position
+		expect(clicks).toBe(1);
+	});
+
+	it("i at its position dispatches sw-info", async () => {
+		const { key, button } = await makeRig(`
+			<div data-mc-row="a"><sw-button label="dock" info="requests clearance"></sw-button></div>
+		`);
+		const got: SwInfoDetail[] = [];
+		document.body.addEventListener("sw-info", (e) => {
+			got.push((e as CustomEvent<SwInfoDetail>).detail);
+		});
+		const e = key("i");
+		expect(got.length).toBe(1);
+		expect(got[0]?.content).toBe("requests clearance");
+		expect(got[0]?.source).toBe(button("sw-button"));
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("disabled: claims its keys but does not act (Space must not fall through)", async () => {
+		const { ctl, key, button } = await makeRig(`
+			<div data-mc-row="a"><sw-button label="dock" hotkey="d" hotkey-direct disabled></sw-button></div>
+			<div data-mc-row="b">plain row</div>
+		`);
+		let clicks = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		expect(key(" ").defaultPrevented).toBe(true); // claimed...
+		expect(clicks).toBe(0); // ...but declined
+		key("ArrowDown"); // move to row b; the disabled DIRECT hotkey still claims from afar
+		expect(key("d").defaultPrevented).toBe(true);
+		expect(clicks).toBe(0);
+		// The jump half of a direct activation still lands: the row is a nav stop
+		// (i can ask why it is off), only the action is suppressed.
+		expect(ctl.nav.focusedId).toBe("a");
+	});
+
+	it("disabled, non-direct: its hotkey still jumps focus to it (i can ask why)", async () => {
+		const { ctl, key, button } = await makeRig(`
+			<div data-mc-row="a"><sw-button label="dock" hotkey="d" disabled></sw-button></div>
+			<div data-mc-row="b">plain row</div>
+		`);
+		let clicks = 0;
+		button("sw-button").addEventListener("click", () => {
+			clicks += 1;
+		});
+		key("ArrowDown"); // -> b
+		expect(key("d").defaultPrevented).toBe(true);
+		expect(ctl.nav.focusedId).toBe("a"); // the jump lands; disabled rows are nav stops
+		expect(clicks).toBe(0);
+		key("Enter"); // Enter on the disabled button: claimed, declined
+		expect(clicks).toBe(0);
+	});
+
+	it("two buttons in a row each claim only their own position's Enter", async () => {
+		const { key, button } = await makeRig(`
+			<div data-mc-row="pair">
+				<span data-mc-main><sw-button label="inspect"></sw-button></span>
+				<span data-mc-aux><sw-button label="×"></sw-button></span>
+			</div>
+		`);
+		let mains = 0;
+		let auxes = 0;
+		button("[data-mc-main] sw-button").addEventListener("click", () => {
+			mains += 1;
+		});
+		button("[data-mc-aux] sw-button").addEventListener("click", () => {
+			auxes += 1;
+		});
+		key("Enter");
+		expect([mains, auxes]).toEqual([1, 0]);
+		key("ArrowRight");
+		key("Enter");
+		expect([mains, auxes]).toEqual([1, 1]);
+	});
+});
diff --git a/packages/yukey/src/sw-button.ts b/packages/yukey/src/sw-button.ts
new file mode 100644
index 0000000..75da923
--- /dev/null
+++ b/packages/yukey/src/sw-button.ts
@@ -0,0 +1,394 @@
+// sw-button: the button that makes the guidelines' keyboard norms structural.
+// Use it and the norms happen; there is nothing to remember per screen.
+//
+// The norms it embodies (docs/precepts-we-love-the-keyboard.md, "buttons always show their hotkey"
+// and the megacolumn sections):
+//
+//   - Hotkey marking: if the hotkey letter occurs in the label, its first
+//     occurrence renders in --sw-hotkey-accent; otherwise the letter shows in a
+//     slot 70% of the way across.  A complex button (more than one concept of
+//     activation) sets `icon` instead, which claims that slot: a single letter
+//     would mislead, so the icon says "more here; ask i".
+//   - The i norm: pressing i (forwarded in a row, or while focused standalone)
+//     dispatches a composed `sw-info` event carrying this button's `info` text.
+//     sw-infopop renders the popup; `info` is also mirrored onto data-info,
+//     the attribute sw-infopop's hover trigger finds carriers by
+//     (one authored content, two triggers).
+//     i answers even when disabled: it is how you ask why a thing is off.
+//   - Hit targets from --sw-hit-min-h/w.
+//   - Active (`active`): the in-effect option among sibling choices, marked with
+//     the selected wash and aria-current -- and STILL clickable: re-issuing an
+//     in-effect choice is the norm's contract (owners decide whether re-issue
+//     means anything).  Not aria-pressed: these are not toggles; activating an
+//     active button never un-presses it.
+//   - Disabled is aria-disabled, never the native disabled attribute: the button
+//     stays perceivable (and standalone: focusable), activation is suppressed.
+//
+// Dual context, detected at connect time:
+//
+//   - Inside a megacolumn row (a [data-mc-row] ancestor in the same shadow tree):
+//     renders non-focusable markup -- the container must stay the column's only
+//     tab stop, and non-focusable content also gives clicks their native behavior
+//     of focusing the container.  Activation arrives as megacolumn-key events,
+//     heard on the row element so the button answers row-scoped keys even when
+//     the forward targeted the row's other control.  Claims (first claim wins;
+//     a claim preventDefaults, and a disabled button claims but declines):
+//       Enter/Space at this button's own position   -> activate
+//       its hotkey, only when hotkeyDirect          -> activate (via the column-
+//                                                      scoped marking contract:
+//                                                      data-mc-hotkey plus
+//                                                      data-mc-hotkey-direct on the
+//                                                      host).  WITHOUT hotkeyDirect
+//                                                      the button declines its own
+//                                                      hotkey: that decline lets the
+//                                                      controller resolve the marking
+//                                                      as a focus move to this row,
+//                                                      and Enter/Space then activate
+//                                                      (see-before-commit).
+//       x/Delete from any position, aux buttons only-> activate (the removal norm)
+//       i at this button's own position             -> sw-info
+//   - Standalone (no [data-mc-container] ancestor): a real native button; Tab
+//     reaches it, Enter/Space activate natively, the focus ring is the tokens'.
+//     Its hotkey and i answer only while it holds focus (a broader standalone
+//     hotkey scope is an app-level concern, not this component's).
+//   - Inside a container but outside any row: fail closed (throws at connect).
+//     A native tab stop loose in a container would quietly fracture the
+//     single-tab-stop model, and the scan cannot see into this shadow root.
+//
+// Activation is a `click` event on this element in every context and by every
+// trigger (mouse, Enter/Space, hotkey): authors write ONE @click handler.
+// In row mode every host click is also CLAIMED (preventDefault) as it passes:
+// the megacolumn controller synthesizes an Enter-equivalent megacolumn-key from
+// unclaimed clicks on row controls (its pointer-activation norm for plain rows),
+// and the claim tells it this widget already owns click-activation -- without it,
+// a mouse click would activate twice, once natively and once synthesized.
+// Standalone clicks stay unclaimed: no controller listens, and a native button's
+// click default is not this component's to consume.
+//
+// Activation feedback: every successful activation flashes the pressed look for
+// --sw-press-ms (press-flash.ts), so keyboard triggers are as visible as a mouse
+// press; a pointer held down shows the same look for as long as it stays down.
+// Disabled buttons show neither: nothing happened, nothing must claim otherwise.
+// The sw-pressed/sw-held classes live on the HOST element, outside classMap's
+// managed set, so renders never clobber a flash in flight.
+
+import { css, html, LitElement, type TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { classMap } from "lit/directives/class-map.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+import type { MegacolumnKeyDetail } from "./megacolumn-controller.js";
+import { flashPress } from "./press-flash.js";
+import { dispatchSwInfo } from "./sw-info.js";
+
+@customElement("sw-button")
+export class SwButton extends LitElement {
+	static styles = css`
+		:host {
+			display: inline-flex;
+		}
+		.btn {
+			position: relative;
+			display: inline-flex;
+			align-items: center;
+			justify-content: center;
+			flex: 1;
+			min-height: var(--sw-hit-min-h);
+			min-width: var(--sw-hit-min-w);
+			padding: 2px var(--sw-space-md);
+			box-sizing: border-box;
+			background: none;
+			border: 1px solid var(--sw-border);
+			color: var(--sw-text);
+			font: inherit;
+			cursor: pointer;
+			user-select: none;
+			white-space: nowrap;
+		}
+		.btn:hover {
+			color: var(--sw-text-bright);
+			border-color: var(--sw-border-bright);
+		}
+		button.btn:focus-visible {
+			outline: var(--sw-focus-ring);
+			outline-offset: var(--sw-focus-ring-offset);
+		}
+		.hk,
+		.slot {
+			color: var(--sw-hotkey-accent);
+		}
+		/* the 70%-offset hotkey slot, per the guidelines' button-marking norm */
+		.btn.slotted {
+			padding-right: calc(var(--sw-space-xxl) + var(--sw-space-md));
+		}
+		.slot {
+			position: absolute;
+			left: 70%;
+			top: 50%;
+			transform: translate(-50%, -50%);
+		}
+		/* the aux (small-x) form: compact, danger-tinted, borderless like the norm */
+		.btn.aux {
+			color: var(--sw-danger);
+			border-color: transparent;
+			padding: 0;
+			flex: none;
+		}
+		.btn.aux:hover {
+			border-color: var(--sw-danger-border);
+		}
+		.btn.disabled {
+			color: var(--sw-text-faint);
+			border-color: var(--sw-border-dim);
+			cursor: default;
+		}
+		/* the active (in-effect) option: selected wash, brighter text, still clickable */
+		.btn.active {
+			color: var(--sw-text-bright);
+			background: var(--sw-wash-selected);
+		}
+		/* a dead hotkey must not advertise itself in the significant color */
+		.btn.disabled .hk,
+		.btn.disabled .slot {
+			color: inherit;
+		}
+		/* activation feedback: the same look for held-under-a-pointer and for the
+		   post-activation flash (see the file header) */
+		:host(.sw-held) .btn,
+		:host(.sw-pressed) .btn {
+			background: var(--sw-wash-pressed);
+			border-color: var(--sw-accent-bright);
+			color: var(--sw-text-bright);
+		}
+	`;
+
+	@property() label = "";
+	@property() icon = "";
+	@property({ type: Boolean, reflect: true }) disabled = false;
+	@property({ type: Boolean }) active = false;
+
+	// The info content, mirrored synchronously onto data-info: that attribute is
+	// the carrier contract sw-infopop's hover trigger finds elements by
+	// (decision-info-content: one authored content, two triggers), and mirroring
+	// keeps this property a typed accessor over the same single source.
+	#info = "";
+	@property()
+	get info(): string {
+		return this.#info;
+	}
+	set info(v: string) {
+		this.#info = v;
+		if (v === "") delete this.dataset.info;
+		else this.dataset.info = v;
+	}
+
+	// The hotkey, normalized to lowercase, mirrored synchronously onto
+	// data-mc-hotkey so the megacolumn controller's scan sees the marking without
+	// waiting for this component's own render.  Validation matches the scan's
+	// (single [a-z0-9]) so a bad hotkey fails loudly in standalone contexts too,
+	// where no scan exists.  x and i are legal but conventionally left unmarked:
+	// the row norms answer them (see the megacolumn DOM contract's guideline).
+	#hotkey = "";
+	@property()
+	get hotkey(): string {
+		return this.#hotkey;
+	}
+	set hotkey(v: string) {
+		if (v !== "" && !/^[a-z0-9]$/i.test(v))
+			throw new Error(`sw-button: hotkey "${v}" must be a single [a-z0-9] character`);
+		const norm = v.toLowerCase();
+		this.#hotkey = norm;
+		if (norm === "") delete this.dataset.mcHotkey;
+		else this.dataset.mcHotkey = norm;
+	}
+
+	// Direct activation: the marked hotkey activates this button immediately
+	// (focus moving to its row as part of the act) instead of only moving focus
+	// there.  Mirrored synchronously onto data-mc-hotkey-direct, the controller's
+	// half of the contract, the same way hotkey mirrors onto data-mc-hotkey.
+	// The deliberately long name is friction: skipping see-before-commit is
+	// legitimate where an undo semantic exists (the guidelines' example: a map
+	// move order is cheap to cancel, so just give the order).  No undo?  Leave
+	// this off and let the letter jump; Enter is close by.
+	#hotkeyDirect = false;
+	@property({ type: Boolean, attribute: "hotkey-direct" })
+	get hotkeyDirect(): boolean {
+		return this.#hotkeyDirect;
+	}
+	set hotkeyDirect(v: boolean) {
+		this.#hotkeyDirect = v;
+		if (v) this.dataset.mcHotkeyDirect = "";
+		else delete this.dataset.mcHotkeyDirect;
+	}
+
+	#row: HTMLElement | null = null;
+	#suppressorWired = false;
+
+	connectedCallback(): void {
+		super.connectedCallback();
+		this.#row = this.closest("[data-mc-row]");
+		if (this.#row === null && this.closest("[data-mc-container]") !== null)
+			throw new Error(
+				"sw-button: inside a megacolumn container but outside any row; " +
+					"a loose tab stop would fracture the single-tab-stop model",
+			);
+		if (this.#row !== null) {
+			this.#row.addEventListener("megacolumn-key", this.#onMegacolumnKey);
+		} else {
+			this.addEventListener("keydown", this.#onKeydown);
+		}
+		this.addEventListener("click", this.#onActivated);
+		this.addEventListener("pointerdown", this.#onPointerDown);
+		this.addEventListener("pointerup", this.#releaseHold);
+		this.addEventListener("pointercancel", this.#releaseHold);
+		this.addEventListener("pointerleave", this.#releaseHold);
+		if (!this.#suppressorWired) {
+			// Disabled clicks die here, before any author's @click on the host can
+			// see them -- capture at the render root beats the internal bubble.
+			this.renderRoot.addEventListener("click", this.#suppressDisabledClick, { capture: true });
+			this.#suppressorWired = true;
+		}
+	}
+
+	disconnectedCallback(): void {
+		super.disconnectedCallback();
+		this.#row?.removeEventListener("megacolumn-key", this.#onMegacolumnKey);
+		this.#row = null;
+		this.removeEventListener("keydown", this.#onKeydown);
+		this.removeEventListener("click", this.#onActivated);
+		this.removeEventListener("pointerdown", this.#onPointerDown);
+		this.removeEventListener("pointerup", this.#releaseHold);
+		this.removeEventListener("pointercancel", this.#releaseHold);
+		this.removeEventListener("pointerleave", this.#releaseHold);
+		this.#releaseHold();
+	}
+
+	// Row-mode claims; see the file header for the claim table.
+	#onMegacolumnKey = (e: Event): void => {
+		if (e.defaultPrevented) return; // someone earlier in the row claimed it
+		const { key, position } = (e as CustomEvent<MegacolumnKeyDetail>).detail;
+		const atUs = position === this.#position();
+		const k = key.toLowerCase();
+		if ((key === "Enter" || key === " ") && atUs) {
+			this.#claimActivation(e);
+		} else if (this.#hotkey !== "" && k === this.#hotkey && this.#hotkeyDirect) {
+			// A non-direct hotkey is deliberately NOT claimed: the decline is what
+			// lets the controller resolve the marking as a focus move to this row.
+			this.#claimActivation(e);
+		} else if ((k === "x" || key === "Delete") && this.#position() === "aux") {
+			this.#claimActivation(e);
+		} else if (k === "i" && atUs && this.info !== "") {
+			e.preventDefault();
+			this.#dispatchInfo();
+		}
+	};
+
+	// Standalone: the hotkey and i answer while the button holds focus.
+	#onKeydown = (e: KeyboardEvent): void => {
+		if (e.ctrlKey || e.altKey || e.metaKey) return;
+		const k = e.key.toLowerCase();
+		if (this.#hotkey !== "" && k === this.#hotkey) {
+			e.preventDefault();
+			if (!this.disabled) this.click();
+		} else if (k === "i" && this.info !== "") {
+			e.preventDefault();
+			this.#dispatchInfo();
+		}
+	};
+
+	#suppressDisabledClick = (e: Event): void => {
+		if (this.disabled) e.stopPropagation();
+	};
+
+	// Every successful activation is a click on this host, whatever the trigger,
+	// so the flash hooks here and keyboard activations get the same visual
+	// acknowledgment as a mouse press.  The disabled check repeats the
+	// suppressor's: a direct host-level click() never crosses the shadow root.
+	// In row mode the click is claimed even when disabled: nothing may activate,
+	// but the controller must not synthesize an activation from it either.
+	#onActivated = (e: Event): void => {
+		if (this.#row !== null) e.preventDefault();
+		if (this.disabled) return;
+		flashPress(this);
+	};
+
+	#onPointerDown = (e: PointerEvent): void => {
+		if (e.button !== 0 || this.disabled) return;
+		this.classList.add("sw-held");
+	};
+
+	#releaseHold = (): void => {
+		this.classList.remove("sw-held");
+	};
+
+	// A claimed key is consumed whether or not the button acts: a disabled
+	// button's Space must not fall through and scroll the column.
+	#claimActivation(e: Event): void {
+		e.preventDefault();
+		if (this.disabled) return;
+		this.click();
+	}
+
+	#dispatchInfo(): void {
+		dispatchSwInfo(this, this.info);
+	}
+
+	#position(): "main" | "aux" {
+		return this.closest("[data-mc-aux]") !== null ? "aux" : "main";
+	}
+
+	render(): TemplateResult {
+		const marked = this.#labelMarkIndex();
+		const slotGlyph = this.icon !== "" ? this.icon : marked === -1 ? this.#hotkey : "";
+		const classes = {
+			btn: true,
+			slotted: slotGlyph !== "",
+			aux: this.#position() === "aux",
+			active: this.active,
+			disabled: this.disabled,
+		};
+		const ariaDisabled = this.disabled ? "true" : undefined;
+		const ariaCurrent = this.active ? "true" : undefined;
+		const ariaKeys = this.#hotkey !== "" ? this.#hotkey : undefined;
+		// The label wrapper keeps the (possibly marking-split) label ONE flex item:
+		// .btn is a flex container, and three anonymous items would have the
+		// whitespace at their edges trimmed ("cycle beacon" -> "cyclebeacon").
+		const content = html`<span class="label">${this.#content(marked)}</span>${
+			slotGlyph !== "" ? html`<span class="slot" aria-hidden="true">${slotGlyph}</span>` : ""
+		}`;
+		// Row mode renders non-focusable markup (the container is the tab stop, and
+		// clicks then natively focus it); standalone renders a real native button.
+		return this.#row !== null
+			? html`<div
+					class=${classMap(classes)}
+					aria-disabled=${ifDefined(ariaDisabled)}
+					aria-current=${ifDefined(ariaCurrent)}
+					aria-keyshortcuts=${ifDefined(ariaKeys)}
+				>${content}</div>`
+			: html`<button
+					type="button"
+					class=${classMap(classes)}
+					aria-disabled=${ifDefined(ariaDisabled)}
+					aria-current=${ifDefined(ariaCurrent)}
+					aria-keyshortcuts=${ifDefined(ariaKeys)}
+				>${content}</button>`;
+	}
+
+	// Index of the letter-in-label marking: the first case-insensitive occurrence
+	// of the hotkey.  -1 when unmarked (no hotkey, no occurrence, or an icon
+	// claiming the slot -- a complex button must not imply one letter is the story).
+	#labelMarkIndex(): number {
+		if (this.#hotkey === "" || this.icon !== "") return -1;
+		return this.label.toLowerCase().indexOf(this.#hotkey);
+	}
+
+	#content(marked: number): TemplateResult {
+		if (marked === -1) return html`${this.label}`;
+		return html`${this.label.slice(0, marked)}<span class="hk">${this.label[marked]}</span>${this.label.slice(marked + 1)}`;
+	}
+}
+
+declare global {
+	interface HTMLElementTagNameMap {
+		"sw-button": SwButton;
+	}
+}
diff --git a/packages/yukey/src/sw-info.ts b/packages/yukey/src/sw-info.ts
new file mode 100644
index 0000000..d63cf3c
--- /dev/null
+++ b/packages/yukey/src/sw-info.ts
@@ -0,0 +1,34 @@
+// The info norm's wiring: the sw-info event shape and the data-info carrier contract.
+// This file is the SSOT for both.  (The norm itself: docs/precepts-we-love-the-keyboard.md,
+// "info pops" -- i means "info, please" on almost any element.)
+//
+// One authored content, two triggers:
+//
+//   - `data-info="<content>"` on any element makes it a CARRIER.  sw-infopop's
+//     hover trigger finds carriers by this attribute, and the megacolumn
+//     controller answers otherwise-unclaimed i forwards for them.  Components
+//     with an info property (sw-button) mirror it onto this attribute;
+//     plain markup writes the attribute directly.  Empty means not a carrier.
+//   - `sw-info` (bubbles + composed) is the keyboard trigger's event: a producer
+//     that answered i dispatches it, sw-infopop renders it.  The detail carries
+//     the anchor element because composed events retarget: a listener outside
+//     the shadow tree sees only the host chain, never the real source.
+//
+// Producers today: sw-button (its own i claim, standalone and in rows) and
+// MegacolumnController (the fallback for plain [data-info] rows).
+
+export interface SwInfoDetail {
+	content: string;
+	// The element to anchor the popup to (see the retargeting note above).
+	source: HTMLElement;
+}
+
+export function dispatchSwInfo(source: HTMLElement, content: string): void {
+	source.dispatchEvent(
+		new CustomEvent<SwInfoDetail>("sw-info", {
+			detail: { content, source },
+			bubbles: true,
+			composed: true,
+		}),
+	);
+}
diff --git a/packages/yukey/src/sw-infopop.gallery.ts b/packages/yukey/src/sw-infopop.gallery.ts
new file mode 100644
index 0000000..93efd76
--- /dev/null
+++ b/packages/yukey/src/sw-infopop.gallery.ts
@@ -0,0 +1,198 @@
+// Gallery entry for sw-infopop: keyboard i and mouse hover raising the same
+// authored content, the pin precedence, plain-row participation via data-info,
+// and the edge flip.  The popup instance under demonstration is the gallery
+// shell's own -- one per app is the model, so the demos raise that one.
+
+import { css, html, LitElement, type TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import type { GalleryEntry } from "./gallery/entry.js";
+import {
+	MegacolumnController,
+	type MegacolumnKeyDetail,
+	megacolumnStyles,
+} from "./megacolumn-controller.js";
+import "./sw-button.js";
+
+// A megacolumn where every row flavor carries info: sw-buttons answer i
+// themselves; the plain text main, the slider row, and the aux x are answered
+// by the controller's [data-info] fallback.  Hover hits the same attributes.
+@customElement("swg-infopop-column")
+export class SwgInfopopColumn extends LitElement {
+	static styles = [
+		megacolumnStyles,
+		css`
+			:host {
+				display: block;
+			}
+			[data-mc-container] {
+				border: 1px solid var(--sw-border-dim);
+				padding: var(--sw-space-md);
+			}
+			[data-mc-row] {
+				display: flex;
+				align-items: center;
+				gap: var(--sw-space-sm);
+				min-height: var(--sw-hit-min-h);
+				padding: 2px var(--sw-space-sm);
+			}
+			[data-mc-row] sw-button:first-child {
+				flex: 1;
+			}
+			.grow {
+				flex: 1;
+			}
+			.meter {
+				flex: 1;
+				height: 8px;
+				border: 1px solid var(--sw-border-dim);
+				position: relative;
+			}
+			.meter .fill {
+				position: absolute;
+				inset: 0 auto 0 0;
+				background: var(--sw-accent);
+			}
+			.pct {
+				color: var(--sw-text-hi);
+				width: 4ch;
+				text-align: right;
+			}
+			[data-mc-aux] {
+				color: var(--sw-danger);
+				min-width: var(--sw-hit-min-w);
+				min-height: var(--sw-hit-min-h);
+				display: inline-flex;
+				align-items: center;
+				justify-content: center;
+			}
+			.log {
+				margin-top: var(--sw-space-md);
+				color: var(--sw-good);
+				font-size: var(--sw-type-ui);
+				min-height: 1.4em;
+			}
+		`,
+	];
+
+	// biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController
+	private mc = new MegacolumnController(this);
+
+	@property() label = "info everywhere";
+
+	@state() private power = 40;
+	@state() private last = "";
+
+	private onMegacolumnKey(e: Event): void {
+		const { key, rowId } = (e as CustomEvent<MegacolumnKeyDetail>).detail;
+		if (rowId === "survey" && (key === "Enter" || key === " ")) {
+			this.last = "survey queued";
+			e.preventDefault();
+			return;
+		}
+		if (rowId === "power" && (key === "ArrowLeft" || key === "ArrowRight")) {
+			const delta = key === "ArrowRight" ? 5 : -5;
+			this.power = Math.min(100, Math.max(0, this.power + delta));
+			this.last = `reactor power ${this.power}%`;
+			e.preventDefault();
+			return;
+		}
+		if (rowId === "probe" && (key === "x" || key === "Delete")) {
+			this.last = "probe recalled";
+			e.preventDefault();
+		}
+	}
+
+	render(): TemplateResult {
+		return html`
+			<div data-mc-container aria-label=${this.label} @megacolumn-key=${this.onMegacolumnKey}>
+				<div data-mc-row="dock">
+					<sw-button
+						label="Dock at station"
+						hotkey="d"
+						info="an sw-button answers i itself; hover reads the same data-info mirror"
+						@click=${() => {
+							this.last = "docked";
+						}}
+					></sw-button>
+				</div>
+				<div data-mc-row="survey" data-info="a plain text row: the megacolumn answers i for its data-info">
+					<span class="grow">queue survey</span>
+				</div>
+				<div data-mc-row="power" data-info="a slider row: left/right adjust it, and i still explains it">
+					<span>reactor</span>
+					<span class="meter"><span class="fill" style="width:${this.power}%"></span></span>
+					<span class="pct">${this.power}%</span>
+				</div>
+				<div data-mc-row="probe" data-info="this row's main story: the probe telemetry feed">
+					<span class="grow" data-mc-main>recall probe</span>
+					<span data-mc-aux data-info="the x's own story: recalls the probe (x or Delete)">×</span>
+				</div>
+			</div>
+			<div class="log">${this.last}</div>
+		`;
+	}
+}
+
+// Buttons pushed against the stage edges, so the preferred below-centered
+// placement has to clamp (sides) or flip (bottom edge, when the page is short).
+@customElement("swg-infopop-edges")
+export class SwgInfopopEdges extends LitElement {
+	static styles = css`
+		:host {
+			display: block;
+		}
+		.corners {
+			display: flex;
+			justify-content: space-between;
+		}
+	`;
+
+	render(): TemplateResult {
+		return html`
+			<div class="corners">
+				<sw-button
+					label="far left"
+					info="anchored at the viewport's left: the popup clamps instead of centering"
+				></sw-button>
+				<sw-button
+					label="far right"
+					info="anchored at the viewport's right: the popup clamps the other way"
+				></sw-button>
+			</div>
+		`;
+	}
+}
+
+const entry: GalleryEntry = {
+	id: "sw-infopop",
+	title: "sw-infopop",
+	description:
+		"The info norm's popup: keyboard i and mouse hover raise the SAME authored " +
+		"content (one data-info attribute per element, two triggers).  Keyboard-raised " +
+		"popups pin until any key or click; hover popups follow the pointer and never " +
+		"displace a pinned one.  The instance shown is the gallery shell's own popup: " +
+		"one per app, listening at its root.",
+	variants: [
+		{
+			caption:
+				"Every row flavor carries info.  Keyboard: focus the column, press i on each row -- " +
+				"the sw-button answers itself, the plain rows are answered by the megacolumn's data-info " +
+				"fallback, and on the probe row, right-arrow onto the × for its own story.  " +
+				"i again on the same row dismisses (a toggle); i after moving to another row follows.  " +
+				"Mouse: hover the same places and linger; the identical text appears.  " +
+				"Precedence: press i, then wave the mouse over other rows -- the pinned popup holds " +
+				"until a key or click dismisses it.  Navigation keys close the popup AND still navigate.",
+			render: () => html`<swg-infopop-column></swg-infopop-column>`,
+		},
+		{
+			caption:
+				"Edge behavior: these buttons hug the stage's sides, so the below-centered placement " +
+				"clamps horizontally to stay inside the viewport.  Hover or focus-and-i near a viewport " +
+				"edge (shrink the window height to see the bottom flip: the popup jumps above the anchor).",
+			wide: true,
+			render: () => html`<swg-infopop-edges></swg-infopop-edges>`,
+		},
+	],
+};
+
+export default entry;
diff --git a/packages/yukey/src/sw-infopop.test.ts b/packages/yukey/src/sw-infopop.test.ts
new file mode 100644
index 0000000..b1736f0
--- /dev/null
+++ b/packages/yukey/src/sw-infopop.test.ts
@@ -0,0 +1,388 @@
+// @vitest-environment jsdom
+//
+// Tests for sw-infopop (sw-infopop.ts): triggers, precedence, and dismissal.
+// Geometry is NOT tested here (jsdom does no layout; rects are all zero) --
+// the placement rule has its own pure tests in infopop-place.test.ts, and the
+// browser verification covers the real flip.  Hover timing runs on fake timers.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { dispatchSwInfo } from "./sw-info.js";
+import { HOVER_DELAY_MS, type SwInfopop } from "./sw-infopop.js";
+import "./sw-infopop.js";
+
+interface Rig {
+	pop: SwInfopop;
+	rowA: HTMLElement;
+	rowB: HTMLElement;
+	aux: HTMLElement;
+	bare: HTMLElement;
+}
+
+// One popup plus a few carriers, the way an app shell hosts it: the popup and
+// the content share a tree (jsdom needs no shadow hosts to prove the wiring;
+// composed bubbling across roots is the platform's, not ours).
+async function makeRig(): Promise<Rig> {
+	document.body.innerHTML = `
+		<div id="rowA" data-info="alpha's story">alpha <span id="aux" data-info="the x's story">x</span></div>
+		<div id="rowB" data-info="beta's story">beta</div>
+		<div id="bare">no info here</div>
+	`;
+	const pop = document.createElement("sw-infopop");
+	document.body.append(pop);
+	await pop.updateComplete;
+	const get = (id: string): HTMLElement => {
+		const el = document.getElementById(id);
+		if (el === null) throw new Error(`rig lost #${id}`);
+		return el;
+	};
+	return { pop, rowA: get("rowA"), rowB: get("rowB"), aux: get("aux"), bare: get("bare") };
+}
+
+function hover(el: HTMLElement): void {
+	// MouseEvent rather than PointerEvent: jsdom listeners see the type string,
+	// and the handler reads nothing a PointerEvent would add.
+	el.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, composed: true }));
+}
+
+function key(el: HTMLElement | Window, k: string): KeyboardEvent {
+	const e = new KeyboardEvent("keydown", {
+		key: k,
+		bubbles: true,
+		composed: true,
+		cancelable: true,
+	});
+	el.dispatchEvent(e);
+	return e;
+}
+
+async function settle(pop: SwInfopop): Promise<void> {
+	await pop.updateComplete;
+}
+
+beforeEach(() => {
+	vi.useFakeTimers();
+});
+
+afterEach(() => {
+	document.body.replaceChildren(); // disconnects the popup and its window listeners
+	vi.useRealTimers();
+});
+
+describe("the keyboard trigger (sw-info)", () => {
+	it("opens pinned with the event's content, anchored to its source", async () => {
+		const { pop, rowA } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		await settle(pop);
+		expect(pop.mode).toBe("pinned");
+		expect(pop.anchor).toBe(rowA);
+		expect(pop.shadowRoot?.textContent).toContain("alpha's story");
+		expect(pop.style.visibility).toBe("visible");
+		expect(pop.hasAttribute("aria-hidden")).toBe(false);
+	});
+
+	it("a later sw-info replaces the popup (new anchor, new content)", async () => {
+		const { pop, rowA, rowB } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		dispatchSwInfo(rowB, "beta's story");
+		await settle(pop);
+		expect(pop.anchor).toBe(rowB);
+		expect(pop.shadowRoot?.textContent).toContain("beta's story");
+	});
+});
+
+describe("the hover trigger", () => {
+	it("opens after the delay with the carrier's data-info", async () => {
+		const { pop, rowA } = await makeRig();
+		hover(rowA);
+		expect(pop.mode).toBe("closed"); // not before the delay
+		vi.advanceTimersByTime(HOVER_DELAY_MS);
+		await settle(pop);
+		expect(pop.mode).toBe("hover");
+		expect(pop.anchor).toBe(rowA);
+		expect(pop.shadowRoot?.textContent).toContain("alpha's story");
+	});
+
+	it("the nearest carrier in the path wins (aux inside a row)", async () => {
+		const { pop, aux } = await makeRig();
+		hover(aux);
+		vi.advanceTimersByTime(HOVER_DELAY_MS);
+		await settle(pop);
+		expect(pop.anchor).toBe(aux);
+		expect(pop.shadowRoot?.textContent).toContain("the x's story");
+	});
+
+	it("re-arms the delay when the carrier changes mid-wait", async () => {
+		const { pop, rowA, rowB } = await makeRig();
+		hover(rowA);
+		vi.advanceTimersByTime(HOVER_DELAY_MS - 100);
+		hover(rowB);
+		vi.advanceTimersByTime(HOVER_DELAY_MS - 100);
+		expect(pop.mode).toBe("closed"); // neither timer has run to completion
+		vi.advanceTimersByTime(100);
+		await settle(pop);
+		expect(pop.anchor).toBe(rowB);
+	});
+
+	it("hovering away and back restarts the delay (no early open off a stale timer)", async () => {
+		const { pop, rowA, rowB } = await makeRig();
+		hover(rowA);
+		vi.advanceTimersByTime(HOVER_DELAY_MS - 100);
+		hover(rowB);
+		hover(rowA); // back again: the original timer must not still be live
+		vi.advanceTimersByTime(150); // past the original deadline, before the new one
+		expect(pop.mode).toBe("closed");
+		vi.advanceTimersByTime(HOVER_DELAY_MS - 150);
+		await settle(pop);
+		expect(pop.anchor).toBe(rowA);
+	});
+
+	it("non-carriers raise nothing", async () => {
+		const { pop, bare } = await makeRig();
+		hover(bare);
+		vi.advanceTimersByTime(HOVER_DELAY_MS * 2);
+		await settle(pop);
+		expect(pop.mode).toBe("closed");
+	});
+
+	it("closes when the pointer leaves the carrier", async () => {
+		const { pop, rowA, bare } = await makeRig();
+		hover(rowA);
+		vi.advanceTimersByTime(HOVER_DELAY_MS);
+		await settle(pop);
+		expect(pop.mode).toBe("hover");
+		hover(bare);
+		await settle(pop);
+		expect(pop.mode).toBe("closed");
+	});
+
+	it("closes when the pointer leaves the window", async () => {
+		const { pop, rowA } = await makeRig();
+		hover(rowA);
+		vi.advanceTimersByTime(HOVER_DELAY_MS);
+		await settle(pop);
+		rowA.dispatchEvent(
+			new MouseEvent("pointerout", { bubbles: true, composed: true, relatedTarget: null }),
+		);
+		await settle(pop);
+		expect(pop.mode).toBe("closed");
+	});
+
+	it("closes when the pointer leaves for an element outside the popup's tree", async () => {
+		// The popup serves the tree it lives in (an app shell's shadow root).  A
+		// destination outside that tree fires no pointermove the popup can hear,
+		// so the carrier's pointerout is the only leave signal there is.
+		document.body.innerHTML = "";
+		const host = document.createElement("div");
+		document.body.append(host);
+		const shell = host.attachShadow({ mode: "open" });
+		shell.innerHTML = `<div id="carrier" data-info="in-shell story">thing</div>`;
+		const pop = document.createElement("sw-infopop");
+		shell.append(pop);
+		await pop.updateComplete;
+		const carrier = shell.getElementById("carrier");
+		if (carrier === null) throw new Error("rig lost the carrier");
+		hover(carrier);
+		vi.advanceTimersByTime(HOVER_DELAY_MS);
+		await settle(pop);
+		expect(pop.mode).toBe("hover");
+		carrier.dispatchEvent(
+			new MouseEvent("pointerout", {
+				bubbles: true,
+				composed: true,
+				relatedTarget: document.body, // outside the shell's shadow tree
+			}),
+		);
+		await settle(pop);
+		expect(pop.mode).toBe("closed");
+	});
+});
+
+describe("precedence: keyboard pins", () => {
+	it("hover never replaces a pinned popup", async () => {
+		const { pop, rowA, rowB } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		hover(rowB);
+		vi.advanceTimersByTime(HOVER_DELAY_MS * 2);
+		await settle(pop);
+		expect(pop.mode).toBe("pinned");
+		expect(pop.anchor).toBe(rowA);
+	});
+
+	it("pointer movement never closes a pinned popup", async () => {
+		const { pop, rowA, bare } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		hover(bare);
+		await settle(pop);
+		expect(pop.mode).toBe("pinned");
+	});
+
+	it("a skipped hover does not resurrect after the pinned popup closes", async () => {
+		// The pointer sits on a carrier while a pinned popup has precedence; when a
+		// key closes the pinned one, nothing should appear without a fresh action.
+		const { pop, rowA, rowB } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		hover(rowB);
+		vi.advanceTimersByTime(HOVER_DELAY_MS); // fires, sees pinned, skips
+		key(window, "Escape");
+		vi.advanceTimersByTime(HOVER_DELAY_MS * 2);
+		await settle(pop);
+		expect(pop.mode).toBe("closed");
+	});
+
+	it("keyboard replaces an open hover popup (and pins)", async () => {
+		const { pop, rowA, rowB } = await makeRig();
+		hover(rowB);
+		vi.advanceTimersByTime(HOVER_DELAY_MS);
+		await settle(pop);
+		expect(pop.mode).toBe("hover");
+		dispatchSwInfo(rowA, "alpha's story");
+		await settle(pop);
+		expect(pop.mode).toBe("pinned");
+		expect(pop.anchor).toBe(rowA);
+	});
+});
+
+describe("dismissal", () => {
+	it("any keydown closes, and the key is never consumed", async () => {
+		const { pop, rowA } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		const e = key(rowA, "ArrowDown");
+		await settle(pop);
+		expect(pop.mode).toBe("closed");
+		expect(e.defaultPrevented).toBe(false); // navigation reaches its owner untouched
+	});
+
+	it("Escape that closes the popup is CONSUMED (one Escape, one layer)", async () => {
+		const { pop, rowA } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		const e = key(rowA, "Escape");
+		await settle(pop);
+		expect(pop.mode).toBe("closed");
+		expect(e.defaultPrevented).toBe(true); // the popup was the layer this Escape closed
+	});
+
+	it("Escape with no popup open passes untouched", async () => {
+		const { pop, rowA } = await makeRig();
+		const e = key(rowA, "Escape");
+		await settle(pop);
+		expect(pop.mode).toBe("closed");
+		expect(e.defaultPrevented).toBe(false); // nothing closed, so the key belongs to outer layers
+	});
+
+	it("bare modifiers close nothing", async () => {
+		const { pop, rowA } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		key(rowA, "Shift");
+		await settle(pop);
+		expect(pop.mode).toBe("pinned");
+	});
+
+	it("one keypress can close the old popup and raise the next (capture order)", async () => {
+		// The i norm in action: the source's keydown handler dispatches sw-info,
+		// and the popup's window-capture close has already run by then.
+		const { pop, rowA, rowB } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		rowB.addEventListener("keydown", (e) => {
+			if (e.key === "i") dispatchSwInfo(rowB, "beta's story");
+		});
+		key(rowB, "i");
+		await settle(pop);
+		expect(pop.mode).toBe("pinned");
+		expect(pop.anchor).toBe(rowB);
+	});
+
+	it("i on the pinned popup's own anchor dismisses instead of re-raising (toggle)", async () => {
+		// The same wiring as the follow test above, but the second i lands on the
+		// element already anchoring the pinned popup: the user is asking it away.
+		const { pop, rowA } = await makeRig();
+		rowA.addEventListener("keydown", (e) => {
+			if (e.key === "i") dispatchSwInfo(rowA, "alpha's story");
+		});
+		key(rowA, "i");
+		await settle(pop);
+		expect(pop.mode).toBe("pinned");
+		key(rowA, "i");
+		await settle(pop);
+		expect(pop.mode).toBe("closed");
+	});
+
+	it("the toggle suppression is scoped to one keypress (i works again afterward)", async () => {
+		const { pop, rowA } = await makeRig();
+		rowA.addEventListener("keydown", (e) => {
+			if (e.key === "i") dispatchSwInfo(rowA, "alpha's story");
+		});
+		key(rowA, "i");
+		key(rowA, "i"); // toggles off
+		key(rowA, "i"); // a fresh press: raises again
+		await settle(pop);
+		expect(pop.mode).toBe("pinned");
+		expect(pop.anchor).toBe(rowA);
+	});
+
+	it("i on a DIFFERENT anchor during the same keypress is never suppressed", async () => {
+		// Guards the follow behavior against the toggle: the capture close sees
+		// key i + pinned mode there too, but the arriving sw-info names another
+		// anchor, so it must open.
+		const { pop, rowA, rowB } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		rowB.addEventListener("keydown", (e) => {
+			if (e.key === "i") dispatchSwInfo(rowB, "beta's story");
+		});
+		key(rowB, "i");
+		await settle(pop);
+		expect(pop.mode).toBe("pinned");
+		expect(pop.anchor).toBe(rowB);
+	});
+
+	it("i on a hover popup's anchor pins it (no toggle for unpinned popups)", async () => {
+		// The keyboard upgrade of a hover popup: only PINNED popups toggle off.
+		const { pop, rowA } = await makeRig();
+		rowA.addEventListener("keydown", (e) => {
+			if (e.key === "i") dispatchSwInfo(rowA, "alpha's story");
+		});
+		hover(rowA);
+		vi.advanceTimersByTime(HOVER_DELAY_MS);
+		await settle(pop);
+		expect(pop.mode).toBe("hover");
+		key(rowA, "i");
+		await settle(pop);
+		expect(pop.mode).toBe("pinned");
+		expect(pop.anchor).toBe(rowA);
+	});
+
+	it("pointerdown anywhere closes", async () => {
+		const { pop, rowA, bare } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		bare.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true }));
+		await settle(pop);
+		expect(pop.mode).toBe("closed");
+	});
+
+	it("scroll closes (anchor rects go stale)", async () => {
+		const { pop, rowA } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		document.dispatchEvent(new Event("scroll"));
+		await settle(pop);
+		expect(pop.mode).toBe("closed");
+	});
+});
+
+describe("announcement surface, never interaction", () => {
+	it("is role=tooltip, never focusable, aria-hidden while closed", async () => {
+		const { pop } = await makeRig();
+		expect(pop.getAttribute("role")).toBe("tooltip");
+		expect(pop.tabIndex).toBe(-1);
+		expect(pop.hasAttribute("tabindex")).toBe(false);
+		expect(pop.getAttribute("aria-hidden")).toBe("true");
+	});
+
+	it("returns to aria-hidden when closed again", async () => {
+		const { pop, rowA } = await makeRig();
+		dispatchSwInfo(rowA, "alpha's story");
+		await settle(pop);
+		key(window, "Escape");
+		await settle(pop);
+		expect(pop.getAttribute("aria-hidden")).toBe("true");
+		expect(pop.style.visibility).toBe("");
+	});
+});
diff --git a/packages/yukey/src/sw-infopop.ts b/packages/yukey/src/sw-infopop.ts
new file mode 100644
index 0000000..8734822
--- /dev/null
+++ b/packages/yukey/src/sw-infopop.ts
@@ -0,0 +1,308 @@
+// sw-infopop: the info norm's popup surface -- the keyboard's tooltip and the
+// mouse's tooltip as ONE element showing one authored content per anchor
+// (the wiring SSOT -- the sw-info event and the data-info carrier attribute --
+// is sw-info.ts; the norm is docs/precepts-we-love-the-keyboard.md "info pops").
+//
+// One per app: place it once in the app shell's render (the gallery shell hosts
+// one today; sw-app hosts one at migration), and every sw-info event and every
+// [data-info] hover in that tree raises it.  Two triggers:
+//
+//   - Keyboard: an sw-info event (composed, so it bubbles here from any open
+//     shadow root below) opens the popup PINNED: pointer movement never closes
+//     or replaces it.  Any later keydown closes it -- heard in capture phase,
+//     so the very key that closes an old popup can raise the next one (i on
+//     another row closes-then-reopens), which is how the popup "follows" row
+//     focus without needing focus-change events.  One exception to re-raising:
+//     i on the anchor the pinned popup already shows is a TOGGLE -- the close
+//     notes which anchor that keypress dismissed, and the matching sw-info
+//     arriving in the same dispatch is swallowed, so the second i dismisses
+//     instead of blinking closed-then-open.  (A different anchor's sw-info,
+//     or any i once that keypress ends, opens normally; an unpinned hover
+//     popup never toggles -- i upgrades it to pinned.)  No key is consumed
+//     except the one Escape that closes the popup (Escape unwinds one layer
+//     at a time, and the popup is the innermost layer): all other navigation
+//     under an open popup reaches the megacolumn untouched.
+//   - Hover: pointermove anywhere in the tree; the nearest [data-info] carrier
+//     in the composed path becomes the candidate, and after a short delay the
+//     popup opens unpinned.  It closes when the pointer leaves the carrier.
+//     Hover never replaces a pinned popup -- not even after the pinned one
+//     closes (no popup appears without a fresh pointer or key action).
+//
+// Dismissal, the complete list: any keydown (bare modifiers excepted),
+// pointerdown anywhere, scroll anywhere (anchor rects go stale; closing beats
+// re-anchoring for a transient surface), and hover-carrier leave (hover-raised
+// only).  Focus moves need no listener of their own: every user-driven focus
+// change starts with a keydown or pointerdown, which already close.
+//
+// The popup is an announcement surface, never an interaction one:
+// pointer-events none, never focusable, never a tab stop.  role=tooltip on the
+// host; where the browser reflects ariaDescribedByElements, the anchor is wired
+// aria-describedby to the popup across shadow trees (the popup's tree is a
+// shadow-including ancestor of every anchor's tree, which element reflection
+// allows; the content-attribute form cannot cross trees, so absent reflection
+// support the wiring is skipped rather than half-done).
+//
+// Placement is infopop-place.ts's pure rule (below-centered, flip, clamp),
+// fed fresh rects at open time.
+
+import { css, html, LitElement, type TemplateResult } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { placeInfopop } from "./infopop-place.js";
+import type { SwInfoDetail } from "./sw-info.js";
+
+export const HOVER_DELAY_MS = 350;
+
+type Mode = "closed" | "pinned" | "hover";
+
+// The element-reflection shape for cross-tree aria-describedby, feature-detected
+// at use (typescript's DOM lib does not carry it yet).
+interface AriaReflection {
+	ariaDescribedByElements: Element[] | null;
+}
+
+@customElement("sw-infopop")
+export class SwInfopop extends LitElement {
+	static styles = css`
+		:host {
+			position: fixed;
+			left: 0;
+			top: 0;
+			z-index: 100;
+			max-width: 44ch;
+			visibility: hidden;
+			pointer-events: none;
+			box-sizing: border-box;
+			padding: var(--sw-space-sm) var(--sw-space-md);
+			background: var(--sw-bg-raised);
+			border: 1px solid var(--sw-border);
+			color: var(--sw-text-hi);
+			font: var(--sw-type-body) var(--sw-font);
+		}
+	`;
+
+	@state() private content = "";
+	#mode: Mode = "closed";
+	#anchor: HTMLElement | null = null;
+	// The [data-info] carrier currently under the pointer (tracked in every mode,
+	// so hover state stays coherent while a pinned popup has precedence).
+	#candidate: HTMLElement | null = null;
+	#timer: number | undefined;
+	#root: Node | null = null;
+	// Set when an i keydown dismisses a pinned popup: the producer answering that
+	// same keypress will usually dispatch a fresh sw-info for the same anchor
+	// (our capture close runs first), and swallowing that arrival is what makes
+	// i a toggle.  Scoped by the keydown event itself: its eventPhase returns to
+	// NONE once its dispatch ends, so a later i escapes suppression naturally,
+	// with no timers or clearing bookkeeping.
+	#dismissal: { anchor: HTMLElement; keydown: KeyboardEvent } | null = null;
+
+	get mode(): Mode {
+		return this.#mode;
+	}
+	get anchor(): HTMLElement | null {
+		return this.#anchor;
+	}
+
+	connectedCallback(): void {
+		super.connectedCallback();
+		this.setAttribute("role", "tooltip");
+		this.setAttribute("aria-hidden", "true");
+		const root = this.getRootNode();
+		this.#root = root;
+		// Triggers, scoped to the tree this popup serves.  The hover carrier is
+		// tracked from pointermove, NOT pointerover: boundary events are suppressed
+		// at this root whenever a move stays inside one descendant shadow root
+		// (target and relatedTarget both retarget to the same host, so the pair is
+		// never dispatched here) -- a row-to-aux move inside a component would be
+		// invisible.  pointermove has no relatedTarget, is never suppressed, and
+		// its composedPath still names the deep target through open shadow roots.
+		root.addEventListener("sw-info", this.#onSwInfo);
+		root.addEventListener("pointermove", this.#onPointermove);
+		root.addEventListener("pointerout", this.#onPointerout);
+		// Dismissals, global by nature.  Capture phase: closing must precede the
+		// target's own handling, so a close and a re-raise can share one keypress.
+		window.addEventListener("keydown", this.#onKeydown, { capture: true });
+		window.addEventListener("pointerdown", this.#onPointerdown, { capture: true });
+		window.addEventListener("scroll", this.#onScroll, { capture: true, passive: true });
+	}
+
+	disconnectedCallback(): void {
+		super.disconnectedCallback();
+		this.#close();
+		this.#candidate = null;
+		this.#dismissal = null;
+		const root = this.#root;
+		if (root !== null) {
+			root.removeEventListener("sw-info", this.#onSwInfo);
+			root.removeEventListener("pointermove", this.#onPointermove);
+			root.removeEventListener("pointerout", this.#onPointerout);
+			this.#root = null;
+		}
+		window.removeEventListener("keydown", this.#onKeydown, { capture: true });
+		window.removeEventListener("pointerdown", this.#onPointerdown, { capture: true });
+		window.removeEventListener("scroll", this.#onScroll, { capture: true });
+	}
+
+	#onSwInfo = (e: Event): void => {
+		const { content, source } = (e as CustomEvent<SwInfoDetail>).detail;
+		// The toggle's second half: this sw-info answers the very keypress that
+		// just dismissed a pinned popup on the same anchor (that keydown is still
+		// mid-dispatch), so it is a dismissal confirmed, not a request to reopen.
+		const d = this.#dismissal;
+		if (d !== null && d.anchor === source && d.keydown.eventPhase !== Event.NONE) return;
+		this.#cancelTimer();
+		this.#open("pinned", source, content);
+	};
+
+	#onPointermove = (e: Event): void => {
+		this.#trackCarrier(nearestCarrier(e.composedPath()));
+	};
+
+	#onPointerout = (e: Event): void => {
+		// Movement within the served tree is fully handled by pointermove.  The
+		// cases only pointerout can see: the pointer leaving the window entirely
+		// (relatedTarget null), and the pointer leaving for an element OUTSIDE
+		// the tree this popup serves -- no pointermove will reach us from there.
+		const to = (e as PointerEvent).relatedTarget;
+		if (to === null || !this.#inServedTree(to)) this.#trackCarrier(null);
+	};
+
+	// Whether a node lives in the tree this popup serves, shadow-inclusively
+	// (the served root, or any shadow tree nested under it).
+	#inServedTree(target: EventTarget): boolean {
+		if (!(target instanceof Node)) return false;
+		let root: Node | null = target.getRootNode();
+		while (root !== null) {
+			if (root === this.#root) return true;
+			root = root instanceof ShadowRoot ? root.host.getRootNode() : null;
+		}
+		return false;
+	}
+
+	// The carrier under the pointer changed.  Hover popups live and die by it;
+	// pinned popups ignore it entirely.
+	#trackCarrier(carrier: HTMLElement | null): void {
+		if (carrier === this.#candidate) return;
+		this.#candidate = carrier;
+		this.#cancelTimer();
+		if (this.#mode === "hover" && carrier !== this.#anchor) this.#close();
+		if (carrier !== null && !(this.#mode === "hover" && carrier === this.#anchor)) {
+			this.#timer = window.setTimeout(() => {
+				this.#timer = undefined;
+				// A pinned popup outranks hover: skip, and do NOT linger a re-try --
+				// after the pinned one closes, a popup should only appear on a fresh
+				// pointer or key action, never spontaneously.
+				if (this.#mode === "pinned" || carrier !== this.#candidate) return;
+				this.#open("hover", carrier, carrier.dataset.info ?? "");
+			}, HOVER_DELAY_MS);
+		}
+	}
+
+	#onKeydown = (e: KeyboardEvent): void => {
+		// Bare modifiers close nothing (Shift held for a capital must not blink the
+		// popup away); every other key closes.  Almost never consumed or stopped --
+		// the megacolumn (and everything else) hears the key untouched -- with ONE
+		// exception: an Escape that closes the popup is consumed.  Escape unwinds
+		// layers one at a time (the guidelines' focus-lifecycle norm), and the popup
+		// is the innermost layer: the same press must not also close a panel below.
+		// Other closing keys stay unconsumed on purpose (an arrow that dismisses the
+		// popup must still navigate; that is how the popup follows row focus).
+		if (e.key === "Shift" || e.key === "Control" || e.key === "Alt" || e.key === "Meta") return;
+		// The toggle's first half: i closing a pinned popup notes which anchor this
+		// keypress dismissed, so #onSwInfo can tell a same-anchor re-raise (toggle
+		// off) from a different-anchor follow (reopen there).
+		if (this.#mode === "pinned" && this.#anchor !== null && e.key.toLowerCase() === "i") {
+			this.#dismissal = { anchor: this.#anchor, keydown: e };
+		}
+		if (this.#mode !== "closed") {
+			if (e.key === "Escape") e.preventDefault();
+			this.#close();
+		}
+	};
+
+	#onPointerdown = (): void => {
+		if (this.#mode !== "closed") this.#close();
+	};
+
+	#onScroll = (): void => {
+		if (this.#mode !== "closed") this.#close();
+	};
+
+	#open(mode: "pinned" | "hover", anchor: HTMLElement, content: string): void {
+		this.#unwireAria();
+		this.#mode = mode;
+		this.#anchor = anchor;
+		this.content = content;
+		this.#wireAria(anchor);
+		// Re-render unconditionally: content alone may be unchanged while the
+		// anchor moved, and positioning rides the update cycle (see updated()).
+		this.requestUpdate();
+	}
+
+	#close(): void {
+		this.#cancelTimer();
+		this.#unwireAria();
+		this.#mode = "closed";
+		this.#anchor = null;
+		this.requestUpdate();
+	}
+
+	#cancelTimer(): void {
+		if (this.#timer !== undefined) {
+			window.clearTimeout(this.#timer);
+			this.#timer = undefined;
+		}
+	}
+
+	// Positioning happens after render: the content must be laid out before the
+	// popup can be measured.  visibility (not display) hides the closed popup,
+	// so measurement works before the reveal.
+	protected updated(): void {
+		if (this.#mode === "closed" || this.#anchor === null) {
+			this.style.visibility = "";
+			this.setAttribute("aria-hidden", "true");
+			return;
+		}
+		const a = this.#anchor.getBoundingClientRect();
+		const self = this.getBoundingClientRect();
+		const p = placeInfopop(
+			{ x: a.x, y: a.y, w: a.width, h: a.height },
+			{ w: self.width, h: self.height },
+			{ w: window.innerWidth, h: window.innerHeight },
+		);
+		this.style.left = `${p.x}px`;
+		this.style.top = `${p.y}px`;
+		this.style.visibility = "visible";
+		this.removeAttribute("aria-hidden");
+	}
+
+	#wireAria(anchor: HTMLElement): void {
+		if (!("ariaDescribedByElements" in anchor)) return;
+		(anchor as unknown as AriaReflection).ariaDescribedByElements = [this];
+	}
+
+	#unwireAria(): void {
+		const prev = this.#anchor;
+		if (prev === null || !("ariaDescribedByElements" in prev)) return;
+		(prev as unknown as AriaReflection).ariaDescribedByElements = null;
+	}
+
+	render(): TemplateResult {
+		return html`${this.content}`;
+	}
+}
+
+// The nearest [data-info] carrier in a composed event path.  Nearest-wins gives
+// nesting for free: an aux x inside an info-bearing row shows its own story.
+function nearestCarrier(path: EventTarget[]): HTMLElement | null {
+	for (const t of path) {
+		if (t instanceof HTMLElement && (t.dataset.info ?? "") !== "") return t;
+	}
+	return null;
+}
+
+declare global {
+	interface HTMLElementTagNameMap {
+		"sw-infopop": SwInfopop;
+	}
+}
diff --git a/packages/yukey/src/sw-list.gallery.test.ts b/packages/yukey/src/sw-list.gallery.test.ts
new file mode 100644
index 0000000..5fa3731
--- /dev/null
+++ b/packages/yukey/src/sw-list.gallery.test.ts
@@ -0,0 +1,198 @@
+// @vitest-environment jsdom
+//
+// Tests for the sw-list gallery demo AS A CONSUMER of the component: the owner's
+// commit handler (a cancelable sw-list-add append), the task-key routing that must
+// skip sw-list's structural rows, and the composed behaviors only a full console
+// exhibits (the churn lockout swallowing t mid-fallback).  The norm MECHANICS --
+// dive/commit/abandon, t/n resolution, group identity -- are sw-list's own and are
+// pinned in sw-list.test.ts; this file deliberately does not re-test them.
+//
+// jsdom note: real browsers fire `change` on blur-with-changed-value and both
+// text-mode exits blur the input, so the abandon test dispatches the change event
+// by hand at exactly the moment a real browser would -- proving the demo's data is
+// untouched even then.  The trusted-input version runs in lc-drive.py.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import "./sw-list.gallery.js";
+import type { SwList } from "./sw-list.js";
+import { installTokens } from "./tokens.js";
+
+// The task removal path flashes the x (press-flash resolves --sw-press-ms from
+// the live cascade and throws when it can't), so the tokens must be installed.
+installTokens();
+
+interface Demo extends HTMLElement {
+	updateComplete: Promise<boolean>;
+}
+
+// jsdom has no matchMedia (sw-live-list asks about prefers-reduced-motion) and
+// implements no scrollIntoView (the controller scrolls on focus identity change).
+beforeEach(() => {
+	vi.useFakeTimers();
+	window.matchMedia ??= (query: string) => ({ matches: false, media: query }) as MediaQueryList;
+});
+afterEach(() => {
+	vi.useRealTimers();
+	document.body.replaceChildren();
+});
+Element.prototype.scrollIntoView = (): void => {};
+
+interface Rig {
+	demo: Demo;
+	root: ShadowRoot;
+	container: HTMLElement;
+	list: SwList;
+	input: HTMLInputElement;
+	key: (key: string, target?: HTMLElement) => KeyboardEvent;
+	focusedRow: () => string | null;
+	taskLabels: () => string[];
+	settle: () => Promise<void>;
+}
+
+// The drain variant (no refill attribute): starts empty, and its drain interval
+// never fires under fake timers, so every task in the list is one a test put there.
+async function makeRig(): Promise<Rig> {
+	const demo = document.createElement("swg-livecolumn-demo") as Demo;
+	document.body.appendChild(demo);
+	await demo.updateComplete;
+	const root = demo.shadowRoot;
+	if (root === null) throw new Error("demo did not render a shadow root");
+	const container = root.querySelector<HTMLElement>("[data-mc-container]");
+	const list = root.querySelector<SwList>("sw-list");
+	if (container === null || list === null) throw new Error("demo missing container or sw-list");
+	await list.updateComplete;
+	const input = list.querySelector<HTMLInputElement>("[data-mc-input]");
+	if (input === null) throw new Error("demo missing the add input");
+	// The task-removal path flashes the x (press-flash resolves --sw-press-ms from
+	// the live cascade and throws when it can't), and jsdom does not carry custom
+	// properties from the document's token stylesheet across the demo's shadow
+	// boundary -- so seed the property inline, inside the shadow tree.
+	container.style.setProperty("--sw-press-ms", "140ms");
+	const key = (k: string, target: HTMLElement = container): KeyboardEvent => {
+		const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true });
+		target.dispatchEvent(e);
+		return e;
+	};
+	const focusedRow = (): string | null =>
+		root.querySelector<HTMLElement>("[data-mc-row].mc-row-focused")?.dataset.mcRow ?? null;
+	const taskLabels = (): string[] =>
+		[...list.querySelectorAll("[data-mc-row] .task .grow")].map((el) => el.textContent ?? "");
+	const settle = async (): Promise<void> => {
+		await demo.updateComplete;
+		await list.updateComplete;
+	};
+	return { demo, root, container, list, input, key, focusedRow, taskLabels, settle };
+}
+
+// Seed one task through the add-row (the drain queue starts empty) and land row
+// focus back on it.
+const seedTask = async (rig: Rig, label: string): Promise<void> => {
+	rig.key("a"); // sw-list's marked direct hotkey: dives into text entry
+	rig.input.value = label;
+	rig.key("Enter", rig.input);
+	await rig.settle();
+};
+
+describe("the demo as an sw-list consumer", () => {
+	it("composes the console: prepend row, sw-list (header first), status row after", async () => {
+		const { container, key, focusedRow } = await makeRig();
+		key("Home");
+		expect(focusedRow()).toBe("prepend");
+		key("ArrowDown");
+		expect(focusedRow()).toBe("queue-header"); // sw-list's structural id: name="queue"
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-queue-header");
+		key("End");
+		expect(focusedRow()).toBe("status");
+	});
+
+	it("commit appends a player task at the tail and logs it", async () => {
+		const rig = await makeRig();
+		await seedTask(rig, "first");
+		rig.key("Enter"); // still on the add-row: re-dive
+		rig.input.value = "second";
+		rig.key("Enter", rig.input);
+		await rig.settle();
+		expect(rig.taskLabels()).toEqual(["first", "second"]);
+		const rows = [...rig.list.querySelectorAll<HTMLElement>("sw-live-list [data-mc-row]")];
+		expect(rows[1]?.querySelector(".origin")?.classList.contains("player")).toBe(true);
+		expect(rig.root.querySelector(".log")?.textContent).toBe("second appended");
+	});
+
+	it("Escape abandons: the demo's data is untouched, even through the blur-change", async () => {
+		const rig = await makeRig();
+		const logBefore = rig.root.querySelector(".log")?.textContent;
+		rig.key("a");
+		rig.input.value = "changed my mind";
+		rig.key("Escape", rig.input);
+		// The blur-change trap: a change-driven commit would append here.
+		rig.input.dispatchEvent(new Event("change", { bubbles: true }));
+		await rig.settle();
+		expect(rig.taskLabels()).toEqual([]);
+		expect(rig.root.querySelector(".log")?.textContent).toBe(logBefore);
+	});
+
+	it("task keys skip sw-list's structural rows (header and add-row decline quietly)", async () => {
+		const rig = await makeRig();
+		const logBefore = rig.root.querySelector(".log")?.textContent;
+		rig.key("Home");
+		rig.key("ArrowDown"); // the header
+		expect(rig.focusedRow()).toBe("queue-header");
+		rig.key("x"); // must not trip the task handler's ghost path ("already gone")
+		expect(rig.root.querySelector(".log")?.textContent).toBe(logBefore);
+		rig.key("End");
+		rig.key("ArrowUp"); // the add-row (row focus, not text mode)
+		expect(rig.focusedRow()).toBe("queue-add");
+		rig.key("x");
+		expect(rig.root.querySelector(".log")?.textContent).toBe(logBefore);
+	});
+
+	it("x on a task row removes it (the owner's key semantics ride through sw-list)", async () => {
+		const rig = await makeRig();
+		await seedTask(rig, "doomed");
+		rig.key("t"); // sw-list's jump to the header
+		rig.key("ArrowDown"); // the task
+		expect(rig.focusedRow()).toBe("t1");
+		rig.key("x");
+		await rig.settle();
+		expect(rig.root.querySelector(".log")?.textContent).toBe("doomed removed");
+	});
+
+	it("t and n work through the demo layout (t to header, n to the status row)", async () => {
+		const rig = await makeRig();
+		await seedTask(rig, "a task");
+		rig.key("t");
+		expect(rig.focusedRow()).toBe("queue-header");
+		rig.key("n");
+		expect(rig.focusedRow()).toBe("status");
+		const e = rig.key("t"); // outside the list: nobody's letter
+		expect(e.defaultPrevented).toBe(false);
+		expect(rig.focusedRow()).toBe("status");
+	});
+
+	it("the churn lockout swallows t mid-fallback and t flows again after", async () => {
+		// Composed behavior only the full console shows: a collapse under focus
+		// moves focus involuntarily, the lockout consumes forward routings, and
+		// t rides the forward routing -- so it is dead for the window, alive after.
+		// (Deliberate wrinkle, pinned in the playtest stage; see the controller header.)
+		const rig = await makeRig();
+		await seedTask(rig, "first");
+		rig.key("Enter");
+		rig.input.value = "second";
+		rig.key("Enter", rig.input);
+		await rig.settle();
+		rig.key("t");
+		rig.key("ArrowDown");
+		expect(rig.focusedRow()).toBe("t1");
+		rig.key("x"); // remove it: ghosts now, collapses at holdMs
+		await rig.settle();
+		vi.advanceTimersByTime(1201); // hold (1200) expires, the collapse begins
+		await rig.settle(); // aria-hidden lands, rescan runs, the fallback moves focus
+		expect(rig.focusedRow()).toBe("t2");
+		rig.key("t"); // in the lockout window: swallowed, no jump
+		expect(rig.focusedRow()).toBe("t2");
+		vi.advanceTimersByTime(351); // the lockout window passes
+		await rig.settle();
+		rig.key("t"); // ... and t flows again
+		expect(rig.focusedRow()).toBe("queue-header");
+	});
+});
diff --git a/packages/yukey/src/sw-list.gallery.ts b/packages/yukey/src/sw-list.gallery.ts
new file mode 100644
index 0000000..1e587c8
--- /dev/null
+++ b/packages/yukey/src/sw-list.gallery.ts
@@ -0,0 +1,406 @@
+// Gallery entry for the live megacolumn: sw-list (the list region: header row,
+// live rows, add-row, t/n) composed into a megacolumn console.  This demo is the
+// rehearsal for the infocard's task queue: a queue that churns on its own (heads
+// complete, assignments refill) AND under player command (prepend, remove, append),
+// fully keyboard-drivable, with focus surviving every kind of row death.  The demo
+// is deliberately a THIN consumer: it owns the data, the row rendering, and what
+// the keys MEAN on task rows; sw-list owns the list norms (sw-list.ts holds that
+// contract); sw-live-list under it owns row lifecycle.  Copy this shape at
+// migration time.
+
+import { css, html, LitElement, nothing, type TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { classMap } from "lit/directives/class-map.js";
+import type { GalleryEntry } from "./gallery/entry.js";
+import {
+	MegacolumnController,
+	type MegacolumnKeyDetail,
+	megacolumnStyles,
+} from "./megacolumn-controller.js";
+import { flashPress } from "./press-flash.js";
+import type { SwListAddDetail } from "./sw-list.js";
+import type { LiveListPhase } from "./sw-live-list.js";
+import "./sw-list.js";
+import "./sw-button.js";
+
+interface QueueTask {
+	id: string;
+	label: string;
+	origin: "player" | "assignment";
+}
+
+const taskKey = (t: QueueTask): string => t.id;
+
+// The task-queue console: one megacolumn whose lower section is a live list.
+// The list stamps row identity itself (sw-live-list.ts holds that contract);
+// this owner renders row CONTENT (main/aux/data-info) and gives the keys meaning.
+@customElement("swg-livecolumn-demo")
+export class SwgLivecolumnDemo extends LitElement {
+	static styles = [
+		megacolumnStyles,
+		css`
+			:host {
+				display: block;
+			}
+			[data-mc-container] {
+				border: 1px solid var(--sw-border-dim);
+				padding: var(--sw-space-md);
+			}
+			.title {
+				color: var(--sw-text-dim);
+				font-size: var(--sw-type-small);
+				letter-spacing: var(--sw-tracking-wide);
+				text-transform: uppercase;
+				margin-bottom: var(--sw-space-sm);
+			}
+			.section {
+				color: var(--sw-text-faint);
+				font-size: var(--sw-type-micro);
+				letter-spacing: var(--sw-tracking-wide);
+				text-transform: uppercase;
+				margin: var(--sw-space-xl) 0 var(--sw-space-xs);
+			}
+			.section:first-of-type {
+				margin-top: 0;
+			}
+			/* The list norms (header wash, ghosted add-row, hotkey gold) are sw-list's
+			   own styles now; this sheet only spaces the list within the console. */
+			sw-list {
+				margin-top: var(--sw-space-xl);
+			}
+			[data-mc-row] {
+				min-height: var(--sw-hit-min-h);
+			}
+			.action-row {
+				display: flex;
+				align-items: center;
+				padding: 2px var(--sw-space-sm);
+			}
+			.action-row sw-button {
+				flex: 1;
+			}
+			/* Task-row content (rendered by the live list into this shadow root,
+			   so this stylesheet reaches it directly -- no itemStyles hop). */
+			.task {
+				display: flex;
+				align-items: center;
+				gap: var(--sw-space-sm);
+				min-height: var(--sw-hit-min-h);
+				padding: 2px var(--sw-space-sm);
+				white-space: nowrap;
+			}
+			.task.committed {
+				opacity: 0.45;
+			}
+			.task .origin {
+				flex: none;
+				width: 88px;
+				font-size: var(--sw-type-small);
+				color: var(--sw-text-faint);
+			}
+			.task .origin.player {
+				color: var(--sw-good);
+			}
+			.task .grow {
+				flex: 1;
+				overflow: hidden;
+				text-overflow: ellipsis;
+			}
+			/* The ✓ occupies the same box the × does in live rows, so the glyph
+			   column holds steady as rows commit (same size, same centering). */
+			/* The glyph box: one element across phases (× live, ✓ committed), so an
+			   acknowledgment flash on it survives the phase flip.  Same box either way:
+			   the column holds steady as rows commit.  All rules keyed to the CLASS,
+			   not [data-mc-aux]: the ghost drops that attribute in the same re-render,
+			   and attribute-keyed styling would un-match mid-flash. */
+			.task .glyph {
+				color: var(--sw-danger);
+				border: 1px solid transparent;
+				min-width: var(--sw-hit-min-w);
+				min-height: var(--sw-hit-min-h);
+				display: inline-flex;
+				align-items: center;
+				justify-content: center;
+				box-sizing: border-box;
+			}
+			.task .glyph.gone {
+				color: var(--sw-good);
+			}
+			/* The aux's own pressed look: danger-family, not the blue activation wash --
+			   ownership distinguishes the flashes, no enumeration of activation kinds. */
+			.task .glyph.sw-pressed {
+				background: hsl(from var(--sw-danger) h calc(s * 2) calc(l * 0.75) / 0.6);
+				color: var(--sw-text-bright);
+			}
+			.idle {
+				color: var(--sw-text-faint);
+				padding: 2px var(--sw-space-sm);
+				line-height: var(--sw-hit-min-h);
+			}
+			.status-row {
+				display: flex;
+				align-items: center;
+				color: var(--sw-text-dim);
+				padding: 2px var(--sw-space-sm);
+				margin-top: var(--sw-space-xl);
+			}
+			.log {
+				margin-top: var(--sw-space-md);
+				color: var(--sw-good);
+				font-size: var(--sw-type-ui);
+				min-height: 1.4em;
+			}
+		`,
+	];
+
+	// biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController
+	private mc = new MegacolumnController(this);
+
+	@property() label = "ops queue";
+	// Refill keeps the queue steady (a completed head is replaced); without it the
+	// queue drains to the empty state and only player prepends fill it.
+	@property({ type: Boolean }) refill = false;
+
+	@state() private tasks: QueueTask[] = [];
+	@state() private lastAction = "";
+	private seq = 0;
+	private timer: number | undefined;
+
+	connectedCallback(): void {
+		super.connectedCallback();
+		if (this.refill) {
+			this.tasks = [this.newTask(), this.newTask(), this.newTask(), this.newTask()];
+			this.timer = window.setInterval(() => this.churn(), 4000);
+		} else {
+			// Drain mode: heads still complete, nothing refills.
+			this.timer = window.setInterval(() => {
+				this.tasks = this.tasks.slice(1);
+			}, 4000);
+		}
+	}
+
+	disconnectedCallback(): void {
+		super.disconnectedCallback();
+		window.clearInterval(this.timer);
+	}
+
+	private newTask(origin: "player" | "assignment" = "assignment"): QueueTask {
+		this.seq += 1;
+		const chores =
+			origin === "player"
+				? ["survey the anomaly", "escort the hauler", "scout the far gate"]
+				: ["move to waypoint", "load ore", "unload struts", "dock", "refuel"];
+		return {
+			id: `t${this.seq}`,
+			label: `${chores[this.seq % chores.length]} (#${this.seq})`,
+			origin,
+		};
+	}
+
+	// One churn beat: the head completes (leaves the data -> ghosts) and new
+	// assignment work arrives at the tail.
+	private churn(): void {
+		this.tasks = [...this.tasks.slice(1), this.newTask()];
+	}
+
+	private prependTask(): void {
+		const task = this.newTask("player");
+		this.tasks = [task, ...this.tasks];
+		this.lastAction = `${task.label} prepended`;
+	}
+
+	// The add-row's commit, arriving as sw-list's cancelable sw-list-add event
+	// (sw-list owns the dive/abandon/ghost mechanics and the nothing-syncs-until-
+	// commit contract; the owner just appends).  Not cancelled here: any trimmed
+	// non-empty text is a legal task.
+	private onListAdd(e: Event): void {
+		const { text } = (e as CustomEvent<SwListAddDetail>).detail;
+		this.seq += 1;
+		this.tasks = [...this.tasks, { id: `t${this.seq}`, label: text, origin: "player" }];
+		this.lastAction = `${text} appended`;
+	}
+
+	// The queue rows' key semantics (the prepend row's keys are sw-button's own
+	// business -- direct-marked, it claims them before this handler could matter):
+	// x/Delete from anywhere in the row removes; Enter/Space on the aux removes;
+	// Enter/Space on main inspects (activation must not be destructive).
+	private onMegacolumnKey(e: Event): void {
+		const { detail } = e as CustomEvent<MegacolumnKeyDetail>;
+		// Only task rows have task semantics: sw-list's structural rows (the
+		// "<name>-header"/"<name>-add" id scheme is its contract) and the demo's
+		// own prepend/status rows decline everything here (the add-row's typing
+		// never reaches this handler; text entry dominates).
+		if (
+			detail.rowId === "prepend" ||
+			detail.rowId === "queue-header" ||
+			detail.rowId === "queue-add" ||
+			detail.rowId === "status"
+		)
+			return;
+		const task = this.tasks.find((t) => t.id === detail.rowId);
+		const activate = detail.key === "Enter" || detail.key === " ";
+		const remove =
+			detail.key === "x" || detail.key === "Delete" || (activate && detail.position === "aux");
+		if (remove) {
+			// A ghost's task is already out of the data: removing again is a no-op,
+			// and saying so quietly beats pretending something happened.
+			this.lastAction = task === undefined ? "already gone" : `${task.label} removed`;
+			if (task !== undefined) {
+				// Acknowledge on the aux x itself -- the control whose activation removal
+				// IS -- in its own danger-tinted pressed style.  The glyph element survives
+				// the phase flip to ✓ (see renderTask), so the flash paints through it.
+				const aux =
+					e.target instanceof Element
+						? e.target.closest("[data-mc-row]")?.querySelector("[data-mc-aux]")
+						: null;
+				if (aux instanceof HTMLElement) flashPress(aux);
+				this.tasks = this.tasks.filter((t) => t.id !== task.id);
+			}
+			e.preventDefault();
+			return;
+		}
+		if (activate) {
+			this.lastAction =
+				task === undefined ? "inspected a completed task" : `${task.label} inspected`;
+			e.preventDefault();
+			// Acknowledge on the row's main control (inside the focused row).
+			if (e.target instanceof HTMLElement) flashPress(e.target);
+		}
+	}
+
+	// Row renderer, handed to the live list.  The committed variant drops the aux
+	// control: a ghost is not removable (it is already gone from the data), and
+	// the list's scan-visible hasAux falls out of that automatically.
+	// One template for both phases, ON PURPOSE: the glyph span is the same element
+	// whether it shows the live × or the committed ✓, so an acknowledgment flash
+	// stamped on it survives the phase flip and paints (a per-phase template swap
+	// would destroy the very control being acknowledged -- the composition goof
+	// this demo exists to not make).  data-mc-aux is conditionally present: ghosts
+	// drop the attribute (not the element), so the megacolumn scan still sees them
+	// as aux-less and the attribute-keyed styling flips with it.
+	private renderTask = (t: QueueTask, phase: LiveListPhase): TemplateResult => {
+		const done = phase === "committed";
+		return html`<div class=${classMap({ task: true, committed: done })}>
+			<span class="origin ${t.origin}">${t.origin}</span>
+			<span class="grow" data-mc-main data-info=${
+				done
+					? "a completed task, held briefly; Enter still inspects it"
+					: "a queued task; Enter inspects it"
+			}>${t.label}</span>
+			<span class=${classMap({ glyph: true, gone: done })}
+				data-mc-aux=${done ? nothing : ""}
+				title=${done ? nothing : "remove"}
+				data-info=${done ? nothing : "removes this task (x or Delete from anywhere in the row)"}
+			>${done ? "✓" : "×"}</span>
+		</div>`;
+	};
+
+	private renderQueueEmpty = (): TemplateResult =>
+		html`<div class="idle">queue empty (prepend something)</div>`;
+
+	render(): TemplateResult {
+		return html`
+			<div class="title">${this.label}</div>
+			<div
+				data-mc-container
+				aria-label=${this.label}
+				@megacolumn-key=${this.onMegacolumnKey}
+			>
+				<div class="section">orders</div>
+				<div data-mc-row="prepend" class="action-row">
+					<!-- Direct-marked (the deliberate exception to hotkeys-move-focus):
+					     a prepend is cheaply undone -- x removes the new head -- so the
+					     see-before-commit step would only slow the order down. -->
+					<sw-button
+						label="prepend priority task"
+						hotkey="p"
+						hotkey-direct
+						info="queues a player task at the HEAD of the queue; watch it appear instantly even mid-keyboard-burst"
+						@click=${() => this.prependTask()}
+					></sw-button>
+				</div>
+
+				<!-- The list region: sw-list owns the norms (header row speaking for the
+				     region, ghosted add-row with the marked direct a, t/n jumps); this
+				     owner supplies data, row rendering, and the commit handler.  The
+				     norm teaching in the info texts is sw-list's own appendix. -->
+				<sw-list
+					name="queue"
+					header="task queue (live)"
+					header-info="the task queue; every row is removable -- x or Delete"
+					addable
+					add-info="appends a task at the tail"
+					.items=${this.tasks}
+					.keyOf=${taskKey}
+					.renderItem=${this.renderTask}
+					.renderEmpty=${this.renderQueueEmpty}
+					@sw-list-add=${this.onListAdd}
+				></sw-list>
+
+				<!-- A stop AFTER the list, so n has somewhere to land (and the demo
+				     shows what n is for: hopping past a long list without arrowing through
+				     it).  A plain informational row; no task semantics. -->
+				<div
+					data-mc-row="status"
+					class="status-row"
+					data-info="queue telemetry; n from anywhere in the list above jumps straight here"
+				>
+					telemetry: queue nominal
+				</div>
+			</div>
+			<div class="log">${this.lastAction}</div>
+		`;
+	}
+}
+
+const entry: GalleryEntry = {
+	id: "sw-list",
+	title: "sw-list: the live megacolumn's list region",
+	description:
+		"The list region component, composed here into a megacolumn console: header row, live rows " +
+		"(sw-live-list inside), ghosted add-row, and the t/n list-scoped jumps -- the list norms as " +
+		"things the component does.  The queue churns and stays fully keyboard-drivable: ghost rows " +
+		"stay navigable while they hold (a stationary ghost is safe for the keyboard exactly as it " +
+		"is for the mouse); collapsing rows leave navigation and focus falls to a neighbor under a " +
+		"brief decisive-key lockout; key activity pins the FOCUSED ghost's geometry (the keyboard " +
+		"aims by identity, so only the aimed-at row needs holding still).  The mouse side is the " +
+		"default click-guard: motion proceeds under the pointer, and only a click on a row that is " +
+		"mid-displacement dies quietly.  This is the rehearsal for the infocard's task queue.",
+	variants: [
+		{
+			caption:
+				"A steady queue: the head completes every few seconds (ghosts ✓, holds, collapses) and new " +
+				"assignment work arrives.  Try: arrow onto a task and just WORK it -- the ghost under your " +
+				"focus holds while you keep pressing keys (the key-activity pin), and collapses moments after " +
+				"you pause; ghosts you are NOT on collapse on their own schedule, each at its own moment.  " +
+				"Removals flash the × itself, danger-tinted, before the row ghosts (x/Delete removes a task from anywhere in its row; " +
+				"right-arrow reaches the ×; Enter on a ghost still inspects it), and if focus was on a ghost " +
+				"when it collapsed, focus falls to the next row under a brief decisive-key lockout -- hammer " +
+				"Enter across the collapse and note nothing mis-fires.  The LIST NORMS are sw-list's own " +
+				"behavior now: a header row (arrow onto it and the " +
+				"whole region washes -- group identity without a nested container; i there describes the list as " +
+				"a list) and the add-row at the tail.  Two ways to add, both earning the direct marking under " +
+				"the easy-undo doctrine: the prepend " +
+				"button is hotkey-direct -- the exception to letters-move-focus: p acts immediately from any row " +
+				"(focus following to the orders row), justified because a prepend is cheaply undone -- x the new " +
+				"head and it is gone.  The add-row's a dives straight into typing (direct on a text row means " +
+				"what Enter means there), justified because nothing touches the queue until the Enter commit " +
+				"(the owner hears a cancelable sw-list-add and appends; watch the task animate in) and Escape " +
+				"abandons the draft.  While focus is anywhere " +
+				"in the list -- header, tasks, add-row -- t jumps back up to the header and n jumps past the " +
+				"list to the next stop after it: list-scoped keys sw-list wires itself, deliberately not " +
+				"gold-marked (they mean the same thing in every list).",
+			render: () => html`<swg-livecolumn-demo refill></swg-livecolumn-demo>`,
+		},
+		{
+			caption:
+				"Drain to empty: nothing refills this queue.  Prepend a few tasks (p), watch them complete and " +
+				"drain away; the empty state is the owner's renderEmpty render-prop (mega mode has no slots).  " +
+				"Focus stays sane through the last row's death: the column itself keeps focus and the arrows " +
+				"revive it the moment a row exists again.",
+			render: () => html`<swg-livecolumn-demo label="drain queue"></swg-livecolumn-demo>`,
+		},
+	],
+};
+
+export default entry;
diff --git a/packages/yukey/src/sw-list.test.ts b/packages/yukey/src/sw-list.test.ts
new file mode 100644
index 0000000..c49f0e2
--- /dev/null
+++ b/packages/yukey/src/sw-list.test.ts
@@ -0,0 +1,528 @@
+// @vitest-environment jsdom
+//
+// Tests for sw-list: the list region component (header row, live rows, add-row,
+// t/n list-scoped keys).  The rig is a minimal owner: a Lit host with a
+// MegacolumnController, one row before the list, one after (n's landing), and
+// an sw-list whose knobs the tests flip per case.  The add-row's commit/abandon
+// distinction is the subtle part: real browsers fire `change` on
+// blur-with-changed-value and BOTH text-mode exits blur the input, so a commit
+// hooked to @change would fire on Escape too -- jsdom fires no change on its
+// own, so the abandon test dispatches one by hand at exactly the moment a real
+// browser would.  The trusted-input version of the story runs in lc-drive.py.
+
+import { html, LitElement, nothing, type TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { MegacolumnController, megacolumnStyles } from "./megacolumn-controller.js";
+import type { SwList, SwListAddDetail } from "./sw-list.js";
+import type { LiveListPhase } from "./sw-live-list.js";
+import "./sw-list.js";
+
+interface Item {
+	id: string;
+	label: string;
+}
+
+// jsdom has no matchMedia (sw-live-list asks about prefers-reduced-motion) and
+// implements no scrollIntoView (the controller scrolls on focus identity change).
+beforeEach(() => {
+	vi.useFakeTimers();
+	window.matchMedia ??= (query: string) => ({ matches: false, media: query }) as MediaQueryList;
+});
+afterEach(() => {
+	vi.useRealTimers();
+	document.body.replaceChildren();
+});
+Element.prototype.scrollIntoView = (): void => {};
+
+// The minimal owner.  `withAfter` controls whether n has a landing; the sw-list
+// knobs pass straight through.
+@customElement("swt-list-owner")
+class SwtListOwner extends LitElement {
+	static styles = [megacolumnStyles];
+	// biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController
+	private mc = new MegacolumnController(this);
+	@property({ attribute: false }) items: Item[] = [];
+	@property() header = "the test list";
+	@property({ attribute: false }) renderHeader?: () => TemplateResult;
+	@property({ type: Boolean }) addable = true;
+	@property({ type: Boolean }) withAfter = true;
+
+	render(): TemplateResult {
+		return html`<div data-mc-container aria-label="test column">
+			<div data-mc-row="before" data-info="the row before the list">before</div>
+			<sw-list
+				name="tl"
+				header=${this.header}
+				header-info="a list of test items"
+				.renderHeader=${this.renderHeader}
+				?addable=${this.addable}
+				add-info="appends a test item"
+				.items=${this.items}
+				.keyOf=${(it: Item) => it.id}
+				.renderItem=${(it: Item, _phase: LiveListPhase) =>
+					html`<div class="row-content">${it.label}</div>`}
+			></sw-list>
+			${this.withAfter ? html`<div data-mc-row="after">after</div>` : nothing}
+		</div>`;
+	}
+}
+
+interface Rig {
+	owner: SwtListOwner;
+	root: ShadowRoot;
+	container: HTMLElement;
+	list: SwList<Item>;
+	input: HTMLInputElement | null;
+	key: (key: string, target?: HTMLElement) => KeyboardEvent;
+	focusedRow: () => string | null;
+	adds: SwListAddDetail[];
+	settle: () => Promise<void>;
+}
+
+async function makeRig(setup?: (owner: SwtListOwner) => void): Promise<Rig> {
+	const owner = document.createElement("swt-list-owner") as SwtListOwner;
+	setup?.(owner);
+	document.body.appendChild(owner);
+	await owner.updateComplete;
+	const root = owner.shadowRoot;
+	if (root === null) throw new Error("owner did not render a shadow root");
+	const container = root.querySelector<HTMLElement>("[data-mc-container]");
+	const list = root.querySelector<SwList<Item>>("sw-list");
+	if (container === null || list === null) throw new Error("rig missing container or sw-list");
+	await list.updateComplete;
+	const input = list.querySelector<HTMLInputElement>("[data-mc-input]");
+	const adds: SwListAddDetail[] = [];
+	list.addEventListener("sw-list-add", (e) => {
+		adds.push((e as CustomEvent<SwListAddDetail>).detail);
+	});
+	const key = (k: string, target: HTMLElement = container): KeyboardEvent => {
+		const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true });
+		target.dispatchEvent(e);
+		return e;
+	};
+	const focusedRow = (): string | null =>
+		root.querySelector<HTMLElement>("[data-mc-row].mc-row-focused")?.dataset.mcRow ?? null;
+	const settle = async (): Promise<void> => {
+		await owner.updateComplete;
+		await list.updateComplete;
+	};
+	return { owner, root, container, list, input, key, focusedRow, adds, settle };
+}
+
+const items = (...ids: string[]): Item[] => ids.map((id) => ({ id, label: `item ${id}` }));
+
+describe("structure", () => {
+	it("renders header row, live list, and add-row inside the element, ids from name", async () => {
+		const { list } = await makeRig();
+		const rows = [...list.querySelectorAll<HTMLElement>("[data-mc-row]")];
+		expect(rows.map((r) => r.dataset.mcRow)).toEqual(["tl-header", "tl-add"]);
+		expect(list.querySelector("sw-live-list")).not.toBeNull();
+		expect(list.querySelector(".sw-list-header")?.textContent).toBe("the test list");
+	});
+
+	it("passes items through to the inner sw-live-list (rows render, keyed)", async () => {
+		const { owner, list, settle } = await makeRig((o) => {
+			o.items = items("a", "b");
+		});
+		await settle();
+		const live = list.querySelector("sw-live-list");
+		await (live as LitElement | null)?.updateComplete;
+		const labels = [...list.querySelectorAll(".row-content")].map((el) => el.textContent);
+		expect(labels).toEqual(["item a", "item b"]);
+		owner.items = items("a", "b", "c");
+		await settle();
+		await (live as LitElement | null)?.updateComplete;
+		expect(list.querySelectorAll(".row-content").length).toBe(3);
+	});
+
+	it("headerless: no header row; not addable: no add-row", async () => {
+		const { list } = await makeRig((o) => {
+			o.header = "";
+			o.addable = false;
+		});
+		expect(list.querySelectorAll("[data-mc-row]").length).toBe(0);
+		expect(list.querySelector("sw-live-list")).not.toBeNull();
+	});
+
+	it("the header row is a nav stop the controller sees (rescan announced)", async () => {
+		const { container, key, focusedRow } = await makeRig();
+		key("Home");
+		expect(focusedRow()).toBe("before");
+		key("ArrowDown");
+		expect(focusedRow()).toBe("tl-header");
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-tl-header");
+	});
+
+	it("announces its own rescan: a structural row appearing without a live-list update", async () => {
+		// Toggling addable re-renders sw-list but not the inner sw-live-list (the
+		// items reference is unchanged, so its properties report no change) -- the
+		// add-row's appearance is on sw-list's OWN announcement, nobody else's.
+		const { list, key, focusedRow, settle } = await makeRig((o) => {
+			o.addable = false;
+		});
+		key("End");
+		expect(focusedRow()).toBe("after");
+		list.addable = true;
+		await settle();
+		key("End");
+		key("ArrowUp");
+		expect(focusedRow()).toBe("tl-add"); // the fresh add-row is a nav stop
+	});
+
+	it("composes owner info with the norm teaching on both structural rows", async () => {
+		const { list } = await makeRig();
+		const headerInfo = list.querySelector<HTMLElement>(".sw-list-header")?.dataset.info ?? "";
+		expect(headerInfo).toContain("a list of test items");
+		expect(headerInfo).toContain("t returns here");
+		expect(headerInfo).toContain("n jumps past the list");
+		expect(headerInfo).toContain("a dives into the add-row");
+		const addInfo = list.querySelector<HTMLElement>(".sw-list-add")?.dataset.info ?? "";
+		expect(addInfo).toContain("appends a test item");
+		expect(addInfo).toContain("Escape abandons");
+	});
+
+	it("marks the add hotkey letter in the label", async () => {
+		const { list } = await makeRig();
+		const hk = list.querySelector(".sw-list-add .sw-list-hk");
+		expect(hk?.textContent).toBe("a");
+		const marked = list.querySelector<HTMLElement>("[data-mc-hotkey]");
+		expect(marked?.dataset.mcHotkey).toBe("a");
+		expect(marked?.hasAttribute("data-mc-hotkey-direct")).toBe(true);
+	});
+});
+
+describe("owner-rendered header content (renderHeader)", () => {
+	it("renderHeader replaces the header row's visible content; the row stays the nav stop", async () => {
+		const { list, container, key, focusedRow } = await makeRig((o) => {
+			o.renderHeader = () => html`<div class="fancy">THE LIST · 3 / 9</div>`;
+		});
+		const headerRow = list.querySelector<HTMLElement>(".sw-list-header");
+		expect(headerRow?.querySelector(".fancy")?.textContent).toBe("THE LIST · 3 / 9");
+		expect(headerRow?.dataset.mcRow).toBe("tl-header");
+		key("Home");
+		key("ArrowDown");
+		expect(focusedRow()).toBe("tl-header");
+		expect(container.getAttribute("aria-activedescendant")).toBe("mc-tl-header");
+	});
+
+	it("the header string still names the group; renderHeader only changes the visible content", async () => {
+		const { list } = await makeRig((o) => {
+			o.renderHeader = () => html`<div>totally different content</div>`;
+		});
+		expect(list.getAttribute("role")).toBe("group");
+		expect(list.getAttribute("aria-label")).toBe("the test list");
+	});
+
+	it("headerless stays headerless: renderHeader without a header renders no row", async () => {
+		const { list } = await makeRig((o) => {
+			o.header = "";
+			o.renderHeader = () => html`<div class="fancy">orphan content</div>`;
+		});
+		expect(list.querySelector(".sw-list-header")).toBeNull();
+		expect(list.querySelector(".fancy")).toBeNull();
+	});
+});
+
+describe("group identity (the header-role decision)", () => {
+	it("with a header: role=group and an aria-label mirroring it; header stays an option", async () => {
+		const { list } = await makeRig();
+		expect(list.getAttribute("role")).toBe("group");
+		expect(list.getAttribute("aria-label")).toBe("the test list");
+		expect(list.querySelector(".sw-list-header")?.getAttribute("role")).toBe("option");
+	});
+
+	it("headerless: no group role, no label", async () => {
+		const { list } = await makeRig((o) => {
+			o.header = "";
+		});
+		expect(list.hasAttribute("role")).toBe(false);
+		expect(list.hasAttribute("aria-label")).toBe(false);
+	});
+
+	it("an author-set role is left alone", async () => {
+		const owner = document.createElement("swt-list-owner") as SwtListOwner;
+		document.body.appendChild(owner);
+		await owner.updateComplete;
+		const list = owner.shadowRoot?.querySelector<SwList<Item>>("sw-list");
+		if (list === undefined || list === null) throw new Error("no sw-list");
+		// Re-run the role logic with an author role in place: simulate by clearing
+		// ours, setting an author's, and forcing an update.
+		list.removeAttribute("role");
+		list.setAttribute("role", "region");
+		list.header = "renamed";
+		await list.updateComplete;
+		expect(list.getAttribute("role")).toBe("region");
+		expect(list.getAttribute("aria-label")).toBe("renamed");
+	});
+});
+
+describe("the add-row", () => {
+	it("the marked hotkey dives straight into text entry (direct on a text row)", async () => {
+		const { root, container, input, key, focusedRow, adds } = await makeRig();
+		key("Home");
+		key("a");
+		expect(focusedRow()).toBe("tl-add");
+		expect(container.classList.contains("mc-text-mode")).toBe(true);
+		expect(root.activeElement).toBe(input); // real DOM focus dove in
+		expect(input?.value).toBe(""); // the triggering a did not type itself
+		expect(adds).toEqual([]); // a dive, not a commit
+	});
+
+	it("Enter commits: one event, trimmed text, draft cleared, focus stays on the add-row", async () => {
+		const { container, input, key, focusedRow, adds } = await makeRig();
+		if (input === null) throw new Error("no input");
+		key("a");
+		input.value = "  calibrate sensors  ";
+		key("Enter", input);
+		// Real browsers fire change on the blur that follows the exit; a
+		// change-driven commit would double-fire here.
+		input.dispatchEvent(new Event("change", { bubbles: true }));
+		expect(adds).toEqual([{ text: "calibrate sensors" }]);
+		expect(input.value).toBe("");
+		expect(container.classList.contains("mc-text-mode")).toBe(false);
+		expect(focusedRow()).toBe("tl-add"); // Enter again re-dives
+	});
+
+	it("Escape abandons: no event, draft cleared, text mode exits", async () => {
+		const { container, input, key, adds } = await makeRig();
+		if (input === null) throw new Error("no input");
+		key("a");
+		input.value = "changed my mind";
+		key("Escape", input);
+		input.dispatchEvent(new Event("change", { bubbles: true })); // the blur-change trap
+		expect(adds).toEqual([]);
+		expect(input.value).toBe("");
+		expect(container.classList.contains("mc-text-mode")).toBe(false);
+	});
+
+	it("an empty or whitespace Enter commits nothing and exits", async () => {
+		const { container, input, key, adds } = await makeRig();
+		if (input === null) throw new Error("no input");
+		key("a");
+		input.value = "   ";
+		key("Enter", input);
+		expect(adds).toEqual([]);
+		expect(input.value).toBe("");
+		expect(container.classList.contains("mc-text-mode")).toBe(false);
+	});
+
+	it("a refused commit (preventDefault) keeps the draft and text mode", async () => {
+		const { root, container, list, input, key } = await makeRig();
+		if (input === null) throw new Error("no input");
+		list.addEventListener("sw-list-add", (e) => e.preventDefault(), { once: true });
+		key("a");
+		input.value = "rejected by validation";
+		key("Enter", input);
+		expect(input.value).toBe("rejected by validation"); // the draft survives
+		expect(container.classList.contains("mc-text-mode")).toBe(true); // still editing
+		expect(root.activeElement).toBe(input);
+	});
+
+	it("typing syncs nothing: no events before the Enter commit", async () => {
+		const { input, key, adds } = await makeRig();
+		if (input === null) throw new Error("no input");
+		key("a");
+		for (const ch of "draft") {
+			key(ch, input);
+			input.value += ch;
+		}
+		expect(adds).toEqual([]);
+	});
+});
+
+describe("the list-scoped keys (t and n)", () => {
+	// Land row focus on a live item row inside the list.
+	const focusItem = async (rig: Rig, id: string): Promise<void> => {
+		const req = new CustomEvent("megacolumn-focus-request", {
+			detail: { rowId: id },
+			bubbles: true,
+			cancelable: true,
+		});
+		rig.list.dispatchEvent(req);
+		expect(req.defaultPrevented).toBe(true);
+		expect(rig.focusedRow()).toBe(id);
+	};
+
+	it("t from an item row lands on the header; claimed", async () => {
+		const rig = await makeRig((o) => {
+			o.items = items("a", "b");
+		});
+		await rig.settle();
+		await focusItem(rig, "b");
+		const e = rig.key("t");
+		expect(rig.focusedRow()).toBe("tl-header");
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("headerless: t lands on the first item row", async () => {
+		const rig = await makeRig((o) => {
+			o.header = "";
+			o.items = items("a", "b");
+		});
+		await rig.settle();
+		await focusItem(rig, "b");
+		rig.key("t");
+		expect(rig.focusedRow()).toBe("a");
+	});
+
+	it("t from the add-row counts as within", async () => {
+		const rig = await makeRig();
+		rig.key("a"); // dive
+		rig.key("Escape", rig.input ?? undefined); // back to row focus on the add-row
+		expect(rig.focusedRow()).toBe("tl-add");
+		rig.key("t");
+		expect(rig.focusedRow()).toBe("tl-header");
+	});
+
+	it("n lands on the first stop after the list; claimed", async () => {
+		const rig = await makeRig((o) => {
+			o.items = items("a");
+		});
+		await rig.settle();
+		await focusItem(rig, "a");
+		const e = rig.key("n");
+		expect(rig.focusedRow()).toBe("after");
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("n with nothing after the list clamps: consumed, focus stays", async () => {
+		const rig = await makeRig((o) => {
+			o.withAfter = false;
+			o.items = items("a");
+		});
+		await rig.settle();
+		await focusItem(rig, "a");
+		const e = rig.key("n");
+		expect(rig.focusedRow()).toBe("a");
+		expect(e.defaultPrevented).toBe(true);
+	});
+
+	it("outside the list the letters are nobody's: unclaimed, no jump", async () => {
+		const rig = await makeRig();
+		rig.key("Home");
+		expect(rig.focusedRow()).toBe("before");
+		const et = rig.key("t");
+		expect(rig.focusedRow()).toBe("before");
+		expect(et.defaultPrevented).toBe(false);
+		const en = rig.key("n");
+		expect(rig.focusedRow()).toBe("before");
+		expect(en.defaultPrevented).toBe(false);
+	});
+
+	it("inner scope beats a column marking: a claimed t never reaches data-mc-hotkey", async () => {
+		const rig = await makeRig((o) => {
+			o.items = items("a");
+		});
+		await rig.settle();
+		rig.root.querySelector('[data-mc-row="before"]')?.setAttribute("data-mc-hotkey", "t");
+		rig.container.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true }));
+		await focusItem(rig, "a");
+		rig.key("t");
+		expect(rig.focusedRow()).toBe("tl-header"); // the list's t, not the marking's
+		// ... and outside the list the same marking resolves normally.
+		rig.key("n"); // to "after", outside the list
+		expect(rig.focusedRow()).toBe("after");
+		rig.key("t");
+		expect(rig.focusedRow()).toBe("before"); // the marking's jump, undisturbed
+	});
+
+	it("in text mode t and n type: no jump (text entry dominates)", async () => {
+		const rig = await makeRig();
+		rig.key("a");
+		const e = rig.key("t", rig.input ?? undefined);
+		expect(rig.focusedRow()).toBe("tl-add");
+		expect(rig.container.classList.contains("mc-text-mode")).toBe(true);
+		expect(e.defaultPrevented).toBe(false); // the browser types it
+	});
+});
+
+describe("fail closed", () => {
+	// Element lifecycle throws surface as window error events, not to the caller.
+	const reportsFrom = (act: () => void): string[] => {
+		const reported: string[] = [];
+		const onError = (e: ErrorEvent): void => {
+			reported.push(String(e.error?.message ?? e.message));
+			e.preventDefault();
+		};
+		window.addEventListener("error", onError);
+		try {
+			act();
+		} finally {
+			window.removeEventListener("error", onError);
+		}
+		return reported;
+	};
+
+	it("throws outside any [data-mc-container]", async () => {
+		// The connect throw surfaces immediately; the already-scheduled first render
+		// then raises the same complaint from the inner sw-live-list (jsdom reports
+		// custom-element reaction throws as window errors, asynchronously) -- so the
+		// capture stays open across the settled update.
+		let list: LitElement | undefined;
+		const reported: string[] = [];
+		const onError = (e: ErrorEvent): void => {
+			reported.push(String(e.error?.message ?? e.message));
+			e.preventDefault();
+		};
+		window.addEventListener("error", onError);
+		try {
+			list = document.createElement("sw-list") as LitElement;
+			document.body.appendChild(list);
+			await list.updateComplete.catch(() => {});
+		} finally {
+			window.removeEventListener("error", onError);
+		}
+		expect(reported.some((m) => /outside any \[data-mc-container\]/.test(m))).toBe(true);
+	});
+
+	it("throws on element children (no slots)", () => {
+		const container = document.createElement("div");
+		container.setAttribute("data-mc-container", "");
+		document.body.appendChild(container);
+		const reported = reportsFrom(() => {
+			const list = document.createElement("sw-list");
+			list.innerHTML = "<div>nope</div>";
+			container.appendChild(list);
+		});
+		expect(reported.some((m) => /no slots/.test(m))).toBe(true);
+	});
+
+	it("throws on a malformed name", async () => {
+		const { list } = await makeRig();
+		list.name = "not a token";
+		await expect(list.updateComplete).rejects.toThrow(/name "not a token"/);
+	});
+
+	it("throws on a malformed add-hotkey", async () => {
+		const { list } = await makeRig();
+		list.addHotkey = "ab";
+		await expect(list.updateComplete).rejects.toThrow(/add-hotkey "ab"/);
+	});
+
+	it("two lists sharing one name in a column fail closed (duplicate structural row ids)", async () => {
+		// The documented contract: two lists in one column need distinct names.
+		// The collision surfaces from the controller's duplicate-row-id throw when
+		// its rescan sees the second "tl-header".  Rescans run inside the second
+		// list's updated(), so jsdom reports the throw as a window error event.
+		const { container } = await makeRig();
+		const reported: string[] = [];
+		const onError = (e: ErrorEvent): void => {
+			reported.push(String(e.error?.message ?? e.message));
+			e.preventDefault();
+		};
+		window.addEventListener("error", onError);
+		try {
+			const twin = document.createElement("sw-list") as SwList<Item>;
+			twin.name = "tl";
+			twin.header = "the twin";
+			twin.keyOf = (it: Item) => it.id;
+			twin.renderItem = () => html`<div></div>`;
+			container.appendChild(twin);
+			await twin.updateComplete.catch(() => {});
+		} finally {
+			window.removeEventListener("error", onError);
+		}
+		expect(reported.some((m) => /duplicate.*tl-header/.test(m))).toBe(true);
+	});
+});
diff --git a/packages/yukey/src/sw-list.ts b/packages/yukey/src/sw-list.ts
new file mode 100644
index 0000000..3f4ce61
--- /dev/null
+++ b/packages/yukey/src/sw-list.ts
@@ -0,0 +1,392 @@
+// sw-list: the list region of a megacolumn, with the list norms built in.
+// An owner composes it into its column ([data-mc-container]) and gets, by
+// construction, the behaviors the guidelines ask every list to have:
+//
+//   - The HEADER ROW (optional): a normal nav stop that speaks for the region.
+//     While it holds focus the whole list gets the quiet group wash; the focus
+//     ring stays on the header row itself -- deliberately no perimeter border,
+//     which would read as a nested focus container.  Its data-info describes
+//     the list AS a list.  Headerless lists are legal (no header row renders).
+//   - The LIVE ROWS: an inner sw-live-list in megacolumn mode (that file owns
+//     the row-lifecycle contract: churn safety, ghosts, geometry, the key pin).
+//   - The ADD-ROW (optional): the append affordance as a permanent ghosted nav
+//     stop at the tail.  Its marked hotkey (default a) is DIRECT: it dives
+//     straight into text entry, earned under the guidelines' easy-undo clause,
+//     because NOTHING SYNCS UNTIL COMMIT: Enter dispatches the commit event,
+//     Escape abandons the draft, and the component holds no copy of the text --
+//     the input's value is the draft, and the owner's data is untouched until
+//     the owner itself acts on the commit.
+//   - The LIST-SCOPED KEYS: t jumps to the list's top (its header when it has
+//     one, else its first row) and n jumps past the list to the next stop after
+//     it.  Documented norms with defaults, deliberately NOT hotkey markings
+//     (every list wearing the same gold letters would be noise; the letters
+//     only mean this while focus is inside the list's own rows).  Scope is DOM
+//     containment: this element hears the bubbling megacolumn-key dispatch from
+//     its own rows before the controller's column-level marking resolution runs,
+//     so inner scope wins by dispatch order.  A claimed key with no landing is
+//     the clamp: consumed, focus stays -- same shape as the machine's no-wrap ends.
+//
+// The public surface:
+//
+//   properties (attributes in kebab-case where marked):
+//     name          Namespaces the structural row ids: the header row is
+//                   "<name>-header", the add-row "<name>-add".  Required
+//                   [a-z0-9-]+; default "list".  Two lists in one column need
+//                   distinct names (the controller throws on duplicate row ids).
+//     header        Header row text.  Empty (the default) means headerless:
+//                   no header row, no group identity claimed.
+//     renderHeader  Optional render-prop for the header ROW's visible content
+//                   (an owner composing its own header line -- live counts, a
+//                   column-label grid).  The `header` string stays the identity
+//                   switch and the group's aria-label: a stable name for AT
+//                   while the visible content varies.  Ignored when headerless.
+//     header-info   The owner's whole-list info text.  The component appends its
+//                   own norm teaching (t/n, the add hotkey) to whatever is given,
+//                   so the norms read the same in every list.
+//     addable       Renders the add-row.  Lists that cannot append (a cargo
+//                   manifest) just leave it off.
+//     add-label     The add-row's label; default "add…".  The hotkey letter is
+//                   marked in the label (first occurrence, sw-button's rule),
+//                   or shown after it when the label lacks the letter.
+//     add-hotkey    The marked direct hotkey; default "a" (single [a-z0-9]).
+//                   Override only when two addable lists share a column: the
+//                   controller throws on duplicate markings.
+//     add-info      The owner's info text for the add-row; norm teaching is
+//                   appended, as with header-info.
+//     items, keyOf, renderItem, renderEmpty, resetKey
+//                   Passed through to the inner sw-live-list untouched: the
+//                   owner renders row CONTENT and keeps data identity; see
+//                   sw-live-list.ts for their contracts.
+//
+//   events:
+//     sw-list-add   The add-row's commit (bubbling, cancelable; detail
+//                   { text: string }, trimmed, never empty).  Dispatched only on
+//                   the input's own Enter keydown -- never on change/blur, so an
+//                   Escape that blurs the input cannot masquerade as a commit.
+//                   Uncancelled: the component clears the draft and the text-mode
+//                   exit proceeds, with row focus remaining on the add-row (Enter
+//                   re-dives).  preventDefault() REFUSES the commit (owner-side
+//                   validation): the draft and text mode both survive, so the
+//                   user fixes the text instead of retyping it.
+//
+//   key semantics stay the owner's: megacolumn-key listeners (what Enter, x,
+//   Delete MEAN on a live row) belong on the owner's container, exactly as
+//   without sw-list.  The structural rows decline every key -- owners routing
+//   by rowId should skip "<name>-header" and "<name>-add" (that id scheme is
+//   public contract).  Owners claiming letters that overlap t/n or the add
+//   hotkey should respect a prior claim (check defaultPrevented), though
+//   wanting to is already a design smell.
+//
+//   rendered structure (public contract, since it renders into the owner's
+//   tree): [header row?] <sw-live-list megacolumn> [add-row?], all inside this
+//   element.  sw-live-list's feel knobs (holdMs, keyPinMs, pointerSafety...)
+//   are deliberately NOT mirrored here -- mirrored twins invite drift -- and are
+//   set on the inner element where they live.  The sw-list-prefixed classes on
+//   the structural rows are stable; their look is the norm's look.
+//
+// Group identity for AT (the header-role decision, settled for flat lists):
+// with a header, this element takes role="group" plus an aria-label mirroring
+// the header text -- the APG listbox pattern's grouping idiom, giving AT the
+// region identity the wash gives sighted users.  The header row itself STAYS a
+// role=option nav stop: reachable with arrows, stable stride counts, t lands on
+// it.  It is still announced as an option, but as the first option of a group
+// named like it -- descriptive, where the bare demo markup was misleading.
+// An author-set role on the host is left alone.  Trees are explicitly NOT this
+// component's business: a treeitem reframe (levels, expansion state) belongs to
+// a distinct tree vocabulary, and nothing here forecloses one -- nesting
+// sw-list was never legal (rows come from renderItem, not markup).
+//
+// Like sw-live-list's mega mode, this renders into the owner's tree (light
+// DOM): the megacolumn scan and aria-activedescendant cannot cross shadow
+// boundaries.  No shadow root, no slots; styles adopt into the owner's root
+// once, sw-list-prefixed.  After every update it dispatches megacolumn-rescan
+// (structural rows change under the controller's scan on this element's own
+// schedule, not the owner's).  Fail closed: connecting outside a
+// [data-mc-container] throws, element children throw, a malformed name or
+// add-hotkey throws.
+
+import { css, html, LitElement, nothing, type TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { adoptIntoRoot } from "./adopt-into-root.js";
+import type { MegacolumnKeyDetail } from "./megacolumn-controller.js";
+import type { LiveListPhase } from "./sw-live-list.js";
+import "./sw-live-list.js";
+
+export interface SwListAddDetail {
+	text: string; // trimmed, never empty
+}
+
+// The norm's look for the structural rows, adopted into the owner's root.
+// The wash rule is the header-row norm made structural: focus on the header
+// washes the whole region (group identity), keyed off the controller's
+// mc- classes; the ring stays the kit's standard row outline on the header.
+// The add-row idles ghosted (an affordance, not a form field); diving in
+// raises the well and the accent border.  The raised look is keyed to the
+// add-row's OWN focus class, not just the container's text mode: two lists in
+// one column must not both light up when one of them is being typed in.
+const swListStyles = css`
+	sw-list {
+		display: block;
+	}
+	.mc-focus-within sw-list:has(> .sw-list-header.mc-row-focused) {
+		background: var(--sw-wash-faint);
+	}
+	.sw-list-header {
+		display: flex;
+		align-items: center;
+		min-height: var(--sw-hit-min-h);
+		color: var(--sw-text-faint);
+		font-size: var(--sw-type-micro);
+		letter-spacing: var(--sw-tracking-wide);
+		text-transform: uppercase;
+		padding: 2px var(--sw-space-sm);
+		box-sizing: border-box;
+	}
+	.sw-list-add {
+		display: flex;
+		align-items: center;
+		gap: var(--sw-space-sm);
+		min-height: var(--sw-hit-min-h);
+		padding: 2px var(--sw-space-sm);
+		color: var(--sw-text-faint);
+		box-sizing: border-box;
+	}
+	.sw-list-add input[data-mc-input] {
+		flex: 1;
+		font: inherit;
+		color: var(--sw-text-bright);
+		background: transparent;
+		border: 1px solid transparent;
+		min-height: var(--sw-hit-min-h);
+		box-sizing: border-box;
+		padding: 0 var(--sw-space-sm);
+	}
+	.mc-text-mode .sw-list-add.mc-row-focused input[data-mc-input] {
+		background: var(--sw-bg-well);
+		border-color: var(--sw-accent);
+	}
+	.sw-list-hk {
+		color: var(--sw-hotkey-accent);
+	}
+`;
+
+@customElement("sw-list")
+export class SwList<T = unknown> extends LitElement {
+	@property() name = "list";
+	@property() header = "";
+	@property({ attribute: "header-info" }) headerInfo = "";
+	@property({ attribute: false }) renderHeader?: () => TemplateResult;
+	@property({ type: Boolean }) addable = false;
+	@property({ attribute: "add-label" }) addLabel = "add…";
+	@property({ attribute: "add-hotkey" }) addHotkey = "a";
+	@property({ attribute: "add-info" }) addInfo = "";
+	@property({ attribute: false }) items: T[] = [];
+	@property({ attribute: false }) keyOf!: (item: T) => string;
+	@property({ attribute: false }) renderItem!: (item: T, phase: LiveListPhase) => TemplateResult;
+	@property({ attribute: false }) renderEmpty?: () => TemplateResult;
+	@property() resetKey = "";
+
+	// The column this list belongs to; n walks its rows.  Set at connect (which
+	// fails closed when there is none).
+	#container: HTMLElement | null = null;
+
+	constructor() {
+		super();
+		// The t/n norm's scope IS this element: megacolumn-key dispatches bubble
+		// from row controls, so this listener only ever hears the list's own rows --
+		// and hears them before the container's marking resolution (inner scope
+		// wins by dispatch order, for free).
+		this.addEventListener("megacolumn-key", this.#onListKey);
+	}
+
+	// Light DOM, no slots (see the header).  Styles adopt into the owner's root.
+	protected override createRenderRoot(): HTMLElement {
+		if (this.children.length > 0)
+			throw new Error(
+				"sw-list: renders into the light DOM and has no slots; rows come from renderItem",
+			);
+		adoptIntoRoot(this.getRootNode(), swListStyles, "data-sw-list");
+		return this;
+	}
+
+	override connectedCallback(): void {
+		super.connectedCallback();
+		const container = this.closest<HTMLElement>("[data-mc-container]");
+		if (container === null) throw new Error("sw-list: outside any [data-mc-container]");
+		this.#container = container;
+	}
+
+	override disconnectedCallback(): void {
+		super.disconnectedCallback();
+		this.#container = null;
+	}
+
+	override willUpdate(): void {
+		if (!/^[a-z0-9-]+$/i.test(this.name))
+			throw new Error(
+				`sw-list: name "${this.name}" must be a non-empty [a-z0-9-]+ token ` +
+					"(it namespaces the structural row ids)",
+			);
+		if (this.addable && !/^[a-z0-9]$/i.test(this.addHotkey))
+			throw new Error(
+				`sw-list: add-hotkey "${this.addHotkey}" must be a single [a-z0-9] character`,
+			);
+	}
+
+	protected override updated(): void {
+		// Structural rows appear and disappear on this element's own update
+		// schedule, after the owner's hostUpdated scan already ran -- announce,
+		// same contract as sw-live-list (re-scans are idempotent).
+		this.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true }));
+		// Group identity for AT (the header-role decision; see the file header).
+		// An author-set role is left alone; only our own role="group" is retracted
+		// when the header goes away.
+		if (this.header !== "") {
+			if (!this.hasAttribute("role")) this.setAttribute("role", "group");
+			this.setAttribute("aria-label", this.header);
+		} else {
+			if (this.getAttribute("role") === "group") this.removeAttribute("role");
+			this.removeAttribute("aria-label");
+		}
+	}
+
+	// The t/n list-scoped keys (see the file header).  Both resolve through
+	// megacolumn-focus-request, the controller's public focus-move affordance:
+	// asking is the filter -- the controller ignores requests for unfocusable or
+	// vanished rows and answers granted ones with preventDefault -- so each key
+	// walks its candidates until one lands.  No landing = the clamp: the key
+	// stays claimed and focus stays put.
+	#onListKey = (e: Event): void => {
+		const { detail } = e as CustomEvent<MegacolumnKeyDetail>;
+		if (detail.key !== "t" && detail.key !== "n") return;
+		e.preventDefault();
+		if (detail.key === "t") {
+			// The list's top: its own rows from the top of the DOM order -- the
+			// header when there is one (always focusable), else the first live row.
+			for (const row of this.querySelectorAll<HTMLElement>("[data-mc-row]")) {
+				if (this.#requestFocus(row.dataset.mcRow ?? "")) return;
+			}
+			return;
+		}
+		// n: the first stop the column grants after this list, in DOM order.
+		if (this.#container === null) return;
+		for (const row of this.#container.querySelectorAll<HTMLElement>("[data-mc-row]")) {
+			if (this.contains(row)) continue;
+			if ((this.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING) === 0) continue;
+			if (this.#requestFocus(row.dataset.mcRow ?? "")) return;
+		}
+	};
+
+	// Ask the column to move logical focus to rowId; true when granted.
+	#requestFocus(rowId: string): boolean {
+		const req = new CustomEvent("megacolumn-focus-request", {
+			detail: { rowId },
+			bubbles: true,
+			cancelable: true,
+		});
+		this.dispatchEvent(req);
+		return req.defaultPrevented;
+	}
+
+	// The add-row's commit/abandon mechanics, on the input's REAL keydown (text
+	// mode gives the input native focus, so this sees every key before the
+	// container's exit handling bubbles).  Deliberately keydown, NOT @change:
+	// both text-mode exits blur the input, and change fires on
+	// blur-with-changed-value -- a change-driven commit would append on Escape
+	// too.  Nothing syncs anywhere until the Enter commit; Escape abandons by
+	// clearing the draft.
+	#onAddKeydown = (e: KeyboardEvent): void => {
+		const input = e.currentTarget as HTMLInputElement;
+		if (e.key === "Escape") {
+			input.value = ""; // abandon; the machine's exit-text routing proceeds
+			return;
+		}
+		if (e.key !== "Enter") return;
+		const text = input.value.trim();
+		if (text === "") {
+			input.value = "";
+			return; // an empty commit is no commit; the exit proceeds
+		}
+		const commit = new CustomEvent<SwListAddDetail>("sw-list-add", {
+			detail: { text },
+			bubbles: true,
+			cancelable: true,
+		});
+		this.dispatchEvent(commit);
+		if (commit.defaultPrevented) {
+			// Commit refused (owner-side validation): keep the draft and stay in
+			// text mode so the user can fix the text rather than retype it.  The
+			// machine's exit-text routing rides this same keydown's bubble to the
+			// container; stopping it here is what keeps text mode alive.
+			e.stopPropagation();
+			return;
+		}
+		input.value = ""; // committed; the exit proceeds, focus stays on the add-row
+	};
+
+	override render(): TemplateResult {
+		return html`
+			${
+				this.header === ""
+					? nothing
+					: html`<div
+							data-mc-row="${this.name}-header"
+							class="sw-list-header"
+							data-info=${this.#composedHeaderInfo()}
+						>${this.renderHeader?.() ?? this.header}</div>`
+			}
+			<sw-live-list
+				megacolumn
+				.items=${this.items}
+				.keyOf=${this.keyOf}
+				.renderItem=${this.renderItem}
+				.renderEmpty=${this.renderEmpty}
+				.resetKey=${this.resetKey}
+			></sw-live-list>
+			${
+				this.addable
+					? html`<div
+							data-mc-row="${this.name}-add"
+							class="sw-list-add"
+							data-info=${this.#composedAddInfo()}
+						>
+							<span data-mc-hotkey=${this.addHotkey} data-mc-hotkey-direct>+ ${this.#markedAddLabel()}</span>
+							<input data-mc-input type="text" @keydown=${this.#onAddKeydown} />
+						</div>`
+					: nothing
+			}
+		`;
+	}
+
+	// The structural rows' info: the owner's text first, then the norm teaching --
+	// appended by the component so the norms read the same in every list.
+	#composedHeaderInfo(): string {
+		const t = this.header === "" ? "t returns to the top" : "t returns here";
+		const add = this.addable ? `${this.addHotkey} dives into the add-row at the tail; ` : "";
+		const norms = `${add}from any row in the list, ${t} and n jumps past the list`;
+		return this.headerInfo === "" ? norms : `${this.headerInfo}; ${norms}`;
+	}
+
+	#composedAddInfo(): string {
+		const top = this.header === "" ? "the list's top" : "the list's header";
+		const norms =
+			`${this.addHotkey} or Enter dives into typing, Enter commits, Escape abandons; ` +
+			`t jumps back to ${top}`;
+		return this.addInfo === "" ? norms : `${this.addInfo}; ${norms}`;
+	}
+
+	// The add-row's hotkey marking: the letter accented in the label (first
+	// case-insensitive occurrence, sw-button's rule), or shown after the label
+	// when it lacks the letter -- a marking must be visible either way.
+	#markedAddLabel(): TemplateResult {
+		const idx = this.addLabel.toLowerCase().indexOf(this.addHotkey.toLowerCase());
+		if (idx === -1) return html`${this.addLabel} <span class="sw-list-hk">${this.addHotkey}</span>`;
+		return html`${this.addLabel.slice(0, idx)}<span class="sw-list-hk">${this.addLabel[idx]}</span>${this.addLabel.slice(idx + 1)}`;
+	}
+}
+
+declare global {
+	interface HTMLElementTagNameMap {
+		"sw-list": SwList;
+	}
+}
diff --git a/packages/yukey/src/sw-live-list.gallery.ts b/packages/yukey/src/sw-live-list.gallery.ts
new file mode 100644
index 0000000..3e6178f
--- /dev/null
+++ b/packages/yukey/src/sw-live-list.gallery.ts
@@ -0,0 +1,129 @@
+// Gallery entry for sw-live-list, the keyed live list whose rows vanish safely
+// under a pointer.  The churn demo element below feeds it a queue that drains and
+// refills on a timer, which is the component's home condition: data that changes
+// out from under the user, mid-hover.  The first two variants contrast the two
+// pointerSafety modes on the same churn.
+
+import { css, html, LitElement, type TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import type { GalleryEntry } from "./gallery/entry.js";
+import type { LiveListPhase } from "./sw-live-list.js";
+import "./sw-live-list.js";
+
+interface DemoRow {
+	id: string;
+	label: string;
+}
+
+const rowStyles = css`
+	.row {
+		display: flex;
+		align-items: baseline;
+		gap: 10px;
+		line-height: 1.9;
+		white-space: nowrap;
+	}
+	.row.committed {
+		opacity: 0.45;
+	}
+	.row .mark {
+		flex: none;
+		width: 16px;
+		color: var(--sw-text-faint);
+	}
+	.row .mark.live {
+		color: var(--sw-good);
+	}
+`;
+
+const renderRow = (item: DemoRow, phase: LiveListPhase): TemplateResult =>
+	html`<div class="row ${phase === "committed" ? "committed" : ""}">
+		<span class="mark ${phase}">${phase === "committed" ? "✓" : "▸"}</span>
+		<span>${item.label}</span>
+	</div>`;
+
+const keyOf = (item: DemoRow): string => item.id;
+
+// A queue that drains itself and gets new work, like a ship's task list does.
+// Every beat the oldest row completes (commit -> hold -> collapse) and a fresh
+// one enters at the tail; the interplay with a hovering pointer is the demo.
+@customElement("swg-live-list-churn")
+export class SwgLiveListChurn extends LitElement {
+	@property() pointerSafety: "click-guard" | "freeze" = "click-guard";
+	@state() private items: DemoRow[] = [];
+	private seq = 0;
+	private timer: number | undefined;
+
+	connectedCallback(): void {
+		super.connectedCallback();
+		this.items = [this.newRow(), this.newRow(), this.newRow()];
+		this.timer = window.setInterval(() => this.churn(), 2500);
+	}
+
+	disconnectedCallback(): void {
+		super.disconnectedCallback();
+		window.clearInterval(this.timer);
+	}
+
+	private newRow(): DemoRow {
+		this.seq += 1;
+		const chores = ["move to waypoint", "load ore", "unload struts", "dock", "survey field"];
+		return { id: `t${this.seq}`, label: `${chores[this.seq % chores.length]} (#${this.seq})` };
+	}
+
+	private churn(): void {
+		this.items = [...this.items.slice(1), this.newRow()];
+	}
+
+	render(): TemplateResult {
+		return html`<sw-live-list
+			.items=${this.items}
+			.keyOf=${keyOf}
+			.renderItem=${renderRow}
+			.itemStyles=${rowStyles}
+			.pointerSafety=${this.pointerSafety}
+		></sw-live-list>`;
+	}
+}
+
+const entry: GalleryEntry = {
+	id: "sw-live-list",
+	title: "sw-live-list",
+	description:
+		"A keyed live list whose rows vanish safely under a pointer.  " +
+		"The owner supplies keying and rendering; this component owns row lifecycle: " +
+		"departed rows ghost and hold before collapsing, insertions grow in.  " +
+		"Click safety is target-scoped by default (pointerSafety: click-guard): motion proceeds " +
+		"under the pointer, and a click is swallowed only while its target row is mid-displacement " +
+		"(or was, within the last 50ms) -- stale clicks die, everything else stays live.  " +
+		"Freeze mode is the opt-in alternative: geometry holds still under the pointer instead.",
+	variants: [
+		{
+			caption:
+				"Self-churning queue, default click safety (click-guard): the head row completes every few " +
+				"seconds and new work enters at the tail.  Watch a row ghost (✓, dimmed), hold, then collapse -- " +
+				"with the pointer resting inside, nothing waits for it.  Try clicking a row the instant one above " +
+				"it collapses: the click on the still-moving row dies quietly (no surprise target), while rows " +
+				"above the motion answer clicks throughout.",
+			render: () => html`<swg-live-list-churn></swg-live-list-churn>`,
+		},
+		{
+			caption:
+				"The same churn in freeze mode (pointerSafety: freeze, the opt-in): rest the pointer inside " +
+				"the list and geometry freezes -- ghosts hold and insertions wait invisibly until the pointer " +
+				"leaves, and the deferred motion plays out one beat apart.  For surfaces that want quiescence " +
+				"under the pointer; the price is a world that visibly waits for the mouse.",
+			render: () => html`<swg-live-list-churn pointerSafety="freeze"></swg-live-list-churn>`,
+		},
+		{
+			caption: "Empty state: the owner provides the empty slot's content.",
+			render: () => html`<sw-live-list
+				.items=${[] as DemoRow[]}
+				.keyOf=${keyOf}
+				.renderItem=${renderRow}
+			><div slot="empty" style="color:var(--sw-text-faint);">queue empty (assignment may refill it)</div></sw-live-list>`,
+		},
+	],
+};
+
+export default entry;
diff --git a/packages/yukey/src/sw-live-list.test.ts b/packages/yukey/src/sw-live-list.test.ts
new file mode 100644
index 0000000..cdc8a52
--- /dev/null
+++ b/packages/yukey/src/sw-live-list.test.ts
@@ -0,0 +1,661 @@
+// @vitest-environment jsdom
+//
+// Tests for sw-live-list's megacolumn mode: light-DOM rendering, phase-dependent
+// row identity stamping, the megacolumn-rescan announcement, the key-activity pin,
+// the two pointerSafety modes (click-guard's target-scoped swallow; freeze's
+// global hover pin + lockout), and the fail-closed contract checks.  The full
+// composition (this component under a real MegacolumnController) is exercised
+// with the controller test rig's fake-host pattern.
+//
+// Shadow mode's behavior (hover pin, phase timing, resurrection...) was built and
+// verified pre-vitest (the vanishing-rows study + browser drivers); these tests
+// cover what THIS stage added, plus shadow-mode regression checks for the pieces
+// it touched (class renames, the shared lifecycle stylesheet, renderEmpty).
+
+import { html, type TemplateResult } from "lit";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { MegacolumnController } from "./megacolumn-controller.js";
+import type { LiveListPhase, SwLiveList } from "./sw-live-list.js";
+import "./sw-live-list.js";
+
+interface Item {
+	id: string;
+	label: string;
+}
+
+const keyOf = (i: Item): string => i.id;
+
+// jsdom implements no scrollIntoView; the controller scrolls the focused row
+// into view on every focus identity change.  A no-op stands in.
+Element.prototype.scrollIntoView = (): void => {};
+// The committed variant drops the aux control, like a real task row would:
+// a ghost is not removable (it is already gone from the data).
+const renderItem = (i: Item, phase: LiveListPhase): TemplateResult =>
+	phase === "committed"
+		? html`<span data-mc-main>${i.label} (done)</span>`
+		: html`<span data-mc-main>${i.label}</span><span data-mc-aux>×</span>`;
+
+const items = (...ids: string[]): Item[] => ids.map((id) => ({ id, label: `job ${id}` }));
+
+// jsdom has no matchMedia; the component asks it about prefers-reduced-motion.
+beforeEach(() => {
+	vi.useFakeTimers();
+	window.matchMedia ??= (query: string) => ({ matches: false, media: query }) as MediaQueryList;
+});
+afterEach(() => {
+	vi.useRealTimers();
+	document.body.replaceChildren();
+});
+
+interface MegaRig {
+	root: HTMLElement;
+	container: HTMLElement;
+	list: SwLiveList<Item>;
+	ctl: MegacolumnController;
+	setItems: (next: Item[]) => Promise<void>;
+	key: (key: string) => KeyboardEvent;
+}
+
+// A megacolumn owner around the list, driven the way the controller rig drives
+// fake hosts: hostUpdated() stands in for the owner's render completing.
+async function makeMegaRig(initial: Item[]): Promise<MegaRig> {
+	const root = document.createElement("div");
+	document.body.appendChild(root);
+	const container = document.createElement("div");
+	container.setAttribute("data-mc-container", "");
+	root.appendChild(container);
+	const list = document.createElement("sw-live-list") as SwLiveList<Item>;
+	list.setAttribute("megacolumn", "");
+	list.keyOf = keyOf;
+	list.renderItem = renderItem;
+	list.items = initial;
+	container.appendChild(list);
+	await list.updateComplete;
+	const host = {
+		renderRoot: root,
+		addController(): void {},
+		removeController(): void {},
+		requestUpdate(): void {},
+		updateComplete: Promise.resolve(true),
+	};
+	const ctl = new MegacolumnController(host);
+	ctl.hostUpdated();
+	const setItems = async (next: Item[]): Promise<void> => {
+		list.items = next;
+		await list.updateComplete;
+	};
+	const key = (k: string): KeyboardEvent => {
+		const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true });
+		container.dispatchEvent(e);
+		return e;
+	};
+	return { root, container, list, ctl, setItems, key };
+}
+
+const rowEl = (root: HTMLElement, key: string): HTMLElement | null =>
+	root.querySelector<HTMLElement>(`[data-key="${key}"]`);
+
+// Real pointer events are composed (they cross shadow boundaries); the click
+// guard listens on the host, so tests must dispatch the same shape.
+const fire = (el: Element, type: "click" | "mousedown"): void => {
+	el.dispatchEvent(new MouseEvent(type, { bubbles: true, composed: true, cancelable: true }));
+};
+
+const countItemClicks = (list: SwLiveList<Item>): (() => number) => {
+	let n = 0;
+	list.addEventListener("item-click", () => {
+		n += 1;
+	});
+	return () => n;
+};
+
+describe("mega mode: light-DOM rendering and row identity", () => {
+	it("renders rows into the owner's tree (no shadow root) with data-mc-row stamped", async () => {
+		const { list, container } = await makeMegaRig(items("a", "b"));
+		expect(list.shadowRoot).toBeNull();
+		const ids = [...container.querySelectorAll("[data-mc-row]")].map(
+			(el) => (el as HTMLElement).dataset.mcRow,
+		);
+		expect(ids).toEqual(["a", "b"]);
+	});
+
+	it("the controller sees live rows as focusable stops with their aux controls", async () => {
+		const { ctl } = await makeMegaRig(items("a", "b"));
+		expect(ctl.nav.focusedId).toBe("a");
+		expect(ctl.nav.rows.map((r) => ({ id: r.id, aux: r.hasAux }))).toEqual([
+			{ id: "a", aux: true },
+			{ id: "b", aux: true },
+		]);
+	});
+
+	it("a committed ghost stays a focusable stop, but sheds its aux", async () => {
+		const { ctl, setItems, root } = await makeMegaRig(items("a", "b"));
+		await setItems(items("b")); // a leaves the data -> committed ghost
+		const ghost = rowEl(root, "a");
+		expect(ghost?.dataset.mcRow).toBe("a");
+		expect(ghost?.getAttribute("aria-hidden")).toBeNull();
+		expect(ctl.nav.rows.find((r) => r.id === "a")).toMatchObject({
+			focusable: true,
+			hasAux: false,
+		});
+	});
+
+	it("a collapsing row is registered but aria-hidden, and thereby not a stop", async () => {
+		const { ctl, setItems, list, root } = await makeMegaRig(items("a", "b"));
+		await setItems(items("b"));
+		vi.advanceTimersByTime(list.holdMs); // hold expires -> collapse begins
+		await list.updateComplete;
+		const dying = rowEl(root, "a");
+		expect(dying?.dataset.mcRow).toBe("a");
+		expect(dying?.getAttribute("aria-hidden")).toBe("true");
+		expect(ctl.nav.rows.find((r) => r.id === "a")).toMatchObject({ focusable: false });
+	});
+
+	it("the empty state is the renderEmpty render-prop", async () => {
+		const { list, container, setItems } = await makeMegaRig(items("a"));
+		list.renderEmpty = () => html`<div class="idle">nothing queued</div>`;
+		await setItems([]);
+		vi.advanceTimersByTime(list.holdMs + list.collapseMs);
+		await list.updateComplete;
+		expect(container.querySelector(".idle")?.textContent).toBe("nothing queued");
+	});
+});
+
+describe("mega mode: the rescan announcement", () => {
+	it("self-driven re-renders (phase timers) reach the controller without a host render", async () => {
+		const { ctl, setItems, list } = await makeMegaRig(items("a", "b"));
+		await setItems(items("b")); // ghost a
+		expect(ctl.nav.rows.find((r) => r.id === "a")).toMatchObject({ focusable: true });
+		vi.advanceTimersByTime(list.holdMs); // collapse starts, NO host render happens
+		await list.updateComplete;
+		expect(ctl.nav.rows.find((r) => r.id === "a")).toMatchObject({ focusable: false });
+		vi.advanceTimersByTime(list.collapseMs); // collapse ends, row drops
+		await list.updateComplete;
+		expect(ctl.nav.rows.map((r) => r.id)).toEqual(["b"]);
+	});
+
+	it("a focused ghost collapsing away moves focus by the fallback and arms the lockout", async () => {
+		const { ctl, setItems, list, root, key } = await makeMegaRig(items("a", "b", "c"));
+		expect(ctl.nav.focusedId).toBe("a");
+		await setItems(items("b", "c")); // focused a ghosts; still a stop
+		expect(ctl.nav.focusedId).toBe("a");
+		vi.advanceTimersByTime(list.holdMs); // ghost starts collapsing -> disabled
+		await list.updateComplete;
+		expect(ctl.nav.focusedId).toBe("b"); // fallback fell downward
+		// ... and the churn lockout is armed: a decisive key dies quietly.
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		const e = key("Enter");
+		expect(forwards).toBe(0);
+		expect(e.defaultPrevented).toBe(true);
+	});
+});
+
+describe("mega mode: the key-activity pin", () => {
+	// The pin defers ONLY the focused row's hold-expiry: the keyboard aims by
+	// identity, so other rows' geometry motion cannot invalidate its aim -- only
+	// the focused row itself needs holding still under a key burst.  (Freeze
+	// mode's hover pin, by contrast, is global: pointers aim at positions.)
+	// The pin is mode-independent: identity aim needs it in both pointerSafety modes.
+
+	it("the focused ghost defers while keys are active and collapses keyPinMs after the last", async () => {
+		const { setItems, list, root, key } = await makeMegaRig(items("a", "b"));
+		await setItems(items("b")); // a ghosts; eager focus was on a, and stays
+		// Work the column without leaving the ghost (ArrowUp cannot move above the
+		// first row): keys land every 100ms, well past the hold's natural expiry.
+		for (let t = 0; t < list.holdMs + 400; t += 100) {
+			vi.advanceTimersByTime(100);
+			key("ArrowUp");
+		}
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull(); // held past holdMs
+		vi.advanceTimersByTime(list.keyPinMs); // decay: the deferred collapse begins
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true");
+		vi.advanceTimersByTime(list.collapseMs); // ...and completes: row gone
+		await list.updateComplete;
+		expect(rowEl(root, "a")).toBeNull();
+	});
+
+	it("a non-focused ghost collapses on its natural schedule despite ongoing keydowns", async () => {
+		const { setItems, list, root, key } = await makeMegaRig(items("a", "b"));
+		key("ArrowDown"); // focus moves to b; a is nobody's aim from here on
+		await setItems(items("b")); // a ghosts, unfocused
+		// Type continuously up to just before the hold expires; then cross the expiry
+		// with NO keydown in between (asserting the schedule itself, not a later
+		// keydown's sweep), the pin still armed from the last keystroke.
+		for (let t = 100; t < list.holdMs; t += 100) {
+			vi.advanceTimersByTime(100);
+			key("ArrowDown"); // b is last: focus stays put, the pin stays armed
+		}
+		vi.advanceTimersByTime(100); // the hold expires here, mid-pin
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); // on schedule
+	});
+
+	it("ghosts removed at different moments collapse at different moments under typing", async () => {
+		const { setItems, list, root, key } = await makeMegaRig(items("a", "b", "c"));
+		key("ArrowDown");
+		key("ArrowDown"); // focus c; the ghosts below are never the aim
+		await setItems(items("b", "c")); // a removed at t=0
+		for (let t = 100; t <= 500; t += 100) {
+			vi.advanceTimersByTime(100);
+			key("ArrowDown");
+		}
+		await setItems(items("c")); // b removed at t=500
+		for (let t = 600; t < list.holdMs; t += 100) {
+			vi.advanceTimersByTime(100);
+			key("ArrowDown");
+		}
+		vi.advanceTimersByTime(100); // t=holdMs: a's own schedule, mid-pin, no keydown since
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true");
+		expect(rowEl(root, "b")?.getAttribute("aria-hidden")).toBeNull(); // b's is 500ms out
+		for (let t = 100; t < 500; t += 100) {
+			vi.advanceTimersByTime(100);
+			key("ArrowDown");
+		}
+		vi.advanceTimersByTime(100); // t=holdMs+500: b's schedule, likewise mid-pin
+		await list.updateComplete;
+		expect(rowEl(root, "b")?.getAttribute("aria-hidden")).toBe("true");
+	});
+
+	it("a deferred ghost is released by the keydown after focus leaves it (no accumulation)", async () => {
+		const { setItems, list, root, key } = await makeMegaRig(items("a", "b"));
+		await setItems(items("b")); // ghost a, focused
+		vi.advanceTimersByTime(list.holdMs - 50);
+		key("ArrowUp"); // arm the pin without moving
+		vi.advanceTimersByTime(50); // expiry under the pin, focused: deferred
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull();
+		key("ArrowDown"); // focus leaves the ghost...
+		key("ArrowDown"); // ...and the next keydown sweeps the stale deferral
+		await list.updateComplete;
+		// Collapsing well before any pin decay: the deferral followed the aim.
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true");
+	});
+
+	it("every keydown re-arms the pin (working the focused ghost holds it indefinitely)", async () => {
+		const { setItems, list, root, key } = await makeMegaRig(items("a", "b"));
+		await setItems(items("b")); // ghost a, focused
+		vi.advanceTimersByTime(list.holdMs - 50);
+		key("ArrowUp"); // pin armed just before the hold expires; focus stays on a
+		vi.advanceTimersByTime(50); // expiry defers under the pin
+		for (let i = 0; i < 5; i++) {
+			vi.advanceTimersByTime(list.keyPinMs - 100); // almost decayed...
+			key("ArrowUp"); // ...re-armed (fresh keyPinMs from here)
+		}
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull();
+		vi.advanceTimersByTime(list.keyPinMs + list.collapseMs); // decay, then the collapse
+		await list.updateComplete;
+		expect(rowEl(root, "a")).toBeNull();
+	});
+
+	it("the key pin does NOT defer insertions (identity aim; feedback beats calm)", async () => {
+		const { setItems, root, key } = await makeMegaRig(items("a"));
+		key("ArrowDown");
+		await setItems(items("new", "a")); // a keystroke-driven prepend, say
+		const fresh = rowEl(root, "new");
+		expect(fresh?.getAttribute("aria-hidden")).toBeNull(); // visible immediately
+	});
+});
+
+describe("click-guard (the default pointerSafety): motion proceeds, stale clicks die", () => {
+	// The default disentangles two principles the old hover pin conflated:
+	// clicks must not land on surprising targets (always on, target-scoped,
+	// brief), and freezing motion under the pointer (an opt-in mode, below).
+
+	it("hovering defers nothing: an insertion appears immediately under the pointer", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a"));
+		list.dispatchEvent(new Event("pointerenter"));
+		await setItems(items("new", "a"));
+		expect(rowEl(root, "new")?.getAttribute("aria-hidden")).toBeNull(); // visible, not pending
+	});
+
+	it("hovering defers nothing: an expired hold collapses on its own schedule", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a", "b"));
+		list.dispatchEvent(new Event("pointerenter"));
+		await setItems(items("b")); // a ghosts under the pointer
+		vi.advanceTimersByTime(list.holdMs);
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); // collapsing on schedule
+		vi.advanceTimersByTime(list.collapseMs);
+		await list.updateComplete;
+		expect(rowEl(root, "a")).toBeNull(); // gone, pointer still inside
+	});
+
+	it("never renders the whole-list pointer-events lockout", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a", "b"));
+		await setItems(items("b"));
+		vi.advanceTimersByTime(list.holdMs); // collapse begins: motion in flight
+		await list.updateComplete;
+		expect(root.querySelector(".sw-ll-items")?.classList.contains("sw-ll-lockout")).toBe(false);
+	});
+
+	it("a click on a row below a collapsing row is swallowed mid-motion, and lands after", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a", "b", "c"));
+		const clicks = countItemClicks(list);
+		await setItems(items("a", "c")); // b ghosts
+		vi.advanceTimersByTime(list.holdMs); // b begins collapsing: rows below it are moving
+		await list.updateComplete;
+		vi.advanceTimersByTime(100); // mid-motion
+		const c = rowEl(root, "c");
+		if (c === null) throw new Error("row c missing");
+		fire(c, "click");
+		expect(clicks()).toBe(0); // swallowed: c was pulled upward by b's collapse
+		vi.advanceTimersByTime(list.collapseMs - 100); // motion ends, b removed
+		await list.updateComplete;
+		vi.advanceTimersByTime(60); // past the brief guard tail
+		fire(c, "click");
+		expect(clicks()).toBe(1); // the same click lands once the world is still
+	});
+
+	it("a click on a row ABOVE the collapsing row lands even mid-motion (target-scoped)", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a", "b", "c"));
+		const clicks = countItemClicks(list);
+		await setItems(items("a", "c")); // b ghosts
+		vi.advanceTimersByTime(list.holdMs);
+		await list.updateComplete;
+		vi.advanceTimersByTime(100); // mid-motion
+		const a = rowEl(root, "a");
+		if (a === null) throw new Error("row a missing");
+		fire(a, "click");
+		expect(clicks()).toBe(1); // a never moved: it stays clickable throughout
+	});
+
+	it("a click just after the motion ended is still swallowed (the guard tail)", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a", "b", "c"));
+		const clicks = countItemClicks(list);
+		await setItems(items("a", "c"));
+		vi.advanceTimersByTime(list.holdMs + list.collapseMs); // b's collapse completes, b removed
+		await list.updateComplete;
+		expect(rowEl(root, "b")).toBeNull();
+		vi.advanceTimersByTime(20); // within CLICK_GUARD_MS of the motion's end
+		const c = rowEl(root, "c");
+		if (c === null) throw new Error("row c missing");
+		fire(c, "click");
+		expect(clicks()).toBe(0); // the guard outlives the removed row, briefly
+	});
+
+	it("an entering row guards the rows it pushes down, until it settles", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a"));
+		const clicks = countItemClicks(list);
+		await setItems(items("new", "a")); // new grows in at the top; a moves down
+		vi.advanceTimersByTime(100); // mid-entrance
+		const a = rowEl(root, "a");
+		if (a === null) throw new Error("row a missing");
+		fire(a, "click");
+		expect(clicks()).toBe(0);
+		vi.advanceTimersByTime(list.enterMs - 100 + 60); // entrance done + tail passed
+		fire(a, "click");
+		expect(clicks()).toBe(1);
+	});
+
+	it("a swallowed mid-motion click synthesizes no megacolumn activation (the guard beats the controller)", async () => {
+		// The controller's pointer-activation synthesis listens for clicks on the
+		// container in the bubble phase; the guard stops propagation in the capture
+		// phase at the list host, upstream of every bubble listener -- so a swallowed
+		// click must produce no megacolumn-key either.  The post-motion click both
+		// lands AND synthesizes, proving the listener was there for the guard to beat.
+		const { root, list, setItems } = await makeMegaRig(items("a", "b", "c"));
+		let forwards = 0;
+		root.addEventListener("megacolumn-key", () => {
+			forwards += 1;
+		});
+		await setItems(items("a", "c")); // b ghosts
+		vi.advanceTimersByTime(list.holdMs); // b begins collapsing: c is a moving target
+		await list.updateComplete;
+		vi.advanceTimersByTime(100); // mid-motion
+		const c = rowEl(root, "c");
+		if (c === null) throw new Error("row c missing");
+		fire(c, "click");
+		expect(forwards).toBe(0); // guarded: not even a synthesized activation
+		vi.advanceTimersByTime(list.collapseMs - 100); // motion ends
+		await list.updateComplete;
+		vi.advanceTimersByTime(60); // past the guard tail
+		fire(c, "click");
+		expect(forwards).toBe(1); // the world is still: the click activates c
+	});
+
+	it("the megacolumn mousedown focus-sync respects the same guard", async () => {
+		const { ctl, list, setItems, root } = await makeMegaRig(items("a", "b", "c"));
+		const c = rowEl(root, "c");
+		const a = rowEl(root, "a");
+		if (c === null || a === null) throw new Error("rows missing");
+		fire(c, "mousedown");
+		expect(ctl.nav.focusedId).toBe("c"); // still world: mousedown syncs focus
+		await setItems(items("a", "c")); // b ghosts
+		vi.advanceTimersByTime(list.holdMs); // b collapsing
+		await list.updateComplete;
+		vi.advanceTimersByTime(100); // mid-motion
+		fire(a, "mousedown");
+		expect(ctl.nav.focusedId).toBe("a"); // above the motion: sync lands
+		fire(c, "mousedown");
+		expect(ctl.nav.focusedId).toBe("a"); // below the motion: sync swallowed
+		vi.advanceTimersByTime(list.collapseMs - 100); // motion ends
+		await list.updateComplete;
+		vi.advanceTimersByTime(60);
+		fire(c, "mousedown");
+		expect(ctl.nav.focusedId).toBe("c"); // still again: sync lands
+	});
+
+	it("guards shadow-mode rows the same way (composed events cross the boundary)", async () => {
+		const list = document.createElement("sw-live-list") as SwLiveList<Item>;
+		list.keyOf = keyOf;
+		list.renderItem = renderItem;
+		list.items = items("a", "b", "c");
+		document.body.appendChild(list);
+		await list.updateComplete;
+		const clicks = countItemClicks(list);
+		list.items = items("a", "c"); // b ghosts
+		await list.updateComplete;
+		vi.advanceTimersByTime(list.holdMs); // b collapsing
+		await list.updateComplete;
+		vi.advanceTimersByTime(100);
+		const slot = (key: string): Element | null =>
+			list.shadowRoot?.querySelector(`[data-key="${key}"]`) ?? null;
+		const c = slot("c");
+		const a = slot("a");
+		if (c === null || a === null) throw new Error("rows missing");
+		fire(c, "click");
+		expect(clicks()).toBe(0); // below the collapse: swallowed
+		fire(a, "click");
+		expect(clicks()).toBe(1); // above it: lands
+	});
+});
+
+describe("freeze mode (the opt-in): geometry holds still under the pointer", () => {
+	// The old default, preserved behind pointerSafety="freeze": hovering defers
+	// hold-expiry AND insertions, pointer-leave releases with the stagger, and the
+	// pointer-events lockout (+ graceMs tail) covers what motion does happen.
+
+	it("the hover pin defers insertions (position aim; the freeze rule)", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a"));
+		list.pointerSafety = "freeze";
+		list.dispatchEvent(new Event("pointerenter"));
+		await setItems(items("new", "a"));
+		expect(rowEl(root, "new")?.getAttribute("aria-hidden")).toBe("true"); // pending
+		list.dispatchEvent(new Event("pointerleave"));
+		await list.updateComplete;
+		expect(rowEl(root, "new")?.getAttribute("aria-hidden")).toBeNull();
+	});
+
+	it("removal still ghosts immediately under the pointer (only geometry defers, never data)", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a", "b"));
+		list.pointerSafety = "freeze";
+		list.dispatchEvent(new Event("pointerenter"));
+		await setItems(items("b")); // a leaves the data
+		const ghost = rowEl(root, "a");
+		expect(ghost?.textContent).toContain("(done)"); // committed variant, at once
+		expect(ghost?.getAttribute("aria-hidden")).toBeNull(); // held open, not collapsed
+	});
+
+	it("applies the pointer-events lockout while geometry is in motion", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a", "b"));
+		list.pointerSafety = "freeze";
+		await setItems(items("b")); // pointer outside: motion proceeds
+		vi.advanceTimersByTime(list.holdMs); // collapse begins
+		await list.updateComplete;
+		expect(root.querySelector(".sw-ll-items")?.classList.contains("sw-ll-lockout")).toBe(true);
+	});
+
+	it("a focused ghost held by both pins survives the key pin decaying (hover holds on)", async () => {
+		const { list, setItems, root, key } = await makeMegaRig(items("a", "b"));
+		list.pointerSafety = "freeze";
+		list.dispatchEvent(new Event("pointerenter"));
+		key("ArrowUp"); // pin armed; focus stays on a
+		await setItems(items("b"));
+		vi.advanceTimersByTime(list.holdMs); // expired under both pins
+		vi.advanceTimersByTime(list.keyPinMs); // key pin decays; pointer still in
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull();
+		list.dispatchEvent(new Event("pointerleave")); // last pin releases
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true");
+	});
+
+	it("a focused ghost held by both pins survives the pointer leaving (key pin holds on)", async () => {
+		const { list, setItems, root, key } = await makeMegaRig(items("a", "b"));
+		list.pointerSafety = "freeze";
+		list.dispatchEvent(new Event("pointerenter"));
+		await setItems(items("b"));
+		vi.advanceTimersByTime(list.holdMs - 50);
+		key("ArrowUp"); // typing resumes just before the hold expires; focus is on a
+		vi.advanceTimersByTime(50); // expired under both pins
+		list.dispatchEvent(new Event("pointerleave")); // hover releases; the key pin still holds
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull();
+		vi.advanceTimersByTime(list.keyPinMs); // last pin releases
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true");
+	});
+
+	it("pointer-leave releases deferred collapses in hold-expiry order, staggered", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a", "b", "c"));
+		list.pointerSafety = "freeze";
+		list.dispatchEvent(new Event("pointerenter"));
+		await setItems(items("b", "c")); // a removed at t=0
+		vi.advanceTimersByTime(500);
+		await setItems(items("c")); // b removed at t=500
+		vi.advanceTimersByTime(list.holdMs + 500); // both holds long expired under the pointer
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull();
+		expect(rowEl(root, "b")?.getAttribute("aria-hidden")).toBeNull();
+		list.dispatchEvent(new Event("pointerleave"));
+		await list.updateComplete;
+		// The backlog does not detonate as one batch: a (expired first) goes now,
+		// b follows a beat later -- each exit stays narratable.
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true");
+		expect(rowEl(root, "b")?.getAttribute("aria-hidden")).toBeNull();
+		vi.advanceTimersByTime(80);
+		await list.updateComplete;
+		expect(rowEl(root, "b")?.getAttribute("aria-hidden")).toBe("true");
+	});
+
+	it("switching back to click-guard releases the deferred backlog", async () => {
+		const { list, setItems, root } = await makeMegaRig(items("a", "b"));
+		list.pointerSafety = "freeze";
+		list.dispatchEvent(new Event("pointerenter"));
+		await setItems(items("new", "b")); // a removed AND new inserted, both deferred
+		vi.advanceTimersByTime(list.holdMs + 100); // a's hold expires under the pointer
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull(); // deferred ghost
+		expect(rowEl(root, "new")?.getAttribute("aria-hidden")).toBe("true"); // pending
+		list.pointerSafety = "click-guard"; // the pointer never left
+		await list.updateComplete;
+		expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); // collapsing now
+		expect(rowEl(root, "new")?.getAttribute("aria-hidden")).toBeNull(); // entering now
+	});
+});
+
+describe("mega mode: fail closed", () => {
+	// Contract-violation throws happen in custom-element reactions
+	// (createRenderRoot / connectedCallback), which report to the window instead
+	// of propagating to the DOM call that triggered them -- so observe the report.
+	function reportsFrom(act: () => void): string[] {
+		const reported: string[] = [];
+		const onError = (e: ErrorEvent): void => {
+			reported.push(e.message);
+			e.preventDefault();
+		};
+		window.addEventListener("error", onError);
+		act();
+		window.removeEventListener("error", onError);
+		return reported;
+	}
+
+	it("throws outside any [data-mc-container]", () => {
+		const reported = reportsFrom(() => {
+			const list = document.createElement("sw-live-list") as SwLiveList<Item>;
+			list.setAttribute("megacolumn", "");
+			list.keyOf = keyOf;
+			list.renderItem = renderItem;
+			document.body.appendChild(list);
+		});
+		expect(reported.some((m) => /outside any \[data-mc-container\]/.test(m))).toBe(true);
+	});
+
+	it("throws on element children (mega mode has no slots)", () => {
+		const container = document.createElement("div");
+		container.setAttribute("data-mc-container", "");
+		document.body.appendChild(container);
+		const reported = reportsFrom(() => {
+			const list = document.createElement("sw-live-list") as SwLiveList<Item>;
+			list.setAttribute("megacolumn", "");
+			list.innerHTML = `<div slot="empty">nope</div>`;
+			list.keyOf = keyOf;
+			list.renderItem = renderItem;
+			container.appendChild(list);
+		});
+		expect(reported.some((m) => /no slots/.test(m))).toBe(true);
+	});
+
+	it("throws on itemStyles in mega mode", async () => {
+		const { list } = await makeMegaRig(items("a"));
+		const { css } = await import("lit");
+		list.itemStyles = css`.x { color: red; }`;
+		await expect(list.updateComplete).rejects.toThrow(/itemStyles has no effect/);
+	});
+
+	it("throws on an unknown pointerSafety value", async () => {
+		const { list } = await makeMegaRig(items("a"));
+		list.pointerSafety = "frozen" as typeof list.pointerSafety; // a markup typo, say
+		await expect(list.updateComplete).rejects.toThrow(/pointerSafety/);
+	});
+});
+
+describe("shadow mode regression (the pieces this stage touched)", () => {
+	it("renders in its own shadow root, no data-mc-row stamped, empty slot alive", async () => {
+		const list = document.createElement("sw-live-list") as SwLiveList<Item>;
+		list.keyOf = keyOf;
+		list.renderItem = renderItem;
+		list.items = items("a");
+		document.body.appendChild(list);
+		await list.updateComplete;
+		expect(list.shadowRoot).not.toBeNull();
+		const slot = list.shadowRoot?.querySelector(`[data-key="a"]`) as HTMLElement;
+		expect(slot).not.toBeNull();
+		expect(slot.hasAttribute("data-mc-row")).toBe(false);
+		expect(list.shadowRoot?.querySelector("slot[name=empty]")).toBeNull(); // a row is visible
+		list.items = [];
+		await list.updateComplete;
+		vi.advanceTimersByTime(list.holdMs + list.collapseMs);
+		await list.updateComplete;
+		expect(list.shadowRoot?.querySelector("slot[name=empty]")).not.toBeNull();
+	});
+
+	it("renderEmpty wins over the slot in shadow mode too", async () => {
+		const list = document.createElement("sw-live-list") as SwLiveList<Item>;
+		list.keyOf = keyOf;
+		list.renderItem = renderItem;
+		list.items = [];
+		list.renderEmpty = () => html`<div class="idle">idle</div>`;
+		document.body.appendChild(list);
+		await list.updateComplete;
+		expect(list.shadowRoot?.querySelector(".idle")).not.toBeNull();
+		expect(list.shadowRoot?.querySelector("slot[name=empty]")).toBeNull();
+	});
+});
diff --git a/packages/yukey/src/sw-live-list.ts b/packages/yukey/src/sw-live-list.ts
new file mode 100644
index 0000000..2a702be
--- /dev/null
+++ b/packages/yukey/src/sw-live-list.ts
@@ -0,0 +1,809 @@
+// sw-live-list: a keyed live list whose rows vanish safely under a pointer.
+// Data replaces wholesale per tick (5Hz here); the owner supplies keying and
+// rendering, this component owns row lifecycle and click safety:
+//
+//   enter ──► live ──────────► committed ────► collapsing ──► removed
+//   (grow-in:   (content updates  (item left the   (grid-rows      (DOM dropped;
+//    motion)     flow freely)      data: full size,  1fr→0fr:        the guard tail
+//                  ▲               dimmed by owner,   motion)         lingers briefly)
+//                  └── resurrection ──┘              still clickable)
+//
+// The safety goal, in one sentence: clicks must not land on surprising targets.
+// Two strategies answer it -- the `pointerSafety` property picks one:
+//
+// - "click-guard" (the default): motion proceeds under the pointer.  Hovering
+//   defers nothing -- insertions appear immediately, expired holds collapse on
+//   their own schedule -- and the protection is target-scoped and brief: a click
+//   (or mousedown) is swallowed only when its target row is a MOVING TARGET.
+//   A row is a moving target iff some geometry animation (entrance, collapse,
+//   resurrection grow-back) is running, or ended within CLICK_GUARD_MS, whose
+//   animating row sits at-or-above it: a collapse pulls every row below it
+//   upward, an entrance pushes them downward, and rows ABOVE the animating row
+//   have not moved and stay clickable throughout.  All of it derives from the
+//   row state machine at click time (each motion records its top boundary as it
+//   begins) -- no geometry reads, and nothing runs while the list is idle.
+// - "freeze" (the opt-in): geometry holds still under the pointer instead.
+//   While the pointer is inside the host, insertions wait as invisible "pending"
+//   rows and expired holds wait for pointer-leave; content-only updates keep
+//   flowing (ETAs tick under the cursor; nothing moves).  A released backlog of
+//   deferred collapses plays out oldest-expiry-first, one RELEASE_STAGGER_MS
+//   beat apart -- each exit narrates itself, never one batch.  What motion does
+//   happen (a pointer arriving mid-collapse) is covered by an interaction
+//   lockout (pointer-events: none on the item container) plus `graceMs` after
+//   the last motion ends.  For surfaces that want quiescence under the pointer;
+//   the price is that the world visibly waits for the mouse.
+//
+// In BOTH modes the data is never deferred: a removed item ghosts immediately,
+// an inserted item enters the row state immediately.  Only geometry (freeze) or
+// click delivery (click-guard) bends; the model acts as if no animation existed.
+//
+// The remaining rules, mode-independent:
+// - The committed hold is never guarded or locked: a stationary row is a safe
+//   row, and selecting a just-done ship is still correct.
+// - Display order: existing rows never reorder (a DOM move kills an in-flight CSS
+//   transition); new keys splice in next to their nearest already-displayed data
+//   neighbor.  Owners must feed a stable sort for that to match the data order.
+// - resetKey change (station switch, epoch change) drops all state instantly:
+//   no farewell parade of the old identity's rows.  The first-ever populate renders
+//   the same way -- rows appear live, no enter animation, no lockout.
+//
+// Megacolumn mode (the static `megacolumn` attribute, set in markup): the list
+// composes with the megacolumn focus model (megacolumn-controller.ts holds the
+// DOM contract).  Rows render into the OWNER's shadow tree (light DOM -- the
+// controller's scan and aria-activedescendant cannot cross shadow boundaries),
+// and this component stamps row identity itself, per phase:
+//
+//   - live/entering/committed rows carry data-mc-row="<key>": navigation stops.
+//     Ghosts included -- a stationary ghost is safe and stays actionable for the
+//     keyboard exactly as it stays clickable for the mouse; its aux control is
+//     gone from the rendered content (renderItem's committed variant drops the x),
+//     so hasAux turns false in the scan by itself.
+//   - pending/collapsing rows are aria-hidden="true": zero-height, closed to
+//     sight -- and that one attribute is also what the controller's scan skips,
+//     so they are never navigation stops.  Staying registered keeps every
+//     wrapper a row (widgets inside rely on a [data-mc-row] ancestor).
+//     When a focused ghost collapses, the machine's vanished-row fallback moves
+//     focus and the controller's churn lockout covers the move.
+//   - everything INSIDE a row stays the owner's renderItem business:
+//     data-mc-main/aux/input, data-info, hotkey markings, sw-buttons.
+//
+// The keyboard's version of the pointer story is the KEY-ACTIVITY PIN (both
+// pointerSafety modes; the keyboard problem is the same in each): while keys
+// are active in the ancestor [data-mc-container] (decaying `keyPinMs` after the
+// last one), the FOCUSED row's expired hold is deferred -- the row the user is
+// aiming at cannot collapse away mid-burst.  Only the focused row's: a pointer
+// aims at positions, but the keyboard aims by identity, and other rows' motion
+// cannot invalidate that aim -- so unfocused ghosts collapse on their natural
+// schedule (removals staged at different moments exit at different moments),
+// and when the aim moves off a deferred ghost, the next keydown releases it.
+// For the same reason the key pin never defers insertions: a keystroke that
+// prepends a row must see it appear immediately.  In freeze mode the hover pin
+// and the key pin may hold the same ghost; it collapses when the last releases.
+// In click-guard mode the list also swallows mousedowns on moving-target rows
+// before they bubble to the megacolumn controller's focus-sync (a focus-sync
+// onto a moving target is the same race as a click; the guard lives here
+// because the list owns its own physics).
+// After any self-driven re-render (phase timers fire outside host renders), the
+// list dispatches `megacolumn-rescan` so the controller re-reads the rows.
+// Mega mode has no shadow root, hence no slots: the empty state is the optional
+// renderEmpty render-prop (which shadow mode also honors, over its default slot),
+// and itemStyles is refused (the owner's own stylesheet reaches light-DOM rows).
+// The row-lifecycle styles are adopted into the owner's root once, all their
+// class names sw-ll- prefixed so nothing collides in the shared tree.
+//
+// The row state machine lives here in component state, outside the template;
+// render() is a pure map over it (keyed repeat() keeps per-row DOM identity across
+// re-renders, so CSS transitions run undisturbed).  Animation is pure CSS; timers
+// only advance phases.  Design per the vanishing-rows study (2026-08-20).
+
+import type { CSSResult, PropertyValues, TemplateResult } from "lit";
+import { adoptStyles, css, html, LitElement, nothing } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+import { classMap } from "lit/directives/class-map.js";
+import { repeat } from "lit/directives/repeat.js";
+import { adoptIntoRoot } from "./adopt-into-root.js";
+
+// The phase vocabulary owners see: renderItem() renders the committed ("✓ done")
+// variant itself -- this component only guarantees geometry and click-safety
+// semantics.  Composition note: morph ONE element's content across the phase
+// flip rather than swapping whole templates.  An acknowledgment visual on a
+// control (a press flash on the x whose activation removed the row) can only
+// survive into the committed render if the flashed element itself survives it;
+// a per-phase template swap destroys the very control being acknowledged.
+export type LiveListPhase = "live" | "committed";
+
+export interface ItemClickDetail<T> {
+	key: string;
+	item: T;
+	phase: LiveListPhase;
+}
+
+// The row-lifecycle styles, shared by both render modes.  Class names carry an
+// sw-ll- prefix because mega mode renders into the owner's tree, where bare
+// names like "closed" would be collision bait.
+const lifecycleStyles = css`
+	/* The lockout class only ever renders in freeze mode; click-guard swallows
+	   stale clicks per target instead of disabling the pointer wholesale. */
+	.sw-ll-items.sw-ll-lockout {
+		pointer-events: none;
+	}
+	.sw-ll-items:focus {
+		outline: none;
+	}
+	.sw-ll-rowslot {
+		display: grid;
+		grid-template-rows: 1fr;
+		transition: grid-template-rows var(--sw-live-list-collapse, 300ms) ease;
+		/* The rowslot is pure geometry machinery and must be free to reach 0 height
+		   (closed rows, and both ends of the grow/collapse animations).  Owners set
+		   hit-target floors on their row CONTENT; in mega mode a [data-mc-row]
+		   min-height rule would land on this element and clamp the animations open
+		   (entrances pop, collapses snap, pending rows hold blank space). */
+		min-height: 0;
+	}
+	.sw-ll-rowslot.sw-ll-grew {
+		animation: sw-live-list-enter var(--sw-live-list-enter, 300ms) ease;
+	}
+	.sw-ll-rowslot.sw-ll-closed {
+		grid-template-rows: 0fr;
+	}
+	.sw-ll-rowslot > .sw-ll-clip {
+		overflow: hidden;
+	}
+	@keyframes sw-live-list-enter {
+		from {
+			grid-template-rows: 0fr;
+		}
+	}
+	/* Geometry snaps instantly under reduced motion; the hold phase stays (it is
+	   semantics -- an announcement plus click safety -- not decoration), and the
+	   grace lockout still covers the snap (the JS side zeroes its durations too). */
+	@media (prefers-reduced-motion: reduce) {
+		.sw-ll-rowslot {
+			transition: none;
+			animation: none;
+		}
+	}
+`;
+
+// The mega-mode sheet: the lifecycle styles plus the host display rule that
+// :host would otherwise provide, adopted into the OWNER's root (once per root).
+const megaModeStyles = css`
+	sw-live-list {
+		display: block;
+	}
+	${lifecycleStyles}
+`;
+
+// Internal row phases.  "pending" is an insertion deferred by freeze mode's
+// hover pin: rendered closed (0fr, invisible, aria-hidden) so it holds its
+// display position, shown only when the pointer leaves.
+type RowPhase = "pending" | "entering" | "live" | "committed" | "collapsing";
+
+// Spacing between deferred collapses when a release lets a backlog go: enough
+// separation that each exit reads as its own event, short enough that a backlog
+// of several rows still clears in well under a second.
+const RELEASE_STAGGER_MS = 80;
+
+// How long a row stays a swallowed click target after the motion that moved it
+// ends (click-guard mode).  Short on purpose: the guard covers a click already
+// in flight toward a target that moved; anything much longer reads as lag.
+const CLICK_GUARD_MS = 50;
+
+// One geometry animation's guard record (click-guard mode): rows at-or-below
+// the animating row are moving targets until `until` (the motion's duration plus
+// the CLICK_GUARD_MS tail).  While the animating row is displayed, movedKey names
+// it; once it is removed from display (a finished collapse), anchorKey names the
+// display neighbor that was just above it -- the topmost row that did NOT move --
+// and null means the motion happened at the very top, so every row moved.
+interface GuardEntry {
+	movedKey: string | null;
+	anchorKey: string | null;
+	until: number;
+}
+
+interface RowState<T> {
+	key: string;
+	item: T; // last-known content; frozen at commit time for ghosts
+	phase: RowPhase;
+	// Entered via the grow-in animation.  Kept for the row's whole life: removing the
+	// animation class mid-play would snap the geometry, and keeping it after is inert.
+	grew: boolean;
+	// The hold timer fired while a pin deferred the collapse; releaseDeferred plays
+	// it out once no pin (hover: any row; key: the focused row) still applies.
+	holdExpired: boolean;
+	// Order the holds expired in, for the staggered release (assigned at first expiry).
+	holdExpiredSeq: number;
+	timer: number | undefined; // the phase-advance timer; at most one per row
+}
+
+@customElement("sw-live-list")
+export class SwLiveList<T = unknown> extends LitElement {
+	static styles = [
+		css`
+			:host {
+				display: block;
+			}
+		`,
+		lifecycleStyles,
+	];
+
+	@property({ attribute: false }) items: T[] = [];
+	@property({ attribute: false }) keyOf!: (item: T) => string;
+	@property({ attribute: false }) renderItem!: (item: T, phase: LiveListPhase) => TemplateResult;
+	// The empty state, rendered when no row is visible.  In shadow mode the default
+	// slot alternative still works; mega mode has no shadow root, so this render-prop
+	// is the only way there.
+	@property({ attribute: false }) renderEmpty?: () => TemplateResult;
+	// Owner-supplied styles for rendered items, adopted into this shadow root
+	// (the owner's own stylesheet can't reach in here).  Shadow mode only:
+	// mega-mode rows live in the owner's tree, already inside its stylesheet's reach.
+	@property({ attribute: false }) itemStyles?: CSSResult;
+	// Identity of the data source.  When it changes, all row state drops and the list
+	// renders fresh -- no exit animations for the old identity's rows.
+	@property() resetKey = "";
+	// Which strategy answers the click-safety goal (see the header).  "click-guard"
+	// (the default): motion proceeds and stale clicks on moving-target rows are
+	// swallowed.  "freeze": geometry defers while the pointer is inside the host.
+	// Runtime-togglable: a host may raise "freeze" as a quasi-lock and drop it again;
+	// leaving freeze releases whatever the hover pin was deferring.
+	@property({ attribute: "pointer-safety" }) pointerSafety: "click-guard" | "freeze" =
+		"click-guard";
+	// Timing knobs, defaulted per the vanishing-rows study.
+	// (graceMs is freeze-mode machinery: how long the pointer-events lockout
+	// outlives the last motion.  Click-guard's tail is CLICK_GUARD_MS, a constant:
+	// it is a click-race window, not a feel knob.)
+	@property({ type: Number }) holdMs = 1200;
+	@property({ type: Number }) collapseMs = 300;
+	@property({ type: Number }) graceMs = 200;
+	@property({ type: Number }) enterMs = 300;
+	// How long the key-activity pin outlives the last keydown (mega mode; see header).
+	// Short: it bridges the gaps inside one burst of typing, and the focused row's
+	// protection should die almost as soon as the fingers stop.
+	@property({ type: Number }) keyPinMs = 200;
+
+	// Row state, insertion-ordered (= display order).  Deliberately not @state:
+	// mutations call requestUpdate explicitly, and items changes re-render anyway.
+	private rows = new Map<string, RowState<T>>();
+	private motionCount = 0; // geometry animations in flight
+	private graceTimer: number | undefined;
+	private pinned = false;
+	private hasReconciled = false;
+	private expirySeq = 0; // orders deferred holds for the staggered release
+	// The click guard's motion records (click-guard mode; see isMovingTarget).
+	// Appended when a motion begins, pruned when consulted: no timers, no idle work.
+	private guards: GuardEntry[] = [];
+	// Bumped on reset/disconnect.  Motion-end timers are anonymous (self-paired with
+	// their motion start), so they check the epoch instead of being cleared by handle.
+	private epoch = 0;
+	// Mega mode plumbing: fixed at createRenderRoot (the attribute is static markup);
+	// keyPinned is the key-activity pin, its timer the decay.
+	private mega = false;
+	private megaContainer: HTMLElement | null = null;
+	private keyPinned = false;
+	private keyPinTimer: number | undefined;
+
+	@query(".sw-ll-items") private itemsEl?: HTMLElement;
+
+	constructor() {
+		super();
+		// Pin listeners live on the host: under freeze mode's lockout the item
+		// container loses pointer-events and hit-testing falls through to the host,
+		// which keeps hover coverage.
+		this.addEventListener("pointerenter", this.onPointerEnter);
+		this.addEventListener("pointerleave", this.onPointerLeave);
+		// The click guard intercepts in the CAPTURE phase on the host: it must beat
+		// both the row content's own listeners (an sw-button acting on a stale click)
+		// and, in mega mode, the megacolumn controller's bubble-phase mousedown
+		// focus-sync on the ancestor container -- stopping propagation here covers
+		// every downstream consumer in one place, and the decision stays in the list
+		// (it owns the physics the guard is derived from).
+		this.addEventListener("click", this.onGuardedPointer, { capture: true });
+		this.addEventListener("mousedown", this.onGuardedPointer, { capture: true });
+	}
+
+	// Mega mode renders into the light DOM: the megacolumn scan and
+	// aria-activedescendant cannot cross a shadow boundary, so the rows must live
+	// in the owner's tree.  Decided here (once, before the first render) off the
+	// static attribute; toggling it later is not a thing.
+	protected override createRenderRoot(): HTMLElement | DocumentFragment {
+		if (!this.hasAttribute("megacolumn")) return super.createRenderRoot();
+		this.mega = true;
+		if (this.children.length > 0)
+			throw new Error(
+				"sw-live-list: megacolumn mode renders into the light DOM and has no slots; " +
+					"use the renderEmpty render-prop",
+			);
+		adoptIntoRoot(this.getRootNode(), megaModeStyles, "data-sw-live-list");
+		return this;
+	}
+
+	override connectedCallback(): void {
+		super.connectedCallback(); // first connect runs createRenderRoot, deciding mega
+		if (this.mega) {
+			// The key-activity pin listens on the column's one real focusable: every
+			// keydown a user aims anywhere in the column lands there first.
+			const container = this.closest<HTMLElement>("[data-mc-container]");
+			if (container === null)
+				throw new Error("sw-live-list: megacolumn mode outside any [data-mc-container]");
+			this.megaContainer = container;
+			container.addEventListener("keydown", this.onKeyActivity);
+		}
+	}
+
+	override disconnectedCallback(): void {
+		super.disconnectedCallback();
+		this.hardReset();
+		this.pinned = false; // pointerleave may never fire on a removed element
+		this.megaContainer?.removeEventListener("keydown", this.onKeyActivity);
+		this.megaContainer = null;
+		window.clearTimeout(this.keyPinTimer);
+		this.keyPinTimer = undefined;
+		this.keyPinned = false;
+	}
+
+	override willUpdate(changed: PropertyValues<this>): void {
+		if (this.mega && this.itemStyles !== undefined)
+			throw new Error(
+				"sw-live-list: itemStyles has no effect in megacolumn mode; " +
+					"style rows from the owner's own stylesheet",
+			);
+		if (this.pointerSafety !== "click-guard" && this.pointerSafety !== "freeze")
+			throw new Error(
+				`sw-live-list: unknown pointerSafety "${this.pointerSafety}" ` +
+					'(the modes are "click-guard" and "freeze")',
+			);
+		// Leaving freeze mode releases whatever the hover pin deferred (the pointer
+		// may never leave); entering it starts deferring from here on.  releaseDeferred
+		// re-checks every deferral condition itself, so one unconditional call covers
+		// both directions.
+		if (changed.has("pointerSafety")) this.releaseDeferred();
+		if (changed.has("resetKey") && this.hasReconciled) this.hardReset();
+		if (changed.has("items") || changed.has("resetKey")) this.reconcile();
+	}
+
+	protected override updated(changed: PropertyValues<this>): void {
+		if (changed.has("itemStyles") && this.itemStyles !== undefined && this.shadowRoot !== null) {
+			const own = (this.constructor as typeof SwLiveList).elementStyles;
+			adoptStyles(this.shadowRoot, [...own, this.itemStyles]);
+		}
+		// Mega mode: any completed render may have changed the row set under the
+		// megacolumn scan -- phase timers re-render outside host renders entirely, and
+		// even host-driven data changes land here AFTER the owner's own hostUpdated
+		// scan ran (this element updates asynchronously, later in the same task).
+		// Announce every time; re-scans are idempotent and O(rows).
+		if (this.mega) this.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true }));
+	}
+
+	// Whether freeze mode governs the pointer story right now (see the header).
+	private get frozen(): boolean {
+		return this.pointerSafety === "freeze";
+	}
+
+	// --- reconciliation: data ticks in, row phases advance ---
+
+	private reconcile(): void {
+		const fresh = !this.hasReconciled;
+		this.hasReconciled = true;
+		const dataByKey = new Map<string, T>();
+		for (const item of this.items) dataByKey.set(this.keyOf(item), item);
+		// Merged display order: existing rows keep their relative order; each new key
+		// splices in right after the nearest preceding data key that is already displayed.
+		const order = [...this.rows.keys()];
+		let anchor = -1; // index in `order` of the last data key seen that is displayed
+		for (const key of dataByKey.keys()) {
+			const idx = order.indexOf(key);
+			if (idx !== -1) {
+				anchor = idx;
+				continue;
+			}
+			anchor += 1;
+			order.splice(anchor, 0, key);
+		}
+		const next = new Map<string, RowState<T>>();
+		for (const key of order) {
+			const row = this.rows.get(key);
+			const item = dataByKey.get(key);
+			if (row === undefined) {
+				if (item === undefined) continue; // unreachable: order = rows ∪ data
+				const newRow: RowState<T> = {
+					key,
+					item,
+					phase: "live",
+					grew: false,
+					holdExpired: false,
+					holdExpiredSeq: 0,
+					timer: undefined,
+				};
+				// Fresh renders (first populate, post-reset) skip the entrance: the rows were
+				// not new arrivals, the viewer was.  No animation, no lockout.
+				if (!fresh) {
+					if (this.pinned && this.frozen) newRow.phase = "pending";
+					else this.beginEnter(newRow);
+				}
+				next.set(key, newRow);
+			} else if (item !== undefined) {
+				if (row.phase === "committed" || row.phase === "collapsing") {
+					this.resurrect(row, item);
+				} else {
+					row.item = item; // content-only update; flows in every phase, pinned or not
+				}
+				next.set(key, row);
+			} else {
+				// Key absent from data: begin the exit (or continue one already underway).
+				if (row.phase === "pending") {
+					continue; // never shown: no farewell for a row nobody saw
+				}
+				if (row.phase === "live" || row.phase === "entering") this.beginHold(row);
+				next.set(key, row);
+			}
+		}
+		this.rows = next;
+	}
+
+	// --- phase transitions (each clears the row's previous timer via setRowTimer) ---
+
+	private beginEnter(row: RowState<T>): void {
+		row.phase = "entering";
+		row.grew = true;
+		const d = this.motionDuration(this.enterMs);
+		this.beginMotion(row, d);
+		this.setRowTimer(row, d, () => {
+			row.phase = "live";
+			this.requestUpdate();
+		});
+	}
+
+	private beginHold(row: RowState<T>): void {
+		row.phase = "committed";
+		// The item stays as last rendered: committed rows freeze their content.
+		this.setRowTimer(row, this.holdMs, () => {
+			if (this.collapseDeferred(row)) {
+				// Collapse deferred until no pin covers this row anymore; the seq
+				// records expiry order for the staggered release.
+				row.holdExpired = true;
+				row.holdExpiredSeq = this.expirySeq++;
+				return;
+			}
+			this.beginCollapse(row);
+			this.requestUpdate();
+		});
+	}
+
+	private beginCollapse(row: RowState<T>): void {
+		row.phase = "collapsing";
+		row.holdExpired = false;
+		const d = this.motionDuration(this.collapseMs);
+		this.beginMotion(row, d);
+		this.setRowTimer(row, d, () => {
+			this.removeRow(row);
+			this.requestUpdate();
+		});
+	}
+
+	private resurrect(row: RowState<T>, item: T): void {
+		const wasCollapsing = row.phase === "collapsing";
+		this.clearRowTimer(row);
+		row.phase = "live";
+		row.holdExpired = false;
+		row.item = item;
+		// Growing back from mid-collapse is geometry motion too: the motion window
+		// covers it (lockout in freeze mode, the click guard in click-guard mode).
+		if (wasCollapsing) this.beginMotion(row, this.motionDuration(this.collapseMs));
+	}
+
+	private removeRow(row: RowState<T>): void {
+		// If real DOM focus is inside the row being dropped, move it out rather than
+		// letting it die silently: to the mega container (the column's one focusable)
+		// in mega mode, else to the list's own item container.
+		const slot = this.renderRoot.querySelector(`.sw-ll-rowslot[data-key="${CSS.escape(row.key)}"]`);
+		const focusRoot = this.shadowRoot ?? (this.getRootNode() as Document | ShadowRoot);
+		const active = focusRoot.activeElement;
+		if (slot !== null && active !== null && slot.contains(active)) {
+			(this.megaContainer ?? this.itemsEl)?.focus();
+		}
+		// The row's guard entries outlive it (the guard tail); re-anchor them to the
+		// display neighbor above, which is the topmost row that did NOT move.  Entries
+		// already anchored on this row re-anchor the same way (a cascade of collapses).
+		const keys = [...this.rows.keys()];
+		const idx = keys.indexOf(row.key);
+		const anchor = idx > 0 ? (keys[idx - 1] ?? null) : null;
+		for (const g of this.guards) {
+			if (g.movedKey === row.key) {
+				g.movedKey = null;
+				g.anchorKey = anchor;
+			} else if (g.movedKey === null && g.anchorKey === row.key) {
+				g.anchorKey = anchor;
+			}
+		}
+		this.rows.delete(row.key);
+	}
+
+	// --- lockout accounting: a counter of motions in flight, plus a grace linger ---
+	// (The lockout only renders in freeze mode; the counter itself is mode-neutral
+	// motion bookkeeping, and the guard entries recorded here are what click-guard
+	// mode consults instead.)
+
+	private get locked(): boolean {
+		return this.motionCount > 0 || this.graceTimer !== undefined;
+	}
+
+	// Every motion start schedules its own end: the pairing survives any phase
+	// transition happening in between (e.g. a commit landing mid-enter).  The
+	// motion's guard entry is recorded here too -- the one place every geometry
+	// animation already passes through.
+	private beginMotion(row: RowState<T>, durationMs: number): void {
+		const now = performance.now();
+		this.guards = this.guards.filter((g) => g.until > now);
+		this.guards.push({
+			movedKey: row.key,
+			anchorKey: null,
+			until: now + durationMs + CLICK_GUARD_MS,
+		});
+		this.motionCount++;
+		if (this.graceTimer !== undefined) {
+			window.clearTimeout(this.graceTimer);
+			this.graceTimer = undefined;
+		}
+		const epoch = this.epoch;
+		window.setTimeout(() => {
+			if (this.epoch === epoch) this.endMotion();
+		}, durationMs);
+		this.requestUpdate();
+	}
+
+	private endMotion(): void {
+		this.motionCount--;
+		if (this.motionCount === 0) {
+			this.graceTimer = window.setTimeout(() => {
+				this.graceTimer = undefined;
+				this.requestUpdate();
+			}, this.graceMs);
+		}
+		this.requestUpdate();
+	}
+
+	private motionDuration(ms: number): number {
+		return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : ms;
+	}
+
+	// --- pointer handling: presence tracking, and the mode-split safety story ---
+
+	// Pointer presence is tracked in both modes (cheap, and pointerSafety may flip
+	// mid-hover); only freeze mode DOES anything with it (see hoverPinned).
+	private onPointerEnter = (): void => {
+		this.pinned = true;
+	};
+
+	private onPointerLeave = (): void => {
+		this.pinned = false;
+		this.releaseDeferred();
+	};
+
+	// The hover pin: freeze mode's global geometry deferral.  In click-guard mode
+	// a hovering pointer defers nothing.
+	private get hoverPinned(): boolean {
+		return this.pinned && this.frozen;
+	}
+
+	// The click guard (click-guard mode): swallow a pointer event whose target row
+	// is a moving target, in the capture phase, before any consumer -- row content
+	// handlers, this list's own @click, and (mega mode) the megacolumn controller's
+	// mousedown focus-sync on the ancestor container -- can act on it.  The target
+	// comes off composedPath: in shadow mode the host listener sees e.target
+	// retargeted to the host itself.
+	private onGuardedPointer = (e: Event): void => {
+		if (this.frozen) return; // freeze mode's lockout owns this story instead
+		const target = e.composedPath()[0];
+		if (!(target instanceof Element)) return;
+		const slot = target.closest<HTMLElement>(".sw-ll-rowslot");
+		if (slot === null) return;
+		const key = slot.dataset.key;
+		if (key === undefined || !this.isMovingTarget(key)) return;
+		e.stopPropagation();
+		e.preventDefault();
+	};
+
+	// Whether a click landing on this row right now would land somewhere surprising:
+	// some motion window (live, or ended within its CLICK_GUARD_MS tail) has its
+	// animating row at-or-above this row in display order.  A collapse pulls every
+	// row below it upward and an entrance pushes them downward, so at-or-below the
+	// animating row means displaced; rows above it never moved.  Pure state reads,
+	// only ever executed inside a pointer event's dispatch.
+	private isMovingTarget(key: string): boolean {
+		const now = performance.now();
+		this.guards = this.guards.filter((g) => g.until > now);
+		if (this.guards.length === 0) return false;
+		const keys = [...this.rows.keys()];
+		const idx = keys.indexOf(key);
+		for (const g of this.guards) {
+			if (g.movedKey !== null) {
+				if (idx >= keys.indexOf(g.movedKey)) return true;
+			} else if (g.anchorKey === null) {
+				return true; // the motion was at the top: everything below it moved
+			} else if (idx > keys.indexOf(g.anchorKey)) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	// --- the key-activity pin (mega mode; both pointerSafety modes) ---
+	// While keys are active (decaying keyPinMs after the last), the FOCUSED row's
+	// expired hold defers: the row the user is aiming at cannot collapse away
+	// mid-burst.  Only that row's -- the keyboard aims by identity, so other
+	// ghosts collapse on their natural schedule -- and insertions are never
+	// deferred (see the header: a keystroke that prepends a row must show it
+	// immediately).
+
+	private onKeyActivity = (): void => {
+		this.keyPinned = true;
+		window.clearTimeout(this.keyPinTimer);
+		// No epoch check here (unlike motion-end timers): the decay is valid across
+		// resets -- it only flips the pin off and re-examines whatever rows exist then.
+		this.keyPinTimer = window.setTimeout(() => {
+			this.keyPinTimer = undefined;
+			this.keyPinned = false;
+			this.releaseDeferred();
+		}, this.keyPinMs);
+		// Focus may have moved OFF a deferred ghost since it was deferred; sweep so a
+		// stale deferral dies with the aim rather than riding out continuous typing.
+		// (This listener may run before the controller's on the same keydown, so a
+		// focus-moving key can be one keydown late here -- and the decay backstops it.)
+		this.releaseDeferred();
+	};
+
+	// One pin released (or an aim moved): play out whatever motion no pin covers
+	// anymore.  A backlog of deferred collapses is released in hold-expiry order,
+	// RELEASE_STAGGER_MS apart -- the oldest goes now, the rest are scheduled on
+	// their row timers (which resurrection already clears).  A scheduled release
+	// re-checks the pins when it fires: a pin re-armed mid-stagger re-defers the
+	// row (holdExpired stays set) for the release after that.
+	private releaseDeferred(): void {
+		let changed = false;
+		const backlog: RowState<T>[] = [];
+		for (const row of this.rows.values()) {
+			if (row.phase === "pending" && !this.hoverPinned) {
+				this.beginEnter(row);
+				changed = true;
+			} else if (
+				row.phase === "committed" &&
+				row.holdExpired &&
+				row.timer === undefined && // not already scheduled by an earlier release
+				!this.collapseDeferred(row)
+			) {
+				backlog.push(row);
+			}
+		}
+		backlog.sort((a, b) => a.holdExpiredSeq - b.holdExpiredSeq);
+		backlog.forEach((row, i) => {
+			if (i === 0) {
+				this.beginCollapse(row);
+				changed = true;
+				return;
+			}
+			this.setRowTimer(row, i * RELEASE_STAGGER_MS, () => {
+				if (this.collapseDeferred(row)) return; // re-deferred
+				this.beginCollapse(row);
+				this.requestUpdate();
+			});
+		});
+		if (changed) this.requestUpdate();
+	}
+
+	// Whether a pin defers this row's collapse right now: the hover pin (freeze
+	// mode only) defers every row (positions demand global stillness), the key pin
+	// only the focused one (identity aim; see the header).  The one predicate all
+	// deferral and release decisions share.
+	private collapseDeferred(row: RowState<T>): boolean {
+		return this.hoverPinned || (this.keyPinned && this.isRowFocused(row));
+	}
+
+	// Whether the megacolumn's logical focus sits on this row.  The controller
+	// stamps mc-row-focused on the focused row element, which in mega mode is this
+	// list's own rowslot (the [data-mc-row] carrier) -- read here, never written.
+	// Read from the DOM rather than via aria-activedescendant so this stays
+	// independent of the controller's id-derivation rules.
+	private isRowFocused(row: RowState<T>): boolean {
+		const slot = this.renderRoot.querySelector(`.sw-ll-rowslot[data-key="${CSS.escape(row.key)}"]`);
+		return slot?.classList.contains("mc-row-focused") ?? false;
+	}
+
+	// --- timers and reset plumbing ---
+
+	private setRowTimer(row: RowState<T>, ms: number, fn: () => void): void {
+		this.clearRowTimer(row);
+		row.timer = window.setTimeout(() => {
+			row.timer = undefined;
+			fn();
+		}, ms);
+	}
+
+	private clearRowTimer(row: RowState<T>): void {
+		if (row.timer !== undefined) {
+			window.clearTimeout(row.timer);
+			row.timer = undefined;
+		}
+	}
+
+	private hardReset(): void {
+		this.epoch++; // orphans in-flight motion-end timers
+		for (const row of this.rows.values()) this.clearRowTimer(row);
+		this.rows.clear();
+		this.motionCount = 0;
+		this.guards = []; // the old identity's motion cannot indict the new rows
+		if (this.graceTimer !== undefined) {
+			window.clearTimeout(this.graceTimer);
+			this.graceTimer = undefined;
+		}
+		this.hasReconciled = false;
+		// `pinned` survives: the pointer is still wherever it is.
+	}
+
+	// --- rendering: a pure map over row state ---
+
+	override render(): TemplateResult {
+		const rowList = [...this.rows.values()];
+		const anyVisible = rowList.some((r) => r.phase !== "pending");
+		const vars = `--sw-live-list-enter: ${this.enterMs}ms; --sw-live-list-collapse: ${this.collapseMs}ms;`;
+		const empty = anyVisible
+			? nothing
+			: (this.renderEmpty?.() ?? (this.mega ? nothing : html`<slot name="empty"></slot>`));
+		return html`
+			<div
+				class=${classMap({ "sw-ll-items": true, "sw-ll-lockout": this.frozen && this.locked })}
+				style=${vars}
+				tabindex="-1"
+			>
+				${repeat(
+					rowList,
+					(r) => r.key,
+					(r) => this.renderRow(r),
+				)}
+			</div>
+			${empty}
+		`;
+	}
+
+	private renderRow(row: RowState<T>): TemplateResult {
+		// Closed rows are clipped visually (0fr); aria-hidden makes the AT view match,
+		// and (mega mode) is also what the controller's scan skips: one attribute,
+		// closed to sight and closed to navigation together.
+		const closed = row.phase === "pending" || row.phase === "collapsing";
+		const phase: LiveListPhase =
+			row.phase === "committed" || row.phase === "collapsing" ? "committed" : "live";
+		// Mega mode stamps row identity for the megacolumn scan (see the header).
+		// classMap (not string interpolation) because the megacolumn controller stamps
+		// its focus classes on these elements imperatively, and an interpolated class
+		// attribute would wipe them on the next content tick.
+		return html`<div
+			class=${classMap({ "sw-ll-rowslot": true, "sw-ll-closed": closed, "sw-ll-grew": row.grew })}
+			data-key=${row.key}
+			data-mc-row=${this.mega ? row.key : nothing}
+			aria-hidden=${closed ? "true" : nothing}
+			@click=${() => this.onRowClick(row)}
+		>
+			<div class="sw-ll-clip">${this.renderItem(row.item, phase)}</div>
+		</div>`;
+	}
+
+	// Fires only when a click actually lands: closed rows are zero-height, and a
+	// click whose target row was displaced by in-flight motion was already stopped
+	// by the mode in force (click-guard's capture handler, or freeze's lockout).
+	private onRowClick(row: RowState<T>): void {
+		const phase: LiveListPhase = row.phase === "committed" ? "committed" : "live";
+		this.dispatchEvent(
+			new CustomEvent<ItemClickDetail<T>>("item-click", {
+				detail: { key: row.key, item: row.item, phase },
+				bubbles: true,
+				composed: true,
+			}),
+		);
+	}
+}
+
+declare global {
+	interface HTMLElementTagNameMap {
+		"sw-live-list": SwLiveList;
+	}
+}
diff --git a/packages/yukey/src/tab-cycle.test.ts b/packages/yukey/src/tab-cycle.test.ts
new file mode 100644
index 0000000..a8f8cef
--- /dev/null
+++ b/packages/yukey/src/tab-cycle.test.ts
@@ -0,0 +1,163 @@
+// @vitest-environment jsdom
+//
+// Tests for the tab-cycle helpers (tab-cycle.ts): the shadow-DOM-aware tabbable
+// walk and the deep activeElement read that the gallery shell's Tab containment
+// is built from.  jsdom has no layout, which is fine: the walk is deliberately
+// attribute-based (see the header of tab-cycle.ts).
+
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+	containTab,
+	deepActiveElement,
+	deepTabbables,
+	installTabContainment,
+} from "./tab-cycle.js";
+
+function host(innerHtml: string): HTMLElement {
+	const el = document.createElement("div");
+	el.attachShadow({ mode: "open" }).innerHTML = innerHtml;
+	return el;
+}
+
+beforeEach(() => {
+	document.body.replaceChildren();
+});
+
+describe("deepTabbables", () => {
+	it("collects tab stops in tree order", () => {
+		document.body.innerHTML = `
+			<a href="#one" id="one">one</a>
+			<div>plain</div>
+			<div tabindex="0" id="two">container</div>
+			<button id="three">three</button>
+		`;
+		expect(deepTabbables(document.body).map((e) => e.id)).toEqual(["one", "two", "three"]);
+	});
+
+	it("skips negative tabindex and unfocusable elements", () => {
+		document.body.innerHTML = `
+			<input data-mc-input tabindex="-1">
+			<div id="only" tabindex="0">container</div>
+			<span>text</span>
+		`;
+		expect(deepTabbables(document.body).map((e) => e.id)).toEqual(["only"]);
+	});
+
+	it("skips aria-hidden subtrees", () => {
+		document.body.innerHTML = `
+			<div aria-hidden="true"><button id="ghost">hidden</button></div>
+			<button id="live">live</button>
+		`;
+		expect(deepTabbables(document.body).map((e) => e.id)).toEqual(["live"]);
+	});
+
+	it("descends into shadow roots in tree order", () => {
+		const inner = host(`<div tabindex="0" id="in-shadow">row container</div>`);
+		document.body.append(
+			Object.assign(document.createElement("a"), { href: "#a", id: "before", textContent: "a" }),
+			inner,
+		);
+		const after = document.createElement("button");
+		after.id = "after";
+		document.body.append(after);
+		expect(deepTabbables(document.body).map((e) => e.id)).toEqual(["before", "in-shadow", "after"]);
+	});
+});
+
+describe("deepActiveElement", () => {
+	it("descends the shadow activeElement chain", () => {
+		const outer = host(`<span></span>`);
+		document.body.append(outer);
+		const innerHost = host(`<div tabindex="0" id="deep">deep</div>`);
+		outer.shadowRoot?.append(innerHost);
+		const deep = innerHost.shadowRoot?.querySelector<HTMLElement>("#deep");
+		deep?.focus();
+		expect(deepActiveElement(document)).toBe(deep);
+	});
+
+	it("returns the light-DOM active element when no shadow is involved", () => {
+		document.body.innerHTML = `<button id="b">b</button>`;
+		const b = document.querySelector<HTMLElement>("#b");
+		b?.focus();
+		expect(deepActiveElement(document)).toBe(b);
+	});
+});
+
+describe("containTab", () => {
+	function tab(shift = false): KeyboardEvent {
+		const e = new KeyboardEvent("keydown", { key: "Tab", shiftKey: shift, cancelable: true });
+		containTab(e, document);
+		return e;
+	}
+
+	beforeEach(() => {
+		document.body.innerHTML = `
+			<button id="first">first</button>
+			<button id="mid">mid</button>
+			<button id="last">last</button>
+		`;
+	});
+
+	it("wraps Tab from the last stop to the first", () => {
+		document.querySelector<HTMLElement>("#last")?.focus();
+		const e = tab();
+		expect(e.defaultPrevented).toBe(true);
+		expect(document.activeElement?.id).toBe("first");
+	});
+
+	it("wraps shift-Tab from the first stop to the last", () => {
+		document.querySelector<HTMLElement>("#first")?.focus();
+		const e = tab(true);
+		expect(e.defaultPrevented).toBe(true);
+		expect(document.activeElement?.id).toBe("last");
+	});
+
+	it("leaves interior stops to native traversal", () => {
+		document.querySelector<HTMLElement>("#mid")?.focus();
+		const e = tab();
+		expect(e.defaultPrevented).toBe(false);
+		expect(document.activeElement?.id).toBe("mid"); // jsdom has no native traversal; untouched
+	});
+
+	it("does nothing when focus is outside the tab order (a text-entry input)", () => {
+		document.body.insertAdjacentHTML("beforeend", `<input id="dive" tabindex="-1">`);
+		document.querySelector<HTMLElement>("#dive")?.focus();
+		const e = tab();
+		expect(e.defaultPrevented).toBe(false);
+	});
+});
+
+describe("installTabContainment", () => {
+	function pressTab(shift = false): KeyboardEvent {
+		const e = new KeyboardEvent("keydown", {
+			key: "Tab",
+			shiftKey: shift,
+			cancelable: true,
+			bubbles: true,
+		});
+		document.activeElement?.dispatchEvent(e);
+		return e;
+	}
+
+	beforeEach(() => {
+		document.body.innerHTML = `
+			<button id="first">first</button>
+			<button id="last">last</button>
+		`;
+	});
+
+	it("installs once per document: first call true, repeats false, edges contained", () => {
+		// One test owns the whole install lifecycle: the WeakSet is per-document and
+		// jsdom shares one document across this file, so split assertions would
+		// couple to test order.
+		expect(installTabContainment(document)).toBe(true);
+		expect(installTabContainment(document)).toBe(false);
+		document.querySelector<HTMLElement>("#last")?.focus();
+		const e = pressTab();
+		expect(e.defaultPrevented).toBe(true);
+		expect(document.activeElement?.id).toBe("first");
+		const back = pressTab(true);
+		expect(back.defaultPrevented).toBe(true);
+		expect(document.activeElement?.id).toBe("last");
+	});
+});
diff --git a/packages/yukey/src/tab-cycle.ts b/packages/yukey/src/tab-cycle.ts
new file mode 100644
index 0000000..6d7cb8e
--- /dev/null
+++ b/packages/yukey/src/tab-cycle.ts
@@ -0,0 +1,79 @@
+// Shadow-DOM-aware Tab containment for page shells: the kit's default is that
+// the Tab key never leaves the page and wanders into browser chrome (the
+// guidelines' "Focus containment and lifecycle" norm).  A shell installs it
+// once with installTabContainment(document); the helpers underneath
+// (deepTabbables, deepActiveElement, containTab) stay exported for tests and
+// for shells that need the walk itself (the gallery seats focus with it).
+//
+// The walk is attribute-based (tabIndex >= 0 on the usual focusable tags plus
+// anything with an explicit tabindex), not layout-based: it does not check
+// visibility or computed styles.  That is deliberate -- the callers' content is
+// megacolumn containers and native buttons, all visible by construction, and
+// staying layout-free keeps the walk cheap and jsdom-testable.  aria-hidden
+// subtrees are skipped: components here hide closed content that way
+// (sw-live-list's collapsing rows), and a tab stop invisible to AT must not be
+// a tab stop at all.
+
+function isTabbable(el: Element): boolean {
+	if (!(el instanceof HTMLElement)) return false;
+	if (el.tabIndex < 0) return false;
+	if (el.hasAttribute("tabindex")) return true;
+	return el.matches("a[href], button, input, select, textarea, [contenteditable='true']");
+}
+
+// All tab stops under `root` in tree order, descending open shadow roots.
+export function deepTabbables(root: ParentNode): HTMLElement[] {
+	const out: HTMLElement[] = [];
+	const walk = (node: ParentNode): void => {
+		for (const child of node.children) {
+			if (child.getAttribute("aria-hidden") === "true") continue;
+			if (isTabbable(child)) out.push(child as HTMLElement);
+			if (child.shadowRoot !== null) walk(child.shadowRoot);
+			walk(child);
+		}
+	};
+	walk(root);
+	return out;
+}
+
+// The really-focused element, descending the shadow activeElement chain.
+export function deepActiveElement(doc: Document): Element | null {
+	let active = doc.activeElement;
+	while (active?.shadowRoot?.activeElement != null) {
+		active = active.shadowRoot.activeElement;
+	}
+	return active;
+}
+
+// Tab containment: keep Tab cycling within the document instead of escaping to
+// the browser chrome.  Only the edges are intercepted (last stop wrapping to
+// first, first back to last); interior Tabs stay native, so within-page traversal
+// is exactly the browser's own.  Focus outside the tab order (a text-entry dive
+// into a tabindex=-1 input) is left alone: Escape/Enter own that exit path.
+export function containTab(e: KeyboardEvent, doc: Document): void {
+	if (e.key !== "Tab") return;
+	const stops = deepTabbables(doc.body);
+	const first = stops[0];
+	const last = stops[stops.length - 1];
+	if (first === undefined || last === undefined) return;
+	const active = deepActiveElement(doc);
+	const edge = e.shiftKey ? first : last;
+	if (active !== edge) return;
+	e.preventDefault();
+	(e.shiftKey ? last : first).focus();
+}
+
+// Documents where containment is already installed.  A WeakSet (not a flag on
+// the document) so tests with fresh jsdom documents never see stale state.
+const installed = new WeakSet<Document>();
+
+// Install Tab containment on a document: the one call a page shell makes to
+// get the kit's default.  Idempotent per document (repeat calls add nothing and
+// return false); the listener lives for the document's life, matching the
+// shells that call it (they never uninstall themselves either).
+export function installTabContainment(doc: Document): boolean {
+	if (installed.has(doc)) return false;
+	installed.add(doc);
+	doc.defaultView?.addEventListener("keydown", (e) => containTab(e, doc));
+	return true;
+}
diff --git a/packages/yukey/src/tokens.gallery.ts b/packages/yukey/src/tokens.gallery.ts
new file mode 100644
index 0000000..f9aaecf
--- /dev/null
+++ b/packages/yukey/src/tokens.gallery.ts
@@ -0,0 +1,328 @@
+// Gallery entry for the design-token vocabulary: the living view of tokens.ts.
+// Everything renders from tokenGroups itself, so this page cannot drift from the
+// stylesheet -- edit a token value, rebuild, and the swatch, ramp, and demo all
+// show the new truth.  This page is the iteration venue for the look: change
+// values in tokens.ts, watch here.
+
+import { css, html, LitElement, type TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import type { GalleryEntry } from "./gallery/entry.js";
+import "./sw-button.js";
+import { type TokenDef, tokenGroups } from "./tokens.js";
+
+// One demo element, four faces (palette / spacing / type / focus): the variants
+// below pick a face.  A single element keeps the styles in one block.
+@customElement("swg-tokens")
+export class SwgTokens extends LitElement {
+	static styles = css`
+		:host {
+			display: block;
+			font: var(--sw-type-body) var(--sw-font);
+			color: var(--sw-text);
+		}
+		.group {
+			margin-bottom: var(--sw-space-xl);
+		}
+		.group:last-child {
+			margin-bottom: 0;
+		}
+		.group-title {
+			color: var(--sw-text-dim);
+			letter-spacing: var(--sw-tracking-wide);
+			text-transform: uppercase;
+			font-size: var(--sw-type-small);
+			border-bottom: 1px solid var(--sw-border-dim);
+			padding-bottom: var(--sw-space-xs);
+			margin-bottom: var(--sw-space-xs);
+		}
+		.group-about {
+			color: var(--sw-text-faint);
+			max-width: 70ch;
+			margin-bottom: var(--sw-space-md);
+		}
+
+		/* palette rows: swatch | name | value | role */
+		.trow {
+			display: grid;
+			grid-template-columns: 72px 220px minmax(120px, 320px) 1fr;
+			gap: var(--sw-space-md);
+			align-items: center;
+			min-height: 28px;
+			margin-bottom: var(--sw-space-xs);
+		}
+		.swatch {
+			height: 28px;
+			border: 1px solid var(--sw-border-dim);
+			/* checkerboard underlay so translucent washes read as translucent */
+			background:
+				repeating-conic-gradient(#2a3448 0 25%, #141c2c 0 50%) 0 0 / 14px 14px;
+		}
+		.swatch > div {
+			height: 100%;
+		}
+		.tname {
+			color: var(--sw-text-hi);
+			user-select: all; /* one click selects the whole name, ready to copy */
+		}
+		.tvalue {
+			color: var(--sw-text-faint);
+			font-size: var(--sw-type-small);
+			overflow-wrap: anywhere;
+		}
+		.tabout {
+			color: var(--sw-text-dim);
+		}
+
+		/* spacing & hit-target bars */
+		.bar {
+			height: 14px;
+			background: var(--sw-accent);
+		}
+		.hitbox {
+			display: inline-flex;
+			align-items: center;
+			justify-content: center;
+			min-height: var(--sw-hit-min-h);
+			min-width: var(--sw-hit-min-w);
+			border: 1px solid var(--sw-border);
+			color: var(--sw-text-dim);
+		}
+		.antbox {
+			display: inline-flex;
+			width: 8px;
+			height: 8px;
+			border: 1px solid var(--sw-warn);
+		}
+
+		/* type specimens */
+		.specimen {
+			color: var(--sw-text-bright);
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+		}
+
+		/* focus & hotkey demos */
+		button.demo {
+			background: none;
+			border: 1px solid var(--sw-border);
+			color: var(--sw-text);
+			font: inherit;
+			cursor: pointer;
+			min-height: var(--sw-hit-min-h);
+			min-width: var(--sw-hit-min-w);
+			padding: 2px var(--sw-space-md);
+		}
+		button.demo:hover {
+			color: var(--sw-text-bright);
+			border-color: var(--sw-border-bright);
+		}
+		button.demo:focus-visible {
+			outline: var(--sw-focus-ring);
+			outline-offset: var(--sw-focus-ring-offset);
+		}
+		.ring-always {
+			outline: var(--sw-focus-ring);
+			outline-offset: var(--sw-focus-ring-offset);
+		}
+		.demo-row {
+			display: flex;
+			align-items: center;
+			gap: var(--sw-space-lg);
+			margin: var(--sw-space-md) 0 var(--sw-space-lg);
+		}
+		.note {
+			color: var(--sw-text-faint);
+			max-width: 70ch;
+		}
+	`;
+
+	@property() face: "palette" | "spacing" | "type" | "focus" = "palette";
+
+	render(): TemplateResult {
+		switch (this.face) {
+			case "palette":
+				return this.renderPalette();
+			case "spacing":
+				return this.renderSpacing();
+			case "type":
+				return this.renderType();
+			case "focus":
+				return this.renderFocus();
+		}
+	}
+
+	private static paletteRow(t: TokenDef): TemplateResult {
+		return html`<div class="trow">
+			<div class="swatch"><div style="background: var(${t.name});"></div></div>
+			<span class="tname">${t.name}</span>
+			<span class="tvalue">${t.value}</span>
+			<span class="tabout">${t.about}</span>
+		</div>`;
+	}
+
+	private renderPalette(): TemplateResult {
+		const colorGroups = tokenGroups
+			.map((g) => ({ ...g, tokens: g.tokens.filter((t) => t.kind === "color") }))
+			.filter((g) => g.tokens.length > 0);
+		return html`${colorGroups.map(
+			(g) => html`<div class="group">
+				<div class="group-title">${g.title}</div>
+				<div class="group-about">${g.about}</div>
+				${g.tokens.map((t) => SwgTokens.paletteRow(t))}
+			</div>`,
+		)}`;
+	}
+
+	private renderSpacing(): TemplateResult {
+		const spacing = tokenGroups.find((g) => g.id === "spacing");
+		const hit = tokenGroups.find((g) => g.id === "hit");
+		if (spacing === undefined || hit === undefined) return html`missing group`;
+		return html`
+			<div class="group">
+				<div class="group-title">${spacing.title}</div>
+				<div class="group-about">${spacing.about}</div>
+				${spacing.tokens.map(
+					(t) => html`<div class="trow">
+						<div><div class="bar" style="width: var(${t.name});"></div></div>
+						<span class="tname">${t.name}</span>
+						<span class="tvalue">${t.value}</span>
+						<span class="tabout">${t.about}</span>
+					</div>`,
+				)}
+			</div>
+			<div class="group">
+				<div class="group-title">${hit.title}</div>
+				<div class="group-about">${hit.about}</div>
+				<div class="demo-row">
+					<span class="hitbox">min</span>
+					<span class="note">the smallest legal interactable
+						(${hit.tokens.map((t) => t.name).join(", ")})</span>
+				</div>
+				<div class="demo-row">
+					<span class="antbox"></span>
+					<span class="note">8px by 8px, for contrast: the guidelines' "we are not ants" bound.
+						Nothing interactable may be this.</span>
+				</div>
+			</div>
+		`;
+	}
+
+	private renderType(): TemplateResult {
+		const type = tokenGroups.find((g) => g.id === "type");
+		if (type === undefined) return html`missing group`;
+		const sizes = type.tokens.filter((t) => t.name.startsWith("--sw-type-"));
+		const rest = type.tokens.filter((t) => !t.name.startsWith("--sw-type-"));
+		return html`<div class="group">
+			<div class="group-title">${type.title}</div>
+			<div class="group-about">${type.about}</div>
+			${sizes.map(
+				(t) => html`<div class="trow">
+					<span class="tvalue">${t.value}</span>
+					<span class="tname">${t.name}</span>
+					<span class="specimen" style="font-size: var(${t.name});">Kestrel Reach 74/74</span>
+					<span class="tabout">${t.about}</span>
+				</div>`,
+			)}
+			${rest.map(
+				(t) => html`<div class="trow">
+					<span class="tvalue">${t.value}</span>
+					<span class="tname">${t.name}</span>
+					<span class="specimen" style="letter-spacing: var(${t.name}); text-transform: uppercase;"
+						>Kestrel Reach</span>
+					<span class="tabout">${t.about}</span>
+				</div>`,
+			)}
+		</div>`;
+	}
+
+	private renderFocus(): TemplateResult {
+		const motion = tokenGroups.find((g) => g.id === "motion");
+		if (motion === undefined) return html`missing group`;
+		return html`
+			<div class="group">
+				<div class="group-title">focus ring</div>
+				<div class="demo-row">
+					<button class="demo">tab to me</button>
+					<button class="demo">then to me</button>
+					<button class="demo ring-always">ring, always on</button>
+				</div>
+				<div class="note">
+					The first two show the ring only on keyboard focus (:focus-visible);
+					the third wears it permanently for inspection.
+					Composite: --sw-focus-ring, offset by --sw-focus-ring-offset.
+				</div>
+			</div>
+			<div class="group">
+				<div class="group-title">hotkey marking</div>
+				<div class="demo-row">
+					<sw-button label="dock" hotkey="d"></sw-button>
+					<sw-button label="engage" hotkey="7"></sw-button>
+				</div>
+				<div class="note">
+					The guidelines' button-marking norm, in --sw-hotkey-accent:
+					letter-in-label when the hotkey appears in the label,
+					else the letter in a slot 70% of the way across.
+					These are live sw-buttons; the full behavior tour is the sw-button entry.
+				</div>
+			</div>
+			<div class="group">
+				<div class="group-title">${motion.title}</div>
+				<div class="group-about">${motion.about}</div>
+				${motion.tokens.map(
+					(t) => html`<div class="trow">
+						<span class="tvalue">${t.value}</span>
+						<span class="tname">${t.name}</span>
+						<span class="tabout">${t.about}</span>
+					</div>`,
+				)}
+				<div class="demo-row">
+					<sw-button label="press me" hotkey="p"></sw-button>
+					<span class="note">
+						Every activation (click, Enter/Space, hotkey) flashes the pressed look
+						for --sw-press-ms; holding the mouse down shows the same look for as
+						long as it is held.  The wash is --sw-wash-pressed.
+					</span>
+				</div>
+			</div>
+		`;
+	}
+}
+
+const entry: GalleryEntry = {
+	id: "tokens",
+	title: "design tokens",
+	description:
+		"The visual vocabulary everything else is built from: --sw-* custom properties, " +
+		"rendered live from their definitions in tokens.ts.  Names are semantic roles and are " +
+		"the durable part; values are the iteration surface -- edit tokens.ts, rebuild, " +
+		"and this page shows the new truth.  Intent notes live in docs/precepts-aesthetics.md.",
+	variants: [
+		{
+			caption:
+				"The palette, by role.  Swatches sit on a checkerboard so translucent washes read as " +
+				"translucent; wash values derive from their base color, so retinting a base retints its washes.",
+			wide: true,
+			render: () => html`<swg-tokens face="palette"></swg-tokens>`,
+		},
+		{
+			caption: "The spacing ramp (gaps carry meaning) and the hit-target minimums.",
+			wide: true,
+			render: () => html`<swg-tokens face="spacing"></swg-tokens>`,
+		},
+		{
+			caption: "The type ramp and tracking values, each rendered at its own size.",
+			wide: true,
+			render: () => html`<swg-tokens face="type"></swg-tokens>`,
+		},
+		{
+			caption:
+				"Keyboard visibility: the focus ring (try tabbing through), the two hotkey-marking forms, " +
+				"and the activation flash (press things; watch them acknowledge).",
+			wide: true,
+			render: () => html`<swg-tokens face="focus"></swg-tokens>`,
+		},
+	],
+};
+
+export default entry;
diff --git a/packages/yukey/src/tokens.test.ts b/packages/yukey/src/tokens.test.ts
new file mode 100644
index 0000000..892d969
--- /dev/null
+++ b/packages/yukey/src/tokens.test.ts
@@ -0,0 +1,57 @@
+// Tests for the token vocabulary's structural invariants.  Values are deliberately
+// NOT pinned here: they're a v0 seed meant to be iterated hard, and a test that
+// breaks on every retint would train people to update it blindly.  What must hold
+// still is the shape: well-formed names, no duplicates, and a stylesheet that
+// actually contains every defined token.
+
+import { describe, expect, it } from "vitest";
+import { tokenGroups, tokensCssText } from "./tokens.js";
+
+describe("the token vocabulary", () => {
+	const all = tokenGroups.flatMap((g) => g.tokens);
+
+	it("names every token in the --sw- namespace, lowercase-and-dashes only", () => {
+		for (const t of all) {
+			expect(t.name).toMatch(/^--sw-[a-z][a-z0-9-]*$/);
+		}
+	});
+
+	it("has no duplicate token names", () => {
+		const names = all.map((t) => t.name);
+		expect(new Set(names).size).toBe(names.length);
+	});
+
+	it("has no duplicate group ids", () => {
+		const ids = tokenGroups.map((g) => g.id);
+		expect(new Set(ids).size).toBe(ids.length);
+	});
+
+	it("describes every token and group (the gallery renders these)", () => {
+		for (const g of tokenGroups) {
+			expect(g.title).not.toBe("");
+			expect(g.about).not.toBe("");
+		}
+		for (const t of all) {
+			expect(t.about).not.toBe("");
+		}
+	});
+
+	it("emits every token into the :root stylesheet, values non-empty", () => {
+		const cssText = tokensCssText(tokenGroups);
+		expect(cssText).toMatch(/^:root \{\n/);
+		expect(cssText).toMatch(/\n\}$/);
+		for (const t of all) {
+			expect(t.value).not.toBe("");
+			expect(cssText).toContain(`${t.name}: ${t.value};`);
+		}
+	});
+
+	it("only references other tokens that exist (var() cycles aside, typos die here)", () => {
+		const names = new Set<string>(all.map((t) => t.name));
+		for (const t of all) {
+			for (const m of t.value.matchAll(/var\((--[a-z0-9-]+)\)/g)) {
+				expect(names, `${t.name} references ${m[1]}`).toContain(m[1]);
+			}
+		}
+	});
+});
diff --git a/packages/yukey/src/tokens.ts b/packages/yukey/src/tokens.ts
new file mode 100644
index 0000000..10d4cb4
--- /dev/null
+++ b/packages/yukey/src/tokens.ts
@@ -0,0 +1,451 @@
+// The design-token vocabulary: every color, gap, size, and type choice the UI is
+// built from, as CSS custom properties.  This file is the SSOT for token names and
+// values; docs/precepts-aesthetics.md describes the intent, and the `tokens` gallery
+// entry renders this structure live.
+//
+// Tokens are defined as data (tokenGroups) and the :root stylesheet is generated
+// from it, so the stylesheet and the gallery's living documentation can never
+// disagree.  Both entry points call installTokens(); custom properties inherit
+// through shadow boundaries, so components just write `var(--sw-...)`.
+//
+// Naming is the durable part: tokens are named for their semantic role, so values
+// can be iterated hard (they will be) while the meaning holds still.  The values
+// here are a v0 seed extracted from the UI's de facto dark/teal SCADA palette,
+// not an enshrinement.
+
+import type { CSSResult } from "lit";
+import { unsafeCSS } from "lit";
+
+// What a token's value IS (the W3C design-tokens draft calls this $type).
+// The gallery picks preview renderings from it; nothing else branches on it.
+export type TokenKind = "color" | "size" | "font" | "composite" | "duration";
+
+export interface TokenDef {
+	name: `--sw-${string}`;
+	value: string;
+	kind: TokenKind;
+	// The role, in a sentence: what kind of thing should use this token.
+	about: string;
+}
+
+export interface TokenGroup {
+	// Stable handle for consumers that single out a group (the gallery's type specimen);
+	// title is display text and free to change.
+	id: string;
+	title: string;
+	about: string;
+	tokens: TokenDef[];
+}
+
+export const tokenGroups: TokenGroup[] = [
+	{
+		id: "surfaces",
+		title: "surfaces",
+		about: "Background layers, from the void of space up to floating overlays.",
+		tokens: [
+			{
+				name: "--sw-bg-void",
+				value: "#05080f",
+				kind: "color",
+				about: "the deepest layer: the map background, the page behind everything",
+			},
+			{
+				name: "--sw-bg-panel-solid",
+				value: "#0a1220",
+				kind: "color",
+				about:
+					"opaque panel surface: cards, sticky headers, toasts -- anywhere bleed-through would muddy",
+			},
+			{
+				name: "--sw-bg-panel",
+				value: "color-mix(in srgb, var(--sw-bg-panel-solid) 94%, transparent)",
+				kind: "color",
+				about: "translucent panel surface: big workspace panels floating over the map",
+			},
+			{
+				name: "--sw-bg-well",
+				value: "#0d1626",
+				kind: "color",
+				about: "inset areas within a panel: chart plots, code samples",
+			},
+			{
+				name: "--sw-bg-raised",
+				value: "#101a2c",
+				kind: "color",
+				about: "small floating surfaces above panel content: tooltips, popovers",
+			},
+		],
+	},
+	{
+		id: "borders",
+		title: "borders",
+		about: "Panel edges and separators.  Brightness tracks prominence.",
+		tokens: [
+			{
+				name: "--sw-border",
+				value: "#3d5578",
+				kind: "color",
+				about: "the standard chrome border: panel edges, buttons, separator bars",
+			},
+			{
+				name: "--sw-border-dim",
+				value: "#1c2a40",
+				kind: "color",
+				about: "subtle borders: section rules, chart frames, quiet separations",
+			},
+			{
+				name: "--sw-border-bright",
+				value: "#5a7290",
+				kind: "color",
+				about: "emphasized borders: hover states on bordered controls",
+			},
+		],
+	},
+	{
+		id: "text",
+		title: "text",
+		about: "The gray-blue text ramp, five tiers from emphasized to nearly-ambient.",
+		tokens: [
+			{
+				name: "--sw-text-bright",
+				value: "#dfe9f5",
+				kind: "color",
+				about: "primary emphasis: names, values, hover text -- what the eye should land on",
+			},
+			{
+				name: "--sw-text-hi",
+				value: "#a8bdd8",
+				kind: "color",
+				about: "secondary emphasis: entity names in rows, highlighted prose",
+			},
+			{
+				name: "--sw-text",
+				value: "#8194ac",
+				kind: "color",
+				about: "body text: the default reading color",
+			},
+			{
+				name: "--sw-text-dim",
+				value: "#5a7290",
+				kind: "color",
+				about: "dim text: labels, section headings, quiet chrome",
+			},
+			{
+				name: "--sw-text-faint",
+				value: "#48566b",
+				kind: "color",
+				about: "faint text: ids, empty states, the almost-not-there tier",
+			},
+		],
+	},
+	{
+		id: "accent",
+		title: "accent",
+		about:
+			"The interactive blue family: selected and hovered things, meters, active chrome.  " +
+			"Washes derive from the accent, so retinting it retints them.",
+		tokens: [
+			{
+				name: "--sw-accent",
+				value: "#4f7ba8",
+				kind: "color",
+				about: "the interactive blue: meter fills, checkboxes, chart lines, attention borders",
+			},
+			{
+				name: "--sw-accent-bright",
+				value: "#8fb3d9",
+				kind: "color",
+				about: "the accent as text: app title, score, links",
+			},
+			{
+				name: "--sw-wash-pressed",
+				value: "hsl(from var(--sw-accent) h calc(s * 1.5) calc(l * 1.2) / 0.5)",
+				kind: "color",
+				about:
+					"background wash for a control being activated: held under a pointer, " +
+					"or flashing for --sw-press-ms after triggering.  Deliberately more saturated " +
+					"and brighter than the accent it derives from: activation must outrank selection at a glance",
+			},
+			{
+				name: "--sw-wash-selected",
+				value: "color-mix(in srgb, var(--sw-accent) 25%, transparent)",
+				kind: "color",
+				about: "background wash for the selected row / the toggled-on button",
+			},
+			{
+				name: "--sw-wash-hover",
+				value: "color-mix(in srgb, var(--sw-accent) 12%, transparent)",
+				kind: "color",
+				about: "background wash for the hovered row",
+			},
+			{
+				name: "--sw-wash-faint",
+				value: "color-mix(in srgb, var(--sw-accent) 6%, transparent)",
+				kind: "color",
+				about: "the quietest wash: help strips, gently set-off regions",
+			},
+		],
+	},
+	{
+		id: "signals",
+		title: "signals",
+		about: "Status colors.  Teal means working, amber means stuck, red means unreachable.",
+		tokens: [
+			{
+				name: "--sw-good",
+				value: "#8fd4c0",
+				kind: "color",
+				about:
+					"the working-economy teal: producing, positive flow, and the map's route system (promises of movement)",
+			},
+			{
+				name: "--sw-warn",
+				value: "#c8863c",
+				kind: "color",
+				about: "the amber alert: rejected commands, blocked production, negative flow, emergencies",
+			},
+			{
+				name: "--sw-warn-border",
+				value: "#6b4a24",
+				kind: "color",
+				about: "border for amber-alert surfaces (the rejection toast)",
+			},
+			{
+				name: "--sw-warn-wash",
+				value: "color-mix(in srgb, var(--sw-warn) 13%, transparent)",
+				kind: "color",
+				about: "background wash for rows in an emergency state",
+			},
+			{
+				name: "--sw-warn-wash-hover",
+				value: "color-mix(in srgb, var(--sw-warn) 22%, transparent)",
+				kind: "color",
+				about: "hover wash for emergency rows",
+			},
+			{
+				name: "--sw-warn-wash-selected",
+				value: "color-mix(in srgb, var(--sw-warn) 30%, transparent)",
+				kind: "color",
+				about: "selected wash for emergency rows",
+			},
+			{
+				name: "--sw-danger",
+				value: "#e8a0a0",
+				kind: "color",
+				about: "the red alert: no connection, destructive controls (the row-remove x)",
+			},
+			{
+				name: "--sw-danger-border",
+				value: "#7a4040",
+				kind: "color",
+				about: "border for red-alert surfaces (the no-connection toast)",
+			},
+		],
+	},
+	{
+		id: "focus",
+		title: "keyboard & focus",
+		about:
+			"The guidelines demand focus be visible and hotkeys be marked; these tokens are how.  " +
+			"The focus-model and button stages consume them heavily.",
+		tokens: [
+			{
+				name: "--sw-hotkey-accent",
+				value: "#e8c86a",
+				kind: "color",
+				about:
+					"the significant color for hotkey marking: the letter-in-label tint, the 70%-offset hotkey slot.  " +
+					"Deliberately the only gold in the palette: hotkeys must pop",
+			},
+			{
+				name: "--sw-selection",
+				value: "#d8ecff",
+				kind: "color",
+				about: "the selection marker: the map's dashed ring around the selected entity",
+			},
+			{
+				name: "--sw-focus-ring-color",
+				value: "#d8ecff",
+				kind: "color",
+				about: "the keyboard focus ring: same where-you-are family as selection, drawn solid",
+			},
+			{
+				name: "--sw-focus-ring-width",
+				value: "2px",
+				kind: "size",
+				about: "focus ring stroke width",
+			},
+			{
+				name: "--sw-focus-ring-offset",
+				value: "2px",
+				kind: "size",
+				about: "gap between a focused element and its ring (pair with outline-offset)",
+			},
+			{
+				name: "--sw-focus-ring",
+				value: "var(--sw-focus-ring-width) solid var(--sw-focus-ring-color)",
+				kind: "composite",
+				about: "the whole ring, ready for `outline:`",
+			},
+			{
+				name: "--sw-scroll-peek",
+				value: "28px",
+				kind: "size",
+				about:
+					"how much neighbor stays visible past the focused row when navigation scrolls: " +
+					"about one row, so the next row is always in view and a scroller's edge is " +
+					"only flush when the list truly ends there",
+			},
+		],
+	},
+	{
+		id: "motion",
+		title: "motion",
+		about:
+			"Interaction-feedback durations.  System parameters, not per-element choices: " +
+			"slowing one here slows it everywhere, which is what an accessibility mode needs.",
+		tokens: [
+			{
+				name: "--sw-press-ms",
+				value: "140ms",
+				kind: "duration",
+				about:
+					"how long the activation flash lingers on a triggered control.  " +
+					"Below ~100ms a flash risks being missed outright; 180ms already reads " +
+					"as sluggish.  Tuned by hand at the 2026-08-23 playtest",
+			},
+		],
+	},
+	{
+		id: "spacing",
+		title: "spacing",
+		about:
+			"The gap ramp.  Gaps carry meaning: xs binds a cluster, sm relates neighbors, " +
+			"lg separates groups, xl+ separates sections (the gaps megacolumn focus jumps across).",
+		tokens: [
+			{
+				name: "--sw-space-xs",
+				value: "4px",
+				kind: "size",
+				about: "within a cluster: icon-to-label",
+			},
+			{
+				name: "--sw-space-sm",
+				value: "8px",
+				kind: "size",
+				about: "between related elements: button rows",
+			},
+			{
+				name: "--sw-space-md",
+				value: "12px",
+				kind: "size",
+				about: "panel edge padding, roomier relations",
+			},
+			{
+				name: "--sw-space-lg",
+				value: "16px",
+				kind: "size",
+				about: "between groups within a section",
+			},
+			{ name: "--sw-space-xl", value: "24px", kind: "size", about: "between sections" },
+			{ name: "--sw-space-xxl", value: "32px", kind: "size", about: "the biggest deliberate gap" },
+		],
+	},
+	{
+		id: "hit",
+		title: "hit targets",
+		about:
+			"We are not ants.  No interactable renders smaller than this -- and that includes the VISIBLE part, not just the hit zone.",
+		tokens: [
+			{
+				name: "--sw-hit-min-h",
+				value: "24px",
+				kind: "size",
+				about: "minimum height of anything interactable",
+			},
+			{
+				name: "--sw-hit-min-w",
+				value: "24px",
+				kind: "size",
+				about: "minimum width of anything interactable (icon-only buttons, checkboxes)",
+			},
+			{
+				name: "--sw-hit-indicator",
+				value: "16px",
+				kind: "size",
+				about:
+					"minimum square for a control's VISIBLE state indicator (checkbox, disclosure, sort caret): drawn geometry sized by this, never a bare text glyph",
+			},
+		],
+	},
+	{
+		id: "type",
+		title: "type",
+		about: "Monospace-heavy by design: this is a SCADA console, not a magazine.",
+		tokens: [
+			{ name: "--sw-font", value: "monospace", kind: "font", about: "the UI font stack" },
+			{ name: "--sw-type-micro", value: "10px", kind: "size", about: "column headers, overlines" },
+			{
+				name: "--sw-type-small",
+				value: "11px",
+				kind: "size",
+				about: "fine print: ids, section headings",
+			},
+			{
+				name: "--sw-type-ui",
+				value: "12px",
+				kind: "size",
+				about: "dense chrome: bars, tables, panels",
+			},
+			{
+				name: "--sw-type-body",
+				value: "13px",
+				kind: "size",
+				about: "reading surfaces: cards, prose",
+			},
+			{ name: "--sw-type-head", value: "16px", kind: "size", about: "page and panel headings" },
+			{ name: "--sw-type-title", value: "18px", kind: "size", about: "the biggest: card names" },
+			{
+				name: "--sw-tracking",
+				value: "0.08em",
+				kind: "size",
+				about: "letterspacing for emphasized titles at text size",
+			},
+			{
+				name: "--sw-tracking-wide",
+				value: "0.12em",
+				kind: "size",
+				about: "letterspacing for uppercase overlines (section headings, app title)",
+			},
+		],
+	},
+];
+
+// Renders the groups into one `:root { ... }` block, group titles as comments.
+// Exported for the unit test; everything else should use `tokens` or installTokens.
+export function tokensCssText(groups: TokenGroup[]): string {
+	const lines: string[] = [":root {"];
+	for (const group of groups) {
+		lines.push(`\t/* ${group.title} */`);
+		for (const t of group.tokens) {
+			lines.push(`\t${t.name}: ${t.value};`);
+		}
+	}
+	lines.push("}");
+	return lines.join("\n");
+}
+
+// unsafeCSS is safe here: the text is generated from the literal definitions in this
+// file, never from runtime data (the same argument as sw-balance's GRID_TEMPLATE).
+export const tokens: CSSResult = unsafeCSS(tokensCssText(tokenGroups));
+
+// Installs the token definitions document-wide (a <style> in head).  Custom
+// properties inherit into every shadow root from there.  Idempotent: entry points
+// call it once, but a second call is harmless.
+export function installTokens(doc: Document = document): void {
+	if (doc.head.querySelector("style[data-sw-tokens]") !== null) return;
+	const style = doc.createElement("style");
+	style.setAttribute("data-sw-tokens", "");
+	style.textContent = tokens.cssText;
+	doc.head.append(style);
+}
diff --git a/packages/yukey/static/gallery.html b/packages/yukey/static/gallery.html
new file mode 100644
index 0000000..bbbd4b2
--- /dev/null
+++ b/packages/yukey/static/gallery.html
@@ -0,0 +1,24 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>yukey gallery</title>
+<style>
+	html, body {
+		margin: 0;
+		height: 100%;
+		background: #05080f;
+	}
+	/* Height only: display comes from the component's own :host rule
+	   (a document rule here would override it and break the shell's grid). */
+	sw-gallery {
+		height: 100%;
+	}
+</style>
+</head>
+<body>
+<sw-gallery></sw-gallery>
+<script type="module" src="/gallery.js"></script>
+</body>
+</html>