@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,456 @@
1
+ /* A card for one step of an automation, with its controls folded into the corner until the pointer asks for them.
2
+ Ported from bencho.dev (MIT).
3
+
4
+ The controls are the component; the card is what they fold into. They come out of the card's own top-right corner
5
+ rather than fading in above it: each starts AT the corner, hidden behind the card, and travels out along a path
6
+ that is the card's outline pushed out by a constant distance (straight along the top, round the corner on an arc
7
+ concentric with it, straight down the right edge). Every point on it is the same distance from the card at every
8
+ corner radius, which a circle struck from one point is not.
9
+ One spring each, and the stagger is a delay on the target, not an ease: four springs always exist (hooks cannot be
10
+ conditional; `count` only decides how many are drawn), each told to open a beat after the one before it. Closing
11
+ runs the other way, last out first back, so the fan folds into the corner. */
12
+
13
+ import {
14
+ type CSSProperties,
15
+ type ComponentType,
16
+ type ReactElement,
17
+ type ReactNode,
18
+ useEffect,
19
+ useRef,
20
+ useState,
21
+ } from "react";
22
+ import { Copy, Gear2, GitCommit, LinkSimple, Plus } from "../icons";
23
+ import { VoiceAvatar } from "../VoiceAvatar";
24
+
25
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
26
+
27
+ /* 0..100 into the two numbers a spring has: stiffness and per-frame decay. Chosen by damping ratio
28
+ (zeta = -ln(d) / (2 * sqrt(k))): 0 is ~0.85 (heavy, arrives without a ring), 50 is ~0.41, 100 is ~0.20 (two
29
+ visible rebounds). Both ends shippable. */
30
+ const springOf = (tune: number) => ({
31
+ k: 0.08 + (tune / 100) * 0.16,
32
+ d: 0.62 + (tune / 100) * 0.2,
33
+ });
34
+
35
+ /* A hand-rolled spring, in frames not milliseconds: `dt` is in sixtieths of a second and the decay is raised to it
36
+ (not multiplied), so a dropped frame sheds the same energy as the two it replaced. The snap threshold is absolute,
37
+ so callers work in 0..100. The loop parks itself once settled, and a restart picks up from the refs. */
38
+ function useSpring(target: number, tune = 50, instant = false) {
39
+ const [at, setAt] = useState(target);
40
+ const cur = useRef(target);
41
+ const vel = useRef(0);
42
+ const raf = useRef(0);
43
+
44
+ useEffect(() => {
45
+ if (instant) {
46
+ cur.current = target;
47
+ vel.current = 0;
48
+ setAt(target);
49
+ return;
50
+ }
51
+ const { k, d } = springOf(tune);
52
+ let prev = 0;
53
+ const tick = (t: number) => {
54
+ const dt = prev ? clamp((t - prev) / 16.67, 0, 2.5) : 1;
55
+ prev = t;
56
+ vel.current += (target - cur.current) * k * dt;
57
+ vel.current *= d ** dt;
58
+ cur.current += vel.current * dt;
59
+ if (Math.abs(target - cur.current) < 0.02 && Math.abs(vel.current) < 0.02) {
60
+ cur.current = target;
61
+ vel.current = 0;
62
+ setAt(target);
63
+ raf.current = 0;
64
+ return;
65
+ }
66
+ setAt(cur.current);
67
+ raf.current = requestAnimationFrame(tick);
68
+ };
69
+ raf.current = requestAnimationFrame(tick);
70
+ return () => {
71
+ cancelAnimationFrame(raf.current);
72
+ raf.current = 0;
73
+ };
74
+ }, [target, tune, instant]);
75
+
76
+ return at;
77
+ }
78
+
79
+ /* read once: a preference, not a live input */
80
+ const stillness = () =>
81
+ typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
82
+
83
+ /* the button and half of it; everything else with a size is measured off this one */
84
+ const DOT = 36;
85
+ const R = DOT / 2;
86
+
87
+ /* centre to centre along the path: a real distance, so twelve pixels of air stay twelve whatever the corner does */
88
+ const SPACING = DOT + 12;
89
+
90
+ /* A point `s` along the offset path, measured from where the corner arc meets the top edge (the corner occupies
91
+ 0..len; outside that it has run onto a straight), and the direction out of the card there. The arc is concentric
92
+ with the card's, so its radius is the card's plus the offset, floored just above zero for a square card. */
93
+ function ride(s: number, corner: number, off: number) {
94
+ const arc = Math.max(0.001, corner + off);
95
+ const len = (arc * Math.PI) / 2;
96
+ if (s <= 0) {
97
+ /* along the top edge, going left */
98
+ return { x: -corner + s, y: -off, nx: 0, ny: -1 };
99
+ }
100
+ if (s >= len) {
101
+ /* down the right edge */
102
+ return { x: off, y: corner + (s - len), nx: 1, ny: 0 };
103
+ }
104
+ const th = Math.PI / 2 - s / arc;
105
+ return {
106
+ x: -corner + arc * Math.cos(th),
107
+ y: corner - arc * Math.sin(th),
108
+ nx: Math.cos(th),
109
+ ny: -Math.sin(th),
110
+ };
111
+ }
112
+
113
+ /* Where they hide, provably inside: a point on the card's diagonal far enough in that a whole button fits behind the
114
+ card. From corner >= R + 2 the corner's own centre works (at least R inside everything, with 2px to spare; at
115
+ exactly R the button is tangent and draws a hairline). Below that the arc has eaten the room near the corner, so go
116
+ past the corner's disc until both edges are clear. One number along the diagonal. */
117
+ const hideAt = (corner: number) =>
118
+ corner >= R + 2 ? corner * Math.SQRT2 : Math.max(R * Math.SQRT2 + 2, corner * Math.SQRT2 + corner + 2);
119
+
120
+ /* Reach is the air between the card and the fan, on the original 0..100 scale and allowed a narrow window of it
121
+ (20 to 40: 11px of gap to 16). Tighter fouls the corner, wider stops reading as attached. Not a pixel count: the
122
+ scale a value was judged on is part of the value. */
123
+ const gapPx = (reach: number) => 6 + (clamp(reach, 20, 40) / 100) * 24;
124
+
125
+ /* The air the block keeps above and below the card for the fan, measured at the top of the Reach window so the box
126
+ does not resize while the knob moves. Never beside: the width stays the card's. */
127
+ const AIR = Math.ceil(gapPx(100) + DOT);
128
+
129
+ /* the beat between one button and the next, at the top of the stagger range */
130
+ const BEAT = 90;
131
+
132
+ /* One corner button. `onClick` is optional because the gallery's demo buttons do nothing. */
133
+ export type NodeAction = {
134
+ id: string;
135
+ label: string;
136
+ icon: ComponentType<{ size?: number; weight?: "bold" }>;
137
+ onClick?: () => void;
138
+ disabled?: boolean;
139
+ };
140
+
141
+ export type NodePerson = { id: string; name: string };
142
+
143
+ /* What a custom card element receives (`render`): spread it on a link or a button and the card stays one element */
144
+ export type NodeCardProps = {
145
+ className: string;
146
+ children: ReactNode;
147
+ title?: string;
148
+ "data-on"?: true;
149
+ "data-selected"?: true;
150
+ };
151
+
152
+ /* connect, add a step, duplicate, settings: the four things you do to a node without opening it. Deliberately not
153
+ delete: the one action that cannot be undone does not belong in a row that appears under your cursor. */
154
+ const ACTS: NodeAction[] = [
155
+ { id: "link", label: "Conectar", icon: LinkSimple },
156
+ { id: "add", label: "Adicionar etapa", icon: Plus },
157
+ { id: "copy", label: "Duplicar", icon: Copy },
158
+ { id: "settings", label: "Configurações", icon: Gear2 },
159
+ ];
160
+
161
+ /* who is on the step: three pictured (the voice avatars stand in for the faces), the rest counted. The chip is the
162
+ difference, not a typed number, so the two halves of one fact cannot drift */
163
+ const CREW: NodePerson[] = [
164
+ { id: "mara", name: "Mara Quinn" },
165
+ { id: "ines", name: "Tomás Oliveira" },
166
+ { id: "kai", name: "Lars Andersen" },
167
+ ];
168
+ const ON_STEP = 6;
169
+
170
+ /* a node is identified by its place in the flow, not by the service it calls */
171
+ const NODE = "Nó 07";
172
+ const SAY = "Lê a conversa, escreve um resumo e posta de volta no canal de onde veio.";
173
+
174
+ export function ActionNode({
175
+ /* the air between the card and the buttons, 20..40 */
176
+ reach = 25,
177
+ /* the spring, 0..100 */
178
+ bounce = 20,
179
+ /* how far apart the buttons open, 0..100 */
180
+ stagger = 55,
181
+ /* how many of the demo buttons are drawn, 2..4 (a controlled `actions` list sets its own count) */
182
+ count = 4,
183
+ /* the card's own corner, in px; the buttons keep theirs */
184
+ corner = 20,
185
+ /* The controlled side. Each is the demo's content when left out; pass an empty list to show none.
186
+ title / description: the two lines of the card. actions: the corner buttons, at most four, none = no fan.
187
+ people / more: the face stack and the "+N" chip. */
188
+ title = NODE,
189
+ description = SAY,
190
+ actions,
191
+ people = CREW,
192
+ more = people === CREW ? ON_STEP - CREW.length : 0,
193
+ /* the glyph box before the title, as the caller's own marker (a status, a number); the box is dropped */
194
+ mark,
195
+ /* the card is a button that calls this; with `render` the caller supplies the element instead (a router link) */
196
+ onOpen,
197
+ render,
198
+ /* native tooltip and the "this one is picked" ring */
199
+ hint,
200
+ selected = false,
201
+ /* the card's width: a number is px, a string any CSS length ("100%" fills its cell). The original is 272 */
202
+ width,
203
+ /* tighter padding and type, for cards that sit in a row of cells instead of on their own */
204
+ dense = false,
205
+ /* the room kept above and below the card for the fan; 0 lets it overlay what is next to the card instead */
206
+ air = AIR,
207
+ /* "down": the fan comes out of the bottom-right corner (the same geometry, mirrored), for a card whose top
208
+ edge has no room */
209
+ fan = "up",
210
+ className,
211
+ }: {
212
+ reach?: number;
213
+ bounce?: number;
214
+ stagger?: number;
215
+ count?: number;
216
+ corner?: number;
217
+ title?: string;
218
+ description?: ReactNode;
219
+ actions?: NodeAction[];
220
+ people?: NodePerson[];
221
+ more?: number;
222
+ mark?: ReactNode;
223
+ onOpen?: () => void;
224
+ render?: (props: NodeCardProps) => ReactElement;
225
+ hint?: string;
226
+ selected?: boolean;
227
+ width?: number | string;
228
+ dense?: boolean;
229
+ air?: number;
230
+ fan?: "up" | "down";
231
+ className?: string;
232
+ } = {}) {
233
+ const acts = (actions ?? ACTS.slice(0, clamp(Math.round(count), 2, 4))).slice(0, 4);
234
+ const n = acts.length;
235
+ const still = stillness();
236
+
237
+ /* `on` is the pointer; `lit` is what each spring aims at, which is the same thing a moment later (the stagger).
238
+ Four flags rather than a count, so the reverse fold is the same mechanism read backwards. */
239
+ const [on, setOn] = useState(false);
240
+ const [lit, setLit] = useState([false, false, false, false]);
241
+ const timers = useRef<number[]>([]);
242
+
243
+ useEffect(() => {
244
+ timers.current.forEach(clearTimeout);
245
+ timers.current = [];
246
+ const gap = still ? 0 : (clamp(stagger, 0, 100) / 100) * BEAT;
247
+ for (let i = 0; i < 4; i += 1) {
248
+ /* opening runs first to last; closing runs last to first */
249
+ const place = on ? i : n - 1 - i;
250
+ const at = gap * place;
251
+ const set = () =>
252
+ setLit(prev => {
253
+ if (prev[i] === on) return prev;
254
+ const next = [...prev];
255
+ next[i] = on;
256
+ return next;
257
+ });
258
+ if (at <= 0) set();
259
+ else timers.current.push(window.setTimeout(set, at));
260
+ }
261
+ return () => {
262
+ timers.current.forEach(clearTimeout);
263
+ timers.current = [];
264
+ };
265
+ }, [on, n, stagger, still]);
266
+
267
+ /* four, always, whatever `count` says: a hook cannot be drawn conditionally */
268
+ const p0 = useSpring(lit[0] ? 100 : 0, bounce, still);
269
+ const p1 = useSpring(lit[1] ? 100 : 0, bounce, still);
270
+ const p2 = useSpring(lit[2] ? 100 : 0, bounce, still);
271
+ const p3 = useSpring(lit[3] ? 100 : 0, bounce, still);
272
+ const at = [p0, p1, p2, p3];
273
+
274
+ /* Where each button ends up. The path is offset from the card by the gap plus a button's own half, so the gap is
275
+ measured to the button's edge. Centred on the corner's midpoint and spread by arc length, so the fan is
276
+ symmetrical about the diagonal however many are on it and however square the card is. */
277
+ const c = clamp(corner, 0, 32);
278
+ const off = gapPx(reach) + R;
279
+ const mid = ((c + off) * Math.PI) / 4;
280
+ const spots = Array.from({ length: n }, (_, i) => ride(mid + (i - (n - 1) / 2) * SPACING, c, off));
281
+
282
+ /* the one place they all fold back to */
283
+ const deep = hideAt(c) / Math.SQRT2;
284
+ const hid = { x: -deep, y: deep };
285
+
286
+ /* The air between the card and the fan is a hole: the wrapper's box is the card, so moving from the card toward a
287
+ button would leave .bn-nod on the way across and fold the fan from under the cursor. The fan carries a hit area:
288
+ the box its buttons occupy, bled a little, hung off the same corner anchor, measured off `spots` so there is no
289
+ second copy of the geometry. Live only while the fan is open, so empty space beside the card never opens it. */
290
+ const BLEED = 6;
291
+ const box = spots.reduce(
292
+ (b, s) => ({
293
+ l: Math.min(b.l, s.x - R - BLEED),
294
+ r: Math.max(b.r, s.x + R + BLEED),
295
+ t: Math.min(b.t, s.y - R - BLEED),
296
+ d: Math.max(b.d, s.y + R + BLEED),
297
+ }),
298
+ /* seeded on the anchor, so the pad reaches back to the corner however far out the fan has been sent */
299
+ { l: 0, r: 0, t: 0, d: 0 },
300
+ );
301
+ const pw = box.r - box.l;
302
+ const ph = box.d - box.t;
303
+ /* the corner itself, in the pad's own coordinates */
304
+ const px = -box.l;
305
+ const py = -box.t;
306
+
307
+ /* The bite taken out of the pad is inside the card: the quadrant x < 0, y > 0 is too greedy (just outside a
308
+ rounded corner the material has pulled back but the quadrant has not, right where the middle button's path
309
+ crosses). x < -c with y > c is card at every radius, so that rectangle is the one to lose. */
310
+ const bx = Math.max(0, px - c);
311
+ const by = Math.min(ph, py + c);
312
+
313
+ const cardProps: NodeCardProps = {
314
+ className: "bn-nod-card",
315
+ title: hint,
316
+ "data-on": on || undefined,
317
+ "data-selected": selected || undefined,
318
+ children: (
319
+ <>
320
+ <span className="bn-nod-top">
321
+ {mark ? (
322
+ <span className="bn-nod-mark" data-bare="">
323
+ {mark}
324
+ </span>
325
+ ) : (
326
+ <span className="bn-nod-mark" aria-hidden="true">
327
+ {/* a ring with a wire out of either side: one step with a flow arriving and a flow leaving. The glyph
328
+ spans its full width, so 18 gives the ring its weight back without the wires reaching the edge */}
329
+ <GitCommit size={18} weight="bold" />
330
+ </span>
331
+ )}
332
+ <span className="bn-nod-name">{title}</span>
333
+ </span>
334
+
335
+ <span className="bn-nod-say">{description}</span>
336
+
337
+ {/* the faces come after the sentence: the least urgent of the three and the heaviest to look at */}
338
+ {(people.length > 0 || more > 0) && (
339
+ <span className="bn-nod-chain" aria-hidden="true">
340
+ {people.map(p => (
341
+ <span key={p.id} className="bn-nod-link">
342
+ <VoiceAvatar seed={p.name} size={24} />
343
+ </span>
344
+ ))}
345
+ {more > 0 && <span className="bn-nod-more">+{more}</span>}
346
+ </span>
347
+ )}
348
+ </>
349
+ ),
350
+ };
351
+
352
+ /* The card is a span-built block so it can be a button or a link without invalid nesting: `render` hands the
353
+ caller its props, `onOpen` makes it a button, and with neither it is a plain box. */
354
+ const card = render ? (
355
+ render(cardProps)
356
+ ) : onOpen ? (
357
+ <button type="button" onClick={onOpen} {...cardProps} />
358
+ ) : (
359
+ <div {...cardProps} />
360
+ );
361
+
362
+ /* the fan hangs off one corner; "down" is the same arrangement flipped about the card's horizontal axis */
363
+ const down = fan === "down";
364
+
365
+ return (
366
+ <div
367
+ className={className ? `bn-nod ${className}` : "bn-nod"}
368
+ data-dense={dense || undefined}
369
+ data-fan={down ? "down" : undefined}
370
+ /* the corner rides on the wrapper as a variable so the Stroke rule can cut its ring to the same radius; the
371
+ mark is cut with the card (a share of its radius, capped at the 15 that makes the old disc), and the
372
+ vertical padding is the fan's own reach */
373
+ style={
374
+ {
375
+ "--nod-r": `${c}px`,
376
+ "--nod-mark-r": `${Math.min(15, c * 0.47).toFixed(2)}px`,
377
+ ...(width != null ? { "--nod-w": typeof width === "number" ? `${width}px` : width } : null),
378
+ paddingBlock: air,
379
+ } as CSSProperties
380
+ }
381
+ onPointerEnter={() => setOn(true)}
382
+ /* `out` with a containment test, not `leave`: a scripted pointer walked off the card carries
383
+ `relatedTarget: null`, which React never turns into a leave */
384
+ onPointerOut={e => {
385
+ const to = e.relatedTarget as Node | null;
386
+ if (!to || !e.currentTarget.contains(to)) setOn(false);
387
+ }}
388
+ onPointerCancel={() => setOn(false)}
389
+ /* the keyboard opens the fan the way the pointer does: focus on the card, then tab runs along the buttons */
390
+ onFocus={() => setOn(true)}
391
+ onBlur={e => {
392
+ if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget as Node)) setOn(false);
393
+ }}
394
+ >
395
+ {card}
396
+
397
+ {/* The fan, anchored ON the card's own top-right corner so every spot is measured from one origin. It hangs
398
+ outside the box on purpose (the box is the card and nothing else). `top: AIR`, not 0: an absolute child is
399
+ laid against the padding box, so the air above would leave the anchor behind at the top of the block. */}
400
+ {n > 0 && (
401
+ <div className="bn-nod-fan" style={down ? { bottom: air, right: 0 } : { top: air, right: 0 }}>
402
+ <i
403
+ className="bn-nod-reach"
404
+ aria-hidden="true"
405
+ data-on={on || undefined}
406
+ style={{
407
+ left: box.l,
408
+ top: box.t,
409
+ width: pw,
410
+ height: ph,
411
+ clipPath: `polygon(0 0, ${pw}px 0, ${pw}px ${ph}px, ${bx}px ${ph}px, ${bx}px ${by}px, 0 ${by}px)`,
412
+ }}
413
+ />
414
+ {acts.map((a, i) => {
415
+ const u = clamp(at[i] / 100, 0, 1.4);
416
+ const spot = spots[i];
417
+ /* out of the corner in a straight line, from the hidden point to its place on the path. `u` is not
418
+ clamped, so the spring's overshoot carries each button a little past its place and back */
419
+ const put = (a: number, b: number) => a + (b - a) * u;
420
+ return (
421
+ <button
422
+ key={a.id}
423
+ className="bn-nod-act"
424
+ type="button"
425
+ aria-label={a.label}
426
+ title={a.label}
427
+ /* a control you cannot see should not be the next thing tab lands on */
428
+ tabIndex={on ? 0 : -1}
429
+ disabled={a.disabled}
430
+ onClick={() => a.onClick?.()}
431
+ style={
432
+ {
433
+ width: DOT,
434
+ height: DOT,
435
+ /* its own centre on the anchor before anything moves it: exactly half a button */
436
+ marginTop: -R,
437
+ marginLeft: -R,
438
+ translate: `${put(hid.x, spot.x)}px ${put(hid.y, spot.y)}px`,
439
+ /* barely any scale and no fade: a button hidden under the card does not need to be transparent,
440
+ and the 0.18 left is weight, not concealment. A variable rather than `scale`, because the
441
+ hover grows the button too and an inline `scale` would beat any rule; the stylesheet
442
+ multiplies the two. */
443
+ "--pop": 0.82 + 0.18 * Math.min(u, 1),
444
+ } as CSSProperties
445
+ }
446
+ >
447
+ {/* 16 in a 36 disc, in proportion to the button */}
448
+ <a.icon size={16} weight="bold" />
449
+ </button>
450
+ );
451
+ })}
452
+ </div>
453
+ )}
454
+ </div>
455
+ );
456
+ }