@grove-dev/astro 0.5.0-next.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/index.d.ts +0 -2
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +0 -2
- package/dist/lib/index.js.map +1 -1
- package/dist/ui/button.d.ts +45 -0
- package/dist/ui/button.d.ts.map +1 -0
- package/dist/ui/button.js +82 -0
- package/dist/ui/button.js.map +1 -0
- package/package.json +9 -5
- package/src/components/CardGrid.astro +41 -0
- package/src/components/CardIcon.astro +67 -0
- package/src/components/CategoryGrid.astro +20 -5
- package/src/components/CollectionCard.astro +56 -0
- package/src/components/CollectionIndex.astro +21 -26
- package/src/components/CollectionPage.astro +36 -13
- package/src/components/CollectionRow.astro +62 -192
- package/src/components/CollectionTeaser.astro +4 -0
- package/src/components/ContributorsGrid.astro +4 -1
- package/src/components/DirectoryIndexClient.astro +127 -30
- package/src/components/EditorialSummary.astro +6 -5
- package/src/components/FilterGroupMenu.astro +24 -10
- package/src/components/FilterOptions.astro +6 -1
- package/src/components/FinalCta.astro +5 -3
- package/src/components/Hero.astro +85 -139
- package/src/components/IndexRow.astro +36 -69
- package/src/components/LanguageBreakdown.astro +5 -1
- package/src/components/OriginalCollection.astro +3 -2
- package/src/components/Pagination.astro +7 -20
- package/src/components/ProjectCard.astro +345 -0
- package/src/components/RecordHeader.astro +111 -82
- package/src/components/RecordSection.astro +14 -10
- package/src/components/RecordSidebar.astro +68 -20
- package/src/components/RefinePanel.astro +41 -62
- package/src/components/SmartLensTabs.astro +3 -7
- package/src/components/StackGrid.astro +26 -6
- package/src/components/SubmissionClient.astro +140 -63
- package/src/components/TableOfContents.astro +31 -5
- package/src/components/WhyThisExists.astro +1 -1
- package/src/index.ts +2 -2
- package/src/layouts/BaseLayout.astro +79 -9
- package/src/layouts/Header.astro +5 -4
- package/src/layouts/SectionHeader.astro +3 -2
- package/src/layouts/Seo.astro +28 -17
- package/src/layouts/ThemeToggle.astro +23 -6
- package/src/lib/index.ts +0 -2
- package/src/server/collections.ts +11 -0
- package/src/server/contrast.test.ts +82 -0
- package/src/server/contrast.ts +117 -0
- package/src/server/directory.test.ts +177 -0
- package/src/server/directory.ts +328 -206
- package/src/server/github-repo.test.ts +88 -0
- package/src/server/github-repo.ts +104 -0
- package/src/server/index.ts +4 -3
- package/src/server/models-home.test.ts +101 -0
- package/src/server/models.test.ts +135 -0
- package/src/server/models.ts +170 -42
- package/src/styles.css +128 -16
- package/src/ui/Badge.astro +38 -0
- package/src/ui/Button.astro +42 -0
- package/src/ui/EmptyState.astro +38 -0
- package/src/ui/FilterDrawer.astro +104 -0
- package/src/ui/PageHeader.astro +39 -0
- package/src/ui/SearchField.astro +50 -0
- package/src/ui/button.test.ts +54 -0
- package/src/ui/button.ts +98 -0
- package/dist/lib/scores.d.ts +0 -2
- package/dist/lib/scores.d.ts.map +0 -1
- package/dist/lib/scores.js +0 -2
- package/dist/lib/scores.js.map +0 -1
- package/src/components/CurationGrid.astro +0 -41
- package/src/components/DecisionRow.astro +0 -89
- package/src/components/ExploreByCategory.astro +0 -67
- package/src/components/ExploreByStack.astro +0 -78
- package/src/components/GroveDocumentHead.astro +0 -28
- package/src/components/ItemCard.astro +0 -324
- package/src/components/MinimalAbout.astro +0 -82
- package/src/components/ScoreBars.astro +0 -102
- package/src/lib/scores.ts +0 -1
package/src/server/models.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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:
|
|
273
|
+
curatedTagIds: taxonomyOrder.tags,
|
|
191
274
|
filters, // Intersection counts: each facet reflects all OTHER filters.
|
|
275
|
+
order: taxonomyOrder,
|
|
192
276
|
});
|
|
193
|
-
const
|
|
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") ?
|
|
196
|
-
platforms: enabled.has("platforms") ?
|
|
197
|
-
categories: enabled.has("categories") ?
|
|
198
|
-
tags: enabled.has("tags") ?
|
|
199
|
-
licenses: enabled.has("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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
--radius
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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-
|
|
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);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Badge — small labeled state indicator.
|
|
4
|
+
*
|
|
5
|
+
* Variant is the MEANING, not the color:
|
|
6
|
+
* - `neutral` — plain metadata (counts, kinds).
|
|
7
|
+
* - `selected` — "you are here" (neutral inversion, never green).
|
|
8
|
+
* - `success` / `warning` / `danger` / `info` — actual status. These
|
|
9
|
+
* use the status tokens, so green here means "healthy/succeeded",
|
|
10
|
+
* not "brand" and not "selected".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type BadgeVariant = "neutral" | "selected" | "success" | "warning" | "danger" | "info";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
variant?: BadgeVariant;
|
|
17
|
+
class?: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const VARIANTS: Record<BadgeVariant, string> = {
|
|
22
|
+
neutral: "border-border bg-secondary text-secondary-foreground",
|
|
23
|
+
selected: "border-transparent bg-selected text-selected-foreground",
|
|
24
|
+
success: "border-transparent bg-success text-success-foreground",
|
|
25
|
+
warning: "border-transparent bg-warning text-warning-foreground",
|
|
26
|
+
danger: "border-transparent bg-danger text-danger-foreground",
|
|
27
|
+
info: "border-transparent bg-info text-info-foreground",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const { variant = "neutral", class: className = "", ...rest } = Astro.props;
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<span
|
|
34
|
+
class={`inline-flex min-h-5 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium leading-none ${VARIANTS[variant]} ${className}`}
|
|
35
|
+
{...rest}
|
|
36
|
+
>
|
|
37
|
+
<slot />
|
|
38
|
+
</span>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Button — the shared interactive control.
|
|
4
|
+
*
|
|
5
|
+
* Renders an `<a>` when `href` is given, otherwise a `<button>`.
|
|
6
|
+
* Styling comes exclusively from `buttonClass()` (ui/button.ts) so
|
|
7
|
+
* server components, consumer pages, and the client script share one
|
|
8
|
+
* definition. Pass layout-only extras (widths, one-off spacing)
|
|
9
|
+
* through `class`.
|
|
10
|
+
*/
|
|
11
|
+
import { buttonClass, type ButtonSize, type ButtonVariant } from "./button.js";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
variant?: ButtonVariant;
|
|
15
|
+
size?: ButtonSize;
|
|
16
|
+
href?: string;
|
|
17
|
+
type?: "button" | "submit" | "reset";
|
|
18
|
+
rel?: string;
|
|
19
|
+
target?: string;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
id?: string;
|
|
22
|
+
class?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
variant = "secondary",
|
|
28
|
+
size = "sm",
|
|
29
|
+
href,
|
|
30
|
+
type = "button",
|
|
31
|
+
class: className = "",
|
|
32
|
+
...rest
|
|
33
|
+
} = Astro.props;
|
|
34
|
+
|
|
35
|
+
const classes = buttonClass(variant, size, className);
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
{href ? (
|
|
39
|
+
<a href={href} class={classes} {...rest}><slot /></a>
|
|
40
|
+
) : (
|
|
41
|
+
<button type={type} class={classes} {...rest}><slot /></button>
|
|
42
|
+
)}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* EmptyState — a deliberate "nothing here" block that cannot be
|
|
4
|
+
* mistaken for normal content. Explains WHY the area is empty and
|
|
5
|
+
* offers a recovery action.
|
|
6
|
+
*
|
|
7
|
+
* Marked with `data-grove-empty-state` so quality gates can assert
|
|
8
|
+
* that empty regions render as empty states, not silent gaps.
|
|
9
|
+
*/
|
|
10
|
+
import { buttonClass, type ButtonVariant } from "./button.js";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
title: string;
|
|
14
|
+
/** Why it's empty / what will make it non-empty. */
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Recovery action (rendered as a button-styled link). */
|
|
17
|
+
action?: { label: string; href: string; variant?: ButtonVariant };
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { title, description, action, class: className = "" } = Astro.props;
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<div
|
|
25
|
+
data-grove-empty-state
|
|
26
|
+
class={`flex flex-col items-center gap-2 rounded-[calc(var(--radius)+0.25rem)] border border-dashed border-border bg-surface-sunken px-6 py-10 text-center ${className}`}
|
|
27
|
+
>
|
|
28
|
+
<p class="m-0 text-sm font-medium text-foreground">{title}</p>
|
|
29
|
+
{description && (
|
|
30
|
+
<p class="m-0 max-w-[52ch] text-sm leading-relaxed text-muted-foreground">{description}</p>
|
|
31
|
+
)}
|
|
32
|
+
<slot />
|
|
33
|
+
{action && (
|
|
34
|
+
<a href={action.href} class={buttonClass(action.variant ?? "secondary", "sm", "mt-2")}>
|
|
35
|
+
{action.label}
|
|
36
|
+
</a>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|