@spunto/design-system 0.9.0 → 0.9.2

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.
@@ -1,130 +0,0 @@
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
- }
@@ -1,132 +0,0 @@
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
- /** Adding stays enabled while the caller persists — the chip is already there. */
18
- onAdd?: (tag: string) => void
19
- onRemove?: (tag: string) => void
20
- /** Longest accepted tag; anything past it is truncated on commit. */
21
- maxLength?: number
22
- className?: string
23
- }
24
-
25
- export function TagChips({ tags, onAdd, onRemove, maxLength = 32, className }: TagChipsProps) {
26
- const editable = !!(onAdd || onRemove)
27
- // Local echo so a freshly typed tag appears instantly, resynced whenever the
28
- // caller's list changes — the server's answer always wins in the end.
29
- const [local, setLocal] = useState<string[]>(tags)
30
- const [input, setInput] = useState("")
31
- const [adding, setAdding] = useState(false)
32
- const inputRef = useRef<HTMLInputElement>(null)
33
-
34
- // Compared by content, not by reference: callers routinely pass
35
- // `worker.tags ?? []`, a fresh array on every poll, and swapping state for an
36
- // identical list would re-render the whole card every few seconds.
37
- useEffect(() => {
38
- setLocal((prev) => (prev.length === tags.length && prev.every((t, i) => t === tags[i]) ? prev : tags))
39
- }, [tags])
40
-
41
- function commit() {
42
- // Spaces and commas are the separators a user reflexively types; swallowing
43
- // them here keeps "one chip = one word" true without an error message.
44
- const raw = input.trim().replace(/,/g, "").slice(0, maxLength)
45
- if (raw && !local.includes(raw)) {
46
- setLocal([...local, raw])
47
- onAdd?.(raw)
48
- }
49
- setInput("")
50
- setAdding(false)
51
- }
52
-
53
- function remove(tag: string) {
54
- setLocal(local.filter((t) => t !== tag))
55
- onRemove?.(tag)
56
- }
57
-
58
- const shown = editable ? local : tags
59
- if (!shown.length && !editable) return null
60
-
61
- return (
62
- <>
63
- {shown.map((tag) => {
64
- const c = tagColor(tag)
65
- return (
66
- <span
67
- key={tag}
68
- className={cn(
69
- "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium leading-none",
70
- c.bg,
71
- c.text,
72
- c.border,
73
- className
74
- )}
75
- >
76
- {tag}
77
- {onRemove && (
78
- <button
79
- type="button"
80
- onClick={(e) => {
81
- e.stopPropagation()
82
- remove(tag)
83
- }}
84
- className="ml-0.5 cursor-pointer opacity-50 transition-opacity hover:opacity-100"
85
- aria-label={`Remove tag ${tag}`}
86
- >
87
- <XIcon className="h-2.5 w-2.5" />
88
- </button>
89
- )}
90
- </span>
91
- )
92
- })}
93
-
94
- {onAdd &&
95
- (adding ? (
96
- <input
97
- ref={inputRef}
98
- autoFocus
99
- value={input}
100
- onChange={(e) => setInput(e.target.value.replace(/[\s,]/g, ""))}
101
- onKeyDown={(e) => {
102
- if (e.key === "Enter") {
103
- e.preventDefault()
104
- commit()
105
- }
106
- if (e.key === "Escape") {
107
- setInput("")
108
- setAdding(false)
109
- }
110
- }}
111
- // Blur-commits rather than discards: clicking away after typing a
112
- // tag reads as "done", never as "cancel".
113
- onBlur={commit}
114
- placeholder="tag…"
115
- 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"
116
- />
117
- ) : (
118
- <button
119
- type="button"
120
- onClick={(e) => {
121
- e.stopPropagation()
122
- setAdding(true)
123
- }}
124
- 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"
125
- aria-label="Add tag"
126
- >
127
- <PlusIcon className="h-2.5 w-2.5" />
128
- </button>
129
- ))}
130
- </>
131
- )
132
- }
@@ -1,28 +0,0 @@
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
- }
@@ -1,44 +0,0 @@
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
- }
@@ -1,305 +0,0 @@
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
- /** Tags are read-only unless at least one of these is given. */
51
- onAddTag?: (tag: string) => void
52
- onRemoveTag?: (tag: string) => void
53
-
54
- /** Project's current config version — a worker below it gets the "Rebuild" banner. */
55
- currentProjectVersion?: number
56
- onRebuild?: () => void
57
- rebuilding?: boolean
58
-
59
- /** Error banner inside the card (a failed mutation, typically). */
60
- error?: string | null
61
- onDismissError?: () => void
62
-
63
- /** The `⋯` menu, top-right. A slot: every entry in it calls the API. */
64
- actions?: ReactNode
65
- /** Replaces the default "View" footer entirely (extra buttons, a VS Code link…). */
66
- footer?: ReactNode
67
- /** Label of the default footer button. */
68
- viewLabel?: string
69
-
70
- className?: string
71
- }
72
-
73
- /**
74
- * One worker, as a card: state, who spawned it, where it runs, what it's working
75
- * on, and what you can do with it.
76
- *
77
- * **Purely presentational.** It receives a snapshot and draws it — no query, no
78
- * mutation, no router. Everything that would talk to an API is a slot
79
- * (`actions`, `footer`) or a callback (`onAddTag`, `onRebuild`…). That rule is
80
- * what makes the same card renderable by the dashboard, by Spunto Lite, and by
81
- * the showcase with hand-written fixtures. The card is the assembly; each brick
82
- * (`WorkerStatusDot`, `SetupProgress`, `ResourceBars`, `GitBranchChips`,
83
- * `TagChips`) is exported on its own because the table view needs them without
84
- * the card.
85
- *
86
- * The layout is a fixed sequence of optional bands — header, setup bar, git
87
- * chips, resource bars, banners, footer — and each band disappears when its data
88
- * is absent, so a stopped worker collapses to a two-line card without any
89
- * caller-side conditional.
90
- */
91
- export function WorkerCard({
92
- worker,
93
- href,
94
- render,
95
- creator,
96
- nodeName,
97
- nodeOnline,
98
- stats,
99
- gitStatus,
100
- repoHref,
101
- onAddTag,
102
- onRemoveTag,
103
- currentProjectVersion,
104
- onRebuild,
105
- rebuilding = false,
106
- error,
107
- onDismissError,
108
- actions,
109
- footer,
110
- viewLabel = "View",
111
- className,
112
- }: WorkerCardProps) {
113
- const status = resolveWorkerStatus(worker)
114
- const setup = worker.setupStatus ?? null
115
- const startupMs = setupTotalMs(setup)
116
- const title = worker.name || (worker.index != null ? `Workspace #${worker.index}` : worker.id)
117
-
118
- // Live detail is only meaningful once the container is up *and* done setting
119
- // up: mid-setup a stale "running" from docker would show stats for a worker
120
- // that isn't usable yet.
121
- const settled = status.running && !status.settingUp
122
- const repos = settled ? (gitStatus ?? []) : []
123
- const outdated =
124
- !status.settingUp &&
125
- worker.projectVersion != null &&
126
- currentProjectVersion != null &&
127
- worker.projectVersion < currentProjectVersion
128
-
129
- const Link: LinkRender =
130
- render?.link ?? (({ href: to, className: cls, children }) => <a href={to} className={cls}>{children}</a>)
131
-
132
- return (
133
- <div
134
- data-slot="worker-card"
135
- className={cn("overflow-hidden rounded-xl border border-border bg-card transition-colors hover:shadow-sm", className)}
136
- >
137
- {/* ── Header : état, nom, pills, tags, méta ── */}
138
- <div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3">
139
- <div className="flex min-w-0 items-start gap-3">
140
- <WorkerStatusDot status={status} className="mt-1" />
141
-
142
- <div className="min-w-0 flex-1">
143
- <div className="flex flex-wrap items-center gap-2">
144
- <span className="text-sm leading-none font-semibold">{title}</span>
145
- <WorkerStatusPill status={status} />
146
- {worker.projectVersion != null && (
147
- <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">
148
- v{worker.projectVersion}
149
- </span>
150
- )}
151
- <TagChips tags={worker.tags ?? []} onAdd={onAddTag} onRemove={onRemoveTag} />
152
- </div>
153
-
154
- <div className="mt-1.5 flex flex-wrap items-center gap-2">
155
- {creator && (
156
- <span className="flex items-center gap-1.5">
157
- <CreatorAvatar creator={creator} />
158
- <span className="text-[11px] text-muted-foreground">{creator.name}</span>
159
- </span>
160
- )}
161
- {nodeName && (
162
- <>
163
- <Dot />
164
- <span className="flex items-center gap-1 text-[11px] text-muted-foreground/60">
165
- <ServerIcon className="h-3 w-3" />
166
- {nodeName}
167
- </span>
168
- </>
169
- )}
170
- {worker.createdAt && (
171
- <>
172
- {creator || nodeName ? <Dot /> : null}
173
- <span
174
- className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
175
- title={new Date(worker.createdAt).toLocaleString()}
176
- >
177
- <ClockIcon className="h-3 w-3" />
178
- {formatRelativeTime(worker.createdAt)}
179
- </span>
180
- </>
181
- )}
182
- {/* Only worth showing once it's over — during setup the live bar says it better. */}
183
- {!status.settingUp && startupMs != null && (
184
- <>
185
- <Dot />
186
- <span
187
- className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
188
- title="Worker startup time (clone, features, postCreate…)"
189
- >
190
- <TimerIcon className="h-3 w-3" />
191
- started in {formatDuration(startupMs)}
192
- </span>
193
- </>
194
- )}
195
- </div>
196
- </div>
197
- </div>
198
-
199
- {actions}
200
- </div>
201
-
202
- {status.settingUp && (
203
- <div className="px-4 pb-3">
204
- <SetupProgress status={setup} />
205
- </div>
206
- )}
207
-
208
- {repos.length > 0 && (
209
- <div className="px-4 pb-3">
210
- <GitBranchChips repos={repos} repoHref={repoHref} />
211
- </div>
212
- )}
213
-
214
- {settled && stats && (
215
- <div className="px-4 pb-3">
216
- <ResourceBars stats={stats} />
217
- </div>
218
- )}
219
-
220
- {nodeOnline === false && (
221
- <Banner tone="offline">
222
- <WifiOffIcon className="h-3.5 w-3.5 shrink-0" />
223
- Node offline
224
- </Banner>
225
- )}
226
-
227
- {outdated && (
228
- <Banner tone="warn">
229
- <span className="flex min-w-0 items-center gap-1.5">
230
- <AlertTriangleIcon className="h-3.5 w-3.5 shrink-0" />
231
- Config <span className="font-mono font-semibold">v{worker.projectVersion}</span> → project at{" "}
232
- <span className="font-mono font-semibold">v{currentProjectVersion}</span>
233
- </span>
234
- {onRebuild && (
235
- <button
236
- type="button"
237
- disabled={rebuilding}
238
- onClick={onRebuild}
239
- className="shrink-0 cursor-pointer text-[11px] font-medium hover:underline disabled:opacity-50"
240
- >
241
- {rebuilding ? "Rebuilding…" : "Rebuild"}
242
- </button>
243
- )}
244
- </Banner>
245
- )}
246
-
247
- {error && (
248
- <Banner tone="error">
249
- <span className="truncate font-mono">{error}</span>
250
- {onDismissError && (
251
- <button
252
- type="button"
253
- onClick={onDismissError}
254
- aria-label="Dismiss error"
255
- className="shrink-0 cursor-pointer opacity-60 transition-opacity hover:opacity-100"
256
- >
257
- <XIcon className="h-3.5 w-3.5" />
258
- </button>
259
- )}
260
- </Banner>
261
- )}
262
-
263
- {(footer ?? href) && (
264
- <div className="border-t border-border/50 bg-muted/20 px-3 py-2">
265
- {footer ?? (
266
- <Link href={href!} className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-7 w-full gap-1.5 text-xs")}>
267
- {viewLabel}
268
- <ChevronRightIcon className="h-3.5 w-3.5" />
269
- </Link>
270
- )}
271
- </div>
272
- )}
273
- </div>
274
- )
275
- }
276
-
277
- /** The faint separator between two meta items. */
278
- function Dot() {
279
- return <span className="text-[11px] text-muted-foreground/30">·</span>
280
- }
281
-
282
- /**
283
- * The card's inline bands. Warm tones = something to know (an unreachable node,
284
- * a stale config), red = something went wrong. They keep the card's horizontal
285
- * margin so they read as *inside* the card, not as a strip glued across it.
286
- */
287
- const BANNER_TONES = {
288
- /** Infrastructure problem — orange, one notch louder than a stale config. */
289
- offline: "border-orange-500/25 bg-orange-500/5 text-orange-600 dark:text-orange-400",
290
- warn: "border-amber-500/25 bg-amber-500/5 text-amber-600 dark:text-amber-400",
291
- error: "border-destructive/25 bg-destructive/5 text-destructive",
292
- } as const
293
-
294
- function Banner({ tone, children }: { tone: keyof typeof BANNER_TONES; children: ReactNode }) {
295
- return (
296
- <div
297
- className={cn(
298
- "mx-4 mb-3 flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-xs",
299
- BANNER_TONES[tone]
300
- )}
301
- >
302
- {children}
303
- </div>
304
- )
305
- }