@crievo/omni 0.1.2 → 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 (122) hide show
  1. package/docs/onboarding.md +69 -161
  2. package/omnivideo.config.example.json +1 -1
  3. package/package.json +2 -1
  4. package/reference/learnings.md +12 -0
  5. package/reference/omnichat.md +5 -4
  6. package/reference/publish.md +9 -9
  7. package/reference/studio.md +1 -1
  8. package/studio/api/app.ts +2 -2
  9. package/studio/api/routes/sound.ts +22 -3
  10. package/studio/api/services/onboarding.ts +242 -21
  11. package/studio/api/services/settings.ts +4 -2
  12. package/studio/api/services/sound.ts +45 -0
  13. package/studio/shared/types/api.ts +26 -1
  14. package/studio/studio.ts +1 -0
  15. package/studio/web/components/AppShell.tsx +3 -1
  16. package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
  17. package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
  18. package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
  19. package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
  20. package/studio/web/components/Omni/OmniWidget.tsx +14 -5
  21. package/studio/web/components/Omni/PendingActions.tsx +25 -6
  22. package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
  23. package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
  24. package/studio/web/components/ResultMessage.tsx +12 -3
  25. package/studio/web/components/Sidebar.tsx +63 -24
  26. package/studio/web/components/StageStepper.tsx +105 -34
  27. package/studio/web/components/Toaster.tsx +25 -0
  28. package/studio/web/components/VoiceCarousel.tsx +19 -16
  29. package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
  30. package/studio/web/components/bencho/ActionNode.tsx +456 -0
  31. package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
  32. package/studio/web/components/bencho/BenchoScope.tsx +8 -0
  33. package/studio/web/components/bencho/Checklist.tsx +421 -0
  34. package/studio/web/components/bencho/CreateMenu.tsx +258 -0
  35. package/studio/web/components/bencho/GenerateImage.tsx +290 -0
  36. package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
  37. package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
  38. package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
  39. package/studio/web/components/bencho/NowPlaying.tsx +403 -0
  40. package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
  41. package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
  42. package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
  43. package/studio/web/components/bencho/SloshSlider.tsx +394 -0
  44. package/studio/web/components/bencho/StackedBars.tsx +342 -0
  45. package/studio/web/components/bencho/ToastStack.tsx +263 -0
  46. package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
  47. package/studio/web/components/bencho/VoiceNote.tsx +705 -0
  48. package/studio/web/components/icons.tsx +27 -0
  49. package/studio/web/components/ui/slider.tsx +34 -1
  50. package/studio/web/components/ui/switch.tsx +18 -5
  51. package/studio/web/hooks/useOnboarding.ts +24 -6
  52. package/studio/web/index.html +1 -1
  53. package/studio/web/lib/alerts.ts +10 -0
  54. package/studio/web/lib/omni-panel.ts +4 -1
  55. package/studio/web/lib/toast.ts +196 -0
  56. package/studio/web/lib/upload.ts +24 -0
  57. package/studio/web/pages/BenchoPage/index.tsx +74 -0
  58. package/studio/web/pages/BenchoPage/registry.ts +174 -0
  59. package/studio/web/pages/GettingStartedPage/components/ComputerCard.tsx +102 -0
  60. package/studio/web/pages/GettingStartedPage/index.tsx +84 -58
  61. package/studio/web/pages/NewVideoPage/index.tsx +6 -2
  62. package/studio/web/pages/ReferencesPage/index.tsx +4 -1
  63. package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +30 -3
  64. package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
  65. package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
  66. package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
  67. package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
  68. package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
  69. package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
  70. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
  71. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
  72. package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
  73. package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
  74. package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
  75. package/studio/web/pages/VideoPage/index.tsx +4 -0
  76. package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
  77. package/studio/web/router.tsx +8 -0
  78. package/studio/web/styles/components/bencho/action-node.css +227 -0
  79. package/studio/web/styles/components/bencho/checklist.css +182 -0
  80. package/studio/web/styles/components/bencho/dock.css +188 -0
  81. package/studio/web/styles/components/bencho/gallery.css +109 -0
  82. package/studio/web/styles/components/bencho/generate.css +151 -0
  83. package/studio/web/styles/components/bencho/image-accordion.css +37 -0
  84. package/studio/web/styles/components/bencho/liq-create.css +279 -0
  85. package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
  86. package/studio/web/styles/components/bencho/one-time-code.css +148 -0
  87. package/studio/web/styles/components/bencho/picker.css +204 -0
  88. package/studio/web/styles/components/bencho/pull.css +313 -0
  89. package/studio/web/styles/components/bencho/scope.css +42 -0
  90. package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
  91. package/studio/web/styles/components/bencho/slosh.css +137 -0
  92. package/studio/web/styles/components/bencho/sound.css +260 -0
  93. package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
  94. package/studio/web/styles/components/bencho/toast-stack.css +192 -0
  95. package/studio/web/styles/components/bencho/tokens.css +243 -0
  96. package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
  97. package/studio/web/styles/components/bencho/voice-note.css +337 -0
  98. package/studio/web/styles/components/clone-voice.css +12 -2
  99. package/studio/web/styles/components/omni.css +6 -1
  100. package/studio/web/styles/components/stepper.css +123 -7
  101. package/studio/web/styles/components/toaster.css +10 -0
  102. package/studio/web/styles/components/ui/slider.css +6 -16
  103. package/studio/web/styles/components/ui/switch.css +2 -41
  104. package/studio/web/styles/components/voice-carousel.css +46 -4
  105. package/studio/web/styles/index.css +23 -0
  106. package/studio/web/styles/pages/getting-started.css +46 -0
  107. package/studio/web/styles/pages/settings/shell.css +4 -0
  108. package/studio/web/styles/pages/video/layout.css +1 -4
  109. package/studio/web/styles/pages/video/sound-timeline.css +8 -0
  110. package/studio/web/styles/theme/identity/shell.css +10 -26
  111. package/tools/lib/agents.mjs +24 -13
  112. package/tools/lib/claude-settings.mjs +32 -3
  113. package/tools/lib/deps.mjs +24 -0
  114. package/tools/lib/platforms/instagram.mjs +1 -1
  115. package/tools/lib/up.mjs +5 -2
  116. package/tools/omni.mjs +7 -5
  117. package/tools/omnichat.mjs +5 -7
  118. package/tools/package.mjs +5 -3
  119. package/tools/publish.mjs +40 -137
  120. package/tools/reference.mjs +1 -1
  121. package/tools/setup.mjs +16 -15
  122. package/tools/sound.mjs +67 -3
