@zen-ui-capri/system 0.0.0-stage → 1.1.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 (105) hide show
  1. package/.agents/skills/capri-zen-ui/SKILL.md +59 -0
  2. package/AGENTS.md +57 -0
  3. package/CLAUDE.md +7 -0
  4. package/LICENSE +21 -0
  5. package/README.md +93 -2
  6. package/bin/capri.mjs +1187 -0
  7. package/dist/lib/audio/ZenAudioController.d.ts +10 -0
  8. package/dist/lib/audio/ZenAudioController.d.ts.map +1 -0
  9. package/dist/lib/audio/useZenAudioSynth.d.ts +26 -0
  10. package/dist/lib/audio/useZenAudioSynth.d.ts.map +1 -0
  11. package/dist/lib/capri/capri-engine.d.ts +127 -0
  12. package/dist/lib/capri/capri-engine.d.ts.map +1 -0
  13. package/dist/lib/components/ZenAIConstruct.d.ts +24 -0
  14. package/dist/lib/components/ZenAIConstruct.d.ts.map +1 -0
  15. package/dist/lib/components/ZenAmbientBackground.d.ts +8 -0
  16. package/dist/lib/components/ZenAmbientBackground.d.ts.map +1 -0
  17. package/dist/lib/components/ZenBadge.d.ts +21 -0
  18. package/dist/lib/components/ZenBadge.d.ts.map +1 -0
  19. package/dist/lib/components/ZenButton.d.ts +24 -0
  20. package/dist/lib/components/ZenButton.d.ts.map +1 -0
  21. package/dist/lib/components/ZenCard.d.ts +36 -0
  22. package/dist/lib/components/ZenCard.d.ts.map +1 -0
  23. package/dist/lib/components/ZenDataDisplay.d.ts +50 -0
  24. package/dist/lib/components/ZenDataDisplay.d.ts.map +1 -0
  25. package/dist/lib/components/ZenDataTable.d.ts +20 -0
  26. package/dist/lib/components/ZenDataTable.d.ts.map +1 -0
  27. package/dist/lib/components/ZenDossierPanel.d.ts +33 -0
  28. package/dist/lib/components/ZenDossierPanel.d.ts.map +1 -0
  29. package/dist/lib/components/ZenDropdown.d.ts +19 -0
  30. package/dist/lib/components/ZenDropdown.d.ts.map +1 -0
  31. package/dist/lib/components/ZenFeedback.d.ts +24 -0
  32. package/dist/lib/components/ZenFeedback.d.ts.map +1 -0
  33. package/dist/lib/components/ZenForms.d.ts +84 -0
  34. package/dist/lib/components/ZenForms.d.ts.map +1 -0
  35. package/dist/lib/components/ZenHeading.d.ts +15 -0
  36. package/dist/lib/components/ZenHeading.d.ts.map +1 -0
  37. package/dist/lib/components/ZenIcon.d.ts +24 -0
  38. package/dist/lib/components/ZenIcon.d.ts.map +1 -0
  39. package/dist/lib/components/ZenInput.d.ts +12 -0
  40. package/dist/lib/components/ZenInput.d.ts.map +1 -0
  41. package/dist/lib/components/ZenModal.d.ts +12 -0
  42. package/dist/lib/components/ZenModal.d.ts.map +1 -0
  43. package/dist/lib/components/ZenNavbar.d.ts +11 -0
  44. package/dist/lib/components/ZenNavbar.d.ts.map +1 -0
  45. package/dist/lib/components/ZenNavigation.d.ts +52 -0
  46. package/dist/lib/components/ZenNavigation.d.ts.map +1 -0
  47. package/dist/lib/components/ZenOverlays.d.ts +48 -0
  48. package/dist/lib/components/ZenOverlays.d.ts.map +1 -0
  49. package/dist/lib/components/ZenSegmentedControl.d.ts +18 -0
  50. package/dist/lib/components/ZenSegmentedControl.d.ts.map +1 -0
  51. package/dist/lib/components/ZenSwitch.d.ts +11 -0
  52. package/dist/lib/components/ZenSwitch.d.ts.map +1 -0
  53. package/dist/lib/components/ZenTabs.d.ts +17 -0
  54. package/dist/lib/components/ZenTabs.d.ts.map +1 -0
  55. package/dist/lib/components/ZenTimelineCard.d.ts +14 -0
  56. package/dist/lib/components/ZenTimelineCard.d.ts.map +1 -0
  57. package/dist/lib/components/ZenToast.d.ts +18 -0
  58. package/dist/lib/components/ZenToast.d.ts.map +1 -0
  59. package/dist/lib/cosmos/ZenCosmosCanvas.d.ts +10 -0
  60. package/dist/lib/cosmos/ZenCosmosCanvas.d.ts.map +1 -0
  61. package/dist/lib/cosmos/ZenTelemetryHud.d.ts +45 -0
  62. package/dist/lib/cosmos/ZenTelemetryHud.d.ts.map +1 -0
  63. package/dist/lib/index.d.ts +33 -0
  64. package/dist/lib/index.d.ts.map +1 -0
  65. package/dist/lib/index.js +3922 -0
  66. package/dist/lib/theme/ZenThemeProvider.d.ts +47 -0
  67. package/dist/lib/theme/ZenThemeProvider.d.ts.map +1 -0
  68. package/dist/lib/tokens.css +888 -0
  69. package/dist/lib/universal/zen-universal.d.ts +77 -0
  70. package/dist/lib/universal/zen-universal.d.ts.map +1 -0
  71. package/dist/lib/universal/zen-universal.js +18 -0
  72. package/dist/lib/zen-universal-C0PD8AI9.js +1352 -0
  73. package/package.json +78 -4
  74. package/src/lib/audio/ZenAudioController.tsx +114 -0
  75. package/src/lib/audio/useZenAudioSynth.ts +289 -0
  76. package/src/lib/capri/capri-engine.ts +939 -0
  77. package/src/lib/components/ZenAIConstruct.tsx +594 -0
  78. package/src/lib/components/ZenAmbientBackground.tsx +210 -0
  79. package/src/lib/components/ZenBadge.tsx +111 -0
  80. package/src/lib/components/ZenButton.tsx +127 -0
  81. package/src/lib/components/ZenCard.tsx +180 -0
  82. package/src/lib/components/ZenDataDisplay.tsx +349 -0
  83. package/src/lib/components/ZenDataTable.tsx +143 -0
  84. package/src/lib/components/ZenDossierPanel.tsx +120 -0
  85. package/src/lib/components/ZenDropdown.tsx +208 -0
  86. package/src/lib/components/ZenFeedback.tsx +137 -0
  87. package/src/lib/components/ZenForms.tsx +664 -0
  88. package/src/lib/components/ZenHeading.tsx +63 -0
  89. package/src/lib/components/ZenIcon.tsx +220 -0
  90. package/src/lib/components/ZenInput.tsx +120 -0
  91. package/src/lib/components/ZenModal.tsx +103 -0
  92. package/src/lib/components/ZenNavbar.tsx +106 -0
  93. package/src/lib/components/ZenNavigation.tsx +300 -0
  94. package/src/lib/components/ZenOverlays.tsx +386 -0
  95. package/src/lib/components/ZenSegmentedControl.tsx +113 -0
  96. package/src/lib/components/ZenSwitch.tsx +63 -0
  97. package/src/lib/components/ZenTabs.tsx +103 -0
  98. package/src/lib/components/ZenTimelineCard.tsx +64 -0
  99. package/src/lib/components/ZenToast.tsx +96 -0
  100. package/src/lib/cosmos/ZenCosmosCanvas.tsx +931 -0
  101. package/src/lib/cosmos/ZenTelemetryHud.tsx +385 -0
  102. package/src/lib/index.ts +293 -0
  103. package/src/lib/styles/zen-tokens.css +888 -0
  104. package/src/lib/theme/ZenThemeProvider.tsx +328 -0
  105. package/src/lib/universal/zen-universal.ts +743 -0
