@spunto/design-system 0.14.2 → 0.15.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.14.2",
3
+ "version": "0.15.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",
@@ -4,7 +4,13 @@ import type { ReactNode } from "react"
4
4
  import { ChevronDownIcon, Settings2Icon } from "lucide-react"
5
5
 
6
6
  import { cn } from "../../utils"
7
- import type { ProjectFormSectionId, ProjectFormValue, ProjectSecretRef } from "./types"
7
+ import {
8
+ isInternalSectionId,
9
+ type ProjectFormCustomSection,
10
+ type ProjectFormSectionId,
11
+ type ProjectFormValue,
12
+ type ProjectSecretRef,
13
+ } from "./types"
8
14
 
9
15
  export interface AdvancedDisclosureProps {
10
16
  open: boolean
@@ -101,8 +107,8 @@ export function AdvancedDisclosure({
101
107
  * `lifecycle`, `3 secrets`. Pure, and exported, because a caller rendering its
102
108
  * own fold needs the same sentence.
103
109
  */
104
- export function advancedSummary(
105
- value: ProjectFormValue,
110
+ export function advancedSummary<TValue extends ProjectFormValue>(
111
+ value: TValue,
106
112
  sections: ProjectFormSectionId[],
107
113
  /**
108
114
  * Secrets already stored server-side. They count: a project whose only
@@ -110,10 +116,23 @@ export function advancedSummary(
110
116
  * keep the fold shut over them — exactly the "hides configuration you didn't
111
117
  * set yourself" failure this component exists to avoid.
112
118
  */
113
- savedSecrets: ProjectSecretRef[] = []
119
+ savedSecrets: ProjectSecretRef[] = [],
120
+ /**
121
+ * App-provided sections. Theirs count too, for the very same reason: a fold
122
+ * that shuts over a field it doesn't happen to know about hides it just as
123
+ * well as one it does.
124
+ */
125
+ customSections: ProjectFormCustomSection<TValue>[] = []
114
126
  ): string[] {
127
+ const custom = new Map(customSections.filter((s) => !isInternalSectionId(s.id)).map((s) => [s.id, s]))
115
128
  const out: string[] = []
116
129
  for (const id of sections) {
130
+ const own = custom.get(id)
131
+ if (own) {
132
+ const label = own.summary?.(value)
133
+ if (label) out.push(label)
134
+ continue
135
+ }
117
136
  switch (id) {
118
137
  case "features":
119
138
  if (value.features.length) out.push(`${value.features.length} feature${value.features.length > 1 ? "s" : ""}`)
@@ -18,7 +18,10 @@
18
18
  // Controlled by a single `ProjectFormValue`, and nothing in here fetches:
19
19
  // catalogs come in as data, the extension search is a callback, the GitHub
20
20
  // combobox is a slot, `onSubmit` hands the value back. Which sections show is a
21
- // prop — creation and editing are one component with a different list.
21
+ // prop — creation and editing are one component with a different list, and an
22
+ // app adds a section of its own to that list (`ProjectFormCustomSection`) for a
23
+ // field the package doesn't model, rather than drawing a card beside the form
24
+ // and losing its numbering, its fold and its manifest row.
22
25
  //
23
26
  // Every brick is exported on its own, because a screen that needs only the repo
24
27
  // rows or only the manifest shouldn't have to take the form with it.
@@ -53,11 +56,15 @@ export type { ExtensionPickerProps } from "./extension-picker"
53
56
  export {
54
57
  CREATE_SECTIONS,
55
58
  EDIT_SECTIONS,
59
+ INTERNAL_SECTION_IDS,
56
60
  emptyProjectFormValue,
61
+ isInternalSectionId,
57
62
  toProjectFormValue,
58
63
  } from "./types"
59
64
  export type {
65
+ InternalSectionId,
60
66
  ProjectFeatureSelection,
67
+ ProjectFormCustomSection,
61
68
  ProjectFormSectionId,
62
69
  ProjectFormValue,
63
70
  ProjectRepo,
@@ -27,6 +27,9 @@ import { RepoList, type GithubConnection, type GithubRepoOption, type RepoFieldR
27
27
  import { SecretList } from "./secret-list"
28
28
  import {
29
29
  CREATE_SECTIONS,
30
+ isInternalSectionId,
31
+ type InternalSectionId,
32
+ type ProjectFormCustomSection,
30
33
  type ProjectFormSectionId,
31
34
  type ProjectFormValue,
32
35
  type ProjectSecretRef,
@@ -35,7 +38,7 @@ import type { DevcontainerFeatureEntry, DevcontainerImageEntry, VscodeExtensionE
35
38
 
36
39
  /** Chrome of each section: number, icon, accent, wording. */
37
40
  const SECTION_META: Record<
38
- ProjectFormSectionId,
41
+ InternalSectionId,
39
42
  { icon: ComponentType<{ className?: string }>; accent: SectionAccent; title: string; hint?: string }
40
43
  > = {
41
44
  identity: { icon: SquarePenIcon, accent: "build", title: "Identity", hint: "Name your project and what it's for" },
@@ -56,7 +59,7 @@ const SECTION_META: Record<
56
59
  * sections in front of someone creating their first project is how a form stops
57
60
  * being filled in at all.
58
61
  */
59
- const DEFAULT_ADVANCED: ProjectFormSectionId[] = [
62
+ const DEFAULT_ADVANCED: InternalSectionId[] = [
60
63
  "features",
61
64
  "extensions",
62
65
  "lifecycle",
@@ -66,13 +69,22 @@ const DEFAULT_ADVANCED: ProjectFormSectionId[] = [
66
69
  "secrets",
67
70
  ]
68
71
 
69
- export interface ProjectFormProps {
70
- value: ProjectFormValue
71
- onChange: (value: ProjectFormValue) => void
72
+ export interface ProjectFormProps<TValue extends ProjectFormValue = ProjectFormValue> {
73
+ /**
74
+ * The complete value. Generic: an app whose project has a field the package
75
+ * doesn't model declares an interface extending `ProjectFormValue` and every
76
+ * callback here — including a custom section's — speaks that type instead.
77
+ */
78
+ value: TValue
79
+ onChange: (value: TValue) => void
72
80
  /** Receives the current value. The app maps it to its own payload and calls its API. */
73
- onSubmit?: (value: ProjectFormValue) => void
81
+ onSubmit?: (value: TValue) => void
74
82
 
75
- /** Which sections to show, in order. Defaults to the seven creation ones. */
83
+ /**
84
+ * Which sections to show, in order. Defaults to the seven creation ones.
85
+ * A `customSections` id sits in here like any built-in — that's how an app's
86
+ * own card gets a place, a number and a fold.
87
+ */
76
88
  sections?: ProjectFormSectionId[]
77
89
  /**
78
90
  * Sections folded behind "Advanced options". Defaults to everything past
@@ -119,6 +131,19 @@ export interface ProjectFormProps {
119
131
  /** Extra content appended inside a section — the deploy key card, a warning… */
120
132
  extras?: Partial<Record<ProjectFormSectionId, ReactNode>>
121
133
 
134
+ /**
135
+ * Cards the app draws itself, for the fields the package doesn't model.
136
+ *
137
+ * Each one is a full section: put its id in `sections` to place it, in
138
+ * `advancedSections` to fold it, and it gets the same numbered card, the same
139
+ * summary line while the fold is closed, the same manifest row. Unlike
140
+ * `extras` — which appends *inside* an existing section — this is a section of
141
+ * its own, so it doesn't have to pretend to belong to one it has nothing to do
142
+ * with. An id that shadows a built-in is ignored: this extends the form, it
143
+ * doesn't override it.
144
+ */
145
+ customSections?: ProjectFormCustomSection<TValue>[]
146
+
122
147
  /** `false` drops the recap panel entirely and gives the form the full width. */
123
148
  manifest?: boolean
124
149
  submitLabel?: ReactNode
@@ -148,8 +173,15 @@ export interface ProjectFormProps {
148
173
  * for the app to send. Which sections are shown is a prop, so creation and
149
174
  * editing are the same component with a different list — not two screens that
150
175
  * drift apart.
176
+ *
177
+ * **And that list is open.** An app with a field the package doesn't model
178
+ * describes its own card in `customSections` and drops its id into `sections`:
179
+ * it then gets the numbering, the fold, the summary and the manifest row that a
180
+ * `FormSection` rendered next to the form can't have. Same for the value — the
181
+ * form is generic over it, so the app's extra fields are typed end to end
182
+ * instead of riding along behind a cast.
151
183
  */
152
- export function ProjectForm({
184
+ export function ProjectForm<TValue extends ProjectFormValue = ProjectFormValue>({
153
185
  value,
154
186
  onChange,
155
187
  onSubmit,
@@ -170,6 +202,7 @@ export function ProjectForm({
170
202
  onSearchExtensions,
171
203
  savedSecrets,
172
204
  extras,
205
+ customSections,
173
206
  manifest = true,
174
207
  submitLabel = "Save",
175
208
  submittingLabel = "Saving…",
@@ -179,14 +212,31 @@ export function ProjectForm({
179
212
  manifestFooter,
180
213
  manifestTitle,
181
214
  className,
182
- }: ProjectFormProps) {
183
- const patch = (next: Partial<ProjectFormValue>) => onChange({ ...value, ...next })
215
+ }: ProjectFormProps<TValue>) {
216
+ // The one and only write: a spread. Which is *why* a value carrying fields the
217
+ // package doesn't model survives a form it never taught about them — a
218
+ // contract now, not a lucky implementation detail (see `ProjectFormValue`).
219
+ // The cast lives here, once, so no app has to write one at every boundary:
220
+ // `{ ...TValue, ...Partial<TValue> }` is a TValue, TypeScript just can't say so
221
+ // about a spread of a type parameter.
222
+ const applyPatch = (next: Partial<ProjectFormValue> | Partial<TValue>) =>
223
+ onChange({ ...value, ...next } as TValue)
224
+ const patch: (next: Partial<ProjectFormValue>) => void = applyPatch
225
+ const patchCustom: (next: Partial<TValue>) => void = applyPatch
226
+
227
+ // An id in `sections` that matches neither a built-in nor a custom section is
228
+ // dropped up front rather than skipped at render: numbering counts positions,
229
+ // so leaving the hole in would shift every section after it.
230
+ const custom = new Map<string, ProjectFormCustomSection<TValue>>(
231
+ (customSections ?? []).filter((s) => !isInternalSectionId(s.id)).map((s) => [s.id, s])
232
+ )
233
+ const shown = sections.filter((id) => isInternalSectionId(id) || custom.has(id))
184
234
 
185
- const rows = manifestRows(value, sections, savedSecrets)
235
+ const rows = manifestRows(value, shown, savedSecrets, customSections)
186
236
 
187
- const advanced = sections.filter((id) => advancedSections.includes(id))
188
- const primary = sections.filter((id) => !advancedSections.includes(id))
189
- const summary = advancedSummary(value, advanced, savedSecrets)
237
+ const advanced = shown.filter((id) => advancedSections.includes(id))
238
+ const primary = shown.filter((id) => !advancedSections.includes(id))
239
+ const summary = advancedSummary(value, advanced, savedSecrets, customSections)
190
240
  // Start open when there's already something in there — the initial value is
191
241
  // read once, on mount, so a later edit can't yank the fold open under the
192
242
  // user's cursor.
@@ -251,7 +301,12 @@ export function ProjectForm({
251
301
 
252
302
  /** One section, numbered by its position in the whole form (folded or not). */
253
303
  function section(id: ProjectFormSectionId, index: number) {
254
- const meta = SECTION_META[id]
304
+ const own = custom.get(id)
305
+ // A custom card is chrome-identical to a built-in one: same `FormSection`,
306
+ // same badge, same accent — only its body comes from the app.
307
+ const meta = own
308
+ ? { icon: own.icon, accent: own.accent, title: own.title, hint: own.hint }
309
+ : SECTION_META[id as InternalSectionId]
255
310
  return (
256
311
  <FormSection
257
312
  key={id}
@@ -265,13 +320,13 @@ export function ProjectForm({
265
320
  // you've already scrolled to it.
266
321
  style={{ animationDelay: `${Math.min(index, 8) * 40}ms` }}
267
322
  >
268
- {renderSection(id)}
323
+ {own ? own.render(value, patchCustom) : renderSection(id as InternalSectionId)}
269
324
  {extras?.[id]}
270
325
  </FormSection>
271
326
  )
272
327
  }
273
328
 
274
- function renderSection(id: ProjectFormSectionId): ReactNode {
329
+ function renderSection(id: InternalSectionId): ReactNode {
275
330
  switch (id) {
276
331
  case "identity":
277
332
  return (
@@ -450,11 +505,17 @@ function parsePorts(raw: string): number[] {
450
505
  * actually shown get a line: a creation form that hides ports shouldn't recap
451
506
  * an empty "ports" row.
452
507
  */
453
- export function manifestRows(
454
- value: ProjectFormValue,
508
+ export function manifestRows<TValue extends ProjectFormValue>(
509
+ value: TValue,
455
510
  sections: ProjectFormSectionId[],
456
511
  /** Counted alongside the drafts — an existing secret is still a secret. */
457
- savedSecrets: ProjectSecretRef[] = []
512
+ savedSecrets: ProjectSecretRef[] = [],
513
+ /**
514
+ * App-provided sections. Their rows come after the built-in ones, in
515
+ * `sections` order — the built-in lines follow a fixed reading order rather
516
+ * than the form's, so there's no position to interleave them at.
517
+ */
518
+ customSections: ProjectFormCustomSection<TValue>[] = []
458
519
  ): ManifestRow[] {
459
520
  const shown = new Set(sections)
460
521
  const rows: ManifestRow[] = []
@@ -491,5 +552,13 @@ export function manifestRows(
491
552
  rows.push({ label: "secrets", value: `${n}`, done: n > 0 })
492
553
  }
493
554
 
555
+ if (customSections.length) {
556
+ const byId = new Map(customSections.filter((s) => !isInternalSectionId(s.id)).map((s) => [s.id, s]))
557
+ for (const id of sections) {
558
+ const row = byId.get(id)?.manifestRow?.(value)
559
+ if (row) rows.push(row)
560
+ }
561
+ }
562
+
494
563
  return rows
495
564
  }
@@ -8,6 +8,11 @@
8
8
  // payload on submit. A complete value is what makes the form controllable
9
9
  // without a single `?? ""` at every input.
10
10
 
11
+ import type { ComponentType, ReactNode } from "react"
12
+
13
+ import type { ManifestRow } from "./build-manifest"
14
+ import type { SectionAccent } from "./form-section"
15
+
11
16
  /** One repository to clone into the workspace. */
12
17
  export interface ProjectRepo {
13
18
  /** Client-side identity, stable across re-renders. Not the API's id. */
@@ -58,7 +63,25 @@ export interface ProjectSecretRef {
58
63
  name: string
59
64
  }
60
65
 
61
- /** Everything the form edits. Every field is present — see the note at the top. */
66
+ /**
67
+ * Everything the form edits. Every field is present — see the note at the top.
68
+ *
69
+ * **An app may carry more, and that is a supported contract, not a trick.** The
70
+ * form only ever patches at the spread (`onChange({ ...value, ...next })`), so
71
+ * any field it doesn't model travels through untouched. Declare an interface
72
+ * that extends this one and pass it to the form — `<ProjectForm<MyValue> …>`, or
73
+ * just let it infer from `value` — and every callback (`onChange`, `onSubmit`,
74
+ * and a custom section's `render` / `summary` / `manifestRow`) speaks *your*
75
+ * type, with no cast at the boundary:
76
+ *
77
+ * ```ts
78
+ * interface LiteFormValue extends ProjectFormValue { sharedVolumes: Volume[] }
79
+ * ```
80
+ *
81
+ * That's the whole extension story for the value, deliberately: a `custom`
82
+ * sub-bag would be a second place to put a field, and the fields apps actually
83
+ * add (a branch, a volume list) belong next to the others, not in a drawer.
84
+ */
62
85
  export interface ProjectFormValue {
63
86
  name: string
64
87
  description: string
@@ -77,7 +100,8 @@ export interface ProjectFormValue {
77
100
  dockerInDocker: boolean
78
101
  }
79
102
 
80
- export type ProjectFormSectionId =
103
+ /** The ten sections the package models and draws itself. */
104
+ export type InternalSectionId =
81
105
  | "identity"
82
106
  | "image"
83
107
  | "repositories"
@@ -89,6 +113,72 @@ export type ProjectFormSectionId =
89
113
  | "docker"
90
114
  | "secrets"
91
115
 
116
+ /**
117
+ * A section of the form: one of the ten above, or the id of a card the app
118
+ * brings itself through `customSections`.
119
+ *
120
+ * Open on purpose, and `(string & {})` rather than plain `string` so the ten
121
+ * known ids keep their autocompletion — a closed union meant an app with a field
122
+ * of its own had to either hang it under an unrelated section via `extras` or
123
+ * draw its own card outside the form, losing the numbering, the fold and the
124
+ * manifest along the way.
125
+ */
126
+ export type ProjectFormSectionId = InternalSectionId | (string & {})
127
+
128
+ /**
129
+ * A card the app draws itself, numbered and folded like the package's own.
130
+ *
131
+ * Same contract as an internal section, no more: `render` gets the value and a
132
+ * patch, and that's it. Nothing here fetches, nothing here holds state — this is
133
+ * a render callback that happens to be recognised by the form's chrome, not a
134
+ * plugin. What it buys over a hand-rolled `FormSection` rendered after the form
135
+ * is everything the chrome owns: its place in `sections` (inside the "Advanced
136
+ * options" fold if the app puts its id in `advancedSections`), its number in the
137
+ * shared sequence, its line in the fold's summary, and its row in the manifest.
138
+ *
139
+ * Generic over the value so an app that extends `ProjectFormValue` reads and
140
+ * patches its own fields without a cast (see the note on `ProjectFormValue`).
141
+ */
142
+ export interface ProjectFormCustomSection<TValue extends ProjectFormValue = ProjectFormValue> {
143
+ /** Used in `sections` / `advancedSections` / `extras`. Ignored if it shadows an internal id. */
144
+ id: string
145
+ title: string
146
+ hint?: string
147
+ icon?: ComponentType<{ className?: string }>
148
+ /** Defaults to `build`, like `FormSection` itself. */
149
+ accent?: SectionAccent
150
+ /** The card's body. `patch` merges into the value, exactly like an internal section. */
151
+ render: (value: TValue, patch: (next: Partial<TValue>) => void) => ReactNode
152
+ /**
153
+ * One short label — `2 volumes` — when this section holds something, `null`
154
+ * otherwise. Feeds the closed fold's summary, and with it the form's decision
155
+ * to start open. Skipping it means the fold can shut over a value the user
156
+ * typed, which is the failure the disclosure exists to prevent.
157
+ */
158
+ summary?: (value: TValue) => string | null
159
+ /** A line in the build manifest, or `null`. Appended after the built-in rows. */
160
+ manifestRow?: (value: TValue) => ManifestRow | null
161
+ }
162
+
163
+ /** Internal ids, as a runtime set — what tells a custom id from a built-in one. */
164
+ export const INTERNAL_SECTION_IDS: readonly InternalSectionId[] = [
165
+ "identity",
166
+ "image",
167
+ "repositories",
168
+ "features",
169
+ "extensions",
170
+ "lifecycle",
171
+ "ports",
172
+ "prewarm",
173
+ "docker",
174
+ "secrets",
175
+ ]
176
+
177
+ /** True for the ten sections the package draws itself. */
178
+ export function isInternalSectionId(id: ProjectFormSectionId): id is InternalSectionId {
179
+ return (INTERNAL_SECTION_IDS as readonly string[]).includes(id)
180
+ }
181
+
92
182
  /** Creation: the seven sections that matter before a project exists. */
93
183
  export const CREATE_SECTIONS: ProjectFormSectionId[] = [
94
184
  "identity",