@grimoire-rs/indexer 0.4.4 → 0.5.1

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 (71) hide show
  1. package/CHANGELOG.md +270 -0
  2. package/NOTICE +30 -0
  3. package/README.md +76 -331
  4. package/dist/cli/dev.d.ts +14 -0
  5. package/dist/cli/dev.d.ts.map +1 -1
  6. package/dist/cli/dev.js +22 -1
  7. package/dist/cli/dev.js.map +1 -1
  8. package/dist/cli/init.d.ts.map +1 -1
  9. package/dist/cli/init.js +35 -4
  10. package/dist/cli/init.js.map +1 -1
  11. package/dist/cli/main.d.ts.map +1 -1
  12. package/dist/cli/main.js +5 -0
  13. package/dist/cli/main.js.map +1 -1
  14. package/dist/config.d.ts +107 -7
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +181 -36
  17. package/dist/config.js.map +1 -1
  18. package/dist/renderer/astro/components/CardLogo.d.ts +5 -0
  19. package/dist/renderer/astro/components/CardLogo.js +58 -0
  20. package/dist/renderer/astro/components/CardLogo.tsx +96 -0
  21. package/dist/renderer/astro/components/Catalog.d.ts +14 -1
  22. package/dist/renderer/astro/components/Catalog.js +573 -108
  23. package/dist/renderer/astro/components/Catalog.tsx +903 -349
  24. package/dist/renderer/astro/components/CodeBlock.astro +74 -0
  25. package/dist/renderer/astro/components/CommandBar.astro +66 -0
  26. package/dist/renderer/astro/components/CopyButton.d.ts +7 -0
  27. package/dist/renderer/astro/components/CopyButton.js +28 -0
  28. package/dist/renderer/astro/components/CopyButton.tsx +56 -0
  29. package/dist/renderer/astro/components/KindMark.d.ts +69 -0
  30. package/dist/renderer/astro/components/KindMark.js +66 -0
  31. package/dist/renderer/astro/components/KindMark.tsx +141 -0
  32. package/dist/renderer/astro/components/PackageCard.d.ts +18 -0
  33. package/dist/renderer/astro/components/PackageCard.js +50 -0
  34. package/dist/renderer/astro/components/PackageCard.tsx +273 -0
  35. package/dist/renderer/astro/components/PackageRow.d.ts +10 -0
  36. package/dist/renderer/astro/components/PackageRow.js +32 -0
  37. package/dist/renderer/astro/components/PackageRow.tsx +126 -0
  38. package/dist/renderer/astro/components/PickerMenu.astro +5 -14
  39. package/dist/renderer/astro/components/SiteFooter.astro +64 -0
  40. package/dist/renderer/astro/components/SiteHeader.astro +74 -0
  41. package/dist/renderer/astro/components/VersionMenu.astro +2 -2
  42. package/dist/renderer/astro/layouts/Base.astro +970 -227
  43. package/dist/renderer/astro/lib/base.d.ts +25 -0
  44. package/dist/renderer/astro/lib/base.js +23 -0
  45. package/dist/renderer/astro/lib/base.ts +27 -0
  46. package/dist/renderer/astro/lib/catalog.d.ts +24 -0
  47. package/dist/renderer/astro/lib/catalog.js +36 -0
  48. package/dist/renderer/astro/lib/catalog.ts +37 -0
  49. package/dist/renderer/astro/lib/code.d.ts +2 -2
  50. package/dist/renderer/astro/lib/code.js +2 -2
  51. package/dist/renderer/astro/lib/code.ts +2 -2
  52. package/dist/renderer/astro/lib/commands.d.ts +64 -0
  53. package/dist/renderer/astro/lib/commands.js +91 -0
  54. package/dist/renderer/astro/lib/commands.ts +133 -0
  55. package/dist/renderer/astro/lib/keywordRail.d.ts +44 -0
  56. package/dist/renderer/astro/lib/keywordRail.js +99 -0
  57. package/dist/renderer/astro/lib/keywordRail.ts +110 -0
  58. package/dist/renderer/astro/pages/index.astro +40 -87
  59. package/dist/renderer/astro/pages/p/[...slug].astro +349 -198
  60. package/dist/renderer/astro/styles/tokens.css +40 -5
  61. package/dist/renderer/index.d.ts +77 -0
  62. package/dist/renderer/index.d.ts.map +1 -1
  63. package/dist/renderer/index.js +565 -7
  64. package/dist/renderer/index.js.map +1 -1
  65. package/dist/renderer/types.d.ts +9 -0
  66. package/dist/renderer/types.d.ts.map +1 -1
  67. package/package.json +9 -4
  68. package/templates/README.md +6 -0
  69. package/templates/gitignore +4 -1
  70. package/templates/theme/README.md +38 -0
  71. package/templates/tsconfig.json +47 -0
@@ -1,12 +1,12 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
3
- // Lucide (ISC) draws the UI; brand marks come from `@mdi/js`, which Lucide
4
- // deliberately does not carry. No SVG on this site is hand-written.
5
- import { ArrowBigUp, Check, FolderRoot, Globe, Image, ImageOff } from "lucide-preact";
6
- import { mdiMicrosoftVisualStudioCode } from "@mdi/js";
7
- import { BrandMark } from "./BrandMark.js";
8
- import { withBase } from "../lib/base.js";
9
- import { lastUpdated, timeAgo, vscodeUrl } from "../lib/catalog.js";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, } from "preact/hooks";
3
+ // Lucide (ISC) draws the toolbar. The brand marks and kind glyphs moved out
4
+ // with the card and the row that wear them.
5
+ import { ArrowDownWideNarrow, ArrowUpNarrowWide, LayoutGrid, List, X, } from "lucide-preact";
6
+ import { PackageCard } from "./PackageCard.js";
7
+ import { PackageRow } from "./PackageRow.js";
8
+ import { keywordFrequency, selectRailKeywords } from "../lib/keywordRail.js";
9
+ import { lastUpdated } from "../lib/catalog.js";
10
10
  // Known kinds get stable chip ordering + badge colors; unknown kinds
11
11
  // (future schema growth) still render with a neutral badge.
12
12
  const KNOWN_KINDS = ["skill", "rule", "agent", "mcp", "bundle"];
@@ -14,6 +14,34 @@ function kindOrder(kind) {
14
14
  const i = KNOWN_KINDS.indexOf(kind);
15
15
  return i === -1 ? KNOWN_KINDS.length : i;
16
16
  }
17
+ /**
18
+ * How many keyword chips the rail shows at once, actives included.
19
+ *
20
+ * Everything past it goes behind the overflow menu. The cap is the point:
21
+ * this catalog's keyword vocabulary is open-ended, and a rail that renders
22
+ * all of it is a wall of chips nobody reads.
23
+ */
24
+ const KEYWORD_CHIP_LIMIT = 8;
25
+ /**
26
+ * `popovertarget` needs an id, and the catalog is a singleton on its page —
27
+ * one island, one toolbar, one overflow menu — so this is a constant rather
28
+ * than something generated per mount.
29
+ */
30
+ const KEYWORD_MENU_ID = "grim-keyword-overflow";
31
+ /**
32
+ * The direction each field is *worth* reading first in — A→Z for a name,
33
+ * newest and best-liked first for the two ranked keys.
34
+ *
35
+ * Picking a field selects its natural direction; the toggle beside the
36
+ * combo box reverses that. So "descending" is not a global default a reader
37
+ * has to correct on every mode, and the arrow always describes what the
38
+ * order actually is rather than which way a flag is set.
39
+ */
40
+ export const NATURAL = {
41
+ name: "asc",
42
+ updated: "desc",
43
+ rating: "desc",
44
+ };
17
45
  /**
18
46
  * Bigger first, with `null` as its own bucket underneath every number.
19
47
  *
@@ -38,7 +66,8 @@ function updatedAt(p) {
38
66
  * mode, and it is unique, so no two rows ever compare equal — a browse order
39
67
  * that is not total is a browse order that reshuffles on rebuild.
40
68
  */
