@spunto/design-system 0.9.2 → 0.9.3

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
@@ -73,6 +73,36 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
73
73
  provider is also the umbrella that mounts the tooltip delay group and the overlay
74
74
  portal container the dialogs/selects render into.
75
75
 
76
+ ## Domain components — `@spunto/design-system/workers`
77
+
78
+ Everything above is domain-free: a `Button` knows nothing about Spunto. Components
79
+ that **do** know a Spunto concept live behind their own entry point, so the root
80
+ export stays primitives-only and a third-party consumer never carries the notion
81
+ of a worker. **The rule for what comes next: any new component that knows a domain
82
+ concept goes behind a domain sub-export, never in the root entry.**
83
+
84
+ ```tsx
85
+ import { Button, Card } from "@spunto/design-system" // primitives
86
+ import { WorkerCard } from "@spunto/design-system/workers" // domain
87
+ ```
88
+
89
+ - **`WorkerCard`** — a Spunto worker as a card: state, author, node, setup
90
+ progress, git branches, resources, banners, actions. **Purely presentational** —
91
+ hand it a snapshot plus slots and it draws. The rule that keeps it that way:
92
+ *everything that CALLS the API is a slot or a callback; everything that DRAWS is
93
+ in the component.* So the `⋯` menu (`actions`), the footer (`footer`), the link
94
+ (`href` + `render.link`, never `next/link`), the rebuild (`onRebuild`) and the
95
+ tag persistence (`onAddTag`/`onRemoveTag`) all stay in the app.
96
+ - **Its bricks, exported individually** because the table view assembles them
97
+ differently: `WorkerStatusDot`/`WorkerStatusPill` (+`resolveWorkerStatus`,
98
+ `workerStatusConfig`), `SetupProgress` (+`setupProgress`, `phaseLabel`,
99
+ `setupTotalMs`), `StepIndicator`, `ResourceBars`, `GitBranchChips`/
100
+ `GitStatusSummary`, `TagChips` (+`tagColor`), `CreatorAvatar`.
101
+ - **Types are structural**, never the apps' OpenAPI types: the dashboard and
102
+ Spunto Lite model a worker differently, so `WorkerCardWorker` asks for the
103
+ minimum, everything optional but `id`, and an unknown state falls back to
104
+ `pending` instead of throwing.
105
+
76
106
  ## Toasts — `SpuntoProvider` + `toast()`
77
107
 
78
108
  `SpuntoProvider` is the design system's client umbrella provider. Mount it once
@@ -145,3 +175,7 @@ automatically (see `showcase/vite-plugin-llms.ts`).
145
175
  ## Peer requirements
146
176
 
147
177
  - React 19+, and a **Tailwind CSS v4** pipeline in the consuming app.
178
+ - `lucide-react` is a **regular dependency** (since `0.9.3`): the worker
179
+ components use ~15 icons, and inlining them as SVG or passing them all as props
180
+ would have been worse than the dependency. It's tree-shakable and both apps
181
+ already ship it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,6 +33,10 @@
33
33
  "types": "./src/colors.ts",
34
34
  "import": "./src/colors.ts"
35
35
  },
36
+ "./workers": {
37
+ "types": "./src/components/workers/index.ts",
38
+ "import": "./src/components/workers/index.ts"
39
+ },
36
40
  "./fonts": {
37
41
  "types": "./src/components/fonts.tsx",
38
42
  "import": "./src/components/fonts.tsx"
@@ -54,6 +58,7 @@
54
58
  "@xterm/xterm": "^6.0.0",
55
59
  "class-variance-authority": "^0.7.1",
56
60
  "clsx": "^2.1.1",
61
+ "lucide-react": "^1.7.0",
57
62
  "tailwind-merge": "^3.6.0"
58
63
  },
