Skip to content

Commit edccc69

Browse files
feat(ui): shortcut registry foundation
Salvages the foundation of #380 by @gjermundgaraba — the original PR sat 6 weeks waiting for review and was closed by the author. This lands the engine, scope data, and auto-generated marketing docs page, deferring per-component handler migration to follow-up PRs. Layout: - packages/ui/shortcuts/{core,runtime,index}.ts — engine + barrel - packages/ui/shortcuts/plan-review/* — scopes for plan editor surfaces - packages/ui/shortcuts/code-review/* — scopes for review editor surfaces - packages/{editor,review-editor}/shortcuts.ts — per-app surface composition - apps/marketing/src/{components,lib} — auto-generated docs page App behavior is unchanged: existing ad-hoc keydown handlers keep running. The registry is plumbing waiting for migration PRs to wire to it. Co-authored-by: Gjermund Garaba <gjermund@garaba.net>
1 parent 5aaac6b commit edccc69

22 files changed

Lines changed: 1347 additions & 25 deletions

AGENTS.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ plannotator/
5252
│ │ │ ├── icons/ # Shared SVG icon components (themeIcons, etc.)
5353
│ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views
5454
│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser
55+
│ │ ├── shortcuts/ # Keyboard shortcut registry (see Keyboard Shortcuts section below)
56+
│ │ │ ├── core.ts # Engine: parser, formatter, dispatcher, validator
57+
│ │ │ ├── runtime.ts # Engine: useShortcutScope, useDoubleTapShortcuts hooks
58+
│ │ │ ├── index.ts # Barrel — re-exports engine + scopes from both subfolders
59+
│ │ │ ├── plan-review/ # Scopes for plan-editor surfaces (annotationToolbar, commentPopover, imageAnnotator, inputMethod, viewer)
60+
│ │ │ └── code-review/ # Scopes for review-editor surfaces (annotationToolbar, fileTree)
61+
│ │ ├── shortcuts.test.ts # Registry unit tests (parser, dispatcher, validator)
5562
│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts
5663
│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts
5764
│ │ └── types.ts
@@ -60,9 +67,12 @@ plannotator/
6067
│ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only)
6168
│ │ ├── draft.ts # Annotation draft persistence (node:fs only)
6269
│ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName)
63-
│ ├── editor/ # Plan review App.tsx
70+
│ ├── editor/ # Plan review app
71+
│ │ ├── App.tsx # Main plan review app
72+
│ │ └── shortcuts.ts # planReviewSurface + annotateSurface — composes plan-review scopes into per-surface registries
6473
│ └── review-editor/ # Code review UI
6574
│ ├── App.tsx # Main review app
75+
│ ├── shortcuts.ts # codeReviewSurface — composes code-review scopes into the review registry
6676
│ ├── components/ # DiffViewer, FileTree, ReviewSidebar
6777
│ ├── dock/ # Dockview center panel infrastructure
6878
│ ├── demoData.ts # Demo diff for standalone mode
@@ -380,6 +390,20 @@ interface Block {
380390

381391
Text highlighting uses `web-highlighter` library. Code blocks use manual `<mark>` wrapping (web-highlighter can't select inside `<pre>`).
382392

393+
## Keyboard Shortcuts
394+
395+
**Location:** `packages/ui/shortcuts/` (engine + scope data), `packages/editor/shortcuts.ts` and `packages/review-editor/shortcuts.ts` (per-app surfaces).
396+
397+
The shortcut system has three layers:
398+
399+
1. **Engine** (`packages/ui/shortcuts/{core,runtime}.ts`) — parser for declarative bindings (`Mod+Enter`, `Alt Alt` double-tap, `Alt hold`), dispatcher, platform-aware formatter (mac glyphs vs. `Ctrl`), validator, and the `useShortcutScope` / `useDoubleTapShortcuts` React hooks. Truly shared — both apps use it as-is.
400+
2. **Scopes**`defineShortcutScope({ id, title, shortcuts: { actionId: { bindings, description, section, ... } } })`. One scope per UI surface (annotation toolbar, comment popover, file tree, etc.). Lives in `packages/ui/shortcuts/{plan-review,code-review}/`**the subfolder names which app's UI the scope serves**. Components/Apps wire handlers to a scope via `useShortcutScope({ scope, handlers: { actionId: () => ... } })`.
401+
3. **Surfaces** (`packages/editor/shortcuts.ts`, `packages/review-editor/shortcuts.ts`) — each app composes its scopes into a `ShortcutSurface` (`planReviewSurface`, `annotateSurface`, `codeReviewSurface`). Surfaces feed both the in-app help modal and the marketing site's auto-generated docs page.
402+
403+
**Convention for adding new shortcuts:** define the action in the relevant scope file under the right subfolder (`plan-review/` or `code-review/`), declare the binding(s) and description, then wire a handler at the call site with `useShortcutScope`. The marketing docs page picks it up automatically at next build. Unit tests in `packages/ui/shortcuts.test.ts` enforce normalized binding tokens (`Mod`, `Shift`, `Alt`, `A-Z`, `1-0`, named keys, `F1``F12`) and unique scope ids.
404+
405+
**Marketing docs auto-generation:** `apps/marketing/src/lib/shortcutReference.ts` reads the three surfaces and `apps/marketing/src/components/ShortcutReference.astro` renders them as tables. The `/docs/reference/keyboard-shortcuts` page is special-cased in `apps/marketing/src/pages/docs/[...slug].astro` to render the component instead of the markdown body.
406+
383407
## URL Sharing
384408

385409
**Location:** `packages/ui/utils/sharing.ts`, `packages/ui/hooks/useSharing.ts`
@@ -482,6 +506,8 @@ Running only `build:opencode` will copy stale HTML files.
482506

483507
`apps/marketing/` is the plannotator.ai website — landing page, documentation, and blog. Built with Astro 5 (static output, zero client JS except a theme toggle island). Docs are markdown files in `src/content/docs/`, blog posts in `src/content/blog/`, both using Astro content collections. Tailwind CSS v4 via `@tailwindcss/vite`. Deploys to S3/CloudFront via GitHub Actions on push to main.
484508

509+
The `/docs/reference/keyboard-shortcuts` page is auto-generated from the shortcut registry at build time — see the Keyboard Shortcuts section above. Editing the markdown body has no effect; update the scope files instead.
510+
485511
## Test plugin locally
486512

487513
```
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
import { shortcutReferenceSurfaces } from '../lib/shortcutReference';
3+
import { formatShortcutBindingsText } from '../../../../packages/ui/shortcuts';
4+
---
5+
6+
<p>Keyboard shortcuts available across the Plannotator review and annotation UIs.</p>
7+
8+
{shortcutReferenceSurfaces.map((surface) => (
9+
<Fragment>
10+
<h2 id={surface.slug}>{surface.title}</h2>
11+
<p>{surface.description}</p>
12+
13+
{surface.sections.map((section) => (
14+
<Fragment>
15+
<h3 id={section.slug}>{section.title}</h3>
16+
<table>
17+
<thead>
18+
<tr>
19+
<th>Shortcut</th>
20+
<th>Action</th>
21+
<th>Notes</th>
22+
</tr>
23+
</thead>
24+
<tbody>
25+
{section.shortcuts.map((shortcut) => (
26+
<tr>
27+
<td><code>{formatShortcutBindingsText(shortcut.bindings)}</code></td>
28+
<td>{shortcut.description}</td>
29+
<td>{shortcut.hint ?? ''}</td>
30+
</tr>
31+
))}
32+
</tbody>
33+
</table>
34+
</Fragment>
35+
))}
36+
</Fragment>
37+
))}

apps/marketing/src/content/docs/reference/keyboard-shortcuts.md

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,14 @@ sidebar:
66
section: "Reference"
77
---
88

9-
Keyboard shortcuts available in the Plannotator plan review, code review, and annotation UIs.
9+
<!--
10+
This page is auto-generated from the shortcut registry at build time.
11+
Editing the body below has no effect — the slug page renders
12+
`apps/marketing/src/components/ShortcutReference.astro` instead.
1013
11-
## Global shortcuts
14+
To change a shortcut, edit the relevant scope file under
15+
`packages/ui/shortcuts/plan-review/` or `packages/ui/shortcuts/code-review/`,
16+
or a per-app surface in `packages/{editor,review-editor}/shortcuts.ts`.
17+
-->
1218

13-
| Shortcut | Context | Action |
14-
|----------|---------|--------|
15-
| `Cmd/Ctrl+Enter` | Plan review (no annotations) | Approve plan |
16-
| `Cmd/Ctrl+Enter` | Plan review (with annotations) | Send feedback |
17-
| `Cmd/Ctrl+Enter` | Code review | Send feedback / Approve |
18-
| `Cmd/Ctrl+Enter` | Annotate mode | Send annotations |
19-
| `Cmd/Ctrl+S` | Any mode (with API) | Quick save to default notes app |
20-
| `Escape` | Annotation toolbar | Close toolbar |
21-
22-
## Notes
23-
24-
- `Cmd/Ctrl+Enter` is blocked when a modal or dialog is open (export, import, confirm dialogs, image annotator)
25-
- `Cmd/Ctrl+Enter` is blocked when typing in an input or textarea
26-
- `Cmd/Ctrl+S` opens the Export modal if no default notes app is configured
27-
- `Escape` in the annotation toolbar closes it without creating an annotation
19+
This page is generated from the shared shortcut registry at build time.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { planReviewSurface, annotateSurface } from '../../../../packages/editor/shortcuts';
2+
import { codeReviewSurface } from '../../../../packages/review-editor/shortcuts';
3+
import { listRegistryShortcutSections } from '../../../../packages/ui/shortcuts';
4+
import type { ShortcutSurface } from '../../../../packages/ui/shortcuts';
5+
6+
const slugify = (value: string) => value.toLowerCase().replace(/\s+/g, '-');
7+
8+
const allSurfaces: ShortcutSurface[] = [planReviewSurface, annotateSurface, codeReviewSurface];
9+
10+
export const shortcutReferenceSurfaces = allSurfaces.map((surface) => ({
11+
...surface,
12+
sections: listRegistryShortcutSections(surface.registry).map((section) => ({
13+
...section,
14+
slug: `${surface.slug}-${slugify(section.title)}`,
15+
})),
16+
}));
17+
18+
export const shortcutReferenceHeadings = shortcutReferenceSurfaces.flatMap((surface) => [
19+
{ depth: 2 as const, slug: surface.slug, text: surface.title },
20+
...surface.sections.map((section) => ({ depth: 3 as const, slug: section.slug, text: section.title })),
21+
]);

apps/marketing/src/pages/docs/[...slug].astro

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
---
22
import { getCollection, render } from 'astro:content';
33
import Docs from '../../layouts/Docs.astro';
4+
import ShortcutReference from '../../components/ShortcutReference.astro';
5+
import { shortcutReferenceHeadings } from '../../lib/shortcutReference';
46
57
export async function getStaticPaths() {
68
const docs = await getCollection('docs');
@@ -11,13 +13,18 @@ export async function getStaticPaths() {
1113
}
1214
1315
const { doc } = Astro.props;
14-
const { Content, headings } = await render(doc);
16+
const isShortcutReference = doc.id === 'reference/keyboard-shortcuts';
17+
const rendered = isShortcutReference ? null : await render(doc);
18+
const Content = rendered?.Content;
19+
const headings = isShortcutReference
20+
? shortcutReferenceHeadings
21+
: rendered?.headings ?? [];
1522
---
1623
<Docs
1724
title={doc.data.title}
1825
description={doc.data.description}
1926
headings={headings}
2027
currentId={doc.id}
2128
>
22-
<Content />
29+
{isShortcutReference ? <ShortcutReference /> : Content && <Content />}
2330
</Docs>

bun.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/editor/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"type": "module",
55
"exports": {
66
".": "./App.tsx",
7-
"./styles": "./index.css"
7+
"./styles": "./index.css",
8+
"./shortcuts": "./shortcuts.ts"
89
},
910
"dependencies": {
1011
"@plannotator/shared": "workspace:*",

packages/editor/shortcuts.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
annotationToolbarShortcuts,
3+
commentPopoverShortcuts,
4+
createShortcutRegistry,
5+
createShortcutScopeHook,
6+
defineShortcutScope,
7+
imageAnnotatorShortcuts,
8+
inputMethodShortcuts,
9+
viewerShortcuts,
10+
type ShortcutSurface,
11+
} from '@plannotator/ui/shortcuts';
12+
13+
export const planEditorShortcuts = defineShortcutScope({
14+
id: 'plan-editor',
15+
title: 'Plan Editor',
16+
shortcuts: {
17+
submitPlan: {
18+
description: 'Approve / Send feedback',
19+
bindings: ['Mod+Enter'],
20+
section: 'Actions',
21+
hint: 'Approves when there are no annotations and sends feedback when there are.',
22+
displayOrder: 10,
23+
},
24+
submitAnnotations: {
25+
description: 'Send annotations',
26+
bindings: ['Mod+Enter'],
27+
section: 'Actions',
28+
displayOrder: 10,
29+
},
30+
quickSave: {
31+
description: 'Save to notes app',
32+
bindings: ['Mod+S'],
33+
section: 'Actions',
34+
hint: 'Opens Export if no default notes app is configured.',
35+
displayOrder: 20,
36+
},
37+
exitPlanDiff: {
38+
description: 'Close diff view',
39+
bindings: ['Escape'],
40+
section: 'Actions',
41+
hint: 'Available while plan diff is open.',
42+
displayOrder: 30,
43+
},
44+
},
45+
});
46+
47+
export const usePlanEditorShortcuts = createShortcutScopeHook(planEditorShortcuts);
48+
49+
const planReviewEditorSettingsShortcuts = defineShortcutScope({
50+
id: 'plan-review-editor-settings',
51+
title: 'Plan Editor',
52+
shortcuts: {
53+
submitPlan: planEditorShortcuts.shortcuts.submitPlan,
54+
quickSave: planEditorShortcuts.shortcuts.quickSave,
55+
exitPlanDiff: planEditorShortcuts.shortcuts.exitPlanDiff,
56+
},
57+
});
58+
59+
const annotateEditorSettingsShortcuts = defineShortcutScope({
60+
id: 'annotate-editor-settings',
61+
title: 'Annotate Editor',
62+
shortcuts: {
63+
submitAnnotations: planEditorShortcuts.shortcuts.submitAnnotations,
64+
quickSave: planEditorShortcuts.shortcuts.quickSave,
65+
},
66+
});
67+
68+
const sharedPlanSurfaceShortcuts = [
69+
inputMethodShortcuts,
70+
annotationToolbarShortcuts,
71+
viewerShortcuts,
72+
commentPopoverShortcuts,
73+
imageAnnotatorShortcuts,
74+
] as const;
75+
76+
export const planReviewSettingsShortcutRegistry = createShortcutRegistry([
77+
planReviewEditorSettingsShortcuts,
78+
...sharedPlanSurfaceShortcuts,
79+
] as const);
80+
81+
export const annotateSettingsShortcutRegistry = createShortcutRegistry([
82+
annotateEditorSettingsShortcuts,
83+
...sharedPlanSurfaceShortcuts,
84+
] as const);
85+
86+
export const planReviewSurface: ShortcutSurface = {
87+
slug: 'plan-review',
88+
title: 'Plan review',
89+
description: 'Shortcuts surfaced by the plan review UI.',
90+
registry: planReviewSettingsShortcutRegistry,
91+
};
92+
93+
export const annotateSurface: ShortcutSurface = {
94+
slug: 'annotate-mode',
95+
title: 'Annotate mode',
96+
description: 'Shortcuts surfaced by the standalone annotation UI.',
97+
registry: annotateSettingsShortcutRegistry,
98+
};

packages/review-editor/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"type": "module",
55
"exports": {
66
".": "./App.tsx",
7-
"./styles": "./index.css"
7+
"./styles": "./index.css",
8+
"./shortcuts": "./shortcuts.ts"
89
},
910
"dependencies": {
1011
"@pierre/diffs": "^1.1.12",

0 commit comments

Comments
 (0)