yukey

Merge branch 'prepublish-cleanup'

commit f60e200ebc526cb695a3ebac4226671f7b82618f · parent 30239003e0 · parent 7d6001756d · browse this tree
warpfork, 2026-10-24 12:53:41 UTC

diff --git a/docs/hacking/README.md b/docs/hacking/README.md
index 32fd096..cc20ceb 100644
--- a/docs/hacking/README.md
+++ b/docs/hacking/README.md
@@ -36,13 +36,10 @@ This is an npm workspace.  The root `package.json` is private and fans out to `p
 Things worth knowing
 --------------------
 
-- The published packages ship `dist/` (`.js` + `.d.ts` + maps), not `.ts`.
+- The published packages ship `dist/` (`.js` + `.d.ts`), not `.ts`.
   Lit's decorators require `experimentalDecorators` and `useDefineForClassFields: false`; compiling here means consumers don't have to match those flags.
   Consequence: anything consuming yukey via a `file:` dependency must `make build` here first.
-- `packages/e2e` imports `yukey-testkit` through the workspace symlink.
-  The testkit's `exports` map carries a `yukey-source` condition pointing at `src/*.ts`;
-  the e2e package's tsconfig (`customConditions`) and its node invocation (`--conditions=yukey-source`) both select it,
-  so typechecking and running the suite never require a testkit build.
-  The default condition still resolves to `dist/`, which is what every outside consumer gets.
+- `packages/e2e` imports `yukey-testkit` through the workspace symlink and resolves to its `dist/`, like any outside consumer would.
+  The e2e package's `check` and `test:e2e` scripts build the testkit first, so this never needs remembering.
 - The e2e suite needs a Chromium.  It defaults to `/headless-shell/headless-shell`; `YUKEY_E2E_CHROME` overrides.
   Chrome is launched with `--no-sandbox` (fine for headless-shell in a container; worth knowing if you point it at a desktop Chrome).
diff --git a/packages/e2e/package.json b/packages/e2e/package.json
index ce82995..cd6a712 100644
--- a/packages/e2e/package.json
+++ b/packages/e2e/package.json
@@ -4,8 +4,8 @@
 	"description": "yukey's own end-to-end suite: serves the gallery bundle, launches a headless Chrome, drives the gallery entries with trusted input",
 	"type": "module",
 	"scripts": {
-		"check": "tsc --noEmit",
-		"test:e2e": "node --conditions=yukey-source main.ts"
+		"check": "npm run build --workspace yukey-testkit && tsc --noEmit",
+		"test:e2e": "npm run build --workspace yukey-testkit && node main.ts"
 	},
 	"dependencies": {
 		"yukey-testkit": "*"
diff --git a/packages/e2e/tsconfig.json b/packages/e2e/tsconfig.json
index 45f5612..6df2cfe 100644
--- a/packages/e2e/tsconfig.json
+++ b/packages/e2e/tsconfig.json
@@ -12,10 +12,7 @@
 		"strict": true,
 		"noUncheckedIndexedAccess": true,
 		"allowImportingTsExtensions": true,
-		"noEmit": true,
-		"customConditions": [
-			"yukey-source"
-		]
+		"noEmit": true
 	},
 	"include": [
 		"."
diff --git a/packages/yukey-testkit/package.json b/packages/yukey-testkit/package.json
index 1795f15..043d1f4 100644
--- a/packages/yukey-testkit/package.json
+++ b/packages/yukey-testkit/package.json
@@ -6,17 +6,14 @@
 	"type": "module",
 	"exports": {
 		"./cdp": {
-			"yukey-source": "./src/cdp.ts",
 			"types": "./dist/cdp.d.ts",
 			"default": "./dist/cdp.js"
 		},
 		"./checks": {
-			"yukey-source": "./src/checks.ts",
 			"types": "./dist/checks.d.ts",
 			"default": "./dist/checks.js"
 		},
 		"./sleep": {
-			"yukey-source": "./src/sleep.ts",
 			"types": "./dist/sleep.d.ts",
 			"default": "./dist/sleep.js"
 		}
diff --git a/packages/yukey-testkit/tsconfig.build.json b/packages/yukey-testkit/tsconfig.build.json
index 30afe55..3ff5e9f 100644
--- a/packages/yukey-testkit/tsconfig.build.json
+++ b/packages/yukey-testkit/tsconfig.build.json
@@ -4,9 +4,7 @@
 		"noEmit": false,
 		"rootDir": "src",
 		"outDir": "dist",
-		"declaration": true,
-		"declarationMap": true,
-		"sourceMap": true
+		"declaration": true
 	},
 	"include": [
 		"src"
diff --git a/packages/yukey/README.md b/packages/yukey/README.md
index d211c2f..0bfbccf 100644
--- a/packages/yukey/README.md
+++ b/packages/yukey/README.md
@@ -33,7 +33,7 @@ installTokens();                                           // once, at startup:
 
 Components (`yk-button`, `yk-list`, `yk-live-list`, `yk-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.
+The gallery (`packages/yukey/src/gallery/` in the repository; not part of the package) is the living documentation: every component, every variant, driven by the real thing.
 
 
 License
diff --git a/packages/yukey/package.json b/packages/yukey/package.json
index 3f297da..1f317c0 100644
--- a/packages/yukey/package.json
+++ b/packages/yukey/package.json
@@ -4,30 +4,10 @@
 	"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": [
-		"./src/yk-button.ts",
-		"./src/yk-infopop.ts",
-		"./src/yk-list.ts",
-		"./src/yk-live-list.ts",
-		"./src/gallery/yk-gallery.ts",
-		"./src/gallery/main.ts",
-		"./src/megacolumn.gallery.ts",
-		"./src/yk-button.gallery.ts",
-		"./src/yk-infopop.gallery.ts",
-		"./src/yk-list.gallery.ts",
-		"./src/yk-live-list.gallery.ts",
-		"./src/tokens.gallery.ts",
 		"./dist/yk-button.js",
 		"./dist/yk-infopop.js",
 		"./dist/yk-list.js",
-		"./dist/yk-live-list.js",
-		"./dist/gallery/yk-gallery.js",
-		"./dist/gallery/main.js",
-		"./dist/megacolumn.gallery.js",
-		"./dist/yk-button.gallery.js",
-		"./dist/yk-infopop.gallery.js",
-		"./dist/yk-list.gallery.js",
-		"./dist/yk-live-list.gallery.js",
-		"./dist/tokens.gallery.js"
+		"./dist/yk-live-list.js"
 	],
 	"type": "module",
 	"exports": {
@@ -44,12 +24,12 @@
 		"LICENSE-MIT"
 	],
 	"scripts": {
-		"build": "tsc -p tsconfig.build.json && esbuild gallery=src/gallery/main.ts --bundle --format=esm --outdir=static --sourcemap=inline",
+		"build": "tsc -p tsconfig.build.json && esbuild gallery=src/gallery/main.ts --bundle --ignore-annotations --format=esm --outdir=static --sourcemap=inline",
 		"check": "tsc --noEmit && vitest run",
 		"test": "vitest run",
 		"test:watch": "vitest",
 		"clean": "rm -rf dist static/gallery.js",
-		"prepack": "npm run build"
+		"prepack": "npm run clean && npm run build"
 	},
 	"devDependencies": {
 		"esbuild": "0.28.2",
diff --git a/packages/yukey/src/adopt-into-root.ts b/packages/yukey/src/adopt-into-root.ts
index 9955dfb..f8ebaf3 100644
--- a/packages/yukey/src/adopt-into-root.ts
+++ b/packages/yukey/src/adopt-into-root.ts
@@ -1,17 +1,18 @@
-// Stylesheet adoption for components that render into their OWNER's tree.
-// Megacolumn-composed components (yk-live-list mega mode, yk-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-yk-live-list").
+// Stylesheet adoption for components that render into their owner's tree.
 
 import type { CSSResult } from "lit";
 
+/**
+ * adoptIntoRoot appends a sheet to the root's adopted stylesheets, once per root.
+ * Megacolumn-composed components (yk-live-list mega mode, yk-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.
+ *
+ * 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-yk-live-list").
+ */
 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`);
diff --git a/packages/yukey/src/focus-stack.ts b/packages/yukey/src/focus-stack.ts
index 4e67aab..de9ec52 100644
--- a/packages/yukey/src/focus-stack.ts
+++ b/packages/yukey/src/focus-stack.ts
@@ -1,74 +1,33 @@
 // 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 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.
+// The contract is on the declarations: FocusStack (the per-document shape), FocusSurface (the vocabulary),
+// and the entered/closed pair (the journey rules).
 
 import { deepActiveElement, deepTabbables } from "./tab-cycle.js";
 
+/**
+ * FocusSurface is 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 one with {@link FocusStack.register}.
+ *
+ * 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.
+ */
 export interface FocusSurface {
-	// Stable identity, unique per document.  Components use their tag name.
+	/** id is the 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 is 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;
 }
 
@@ -77,8 +36,10 @@ interface StackEntry {
 	origin: string | null;
 }
 
-// Whether `node` sits inside `ancestor`, crossing shadow boundaries upward
-// (a shadow root's contents are composed-inside its host).
+/**
+ * composedContains is 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) {
@@ -88,6 +49,15 @@ function composedContains(ancestor: Node, node: Node | null): boolean {
 	return false;
 }
 
+/**
+ * FocusStack is one stack per document.
+ * The shape mirrors tab containment (tab-cycle.ts):
+ * the page shell installs it with {@link installFocusStack},
+ * and components participate through the same per-document accessor, {@link focusStackOf}.
+ *
+ * The owner of a surface's open/close lifecycle makes two calls, {@link entered} and {@link closed};
+ * the journey rules are documented on them.
+ */
 export class FocusStack {
 	#doc: Document;
 	#surfaces = new Map<string, FocusSurface>();
@@ -98,26 +68,34 @@ export class FocusStack {
 		this.#doc = doc;
 	}
 
-	// The end of the line for pops: the page shell's one predictable focus home
-	// (its root surface).
+	/** setFallback sets the end of the line for pops: the page shell's one predictable focus home (its root surface). */
 	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).
+	/**
+	 * entries is 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 retires 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 adds a surface; a component registers itself while connected (and unregisters on disconnect),
+	 * naming itself with a stable id: `stack.register({ id: CARD_SURFACE, root: () => this })`.
+	 * Registering an id twice throws.
+	 */
 	register(surface: FocusSurface): void {
 		if (this.#surfaces.has(surface.id)) {
 			throw new Error(`focus-stack: surface "${surface.id}" is already registered`);
@@ -129,9 +107,11 @@ export class FocusStack {
 		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 is 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 [];
@@ -148,8 +128,19 @@ export class FocusStack {
 		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 records where focus came from: a user-opened surface is about to seat focus.
+	 * Call right before seating focus in the surface, while focus still sits wherever the user came from
+	 * (before the seat moves it).
+	 * Pushes { entered: id, origin: the innermost registered surface holding focus, or null }.
+	 *
+	 * The journey rules it implements:
+	 * - 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.
+	 */
 	entered(id: string): void {
 		if (!this.#surfaces.has(id)) {
 			throw new Error(`focus-stack: entered("${id}") but no such surface is registered`);
@@ -164,9 +155,27 @@ export class FocusStack {
 		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 is the call when a 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 ({@link path} is the tool).
+	 * Retires its entry always, and sweeps entries whose surfaces died with it;
+	 * when the surface held focus, routes focus to the journey's nearest live stop.
+	 *
+	 * The journey rules it implements:
+	 * - 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.
+	 */
 	closed(id: string, held: boolean): void {
 		let retired: StackEntry | undefined;
 		for (let i = this.#entries.length - 1; i >= 0; i--) {
@@ -202,8 +211,10 @@ export class FocusStack {
 		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 seats 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;
@@ -215,11 +226,14 @@ export class FocusStack {
 	}
 }
 
-// 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.
+/**
+ * stacks is 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>();
 
+/** focusStackOf is the per-document accessor components participate through: `focusStackOf(this.ownerDocument)`. */
 export function focusStackOf(doc: Document): FocusStack {
 	let stack = stacks.get(doc);
 	if (stack === undefined) {
@@ -229,9 +243,11 @@ export function focusStackOf(doc: Document): FocusStack {
 	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).
+/**
+ * installFocusStack is the one call a page shell makes: the document's stack, with the shell's fallback wired:
+ * `installFocusStack(document, () => <the fallback element>)`.
+ * 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);
diff --git a/packages/yukey/src/infopop-place.ts b/packages/yukey/src/infopop-place.ts
index f8b3880..23bbc54 100644
--- a/packages/yukey/src/infopop-place.ts
+++ b/packages/yukey/src/infopop-place.ts
@@ -1,15 +1,5 @@
 // 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.  yk-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).
+// yk-infopop feeds it measured values and applies the result.
 
 export interface Rect {
 	x: number;
@@ -29,11 +19,23 @@ export interface Placement {
 	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.
+/** ANCHOR_GAP is the gap between the anchor and the popup.  Exported for the component's stylesheet-independent tests. */
 export const ANCHOR_GAP = 6;
+
+/** VIEWPORT_MARGIN is the minimum clearance kept to the viewport edges.  Exported for the component's stylesheet-independent tests. */
 export const VIEWPORT_MARGIN = 4;
 
+/**
+ * placeInfopop decides where the popup goes,
+ * given the anchor's rectangle, the popup's size, and the viewport size.
+ *
+ * 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 function placeInfopop(anchor: Rect, popup: Size, viewport: Size): Placement {
 	const x = clamp(
 		anchor.x + anchor.w / 2 - popup.w / 2,
diff --git a/packages/yukey/src/megacolumn-controller.ts b/packages/yukey/src/megacolumn-controller.ts
index 2d1a2d5..69d1061 100644
--- a/packages/yukey/src/megacolumn-controller.ts
+++ b/packages/yukey/src/megacolumn-controller.ts
@@ -1,222 +1,259 @@
-// 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 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):
+// The DOM contract and the event contracts are documented on the declarations that carry them:
+// MegacolumnController (the attributes it scans),
+// MegacolumnKeyDetail (megacolumn-key: forwarded keys, claims, pointer activation),
+// MegacolumnFocusChangeDetail (megacolumn-focus-change),
+// MegacolumnFocusRequestDetail (megacolumn-focus-request), and MEGACOLUMN_RESCAN_EVENT (megacolumn-rescan).
+// Each event's name is an exported constant beside its detail type.
 //
-//   [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: yk-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 yk-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 yk-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 (yk-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 yk-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;
+// 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 yk-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.
+// 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:
+// 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.
+// 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.
 
 import type { ReactiveController, ReactiveControllerHost } from "lit";
 import { css } from "lit";
 import { MegacolumnNav, type NavRow, type RowPosition } from "./megacolumn-nav.js";
 import { dispatchYkInfo } from "./yk-info.js";
 
+/** MEGACOLUMN_KEY_EVENT is the name of the event whose detail is {@link MegacolumnKeyDetail}. */
+export const MEGACOLUMN_KEY_EVENT = "megacolumn-key";
+
+/**
+ * MegacolumnKeyDetail is the detail of a `megacolumn-key` event.
+ * 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.
+ * That preventDefault is a claim: later listeners and the controller treat the key as handled.
+ * 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 (yk-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.
+ */
 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.
+/** MEGACOLUMN_FOCUS_CHANGE_EVENT is the name of the event whose detail is {@link MegacolumnFocusChangeDetail}. */
+export const MEGACOLUMN_FOCUS_CHANGE_EVENT = "megacolumn-focus-change";
+
+/**
+ * MegacolumnFocusChangeDetail is the `megacolumn-focus-change` detail:
+ * the row now holding logical focus (null when the column emptied out).
+ * 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 this detail.
+ * 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.
+ */
 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.
+/**
+ * MEGACOLUMN_RESCAN_EVENT is the name of the `megacolumn-rescan` event, which has no detail.
+ * 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.
+ */
+export const MEGACOLUMN_RESCAN_EVENT = "megacolumn-rescan";
+
+/** MEGACOLUMN_FOCUS_REQUEST_EVENT is the name of the event whose detail is {@link MegacolumnFocusRequestDetail}. */
+export const MEGACOLUMN_FOCUS_REQUEST_EVENT = "megacolumn-focus-request";
+
+/**
+ * MegacolumnFocusRequestDetail is the `megacolumn-focus-request` detail: a programmatic focus move.
+ * Any element inside the container may ask for logical focus
+ * by dispatching a bubbling, cancelable `megacolumn-focus-request` CustomEvent with this detail.
+ * 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.
+ */
+export interface MegacolumnFocusRequestDetail {
+	rowId: string;
+}
+
+/**
+ * FALLBACK_LOCKOUT_MS is 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.
+/**
+ * CLAIM_EXCUSE_MS is 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).
+/** Host is 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 {
+/**
+ * closedToSight is 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.
+ */
+export function closedToSight(el: HTMLElement): boolean {
 	return el.closest("[aria-hidden=\"true\"]") !== null;
 }
 
+/**
+ * MegacolumnController binds a host component's rendered rows to the megacolumn navigation model.
+ * It reads the host's 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 {@link closedToSight}).
+ *   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: `YkInfoDetail`, yk-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 yk-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 yk-live-list does);
+ * slotting rows in from outside is not supported and scans as an empty column.
+ *
+ * Forwarded keys and pointer activation arrive as {@link MegacolumnKeyDetail} events;
+ * focus identity changes are announced as {@link MegacolumnFocusChangeDetail} events.
+ * Rows changed outside a host render are announced to the controller with {@link MEGACOLUMN_RESCAN_EVENT};
+ * programmatic focus moves are asked for with {@link MegacolumnFocusRequestDetail} events.
+ *
+ * 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.
+ *
+ * @see {@link megacolumnStyles} for the focus-ring classes this controller maintains.
+ */
 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).
+
+	/** #ringed is the elements currently carrying mc- focus classes. */
+	#ringed: Element[] = [];
+
+	/** #hotkeys maps lowercased marking char -> marked element. */
+	#hotkeys = new Map<string, HTMLElement>();
+
+	/** #scrolledTo is the last row id scrolled into view (scroll on identity change only). */
+	#scrolledTo: string | null = null;
+
+	/** #announcedFocus is the last focus identity announced via megacolumn-focus-change. */
+	#announcedFocus: string | null = null;
+
+	/**
+	 * #lockedOut is the churn lockout (see the file 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
+
+	/** #claimExcuse is whether a forwarded key was claimed within CLAIM_EXCUSE_MS. */
+	#claimExcuse = false;
 	#claimExcuseTimer: number | undefined;
 
 	constructor(host: Host) {
@@ -254,18 +291,18 @@ export class MegacolumnController implements ReactiveController {
 			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", () => {
+			// event (see the MegacolumnController class doc); a render-driven scan happens in hostUpdated as always.
+			container.addEventListener(MEGACOLUMN_RESCAN_EVENT, () => {
 				this.scanRows(this.mustContainer());
 				this.applyFocusState();
 			});
-			container.addEventListener("megacolumn-focus-request", (e) => this.onFocusRequest(e));
+			container.addEventListener(MEGACOLUMN_FOCUS_REQUEST_EVENT, (e) => this.onFocusRequest(e));
 		}
 		this.scanRows(container);
 		this.applyFocusState();
 	}
 
-	// Read the rendered rows (DOM order = display order) into the machine.
+	/** scanRows reads 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]")) {
@@ -289,7 +326,7 @@ export class MegacolumnController implements ReactiveController {
 			}
 			// 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
+			// 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;
@@ -306,10 +343,12 @@ export class MegacolumnController implements ReactiveController {
 		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.
+	/**
+	 * armLockout arms (or re-arms) the churn lockout: the machine's fallback just moved focus without a user keystroke.
+	 * Excused when a claimed forward landed within {@link 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;
@@ -320,7 +359,7 @@ export class MegacolumnController implements ReactiveController {
 		}, FALLBACK_LOCKOUT_MS);
 	}
 
-	// A forwarded key was claimed: open the excuse window (see armLockout).
+	/** noteClaim records that a forwarded key was claimed: open the excuse window (see {@link armLockout}). */
 	private noteClaim(): void {
 		this.#claimExcuse = true;
 		window.clearTimeout(this.#claimExcuseTimer);
@@ -330,22 +369,26 @@ export class MegacolumnController implements ReactiveController {
 		}, 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.
+	/**
+	 * jumpToMarking moves logical focus to a marking's row (the hotkey-jump resolution; see the file 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.
+	/**
+	 * scanHotkeys collects the column's [data-mc-hotkey] markings (see the DOM contract on {@link MegacolumnController}).
+	 * 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: {@link closedToSight}.
+	 */
 	private scanHotkeys(container: HTMLElement): void {
 		this.#hotkeys = new Map();
 		for (const el of container.querySelectorAll<HTMLElement>("[data-mc-hotkey]")) {
@@ -379,9 +422,11 @@ export class MegacolumnController implements ReactiveController {
 		}
 	}
 
-	// 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.
+	/**
+	 * applyFocusState renders 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");
@@ -398,7 +443,7 @@ export class MegacolumnController implements ReactiveController {
 			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
+			// 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
@@ -413,14 +458,14 @@ export class MegacolumnController implements ReactiveController {
 			"mc-empty-focus",
 			focusedId === null && this.focusIsInside(container),
 		);
-		// Announce focus IDENTITY changes (see the header's megacolumn-focus-change
+		// Announce focus identity changes (the MegacolumnFocusChangeDetail
 		// 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", {
+				new CustomEvent<MegacolumnFocusChangeDetail>(MEGACOLUMN_FOCUS_CHANGE_EVENT, {
 					detail: { rowId: focusedId },
 					bubbles: true,
 				}),
@@ -457,7 +502,7 @@ export class MegacolumnController implements ReactiveController {
 			case "forward": {
 				const row = this.rowElement(routing.rowId);
 				const control = this.controlElement(row, routing.position);
-				const forwarded = new CustomEvent<MegacolumnKeyDetail>("megacolumn-key", {
+				const forwarded = new CustomEvent<MegacolumnKeyDetail>(MEGACOLUMN_KEY_EVENT, {
 					detail: { key: e.key, rowId: routing.rowId, position: routing.position },
 					bubbles: true,
 					cancelable: true,
@@ -493,7 +538,7 @@ export class MegacolumnController implements ReactiveController {
 								// 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", {
+									new CustomEvent<MegacolumnKeyDetail>(MEGACOLUMN_KEY_EVENT, {
 										detail: { key: e.key, rowId: markedId, position: "main" },
 										bubbles: true,
 										cancelable: true,
@@ -535,10 +580,12 @@ export class MegacolumnController implements ReactiveController {
 		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.
+	/**
+	 * diveIntoText dives 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]`);
@@ -546,11 +593,13 @@ export class MegacolumnController implements ReactiveController {
 		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.
+	/**
+	 * onFocusRequest handles the megacolumn-focus-request event (see {@link MegacolumnFocusRequestDetail}):
+	 * 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;
@@ -564,8 +613,10 @@ export class MegacolumnController implements ReactiveController {
 		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.
+	/**
+	 * onMousedown syncs logical focus to mouse clicks.
+	 * 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;
@@ -579,16 +630,19 @@ export class MegacolumnController implements ReactiveController {
 		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 (yk-live-list's click guard) before this bubble listener can hear them.
+	/**
+	 * onClick is pointer activation (see {@link MegacolumnKeyDetail}):
+	 * 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 (yk-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;
@@ -602,7 +656,7 @@ export class MegacolumnController implements ReactiveController {
 		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", {
+		const synthesized = new CustomEvent<MegacolumnKeyDetail>(MEGACOLUMN_KEY_EVENT, {
 			detail: { key: "Enter", rowId: id, position },
 			bubbles: true,
 			cancelable: true,
@@ -650,8 +704,10 @@ export class MegacolumnController implements ReactiveController {
 		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.
+	/**
+	 * infoCarrier finds 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) {
@@ -687,22 +743,25 @@ export class MegacolumnController implements ReactiveController {
 	}
 }
 
-// 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.
+/**
+ * megacolumnStyles is 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.
+ *
+ * @see {@link MegacolumnController}
+ */
 export const megacolumnStyles = css`
 	[data-mc-container] {
 		outline: none; /* the ring is drawn on the focused row instead */
diff --git a/packages/yukey/src/megacolumn-nav.ts b/packages/yukey/src/megacolumn-nav.ts
index ed4805f..e089dc1 100644
--- a/packages/yukey/src/megacolumn-nav.ts
+++ b/packages/yukey/src/megacolumn-nav.ts
@@ -1,66 +1,44 @@
 // 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.
+// 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 is on the MegacolumnNav class doc; the key routing table is on handleKey.
 //
-// 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
+// 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 is the 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 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 is 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 means 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.
+/**
+ * KeyRouting is 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 }
@@ -72,34 +50,64 @@ function isFocusable(row: NavRow): boolean {
 	return row.focusable ?? true;
 }
 
+/**
+ * MegacolumnNav implements the megacolumn model 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.
+ *
+ * Rows are identified by stable ids, never indices:
+ * rows come and go (live lists churn), and focus must follow identity.
+ * {@link 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.
+ */
 export class MegacolumnNav {
 	#rows: NavRow[] = [];
 	#focusedId: string | null = null;
 	#position: RowPosition = "main";
 	#textMode = false;
 
+	/** rows is the ordered row list as last given to {@link setRows}. */
 	get rows(): readonly NavRow[] {
 		return this.#rows;
 	}
+
+	/**
+	 * focusedId is the focused row's id.
+	 * 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.)
+	 */
 	get focusedId(): string | null {
 		return this.#focusedId;
 	}
+
+	/** position is the focused control within the focused row. */
 	get position(): RowPosition {
 		return this.#position;
 	}
+
+	/** textMode is whether text entry is in progress. */
 	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 reconciles 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) {
@@ -123,9 +131,10 @@ export class MegacolumnNav {
 		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 moves 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}`);
