@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
@@ -7,7 +7,13 @@
7
7
  // The tokens themselves live in `../styles/tokens.css`, which declares the
8
8
  // same layer. That file is the contract; this one only spends it.
9
9
  import "../styles/tokens.css";
10
- import { Moon, Sun } from "lucide-preact";
10
+ // Header and footer are separate components so an index can replace one of
11
+ // them without owning this file — the head, the centered `main`, the copy
12
+ // toast and the whole style block below are what it would otherwise inherit
13
+ // the hard way. Their styles stay here: the block is `is:global`, so it
14
+ // reaches their markup wherever it lives.
15
+ import SiteHeader from "../components/SiteHeader.astro";
16
+ import SiteFooter from "../components/SiteFooter.astro";
11
17
  import { withBase } from "../lib/base";
12
18
  import { data } from "../lib/data";
13
19
 
@@ -20,33 +26,6 @@ const { config, css } = data;
20
26
  // `og:image` falls back to the logo before the favicon: a favicon is drawn to
21
27
  // read at 16px, and a link preview is not that.
22
28
  const { title, description, image = config.logo ?? config.favicon } = Astro.props;
23
- // Shown as well as linked: on a subpath deployment the bare path is not
24
- // where the file is, so the label has to carry the prefix too.
25
- const allJson = withBase("/all.json");
26
- // The mark is styled separately, so it only applies when it really is the
27
- // leading run of the brand — otherwise the header renders the plain text.
28
- const brandRest =
29
- config.brandMark && config.brand.startsWith(config.brandMark)
30
- ? config.brand.slice(config.brandMark.length)
31
- : null;
32
-
33
- /**
34
- * Name the forge `repoUrl` actually points at, rather than assuming one.
35
- * `repoUrl` is free-form config: an index hosted on GitLab was previously
36
- * labelled "GitHub" in its own header. An unrecognized host is named
37
- * outright — honest, and already lowercase, which is the house style for
38
- * this nav.
39
- */
40
- const FORGES = ["github", "gitlab", "bitbucket", "codeberg", "sourcehut"];
41
- function repoLabel(url: string): string {
42
- let host: string;
43
- try {
44
- host = new URL(url).hostname.replace(/^www\./, "");
45
- } catch {
46
- return "repo";
47
- }
48
- return FORGES.find((forge) => host.includes(forge)) ?? host;
49
- }
50
29
  ---
51
30
 
52
31
  <html lang="en">
