@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.
- package/.agents/skills/capri-zen-ui/SKILL.md +59 -0
- package/AGENTS.md +57 -0
- package/CLAUDE.md +7 -0
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/bin/capri.mjs +1187 -0
- package/dist/lib/audio/ZenAudioController.d.ts +10 -0
- package/dist/lib/audio/ZenAudioController.d.ts.map +1 -0
- package/dist/lib/audio/useZenAudioSynth.d.ts +26 -0
- package/dist/lib/audio/useZenAudioSynth.d.ts.map +1 -0
- package/dist/lib/capri/capri-engine.d.ts +127 -0
- package/dist/lib/capri/capri-engine.d.ts.map +1 -0
- package/dist/lib/components/ZenAIConstruct.d.ts +24 -0
- package/dist/lib/components/ZenAIConstruct.d.ts.map +1 -0
- package/dist/lib/components/ZenAmbientBackground.d.ts +8 -0
- package/dist/lib/components/ZenAmbientBackground.d.ts.map +1 -0
- package/dist/lib/components/ZenBadge.d.ts +21 -0
- package/dist/lib/components/ZenBadge.d.ts.map +1 -0
- package/dist/lib/components/ZenButton.d.ts +24 -0
- package/dist/lib/components/ZenButton.d.ts.map +1 -0
- package/dist/lib/components/ZenCard.d.ts +36 -0
- package/dist/lib/components/ZenCard.d.ts.map +1 -0
- package/dist/lib/components/ZenDataDisplay.d.ts +50 -0
- package/dist/lib/components/ZenDataDisplay.d.ts.map +1 -0
- package/dist/lib/components/ZenDataTable.d.ts +20 -0
- package/dist/lib/components/ZenDataTable.d.ts.map +1 -0
- package/dist/lib/components/ZenDossierPanel.d.ts +33 -0
- package/dist/lib/components/ZenDossierPanel.d.ts.map +1 -0
- package/dist/lib/components/ZenDropdown.d.ts +19 -0
- package/dist/lib/components/ZenDropdown.d.ts.map +1 -0
- package/dist/lib/components/ZenFeedback.d.ts +24 -0
- package/dist/lib/components/ZenFeedback.d.ts.map +1 -0
- package/dist/lib/components/ZenForms.d.ts +84 -0
- package/dist/lib/components/ZenForms.d.ts.map +1 -0
- package/dist/lib/components/ZenHeading.d.ts +15 -0
- package/dist/lib/components/ZenHeading.d.ts.map +1 -0
- package/dist/lib/components/ZenIcon.d.ts +24 -0
- package/dist/lib/components/ZenIcon.d.ts.map +1 -0
- package/dist/lib/components/ZenInput.d.ts +12 -0
- package/dist/lib/components/ZenInput.d.ts.map +1 -0
- package/dist/lib/components/ZenModal.d.ts +12 -0
- package/dist/lib/components/ZenModal.d.ts.map +1 -0
- package/dist/lib/components/ZenNavbar.d.ts +11 -0
- package/dist/lib/components/ZenNavbar.d.ts.map +1 -0
- package/dist/lib/components/ZenNavigation.d.ts +52 -0
- package/dist/lib/components/ZenNavigation.d.ts.map +1 -0
- package/dist/lib/components/ZenOverlays.d.ts +48 -0
- package/dist/lib/components/ZenOverlays.d.ts.map +1 -0
- package/dist/lib/components/ZenSegmentedControl.d.ts +18 -0
- package/dist/lib/components/ZenSegmentedControl.d.ts.map +1 -0
- package/dist/lib/components/ZenSwitch.d.ts +11 -0
- package/dist/lib/components/ZenSwitch.d.ts.map +1 -0
- package/dist/lib/components/ZenTabs.d.ts +17 -0
- package/dist/lib/components/ZenTabs.d.ts.map +1 -0
- package/dist/lib/components/ZenTimelineCard.d.ts +14 -0
- package/dist/lib/components/ZenTimelineCard.d.ts.map +1 -0
- package/dist/lib/components/ZenToast.d.ts +18 -0
- package/dist/lib/components/ZenToast.d.ts.map +1 -0
- package/dist/lib/cosmos/ZenCosmosCanvas.d.ts +10 -0
- package/dist/lib/cosmos/ZenCosmosCanvas.d.ts.map +1 -0
- package/dist/lib/cosmos/ZenTelemetryHud.d.ts +45 -0
- package/dist/lib/cosmos/ZenTelemetryHud.d.ts.map +1 -0
- package/dist/lib/index.d.ts +33 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +3922 -0
- package/dist/lib/theme/ZenThemeProvider.d.ts +47 -0
- package/dist/lib/theme/ZenThemeProvider.d.ts.map +1 -0
- package/dist/lib/tokens.css +888 -0
- package/dist/lib/universal/zen-universal.d.ts +77 -0
- package/dist/lib/universal/zen-universal.d.ts.map +1 -0
- package/dist/lib/universal/zen-universal.js +18 -0
- package/dist/lib/zen-universal-C0PD8AI9.js +1352 -0
- package/package.json +78 -4
- package/src/lib/audio/ZenAudioController.tsx +114 -0
- package/src/lib/audio/useZenAudioSynth.ts +289 -0
- package/src/lib/capri/capri-engine.ts +939 -0
- package/src/lib/components/ZenAIConstruct.tsx +594 -0
- package/src/lib/components/ZenAmbientBackground.tsx +210 -0
- package/src/lib/components/ZenBadge.tsx +111 -0
- package/src/lib/components/ZenButton.tsx +127 -0
- package/src/lib/components/ZenCard.tsx +180 -0
- package/src/lib/components/ZenDataDisplay.tsx +349 -0
- package/src/lib/components/ZenDataTable.tsx +143 -0
- package/src/lib/components/ZenDossierPanel.tsx +120 -0
- package/src/lib/components/ZenDropdown.tsx +208 -0
- package/src/lib/components/ZenFeedback.tsx +137 -0
- package/src/lib/components/ZenForms.tsx +664 -0
- package/src/lib/components/ZenHeading.tsx +63 -0
- package/src/lib/components/ZenIcon.tsx +220 -0
- package/src/lib/components/ZenInput.tsx +120 -0
- package/src/lib/components/ZenModal.tsx +103 -0
- package/src/lib/components/ZenNavbar.tsx +106 -0
- package/src/lib/components/ZenNavigation.tsx +300 -0
- package/src/lib/components/ZenOverlays.tsx +386 -0
- package/src/lib/components/ZenSegmentedControl.tsx +113 -0
- package/src/lib/components/ZenSwitch.tsx +63 -0
- package/src/lib/components/ZenTabs.tsx +103 -0
- package/src/lib/components/ZenTimelineCard.tsx +64 -0
- package/src/lib/components/ZenToast.tsx +96 -0
- package/src/lib/cosmos/ZenCosmosCanvas.tsx +931 -0
- package/src/lib/cosmos/ZenTelemetryHud.tsx +385 -0
- package/src/lib/index.ts +293 -0
- package/src/lib/styles/zen-tokens.css +888 -0
- package/src/lib/theme/ZenThemeProvider.tsx +328 -0
- 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
|
+
}
|