41
- const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) || a.ref.localeCompare(b.ref);
69
+ const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) ||
70
+ a.ref.localeCompare(b.ref);
42
71
  /**
43
72
  * Newest first. No usable date is *unknown*, not epoch 0: dating an undated
44
73
  * package to 1970 sorts it below real packages by accident rather than by
@@ -62,11 +91,14 @@ const CHAINS = {
62
91
  // any other row when the toggle brings them back. grim's own browse order
63
92
  // (`browse_sort.rs`) has no deprecated key either; keeping this comparator
64
93
  // silent on deprecation is what keeps the two in sync.
65
- export function compare(a, b, sort) {
94
+ export function compare(a, b, sort, dir = NATURAL[sort]) {
66
95
  for (const key of CHAINS[sort]) {
67
96
  const d = key(a, b);
97
+ // Reversed means reversed all the way down, the ref tiebreak included.
98
+ // Every chain ends on a unique key, so no two rows compare equal and
99
+ // negating the whole answer leaves the order just as total as it was.
68
100
  if (d !== 0)
69
- return d;
101
+ return dir === NATURAL[sort] ? d : -d;
70
102
  }
71
103
  return 0;
72
104
  }
@@ -74,12 +106,56 @@ export function compare(a, b, sort) {
74
106
  // codicons so "project" and "global" read identically in both. That parity
75
107
  // is gone on purpose: every icon now comes from one set. `FolderRoot` and
76
108
  // `Globe` are the nearest Lucide equivalents and carry the same meaning.
109
+ /**
110
+ * The reader's own preferences, kept out of the URL.
111
+ *
112
+ * The split is deliberate and matches grim: `q`, `kind` and `kw` are *what
113
+ * you are looking at* — a keyword chip on a package page links to
114
+ * `/?kw=<keyword>`, so that half has to stay shareable — while sort,
115
+ * direction, deprecated visibility and the cards/table choice are *how you
116
+ * like the catalog arranged*, the same answer on every visit. grim keeps
117
+ * `show_deprecated` in its config file for exactly that reason.
118
+ *
119
+ * Both accessors swallow: reading `localStorage` throws outright, not
120
+ * returns null, in a browser set to block site data, and a catalog is not
121
+ * worth a blank page. A reader who blocks it browses without preferences.
122
+ */
123
+ const PREF = "grim.catalog.";
124
+ function readPref(key) {
125
+ try {
126
+ return localStorage.getItem(PREF + key);
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ function writePref(key, value) {
133
+ try {
134
+ if (value === null)
135
+ localStorage.removeItem(PREF + key);
136
+ else
137
+ localStorage.setItem(PREF + key, value);
138
+ }
139
+ catch {
140
+ // Nothing to do and nothing to report: preferences are a convenience.
141
+ }
142
+ }
143
+ /**
144
+ * A comma-joined URL parameter, back into the list it was.
145
+ *
146
+ * Empty and absent are the same answer — `?kind=` is a reader who cleared
147
+ * the filter, not a request for the kind named "". Duplicates collapse so a
148
+ * hand-edited `?kw=a,a` cannot render the same chip twice.
149
+ */
150
+ function list(value) {
151
+ return [...new Set((value ?? "").split(",").filter(Boolean))];
152
+ }
77
153
  /** Typing inside one of these means a bare keystroke is text, not a shortcut. */
78
154
  function isTyping(el) {
79
155
  const node = el;
80
156
  if (!node)
81
157
  return false;
82
- return node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName);
158
+ return (node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName));
83
159
  }
84
160
  /**
85
161
  * How many cards the grid actually laid out per row.
@@ -87,6 +163,10 @@ function isTyping(el) {
87
163
  * Measured, not read from CSS: the track list is `auto-fill` with a minimum
88
164
  * width, so the count is a layout outcome that depends on the viewport. The
89
165
  * first card whose top edge drops below the first row's starts row two.
166
+ *
167
+ * The table view needs no branch of its own — its rows stack, so the second
168
+ * one is already below the first and this measures the 1 that makes every
169
+ * arrow key move by a single row.
90
170
  */
