@grimoire-rs/indexer 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +38 -0
  2. package/dist/cli/init.js +1 -1
  3. package/dist/cli/init.js.map +1 -1
  4. package/dist/config.d.ts +21 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +19 -2
  7. package/dist/config.js.map +1 -1
  8. package/dist/enrich/index.d.ts.map +1 -1
  9. package/dist/enrich/index.js +62 -0
  10. package/dist/enrich/index.js.map +1 -1
  11. package/dist/renderer/astro/components/BrandMark.d.ts +14 -0
  12. package/dist/renderer/astro/components/BrandMark.d.ts.map +1 -0
  13. package/dist/renderer/astro/components/BrandMark.js +16 -0
  14. package/dist/renderer/astro/components/BrandMark.js.map +1 -0
  15. package/dist/renderer/astro/components/BrandMark.tsx +19 -0
  16. package/dist/renderer/astro/components/Catalog.d.ts.map +1 -1
  17. package/dist/renderer/astro/components/Catalog.js +310 -18
  18. package/dist/renderer/astro/components/Catalog.js.map +1 -1
  19. package/dist/renderer/astro/components/Catalog.tsx +445 -63
  20. package/dist/renderer/astro/components/CommandField.astro +43 -0
  21. package/dist/renderer/astro/components/PickerMenu.astro +68 -0
  22. package/dist/renderer/astro/components/VersionMenu.astro +6 -2
  23. package/dist/renderer/astro/content.config.ts +23 -5
  24. package/dist/renderer/astro/layouts/Base.astro +794 -66
  25. package/dist/renderer/astro/lib/catalog.d.ts +24 -0
  26. package/dist/renderer/astro/lib/catalog.d.ts.map +1 -1
  27. package/dist/renderer/astro/lib/catalog.js +62 -0
  28. package/dist/renderer/astro/lib/catalog.js.map +1 -1
  29. package/dist/renderer/astro/lib/catalog.ts +62 -0
  30. package/dist/renderer/astro/lib/code.d.ts +22 -0
  31. package/dist/renderer/astro/lib/code.d.ts.map +1 -0
  32. package/dist/renderer/astro/lib/code.js +21 -0
  33. package/dist/renderer/astro/lib/code.js.map +1 -0
  34. package/dist/renderer/astro/lib/code.ts +21 -0
  35. package/dist/renderer/astro/pages/index.astro +144 -36
  36. package/dist/renderer/astro/pages/p/[...slug].astro +403 -55
  37. package/dist/renderer/index.d.ts +23 -0
  38. package/dist/renderer/index.d.ts.map +1 -1
  39. package/dist/renderer/index.js +117 -30
  40. package/dist/renderer/index.js.map +1 -1
  41. package/package.json +5 -1
  42. package/dist/renderer/astro/components/CopyButton.astro +0 -24
@@ -3,6 +3,7 @@
3
3
  // sheet. Everything in the global block sits in `@layer grimoire`, so a
4
4
  // user CSS file (unlayered) wins over any of it regardless of specificity
5
5
  // or injection order — that layer is the entire theming contract.
6
+ import { Moon, Sun } from "lucide-preact";
6
7
  import { withBase } from "../lib/base";
7
8
  import { data } from "../lib/data";
8
9
 
@@ -12,7 +13,9 @@ interface Props {
12
13
  image?: string;
13
14
  }
14
15
  const { config, css } = data;
15
- const { title, description, image = config.favicon } = Astro.props;
16
+ // `og:image` falls back to the logo before the favicon: a favicon is drawn to
17
+ // read at 16px, and a link preview is not that.
18
+ const { title, description, image = config.logo ?? config.favicon } = Astro.props;
16
19
  // Shown as well as linked: on a subpath deployment the bare path is not
17
20
  // where the file is, so the label has to carry the prefix too.
18
21
  const allJson = withBase("/all.json");
@@ -22,6 +25,24 @@ const brandRest =
22
25
  config.brandMark && config.brand.startsWith(config.brandMark)
23
26
  ? config.brand.slice(config.brandMark.length)
24
27
  : null;
28
+
29
+ /**
30
+ * Name the forge `repoUrl` actually points at, rather than assuming one.
31
+ * `repoUrl` is free-form config: an index hosted on GitLab was previously
32
+ * labelled "GitHub" in its own header. An unrecognized host is named
33
+ * outright — honest, and already lowercase, which is the house style for
34
+ * this nav.
35
+ */
36
+ const FORGES = ["github", "gitlab", "bitbucket", "codeberg", "sourcehut"];
37
+ function repoLabel(url: string): string {
38
+ let host: string;
39
+ try {
40
+ host = new URL(url).hostname.replace(/^www\./, "");
41
+ } catch {
42
+ return "repo";
43
+ }
44
+ return FORGES.find((forge) => host.includes(forge)) ?? host;
45
+ }
25
46
  ---
26
47
 
27
48
  <html lang="en">
@@ -42,12 +63,67 @@ const brandRest =
42
63
  (matchMedia("(prefers-color-scheme: dark)").matches
43
64
  ? "dark"
44
65
  : "light");
