Files
platform/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx
T
pastilhasandClaude Opus 5 f586d748d6 ctrl+space opens spotlight, and ctrl+? tells you everything else
Fourth chord for the launcher, and the last move is a return: ctrl-space → shift-space → alt-space →
ctrl-space. Every one was the owner naming a real collision, and this one is the interesting direction —
alt-space is the GNOME window menu, he rebound the desktop to keep it, disliked what he rebound it to, and
would rather move the app than the window manager.

ctrl-space 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 trade has changed: rebinding a browser is cheaper than
rebinding a desktop. Written down in the file, because if the address bar still wins the key never reaches
the handler and nothing in this repository can help — the fix would be in Brave.

`!ev.shiftKey && !ev.altKey` matters: ctrl+SHIFT+space is the theme browser, and one chord opening both is
the bug that needed a capture-phase listener to fix on the theme side. Verified in the built bundle that the
two conditions are disjoint.

CTRL+? renders every shortcut from a registry, ported in shape from vimazing's gameInfo.ts — controls
declared as data, help screen renders them. Its renderer was a recursive `any` walk, 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 value is in state/shortcuts.ts.

The registry is deliberately NOT authoritative yet, and says so: the handlers still hold their own
conditions, so changing a chord here does not move it. Each entry names the file that implements it, so the
pair is one search apart. Making it the source of truth is the configurable-shortcuts work, and doing it now
would rewrite three handlers for nothing visible.

Also removed a line I put in the theme dropdown yesterday promising "alt+space to search every theme by
name". Themes are not Spotlight items — that was an instruction for a feature nobody built, sitting in the
UI as though it worked. It now points at ctrl+?, which exists. Two comments naming ctrl+alt+space for the
theme browser were corrected to ctrl+shift+space with the older chord noted as still accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:57:42 +00:00

