create-omg 0.4.30

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 (38) hide show
  1. package/README.md +14 -0
  2. package/dist/index.mjs +100 -0
  3. package/dist/template/AGENTS.md +49 -0
  4. package/dist/template/README.md +18 -0
  5. package/dist/template/_gitignore +24 -0
  6. package/dist/template/bun.lock +898 -0
  7. package/dist/template/eslint.config.js +23 -0
  8. package/dist/template/functions/.gitkeep +0 -0
  9. package/dist/template/index.html +20 -0
  10. package/dist/template/package.json +48 -0
  11. package/dist/template/public/favicon.svg +1 -0
  12. package/dist/template/public/icons/apple-touch-icon.png +0 -0
  13. package/dist/template/public/icons/pwa-192x192.png +0 -0
  14. package/dist/template/public/icons/pwa-512x512-maskable.png +0 -0
  15. package/dist/template/public/icons/pwa-512x512.png +0 -0
  16. package/dist/template/public/icons.svg +24 -0
  17. package/dist/template/schema.ts +5 -0
  18. package/dist/template/server/db.ts +103 -0
  19. package/dist/template/src/App.tsx +128 -0
  20. package/dist/template/src/components/ui/animated-number.tsx +102 -0
  21. package/dist/template/src/components/ui/animated-text.tsx +109 -0
  22. package/dist/template/src/components/ui/bottom-nav.tsx +131 -0
  23. package/dist/template/src/components/ui/button.tsx +84 -0
  24. package/dist/template/src/components/ui/card.tsx +70 -0
  25. package/dist/template/src/components/ui/otp-input.tsx +179 -0
  26. package/dist/template/src/components/ui/scroll-affordance.tsx +106 -0
  27. package/dist/template/src/components/ui/sound.ts +182 -0
  28. package/dist/template/src/components/ui/transitions.tsx +419 -0
  29. package/dist/template/src/components/ui/vibes-ui-styles.ts +388 -0
  30. package/dist/template/src/db.drizzle.ts +5 -0
  31. package/dist/template/src/index.css +1 -0
  32. package/dist/template/src/lib/utils.ts +6 -0
  33. package/dist/template/src/main.tsx +10 -0
  34. package/dist/template/tsconfig.app.json +30 -0
  35. package/dist/template/tsconfig.json +10 -0
  36. package/dist/template/tsconfig.node.json +24 -0
  37. package/dist/template/vite.config.ts +23 -0
  38. package/package.json +37 -0
