@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,488 @@
1
+ "use client"
2
+
3
+ import { useState, type ComponentType, type ReactNode } from "react"
4
+ import {
5
+ BlocksIcon,
6
+ BoxIcon,
7
+ GitBranchIcon,
8
+ KeyRoundIcon,
9
+ LayersIcon,
10
+ NetworkIcon,
11
+ PuzzleIcon,
12
+ SquarePenIcon,
13
+ TerminalIcon,
14
+ } from "lucide-react"
15
+
16
+ import { cn } from "../../utils"
17
+ import { Input } from "../input"
18
+ import { Switch } from "../switch"
19
+ import { Textarea } from "../textarea"
20
+ import { ExtensionPicker } from "./extension-picker"
21
+ import { FeaturePicker, type FeaturePickerProps } from "./feature-picker"
22
+ import { ImagePicker, type ImagePickerProps } from "./image-picker"
23
+ import { AdvancedDisclosure, advancedSummary } from "./advanced-sections"
24
+ import { BuildManifest, type ManifestRow } from "./build-manifest"
25
+ import { Field, FormSection, type SectionAccent } from "./form-section"
26
+ import { RepoList, type GithubConnection, type GithubRepoOption, type RepoFieldRender } from "./repo-list"
27
+ import { SecretList } from "./secret-list"
28
+ import {
29
+ CREATE_SECTIONS,
30
+ type ProjectFormSectionId,
31
+ type ProjectFormValue,
32
+ type ProjectSecretRef,
33
+ } from "./types"
34
+ import type { DevcontainerFeatureEntry, DevcontainerImageEntry, VscodeExtensionEntry } from "../devcontainer/types"
35
+
36
+ /** Chrome of each section: number, icon, accent, wording. */
37
+ const SECTION_META: Record<
38
+ ProjectFormSectionId,
39
+ { icon: ComponentType<{ className?: string }>; accent: SectionAccent; title: string; hint?: string }
40
+ > = {
41
+ identity: { icon: SquarePenIcon, accent: "build", title: "Identity", hint: "Name your project and what it's for" },
42
+ image: { icon: BoxIcon, accent: "ship", title: "Base image", hint: "The devcontainer your workers run on" },
43
+ repositories: { icon: GitBranchIcon, accent: "run", title: "Repositories", hint: "Cloned into /workspace at startup" },
44
+ features: { icon: PuzzleIcon, accent: "flame", title: "Features", hint: "Devcontainer features baked into the image" },
45
+ extensions: { icon: BlocksIcon, accent: "build", title: "Extensions", hint: "VS Code extensions, installed via code-server" },
46
+ lifecycle: { icon: TerminalIcon, accent: "ship", title: "Lifecycle", hint: "Commands run on create & on every start" },
47
+ ports: { icon: NetworkIcon, accent: "run", title: "Ports", hint: "Automatically forwarded for every worker" },
48
+ prewarm: { icon: LayersIcon, accent: "build", title: "Prewarm images", hint: "Docker images pre-loaded into the worker's docker-in-docker on first start" },
49
+ docker: { icon: BoxIcon, accent: "build", title: "Docker-in-Docker", hint: "Run workers privileged with a Docker daemon inside" },
50
+ secrets: { icon: KeyRoundIcon, accent: "flame", title: "Secrets", hint: "Env vars injected into every workspace" },
51
+ }
52
+
53
+ /**
54
+ * Folded by default: everything past identity / base image / repositories.
55
+ * Those three decide what a worker *is*; the rest is tuning, and putting ten
56
+ * sections in front of someone creating their first project is how a form stops
57
+ * being filled in at all.
58
+ */
59
+ const DEFAULT_ADVANCED: ProjectFormSectionId[] = [
60
+ "features",
61
+ "extensions",
62
+ "lifecycle",
63
+ "ports",
64
+ "prewarm",
65
+ "docker",
66
+ "secrets",
67
+ ]
68
+
69
+ export interface ProjectFormProps {
70
+ value: ProjectFormValue
71
+ onChange: (value: ProjectFormValue) => void
72
+ /** Receives the current value. The app maps it to its own payload and calls its API. */
73
+ onSubmit?: (value: ProjectFormValue) => void
74
+
75
+ /** Which sections to show, in order. Defaults to the seven creation ones. */
76
+ sections?: ProjectFormSectionId[]
77
+ /**
78
+ * Sections folded behind "Advanced options". Defaults to everything past
79
+ * identity / base image / repositories — the three that actually decide what
80
+ * a worker is. Pass `[]` to show the whole form flat.
81
+ *
82
+ * Safe to fold *because* the disclosure names what it holds while closed and
83
+ * the form opens it on its own when any of these already has a value: editing
84
+ * a project never hides settings you set earlier behind a shut drawer.
85
+ */
86
+ advancedSections?: ProjectFormSectionId[]
87
+ advancedLabel?: string
88
+ advancedHint?: string
89
+ /** Forces the initial state. Omitted → open iff an advanced section has a value. */
90
+ defaultAdvancedOpen?: boolean
91
+
92
+ /** Catalogs, already fetched by the app — the form never calls an API. */
93
+ images?: DevcontainerImageEntry[]
94
+ features?: DevcontainerFeatureEntry[]
95
+ /**
96
+ * How the image catalog is laid out. Passed straight through, because the
97
+ * right answer depends on the page around the form: a screen that already
98
+ * spends 360 px on the manifest leaves the picker too narrow for the detail
99
+ * card to sit beside the chips, and `auto` will stack it. Force `side` if you
100
+ * want the panel there regardless.
101
+ */
102
+ imagePicker?: Pick<ImagePickerProps, "layout" | "detail">
103
+ /** Same passthrough for the feature catalog. */
104
+ featurePicker?: Pick<FeaturePickerProps, "layout" | "detail">
105
+
106
+ githubRepos?: GithubRepoOption[]
107
+ github?: GithubConnection
108
+ connectUrl?: string
109
+ /** App-level combobox for the repo field; without it, a plain input + datalist. */
110
+ renderRepoField?: RepoFieldRender
111
+ githubIcon?: ReactNode
112
+
113
+ /** Searches Open VSX (or any marketplace). Absent → extensions are typed by hand. */
114
+ onSearchExtensions?: (query: string) => Promise<VscodeExtensionEntry[]>
115
+
116
+ /** Secrets that already exist server-side (edit mode). */
117
+ savedSecrets?: ProjectSecretRef[]
118
+
119
+ /** Extra content appended inside a section — the deploy key card, a warning… */
120
+ extras?: Partial<Record<ProjectFormSectionId, ReactNode>>
121
+
122
+ /** `false` drops the recap panel entirely and gives the form the full width. */
123
+ manifest?: boolean
124
+ submitLabel?: ReactNode
125
+ submittingLabel?: ReactNode
126
+ submitting?: boolean
127
+ error?: string | null
128
+ manifestCaption?: ReactNode
129
+ manifestFooter?: ReactNode
130
+ manifestTitle?: string
131
+
132
+ className?: string
133
+ }
134
+
135
+ /**
136
+ * The whole "compose a dev environment" form: identity, base image, repos,
137
+ * features, extensions, lifecycle, ports, prewarm, docker-in-docker, secrets —
138
+ * plus the live build manifest that recaps it and carries the submit button.
139
+ *
140
+ * **Controlled, single value.** One `ProjectFormValue` in, one `onChange` out.
141
+ * The dashboard used to spread this across seven pieces of local state, three
142
+ * hidden inputs carrying JSON, and a `FormData` read at submit — which meant the
143
+ * value was only knowable *at* submit, so no validation, no draft, no preview.
144
+ * Here the value is always readable, and the hidden-input trick disappears.
145
+ *
146
+ * **Nothing in here fetches.** Catalogs come in as data, the extension search is
147
+ * a callback, the GitHub combobox is a slot, and `onSubmit` hands the value back
148
+ * for the app to send. Which sections are shown is a prop, so creation and
149
+ * editing are the same component with a different list — not two screens that
150
+ * drift apart.
151
+ */
152
+ export function ProjectForm({
153
+ value,
154
+ onChange,
155
+ onSubmit,
156
+ sections = CREATE_SECTIONS,
157
+ advancedSections = DEFAULT_ADVANCED,
158
+ advancedLabel,
159
+ advancedHint,
160
+ defaultAdvancedOpen,
161
+ images = [],
162
+ features = [],
163
+ imagePicker,
164
+ featurePicker,
165
+ githubRepos,
166
+ github,
167
+ connectUrl,
168
+ renderRepoField,
169
+ githubIcon,
170
+ onSearchExtensions,
171
+ savedSecrets,
172
+ extras,
173
+ manifest = true,
174
+ submitLabel = "Save",
175
+ submittingLabel = "Saving…",
176
+ submitting = false,
177
+ error,
178
+ manifestCaption,
179
+ manifestFooter,
180
+ manifestTitle,
181
+ className,
182
+ }: ProjectFormProps) {
183
+ const patch = (next: Partial<ProjectFormValue>) => onChange({ ...value, ...next })
184
+
185
+ const rows = manifestRows(value, sections)
186
+
187
+ const advanced = sections.filter((id) => advancedSections.includes(id))
188
+ const primary = sections.filter((id) => !advancedSections.includes(id))
189
+ const summary = advancedSummary(value, advanced)
190
+ // Start open when there's already something in there — the initial value is
191
+ // read once, on mount, so a later edit can't yank the fold open under the
192
+ // user's cursor.
193
+ const [advancedOpen, setAdvancedOpen] = useState(() => defaultAdvancedOpen ?? summary.length > 0)
194
+
195
+ return (
196
+ // The container is this wrapper, not the form: a container query styles a
197
+ // container's DESCENDANTS, never the element that declares it — put both on
198
+ // one node and the query silently never matches.
199
+ //
200
+ // And it's a container query rather than `lg:` because the form is a full
201
+ // page in the dashboard and a ~700 px panel in the showcase. Keyed on the
202
+ // viewport, it split into two columns in both, squeezing the catalog cards
203
+ // to 130 px in the narrow one. Now it reads its own width.
204
+ <div className={cn("@container/form", className)}>
205
+ <form
206
+ data-slot="project-form"
207
+ onSubmit={(e) => {
208
+ e.preventDefault()
209
+ onSubmit?.(value)
210
+ }}
211
+ className={cn(
212
+ manifest
213
+ ? "grid gap-6 @min-[60rem]/form:grid-cols-[1fr_minmax(320px,360px)] @min-[60rem]/form:items-start"
214
+ : "block"
215
+ )}
216
+ >
217
+ <div className="space-y-4">
218
+ {primary.map((id, index) => section(id, index))}
219
+
220
+ {advanced.length > 0 && (
221
+ <AdvancedDisclosure
222
+ open={advancedOpen}
223
+ onOpenChange={setAdvancedOpen}
224
+ label={advancedLabel}
225
+ hint={advancedHint}
226
+ summary={summary}
227
+ >
228
+ {advanced.map((id, index) => section(id, primary.length + index))}
229
+ </AdvancedDisclosure>
230
+ )}
231
+ </div>
232
+
233
+ {manifest && (
234
+ <div className="@min-[60rem]/form:sticky @min-[60rem]/form:top-20">
235
+ <BuildManifest
236
+ rows={rows}
237
+ submitLabel={submitLabel}
238
+ submittingLabel={submittingLabel}
239
+ submitting={submitting}
240
+ disabled={!value.name.trim()}
241
+ error={error}
242
+ caption={manifestCaption}
243
+ footer={manifestFooter}
244
+ title={manifestTitle}
245
+ />
246
+ </div>
247
+ )}
248
+ </form>
249
+ </div>
250
+ )
251
+
252
+ /** One section, numbered by its position in the whole form (folded or not). */
253
+ function section(id: ProjectFormSectionId, index: number) {
254
+ const meta = SECTION_META[id]
255
+ return (
256
+ <FormSection
257
+ key={id}
258
+ n={index + 1}
259
+ icon={meta.icon}
260
+ accent={meta.accent}
261
+ title={meta.title}
262
+ hint={meta.hint}
263
+ // The stagger is an inline delay rather than ten Tailwind variants;
264
+ // capped, or a long form's last section would arrive noticeably after
265
+ // you've already scrolled to it.
266
+ style={{ animationDelay: `${Math.min(index, 8) * 40}ms` }}
267
+ >
268
+ {renderSection(id)}
269
+ {extras?.[id]}
270
+ </FormSection>
271
+ )
272
+ }
273
+
274
+ function renderSection(id: ProjectFormSectionId): ReactNode {
275
+ switch (id) {
276
+ case "identity":
277
+ return (
278
+ <div className="space-y-4">
279
+ <Field label="Name" htmlFor="project-name">
280
+ <Input
281
+ id="project-name"
282
+ name="name"
283
+ placeholder="my-project"
284
+ required
285
+ value={value.name}
286
+ onChange={(e) => patch({ name: e.target.value })}
287
+ />
288
+ </Field>
289
+ <Field label="Description" htmlFor="project-description">
290
+ <Textarea
291
+ id="project-description"
292
+ name="description"
293
+ rows={2}
294
+ placeholder="What is this project about?"
295
+ value={value.description}
296
+ onChange={(e) => patch({ description: e.target.value })}
297
+ />
298
+ </Field>
299
+ </div>
300
+ )
301
+
302
+ case "image":
303
+ return (
304
+ <ImagePicker
305
+ images={images}
306
+ value={value.image}
307
+ onChange={(image) => patch({ image })}
308
+ layout={imagePicker?.layout}
309
+ detail={imagePicker?.detail}
310
+ />
311
+ )
312
+
313
+ case "repositories":
314
+ return (
315
+ <RepoList
316
+ repos={value.repositories}
317
+ onChange={(repositories) => patch({ repositories })}
318
+ githubRepos={githubRepos}
319
+ github={github}
320
+ connectUrl={connectUrl}
321
+ renderRepoField={renderRepoField}
322
+ githubIcon={githubIcon}
323
+ />
324
+ )
325
+
326
+ case "features":
327
+ return (
328
+ <FeaturePicker
329
+ features={features}
330
+ value={value.features}
331
+ onChange={(next) => patch({ features: next })}
332
+ layout={featurePicker?.layout}
333
+ detail={featurePicker?.detail}
334
+ />
335
+ )
336
+
337
+ case "extensions":
338
+ return (
339
+ <ExtensionPicker
340
+ value={value.vscodeExtensions}
341
+ onChange={(vscodeExtensions) => patch({ vscodeExtensions })}
342
+ onSearch={onSearchExtensions}
343
+ />
344
+ )
345
+
346
+ case "lifecycle":
347
+ return (
348
+ <div className="space-y-4">
349
+ <Field label="Post-create command" htmlFor="post-create" hint="Runs once after container creation">
350
+ <Input
351
+ id="post-create"
352
+ name="postCreateCommand"
353
+ placeholder="npm install"
354
+ className="font-mono text-xs"
355
+ value={value.postCreateCommand}
356
+ onChange={(e) => patch({ postCreateCommand: e.target.value })}
357
+ />
358
+ </Field>
359
+ <Field label="Post-start command" htmlFor="post-start" hint="Runs on every container start">
360
+ <Input
361
+ id="post-start"
362
+ name="postStartCommand"
363
+ placeholder="npm run dev"
364
+ className="font-mono text-xs"
365
+ value={value.postStartCommand}
366
+ onChange={(e) => patch({ postStartCommand: e.target.value })}
367
+ />
368
+ </Field>
369
+ </div>
370
+ )
371
+
372
+ case "ports":
373
+ return (
374
+ <Field hint="Comma-separated port numbers">
375
+ <Input
376
+ name="forwardPorts"
377
+ placeholder="3000, 8080"
378
+ // Kept as text while typing: parsing on every keystroke would
379
+ // delete the comma the moment you type it.
380
+ value={value.forwardPorts.join(", ")}
381
+ onChange={(e) => patch({ forwardPorts: parsePorts(e.target.value) })}
382
+ />
383
+ </Field>
384
+ )
385
+
386
+ case "prewarm":
387
+ return (
388
+ <Field
389
+ hint={
390
+ <>
391
+ One image per line. Baked into the prebuilt image and <code>docker load</code>-ed at first
392
+ start — turns an internal <code>docker compose up</code> from minutes into seconds.
393
+ </>
394
+ }
395
+ >
396
+ <Textarea
397
+ name="prewarmImages"
398
+ rows={3}
399
+ className="font-mono text-xs"
400
+ placeholder={"traefik:v3\nnode:24\nghcr.io/org/image:tag"}
401
+ value={value.prewarmImages.join("\n")}
402
+ onChange={(e) => patch({ prewarmImages: e.target.value.split("\n").map((l) => l.trim()).filter(Boolean) })}
403
+ />
404
+ </Field>
405
+ )
406
+
407
+ case "docker":
408
+ return (
409
+ <label className="flex cursor-pointer items-start justify-between gap-4 rounded-md border bg-muted/30 px-3 py-2.5">
410
+ <span className="space-y-0.5">
411
+ <span className="block text-sm leading-none font-medium">Enable Docker-in-Docker</span>
412
+ <span className="block text-xs text-muted-foreground">
413
+ Runs each worker privileged with <code>dockerd</code> and dedicated{" "}
414
+ <code>/var/lib/docker</code> volumes. Use with a base image that bakes Docker — no{" "}
415
+ <code>docker-in-docker</code> feature needed.
416
+ </span>
417
+ </span>
418
+ <Switch
419
+ checked={value.dockerInDocker}
420
+ onCheckedChange={(checked) => patch({ dockerInDocker: checked })}
421
+ />
422
+ </label>
423
+ )
424
+
425
+ case "secrets":
426
+ return (
427
+ <SecretList
428
+ drafts={value.secrets}
429
+ onDraftsChange={(secrets) => patch({ secrets })}
430
+ saved={savedSecrets}
431
+ removedIds={value.removedSecretIds}
432
+ onRemovedIdsChange={(removedSecretIds) => patch({ removedSecretIds })}
433
+ hint="Shared across every member working on this project. Override at org or personal level anytime."
434
+ />
435
+ )
436
+ }
437
+ }
438
+ }
439
+
440
+ /** `"3000, 8080, "` → `[3000, 8080]`. Anything that isn't a number is dropped. */
441
+ function parsePorts(raw: string): number[] {
442
+ return raw
443
+ .split(",")
444
+ .map((p) => Number.parseInt(p.trim(), 10))
445
+ .filter((p) => Number.isFinite(p) && p > 0)
446
+ }
447
+
448
+ /**
449
+ * The manifest lines, derived from the value — never stored. Only the sections
450
+ * actually shown get a line: a creation form that hides ports shouldn't recap
451
+ * an empty "ports" row.
452
+ */
453
+ export function manifestRows(value: ProjectFormValue, sections: ProjectFormSectionId[]): ManifestRow[] {
454
+ const shown = new Set(sections)
455
+ const rows: ManifestRow[] = []
456
+
457
+ if (shown.has("identity")) rows.push({ label: "name", value: value.name || "untitled", done: !!value.name.trim() })
458
+ if (shown.has("image"))
459
+ rows.push({ label: "image", value: value.image ? (value.image.split("/").pop() ?? value.image) : "—", done: !!value.image })
460
+ if (shown.has("repositories")) {
461
+ const n = value.repositories.length
462
+ rows.push({ label: "sources", value: `${n} repo${n === 1 ? "" : "s"}`, done: n > 0 })
463
+ }
464
+ if (shown.has("features") || shown.has("extensions"))
465
+ rows.push({
466
+ label: "features",
467
+ value: `${value.features.length} · ${value.vscodeExtensions.length} ext`,
468
+ done: value.features.length > 0 || value.vscodeExtensions.length > 0,
469
+ })
470
+ if (shown.has("lifecycle"))
471
+ rows.push({
472
+ label: "lifecycle",
473
+ value:
474
+ [value.postCreateCommand && "create", value.postStartCommand && "start"].filter(Boolean).join(" + ") ||
475
+ "default",
476
+ done: !!(value.postCreateCommand || value.postStartCommand),
477
+ })
478
+ if (shown.has("ports"))
479
+ rows.push({ label: "ports", value: value.forwardPorts.join(", ") || "—", done: value.forwardPorts.length > 0 })
480
+ if (shown.has("prewarm"))
481
+ rows.push({ label: "prewarm", value: `${value.prewarmImages.length}`, done: value.prewarmImages.length > 0 })
482
+ if (shown.has("docker"))
483
+ rows.push({ label: "docker", value: value.dockerInDocker ? "in-docker" : "off", done: value.dockerInDocker })
484
+ if (shown.has("secrets"))
485
+ rows.push({ label: "secrets", value: `${value.secrets.length}`, done: value.secrets.length > 0 })
486
+
487
+ return rows
488
+ }