@spunto/design-system 0.9.1 → 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,139 +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
- /**
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
- }
@@ -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,309 +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
- /**
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
- className={cn("overflow-hidden rounded-xl border border-border bg-card transition-colors hover:shadow-sm", className)}
140
- >
141
- {/* ── Header : état, nom, pills, tags, méta ── */}
142
- <div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3">
143
- <div className="flex min-w-0 items-start gap-3">
144
- <WorkerStatusDot status={status} className="mt-1" />
145
-
146
- <div className="min-w-0 flex-1">
147
- <div className="flex flex-wrap items-center gap-2">
148
- <span className="text-sm leading-none font-semibold">{title}</span>
149
- <WorkerStatusPill status={status} />
150
- {worker.projectVersion != null && (
151
- <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">
152
- v{worker.projectVersion}
153
- </span>
154
- )}
155
- <TagChips tags={worker.tags ?? []} onAdd={onAddTag} onRemove={onRemoveTag} />
156
- </div>
157
-
158
- <div className="mt-1.5 flex flex-wrap items-center gap-2">
159
- {creator && (
160
- <span className="flex items-center gap-1.5">
161
- <CreatorAvatar creator={creator} />
162
- <span className="text-[11px] text-muted-foreground">{creator.name}</span>
163
- </span>
164
- )}
165
- {nodeName && (
166
- <>
167
- <Dot />
168
- <span className="flex items-center gap-1 text-[11px] text-muted-foreground/60">
169
- <ServerIcon className="h-3 w-3" />
170
- {nodeName}
171
- </span>
172
- </>
173
- )}
174
- {worker.createdAt && (
175
- <>
176
- {creator || nodeName ? <Dot /> : null}
177
- <span
178
- className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
179
- title={new Date(worker.createdAt).toLocaleString()}
180
- >
181
- <ClockIcon className="h-3 w-3" />
182
- {formatRelativeTime(worker.createdAt)}
183
- </span>
184
- </>
185
- )}
186
- {/* Only worth showing once it's over — during setup the live bar says it better. */}
187
- {!status.settingUp && startupMs != null && (
188
- <>
189
- <Dot />
190
- <span
191
- className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
192
- title="Worker startup time (clone, features, postCreate…)"
193
- >
194
- <TimerIcon className="h-3 w-3" />
195
- started in {formatDuration(startupMs)}
196
- </span>
197
- </>
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
- <WifiOffIcon className="h-3.5 w-3.5 shrink-0" />
227
- Node offline
228
- </Banner>
229
- )}
230
-
231
- {outdated && (
232
- <Banner tone="warn">
233
- <span className="flex min-w-0 items-center gap-1.5">
234
- <AlertTriangleIcon className="h-3.5 w-3.5 shrink-0" />
235
- Config <span className="font-mono font-semibold">v{worker.projectVersion}</span> → project at{" "}
236
- <span className="font-mono font-semibold">v{currentProjectVersion}</span>
237
- </span>
238
- {onRebuild && (
239
- <button
240
- type="button"
241
- disabled={rebuilding}
242
- onClick={onRebuild}
243
- className="shrink-0 cursor-pointer text-[11px] font-medium hover:underline disabled:opacity-50"
244
- >
245
- {rebuilding ? "Rebuilding…" : "Rebuild"}
246
- </button>
247
- )}
248
- </Banner>
249
- )}
250
-
251
- {error && (
252
- <Banner tone="error">
253
- <span className="truncate font-mono">{error}</span>
254
- {onDismissError && (
255
- <button
256
- type="button"
257
- onClick={onDismissError}
258
- aria-label="Dismiss error"
259
- className="shrink-0 cursor-pointer opacity-60 transition-opacity hover:opacity-100"
260
- >
261
- <XIcon className="h-3.5 w-3.5" />
262
- </button>
263
- )}
264
- </Banner>
265
- )}
266
-
267
- {(footer ?? href) && (
268
- <div className="border-t border-border/50 bg-muted/20 px-3 py-2">
269
- {footer ?? (
270
- <Link href={href!} className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-7 w-full gap-1.5 text-xs")}>
271
- {viewLabel}
272
- <ChevronRightIcon className="h-3.5 w-3.5" />
273
- </Link>
274
- )}
275
- </div>
276
- )}
277
- </div>
278
- )
279
- }
280
-
281
- /** The faint separator between two meta items. */
282
- function Dot() {
283
- return <span className="text-[11px] text-muted-foreground/30">·</span>
284
- }
285
-
286
- /**
287
- * The card's inline bands. Warm tones = something to know (an unreachable node,
288
- * a stale config), red = something went wrong. They keep the card's horizontal
289
- * margin so they read as *inside* the card, not as a strip glued across it.
290
- */
291
- const BANNER_TONES = {
292
- /** Infrastructure problem — orange, one notch louder than a stale config. */
293
- offline: "border-orange-500/25 bg-orange-500/5 text-orange-600 dark:text-orange-400",
294
- warn: "border-amber-500/25 bg-amber-500/5 text-amber-600 dark:text-amber-400",
295
- error: "border-destructive/25 bg-destructive/5 text-destructive",
296
- } as const
297
-
298
- function Banner({ tone, children }: { tone: keyof typeof BANNER_TONES; children: ReactNode }) {
299
- return (
300
- <div
301
- className={cn(
302
- "mx-4 mb-3 flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-xs",
303
- BANNER_TONES[tone]
304
- )}
305
- >
306
- {children}
307
- </div>
308
- )
309
- }