@grove-dev/astro 0.5.0-next.2 → 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 (72) hide show
  1. package/dist/index.js +2 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/index.d.ts +0 -2
  4. package/dist/lib/index.d.ts.map +1 -1
  5. package/dist/lib/index.js +0 -2
  6. package/dist/lib/index.js.map +1 -1
  7. package/dist/ui/button.d.ts +45 -0
  8. package/dist/ui/button.d.ts.map +1 -0
  9. package/dist/ui/button.js +82 -0
  10. package/dist/ui/button.js.map +1 -0
  11. package/package.json +8 -5
  12. package/src/components/CardGrid.astro +41 -0
  13. package/src/components/CardIcon.astro +67 -0
  14. package/src/components/CategoryGrid.astro +20 -5
  15. package/src/components/CollectionCard.astro +56 -0
  16. package/src/components/CollectionIndex.astro +21 -26
  17. package/src/components/CollectionPage.astro +36 -13
  18. package/src/components/CollectionRow.astro +62 -192
  19. package/src/components/CollectionTeaser.astro +4 -0
  20. package/src/components/ContributorsGrid.astro +4 -1
  21. package/src/components/DirectoryIndexClient.astro +127 -30
  22. package/src/components/EditorialSummary.astro +6 -5
  23. package/src/components/FilterGroupMenu.astro +24 -10
  24. package/src/components/FilterOptions.astro +6 -1
  25. package/src/components/FinalCta.astro +5 -3
  26. package/src/components/Hero.astro +85 -139
  27. package/src/components/IndexRow.astro +36 -69
  28. package/src/components/LanguageBreakdown.astro +5 -1
  29. package/src/components/OriginalCollection.astro +3 -2
  30. package/src/components/Pagination.astro +7 -20
  31. package/src/components/ProjectCard.astro +345 -0
  32. package/src/components/RecordHeader.astro +111 -82
  33. package/src/components/RecordSection.astro +14 -10
  34. package/src/components/RecordSidebar.astro +68 -20
  35. package/src/components/RefinePanel.astro +41 -62
  36. package/src/components/SmartLensTabs.astro +3 -7
  37. package/src/components/StackGrid.astro +26 -6
  38. package/src/components/SubmissionClient.astro +140 -63
  39. package/src/components/TableOfContents.astro +31 -5
  40. package/src/components/WhyThisExists.astro +1 -1
  41. package/src/index.ts +2 -2
  42. package/src/layouts/BaseLayout.astro +79 -9
  43. package/src/layouts/Header.astro +5 -4
  44. package/src/layouts/SectionHeader.astro +3 -2
  45. package/src/layouts/Seo.astro +28 -17
  46. package/src/layouts/ThemeToggle.astro +23 -6
  47. package/src/lib/index.ts +0 -2
  48. package/src/server/collections.ts +11 -0
  49. package/src/server/contrast.test.ts +82 -0
  50. package/src/server/contrast.ts +117 -0
  51. package/src/server/directory.test.ts +177 -0
  52. package/src/server/directory.ts +328 -206
  53. package/src/server/github-repo.test.ts +88 -0
  54. package/src/server/github-repo.ts +104 -0
  55. package/src/server/index.ts +4 -3
  56. package/src/server/models-home.test.ts +101 -0
  57. package/src/server/models.test.ts +135 -0
  58. package/src/server/models.ts +170 -42
  59. package/src/styles.css +128 -16
  60. package/dist/lib/scores.d.ts +0 -2
  61. package/dist/lib/scores.d.ts.map +0 -1
  62. package/dist/lib/scores.js +0 -2
  63. package/dist/lib/scores.js.map +0 -1
  64. package/src/components/CurationGrid.astro +0 -41
  65. package/src/components/DecisionRow.astro +0 -89
  66. package/src/components/ExploreByCategory.astro +0 -67
  67. package/src/components/ExploreByStack.astro +0 -78
  68. package/src/components/GroveDocumentHead.astro +0 -28
  69. package/src/components/ItemCard.astro +0 -324
  70. package/src/components/MinimalAbout.astro +0 -82
  71. package/src/components/ScoreBars.astro +0 -102
  72. package/src/lib/scores.ts +0 -1