91
171
  function columnCount(cards) {
92
172
  if (cards.length < 2)
@@ -96,69 +176,33 @@ function columnCount(cards) {
96
176
  return wrapped === -1 ? cards.length : wrapped;
97
177
  }
98
178
  /**
99
- * The card's 28px logo slot, in its three states.
179
+ * The same packages as a list, for reading down a column rather than across
180
+ * a grid.
181
+ *
182
+ * **A row is an anchor, and there is no `<table>`.** Two reasons, and the
183
+ * first is the load-bearing one: a header row that cannot sort is a header
184
+ * row that *looks* like it sorts — every reader who has met a data table
185
+ * clicks it once. Sorting lives in the toolbar, so the table has no headers,
186
+ * and a headerless table has no column semantics left to justify the element.
187
+ * What remains is a list of links, which is what this is. A CSS grid with
188
+ * `subgrid` rows keeps the columns aligned without the markup.
100
189
  *
101
- * The third one is the reason this is a component rather than inline JSX: a
102
- * package can declare a `logo` whose file is not actually served — the
103
- * enrich step failed, the asset was pruned, the path is stale and the
104
- * browser's own broken-image glyph is both ugly and says nothing. So a
105
- * declared-but-unreachable logo degrades to a marked placeholder, which is
106
- * deliberately *not* the same as the initial-letter tile a package with no
107
- * logo at all gets: one is a fault worth seeing, the other is normal.
190
+ * The anchor is also what makes the whole row clickable, focusable and
191
+ * middle-clickable for free no stretched-link overlay, no synthetic Enter
192
+ * handler. A row carries no controls of its own: the install buttons and the
193
+ * vote links are what the card exists for, and repeating them per row would
194
+ * be five columns of icons. The detail page has all of them.
108
195
  *
109
- * The static detail page needs the same treatment but cannot use `onError`,
110
- * so it opts into the global handler in `Base.astro` instead keep the two
111
- * placeholders looking alike.
196
+ * Columns are fixed, unlike the keyword rail above deliberately. A column
197
+ * is a slot the eye tracks down; one that appears and disappears as the
198
+ * filters change destroys the alignment the table exists to give. The rating
199
+ * column is the one exception, and it is decided once per index rather than
200
+ * per filter.
112
201
  */
113
- function CardLogo({ pkg }) {
114
- const [state, setState] = useState("loading");
115
- const imgRef = useRef(null);
116
- // The image is server-rendered, so the browser begins fetching it while
117
- // parsing the HTML — long before this island hydrates. Two consequences,
118
- // and the slot markup below answers both: `onError` can fire before any
119
- // listener exists (the placeholder used to appear only sometimes), and a
120
- // failed image paints the browser's broken glyph on the way (the flash on
121
- // reload). Starting the image hidden means nothing is ever shown until it
122
- // is known to be good.
123
- //
124
- // `complete` says the browser finished, not how it went. `decode()` is
125
- // what separates the two: it rejects for a failure and resolves for a good
126
- // image — including an SVG with no intrinsic size, where the usual
127
- // `naturalWidth === 0` test reports a false failure. Gating on `complete`
128
- // means it never starts a fetch, so `loading="lazy"` still holds off
129
- // -screen cards.
130
- useEffect(() => {
131
- setState("loading");
132
- const img = imgRef.current;
133
- if (!img?.complete)
134
- return;
135
- let live = true;
136
- img.decode().then(() => live && setState("ready"), () => live && setState("broken"));
137
- return () => {
138
- live = false;
139
- };
140
- }, [pkg.logo]);
141
- if (!pkg.logo) {
142
- return (_jsx("span", { class: "card-logo card-logo-fallback", "aria-hidden": "true", style: { background: `var(--grim-color-kind-${pkg.kind}, var(--grim-color-muted))` }, children: pkg.name[0]?.toUpperCase() }));
143
- }
144
- return (_jsxs("span", { class: "card-logo logo-slot", "data-state": state, role: state === "broken" ? "img" : undefined, "aria-label": state === "broken" ? "Logo image unavailable" : undefined, title: state === "broken" ? "Logo image unavailable" : undefined, children: [state === "broken" ? (_jsx(ImageOff, { class: "logo-mark", "aria-hidden": "true" })) : (_jsx(Image, { class: "logo-mark", "aria-hidden": "true" })), _jsx("img", { ref: imgRef, src: withBase(pkg.logo), alt: "", loading: "lazy", onLoad: () => setState("ready"), onError: () => setState("broken") })] }));
145
- }
146
- function CopyButton({ command, variant = "default", name, }) {
147
- const [copied, setCopied] = useState(false);
148
- const copy = () => {
149
- navigator.clipboard.writeText(command).then(() => {
150
- setCopied(true);
151
- // The toast lives in Base.astro's inline script, outside this island —
152
- // an event is how a hydrated component reaches it without either side
153
- // importing the other.
154
- document.dispatchEvent(new CustomEvent("grimoire:copied", { detail: { name, value: command } }));
155
- setTimeout(() => setCopied(false), 1500);
156
- });
157
- };
158
- return (_jsx("button", { type: "button", class: copied ? "copy copied" : "copy", title: command, "aria-label": `Copy: ${command}`,
159
- // Out of the Tab sequence: the card is the stop, and the same command
160
- // is copyable from the detail page Enter opens.
161
- tabIndex: -1, onClick: copy, children: copied ? _jsx(Check, { size: 14 }) : variant === "global" ? _jsx(Globe, { size: 14 }) : _jsx(FolderRoot, { size: 14 }) }));
202
+ function PackageTable({ packages, hasRatings, onKeyDown, rootRef, }) {
203
+ return (_jsx("div", { class: hasRatings ? "table rated" : "table", "data-slot": "package-table", ref: (el) => {
204
+ rootRef.current = el;
205
+ }, children: packages.map((p) => (_jsx(PackageRow, { pkg: p, hasRatings: hasRatings, onKeyDown: onKeyDown }, `${p.namespace}/${p.name}`))) }));
162
206
  }
163
207
  // `vscodeExtension` arrives as a prop, not from lib/data: this island
164
208
  // hydrates in the browser, so importing the build-time payload here would
@@ -179,19 +223,222 @@ export default function Catalog({ packages, vscodeExtension, }) {
179
223
  // Whether the URL's query has been applied. Gates the reveal below, so the
180
224
  // catalog is never unhidden while it still shows the unfiltered list.
181
225
  const [seeded, setSeeded] = useState(false);
182
- const [kind, setKind] = useState(null);
226
+ // Kinds combine with OR, keywords with AND, and the two groups with each
227
+ // other. That is not an inconsistency, it follows from the data: a package
228
+ // has exactly one kind, so requiring both of two kinds always yields
229
+ // nothing, while it carries many keywords, so requiring both of two is the
230
+ // only reading under which a second click narrows. A facet whose second
231
+ // click *widens* the result set reads as broken.
232
+ const [kinds, setKinds] = useState([]);
233
+ const [keywords, setKeywords] = useState([]);
183
234
  const [sort, setSort] = useState("name");
235
+ // Direction, not "reversed": what the arrow draws is the order itself.
236
+ const [dir, setDir] = useState(NATURAL.name);
184
237
  // Deprecated packages are hidden until asked for: a retired package is
185
238
  // noise for someone browsing what to install, and the publisher already
186
239
  // said as much by deprecating it.
187
240
  const [showDeprecated, setShowDeprecated] = useState(false);
241
+ const [view, setView] = useState("cards");
242
+ // Local to the overflow menu and deliberately not shareable: it narrows
243
+ // the list of keywords, not the catalog.
244
+ const [keywordFilter, setKeywordFilter] = useState("");
188
245
  const searchRef = useRef(null);
189
246
  const gridRef = useRef(null);
190
247
  const controlsRef = useRef(null);
191
- const cardsOf = () => [...(gridRef.current?.querySelectorAll("li.card") ?? [])];
248
+ // Both views, one selector: a table row is the Tab stop its card is, so
249
+ // every keyboard path below — the search hatch, ArrowDown out of the
250
+ // chips, Escape's blur — works in either without knowing which is up.
251
+ const cardsOf = () => [
252
+ ...(gridRef.current?.querySelectorAll("li.card, a.row") ?? []),
253
+ ];
254
+ // Clipped keyword chips are excluded: they are drawn as nothing, so an
255
+ // arrow key that landed on one would move focus somewhere the reader
256
+ // cannot see it.
192
257
  const chipsOf = () => [
193
- ...(controlsRef.current?.querySelectorAll("button.chip") ?? []),
258
+ ...(controlsRef.current?.querySelectorAll('button.chip:not([aria-hidden="true"])') ?? []),
194
259
  ];
260
+ const railRefs = useRef(new Map());
261
+ const railRects = useRef(new Map());
262
+ const railRef = useRef(null);
263
+ const kwMenuRef = useRef(null);
264
+ const kwTriggerRef = useRef(null);
265
+ // Drives the trigger's own `aria-expanded` and its open styling. The panel's
266
+ // visibility is the popover's business, not this flag's.
267
+ const [kwMenuOpen, setKwMenuOpen] = useState(false);
268
+ /**
269
+ * Seat the overflow menu under its trigger.
270
+ *
271
+ * The panel is a popover, so it lives in the top layer and is positioned
272
+ * against the viewport rather than against any ancestor — which is the whole
273
+ * point (`.filter-row` is a scroll container and used to crop it). That
274
+ * leaves the seat to us.
275
+ *
276
+ * It always opens downward: the toolbar sits at the top of the page, and a
277
+ * flip would only ever fire on a viewport short enough that the panel has
278
+ * nowhere to go either way. What does not fit becomes `max-height` and
279
+ * scrolls inside the list.
280
+ *
281
+ * Called twice per open, from `beforetoggle` and again from `toggle`. The
282
+ * first runs while the panel is still `display: none`, so its measured width
283
+ * is 0 and the horizontal clamp is a no-op — but the vertical seat is right,
284
+ * which is what stops it appearing in the wrong place for a frame. The
285
+ * second has a real width and finishes the clamp.
286
+ */
287
+ const placeKwMenu = () => {
288
+ const panel = kwMenuRef.current;
289
+ const trigger = kwTriggerRef.current;
290
+ if (!panel || !trigger)
291
+ return;
292
+ const seat = trigger.getBoundingClientRect();
293
+ const gap = 8;
294
+ const width = panel.getBoundingClientRect().width;
295
+ panel.style.left = `${Math.max(gap, Math.min(seat.left, window.innerWidth - width - gap))}px`;
296
+ panel.style.top = `${seat.bottom + gap}px`;
297
+ panel.style.maxHeight = `${Math.max(120, window.innerHeight - seat.bottom - gap * 3)}px`;
298
+ };
299
+ // A fixed panel does not travel with the trigger, so it is re-seated rather
300
+ // than left behind. Only while open — there is nothing to follow otherwise.
301
+ useEffect(() => {
302
+ if (!kwMenuOpen)
303
+ return;
304
+ const reseat = () => placeKwMenu();
305
+ // Capturing: the scroll may be any ancestor's, including `.filter-row`'s.
306
+ window.addEventListener("scroll", reseat, { capture: true, passive: true });
307
+ window.addEventListener("resize", reseat);
308
+ return () => {
309
+ window.removeEventListener("scroll", reseat, { capture: true });
310
+ window.removeEventListener("resize", reseat);
311
+ };
312
+ }, [kwMenuOpen]);
313
+ /**
314
+ * How many keyword chips actually fit on the row, measured.
315
+ *
316
+ * Not a constant, because the answer is a layout outcome: the rail is the
317
+ * one flexible child of the filter row, so its width is whatever the kinds,
318
+ * the overflow menu and the deprecated toggle left, and the chips are as
319
+ * wide as the words publishers wrote. `KEYWORD_CHIP_LIMIT` bounds how many
320
+ * are *offered*; this is how many are shown.
321
+ *
322
+ * The rule it enforces: the rail never wraps and never scrolls. A second
323
+ * row makes the toolbar a different height on every filter click, and a
324
+ * scrollbar hides the chips behind a gesture nobody looks for — it also
325
+ * pushed the overflow menu off the end of the row entirely.
326
+ *
327
+ * Every chip stays in the flow whatever this says; the ones past it are
328
+ * drawn as nothing (see `.chip.kw.clipped`). Taking them out of the flow
329
+ * would free the width that excluded them, which is a measurement that
330
+ * disagrees with itself on every other frame.
331
+ */
332
+ const [railFit, setRailFit] = useState(KEYWORD_CHIP_LIMIT);
333
+ useLayoutEffect(() => {
334
+ const rail = railRef.current;
335
+ if (!rail)
336
+ return;
337
+ const measure = () => {
338
+ const edge = rail.getBoundingClientRect().right;
339
+ let fits = 0;
340
+ for (const chip of rail.children) {
341
+ // Half a pixel of slack: a fractional layout can leave a chip's right
342
+ // edge a rounding error past a boundary it visually sits inside.
343
+ if (chip.getBoundingClientRect().right > edge + 0.5)
344
+ break;
345
+ fits += 1;
346
+ }
347
+ // At least one, always. A rail too narrow for its shortest chip should
348
+ // show that chip clipped rather than render an empty group beside a
349
+ // divider that then divides nothing.
350
+ setRailFit(Math.max(1, fits));
351
+ };
352
+ measure();
353
+ // Guarded rather than assumed: this effect also runs under the test
354
+ // renderer, whose DOM has no `ResizeObserver` — and a missing one costs
355
+ // only re-measurement on viewport resize, which is not worth throwing
356
+ // during a render over.
357
+ if (typeof ResizeObserver === "undefined")
358
+ return;
359
+ const observer = new ResizeObserver(measure);
360
+ observer.observe(rail);
361
+ return () => observer.disconnect();
362
+ // Re-measured on every commit that changes which chips are up, since the
363
+ // observer only fires when the rail's own box changes and a rescore can
364
+ // swap a short word for a long one at the same width.
365
+ });
366
+ /**
367
+ * FLIP for the keyword rail: chips slide to their new places instead of
368
+ * teleporting.
369
+ *
370
+ * The rail is rescored against the current result set, so it reorders on
371
+ * every click — the chip just picked moves to the front and the rest flow
372
+ * around it. Animating that is not decoration: a rail whose contents change
373
+ * between two frames reads as having been *replaced*, and a reader who
374
+ * cannot see that a chip moved has no reason to believe it is the same one.
375
+ *
376
+ * First (the map of rects kept from the last commit), Last (measured now),
377
+ * Invert (an inline translate back to where the chip was), Play (dropped on
378
+ * the next frame, so the stylesheet's transition carries it home). Measure
379
+ * every chip before transforming any: `translate` composites and does not
380
+ * reflow, but reading a rect after writing a style on a sibling is the
381
+ * shape that makes a layout thrash, and this runs per keystroke.
382
+ */
383
+ useLayoutEffect(() => {
384
+ // Undo whatever the last pass left on the chips before measuring
385
+ // anything. Two reasons, and both were visible: `getBoundingClientRect`
386
+ // reports the *translated* box, so a chip caught mid-slide would be
387
+ // measured where it is drawn rather than where it belongs and the next
388
+ // inversion would compound that error; and a chip whose play frame never
389
+ // ran is still carrying `transition: none` with an offset, which is a chip
390
+ // frozen off its seat. Clearing here is what unfreezes it.
391
+ //
392
+ // Mid-slide is not a rare case: the fit measurement above commits a second
393
+ // time whenever the rescore changes how many chips fit, and that commit
394
+ // lands between this one and its animation frame.
395
+ for (const el of railRefs.current.values()) {
396
+ el.style.transition = "";
397
+ el.style.translate = "";
398
+ }
399
+ const previous = railRects.current;
400
+ const current = new Map();
401
+ const moved = [];
402
+ // A second pass, deliberately: every write above is flushed before the
403
+ // first read below, rather than interleaving them per chip.
404
+ for (const [keyword, el] of railRefs.current) {
405
+ const rect = el.getBoundingClientRect();
406
+ current.set(keyword, rect);
407
+ const was = previous.get(keyword);
408
+ if (!was)
409
+ continue;
410
+ const dx = was.left - rect.left;
411
+ const dy = was.top - rect.top;
412
+ if (dx !== 0 || dy !== 0)
413
+ moved.push({ el, dx, dy });
414
+ }
415
+ railRects.current = current;
416
+ if (moved.length === 0)
417
+ return;
418
+ for (const { el, dx, dy } of moved) {
419
+ el.style.transition = "none";
420
+ el.style.translate = `${dx}px ${dy}px`;
421
+ }
422
+ const frame = requestAnimationFrame(() => {
423
+ for (const { el } of moved) {
424
+ el.style.transition = "";
425
+ el.style.translate = "";
426
+ }
427
+ });
428
+ return () => {
429
+ cancelAnimationFrame(frame);
430
+ // Cancelling is not enough on its own. Nothing else takes these off, so
431
+ // a commit landing before the frame ran would leave every moved chip
432
+ // sitting at its inverted offset with transitions disabled — the rail
433
+ // stopping halfway and staying there. Deselecting the last keyword is
434
+ // the reliable way to see it: the rescore is at its largest, so the fit
435
+ // changes and the extra commit always lands.
436
+ for (const { el } of moved) {
437
+ el.style.transition = "";
438
+ el.style.translate = "";
439
+ }
440
+ };
441
+ });
195
442
  /** Move focus `delta` cards along, clamping at both ends rather than wrapping. */
196
443
  const focusCard = (from, delta) => {
197
444
  const cards = cardsOf();
@@ -218,17 +465,90 @@ export default function Catalog({ packages, vscodeExtension, }) {
218
465
  input.select();
219
466
  input.scrollIntoView({
220
467
  block: "start",
221
- behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
468
+ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches
469
+ ? "auto"
470
+ : "smooth",
222
471
  });
223
472
  };
224
- // Apply `?q=…`, now that hydration has matched the server's markup and
225
- // Preact owns the tree. A layout effect rather than a plain one: the
473
+ /**
474
+ * Put the reader's view into state the query from the URL, the
475
+ * preferences from storage. Neither half was there before, and the missing
476
+ * preference half is what made a deprecated package unreachable by Back:
477
+ * you turned the toggle on, opened the package, came back, and the
478
+ * remounted catalog knew nothing about it, so the card you had just been
479
+ * looking at was hidden again.
480
+ *
481
+ * Unknown values are dropped rather than trusted at both doors: `kind`
482
+ * reaches a class name, `kw` reaches a chip that stays on screen until it
483
+ * is clicked off, and `sort` selects a comparator, so none of them follows
484
+ * a hand-typed URL or a hand-edited storage entry anywhere the controls
485
+ * cannot go. `kw` is checked against the catalog's own vocabulary rather
486
+ * than a fixed list, since keywords are whatever publishers wrote.
487
+ */
488
+ const applyView = () => {
489
+ const params = new URLSearchParams(location.search);
490
+ const s = readPref("sort");
491
+ const d = readPref("dir");
492
+ const v = readPref("view");
493
+ const field = s === "updated" || s === "rating" ? s : "name";
494
+ const published = new Set(packages.flatMap((p) => p.keywords ?? []));
495
+ setQuery(params.get("q") ?? "");
496
+ setKinds(list(params.get("kind")).filter((k) => KNOWN_KINDS.includes(k)));
497
+ setKeywords(list(params.get("kw")).filter((k) => published.has(k)));
498
+ setSort(field);
499
+ setDir(d === "asc" || d === "desc" ? d : NATURAL[field]);
500
+ // A flag: stored at all means on.
501
+ setShowDeprecated(readPref("deprecated") !== null);
502
+ setView(v === "table" ? "table" : "cards");
503
+ };
504
+ // Apply the URL's view, now that hydration has matched the server's markup
505
+ // and Preact owns the tree. A layout effect rather than a plain one: the
226
506
  // resulting render must land before the browser paints, or a `?q=` visitor
227
507
  // sees the whole catalog flash past on the way to their results.
228
508
  useLayoutEffect(() => {
229
- setQuery(new URLSearchParams(location.search).get("q") ?? "");
509
+ applyView();
230
510
  setSeeded(true);
231
511
  }, []);
512
+ // Back and Forward within the catalog — a keyword chip on a package page
513
+ // links to `/?q=…`, so the reader can land here more than once without a
514
+ // reload, and `popstate` is the only notice of it.
515
+ useEffect(() => {
516
+ const onPop = () => applyView();
517
+ addEventListener("popstate", onPop);
518
+ return () => removeEventListener("popstate", onPop);
519
+ }, []);
520
+ // The query, into the URL — so it can be shared, and so Back lands on the
521
+ // search the reader left. `replaceState`, not `pushState`: a history entry
522
+ // per keystroke would make Back mean "undo one letter" rather than "the
523
+ // page I came from". Gated on `seeded`, since writing before the URL has
524
+ // been read would erase a deep link on arrival.
525
+ useEffect(() => {
526
+ if (!seeded)
527
+ return;
528
+ const params = new URLSearchParams(location.search);
529
+ const set = (key, value) => value === null ? params.delete(key) : params.set(key, value);
530
+ set("q", query || null);
531
+ set("kind", kinds.length > 0 ? kinds.join(",") : null);
532
+ set("kw", keywords.length > 0 ? keywords.join(",") : null);
533
+ const search = params.toString();
534
+ const next = `${location.pathname}${search ? `?${search}` : ""}${location.hash}`;
535
+ if (next !== `${location.pathname}${location.search}${location.hash}`) {
536
+ history.replaceState(history.state, "", next);
537
+ }
538
+ // `keywords` is compared by identity, which is what we want: the array is
539
+ // replaced on every toggle and never mutated in place.
540
+ }, [seeded, query, kinds, keywords]);
541
+ // The preferences, into storage — so the next visit opens the way this one
542
+ // ended. Each is stored only when it is not the default, so a reader who
543
+ // never touched a control leaves nothing behind.
544
+ useEffect(() => {
545
+ if (!seeded)
546
+ return;
547
+ writePref("sort", sort === "name" ? null : sort);
548
+ writePref("dir", dir === NATURAL[sort] ? null : dir);
549
+ writePref("deprecated", showDeprecated ? "1" : null);
550
+ writePref("view", view === "cards" ? null : view);
551
+ }, [seeded, sort, dir, showDeprecated, view]);
232
552
  // Base.astro hides the catalog before first paint when the URL carries a
233
553
  // query. Reveal it only once the filtered render is in the DOM — keyed on
234
554
  // `seeded`, so the unfiltered first render is never the one revealed.
@@ -282,28 +602,38 @@ export default function Catalog({ packages, vscodeExtension, }) {
282
602
  const active = document.activeElement;
283
603
  // Also true for a control *inside* a card, which is still the card
284
604
  // being selected as far as the reader is concerned.
285
- const card = active instanceof HTMLElement ? active.closest("li.card") : null;
286
- if (!query && kind === null && !card)
287
- return; // nothing selected: not our key
605
+ const card = active instanceof HTMLElement ? active.closest("li.card, a.row") : null;
606
+ // Nothing selected: not our key.
607
+ if (!query && kinds.length === 0 && keywords.length === 0 && !card)
608
+ return;
288
609
  event.preventDefault();
289
610
  setQuery("");
290
- setKind(null);
611
+ setKinds([]);
612
+ setKeywords([]);
291
613
  if (card)
292
614
  active.blur();
293
615
  };
294
616
  document.addEventListener("keydown", onEscape);
295
617
  return () => document.removeEventListener("keydown", onEscape);
296
- }, [query, kind]);
618
+ }, [query, kinds, keywords]);
619
+ /** Both facets toggle the same way; only the relation between values differs. */
620
+ const toggle = (set) => (value) => set((was) => was.includes(value) ? was.filter((v) => v !== value) : [...was, value]);
621
+ const toggleKind = toggle(setKinds);
622
+ // Appends rather than inserts, so the pinned chips below stay in the order
623
+ // they were picked — the rail reorders underneath them, the actives do not.
624
+ const toggleKeyword = toggle(setKeywords);
297
625
  /**
298
- * The filter and sort chips are not Tab stops Tab is reserved for
299
- * crossing the catalog, so it runs search card card. The chips sit in
300
- * the row above the grid, so they are reached the way that row is:
301
- * ArrowUp out of the top card row, ArrowDown back into it.
626
+ * Arrow keys move *across* a rail the reader is already standing in. They
627
+ * are not Tab's replacement, and this is the correction of a real defect:
628
+ * the chips used to carry `tabIndex={-1}` whenever the grid had anything
629
+ * in it, which left filtering reachable by pointer and arrow key only.
630
+ * That is a WCAG 2.1.1 (A) failure — every control has to be operable from
631
+ * the keyboard through the ordinary sequence, and an undocumented arrow
632
+ * convention is not that sequence. The sibling `@ocx-sh/catalog` renderer
633
+ * shipped the same shortcut and reverted it for the same reason.
302
634
  *
303
- * The one case that would strand them is an empty result set, where there
304
- * is no card to arrow up from so with nothing shown they rejoin the Tab
305
- * order (see `chipTabIndex` below), which is also exactly when a reader
306
- * needs them most.
635
+ * So the chips are ordinary Tab stops now, and ArrowUp/ArrowDown remain as
636
+ * the faster way to cross a long rail or drop back into the grid.
307
637
  */
