@spunto/design-system 0.11.1 → 0.13.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.
@@ -0,0 +1,40 @@
1
+ // The panel's footer: a few label → value lines, no border, no title.
2
+ //
3
+ // The two apps put different things here (the dashboard "Nodes online", Lite
4
+ // "Runtime: Docker · local"), so this is a plain list of items rather than a
5
+ // fixed set of fields — only "Created" is filled in by `ProjectPanel` itself.
6
+
7
+ import { cn } from "../../utils"
8
+ import type { ProjectPanelMetaItem } from "./panel-types"
9
+
10
+ const TONES = {
11
+ default: "text-muted-foreground",
12
+ ok: "font-medium text-green-600 dark:text-green-400",
13
+ muted: "text-muted-foreground/60",
14
+ } as const
15
+
16
+ export interface PanelMetaProps {
17
+ items: ProjectPanelMetaItem[]
18
+ className?: string
19
+ }
20
+
21
+ /** One line per metadata item: icon + label on the left, value on the right. */
22
+ export function PanelMeta({ items, className }: PanelMetaProps) {
23
+ if (!items.length) return null
24
+
25
+ return (
26
+ <div data-slot="panel-meta" className={cn("space-y-1.5", className)}>
27
+ {items.map((item) => (
28
+ <div key={item.id} className="flex items-center justify-between gap-2 text-[11px]">
29
+ <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground/60">
30
+ {item.icon}
31
+ <span className="truncate">{item.label}</span>
32
+ </span>
33
+ <span className={cn("min-w-0 truncate text-right", TONES[item.tone ?? "default"])}>
34
+ {item.value}
35
+ </span>
36
+ </div>
37
+ ))}
38
+ </div>
39
+ )
40
+ }
@@ -0,0 +1,68 @@
1
+ // The panel's structural shell: one band per topic, with its uppercase label.
2
+ //
3
+ // This is the motif that was copy-pasted ~12 times per app (`px-4 py-3` +
4
+ // `border-b border-border/60` + a `text-[10px] uppercase` paragraph). Lite had
5
+ // already extracted the label as a local `Eyebrow`; the dashboard rewrote it by
6
+ // hand in every section. Both are this.
7
+
8
+ import type { ReactNode } from "react"
9
+
10
+ import { cn } from "../../utils"
11
+
12
+ export interface EyebrowProps {
13
+ /** Small glyph before the label — 3×3 (`h-3 w-3`) to sit on the cap height. */
14
+ icon?: ReactNode
15
+ children: ReactNode
16
+ className?: string
17
+ }
18
+
19
+ /** The uppercase label above a section's content. */
20
+ export function Eyebrow({ icon, children, className }: EyebrowProps) {
21
+ return (
22
+ <p
23
+ data-slot="eyebrow"
24
+ className={cn(
25
+ "text-[10px] font-semibold tracking-wider text-muted-foreground/60 uppercase",
26
+ icon && "flex items-center gap-1.5",
27
+ className
28
+ )}
29
+ >
30
+ {icon}
31
+ {children}
32
+ </p>
33
+ )
34
+ }
35
+
36
+ export interface PanelSectionProps {
37
+ /** Rendered as an `Eyebrow`. Absent → the section is just a bordered band. */
38
+ title?: ReactNode
39
+ icon?: ReactNode
40
+ /**
41
+ * Right-hand affordance on the title row — a "Manage" link, a "Pre-build"
42
+ * button. Anything that calls an API stays the caller's business.
43
+ */
44
+ action?: ReactNode
45
+ /** Last section of a panel → no bottom border (the card's edge closes it). */
46
+ last?: boolean
47
+ children?: ReactNode
48
+ className?: string
49
+ }
50
+
51
+ /**
52
+ * One band of the project panel. Keeps the header row and the content spaced
53
+ * consistently whether or not there is an action on the right, so a section
54
+ * with a "Manage" link lines up with one without.
55
+ */
56
+ export function PanelSection({ title, icon, action, last = false, children, className }: PanelSectionProps) {
57
+ return (
58
+ <div data-slot="panel-section" className={cn("px-4 py-3", !last && "border-b border-border/60", className)}>
59
+ {(title || action) && (
60
+ <div className={cn("mb-2 flex min-w-0 items-center justify-between gap-2", !title && "justify-end")}>
61
+ {title && <Eyebrow icon={icon}>{title}</Eyebrow>}
62
+ {action}
63
+ </div>
64
+ )}
65
+ {children}
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,101 @@
1
+ // Structural, deliberately laxist shapes for what the project PANEL reads.
2
+ //
3
+ // Separate from `types.ts`, which holds `ProjectFormValue` & co: the form owns a
4
+ // *complete* value it edits (every field present, never undefined), the panel
5
+ // reads a *partial* snapshot it merely displays. Same domain, opposite
6
+ // invariants — merging them would force one of the two to lie.
7
+ //
8
+ // Same rule as the worker types: the design system must NOT import the
9
+ // OpenAPI-generated types of the Spunto dashboard, nor Spunto Lite's
10
+ // `lib/types.ts` — the two model a project differently. A repository carries a
11
+ // `project` slug on the dashboard and a full `cloneUrl` on Lite;
12
+ // `deployPublicKey` is a `(project as any)` cast on the dashboard and a real
13
+ // field on Lite; `currentVersion` is optional on one side and guaranteed on the
14
+ // other. So the shapes below describe the *minimum* these components read,
15
+ // everything optional but `name` / `image` / `createdAt`, and an unknown value
16
+ // (an unheard-of `state`, a missing repo slug) degrades instead of crashing.
17
+
18
+ import type { ReactNode } from "react"
19
+
20
+ /** One repository cloned into a worker's `/workspace`. */
21
+ export interface ProjectPanelRepository {
22
+ id: string
23
+ /** `owner/repo` slug — how the dashboard names a GitHub repository. */
24
+ project?: string | null
25
+ /** Full clone URL (SSH or HTTPS) — how Lite names a generic "git" repository. */
26
+ cloneUrl?: string | null
27
+ /** Folder under `/workspace` the repo is checked out into. */
28
+ workspacePath: string
29
+ }
30
+
31
+ /** The project snapshot a `ProjectPanel` draws. */
32
+ export interface ProjectPanelProject {
33
+ name: string
34
+ description?: string | null
35
+ /** `created` | `creating` | `ready` | `updating` | `error`… Absent → no badge. */
36
+ state?: string | null
37
+ /** Base image reference — only its last segment is displayed. */
38
+ image: string
39
+ /** Current config version → the `vN` of the stats row. */
40
+ currentVersion?: number
41
+ /** ISO date → the "Created" line of the footer. */
42
+ createdAt: string
43
+ repositories?: ProjectPanelRepository[]
44
+ /** devcontainer features, by full id (`ghcr.io/devcontainers/features/node:1`). */
45
+ features?: { id: string }[]
46
+ vscodeExtensions?: string[]
47
+ forwardPorts?: (number | string)[]
48
+ postCreateCommand?: string | null
49
+ postStartCommand?: string | null
50
+ /** Public half of the project's SSH deploy key (RFC 0013). */
51
+ deployPublicKey?: string | null
52
+ }
53
+
54
+ /** A secret, by NAME only — a value never reaches the design system. */
55
+ export interface ProjectSecretMeta {
56
+ id: string
57
+ name: string
58
+ }
59
+
60
+ /**
61
+ * One target of the pre-build cache: a BYOC node on the dashboard, the single
62
+ * local Docker daemon on Lite.
63
+ */
64
+ export interface ProjectBuildTarget {
65
+ id: string
66
+ label: string
67
+ /** `building` | `ready` | `error`. Absent/unknown → "not built". */
68
+ state?: "building" | "ready" | "error" | string | null
69
+ /** Absent → the row is inert (Lite has no build log to open). */
70
+ onSelect?: () => void
71
+ }
72
+
73
+ /** An external account the project can be connected to (GitHub, GCP…). */
74
+ export interface ProjectIntegration {
75
+ id: string
76
+ label: string
77
+ /** The provider's logo/glyph — a slot, the design system ships no brand icons. */
78
+ icon?: ReactNode
79
+ connected?: boolean
80
+ /** Target of the "Connect" affordance when not connected. */
81
+ connectHref?: string
82
+ }
83
+
84
+ /** One entry of the version history. */
85
+ export interface ProjectVersionEntry {
86
+ id: string
87
+ version: number
88
+ createdAt: string
89
+ /** Image of *that* version — shown under the date, last segment only. */
90
+ image?: string
91
+ }
92
+
93
+ /** One line of the panel's footer: icon, label, value. */
94
+ export interface ProjectPanelMetaItem {
95
+ id: string
96
+ label: string
97
+ value: ReactNode
98
+ icon?: ReactNode
99
+ /** `ok` greens the value (a positive count), `muted` dims it. */
100
+ tone?: "default" | "ok" | "muted"
101
+ }
@@ -0,0 +1,330 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { CalendarIcon, KeyRoundIcon, SquarePenIcon, ZapIcon } from "lucide-react"
5
+
6
+ import { cn, formatShortDate } from "../../utils"
7
+ // The catalog surface already turns a registry reference into a logo and a
8
+ // readable ref — the panel shows the same image, so it reuses them instead of
9
+ // re-deriving its own.
10
+ import { RegistryRef } from "../devcontainer/registry-ref"
11
+ import { resolveStackMark, StackMark } from "../devcontainer/stack-mark"
12
+ import { buttonVariants } from "../button-variants"
13
+ import type { LinkRender } from "../workers/worker-card"
14
+ import { BuildCacheList, PrebuildButton } from "./build-cache-list"
15
+ import { ExtensionChips, FeatureChips, PortChips, SecretChips } from "./chip-list"
16
+ import { DeployKeySection } from "./deploy-key-section"
17
+ import { IntegrationsList } from "./integrations-list"
18
+ import { LifecycleCommands } from "./lifecycle-commands"
19
+ import { PanelMeta } from "./panel-meta"
20
+ import { PanelSection } from "./panel-section"
21
+ import { ProjectStatePill } from "./project-state"
22
+ import { RepositoryList } from "./repository-list"
23
+ import type {
24
+ ProjectBuildTarget,
25
+ ProjectIntegration,
26
+ ProjectPanelMetaItem,
27
+ ProjectPanelProject,
28
+ ProjectSecretMeta,
29
+ ProjectVersionEntry,
30
+ } from "./panel-types"
31
+ import { VersionHistory } from "./version-history"
32
+
33
+ export interface ProjectPanelProps {
34
+ project: ProjectPanelProject
35
+
36
+ /** Worker counts of the stats row. Absent → the row shows only `vN`, or nothing. */
37
+ stats?: { running: number; total: number }
38
+
39
+ /** Target of the header's "Edit" button. Absent → no button. */
40
+ editHref?: string
41
+ editLabel?: string
42
+ /** Target of the "Manage" link of the Secrets section. */
43
+ secretsHref?: string
44
+ manageLabel?: string
45
+ /**
46
+ * Escape hatch for links. `link` receives `{ href, className, children }` and
47
+ * must return an anchor-ish node (`next/link`, a router link…); without it the
48
+ * panel falls back to a plain `<a>`. Same slot as `WorkerCard` — the package
49
+ * never imports `next/link`, it is also consumed by a Vite app.
50
+ */
51
+ render?: { link?: LinkRender }
52
+
53
+ /** Secret NAMES. `[]` renders the empty state, `undefined` hides the section. */
54
+ secrets?: ProjectSecretMeta[]
55
+
56
+ /** Build cache targets: BYOC nodes on the dashboard, one local daemon on Lite. */
57
+ buildTargets?: ProjectBuildTarget[]
58
+ onPrebuild?: () => void
59
+ prebuilding?: boolean
60
+
61
+ integrations?: ProjectIntegration[]
62
+
63
+ versions?: ProjectVersionEntry[]
64
+ /** Restoring is a mutation → the app's. The callback gets the whole entry. */
65
+ onRestoreVersion?: (version: ProjectVersionEntry) => void
66
+ restoringVersionId?: string | null
67
+ versionsDefaultOpen?: boolean
68
+ /** Inline error of the version history (a failed restore). */
69
+ error?: string | null
70
+
71
+ /** Registration guidance under the deploy key — differs per app, hence a slot. */
72
+ deployKeyHelp?: ReactNode
73
+
74
+ /** Extra footer lines. "Created" is always first, from `project.createdAt`. */
75
+ meta?: ProjectPanelMetaItem[]
76
+
77
+ /** Sections an app has and the others don't, appended before the footer. */
78
+ children?: ReactNode
79
+ className?: string
80
+ }
81
+
82
+ /**
83
+ * The left-hand panel of a project page: identity, image, repositories, deploy
84
+ * key, features, extensions, build cache, ports, lifecycle, secrets,
85
+ * integrations, version history, footer.
86
+ *
87
+ * **Purely presentational.** Hand it a project plus slots and it draws — no
88
+ * query, no mutation, no router. Everything that would call an API is a
89
+ * callback (`onPrebuild`, `onRestoreVersion`) or a link the app renders
90
+ * (`editHref` + `render.link`). That is what makes the same panel renderable by
91
+ * the dashboard, by Spunto Lite and by the showcase with hand-written fixtures.
92
+ *
93
+ * The layout is a fixed sequence of optional bands, each disappearing when its
94
+ * data is absent: a project with no repository, no feature and no secret
95
+ * collapses to a header plus an image without a single caller-side conditional.
96
+ *
97
+ * The card is the panel's business; **its placement is not** — the
98
+ * `<aside className="w-full lg:w-72 lg:sticky …">` that pins it in the page grid
99
+ * stays in the app.
100
+ */
101
+ export function ProjectPanel({
102
+ project,
103
+ stats,
104
+ editHref,
105
+ editLabel = "Edit",
106
+ secretsHref,
107
+ manageLabel = "Manage",
108
+ render,
109
+ secrets,
110
+ buildTargets,
111
+ onPrebuild,
112
+ prebuilding = false,
113
+ integrations,
114
+ versions,
115
+ onRestoreVersion,
116
+ restoringVersionId,
117
+ versionsDefaultOpen = false,
118
+ error,
119
+ deployKeyHelp,
120
+ meta,
121
+ children,
122
+ className,
123
+ }: ProjectPanelProps) {
124
+ const Link: LinkRender =
125
+ render?.link ?? (({ href, className: cls, children: kids }) => <a href={href} className={cls}>{kids}</a>)
126
+
127
+ const imageMark = resolveStackMark(project.image)
128
+ const repositories = project.repositories ?? []
129
+ const features = project.features ?? []
130
+ const extensions = project.vscodeExtensions ?? []
131
+ const ports = project.forwardPorts ?? []
132
+ const showBuildCache = !!buildTargets?.length || (!!buildTargets && !!onPrebuild)
133
+ const showStats = !!stats || project.currentVersion != null
134
+
135
+ const metaItems: ProjectPanelMetaItem[] = [
136
+ {
137
+ id: "created",
138
+ label: "Created",
139
+ icon: <CalendarIcon className="h-3 w-3 shrink-0" />,
140
+ value: formatShortDate(project.createdAt),
141
+ },
142
+ ...(meta ?? []),
143
+ ]
144
+
145
+ return (
146
+ <div
147
+ data-slot="project-panel"
148
+ // `@container`: the few responsive rules below are container queries, never
149
+ // viewport ones. This panel is a 288 px sidebar column on a desktop and a
150
+ // full-width block on a phone — and the desktop case is the *narrow* one.
151
+ className={cn(
152
+ "@container overflow-hidden rounded-xl border border-border bg-card shadow-sm",
153
+ className
154
+ )}
155
+ >
156
+ {/* ── Header : identité, état, Edit, ligne de stats ── */}
157
+ <div className="border-b border-border/60 px-4 pt-4 pb-3">
158
+ <div className="flex items-start justify-between gap-2">
159
+ <div className="min-w-0">
160
+ {/* A project name is one unbreakable token more often than not. */}
161
+ <p className="truncate text-sm leading-tight font-semibold" title={project.name}>
162
+ {project.name}
163
+ </p>
164
+ {project.description && (
165
+ <p className="mt-1 text-xs leading-relaxed text-muted-foreground">{project.description}</p>
166
+ )}
167
+ </div>
168
+ <div className="flex shrink-0 items-center gap-1">
169
+ {project.state && <ProjectStatePill state={project.state} />}
170
+ {editHref && (
171
+ <Link
172
+ href={editHref}
173
+ className={cn(
174
+ buttonVariants({ variant: "ghost", size: "sm" }),
175
+ "h-7 gap-1.5 px-2 text-xs text-muted-foreground hover:text-foreground"
176
+ )}
177
+ >
178
+ <SquarePenIcon className="h-3 w-3" />
179
+ {/* Below ~256 px the state pill plus a labelled button push the
180
+ name down to nothing — the pencil alone still reads. */}
181
+ <span className="@max-[16rem]:hidden">{editLabel}</span>
182
+ </Link>
183
+ )}
184
+ </div>
185
+ </div>
186
+
187
+ {showStats && (
188
+ <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1">
189
+ {stats && (
190
+ <>
191
+ <span className="flex items-center gap-1.5">
192
+ <span className={cn("h-2 w-2 rounded-full", stats.running > 0 ? "bg-green-500" : "bg-muted-foreground/40")} />
193
+ <span className="text-xs text-muted-foreground">
194
+ <span className="font-medium text-foreground">{stats.running}</span> running
195
+ </span>
196
+ </span>
197
+ <Separator />
198
+ <span className="text-xs text-muted-foreground">
199
+ <span className="font-medium text-foreground">{stats.total}</span> total
200
+ </span>
201
+ </>
202
+ )}
203
+ {project.currentVersion != null && (
204
+ <>
205
+ {stats && <Separator />}
206
+ <span className="font-mono text-xs text-muted-foreground">v{project.currentVersion}</span>
207
+ </>
208
+ )}
209
+ </div>
210
+ )}
211
+ </div>
212
+
213
+ <PanelSection title="Image">
214
+ {/* The base image, drawn like the catalog draws it: the runtime's own
215
+ mark (derived from the ref, never fetched) and the reference with its
216
+ host dimmed — so the eye lands on `name:tag`, the part that actually
217
+ varies. Copyable, because pasting it into a devcontainer.json is the
218
+ one thing you want to do with it. */}
219
+ <div className="flex min-w-0 items-center gap-2 rounded-lg bg-muted/50 px-3 py-2">
220
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
221
+ <StackMark name={imageMark} className="h-4 w-4" />
222
+ </span>
223
+ <RegistryRef value={project.image} copyable className="min-w-0 text-[11px]" />
224
+ </div>
225
+ </PanelSection>
226
+
227
+ {repositories.length > 0 && (
228
+ <PanelSection title="Repositories">
229
+ <RepositoryList repositories={repositories} />
230
+ </PanelSection>
231
+ )}
232
+
233
+ {project.deployPublicKey && (
234
+ <PanelSection title="Deploy key" icon={<KeyRoundIcon className="h-3 w-3" />}>
235
+ <DeployKeySection publicKey={project.deployPublicKey}>{deployKeyHelp}</DeployKeySection>
236
+ </PanelSection>
237
+ )}
238
+
239
+ {features.length > 0 && (
240
+ <PanelSection title="Features">
241
+ <FeatureChips features={features} />
242
+ </PanelSection>
243
+ )}
244
+
245
+ {extensions.length > 0 && (
246
+ <PanelSection title="Extensions">
247
+ <ExtensionChips extensions={extensions} />
248
+ </PanelSection>
249
+ )}
250
+
251
+ {showBuildCache && (
252
+ <PanelSection
253
+ title="Build cache"
254
+ icon={<ZapIcon className="h-3 w-3" />}
255
+ action={onPrebuild && <PrebuildButton onClick={onPrebuild} pending={prebuilding} />}
256
+ >
257
+ <BuildCacheList targets={buildTargets ?? []} />
258
+ </PanelSection>
259
+ )}
260
+
261
+ {ports.length > 0 && (
262
+ <PanelSection title="Forwarded ports">
263
+ <PortChips ports={ports} />
264
+ </PanelSection>
265
+ )}
266
+
267
+ {(project.postCreateCommand || project.postStartCommand) && (
268
+ <PanelSection title="Lifecycle">
269
+ <LifecycleCommands
270
+ postCreateCommand={project.postCreateCommand}
271
+ postStartCommand={project.postStartCommand}
272
+ />
273
+ </PanelSection>
274
+ )}
275
+
276
+ {secrets && (
277
+ <PanelSection
278
+ title="Secrets"
279
+ icon={<KeyRoundIcon className="h-3 w-3" />}
280
+ action={
281
+ secretsHref && (
282
+ <Link
283
+ href={secretsHref}
284
+ className="shrink-0 text-[10px] text-primary transition-colors hover:text-primary/80"
285
+ >
286
+ {manageLabel}
287
+ </Link>
288
+ )
289
+ }
290
+ >
291
+ <SecretChips secrets={secrets} />
292
+ </PanelSection>
293
+ )}
294
+
295
+ {!!integrations?.length && (
296
+ <PanelSection title="Integrations">
297
+ <IntegrationsList integrations={integrations} link={render?.link} />
298
+ </PanelSection>
299
+ )}
300
+
301
+ {!!versions?.length && (
302
+ <PanelSection>
303
+ <VersionHistory
304
+ versions={versions}
305
+ currentVersion={project.currentVersion}
306
+ onRestore={onRestoreVersion}
307
+ restoringVersionId={restoringVersionId}
308
+ error={error}
309
+ defaultOpen={versionsDefaultOpen}
310
+ />
311
+ </PanelSection>
312
+ )}
313
+
314
+ {children}
315
+
316
+ <PanelSection last>
317
+ <PanelMeta items={metaItems} />
318
+ </PanelSection>
319
+ </div>
320
+ )
321
+ }
322
+
323
+ /**
324
+ * The "·" between two stats. Hidden below ~272 px: the row is two lines deep by
325
+ * then, and a separator that ends up alone at the end of a line points at
326
+ * nothing (same reasoning as `WorkerCard`'s meta line).
327
+ */
328
+ function Separator() {
329
+ return <span className="text-border @max-[17rem]:hidden">·</span>
330
+ }
@@ -0,0 +1,47 @@
1
+ // The project's lifecycle state, as a pill.
2
+ //
3
+ // Replaces the `stateColors` map both apps kept inline, in raw Tailwind palette
4
+ // classes with hand-written `dark:` variants. Same semantics as the worker
5
+ // statuses (green = healthy, yellow = in flight, red = failure, neutral =
6
+ // nothing happening), and errors go through the `destructive` token rather than
7
+ // a raw red so they track the theme.
8
+ //
9
+ // No "use client": a pure table plus a hookless component.
10
+
11
+ import { cn } from "../../utils"
12
+
13
+ export type ProjectStateKey = "created" | "creating" | "ready" | "updating" | "error"
14
+
15
+ const NEUTRAL = "bg-muted text-muted-foreground border-border"
16
+
17
+ export const projectStateConfig: Record<ProjectStateKey, string> = {
18
+ created: NEUTRAL,
19
+ creating: "bg-yellow-500/10 text-yellow-700 border-yellow-500/20 dark:text-yellow-400",
20
+ ready: "bg-green-500/10 text-green-700 border-green-500/20 dark:text-green-400",
21
+ updating: "bg-primary/10 text-primary border-primary/20",
22
+ error: "bg-destructive/10 text-destructive border-destructive/20",
23
+ }
24
+
25
+ export interface ProjectStatePillProps {
26
+ /** Raw state string. Unknown values render neutral instead of throwing. */
27
+ state: string
28
+ label?: string
29
+ className?: string
30
+ }
31
+
32
+ /** The state chip next to the project name. */
33
+ export function ProjectStatePill({ state, label, className }: ProjectStatePillProps) {
34
+ const tone = projectStateConfig[state as ProjectStateKey] ?? NEUTRAL
35
+ return (
36
+ <span
37
+ data-slot="project-state-pill"
38
+ className={cn(
39
+ "inline-flex h-5 shrink-0 items-center rounded-full border px-2 text-[10px] leading-none font-medium capitalize",
40
+ tone,
41
+ className
42
+ )}
43
+ >
44
+ {label ?? state}
45
+ </span>
46
+ )
47
+ }