@@ -138,8 +147,11 @@ export class MegacolumnNav {
 		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 is for when 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}`);
@@ -150,15 +162,31 @@ export class MegacolumnNav {
 		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 is for when 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 routes one key (a KeyboardEvent.key value; the binding filters out ctrl/alt/meta chords before asking).
+	 *
+	 * 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
+	 */
 	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.)
@@ -230,11 +258,13 @@ export class MegacolumnNav {
 		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.
+	/**
+	 * moveFocus steps 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";
@@ -260,10 +290,13 @@ export class MegacolumnNav {
 		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.
+	/**
+	 * fallbackFrom is 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) {
diff --git a/packages/yukey/src/press-flash.ts b/packages/yukey/src/press-flash.ts
index 0db8626..8b382cd 100644
--- a/packages/yukey/src/press-flash.ts
+++ b/packages/yukey/src/press-flash.ts
@@ -1,22 +1,23 @@
-// Activation feedback, shared across widgets: the "yk-pressed" flash class that
-// acknowledges a successful activation for --yk-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:
-//   - yk-button flashes its host element (and manages its own pointer-held
-//     "yk-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 .yk-pressed
-//     rendering lives in megacolumnStyles, beside the focus washes it must outrank.
+// Activation feedback, shared across widgets:
+// the "yk-pressed" flash class that acknowledges a successful activation for --yk-press-ms,
+// so keyboard triggers (Enter/Space, hotkeys) are as visible as a mouse press.
 
+/**
+ * PRESSED_CLASS is the flash class.  Consumers and their class carriers:
+ * - yk-button flashes its host element (and manages its own pointer-held "yk-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 .yk-pressed rendering lives in megacolumnStyles, beside the focus washes it must outrank.
+ */
 export const PRESSED_CLASS = "yk-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.
+/**
+ * pressMs is the flash duration, resolved from the cascade at el.
+ * 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.
+ * 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("--yk-press-ms").trim();
 	const m = /^(\d+(?:\.\d+)?)(m?s)$/.exec(raw);
@@ -32,9 +33,11 @@ export function pressMs(el: Element): number {
 
 const timers = new WeakMap<HTMLElement, number>();
 
-// Show the pressed look on el for --yk-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.
+/**
+ * flashPress shows the pressed look on el for --yk-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));
diff --git a/packages/yukey/src/tab-cycle.ts b/packages/yukey/src/tab-cycle.ts
index e43c250..abbc946 100644
--- a/packages/yukey/src/tab-cycle.ts
+++ b/packages/yukey/src/tab-cycle.ts
@@ -1,18 +1,9 @@
-// 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
-// (yk-live-list's collapsing rows), and a tab stop invisible to AT must not be
-// a tab stop at all.
+// 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).
 
 function isTabbable(el: Element): boolean {
 	if (!(el instanceof HTMLElement)) return false;
@@ -21,7 +12,17 @@ function isTabbable(el: Element): boolean {
 	return el.matches("a[href], button, input, select, textarea, [contenteditable='true']");
 }
 
-// All tab stops under `root` in tree order, descending open shadow roots.
+/**
+ * deepTabbables is all tab stops under `root` in tree order, descending open shadow roots.
+ *
+ * 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 (yk-live-list's collapsing rows),
+ * and a tab stop invisible to AT must not be a tab stop at all.
+ */
 export function deepTabbables(root: ParentNode): HTMLElement[] {
 	const out: HTMLElement[] = [];
 	const walk = (node: ParentNode): void => {
@@ -36,7 +37,7 @@ export function deepTabbables(root: ParentNode): HTMLElement[] {
 	return out;
 }
 
-// The really-focused element, descending the shadow activeElement chain.
+/** deepActiveElement is 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) {
@@ -45,11 +46,13 @@ export function deepActiveElement(doc: Document): Element | null {
 	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.
+/**
+ * containTab is 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);
@@ -63,14 +66,17 @@ export function containTab(e: KeyboardEvent, doc: Document): void {
 	(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.
+/**
+ * installed is the 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).
+/**
+ * installTabContainment installs 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);
diff --git a/packages/yukey/src/themes/default.ts b/packages/yukey/src/themes/default.ts
index c088354..5fe6392 100644
--- a/packages/yukey/src/themes/default.ts
+++ b/packages/yukey/src/themes/default.ts
@@ -1,15 +1,18 @@
-// The kit's default theme: dark, teal-accented, SCADA-console.  installTokens() with
-// no argument installs this one.
-//
-// Values only; the names and their roles are the vocabulary in ../tokens.ts.  A theme
-// supplies every name (the Theme type enforces it) and nothing else.  To restyle the
-// kit, copy this file, change values, and pass yours to installTokens.
-//
-// These values are a first palette, not an enshrinement: iterate them freely.  The
-// `tokens` gallery entry shows whatever theme is installed.
+// The kit's default theme.
 
 import type { Theme } from "../tokens.js";
 
+/**
+ * defaultTheme is the kit's default theme: dark, teal-accented, SCADA-console.
+ * installTokens() with no argument installs this one.
+ *
+ * Values only; the names and their roles are the vocabulary in tokens.ts.
+ * A theme supplies every name (the Theme type enforces it) and nothing else.
+ * To restyle the kit, copy this file, change values, and pass yours to installTokens.
+ *
+ * These values are a first palette, not an enshrinement: iterate them freely.
+ * The `tokens` gallery entry shows whatever theme is installed.
+ */
 export const defaultTheme: Theme = {
 	"--yk-bg-void": "#05080f",
 	"--yk-bg-panel-solid": "#0a1220",
diff --git a/packages/yukey/src/tokens.ts b/packages/yukey/src/tokens.ts
index afb5543..adfba7e 100644
--- a/packages/yukey/src/tokens.ts
+++ b/packages/yukey/src/tokens.ts
@@ -1,41 +1,39 @@
-// The design-token vocabulary: every color, gap, size, and type choice the kit's
-// components are styled with, as CSS custom properties.  This file is the SSOT for
-// token NAMES and their roles; docs/precepts-aesthetics.md describes the intent, and
-// the `tokens` gallery entry renders the vocabulary live.
-//
-// Values live in a Theme (themes/default.ts is the one the kit ships).  The split
-// is the point: components write `var(--yk-...)` against names that hold still,
-// and an application restyles the whole kit by installing a different theme.
-// A Theme is typed against the vocabulary, so a theme missing a token is a compile
-// error rather than a silently unstyled element.
-//
-// installTokens(theme) puts the theme's values on :root; custom properties inherit
-// through shadow boundaries from there, so that one call is the whole setup.
+// The design-token vocabulary:
+// every color, gap, size, and type choice the kit's components are styled with, as CSS custom properties.
+// This file is the SSOT for token names and their roles;
+// docs/precepts-aesthetics.md describes the intent, and the `tokens` gallery entry renders the vocabulary live.
+// Values live in a Theme; installTokens is the whole setup.
 
 import type { CSSResult } from "lit";
 import { unsafeCSS } from "lit";
 import { defaultTheme } from "./themes/default.js";
 
-// 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.
+/**
+ * TokenKind is 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: `--yk-${string}`;
 	kind: TokenKind;
-	// The role, in a sentence: what kind of thing should use this token.
+
+	/** about is 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 is a 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: readonly TokenDef[];
 }
 
+/** tokenGroups is the vocabulary, grouped, in display order. */
 export const tokenGroups = [
 	{
 		id: "surfaces",
@@ -363,15 +361,26 @@ export const tokenGroups = [
 	},
 ] as const satisfies readonly TokenGroup[];
 
-// Every token name in the vocabulary, as a type.  Derived from tokenGroups, so adding
-// a token there is the one edit; every Theme then has to supply it.
+/**
+ * TokenName is every token name in the vocabulary, as a type.
+ * Derived from {@link tokenGroups}, so adding a token there is the one edit; every Theme then has to supply it.
+ */
 export type TokenName = (typeof tokenGroups)[number]["tokens"][number]["name"];
 
-// A complete set of values for the vocabulary: one CSS value per token name.
+/**
+ * Theme is a complete set of values for the vocabulary: one CSS value per token name
+ * (themes/default.ts is the one the kit ships).
+ * The split is the point: components write `var(--yk-...)` against names that hold still,
+ * and an application restyles the whole kit by installing a different theme.
+ * A Theme is typed against the vocabulary,
+ * so a theme missing a token is a compile error rather than a silently unstyled element.
+ */
 export type Theme = Readonly<Record<TokenName, string>>;
 
-// Renders a theme into one `:root { ... }` block, in vocabulary order, group titles as comments.
-// Exported for the unit test; everything else should use installTokens.
+/**
+ * themeCssText renders a theme into one `:root { ... }` block, in vocabulary order, group titles as comments.
+ * Exported for the unit test; everything else should use {@link installTokens}.
+ */
 export function themeCssText(theme: Theme, groups: readonly TokenGroup[] = tokenGroups): string {
 	const lines: string[] = [":root {"];
 	for (const group of groups) {
@@ -384,16 +393,20 @@ export function themeCssText(theme: Theme, groups: readonly TokenGroup[] = token
 	return lines.join("\n");
 }
 
-// A theme as a lit CSSResult, for callers that compose stylesheets by hand.
-// unsafeCSS is safe here: the text is generated from the vocabulary and the theme's
-// literal values, never from runtime data.
+/**
+ * themeStyles is a theme as a lit CSSResult, for callers that compose stylesheets by hand.
+ * unsafeCSS is safe here: the text is generated from the vocabulary and the theme's literal values, never from runtime data.
+ */
 export function themeStyles(theme: Theme): CSSResult {
 	return unsafeCSS(themeCssText(theme));
 }
 
-// Installs a theme document-wide (a <style> in head).  Custom properties inherit
-// into every shadow root from there.  With no argument, the kit's default theme.
-// Idempotent: a second call is harmless and leaves the first theme in place.
+/**
+ * installTokens installs a theme document-wide (a `<style>` in head);
+ * custom properties inherit through shadow boundaries into every shadow root from there, so that one call is the whole setup.
+ * With no argument, the kit's default theme.
+ * Idempotent: a second call is harmless and leaves the first theme in place.
+ */
 export function installTokens(theme: Theme = defaultTheme, doc: Document = document): void {
 	if (doc.head.querySelector("style[data-yk-tokens]") !== null) return;
 	const style = doc.createElement("style");
diff --git a/packages/yukey/src/yk-button.ts b/packages/yukey/src/yk-button.ts
index 89dcd1c..ea4dd0c 100644
--- a/packages/yukey/src/yk-button.ts
+++ b/packages/yukey/src/yk-button.ts
@@ -1,85 +1,66 @@
 // yk-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 --yk-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 `yk-info` event carrying this button's `info` text.
-//     yk-infopop renders the popup; `info` is also mirrored onto data-info,
-//     the attribute yk-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 --yk-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             -> yk-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
-// --yk-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 yk-pressed/yk-held classes live on the HOST element, outside classMap's
-// managed set, so renders never clobber a flash in flight.
+// (The norms: docs/precepts-we-love-the-keyboard.md, "buttons always show their hotkey" and the megacolumn sections.
+// Each property's doc names the norm it carries; the YkButton class doc has the per-context contract.)
 
 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 { MEGACOLUMN_KEY_EVENT, type MegacolumnKeyDetail } from "./megacolumn-controller.js";
 import { flashPress } from "./press-flash.js";
 import { dispatchYkInfo } from "./yk-info.js";
 
+/**
+ * YkButton is a button with the guidelines' keyboard norms built in:
+ * a marked hotkey, the `i` norm, the aux (small-x) form inside megacolumn rows,
+ * and one `click` event for every trigger.
+ * Hit targets from `--yk-hit-min-h`/`--yk-hit-min-w`.
+ *
+ * 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.
+ *   The button claims keys: it preventDefaults the forwarded `megacolumn-key` event,
+ *   so later listeners and the controller treat the key as handled
+ *   (the claim idiom is defined on {@link MegacolumnKeyDetail}).
+ *   First claim wins, and a disabled button claims but declines to act.  What it claims:
+ *   - Enter/Space at this button's own position: activate.
+ *   - Its hotkey, only when {@link 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: yk-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 `--yk-press-ms` (see {@link flashPress}),
+ * 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 `yk-pressed`/`yk-held` classes live on the host element, outside classMap's managed set,
+ * so renders never clobber a flash in flight.
+ */
 @customElement("yk-button")
 export class YkButton extends LitElement {
 	static styles = css`
