yana-web 0.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.
@@ -0,0 +1,558 @@
1
+ // @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
2
+
3
+ /* BEGIN USAGE */
4
+ // tweaks-panel.jsx
5
+ // Reusable Tweaks shell + form-control helpers.
6
+ // Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
7
+ // TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
8
+ //
9
+ // Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
10
+ // posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
11
+ // individual prototypes don't re-roll it. Ships a consistent set of controls so you
12
+ // don't hand-draw <input type="range">, segmented radios, steppers, etc.
13
+ //
14
+ // Usage (in an HTML file that loads React + Babel):
15
+ //
16
+ // const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
17
+ // "primaryColor": "#D97757",
18
+ // "palette": ["#D97757", "#29261b", "#f6f4ef"],
19
+ // "fontSize": 16,
20
+ // "density": "regular",
21
+ // "dark": false
22
+ // }/*EDITMODE-END*/;
23
+ //
24
+ // function App() {
25
+ // const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
26
+ // return (
27
+ // <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
28
+ // Hello
29
+ // <TweaksPanel>
30
+ // <TweakSection label="Typography" />
31
+ // <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
32
+ // onChange={(v) => setTweak('fontSize', v)} />
33
+ // <TweakRadio label="Density" value={t.density}
34
+ // options={['compact', 'regular', 'comfy']}
35
+ // onChange={(v) => setTweak('density', v)} />
36
+ // <TweakSection label="Theme" />
37
+ // <TweakColor label="Primary" value={t.primaryColor}
38
+ // options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
39
+ // onChange={(v) => setTweak('primaryColor', v)} />
40
+ // <TweakColor label="Palette" value={t.palette}
41
+ // options={[['#D97757', '#29261b', '#f6f4ef'],
42
+ // ['#475569', '#0f172a', '#f1f5f9']]}
43
+ // onChange={(v) => setTweak('palette', v)} />
44
+ // <TweakToggle label="Dark mode" value={t.dark}
45
+ // onChange={(v) => setTweak('dark', v)} />
46
+ // </TweaksPanel>
47
+ // </div>
48
+ // );
49
+ // }
50
+ //
51
+ // TweakRadio is the segmented control for 2–3 short options (auto-falls-back to
52
+ // TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
53
+ // options are many or long. For color tweaks always curate 3-4 options rather than
54
+ // a free picker; an option can also be a whole 2–5 color palette (the stored value
55
+ // is the array). The Tweak* controls are a floor, not a ceiling — build custom
56
+ // controls inside the panel if a tweak calls for UI they don't cover.
57
+ /* END USAGE */
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ const __TWEAKS_STYLE = `
61
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
62
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
63
+ transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
64
+ background:rgba(247,251,249,.80);color:#21403a;
65
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
66
+ border:.5px solid rgba(255,255,255,.7);border-radius:14px;
67
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
68
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
69
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
70
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
71
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
72
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
73
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
74
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
75
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
76
+ overflow-y:auto;overflow-x:hidden;min-height:0;
77
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
78
+ .twk-body::-webkit-scrollbar{width:8px}
79
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
80
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
81
+ border:2px solid transparent;background-clip:content-box}
82
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
83
+ border:2px solid transparent;background-clip:content-box}
84
+ .twk-row{display:flex;flex-direction:column;gap:5px}
85
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
86
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
87
+ color:rgba(41,38,27,.72)}
88
+ .twk-lbl>span:first-child{font-weight:500}
89
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
90
+
91
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
92
+ color:rgba(41,38,27,.45);padding:10px 0 0}
93
+ .twk-sect:first-child{padding-top:0}
94
+
95
+ .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
96
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
97
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
98
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
99
+ select.twk-field{padding-right:22px;
100
+ background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
101
+ background-repeat:no-repeat;background-position:right 8px center}
102
+
103
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
104
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
105
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
106
+ width:14px;height:14px;border-radius:50%;background:#fff;
107
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
108
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
109
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
110
+
111
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
112
+ background:rgba(0,0,0,.06);user-select:none}
113
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
114
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
115
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
116
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
117
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
118
+ background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
119
+ border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
120
+ overflow-wrap:anywhere}
121
+
122
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
123
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
124
+ .twk-toggle[data-on="1"]{background:#34c759}
125
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
126
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
127
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
128
+
129
+ .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
130
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
131
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
132
+ user-select:none;padding-right:8px}
133
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
134
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
135
+ outline:none;color:inherit;-moz-appearance:textfield}
136
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
137
+ -webkit-appearance:none;margin:0}
138
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
139
+
140
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
141
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
142
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
143
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
144
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
145
+
146
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
147
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
148
+ background:transparent;flex-shrink:0}
149
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
150
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
151
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
152
+
153
+ .twk-chips{display:flex;gap:6px}
154
+ .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
155
+ padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
156
+ box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
157
+ transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
158
+ .twk-chip:hover{transform:translateY(-1px);
159
+ box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
160
+ .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
161
+ 0 2px 6px rgba(0,0,0,.15)}
162
+ .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
163
+ display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
164
+ .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
165
+ .twk-chip>span>i:first-child{box-shadow:none}
166
+ .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
167
+ filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
168
+ `;
169
+
170
+ // ── useTweaks ───────────────────────────────────────────────────────────────
171
+ // Single source of truth for tweak values. Values hydrate from and persist to
172
+ // localStorage so the user's appearance settings (theme, accent, density,
173
+ // language…) survive reloads. The host protocol (__edit_mode_set_keys) is
174
+ // still posted for prototype-host compatibility.
175
+ const __TWEAKS_STORE = 'yana.tweaks';
176
+
177
+ function useTweaks(defaults) {
178
+ const [values, setValues] = React.useState(() => {
179
+ try {
180
+ const saved = JSON.parse(localStorage.getItem(__TWEAKS_STORE));
181
+ // merge over defaults so new tweak keys added in code get their default
182
+ if (saved && typeof saved === 'object' && !Array.isArray(saved)) {
183
+ return { ...defaults, ...saved };
184
+ }
185
+ } catch (_) {}
186
+ return defaults;
187
+ });
188
+ // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
189
+ // useState-style call doesn't write a "[object Object]" key into the persisted
190
+ // JSON block.
191
+ const setTweak = React.useCallback((keyOrEdits, val) => {
192
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
193
+ ? keyOrEdits : { [keyOrEdits]: val };
194
+ setValues((prev) => {
195
+ const next = { ...prev, ...edits };
196
+ try { localStorage.setItem(__TWEAKS_STORE, JSON.stringify(next)); } catch (_) {}
197
+ return next;
198
+ });
199
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
200
+ // Same-window signal so in-page listeners (deck-stage rail thumbnails)
201
+ // can react — the parent message only reaches the host, not peers.
202
+ window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
203
+ }, []);
204
+ return [values, setTweak];
205
+ }
206
+
207
+ // ── TweaksPanel ─────────────────────────────────────────────────────────────
208
+ // Floating shell. Registers the protocol listener BEFORE announcing
209
+ // availability — if the announce ran first, the host's activate could land
210
+ // before our handler exists and the toolbar toggle would silently no-op.
211
+ // The close button posts __edit_mode_dismissed so the host's toolbar toggle
212
+ // flips off in lockstep; the host echoes __deactivate_edit_mode back which
213
+ // is what actually hides the panel.
214
+ function TweaksPanel({ title = 'Tweaks', children }) {
215
+ const [open, setOpen] = React.useState(false);
216
+ const dragRef = React.useRef(null);
217
+ const offsetRef = React.useRef({ x: 16, y: 16 });
218
+ const PAD = 16;
219
+
220
+ const clampToViewport = React.useCallback(() => {
221
+ const panel = dragRef.current;
222
+ if (!panel) return;
223
+ const w = panel.offsetWidth, h = panel.offsetHeight;
224
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
225
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
226
+ offsetRef.current = {
227
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
228
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
229
+ };
230
+ panel.style.right = offsetRef.current.x + 'px';
231
+ panel.style.bottom = offsetRef.current.y + 'px';
232
+ }, []);
233
+
234
+ React.useEffect(() => {
235
+ if (!open) return;
236
+ clampToViewport();
237
+ if (typeof ResizeObserver === 'undefined') {
238
+ window.addEventListener('resize', clampToViewport);
239
+ return () => window.removeEventListener('resize', clampToViewport);
240
+ }
241
+ const ro = new ResizeObserver(clampToViewport);
242
+ ro.observe(document.documentElement);
243
+ return () => ro.disconnect();
244
+ }, [open, clampToViewport]);
245
+
246
+ React.useEffect(() => {
247
+ const onMsg = (e) => {
248
+ const t = e?.data?.type;
249
+ if (t === '__activate_edit_mode') setOpen(true);
250
+ else if (t === '__deactivate_edit_mode') setOpen(false);
251
+ };
252
+ window.addEventListener('message', onMsg);
253
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
254
+ return () => window.removeEventListener('message', onMsg);
255
+ }, []);
256
+
257
+ const dismiss = () => {
258
+ setOpen(false);
259
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
260
+ };
261
+
262
+ const onDragStart = (e) => {
263
+ const panel = dragRef.current;
264
+ if (!panel) return;
265
+ const r = panel.getBoundingClientRect();
266
+ const sx = e.clientX, sy = e.clientY;
267
+ const startRight = window.innerWidth - r.right;
268
+ const startBottom = window.innerHeight - r.bottom;
269
+ const move = (ev) => {
270
+ offsetRef.current = {
271
+ x: startRight - (ev.clientX - sx),
272
+ y: startBottom - (ev.clientY - sy),
273
+ };
274
+ clampToViewport();
275
+ };
276
+ const up = () => {
277
+ window.removeEventListener('mousemove', move);
278
+ window.removeEventListener('mouseup', up);
279
+ };
280
+ window.addEventListener('mousemove', move);
281
+ window.addEventListener('mouseup', up);
282
+ };
283
+
284
+ if (!open) return null;
285
+ return (
286
+ <>
287
+ <style>{__TWEAKS_STYLE}</style>
288
+ <div ref={dragRef} className="twk-panel" data-omelette-chrome=""
289
+ style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
290
+ <div className="twk-hd" onMouseDown={onDragStart}>
291
+ <b>{title}</b>
292
+ <button className="twk-x" aria-label="Close tweaks"
293
+ onMouseDown={(e) => e.stopPropagation()}
294
+ onClick={dismiss}>✕</button>
295
+ </div>
296
+ <div className="twk-body">
297
+ {children}
298
+ </div>
299
+ </div>
300
+ </>
301
+ );
302
+ }
303
+
304
+ // ── Layout helpers ──────────────────────────────────────────────────────────
305
+
306
+ function TweakSection({ label, children }) {
307
+ return (
308
+ <>
309
+ <div className="twk-sect">{label}</div>
310
+ {children}
311
+ </>
312
+ );
313
+ }
314
+
315
+ function TweakRow({ label, value, children, inline = false }) {
316
+ return (
317
+ <div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
318
+ <div className="twk-lbl">
319
+ <span>{label}</span>
320
+ {value != null && <span className="twk-val">{value}</span>}
321
+ </div>
322
+ {children}
323
+ </div>
324
+ );
325
+ }
326
+
327
+ // ── Controls ────────────────────────────────────────────────────────────────
328
+
329
+ function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
330
+ return (
331
+ <TweakRow label={label} value={`${value}${unit}`}>
332
+ <input type="range" className="twk-slider" min={min} max={max} step={step}
333
+ value={value} onChange={(e) => onChange(Number(e.target.value))} />
334
+ </TweakRow>
335
+ );
336
+ }
337
+
338
+ function TweakToggle({ label, value, onChange }) {
339
+ return (
340
+ <div className="twk-row twk-row-h">
341
+ <div className="twk-lbl"><span>{label}</span></div>
342
+ <button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
343
+ role="switch" aria-checked={!!value}
344
+ onClick={() => onChange(!value)}><i /></button>
345
+ </div>
346
+ );
347
+ }
348
+
349
+ function TweakRadio({ label, value, options, onChange }) {
350
+ const trackRef = React.useRef(null);
351
+ const [dragging, setDragging] = React.useState(false);
352
+ // The active value is read by pointer-move handlers attached for the lifetime
353
+ // of a drag — ref it so a stale closure doesn't fire onChange for every move.
354
+ const valueRef = React.useRef(value);
355
+ valueRef.current = value;
356
+
357
+ // Segments wrap mid-word once per-segment width runs out. The track is
358
+ // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
359
+ // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
360
+ // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
361
+ // back to a dropdown rather than wrap.
362
+ const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
363
+ const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
364
+ const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
365
+ if (!fitsAsSegments) {
366
+ // <select> emits strings — map back to the original option value so the
367
+ // fallback stays type-preserving (numbers, booleans) like the segment path.
368
+ const resolve = (s) => {
369
+ const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
370
+ return m === undefined ? s : typeof m === 'object' ? m.value : m;
371
+ };
372
+ return <TweakSelect label={label} value={value} options={options}
373
+ onChange={(s) => onChange(resolve(s))} />;
374
+ }
375
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
376
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
377
+ const n = opts.length;
378
+
379
+ const segAt = (clientX) => {
380
+ const r = trackRef.current.getBoundingClientRect();
381
+ const inner = r.width - 4;
382
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
383
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
384
+ };
385
+
386
+ const onPointerDown = (e) => {
387
+ setDragging(true);
388
+ const v0 = segAt(e.clientX);
389
+ if (v0 !== valueRef.current) onChange(v0);
390
+ const move = (ev) => {
391
+ if (!trackRef.current) return;
392
+ const v = segAt(ev.clientX);
393
+ if (v !== valueRef.current) onChange(v);
394
+ };
395
+ const up = () => {
396
+ setDragging(false);
397
+ window.removeEventListener('pointermove', move);
398
+ window.removeEventListener('pointerup', up);
399
+ };
400
+ window.addEventListener('pointermove', move);
401
+ window.addEventListener('pointerup', up);
402
+ };
403
+
404
+ return (
405
+ <TweakRow label={label}>
406
+ <div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
407
+ className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
408
+ <div className="twk-seg-thumb"
409
+ style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
410
+ width: `calc((100% - 4px) / ${n})` }} />
411
+ {opts.map((o) => (
412
+ <button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
413
+ {o.label}
414
+ </button>
415
+ ))}
416
+ </div>
417
+ </TweakRow>
418
+ );
419
+ }
420
+
421
+ function TweakSelect({ label, value, options, onChange }) {
422
+ return (
423
+ <TweakRow label={label}>
424
+ <select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
425
+ {options.map((o) => {
426
+ const v = typeof o === 'object' ? o.value : o;
427
+ const l = typeof o === 'object' ? o.label : o;
428
+ return <option key={v} value={v}>{l}</option>;
429
+ })}
430
+ </select>
431
+ </TweakRow>
432
+ );
433
+ }
434
+
435
+ function TweakText({ label, value, placeholder, onChange }) {
436
+ return (
437
+ <TweakRow label={label}>
438
+ <input className="twk-field" type="text" value={value} placeholder={placeholder}
439
+ onChange={(e) => onChange(e.target.value)} />
440
+ </TweakRow>
441
+ );
442
+ }
443
+
444
+ function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
445
+ const clamp = (n) => {
446
+ if (min != null && n < min) return min;
447
+ if (max != null && n > max) return max;
448
+ return n;
449
+ };
450
+ const startRef = React.useRef({ x: 0, val: 0 });
451
+ const onScrubStart = (e) => {
452
+ e.preventDefault();
453
+ startRef.current = { x: e.clientX, val: value };
454
+ const decimals = (String(step).split('.')[1] || '').length;
455
+ const move = (ev) => {
456
+ const dx = ev.clientX - startRef.current.x;
457
+ const raw = startRef.current.val + dx * step;
458
+ const snapped = Math.round(raw / step) * step;
459
+ onChange(clamp(Number(snapped.toFixed(decimals))));
460
+ };
461
+ const up = () => {
462
+ window.removeEventListener('pointermove', move);
463
+ window.removeEventListener('pointerup', up);
464
+ };
465
+ window.addEventListener('pointermove', move);
466
+ window.addEventListener('pointerup', up);
467
+ };
468
+ return (
469
+ <div className="twk-num">
470
+ <span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
471
+ <input type="number" value={value} min={min} max={max} step={step}
472
+ onChange={(e) => onChange(clamp(Number(e.target.value)))} />
473
+ {unit && <span className="twk-num-unit">{unit}</span>}
474
+ </div>
475
+ );
476
+ }
477
+
478
+ // Relative-luminance contrast pick — checkmarks drawn over a swatch need to
479
+ // read on both #111 and #fafafa without per-option configuration. Hex input
480
+ // only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
481
+ function __twkIsLight(hex) {
482
+ const h = String(hex).replace('#', '');
483
+ const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
484
+ const n = parseInt(x.slice(0, 6), 16);
485
+ if (Number.isNaN(n)) return true;
486
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
487
+ return r * 299 + g * 587 + b * 114 > 148000;
488
+ }
489
+
490
+ const __TwkCheck = ({ light }) => (
491
+ <svg viewBox="0 0 14 14" aria-hidden="true">
492
+ <path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
493
+ strokeLinecap="round" strokeLinejoin="round"
494
+ stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
495
+ </svg>
496
+ );
497
+
498
+ // TweakColor — curated color/palette picker. Each option is either a single
499
+ // hex string or an array of 1-5 hex strings; the card adapts — a lone color
500
+ // renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
501
+ // rest stacked in a sharp column on the right. onChange emits the
502
+ // option in the shape it was passed (string stays string, array stays array).
503
+ // Without options it falls back to the native color input for back-compat.
504
+ function TweakColor({ label, value, options, onChange }) {
505
+ if (!options || !options.length) {
506
+ return (
507
+ <div className="twk-row twk-row-h">
508
+ <div className="twk-lbl"><span>{label}</span></div>
509
+ <input type="color" className="twk-swatch" value={value}
510
+ onChange={(e) => onChange(e.target.value)} />
511
+ </div>
512
+ );
513
+ }
514
+ // Native <input type=color> emits lowercase hex per the HTML spec, so
515
+ // compare case-insensitively. String() guards JSON.stringify(undefined),
516
+ // which returns the primitive undefined (no .toLowerCase).
517
+ const key = (o) => String(JSON.stringify(o)).toLowerCase();
518
+ const cur = key(value);
519
+ return (
520
+ <TweakRow label={label}>
521
+ <div className="twk-chips" role="radiogroup">
522
+ {options.map((o, i) => {
523
+ const colors = Array.isArray(o) ? o : [o];
524
+ const [hero, ...rest] = colors;
525
+ const sup = rest.slice(0, 4);
526
+ const on = key(o) === cur;
527
+ return (
528
+ <button key={i} type="button" className="twk-chip" role="radio"
529
+ aria-checked={on} data-on={on ? '1' : '0'}
530
+ aria-label={colors.join(', ')} title={colors.join(' · ')}
531
+ style={{ background: hero }}
532
+ onClick={() => onChange(o)}>
533
+ {sup.length > 0 && (
534
+ <span>
535
+ {sup.map((c, j) => <i key={j} style={{ background: c }} />)}
536
+ </span>
537
+ )}
538
+ {on && <__TwkCheck light={__twkIsLight(hero)} />}
539
+ </button>
540
+ );
541
+ })}
542
+ </div>
543
+ </TweakRow>
544
+ );
545
+ }
546
+
547
+ function TweakButton({ label, onClick, secondary = false }) {
548
+ return (
549
+ <button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
550
+ onClick={onClick}>{label}</button>
551
+ );
552
+ }
553
+
554
+ Object.assign(window, {
555
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
556
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
557
+ TweakText, TweakNumber, TweakColor, TweakButton,
558
+ });