@grimoire-rs/indexer 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +38 -0
  2. package/dist/cli/init.js +1 -1
  3. package/dist/cli/init.js.map +1 -1
  4. package/dist/config.d.ts +21 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +19 -2
  7. package/dist/config.js.map +1 -1
  8. package/dist/enrich/index.d.ts.map +1 -1
  9. package/dist/enrich/index.js +62 -0
  10. package/dist/enrich/index.js.map +1 -1
  11. package/dist/renderer/astro/components/BrandMark.d.ts +14 -0
  12. package/dist/renderer/astro/components/BrandMark.d.ts.map +1 -0
  13. package/dist/renderer/astro/components/BrandMark.js +16 -0
  14. package/dist/renderer/astro/components/BrandMark.js.map +1 -0
  15. package/dist/renderer/astro/components/BrandMark.tsx +19 -0
  16. package/dist/renderer/astro/components/Catalog.d.ts.map +1 -1
  17. package/dist/renderer/astro/components/Catalog.js +310 -18
  18. package/dist/renderer/astro/components/Catalog.js.map +1 -1
  19. package/dist/renderer/astro/components/Catalog.tsx +445 -63
  20. package/dist/renderer/astro/components/CommandField.astro +43 -0
  21. package/dist/renderer/astro/components/PickerMenu.astro +68 -0
  22. package/dist/renderer/astro/components/VersionMenu.astro +6 -2
  23. package/dist/renderer/astro/content.config.ts +23 -5
  24. package/dist/renderer/astro/layouts/Base.astro +790 -76
  25. package/dist/renderer/astro/lib/catalog.d.ts +10 -0
  26. package/dist/renderer/astro/lib/catalog.d.ts.map +1 -1
  27. package/dist/renderer/astro/lib/catalog.js +28 -0
  28. package/dist/renderer/astro/lib/catalog.js.map +1 -1
  29. package/dist/renderer/astro/lib/catalog.ts +27 -0
  30. package/dist/renderer/astro/lib/code.d.ts +22 -0
  31. package/dist/renderer/astro/lib/code.d.ts.map +1 -0
  32. package/dist/renderer/astro/lib/code.js +21 -0
  33. package/dist/renderer/astro/lib/code.js.map +1 -0
  34. package/dist/renderer/astro/lib/code.ts +21 -0
  35. package/dist/renderer/astro/pages/index.astro +137 -43
  36. package/dist/renderer/astro/pages/p/[...slug].astro +403 -55
  37. package/dist/renderer/index.d.ts +23 -0
  38. package/dist/renderer/index.d.ts.map +1 -1
  39. package/dist/renderer/index.js +117 -30
  40. package/dist/renderer/index.js.map +1 -1
  41. package/package.json +5 -1
  42. package/dist/renderer/astro/components/CopyButton.astro +0 -24
@@ -1,4 +1,9 @@
1
- import { useMemo, useState } from "preact/hooks";
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
2
+ // Lucide (ISC) draws the UI; brand marks come from `@mdi/js`, which Lucide
3
+ // deliberately does not carry. No SVG on this site is hand-written.
4
+ import { Check, FolderRoot, Globe, Image, ImageOff } from "lucide-preact";
5
+ import { mdiMicrosoftVisualStudioCode } from "@mdi/js";
6
+ import { BrandMark } from "./BrandMark.js";
2
7
  import { withBase } from "../lib/base.js";
3
8
  import { timeAgo, vscodeUrl, type CatalogPackage } from "../lib/catalog.js";
4
9
 
@@ -23,28 +28,136 @@ function compare(a: CatalogPackage, b: CatalogPackage, sort: Sort): number {
23
28
  return new Date(b.created).getTime() - new Date(a.created).getTime();
24
29
  }
25
30
 
