diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
index b3491b8d..60f27099 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
@@ -8,6 +8,7 @@ import { Background } from './Background';
import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
import { Spotlight } from './Spotlight/Spotlight';
+import { ShortcutsModal } from './ShortcutsModal';
import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle';
import { RouteGate } from './RouteGate';
@@ -51,6 +52,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
+ {/* Outside the content region for the same reason Spotlight is: ctrl+? has to work from a maximised
+ panel, an error boundary, or mid-navigation. */}
+
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx
index 0c1c7b86..1a1694e8 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx
@@ -23,7 +23,7 @@ const {
*
* ── The overlay ──
*
- * `ctrl+alt+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
+ * `ctrl+shift+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
* you browse, so as little of it as possible should be covered.
@@ -90,7 +90,7 @@ export const ThemeSwitcher = () => {
ctrl+shift+←/→ cycle · ctrl+shift+space browse
- alt+space to search every theme by name
+ ctrl+? for every shortcut
(
+
+ {/* Fixed width, tabular: the chords line up into a column you can scan, which is the whole point of
+ putting them on the left. */}
+
+ {keys}
+
+ {description}
+
+);
+
+export const ShortcutsModal = () => {
+ const [open, setOpen] = useState(false);
+
+ 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 (
+ setOpen(false)}
+ >
+
ev.stopPropagation()}
+ >
+
+
+
Keyboard shortcuts
+ Ctrl+? · Esc to close
+
+
+ {/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
+
+ {SHORTCUT_SECTIONS.map((section) => (
+
+ {section.title}
+ {section.note && {section.note}
}
+
+ {section.shortcuts.map((shortcut) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx
index db417c74..6d5cec5b 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx
@@ -11,12 +11,18 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
//
// ── Keyboard first, and the trigger ──
//
-// `alt-space`, and ONLY that. It went ctrl-space → shift-space → alt-space, and each move was the owner
-// telling us what the chord actually collides with on the machine he uses:
+// `ctrl-space`, and ONLY that. It has now been ctrl-space → shift-space → alt-space → ctrl-space, and every
+// move was the owner naming a real collision on the machine he uses:
//
-// ctrl-space focuses the address bar in Brave
-// shift-space types a space — see below, this was the real problem
-// alt-space taken by the window menu, and the owner rebound it rather than give up the chord
+// shift-space types a space — see below, this was the worst of them
+// alt-space the GNOME window menu. He rebound the OS to keep it, disliked what he rebound it TO, and
+// asked for the OS binding back — so the app moves rather than the desktop.
+// ctrl-space chosen deliberately on the way back. It was ruled out on 2026-08-20 as "focuses the
+// address bar in Brave"; that observation was made before anything preventDefault'ed it, and
+// the owner would rather rebind a browser than his window manager.
+//
+// If the address bar still steals it, the key never reaches this handler and nothing here can help — the fix
+// is in Brave, not in this file. Worth knowing WHICH of the two it is before changing anything.
//
// **Shift-space was never a good choice**, and that is why it did not last a day. It is a real key
// combination inside a text field: it types a space, and people hold shift through one more often than
@@ -24,7 +30,7 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
// — which is where focus usually is, the chat composer — so opening Spotlight meant clicking out first.
// A launcher you have to prepare for is not a launcher.
//
-// Alt-space has no meaning inside a text field, so there is NO focus check at all. The guard shift-space
+// Ctrl-space has no meaning inside a text field, so there is NO focus check at all. The guard shift-space
// needed was kept for one commit as "belt-and-braces" and was simply the old bug wearing a new hat: focus
// is usually in the chat composer, so a launcher that stands down there is a launcher that does not open
// when you want it. `preventDefault` on the handler covers the only real risk the guard addressed.
@@ -200,15 +206,18 @@ export const Spotlight = () => {
// anywhere — including inside a panel that has its own key handling.
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
- // `altKey` and `code`, not `key`: alt-space produces a different `key` on several layouts (and
+ // `ctrlKey` and `code`, not `key`: a modifier+space produces a different `key` on several layouts (and
// nothing at all on some), while the physical key is `Space` everywhere.
//
+ // `!ev.shiftKey`, because ctrl+SHIFT+space is the theme browser. Without it one chord would open both,
+ // which is the bug that took a capture-phase listener to fix on the theme side.
+ //
// No focus check. There was one, inherited from shift-space where it was mandatory, and keeping it
// "as belt-and-braces" was wrong in the plainest way: focus is usually IN the chat composer, so the
// guard meant the launcher did not open from the place it is most often wanted. `preventDefault`
// below covers the only thing the guard was protecting against — a layout that would otherwise type
// a character.
- if (ev.altKey && ev.code === 'Space') {
+ if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && ev.code === 'Space') {
ev.preventDefault();
// Take focus off whatever had it, explicitly, before the overlay mounts. The effect below focuses
// this panel's own input, and on paper that is enough — but "on paper" is what produced arrow keys
diff --git a/src/apps/officer-web/state/shortcuts.ts b/src/apps/officer-web/state/shortcuts.ts
new file mode 100644
index 00000000..df522366
--- /dev/null
+++ b/src/apps/officer-web/state/shortcuts.ts
@@ -0,0 +1,127 @@
+/*
+ Every keyboard shortcut the shell owns, as data.
+
+ ── Why a registry ──
+
+ Ported in shape from the owner's `vimazing` (`vim-maze/src/gameInfo.ts`), where the controls are declared
+ once and the help screen renders them. The alternative is what this replaced: chords spelled out inside
+ three separate key handlers, and a help text nobody updates because it lives somewhere else.
+
+ Two things follow from having it. `Ctrl+?` can render an accurate list without anybody maintaining prose.
+ And when shortcuts become configurable — the next big piece — this is the table a binding UI edits, rather
+ than a grep across handlers.
+
+ ── What it is NOT, yet ──
+
+ Declarative but not authoritative: the handlers still contain their own conditions, so a chord changed here
+ does not move. Making this the single source is part of the configurable-shortcuts work, and doing it now
+ would mean a rewrite of three handlers for no visible gain. Where a chord appears in both places the file
+ that owns it is named in `implementedIn`, so the pair can be found in one search.
+*/
+
+export type Shortcut = {
+ /** As a person reads it — `Ctrl+Shift+Space`, not a KeyboardEvent spelling. */
+ keys: string;
+ description: string;
+ /** The file whose handler actually implements it, until this registry is authoritative. */
+ implementedIn: string;
+};
+
+export type ShortcutSection = {
+ title: string;
+ /** Shown under the title when the section needs a caveat that a per-row description cannot carry. */
+ note?: string;
+ shortcuts: Shortcut[];
+};
+
+export const SHORTCUT_SECTIONS: ShortcutSection[] = [
+ {
+ title: 'Navigation',
+ shortcuts: [
+ {
+ keys: 'Ctrl+Space',
+ description: 'Spotlight — go to any screen, plugin or setting by name',
+ implementedIn: 'Layout/Spotlight/Spotlight.tsx',
+ },
+ { keys: 'Ctrl+?', description: 'This list', implementedIn: 'Layout/ShortcutsModal.tsx' },
+ ],
+ },
+ {
+ title: 'Themes',
+ note: 'The browser applies as you move, so you watch the app change rather than reading names.',
+ shortcuts: [
+ {
+ keys: 'Ctrl+Shift+Space',
+ description: 'Open the theme browser',
+ implementedIn: 'theme/useThemeKeys.ts',
+ },
+ {
+ keys: 'Ctrl+Shift+← / →',
+ description: 'Previous / next theme, without opening anything',
+ implementedIn: 'theme/useThemeKeys.ts',
+ },
+ {
+ keys: 'Ctrl+Shift+F',
+ description: 'Star or unstar the current theme, from anywhere',
+ implementedIn: 'theme/useThemeKeys.ts',
+ },
+ ],
+ },
+ {
+ title: 'In the theme browser',
+ shortcuts: [
+ { keys: 'j / k ↑ / ↓', description: 'Move through the list', implementedIn: 'theme/useThemeKeys.ts' },
+ {
+ keys: 'l →',
+ description: 'Apply the focused theme and keep browsing',
+ implementedIn: 'theme/useThemeKeys.ts',
+ },
+ { keys: 'h ←', description: 'Put back the theme you started on', implementedIn: 'theme/useThemeKeys.ts' },
+ { keys: 'f', description: 'Star or unstar the focused theme', implementedIn: 'theme/useThemeKeys.ts' },
+ { keys: 'Enter', description: 'Keep what you are looking at', implementedIn: 'theme/useThemeKeys.ts' },
+ { keys: 'Esc', description: 'Cancel and restore', implementedIn: 'theme/useThemeKeys.ts' },
+ ],
+ },
+ {
+ title: 'In Spotlight',
+ shortcuts: [
+ { keys: '↑ / ↓', description: 'Move through the results', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
+ { keys: 'Enter', description: 'Go there', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
+ { keys: 'Esc', description: 'Close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
+ ],
+ },
+ {
+ title: 'Chat',
+ note: 'Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.',
+ shortcuts: [
+ {
+ keys: '↑ / ↓',
+ description: 'Previous / next prompt you sent, shell-style',
+ implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
+ },
+ {
+ keys: 'Enter',
+ description: 'Send · on an empty composer with something queued, send that now',
+ implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
+ },
+ { keys: 'Shift+Enter', description: 'Newline', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts' },
+ {
+ keys: 'Esc',
+ description: 'Stop the turn that is generating',
+ implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
+ },
+ ],
+ },
+];
+
+/**
+ * Is this the `Ctrl+?` chord?
+ *
+ * `?` is `Shift+/` on most layouts and its own key on some, so both spellings are accepted rather than
+ * guessing which keyboard is attached. `code === 'Slash'` is the physical key, which is stable where `key`
+ * is not.
+ */
+export function isShortcutsChord(ev: KeyboardEvent): boolean {
+ if (!ev.ctrlKey) return false;
+ return ev.key === '?' || (ev.shiftKey && ev.code === 'Slash');
+}
diff --git a/src/workspaces/theme/src/useThemeKeys.ts b/src/workspaces/theme/src/useThemeKeys.ts
index 9b930b05..eab58346 100644
--- a/src/workspaces/theme/src/useThemeKeys.ts
+++ b/src/workspaces/theme/src/useThemeKeys.ts
@@ -78,8 +78,8 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
const onKeyDown = (event: KeyboardEvent) => {
const { themeId: current, focused: focus, open: isOpen } = state.current;
- // Toggle the list. `ctrl+alt+space` still works — it is what this shipped with an hour ago and
- // fingers are quicker to learn than to unlearn.
+ // Toggle the list. `ctrl+alt+space` is still accepted alongside `ctrl+shift+space` — it is what this
+ // shipped with first, and fingers are quicker to learn than to unlearn.
if (event.ctrlKey && (event.shiftKey || event.altKey) && event.code === 'Space') {
event.preventDefault();
/*