@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
@@ -0,0 +1,345 @@
1
+ ---
2
+ /**
3
+ * ProjectCard — the canonical card for one directory record.
4
+ *
5
+ * V1 (v0.5.0) refined design for the three-column grid, now shared by
6
+ * every listing surface (home sections, browse results, collection
7
+ * entries, taxonomy pages) through thin adapters (`IndexRow`,
8
+ * `CollectionRow`):
9
+ *
10
+ * - Default mode: the whole card is one clickable `<a>` (cursor:
11
+ * pointer, subtle border + surface lift on hover, no more than
12
+ * 1–2px vertical motion).
13
+ * - When `secondaryLinks` are passed the root becomes an `<article>`
14
+ * with a stretched title link, because nesting `<a>` inside `<a>`
15
+ * is invalid HTML. Secondary links sit above the stretched link
16
+ * (`relative z-10`).
17
+ * - App icon top-left (48px), app name + repository owner/repo,
18
+ * two-line description with reserved vertical space.
19
+ * - Footer row: platform logos (or labelled stack/platform chips via
20
+ * `labelledChips`), stars/updated/license on the right, and the
21
+ * navigation arrow anchored bottom-right.
22
+ * - Everything beyond the default home-page rendering is opt-in:
23
+ * `showCurated`, `showLicense`, `showCategoryStatus`,
24
+ * `labelledChips`, `secondaryLinks`, analytics `data-event` props.
25
+ *
26
+ * Data can come from a full `record` or from explicit props (adapters
27
+ * for shapes like `CollectionEntry` pass fields directly); explicit
28
+ * props win over record-derived values.
29
+ *
30
+ * Uses the package's design-system tokens plus the standard `dark:`
31
+ * variant. Equal-height cards in the row are achieved at the grid
32
+ * level (`CardGrid`: `grid` + `items-stretch`).
33
+ */
34
+ import type { IndexRecord, Resource, ProjectRecord, IndexProjectRecord } from "@grove-dev/core";
35
+ import {
36
+ getOwnerAndRepoFromRepoUrl,
37
+ getOwnerAvatarUrl,
38
+ formatRelative,
39
+ formatStars,
40
+ licenseDisplay,
41
+ nameInitials,
42
+ projectStackIds,
43
+ statusDisplay,
44
+ } from "@grove-dev/astro";
45
+ import CardIcon from "./CardIcon.astro";
46
+ import Icon from "./Icon.astro";
47
+ import StackPlatformChips from "./StackPlatformChips.astro";
48
+
49
+ type CardRecord = IndexRecord | Resource | ProjectRecord | IndexProjectRecord;
50
+
51
+ interface SecondaryLink {
52
+ label: string;
53
+ href: string;
54
+ /** Adds target="_blank" + rel and the external-link glyph. */
55
+ external?: boolean;
56
+ /** Analytics hooks forwarded as data-event attributes. */
57
+ event?: string;
58
+ eventSource?: string;
59
+ }
60
+
61
+ interface Props {
62
+ /** Record to render. Optional when explicit props are passed. */
63
+ record?: CardRecord;
64
+ /** Full detail-page URL. */
65
+ href: string;
66
+ /** Optional pre-resolved logo URL. Wins over the GitHub avatar. */
67
+ logoUrl?: string;
68
+ /** Override the description (defaults to `record.description`). */
69
+ description?: string;
70
+ /** Explicit overrides for record-derived values (adapter use). */
71
+ name?: string;
72
+ slug?: string;
73
+ repoUrl?: string;
74
+ stars?: number;
75
+ pushedAt?: string | null;
76
+ license?: string | null;
77
+ category?: string;
78
+ status?: string;
79
+ curated?: boolean;
80
+ stack?: string;
81
+ platforms?: string[];
82
+ /** Opt-in extras (all default off to keep the home grid unchanged). */
83
+ showCurated?: boolean;
84
+ showLicense?: boolean;
85
+ showCategoryStatus?: boolean;
86
+ /** Labelled stack/platform chip rows instead of the logo strip. */
87
+ labelledChips?: boolean;
88
+ /** Footer links; presence switches the root to `<article>` mode. */
89
+ secondaryLinks?: SecondaryLink[];
90
+ /** Eager-load the avatar (above-the-fold rows). */
91
+ eager?: boolean;
92
+ /** Analytics data-event attributes on the card root. */
93
+ event?: string;
94
+ eventSource?: string;
95
+ class?: string;
96
+ }
97
+
98
+ const {
99
+ record,
100
+ href,
101
+ logoUrl: logoUrlProp,
102
+ description: descriptionProp,
103
+ name: nameProp,
104
+ slug: slugProp,
105
+ repoUrl: repoUrlProp,
106
+ stars: starsProp,
107
+ pushedAt: pushedAtProp,
108
+ license: licenseProp,
109
+ category: categoryProp,
110
+ status: statusProp,
111
+ curated: curatedProp,
112
+ stack: stackProp,
113
+ platforms: platformsProp,
114
+ showCurated = false,
115
+ showLicense = false,
116
+ showCategoryStatus = false,
117
+ labelledChips = false,
118
+ secondaryLinks = [],
119
+ eager = false,
120
+ event,
121
+ eventSource,
122
+ class: className = "",
123
+ } = Astro.props;
124
+
125
+ const isProject = record?.kind === "project";
126
+ const name =
127
+ nameProp ??
128
+ (record
129
+ ? record.kind === "resource"
130
+ ? (record as { title: string }).title
131
+ : (record as { name: string }).name
132
+ : "");
133
+ const slug = slugProp ?? (record as { slug?: string } | undefined)?.slug;
134
+ const repoHref =
135
+ repoUrlProp ??
136
+ (isProject
137
+ ? (record as { repoUrl?: string }).repoUrl
138
+ : (record as { links?: { github?: string } } | undefined)?.links?.github);
139
+
140
+ const { owner, repo } = getOwnerAndRepoFromRepoUrl(repoHref ?? "");
141
+ const avatarUrl =
142
+ logoUrlProp ??
143
+ (isProject ? (record as { logoUrl?: string }).logoUrl : undefined) ??
144
+ getOwnerAvatarUrl(owner, 80);
145
+
146
+ const initials = nameInitials(name);
147
+
148
+ const description =
149
+ descriptionProp ?? (record as { description?: string } | undefined)?.description ?? "";
150
+
151
+ const platforms =
152
+ platformsProp ?? (isProject ? ((record as { platforms?: string[] }).platforms ?? []) : []);
153
+ const stars =
154
+ starsProp ?? (isProject ? (record as { github?: { stars?: number } }).github?.stars : undefined);
155
+ const pushedAt =
156
+ pushedAtProp ??
157
+ (isProject
158
+ ? (record as { github?: { pushedAt?: string | null } }).github?.pushedAt
159
+ : (record as { publishedAt?: string | null } | undefined)?.publishedAt);
160
+ const license =
161
+ licenseProp ??
162
+ (isProject ? (record as { github?: { license?: string | null } }).github?.license : undefined);
163
+ const category = categoryProp ?? (record as { category?: string } | undefined)?.category;
164
+ const status =
165
+ statusProp ?? (isProject ? (record as { health?: { status?: string } }).health?.status : undefined);
166
+ const curated =
167
+ curatedProp ??
168
+ Boolean(
169
+ (record as { curation?: { reviewed?: boolean } } | undefined)?.curation?.reviewed ||
170
+ (isProject &&
171
+ ((record as { bestFor?: string[] }).bestFor?.length ||
172
+ (record as { whyListed?: string[] }).whyListed?.length ||
173
+ (record as { caveats?: string[] }).caveats?.length)),
174
+ );
175
+
176
+ const stackId =
177
+ stackProp ??
178
+ (isProject
179
+ ? projectStackIds(record as { stack?: string; stacks?: string[] })[0]
180
+ : undefined);
181
+
182
+ const starsLabel = formatStars(stars);
183
+ const updatedLabel = formatRelative(pushedAt);
184
+ // "Unknown" is an internal placeholder, not information — suppress it
185
+ // so card metadata never reads "Category · Unknown" to visitors.
186
+ const rawStatusText = status ? statusDisplay(status as Parameters<typeof statusDisplay>[0]) : "";
187
+ const statusText = rawStatusText === "Unknown" ? "" : rawStatusText;
188
+ const licenseLabel = license ? licenseDisplay(license) : null;
189
+
190
+ const hasStars = starsLabel !== null && typeof stars === "number" && stars > 0;
191
+ const hasUpdated = Boolean(pushedAt);
192
+
193
+ // `<a>`-in-`<a>` is invalid HTML, so cards with secondary links render
194
+ // as an `<article>` whose title link is stretched over the card.
195
+ const isArticle = secondaryLinks.length > 0;
196
+ const Root = isArticle ? "article" : "a";
197
+ const rootAttrs = isArticle ? {} : { href, "aria-label": `Open ${name}` };
198
+
199
+ // Render the homepage brand strip through the shared `Icon` component
200
+ // so brand marks come from the same `/icons/platforms/` files the
201
+ // chip rows use. No more inline SVG strings — one source of truth.
202
+ // `Icon` falls back to initials when an asset is missing, so the
203
+ // strip simply renders whatever platforms the record declares.
204
+ const visiblePlatforms = platforms.slice(0, 4);
205
+ const platformOverflow = platforms.length - visiblePlatforms.length;
206
+ const hasPlatforms = visiblePlatforms.length > 0;
207
+ ---
208
+
209
+ <Root
210
+ {...rootAttrs}
211
+ class={`groove-project-card group relative flex h-full min-w-0 flex-col gap-3 rounded-xl border border-ink-200 bg-card p-5 outline-none transition-[border-color,background-color,transform] duration-150 hover:-translate-y-0.5 hover:border-ink-300 hover:bg-ink-50 focus-visible:border-ink-500 focus-visible:ring-2 focus-visible:ring-ink-900/15 dark:border-ink-800 dark:hover:border-ink-700 dark:hover:bg-ink-900 dark:focus-visible:border-ink-500 dark:focus-visible:ring-ink-100/15 ${className}`}
212
+ data-record-slug={slug}
213
+ data-event={event}
214
+ data-event-source={eventSource}
215
+ data-event-item={event ? slug : undefined}
216
+ >
217
+ {/* Header: icon + name + owner */}
218
+ <header class="flex items-start gap-3">
219
+ {avatarUrl ? (
220
+ <img
221
+ src={avatarUrl}
222
+ alt=""
223
+ width="48"
224
+ height="48"
225
+ loading={eager ? "eager" : "lazy"}
226
+ fetchpriority={eager ? "high" : undefined}
227
+ decoding="async"
228
+ class="h-12 w-12 shrink-0 rounded-lg bg-white object-contain p-1 dark:bg-white"
229
+ />
230
+ ) : (
231
+ <span
232
+ aria-hidden="true"
233
+ class="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-ink-100 text-base font-semibold uppercase text-ink-700 dark:bg-ink-800 dark:text-ink-200"
234
+ >
235
+ {initials}
236
+ </span>
237
+ )}
238
+
239
+ <div class="flex min-w-0 flex-1 flex-col gap-0.5">
240
+ <h3 class="m-0 truncate text-[17px] font-semibold leading-snug tracking-tight text-ink-900 sm:text-[18px] dark:text-ink-100">
241
+ {isArticle ? (
242
+ <a
243
+ href={href}
244
+ class="text-inherit no-underline before:absolute before:inset-0 before:z-0 before:content-['']"
245
+ >
246
+ {name}
247
+ </a>
248
+ ) : (
249
+ name
250
+ )}
251
+ </h3>
252
+ {owner && repo && (
253
+ <p class="m-0 truncate font-mono text-[13px] text-ink-500 dark:text-ink-400">
254
+ {owner}<span class="opacity-60">/</span>{repo}
255
+ </p>
256
+ )}
257
+ {showCategoryStatus && (category || statusText) && (
258
+ <p class="m-0 truncate text-2xs text-ink-500 dark:text-ink-400">
259
+ {category}
260
+ {category && statusText && <span class="mx-1 text-ink-300 dark:text-ink-700">·</span>}
261
+ {statusText}
262
+ </p>
263
+ )}
264
+ </div>
265
+
266
+ {showCurated && curated && (
267
+ <span
268
+ class="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"
269
+ title="Reviewed by a curator"
270
+ >
271
+ <CardIcon name="curated" />
272
+ curated
273
+ </span>
274
+ )}
275
+ </header>
276
+
277
+ {/* Description — two-line clamp with reserved vertical space */}
278
+ <p
279
+ class="m-0 line-clamp-2 min-h-[42px] text-[14px] leading-[1.5] text-ink-500 dark:text-ink-400"
280
+ >
281
+ {description}
282
+ </p>
283
+
284
+ {/* Labelled stack/platform chip rows (opt-in; directory surfaces) */}
285
+ {labelledChips && (
286
+ <StackPlatformChips stack={stackId} platform={platforms} />
287
+ )}
288
+
289
+ {/* Footer: links / platform logos · meta · arrow */}
290
+ <footer class="mt-auto flex items-center justify-between gap-3 text-[12px] sm:text-[13px]">
291
+ <div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-ink-500 dark:text-ink-400">
292
+ {secondaryLinks.map((link) => (
293
+ <a
294
+ href={link.href}
295
+ {...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
296
+ data-event={link.event}
297
+ data-event-source={link.eventSource}
298
+ data-event-item={link.event ? slug : undefined}
299
+ class="relative z-10 inline-flex items-center gap-1 font-medium text-ink-700 no-underline transition-colors hover:text-ink-900 dark:text-ink-300 dark:hover:text-ink-100"
300
+ >
301
+ {link.label}
302
+ {link.external && <CardIcon name="external" />}
303
+ </a>
304
+ ))}
305
+ {!labelledChips && hasPlatforms && (
306
+ <div class="flex items-center gap-2.5">
307
+ {visiblePlatforms.map((platform) => (
308
+ <Icon
309
+ name={platform}
310
+ category="platform"
311
+ size={22}
312
+ title={platform}
313
+ />
314
+ ))}
315
+ {platformOverflow > 0 && (
316
+ <span class="text-[12px] text-ink-500 dark:text-ink-400">+{platformOverflow}</span>
317
+ )}
318
+ </div>
319
+ )}
320
+ </div>
321
+
322
+ <div class="flex min-w-0 shrink-0 items-center gap-3 text-ink-500 dark:text-ink-400">
323
+ {hasStars && (
324
+ <span class="inline-flex items-center gap-1" title={`${stars ?? 0} stars on GitHub`}>
325
+ <span class="inline-flex text-amber-500"><CardIcon name="star" /></span>
326
+ <span class="font-mono">{starsLabel}</span>
327
+ </span>
328
+ )}
329
+ {hasUpdated && (
330
+ <span class="whitespace-nowrap" title={`Last updated ${pushedAt}`}>
331
+ Updated {updatedLabel}
332
+ </span>
333
+ )}
334
+ {showLicense && licenseLabel && (
335
+ <span class="font-mono text-[10px] uppercase tracking-wide" title={`License: ${licenseLabel}`}>
336
+ {licenseLabel}
337
+ </span>
338
+ )}
339
+ <CardIcon
340
+ name="arrow"
341
+ class="shrink-0 text-ink-400 transition-colors group-hover:text-ink-700 dark:text-ink-500 dark:group-hover:text-ink-100"
342
+ />
343
+ </div>
344
+ </footer>
345
+ </Root>
@@ -21,6 +21,7 @@
21
21
  * the badge's `data-activity-tone` attribute picks one at runtime.
22
22
  */
23
23
  import type { RecordDetailModel } from "../server/models.js";
24
+ import { buttonClass } from "../ui/button.js";
24
25
 
25
26
  interface Props {
26
27
  detail: RecordDetailModel;
@@ -40,34 +41,64 @@ const {
40
41
  sourceDescription,
41
42
  screenshots,
42
43
  category,
43
- description,
44
44
  name,
45
45
  } = detail;
46
46
  ---
47
47
 
48
- <header class="mb-10 flex flex-col gap-5 sm:flex-row sm:items-start sm:gap-6">
49
- {avatarSrc ? (
50
- <img
51
- src={avatarSrc}
52
- alt=""
53
- width="64"
54
- height="64"
55
- loading="lazy"
56
- decoding="async"
57
- class="h-14 w-14 shrink-0 rounded-lg border border-ink-200 object-contain bg-white p-1 dark:border-ink-800 dark:bg-ink-950 sm:h-16 sm:w-16"
58
- />
59
- ) : (
60
- <span
61
- aria-hidden="true"
62
- class="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border border-ink-200 bg-ink-50 text-xl font-semibold text-ink-700 dark:border-ink-800 dark:bg-ink-900 dark:text-ink-200 sm:h-16 sm:w-16"
63
- >
64
- {initials || "·"}
65
- </span>
66
- )}
48
+ <header class="mb-8">
49
+ {/* Identity row: logo sized to the name+repo block beside it — no
50
+ tall avatar column leaving dead space under the logo. Pills sit
51
+ at the row's right edge on wide viewports and wrap below on
52
+ mobile. */}
53
+ <div class="flex items-center gap-4">
54
+ {avatarSrc ? (
55
+ <img
56
+ src={avatarSrc}
57
+ alt=""
58
+ width="56"
59
+ height="56"
60
+ loading="lazy"
61
+ decoding="async"
62
+ class="h-12 w-12 shrink-0 rounded-lg border border-ink-200 object-contain bg-white p-1 dark:border-ink-800 dark:bg-ink-950 sm:h-14 sm:w-14"
63
+ />
64
+ ) : (
65
+ <span
66
+ aria-hidden="true"
67
+ class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-ink-200 bg-ink-50 text-xl font-semibold text-ink-700 dark:border-ink-800 dark:bg-ink-900 dark:text-ink-200 sm:h-14 sm:w-14"
68
+ >
69
+ {initials || "·"}
70
+ </span>
71
+ )}
72
+
73
+ <div class="min-w-0 flex-1">
74
+ <h1 class="m-0 text-[1.75rem] font-semibold leading-tight tracking-tight text-ink-900 dark:text-ink-100 sm:text-[2rem]">
75
+ {name}
76
+ </h1>
77
+ {ownerRepo && (
78
+ <p class="m-0 mt-0.5 text-sm text-ink-500 dark:text-ink-400">
79
+ <a
80
+ href={`https://github.com/${ownerRepo.owner}`}
81
+ target="_blank"
82
+ rel="noopener noreferrer"
83
+ class="text-inherit no-underline hover:text-ink-900 dark:hover:text-ink-100"
84
+ >
85
+ @{ownerRepo.owner}
86
+ </a>
87
+ <span class="mx-1 text-ink-300 dark:text-ink-700">/</span>
88
+ <a
89
+ href={repoUrl}
90
+ target="_blank"
91
+ rel="noopener noreferrer"
92
+ class="text-inherit no-underline hover:text-ink-900 dark:hover:text-ink-100"
93
+ >
94
+ {ownerRepo.repo}
95
+ </a>
96
+ </p>
97
+ )}
98
+ </div>
67
99
 
68
- <div class="min-w-0 flex-1">
69
- {/* Pills row category + curation + Curated check + activity badge. */}
70
- <div class="flex flex-wrap items-center gap-1.5">
100
+ {/* Pills — right-aligned beside the name on wide viewports. */}
101
+ <div class="hidden shrink-0 flex-wrap items-center justify-end gap-1.5 md:flex">
71
102
  {category && (
72
103
  <span class="rounded-full border border-ink-200 px-2 py-0.5 text-2xs font-medium text-ink-700 dark:border-ink-800 dark:text-ink-300">
73
104
  {category}
@@ -78,8 +109,9 @@ const {
78
109
  {l}
79
110
  </span>
80
111
  ))}
112
+ {/* Curated = reviewed provenance, not a success status. */}
81
113
  {record.curation?.reviewed && (
82
- <span class="inline-flex items-center gap-1 rounded border border-emerald-300/60 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:border-emerald-800/60 dark:bg-emerald-950/40 dark:text-emerald-300">
114
+ <span class="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-2 py-0.5 text-2xs font-medium text-secondary-foreground">
83
115
  <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
84
116
  <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0Zm3.41 5.09a.75.75 0 0 0-1.06 0L6.75 8.69 5.65 7.59a.75.75 0 1 0-1.06 1.06l1.65 1.65a.75.75 0 0 0 1.06 0l4.11-4.11a.75.75 0 0 0 0-1.06Z" />
85
117
  </svg>
@@ -89,7 +121,7 @@ const {
89
121
  {activityBadge && (
90
122
  <span
91
123
  data-activity-tone={activityBadge.tone}
92
- class="grove-pill grove-pill--activity inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium"
124
+ class="grove-pill grove-pill--activity inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-2xs font-medium"
93
125
  >
94
126
  <span class="relative flex h-1.5 w-1.5">
95
127
  <span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60"></span>
@@ -99,68 +131,65 @@ const {
99
131
  </span>
100
132
  )}
101
133
  </div>
134
+ </div>
102
135
 
103
- <h1 class="m-0 mt-2 text-[2rem] font-semibold leading-tight tracking-tight text-ink-900 dark:text-ink-100 sm:text-[2.25rem]">
104
- {name}
105
- </h1>
106
-
107
- {ownerRepo && (
108
- <p class="m-0 mt-1 text-sm text-ink-500 dark:text-ink-400">
109
- <a
110
- href={`https://github.com/${ownerRepo.owner}`}
111
- target="_blank"
112
- rel="noopener noreferrer"
113
- class="text-inherit no-underline hover:text-ink-900 dark:hover:text-ink-100"
114
- >
115
- @{ownerRepo.owner}
116
- </a>
117
- <span class="mx-1 text-ink-300 dark:text-ink-700">/</span>
118
- <a
119
- href={repoUrl}
120
- target="_blank"
121
- rel="noopener noreferrer"
122
- class="text-inherit no-underline hover:text-ink-900 dark:hover:text-ink-100"
123
- >
124
- {ownerRepo.repo}
125
- </a>
126
- </p>
136
+ {/* Same pills below the identity row on narrow viewports. */}
137
+ <div class="mt-3 flex flex-wrap items-center gap-1.5 md:hidden">
138
+ {category && (
139
+ <span class="rounded-full border border-ink-200 px-2 py-0.5 text-2xs font-medium text-ink-700 dark:border-ink-800 dark:text-ink-300">
140
+ {category}
141
+ </span>
142
+ )}
143
+ {curationLabels.map((l) => (
144
+ <span class="rounded-full bg-secondary px-2 py-0.5 text-2xs font-medium text-secondary-foreground">
145
+ {l}
146
+ </span>
147
+ ))}
148
+ {record.curation?.reviewed && (
149
+ <span class="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-2 py-0.5 text-2xs font-medium text-secondary-foreground">
150
+ <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
151
+ <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0Zm3.41 5.09a.75.75 0 0 0-1.06 0L6.75 8.69 5.65 7.59a.75.75 0 1 0-1.06 1.06l1.65 1.65a.75.75 0 0 0 1.06 0l4.11-4.11a.75.75 0 0 0 0-1.06Z" />
152
+ </svg>
153
+ Curated
154
+ </span>
127
155
  )}
156
+ </div>
128
157
 
129
- {summary && (
130
- <p class="m-0 mt-3 max-w-[68ch] text-base leading-relaxed text-ink-700 dark:text-ink-200">
131
- {summary}
132
- </p>
158
+ {/* Description — full width below the identity row. */}
159
+ {summary && (
160
+ <p class="m-0 mt-4 max-w-[72ch] text-base leading-relaxed text-ink-700 dark:text-ink-200">
161
+ {summary}
162
+ </p>
163
+ )}
164
+ {sourceDescription && sourceDescription !== summary && (
165
+ <p class="m-0 mt-2 max-w-[72ch] text-sm leading-relaxed text-ink-500 dark:text-ink-400">
166
+ <span class="text-2xs font-semibold uppercase tracking-wider">From the project's README: </span>
167
+ {sourceDescription}
168
+ </p>
169
+ )}
170
+
171
+ {/* CTA row — primary actions only. */}
172
+ <div class="mt-4 flex flex-wrap items-center gap-2">
173
+ {repoUrl && (
174
+ <a
175
+ href={repoUrl}
176
+ target="_blank"
177
+ rel="noopener noreferrer"
178
+ class={buttonClass("primary", "sm")}
179
+ >
180
+ View on GitHub →
181
+ </a>
133
182
  )}
134
- {sourceDescription && sourceDescription !== summary && (
135
- <p class="m-0 mt-2 max-w-[68ch] text-sm leading-relaxed text-ink-500 dark:text-ink-400">
136
- <span class="text-2xs font-semibold uppercase tracking-wider">From the project's README: </span>
137
- {sourceDescription}
138
- </p>
183
+ {homepageUrl && (
184
+ <a
185
+ href={homepageUrl}
186
+ target="_blank"
187
+ rel="noopener noreferrer"
188
+ class={buttonClass("secondary", "sm")}
189
+ >
190
+ Homepage
191
+ </a>
139
192
  )}
140
-
141
- {/* CTA row — primary actions only. */}
142
- <div class="mt-4 flex flex-wrap items-center gap-2">
143
- {repoUrl && (
144
- <a
145
- href={repoUrl}
146
- target="_blank"
147
- rel="noopener noreferrer"
148
- class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent px-3 text-sm font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 bg-primary text-primary-foreground hover:bg-primary/90"
149
- >
150
- View on GitHub →
151
- </a>
152
- )}
153
- {homepageUrl && (
154
- <a
155
- href={homepageUrl}
156
- target="_blank"
157
- rel="noopener noreferrer"
158
- class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-border bg-secondary px-3 text-sm font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 text-foreground hover:bg-accent"
159
- >
160
- Homepage
161
- </a>
162
- )}
163
- </div>
164
193
  </div>
165
194
  {screenshots && screenshots.length > 0 && (
166
195
  <div class="mt-6 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-label="Screenshots">
@@ -3,9 +3,9 @@
3
3
  * RecordSection — generic lens-style section wrapper for the
4
4
  * home page.
5
5
  *
6
- * Renders a SectionHeader + a 3-column grid of `ItemCard`s (a
7
- * record card; kept as `ItemCard` for V1 published API stability),
8
- * optional "View all" link in the header. Generic: takes any
6
+ * Renders a SectionHeader + a 3-column grid of `ProjectCard`s
7
+ * (the v0.5.0 refined card), plus an optional "View all" link
8
+ * in the header. Generic: takes any
9
9
  * `IndexRecord[]` (the slim list-page shape, discriminated union
10
10
  * over `kind`).
11
11
  *
@@ -24,7 +24,8 @@
24
24
  * homepage never collapses to a blank section.
25
25
  */
26
26
  import type { IndexRecord } from "@grove-dev/core";
27
- import ItemCard from "./ItemCard.astro";
27
+ import CardGrid from "./CardGrid.astro";
28
+ import ProjectCard from "./ProjectCard.astro";
28
29
  import Container from "../layouts/Container.astro";
29
30
  import SectionHeader from "../layouts/SectionHeader.astro";
30
31
 
@@ -47,6 +48,8 @@ interface Props {
47
48
  emptyLabel?: string;
48
49
  /** Extra class on the outer <section>. */
49
50
  class?: string;
51
+ /** How many records to render. Default 3 — the refined grid shows one row. */
52
+ limit?: number;
50
53
  }
51
54
 
52
55
  const {
@@ -61,9 +64,10 @@ const {
61
64
  eyebrow,
62
65
  emptyLabel = "No records yet.",
63
66
  class: className = "",
67
+ limit = 3,
64
68
  } = Astro.props;
65
69
 
66
- const visible = items.slice(0, 6);
70
+ const visible = items.slice(0, limit);
67
71
  ---
68
72
 
69
73
  <section
@@ -92,9 +96,7 @@ const visible = items.slice(0, 6);
92
96
  {emptyLabel}
93
97
  </p>
94
98
  ) : (
95
- <div
96
- class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
97
- >
99
+ <CardGrid>
98
100
  {visible.map((item) => {
99
101
  // Resolve href: caller-supplied `hrefFor` callback wins;
100
102
  // otherwise fall back to `${pathPrefix}/${slug}`. The href
@@ -106,10 +108,12 @@ const visible = items.slice(0, 6);
106
108
  const finalHref =
107
109
  typeof hrefFor === "function" ? hrefFor(item) : fallbackHref;
108
110
  return (
109
- <ItemCard record={item} href={finalHref} />
111
+ <li class="m-0 p-0">
112
+ <ProjectCard record={item} href={finalHref} />
113
+ </li>
110
114
  );
111
115
  })}
112
- </div>
116
+ </CardGrid>
113
117
  )}
114
118
 
115
119
  {viewAllHref && (