66
+
67
+ // Also before first paint: a `?q=` deep link (a keyword chip on a
68
+ // package page) means the catalog the server rendered — every package,
69
+ // empty search box — is the wrong one. Flag it so CSS holds the
70
+ // catalog back rather than showing the full list and collapsing it a
71
+ // moment later. The island drops the flag on its first render, which
72
+ // is already the filtered one; the timeout is the failsafe for an
73
+ // island that never hydrates, where a flash beats a blank page.
74
+ if (new URLSearchParams(location.search).get("q")) {
75
+ document.documentElement.dataset.query = "";
76
+ setTimeout(() => delete document.documentElement.dataset.query, 3000);
77
+ }
78
+
79
+ // Selecting one choice in a command bar. Defined here, in the head,
80
+ // because it has two callers that cannot share a script: the click
81
+ // handler at the end of the document, and the host-platform preselect
82
+ // that has to run while the hero is still being parsed (see
83
+ // `index.astro`). One definition, so the two cannot drift.
84
+ window.__grimPick = (bar, pick) => {
85
+ const command = pick.dataset.osPick;
86
+ const field = bar.querySelector("[data-copy]");
87
+ const code = field?.querySelector("code");
88
+ // One element is both the shown command and what gets copied, so
89
+ // they cannot drift apart.
90
+ if (code) code.textContent = command;
91
+ if (field) field.dataset.copy = command;
92
+ // Keep the toast's name in step with the choice: the bar's noun is
93
+ // fixed ("install command"), the choice supplies the adjective.
94
+ if (field && bar.dataset.osNoun) {
95
+ field.dataset.copyName = `${pick.dataset.osName} ${bar.dataset.osNoun}`;
96
+ }
97
+ for (const other of bar.querySelectorAll("[data-os-pick]")) {
98
+ other.setAttribute("aria-checked", other === pick ? "true" : "false");
99
+ }
100
+ // The trigger carries every glyph and shows one; swapping which is
101
+ // hidden beats rewriting an SVG path.
102
+ for (const glyph of bar.querySelectorAll("[data-os-glyph]")) {
103
+ glyph.hidden = glyph.dataset.osGlyph !== pick.dataset.osName;
104
+ }
105
+ };
106
+
107
+ // The platform a bar should start on, or null for one this visitor's
108
+ // host is not among. Order matters: a macOS UA string contains "Mac"
109
+ // but a Windows one contains "Win", and ChromeOS reports "CrOS" while
110
+ // running the same shell installer as Linux.
111
+ window.__grimHostOs = () => {
112
+ const ua = navigator.userAgentData?.platform || navigator.platform || navigator.userAgent;
113
+ if (/mac|iphone|ipad|darwin/i.test(ua)) return "macOS";
114
+ if (/win/i.test(ua)) return "Windows";
115
+ if (/linux|android|cros|x11/i.test(ua)) return "Linux";
116
+ return null;
117
+ };
45
118
  </script>
46
119
  </head>
47
120
  <body>
48
121
  <header>
49
122
  <div class="shell header-row">
50
123
  <a class="brand" href={withBase("/")}>
