@spunto/design-system 0.9.2 → 0.10.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,139 @@
1
+ "use client"
2
+
3
+ // Free-form worker tags, coloured by hash.
4
+ //
5
+ // Read-only by default (Spunto Lite's case). Pass `onAdd`/`onRemove` and the
6
+ // chips grow a cross and a "+" button — the *persistence* stays with the caller,
7
+ // this component only reports intent.
8
+
9
+ import { useEffect, useRef, useState } from "react"
10
+ import { PlusIcon, XIcon } from "lucide-react"
11
+
12
+ import { cn } from "../../utils"
13
+ import { tagColor } from "./tag-color"
14
+
15
+ export interface TagChipsProps {
16
+ tags: string[]
17
+ /**
18
+ * Both callbacks receive the tag *and* the resulting list. Persist `next` —
19
+ * recomputing it caller-side from a polled `worker.tags` silently drops any
20
+ * edit made since the last refetch (two tags added in a row, then one removed,
21
+ * and the second one disappears with it).
22
+ */
23
+ onAdd?: (tag: string, next: string[]) => void
24
+ onRemove?: (tag: string, next: string[]) => void
25
+ /** Longest accepted tag; anything past it is truncated on commit. */
26
+ maxLength?: number
27
+ className?: string
28
+ }
29
+
30
+ export function TagChips({ tags, onAdd, onRemove, maxLength = 32, className }: TagChipsProps) {
31
+ const editable = !!(onAdd || onRemove)
32
+ // Local echo so a freshly typed tag appears instantly, resynced whenever the
33
+ // caller's list changes — the server's answer always wins in the end.
34
+ const [local, setLocal] = useState<string[]>(tags)
35
+ const [input, setInput] = useState("")
36
+ const [adding, setAdding] = useState(false)
37
+ const inputRef = useRef<HTMLInputElement>(null)
38
+
39
+ // Compared by content, not by reference: callers routinely pass
40
+ // `worker.tags ?? []`, a fresh array on every poll, and swapping state for an
41
+ // identical list would re-render the whole card every few seconds.
42
+ useEffect(() => {
43
+ setLocal((prev) => (prev.length === tags.length && prev.every((t, i) => t === tags[i]) ? prev : tags))
44
+ }, [tags])
45
+
46
+ function commit() {
47
+ // Spaces and commas are the separators a user reflexively types; swallowing
48
+ // them here keeps "one chip = one word" true without an error message.
49
+ const raw = input.trim().replace(/,/g, "").slice(0, maxLength)
50
+ if (raw && !local.includes(raw)) {
51
+ const next = [...local, raw]
52
+ setLocal(next)
53
+ onAdd?.(raw, next)
54
+ }
55
+ setInput("")
56
+ setAdding(false)
57
+ }
58
+
59
+ function remove(tag: string) {
60
+ const next = local.filter((t) => t !== tag)
61
+ setLocal(next)
62
+ onRemove?.(tag, next)
63
+ }
64
+
65
+ const shown = editable ? local : tags
66
+ if (!shown.length && !editable) return null
67
+
68
+ return (
69
+ <>
70
+ {shown.map((tag) => {
71
+ const c = tagColor(tag)
72
+ return (
73
+ <span
74
+ key={tag}
75
+ className={cn(
76
+ "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium leading-none",
77
+ c.bg,
78
+ c.text,
79
+ c.border,
80
+ className
81
+ )}
82
+ >
83
+ {tag}
84
+ {onRemove && (
85
+ <button
86
+ type="button"
87
+ onClick={(e) => {
88
+ e.stopPropagation()
89
+ remove(tag)
90
+ }}
91
+ className="ml-0.5 cursor-pointer opacity-50 transition-opacity hover:opacity-100"
92
+ aria-label={`Remove tag ${tag}`}
93
+ >
94
+ <XIcon className="h-2.5 w-2.5" />
95
+ </button>
96
+ )}
97
+ </span>
98
+ )
99
+ })}
100
+
101
+ {onAdd &&
102
+ (adding ? (
103
+ <input
104
+ ref={inputRef}
105
+ autoFocus
106
+ value={input}
107
+ onChange={(e) => setInput(e.target.value.replace(/[\s,]/g, ""))}
108
+ onKeyDown={(e) => {
109
+ if (e.key === "Enter") {
110
+ e.preventDefault()
111
+ commit()
112
+ }
113
+ if (e.key === "Escape") {
114
+ setInput("")
115
+ setAdding(false)
116
+ }
117
+ }}
118
+ // Blur-commits rather than discards: clicking away after typing a
119
+ // tag reads as "done", never as "cancel".
120
+ onBlur={commit}
121
+ placeholder="tag…"
122
+ className="h-4 w-20 rounded-full border border-primary/40 bg-primary/5 px-2 text-[10px] text-primary outline-none focus:border-primary/60"
123
+ />
124
+ ) : (
125
+ <button
126
+ type="button"
127
+ onClick={(e) => {
128
+ e.stopPropagation()
129
+ setAdding(true)
130
+ }}
131
+ className="inline-flex cursor-pointer items-center rounded-full border border-dashed border-border px-1.5 py-0.5 text-[10px] text-muted-foreground/50 transition-colors hover:border-border/80 hover:text-muted-foreground"
132
+ aria-label="Add tag"
133
+ >
134
+ <PlusIcon className="h-2.5 w-2.5" />
135
+ </button>
136
+ ))}
137
+ </>
138
+ )
139
+ }
@@ -0,0 +1,28 @@
1
+ // Deterministic tag → pastel palette, split out of `tag-chips.tsx` (a "use client"
2
+ // module) so it stays callable from a React Server Component and from plain
3
+ // helpers — same reason `buttonVariants` has its own file.
4
+
5
+ const TAG_PALETTES = [
6
+ { bg: "bg-orange-500/12", text: "text-orange-700 dark:text-orange-300", border: "border-orange-400/30" },
7
+ { bg: "bg-amber-500/12", text: "text-amber-700 dark:text-amber-300", border: "border-amber-400/30" },
8
+ { bg: "bg-lime-500/12", text: "text-lime-700 dark:text-lime-300", border: "border-lime-400/30" },
9
+ { bg: "bg-green-500/12", text: "text-green-700 dark:text-green-300", border: "border-green-400/30" },
10
+ { bg: "bg-teal-500/12", text: "text-teal-700 dark:text-teal-300", border: "border-teal-400/30" },
11
+ { bg: "bg-sky-500/12", text: "text-sky-700 dark:text-sky-300", border: "border-sky-400/30" },
12
+ { bg: "bg-violet-500/12", text: "text-violet-700 dark:text-violet-300", border: "border-violet-400/30" },
13
+ { bg: "bg-pink-500/12", text: "text-pink-700 dark:text-pink-300", border: "border-pink-400/30" },
14
+ ] as const
15
+
16
+ export type TagPalette = (typeof TAG_PALETTES)[number]
17
+
18
+ /**
19
+ * Notion-style pastel picked from the tag's own characters, so the same tag is
20
+ * the same colour on every card, in every app, forever — no palette state, no
21
+ * assignment to persist. The classes are written out in full (never built by
22
+ * string concatenation) so Tailwind's scanner can see them.
23
+ */
24
+ export function tagColor(tag: string): TagPalette {
25
+ let h = 0
26
+ for (let i = 0; i < tag.length; i++) h = (Math.imul(31, h) + tag.charCodeAt(i)) | 0
27
+ return TAG_PALETTES[Math.abs(h) % TAG_PALETTES.length]
28
+ }
@@ -0,0 +1,44 @@
1
+ // Structural, deliberately laxist shapes for everything worker-related.
2
+ //
3
+ // The design system must NOT import the OpenAPI-generated types of the Spunto
4
+ // dashboard (nor Spunto Lite's): the two apps model a worker differently — the
5
+ // dashboard has both a lifecycle `state` and a docker-level `dockerState`, Lite
6
+ // has a single `state`; the dashboard's `name` is nullable and backed by an
7
+ // `index`, Lite's is guaranteed. So the types below describe the *minimum* these
8
+ // components read, every field optional, and the state resolution tolerates an
9
+ // unknown value instead of crashing on it (see `resolveWorkerStatus`).
10
+
11
+ /** Container-side setup progress, as reported by the worker agent. */
12
+ export interface WorkerSetupStatus {
13
+ /** `initializing` | `credentials` | `dotfiles` | `cloning` | `lifecycle` | `ready` | `error`. */
14
+ phase?: string | null
15
+ repos?: { name?: string; state?: string | null }[] | null
16
+ postCreate?: string | null
17
+ postStart?: string | null
18
+ error?: string | null
19
+ /** Per-milestone start timestamps (epoch ms) — `initializing` → `ready` brackets the startup. */
20
+ timings?: Record<string, number> | null
21
+ }
22
+
23
+ /** The snapshot a `WorkerCard` draws. Everything but `id` is optional. */
24
+ export interface WorkerCardWorker {
25
+ id: string
26
+ name?: string | null
27
+ /** Fallback identity when `name` is empty — renders as "Workspace #3". */
28
+ index?: number | null
29
+ /** Lifecycle state: `provisioning` | `pulling` | `starting` | `setup` | `ready` | `stopping` | `stopped` | `deleting` | `error`. */
30
+ state?: string | null
31
+ /** Docker-level state: `running` | `stopped` | `exited` | `created` | `not_found` | `error`. */
32
+ dockerState?: string | null
33
+ createdAt?: string | null
34
+ projectVersion?: number | null
35
+ tags?: string[] | null
36
+ setupStatus?: WorkerSetupStatus | null
37
+ }
38
+
39
+ /** Author of a worker — avatar + name, nothing else. */
40
+ export interface WorkerCreator {
41
+ name: string
42
+ login?: string
43
+ avatarUrl?: string | null
44
+ }
@@ -0,0 +1,345 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import {
5
+ AlertTriangleIcon,
6
+ ChevronRightIcon,
7
+ ClockIcon,
8
+ ServerIcon,
9
+ TimerIcon,
10
+ WifiOffIcon,
11
+ XIcon,
12
+ } from "lucide-react"
13
+
14
+ import { cn, formatDuration, formatRelativeTime } from "../../utils"
15
+ import { buttonVariants } from "../button-variants"
16
+ import { CreatorAvatar } from "./creator-avatar"
17
+ import { GitBranchChips, type GitRepoStatus } from "./git-branch-chips"
18
+ import { ResourceBars, type WorkerStats } from "./resource-bars"
19
+ import { SetupProgress, setupTotalMs } from "./setup-progress"
20
+ import { TagChips } from "./tag-chips"
21
+ import type { WorkerCardWorker, WorkerCreator } from "./types"
22
+ import { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus } from "./worker-status"
23
+
24
+ /** Renders the "View" affordance. A slot, so the package never imports `next/link`. */
25
+ export type LinkRender = (props: { href: string; className?: string; children: ReactNode }) => ReactNode
26
+
27
+ export interface WorkerCardProps {
28
+ worker: WorkerCardWorker
29
+
30
+ /** Target of the title + footer button. Omitted → the card isn't a link. */
31
+ href?: string
32
+ /**
33
+ * Escape hatches for app-specific rendering. `link` receives `{ href, className,
34
+ * children }` and must return an anchor-ish node (`next/link`, a router link…);
35
+ * without it the card falls back to a plain `<a>`.
36
+ */
37
+ render?: { link?: LinkRender }
38
+
39
+ creator?: WorkerCreator
40
+ nodeName?: string
41
+ /** `false` draws the "Node offline" banner. `undefined` = unknown, no banner. */
42
+ nodeOnline?: boolean
43
+ /** Live container stats. Absent → no resource bars (the usual case for a stopped worker). */
44
+ stats?: WorkerStats | null
45
+ /** Checked-out repositories. Absent/empty → no chips. */
46
+ gitStatus?: GitRepoStatus[] | null
47
+ /** Chip → URL (e.g. code-server at that folder). Returning undefined renders a static chip. */
48
+ repoHref?: (repo: GitRepoStatus) => string | undefined
49
+
50
+ /**
51
+ * Tags are read-only unless at least one of these is given. Both receive the
52
+ * tag *and* the resulting list — persist the list, don't recompute it from a
53
+ * polled `worker.tags` (see `TagChips`).
54
+ */
55
+ onAddTag?: (tag: string, next: string[]) => void
56
+ onRemoveTag?: (tag: string, next: string[]) => void
57
+
58
+ /** Project's current config version — a worker below it gets the "Rebuild" banner. */
59
+ currentProjectVersion?: number
60
+ onRebuild?: () => void
61
+ rebuilding?: boolean
62
+
63
+ /** Error banner inside the card (a failed mutation, typically). */
64
+ error?: string | null
65
+ onDismissError?: () => void
66
+
67
+ /** The `⋯` menu, top-right. A slot: every entry in it calls the API. */
68
+ actions?: ReactNode
69
+ /** Replaces the default "View" footer entirely (extra buttons, a VS Code link…). */
70
+ footer?: ReactNode
71
+ /** Label of the default footer button. */
72
+ viewLabel?: string
73
+
74
+ className?: string
75
+ }
76
+
77
+ /**
78
+ * One worker, as a card: state, who spawned it, where it runs, what it's working
79
+ * on, and what you can do with it.
80
+ *
81
+ * **Purely presentational.** It receives a snapshot and draws it — no query, no
82
+ * mutation, no router. Everything that would talk to an API is a slot
83
+ * (`actions`, `footer`) or a callback (`onAddTag`, `onRebuild`…). That rule is
84
+ * what makes the same card renderable by the dashboard, by Spunto Lite, and by
85
+ * the showcase with hand-written fixtures. The card is the assembly; each brick
86
+ * (`WorkerStatusDot`, `SetupProgress`, `ResourceBars`, `GitBranchChips`,
87
+ * `TagChips`) is exported on its own because the table view needs them without
88
+ * the card.
89
+ *
90
+ * The layout is a fixed sequence of optional bands — header, setup bar, git
91
+ * chips, resource bars, banners, footer — and each band disappears when its data
92
+ * is absent, so a stopped worker collapses to a two-line card without any
93
+ * caller-side conditional.
94
+ */
95
+ export function WorkerCard({
96
+ worker,
97
+ href,
98
+ render,
99
+ creator,
100
+ nodeName,
101
+ nodeOnline,
102
+ stats,
103
+ gitStatus,
104
+ repoHref,
105
+ onAddTag,
106
+ onRemoveTag,
107
+ currentProjectVersion,
108
+ onRebuild,
109
+ rebuilding = false,
110
+ error,
111
+ onDismissError,
112
+ actions,
113
+ footer,
114
+ viewLabel = "View",
115
+ className,
116
+ }: WorkerCardProps) {
117
+ const status = resolveWorkerStatus(worker)
118
+ const setup = worker.setupStatus ?? null
119
+ const startupMs = setupTotalMs(setup)
120
+ const title = worker.name || (worker.index != null ? `Workspace #${worker.index}` : worker.id)
121
+
122
+ // Live detail is only meaningful once the container is up *and* done setting
123
+ // up: mid-setup a stale "running" from docker would show stats for a worker
124
+ // that isn't usable yet.
125
+ const settled = status.running && !status.settingUp
126
+ const repos = settled ? (gitStatus ?? []) : []
127
+ const outdated =
128
+ !status.settingUp &&
129
+ worker.projectVersion != null &&
130
+ currentProjectVersion != null &&
131
+ worker.projectVersion < currentProjectVersion
132
+
133
+ const Link: LinkRender =
134
+ render?.link ?? (({ href: to, className: cls, children }) => <a href={to} className={cls}>{children}</a>)
135
+
136
+ return (
137
+ <div
138
+ data-slot="worker-card"
139
+ // `@container`: every responsive rule below is a container query, never a
140
+ // viewport one. The same card is a half-width tile on a desktop grid and a
141
+ // full-width row on a phone — and a 290 px card in a sidebar on a 27"
142
+ // screen is exactly as cramped as a phone.
143
+ className={cn(
144
+ "@container overflow-hidden rounded-xl border border-border bg-card transition-colors hover:shadow-sm",
145
+ className
146
+ )}
147
+ >
148
+ {/* ── Header : état, nom, pills, tags, méta ── */}
149
+ <div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3">
150
+ <div className="flex min-w-0 items-start gap-3">
151
+ <WorkerStatusDot status={status} className="mt-1" />
152
+
153
+ <div className="min-w-0 flex-1">
154
+ <div className="flex flex-wrap items-center gap-2">
155
+ {/* A worker name is one unbreakable token ("spunto-fix-tunnel-42"):
156
+ without the cap it pushes the card wider than its column. */}
157
+ <span className="max-w-full truncate text-sm leading-none font-semibold">{title}</span>
158
+ <WorkerStatusPill status={status} />
159
+ {worker.projectVersion != null && (
160
+ <span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 font-mono text-[10px] leading-none text-muted-foreground">
161
+ v{worker.projectVersion}
162
+ </span>
163
+ )}
164
+ <TagChips tags={worker.tags ?? []} onAdd={onAddTag} onRemove={onRemoveTag} />
165
+ </div>
166
+
167
+ {/* Meta line. Each item carries its own leading separator instead of
168
+ the separators being siblings: this row wraps onto two lines as
169
+ soon as the card is narrow, and a sibling "·" then ends up
170
+ stranded at the end of a line, pointing at nothing. Attached to
171
+ the item, the worst case is a line that *starts* with "·", which
172
+ reads as a continuation. */}
173
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
174
+ {creator && (
175
+ <MetaItem first>
176
+ <CreatorAvatar creator={creator} />
177
+ <span className="text-muted-foreground">{creator.name}</span>
178
+ </MetaItem>
179
+ )}
180
+ {nodeName && (
181
+ <MetaItem first={!creator}>
182
+ <ServerIcon className="h-3 w-3 shrink-0" />
183
+ <span className="truncate">{nodeName}</span>
184
+ </MetaItem>
185
+ )}
186
+ {worker.createdAt && (
187
+ <MetaItem first={!creator && !nodeName} title={new Date(worker.createdAt).toLocaleString()}>
188
+ <ClockIcon className="h-3 w-3 shrink-0" />
189
+ {formatRelativeTime(worker.createdAt)}
190
+ </MetaItem>
191
+ )}
192
+ {/* Only worth showing once it's over — during setup the live bar says it better. */}
193
+ {!status.settingUp && startupMs != null && (
194
+ <MetaItem title="Worker startup time (clone, features, postCreate…)">
195
+ <TimerIcon className="h-3 w-3 shrink-0" />
196
+ started in {formatDuration(startupMs)}
197
+ </MetaItem>
198
+ )}
199
+ </div>
200
+ </div>
201
+ </div>
202
+
203
+ {actions}
204
+ </div>
205
+
206
+ {status.settingUp && (
207
+ <div className="px-4 pb-3">
208
+ <SetupProgress status={setup} />
209
+ </div>
210
+ )}
211
+
212
+ {repos.length > 0 && (
213
+ <div className="px-4 pb-3">
214
+ <GitBranchChips repos={repos} repoHref={repoHref} />
215
+ </div>
216
+ )}
217
+
218
+ {settled && stats && (
219
+ <div className="px-4 pb-3">
220
+ <ResourceBars stats={stats} />
221
+ </div>
222
+ )}
223
+
224
+ {nodeOnline === false && (
225
+ <Banner tone="offline">
226
+ {/* One span, not two children: the banner spreads its children apart,
227
+ and an icon pinned left with its own label pinned right reads as
228
+ two unrelated things. */}
229
+ <span className="flex min-w-0 items-center gap-2">
230
+ <WifiOffIcon className="h-3.5 w-3.5 shrink-0" />
231
+ Node offline
232
+ </span>
233
+ </Banner>
234
+ )}
235
+
236
+ {outdated && (
237
+ <Banner tone="warn">
238
+ <span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5">
239
+ <AlertTriangleIcon className="h-3.5 w-3.5 shrink-0" />
240
+ Config <span className="font-mono font-semibold">v{worker.projectVersion}</span> → project at{" "}
241
+ <span className="font-mono font-semibold">v{currentProjectVersion}</span>
242
+ </span>
243
+ {onRebuild && (
244
+ <button
245
+ type="button"
246
+ disabled={rebuilding}
247
+ onClick={onRebuild}
248
+ className="shrink-0 cursor-pointer text-[11px] font-medium hover:underline disabled:opacity-50"
249
+ >
250
+ {rebuilding ? "Rebuilding…" : "Rebuild"}
251
+ </button>
252
+ )}
253
+ </Banner>
254
+ )}
255
+
256
+ {error && (
257
+ <Banner tone="error">
258
+ {/* Two lines then ellipsis: on a phone a single truncated line of a
259
+ docker error is almost always cut before the useful part. */}
260
+ <span className="line-clamp-2 min-w-0 flex-1 font-mono break-words">{error}</span>
261
+ {onDismissError && (
262
+ <button
263
+ type="button"
264
+ onClick={onDismissError}
265
+ aria-label="Dismiss error"
266
+ className="shrink-0 cursor-pointer opacity-60 transition-opacity hover:opacity-100"
267
+ >
268
+ <XIcon className="h-3.5 w-3.5" />
269
+ </button>
270
+ )}
271
+ </Banner>
272
+ )}
273
+
274
+ {(footer ?? href) && (
275
+ <div className="border-t border-border/50 bg-muted/20 px-3 py-2">
276
+ {footer ?? (
277
+ <Link href={href!} className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-7 w-full gap-1.5 text-xs")}>
278
+ {viewLabel}
279
+ <ChevronRightIcon className="h-3.5 w-3.5" />
280
+ </Link>
281
+ )}
282
+ </div>
283
+ )}
284
+ </div>
285
+ )
286
+ }
287
+
288
+ /**
289
+ * One item of the meta line: creator, node, age, startup time.
290
+ *
291
+ * The separator is a `::before` on the item rather than a sibling element, so
292
+ * it can never be left behind at a line break. Below ~22 rem it disappears
293
+ * entirely: under ~26 rem the row is two lines deep anyway and the icons already tell
294
+ * the items apart, so the dots are pure noise there.
295
+ */
296
+ function MetaItem({
297
+ first = false,
298
+ title,
299
+ children,
300
+ }: {
301
+ first?: boolean
302
+ title?: string
303
+ children: ReactNode
304
+ }) {
305
+ return (
306
+ <span
307
+ title={title}
308
+ className={cn(
309
+ "flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground/60",
310
+ !first &&
311
+ "before:hidden before:text-muted-foreground/30 before:content-['·'] @min-[26rem]:before:mr-1 @min-[26rem]:before:inline"
312
+ )}
313
+ >
314
+ {children}
315
+ </span>
316
+ )
317
+ }
318
+
319
+ /**
320
+ * The card's inline bands. Warm tones = something to know (an unreachable node,
321
+ * a stale config), red = something went wrong. They keep the card's horizontal
322
+ * margin so they read as *inside* the card, not as a strip glued across it.
323
+ */
324
+ const BANNER_TONES = {
325
+ /** Infrastructure problem — orange, one notch louder than a stale config. */
326
+ offline: "border-orange-500/25 bg-orange-500/5 text-orange-600 dark:text-orange-400",
327
+ warn: "border-amber-500/25 bg-amber-500/5 text-amber-600 dark:text-amber-400",
328
+ error: "border-destructive/25 bg-destructive/5 text-destructive",
329
+ } as const
330
+
331
+ function Banner({ tone, children }: { tone: keyof typeof BANNER_TONES; children: ReactNode }) {
332
+ return (
333
+ <div
334
+ className={cn(
335
+ // No `flex-wrap`: the action (Rebuild, ✕) stays on the message's line
336
+ // whatever happens — wrapping it underneath reads as a second, unrelated
337
+ // control. The message shrinks and wraps internally instead.
338
+ "mx-4 mb-3 flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-xs",
339
+ BANNER_TONES[tone]
340
+ )}
341
+ >
342
+ {children}
343
+ </div>
344
+ )
345
+ }