@spunto/design-system 0.9.3 → 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 (31) hide show
  1. package/README.md +102 -4
  2. package/package.json +9 -1
  3. package/src/components/command-palette.tsx +702 -0
  4. package/src/components/devcontainer/catalog-card.tsx +269 -0
  5. package/src/components/devcontainer/catalog-tile.tsx +133 -0
  6. package/src/components/devcontainer/extension-card.tsx +146 -0
  7. package/src/components/devcontainer/feature-card.tsx +157 -0
  8. package/src/components/devcontainer/feature-tile.tsx +63 -0
  9. package/src/components/devcontainer/image-card.tsx +107 -0
  10. package/src/components/devcontainer/image-tile.tsx +56 -0
  11. package/src/components/devcontainer/index.ts +68 -0
  12. package/src/components/devcontainer/publisher-badge.tsx +91 -0
  13. package/src/components/devcontainer/registry-ref.tsx +89 -0
  14. package/src/components/devcontainer/registry.ts +154 -0
  15. package/src/components/devcontainer/stack-mark-paths.ts +60 -0
  16. package/src/components/devcontainer/stack-mark.tsx +92 -0
  17. package/src/components/devcontainer/types.ts +84 -0
  18. package/src/components/kbd.tsx +117 -0
  19. package/src/components/projects/advanced-sections.tsx +136 -0
  20. package/src/components/projects/build-manifest.tsx +116 -0
  21. package/src/components/projects/extension-picker.tsx +169 -0
  22. package/src/components/projects/feature-picker.tsx +193 -0
  23. package/src/components/projects/form-section.tsx +97 -0
  24. package/src/components/projects/image-picker.tsx +206 -0
  25. package/src/components/projects/index.ts +58 -0
  26. package/src/components/projects/project-form.tsx +488 -0
  27. package/src/components/projects/repo-list.tsx +302 -0
  28. package/src/components/projects/secret-list.tsx +159 -0
  29. package/src/components/projects/types.ts +136 -0
  30. package/src/index.ts +35 -0
  31. package/styles.css +13 -0
@@ -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
+ }