308
638
  const onChipKeyDown = (event) => {
309
639
  const chips = chipsOf();
@@ -334,7 +664,33 @@ export default function Catalog({ packages, vscodeExtension, }) {
334
664
  event.preventDefault();
335
665
  cardsOf()[0]?.focus();
336
666
  }
337
- else if (event.key === "Escape" && !query && kind === null) {
667
+ else if (event.key === "Tab" &&
668
+ !event.shiftKey &&
669
+ !event.metaKey &&
670
+ !event.ctrlKey &&
671
+ !event.altKey) {
672
+ // The hatch. Everything between the field and the grid — sort, the
673
+ // view toggle, every chip — sits after it in the DOM and is a real Tab
674
+ // stop again, so plain Tab would walk the whole toolbar before
675
+ // reaching a single package. Forward Tab skips to the results; the
676
+ // toolbar stays reachable by Shift+Tab back out of the grid.
677
+ //
678
+ // Reordering the DOM instead would have put focus order at odds with
679
+ // visual order, which is the worse defect of the two.
680
+ //
681
+ // With nothing to jump to — an empty result set — Tab is left alone
682
+ // rather than swallowed: trapping focus in the field is worse than the
683
+ // walk it was meant to save.
684
+ const first = cardsOf()[0];
685
+ if (!first)
686
+ return;
687
+ event.preventDefault();
688
+ first.focus();
689
+ }
690
+ else if (event.key === "Escape" &&
691
+ !query &&
692
+ kinds.length === 0 &&
693
+ keywords.length === 0) {
338
694
  // Clearing is the document handler's job; this is only the second
339
695
  // press, once there is nothing left to clear — so Escape leaves the
340
696
  // field rather than being a dead key.
@@ -382,6 +738,11 @@ export default function Catalog({ packages, vscodeExtension, }) {
382
738
  // mouse must keep its own Space/Enter behaviour.
383
739
  if (event.target !== card)
384
740
  return;
741
+ // A table row *is* an anchor, so Enter is the browser's to handle —
742
+ // swallowing it here would break activation rather than provide it.
743
+ // Only the card needs its title link clicked on its behalf.
744
+ if (card instanceof HTMLAnchorElement)
745
+ return;
385
746
  event.preventDefault();
386
747
  card.querySelector("h2 a")?.click();
387
748
  return;
@@ -393,18 +754,24 @@ export default function Catalog({ packages, vscodeExtension, }) {
393
754
  // deprecated entries drop out of those totals too while they are hidden,
394
755
  // so no count ever promises more than the grid shows.
395
756
  const counted = useMemo(() => (showDeprecated ? packages : packages.filter((p) => !p.deprecated)), [packages, showDeprecated]);
396
- const kinds = useMemo(() => {
397
- const counts = new Map();
398
- for (const p of counted)
399
- counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
400
- return [...counts.entries()].sort((a, b) => kindOrder(a[0]) - kindOrder(b[0]) || a[0].localeCompare(b[0]));
757
+ // Which kinds this catalog publishes, in chip order. No counts on the
758
+ // chips: they cost every chip the width of a number, which is width the
759
+ // keyword rail beside them needs more, and the meta row already states how
760
+ // many packages the filters left. A per-chip count is also the harder one
761
+ // to read honestly — kinds are an OR group, so a count taken after the
762
+ // filter says "3" about a chip that is about to reveal thirty.
763
+ const kindNames = useMemo(() => {
764
+ const seen = new Set(counted.map((p) => p.kind));
765
+ return [...seen].sort((a, b) => kindOrder(a) - kindOrder(b) || a.localeCompare(b));
401
766
  }, [counted]);
402
767
  const q = query.trim().toLowerCase();
403
- // Query and kind first, deprecation last — so the toggle can report how
768
+ // Query and facets first, deprecation last — so the toggle can report how
404
769
  // many entries *it alone* is holding back, rather than a catalog-wide
405
770
  // number that has nothing to do with what is on screen.
406
771
  const matching = packages.filter((p) => {
407
- if (kind && p.kind !== kind)
772
+ if (kinds.length > 0 && !kinds.includes(p.kind))
773
+ return false;
774
+ if (!keywords.every((kw) => p.keywords?.includes(kw)))
408
775
  return false;
409
776
  if (!q)
410
777
  return true;
@@ -418,16 +785,114 @@ export default function Catalog({ packages, vscodeExtension, }) {
418
785
  (p.keywords ?? []).join(" "),
419
786
  ].some((field) => field.toLowerCase().includes(q));
420
787
  });
421
- const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) => compare(a, b, sort));
788
+ const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) => compare(a, b, sort, dir));
789
+ /**
790
+ * The keyword rail, over what is on screen rather than over the catalog.
791
+ *
792
+ * Two decisions, both borrowed from `@ocx-sh/catalog` and both load-bearing:
793
+ *
794
+ * Active keywords are **pinned**, first and in the order they were clicked,
795
+ * and never scored. A filter that scrolls out of the rail is a filter the
796
+ * reader cannot lift. Their count is `shown.length` by construction — under
797
+ * AND, every surviving package carries every active keyword.
798
+ *
799
+ * The rest are picked by splitting power over `shown`, not by frequency
800
+ * over `packages`. A rail scored against the whole catalog keeps offering
801
+ * keywords no surviving package carries, and under AND that is most of
802
+ * them — every such chip is one click to an empty grid.
803
+ *
804
+ * The cost, accepted: the rail's contents move as the reader filters, which
805
+ * is what the FLIP effect above animates. The set changing invisibly is
806
+ * what would read as broken.
807
+ */
808
+ const pinned = keywords.map((keyword) => ({
809
+ keyword,
810
+ count: shown.length,
811
+ }));
812
+ const rail = selectRailKeywords(shown, KEYWORD_CHIP_LIMIT)
813
+ // `selectRailKeywords` scores the actives like any other keyword, so
814
+ // over-request and drop them rather than spend rail slots twice.
815
+ .filter((k) => !keywords.includes(k.keyword))
816
+ .slice(0, Math.max(0, KEYWORD_CHIP_LIMIT - pinned.length));
817
+ const visibleKeywords = [...pinned, ...rail];
818
+ // What the menu has to carry: everything the rail had no slot for, plus
819
+ // everything it has a slot for but no ROOM for. The second half is why the
820
+ // menu is built from `railFit` rather than from `KEYWORD_CHIP_LIMIT` — a
821
+ // chip clipped at the rail's edge is one the reader cannot reach anywhere
822
+ // else, and a "+N more" that does not count it is lying about where it is.
823
+ const clippedKeywords = visibleKeywords.slice(railFit).map((k) => k.keyword);
824
+ const menuKeywords = keywordFrequency(shown).filter((k) => clippedKeywords.includes(k.keyword) ||
825
+ !visibleKeywords.some((v) => v.keyword === k.keyword));
826
+ // Plain substring, not a fuzzy match: this searches a list the reader is
827
+ // looking at, and every entry in it is one short known word.
828
+ const menuShown = menuKeywords.filter((k) => k.keyword.toLowerCase().includes(keywordFilter.trim().toLowerCase()));
422
829
  // A catalog with nothing deprecated gets no toggle — a control that can
423
830
  // only ever be a no-op is worse than its absence. An index that publishes
424
831
  // no ratings gets no rating chip for the same reason.
425
832
  const hasDeprecated = packages.some((p) => p.deprecated);
426
833
  const hasRatings = packages.some((p) => p.rating);
427
- // Chips leave the Tab order only while there is a grid to arrow up from.
428
- const chipTabIndex = shown.length === 0 ? 0 : -1;
429
- return (_jsxs("section", { class: "catalog", "data-slot": "catalog", children: [_jsxs("div", { class: "controls", "data-slot": "catalog-toolbar", ref: controlsRef, children: [_jsxs("div", { class: "search-field", "data-slot": "catalog-search", children: [_jsx("input", { ref: searchRef, type: "search", placeholder: `Search ${counted.length} packages…`, value: query, onInput: (e) => setQuery(e.target.value), onKeyDown: onSearchKeyDown, "aria-label": "Search packages", "aria-keyshortcuts": "/" }), _jsx("kbd", { class: "search-hint", "aria-hidden": "true", children: "/" })] }), _jsxs("div", { class: "chips", role: "group", "aria-label": "Sort by", children: [_jsx("button", { type: "button", class: sort === "name" ? "chip active" : "chip", "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("name"), children: "name" }), _jsx("button", { type: "button", class: sort === "updated" ? "chip active" : "chip", "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("updated"), children: "updated" }), hasRatings && (_jsx("button", { type: "button", class: sort === "rating" ? "chip active" : "chip", "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("rating"), children: "rating" }))] }), _jsx("span", { class: "chip-sep", "aria-hidden": "true" }), _jsxs("div", { class: "chips", role: "group", "aria-label": "Filter by kind", children: [_jsxs("button", { type: "button", class: kind === null ? "chip active" : "chip", "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setKind(null), children: ["all ", _jsx("small", { children: counted.length })] }), kinds.map(([k, count]) => (_jsxs("button", { type: "button", class: kind === k ? `chip active kind-${k}` : `chip kind-${k}`, "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setKind(kind === k ? null : k), children: [k, " ", _jsx("small", { children: count })] }, k)))] }), hasDeprecated && (_jsx("button", { type: "button", class: showDeprecated ? "chip deprecated-toggle active" : "chip deprecated-toggle", "aria-pressed": showDeprecated, title: showDeprecated ? "Hide deprecated packages" : "Show deprecated packages", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setShowDeprecated((on) => !on), children: "deprecated" }))] }), shown.length === 0 ? (_jsx("p", { class: "empty", children: "No packages match." })) : (_jsx("ul", { class: "grid", ref: gridRef, children: shown.map((p) => (_jsxs("li", { class: "card", "data-slot": "package-card", tabIndex: 0, onKeyDown: onCardKeyDown, children: [_jsxs("div", { class: "card-head", children: [_jsx(CardLogo, { pkg: p }), _jsx("h2", { "data-slot": "package-name", children: _jsx("a", { href: withBase(`/p/${p.namespace}/${p.name}/`), tabIndex: -1, children: p.name }) }), p.deprecated ? (_jsx("span", { class: "badge deprecated", "data-slot": "package-kind", children: "deprecated" })) : (_jsx("span", { class: `badge kind-${p.kind}`, "data-slot": "package-kind", children: p.kind }))] }), _jsx("p", { class: "namespace", children: p.namespace }), (p.version || p.license || lastUpdated(p) || p.rating) && (_jsxs("div", { class: "meta-row", "data-slot": "package-meta", children: [p.version && _jsxs("span", { class: "pill version", children: ["v", p.version] }), p.license && _jsx("span", { class: "pill license", children: p.license }), p.rating && (_jsxs("span", { class: "pill rating", title: `${p.rating.up} upvote${p.rating.up === 1 ? "" : "s"}`, children: [_jsx(ArrowBigUp, { size: 13, "aria-hidden": "true" }), p.rating.up] })), (() => {
430
- const at = lastUpdated(p);
431
- return at && timeAgo(at) ? (_jsxs("time", { class: "updated", datetime: at, title: at, children: ["updated ", timeAgo(at)] })) : null;
432
- })()] })), p.deprecated && (_jsxs("p", { class: "deprecated-strip", children: ["deprecated", p.replacedBy ? ` — replaced by ${p.replacedBy}` : ""] })), p.description && _jsx("p", { class: "description", children: p.description }), p.keywords && p.keywords.length > 0 && (_jsxs("div", { class: "keywords", "data-slot": "package-keywords", children: [p.keywords.slice(0, 5).map((kw) => (_jsx("button", { type: "button", class: "chip keyword", tabIndex: -1, onClick: () => setQuery(kw), children: kw }, kw))), p.keywords.length > 5 && (_jsxs("span", { class: "chip keyword overflow", children: ["+", p.keywords.length - 5] }))] })), _jsxs("div", { class: "card-foot", children: [_jsxs("div", { class: "copy-group", children: [_jsx(CopyButton, { command: `grim add --global ${p.ref}`, variant: "global", name: `global add for ${p.name}` }), _jsx(CopyButton, { command: `grim add ${p.ref}`, name: `project add for ${p.name}` }), vscodeUrl(vscodeExtension, p.ref) && (_jsx("a", { class: "copy vscode", href: vscodeUrl(vscodeExtension, p.ref), title: "Open in VS Code", "aria-label": `Open ${p.name} in VS Code`, tabIndex: -1, children: _jsx(BrandMark, { path: mdiMicrosoftVisualStudioCode }) }))] }), p.repository && (_jsx("a", { class: "source", href: p.repository, target: "_blank", rel: "noopener noreferrer", tabIndex: -1, children: "source" }))] })] }, `${p.namespace}/${p.name}`))) }))] }));
834
+ return (_jsxs("section", { class: "catalog", "data-slot": "catalog", children: [_jsxs("div", { class: "controls", "data-slot": "catalog-toolbar", ref: controlsRef, children: [_jsxs("div", { class: "search-field", "data-slot": "catalog-search", children: [_jsx("input", { ref: searchRef, type: "search", placeholder: "Search packages \u2014 name, keyword, description\u2026", value: query, onInput: (e) => setQuery(e.target.value), onKeyDown: onSearchKeyDown, "aria-label": "Search packages", "aria-keyshortcuts": "/" }), _jsx("kbd", { class: "search-hint", "aria-hidden": "true", children: "/" })] }), _jsxs("div", { class: "filter-row", children: [_jsxs("div", { class: "chips kind-chips", role: "group", "aria-label": "Filter by kind", children: [_jsx("button", { type: "button", class: kinds.length === 0 ? "chip active" : "chip", "data-slot": "filter-chip", "aria-pressed": kinds.length === 0, onKeyDown: onChipKeyDown, onClick: () => setKinds([]), children: "all" }), kindNames.map((k) => (_jsx("button", { type: "button", class: kinds.includes(k) ? `chip active kind-${k}` : `chip kind-${k}`, "data-slot": "filter-chip", "aria-pressed": kinds.includes(k), onKeyDown: onChipKeyDown, onClick: () => toggleKind(k), children: k }, k)))] }), visibleKeywords.length > 0 && (_jsxs(_Fragment, { children: [_jsx("span", { class: "filter-divider", "aria-hidden": "true" }), _jsx("div", { class: "chips kw-rail", role: "group", "aria-label": "Filter by keyword", ref: railRef, children: visibleKeywords.map(({ keyword }, i) => {
835
+ // Past the measured fit: still laid out, so the measurement
836
+ // that decided this stays true on the next pass, but drawn
837
+ // as nothing and out of reach. Removing it from the flow
838
+ // instead would free the width that excluded it, which is
839
+ // the oscillation this shape exists to avoid.
840
+ const clipped = i >= railFit;
841
+ return (_jsx("button", { ref: (el) => {
842
+ // The FLIP effect measures whatever is in this map, so
843
+ // a chip that leaves has to leave the map with it —
844
+ // Preact calls back with null on unmount for that.
845
+ if (el)
846
+ railRefs.current.set(keyword, el);
847
+ else
848
+ railRefs.current.delete(keyword);
849
+ }, type: "button", class: [
850
+ "chip kw",
851
+ keywords.includes(keyword) ? "active" : "",
852
+ clipped ? "clipped" : "",
853
+ ]
854
+ .filter(Boolean)
855
+ .join(" "), "data-slot": "filter-chip", "aria-pressed": keywords.includes(keyword), "aria-hidden": clipped ? "true" : undefined, tabIndex: clipped ? -1 : undefined, onKeyDown: onChipKeyDown, onClick: () => toggleKeyword(keyword), children: keyword }, keyword));
856
+ }) })] })), menuKeywords.length > 0 && (_jsxs("div", { class: "kw-menu", children: [_jsxs("button", { type: "button", class: "chip", "data-slot": "filter-chip", ref: kwTriggerRef, popovertarget: KEYWORD_MENU_ID, "aria-expanded": kwMenuOpen, children: ["+", menuKeywords.length, " more"] }), _jsxs("div", { class: "kw-menu-panel", id: KEYWORD_MENU_ID, popover: "auto", ref: kwMenuRef,
857
+ // Both, and in this order: `beforetoggle` runs synchronously
858
+ // inside the show steps, so the panel is seated before it is
859
+ // ever painted; `toggle` runs after, when its width can
860
+ // actually be measured for the clamp.
861
+ onBeforeToggle: (e) => {
862
+ setKwMenuOpen(e.newState === "open");
863
+ placeKwMenu();
864
+ }, onToggle: (e) => {
865
+ setKwMenuOpen(e.newState === "open");
866
+ placeKwMenu();
867
+ }, children: [_jsx("input", { type: "text", class: "kw-menu-search", placeholder: "Filter keywords\u2026", "aria-label": "Filter keywords", value: keywordFilter, onInput: (e) => setKeywordFilter(e.target.value) }), _jsxs("div", { class: "kw-menu-list", children: [menuShown.map(({ keyword, count }) => (_jsxs("button", { type: "button", class: "kw-menu-item", onClick: () => toggleKeyword(keyword), children: [_jsx("span", { children: keyword }), _jsx("small", { children: count })] }, keyword))), menuShown.length === 0 && (_jsx("p", { class: "kw-menu-empty", children: "No keyword matches." }))] })] })] })), keywords.length > 0 && (_jsxs("button", { type: "button", class: "chip kw-clear", "data-slot": "filter-chip", title: "Clear the keyword filters", onKeyDown: onChipKeyDown, onClick: (e) => {
868
+ setKeywords([]);
869
+ // This button is the last thing standing when it is pressed:
870
+ // clearing the facets unmounts it, and focus would land on
871
+ // `<body>`, sending a keyboard reader back to the top of the
872
+ // document. `detail === 0` is a click synthesized by Enter or
873
+ // Space, so a pointer user is left alone and a keyboard one
874
+ // gets the toolbar's own anchor instead of nothing.
875
+ if (e.detail === 0)
876
+ searchRef.current?.focus();
877
+ }, children: [_jsx(X, { size: 13, "aria-hidden": "true" }), "clear ", keywords.length] })), hasDeprecated && (_jsx("button", { type: "button", class: showDeprecated
878
+ ? "chip deprecated-toggle active"
879
+ : "chip deprecated-toggle", "aria-pressed": showDeprecated, title: showDeprecated
880
+ ? "Hide deprecated packages"
881
+ : "Show deprecated packages", onKeyDown: onChipKeyDown, onClick: () => setShowDeprecated((on) => !on), children: "deprecated" }))] }), _jsxs("div", { class: "meta-row", children: [_jsx("p", { class: "result-count", role: "status", "aria-atomic": "true", children: shown.length === counted.length
882
+ ? `${counted.length} packages`
883
+ : `${shown.length} of ${counted.length} packages` }), _jsxs("div", { class: "sort-group", role: "group", "aria-label": "Sort by", children: [_jsx("button", { type: "button", class: "sort-dir", "data-slot": "filter-chip", title: dir === "asc"
884
+ ? "Ascending — click for descending"
885
+ : "Descending — click for ascending", "aria-label": dir === "asc"
886
+ ? "Sorted ascending; sort descending"
887
+ : "Sorted descending; sort ascending", onClick: () => setDir((d) => (d === "asc" ? "desc" : "asc")), children: dir === "asc" ? (_jsx(ArrowUpNarrowWide, { size: 15, "aria-hidden": "true" })) : (_jsx(ArrowDownWideNarrow, { size: 15, "aria-hidden": "true" })) }), _jsxs("select", { class: "sort-field", "data-slot": "filter-chip", "aria-label": "Sort by", value: sort, onChange: (event) => {
888
+ const next = event.currentTarget
889
+ .value;
890
+ setSort(next);
891
+ // Picking a field takes that field's own direction. Carrying
892
+ // the previous one over lands the reader on "oldest first"
893
+ // because they had asked for Z→A a moment ago.
894
+ setDir(NATURAL[next]);
895
+ }, children: [_jsx("option", { value: "name", children: "name" }), _jsx("option", { value: "updated", children: "updated" }), hasRatings && _jsx("option", { value: "rating", children: "rating" })] })] }), _jsxs("div", { class: "view-toggle", role: "group", "aria-label": "Catalog view", children: [_jsx("button", { type: "button", class: view === "cards" ? "view-pick active" : "view-pick", "data-slot": "filter-chip", "aria-pressed": view === "cards", title: "Cards", "aria-label": "Show packages as cards", onClick: () => setView("cards"), children: _jsx(LayoutGrid, { size: 15, "aria-hidden": "true" }) }), _jsx("button", { type: "button", class: view === "table" ? "view-pick active" : "view-pick", "data-slot": "filter-chip", "aria-pressed": view === "table", title: "List", "aria-label": "Show packages as a list", onClick: () => setView("table"), children: _jsx(List, { size: 15, "aria-hidden": "true" }) })] })] })] }), shown.length === 0 ? (_jsx("p", { class: "empty", children: "No packages match." })) : view === "table" ? (_jsx(PackageTable, { packages: shown, hasRatings: hasRatings, onKeyDown: onCardKeyDown, rootRef: gridRef })) : (_jsx("ul", { class: "grid", ref: (el) => {
896
+ gridRef.current = el;
897
+ }, children: shown.map((p) => (_jsx(PackageCard, { pkg: p, vscodeExtension: vscodeExtension, activeKeywords: keywords, onToggleKeyword: toggleKeyword, onKeyDown: onCardKeyDown }, `${p.namespace}/${p.name}`))) }))] }));
433
898
  }