shortcuts.ts is data with no component around it, so it carries catalog keys and ShortcutsModal resolves them -- same shape as the settings sections. gen-platform-skill.ts also reads this registry to generate the agent-facing SKILL.md, which docs:check validates. That doc stays in one language, so the generator resolves against en-GB directly and throws if a key is missing rather than emitting a dot-path into a file an agent will quote at somebody. The regenerated skill is byte-identical, which is the check that keying the registry changed nothing an agent reads.
88 lines
3.8 KiB
TypeScript
88 lines
3.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Keyboard } from 'lucide-react';
|
|
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
/*
|
|
`Ctrl+?` — every shortcut the shell owns, on one screen.
|
|
|
|
Ported in shape from `vimazing`, where the controls are declared as data and the help screen renders them.
|
|
Its renderer was a recursive `any` walk over arbitrary content, which is right for a game whose help also
|
|
carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the
|
|
interesting part is `state/shortcuts.ts`.
|
|
|
|
Card per section, two columns on anything wider than a phone — the same arrangement vimazing used for its
|
|
controls grid, and for the same reason: these are short lists that read badly as one long column.
|
|
*/
|
|
|
|
const Row = ({ keys, description }: { keys: string; description: string }) => (
|
|
<div className="flex items-baseline gap-3 py-1">
|
|
{/* Fixed width, tabular: the chords line up into a column you can scan, which is the whole point of
|
|
putting them on the left. */}
|
|
<kbd className="w-32 shrink-0 rounded border border-border bg-muted/60 px-1.5 py-0.5 text-center font-mono text-[11px] text-foreground/90">
|
|
{keys}
|
|
</kbd>
|
|
<span className="text-sm text-muted-foreground">{description}</span>
|
|
</div>
|
|
);
|
|
|
|
export const ShortcutsModal = () => {
|
|
const [open, setOpen] = useState(false);
|
|
const { t } = useTranslation();
|
|
|
|
useEffect(() => {
|
|
const onKey = (ev: KeyboardEvent) => {
|
|
if (isShortcutsChord(ev)) {
|
|
// No focus check: `Ctrl+?` types nothing in a text field, and a help list you cannot open from the
|
|
// composer is a help list you cannot open — the lesson from the Spotlight guard.
|
|
ev.preventDefault();
|
|
setOpen((was) => !was);
|
|
return;
|
|
}
|
|
if (ev.key === 'Escape' && open) setOpen(false);
|
|
};
|
|
// Capture, so a panel with its own key handling cannot swallow it first.
|
|
window.addEventListener('keydown', onKey, true);
|
|
return () => window.removeEventListener('keydown', onKey, true);
|
|
}, [open]);
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[901] flex items-start justify-center bg-black/50 pt-[8vh] backdrop-blur-sm"
|
|
onMouseDown={() => setOpen(false)}
|
|
>
|
|
<div
|
|
className="flex max-h-[80vh] w-[min(94vw,860px)] flex-col overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
|
|
onMouseDown={(ev) => ev.stopPropagation()}
|
|
>
|
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
|
|
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
|
<h2 className="text-sm font-semibold">{t('shortcutsModal.title')}</h2>
|
|
<span className="ms-auto text-xs text-muted-foreground">{t('shortcutsModal.hint')}</span>
|
|
</div>
|
|
|
|
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
|
|
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
|
|
{SHORTCUT_SECTIONS.map((section) => (
|
|
<section key={section.titleKey} className="rounded-xl border border-border bg-muted/20 p-4">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">
|
|
{t(section.titleKey)}
|
|
</h3>
|
|
{section.noteKey && (
|
|
<p className="mt-1 text-[11px] leading-snug text-muted-foreground">{t(section.noteKey)}</p>
|
|
)}
|
|
<div className="mt-2">
|
|
{section.shortcuts.map((shortcut) => (
|
|
<Row key={shortcut.keys} keys={shortcut.keys} description={t(shortcut.descriptionKey)} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|