@spunto/design-system 0.10.0 → 0.11.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 (28) hide show
  1. package/README.md +85 -3
  2. package/package.json +9 -1
  3. package/src/components/devcontainer/catalog-card.tsx +269 -0
  4. package/src/components/devcontainer/catalog-tile.tsx +133 -0
  5. package/src/components/devcontainer/extension-card.tsx +146 -0
  6. package/src/components/devcontainer/feature-card.tsx +157 -0
  7. package/src/components/devcontainer/feature-tile.tsx +63 -0
  8. package/src/components/devcontainer/image-card.tsx +107 -0
  9. package/src/components/devcontainer/image-tile.tsx +56 -0
  10. package/src/components/devcontainer/index.ts +68 -0
  11. package/src/components/devcontainer/publisher-badge.tsx +91 -0
  12. package/src/components/devcontainer/registry-ref.tsx +89 -0
  13. package/src/components/devcontainer/registry.ts +154 -0
  14. package/src/components/devcontainer/stack-mark-paths.ts +60 -0
  15. package/src/components/devcontainer/stack-mark.tsx +92 -0
  16. package/src/components/devcontainer/types.ts +84 -0
  17. package/src/components/projects/advanced-sections.tsx +136 -0
  18. package/src/components/projects/build-manifest.tsx +116 -0
  19. package/src/components/projects/extension-picker.tsx +169 -0
  20. package/src/components/projects/feature-picker.tsx +193 -0
  21. package/src/components/projects/form-section.tsx +97 -0
  22. package/src/components/projects/image-picker.tsx +206 -0
  23. package/src/components/projects/index.ts +58 -0
  24. package/src/components/projects/project-form.tsx +488 -0
  25. package/src/components/projects/repo-list.tsx +302 -0
  26. package/src/components/projects/secret-list.tsx +159 -0
  27. package/src/components/projects/types.ts +136 -0
  28. package/styles.css +13 -0
package/README.md CHANGED
@@ -89,7 +89,7 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
89
89
  provider is also the umbrella that mounts the tooltip delay group and the overlay
90
90
  portal container the dialogs/selects render into.
91
91
 
92
- ## Domain components — `@spunto/design-system/workers`
92
+ ## Domain components — `/workers`, `/devcontainer`, `/projects`
93
93
 
94
94
  Everything above is domain-free: a `Button` knows nothing about Spunto. Components
95
95
  that **do** know a Spunto concept live behind their own entry point, so the root
@@ -98,10 +98,14 @@ of a worker. **The rule for what comes next: any new component that knows a doma
98
98
  concept goes behind a domain sub-export, never in the root entry.**
99
99
 
100
100
  ```tsx
101
- import { Button, Card } from "@spunto/design-system" // primitives
102
- import { WorkerCard } from "@spunto/design-system/workers" // domain
101
+ import { Button, Card } from "@spunto/design-system" // primitives
102
+ import { WorkerCard } from "@spunto/design-system/workers" // domain
103
+ import { ImageCard, FeatureCard, ExtensionCard } from "@spunto/design-system/devcontainer" // domain
104
+ import { ProjectForm } from "@spunto/design-system/projects" // domain
103
105
  ```
104
106
 
107
+ ### `@spunto/design-system/workers`
108
+
105
109
  - **`WorkerCard`** — a Spunto worker as a card: state, author, node, setup
106
110
  progress, git branches, resources, banners, actions. **Purely presentational** —
107
111
  hand it a snapshot plus slots and it draws. The rule that keeps it that way:
@@ -119,6 +123,84 @@ import { WorkerCard } from "@spunto/design-system/workers" // domain
119
123
  minimum, everything optional but `id`, and an unknown state falls back to
120
124
  `pending` instead of throwing.
121
125
 
