@xemahq/ui-kernel 0.4.1 → 0.6.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 (83) hide show
  1. package/README.md +19 -0
  2. package/dist/lib/biome-host/define-web-biome.d.ts +0 -1
  3. package/dist/lib/biome-host/define-web-biome.d.ts.map +1 -1
  4. package/dist/lib/biome-host/define-web-biome.js +0 -1
  5. package/dist/lib/biome-host/define-web-biome.js.map +1 -1
  6. package/dist/lib/biome-host/frontend-biome.d.ts +0 -2
  7. package/dist/lib/biome-host/frontend-biome.d.ts.map +1 -1
  8. package/dist/lib/biome-host/index.d.ts +2 -1
  9. package/dist/lib/biome-host/index.d.ts.map +1 -1
  10. package/dist/lib/biome-host/index.js +2 -1
  11. package/dist/lib/biome-host/index.js.map +1 -1
  12. package/dist/lib/biome-host/use-mutation-with-error-toast.d.ts +10 -0
  13. package/dist/lib/biome-host/use-mutation-with-error-toast.d.ts.map +1 -0
  14. package/dist/lib/biome-host/use-mutation-with-error-toast.js +35 -0
  15. package/dist/lib/biome-host/use-mutation-with-error-toast.js.map +1 -0
  16. package/dist/lib/biome-host/use-page-state.d.ts +4 -0
  17. package/dist/lib/biome-host/use-page-state.d.ts.map +1 -0
  18. package/dist/lib/biome-host/use-page-state.js +140 -0
  19. package/dist/lib/biome-host/use-page-state.js.map +1 -0
  20. package/dist/ui/chrome/confirm-delete-dialog.d.ts +11 -0
  21. package/dist/ui/chrome/confirm-delete-dialog.d.ts.map +1 -0
  22. package/dist/ui/chrome/confirm-delete-dialog.js +10 -0
  23. package/dist/ui/chrome/confirm-delete-dialog.js.map +1 -0
  24. package/dist/ui/chrome/page-shell.d.ts +27 -0
  25. package/dist/ui/chrome/page-shell.d.ts.map +1 -0
  26. package/dist/ui/chrome/page-shell.js +19 -0
  27. package/dist/ui/chrome/page-shell.js.map +1 -0
  28. package/dist/ui/chrome/scope-badge.d.ts +9 -0
  29. package/dist/ui/chrome/scope-badge.d.ts.map +1 -0
  30. package/dist/ui/chrome/scope-badge.js +10 -0
  31. package/dist/ui/chrome/scope-badge.js.map +1 -0
  32. package/dist/ui/chrome/status-badge.d.ts +11 -0
  33. package/dist/ui/chrome/status-badge.d.ts.map +1 -0
  34. package/dist/ui/chrome/status-badge.js +30 -0
  35. package/dist/ui/chrome/status-badge.js.map +1 -0
  36. package/dist/ui/design-tokens.d.ts +72 -0
  37. package/dist/ui/design-tokens.d.ts.map +1 -0
  38. package/dist/ui/design-tokens.js +251 -0
  39. package/dist/ui/design-tokens.js.map +1 -0
  40. package/dist/ui/hooks/use-debounced-value.d.ts +2 -0
  41. package/dist/ui/hooks/use-debounced-value.d.ts.map +1 -0
  42. package/dist/ui/hooks/use-debounced-value.js +13 -0
  43. package/dist/ui/hooks/use-debounced-value.js.map +1 -0
  44. package/dist/ui/index.d.ts +8 -0
  45. package/dist/ui/index.d.ts.map +1 -1
  46. package/dist/ui/index.js +14 -1
  47. package/dist/ui/index.js.map +1 -1
  48. package/dist/ui/primitives/async-combobox.d.ts +19 -0
  49. package/dist/ui/primitives/async-combobox.d.ts.map +1 -0
  50. package/dist/ui/primitives/async-combobox.js +42 -0
  51. package/dist/ui/primitives/async-combobox.js.map +1 -0
  52. package/dist/ui/primitives/form-stepper.d.ts +19 -0
  53. package/dist/ui/primitives/form-stepper.d.ts.map +1 -0
  54. package/dist/ui/primitives/form-stepper.js +23 -0
  55. package/dist/ui/primitives/form-stepper.js.map +1 -0
  56. package/package.json +1 -1
  57. package/src/lib/biome-host/define-web-biome.ts +5 -11
  58. package/src/lib/biome-host/frontend-biome.ts +2 -14
  59. package/src/lib/biome-host/index.ts +2 -1
  60. package/src/lib/biome-host/use-mutation-with-error-toast.ts +88 -0
  61. package/src/lib/biome-host/use-page-state.ts +231 -0
  62. package/src/ui/chrome/confirm-delete-dialog.tsx +59 -0
  63. package/src/ui/chrome/page-shell.tsx +165 -0
  64. package/src/ui/chrome/scope-badge.tsx +40 -0
  65. package/src/ui/chrome/status-badge.tsx +75 -0
  66. package/src/ui/design-tokens.ts +346 -0
  67. package/src/ui/hooks/use-debounced-value.ts +16 -0
  68. package/src/ui/index.ts +15 -0
  69. package/src/ui/primitives/async-combobox.tsx +178 -0
  70. package/src/ui/primitives/form-stepper.tsx +109 -0
  71. package/dist/lib/biome-host/composition-validation.d.ts +0 -22
  72. package/dist/lib/biome-host/composition-validation.d.ts.map +0 -1
  73. package/dist/lib/biome-host/composition-validation.js +0 -127
  74. package/dist/lib/biome-host/composition-validation.js.map +0 -1
  75. package/dist/lib/biome-host/nav.d.ts +0 -17
  76. package/dist/lib/biome-host/nav.d.ts.map +0 -1
  77. package/dist/lib/biome-host/nav.js +0 -52
  78. package/dist/lib/biome-host/nav.js.map +0 -1
  79. package/dist/registry/lib/composition-validation-host.d.ts +0 -3
  80. package/dist/registry/lib/composition-validation-host.d.ts.map +0 -1
  81. package/dist/registry/lib/composition-validation-host.js +0 -10
  82. package/dist/registry/lib/composition-validation-host.js.map +0 -1
  83. package/src/lib/biome-host/nav.ts +0 -83
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Design Token System — Single source of truth for semantic styling.
3
+ *
4
+ * All components import from here. To reskin the app, edit this file + CSS variables.
5
+ * Uses Tailwind classes mapped to semantic states, priorities, and severities.
6
+ *
7
+ * COLOR PHILOSOPHY: Bold, vibrant primary colors (Microsoft-logo style).
8
+ * Red (#F25022), Green (#7FBA00), Blue (#00A4EF), Yellow (#FFB900) — strong, not pastel.
9
+ *
10
+ * DARK MODE: Uses opacity-based colors (e.g. bg-success/15) that adapt naturally.
11
+ * Text colors use dark: variants where needed for readable contrast.
12
+ */
13
+
14
+ // ── Status Styles ──────────────────────────────────────────────────
15
+
16
+ export interface StatusStyle {
17
+ badge: string;
18
+ dot: string;
19
+ text: string;
20
+ bg: string;
21
+ border: string;
22
+ icon: string;
23
+ }
24
+
25
+ // Reusable style fragments — keep the map below a flat enum-like lookup
26
+ // rather than scattering bg-success/15 strings, so adding a new closed-set
27
+ // status is a one-line entry.
28
+ const SUCCESS_STYLE: StatusStyle = {
29
+ badge: 'bg-success/15 text-success dark:text-success border-success/30',
30
+ dot: 'bg-success',
31
+ text: 'text-success dark:text-success',
32
+ bg: 'bg-success/10',
33
+ border: 'border-success/30',
34
+ icon: 'text-success',
35
+ };
36
+ const WARNING_STYLE: StatusStyle = {
37
+ badge: 'bg-warning/15 text-warning dark:text-warning border-warning/30',
38
+ dot: 'bg-warning',
39
+ text: 'text-warning dark:text-warning',
40
+ bg: 'bg-warning/10',
41
+ border: 'border-warning/30',
42
+ icon: 'text-warning',
43
+ };
44
+ const INFO_STYLE: StatusStyle = {
45
+ badge: 'bg-info/15 text-info dark:text-info border-info/30',
46
+ dot: 'bg-info',
47
+ text: 'text-info dark:text-info',
48
+ bg: 'bg-info/10',
49
+ border: 'border-info/30',
50
+ icon: 'text-info',
51
+ };
52
+ const PRIMARY_STYLE: StatusStyle = {
53
+ badge: 'bg-primary/15 text-primary dark:text-primary border-primary/30',
54
+ dot: 'bg-primary',
55
+ text: 'text-primary dark:text-primary',
56
+ bg: 'bg-primary/10',
57
+ border: 'border-primary/30',
58
+ icon: 'text-primary',
59
+ };
60
+ const DESTRUCTIVE_STYLE: StatusStyle = {
61
+ badge: 'bg-destructive/15 text-destructive dark:text-destructive border-destructive/30',
62
+ dot: 'bg-destructive',
63
+ text: 'text-destructive dark:text-destructive',
64
+ bg: 'bg-destructive/10',
65
+ border: 'border-destructive/30',
66
+ icon: 'text-destructive',
67
+ };
68
+ const NEUTRAL_STYLE: StatusStyle = {
69
+ badge: 'bg-paper-elev text-ink-3 border-border',
70
+ dot: 'bg-muted-foreground/40',
71
+ text: 'text-ink-3',
72
+ bg: 'bg-muted/30',
73
+ border: 'border-border',
74
+ icon: 'text-ink-3',
75
+ };
76
+
77
+ // ── Tone Styles ────────────────────────────────────────────────────
78
+ // Generic tonal palette used by `<ScopeBadge>` and any surface that
79
+ // needs a closed-set chip without a status semantic. Status badges
80
+ // continue to read from STATUS_STYLES (richer shape: badge + dot + …).
81
+
82
+ export type Tone = 'neutral' | 'primary' | 'info' | 'success' | 'warning' | 'danger' | 'accent';
83
+
84
+ export const TONE_STYLES: Record<Tone, string> = {
85
+ neutral: 'bg-paper-elev text-ink-3 border-border',
86
+ primary: 'bg-primary/10 text-primary border-primary/20',
87
+ info: 'bg-info/15 text-info dark:text-info border-info/20',
88
+ success: 'bg-success/15 text-success dark:text-success border-success/20',
89
+ warning: 'bg-warning/15 text-warning dark:text-warning border-warning/20',
90
+ danger: 'bg-destructive/15 text-destructive dark:text-destructive border-destructive/20',
91
+ accent: 'bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/20',
92
+ };
93
+
94
+ export const STATUS_STYLES: Record<string, StatusStyle> = {
95
+ // ── Workflow run states (WorkflowRunStatus enum) ────────────────────
96
+ pending: INFO_STYLE,
97
+ running: WARNING_STYLE,
98
+ waiting_on_approval: WARNING_STYLE,
99
+ waiting_on_human: WARNING_STYLE,
100
+ succeeded: SUCCESS_STYLE,
101
+ failed: DESTRUCTIVE_STYLE,
102
+ cancelled: NEUTRAL_STYLE,
103
+ aborted_for_migration: NEUTRAL_STYLE,
104
+
105
+ // Legacy / generic synonyms — kept so older surfaces (project status,
106
+ // backlog "done") keep rendering correctly. Use the canonical run
107
+ // state above when emitting from new code.
108
+ completed: SUCCESS_STYLE,
109
+ stopped: WARNING_STYLE,
110
+ undone: PRIMARY_STYLE,
111
+ skipped: NEUTRAL_STYLE,
112
+
113
+ // ── Interactive session states (SessionStatus) ──────────────────────
114
+ creating: PRIMARY_STYLE,
115
+ provisioning: PRIMARY_STYLE,
116
+ active: SUCCESS_STYLE,
117
+ paused: WARNING_STYLE,
118
+ recovering: WARNING_STYLE,
119
+ completing: PRIMARY_STYLE,
120
+ archived: NEUTRAL_STYLE,
121
+
122
+ // ── Backlog / work ──────────────────────────────────────────────────
123
+ todo: NEUTRAL_STYLE,
124
+ in_progress: INFO_STYLE,
125
+ in_review: WARNING_STYLE,
126
+ done: SUCCESS_STYLE,
127
+ blocked: DESTRUCTIVE_STYLE,
128
+
129
+ // ── Design System Builder ───────────────────────────────────────────
130
+ open: INFO_STYLE,
131
+ exploring: WARNING_STYLE,
132
+ proposed: PRIMARY_STYLE,
133
+ accepted: SUCCESS_STYLE,
134
+ rejected: DESTRUCTIVE_STYLE,
135
+ merged: NEUTRAL_STYLE,
136
+
137
+ // ── SCM ─────────────────────────────────────────────────────────────
138
+ draft: NEUTRAL_STYLE,
139
+
140
+ // ── Gate ────────────────────────────────────────────────────────────
141
+ approved: SUCCESS_STYLE,
142
+ manual_review: WARNING_STYLE,
143
+ };
144
+
145
+ export function getStatusStyle(status: string): StatusStyle {
146
+ return STATUS_STYLES[status] ?? STATUS_STYLES.pending;
147
+ }
148
+
149
+ // ── Priority Styles ────────────────────────────────────────────────
150
+
151
+ export interface PriorityStyle {
152
+ badge: string;
153
+ text: string;
154
+ bg: string;
155
+ border: string;
156
+ label: string;
157
+ }
158
+
159
+ export const PRIORITY_STYLES: Record<string, PriorityStyle> = {
160
+ p0: {
161
+ badge: 'bg-destructive text-white border-destructive',
162
+ text: 'text-destructive dark:text-destructive',
163
+ bg: 'bg-destructive/10',
164
+ border: 'border-destructive/30',
165
+ label: 'Critical',
166
+ },
167
+ p1: {
168
+ badge: 'bg-warning text-white border-warning',
169
+ text: 'text-warning dark:text-warning',
170
+ bg: 'bg-warning/10',
171
+ border: 'border-warning/30',
172
+ label: 'High',
173
+ },
174
+ p2: {
175
+ badge: 'bg-warning text-white border-warning',
176
+ text: 'text-warning dark:text-warning',
177
+ bg: 'bg-warning/10',
178
+ border: 'border-warning/30',
179
+ label: 'Medium',
180
+ },
181
+ p3: {
182
+ badge: 'bg-paper-elev text-ink-3 border-border',
183
+ text: 'text-ink-3',
184
+ bg: 'bg-muted/30',
185
+ border: 'border-border',
186
+ label: 'Low',
187
+ },
188
+ };
189
+
190
+ export function getPriorityStyle(priority: string): PriorityStyle {
191
+ return PRIORITY_STYLES[priority] ?? PRIORITY_STYLES.p3;
192
+ }
193
+
194
+ // ── Severity Styles ────────────────────────────────────────────────
195
+
196
+ export interface SeverityStyle {
197
+ badge: string;
198
+ text: string;
199
+ bg: string;
200
+ border: string;
201
+ }
202
+
203
+ export const SEVERITY_STYLES: Record<string, SeverityStyle> = {
204
+ critical: {
205
+ badge: 'bg-destructive text-white border-destructive',
206
+ text: 'text-destructive dark:text-destructive',
207
+ bg: 'bg-destructive/10',
208
+ border: 'border-destructive/30',
209
+ },
210
+ error: {
211
+ badge: 'bg-destructive text-white border-destructive',
212
+ text: 'text-destructive dark:text-destructive',
213
+ bg: 'bg-destructive/10',
214
+ border: 'border-destructive/30',
215
+ },
216
+ major: {
217
+ badge: 'bg-warning text-white border-warning',
218
+ text: 'text-warning dark:text-warning',
219
+ bg: 'bg-warning/10',
220
+ border: 'border-warning/30',
221
+ },
222
+ warning: {
223
+ badge: 'bg-warning text-white border-warning',
224
+ text: 'text-warning dark:text-warning',
225
+ bg: 'bg-warning/10',
226
+ border: 'border-warning/30',
227
+ },
228
+ minor: {
229
+ badge: 'bg-info/15 text-info dark:text-info border-info/30',
230
+ text: 'text-info dark:text-info',
231
+ bg: 'bg-info/10',
232
+ border: 'border-info/30',
233
+ },
234
+ info: {
235
+ badge: 'bg-info/15 text-info dark:text-info border-info/30',
236
+ text: 'text-info dark:text-info',
237
+ bg: 'bg-info/10',
238
+ border: 'border-info/30',
239
+ },
240
+ };
241
+
242
+ export function getSeverityStyle(severity: string): SeverityStyle {
243
+ const s = severity.toLowerCase();
244
+ return SEVERITY_STYLES[s] ?? { badge: 'bg-paper-elev text-ink-3 border-border', text: 'text-ink-3', bg: 'bg-muted/30', border: 'border-border' };
245
+ }
246
+
247
+ // ── Type Styles (Backlog items) ────────────────────────────────────
248
+
249
+ export const TYPE_STYLES: Record<string, { badge: string; text: string; border: string }> = {
250
+ epic: { badge: 'bg-primary/15 text-primary dark:text-primary border-primary/30', text: 'text-primary dark:text-primary', border: 'border-l-purple-600' },
251
+ story: { badge: 'bg-info/15 text-info dark:text-info border-info/30', text: 'text-info dark:text-info', border: 'border-l-blue-600' },
252
+ task: { badge: 'bg-success/15 text-success dark:text-success border-success/30', text: 'text-success dark:text-success', border: 'border-l-green-600' },
253
+ bug: { badge: 'bg-destructive/15 text-destructive dark:text-destructive border-destructive/30', text: 'text-destructive dark:text-destructive', border: 'border-l-red-600' },
254
+ };
255
+
256
+ // ── Card Styles ────────────────────────────────────────────────────
257
+
258
+ export const CARD_STYLES = {
259
+ default: 'bg-card border border-border/50 rounded-xl shadow-[var(--shadow-2)]',
260
+ elevated: 'bg-card border border-border/40 rounded-xl shadow-[var(--shadow-8)]',
261
+ dark: 'bg-foreground text-background rounded-xl shadow-[var(--shadow-16)]',
262
+ accent: 'bg-primary/5 border border-primary/15 rounded-xl',
263
+ empty: 'border-2 border-dashed border-border/50 rounded-xl',
264
+ } as const;
265
+
266
+ // ── Section / Typography Styles ────────────────────────────────────
267
+
268
+ export const SECTION_STYLES = {
269
+ pageTitle: 'text-lg sm:text-xl font-semibold tracking-tight text-ink',
270
+ sectionHeader: 'text-sm font-semibold text-ink',
271
+ cardLabel: 'text-[11px] font-semibold uppercase tracking-wider text-ink-3',
272
+ description: 'text-sm text-ink-3',
273
+ metricValue: 'text-xl sm:text-2xl font-semibold tabular-nums text-ink',
274
+ metricLabel: 'text-sm text-ink-3',
275
+ } as const;
276
+
277
+ // ── Importance Styles (Memory) ─────────────────────────────────────
278
+
279
+ export const IMPORTANCE_STYLES: Record<number, { badge: string; label: string; star: string }> = {
280
+ 1: { badge: 'bg-paper-elev text-ink-3 border-border', label: 'Low', star: 'text-ink-3' },
281
+ 2: { badge: 'bg-info/15 text-info dark:text-info border-info/30', label: 'Normal', star: 'text-info' },
282
+ 3: { badge: 'bg-warning/15 text-warning dark:text-warning border-warning/30', label: 'High', star: 'text-warning' },
283
+ 4: { badge: 'bg-destructive/15 text-destructive dark:text-destructive border-destructive/30', label: 'Critical', star: 'text-destructive' },
284
+ };
285
+
286
+ // ── Risk Level Styles ──────────────────────────────────────────────
287
+
288
+ export const RISK_LEVEL_STYLES: Record<string, { badge: string; text: string }> = {
289
+ low: { badge: 'bg-success/15 text-success dark:text-success border-success/30', text: 'text-success dark:text-success' },
290
+ medium: { badge: 'bg-warning/15 text-warning dark:text-warning border-warning/30', text: 'text-warning dark:text-warning' },
291
+ high: { badge: 'bg-warning/15 text-warning dark:text-warning border-warning/30', text: 'text-warning dark:text-warning' },
292
+ critical: { badge: 'bg-destructive/15 text-destructive dark:text-destructive border-destructive/30', text: 'text-destructive dark:text-destructive' },
293
+ };
294
+
295
+ export function getRiskLevelStyle(level: string): { badge: string; text: string } {
296
+ return RISK_LEVEL_STYLES[level] ?? RISK_LEVEL_STYLES.low;
297
+ }
298
+
299
+ // ── Agent Source Styles ────────────────────────────────────────────
300
+
301
+ export const AGENT_SOURCE_STYLES: Record<string, { border: string; bg: string; icon: string; iconBg: string }> = {
302
+ dynamic: {
303
+ border: 'border-warning/30',
304
+ bg: 'bg-warning/5',
305
+ icon: 'text-warning',
306
+ iconBg: 'bg-warning/10',
307
+ },
308
+ predefined: {
309
+ border: 'border-border',
310
+ bg: 'bg-muted/20',
311
+ icon: 'text-primary',
312
+ iconBg: 'bg-primary/10',
313
+ },
314
+ };
315
+
316
+ // ── Kind Styles (Memory Graph Model) ─────────────────────────────────
317
+
318
+ export const KIND_STYLES: Record<string, string> = {
319
+ LESSON: 'bg-info/15 text-info dark:text-info border-info/30',
320
+ PATTERN: 'bg-primary/15 text-primary dark:text-primary border-primary/30',
321
+ CORRECTION: 'bg-destructive/15 text-destructive dark:text-destructive border-destructive/30',
322
+ PREFERENCE: 'bg-success/15 text-success dark:text-success border-success/30',
323
+ };
324
+
325
+ // ── State Styles (Memory Graph Model) ────────────────────────────────
326
+
327
+ export const STATE_STYLES: Record<string, string> = {
328
+ CANDIDATE: 'bg-paper-elev text-ink-3 border-border',
329
+ ACTIVE: 'bg-success/15 text-success dark:text-success border-success/30',
330
+ SUPERSEDED: 'bg-paper-elev text-ink-3 border-border line-through',
331
+ CONTRADICTED: 'bg-destructive/15 text-destructive dark:text-destructive border-destructive/30 line-through',
332
+ REVIEW_REQUIRED: 'bg-warning/15 text-warning dark:text-warning border-warning/30',
333
+ ARCHIVED: 'bg-paper-elev text-ink-3 border-border opacity-60',
334
+ };
335
+
336
+ // ── Origin Styles (Memory Graph Model) ───────────────────────────────
337
+
338
+ export const ORIGIN_STYLES: Record<string, string> = {
339
+ GATE_REJECTION: 'bg-destructive/15 text-destructive dark:text-destructive border-destructive/30',
340
+ GATE_APPROVAL: 'bg-success/15 text-success dark:text-success border-success/30',
341
+ USER_CORRECTION: 'bg-warning/15 text-warning dark:text-warning border-warning/30',
342
+ MANUAL_OVERRIDE: 'bg-destructive/15 text-destructive dark:text-destructive border-destructive/30',
343
+ REVIEW_FINDING: 'bg-warning/15 text-warning dark:text-warning border-warning/30',
344
+ PATTERN_DETECTION: 'bg-primary/15 text-primary dark:text-primary border-primary/30',
345
+ USER_CREATED: 'bg-info/15 text-info dark:text-info border-info/30',
346
+ };
@@ -0,0 +1,16 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Returns a debounced version of the provided value.
5
+ * Updates only after `delayMs` milliseconds of inactivity.
6
+ */
7
+ export function useDebouncedValue<T>(value: T, delayMs = 300): T {
8
+ const [debounced, setDebounced] = useState(value);
9
+
10
+ useEffect(() => {
11
+ const timer = setTimeout(() => setDebounced(value), delayMs);
12
+ return () => clearTimeout(timer);
13
+ }, [value, delayMs]);
14
+
15
+ return debounced;
16
+ }
package/src/ui/index.ts CHANGED
@@ -12,6 +12,12 @@
12
12
 
13
13
  export { cn } from './cn';
14
14
 
15
+ // ── design tokens (semantic styling SSOT) ──
16
+ export * from './design-tokens';
17
+
18
+ // ── hooks ──
19
+ export { useDebouncedValue } from './hooks/use-debounced-value';
20
+
15
21
  // ── shadcn primitives ──
16
22
  export * from './primitives/button';
17
23
  export * from './primitives/badge';
@@ -38,9 +44,18 @@ export * from './primitives/radio-group';
38
44
  export * from './primitives/overflow-tabs';
39
45
  export * from './primitives/tag-multi-select';
40
46
  export * from './primitives/sheet';
47
+ export * from './primitives/form-stepper';
48
+ export * from './primitives/async-combobox';
41
49
 
42
50
  // ── Xema chrome ──
43
51
  export { default as ErrorCard, type ErrorMessageFormatter } from './chrome/ErrorCard';
52
+ export { default as StatusBadge, type StatusBadgeProps, type StatusBadgeSize } from './chrome/status-badge';
53
+ export { default as ScopeBadge, type ScopeBadgeProps } from './chrome/scope-badge';
54
+ export {
55
+ default as ConfirmDeleteDialog,
56
+ type ConfirmDeleteDialogProps,
57
+ } from './chrome/confirm-delete-dialog';
58
+ export { default as PageShell } from './chrome/page-shell';
44
59
  export {
45
60
  default as EmptyState,
46
61
  type EmptyStateVariant,
@@ -0,0 +1,178 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { ChevronsUpDown, Loader2, Search } from 'lucide-react';
3
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
4
+
5
+ import { cn } from '../cn';
6
+ import { useDebouncedValue } from '../hooks/use-debounced-value';
7
+ import { Button } from './button';
8
+ import { Input } from './input';
9
+ import { Popover, PopoverContent, PopoverTrigger } from './popover';
10
+
11
+ export interface AsyncComboboxProps<T> {
12
+ /** Unique key prefix for query caching to avoid collisions between combobox instances. */
13
+ queryKeyPrefix?: string;
14
+ /** Currently selected item (null = nothing selected). */
15
+ value: T | null;
16
+ /** Text shown on the trigger when an item is selected. */
17
+ displayValue?: string;
18
+ /** Trigger button placeholder when nothing is selected. */
19
+ placeholder?: string;
20
+ /** Placeholder inside the search input. */
21
+ searchPlaceholder?: string;
22
+ /** Message shown when the search returns no results. */
23
+ emptyMessage?: string;
24
+ /** Async (or sync) function that returns items matching the query. */
25
+ onSearch: (query: string) => Promise<readonly T[]> | readonly T[];
26
+ /**
27
+ * Minimum characters before the search query is sent. Default is 0 — the
28
+ * picker fetches the full list as soon as it opens so users can browse
29
+ * without typing. Override to a higher number only when the underlying
30
+ * loader cannot handle an empty query (very large unbounded sets).
31
+ */
32
+ minSearchLength?: number;
33
+ /** Debounce delay in ms (default 300). */
34
+ debounceMs?: number;
35
+ /** Render a single result row. */
36
+ renderItem: (item: T) => ReactNode;
37
+ /** Unique key for each item. */
38
+ getItemKey: (item: T) => string;
39
+ /** Called when the user selects an item. */
40
+ onSelect: (item: T) => void;
41
+ /** Disable the combobox. */
42
+ disabled?: boolean;
43
+ /** Extra className for the trigger button. */
44
+ triggerClassName?: string;
45
+ }
46
+
47
+ /**
48
+ * Debounced async picker: a popover trigger + search input backed by a
49
+ * react-query'd loader. Host-framework-agnostic — the caller supplies the
50
+ * loader (`onSearch`) and row renderer, so it works against any data source.
51
+ *
52
+ * Requires `@tanstack/react-query` (an optional peer of the kernel) to be
53
+ * installed and a `QueryClientProvider` mounted above.
54
+ */
55
+ export function AsyncCombobox<T>({
56
+ queryKeyPrefix = 'default',
57
+ value,
58
+ displayValue,
59
+ placeholder = 'Select…',
60
+ searchPlaceholder = 'Search…',
61
+ emptyMessage = 'No results found',
62
+ onSearch,
63
+ minSearchLength = 0,
64
+ debounceMs = 300,
65
+ renderItem,
66
+ getItemKey,
67
+ onSelect,
68
+ disabled,
69
+ triggerClassName,
70
+ }: Readonly<AsyncComboboxProps<T>>) {
71
+ const [open, setOpen] = useState(false);
72
+ const [search, setSearch] = useState('');
73
+ const inputRef = useRef<HTMLInputElement>(null);
74
+
75
+ const debouncedSearch = useDebouncedValue(search, debounceMs);
76
+
77
+ const searchEnabled = open && debouncedSearch.length >= minSearchLength;
78
+
79
+ const { data: results = [], isLoading } = useQuery({
80
+ queryKey: ['async-combobox', queryKeyPrefix, debouncedSearch],
81
+ queryFn: () => onSearch(debouncedSearch),
82
+ enabled: searchEnabled,
83
+ staleTime: 30_000,
84
+ });
85
+
86
+ // Auto-focus the search input when the popover opens; clear search on close.
87
+ useEffect(() => {
88
+ if (open) {
89
+ const timer = setTimeout(() => inputRef.current?.focus(), 50);
90
+ return () => clearTimeout(timer);
91
+ }
92
+ setSearch('');
93
+ return undefined;
94
+ }, [open]);
95
+
96
+ const handleSelect = (item: T) => {
97
+ onSelect(item);
98
+ setOpen(false);
99
+ };
100
+
101
+ const showTypeToSearchHint =
102
+ !isLoading && minSearchLength > 0 && debouncedSearch.length < minSearchLength;
103
+ const showEmptyMessage = !isLoading && searchEnabled && results.length === 0;
104
+
105
+ return (
106
+ <Popover open={open} onOpenChange={setOpen}>
107
+ <PopoverTrigger asChild>
108
+ <Button
109
+ variant="outline"
110
+ role="combobox"
111
+ aria-expanded={open}
112
+ disabled={disabled}
113
+ className={cn(
114
+ 'w-full justify-between font-normal',
115
+ !value && 'text-ink-3',
116
+ triggerClassName,
117
+ )}
118
+ >
119
+ <span className="truncate">
120
+ {value ? (displayValue ?? placeholder) : placeholder}
121
+ </span>
122
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
123
+ </Button>
124
+ </PopoverTrigger>
125
+ <PopoverContent
126
+ className="w-[--radix-popover-trigger-width] min-w-[18rem] p-0"
127
+ align="start"
128
+ onOpenAutoFocus={(e) => e.preventDefault()}
129
+ >
130
+ {/* Search input */}
131
+ <div className="relative border-b">
132
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-3" />
133
+ <Input
134
+ ref={inputRef}
135
+ value={search}
136
+ onChange={(e) => setSearch(e.target.value)}
137
+ placeholder={searchPlaceholder}
138
+ className="h-9 text-body-1 pl-8 border-0 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0"
139
+ />
140
+ </div>
141
+
142
+ {/* Results — native scroll, not Radix ScrollArea (which collapses
143
+ to content height when the parent uses max-h instead of h). */}
144
+ <div className="max-h-80 overflow-y-auto overscroll-contain">
145
+ <div className="p-1">
146
+ {isLoading && (
147
+ <div className="flex items-center justify-center py-4">
148
+ <Loader2 className="h-4 w-4 animate-spin text-ink-3" />
149
+ </div>
150
+ )}
151
+
152
+ {showTypeToSearchHint && (
153
+ <p className="text-body-1 text-ink-3 text-center py-4">
154
+ Type {minSearchLength}+ characters to search
155
+ </p>
156
+ )}
157
+
158
+ {showEmptyMessage && (
159
+ <p className="text-body-1 text-ink-3 text-center py-4">{emptyMessage}</p>
160
+ )}
161
+
162
+ {!isLoading &&
163
+ results.map((item) => (
164
+ <button
165
+ key={getItemKey(item)}
166
+ type="button"
167
+ className="w-full text-left text-body-1 px-2 py-1.5 rounded hover:bg-paper-elev transition-colors flex items-center gap-2"
168
+ onClick={() => handleSelect(item)}
169
+ >
170
+ {renderItem(item)}
171
+ </button>
172
+ ))}
173
+ </div>
174
+ </div>
175
+ </PopoverContent>
176
+ </Popover>
177
+ );
178
+ }
@@ -0,0 +1,109 @@
1
+ import { Check } from 'lucide-react';
2
+
3
+ import type { ReactNode } from 'react';
4
+
5
+ import { cn } from '../cn';
6
+
7
+ export enum FormStepStatus {
8
+ PENDING = 'pending',
9
+ ACTIVE = 'active',
10
+ DONE = 'done',
11
+ }
12
+
13
+ export interface FormStep {
14
+ readonly key: string;
15
+ readonly label: string;
16
+ readonly hint?: string;
17
+ readonly status: FormStepStatus;
18
+ readonly children: ReactNode;
19
+ }
20
+
21
+ interface FormStepperProps {
22
+ readonly steps: readonly FormStep[];
23
+ }
24
+
25
+ /**
26
+ * Vertical stepper for in-dialog multi-step forms — numbered badges with a
27
+ * connector rail. Every step's body is rendered inline (pending steps are
28
+ * dimmed and pointer-disabled). The dialog owns the submit button; this
29
+ * primitive renders structure only.
30
+ */
31
+ export function FormStepper({ steps }: FormStepperProps) {
32
+ return (
33
+ <ol className="space-y-4">
34
+ {steps.map((step, index) => (
35
+ <FormStepperItem
36
+ key={step.key}
37
+ step={step}
38
+ stepNumber={index + 1}
39
+ isLast={index === steps.length - 1}
40
+ />
41
+ ))}
42
+ </ol>
43
+ );
44
+ }
45
+
46
+ interface FormStepperItemProps {
47
+ readonly step: FormStep;
48
+ readonly stepNumber: number;
49
+ readonly isLast: boolean;
50
+ }
51
+
52
+ function FormStepperItem({ step, stepNumber, isLast }: FormStepperItemProps) {
53
+ const isPending = step.status === FormStepStatus.PENDING;
54
+ const isDone = step.status === FormStepStatus.DONE;
55
+ const isActive = step.status === FormStepStatus.ACTIVE;
56
+
57
+ return (
58
+ <li className="relative flex gap-3">
59
+ {/* Connector rail — hugs the numbered badge column. Hidden on the last
60
+ item so the chain doesn't dangle past the final step. */}
61
+ {!isLast && (
62
+ <span
63
+ aria-hidden
64
+ className={cn(
65
+ 'absolute left-3.5 top-8 bottom-0 w-px -translate-x-1/2',
66
+ isDone ? 'bg-primary/30' : 'bg-rule',
67
+ )}
68
+ />
69
+ )}
70
+
71
+ <span
72
+ className={cn(
73
+ 'relative z-[1] mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-caption font-semibold',
74
+ isDone && 'border-primary/40 bg-primary/15 text-primary',
75
+ isActive && 'border-primary bg-primary text-primary-foreground',
76
+ isPending && 'border-rule bg-paper-elev text-ink-3',
77
+ )}
78
+ >
79
+ {isDone ? <Check className="h-3.5 w-3.5" strokeWidth={2.5} /> : stepNumber}
80
+ </span>
81
+
82
+ <div
83
+ className={cn(
84
+ 'flex-1 min-w-0 rounded-lg border bg-card p-4 transition-opacity',
85
+ isActive && 'border-primary/40 shadow-sm',
86
+ isDone && 'border-rule',
87
+ isPending && 'border-rule opacity-60',
88
+ )}
89
+ >
90
+ <div className="mb-3 space-y-0.5">
91
+ <p
92
+ className={cn(
93
+ 'text-body-1 font-medium',
94
+ isPending ? 'text-ink-3' : 'text-foreground',
95
+ )}
96
+ >
97
+ {step.label}
98
+ </p>
99
+ {step.hint && <p className="text-caption text-ink-3">{step.hint}</p>}
100
+ </div>
101
+ {/* Keep pointer/keyboard focus out of pending steps without disabling
102
+ them globally — every input retains its native disabled semantics. */}
103
+ <div className={cn(isPending && 'pointer-events-none select-none')}>
104
+ {step.children}
105
+ </div>
106
+ </div>
107
+ </li>
108
+ );
109
+ }