304 lines
13 KiB
TypeScript

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`, 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:
//
// 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
// they think while typing capitals. Guarding it meant IGNORING the trigger whenever focus was in an input
// — 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.
//
// 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.
//
// Nothing else opens it. There is deliberately no second chord — two triggers for one thing means two
// things to collide with, and this one moved twice already.
//
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
//
// ── Sizing ──
//
// Loosened 2026-08-22: the first pass reused the app's list metrics (7px tile, text-sm, py-2) and read as
// cramped, because this is not a list inside a panel — it is a modal you look at for two seconds with
// nothing else competing for the eye. So the search input leads at `text-base`, rows are `py-2.5` with a
// 9px tile, and the label is `text-[15px]` with the hint and path kept deliberately subordinate.
//
// The panel widened with them (560 → 640). Rows now carry a label, a hint and a route on one line, and the
// route is the first thing to be squeezed out — it is also the thing that disambiguates two rows with the
// same name.
//
// The results box grew 52vh → 60vh for the arithmetic reason: taller rows in the same box show fewer
// results, and the cap on how many are listed was removed for a reason.
/*
No cap, deliberately, and it used to be 8.
Eight was fine on the machine it was written on, which has few plugins. On a populated server the dock
carries fourteen plugin tiles and the core items alone fill all eight slots — so with an empty query the
list stopped at Browser and every plugin was unreachable unless you already knew its name well enough to
type it. A launcher you can only use for things you can already name is not doing the job.
The container has scrolled since it was written (`max-h-[52vh] overflow-y-auto`), so there was never a
layout reason for the limit, and the truncation was silent — nothing said "and 20 more", which is how a
cap becomes indistinguishable from a missing feature.
*/
/** 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) ? (
<span key={i} className="text-duck-teal">
{char}
</span>
) : (
<span key={i}>{char}</span>
),
)}
</>
);
};
const Row = ({
item,
hits,
active,
onPick,
}: {
item: SpotlightItem;
hits: number[];
active: boolean;
onPick: () => void;
}) => {
const Icon = item.icon;
// Keyboard navigation has to survive a list taller than the box. `block: 'nearest'` scrolls only when the
// row is actually out of sight, so arrowing through the visible middle of the list does not jump it.
const ref = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (active) ref.current?.scrollIntoView({ block: 'nearest' });
}, [active]);
return (
<button
ref={ref}
type="button"
// `onMouseDown`, not `onClick`: the input holds focus and a click would blur it first, closing the
// overlay on the blur handler before the navigation ever ran.
onMouseDown={(ev) => {
ev.preventDefault();
onPick();
}}
className={`flex w-full items-center gap-3.5 rounded-lg px-3.5 py-2.5 text-left transition-colors ${
active ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
{/*
The dock's tile, at row size. Same two cases and the same rule for choosing between them, because
these are the SAME things and a second visual language for them is what made this list read as
somebody else's menu:
badge a filled square of the item's colour with a white glyph — a white glyph on nothing is
invisible, which is why the fill is not decoration
bare no background, artwork fills the box — a logo framed in an arbitrary swatch reads as a
mistake, and every plugin shipping its own art is this case
The tinted `${color}22` square this used for everything was a third style belonging to neither, and
it put a coloured pane behind Calendar, Contacts and every plugin logo.
*/}
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
style={{
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
color: item.color ? '#fff' : undefined,
}}
>
{item.image ? (
<img
src={item.image}
alt=""
className={item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-5 w-5 object-contain'}
/>
) : Icon ? (
<Icon className={`h-[18px] w-[18px] ${item.color ? '' : 'text-muted-foreground'}`} />
) : (
<Search className="h-[18px] w-[18px] text-muted-foreground" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[15px] leading-snug text-foreground">
<Highlighted label={item.label} hits={hits} />
</span>
{item.hint ? (
<span className="block truncate text-[13px] leading-snug text-muted-foreground">{item.hint}</span>
) : null}
</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground/60">{item.to}</span>
</button>
);
};
export const Spotlight = () => {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate();
const items = useSpotlightItems();
const results = useMemo(() => rank(items, query), [items, query]);
/*
The window handler reads these through refs.
It has to: the listener is registered once per open, and closing over `results` and `active` would pin
it to their values at registration — so arrowing down would read a stale list and Enter would open
whatever was first when the panel appeared. Adding them to the effect's deps instead would tear down and
re-register the listener on every keystroke, which is the same bug wearing a costume.
*/
const resultsRef = useRef(results);
resultsRef.current = results;
const activeRef = useRef(active);
activeRef.current = active;
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) => {
// `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.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
// that sometimes did nothing: focus stayed in the composer, so ArrowDown went to the textarea and
// the launcher never saw it. Blurring the outgoing element is one line and removes the race rather
// than narrowing it.
(document.activeElement as HTMLElement | null)?.blur?.();
setOpen((wasOpen) => !wasOpen);
return;
}
if (!open) return;
if (ev.key === 'Escape') {
close();
return;
}
// Navigation is handled HERE rather than on the input, so it works no matter what has focus — a
// panel that grabbed it back, a blur that lost the race, an element with its own key handling. The
// input's own onKeyDown used to own this, which made every keystroke conditional on focus having
// landed where we expected.
if (ev.key === 'ArrowDown') {
ev.preventDefault();
setActive((i) => Math.min(i + 1, resultsRef.current.length - 1));
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (ev.key === 'Enter') {
ev.preventDefault();
pick(resultsRef.current[activeRef.current]?.item);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, close, pick]);
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 (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-[12vh] backdrop-blur-sm"
onMouseDown={close}
>
<div
className="w-[min(92vw,640px)] overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
// The backdrop closes on mousedown; this stops a click INSIDE the panel counting as one.
onMouseDown={(ev) => ev.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4">
<Search className="h-5 w-5 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={query}
onChange={(ev) => setQuery(ev.target.value)}
placeholder="Go to…"
className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2">
{results.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
) : (
results.map((result, i) => (
<Row
key={result.item.id}
item={result.item}
hits={result.hits}
active={i === active}
onPick={() => pick(result.item)}
/>
))
)}
</div>
</div>
</div>
);
};