@@ -0,0 +1,664 @@
1
+ import {
2
+ useId,
3
+ useRef,
4
+ useEffect,
5
+ useState,
6
+ type ChangeEvent,
7
+ type TextareaHTMLAttributes,
8
+ type KeyboardEvent,
9
+ type ReactNode,
10
+ type DragEvent,
11
+ } from 'react';
12
+ import { Minus, Plus, Check, UploadCloud, FileText, X } from 'lucide-react';
13
+ import { capri } from '../capri/capri-engine';
14
+
15
+ export interface ZenTextareaProps
16
+ extends TextareaHTMLAttributes<HTMLTextAreaElement> {
17
+ readonly label: string;
18
+ readonly hint?: string;
19
+ readonly error?: string;
20
+ readonly maxLengthCount?: number;
21
+ readonly mono?: boolean;
22
+ readonly capriGuard?: boolean;
23
+ }
24
+
25
+ export function ZenTextarea({
26
+ label,
27
+ hint,
28
+ error,
29
+ maxLengthCount,
30
+ mono = false,
31
+ capriGuard = true,
32
+ id,
33
+ value,
34
+ defaultValue,
35
+ onChange,
36
+ className = '',
37
+ ...rest
38
+ }: ZenTextareaProps) {
39
+ const generatedId = useId();
40
+ const textareaId = id ?? generatedId;
41
+ const descId = `${textareaId}-desc`;
42
+ const [internalVal, setInternalVal] = useState<string>(
43
+ typeof defaultValue === 'string' ? defaultValue : ''
44
+ );
45
+
46
+ const liveText =
47
+ typeof value === 'string'
48
+ ? value
49
+ : typeof value === 'number'
50
+ ? String(value)
51
+ : internalVal;
52
+ const currentLength = liveText.length;
53
+
54
+ const securityScan = capriGuard ? capri.scanInputSecurity(liveText) : null;
55
+ const guardError =
56
+ securityScan && !securityScan.safe && securityScan.threats[0]
57
+ ? `CAPRI GUARD BLOCKED [${securityScan.threats[0].category}]: ${securityScan.threats[0].patternName}`
58
+ : undefined;
59
+ const activeError = error || guardError;
60
+
61
+ const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
62
+ if (value === undefined) {
63
+ setInternalVal(e.target.value);
64
+ }
65
+ onChange?.(e);
66
+ };
67
+
68
+ return (
69
+ <div
70
+ data-zen-occlude="true"
71
+ className={`flex flex-col gap-1.5 ${className}`.trim()}
72
+ >
73
+ <div className="flex items-center justify-between gap-2">
74
+ <label
75
+ htmlFor={textareaId}
76
+ className="zen-telemetry-label text-[var(--zen-ink-secondary)]"
77
+ >
78
+ {label}
79
+ </label>
80
+ {maxLengthCount !== undefined && (
81
+ <span className="zen-telemetry-label">
82
+ {currentLength} / {maxLengthCount}
83
+ </span>
84
+ )}
85
+ </div>
86
+
87
+ <textarea
88
+ id={textareaId}
89
+ value={value}
90
+ defaultValue={defaultValue}
91
+ onChange={handleChange}
92
+ maxLength={maxLengthCount}
93
+ aria-invalid={Boolean(activeError)}
94
+ aria-describedby={activeError || hint ? descId : undefined}
95
+ data-capri-threat={
96
+ securityScan && !securityScan.safe && securityScan.threats[0]
97
+ ? securityScan.threats[0].category
98
+ : undefined
99
+ }
100
+ className={`w-full min-h-24 px-3.5 py-2.5 rounded-xl bg-[var(--zen-surface-1)] border text-sm text-[var(--zen-ink-primary)] placeholder:text-[var(--zen-ink-muted)] focus:outline-none transition-colors ${
101
+ activeError
102
+ ? 'border-[var(--zen-status-danger)]'
103
+ : 'border-[var(--zen-border-default)] focus:border-[var(--zen-accent-primary)]'
104
+ } ${mono ? 'zen-font-mono' : 'zen-font-sans'}`}
105
+ {...rest}
106
+ />
107
+
108
+ {(activeError || hint) && (
109
+ <span
110
+ id={descId}
111
+ className={`zen-font-mono text-[11px] ${
112
+ activeError
113
+ ? 'text-[var(--zen-status-danger)]'
114
+ : 'text-[var(--zen-ink-muted)]'
115
+ }`}
116
+ >
117
+ {activeError ?? hint}
118
+ </span>
119
+ )}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ export interface ZenNumberStepperProps {
125
+ readonly label: string;
126
+ readonly value: number;
127
+ readonly onChange: (next: number) => void;
128
+ readonly min?: number;
129
+ readonly max?: number;
130
+ readonly step?: number;
131
+ readonly unit?: string;
132
+ readonly disabled?: boolean;
133
+ readonly className?: string;
134
+ }
135
+
136
+ export function ZenNumberStepper({
137
+ label,
138
+ value,
139
+ onChange,
140
+ min = 0,
141
+ max = 100,
142
+ step = 1,
143
+ unit,
144
+ disabled = false,
145
+ className = '',
146
+ }: ZenNumberStepperProps) {
147
+ const precision = useId();
148
+ const decimals = (() => {
149
+ const s = String(step);
150
+ const dot = s.indexOf('.');
151
+ return dot === -1 ? 0 : s.length - dot - 1;
152
+ })();
153
+
154
+ const clampAndRound = (raw: number) => {
155
+ const clamped = Math.max(min, Math.min(max, raw));
156
+ return Number(clamped.toFixed(decimals));
157
+ };
158
+
159
+ const handleStep = (delta: number) => {
160
+ if (disabled) return;
161
+ onChange(clampAndRound(value + delta * step));
162
+ };
163
+
164
+ return (
165
+ <div
166
+ data-zen-occlude="true"
167
+ className={`flex flex-col gap-1.5 ${className}`.trim()}
168
+ >
169
+ <span
170
+ id={precision}
171
+ className="zen-telemetry-label text-[var(--zen-ink-secondary)]"
172
+ >
173
+ {label}
174
+ </span>
175
+ <div className="inline-flex items-center justify-between gap-2 p-1.5 rounded-xl bg-[var(--zen-surface-1)] border border-[var(--zen-border-default)]">
176
+ <button
177
+ type="button"
178
+ aria-label={`Decrease ${label}`}
179
+ disabled={disabled || value <= min}
180
+ onClick={() => handleStep(-1)}
181
+ className="zen-focus-ring p-2 rounded-lg bg-[var(--zen-surface-subtle)] hover:bg-[var(--zen-accent-subtle)] text-[var(--zen-ink-primary)] disabled:opacity-35 cursor-pointer transition-colors"
182
+ >
183
+ <Minus className="w-3.5 h-3.5" />
184
+ </button>
185
+
186
+ <div
187
+ role="spinbutton"
188
+ tabIndex={disabled ? -1 : 0}
189
+ aria-labelledby={precision}
190
+ aria-valuenow={value}
191
+ aria-valuemin={min}
192
+ aria-valuemax={max}
193
+ onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
194
+ if (e.key === 'ArrowUp') {
195
+ e.preventDefault();
196
+ handleStep(1);
197
+ } else if (e.key === 'ArrowDown') {
198
+ e.preventDefault();
199
+ handleStep(-1);
200
+ }
201
+ }}
202
+ className="zen-focus-ring px-3 py-1 rounded-lg zen-font-mono text-sm font-semibold text-[var(--zen-ink-primary)] text-center min-w-20"
203
+ >
204
+ {value.toFixed(decimals)}
205
+ {unit ? (
206
+ <span className="ml-1 text-xs text-[var(--zen-ink-muted)]">
207
+ {unit}
208
+ </span>
209
+ ) : null}
210
+ </div>
211
+
212
+ <button
213
+ type="button"
214
+ aria-label={`Increase ${label}`}
215
+ disabled={disabled || value >= max}
216
+ onClick={() => handleStep(1)}
217
+ className="zen-focus-ring p-2 rounded-lg bg-[var(--zen-surface-subtle)] hover:bg-[var(--zen-accent-subtle)] text-[var(--zen-ink-primary)] disabled:opacity-35 cursor-pointer transition-colors"
218
+ >
219
+ <Plus className="w-3.5 h-3.5" />
220
+ </button>
221
+ </div>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ export interface ZenCheckboxProps {
227
+ readonly checked: boolean;
228
+ readonly indeterminate?: boolean;
229
+ readonly onChange: (nextChecked: boolean) => void;
230
+ readonly label: ReactNode;
231
+ readonly description?: string;
232
+ readonly disabled?: boolean;
233
+ readonly className?: string;
234
+ }
235
+
236
+ export function ZenCheckbox({
237
+ checked,
238
+ indeterminate = false,
239
+ onChange,
240
+ label,
241
+ description,
242
+ disabled = false,
243
+ className = '',
244
+ }: ZenCheckboxProps) {
245
+ const inputRef = useRef<HTMLInputElement>(null);
246
+ const id = useId();
247
+
248
+ useEffect(() => {
249
+ if (inputRef.current) {
250
+ inputRef.current.indeterminate = indeterminate;
251
+ }
252
+ }, [indeterminate]);
253
+
254
+ return (
255
+ <label
256
+ htmlFor={id}
257
+ data-zen-occlude="true"
258
+ className={`flex items-start gap-3 cursor-pointer select-none ${
259
+ disabled ? 'opacity-45 cursor-not-allowed' : ''
260
+ } ${className}`.trim()}
261
+ >
262
+ <div className="relative flex items-center justify-center mt-0.5">
263
+ <input
264
+ ref={inputRef}
265
+ id={id}
266
+ type="checkbox"
267
+ checked={checked}
268
+ disabled={disabled}
269
+ aria-checked={indeterminate ? 'mixed' : checked}
270
+ onChange={(e) => onChange(e.target.checked)}
271
+ className="peer sr-only"
272
+ />
273
+ <span
274
+ className={`w-5 h-5 rounded-md border flex items-center justify-center transition-all peer-focus-visible:outline-2 peer-focus-visible:outline-[var(--zen-accent-focus)] peer-focus-visible:outline-offset-2 ${
275
+ checked || indeterminate
276
+ ? 'bg-[var(--zen-accent-primary)] border-[var(--zen-accent-primary)] text-[var(--zen-ink-on-accent)]'
277
+ : 'bg-[var(--zen-surface-1)] border-[var(--zen-border-control)]'
278
+ }`}
279
+ >
280
+ {indeterminate ? (
281
+ <Minus className="w-3.5 h-3.5 stroke-[3]" />
282
+ ) : checked ? (
283
+ <Check className="w-3.5 h-3.5 stroke-[3]" />
284
+ ) : null}
285
+ </span>
286
+ </div>
287
+ <div className="min-w-0">
288
+ <div className="text-sm font-medium text-[var(--zen-ink-primary)]">
289
+ {label}
290
+ </div>
291
+ {description && (
292
+ <div className="zen-font-mono text-[11px] text-[var(--zen-ink-muted)] mt-0.5">
293
+ {description}
294
+ </div>
295
+ )}
296
+ </div>
297
+ </label>
298
+ );
299
+ }
300
+
301
+ export interface ZenRadioOption<T extends string> {
302
+ readonly value: T;
303
+ readonly label: string;
304
+ readonly description?: string;
305
+ readonly badge?: string;
306
+ readonly disabled?: boolean;
307
+ }
308
+
309
+ export interface ZenRadioGroupProps<T extends string> {
310
+ readonly label: string;
311
+ readonly options: readonly ZenRadioOption<T>[];
312
+ readonly value: T;
313
+ readonly onChange: (nextValue: T) => void;
314
+ readonly className?: string;
315
+ }
316
+
317
+ export function ZenRadioGroup<T extends string>({
318
+ label,
319
+ options,
320
+ value,
321
+ onChange,
322
+ className = '',
323
+ }: ZenRadioGroupProps<T>) {
324
+ const groupId = useId();
325
+ const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
326
+
327
+ const enabledIndices = options
328
+ .map((o, idx) => (o.disabled ? -1 : idx))
329
+ .filter((idx) => idx !== -1);
330
+
331
+ const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>, idx: number) => {
332
+ const pos = enabledIndices.indexOf(idx);
333
+ if (pos === -1) return;
334
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
335
+ e.preventDefault();
336
+ const nextIdx = enabledIndices[(pos + 1) % enabledIndices.length];
337
+ btnRefs.current[nextIdx]?.focus();
338
+ onChange(options[nextIdx].value);
339
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
340
+ e.preventDefault();
341
+ const prevIdx =
342
+ enabledIndices[
343
+ (pos - 1 + enabledIndices.length) % enabledIndices.length
344
+ ];
345
+ btnRefs.current[prevIdx]?.focus();
346
+ onChange(options[prevIdx].value);
347
+ }
348
+ };
349
+
350
+ return (
351
+ <div
352
+ role="radiogroup"
353
+ aria-labelledby={groupId}
354
+ data-zen-occlude="true"
355
+ className={`space-y-2 ${className}`.trim()}
356
+ >
357
+ <div
358
+ id={groupId}
359
+ className="zen-telemetry-label text-[var(--zen-ink-secondary)]"
360
+ >
361
+ {label}
362
+ </div>
363
+ <div className="grid grid-cols-1 gap-2">
364
+ {options.map((opt, idx) => {
365
+ const isSelected = opt.value === value;
366
+ return (
367
+ <button
368
+ key={opt.value}
369
+ ref={(el) => {
370
+ btnRefs.current[idx] = el;
371
+ }}
372
+ type="button"
373
+ role="radio"
374
+ aria-checked={isSelected}
375
+ disabled={opt.disabled}
376
+ tabIndex={isSelected ? 0 : -1}
377
+ onClick={() => onChange(opt.value)}
378
+ onKeyDown={(e) => handleKeyDown(e, idx)}
379
+ className={`zen-focus-ring flex items-center justify-between gap-3 p-3.5 rounded-xl border text-left cursor-pointer transition-all ${
380
+ isSelected
381
+ ? 'bg-[var(--zen-accent-subtle)] border-[var(--zen-accent-border)]'
382
+ : 'bg-[var(--zen-surface-1)] border-[var(--zen-border-subtle)] hover:border-[var(--zen-border-strong)]'
383
+ }`}
384
+ >
385
+ <div className="flex items-start gap-3 min-w-0">
386
+ <span
387
+ className={`mt-0.5 w-4 h-4 rounded-full border flex items-center justify-center shrink-0 ${
388
+ isSelected
389
+ ? 'border-[var(--zen-accent-primary)]'
390
+ : 'border-[var(--zen-border-control)]'
391
+ }`}
392
+ >
393
+ {isSelected && (
394
+ <span className="w-2 h-2 rounded-full bg-[var(--zen-accent-primary)]" />
395
+ )}
396
+ </span>
397
+ <div className="min-w-0">
398
+ <div className="text-xs sm:text-sm font-semibold text-[var(--zen-ink-primary)]">
399
+ {opt.label}
400
+ </div>
401
+ {opt.description && (
402
+ <div className="zen-font-mono text-[11px] text-[var(--zen-ink-muted)] mt-0.5">
403
+ {opt.description}
404
+ </div>
405
+ )}
406
+ </div>
407
+ </div>
408
+ {opt.badge && (
409
+ <span className="zen-telemetry-label text-[var(--zen-accent-primary)] shrink-0">
410
+ {opt.badge}
411
+ </span>
412
+ )}
413
+ </button>
414
+ );
415
+ })}
416
+ </div>
417
+ </div>
418
+ );
419
+ }
420
+
421
+ export interface ZenSliderProps {
422
+ readonly label: string;
423
+ readonly value: number;
424
+ readonly onChange: (next: number) => void;
425
+ readonly min?: number;
426
+ readonly max?: number;
427
+ readonly step?: number;
428
+ readonly formatValue?: (val: number) => string;
429
+ readonly disabled?: boolean;
430
+ readonly className?: string;
431
+ }
432
+
433
+ export function ZenSlider({
434
+ label,
435
+ value,
436
+ onChange,
437
+ min = 0,
438
+ max = 100,
439
+ step = 1,
440
+ formatValue,
441
+ disabled = false,
442
+ className = '',
443
+ }: ZenSliderProps) {
444
+ const id = useId();
445
+ const pct = Math.max(
446
+ 0,
447
+ Math.min(100, ((value - min) / Math.max(max - min, 1e-6)) * 100)
448
+ );
449
+
450
+ return (
451
+ <div
452
+ data-zen-occlude="true"
453
+ className={`flex flex-col gap-2 ${className}`.trim()}
454
+ >
455
+ <div className="flex items-center justify-between gap-2">
456
+ <label
457
+ htmlFor={id}
458
+ className="zen-telemetry-label text-[var(--zen-ink-secondary)]"
459
+ >
460
+ {label}
461
+ </label>
462
+ <span className="zen-font-mono text-xs font-semibold text-[var(--zen-accent-primary)]">
463
+ {formatValue ? formatValue(value) : value}
464
+ </span>
465
+ </div>
466
+
467
+ <div className="relative flex items-center">
468
+ <div className="w-full h-2 rounded-full bg-[var(--zen-surface-subtle)] border border-[var(--zen-border-subtle)] overflow-hidden">
469
+ <div
470
+ style={{ width: `${pct}%` }}
471
+ className="h-full bg-[var(--zen-accent-primary)] transition-all duration-75"
472
+ />
473
+ </div>
474
+ <input
475
+ id={id}
476
+ type="range"
477
+ min={min}
478
+ max={max}
479
+ step={step}
480
+ value={value}
481
+ disabled={disabled}
482
+ onChange={(e) => onChange(Number(e.target.value))}
483
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
484
+ />
485
+ </div>
486
+ </div>
487
+ );
488
+ }
489
+
490
+ export interface ZenChipOption<T extends string> {
491
+ readonly value: T;
492
+ readonly label: string;
493
+ }
494
+
495
+ export interface ZenChipFilterProps<T extends string> {
496
+ readonly label?: string;
497
+ readonly options: readonly ZenChipOption<T>[];
498
+ readonly selected: readonly T[];
499
+ readonly onChange: (nextSelected: readonly T[]) => void;
500
+ readonly className?: string;
501
+ }
502
+
503
+ export function ZenChipFilter<T extends string>({
504
+ label,
505
+ options,
506
+ selected,
507
+ onChange,
508
+ className = '',
509
+ }: ZenChipFilterProps<T>) {
510
+ const toggleChip = (val: T) => {
511
+ if (selected.includes(val)) {
512
+ onChange(selected.filter((item) => item !== val));
513
+ } else {
514
+ onChange([...selected, val]);
515
+ }
516
+ };
517
+
518
+ return (
519
+ <div
520
+ data-zen-occlude="true"
521
+ className={`space-y-2 ${className}`.trim()}
522
+ >
523
+ {label && (
524
+ <div className="zen-telemetry-label text-[var(--zen-ink-secondary)]">
525
+ {label}
526
+ </div>
527
+ )}
528
+ <div className="flex flex-wrap gap-2">
529
+ {options.map((opt) => {
530
+ const active = selected.includes(opt.value);
531
+ return (
532
+ <button
533
+ key={opt.value}
534
+ type="button"
535
+ aria-pressed={active}
536
+ onClick={() => toggleChip(opt.value)}
537
+ className={`zen-focus-ring px-3 py-1.5 rounded-full border zen-font-mono text-xs inline-flex items-center gap-1.5 cursor-pointer transition-all ${
538
+ active
539
+ ? 'bg-[var(--zen-accent-primary)] text-[var(--zen-ink-on-accent)] border-[var(--zen-accent-primary)] font-semibold'
540
+ : 'bg-[var(--zen-surface-subtle)] text-[var(--zen-ink-secondary)] border-[var(--zen-border-default)] hover:border-[var(--zen-accent-border)]'
541
+ }`}
542
+ >
543
+ {active && <Check className="w-3 h-3" />}
544
+ <span>{opt.label}</span>
545
+ </button>
546
+ );
547
+ })}
548
+ </div>
549
+ </div>
550
+ );
551
+ }
552
+
553
+ export interface ZenUploadedFile {
554
+ readonly name: string;
555
+ readonly sizeBytes: number;
556
+ }
557
+
558
+ export interface ZenFileUploadProps {
559
+ readonly label: string;
560
+ readonly acceptHint?: string;
561
+ readonly files: readonly ZenUploadedFile[];
562
+ readonly onFilesChange: (nextFiles: readonly ZenUploadedFile[]) => void;
563
+ readonly className?: string;
564
+ }
565
+
566
+ export function ZenFileUpload({
567
+ label,
568
+ acceptHint = 'JSON, YAML, ICS, or PDF up to 25 MB',
569
+ files,
570
+ onFilesChange,
571
+ className = '',
572
+ }: ZenFileUploadProps) {
573
+ const [dragging, setDragging] = useState(false);
574
+ const inputRef = useRef<HTMLInputElement>(null);
575
+
576
+ const handleFileList = (list: FileList | null) => {
577
+ if (!list) return;
578
+ const added: ZenUploadedFile[] = Array.from(list).map((f) => ({
579
+ name: f.name,
580
+ sizeBytes: f.size,
581
+ }));
582
+ onFilesChange([...files, ...added]);
583
+ };
584
+
585
+ return (
586
+ <div
587
+ data-zen-occlude="true"
588
+ className={`space-y-2.5 ${className}`.trim()}
589
+ >
590
+ <span className="zen-telemetry-label text-[var(--zen-ink-secondary)]">
591
+ {label}
592
+ </span>
593
+
594
+ <div
595
+ role="button"
596
+ tabIndex={0}
597
+ onClick={() => inputRef.current?.click()}
598
+ onKeyDown={(e) => {
599
+ if (e.key === 'Enter' || e.key === ' ') {
600
+ e.preventDefault();
601
+ inputRef.current?.click();
602
+ }
603
+ }}
604
+ onDragOver={(e: DragEvent<HTMLDivElement>) => {
605
+ e.preventDefault();
606
+ setDragging(true);
607
+ }}
608
+ onDragLeave={() => setDragging(false)}
609
+ onDrop={(e: DragEvent<HTMLDivElement>) => {
610
+ e.preventDefault();
611
+ setDragging(false);
612
+ handleFileList(e.dataTransfer.files);
613
+ }}
614
+ className={`zen-focus-ring p-5 rounded-2xl border border-dashed text-center cursor-pointer transition-all ${
615
+ dragging
616
+ ? 'border-[var(--zen-accent-primary)] bg-[var(--zen-accent-subtle)]'
617
+ : 'border-[var(--zen-border-strong)] bg-[var(--zen-surface-subtle)] hover:border-[var(--zen-accent-border)]'
618
+ }`}
619
+ >
620
+ <input
621
+ ref={inputRef}
622
+ type="file"
623
+ aria-label={label}
624
+ multiple
625
+ onChange={(e) => handleFileList(e.target.files)}
626
+ className="sr-only"
627
+ />
628
+ <UploadCloud className="w-6 h-6 text-[var(--zen-accent-primary)] mx-auto mb-2" />
629
+ <div className="text-xs sm:text-sm font-semibold text-[var(--zen-ink-primary)]">
630
+ Drop artifacts here or click to browse
631
+ </div>
632
+ <div className="zen-font-mono text-[11px] text-[var(--zen-ink-muted)] mt-0.5">
633
+ {acceptHint}
634
+ </div>
635
+ </div>
636
+
637
+ {files.length > 0 && (
638
+ <ul className="space-y-1.5">
639
+ {files.map((f, idx) => (
640
+ <li
641
+ key={`${f.name}-${idx}`}
642
+ className="flex items-center justify-between gap-2 px-3 py-2 rounded-xl bg-[var(--zen-surface-1)] border border-[var(--zen-border-subtle)] text-xs"
643
+ >
644
+ <span className="inline-flex items-center gap-2 truncate zen-font-mono">
645
+ <FileText className="w-3.5 h-3.5 text-[var(--zen-accent-primary)] shrink-0" />
646
+ <span className="truncate">{f.name}</span>
647
+ </span>
648
+ <button
649
+ type="button"
650
+ aria-label={`Remove ${f.name}`}
651
+ onClick={() =>
652
+ onFilesChange(files.filter((_, i) => i !== idx))
653
+ }
654
+ className="text-[var(--zen-ink-muted)] hover:text-[var(--zen-status-danger)] cursor-pointer"
655
+ >
656
+ <X className="w-3.5 h-3.5" />
657
+ </button>
658
+ </li>
659
+ ))}
660
+ </ul>
661
+ )}
662
+ </div>
663
+ );
664
+ }