@crievo/omni 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/package.json +2 -1
  2. package/reference/learnings.md +12 -0
  3. package/studio/api/app.ts +2 -2
  4. package/studio/api/routes/sound.ts +22 -3
  5. package/studio/api/services/sound.ts +45 -0
  6. package/studio/studio.ts +1 -0
  7. package/studio/web/components/AppShell.tsx +3 -1
  8. package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
  9. package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
  10. package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
  11. package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
  12. package/studio/web/components/Omni/OmniWidget.tsx +14 -5
  13. package/studio/web/components/Omni/PendingActions.tsx +25 -6
  14. package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
  15. package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
  16. package/studio/web/components/ResultMessage.tsx +12 -3
  17. package/studio/web/components/Sidebar.tsx +63 -24
  18. package/studio/web/components/StageStepper.tsx +105 -34
  19. package/studio/web/components/Toaster.tsx +25 -0
  20. package/studio/web/components/VoiceCarousel.tsx +19 -16
  21. package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
  22. package/studio/web/components/bencho/ActionNode.tsx +456 -0
  23. package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
  24. package/studio/web/components/bencho/BenchoScope.tsx +8 -0
  25. package/studio/web/components/bencho/Checklist.tsx +421 -0
  26. package/studio/web/components/bencho/CreateMenu.tsx +258 -0
  27. package/studio/web/components/bencho/GenerateImage.tsx +290 -0
  28. package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
  29. package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
  30. package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
  31. package/studio/web/components/bencho/NowPlaying.tsx +403 -0
  32. package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
  33. package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
  34. package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
  35. package/studio/web/components/bencho/SloshSlider.tsx +394 -0
  36. package/studio/web/components/bencho/StackedBars.tsx +342 -0
  37. package/studio/web/components/bencho/ToastStack.tsx +263 -0
  38. package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
  39. package/studio/web/components/bencho/VoiceNote.tsx +705 -0
  40. package/studio/web/components/icons.tsx +27 -0
  41. package/studio/web/components/ui/slider.tsx +34 -1
  42. package/studio/web/components/ui/switch.tsx +18 -5
  43. package/studio/web/index.html +1 -1
  44. package/studio/web/lib/omni-panel.ts +4 -1
  45. package/studio/web/lib/toast.ts +196 -0
  46. package/studio/web/lib/upload.ts +24 -0
  47. package/studio/web/pages/BenchoPage/index.tsx +74 -0
  48. package/studio/web/pages/BenchoPage/registry.ts +174 -0
  49. package/studio/web/pages/NewVideoPage/index.tsx +6 -2
  50. package/studio/web/pages/ReferencesPage/index.tsx +4 -1
  51. package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
  52. package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
  53. package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
  54. package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
  55. package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
  56. package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
  57. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
  58. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
  59. package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
  60. package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
  61. package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
  62. package/studio/web/pages/VideoPage/index.tsx +4 -0
  63. package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
  64. package/studio/web/router.tsx +8 -0
  65. package/studio/web/styles/components/bencho/action-node.css +227 -0
  66. package/studio/web/styles/components/bencho/checklist.css +182 -0
  67. package/studio/web/styles/components/bencho/dock.css +188 -0
  68. package/studio/web/styles/components/bencho/gallery.css +109 -0
  69. package/studio/web/styles/components/bencho/generate.css +151 -0
  70. package/studio/web/styles/components/bencho/image-accordion.css +37 -0
  71. package/studio/web/styles/components/bencho/liq-create.css +279 -0
  72. package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
  73. package/studio/web/styles/components/bencho/one-time-code.css +148 -0
  74. package/studio/web/styles/components/bencho/picker.css +204 -0
  75. package/studio/web/styles/components/bencho/pull.css +313 -0
  76. package/studio/web/styles/components/bencho/scope.css +42 -0
  77. package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
  78. package/studio/web/styles/components/bencho/slosh.css +137 -0
  79. package/studio/web/styles/components/bencho/sound.css +260 -0
  80. package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
  81. package/studio/web/styles/components/bencho/toast-stack.css +192 -0
  82. package/studio/web/styles/components/bencho/tokens.css +243 -0
  83. package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
  84. package/studio/web/styles/components/bencho/voice-note.css +337 -0
  85. package/studio/web/styles/components/clone-voice.css +12 -2
  86. package/studio/web/styles/components/omni.css +6 -1
  87. package/studio/web/styles/components/stepper.css +123 -7
  88. package/studio/web/styles/components/toaster.css +10 -0
  89. package/studio/web/styles/components/ui/slider.css +6 -16
  90. package/studio/web/styles/components/ui/switch.css +2 -41
  91. package/studio/web/styles/components/voice-carousel.css +46 -4
  92. package/studio/web/styles/index.css +23 -0
  93. package/studio/web/styles/pages/video/layout.css +1 -4
  94. package/studio/web/styles/pages/video/sound-timeline.css +8 -0
  95. package/studio/web/styles/theme/identity/shell.css +10 -26
  96. package/tools/sound.mjs +67 -3
