@xemahq/ui-kernel 0.4.1 → 0.5.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.
Files changed (62) hide show
  1. package/README.md +19 -0
  2. package/dist/lib/biome-host/index.d.ts +2 -0
  3. package/dist/lib/biome-host/index.d.ts.map +1 -1
  4. package/dist/lib/biome-host/index.js +2 -0
  5. package/dist/lib/biome-host/index.js.map +1 -1
  6. package/dist/lib/biome-host/use-mutation-with-error-toast.d.ts +10 -0
  7. package/dist/lib/biome-host/use-mutation-with-error-toast.d.ts.map +1 -0
  8. package/dist/lib/biome-host/use-mutation-with-error-toast.js +35 -0
  9. package/dist/lib/biome-host/use-mutation-with-error-toast.js.map +1 -0
  10. package/dist/lib/biome-host/use-page-state.d.ts +4 -0
  11. package/dist/lib/biome-host/use-page-state.d.ts.map +1 -0
  12. package/dist/lib/biome-host/use-page-state.js +140 -0
  13. package/dist/lib/biome-host/use-page-state.js.map +1 -0
  14. package/dist/ui/chrome/confirm-delete-dialog.d.ts +11 -0
  15. package/dist/ui/chrome/confirm-delete-dialog.d.ts.map +1 -0
  16. package/dist/ui/chrome/confirm-delete-dialog.js +10 -0
  17. package/dist/ui/chrome/confirm-delete-dialog.js.map +1 -0
  18. package/dist/ui/chrome/page-shell.d.ts +27 -0
  19. package/dist/ui/chrome/page-shell.d.ts.map +1 -0
  20. package/dist/ui/chrome/page-shell.js +19 -0
  21. package/dist/ui/chrome/page-shell.js.map +1 -0
  22. package/dist/ui/chrome/scope-badge.d.ts +9 -0
  23. package/dist/ui/chrome/scope-badge.d.ts.map +1 -0
  24. package/dist/ui/chrome/scope-badge.js +10 -0
  25. package/dist/ui/chrome/scope-badge.js.map +1 -0
  26. package/dist/ui/chrome/status-badge.d.ts +11 -0
  27. package/dist/ui/chrome/status-badge.d.ts.map +1 -0
  28. package/dist/ui/chrome/status-badge.js +30 -0
  29. package/dist/ui/chrome/status-badge.js.map +1 -0
  30. package/dist/ui/design-tokens.d.ts +72 -0
  31. package/dist/ui/design-tokens.d.ts.map +1 -0
  32. package/dist/ui/design-tokens.js +251 -0
  33. package/dist/ui/design-tokens.js.map +1 -0
  34. package/dist/ui/hooks/use-debounced-value.d.ts +2 -0
  35. package/dist/ui/hooks/use-debounced-value.d.ts.map +1 -0
  36. package/dist/ui/hooks/use-debounced-value.js +13 -0
  37. package/dist/ui/hooks/use-debounced-value.js.map +1 -0
  38. package/dist/ui/index.d.ts +8 -0
  39. package/dist/ui/index.d.ts.map +1 -1
  40. package/dist/ui/index.js +14 -1
  41. package/dist/ui/index.js.map +1 -1
  42. package/dist/ui/primitives/async-combobox.d.ts +19 -0
  43. package/dist/ui/primitives/async-combobox.d.ts.map +1 -0
  44. package/dist/ui/primitives/async-combobox.js +42 -0
  45. package/dist/ui/primitives/async-combobox.js.map +1 -0
  46. package/dist/ui/primitives/form-stepper.d.ts +19 -0
  47. package/dist/ui/primitives/form-stepper.d.ts.map +1 -0
  48. package/dist/ui/primitives/form-stepper.js +23 -0
  49. package/dist/ui/primitives/form-stepper.js.map +1 -0
  50. package/package.json +1 -1
  51. package/src/lib/biome-host/index.ts +2 -0
  52. package/src/lib/biome-host/use-mutation-with-error-toast.ts +88 -0
  53. package/src/lib/biome-host/use-page-state.ts +231 -0
  54. package/src/ui/chrome/confirm-delete-dialog.tsx +59 -0
  55. package/src/ui/chrome/page-shell.tsx +165 -0
  56. package/src/ui/chrome/scope-badge.tsx +40 -0
  57. package/src/ui/chrome/status-badge.tsx +75 -0
  58. package/src/ui/design-tokens.ts +346 -0
  59. package/src/ui/hooks/use-debounced-value.ts +16 -0
  60. package/src/ui/index.ts +15 -0
  61. package/src/ui/primitives/async-combobox.tsx +178 -0
  62. package/src/ui/primitives/form-stepper.tsx +109 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * usePageState — page-level UI state synced to the URL query string.