26
- // The two install scopes wear the same glyphs as the VS Code extension, so
27
- // "project" and "global" read identically in both: codicons `root-folder`
28
- // and `globe` (@vscode/codicons 0.0.45, CC-BY-4.0), copied as paths rather
29
- // than pulling in the icon font for two icons.
30
- const SCOPE_PROJECT =
31
- "M4.5 7C2.019 7 0 9.019 0 11.5C0 13.981 2.019 16 4.5 16C6.981 16 9 13.981 9 11.5C9 9.019 6.981 7 4.5 7ZM4.5 15C2.57 15 1 13.43 1 11.5C1 9.57 2.57 8 4.5 8C6.43 8 8 9.57 8 11.5C8 13.43 6.43 15 4.5 15ZM7 11.5C7 12.881 5.881 14 4.5 14C3.119 14 2 12.881 2 11.5C2 10.119 3.119 9 4.5 9C5.881 9 7 10.119 7 11.5ZM15 6.5V11.5C15 12.881 13.881 14 12.5 14H10V13H12.5C13.328 13 14 12.328 14 11.5V6.5C14 5.672 13.328 5 12.5 5H8.207L7.207 6H5.586C5.719 6 5.846 5.947 5.94 5.854L7.294 4.5L5.94 3.146C5.846 3.052 5.719 3 5.586 3H3.5C2.672 3 2 3.672 2 4.5V6H1V4.5C1 3.119 2.119 2 3.5 2H5.586C5.984 2 6.365 2.158 6.647 2.439L8.208 4H12.501C13.882 4 15.001 5.119 15.001 6.5H15Z";
32
- const SCOPE_GLOBAL =
33
- "M8 1C4.141 1 1 4.141 1 8C1 11.859 4.141 15 8 15C11.859 15 15 11.859 15 8C15 4.141 11.859 1 8 1ZM8 14C7.422 14 6.686 12.906 6.288 11H9.713C9.315 12.906 8.579 14 8.001 14H8ZM6.121 10C6.044 9.392 6 8.723 6 8C6 7.277 6.044 6.608 6.121 6H9.878C9.955 6.608 9.999 7.277 9.999 8C9.999 8.723 9.955 9.392 9.878 10H6.121ZM2 8C2 7.299 2.121 6.626 2.343 6H5.121C5.041 6.656 5 7.332 5 8C5 8.668 5.041 9.344 5.121 10H2.343C2.121 9.374 2 8.701 2 8ZM8 2C8.578 2 9.314 3.094 9.712 5H6.287C6.685 3.094 7.422 2 8 2ZM10.879 6H13.657C13.879 6.626 14 7.299 14 8C14 8.701 13.879 9.374 13.657 10H10.879C10.959 9.344 11 8.668 11 8C11 7.332 10.959 6.656 10.879 6ZM13.195 5H10.722C10.516 3.938 10.199 2.98 9.775 2.268C11.228 2.719 12.446 3.707 13.195 5ZM6.226 2.268C5.802 2.98 5.484 3.938 5.279 5H2.806C3.556 3.707 4.774 2.718 6.226 2.268ZM2.805 11H5.278C5.484 12.062 5.801 13.02 6.225 13.732C4.772 13.281 3.554 12.293 2.805 11ZM9.774 13.732C10.198 13.02 10.516 12.062 10.721 11H13.194C12.444 12.293 11.226 13.282 9.774 13.732Z";
34
- const CHECK =
35
- "M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z";
31
+ // The two install scopes previously wore the VS Code extension's own
32
+ // codicons so "project" and "global" read identically in both. That parity
33
+ // is gone on purpose: every icon now comes from one set. `FolderRoot` and
34
+ // `Globe` are the nearest Lucide equivalents and carry the same meaning.
35
+
36
+ /** Typing inside one of these means a bare keystroke is text, not a shortcut. */
37
+ function isTyping(el: EventTarget | null): boolean {
38
+ const node = el as HTMLElement | null;
39
+ if (!node) return false;
40
+ return node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName);
41
+ }
42
+
43
+ /**
44
+ * How many cards the grid actually laid out per row.
45
+ *
46
+ * Measured, not read from CSS: the track list is `auto-fill` with a minimum
47
+ * width, so the count is a layout outcome that depends on the viewport. The
48
+ * first card whose top edge drops below the first row's starts row two.
49
+ */
50
+ function columnCount(cards: HTMLElement[]): number {
51
+ if (cards.length < 2) return 1;
52
+ const top = cards[0]!.offsetTop;
53
+ const wrapped = cards.findIndex((card) => card.offsetTop > top);
54
+ return wrapped === -1 ? cards.length : wrapped;
55
+ }
56
+
57
+ /**
58
+ * The card's 28px logo slot, in its three states.
59
+ *
60
+ * The third one is the reason this is a component rather than inline JSX: a
61
+ * package can declare a `logo` whose file is not actually served — the
62
+ * enrich step failed, the asset was pruned, the path is stale — and the
63
+ * browser's own broken-image glyph is both ugly and says nothing. So a
64
+ * declared-but-unreachable logo degrades to a marked placeholder, which is
65
+ * deliberately *not* the same as the initial-letter tile a package with no
66
+ * logo at all gets: one is a fault worth seeing, the other is normal.
67
+ *
68
+ * The static detail page needs the same treatment but cannot use `onError`,
69
+ * so it opts into the global handler in `Base.astro` instead — keep the two
70
+ * placeholders looking alike.
71
+ */
72
+ function CardLogo({ pkg }: { pkg: CatalogPackage }) {
73
+ const [state, setState] = useState<"loading" | "ready" | "broken">("loading");
74
+ const imgRef = useRef<HTMLImageElement>(null);
75
+
76
+ // The image is server-rendered, so the browser begins fetching it while
77
+ // parsing the HTML — long before this island hydrates. Two consequences,
78
+ // and the slot markup below answers both: `onError` can fire before any
79
+ // listener exists (the placeholder used to appear only sometimes), and a
80
+ // failed image paints the browser's broken glyph on the way (the flash on
81
+ // reload). Starting the image hidden means nothing is ever shown until it
82
+ // is known to be good.
83
+ //
84
+ // `complete` says the browser finished, not how it went. `decode()` is
85
+ // what separates the two: it rejects for a failure and resolves for a good
86
+ // image — including an SVG with no intrinsic size, where the usual
87
+ // `naturalWidth === 0` test reports a false failure. Gating on `complete`
88
+ // means it never starts a fetch, so `loading="lazy"` still holds off
89
+ // -screen cards.
90
+ useEffect(() => {
91
+ setState("loading");
92
+ const img = imgRef.current;
93
+ if (!img?.complete) return;
94
+ let live = true;
95
+ img.decode().then(
96
+ () => live && setState("ready"),
97
+ () => live && setState("broken"),
98
+ );
99
+ return () => {
100
+ live = false;
101
+ };
102
+ }, [pkg.logo]);
103
+
104
+ if (!pkg.logo) {
105
+ return (
106
+ <span
107
+ class="card-logo card-logo-fallback"
108
+ aria-hidden="true"
109
+ style={{ background: `var(--kind-${pkg.kind}, var(--muted))` }}
110
+ >
111
+ {pkg.name[0]?.toUpperCase()}
112
+ </span>
113
+ );
114
+ }
115
+
116
+ return (
117
+ <span
118
+ class="card-logo logo-slot"
119
+ data-state={state}
120
+ role={state === "broken" ? "img" : undefined}
121
+ aria-label={state === "broken" ? "Logo image unavailable" : undefined}
122
+ title={state === "broken" ? "Logo image unavailable" : undefined}
123
+ >
124
+ {state === "broken" ? (
125
+ <ImageOff class="logo-mark" aria-hidden="true" />
126
+ ) : (
127
+ <Image class="logo-mark" aria-hidden="true" />
128
+ )}
129
+ <img
130
+ ref={imgRef}
131
+ src={withBase(pkg.logo)}
132
+ alt=""
133
+ loading="lazy"
134
+ onLoad={() => setState("ready")}
135
+ onError={() => setState("broken")}
136
+ />
137
+ </span>
138
+ );
139
+ }
36
140
 