@@ -152,7 +133,7 @@ export class YkButton extends LitElement {
 			color: inherit;
 		}
 		/* activation feedback: the same look for held-under-a-pointer and for the
-		   post-activation flash (see the file header) */
+		   post-activation flash (see the class doc) */
 		:host(.yk-held) .btn,
 		:host(.yk-pressed) .btn {
 			background: var(--yk-wash-pressed);
@@ -161,20 +142,56 @@ export class YkButton extends LitElement {
 		}
 	`;
 
+	/**
+	 * label is the visible text.
+	 * Hotkey marking: if the hotkey letter occurs in the label, its first occurrence renders in `--yk-hotkey-accent`;
+	 * otherwise the letter shows in a slot 70% of the way across.
+	 */
 	@property()
 	label = "";
+
+	/**
+	 * icon is a glyph for the hotkey slot.
+	 * A complex button (more than one concept of activation) sets this instead of relying on the letter,
+	 * and it takes that slot:
+	 * a single letter would mislead, so the icon says "more here; ask `i`".
+	 */
 	@property()
 	icon = "";
+
+	/**
+	 * disabled suppresses activation.
+	 * Disabled is `aria-disabled`, never the native disabled attribute:
+	 * the button stays perceivable (and standalone: focusable).
+	 * `i` answers even when disabled: it is how you ask why a thing is off.
+	 */
 	@property({ type: Boolean, reflect: true })
 	disabled = false;
+
+	/**
+	 * active marks the in-effect option among sibling choices, 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.
+	 */
 	@property({ type: Boolean })
 	active = false;
 
-	// The info content, mirrored synchronously onto data-info: that attribute is
-	// the carrier contract yk-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 is the info content, mirrored synchronously onto `data-info`:
+	 * that attribute is the carrier contract yk-infopop's hover trigger finds elements by
+	 * (decision-info-content: one authored content, two triggers),
+	 * and mirroring keeps the `info` property a typed accessor over the same single source.
+	 */
 	#info = "";
+
+	/**
+	 * info is the text for the `i` norm:
+	 * pressing `i` (forwarded in a row, or while focused standalone)
+	 * dispatches a composed `yk-info` event carrying this text; yk-infopop renders the popup.
+	 * Also mirrored onto `data-info` for yk-infopop's hover trigger.
+	 * Empty means not a carrier.
+	 */
 	@property()
 	get info(): string {
 		return this.#info;
@@ -185,13 +202,22 @@ export class YkButton extends LitElement {
 		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 is 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 (in the setter) matches the scan's (single `[a-z0-9]`)
+	 * so a bad hotkey fails loudly in standalone contexts too, where no scan exists.
+	 */
 	#hotkey = "";
+
+	/**
+	 * hotkey is a single `[a-z0-9]` character, case-insensitive; setting anything else throws.
+	 * In a megacolumn row, pressing it moves logical focus to this row and Enter/Space then activate (see-before-commit);
+	 * with {@link hotkeyDirect} the press activates outright.
+	 * Standalone, it activates while the button holds focus.
+	 * `x` and `i` are legal but conventionally left unmarked:
+	 * the row norms answer them (see the megacolumn DOM contract's guideline).
+	 */
 	@property()
 	get hotkey(): string {
 		return this.#hotkey;
@@ -206,15 +232,22 @@ export class YkButton extends LitElement {
 		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 is mirrored synchronously onto `data-mc-hotkey-direct`, the controller's half of the contract,
+	 * the same way hotkey mirrors onto `data-mc-hotkey`.
+	 */
 	#hotkeyDirect = false;
+
+	/**
+	 * hotkeyDirect (attribute `hotkey-direct`) is 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.
+	 *
+	 * The deliberately long name is friction:
+	 * skipping see-before-commit is only 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.
+	 */
 	@property({ type: Boolean, attribute: "hotkey-direct" })
 	get hotkeyDirect(): boolean {
 		return this.#hotkeyDirect;
@@ -238,7 +271,7 @@ export class YkButton extends LitElement {
 			);
 		}
 		if (this.#row !== null) {
-			this.#row.addEventListener("megacolumn-key", this.#onMegacolumnKey);
+			this.#row.addEventListener(MEGACOLUMN_KEY_EVENT, this.#onMegacolumnKey);
 		} else {
 			this.addEventListener("keydown", this.#onKeydown);
 		}
@@ -257,7 +290,7 @@ export class YkButton extends LitElement {
 
 	disconnectedCallback(): void {
 		super.disconnectedCallback();
-		this.#row?.removeEventListener("megacolumn-key", this.#onMegacolumnKey);
+		this.#row?.removeEventListener(MEGACOLUMN_KEY_EVENT, this.#onMegacolumnKey);
 		this.#row = null;
 		this.removeEventListener("keydown", this.#onKeydown);
 		this.removeEventListener("click", this.#onActivated);
@@ -268,7 +301,7 @@ export class YkButton extends LitElement {
 		this.#releaseHold();
 	}
 
-	// Row-mode claims; see the file header for the claim table.
+	/** #onMegacolumnKey answers forwarded keys in row mode; the {@link YkButton} class doc has the list of which keys it claims. */
 	#onMegacolumnKey = (e: Event): void => {
 		if (e.defaultPrevented) return; // someone earlier in the row claimed it
 		const { key, position } = (e as CustomEvent<MegacolumnKeyDetail>).detail;
@@ -277,7 +310,7 @@ export class YkButton extends LitElement {
 		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
+			// 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") {
@@ -288,7 +321,7 @@ export class YkButton extends LitElement {
 		}
 	};
 
-	// Standalone: the hotkey and i answer while the button holds focus.
+	/** #onKeydown is standalone mode: 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();
@@ -305,12 +338,15 @@ export class YkButton extends LitElement {
 		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 is registered as the host's `click` listener in connectedCallback.
+	 * Every activation path ends in a `click` event on this host (the keyboard paths call `this.click()`),
+	 * so this one listener sees every activation and gives it the flash;
+	 * that is how 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 (preventDefault) 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;
@@ -326,8 +362,10 @@ export class YkButton extends LitElement {
 		this.classList.remove("yk-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 consumes a claimed key 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;
@@ -355,7 +393,7 @@ export class YkButton extends LitElement {
 		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:
+		// 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>${
@@ -379,9 +417,11 @@ export class YkButton extends LitElement {
 				>${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 is the 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);
diff --git a/packages/yukey/src/yk-info.ts b/packages/yukey/src/yk-info.ts
index 279a9c9..9d9df8f 100644
--- a/packages/yukey/src/yk-info.ts
+++ b/packages/yukey/src/yk-info.ts
@@ -1,31 +1,39 @@
 // The info norm's wiring: the yk-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.  yk-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 (yk-button) mirror it onto this attribute;
-//     plain markup writes the attribute directly.  Empty means not a carrier.
-//   - `yk-info` (bubbles + composed) is the keyboard trigger's event: a producer
-//     that answered i dispatches it, yk-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: yk-button (its own i claim, standalone and in rows) and
-// MegacolumnController (the fallback for plain [data-info] rows).
+// This file is the SSOT for both, on YkInfoDetail.
+// (The norm itself: docs/precepts-we-love-the-keyboard.md, "info pops" -- i means "info, please" on almost any element.)
 
+/** YK_INFO_EVENT is the name of the event whose detail is {@link YkInfoDetail}. */
+export const YK_INFO_EVENT = "yk-info";
+
+/**
+ * YkInfoDetail is the `yk-info` event's detail, and the SSOT for the info norm's wiring.
+ * One authored content, two triggers:
+ *
+ * - `data-info="<content>"` on any element makes it a carrier.
+ *   yk-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 (yk-button) mirror it onto this attribute; plain markup writes the attribute directly.
+ *   Empty means not a carrier.
+ * - `yk-info` (bubbles + composed) is the keyboard trigger's event:
+ *   a producer that answered `i` dispatches it ({@link dispatchYkInfo}), yk-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.
+ */
 export interface YkInfoDetail {
 	content: string;
-	// The element to anchor the popup to (see the retargeting note above).
+
+	/** source is the element to anchor the popup to (see the retargeting note on {@link YkInfoDetail}). */
 	source: HTMLElement;
 }
 
+/**
+ * dispatchYkInfo dispatches a `yk-info` event from source.
+ * Producers today: yk-button (its own `i` claim, standalone and in rows)
+ * and MegacolumnController (the fallback for plain [data-info] rows).
+ */
 export function dispatchYkInfo(source: HTMLElement, content: string): void {
 	source.dispatchEvent(
-		new CustomEvent<YkInfoDetail>("yk-info", {
+		new CustomEvent<YkInfoDetail>(YK_INFO_EVENT, {
 			detail: { content, source },
 			bubbles: true,
 			composed: true,
diff --git a/packages/yukey/src/yk-infopop.ts b/packages/yukey/src/yk-infopop.ts
index f233a4c..7d110d8 100644
--- a/packages/yukey/src/yk-infopop.ts
+++ b/packages/yukey/src/yk-infopop.ts
@@ -1,65 +1,71 @@
-// yk-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 yk-info event and the data-info carrier attribute --
-// is yk-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), and every yk-info event and every
-// [data-info] hover in that tree raises it.  Two triggers:
-//
-//   - Keyboard: a yk-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 yk-info
-//     arriving in the same dispatch is swallowed, so the second i dismisses
-//     instead of blinking closed-then-open.  (A different anchor's yk-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.
+// yk-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 norm is docs/precepts-we-love-the-keyboard.md "info pops"; the contract is on the YkInfopop class doc.
 
 import { css, html, LitElement, type TemplateResult } from "lit";
 import { customElement, state } from "lit/decorators.js";
 import { placeInfopop } from "./infopop-place.js";
-import type { YkInfoDetail } from "./yk-info.js";
+import { YK_INFO_EVENT, type YkInfoDetail } from "./yk-info.js";
 
+/** HOVER_DELAY_MS is the short delay between a hover carrier becoming the candidate and the popup opening unpinned. */
 export const HOVER_DELAY_MS = 350;
 
+/** Mode is the popup's state: closed, pinned (raised by a `yk-info` event), or hover (raised by the pointer). */
 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).
+/**
+ * AriaReflection is 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;
 }
 
+/**
+ * YkInfopop is 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 yk-info event and the data-info carrier attribute -- is {@link YkInfoDetail}).
+ *
+ * One per app: place it once in the app shell's render (the gallery shell hosts one),
+ * and every yk-info event and every [data-info] hover in that tree raises it.
+ * Two triggers:
+ *
+ * - Keyboard: a yk-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 yk-info arriving in the same dispatch is swallowed,
+ *   so the second `i` dismisses instead of blinking closed-then-open.
+ *   (A different anchor's yk-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 ({@link HOVER_DELAY_MS}) 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 {@link placeInfopop}'s pure rule (below-centered, flip, clamp), fed fresh rects at open time.
+ */
 @customElement("yk-infopop")
 export class YkInfopop extends LitElement {
 	static styles = css`
@@ -84,22 +90,31 @@ export class YkInfopop extends LitElement {
 	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 is 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 yk-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 is set when an `i` keydown dismisses a pinned popup:
+	 * the producer answering that same keypress will usually dispatch a fresh yk-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;
 
+	/** mode is the popup's current state. */
 	get mode(): Mode {
 		return this.#mode;
 	}
+
+	/** anchor is the element the open popup describes; null when closed. */
 	get anchor(): HTMLElement | null {
 		return this.#anchor;
 	}
@@ -111,13 +126,13 @@ export class YkInfopop extends LitElement {
 		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
+		// 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("yk-info", this.#onYkInfo);
+		root.addEventListener(YK_INFO_EVENT, this.#onYkInfo);
 		root.addEventListener("pointermove", this.#onPointermove);
 		root.addEventListener("pointerout", this.#onPointerout);
 		// Dismissals, global by nature.  Capture phase: closing must precede the
@@ -134,7 +149,7 @@ export class YkInfopop extends LitElement {
 		this.#dismissal = null;
 		const root = this.#root;
 		if (root !== null) {
-			root.removeEventListener("yk-info", this.#onYkInfo);
+			root.removeEventListener(YK_INFO_EVENT, this.#onYkInfo);
 			root.removeEventListener("pointermove", this.#onPointermove);
 			root.removeEventListener("pointerout", this.#onPointerout);
 			this.#root = null;
@@ -162,14 +177,16 @@ export class YkInfopop extends LitElement {
 	#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
+		// (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 is 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();
@@ -180,8 +197,10 @@ export class YkInfopop extends LitElement {
 		return false;
 	}
 
-	// The carrier under the pointer changed.  Hover popups live and die by it;
-	// pinned popups ignore it entirely.
+	/**
+	 * #trackCarrier notes that 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;
@@ -190,7 +209,7 @@ export class YkInfopop extends LitElement {
 		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 --
+				// 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;
@@ -202,7 +221,7 @@ export class YkInfopop extends LitElement {
 	#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
+		// 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.
@@ -255,9 +274,11 @@ export class YkInfopop extends LitElement {
 		}
 	}
 
-	// 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.
+	/**
+	 * updated is where 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 = "";
@@ -293,8 +314,10 @@ export class YkInfopop extends LitElement {
 	}
 }
 
-// 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.
+/**
+ * nearestCarrier is 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;
diff --git a/packages/yukey/src/yk-list.ts b/packages/yukey/src/yk-list.ts
index d11e310..945f63d 100644
--- a/packages/yukey/src/yk-list.ts
+++ b/packages/yukey/src/yk-list.ts
@@ -1,129 +1,57 @@
 // yk-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 yk-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, yk-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 yk-live-list untouched: the
-//                   owner renders row CONTENT and keeps data identity; see
-//                   yk-live-list.ts for their contracts.
-//
-//   events:
-//     yk-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 yk-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?] <yk-live-list megacolumn> [add-row?], all inside this
-//   element.  yk-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 yk-list-prefixed classes on
-//   the structural rows are stable; their look is the norm's look.
+// The public contract is on the YkList class doc and its properties; yk-list-add is on YkListAddDetail.
 //
 // 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
-// yk-list was never legal (rows come from renderItem, not markup).
-//
-// Like yk-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, yk-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.
+// 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 yk-list was never legal (rows come from renderItem, not markup).
 
 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 {
+	MEGACOLUMN_FOCUS_REQUEST_EVENT,
+	MEGACOLUMN_KEY_EVENT,
+	MEGACOLUMN_RESCAN_EVENT,
+	type MegacolumnFocusRequestDetail,
+	type MegacolumnKeyDetail,
+} from "./megacolumn-controller.js";
 import type { LiveListPhase } from "./yk-live-list.js";
 import "./yk-live-list.js";
 
+/** YK_LIST_ADD_EVENT is the name of the event whose detail is {@link YkListAddDetail}. */
+export const YK_LIST_ADD_EVENT = "yk-list-add";
+
+/**
+ * YkListAddDetail is the detail of a `yk-list-add` event: 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.
+ */
 export interface YkListAddDetail {
-	text: string; // trimmed, never empty
+	/** text is trimmed, never empty. */
+	text: string;
 }
 
-// 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.
+/**
+ * ykListStyles is 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 ykListStyles = css`
 	yk-list {
 		display: block;
@@ -170,37 +98,149 @@ const ykListStyles = css`
 	}
 `;
 
+/**
+ * YkList is 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 yk-live-list in megacolumn mode
+ *   (that element 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.
+ *
+ * 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 yk-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?] <yk-live-list megacolumn> [add-row?]`, all inside this element.
+ * yk-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 yk-list-prefixed classes on the structural rows are stable; their look is the norm's look.
+ *
+ * Like yk-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, yk-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.
+ */
 @customElement("yk-list")
 export class YkList<T = unknown> extends LitElement {
+	/**
+	 * 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).
+	 */
 	@property()
 	name = "list";
+
+	/**
+	 * header is the header row text.
+	 * Empty (the default) means headerless: no header row, no group identity taken.
+	 */
 	@property()
 	header = "";
+
+	/**
+	 * headerInfo (attribute `header-info`) is 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.
+	 */
 	@property({ attribute: "header-info" })
 	headerInfo = "";
+
+	/**
+	 * renderHeader is an 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.
+	 */
 	@property({ attribute: false })
 	renderHeader?: () => TemplateResult;
+
+	/**
+	 * addable renders the add-row.
+	 * Lists that cannot append (a cargo manifest) just leave it off.
+	 */
 	@property({ type: Boolean })
 	addable = false;
+
+	/**
+	 * addLabel (attribute `add-label`) is the add-row's label; default "add…".
+	 * The hotkey letter is marked in the label (first occurrence, yk-button's rule),
+	 * or shown after it when the label lacks the letter.
+	 */
 	@property({ attribute: "add-label" })
 	addLabel = "add…";
+
+	/**
+	 * addHotkey (attribute `add-hotkey`) is 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.
+	 */
 	@property({ attribute: "add-hotkey" })
 	addHotkey = "a";
+
+	/**
+	 * addInfo (attribute `add-info`) is the owner's info text for the add-row;
+	 * norm teaching is appended, as with {@link headerInfo}.
+	 */
 	@property({ attribute: "add-info" })
 	addInfo = "";
+
+	/**
+	 * items is passed through to the inner yk-live-list untouched,
+	 * as are {@link keyOf}, {@link renderItem}, {@link renderEmpty}, and {@link resetKey}:
+	 * the owner renders row content and keeps data identity; see yk-live-list for their contracts.
+	 */
 	@property({ attribute: false })
 	items: T[] = [];
+
+	/** keyOf is passed through to the inner yk-live-list untouched; see {@link items}. */
 	@property({ attribute: false })
 	keyOf!: (item: T) => string;
+
+	/** renderItem is passed through to the inner yk-live-list untouched; see {@link items}. */
 	@property({ attribute: false })
 	renderItem!: (item: T, phase: LiveListPhase) => TemplateResult;
+
+	/** renderEmpty is passed through to the inner yk-live-list untouched; see {@link items}. */
 	@property({ attribute: false })
 	renderEmpty?: () => TemplateResult;
+
+	/** resetKey is passed through to the inner yk-live-list untouched; see {@link items}. */
 	@property()
 	resetKey = "";
 
-	// The column this list belongs to; n walks its rows.  Set at connect (which
-	// fails closed when there is none).
+	/**
+	 * #container is 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() {
@@ -209,10 +249,10 @@ export class YkList<T = unknown> extends LitElement {
 		// 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);
+		this.addEventListener(MEGACOLUMN_KEY_EVENT, this.#onListKey);
 	}
 
-	// Light DOM, no slots (see the header).  Styles adopt into the owner's root.
+	/** createRenderRoot is light DOM, no slots (see {@link YkList}).  Styles adopt into the owner's root. */
 	protected override createRenderRoot(): HTMLElement {
 		if (this.children.length > 0) {
 			throw new Error(
@@ -253,7 +293,7 @@ export class YkList<T = unknown> extends LitElement {
 		// Structural rows appear and disappear on this element's own update
 		// schedule, after the owner's hostUpdated scan already ran -- announce,
 		// same contract as yk-live-list (re-scans are idempotent).
-		this.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true }));
+		this.dispatchEvent(new CustomEvent(MEGACOLUMN_RESCAN_EVENT, { 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.
@@ -266,12 +306,15 @@ export class YkList<T = unknown> extends LitElement {
 		}
 	}
 
-	// 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 is the `t`/`n` list-scoped keys (see {@link YkList}),
+	 * registered as this element's megacolumn-key listener in the constructor.
+	 * 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;
@@ -293,9 +336,9 @@ export class YkList<T = unknown> extends LitElement {
 		}
 	};
 
-	// Ask the column to move logical focus to rowId; true when granted.
+	/** #requestFocus asks the column to move logical focus to rowId; true when granted. */
 	#requestFocus(rowId: string): boolean {
-		const req = new CustomEvent("megacolumn-focus-request", {
+		const req = new CustomEvent<MegacolumnFocusRequestDetail>(MEGACOLUMN_FOCUS_REQUEST_EVENT, {
 			detail: { rowId },
 			bubbles: true,
 			cancelable: true,
@@ -304,13 +347,14 @@ export class YkList<T = unknown> extends LitElement {
 		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 is 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") {
@@ -323,7 +367,7 @@ export class YkList<T = unknown> extends LitElement {
 			input.value = "";
 			return; // an empty commit is no commit; the exit proceeds
 		}
-		const commit = new CustomEvent<YkListAddDetail>("yk-list-add", {
+		const commit = new CustomEvent<YkListAddDetail>(YK_LIST_ADD_EVENT, {
 			detail: { text },
 			bubbles: true,
 			cancelable: true,
@@ -374,8 +418,10 @@ export class YkList<T = unknown> extends LitElement {
 		`;
 	}
 
-	// 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 is 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; ` : "";
@@ -390,9 +436,11 @@ export class YkList<T = unknown> extends LitElement {
 		return this.addInfo === "" ? norms : `${this.addInfo}; ${norms}`;
 	}
 
-	// The add-row's hotkey marking: the letter accented in the label (first
-	// case-insensitive occurrence, yk-button's rule), or shown after the label
-	// when it lacks the letter -- a marking must be visible either way.
+	/**
+	 * #markedAddLabel is the add-row's hotkey marking:
+	 * the letter accented in the label (first case-insensitive occurrence, yk-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="yk-list-hk">${this.addHotkey}</span>`;
diff --git a/packages/yukey/src/yk-live-list.ts b/packages/yukey/src/yk-live-list.ts
index 849a2b5..4f141d6 100644
--- a/packages/yukey/src/yk-live-list.ts
+++ b/packages/yukey/src/yk-live-list.ts
@@ -1,6 +1,8 @@
 // yk-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:
+// The public contract is on the YkLiveList class doc and its properties
+// (pointerSafety carries the two click-safety strategies); the row phases owners see are LiveListPhase.
+//
+// The row lifecycle:
 //
 //   enter ──► live ──────────► committed ────► collapsing ──► removed
 //   (grow-in:   (content updates  (item left the   (grid-rows      (DOM dropped;
@@ -8,93 +10,10 @@
 //                  ▲               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, yk-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 yk-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).
+// 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";
@@ -102,25 +21,34 @@ 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.
+import { MEGACOLUMN_RESCAN_EVENT } from "./megacolumn-controller.js";
+
+/**
+ * LiveListPhase is 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";
 
+/**
+ * ItemClickDetail is the detail of an `item-click` event (bubbling, composed),
+ * dispatched when a click actually lands on a row: its key, its last-known item, and its {@link LiveListPhase}.
+ */
 export interface ItemClickDetail<T> {
 	key: string;
 	item: T;
 	phase: LiveListPhase;
 }
 
-// The row-lifecycle styles, shared by both render modes.  Class names carry an
-// yk-ll- prefix because mega mode renders into the owner's tree, where bare
-// names like "closed" would be collision bait.
+/**
+ * lifecycleStyles is the row-lifecycle styles, shared by both render modes.
+ * Class names carry an yk-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. */
@@ -136,7 +64,7 @@ const lifecycleStyles = css`
 		transition: grid-template-rows var(--yk-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]
+		   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;
@@ -166,8 +94,11 @@ const lifecycleStyles = css`
 	}
 `;
 
-// 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).
+/**
+ * megaModeStyles is 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`
 	yk-live-list {
 		display: block;
@@ -175,27 +106,36 @@ const megaModeStyles = css`
 	${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.
+/**
+ * RowPhase is the internal row phases (the file header has the lifecycle diagram).
+ * "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.
+/**
+ * RELEASE_STAGGER_MS is the 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.
+/**
+ * CLICK_GUARD_MS is 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.
+/**
+ * GuardEntry is 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 {@link 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;
@@ -204,19 +144,86 @@ interface GuardEntry {
 
 interface RowState<T> {
 	key: string;
-	item: T; // last-known content; frozen at commit time for ghosts
+
+	/** item is the last-known content; frozen at commit time for ghosts. */
+	item: T;
 	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 is whether the row 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 is whether 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 is the 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
+
+	/** timer is the phase-advance timer; at most one per row. */
+	timer: number | undefined;
 }
 
+/**
+ * YkLiveList is 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.
+ * The safety goal, in one sentence: clicks must not land on surprising targets.
+ * Two strategies answer it -- the {@link pointerSafety} property picks one.
+ *
+ * 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.
+ * - {@link resetKey} change drops all state instantly.
+ *
+ * Megacolumn mode (the static `megacolumn` attribute, set in markup):
+ * the list composes with the megacolumn focus model (`MegacolumnController` 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, yk-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 {@link 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 {@link renderEmpty} render-prop (which shadow mode also honors, over its default slot),
+ * and {@link 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 yk-ll- prefixed so nothing collides in the shared tree.
+ */
 @customElement("yk-live-list")
 export class YkLiveList<T = unknown> extends LitElement {
 	static styles = [
@@ -228,69 +235,140 @@ export class YkLiveList<T = unknown> extends LitElement {
 		lifecycleStyles,
 	];
 
+	/** items is the data, replaced wholesale per tick; rows are keyed from it by {@link keyOf}. */
 	@property({ attribute: false })
 	items: T[] = [];
+
+	/** keyOf returns an item's stable key: row identity across ticks. */
 	@property({ attribute: false })
 	keyOf!: (item: T) => string;
+
+	/** renderItem renders a row's content for its {@link LiveListPhase}; the committed variant is the owner's to draw. */
 	@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.
+
+	/**
+	 * renderEmpty is 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.
+
+	/**
+	 * itemStyles is 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.
+
+	/**
+	 * resetKey is the identity of the data source.
+	 * When it changes (station switch, epoch change), all row state drops and the list renders fresh --
+	 * no exit animations for the old identity's rows.
+	 * The first-ever populate renders the same way -- rows appear live, no enter animation, no lockout.
+	 */
 	@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.
+
+	/**
+	 * pointerSafety (attribute `pointer-safety`) is which strategy answers the click-safety goal:
+	 * clicks must not land on surprising targets.
+	 *
+	 * - "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 {@link 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.
+	 *
+	 * 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.)
+
+	/**
+	 * holdMs is the committed hold: how long a removed item's row stays, full size and clickable, before it collapses.
+	 * The timing knobs (holdMs, collapseMs, graceMs, enterMs) are defaulted per the vanishing-rows study.
+	 */
 	@property({ type: Number })
 	holdMs = 1200;
+
+	/** collapseMs is the collapse animation's duration (`--yk-live-list-collapse`). */
 	@property({ type: Number })
 	collapseMs = 300;
+
+	/**
+	 * 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 })
 	graceMs = 200;
+
+	/** enterMs is the entrance animation's duration (`--yk-live-list-enter`). */
 	@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.
+
+	/**
+	 * keyPinMs is how long the key-activity pin outlives the last keydown (mega mode; see {@link YkLiveList}).
+	 * 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.
+	/**
+	 * rows is the 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
+
+	/** motionCount is the geometry animations in flight. */
+	private motionCount = 0;
 	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.
+
+	/** expirySeq orders deferred holds for the staggered release. */
+	private expirySeq = 0;
+
+	/**
+	 * guards is the click guard's motion records (click-guard mode; see {@link 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.
+
+	/**
+	 * epoch is 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.
+
+	/** mega is mega mode plumbing: fixed at createRenderRoot (the attribute is static markup). */
 	private mega = false;
 	private megaContainer: HTMLElement | null = null;
+
+	/** keyPinned is the key-activity pin, its timer the decay. */
 	private keyPinned = false;
 	private keyPinTimer: number | undefined;
 
@@ -304,7 +382,7 @@ export class YkLiveList<T = unknown> extends LitElement {
 		// 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
+		// The click guard intercepts in the capture phase on the host: it must beat
 		// both the row content's own listeners (a yk-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
@@ -314,10 +392,12 @@ export class YkLiveList<T = unknown> extends LitElement {
 		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.
+	/**
+	 * createRenderRoot decides mega mode.
+	 * 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;
@@ -385,13 +465,13 @@ export class YkLiveList<T = unknown> extends LitElement {
 		}
 		// 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
+		// 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 }));
+		if (this.mega) this.dispatchEvent(new CustomEvent(MEGACOLUMN_RESCAN_EVENT, { bubbles: true }));
 	}
 
-	// Whether freeze mode governs the pointer story right now (see the header).
+	/** frozen is whether freeze mode governs the pointer story right now (see {@link pointerSafety}). */
 	private get frozen(): boolean {
 		return this.pointerSafety === "freeze";
 	}
@@ -519,7 +599,7 @@ export class YkLiveList<T = unknown> extends LitElement {
 			(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
+		// 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);
@@ -536,18 +616,21 @@ export class YkLiveList<T = unknown> extends LitElement {
 	}
 
 	// --- 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.)
 
+	/**
+	 * locked is whether a motion is in flight or its grace linger is still running.
+	 * (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.
+	/**
+	 * beginMotion counts a geometry animation in, and 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);
@@ -585,8 +668,11 @@ export class YkLiveList<T = unknown> extends LitElement {
 
 	// --- 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).
+	/**
+	 * onPointerEnter and onPointerLeave track pointer presence, registered on the host in the constructor.
+	 * Pointer presence is tracked in both modes (cheap, and pointerSafety may flip mid-hover);
+	 * only freeze mode does anything with it (see {@link hoverPinned}).
+	 */
 	private onPointerEnter = (): void => {
 		this.pinned = true;
 	};
@@ -596,18 +682,22 @@ export class YkLiveList<T = unknown> extends LitElement {
 		this.releaseDeferred();
 	};
 
-	// The hover pin: freeze mode's global geometry deferral.  In click-guard mode
-	// a hovering pointer defers nothing.
+	/**
+	 * hoverPinned is 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.
+	/**
+	 * onGuardedPointer is the click guard (click-guard mode),
+	 * registered as the host's capture-phase click and mousedown listener in the constructor:
+	 * 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];
@@ -620,12 +710,14 @@ export class YkLiveList<T = unknown> extends LitElement {
 		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.
+	/**
+	 * isMovingTarget is whether a click landing on this row right now would land somewhere surprising:
+	 * some motion window (live, or ended within its {@link 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);
@@ -645,13 +737,16 @@ export class YkLiveList<T = unknown> extends LitElement {
 	}
 
 	// --- 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).
 
+	/**
+	 * onKeyActivity arms the key-activity pin,
+	 * registered as the megacolumn container's keydown listener in connectedCallback.
+	 * While keys are active (decaying {@link 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 {@link YkLiveList}: a keystroke that prepends a row must show it immediately).
+	 */
 	private onKeyActivity = (): void => {
 		this.keyPinned = true;
 		window.clearTimeout(this.keyPinTimer);
@@ -662,19 +757,21 @@ export class YkLiveList<T = unknown> extends LitElement {
 			this.keyPinned = false;
 			this.releaseDeferred();
 		}, this.keyPinMs);
-		// Focus may have moved OFF a deferred ghost since it was deferred; sweep so a
+		// 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.
+	/**
+	 * releaseDeferred runs when 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, {@link 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>[] = [];
@@ -707,19 +804,23 @@ export class YkLiveList<T = unknown> extends LitElement {
 		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.
+	/**
+	 * collapseDeferred is 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 {@link YkLiveList}).
+	 * 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.
+	/**
+	 * isRowFocused is 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(`.yk-ll-rowslot[data-key="${CSS.escape(row.key)}"]`);
 		return slot?.classList.contains("mc-row-focused") ?? false;
@@ -789,7 +890,7 @@ export class YkLiveList<T = unknown> extends LitElement {
 		// 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).
+		// Mega mode stamps row identity for the megacolumn scan (the MegacolumnController class doc has the attribute contract).
 		// 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.
@@ -804,9 +905,12 @@ export class YkLiveList<T = unknown> extends LitElement {
 		</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).
+	/**
+	 * onRowClick dispatches the `item-click` event ({@link ItemClickDetail}).
+	 * 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(
diff --git a/packages/yukey/tsconfig.build.json b/packages/yukey/tsconfig.build.json
index 98ded3f..c06f451 100644
--- a/packages/yukey/tsconfig.build.json
+++ b/packages/yukey/tsconfig.build.json
@@ -4,10 +4,8 @@
 		"noEmit": false,
 		"rootDir": "src",
 		"outDir": "dist",
-		"declaration": true,
-		"declarationMap": true,
-		"sourceMap": true
+		"declaration": true
 	},
 	"include": ["src"],
-	"exclude": ["src/**/*.test.ts"]
+	"exclude": ["src/**/*.test.ts", "src/**/*.gallery.ts", "src/gallery"]
 }