3
+ *
4
+ * State survives refresh, back/forward, and is shareable via URL. Rebuilt on
5
+ * the {@link HostBridge} navigation port (`useSearchParams` to read,
6
+ * `useLocation` for the current path, `push`/`replace` to write) so biome code
7
+ * never imports a router primitive (`next/navigation`, `react-router-dom`)
8
+ * directly — the host owns history semantics.
9
+ *
10
+ * Usage:
11
+ * const [activeTab, setActiveTab] = usePageState<string>('tab', 'runs');
12
+ * const [selectedId, setSelectedId] = usePageState<string | null>('run', null);
13
+ */
14
+
15
+ import { useCallback, useEffect, useRef, useState } from 'react';
16
+
17
+ import { useHostBridge } from './host-bridge';
18
+
19
+ /** Default debounce for the URL mirror of a free-text input, in ms. */
20
+ const INPUT_URL_MIRROR_DEBOUNCE_MS = 300;
21
+
22
+ function buildPath(pathname: string, params: URLSearchParams): string {
23
+ const queryString = params.toString();
24
+ return queryString ? `${pathname}?${queryString}` : pathname;
25
+ }
26
+
27
+ function serializeValue<T>(value: T): string {
28
+ if (typeof value === 'string') return value;
29
+ if (typeof value === 'number' || typeof value === 'boolean')
30
+ return String(value);
31
+ return JSON.stringify(value);
32
+ }
33
+
34
+ function tryParseJson(rawValue: string): unknown {
35
+ try {
36
+ return JSON.parse(rawValue);
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ function deserializePrimitive<T>(
43
+ rawValue: string,
44
+ defaultValue: T,
45
+ ): T | undefined {
46
+ if (typeof defaultValue === 'string') return rawValue as T;
47
+
48
+ if (typeof defaultValue === 'number') {
49
+ const numericValue = Number(rawValue);
50
+ return (Number.isNaN(numericValue) ? defaultValue : numericValue) as T;
51
+ }
52
+
53
+ if (typeof defaultValue === 'boolean') {
54
+ if (rawValue === 'true') return true as T;
55
+ if (rawValue === 'false') return false as T;
56
+ return defaultValue;
57
+ }
58
+
59
+ return undefined;
60
+ }
61
+
62
+ function deserializeValue<T>(rawValue: string | null, defaultValue: T): T {
63
+ if (rawValue === null) return defaultValue;
64
+
65
+ const primitive = deserializePrimitive(rawValue, defaultValue);
66
+ if (primitive !== undefined) return primitive;
67
+
68
+ // Default is null/undefined or a complex type (array/object). The value was
69
+ // written via JSON.stringify in serializeValue, so attempt to parse it back.
70
+ // If parsing yields a structured value (array/object/null), return it;
71
+ // otherwise fall back to the raw string (covers `T = string | null`).
72
+ const parsedValue = tryParseJson(rawValue);
73
+ if (parsedValue !== undefined && typeof parsedValue === 'object') {
74
+ return parsedValue as T;
75
+ }
76
+
77
+ if (defaultValue === null || defaultValue === undefined) return rawValue as T;
78
+
79
+ if (parsedValue !== undefined) return parsedValue as T;
80
+
81
+ if (Array.isArray(defaultValue)) {
82
+ return rawValue ? ([rawValue] as unknown as T) : defaultValue;
83
+ }
84
+
85
+ return defaultValue;
86
+ }
87
+
88
+ export function usePageState<T>(
89
+ stateKey: string,
90
+ defaultValue: T,
91
+ ): [T, (value: T) => void] {
92
+ const { navigation } = useHostBridge();
93
+ const searchParams = navigation.useSearchParams();
94
+ const { pathname } = navigation.useLocation();
95
+
96
+ const rawValue = searchParams.get(stateKey);
97
+ const value = deserializeValue(rawValue, defaultValue);
98
+
99
+ const setValue = useCallback(
100
+ (newValue: T) => {
101
+ const next = new URLSearchParams(searchParams);
102
+ if (
103
+ newValue === null ||
104
+ newValue === undefined ||
105
+ serializeValue(newValue) === serializeValue(defaultValue)
106
+ ) {
107
+ next.delete(stateKey);
108
+ } else {
109
+ next.set(stateKey, serializeValue(newValue));
110
+ }
111
+ navigation.push(buildPath(pathname, next));
112
+ },
113
+ [defaultValue, stateKey, searchParams, pathname, navigation],
114
+ );
115
+
116
+ return [value, setValue];
117
+ }
118
+
119
+ /**
120
+ * usePageInputState — like {@link usePageState}, but tuned for FREE-TEXT
121
+ * `<Input>` values that are mirrored to the URL on every keystroke.
122
+ *
123
+ * The returned value lives in LOCAL React state, so typing is instant. The URL
124
+ * is mirrored — debounced — via the host's `replace` (an in-place history entry
125
+ * update), so the value stays shareable / refresh-survivable WITHOUT pushing a
126
+ * new history entry per keystroke.
127
+ *
128
+ * The local value re-syncs whenever the underlying search param changes from
129
+ * the outside (a real navigation, a "Clear" button writing the param, or
130
+ * back/forward), so external resets still flow through.
131
+ *
132
+ * Use this ONLY for free-text inputs. Tabs, selects, and row selection are real
133
+ * navigations — keep them on {@link usePageState}.
134
+ *
135
+ * Usage:
136
+ * const [filter, setFilter] = usePageInputState('runFilter', '');
137
+ * <Input value={filter} onChange={(e) => setFilter(e.target.value)} />
138
+ */
139
+ export function usePageInputState(
140
+ stateKey: string,
141
+ defaultValue: string,
142
+ debounceMs: number = INPUT_URL_MIRROR_DEBOUNCE_MS,
143
+ ): [string, (value: string) => void] {
144
+ const { navigation } = useHostBridge();
145
+ const searchParams = navigation.useSearchParams();
146
+ const { pathname } = navigation.useLocation();
147
+
148
+ const urlValue = searchParams.get(stateKey) ?? defaultValue;
149
+
150
+ const [localValue, setLocalValue] = useState(urlValue);
151
+
152
+ // Resync local state when the URL param changes from the outside (real
153
+ // navigation, Clear button, back/forward). We compare against the value we
154
+ // last mirrored so our own debounced writes don't bounce back and clobber
155
+ // freshly-typed characters.
156
+ const lastMirroredRef = useRef(urlValue);
157
+ useEffect(() => {
158
+ if (urlValue !== lastMirroredRef.current) {
159
+ lastMirroredRef.current = urlValue;
160
+ setLocalValue(urlValue);
161
+ }
162
+ }, [urlValue]);
163
+
164
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
165
+ useEffect(
166
+ () => () => {
167
+ if (debounceTimerRef.current !== null) {
168
+ clearTimeout(debounceTimerRef.current);
169
+ }
170
+ },
171
+ [],
172
+ );
173
+
174
+ const setValue = useCallback(
175
+ (newValue: string) => {
176
+ // Instant: drive the input from local state synchronously.
177
+ setLocalValue(newValue);
178
+
179
+ if (debounceTimerRef.current !== null) {
180
+ clearTimeout(debounceTimerRef.current);
181
+ }
182
+ debounceTimerRef.current = setTimeout(() => {
183
+ debounceTimerRef.current = null;
184
+ lastMirroredRef.current =
185
+ newValue === defaultValue ? defaultValue : newValue;
186
+ const next = new URLSearchParams(searchParams);
187
+ if (newValue === defaultValue || newValue.length === 0) {
188
+ next.delete(stateKey);
189
+ } else {
190
+ next.set(stateKey, newValue);
191
+ }
192
+ navigation.replace(buildPath(pathname, next));
193
+ }, debounceMs);
194
+ },
195
+ [debounceMs, defaultValue, stateKey, searchParams, pathname, navigation],
196
+ );
197
+
198
+ return [localValue, setValue];
199
+ }
200
+
201
+ /**
202
+ * Batch-update multiple page-state keys in a single navigation.
203
+ *
204
+ * Pass `null` for a value to delete the key (reset to default).
205
+ *
206
+ * Usage:
207
+ * const batch = useBatchPageState();
208
+ * batch({ selectedId: 'abc', mainTab: null, contextTab: null });
209
+ */
210
+ export function useBatchPageState(): (
211
+ updates: Record<string, string | null>,
212
+ ) => void {
213
+ const { navigation } = useHostBridge();
214
+ const searchParams = navigation.useSearchParams();
215
+ const { pathname } = navigation.useLocation();
216
+
217
+ return useCallback(
218
+ (updates: Record<string, string | null>) => {
219
+ const next = new URLSearchParams(searchParams);
220
+ for (const [key, value] of Object.entries(updates)) {
221
+ if (value === null || value === undefined) {
222
+ next.delete(key);
223
+ } else {
224
+ next.set(key, value);
225
+ }
226
+ }
227
+ navigation.push(buildPath(pathname, next));
228
+ },
229
+ [searchParams, pathname, navigation],
230
+ );
231
+ }
@@ -0,0 +1,59 @@
1
+ import { Loader2 } from 'lucide-react';
2
+
3
+ import {
4
+ AlertDialog,
5
+ AlertDialogAction,
6
+ AlertDialogCancel,
7
+ AlertDialogContent,
8
+ AlertDialogDescription,
9
+ AlertDialogFooter,
10
+ AlertDialogHeader,
11
+ AlertDialogTitle,
12
+ } from '../primitives/alert-dialog';
13
+
14
+ export interface ConfirmDeleteDialogProps {
15
+ open: boolean;
16
+ onOpenChange: (open: boolean) => void;
17
+ onConfirm: () => void;
18
+ title?: string;
19
+ description?: string;
20
+ isPending?: boolean;
21
+ /** Confirm-button label. Defaults to 'Delete' for the destructive case. */
22
+ confirmLabel?: string;
23
+ }
24
+
25
+ /**
26
+ * Reusable delete confirmation dialog.
27
+ * Used across CRUD surfaces to prevent accidental destructive actions.
28
+ */
29
+ export default function ConfirmDeleteDialog({
30
+ open,
31
+ onOpenChange,
32
+ onConfirm,
33
+ title = 'Delete item',
34
+ description = 'This action cannot be undone. Are you sure you want to continue?',
35
+ isPending,
36
+ confirmLabel = 'Delete',
37
+ }: ConfirmDeleteDialogProps) {
38
+ return (
39
+ <AlertDialog open={open} onOpenChange={onOpenChange}>
40
+ <AlertDialogContent>
41
+ <AlertDialogHeader>
42
+ <AlertDialogTitle>{title}</AlertDialogTitle>
43
+ <AlertDialogDescription>{description}</AlertDialogDescription>
44
+ </AlertDialogHeader>
45
+ <AlertDialogFooter>
46
+ <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
47
+ <AlertDialogAction
48
+ onClick={onConfirm}
49
+ disabled={isPending}
50
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
51
+ >
52
+ {isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
53
+ {confirmLabel}
54
+ </AlertDialogAction>
55
+ </AlertDialogFooter>
56
+ </AlertDialogContent>
57
+ </AlertDialog>
58
+ );
59
+ }
@@ -0,0 +1,165 @@
1
+ import { FileQuestion, type LucideIcon } from 'lucide-react';
2
+ import { type ReactNode } from 'react';
3
+
4
+ import { useHostBridge, type PageBackTarget } from '../../lib/biome-host';
5
+ import EmptyState from './EmptyState';
6
+ import { Tabs, TabsList, TabsTrigger } from '../primitives/tabs';
7
+
8
+ interface TabDef {
9
+ value: string;
10
+ label: string | ReactNode;
11
+ count?: number;
12
+ indicator?: boolean;
13
+ }
14
+
15
+ interface PageShellProps {
16
+ icon: LucideIcon;
17
+ title: string;
18
+ description?: string;
19
+ children?: ReactNode;
20
+ actions?: ReactNode;
21
+ tabs?: TabDef[];
22
+ activeTab?: string;
23
+ onTabChange?: (value: string) => void;
24
+ maxWidth?: string;
25
+ embedded?: boolean;
26
+ /** Mono caption rendered before the title in the topbar (parent context). */
27
+ eyebrow?: string;
28
+ /**
29
+ * Leftmost icon-only back affordance on the topbar. Pages with a parent
30
+ * context MUST set this and MUST NOT render their own in-body back button.
31
+ */
32
+ backTo?: PageBackTarget;
33
+ /**
34
+ * Optional right-aligned topbar action cluster. Use for primary CTAs
35
+ * (1-3 small buttons). Heavier in-page chrome (filter rows, search
36
+ * inputs) should keep using the `actions` slot which renders inside the
37
+ * page body.
38
+ */
39
+ topbarActions?: ReactNode;
40
+ }
41
+
42
+ /**
43
+ * Full-page shell — registers the topbar page-meta (via the HostBridge
44
+ * page-meta store) and renders an optional tab strip + action row above the
45
+ * scrollable content canvas. Host-framework-agnostic: reaches the page-meta
46
+ * store through the kernel `HostBridge` singleton, never a host import.
47
+ */
48
+ export default function PageShell({
49
+ icon: _Icon,
50
+ title,
51
+ description,
52
+ children,
53
+ actions,
54
+ tabs,
55
+ activeTab,
56
+ onTabChange,
57
+ maxWidth,
58
+ embedded,
59
+ eyebrow,
60
+ backTo,
61
+ topbarActions,
62
+ }: Readonly<PageShellProps>) {
63
+ // Single source of truth for the topbar title. Pages that bypass
64
+ // PageShell call the page-meta hook directly; PageShell wraps the same
65
+ // hook so consumers don't need to register meta twice.
66
+ useHostBridge().pageMeta.usePageMeta({ title, description, eyebrow, backTo, topbarActions });
67
+ if (embedded) {
68
+ // Embedded mode: the host page already provides the title + tabs +
69
+ // context, so we render ONLY the child content + any sub-tab strip +
70
+ // actions inline. This eliminates the legacy duplicate icon-and-title
71
+ // row that previously stole vertical space.
72
+ return (
73
+ <div className="flex h-full min-h-0 flex-col bg-paper">
74
+ {(tabs && tabs.length > 0) || actions ? (
75
+ <div className="shrink-0 border-b border-rule bg-paper-elev/40">
76
+ <div className="flex items-center gap-3 px-4 py-2 lg:px-6">
77
+ {tabs && tabs.length > 0 && (
78
+ <Tabs value={activeTab} onValueChange={onTabChange} className="flex-1 min-w-0">
79
+ <div className="overflow-x-auto">
80
+ <TabsList className="h-7 bg-transparent p-0 gap-0">
81
+ {tabs.map((tab) => (
82
+ <TabsTrigger
83
+ key={tab.value}
84
+ value={tab.value}
85
+ className="rounded-none border-b-2 border-transparent px-2 py-1 text-body-2 font-medium text-ink-3 transition-colors data-[state=active]:border-primary data-[state=active]:text-ink data-[state=active]:bg-transparent data-[state=active]:shadow-none"
86
+ >
87
+ {tab.label}
88
+ {tab.count !== undefined && (
89
+ <span className="ml-1.5 text-caption tabular-nums opacity-70">{tab.count}</span>
90
+ )}
91
+ {tab.indicator && (
92
+ <span className="ml-1.5 inline-block h-1.5 w-1.5 rounded-full bg-warning" />
93
+ )}
94
+ </TabsTrigger>
95
+ ))}
96
+ </TabsList>
97
+ </div>
98
+ </Tabs>
99
+ )}
100
+ {!tabs && <div className="flex-1" />}
101
+ {actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
102
+ </div>
103
+ </div>
104
+ ) : null}
105
+ <div className="flex-1 overflow-auto min-h-0">
106
+ <div className="flex h-full min-h-0 flex-col px-4 pb-4 pt-3 lg:px-6">{children}</div>
107
+ </div>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ return (
113
+ <div className={`flex h-full min-h-0 flex-col ${maxWidth ?? ''}`}>
114
+ {/* Compact toolbar: tabs + actions on the same row */}
115
+ {(tabs || actions) && (
116
+ <div className="shrink-0 border-b border-border/50 px-4 sm:px-5 lg:px-8">
117
+ <div className="flex min-h-[34px] flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-3 sm:py-0">
118
+ {tabs && tabs.length > 0 && (
119
+ <Tabs value={activeTab} onValueChange={onTabChange} className="flex-1 min-w-0">
120
+ <div className="overflow-x-auto">
121
+ <TabsList className="h-7 bg-transparent p-0 gap-0">
122
+ {tabs.map((tab) => (
123
+ <TabsTrigger
124
+ key={tab.value}
125
+ value={tab.value}
126
+ className="whitespace-nowrap rounded-none border-b-2 border-transparent px-2.5 py-1.5 text-caption font-medium text-ink-3/90 transition-colors data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-ink data-[state=active]:shadow-none hover:text-ink"
127
+ >
128
+ {tab.label}
129
+ {tab.count !== undefined && (
130
+ <span className="ml-1.5 text-caption tabular-nums opacity-60">{tab.count}</span>
131
+ )}
132
+ {tab.indicator && (
133
+ <span className="ml-1.5 inline-block h-1.5 w-1.5 rounded-full bg-warning" />
134
+ )}
135
+ </TabsTrigger>
136
+ ))}
137
+ </TabsList>
138
+ </div>
139
+ </Tabs>
140
+ )}
141
+ {!tabs && <div className="flex-1" />}
142
+ {actions && (
143
+ <div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0 sm:justify-end">
144
+ {actions}
145
+ </div>
146
+ )}
147
+ </div>
148
+ </div>
149
+ )}
150
+
151
+ {/* Content area — full height. Padding sized to match other editorial
152
+ surfaces (PageHeader-rooted pages use the same `px-4 sm:px-6 py-5`
153
+ rhythm). */}
154
+ <div className="flex min-h-0 flex-1 flex-col overflow-auto px-4 pb-5 pt-4 sm:px-6 sm:pb-6 sm:pt-5">
155
+ {children || (
156
+ <EmptyState
157
+ icon={FileQuestion}
158
+ title="Nothing to show yet"
159
+ description="This page has no content to display. Check back once the workspace has data."
160
+ />
161
+ )}
162
+ </div>
163
+ </div>
164
+ );
165
+ }
@@ -0,0 +1,40 @@
1
+ import { cn } from '../cn';
2
+ import { TONE_STYLES, type Tone } from '../design-tokens';
3
+
4
+ export interface ScopeBadgeProps {
5
+ /** Short label shown inside the chip. e.g. "system", "org", "override". */
6
+ label: string;
7
+ /** Visual tone — drives bg/text/border via design tokens. */
8
+ tone?: Tone;
9
+ /** Optional tooltip text. */
10
+ hint?: string;
11
+ className?: string;
12
+ }
13
+
14
+ /**
15
+ * Compact uppercase chip for closed-enum scope/source pills (workflow
16
+ * source, trigger scope, etc.). Distinct from `<StatusBadge>` which
17
+ * carries process-state semantics (running/succeeded/failed/…).
18
+ *
19
+ * Always renders at the smallest size (`text-[10px]`) because it sits
20
+ * inline next to other content on dense list rows.
21
+ */
22
+ export default function ScopeBadge({
23
+ label,
24
+ tone = 'neutral',
25
+ hint,
26
+ className,
27
+ }: ScopeBadgeProps) {
28
+ return (
29
+ <span
30
+ className={cn(
31
+ 'inline-flex shrink-0 items-center rounded-sm border px-1.5 py-0 text-[10px] font-medium uppercase tracking-wide',
32
+ TONE_STYLES[tone],
33
+ className,
34
+ )}
35
+ title={hint}
36
+ >
37
+ {label}
38
+ </span>
39
+ );
40
+ }
@@ -0,0 +1,75 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import { cn } from '../cn';
4
+ import { getStatusStyle } from '../design-tokens';
5
+ import { Badge } from '../primitives/badge';
6
+
7
+ export type StatusBadgeSize = 'xs' | 'sm' | 'md';
8
+
9
+ export interface StatusBadgeProps {
10
+ status: string;
11
+ className?: string;
12
+ dot?: boolean;
13
+ size?: StatusBadgeSize;
14
+ /** Override the rendered label. Falls back to a capitalized version of `status`. */
15
+ children?: ReactNode;
16
+ }
17
+
18
+ interface SizeTokens {
19
+ badge: string;
20
+ dot: string;
21
+ gap: string;
22
+ }
23
+
24
+ const SIZE_TOKENS: Record<StatusBadgeSize, SizeTokens> = {
25
+ xs: {
26
+ badge: 'h-[18px] px-1.5 py-0 text-[10px] font-medium',
27
+ dot: 'h-1 w-1',
28
+ gap: 'gap-1',
29
+ },
30
+ sm: {
31
+ badge: 'h-5 px-2 py-0 text-caption font-medium',
32
+ dot: 'h-1.5 w-1.5',
33
+ gap: 'gap-1.5',
34
+ },
35
+ md: {
36
+ badge: 'px-2.5 py-0.5 text-body-1',
37
+ dot: 'h-1.5 w-1.5',
38
+ gap: 'gap-1.5',
39
+ },
40
+ };
41
+
42
+ /**
43
+ * Semantic status badge that reads from the design token system.
44
+ * Use across all pages for consistent status display.
45
+ *
46
+ * Size guidance:
47
+ * - `xs` — dense list rows (Sessions, Brainstorming)
48
+ * - `sm` — default list rows (Workflow Runs, Schedules, Triggers, dashboards)
49
+ * - `md` — detail-page headers + hero surfaces
50
+ */
51
+ export default function StatusBadge({
52
+ status,
53
+ className,
54
+ dot,
55
+ size = 'sm',
56
+ children,
57
+ }: StatusBadgeProps) {
58
+ const style = getStatusStyle(status);
59
+ const sz = SIZE_TOKENS[size];
60
+ return (
61
+ <Badge
62
+ variant="outline"
63
+ className={cn(
64
+ 'inline-flex items-center rounded-full',
65
+ sz.badge,
66
+ sz.gap,
67
+ style.badge,
68
+ className,
69
+ )}
70
+ >
71
+ {dot && <span className={cn('shrink-0 rounded-full', sz.dot, style.dot)} />}
72
+ {children ?? <span className="capitalize">{status.replace(/_/g, ' ')}</span>}
73
+ </Badge>
74
+ );
75
+ }