@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 +34 -0
- package/package.json +6 -1
- package/src/components/workers/creator-avatar.tsx +38 -0
- package/src/components/workers/git-branch-chips.tsx +128 -0
- package/src/components/workers/index.ts +35 -0
- package/src/components/workers/resource-bars.tsx +104 -0
- package/src/components/workers/setup-progress.tsx +130 -0
- package/src/components/workers/tag-chips.tsx +139 -0
- package/src/components/workers/tag-color.ts +28 -0
- package/src/components/workers/types.ts +44 -0
- package/src/components/workers/worker-card.tsx +345 -0
- package/src/components/workers/worker-status.tsx +181 -0
- package/src/index.ts +5 -1
- package/src/utils.ts +23 -0
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.
|
|
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
|
+
}
|