@grove-dev/astro 0.5.0-next.0 → 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 +137 -77
  33. package/src/components/RecordSection.astro +14 -10
  34. package/src/components/RecordSidebar.astro +80 -22
  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 +173 -52
  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 +88 -1
  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 +192 -43
  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 -323
  70. package/src/components/MinimalAbout.astro +0 -82
  71. package/src/components/ScoreBars.astro +0 -102
  72. package/src/lib/scores.ts +0 -1
@@ -17,6 +17,7 @@ import {
17
17
  activeFilterChips,
18
18
  applySort,
19
19
  buildFacets,
20
+ configuredFacetDefs,
20
21
  effectivePage,
21
22
  effectiveSort,
22
23
  extractToc,
@@ -51,7 +52,9 @@ export interface DirectorySiteConfig {
51
52
  description?: string;
52
53
  repoUrl?: string;
53
54
  nav?: Array<{ label: string; href: string }>;
54
- facets?: string[];
55
+ browse?: {
56
+ facets?: string[];
57
+ };
55
58
  analytics?: {
56
59
  googleAnalyticsId?: string;
57
60
  };
@@ -79,6 +82,12 @@ export interface DirectorySiteConfig {
79
82
  categories?: Array<{ id: string; name: string }>;
80
83
  stacks?: Array<{ id: string; name: string }>;
81
84
  platforms?: Array<{ id: string; name: string }>;
85
+ topics?: Array<{ id: string; name: string }>;
86
+ licenses?: Array<{ id: string; name: string }>;
87
+ distributionChannels?: Array<{ id: string; name: string }>;
88
+ };
89
+ contributors?: {
90
+ showContributionCount?: boolean;
82
91
  };
83
92
  stats?: {
84
93
  originalRepo?: string;
@@ -114,30 +123,96 @@ export function loadDirectoryContributors(root = process.cwd()): DirectoryContri
114
123
  }
115
124
  }
116
125
 
126
+ /**
127
+ * Canonical taxonomy counts — the ONE algorithm every count surface
128
+ * uses. Counts the VISIBLE index (`items`, the same set browse
129
+ * filters over) with the primary+supporting stack union
130
+ * (`projectStackIds`), so the homepage grid, `/stacks`,
131
+ * `/categories`, and the browse facet counts can never disagree.
132
+ * (Counting `fullItems` with the singular `record.stack` was the
133
+ * audit's "Python 3 vs 4" drift.)
134
+ */
135
+ export function countTaxonomies() {
136
+ const stackCounts = new Map<string, number>();
137
+ const categoryCounts = new Map<string, number>();
138
+ for (const record of items) {
139
+ if (record.category) categoryCounts.set(record.category, (categoryCounts.get(record.category) ?? 0) + 1);
140
+ if (record.kind === "project") {
141
+ for (const stackId of projectStackIds(record)) {
142
+ stackCounts.set(stackId, (stackCounts.get(stackId) ?? 0) + 1);
143
+ }
144
+ }
145
+ }
146
+ const toEntries = (
147
+ counts: Map<string, number>,
148
+ kind: "stacks" | "categories",
149
+ ) =>
150
+ [...counts]
151
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
152
+ .map(([id, count]) => ({ name: taxonomyLabel(kind, id), slug: id, count }));
153
+ return {
154
+ stacks: toEntries(stackCounts, "stacks"),
155
+ categories: toEntries(categoryCounts, "categories"),
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Uncapped taxonomy index model for the `/stacks` and `/categories`
161
+ * pages — same canonical counts, no homepage display cap.
162
+ */
163
+ export function getTaxonomyIndexModel() {
164
+ return countTaxonomies();
165
+ }
166
+
117
167
  export function getHomePageModel(site: DirectorySiteConfig) {
118
168
  const slug = site.blueprintConfig?.routeSlug ?? "projects";
119
169
  const singular = site.blueprintConfig?.labelSingular ?? itemLabel();
120
170
  const plural = site.blueprintConfig?.labelPlural ?? itemLabelPlural();
121
171
  const projects = items.filter((record) => record.kind === "project");
122
- const hot = filterRecords(projects, { labels: ["hot"] }).slice(0, 6);
123
- const recentlyAdded = applySort(projects, "recently-added").slice(0, 6);
124
- const established = filterRecords(projects, { labels: ["mature"] }).slice(0, 6);
172
+ // Hot: most-starred items carrying the "hot" lens label. Without an
173
+ // explicit sort, the filter preserves the index order, which is
174
+ // alphabetical so the homepage's "Trending now" panel ends up
175
+ // showing the same 6 items as the "Established" panel (because
176
+ // ~95% of mature items also carry the "hot" label).
177
+ //
178
+ // Top-up guard: when fewer than MIN_HOT records carry the "hot"
179
+ // label, fill the remainder from the most-starred remaining
180
+ // projects so the first lens panel can always render a full
181
+ // three-column grid. Topped-up records are still excluded from the
182
+ // "Recently added" / "Established" panels below.
183
+ const MIN_HOT = 3;
184
+ const hotLabelled = applySort(filterRecords(projects, { labels: ["hot"] }), "most-starred").slice(0, 6);
185
+ const labelledSlugs = new Set(hotLabelled.map((r) => r.slug));
186
+ const hot =
187
+ hotLabelled.length >= MIN_HOT
188
+ ? hotLabelled
189
+ : [
190
+ ...hotLabelled,
191
+ ...applySort(projects, "most-starred")
192
+ .filter((r) => !labelledSlugs.has(r.slug))
193
+ .slice(0, MIN_HOT - hotLabelled.length),
194
+ ];
195
+ // Recently added: most recently reviewed items. We exclude items
196
+ // already shown in the hot panel so the three lens sections on the
197
+ // homepage never echo the same record card.
198
+ const hotSlugs = new Set(hot.map((r) => r.slug));
199
+ const recentlyAdded = applySort(
200
+ projects.filter((r) => !hotSlugs.has(r.slug)),
201
+ "recently-added",
202
+ ).slice(0, 6);
203
+ // Established: longest-running mature items, again excluding any
204
+ // record already surfaced in the hot or recently-added panel so the
205
+ // three sections read as distinct perspectives.
206
+ const recentSlugs = new Set([...hotSlugs, ...recentlyAdded.map((r) => r.slug)]);
207
+ const established = applySort(
208
+ filterRecords(projects, { labels: ["mature"] }).filter((r) => !recentSlugs.has(r.slug)),
209
+ "most-starred",
210
+ ).slice(0, 6);
125
211
 
126
- const stackCounts = new Map<string, number>();
127
- const categoryCounts = new Map<string, number>();
128
- for (const record of fullItems) {
129
- if (record.category) categoryCounts.set(record.category, (categoryCounts.get(record.category) ?? 0) + 1);
130
- if (record.kind === "project" && record.stack) {
131
- stackCounts.set(record.stack, (stackCounts.get(record.stack) ?? 0) + 1);
132
- }
133
- }
134
- const stacks = [...stackCounts]
135
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
136
- .slice(0, 12)
137
- .map(([id, count]) => ({ name: taxonomyLabel("stacks", id), slug: id, count }));
138
- const categories = [...categoryCounts]
139
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
140
- .map(([id, count]) => ({ name: taxonomyLabel("categories", id), slug: id, count }));
212
+ // Homepage layout choice: cap the stack grid at 12. The counting
213
+ // itself is the canonical algorithm in `countTaxonomies`.
214
+ const { stacks: allStacks, categories } = countTaxonomies();
215
+ const stacks = allStacks.slice(0, 12);
141
216
  const contributors = loadDirectoryContributors();
142
217
  const description = site.description ??
143
218
  `A searchable directory of real ${plural} — organized by stack, category, platform, license, activity, and maturity.`;
@@ -165,36 +240,69 @@ export function getHomePageModel(site: DirectorySiteConfig) {
165
240
  };
166
241
  }
167
242
 
168
- const FACET_ALIASES: Record<string, "stacks" | "platforms" | "categories" | "tags" | "licenses"> = {
169
- stack: "stacks",
243
+ /**
244
+ * Ordered facet definitions for this site — the single registry in
245
+ * `@grove-dev/core` resolves `browse.facets`, so visibility, ORDER,
246
+ * labels, and selection mode all come from one place.
247
+ */
248
+ function enabledFacetDefs(site?: DirectorySiteConfig) {
249
+ return configuredFacetDefs(site?.browse?.facets);
250
+ }
251
+
252
+ /** Taxonomy list that labels/orders each facet dimension. */
253
+ const TAXONOMY_KIND_FOR_DIMENSION = {
170
254
  stacks: "stacks",
171
- platform: "platforms",
172
255
  platforms: "platforms",
173
- category: "categories",
174
256
  categories: "categories",
175
- tag: "tags",
176
- tags: "tags",
177
- license: "licenses",
257
+ tags: "topics",
178
258
  licenses: "licenses",
179
- };
180
-
181
- function configuredFacets(site?: DirectorySiteConfig) {
182
- return new Set((site?.facets ?? ["category", "tags"])
183
- .map((facet) => FACET_ALIASES[facet])
184
- .filter((facet): facet is NonNullable<typeof facet> => Boolean(facet)));
185
- }
259
+ } as const;
186
260
 
187
261
  export function getDirectoryIndexModel(searchParams: URLSearchParams, site?: DirectorySiteConfig) {
188
262
  const filters = filtersFromSearchParams(searchParams);
189
- const rawFacets = buildFacets(items);
190
- const enabled = configuredFacets(site);
263
+ // Taxonomy YAML owns option order: the generated arrays preserve
264
+ // file/`order:` position, so their id sequence IS the display order.
265
+ const taxonomyOrder = {
266
+ stacks: site?.taxonomy?.stacks?.map((t) => t.id),
267
+ platforms: site?.taxonomy?.platforms?.map((t) => t.id),
268
+ categories: site?.taxonomy?.categories?.map((t) => t.id),
269
+ tags: site?.taxonomy?.topics?.map((t) => t.id),
270
+ licenses: site?.taxonomy?.licenses?.map((t) => t.id),
271
+ };
272
+ const rawFacets = buildFacets(items, {
273
+ curatedTagIds: taxonomyOrder.tags,
274
+ filters, // Intersection counts: each facet reflects all OTHER filters.
275
+ order: taxonomyOrder,
276
+ });
277
+ const defs = enabledFacetDefs(site);
278
+ const enabled = new Set(defs.map((def) => def.dimension));
279
+ const labeled = (dimension: keyof typeof TAXONOMY_KIND_FOR_DIMENSION) =>
280
+ rawFacets[dimension].map((option) => ({
281
+ ...option,
282
+ label: taxonomyLabel(TAXONOMY_KIND_FOR_DIMENSION[dimension], option.value),
283
+ }));
191
284
  const facets = {
192
- stacks: enabled.has("stacks") ? rawFacets.stacks.map((option) => ({ ...option, label: taxonomyLabel("stacks", option.value) })) : [],
193
- platforms: enabled.has("platforms") ? rawFacets.platforms.map((option) => ({ ...option, label: taxonomyLabel("platforms", option.value) })) : [],
194
- categories: enabled.has("categories") ? rawFacets.categories.map((option) => ({ ...option, label: taxonomyLabel("categories", option.value) })) : [],
195
- tags: enabled.has("tags") ? rawFacets.tags : [],
196
- licenses: enabled.has("licenses") ? rawFacets.licenses : [],
285
+ stacks: enabled.has("stacks") ? labeled("stacks") : [],
286
+ platforms: enabled.has("platforms") ? labeled("platforms") : [],
287
+ categories: enabled.has("categories") ? labeled("categories") : [],
288
+ tags: enabled.has("tags") ? labeled("tags") : [],
289
+ licenses: enabled.has("licenses") ? labeled("licenses") : [],
197
290
  };
291
+ // Ordered filter groups — `browse.facets` array order is the render
292
+ // order; label + selection mode come from the core registry.
293
+ const facetGroups = defs
294
+ .map((def) => ({
295
+ id: def.id,
296
+ filterKey: def.dimension,
297
+ label: def.label,
298
+ single: def.single,
299
+ options: facets[def.dimension],
300
+ initial: (filters[def.dimension] ?? []) as string[],
301
+ }))
302
+ .filter((group) => group.options.length > 0);
303
+ const plural = site?.blueprintConfig?.labelPlural ?? itemLabelPlural();
304
+ // Placeholder names only the ENABLED dimensions, in config order.
305
+ const searchPlaceholder = `Search ${plural}, ${defs.map((def) => def.dimension).join(", ")}...`;
198
306
  const sort = effectiveSort(filters);
199
307
  const sorted = applySort(filterRecords(items, filters), sort);
200
308
  const pageCount = totalPages(sorted.length);
@@ -204,6 +312,8 @@ export function getDirectoryIndexModel(searchParams: URLSearchParams, site?: Dir
204
312
  total: items.length,
205
313
  filters,
206
314
  facets,
315
+ facetGroups,
316
+ searchPlaceholder,
207
317
  sort,
208
318
  sorted,
209
319
  pages: pageCount,
@@ -215,7 +325,13 @@ export function getDirectoryIndexModel(searchParams: URLSearchParams, site?: Dir
215
325
 
216
326
  export function getContributorsPageModel(site: DirectorySiteConfig) {
217
327
  const contributors = loadDirectoryContributors();
218
- const sorted = [...contributors].sort((a, b) =>
328
+ // Render-time guard: even if a stale `data/generated/contributors.json`
329
+ // was produced before the sync-time filter was added, never surface
330
+ // bot accounts (login ends with `[bot]`) on the public contributors
331
+ // page. The ContributorsGrid uses the same total for its heading, so
332
+ // the headline count always agrees with the rendered grid.
333
+ const human = contributors.filter((c) => !c.username.endsWith("[bot]"));
334
+ const sorted = [...human].sort((a, b) =>
219
335
  (b.contributions ?? 0) - (a.contributions ?? 0) || a.username.localeCompare(b.username)
220
336
  );
221
337
  const repo = site.repoUrl?.replace(/\/$/, "");
@@ -225,12 +341,16 @@ export function getContributorsPageModel(site: DirectorySiteConfig) {
225
341
  title: `Community — ${site.name}`,
226
342
  description: `The people who have contributed to ${site.name} through code, curation, and review.`,
227
343
  contributorsGraphHref: repo ? `${repo}/graphs/contributors` : null,
344
+ // Surface the consumer's per-user contribution-count preference to
345
+ // the consumer page so it can render quieter cards when the
346
+ // directory does not curate contributor activity.
347
+ showContributionCount: site.contributors?.showContributionCount ?? true,
228
348
  };
229
349
  }
230
350
 
231
351
  export function getSubmissionPageModel(site: DirectorySiteConfig) {
232
352
  const singular = site.blueprintConfig?.labelSingular ?? itemLabel();
233
- const enabled = configuredFacets(site);
353
+ const enabled = new Set(enabledFacetDefs(site).map((def) => def.dimension));
234
354
  const values = (key: "category" | "stack") =>
235
355
  [...new Set(fullProjects.map((record) => String(record[key] ?? "")).filter(Boolean))]
236
356
  .sort((a, b) => a.localeCompare(b));
@@ -259,6 +379,9 @@ export function getSubmissionPageModel(site: DirectorySiteConfig) {
259
379
  stack: enabled.has("stacks"),
260
380
  platforms: enabled.has("platforms"),
261
381
  tags: enabled.has("tags"),
382
+ // The submit form mirrors the browse dimensions: a directory
383
+ // that filters by license also collects it at submission time.
384
+ license: enabled.has("licenses"),
262
385
  },
263
386
  categories,
264
387
  stacks,
@@ -266,6 +389,14 @@ export function getSubmissionPageModel(site: DirectorySiteConfig) {
266
389
  existingFullNames,
267
390
  platformOptions: site.taxonomy?.platforms?.map(({ id, name }) => ({ id, label: name })) ??
268
391
  ["ios", "android", "web", "macos", "windows", "linux"].map((id) => ({ id, label: taxonomyLabel("platforms", id) })),
392
+ licenseOptions: site.taxonomy?.licenses?.map(({ id, name }) => ({ id, label: name })) ?? [],
393
+ // Ids for client-side validation — without this the submit form's
394
+ // taxonomy checks silently no-op.
395
+ taxonomy: {
396
+ categoryIds: categories.map((option) => option.id),
397
+ stackIds: stacks.map((option) => option.id),
398
+ platformIds: (site.taxonomy?.platforms ?? []).map(({ id }) => id),
399
+ },
269
400
  };
270
401
  }
271
402
 
@@ -432,7 +563,23 @@ const tocBody = readContentFile(typeof record.content === "string" ? record.cont
432
563
  curationLabels: record.curation?.labels ?? [],
433
564
  tags,
434
565
  healthLabel,
566
+ // Top-level `category` so the package's RecordHeader / RecordSidebar
567
+ // components can render the category pill without each consumer
568
+ // having to destructure `record.category` themselves.
569
+ category: record.category,
435
570
  contentHtml: isProject ? getContentHtml(recordSlug) : null,
571
+ // Curated summary (Open Apps-written) takes priority over the
572
+ // raw `description` (typically copied from GitHub). Fall back to
573
+ // `description` when the curator has not written a summary yet.
574
+ summary: (record.summary && record.summary.trim()) || record.description || "",
575
+ sourceDescription: record.sourceDescription ?? record.description ?? "",
576
+ // Collection membership is populated by the consumer detail page
577
+ // after calling `findCollectionsFor`. Defaulting to [] keeps the
578
+ // model safe when the consumer doesn't wire it.
579
+ collectionMembership: [],
580
+ // Curated screenshots array. Defaulting to [] keeps the renderer
581
+ // safe; RecordHeader shows a gallery strip when non-empty.
582
+ screenshots: proj?.screenshots ?? [],
436
583
  monthlyCommits,
437
584
  maxMonthlyCommits: Math.max(1, ...monthlyCommits.map((item) => item.commits)),
438
585
  contributionSignals,
@@ -467,7 +614,9 @@ const tocBody = readContentFile(typeof record.content === "string" ? record.cont
467
614
  // because the TOC + reading-time want the *body* (frontmatter
468
615
  // stripped), not the HTML. Re-reading is cheap and keeps the
469
616
  // pipeline self-documenting.
470
- toc: tocBody ? extractToc(tocBody.body, { maxDepth: 2 }) : [],
617
+ // Depth 3 so subsections surface in the TOC, indented under
618
+ // their h2 parents.
619
+ toc: tocBody ? extractToc(tocBody.body, { maxDepth: 3 }) : [],
471
620
  readingMetrics: tocBody ? readingMetrics(tocBody.body) : { wordCount: 0, minutes: 1 },
472
621
  jsonLd,
473
622
  };
package/src/styles.css CHANGED
@@ -62,6 +62,33 @@
62
62
  --color-popover: var(--grove-popover);
63
63
  --color-popover-foreground: var(--grove-popover-foreground);
64
64
 
65
+ /* ── Surfaces ───────────────────────────────────────────────────
66
+ * Elevation roles. `card` and `popover` above resolve to these so
67
+ * legacy utilities keep working while new components can name the
68
+ * elevation they mean.
69
+ */
70
+ --color-surface-raised: var(--grove-surface-raised);
71
+ --color-surface-sunken: var(--grove-surface-sunken);
72
+ --color-surface-overlay: var(--grove-surface-overlay);
73
+
74
+ /* ── Selection (neutral inversion) ──────────────────────────────
75
+ * Active filters, current lens/page, and other "you are here"
76
+ * states. Deliberately decoupled from the brand primary so green
77
+ * can mean brand action or success — never selection.
78
+ */
79
+ --color-selected: var(--grove-selected);
80
+ --color-selected-foreground: var(--grove-selected-foreground);
81
+
82
+ /* ── Status roles (solid fill + text-on-fill pairs) ───────────── */
83
+ --color-success: var(--grove-success);
84
+ --color-success-foreground: var(--grove-success-foreground);
85
+ --color-warning: var(--grove-warning);
86
+ --color-warning-foreground: var(--grove-warning-foreground);
87
+ --color-danger: var(--grove-danger);
88
+ --color-danger-foreground: var(--grove-danger-foreground);
89
+ --color-info: var(--grove-info);
90
+ --color-info-foreground: var(--grove-info-foreground);
91
+
65
92
  /* ── Accent (interactive states) ─────────────────────────────── */
66
93
  --color-accent-muted: color-mix(in oklab, var(--grove-foreground) 8%, transparent);
67
94
 
@@ -116,15 +143,20 @@
116
143
  --text-2xs: 0.6875rem;
117
144
  --text-2xs--line-height: 1rem;
118
145
 
119
- /* ── Container widths (showcase rhythm) ──────────────────────── */
120
- --container-container: 1140px;
146
+ /* ── Container widths (showcase rhythm) ──────────────────────────
147
+ * The default container tracks `theme.containerWidth` from
148
+ * grove.config.ts: BaseLayout emits `--grove-container` on <html>
149
+ * and the token falls back to the package default when unset. */
150
+ --container-container: var(--grove-container, 1160px);
121
151
  --container-wide: 1400px;
122
152
  --container-narrow: 720px;
123
153
 
124
- /* ── Border radius scale ─────────────────────────────────────── */
125
- --radius: 0.625rem;
126
- --radius-lg: 0.5rem;
127
- --radius-xl: 0.75rem;
154
+ /* ── Border radius scale ──────────────────────────────────────────
155
+ * Driven by `theme.radius` ("none" | "soft" | "round") the same
156
+ * way — BaseLayout emits the `--grove-radius*` overrides. */
157
+ --radius: var(--grove-radius, 0.625rem);
158
+ --radius-lg: var(--grove-radius-lg, 0.5rem);
159
+ --radius-xl: var(--grove-radius-xl, 0.75rem);
128
160
 
129
161
  /* ── Animations ──────────────────────────────────────────────── */
130
162
  --animate-fade-in-up: fade-in-up 0.6s ease-out both;
@@ -191,8 +223,11 @@
191
223
  --grove-gray-5: var(--color-ink-400);
192
224
  --grove-gray-6: var(--color-ink-300);
193
225
  --grove-gray-7: var(--color-ink-100);
194
- --grove-primary: var(--grove-foreground);
195
- --grove-primary-foreground: var(--grove-gray-7);
226
+ /* Brand color from `theme.primaryColor` (grove.config.ts)
227
+ * BaseLayout emits `--grove-theme-primary*` on <html>; without
228
+ * a config the site keeps the neutral ink treatment. */
229
+ --grove-primary: var(--grove-theme-primary, var(--grove-foreground));
230
+ --grove-primary-foreground: var(--grove-theme-primary-foreground, var(--grove-gray-7));
196
231
  --grove-secondary: var(--grove-gray-7);
197
232
  --grove-secondary-foreground: var(--grove-foreground);
198
233
  --grove-muted: var(--grove-gray-7);
@@ -202,10 +237,28 @@
202
237
  --grove-border: var(--grove-gray-6);
203
238
  --grove-input: var(--grove-gray-6);
204
239
  --grove-ring: var(--grove-gray-4);
205
- --grove-card: var(--grove-background);
240
+ /* Elevation: raised surfaces stay in the light family — cards sit
241
+ * on the page via border, not a dark fill. */
242
+ --grove-surface-raised: var(--grove-background);
243
+ --grove-surface-sunken: var(--color-ink-100);
244
+ --grove-surface-overlay: var(--grove-background);
245
+ --grove-card: var(--grove-surface-raised);
206
246
  --grove-card-foreground: var(--grove-foreground);
207
- --grove-popover: var(--grove-background);
247
+ --grove-popover: var(--grove-surface-overlay);
208
248
  --grove-popover-foreground: var(--grove-foreground);
249
+ /* Selection: neutral inversion (vite.dev-style), never the brand
250
+ * primary. */
251
+ --grove-selected: var(--color-ink-900);
252
+ --grove-selected-foreground: var(--color-ink-50);
253
+ /* Status fills with AA text-on-fill partners. */
254
+ --grove-success: #15803d;
255
+ --grove-success-foreground: #ffffff;
256
+ --grove-warning: #92400e;
257
+ --grove-warning-foreground: #ffffff;
258
+ --grove-danger: #b91c1c;
259
+ --grove-danger-foreground: #ffffff;
260
+ --grove-info: #1d4ed8;
261
+ --grove-info-foreground: #ffffff;
209
262
  }
210
263
 
211
264
  :root.dark {
@@ -219,8 +272,11 @@
219
272
  --grove-gray-5: var(--color-ink-700);
220
273
  --grove-gray-6: var(--color-ink-800);
221
274
  --grove-gray-7: var(--color-ink-900);
222
- --grove-primary: var(--grove-foreground);
223
- --grove-primary-foreground: var(--grove-gray-7);
275
+ /* Dark mode lifts the brand color toward white (see BaseLayout's
276
+ * emitted `--grove-theme-primary-dark`) so contrast holds. The
277
+ * foreground is computed against that lifted value, never assumed. */
278
+ --grove-primary: var(--grove-theme-primary-dark, var(--grove-foreground));
279
+ --grove-primary-foreground: var(--grove-theme-primary-dark-foreground, var(--grove-gray-7));
224
280
  --grove-secondary: var(--grove-gray-6);
225
281
  --grove-secondary-foreground: var(--grove-foreground);
226
282
  --grove-muted: var(--grove-gray-6);
@@ -230,10 +286,27 @@
230
286
  --grove-border: var(--grove-gray-6);
231
287
  --grove-input: var(--grove-gray-5);
232
288
  --grove-ring: var(--grove-gray-4);
233
- --grove-card: var(--grove-background);
289
+ /* Elevation: the dark raised surface sits just above the ink-950
290
+ * page (deliberate landing-page value; hover lifts to ink-900). */
291
+ --grove-surface-raised: #141516;
292
+ --grove-surface-sunken: oklch(11.5% 0 0);
293
+ --grove-surface-overlay: var(--grove-gray-7);
294
+ --grove-card: var(--grove-surface-raised);
234
295
  --grove-card-foreground: var(--grove-foreground);
235
- --grove-popover: var(--grove-gray-7);
296
+ --grove-popover: var(--grove-surface-overlay);
236
297
  --grove-popover-foreground: var(--grove-foreground);
298
+ /* Selection: neutral inversion, mirrored. */
299
+ --grove-selected: var(--color-ink-100);
300
+ --grove-selected-foreground: var(--color-ink-950);
301
+ /* Status fills lift toward pastels with dark text-on-fill. */
302
+ --grove-success: #4ade80;
303
+ --grove-success-foreground: var(--color-ink-950);
304
+ --grove-warning: #fbbf24;
305
+ --grove-warning-foreground: var(--color-ink-950);
306
+ --grove-danger: #f87171;
307
+ --grove-danger-foreground: var(--color-ink-950);
308
+ --grove-info: #60a5fa;
309
+ --grove-info-foreground: var(--color-ink-950);
237
310
  }
238
311
 
239
312
  html {
@@ -289,9 +362,33 @@
289
362
  input[type="url"],
290
363
  input[type="email"],
291
364
  input:not([type]),
292
- select,
293
365
  textarea
294
366
  ) {
367
+ appearance: none;
368
+ -webkit-appearance: none;
369
+ }
370
+
371
+ input[type="search"]::-webkit-search-cancel-button,
372
+ input[type="search"]::-webkit-search-results-button,
373
+ input[type="search"]::-webkit-search-results-decoration {
374
+ -webkit-appearance: none;
375
+ display: none;
376
+ }
377
+
378
+ /* Inputs that live INSIDE a styled shell (the hero search pill)
379
+ * opt out with `.grove-input-unstyled`: the shell owns the border,
380
+ * radius, background, and the focus treatment via `focus-within` —
381
+ * forcing a second background/radius/focus ring on the inner input
382
+ * paints a box-within-a-box. */
383
+ :where(
384
+ input[type="text"],
385
+ input[type="search"],
386
+ input[type="url"],
387
+ input[type="email"],
388
+ input:not([type]),
389
+ select,
390
+ textarea
391
+ ):not(.grove-input-unstyled) {
295
392
  border-color: var(--grove-input) !important;
296
393
  border-radius: var(--radius) !important;
297
394
  background-color: var(--grove-background) !important;
@@ -306,7 +403,7 @@
306
403
  input:not([type]),
307
404
  select,
308
405
  textarea
309
- ):focus-visible {
406
+ ):not(.grove-input-unstyled):focus-visible {
310
407
  border-color: var(--grove-ring) !important;
311
408
  outline: none;
312
409
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--grove-ring) 35%, transparent);
@@ -770,6 +867,21 @@
770
867
  .grove-toc summary { color: inherit; }
771
868
  .grove-toc[open] .grove-toc-chevron,
772
869
  .grove-toc > summary:hover .grove-toc-chevron { transform: rotate(90deg); }
870
+ /* Desktop (lg+): keep the TOC contents visible even before the
871
+ * component script sets the `open` attribute (and with JS disabled).
872
+ * Closed `<details>` content lives in a hidden UA shadow slot, so
873
+ * both the legacy child rule and the modern `::details-content`
874
+ * override are needed for cross-browser coverage; the script in
875
+ * `TableOfContents.astro` sets `open` as the primary mechanism. */
876
+ @media (width >= 64rem) {
877
+ .grove-toc:not([open]) > *:not(summary) {
878
+ display: block;
879
+ }
880
+ .grove-toc:not([open])::details-content {
881
+ content-visibility: visible;
882
+ display: block;
883
+ }
884
+ }
773
885
  .grove-toc-item { padding: 0; }
774
886
  .grove-toc-link[data-toc-active] {
775
887
  background-color: var(--color-ink-100, #f0f0f0);
@@ -1,2 +0,0 @@
1
- export * from "@grove-dev/core";
2
- //# sourceMappingURL=scores.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"scores.d.ts","sourceRoot":"","sources":["../../src/lib/scores.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC"}
@@ -1,2 +0,0 @@
1
- export * from "@grove-dev/core";
2
- //# sourceMappingURL=scores.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"scores.js","sourceRoot":"","sources":["../../src/lib/scores.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC"}
@@ -1,41 +0,0 @@
1
- ---
2
- interface Props {
3
- bestFor?: string[];
4
- whyListed?: string[];
5
- caveats?: string[];
6
- class?: string;
7
- }
8
-
9
- const {
10
- bestFor = [],
11
- whyListed = [],
12
- caveats = [],
13
- class: className = "",
14
- } = Astro.props;
15
-
16
- const columns = [
17
- { title: "Best for", items: bestFor },
18
- { title: "Why listed", items: whyListed },
19
- { title: "Caveats", items: caveats },
20
- ].filter((column) => column.items.length > 0);
21
- ---
22
-
23
- {columns.length > 0 && (
24
- <section
25
- class:list={["grid grid-cols-1 gap-4 md:grid-cols-3", className]}
26
- aria-label="Curation notes"
27
- >
28
- {columns.map((column) => (
29
- <div class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors p-5">
30
- <h2 class="m-0 text-sm font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
31
- {column.title}
32
- </h2>
33
- <ul class="mt-3 mb-0 flex list-disc flex-col gap-1.5 pl-5">
34
- {column.items.map((item) => (
35
- <li class="text-sm text-ink-700 dark:text-ink-200">{item}</li>
36
- ))}
37
- </ul>
38
- </div>
39
- ))}
40
- </section>
41
- )}