yukey

megacolumn-controller: contracts move onto the declarations that carry them

commit 67070847d256403d46b53fe18d52278d25297011 · parent 62e529c9f2 · browse this tree
Ximianth, 2026-10-18 10:30:59 UTC

The DOM contract (the data attributes the scan reads, as bullets) and the
megacolumn-rescan and megacolumn-focus-request contracts now live on the
MegacolumnController class doc, so they reach dist/megacolumn-controller.d.ts
and hover.  The megacolumn-key contract, including the definition of a
claim (preventDefault on the forwarded event) and pointer activation, lives
on MegacolumnKeyDetail; the focus-change contract on
MegacolumnFocusChangeDetail, whose "see the header's event contract" pointer
is thereby resolved.  Every other documented member, public or private, gets
its comment as a /** */ block starting with its name; trailing field
comments become blocks too.  The constants, Host, closedToSight, and
megacolumnStyles (class table now bullets) likewise.

The header keeps the module's purpose, the churn lockout, and the
column-scoped hotkey resolution: cross-cutting intent with no single
declaration.  "See the header" references become {@link}s to the
declaration that now holds the content.  Wording is unchanged apart from
lowercasing emphasis, backticks on attribute and class names, and the
name-first prefixes; lines break at clauses.
diff --git a/packages/yukey/src/megacolumn-controller.ts b/packages/yukey/src/megacolumn-controller.ts
index 2d1a2d5..4315c93 100644
--- a/packages/yukey/src/megacolumn-controller.ts
+++ b/packages/yukey/src/megacolumn-controller.ts
@@ -1,222 +1,236 @@
-// 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, megacolumn-rescan, megacolumn-focus-request),
+// MegacolumnKeyDetail (megacolumn-key: forwarded keys, claims, pointer activation),
+// and MegacolumnFocusChangeDetail (megacolumn-focus-change).
 //
-//   [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";
 
+/**
+ * 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.
+/**
+ * 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.
+/**
+ * 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.
+/**
+ * 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.
+ */
 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: 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 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.
+ *
+ * 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.
+ *
+ * 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) {
@@ -265,7 +279,7 @@ export class MegacolumnController implements ReactiveController {
 		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]")) {
@@ -306,10 +320,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 +336,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 +346,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 +399,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");
@@ -535,10 +557,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 +570,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 MegacolumnController}):
+	 * 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 +590,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 +607,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;
@@ -650,8 +681,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 +720,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 */