@@ -61,22 +40,38 @@ function repoLabel(url: string): string {
61
40
  <meta property="og:image" content={withBase(image)} />
62
41
  <meta name="twitter:card" content="summary" />
63
42
  <script is:inline>
43
+ // Reading `localStorage` *throws* in a browser set to block site data —
44
+ // it does not return null — and everything below this point, the
45
+ // command-bar helpers included, lives in the same script block. One
46
+ // guarded reader, so a blocked store costs a preference rather than
47
+ // the page.
48
+ const pref = (key) => {
49
+ try {
50
+ return localStorage.getItem(key);
51
+ } catch {
52
+ return null;
53
+ }
54
+ };
55
+
64
56
  // Before first paint: stored preference wins, else system scheme.
65
57
  document.documentElement.dataset.theme =
66
- localStorage.getItem("theme") ??
58
+ pref("theme") ??
67
59
  (matchMedia("(prefers-color-scheme: dark)").matches
68
60
  ? "dark"
69
61
  : "light");
70
62
 
71
- // Also before first paint: a `?q=` deep link (a keyword chip on a
72
- // package page) means the catalog the server rendered — every package,
73
- // empty search box is the wrong one. Flag it so CSS holds the
74
- // catalog back rather than showing the full list and collapsing it a
75
- // moment later. The island drops the flag once it has applied the
76
- // query which is the render after hydration, never the hydrating one
77
- // itself; the timeout is the failsafe for an island that never
78
- // hydrates, where a flash beats a blank page.
79
- if (new URLSearchParams(location.search).get("q")) {
63
+ // Also before first paint: a deep link (a keyword chip on a package
64
+ // page) or a stored preference means the catalog the server rendered —
65
+ // every package, empty search box, name order, deprecated hidden is
66
+ // the wrong one. Flag it so CSS holds the catalog back rather than
67
+ // showing the full list and rearranging it a moment later. The island
68
+ // drops the flag once it has applied the view which is the render
69
+ // after hydration, never the hydrating one itself; the timeout is the
70
+ // failsafe for an island that never hydrates, where a flash beats a
71
+ // blank page.
72
+ const view = new URLSearchParams(location.search);
73
+ const arranged = ["sort", "dir", "deprecated"].some((k) => pref("grim.catalog." + k));
74
+ if (view.get("q") || view.get("kind") || arranged) {
80
75
  document.documentElement.dataset.query = "";
81
76
  setTimeout(() => delete document.documentElement.dataset.query, 3000);
82
77
  }
@@ -123,88 +118,25 @@ function repoLabel(url: string): string {
123
118
  </script>
124
119
  </head>
125
120
  <body>
126
- <header data-slot="site-header">
127
- <div class="shell header-row">
128
- <a class="brand" href={withBase("/")} data-slot="brand">
129
- {/* Decorative: the brand text is right beside it, so announcing the
130
- logo too would read the index's name twice. */}
131
- {config.logo && <img class="brand-logo" src={withBase(config.logo)} alt="" />}
132
- {
133
- brandRest === null ? (
134
- config.brand
135
- ) : (
136
- <Fragment>
137
- <span class="brand-mark">{config.brandMark}</span>
138
- {brandRest}
139
- </Fragment>
140
- )
141
- }
142
- </a>
143
- <nav>
144
- {config.docsUrl && <a href={config.docsUrl}>docs</a>}
145
- {
146
- config.repoUrl && (
147
- <a href={config.repoUrl} target="_blank" rel="noopener noreferrer">
148
- {repoLabel(config.repoUrl)}
149
- </a>
150
- )
151
- }
152
- {/* Stroked, not filled, and inheriting the nav's own colour, so the
153
- toggle reads as a third nav item rather than a control bolted
154
- beside them. Rendered statically — no client directive, so no
155
- JavaScript ships for the icons. */}
156
- <button id="theme-toggle" type="button" aria-label="Toggle theme">
157
- <Moon class="when-light" size={16} aria-hidden="true" />
158
- <Sun class="when-dark" size={16} aria-hidden="true" />
159
- </button>
160
- </nav>
161
- </div>
162
- </header>
121
+ <SiteHeader />
122
+
123
+ {/* Whatever this index has to say on every page. Its own element rather
124
+ than a line inside `main`, so a consumer can make it full-bleed by
125
+ restyling one slot the inner `shell` keeps it aligned with the page
126
+ until they do. */}
127
+ {
128
+ config.notice && (
129
+ <div class="notice" data-slot="site-notice">
130
+ <p class="shell">{config.notice}</p>
131
+ </div>
132
+ )
133
+ }
163
134
 
164
135
  <main class="shell">
165
136
  <slot />
166
137
  </main>
167
138
 
168
- <footer data-slot="site-footer">
169
- <div class="shell">
170
- <p>
171
- Raw data: <a href={allJson}><code>{allJson}</code></a>{
172
- config.footerNote ? ` · ${config.footerNote}` : ""
173
- }
174
- </p>
175
- {/* Whatever this index has to link — a privacy notice, an imprint, a
176
- code of conduct. Absent by default, so a footer without them looks
177
- exactly as it did before. */}
178
- {
179
- config.footerLinks.length > 0 && (
180
- <p class="footer-links">
181
- {config.footerLinks.map((link) => (
182
- <a href={link.href}>{link.label}</a>
183
- ))}
184
- </p>
185
- )
186
- }
187
- {/* Opt-out, not opt-in: an index runner who does not want to say
188
- where the software came from sets `attribution: false`, and this
189
- disappears. Nothing else on the page changes.
190
-
191
- "using", not "by": the index runner built this site — Grimoire is
192
- the tool they built it with. */}
193
- {
194
- config.attribution && (
195
- <p class="attribution">
196
- Built with <span aria-hidden="true">♥</span><span class="sr-only">love</span> using{" "}
197
- {/* New tab: this is the one link that leaves the index for an
198
- unrelated site, and a visitor mid-browse should not lose
199
- their place to it. */}
200
- <a href="https://grimoire.rs" target="_blank" rel="noopener">
201
- Grimoire
202
- </a>
203
- </p>
204
- )
205
- }
206
- </div>
207
- </footer>
139
+ <SiteFooter />
208
140
 
209
141
  {/* One toast for the whole page, outside every scrolling container so a
210
142
  copy from a card at the bottom lands in the same place as one from
@@ -216,7 +148,11 @@ function repoLabel(url: string): string {
216
148
  </div>
217
149
 
218
150
  <script is:inline>
219
- document.getElementById("theme-toggle").addEventListener("click", () => {
151
+ // Optional: an index repo that replaces `SiteHeader.astro` is free to
152
+ // ship a header with no toggle, and this block runs on every page
153
+ // regardless. Unguarded, that one dereference threw and took every
154
+ // script below it with it — copy buttons, picker wiring, toast.
155
+ document.getElementById("theme-toggle")?.addEventListener("click", () => {
220
156
  const root = document.documentElement;
221
157
  const next = root.dataset.theme === "dark" ? "light" : "dark";
222
158
  root.dataset.theme = next;
@@ -280,6 +216,26 @@ function repoLabel(url: string): string {
280
216
  }
281
217
  }
282
218
 
219
+ // Preselect the host platform in every bar that asked for it. Lives
220
+ // here rather than in `index.astro` because `detect` has to mean the
221
+ // same thing wherever a bar is drawn — a page under `theme/pages/`
222
+ // rendering `<CommandBar detect />` gets this, and used to get the
223
+ // configured first choice however the visitor arrived. A platform the
224
+ // bar does not offer leaves that first choice alone.
225
+ //
226
+ // The landing page ALSO runs this in its own script, right beside the
227
+ // hero: this one sits past the whole catalog, late enough for the
228
+ // browser to paint a Linux command at someone on Windows first. Both
229
+ // runs pick the same choice and `__grimPick` is idempotent, so the
230
+ // duplicate costs nothing and the early one is what a visitor sees.
231
+ for (const bar of document.querySelectorAll("[data-os-detect]")) {
232
+ const host = window.__grimHostOs();
233
+ const match = [...bar.querySelectorAll("[data-os-pick]")].find(
234
+ (p) => p.dataset.osName === host,
235
+ );
236
+ if (match) window.__grimPick(bar, match);
237
+ }
238
+
283
239
  // One toast for every copy on the page. The in-place check confirms
284
240
  // that *something* was copied; a card has three copy buttons a few
285
241
  // pixels apart, all of which flash the same check, so the toast is
@@ -323,10 +279,25 @@ function repoLabel(url: string): string {
323
279
  '<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
324
280
 
325
281
  for (const pre of document.querySelectorAll("pre.astro-code")) {
326
- const wrapper = document.createElement("div");
327
- wrapper.className = "code-block";
328
- pre.replaceWith(wrapper);
329
- wrapper.append(pre);
282
+ // `CodeBlock.astro` already ships the wrapper and the action slot, so
283
+ // adopt them where they are there. Wrapping a second time would nest
284
+ // two positioning contexts and orphan whatever the component put in
285
+ // the slot.
286
+ const parent = pre.parentElement;
287
+ let wrapper = parent?.classList.contains("code-block") ? parent : null;
288
+ if (!wrapper) {
289
+ wrapper = document.createElement("div");
290
+ wrapper.className = "code-block";
291
+ pre.replaceWith(wrapper);
292
+ wrapper.append(pre);
293
+ }
294
+
295
+ let actions = wrapper.querySelector(".code-actions");
296
+ if (!actions) {
297
+ actions = document.createElement("span");
298
+ actions.className = "code-actions";
299
+ wrapper.append(actions);
300
+ }
330
301
 
331
302
  const btn = document.createElement("button");
332
303
  btn.type = "button";
@@ -336,9 +307,10 @@ function repoLabel(url: string): string {
336
307
  // `textContent`, not `innerText`: the block is not rendered yet at
337
308
  // this point, and `innerText` would collapse its whitespace.
338
309
  btn.dataset.copy = pre.textContent ?? "";
339
- btn.dataset.copyName = "code block";
310
+ // A component-rendered block names itself; a markdown one cannot.
311
+ btn.dataset.copyName = wrapper.dataset.copyName || "code block";
340
312
  btn.innerHTML = CLIPBOARD_ICON + '<span class="sr-only" role="status"></span>';
341
- wrapper.append(btn);
313
+ actions.append(btn);
342
314
  }
343
315
 
344
316
  for (const btn of document.querySelectorAll("[data-copy]")) {
@@ -404,6 +376,20 @@ function repoLabel(url: string): string {
404
376
  padding-block: var(--grim-space-5);
405
377
  gap: var(--grim-space-6);
406
378
  }
379
+ /* Deliberately neutral, not the amber of `--grim-color-banner-*`:
380
+ that palette says "warning", and this line is whatever the index
381
+ has to state on every page. An index that wants it loud restyles
382
+ `[data-slot="site-notice"]` — one selector, no new token. */
383
+ .notice {
384
+ border-bottom: var(--grim-border-width) solid var(--grim-color-border);
385
+ background: var(--grim-color-chip-bg);
386
+ color: var(--grim-color-muted);
387
+ font-size: var(--grim-text-sm);
388
+ }
389
+ .notice p {
390
+ margin: 0;
391
+ padding-block: var(--grim-space-4);
392
+ }
407
393
  .brand {
408
394
  display: inline-flex;
409
395
  align-items: center;
@@ -506,7 +492,7 @@ function repoLabel(url: string): string {
506
492
  }
507
493
  .hero code {
508
494
  background: var(--grim-color-chip-bg);
509
- border-radius: var(--grim-radius-sm);
495
+ border-radius: var(--grim-radius-code);
510
496
  padding: var(--grim-space-1) var(--grim-space-3);
511
497
  /* `em`, not a type token: inline code sizes against the prose it
512
498
  sits in, so it must follow whatever that text is set at. */
@@ -521,7 +507,7 @@ function repoLabel(url: string): string {
521
507
  min-width: 0;
522
508
  background: var(--grim-color-card);
523
509
  border: var(--grim-border-width) solid var(--grim-color-border);
524
- border-radius: var(--grim-radius-lg);
510
+ border-radius: var(--grim-radius-control);
525
511
  }
526
512
  /* No `overflow: hidden` — that would clip the platform menu. The end
527
513
  segments round themselves instead (8px border box, 1px border).
@@ -677,7 +663,7 @@ function repoLabel(url: string): string {
677
663
  list-style: none;
678
664
  background: var(--grim-color-card);
679
665
  border: var(--grim-border-width) solid var(--grim-color-border);
680
- border-radius: var(--grim-radius-lg);
666
+ border-radius: var(--grim-radius-control);
681
667
  box-shadow: var(--grim-shadow-raised);
682
668
  }
683
669
  .os-menu li button {
@@ -686,7 +672,7 @@ function repoLabel(url: string): string {
686
672
  gap: var(--grim-space-4);
687
673
  width: 100%;
688
674
  border: none;
689
- border-radius: var(--grim-radius-md);
675
+ border-radius: var(--grim-radius-inset);
690
676
  background: none;
691
677
  padding: var(--grim-space-3) var(--grim-space-4);
692
678
  color: var(--grim-color-fg);
@@ -718,14 +704,19 @@ function repoLabel(url: string): string {
718
704
  shows until a hover, focus or open state paints a background — then
719
705
  square corners poke out past the bar's own 8px rounding.
720
706
  The summary is named explicitly rather than left to inherit. */
707
+ /* Clamped at zero, because the radius is a knob now: with
708
+ `--grim-radius-base: 0px` the bare subtraction is `-1px`, and a
709
+ negative `border-radius` is not invalid-but-ignored per corner —
710
+ it drops the whole declaration. Harmless while everything is
711
+ square, and a trap the moment someone rounds the site back up. */
721
712
  .cmd-bar > :first-child,
722
713
  .cmd-bar > :first-child > summary {
723
- border-start-start-radius: calc(var(--grim-radius-lg) - var(--grim-border-width));
724
- border-end-start-radius: calc(var(--grim-radius-lg) - var(--grim-border-width));
714
+ border-start-start-radius: max(0px, calc(var(--grim-radius-control) - var(--grim-border-width)));
715
+ border-end-start-radius: max(0px, calc(var(--grim-radius-control) - var(--grim-border-width)));
725
716
  }
726
717
  .cmd-bar > :last-child {
727
- border-start-end-radius: calc(var(--grim-radius-lg) - var(--grim-border-width));
728
- border-end-end-radius: calc(var(--grim-radius-lg) - var(--grim-border-width));
718
+ border-start-end-radius: max(0px, calc(var(--grim-radius-control) - var(--grim-border-width)));
719
+ border-end-end-radius: max(0px, calc(var(--grim-radius-control) - var(--grim-border-width)));
729
720
  }
730
721
 
731
722
  /* Attribution sits opposite the raw-data line, on the same row where
@@ -770,7 +761,7 @@ function repoLabel(url: string): string {
770
761
  padding: var(--grim-space-4) var(--grim-space-5);
771
762
  background: var(--grim-color-card);
772
763
  border: var(--grim-border-width) solid var(--grim-color-border);
773
- border-radius: var(--grim-radius-xl);
764
+ border-radius: var(--grim-radius-surface);
774
765
  box-shadow: var(--grim-shadow-overlay);
775
766
  /* Out of the layout and unhittable until shown, so it never eats a
776
767
  click aimed at whatever is beneath it. */
@@ -817,22 +808,30 @@ function repoLabel(url: string): string {
817
808
  visibility: hidden;
818
809
  }
819
810
 
811
+ /* Three stacked rows, not one wrapping line: the search field, then
812
+ the filters, then what they left plus the controls that arrange it.
813
+ A wrapping row put a different set on each line at each viewport
814
+ width, so the toolbar read as a different object on every screen. */
820
815
  .controls {
821
816
  display: flex;
822
- flex-wrap: wrap;
823
- align-items: center;
817
+ flex-direction: column;
818
+ align-items: stretch;
824
819
  gap: var(--grim-space-5);
825
- margin-bottom: var(--grim-space-7);
826
- }
827
- /* Full-width basis in a wrapping row: the search bar takes a line to
828
- itself and pushes both chip groups onto the next one, which then
829
- sit side by side. The container's 0.75rem gap separates the two
830
- rows, and reads wider than the 0.4rem inside a group — so sort and
831
- kind still group visually without a wrapper element. */
820
+ /* Close, because the last row of the toolbar belongs to the list
821
+ below rather than to the toolbar above it — it says what the
822
+ filters left and how it is arranged. Proximity is what groups
823
+ them; `.meta-row` opens the gap on its other side to match. */
824
+ margin-bottom: var(--grim-space-3);
825
+ }
826
+ /* Wide basis, not full width: the field and the sort control share
827
+ the first line and the kind chips wrap to the next, and on a narrow
828
+ viewport the sort control drops below the field instead of
829
+ squeezing it. The container gap separates the rows and reads wider
830
+ than the gap inside a chip group, so the groups still read as
831
+ groups without a wrapper element. */
832
832
  .search-field {
833
833
  position: relative;
834
834
  display: flex;
835
- flex: 1 1 100%;
836
835
  /* Breathing room above the field when `/` scrolls it to the top of
837
836
  the viewport, so it does not sit flush against the edge. */
838
837
  scroll-margin-top: 1.25rem;
@@ -844,7 +843,7 @@ function repoLabel(url: string): string {
844
843
  /* 2.4rem is the hint's measured width, not a rhythm step. */
845
844
  padding-right: 2.4rem;
846
845
  border: var(--grim-border-width) solid var(--grim-color-border);
847
- border-radius: var(--grim-radius-lg);
846
+ border-radius: var(--grim-radius-control);
848
847
  background: var(--grim-color-card);
849
848
  color: var(--grim-color-fg);
850
849
  font-size: var(--grim-text-md);
@@ -866,7 +865,7 @@ function repoLabel(url: string): string {
866
865
  pointer-events: none;
867
866
  border: var(--grim-border-width) solid var(--grim-color-border);
868
867
  border-bottom-width: 2px;
869
- border-radius: var(--grim-radius-md);
868
+ border-radius: var(--grim-radius-inset);
870
869
  background: var(--grim-color-chip-bg);
871
870
  color: var(--grim-color-muted);
872
871
  font-family: ui-monospace, "SFMono-Regular", monospace;
@@ -878,24 +877,99 @@ function repoLabel(url: string): string {
878
877
  .search-field:has(input:not(:placeholder-shown)) .search-hint {
879
878
  display: none;
880
879
  }
880
+ /* One line, always. Wrapping was the defect: at a narrow width the
881
+ row dropped `deprecated` onto a second line, because no chip can
882
+ shrink below its own longest word and the rail's chip count is a
883
+ constant rather than a viewport reading. So the KEYWORD group is
884
+ the single flexible child and scrolls; the kinds and the toggle
885
+ keep their size and their place. */
886
+ .filter-row {
887
+ display: flex;
888
+ align-items: center;
889
+ gap: var(--grim-space-3);
890
+ flex-wrap: nowrap;
891
+ /* Backstop for the extreme case where the kinds alone overflow. */
892
+ overflow-x: auto;
893
+ }
881
894
  .chips {
882
895
  display: flex;
883
- flex-wrap: wrap;
896
+ align-items: center;
884
897
  gap: var(--grim-space-3);
885
898
  }
886
- /* A drawn rule rather than a "|" glyph: its height is the chip row's,
887
- not whatever the font gives a pipe. `stretch` overrides the
888
- container's `align-items: center` for this one item. */
889
- .chip-sep {
899
+ .chips.kind-chips {
900
+ flex-shrink: 0;
901
+ }
902
+ /* The one child that absorbs the row's slack — which is also what
903
+ pushes the overflow menu and the deprecated toggle to the far end,
904
+ without an `auto` margin that would put them out of reach once the
905
+ row does overflow.
906
+
907
+ `hidden`, never `auto`: a scrollbar here put the chips behind a
908
+ gesture nobody looks for and, worse, let the rail grow past the row
909
+ so the overflow menu it was supposed to sit beside was pushed off
910
+ the end entirely. What does not fit is decided by measurement (see
911
+ `railFit`) and drawn as nothing. */
912
+ .chips.kw-rail {
913
+ flex: 1 1 auto;
914
+ min-width: 0;
915
+ overflow: hidden;
916
+ }
917
+ /* Past the measured fit. `visibility`, not `display`: the chip keeps
918
+ its box, so the measurement that excluded it stays true instead of
919
+ freeing the width that would let it back in. It is in the overflow
920
+ menu, which is where the reader reaches it. */
921
+ .chip.kw.clipped {
922
+ visibility: hidden;
923
+ }
924
+ .filter-divider {
925
+ flex-shrink: 0;
890
926
  align-self: stretch;
891
- width: 1px;
927
+ width: var(--grim-border-width);
892
928
  background: var(--grim-color-border);
893
929
  }
894
- /* Held at the far end of the chip row: it answers a different
895
- question from sort and kindwhat the catalog is withholding,
896
- not how to arrange it. */
930
+ /* Answers a different question from sort and kind what the catalog
931
+ is withholding, not how to arrange it so it sits apart, at the
932
+ end of the row. */
897
933
  .chip.deprecated-toggle {
898
- margin-left: auto;
934
+ flex-shrink: 0;
935
+ }
936
+ /* Lifts every keyword at once, and is only in the markup while there
937
+ is one to lift — so it never competes with the rail for room in the
938
+ common case. Muted until hovered: it undoes work, and it should not
939
+ read as another facet to pick. */
940
+ .chip.kw-clear {
941
+ flex-shrink: 0;
942
+ gap: var(--grim-space-2);
943
+ color: var(--grim-color-muted);
944
+ }
945
+ .chip.kw-clear:hover,
946
+ .chip.kw-clear:focus-visible {
947
+ border-color: var(--grim-color-accent);
948
+ color: var(--grim-color-fg);
949
+ }
950
+ .kw-menu {
951
+ flex-shrink: 0;
952
+ }
953
+ /* Row three: the answer on the left, the controls that arrange it on
954
+ the right. */
955
+ .meta-row {
956
+ display: flex;
957
+ align-items: center;
958
+ gap: var(--grim-space-4);
959
+ flex-wrap: wrap;
960
+ /* On top of the stack's own gap: it takes this row away from the
961
+ filters and leaves it sitting on the list it describes. */
962
+ margin-top: var(--grim-space-5);
963
+ }
964
+ /* Bottom-aligned, not centred: it is a caption for the list below
965
+ it, and centring it against two bordered controls left it floating
966
+ between the two rows belonging to neither. */
967
+ .result-count {
968
+ margin: 0;
969
+ margin-right: auto;
970
+ align-self: end;
971
+ color: var(--grim-color-muted);
972
+ font-size: var(--grim-text-sm);
899
973
  }
900
974
  /* Pressed state borrows the deprecation colour rather than the
901
975
  accent, so "deprecated entries are in view" is signalled by the
@@ -905,20 +979,81 @@ function repoLabel(url: string): string {
905
979
  border-color: var(--grim-color-deprecated);
906
980
  background: var(--grim-color-chip-bg);
907
981
  }
982
+ /* One object with two halves — a direction cap and the field —
983
+ sharing an edge, and built to the search field's own measurements
984
+ so the pair sits level with it at the same height. Squarer than a
985
+ chip on purpose: a chip toggles a filter, this chooses a value,
986
+ and it belongs to the field beside it rather than to the row of
987
+ filters below. */
988
+ .sort-group {
989
+ display: flex;
990
+ align-items: stretch;
991
+ flex: 0 0 auto;
992
+ }
993
+ /* Sized to their own row, not to the search field. They used to sit
994
+ beside it and had to match its height to read as one strip; on a
995
+ line of their own that height is just bulk. */
996
+ .sort-dir,
997
+ .sort-field {
998
+ position: relative;
999
+ padding: var(--grim-space-2) var(--grim-space-4);
1000
+ border: var(--grim-border-width) solid var(--grim-color-border);
1001
+ background: var(--grim-color-card);
1002
+ color: var(--grim-color-fg);
1003
+ font-family: inherit;
1004
+ font-size: var(--grim-text-sm);
1005
+ cursor: pointer;
1006
+ }
1007
+ .sort-dir {
1008
+ display: inline-flex;
1009
+ align-items: center;
1010
+ border-radius: var(--grim-radius-control) 0 0 var(--grim-radius-control);
1011
+ }
1012
+ /* Pulled onto its neighbour's border so the seam is one line, not a
1013
+ doubled one. Derived from the token, never a hand-written -1px. */
1014
+ .sort-field {
1015
+ border-radius: 0 var(--grim-radius-control) var(--grim-radius-control) 0;
1016
+ margin-left: calc(-1 * var(--grim-border-width));
1017
+ }
1018
+ .sort-dir:hover,
1019
+ .sort-field:hover {
1020
+ border-color: var(--grim-color-accent);
1021
+ }
1022
+ /* Whichever half is hovered or focused owns the shared edge. */
1023
+ .sort-dir:hover,
1024
+ .sort-field:hover,
1025
+ .sort-dir:focus-visible,
1026
+ .sort-field:focus-visible {
1027
+ z-index: 1;
1028
+ }
1029
+ .sort-dir:focus-visible,
1030
+ .sort-field:focus-visible {
1031
+ outline: 2px solid var(--grim-color-accent);
1032
+ outline-offset: 1px;
1033
+ }
1034
+ /* One box for every chip in the filter row — the kinds, the keywords,
1035
+ the deprecated toggle and the overflow menu's trigger alike. The
1036
+ line-height is stated rather than inherited because a `<summary>`
1037
+ and a `<button>` do not agree on their own, and a row of chips that
1038
+ disagree by a pixel reads as sloppy rather than as a group. */
908
1039
  .chip {
1040
+ display: inline-flex;
1041
+ align-items: center;
909
1042
  border: var(--grim-border-width) solid var(--grim-color-border);
910
1043
  background: var(--grim-color-chip-bg);
911
1044
  color: var(--grim-color-fg);
912
1045
  border-radius: var(--grim-radius-pill);
913
1046
  padding: var(--grim-space-2) var(--grim-space-5);
914
1047
  font-size: var(--grim-text-sm);
1048
+ line-height: 1.4;
1049
+ white-space: nowrap;
915
1050
  transition: border-color var(--grim-duration-base) ease;
916
1051
  cursor: pointer;
917
1052
  }
918
1053
  .chip small {
919
1054
  color: var(--grim-color-muted);
920
1055
  }
921
- /* button-only: the "+N" overflow chip is a span and stays inert */
1056
+ /* button-only: a chip rendered as a span is inert and takes no hover */
922
1057
  button.chip:hover {
923
1058
  border-color: var(--grim-color-accent);
924
1059
  }
@@ -926,6 +1061,189 @@ function repoLabel(url: string): string {
926
1061
  border-color: var(--grim-color-accent);
927
1062
  background: var(--grim-color-accent-soft);
928
1063
  }
1064
+ /* The keyword rail reorders on every click — it is rescored against
1065
+ what is on screen — and the chips slide rather than jump. The FLIP
1066
+ effect in `Catalog.tsx` sets an inline `translate` back to where
1067
+ each chip was and drops it on the next frame; this transition is
1068
+ what plays that back. Without a declared duration the invert would
1069
+ simply stick. */
1070
+ .chip.kw {
1071
+ transition:
1072
+ border-color var(--grim-duration-base) ease,
1073
+ translate var(--grim-duration-slow) ease-out;
1074
+ }
1075
+ @media (prefers-reduced-motion: reduce) {
1076
+ .chip.kw {
1077
+ transition: border-color var(--grim-duration-base) ease;
1078
+ }
1079
+ }
1080
+ /* The overflow menu. A popover, and it has to be one: `.filter-row`
1081
+ above is a scroll container, and an absolutely-positioned panel
1082
+ inside a scroll container is cropped to the row AND stretches the
1083
+ row's scroll extent — which is where a menu that opened invisibly,
1084
+ with a stray horizontal and vertical scrollbar to show for it, came
1085
+ from. The top layer is outside every ancestor's `overflow`, and it
1086
+ brings light dismiss and Escape, which the `<details>` this replaced
1087
+ never had. Its seat is `placeKwMenu` in `Catalog.tsx`. */
1088
+ .kw-menu {
1089
+ display: flex;
1090
+ }
1091
+ /* The trigger wears `.chip`, so its box comes from there and cannot
1092
+ drift a pixel off the chips it sits beside. Only what a chip does
1093
+ not cover is stated here. */
1094
+ .kw-menu > button {
1095
+ display: inline-flex;
1096
+ align-items: center;
1097
+ color: var(--grim-color-muted);
1098
+ }
1099
+ .kw-menu > button:hover,
1100
+ .kw-menu > button:focus-visible,
1101
+ .kw-menu > button[aria-expanded="true"] {
1102
+ border-color: var(--grim-color-accent);
1103
+ color: var(--grim-color-fg);
1104
+ }
1105
+ /* `inset` and `margin` undo the UA's centring of a popover, so the
1106
+ `left`/`top` the script writes are the whole position. `color` is
1107
+ restated because the UA sets `CanvasText` on `[popover]` directly,
1108
+ which beats what the panel would otherwise inherit. `display` is
1109
+ deliberately absent: the UA's `display: none` for a closed popover
1110
+ has to keep winning, so the flex box is declared on `:popover-open`
1111
+ alone — an unconditional `display` here would show the menu shut. */
1112
+ .kw-menu-panel {
1113
+ position: fixed;
1114
+ inset: auto;
1115
+ margin: 0;
1116
+ /* The panel's own measurement: wide enough for the longest keyword
1117
+ a publisher is likely to write plus its count, and capped so a
1118
+ long vocabulary scrolls instead of running off the viewport. */
1119
+ width: 15rem;
1120
+ padding: var(--grim-space-3);
1121
+ background: var(--grim-color-card);
1122
+ color: var(--grim-color-fg);
1123
+ border: var(--grim-border-width) solid var(--grim-color-border);
1124
+ border-radius: var(--grim-radius-control);
1125
+ box-shadow: var(--grim-shadow-raised);
1126
+ /* The list inside is the thing that scrolls. Without this the UA's
1127
+ own `overflow: auto` on `[popover]` adds a second scrollbar once
1128
+ the script caps the panel's height. */
1129
+ overflow: hidden;
1130
+ }
1131
+ .kw-menu-panel:popover-open {
1132
+ display: flex;
1133
+ flex-direction: column;
1134
+ gap: var(--grim-space-3);
1135
+ }
1136
+ .kw-menu-search {
1137
+ width: 100%;
1138
+ padding: var(--grim-space-3) var(--grim-space-4);
1139
+ border: var(--grim-border-width) solid var(--grim-color-border);
1140
+ border-radius: var(--grim-radius-inset);
1141
+ background: var(--grim-color-bg);
1142
+ color: var(--grim-color-fg);
1143
+ font: inherit;
1144
+ font-size: var(--grim-text-sm);
1145
+ }
1146
+ .kw-menu-search:focus-visible {
1147
+ outline: 2px solid var(--grim-color-accent);
1148
+ outline-offset: 1px;
1149
+ }
1150
+ .kw-menu-list {
1151
+ display: flex;
1152
+ flex-direction: column;
1153
+ /* Ten rows and then a scrollbar — a measured height, not a rhythm
1154
+ step: it is the row's own height times the count that still reads
1155
+ as a list rather than a page. */
1156
+ max-height: 15rem;
1157
+ overflow-y: auto;
1158
+ /* And shorter still where the panel's own cap is tighter — a
1159
+ flex item's automatic minimum size is its content, so without
1160
+ `min-height` the list refuses to shrink and overflows the panel
1161
+ the script just sized to the space below the trigger. */
1162
+ min-height: 0;
1163
+ }
1164
+ .kw-menu-item {
1165
+ display: flex;
1166
+ align-items: center;
1167
+ justify-content: space-between;
1168
+ gap: var(--grim-space-4);
1169
+ width: 100%;
1170
+ border: none;
1171
+ border-radius: var(--grim-radius-inset);
1172
+ background: none;
1173
+ padding: var(--grim-space-3) var(--grim-space-4);
1174
+ color: var(--grim-color-fg);
1175
+ font: inherit;
1176
+ font-size: var(--grim-text-sm);
1177
+ text-align: left;
1178
+ cursor: pointer;
1179
+ }
1180
+ .kw-menu-item:hover,
1181
+ .kw-menu-item:focus-visible {
1182
+ background: var(--grim-color-chip-bg);
1183
+ }
1184
+ .kw-menu-item small {
1185
+ color: var(--grim-color-muted);
1186
+ }
1187
+ .kw-menu-empty {
1188
+ margin: 0;
1189
+ padding: var(--grim-space-3) var(--grim-space-4);
1190
+ color: var(--grim-color-muted);
1191
+ font-size: var(--grim-text-sm);
1192
+ }
1193
+ /* Cards or list, beside the sort control because it answers the same
1194
+ question — how the catalog is arranged. Built to the sort group's
1195
+ measurements, and the second button is pulled onto the first's
1196
+ border so the seam is one line rather than a doubled one. */
1197
+ .view-toggle {
1198
+ display: flex;
1199
+ align-items: stretch;
1200
+ flex: 0 0 auto;
1201
+ /* The row centres its items, which left these two shorter than the
1202
+ search field and the sort control beside them: a flex container
1203
+ raises no strut, so a button holding nothing but a 15px icon is
1204
+ 15px tall plus its padding, while the select next to it is a text
1205
+ line tall. Stretching to the line's own height is what makes the
1206
+ three agree without any of them stating a height — `.sort-group`
1207
+ gets the same result from its own `align-items: stretch`, since
1208
+ the select inside it is the tall one. */
1209
+ align-self: stretch;
1210
+ }
1211
+ .view-pick {
1212
+ position: relative;
1213
+ display: inline-flex;
1214
+ align-items: center;
1215
+ justify-content: center;
1216
+ padding: var(--grim-space-2) var(--grim-space-4);
1217
+ border: var(--grim-border-width) solid var(--grim-color-border);
1218
+ background: var(--grim-color-card);
1219
+ color: var(--grim-color-muted);
1220
+ cursor: pointer;
1221
+ }
1222
+ .view-pick:first-child {
1223
+ border-radius: var(--grim-radius-control) 0 0 var(--grim-radius-control);
1224
+ }
1225
+ .view-pick:last-child {
1226
+ border-radius: 0 var(--grim-radius-control) var(--grim-radius-control) 0;
1227
+ margin-left: calc(-1 * var(--grim-border-width));
1228
+ }
1229
+ .view-pick:hover {
1230
+ border-color: var(--grim-color-accent);
1231
+ }
1232
+ .view-pick.active {
1233
+ border-color: var(--grim-color-accent);
1234
+ background: var(--grim-color-accent-soft);
1235
+ color: var(--grim-color-fg);
1236
+ }
1237
+ /* Whichever half is hovered, focused or current owns the shared edge. */
1238
+ .view-pick:hover,
1239
+ .view-pick:focus-visible,
1240
+ .view-pick.active {
1241
+ z-index: 1;
1242
+ }
1243
+ .view-pick:focus-visible {
1244
+ outline: 2px solid var(--grim-color-accent);
1245
+ outline-offset: 1px;
1246
+ }
929
1247
 
930
1248
  .grid {
931
1249
  list-style: none;
@@ -935,14 +1253,41 @@ function repoLabel(url: string): string {
935
1253
  grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
936
1254
  gap: var(--grim-space-6);
937
1255
  }
1256
+ /* Off-screen cards skip layout and paint until they are scrolled to.
1257
+ A card is the expensive item on this page — a masked keyword strip,
1258
+ a clipped watermark in its own stacking context, a logo — and the
1259
+ grid and the table are separate component trees, so switching view
1260
+ repaints every one of them at once.
1261
+
1262
+ `auto` remembers each card's last real height, so the scrollbar
1263
+ stops jumping after the first pass; the `18rem` is only the guess
1264
+ before that. Cards stay focusable and findable — the engine renders
1265
+ one on focus or find-in-page.
1266
+
1267
+ ponytail: this bounds the PAINT, not the JS. Measured in jsdom (no
1268
+ layout at all) the Preact commit for one view switch is ~30ms at 100
1269
+ packages, ~90-170ms at 500 and ~180-350ms at 1000, because the two
1270
+ views share no element types and every card unmounts as every row
1271
+ mounts. Past a few hundred packages the answer is windowing the
1272
+ list, not another CSS property. */
1273
+ .grid > li {
1274
+ content-visibility: auto;
1275
+ contain-intrinsic-size: auto 18rem;
1276
+ }
938
1277
  .card {
939
1278
  display: flex;
940
1279
  flex-direction: column;
941
- gap: var(--grim-space-3);
1280
+ gap: var(--grim-space-5);
942
1281
  background: var(--grim-color-card);
943
1282
  border: var(--grim-border-width) solid var(--grim-color-border);
944
- border-radius: var(--grim-radius-xl);
1283
+ border-radius: var(--grim-radius-surface);
945
1284
  padding: var(--grim-space-6) var(--grim-space-6);
1285
+ /* Crops the kind watermark to the card's rounded edge, and gives
1286
+ it a stacking context to sit behind in. Without `isolation` a
1287
+ `z-index: -1` child escapes to the root and disappears under the
1288
+ card's own background instead of showing through it. */
1289
+ overflow: hidden;
1290
+ isolation: isolate;
946
1291
  /* Whole card navigates to the detail page via the stretched title
947
1292
  link below; real controls inside re-stack above the overlay. */
948
1293
  position: relative;
@@ -951,55 +1296,126 @@ function repoLabel(url: string): string {
951
1296
  }
952
1297
  /* Card highlight yields while an inner control is hovered, so its
953
1298
  own hover state reads as the click target instead. */
954
- .card:hover:not(
955
- :has(.keywords :hover, .copy-group :hover, .source:hover)
956
- ) {
1299
+ .card:hover:not(:has(.keywords :hover, .copy-group :hover, .rating-group a:hover)) {
957
1300
  border-color: var(--grim-color-accent);
958
1301
  }
959
- /* Keyboard focus wears the hover treatment rather than an outline on
960
- top of it one affordance, whichever way the card was reached.
961
- The border-colour change is still a visible focus indicator, which
962
- is the part that may not be dropped. */
1302
+ /* Keyboard focus reads as a thicker edge, not a second decoration:
1303
+ the border takes the accent and a 2px outline sits directly
1304
+ outside it, so the two merge into one ring that follows the card's
1305
+ radius. Matched to the list view's focus ring, which is the same
1306
+ 2px — a card and a row are the same Tab stop in two shapes, and a
1307
+ focus indicator that changes weight between them reads as two
1308
+ different states. */
963
1309
  .card:focus-visible {
964
- outline: none;
1310
+ outline: 2px solid var(--grim-color-accent);
1311
+ outline-offset: 0;
965
1312
  border-color: var(--grim-color-accent);
966
1313
  }
1314
+ /* Makes the whole card the package link. Everything interactive
1315
+ inside it therefore has to be lifted above this — `z-index: 0`
1316
+ here, `1` on each control, so the contract is two stated numbers
1317
+ rather than a tree-order tie that a markup edit can reverse. */
967
1318
  .card h2 a::after {
968
1319
  content: "";
969
1320
  position: absolute;
970
1321
  inset: 0;
1322
+ z-index: 0;
971
1323
  }
972
1324
  .card .keywords,
973
- .card .copy-group,
974
- .card .source {
1325
+ .card .copy-group {
975
1326
  position: relative;
976
1327
  }
1328
+ /* Two rows — the name, then the address — with the logo spanning
1329
+ both down the left. A grid rather than the flex row it was: the
1330
+ name has to be able to shrink to nothing so it can ellipsis, and
1331
+ the address has to line up under it rather than under the logo.
1332
+ Nothing shares those rows: every badge that tried cost the name
1333
+ its width on a narrow card. They live in `.card-tags` below. */
977
1334
  .card-head {
978
- display: flex;
1335
+ display: grid;
1336
+ grid-template-columns: auto minmax(0, 1fr) auto;
979
1337
  align-items: center;
980
- gap: var(--grim-space-4);
1338
+ column-gap: var(--grim-space-4);
1339
+ }
1340
+ .card-head .card-logo {
1341
+ grid-row: 1 / span 2;
1342
+ }
1343
+ /* The address line runs the full width under the name: the kind is
1344
+ a word on it now, not a badge in the head, so nothing sits to its
1345
+ right to make room for. */
1346
+ .card-head .namespace {
1347
+ grid-column: 2 / -1;
1348
+ grid-row: 2;
1349
+ }
1350
+ .card-head h2 {
1351
+ grid-column: 2;
1352
+ grid-row: 1;
1353
+ }
1354
+ /* Level with the name, in the card's top-right corner. The only
1355
+ thing that shares this row — the kind badge that used to sit here
1356
+ went down to the address line, which is what stopped the name
1357
+ being squeezed between two of them. */
1358
+ .card-head .rating-group {
1359
+ grid-column: 3;
1360
+ grid-row: 1;
1361
+ justify-self: end;
1362
+ }
1363
+ /* Texture, not an icon: oversized and hung off the card's own
1364
+ bottom-right corner, so `overflow: hidden` clips it to the rounded
1365
+ edge and only part of the glyph is on the card at all.
1366
+ The overhang is a share of the glyph, not a length: `translate`
1367
+ resolves its percentages against the element's own box, so the
1368
+ card and the detail panel hang their marks out by the same
1369
+ fraction even though the two are drawn at different sizes. An
1370
+ inset percentage would not — those resolve against the container,
1371
+ which is a different width on each page. */
1372
+ .card-watermark {
1373
+ position: absolute;
1374
+ z-index: -1;
1375
+ /* On the element, never on the stroke colour. The glyph is
1376
+ overlapping stroked paths; a translucent colour composites each
1377
+ crossing twice and leaves the joins visibly darker than the
1378
+ lines they join. Opacity here rasterizes the whole glyph at full
1379
+ strength and blends that once, so the tint is flat. */
1380
+ opacity: 0.14;
1381
+ bottom: 0;
1382
+ right: 0;
1383
+ transform: translate(26%, 26%);
1384
+ pointer-events: none;
981
1385
  }
982
1386
  .card h2 {
983
- flex: 1;
984
1387
  min-width: 0;
985
1388
  margin: 0;
986
1389
  font-size: var(--grim-text-lg);
1390
+ /* Tight, so the address sits under the name rather than a line
1391
+ away from it — the two are one label in two parts, and the
1392
+ body's reading leading put a paragraph break between them. */
1393
+ line-height: 1.2;
987
1394
  font-family: ui-monospace, "SFMono-Regular", monospace;
988
- word-break: break-word;
989
1395
  }
1396
+ /* One line, always. A package name is a single token, so wrapping it
1397
+ gave a two-line head on a long name and a ragged grid; the ellipsis
1398
+ keeps every card's head the same height, and the full name is on
1399
+ the page this links to. Colour and underline come from the card,
1400
+ not from the link default — the whole card is the link, so the
1401
+ name is a heading that happens to be clickable. */
990
1402
  .card h2 a {
1403
+ display: block;
1404
+ overflow: hidden;
1405
+ text-overflow: ellipsis;
1406
+ white-space: nowrap;
991
1407
  color: inherit;
992
1408
  text-decoration: none;
993
1409
  }
1410
+ /* Geometry, not a scale step: the tile is sized to the two rows it
1411
+ spans — the name line plus the address line under it — so it reads
1412
+ as one block beside them rather than a badge floating against the
1413
+ first. `.logo-mark` is a percentage of this, so the placeholder
1414
+ and the broken glyph follow on their own. */
994
1415
  .card-logo {
995
- flex-shrink: 0;
996
- width: 28px;
997
- height: 28px;
998
- border-radius: var(--grim-radius-md);
999
- }
1000
- img.card-logo {
1001
- object-fit: contain;
1002
- background: var(--grim-color-chip-bg);
1416
+ width: 36px;
1417
+ height: 36px;
1418
+ border-radius: var(--grim-radius-inset);
1003
1419
  }
1004
1420
  .card-logo-fallback {
1005
1421
  display: flex;
@@ -1007,7 +1423,8 @@ function repoLabel(url: string): string {
1007
1423
  justify-content: center;
1008
1424
  color: var(--grim-color-on-accent);
1009
1425
  font-weight: 700;
1010
- font-size: var(--grim-text-sm);
1426
+ /* Up a step with the tile: `sm` left the letter adrift in 40px. */
1427
+ font-size: var(--grim-text-lg);
1011
1428
  font-family: ui-monospace, "SFMono-Regular", monospace;
1012
1429
  }
1013
1430
  /* THE LOGO SLOT — one box, three states, no layout shift.
@@ -1020,17 +1437,26 @@ function repoLabel(url: string): string {
1020
1437
 
1021
1438
  loading placeholder, no slash — the resting state
1022
1439
  ready image faded in, placeholder faded out
1023
- broken placeholder with its slash, dashed edge
1440
+ broken placeholder with its slash
1024
1441
 
1025
1442
  Kept distinct from `.card-logo-fallback`: that is a package with
1026
1443
  no logo at all, which is normal, not a fault. */
1444
+ /* No background. A logo is already a designed mark on its own ground,
1445
+ and a tinted square behind it framed it twice — worst on the many
1446
+ logos that are themselves a rounded square, which then sat inside a
1447
+ slightly larger rounded square. What is left is a box with a size,
1448
+ so the three states still swap without shifting anything. Nothing is
1449
+ drawn in any of them: the placeholder and the broken glyph carry
1450
+ their own colour, and the slash is what separates the two — a
1451
+ dashed frame around the broken one read as a fault in the layout
1452
+ rather than in the image, which is not what it means. The state is
1453
+ still announced, through `role="img"` and a label on the slot. */
1027
1454
  .logo-slot {
1028
1455
  position: relative;
1029
1456
  display: flex;
1030
1457
  align-items: center;
1031
1458
  justify-content: center;
1032
1459
  overflow: hidden;
1033
- background: var(--grim-color-chip-bg);
1034
1460
  color: var(--grim-color-muted);
1035
1461
  }
1036
1462
  .logo-slot .logo-mark,
@@ -1063,15 +1489,220 @@ function repoLabel(url: string): string {
1063
1489
  .logo-slot:not([data-state="broken"]) .mark-broken {
1064
1490
  display: none;
1065
1491
  }
1066
- .logo-slot[data-state="broken"] {
1067
- border: var(--grim-border-width) dashed var(--grim-color-border);
1068
- }
1069
1492
  @media (prefers-reduced-motion: reduce) {
1070
1493
  .logo-slot .logo-mark,
1071
1494
  .logo-slot img {
1072
1495
  transition: none;
1073
1496
  }
1074
1497
  }
1498
+ /* The list view: a grid of row anchors, not a `<table>`.
1499
+ No headers, deliberately — a header row that cannot sort is a
1500
+ header row that looks like it sorts, and sorting lives in the
1501
+ toolbar. With the headers gone there are no column semantics left
1502
+ to justify the element, and what remains is a list of links.
1503
+ `subgrid` keeps the columns aligned without the markup.
1504
+
1505
+ The container owns the border, the radius and the clip; the rows
1506
+ own the rules between them. That is what makes the corners round
1507
+ in every state — a row's hover fill and focus ring are clipped to
1508
+ the container rather than cutting square across it. */
1509
+ .table {
1510
+ display: grid;
1511
+ /* logo | kind | name | description | updated. The two fixed-width
1512
+ marks lead, so the names start on one line down the whole list
1513
+ and the eye runs the column it actually reads. The name is
1514
+ capped rather than `max-content`: an unbounded name ate the
1515
+ description's space, and past the cap both clip instead. The
1516
+ description is the only track that grows. */
1517
+ grid-template-columns: auto auto minmax(8rem, 14rem) 1fr auto;
1518
+ background: var(--grim-color-card);
1519
+ border: var(--grim-border-width) solid var(--grim-color-border);
1520
+ /* `xl`, the content-surface step — the same one the card wears.
1521
+ The two are one list in two shapes, so a reader switching between
1522
+ them should see the corner stay put. It was `lg`, the control
1523
+ step, which put the table's corners with the sort pair's rather
1524
+ than with the cards'. */
1525
+ border-radius: var(--grim-radius-surface);
1526
+ overflow: hidden;
1527
+ }
1528
+ /* One more track when the index publishes ratings. Decided once per
1529
+ index, never per filter — see `PackageTable`. */
1530
+ .table.rated {
1531
+ grid-template-columns: auto auto minmax(8rem, 14rem) 1fr auto auto;
1532
+ }
1533
+ .table .row {
1534
+ grid-column: 1 / -1;
1535
+ display: grid;
1536
+ grid-template-columns: subgrid;
1537
+ align-items: center;
1538
+ gap: var(--grim-space-5);
1539
+ padding: var(--grim-space-3) var(--grim-space-5);
1540
+ color: inherit;
1541
+ text-decoration: none;
1542
+ font-size: var(--grim-text-sm);
1543
+ transition: background-color var(--grim-duration-base) ease;
1544
+ }
1545
+ /* The rule belongs between rows, not under the last one — which the
1546
+ container's own border already draws. */
1547
+ .table .row + .row {
1548
+ border-top: var(--grim-border-width) solid var(--grim-color-border);
1549
+ }
1550
+ .table .row:hover {
1551
+ background: var(--grim-color-chip-bg);
1552
+ }
1553
+ /* Inset, so the ring stays inside the row: an outline sits outside
1554
+ the border box and the container's `overflow: hidden` would clip
1555
+ it to a stray line on the first and last rows. Pulled in by its
1556
+ own width, which is the a11y floor rather than a style. */
1557
+ .table .row:focus-visible {
1558
+ outline: 2px solid var(--grim-color-accent);
1559
+ outline-offset: -2px;
1560
+ background: var(--grim-color-accent-soft);
1561
+ }
1562
+ /* The two rows that meet the container's corners carry its radius,
1563
+ so a hover fill or a focus ring on either follows the rounding
1564
+ instead of cutting straight across it. */
1565
+ .table .row:first-child {
1566
+ border-start-start-radius: var(--grim-radius-surface);
1567
+ border-start-end-radius: var(--grim-radius-surface);
1568
+ }
1569
+ .table .row:last-child {
1570
+ border-end-start-radius: var(--grim-radius-surface);
1571
+ border-end-end-radius: var(--grim-radius-surface);
1572
+ }
1573
+ /* Exactly one line per cell, always. Even rows are the whole reason
1574
+ to prefer this view to the cards: a cell that wraps makes its row
1575
+ taller than its neighbours, and a column the eye can no longer run
1576
+ down is a list doing the cards' job badly. */
1577
+ .table .row > * {
1578
+ min-width: 0;
1579
+ white-space: nowrap;
1580
+ overflow: hidden;
1581
+ text-overflow: ellipsis;
1582
+ /* Every cell is one text line and no cell is a flex box, so all
1583
+ five have the same height and the same baseline — the icons ride
1584
+ that line on `vertical-align` rather than being centred as boxes
1585
+ of their own. Cell-by-cell centring is what left the marks and
1586
+ the words a pixel apart; this makes them the same line. It is
1587
+ also what keeps `text-overflow` working, which a flex container
1588
+ would have silently dropped. */
1589
+ line-height: 1.6;
1590
+ }
1591
+ /* One glyph wide, and the same width whatever the kind is — so every
1592
+ name beside it starts on one line down the whole list. The colour
1593
+ is set inline, from the kind's own token or the deprecation one. */
1594
+ .t-kind {
1595
+ justify-content: center;
1596
+ }
1597
+ /* The card's tile at a row's height. Geometry, not a scale step, and
1598
+ measured against the text line beside it rather than against the
1599
+ kind mark: 20px is the tile that reads as belonging to the name
1600
+ without making the row taller than one line of type. `.logo-mark`
1601
+ and the fallback letter are proportions of the tile, so both follow
1602
+ on their own — only the letter needs a step down, since the card's
1603
+ is sized for a 36px tile. */
1604
+ .t-logo .card-logo {
1605
+ width: 20px;
1606
+ height: 20px;
1607
+ }
1608
+ .t-logo .card-logo-fallback {
1609
+ font-size: var(--grim-text-2xs);
1610
+ }
1611
+ /* The fallback for a kind this renderer has no mark for. */
1612
+ .t-kind-word {
1613
+ font-size: var(--grim-text-2xs);
1614
+ color: var(--grim-color-muted);
1615
+ }
1616
+ .t-name {
1617
+ color: var(--grim-color-fg);
1618
+ font-weight: 600;
1619
+ }
1620
+ .t-desc,
1621
+ .t-updated {
1622
+ color: var(--grim-color-muted);
1623
+ }
1624
+ /* Last column, held at the right edge: the count, then its arrow.
1625
+ The two sit next to each other rather than at opposite sides of a
1626
+ wide cell — they are one reading, and the gap that separated them
1627
+ was the cell's width showing through. */
1628
+ .t-rating {
1629
+ justify-content: flex-end;
1630
+ color: var(--grim-color-muted);
1631
+ }
1632
+ .t-rating svg {
1633
+ margin-left: var(--grim-space-2);
1634
+ }
1635
+ /* The two cells that carry a glyph centre it as a box, not on the
1636
+ text line. `vertical-align: middle` aligns to half the x-height,
1637
+ which sits about 2px under the line's true middle at this step —
1638
+ the eye reads that as the mark and the arrow sagging below the
1639
+ words beside them. Neither cell owes anything to the row's
1640
+ no-flex rule above: the kind is one mark and the rating is a
1641
+ fixed-width count plus its arrow, so there is no text here for
1642
+ `text-overflow` to elide. */
1643
+ .t-kind,
1644
+ .t-rating {
1645
+ display: flex;
1646
+ align-items: center;
1647
+ }
1648
+ /* The count carries the column's alignment, and the arrow after it
1649
+ inherits it: a fixed box, right-aligned, in tabular figures. Without
1650
+ the fixed box a three-digit count would push its own arrow right of
1651
+ the one above it. Geometry — it holds four digits at this step. */
1652
+ .t-votes {
1653
+ /* Never shrunk to its digits: the box is what holds the arrow
1654
+ after it in one place down the column. */
1655
+ flex: none;
1656
+ width: 2rem;
1657
+ text-align: right;
1658
+ font-variant-numeric: tabular-nums;
1659
+ }
1660
+ /* Every cell names its own column rather than being auto-placed.
1661
+ Auto-placement fills tracks in document order, so the moment a cell
1662
+ is dropped — which the narrow layout below does to the description
1663
+ — every cell after it slides one track left and the columns stop
1664
+ meaning what they did. Naming them makes dropping one safe. */
1665
+ .t-logo {
1666
+ grid-column: 1;
1667
+ }
1668
+ .t-kind {
1669
+ grid-column: 2;
1670
+ }
1671
+ .t-name {
1672
+ grid-column: 3;
1673
+ }
1674
+ .t-desc {
1675
+ grid-column: 4;
1676
+ }
1677
+ .t-updated {
1678
+ grid-column: 5;
1679
+ }
1680
+ .t-rating {
1681
+ grid-column: 6;
1682
+ }
1683
+ /* Narrow viewports lose the description rather than scroll sideways:
1684
+ it is context for the name beside it, and the detail page states it
1685
+ in full. The remaining cells move up a track with it, so no empty
1686
+ column and no stray gap is left behind. */
1687
+ @media (max-width: 40rem) {
1688
+ .table,
1689
+ .table.rated {
1690
+ grid-template-columns: auto auto minmax(6rem, 1fr) auto;
1691
+ }
1692
+ .table.rated {
1693
+ grid-template-columns: auto auto minmax(6rem, 1fr) auto auto;
1694
+ }
1695
+ .t-desc {
1696
+ display: none;
1697
+ }
1698
+ .t-updated {
1699
+ grid-column: 4;
1700
+ }
1701
+ .t-rating {
1702
+ grid-column: 5;
1703
+ }
1704
+ }
1705
+
1075
1706
  .badge {
1076
1707
  flex-shrink: 0;
1077
1708
  font-size: var(--grim-text-2xs);
@@ -1106,78 +1737,180 @@ function repoLabel(url: string): string {
1106
1737
  .chip[class*="kind-"] {
1107
1738
  color: var(--grim-color-fg);
1108
1739
  }
1740
+ /* A step below the name and quieter: where the package lives is
1741
+ context for the name above it, not a second heading. */
1109
1742
  .namespace {
1110
1743
  margin: 0;
1111
1744
  color: var(--grim-color-muted);
1112
- font-size: var(--grim-text-sm);
1745
+ font-size: var(--grim-text-xs);
1746
+ line-height: 1.3;
1113
1747
  font-family: ui-monospace, "SFMono-Regular", monospace;
1114
1748
  }
1115
- .meta-row {
1116
- display: flex;
1117
- flex-wrap: wrap;
1118
- align-items: center;
1119
- gap: var(--grim-space-3);
1749
+ /* The rating is one control with two halves — the count, which opens
1750
+ the forge thread, and the vote, which casts one — joined the way
1751
+ the sort combo is. It was two badges with a dash between them,
1752
+ which read as punctuation rather than as a control. */
1753
+ .rating-group {
1754
+ display: inline-flex;
1755
+ align-items: stretch;
1756
+ /* Above the card's full-card overlay (`.card h2 a::after`), so each
1757
+ half takes its own click instead of opening the package page.
1758
+ Stated rather than left to tree order: the overlay and these are
1759
+ both positioned, and "later in the DOM wins" is the kind of tie
1760
+ a later markup edit breaks silently. */
1761
+ position: relative;
1762
+ z-index: 1;
1120
1763
  }
1121
- .pill {
1764
+ /* Same treatment `.pill` gives the version and licence badges beside
1765
+ them, applied per half so the border can be shared. */
1766
+ .rating-count,
1767
+ .rating-vote {
1768
+ display: inline-flex;
1769
+ align-items: center;
1770
+ gap: var(--grim-space-1);
1122
1771
  font-size: var(--grim-text-xs);
1123
1772
  color: var(--grim-color-muted);
1124
1773
  border: var(--grim-border-width) solid var(--grim-color-border);
1125
- border-radius: var(--grim-radius-pill);
1126
- /* Optical, as above — the smallest badge in the UI. */
1774
+ /* Optical, matching `.pill` — the smallest badge in the UI. */
1127
1775
  padding: 0.03rem var(--grim-space-4);
1776
+ text-decoration: none;
1128
1777
  }
1129
- .pill.version {
1130
- font-family: ui-monospace, "SFMono-Regular", monospace;
1778
+ /* Lifted individually as well as through the group: whichever of the
1779
+ two ends up establishing a stacking context, the link is above the
1780
+ card overlay and takes its own click. */
1781
+ .rating-count,
1782
+ .rating-vote {
1783
+ position: relative;
1784
+ z-index: 1;
1785
+ }
1786
+ .rating-count {
1787
+ border-radius: var(--grim-radius-pill) 0 0 var(--grim-radius-pill);
1788
+ /* Opaque, like its tinted other half. A bordered control with a
1789
+ see-through interior let the kind watermark run straight through
1790
+ the count — the one place on the card where a big glyph and a
1791
+ small control land on the same corner. */
1792
+ background: var(--grim-color-card);
1131
1793
  }
1132
- /* The only pill carrying a glyph inline would baseline the arrow
1133
- low against the digits beside it. */
1134
- .pill.rating {
1135
- display: inline-flex;
1136
- align-items: center;
1137
- gap: var(--grim-space-1);
1794
+ /* An index with no VS Code extension configured shows the count
1795
+ alone, and a lone half is a whole pill again. */
1796
+ .rating-count:last-child {
1797
+ border-radius: var(--grim-radius-pill);
1138
1798
  }
1139
- .updated {
1140
- font-size: var(--grim-text-xs);
1141
- color: var(--grim-color-muted);
1799
+ /* Tinted rather than outlined differently: this is the half that
1800
+ posts something, and a shade says so without a second border
1801
+ colour competing with the hover state. Pulled onto its neighbour's
1802
+ border so the seam is one line, derived from the token. */
1803
+ .rating-vote {
1804
+ border-radius: 0 var(--grim-radius-pill) var(--grim-radius-pill) 0;
1805
+ margin-left: calc(-1 * var(--grim-border-width));
1806
+ background: var(--grim-color-chip-bg);
1807
+ /* Wider on the outside than the seam: the glyph sat against the
1808
+ rounded edge, which reads as clipped rather than as inset. */
1809
+ padding-inline: var(--grim-space-3) var(--grim-space-5);
1142
1810
  }
1143
- .deprecated-strip {
1811
+ a.rating-count:hover,
1812
+ a.rating-vote:hover {
1813
+ color: var(--grim-color-accent);
1814
+ border-color: var(--grim-color-accent);
1815
+ /* 2, not 1: both halves already sit at 1 so they clear the card
1816
+ overlay, and the vote half is pulled onto the count's right
1817
+ border by a negative margin. At equal z-index the later element
1818
+ wins, so hovering the count lit an edge its neighbour was
1819
+ covering — the accent appeared on three sides and stopped. */
1820
+ z-index: 2;
1821
+ }
1822
+ /* Version and recency as one aside. Italic and a step below the rest
1823
+ on purpose — it is a note about the card, not one of its facts, and
1824
+ it sits opposite two buttons that should keep the eye. Bottom-
1825
+ aligned rather than centred on them, since a centred aside reads as
1826
+ a third control in the row. */
1827
+ .card-meta {
1144
1828
  margin: 0;
1145
- font-size: var(--grim-text-sm);
1146
- color: var(--grim-color-deprecated);
1829
+ align-self: flex-end;
1830
+ font-size: var(--grim-text-2xs);
1831
+ font-style: italic;
1832
+ color: var(--grim-color-muted);
1833
+ white-space: nowrap;
1834
+ }
1835
+ .card-version {
1836
+ font-family: ui-monospace, "SFMono-Regular", monospace;
1837
+ font-style: normal;
1147
1838
  }
1148
1839
  /* Long descriptions would stretch their whole grid row; clamp to 3
1149
1840
  lines so cards stay uniform. Full text lives on the detail page. */
1841
+ /* Exactly two lines, whether it needs them or not.
1842
+ `line-clamp` alone only caps the tall ones; a one-line description
1843
+ still drew a shorter block, so every card in a row started its
1844
+ keywords and its buttons at a different height. `min-height` is the
1845
+ other half — two lines of this leading, reserved — and the two
1846
+ together make the block a fixed size regardless of the text. */
1150
1847
  .description {
1151
1848
  margin: 0;
1152
1849
  font-size: var(--grim-text-md);
1850
+ line-height: 1.45;
1851
+ min-height: calc(2 * 1.45em);
1153
1852
  /* `auto` basis, not `0`: overflow:hidden drops the min-height:auto
1154
1853
  floor, so a 0-basis item can collapse in an auto-height column. */
1155
- flex: 1 1 auto;
1854
+ flex: 0 0 auto;
1156
1855
  display: -webkit-box;
1157
1856
  -webkit-box-orient: vertical;
1158
- -webkit-line-clamp: 3;
1159
- line-clamp: 3;
1857
+ -webkit-line-clamp: 2;
1858
+ line-clamp: 2;
1160
1859
  overflow: hidden;
1161
1860
  }
1861
+ /* One row, always. A card in an auto-fill grid has to keep its
1862
+ height predictable, and a package with long keywords was wrapping
1863
+ to a second line and pushing the foot down out of step with its
1864
+ neighbours.
1865
+
1866
+ Clipped rather than counted: a "+N" chip sits at the end of the
1867
+ row, so it is the first thing the clip eats, and it would be
1868
+ claiming a number while the row silently hid more. The fade says
1869
+ "there is more" without naming a figure, and the full list is on
1870
+ the page the card links to. Same treatment the command field uses
1871
+ for its overflow. */
1872
+ /* One row, whatever it holds: `nowrap` plus `hidden` means a set of
1873
+ keywords too wide for the card is cut off rather than wrapped onto
1874
+ a second row that would unbalance the grid.
1875
+
1876
+ The fade is on the row, which is full-width — so it always sits at
1877
+ the card's right edge, and on a row whose chips fit it falls
1878
+ entirely on empty space and shows nothing. Overflow is exactly the
1879
+ case where a chip reaches under it, which is why no measurement
1880
+ and no JavaScript is needed to decide. */
1162
1881
  .keywords {
1163
1882
  display: flex;
1164
- flex-wrap: wrap;
1883
+ flex-wrap: nowrap;
1165
1884
  gap: var(--grim-space-2);
1885
+ overflow: hidden;
1886
+ mask-image: linear-gradient(to right, #000 calc(100% - 3rem), transparent);
1887
+ }
1888
+ /* Each chip keeps its own width; the row gives up, not the words. */
1889
+ .chip.keyword {
1890
+ flex: 0 0 auto;
1166
1891
  }
1167
1892
  .chip.keyword {
1168
1893
  font-size: var(--grim-text-2xs);
1169
1894
  padding: var(--grim-space-1) var(--grim-space-4);
1170
1895
  color: var(--grim-color-muted);
1171
1896
  }
1172
- .chip.keyword.overflow {
1173
- cursor: default;
1897
+ /* A card chip applies the keyword facet, so it can be the one already
1898
+ applied — it says so rather than staying muted like the rest. */
1899
+ .chip.keyword.active {
1900
+ color: var(--grim-color-fg);
1174
1901
  }
1175
1902
  .card-foot {
1176
1903
  display: flex;
1177
1904
  align-items: center;
1178
1905
  justify-content: space-between;
1179
1906
  gap: var(--grim-space-4);
1180
- margin-top: var(--grim-space-4);
1907
+ }
1908
+ /* The slack goes above the buttons, so they line up across a row
1909
+ however tall the cards are. The foot is the last group on every
1910
+ card — the keywords moved up under the head — so there is one
1911
+ element to hang it on rather than one per layout. */
1912
+ .card-foot {
1913
+ margin-top: auto;
1181
1914
  }
1182
1915
  .copy-group {
1183
1916
  display: flex;
@@ -1192,7 +1925,7 @@ function repoLabel(url: string): string {
1192
1925
  border: var(--grim-border-width) solid var(--grim-color-border);
1193
1926
  background: var(--grim-color-chip-bg);
1194
1927
  color: var(--grim-color-fg);
1195
- border-radius: var(--grim-radius-md);
1928
+ border-radius: var(--grim-radius-inset);
1196
1929
  padding: var(--grim-space-2) var(--grim-space-4);
1197
1930
  font-size: var(--grim-text-sm);
1198
1931
  font-family: ui-monospace, "SFMono-Regular", monospace;
@@ -1228,7 +1961,7 @@ function repoLabel(url: string): string {
1228
1961
  margin: 0;
1229
1962
  padding: var(--grim-space-6) var(--grim-space-6);
1230
1963
  border: var(--grim-border-width) solid var(--grim-color-border);
1231
- border-radius: var(--grim-radius-lg);
1964
+ border-radius: var(--grim-radius-control);
1232
1965
  overflow-x: auto;
1233
1966
  font-size: var(--grim-text-sm);
1234
1967
  line-height: 1.5;
@@ -1254,12 +1987,21 @@ function repoLabel(url: string): string {
1254
1987
  .astro-code span:not([style*="background"]) {
1255
1988
  background-color: transparent !important;
1256
1989
  }
1257
- /* Reveals on hover or keyboard focus, so it is not a permanent
1258
- smudge over the first line of every block. */
1259
- .code-copy {
1990
+ /* The corner the block's buttons sit in. It is a row rather than one
1991
+ positioned button because `CodeBlock.astro` can add a second one —
1992
+ a VS Code deep link — and the copy button `Base.astro` injects has
1993
+ to land beside it without either knowing the other's width. */
1994
+ .code-actions {
1260
1995
  position: absolute;
1261
1996
  top: 0.45rem;
1262
1997
  right: 0.45rem;
1998
+ display: flex;
1999
+ gap: var(--grim-space-2);
2000
+ }
2001
+ /* Reveals on hover or keyboard focus, so they are not a permanent
2002
+ smudge over the first line of every block. */
2003
+ .code-copy,
2004
+ .code-vscode {
1263
2005
  display: inline-flex;
1264
2006
  align-items: center;
1265
2007
  justify-content: center;
@@ -1267,9 +2009,10 @@ function repoLabel(url: string): string {
1267
2009
  height: 1.9rem;
1268
2010
  padding: 0;
1269
2011
  border: var(--grim-border-width) solid var(--grim-color-border);
1270
- border-radius: var(--grim-radius-md);
2012
+ border-radius: var(--grim-radius-inset);
1271
2013
  background: var(--grim-color-card);
1272
2014
  color: var(--grim-color-muted);
2015
+ text-decoration: none;
1273
2016
  cursor: pointer;
1274
2017
  opacity: 0;
1275
2018
  transition:
@@ -1278,10 +2021,13 @@ function repoLabel(url: string): string {
1278
2021
  border-color var(--grim-duration-fast) ease;
1279
2022
  }
1280
2023
  .code-block:hover .code-copy,
1281
- .code-copy:focus-visible {
2024
+ .code-block:hover .code-vscode,
2025
+ .code-copy:focus-visible,
2026
+ .code-vscode:focus-visible {
1282
2027
  opacity: 1;
1283
2028
  }
1284
- .code-copy:hover {
2029
+ .code-copy:hover,
2030
+ .code-vscode:hover {
1285
2031
  color: var(--grim-color-accent);
1286
2032
  border-color: var(--grim-color-accent);
1287
2033
  }
@@ -1291,7 +2037,8 @@ function repoLabel(url: string): string {
1291
2037
  opacity: 1;
1292
2038
  }
1293
2039
  @media (prefers-reduced-motion: reduce) {
1294
- .code-copy {
2040
+ .code-copy,
2041
+ .code-vscode {
1295
2042
  transition: none;
1296
2043
  }
1297
2044
  }
@@ -1305,10 +2052,6 @@ function repoLabel(url: string): string {
1305
2052
  gap: 0;
1306
2053
  text-decoration: none;
1307
2054
  }
1308
- .source {
1309
- font-size: var(--grim-text-sm);
1310
- white-space: nowrap;
1311
- }
1312
2055
  .empty {
1313
2056
  color: var(--grim-color-muted);
1314
2057
  }