create-omg 0.4.41 → 0.4.43

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 (81) hide show
  1. package/README.md +16 -6
  2. package/dist/index.mjs +71 -12
  3. package/dist/{template → templates/react-ts}/AGENTS.md +22 -0
  4. package/dist/templates/react-ts/_gitignore +24 -0
  5. package/dist/{template → templates/react-ts}/bun.lock +105 -58
  6. package/dist/templates/react-ts/components.json +25 -0
  7. package/dist/templates/react-ts/eslint.config.js +23 -0
  8. package/dist/templates/react-ts/functions/.gitkeep +0 -0
  9. package/dist/{template → templates/react-ts}/package.json +5 -0
  10. package/dist/templates/react-ts/public/favicon.svg +1 -0
  11. package/dist/templates/react-ts/public/icons/apple-touch-icon.png +0 -0
  12. package/dist/templates/react-ts/public/icons/pwa-192x192.png +0 -0
  13. package/dist/templates/react-ts/public/icons/pwa-512x512-maskable.png +0 -0
  14. package/dist/templates/react-ts/public/icons/pwa-512x512.png +0 -0
  15. package/dist/templates/react-ts/public/icons.svg +24 -0
  16. package/dist/templates/react-ts/schema.ts +5 -0
  17. package/dist/templates/react-ts/server/db.ts +103 -0
  18. package/dist/templates/react-ts/src/App.tsx +128 -0
  19. package/dist/templates/react-ts/src/components/ui/animated-number.tsx +102 -0
  20. package/dist/templates/react-ts/src/components/ui/animated-text.tsx +109 -0
  21. package/dist/templates/react-ts/src/components/ui/bottom-nav.tsx +131 -0
  22. package/dist/templates/react-ts/src/components/ui/button.tsx +84 -0
  23. package/dist/templates/react-ts/src/components/ui/card.tsx +70 -0
  24. package/dist/templates/react-ts/src/components/ui/otp-input.tsx +179 -0
  25. package/dist/templates/react-ts/src/components/ui/scroll-affordance.tsx +106 -0
  26. package/dist/templates/react-ts/src/components/ui/sound.ts +182 -0
  27. package/dist/templates/react-ts/src/components/ui/transitions.tsx +419 -0
  28. package/dist/templates/react-ts/src/components/ui/vibes-ui-styles.ts +388 -0
  29. package/dist/templates/react-ts/src/db.drizzle.ts +5 -0
  30. package/dist/templates/react-ts/src/index.css +168 -0
  31. package/dist/templates/react-ts/src/lib/utils.ts +6 -0
  32. package/dist/templates/react-ts/src/main.tsx +22 -0
  33. package/dist/templates/react-ts/src/routeTree.gen.ts +59 -0
  34. package/dist/templates/react-ts/src/routes/__root.tsx +7 -0
  35. package/dist/templates/react-ts/src/routes/index.tsx +9 -0
  36. package/dist/templates/react-ts/tsconfig.app.json +30 -0
  37. package/dist/templates/react-ts/tsconfig.json +10 -0
  38. package/dist/templates/react-ts/tsconfig.node.json +24 -0
  39. package/dist/{template → templates/react-ts}/vite.config.ts +9 -1
  40. package/dist/templates/react-ts-start/AGENTS.md +75 -0
  41. package/dist/templates/react-ts-start/README.md +58 -0
  42. package/dist/templates/react-ts-start/bun.lock +1688 -0
  43. package/dist/templates/react-ts-start/package.json +58 -0
  44. package/dist/templates/react-ts-start/src/routeTree.gen.ts +68 -0
  45. package/dist/templates/react-ts-start/src/router.tsx +18 -0
  46. package/dist/templates/react-ts-start/src/routes/__root.tsx +40 -0
  47. package/dist/templates/react-ts-start/src/routes/index.tsx +7 -0
  48. package/dist/templates/react-ts-start/vite.config.ts +29 -0
  49. package/package.json +3 -3
  50. package/dist/template/src/main.tsx +0 -10
  51. /package/dist/{template → templates/react-ts}/README.md +0 -0
  52. /package/dist/{template → templates/react-ts}/index.html +0 -0
  53. /package/dist/{template → templates/react-ts-start}/_gitignore +0 -0
  54. /package/dist/{template → templates/react-ts-start}/components.json +0 -0
  55. /package/dist/{template → templates/react-ts-start}/eslint.config.js +0 -0
  56. /package/dist/{template → templates/react-ts-start}/functions/.gitkeep +0 -0
  57. /package/dist/{template → templates/react-ts-start}/public/favicon.svg +0 -0
  58. /package/dist/{template → templates/react-ts-start}/public/icons/apple-touch-icon.png +0 -0
  59. /package/dist/{template → templates/react-ts-start}/public/icons/pwa-192x192.png +0 -0
  60. /package/dist/{template → templates/react-ts-start}/public/icons/pwa-512x512-maskable.png +0 -0
  61. /package/dist/{template → templates/react-ts-start}/public/icons/pwa-512x512.png +0 -0
  62. /package/dist/{template → templates/react-ts-start}/public/icons.svg +0 -0
  63. /package/dist/{template → templates/react-ts-start}/schema.ts +0 -0
  64. /package/dist/{template → templates/react-ts-start}/server/db.ts +0 -0
  65. /package/dist/{template → templates/react-ts-start}/src/App.tsx +0 -0
  66. /package/dist/{template → templates/react-ts-start}/src/components/ui/animated-number.tsx +0 -0
  67. /package/dist/{template → templates/react-ts-start}/src/components/ui/animated-text.tsx +0 -0
  68. /package/dist/{template → templates/react-ts-start}/src/components/ui/bottom-nav.tsx +0 -0
  69. /package/dist/{template → templates/react-ts-start}/src/components/ui/button.tsx +0 -0
  70. /package/dist/{template → templates/react-ts-start}/src/components/ui/card.tsx +0 -0
  71. /package/dist/{template → templates/react-ts-start}/src/components/ui/otp-input.tsx +0 -0
  72. /package/dist/{template → templates/react-ts-start}/src/components/ui/scroll-affordance.tsx +0 -0
  73. /package/dist/{template → templates/react-ts-start}/src/components/ui/sound.ts +0 -0
  74. /package/dist/{template → templates/react-ts-start}/src/components/ui/transitions.tsx +0 -0
  75. /package/dist/{template → templates/react-ts-start}/src/components/ui/vibes-ui-styles.ts +0 -0
  76. /package/dist/{template → templates/react-ts-start}/src/db.drizzle.ts +0 -0
  77. /package/dist/{template → templates/react-ts-start}/src/index.css +0 -0
  78. /package/dist/{template → templates/react-ts-start}/src/lib/utils.ts +0 -0
  79. /package/dist/{template → templates/react-ts-start}/tsconfig.app.json +0 -0
  80. /package/dist/{template → templates/react-ts-start}/tsconfig.json +0 -0
  81. /package/dist/{template → templates/react-ts-start}/tsconfig.node.json +0 -0
