diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
index 2b8a1cb6..b3491b8d 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
@@ -7,6 +7,7 @@ import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background';
import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
+import { Spotlight } from './Spotlight/Spotlight';
import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle';
import { RouteGate } from './RouteGate';
@@ -47,6 +48,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
return (
+ {/* 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. */}
+
{/* 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/Spotlight/Spotlight.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx
new file mode 100644
index 00000000..2c5b9dc4
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx
@@ -0,0 +1,189 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useNavigate } from 'react-router';
+import { Search } from 'lucide-react';
+import { rank } from './match';
+import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
+
+// Spotlight: type a few letters, go somewhere.
+//
+// The dock is full and cannot grow, which is what this is for — once navigation lives here, the dock is
+// free to be a short list of what you actually use rather than a complete one.
+//
+// ── Keyboard first, and the trigger ──
+//
+// `ctrl-space`. `alt-space` is the more macOS-shaped chord and is already load-bearing for the owner
+// elsewhere, so it is deliberately not taken. `cmd-k` is not used either: it belongs to the browser's own
+// surface on some platforms and to text fields on others.
+//
+// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
+
+const MAX_RESULTS = 8;
+
+/** Label with the matched characters picked out, so it is obvious WHY a result is here. */
+const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => {
+ if (!hits.length) return <>{label}>;
+ const set = new Set(hits);
+ return (
+ <>
+ {label.split('').map((char, i) =>
+ set.has(i) ? (
+
+ {char}
+
+ ) : (
+ {char}
+ ),
+ )}
+ >
+ );
+};
+
+const Row = ({
+ item,
+ hits,
+ active,
+ onPick,
+}: {
+ item: SpotlightItem;
+ hits: number[];
+ active: boolean;
+ onPick: () => void;
+}) => {
+ const Icon = item.icon;
+ return (
+
+ );
+};
+
+export const Spotlight = () => {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState('');
+ const [active, setActive] = useState(0);
+ const inputRef = useRef(null);
+ const navigate = useNavigate();
+ const items = useSpotlightItems();
+
+ const results = useMemo(() => rank(items, query).slice(0, MAX_RESULTS), [items, query]);
+
+ const close = useCallback(() => {
+ setOpen(false);
+ setQuery('');
+ setActive(0);
+ }, []);
+
+ const pick = useCallback(
+ (item: SpotlightItem | undefined) => {
+ if (!item) return;
+ close();
+ navigate(item.to);
+ },
+ [close, navigate],
+ );
+
+ // The trigger. Bound to the window rather than to a container, because it has to work while focus is
+ // anywhere — including inside a panel that has its own key handling.
+ useEffect(() => {
+ const onKey = (ev: KeyboardEvent) => {
+ if (ev.ctrlKey && ev.code === 'Space') {
+ ev.preventDefault();
+ setOpen((wasOpen) => !wasOpen);
+ return;
+ }
+ if (ev.key === 'Escape' && open) close();
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [open, close]);
+
+ useEffect(() => {
+ if (open) inputRef.current?.focus();
+ }, [open]);
+
+ // Reset the cursor whenever the result set changes, so it never points past the end.
+ useEffect(() => setActive(0), [query]);
+
+ if (!open) return null;
+
+ return (
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/match.test.ts b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/match.test.ts
new file mode 100644
index 00000000..8698c0f7
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/match.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, test } from 'bun:test';
+import { rank } from './match';
+import type { SpotlightItem } from './useSpotlightItems';
+
+// The behaviours a launcher is judged on, which are all about ORDER rather than about matching. Anyone
+// can return the right set; returning it with the right thing first is the whole product.
+
+const item = (label: string, to: string, hint?: string): SpotlightItem => ({ id: to, label, to, hint });
+
+const ITEMS: SpotlightItem[] = [
+ item('Home', '/'),
+ item('Files', '/files'),
+ item('Chat', '/chat'),
+ item('Command HQ', '/command-hq'),
+ item('Editor', '/code-editor'),
+ item('Profile', '/settings/profile', 'avatar, name, email'),
+ item('API keys', '/settings/profile/api-keys', 'per-app keys'),
+ item('App store', '/app-store'),
+ item('Dashboards', '/dashboards'),
+];
+
+const top = (query: string, n = 1) =>
+ rank(ITEMS, query)
+ .slice(0, n)
+ .map((s) => s.item.label);
+
+describe('spotlight ranking', () => {
+ test('an exact label wins', () => {
+ expect(top('chat')).toEqual(['Chat']);
+ });
+
+ test('a prefix beats a scattered match — "fi" is Files, not Profile', () => {
+ expect(top('fi')).toEqual(['Files']);
+ });
+
+ test('initials find a two-word label — "chq" is Command HQ', () => {
+ expect(top('chq')).toEqual(['Command HQ']);
+ });
+
+ test('a subsequence works, so you can type the shape of a word', () => {
+ expect(top('apik')).toEqual(['API keys']);
+ });
+
+ test('a hint matches, but never outranks a label', () => {
+ // "keys" is API keys' own label AND its hint. It must not be beaten by anything mentioning keys.
+ expect(top('keys')).toEqual(['API keys']);
+ });
+
+ test('a path fragment finds the item without outranking labels', () => {
+ expect(rank(ITEMS, 'settings').map((s) => s.item.label)).toContain('Profile');
+ // "app" is a prefix of "App store" and appears in API keys' hint ("per-app keys"). Label wins.
+ expect(top('app')).toEqual(['App store']);
+ });
+
+ test('nothing matches means nothing returned, not everything', () => {
+ expect(rank(ITEMS, 'zzzzz')).toHaveLength(0);
+ });
+
+ test('an empty query keeps the given order rather than sorting it', () => {
+ expect(rank(ITEMS, '').map((s) => s.item.label)).toEqual(ITEMS.map((i) => i.label));
+ });
+
+ test('shorter labels break ties — "Chat" over "Chat history"', () => {
+ const withLonger = [...ITEMS, item('Chat history', '/chat-history')];
+ expect(rank(withLonger, 'chat')[0]!.item.label).toBe('Chat');
+ });
+});
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/match.ts b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/match.ts
new file mode 100644
index 00000000..dcf97b68
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/match.ts
@@ -0,0 +1,93 @@
+import type { SpotlightItem } from './useSpotlightItems';
+
+// Ranking, kept apart from React so it can be tested with strings.
+//
+// Subsequence matching, not substring: "cmdhq" should find "Command HQ" and "apik" should find
+// "API keys". Anyone who has used a launcher expects to type the shape of a word rather than a prefix
+// of it, and a substring match makes them type more to get less.
+
+export type Scored = { item: SpotlightItem; score: number; hits: number[] };
+
+/**
+ * Where each character of `query` lands in `text`, or null if it does not fit.
+ *
+ * Greedy left-to-right, which is what makes it cheap and predictable. It can miss a better alignment —
+ * "ao" against "app store" takes the first `o` in the first word rather than the one in "store" — and
+ * that costs a slightly worse score, never a wrong answer, because the item still matches.
+ */
+function subsequence(text: string, query: string): number[] | null {
+ const hits: number[] = [];
+ let at = 0;
+ for (const char of query) {
+ const found = text.indexOf(char, at);
+ if (found === -1) return null;
+ hits.push(found);
+ at = found + 1;
+ }
+ return hits;
+}
+
+/** Start of the string, or the character after a space, dash or slash. */
+const isBoundary = (text: string, i: number): boolean => i === 0 || /[\s\-/]/.test(text[i - 1]!);
+
+/**
+ * Higher is better. The weights encode what a person means by "the right result":
+ *
+ * - **an exact label** is what they typed, so nothing beats it
+ * - **a prefix** beats a scattered match — typing "fi" should rank Files above Profile
+ * - **word boundaries** are why "chq" finds Command HQ at all, and should rank it above an item where
+ * those letters happen to fall mid-word
+ * - **adjacency** rewards a run of letters, which is what a substring match would have found
+ * - **shortness** breaks ties toward the more specific label: "Chat" over "Chat history"
+ *
+ * The label is worth roughly three times the hint, so typing a word that IS a label beats an item that
+ * merely mentions it in passing.
+ */
+function scoreOne(text: string, query: string, weight: number): { score: number; hits: number[] } | null {
+ const hits = subsequence(text, query);
+ if (!hits) return null;
+
+ let score = 0;
+ if (text === query) score += 1000;
+ else if (text.startsWith(query)) score += 400;
+
+ for (let i = 0; i < hits.length; i++) {
+ const at = hits[i]!;
+ if (isBoundary(text, at)) score += 30;
+ if (i > 0 && at === hits[i - 1]! + 1) score += 15;
+ }
+
+ // Shorter labels win ties. Capped so a very long one is not driven negative.
+ score += Math.max(0, 40 - text.length);
+ return { score: score * weight, hits };
+}
+
+/**
+ * Rank items against a query.
+ *
+ * An empty query returns everything in its given order — the index is already in a sensible one (dock
+ * first, settings after), and imposing an alphabetical sort on an empty box would move Home away from
+ * the top for no reason.
+ */
+export function rank(items: SpotlightItem[], query: string): Scored[] {
+ const q = query.trim().toLowerCase();
+ if (!q) return items.map((item) => ({ item, score: 0, hits: [] }));
+
+ const scored: Scored[] = [];
+ for (const item of items) {
+ const label = scoreOne(item.label.toLowerCase(), q, 1);
+ const hint = item.hint ? scoreOne(item.hint.toLowerCase(), q, 0.3) : null;
+ // The PATH is matched too, unweighted almost to nothing: it is how "/settings/profile" finds Profile
+ // when somebody types a URL fragment, without letting a path ever outrank a real label.
+ const path = scoreOne(item.to.toLowerCase(), q, 0.15);
+
+ const best = [label, hint, path]
+ .filter((s): s is { score: number; hits: number[] } => !!s)
+ .sort((a, b) => b.score - a.score)[0];
+ if (!best) continue;
+ // `hits` is only meaningful for the label, since that is the only string rendered with highlighting.
+ scored.push({ item, score: best.score, hits: label?.hits ?? [] });
+ }
+
+ return scored.sort((a, b) => b.score - a.score || a.item.label.length - b.item.label.length);
+}
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/useSpotlightItems.ts b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/useSpotlightItems.ts
new file mode 100644
index 00000000..783e4132
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/useSpotlightItems.ts
@@ -0,0 +1,81 @@
+import { useMemo } from 'react';
+import type { LucideIcon } from 'lucide-react';
+import { Settings, User, Puzzle, Server, Cpu, Users } from 'lucide-react';
+import { usePermissions } from 'hooks/usePermissions';
+import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
+
+// What Spotlight can take you to.
+//
+// ── It indexes the DOCK, rather than a list of its own ──
+//
+// The dock is already the composed, permission-filtered answer to "what can this account open here":
+// the shell's own screens, plus whatever installed plugins contribute, minus anything `canVisit` refuses.
+// Building a second list would mean two answers to one question, and the second would be wrong the day
+// somebody installs a plugin — which is the whole failure the dock's own comment warns about, arrived at
+// from a third direction.
+//
+// So a new plugin appears in Spotlight for the same reason it appears in the dock: because it is
+// installed. Nothing here is edited.
+//
+// Settings sections are added because they are the things people hunt for and the dock deliberately does
+// not carry. They are static: a settings screen is shell, not a plugin.
+
+export type SpotlightItem = {
+ /** Stable across renders — used as the React key and for the recency store. */
+ id: string;
+ label: string;
+ /** Where it goes. Matched against `canVisit` for anything not already filtered. */
+ to: string;
+ /** Shown small beside the label, so two "Profile"s are distinguishable. */
+ hint?: string;
+ icon?: LucideIcon;
+ /** Plugin artwork, when the tile has some. */
+ image?: string;
+ color?: string;
+};
+
+/**
+ * The settings sections, which the dock does not carry.
+ *
+ * Hand-listed rather than derived, and that is a real cost: a section added to `IntegrationsSettings`
+ * will not appear here until someone adds it. The alternative is exporting every settings screen's
+ * section array up into the shell, which couples five screens to this one to save five lines.
+ *
+ * Worth revisiting if it drifts. `hint` is what makes them findable — typing "keys" should reach the API
+ * keys screen, and the label alone does not say where it lives.
+ */
+const SETTINGS_ITEMS: SpotlightItem[] = [
+ { id: 'set:profile', label: 'Profile', to: '/settings/profile', hint: 'avatar, name, email', icon: User },
+ { id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: User },
+ { id: 'set:api-keys', label: 'API keys', to: '/settings/profile/api-keys', hint: 'per-app keys', icon: User },
+ { id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: User },
+ { id: 'set:integrations', label: 'Integrations', to: '/settings/integrations', icon: Puzzle },
+ { id: 'set:server', label: 'Server settings', to: '/settings/server', icon: Server },
+ { id: 'set:system', label: 'System', to: '/settings/system', icon: Cpu },
+ { id: 'set:users', label: 'Users', to: '/settings/users', hint: 'accounts and permissions', icon: Users },
+ { id: 'set:ai', label: 'AI', to: '/settings/ai', hint: 'models and harnesses', icon: Settings },
+];
+
+/** Everything Spotlight can offer this account, in a stable order. */
+export function useSpotlightItems(): SpotlightItem[] {
+ const { canVisit, plugins } = usePermissions();
+
+ return useMemo(() => {
+ const fromDock: SpotlightItem[] = [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)]
+ .filter((item) => canVisit(item.to))
+ .map((item) => ({
+ id: `nav:${item.to}`,
+ label: item.label,
+ to: item.to,
+ icon: item.icon,
+ image: item.image,
+ color: item.color,
+ }));
+
+ // Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
+ // courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings.
+ const settings = SETTINGS_ITEMS.filter((item) => canVisit(item.to));
+
+ return [...fromDock, ...settings];
+ }, [canVisit, plugins]);
+}