@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,136 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { ChevronDownIcon, Settings2Icon } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+ import type { ProjectFormSectionId, ProjectFormValue } from "./types"
8
+
9
+ export interface AdvancedDisclosureProps {
10
+ open: boolean
11
+ onOpenChange: (open: boolean) => void
12
+ label?: string
13
+ hint?: string
14
+ /**
15
+ * What's configured inside, shown while it's closed. Empty → a "nothing set"
16
+ * line. This is what makes folding the sections away safe rather than sneaky.
17
+ */
18
+ summary?: string[]
19
+ children: ReactNode
20
+ className?: string
21
+ }
22
+
23
+ /**
24
+ * The fold that hides the sections most people never touch.
25
+ *
26
+ * Two rules make it honest. It **says what's inside while it's closed** — a
27
+ * summary of the configured items, not just a chevron — and the parent form
28
+ * **opens it on its own** when any of those sections already holds a value.
29
+ * Between them, no setting can ever be hidden behind a shut drawer: a fold that
30
+ * conceals configuration you didn't set yourself is how people end up debugging
31
+ * a worker for twenty minutes over a post-create command they can't see.
32
+ */
33
+ export function AdvancedDisclosure({
34
+ open,
35
+ onOpenChange,
36
+ label = "Advanced options",
37
+ hint = "Features, extensions, lifecycle commands, secrets — the environment beyond the base image.",
38
+ summary = [],
39
+ children,
40
+ className,
41
+ }: AdvancedDisclosureProps) {
42
+ return (
43
+ <div className={cn("space-y-4", className)}>
44
+ <button
45
+ type="button"
46
+ onClick={() => onOpenChange(!open)}
47
+ aria-expanded={open}
48
+ data-slot="advanced-disclosure"
49
+ className={cn(
50
+ // `items-start`: on a phone the hint wraps to three or four lines, and
51
+ // a vertically-centred icon then floats halfway down a paragraph it's
52
+ // supposed to label. Padding follows the form's width, like the sections.
53
+ "flex w-full cursor-pointer items-start gap-3 rounded-xl border border-dashed px-4 py-4 text-left transition-colors @min-[30rem]/form:px-5",
54
+ open ? "border-border bg-muted/30" : "border-border hover:border-muted-foreground/40 hover:bg-muted/30"
55
+ )}
56
+ >
57
+ <span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
58
+ <Settings2Icon className="h-4 w-4" />
59
+ </span>
60
+
61
+ <span className="min-w-0 flex-1">
62
+ <span className="block font-display text-base font-bold tracking-tight">{label}</span>
63
+ {/* While it's open the hint is redundant — the sections are right
64
+ there. While it's closed, the summary is the only trace of what
65
+ they hold, so it takes the line. */}
66
+ {open ? (
67
+ <span className="block text-xs text-muted-foreground">{hint}</span>
68
+ ) : summary.length > 0 ? (
69
+ <span className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1">
70
+ {summary.map((item) => (
71
+ <span
72
+ key={item}
73
+ className="inline-flex items-center rounded border border-primary/25 bg-primary/5 px-1.5 py-0.5 font-mono text-[10px] leading-none text-primary"
74
+ >
75
+ {item}
76
+ </span>
77
+ ))}
78
+ </span>
79
+ ) : (
80
+ // Clamped: closed and empty, this is a caption, and four lines of it
81
+ // on a phone is longer than the control it describes.
82
+ <span className="line-clamp-2 block text-xs text-muted-foreground">{hint}</span>
83
+ )}
84
+ </span>
85
+
86
+ <ChevronDownIcon
87
+ className={cn("mt-2 h-4 w-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")}
88
+ aria-hidden
89
+ />
90
+ </button>
91
+
92
+ {/* Unmounted rather than hidden: these sections carry inputs, and a
93
+ `display:none` fieldset still tab-focuses and still validates. */}
94
+ {open && children}
95
+ </div>
96
+ )
97
+ }
98
+
99
+ /**
100
+ * One short label per advanced section that holds something — `2 features`,
101
+ * `lifecycle`, `3 secrets`. Pure, and exported, because a caller rendering its
102
+ * own fold needs the same sentence.
103
+ */
104
+ export function advancedSummary(value: ProjectFormValue, sections: ProjectFormSectionId[]): string[] {
105
+ const out: string[] = []
106
+ for (const id of sections) {
107
+ switch (id) {
108
+ case "features":
109
+ if (value.features.length) out.push(`${value.features.length} feature${value.features.length > 1 ? "s" : ""}`)
110
+ break
111
+ case "extensions":
112
+ if (value.vscodeExtensions.length)
113
+ out.push(`${value.vscodeExtensions.length} extension${value.vscodeExtensions.length > 1 ? "s" : ""}`)
114
+ break
115
+ case "lifecycle":
116
+ if (value.postCreateCommand || value.postStartCommand) out.push("lifecycle")
117
+ break
118
+ case "ports":
119
+ if (value.forwardPorts.length) out.push(`ports ${value.forwardPorts.join(", ")}`)
120
+ break
121
+ case "prewarm":
122
+ if (value.prewarmImages.length) out.push(`${value.prewarmImages.length} prewarm`)
123
+ break
124
+ case "docker":
125
+ if (value.dockerInDocker) out.push("docker-in-docker")
126
+ break
127
+ case "secrets":
128
+ if (value.secrets.length) out.push(`${value.secrets.length} secret${value.secrets.length > 1 ? "s" : ""}`)
129
+ break
130
+ // Identity, image and repositories are never advanced — they're the form.
131
+ default:
132
+ break
133
+ }
134
+ }
135
+ return out
136
+ }
@@ -0,0 +1,116 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { CheckIcon, TerminalIcon } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+ import { Button } from "../button"
8
+
9
+ export interface ManifestRow {
10
+ label: string
11
+ value: string
12
+ /** Drawn as ticked. "Filled in", not "valid" — the form doesn't grade you. */
13
+ done?: boolean
14
+ }
15
+
16
+ export interface BuildManifestProps {
17
+ rows: ManifestRow[]
18
+ /** Rendered as the panel's primary action. Its `type="submit"` reaches the enclosing form. */
19
+ submitLabel?: ReactNode
20
+ submittingLabel?: ReactNode
21
+ submitting?: boolean
22
+ disabled?: boolean
23
+ /** Error banner above the button — a failed mutation, typically. */
24
+ error?: string | null
25
+ /** Small print under the button. */
26
+ caption?: ReactNode
27
+ /** Anything after the caption (an "or start from a template" link). */
28
+ footer?: ReactNode
29
+ title?: string
30
+ className?: string
31
+ }
32
+
33
+ /**
34
+ * The live recap of a long form, as a terminal-ish panel: one line per section,
35
+ * ticked as you fill it, then the submit button.
36
+ *
37
+ * It's a **mirror, not a source** — it reads values the form already owns and
38
+ * never writes any. That's what lets it be sticky next to a seven-section form
39
+ * without becoming a second copy of the state, and what lets an app drop it
40
+ * entirely (`manifest={false}`) and put its own footer instead.
41
+ */
42
+ export function BuildManifest({
43
+ rows,
44
+ submitLabel = "Save",
45
+ submittingLabel = "Saving…",
46
+ submitting = false,
47
+ disabled = false,
48
+ error,
49
+ caption,
50
+ footer,
51
+ title = "build manifest",
52
+ className,
53
+ }: BuildManifestProps) {
54
+ return (
55
+ <div
56
+ data-slot="build-manifest"
57
+ className={cn("overflow-hidden rounded-xl border border-border bg-card", className)}
58
+ >
59
+ {/* Window chrome in the three pillar colours — the same "this is a machine
60
+ artefact" cue as TerminalPanel, so the recap reads as the manifest a
61
+ worker will be built from rather than as a form sidebar. */}
62
+ <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2.5">
63
+ <div className="flex gap-1.5">
64
+ <span className="h-2.5 w-2.5 rounded-full bg-run/70" />
65
+ <span className="h-2.5 w-2.5 rounded-full bg-build/70" />
66
+ <span className="h-2.5 w-2.5 rounded-full bg-ship/70" />
67
+ </div>
68
+ <div className="ml-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
69
+ <TerminalIcon className="h-3.5 w-3.5" />
70
+ {title}
71
+ </div>
72
+ </div>
73
+
74
+ <div className="space-y-4 p-5">
75
+ <dl className="space-y-2.5">
76
+ {rows.map((row) => (
77
+ <div key={row.label} className="flex items-center gap-3 text-sm">
78
+ <span
79
+ className={cn(
80
+ "flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition-colors",
81
+ row.done ? "border-primary bg-primary text-background" : "border-border text-transparent"
82
+ )}
83
+ >
84
+ <CheckIcon className="h-2.5 w-2.5" strokeWidth={3} />
85
+ </span>
86
+ <dt className="w-16 shrink-0 font-mono text-[11px] tracking-wide text-muted-foreground uppercase">
87
+ {row.label}
88
+ </dt>
89
+ <dd className="min-w-0 flex-1 truncate text-right font-medium">{row.value}</dd>
90
+ </div>
91
+ ))}
92
+ </dl>
93
+
94
+ {error && (
95
+ <p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
96
+ {error}
97
+ </p>
98
+ )}
99
+
100
+ <Button type="submit" size="lg" disabled={submitting || disabled} className="group w-full">
101
+ {submitting ? (
102
+ <span className="flex items-center gap-2">
103
+ <span className="h-4 w-4 animate-spin rounded-full border-2 border-background/40 border-t-background" />
104
+ {submittingLabel}
105
+ </span>
106
+ ) : (
107
+ submitLabel
108
+ )}
109
+ </Button>
110
+
111
+ {caption && <p className="text-center text-[11px] text-muted-foreground">{caption}</p>}
112
+ {footer}
113
+ </div>
114
+ </div>
115
+ )
116
+ }
@@ -0,0 +1,169 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef, useState } from "react"
4
+ import { Loader2Icon, SearchIcon, XIcon } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+ import { ExtensionCard, ExtensionCardSkeleton } from "../devcontainer/extension-card"
8
+ import { extensionId } from "../devcontainer/registry"
9
+ import type { VscodeExtensionEntry } from "../devcontainer/types"
10
+ import { Input } from "../input"
11
+
12
+ export interface ExtensionPickerProps {
13
+ /** The ids a project stores — `publisher.name`. */
14
+ value: string[]
15
+ onChange: (value: string[]) => void
16
+ /**
17
+ * Searches a marketplace. A callback, not a fetch: the package never talks to
18
+ * open-vsx.org itself, and an app without one still gets a working picker
19
+ * where ids are typed by hand.
20
+ */
21
+ onSearch?: (query: string) => Promise<VscodeExtensionEntry[]>
22
+ /** Debounce before firing `onSearch`, ms. */
23
+ debounceMs?: number
24
+ className?: string
25
+ }
26
+
27
+ /**
28
+ * Pick the VS Code extensions installed into code-server: a marketplace search
29
+ * rendering `ExtensionCard`s, plus the picked list.
30
+ *
31
+ * The payloads of everything picked this session are kept around, so a selected
32
+ * extension keeps its icon and publisher in the list below instead of collapsing
33
+ * back to a bare id the moment the results are cleared — the project only ever
34
+ * stores the id, but the screen doesn't have to look like it.
35
+ */
36
+ export function ExtensionPicker({ value, onChange, onSearch, debounceMs = 300, className }: ExtensionPickerProps) {
37
+ const [query, setQuery] = useState("")
38
+ const [results, setResults] = useState<VscodeExtensionEntry[]>([])
39
+ const [known, setKnown] = useState<Record<string, VscodeExtensionEntry>>({})
40
+ const [loading, setLoading] = useState(false)
41
+ const [open, setOpen] = useState(false)
42
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
43
+ // Guards against an earlier, slower search landing after a later one and
44
+ // overwriting its results — the classic out-of-order autocomplete bug.
45
+ const runId = useRef(0)
46
+
47
+ useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])
48
+
49
+ function handleQuery(next: string) {
50
+ setQuery(next)
51
+ if (timer.current) clearTimeout(timer.current)
52
+ if (!next.trim() || !onSearch) {
53
+ setResults([])
54
+ setOpen(false)
55
+ return
56
+ }
57
+ setLoading(true)
58
+ setOpen(true)
59
+ const run = ++runId.current
60
+ timer.current = setTimeout(async () => {
61
+ let found: VscodeExtensionEntry[] = []
62
+ try {
63
+ found = await onSearch(next)
64
+ } catch {
65
+ found = []
66
+ }
67
+ if (run !== runId.current) return
68
+ setResults(found)
69
+ setLoading(false)
70
+ }, debounceMs)
71
+ }
72
+
73
+ function add(extension: VscodeExtensionEntry) {
74
+ const id = extensionId(extension)
75
+ if (!id) return
76
+ setKnown((prev) => ({ ...prev, [id]: extension }))
77
+ if (!value.includes(id)) onChange([...value, id])
78
+ reset()
79
+ }
80
+
81
+ function addTyped() {
82
+ const id = query.trim()
83
+ if (!id || value.includes(id)) return
84
+ onChange([...value, id])
85
+ reset()
86
+ }
87
+
88
+ function reset() {
89
+ setQuery("")
90
+ setResults([])
91
+ setOpen(false)
92
+ }
93
+
94
+ return (
95
+ <div className={cn("space-y-2", className)}>
96
+ <div>
97
+ <div className="flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5">
98
+ <SearchIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
99
+ <input
100
+ type="text"
101
+ value={query}
102
+ onChange={(e) => handleQuery(e.target.value)}
103
+ onKeyDown={(e) => {
104
+ if (e.key === "Enter") { e.preventDefault(); addTyped() }
105
+ if (e.key === "Escape") setOpen(false)
106
+ }}
107
+ onFocus={() => results.length > 0 && setOpen(true)}
108
+ placeholder={onSearch ? "Search extensions or type an ID…" : "Type an extension ID…"}
109
+ aria-label="Extension search"
110
+ className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none"
111
+ />
112
+ {loading && <Loader2Icon className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />}
113
+ </div>
114
+
115
+ {/* Results in the flow rather than in a popover: this sits inside a
116
+ section card that clips its overflow, and a floating list gets cut
117
+ at the card's bottom edge. Pushing the form down costs nothing. */}
118
+ {open && (results.length > 0 || loading) && (
119
+ <div className="mt-1 space-y-1 rounded-md border border-border bg-muted/20 p-1">
120
+ {loading && results.length === 0
121
+ ? [0, 1, 2].map((i) => <ExtensionCardSkeleton key={i} />)
122
+ : results.map((extension) => {
123
+ const id = extensionId(extension)
124
+ const already = value.includes(id)
125
+ return (
126
+ <ExtensionCard
127
+ key={id}
128
+ extension={extension}
129
+ selected={already}
130
+ disabled={already}
131
+ onSelect={() => add(extension)}
132
+ className="border-transparent bg-card"
133
+ />
134
+ )
135
+ })}
136
+ </div>
137
+ )}
138
+ </div>
139
+
140
+ {value.length > 0 && (
141
+ <div className="space-y-1.5">
142
+ {value.map((id) => (
143
+ <ExtensionCard
144
+ key={id}
145
+ extension={known[id] ?? { id }}
146
+ custom={!known[id] && !id.includes(".")}
147
+ action={
148
+ <button
149
+ type="button"
150
+ aria-label={`Remove ${id}`}
151
+ onClick={() => onChange(value.filter((e) => e !== id))}
152
+ className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
153
+ >
154
+ <XIcon className="h-3.5 w-3.5" />
155
+ </button>
156
+ }
157
+ />
158
+ ))}
159
+ </div>
160
+ )}
161
+
162
+ {value.length > 0 && (
163
+ <p className="text-xs text-muted-foreground">
164
+ {value.length} extension{value.length > 1 ? "s" : ""} — installed at worker startup via code-server
165
+ </p>
166
+ )}
167
+ </div>
168
+ )
169
+ }
@@ -0,0 +1,193 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { PlusIcon, XIcon } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+ import { FeatureCard } from "../devcontainer/feature-card"
8
+ import { FeatureTile } from "../devcontainer/feature-tile"
9
+ import { parseRegistryRef } from "../devcontainer/registry"
10
+ import type { DevcontainerFeatureEntry } from "../devcontainer/types"
11
+ import { Button } from "../button"
12
+ import { Input } from "../input"
13
+ import type { ProjectFeatureSelection } from "./types"
14
+
15
+ export interface FeaturePickerProps {
16
+ features?: DevcontainerFeatureEntry[]
17
+ value: ProjectFeatureSelection[]
18
+ onChange: (value: ProjectFeatureSelection[]) => void
19
+ /**
20
+ * `chips` (default) — the catalog as tag-sized entries that flow and wrap,
21
+ * with the cards of what you picked beside them. `cards` — every entry as a
22
+ * full card, which only stays readable while the catalog is short.
23
+ */
24
+ layout?: "chips" | "cards"
25
+ /** Where the picked cards go. `auto` splits above ~52rem. */
26
+ detail?: "auto" | "side" | "below"
27
+ className?: string
28
+ }
29
+
30
+ /**
31
+ * Pick the devcontainer features: the catalog on one side, what you've composed
32
+ * on the other.
33
+ *
34
+ * Same treatment as `ImagePicker`, with one structural difference — features are
35
+ * **multi-select**, so the panel can't be "the card of the selected one". It's
36
+ * the list of everything picked, in install order, each with its option chips,
37
+ * its author and its OCI reference, and a cross to drop it. Hand-typed refs land
38
+ * in that same list rather than in a second one underneath, because from the
39
+ * user's side they're the same thing: a feature that will be installed.
40
+ *
41
+ * Selection order is preserved — features install in the order they're listed,
42
+ * so a `Set` would quietly lose information here.
43
+ */
44
+ export function FeaturePicker({
45
+ features = [],
46
+ value,
47
+ onChange,
48
+ layout = "chips",
49
+ detail: detailPosition = "auto",
50
+ className,
51
+ }: FeaturePickerProps) {
52
+ const [customInput, setCustomInput] = useState("")
53
+
54
+ const isSelected = (id?: string) => !!id && value.some((f) => f.id === id)
55
+ const catalogById = new Map(features.filter((f) => f.id).map((f) => [f.id!, f]))
56
+
57
+ function toggle(feature: DevcontainerFeatureEntry) {
58
+ const id = feature.id
59
+ if (!id) return
60
+ onChange(isSelected(id) ? value.filter((f) => f.id !== id) : [...value, { id }])
61
+ }
62
+
63
+ function addCustom() {
64
+ const ociRef = customInput.trim()
65
+ if (!ociRef || value.some((f) => f.ociRef === ociRef)) return
66
+ // Same id shape the dashboard has always written, so an existing project
67
+ // round-trips unchanged: "ghcr.io/foo/features/mytool:1" → "custom-mytool".
68
+ const id = `custom-${parseRegistryRef(ociRef).name ?? ociRef}`
69
+ onChange([...value, { id, ociRef }])
70
+ setCustomInput("")
71
+ }
72
+
73
+ const customField = (
74
+ <div className="flex gap-2">
75
+ <Input
76
+ value={customInput}
77
+ aria-label="Custom OCI reference"
78
+ placeholder="Custom OCI ref, e.g. ghcr.io/foo/features/mytool:1"
79
+ className="flex-1"
80
+ onChange={(e) => setCustomInput(e.target.value)}
81
+ onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addCustom())}
82
+ />
83
+ <Button type="button" variant="outline" onClick={addCustom}>
84
+ <PlusIcon className="mr-1 h-3.5 w-3.5" />
85
+ Add
86
+ </Button>
87
+ </div>
88
+ )
89
+
90
+ /** One picked feature — catalog entry or hand-typed ref — with its remove cross. */
91
+ const picked = value.map((selection) => {
92
+ const entry = catalogById.get(selection.id)
93
+ return (
94
+ <FeatureCard
95
+ key={selection.id}
96
+ feature={entry ?? { id: selection.id, ociRef: selection.ociRef }}
97
+ custom={!entry}
98
+ copyable
99
+ options={selection.options}
100
+ action={
101
+ <button
102
+ type="button"
103
+ aria-label={`Remove ${entry?.label ?? selection.ociRef ?? selection.id}`}
104
+ onClick={() => onChange(value.filter((f) => f.id !== selection.id))}
105
+ className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
106
+ >
107
+ <XIcon className="h-3.5 w-3.5" />
108
+ </button>
109
+ }
110
+ />
111
+ )
112
+ })
113
+
114
+ if (layout === "cards") {
115
+ return (
116
+ <div className={cn("@container/picker space-y-2", className)}>
117
+ <div className="grid grid-cols-1 gap-2 @min-[34rem]/picker:grid-cols-2">
118
+ {features.map((feature) => (
119
+ <FeatureCard
120
+ key={feature.id ?? feature.ociRef}
121
+ feature={feature}
122
+ copyable
123
+ options={value.find((f) => f.id === feature.id)?.options}
124
+ selected={isSelected(feature.id)}
125
+ onSelect={() => toggle(feature)}
126
+ />
127
+ ))}
128
+ </div>
129
+ {customField}
130
+ {value.length > 0 && <Count n={value.length} />}
131
+ </div>
132
+ )
133
+ }
134
+
135
+ // A CONTAINER query, so the split depends on the picker's own width and never
136
+ // on the window's. Stacked is the same reading order, just vertical.
137
+ const split = {
138
+ auto: "@min-[52rem]/picker:grid-cols-[1fr_minmax(18rem,22rem)] @min-[52rem]/picker:items-start",
139
+ // Even "forced" has a floor: below ~34rem two columns simply don't fit, and
140
+ // the panel would push the page into horizontal scrolling. `side` means "as
141
+ // soon as it fits", not "always".
142
+ side: "@min-[34rem]/picker:grid-cols-[1fr_minmax(15rem,22rem)] @min-[34rem]/picker:items-start",
143
+ below: "",
144
+ }[detailPosition]
145
+
146
+ return (
147
+ <div className={cn("@container/picker", className)}>
148
+ <div className={cn("grid gap-3", split)}>
149
+ <div className="space-y-3">
150
+ <div className="flex flex-wrap content-start gap-1.5">
151
+ {features.map((feature) => (
152
+ <FeatureTile
153
+ key={feature.id ?? feature.ociRef}
154
+ feature={feature}
155
+ selected={isSelected(feature.id)}
156
+ onSelect={() => toggle(feature)}
157
+ />
158
+ ))}
159
+ </div>
160
+ {customField}
161
+ </div>
162
+
163
+ <div className="space-y-1.5">
164
+ {picked.length > 0 ? (
165
+ <>
166
+ {picked}
167
+ <Count n={value.length} />
168
+ </>
169
+ ) : (
170
+ // A placeholder rather than nothing: without it the whole section
171
+ // reflows on the first pick and jumps under the cursor.
172
+ <div
173
+ className={cn(
174
+ "min-h-[7.5rem] items-center justify-center rounded-lg border border-dashed border-border p-4 text-center text-xs text-muted-foreground",
175
+ detailPosition === "side" ? "hidden @min-[34rem]/picker:flex" : "hidden @min-[52rem]/picker:flex"
176
+ )}
177
+ >
178
+ Nothing selected — the base image ships as-is.
179
+ </div>
180
+ )}
181
+ </div>
182
+ </div>
183
+ </div>
184
+ )
185
+ }
186
+
187
+ function Count({ n }: { n: number }) {
188
+ return (
189
+ <p className="text-xs text-muted-foreground">
190
+ {n} feature{n > 1 ? "s" : ""} selected — installed at worker startup
191
+ </p>
192
+ )
193
+ }