37
141
  function CopyButton({
38
142
  command,
39
143
  variant = "default",
144
+ name,
40
145
  }: {
41
146
  command: string;
42
147
  variant?: "default" | "global";
148
+ /** What the copy toast calls this, e.g. `"global install command"`. */
149
+ name?: string;
43
150
  }) {
44
151
  const [copied, setCopied] = useState(false);
45
152
  const copy = () => {
46
153
  navigator.clipboard.writeText(command).then(() => {
47
154
  setCopied(true);
155
+ // The toast lives in Base.astro's inline script, outside this island —
156
+ // an event is how a hydrated component reaches it without either side
157
+ // importing the other.
158
+ document.dispatchEvent(
159
+ new CustomEvent("grimoire:copied", { detail: { name, value: command } }),
160
+ );
48
161
  setTimeout(() => setCopied(false), 1500);
49
162
  });
50
163
  };
@@ -54,11 +167,12 @@ function CopyButton({
54
167
  class={copied ? "copy copied" : "copy"}
55
168
  title={command}
56
169
  aria-label={`Copy: ${command}`}
170
+ // Out of the Tab sequence: the card is the stop, and the same command
171
+ // is copyable from the detail page Enter opens.
172
+ tabIndex={-1}
57
173
  onClick={copy}
58
174
  >
59
- <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
60
- <path d={copied ? CHECK : variant === "global" ? SCOPE_GLOBAL : SCOPE_PROJECT} />
61
- </svg>
175
+ {copied ? <Check size={14} /> : variant === "global" ? <Globe size={14} /> : <FolderRoot size={14} />}
62
176
  </button>
63
177
  );
64
178
  }
@@ -73,49 +187,282 @@ export default function Catalog({
73
187
  packages: CatalogPackage[];
74
188
  vscodeExtension: string | null;
75
189
  }) {
76
- const [query, setQuery] = useState("");
190
+ // Seeded from `?q=…` on the very first render, not from an effect: a
191
+ // keyword chip on a package page links here, and filtering one render late
192
+ // means painting the full catalog and then collapsing it. The server has
193
+ // no `location`, so it renders the unfiltered list — which is what a
194
+ // crawler and a `?q=`-less visitor should both get.
195
+ const [query, setQuery] = useState(() =>
196
+ typeof location === "undefined" ? "" : (new URLSearchParams(location.search).get("q") ?? ""),
197
+ );
77
198
  const [kind, setKind] = useState<string | null>(null);
78
199
  const [sort, setSort] = useState<Sort>("name");
200
+ // Deprecated packages are hidden until asked for: a retired package is
201
+ // noise for someone browsing what to install, and the publisher already
202
+ // said as much by deprecating it.
203
+ const [showDeprecated, setShowDeprecated] = useState(false);
204
+
205
+ const searchRef = useRef<HTMLInputElement>(null);
206
+ const gridRef = useRef<HTMLUListElement>(null);
207
+ const controlsRef = useRef<HTMLDivElement>(null);
208
+
209
+ const cardsOf = () => [...(gridRef.current?.querySelectorAll<HTMLElement>("li.card") ?? [])];
210
+ const chipsOf = () => [
211
+ ...(controlsRef.current?.querySelectorAll<HTMLElement>("button.chip") ?? []),
212
+ ];
213
+
214
+ /** Move focus `delta` cards along, clamping at both ends rather than wrapping. */
215
+ const focusCard = (from: number, delta: number) => {
216
+ const cards = cardsOf();
217
+ if (cards.length === 0) return;
218
+ const next = Math.min(cards.length - 1, Math.max(0, from + delta));
219
+ cards[next]?.focus();
220
+ };
221
+
222
+ /**
223
+ * Focus the search box and bring it to the top of the viewport, so the
224
+ * results — not whatever was on screen before — are what you are looking
225
+ * at while typing. `scroll-margin-top` on the field supplies the gap.
226
+ *
227
+ * Used by every path that moves focus there deliberately (`/`, arrowing up
228
+ * out of the chips, Escape). A plain mouse click is left alone: scrolling
229
+ * the page under a reader who just clicked a visible field is a jolt, not
230
+ * a help.
231
+ */
232
+ const focusSearch = () => {
233
+ const input = searchRef.current;
234
+ if (!input) return;
235
+ input.focus();
236
+ input.select();
237
+ input.scrollIntoView({
238
+ block: "start",
239
+ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
240
+ });
241
+ };
242
+
243
+ // Base.astro hides the catalog before first paint when the URL carries a
244
+ // query. Reveal it once this render — the filtered one — has hit the DOM.
245
+ // A layout effect, so the reveal lands in the same frame as the content.
246
+ useLayoutEffect(() => {
247
+ // Hydration attaches handlers but does not diff props against the server
248
+ // markup, so the box the server rendered empty stays empty even though
249
+ // this render filtered on `query`. Written straight to the DOM, which is
250
+ // what the vnode already claims.
251
+ const input = searchRef.current;
252
+ if (input && input.value !== query) input.value = query;
253
+ delete document.documentElement.dataset.query;
254
+ }, []);
255
+
256
+ // `/` jumps to the search box, the convention every package registry
257
+ // shares. Bound on the document so it works wherever the reader is.
258
+ useEffect(() => {
259
+ const onKeyDown = (event: KeyboardEvent) => {
260
+ if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
261
+ if (isTyping(event.target)) return;
262
+ event.preventDefault();
263
+ focusSearch();
264
+ };
265
+ document.addEventListener("keydown", onKeyDown);
266
+ return () => document.removeEventListener("keydown", onKeyDown);
267
+ }, []);
268
+
269
+ /**
270
+ * Escape drops the selection, from wherever the reader is — the search
271
+ * box, a chip, a card. "Selection" is both halves of it: the active
272
+ * filters, and the card currently holding focus, which wears the accent
273
+ * border and reads as picked. Clearing one and leaving the other visibly
274
+ * selected is the wrong half.
275
+ *
276
+ * The card is blurred rather than handed back to the search box: Escape
277
+ * means "never mind", not "go here instead", and `/` reaches search from
278
+ * anywhere. It matches what a second Escape in the search box already does.
279
+ *
280
+ * Document-level for the same reason `/` is: this is one piece of state,
281
+ * so the key that clears it should not depend on what happens to hold
282
+ * focus. Bailing on `defaultPrevented` leaves the menus that close
283
+ * themselves on Escape — the platform picker, the version popovers — to do
284
+ * that first without also wiping the catalog.
285
+ */
286
+ useEffect(() => {
287
+ const onEscape = (event: KeyboardEvent) => {
288
+ if (event.key !== "Escape" || event.defaultPrevented) return;
289
+ const active = document.activeElement;
290
+ // Also true for a control *inside* a card, which is still the card
291
+ // being selected as far as the reader is concerned.
292
+ const card = active instanceof HTMLElement ? active.closest("li.card") : null;
293
+ if (!query && kind === null && !card) return; // nothing selected: not our key
294
+ event.preventDefault();
295
+ setQuery("");
296
+ setKind(null);
297
+ if (card) (active as HTMLElement).blur();
298
+ };
299
+ document.addEventListener("keydown", onEscape);
300
+ return () => document.removeEventListener("keydown", onEscape);
301
+ }, [query, kind]);
302
+
303
+ /**
304
+ * The filter and sort chips are not Tab stops — Tab is reserved for
305
+ * crossing the catalog, so it runs search → card → card. The chips sit in
306
+ * the row above the grid, so they are reached the way that row is:
307
+ * ArrowUp out of the top card row, ArrowDown back into it.
308
+ *
309
+ * The one case that would strand them is an empty result set, where there
310
+ * is no card to arrow up from — so with nothing shown they rejoin the Tab
311
+ * order (see `chipTabIndex` below), which is also exactly when a reader
312
+ * needs them most.
313
+ */
314
+ const onChipKeyDown = (event: KeyboardEvent) => {
315
+ const chips = chipsOf();
316
+ const index = chips.indexOf(event.currentTarget as HTMLElement);
317
+ if (index === -1) return;
318
+ switch (event.key) {
319
+ case "ArrowRight":
320
+ event.preventDefault();
321
+ return chips[Math.min(chips.length - 1, index + 1)]?.focus();
322
+ case "ArrowLeft":
323
+ event.preventDefault();
324
+ return chips[Math.max(0, index - 1)]?.focus();
325
+ case "ArrowDown":
326
+ event.preventDefault();
327
+ return cardsOf()[0]?.focus();
328
+ case "ArrowUp":
329
+ // Not Escape any more — that clears the filters now, from here as
330
+ // much as anywhere else. ArrowUp is still the way back to search.
331
+ event.preventDefault();
332
+ return focusSearch();
333
+ default:
334
+ return;
335
+ }
336
+ };
337
+
338
+ const onSearchKeyDown = (event: KeyboardEvent) => {
339
+ if (event.key === "ArrowDown") {
340
+ event.preventDefault();
341
+ cardsOf()[0]?.focus();
342
+ } else if (event.key === "Escape" && !query && kind === null) {
343
+ // Clearing is the document handler's job; this is only the second
344
+ // press, once there is nothing left to clear — so Escape leaves the
345
+ // field rather than being a dead key.
346
+ searchRef.current?.blur();
347
+ }
348
+ };
349
+
350
+ const onCardKeyDown = (event: KeyboardEvent) => {
351
+ const card = event.currentTarget as HTMLElement;
352
+ const index = cardsOf().indexOf(card);
353
+ if (index === -1) return;
354
+
355
+ switch (event.key) {
356
+ case "ArrowRight":
357
+ event.preventDefault();
358
+ return focusCard(index, 1);
359
+ case "ArrowLeft":
360
+ event.preventDefault();
361
+ return focusCard(index, -1);
362
+ case "ArrowDown":
363
+ event.preventDefault();
364
+ return focusCard(index, columnCount(cardsOf()));
365
+ case "ArrowUp": {
366
+ event.preventDefault();
367
+ const columns = columnCount(cardsOf());
368
+ // Up out of the first row lands on the control row that sits above
369
+ // the grid — the chips, which have no other keyboard path. Search is
370
+ // one more ArrowUp away, and `/` reaches it from anywhere.
371
+ if (index < columns) {
372
+ const chip = chipsOf()[0];
373
+ if (chip) return chip.focus();
374
+ return focusSearch();
375
+ }
376
+ return focusCard(index, -columns);
377
+ }
378
+ case "Home":
379
+ event.preventDefault();
380
+ return focusCard(index, -index);
381
+ case "End":
382
+ event.preventDefault();
383
+ return focusCard(index, cardsOf().length);
384
+ case "Enter":
385
+ case " ":
386
+ // Only when the card itself holds focus: an inner control reached by
387
+ // mouse must keep its own Space/Enter behaviour.
388
+ if (event.target !== card) return;
389
+ event.preventDefault();
390
+ card.querySelector<HTMLAnchorElement>("h2 a")?.click();
391
+ return;
392
+ default:
393
+ return;
394
+ }
395
+ };
396
+
397
+ // The catalog as the kind chips and the search placeholder count it —
398
+ // deprecated entries drop out of those totals too while they are hidden,
399
+ // so no count ever promises more than the grid shows.
400
+ const counted = useMemo(
401
+ () => (showDeprecated ? packages : packages.filter((p) => !p.deprecated)),
402
+ [packages, showDeprecated],
403
+ );
79
404
 
80
405
  const kinds = useMemo(() => {
81
406
  const counts = new Map<string, number>();
82
- for (const p of packages) counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
407
+ for (const p of counted) counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
83
408
  return [...counts.entries()].sort(
84
409
  (a, b) => kindOrder(a[0]) - kindOrder(b[0]) || a[0].localeCompare(b[0]),
85
410
  );
86
- }, [packages]);
411
+ }, [counted]);
87
412
 
