@spunto/design-system 0.7.2 → 0.9.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,642 @@
1
+ "use client"
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react"
4
+
5
+ import { cn } from "../utils"
6
+
7
+ // Same surface as `Terminal` (#09090b) and the same ANSI accent hues, so an
8
+ // ImagePull dropped right above a Terminal reads as *one* object — a build view
9
+ // becomes two stacked bricks rather than two unrelated widgets.
10
+ const pullTheme = {
11
+ background: "#09090b",
12
+ border: "#27272a",
13
+ /** Unfilled part of a segment / of a layer's thin bar. */
14
+ track: "#1f1f23",
15
+ text: "#e4e4e7",
16
+ dim: "#a1a1aa",
17
+ faint: "#71717a",
18
+ /** Tag of the image reference — the part that actually varies, so it's the bright one. */
19
+ tag: "#fdba74",
20
+ // Phase colors. Flame is the brand accent and downloading is the main event;
21
+ // yellow/green/red come straight from the Terminal's shared ANSI palette so
22
+ // both surfaces agree on what "in progress" / "done" / "failed" look like.
23
+ waiting: "#3f3f46",
24
+ downloading: "#ea5400",
25
+ verifying: "#fb923c",
26
+ extracting: "#facc15",
27
+ done: "#4ade80",
28
+ error: "#f87171",
29
+ } as const
30
+
31
+ export type PullLayerStatus = "waiting" | "downloading" | "verifying" | "extracting" | "done"
32
+
33
+ export interface PullLayer {
34
+ /** Docker layer id (full sha or short form) — the list shows the first 12 chars. */
35
+ id: string
36
+ status: PullLayerStatus
37
+ /** Bytes transferred (while downloading) or extracted (while extracting). */
38
+ current?: number
39
+ /** Layer size. Often unknown for the first moments of a layer's life — that's expected. */
40
+ total?: number
41
+ }
42
+
43
+ export type ImagePullState = "starting" | "pulling" | "done" | "cached" | "error"
44
+
45
+ export interface ImagePullProps {
46
+ /** Full image reference, e.g. `registry.spunto.net/spunto/worker-base:0.7.2`. */
47
+ image: string
48
+ layers: PullLayer[]
49
+ state?: ImagePullState
50
+ /** Failure message, shown in the red band when `state="error"`. */
51
+ error?: string
52
+ /** Epoch ms — drives the elapsed clock and the final "Pullée en 42 s". */
53
+ startedAt?: number
54
+ defaultExpanded?: boolean
55
+ className?: string
56
+ }
57
+
58
+ const STATUS_LABEL: Record<PullLayerStatus, string> = {
59
+ waiting: "en attente",
60
+ downloading: "téléchargement",
61
+ verifying: "vérification",
62
+ extracting: "extraction",
63
+ done: "terminé",
64
+ }
65
+
66
+ const STATUS_COLOR: Record<PullLayerStatus, string> = {
67
+ waiting: pullTheme.waiting,
68
+ downloading: pullTheme.downloading,
69
+ verifying: pullTheme.verifying,
70
+ extracting: pullTheme.extracting,
71
+ done: pullTheme.done,
72
+ }
73
+
74
+ const KIB = 1024
75
+ const MIB = 1024 * 1024
76
+ const GIB = 1024 * 1024 * 1024
77
+
78
+ /** French, base-1024 byte sizes ("148 Mo"). Fixed-width friendly: at most one decimal. */
79
+ function formatBytes(n: number): string {
80
+ if (!Number.isFinite(n) || n <= 0) return "0 o"
81
+ if (n < KIB) return `${Math.round(n)} o`
82
+ if (n < MIB) return `${Math.round(n / KIB)} Ko`
83
+ if (n < GIB) return `${(n / MIB).toFixed(n < 10 * MIB ? 1 : 0).replace(".", ",")} Mo`
84
+ return `${(n / GIB).toFixed(1).replace(".", ",")} Go`
85
+ }
86
+
87
+ function formatRate(bytesPerSecond: number): string {
88
+ return `${formatBytes(bytesPerSecond)}/s`
89
+ }
90
+
91
+ function formatDuration(ms: number): string {
92
+ const s = Math.max(0, Math.round(ms / 1000))
93
+ if (s < 60) return `${s} s`
94
+ const m = Math.floor(s / 60)
95
+ const rest = s % 60
96
+ return rest ? `${m} min ${rest} s` : `${m} min`
97
+ }
98
+
99
+ /**
100
+ * Splits an image reference into a *truncatable* head and a head-on tail, which
101
+ * is how the header gets middle-truncation with plain CSS: the head gets
102
+ * `truncate`, the tail never shrinks. The tag stays readable no matter how
103
+ * narrow the container gets — it's the part that actually tells you *what* is
104
+ * being pulled.
105
+ */
106
+ function splitImageRef(image: string): { head: string; name: string; tag: string } {
107
+ const slash = image.lastIndexOf("/")
108
+ const head = slash >= 0 ? image.slice(0, slash + 1) : ""
109
+ const rest = slash >= 0 ? image.slice(slash + 1) : image
110
+
111
+ const at = rest.indexOf("@")
112
+ if (at >= 0) {
113
+ // Digest pin — keep it recognisable but don't let 71 chars of sha eat the row.
114
+ const digest = rest.slice(at)
115
+ return { head, name: rest.slice(0, at), tag: digest.length > 20 ? `${digest.slice(0, 20)}…` : digest }
116
+ }
117
+ const colon = rest.lastIndexOf(":")
118
+ if (colon > 0) return { head, name: rest.slice(0, colon), tag: rest.slice(colon) }
119
+ return { head, name: rest, tag: "" }
120
+ }
121
+
122
+ /** Bytes actually transferred for a layer: past the download, the transfer is complete. */
123
+ function downloadedBytes(layer: PullLayer): number {
124
+ if (layer.status === "waiting") return 0
125
+ if (layer.status === "downloading") {
126
+ const current = layer.current ?? 0
127
+ return layer.total ? Math.min(current, layer.total) : current
128
+ }
129
+ return layer.total ?? layer.current ?? 0
130
+ }
131
+
132
+ /** Bytes unpacked for a layer — the second half of the work, after the transfer. */
133
+ function extractedBytes(layer: PullLayer): number {
134
+ if (layer.status === "done") return layer.total ?? layer.current ?? 0
135
+ if (layer.status === "extracting") {
136
+ const current = layer.current ?? 0
137
+ return layer.total ? Math.min(current, layer.total) : current
138
+ }
139
+ return 0
140
+ }
141
+
142
+ function clamp01(n: number): number {
143
+ return Math.min(1, Math.max(0, n))
144
+ }
145
+
146
+ /**
147
+ * Docker image pull, rendered as a real UI instead of ANSI art redrawn in a
148
+ * terminal. Purely presentational: it takes a snapshot (`image` + `layers` +
149
+ * `state`) and draws it — no WebSocket, no docker, no polling. Throughput and
150
+ * ETA are derived internally from successive snapshots, so callers only have to
151
+ * forward what the daemon already tells them.
152
+ *
153
+ * The centrepiece is a *single* overall bar whose colour follows the phase:
154
+ * flame as bytes come down, amber growing over it as layers unpack, solid green
155
+ * at the end. It was segmented per layer at first — one block per layer, sized
156
+ * by weight — and the segments were arithmetically right but unreadable: the
157
+ * filled parts end up scattered across five or six blocks, so the eye can't add
158
+ * them up to the announced percentage, and a 148 kB layer next to a 75 MB one is
159
+ * a 2 px sliver that just looks like a glitch. Per-layer detail lives in the
160
+ * expandable list below, where each layer gets a full line to itself.
161
+ *
162
+ * Two rendering traps this component exists to avoid:
163
+ *
164
+ * 1. **Stable order.** Layer sizes arrive late; sorting by weight would make
165
+ * the list reshuffle for the entire pull. Order is first-appearance and
166
+ * frozen for good (see `orderRef`).
167
+ * 2. **Growing denominator.** The total is unknown at the start and grows as
168
+ * layers announce themselves, so the *percentage can go down*. Hence the
169
+ * absolute byte count printed next to it (that one never lies), a `~` marker
170
+ * while sizes are still missing, and damped width transitions so the bar
171
+ * never snaps backwards.
172
+ */
173
+ function ImagePull({
174
+ image,
175
+ layers,
176
+ state = "pulling",
177
+ error,
178
+ startedAt,
179
+ defaultExpanded = false,
180
+ className,
181
+ }: ImagePullProps) {
182
+ const [expanded, setExpanded] = useState(defaultExpanded)
183
+
184
+ // First-appearance order, frozen. Mutating the ref during render is safe here
185
+ // because it's idempotent — re-running with the same `layers` yields the same
186
+ // map — and it has to happen before the sort below, not in an effect.
187
+ const orderRef = useRef<Map<string, number>>(new Map())
188
+ const ordered = useMemo(() => {
189
+ const order = orderRef.current
190
+ for (const layer of layers) if (!order.has(layer.id)) order.set(layer.id, order.size)
191
+ return [...layers].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0))
192
+ }, [layers])
193
+
194
+ const stats = useMemo(() => {
195
+ const known = ordered.filter((l) => l.total && l.total > 0)
196
+ const knownBytes = known.reduce((sum, l) => sum + (l.total ?? 0), 0)
197
+ // Layers whose size is still unknown count for an equal share, estimated
198
+ // from the ones we do know. Without it the total would jump every time a
199
+ // size lands, and the percentage would fall off a cliff each time.
200
+ const estimate = known.length ? knownBytes / known.length : MIB
201
+ const downloaded = ordered.reduce((sum, l) => sum + downloadedBytes(l), 0)
202
+ const extracted = ordered.reduce((sum, l) => sum + extractedBytes(l), 0)
203
+ const total = ordered.reduce((sum, l) => sum + (l.total && l.total > 0 ? l.total : estimate), 0)
204
+ return {
205
+ downloaded,
206
+ extracted,
207
+ total,
208
+ /** True while at least one layer hasn't announced its size — the total is a guess. */
209
+ estimated: known.length !== ordered.length,
210
+ }
211
+ }, [ordered])
212
+
213
+ const rate = useThroughput(stats.downloaded, state)
214
+ const elapsed = useElapsed(startedAt, state)
215
+
216
+ // Failures are the one case where the layer list is the answer, not a detail.
217
+ useEffect(() => {
218
+ if (state === "error") setExpanded(true)
219
+ if (state === "done") setExpanded(false)
220
+ }, [state])
221
+
222
+ const isTerminal = state === "done" || state === "cached"
223
+ const hasLayers = ordered.length > 0
224
+ const percent = stats.total > 0 ? clamp01(stats.downloaded / stats.total) : 0
225
+ const extractPercent = stats.total > 0 ? clamp01(stats.extracted / stats.total) : 0
226
+ // Both phases overlap for most of a pull (layer 1 unpacks while layer 3 still
227
+ // downloads), so the headline number stays the download until every byte is
228
+ // in — only then does the unpacking become the thing you're waiting on.
229
+ const unpacking = hasLayers && percent >= 1 && extractPercent < 1
230
+ const remaining = Math.max(0, stats.total - stats.downloaded)
231
+ // Under a second there's nothing useful to announce — "0 s restantes" is noise.
232
+ const etaSeconds = rate && rate > 0 && remaining > 0 ? remaining / rate : 0
233
+ const eta = etaSeconds >= 1 ? etaSeconds : null
234
+
235
+ return (
236
+ <div
237
+ data-slot="image-pull"
238
+ className={cn(
239
+ // Square by default, like `Terminal`: this is a primitive, it doesn't
240
+ // impose a corner style. Consumers add `rounded-*` via className.
241
+ //
242
+ // `@container`, and every responsive rule below is a container query
243
+ // (`@sm:`, `@md:`…), never a viewport one: what has to fit is this box,
244
+ // and a 380 px-wide panel on a desktop is exactly as cramped as a phone.
245
+ "@container w-full min-w-0 overflow-hidden border font-mono text-xs leading-none",
246
+ className
247
+ )}
248
+ style={{
249
+ backgroundColor: pullTheme.background,
250
+ borderColor: state === "error" ? "#7f1d1d" : pullTheme.border,
251
+ color: pullTheme.text,
252
+ }}
253
+ >
254
+ {/* Narrow: image reference and chevron on one line, the figures on their
255
+ own line underneath (the `w-full` is what forces that wrap). Wide: all
256
+ four on a single line, figures back to their usual spot before the
257
+ chevron — hence the `order-*` swap rather than a second copy of the
258
+ markup. */}
259
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1.5 px-3 py-2.5">
260
+ <StateIcon state={state} />
261
+ <ImageRef image={image} />
262
+ {hasLayers && (
263
+ <button
264
+ type="button"
265
+ onClick={() => setExpanded((v) => !v)}
266
+ aria-expanded={expanded}
267
+ aria-label={expanded ? "Masquer les couches" : "Afficher les couches"}
268
+ className="order-3 -mr-1 shrink-0 cursor-pointer rounded p-1 transition-colors hover:bg-white/5 @sm:order-4"
269
+ style={{ color: pullTheme.faint }}
270
+ >
271
+ <Chevron open={expanded} />
272
+ </button>
273
+ )}
274
+ <span className="order-4 w-full min-w-0 @sm:order-3 @sm:w-auto">
275
+ <Aggregate
276
+ state={state}
277
+ percent={percent}
278
+ extractPercent={extractPercent}
279
+ unpacking={unpacking}
280
+ stats={stats}
281
+ rate={rate}
282
+ eta={eta}
283
+ elapsed={elapsed}
284
+ layerCount={ordered.length}
285
+ />
286
+ </span>
287
+ </div>
288
+
289
+ {/* The bar itself. Skipped in the two terminal states that have nothing to
290
+ show: `done` collapses to the single success line above, and `cached`
291
+ must never draw a 100 % bar for a download that never happened. */}
292
+ {!isTerminal && (
293
+ <div className="px-3 pb-3">
294
+ {hasLayers ? (
295
+ <OverallBar percent={percent} extractPercent={extractPercent} state={state} />
296
+ ) : (
297
+ <IndeterminateBar />
298
+ )}
299
+ </div>
300
+ )}
301
+
302
+ {state === "error" && error && (
303
+ <div
304
+ className="flex items-start gap-2 border-t px-3 py-2 leading-relaxed"
305
+ style={{ borderColor: "#7f1d1d", backgroundColor: "rgba(127,29,29,0.15)", color: pullTheme.error }}
306
+ >
307
+ <span className="shrink-0 pt-px">✕</span>
308
+ <span className="min-w-0 break-words">{error}</span>
309
+ </div>
310
+ )}
311
+
312
+ {expanded && hasLayers && (
313
+ <div className="max-h-64 overflow-y-auto border-t" style={{ borderColor: pullTheme.border }}>
314
+ {ordered.map((layer) => (
315
+ <LayerRow key={layer.id} layer={layer} failed={state === "error" && layer.status !== "done"} />
316
+ ))}
317
+ </div>
318
+ )}
319
+ </div>
320
+ )
321
+ }
322
+
323
+ /**
324
+ * The overall bar: one fill for the whole image, colour by phase.
325
+ *
326
+ * Two stacked fills rather than one, because a pull is two jobs: bytes come down
327
+ * (flame) and layers unpack (amber). The amber grows *over* the flame instead of
328
+ * replacing it, so the bar only ever moves forward — resetting to zero for the
329
+ * unpack phase would read as a regression, and sitting frozen at 100 % through a
330
+ * long extraction would read as a hang.
331
+ */
332
+ function OverallBar({
333
+ percent,
334
+ extractPercent,
335
+ state,
336
+ }: {
337
+ percent: number
338
+ extractPercent: number
339
+ state: ImagePullState
340
+ }) {
341
+ const failed = state === "error"
342
+ return (
343
+ <div className="relative h-2 w-full overflow-hidden rounded-full" style={{ backgroundColor: pullTheme.track }}>
344
+ <div
345
+ className="absolute inset-y-0 left-0 rounded-full transition-[width] duration-500 ease-out motion-reduce:transition-none"
346
+ style={{ width: `${percent * 100}%`, backgroundColor: failed ? pullTheme.error : pullTheme.downloading }}
347
+ />
348
+ {!failed && extractPercent > 0 && (
349
+ <div
350
+ className="absolute inset-y-0 left-0 rounded-full transition-[width] duration-500 ease-out motion-reduce:transition-none"
351
+ style={{
352
+ width: `${extractPercent * 100}%`,
353
+ // Green once everything is unpacked: the last colour change is the
354
+ // signal that the image is actually usable, not merely downloaded.
355
+ backgroundColor: extractPercent >= 1 ? pullTheme.done : pullTheme.extracting,
356
+ }}
357
+ />
358
+ )}
359
+ </div>
360
+ )
361
+ }
362
+
363
+ /** Registry contacted, not a single layer announced yet — nothing to segment. */
364
+ function IndeterminateBar() {
365
+ return (
366
+ <div className="relative h-2 w-full overflow-hidden rounded-full" style={{ backgroundColor: pullTheme.track }}>
367
+ <Sweep />
368
+ </div>
369
+ )
370
+ }
371
+
372
+ /** The moving highlight used by every indeterminate surface here. */
373
+ function Sweep() {
374
+ return (
375
+ <div
376
+ data-slot="image-pull-sweep"
377
+ className="absolute inset-y-0 w-1/3"
378
+ style={{
379
+ background: `linear-gradient(90deg, transparent, ${pullTheme.downloading}, transparent)`,
380
+ }}
381
+ />
382
+ )
383
+ }
384
+
385
+ function ImageRef({ image }: { image: string }) {
386
+ const { head, name, tag } = splitImageRef(image)
387
+ return (
388
+ // `overflow-hidden` is the safety net: whatever happens to the widths below,
389
+ // the reference gets clipped, never painted on top of the figures.
390
+ <div className="flex min-w-[6rem] flex-1 items-baseline overflow-hidden" title={image}>
391
+ {/* The registry/path head is the least useful part of a reference, so it's
392
+ the first thing to go: truncated when space gets tight, dropped
393
+ entirely on a narrow container. The name truncates too (long names
394
+ exist), the tag never does — it's the part that says *what* is being
395
+ pulled. */}
396
+ {head && (
397
+ // The head is the *only* shrinkable part, so it absorbs the whole
398
+ // squeeze on its own. Letting the name shrink too gives you
399
+ // "registry.spunt…worker-ba… :2.4.0" — two ellipses and no information;
400
+ // one ellipsis on the registry path reads fine.
401
+ <span className="hidden min-w-0 truncate @sm:inline" style={{ color: pullTheme.faint }}>
402
+ {head}
403
+ </span>
404
+ )}
405
+ <span className="shrink-0 whitespace-nowrap" style={{ color: pullTheme.dim }}>
406
+ {name}
407
+ </span>
408
+ {tag && (
409
+ <span className="shrink-0 whitespace-nowrap font-medium" style={{ color: pullTheme.tag }}>
410
+ {tag}
411
+ </span>
412
+ )}
413
+ </div>
414
+ )
415
+ }
416
+
417
+ function Aggregate({
418
+ state,
419
+ percent,
420
+ extractPercent,
421
+ unpacking,
422
+ stats,
423
+ rate,
424
+ eta,
425
+ elapsed,
426
+ layerCount,
427
+ }: {
428
+ state: ImagePullState
429
+ percent: number
430
+ extractPercent: number
431
+ unpacking: boolean
432
+ stats: { downloaded: number; extracted: number; total: number; estimated: boolean }
433
+ rate: number | null
434
+ eta: number | null
435
+ elapsed: number | null
436
+ layerCount: number
437
+ }) {
438
+ if (state === "cached") {
439
+ return (
440
+ <span className="shrink-0 whitespace-nowrap" style={{ color: pullTheme.faint }}>
441
+ déjà présente localement
442
+ </span>
443
+ )
444
+ }
445
+
446
+ if (state === "done") {
447
+ return (
448
+ <span className="shrink-0 whitespace-nowrap tabular-nums" style={{ color: pullTheme.done }}>
449
+ {elapsed != null ? `Pullée en ${formatDuration(elapsed)} · ` : ""}
450
+ {formatBytes(stats.total)}
451
+ </span>
452
+ )
453
+ }
454
+
455
+ if (state === "starting" || layerCount === 0) {
456
+ return (
457
+ <span className="shrink-0 whitespace-nowrap" style={{ color: pullTheme.faint }}>
458
+ contact du registre…
459
+ </span>
460
+ )
461
+ }
462
+
463
+ // Once every byte is down, the number that matters is the unpacking — quoting
464
+ // "100 %" while the image still isn't usable is the same lie as `cached`.
465
+ const shown = unpacking ? extractPercent : percent
466
+ const shownBytes = unpacking ? stats.extracted : stats.downloaded
467
+
468
+ return (
469
+ <span className="flex shrink-0 items-baseline gap-2 whitespace-nowrap tabular-nums">
470
+ <span style={{ color: state === "error" ? pullTheme.error : pullTheme.text }}>
471
+ {unpacking && <span style={{ color: pullTheme.extracting }}>extraction · </span>}
472
+ {Math.round(shown * 100)} %
473
+ <span style={{ color: pullTheme.faint }}>
474
+ {" · "}
475
+ {/* The absolute count is the honest number: unlike the percentage, it
476
+ can't be pushed backwards by a total that keeps growing. */}
477
+ {formatBytes(shownBytes)} / {stats.estimated ? "~" : ""}
478
+ {formatBytes(stats.total)}
479
+ </span>
480
+ </span>
481
+ {state === "pulling" && (rate || eta) && (
482
+ // Débit et ETA sont du confort : les premiers sacrifiés quand la place manque.
483
+ <span className="hidden @md:inline" style={{ color: pullTheme.faint }}>
484
+ {"· "}
485
+ {rate ? formatRate(rate) : ""}
486
+ {rate && eta ? " · " : ""}
487
+ {eta ? formatDuration(eta * 1000) : ""}
488
+ </span>
489
+ )}
490
+ </span>
491
+ )
492
+ }
493
+
494
+ /** `failed` = the pull errored and this layer never finished — the same red the bar uses. */
495
+ function LayerRow({ layer, failed }: { layer: PullLayer; failed?: boolean }) {
496
+ const known = !!(layer.total && layer.total > 0)
497
+ const fraction = known ? clamp01((layer.current ?? 0) / layer.total!) : 0
498
+ const done = layer.status === "done"
499
+ const color = failed ? pullTheme.error : STATUS_COLOR[layer.status]
500
+
501
+ return (
502
+ <div className="flex items-center gap-2.5 px-3 py-1.5">
503
+ <span className="w-3 shrink-0 text-center" style={{ color }}>
504
+ {done ? "✓" : layer.status === "waiting" ? "·" : "↓"}
505
+ </span>
506
+ <span className="w-[5.5rem] shrink-0 truncate @sm:w-[6.5rem]" style={{ color: done ? pullTheme.done : pullTheme.dim }}>
507
+ {layer.id.replace(/^sha256:/, "").slice(0, 12)}
508
+ </span>
509
+ <div className="relative h-1 min-w-0 flex-1 overflow-hidden rounded-full" style={{ backgroundColor: pullTheme.track }}>
510
+ {known || done ? (
511
+ <div
512
+ className="absolute inset-y-0 left-0 transition-[width] duration-500 ease-out motion-reduce:transition-none"
513
+ style={{ width: `${(done ? 1 : fraction) * 100}%`, backgroundColor: color }}
514
+ />
515
+ ) : layer.status !== "waiting" ? (
516
+ <Sweep />
517
+ ) : null}
518
+ </div>
519
+ {/* Narrow: only the bytes already in — the row's own bar carries the
520
+ fraction, so repeating "/ total" just squeezes that bar to nothing. */}
521
+ <span className="w-[4.5rem] shrink-0 text-right tabular-nums @sm:hidden" style={{ color: pullTheme.faint }}>
522
+ {known || done ? formatBytes(layer.current ?? 0) : "—"}
523
+ </span>
524
+ <span className="hidden w-[7.5rem] shrink-0 text-right tabular-nums @sm:inline" style={{ color: pullTheme.faint }}>
525
+ {known ? `${formatBytes(layer.current ?? 0)} / ${formatBytes(layer.total!)}` : done ? formatBytes(layer.current ?? 0) : "—"}
526
+ </span>
527
+ {/* The status dot already encodes the phase — the word is a wide-container bonus. */}
528
+ <span className="hidden w-24 shrink-0 text-right @lg:inline" style={{ color: done ? pullTheme.done : pullTheme.faint }}>
529
+ {STATUS_LABEL[layer.status]}
530
+ </span>
531
+ </div>
532
+ )
533
+ }
534
+
535
+ function StateIcon({ state }: { state: ImagePullState }) {
536
+ const common = "size-3.5 shrink-0"
537
+ if (state === "done")
538
+ return (
539
+ <svg viewBox="0 0 16 16" fill="none" className={common} style={{ color: pullTheme.done }} aria-hidden>
540
+ <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
541
+ <path d="m5 8.2 2.1 2.1L11 6.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
542
+ </svg>
543
+ )
544
+ if (state === "cached")
545
+ return (
546
+ // A disk, not a checkmark: nothing was downloaded, and the icon shouldn't
547
+ // suggest otherwise.
548
+ <svg viewBox="0 0 16 16" fill="none" className={common} style={{ color: pullTheme.faint }} aria-hidden>
549
+ <ellipse cx="8" cy="4" rx="5.5" ry="2.2" stroke="currentColor" strokeWidth="1.4" />
550
+ <path d="M2.5 4v8c0 1.2 2.5 2.2 5.5 2.2s5.5-1 5.5-2.2V4" stroke="currentColor" strokeWidth="1.4" />
551
+ <path d="M2.5 8c0 1.2 2.5 2.2 5.5 2.2s5.5-1 5.5-2.2" stroke="currentColor" strokeWidth="1.4" />
552
+ </svg>
553
+ )
554
+ if (state === "error")
555
+ return (
556
+ <svg viewBox="0 0 16 16" fill="none" className={common} style={{ color: pullTheme.error }} aria-hidden>
557
+ <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
558
+ <path d="M8 4.5v4.2M8 11.2v.6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
559
+ </svg>
560
+ )
561
+ return (
562
+ <svg viewBox="0 0 16 16" fill="none" className={common} style={{ color: pullTheme.downloading }} aria-hidden>
563
+ <path d="M8 2.5v7.5m0 0L5 7.2M8 10l3-2.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
564
+ <path d="M2.8 12.2h10.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
565
+ </svg>
566
+ )
567
+ }
568
+
569
+ function Chevron({ open }: { open: boolean }) {
570
+ return (
571
+ <svg
572
+ viewBox="0 0 16 16"
573
+ fill="none"
574
+ className={cn("size-3.5 transition-transform duration-200 motion-reduce:transition-none", open && "rotate-180")}
575
+ aria-hidden
576
+ >
577
+ <path d="m4 6.5 4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
578
+ </svg>
579
+ )
580
+ }
581
+
582
+ /**
583
+ * Bytes/second over a short sliding window of successive snapshots. Derived
584
+ * here rather than asked of the caller: the component already sees every
585
+ * snapshot, and a plain `current/elapsed` average would keep quoting a stale
586
+ * number long after the connection changed pace.
587
+ */
588
+ function useThroughput(downloaded: number, state: ImagePullState): number | null {
589
+ const samplesRef = useRef<Array<{ t: number; b: number }>>([])
590
+ const [rate, setRate] = useState<number | null>(null)
591
+
592
+ useEffect(() => {
593
+ if (state !== "pulling" && state !== "starting") {
594
+ samplesRef.current = []
595
+ setRate(null)
596
+ return
597
+ }
598
+ const now = Date.now()
599
+ const samples = samplesRef.current
600
+ samples.push({ t: now, b: downloaded })
601
+ while (samples.length > 2 && now - samples[0].t > 5000) samples.shift()
602
+
603
+ const first = samples[0]
604
+ const seconds = (now - first.t) / 1000
605
+ // Under half a second of window the number is noise — keep the previous one
606
+ // rather than flashing a wildly wrong rate.
607
+ if (seconds >= 0.5 && downloaded > first.b) setRate((downloaded - first.b) / seconds)
608
+ }, [downloaded, state])
609
+
610
+ return rate
611
+ }
612
+
613
+ /**
614
+ * Milliseconds since `startedAt`, ticking while the pull runs and frozen the
615
+ * moment it reaches a terminal state — that frozen value is what the collapsed
616
+ * "Pullée en 42 s" line reports. Returns null until mounted, so server and
617
+ * client render the same thing.
618
+ */
619
+ function useElapsed(startedAt: number | undefined, state: ImagePullState): number | null {
620
+ const [now, setNow] = useState<number | null>(null)
621
+ const [endedAt, setEndedAt] = useState<number | null>(null)
622
+ const settled = state === "done" || state === "cached" || state === "error"
623
+
624
+ useEffect(() => {
625
+ if (settled) {
626
+ // Stamped here, not on the last tick, so "Pullée en 42 s" isn't off by
627
+ // up to a second of interval slack.
628
+ setEndedAt(Date.now())
629
+ return
630
+ }
631
+ setEndedAt(null)
632
+ setNow(Date.now())
633
+ const id = setInterval(() => setNow(Date.now()), 1000)
634
+ return () => clearInterval(id)
635
+ }, [settled])
636
+
637
+ const reference = endedAt ?? now
638
+ if (startedAt == null || reference == null) return null
639
+ return Math.max(0, reference - startedAt)
640
+ }
641
+
642
+ export { ImagePull }
@@ -0,0 +1,38 @@
1
+ // Who spawned the worker, in 20 px.
2
+ //
3
+ // A plain <img>, deliberately: the design system is consumed by two Next apps
4
+ // *and* by a Vite showcase, so it can't reach for `next/image`. Avatars come
5
+ // from GitHub/Google CDNs already sized for this.
6
+
7
+ import { cn } from "../../utils"
8
+ import type { WorkerCreator } from "./types"
9
+
10
+ export function CreatorAvatar({ creator, className }: { creator: WorkerCreator; className?: string }) {
11
+ if (creator.avatarUrl) {
12
+ return (
13
+ <img
14
+ src={creator.avatarUrl}
15
+ alt={creator.name}
16
+ className={cn("h-5 w-5 shrink-0 rounded-full object-cover ring-1 ring-border", className)}
17
+ />
18
+ )
19
+ }
20
+
21
+ const initials = creator.name
22
+ .split(" ")
23
+ .map((n) => n[0])
24
+ .join("")
25
+ .toUpperCase()
26
+ .slice(0, 2)
27
+
28
+ return (
29
+ <span
30
+ className={cn(
31
+ "flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[9px] font-semibold text-muted-foreground ring-1 ring-border",
32
+ className
33
+ )}
34
+ >
35
+ {initials}
36
+ </span>
37
+ )
38
+ }