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 { // textarea, search) so an attached session's keys are never hijacked. document.addEventListener("keydown", (e) => { if (e.ctrlKey || e.altKey || e.metaKey) return; - const target = e.target as HTMLElement | null; - if ( - target && - (target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.tagName === "SELECT" || - target.isContentEditable) - ) { - return; - } + if (isTypingTarget(e.target)) return; + // The help overlay owns the keyboard while it is up. + if (isHelpOpen()) return; if (e.key === "n") { e.preventDefault(); requestOpenSpawn(); diff --git a/web/src/styles.css b/web/src/styles.css index 67fabfb..ddb2e04 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -876,6 +876,113 @@ input.leaf-rename:focus { background: var(--danger-tint); } +/* ---------- help overlay (`?`) ---------- */ +.help-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: grid; + place-items: center; + padding: var(--sp-4); + background: oklch(0 0 0 / 0.5); + backdrop-filter: blur(4px); +} + +.help-panel { + width: min(440px, 100%); + max-height: min(80vh, 640px); + overflow-y: auto; + padding: var(--sp-4); + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--r-3); + box-shadow: var(--shadow-2); +} + +.help-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-2); + margin-bottom: var(--sp-3); +} + +.help-title { + margin: 0; + font-family: var(--font-mono); + font-size: var(--fs-15); + color: var(--ink); +} + +.help-close { + appearance: none; + background: none; + border: 0; + border-radius: var(--r-2); + color: var(--muted); + font: inherit; + line-height: 1; + padding: var(--sp-1) var(--sp-2); + cursor: pointer; +} + +.help-close:hover, +.help-close:focus-visible { + background: var(--surface-3); + color: var(--ink); + outline: none; +} + +.help-group + .help-group { + margin-top: var(--sp-4); +} + +.help-group-title { + margin: 0 0 var(--sp-2); + font-size: var(--fs-11); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); +} + +.help-list { + margin: 0; +} + +/* Keycaps in a fixed column so the descriptions line up down the list. */ +.help-row { + display: grid; + grid-template-columns: 14ch 1fr; + align-items: baseline; + gap: var(--sp-3); + padding: var(--sp-1) 0; +} + +.help-keys { + display: flex; + flex-wrap: nowrap; + gap: var(--sp-1); + margin: 0; +} + +.help-keys kbd { + font-family: var(--font-mono); + font-size: var(--fs-11); + background: var(--surface-2); + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: var(--r-1); + padding: 1px 6px; + color: var(--ink); +} + +.help-desc { + margin: 0; + font-size: var(--fs-13); + color: var(--muted); +} + /* Line 2 — dot + duration in the state's colour, then muted fragments. The state word itself is never spelled out here; the colour carries it. */ .card-line2 { @@ -2106,7 +2213,7 @@ input.leaf-rename:focus { .mv-canvas > .mv-split, .mv-canvas > .mv-pane { position: absolute; - inset: var(--sp-2); + inset: 0; } .mv-canvas > .content-welcome { position: absolute; @@ -2346,7 +2453,6 @@ input.leaf-rename:focus { .mv-mobile { position: absolute; - /* Flush to the edges — a single full-screen pane needs no canvas inset. */ inset: 0; display: flex; flex-direction: column;