@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,97 @@
1
+ import type { ComponentType, ReactNode } from "react"
2
+
3
+ import { cn } from "../../utils"
4
+ import { Label } from "../label"
5
+
6
+ /** The four warm accents; a section rotates through them down the form. */
7
+ export type SectionAccent = "build" | "ship" | "run" | "flame"
8
+
9
+ export interface FormSectionProps {
10
+ /** Step number in the badge. Omitted → no badge. */
11
+ n?: number
12
+ icon?: ComponentType<{ className?: string }>
13
+ accent?: SectionAccent
14
+ title: string
15
+ hint?: string
16
+ children: ReactNode
17
+ /** Mostly for the entrance `animationDelay` a stack of sections staggers with. */
18
+ style?: React.CSSProperties
19
+ className?: string
20
+ }
21
+
22
+ /**
23
+ * One numbered step of a long form: an accent bar, a number, a title, a hint.
24
+ *
25
+ * Exported on its own because a form this long is really a *stack of sections*,
26
+ * and an app that adds one of its own (a deploy key, a billing block) should get
27
+ * the same object rather than approximate it.
28
+ *
29
+ * The entrance animation the dashboard used to do with framer-motion is a plain
30
+ * CSS keyframe here — the package can't take a motion library as a dependency
31
+ * for one fade-in, and `motion-reduce:animate-none` covers the accessibility
32
+ * side that `useReducedMotion` covered before.
33
+ */
34
+ export function FormSection({ n, icon: Icon, accent = "build", title, hint, children, style, className }: FormSectionProps) {
35
+ return (
36
+ <section
37
+ data-slot="form-section"
38
+ // `--a` set once here, then read by the bar, the badge and the icon: the
39
+ // accent is one custom property, not four Tailwind variants to keep in sync.
40
+ style={{ "--a": `var(--${accent})`, ...style } as React.CSSProperties}
41
+ className={cn(
42
+ // `p-4` on a phone: 20px of padding each side eats a tenth of a 390px
43
+ // screen twice over. The container is the form, so this follows the
44
+ // form's width and not the window's.
45
+ "relative overflow-hidden rounded-xl border border-border bg-card p-4 @min-[30rem]/form:p-5",
46
+ "motion-safe:animate-[form-section-in_0.5s_cubic-bezier(0.22,1,0.36,1)_both]",
47
+ className
48
+ )}
49
+ >
50
+ <span aria-hidden className="absolute inset-y-0 left-0 w-0.5 bg-[var(--a)]/40" />
51
+ <div className="mb-4 flex items-center gap-3">
52
+ {n != null && (
53
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--a)]/[0.12] font-display text-sm font-bold text-[var(--a)]">
54
+ {n}
55
+ </span>
56
+ )}
57
+ <div className="min-w-0">
58
+ <h3 className="flex items-center gap-2 font-display text-base font-bold tracking-tight">
59
+ {Icon && <Icon className="h-4 w-4 shrink-0 text-[var(--a)]" />}
60
+ {title}
61
+ </h3>
62
+ {hint && <p className="text-xs text-muted-foreground">{hint}</p>}
63
+ </div>
64
+ </div>
65
+ {children}
66
+ </section>
67
+ )
68
+ }
69
+
70
+ /** Label + control + help line — the unit every section is built out of. */
71
+ export function Field({
72
+ label,
73
+ htmlFor,
74
+ hint,
75
+ error,
76
+ children,
77
+ className,
78
+ }: {
79
+ label?: ReactNode
80
+ htmlFor?: string
81
+ hint?: ReactNode
82
+ error?: ReactNode
83
+ children: ReactNode
84
+ className?: string
85
+ }) {
86
+ return (
87
+ <div className={cn("space-y-1.5", className)}>
88
+ {label && <Label htmlFor={htmlFor}>{label}</Label>}
89
+ {children}
90
+ {error ? (
91
+ <p className="text-xs text-destructive">{error}</p>
92
+ ) : hint ? (
93
+ <p className="text-xs text-muted-foreground">{hint}</p>
94
+ ) : null}
95
+ </div>
96
+ )
97
+ }
@@ -0,0 +1,206 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+
5
+ import { cn } from "../../utils"
6
+ import { ImageCard } from "../devcontainer/image-card"
7
+ import { ImageTile } from "../devcontainer/image-tile"
8
+ import type { DevcontainerImageEntry } from "../devcontainer/types"
9
+ import { Input } from "../input"
10
+
11
+ export interface ImagePickerProps {
12
+ images?: DevcontainerImageEntry[]
13
+ /** The image reference itself — not a catalog id. Empty = nothing picked yet. */
14
+ value: string
15
+ onChange: (image: string) => void
16
+ /**
17
+ * `chips` (default) — tag-sized entries flowing and wrapping on the left, the
18
+ * full card of the selected one on the right. `tiles` — a square-tile grid
19
+ * with the detail card underneath. `cards` — every entry as a full card, which
20
+ * only stays readable while the catalog is short.
21
+ */
22
+ layout?: "chips" | "tiles" | "cards"
23
+ /**
24
+ * Where the detail card goes in `chips` layout. `auto` (default) puts it on
25
+ * the right once the picker is wide enough for both, and underneath when it
26
+ * isn't — below roughly 52rem the chip column gets down to two chips a row,
27
+ * at which point the list is taller than it is scannable and stacking wins.
28
+ * `side` / `below` force it, for a layout that already knows its own width.
29
+ */
30
+ detail?: "auto" | "side" | "below"
31
+ className?: string
32
+ }
33
+
34
+ /**
35
+ * Pick the base image: the catalog, plus a "custom image" entry that swaps in a
36
+ * free-text field.
37
+ *
38
+ * **Why chips + a detail panel.** Full cards are the right object for six
39
+ * entries. Past a dozen they're a wall of near-identical blocks all repeating
40
+ * `mcr.microsoft.com/devcontainers/`, and picking a runtime is a *logo* task,
41
+ * not a reading one. Chips flow like tags — thirty images take the room six
42
+ * cards used to — and everything a chip has to drop (publisher, description, the
43
+ * full reference) comes back in one big card, for the single entry you actually
44
+ * chose. Scanning and reading stop competing for the same space.
45
+ *
46
+ * **The value is the reference, never the catalog id.** A ref that isn't in the
47
+ * catalog (typed by hand, or from a catalog that has since changed) is a
48
+ * first-class state, not a bug: it selects the custom entry and shows itself in
49
+ * the field.
50
+ */
51
+ export function ImagePicker({
52
+ images = [],
53
+ value,
54
+ onChange,
55
+ layout = "chips",
56
+ detail: detailPosition = "auto",
57
+ className,
58
+ }: ImagePickerProps) {
59
+ const matched = images.find((image) => image.image === value)
60
+ // An unrecognised ref means custom — but so does an empty value the user has
61
+ // just switched to, and the two are indistinguishable from `value` alone.
62
+ // Without this flag, clearing the field would bounce the selection back to a
63
+ // catalog entry mid-typing.
64
+ const [customPinned, setCustomPinned] = useState(false)
65
+ const isCustom = customPinned || (!!value && !matched)
66
+
67
+ const customEntry: DevcontainerImageEntry = {
68
+ label: "Custom image",
69
+ description: "Enter any devcontainer-compatible image",
70
+ image: isCustom ? value : undefined,
71
+ }
72
+
73
+ function selectCatalog(image: DevcontainerImageEntry) {
74
+ setCustomPinned(false)
75
+ onChange(image.image ?? "")
76
+ }
77
+
78
+ function selectCustom() {
79
+ setCustomPinned(true)
80
+ if (matched) onChange("")
81
+ }
82
+
83
+ const isSelected = (image: DevcontainerImageEntry) => !isCustom && image.image === value
84
+ // Only worth a card once there's something to detail: custom-but-empty has
85
+ // nothing to show yet, and an empty card reads as a broken one.
86
+ const detail = matched ?? (isCustom && value.trim() ? { label: "Custom image", image: value } : null)
87
+
88
+ const customInput = isCustom ? <CustomInput value={value} onChange={onChange} /> : null
89
+
90
+ if (layout === "cards") {
91
+ return (
92
+ <div className={cn("@container/picker space-y-3", className)}>
93
+ <div className="grid grid-cols-1 gap-2 @min-[34rem]/picker:grid-cols-2">
94
+ {images.map((image) => (
95
+ <ImageCard
96
+ key={image.id ?? image.image}
97
+ image={image}
98
+ copyable
99
+ selected={isSelected(image)}
100
+ onSelect={() => selectCatalog(image)}
101
+ />
102
+ ))}
103
+ <ImageCard image={customEntry} custom selected={isCustom} onSelect={selectCustom} />
104
+ </div>
105
+ {customInput}
106
+ </div>
107
+ )
108
+ }
109
+
110
+ if (layout === "tiles") {
111
+ return (
112
+ <div className={cn("space-y-3", className)}>
113
+ {/* `auto-fill` + a min track, not a fixed column count: the same grid is
114
+ four tiles wide in a form column and eight on a full-width page. */}
115
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(84px,1fr))] gap-2">
116
+ {images.map((image) => (
117
+ <ImageTile
118
+ key={image.id ?? image.image}
119
+ image={image}
120
+ variant="tile"
121
+ selected={isSelected(image)}
122
+ onSelect={() => selectCatalog(image)}
123
+ />
124
+ ))}
125
+ <ImageTile image={customEntry} variant="tile" custom selected={isCustom} onSelect={selectCustom} />
126
+ </div>
127
+ {customInput}
128
+ {detail && <Detail image={detail} custom={isCustom} />}
129
+ </div>
130
+ )
131
+ }
132
+
133
+ // A CONTAINER query, so the split depends on the picker's own width and never
134
+ // on the window's. Stacked is the same reading order, just vertical.
135
+ const split = {
136
+ auto: "@min-[52rem]/picker:grid-cols-[1fr_minmax(18rem,22rem)] @min-[52rem]/picker:items-start",
137
+ // Even "forced" has a floor: below ~34rem two columns simply don't fit, and
138
+ // the panel would push the page into horizontal scrolling. `side` means "as
139
+ // soon as it fits", not "always".
140
+ side: "@min-[34rem]/picker:grid-cols-[1fr_minmax(15rem,22rem)] @min-[34rem]/picker:items-start",
141
+ below: "",
142
+ }[detailPosition]
143
+
144
+ return (
145
+ <div className={cn("@container/picker", className)}>
146
+ <div className={cn("grid gap-3", split)}>
147
+ <div className="space-y-3">
148
+ <div className="flex flex-wrap content-start gap-1.5">
149
+ {images.map((image) => (
150
+ <ImageTile
151
+ key={image.id ?? image.image}
152
+ image={image}
153
+ selected={isSelected(image)}
154
+ onSelect={() => selectCatalog(image)}
155
+ />
156
+ ))}
157
+ <ImageTile image={customEntry} custom selected={isCustom} onSelect={selectCustom} />
158
+ </div>
159
+ {customInput}
160
+ </div>
161
+
162
+ {detail ? (
163
+ <Detail image={detail} custom={isCustom} />
164
+ ) : (
165
+ // A placeholder rather than nothing: without it the chips reflow the
166
+ // moment you pick one, and the whole section jumps under the cursor.
167
+ <div
168
+ className={cn(
169
+ "min-h-[7.5rem] items-center justify-center rounded-lg border border-dashed border-border p-4 text-center text-xs text-muted-foreground",
170
+ detailPosition === "side" ? "hidden @min-[34rem]/picker:flex" : "hidden @min-[52rem]/picker:flex"
171
+ )}
172
+ >
173
+ Pick an image to see what it ships.
174
+ </div>
175
+ )}
176
+ </div>
177
+ </div>
178
+ )
179
+ }
180
+
181
+ function Detail({ image, custom }: { image: DevcontainerImageEntry; custom: boolean }) {
182
+ return (
183
+ <ImageCard
184
+ // Keyed on the reference so switching images replays the entrance — the
185
+ // panel is the answer to a click, and it should read as having changed.
186
+ key={image.image}
187
+ image={image}
188
+ custom={custom}
189
+ copyable
190
+ className="motion-safe:animate-[form-section-in_0.35s_cubic-bezier(0.22,1,0.36,1)_both]"
191
+ />
192
+ )
193
+ }
194
+
195
+ function CustomInput({ value, onChange }: { value: string; onChange: (value: string) => void }) {
196
+ return (
197
+ <Input
198
+ aria-label="Custom image reference"
199
+ placeholder="mcr.microsoft.com/devcontainers/typescript-node:20"
200
+ value={value}
201
+ required
202
+ className="font-mono text-xs"
203
+ onChange={(e) => onChange(e.target.value)}
204
+ />
205
+ )
206
+ }
@@ -0,0 +1,58 @@
1
+ // @spunto/design-system/projects — composing a project.
2
+ //
3
+ // Third domain sub-export, after `/workers` and `/devcontainer`. `ProjectForm`
4
+ // is the whole "compose a dev environment" screen: identity, base image, repos,
5
+ // features, extensions, lifecycle, ports, prewarm, docker-in-docker, secrets,
6
+ // plus the live build manifest that recaps it and carries the submit.
7
+ //
8
+ // Controlled by a single `ProjectFormValue`, and nothing in here fetches:
9
+ // catalogs come in as data, the extension search is a callback, the GitHub
10
+ // combobox is a slot, `onSubmit` hands the value back. Which sections show is a
11
+ // prop — creation and editing are one component with a different list.
12
+ //
13
+ // Every brick is exported on its own, because a screen that needs only the repo
14
+ // rows or only the manifest shouldn't have to take the form with it.
15
+
16
+ export { ProjectForm, manifestRows } from "./project-form"
17
+ export type { ProjectFormProps } from "./project-form"
18
+
19
+ export { FormSection, Field } from "./form-section"
20
+ export type { FormSectionProps, SectionAccent } from "./form-section"
21
+
22
+ export { AdvancedDisclosure, advancedSummary } from "./advanced-sections"
23
+ export type { AdvancedDisclosureProps } from "./advanced-sections"
24
+
25
+ export { BuildManifest } from "./build-manifest"
26
+ export type { BuildManifestProps, ManifestRow } from "./build-manifest"
27
+
28
+ export { RepoList, deriveFromGitUrl } from "./repo-list"
29
+ export type { RepoListProps, GithubRepoOption, GithubConnection, RepoFieldRender } from "./repo-list"
30
+
31
+ export { SecretList } from "./secret-list"
32
+ export type { SecretListProps } from "./secret-list"
33
+
34
+ export { ImagePicker } from "./image-picker"
35
+ export type { ImagePickerProps } from "./image-picker"
36
+
37
+ export { FeaturePicker } from "./feature-picker"
38
+ export type { FeaturePickerProps } from "./feature-picker"
39
+
40
+ export { ExtensionPicker } from "./extension-picker"
41
+ export type { ExtensionPickerProps } from "./extension-picker"
42
+
43
+ export {
44
+ CREATE_SECTIONS,
45
+ EDIT_SECTIONS,
46
+ emptyProjectFormValue,
47
+ toProjectFormValue,
48
+ } from "./types"
49
+ export type {
50
+ ProjectFeatureSelection,
51
+ ProjectFormSectionId,
52
+ ProjectFormValue,
53
+ ProjectRepo,
54
+ ProjectSecretDraft,
55
+ ProjectSecretRef,
56
+ } from "./types"
57
+
58
+ export { cn } from "../../utils"