@spunto/design-system 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState, type ComponentProps } from "react"
4
+
5
+ import { cn } from "../utils"
6
+
7
+ // One key = one cap. Kept as a constant so `Kbd` and every caller (the command
8
+ // palette footer, an item's `shortcut`) draw the exact same box.
9
+ const capClass =
10
+ "inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-muted px-1.5 font-sans text-[0.6875rem] leading-none font-medium text-muted-foreground"
11
+
12
+ /**
13
+ * Symbol shown per modifier, per platform, plus the accessible name a screen
14
+ * reader gets instead of the glyph ("⌘" alone reads as nothing useful).
15
+ * `mod` is the portable modifier: ⌘ on Apple, Ctrl everywhere else — the one
16
+ * every "⌘K" shortcut actually means.
17
+ */
18
+ const KEYS: Record<string, { mac: string; other: string; label: string }> = {
19
+ mod: { mac: "⌘", other: "Ctrl", label: "Ctrl" },
20
+ meta: { mac: "⌘", other: "Win", label: "Meta" },
21
+ cmd: { mac: "⌘", other: "Ctrl", label: "Commande" },
22
+ command: { mac: "⌘", other: "Ctrl", label: "Commande" },
23
+ ctrl: { mac: "⌃", other: "Ctrl", label: "Ctrl" },
24
+ control: { mac: "⌃", other: "Ctrl", label: "Ctrl" },
25
+ alt: { mac: "⌥", other: "Alt", label: "Alt" },
26
+ option: { mac: "⌥", other: "Alt", label: "Alt" },
27
+ shift: { mac: "⇧", other: "Maj", label: "Maj" },
28
+ enter: { mac: "↵", other: "↵", label: "Entrée" },
29
+ return: { mac: "↵", other: "↵", label: "Entrée" },
30
+ esc: { mac: "esc", other: "esc", label: "Échap" },
31
+ escape: { mac: "esc", other: "esc", label: "Échap" },
32
+ backspace: { mac: "⌫", other: "⌫", label: "Retour arrière" },
33
+ delete: { mac: "⌦", other: "Suppr", label: "Suppr" },
34
+ tab: { mac: "⇥", other: "⇥", label: "Tab" },
35
+ space: { mac: "␣", other: "␣", label: "Espace" },
36
+ up: { mac: "↑", other: "↑", label: "Flèche haut" },
37
+ down: { mac: "↓", other: "↓", label: "Flèche bas" },
38
+ left: { mac: "←", other: "←", label: "Flèche gauche" },
39
+ right: { mac: "→", other: "→", label: "Flèche droite" },
40
+ }
41
+
42
+ /**
43
+ * Apple platform detection, deferred to an effect on purpose: the package is
44
+ * consumed from a Next.js App Router, so the first render must be identical on
45
+ * the server and on the client. We render the portable label ("Ctrl") first and
46
+ * swap to "⌘" after mount rather than reading `navigator` during render.
47
+ */
48
+ function useApplePlatform(): boolean {
49
+ const [isApple, setIsApple] = useState(false)
50
+ useEffect(() => {
51
+ const ua = navigator.userAgent
52
+ setIsApple(/Mac|iPhone|iPad|iPod/.test(ua))
53
+ }, [])
54
+ return isApple
55
+ }
56
+
57
+ function keyGlyph(part: string, isApple: boolean): string {
58
+ const known = KEYS[part.toLowerCase()]
59
+ if (known) return isApple ? known.mac : known.other
60
+ // Single letters read better upper-cased ("K"), longer names as written ("F5").
61
+ return part.length === 1 ? part.toUpperCase() : part
62
+ }
63
+
64
+ function keyLabel(part: string): string {
65
+ return KEYS[part.toLowerCase()]?.label ?? part.toUpperCase()
66
+ }
67
+
68
+ export interface KbdProps extends ComponentProps<"kbd"> {
69
+ /**
70
+ * Shortcut expressed as `"mod+shift+p"` — rendered as one cap per key, with
71
+ * `mod` resolved to ⌘ or Ctrl depending on the platform. Omit it and pass
72
+ * children instead to draw a single raw cap (`<Kbd>↵</Kbd>`).
73
+ */
74
+ keys?: string
75
+ }
76
+
77
+ /**
78
+ * A keyboard key, drawn as a cap. Either a raw one (`<Kbd>↵</Kbd>`) or a whole
79
+ * shortcut (`<Kbd keys="mod+k" />` → ⌘ K), which is the form the command palette
80
+ * uses for item shortcuts and footer hints.
81
+ *
82
+ * The accessible name is spelled out ("Commande + K"), because the glyphs alone
83
+ * announce as gibberish — or as nothing at all.
84
+ */
85
+ function Kbd({ className, keys, children, ...props }: KbdProps) {
86
+ const isApple = useApplePlatform()
87
+
88
+ if (!keys) {
89
+ return (
90
+ <kbd data-slot="kbd" className={cn(capClass, className)} {...props}>
91
+ {children}
92
+ </kbd>
93
+ )
94
+ }
95
+
96
+ const parts = keys
97
+ .split("+")
98
+ .map((p) => p.trim())
99
+ .filter(Boolean)
100
+
101
+ return (
102
+ <kbd
103
+ data-slot="kbd"
104
+ aria-label={parts.map(keyLabel).join(" + ")}
105
+ className={cn("inline-flex items-center gap-1 border-0 bg-transparent p-0", className)}
106
+ {...props}
107
+ >
108
+ {parts.map((part, i) => (
109
+ <kbd key={`${part}-${i}`} aria-hidden className={capClass}>
110
+ {keyGlyph(part, isApple)}
111
+ </kbd>
112
+ ))}
113
+ </kbd>
114
+ )
115
+ }
116
+
117
+ export { Kbd }
@@ -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
+ }