@@ -0,0 +1,182 @@
1
+ // useSound / playSound — the AUDIO half of the delight kit (its haptic-ish
2
+ // sibling to the visual `transitions` / `AnimatedNumber` primitives).
3
+ //
4
+ // Every sound is SYNTHESIZED at runtime with the Web Audio API — there are no
5
+ // `.mp3`/`.wav` files to ship, fetch, or decode. The whole thing is a few
6
+ // oscillators and a gain envelope, so it adds ~0kb of assets and a couple kb of
7
+ // code, and it themes to nothing (pure tones) so it drops into any app.
8
+ //
9
+ // Design rules baked in, so a button that opts into sound never feels cheap:
10
+ // - One shared, lazily-created AudioContext, resumed on the first gesture.
11
+ // Browsers block audio until a user interaction, and a button press IS one,
12
+ // so the first click both unlocks and plays.
13
+ // - Silently no-ops when Web Audio is unavailable (SSR, old browsers) or when
14
+ // sound is disabled — exactly like the haptics singleton. It never throws.
15
+ // - Respects `prefers-reduced-motion`: a user who asked for a calmer, quieter
16
+ // experience gets silence unless the app explicitly forces sound on.
17
+ // - Short, quiet, rounded envelopes (soft attack + exponential release) so
18
+ // presses read as a tactile "tick", never a harsh beep.
19
+ //
20
+ // Self-contained: no CSS, no Tailwind, no runtime dependency beyond React.
21
+ // Edit the PRESETS table freely — it's vendored into your project.
22
+
23
+ import { useCallback, useMemo } from "react"
24
+
25
+ /** Built-in synthesized sounds. Pass one of these names to `play`. */
26
+ export type SoundName =
27
+ | "tap" // soft, short press tick — the default for buttons
28
+ | "click" // crisper, slightly louder press
29
+ | "toggleOn" // rising two-tone — switch/checkbox turning on
30
+ | "toggleOff" // falling two-tone — switch/checkbox turning off
31
+ | "success" // pleasant rising major third — save/confirm
32
+ | "error" // low, buzzy descending — invalid/destructive
33
+ | "notify" // gentle two-note chime — a new item/notification
34
+
35
+ type Tone = {
36
+ /** Oscillator frequency in Hz (or the START of a glide, with `to`). */
37
+ freq: number
38
+ /** Optional glide target — the pitch ramps from `freq` to `to`. */
39
+ to?: number
40
+ /** How long the tone rings, in seconds. */
41
+ duration: number
42
+ /** Peak gain (0–1). Kept low so sounds stay tactile, not startling. */
43
+ gain?: number
44
+ /** Oscillator shape. `sine`/`triangle` are soft; `square`/`sawtooth` bite. */
45
+ type?: OscillatorType
46
+ /** Delay before this tone starts, in seconds (for two-note sequences). */
47
+ delay?: number
48
+ }
49
+
50
+ // Each preset is one or more tones layered/sequenced. Frequencies are chosen
51
+ // to sit in a musical relationship (unison, thirds, fifths) so multi-tone
52
+ // sounds read as "nice" rather than random.
53
+ const PRESETS: Record<SoundName, Tone[]> = {
54
+ tap: [{ freq: 660, duration: 0.05, gain: 0.05, type: "sine" }],
55
+ click: [{ freq: 880, duration: 0.045, gain: 0.07, type: "triangle" }],
56
+ toggleOn: [
57
+ { freq: 587.33, duration: 0.06, gain: 0.05, type: "sine" },
58
+ { freq: 880, duration: 0.07, gain: 0.05, type: "sine", delay: 0.045 },
59
+ ],
60
+ toggleOff: [
61
+ { freq: 880, duration: 0.06, gain: 0.05, type: "sine" },
62
+ { freq: 587.33, duration: 0.07, gain: 0.05, type: "sine", delay: 0.045 },
63
+ ],
64
+ success: [
65
+ { freq: 523.25, duration: 0.08, gain: 0.05, type: "sine" },
66
+ { freq: 659.25, duration: 0.12, gain: 0.05, type: "sine", delay: 0.07 },
67
+ ],
68
+ error: [{ freq: 200, to: 120, duration: 0.16, gain: 0.06, type: "sawtooth" }],
69
+ notify: [
70
+ { freq: 784, duration: 0.08, gain: 0.045, type: "triangle" },
71
+ { freq: 1046.5, duration: 0.12, gain: 0.045, type: "triangle", delay: 0.06 },
72
+ ],
73
+ }
74
+
75
+ let ctx: AudioContext | null = null
76
+ let enabled = true
77
+
78
+ function prefersReducedMotion(): boolean {
79
+ return (
80
+ typeof window !== "undefined" &&
81
+ typeof window.matchMedia === "function" &&
82
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
83
+ )
84
+ }
85
+
86
+ function getContext(): AudioContext | null {
87
+ if (typeof window === "undefined") return null
88
+ const Ctor: typeof AudioContext | undefined =
89
+ window.AudioContext ??
90
+ (window as unknown as { webkitAudioContext?: typeof AudioContext })
91
+ .webkitAudioContext
92
+ if (!Ctor) return null
93
+ if (!ctx) {
94
+ try {
95
+ ctx = new Ctor()
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+ // A context created before a gesture starts `suspended`; a gesture-driven
101
+ // call resumes it. Fire-and-forget — if it's blocked, playback just no-ops.
102
+ if (ctx.state === "suspended") void ctx.resume().catch(() => {})
103
+ return ctx
104
+ }
105
+
106
+ function ring(context: AudioContext, tone: Tone, startAt: number) {
107
+ const osc = context.createOscillator()
108
+ const amp = context.createGain()
109
+ const peak = tone.gain ?? 0.05
110
+ const t0 = startAt + (tone.delay ?? 0)
111
+ const t1 = t0 + tone.duration
112
+
113
+ osc.type = tone.type ?? "sine"
114
+ osc.frequency.setValueAtTime(tone.freq, t0)
115
+ if (tone.to != null) osc.frequency.exponentialRampToValueAtTime(tone.to, t1)
116
+
117
+ // Soft attack, exponential release — a rounded "tick", not a click artifact.
118
+ amp.gain.setValueAtTime(0.0001, t0)
119
+ amp.gain.exponentialRampToValueAtTime(peak, t0 + 0.008)
120
+ amp.gain.exponentialRampToValueAtTime(0.0001, t1)
121
+
122
+ osc.connect(amp).connect(context.destination)
123
+ osc.start(t0)
124
+ osc.stop(t1 + 0.02)
125
+ }
126
+
127
+ /**
128
+ * Play a synthesized sound. Safe to call anywhere: silently no-ops on unsupported
129
+ * platforms, when sound is disabled, or under `prefers-reduced-motion`. Best
130
+ * called from within a user gesture (a click/press handler) so the browser lets
131
+ * audio through.
132
+ *
133
+ * You can also pass an ad-hoc tone (or array of tones) instead of a preset name
134
+ * to synthesize a one-off sound.
135
+ */
136
+ export function playSound(sound: SoundName | Tone | Tone[]): void {
137
+ if (!enabled || prefersReducedMotion()) return
138
+ const context = getContext()
139
+ if (!context) return
140
+ const tones =
141
+ typeof sound === "string" ? PRESETS[sound] : Array.isArray(sound) ? sound : [sound]
142
+ if (!tones || tones.length === 0) return
143
+ const now = context.currentTime
144
+ try {
145
+ for (const tone of tones) ring(context, tone, now)
146
+ } catch {
147
+ // AudioContext can throw if it was closed out from under us — ignore.
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Globally enable or disable all synthesized sound (e.g. a settings toggle).
153
+ * Defaults to enabled. Disabling makes every `playSound`/`useSound().play` a
154
+ * no-op without changing any call sites.
155
+ */
156
+ export function setSoundEnabled(next: boolean): void {
157
+ enabled = next
158
+ }
159
+
160
+ /** Whether synthesized sound is currently enabled (ignores reduced-motion). */
161
+ export function isSoundEnabled(): boolean {
162
+ return enabled
163
+ }
164
+
165
+ /**
166
+ * Hook wrapper. Returns a stable `play` you can drop into event handlers, plus
167
+ * the enable/disable controls. `play` defaults to the `"tap"` press tick.
168
+ *
169
+ * ```tsx
170
+ * const { play } = useSound()
171
+ * <Button onClick={() => { play("success"); save() }}>Save</Button>
172
+ * ```
173
+ */
174
+ export function useSound() {
175
+ const play = useCallback((sound: SoundName | Tone | Tone[] = "tap") => {
176
+ playSound(sound)
177
+ }, [])
178
+ return useMemo(
179
+ () => ({ play, setEnabled: setSoundEnabled, isEnabled: isSoundEnabled }),
180
+ [play],
181
+ )
182
+ }
@@ -0,0 +1,419 @@
1
+ // Transitions — dependency-free, CSS-only micro-interactions.
2
+ //
3
+ // Vendored from transitions.dev (Jakub Antalík), editable source. The motion
4
+ // lives in the injected `.vui-t-*` stylesheet (see vibes-ui-styles.ts); these
5
+ // React wrappers just own the awkward bits — flipping `.is-open` AFTER first
6
+ // paint so the open transition actually runs, keeping a closing element mounted
7
+ // until its exit finishes, and the three-phase reflow dance for swapping text.
8
+ // Self-contained: no Tailwind utility classes, no motion library, no runtime
9
+ // dependency beyond React. Only transform/opacity/filter animate, and every
10
+ // family honors prefers-reduced-motion.
11
+ //
12
+ // Reach for these when the DELIGHT rule "motion should REDUCE perceived travel"
13
+ // applies: a dialog, a menu, a drawer, a wizard step, or any element/label that
14
+ // swaps. Don't pull in Framer Motion for these — and don't wrap them in
15
+ // <AnimatePresence>; the two systems fight over the same element.
16
+
17
+ import {
18
+ useEffect,
19
+ useRef,
20
+ useState,
21
+ type CSSProperties,
22
+ type ReactNode,
23
+ } from "react"
24
+ import { flushSync } from "react-dom"
25
+ import { ensureStyles } from "@/components/ui/vibes-ui-styles"
26
+
27
+ function cx(...parts: Array<string | false | null | undefined>): string {
28
+ return parts.filter(Boolean).join(" ")
29
+ }
30
+
31
+ /**
32
+ * Mount/show lifecycle for an open/close element. Returns `mounted` (keep it in
33
+ * the DOM through the exit transition) and `show` (drives `.is-open`). On open
34
+ * it waits two animation frames before flipping `show`, so the element first
35
+ * paints in its closed state and the open transition has somewhere to travel
36
+ * from; on close it holds the element mounted for `closeMs` while `.is-closing`
37
+ * plays, then unmounts it.
38
+ */
39
+ function usePresence(open: boolean, closeMs: number) {
40
+ const [mounted, setMounted] = useState(open)
41
+ const [show, setShow] = useState(open)
42
+
43
+ useEffect(() => {
44
+ if (open) {
45
+ setMounted(true)
46
+ let raf2 = 0
47
+ const raf1 = requestAnimationFrame(() => {
48
+ raf2 = requestAnimationFrame(() => setShow(true))
49
+ })
50
+ return () => {
51
+ cancelAnimationFrame(raf1)
52
+ cancelAnimationFrame(raf2)
53
+ }
54
+ }
55
+ setShow(false)
56
+ const timer = setTimeout(() => setMounted(false), closeMs)
57
+ return () => clearTimeout(timer)
58
+ }, [open, closeMs])
59
+
60
+ return { mounted, show }
61
+ }
62
+
63
+ // ── Modal ──────────────────────────────────────────────────────────────────
64
+
65
+ export type ModalProps = {
66
+ /** Whether the dialog is open. */
67
+ open: boolean
68
+ /** Called when the backdrop is clicked (wire it to your close handler). */
69
+ onClose?: () => void
70
+ children: ReactNode
71
+ /** Render a fading scrim behind the dialog. Default `true`. */
72
+ backdrop?: boolean
73
+ className?: string
74
+ style?: CSSProperties
75
+ }
76
+
77
+ /**
78
+ * Centered dialog that scales + fades in, and (faster) back out. Stays mounted
79
+ * through the close transition, then unmounts itself. Renders an optional
80
+ * click-to-close backdrop. Style the dialog surface via `className` — bring your
81
+ * own `Card` or padding/background.
82
+ */
83
+ export function Modal({
84
+ open,
85
+ onClose,
86
+ children,
87
+ backdrop = true,
88
+ className = "",
89
+ style,
90
+ }: ModalProps) {
91
+ useEffect(ensureStyles, [])
92
+ const { mounted, show } = usePresence(open, 150)
93
+ if (!mounted) return null
94
+ const state = show ? "is-open" : "is-closing"
95
+
96
+ return (
97
+ <>
98
+ {backdrop ? (
99
+ <div
100
+ className={cx("vui", "vui-t-backdrop", state)}
101
+ onClick={onClose}
102
+ aria-hidden="true"
103
+ />
104
+ ) : null}
105
+ <div
106
+ className={cx("vui", "vui-t-modal", state, className)}
107
+ style={style}
108
+ role="dialog"
109
+ aria-modal="true"
110
+ >
111
+ {children}
112
+ </div>
113
+ </>
114
+ )
115
+ }
116
+
117
+ // ── Menu ─────────────────────────────────────────────────────────────────────
118
+
119
+ export type MenuOrigin =
120
+ | "top-left"
121
+ | "top-center"
122
+ | "top-right"
123
+ | "bottom-left"
124
+ | "bottom-center"
125
+ | "bottom-right"
126
+
127
+ export type MenuProps = {
128
+ /** Whether the menu is open. */
129
+ open: boolean
130
+ children: ReactNode
131
+ /** Corner the menu scales out from — match it to the trigger. Default `top-left`. */
132
+ origin?: MenuOrigin
133
+ className?: string
134
+ style?: CSSProperties
135
+ }
136
+
137
+ /**
138
+ * Popover/dropdown that scales from its anchor corner. Position the element
139
+ * yourself (the caller owns layout — e.g. an absolutely-positioned wrapper under
140
+ * the trigger); this only animates open/close and unmounts when closed.
141
+ */
142
+ export function Menu({
143
+ open,
144
+ children,
145
+ origin = "top-left",
146
+ className = "",
147
+ style,
148
+ }: MenuProps) {
149
+ useEffect(ensureStyles, [])
150
+ const { mounted, show } = usePresence(open, 150)
151
+ if (!mounted) return null
152
+
153
+ return (
154
+ <div
155
+ className={cx("vui", "vui-t-menu", show ? "is-open" : "is-closing", className)}
156
+ data-origin={origin}
157
+ style={style}
158
+ role="menu"
159
+ >
160
+ {children}
161
+ </div>
162
+ )
163
+ }
164
+
165
+ // ── Panel ────────────────────────────────────────────────────────────────────
166
+
167
+ export type PanelProps = {
168
+ /** Whether the panel is revealed. */
169
+ open: boolean
170
+ children: ReactNode
171
+ className?: string
172
+ style?: CSSProperties
173
+ }
174
+
175
+ /**
176
+ * Inline reveal — a drawer, sheet, or expander that slides up, fades, and
177
+ * un-blurs into place. Stays mounted in both states (so it can clip inside an
178
+ * `overflow: hidden` container); set `--vui-t-panel-y` to ~half the panel
179
+ * height via `style` for a fuller travel.
180
+ */
181
+ export function Panel({ open, children, className = "", style }: PanelProps) {
182
+ useEffect(ensureStyles, [])
183
+ return (
184
+ <div
185
+ className={cx("vui", "vui-t-panel", className)}
186
+ data-open={open ? "true" : "false"}
187
+ style={style}
188
+ >
189
+ {children}
190
+ </div>
191
+ )
192
+ }
193
+
194
+ // ── Expand ───────────────────────────────────────────────────────────────────
195
+
196
+ export type ExpandProps = {
197
+ /** Whether the region is expanded. */
198
+ open: boolean
199
+ children: ReactNode
200
+ className?: string
201
+ style?: CSSProperties
202
+ }
203
+
204
+ /**
205
+ * Height-auto reveal that animates smoothly WITHOUT measuring the DOM — an
206
+ * accordion row, a disclosure, a "show more" section, a collapsible filter
207
+ * panel. Built on the `grid-template-rows: 0fr → 1fr` trick, so it interpolates
208
+ * to the content's natural height (no fixed `max-height` guesswork, no JS
209
+ * measurement, no clipping when the content grows). Stays mounted in both
210
+ * states and clips its overflow while collapsed. Reach for this instead of
211
+ * toggling `hidden`/`display:none` (which snaps) or hand-rolling a `max-height`
212
+ * hack. For an overlay drawer/sheet that floats above content use `Panel`; this
213
+ * one pushes surrounding layout as it opens.
214
+ */
215
+ export function Expand({ open, children, className = "", style }: ExpandProps) {
216
+ useEffect(ensureStyles, [])
217
+ return (
218
+ <div
219
+ className={cx("vui", "vui-t-expand", className)}
220
+ data-open={open ? "true" : "false"}
221
+ style={style}
222
+ >
223
+ <div className="vui-t-expand-inner">{children}</div>
224
+ </div>
225
+ )
226
+ }
227
+
228
+ // ── animateView (View Transitions) ─────────────────────────────────────────────
229
+
230
+ /**
231
+ * Run a state update inside a native View Transition so the browser cross-fades
232
+ * / morphs between the before and after DOM — list reorders, a filter that adds
233
+ * or removes rows, a route/tab swap, an item moving between two lists. Give the
234
+ * elements that should morph a stable `viewTransitionName` (via `style`) and the
235
+ * browser tweens their position/size for you.
236
+ *
237
+ * Uses `document.startViewTransition` with `flushSync` so React commits the
238
+ * update synchronously inside the transition callback. Degrades gracefully: if
239
+ * the API is unsupported OR the user prefers reduced motion, the update is
240
+ * applied immediately with no animation. Safe to call from any event handler.
241
+ *
242
+ * animateView(() => setItems(reorder(items)))
243
+ */
244
+ export function animateView(update: () => void): void {
245
+ const doc = document as Document & {
246
+ startViewTransition?: (cb: () => void | Promise<void>) => unknown
247
+ }
248
+ const reducedMotion =
249
+ typeof window !== "undefined" &&
250
+ typeof window.matchMedia === "function" &&
251
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
252
+
253
+ if (typeof doc.startViewTransition !== "function" || reducedMotion) {
254
+ update()
255
+ return
256
+ }
257
+ doc.startViewTransition(() => flushSync(update))
258
+ }
259
+
260
+ // ── PageSlide ────────────────────────────────────────────────────────────────
261
+
262
+ export type PageSlideProps = {
263
+ /** Active page index (0-based). */
264
+ page: number
265
+ /** One node per page, in order. Pages before `page` exit left, after exit right. */
266
+ pages: ReactNode[]
267
+ className?: string
268
+ style?: CSSProperties
269
+ }
270
+
271
+ /**
272
+ * Side-by-side wizard / tab steps. Only the active page is interactive; the
273
+ * others sit stacked, blurred, and shifted toward the side they came from, so
274
+ * forward/back reads as horizontal travel. Keeps every page mounted.
275
+ */
276
+ export function PageSlide({ page, pages, className = "", style }: PageSlideProps) {
277
+ useEffect(ensureStyles, [])
278
+ return (
279
+ <div className={cx("vui", "vui-t-pages", className)} style={style}>
280
+ {pages.map((node, i) => (
281
+ <div
282
+ key={i}
283
+ className={cx("vui-t-page", i === page && "is-active")}
284
+ style={
285
+ {
286
+ "--vui-t-page-x":
287
+ i < page
288
+ ? "calc(var(--vui-t-page-dist, 8px) * -1)"
289
+ : "var(--vui-t-page-dist, 8px)",
290
+ } as CSSProperties
291
+ }
292
+ aria-hidden={i === page ? undefined : true}
293
+ >
294
+ {node}
295
+ </div>
296
+ ))}
297
+ </div>
298
+ )
299
+ }
300
+
301
+ // ── IconSwap ─────────────────────────────────────────────────────────────────
302
+
303
+ export type IconSwapProps = {
304
+ /** Which icon is showing. */
305
+ active: "a" | "b"
306
+ /** First icon (shown when `active === "a"`). */
307
+ a: ReactNode
308
+ /** Second icon (shown when `active === "b"`). */
309
+ b: ReactNode
310
+ className?: string
311
+ style?: CSSProperties
312
+ }
313
+
314
+ /**
315
+ * Cross-fade + scale between two stacked icons (play/pause, moon/sun,
316
+ * copy/check). Both icons render at once, sharing a grid cell; the inactive one
317
+ * fades, blurs, and shrinks away.
318
+ */
319
+ export function IconSwap({ active, a, b, className = "", style }: IconSwapProps) {
320
+ useEffect(ensureStyles, [])
321
+ return (
322
+ <span
323
+ className={cx("vui", "vui-t-icon-swap", className)}
324
+ data-state={active}
325
+ style={style}
326
+ >
327
+ <span className="vui-t-icon" data-icon="a" aria-hidden={active !== "a"}>
328
+ {a}
329
+ </span>
330
+ <span className="vui-t-icon" data-icon="b" aria-hidden={active !== "b"}>
331
+ {b}
332
+ </span>
333
+ </span>
334
+ )
335
+ }
336
+
337
+ // ── TextSwap ─────────────────────────────────────────────────────────────────
338
+
339
+ export type TextSwapProps = {
340
+ /** The current text. Changing it animates the old out (up) and the new in (from below). */
341
+ children: string
342
+ className?: string
343
+ style?: CSSProperties
344
+ }
345
+
346
+ /**
347
+ * Swap a short label/status string in place: the old text lifts up + blurs +
348
+ * fades, then the new text rises from below. Handy for state words ("Saving…" →
349
+ * "Saved", "Copy" → "Copied"). For runtime numbers reach for `AnimatedNumber`,
350
+ * and for rolling slot-text reach for `AnimatedText`; this is for arbitrary
351
+ * label replacement.
352
+ */
353
+ export function TextSwap({ children, className = "", style }: TextSwapProps) {
354
+ useEffect(ensureStyles, [])
355
+ const ref = useRef<HTMLSpanElement | null>(null)
356
+ const [text, setText] = useState(children)
357
+ const prev = useRef(children)
358
+
359
+ useEffect(() => {
360
+ const el = ref.current
361
+ if (!el || children === prev.current) return
362
+ const next = children
363
+ prev.current = next
364
+
365
+ el.classList.add("is-exit")
366
+ const dur =
367
+ parseFloat(getComputedStyle(el).getPropertyValue("--vui-t-text-dur")) || 200
368
+ const timer = setTimeout(() => {
369
+ setText(next)
370
+ el.classList.remove("is-exit")
371
+ el.classList.add("is-enter-start")
372
+ // Force reflow so the jump-below state paints before we release it.
373
+ void el.offsetWidth
374
+ el.classList.remove("is-enter-start")
375
+ }, dur)
376
+ return () => clearTimeout(timer)
377
+ }, [children])
378
+
379
+ return (
380
+ <span ref={ref} className={cx("vui", "vui-t-text", className)} style={style}>
381
+ {text}
382
+ </span>
383
+ )
384
+ }
385
+
386
+ // ── NotificationBadge ────────────────────────────────────────────────────────
387
+
388
+ export type NotificationBadgeProps = {
389
+ /** Whether the badge is shown. Toggling pops it in / out. */
390
+ show: boolean
391
+ /** Badge contents (a count, a dot). */
392
+ children?: ReactNode
393
+ className?: string
394
+ style?: CSSProperties
395
+ }
396
+
397
+ /**
398
+ * A count/dot that pops in with a slight overshoot and shrinks-blurs out. Place
399
+ * it inside a `position: relative` trigger (e.g. a bell button); it pins to the
400
+ * top-right corner and never intercepts pointer events.
401
+ */
402
+ export function NotificationBadge({
403
+ show,
404
+ children,
405
+ className = "",
406
+ style,
407
+ }: NotificationBadgeProps) {
408
+ useEffect(ensureStyles, [])
409
+ return (
410
+ <span
411
+ className={cx("vui", "vui-t-badge", className)}
412
+ data-open={show ? "true" : "false"}
413
+ style={style}
414
+ aria-hidden={!show}
415
+ >
416
+ <span className="vui-t-badge-dot">{children}</span>
417
+ </span>
418
+ )
419
+ }