@grimoire-rs/indexer 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/NOTICE +30 -0
  3. package/README.md +76 -331
  4. package/dist/cli/init.d.ts.map +1 -1
  5. package/dist/cli/init.js +35 -4
  6. package/dist/cli/init.js.map +1 -1
  7. package/dist/config.d.ts +107 -7
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +181 -36
  10. package/dist/config.js.map +1 -1
  11. package/dist/renderer/astro/components/CardLogo.d.ts +5 -0
  12. package/dist/renderer/astro/components/CardLogo.js +58 -0
  13. package/dist/renderer/astro/components/CardLogo.tsx +96 -0
  14. package/dist/renderer/astro/components/Catalog.d.ts +14 -1
  15. package/dist/renderer/astro/components/Catalog.js +467 -108
  16. package/dist/renderer/astro/components/Catalog.tsx +756 -349
  17. package/dist/renderer/astro/components/CommandBar.astro +66 -0
  18. package/dist/renderer/astro/components/CopyButton.d.ts +7 -0
  19. package/dist/renderer/astro/components/CopyButton.js +28 -0
  20. package/dist/renderer/astro/components/CopyButton.tsx +56 -0
  21. package/dist/renderer/astro/components/KindMark.d.ts +69 -0
  22. package/dist/renderer/astro/components/KindMark.js +66 -0
  23. package/dist/renderer/astro/components/KindMark.tsx +141 -0
  24. package/dist/renderer/astro/components/PackageCard.d.ts +18 -0
  25. package/dist/renderer/astro/components/PackageCard.js +50 -0
  26. package/dist/renderer/astro/components/PackageCard.tsx +273 -0
  27. package/dist/renderer/astro/components/PackageRow.d.ts +10 -0
  28. package/dist/renderer/astro/components/PackageRow.js +32 -0
  29. package/dist/renderer/astro/components/PackageRow.tsx +126 -0
  30. package/dist/renderer/astro/components/PickerMenu.astro +5 -14
  31. package/dist/renderer/astro/components/SiteFooter.astro +64 -0
  32. package/dist/renderer/astro/components/SiteHeader.astro +74 -0
  33. package/dist/renderer/astro/components/VersionMenu.astro +2 -2
  34. package/dist/renderer/astro/layouts/Base.astro +860 -206
  35. package/dist/renderer/astro/lib/base.d.ts +25 -0
  36. package/dist/renderer/astro/lib/base.js +23 -0
  37. package/dist/renderer/astro/lib/base.ts +27 -0
  38. package/dist/renderer/astro/lib/catalog.d.ts +24 -0
  39. package/dist/renderer/astro/lib/catalog.js +36 -0
  40. package/dist/renderer/astro/lib/catalog.ts +37 -0
  41. package/dist/renderer/astro/lib/commands.d.ts +58 -0
  42. package/dist/renderer/astro/lib/commands.js +86 -0
  43. package/dist/renderer/astro/lib/commands.ts +117 -0
  44. package/dist/renderer/astro/lib/keywordRail.d.ts +44 -0
  45. package/dist/renderer/astro/lib/keywordRail.js +99 -0
  46. package/dist/renderer/astro/lib/keywordRail.ts +110 -0
  47. package/dist/renderer/astro/pages/index.astro +40 -87
  48. package/dist/renderer/astro/pages/p/[...slug].astro +340 -195
  49. package/dist/renderer/astro/styles/tokens.css +40 -5
  50. package/dist/renderer/index.d.ts +58 -0
  51. package/dist/renderer/index.d.ts.map +1 -1
  52. package/dist/renderer/index.js +547 -5
  53. package/dist/renderer/index.js.map +1 -1
  54. package/dist/renderer/types.d.ts +9 -0
  55. package/dist/renderer/types.d.ts.map +1 -1
  56. package/package.json +9 -4
  57. package/templates/README.md +6 -0
  58. package/templates/gitignore +4 -1
  59. package/templates/theme/README.md +38 -0
  60. 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
@@ -404,6 +360,20 @@ function repoLabel(url: string): string {
404
360
  padding-block: var(--grim-space-5);
405
361
  gap: var(--grim-space-6);
406
362
  }
363
+ /* Deliberately neutral, not the amber of `--grim-color-banner-*`:
364
+ that palette says "warning", and this line is whatever the index
365
+ has to state on every page. An index that wants it loud restyles
366
+ `[data-slot="site-notice"]` — one selector, no new token. */
367
+ .notice {
368
+ border-bottom: var(--grim-border-width) solid var(--grim-color-border);
369
+ background: var(--grim-color-chip-bg);
370
+ color: var(--grim-color-muted);
371
+ font-size: var(--grim-text-sm);
372
+ }
373
+ .notice p {
374
+ margin: 0;
375
+ padding-block: var(--grim-space-4);
376
+ }
407
377
  .brand {
408
378
  display: inline-flex;
409
379
  align-items: center;
@@ -506,7 +476,7 @@ function repoLabel(url: string): string {
506
476
  }
507
477
  .hero code {
508
478
  background: var(--grim-color-chip-bg);
509
- border-radius: var(--grim-radius-sm);
479
+ border-radius: var(--grim-radius-code);
510
480
  padding: var(--grim-space-1) var(--grim-space-3);
511
481
  /* `em`, not a type token: inline code sizes against the prose it
512
482
  sits in, so it must follow whatever that text is set at. */
@@ -521,7 +491,7 @@ function repoLabel(url: string): string {
521
491
  min-width: 0;
522
492
  background: var(--grim-color-card);
523
493
  border: var(--grim-border-width) solid var(--grim-color-border);
524
- border-radius: var(--grim-radius-lg);
494
+ border-radius: var(--grim-radius-control);
525
495
  }
526
496
  /* No `overflow: hidden` — that would clip the platform menu. The end
527
497
  segments round themselves instead (8px border box, 1px border).
@@ -677,7 +647,7 @@ function repoLabel(url: string): string {
677
647
  list-style: none;
678
648
  background: var(--grim-color-card);
679
649
  border: var(--grim-border-width) solid var(--grim-color-border);
680
- border-radius: var(--grim-radius-lg);
650
+ border-radius: var(--grim-radius-control);
681
651
  box-shadow: var(--grim-shadow-raised);
682
652
  }
683
653
  .os-menu li button {
@@ -686,7 +656,7 @@ function repoLabel(url: string): string {
686
656
  gap: var(--grim-space-4);
687
657
  width: 100%;
688
658
  border: none;
689
- border-radius: var(--grim-radius-md);
659
+ border-radius: var(--grim-radius-inset);
690
660
  background: none;
691
661
  padding: var(--grim-space-3) var(--grim-space-4);
692
662
  color: var(--grim-color-fg);
@@ -718,14 +688,19 @@ function repoLabel(url: string): string {
718
688
  shows until a hover, focus or open state paints a background — then
719
689
  square corners poke out past the bar's own 8px rounding.
720
690
  The summary is named explicitly rather than left to inherit. */
691
+ /* Clamped at zero, because the radius is a knob now: with
692
+ `--grim-radius-base: 0px` the bare subtraction is `-1px`, and a
693
+ negative `border-radius` is not invalid-but-ignored per corner —
694
+ it drops the whole declaration. Harmless while everything is
695
+ square, and a trap the moment someone rounds the site back up. */
721
696
  .cmd-bar > :first-child,
722
697
  .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));
