/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help * overlay. It renders the command registry (`commands.ts`) — the same list * that dispatches the keys — so a binding can never be documented as one * thing and do another. */ import { CommandSet, defineCommands, keyCaps, publishedCommands, type Command, } from "./commands.js"; let open: { el: HTMLElement; dispose: () => void } | null = null; export function isHelpOpen(): boolean { return open !== null; } export function closeHelp(): void { open?.dispose(); } export function toggleHelp(): void { if (open) closeHelp(); else openHelp(); } /** Mount the overlay. Opening while already open is a no-op re-render, so a * stray second call can never stack two dialogs. */ export function openHelp(): HTMLElement { closeHelp(); const el = document.createElement("div"); el.className = "help-overlay"; el.setAttribute("role", "dialog"); el.setAttribute("aria-modal", "true"); el.setAttribute("aria-label", "Keyboard shortcuts"); const panel = document.createElement("div"); panel.className = "help-panel"; const head = document.createElement("header"); head.className = "help-head"; const title = document.createElement("h2"); title.className = "help-title"; title.textContent = "Keyboard shortcuts"; const close = document.createElement("button"); close.type = "button"; close.className = "help-close"; close.setAttribute("aria-label", "Close"); close.textContent = "✕"; close.addEventListener("click", closeHelp); head.append(title, close); panel.appendChild(head); for (const group of groupedCommands()) { const section = document.createElement("section"); section.className = "help-group"; const heading = document.createElement("h3"); heading.className = "help-group-title"; heading.textContent = group.title; const list = document.createElement("dl"); list.className = "help-list"; for (const cmd of group.items) { const row = document.createElement("div"); row.className = "help-row"; row.dataset.commandId = cmd.id; const keys = document.createElement("dt"); keys.className = "help-keys"; for (const key of keyCaps(cmd.keys[0])) { const cap = document.createElement("kbd"); cap.textContent = key; keys.appendChild(cap); } const desc = document.createElement("dd"); desc.className = "help-desc"; desc.textContent = cmd.label; row.append(keys, desc); list.appendChild(row); } section.append(heading, list); panel.appendChild(section); } // A press on the scrim closes; one inside the panel is left alone. el.addEventListener("click", (ev) => { if (!panel.contains(ev.target as Node)) closeHelp(); }); el.appendChild(panel); document.body.appendChild(el); const dispose = () => { if (open?.el !== el) return; open = null; el.remove(); }; open = { el, dispose }; close.focus(); return el; } /** The registry's commands, in registry order, bucketed into the help * overlay's sections. Groups appear in the order their first command does. */ export function groupedCommands(): { title: string; items: Command[] }[] { const groups: { title: string; items: Command[] }[] = []; for (const cmd of publishedCommands()) { let group = groups.find((g) => g.title === cmd.group); if (!group) { group = { title: cmd.group, items: [] }; groups.push(group); } group.items.push(cmd); } return groups; } // The overlay's own two commands. `help.close` is `overlay`-scoped: it is only // fed keys while the dialog is up. const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => toggleHelp() })); const overlayKeys = new CommandSet(defineCommands({ "help.close": () => closeHelp() })); let installed = false; /** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a * second call during a hot reload doesn't double-toggle. */ export function installHelpKeybind(): void { if (installed) return; installed = true; window.addEventListener("keydown", (e) => { if (open && overlayKeys.handle(e)) return; helpKeys.handle(e); }); }