126
+ ### `@spunto/design-system/devcontainer`
127
+
128
+ The three catalogs you compose an environment from. They exist identically in the
129
+ dashboard and in Spunto Lite, and were hand-drawn as raw `<button>`/`<li>` on both
130
+ sides — exactly the duplication this package is for.
131
+
132
+ - **`ImageCard`** — a devcontainer base image: runtime mark, label, publisher +
133
+ avatar, image reference in mono, description.
134
+ - **`FeatureCard`** — a devcontainer feature: the author's avatar (the GitHub org
135
+ behind the `ghcr.io` ref), label, description, option chips (`version=lts`…),
136
+ OCI reference.
137
+ - **`ImageTile` / `FeatureTile`** (+ the shared `CatalogTile`) — the same entries
138
+ at chip size: a mark, a name, one trailing detail. What a catalog needs past a
139
+ dozen entries, where picking is a *logo* task and full cards become a wall.
140
+ They flow and wrap like tags; the pickers pair them with the full card of
141
+ what's selected.
142
+ - **`ExtensionCard`** (+ `ExtensionCardSkeleton`) — a VS Code extension: its real
143
+ Open VSX icon, display name, publisher + verified badge, version, formatted
144
+ install count, rating.
145
+
146
+ All three share one selectable state (flame check + ring), one **compact
147
+ read-only** variant for the project sheet, and one **`custom`** variant for a
148
+ hand-typed reference that degrades cleanly.
149
+
150
+ - **Nothing here fetches anything.** `onSelect` is a callback, the option row
151
+ (`optionsSlot`) and the trailing control (`action`) are slots. The author and
152
+ the logo are **derived from the reference** by pure functions, because the
153
+ catalogs carry neither: `parseRegistryRef`, `derivePublisher`
154
+ (`ghcr.io/<owner>/…` → that GitHub org, avatar included via
155
+ `github.com/<owner>.png`; `mcr.microsoft.com/devcontainers/…` → Dev Containers)
156
+ and `resolveStackMark`. `publisher` / `mark` / `iconUrl` are the overrides for
157
+ the day the API knows better — no component change needed then.
158
+ - **Brand marks ship with the package** as single-path SVGs drawn with
159
+ `currentColor` (`StackMark`, `STACK_MARK_PATHS`): no third-party CDN, and each
160
+ logo inherits the warm accent of its card instead of dragging 20 clashing brand
161
+ palettes into one grid.
162
+ - **Its bricks, exported individually**: `CatalogCard` (the selectable shell),
163
+ `CatalogMark` (the icon → brand → avatar → monogram cascade), `CatalogChip`,
164
+ `PublisherBadge`/`PublisherAvatar`, `RegistryRef` (mono + copy button), plus the
165
+ pure helpers `parseRegistryRef`, `derivePublisher`, `githubAvatarUrl`,
166
+ `resolveStackMark`, `resolveFeatureOptions`, `formatCount`, `parseExtensionId`.
167
+ - **Types are structural** here too: `DevcontainerImageEntry`,
168
+ `DevcontainerFeatureEntry` and `VscodeExtensionEntry` are all-optional, and an
169
+ unknown registry degrades to a monogram instead of an invented author.
170
+
171
+ ### `@spunto/design-system/projects`
172
+
173
+ - **`ProjectForm`** — the whole "compose a dev environment" screen: identity,
174
+ base image, repos, features, extensions, lifecycle, ports, prewarm images,
175
+ docker-in-docker, secrets, plus the live **build manifest** that recaps it and
176
+ carries the submit button.
177
+ - **Controlled by one value.** A single `ProjectFormValue` in, one `onChange`
178
+ out, `onSubmit(value)` at the end. Start from `emptyProjectFormValue()`, or
179
+ `toProjectFormValue(partial)` to fill the gaps of an existing project. The
180
+ value is *always* readable — which is what makes validation, drafts and
181
+ previews possible at all.
182
+ - **Which sections show is a prop** (`CREATE_SECTIONS` / `EDIT_SECTIONS` / your
183
+ own list, in your own order). Creation and editing are the same component with
184
+ a different list, not two screens that drift apart.
185
+ - **Everything past identity / base image / repos folds under "Advanced
186
+ options"** (`advancedSections`, `[]` to disable). Two rules keep that honest:
187
+ the closed fold *writes out what it holds* ("2 features · lifecycle · 3
188
+ secrets") instead of showing a bare chevron, and the form opens it by itself
189
+ when any of those sections already has a value — so editing a project never
190
+ hides a setting you made earlier.
191
+ - **Nothing fetches.** Catalogs come in as data, the marketplace search is
192
+ `onSearchExtensions`, the GitHub combobox is `renderRepoField` (without it: a
193
+ plain `<input list>` + `<datalist>`, no dependency, keyboard-friendly), and
194
+ `extras` slips app-specific content *inside* a section — the deploy key card,
195
+ a warning — without forking the component.
196
+ - **Its bricks, exported individually**: `FormSection`, `Field`,
197
+ `BuildManifest`, `RepoList` (+`deriveFromGitUrl`), `SecretList`,
198
+ `ImagePicker`/`FeaturePicker`/`ExtensionPicker`, and the pure `manifestRows`.
199
+ - **Responsive by container query, not viewport.** The form is a full page in
200
+ one app and a 700 px panel in another; it reads its own width to decide
201
+ whether the manifest sits beside it and whether the catalogs are one column or
202
+ two.
203
+
122
204
  ## Toasts — `SpuntoProvider` + `toast()`
123
205
 
124
206
  `SpuntoProvider` is the design system's client umbrella provider. Mount it once
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,14 @@
37
37
  "types": "./src/components/workers/index.ts",
38
38
  "import": "./src/components/workers/index.ts"
39
39
  },
