@spunto/design-system 0.20.0 → 0.22.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 +37 -0
- package/package.json +1 -1
- package/src/components/avatar.tsx +209 -7
- package/src/components/projects/build-steps.tsx +213 -0
- package/src/components/projects/index.ts +7 -0
- package/src/index.ts +5 -1
package/README.md
CHANGED
|
@@ -56,6 +56,20 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
56
56
|
`Separator`, `Spinner`, `Skeleton`, `Avatar` (+`AvatarImage`/`AvatarFallback`),
|
|
57
57
|
`Kbd` — one keyboard cap, or a whole shortcut (`keys="mod+k"` → ⌘ K, Ctrl K off Apple),
|
|
58
58
|
spelled out for screen readers.
|
|
59
|
+
- _AvatarGroup_ — the stack of overlapping avatars. `size` (`xs`…`xl`) sizes the whole
|
|
60
|
+
stack at once — on `Avatar` it sets the circle **and** its initials, because a 64 px
|
|
61
|
+
avatar with 14 px initials looks broken. **Compositional**: the group never reads a
|
|
62
|
+
`{ name, src }` shape, it wraps whatever you hand it (an `Avatar`, one inside a
|
|
63
|
+
`Tooltip`, a round `Skeleton` while the list loads) in its own span, so a loading
|
|
64
|
+
stack has exactly the geometry of the loaded one. It owns only what a caller can't
|
|
65
|
+
get right by hand: the overlap (a ratio of the diameter, `spacing` = ~40/29/20%), the
|
|
66
|
+
`ringClassName` that separates two faces (match the surface *behind* the stack —
|
|
67
|
+
`ring-card` inside a `Card`), the paint order, and the `+N`. Counting has two rules:
|
|
68
|
+
the counter adds the children `max` cut **and** the people never rendered
|
|
69
|
+
(`total` − children), so a paginated list shows `+38` without fabricating 38 ghost
|
|
70
|
+
avatars; and a `+1` never appears — one hidden child takes exactly the room its
|
|
71
|
+
counter would, saying less, so the face is drawn instead. `onOverflowClick` makes
|
|
72
|
+
the counter a real button.
|
|
59
73
|
- _Forms_ — `Input`, `Textarea`, `Label`, `Switch`, `Checkbox`,
|
|
60
74
|
`RadioGroup` (+`RadioGroupItem`), `Select` (+`SelectTrigger`/`SelectValue`/
|
|
61
75
|
`SelectContent`/`SelectItem`/`SelectGroup`/`SelectGroupLabel`/`SelectSeparator`).
|
|
@@ -332,6 +346,29 @@ And the read-only twin of that form:
|
|
|
332
346
|
- **Container queries** here too: the panel is 288 px in a sidebar on a 27" screen
|
|
333
347
|
and full width on a phone — and the desktop case is the narrow one.
|
|
334
348
|
|
|
349
|
+
And the build that configuration triggers, while it runs:
|
|
350
|
+
|
|
351
|
+
- **`BuildSteps`** — an image build as the **list of blocks it is made of**
|
|
352
|
+
(base image, bootstrap, one per devcontainer feature, VS Code extensions,
|
|
353
|
+
finalize, DinD seed) rather than a wall of log. Each row lights up in turn and
|
|
354
|
+
carries its own duration, so "what is it doing, and for how long" reads at a
|
|
355
|
+
glance and a slow feature is visibly *the* slow one.
|
|
356
|
+
- **The whole plan is drawn greyed out from the first frame**, instead of rows
|
|
357
|
+
appearing one by one: the list doubles as a table of contents for what the
|
|
358
|
+
image contains, the `done/total` counter has a denominator immediately, and
|
|
359
|
+
nothing shifts under the cursor for the four minutes of a build. A block
|
|
360
|
+
*absent* from the plan isn't `skipped` — `skipped` is a block that was reached
|
|
361
|
+
and had nothing to do.
|
|
362
|
+
- **Presentational, so it isn't only about Docker**: it knows no WebSocket, no
|
|
363
|
+
marker, no daemon — hand it a snapshot of timestamped blocks and it draws.
|
|
364
|
+
Anything that can describe itself as ordered blocks (a CI pipeline, a
|
|
365
|
+
migration, a multi-phase command) can feed it.
|
|
366
|
+
- **Timestamps in, durations out.** The running block counts from `startedAt`
|
|
367
|
+
itself, every second — a duration computed app-side freezes between two
|
|
368
|
+
snapshots and reads as a stuck build. Under a minute the package's
|
|
369
|
+
`formatDuration` is reused (one formatter, no drift); past it the format
|
|
370
|
+
switches to `2m 13s`.
|
|
371
|
+
|
|
335
372
|
## Marketing — `/marketing`
|
|
336
373
|
|
|
337
374
|
```tsx
|
package/package.json
CHANGED
|
@@ -1,18 +1,54 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import { Children, createContext, useContext, type ComponentProps, type ReactNode } from "react"
|
|
3
4
|
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
|
5
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
4
6
|
|
|
5
7
|
import { cn } from "../utils"
|
|
6
8
|
|
|
9
|
+
const avatarVariants = cva("relative flex shrink-0 overflow-hidden rounded-full bg-muted select-none", {
|
|
10
|
+
variants: {
|
|
11
|
+
// The font-size of each step lives here rather than on the fallback: the
|
|
12
|
+
// initials then *inherit* it, so one `size` sets the circle and its text
|
|
13
|
+
// together (a 64 px avatar with 14 px initials looks broken).
|
|
14
|
+
size: {
|
|
15
|
+
xs: "size-5 text-[0.5625rem]",
|
|
16
|
+
sm: "size-7 text-[0.6875rem]",
|
|
17
|
+
default: "size-9 text-sm",
|
|
18
|
+
lg: "size-12 text-base",
|
|
19
|
+
xl: "size-16 text-xl",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: {
|
|
23
|
+
size: "default",
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export type AvatarSize = NonNullable<VariantProps<typeof avatarVariants>["size"]>
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The size an `AvatarGroup` imposes on the avatars inside it, so a stack is
|
|
31
|
+
* sized once instead of on every child. `null` = no group above.
|
|
32
|
+
*/
|
|
33
|
+
const AvatarGroupSizeContext = createContext<AvatarSize | null>(null)
|
|
34
|
+
|
|
35
|
+
export interface AvatarProps extends AvatarPrimitive.Root.Props {
|
|
36
|
+
/**
|
|
37
|
+
* Diameter of the circle (20 / 28 / 36 / 48 / 64 px) — and the size of the
|
|
38
|
+
* fallback initials with it. Inside an `AvatarGroup`, defaults to the group's.
|
|
39
|
+
* @default "default"
|
|
40
|
+
*/
|
|
41
|
+
size?: AvatarSize
|
|
42
|
+
}
|
|
43
|
+
|
|
7
44
|
/** Circular avatar container. Compose `AvatarImage` + `AvatarFallback` inside. */
|
|
8
|
-
function Avatar({ className, ...props }:
|
|
45
|
+
function Avatar({ className, size, ...props }: AvatarProps) {
|
|
46
|
+
const groupSize = useContext(AvatarGroupSizeContext)
|
|
47
|
+
|
|
9
48
|
return (
|
|
10
49
|
<AvatarPrimitive.Root
|
|
11
50
|
data-slot="avatar"
|
|
12
|
-
className={cn(
|
|
13
|
-
"relative flex size-9 shrink-0 overflow-hidden rounded-full bg-muted select-none",
|
|
14
|
-
className
|
|
15
|
-
)}
|
|
51
|
+
className={cn(avatarVariants({ size: size ?? groupSize ?? undefined }), className)}
|
|
16
52
|
{...props}
|
|
17
53
|
/>
|
|
18
54
|
)
|
|
@@ -29,7 +65,8 @@ function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props)
|
|
|
29
65
|
<AvatarPrimitive.Fallback
|
|
30
66
|
data-slot="avatar-fallback"
|
|
31
67
|
className={cn(
|
|
32
|
-
|
|
68
|
+
// No text-size here on purpose — it comes from the root's `size`.
|
|
69
|
+
"flex size-full items-center justify-center bg-muted font-medium text-muted-foreground",
|
|
33
70
|
className
|
|
34
71
|
)}
|
|
35
72
|
{...props}
|
|
@@ -37,4 +74,169 @@ function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props)
|
|
|
37
74
|
)
|
|
38
75
|
}
|
|
39
76
|
|
|
40
|
-
export
|
|
77
|
+
export type AvatarGroupSpacing = "tight" | "default" | "loose"
|
|
78
|
+
|
|
79
|
+
// How far each avatar slides under the previous one. Expressed per size because
|
|
80
|
+
// an overlap is a *ratio* of the circle (~29% at default), and Tailwind needs
|
|
81
|
+
// whole class names — hence a table rather than a computed `-ml-${n}`.
|
|
82
|
+
const OVERLAP: Record<AvatarSize, Record<AvatarGroupSpacing, string>> = {
|
|
83
|
+
xs: { tight: "-ml-2", default: "-ml-1.5", loose: "-ml-1" },
|
|
84
|
+
sm: { tight: "-ml-3", default: "-ml-2", loose: "-ml-1.5" },
|
|
85
|
+
default: { tight: "-ml-3.5", default: "-ml-2.5", loose: "-ml-2" },
|
|
86
|
+
lg: { tight: "-ml-5", default: "-ml-3.5", loose: "-ml-2.5" },
|
|
87
|
+
xl: { tight: "-ml-6", default: "-ml-5", loose: "-ml-3.5" },
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The ring is what separates two overlapping circles — it has to grow with them.
|
|
91
|
+
const RING: Record<AvatarSize, string> = {
|
|
92
|
+
xs: "ring-[1.5px]",
|
|
93
|
+
sm: "ring-2",
|
|
94
|
+
default: "ring-2",
|
|
95
|
+
lg: "ring-2",
|
|
96
|
+
xl: "ring-[3px]",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface AvatarGroupProps extends Omit<ComponentProps<"div">, "children"> {
|
|
100
|
+
/** The avatars, in reading order. Each one paints over its left neighbour. */
|
|
101
|
+
children?: ReactNode
|
|
102
|
+
/**
|
|
103
|
+
* Size applied to every avatar in the stack (a child's own `size` still wins).
|
|
104
|
+
* @default "default"
|
|
105
|
+
*/
|
|
106
|
+
size?: AvatarSize
|
|
107
|
+
/**
|
|
108
|
+
* Show at most this many avatars; the rest collapse into a `+N` counter.
|
|
109
|
+
* Left unset, everything is drawn.
|
|
110
|
+
*/
|
|
111
|
+
max?: number
|
|
112
|
+
/**
|
|
113
|
+
* Real number of people, when the caller only renders a sample of them (a
|
|
114
|
+
* members list that fetched 5 avatars out of 40 → `total={40}` counts the 35
|
|
115
|
+
* it never received). Defaults to the number of children.
|
|
116
|
+
*/
|
|
117
|
+
total?: number
|
|
118
|
+
/**
|
|
119
|
+
* How much of each avatar the next one covers — ~40%, ~29%, ~20%.
|
|
120
|
+
* @default "default"
|
|
121
|
+
*/
|
|
122
|
+
spacing?: AvatarGroupSpacing
|
|
123
|
+
/**
|
|
124
|
+
* Ring color punched between overlapping avatars: it must match the surface
|
|
125
|
+
* *behind* the stack, so inside a `Card` it's `ring-card`.
|
|
126
|
+
* @default "ring-background"
|
|
127
|
+
*/
|
|
128
|
+
ringClassName?: string
|
|
129
|
+
/**
|
|
130
|
+
* Hovering an avatar brings it to the front (and lifts it a hair), which is
|
|
131
|
+
* how a covered face becomes readable without a click.
|
|
132
|
+
* @default true
|
|
133
|
+
*/
|
|
134
|
+
revealOnHover?: boolean
|
|
135
|
+
/** Makes the `+N` counter a real button — "see all members". */
|
|
136
|
+
onOverflowClick?: () => void
|
|
137
|
+
/** Accessible name of that button. @default "Voir tout" */
|
|
138
|
+
overflowLabel?: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A stack of overlapping avatars — the compact way to say "these people".
|
|
143
|
+
*
|
|
144
|
+
* ```tsx
|
|
145
|
+
* <AvatarGroup size="sm" max={4} total={members.length}>
|
|
146
|
+
* {members.map((m) => (
|
|
147
|
+
* <Avatar key={m.id}>
|
|
148
|
+
* <AvatarImage src={m.avatarUrl} alt={m.name} />
|
|
149
|
+
* <AvatarFallback>{initials(m.name)}</AvatarFallback>
|
|
150
|
+
* </Avatar>
|
|
151
|
+
* ))}
|
|
152
|
+
* </AvatarGroup>
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* Purely presentational, and deliberately **compositional**: the group never
|
|
156
|
+
* reads a `{ name, src }` shape, it wraps whatever you put in it — an `Avatar`,
|
|
157
|
+
* one inside a `Tooltip`, a `Skeleton` while the list loads. It only owns what
|
|
158
|
+
* a caller can't get right by hand: the overlap, the ring that separates two
|
|
159
|
+
* faces, the paint order, and the `+N`.
|
|
160
|
+
*/
|
|
161
|
+
function AvatarGroup({
|
|
162
|
+
className,
|
|
163
|
+
children,
|
|
164
|
+
size = "default",
|
|
165
|
+
max,
|
|
166
|
+
total,
|
|
167
|
+
spacing = "default",
|
|
168
|
+
ringClassName = "ring-background",
|
|
169
|
+
revealOnHover = true,
|
|
170
|
+
onOverflowClick,
|
|
171
|
+
overflowLabel = "Voir tout",
|
|
172
|
+
...props
|
|
173
|
+
}: AvatarGroupProps) {
|
|
174
|
+
const items = Children.toArray(children)
|
|
175
|
+
const limit = max != null && max > 0 ? max : items.length
|
|
176
|
+
|
|
177
|
+
let shown = items.slice(0, limit)
|
|
178
|
+
// People the caller knows about but didn't render at all.
|
|
179
|
+
const unrendered = Math.max(0, (total ?? items.length) - items.length)
|
|
180
|
+
let overflow = items.length - shown.length + unrendered
|
|
181
|
+
|
|
182
|
+
// A single hidden child costs exactly the room a "+1" would take, and says
|
|
183
|
+
// strictly less. Draw the face instead.
|
|
184
|
+
if (overflow === 1 && unrendered === 0) {
|
|
185
|
+
shown = items
|
|
186
|
+
overflow = 0
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const item = (node: ReactNode, index: number) => (
|
|
190
|
+
<span
|
|
191
|
+
key={index}
|
|
192
|
+
data-slot="avatar-group-item"
|
|
193
|
+
className={cn(
|
|
194
|
+
"relative inline-flex rounded-full",
|
|
195
|
+
RING[size],
|
|
196
|
+
ringClassName,
|
|
197
|
+
index > 0 && OVERLAP[size][spacing],
|
|
198
|
+
revealOnHover && "transition-transform duration-150 hover:z-10 hover:-translate-y-0.5"
|
|
199
|
+
)}
|
|
200
|
+
>
|
|
201
|
+
{node}
|
|
202
|
+
</span>
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const counter = `+${overflow}`
|
|
206
|
+
const counterClass = cn(
|
|
207
|
+
avatarVariants({ size }),
|
|
208
|
+
"items-center justify-center font-medium text-muted-foreground"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<AvatarGroupSizeContext.Provider value={size}>
|
|
213
|
+
<div data-slot="avatar-group" className={cn("flex items-center", className)} {...props}>
|
|
214
|
+
{shown.map(item)}
|
|
215
|
+
{overflow > 0 &&
|
|
216
|
+
item(
|
|
217
|
+
onOverflowClick ? (
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
data-slot="avatar-group-overflow"
|
|
221
|
+
aria-label={overflowLabel}
|
|
222
|
+
onClick={onOverflowClick}
|
|
223
|
+
className={cn(
|
|
224
|
+
counterClass,
|
|
225
|
+
"cursor-pointer transition-colors hover:bg-primary/15 hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
226
|
+
)}
|
|
227
|
+
>
|
|
228
|
+
{counter}
|
|
229
|
+
</button>
|
|
230
|
+
) : (
|
|
231
|
+
<span data-slot="avatar-group-overflow" className={counterClass}>
|
|
232
|
+
{counter}
|
|
233
|
+
</span>
|
|
234
|
+
),
|
|
235
|
+
shown.length
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
</AvatarGroupSizeContext.Provider>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup }
|
|
@@ -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
|
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,11 @@ export { RadioGroup, RadioGroupItem } from "./components/radio-group"
|
|
|
23
23
|
export { Separator } from "./components/separator"
|
|
24
24
|
export { Skeleton } from "./components/skeleton"
|
|
25
25
|
export { Spinner, spinnerVariants } from "./components/spinner"
|
|
26
|
-
|
|
26
|
+
// Avatar + the stack of overlapping avatars (`AvatarGroup`) — compositional:
|
|
27
|
+
// the group wraps whatever you put in it and owns the overlap, the ring, the
|
|
28
|
+
// paint order and the `+N`.
|
|
29
|
+
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup } from "./components/avatar"
|
|
30
|
+
export type { AvatarProps, AvatarSize, AvatarGroupProps, AvatarGroupSpacing } from "./components/avatar"
|
|
27
31
|
|
|
28
32
|
// xterm.js surface — theme + fit-on-resize, transport-agnostic (feed it via
|
|
29
33
|
// the write/writeln ref handle).
|