@spunto/design-system 0.10.0 → 0.11.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 (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 +148 -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 +495 -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
@@ -0,0 +1,302 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { GitBranchIcon, Trash2Icon } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+ import { Button } from "../button"
8
+ import { Input } from "../input"
9
+ import type { ProjectRepo } from "./types"
10
+
11
+ /** A repository the user can pick from a connected GitHub account. */
12
+ export interface GithubRepoOption {
13
+ fullName: string
14
+ private?: boolean
15
+ installationId?: number
16
+ }
17
+
18
+ /** State of the org's GitHub App connection, as far as this list needs it. */
19
+ export interface GithubConnection {
20
+ connected?: boolean
21
+ appSlug?: string
22
+ installedAccounts?: { login: string; avatarUrl?: string | null }[]
23
+ }
24
+
25
+ /**
26
+ * Renders the GitHub repository field. A slot, because a real combobox is an
27
+ * app-level dependency (the dashboard has a Base UI one): without it the list
28
+ * falls back to an `<input list>` + `<datalist>`, which needs nothing, works
29
+ * with a keyboard, and is honest about being a plain text field.
30
+ */
31
+ export type RepoFieldRender = (props: {
32
+ value: string
33
+ onChange: (value: string) => void
34
+ options: GithubRepoOption[]
35
+ placeholder: string
36
+ id: string
37
+ }) => ReactNode
38
+
39
+ export interface RepoListProps {
40
+ repos: ProjectRepo[]
41
+ onChange: (repos: ProjectRepo[]) => void
42
+ /** Repositories reachable through the connected GitHub App. */
43
+ githubRepos?: GithubRepoOption[]
44
+ github?: GithubConnection
45
+ /** Where "Connect GitHub" points. The package knows no route of its own. */
46
+ connectUrl?: string
47
+ renderRepoField?: RepoFieldRender
48
+ /** Icon for GitHub rows — the app passes its brand glyph; defaults to a git branch. */
49
+ githubIcon?: ReactNode
50
+ className?: string
51
+ }
52
+
53
+ /** Human label + default workspace dir out of a raw clone URL. */
54
+ export function deriveFromGitUrl(url: string): { label: string; repoName: string } {
55
+ const path = url
56
+ .trim()
57
+ .replace(/^git@[^:]+:/, "")
58
+ .replace(/^ssh:\/\/[^/]+\//, "")
59
+ .replace(/^https?:\/\/[^/]+\//, "")
60
+ .replace(/\.git$/, "")
61
+ const segments = path.split("/").filter(Boolean)
62
+ const repoName = segments[segments.length - 1] ?? ""
63
+ const label = segments.slice(-2).join("/") || path
64
+ return { label, repoName }
65
+ }
66
+
67
+ /**
68
+ * The repository field's slot in a row: its own full-width line on a phone,
69
+ * a shared column as soon as there's room for one.
70
+ */
71
+ const ROW_LEAD = "relative min-w-0 basis-full @min-[30rem]/repos:basis-0 @min-[30rem]/repos:flex-1"
72
+
73
+ let seq = 0
74
+ /**
75
+ * Identity for a row that doesn't exist server-side yet. A counter rather than
76
+ * `crypto.randomUUID()`: it only has to be unique among the rows of one form,
77
+ * and a counter is stable under SSR, where `randomUUID` would hand the server
78
+ * and the client two different keys.
79
+ */
80
+ function localRepoId(): string {
81
+ seq += 1
82
+ return `repo-${seq}`
83
+ }
84
+
85
+ /**
86
+ * The repositories a project clones into `/workspace`, one row each: where the
87
+ * code comes from, and the directory it lands in.
88
+ *
89
+ * **Fully controlled** — it never keeps a repo in local state. The one piece of
90
+ * cleverness it does keep is `workspacePathTouched`: the path follows the repo
91
+ * name while you're picking a repo, and stops the moment you type your own.
92
+ * Without that flag, choosing a repo silently overwrites a path you had set.
93
+ */
94
+ export function RepoList({
95
+ repos,
96
+ onChange,
97
+ githubRepos = [],
98
+ github,
99
+ connectUrl,
100
+ renderRepoField,
101
+ githubIcon,
102
+ className,
103
+ }: RepoListProps) {
104
+ const hasGithubRepos = githubRepos.length > 0
105
+ const hasGitRepos = repos.some((r) => r.provider === "git")
106
+
107
+ const patch = (id: string, next: Partial<ProjectRepo>) =>
108
+ onChange(repos.map((r) => (r.id === id ? { ...r, ...next } : r)))
109
+
110
+ function setProject(repo: ProjectRepo, value: string) {
111
+ const repoName = value.split("/")[1] ?? value
112
+ patch(repo.id, {
113
+ project: value,
114
+ // Tag the row with the installation that grants access, so a worker clones
115
+ // it with the right token when the org connected several GitHub accounts.
116
+ installationId: githubRepos.find((r) => r.fullName === value)?.installationId,
117
+ ...(repo.workspacePathTouched ? {} : { workspacePath: repoName }),
118
+ })
119
+ }
120
+
121
+ function setCloneUrl(repo: ProjectRepo, value: string) {
122
+ const { label, repoName } = deriveFromGitUrl(value)
123
+ patch(repo.id, {
124
+ cloneUrl: value,
125
+ project: label,
126
+ ...(repo.workspacePathTouched ? {} : { workspacePath: repoName }),
127
+ })
128
+ }
129
+
130
+ return (
131
+ <div className={cn("@container/repos space-y-2", className)}>
132
+ {repos.length > 0 && (
133
+ <div className="mb-1 hidden grid-cols-[1fr_1fr_auto] gap-2 px-1 text-xs text-muted-foreground @min-[30rem]/repos:grid">
134
+ <span>Repository</span>
135
+ <span>Workspace path</span>
136
+ <span />
137
+ </div>
138
+ )}
139
+
140
+ {repos.map((repo) => (
141
+ // `flex-wrap` + `basis-full`, not a grid: at phone width the two-column
142
+ // grid gave the `auto` track to the workspace-path input, which sized to
143
+ // its content and squeezed the repository field down to its icon. Here
144
+ // the repository takes a full row of its own until there's room, and the
145
+ // path + bin share the next one.
146
+ <div key={repo.id} className="flex flex-wrap items-center gap-2">
147
+ {repo.provider === "git" ? (
148
+ <div className={ROW_LEAD}>
149
+ <GitBranchIcon className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
150
+ <Input
151
+ className="pl-8 font-mono text-xs"
152
+ placeholder="git@gitlab.com:group/repo.git"
153
+ value={repo.cloneUrl ?? ""}
154
+ onChange={(e) => setCloneUrl(repo, e.target.value)}
155
+ />
156
+ </div>
157
+ ) : renderRepoField && hasGithubRepos ? (
158
+ <div className={ROW_LEAD}>
159
+ {renderRepoField({
160
+ value: repo.project,
161
+ onChange: (value) => setProject(repo, value),
162
+ options: githubRepos,
163
+ placeholder: "Search or type owner/repo…",
164
+ id: repo.id,
165
+ })}
166
+ </div>
167
+ ) : (
168
+ <div className={cn(ROW_LEAD, "relative")}>
169
+ <span className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground">
170
+ {githubIcon ?? <GitBranchIcon className="h-3.5 w-3.5" />}
171
+ </span>
172
+ <Input
173
+ className="pl-8"
174
+ list={hasGithubRepos ? `repos-${repo.id}` : undefined}
175
+ placeholder="owner/repo"
176
+ value={repo.project}
177
+ onChange={(e) => setProject(repo, e.target.value)}
178
+ />
179
+ {hasGithubRepos && (
180
+ <datalist id={`repos-${repo.id}`}>
181
+ {githubRepos.map((r) => (
182
+ <option key={r.fullName} value={r.fullName} />
183
+ ))}
184
+ </datalist>
185
+ )}
186
+ </div>
187
+ )}
188
+
189
+ <Input
190
+ placeholder="e.g. app"
191
+ aria-label="Workspace path"
192
+ className="min-w-0 flex-1"
193
+ value={repo.workspacePath}
194
+ onChange={(e) => patch(repo.id, { workspacePath: e.target.value, workspacePathTouched: true })}
195
+ />
196
+
197
+ <Button
198
+ type="button"
199
+ variant="ghost"
200
+ size="icon"
201
+ className="shrink-0"
202
+ aria-label={`Remove ${repo.project || "repository"}`}
203
+ onClick={() => onChange(repos.filter((r) => r.id !== repo.id))}
204
+ >
205
+ <Trash2Icon className="h-4 w-4" />
206
+ </Button>
207
+ </div>
208
+ ))}
209
+
210
+ <div className="flex flex-wrap gap-2">
211
+ <Button
212
+ type="button"
213
+ variant="outline"
214
+ size="sm"
215
+ onClick={() =>
216
+ onChange([...repos, { id: localRepoId(), provider: "github", project: "", workspacePath: "" }])
217
+ }
218
+ >
219
+ <span className="mr-1 flex h-4 w-4 items-center justify-center">
220
+ {githubIcon ?? <GitBranchIcon className="h-4 w-4" />}
221
+ </span>
222
+ Add GitHub repo
223
+ </Button>
224
+ <Button
225
+ type="button"
226
+ variant="outline"
227
+ size="sm"
228
+ onClick={() =>
229
+ onChange([...repos, { id: localRepoId(), provider: "git", project: "", workspacePath: "", cloneUrl: "" }])
230
+ }
231
+ >
232
+ <GitBranchIcon className="mr-1 h-4 w-4" />
233
+ Add Git URL
234
+ </Button>
235
+ </div>
236
+
237
+ <p className="text-xs text-muted-foreground">
238
+ Cloned into <code>/workspace/&lt;path&gt;</code> at worker startup.{" "}
239
+ {connectUrl &&
240
+ (github?.connected ? (
241
+ <a href={connectUrl} className="underline underline-offset-2">
242
+ Reconnect GitHub
243
+ </a>
244
+ ) : (
245
+ <>
246
+ <a href={connectUrl} className="underline underline-offset-2">
247
+ Connect GitHub
248
+ </a>{" "}
249
+ to enable private repos for this organization.
250
+ </>
251
+ ))}
252
+ </p>
253
+
254
+ {hasGitRepos && (
255
+ <p className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
256
+ Git URL repos (GitLab, Gitea, Bitbucket, self-hosted…) are cloned with this project&apos;s SSH{" "}
257
+ <strong>deploy key</strong>. After saving, register the public key shown on the project page as a{" "}
258
+ <strong>read-only</strong> deploy key on each repo. <code>https://</code> URLs to public repos work
259
+ without a key.
260
+ </p>
261
+ )}
262
+
263
+ {github?.connected && github.installedAccounts && github.installedAccounts.length > 0 && (
264
+ <div className="space-y-2 rounded-md border border-dashed p-3">
265
+ <p className="text-xs font-medium">GitHub App installed on:</p>
266
+ <div className="flex flex-wrap gap-2">
267
+ {github.installedAccounts.map((account) => (
268
+ <span key={account.login} className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs">
269
+ {account.avatarUrl && (
270
+ <img src={account.avatarUrl} alt="" className="h-4 w-4 rounded-sm" />
271
+ )}
272
+ {account.login}
273
+ </span>
274
+ ))}
275
+ </div>
276
+ {github.appSlug && (
277
+ <p className="text-xs text-muted-foreground">
278
+ <a
279
+ href={`https://github.com/apps/${github.appSlug}/installations/new`}
280
+ target="_blank"
281
+ rel="noopener noreferrer"
282
+ className="underline underline-offset-2"
283
+ >
284
+ Install on another account
285
+ </a>{" "}
286
+ to access more repos
287
+ {connectUrl && (
288
+ <>
289
+ , then{" "}
290
+ <a href={connectUrl} className="underline underline-offset-2">
291
+ reconnect
292
+ </a>
293
+ </>
294
+ )}
295
+ .
296
+ </p>
297
+ )}
298
+ </div>
299
+ )}
300
+ </div>
301
+ )
302
+ }
@@ -0,0 +1,159 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { PlusIcon, Trash2Icon } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+ import { Input } from "../input"
8
+ import type { ProjectSecretDraft, ProjectSecretRef } from "./types"
9
+
10
+ /** Env var names, the shell convention: `MY_SECRET`, never `my-secret`. */
11
+ const NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/
12
+
13
+ export interface SecretListProps {
14
+ /** Secrets typed in this session, values still in memory. */
15
+ drafts: ProjectSecretDraft[]
16
+ onDraftsChange: (drafts: ProjectSecretDraft[]) => void
17
+ /** Already-saved secrets. Values are never returned by an API — only names show. */
18
+ saved?: ProjectSecretRef[]
19
+ /** Ids of saved secrets staged for removal; they disappear from the list. */
20
+ removedIds?: string[]
21
+ onRemovedIdsChange?: (ids: string[]) => void
22
+ hint?: string
23
+ className?: string
24
+ }
25
+
26
+ let seq = 0
27
+
28
+ /**
29
+ * The env vars injected into every workspace: a list of names, and a row to add
30
+ * one.
31
+ *
32
+ * A secret is write-only by nature — the API never returns a value — so the list
33
+ * shows names alone and the two states are told apart by colour: saved ones are
34
+ * neutral, the ones you just typed are flame, because they only exist in this
35
+ * page until you submit.
36
+ *
37
+ * Validation lives here rather than in the parent form: the name rule is a
38
+ * property of a secret, not of the screen it's typed on.
39
+ */
40
+ export function SecretList({
41
+ drafts,
42
+ onDraftsChange,
43
+ saved = [],
44
+ removedIds = [],
45
+ onRemovedIdsChange,
46
+ hint,
47
+ className,
48
+ }: SecretListProps) {
49
+ const [name, setName] = useState("")
50
+ const [value, setValue] = useState("")
51
+ const [error, setError] = useState<string | null>(null)
52
+
53
+ const visible = saved.filter((s) => !removedIds.includes(s.id))
54
+
55
+ function add() {
56
+ setError(null)
57
+ const key = name.trim().toUpperCase()
58
+ if (!key || !value.trim()) return
59
+ if (!NAME_PATTERN.test(key)) {
60
+ setError("UPPER_SNAKE_CASE required (e.g. MY_SECRET)")
61
+ return
62
+ }
63
+ if (visible.some((s) => s.name === key) || drafts.some((s) => s.name === key)) {
64
+ setError(`${key} is already set`)
65
+ return
66
+ }
67
+ seq += 1
68
+ onDraftsChange([...drafts, { localId: `secret-${seq}`, name: key, value: value.trim() }])
69
+ setName("")
70
+ setValue("")
71
+ }
72
+
73
+ return (
74
+ <div className={cn("@container/secrets space-y-3", className)}>
75
+ {(visible.length > 0 || drafts.length > 0) && (
76
+ <div className="space-y-1.5">
77
+ {visible.map((secret) => (
78
+ <Row
79
+ key={secret.id}
80
+ name={secret.name}
81
+ onRemove={onRemovedIdsChange ? () => onRemovedIdsChange([...removedIds, secret.id]) : undefined}
82
+ />
83
+ ))}
84
+ {drafts.map((secret) => (
85
+ <Row
86
+ key={secret.localId}
87
+ name={secret.name}
88
+ pending
89
+ onRemove={() => onDraftsChange(drafts.filter((s) => s.localId !== secret.localId))}
90
+ />
91
+ ))}
92
+ </div>
93
+ )}
94
+
95
+ <div className="space-y-1.5">
96
+ {/* Same reason as RepoList: a two-column grid handed the `auto` track to
97
+ the value field, which squeezed NAME down to two characters on a
98
+ phone. Wrapping gives NAME its own line until both fit. */}
99
+ <div className="flex flex-wrap items-end gap-2">
100
+ <Input
101
+ placeholder="NAME"
102
+ aria-label="Secret name"
103
+ value={name}
104
+ spellCheck={false}
105
+ className="h-8 min-w-0 basis-full font-mono text-sm @min-[26rem]/secrets:basis-0 @min-[26rem]/secrets:flex-1"
106
+ onChange={(e) => setName(e.target.value.toUpperCase())}
107
+ onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), add())}
108
+ />
109
+ <Input
110
+ type="password"
111
+ placeholder="value"
112
+ aria-label="Secret value"
113
+ value={value}
114
+ autoComplete="off"
115
+ className="h-8 min-w-0 flex-1 font-mono text-sm"
116
+ onChange={(e) => setValue(e.target.value)}
117
+ onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), add())}
118
+ />
119
+ {/* `type="button"`: this sits inside the project form, and a bare
120
+ button would submit the whole thing on Enter. */}
121
+ <button
122
+ type="button"
123
+ onClick={add}
124
+ aria-label="Add secret"
125
+ className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
126
+ >
127
+ <PlusIcon className="h-3.5 w-3.5" />
128
+ </button>
129
+ </div>
130
+ {error && <p className="text-xs text-destructive">{error}</p>}
131
+ </div>
132
+
133
+ {hint && <p className="text-xs text-muted-foreground">{hint}</p>}
134
+ </div>
135
+ )
136
+ }
137
+
138
+ function Row({ name, pending = false, onRemove }: { name: string; pending?: boolean; onRemove?: () => void }) {
139
+ return (
140
+ <div
141
+ className={cn(
142
+ "flex items-center justify-between gap-3 rounded-md border px-3 py-2",
143
+ pending ? "border-primary/30 bg-primary/5" : "bg-muted/30"
144
+ )}
145
+ >
146
+ <code className={cn("truncate font-mono text-sm", pending && "text-primary")}>{name}</code>
147
+ {onRemove && (
148
+ <button
149
+ type="button"
150
+ onClick={onRemove}
151
+ aria-label={`Remove ${name}`}
152
+ className="shrink-0 cursor-pointer text-muted-foreground transition-colors hover:text-destructive"
153
+ >
154
+ <Trash2Icon className="h-3.5 w-3.5" />
155
+ </button>
156
+ )}
157
+ </div>
158
+ )
159
+ }
@@ -0,0 +1,136 @@
1
+ // The shape a project form edits, and nothing more.
2
+ //
3
+ // Same rule as the rest of the package: this is NOT an app's OpenAPI type. The
4
+ // dashboard's create payload and its update payload already differ from each
5
+ // other (creation has no ports/prewarm/dind, update has no inline secrets), and
6
+ // Spunto Lite's project is smaller still. So the form owns one **complete**
7
+ // value — every field present, never undefined — and each app maps it to its own
8
+ // payload on submit. A complete value is what makes the form controllable
9
+ // without a single `?? ""` at every input.
10
+
11
+ /** One repository to clone into the workspace. */
12
+ export interface ProjectRepo {
13
+ /** Client-side identity, stable across re-renders. Not the API's id. */
14
+ id: string
15
+ /** `github` resolves through the connected GitHub App; `git` clones a raw URL with the deploy key. */
16
+ provider: "github" | "git"
17
+ /** `owner/repo` for GitHub, a human label for a raw URL. */
18
+ project: string
19
+ /** Directory under `/workspace`. Auto-derived from the repo name until the user edits it. */
20
+ workspacePath: string
21
+ /**
22
+ * True once the user typed their own path — the form then stops re-deriving it.
23
+ * Without it, picking a repo silently overwrites a path the user had set.
24
+ */
25
+ workspacePathTouched?: boolean
26
+ /** Which GitHub App installation grants access (an org can connect several). */
27
+ installationId?: number
28
+ /** Raw clone URL, `git` provider only. */
29
+ cloneUrl?: string
30
+ }
31
+
32
+ /** A devcontainer feature, as the form carries it. */
33
+ export interface ProjectFeatureSelection {
34
+ id: string
35
+ /** Set for a hand-typed OCI ref that isn't in the catalog. */
36
+ ociRef?: string
37
+ options?: Record<string, string>
38
+ }
39
+
40
+ /** A secret the user typed but that isn't saved yet. Values never come back from an API. */
41
+ export interface ProjectSecretDraft {
42
+ /** Client-side identity — a secret has no id until it's persisted. */
43
+ localId: string
44
+ name: string
45
+ value: string
46
+ }
47
+
48
+ /** A secret that already exists server-side. The form can only remove it. */
49
+ export interface ProjectSecretRef {
50
+ id: string
51
+ name: string
52
+ }
53
+
54
+ /** Everything the form edits. Every field is present — see the note at the top. */
55
+ export interface ProjectFormValue {
56
+ name: string
57
+ description: string
58
+ image: string
59
+ repositories: ProjectRepo[]
60
+ features: ProjectFeatureSelection[]
61
+ vscodeExtensions: string[]
62
+ postCreateCommand: string
63
+ postStartCommand: string
64
+ /** Secrets typed in this session. */
65
+ secrets: ProjectSecretDraft[]
66
+ /** Ids of already-saved secrets the user asked to remove. */
67
+ removedSecretIds: string[]
68
+ forwardPorts: number[]
69
+ prewarmImages: string[]
70
+ dockerInDocker: boolean
71
+ }
72
+
73
+ export type ProjectFormSectionId =
74
+ | "identity"
75
+ | "image"
76
+ | "repositories"
77
+ | "features"
78
+ | "extensions"
79
+ | "lifecycle"
80
+ | "ports"
81
+ | "prewarm"
82
+ | "docker"
83
+ | "secrets"
84
+
85
+ /** Creation: the seven sections that matter before a project exists. */
86
+ export const CREATE_SECTIONS: ProjectFormSectionId[] = [
87
+ "identity",
88
+ "image",
89
+ "repositories",
90
+ "features",
91
+ "extensions",
92
+ "lifecycle",
93
+ "secrets",
94
+ ]
95
+
96
+ /** Editing: everything, including the runtime knobs you only tune once it runs. */
97
+ export const EDIT_SECTIONS: ProjectFormSectionId[] = [
98
+ "identity",
99
+ "image",
100
+ "repositories",
101
+ "features",
102
+ "extensions",
103
+ "lifecycle",
104
+ "ports",
105
+ "prewarm",
106
+ "docker",
107
+ "secrets",
108
+ ]
109
+
110
+ /** An empty, complete value — the starting point of a creation form. */
111
+ export function emptyProjectFormValue(): ProjectFormValue {
112
+ return {
113
+ name: "",
114
+ description: "",
115
+ image: "",
116
+ repositories: [],
117
+ features: [],
118
+ vscodeExtensions: [],
119
+ postCreateCommand: "",
120
+ postStartCommand: "",
121
+ secrets: [],
122
+ removedSecretIds: [],
123
+ forwardPorts: [],
124
+ prewarmImages: [],
125
+ dockerInDocker: false,
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Fill the gaps of a partial value — what an app has after mapping an existing
131
+ * project, where half the fields are nullable. Keeps the form's "everything is
132
+ * present" invariant without pushing a dozen `?? ""` onto every caller.
133
+ */
134
+ export function toProjectFormValue(partial?: Partial<ProjectFormValue> | null): ProjectFormValue {
135
+ return { ...emptyProjectFormValue(), ...(partial ?? {}) }
136
+ }
package/styles.css CHANGED
@@ -513,3 +513,16 @@
513
513
  [data-slot="image-pull-sweep"] { animation: none; opacity: 0.45; }
514
514
  [data-slot="image-pull"] * { transition-duration: 0.01ms !important; }
515
515
  }
516
+
517
+ /* ---------------------------------------------------------------------------
518
+ FormSection — entrance.
519
+ A long form arrives as a stack of cards; each one lifts in slightly after the
520
+ previous (the delay is an inline style, since it depends on the section's
521
+ index). Deliberately CSS and not a motion library: the package won't take a
522
+ dependency on one for a single fade-in, and `motion-safe:` already gates it
523
+ on the user's reduced-motion setting.
524
+ --------------------------------------------------------------------------- */
525
+ @keyframes form-section-in {
526
+ from { opacity: 0; transform: translateY(16px); }
527
+ to { opacity: 1; transform: none; }
528
+ }