@@ -19,15 +19,29 @@
19
19
  * scrolls the body. Cards are individually rendered so the
20
20
  * sticky container's height is bounded by the actual content.
21
21
  */
22
- import { formatStars, compact, licenseDisplay } from "@grove-dev/core";
22
+ import { licenseDisplay } from "@grove-dev/core";
23
23
  import type { RecordDetailModel } from "../server/models.js";
24
+ import { taxonomyLabel } from "../server/directory.js";
25
+ import Icon from "./Icon.astro";
24
26
  import LanguageBreakdown from "./LanguageBreakdown.astro";
25
27
 
26
28
  interface Props {
27
29
  detail: RecordDetailModel;
30
+ /**
31
+ * Pass false when a parent wrapper owns the sticky behavior (e.g.
32
+ * the detail page stacks the TOC above this sidebar inside one
33
+ * sticky column).
34
+ */
35
+ sticky?: boolean;
36
+ /**
37
+ * "column" (default) stacks the cards vertically for a rail;
38
+ * "grid" lays them out in responsive columns for full-width
39
+ * placement below the article.
40
+ */
41
+ layout?: "column" | "grid";
28
42
  }
29
43
 
30
- const { detail } = Astro.props;
44
+ const { detail, sticky = true, layout = "column" } = Astro.props;
31
45
  const {
32
46
  sidebar,
33
47
  github,
@@ -35,10 +49,8 @@ const {
35
49
  forks,
36
50
  language,
37
51
  licenseSpdx,
38
- pushedAt,
39
52
  pushedLabel,
40
53
  repoUrl,
41
- homepageUrl,
42
54
  ownerRepo,
43
55
  healthLabel,
44
56
  activityBadge,
@@ -71,16 +83,32 @@ function row(label: string, value: unknown) {
71
83
  }
72
84
  ---
73
85
 
74
- <aside class="grove-sidebar flex flex-col gap-6 lg:sticky lg:top-20 lg:max-h-[calc(100vh-5rem)] lg:overflow-y-auto" aria-label="Project data">
86
+ <aside
87
+ class:list={[
88
+ layout === "grid"
89
+ ? "grove-sidebar grid grid-cols-1 items-start gap-4 sm:grid-cols-2 xl:grid-cols-4"
90
+ : "grove-sidebar flex flex-col gap-4",
91
+ layout === "column" &&
92
+ sticky &&
93
+ "lg:sticky lg:top-20 lg:max-h-[calc(100vh-5rem)] lg:self-start lg:overflow-y-auto",
94
+ ]}
95
+ aria-label="Record metadata"
96
+ >
75
97
 
76
98
  {/* ── Activity card ──────────────────────────────────────── */}
77
99
  {sidebar.showActivity && (
78
- <section class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-background p-5 dark:border-ink-800" aria-label="Activity">
79
- <h2 class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
100
+ <section
101
+ class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-card p-4 dark:border-ink-800"
102
+ aria-labelledby="sidebar-activity-title"
103
+ >
104
+ <h2
105
+ id="sidebar-activity-title"
106
+ class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400"
107
+ >
80
108
  Activity
81
109
  </h2>
82
110
  <dl class="grove-kv m-0 flex flex-col gap-2.5 text-sm">
83
- {row("Stars", starsNumber !== null ? formatStars(starsNumber) ?? compact(starsNumber) : null) && (
111
+ {row("Stars", starsNumber !== null ? starsNumber : null) && (
84
112
  <div class="flex items-baseline justify-between gap-3">
85
113
  <dt class="text-ink-500 dark:text-ink-400">Stars</dt>
86
114
  <dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{starsNumber!.toLocaleString()}</dd>
@@ -159,8 +187,14 @@ function row(label: string, value: unknown) {
159
187
 
160
188
  {/* ── Freshness card ─────────────────────────────────────── */}
161
189
  {sidebar.showFreshness && (
162
- <section class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-background p-5 dark:border-ink-800" aria-label="Freshness">
163
- <h2 class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
190
+ <section
191
+ class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-card p-4 dark:border-ink-800"
192
+ aria-labelledby="sidebar-freshness-title"
193
+ >
194
+ <h2
195
+ id="sidebar-freshness-title"
196
+ class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400"
197
+ >
164
198
  Freshness
165
199
  </h2>
166
200
  <dl class="grove-kv m-0 flex flex-col gap-2.5 text-sm">
@@ -226,8 +260,14 @@ function row(label: string, value: unknown) {
226
260
 
227
261
  {/* ── Ecosystem card ─────────────────────────────────────── */}
228
262
  {sidebar.showEcosystem && (
229
- <section class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-background p-5 dark:border-ink-800" aria-label="Ecosystem">
230
- <h2 class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
263
+ <section
264
+ class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-card p-4 dark:border-ink-800"
265
+ aria-labelledby="sidebar-ecosystem-title"
266
+ >
267
+ <h2
268
+ id="sidebar-ecosystem-title"
269
+ class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400"
270
+ >
231
271
  Ecosystem
232
272
  </h2>
233
273
 
@@ -237,8 +277,9 @@ function row(label: string, value: unknown) {
237
277
  <ul class="m-0 flex list-none flex-wrap gap-1.5 p-0">
238
278
  {stacks.map((s) => (
239
279
  <li>
240
- <a href={`/${detail.slug}?stack=${encodeURIComponent(s)}`} class="rounded-full bg-secondary px-2 py-0.5 text-2xs font-medium text-secondary-foreground no-underline">
241
- {s}
280
+ <a href={`/${detail.slug}?stack=${encodeURIComponent(s)}`} class="inline-flex items-center gap-1.5 rounded-full bg-secondary py-0.5 pl-1.5 pr-2 text-2xs font-medium text-secondary-foreground no-underline">
281
+ <Icon name={s} category="stack" size={12} />
282
+ {taxonomyLabel("stacks", s)}
242
283
  </a>
243
284
  </li>
244
285
  ))}
@@ -251,8 +292,9 @@ function row(label: string, value: unknown) {
251
292
  <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Platforms</h3>
252
293
  <ul class="m-0 flex list-none flex-wrap gap-1.5 p-0">
253
294
  {platforms.map((p) => (
254
- <li class="rounded-full border border-ink-200 px-2 py-0.5 text-2xs font-medium text-ink-700 dark:border-ink-800 dark:text-ink-300">
255
- {p}
295
+ <li class="inline-flex items-center gap-1.5 rounded-full border border-ink-200 py-0.5 pl-1.5 pr-2 text-2xs font-medium text-ink-700 dark:border-ink-800 dark:text-ink-300">
296
+ <Icon name={p} category="platform" size={12} />
297
+ {taxonomyLabel("platforms", p)}
256
298
  </li>
257
299
  ))}
258
300
  </ul>
@@ -263,7 +305,7 @@ function row(label: string, value: unknown) {
263
305
  <div class="mb-3">
264
306
  <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Category</h3>
265
307
  <a href={`/${detail.slug}?category=${encodeURIComponent(category)}`} class="rounded-full bg-secondary px-2 py-0.5 text-2xs font-medium text-secondary-foreground no-underline">
266
- {category}
308
+ {taxonomyLabel("categories", category)}
267
309
  </a>
268
310
  </div>
269
311
  )}
@@ -275,7 +317,7 @@ function row(label: string, value: unknown) {
275
317
  {tags.map((t) => (
276
318
  <li>
277
319
  <a href={`/${detail.slug}?tag=${encodeURIComponent(t)}`} class="rounded-full border border-ink-200 px-2 py-0.5 text-2xs font-medium text-ink-700 no-underline dark:border-ink-800 dark:text-ink-300">
278
- {t}
320
+ {taxonomyLabel("topics", t)}
279
321
  </a>
280
322
  </li>
281
323
  ))}
@@ -309,8 +351,14 @@ function row(label: string, value: unknown) {
309
351
 
310
352
  {/* ── Source card (small) ────────────────────────────────── */}
311
353
  {sidebar.showSource && (
312
- <section class="grove-card grove-card--muted rounded-[var(--radius-lg)] border border-dashed border-ink-200 bg-background/50 p-4 text-2xs text-ink-500 dark:border-ink-800 dark:text-ink-400" aria-label="Sources">
313
- <h2 class="grove-card-title m-0 mb-1.5 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
354
+ <section
355
+ class="grove-card grove-card--muted rounded-[var(--radius-lg)] border border-dashed border-ink-200 bg-card/50 p-4 text-2xs text-ink-500 dark:border-ink-800 dark:text-ink-400"
356
+ aria-labelledby="sidebar-source-title"
357
+ >
358
+ <h2
359
+ id="sidebar-source-title"
360
+ class="grove-card-title m-0 mb-1.5 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400"
361
+ >
314
362
  Source
315
363
  </h2>
316
364
  <p class="m-0 leading-relaxed">
@@ -2,17 +2,15 @@
2
2
  /**
3
3
  * RefinePanel — multi-select facet dropdowns.
4
4
  *
5
- * Renders a row of facet dropdowns (Stack, Platform, Category,
6
- * License) plus a Sort dropdown, all using native <select> for
7
- * server-render-only behavior. Selection auto-submits the form
8
- * to the same list page with the chosen values merged into the URL.
5
+ * Renders the ordered `groups` view-model produced by
6
+ * `getDirectoryIndexModel().facetGroups` visibility, ORDER, display
7
+ * label, and selection mode all come from `browse.facets` + the core
8
+ * facet registry, never from this component. Option labels arrive
9
+ * pre-resolved from the taxonomy YAML, so the server render and the
10
+ * client script share one label source.
9
11
  *
10
12
  * The form is a GET to the list page base, with each facet as a
11
13
  * repeated key. Empty values are dropped, so the URL stays clean.
12
- *
13
- * Generic: takes a `facets` map of `{ value, label, count }[]` per
14
- * dimension and an `initial` `IndexFilters` for preselecting the
15
- * current URL state.
16
14
  */
17
15
 
18
16
  import type { IndexFilters } from "../lib/search";
@@ -25,78 +23,50 @@ interface FacetValue {
25
23
  count?: number;
26
24
  }
27
25
 
28
- interface Facets {
29
- stacks?: FacetValue[];
30
- platforms?: FacetValue[];
31
- categories?: FacetValue[];
32
- tags?: FacetValue[];
33
- labels?: FacetValue[];
34
- licenses?: FacetValue[];
26
+ interface FacetGroupModel {
27
+ id: string;
28
+ filterKey: "stacks" | "platforms" | "categories" | "tags" | "licenses";
29
+ label: string;
30
+ single?: boolean;
31
+ options: FacetValue[];
32
+ initial: string[];
35
33
  }
36
34
 
37
35
  interface Props {
38
- facets: Facets;
36
+ /** Ordered filter groups (see `getDirectoryIndexModel`). */
37
+ groups: FacetGroupModel[];
38
+ /** Current URL filter state — used to preselect the sort. */
39
39
  initial: IndexFilters;
40
40
  pathPrefix?: string;
41
41
  class?: string;
42
- /** Hide specific facets. Default: all shown. */
43
- hide?: Array<"stack" | "platform" | "category" | "tag" | "license" | "sort">;
42
+ /** Hide the sort select (e.g. when the page renders its own). */
43
+ hide?: Array<"sort">;
44
44
  }
45
45
 
46
46
  const {
47
- facets,
47
+ groups: groupModels,
48
48
  initial,
49
49
  pathPrefix = "/",
50
50
  class: className = "",
51
51
  hide = [],
52
52
  } = Astro.props;
53
53
 
54
- interface FilterGroup {
55
- label: string;
56
- filterKey: "stacks" | "platforms" | "categories" | "tags" | "licenses";
57
- options: FacetValue[];
58
- initial: string[];
59
- single?: boolean;
60
- }
61
-
62
- interface RenderedFilterGroup extends FilterGroup {
63
- active: boolean;
64
- popoverId: string;
65
- trigger: string;
66
- }
67
-
68
- const baseGroups: FilterGroup[] = [
69
- { label: "Stack", filterKey: "stacks", options: facets.stacks ?? [], initial: initial.stacks ?? [] },
70
- { label: "Platform", filterKey: "platforms", options: facets.platforms ?? [], initial: initial.platforms ?? [] },
71
- { label: "Category", filterKey: "categories", options: facets.categories ?? [], initial: initial.categories ?? [] },
72
- { label: "Tag", filterKey: "tags", options: facets.tags ?? [], initial: initial.tags ?? [] },
73
- { label: "License", filterKey: "licenses", options: facets.licenses ?? [], initial: initial.licenses ?? [], single: true },
74
- ];
75
-
76
- function pretty(value: string): string {
77
- return value
78
- .split(/[-_]/)
79
- .map((part) => part ? part[0].toUpperCase() + part.slice(1) : part)
80
- .join(" ");
81
- }
82
-
83
- function triggerLabel(group: FilterGroup): string {
54
+ function triggerLabel(group: FacetGroupModel): string {
84
55
  if (group.initial.length === 0) return `${group.label}: Any`;
85
- if (group.initial.length === 1) return `${group.label}: ${pretty(group.initial[0])}`;
56
+ if (group.initial.length === 1) {
57
+ const selected = group.initial[0];
58
+ const option = group.options.find((candidate) => candidate.value === selected);
59
+ return `${group.label}: ${option?.label ?? selected}`;
60
+ }
86
61
  return `${group.label}: ${group.initial.length} selected`;
87
62
  }
88
63
 
89
- const groups: RenderedFilterGroup[] = baseGroups
90
- .filter((group) => {
91
- const singular = group.filterKey.slice(0, -1) as "stack" | "platform" | "category" | "tag" | "license";
92
- return !hide.includes(singular) && group.options.length > 0;
93
- })
94
- .map((group) => ({
95
- ...group,
96
- active: group.initial.length > 0,
97
- popoverId: `filter-popover-${group.filterKey}`,
98
- trigger: triggerLabel(group),
99
- }));
64
+ const groups = groupModels.map((group) => ({
65
+ ...group,
66
+ active: group.initial.length > 0,
67
+ popoverId: `filter-popover-${group.filterKey}`,
68
+ trigger: triggerLabel(group),
69
+ }));
100
70
  ---
101
71
 
102
72
  <div class:list={["flex flex-wrap items-center gap-2", className]} role="toolbar" aria-label="Filter items">
@@ -160,7 +130,16 @@ const groups: RenderedFilterGroup[] = baseGroups
160
130
  popover?.addEventListener("click", (event) => event.stopPropagation());
161
131
  });
162
132
  document.addEventListener("click", () => closeAll());
163
- document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeAll(); });
133
+ document.addEventListener("keydown", (event) => {
134
+ if (event.key !== "Escape") return;
135
+ // Restore focus to the trigger whose popover was open so
136
+ // keyboard users don't lose their place.
137
+ const openGroup = [...groups].find((group) =>
138
+ group.querySelector('.grove-filter-trigger[aria-expanded="true"]'),
139
+ );
140
+ closeAll();
141
+ openGroup?.querySelector(".grove-filter-trigger")?.focus();
142
+ });
164
143
  document.querySelector('select[name="sort"]')?.addEventListener("change", (event) => {
165
144
  const params = new URLSearchParams(location.search);
166
145
  params.set("sort", event.target.value);
@@ -17,13 +17,14 @@
17
17
 
18
18
  import { LENSES, hrefForLens, isLensActive } from "../lib/lenses";
19
19
  import { lensDisplay } from "../lib/display";
20
+ import { lensTabClass } from "../ui/button.js";
20
21
 
21
22
  interface Props {
22
23
  currentParams: URLSearchParams;
23
24
  pathPrefix?: string;
24
25
  class?: string;
25
26
  /** Override the visible lens list (defaults to LENSES). */
26
- lensIds?: string[];
27
+ lensIds?: readonly string[];
27
28
  /** Consumer noun for the unfiltered lens, e.g. "All apps". */
28
29
  allLabel?: string;
29
30
  }
@@ -57,12 +58,7 @@ const lenses =
57
58
  return (
58
59
  <a
59
60
  href={hrefForLens(lens.id, currentParams, pathPrefix)}
60
- class:list={[
61
- "inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent px-3 text-[0.8125rem] font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35",
62
- active
63
- ? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
64
- : "border-border bg-secondary text-foreground hover:bg-accent",
65
- ]}
61
+ class={lensTabClass(active)}
66
62
  aria-current={active ? "page" : undefined}
67
63
  data-lens-id={lens.id}
68
64
  data-event="lens_tab_click"
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { formatCount } from "@grove-dev/core";
2
3
  import Icon from "./Icon.astro";
3
4
  /**
4
5
  * StackGrid — browse-by-stack grid for the home page.
@@ -32,6 +33,16 @@ interface Props {
32
33
  /** Hide the section eyebrow / header entirely. */
33
34
  bare?: boolean;
34
35
  itemLabelPlural?: string;
36
+ /** Noun pair for count chips — singular fixes "1 projects". */
37
+ itemNoun?: { singular: string; plural: string };
38
+ /** Pass 1 when this grid IS the page (`/stacks` index). */
39
+ headingLevel?: 1 | 2;
40
+ /**
41
+ * Card link builder. Defaults to the browse page filtered by stack
42
+ * (`{pathPrefix}?stack=…`); pass e.g. `(slug) => \`/stacks/${slug}\``
43
+ * to link to taxonomy detail routes instead.
44
+ */
45
+ hrefFor?: (slug: string) => string;
35
46
  }
36
47
 
37
48
  const {
@@ -41,9 +52,15 @@ const {
41
52
  class: className = "",
42
53
  bare = false,
43
54
  itemLabelPlural = "items",
55
+ itemNoun = { singular: "item", plural: "items" },
56
+ headingLevel = 2,
57
+ hrefFor,
44
58
  } = Astro.props;
45
59
 
60
+ const Heading = `h${headingLevel}` as "h1" | "h2";
61
+
46
62
  function buildHref(slug: string): string {
63
+ if (hrefFor) return hrefFor(slug);
47
64
  const base = pathPrefix.replace(/\/$/, "");
48
65
  return `${base}?stack=${encodeURIComponent(slug)}`;
49
66
  }
@@ -73,12 +90,12 @@ function statusLabel(s: StackEntry["status"]): string | null {
73
90
  <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
74
91
  Stacks
75
92
  </span>
76
- <h2
93
+ <Heading
77
94
  id="grove-stackgrid-heading"
78
95
  class="m-0 mt-1 text-[1.5rem] sm:text-[1.625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100"
79
96
  >
80
97
  Browse by stack
81
- </h2>
98
+ </Heading>
82
99
  <p class="m-0 max-w-[60ch] text-[0.9375rem] text-ink-500 dark:text-ink-400">
83
100
  Pick a stack to see real {itemLabelPlural} built with it.
84
101
  </p>
@@ -89,7 +106,7 @@ function statusLabel(s: StackEntry["status"]): string | null {
89
106
  {stacks.map((s) => (
90
107
  <a
91
108
  href={buildHref(s.slug)}
92
- class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors hover:bg-secondary group flex h-full items-center gap-3 p-3.5 no-underline"
109
+ class="rounded-[calc(var(--radius)+0.25rem)] border border-border bg-card shadow-none transition-colors hover:border-ink-300 dark:hover:border-ink-700 group flex h-full items-center gap-3 p-3.5 no-underline"
93
110
  data-event="stack_grid_click"
94
111
  data-event-source="stack_grid"
95
112
  >
@@ -99,12 +116,15 @@ function statusLabel(s: StackEntry["status"]): string | null {
99
116
 
100
117
  <div class="flex min-w-0 flex-1 flex-col gap-0.5">
101
118
  <div class="flex items-center gap-2">
102
- <h3 class="m-0 text-[0.875rem] font-semibold tracking-tight text-ink-900 group-hover:underline dark:text-ink-100">
119
+ {/* Not a heading: these tiles are links in a grid, and
120
+ heading-per-card breaks the outline when the grid
121
+ sits directly under the page h1 (/stacks). */}
122
+ <span class="m-0 text-[0.875rem] font-semibold tracking-tight text-ink-900 group-hover:underline dark:text-ink-100">
103
123
  {s.name}
104
- </h3>
124
+ </span>
105
125
  {typeof s.count === "number" && (
106
126
  <span class="inline-flex items-center rounded-md border border-ink-200 bg-ink-50 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ink-600 dark:border-ink-800 dark:bg-ink-900/60 dark:text-ink-400">
107
- {s.count} {itemLabelPlural}
127
+ {formatCount(s.count, itemNoun)}
108
128
  </span>
109
129
  )}
110
130
  </div>