@1agh/maude 0.43.0 → 0.44.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/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- package/plugins/design/dependencies.json +18 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
// inspector-controls.jsx — the shared control library for the Inspector edit
|
|
2
|
+
// panels (feature-inspector-controls-redesign).
|
|
3
|
+
//
|
|
4
|
+
// ONE source of truth for the input controls both the CSS panel (`CssKnobs` in
|
|
5
|
+
// app.jsx) and the Photo panel (`photo-knobs.jsx`) render. Before this file,
|
|
6
|
+
// each panel hand-rolled its own number field / slider / toggle 6+ times, so
|
|
7
|
+
// scrub-to-change was wired onto the <input> (fighting click-to-edit), there
|
|
8
|
+
// was no select-all-on-focus, arrow-key stepping was missing, and the "sliders"
|
|
9
|
+
// had no track. These primitives fix the interaction model once:
|
|
10
|
+
//
|
|
11
|
+
// • NumberField — typeable, mono/tabular. The DRAG-TO-SCRUB handle is the
|
|
12
|
+
// leading icon/grip (Figma/Blender/Base UI convention), NOT the input body,
|
|
13
|
+
// so click-to-type and select-all still work. Select-all on focus with a
|
|
14
|
+
// click-again-to-caret escape hatch. ArrowUp/Down (Shift ×10, PageUp/Down
|
|
15
|
+
// large, Home/End → min/max) step the value. Optional ▲▼ steppers + unit slot.
|
|
16
|
+
// • Slider / SliderField — a real WAI-ARIA slider (track · fill · thumb,
|
|
17
|
+
// keyboard-accessible), and the coarse+fine COMBO that links a Slider to a
|
|
18
|
+
// NumberField (edit either → both update; drag previews, release commits).
|
|
19
|
+
// • UnitSelect — the unit suffix on a CSS length field (px/rem/%/em/…),
|
|
20
|
+
// plugged into a NumberField's `unitSlot`.
|
|
21
|
+
// • Segmented — ≤5 mutually-exclusive options, all visible (radio semantics).
|
|
22
|
+
// • Select — styled native <select> (6+ options).
|
|
23
|
+
// • Swatch — a compact color trigger that opens a caller-supplied popover/picker.
|
|
24
|
+
// • Toggle — immediate-effect boolean switch.
|
|
25
|
+
// • Field — the label + control [+ help] inspector row.
|
|
26
|
+
//
|
|
27
|
+
// Runs on the MAIN origin (studio shell), so it's plain React with the shell's
|
|
28
|
+
// `.st-cp-*` classes (styled in client/styles/3-shell-maude.css). Value model is
|
|
29
|
+
// numeric-first; NumberField accepts optional parse/format hooks so CssKnobs can
|
|
30
|
+
// adapt its CSS-unit strings without the library learning about units.
|
|
31
|
+
|
|
32
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
33
|
+
import { Check, ChevronDown, Diamond, Eye, EyeOff, RotateCcw, Search, SquareDashed, X } from 'lucide-react';
|
|
34
|
+
|
|
35
|
+
// lucide wrapper — hairline stroke to match the shell's icon weight.
|
|
36
|
+
const Ic = ({ as: C, size = 14 }) => <C size={size} strokeWidth={1.75} style={{ display: 'block', flex: 'none' }} />;
|
|
37
|
+
|
|
38
|
+
const pretty = (n) => n.replace(/^--/, '').replace(/-/g, ' ');
|
|
39
|
+
|
|
40
|
+
const clampTo = (n, min, max) => {
|
|
41
|
+
let v = n;
|
|
42
|
+
if (min != null && v < min) v = min;
|
|
43
|
+
if (max != null && v > max) v = max;
|
|
44
|
+
return v;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Round to a step's natural precision so 0.1+0.2 doesn't surface 0.30000000004.
|
|
48
|
+
const precisionOf = (step) => {
|
|
49
|
+
if (!(step > 0)) return 2;
|
|
50
|
+
if (step >= 1) return 0;
|
|
51
|
+
const s = String(step);
|
|
52
|
+
const dot = s.indexOf('.');
|
|
53
|
+
return dot === -1 ? 0 : s.length - dot - 1;
|
|
54
|
+
};
|
|
55
|
+
const snap = (n, step) => {
|
|
56
|
+
const p = precisionOf(step);
|
|
57
|
+
return Number(n.toFixed(p));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ── the scrub hook ───────────────────────────────────────────────────────────
|
|
61
|
+
//
|
|
62
|
+
// Generalizes app.jsx `makeScrub` + photo-knobs `makePhotoScrub` into one
|
|
63
|
+
// pointer-drag engine, attached to a HANDLE element (not the input). 3px
|
|
64
|
+
// dead-zone so a click never reads as a 1px scrub; Shift = coarse ×10, Alt =
|
|
65
|
+
// fine ×0.1. `delta = dx * step * modifier` — with step=1 (CSS lengths) that's
|
|
66
|
+
// 1px→1unit, with step=0.01 (photo adjustments) it's the gentle sweep the photo
|
|
67
|
+
// panel already used. Non-locked (drag stops at the screen edge) — matches
|
|
68
|
+
// today's behaviour; deliberately no Pointer Lock (WKWebView/Safari layout-shift
|
|
69
|
+
// risk), can be layered on later.
|
|
70
|
+
// `sides` (optional) generalizes app.jsx's box-model scrub grammar: dragging a
|
|
71
|
+
// margin/padding/inset cell with Alt held moves its axis PAIR too, Alt+Shift
|
|
72
|
+
// moves all four — Webflow's box-model convention. When `sides` is given, the
|
|
73
|
+
// per-axis Shift(×10)/Alt(fine) modifiers are unavailable (Alt/Shift are spoken
|
|
74
|
+
// for), so granularity is fixed at 1 — matching the prior makeScrub behavior.
|
|
75
|
+
// `onInput`/`onCommit` receive `(value, activeSides)`; `activeSides` is
|
|
76
|
+
// `undefined` when `sides` isn't configured, or `sides.pair`/`sides.all`/absent
|
|
77
|
+
// (self only) per the held modifiers.
|
|
78
|
+
// Plain (non-hook) builder — the actual drag-math engine. Exported directly for
|
|
79
|
+
// callers that invoke it from a per-row helper closure rather than a real React
|
|
80
|
+
// component (e.g. CssKnobs' `side()`/`inset()` box-model cells, which are
|
|
81
|
+
// plain functions called during render, not components — wrapping this in a
|
|
82
|
+
// hook there would call `useCallback` conditionally on render order, breaking
|
|
83
|
+
// the rules of hooks). `useScrub` below is the convenience hook form for actual
|
|
84
|
+
// components (NumberField).
|
|
85
|
+
export function makeScrubHandler({ getBase, min, max, step = 1, onInput, onCommit, sensitivity = 1, sides }) {
|
|
86
|
+
return (e) => {
|
|
87
|
+
if (e.button !== 0) return;
|
|
88
|
+
const startX = e.clientX;
|
|
89
|
+
const base = Number.parseFloat(getBase()) || 0;
|
|
90
|
+
let scrubbing = false;
|
|
91
|
+
let last = base;
|
|
92
|
+
const activeSidesFor = (ev) => {
|
|
93
|
+
if (!sides) return undefined;
|
|
94
|
+
if (ev.altKey && ev.shiftKey) return sides.all;
|
|
95
|
+
if (ev.altKey) return sides.pair;
|
|
96
|
+
return undefined;
|
|
97
|
+
};
|
|
98
|
+
const move = (ev) => {
|
|
99
|
+
const dx = ev.clientX - startX;
|
|
100
|
+
if (!scrubbing && Math.abs(dx) < 3) return;
|
|
101
|
+
if (!scrubbing) {
|
|
102
|
+
scrubbing = true;
|
|
103
|
+
document.body.classList.add('st-scrubbing');
|
|
104
|
+
}
|
|
105
|
+
ev.preventDefault();
|
|
106
|
+
const modifier = sides ? 1 : ev.shiftKey ? 10 : ev.altKey ? 0.1 : 1;
|
|
107
|
+
last = snap(clampTo(base + dx * step * sensitivity * modifier, min, max), step);
|
|
108
|
+
onInput?.(last, activeSidesFor(ev));
|
|
109
|
+
};
|
|
110
|
+
const up = (ev) => {
|
|
111
|
+
document.removeEventListener('pointermove', move);
|
|
112
|
+
document.removeEventListener('pointerup', up);
|
|
113
|
+
if (!scrubbing) return;
|
|
114
|
+
document.body.classList.remove('st-scrubbing');
|
|
115
|
+
onCommit?.(last, activeSidesFor(ev));
|
|
116
|
+
};
|
|
117
|
+
document.addEventListener('pointermove', move);
|
|
118
|
+
document.addEventListener('pointerup', up);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function useScrub({ getBase, min, max, step = 1, onInput, onCommit, sensitivity = 1, sides }) {
|
|
123
|
+
return useCallback(
|
|
124
|
+
makeScrubHandler({ getBase, min, max, step, onInput, onCommit, sensitivity, sides }),
|
|
125
|
+
[getBase, min, max, step, onInput, onCommit, sensitivity, sides]
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── NumberField ──────────────────────────────────────────────────────────────
|
|
130
|
+
//
|
|
131
|
+
// Uncontrolled input (like photo-knobs' old Slider) so imperative mid-scrub
|
|
132
|
+
// value writes don't force a remount; an effect re-syncs from an external
|
|
133
|
+
// `value` change (reset / undo) whenever the field isn't focused.
|
|
134
|
+
export function NumberField({
|
|
135
|
+
value,
|
|
136
|
+
min,
|
|
137
|
+
max,
|
|
138
|
+
step = 1,
|
|
139
|
+
lead, // node rendered in the drag handle (icon/letter); a grip is shown if omitted
|
|
140
|
+
scrub = true,
|
|
141
|
+
steppers = true,
|
|
142
|
+
unitSlot, // e.g. a <UnitSelect/> or static suffix, rendered after the steppers
|
|
143
|
+
sensitivity = 1,
|
|
144
|
+
sides, // { pair, all } — box-model multi-side scrub grammar, forwarded to useScrub
|
|
145
|
+
format, // (n) => string display formatter (default: step-precision)
|
|
146
|
+
parse, // (raw: string) => number|null typed-value parser (default: parseFloat)
|
|
147
|
+
onInput, // (n, activeSides?) => void live preview during scrub/step (optional)
|
|
148
|
+
onCommit, // (n, activeSides?) => void final commit (blur / scrub-end / step / arrow)
|
|
149
|
+
ariaLabel,
|
|
150
|
+
className = '',
|
|
151
|
+
}) {
|
|
152
|
+
const ref = useRef(null);
|
|
153
|
+
const fmt = useCallback((n) => (format ? format(n) : String(snap(Number(n) || 0, step))), [format, step]);
|
|
154
|
+
const numOf = (v) => (typeof v === 'number' ? v : Number.parseFloat(v)) || 0;
|
|
155
|
+
|
|
156
|
+
// Re-sync the uncontrolled input from an external value change, but never
|
|
157
|
+
// while focused (don't fight typing / mid-scrub imperative writes).
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (ref.current && document.activeElement !== ref.current) ref.current.value = fmt(value);
|
|
160
|
+
}, [value, fmt]);
|
|
161
|
+
|
|
162
|
+
const commit = (n, activeSides) => {
|
|
163
|
+
const v = snap(clampTo(n, min, max), step);
|
|
164
|
+
if (ref.current) ref.current.value = fmt(v);
|
|
165
|
+
onCommit?.(v, activeSides);
|
|
166
|
+
};
|
|
167
|
+
const preview = (n, activeSides) => {
|
|
168
|
+
const v = snap(clampTo(n, min, max), step);
|
|
169
|
+
if (ref.current) ref.current.value = fmt(v);
|
|
170
|
+
onInput?.(v, activeSides);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const onScrub = useScrub({
|
|
174
|
+
getBase: () => (ref.current ? ref.current.value : numOf(value)),
|
|
175
|
+
min,
|
|
176
|
+
max,
|
|
177
|
+
step,
|
|
178
|
+
sensitivity,
|
|
179
|
+
sides,
|
|
180
|
+
onInput: preview,
|
|
181
|
+
onCommit: commit,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const bump = (dir, mult) => commit(numOf(ref.current?.value ?? value) + dir * step * mult);
|
|
185
|
+
|
|
186
|
+
const onKeyDown = (e) => {
|
|
187
|
+
const mult = e.shiftKey ? 10 : 1;
|
|
188
|
+
if (e.key === 'ArrowUp') {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
bump(1, mult);
|
|
191
|
+
} else if (e.key === 'ArrowDown') {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
bump(-1, mult);
|
|
194
|
+
} else if (e.key === 'PageUp') {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
bump(1, 10);
|
|
197
|
+
} else if (e.key === 'PageDown') {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
bump(-1, 10);
|
|
200
|
+
} else if (e.key === 'Home' && min != null) {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
commit(min);
|
|
203
|
+
} else if (e.key === 'End' && max != null) {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
commit(max);
|
|
206
|
+
} else if (e.key === 'Enter') {
|
|
207
|
+
e.currentTarget.blur();
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const onFocus = (e) => {
|
|
212
|
+
// Select-all on entry so the dominant task (replace the value) is one keystroke.
|
|
213
|
+
// Deferred past the browser's own click-caret placement; a SECOND click (already
|
|
214
|
+
// focused → no focus event) places the caret instead. That's the pro-tool escape hatch.
|
|
215
|
+
const el = e.currentTarget;
|
|
216
|
+
requestAnimationFrame(() => {
|
|
217
|
+
if (document.activeElement === el) el.select();
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const onBlur = (e) => {
|
|
222
|
+
const raw = String(e.currentTarget.value).trim();
|
|
223
|
+
if (raw === '') return;
|
|
224
|
+
const n = parse ? parse(raw) : Number.parseFloat(raw);
|
|
225
|
+
if (n == null || Number.isNaN(n)) {
|
|
226
|
+
// Revert to the last good value.
|
|
227
|
+
e.currentTarget.value = fmt(value);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
commit(n);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div className={`st-cp-num ${className}`.trim()}>
|
|
235
|
+
{scrub ? (
|
|
236
|
+
<span
|
|
237
|
+
className="st-cp-numlead st-cp-handle"
|
|
238
|
+
role="presentation"
|
|
239
|
+
aria-hidden="true"
|
|
240
|
+
title="Drag to change · Shift ×10 · Alt fine"
|
|
241
|
+
onPointerDown={onScrub}
|
|
242
|
+
>
|
|
243
|
+
{lead ?? <span className="st-cp-grip" />}
|
|
244
|
+
</span>
|
|
245
|
+
) : lead ? (
|
|
246
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
247
|
+
{lead}
|
|
248
|
+
</span>
|
|
249
|
+
) : null}
|
|
250
|
+
<input
|
|
251
|
+
ref={ref}
|
|
252
|
+
className="st-cp-numin"
|
|
253
|
+
role="spinbutton"
|
|
254
|
+
aria-label={ariaLabel}
|
|
255
|
+
aria-valuenow={numOf(value)}
|
|
256
|
+
aria-valuemin={min}
|
|
257
|
+
aria-valuemax={max}
|
|
258
|
+
defaultValue={fmt(value)}
|
|
259
|
+
inputMode="decimal"
|
|
260
|
+
onFocus={onFocus}
|
|
261
|
+
onKeyDown={onKeyDown}
|
|
262
|
+
onBlur={onBlur}
|
|
263
|
+
/>
|
|
264
|
+
{steppers ? (
|
|
265
|
+
<span className="st-cp-step">
|
|
266
|
+
<button type="button" className="st-cp-stepb" tabIndex={-1} aria-label={`increase ${ariaLabel || 'value'}`} onClick={() => bump(1, 1)}>
|
|
267
|
+
▲
|
|
268
|
+
</button>
|
|
269
|
+
<button type="button" className="st-cp-stepb" tabIndex={-1} aria-label={`decrease ${ariaLabel || 'value'}`} onClick={() => bump(-1, 1)}>
|
|
270
|
+
▼
|
|
271
|
+
</button>
|
|
272
|
+
</span>
|
|
273
|
+
) : null}
|
|
274
|
+
{unitSlot ?? null}
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Slider (real track/thumb) ────────────────────────────────────────────────
|
|
280
|
+
//
|
|
281
|
+
// WAI-ARIA slider. Reuses the .st-cp-slider/track/fill/thumb CSS. Bipolar ranges
|
|
282
|
+
// (min<0<max) fill from the zero-point, not from min. Keyboard: Arrow ±step
|
|
283
|
+
// (Shift ×10), PageUp/Down large, Home/End → min/max. Pointer drag previews via
|
|
284
|
+
// onInput and commits on release.
|
|
285
|
+
export function Slider({ value, min = 0, max = 1, step = 0.01, onInput, onCommit, ariaLabel }) {
|
|
286
|
+
const trackRef = useRef(null);
|
|
287
|
+
const v = clampTo(value ?? (min < 0 ? 0 : min), min, max);
|
|
288
|
+
const span = max - min || 1;
|
|
289
|
+
const frac = (v - min) / span;
|
|
290
|
+
const zeroFrac = min < 0 && max > 0 ? (0 - min) / span : 0;
|
|
291
|
+
const fillLeft = Math.min(frac, zeroFrac);
|
|
292
|
+
const fillWidth = Math.abs(frac - zeroFrac);
|
|
293
|
+
|
|
294
|
+
const valueAtX = (clientX) => {
|
|
295
|
+
const r = trackRef.current?.getBoundingClientRect();
|
|
296
|
+
if (!r || r.width === 0) return v;
|
|
297
|
+
const f = clampTo((clientX - r.left) / r.width, 0, 1);
|
|
298
|
+
return snap(min + f * span, step);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const onPointerDown = (e) => {
|
|
302
|
+
if (e.button !== 0) return;
|
|
303
|
+
e.preventDefault();
|
|
304
|
+
trackRef.current?.focus();
|
|
305
|
+
let last = valueAtX(e.clientX);
|
|
306
|
+
onInput?.(last);
|
|
307
|
+
const move = (ev) => {
|
|
308
|
+
last = valueAtX(ev.clientX);
|
|
309
|
+
onInput?.(last);
|
|
310
|
+
};
|
|
311
|
+
const up = () => {
|
|
312
|
+
document.removeEventListener('pointermove', move);
|
|
313
|
+
document.removeEventListener('pointerup', up);
|
|
314
|
+
onCommit?.(last);
|
|
315
|
+
};
|
|
316
|
+
document.addEventListener('pointermove', move);
|
|
317
|
+
document.addEventListener('pointerup', up);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const onKeyDown = (e) => {
|
|
321
|
+
const big = e.shiftKey ? 10 : 1;
|
|
322
|
+
let next = null;
|
|
323
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = v + step * big;
|
|
324
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = v - step * big;
|
|
325
|
+
else if (e.key === 'PageUp') next = v + step * 10;
|
|
326
|
+
else if (e.key === 'PageDown') next = v - step * 10;
|
|
327
|
+
else if (e.key === 'Home') next = min;
|
|
328
|
+
else if (e.key === 'End') next = max;
|
|
329
|
+
if (next != null) {
|
|
330
|
+
e.preventDefault();
|
|
331
|
+
onCommit?.(snap(clampTo(next, min, max), step));
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<div className="st-cp-slider">
|
|
337
|
+
<div
|
|
338
|
+
ref={trackRef}
|
|
339
|
+
className="st-cp-track"
|
|
340
|
+
role="slider"
|
|
341
|
+
tabIndex={0}
|
|
342
|
+
aria-label={ariaLabel}
|
|
343
|
+
aria-valuemin={min}
|
|
344
|
+
aria-valuemax={max}
|
|
345
|
+
aria-valuenow={v}
|
|
346
|
+
onPointerDown={onPointerDown}
|
|
347
|
+
onKeyDown={onKeyDown}
|
|
348
|
+
>
|
|
349
|
+
{min < 0 && max > 0 ? <span className="st-cp-zero" style={{ left: `${zeroFrac * 100}%` }} /> : null}
|
|
350
|
+
<span className="st-cp-fill" style={{ left: `${fillLeft * 100}%`, width: `${fillWidth * 100}%` }} />
|
|
351
|
+
<span className="st-cp-thumb" style={{ left: `${frac * 100}%` }} />
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── SliderField (Slider linked to a compact NumberField) ─────────────────────
|
|
358
|
+
//
|
|
359
|
+
// The coarse+fine combo NN/g prescribes for bounded params: the slider for
|
|
360
|
+
// exploration with live feedback, the numeric for the exact value — continuously
|
|
361
|
+
// linked. `onInput` fires during drag (preview, no undo); `onCommit` on release /
|
|
362
|
+
// typed entry (persist + undo).
|
|
363
|
+
export function SliderField({ value, min = 0, max = 1, step = 0.01, unit = '', onInput, onCommit, ariaLabel }) {
|
|
364
|
+
const v = value ?? (min < 0 ? 0 : min);
|
|
365
|
+
return (
|
|
366
|
+
<div className="st-cp-sfield">
|
|
367
|
+
<Slider value={v} min={min} max={max} step={step} onInput={onInput} onCommit={onCommit} ariaLabel={ariaLabel} />
|
|
368
|
+
<NumberField
|
|
369
|
+
className="st-cp-num--compact"
|
|
370
|
+
value={v}
|
|
371
|
+
min={min}
|
|
372
|
+
max={max}
|
|
373
|
+
step={step}
|
|
374
|
+
scrub={false}
|
|
375
|
+
steppers={false}
|
|
376
|
+
unitSlot={unit ? <span className="st-cp-numsuffix" aria-hidden="true">{unit}</span> : null}
|
|
377
|
+
onInput={onInput}
|
|
378
|
+
onCommit={onCommit}
|
|
379
|
+
ariaLabel={ariaLabel}
|
|
380
|
+
/>
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── UnitSelect (the unit suffix on a CSS length NumberField) ─────────────────
|
|
386
|
+
//
|
|
387
|
+
// A thin `.st-cp-unitsel` wrapper generalizing the inline unit `<select>` that
|
|
388
|
+
// used to live inside `CssKnobs`' `num()` factory — passed as a `NumberField`
|
|
389
|
+
// `unitSlot`. `onChange` receives the raw unit string (e.g. 'rem'); the caller
|
|
390
|
+
// owns recombining it with the numeric part (unit conversion is CSS-specific,
|
|
391
|
+
// not something this library should know about).
|
|
392
|
+
export function UnitSelect({ value, units, onChange, ariaLabel }) {
|
|
393
|
+
return (
|
|
394
|
+
<select className="st-cp-unitsel" aria-label={ariaLabel} value={value} onChange={(e) => onChange(e.target.value)}>
|
|
395
|
+
{units.map((u) => (
|
|
396
|
+
<option key={u} value={u}>
|
|
397
|
+
{u}
|
|
398
|
+
</option>
|
|
399
|
+
))}
|
|
400
|
+
</select>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Segmented (≤5 options, all visible) ──────────────────────────────────────
|
|
405
|
+
export function Segmented({ value, options, onChange, ariaLabel }) {
|
|
406
|
+
return (
|
|
407
|
+
<div className="st-cp-seg" role="radiogroup" aria-label={ariaLabel}>
|
|
408
|
+
{options.map((o) => {
|
|
409
|
+
const val = typeof o === 'string' ? o : o.value;
|
|
410
|
+
const label = typeof o === 'string' ? o : o.label ?? o.value;
|
|
411
|
+
return (
|
|
412
|
+
<button
|
|
413
|
+
key={val}
|
|
414
|
+
type="button"
|
|
415
|
+
role="radio"
|
|
416
|
+
aria-checked={value === val}
|
|
417
|
+
className={`st-cp-segbtn${value === val ? ' is-active' : ''}`}
|
|
418
|
+
onClick={() => onChange(val)}
|
|
419
|
+
>
|
|
420
|
+
{label}
|
|
421
|
+
</button>
|
|
422
|
+
);
|
|
423
|
+
})}
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── Select (styled native <select>, 6+ options) ──────────────────────────────
|
|
429
|
+
export function Select({ value, options, onChange, ariaLabel, mini = false }) {
|
|
430
|
+
return (
|
|
431
|
+
<select
|
|
432
|
+
className={`st-cp-nsel${mini ? ' st-cp-nsel--mini' : ''}`}
|
|
433
|
+
aria-label={ariaLabel}
|
|
434
|
+
value={value}
|
|
435
|
+
onChange={(e) => onChange(e.target.value)}
|
|
436
|
+
>
|
|
437
|
+
{options.map((o) => {
|
|
438
|
+
const val = typeof o === 'string' ? o : o.value;
|
|
439
|
+
const label = typeof o === 'string' ? o : o.label ?? o.value;
|
|
440
|
+
return (
|
|
441
|
+
<option key={val} value={val}>
|
|
442
|
+
{label}
|
|
443
|
+
</option>
|
|
444
|
+
);
|
|
445
|
+
})}
|
|
446
|
+
</select>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Toggle (immediate-effect boolean) ────────────────────────────────────────
|
|
451
|
+
export function Toggle({ checked, onChange, label, ariaLabel }) {
|
|
452
|
+
return (
|
|
453
|
+
<label className="st-cp-toggle">
|
|
454
|
+
<input type="checkbox" className="st-cp-switch" checked={!!checked} aria-label={ariaLabel || label} onChange={(e) => onChange(e.target.checked)} />
|
|
455
|
+
{label ? <span className="st-cp-toggle-lbl">{label}</span> : null}
|
|
456
|
+
</label>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Swatch (compact color trigger → caller-supplied picker) ───────────────────
|
|
461
|
+
//
|
|
462
|
+
// `render` is a render-prop the caller uses to plug in its own popover/picker
|
|
463
|
+
// (CssKnobs → TokenPopover, photo-knobs → the injected HSV ColorPicker). If no
|
|
464
|
+
// render is given it falls back to a native <input type="color">.
|
|
465
|
+
export function Swatch({ value, fallback = '#000000', label, onApply, render }) {
|
|
466
|
+
const hex = value || fallback;
|
|
467
|
+
if (render) return render({ hex, label, onApply });
|
|
468
|
+
return (
|
|
469
|
+
<label className="st-cp-swatch st-cp-swatch--mini st-cp-swatch--trigger" style={{ background: hex }} title={label}>
|
|
470
|
+
<input
|
|
471
|
+
type="color"
|
|
472
|
+
value={hex}
|
|
473
|
+
aria-label={label || 'color'}
|
|
474
|
+
onChange={(e) => onApply(e.target.value)}
|
|
475
|
+
style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer' }}
|
|
476
|
+
/>
|
|
477
|
+
</label>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── Field (the inspector row: label + control [+ help]) ──────────────────────
|
|
482
|
+
export function Field({ label, help, children, wide = false }) {
|
|
483
|
+
return (
|
|
484
|
+
<div className={`st-cp-row${wide ? ' st-cp-row--wide' : ''}`}>
|
|
485
|
+
{label ? <span className="st-cp-label">{label}</span> : null}
|
|
486
|
+
<span className="st-cp-ctl">{children}</span>
|
|
487
|
+
{help ? <span className="st-cp-help">{help}</span> : null}
|
|
488
|
+
</div>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── IconButtonGroup / IconToggleGroup (≤5 options with a glyph each) ──────────
|
|
493
|
+
//
|
|
494
|
+
// Single-choice radio group (flex-direction, justify, align, text-align,
|
|
495
|
+
// overflow) or a multi-select toggle group (bold/italic/underline). `options`:
|
|
496
|
+
// `[{ value, node, label }]` where `node` is a lucide icon element.
|
|
497
|
+
export function IconButtonGroup({ value, options, ariaLabel, onChange }) {
|
|
498
|
+
return (
|
|
499
|
+
<div className="st-cp-iconseg" role="radiogroup" aria-label={ariaLabel}>
|
|
500
|
+
{options.map((o) => (
|
|
501
|
+
<button
|
|
502
|
+
key={o.value}
|
|
503
|
+
type="button"
|
|
504
|
+
role="radio"
|
|
505
|
+
aria-checked={value === o.value}
|
|
506
|
+
aria-label={o.label}
|
|
507
|
+
title={o.label}
|
|
508
|
+
className={value === o.value ? 'is-active' : ''}
|
|
509
|
+
onClick={() => onChange(o.value)}
|
|
510
|
+
>
|
|
511
|
+
{o.node}
|
|
512
|
+
</button>
|
|
513
|
+
))}
|
|
514
|
+
</div>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
export function IconToggleGroup({ value, options, ariaLabel, onToggle }) {
|
|
518
|
+
return (
|
|
519
|
+
<div className="st-cp-iconseg" role="group" aria-label={ariaLabel}>
|
|
520
|
+
{options.map((o) => (
|
|
521
|
+
<button key={o.value} type="button" aria-pressed={!!value[o.value]} aria-label={o.label} title={o.label} onClick={() => onToggle(o.value)}>
|
|
522
|
+
{o.node}
|
|
523
|
+
</button>
|
|
524
|
+
))}
|
|
525
|
+
</div>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Checkbox (custom box + check, in the DS accent language) ──────────────────
|
|
530
|
+
export function Checkbox({ checked, label, ariaLabel, onChange }) {
|
|
531
|
+
return (
|
|
532
|
+
<label className="st-cp-check">
|
|
533
|
+
<input type="checkbox" checked={!!checked} aria-label={ariaLabel} onChange={(e) => onChange(e.target.checked)} />
|
|
534
|
+
<span className="st-cp-check-box"><Ic as={Check} size={11} /></span>
|
|
535
|
+
{label ? <span className="st-cp-check-lbl">{label}</span> : null}
|
|
536
|
+
</label>
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── AlignPad (9-point auto-layout alignment) ─────────────────────────────────
|
|
541
|
+
const ALIGN_CELLS = ['tl', 'tc', 'tr', 'cl', 'cc', 'cr', 'bl', 'bc', 'br'];
|
|
542
|
+
export function AlignPad({ value, onChange, ariaLabel = 'alignment' }) {
|
|
543
|
+
return (
|
|
544
|
+
<div className="st-cp-alignpad" role="radiogroup" aria-label={ariaLabel}>
|
|
545
|
+
{ALIGN_CELLS.map((c) => (
|
|
546
|
+
<button key={c} type="button" role="radio" aria-checked={value === c} aria-label={`align ${c}`} className={value === c ? 'is-active' : ''} onClick={() => onChange(c)} />
|
|
547
|
+
))}
|
|
548
|
+
</div>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── AngleDial (drag around the circle → 0–360°) ──────────────────────────────
|
|
553
|
+
export function AngleDial({ value, onChange, ariaLabel = 'rotation' }) {
|
|
554
|
+
const ref = useRef(null);
|
|
555
|
+
const angleAt = (cx, cy) => {
|
|
556
|
+
const r = ref.current?.getBoundingClientRect();
|
|
557
|
+
if (!r) return value;
|
|
558
|
+
let deg = Math.round((Math.atan2(cy - (r.top + r.height / 2), cx - (r.left + r.width / 2)) * 180) / Math.PI);
|
|
559
|
+
if (deg < 0) deg += 360;
|
|
560
|
+
return deg;
|
|
561
|
+
};
|
|
562
|
+
const onPointerDown = (e) => {
|
|
563
|
+
if (e.button !== 0) return;
|
|
564
|
+
e.preventDefault();
|
|
565
|
+
document.body.classList.add('st-scrubbing', 'st-scrubbing--dial');
|
|
566
|
+
onChange(angleAt(e.clientX, e.clientY));
|
|
567
|
+
const move = (ev) => onChange(angleAt(ev.clientX, ev.clientY));
|
|
568
|
+
const up = () => {
|
|
569
|
+
document.removeEventListener('pointermove', move);
|
|
570
|
+
document.removeEventListener('pointerup', up);
|
|
571
|
+
document.body.classList.remove('st-scrubbing', 'st-scrubbing--dial');
|
|
572
|
+
};
|
|
573
|
+
document.addEventListener('pointermove', move);
|
|
574
|
+
document.addEventListener('pointerup', up);
|
|
575
|
+
};
|
|
576
|
+
// Keyboard is a first-class stepper for the dial (WAI-ARIA slider): Arrow ±1°
|
|
577
|
+
// (Shift ×10), Page ±15°, Home/End → 0/360. Mirrors `Slider`'s onKeyDown so
|
|
578
|
+
// the rotation is reachable without a pointer (WCAG 2.1.1 / 4.1.2).
|
|
579
|
+
const cur = value ?? 0;
|
|
580
|
+
const onKeyDown = (e) => {
|
|
581
|
+
const big = e.shiftKey ? 10 : 1;
|
|
582
|
+
let next = null;
|
|
583
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = cur + big;
|
|
584
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = cur - big;
|
|
585
|
+
else if (e.key === 'PageUp') next = cur + 15;
|
|
586
|
+
else if (e.key === 'PageDown') next = cur - 15;
|
|
587
|
+
else if (e.key === 'Home') next = 0;
|
|
588
|
+
else if (e.key === 'End') next = 360;
|
|
589
|
+
if (next != null) {
|
|
590
|
+
e.preventDefault();
|
|
591
|
+
onChange(clampTo(next, 0, 360));
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
return (
|
|
595
|
+
<button
|
|
596
|
+
ref={ref}
|
|
597
|
+
type="button"
|
|
598
|
+
className="st-cp-dial"
|
|
599
|
+
role="slider"
|
|
600
|
+
aria-label={ariaLabel}
|
|
601
|
+
aria-valuemin={0}
|
|
602
|
+
aria-valuemax={360}
|
|
603
|
+
aria-valuenow={cur}
|
|
604
|
+
aria-valuetext={`${cur}°`}
|
|
605
|
+
title="drag to rotate · arrows to nudge"
|
|
606
|
+
onPointerDown={onPointerDown}
|
|
607
|
+
onKeyDown={onKeyDown}
|
|
608
|
+
>
|
|
609
|
+
<span className="st-cp-dial-needle" style={{ '--angle': `${value}deg` }} />
|
|
610
|
+
</button>
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ── PanelSection (collapsable section — caret + reset + height animation) ─────
|
|
615
|
+
//
|
|
616
|
+
// Mirrors CssKnobs `sec()` (caret toggle + section reset) but with a smooth
|
|
617
|
+
// grid-rows collapse animation. `onReset` (optional) renders the ⟲ affordance.
|
|
618
|
+
export function PanelSection({ title, defaultOpen = true, onReset, right, children }) {
|
|
619
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
620
|
+
return (
|
|
621
|
+
<section className="st-cp-sec">
|
|
622
|
+
<div className="st-cp-sechd-row">
|
|
623
|
+
<button type="button" className="st-cp-sechd" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
|
|
624
|
+
<span className="st-cp-caret" aria-hidden="true"><Ic as={ChevronDown} size={12} /></span>
|
|
625
|
+
{title}
|
|
626
|
+
</button>
|
|
627
|
+
{right ?? null}
|
|
628
|
+
{onReset ? (
|
|
629
|
+
<button type="button" className="st-cp-secreset" aria-label={`reset ${title} section`} title={`reset ${title}`} onClick={onReset}>
|
|
630
|
+
<Ic as={RotateCcw} size={12} />
|
|
631
|
+
</button>
|
|
632
|
+
) : null}
|
|
633
|
+
</div>
|
|
634
|
+
<div className={`st-cp-sec-anim${open ? ' is-open' : ''}`}>
|
|
635
|
+
<div className="st-cp-sec-inner">{children}</div>
|
|
636
|
+
</div>
|
|
637
|
+
</section>
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ── ColorField (compact flush shell — swatch prefix + value [+ alpha + eye]) ──
|
|
642
|
+
//
|
|
643
|
+
// The ONE colour field: the caller passes the swatch TRIGGER (a `TokenPopover`
|
|
644
|
+
// swatch button in CssKnobs, or an injected `ColorPicker`-opening button in
|
|
645
|
+
// photo-knobs) as `swatch`; it renders flush as the left prefix (divider, no
|
|
646
|
+
// gap). `displayValue` is the hex / token-name shown in the value input;
|
|
647
|
+
// `alpha`/`onAlpha` + `visible`/`onVisible` opt into the richer fill controls.
|
|
648
|
+
export function ColorField({ swatch, displayValue, bound, alpha, visible, ariaLabel, onValue, onAlpha, onVisible }) {
|
|
649
|
+
return (
|
|
650
|
+
<span className="st-cp-cf">
|
|
651
|
+
{swatch}
|
|
652
|
+
<input
|
|
653
|
+
className={`st-cp-cf-in${bound ? ' is-bound' : ''}`}
|
|
654
|
+
aria-label={ariaLabel}
|
|
655
|
+
value={displayValue}
|
|
656
|
+
onChange={(e) => onValue?.(e.target.value)}
|
|
657
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
658
|
+
readOnly={!onValue}
|
|
659
|
+
/>
|
|
660
|
+
{onAlpha ? (
|
|
661
|
+
<input className="st-cp-cf-alpha" aria-label={`${ariaLabel || 'colour'} alpha`} value={`${alpha}`} onChange={(e) => onAlpha(clampTo(Number.parseFloat(e.target.value) || 0, 0, 100))} onFocus={(e) => e.currentTarget.select()} />
|
|
662
|
+
) : null}
|
|
663
|
+
{onVisible ? (
|
|
664
|
+
<button type="button" className={`st-cp-cf-eye${visible ? '' : ' is-off'}`} aria-pressed={visible} aria-label={`${ariaLabel || 'colour'} visibility`} onClick={() => onVisible(!visible)}>
|
|
665
|
+
{visible ? <Ic as={Eye} /> : <Ic as={EyeOff} />}
|
|
666
|
+
</button>
|
|
667
|
+
) : null}
|
|
668
|
+
</span>
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ── ValueTokenField (◇ — bind a NumberField to a space/radius variable) ──────
|
|
673
|
+
//
|
|
674
|
+
// Value is `number | "var(--x)"`. Bound → the field turns into an accent chip
|
|
675
|
+
// with the token name + a ✕ detach; the ◇ opens a searchable token list. Mirrors
|
|
676
|
+
// CssKnobs `tok()` but with the bound-chip display the design specifies.
|
|
677
|
+
export function ValueTokenField({ value, tokens, ariaLabel, lead, unit, unitSlot, min = 0, max, step = 1, onChange }) {
|
|
678
|
+
const [open, setOpen] = useState(false);
|
|
679
|
+
const [q, setQ] = useState('');
|
|
680
|
+
const wrapRef = useRef(null);
|
|
681
|
+
const bound = typeof value === 'string' && value.startsWith('var(');
|
|
682
|
+
const tokenName = bound ? value.replace(/^var\(\s*|\s*\)$/g, '') : '';
|
|
683
|
+
const resolved = bound ? (tokens.find((t) => t.name === tokenName)?.value ?? '') : '';
|
|
684
|
+
useEffect(() => {
|
|
685
|
+
if (!open) {
|
|
686
|
+
setQ('');
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
689
|
+
const onDoc = (e) => {
|
|
690
|
+
if (!wrapRef.current?.contains(e.target)) setOpen(false);
|
|
691
|
+
};
|
|
692
|
+
document.addEventListener('pointerdown', onDoc, true);
|
|
693
|
+
return () => document.removeEventListener('pointerdown', onDoc, true);
|
|
694
|
+
}, [open]);
|
|
695
|
+
const list = q ? tokens.filter((t) => pretty(t.name).includes(q) || (t.value || '').toLowerCase().includes(q)) : tokens;
|
|
696
|
+
return (
|
|
697
|
+
<div className="st-cp-tokfield">
|
|
698
|
+
{bound ? (
|
|
699
|
+
<div className="st-cp-boundchip" title={`${tokenName} = ${resolved}`}>
|
|
700
|
+
{lead ? <span className="st-cp-boundchip-lead" aria-hidden="true">{lead}</span> : null}
|
|
701
|
+
<span className="st-cp-boundchip-name">{pretty(tokenName)}</span>
|
|
702
|
+
<button type="button" className="st-cp-boundchip-x" aria-label="detach token" title="detach — back to a raw value" onClick={() => onChange(Number.parseFloat(resolved) || 0)}>
|
|
703
|
+
<Ic as={X} size={12} />
|
|
704
|
+
</button>
|
|
705
|
+
</div>
|
|
706
|
+
) : (
|
|
707
|
+
<NumberField
|
|
708
|
+
value={typeof value === 'number' ? value : 0}
|
|
709
|
+
min={min}
|
|
710
|
+
max={max}
|
|
711
|
+
step={step}
|
|
712
|
+
ariaLabel={ariaLabel}
|
|
713
|
+
lead={lead}
|
|
714
|
+
steppers={false}
|
|
715
|
+
unitSlot={unitSlot ?? (unit ? <span className="st-cp-numsuffix" aria-hidden="true">{unit}</span> : null)}
|
|
716
|
+
onCommit={(n) => onChange(n)}
|
|
717
|
+
/>
|
|
718
|
+
)}
|
|
719
|
+
<span ref={wrapRef} style={{ position: 'relative', display: 'inline-flex' }}>
|
|
720
|
+
<button type="button" className={`st-cp-tokbtn${bound ? ' is-bound' : ''}`} aria-haspopup="dialog" aria-expanded={open} aria-label={`${ariaLabel} — bind a design token`} title="bind a design token" onClick={() => setOpen((o) => !o)}>
|
|
721
|
+
<Ic as={Diamond} size={13} />
|
|
722
|
+
</button>
|
|
723
|
+
{open ? (
|
|
724
|
+
<div className="st-cp-pop" role="dialog" aria-label="design tokens" style={{ left: 'auto', right: 0 }}>
|
|
725
|
+
<div className="st-cp-pop-search">
|
|
726
|
+
<Ic as={Search} size={12} />
|
|
727
|
+
<input value={q} onChange={(e) => setQ(e.target.value.toLowerCase())} placeholder="Search variables" aria-label="search variables" />
|
|
728
|
+
</div>
|
|
729
|
+
{list.length ? (
|
|
730
|
+
list.map((t) => (
|
|
731
|
+
<button key={t.name} type="button" className={`st-cp-pop-row${tokenName === t.name ? ' is-on' : ''}`} onClick={() => { onChange(`var(${t.name})`); setOpen(false); }}>
|
|
732
|
+
<span className="st-cp-pop-name">{pretty(t.name)}</span>
|
|
733
|
+
<span className="st-cp-pop-val">{t.value}</span>
|
|
734
|
+
</button>
|
|
735
|
+
))
|
|
736
|
+
) : (
|
|
737
|
+
<div className="st-cp-pop-empty">No match</div>
|
|
738
|
+
)}
|
|
739
|
+
</div>
|
|
740
|
+
) : null}
|
|
741
|
+
</span>
|
|
742
|
+
</div>
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ── RadiusControl (uniform field + ▢ detach → 2×2 corner quad) ───────────────
|
|
747
|
+
//
|
|
748
|
+
// Research-grounded (Figma/Webflow): one unified radius field + a detach toggle;
|
|
749
|
+
// the four corner fields appear below, each with a per-corner glyph. `corners` =
|
|
750
|
+
// `{ tl, tr, bl, br }` numbers. `lead` is the radius glyph (caller-supplied so
|
|
751
|
+
// the icon set stays the caller's choice).
|
|
752
|
+
export function RadiusControl({ corners, lead, onCorners }) {
|
|
753
|
+
const [split, setSplit] = useState(false);
|
|
754
|
+
const setAll = (v) => onCorners({ tl: v, tr: v, bl: v, br: v });
|
|
755
|
+
const cell = (k) => (
|
|
756
|
+
<label className="st-cp-corner">
|
|
757
|
+
<span className={`st-cp-cnr st-cp-cnr--${k}`} aria-hidden="true" />
|
|
758
|
+
<input aria-label={`${k} radius`} value={corners[k]} onChange={(e) => onCorners({ ...corners, [k]: Number.parseFloat(e.target.value) || 0 })} onFocus={(e) => e.currentTarget.select()} />
|
|
759
|
+
</label>
|
|
760
|
+
);
|
|
761
|
+
return (
|
|
762
|
+
<div className="st-cp-radius">
|
|
763
|
+
<div className="st-cp-radius-main">
|
|
764
|
+
<NumberField
|
|
765
|
+
value={corners.tl}
|
|
766
|
+
min={0}
|
|
767
|
+
max={200}
|
|
768
|
+
ariaLabel="border-radius"
|
|
769
|
+
lead={lead}
|
|
770
|
+
steppers={false}
|
|
771
|
+
unitSlot={<span className="st-cp-numsuffix" aria-hidden="true">px</span>}
|
|
772
|
+
onCommit={(v) => (split ? onCorners({ ...corners, tl: v }) : setAll(v))}
|
|
773
|
+
/>
|
|
774
|
+
<button type="button" className={`st-cp-splitbtn${split ? ' is-on' : ''}`} aria-pressed={split} aria-label="detach corners" title="detach corners — edit each independently" onClick={() => setSplit((s) => !s)}>
|
|
775
|
+
<Ic as={SquareDashed} size={14} />
|
|
776
|
+
</button>
|
|
777
|
+
</div>
|
|
778
|
+
{split ? <div className="st-cp-corners">{cell('tl')}{cell('tr')}{cell('bl')}{cell('br')}</div> : null}
|
|
779
|
+
</div>
|
|
780
|
+
);
|
|
781
|
+
}
|