88
413
  const q = query.trim().toLowerCase();
89
- const shown = packages
90
- .filter((p) => {
91
- if (kind && p.kind !== kind) return false;
92
- if (!q) return true;
93
- return [
94
- p.name,
95
- p.description ?? "",
96
- p.namespace,
97
- p.kind,
98
- p.ref,
99
- p.summary ?? "",
100
- (p.keywords ?? []).join(" "),
101
- ].some((field) => field.toLowerCase().includes(q));
102
- })
103
- .sort((a, b) => compare(a, b, sort));
414
+ // Query and kind first, deprecation last — so the toggle can report how
415
+ // many entries *it alone* is holding back, rather than a catalog-wide
416
+ // number that has nothing to do with what is on screen.
417
+ const matching = packages.filter((p) => {
418
+ if (kind && p.kind !== kind) return false;
419
+ if (!q) return true;
420
+ return [
421
+ p.name,
422
+ p.description ?? "",
423
+ p.namespace,
424
+ p.kind,
425
+ p.ref,
426
+ p.summary ?? "",
427
+ (p.keywords ?? []).join(" "),
428
+ ].some((field) => field.toLowerCase().includes(q));
429
+ });
430
+ const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) =>
431
+ compare(a, b, sort),
432
+ );
433
+
434
+ // A catalog with nothing deprecated gets no toggle — a control that can
435
+ // only ever be a no-op is worse than its absence.
436
+ const hasDeprecated = packages.some((p) => p.deprecated);
437
+
438
+ // Chips leave the Tab order only while there is a grid to arrow up from.
439
+ const chipTabIndex = shown.length === 0 ? 0 : -1;
104
440
 