698
+ border-start-start-radius: max(0px, calc(var(--grim-radius-control) - var(--grim-border-width)));
699
+ border-end-start-radius: max(0px, calc(var(--grim-radius-control) - var(--grim-border-width)));
725
700
  }
726
701
  .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));
702
+ border-start-end-radius: max(0px, calc(var(--grim-radius-control) - var(--grim-border-width)));
703
+ border-end-end-radius: max(0px, calc(var(--grim-radius-control) - var(--grim-border-width)));
729
704
  }
730
705
 
731
706
  /* Attribution sits opposite the raw-data line, on the same row where
@@ -770,7 +745,7 @@ function repoLabel(url: string): string {
770
745
  padding: var(--grim-space-4) var(--grim-space-5);
771
746
  background: var(--grim-color-card);
772
747
  border: var(--grim-border-width) solid var(--grim-color-border);
773
- border-radius: var(--grim-radius-xl);
748
+ border-radius: var(--grim-radius-surface);
774
749
  box-shadow: var(--grim-shadow-overlay);
775
750
  /* Out of the layout and unhittable until shown, so it never eats a
776
751
  click aimed at whatever is beneath it. */
@@ -817,22 +792,30 @@ function repoLabel(url: string): string {
817
792
  visibility: hidden;
818
793
  }
819
794
 
