diff --git a/web/src/catalog.ts b/web/src/catalog.ts index febb706..2f1a4f2 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -1,6 +1,7 @@ import { escapeHtml } from "./html.js"; import { getDaemonInfo, killSession, renameSession } from "./api.js"; import { openContextMenu } from "./contextMenu.js"; +import { toggleHelp } from "./help.js"; import { CatalogStore } from "./events.js"; import type { Session } from "./types.js"; import { @@ -305,6 +306,14 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): kit.title = "Open the design kit (DEV)"; actions.appendChild(kit); } + const helpBtn = document.createElement("button"); + helpBtn.type = "button"; + helpBtn.className = "btn btn-ghost btn-icon help-btn"; + helpBtn.textContent = "?"; + helpBtn.title = "Keyboard shortcuts (?)"; + helpBtn.setAttribute("aria-label", "Keyboard shortcuts"); + helpBtn.addEventListener("click", () => toggleHelp()); + actions.appendChild(helpBtn); actions.appendChild(createThemeToggle()); const spawnBtn = document.createElement("button"); spawnBtn.type = "button"; diff --git a/web/src/help.test.ts b/web/src/help.test.ts new file mode 100644 index 0000000..660e06d --- /dev/null +++ b/web/src/help.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + SHORTCUTS, + closeHelp, + installHelpKeybind, + isHelpOpen, + isTypingTarget, + openHelp, +} from "./help.js"; + +beforeEach(() => { + installHelpKeybind(); +}); + +afterEach(() => { + closeHelp(); + document.body.replaceChildren(); +}); + +function press(key: string, init: KeyboardEventInit = {}): void { + window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init })); +} + +function rows(): string[] { + return [...document.querySelectorAll(".help-row")].map((r) => + [...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "), + ); +} + +describe("help overlay", () => { + it("renders every registered shortcut, grouped", () => { + openHelp(); + const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent); + expect(groups).toEqual(SHORTCUTS.map((g) => g.title)); + expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" ")))); + expect(document.querySelectorAll(".help-desc")).toHaveLength( + SHORTCUTS.reduce((n, g) => n + g.items.length, 0), + ); + }); + + it("opens on ? and closes on a second ?", () => { + press("?", { shiftKey: true }); + expect(isHelpOpen()).toBe(true); + press("?", { shiftKey: true }); + expect(isHelpOpen()).toBe(false); + }); + + it("closes on Escape, on the close button, and on a click outside the panel", () => { + openHelp(); + press("Escape"); + expect(isHelpOpen()).toBe(false); + + openHelp(); + (document.querySelector(".help-close") as HTMLButtonElement).click(); + expect(isHelpOpen()).toBe(false); + + openHelp(); + (document.querySelector(".help-overlay") as HTMLElement).dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + expect(isHelpOpen()).toBe(false); + }); + + it("stays open for a click inside the panel", () => { + openHelp(); + (document.querySelector(".help-panel") as HTMLElement).dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + expect(isHelpOpen()).toBe(true); + }); + + it("opening twice leaves one overlay", () => { + openHelp(); + openHelp(); + expect(document.querySelectorAll(".help-overlay")).toHaveLength(1); + }); + + it("ignores ? while typing and when a non-shift modifier is held", () => { + const input = document.createElement("input"); + document.body.appendChild(input); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true, shiftKey: true })); + expect(isHelpOpen()).toBe(false); + + press("?", { ctrlKey: true }); + expect(isHelpOpen()).toBe(false); + }); + + it("installing the keybind twice still toggles once", () => { + installHelpKeybind(); + press("?", { shiftKey: true }); + expect(isHelpOpen()).toBe(true); + expect(document.querySelectorAll(".help-overlay")).toHaveLength(1); + }); +}); + +describe("isTypingTarget", () => { + it("is true for form fields and editable nodes, false otherwise", () => { + const el = (html: string) => { + const d = document.createElement("div"); + d.innerHTML = html; + return d.firstElementChild as HTMLElement; + }; + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el('
'))).toBe(true); + expect(isTypingTarget(el(""))).toBe(false); + expect(isTypingTarget(null)).toBe(false); + }); +}); diff --git a/web/src/help.ts b/web/src/help.ts new file mode 100644 index 0000000..2437aec --- /dev/null +++ b/web/src/help.ts @@ -0,0 +1,159 @@ +/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help + * overlay. One registry drives both the rendering and what we claim is bound, + * so a binding added to `main.ts` without an entry here reads as missing. */ + +export interface Shortcut { + /** Keycaps, rendered left to right as separate . */ + keys: string[]; + description: string; +} + +export interface ShortcutGroup { + title: string; + items: Shortcut[]; +} + +export const SHORTCUTS: ShortcutGroup[] = [ + { + title: "Sessions", + items: [ + { keys: ["n"], description: "Open a spawn draft pane" }, + { keys: ["N"], description: "Spawn a session with every default resolved" }, + ], + }, + { + title: "Help", + items: [ + { keys: ["?"], description: "Show or hide this help" }, + { keys: ["Esc"], description: "Close the help overlay or an open menu" }, + ], + }, + { + title: "Debug", + items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }], + }, +]; + +/** True when the event target swallows plain keystrokes — a form field, or the + * hidden textarea xterm types into. Shared with `main.ts` so every global + * single-key binding is deaf in the same places. */ +export function isTypingTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + if (!el || typeof el.tagName !== "string") return false; + return ( + el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + el.tagName === "SELECT" || + el.isContentEditable + ); +} + +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 SHORTCUTS) { + 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 item of group.items) { + const row = document.createElement("div"); + row.className = "help-row"; + const keys = document.createElement("dt"); + keys.className = "help-keys"; + for (const key of item.keys) { + const cap = document.createElement("kbd"); + cap.textContent = key; + keys.appendChild(cap); + } + const desc = document.createElement("dd"); + desc.className = "help-desc"; + desc.textContent = item.description; + 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; +} + +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 (e.key === "Escape" && open) { + e.preventDefault(); + closeHelp(); + return; + } + // Shift is how `?` is typed on most layouts; the others would be a + // different binding entirely. + if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return; + if (isTypingTarget(e.target)) return; + e.preventDefault(); + toggleHelp(); + }); +} diff --git a/web/src/main.ts b/web/src/main.ts index 11130bd..857c96f 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,6 +1,7 @@ import { renderSidebar, renderTopbar } from "./catalog.js"; import { ConnectionPool } from "./connections.js"; import { installHudKeybind } from "./debug.js"; +import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js"; import { CatalogStore } from "./events.js"; import { shouldRetryClose } from "./reconnect.js"; import { @@ -28,6 +29,7 @@ import "./styles.css"; initTheme(); installViewportSizing(); installHudKeybind(); +installHelpKeybind(); registerServiceWorker(); // Register the PWA service worker so the app is installable and the shell loads @@ -219,6 +221,7 @@ function renderWelcome(): HTMLElement { Pick a session from the list, or start a new one. Open sessions keep streaming in the background — switching back is instant. +Press ? for keyboard shortcuts.
`; const spawn = document.createElement("button"); spawn.type = "button"; @@ -327,16 +330,9 @@ async function requestFastForward(): Promise