105
441
  return (
106
- <section>
107
- <div class="controls">
108
- <input
109
- type="search"
110
- placeholder={`Search ${packages.length} packages…`}
111
- value={query}
112
- onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
113
- aria-label="Search packages"
114
- />
442
+ <section class="catalog">
443
+ <div class="controls" ref={controlsRef}>
444
+ <div class="search-field">
445
+ <input
446
+ ref={searchRef}
447
+ type="search"
448
+ placeholder={`Search ${counted.length} packages…`}
449
+ value={query}
450
+ onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
451
+ onKeyDown={onSearchKeyDown}
452
+ aria-label="Search packages"
453
+ aria-keyshortcuts="/"
454
+ />
455
+ {/* Decorative: the shortcut is announced by aria-keyshortcuts, so
456
+ repeating it here would be read twice. CSS hides it as soon as
457
+ the field is focused or holds a query. */}
458
+ <kbd class="search-hint" aria-hidden="true">/</kbd>
459
+ </div>
115
460
  <div class="chips" role="group" aria-label="Sort by">
116
461
  <button
117
462
  type="button"
118
463
  class={sort === "name" ? "chip active" : "chip"}
464
+ tabIndex={chipTabIndex}
465
+ onKeyDown={onChipKeyDown}
119
466
  onClick={() => setSort("name")}
120
467
  >
121
468
  name
@@ -123,52 +470,85 @@ export default function Catalog({
123
470
  <button
124
471
  type="button"
125
472
  class={sort === "updated" ? "chip active" : "chip"}
473
+ tabIndex={chipTabIndex}
474
+ onKeyDown={onChipKeyDown}
126
475
  onClick={() => setSort("updated")}
127
476
  >
128
477
  updated
129
478
  </button>
130
479
  </div>
480
+ {/* Divides sort from filter — two different questions sharing a row.
481
+ Decorative only: each group already carries its own aria-label,
482
+ so this is hidden rather than announced. */}
483
+ <span class="chip-sep" aria-hidden="true"></span>
131
484
  <div class="chips" role="group" aria-label="Filter by kind">
132
485
  <button
133
486
  type="button"
134
487
  class={kind === null ? "chip active" : "chip"}
488
+ tabIndex={chipTabIndex}
489
+ onKeyDown={onChipKeyDown}
135
490
  onClick={() => setKind(null)}
136
491
  >
137
- all <small>{packages.length}</small>
492
+ all <small>{counted.length}</small>
138
493
  </button>
139
494
  {kinds.map(([k, count]) => (
140
495
  <button
141
496
  key={k}
142
497
  type="button"
143
498
  class={kind === k ? `chip active kind-${k}` : `chip kind-${k}`}
499
+ tabIndex={chipTabIndex}
500
+ onKeyDown={onChipKeyDown}
144
501
  onClick={() => setKind(kind === k ? null : k)}
145
502
  >
146
503
  {k} <small>{count}</small>
147
504
  </button>
148
505
  ))}
149
506
  </div>
507
+ {hasDeprecated && (
508
+ // A toggle, not a filter: `aria-pressed` rather than the `active`
509
+ // class alone, so it is announced as on/off instead of selected.
510
+ //
511
+ // No count, unlike the kind chips. Theirs is a fixed property of
512
+ // the catalog; this one would be the number currently hidden, which
513
+ // is zero once the toggle is on — so it vanished exactly when
514
+ // pressed and the chip changed width under the pointer.
515
+ <button
516
+ type="button"
517
+ class={showDeprecated ? "chip deprecated-toggle active" : "chip deprecated-toggle"}
518
+ aria-pressed={showDeprecated}
519
+ title={showDeprecated ? "Hide deprecated packages" : "Show deprecated packages"}
520
+ tabIndex={chipTabIndex}
521
+ onKeyDown={onChipKeyDown}
522
+ onClick={() => setShowDeprecated((on) => !on)}
523
+ >
524
+ deprecated
525
+ </button>
526
+ )}
150
527
  </div>
151
528
 
152
529
  {shown.length === 0 ? (
153
530
  <p class="empty">No packages match.</p>
154
531
  ) : (
155
- <ul class="grid">
532
+ <ul class="grid" ref={gridRef}>
156
533
  {shown.map((p) => (
157
- <li key={`${p.namespace}/${p.name}`} class="card">
534
+ // One Tab stop per card, in DOM order — which the grid lays out
535
+ // left to right, top to bottom. Every control inside is taken
536
+ // out of the sequence (`tabindex={-1}`) so tabbing crosses the
537
+ // catalog instead of wading through it; arrow keys move by row
538
+ // and column, and Enter opens the detail page, which carries the
539
+ // same install commands the card's buttons do.
540
+ <li
541
+ key={`${p.namespace}/${p.name}`}
542
+ class="card"
543
+ tabIndex={0}
544
+ onKeyDown={onCardKeyDown}
545
+ >
158
546
  <div class="card-head">
159
- {p.logo ? (
160
- <img class="card-logo" src={withBase(p.logo)} alt="" loading="lazy" />
161
- ) : (
162
- <span
163
- class="card-logo card-logo-fallback"
164
- aria-hidden="true"
165
- style={{ background: `var(--kind-${p.kind}, var(--muted))` }}
166
- >
167
- {p.name[0]?.toUpperCase()}
168
- </span>
169
- )}
547
+ <CardLogo pkg={p} />
170
548
  <h2>
171
- <a href={withBase(`/p/${p.namespace}/${p.name}/`)}>{p.name}</a>
549
+ <a href={withBase(`/p/${p.namespace}/${p.name}/`)} tabIndex={-1}>
550
+ {p.name}
551
+ </a>
172
552
  </h2>
173
553
  {p.deprecated ? (
174
554
  <span class="badge deprecated">deprecated</span>
@@ -202,6 +582,7 @@ export default function Catalog({
202
582
  key={kw}
203
583
  type="button"
204
584
  class="chip keyword"
585
+ tabIndex={-1}
205
586
  onClick={() => setQuery(kw)}
206
587
  >
207
588
  {kw}
@@ -216,24 +597,24 @@ export default function Catalog({
216
597
  )}
217
598
  <div class="card-foot">
218
599
  <div class="copy-group">
219
- <CopyButton command={`grim add ${p.ref}`} />
600
+ {/* Global first, matching the hero's scope picker — the two
601
+ are the same choice in two places, so they lead with the
602
+ same one. */}
220
603
  <CopyButton
221
604
  command={`grim add --global ${p.ref}`}
222
605
  variant="global"
606
+ name={`global add for ${p.name}`}
223
607
  />
608
+ <CopyButton command={`grim add ${p.ref}`} name={`project add for ${p.name}`} />
224
609
  {vscodeUrl(vscodeExtension, p.ref) && (
225
610
  <a
226
611
  class="copy vscode"
227
612
  href={vscodeUrl(vscodeExtension, p.ref)!}
228
613
  title="Open in VS Code"
229
614
  aria-label={`Open ${p.name} in VS Code`}
615
+ tabIndex={-1}
230
616
  >
231
- <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
232
- <path
233
- fill="currentColor"
234
- d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12l-3.573 3.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"
235
- />
236
- </svg>
617
+ <BrandMark path={mdiMicrosoftVisualStudioCode} />
237
618
  </a>
238
619
  )}
239
620
  </div>
@@ -243,6 +624,7 @@ export default function Catalog({
243
624
  href={p.repository}
244
625
  target="_blank"
245
626
  rel="noopener noreferrer"
627
+ tabIndex={-1}
246
628
  >
247
629
  source
248
630
  </a>
@@ -0,0 +1,43 @@
1
+ ---
2
+ // A command you copy by clicking it. The whole field is the button — there
3
+ // is no separate copy control, just an icon at the right edge that swaps to
4
+ // a green check on success. The click handler is the shared `[data-copy]`
5
+ // listener in Base.astro.
6
+ //
7
+ // The icons are Preact components with no client directive, so Astro renders
8
+ // them to static SVG at build time — no JavaScript ships for them.
9
+ import { Check, Copy } from "lucide-preact";
10
+
11
+ interface Props {
12
+ /** The command: shown, and copied verbatim. */
13
+ value: string;
14
+ /** Accessible name for the button — the visible text is the command. */
15
+ label: string;
16
+ /**
17
+ * What the copy toast calls this, e.g. `"Linux install command"`. A bar
18
+ * with a picker rewrites it on every pick, alongside `data-copy`.
19
+ */
20
+ name?: string;
21
+ }
22
+ const { value, label, name } = Astro.props;
23
+ ---
24
+
25
+ <button
26
+ type="button"
27
+ class="cmd-field"
28
+ data-copy={value}
29
+ data-copy-name={name}
30
+ aria-label={label}
31
+ >
32
+ <code>{value}</code>
33
+ {/* Both icons occupy one grid cell and cross-fade, so nothing reflows and
34
+ the check lands exactly where the copy glyph was. */}
35
+ <span class="cmd-icons" aria-hidden="true">
36
+ <Copy class="icon-idle" size={14} />
37
+ <Check class="icon-done" size={14} />
38
+ </span>
39
+ {/* The cross-fade is the only confirmation a sighted user gets;
40
+ `role="status"` makes it an implicit live region so the click is
41
+ announced too. */}
42
+ <span class="sr-only" role="status"></span>
43
+ </button>