@spunto/design-system 0.20.0 → 0.21.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/README.md CHANGED
@@ -332,6 +332,29 @@ And the read-only twin of that form:
332
332
  - **Container queries** here too: the panel is 288 px in a sidebar on a 27" screen
333
333
  and full width on a phone — and the desktop case is the narrow one.
334
334
 
335
+ And the build that configuration triggers, while it runs:
336
+
337
+ - **`BuildSteps`** — an image build as the **list of blocks it is made of**
338
+ (base image, bootstrap, one per devcontainer feature, VS Code extensions,
339
+ finalize, DinD seed) rather than a wall of log. Each row lights up in turn and
340
+ carries its own duration, so "what is it doing, and for how long" reads at a
341
+ glance and a slow feature is visibly *the* slow one.
342
+ - **The whole plan is drawn greyed out from the first frame**, instead of rows
343
+ appearing one by one: the list doubles as a table of contents for what the
344
+ image contains, the `done/total` counter has a denominator immediately, and
345
+ nothing shifts under the cursor for the four minutes of a build. A block
346
+ *absent* from the plan isn't `skipped` — `skipped` is a block that was reached
347
+ and had nothing to do.
348
+ - **Presentational, so it isn't only about Docker**: it knows no WebSocket, no
349
+ marker, no daemon — hand it a snapshot of timestamped blocks and it draws.
350
+ Anything that can describe itself as ordered blocks (a CI pipeline, a
351
+ migration, a multi-phase command) can feed it.
352
+ - **Timestamps in, durations out.** The running block counts from `startedAt`
353
+ itself, every second — a duration computed app-side freezes between two
354
+ snapshots and reads as a stuck build. Under a minute the package's
355
+ `formatDuration` is reused (one formatter, no drift); past it the format
356
+ switches to `2m 13s`.
357
+
335
358
  ## Marketing — `/marketing`
336
359
 
