@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
@@ -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,39 +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);
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
+ };
189
272
  const rawFacets = buildFacets(items, {
190
- curatedTagIds: site.taxonomy?.topics?.map((t) => t.id),
273
+ curatedTagIds: taxonomyOrder.tags,
191
274
  filters, // Intersection counts: each facet reflects all OTHER filters.
275
+ order: taxonomyOrder,
192
276
  });
193
- const enabled = configuredFacets(site);
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
+ }));
194
284
  const facets = {
195
- stacks: enabled.has("stacks") ? rawFacets.stacks.map((option) => ({ ...option, label: taxonomyLabel("stacks", option.value) })) : [],
196
- platforms: enabled.has("platforms") ? rawFacets.platforms.map((option) => ({ ...option, label: taxonomyLabel("platforms", option.value) })) : [],
197
- categories: enabled.has("categories") ? rawFacets.categories.map((option) => ({ ...option, label: taxonomyLabel("categories", option.value) })) : [],
198
- tags: enabled.has("tags") ? rawFacets.tags : [],
199
- 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") : [],
200
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(", ")}...`;
201
306
  const sort = effectiveSort(filters);
202
307
  const sorted = applySort(filterRecords(items, filters), sort);
203
308
  const pageCount = totalPages(sorted.length);
@@ -207,6 +312,8 @@ export function getDirectoryIndexModel(searchParams: URLSearchParams, site?: Dir
207
312
  total: items.length,
208
313
  filters,
209
314
  facets,
315
+ facetGroups,
316
+ searchPlaceholder,
210
317
  sort,
211
318
  sorted,
212
319
  pages: pageCount,
@@ -234,12 +341,16 @@ export function getContributorsPageModel(site: DirectorySiteConfig) {
234
341
  title: `Community — ${site.name}`,
235
342
  description: `The people who have contributed to ${site.name} through code, curation, and review.`,
236
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,
237
348
  };
238
349
  }
239
350
 
240
351
  export function getSubmissionPageModel(site: DirectorySiteConfig) {
241
352
  const singular = site.blueprintConfig?.labelSingular ?? itemLabel();
242
- const enabled = configuredFacets(site);
353
+ const enabled = new Set(enabledFacetDefs(site).map((def) => def.dimension));
243
354
  const values = (key: "category" | "stack") =>
244
355
  [...new Set(fullProjects.map((record) => String(record[key] ?? "")).filter(Boolean))]
245
356
  .sort((a, b) => a.localeCompare(b));
@@ -268,6 +379,9 @@ export function getSubmissionPageModel(site: DirectorySiteConfig) {
268
379
  stack: enabled.has("stacks"),
269
380
  platforms: enabled.has("platforms"),
270
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"),
271
385
  },
272
386
  categories,
273
387
  stacks,
@@ -275,6 +389,14 @@ export function getSubmissionPageModel(site: DirectorySiteConfig) {
275
389
  existingFullNames,
276
390
  platformOptions: site.taxonomy?.platforms?.map(({ id, name }) => ({ id, label: name })) ??
277
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
+ },
278
400
  };
279
401
  }
280
402
 
@@ -441,6 +563,10 @@ const tocBody = readContentFile(typeof record.content === "string" ? record.cont
441
563
  curationLabels: record.curation?.labels ?? [],
442
564
  tags,
443
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,
444
570
  contentHtml: isProject ? getContentHtml(recordSlug) : null,
445
571
  // Curated summary (Open Apps-written) takes priority over the
446
572
  // raw `description` (typically copied from GitHub). Fall back to
@@ -488,7 +614,9 @@ const tocBody = readContentFile(typeof record.content === "string" ? record.cont
488
614
  // because the TOC + reading-time want the *body* (frontmatter
489
615
  // stripped), not the HTML. Re-reading is cheap and keeps the
490
616
  // pipeline self-documenting.
491
- 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 }) : [],
492
620
  readingMetrics: tocBody ? readingMetrics(tocBody.body) : { wordCount: 0, minutes: 1 },
493
621
  jsonLd,
494
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
- )}
@@ -1,89 +0,0 @@
1
- ---
2
- /**
3
- * DecisionRow — a single row of the curation decision table.
4
- *
5
- * Used on the "Decisions" admin/curation page. Renders the record
6
- * name, the current visibility/tier, the cleanup-candidate flag,
7
- * and links to the source repo and detail page.
8
- *
9
- * Generic: takes a `Resource` and reads the standard fields. No
10
- * blueprint-specific copy.
11
- */
12
-
13
- import type { Resource, ProjectRecord } from "@grove-dev/core";
14
- import { statusDisplay, prettySlug } from "../lib/display";
15
- import { getOwnerAndRepoFromRepoUrl } from "../lib/repo";
16
-
17
- interface Props {
18
- record: Resource;
19
- detailHref?: string;
20
- class?: string;
21
- }
22
-
23
- const { record, detailHref, class: className = "" } = Astro.props;
24
-
25
- const isProject = record.kind === "project";
26
- const project = isProject ? (record as ProjectRecord) : null;
27
-
28
- const name =
29
- record.kind === "resource"
30
- ? (record as { title: string }).title
31
- : (record as { name: string }).name;
32
-
33
- const repoUrl = project?.repoUrl;
34
- const ownerRepo = repoUrl ? getOwnerAndRepoFromRepoUrl(repoUrl) : null;
35
-
36
- const visibility = project?.health?.visibility ?? "keep";
37
- const tier = project?.health?.tier;
38
- const status = project?.health?.status;
39
- const cleanup = project?.health?.cleanupCandidate;
40
- ---
41
-
42
- <tr
43
- class:list={["grove-decision-row align-top text-sm", className]}
44
- data-record-slug={record.slug}
45
- >
46
- <td class="px-3 py-2">
47
- <div class="flex flex-col gap-0.5">
48
- <a
49
- href={detailHref ?? `/${record.slug}`}
50
- class="font-medium text-ink-900 hover:underline dark:text-ink-100"
51
- >
52
- {name}
53
- </a>
54
- {ownerRepo && (
55
- <span class="text-2xs text-ink-500 dark:text-ink-400">
56
- @{ownerRepo.owner}/{ownerRepo.repo}
57
- </span>
58
- )}
59
- </div>
60
- </td>
61
- <td class="px-3 py-2 font-mono text-2xs text-ink-700 dark:text-ink-300">
62
- {prettySlug(visibility)}
63
- </td>
64
- <td class="px-3 py-2 font-mono text-2xs text-ink-700 dark:text-ink-300">
65
- {tier ? prettySlug(tier) : "—"}
66
- </td>
67
- <td class="px-3 py-2 font-mono text-2xs text-ink-700 dark:text-ink-300">
68
- {status ? statusDisplay(status) : "—"}
69
- </td>
70
- <td class="px-3 py-2 text-2xs">
71
- {cleanup ? (
72
- <span class="inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground !bg-amber-50 !text-amber-800 dark:!bg-amber-950/80 dark:!text-amber-300">cleanup</span>
73
- ) : (
74
- <span class="text-ink-400">—</span>
75
- )}
76
- </td>
77
- <td class="px-3 py-2 text-right">
78
- {repoUrl && (
79
- <a
80
- href={repoUrl}
81
- target="_blank"
82
- rel="noopener noreferrer"
83
- class="text-2xs text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
84
- >
85
- repo →
86
- </a>
87
- )}
88
- </td>
89
- </tr>