@grimoire-rs/indexer 0.4.3 → 0.5.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 (61) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/NOTICE +30 -0
  3. package/README.md +76 -331
  4. package/dist/cli/init.d.ts.map +1 -1
  5. package/dist/cli/init.js +35 -4
  6. package/dist/cli/init.js.map +1 -1
  7. package/dist/config.d.ts +107 -7
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +181 -36
  10. package/dist/config.js.map +1 -1
  11. package/dist/renderer/astro/components/CardLogo.d.ts +5 -0
  12. package/dist/renderer/astro/components/CardLogo.js +58 -0
  13. package/dist/renderer/astro/components/CardLogo.tsx +96 -0
  14. package/dist/renderer/astro/components/Catalog.d.ts +14 -1
  15. package/dist/renderer/astro/components/Catalog.js +467 -108
  16. package/dist/renderer/astro/components/Catalog.tsx +756 -349
  17. package/dist/renderer/astro/components/CommandBar.astro +66 -0
  18. package/dist/renderer/astro/components/CopyButton.d.ts +7 -0
  19. package/dist/renderer/astro/components/CopyButton.js +28 -0
  20. package/dist/renderer/astro/components/CopyButton.tsx +56 -0
  21. package/dist/renderer/astro/components/KindMark.d.ts +69 -0
  22. package/dist/renderer/astro/components/KindMark.js +66 -0
  23. package/dist/renderer/astro/components/KindMark.tsx +141 -0
  24. package/dist/renderer/astro/components/PackageCard.d.ts +18 -0
  25. package/dist/renderer/astro/components/PackageCard.js +50 -0
  26. package/dist/renderer/astro/components/PackageCard.tsx +273 -0
  27. package/dist/renderer/astro/components/PackageRow.d.ts +10 -0
  28. package/dist/renderer/astro/components/PackageRow.js +32 -0
  29. package/dist/renderer/astro/components/PackageRow.tsx +126 -0
  30. package/dist/renderer/astro/components/PickerMenu.astro +5 -14
  31. package/dist/renderer/astro/components/SiteFooter.astro +64 -0
  32. package/dist/renderer/astro/components/SiteHeader.astro +74 -0
  33. package/dist/renderer/astro/components/VersionMenu.astro +2 -2
  34. package/dist/renderer/astro/layouts/Base.astro +860 -206
  35. package/dist/renderer/astro/lib/base.d.ts +25 -0
  36. package/dist/renderer/astro/lib/base.js +23 -0
  37. package/dist/renderer/astro/lib/base.ts +27 -0
  38. package/dist/renderer/astro/lib/catalog.d.ts +24 -0
  39. package/dist/renderer/astro/lib/catalog.js +36 -0
  40. package/dist/renderer/astro/lib/catalog.ts +37 -0
  41. package/dist/renderer/astro/lib/commands.d.ts +58 -0
  42. package/dist/renderer/astro/lib/commands.js +86 -0
  43. package/dist/renderer/astro/lib/commands.ts +117 -0
  44. package/dist/renderer/astro/lib/keywordRail.d.ts +44 -0
  45. package/dist/renderer/astro/lib/keywordRail.js +99 -0
  46. package/dist/renderer/astro/lib/keywordRail.ts +110 -0
  47. package/dist/renderer/astro/pages/index.astro +40 -87
  48. package/dist/renderer/astro/pages/p/[...slug].astro +340 -195
  49. package/dist/renderer/astro/styles/tokens.css +40 -5
  50. package/dist/renderer/index.d.ts +58 -0
  51. package/dist/renderer/index.d.ts.map +1 -1
  52. package/dist/renderer/index.js +547 -5
  53. package/dist/renderer/index.js.map +1 -1
  54. package/dist/renderer/types.d.ts +9 -0
  55. package/dist/renderer/types.d.ts.map +1 -1
  56. package/package.json +9 -4
  57. package/templates/README.md +6 -0
  58. package/templates/ci/github-ratings.yml +5 -0
  59. package/templates/gitignore +4 -1
  60. package/templates/theme/README.md +38 -0
  61. package/templates/tsconfig.json +47 -0
@@ -1,11 +1,22 @@
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 { ArrowBigUp, Check, FolderRoot, Globe, Image, ImageOff } from "lucide-preact";
5
- import { mdiMicrosoftVisualStudioCode } from "@mdi/js";
6
- import { BrandMark } from "./BrandMark.js";
7
- import { withBase } from "../lib/base.js";
8
- import { lastUpdated, timeAgo, vscodeUrl, type CatalogPackage } from "../lib/catalog.js";
1
+ import {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from "preact/hooks";
8
+ // Lucide (ISC) draws the toolbar. The brand marks and kind glyphs moved out
9
+ // with the card and the row that wear them.
10
+ import {
11
+ ArrowDownWideNarrow,
12
+ ArrowUpNarrowWide,
13
+ LayoutGrid,
14
+ List,
15
+ } from "lucide-preact";
16
+ import { PackageCard } from "./PackageCard.js";
17
+ import { PackageRow } from "./PackageRow.js";
18
+ import { keywordFrequency, selectRailKeywords } from "../lib/keywordRail.js";
19
+ import { lastUpdated, type CatalogPackage } from "../lib/catalog.js";
9
20
 
10
21
  // Known kinds get stable chip ordering + badge colors; unknown kinds
11
22
  // (future schema growth) still render with a neutral badge.
@@ -17,6 +28,33 @@ function kindOrder(kind: string): number {
17
28
  }
18
29
 
19
30
  export type Sort = "name" | "updated" | "rating";
31
+ export type Dir = "asc" | "desc";
32
+ /** Roomy cards, or the same packages as a scannable list. */
33
+ export type View = "cards" | "table";
34
+
35
+ /**
36
+ * How many keyword chips the rail shows at once, actives included.
37
+ *
38
+ * Everything past it goes behind the overflow menu. The cap is the point:
39
+ * this catalog's keyword vocabulary is open-ended, and a rail that renders
40
+ * all of it is a wall of chips nobody reads.
41
+ */
42
+ const KEYWORD_CHIP_LIMIT = 8;
43
+
44
+ /**
45
+ * The direction each field is *worth* reading first in — A→Z for a name,
46
+ * newest and best-liked first for the two ranked keys.
47
+ *
48
+ * Picking a field selects its natural direction; the toggle beside the
49
+ * combo box reverses that. So "descending" is not a global default a reader
50
+ * has to correct on every mode, and the arrow always describes what the
51
+ * order actually is rather than which way a flag is set.
52
+ */
53
+ export const NATURAL: Record<Sort, Dir> = {
54
+ name: "asc",
55
+ updated: "desc",
56
+ rating: "desc",
57
+ };
20
58
 
21
59
  type Key = (a: CatalogPackage, b: CatalogPackage) => number;
22
60
 
@@ -46,7 +84,8 @@ function updatedAt(p: CatalogPackage): number | null {
46
84
  * that is not total is a browse order that reshuffles on rebuild.
47
85
  */
48
86
  const byName: Key = (a, b) =>
49
- a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) || a.ref.localeCompare(b.ref);
87
+ a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) ||
88
+ a.ref.localeCompare(b.ref);
50
89
 
51
90
  /**
52
91
  * Newest first. No usable date is *unknown*, not epoch 0: dating an undated
@@ -60,7 +99,8 @@ const byUpdated: Key = (a, b) => descending(updatedAt(a), updatedAt(b));
60
99
  * a fresh index is all-unrated, and zeroes would leave every one of those
61
100
  * rows comparing equal with nothing left to break the tie.
62
101
  */
63
- const byRating: Key = (a, b) => descending(a.rating?.up ?? null, b.rating?.up ?? null);
102
+ const byRating: Key = (a, b) =>
103
+ descending(a.rating?.up ?? null, b.rating?.up ?? null);
64
104
 
65
105
  /** Each mode as a chain of keys, most significant first. */