40
+ "./devcontainer": {
41
+ "types": "./src/components/devcontainer/index.ts",
42
+ "import": "./src/components/devcontainer/index.ts"
43
+ },
44
+ "./projects": {
45
+ "types": "./src/components/projects/index.ts",
46
+ "import": "./src/components/projects/index.ts"
47
+ },
40
48
  "./fonts": {
41
49
  "types": "./src/components/fonts.tsx",
42
50
  "import": "./src/components/fonts.tsx"
@@ -0,0 +1,269 @@
1
+ "use client"
2
+
3
+ import { useState, type ReactNode } from "react"
4
+ import { CheckIcon } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+ import { monogram, PublisherAvatar } from "./publisher-badge"
8
+ import { StackMark, type StackMarkName } from "./stack-mark"
9
+ import type { CatalogPublisher } from "./types"
10
+
11
+ /** Warm accents the catalogs use — one per pillar, plus the brand flame. */
12
+ export type CatalogTone = "build" | "ship" | "run" | "flame"
13
+
14
+ const TONE_TILE: Record<CatalogTone, string> = {
15
+ build: "bg-build/10 text-build",
16
+ ship: "bg-ship/10 text-ship",
17
+ run: "bg-run/10 text-run",
18
+ flame: "bg-primary/10 text-primary",
19
+ }
20
+
21
+ const TONE_WATERMARK: Record<CatalogTone, string> = {
22
+ build: "text-build",
23
+ ship: "text-ship",
24
+ run: "text-run",
25
+ flame: "text-primary",
26
+ }
27
+
28
+ export interface CatalogMarkProps {
29
+ /** A real logo (an extension icon, say). Beats every derived mark. */
30
+ iconUrl?: string | null
31
+ /** Embedded brand mark, when one could be derived. */
32
+ mark?: StackMarkName | string | null
33
+ /** Used as the mark when no brand matched — an author's avatar still says something. */
34
+ publisher?: CatalogPublisher
35
+ /** Last resort: initials. */
36
+ label?: string
37
+ tone?: CatalogTone
38
+ compact?: boolean
39
+ className?: string
40
+ }
41
+
42
+ /**
43
+ * The tile at the left of every catalog card, resolved by a single cascade —
44
+ * **real icon → brand mark → publisher avatar → monogram**. Each catalog has a
45
+ * different amount of truth available (Open VSX ships an icon, ghcr.io only
46
+ * implies a GitHub org, a hand-typed ref implies nothing), and this cascade is
47
+ * what makes all three grids look like one product anyway.
48
+ */
49
+ export function CatalogMark({
50
+ iconUrl,
51
+ mark,
52
+ publisher,
53
+ label,
54
+ tone = "build",
55
+ compact = false,
56
+ className,
57
+ }: CatalogMarkProps) {
58
+ const [broken, setBroken] = useState(false)
59
+ const box = compact ? "h-5 w-5 rounded" : "h-9 w-9 rounded-md"
60
+
61
+ if (iconUrl && !broken) {
62
+ return (
63
+ <img
64
+ src={iconUrl}
65
+ alt=""
66
+ loading="lazy"
67
+ onError={() => setBroken(true)}
68
+ className={cn(box, "shrink-0 bg-muted object-contain ring-1 ring-border/60", className)}
69
+ />
70
+ )
71
+ }
72
+
73
+ if (mark) {
74
+ return (
75
+ <span className={cn(box, "flex shrink-0 items-center justify-center", TONE_TILE[tone], className)}>
76
+ <StackMark name={mark} className={compact ? "h-3 w-3" : "h-5 w-5"} />
77
+ </span>
78
+ )
79
+ }
80
+
81
+ if (publisher?.avatarUrl) {
82
+ return <PublisherAvatar publisher={publisher} fallbackLabel={label} className={cn(box, "rounded-md", className)} />
83
+ }
84
+
85
+ return (
86
+ <span
87
+ aria-hidden
88
+ className={cn(
89
+ box,
90
+ "flex shrink-0 items-center justify-center font-semibold",
91
+ compact ? "text-[8px]" : "text-[11px]",
92
+ TONE_TILE[tone],
93
+ className
94
+ )}
95
+ >
96
+ {monogram(label || publisher?.name || publisher?.login)}
97
+ </span>
98
+ )
99
+ }
100
+
101
+ export interface CatalogCardProps {
102
+ /** The tile — usually a `CatalogMark`. */
103
+ mark?: ReactNode
104
+ title: ReactNode
105
+ /** Same line as the title, right after it: a version, a tag. */
106
+ titleMeta?: ReactNode
107
+ /** The publisher line, under the title. */
108
+ meta?: ReactNode
109
+ description?: ReactNode
110
+ /** Chips, refs — anything under the description. */
111
+ children?: ReactNode
112
+
113
+ selected?: boolean
114
+ /**
115
+ * Provided → the card is selectable (a stretched button covers it, and the
116
+ * flame check + ring appear when `selected`). Absent → a plain read-only
117
+ * card, which is exactly what the project sheet needs.
118
+ */
119
+ onSelect?: () => void
120
+ disabled?: boolean
121
+
122
+ /** One-line, denser rendering: no description, no watermark. */
123
+ compact?: boolean
124
+ /** Nothing was derivable (a hand-typed ref) → dashed border. */
125
+ custom?: boolean
126
+
127
+ /** Trailing control (a remove ✕). Sits above the selection overlay. */
128
+ action?: ReactNode
129
+ /** Brand mark bled large and faint into the background. */
130
+ watermark?: StackMarkName | string | null
131
+ tone?: CatalogTone
132
+ /** Accessible name of the selection button when the title isn't a string. */
133
+ selectLabel?: string
134
+ className?: string
135
+ }
136
+
137
+ /**
138
+ * The shell all three catalog cards share: tile, title, meta, description,
139
+ * extras — plus the selected state (flame check + ring) that the three pickers
140
+ * each used to re-implement in a `<button>` of their own.
141
+ *
142
+ * **Why the selection is a stretched overlay button rather than a `<button>`
143
+ * wrapping everything:** these cards carry controls of their own (copy the ref,
144
+ * remove a custom entry), and a button inside a button is invalid HTML —
145
+ * browsers unnest it and the inner control stops working. So the card is a
146
+ * `<div>`, selection is one `absolute inset-0` button covering it, and inner
147
+ * controls sit above that overlay on `z-10`. The card's own text is left
148
+ * *static* on purpose: static content paints below positioned siblings, so the
149
+ * overlay covers it and a click anywhere on the card still selects it.
150
+ */
151
+ export function CatalogCard({
152
+ mark,
153
+ title,
154
+ titleMeta,
155
+ meta,
156
+ description,
157
+ children,
158
+ selected = false,
159
+ onSelect,
160
+ disabled = false,
161
+ compact = false,
162
+ custom = false,
163
+ action,
164
+ watermark,
165
+ tone = "build",
166
+ selectLabel,
167
+ className,
168
+ }: CatalogCardProps) {
169
+ const selectable = Boolean(onSelect)
170
+
171
+ return (
172
+ <div
173
+ data-slot="catalog-card"
174
+ data-selected={selected || undefined}
175
+ // `isolate`: the watermark sits on a negative z-index so it stays behind
176
+ // the text, and without a stacking context here it would slide behind the
177
+ // card's own background instead.
178
+ className={cn(
179
+ "group relative isolate flex min-w-0 items-start gap-3 overflow-hidden border bg-card text-left transition-colors",
180
+ compact ? "gap-2 rounded-md px-2 py-1.5" : "rounded-lg px-3 py-2.5",
181
+ custom ? "border-dashed border-border" : "border-border",
182
+ selected && "border-primary bg-primary/5 ring-1 ring-primary",
183
+ selectable && !selected && !disabled && "hover:border-muted-foreground/40 hover:bg-muted/50",
184
+ disabled && "opacity-50",
185
+ className
186
+ )}
187
+ >
188
+ {/* The runtime's mark, bled large into the corner and almost invisible.
189
+ It's texture, not information — it's what lets you tell a Python card
190
+ from a Go one out of the corner of your eye, in a grid where every
191
+ card is otherwise the same three lines of text. */}
192
+ {watermark && !compact && (
193
+ <span
194
+ aria-hidden
195
+ className={cn(
196
+ "pointer-events-none absolute -top-5 -right-6 -z-10 transition-opacity",
197
+ selected ? "opacity-[0.08]" : "opacity-[0.04] group-hover:opacity-[0.06]",
198
+ TONE_WATERMARK[tone]
199
+ )}
200
+ >
201
+ <StackMark name={watermark} className="h-28 w-28" />
202
+ </span>
203
+ )}
204
+
205
+ {selectable && (
206
+ <button
207
+ type="button"
208
+ onClick={onSelect}
209
+ disabled={disabled}
210
+ aria-pressed={selected}
211
+ aria-label={selectLabel ?? (typeof title === "string" ? title : undefined)}
212
+ className={cn(
213
+ "absolute inset-0 cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed",
214
+ compact ? "rounded-md" : "rounded-lg"
215
+ )}
216
+ />
217
+ )}
218
+
219
+ {mark && <span className="mt-px">{mark}</span>}
220
+
221
+ <div className="min-w-0 flex-1 space-y-1">
222
+ <div className="flex min-w-0 items-center gap-2">
223
+ <span className={cn("min-w-0 truncate font-medium leading-tight", compact ? "text-xs" : "text-sm")}>
224
+ {title}
225
+ </span>
226
+ {titleMeta}
227
+ </div>
228
+
229
+ {meta && <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">{meta}</div>}
230
+
231
+ {description && !compact && <p className="line-clamp-2 text-xs text-muted-foreground">{description}</p>}
232
+
233
+ {children}
234
+ </div>
235
+
236
+ {/* The check sits in the flow rather than pinned absolutely: at 288 px a
237
+ title wrapping onto two lines used to slide under a pinned check. */}
238
+ {(selected || action) && (
239
+ <span className="relative z-10 flex shrink-0 items-center gap-1 self-start pt-px">
240
+ {selected && <CheckIcon className={cn("text-primary", compact ? "h-3.5 w-3.5" : "h-4 w-4")} aria-hidden />}
241
+ {action}
242
+ </span>
243
+ )}
244
+ </div>
245
+ )
246
+ }
247
+
248
+ /** Chip for a feature option, an extension version, a download count. */
249
+ export function CatalogChip({
250
+ children,
251
+ title,
252
+ className,
253
+ }: {
254
+ children: ReactNode
255
+ title?: string
256
+ className?: string
257
+ }) {
258
+ return (
259
+ <span
260
+ title={title}
261
+ className={cn(
262
+ "inline-flex max-w-full items-center gap-1 truncate rounded border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-[10px] leading-none text-muted-foreground",
263
+ className
264
+ )}
265
+ >
266
+ {children}
267
+ </span>
268
+ )
269
+ }
@@ -0,0 +1,133 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { CheckIcon } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+ import type { CatalogTone } from "./catalog-card"
8
+
9
+ const TONE = {
10
+ build: "text-build",
11
+ ship: "text-ship",
12
+ run: "text-run",
13
+ flame: "text-primary",
14
+ } as const satisfies Record<CatalogTone, string>
15
+
16
+ export interface CatalogTileProps {
17
+ /** The glyph — a brand mark, an avatar, a `+`. Sized by the tile. */
18
+ mark: ReactNode
19
+ label: string
20
+ /** Trailing detail: an image tag, a version. Dropped in `tile` when it doesn't fit. */
21
+ meta?: string
22
+ /** Full text for the native tooltip — the label truncates. */
23
+ title?: string
24
+
25
+ selected?: boolean
26
+ onSelect?: () => void
27
+ disabled?: boolean
28
+ custom?: boolean
29
+ variant?: "chip" | "tile"
30
+ tone?: CatalogTone
31
+ className?: string
32
+ }
33
+
34
+ /**
35
+ * The shell behind `ImageTile` and `FeatureTile`: a catalog entry reduced to a
36
+ * glyph, a name and one trailing detail.
37
+ *
38
+ * Extracted rather than copied, because the two only differ in what they *put*
39
+ * in it — an image derives a runtime mark and a tag, a feature derives its
40
+ * author's avatar — and this package exists to stop that kind of near-duplicate
41
+ * from drifting apart.
42
+ *
43
+ * `chip` flows and wraps like a tag; `tile` is a square-ish grid cell. Both
44
+ * carry the same flame check as the full cards, because a chip grid and a card
45
+ * grid are the same choice and "this one is picked" has to look the same.
46
+ */
47
+ export function CatalogTile({
48
+ mark,
49
+ label,
50
+ meta,
51
+ title,
52
+ selected = false,
53
+ onSelect,
54
+ disabled = false,
55
+ custom = false,
56
+ variant = "chip",
57
+ tone = "ship",
58
+ className,
59
+ }: CatalogTileProps) {
60
+ const Root = onSelect ? "button" : "div"
61
+
62
+ const common = {
63
+ "data-slot": "catalog-tile",
64
+ "data-selected": selected || undefined,
65
+ "data-variant": variant,
66
+ title: title ?? label,
67
+ ...(onSelect
68
+ ? { type: "button" as const, onClick: onSelect, disabled, "aria-pressed": selected, "aria-label": label }
69
+ : {}),
70
+ }
71
+
72
+ const state = cn(
73
+ custom ? "border-dashed border-border" : "border-border",
74
+ selected ? "border-primary bg-primary/5 ring-1 ring-primary" : "bg-card",
75
+ onSelect && !disabled && "cursor-pointer",
76
+ onSelect && !selected && !disabled && "hover:border-muted-foreground/40 hover:bg-muted/50",
77
+ disabled && "cursor-not-allowed opacity-50"
78
+ )
79
+
80
+ const markClass = cn(
81
+ "flex shrink-0 items-center justify-center transition-colors",
82
+ selected ? TONE[tone] : "text-muted-foreground group-hover:text-foreground"
83
+ )
84
+
85
+ if (variant === "chip") {
86
+ return (
87
+ <Root
88
+ {...common}
89
+ className={cn(
90
+ // `inline-flex` + the parent's `flex-wrap`: entries flow and break
91
+ // onto the next line like text, so a catalog of any length just takes
92
+ // the rows it needs — no column count to choose, no ragged grid.
93
+ "group inline-flex max-w-full min-w-0 items-center gap-2 rounded-md border py-1.5 pr-2.5 pl-2 text-left transition-colors",
94
+ state,
95
+ className
96
+ )}
97
+ >
98
+ <span className={cn(markClass, "h-5 w-5")}>{mark}</span>
99
+ <span className="min-w-0 truncate text-xs font-medium">{label}</span>
100
+ {meta && <span className="shrink-0 font-mono text-[10px] leading-none text-muted-foreground/60">{meta}</span>}
101
+ {selected && <CheckIcon className="h-3.5 w-3.5 shrink-0 text-primary" aria-hidden />}
102
+ </Root>
103
+ )
104
+ }
105
+
106
+ return (
107
+ <Root
108
+ {...common}
109
+ className={cn(
110
+ // Square-ISH, via a min-height rather than `aspect-square`: a strict
111
+ // square clipped "Node.js 20 (TypeScript)" the moment it wrapped onto a
112
+ // second line. Grid rows stretch their cells to the tallest anyway, so
113
+ // the row stays even without forcing the ratio.
114
+ "group relative flex min-h-[5.5rem] min-w-0 flex-col items-center justify-center gap-1.5 overflow-hidden rounded-lg border px-2 py-2.5 text-center transition-colors",
115
+ state,
116
+ className
117
+ )}
118
+ >
119
+ {selected && <CheckIcon className="absolute top-1.5 right-1.5 h-3.5 w-3.5 text-primary" aria-hidden />}
120
+
121
+ <span className={cn(markClass, "h-8 w-8")}>{mark}</span>
122
+
123
+ {/* Two lines then ellipsis: "Node.js 20 (TypeScript)" needs both, and a
124
+ single truncated line would cut every entry of a same runtime to the
125
+ identical, useless "Node.js 20 (Type…". */}
126
+ <span className="line-clamp-2 w-full text-[11px] leading-tight font-medium break-words">{label}</span>
127
+
128
+ {meta && (
129
+ <span className="max-w-full truncate font-mono text-[9px] leading-none text-muted-foreground/70">{meta}</span>
130
+ )}
131
+ </Root>
132
+ )
133
+ }
@@ -0,0 +1,146 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { DownloadIcon, StarIcon } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+ import { CatalogCard, CatalogMark, type CatalogTone } from "./catalog-card"
8
+ import { PublisherBadge } from "./publisher-badge"
9
+ import { extensionId, formatCount, parseExtensionId } from "./registry"
10
+ import { resolveStackMark } from "./stack-mark"
11
+ import type { VscodeExtensionEntry } from "./types"
12
+
13
+ export interface ExtensionCardProps {
14
+ extension: VscodeExtensionEntry
15
+ selected?: boolean
16
+ onSelect?: () => void
17
+ disabled?: boolean
18
+ compact?: boolean
19
+ /** An id typed by hand: no icon, no publisher, no counts to show. */
20
+ custom?: boolean
21
+ action?: ReactNode
22
+ tone?: CatalogTone
23
+ className?: string
24
+ }
25
+
26
+ /**
27
+ * A VS Code extension, as a card: its real Open VSX icon, its display name, the
28
+ * publisher (with the marketplace's verified check), version, install count and
29
+ * rating.
30
+ *
31
+ * Open VSX already returns every one of those on `/api/-/search` — the picker
32
+ * used to fetch them and throw them away, rendering `displayName` + `id`. Wiring
33
+ * them in is what turns a list of identifiers into a catalogue you can choose
34
+ * from.
35
+ */
36
+ export function ExtensionCard({
37
+ extension,
38
+ selected = false,
39
+ onSelect,
40
+ disabled = false,
41
+ compact = false,
42
+ custom = false,
43
+ action,
44
+ tone = "flame",
45
+ className,
46
+ }: ExtensionCardProps) {
47
+ const id = extensionId(extension)
48
+ // A project only ever stores the id, so a card drawn from the database has to
49
+ // split it back apart to have anything to say at all.
50
+ const fromId = parseExtensionId(id)
51
+ const namespace = extension.namespace ?? fromId.namespace
52
+ const name = extension.name ?? fromId.name
53
+ const title = extension.displayName || name || id || "Extension"
54
+ const iconUrl = custom ? undefined : (extension.iconUrl ?? extension.files?.icon ?? undefined)
55
+ // Only ever a *fallback* mark: an extension's own icon is always better than
56
+ // a guess made from its id, so this is what fills the tile when Open VSX has
57
+ // no icon (or the card is drawn from a stored id alone).
58
+ const mark = custom ? undefined : resolveStackMark(name, extension.displayName)
59
+ const publisher =
60
+ extension.publisher ??
61
+ (namespace ? { name: namespace, login: namespace, verified: extension.verified } : undefined)
62
+
63
+ const downloads = formatCount(extension.downloadCount)
64
+ const rating = extension.averageRating != null ? Math.round(extension.averageRating * 10) / 10 : undefined
65
+
66
+ return (
67
+ <CatalogCard
68
+ className={className}
69
+ compact={compact}
70
+ custom={custom}
71
+ selected={selected}
72
+ onSelect={onSelect}
73
+ disabled={disabled}
74
+ action={action}
75
+ tone={tone}
76
+ selectLabel={typeof title === "string" ? title : undefined}
77
+ mark={
78
+ <CatalogMark iconUrl={iconUrl} mark={mark} publisher={publisher} label={title} tone={tone} compact={compact} />
79
+ }
80
+ title={title}
81
+ titleMeta={
82
+ extension.version && !compact ? (
83
+ <span className="shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] leading-none text-muted-foreground">
84
+ v{extension.version}
85
+ </span>
86
+ ) : undefined
87
+ }
88
+ meta={
89
+ !compact ? (
90
+ <>
91
+ {publisher && <PublisherBadge publisher={publisher} showAvatar={false} />}
92
+ {downloads && (
93
+ <span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/70" title="Installs">
94
+ <DownloadIcon className="h-3 w-3 shrink-0" />
95
+ {downloads}
96
+ </span>
97
+ )}
98
+ {rating != null && rating > 0 && (
99
+ <span
100
+ className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/70"
101
+ title={extension.reviewCount ? `${extension.reviewCount} reviews` : "Average rating"}
102
+ >
103
+ <StarIcon className="h-3 w-3 shrink-0 fill-current" />
104
+ {rating.toFixed(1)}
105
+ </span>
106
+ )}
107
+ </>
108
+ ) : undefined
109
+ }
110
+ description={extension.description}
111
+ >
112
+ {id && (
113
+ <span
114
+ title={id}
115
+ className={cn(
116
+ "block max-w-full truncate font-mono text-[10px] leading-none text-muted-foreground/60",
117
+ compact ? "mt-0.5" : "mt-1.5"
118
+ )}
119
+ >
120
+ {id}
121
+ </span>
122
+ )}
123
+ </CatalogCard>
124
+ )
125
+ }
126
+
127
+ /**
128
+ * Placeholder while the Open VSX search is in flight. The search used to show a
129
+ * spinner in the input and nothing else, so the dropdown appeared to pop out of
130
+ * nowhere; three of these keep the space and let you see it coming.
131
+ */
132
+ export function ExtensionCardSkeleton({ className }: { className?: string }) {
133
+ return (
134
+ <div
135
+ aria-hidden
136
+ className={cn("flex items-start gap-3 rounded-lg border border-border bg-card px-3 py-2.5", className)}
137
+ >
138
+ <span className="h-9 w-9 shrink-0 animate-pulse rounded-md bg-muted" />
139
+ <div className="min-w-0 flex-1 space-y-1.5 py-0.5">
140
+ <span className="block h-3 w-1/3 animate-pulse rounded bg-muted" />
141
+ <span className="block h-2.5 w-1/4 animate-pulse rounded bg-muted/70" />
142
+ <span className="block h-2.5 w-3/4 animate-pulse rounded bg-muted/70" />
143
+ </div>
144
+ </div>
145
+ )
146
+ }