795
+ /* Three stacked rows, not one wrapping line: the search field, then
796
+ the filters, then what they left plus the controls that arrange it.
797
+ A wrapping row put a different set on each line at each viewport
798
+ width, so the toolbar read as a different object on every screen. */
820
799
  .controls {
821
800
  display: flex;
822
- flex-wrap: wrap;
823
- align-items: center;
801
+ flex-direction: column;
802
+ align-items: stretch;
824
803
  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. */
804
+ /* Close, because the last row of the toolbar belongs to the list
805
+ below rather than to the toolbar above it — it says what the
806
+ filters left and how it is arranged. Proximity is what groups
807
+ them; `.meta-row` opens the gap on its other side to match. */
808
+ margin-bottom: var(--grim-space-3);
809
+ }
810
+ /* Wide basis, not full width: the field and the sort control share
811
+ the first line and the kind chips wrap to the next, and on a narrow
812
+ viewport the sort control drops below the field instead of
813
+ squeezing it. The container gap separates the rows and reads wider
814
+ than the gap inside a chip group, so the groups still read as
815
+ groups without a wrapper element. */
832
816
  .search-field {
833
817
  position: relative;
834
818
  display: flex;
835
- flex: 1 1 100%;
836
819
  /* Breathing room above the field when `/` scrolls it to the top of
837
820
  the viewport, so it does not sit flush against the edge. */
838
821
  scroll-margin-top: 1.25rem;
@@ -844,7 +827,7 @@ function repoLabel(url: string): string {
844
827
  /* 2.4rem is the hint's measured width, not a rhythm step. */
845
828
  padding-right: 2.4rem;
846
829
  border: var(--grim-border-width) solid var(--grim-color-border);
847
- border-radius: var(--grim-radius-lg);
830
+ border-radius: var(--grim-radius-control);
848
831
  background: var(--grim-color-card);
849
832
  color: var(--grim-color-fg);
850
833
  font-size: var(--grim-text-md);
@@ -866,7 +849,7 @@ function repoLabel(url: string): string {
866
849
  pointer-events: none;
867
850
  border: var(--grim-border-width) solid var(--grim-color-border);
868
851
  border-bottom-width: 2px;
869
- border-radius: var(--grim-radius-md);
852
+ border-radius: var(--grim-radius-inset);
870
853
  background: var(--grim-color-chip-bg);
871
854
  color: var(--grim-color-muted);
872
855
  font-family: ui-monospace, "SFMono-Regular", monospace;
@@ -878,24 +861,85 @@ function repoLabel(url: string): string {
878
861
  .search-field:has(input:not(:placeholder-shown)) .search-hint {
879
862
  display: none;
880
863
  }
864
+ /* One line, always. Wrapping was the defect: at a narrow width the
865
+ row dropped `deprecated` onto a second line, because no chip can
866
+ shrink below its own longest word and the rail's chip count is a
867
+ constant rather than a viewport reading. So the KEYWORD group is
868
+ the single flexible child and scrolls; the kinds and the toggle
869
+ keep their size and their place. */
870
+ .filter-row {
871
+ display: flex;
872
+ align-items: center;
873
+ gap: var(--grim-space-3);
874
+ flex-wrap: nowrap;
875
+ /* Backstop for the extreme case where the kinds alone overflow. */
876
+ overflow-x: auto;
877
+ }
881
878
  .chips {
882
879
  display: flex;
883
- flex-wrap: wrap;
880
+ align-items: center;
884
881
  gap: var(--grim-space-3);
885
882
  }
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 {
883
+ .chips.kind-chips {
884
+ flex-shrink: 0;
885
+ }
886
+ /* The one child that absorbs the row's slack — which is also what
887
+ pushes the overflow menu and the deprecated toggle to the far end,
888
+ without an `auto` margin that would put them out of reach once the
889
+ row does overflow.
890
+
891
+ `hidden`, never `auto`: a scrollbar here put the chips behind a
892
+ gesture nobody looks for and, worse, let the rail grow past the row
893
+ so the overflow menu it was supposed to sit beside was pushed off
894
+ the end entirely. What does not fit is decided by measurement (see
895
+ `railFit`) and drawn as nothing. */
896
+ .chips.kw-rail {
897
+ flex: 1 1 auto;
898
+ min-width: 0;
899
+ overflow: hidden;
900
+ }
901
+ /* Past the measured fit. `visibility`, not `display`: the chip keeps
902
+ its box, so the measurement that excluded it stays true instead of
903
+ freeing the width that would let it back in. It is in the overflow
904
+ menu, which is where the reader reaches it. */
905
+ .chip.kw.clipped {
906
+ visibility: hidden;
907
+ }
908
+ .filter-divider {
909
+ flex-shrink: 0;
890
910
  align-self: stretch;
891
- width: 1px;
911
+ width: var(--grim-border-width);
892
912
  background: var(--grim-color-border);
893
913
  }
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. */
914
+ /* Answers a different question from sort and kind what the catalog
915
+ is withholding, not how to arrange it so it sits apart, at the
916
+ end of the row. */
897
917
  .chip.deprecated-toggle {
898
- margin-left: auto;
918
+ flex-shrink: 0;
919
+ }
920
+ .kw-menu {
921
+ flex-shrink: 0;
922
+ }
923
+ /* Row three: the answer on the left, the controls that arrange it on
924
+ the right. */
925
+ .meta-row {
926
+ display: flex;
927
+ align-items: center;
928
+ gap: var(--grim-space-4);
929
+ flex-wrap: wrap;
930
+ /* On top of the stack's own gap: it takes this row away from the
931
+ filters and leaves it sitting on the list it describes. */
932
+ margin-top: var(--grim-space-5);
933
+ }
934
+ /* Bottom-aligned, not centred: it is a caption for the list below
935
+ it, and centring it against two bordered controls left it floating
936
+ between the two rows belonging to neither. */
937
+ .result-count {
938
+ margin: 0;
939
+ margin-right: auto;
940
+ align-self: end;
941
+ color: var(--grim-color-muted);
942
+ font-size: var(--grim-text-sm);
899
943
  }
900
944
  /* Pressed state borrows the deprecation colour rather than the
901
945
  accent, so "deprecated entries are in view" is signalled by the
@@ -905,20 +949,81 @@ function repoLabel(url: string): string {
905
949
  border-color: var(--grim-color-deprecated);
906
950
  background: var(--grim-color-chip-bg);
907
951
  }
952
+ /* One object with two halves — a direction cap and the field —
953
+ sharing an edge, and built to the search field's own measurements
954
+ so the pair sits level with it at the same height. Squarer than a
955
+ chip on purpose: a chip toggles a filter, this chooses a value,
956
+ and it belongs to the field beside it rather than to the row of
957
+ filters below. */
958
+ .sort-group {
959
+ display: flex;
960
+ align-items: stretch;
961
+ flex: 0 0 auto;
962
+ }
963
+ /* Sized to their own row, not to the search field. They used to sit
964
+ beside it and had to match its height to read as one strip; on a
965
+ line of their own that height is just bulk. */
966
+ .sort-dir,
967
+ .sort-field {
968
+ position: relative;
969
+ padding: var(--grim-space-2) var(--grim-space-4);
970
+ border: var(--grim-border-width) solid var(--grim-color-border);
971
+ background: var(--grim-color-card);
972
+ color: var(--grim-color-fg);
973
+ font-family: inherit;
974
+ font-size: var(--grim-text-sm);
975
+ cursor: pointer;
976
+ }
977
+ .sort-dir {
978
+ display: inline-flex;
979
+ align-items: center;
980
+ border-radius: var(--grim-radius-control) 0 0 var(--grim-radius-control);
981
+ }
982
+ /* Pulled onto its neighbour's border so the seam is one line, not a
983
+ doubled one. Derived from the token, never a hand-written -1px. */
984
+ .sort-field {
985
+ border-radius: 0 var(--grim-radius-control) var(--grim-radius-control) 0;
986
+ margin-left: calc(-1 * var(--grim-border-width));
987
+ }
988
+ .sort-dir:hover,
989
+ .sort-field:hover {
990
+ border-color: var(--grim-color-accent);
991
+ }
992
+ /* Whichever half is hovered or focused owns the shared edge. */
993
+ .sort-dir:hover,
994
+ .sort-field:hover,
995
+ .sort-dir:focus-visible,
996
+ .sort-field:focus-visible {
997
+ z-index: 1;
998
+ }
999
+ .sort-dir:focus-visible,
1000
+ .sort-field:focus-visible {
1001
+ outline: 2px solid var(--grim-color-accent);
1002
+ outline-offset: 1px;
1003
+ }
1004
+ /* One box for every chip in the filter row — the kinds, the keywords,
1005
+ the deprecated toggle and the overflow menu's trigger alike. The
1006
+ line-height is stated rather than inherited because a `<summary>`
1007
+ and a `<button>` do not agree on their own, and a row of chips that
1008
+ disagree by a pixel reads as sloppy rather than as a group. */
908
1009
  .chip {
1010
+ display: inline-flex;
1011
+ align-items: center;
909
1012
  border: var(--grim-border-width) solid var(--grim-color-border);
910
1013
  background: var(--grim-color-chip-bg);
911
1014
  color: var(--grim-color-fg);
912
1015
  border-radius: var(--grim-radius-pill);
913
1016
  padding: var(--grim-space-2) var(--grim-space-5);
914
1017
  font-size: var(--grim-text-sm);
1018
+ line-height: 1.4;
1019
+ white-space: nowrap;
915
1020
  transition: border-color var(--grim-duration-base) ease;
916
1021
  cursor: pointer;
917
1022
  }
918
1023
  .chip small {
919
1024
  color: var(--grim-color-muted);
920
1025
  }
921
- /* button-only: the "+N" overflow chip is a span and stays inert */
1026
+ /* button-only: a chip rendered as a span is inert and takes no hover */
922
1027
  button.chip:hover {
923
1028
  border-color: var(--grim-color-accent);
924
1029
  }
@@ -926,6 +1031,174 @@ function repoLabel(url: string): string {
926
1031
  border-color: var(--grim-color-accent);
927
1032
  background: var(--grim-color-accent-soft);
928
1033
  }
1034
+ /* The keyword rail reorders on every click — it is rescored against
1035
+ what is on screen — and the chips slide rather than jump. The FLIP
1036
+ effect in `Catalog.tsx` sets an inline `translate` back to where
1037
+ each chip was and drops it on the next frame; this transition is
1038
+ what plays that back. Without a declared duration the invert would
1039
+ simply stick. */
1040
+ .chip.kw {
1041
+ transition:
1042
+ border-color var(--grim-duration-base) ease,
1043
+ translate var(--grim-duration-slow) ease-out;
1044
+ }
1045
+ @media (prefers-reduced-motion: reduce) {
1046
+ .chip.kw {
1047
+ transition: border-color var(--grim-duration-base) ease;
1048
+ }
1049
+ }
1050
+ /* The overflow menu. A `<details>` for the same reasons the platform
1051
+ picker is one — it opens, closes and takes Escape without a line of
1052
+ script — and the panel is the `.os-menu` popover's measurements so
1053
+ the two read as the same object in two places. */
1054
+ .kw-menu {
1055
+ position: relative;
1056
+ display: flex;
1057
+ }
1058
+ /* The trigger wears `.chip`, so its box comes from there and cannot
1059
+ drift a pixel off the chips it sits beside. Only what a chip does
1060
+ not cover is stated here. */
1061
+ .kw-menu > summary {
1062
+ display: inline-flex;
1063
+ align-items: center;
1064
+ color: var(--grim-color-muted);
1065
+ /* Both spellings: the default triangle is a marker in Firefox and
1066
+ a `::-webkit-details-marker` pseudo-element in older WebKit. */
1067
+ list-style: none;
1068
+ }
1069
+ .kw-menu > summary::-webkit-details-marker {
1070
+ display: none;
1071
+ }
1072
+ .kw-menu > summary:hover,
1073
+ .kw-menu > summary:focus-visible,
1074
+ .kw-menu[open] > summary {
1075
+ border-color: var(--grim-color-accent);
1076
+ color: var(--grim-color-fg);
1077
+ }
1078
+ .kw-menu-panel {
1079
+ position: absolute;
1080
+ top: calc(100% + var(--grim-space-2));
1081
+ left: 0;
1082
+ z-index: 5;
1083
+ display: flex;
1084
+ flex-direction: column;
1085
+ gap: var(--grim-space-3);
1086
+ /* The panel's own measurement: wide enough for the longest keyword
1087
+ a publisher is likely to write plus its count, and capped so a
1088
+ long vocabulary scrolls instead of running off the viewport. */
1089
+ width: 15rem;
1090
+ padding: var(--grim-space-3);
1091
+ background: var(--grim-color-card);
1092
+ border: var(--grim-border-width) solid var(--grim-color-border);
1093
+ border-radius: var(--grim-radius-control);
1094
+ box-shadow: var(--grim-shadow-raised);
1095
+ }
1096
+ .kw-menu-search {
1097
+ width: 100%;
1098
+ padding: var(--grim-space-3) var(--grim-space-4);
1099
+ border: var(--grim-border-width) solid var(--grim-color-border);
1100
+ border-radius: var(--grim-radius-inset);
1101
+ background: var(--grim-color-bg);
1102
+ color: var(--grim-color-fg);
1103
+ font: inherit;
1104
+ font-size: var(--grim-text-sm);
1105
+ }
1106
+ .kw-menu-search:focus-visible {
1107
+ outline: 2px solid var(--grim-color-accent);
1108
+ outline-offset: 1px;
1109
+ }
1110
+ .kw-menu-list {
1111
+ display: flex;
1112
+ flex-direction: column;
1113
+ /* Ten rows and then a scrollbar — a measured height, not a rhythm
1114
+ step: it is the row's own height times the count that still reads
1115
+ as a list rather than a page. */
1116
+ max-height: 15rem;
1117
+ overflow-y: auto;
1118
+ }
1119
+ .kw-menu-item {
1120
+ display: flex;
1121
+ align-items: center;
1122
+ justify-content: space-between;
1123
+ gap: var(--grim-space-4);
1124
+ width: 100%;
1125
+ border: none;
1126
+ border-radius: var(--grim-radius-inset);
1127
+ background: none;
1128
+ padding: var(--grim-space-3) var(--grim-space-4);
1129
+ color: var(--grim-color-fg);
1130
+ font: inherit;
1131
+ font-size: var(--grim-text-sm);
1132
+ text-align: left;
1133
+ cursor: pointer;
1134
+ }
1135
+ .kw-menu-item:hover,
1136
+ .kw-menu-item:focus-visible {
1137
+ background: var(--grim-color-chip-bg);
1138
+ }
1139
+ .kw-menu-item small {
1140
+ color: var(--grim-color-muted);
1141
+ }
1142
+ .kw-menu-empty {
1143
+ margin: 0;
1144
+ padding: var(--grim-space-3) var(--grim-space-4);
1145
+ color: var(--grim-color-muted);
1146
+ font-size: var(--grim-text-sm);
1147
+ }
1148
+ /* Cards or list, beside the sort control because it answers the same
1149
+ question — how the catalog is arranged. Built to the sort group's
1150
+ measurements, and the second button is pulled onto the first's
1151
+ border so the seam is one line rather than a doubled one. */
1152
+ .view-toggle {
1153
+ display: flex;
1154
+ align-items: stretch;
1155
+ flex: 0 0 auto;
1156
+ /* The row centres its items, which left these two shorter than the
1157
+ search field and the sort control beside them: a flex container
1158
+ raises no strut, so a button holding nothing but a 15px icon is
1159
+ 15px tall plus its padding, while the select next to it is a text
1160
+ line tall. Stretching to the line's own height is what makes the
1161
+ three agree without any of them stating a height — `.sort-group`
1162
+ gets the same result from its own `align-items: stretch`, since
1163
+ the select inside it is the tall one. */
1164
+ align-self: stretch;
1165
+ }
1166
+ .view-pick {
1167
+ position: relative;
1168
+ display: inline-flex;
1169
+ align-items: center;
1170
+ justify-content: center;
1171
+ padding: var(--grim-space-2) var(--grim-space-4);
1172
+ border: var(--grim-border-width) solid var(--grim-color-border);
1173
+ background: var(--grim-color-card);
1174
+ color: var(--grim-color-muted);
1175
+ cursor: pointer;
1176
+ }
1177
+ .view-pick:first-child {
1178
+ border-radius: var(--grim-radius-control) 0 0 var(--grim-radius-control);
1179
+ }
1180
+ .view-pick:last-child {
1181
+ border-radius: 0 var(--grim-radius-control) var(--grim-radius-control) 0;
1182
+ margin-left: calc(-1 * var(--grim-border-width));
1183
+ }
1184
+ .view-pick:hover {
1185
+ border-color: var(--grim-color-accent);
1186
+ }
1187
+ .view-pick.active {
1188
+ border-color: var(--grim-color-accent);
1189
+ background: var(--grim-color-accent-soft);
1190
+ color: var(--grim-color-fg);
1191
+ }
1192
+ /* Whichever half is hovered, focused or current owns the shared edge. */
1193
+ .view-pick:hover,
1194
+ .view-pick:focus-visible,
1195
+ .view-pick.active {
1196
+ z-index: 1;
1197
+ }
1198
+ .view-pick:focus-visible {
1199
+ outline: 2px solid var(--grim-color-accent);
1200
+ outline-offset: 1px;
1201
+ }
929
1202
 
930
1203
  .grid {
931
1204
  list-style: none;
@@ -938,11 +1211,17 @@ function repoLabel(url: string): string {
938
1211
  .card {
939
1212
  display: flex;
940
1213
  flex-direction: column;
941
- gap: var(--grim-space-3);
1214
+ gap: var(--grim-space-5);
942
1215
  background: var(--grim-color-card);
943
1216
  border: var(--grim-border-width) solid var(--grim-color-border);
944
- border-radius: var(--grim-radius-xl);
1217
+ border-radius: var(--grim-radius-surface);
945
1218
  padding: var(--grim-space-6) var(--grim-space-6);
1219
+ /* Crops the kind watermark to the card's rounded edge, and gives
1220
+ it a stacking context to sit behind in. Without `isolation` a
1221
+ `z-index: -1` child escapes to the root and disappears under the
1222
+ card's own background instead of showing through it. */
1223
+ overflow: hidden;
1224
+ isolation: isolate;
946
1225
  /* Whole card navigates to the detail page via the stretched title
947
1226
  link below; real controls inside re-stack above the overlay. */
948
1227
  position: relative;
@@ -951,51 +1230,126 @@ function repoLabel(url: string): string {
951
1230
  }
952
1231
  /* Card highlight yields while an inner control is hovered, so its
953
1232
  own hover state reads as the click target instead. */
954
- .card:hover:not(
955
- :has(.keywords :hover, .copy-group :hover, .source:hover)
956
- ) {
1233
+ .card:hover:not(:has(.keywords :hover, .copy-group :hover, .rating-group a:hover)) {
957
1234
  border-color: var(--grim-color-accent);
958
1235
  }
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. */
1236
+ /* Keyboard focus reads as a thicker edge, not a second decoration:
1237
+ the border takes the accent and a 2px outline sits directly
1238
+ outside it, so the two merge into one ring that follows the card's
1239
+ radius. Matched to the list view's focus ring, which is the same
1240
+ 2px — a card and a row are the same Tab stop in two shapes, and a
1241
+ focus indicator that changes weight between them reads as two
1242
+ different states. */
963
1243
  .card:focus-visible {
964
- outline: none;
1244
+ outline: 2px solid var(--grim-color-accent);
1245
+ outline-offset: 0;
965
1246
  border-color: var(--grim-color-accent);
966
1247
  }
1248
+ /* Makes the whole card the package link. Everything interactive
1249
+ inside it therefore has to be lifted above this — `z-index: 0`
1250
+ here, `1` on each control, so the contract is two stated numbers
1251
+ rather than a tree-order tie that a markup edit can reverse. */
967
1252
  .card h2 a::after {
968
1253
  content: "";
969
1254
  position: absolute;
970
1255
  inset: 0;
1256
+ z-index: 0;
971
1257
  }
972
1258
  .card .keywords,
973
- .card .copy-group,
974
- .card .source {
1259
+ .card .copy-group {
975
1260
  position: relative;
976
1261
  }
1262
+ /* Two rows — the name, then the address — with the logo spanning
1263
+ both down the left. A grid rather than the flex row it was: the
1264
+ name has to be able to shrink to nothing so it can ellipsis, and
1265
+ the address has to line up under it rather than under the logo.
1266
+ Nothing shares those rows: every badge that tried cost the name
1267
+ its width on a narrow card. They live in `.card-tags` below. */
977
1268
  .card-head {
978
- display: flex;
1269
+ display: grid;
1270
+ grid-template-columns: auto minmax(0, 1fr) auto;
979
1271
  align-items: center;
980
- gap: var(--grim-space-4);
1272
+ column-gap: var(--grim-space-4);
1273
+ }
1274
+ .card-head .card-logo {
1275
+ grid-row: 1 / span 2;
1276
+ }
1277
+ /* The address line runs the full width under the name: the kind is
1278
+ a word on it now, not a badge in the head, so nothing sits to its
1279
+ right to make room for. */
1280
+ .card-head .namespace {
1281
+ grid-column: 2 / -1;
1282
+ grid-row: 2;
1283
+ }
1284
+ .card-head h2 {
1285
+ grid-column: 2;
1286
+ grid-row: 1;
1287
+ }
1288
+ /* Level with the name, in the card's top-right corner. The only
1289
+ thing that shares this row — the kind badge that used to sit here
1290
+ went down to the address line, which is what stopped the name
1291
+ being squeezed between two of them. */
1292
+ .card-head .rating-group {
1293
+ grid-column: 3;
1294
+ grid-row: 1;
1295
+ justify-self: end;
1296
+ }
1297
+ /* Texture, not an icon: oversized and hung off the card's own
1298
+ bottom-right corner, so `overflow: hidden` clips it to the rounded
1299
+ edge and only part of the glyph is on the card at all.
1300
+ The overhang is a share of the glyph, not a length: `translate`
1301
+ resolves its percentages against the element's own box, so the
1302
+ card and the detail panel hang their marks out by the same
1303
+ fraction even though the two are drawn at different sizes. An
1304
+ inset percentage would not — those resolve against the container,
1305
+ which is a different width on each page. */
1306
+ .card-watermark {
1307
+ position: absolute;
1308
+ z-index: -1;
1309
+ /* On the element, never on the stroke colour. The glyph is
1310
+ overlapping stroked paths; a translucent colour composites each
1311
+ crossing twice and leaves the joins visibly darker than the
1312
+ lines they join. Opacity here rasterizes the whole glyph at full
1313
+ strength and blends that once, so the tint is flat. */
1314
+ opacity: 0.14;
1315
+ bottom: 0;
1316
+ right: 0;
1317
+ transform: translate(26%, 26%);
1318
+ pointer-events: none;
981
1319
  }
982
1320
  .card h2 {
983
- flex: 1;
984
1321
  min-width: 0;
985
1322
  margin: 0;
986
1323
  font-size: var(--grim-text-lg);
1324
+ /* Tight, so the address sits under the name rather than a line
1325
+ away from it — the two are one label in two parts, and the
1326
+ body's reading leading put a paragraph break between them. */
1327
+ line-height: 1.2;
987
1328
  font-family: ui-monospace, "SFMono-Regular", monospace;
988
- word-break: break-word;
989
1329
  }
1330
+ /* One line, always. A package name is a single token, so wrapping it
1331
+ gave a two-line head on a long name and a ragged grid; the ellipsis
1332
+ keeps every card's head the same height, and the full name is on
1333
+ the page this links to. Colour and underline come from the card,
1334
+ not from the link default — the whole card is the link, so the
1335
+ name is a heading that happens to be clickable. */
990
1336
  .card h2 a {
1337
+ display: block;
1338
+ overflow: hidden;
1339
+ text-overflow: ellipsis;
1340
+ white-space: nowrap;
991
1341
  color: inherit;
992
1342
  text-decoration: none;
993
1343
  }
1344
+ /* Geometry, not a scale step: the tile is sized to the two rows it
1345
+ spans — the name line plus the address line under it — so it reads
1346
+ as one block beside them rather than a badge floating against the
1347
+ first. `.logo-mark` is a percentage of this, so the placeholder
1348
+ and the broken glyph follow on their own. */
994
1349
  .card-logo {
995
- flex-shrink: 0;
996
- width: 28px;
997
- height: 28px;
998
- border-radius: var(--grim-radius-md);
1350
+ width: 36px;
1351
+ height: 36px;
1352
+ border-radius: var(--grim-radius-inset);
999
1353
  }
1000
1354
  img.card-logo {
1001
1355
  object-fit: contain;
@@ -1007,7 +1361,8 @@ function repoLabel(url: string): string {
1007
1361
  justify-content: center;
1008
1362
  color: var(--grim-color-on-accent);
1009
1363
  font-weight: 700;
1010
- font-size: var(--grim-text-sm);
1364
+ /* Up a step with the tile: `sm` left the letter adrift in 40px. */
1365
+ font-size: var(--grim-text-lg);
1011
1366
  font-family: ui-monospace, "SFMono-Regular", monospace;
1012
1367
  }
1013
1368
  /* THE LOGO SLOT — one box, three states, no layout shift.
@@ -1072,6 +1427,207 @@ function repoLabel(url: string): string {
1072
1427
  transition: none;
1073
1428
  }
1074
1429
  }
1430
+ /* The list view: a grid of row anchors, not a `<table>`.
1431
+ No headers, deliberately — a header row that cannot sort is a
1432
+ header row that looks like it sorts, and sorting lives in the
1433
+ toolbar. With the headers gone there are no column semantics left
1434
+ to justify the element, and what remains is a list of links.
1435
+ `subgrid` keeps the columns aligned without the markup.
1436
+
1437
+ The container owns the border, the radius and the clip; the rows
1438
+ own the rules between them. That is what makes the corners round
1439
+ in every state — a row's hover fill and focus ring are clipped to
1440
+ the container rather than cutting square across it. */
1441
+ .table {
1442
+ display: grid;
1443
+ /* logo | kind | name | description | updated. The two fixed-width
1444
+ marks lead, so the names start on one line down the whole list
1445
+ and the eye runs the column it actually reads. The name is
1446
+ capped rather than `max-content`: an unbounded name ate the
1447
+ description's space, and past the cap both clip instead. The
1448
+ description is the only track that grows. */
1449
+ grid-template-columns: auto auto minmax(8rem, 14rem) 1fr auto;
1450
+ background: var(--grim-color-card);
1451
+ border: var(--grim-border-width) solid var(--grim-color-border);
1452
+ /* `xl`, the content-surface step — the same one the card wears.
1453
+ The two are one list in two shapes, so a reader switching between
1454
+ them should see the corner stay put. It was `lg`, the control
1455
+ step, which put the table's corners with the sort pair's rather
1456
+ than with the cards'. */
1457
+ border-radius: var(--grim-radius-surface);
1458
+ overflow: hidden;
1459
+ }
1460
+ /* One more track when the index publishes ratings. Decided once per
1461
+ index, never per filter — see `PackageTable`. */
1462
+ .table.rated {
1463
+ grid-template-columns: auto auto minmax(8rem, 14rem) 1fr auto auto;
1464
+ }
1465
+ .table .row {
1466
+ grid-column: 1 / -1;
1467
+ display: grid;
1468
+ grid-template-columns: subgrid;
1469
+ align-items: center;
1470
+ gap: var(--grim-space-5);
1471
+ padding: var(--grim-space-3) var(--grim-space-5);
1472
+ color: inherit;
1473
+ text-decoration: none;
1474
+ font-size: var(--grim-text-sm);
1475
+ transition: background-color var(--grim-duration-base) ease;
1476
+ }
1477
+ /* The rule belongs between rows, not under the last one — which the
1478
+ container's own border already draws. */
1479
+ .table .row + .row {
1480
+ border-top: var(--grim-border-width) solid var(--grim-color-border);
1481
+ }
1482
+ .table .row:hover {
1483
+ background: var(--grim-color-chip-bg);
1484
+ }
1485
+ /* Inset, so the ring stays inside the row: an outline sits outside
1486
+ the border box and the container's `overflow: hidden` would clip
1487
+ it to a stray line on the first and last rows. Pulled in by its
1488
+ own width, which is the a11y floor rather than a style. */
1489
+ .table .row:focus-visible {
1490
+ outline: 2px solid var(--grim-color-accent);
1491
+ outline-offset: -2px;
1492
+ background: var(--grim-color-accent-soft);
1493
+ }
1494
+ /* The two rows that meet the container's corners carry its radius,
1495
+ so a hover fill or a focus ring on either follows the rounding
1496
+ instead of cutting straight across it. */
1497
+ .table .row:first-child {
1498
+ border-start-start-radius: var(--grim-radius-surface);
1499
+ border-start-end-radius: var(--grim-radius-surface);
1500
+ }
1501
+ .table .row:last-child {
1502
+ border-end-start-radius: var(--grim-radius-surface);
1503
+ border-end-end-radius: var(--grim-radius-surface);
1504
+ }
1505
+ /* Exactly one line per cell, always. Even rows are the whole reason
1506
+ to prefer this view to the cards: a cell that wraps makes its row
1507
+ taller than its neighbours, and a column the eye can no longer run
1508
+ down is a list doing the cards' job badly. */
1509
+ .table .row > * {
1510
+ min-width: 0;
1511
+ white-space: nowrap;
1512
+ overflow: hidden;
1513
+ text-overflow: ellipsis;
1514
+ /* Every cell is one text line and no cell is a flex box, so all
1515
+ five have the same height and the same baseline — the icons ride
1516
+ that line on `vertical-align` rather than being centred as boxes
1517
+ of their own. Cell-by-cell centring is what left the marks and
1518
+ the words a pixel apart; this makes them the same line. It is
1519
+ also what keeps `text-overflow` working, which a flex container
1520
+ would have silently dropped. */
1521
+ line-height: 1.6;
1522
+ }
1523
+ /* One glyph wide, and the same width whatever the kind is — so every
1524
+ name beside it starts on one line down the whole list. The colour
1525
+ is set inline, from the kind's own token or the deprecation one. */
1526
+ .t-kind {
1527
+ text-align: center;
1528
+ }
1529
+ .t-kind svg {
1530
+ vertical-align: middle;
1531
+ }
1532
+ /* The card's tile at a row's height. Geometry, not a scale step, and
1533
+ measured against the text line beside it rather than against the
1534
+ kind mark: 20px is the tile that reads as belonging to the name
1535
+ without making the row taller than one line of type. `.logo-mark`
1536
+ and the fallback letter are proportions of the tile, so both follow
1537
+ on their own — only the letter needs a step down, since the card's
1538
+ is sized for a 36px tile. */
1539
+ .t-logo .card-logo {
1540
+ width: 20px;
1541
+ height: 20px;
1542
+ }
1543
+ .t-logo .card-logo-fallback {
1544
+ font-size: var(--grim-text-2xs);
1545
+ }
1546
+ /* The fallback for a kind this renderer has no mark for. */
1547
+ .t-kind-word {
1548
+ font-size: var(--grim-text-2xs);
1549
+ color: var(--grim-color-muted);
1550
+ }
1551
+ .t-name {
1552
+ color: var(--grim-color-fg);
1553
+ font-weight: 600;
1554
+ }
1555
+ .t-desc,
1556
+ .t-updated {
1557
+ color: var(--grim-color-muted);
1558
+ }
1559
+ /* Last column, held at the right edge: the count, then its arrow.
1560
+ The two sit next to each other rather than at opposite sides of a
1561
+ wide cell — they are one reading, and the gap that separated them
1562
+ was the cell's width showing through. */
1563
+ .t-rating {
1564
+ text-align: right;
1565
+ color: var(--grim-color-muted);
1566
+ }
1567
+ .t-rating svg {
1568
+ vertical-align: middle;
1569
+ margin-left: var(--grim-space-2);
1570
+ /* Optical, not rhythm: the glyph's ink sits high in its own box, so
1571
+ the middle of the box reads above the digits beside it. A pixel
1572
+ down puts the two on the same visual line. */
1573
+ translate: 0 1px;
1574
+ }
1575
+ /* The count carries the column's alignment, and the arrow after it
1576
+ inherits it: a fixed box, right-aligned, in tabular figures. Without
1577
+ the fixed box a three-digit count would push its own arrow right of
1578
+ the one above it. Geometry — it holds four digits at this step. */
1579
+ .t-votes {
1580
+ display: inline-block;
1581
+ width: 2rem;
1582
+ text-align: right;
1583
+ font-variant-numeric: tabular-nums;
1584
+ }
1585
+ /* Every cell names its own column rather than being auto-placed.
1586
+ Auto-placement fills tracks in document order, so the moment a cell
1587
+ is dropped — which the narrow layout below does to the description
1588
+ — every cell after it slides one track left and the columns stop
1589
+ meaning what they did. Naming them makes dropping one safe. */
1590
+ .t-logo {
1591
+ grid-column: 1;
1592
+ }
1593
+ .t-kind {
1594
+ grid-column: 2;
1595
+ }
1596
+ .t-name {
1597
+ grid-column: 3;
1598
+ }
1599
+ .t-desc {
1600
+ grid-column: 4;
1601
+ }
1602
+ .t-updated {
1603
+ grid-column: 5;
1604
+ }
1605
+ .t-rating {
1606
+ grid-column: 6;
1607
+ }
1608
+ /* Narrow viewports lose the description rather than scroll sideways:
1609
+ it is context for the name beside it, and the detail page states it
1610
+ in full. The remaining cells move up a track with it, so no empty
1611
+ column and no stray gap is left behind. */
1612
+ @media (max-width: 40rem) {
1613
+ .table,
1614
+ .table.rated {
1615
+ grid-template-columns: auto auto minmax(6rem, 1fr) auto;
1616
+ }
1617
+ .table.rated {
1618
+ grid-template-columns: auto auto minmax(6rem, 1fr) auto auto;
1619
+ }
1620
+ .t-desc {
1621
+ display: none;
1622
+ }
1623
+ .t-updated {
1624
+ grid-column: 4;
1625
+ }
1626
+ .t-rating {
1627
+ grid-column: 5;
1628
+ }
1629
+ }
1630
+
1075
1631
  .badge {
1076
1632
  flex-shrink: 0;
1077
1633
  font-size: var(--grim-text-2xs);
@@ -1106,78 +1662,180 @@ function repoLabel(url: string): string {
1106
1662
  .chip[class*="kind-"] {
1107
1663
  color: var(--grim-color-fg);
1108
1664
  }
1665
+ /* A step below the name and quieter: where the package lives is
1666
+ context for the name above it, not a second heading. */
1109
1667
  .namespace {
1110
1668
  margin: 0;
1111
1669
  color: var(--grim-color-muted);
1112
- font-size: var(--grim-text-sm);
1670
+ font-size: var(--grim-text-xs);
1671
+ line-height: 1.3;
1113
1672
  font-family: ui-monospace, "SFMono-Regular", monospace;
1114
1673
  }
1115
- .meta-row {
1116
- display: flex;
1117
- flex-wrap: wrap;
1118
- align-items: center;
1119
- gap: var(--grim-space-3);
1674
+ /* The rating is one control with two halves — the count, which opens
1675
+ the forge thread, and the vote, which casts one — joined the way
1676
+ the sort combo is. It was two badges with a dash between them,
1677
+ which read as punctuation rather than as a control. */
1678
+ .rating-group {
1679
+ display: inline-flex;
1680
+ align-items: stretch;
1681
+ /* Above the card's full-card overlay (`.card h2 a::after`), so each
1682
+ half takes its own click instead of opening the package page.
1683
+ Stated rather than left to tree order: the overlay and these are
1684
+ both positioned, and "later in the DOM wins" is the kind of tie
1685
+ a later markup edit breaks silently. */
1686
+ position: relative;
1687
+ z-index: 1;
1120
1688
  }
1121
- .pill {
1689
+ /* Same treatment `.pill` gives the version and licence badges beside
1690
+ them, applied per half so the border can be shared. */
1691
+ .rating-count,
1692
+ .rating-vote {
1693
+ display: inline-flex;
1694
+ align-items: center;
1695
+ gap: var(--grim-space-1);
1122
1696
  font-size: var(--grim-text-xs);
1123
1697
  color: var(--grim-color-muted);
1124
1698
  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. */
1699
+ /* Optical, matching `.pill` — the smallest badge in the UI. */
1127
1700
  padding: 0.03rem var(--grim-space-4);
1701
+ text-decoration: none;
1128
1702
  }
1129
- .pill.version {
1130
- font-family: ui-monospace, "SFMono-Regular", monospace;
1703
+ /* Lifted individually as well as through the group: whichever of the
1704
+ two ends up establishing a stacking context, the link is above the
1705
+ card overlay and takes its own click. */
1706
+ .rating-count,
1707
+ .rating-vote {
1708
+ position: relative;
1709
+ z-index: 1;
1710
+ }
1711
+ .rating-count {
1712
+ border-radius: var(--grim-radius-pill) 0 0 var(--grim-radius-pill);
1713
+ /* Opaque, like its tinted other half. A bordered control with a
1714
+ see-through interior let the kind watermark run straight through
1715
+ the count — the one place on the card where a big glyph and a
1716
+ small control land on the same corner. */
1717
+ background: var(--grim-color-card);
1131
1718
  }
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);
1719
+ /* An index with no VS Code extension configured shows the count
1720
+ alone, and a lone half is a whole pill again. */
1721
+ .rating-count:last-child {
1722
+ border-radius: var(--grim-radius-pill);
1138
1723
  }
1139
- .updated {
1140
- font-size: var(--grim-text-xs);
1141
- color: var(--grim-color-muted);
1724
+ /* Tinted rather than outlined differently: this is the half that
1725
+ posts something, and a shade says so without a second border
1726
+ colour competing with the hover state. Pulled onto its neighbour's
1727
+ border so the seam is one line, derived from the token. */
1728
+ .rating-vote {
1729
+ border-radius: 0 var(--grim-radius-pill) var(--grim-radius-pill) 0;
1730
+ margin-left: calc(-1 * var(--grim-border-width));
1731
+ background: var(--grim-color-chip-bg);
1732
+ /* Wider on the outside than the seam: the glyph sat against the
1733
+ rounded edge, which reads as clipped rather than as inset. */
1734
+ padding-inline: var(--grim-space-3) var(--grim-space-5);
1142
1735
  }
1143
- .deprecated-strip {
1736
+ a.rating-count:hover,
1737
+ a.rating-vote:hover {
1738
+ color: var(--grim-color-accent);
1739
+ border-color: var(--grim-color-accent);
1740
+ /* 2, not 1: both halves already sit at 1 so they clear the card
1741
+ overlay, and the vote half is pulled onto the count's right
1742
+ border by a negative margin. At equal z-index the later element
1743
+ wins, so hovering the count lit an edge its neighbour was
1744
+ covering — the accent appeared on three sides and stopped. */
1745
+ z-index: 2;
1746
+ }
1747
+ /* Version and recency as one aside. Italic and a step below the rest
1748
+ on purpose — it is a note about the card, not one of its facts, and
1749
+ it sits opposite two buttons that should keep the eye. Bottom-
1750
+ aligned rather than centred on them, since a centred aside reads as
1751
+ a third control in the row. */
1752
+ .card-meta {
1144
1753
  margin: 0;
1145
- font-size: var(--grim-text-sm);
1146
- color: var(--grim-color-deprecated);
1754
+ align-self: flex-end;
1755
+ font-size: var(--grim-text-2xs);
1756
+ font-style: italic;
1757
+ color: var(--grim-color-muted);
1758
+ white-space: nowrap;
1759
+ }
1760
+ .card-version {
1761
+ font-family: ui-monospace, "SFMono-Regular", monospace;
1762
+ font-style: normal;
1147
1763
  }
1148
1764
  /* Long descriptions would stretch their whole grid row; clamp to 3
1149
1765
  lines so cards stay uniform. Full text lives on the detail page. */
1766
+ /* Exactly two lines, whether it needs them or not.
1767
+ `line-clamp` alone only caps the tall ones; a one-line description
1768
+ still drew a shorter block, so every card in a row started its
1769
+ keywords and its buttons at a different height. `min-height` is the
1770
+ other half — two lines of this leading, reserved — and the two
1771
+ together make the block a fixed size regardless of the text. */
1150
1772
  .description {
1151
1773
  margin: 0;
1152
1774
  font-size: var(--grim-text-md);
1775
+ line-height: 1.45;
1776
+ min-height: calc(2 * 1.45em);
1153
1777
  /* `auto` basis, not `0`: overflow:hidden drops the min-height:auto
1154
1778
  floor, so a 0-basis item can collapse in an auto-height column. */
1155
- flex: 1 1 auto;
1779
+ flex: 0 0 auto;
1156
1780
  display: -webkit-box;
1157
1781
  -webkit-box-orient: vertical;
1158
- -webkit-line-clamp: 3;
1159
- line-clamp: 3;
1782
+ -webkit-line-clamp: 2;
1783
+ line-clamp: 2;
1160
1784
  overflow: hidden;
1161
1785
  }
1786
+ /* One row, always. A card in an auto-fill grid has to keep its
1787
+ height predictable, and a package with long keywords was wrapping
1788
+ to a second line and pushing the foot down out of step with its
1789
+ neighbours.
1790
+
1791
+ Clipped rather than counted: a "+N" chip sits at the end of the
1792
+ row, so it is the first thing the clip eats, and it would be
1793
+ claiming a number while the row silently hid more. The fade says
1794
+ "there is more" without naming a figure, and the full list is on
1795
+ the page the card links to. Same treatment the command field uses
1796
+ for its overflow. */
1797
+ /* One row, whatever it holds: `nowrap` plus `hidden` means a set of
1798
+ keywords too wide for the card is cut off rather than wrapped onto
1799
+ a second row that would unbalance the grid.
1800
+
1801
+ The fade is on the row, which is full-width — so it always sits at
1802
+ the card's right edge, and on a row whose chips fit it falls
1803
+ entirely on empty space and shows nothing. Overflow is exactly the
1804
+ case where a chip reaches under it, which is why no measurement
1805
+ and no JavaScript is needed to decide. */
1162
1806
  .keywords {
1163
1807
  display: flex;
1164
- flex-wrap: wrap;
1808
+ flex-wrap: nowrap;
1165
1809
  gap: var(--grim-space-2);
1810
+ overflow: hidden;
1811
+ mask-image: linear-gradient(to right, #000 calc(100% - 3rem), transparent);
1812
+ }
1813
+ /* Each chip keeps its own width; the row gives up, not the words. */
1814
+ .chip.keyword {
1815
+ flex: 0 0 auto;
1166
1816
  }
1167
1817
  .chip.keyword {
1168
1818
  font-size: var(--grim-text-2xs);
1169
1819
  padding: var(--grim-space-1) var(--grim-space-4);
1170
1820
  color: var(--grim-color-muted);
1171
1821
  }
1172
- .chip.keyword.overflow {
1173
- cursor: default;
1822
+ /* A card chip applies the keyword facet, so it can be the one already
1823
+ applied — it says so rather than staying muted like the rest. */
1824
+ .chip.keyword.active {
1825
+ color: var(--grim-color-fg);
1174
1826
  }
1175
1827
  .card-foot {
1176
1828
  display: flex;
1177
1829
  align-items: center;
1178
1830
  justify-content: space-between;
1179
1831
  gap: var(--grim-space-4);
1180
- margin-top: var(--grim-space-4);
1832
+ }
1833
+ /* The slack goes above the buttons, so they line up across a row
1834
+ however tall the cards are. The foot is the last group on every
1835
+ card — the keywords moved up under the head — so there is one
1836
+ element to hang it on rather than one per layout. */
1837
+ .card-foot {
1838
+ margin-top: auto;
1181
1839
  }
1182
1840
  .copy-group {
1183
1841
  display: flex;
@@ -1192,7 +1850,7 @@ function repoLabel(url: string): string {
1192
1850
  border: var(--grim-border-width) solid var(--grim-color-border);
1193
1851
  background: var(--grim-color-chip-bg);
1194
1852
  color: var(--grim-color-fg);
1195
- border-radius: var(--grim-radius-md);
1853
+ border-radius: var(--grim-radius-inset);
1196
1854
  padding: var(--grim-space-2) var(--grim-space-4);
1197
1855
  font-size: var(--grim-text-sm);
1198
1856
  font-family: ui-monospace, "SFMono-Regular", monospace;
@@ -1228,7 +1886,7 @@ function repoLabel(url: string): string {
1228
1886
  margin: 0;
1229
1887
  padding: var(--grim-space-6) var(--grim-space-6);
1230
1888
  border: var(--grim-border-width) solid var(--grim-color-border);
1231
- border-radius: var(--grim-radius-lg);
1889
+ border-radius: var(--grim-radius-control);
1232
1890
  overflow-x: auto;
1233
1891
  font-size: var(--grim-text-sm);
1234
1892
  line-height: 1.5;
@@ -1267,7 +1925,7 @@ function repoLabel(url: string): string {
1267
1925
  height: 1.9rem;
1268
1926
  padding: 0;
1269
1927
  border: var(--grim-border-width) solid var(--grim-color-border);
1270
- border-radius: var(--grim-radius-md);
1928
+ border-radius: var(--grim-radius-inset);
1271
1929
  background: var(--grim-color-card);
1272
1930
  color: var(--grim-color-muted);
1273
1931
  cursor: pointer;
@@ -1305,10 +1963,6 @@ function repoLabel(url: string): string {
1305
1963
  gap: 0;
1306
1964
  text-decoration: none;
1307
1965
  }
1308
- .source {
1309
- font-size: var(--grim-text-sm);
1310
- white-space: nowrap;
1311
- }
1312
1966
  .empty {
1313
1967
  color: var(--grim-color-muted);
1314
1968
  }