From b33f1feb792a31341d526d36fcede319e0579aa2 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Sat, 9 May 2026 21:47:34 +0000 Subject: [PATCH 1/2] feat(canvas): add keyboard shortcuts help dialog + global ? trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "no keyboard shortcut help dialog" audit gap (MEDIUM). Changes: - Add KeyboardShortcutsDialog component: portal-based, accessible dialog listing all canvas + navigation + agent shortcuts grouped by category. WCAG 2.1 compliant (focus trap, Esc close, aria-modal, aria-labelledby, focus restoration on close). - Add global ? shortcut: opens the dialog when pressed outside any input field and no modal is already open. - Add "See all shortcuts →" link in the Toolbar quick-start popup linking to the dialog. Test plan: - [x] npx vitest run (182 tests pass) - [x] tsc --noEmit (no type errors) Co-Authored-By: Claude Opus 4.7 --- .../components/KeyboardShortcutsDialog.tsx | 227 ++++++++++++++++++ canvas/src/components/Toolbar.tsx | 38 +++ 2 files changed, 265 insertions(+) create mode 100644 canvas/src/components/KeyboardShortcutsDialog.tsx diff --git a/canvas/src/components/KeyboardShortcutsDialog.tsx b/canvas/src/components/KeyboardShortcutsDialog.tsx new file mode 100644 index 00000000..31b73da1 --- /dev/null +++ b/canvas/src/components/KeyboardShortcutsDialog.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface ShortcutGroup { + title: string; + shortcuts: Array<{ keys: string[]; description: string }>; +} + +const SHORTCUT_GROUPS: ShortcutGroup[] = [ + { + title: "Canvas", + shortcuts: [ + { + keys: ["Esc"], + description: "Close context menu, clear selection, or deselect", + }, + { + keys: ["Enter"], + description: "Descend into selected node's first child", + }, + { + keys: ["Shift", "Enter"], + description: "Ascend to selected node's parent", + }, + { + keys: ["Cmd", "]"], + description: "Bring selected node forward in z-order", + }, + { + keys: ["Cmd", "["], + description: "Send selected node backward in z-order", + }, + { + keys: ["Z"], + description: "Zoom to fit the selected team and its sub-workspaces", + }, + ], + }, + { + title: "Navigation", + shortcuts: [ + { + keys: ["⌘K"], + description: "Open workspace search", + }, + { + keys: ["Palette"], + description: "Open the template palette to deploy a new workspace", + }, + { + keys: ["Dbl-click"], + description: "Zoom canvas to fit a team node and all its sub-workspaces", + }, + { + keys: ["Right-click"], + description: "Open the workspace context menu", + }, + ], + }, + { + title: "Agent", + shortcuts: [ + { + keys: ["Chat"], + description: "Send a message or resume a running task", + }, + { + keys: ["Config"], + description: "Edit skills, model, secrets, and runtime settings", + }, + { + keys: ["Audit"], + description: "View the activity ledger for the selected workspace", + }, + ], + }, +]; + +interface Props { + open: boolean; + onClose: () => void; +} + +export function KeyboardShortcutsDialog({ open, onClose }: Props) { + const dialogRef = useRef(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + // Move focus into the dialog when it opens (WCAG 2.1 SC 2.4.3) + useEffect(() => { + if (!open || !mounted) return; + const raf = requestAnimationFrame(() => { + dialogRef.current?.querySelector("button")?.focus(); + }); + return () => cancelAnimationFrame(raf); + }, [open, mounted]); + + // Keyboard: Escape closes, Tab is trapped + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key === "Tab" && dialogRef.current) { + const focusable = Array.from( + dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => !el.hasAttribute("disabled")); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + if (!open || !mounted) return null; + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+

+ Keyboard Shortcuts +

+ +
+ + {/* Content */} +
+ {SHORTCUT_GROUPS.map((group) => ( +
+

+ {group.title} +

+
+ {group.shortcuts.map((shortcut, i) => ( +
+ + {shortcut.description} + + + {shortcut.keys.map((k, j) => ( + + {j > 0 && ( + + + + + )} + + {k} + + + ))} + +
+ ))} +
+
+ ))} +
+ + {/* Footer */} +
+

+ Press{" "} + + Esc + {" "} + to close +

+
+
+
, + document.body + ); +} diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index 777d4f92..1d8cc12f 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -9,6 +9,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog"; import { showToast } from "@/components/Toaster"; import { ThemeToggle } from "@/components/ThemeToggle"; import { statusDotClass } from "@/lib/design-tokens"; +import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog"; export function Toolbar() { const nodes = useCanvasStore((s) => s.nodes); @@ -33,6 +34,7 @@ export function Toolbar() { const [restartingAll, setRestartingAll] = useState(false); const [restartConfirmOpen, setRestartConfirmOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); + const [shortcutsOpen, setShortcutsOpen] = useState(false); const helpRef = useRef(null); // Suppress toast on the very first connect at page load; only fire on reconnects. @@ -127,6 +129,29 @@ export function Toolbar() { }; }, []); + // Global ? shortcut opens the shortcuts dialog (mirrors the help button). + // Skip when the user is typing in an input so ? in a text field doesn't + // steal focus. Also skip when a modal/dialog is already open. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== "?") return; + const tag = (e.target as HTMLElement).tagName; + const inInput = + tag === "INPUT" || + tag === "TEXTAREA" || + tag === "SELECT" || + (e.target as HTMLElement).isContentEditable; + if (inInput) return; + // Don't fire when a modal/dialog is already mounted (canvas modals, + // side panel, etc. use z-50 or above). + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; + e.preventDefault(); + setShortcutsOpen(true); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + return (
+ {/* Link to the full keyboard shortcuts dialog */} +
)} @@ -340,6 +373,11 @@ export function Toolbar() { onConfirm={restartAll} onCancel={() => setRestartConfirmOpen(false)} /> + + setShortcutsOpen(false)} + /> ); } From dff7d8fbab5fe2d7d1b6cd9419378d3bb90571d2 Mon Sep 17 00:00:00 2001 From: Molecule AI Core Platform Lead Date: Sat, 9 May 2026 21:55:14 +0000 Subject: [PATCH 2/2] trigger: re-run sop-tier-check after core-lead approval + main sync