59
64
  "peerDependencies": {
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ // "repo · branch +3" chips — what a worker is actually working on.
2
+ //
3
+ // The dashboard's version shows the modified count; Lite's also shows ahead /
4
+ // behind. This is the union: every counter is rendered only when the caller
5
+ // supplies it, so neither app has to send a field it doesn't have.
6
+
7
+ import { ArrowDownIcon, ArrowUpIcon, ExternalLinkIcon, GitBranchIcon } from "lucide-react"
8
+
9
+ import { cn } from "../../utils"
10
+
11
+ export interface GitRepoStatus {
12
+ /** Absolute path of the checkout inside the worker — also the React key. */
13
+ path: string
14
+ branch?: string | null
15
+ /** Display name; defaults to the last segment of `path`. */
16
+ name?: string
17
+ /** Uncommitted files. */
18
+ modified?: number
19
+ /** Commits ahead of / behind the upstream branch. */
20
+ ahead?: number
21
+ behind?: number
22
+ }
23
+
24
+ export interface GitBranchChipsProps {
25
+ repos: GitRepoStatus[]
26
+ /**
27
+ * Chip → URL. A slot, not a link component: the design system knows nothing
28
+ * about code-server URLs, and returning `undefined` renders a plain span.
29
+ */
30
+ repoHref?: (repo: GitRepoStatus) => string | undefined
31
+ className?: string
32
+ }
33
+
34
+ export function repoName(repo: GitRepoStatus): string {
35
+ return repo.name ?? repo.path.split("/").filter(Boolean).pop() ?? repo.path
36
+ }
37
+
38
+ /** One chip per checked-out repository, wrapping onto as many lines as needed. */
39
+ export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsProps) {
40
+ if (!repos.length) return null
41
+
42
+ return (
43
+ // `@container` so the branch cap below reads *this row's* width: the same
44
+ // chips sit in a 600 px card on a desktop and in a 290 px one on a phone,
45
+ // and a viewport media query can't tell those apart.
46
+ <div data-slot="git-branch-chips" className={cn("@container flex flex-wrap gap-1.5", className)}>
47
+ {repos.map((repo) => {
48
+ const href = repoHref?.(repo)
49
+ const Tag = href ? "a" : "span"
50
+ return (
51
+ <Tag
52
+ key={repo.path}
53
+ {...(href ? { href, target: "_blank", rel: "noopener noreferrer" } : {})}
54
+ title={repo.path}
55
+ // `max-w-full` is what keeps a chip inside the card: without it a
56
+ // long "repo · branch" is a single unbreakable row that runs under
57
+ // the card's rounded edge and gets clipped.
58
+ className={cn(
59
+ "inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground",
60
+ href && "transition-colors hover:border-border/80 hover:bg-muted/70"
61
+ )}
62
+ >
63
+ <span className="shrink-0 font-medium text-foreground/70">{repoName(repo)}</span>
64
+ <span className="mx-0.5 shrink-0 text-muted-foreground/40">·</span>
65
+ <GitBranchIcon className="h-2.5 w-2.5 shrink-0" />
66
+ {/* The branch is the only part allowed to give up room — the repo
67
+ name and the counters are short and carry more meaning. */}
68
+ <span className="min-w-0 max-w-[6rem] truncate @min-[20rem]:max-w-[120px]">{repo.branch || "—"}</span>
69
+ <RepoCounters repo={repo} />
70
+ {href && <ExternalLinkIcon className="ml-0.5 h-2.5 w-2.5 shrink-0 opacity-40" />}
71
+ </Tag>
72
+ )
73
+ })}
74
+ </div>
75
+ )
76
+ }
77
+
78
+ /** Amber for local work not yet committed/pushed, muted for what's merely behind. */
79
+ function RepoCounters({ repo }: { repo: GitRepoStatus }) {
80
+ return (
81
+ <>
82
+ {!!repo.modified && <span className="ml-0.5 shrink-0 text-amber-500 dark:text-amber-400">+{repo.modified}</span>}
83
+ {!!repo.ahead && (
84
+ <span className="ml-0.5 inline-flex shrink-0 items-center text-amber-500 dark:text-amber-400">
85
+ <ArrowUpIcon className="h-2.5 w-2.5" />
86
+ {repo.ahead}
87
+ </span>
88
+ )}
89
+ {!!repo.behind && (
90
+ <span className="ml-0.5 inline-flex shrink-0 items-center text-muted-foreground/70">
91
+ <ArrowDownIcon className="h-2.5 w-2.5" />
92
+ {repo.behind}
93
+ </span>
94
+ )}
95
+ </>
96
+ )
97
+ }
98
+
99
+ export interface GitStatusSummaryProps {
100
+ repos: GitRepoStatus[]
101
+ /** Heading above the list — e.g. "Open in VS Code" inside a tooltip. */
102
+ title?: string
103
+ className?: string
104
+ }
105
+
106
+ /**
107
+ * The same information as the chips, stacked one repo per line. Meant for the
108
+ * inside of a tooltip or a popover, where chips would wrap badly — the tooltip
109
+ * itself stays the app's business (the design system doesn't decide what opens
110
+ * it).
111
+ */
112
+ export function GitStatusSummary({ repos, title, className }: GitStatusSummaryProps) {
113
+ if (!repos.length) return null
114
+
115
+ return (
116
+ <span data-slot="git-status-summary" className={cn("flex flex-col gap-1", className)}>
117
+ {title && <span className="text-[10px] uppercase tracking-wide opacity-60">{title}</span>}
118
+ {repos.map((repo) => (
119
+ <span key={repo.path} className="flex items-center gap-1.5">
120
+ <span className="font-medium">{repoName(repo)}</span>
121
+ <GitBranchIcon className="h-3 w-3 opacity-60" />
122
+ <span className="font-mono">{repo.branch || "—"}</span>
123
+ <RepoCounters repo={repo} />
124
+ </span>
125
+ ))}
126
+ </span>
127
+ )
128
+ }
@@ -0,0 +1,35 @@
1
+ // @spunto/design-system/workers — Spunto's *domain* components.
2
+ //
3
+ // Deliberately a sub-export rather than part of the main entry: the root of the
4
+ // package stays "primitives with no domain" (a Button knows nothing about a
5
+ // worker), and a third party consuming the design system never has to carry the
6
+ // notion of a worker. New business components go here — see the README's
7
+ // "Domain components" section for the rule.
8
+
9
+ export { WorkerCard } from "./worker-card"
10
+ export type { WorkerCardProps, LinkRender } from "./worker-card"
11
+
12
+ export { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus, workerStatusConfig } from "./worker-status"
13
+ export type { WorkerStatus, WorkerStatusConfig, WorkerStatusKey } from "./worker-status"
14
+
15
+ export { SetupProgress, StepIndicator, phaseLabel, setupProgress, setupTotalMs } from "./setup-progress"
16
+ export type { SetupProgressProps, StepState } from "./setup-progress"
17
+
18
+ export { ResourceBars } from "./resource-bars"
19
+ export type { ResourceBarsProps, WorkerStats } from "./resource-bars"
20
+
21
+ export { GitBranchChips, GitStatusSummary, repoName } from "./git-branch-chips"
22
+ export type { GitBranchChipsProps, GitRepoStatus, GitStatusSummaryProps } from "./git-branch-chips"
23
+
24
+ export { TagChips } from "./tag-chips"
25
+ export type { TagChipsProps } from "./tag-chips"
26
+ export { tagColor } from "./tag-color"
27
+ export type { TagPalette } from "./tag-color"
28
+
29
+ export { CreatorAvatar } from "./creator-avatar"
30
+
31
+ export type { WorkerCardWorker, WorkerCreator, WorkerSetupStatus } from "./types"
32
+
33
+ // Re-exported here too so a consumer of the worker surface doesn't have to
34
+ // import the root entry just to format the timestamps it feeds these components.
35
+ export { cn, formatDuration, formatRelativeTime } from "../../utils"
@@ -0,0 +1,104 @@
1
+ // CPU + memory of a running container, as two thin bars.
2
+ //
3
+ // Written identically in the dashboard's card and its table row — the `compact`
4
+ // flag is the only thing that ever differed between the two copies.
5
+
6
+ import type { ReactNode } from "react"
7
+ import { CpuIcon, MemoryStickIcon } from "lucide-react"
8
+
9
+ import { cn } from "../../utils"
10
+
11
+ export interface WorkerStats {
12
+ cpuPercent: number
13
+ memUsageMb: number
14
+ memLimitMb?: number
15
+ memPercent?: number
16
+ }
17
+
18
+ export interface ResourceBarsProps {
19
+ stats: WorkerStats
20
+ /** Drops the framed panel — just the two bars, for a table cell or a tooltip. */
21
+ compact?: boolean
22
+ className?: string
23
+ }
24
+
25
+ /** Past this, the bar turns red: the container is about to be the problem. */
26
+ const DANGER = 80
27
+
28
+ export function ResourceBars({ stats, compact = false, className }: ResourceBarsProps) {
29
+ const cpu = stats.cpuPercent ?? 0
30
+ const mem = stats.memPercent ?? 0
31
+
32
+ return (
33
+ <div
34
+ data-slot="resource-bars"
35
+ className={cn(
36
+ // Side by side as soon as there's room for it, stacked below ~240 px —
37
+ // under that the label and its figure end up touching. Container query,
38
+ // not a media query: these bars live in a card, a table cell and a
39
+ // sidebar, and only their own width tells them what to do.
40
+ "@container grid grid-cols-1 gap-3 @min-[15rem]:grid-cols-2",
41
+ !compact && "rounded-lg border border-border/40 bg-muted/20 px-3 py-2.5",
42
+ className
43
+ )}
44
+ >
45
+ <Metric
46
+ icon={<CpuIcon className="h-3 w-3" />}
47
+ label="CPU"
48
+ value={`${cpu.toFixed(1)}%`}
49
+ valueClass={cpu > DANGER ? "text-orange-500 dark:text-orange-400" : "text-foreground/70"}
50
+ percent={cpu}
51
+ // Flame for CPU, Build amber for memory: two bars side by side need to be
52
+ // told apart at a glance, and both hues already mean "compute" here.
53
+ barClass={cpu > DANGER ? "bg-destructive" : "bg-primary"}
54
+ />
55
+ <Metric
56
+ icon={<MemoryStickIcon className="h-3 w-3" />}
57
+ label="Memory"
58
+ value={
59
+ <>
60
+ {(stats.memUsageMb ?? 0).toFixed(0)}
61
+ <span className="text-muted-foreground/50"> MB</span>
62
+ </>
63
+ }
64
+ valueClass="text-foreground/70"
65
+ percent={mem}
66
+ barClass={mem > DANGER ? "bg-destructive" : "bg-build"}
67
+ />
68
+ </div>
69
+ )
70
+ }
71
+
72
+ function Metric({
73
+ icon,
74
+ label,
75
+ value,
76
+ valueClass,
77
+ percent,
78
+ barClass,
79
+ }: {
80
+ icon: ReactNode
81
+ label: string
82
+ value: ReactNode
83
+ valueClass: string
84
+ percent: number
85
+ barClass: string
86
+ }) {
87
+ return (
88
+ <div>
89
+ <div className="mb-1.5 flex items-center justify-between gap-2">
90
+ <span className="flex min-w-0 items-center gap-1 truncate text-[11px] text-muted-foreground">
91
+ {icon}
92
+ {label}
93
+ </span>
94
+ <span className={cn("shrink-0 font-mono text-[11px] tabular-nums", valueClass)}>{value}</span>
95
+ </div>
96
+ <div className="h-1 overflow-hidden rounded-full bg-muted">
97
+ <div
98
+ className={cn("h-full rounded-full transition-all duration-500 motion-reduce:transition-none", barClass)}
99
+ style={{ width: `${Math.min(Math.max(percent, 0), 100)}%` }}
100
+ />
101
+ </div>
102
+ </div>
103
+ )
104
+ }
@@ -0,0 +1,130 @@
1
+ // Worker setup, as a percentage and a sentence.
2
+ //
3
+ // The phase → percentage switch and the phase → label switch were written twice,
4
+ // by hand, in two apps — and are the kind of thing that drifts silently (a phase
5
+ // added on the agent side lands in one app's switch and not the other's). One
6
+ // copy, here, plus the bar itself.
7
+ //
8
+ // Hookless, so it stays callable from a React Server Component.
9
+
10
+ import { CheckIcon, CircleIcon, LoaderIcon } from "lucide-react"
11
+
12
+ import { cn } from "../../utils"
13
+ import type { WorkerSetupStatus } from "./types"
14
+
15
+ /**
16
+ * Setup completion, 0-100.
17
+ *
18
+ * The numbers are deliberately uneven: they're calibrated on how long each phase
19
+ * actually takes, not on how many phases there are. Cloning gets a 30-point
20
+ * span sub-divided by repository, because it's the phase where a user watches
21
+ * the longest and the only one with real sub-progress to report.
22
+ */
23
+ export function setupProgress(status: WorkerSetupStatus | null | undefined): number {
24
+ if (!status) return 5
25
+ switch (status.phase) {
26
+ case "initializing": return 10
27
+ case "credentials": return 25
28
+ case "dotfiles": return 35
29
+ case "cloning": {
30
+ const repos = status.repos ?? []
31
+ const done = repos.filter((r) => r.state === "done").length
32
+ const total = repos.length || 1
33
+ return 40 + Math.round((done / total) * 30)
34
+ }
35
+ case "lifecycle": {
36
+ if (status.postCreate === "running") return 75
37
+ if (status.postCreate === "done" && status.postStart === "running") return 88
38
+ return 80
39
+ }
40
+ case "ready": return 100
41
+ case "error": return 100
42
+ default: return 5
43
+ }
44
+ }
45
+
46
+ /** Human phase label — the line printed above the bar. */
47
+ export function phaseLabel(status: WorkerSetupStatus | null | undefined): string {
48
+ if (!status) return "Starting up…"
49
+ switch (status.phase) {
50
+ case "initializing": return "Starting up…"
51
+ case "credentials": return "Configuring credentials…"
52
+ case "dotfiles": return "Installing dotfiles…"
53
+ case "cloning": return "Cloning repositories…"
54
+ case "lifecycle":
55
+ if (status.postCreate === "running") return "Running post-create command…"
56
+ if (status.postStart === "running") return "Running post-start command…"
57
+ return "Running setup commands…"
58
+ case "ready": return "Ready"
59
+ case "error": return "Setup failed"
60
+ default: return "Setting up…"
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Total startup duration (ms) from the container-side phase timings, or null
66
+ * when we can't bracket it — old or crashed workers have no `ready` stamp, and
67
+ * an invented duration would be worse than no duration.
68
+ */
69
+ export function setupTotalMs(status: WorkerSetupStatus | null | undefined): number | null {
70
+ const t = status?.timings
71
+ if (!t || t.initializing == null || t.ready == null) return null
72
+ const d = t.ready - t.initializing
73
+ return d > 0 ? d : null
74
+ }
75
+
76
+ export interface SetupProgressProps {
77
+ status?: WorkerSetupStatus | null
78
+ /** Hides the phase line + percentage, keeping only the bar (table rows). */
79
+ barOnly?: boolean
80
+ className?: string
81
+ }
82
+
83
+ /** Phase label + percentage + the bar itself. Red as soon as the setup failed. */
84
+ export function SetupProgress({ status, barOnly = false, className }: SetupProgressProps) {
85
+ const pct = setupProgress(status)
86
+ const failed = status?.phase === "error"
87
+
88
+ return (
89
+ <div className={cn("w-full", className)}>
90
+ {!barOnly && (
91
+ <div className="mb-1.5 flex items-center justify-between gap-2">
92
+ <span
93
+ className={cn(
94
+ "min-w-0 truncate font-mono text-[11px]",
95
+ failed ? "text-destructive" : "text-yellow-500 dark:text-yellow-400"
96
+ )}
97
+ >
98
+ {phaseLabel(status)}
99
+ </span>
100
+ <span className="shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground">{pct}%</span>
101
+ </div>
102
+ )}
103
+ <div className="h-1 overflow-hidden rounded-full bg-muted">
104
+ <div
105
+ className={cn(
106
+ "h-full rounded-full transition-all duration-700 motion-reduce:transition-none",
107
+ failed ? "bg-destructive" : "bg-yellow-400"
108
+ )}
109
+ style={{ width: `${pct}%` }}
110
+ />
111
+ </div>
112
+ </div>
113
+ )
114
+ }
115
+
116
+ export type StepState = "pending" | "running" | "done" | "error"
117
+
118
+ /**
119
+ * The per-step bullet of a detailed setup view (one repository being cloned, a
120
+ * lifecycle command running). Same four states, same four colours, everywhere.
121
+ */
122
+ export function StepIndicator({ state, className }: { state: StepState; className?: string }) {
123
+ const common = cn("h-3 w-3 shrink-0", className)
124
+
125
+ if (state === "running")
126
+ return <LoaderIcon className={cn(common, "animate-spin text-yellow-400 motion-reduce:animate-none")} />
127
+ if (state === "done") return <CheckIcon className={cn(common, "text-green-500")} />
128
+ if (state === "error") return <CircleIcon className={cn(common, "fill-destructive text-destructive")} />
129
+ return <CircleIcon className={cn(common, "text-muted-foreground/30")} />
130
+ }
@@ -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
+ }
@@ -0,0 +1,181 @@
1
+ // The single state → colours/label table both worker views read from.
2
+ //
3
+ // It used to live twice — `STATUS_CONFIG` in the dashboard and its hand-copied
4
+ // twin in Spunto Lite — and the two had already drifted (different labels for
5
+ // the same docker state, transient states handled in one and not the other).
6
+ // This module is the union of both, plus the transient states (`pulling`,
7
+ // `stopping`, `deleting`) that only ever existed as ad-hoc `if`s above the table.
8
+ //
9
+ // No "use client": pure functions and hookless components, so a React Server
10
+ // Component can call them.
11
+
12
+ import { cn } from "../../utils"
13
+ import type { WorkerCardWorker } from "./types"
14
+
15
+ export type WorkerStatusKey =
16
+ | "running"
17
+ | "stopped"
18
+ | "exited"
19
+ | "created"
20
+ | "not_found"
21
+ | "error"
22
+ | "pending"
23
+ | "setup"
24
+ | "pulling"
25
+ | "stopping"
26
+ | "deleting"
27
+
28
+ export interface WorkerStatusConfig {
29
+ label: string
30
+ /** Class for the 8 px dot. */
31
+ dotClass: string
32
+ /** Classes for the rounded state pill (background + text + border). */
33
+ pillClass: string
34
+ /** Left border accent, for surfaces that stripe the card by state. */
35
+ accent: string
36
+ }
37
+
38
+ // Status semantics follow docs/design-system.md: green = running/healthy,
39
+ // amber/yellow = in progress or warning, red = failure, neutral = idle. Errors
40
+ // go through the `destructive` token rather than a raw red so they track the
41
+ // theme; green/yellow have no token, so the Tailwind palette stays.
42
+ const RUNNING: WorkerStatusConfig = {
43
+ label: "Running",
44
+ dotClass: "bg-green-500",
45
+ pillClass: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
46
+ accent: "border-l-green-500",
47
+ }
48
+
49
+ const IDLE: WorkerStatusConfig = {
50
+ label: "Stopped",
51
+ dotClass: "bg-muted-foreground/50",
52
+ pillClass: "bg-muted text-muted-foreground border-border",
53
+ accent: "border-l-border",
54
+ }
55
+
56
+ const BUSY: WorkerStatusConfig = {
57
+ label: "Starting…",
58
+ dotClass: "bg-yellow-400",
59
+ pillClass: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
60
+ accent: "border-l-yellow-400",
61
+ }
62
+
63
+ export const workerStatusConfig: Record<WorkerStatusKey, WorkerStatusConfig> = {
64
+ running: RUNNING,
65
+ stopped: IDLE,
66
+ exited: { ...IDLE, label: "Exited" },
67
+ created: { ...BUSY, label: "Created" },
68
+ not_found: {
69
+ label: "Not found",
70
+ dotClass: "bg-orange-400",
71
+ pillClass: "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
72
+ accent: "border-l-orange-400",
73
+ },
74
+ error: {
75
+ label: "Error",
76
+ dotClass: "bg-destructive",
77
+ pillClass: "bg-destructive/10 text-destructive border-destructive/20",
78
+ accent: "border-l-destructive",
79
+ },
80
+ pending: BUSY,
81
+ setup: { ...BUSY, label: "Setting up…" },
82
+ pulling: { ...BUSY, label: "Pulling image…" },
83
+ stopping: { ...BUSY, label: "Stopping…" },
84
+ deleting: { ...BUSY, label: "Deleting…" },
85
+ }
86
+
87
+ export interface WorkerStatus extends WorkerStatusConfig {
88
+ key: WorkerStatusKey
89
+ /** The worker is still being provisioned — the setup bar is the interesting part. */
90
+ settingUp: boolean
91
+ /** Container up and past its setup — stats, git chips and the pulsing dot apply. */
92
+ running: boolean
93
+ }
94
+
95
+ /** Lifecycle states that mean "not usable yet, a setup is in flight". */
96
+ const SETUP_STATES = new Set(["provisioning", "starting", "setup"])
97
+
98
+ /**
99
+ * Worker snapshot → one status, tolerant of shapes it has never seen.
100
+ *
101
+ * Order matters: the *lifecycle* state wins whenever it describes an action in
102
+ * flight (deleting, stopping, pulling, setting up), because docker still reports
103
+ * the container as `running` throughout — showing "Running" while a worker is
104
+ * being deleted is the bug this ordering exists to prevent. Past that, the
105
+ * docker state is the truth. Apps with a single `state` field (Spunto Lite) fall
106
+ * through to the last block, and anything unknown lands on `pending` rather than
107
+ * throwing.
108
+ */
109
+ export function resolveWorkerStatus(worker: WorkerCardWorker): WorkerStatus {
110
+ const state = worker.state ?? null
111
+ const docker = worker.dockerState ?? null
112
+
113
+ const of = (key: WorkerStatusKey, flags?: Partial<Pick<WorkerStatus, "settingUp" | "running">>): WorkerStatus => ({
114
+ key,
115
+ ...workerStatusConfig[key],
116
+ settingUp: false,
117
+ running: key === "running",
118
+ ...flags,
119
+ })
120
+
121
+ if (state === "deleting") return of("deleting")
122
+ if (state === "stopping") return of("stopping")
123
+ if (state === "pulling") return of("pulling", { settingUp: true })
124
+ if (state && SETUP_STATES.has(state)) return of("setup", { settingUp: true })
125
+
126
+ if (docker && docker in workerStatusConfig) return of(docker as WorkerStatusKey)
127
+ if (docker) return of("pending")
128
+
129
+ // Single-`state` apps (Lite): no docker level to consult.
130
+ if (state === "ready") return of("running")
131
+ if (state === "error") return of("error")
132
+ if (state === "stopped") return of("stopped")
133
+ return of("pending")
134
+ }
135
+
136
+ /**
137
+ * The 8 px state dot. It pings while the worker runs — the one animation on the
138
+ * card, reserved for the one state where "it is alive right now" is the message.
139
+ */
140
+ export function WorkerStatusDot({
141
+ status,
142
+ className,
143
+ }: {
144
+ status: WorkerStatus | WorkerStatusKey
145
+ className?: string
146
+ }) {
147
+ const cfg = typeof status === "string" ? { ...workerStatusConfig[status], running: status === "running" } : status
148
+ return (
149
+ <span className={cn("relative flex h-2 w-2 shrink-0", className)}>
150
+ <span className={cn("h-2 w-2 rounded-full", cfg.dotClass)} />
151
+ {cfg.running && (
152
+ <span className="absolute inset-0 h-2 w-2 animate-ping rounded-full bg-green-500 opacity-50 motion-reduce:animate-none" />
153
+ )}
154
+ </span>
155
+ )
156
+ }
157
+
158
+ /** The state chip that sits next to the worker name. */
159
+ export function WorkerStatusPill({
160
+ status,
161
+ label,
162
+ className,
163
+ }: {
164
+ status: WorkerStatus | WorkerStatusKey
165
+ /** Overrides the table's label — e.g. a live setup phase instead of "Setting up…". */
166
+ label?: string
167
+ className?: string
168
+ }) {
169
+ const cfg = typeof status === "string" ? workerStatusConfig[status] : status
170
+ return (
171
+ <span
172
+ className={cn(
173
+ "inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium leading-none",
174
+ cfg.pillClass,
175
+ className
176
+ )}
177
+ >
178
+ {label ?? cfg.label}
179
+ </span>
180
+ )
181
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  // @spunto/design-system — shared UI primitives, helpers, and color constants.
2
2
  // Tokens ship as a CSS file: `import "@spunto/design-system/styles.css"`.
3
3
 
4
- export { cn } from "./utils"
4
+ export { cn, formatDuration, formatRelativeTime } from "./utils"
5
5
  export * from "./colors"
6
6
 
7
+ // Domain (Spunto-specific) components live behind their own entry point —
8
+ // `@spunto/design-system/workers` — so this root stays domain-free primitives.
9
+ // See README § "Domain components".
10
+
7
11
  export { Button } from "./components/button"
8
12
  // `buttonVariants` is exported from its own directive-free module (not through the
9
13
  // "use client" button.tsx) so it stays callable from React Server Components.
package/src/utils.ts CHANGED
@@ -5,3 +5,26 @@ import { twMerge } from "tailwind-merge"
5
5
  export function cn(...inputs: ClassValue[]) {
6
6
  return twMerge(clsx(inputs))
7
7
  }
8
+
9
+ /**
10
+ * Coarse "3h ago" stamp — minutes, then hours, then days. Deliberately not a
11
+ * live-ticking relative time: every surface using it (worker cards, tables)
12
+ * already re-renders on its own polling cadence, and a second timer per row
13
+ * would cost more than the precision is worth.
14
+ */
15
+ export function formatRelativeTime(date: string | number | Date): string {
16
+ const ms = date instanceof Date ? date.getTime() : new Date(date).getTime()
17
+ if (!Number.isFinite(ms)) return ""
18
+ const minutes = Math.floor((Date.now() - ms) / 60_000)
19
+ if (minutes < 1) return "just now"
20
+ if (minutes < 60) return `${minutes}m ago`
21
+ const hours = Math.floor(minutes / 60)
22
+ if (hours < 24) return `${hours}h ago`
23
+ return `${Math.floor(hours / 24)}d ago`
24
+ }
25
+
26
+ /** Short duration, one significant decimal under 10 s ("840ms", "4.2s", "38s"). */
27
+ export function formatDuration(ms: number): string {
28
+ if (ms >= 1000) return `${(ms / 1000).toFixed(ms >= 10_000 ? 0 : 1)}s`
29
+ return `${Math.max(0, Math.round(ms))}ms`
30
+ }