@@ -0,0 +1,342 @@
1
+ /* An alert-volume card: a stack of four sources for every day (or week), and under the baseline the few that bounced.
2
+ Ported from bencho.dev (MIT).
3
+
4
+ Run along it: the column under the pointer gets a ring round its whole stack, the rest step back (their colour
5
+ mixed toward the slab, never faded), and one tooltip glides beside it on a spring with the date, the total and what
6
+ made it up. Day / Week swaps the dataset; every segment eases to its new height and offset a beat after its left
7
+ neighbour, and the same transition grows the bars out of the baseline the first time the card is seen. */
8
+
9
+ import { type CSSProperties, type PointerEvent, useEffect, useMemo, useRef, useState } from "react";
10
+ import { animate, motion, useMotionValue } from "motion/react";
11
+
12
+ const W = 300;
13
+ const PAD = 16;
14
+ const CW = W - PAD * 2; // the plot's width
15
+ const CH = 104; // room above the baseline
16
+ const BH = 22; // room below it for the bounced bars
17
+ const SEG_GAP = 2;
18
+ const LIFT = 2; // air between the baseline and the bars either side of it
19
+ const TIP_W = 132;
20
+ const SPRING = { type: "spring" as const, stiffness: 380, damping: 34 };
21
+
22
+ const CATS = ["Direto", "Busca", "Social", "E-mail"] as const;
23
+ const MONTHS = ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"];
24
+ const LAST = Date.UTC(2026, 9, 11); // the last day shown, Oct 11
25
+ const DAY_MS = 86400000;
26
+
27
+ type Mode = "Day" | "Week";
28
+ type Bar = { vals: number[]; esc: number; label: string; short: string };
29
+
30
+ const MODES: { id: Mode; label: string }[] = [
31
+ { id: "Day", label: "Dia" },
32
+ { id: "Week", label: "Semana" },
33
+ ];
34
+
35
+ /* a small seeded generator: the same chart on every load */
36
+ function mulberry(seed: number) {
37
+ let a = seed >>> 0;
38
+ return () => {
39
+ a = (a + 0x6d2b79f5) >>> 0;
40
+ let t = a;
41
+ t = Math.imul(t ^ (t >>> 15), t | 1);
42
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
43
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
44
+ };
45
+ }
46
+
47
+ const fmtDay = (ms: number) => {
48
+ const d = new Date(ms);
49
+ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
50
+ };
51
+
52
+ function makeBars(mode: Mode, n: number): Bar[] {
53
+ const rnd = mulberry(mode === "Day" ? 4396 : 9117);
54
+ const base = [520, 360, 210, 260];
55
+ const out: Bar[] = [];
56
+ for (let i = 0; i < 30; i++) {
57
+ /* only the last n are kept, so the data does not reshuffle with the count */
58
+ const back = 29 - i;
59
+ if (mode === "Day") {
60
+ const ms = LAST - back * DAY_MS;
61
+ const wd = new Date(ms).getUTCDay();
62
+ const quiet = wd === 0 || wd === 6 ? 0.55 : 1;
63
+ const surge = rnd() < 0.12 ? 1.5 : 1;
64
+ const vals = base.map((b, k) => Math.round(b * quiet * (0.6 + rnd() * 0.8) * (k === 3 ? surge * surge : surge)));
65
+ const esc = Math.round(3 + rnd() * 9 * quiet * surge);
66
+ out.push({ vals, esc, label: fmtDay(ms), short: fmtDay(ms) });
67
+ } else {
68
+ const end = LAST - back * 7 * DAY_MS;
69
+ const start = end - 6 * DAY_MS;
70
+ const vals = base.map(b => Math.round(b * 5.6 * (0.75 + rnd() * 0.5)));
71
+ const esc = Math.round(30 + rnd() * 44);
72
+ const s = new Date(start);
73
+ const e = new Date(end);
74
+ const label =
75
+ s.getUTCMonth() === e.getUTCMonth()
76
+ ? `${s.getUTCDate()} – ${fmtDay(end)}`
77
+ : `${fmtDay(start)} – ${fmtDay(end)}`;
78
+ out.push({ vals, esc, label, short: fmtDay(start) });
79
+ }
80
+ }
81
+ return out.slice(30 - n);
82
+ }
83
+
84
+ const fmt = (v: number) => Math.round(v).toLocaleString("pt-BR");
85
+ const sum = (a: number[]) => a.reduce((s, v) => s + v, 0);
86
+
87
+ export function StackedBars({
88
+ colour = "Colour",
89
+ bars = 20,
90
+ gap = 6,
91
+ }: {
92
+ colour?: "Mono" | "Colour";
93
+ bars?: number;
94
+ gap?: number;
95
+ }) {
96
+ const n = Math.max(2, Math.round(bars));
97
+ const [mode, setMode] = useState<Mode>("Day");
98
+ const [at, setAt] = useState<number | null>(null);
99
+ const [seen, setSeen] = useState(false);
100
+ const root = useRef<HTMLDivElement | null>(null);
101
+ const plot = useRef<HTMLDivElement | null>(null);
102
+
103
+ /* grow in once, the first time the card is on screen */
104
+ useEffect(() => {
105
+ const el = root.current;
106
+ if (!el || seen) return;
107
+ if (typeof IntersectionObserver === "undefined") {
108
+ setSeen(true);
109
+ return;
110
+ }
111
+ const io = new IntersectionObserver(
112
+ ([e]) => {
113
+ if (e.isIntersecting) {
114
+ setSeen(true);
115
+ io.disconnect();
116
+ }
117
+ },
118
+ { threshold: 0.3 },
119
+ );
120
+ io.observe(el);
121
+ return () => io.disconnect();
122
+ }, [seen]);
123
+
124
+ const data = useMemo(() => makeBars(mode, n), [mode, n]);
125
+
126
+ /* geometry: the gap gives way before a bar gets thinner than 3px, so 30 bars at the widest gap still draw */
127
+ const g = Math.max(1, Math.min(gap, (CW - 3 * n) / (n - 1)));
128
+ const bw = (CW - g * (n - 1)) / n;
129
+ const r = Math.min(2.5, bw / 2);
130
+ const totals = data.map(b => sum(b.vals));
131
+ const maxT = Math.max(...totals);
132
+ const maxE = Math.max(...data.map(b => b.esc));
133
+ const scale = (CH - LIFT - 2 - SEG_GAP * (CATS.length - 1)) / maxT;
134
+ const layout = data.map(b => {
135
+ let y = 0;
136
+ const segs = b.vals.map(v => {
137
+ const h = Math.max(2, v * scale);
138
+ const s = { bottom: y, h };
139
+ y += h + SEG_GAP;
140
+ return s;
141
+ });
142
+ return { segs, top: y - SEG_GAP, esc: Math.max(3, (b.esc / maxE) * (BH - LIFT - 2)) };
143
+ });
144
+
145
+ /* The headline pops in on a change: every character is keyed by a generation, so a new total is new elements and
146
+ each plays one short entrance, left to right. The first paint does not play it; only a change does. */
147
+ const total = sum(totals);
148
+ const [gen, setGen] = useState(0);
149
+ const last = useRef(total);
150
+ useEffect(() => {
151
+ if (last.current === total) return;
152
+ last.current = total;
153
+ setGen(g => g + 1);
154
+ }, [total]);
155
+
156
+ /* ring and tooltip: placed at once when they appear, glide after */
157
+ const ringX = useMotionValue(0);
158
+ const ringY = useMotionValue(0);
159
+ const ringH = useMotionValue(0);
160
+ const tipX = useMotionValue(0);
161
+ const was = useRef<number | null>(null);
162
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the motion values and `layout` follow from these
163
+ useEffect(() => {
164
+ if (at === null || at >= n) {
165
+ was.current = null;
166
+ return;
167
+ }
168
+ const L = layout[at];
169
+ const left = at * (bw + g);
170
+ const x = left - 3;
171
+ const up = seen ? L.top : 0;
172
+ const down = seen ? L.esc : 0;
173
+ const y = CH - LIFT - up - 3;
174
+ const h = up + LIFT + 1 + LIFT + down + 6;
175
+ /* beside the column: right while it fits there, else left */
176
+ const right = left + bw + 8;
177
+ const tx = right + TIP_W <= CW || left - 8 - TIP_W < 0 ? Math.min(CW - TIP_W, right) : left - 8 - TIP_W;
178
+ const go = (mv: typeof ringX, v: number) => {
179
+ if (was.current === null) mv.set(v);
180
+ else animate(mv, v, SPRING);
181
+ };
182
+ go(ringX, x);
183
+ go(ringY, y);
184
+ go(ringH, h);
185
+ go(tipX, tx);
186
+ was.current = at;
187
+ }, [at, n, bw, g, mode, seen]);
188
+
189
+ const pick = (e: PointerEvent) => {
190
+ const b = plot.current?.getBoundingClientRect();
191
+ if (!b || b.width === 0) return;
192
+ const i = Math.floor(((e.clientX - b.left) / b.width) * n);
193
+ setAt(Math.max(0, Math.min(n - 1, i)));
194
+ };
195
+
196
+ const cur = at !== null && at < n ? data[at] : null;
197
+ const top2 = cur
198
+ ? cur.vals
199
+ .map((v, k) => ({ v, k }))
200
+ .sort((a, b) => b.v - a.v)
201
+ .slice(0, 2)
202
+ : [];
203
+
204
+ return (
205
+ <div
206
+ ref={root}
207
+ className="bn-stb"
208
+ data-colour={colour === "Colour" ? "on" : "off"}
209
+ data-scrub={cur ? "" : undefined}
210
+ style={{ width: W, ["--stb-r" as string]: `${r}px`, ["--stb-rr" as string]: `${r + 3}px` } as CSSProperties}
211
+ >
212
+ <div className="bn-stb-head">
213
+ <div className="bn-stb-titles">
214
+ <div className="bn-stb-title">Tráfego</div>
215
+ <div className="bn-stb-sub">
216
+ Últimos {n} {mode === "Day" ? "dias" : "semanas"}
217
+ </div>
218
+ </div>
219
+ <div className="bn-stb-seg-ctl" data-mode={mode} role="tablist">
220
+ <span className="bn-stb-thumb" aria-hidden />
221
+ {MODES.map(m => (
222
+ <button
223
+ key={m.id}
224
+ type="button"
225
+ role="tab"
226
+ aria-selected={mode === m.id}
227
+ data-on={mode === m.id || undefined}
228
+ className="bn-stb-tab"
229
+ onClick={() => {
230
+ setMode(m.id);
231
+ }}
232
+ >
233
+ {m.label}
234
+ </button>
235
+ ))}
236
+ </div>
237
+ </div>
238
+
239
+ <div className="bn-stb-figure">
240
+ <span className="bn-stb-num" aria-label={fmt(total)}>
241
+ {fmt(total)
242
+ .split("")
243
+ .map((ch, i) => (
244
+ <span
245
+ // biome-ignore lint/suspicious/noArrayIndexKey: the key is the generation plus the position, on purpose
246
+ key={`${gen}-${i}`}
247
+ className="bn-stb-digit"
248
+ data-pop={gen > 0 || undefined}
249
+ style={{ ["--i" as string]: i } as CSSProperties}
250
+ aria-hidden
251
+ >
252
+ {ch}
253
+ </span>
254
+ ))}
255
+ </span>
256
+ <span className="bn-stb-unit">visitas</span>
257
+ </div>
258
+
259
+ <div
260
+ ref={plot}
261
+ className="bn-stb-plot"
262
+ style={{ height: CH + 1 + BH }}
263
+ onPointerDown={e => {
264
+ try {
265
+ e.currentTarget.setPointerCapture(e.pointerId);
266
+ } catch {
267
+ /* scripted */
268
+ }
269
+ pick(e);
270
+ }}
271
+ onPointerMove={pick}
272
+ onPointerUp={e => {
273
+ try {
274
+ e.currentTarget.releasePointerCapture(e.pointerId);
275
+ } catch {
276
+ /* scripted */
277
+ }
278
+ if (e.pointerType !== "mouse") setAt(null);
279
+ }}
280
+ onPointerCancel={() => setAt(null)}
281
+ onPointerLeave={() => setAt(null)}
282
+ >
283
+ <span className="bn-stb-base" style={{ top: CH }} />
284
+ {data.map((_, i) => (
285
+ <div
286
+ // biome-ignore lint/suspicious/noArrayIndexKey: columns are positional, a mode switch retargets them
287
+ key={i}
288
+ className="bn-stb-col"
289
+ data-on={at === i || undefined}
290
+ style={{ left: i * (bw + g), width: bw, ["--i" as string]: i } as CSSProperties}
291
+ >
292
+ {layout[i].segs.map((s, k) => (
293
+ <span
294
+ // biome-ignore lint/suspicious/noArrayIndexKey: one segment per source, in order
295
+ key={k}
296
+ className="bn-stb-bar"
297
+ style={
298
+ {
299
+ ["--c" as string]: `var(--stb-c${k + 1})`,
300
+ bottom: BH + 1 + LIFT + (seen ? s.bottom : 0),
301
+ height: seen ? s.h : 0,
302
+ } as CSSProperties
303
+ }
304
+ />
305
+ ))}
306
+ <span className="bn-stb-esc" style={{ top: CH + 1 + LIFT, height: seen ? layout[i].esc : 0 }} />
307
+ </div>
308
+ ))}
309
+
310
+ <motion.span className="bn-stb-ring" aria-hidden style={{ x: ringX, y: ringY, height: ringH, width: bw + 6 }} />
311
+
312
+ <motion.div className="bn-stb-tip" style={{ x: tipX, width: TIP_W }} aria-hidden={!cur}>
313
+ {cur && (
314
+ <>
315
+ <div className="bn-stb-tip-head">
316
+ <span>{cur.label}</span>
317
+ <b>{fmt(totals[at as number])}</b>
318
+ </div>
319
+ {top2.map(({ v, k }) => (
320
+ <div className="bn-stb-tip-row" key={CATS[k]}>
321
+ <i style={{ ["--c" as string]: `var(--stb-c${k + 1})` } as CSSProperties} />
322
+ <span>{CATS[k]}</span>
323
+ <b>{fmt(v)}</b>
324
+ </div>
325
+ ))}
326
+ <div className="bn-stb-tip-row" data-esc="">
327
+ <i />
328
+ <span>Rejeitados</span>
329
+ <b>{cur.esc}</b>
330
+ </div>
331
+ </>
332
+ )}
333
+ </motion.div>
334
+ </div>
335
+
336
+ <div className="bn-stb-axis">
337
+ <span>{data[0].short}</span>
338
+ <span>{data[n - 1].short}</span>
339
+ </div>
340
+ </div>
341
+ );
342
+ }
@@ -0,0 +1,263 @@
1
+ /* Three toasts at most, always: the newest in front, the two before it tucked behind with a sliver showing above, a
2
+ step smaller each. Point at the stack and it fans out into a list; move away and it gathers back up.
3
+ Ported from bencho.dev (MIT).
4
+
5
+ One spring, three places: a toast's place comes from how far back it is and whether the stack is open, and Motion
6
+ springs it there. Opening, closing, a toast leaving and the next arriving are the same move, on a spring a touch
7
+ under critical so it settles with a hint of give.
8
+ Hover is the whole frame: the stack opens about its middle, so hovering the toasts themselves would let the list
9
+ slide out from under the pointer and close again. Over/out with a containment test (not enter/leave) so a
10
+ synthetic pointer event with no relatedTarget opens and closes it too.
11
+
12
+ Two ways to run it. With no `toasts` it is the gallery demo: it never runs out, dismiss one and it slides away
13
+ while a new one rises in at the front from the pool. With `toasts` (lib/toast.ts, through components/Toaster.tsx)
14
+ it only draws what it is given: the owner of the list removes a toast on `onDismiss`, and the exit animation comes
15
+ from AnimatePresence, so a toast that times out leaves the same way as one that is dismissed. */
16
+
17
+ import { type CSSProperties, type ComponentType, useCallback, useEffect, useRef, useState } from "react";
18
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
19
+ import { CheckCircle, Info, Warning, X, XCircle } from "../icons";
20
+
21
+ export type ToastKind = "success" | "info" | "error" | "warning";
22
+ export type ToastView = {
23
+ id: string;
24
+ kind: ToastKind;
25
+ title: string;
26
+ body?: string;
27
+ action?: { label: string; onClick: () => void };
28
+ };
29
+
30
+ const ICON: Record<ToastKind, ComponentType<{ size?: number; weight?: "bold"; "aria-hidden"?: boolean }>> = {
31
+ success: CheckCircle,
32
+ info: Info,
33
+ error: XCircle,
34
+ warning: Warning,
35
+ };
36
+
37
+ const POOL: Omit<ToastView, "id" | "action">[] = [
38
+ { kind: "error", title: "Cartão recusado", body: "Use outro cartão." },
39
+ { kind: "info", title: "Kai entrou no seu projeto", body: "Pode comentar e copiar." },
40
+ { kind: "success", title: "brand-kit.zip enviado", body: "Salvo em Semana de lançamento." },
41
+ { kind: "warning", title: "Armazenamento em 92%", body: "As exportações param no limite." },
42
+ { kind: "success", title: "Vídeo salvo", body: "Está em Favoritos." },
43
+ ];
44
+
45
+ const W = 300;
46
+ const H = 64;
47
+ const SHOWN = 3;
48
+ const SPRING = { type: "spring", stiffness: 380, damping: 34, mass: 1 } as const;
49
+ const LEAVE_MS = 220;
50
+
51
+ type Props = {
52
+ /* what to draw, front first; leave it out for the gallery demo */
53
+ toasts?: ToastView[];
54
+ onDismiss?: (id: string) => void;
55
+ /* the stack opened or closed (hover or focus): the owner of the list holds the timers while it is open */
56
+ onOpenChange?: (open: boolean) => void;
57
+ /* "center": the closed stack sits in the middle of its frame, as in the gallery; "end": it hangs from the bottom
58
+ edge, so the front toast never moves when the stack opens (a corner of the screen) */
59
+ align?: "center" | "end";
60
+ /* a soft shadow and a hairline, for a stack that floats over a page instead of sitting on a card */
61
+ floating?: boolean;
62
+ peek?: number;
63
+ gap?: number;
64
+ corner?: number;
65
+ className?: string;
66
+ };
67
+
68
+ export function ToastStack({
69
+ toasts,
70
+ onDismiss,
71
+ onOpenChange,
72
+ align = "center",
73
+ floating,
74
+ peek = 14,
75
+ gap = 10,
76
+ corner = 20,
77
+ className,
78
+ }: Props) {
79
+ const demo = useDemoToasts(!toasts);
80
+ const list = (toasts ?? demo.toasts).slice(0, SHOWN);
81
+ const dismiss = toasts ? onDismiss : demo.dismiss;
82
+ const reduce = useReducedMotion();
83
+ const [open, setOpen] = useState(false);
84
+ const root = useRef<HTMLDivElement | null>(null);
85
+
86
+ const change = useCallback(
87
+ (v: boolean) => {
88
+ setOpen(v);
89
+ onOpenChange?.(v);
90
+ },
91
+ [onOpenChange],
92
+ );
93
+
94
+ /* the toast under the pointer can go away (dismissed, timed out) and take the pointer-out with it: while open,
95
+ any move outside the stack closes it, and an empty stack is closed */
96
+ const empty = list.length === 0;
97
+ useEffect(() => {
98
+ if (!open) return;
99
+ if (empty) {
100
+ change(false);
101
+ return;
102
+ }
103
+ const away = (e: PointerEvent) => {
104
+ if (!root.current?.contains(e.target as Node | null)) change(false);
105
+ };
106
+ document.addEventListener("pointermove", away);
107
+ return () => document.removeEventListener("pointermove", away);
108
+ }, [open, empty, change]);
109
+
110
+ /* the frame is the open list's size; the toasts hang from its bottom edge, so a toast arriving or leaving never
111
+ shifts the others, it only grows the frame upward */
112
+ const n = Math.max(list.length, 1);
113
+ const listH = n * H + (n - 1) * gap;
114
+ const stackH = H + (n - 1) * peek;
115
+ const FH = listH;
116
+
117
+ /* y is measured up from the bottom edge, where every toast rests */
118
+ const place = (k: number) => {
119
+ if (open) return { y: -k * (H + gap), scale: 1 };
120
+ /* the stack: front at the bottom, each one behind a peek higher and a step smaller, hanging from its top edge */
121
+ const d = Math.min(k, SHOWN - 1);
122
+ const slack = align === "center" ? (FH - stackH) / 2 : 0;
123
+ return { y: -d * peek - slack, scale: 1 - d * 0.05 };
124
+ };
125
+
126
+ return (
127
+ <div
128
+ ref={root}
129
+ className={`bn-tst${className ? ` ${className}` : ""}`}
130
+ role="region"
131
+ aria-label="Notificações"
132
+ data-open={open || undefined}
133
+ data-floating={floating || undefined}
134
+ style={
135
+ {
136
+ width: `min(${W}px, calc(100vw - 32px))`,
137
+ height: FH,
138
+ "--tst-r": `${corner}px`,
139
+ "--tst-gap": `${gap}px`,
140
+ } as CSSProperties
141
+ }
142
+ onPointerOver={() => !open && !empty && change(true)}
143
+ onPointerOut={e => {
144
+ const to = e.relatedTarget as Node | null;
145
+ if (!to || !root.current?.contains(to)) change(false);
146
+ }}
147
+ onFocus={() => !open && !empty && change(true)}
148
+ onBlur={e => {
149
+ if (!root.current?.contains(e.relatedTarget as Node | null)) change(false);
150
+ }}
151
+ >
152
+ <AnimatePresence>
153
+ {list.map((t, k) => {
154
+ const Icon = ICON[t.kind];
155
+ const p = place(k);
156
+ const front = k === 0;
157
+ return (
158
+ <motion.div
159
+ key={t.id}
160
+ className="bn-tst-toast"
161
+ data-kind={t.kind}
162
+ data-front={front || undefined}
163
+ style={{ height: H, zIndex: SHOWN - k }}
164
+ /* a new one rises in from below the front; one coming up from the queue behind just fades in */
165
+ initial={{ y: front && !reduce ? p.y + 24 : p.y, scale: p.scale, opacity: 0, x: 0 }}
166
+ animate={{ y: p.y, scale: p.scale, opacity: 1, x: 0 }}
167
+ exit={
168
+ reduce
169
+ ? { opacity: 0, transition: { duration: 0.12 } }
170
+ : { opacity: 0, x: 60, transition: { duration: LEAVE_MS / 1000, ease: [0.4, 0, 1, 1] } }
171
+ }
172
+ transition={reduce ? { duration: 0 } : SPRING}
173
+ /* an error interrupts the reader; the rest wait their turn */
174
+ role={t.kind === "error" ? "alert" : "status"}
175
+ aria-hidden={!open && !front ? true : undefined}
176
+ >
177
+ <span className="bn-tst-icon">
178
+ <Icon size={18} weight="bold" aria-hidden />
179
+ </span>
180
+ <span className="bn-tst-text" title={t.body && t.body.length > 60 ? `${t.title}: ${t.body}` : undefined}>
181
+ <b>{t.title}</b>
182
+ {t.body && <span>{t.body}</span>}
183
+ </span>
184
+ {t.action && (
185
+ <button
186
+ type="button"
187
+ className="bn-tst-act"
188
+ tabIndex={open || front ? 0 : -1}
189
+ onClick={t.action.onClick}
190
+ >
191
+ {t.action.label}
192
+ </button>
193
+ )}
194
+ <button
195
+ type="button"
196
+ className="bn-tst-x"
197
+ aria-label={`Dispensar ${t.title}`}
198
+ tabIndex={open || front ? 0 : -1}
199
+ onClick={() => dismiss?.(t.id)}
200
+ >
201
+ <X size={16} weight="bold" aria-hidden />
202
+ </button>
203
+ </motion.div>
204
+ );
205
+ })}
206
+ </AnimatePresence>
207
+ </div>
208
+ );
209
+ }
210
+
211
+ /* The gallery's pool of toasts: three at a time, a dismissed one replaced from the pool once it has left. Idle (and
212
+ empty) when the stack is driven by a real list. */
213
+ function useDemoToasts(active: boolean) {
214
+ /* ids count up so a returning toast is a new element */
215
+ const next = useRef(SHOWN);
216
+ const [toasts, setToasts] = useState<ToastView[]>([]);
217
+ const timers = useRef(new Set<number>());
218
+
219
+ /* Retry on the failed payment turns that toast into the payment going through, in place */
220
+ const retry = useCallback((id: string) => {
221
+ setToasts(s =>
222
+ s.map(t =>
223
+ t.id === id
224
+ ? { ...t, kind: "success", title: "Pagamento concluído", body: "Seu cartão foi cobrado.", action: undefined }
225
+ : t,
226
+ ),
227
+ );
228
+ }, []);
229
+
230
+ const make = useCallback(
231
+ (n: number): ToastView => {
232
+ const t = POOL[n % POOL.length];
233
+ const id = `demo-${n}`;
234
+ return { ...t, id, action: t.kind === "error" ? { label: "Repetir", onClick: () => retry(id) } : undefined };
235
+ },
236
+ [retry],
237
+ );
238
+
239
+ useEffect(() => {
240
+ if (!active) return;
241
+ setToasts(Array.from({ length: SHOWN }, (_, i) => make(i)));
242
+ const pending = timers.current;
243
+ return () => {
244
+ for (const t of pending) window.clearTimeout(t);
245
+ pending.clear();
246
+ };
247
+ }, [active, make]);
248
+
249
+ const dismiss = useCallback(
250
+ (id: string) => {
251
+ setToasts(s => s.filter(t => t.id !== id));
252
+ /* the replacement waits for the old one to be gone, then rises in at the front */
253
+ const t = window.setTimeout(() => {
254
+ timers.current.delete(t);
255
+ setToasts(s => [make(next.current++), ...s]);
256
+ }, LEAVE_MS);
257
+ timers.current.add(t);
258
+ },
259
+ [make],
260
+ );
261
+
262
+ return { toasts, dismiss };
263
+ }