337
360
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.20.0",
3
+ "version": "0.21.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",
@@ -0,0 +1,213 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import {
5
+ BoxIcon,
6
+ CheckIcon,
7
+ CircleIcon,
8
+ LayersIcon,
9
+ Loader2Icon,
10
+ MinusIcon,
11
+ PuzzleIcon,
12
+ TerminalIcon,
13
+ XIcon,
14
+ } from "lucide-react"
15
+
16
+ import { cn, formatDuration } from "../../utils"
17
+
18
+ export type BuildStepState = "pending" | "running" | "done" | "error" | "skipped"
19
+
20
+ /** The family a block belongs to — an icon, not a status. */
21
+ export type BuildStepKind = "image" | "runtime" | "feature" | "extensions" | "finalize"
22
+
23
+ export interface BuildStepItem {
24
+ id: string
25
+ label: string
26
+ kind: BuildStepKind
27
+ /** Second line: the base image ref, a feature's OCI ref, "3 extensions"… */
28
+ detail?: string
29
+ state: BuildStepState
30
+ /** ISO timestamps — the elapsed time of the running block ticks from `startedAt`. */
31
+ startedAt?: string
32
+ completedAt?: string
33
+ }
34
+
35
+ export interface BuildStepsProps {
36
+ steps: BuildStepItem[]
37
+ title?: string
38
+ /** Shown instead of the list when there is nothing to draw yet. */
39
+ emptyLabel?: string
40
+ className?: string
41
+ }
42
+
43
+ const KIND_ICON: Record<BuildStepKind, typeof BoxIcon> = {
44
+ image: LayersIcon,
45
+ runtime: TerminalIcon,
46
+ feature: PuzzleIcon,
47
+ extensions: PuzzleIcon,
48
+ finalize: BoxIcon,
49
+ }
50
+
51
+ /**
52
+ * `formatDuration` with a minutes tier on top.
53
+ *
54
+ * The package's formatter stops at seconds, which is right where it is used
55
+ * (a command, a request) and wrong here: pulling a base image or compiling a
56
+ * feature routinely passes the minute, and "127s" is a number one has to
57
+ * convert before reading it. Below a minute it delegates, so the two never
58
+ * drift on the part they share.
59
+ */
60
+ function formatStepDuration(ms: number): string {
61
+ if (ms < 60_000) return formatDuration(ms)
62
+ const s = Math.round(ms / 1000)
63
+ const m = Math.floor(s / 60)
64
+ const rest = s % 60
65
+ return rest ? `${m}m ${rest}s` : `${m}m`
66
+ }
67
+
68
+ /** Elapsed for a block: fixed once it ended, ticking while it runs. */
69
+ function useStepDuration(step: BuildStepItem): string | null {
70
+ const running = step.state === "running" && !!step.startedAt
71
+ const [now, setNow] = useState(() => Date.now())
72
+
73
+ useEffect(() => {
74
+ if (!running) return
75
+ const id = setInterval(() => setNow(Date.now()), 1000)
76
+ return () => clearInterval(id)
77
+ }, [running])
78
+
79
+ if (!step.startedAt) return null
80
+ const start = new Date(step.startedAt).getTime()
81
+ if (Number.isNaN(start)) return null
82
+ const end = step.completedAt ? new Date(step.completedAt).getTime() : running ? now : null
83
+ if (end == null || Number.isNaN(end)) return null
84
+ return formatStepDuration(end - start)
85
+ }
86
+
87
+ function StepBullet({ state }: { state: BuildStepState }) {
88
+ const base = "flex h-5 w-5 shrink-0 items-center justify-center rounded-full border"
89
+ if (state === "running")
90
+ return (
91
+ <span className={cn(base, "border-primary/40 bg-primary/10 text-primary")}>
92
+ <Loader2Icon className="h-3 w-3 animate-spin motion-reduce:animate-none" />
93
+ </span>
94
+ )
95
+ if (state === "done")
96
+ return (
97
+ <span className={cn(base, "border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400")}>
98
+ <CheckIcon className="h-3 w-3" />
99
+ </span>
100
+ )
101
+ if (state === "error")
102
+ return (
103
+ <span className={cn(base, "border-destructive/40 bg-destructive/10 text-destructive")}>
104
+ <XIcon className="h-3 w-3" />
105
+ </span>
106
+ )
107
+ if (state === "skipped")
108
+ return (
109
+ <span className={cn(base, "border-border bg-muted/40 text-muted-foreground/60")}>
110
+ <MinusIcon className="h-3 w-3" />
111
+ </span>
112
+ )
113
+ return (
114
+ <span className={cn(base, "border-border bg-background text-muted-foreground/40")}>
115
+ <CircleIcon className="h-1.5 w-1.5 fill-current" />
116
+ </span>
117
+ )
118
+ }
119
+
120
+ function StepRow({ step, last }: { step: BuildStepItem; last: boolean }) {
121
+ const duration = useStepDuration(step)
122
+ const KindIcon = KIND_ICON[step.kind] ?? BoxIcon
123
+ const dim = step.state === "pending" || step.state === "skipped"
124
+
125
+ return (
126
+ <li className="relative flex gap-2.5 pb-2.5 last:pb-0">
127
+ {/* The rail joining the bullets — drawn behind them, stopped on the last row. */}
128
+ {!last && <span aria-hidden className="absolute left-[9px] top-5 h-full w-px bg-border" />}
129
+ <StepBullet state={step.state} />
130
+
131
+ <div className="min-w-0 flex-1">
132
+ <div className="flex items-baseline gap-2">
133
+ <span
134
+ className={cn(
135
+ "min-w-0 flex-1 truncate text-[12px] leading-5",
136
+ step.state === "running" && "font-medium text-foreground",
137
+ step.state === "done" && "text-foreground",
138
+ step.state === "error" && "font-medium text-destructive",
139
+ dim && "text-muted-foreground/70",
140
+ )}
141
+ title={step.label}
142
+ >
143
+ {step.label}
144
+ </span>
145
+ {duration && (
146
+ <span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">{duration}</span>
147
+ )}
148
+ </div>
149
+ {step.detail && (
150
+ <div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
151
+ <KindIcon className="h-2.5 w-2.5 shrink-0" />
152
+ <span className="min-w-0 truncate" title={step.detail}>
153
+ {step.detail}
154
+ </span>
155
+ </div>
156
+ )}
157
+ </div>
158
+ </li>
159
+ )
160
+ }
161
+
162
+ /**
163
+ * An image build, as the list of blocks it is actually made of, instead of a wall of log.
164
+ *
165
+ * A project image build pulls a base image, creates the user, installs the editor and the terminal
166
+ * tooling, every devcontainer feature and every VS Code extension, then bakes the DinD seed — all of
167
+ * it, until now, indistinguishable from the outside: one terminal scrolling. Here each of those is a
168
+ * row that lights up in turn, with its own duration, so "what is it doing, and for how long" is
169
+ * readable at a glance and a slow feature is visibly *the* slow one.
170
+ *
171
+ * Purely presentational — it draws the snapshot it's given (the app streams it from the build's log
172
+ * socket, and owns every byte of that transport). The whole plan is known upfront, so the blocks
173
+ * that haven't run yet are drawn greyed out rather than appearing one by one: it doubles as a table
174
+ * of contents for what the image is made of, which is what these blocks are on their way to becoming
175
+ * (a checklist of what to include).
176
+ *
177
+ * Sized by its container, scrolls inside it: give it a height (`className="h-full"` in a panel) and
178
+ * only the list scrolls — the header with its `done/total` counter stays put.
179
+ */
180
+ export function BuildSteps({ steps, title = "Build steps", emptyLabel = "No step reported", className }: BuildStepsProps) {
181
+ const done = steps.filter((s) => s.state === "done").length
182
+ const failed = steps.some((s) => s.state === "error")
183
+
184
+ return (
185
+ <div data-slot="build-steps" className={cn("flex min-h-0 flex-col", className)}>
186
+ <div className="flex shrink-0 items-baseline justify-between gap-2 px-3 py-2">
187
+ <span className="text-[11px] font-medium text-muted-foreground">{title}</span>
188
+ {steps.length > 0 && (
189
+ <span
190
+ className={cn(
191
+ "font-mono text-[10px] tabular-nums",
192
+ failed ? "text-destructive" : "text-muted-foreground",
193
+ )}
194
+ >
195
+ {done}/{steps.length}
196
+ </span>
197
+ )}
198
+ </div>
199
+
200
+ <div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3">
201
+ {steps.length === 0 ? (
202
+ <p className="text-[11px] text-muted-foreground/70">{emptyLabel}</p>
203
+ ) : (
204
+ <ol className="relative">
205
+ {steps.map((step, i) => (
206
+ <StepRow key={step.id} step={step} last={i === steps.length - 1} />
207
+ ))}
208
+ </ol>
209
+ )}
210
+ </div>
211
+ </div>
212
+ )
213
+ }
@@ -92,6 +92,13 @@ export type { ChipListProps, ChipItem, ChipTone } from "./chip-list"
92
92
  export { BuildCacheList, BuildStateLabel, PrebuildButton } from "./build-cache-list"
93
93
  export type { BuildCacheListProps, PrebuildButtonProps } from "./build-cache-list"
94
94
 
95
+ // The build itself, while it runs: the blocks an image is made of, each lighting
96
+ // up in turn. Same domain as the cache list it sits next to (a project's image),
97
+ // hence this entry rather than one of its own — and domain it is, since the
98
+ // blocks are Spunto's (a devcontainer feature, a VS Code extension).
99
+ export { BuildSteps } from "./build-steps"
100
+ export type { BuildStepsProps, BuildStepItem, BuildStepKind, BuildStepState } from "./build-steps"
101
+
95
102
  export { LifecycleCommands, LifecycleCommand } from "./lifecycle-commands"
96
103
  export type { LifecycleCommandsProps, LifecycleCommandProps } from "./lifecycle-commands"
97
104