@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.
Files changed (80) 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 +9 -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/src/ui/Badge.astro +38 -0
  61. package/src/ui/Button.astro +42 -0
  62. package/src/ui/EmptyState.astro +38 -0
  63. package/src/ui/FilterDrawer.astro +104 -0
  64. package/src/ui/PageHeader.astro +39 -0
  65. package/src/ui/SearchField.astro +50 -0
  66. package/src/ui/button.test.ts +54 -0
  67. package/src/ui/button.ts +98 -0
  68. package/dist/lib/scores.d.ts +0 -2
  69. package/dist/lib/scores.d.ts.map +0 -1
  70. package/dist/lib/scores.js +0 -2
  71. package/dist/lib/scores.js.map +0 -1
  72. package/src/components/CurationGrid.astro +0 -41
  73. package/src/components/DecisionRow.astro +0 -89
  74. package/src/components/ExploreByCategory.astro +0 -67
  75. package/src/components/ExploreByStack.astro +0 -78
  76. package/src/components/GroveDocumentHead.astro +0 -28
  77. package/src/components/ItemCard.astro +0 -324
  78. package/src/components/MinimalAbout.astro +0 -82
  79. package/src/components/ScoreBars.astro +0 -102
  80. package/src/lib/scores.ts +0 -1
@@ -83,25 +83,32 @@ const {
83
83
  // items label so "apps" → "app" without an extra prop.
84
84
  const singular = (itemLabel ?? itemsLabel.replace(/s$/, "")).toLowerCase();
85
85
 
86
- const githubUrl = stats.originalRepo;
87
-
88
- const trustStats: { value: string; label: string }[] = [
89
- ...(stats.totalApps !== undefined
90
- ? [{ value: compact(stats.totalApps) ?? "0", label: `${itemsLabel} indexed` }]
86
+ // One compact stats line: "6 projects indexed · 5 categories · 3 stacks".
87
+ // Every entry is guarded so a fresh directory never renders a zero-value.
88
+ const statParts: string[] = [
89
+ ...(stats.totalApps !== undefined && stats.totalApps > 0
90
+ ? [`${compact(stats.totalApps)} ${itemsLabel} indexed`]
91
91
  : []),
92
- ...(stats.totalCategories !== undefined
93
- ? [{ value: String(stats.totalCategories), label: "categories" }]
92
+ ...(stats.totalCategories !== undefined && stats.totalCategories > 0
93
+ ? [`${stats.totalCategories} categories`]
94
94
  : []),
95
- ...(stats.totalStacks !== undefined
96
- ? [{ value: String(stats.totalStacks), label: "stacks" }]
95
+ ...(stats.totalStacks !== undefined && stats.totalStacks > 0
96
+ ? [`${stats.totalStacks} stacks`]
97
97
  : []),
98
98
  ...(stats.totalOwners !== undefined && stats.totalOwners > 0
99
- ? [{ value: compact(stats.totalOwners) ?? "0", label: "project owners" }]
99
+ ? [`${compact(stats.totalOwners)} owners`]
100
100
  : []),
101
101
  ...(stats.totalStars !== undefined && stats.totalStars > 0
102
- ? [{ value: compact(stats.totalStars) ?? "0", label: "stars across projects" }]
102
+ ? [`${compact(stats.totalStars)} stars`]
103
103
  : []),
104
104
  ];
105
+
106
+ // One primary CTA only — Submit already lives in the navbar
107
+ // (BaseLayout → Header submitHref), so the hero never duplicates it.
108
+ const primaryCta: CTA | undefined =
109
+ ctas.find((c) => c.variant === "primary") ??
110
+ ctas[0] ??
111
+ { label: `Browse ${itemsLabel}`, href: searchHref, variant: "primary" };
105
112
  ---
106
113
 
107
114
  <section
@@ -110,180 +117,119 @@ const trustStats: { value: string; label: string }[] = [
110
117
  className,
111
118
  ]}
112
119
  >
120
+ {/* Dark mode only: a barely-there radial lift behind the headline. */}
113
121
  <div
114
122
  aria-hidden="true"
115
- class="pointer-events-none absolute inset-0 opacity-60 dark:opacity-40"
116
- style="
117
- background-image:
118
- radial-gradient(60% 50% at 15% 20%, color-mix(in oklab, var(--color-ink-300) 34%, transparent), transparent 60%),
119
- radial-gradient(50% 40% at 85% 10%, color-mix(in oklab, var(--color-ink-200) 28%, transparent), transparent 60%),
120
- radial-gradient(40% 40% at 50% 90%, color-mix(in oklab, var(--color-ink-400) 18%, transparent), transparent 60%);
121
- "
123
+ class="pointer-events-none absolute inset-0 hidden dark:block"
124
+ style="background: radial-gradient(60% 55% at 50% 0%, color-mix(in oklab, var(--grove-foreground) 5%, transparent) 0%, transparent 70%);"
122
125
  ></div>
123
126
 
124
127
  <Container>
125
- <div class="relative py-12 sm:py-16">
126
- <div class="max-w-3xl">
127
- <span
128
- class="inline-flex animate-fade-in items-center gap-2 text-2xs font-medium text-ink-500 dark:text-ink-400"
129
- >
130
- <span
131
- aria-hidden="true"
132
- class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500"
133
- ></span>
134
- {eyebrow}
135
- {eyebrowBadge && (
136
- <span
137
- class="inline-flex items-center rounded-full border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300"
138
- aria-label={eyebrowBadge}
139
- >
140
- {eyebrowBadge}
141
- </span>
142
- )}
143
- </span>
144
-
145
- <h1
146
- class="mt-3 animate-fade-in-up text-[2.25rem] sm:text-[2.75rem] md:text-[3rem] font-semibold tracking-[-0.02em] leading-[1.05] text-ink-900 dark:text-ink-100 m-0"
147
- style="animation-delay: 0.05s;"
148
- >
149
- {name}<br class="hidden sm:block" />
128
+ <div class="relative mx-auto flex max-w-[900px] flex-col items-center py-[88px] text-center lg:py-[104px]">
129
+ {/* Eyebrow */}
130
+ <span class="inline-flex animate-fade-in items-center gap-2 text-[13px] font-medium text-ink-500 dark:text-ink-400">
131
+ <span aria-hidden="true" class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500"></span>
132
+ {eyebrow}
133
+ {eyebrowBadge && (
150
134
  <span
151
- class="bg-gradient-to-r from-ink-900 via-ink-700 to-ink-900 bg-clip-text text-transparent dark:from-ink-100 dark:via-ink-300 dark:to-ink-100"
152
- style="background-size: 200% 200%; animation: fade-in-up 0.7s ease-out 0.1s both, gradient-shift 8s ease-in-out 1s infinite;"
135
+ class="inline-flex items-center rounded-full border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300"
136
+ aria-label={eyebrowBadge}
153
137
  >
154
- {tagline}
138
+ {eyebrowBadge}
155
139
  </span>
156
- </h1>
157
-
158
- {description && (
159
- <p
160
- class="mt-4 animate-fade-in-up text-[0.9375rem] sm:text-[1rem] leading-relaxed text-ink-500 dark:text-ink-400 m-0 max-w-[64ch]"
161
- style="animation-delay: 0.15s;"
162
- >
163
- {description}
164
- </p>
165
140
  )}
166
- </div>
141
+ </span>
167
142
 
168
- {/* Trust stats row */}
169
- {trustStats.length > 0 && (
170
- <dl
171
- class:list={[
172
- "mt-7 grid max-w-3xl grid-cols-2 gap-x-6 gap-y-3",
173
- trustStats.length === 2 && "sm:grid-cols-2",
174
- trustStats.length === 3 && "grid-cols-3",
175
- trustStats.length === 4 && "sm:grid-cols-4",
176
- trustStats.length >= 5 && "sm:grid-cols-5",
177
- ]}
178
- aria-label="Directory at a glance"
143
+ {/* Headline */}
144
+ <h1
145
+ class="m-0 mt-5 max-w-[820px] animate-fade-in-up text-balance text-[clamp(44px,5vw,64px)] font-[650] leading-[1.08] tracking-[-0.035em] text-ink-900 dark:text-ink-100"
146
+ style="animation-delay: 0.05s;"
147
+ >
148
+ <span class="block">{name}</span>
149
+ <span class="block">{tagline}</span>
150
+ </h1>
151
+
152
+ {description && (
153
+ <p
154
+ class="m-0 mt-5 max-w-[640px] animate-fade-in-up text-[16px] leading-[1.6] text-ink-500 dark:text-ink-400"
155
+ style="animation-delay: 0.1s;"
179
156
  >
180
- {trustStats.map((s) => (
181
- <div class="flex flex-col gap-0.5">
182
- <dt class="sr-only">{s.label}</dt>
183
- <dd class="m-0 font-mono text-[1.25rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
184
- {s.value}
185
- </dd>
186
- <dd class="m-0 text-2xs uppercase tracking-wider text-ink-500 dark:text-ink-400">
187
- {s.label}
188
- </dd>
189
- </div>
190
- ))}
191
- </dl>
157
+ {description}
158
+ </p>
192
159
  )}
193
160
 
194
- {/* Search */}
161
+ {/* Search — primary interaction. The FORM is the visual control:
162
+ it owns the border, radius, and the single focus treatment
163
+ (focus-within border + soft ring). The inner input opts out
164
+ of the global input styling (`grove-input-unstyled`) so it
165
+ stays a transparent, chrome-free text slot — no second box
166
+ or ring appearing inside the pill on focus. */}
195
167
  <form
196
- class="mt-7 flex max-w-4xl flex-col gap-2 sm:flex-row sm:items-stretch"
168
+ class="mt-8 flex h-[58px] w-full max-w-[840px] animate-fade-in-up items-center gap-3 rounded-xl border border-ink-200 bg-background p-1.5 pl-4 transition-[border-color,box-shadow] focus-within:border-ink-400 focus-within:ring-3 focus-within:ring-ring/15 dark:border-ink-800 dark:focus-within:border-ink-600"
169
+ style="animation-delay: 0.15s;"
197
170
  role="search"
198
171
  action={searchHref}
199
172
  method="get"
200
173
  >
201
- <label class="relative flex-1">
202
- <span class="sr-only">Search {itemsLabel}, stacks, categories, licenses</span>
203
- <svg
204
- viewBox="0 0 16 16"
205
- width="16"
206
- height="16"
207
- aria-hidden="true"
208
- class="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-400"
209
- fill="currentColor"
210
- >
211
- <path
212
- d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"
213
- ></path>
174
+ <label class="flex min-w-0 flex-1 items-center gap-3">
175
+ <span class="sr-only">Search {itemsLabel}, stacks, and categories</span>
176
+ <svg viewBox="0 0 16 16" width="20" height="20" aria-hidden="true" class="shrink-0 text-ink-400 dark:text-ink-500" fill="currentColor">
177
+ <path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"></path>
214
178
  </svg>
215
179
  <input
216
180
  type="search"
217
181
  name="q"
218
182
  placeholder={searchPlaceholder}
219
- class="h-10 w-full rounded-md border border-ink-200 bg-white pl-10 pr-3 text-[0.9375rem] text-ink-900 placeholder:text-ink-400 focus:border-ink-900 focus:outline-none focus:ring-2 focus:ring-ink-900/10 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-100 dark:placeholder:text-ink-500 dark:focus:border-ink-100 dark:focus:ring-ink-100/15"
183
+ class="grove-input-unstyled h-full w-full min-w-0 rounded-none border-0 bg-transparent px-0 text-[16px] text-ink-900 outline-none placeholder:text-ink-400 dark:text-ink-100 dark:placeholder:text-ink-500"
220
184
  />
221
185
  </label>
222
186
  <button
223
187
  type="submit"
224
- 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 h-10 px-4 sm:px-5"
188
+ class="inline-flex h-[46px] shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-primary px-5 text-[15px] font-medium leading-none text-primary-foreground outline-none transition-colors hover:bg-primary/90 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35"
225
189
  >
226
190
  Search
227
191
  </button>
228
192
  </form>
229
193
 
230
194
  {quickFilters.length > 0 && (
231
- <p
232
- class="mt-2 text-2xs text-ink-500 dark:text-ink-400 m-0"
233
- >
195
+ <p class="m-0 mt-4 text-[13px] text-ink-500 dark:text-ink-400">
234
196
  Try{" "}
235
197
  {quickFilters.map((f, i) => (
236
198
  <>
237
- <a
238
- href={f.href}
239
- class="font-mono text-ink-700 underline-offset-2 hover:underline dark:text-ink-300"
240
- >
241
- {f.label}
242
- </a>
243
- {i < quickFilters.length - 1 && <span class="mx-1">·</span>}
199
+ <a href={f.href} class="font-mono text-ink-700 underline-offset-2 hover:underline dark:text-ink-300">{f.label}</a>
200
+ {i < quickFilters.length - 1 && <span aria-hidden="true" class="mx-1">·</span>}
244
201
  </>
245
202
  ))}
246
203
  </p>
247
204
  )}
248
205
 
249
- {ctas.length > 0 && (
250
- <div class="mt-5 flex flex-wrap items-center gap-2">
251
- {ctas.map((cta) => (
252
- <a
253
- href={cta.href}
254
- class:list={[
255
- "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",
256
- cta.variant === "primary"
257
- ? "bg-primary text-primary-foreground hover:bg-primary/90"
258
- : cta.variant === "ghost"
259
- ? "bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground"
260
- : "border-border bg-secondary text-foreground hover:bg-accent",
261
- ]}
262
- {...(cta.external
263
- ? { target: "_blank", rel: "noopener noreferrer" }
264
- : {})}
265
- data-event="hero_cta_click"
266
- data-event-source="hero"
267
- >
268
- {cta.label}
269
- </a>
206
+ {/* Inline stats single compact line, middle-dot separators */}
207
+ {statParts.length > 0 && (
208
+ <p class="m-0 mt-5 text-[13px] text-ink-500 dark:text-ink-400">
209
+ {statParts.map((part, i) => (
210
+ <>
211
+ {i > 0 && <span aria-hidden="true" class="mx-2 opacity-60">·</span>}
212
+ <span>{part}</span>
213
+ </>
270
214
  ))}
271
- </div>
215
+ </p>
272
216
  )}
273
217
 
274
- {/* Help debug the type system when callers forget to pass ctas. */}
275
- {ctas.length === 0 && githubUrl && (
276
- <div class="mt-5 flex flex-wrap items-center gap-2">
277
- <a href={searchHref} 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">
278
- Browse {itemsLabel}
279
- </a>
218
+ {/* Primary CTA only */}
219
+ {primaryCta && (
220
+ <div class="mt-8">
280
221
  <a
281
- href={`${githubUrl}/blob/main/CONTRIBUTING.md`}
282
- target="_blank"
283
- rel="noopener noreferrer"
284
- 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 border-border bg-secondary text-foreground hover:bg-accent"
222
+ href={primaryCta.href}
223
+ class="inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius)] border border-transparent bg-primary px-5 text-[15px] font-medium leading-none text-primary-foreground outline-none transition-colors hover:bg-primary/90 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35"
224
+ {...(primaryCta.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
225
+ data-event="hero_cta_click"
226
+ data-event-source="hero"
285
227
  >
286
- Submit via GitHub
228
+ {primaryCta.label}
229
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
230
+ <path d="M5 12h14"></path>
231
+ <path d="m12 5 7 7-7 7"></path>
232
+ </svg>
287
233
  </a>
288
234
  </div>
289
235
  )}
@@ -1,7 +1,16 @@
1
1
  ---
2
+ /**
3
+ * IndexRow — browse-page adapter around the canonical `ProjectCard`.
4
+ *
5
+ * Keeps the historical public props (`record`, `detailHref`,
6
+ * `showChips`, `eager`) so consumer pages copied from earlier versions
7
+ * keep working, while the visual language is now identical to every
8
+ * other card surface. The directory extras — curated badge, license,
9
+ * category · status line, labelled stack/platform chips, and the
10
+ * GitHub footer link — are enabled via `ProjectCard`'s opt-in props.
11
+ */
2
12
  import type { IndexRecord } from "@grove-dev/core";
3
- import StackPlatformChips from "./StackPlatformChips.astro";
4
- import { formatRelative, formatStars, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds, statusDisplay, licenseDisplay } from "@grove-dev/astro";
13
+ import ProjectCard from "./ProjectCard.astro";
5
14
 
6
15
  interface Props {
7
16
  record: IndexRecord;
@@ -16,73 +25,31 @@ interface Props {
16
25
  }
17
26
 
18
27
  const { record, detailHref, showChips = true, eager = false, class: className = "" } = Astro.props;
19
- const item = record;
20
- const project = item.kind === "project" ? item : null;
21
- const name = item.kind === "resource" ? item.title : item.name;
22
- const repoHref = project?.repoUrl ?? item.links.github;
23
- const { owner, repo } = getOwnerAndRepoFromRepoUrl(repoHref ?? "");
24
- const avatar = project ? project.logoUrl ?? getOwnerAvatarUrl(owner, 80) : undefined;
25
- const initials = name.split(/\s+/).map((word) => word[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
26
- const stacks = projectStackIds(project);
27
- const platforms = project?.platforms ?? [];
28
- const stars = project?.github?.stars;
29
- const updatedAt = project?.github?.pushedAt ?? (item.kind === "resource" ? item.publishedAt : null);
30
- const license = project?.github?.license;
31
- const status = project?.health?.status;
32
- const curated = Boolean(item.curation?.reviewed || (project && (project.bestFor.length || project.whyListed.length || project.caveats.length)));
33
- ---
34
-
35
- <article class={`rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors hover:bg-secondary group relative flex flex-col gap-3 p-4 ${className}`}>
36
- <header class="flex items-start gap-3">
37
- <a href={detailHref} class="relative z-10 flex shrink-0" aria-label={`Open ${name} details`}>
38
- {avatar ? (
39
- <img
40
- src={avatar}
41
- alt={`${name} avatar`}
42
- width="44"
43
- height="44"
44
- loading={eager ? "eager" : "lazy"}
45
- fetchpriority={eager ? "high" : undefined}
46
- decoding="async"
47
- class="h-11 w-11 rounded-md border border-ink-200 bg-white object-cover dark:border-ink-800 dark:bg-ink-900"
48
- />
49
- ) : (
50
- <span class="flex h-11 w-11 items-center justify-center rounded-md border border-ink-200 bg-ink-50 text-sm font-semibold text-ink-700 dark:border-ink-800 dark:bg-ink-900/60 dark:text-ink-300">{initials}</span>
51
- )}
52
- </a>
53
- <div class="min-w-0 flex-1">
54
- <div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
55
- <h3 class="m-0 text-[1.0625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
56
- <a href={detailHref} class="relative z-10 hover:underline">{name}</a>
57
- </h3>
58
- {curated && <span class="text-[10px] font-medium uppercase tracking-wider text-emerald-700 dark:text-emerald-400">✓ curated</span>}
59
- </div>
60
- <div class="flex flex-wrap items-center gap-x-1.5 text-2xs text-ink-500 dark:text-ink-400">
61
- {repoHref && owner && <a href={repoHref} target="_blank" rel="noopener noreferrer" class="relative z-10 font-mono hover:text-ink-900 dark:hover:text-ink-100">@{owner}{repo ? `/${repo}` : ""}</a>}
62
- {owner && <span class="text-ink-300 dark:text-ink-700">·</span>}
63
- <span>{item.category}</span>
64
- {status && <><span class="text-ink-300 dark:text-ink-700">·</span><span>{statusDisplay(status)}</span></>}
65
- </div>
66
- </div>
67
- </header>
68
28
 
69
- <p class="m-0 line-clamp-2 text-[0.8125rem] leading-relaxed text-ink-600 dark:text-ink-300">{item.description}</p>
29
+ const repoHref =
30
+ record.kind === "project" ? (record.repoUrl ?? record.links.github) : record.links.github;
70
31
 
71
- <dl class="grid grid-cols-3 gap-x-3 text-2xs">
72
- <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Stars</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{formatStars(stars) ?? "—"}</dd></div>
73
- <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Updated</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{formatRelative(updatedAt)}</dd></div>
74
- <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">License</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{license ? licenseDisplay(license) : "—"}</dd></div>
75
- </dl>
76
-
77
- {showChips && (platforms.length > 0 || stacks.length > 0) && (
78
- <StackPlatformChips
79
- stack={stacks[0]}
80
- platform={platforms}
81
- />
82
- )}
32
+ const secondaryLinks = repoHref
33
+ ? [
34
+ {
35
+ label: "GitHub",
36
+ href: repoHref,
37
+ external: true,
38
+ event: "external_repo_click",
39
+ eventSource: "index_row_github",
40
+ },
41
+ ]
42
+ : [];
43
+ ---
83
44
 
84
- <footer class="relative z-10 mt-1 flex items-center gap-2 border-t border-ink-100 pt-2 text-2xs dark:border-ink-900">
85
- <a href={detailHref} 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 border-border bg-secondary text-foreground hover:bg-accent px-2 text-2xs">View details →</a>
86
- {repoHref && <a href={repoHref} target="_blank" rel="noopener noreferrer" 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 border-border bg-secondary text-foreground hover:bg-accent px-2 text-2xs">GitHub</a>}
87
- </footer>
88
- </article>
45
+ <ProjectCard
46
+ record={record}
47
+ href={detailHref}
48
+ showCurated
49
+ showLicense
50
+ showCategoryStatus
51
+ labelledChips={showChips}
52
+ secondaryLinks={secondaryLinks}
53
+ eager={eager}
54
+ class={className}
55
+ />
@@ -38,7 +38,11 @@ const segments = languages.slice(0, maxSegments).map(([name, bytes]) => {
38
38
  <h3 class="m-0 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
39
39
  Code composition
40
40
  </h3>
41
- <div class="grove-langbreak-bar mt-2 flex h-2 w-full overflow-hidden rounded-full bg-ink-100 dark:bg-ink-800">
41
+ <div
42
+ class="grove-langbreak-bar mt-2 flex h-2 w-full overflow-hidden rounded-full bg-ink-100 dark:bg-ink-800"
43
+ role="img"
44
+ aria-label={`Code composition: ${segments.map((s) => `${s.name} ${s.pct}%`).join(', ')}`}
45
+ >
42
46
  {segments.map((s) => (
43
47
  <span
44
48
  class="grove-langbreak-seg h-full"
@@ -10,6 +10,7 @@
10
10
  * Generic: takes plain `stats` and a `repoUrl`. No blueprint or
11
11
  * site-specific copy.
12
12
  */
13
+ import { buttonClass } from "../ui/button.js";
13
14
 
14
15
  interface OriginStats {
15
16
  stars?: number;
@@ -54,7 +55,7 @@ const hasStats =
54
55
  <section class:list={["py-12 sm:py-16", className]}>
55
56
  <div class="mx-auto w-full max-w-container px-5 sm:px-7">
56
57
  <div
57
- class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between"
58
+ class="rounded-[calc(var(--radius)+0.25rem)] border border-border bg-card shadow-none transition-colors flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between"
58
59
  >
59
60
  <div class="flex flex-col gap-2">
60
61
  <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
@@ -104,7 +105,7 @@ const hasStats =
104
105
  href={repoUrl}
105
106
  target="_blank"
106
107
  rel="noopener noreferrer"
107
- 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"
108
+ class={buttonClass("primary", "sm")}
108
109
  data-event="original_collection_click"
109
110
  data-event-source="original_collection"
110
111
  >
@@ -6,24 +6,14 @@ interface Props {
6
6
  class?: string;
7
7
  }
8
8
 
9
- const { current, total, hrefFor, class: className = "" } = Astro.props;
9
+ import { paginationPageList } from "@grove-dev/astro";
10
+ import { buttonClass, PAGINATION_EXTRA } from "../ui/button.js";
10
11
 
11
- function pageList(active: number, count: number): Array<number | "ellipsis"> {
12
- if (count <= 7) return Array.from({ length: count }, (_, index) => index + 1);
13
- const pages = new Set([1, count, active - 1, active, active + 1]);
14
- const sorted = [...pages].filter((page) => page >= 1 && page <= count).sort((a, b) => a - b);
15
- const result: Array<number | "ellipsis"> = [];
16
- for (const page of sorted) {
17
- const previous = result.at(-1);
18
- if (typeof previous === "number" && page - previous > 1) result.push("ellipsis");
19
- result.push(page);
20
- }
21
- return result;
22
- }
12
+ const { current, total, hrefFor, class: className = "" } = Astro.props;
23
13
 
24
- const pages = pageList(current, total);
25
- const linkClass =
26
- "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 border-border bg-secondary text-foreground hover:bg-accent min-w-9 px-2 py-1.5 text-2xs";
14
+ const pages = paginationPageList(current, total);
15
+ const linkClass = buttonClass("secondary", "sm", PAGINATION_EXTRA);
16
+ const currentClass = buttonClass("selected", "sm", PAGINATION_EXTRA);
27
17
  ---
28
18
 
29
19
  {total > 1 && (
@@ -43,10 +33,7 @@ const linkClass =
43
33
  ) : (
44
34
  <a
45
35
  href={hrefFor(page)}
46
- class:list={[
47
- linkClass,
48
- page === current && "bg-primary text-primary-foreground hover:bg-primary/90",
49
- ]}
36
+ class={page === current ? currentClass : linkClass}
50
37
  aria-current={page === current ? "page" : undefined}
51
38
  >
52
39
  {page}