@grimoire-rs/indexer 0.5.2 → 0.5.3

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.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ useCallback,
2
3
  useEffect,
3
4
  useLayoutEffect,
4
5
  useMemo,
@@ -17,7 +18,13 @@ import {
17
18
  import { PackageCard } from "./PackageCard.js";
18
19
  import { PackageRow } from "./PackageRow.js";
19
20
  import { keywordFrequency, selectRailKeywords } from "../lib/keywordRail.js";
20
- import { lastUpdated, type CatalogPackage } from "../lib/catalog.js";
21
+ import { lastUpdated, type CardPackage } from "../lib/catalog.js";
22
+ import { withBase } from "../lib/base.js";
23
+ // TYPE-ONLY, and it has to stay that way: `../lib/search.js` is the only
24
+ // module that pulls `fuzzysort` in, and it is loaded with `await import()`
25
+ // below so neither reaches a reader who never types. A value import here
26
+ // would fold both back into the island's own chunk.
27
+ import type { Scores, SearchIndex } from "../lib/search.js";
21
28
 
22
29
  // Known kinds get stable chip ordering + badge colors; unknown kinds
23
30
  // (future schema growth) still render with a neutral badge.
@@ -28,7 +35,15 @@ function kindOrder(kind: string): number {
28
35
  return i === -1 ? KNOWN_KINDS.length : i;
29
36
  }
30
37
 
31
- export type Sort = "name" | "updated" | "rating";
38
+ /**
39
+ * `relevance` orders by how well each package answered the query on screen,
40
+ * where the other three order by something every package carries. It is
41
+ * offered and stored like any of them even so: with no query it has nothing
42
+ * to rank, and `CHAINS.relevance` answers that with alphabetical — the order
43
+ * the catalog opens on anyway — so a reader can leave the catalog set to it
44
+ * and have every later search come back ranked without touching the control.
45
+ */
46
+ export type Sort = "name" | "updated" | "rating" | "relevance";
32
47
  export type Dir = "asc" | "desc";
33
48
  /** Roomy cards, or the same packages as a scannable list. */
34
49
  export type View = "cards" | "table";
@@ -42,6 +57,11 @@ export type View = "cards" | "table";
42
57
  */
43
58
  const KEYWORD_CHIP_LIMIT = 8;
44
59
 
60
+ /**
61
+ * How many packages the catalog builds at a time. See `limit` in `Catalog`.
62
+ */
63
+ const WINDOW = 48;
64
+
45
65
  /**
46
66
  * `popovertarget` needs an id, and the catalog is a singleton on its page —
47
67
  * one island, one toolbar, one overflow menu — so this is a constant rather
@@ -62,9 +82,10 @@ export const NATURAL: Record<Sort, Dir> = {
62
82
  name: "asc",
63
83
  updated: "desc",
64
84
  rating: "desc",
85
+ relevance: "desc",
65
86
  };
66
87
 
67
- type Key = (a: CatalogPackage, b: CatalogPackage) => number;
88
+ type Key = (a: CardPackage, b: CardPackage) => number;
68
89
 
69
90
  /**
70
91
  * Bigger first, with `null` as its own bucket underneath every number.
@@ -80,7 +101,7 @@ function descending(a: number | null, b: number | null): number {
80
101
  }
81
102
 
82
103
  /** `updated` as epoch ms; null when absent, empty or not a date at all. */
83
- function updatedAt(p: CatalogPackage): number | null {
104
+ function updatedAt(p: CardPackage): number | null {
84
105
  const at = lastUpdated(p);
85
106
  const ms = at ? new Date(at).getTime() : NaN;
86
107
  return Number.isFinite(ms) ? ms : null;
@@ -115,6 +136,16 @@ const CHAINS: Record<Sort, Key[]> = {
115
136
  name: [byName],
116
137
  updated: [byUpdated, byName],
117
138
  rating: [byRating, byUpdated, byName],
139
+ // Relevance cannot be a key here: a score belongs to a query, not to a
140
+ // package, so it is not on the record `compare` is handed. `shown` sorts
141
+ // that mode itself.
142
+ //
143
+ // This entry is what the mode falls back to whenever there are no scores —
144
+ // no query typed, or the fuzzy index still downloading — which is also why
145
+ // relevance can be offered and stored like any other mode. Alphabetical is
146
+ // the honest answer to "rank these against nothing", and it is what the
147
+ // catalog already shows on arrival.
148
+ relevance: [byName],
118
149
  };
119
150
 
120
151
  // Deprecated packages get no special ordering here — they are filtered out
@@ -123,8 +154,8 @@ const CHAINS: Record<Sort, Key[]> = {
123
154
  // (`browse_sort.rs`) has no deprecated key either; keeping this comparator
124
155
  // silent on deprecation is what keeps the two in sync.
125
156
  export function compare(
126
- a: CatalogPackage,
127
- b: CatalogPackage,
157
+ a: CardPackage,
158
+ b: CardPackage,
128
159
  sort: Sort,
129
160
  dir: Dir = NATURAL[sort],
130
161
  ): number {
@@ -244,7 +275,7 @@ function PackageTable({
244
275
  onKeyDown,
245
276
  rootRef,
246
277
  }: {
247
- packages: CatalogPackage[];
278
+ packages: CardPackage[];
248
279
  hasRatings: boolean;
249
280
  onKeyDown: (event: KeyboardEvent) => void;
250
281
  rootRef: { current: HTMLElement | null };
@@ -276,7 +307,7 @@ export default function Catalog({
276
307
  packages,
277
308
  vscodeExtension,
278
309
  }: {
279
- packages: CatalogPackage[];
310
+ packages: CardPackage[];
280
311
  vscodeExtension: string | null;
281
312
  }) {
282
313
  // Empty on the first render, ALWAYS — `?q=…` is applied a beat later, in
@@ -313,8 +344,14 @@ export default function Catalog({
313
344
  // Local to the overflow menu and deliberately not shareable: it narrows
314
345
  // the list of keywords, not the catalog.
315
346
  const [keywordFilter, setKeywordFilter] = useState("");
347
+ // The fuzzy matcher, once it has been fetched. `null` is the normal state
348
+ // for most of a visit — see the loader below — and every consumer treats
349
+ // it as "fall back to the substring filter", never as an error.
350
+ const [index, setIndex] = useState<SearchIndex | null>(null);
316
351
 
317
352
  const searchRef = useRef<HTMLInputElement>(null);
353
+ /** Whether the sort combo's last interaction came from a pointer. */
354
+ const pickedByPointer = useRef(false);
318
355
  const gridRef = useRef<HTMLElement>(null);
319
356
  const controlsRef = useRef<HTMLDivElement>(null);
320
357
 
@@ -347,6 +384,27 @@ export default function Catalog({
347
384
  /** The slide each chip is currently running, so a new one can replace it. */
348
385
  const railSlides = useRef(new WeakMap<HTMLElement, Animation>());
349
386
  const railRef = useRef<HTMLDivElement>(null);
387
+ /**
388
+ * Which chips are up, in order, as of the current render — written below,
389
+ * once `visibleKeywords` exists, and read by the two layout effects.
390
+ *
391
+ * Both of those effects measure geometry, and a geometry read forces a
392
+ * synchronous style and layout pass over the WHOLE document. Neither had a
393
+ * dependency array, so both ran on every commit — including a commit that
394
+ * changed no chip at all. Switching to the list view is that commit, and at
395
+ * 500 packages it paid two forced layouts over several hundred rows Preact
396
+ * had just mounted: a 600ms task, most of it in `ForcedStyleAndLayout`.
397
+ *
398
+ * A ref rather than a dependency array because the chip list is computed
399
+ * hundreds of lines below these hooks, and a value cannot be a dependency
400
+ * before it exists. Writing a ref during render is safe — it is not state,
401
+ * nothing re-renders from it, and an effect body always runs after the
402
+ * render that wrote it.
403
+ */
404
+ const railSignature = useRef("");
405
+ /** What `railSignature` was when each effect last did its reads. */
406
+ const railMeasured = useRef<string | null>(null);
407
+ const railFlipped = useRef<string | null>(null);
350
408
 
351
409
  const kwMenuRef = useRef<HTMLDivElement>(null);
352
410
  const kwTriggerRef = useRef<HTMLButtonElement>(null);
@@ -420,35 +478,56 @@ export default function Catalog({
420
478
  */
421
479
  const [railFit, setRailFit] = useState(KEYWORD_CHIP_LIMIT);
422
480
 
423
- useLayoutEffect(() => {
481
+ const measureRail = useCallback(() => {
424
482
  const rail = railRef.current;
425
483
  if (!rail) return;
426
- const measure = () => {
427
- const edge = rail.getBoundingClientRect().right;
428
- let fits = 0;
429
- for (const chip of rail.children) {
430
- // Half a pixel of slack: a fractional layout can leave a chip's right
431
- // edge a rounding error past a boundary it visually sits inside.
432
- if (chip.getBoundingClientRect().right > edge + 0.5) break;
433
- fits += 1;
434
- }
435
- // At least one, always. A rail too narrow for its shortest chip should
436
- // show that chip clipped rather than render an empty group beside a
437
- // divider that then divides nothing.
438
- setRailFit(Math.max(1, fits));
439
- };
440
- measure();
441
- // Guarded rather than assumed: this effect also runs under the test
442
- // renderer, whose DOM has no `ResizeObserver`and a missing one costs
443
- // only re-measurement on viewport resize, which is not worth throwing
444
- // during a render over.
445
- if (typeof ResizeObserver === "undefined") return;
446
- const observer = new ResizeObserver(measure);
447
- observer.observe(rail);
448
- return () => observer.disconnect();
449
- // Re-measured on every commit that changes which chips are up, since the
450
- // observer only fires when the rail's own box changes and a rescore can
451
- // swap a short word for a long one at the same width.
484
+ const edge = rail.getBoundingClientRect().right;
485
+ let fits = 0;
486
+ for (const chip of rail.children) {
487
+ // Half a pixel of slack: a fractional layout can leave a chip's right
488
+ // edge a rounding error past a boundary it visually sits inside.
489
+ if (chip.getBoundingClientRect().right > edge + 0.5) break;
490
+ fits += 1;
491
+ }
492
+ // At least one, always. A rail too narrow for its shortest chip should
493
+ // show that chip clipped rather than render an empty group beside a
494
+ // divider that then divides nothing.
495
+ setRailFit(Math.max(1, fits));
496
+ }, []);
497
+
498
+ /**
499
+ * The rail's own `ref`, so the resize observer is attached exactly when the
500
+ * element exists. It is conditionally renderedno keywords, no rail — and
501
+ * a `[]`-dependency effect would miss it appearing later.
502
+ *
503
+ * This also stops the observer being torn down and rebuilt on every commit,
504
+ * which was not free: `observe()` delivers an initial callback, so a rebuild
505
+ * per commit meant a measurement per commit no matter what gated the effect.
506
+ *
507
+ * Guarded rather than assumed: this runs under the test renderer too, whose
508
+ * DOM has no `ResizeObserver` and a missing one costs only re-measurement
509
+ * on viewport resize, which is not worth throwing during a render over.
510
+ */
511
+ const railObserver = useRef<ResizeObserver | null>(null);
512
+ const attachRail = useCallback(
513
+ (el: HTMLDivElement | null) => {
514
+ railRef.current = el;
515
+ railObserver.current?.disconnect();
516
+ railObserver.current = null;
517
+ if (!el || typeof ResizeObserver === "undefined") return;
518
+ railObserver.current = new ResizeObserver(measureRail);
519
+ railObserver.current.observe(el);
520
+ },
521
+ [measureRail],
522
+ );
523
+
524
+ // Re-measured when the chips change, and only then — a rescore can swap a
525
+ // short word for a long one at the same width, which the observer above
526
+ // would never see. See `railSignature` for why the guard is a ref.
527
+ useLayoutEffect(() => {
528
+ if (railSignature.current === railMeasured.current) return;
529
+ railMeasured.current = railSignature.current;
530
+ measureRail();
452
531
  });
453
532
 
454
533
  /**
@@ -484,6 +563,18 @@ export default function Catalog({
484
563
  * property of the mechanism rather than of a cleanup remembering to run.
485
564
  */
486
565
  useLayoutEffect(() => {
566
+ // Same guard as the fit measurement, for the same reason: `offsetLeft`
567
+ // forces layout, and a commit that moved no chip has nothing to animate.
568
+ //
569
+ // The seats it leaves behind are therefore from the last chip change
570
+ // rather than from the last commit, which is what FLIP wants. The one
571
+ // case that costs: a viewport resize between two chip changes moves the
572
+ // chips without changing the signature, so the next slide starts from
573
+ // pre-resize seats. One slightly-off slide after a resize, against two
574
+ // forced layouts on every commit — including every keystroke and every
575
+ // view switch — which is the trade this makes.
576
+ if (railSignature.current === railFlipped.current) return;
577
+ railFlipped.current = railSignature.current;
487
578
  const previous = railSeats.current;
488
579
  const current = new Map<string, { x: number; y: number }>();
489
580
  const moved: { el: HTMLElement; dx: number; dy: number }[] = [];
@@ -587,7 +678,8 @@ export default function Catalog({
587
678
  const s = readPref("sort");
588
679
  const d = readPref("dir");
589
680
  const v = readPref("view");
590
- const field: Sort = s === "updated" || s === "rating" ? s : "name";
681
+ const field: Sort =
682
+ s === "updated" || s === "rating" || s === "relevance" ? s : "name";
591
683
  const published = new Set(packages.flatMap((p) => p.keywords ?? []));
592
684
  setQuery(params.get("q") ?? "");
593
685
  setKinds(list(params.get("kind")).filter((k) => KNOWN_KINDS.includes(k)));
@@ -877,26 +969,164 @@ export default function Catalog({
877
969
  }, [counted]);
878
970
 
879
971
  const q = query.trim().toLowerCase();
972
+
973
+ /**
974
+ * Fetch the fuzzy matcher, once, the first time anyone searches.
975
+ *
976
+ * Not on mount: a reader who never types pays nothing — no `fuzzysort`
977
+ * chunk, no `/all.json`. Not per keystroke either; `tried` latches on the
978
+ * first attempt, so a failed load degrades to the substring filter for the
979
+ * rest of the visit rather than re-fetching on every letter.
980
+ *
981
+ * A failure is not shown to the reader on purpose. Search keeps working —
982
+ * the substring path over the card's own fields is what the catalog did
983
+ * before this existed — so an error banner would report a downgrade nobody
984
+ * asked about, over a page that is doing what they asked. It goes to the
985
+ * console with the error value itself, chain and stack intact.
986
+ */
987
+ const tried = useRef(false);
988
+ useEffect(() => {
989
+ if (!q || tried.current) return;
990
+ tried.current = true;
991
+ // Self-catching, so the `void` attaches nothing it needs to: every await
992
+ // on the path is inside the try.
993
+ void (async () => {
994
+ try {
995
+ const { loadSearchIndex } = await import("../lib/search.js");
996
+ setIndex(await loadSearchIndex(withBase("/all.json")));
997
+ } catch (err) {
998
+ console.error("catalog: fuzzy search unavailable, using substring match", err);
999
+ }
1000
+ })();
1001
+ }, [q]);
1002
+
1003
+ /**
1004
+ * What the current query scored against every package, or `null` when
1005
+ * there is no query or no matcher yet.
1006
+ *
1007
+ * Memoized on the pair: re-scoring the whole catalog is the one genuinely
1008
+ * expensive thing a keystroke triggers, and every consumer below reads it.
1009
+ */
1010
+ const scores: Scores | null = useMemo(
1011
+ () => (index && q ? index.search(q) : null),
1012
+ [index, q],
1013
+ );
1014
+
880
1015
  // Query and facets first, deprecation last — so the toggle can report how
881
1016
  // many entries *it alone* is holding back, rather than a catalog-wide
882
1017
  // number that has nothing to do with what is on screen.
883
- const matching = packages.filter((p) => {
884
- if (kinds.length > 0 && !kinds.includes(p.kind)) return false;
885
- if (!keywords.every((kw) => p.keywords?.includes(kw))) return false;
886
- if (!q) return true;
887
- return [
888
- p.name,
889
- p.description ?? "",
890
- p.namespace,
891
- p.kind,
892
- p.ref,
893
- p.summary ?? "",
894
- (p.keywords ?? []).join(" "),
895
- ].some((field) => field.toLowerCase().includes(q));
896
- });
897
- const shown = (
898
- showDeprecated ? matching : matching.filter((p) => !p.deprecated)
899
- ).sort((a, b) => compare(a, b, sort, dir));
1018
+ //
1019
+ // Memoized, and `shown` with it, for a reason beyond the scan's own cost:
1020
+ // an unmemoized `.filter().sort()` yields a NEW array on every render, so
1021
+ // anything downstream keyed on `shown` — the keyword rail's set-cover below
1022
+ // — could never hit its own cache. Both had to move together or neither
1023
+ // helped. Every dependency here is a primitive or a state array, so the
1024
+ // identity is stable exactly when the answer is.
1025
+ const matching = useMemo(
1026
+ () =>
1027
+ packages.filter((p) => {
1028
+ if (kinds.length > 0 && !kinds.includes(p.kind)) return false;
1029
+ if (!keywords.every((kw) => p.keywords?.includes(kw))) return false;
1030
+ if (!q) return true;
1031
+ // The fuzzy index, once it is here: multi-term, order-independent,
1032
+ // typo-tolerant, and over every field `/all.json` carries — the
1033
+ // licence, the vendor, the repository, the authors, none of which
1034
+ // are on the record this island was handed.
1035
+ if (scores) return scores.has(p.ref);
1036
+ // Until then, and if the fetch never lands: the substring pass over
1037
+ // the fields the card itself ships. Narrower on both axes, and the
1038
+ // reason the search box is never dead while a chunk downloads.
1039
+ return [
1040
+ p.name,
1041
+ p.description ?? "",
1042
+ p.namespace,
1043
+ p.kind,
1044
+ p.ref,
1045
+ p.summary ?? "",
1046
+ (p.keywords ?? []).join(" "),
1047
+ ].some((field) => field.toLowerCase().includes(q));
1048
+ }),
1049
+ [packages, kinds, keywords, q, scores],
1050
+ );
1051
+ const shown = useMemo(() => {
1052
+ // `.filter()` already returns a fresh array, so sorting in place here
1053
+ // mutates nothing the memo above holds — except in the `showDeprecated`
1054
+ // branch, where `matching` IS that array. Copy before sorting.
1055
+ const list = showDeprecated
1056
+ ? [...matching]
1057
+ : matching.filter((p) => !p.deprecated);
1058
+ // Relevance is sorted here rather than in `compare`, because the score is
1059
+ // a property of the query and not of the package — see `CHAINS`. Name
1060
+ // breaks the tie, so equally-scored packages keep a total order and the
1061
+ // list cannot reshuffle between renders.
1062
+ if (sort === "relevance" && scores) {
1063
+ return list.sort((a, b) => {
1064
+ const d =
1065
+ descending(scores.get(a.ref) ?? null, scores.get(b.ref) ?? null) ||
1066
+ byName(a, b);
1067
+ return dir === NATURAL.relevance ? d : -d;
1068
+ });
1069
+ }
1070
+ return list.sort((a, b) => compare(a, b, sort, dir));
1071
+ }, [matching, showDeprecated, sort, dir, scores]);
1072
+
1073
+
1074
+ /**
1075
+ * How many of `shown` are actually built.
1076
+ *
1077
+ * The island's cost is dominated by constructing components, not by drawing
1078
+ * them: `content-visibility` on the card and the row already means the
1079
+ * browser skips layout and paint for anything off screen, but Preact still
1080
+ * built every one. At 500 packages that was ~440 card components on
1081
+ * hydration and another ~440 row components the moment the view changed —
1082
+ * a 1.9s wait before a stored list view was on screen at all.
1083
+ *
1084
+ * So only a viewport's worth is built, and the slice GROWS as a sentinel
1085
+ * below the list comes into view. It never shrinks, which is the whole
1086
+ * reason this is a slice rather than true virtualization: an item that has
1087
+ * been built stays built, so scrolling back up can never meet a blank row,
1088
+ * and find-in-page keeps working over everything reached so far. The
1089
+ * worst case — a reader who scrolls to the bottom — is exactly today's
1090
+ * behaviour, and its paint is still bounded by `content-visibility`.
1091
+ *
1092
+ * 48 covers a tall viewport of either shape with room over: rows are 34px,
1093
+ * and the card grid is three or four across at 19rem minimum.
1094
+ */
1095
+ const [limit, setLimit] = useState(WINDOW);
1096
+ // A new result set starts a new window — otherwise narrowing to 3 matches
1097
+ // and clearing the filter again would leave the whole catalog built.
1098
+ // `shown` is memoized, so this identity changes exactly when the answer does.
1099
+ const shownRef = useRef(shown);
1100
+ if (shownRef.current !== shown) {
1101
+ shownRef.current = shown;
1102
+ if (limit !== WINDOW) setLimit(WINDOW);
1103
+ }
1104
+ const visible = limit >= shown.length ? shown : shown.slice(0, limit);
1105
+
1106
+ /**
1107
+ * Grow the window when the sentinel below the list is reached.
1108
+ *
1109
+ * `rootMargin` is what keeps this invisible in use: the next slice is built
1110
+ * a screen and a half before the reader gets to it, so the list reads as
1111
+ * complete rather than as something that loads while you look at it. The
1112
+ * observer re-fires while the sentinel stays in view, so a fast scroll
1113
+ * keeps growing the window a slice per frame rather than stalling.
1114
+ */
1115
+ const sentinelRef = useRef<HTMLDivElement>(null);
1116
+ useEffect(() => {
1117
+ const el = sentinelRef.current;
1118
+ // Guarded like the rail's observer: the test renderer's DOM has neither.
1119
+ if (!el || typeof IntersectionObserver === "undefined") return;
1120
+ const io = new IntersectionObserver(
1121
+ (entries) => {
1122
+ if (!entries.some((e) => e.isIntersecting)) return;
1123
+ setLimit((l) => (l >= shown.length ? l : l + WINDOW));
1124
+ },
1125
+ { rootMargin: "150% 0px" },
1126
+ );
1127
+ io.observe(el);
1128
+ return () => io.disconnect();
1129
+ }, [shown.length]);
900
1130
 
901
1131
  /**
902
1132
  * The keyword rail, over what is on screen rather than over the catalog.
@@ -921,7 +1151,15 @@ export default function Catalog({
921
1151
  keyword,
922
1152
  count: shown.length,
923
1153
  }));
924
- const rail = selectRailKeywords(shown, KEYWORD_CHIP_LIMIT)
1154
+ // Memoized on `shown` alone, because that is the only thing the scan reads.
1155
+ // `selectRailKeywords` is a greedy set-cover over every keyword of every
1156
+ // shown package — it scales with catalog size, and unmemoized it ran on
1157
+ // EVERY render: each keystroke in the search box, each chip click, each
1158
+ // view toggle, and once more for every re-render none of those caused. At a
1159
+ // corporate-sized catalog that is the most expensive thing in the render
1160
+ // path, repeated for an answer that had not changed.
1161
+ const scored = useMemo(() => selectRailKeywords(shown, KEYWORD_CHIP_LIMIT), [shown]);
1162
+ const rail = scored
925
1163
  // `selectRailKeywords` scores the actives like any other keyword, so
926
1164
  // over-request and drop them rather than spend rail slots twice.
927
1165
  .filter((k) => !keywords.includes(k.keyword))
@@ -933,6 +1171,10 @@ export default function Catalog({
933
1171
  // chip clipped at the rail's edge is one the reader cannot reach anywhere
934
1172
  // else, and a "+N more" that does not count it is lying about where it is.
935
1173
  const clippedKeywords = visibleKeywords.slice(railFit).map((k) => k.keyword);
1174
+ // What the two layout effects above compare against. In render order, so a
1175
+ // reorder counts as a change — the FLIP effect exists to animate exactly
1176
+ // that. NUL-joined because a keyword may contain anything but that.
1177
+ railSignature.current = visibleKeywords.map((k) => k.keyword).join("\u0000");
936
1178
  const menuKeywords = keywordFrequency(shown).filter(
937
1179
  (k) =>
938
1180
  clippedKeywords.includes(k.keyword) ||
@@ -970,6 +1212,31 @@ export default function Catalog({
970
1212
  <kbd class="search-hint" aria-hidden="true">
971
1213
  /
972
1214
  </kbd>
1215
+ {/* Ours, in the hint's own box and its own place — the browser's
1216
+ built-in `type="search"` clear button lands in the same corner
1217
+ wearing the UA's styling, which belongs to no theme this site
1218
+ has. It is hidden in CSS and this replaces it, so the corner
1219
+ holds exactly one control that looks like the rest of the
1220
+ toolbar: the key hint when the field is empty, the same box
1221
+ carrying an X when it is not.
1222
+
1223
+ Focus goes back to the field rather than staying on a button
1224
+ that is about to unmount — the same thing Escape already does
1225
+ from in here. */}
1226
+ {query && (
1227
+ <button
1228
+ type="button"
1229
+ class="search-clear"
1230
+ aria-label="Clear search"
1231
+ title="Clear search"
1232
+ onClick={() => {
1233
+ setQuery("");
1234
+ searchRef.current?.focus();
1235
+ }}
1236
+ >
1237
+ <X size={12} aria-hidden="true" />
1238
+ </button>
1239
+ )}
973
1240
  </div>
974
1241
  {/* One row, three groups, in the order they narrow: what sort of
975
1242
  thing, then what it is about, then what the catalog is
@@ -1023,7 +1290,7 @@ export default function Catalog({
1023
1290
  class="chips kw-rail"
1024
1291
  role="group"
1025
1292
  aria-label="Filter by keyword"
1026
- ref={railRef}
1293
+ ref={attachRail}
1027
1294
  >
1028
1295
  {visibleKeywords.map(({ keyword }, i) => {
1029
1296
  // Past the measured fit: still laid out, so the measurement
@@ -1244,6 +1511,40 @@ export default function Catalog({
1244
1511
  data-slot="filter-chip"
1245
1512
  aria-label="Sort by"
1246
1513
  value={sort}
1514
+ // Chromium matches `:focus-visible` on a `<select>` after a
1515
+ // plain MOUSE click — a select accepts keyboard input, so the
1516
+ // engine treats every focus as keyboard focus. The repo's
1517
+ // `:focus-visible` convention therefore cannot keep the accent
1518
+ // ring off this one control, and CSS has nothing else to go on:
1519
+ // no selector distinguishes focus that arrived from a pointer.
1520
+ //
1521
+ // So the pointer marks itself. `data-pointer` suppresses the
1522
+ // ring for the whole pointer interaction — an open dropdown is
1523
+ // its own affordance and needs no second one around the closed
1524
+ // box behind it — and the pick then hands focus back, so
1525
+ // nothing is left lit beside the thin neutral chips.
1526
+ //
1527
+ // The keyboard path must do NEITHER. Arrow keys on a closed
1528
+ // select fire `change` per option, so blurring there would take
1529
+ // the control away mid-selection, and a keyboard reader is
1530
+ // exactly who the ring exists for. `onKeyDown` clears both, so
1531
+ // a reader who clicks once and later tabs back is a keyboard
1532
+ // reader again.
1533
+ //
1534
+ // Written to the node rather than to state: this fires while
1535
+ // the native dropdown is open, and a re-render of the element
1536
+ // holding it open is not worth the risk for a styling hint.
1537
+ onPointerDown={(event) => {
1538
+ pickedByPointer.current = true;
1539
+ event.currentTarget.dataset.pointer = "";
1540
+ }}
1541
+ onKeyDown={(event) => {
1542
+ pickedByPointer.current = false;
1543
+ delete event.currentTarget.dataset.pointer;
1544
+ }}
1545
+ onBlur={(event) => {
1546
+ delete event.currentTarget.dataset.pointer;
1547
+ }}
1247
1548
  onChange={(event) => {
1248
1549
  const next = (event.currentTarget as HTMLSelectElement)
1249
1550
  .value as Sort;
@@ -1252,11 +1553,14 @@ export default function Catalog({
1252
1553
  // the previous one over lands the reader on "oldest first"
1253
1554
  // because they had asked for Z→A a moment ago.
1254
1555
  setDir(NATURAL[next]);
1556
+ // The pointer path only: see the handlers above.
1557
+ if (pickedByPointer.current) event.currentTarget.blur();
1255
1558
  }}
1256
1559
  >
1257
1560
  <option value="name">name</option>
1258
1561
  <option value="updated">updated</option>
1259
1562
  {hasRatings && <option value="rating">rating</option>}
1563
+ <option value="relevance">relevance</option>
1260
1564
  </select>
1261
1565
  </div>
1262
1566
  {/* Beside sort, because it answers the same kind of question — how
@@ -1296,7 +1600,7 @@ export default function Catalog({
1296
1600
  <p class="empty">No packages match.</p>
1297
1601
  ) : view === "table" ? (
1298
1602
  <PackageTable
1299
- packages={shown}
1603
+ packages={visible}
1300
1604
  hasRatings={hasRatings}
1301
1605
  onKeyDown={onCardKeyDown}
1302
1606
  rootRef={gridRef}
@@ -1308,7 +1612,7 @@ export default function Catalog({
1308
1612
  gridRef.current = el;
1309
1613
  }}
1310
1614
  >
1311
- {shown.map((p) => (
1615
+ {visible.map((p) => (
1312
1616
  <PackageCard
1313
1617
  key={`${p.namespace}/${p.name}`}
1314
1618
  pkg={p}
@@ -1320,6 +1624,14 @@ export default function Catalog({
1320
1624
  ))}
1321
1625
  </ul>
1322
1626
  )}
1627
+ {/* The sentinel. Outside the list rather than inside it, so it is not a
1628
+ stray child of a `<ul>` whose children are all `<li>`, nor of the
1629
+ table's grid where it would take a row of tracks. `aria-hidden`
1630
+ because it is a scroll position, not content — the count above the
1631
+ list is what tells a screen reader how many packages there are. */}
1632
+ {visible.length < shown.length && (
1633
+ <div ref={sentinelRef} aria-hidden="true" />
1634
+ )}
1323
1635
  </section>
1324
1636
  );
1325
1637
  }
@@ -1,6 +1,6 @@
1
- import { type CatalogPackage } from "../lib/catalog.js";
1
+ import { type CardPackage } from "../lib/catalog.js";
2
2
  export interface PackageCardProps {
3
- pkg: CatalogPackage;
3
+ pkg: CardPackage;
4
4
  /**
5
5
  * `publisher.extension` id behind the deep links, or `null`. A prop rather
6
6
  * than a `lib/data` read: this island hydrates in the browser, and importing
@@ -22,11 +22,11 @@ import {
22
22
  timeAgo,
23
23
  vscodeUrl,
24
24
  vscodeVoteUrl,
25
- type CatalogPackage,
25
+ type CardPackage,
26
26
  } from "../lib/catalog.js";
27
27
 
28
28
  export interface PackageCardProps {
29
- pkg: CatalogPackage;
29
+ pkg: CardPackage;
30
30
  /**
31
31
  * `publisher.extension` id behind the deep links, or `null`. A prop rather
32
32
  * than a `lib/data` read: this island hydrates in the browser, and importing
@@ -1,6 +1,6 @@
1
- import { type CatalogPackage } from "../lib/catalog.js";
1
+ import { type CardPackage } from "../lib/catalog.js";
2
2
  export interface PackageRowProps {
3
- pkg: CatalogPackage;
3
+ pkg: CardPackage;
4
4
  /** Whether this index publishes ratings at all — decided once, not per row. */
5
5
  hasRatings: boolean;
6
6
  /** The catalog's own arrow-key navigation. */
@@ -13,10 +13,10 @@ import { ArrowBigUp } from "lucide-preact";
13
13
  import { CardLogo } from "./CardLogo.js";
14
14
  import { DEPRECATED_MARK, KIND_MARKS, KindMark } from "./KindMark.js";
15
15
  import { withBase } from "../lib/base.js";
16
- import { lastUpdated, timeAgo, type CatalogPackage } from "../lib/catalog.js";
16
+ import { lastUpdated, timeAgo, type CardPackage } from "../lib/catalog.js";
17
17
 
18
18
  export interface PackageRowProps {
19
- pkg: CatalogPackage;
19
+ pkg: CardPackage;
20
20
  /** Whether this index publishes ratings at all — decided once, not per row. */
21
21
  hasRatings: boolean;
22
22
  /** The catalog's own arrow-key navigation. */