@@ -0,0 +1,84 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+ import { playSound, type SoundName } from "@/components/ui/sound"
5
+
6
+ // Squishy base Button — vendored, self-contained (no class-variance-authority
7
+ // and no @base-ui dependency, so it works in a bare app without extra installs).
8
+ //
9
+ // The iOS "squish": `active:scale-[0.97]` on press, eased with the omg motion
10
+ // curve, snapping back instantly on release (`active:transition-none`). Corners
11
+ // pair the baked `squircle` utility with `rounded-md` so they read as soft
12
+ // continuous corners on Chromium 139+ and fall back to normal rounding
13
+ // everywhere else.
14
+ //
15
+ // Opt-in press SOUND: pass `sound` (or `sound="success"` etc.) and the button
16
+ // plays a synthesized, zero-file Web Audio tick on press — the audio companion
17
+ // to the visual squish. Off by default (silence is the right default; sound on
18
+ // EVERY button is noise), gesture-driven so the browser lets it through, and it
19
+ // self-mutes under prefers-reduced-motion. See `./sound`.
20
+ //
21
+ // Every colour reads the app's shadcn theme variables (--primary, --secondary,
22
+ // …), so the button inherits whatever preset the app uses. Edit freely — it's
23
+ // vendored into your project, not imported from a package.
24
+
25
+ type Variant = "default" | "secondary" | "outline" | "ghost" | "destructive"
26
+ type Size = "default" | "sm" | "lg" | "icon"
27
+
28
+ const VARIANTS: Record<Variant, string> = {
29
+ default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
30
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
31
+ outline:
32
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
33
+ ghost: "hover:bg-accent hover:text-accent-foreground",
34
+ destructive: "bg-destructive text-white hover:bg-destructive/90",
35
+ }
36
+
37
+ const SIZES: Record<Size, string> = {
38
+ default: "h-9 px-4 py-2",
39
+ sm: "h-8 gap-1.5 px-3 text-xs",
40
+ lg: "h-10 px-6",
41
+ icon: "size-9",
42
+ }
43
+
44
+ export interface ButtonProps
45
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
46
+ variant?: Variant
47
+ size?: Size
48
+ /**
49
+ * Play a synthesized press sound on pointer-down. `true` uses the soft
50
+ * `"tap"` tick; pass a preset name (`"click"`, `"success"`, …) to pick
51
+ * another. Off by default. Fires on pointer-down so the tick lands with the
52
+ * press, not the release.
53
+ */
54
+ sound?: boolean | SoundName
55
+ }
56
+
57
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
58
+ function Button(
59
+ { className, variant = "default", size = "default", sound, onPointerDown, ...props },
60
+ ref,
61
+ ) {
62
+ const handlePointerDown = React.useCallback(
63
+ (event: React.PointerEvent<HTMLButtonElement>) => {
64
+ if (sound && !props.disabled) playSound(sound === true ? "tap" : sound)
65
+ onPointerDown?.(event)
66
+ },
67
+ [sound, props.disabled, onPointerDown],
68
+ )
69
+ return (
70
+ <button
71
+ ref={ref}
72
+ data-slot="button"
73
+ onPointerDown={handlePointerDown}
74
+ className={cn(
75
+ "inline-flex shrink-0 select-none items-center justify-center gap-2 rounded-md squircle text-sm font-medium whitespace-nowrap outline-none transition-[transform,background-color,box-shadow,border-color,color] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-[0.97] active:transition-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
76
+ VARIANTS[variant],
77
+ SIZES[size],
78
+ className,
79
+ )}
80
+ {...props}
81
+ />
82
+ )
83
+ },
84
+ )
@@ -0,0 +1,70 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ // Squircle Card — shadcn-shaped (Card / CardHeader / CardTitle / CardDescription
6
+ // / CardContent / CardFooter) so it's a drop-in for anyone used to shadcn, with
7
+ // the iOS continuous-corner `squircle` on the root surface. Theme-var driven
8
+ // (bg-card / text-card-foreground), so it follows the app's preset. Vendored —
9
+ // edit freely.
10
+
11
+ function Card({ className, ...props }: React.ComponentProps<"div">) {
12
+ return (
13
+ <div
14
+ data-slot="card"
15
+ className={cn(
16
+ "flex flex-col gap-6 rounded-xl squircle border bg-card py-6 text-card-foreground shadow-sm",
17
+ className,
18
+ )}
19
+ {...props}
20
+ />
21
+ )
22
+ }
23
+
24
+ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
25
+ return (
26
+ <div
27
+ data-slot="card-header"
28
+ className={cn("flex flex-col gap-1.5 px-6", className)}
29
+ {...props}
30
+ />
31
+ )
32
+ }
33
+
34
+ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
35
+ return (
36
+ <div
37
+ data-slot="card-title"
38
+ className={cn("font-semibold leading-none tracking-tight", className)}
39
+ {...props}
40
+ />
41
+ )
42
+ }
43
+
44
+ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
45
+ return (
46
+ <div
47
+ data-slot="card-description"
48
+ className={cn("text-sm text-muted-foreground", className)}
49
+ {...props}
50
+ />
51
+ )
52
+ }
53
+
54
+ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
55
+ return (
56
+ <div data-slot="card-content" className={cn("px-6", className)} {...props} />
57
+ )
58
+ }
59
+
60
+ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
61
+ return (
62
+ <div
63
+ data-slot="card-footer"
64
+ className={cn("flex items-center px-6", className)}
65
+ {...props}
66
+ />
67
+ )
68
+ }
69
+
70
+ export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
@@ -0,0 +1,179 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+ import { playSound, type SoundName } from "@/components/ui/sound"
5
+
6
+ // <OtpInput> — segmented one-time-code field (the "type the 6-digit code"
7
+ // input). Vendored, self-contained (no input-otp / no @radix dependency), so
8
+ // it drops into a bare app with nothing to install.
9
+ //
10
+ // Why it's not N separate <input>s: this renders ONE real, focusable text
11
+ // input stretched invisibly across the field, with the digit cells painted
12
+ // beneath it. That single input keeps everything the platform gives you for
13
+ // free — native paste of a whole code, iOS/Android SMS autofill
14
+ // (`autocomplete="one-time-code"`), the numeric soft keyboard on mobile
15
+ // (`inputmode="numeric"`), and correct screen-reader semantics — while the
16
+ // cells are yours to style. Auto-advance and backspace "just work" because
17
+ // there's only one caret to move.
18
+ //
19
+ // Delight, on by the same rules as the rest of the kit:
20
+ // - Squircle continuous corners (paired with `rounded-md`, the fallback) so
21
+ // the cells match `Button`/`Card`.
22
+ // - The active cell shows a soft blinking caret; a filled field pops a subtle
23
+ // ring. Motion self-mutes under `prefers-reduced-motion`.
24
+ // - Opt-in tactile SOUND (`sound` prop): a quiet `tap` per digit and a
25
+ // `success` chime when the code completes — off by default (silence is the
26
+ // right default), gesture-driven, and self-muting under reduced-motion.
27
+ // See `./sound`.
28
+ //
29
+ // Controlled: drive `value` / `onChange` from state (mirrors how `VibesLogin`
30
+ // already tracks the code). `onComplete` fires once the field fills — wire your
31
+ // verify call there. Every colour reads the app's shadcn theme variables, so it
32
+ // inherits whatever preset the app uses. Edit freely — it's yours now.
33
+
34
+ export interface OtpInputProps {
35
+ /** The current code (controlled). Only allowed characters are ever stored. */
36
+ value: string
37
+ /** Called with the sanitized next value on every change. */
38
+ onChange: (value: string) => void
39
+ /** Fires once the field reaches `length`. Great place to auto-submit/verify. */
40
+ onComplete?: (value: string) => void
41
+ /** Number of cells. Defaults to 6 (the vibes email-code length). */
42
+ length?: number
43
+ /**
44
+ * Play a synthesized sound as the user types. `true` uses a soft `tap` per
45
+ * digit plus a `success` chime on completion; pass a preset name to use that
46
+ * tone for the per-digit tick instead. Off by default.
47
+ */
48
+ sound?: boolean | SoundName
49
+ disabled?: boolean
50
+ autoFocus?: boolean
51
+ /** Soft-keyboard hint. `"numeric"` (default) for codes; `"text"` for alnum. */
52
+ inputMode?: "numeric" | "text"
53
+ /**
54
+ * Which characters are accepted. Defaults to digits only. Anything typed or
55
+ * pasted that doesn't match is dropped (so pasting "123-456" yields "123456").
56
+ */
57
+ allowed?: RegExp
58
+ "aria-label"?: string
59
+ className?: string
60
+ /** Extra classes merged onto each cell (e.g. sizing, radius). */
61
+ cellClassName?: string
62
+ }
63
+
64
+ export function OtpInput({
65
+ value,
66
+ onChange,
67
+ onComplete,
68
+ length = 6,
69
+ sound = false,
70
+ disabled = false,
71
+ autoFocus = false,
72
+ inputMode = "numeric",
73
+ allowed = /[0-9]/,
74
+ "aria-label": ariaLabel = "Verification code",
75
+ className,
76
+ cellClassName,
77
+ }: OtpInputProps) {
78
+ const [focused, setFocused] = React.useState(false)
79
+ const inputRef = React.useRef<HTMLInputElement>(null)
80
+ // Guard so onComplete fires once per fill, not on every keystroke while full.
81
+ const completedRef = React.useRef(false)
82
+
83
+ const tapTone: SoundName = sound === true ? "tap" : (sound as SoundName)
84
+
85
+ const sanitize = React.useCallback(
86
+ (raw: string) =>
87
+ Array.from(raw)
88
+ .filter((ch) => allowed.test(ch))
89
+ .join("")
90
+ .slice(0, length),
91
+ [allowed, length],
92
+ )
93
+
94
+ function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
95
+ const next = sanitize(e.target.value)
96
+ if (next === value) return
97
+ const grew = next.length > value.length
98
+ onChange(next)
99
+
100
+ if (next.length === length) {
101
+ if (!completedRef.current) {
102
+ completedRef.current = true
103
+ if (sound) playSound("success")
104
+ onComplete?.(next)
105
+ }
106
+ } else {
107
+ completedRef.current = false
108
+ if (grew && sound) playSound(tapTone)
109
+ }
110
+ }
111
+
112
+ // The active cell is the first empty slot, clamped to the last cell when full
113
+ // so the caret has somewhere to sit.
114
+ const activeIndex = Math.min(value.length, length - 1)
115
+ const cells = Array.from({ length }, (_, i) => value[i] ?? "")
116
+
117
+ return (
118
+ <div
119
+ className={cn("relative inline-flex w-full", className)}
120
+ onMouseDown={(e) => {
121
+ // Keep clicks anywhere in the field focusing the (invisible) input,
122
+ // even if they land between cells.
123
+ if (e.target !== inputRef.current) {
124
+ e.preventDefault()
125
+ inputRef.current?.focus()
126
+ }
127
+ }}
128
+ >
129
+ <div className="flex w-full items-center justify-center gap-2">
130
+ {cells.map((char, i) => {
131
+ const isActive = focused && i === activeIndex && !disabled
132
+ return (
133
+ <div
134
+ key={i}
135
+ data-slot="otp-cell"
136
+ data-active={isActive || undefined}
137
+ data-filled={char ? true : undefined}
138
+ className={cn(
139
+ "relative flex h-12 w-10 items-center justify-center rounded-md squircle border border-input bg-background text-xl font-medium tabular-nums shadow-sm transition-[color,box-shadow,border-color] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)]",
140
+ isActive && "border-ring ring-[3px] ring-ring/50 z-10",
141
+ disabled && "opacity-50",
142
+ cellClassName,
143
+ )}
144
+ >
145
+ {char || (
146
+ // Blinking caret on the active empty cell; motion off under
147
+ // prefers-reduced-motion (Tailwind's motion-reduce variant).
148
+ isActive ? (
149
+ <span
150
+ aria-hidden="true"
151
+ className="h-6 w-px animate-pulse bg-foreground motion-reduce:animate-none"
152
+ />
153
+ ) : null
154
+ )}
155
+ </div>
156
+ )
157
+ })}
158
+ </div>
159
+
160
+ <input
161
+ ref={inputRef}
162
+ value={value}
163
+ onChange={handleChange}
164
+ onFocus={() => setFocused(true)}
165
+ onBlur={() => setFocused(false)}
166
+ disabled={disabled}
167
+ autoFocus={autoFocus}
168
+ inputMode={inputMode}
169
+ pattern={inputMode === "numeric" ? "[0-9]*" : undefined}
170
+ autoComplete="one-time-code"
171
+ aria-label={ariaLabel}
172
+ maxLength={length}
173
+ // Invisible but real: fills the field, captures typing/paste/autofill,
174
+ // and owns the (hidden) caret so the platform handles selection for us.
175
+ className="absolute inset-0 h-full w-full cursor-default bg-transparent text-center text-transparent caret-transparent opacity-0 outline-none disabled:cursor-not-allowed"
176
+ />
177
+ </div>
178
+ )
179
+ }
@@ -0,0 +1,106 @@
1
+ // <ScrollAffordance> — a "there's more below" cue.
2
+ //
3
+ // Vendored, editable source (was @omg-dev/ui). Bobs while the watched container
4
+ // is at the top, fades the instant the user scrolls, and hides itself when
5
+ // there's nothing to scroll — so it's safe to render unconditionally.
6
+ // Self-contained: no Tailwind utility classes, no runtime dependency beyond
7
+ // React. Themes itself from your shadcn CSS variables (--muted-foreground)
8
+ // with light/dark fallbacks and honors prefers-reduced-motion.
9
+
10
+ import {
11
+ useEffect,
12
+ useLayoutEffect,
13
+ useState,
14
+ type CSSProperties,
15
+ type RefObject,
16
+ } from "react"
17
+ import { ensureStyles } from "@/components/ui/vibes-ui-styles"
18
+
19
+ // useLayoutEffect warns during SSR; fall back to useEffect on the server.
20
+ const useIsoLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect
21
+
22
+ export type ScrollAffordanceProps = {
23
+ /**
24
+ * The scroll container to watch. Omit to watch the window (the common case
25
+ * for a long landing page). Pass a ref to watch an overflowing panel.
26
+ */
27
+ scrollRef?: RefObject<HTMLElement | null>
28
+ /** Caption above the chevron. Pass `null` for a bare chevron. */
29
+ label?: string | null
30
+ /** Hide once scrolled this many px past the top. */
31
+ threshold?: number
32
+ className?: string
33
+ style?: CSSProperties
34
+ }
35
+
36
+ /**
37
+ * A self-aware "scroll for more" cue. It bobs while the watched container is
38
+ * at the top, fades away the moment the user scrolls, and hides itself
39
+ * entirely when there's nothing to scroll — so it's safe to render
40
+ * unconditionally.
41
+ */
42
+ export function ScrollAffordance({
43
+ scrollRef,
44
+ label = "Scroll",
45
+ threshold = 24,
46
+ className = "",
47
+ style,
48
+ }: ScrollAffordanceProps) {
49
+ useEffect(ensureStyles, [])
50
+ // Start hidden; the first measurement reveals it only if there's overflow.
51
+ const [hidden, setHidden] = useState(true)
52
+
53
+ useIsoLayoutEffect(() => {
54
+ if (typeof window === "undefined") return
55
+ const el = scrollRef?.current ?? null
56
+
57
+ const measure = () => {
58
+ const scrollTop = el ? el.scrollTop : window.scrollY
59
+ const scrollHeight = el
60
+ ? el.scrollHeight
61
+ : document.documentElement.scrollHeight
62
+ const clientHeight = el ? el.clientHeight : window.innerHeight
63
+ const scrollable = scrollHeight - clientHeight > threshold
64
+ setHidden(!scrollable || scrollTop > threshold)
65
+ }
66
+
67
+ const source: HTMLElement | Window = el ?? window
68
+ measure()
69
+ source.addEventListener("scroll", measure, { passive: true })
70
+ window.addEventListener("resize", measure)
71
+ // Content can grow after async data loads — re-measure on layout changes.
72
+ const ro =
73
+ typeof ResizeObserver !== "undefined"
74
+ ? new ResizeObserver(measure)
75
+ : null
76
+ if (ro) ro.observe(el ?? document.documentElement)
77
+
78
+ return () => {
79
+ source.removeEventListener("scroll", measure)
80
+ window.removeEventListener("resize", measure)
81
+ ro?.disconnect()
82
+ }
83
+ }, [scrollRef, threshold])
84
+
85
+ return (
86
+ <span
87
+ className={`vui vui-scroll ${className}`.trim()}
88
+ style={style}
89
+ data-hidden={hidden}
90
+ aria-hidden="true"
91
+ >
92
+ {label ? <span>{label}</span> : null}
93
+ <svg
94
+ className="vui-scroll-chevron"
95
+ viewBox="0 0 24 24"
96
+ fill="none"
97
+ stroke="currentColor"
98
+ strokeWidth="2"
99
+ strokeLinecap="round"
100
+ strokeLinejoin="round"
101
+ >
102
+ <path d="m6 9 6 6 6-6" />
103
+ </svg>
104
+ </span>
105
+ )
106
+ }
@@ -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
+ }