124
+ {/* Decorative: the brand text is right beside it, so announcing the
125
+ logo too would read the index's name twice. */}
126
+ {config.logo && <img class="brand-logo" src={withBase(config.logo)} alt="" />}
51
127
  {
52
128
  brandRest === null ? (
53
129
  config.brand
@@ -64,13 +140,17 @@ const brandRest =
64
140
  {
65
141
  config.repoUrl && (
66
142
  <a href={config.repoUrl} target="_blank" rel="noopener noreferrer">
67
- GitHub
143
+ {repoLabel(config.repoUrl)}
68
144
  </a>
69
145
  )
70
146
  }
147
+ {/* Stroked, not filled, and inheriting the nav's own colour, so the
148
+ toggle reads as a third nav item rather than a control bolted
149
+ beside them. Rendered statically — no client directive, so no
150
+ JavaScript ships for the icons. */}
71
151
  <button id="theme-toggle" type="button" aria-label="Toggle theme">
72
- <span class="when-light" aria-hidden="true">🌙</span>
73
- <span class="when-dark" aria-hidden="true">☀️</span>
152
+ <Moon class="when-light" size={16} aria-hidden="true" />
153
+ <Sun class="when-dark" size={16} aria-hidden="true" />
74
154
  </button>
75
155
  </nav>
76
156
  </div>
@@ -87,9 +167,37 @@ const brandRest =
87
167
  config.footerNote ? ` · ${config.footerNote}` : ""
88
168
  }
89
169
  </p>
170
+ {/* Opt-out, not opt-in: an index runner who does not want to say
171
+ where the software came from sets `attribution: false`, and this
172
+ disappears. Nothing else on the page changes.
173
+
174
+ "using", not "by": the index runner built this site — Grimoire is
175
+ the tool they built it with. */}
176
+ {
177
+ config.attribution && (
178
+ <p class="attribution">
179
+ Built with <span aria-hidden="true">♥</span><span class="sr-only">love</span> using{" "}
180
+ {/* New tab: this is the one link that leaves the index for an
181
+ unrelated site, and a visitor mid-browse should not lose
182
+ their place to it. */}
183
+ <a href="https://grimoire.rs" target="_blank" rel="noopener">
184
+ Grimoire
185
+ </a>
186
+ </p>
187
+ )
188
+ }
90
189
  </div>
91
190
  </footer>
92
191
 
192
+ {/* One toast for the whole page, outside every scrolling container so a
193
+ copy from a card at the bottom lands in the same place as one from
194
+ the hero. `aria-hidden`, because each copy button already carries its
195
+ own `role="status"` — without this it is announced twice. */}
196
+ <div id="copy-toast" aria-hidden="true">
197
+ <span class="toast-name"></span>
198
+ <code class="toast-value"></code>
199
+ </div>
200
+
93
201
  <script is:inline>
94
202
  document.getElementById("theme-toggle").addEventListener("click", () => {
95
203
  const root = document.documentElement;
@@ -98,18 +206,139 @@ const brandRest =
98
206
  localStorage.setItem("theme", next);
99
207
  });
100
208
 
209
+ // Static-page logo slots. The catalog island runs the same state
210
+ // machine in Preact, because it owns those nodes; this drives the ones
211
+ // Astro rendered. The placeholder is already in the markup, so all
212
+ // there is to do is move the slot from `loading` to `ready`/`broken`.
213
+ //
214
+ // The initial `complete` sweep is not redundant with the listeners: an
215
+ // image starts loading while the document is parsed, so a fast result
216
+ // can land before this script runs and fire its event at nobody.
217
+ // `complete` says the browser finished, not how it went — `decode()`
218
+ // separates them, and unlike `naturalWidth === 0` it does not
219
+ // misreport an SVG that has no intrinsic size.
220
+ // `[data-static]` is load-bearing, not decoration: the catalog island
221
+ // renders `.logo-slot` too, and Preact owns `data-state` there. Writing
222
+ // it from here made the slot flip to the script's answer, then back to
223
+ // Preact's `loading` on hydration, then forward again — the icon fading
224
+ // in and straight back out.
225
+ for (const slot of document.querySelectorAll(".logo-slot[data-static]")) {
226
+ const img = slot.querySelector("img");
227
+ if (!img) continue;
228
+ const settle = (state) => slot.setAttribute("data-state", state);
229
+ img.addEventListener("load", () => settle("ready"));
230
+ img.addEventListener("error", () => settle("broken"));
231
+ if (img.complete) {
232
+ img.decode().then(
233
+ () => settle("ready"),
234
+ () => settle("broken"),
235
+ );
236
+ }
237
+ }
238
+
239
+ // Command-bar pickers. `__grimPick` (head) does the swap; this only
240
+ // wires the ways a choice gets made and the menu closed. The copy
241
+ // handler reads `data-copy` at click time, so it needs no part in this.
242
+ for (const bar of document.querySelectorAll("[data-os-switch]")) {
243
+ const menu = bar.querySelector(".os-menu");
244
+ const summary = menu?.querySelector("summary");
245
+ for (const pick of bar.querySelectorAll("[data-os-pick]")) {
246
+ pick.addEventListener("click", () => {
247
+ window.__grimPick(bar, pick);
248
+ if (menu) menu.open = false;
249
+ summary?.focus();
250
+ });
251
+ }
252
+ // `<details>` opens and closes itself but knows nothing about the
253
+ // two ways every other menu closes.
254
+ if (menu) {
255
+ document.addEventListener("click", (event) => {
256
+ if (!menu.contains(event.target)) menu.open = false;
257
+ });
258
+ menu.addEventListener("keydown", (event) => {
259
+ if (event.key !== "Escape") return;
260
+ menu.open = false;
261
+ summary?.focus();
262
+ });
263
+ }
264
+ }
265
+
266
+ // One toast for every copy on the page. The in-place check confirms
267
+ // that *something* was copied; a card has three copy buttons a few
268
+ // pixels apart, all of which flash the same check, so the toast is
269
+ // what says WHICH. It carries both readings: a name for what was
270
+ // copied, and the exact string, because "the install command" is the
271
+ // useful part when you meant to pick a different platform and the raw
272
+ // text is the useful part when you are not sure what landed.
273
+ //
274
+ // Announcing is left to the per-button status span that already exists
275
+ // — this element is `aria-hidden`, or every copy would be read twice.
276
+ const toast = document.getElementById("copy-toast");
277
+ let toastTimer;
278
+ function showToast(name, value) {
279
+ if (!toast) return;
280
+ toast.querySelector(".toast-name").textContent = name
281
+ ? `Copied ${name}`
282
+ : "Copied";
283
+ toast.querySelector(".toast-value").textContent = value;
284
+ // Restart the animation even mid-fade: without the reflow a second
285
+ // copy inside the window leaves the class already set and nothing
286
+ // moves.
287
+ toast.classList.remove("show");
288
+ void toast.offsetWidth;
289
+ toast.classList.add("show");
290
+ clearTimeout(toastTimer);
291
+ toastTimer = setTimeout(() => toast.classList.remove("show"), 2600);
292
+ }
293
+ // Hydrated islands cannot reach this function, so they say so by event.
294
+ document.addEventListener("grimoire:copied", (event) => {
295
+ showToast(event.detail.name, event.detail.value);
296
+ });
297
+
298
+ // A copy button on every code block. Injected rather than authored:
299
+ // the blocks come out of Shiki, inside rendered markdown, so there is
300
+ // no markup to add one to. `data-copy` is all it takes to join the
301
+ // handler below — same clipboard path, same flash, same toast.
302
+ const CLIPBOARD_ICON =
303
+ '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
304
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
305
+ '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>' +
306
+ '<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
307
+
308
+ for (const pre of document.querySelectorAll("pre.astro-code")) {
309
+ const wrapper = document.createElement("div");
310
+ wrapper.className = "code-block";
311
+ pre.replaceWith(wrapper);
312
+ wrapper.append(pre);
313
+
314
+ const btn = document.createElement("button");
315
+ btn.type = "button";
316
+ btn.className = "code-copy";
317
+ btn.title = "Copy";
318
+ btn.setAttribute("aria-label", "Copy this code block");
319
+ // `textContent`, not `innerText`: the block is not rendered yet at
320
+ // this point, and `innerText` would collapse its whitespace.
321
+ btn.dataset.copy = pre.textContent ?? "";
322
+ btn.dataset.copyName = "code block";
323
+ btn.innerHTML = CLIPBOARD_ICON + '<span class="sr-only" role="status"></span>';
324
+ wrapper.append(btn);
325
+ }
326
+
101
327
  for (const btn of document.querySelectorAll("[data-copy]")) {
102
- // Read the label up front: re-reading it inside the handler snapshots
103
- // "copied" when the button is clicked twice inside the 1500ms window.
104
- const label = btn.querySelector("span");
105
- const original = label?.textContent ?? "copy";
328
+ // The visible label never changes swapping "copy" for "copied"
329
+ // resized the button and shifted its neighbours. `.copied` swaps the
330
+ // icon instead, and the status span carries the same news to a
331
+ // screen reader without occupying any width.
332
+ const status = btn.querySelector('[role="status"]');
106
333
  btn.addEventListener("click", () => {
107
- navigator.clipboard.writeText(btn.dataset.copy ?? "").then(() => {
334
+ const value = btn.dataset.copy ?? "";
335
+ navigator.clipboard.writeText(value).then(() => {
108
336
  btn.classList.add("copied");
109
- if (label) label.textContent = "copied";
337
+ if (status) status.textContent = "copied";
338
+ showToast(btn.dataset.copyName ?? "", value);
110
339
  setTimeout(() => {
111
340
  btn.classList.remove("copied");
112
- if (label) label.textContent = original;
341
+ if (status) status.textContent = "";
113
342
  }, 1500);
114
343
  });
115
344
  });
@@ -222,11 +451,21 @@ const brandRest =
222
451
  gap: 1rem;
223
452
  }
224
453
  .brand {
454
+ display: inline-flex;
455
+ align-items: center;
456
+ gap: 0.55rem;
225
457
  color: var(--fg);
226
458
  text-decoration: none;
227
459
  font-weight: 600;
228
460
  font-size: 1.05rem;
229
461
  }
462
+ /* Height-bound, width free: a wordmark and a square icon are both
463
+ valid logos and neither should be squashed into the other's box. */
464
+ .brand-logo {
465
+ height: 1.5rem;
466
+ width: auto;
467
+ max-width: 12rem;
468
+ }
230
469
  .brand-mark {
231
470
  color: var(--accent);
232
471
  font-family: ui-monospace, "SFMono-Regular", monospace;
@@ -243,45 +482,73 @@ const brandRest =
243
482
  nav a:hover {
244
483
  color: var(--fg);
245
484
  }
485
+ /* Borderless and colour-inheriting: same resting and hover colours as
486
+ the nav links beside it, so the row reads as one set. */
246
487
  #theme-toggle {
247
- border: 1px solid var(--border);
248
- background: transparent;
249
- border-radius: 999px;
250
- padding: 0.25rem 0.5rem;
488
+ display: inline-flex;
489
+ align-items: center;
490
+ border: none;
491
+ background: none;
492
+ padding: 0;
493
+ color: var(--muted);
251
494
  cursor: pointer;
252
- font-size: 0.9rem;
253
- line-height: 1;
495
+ }
496
+ #theme-toggle:hover {
497
+ color: var(--fg);
254
498
  }
255
499
  [data-theme="dark"] .when-light,
256
500
  [data-theme="light"] .when-dark {
257
501
  display: none;
258
502
  }
259
503
 
504
+ /* Description left, getting-started block right. The right column is
505
+ `auto` — it takes exactly what the add-index unit needs and gives
506
+ the rest to the text. h1 spans both. */
260
507
  .hero {
261
508
  display: grid;
262
- /* fit-content: install column takes what its rows need, but can
263
- shrink below content width (commands scroll) before squeezing
264
- the text column out. h1 spans the full row; paragraph and
265
- install strip share the second row, top-aligned. */
266
- grid-template-columns: 1fr fit-content(44rem);
509
+ grid-template-columns: 1fr auto;
267
510
  align-items: start;
268
- gap: 0.75rem 2rem;
269
- margin-block: 1rem 1.5rem;
511
+ gap: 0.6rem 2rem;
512
+ margin-block: 1rem 1.25rem;
270
513
  }
271
514
  .hero h1 {
272
515
  grid-column: 1 / -1;
273
516
  margin: 0;
274
517
  font-size: 1.6rem;
275
518
  }
276
- .hero p {
277
- margin: 0;
519
+ /* Label column, bar column. `minmax(0, …)` on the bars is what lets
520
+ the commands scroll instead of widening the grid. */
521
+ .hero-side {
522
+ display: grid;
523
+ grid-template-columns: auto minmax(0, 34rem);
524
+ align-items: center;
525
+ gap: 0.4rem 0.7rem;
526
+ }
527
+ .side-label {
278
528
  color: var(--muted);
279
- max-width: 46rem;
529
+ font-size: 0.72rem;
530
+ font-weight: 600;
531
+ text-transform: uppercase;
532
+ letter-spacing: 0.04em;
533
+ white-space: nowrap;
280
534
  }
281
- @media (max-width: 64rem) {
535
+ /* Stacked below the description before the two get cramped rather
536
+ than after — a command squeezed to a few visible characters is
537
+ worse than one on its own row. Stacked, the bars take the whole
538
+ width instead of staying at their side-by-side cap, which is the
539
+ point of dropping down. */
540
+ @media (max-width: 72rem) {
282
541
  .hero {
283
542
  grid-template-columns: 1fr;
284
543
  }
544
+ .hero-side {
545
+ grid-template-columns: auto minmax(0, 1fr);
546
+ }
547
+ }
548
+ .hero p {
549
+ margin: 0;
550
+ color: var(--muted);
551
+ max-width: 46rem;
285
552
  }
286
553
  .hero code {
287
554
  background: var(--chip-bg);
@@ -290,60 +557,299 @@ const brandRest =
290
557
  font-size: 0.9em;
291
558
  }
292
559
 
293
- .install {
294
- display: grid;
295
- /* One track set for all rows so the commands start at the same x
296
- regardless of OS-label width; rows pick it up via subgrid. */
297
- grid-template-columns: auto 1fr auto;
298
- gap: 0.5rem;
560
+ /* The command and every way to act on it inside one border — a
561
+ single control, not a field with buttons parked beside it. */
562
+ .cmd-bar {
563
+ display: flex;
564
+ align-items: stretch;
299
565
  min-width: 0;
300
- }
301
- .install-row {
302
- grid-column: 1 / -1;
303
- display: grid;
304
- grid-template-columns: subgrid;
305
- align-items: center;
306
- column-gap: 0.6rem;
307
566
  background: var(--card);
308
567
  border: 1px solid var(--border);
309
568
  border-radius: 8px;
310
- padding: 0.45rem 0.7rem;
311
- }
312
- .install-row .os {
313
- color: var(--muted);
314
- font-size: 0.78rem;
315
- font-weight: 600;
316
- text-transform: uppercase;
317
- letter-spacing: 0.04em;
318
- white-space: nowrap;
319
569
  }
320
- .install-row code {
570
+ /* No `overflow: hidden` — that would clip the platform menu. The end
571
+ segments round themselves instead (8px border box, 1px border).
572
+ Stated at the bottom of this block, after `.seg`, because that is
573
+ what the cascade needs — see the note there. */
574
+ /* The command *is* the copy button: the whole field is clickable, so
575
+ there is no separate control taking width beside it. */
576
+ .cmd-field {
577
+ position: relative;
578
+ flex: 1;
579
+ min-width: 0;
580
+ display: flex;
581
+ align-items: center;
582
+ border: none;
321
583
  background: none;
322
584
  padding: 0;
585
+ font: inherit;
586
+ color: inherit;
587
+ text-align: left;
588
+ cursor: pointer;
589
+ }
590
+ .cmd-field:hover {
591
+ background: var(--chip-bg);
592
+ }
593
+ .cmd-field:focus-visible {
594
+ outline: 2px solid var(--accent);
595
+ outline-offset: -2px;
596
+ }
597
+ .cmd-field code {
598
+ flex: 1;
599
+ min-width: 0;
600
+ background: none;
601
+ border-radius: 0;
602
+ /* Right padding is the icon's lane — the text scrolls under the
603
+ fade rather than up against the glyph. */
604
+ padding: 0.4rem 2.1rem 0.4rem 0.7rem;
323
605
  font-size: 0.85rem;
324
- overflow-x: auto;
325
606
  white-space: nowrap;
326
- /* still scrollable, no visible bar */
607
+ overflow-x: auto;
608
+ /* Scrollable, no bar: the bar is the height of the control and
609
+ would sit under the text. The mask says "there is more" in its
610
+ place — it rides the padding box, so it stays at the visible
611
+ edge as the text scrolls under it. */
327
612
  scrollbar-width: none;
328
- }
329
- .install-row code::-webkit-scrollbar {
613
+ /* Fully transparent 1.8rem from the edge — where the icon lane
614
+ starts not at the edge itself, so no text is ever visible
615
+ under the glyph. */
616
+ mask-image: linear-gradient(
617
+ to right,
618
+ #000 calc(100% - 3.2rem),
619
+ transparent calc(100% - 1.8rem)
620
+ );
621
+ }
622
+ .cmd-field code::-webkit-scrollbar {
330
623
  display: none;
331
624
  }
332
- /* The `[[registries]]` snippet is the one multi-line command: let it
333
- wrap inside its cell instead of scrolling a three-line block. */
334
- .install-row code.block {
335
- white-space: pre;
336
- line-height: 1.35;
625
+ /* One grid cell holds both icons, so the check lands exactly where
626
+ the copy glyph was and nothing reflows during the swap. */
627
+ .cmd-icons {
628
+ position: absolute;
629
+ right: 0.6rem;
630
+ display: grid;
631
+ place-items: center;
632
+ color: var(--muted);
633
+ }
634
+ .cmd-icons > * {
635
+ grid-area: 1 / 1;
636
+ transition: opacity 0.18s ease;
637
+ }
638
+ .cmd-icons .icon-done {
639
+ opacity: 0;
640
+ color: var(--kind-rule);
337
641
  }
338
- @media (max-width: 40rem) {
339
- .install {
340
- grid-template-columns: 1fr auto;
642
+ .cmd-field:hover .cmd-icons,
643
+ .cmd-field:focus-visible .cmd-icons {
644
+ color: var(--fg);
645
+ }
646
+ .cmd-field.copied .icon-idle {
647
+ opacity: 0;
648
+ }
649
+ .cmd-field.copied .icon-done {
650
+ opacity: 1;
651
+ }
652
+ @media (prefers-reduced-motion: reduce) {
653
+ .cmd-icons > * {
654
+ transition: none;
341
655
  }
342
- .install-row .os {
343
- grid-column: 1 / -1;
656
+ }
657
+ /* Segments: they drop their own border and radius, and one hairline
658
+ divides them. `flex: none` keeps them at full size while the
659
+ command scrolls. */
660
+ .cmd-bar .seg {
661
+ flex: none;
662
+ display: inline-flex;
663
+ align-items: center;
664
+ border: none;
665
+ border-left: 1px solid var(--border);
666
+ border-radius: 0;
667
+ background: none;
668
+ padding: 0 0.5rem;
669
+ color: var(--muted);
670
+ text-decoration: none;
671
+ cursor: pointer;
672
+ }
673
+ /* Platform picker, at the head of the bar: the trigger shows the
674
+ chosen platform's glyph, the menu names them all. */
675
+ .os-menu {
676
+ position: relative;
677
+ flex: none;
678
+ display: flex;
679
+ }
680
+ .os-menu > summary {
681
+ display: inline-flex;
682
+ align-items: center;
683
+ gap: 0.1rem;
684
+ padding: 0 0.45rem;
685
+ color: var(--muted);
686
+ cursor: pointer;
687
+ border-right: 1px solid var(--border);
688
+ /* Both spellings: the default triangle is a marker in Firefox and
689
+ a `::-webkit-details-marker` pseudo-element in older WebKit. */
690
+ list-style: none;
691
+ }
692
+ .os-menu > summary::-webkit-details-marker {
693
+ display: none;
694
+ }
695
+ .os-menu > summary:hover,
696
+ .os-menu > summary:focus-visible,
697
+ .os-menu[open] > summary {
698
+ background: var(--chip-bg);
699
+ color: var(--fg);
700
+ }
701
+ .os-glyph {
702
+ display: inline-flex;
703
+ }
704
+ /* Any author `display` beats the UA sheet's `[hidden] { display: none }`
705
+ regardless of specificity, so the rule above showed all three glyphs
706
+ at once — the `hidden` attribute the markup and the toggle both set
707
+ had no effect at all. Restore it for this class rather than trusting
708
+ the UA default. */
709
+ .os-glyph[hidden] {
710
+ display: none;
711
+ }
712
+ .os-menu ul {
713
+ position: absolute;
714
+ top: calc(100% + 4px);
715
+ left: 0;
716
+ z-index: 5;
717
+ min-width: 9rem;
718
+ margin: 0;
719
+ padding: 0.25rem;
720
+ list-style: none;
721
+ background: var(--card);
722
+ border: 1px solid var(--border);
723
+ border-radius: 8px;
724
+ box-shadow: 0 6px 20px rgb(0 0 0 / 18%);
725
+ }
726
+ .os-menu li button {
727
+ display: flex;
728
+ align-items: center;
729
+ gap: 0.5rem;
730
+ width: 100%;
731
+ border: none;
732
+ border-radius: 6px;
733
+ background: none;
734
+ padding: 0.35rem 0.5rem;
735
+ color: var(--fg);
736
+ font: inherit;
737
+ font-size: 0.85rem;
738
+ text-align: left;
739
+ cursor: pointer;
740
+ }
741
+ .os-menu li button:hover,
742
+ .os-menu li button:focus-visible {
743
+ background: var(--chip-bg);
744
+ }
745
+ .os-menu li button[aria-checked="true"] {
746
+ color: var(--accent);
747
+ }
748
+ /* Borderless segments need a different hover than the pills do — the
749
+ border they would have tinted is not theirs any more. */
750
+ .cmd-bar .seg:hover,
751
+ .cmd-bar .seg:focus-visible {
752
+ background: var(--chip-bg);
753
+ color: var(--fg);
754
+ }
755
+
756
+ /* The bar's end rounding, last so it wins.
757
+ Two things defeated it while it sat higher up: `.cmd-bar .seg` is
758
+ the same specificity and declares `border-radius: 0`, which squared
759
+ the trailing segment; and `<summary>` took `border-radius: inherit`
760
+ yet still computed 0, so the leading segment was square too. Neither
761
+ shows until a hover, focus or open state paints a background — then
762
+ square corners poke out past the bar's own 8px rounding.
763
+ The summary is named explicitly rather than left to inherit. */
764
+ .cmd-bar > :first-child,
765
+ .cmd-bar > :first-child > summary {
766
+ border-start-start-radius: 7px;
767
+ border-end-start-radius: 7px;
768
+ }
769
+ .cmd-bar > :last-child {
770
+ border-start-end-radius: 7px;
771
+ border-end-end-radius: 7px;
772
+ }
773
+
774
+ /* Attribution sits opposite the raw-data line, on the same row where
775
+ there is width for it and beneath it when there is not. */
776
+ footer .shell {
777
+ display: flex;
778
+ flex-wrap: wrap;
779
+ justify-content: space-between;
780
+ align-items: baseline;
781
+ gap: 0.35rem 1rem;
782
+ }
783
+ .attribution {
784
+ margin: 0;
785
+ margin-inline-start: auto;
786
+ text-align: end;
787
+ }
788
+ .attribution span[aria-hidden] {
789
+ color: var(--accent);
790
+ }
791
+
792
+ /* Copy toast. Fixed and centred at the bottom: a card's three copy
793
+ buttons sit a few pixels apart and all flash the same check, so
794
+ this is what says which one fired — and it has to land in the same
795
+ place whether the copy came from the hero or a card at the bottom
796
+ of a long list. */
797
+ #copy-toast {
798
+ position: fixed;
799
+ left: 50%;
800
+ bottom: 1.25rem;
801
+ z-index: 20;
802
+ display: grid;
803
+ gap: 0.15rem;
804
+ max-width: min(30rem, calc(100vw - 2rem));
805
+ padding: 0.5rem 0.85rem;
806
+ background: var(--card);
807
+ border: 1px solid var(--border);
808
+ border-radius: 10px;
809
+ box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
810
+ /* Out of the layout and unhittable until shown, so it never eats a
811
+ click aimed at whatever is beneath it. */
812
+ pointer-events: none;
813
+ opacity: 0;
814
+ transform: translate(-50%, 0.5rem);
815
+ transition:
816
+ opacity 160ms ease,
817
+ transform 160ms ease;
818
+ }
819
+ #copy-toast.show {
820
+ opacity: 1;
821
+ transform: translate(-50%, 0);
822
+ }
823
+ #copy-toast .toast-name {
824
+ font-size: 0.8rem;
825
+ font-weight: 600;
826
+ color: var(--fg);
827
+ }
828
+ /* The exact string, one line, clipped rather than wrapped — a long
829
+ install one-liner must not grow the toast into the page. */
830
+ #copy-toast .toast-value {
831
+ font-size: 0.78rem;
832
+ color: var(--muted);
833
+ white-space: nowrap;
834
+ overflow: hidden;
835
+ text-overflow: ellipsis;
836
+ }
837
+ @media (prefers-reduced-motion: reduce) {
838
+ #copy-toast {
839
+ transition-duration: 0ms;
344
840
  }
345
841
  }
346
842
 
843
+ /* Arriving on `?q=…`: the server rendered every package and an empty
844
+ search box, so painting it and then collapsing to the match is a
845
+ flash of the wrong answer. `visibility` rather than `display` —
846
+ the space stays reserved, so the footer does not jump either. The
847
+ attribute is set before first paint and dropped by the island's
848
+ first render, which is already the filtered one. */
849
+ :root[data-query] .catalog {
850
+ visibility: hidden;
851
+ }
852
+
347
853
  .controls {
348
854
  display: flex;
349
855
  flex-wrap: wrap;
@@ -351,24 +857,86 @@ const brandRest =
351
857
  gap: 0.75rem;
352
858
  margin-bottom: 1.25rem;
353
859
  }
860
+ /* Full-width basis in a wrapping row: the search bar takes a line to
861
+ itself and pushes both chip groups onto the next one, which then
862
+ sit side by side. The container's 0.75rem gap separates the two
863
+ rows, and reads wider than the 0.4rem inside a group — so sort and
864
+ kind still group visually without a wrapper element. */
865
+ .search-field {
866
+ position: relative;
867
+ display: flex;
868
+ flex: 1 1 100%;
869
+ /* Breathing room above the field when `/` scrolls it to the top of
870
+ the viewport, so it does not sit flush against the edge. */
871
+ scroll-margin-top: 1.25rem;
872
+ }
354
873
  .controls input[type="search"] {
355
- flex: 1 1 16rem;
874
+ flex: 1;
356
875
  padding: 0.55rem 0.8rem;
876
+ /* Room for the key hint, so a long query never runs under it. */
877
+ padding-right: 2.4rem;
357
878
  border: 1px solid var(--border);
358
879
  border-radius: 8px;
359
880
  background: var(--card);
360
881
  color: var(--fg);
361
882
  font-size: 0.95rem;
883
+ min-width: 0;
362
884
  }
363
885
  .controls input[type="search"]:focus-visible {
364
886
  outline: 2px solid var(--accent);
365
887
  outline-offset: 1px;
366
888
  }
889
+ /* The `/` shortcut, shown as the key it is. It is a hint for a field
890
+ that is not yet in use, so it withdraws the moment the field is
891
+ focused or holds a query — at which point it would only be
892
+ sitting on top of what is being typed. */
893
+ .search-hint {
894
+ position: absolute;
895
+ right: 0.5rem;
896
+ top: 50%;
897
+ translate: 0 -50%;
898
+ pointer-events: none;
899
+ border: 1px solid var(--border);
900
+ border-bottom-width: 2px;
901
+ border-radius: 5px;
902
+ background: var(--chip-bg);
903
+ color: var(--muted);
904
+ font-family: ui-monospace, "SFMono-Regular", monospace;
905
+ font-size: 0.72rem;
906
+ line-height: 1;
907
+ padding: 0.2rem 0.4rem;
908
+ }
909
+ .search-field:focus-within .search-hint,
910
+ .search-field:has(input:not(:placeholder-shown)) .search-hint {
911
+ display: none;
912
+ }
367
913
  .chips {
368
914
  display: flex;
369
915
  flex-wrap: wrap;
370
916
  gap: 0.4rem;
371
917
  }
918
+ /* A drawn rule rather than a "|" glyph: its height is the chip row's,
919
+ not whatever the font gives a pipe. `stretch` overrides the
920
+ container's `align-items: center` for this one item. */
921
+ .chip-sep {
922
+ align-self: stretch;
923
+ width: 1px;
924
+ background: var(--border);
925
+ }
926
+ /* Held at the far end of the chip row: it answers a different
927
+ question from sort and kind — what the catalog is withholding,
928
+ not how to arrange it. */
929
+ .chip.deprecated-toggle {
930
+ margin-left: auto;
931
+ }
932
+ /* Pressed state borrows the deprecation colour rather than the
933
+ accent, so "deprecated entries are in view" is signalled by the
934
+ same colour the badges on those entries carry. */
935
+ .chip.deprecated-toggle.active {
936
+ color: var(--deprecated);
937
+ border-color: var(--deprecated);
938
+ background: var(--chip-bg);
939
+ }
372
940
  .chip {
373
941
  border: 1px solid var(--border);
374
942
  background: var(--chip-bg);
@@ -420,6 +988,14 @@ const brandRest =
420
988
  ) {
421
989
  border-color: var(--accent);
422
990
  }
991
+ /* Keyboard focus wears the hover treatment rather than an outline on
992
+ top of it — one affordance, whichever way the card was reached.
993
+ The border-colour change is still a visible focus indicator, which
994
+ is the part that may not be dropped. */
995
+ .card:focus-visible {
996
+ outline: none;
997
+ border-color: var(--accent);
998
+ }
423
999
  .card h2 a::after {
424
1000
  content: "";
425
1001
  position: absolute;
@@ -466,6 +1042,68 @@ const brandRest =
466
1042
  font-size: 0.85rem;
467
1043
  font-family: ui-monospace, "SFMono-Regular", monospace;
468
1044
  }
1045
+ /* THE LOGO SLOT — one box, three states, no layout shift.
1046
+ The placeholder ships in the HTML and the image sits on top of it
1047
+ starting fully transparent, so a failing load never gets to paint
1048
+ the browser's broken glyph and a slow one never leaves a hole. The
1049
+ image fades in only once it is known good; `loading="lazy"` keeps
1050
+ off-screen cards from fetching at all, which is the state this
1051
+ placeholder was designed to look deliberate in.
1052
+
1053
+ loading placeholder, no slash — the resting state
1054
+ ready image faded in, placeholder faded out
1055
+ broken placeholder with its slash, dashed edge
1056
+
1057
+ Kept distinct from `.card-logo-fallback`: that is a package with
1058
+ no logo at all, which is normal, not a fault. */
1059
+ .logo-slot {
1060
+ position: relative;
1061
+ display: flex;
1062
+ align-items: center;
1063
+ justify-content: center;
1064
+ overflow: hidden;
1065
+ background: var(--chip-bg);
1066
+ color: var(--muted);
1067
+ }
1068
+ .logo-slot .logo-mark,
1069
+ .logo-slot img {
1070
+ transition: opacity 200ms ease;
1071
+ }
1072
+ .logo-slot .logo-mark {
1073
+ width: 60%;
1074
+ height: 60%;
1075
+ }
1076
+ .logo-slot img {
1077
+ position: absolute;
1078
+ inset: 0;
1079
+ width: 100%;
1080
+ height: 100%;
1081
+ object-fit: contain;
1082
+ opacity: 0;
1083
+ }
1084
+ .logo-slot[data-state="ready"] img {
1085
+ opacity: 1;
1086
+ }
1087
+ .logo-slot[data-state="ready"] .logo-mark {
1088
+ opacity: 0;
1089
+ }
1090
+ /* Two marks, one shown per state — the static page cannot choose in
1091
+ markup, because nothing has run yet when it is parsed. The card
1092
+ grid renders only the one it needs, so these rules never apply
1093
+ there. */
1094
+ .logo-slot[data-state="broken"] .mark-loading,
1095
+ .logo-slot:not([data-state="broken"]) .mark-broken {
1096
+ display: none;
1097
+ }
1098
+ .logo-slot[data-state="broken"] {
1099
+ border: 1px dashed var(--border);
1100
+ }
1101
+ @media (prefers-reduced-motion: reduce) {
1102
+ .logo-slot .logo-mark,
1103
+ .logo-slot img {
1104
+ transition: none;
1105
+ }
1106
+ }
469
1107
  .badge {
470
1108
  flex-shrink: 0;
471
1109
  font-size: 0.72rem;
@@ -589,6 +1227,96 @@ const brandRest =
589
1227
  color: var(--kind-rule);
590
1228
  border-color: var(--kind-rule);
591
1229
  }
1230
+ .sr-only {
1231
+ position: absolute;
1232
+ width: 1px;
1233
+ height: 1px;
1234
+ margin: -1px;
1235
+ padding: 0;
1236
+ border: 0;
1237
+ overflow: hidden;
1238
+ clip-path: inset(50%);
1239
+ white-space: nowrap;
1240
+ }
1241
+
1242
+ /* Code blocks — Shiki's output, wearing this site's box. Shiki owns
1243
+ the token colours and nothing else: the frame, the spacing and the
1244
+ corners are the same ones every other panel uses. */
1245
+ .code-block {
1246
+ position: relative;
1247
+ margin: 1rem 0;
1248
+ }
1249
+ .astro-code {
1250
+ margin: 0;
1251
+ padding: 0.9rem 1rem;
1252
+ border: 1px solid var(--border);
1253
+ border-radius: 8px;
1254
+ overflow-x: auto;
1255
+ font-size: 0.85rem;
1256
+ line-height: 1.5;
1257
+ tab-size: 2;
1258
+ /* Shiki colours tokens; the surface is ours. Its own background is
1259
+ the theme's editor chrome — GitHub's blue-grey `#24292e` — which
1260
+ next to this page's purple-tinted card reads as a green cast.
1261
+ `!important` because Shiki writes it as an inline style. */
1262
+ background-color: var(--chip-bg) !important;
1263
+ }
1264
+ /* The dark half of the pair. Shiki writes the light colour inline and
1265
+ the dark one as a custom property on the same token, so switching
1266
+ themes is this rule rather than a second render. `!important` for
1267
+ the same reason: the light value is inline. */
1268
+ :root[data-theme="dark"] .astro-code,
1269
+ :root[data-theme="dark"] .astro-code span {
1270
+ color: var(--shiki-dark) !important;
1271
+ font-style: var(--shiki-dark-font-style) !important;
1272
+ font-weight: var(--shiki-dark-font-weight) !important;
1273
+ text-decoration: var(--shiki-dark-text-decoration) !important;
1274
+ }
1275
+ /* A token can carry its own highlight background; nothing else may. */
1276
+ .astro-code span:not([style*="background"]) {
1277
+ background-color: transparent !important;
1278
+ }
1279
+ /* Reveals on hover or keyboard focus, so it is not a permanent
1280
+ smudge over the first line of every block. */
1281
+ .code-copy {
1282
+ position: absolute;
1283
+ top: 0.45rem;
1284
+ right: 0.45rem;
1285
+ display: inline-flex;
1286
+ align-items: center;
1287
+ justify-content: center;
1288
+ width: 1.9rem;
1289
+ height: 1.9rem;
1290
+ padding: 0;
1291
+ border: 1px solid var(--border);
1292
+ border-radius: 6px;
1293
+ background: var(--card);
1294
+ color: var(--muted);
1295
+ cursor: pointer;
1296
+ opacity: 0;
1297
+ transition:
1298
+ opacity 120ms ease,
1299
+ color 120ms ease,
1300
+ border-color 120ms ease;
1301
+ }
1302
+ .code-block:hover .code-copy,
1303
+ .code-copy:focus-visible {
1304
+ opacity: 1;
1305
+ }
1306
+ .code-copy:hover {
1307
+ color: var(--accent);
1308
+ border-color: var(--accent);
1309
+ }
1310
+ .code-copy.copied {
1311
+ color: var(--kind-rule);
1312
+ border-color: var(--kind-rule);
1313
+ opacity: 1;
1314
+ }
1315
+ @media (prefers-reduced-motion: reduce) {
1316
+ .code-copy {
1317
+ transition: none;
1318
+ }
1319
+ }
592
1320
  /* card actions: icon-only square buttons. Scoped to .copy-group so the
593
1321
  hero install buttons (also .copy, elsewhere) keep their text label. */
594
1322
  .copy-group .copy {