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.
- package/README.md +16 -6
- package/dist/index.mjs +71 -12
- package/dist/{template → templates/react-ts}/AGENTS.md +22 -0
- package/dist/templates/react-ts/_gitignore +24 -0
- package/dist/{template → templates/react-ts}/bun.lock +105 -58
- package/dist/templates/react-ts/components.json +25 -0
- package/dist/templates/react-ts/eslint.config.js +23 -0
- package/dist/templates/react-ts/functions/.gitkeep +0 -0
- package/dist/{template → templates/react-ts}/package.json +5 -0
- package/dist/templates/react-ts/public/favicon.svg +1 -0
- package/dist/templates/react-ts/public/icons/apple-touch-icon.png +0 -0
- package/dist/templates/react-ts/public/icons/pwa-192x192.png +0 -0
- package/dist/templates/react-ts/public/icons/pwa-512x512-maskable.png +0 -0
- package/dist/templates/react-ts/public/icons/pwa-512x512.png +0 -0
- package/dist/templates/react-ts/public/icons.svg +24 -0
- package/dist/templates/react-ts/schema.ts +5 -0
- package/dist/templates/react-ts/server/db.ts +103 -0
- package/dist/templates/react-ts/src/App.tsx +128 -0
- package/dist/templates/react-ts/src/components/ui/animated-number.tsx +102 -0
- package/dist/templates/react-ts/src/components/ui/animated-text.tsx +109 -0
- package/dist/templates/react-ts/src/components/ui/bottom-nav.tsx +131 -0
- package/dist/templates/react-ts/src/components/ui/button.tsx +84 -0
- package/dist/templates/react-ts/src/components/ui/card.tsx +70 -0
- package/dist/templates/react-ts/src/components/ui/otp-input.tsx +179 -0
- package/dist/templates/react-ts/src/components/ui/scroll-affordance.tsx +106 -0
- package/dist/templates/react-ts/src/components/ui/sound.ts +182 -0
- package/dist/templates/react-ts/src/components/ui/transitions.tsx +419 -0
- package/dist/templates/react-ts/src/components/ui/vibes-ui-styles.ts +388 -0
- package/dist/templates/react-ts/src/db.drizzle.ts +5 -0
- package/dist/templates/react-ts/src/index.css +168 -0
- package/dist/templates/react-ts/src/lib/utils.ts +6 -0
- package/dist/templates/react-ts/src/main.tsx +22 -0
- package/dist/templates/react-ts/src/routeTree.gen.ts +59 -0
- package/dist/templates/react-ts/src/routes/__root.tsx +7 -0
- package/dist/templates/react-ts/src/routes/index.tsx +9 -0
- package/dist/templates/react-ts/tsconfig.app.json +30 -0
- package/dist/templates/react-ts/tsconfig.json +10 -0
- package/dist/templates/react-ts/tsconfig.node.json +24 -0
- package/dist/{template → templates/react-ts}/vite.config.ts +9 -1
- package/dist/templates/react-ts-start/AGENTS.md +75 -0
- package/dist/templates/react-ts-start/README.md +58 -0
- package/dist/templates/react-ts-start/bun.lock +1688 -0
- package/dist/templates/react-ts-start/package.json +58 -0
- package/dist/templates/react-ts-start/src/routeTree.gen.ts +68 -0
- package/dist/templates/react-ts-start/src/router.tsx +18 -0
- package/dist/templates/react-ts-start/src/routes/__root.tsx +40 -0
- package/dist/templates/react-ts-start/src/routes/index.tsx +7 -0
- package/dist/templates/react-ts-start/vite.config.ts +29 -0
- package/package.json +3 -3
- package/dist/template/src/main.tsx +0 -10
- /package/dist/{template → templates/react-ts}/README.md +0 -0
- /package/dist/{template → templates/react-ts}/index.html +0 -0
- /package/dist/{template → templates/react-ts-start}/_gitignore +0 -0
- /package/dist/{template → templates/react-ts-start}/components.json +0 -0
- /package/dist/{template → templates/react-ts-start}/eslint.config.js +0 -0
- /package/dist/{template → templates/react-ts-start}/functions/.gitkeep +0 -0
- /package/dist/{template → templates/react-ts-start}/public/favicon.svg +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/apple-touch-icon.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/pwa-192x192.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/pwa-512x512-maskable.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/pwa-512x512.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons.svg +0 -0
- /package/dist/{template → templates/react-ts-start}/schema.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/server/db.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/App.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/animated-number.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/animated-text.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/bottom-nav.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/button.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/card.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/otp-input.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/scroll-affordance.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/sound.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/transitions.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/vibes-ui-styles.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/db.drizzle.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/index.css +0 -0
- /package/dist/{template → templates/react-ts-start}/src/lib/utils.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/tsconfig.app.json +0 -0
- /package/dist/{template → templates/react-ts-start}/tsconfig.json +0 -0
- /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
|
+
}
|