@@ -0,0 +1,273 @@
1
+ /* A row of pictures cropped to slivers. Point at one and it opens; the others give up exactly what it takes.
2
+ Ported from bencho.dev (MIT).
3
+
4
+ The row is a fixed width, and that is the component: nothing grows, so opening a picture is a negotiation. Every
5
+ pixel the pointed one gains the other four pay between them. Flexbox with five grow numbers does that arithmetic at
6
+ any count and any gap.
7
+ The neighbours of the open picture open a little too (a falloff, not a switch being thrown). `reach` is that
8
+ falloff: tight, one picture opens; wide, the whole row leans toward the cursor.
9
+ Pointing is stable: the picture under the pointer is the one that grows, so its edges move away from the pointer,
10
+ never across it, and it cannot shrink out from under you and hand the hover to a neighbour. */
11
+
12
+ import { useEffect, useRef, useState } from "react";
13
+
14
+ /* One spring for everything that settles: velocity accumulates toward the target, is damped, and the loop parks itself
15
+ when both the delta and the velocity fall under 0.02. `dt` is in sixtieths of a second and the damping is raised to
16
+ it (not multiplied), so a dropped frame decays the same energy as the two frames it replaced. */
17
+
18
+ /* 0..100 into the two numbers a spring has, chosen by damping ratio zeta = -ln(d) / (2 sqrt(k)):
19
+ 0 -> zeta ~0.85, heavy, arrives without a ring
20
+ 50 -> zeta ~0.41
21
+ 100 -> zeta ~0.20, lively, two visible rebounds
22
+ Both ends stay usable. */
23
+ const springOf = (tune: number) => ({
24
+ /* stiffness: how hard it is pulled toward the target */
25
+ k: 0.08 + (tune / 100) * 0.16,
26
+ /* decay, per frame: how much of the velocity survives */
27
+ d: 0.62 + (tune / 100) * 0.2,
28
+ });
29
+
30
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
31
+
32
+ /* The snap threshold is absolute, so callers work in pixels or in 0..100: a spring over 0..1 would be "settled"
33
+ before it had visibly moved. */
34
+ function useSpring(target: number, tune = 50, instant = false) {
35
+ const [at, setAt] = useState(target);
36
+ const cur = useRef(target);
37
+ const vel = useRef(0);
38
+ const raf = useRef(0);
39
+
40
+ useEffect(() => {
41
+ if (instant) {
42
+ cur.current = target;
43
+ vel.current = 0;
44
+ setAt(target);
45
+ return;
46
+ }
47
+ const { k, d } = springOf(tune);
48
+ let prev = 0;
49
+ const tick = (t: number) => {
50
+ const dt = prev ? clamp((t - prev) / 16.67, 0, 2.5) : 1;
51
+ prev = t;
52
+ vel.current += (target - cur.current) * k * dt;
53
+ vel.current *= d ** dt;
54
+ cur.current += vel.current * dt;
55
+ if (Math.abs(target - cur.current) < 0.02 && Math.abs(vel.current) < 0.02) {
56
+ cur.current = target;
57
+ vel.current = 0;
58
+ setAt(target);
59
+ raf.current = 0;
60
+ return;
61
+ }
62
+ setAt(cur.current);
63
+ raf.current = requestAnimationFrame(tick);
64
+ };
65
+ raf.current = requestAnimationFrame(tick);
66
+ return () => {
67
+ cancelAnimationFrame(raf.current);
68
+ raf.current = 0;
69
+ };
70
+ /* `tune` sits beside `target` because the loop closes over it: a knob turned mid-flight restarts the effect,
71
+ which picks up from the refs and continues rather than snapping. */
72
+ }, [target, tune, instant]);
73
+
74
+ return at;
75
+ }
76
+
77
+ /* Read once: a preference, not a live input. */
78
+ const stillness = () =>
79
+ typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
80
+
81
+ /* The pictures: five generated landscapes, 800x1200 portrait (the one shape this block wants), drawn as inline SVG
82
+ and handed to <img> as data URIs so `object-fit: cover` re-frames them exactly as it would a photograph. Every
83
+ ridge is a few summed sines, so the set is deterministic. */
84
+ type Shot = {
85
+ name: string;
86
+ sky: [string, string];
87
+ sun: [number, number, number, string];
88
+ ridges: [number, number, number, number, string][];
89
+ water?: string;
90
+ };
91
+
92
+ /* a ridge line across the frame at height `y`, `amp` tall, `f` wiggles, shifted by `ph`, filled down to the bottom */
93
+ const ridge = (y: number, amp: number, f: number, ph: number) => {
94
+ let d = "M0 1200";
95
+ for (let x = 0; x <= 800; x += 20) {
96
+ const u = x / 800;
97
+ const h = Math.sin(u * f * Math.PI * 2 + ph) * 0.6 + Math.sin(u * f * 2.3 * Math.PI * 2 + ph * 1.7) * 0.4;
98
+ d += ` L${x} ${(y - h * amp).toFixed(1)}`;
99
+ }
100
+ return `${d} L800 1200 Z`;
101
+ };
102
+
103
+ const paint = (s: Shot) => {
104
+ const [sx, sy, sr, sc] = s.sun;
105
+ const layers = s.ridges.map(([y, amp, f, ph, fill]) => `<path d="${ridge(y, amp, f, ph)}" fill="${fill}"/>`);
106
+ const water = s.water
107
+ ? `<rect y="860" width="800" height="340" fill="${s.water}" opacity=".55"/><ellipse cx="${sx}" cy="960" rx="${sr * 0.3}" ry="${sr * 2.4}" fill="${sc}" opacity=".7"/>`
108
+ : "";
109
+ const svg =
110
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1200"><defs>` +
111
+ `<linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${s.sky[0]}"/><stop offset="1" stop-color="${s.sky[1]}"/></linearGradient>` +
112
+ `<radialGradient id="h"><stop offset="0" stop-color="${sc}" stop-opacity=".9"/><stop offset="1" stop-color="${sc}" stop-opacity="0"/></radialGradient></defs>` +
113
+ `<rect width="800" height="1200" fill="url(#g)"/>` +
114
+ `<circle cx="${sx}" cy="${sy}" r="${sr * 3}" fill="url(#h)"/><circle cx="${sx}" cy="${sy}" r="${sr}" fill="${sc}"/>` +
115
+ `${layers.join("")}${water}</svg>`;
116
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
117
+ };
118
+
119
+ /* ridges are [height, amplitude, wiggles, phase, fill], back to front */
120
+ const SHOTS: (Shot & { src: string })[] = (
121
+ [
122
+ {
123
+ name: "dunas",
124
+ sky: ["#f6c58b", "#fbe7c8"],
125
+ sun: [560, 330, 70, "#fff3d6"],
126
+ ridges: [
127
+ [700, 70, 1.2, 0.4, "#e2a366"],
128
+ [820, 90, 1.6, 2.1, "#c97f47"],
129
+ [960, 80, 1.1, 4.0, "#a85d34"],
130
+ [1100, 60, 1.8, 1.0, "#7d3f27"],
131
+ ],
132
+ },
133
+ {
134
+ name: "lago",
135
+ sky: ["#2f4b7c", "#f2a58e"],
136
+ sun: [250, 640, 55, "#ffe9c2"],
137
+ ridges: [
138
+ [700, 120, 1.5, 1.3, "#3b3f6b"],
139
+ [800, 90, 2.2, 3.3, "#262a4f"],
140
+ ],
141
+ water: "#1d2442",
142
+ },
143
+ {
144
+ name: "serra",
145
+ sky: ["#a7c7d9", "#eef3ef"],
146
+ sun: [620, 260, 46, "#ffffff"],
147
+ ridges: [
148
+ [560, 150, 1.3, 0.2, "#9db3c2"],
149
+ [700, 160, 1.9, 2.6, "#6f8aa0"],
150
+ [860, 130, 1.4, 4.4, "#47647c"],
151
+ [1040, 90, 2.4, 0.9, "#2b4258"],
152
+ ],
153
+ },
154
+ {
155
+ name: "pôr do sol",
156
+ sky: ["#5a2a6b", "#ff9a5a"],
157
+ sun: [400, 700, 120, "#ffd27a"],
158
+ ridges: [
159
+ [820, 70, 2.0, 0.7, "#8a3a5a"],
160
+ [940, 80, 1.4, 2.9, "#5b2650"],
161
+ [1080, 60, 2.6, 5.0, "#341a3d"],
162
+ ],
163
+ },
164
+ {
165
+ name: "floresta",
166
+ sky: ["#cfe3c3", "#f4f1d0"],
167
+ sun: [180, 300, 58, "#fffbe0"],
168
+ ridges: [
169
+ [640, 90, 2.8, 1.1, "#8fbf86"],
170
+ [780, 110, 3.4, 3.7, "#5e9a68"],
171
+ [930, 100, 2.2, 0.3, "#3d7653"],
172
+ [1090, 70, 3.8, 2.4, "#245240"],
173
+ ],
174
+ },
175
+ ] satisfies Shot[]
176
+ ).map(s => ({ ...s, src: paint(s) }));
177
+
178
+ /* The strip is fixed: 340 across and 300 down, so at the default gap five pictures are a little under 62 wide against
179
+ 300 tall, the ratio at which a photograph stops being a photograph and becomes a stripe of colour. The width is not
180
+ a function of the count: take a picture out and the rest get wider. */
181
+ const W = 340;
182
+ const H = 300;
183
+
184
+ const PICS = SHOTS;
185
+
186
+ /* The corner follows the gap, capped at RMAX. A rounded picture next to a rounded picture with no air between leaves
187
+ a notch of page at every join, so the radius can never be larger than the air it has to fit into. The strip reads
188
+ the same number, so the ends are not rounder than the middle three. */
189
+ const RMAX = 12;
190
+
191
+ /* How much the open one takes, as a share of what an unopened picture has. 50 is 1.8: the open picture is nearly three
192
+ times a resting one. At 0 it is a lean rather than an opening; at 100 it is about two fifths of the strip. */
193
+ const lift = (open: number) => 0.4 + (clamp(open, 0, 100) / 100) * 2.8;
194
+
195
+ /* How far the swell carries: a gaussian on the distance in pictures. 0.35 puts the first neighbour at three
196
+ ten-thousandths (off), 1.25 at better than half; the default of 35 lands it at a tenth. */
197
+ const fall = (d: number, reach: number) => {
198
+ const s = 0.35 + (clamp(reach, 0, 100) / 100) * 0.9;
199
+ return Math.exp(-((d / s) ** 2));
200
+ };
201
+
202
+ export function ImageAccordion({
203
+ /* how much the open picture takes, 0..100 */
204
+ open = 50,
205
+ /* how far the swell carries into its neighbours, 0..100 */
206
+ reach = 35,
207
+ /* the spring, 0..100 */
208
+ bounce = 10,
209
+ /* the air between pictures, in px. The corner follows it. */
210
+ gap = 8,
211
+ }: {
212
+ open?: number;
213
+ reach?: number;
214
+ bounce?: number;
215
+ gap?: number;
216
+ } = {}) {
217
+ /* which picture the pointer is on; null rather than -1 so "none" cannot be confused with the first */
218
+ const [at, setAt] = useState<number | null>(null);
219
+ const still = stillness();
220
+
221
+ /* One spring each and no stagger: these are not five things responding in turn, they are one width being divided.
222
+ If the last picture gave up its share a beat after its neighbour, the strip would visibly fail to add up for that
223
+ beat. A hook cannot be called conditionally, so there are five, one per picture. Driven over 0..100 rather than
224
+ over the grow number itself (see useSpring). */
225
+ const want = (i: number) => (at === null ? 0 : fall(Math.abs(i - at), reach) * 100);
226
+ const a0 = useSpring(want(0), bounce, still);
227
+ const a1 = useSpring(want(1), bounce, still);
228
+ const a2 = useSpring(want(2), bounce, still);
229
+ const a3 = useSpring(want(3), bounce, still);
230
+ const a4 = useSpring(want(4), bounce, still);
231
+ const swell = [a0, a1, a2, a3, a4];
232
+
233
+ const g = clamp(gap, 0, 20);
234
+ const k = lift(open);
235
+
236
+ return (
237
+ <div
238
+ className="bn-acc"
239
+ style={{ width: W, height: H, gap: g, borderRadius: Math.min(RMAX, g) }}
240
+ /* `out` with a containment test, not `leave`: a scripted pointer walked off the block carries
241
+ `relatedTarget: null`, which React never turns into a leave */
242
+ onPointerOut={e => {
243
+ const to = e.relatedTarget as Node | null;
244
+ if (!to || !e.currentTarget.contains(to)) setAt(null);
245
+ }}
246
+ onPointerCancel={() => setAt(null)}
247
+ >
248
+ {PICS.map((pic, i) => {
249
+ const u = swell[i] / 100;
250
+ return (
251
+ <div
252
+ key={pic.name}
253
+ className="bn-acc-cell"
254
+ data-open={at === i || undefined}
255
+ style={{
256
+ /* the whole layout, in one number per picture: `flex-basis: 0` so the grow numbers are the entire
257
+ story (and the cell carries `min-width: 0` in the sheet, or the image would stop it shrinking) */
258
+ flexGrow: 1 + k * u,
259
+ borderRadius: Math.min(RMAX, g),
260
+ }}
261
+ onPointerEnter={() => {
262
+ if (at !== i) setAt(i);
263
+ }}
264
+ >
265
+ {/* `cover` re-frames rather than stretching: the height never changes, so as the cell widens the crop
266
+ admits more of the picture on both sides and nothing in it moves or resizes */}
267
+ <img src={pic.src} alt="" draggable={false} />
268
+ </div>
269
+ );
270
+ })}
271
+ </div>
272
+ );
273
+ }
@@ -0,0 +1,289 @@
1
+ /* A switch whose droplet stretches as it slides across the track. Drag the droplet, or click, or press Space/Enter.
2
+ Ported from bencho.dev (MIT).
3
+
4
+ One body, not a trail: a second, smaller blob on a lagging spring necked into a thread through a goo filter, which
5
+ is what a trail is and what it looks like. The one droplet lengthens along its direction of travel and thins across
6
+ it instead (area kept: scaleY is 1/scaleX), so the elasticity is in the shape of the thing moving. With nothing left
7
+ to merge there is no goo filter either: a blur-then-threshold pass over a single round shape can only soften its
8
+ edge, and a plain border-radius circle is drawn at device resolution and stays crisp at any zoom. */
9
+
10
+ import {
11
+ type ComponentPropsWithoutRef,
12
+ type CSSProperties,
13
+ forwardRef,
14
+ type MouseEvent as ReactMouseEvent,
15
+ type PointerEvent as ReactPointerEvent,
16
+ useCallback,
17
+ useEffect,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ } from "react";
22
+ import { animate, motion, useMotionValue, useSpring, useTransform, useVelocity } from "motion/react";
23
+
24
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
25
+ const noop = () => {};
26
+
27
+ const TRACK = 92;
28
+ /* The droplet, and the air round it: 36 in a 46 track, so five pixels of track show all the way round (a droplet
29
+ sitting in a slot, not a thumb pressed into it). PAD is the same five, because the inset at the ends and the inset
30
+ above and below are the same gap seen twice. The size is handed to the stylesheet as `--liq-thumb` rather than
31
+ written there as well: the travel maths needs it and so does the circle, and two copies of a number that must agree
32
+ is one copy too many. */
33
+ const THUMB = 36;
34
+ const PAD = (46 - THUMB) / 2;
35
+ const HEIGHT = 46;
36
+
37
+ /* the two ends of the thumb's travel, and the line between */
38
+ const SHUT_X = PAD;
39
+ const OPEN_X = TRACK - THUMB - PAD;
40
+ const MID_X = (SHUT_X + OPEN_X) / 2;
41
+
42
+ export type LiquidSwitchProps = Omit<
43
+ ComponentPropsWithoutRef<"button">,
44
+ "type" | "role" | "value" | "defaultValue" | "defaultChecked" | "children" | "onChange"
45
+ > & {
46
+ /* controlled value; when given it wins over everything the switch does on its own */
47
+ checked?: boolean;
48
+ /* uncontrolled start value, for a caller that keeps no state */
49
+ defaultChecked?: boolean;
50
+ onCheckedChange?: (checked: boolean) => void;
51
+ /* a form field: the hidden checkbox is only written when there is a name */
52
+ name?: string;
53
+ value?: string;
54
+ required?: boolean;
55
+ /* the whole switch drawn at a fraction; the layout box is the scaled size, the physics stay in the 92x46 units */
56
+ scale?: number;
57
+ /* how much the droplet lengthens into its own travel, 0..100 */
58
+ stretch?: number;
59
+ /* how fast it crosses, 0..100 */
60
+ speed?: number;
61
+ };
62
+
63
+ export const LiquidSwitch = forwardRef<HTMLButtonElement, LiquidSwitchProps>(function LiquidSwitch(
64
+ {
65
+ checked,
66
+ defaultChecked = false,
67
+ onCheckedChange,
68
+ name,
69
+ value = "on",
70
+ required,
71
+ disabled,
72
+ scale = 1,
73
+ stretch = 36,
74
+ speed = 50,
75
+ className,
76
+ style,
77
+ onClick,
78
+ ...rest
79
+ },
80
+ ref,
81
+ ) {
82
+ const controlled = checked !== undefined;
83
+ const [inner, setInner] = useState(defaultChecked);
84
+ /* the controlled value is the only truth: a parent that refuses a change leaves this where it was, and the droplet
85
+ settles back to it when the finger lets go */
86
+ const on = controlled ? checked : inner;
87
+ /* held, so the settling spring knows to keep out of the way while a finger owns the thumb */
88
+ const [held, setHeld] = useState(false);
89
+ /* which side of the middle a drag is on. The track answers under the finger, but nothing is reported to the parent
90
+ until release: a caller's onCheckedChange can save or confirm, and must fire once, not on every crossing */
91
+ const [lean, setLean] = useState<boolean | null>(null);
92
+ /* the pointer is over the switch: state and not `:hover`, because what it drives is a spring in the same transform
93
+ the position lives in (see `swell`) */
94
+ const [hot, setHot] = useState(false);
95
+ const rail = useRef<HTMLButtonElement | null>(null);
96
+ const grip = useRef<{ id: number; grab: number | null; moved: boolean } | null>(null);
97
+ /* a tap is toggled on release, and the click the browser sends right after must not toggle it a second time */
98
+ const tapped = useRef(false);
99
+
100
+ const setRefs = useCallback(
101
+ (el: HTMLButtonElement | null) => {
102
+ rail.current = el;
103
+ if (typeof ref === "function") ref(el);
104
+ else if (ref) ref.current = el;
105
+ },
106
+ [ref],
107
+ );
108
+
109
+ const commit = (next: boolean) => {
110
+ if (next === on) return;
111
+ if (!controlled) setInner(next);
112
+ onCheckedChange?.(next);
113
+ };
114
+
115
+ /* One position, written two ways: the thumb's x is a motion value rather than a prop, which lets a finger and a
116
+ spring both drive it without one having to know about the other. A drag writes it directly, a release animates
117
+ it, and nothing re-renders either way. */
118
+ const x = useMotionValue<number>(on ? OPEN_X : SHUT_X);
119
+
120
+ /* The stretch follows the thumb's velocity through a spring. Dragging slowly keeps the pair one shape; a flick
121
+ lengthens it. The divisor is the whole tuning: the peak speed on a 44px crossing is about 150px a second, so
122
+ 600 puts the peak stretch at about 1.09 at the default (1400 peaked at 1.039, present and invisible), and the cap
123
+ at 0.4 leaves the top of the knob somewhere to go. */
124
+ const vel = useVelocity(x);
125
+ const eased = useSpring(vel, { stiffness: 380, damping: 34, mass: 0.6 });
126
+ const lengthen = (v: number) => 1 + Math.min(0.4, Math.abs(v) / 600) * (clamp(stretch, 0, 100) / 100);
127
+
128
+ /* The hover swell goes in the same transform. As the CSS `scale` property it composed wrongly: individual transform
129
+ properties apply before `transform`, so `scale: 1.035` scaled the coordinate system `translateX(51px)` then moved
130
+ through (51 became 52.8) and the droplet drifted toward the end it already sat at. Multiplied into scaleX and
131
+ scaleY here it is a swell about the droplet's own centre and the position is untouched. */
132
+ const swell = useSpring(hot && !disabled ? 1.035 : 1, { stiffness: 380, damping: 34, mass: 0.6 });
133
+ const wide = useTransform([eased, swell], ([v, s]: number[]) => lengthen(v) * s);
134
+ const tall = useTransform([eased, swell], ([v, s]: number[]) => s / lengthen(v));
135
+
136
+ /* How the thumb lands when it is let go: Speed is the stiffness, damping is a constant. 170/21.5 is zeta 0.87, which
137
+ arrives with a hint of give and no hard stop (a switch is not a thing anybody wants a bounce dial on). */
138
+ const settle = useMemo(
139
+ () => ({
140
+ type: "spring" as const,
141
+ stiffness: 170 - (50 - speed) * 1.1,
142
+ damping: 21.5,
143
+ mass: 0.9,
144
+ }),
145
+ [speed],
146
+ );
147
+
148
+ /* Rest is wherever `on` says, and only applied when nothing is holding the thumb. The cleanup stopping the animation
149
+ is what makes grabbing it mid-flight work: `held` turning true tears down the settle and leaves the value exactly
150
+ where the spring had got to. `held` going false re-runs it even when `on` did not change, which is what brings the
151
+ droplet home after a drag the parent refused. */
152
+ useEffect(() => {
153
+ if (held) return;
154
+ const run = animate(x, on ? OPEN_X : SHUT_X, settle);
155
+ return () => run.stop();
156
+ }, [on, held, x, settle]);
157
+
158
+ /* the component may be drawn at a fraction, so a client delta has to be divided back out before it means anything in
159
+ the track's own units */
160
+ const local = (clientX: number) => {
161
+ const el = rail.current;
162
+ if (!el) return 0;
163
+ const b = el.getBoundingClientRect();
164
+ const k = b.width / (el.offsetWidth || b.width) || 1;
165
+ return (clientX - b.left) / k;
166
+ };
167
+
168
+ const down = (e: ReactPointerEvent) => {
169
+ if (disabled || e.button !== 0) return;
170
+ /* No offset yet: it is taken at the first move. Deciding here made an off-thumb press teleport the droplet, since
171
+ `x.set()` is a hard write with no spring behind it (46px between two frames). Taking it at the first move means
172
+ that move produces no displacement and every one after tracks the delta, so a drag always starts from where the
173
+ droplet actually is. */
174
+ grip.current = { id: e.pointerId, grab: null, moved: false };
175
+ setHeld(true);
176
+ /* it throws if the id is not a live pointer (a synthetic event), and the drag is usable without it */
177
+ try {
178
+ rail.current?.setPointerCapture(e.pointerId);
179
+ } catch {
180
+ /* not live */
181
+ }
182
+ };
183
+
184
+ const move = (e: ReactPointerEvent) => {
185
+ const g = grip.current;
186
+ if (!g || g.id !== e.pointerId) return;
187
+ const at = local(e.clientX);
188
+ /* the offset, taken from where the droplet is: the first move asks for exactly the position it already has */
189
+ if (g.grab === null) g.grab = at - x.get();
190
+ const next = clamp(at - g.grab, SHUT_X, OPEN_X);
191
+ if (Math.abs(next - x.get()) > 0.4) g.moved = true;
192
+ x.set(next);
193
+ /* it flips as it passes the middle rather than on release, so the track answers under your finger */
194
+ if (g.moved) setLean(next > MID_X);
195
+ };
196
+
197
+ const up = (e: ReactPointerEvent) => {
198
+ const g = grip.current;
199
+ if (!g) return;
200
+ grip.current = null;
201
+ /* guarded too: the capture in `down` can quietly fail, and an unguarded throw here would skip `setHeld(false)`
202
+ and leave the droplet stopped wherever the drag ended */
203
+ try {
204
+ rail.current?.releasePointerCapture?.(e.pointerId);
205
+ } catch {
206
+ /* never captured */
207
+ }
208
+ /* a cancelled gesture (the system took the touch) decides nothing */
209
+ if (e.type !== "pointercancel") {
210
+ /* a press that never travelled is a click, and a click toggles; a drag decides by where the droplet ended. Both
211
+ swallow the click event that follows, which would otherwise toggle again. */
212
+ commit(g.moved ? x.get() > MID_X : !on);
213
+ tapped.current = true;
214
+ setTimeout(() => {
215
+ tapped.current = false;
216
+ }, 0);
217
+ }
218
+ setLean(null);
219
+ setHeld(false);
220
+ };
221
+
222
+ /* Clicks that did not come from this pointer: Space and Enter on the focused button, a <label> wrapped round the
223
+ switch, a programmatic .click(). They toggle like any switch. */
224
+ const click = (e: ReactMouseEvent<HTMLButtonElement>) => {
225
+ onClick?.(e);
226
+ if (e.defaultPrevented || disabled || tapped.current) return;
227
+ commit(!on);
228
+ };
229
+
230
+ const shown = lean ?? on;
231
+
232
+ return (
233
+ <span
234
+ className={`bn-liq-box${className ? ` ${className}` : ""}`}
235
+ style={{ width: TRACK * scale, height: HEIGHT * scale }}
236
+ data-disabled={disabled ? "" : undefined}
237
+ >
238
+ <button
239
+ {...rest}
240
+ ref={setRefs}
241
+ type="button"
242
+ className="bn-liq-sw"
243
+ style={{ "--liq-thumb": `${THUMB}px`, transform: `scale(${scale})`, ...style } as CSSProperties}
244
+ data-on={shown}
245
+ data-state={on ? "checked" : "unchecked"}
246
+ data-disabled={disabled ? "" : undefined}
247
+ role="switch"
248
+ aria-checked={on}
249
+ aria-required={required || undefined}
250
+ disabled={disabled}
251
+ onClick={click}
252
+ onPointerDown={down}
253
+ onPointerMove={move}
254
+ onPointerUp={up}
255
+ onPointerCancel={up}
256
+ onPointerEnter={() => setHot(true)}
257
+ onPointerLeave={() => setHot(false)}
258
+ >
259
+ <span className="bn-liq-sw-blobs" aria-hidden="true">
260
+ <motion.span className="bn-liq-thumb" style={{ x, scaleX: wide, scaleY: tall }} />
261
+ </span>
262
+ </button>
263
+ {/* after the button on purpose: a <label> around the switch targets the first labelable element inside it */}
264
+ {name !== undefined && (
265
+ <input
266
+ hidden
267
+ type="checkbox"
268
+ tabIndex={-1}
269
+ aria-hidden="true"
270
+ name={name}
271
+ value={value}
272
+ checked={on}
273
+ required={required}
274
+ disabled={disabled}
275
+ onChange={noop}
276
+ />
277
+ )}
278
+ </span>
279
+ );
280
+ });
281
+
282
+ /* The gallery's demo: the same switch at full size, on a stage of its own with the two tuning dials. */
283
+ export function LiquidToggle({ stretch = 36, speed = 50 }: { stretch?: number; speed?: number } = {}) {
284
+ return (
285
+ <div className="bn-liq-well">
286
+ <LiquidSwitch stretch={stretch} speed={speed} aria-label="Interruptor líquido" />
287
+ </div>
288
+ );
289
+ }