66
106
  const CHAINS: Record<Sort, Key[]> = {
@@ -74,10 +114,18 @@ const CHAINS: Record<Sort, Key[]> = {
74
114
  // any other row when the toggle brings them back. grim's own browse order
75
115
  // (`browse_sort.rs`) has no deprecated key either; keeping this comparator
76
116
  // silent on deprecation is what keeps the two in sync.
77
- export function compare(a: CatalogPackage, b: CatalogPackage, sort: Sort): number {
117
+ export function compare(
118
+ a: CatalogPackage,
119
+ b: CatalogPackage,
120
+ sort: Sort,
121
+ dir: Dir = NATURAL[sort],
122
+ ): number {
78
123
  for (const key of CHAINS[sort]) {
79
124
  const d = key(a, b);
80
- if (d !== 0) return d;
125
+ // Reversed means reversed all the way down, the ref tiebreak included.
126
+ // Every chain ends on a unique key, so no two rows compare equal and
127
+ // negating the whole answer leaves the order just as total as it was.
128
+ if (d !== 0) return dir === NATURAL[sort] ? d : -d;
81
129
  }
82
130
  return 0;
83
131
  }
@@ -87,11 +135,57 @@ export function compare(a: CatalogPackage, b: CatalogPackage, sort: Sort): numbe
87
135
  // is gone on purpose: every icon now comes from one set. `FolderRoot` and
88
136
  // `Globe` are the nearest Lucide equivalents and carry the same meaning.
89
137
 
138
+ /**
139
+ * The reader's own preferences, kept out of the URL.
140
+ *
141
+ * The split is deliberate and matches grim: `q`, `kind` and `kw` are *what
142
+ * you are looking at* — a keyword chip on a package page links to
143
+ * `/?kw=<keyword>`, so that half has to stay shareable — while sort,
144
+ * direction, deprecated visibility and the cards/table choice are *how you
145
+ * like the catalog arranged*, the same answer on every visit. grim keeps
146
+ * `show_deprecated` in its config file for exactly that reason.
147
+ *
148
+ * Both accessors swallow: reading `localStorage` throws outright, not
149
+ * returns null, in a browser set to block site data, and a catalog is not
150
+ * worth a blank page. A reader who blocks it browses without preferences.
151
+ */
152
+ const PREF = "grim.catalog.";
153
+
154
+ function readPref(key: string): string | null {
155
+ try {
156
+ return localStorage.getItem(PREF + key);
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ function writePref(key: string, value: string | null): void {
163
+ try {
164
+ if (value === null) localStorage.removeItem(PREF + key);
165
+ else localStorage.setItem(PREF + key, value);
166
+ } catch {
167
+ // Nothing to do and nothing to report: preferences are a convenience.
168
+ }
169
+ }
170
+
171
+ /**
172
+ * A comma-joined URL parameter, back into the list it was.
173
+ *
174
+ * Empty and absent are the same answer — `?kind=` is a reader who cleared
175
+ * the filter, not a request for the kind named "". Duplicates collapse so a
176
+ * hand-edited `?kw=a,a` cannot render the same chip twice.
177
+ */
178
+ function list(value: string | null): string[] {
179
+ return [...new Set((value ?? "").split(",").filter(Boolean))];
180
+ }
181
+
90
182
  /** Typing inside one of these means a bare keystroke is text, not a shortcut. */
91
183
  function isTyping(el: EventTarget | null): boolean {
92
184
  const node = el as HTMLElement | null;
93
185
  if (!node) return false;
94
- return node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName);
186
+ return (
187
+ node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName)
188
+ );
95
189
  }
96
190
 
97
191
  /**
@@ -100,6 +194,10 @@ function isTyping(el: EventTarget | null): boolean {
100
194
  * Measured, not read from CSS: the track list is `auto-fill` with a minimum
101
195
  * width, so the count is a layout outcome that depends on the viewport. The
102
196
  * first card whose top edge drops below the first row's starts row two.
197
+ *
198
+ * The table view needs no branch of its own — its rows stack, so the second
199
+ * one is already below the first and this measures the 1 that makes every
200
+ * arrow key move by a single row.
103
201
  */
104
202
  function columnCount(cards: HTMLElement[]): number {
105
203
  if (cards.length < 2) return 1;
@@ -109,125 +207,57 @@ function columnCount(cards: HTMLElement[]): number {
109
207
  }
110
208
 
111
209
  /**
112
- * The card's 28px logo slot, in its three states.
210
+ * The same packages as a list, for reading down a column rather than across
211
+ * a grid.
113
212
  *
114
- * The third one is the reason this is a component rather than inline JSX: a
115
- * package can declare a `logo` whose file is not actually served — the
116
- * enrich step failed, the asset was pruned, the path is stale and the
117
- * browser's own broken-image glyph is both ugly and says nothing. So a
118
- * declared-but-unreachable logo degrades to a marked placeholder, which is
119
- * deliberately *not* the same as the initial-letter tile a package with no
120
- * logo at all gets: one is a fault worth seeing, the other is normal.
213
+ * **A row is an anchor, and there is no `<table>`.** Two reasons, and the
214
+ * first is the load-bearing one: a header row that cannot sort is a header
215
+ * row that *looks* like it sorts every reader who has met a data table
216
+ * clicks it once. Sorting lives in the toolbar, so the table has no headers,
217
+ * and a headerless table has no column semantics left to justify the element.
218
+ * What remains is a list of links, which is what this is. A CSS grid with
219
+ * `subgrid` rows keeps the columns aligned without the markup.
121
220
  *
122
- * The static detail page needs the same treatment but cannot use `onError`,
123
- * so it opts into the global handler in `Base.astro` instead — keep the two
124
- * placeholders looking alike.
221
+ * The anchor is also what makes the whole row clickable, focusable and
222
+ * middle-clickable for free no stretched-link overlay, no synthetic Enter
223
+ * handler. A row carries no controls of its own: the install buttons and the
224
+ * vote links are what the card exists for, and repeating them per row would
225
+ * be five columns of icons. The detail page has all of them.
226
+ *
227
+ * Columns are fixed, unlike the keyword rail above — deliberately. A column
228
+ * is a slot the eye tracks down; one that appears and disappears as the
229
+ * filters change destroys the alignment the table exists to give. The rating
230
+ * column is the one exception, and it is decided once per index rather than
231
+ * per filter.
125
232
  */
126
- function CardLogo({ pkg }: { pkg: CatalogPackage }) {
127
- const [state, setState] = useState<"loading" | "ready" | "broken">("loading");
128
- const imgRef = useRef<HTMLImageElement>(null);
129
-
130
- // The image is server-rendered, so the browser begins fetching it while
131
- // parsing the HTML — long before this island hydrates. Two consequences,
132
- // and the slot markup below answers both: `onError` can fire before any
133
- // listener exists (the placeholder used to appear only sometimes), and a
134
- // failed image paints the browser's broken glyph on the way (the flash on
135
- // reload). Starting the image hidden means nothing is ever shown until it
136
- // is known to be good.
137
- //
138
- // `complete` says the browser finished, not how it went. `decode()` is
139
- // what separates the two: it rejects for a failure and resolves for a good
140
- // image — including an SVG with no intrinsic size, where the usual
141
- // `naturalWidth === 0` test reports a false failure. Gating on `complete`
142
- // means it never starts a fetch, so `loading="lazy"` still holds off
143
- // -screen cards.
144
- useEffect(() => {
145
- setState("loading");
146
- const img = imgRef.current;
147
- if (!img?.complete) return;
148
- let live = true;
149
- img.decode().then(
150
- () => live && setState("ready"),
151
- () => live && setState("broken"),
152
- );
153
- return () => {
154
- live = false;
155
- };
156
- }, [pkg.logo]);
157
-
158
- if (!pkg.logo) {
159
- return (
160
- <span
161
- class="card-logo card-logo-fallback"
162
- aria-hidden="true"
163
- style={{ background: `var(--grim-color-kind-${pkg.kind}, var(--grim-color-muted))` }}
164
- >
165
- {pkg.name[0]?.toUpperCase()}
166
- </span>
167
- );
168
- }
169
-
170
- return (
171
- <span
172
- class="card-logo logo-slot"
173
- data-state={state}
174
- role={state === "broken" ? "img" : undefined}
175
- aria-label={state === "broken" ? "Logo image unavailable" : undefined}
176
- title={state === "broken" ? "Logo image unavailable" : undefined}
177
- >
178
- {state === "broken" ? (
179
- <ImageOff class="logo-mark" aria-hidden="true" />
180
- ) : (
181
- <Image class="logo-mark" aria-hidden="true" />
182
- )}
183
- <img
184
- ref={imgRef}
185
- src={withBase(pkg.logo)}
186
- alt=""
187
- loading="lazy"
188
- onLoad={() => setState("ready")}
189
- onError={() => setState("broken")}
190
- />
191
- </span>
192
- );
193
- }
194
-
195
- function CopyButton({
196
- command,
197
- variant = "default",
198
- name,
233
+ function PackageTable({
234
+ packages,
235
+ hasRatings,
236
+ onKeyDown,
237
+ rootRef,
199
238
  }: {
200
- command: string;
201
- variant?: "default" | "global";
202
- /** What the copy toast calls this, e.g. `"global install command"`. */
203
- name?: string;
239
+ packages: CatalogPackage[];
240
+ hasRatings: boolean;
241
+ onKeyDown: (event: KeyboardEvent) => void;
242
+ rootRef: { current: HTMLElement | null };
204
243
  }) {
205
- const [copied, setCopied] = useState(false);
206
- const copy = () => {
207
- navigator.clipboard.writeText(command).then(() => {
208
- setCopied(true);
209
- // The toast lives in Base.astro's inline script, outside this island —
210
- // an event is how a hydrated component reaches it without either side
211
- // importing the other.
212
- document.dispatchEvent(
213
- new CustomEvent("grimoire:copied", { detail: { name, value: command } }),
214
- );
215
- setTimeout(() => setCopied(false), 1500);
216
- });
217
- };
218
244
  return (
219
- <button
220
- type="button"
221
- class={copied ? "copy copied" : "copy"}
222
- title={command}
223
- aria-label={`Copy: ${command}`}
224
- // Out of the Tab sequence: the card is the stop, and the same command
225
- // is copyable from the detail page Enter opens.
226
- tabIndex={-1}
227
- onClick={copy}
245
+ <div
246
+ class={hasRatings ? "table rated" : "table"}
247
+ data-slot="package-table"
248
+ ref={(el) => {
249
+ rootRef.current = el;
250
+ }}
228
251
  >
229
- {copied ? <Check size={14} /> : variant === "global" ? <Globe size={14} /> : <FolderRoot size={14} />}
230
- </button>
252
+ {packages.map((p) => (
253
+ <PackageRow
254
+ key={`${p.namespace}/${p.name}`}
255
+ pkg={p}
256
+ hasRatings={hasRatings}
257
+ onKeyDown={onKeyDown}
258
+ />
259
+ ))}
260
+ </div>
231
261
  );
232
262
  }
233
263
 
@@ -256,22 +286,146 @@ export default function Catalog({
256
286
  // Whether the URL's query has been applied. Gates the reveal below, so the
257
287
  // catalog is never unhidden while it still shows the unfiltered list.
258
288
  const [seeded, setSeeded] = useState(false);
259
- const [kind, setKind] = useState<string | null>(null);
289
+ // Kinds combine with OR, keywords with AND, and the two groups with each
290
+ // other. That is not an inconsistency, it follows from the data: a package
291
+ // has exactly one kind, so requiring both of two kinds always yields
292
+ // nothing, while it carries many keywords, so requiring both of two is the
293
+ // only reading under which a second click narrows. A facet whose second
294
+ // click *widens* the result set reads as broken.
295
+ const [kinds, setKinds] = useState<string[]>([]);
296
+ const [keywords, setKeywords] = useState<string[]>([]);
260
297
  const [sort, setSort] = useState<Sort>("name");
298
+ // Direction, not "reversed": what the arrow draws is the order itself.
299
+ const [dir, setDir] = useState<Dir>(NATURAL.name);
261
300
  // Deprecated packages are hidden until asked for: a retired package is
262
301
  // noise for someone browsing what to install, and the publisher already
263
302
  // said as much by deprecating it.
264
303
  const [showDeprecated, setShowDeprecated] = useState(false);
304
+ const [view, setView] = useState<View>("cards");
305
+ // Local to the overflow menu and deliberately not shareable: it narrows
306
+ // the list of keywords, not the catalog.
307
+ const [keywordFilter, setKeywordFilter] = useState("");
265
308
 
266
309
  const searchRef = useRef<HTMLInputElement>(null);
267
- const gridRef = useRef<HTMLUListElement>(null);
310
+ const gridRef = useRef<HTMLElement>(null);
268
311
  const controlsRef = useRef<HTMLDivElement>(null);
269
312
 
270
- const cardsOf = () => [...(gridRef.current?.querySelectorAll<HTMLElement>("li.card") ?? [])];
313
+ // Both views, one selector: a table row is the Tab stop its card is, so
314
+ // every keyboard path below — the search hatch, ArrowDown out of the
315
+ // chips, Escape's blur — works in either without knowing which is up.
316
+ const cardsOf = () => [
317
+ ...(gridRef.current?.querySelectorAll<HTMLElement>("li.card, a.row") ?? []),
318
+ ];
319
+ // Clipped keyword chips are excluded: they are drawn as nothing, so an
320
+ // arrow key that landed on one would move focus somewhere the reader
321
+ // cannot see it.
271
322
  const chipsOf = () => [
272
- ...(controlsRef.current?.querySelectorAll<HTMLElement>("button.chip") ?? []),
323
+ ...(controlsRef.current?.querySelectorAll<HTMLElement>(
324
+ 'button.chip:not([aria-hidden="true"])',
325
+ ) ?? []),
273
326
  ];
274
327
 
328
+ const railRefs = useRef(new Map<string, HTMLElement>());
329
+ const railRects = useRef(new Map<string, DOMRect>());
330
+ const railRef = useRef<HTMLDivElement>(null);
331
+
332
+ /**
333
+ * How many keyword chips actually fit on the row, measured.
334
+ *
335
+ * Not a constant, because the answer is a layout outcome: the rail is the
336
+ * one flexible child of the filter row, so its width is whatever the kinds,
337
+ * the overflow menu and the deprecated toggle left, and the chips are as
338
+ * wide as the words publishers wrote. `KEYWORD_CHIP_LIMIT` bounds how many
339
+ * are *offered*; this is how many are shown.
340
+ *
341
+ * The rule it enforces: the rail never wraps and never scrolls. A second
342
+ * row makes the toolbar a different height on every filter click, and a
343
+ * scrollbar hides the chips behind a gesture nobody looks for — it also
344
+ * pushed the overflow menu off the end of the row entirely.
345
+ *
346
+ * Every chip stays in the flow whatever this says; the ones past it are
347
+ * drawn as nothing (see `.chip.kw.clipped`). Taking them out of the flow
348
+ * would free the width that excluded them, which is a measurement that
349
+ * disagrees with itself on every other frame.
350
+ */
351
+ const [railFit, setRailFit] = useState(KEYWORD_CHIP_LIMIT);
352
+
353
+ useLayoutEffect(() => {
354
+ const rail = railRef.current;
355
+ if (!rail) return;
356
+ const measure = () => {
357
+ const edge = rail.getBoundingClientRect().right;
358
+ let fits = 0;
359
+ for (const chip of rail.children) {
360
+ // Half a pixel of slack: a fractional layout can leave a chip's right
361
+ // edge a rounding error past a boundary it visually sits inside.
362
+ if (chip.getBoundingClientRect().right > edge + 0.5) break;
363
+ fits += 1;
364
+ }
365
+ // At least one, always. A rail too narrow for its shortest chip should
366
+ // show that chip clipped rather than render an empty group beside a
367
+ // divider that then divides nothing.
368
+ setRailFit(Math.max(1, fits));
369
+ };
370
+ measure();
371
+ // Guarded rather than assumed: this effect also runs under the test
372
+ // renderer, whose DOM has no `ResizeObserver` — and a missing one costs
373
+ // only re-measurement on viewport resize, which is not worth throwing
374
+ // during a render over.
375
+ if (typeof ResizeObserver === "undefined") return;
376
+ const observer = new ResizeObserver(measure);
377
+ observer.observe(rail);
378
+ return () => observer.disconnect();
379
+ // Re-measured on every commit that changes which chips are up, since the
380
+ // observer only fires when the rail's own box changes and a rescore can
381
+ // swap a short word for a long one at the same width.
382
+ });
383
+
384
+ /**
385
+ * FLIP for the keyword rail: chips slide to their new places instead of
386
+ * teleporting.
387
+ *
388
+ * The rail is rescored against the current result set, so it reorders on
389
+ * every click — the chip just picked moves to the front and the rest flow
390
+ * around it. Animating that is not decoration: a rail whose contents change
391
+ * between two frames reads as having been *replaced*, and a reader who
392
+ * cannot see that a chip moved has no reason to believe it is the same one.
393
+ *
394
+ * First (the map of rects kept from the last commit), Last (measured now),
395
+ * Invert (an inline translate back to where the chip was), Play (dropped on
396
+ * the next frame, so the stylesheet's transition carries it home). Measure
397
+ * every chip before transforming any: `translate` composites and does not
398
+ * reflow, but reading a rect after writing a style on a sibling is the
399
+ * shape that makes a layout thrash, and this runs per keystroke.
400
+ */
401
+ useLayoutEffect(() => {
402
+ const previous = railRects.current;
403
+ const current = new Map<string, DOMRect>();
404
+ const moved: { el: HTMLElement; dx: number; dy: number }[] = [];
405
+ for (const [keyword, el] of railRefs.current) {
406
+ const rect = el.getBoundingClientRect();
407
+ current.set(keyword, rect);
408
+ const was = previous.get(keyword);
409
+ if (!was) continue;
410
+ const dx = was.left - rect.left;
411
+ const dy = was.top - rect.top;
412
+ if (dx !== 0 || dy !== 0) moved.push({ el, dx, dy });
413
+ }
414
+ railRects.current = current;
415
+ if (moved.length === 0) return;
416
+ for (const { el, dx, dy } of moved) {
417
+ el.style.transition = "none";
418
+ el.style.translate = `${dx}px ${dy}px`;
419
+ }
420
+ const frame = requestAnimationFrame(() => {
421
+ for (const { el } of moved) {
422
+ el.style.transition = "";
423
+ el.style.translate = "";
424
+ }
425
+ });
426
+ return () => cancelAnimationFrame(frame);
427
+ });
428
+
275
429
  /** Move focus `delta` cards along, clamping at both ends rather than wrapping. */
276
430
  const focusCard = (from: number, delta: number) => {
277
431
  const cards = cardsOf();
@@ -297,19 +451,95 @@ export default function Catalog({
297
451
  input.select();
298
452
  input.scrollIntoView({
299
453
  block: "start",
300
- behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
454
+ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches
455
+ ? "auto"
456
+ : "smooth",
301
457
  });
302
458
  };
303
459
 
304
- // Apply `?q=…`, now that hydration has matched the server's markup and
305
- // Preact owns the tree. A layout effect rather than a plain one: the
460
+ /**
461
+ * Put the reader's view into state the query from the URL, the
462
+ * preferences from storage. Neither half was there before, and the missing
463
+ * preference half is what made a deprecated package unreachable by Back:
464
+ * you turned the toggle on, opened the package, came back, and the
465
+ * remounted catalog knew nothing about it, so the card you had just been
466
+ * looking at was hidden again.
467
+ *
468
+ * Unknown values are dropped rather than trusted at both doors: `kind`
469
+ * reaches a class name, `kw` reaches a chip that stays on screen until it
470
+ * is clicked off, and `sort` selects a comparator, so none of them follows
471
+ * a hand-typed URL or a hand-edited storage entry anywhere the controls
472
+ * cannot go. `kw` is checked against the catalog's own vocabulary rather
473
+ * than a fixed list, since keywords are whatever publishers wrote.
474
+ */
475
+ const applyView = () => {
476
+ const params = new URLSearchParams(location.search);
477
+ const s = readPref("sort");
478
+ const d = readPref("dir");
479
+ const v = readPref("view");
480
+ const field: Sort = s === "updated" || s === "rating" ? s : "name";
481
+ const published = new Set(packages.flatMap((p) => p.keywords ?? []));
482
+ setQuery(params.get("q") ?? "");
483
+ setKinds(list(params.get("kind")).filter((k) => KNOWN_KINDS.includes(k)));
484
+ setKeywords(list(params.get("kw")).filter((k) => published.has(k)));
485
+ setSort(field);
486
+ setDir(d === "asc" || d === "desc" ? d : NATURAL[field]);
487
+ // A flag: stored at all means on.
488
+ setShowDeprecated(readPref("deprecated") !== null);
489
+ setView(v === "table" ? "table" : "cards");
490
+ };
491
+
492
+ // Apply the URL's view, now that hydration has matched the server's markup
493
+ // and Preact owns the tree. A layout effect rather than a plain one: the
306
494
  // resulting render must land before the browser paints, or a `?q=` visitor
307
495
  // sees the whole catalog flash past on the way to their results.
308
496
  useLayoutEffect(() => {
309
- setQuery(new URLSearchParams(location.search).get("q") ?? "");
497
+ applyView();
310
498
  setSeeded(true);
311
499
  }, []);
312
500
 
501
+ // Back and Forward within the catalog — a keyword chip on a package page
502
+ // links to `/?q=…`, so the reader can land here more than once without a
503
+ // reload, and `popstate` is the only notice of it.
504
+ useEffect(() => {
505
+ const onPop = () => applyView();
506
+ addEventListener("popstate", onPop);
507
+ return () => removeEventListener("popstate", onPop);
508
+ }, []);
509
+
510
+ // The query, into the URL — so it can be shared, and so Back lands on the
511
+ // search the reader left. `replaceState`, not `pushState`: a history entry
512
+ // per keystroke would make Back mean "undo one letter" rather than "the
513
+ // page I came from". Gated on `seeded`, since writing before the URL has
514
+ // been read would erase a deep link on arrival.
515
+ useEffect(() => {
516
+ if (!seeded) return;
517
+ const params = new URLSearchParams(location.search);
518
+ const set = (key: string, value: string | null) =>
519
+ value === null ? params.delete(key) : params.set(key, value);
520
+ set("q", query || null);
521
+ set("kind", kinds.length > 0 ? kinds.join(",") : null);
522
+ set("kw", keywords.length > 0 ? keywords.join(",") : null);
523
+ const search = params.toString();
524
+ const next = `${location.pathname}${search ? `?${search}` : ""}${location.hash}`;
525
+ if (next !== `${location.pathname}${location.search}${location.hash}`) {
526
+ history.replaceState(history.state, "", next);
527
+ }
528
+ // `keywords` is compared by identity, which is what we want: the array is
529
+ // replaced on every toggle and never mutated in place.
530
+ }, [seeded, query, kinds, keywords]);
531
+
532
+ // The preferences, into storage — so the next visit opens the way this one
533
+ // ended. Each is stored only when it is not the default, so a reader who
534
+ // never touched a control leaves nothing behind.
535
+ useEffect(() => {
536
+ if (!seeded) return;
537
+ writePref("sort", sort === "name" ? null : sort);
538
+ writePref("dir", dir === NATURAL[sort] ? null : dir);
539
+ writePref("deprecated", showDeprecated ? "1" : null);
540
+ writePref("view", view === "cards" ? null : view);
541
+ }, [seeded, sort, dir, showDeprecated, view]);
542
+
313
543
  // Base.astro hides the catalog before first paint when the URL carries a
314
544
  // query. Reveal it only once the filtered render is in the DOM — keyed on
315
545
  // `seeded`, so the unfiltered first render is never the one revealed.
@@ -328,7 +558,8 @@ export default function Catalog({
328
558
  // shares. Bound on the document so it works wherever the reader is.
329
559
  useEffect(() => {
330
560
  const onKeyDown = (event: KeyboardEvent) => {
331
- if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
561
+ if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey)
562
+ return;
332
563
  if (isTyping(event.target)) return;
333
564
  event.preventDefault();
334
565
  focusSearch();
@@ -360,27 +591,44 @@ export default function Catalog({
360
591
  const active = document.activeElement;
361
592
  // Also true for a control *inside* a card, which is still the card
362
593
  // being selected as far as the reader is concerned.
363
- const card = active instanceof HTMLElement ? active.closest("li.card") : null;
364
- if (!query && kind === null && !card) return; // nothing selected: not our key
594
+ const card =
595
+ active instanceof HTMLElement ? active.closest("li.card, a.row") : null;
596
+ // Nothing selected: not our key.
597
+ if (!query && kinds.length === 0 && keywords.length === 0 && !card)
598
+ return;
365
599
  event.preventDefault();
366
600
  setQuery("");
367
- setKind(null);
601
+ setKinds([]);
602
+ setKeywords([]);
368
603
  if (card) (active as HTMLElement).blur();
369
604
  };
370
605
  document.addEventListener("keydown", onEscape);
371
606
  return () => document.removeEventListener("keydown", onEscape);
372
- }, [query, kind]);
607
+ }, [query, kinds, keywords]);
608
+
609
+ /** Both facets toggle the same way; only the relation between values differs. */
610
+ const toggle =
611
+ (set: (next: (was: string[]) => string[]) => void) => (value: string) =>
612
+ set((was) =>
613
+ was.includes(value) ? was.filter((v) => v !== value) : [...was, value],
614
+ );
615
+ const toggleKind = toggle(setKinds);
616
+ // Appends rather than inserts, so the pinned chips below stay in the order
617
+ // they were picked — the rail reorders underneath them, the actives do not.
618
+ const toggleKeyword = toggle(setKeywords);
373
619
 
374
620
  /**
375
- * The filter and sort chips are not Tab stops Tab is reserved for
376
- * crossing the catalog, so it runs search card card. The chips sit in
377
- * the row above the grid, so they are reached the way that row is:
378
- * ArrowUp out of the top card row, ArrowDown back into it.
621
+ * Arrow keys move *across* a rail the reader is already standing in. They
622
+ * are not Tab's replacement, and this is the correction of a real defect:
623
+ * the chips used to carry `tabIndex={-1}` whenever the grid had anything
624
+ * in it, which left filtering reachable by pointer and arrow key only.
625
+ * That is a WCAG 2.1.1 (A) failure — every control has to be operable from
626
+ * the keyboard through the ordinary sequence, and an undocumented arrow
627
+ * convention is not that sequence. The sibling `@ocx-sh/catalog` renderer
628
+ * shipped the same shortcut and reverted it for the same reason.
379
629
  *
380
- * The one case that would strand them is an empty result set, where there
381
- * is no card to arrow up from so with nothing shown they rejoin the Tab
382
- * order (see `chipTabIndex` below), which is also exactly when a reader
383
- * needs them most.
630
+ * So the chips are ordinary Tab stops now, and ArrowUp/ArrowDown remain as
631
+ * the faster way to cross a long rail or drop back into the grid.
384
632
  */
385
633
  const onChipKeyDown = (event: KeyboardEvent) => {
386
634
  const chips = chipsOf();
@@ -410,7 +658,35 @@ export default function Catalog({
410
658
  if (event.key === "ArrowDown") {
411
659
  event.preventDefault();
412
660
  cardsOf()[0]?.focus();
413
- } else if (event.key === "Escape" && !query && kind === null) {
661
+ } else if (
662
+ event.key === "Tab" &&
663
+ !event.shiftKey &&
664
+ !event.metaKey &&
665
+ !event.ctrlKey &&
666
+ !event.altKey
667
+ ) {
668
+ // The hatch. Everything between the field and the grid — sort, the
669
+ // view toggle, every chip — sits after it in the DOM and is a real Tab
670
+ // stop again, so plain Tab would walk the whole toolbar before
671
+ // reaching a single package. Forward Tab skips to the results; the
672
+ // toolbar stays reachable by Shift+Tab back out of the grid.
673
+ //
674
+ // Reordering the DOM instead would have put focus order at odds with
675
+ // visual order, which is the worse defect of the two.
676
+ //
677
+ // With nothing to jump to — an empty result set — Tab is left alone
678
+ // rather than swallowed: trapping focus in the field is worse than the
679
+ // walk it was meant to save.
680
+ const first = cardsOf()[0];
681
+ if (!first) return;
682
+ event.preventDefault();
683
+ first.focus();
684
+ } else if (
685
+ event.key === "Escape" &&
686
+ !query &&
687
+ kinds.length === 0 &&
688
+ keywords.length === 0
689
+ ) {
414
690
  // Clearing is the document handler's job; this is only the second
415
691
  // press, once there is nothing left to clear — so Escape leaves the
416
692
  // field rather than being a dead key.
@@ -457,6 +733,10 @@ export default function Catalog({
457
733
  // Only when the card itself holds focus: an inner control reached by
458
734
  // mouse must keep its own Space/Enter behaviour.
459
735
  if (event.target !== card) return;
736
+ // A table row *is* an anchor, so Enter is the browser's to handle —
737
+ // swallowing it here would break activation rather than provide it.
738
+ // Only the card needs its title link clicked on its behalf.
739
+ if (card instanceof HTMLAnchorElement) return;
460
740
  event.preventDefault();
461
741
  card.querySelector<HTMLAnchorElement>("h2 a")?.click();
462
742
  return;
@@ -473,20 +753,26 @@ export default function Catalog({
473
753
  [packages, showDeprecated],
474
754
  );
475
755
 
476
- const kinds = useMemo(() => {
477
- const counts = new Map<string, number>();
478
- for (const p of counted) counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
479
- return [...counts.entries()].sort(
480
- (a, b) => kindOrder(a[0]) - kindOrder(b[0]) || a[0].localeCompare(b[0]),
756
+ // Which kinds this catalog publishes, in chip order. No counts on the
757
+ // chips: they cost every chip the width of a number, which is width the
758
+ // keyword rail beside them needs more, and the meta row already states how
759
+ // many packages the filters left. A per-chip count is also the harder one
760
+ // to read honestly — kinds are an OR group, so a count taken after the
761
+ // filter says "3" about a chip that is about to reveal thirty.
762
+ const kindNames = useMemo(() => {
763
+ const seen = new Set(counted.map((p) => p.kind));
764
+ return [...seen].sort(
765
+ (a, b) => kindOrder(a) - kindOrder(b) || a.localeCompare(b),
481
766
  );
482
767
  }, [counted]);
483
768
 
484
769
  const q = query.trim().toLowerCase();
485
- // Query and kind first, deprecation last — so the toggle can report how
770
+ // Query and facets first, deprecation last — so the toggle can report how
486
771
  // many entries *it alone* is holding back, rather than a catalog-wide
487
772
  // number that has nothing to do with what is on screen.
488
773
  const matching = packages.filter((p) => {
489
- if (kind && p.kind !== kind) return false;
774
+ if (kinds.length > 0 && !kinds.includes(p.kind)) return false;
775
+ if (!keywords.every((kw) => p.keywords?.includes(kw))) return false;
490
776
  if (!q) return true;
491
777
  return [
492
778
  p.name,
@@ -498,8 +784,54 @@ export default function Catalog({
498
784
  (p.keywords ?? []).join(" "),
499
785
  ].some((field) => field.toLowerCase().includes(q));
500
786
  });
501
- const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) =>
502
- compare(a, b, sort),
787
+ const shown = (
788
+ showDeprecated ? matching : matching.filter((p) => !p.deprecated)
789
+ ).sort((a, b) => compare(a, b, sort, dir));
790
+
791
+ /**
792
+ * The keyword rail, over what is on screen rather than over the catalog.
793
+ *
794
+ * Two decisions, both borrowed from `@ocx-sh/catalog` and both load-bearing:
795
+ *
796
+ * Active keywords are **pinned**, first and in the order they were clicked,
797
+ * and never scored. A filter that scrolls out of the rail is a filter the
798
+ * reader cannot lift. Their count is `shown.length` by construction — under
799
+ * AND, every surviving package carries every active keyword.
800
+ *
801
+ * The rest are picked by splitting power over `shown`, not by frequency
802
+ * over `packages`. A rail scored against the whole catalog keeps offering
803
+ * keywords no surviving package carries, and under AND that is most of
804
+ * them — every such chip is one click to an empty grid.
805
+ *
806
+ * The cost, accepted: the rail's contents move as the reader filters, which
807
+ * is what the FLIP effect above animates. The set changing invisibly is
808
+ * what would read as broken.
809
+ */
810
+ const pinned = keywords.map((keyword) => ({
811
+ keyword,
812
+ count: shown.length,
813
+ }));
814
+ const rail = selectRailKeywords(shown, KEYWORD_CHIP_LIMIT)
815
+ // `selectRailKeywords` scores the actives like any other keyword, so
816
+ // over-request and drop them rather than spend rail slots twice.
817
+ .filter((k) => !keywords.includes(k.keyword))
818
+ .slice(0, Math.max(0, KEYWORD_CHIP_LIMIT - pinned.length));
819
+ const visibleKeywords = [...pinned, ...rail];
820
+ // What the menu has to carry: everything the rail had no slot for, plus
821
+ // everything it has a slot for but no ROOM for. The second half is why the
822
+ // menu is built from `railFit` rather than from `KEYWORD_CHIP_LIMIT` — a
823
+ // chip clipped at the rail's edge is one the reader cannot reach anywhere
824
+ // else, and a "+N more" that does not count it is lying about where it is.
825
+ const clippedKeywords = visibleKeywords.slice(railFit).map((k) => k.keyword);
826
+ const menuKeywords = keywordFrequency(shown).filter(
827
+ (k) =>
828
+ clippedKeywords.includes(k.keyword) ||
829
+ !visibleKeywords.some((v) => v.keyword === k.keyword),
830
+ );
831
+ // Plain substring, not a fuzzy match: this searches a list the reader is
832
+ // looking at, and every entry in it is one short known word.
833
+ const menuShown = menuKeywords.filter((k) =>
834
+ k.keyword.toLowerCase().includes(keywordFilter.trim().toLowerCase()),
503
835
  );
504
836
 
505
837
  // A catalog with nothing deprecated gets no toggle — a control that can
@@ -508,9 +840,6 @@ export default function Catalog({
508
840
  const hasDeprecated = packages.some((p) => p.deprecated);
509
841
  const hasRatings = packages.some((p) => p.rating);
510
842
 
511
- // Chips leave the Tab order only while there is a grid to arrow up from.
512
- const chipTabIndex = shown.length === 0 ? 0 : -1;
513
-
514
843
  return (
515
844
  <section class="catalog" data-slot="catalog">
516
845
  <div class="controls" data-slot="catalog-toolbar" ref={controlsRef}>
@@ -518,7 +847,7 @@ export default function Catalog({
518
847
  <input
519
848
  ref={searchRef}
520
849
  type="search"
521
- placeholder={`Search ${counted.length} packages…`}
850
+ placeholder="Search packages — name, keyword, description…"
522
851
  value={query}
523
852
  onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
524
853
  onKeyDown={onSearchKeyDown}
@@ -528,219 +857,297 @@ export default function Catalog({
528
857
  {/* Decorative: the shortcut is announced by aria-keyshortcuts, so
529
858
  repeating it here would be read twice. CSS hides it as soon as
530
859
  the field is focused or holds a query. */}
531
- <kbd class="search-hint" aria-hidden="true">/</kbd>
860
+ <kbd class="search-hint" aria-hidden="true">
861
+ /
862
+ </kbd>
532
863
  </div>
533
- <div class="chips" role="group" aria-label="Sort by">
534
- <button
535
- type="button"
536
- class={sort === "name" ? "chip active" : "chip"}
537
- data-slot="filter-chip"
538
- tabIndex={chipTabIndex}
539
- onKeyDown={onChipKeyDown}
540
- onClick={() => setSort("name")}
541
- >
542
- name
543
- </button>
544
- <button
545
- type="button"
546
- class={sort === "updated" ? "chip active" : "chip"}
547
- data-slot="filter-chip"
548
- tabIndex={chipTabIndex}
549
- onKeyDown={onChipKeyDown}
550
- onClick={() => setSort("updated")}
864
+ {/* One row, three groups, in the order they narrow: what sort of
865
+ thing, then what it is about, then what the catalog is
866
+ withholding. Only the keyword group flexes and scrolls, so a
867
+ narrow viewport shrinks IT rather than dropping the deprecated
868
+ toggle onto a second line — no chip can shrink below its own
869
+ longest word, and the rail's chip count is a constant, not a
870
+ viewport reading. */}
871
+ <div class="filter-row">
872
+ <div
873
+ class="chips kind-chips"
874
+ role="group"
875
+ aria-label="Filter by kind"
551
876
  >
552
- updated
553
- </button>
554
- {hasRatings && (
877
+ {/* "all" is the empty selection rendered as a chip, not a sixth
878
+ kind — so it is active exactly when nothing else is, and
879
+ clicking it clears rather than selects. */}
555
880
  <button
556
881
  type="button"
557
- class={sort === "rating" ? "chip active" : "chip"}
882
+ class={kinds.length === 0 ? "chip active" : "chip"}
558
883
  data-slot="filter-chip"
559
- tabIndex={chipTabIndex}
884
+ aria-pressed={kinds.length === 0}
560
885
  onKeyDown={onChipKeyDown}
561
- onClick={() => setSort("rating")}
886
+ onClick={() => setKinds([])}
562
887
  >
563
- rating
888
+ all
889
+ </button>
890
+ {kindNames.map((k) => (
891
+ <button
892
+ key={k}
893
+ type="button"
894
+ class={
895
+ kinds.includes(k) ? `chip active kind-${k}` : `chip kind-${k}`
896
+ }
897
+ data-slot="filter-chip"
898
+ aria-pressed={kinds.includes(k)}
899
+ onKeyDown={onChipKeyDown}
900
+ onClick={() => toggleKind(k)}
901
+ >
902
+ {k}
903
+ </button>
904
+ ))}
905
+ </div>
906
+ {/* Divides "what sort of thing" from "about what" — two
907
+ questions sharing a row. Decorative: each group carries its own
908
+ aria-label, so this is drawn, not announced. */}
909
+ {visibleKeywords.length > 0 && (
910
+ <>
911
+ <span class="filter-divider" aria-hidden="true" />
912
+ <div
913
+ class="chips kw-rail"
914
+ role="group"
915
+ aria-label="Filter by keyword"
916
+ ref={railRef}
917
+ >
918
+ {visibleKeywords.map(({ keyword }, i) => {
919
+ // Past the measured fit: still laid out, so the measurement
920
+ // that decided this stays true on the next pass, but drawn
921
+ // as nothing and out of reach. Removing it from the flow
922
+ // instead would free the width that excluded it, which is
923
+ // the oscillation this shape exists to avoid.
924
+ const clipped = i >= railFit;
925
+ return (
926
+ <button
927
+ key={keyword}
928
+ ref={(el) => {
929
+ // The FLIP effect measures whatever is in this map, so
930
+ // a chip that leaves has to leave the map with it —
931
+ // Preact calls back with null on unmount for that.
932
+ if (el)
933
+ railRefs.current.set(keyword, el as HTMLElement);
934
+ else railRefs.current.delete(keyword);
935
+ }}
936
+ type="button"
937
+ class={[
938
+ "chip kw",
939
+ keywords.includes(keyword) ? "active" : "",
940
+ clipped ? "clipped" : "",
941
+ ]
942
+ .filter(Boolean)
943
+ .join(" ")}
944
+ data-slot="filter-chip"
945
+ aria-pressed={keywords.includes(keyword)}
946
+ aria-hidden={clipped ? "true" : undefined}
947
+ tabIndex={clipped ? -1 : undefined}
948
+ onKeyDown={onChipKeyDown}
949
+ onClick={() => toggleKeyword(keyword)}
950
+ >
951
+ {keyword}
952
+ </button>
953
+ );
954
+ })}
955
+ </div>
956
+ </>
957
+ )}
958
+ {menuKeywords.length > 0 && (
959
+ // Everything the rail had no room for, behind a search box.
960
+ // Not an "expand" that dumps the remaining chips inline: a
961
+ // catalog's keyword vocabulary is open-ended, and a few hundred
962
+ // chips at once is a wall, not a control.
963
+ //
964
+ // A `<details>` for the same reason the platform picker and the
965
+ // version menus are: it opens, closes and takes Escape on its
966
+ // own, with no popover library and no script.
967
+ <details class="kw-menu">
968
+ <summary class="chip" data-slot="filter-chip">
969
+ +{menuKeywords.length} more
970
+ </summary>
971
+ <div class="kw-menu-panel">
972
+ <input
973
+ type="text"
974
+ class="kw-menu-search"
975
+ placeholder="Filter keywords…"
976
+ aria-label="Filter keywords"
977
+ value={keywordFilter}
978
+ onInput={(e) =>
979
+ setKeywordFilter((e.target as HTMLInputElement).value)
980
+ }
981
+ />
982
+ <div class="kw-menu-list">
983
+ {menuShown.map(({ keyword, count }) => (
984
+ <button
985
+ key={keyword}
986
+ type="button"
987
+ class="kw-menu-item"
988
+ onClick={() => toggleKeyword(keyword)}
989
+ >
990
+ <span>{keyword}</span>
991
+ <small>{count}</small>
992
+ </button>
993
+ ))}
994
+ {menuShown.length === 0 && (
995
+ <p class="kw-menu-empty">No keyword matches.</p>
996
+ )}
997
+ </div>
998
+ </div>
999
+ </details>
1000
+ )}
1001
+ {hasDeprecated && (
1002
+ // A toggle, not a filter: `aria-pressed` rather than the `active`
1003
+ // class alone, so it is announced as on/off instead of selected.
1004
+ //
1005
+ // No count, unlike the kind chips. Theirs is a fixed property of
1006
+ // the catalog; this one would be the number currently hidden, which
1007
+ // is zero once the toggle is on — so it vanished exactly when
1008
+ // pressed and the chip changed width under the pointer.
1009
+ <button
1010
+ type="button"
1011
+ class={
1012
+ showDeprecated
1013
+ ? "chip deprecated-toggle active"
1014
+ : "chip deprecated-toggle"
1015
+ }
1016
+ aria-pressed={showDeprecated}
1017
+ title={
1018
+ showDeprecated
1019
+ ? "Hide deprecated packages"
1020
+ : "Show deprecated packages"
1021
+ }
1022
+ onKeyDown={onChipKeyDown}
1023
+ onClick={() => setShowDeprecated((on) => !on)}
1024
+ >
1025
+ deprecated
564
1026
  </button>
565
1027
  )}
566
1028
  </div>
567
- {/* Divides sort from filter two different questions sharing a row.
568
- Decorative only: each group already carries its own aria-label,
569
- so this is hidden rather than announced. */}
570
- <span class="chip-sep" aria-hidden="true"></span>
571
- <div class="chips" role="group" aria-label="Filter by kind">
572
- <button
573
- type="button"
574
- class={kind === null ? "chip active" : "chip"}
575
- data-slot="filter-chip"
576
- tabIndex={chipTabIndex}
577
- onKeyDown={onChipKeyDown}
578
- onClick={() => setKind(null)}
579
- >
580
- all <small>{counted.length}</small>
581
- </button>
582
- {kinds.map(([k, count]) => (
1029
+ {/* The bottom line of the toolbar: what the filters above left, and
1030
+ the three controls that arrange it. The count leads because it is
1031
+ the answer to everything above it; the controls are pushed to the
1032
+ far end because they are not. */}
1033
+ <div class="meta-row">
1034
+ {/* `role="status"` on the count alone. It re-announces "N of M
1035
+ packages" as the filters change; putting it on the grid would
1036
+ read the whole result list out on every keystroke. */}
1037
+ <p class="result-count" role="status" aria-atomic="true">
1038
+ {shown.length === counted.length
1039
+ ? `${counted.length} packages`
1040
+ : `${shown.length} of ${counted.length} packages`}
1041
+ </p>
1042
+ {/* Held at the far end, away from the chips: choosing an order is
1043
+ not filtering. Both halves take an ordinary tab stop; a select
1044
+ owns ArrowLeft/Right for its options, so neither can join the
1045
+ chips' roving arrow ring. */}
1046
+ <div class="sort-group" role="group" aria-label="Sort by">
1047
+ {/* Left, because that is the order the pair reads in: "descending,
1048
+ by rating". The bars-and-arrow glyph draws the order itself —
1049
+ tall-to-short under a down arrow — rather than labelling a flag,
1050
+ so it stays right whichever field is selected. */}
583
1051
  <button
584
- key={k}
585
1052
  type="button"
586
- class={kind === k ? `chip active kind-${k}` : `chip kind-${k}`}
1053
+ class="sort-dir"
587
1054
  data-slot="filter-chip"
588
- tabIndex={chipTabIndex}
589
- onKeyDown={onChipKeyDown}
590
- onClick={() => setKind(kind === k ? null : k)}
1055
+ title={
1056
+ dir === "asc"
1057
+ ? "Ascending click for descending"
1058
+ : "Descending — click for ascending"
1059
+ }
1060
+ aria-label={
1061
+ dir === "asc"
1062
+ ? "Sorted ascending; sort descending"
1063
+ : "Sorted descending; sort ascending"
1064
+ }
1065
+ onClick={() => setDir((d) => (d === "asc" ? "desc" : "asc"))}
591
1066
  >
592
- {k} <small>{count}</small>
1067
+ {dir === "asc" ? (
1068
+ <ArrowUpNarrowWide size={15} aria-hidden="true" />
1069
+ ) : (
1070
+ <ArrowDownWideNarrow size={15} aria-hidden="true" />
1071
+ )}
593
1072
  </button>
594
- ))}
1073
+ <select
1074
+ class="sort-field"
1075
+ data-slot="filter-chip"
1076
+ aria-label="Sort by"
1077
+ value={sort}
1078
+ onChange={(event) => {
1079
+ const next = (event.currentTarget as HTMLSelectElement)
1080
+ .value as Sort;
1081
+ setSort(next);
1082
+ // Picking a field takes that field's own direction. Carrying
1083
+ // the previous one over lands the reader on "oldest first"
1084
+ // because they had asked for Z→A a moment ago.
1085
+ setDir(NATURAL[next]);
1086
+ }}
1087
+ >
1088
+ <option value="name">name</option>
1089
+ <option value="updated">updated</option>
1090
+ {hasRatings && <option value="rating">rating</option>}
1091
+ </select>
1092
+ </div>
1093
+ {/* Beside sort, because it answers the same kind of question — how
1094
+ the catalog is arranged, not which of it is shown. Two buttons
1095
+ rather than one that toggles: a single button has to be labelled
1096
+ with either the state or the action, and whichever it picks reads
1097
+ as the other half the time. `aria-pressed` on both says which is
1098
+ current without either label lying. */}
1099
+ <div class="view-toggle" role="group" aria-label="Catalog view">
1100
+ <button
1101
+ type="button"
1102
+ class={view === "cards" ? "view-pick active" : "view-pick"}
1103
+ data-slot="filter-chip"
1104
+ aria-pressed={view === "cards"}
1105
+ title="Cards"
1106
+ aria-label="Show packages as cards"
1107
+ onClick={() => setView("cards")}
1108
+ >
1109
+ <LayoutGrid size={15} aria-hidden="true" />
1110
+ </button>
1111
+ <button
1112
+ type="button"
1113
+ class={view === "table" ? "view-pick active" : "view-pick"}
1114
+ data-slot="filter-chip"
1115
+ aria-pressed={view === "table"}
1116
+ title="List"
1117
+ aria-label="Show packages as a list"
1118
+ onClick={() => setView("table")}
1119
+ >
1120
+ <List size={15} aria-hidden="true" />
1121
+ </button>
1122
+ </div>
595
1123
  </div>
596
- {hasDeprecated && (
597
- // A toggle, not a filter: `aria-pressed` rather than the `active`
598
- // class alone, so it is announced as on/off instead of selected.
599
- //
600
- // No count, unlike the kind chips. Theirs is a fixed property of
601
- // the catalog; this one would be the number currently hidden, which
602
- // is zero once the toggle is on — so it vanished exactly when
603
- // pressed and the chip changed width under the pointer.
604
- <button
605
- type="button"
606
- class={showDeprecated ? "chip deprecated-toggle active" : "chip deprecated-toggle"}
607
- aria-pressed={showDeprecated}
608
- title={showDeprecated ? "Hide deprecated packages" : "Show deprecated packages"}
609
- tabIndex={chipTabIndex}
610
- onKeyDown={onChipKeyDown}
611
- onClick={() => setShowDeprecated((on) => !on)}
612
- >
613
- deprecated
614
- </button>
615
- )}
616
1124
  </div>
617
1125
 
618
1126
  {shown.length === 0 ? (
619
1127
  <p class="empty">No packages match.</p>
1128
+ ) : view === "table" ? (
1129
+ <PackageTable
1130
+ packages={shown}
1131
+ hasRatings={hasRatings}
1132
+ onKeyDown={onCardKeyDown}
1133
+ rootRef={gridRef}
1134
+ />
620
1135
  ) : (
621
- <ul class="grid" ref={gridRef}>
1136
+ <ul
1137
+ class="grid"
1138
+ ref={(el) => {
1139
+ gridRef.current = el;
1140
+ }}
1141
+ >
622
1142
  {shown.map((p) => (
623
- // One Tab stop per card, in DOM order — which the grid lays out
624
- // left to right, top to bottom. Every control inside is taken
625
- // out of the sequence (`tabindex={-1}`) so tabbing crosses the
626
- // catalog instead of wading through it; arrow keys move by row
627
- // and column, and Enter opens the detail page, which carries the
628
- // same install commands the card's buttons do.
629
- <li
1143
+ <PackageCard
630
1144
  key={`${p.namespace}/${p.name}`}
631
- class="card"
632
- data-slot="package-card"
633
- tabIndex={0}
1145
+ pkg={p}
1146
+ vscodeExtension={vscodeExtension}
1147
+ activeKeywords={keywords}
1148
+ onToggleKeyword={toggleKeyword}
634
1149
  onKeyDown={onCardKeyDown}
635
- >
636
- <div class="card-head">
637
- <CardLogo pkg={p} />
638
- <h2 data-slot="package-name">
639
- <a href={withBase(`/p/${p.namespace}/${p.name}/`)} tabIndex={-1}>
640
- {p.name}
641
- </a>
642
- </h2>
643
- {p.deprecated ? (
644
- <span class="badge deprecated" data-slot="package-kind">deprecated</span>
645
- ) : (
646
- <span class={`badge kind-${p.kind}`} data-slot="package-kind">{p.kind}</span>
647
- )}
648
- </div>
649
- <p class="namespace">{p.namespace}</p>
650
- {(p.version || p.license || lastUpdated(p) || p.rating) && (
651
- <div class="meta-row" data-slot="package-meta">
652
- {p.version && <span class="pill version">v{p.version}</span>}
653
- {p.license && <span class="pill license">{p.license}</span>}
654
- {/* A count and nothing more. The page is prerendered once
655
- for everyone, so it cannot know whether *you* voted —
656
- showing "not voted" to someone who has would be worse
657
- than showing nothing. */}
658
- {p.rating && (
659
- <span
660
- class="pill rating"
661
- title={`${p.rating.up} upvote${p.rating.up === 1 ? "" : "s"}`}
662
- >
663
- <ArrowBigUp size={13} aria-hidden="true" />
664
- {p.rating.up}
665
- </span>
666
- )}
667
- {/* The date the sidecar derived, not the artifact's own
668
- `created`: a package republished from the same commit
669
- keeps its date, and one with no commit date at all gets
670
- the day this index first saw its current digest. */}
671
- {(() => {
672
- const at = lastUpdated(p);
673
- return at && timeAgo(at) ? (
674
- <time class="updated" datetime={at} title={at}>
675
- updated {timeAgo(at)}
676
- </time>
677
- ) : null;
678
- })()}
679
- </div>
680
- )}
681
- {p.deprecated && (
682
- <p class="deprecated-strip">
683
- deprecated
684
- {p.replacedBy ? ` — replaced by ${p.replacedBy}` : ""}
685
- </p>
686
- )}
687
- {p.description && <p class="description">{p.description}</p>}
688
- {p.keywords && p.keywords.length > 0 && (
689
- <div class="keywords" data-slot="package-keywords">
690
- {p.keywords.slice(0, 5).map((kw) => (
691
- <button
692
- key={kw}
693
- type="button"
694
- class="chip keyword"
695
- tabIndex={-1}
696
- onClick={() => setQuery(kw)}
697
- >
698
- {kw}
699
- </button>
700
- ))}
701
- {p.keywords.length > 5 && (
702
- <span class="chip keyword overflow">
703
- +{p.keywords.length - 5}
704
- </span>
705
- )}
706
- </div>
707
- )}
708
- <div class="card-foot">
709
- <div class="copy-group">
710
- {/* Global first, matching the hero's scope picker — the two
711
- are the same choice in two places, so they lead with the
712
- same one. */}
713
- <CopyButton
714
- command={`grim add --global ${p.ref}`}
715
- variant="global"
716
- name={`global add for ${p.name}`}
717
- />
718
- <CopyButton command={`grim add ${p.ref}`} name={`project add for ${p.name}`} />
719
- {vscodeUrl(vscodeExtension, p.ref) && (
720
- <a
721
- class="copy vscode"
722
- href={vscodeUrl(vscodeExtension, p.ref)!}
723
- title="Open in VS Code"
724
- aria-label={`Open ${p.name} in VS Code`}
725
- tabIndex={-1}
726
- >
727
- <BrandMark path={mdiMicrosoftVisualStudioCode} />
728
- </a>
729
- )}
730
- </div>
731
- {p.repository && (
732
- <a
733
- class="source"
734
- href={p.repository}
735
- target="_blank"
736
- rel="noopener noreferrer"
737
- tabIndex={-1}
738
- >
739
- source
740
- </a>
741
- )}
742
- </div>
743
- </li>
1150
+ />
744
1151
  ))}
745
1152
  </ul>
746
1153
  )}