{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sound","type":"registry:lib","title":"Sound","description":"The opt-in interface sound engine, its themes, hooks and toggle.","registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/sound/SoundToggle.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../utils\";\nimport { sound } from \"./sound\";\nimport { useSoundEnabled, useSoundEngineState } from \"./use-sound\";\nimport \"./sound-toggle.css\";\n\nexport type SoundToggleSize = \"sm\" | \"md\" | \"lg\";\nexport type SoundToggleVariant = \"outline\" | \"ghost\";\n\nexport interface SoundToggleProps {\n\t/** Visual size of the control; `md` matches header-style triggers */\n\tsize?: SoundToggleSize;\n\t/** `\"outline\"` keeps a resting border, `\"ghost\"` shows one only on hover */\n\tvariant?: SoundToggleVariant;\n\t/** Renders the label and the On/Off word beside the icon; icon-only otherwise */\n\tshowLabel?: boolean;\n\t/** Accessible name of the switch. Stays constant — the state is announced through aria-checked */\n\tlabel?: string;\n\t/** Visible state word when `showLabel` is set */\n\tlabelOn?: string;\n\t/** Visible state word when `showLabel` is set */\n\tlabelOff?: string;\n\t/** Disables the control. A browser with no Web Audio also disables it — but only while sound is off, so a stored \"on\" can always be undone */\n\tdisabled?: boolean;\n\t/** Called after the preference flips, with the new value */\n\tonEnabledChange?: (enabled: boolean) => void;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\nconst SIZE_CLASSES = {\n\tsm: \"h-8 gap-1 rounded-md px-1.5 text-xs\",\n\tmd: \"h-9 gap-1.5 rounded-md px-2 text-sm\",\n\tlg: \"h-10 gap-2 rounded-md px-2.5 text-sm\",\n} as const satisfies Record<SoundToggleSize, string>;\n\nconst ICON_SIZE = { sm: 14, md: 16, lg: 18 } as const satisfies Record<SoundToggleSize, number>;\n\nexport const SoundToggle = forwardRef<HTMLButtonElement, SoundToggleProps>(function SoundToggle(\n\t{\n\t\tsize = \"md\",\n\t\tvariant = \"outline\",\n\t\tshowLabel = false,\n\t\tlabel = \"Sound\",\n\t\tlabelOn = \"On\",\n\t\tlabelOff = \"Off\",\n\t\tdisabled = false,\n\t\tonEnabledChange,\n\t\tclassName,\n\t},\n\tref\n) {\n\tconst enabled = useSoundEnabled();\n\t// Web Audio support is a browser fact, not a preference. A switch the engine\n\t// can never honour is presented disabled — but only while sound is OFF:\n\t// disabling it while it is on would strand the user with a persisted \"on\"\n\t// preference and no control left to undo it.\n\t//\n\t// The engine state is read through its own selector, not through the whole\n\t// `status` object: the object is rebuilt on every cue (lastCue,\n\t// lastPlayedAt), which would re-render this toggle every time anything on\n\t// the page made a sound.\n\tconst unsupported = useSoundEngineState() === \"unsupported\";\n\tconst effectiveDisabled = disabled || (unsupported && !enabled);\n\n\tconst classes = cn(\n\t\t\"ft-sound-toggle text-foreground inline-flex shrink-0 items-center font-medium transition-colors\",\n\t\t\"focus-visible:outline-none\",\n\t\t\"disabled:pointer-events-none disabled:opacity-50\",\n\t\tSIZE_CLASSES[size],\n\t\tvariant === \"outline\"\n\t\t\t? \"border-border bg-background hover:bg-accent hover:text-accent-foreground border\"\n\t\t\t: \"hover:bg-accent hover:text-accent-foreground\",\n\t\tclassName\n\t);\n\n\t// The only place a confirmation cue plays: the click that just turned\n\t// sound on. `unlock()` creates/resumes the AudioContext inside this same\n\t// gesture, and the cue only fires once that context is actually running —\n\t// never on the click that turns sound off, never speculatively.\n\t//\n\t// The native `disabled` attribute already blocks a real user's click, but\n\t// a synthetic event dispatched straight at the element — as a test does —\n\t// walks past that guard the same way it does on Switch/Checkbox/Toggle,\n\t// so the early return is repeated here rather than trusted to the\n\t// attribute alone.\n\tfunction handleClick() {\n\t\tif (effectiveDisabled) return;\n\t\tconst next = sound.toggle();\n\t\tif (next) {\n\t\t\tvoid sound.unlock().then((ok) => {\n\t\t\t\tif (ok) sound.play(\"toggle-on\");\n\t\t\t});\n\t\t}\n\t\tonEnabledChange?.(next);\n\t}\n\n\treturn (\n\t\t<button\n\t\t\tref={ref}\n\t\t\ttype=\"button\"\n\t\t\trole=\"switch\"\n\t\t\taria-checked={enabled}\n\t\t\taria-label={label}\n\t\t\tdisabled={effectiveDisabled}\n\t\t\ttitle={unsupported ? \"This browser has no Web Audio support\" : undefined}\n\t\t\tdata-sound-toggle=\"\"\n\t\t\tdata-state={enabled ? \"on\" : \"off\"}\n\t\t\tdata-size={size}\n\t\t\tdata-variant={variant}\n\t\t\tclassName={classes}\n\t\t\tonClick={handleClick}\n\t\t>\n\t\t\t<svg\n\t\t\t\tclassName=\"ft-sound-toggle-icon shrink-0\"\n\t\t\t\twidth={ICON_SIZE[size]}\n\t\t\t\theight={ICON_SIZE[size]}\n\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\tfill=\"none\"\n\t\t\t\tstroke=\"currentColor\"\n\t\t\t\tstrokeWidth=\"2\"\n\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\taria-hidden=\"true\"\n\t\t\t>\n\t\t\t\t<path d=\"M11 5 6 9H2v6h4l5 4V5z\" />\n\t\t\t\t{/* Both glyph states live in the DOM at all times — which one shows is\n\t\t\t\t    a pure CSS decision keyed off data-state on the button above, never\n\t\t\t\t    a conditional. That matters at hydration: the server renders off\n\t\t\t\t    (sound defaults to off with no storage to read), the client may\n\t\t\t\t    immediately learn the stored preference is on, and this way the\n\t\t\t\t    DOM shape never has to change to reflect it — only opacity/scale\n\t\t\t\t    do, on the very node the server already produced. */}\n\t\t\t\t<g className=\"ft-sound-toggle-glyph ft-sound-toggle-glyph-on\">\n\t\t\t\t\t<path d=\"M15.5 8.5a5 5 0 0 1 0 7\" />\n\t\t\t\t\t<path d=\"M19 5a9 9 0 0 1 0 14\" />\n\t\t\t\t</g>\n\t\t\t\t<g className=\"ft-sound-toggle-glyph ft-sound-toggle-glyph-off\">\n\t\t\t\t\t<path d=\"m16 9 6 6M22 9l-6 6\" />\n\t\t\t\t</g>\n\t\t\t</svg>\n\t\t\t{showLabel && (\n\t\t\t\t<>\n\t\t\t\t\t<span aria-hidden=\"true\">{label}</span>\n\t\t\t\t\t<span aria-hidden=\"true\" className=\"text-muted-foreground\">\n\t\t\t\t\t\t{enabled ? labelOn : labelOff}\n\t\t\t\t\t</span>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</button>\n\t);\n});\n","type":"registry:lib","target":"@ui/fancy/sound/SoundToggle.tsx"},{"path":"react/src/sound/engine.ts","content":"\"use client\";\n\n/**\n * Sound engine — a lazy, rune-free Web Audio interpreter for cue recipes.\n *\n * Owned by Agent 03. Exported names and signatures are the contract\n * (see workflow/contracts.md §3) and must not change.\n *\n * Invariants every implementation must keep:\n * - `createSoundEngine()` allocates nothing. Only `play()` / `unlock()` may\n *   touch Web Audio, and only through `ensureContext()`.\n * - Never throws. Every failure is swallowed into `state` / `lastError`.\n * - No module-evaluation access to window/navigator/AudioContext.\n */\n\nimport {\n\tSOUND_LIMITS,\n\tSOUND_MIN_INTERVAL_MS,\n\ttype SoundCue,\n\ttype SoundEngineState,\n\ttype SoundLayer,\n\ttype SoundPlayOptions,\n\ttype SoundThemeDefinition,\n} from \"./types\";\nimport { FANCY_SOUND_THEME } from \"./themes\";\n\nexport interface SoundEngineOptions {\n\t/**\n\t * Returns the context to use, or null when none can/should be created.\n\t * Default: lazily `new (window.AudioContext ?? window.webkitAudioContext)()`, memoised.\n\t * Tests inject a FakeAudioContext here.\n\t */\n\tgetContext?: () => AudioContext | null;\n\t/** Monotonic clock in ms for rate limiting. Default `performance.now`, falling back to `Date.now`. */\n\tnow?: () => number;\n\t/**\n\t * Whether a context may be created right now. Default:\n\t * `typeof navigator === \"undefined\" ? false : (navigator.userActivation?.isActive ?? true)`.\n\t * Keeps context creation inside a user gesture; when it returns false and no\n\t * context exists yet, `play()` returns false and state stays \"idle\".\n\t */\n\tcanCreateContext?: () => boolean;\n\t/** Initial theme. Default `FANCY_SOUND_THEME`. */\n\ttheme?: SoundThemeDefinition;\n\t/** Overrides for `SOUND_MIN_INTERVAL_MS`. */\n\tminIntervals?: Partial<Record<SoundCue, number>>;\n\t/** Initial master volume 0..1. Default 0.5. */\n\tmasterVolume?: number;\n\t/** Mirrors every state transition (the controller lifts this into `$state`). */\n\tonStateChange?: (state: SoundEngineState, error: string | null) => void;\n}\n\nexport interface SoundEngine {\n\treadonly state: SoundEngineState;\n\treadonly lastError: string | null;\n\t/** Voices currently sounding (for leak tests and the status line). */\n\treadonly voiceCount: number;\n\t/**\n\t * `true` once an AudioContext constructor was found in this environment\n\t * (probed without constructing). `false` on the server.\n\t */\n\treadonly supported: boolean;\n\t/**\n\t * Schedules `cue` now. Returns true only if a voice was actually scheduled.\n\t * Returns false (never throws) when: unsupported, no context and creation not\n\t * allowed, context not running (resume is kicked off, cue dropped), rate-limited,\n\t * MAX_VOICES reached, unknown cue.\n\t */\n\tplay(cue: SoundCue, options?: SoundPlayOptions): boolean;\n\t/**\n\t * Creates the context if allowed and resumes it if suspended. Resolves true\n\t * when the context is running afterwards. Never rejects. Call from a gesture.\n\t */\n\tunlock(): Promise<boolean>;\n\t/** 0..1; engine multiplies by SOUND_LIMITS.MASTER_CEILING. Ramped over ~20ms. */\n\tsetMasterVolume(volume: number): void;\n\tsetTheme(theme: SoundThemeDefinition): void;\n\t/** Fades live voices out, stops/disconnects all nodes, closes an owned context, state → \"idle\". Idempotent. */\n\tdispose(): void;\n}\n\ninterface VoiceHandle {\n\tnodes: AudioNode[];\n\tsource: OscillatorNode | AudioBufferSourceNode;\n}\n\n/** Where exponential envelope segments bottom out before the final step to 0. */\nconst EXP_FLOOR = 1e-4;\n\nfunction clamp(value: number, min: number, max: number): number {\n\treturn Math.min(max, Math.max(min, value));\n}\n\nfunction errorMessage(err: unknown): string {\n\treturn err instanceof Error ? err.message : String(err);\n}\n\nfunction defaultGetContext(): AudioContext | null {\n\tif (typeof window === \"undefined\") return null;\n\tconst w = window as unknown as {\n\t\tAudioContext?: typeof AudioContext;\n\t\twebkitAudioContext?: typeof AudioContext;\n\t};\n\tconst Ctor = w.AudioContext ?? w.webkitAudioContext;\n\treturn Ctor ? new Ctor() : null;\n}\n\nfunction defaultCanCreateContext(): boolean {\n\tif (typeof navigator === \"undefined\") return false;\n\tconst nav = navigator as Navigator & { userActivation?: { isActive: boolean } };\n\treturn nav.userActivation?.isActive ?? true;\n}\n\nfunction defaultNow(): number {\n\treturn typeof performance !== \"undefined\" ? performance.now() : Date.now();\n}\n\nfunction probeSupported(): boolean {\n\tif (typeof window === \"undefined\") return false;\n\tconst w = window as unknown as { AudioContext?: unknown; webkitAudioContext?: unknown };\n\treturn typeof w.AudioContext !== \"undefined\" || typeof w.webkitAudioContext !== \"undefined\";\n}\n\n/** Seeded xorshift32 noise, mapped to [-1, 1]. Deterministic for a given length. */\nfunction fillNoise(data: Float32Array): void {\n\tlet s = 0x9e3779b9;\n\tfor (let i = 0; i < data.length; i++) {\n\t\ts ^= s << 13;\n\t\ts >>>= 0;\n\t\ts ^= s >>> 17;\n\t\ts >>>= 0;\n\t\ts ^= s << 5;\n\t\ts >>>= 0;\n\t\tdata[i] = (s / 0xffffffff) * 2 - 1;\n\t}\n}\n\nexport function createSoundEngine(options: SoundEngineOptions = {}): SoundEngine {\n\tconst getContextFn = options.getContext ?? defaultGetContext;\n\tconst canCreateContextFn = options.canCreateContext ?? defaultCanCreateContext;\n\tconst nowFn = options.now ?? defaultNow;\n\tconst minIntervals = options.minIntervals ?? {};\n\tlet theme = options.theme ?? FANCY_SOUND_THEME;\n\tlet masterVolume = clamp(\n\t\tNumber.isFinite(options.masterVolume) ? (options.masterVolume as number) : 0.5,\n\t\t0,\n\t\t1\n\t);\n\tconst onStateChangeCb = options.onStateChange;\n\n\tlet ctx: AudioContext | null = null;\n\tlet master: GainNode | null = null;\n\tlet limiter: DynamicsCompressorNode | null = null;\n\tlet statechangeListener: (() => void) | null = null;\n\tlet noiseBuffer: AudioBuffer | null = null;\n\tlet resumePromise: Promise<void> | null = null;\n\t// Bumped by dispose(). Async continuations captured before a teardown compare\n\t// against it and stay silent when they belong to a previous life.\n\tlet disposeGeneration = 0;\n\tlet state: SoundEngineState = \"idle\";\n\tlet lastError: string | null = null;\n\tlet voiceCount = 0;\n\tconst activeVoices = new Set<VoiceHandle>();\n\tconst lastAt: Partial<Record<SoundCue, number>> = {};\n\n\tfunction setState(next: SoundEngineState, error: string | null): void {\n\t\tif (next === state) {\n\t\t\tif (error) lastError = error;\n\t\t\treturn;\n\t\t}\n\t\tstate = next;\n\t\tlastError = error;\n\t\tonStateChangeCb?.(next, error);\n\t}\n\n\tfunction syncFromContextState(rawState: string): void {\n\t\tif (rawState === \"running\") setState(\"ready\", null);\n\t\telse if (rawState === \"suspended\" || rawState === \"interrupted\") setState(\"suspended\", null);\n\t\telse if (rawState === \"closed\") setState(\"blocked\", null);\n\t}\n\n\tfunction attachContext(context: AudioContext): void {\n\t\tctx = context;\n\t\tconst listener = () => syncFromContextState(context.state);\n\t\tcontext.addEventListener(\"statechange\", listener);\n\t\tstatechangeListener = listener;\n\n\t\tmaster = context.createGain();\n\t\tmaster.gain.value = masterVolume * SOUND_LIMITS.MASTER_CEILING;\n\n\t\tif (typeof context.createDynamicsCompressor === \"function\") {\n\t\t\ttry {\n\t\t\t\tconst comp = context.createDynamicsCompressor();\n\t\t\t\t// A safety limiter, not a colour: it only engages near full scale.\n\t\t\t\t// Set lower, its ~3 ms attack shaved the first transient of every\n\t\t\t\t// short tap (the part that makes a tap sound crisp) by up to 8 dB.\n\t\t\t\tcomp.threshold.value = -3;\n\t\t\t\tcomp.knee.value = 0;\n\t\t\t\tcomp.ratio.value = 20;\n\t\t\t\tcomp.attack.value = 0.001;\n\t\t\t\tcomp.release.value = 0.1;\n\t\t\t\tmaster.connect(comp);\n\t\t\t\tcomp.connect(context.destination);\n\t\t\t\tlimiter = comp;\n\t\t\t} catch {\n\t\t\t\tlimiter = null;\n\t\t\t\tmaster.connect(context.destination);\n\t\t\t}\n\t\t} else {\n\t\t\tmaster.connect(context.destination);\n\t\t}\n\n\t\tsyncFromContextState(context.state);\n\t}\n\n\tfunction ensureContext(): AudioContext | null {\n\t\tif (ctx) return ctx;\n\t\tif (!canCreateContextFn()) return null;\n\t\tlet created: AudioContext | null;\n\t\ttry {\n\t\t\tcreated = getContextFn();\n\t\t\tif (!created) {\n\t\t\t\tsetState(\"unsupported\", null);\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\t// A context whose createGain()/addEventListener throws degrades to\n\t\t\t// \"unsupported\" here instead of throwing out through a click handler.\n\t\t\tattachContext(created);\n\t\t} catch (err) {\n\t\t\tsetState(\"unsupported\", errorMessage(err));\n\t\t\treturn null;\n\t\t}\n\t\treturn created;\n\t}\n\n\tfunction kickResume(context: AudioContext): Promise<void> {\n\t\tif (resumePromise) return resumePromise;\n\t\t// dispose() can land while resume() is still pending. The continuations\n\t\t// below then hold a context this engine no longer owns, so they may only\n\t\t// touch state while that context is still the current one — otherwise a\n\t\t// disposed engine would report itself ready/blocked again and call its\n\t\t// state callback from an obsolete context.\n\t\tconst generation = disposeGeneration;\n\t\tconst isCurrent = () => generation === disposeGeneration && ctx === context;\n\t\tresumePromise = context\n\t\t\t.resume()\n\t\t\t.then(() => {\n\t\t\t\tif (isCurrent()) syncFromContextState(context.state);\n\t\t\t})\n\t\t\t.catch((err: unknown) => {\n\t\t\t\tif (isCurrent()) setState(\"blocked\", errorMessage(err));\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\t// A dispose() already cleared this slot (and a later kickResume may\n\t\t\t\t// own it now); only the current generation may null it out.\n\t\t\t\tif (generation === disposeGeneration) resumePromise = null;\n\t\t\t});\n\t\treturn resumePromise;\n\t}\n\n\t// 250 ms is longer than any noise layer a valid theme may contain\n\t// (SOUND_LIMITS.MAX_LAYER_MS is 400 but every transient is < 100 ms), and\n\t// sources loop — a full second would cost ~190 KiB and ~0.8 ms of fill on\n\t// the user's very first cue for nothing.\n\tconst NOISE_BUFFER_SECONDS = 0.25;\n\tfunction getNoiseBuffer(context: AudioContext): AudioBuffer {\n\t\tif (noiseBuffer) return noiseBuffer;\n\t\tconst length = Math.max(1, Math.round(context.sampleRate * NOISE_BUFFER_SECONDS));\n\t\tconst buffer = context.createBuffer(1, length, context.sampleRate);\n\t\tfillNoise(buffer.getChannelData(0));\n\t\tnoiseBuffer = buffer;\n\t\treturn buffer;\n\t}\n\t// Each noise voice starts at a different offset into the shared buffer so\n\t// consecutive transients are not bit-identical; the rotation is\n\t// deterministic (no Math.random) so tests stay reproducible.\n\tlet noiseOffsetStep = 0;\n\tfunction nextNoiseOffset(buffer: AudioBuffer): number {\n\t\tnoiseOffsetStep = (noiseOffsetStep + 1) % 8;\n\t\treturn (buffer.duration * noiseOffsetStep) / 8;\n\t}\n\n\tfunction renderLayer(\n\t\tcontext: AudioContext,\n\t\tmasterNode: AudioNode,\n\t\tlayer: SoundLayer,\n\t\trecipeGain: number,\n\t\topts: { volume: number; pitch: number; rate: number }\n\t): VoiceHandle {\n\t\tconst { volume, pitch, rate } = opts;\n\t\tconst delayMs = layer.delay ?? 0;\n\t\tconst t0 = context.currentTime + delayMs / 1000 / rate;\n\t\t// MAX_LAYER_MS is documented as a non-configurable hard guard, but only\n\t\t// validateSoundTheme() enforces it — a theme passed straight to setTheme()\n\t\t// is never validated. Clamp here too so a huge finite duration cannot keep\n\t\t// a source/voice slot alive for minutes.\n\t\tconst durationMs = Math.min(layer.duration, SOUND_LIMITS.MAX_LAYER_MS);\n\t\tconst durationSec = durationMs / 1000 / rate;\n\t\tconst pitchMul = 2 ** (pitch / 12);\n\n\t\tlet source: OscillatorNode | AudioBufferSourceNode;\n\t\tlet noiseStartOffset = 0;\n\t\tif (layer.kind === \"oscillator\") {\n\t\t\tconst osc = context.createOscillator();\n\t\t\tosc.type = layer.wave;\n\t\t\tconst freqStart = layer.frequency * pitchMul;\n\t\t\tosc.frequency.value = freqStart;\n\t\t\tif (layer.detune) osc.detune.value = layer.detune;\n\t\t\tif (layer.frequencyEnd != null) {\n\t\t\t\tconst freqEnd = layer.frequencyEnd * pitchMul;\n\t\t\t\t// A glide shorter than the layer lets a fast pitch drop ring out on\n\t\t\t\t// its landing note; unset or out-of-range falls back to the whole layer.\n\t\t\t\tconst glideMs =\n\t\t\t\t\tlayer.glide != null && Number.isFinite(layer.glide) && layer.glide > 0\n\t\t\t\t\t\t? Math.min(layer.glide, durationMs)\n\t\t\t\t\t\t: durationMs;\n\t\t\t\tosc.frequency.setValueAtTime(freqStart, t0);\n\t\t\t\tosc.frequency.exponentialRampToValueAtTime(\n\t\t\t\t\tMath.max(1, freqEnd),\n\t\t\t\t\tt0 + glideMs / 1000 / rate\n\t\t\t\t);\n\t\t\t}\n\t\t\tsource = osc;\n\t\t} else {\n\t\t\tconst buf = context.createBufferSource();\n\t\t\tbuf.buffer = getNoiseBuffer(context);\n\t\t\tbuf.loop = true;\n\t\t\tsource = buf;\n\t\t\tnoiseStartOffset = nextNoiseOffset(buf.buffer);\n\t\t}\n\n\t\tconst nodes: AudioNode[] = [source];\n\t\tlet node: AudioNode = source;\n\n\t\tif (layer.filter) {\n\t\t\tconst filterNode = context.createBiquadFilter();\n\t\t\tfilterNode.type = layer.filter.type;\n\t\t\tfilterNode.frequency.value = layer.filter.frequency;\n\t\t\tif (layer.filter.q != null) filterNode.Q.value = layer.filter.q;\n\t\t\tif (layer.filter.frequencyEnd != null) {\n\t\t\t\tfilterNode.frequency.setValueAtTime(layer.filter.frequency, t0);\n\t\t\t\tfilterNode.frequency.exponentialRampToValueAtTime(\n\t\t\t\t\tMath.max(1, layer.filter.frequencyEnd),\n\t\t\t\t\tt0 + durationSec\n\t\t\t\t);\n\t\t\t}\n\t\t\tnode.connect(filterNode);\n\t\t\tnode = filterNode;\n\t\t\tnodes.push(filterNode);\n\t\t}\n\n\t\tconst gainNode = context.createGain();\n\t\tnode.connect(gainNode);\n\t\tgainNode.connect(masterNode);\n\t\tnodes.push(gainNode);\n\n\t\tconst a = Math.max(layer.envelope.attack, SOUND_LIMITS.MIN_ATTACK_MS) / 1000 / rate;\n\t\tconst d = layer.envelope.decay / 1000 / rate;\n\t\tconst r = Math.max(layer.envelope.release, SOUND_LIMITS.MIN_RELEASE_MS) / 1000 / rate;\n\t\tconst sustain = layer.envelope.sustain;\n\t\tconst peak = Math.min(layer.gain * recipeGain * volume, SOUND_LIMITS.LAYER_PEAK_CEILING);\n\t\tconst tDecayEnd = t0 + a + d;\n\t\t// Float drift can put `t0 + duration - r` a hair BEFORE `t0 + a + d` even\n\t\t// when the recipe makes them equal; a hold scheduled before the decay\n\t\t// ramp's end turns that whole ramp into a flat hold at the peak.\n\t\tconst tRel = Math.max(t0 + durationSec - r, tDecayEnd);\n\n\t\tgainNode.gain.setValueAtTime(0, t0);\n\t\tgainNode.gain.linearRampToValueAtTime(peak, t0 + a);\n\t\tif (layer.envelope.curve === \"exponential\" && peak > EXP_FLOOR) {\n\t\t\t// Exponential ramps cannot reach 0, so they fall to a floor ~-80 dB\n\t\t\t// below full scale and the final step to 0 lands on silence.\n\t\t\tconst held = Math.max(peak * sustain, EXP_FLOOR);\n\t\t\tif (d > 0) gainNode.gain.exponentialRampToValueAtTime(held, tDecayEnd);\n\t\t\tgainNode.gain.setValueAtTime(held, tRel);\n\t\t\tgainNode.gain.exponentialRampToValueAtTime(EXP_FLOOR, tRel + r);\n\t\t\tgainNode.gain.setValueAtTime(0, tRel + r);\n\t\t} else {\n\t\t\tgainNode.gain.linearRampToValueAtTime(peak * sustain, tDecayEnd);\n\t\t\tgainNode.gain.setValueAtTime(peak * sustain, tRel);\n\t\t\tgainNode.gain.linearRampToValueAtTime(0, tRel + r);\n\t\t}\n\n\t\tif (layer.kind === \"noise\") (source as AudioBufferSourceNode).start(t0, noiseStartOffset);\n\t\telse source.start(t0);\n\t\tsource.stop(t0 + durationSec + 0.01);\n\n\t\treturn { nodes, source };\n\t}\n\n\treturn {\n\t\tget state(): SoundEngineState {\n\t\t\treturn state;\n\t\t},\n\t\tget lastError(): string | null {\n\t\t\treturn lastError;\n\t\t},\n\t\tget voiceCount(): number {\n\t\t\treturn voiceCount;\n\t\t},\n\t\tget supported(): boolean {\n\t\t\treturn probeSupported();\n\t\t},\n\n\t\tplay(cue: SoundCue, options: SoundPlayOptions = {}): boolean {\n\t\t\tif (state === \"unsupported\") return false;\n\n\t\t\tconst recipe = theme.cues[cue];\n\t\t\tif (!recipe) return false;\n\n\t\t\tconst interval = minIntervals[cue] ?? SOUND_MIN_INTERVAL_MS[cue] ?? 15;\n\t\t\tconst last = lastAt[cue] ?? Number.NEGATIVE_INFINITY;\n\t\t\tif (nowFn() - last < interval) return false;\n\n\t\t\tconst context = ensureContext();\n\t\t\tif (!context) return false;\n\n\t\t\tif (context.state !== \"running\") {\n\t\t\t\tvoid kickResume(context);\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\t// Voices are counted per layer, so admit a cue only if ALL its layers fit.\n\t\t\tif (voiceCount + recipe.layers.length > SOUND_LIMITS.MAX_VOICES) return false;\n\n\t\t\tconst volume = clamp(Number.isFinite(options.volume) ? (options.volume as number) : 1, 0, 1);\n\t\t\tconst pitch = clamp(Number.isFinite(options.pitch) ? (options.pitch as number) : 0, -24, 24);\n\t\t\tconst rate = clamp(\n\t\t\t\tNumber.isFinite(options.playbackRate) ? (options.playbackRate as number) : 1,\n\t\t\t\t0.25,\n\t\t\t\t4\n\t\t\t);\n\t\t\tconst recipeGain = recipe.gain ?? 1;\n\n\t\t\tconst createdVoices: VoiceHandle[] = [];\n\t\t\ttry {\n\t\t\t\tif (!master) throw new Error(\"master gain missing\");\n\t\t\t\tfor (const layer of recipe.layers) {\n\t\t\t\t\tcreatedVoices.push(\n\t\t\t\t\t\trenderLayer(context, master, layer, recipeGain, { volume, pitch, rate })\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t} catch (err) {\n\t\t\t\tfor (const voice of createdVoices) {\n\t\t\t\t\tfor (const n of voice.nodes) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tn.disconnect();\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t// already disconnected\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tlastError = errorMessage(err);\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tvoiceCount += createdVoices.length;\n\t\t\tfor (const voice of createdVoices) {\n\t\t\t\tactiveVoices.add(voice);\n\t\t\t\tvoice.source.onended = () => {\n\t\t\t\t\tactiveVoices.delete(voice);\n\t\t\t\t\tfor (const n of voice.nodes) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tn.disconnect();\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t// already disconnected\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tvoiceCount = Math.max(0, voiceCount - 1);\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tlastAt[cue] = nowFn();\n\t\t\treturn true;\n\t\t},\n\n\t\tasync unlock(): Promise<boolean> {\n\t\t\ttry {\n\t\t\t\tconst context = ensureContext();\n\t\t\t\tif (!context) return false;\n\t\t\t\tif (context.state !== \"running\") {\n\t\t\t\t\tawait kickResume(context);\n\t\t\t\t}\n\t\t\t\treturn context.state === \"running\";\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t},\n\n\t\tsetMasterVolume(volume: number): void {\n\t\t\tconst v = clamp(Number.isFinite(volume) ? volume : 0, 0, 1);\n\t\t\tmasterVolume = v;\n\t\t\tif (master && ctx) {\n\t\t\t\tconst now = ctx.currentTime;\n\t\t\t\tconst target = v * SOUND_LIMITS.MASTER_CEILING;\n\t\t\t\tmaster.gain.cancelScheduledValues(now);\n\t\t\t\tmaster.gain.linearRampToValueAtTime(target, now + 0.02);\n\t\t\t}\n\t\t},\n\n\t\tsetTheme(next: SoundThemeDefinition): void {\n\t\t\ttheme = next;\n\t\t},\n\n\t\tdispose(): void {\n\t\t\t// Invalidates every async continuation captured before this point.\n\t\t\tdisposeGeneration += 1;\n\t\t\t// A hard stop by design: dispose() tears the graph down synchronously\n\t\t\t// (the context is closed right after), so a fade scheduled here could\n\t\t\t// never render. Voices are ≤ 300 ms and dispose() is not a user path.\n\t\t\tfor (const voice of [...activeVoices]) {\n\t\t\t\ttry {\n\t\t\t\t\tconst now = ctx ? ctx.currentTime : 0;\n\t\t\t\t\tconst gainNode = voice.nodes[voice.nodes.length - 1] as GainNode;\n\t\t\t\t\tgainNode.gain.cancelScheduledValues(now);\n\t\t\t\t\tgainNode.gain.setValueAtTime(0, now);\n\t\t\t\t\tvoice.source.stop(now);\n\t\t\t\t} catch {\n\t\t\t\t\t// best-effort; ignore\n\t\t\t\t}\n\t\t\t\tfor (const n of voice.nodes) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tn.disconnect();\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// already disconnected\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tactiveVoices.clear();\n\t\t\tvoiceCount = 0;\n\n\t\t\tif (master) {\n\t\t\t\ttry {\n\t\t\t\t\tmaster.disconnect();\n\t\t\t\t} catch {\n\t\t\t\t\t// already disconnected\n\t\t\t\t}\n\t\t\t\tmaster = null;\n\t\t\t}\n\t\t\tif (limiter) {\n\t\t\t\ttry {\n\t\t\t\t\tlimiter.disconnect();\n\t\t\t\t} catch {\n\t\t\t\t\t// already disconnected\n\t\t\t\t}\n\t\t\t\tlimiter = null;\n\t\t\t}\n\t\t\tif (ctx) {\n\t\t\t\tif (statechangeListener) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tctx.removeEventListener(\"statechange\", statechangeListener);\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// ignore\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tvoid ctx.close();\n\t\t\t\t} catch {\n\t\t\t\t\t// ignore\n\t\t\t\t}\n\t\t\t\tctx = null;\n\t\t\t}\n\t\t\tstatechangeListener = null;\n\t\t\tnoiseBuffer = null;\n\t\t\tresumePromise = null;\n\t\t\tfor (const key of Object.keys(lastAt)) delete lastAt[key as SoundCue];\n\n\t\t\tsetState(\"idle\", null);\n\t\t},\n\t};\n}\n","type":"registry:lib","target":"@ui/fancy/sound/engine.ts"},{"path":"react/src/sound/index.ts","content":"\"use client\";\n\n// Public barrel of the sound engine — mirrors src/lib/fancy-ui/sound/index.ts\n// on the Svelte side, plus the React-only hooks (use-sound.ts), which are this\n// package's counterpart of reading the `sound` controller's runes directly.\nexport {\n\tSoundToggle,\n\ttype SoundToggleProps,\n\ttype SoundToggleSize,\n\ttype SoundToggleVariant,\n} from \"./SoundToggle\";\nexport { sound, getSoundStatus, type SoundController, type SoundSnapshot } from \"./sound\";\nexport {\n\tattachSoundFeedback,\n\tuseSoundFeedback,\n\tDEFAULT_SOUND_FEEDBACK_ON,\n\ttype SoundFeedbackOptions,\n\ttype SoundFeedbackHandle,\n\ttype SoundCueResolver,\n\ttype SoundCueSpec,\n} from \"./sound-feedback\";\nexport {\n\tuseSound,\n\tuseSoundCue,\n\tuseSoundEnabled,\n\tuseSoundStatus,\n\ttype SoundControls,\n} from \"./use-sound\";\nexport { createSoundEngine, type SoundEngine, type SoundEngineOptions } from \"./engine\";\nexport { FANCY_SOUND_THEME, SOUND_THEMES, getSoundTheme, validateSoundTheme } from \"./themes\";\nexport {\n\tSOUND_CUES,\n\tSOUND_THEME_NAMES,\n\tSOUND_STORAGE_KEY,\n\tSOUND_MIN_INTERVAL_MS,\n\tSOUND_LIMITS,\n\tDEFAULT_SOUND_PREFERENCES,\n\ttype SoundCue,\n\ttype SoundThemeName,\n\ttype SoundPlayOptions,\n\ttype SoundPreferences,\n\ttype SoundPreferencesV1,\n\ttype SoundStatus,\n\ttype SoundEngineState,\n\ttype SoundThemeDefinition,\n\ttype CueRecipe,\n\ttype SoundLayer,\n\ttype SoundOscillatorLayer,\n\ttype SoundNoiseLayer,\n\ttype SoundEnvelope,\n\ttype SoundFilter,\n\ttype SoundWave,\n} from \"./types\";\n","type":"registry:lib","target":"@ui/fancy/sound/index.ts"},{"path":"react/src/sound/sound-feedback.ts","content":"\"use client\";\n\n/**\n * Declarative event → cue mapping for any element.\n *\n * Semantics:\n * - SSR (`typeof document === \"undefined\"`) → an inert handle, nothing bound.\n * - Listeners are bound `{ passive: true }`; this module never calls\n *   `preventDefault`/`stopPropagation` — it only listens.\n * - Defaults to `{ click: \"press\" }`. Hover is opt-in (`on: { pointerenter:\n *   \"hover\" }`) — a hover cue is a deliberate choice for dense pointer UI, not\n *   something every button should inherit. `on` REPLACES the defaults.\n * - Universal guard: `opts.disabled` or the node matching\n *   `:disabled,[aria-disabled=\"true\"],[data-disabled=\"true\"]` → silent.\n * - Hover guard (pointerenter/mouseenter only), all of which must pass:\n *   · `event.isTrusted` unless `allowUntrusted`;\n *   · `pointerType` is \"mouse\" or \"pen\" — a touch tap fires pointerenter and\n *     then click, and one tap must be one cue; \"\" is a synthesized pointer; a\n *     compatibility mouseenter from a tap carries `sourceCapabilities.firesTouchEvents`;\n *   · a pointer actually moved within the last HOVER_RECENCY_MS — content\n *     scrolling under a stationary pointer also fires pointerenter, and that\n *     is not a hover;\n *   · `relatedTarget` is not inside the node (re-entry from a descendant).\n * - Resolver errors are swallowed — a broken resolver silences that event,\n *   it never throws into the DOM dispatch.\n * - `update()` unbinds every listener and rebinds from the new options.\n *   `destroy()` unbinds every listener.\n */\n\nimport { useEffect } from \"react\";\nimport { useElementRef } from \"../internals/dom/use-element-ref\";\nimport { useLiveRef } from \"../internals/dom/use-live-ref\";\nimport { sound } from \"./sound\";\nimport type { SoundCue } from \"./types\";\n\n/** Return a cue to play, or null/undefined to stay silent for this event. */\nexport type SoundCueResolver = (event: Event) => SoundCue | null | undefined;\nexport type SoundCueSpec = SoundCue | SoundCueResolver;\n\nexport interface SoundFeedbackOptions {\n\t/**\n\t * DOM event name → cue. When omitted the default is `{ click: \"press\" }`.\n\t * When given it REPLACES the defaults — add `pointerenter: \"hover\"` to opt\n\t * into hover feedback.\n\t */\n\ton?: Record<string, SoundCueSpec>;\n\t/** Skips every cue while true. */\n\tdisabled?: boolean;\n\t/** Forwarded to `sound.play` for every cue. */\n\tvolume?: number;\n\tpitch?: number;\n\t/** Let `isTrusted === false` hover events through (tests, programmatic pointer events). Default false. */\n\tallowUntrusted?: boolean;\n}\n\n/** The handle `attachSoundFeedback` returns — the action's `{ update, destroy }`. */\nexport interface SoundFeedbackHandle {\n\tupdate(next?: SoundFeedbackOptions): void;\n\tdestroy(): void;\n}\n\nexport const DEFAULT_SOUND_FEEDBACK_ON: Readonly<Record<string, SoundCue>> = Object.freeze({\n\tclick: \"press\",\n});\n\nconst HOVER_EVENTS = new Set([\"pointerenter\", \"mouseenter\"]);\nconst HOVER_POINTER_TYPES = new Set([\"mouse\", \"pen\"]);\n/** A hover needs a pointer that moved this recently; scroll-under enters have no movement. */\nconst HOVER_RECENCY_MS = 150;\n\n// One document-level pointermove listener shared by every instance that maps a\n// hover event, attached with the first and removed with the last.\nlet lastPointerMoveAt = -Infinity;\nlet hoverInstances = 0;\nfunction onDocumentPointerMove(): void {\n\tlastPointerMoveAt = performance.now();\n}\nfunction retainHoverTracking(): void {\n\tif (hoverInstances++ === 0) {\n\t\tdocument.addEventListener(\"pointermove\", onDocumentPointerMove, {\n\t\t\tcapture: true,\n\t\t\tpassive: true,\n\t\t});\n\t}\n}\nfunction releaseHoverTracking(): void {\n\tif (--hoverInstances === 0) {\n\t\tdocument.removeEventListener(\"pointermove\", onDocumentPointerMove, { capture: true });\n\t\tlastPointerMoveAt = -Infinity;\n\t}\n}\n\n/** Test-only: drops the shared pointer-tracking state between tests. Not exported from index.ts. */\nexport function resetSoundFeedbackForTests(): void {\n\tif (hoverInstances > 0 && typeof document !== \"undefined\") {\n\t\tdocument.removeEventListener(\"pointermove\", onDocumentPointerMove, { capture: true });\n\t}\n\thoverInstances = 0;\n\tlastPointerMoveAt = -Infinity;\n}\n\n/** Test-only leak counter for the shared document-level `pointermove` listener. */\nexport function __soundFeedbackHoverInstances(): number {\n\treturn hoverInstances;\n}\n\n/**\n * The framework-free core: binds the mapped listeners to `node` and hands back\n * the action's own `{ update, destroy }`.\n */\nexport function attachSoundFeedback(\n\tnode: HTMLElement,\n\tinitialOptions?: SoundFeedbackOptions\n): SoundFeedbackHandle {\n\tif (typeof document === \"undefined\") {\n\t\t// The Svelte action returns a bare `{}` off-browser; the typed handle\n\t\t// makes that two no-ops instead. Nothing is bound either way.\n\t\treturn { update() {}, destroy() {} };\n\t}\n\n\tlet options = initialOptions ?? {};\n\tlet bound: Array<{ event: string; listener: EventListener }> = [];\n\tlet tracksHover = false;\n\n\tfunction isDisabled(): boolean {\n\t\tif (options.disabled) return true;\n\t\treturn node.matches(':disabled,[aria-disabled=\"true\"],[data-disabled=\"true\"]');\n\t}\n\n\tfunction isSuppressedHover(event: Event): boolean {\n\t\tif (event.isTrusted === false && !options.allowUntrusted) return true;\n\t\tconst pointerType = (event as PointerEvent).pointerType;\n\t\t// pointerenter always carries a pointerType; anything but mouse/pen is a\n\t\t// tap or a synthesized pointer. A bare mouseenter has none — then the only\n\t\t// tell for a tap is sourceCapabilities.firesTouchEvents.\n\t\tif (pointerType !== undefined && !HOVER_POINTER_TYPES.has(pointerType)) return true;\n\t\tconst capabilities = (\n\t\t\tevent as UIEvent & { sourceCapabilities?: { firesTouchEvents?: boolean } }\n\t\t).sourceCapabilities;\n\t\tif (capabilities?.firesTouchEvents) return true;\n\t\tif (performance.now() - lastPointerMoveAt > HOVER_RECENCY_MS) return true;\n\t\tconst relatedTarget = (event as MouseEvent).relatedTarget;\n\t\tif (relatedTarget instanceof Node && node.contains(relatedTarget)) return true;\n\t\treturn false;\n\t}\n\n\tfunction makeListener(eventName: string, spec: SoundCueSpec): EventListener {\n\t\treturn (event: Event) => {\n\t\t\tif (isDisabled()) return;\n\t\t\tif (HOVER_EVENTS.has(eventName) && isSuppressedHover(event)) return;\n\n\t\t\tlet cue: SoundCue | null | undefined;\n\t\t\ttry {\n\t\t\t\tcue = typeof spec === \"function\" ? spec(event) : spec;\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (cue == null) return;\n\t\t\tsound.play(cue, { volume: options.volume, pitch: options.pitch });\n\t\t};\n\t}\n\n\tfunction bind(): void {\n\t\tconst on = options.on ?? DEFAULT_SOUND_FEEDBACK_ON;\n\t\tbound = Object.entries(on).map(([event, spec]) => {\n\t\t\tconst listener = makeListener(event, spec);\n\t\t\tnode.addEventListener(event, listener, { passive: true });\n\t\t\treturn { event, listener };\n\t\t});\n\t\ttracksHover = bound.some(({ event }) => HOVER_EVENTS.has(event));\n\t\tif (tracksHover) retainHoverTracking();\n\t}\n\n\tfunction unbind(): void {\n\t\tfor (const { event, listener } of bound) {\n\t\t\tnode.removeEventListener(event, listener);\n\t\t}\n\t\tbound = [];\n\t\tif (tracksHover) {\n\t\t\ttracksHover = false;\n\t\t\treleaseHoverTracking();\n\t\t}\n\t}\n\n\tbind();\n\n\treturn {\n\t\tupdate(next?: SoundFeedbackOptions) {\n\t\t\toptions = next ?? {};\n\t\t\tunbind();\n\t\t\tbind();\n\t\t},\n\t\tdestroy() {\n\t\t\tunbind();\n\t\t},\n\t};\n}\n\nconst EMPTY_OPTIONS: SoundFeedbackOptions = {};\n/** Not a character any DOM event name contains, so the joined key is unambiguous. */\nconst EVENT_KEY_SEPARATOR = \"\u0000\";\n\n/** Duck-typed so it is also correct off-browser, where `Element` may not exist. */\nfunction isElement(value: unknown): value is HTMLElement {\n\treturn typeof (value as Node | null)?.nodeType === \"number\";\n}\n\n/**\n * The hook binding, in two shapes.\n *\n * `useSoundFeedback(options?)` returns a callback ref to put straight on the\n * element — the common case, and the idiomatic React answer to `use:soundFeedback`:\n *\n *     const soundRef = useSoundFeedback({ on: { click: \"press\" } });\n *     return <button ref={soundRef} />;\n *\n * `useSoundFeedback(node, options?)` takes a node you already hold (convention\n * C-1, paired with `useElementRef`); `null` binds nothing. Use it when the same\n * element is already published for another hook, so it is not published twice.\n *\n * The two are the same binding — the first just owns the node state for you.\n */\nexport function useSoundFeedback(node: HTMLElement | null, options?: SoundFeedbackOptions): void;\nexport function useSoundFeedback(\n\toptions?: SoundFeedbackOptions\n): (node: HTMLElement | null) => void;\nexport function useSoundFeedback(\n\tnodeOrOptions?: HTMLElement | null | SoundFeedbackOptions,\n\tmaybeOptions?: SoundFeedbackOptions\n): ((node: HTMLElement | null) => void) | void {\n\t// `null` is only ever a node — the options form is called with an object or\n\t// with nothing at all, so the two are never ambiguous at a real call site.\n\tconst isNodeForm = nodeOrOptions === null || isElement(nodeOrOptions);\n\n\t// Both hooks run on every call, in a fixed order: a single call site never\n\t// switches shape, but the rules of hooks are about the call, not the site.\n\tconst [ownNode, ownRef] = useElementRef<HTMLElement>();\n\n\tconst node = isNodeForm ? (nodeOrOptions as HTMLElement | null) : ownNode;\n\tconst options = isNodeForm ? maybeOptions : (nodeOrOptions as SoundFeedbackOptions | undefined);\n\n\tuseSoundFeedbackBinding(node, options);\n\n\treturn isNodeForm ? undefined : ownRef;\n}\n\n/**\n * The binding itself.\n *\n * D-8: the Svelte `update()` unbinds and rebinds every listener whenever any\n * option changes. In React `on` is typically an inline object literal, so that\n * would rebind on every render. The bind effect is keyed on the sorted, joined\n * event-NAME list, and the cue specs, `disabled`, `volume`, `pitch` and\n * `allowUntrusted` are all resolved through a live ref at dispatch time.\n * Listeners rebind only when the set of event names actually changes.\n * Observably identical, strictly less work.\n */\nfunction useSoundFeedbackBinding(node: HTMLElement | null, options?: SoundFeedbackOptions): void {\n\tconst optionsRef = useLiveRef(options ?? EMPTY_OPTIONS);\n\tconst eventKey = Object.keys(options?.on ?? DEFAULT_SOUND_FEEDBACK_ON)\n\t\t.sort()\n\t\t.join(EVENT_KEY_SEPARATOR);\n\n\tuseEffect(() => {\n\t\tif (!node) return;\n\n\t\tconst names = eventKey === \"\" ? [] : eventKey.split(EVENT_KEY_SEPARATOR);\n\t\tconst on: Record<string, SoundCueSpec> = {};\n\t\tfor (const name of names) {\n\t\t\t// A delegating resolver, so a changed cue for an unchanged event name\n\t\t\t// needs no rebind. The core's own try/catch still swallows a user\n\t\t\t// resolver that throws, because it calls this one from inside it.\n\t\t\ton[name] = (event: Event) => {\n\t\t\t\tconst spec = (optionsRef.current.on ?? DEFAULT_SOUND_FEEDBACK_ON)[name];\n\t\t\t\treturn typeof spec === \"function\" ? spec(event) : spec;\n\t\t\t};\n\t\t}\n\n\t\t// Getters, not a spread: the core reads these at dispatch time, so every\n\t\t// option but the event-name set stays live without rebinding.\n\t\tconst live: SoundFeedbackOptions = {\n\t\t\ton,\n\t\t\tget disabled() {\n\t\t\t\treturn optionsRef.current.disabled;\n\t\t\t},\n\t\t\tget volume() {\n\t\t\t\treturn optionsRef.current.volume;\n\t\t\t},\n\t\t\tget pitch() {\n\t\t\t\treturn optionsRef.current.pitch;\n\t\t\t},\n\t\t\tget allowUntrusted() {\n\t\t\t\treturn optionsRef.current.allowUntrusted;\n\t\t\t},\n\t\t};\n\n\t\tconst handle = attachSoundFeedback(node, live);\n\t\treturn () => {\n\t\t\thandle.destroy();\n\t\t};\n\t}, [node, eventKey, optionsRef]);\n}\n","type":"registry:lib","target":"@ui/fancy/sound/sound-feedback.ts"},{"path":"react/src/sound/sound-toggle.css","content":"/* Transposed from the Svelte source's <style> block. Every rule was already\n   anchored on `.ft-sound-toggle` / `.ft-sound-toggle-glyph`, so no port-added\n   anchor class is needed — see PORTING.md, styling rule 2. */\n\n.ft-sound-toggle {\n\t--ft-sound-toggle-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n}\n\n.ft-sound-toggle:focus-visible {\n\tbox-shadow: 0 0 0 3px color-mix(in oklab, var(--ft-sound-toggle-accent) 35%, transparent);\n}\n\n/* Resting state for both glyph groups: hidden and slightly shrunk. Only\n   the group matching data-state grows back to full opacity/scale — a\n   plain attribute selector, never a template conditional. */\n.ft-sound-toggle-glyph {\n\topacity: 0;\n\ttransform: scale(0.85);\n\ttransform-origin: 12px 12px;\n}\n.ft-sound-toggle[data-state=\"on\"] .ft-sound-toggle-glyph-on {\n\topacity: 1;\n\ttransform: scale(1);\n}\n.ft-sound-toggle[data-state=\"off\"] .ft-sound-toggle-glyph-off {\n\topacity: 1;\n\ttransform: scale(1);\n}\n\n/* The only motion here: a ≤200ms cross-fade between the two glyph groups\n   when data-state flips. No continuous animation, and no transition at\n   all outside this media query — the swap is an instant snap unless the\n   user has not asked to reduce motion. */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-sound-toggle-glyph {\n\t\ttransition:\n\t\t\topacity 150ms ease,\n\t\t\ttransform 150ms ease;\n\t}\n}\n","type":"registry:lib","target":"@ui/fancy/sound/sound-toggle.css"},{"path":"react/src/sound/sound.ts","content":"\"use client\";\n\n/**\n * Sound controller — the module-level singleton that owns the user's sound\n * preference, persists it, and drives the engine. It is a singleton because\n * `sound.play()` must be callable from anywhere, including code that never\n * runs inside a React tree.\n *\n * Invariants:\n * - ZERO browser-global access at module evaluation. Preferences hydrate\n *   lazily on the first IMPERATIVE entry point — any controller getter or\n *   method, or `hydrateSound()` called from an effect — never on import and\n *   never from a render path.\n * - `play()` is a no-op unless in the browser AND enabled AND the engine can\n *   schedule. It never throws.\n * - `enable()` never plays a cue itself; the confirmation cue lives in\n *   SoundToggle (inside the user's click).\n * - A cue that arrives while the AudioContext is still idle or suspended is\n *   not lost: exactly ONE pending cue is kept, the context is unlocked inside\n *   the same gesture, and that cue is replayed once the context runs. Bursts\n *   are never queued.\n * - `sound` is a plain object literal so tests can `vi.spyOn(sound, \"play\")`.\n *\n * React binding: a module-scope store read through `useSyncExternalStore`\n * (`subscribeSound` / `getSoundSnapshot` / `getSoundServerSnapshot`), with no\n * provider and no context. The `sound` prop is an ordinary boolean on seven\n * components and must work with zero setup; the state is genuinely global (one\n * storage key, one cross-tab listener, one AudioContext); and a store\n * re-renders only the components that actually subscribed, where a provider\n * would re-render its whole subtree on every volume change.\n */\n\nimport { createSoundEngine, type SoundEngine } from \"./engine\";\nimport { getSoundTheme } from \"./themes\";\nimport {\n\tDEFAULT_SOUND_PREFERENCES,\n\tSOUND_STORAGE_KEY,\n\tSOUND_THEME_NAMES,\n\ttype SoundCue,\n\ttype SoundPlayOptions,\n\ttype SoundPreferences,\n\ttype SoundStatus,\n\ttype SoundThemeName,\n} from \"./types\";\n\nexport interface SoundController {\n\t// --- current preferences (each read hydrates on first access) ---\n\treadonly enabled: boolean;\n\treadonly volume: number;\n\treadonly theme: SoundThemeName;\n\t/** Plain snapshot object (new object per read), safe to spread/JSON. */\n\treadonly preferences: SoundPreferences;\n\t/** Stable view over the live status; `getSoundStatus()` returns the same data as a fresh object. */\n\treadonly status: SoundStatus;\n\n\t// --- playback ---\n\t/** No-op (returns void, never throws) unless in browser AND enabled AND engine can schedule. */\n\tplay(cue: SoundCue, options?: SoundPlayOptions): void;\n\t/** Creates/resumes the AudioContext inside the current gesture. Resolves engine-running. */\n\tunlock(): Promise<boolean>;\n\n\t// --- preferences (each persists, notifies subscribers, mirrors into the engine) ---\n\t/** Also calls unlock() fire-and-forget — the enabling click IS the gesture. Plays nothing. */\n\tenable(): void;\n\tdisable(): void;\n\t/** Returns the new `enabled`. */\n\ttoggle(): boolean;\n\tsetEnabled(enabled: boolean): void;\n\t/** Clamped [0,1]; NaN ignored. */\n\tsetVolume(volume: number): void;\n\t/** Unknown names ignored. */\n\tsetTheme(theme: SoundThemeName): void;\n\n\t/** Store contract, unchanged: calls `run` immediately, then on every preference change. */\n\tsubscribe(run: (prefs: SoundPreferences) => void): () => void;\n}\n\n/** What a `useSyncExternalStore` reader sees. Identity-cached — see `getSoundSnapshot`. */\nexport interface SoundSnapshot {\n\treadonly enabled: boolean;\n\treadonly volume: number;\n\treadonly theme: SoundThemeName;\n\treadonly status: SoundStatus;\n}\n\nfunction createInitialStatus(): SoundStatus {\n\treturn {\n\t\tsupported: false,\n\t\tenabled: false,\n\t\tvolume: DEFAULT_SOUND_PREFERENCES.volume,\n\t\ttheme: DEFAULT_SOUND_PREFERENCES.theme,\n\t\tengine: \"idle\",\n\t\tstorage: \"untouched\",\n\t\tlastCue: null,\n\t\tlastPlayedAt: null,\n\t\tlastError: null,\n\t};\n}\n\nconst prefs: SoundPreferences = { ...DEFAULT_SOUND_PREFERENCES };\nconst status: SoundStatus = createInitialStatus();\nlet hydrated = false;\nlet storageListenerAttached = false;\nlet engine: SoundEngine | null = null;\nlet pending: { cue: SoundCue; options?: SoundPlayOptions } | null = null;\nlet unlocking = false;\nconst subscribers = new Set<(prefs: SoundPreferences) => void>();\nconst reactListeners = new Set<() => void>();\n/** Bumped by every mutation path; the snapshot cache is keyed on it. */\nlet version = 0;\n\n/** Pure parser for the stored JSON. Never throws. Exported for tests. */\nexport function parseStoredPreferences(raw: string | null): SoundPreferences {\n\tif (raw == null) return { ...DEFAULT_SOUND_PREFERENCES };\n\ttry {\n\t\tconst data = JSON.parse(raw) as Partial<Record<string, unknown>> | null;\n\t\tif (!data || typeof data !== \"object\" || data.v !== 1) return { ...DEFAULT_SOUND_PREFERENCES };\n\t\tconst volume =\n\t\t\ttypeof data.volume === \"number\" && Number.isFinite(data.volume)\n\t\t\t\t? Math.min(1, Math.max(0, data.volume))\n\t\t\t\t: DEFAULT_SOUND_PREFERENCES.volume;\n\t\tconst theme = (SOUND_THEME_NAMES as readonly string[]).includes(data.theme as string)\n\t\t\t? (data.theme as SoundThemeName)\n\t\t\t: DEFAULT_SOUND_PREFERENCES.theme;\n\t\treturn { enabled: data.enabled === true, volume, theme };\n\t} catch {\n\t\treturn { ...DEFAULT_SOUND_PREFERENCES };\n\t}\n}\n\n/**\n * Writes preferences field by field, never `prefs = …`. The Svelte reason (a\n * replacement `$state` proxy created inside a reader's own derived would be\n * untracked for that reader) is gone with the runes, but `ensureEngine`'s\n * options and `statusView` both read through this one object, so the rule is\n * kept: one object, mutated in place, and the two files stay diffable.\n */\nfunction assignPrefs(next: SoundPreferences): void {\n\tprefs.enabled = next.enabled;\n\tprefs.volume = next.volume;\n\tprefs.theme = next.theme;\n}\n\nfunction snapshot(): SoundPreferences {\n\treturn { enabled: prefs.enabled, volume: prefs.volume, theme: prefs.theme };\n}\n\n/**\n * Marks the state dirty and wakes every `useSyncExternalStore` reader.\n *\n * Deliberately split from `notify()`. `subscribe()`'s contract is a\n * PREFERENCES store: it must fire exactly where the Svelte controller fires it\n * and not one time more. A React reader additionally renders `status`, which\n * `persist()`, `markPlayed()` and the engine's `onStateChange` change without\n * touching preferences. So every mutation path bumps the version; only the\n * preference paths also fan out to `subscribers`.\n */\nfunction bump(): void {\n\tversion += 1;\n\tfor (const listener of reactListeners) listener();\n}\n\nfunction notify(): void {\n\tbump();\n\tconst next = snapshot();\n\tfor (const run of subscribers) run(next);\n}\n\n/** Persists the current preferences. Every failure is swallowed into `status.storage`. */\nfunction persist(): void {\n\tif (typeof window === \"undefined\") return;\n\ttry {\n\t\twindow.localStorage.setItem(\n\t\t\tSOUND_STORAGE_KEY,\n\t\t\tJSON.stringify({ v: 1, enabled: prefs.enabled, volume: prefs.volume, theme: prefs.theme })\n\t\t);\n\t\tstatus.storage = \"ok\";\n\t} catch (err) {\n\t\tstatus.storage = \"error\";\n\t\tstatus.lastError = err instanceof Error ? err.message : String(err);\n\t}\n\tbump();\n}\n\n/** Cross-tab sync. Never writes back to storage — that would loop the tab that wrote it. */\nfunction handleStorage(event: StorageEvent): void {\n\tif (event.key !== SOUND_STORAGE_KEY && event.key !== null) return;\n\tconst next = parseStoredPreferences(event.newValue);\n\t// The Svelte source wrapped the two writes below in `untrack`, because\n\t// Svelte rejects a `$state` write made while a derived is the active\n\t// reaction. React has no such rule and the wrappers are gone; nothing else\n\t// about this function changed.\n\tassignPrefs(next);\n\tstatus.storage = \"ok\";\n\tengine?.setMasterVolume(next.volume);\n\tengine?.setTheme(getSoundTheme(next.theme));\n\tnotify();\n}\n\n/** Lazy hydration: reads storage once, attaches the cross-tab listener once, probes support once. */\nfunction ensure(): void {\n\tif (hydrated) return;\n\thydrated = true;\n\tif (typeof window === \"undefined\") return;\n\ttry {\n\t\tassignPrefs(parseStoredPreferences(window.localStorage.getItem(SOUND_STORAGE_KEY)));\n\t\tstatus.storage = \"ok\";\n\t} catch {\n\t\tstatus.storage = \"unavailable\";\n\t}\n\t// Probe without constructing: a browser with no Web Audio is known\n\t// before the first click, so a SoundToggle can present itself honestly.\n\tstatus.supported = typeof window.AudioContext !== \"undefined\" || \"webkitAudioContext\" in window;\n\tif (!status.supported) status.engine = \"unsupported\";\n\tif (!storageListenerAttached) {\n\t\tstorageListenerAttached = true;\n\t\twindow.addEventListener(\"storage\", handleStorage);\n\t}\n\t// React readers start at the frozen defaults — the same values the server\n\t// rendered — and this is the moment they learn the stored preference.\n\t// `subscribers` are NOT called: `subscribe()` runs `ensure()` before it\n\t// registers, so a store consumer never saw the pre-hydration value.\n\tbump();\n}\n\n/** Creates the engine on first use. Creating it allocates nothing (no AudioContext yet). */\nfunction ensureEngine(): SoundEngine {\n\tif (!engine) {\n\t\tengine = createSoundEngine({\n\t\t\tonStateChange: (state, error) => {\n\t\t\t\tstatus.engine = state;\n\t\t\t\t// A transition without an error must not erase a storage message\n\t\t\t\t// the Sound Lab still wants to show; `lastError` means \"last\".\n\t\t\t\tif (error) status.lastError = error;\n\t\t\t\tbump();\n\t\t\t},\n\t\t\tmasterVolume: prefs.volume,\n\t\t\ttheme: getSoundTheme(prefs.theme),\n\t\t});\n\t}\n\treturn engine;\n}\n\nfunction markPlayed(cue: SoundCue): void {\n\tstatus.lastCue = cue;\n\tstatus.lastPlayedAt = performance.now();\n\tbump();\n}\n\n/**\n * Stable view over the live status. A fresh spread would hand every caller a\n * new object identity on every read; with getters, `sound.status` is one\n * object for the life of the module and still reports live values.\n */\nconst statusView: SoundStatus = {\n\tget supported() {\n\t\treturn status.supported;\n\t},\n\tget enabled() {\n\t\treturn prefs.enabled;\n\t},\n\tget volume() {\n\t\treturn prefs.volume;\n\t},\n\tget theme() {\n\t\treturn prefs.theme;\n\t},\n\tget engine() {\n\t\treturn status.engine;\n\t},\n\tget storage() {\n\t\treturn status.storage;\n\t},\n\tget lastCue() {\n\t\treturn status.lastCue;\n\t},\n\tget lastPlayedAt() {\n\t\treturn status.lastPlayedAt;\n\t},\n\tget lastError() {\n\t\treturn status.lastError;\n\t},\n};\n\nexport const sound: SoundController = {\n\tget enabled() {\n\t\tensure();\n\t\treturn prefs.enabled;\n\t},\n\tget volume() {\n\t\tensure();\n\t\treturn prefs.volume;\n\t},\n\tget theme() {\n\t\tensure();\n\t\treturn prefs.theme;\n\t},\n\tget preferences() {\n\t\tensure();\n\t\treturn snapshot();\n\t},\n\tget status() {\n\t\tensure();\n\t\treturn statusView;\n\t},\n\tplay(cue: SoundCue, options?: SoundPlayOptions): void {\n\t\tensure();\n\t\tif (typeof window === \"undefined\") return;\n\t\tif (!prefs.enabled) return;\n\t\tconst eng = ensureEngine();\n\t\tif (eng.play(cue, options)) {\n\t\t\tmarkPlayed(cue);\n\t\t\treturn;\n\t\t}\n\t\t// Not scheduled. If that is because the context is still idle (first\n\t\t// cue after a cold load), suspended (browser paused it), or blocked (an\n\t\t// earlier resume() was rejected outside a gesture — this call comes from\n\t\t// one, so it can still recover), unlock inside this same gesture and\n\t\t// replay exactly this one cue once running. Any other reason\n\t\t// (unsupported, rate-limited, voice cap) stays dropped.\n\t\tif (eng.state !== \"idle\" && eng.state !== \"suspended\" && eng.state !== \"blocked\") return;\n\t\tpending = { cue, options };\n\t\tif (unlocking) return;\n\t\tunlocking = true;\n\t\tvoid eng.unlock().then((ok) => {\n\t\t\tunlocking = false;\n\t\t\tconst next = pending;\n\t\t\tpending = null;\n\t\t\tif (!ok || !next || !prefs.enabled) return;\n\t\t\tif (eng.play(next.cue, next.options)) markPlayed(next.cue);\n\t\t});\n\t},\n\tunlock(): Promise<boolean> {\n\t\tensure();\n\t\tif (typeof window === \"undefined\") return Promise.resolve(false);\n\t\treturn ensureEngine().unlock();\n\t},\n\tenable(): void {\n\t\tensure();\n\t\tprefs.enabled = true;\n\t\tpersist();\n\t\tnotify();\n\t\t// Fire-and-forget: the click that enables sound IS the user gesture that\n\t\t// may unlock the AudioContext. No cue plays here — see SoundToggle.\n\t\tvoid sound.unlock();\n\t},\n\tdisable(): void {\n\t\tensure();\n\t\tprefs.enabled = false;\n\t\tpersist();\n\t\tnotify();\n\t},\n\t// toggle() and setEnabled() route through enable()/disable() so all three\n\t// share one unlock policy — a consumer building their own control with\n\t// `sound.toggle()` gets the same gesture-bound AudioContext as SoundToggle.\n\ttoggle(): boolean {\n\t\tensure();\n\t\tif (prefs.enabled) sound.disable();\n\t\telse sound.enable();\n\t\treturn prefs.enabled;\n\t},\n\tsetEnabled(enabled: boolean): void {\n\t\tensure();\n\t\tif (enabled === true) sound.enable();\n\t\telse sound.disable();\n\t},\n\tsetVolume(volume: number): void {\n\t\tensure();\n\t\tif (!Number.isFinite(volume)) return;\n\t\tprefs.volume = Math.min(1, Math.max(0, volume));\n\t\tpersist();\n\t\tengine?.setMasterVolume(prefs.volume);\n\t\tnotify();\n\t},\n\tsetTheme(theme: SoundThemeName): void {\n\t\tensure();\n\t\tif (!(SOUND_THEME_NAMES as readonly string[]).includes(theme)) return;\n\t\tprefs.theme = theme;\n\t\tpersist();\n\t\tengine?.setTheme(getSoundTheme(theme));\n\t\tnotify();\n\t},\n\tsubscribe(run: (prefs: SoundPreferences) => void): () => void {\n\t\tensure();\n\t\tsubscribers.add(run);\n\t\trun(snapshot());\n\t\treturn () => {\n\t\t\tsubscribers.delete(run);\n\t\t};\n\t},\n};\n\n/** Non-reactive snapshot for status lines / logging. Always a fresh plain object. */\nexport function getSoundStatus(): SoundStatus {\n\tensure();\n\treturn {\n\t\tsupported: status.supported,\n\t\tenabled: prefs.enabled,\n\t\tvolume: prefs.volume,\n\t\ttheme: prefs.theme,\n\t\tengine: status.engine,\n\t\tstorage: status.storage,\n\t\tlastCue: status.lastCue,\n\t\tlastPlayedAt: status.lastPlayedAt,\n\t\tlastError: status.lastError,\n\t};\n}\n\n// ---------------------------------------------------------------------------\n// The React store contract\n// ---------------------------------------------------------------------------\n\n/**\n * What the server renders and what the client's HYDRATION render returns:\n * the frozen defaults. Never calls `ensure()`, never touches localStorage, so\n * a server/client divergence is not reachable — the stored preference only\n * arrives after `hydrateSound()` runs in an effect.\n */\nconst SERVER_SNAPSHOT: SoundSnapshot = Object.freeze({\n\tenabled: DEFAULT_SOUND_PREFERENCES.enabled,\n\tvolume: DEFAULT_SOUND_PREFERENCES.volume,\n\ttheme: DEFAULT_SOUND_PREFERENCES.theme,\n\tstatus: Object.freeze(createInitialStatus()),\n});\n\nlet snapshotCache: SoundSnapshot = SERVER_SNAPSHOT;\nlet snapshotVersion = version;\n\nexport function subscribeSound(listener: () => void): () => void {\n\treactListeners.add(listener);\n\treturn () => {\n\t\treactListeners.delete(listener);\n\t};\n}\n\n/**\n * IDENTITY-CACHED, and mandatory: `useSyncExternalStore` calls `getSnapshot`\n * on every render and infinite-loops if the identity changes with no real\n * change. `sound.preferences` and `getSoundStatus()` still return a fresh\n * object per call by design — this cache is additive, rebuilt only when a\n * mutation path bumped the version.\n *\n * It never calls `ensure()`: a render path must not read localStorage. Before\n * hydration it returns exactly the object `getSoundServerSnapshot()` returns.\n */\nexport function getSoundSnapshot(): SoundSnapshot {\n\tif (snapshotVersion !== version) {\n\t\tsnapshotVersion = version;\n\t\tsnapshotCache = {\n\t\t\tenabled: prefs.enabled,\n\t\t\tvolume: prefs.volume,\n\t\t\ttheme: prefs.theme,\n\t\t\tstatus: {\n\t\t\t\tsupported: status.supported,\n\t\t\t\tenabled: prefs.enabled,\n\t\t\t\tvolume: prefs.volume,\n\t\t\t\ttheme: prefs.theme,\n\t\t\t\tengine: status.engine,\n\t\t\t\tstorage: status.storage,\n\t\t\t\tlastCue: status.lastCue,\n\t\t\t\tlastPlayedAt: status.lastPlayedAt,\n\t\t\t\tlastError: status.lastError,\n\t\t\t},\n\t\t};\n\t}\n\treturn snapshotCache;\n}\n\n/** The frozen defaults. NEVER calls `ensure()`, never touches localStorage. */\nexport function getSoundServerSnapshot(): SoundSnapshot {\n\treturn SERVER_SNAPSHOT;\n}\n\n/**\n * Idempotent. Reads localStorage, attaches the cross-tab listener, probes\n * AudioContext support without constructing one, then wakes every store\n * reader. Called from an EFFECT (`use-sound.ts`), never from a render.\n */\nexport function hydrateSound(): void {\n\tensure();\n}\n\n/**\n * Test-only: disposes the engine, resets prefs to defaults, clears the storage\n * listener and the preference subscribers, forgets storage state. Not exported\n * from index.ts.\n *\n * `reactListeners` is deliberately NOT cleared: those subscriptions belong to\n * `useSyncExternalStore` and are released by its own cleanup. Dropping one\n * behind React's back would strand a still-mounted component with a store it\n * can never hear from again.\n */\nexport function resetSoundForTests(): void {\n\tengine?.dispose();\n\tengine = null;\n\tif (storageListenerAttached && typeof window !== \"undefined\") {\n\t\twindow.removeEventListener(\"storage\", handleStorage);\n\t}\n\tstorageListenerAttached = false;\n\tsubscribers.clear();\n\tpending = null;\n\tunlocking = false;\n\thydrated = false;\n\tassignPrefs(DEFAULT_SOUND_PREFERENCES);\n\tObject.assign(status, createInitialStatus());\n\tbump();\n}\n","type":"registry:lib","target":"@ui/fancy/sound/sound.ts"},{"path":"react/src/sound/themes.ts","content":"/**\n * Sound themes — pure data plus a validator.\n *\n * `FANCY_SOUND_THEME` is the FancyUI palette: short tactile taps (one\n * oscillator, a fast downward pitch sweep under an exponential decay) and a\n * quiet sine bell kept for success. No noise, no held notes. Ascending pairs\n * and sweeps read as positive/open, descending ones as negative/close.\n */\n\nimport {\n\tSOUND_CUES,\n\tSOUND_LIMITS,\n\ttype CueRecipe,\n\ttype SoundCue,\n\ttype SoundEnvelope,\n\ttype SoundOscillatorLayer,\n\ttype SoundThemeDefinition,\n\ttype SoundThemeName,\n} from \"./types\";\n\nfunction env(attack: number, decay: number, sustain: number, release: number): SoundEnvelope {\n\treturn { attack, decay, sustain, release };\n}\n\nfunction exp(attack: number, decay: number, sustain: number, release: number): SoundEnvelope {\n\treturn { attack, decay, sustain, release, curve: \"exponential\" };\n}\n\nfunction deepFreeze<T>(value: T): T {\n\tif (value && typeof value === \"object\" && !Object.isFrozen(value)) {\n\t\tfor (const key of Object.keys(value as Record<string, unknown>)) {\n\t\t\tdeepFreeze((value as Record<string, unknown>)[key]);\n\t\t}\n\t\tObject.freeze(value);\n\t}\n\treturn value;\n}\n\n/**\n * A tap: one oscillator whose pitch falls a couple of octaves over the whole\n * decay while its level falls off exponentially. That fast, the ear hears no\n * note, only a soft knock. Every gesture cue below is built from taps.\n */\nfunction tap(\n\tfrequency: number,\n\tfrequencyEnd: number,\n\tgain: number,\n\tdecay: number,\n\textra: Partial<SoundOscillatorLayer> = {}\n): SoundOscillatorLayer {\n\treturn {\n\t\tkind: \"oscillator\",\n\t\twave: \"triangle\",\n\t\tfrequency,\n\t\tfrequencyEnd,\n\t\tglide: 2 + decay,\n\t\tgain,\n\t\tenvelope: exp(2, decay, 0, 8),\n\t\tduration: 2 + decay + 8,\n\t\t...extra,\n\t};\n}\n\n/** A quiet sine bell that rings out, reserved for outcomes (success). */\nfunction bell(frequency: number, gain: number, decay: number, delay = 0): SoundOscillatorLayer {\n\treturn {\n\t\tkind: \"oscillator\",\n\t\twave: \"sine\",\n\t\tfrequency,\n\t\tgain,\n\t\tenvelope: exp(2, decay, 0, 10),\n\t\tduration: 2 + decay + 10,\n\t\tdelay,\n\t};\n}\n\nconst FANCY_CUES: Record<SoundCue, CueRecipe> = {\n\t// Bright and tiny: present under the pointer, gone before it registers as a sound.\n\thover: {\n\t\tlayers: [tap(1680, 420, 0.5, 10, { wave: \"sine\" })],\n\t},\n\t// The reference cue every other one is tuned against.\n\tpress: {\n\t\tlayers: [tap(850, 160, 0.8, 18)],\n\t},\n\t\"toggle-on\": {\n\t\tlayers: [tap(700, 150, 0.6, 16), tap(1100, 240, 0.6, 16, { delay: 45 })],\n\t},\n\t\"toggle-off\": {\n\t\tlayers: [tap(1100, 240, 0.55, 16), tap(700, 150, 0.55, 16, { delay: 45 })],\n\t},\n\t// Rising and falling sine sweeps: direction tells open from close.\n\topen: {\n\t\tlayers: [\n\t\t\ttap(420, 1260, 0.45, 34, { wave: \"sine\", glide: 24 }),\n\t\t\ttap(850, 160, 0.35, 14, { delay: 4 }),\n\t\t],\n\t},\n\tclose: {\n\t\tlayers: [\n\t\t\ttap(1260, 380, 0.4, 34, { wave: \"sine\", glide: 24 }),\n\t\t\ttap(700, 140, 0.35, 14, { delay: 26 }),\n\t\t],\n\t},\n\tselect: {\n\t\tlayers: [tap(1150, 230, 0.7, 14)],\n\t},\n\tsuccess: {\n\t\tlayers: [tap(850, 160, 0.6, 18), bell(1046.5, 0.22, 110, 30), bell(1568, 0.18, 140, 90)],\n\t},\n\terror: {\n\t\tlayers: [\n\t\t\ttap(320, 120, 0.3, 40, { filter: { type: \"lowpass\", frequency: 1600 } }),\n\t\t\ttap(240, 95, 0.3, 50, { delay: 95, filter: { type: \"lowpass\", frequency: 1600 } }),\n\t\t],\n\t},\n\ttick: {\n\t\tlayers: [tap(2700, 675, 0.3, 8, { wave: \"sine\" })],\n\t},\n\tcopy: {\n\t\tlayers: [tap(1300, 260, 0.6, 14), tap(1800, 360, 0.55, 14, { delay: 40 })],\n\t},\n};\n\nexport const FANCY_SOUND_THEME: SoundThemeDefinition = deepFreeze({\n\tname: \"fancy\",\n\tcues: FANCY_CUES,\n});\n\nexport const SOUND_THEMES: Readonly<Record<SoundThemeName, SoundThemeDefinition>> = Object.freeze({\n\tfancy: FANCY_SOUND_THEME,\n});\n\n/** Falls back to the fancy theme for unknown names (runtime safety for stored prefs). */\nexport function getSoundTheme(name: SoundThemeName): SoundThemeDefinition {\n\treturn SOUND_THEMES[name] ?? FANCY_SOUND_THEME;\n}\n\nconst VALID_WAVES = new Set([\"sine\", \"triangle\", \"square\", \"sawtooth\"]);\nconst VALID_FILTERS = new Set([\"lowpass\", \"highpass\", \"bandpass\", \"notch\", \"peaking\"]);\n\n/**\n * Returns human-readable problems; [] when valid. Checks: every cue present, ≥1\n * layer, all numbers finite/non-negative, gains ≤ 1, sustain 0..1,\n * duration ≥ attack+decay+release, layer ≤ SOUND_LIMITS.MAX_LAYER_MS, whole-cue\n * span (max(delay+duration)) ≤ 120ms except success/error ≤ 300ms.\n */\nexport function validateSoundTheme(theme: SoundThemeDefinition): string[] {\n\tconst problems: string[] = [];\n\n\tfor (const cue of SOUND_CUES) {\n\t\tconst recipe = theme.cues[cue];\n\t\tif (!recipe || !recipe.layers || recipe.layers.length === 0) {\n\t\t\tproblems.push(`${cue}: missing recipe or no layers`);\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (recipe.gain != null) {\n\t\t\tif (!Number.isFinite(recipe.gain) || recipe.gain < 0) {\n\t\t\t\tproblems.push(`${cue}: recipe gain must be a finite number >= 0`);\n\t\t\t} else if (recipe.gain > 1) {\n\t\t\t\tproblems.push(`${cue}: recipe gain must be <= 1`);\n\t\t\t}\n\t\t}\n\n\t\tlet maxSpan = 0;\n\n\t\trecipe.layers.forEach((layer, index) => {\n\t\t\tconst label = `${cue}[${index}]`;\n\t\t\tconst delay = layer.delay ?? 0;\n\n\t\t\tconst numericFields: Array<[string, number]> = [\n\t\t\t\t[\"gain\", layer.gain],\n\t\t\t\t[\"duration\", layer.duration],\n\t\t\t\t[\"delay\", delay],\n\t\t\t\t[\"envelope.attack\", layer.envelope.attack],\n\t\t\t\t[\"envelope.decay\", layer.envelope.decay],\n\t\t\t\t[\"envelope.sustain\", layer.envelope.sustain],\n\t\t\t\t[\"envelope.release\", layer.envelope.release],\n\t\t\t];\n\t\t\tfor (const [field, value] of numericFields) {\n\t\t\t\tif (!Number.isFinite(value) || value < 0) {\n\t\t\t\t\tproblems.push(`${label}: ${field} must be a finite number >= 0`);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (Number.isFinite(layer.gain) && layer.gain > 1) {\n\t\t\t\tproblems.push(`${label}: gain must be <= 1`);\n\t\t\t}\n\t\t\tif (\n\t\t\t\tNumber.isFinite(layer.envelope.sustain) &&\n\t\t\t\t(layer.envelope.sustain < 0 || layer.envelope.sustain > 1)\n\t\t\t) {\n\t\t\t\tproblems.push(`${label}: envelope.sustain must be between 0 and 1`);\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tlayer.envelope.curve != null &&\n\t\t\t\tlayer.envelope.curve !== \"linear\" &&\n\t\t\t\tlayer.envelope.curve !== \"exponential\"\n\t\t\t) {\n\t\t\t\tproblems.push(`${label}: invalid envelope.curve \"${layer.envelope.curve}\"`);\n\t\t\t}\n\n\t\t\tconst requiredSpan = layer.envelope.attack + layer.envelope.decay + layer.envelope.release;\n\t\t\tif (Number.isFinite(layer.duration) && layer.duration < requiredSpan) {\n\t\t\t\tproblems.push(`${label}: duration must be >= attack + decay + release`);\n\t\t\t}\n\t\t\tif (Number.isFinite(layer.duration) && layer.duration > SOUND_LIMITS.MAX_LAYER_MS) {\n\t\t\t\tproblems.push(`${label}: duration must be <= ${SOUND_LIMITS.MAX_LAYER_MS}ms`);\n\t\t\t}\n\n\t\t\tif (layer.kind === \"oscillator\") {\n\t\t\t\tif (!VALID_WAVES.has(layer.wave)) {\n\t\t\t\t\tproblems.push(`${label}: invalid wave \"${layer.wave}\"`);\n\t\t\t\t}\n\t\t\t\tif (!Number.isFinite(layer.frequency) || layer.frequency <= 0) {\n\t\t\t\t\tproblems.push(`${label}: frequency must be a finite number > 0`);\n\t\t\t\t}\n\t\t\t\tif (\n\t\t\t\t\tlayer.frequencyEnd != null &&\n\t\t\t\t\t(!Number.isFinite(layer.frequencyEnd) || layer.frequencyEnd <= 0)\n\t\t\t\t) {\n\t\t\t\t\tproblems.push(`${label}: frequencyEnd must be a finite number > 0`);\n\t\t\t\t}\n\t\t\t\tif (\n\t\t\t\t\tlayer.glide != null &&\n\t\t\t\t\t(!Number.isFinite(layer.glide) ||\n\t\t\t\t\t\tlayer.glide <= 0 ||\n\t\t\t\t\t\t(Number.isFinite(layer.duration) && layer.glide > layer.duration))\n\t\t\t\t) {\n\t\t\t\t\tproblems.push(`${label}: glide must be a finite number > 0 and <= duration`);\n\t\t\t\t}\n\t\t\t\tif (layer.detune != null && !Number.isFinite(layer.detune)) {\n\t\t\t\t\tproblems.push(`${label}: detune must be a finite number`);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (layer.filter) {\n\t\t\t\tif (!VALID_FILTERS.has(layer.filter.type)) {\n\t\t\t\t\tproblems.push(`${label}: invalid filter type \"${layer.filter.type}\"`);\n\t\t\t\t}\n\t\t\t\tif (!Number.isFinite(layer.filter.frequency) || layer.filter.frequency <= 0) {\n\t\t\t\t\tproblems.push(`${label}: filter frequency must be a finite number > 0`);\n\t\t\t\t}\n\t\t\t\tif (\n\t\t\t\t\tlayer.filter.frequencyEnd != null &&\n\t\t\t\t\t(!Number.isFinite(layer.filter.frequencyEnd) || layer.filter.frequencyEnd <= 0)\n\t\t\t\t) {\n\t\t\t\t\tproblems.push(`${label}: filter frequencyEnd must be a finite number > 0`);\n\t\t\t\t}\n\t\t\t\tif (layer.filter.q != null && !Number.isFinite(layer.filter.q)) {\n\t\t\t\t\tproblems.push(`${label}: filter q must be a finite number`);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (Number.isFinite(delay) && Number.isFinite(layer.duration)) {\n\t\t\t\tmaxSpan = Math.max(maxSpan, delay + layer.duration);\n\t\t\t}\n\t\t});\n\n\t\tconst spanLimit = cue === \"success\" || cue === \"error\" ? 300 : 120;\n\t\tif (maxSpan > spanLimit) {\n\t\t\tproblems.push(`${cue}: total span ${maxSpan}ms exceeds ${spanLimit}ms limit`);\n\t\t}\n\t}\n\n\treturn problems;\n}\n","type":"registry:lib","target":"@ui/fancy/sound/themes.ts"},{"path":"react/src/sound/types.ts","content":"/**\n * Sound design — shared types and constants.\n *\n * Themes are DATA (a map from semantic cue to a synthesis recipe) and the\n * engine is the interpreter. Components only ever name a cue; they never\n * know how it sounds, which is what lets a future sound theme follow a visual\n * skin without coupling audio code to it.\n *\n * This module has no runtime side effects and touches no browser global.\n */\n\n/** Semantic UI cues. Every theme defines a recipe for each one. */\nexport const SOUND_CUES = [\n\t\"hover\",\n\t\"press\",\n\t\"toggle-on\",\n\t\"toggle-off\",\n\t\"open\",\n\t\"close\",\n\t\"select\",\n\t\"success\",\n\t\"error\",\n\t\"tick\",\n\t\"copy\",\n] as const;\nexport type SoundCue = (typeof SOUND_CUES)[number];\n\nexport const SOUND_THEME_NAMES = [\"fancy\"] as const;\nexport type SoundThemeName = (typeof SOUND_THEME_NAMES)[number];\n\nexport interface SoundPlayOptions {\n\t/** Multiplier on the cue's own gain, clamped to [0, 1]. Default 1. */\n\tvolume?: number;\n\t/** Semitone offset applied to every oscillator layer, clamped to [-24, 24]. Noise layers are unaffected. Default 0. */\n\tpitch?: number;\n\t/** Time-stretch divisor on every envelope, duration and delay, clamped to [0.25, 4]. 2 = twice as fast. Default 1. */\n\tplaybackRate?: number;\n}\n\nexport interface SoundPreferences {\n\t/** Master switch. Default false — sound is strictly opt-in. */\n\tenabled: boolean;\n\t/** 0..1, default 0.5. The engine multiplies it again by SOUND_LIMITS.MASTER_CEILING. */\n\tvolume: number;\n\ttheme: SoundThemeName;\n}\n\nexport const DEFAULT_SOUND_PREFERENCES: Readonly<SoundPreferences> = Object.freeze({\n\tenabled: false,\n\tvolume: 0.5,\n\ttheme: \"fancy\",\n});\n\n/** What `localStorage[\"fancy-ui-sound\"]` holds: `{ v: 1, ...SoundPreferences }`. */\nexport interface SoundPreferencesV1 extends SoundPreferences {\n\tv: 1;\n}\nexport const SOUND_STORAGE_KEY = \"fancy-ui-sound\";\n\n// ---------------------------------------------------------------------------\n// Synthesis description\n// ---------------------------------------------------------------------------\n\nexport type SoundWave = \"sine\" | \"triangle\" | \"square\" | \"sawtooth\";\n\n/** All times in milliseconds (before `playbackRate`). `sustain` is a level 0..1. */\nexport interface SoundEnvelope {\n\tattack: number;\n\tdecay: number;\n\tsustain: number;\n\trelease: number;\n\t/**\n\t * Shape of the decay and release segments. `\"linear\"` (default) ramps\n\t * straight to the target; `\"exponential\"` falls off like a struck object,\n\t * which is what makes short percussive taps sound natural. Attack is always linear.\n\t */\n\tcurve?: \"linear\" | \"exponential\";\n}\n\nexport interface SoundFilter {\n\ttype: \"lowpass\" | \"highpass\" | \"bandpass\" | \"notch\" | \"peaking\";\n\t/** Hz at the layer's start. */\n\tfrequency: number;\n\t/** Hz at the layer's end; exponential glide over the layer's `duration` when set. */\n\tfrequencyEnd?: number;\n\tq?: number;\n}\n\ninterface SoundLayerBase {\n\t/** Peak linear gain 0..1 (engine clamps to SOUND_LIMITS.LAYER_PEAK_CEILING). */\n\tgain: number;\n\tenvelope: SoundEnvelope;\n\tfilter?: SoundFilter;\n\t/** Ms after the cue's start before this layer begins. Default 0. */\n\tdelay?: number;\n\t/** Total ms from this layer's start to the end of its release. Release begins at `duration - envelope.release`. */\n\tduration: number;\n}\n\nexport interface SoundOscillatorLayer extends SoundLayerBase {\n\tkind: \"oscillator\";\n\twave: SoundWave;\n\t/** Hz at start. */\n\tfrequency: number;\n\t/** Hz at end; exponential glide over `glide` (default `duration`) when set. */\n\tfrequencyEnd?: number;\n\t/** Ms to reach `frequencyEnd`, > 0 and <= `duration`. Default `duration`. */\n\tglide?: number;\n\t/** Cents. Default 0. */\n\tdetune?: number;\n}\n\nexport interface SoundNoiseLayer extends SoundLayerBase {\n\tkind: \"noise\";\n}\n\nexport type SoundLayer = SoundOscillatorLayer | SoundNoiseLayer;\n\nexport interface CueRecipe {\n\tlayers: SoundLayer[];\n\t/** Trim applied to every layer, 0..1. Default 1. */\n\tgain?: number;\n}\n\nexport interface SoundThemeDefinition {\n\tname: SoundThemeName;\n\tcues: Record<SoundCue, CueRecipe>;\n}\n\n// ---------------------------------------------------------------------------\n// Engine\n// ---------------------------------------------------------------------------\n\n/**\n * idle         — no AudioContext yet (nothing attempted, or attempted outside a user gesture)\n * ready        — context running\n * suspended    — context exists but is suspended/interrupted; resume pending or awaiting next gesture\n * unsupported  — no AudioContext constructor, or construction threw (also SSR)\n * blocked      — resume() rejected, or the context is closed\n */\nexport type SoundEngineState = \"idle\" | \"ready\" | \"suspended\" | \"unsupported\" | \"blocked\";\n\n/** Default same-cue minimum interval, ms. Engine-enforced, independent of the action's own guards. */\nexport const SOUND_MIN_INTERVAL_MS: Readonly<Record<SoundCue, number>> = Object.freeze({\n\thover: 30,\n\ttick: 15,\n\tselect: 40,\n\tpress: 15,\n\t\"toggle-on\": 15,\n\t\"toggle-off\": 15,\n\topen: 15,\n\tclose: 15,\n\tsuccess: 15,\n\terror: 15,\n\tcopy: 15,\n});\n\n/** Hard safety limits. Not configurable. */\nexport const SOUND_LIMITS = Object.freeze({\n\t/** Master gain = prefs.volume * MASTER_CEILING. */\n\tMASTER_CEILING: 0.8,\n\t/** Per-layer peak gain clamp. */\n\tLAYER_PEAK_CEILING: 0.9,\n\t/** Concurrent voices; extra `play()` calls are dropped, never steal. */\n\tMAX_VOICES: 8,\n\t/** Minimum attack / release enforced for click-free edges. */\n\tMIN_ATTACK_MS: 2,\n\tMIN_RELEASE_MS: 8,\n\t/** Longest any single layer may run (guards a malformed theme). */\n\tMAX_LAYER_MS: 400,\n});\n\nexport interface SoundStatus {\n\t/** `true` once an AudioContext constructor was found in this environment. `false` on the server and until first probe. */\n\tsupported: boolean;\n\tenabled: boolean;\n\tvolume: number;\n\ttheme: SoundThemeName;\n\tengine: SoundEngineState;\n\t/** Outcome of the most recent storage read/write. */\n\tstorage: \"untouched\" | \"ok\" | \"unavailable\" | \"error\";\n\tlastCue: SoundCue | null;\n\t/** `performance.now()` of the last cue actually scheduled, or null. */\n\tlastPlayedAt: number | null;\n\t/** Last swallowed Web Audio / storage error message, for the Sound Lab status line. */\n\tlastError: string | null;\n}\n","type":"registry:lib","target":"@ui/fancy/sound/types.ts"},{"path":"react/src/sound/use-sound.ts","content":"\"use client\";\n\n/**\n * The React bindings over the sound singleton.\n *\n * Two audiences, two hooks. A component with a `sound` PROP only ever plays a\n * cue, so it uses `useSoundCue` and never subscribes. A control that RENDERS\n * the preference — SoundToggle, the docs' Sound Lab — reads the store through\n * `useSound` / `useSoundEnabled` / `useSoundStatus`, which subscribe and which\n * trigger the one lazy `localStorage` read from an effect.\n */\n\nimport { useCallback, useEffect, useMemo, useSyncExternalStore } from \"react\";\nimport { useLiveRef } from \"../internals/dom/use-live-ref\";\nimport {\n\tgetSoundServerSnapshot,\n\tgetSoundSnapshot,\n\thydrateSound,\n\tsound,\n\tsubscribeSound,\n\ttype SoundController,\n\ttype SoundSnapshot,\n} from \"./sound\";\nimport type {\n\tSoundCue,\n\tSoundEngineState,\n\tSoundPlayOptions,\n\tSoundStatus,\n\tSoundThemeName,\n} from \"./types\";\n\n/** The imperative half of `useSound()`'s return value. */\nexport type SoundControls = Pick<\n\tSoundController,\n\t\"play\" | \"unlock\" | \"enable\" | \"disable\" | \"toggle\" | \"setEnabled\" | \"setVolume\" | \"setTheme\"\n>;\n\n/**\n * One frozen module-scope object, so the identity is stable for every consumer\n * for the life of the page. Every method delegates to `sound` at CALL time\n * rather than capturing the function — that is what keeps `vi.spyOn(sound,\n * \"play\")` observable through the hook, and what lets `enable()` reach the\n * live `sound.unlock()`.\n */\nconst CONTROLS: SoundControls = Object.freeze({\n\tplay(cue: SoundCue, options?: SoundPlayOptions): void {\n\t\tsound.play(cue, options);\n\t},\n\tunlock(): Promise<boolean> {\n\t\treturn sound.unlock();\n\t},\n\tenable(): void {\n\t\tsound.enable();\n\t},\n\tdisable(): void {\n\t\tsound.disable();\n\t},\n\ttoggle(): boolean {\n\t\treturn sound.toggle();\n\t},\n\tsetEnabled(enabled: boolean): void {\n\t\tsound.setEnabled(enabled);\n\t},\n\tsetVolume(volume: number): void {\n\t\tsound.setVolume(volume);\n\t},\n\tsetTheme(theme: SoundThemeName): void {\n\t\tsound.setTheme(theme);\n\t},\n});\n\nconst getEnabled = (): boolean => getSoundSnapshot().enabled;\nconst getServerEnabled = (): boolean => getSoundServerSnapshot().enabled;\nconst getStatus = (): SoundStatus => getSoundSnapshot().status;\nconst getServerStatus = (): SoundStatus => getSoundServerSnapshot().status;\nconst getEngineState = (): SoundEngineState => getSoundSnapshot().status.engine;\nconst getServerEngineState = (): SoundEngineState => getSoundServerSnapshot().status.engine;\n\n/**\n * The one place storage is read. It runs in an effect, so the server render\n * and the hydration render both see the defaults and only the commit that\n * follows can learn the stored preference.\n */\nfunction useHydrateSound(): void {\n\tuseEffect(() => {\n\t\thydrateSound();\n\t}, []);\n}\n\n/**\n * A cue player for a component's own `sound` prop. Permanently identity-stable;\n * a no-op while `enabled` is falsy.\n *\n * DELIBERATELY DOES NOT SUBSCRIBE — a Button must not re-render because the\n * user changed the volume in a settings panel elsewhere on the page. Whether a\n * cue is audible is decided inside `sound.play()` at call time, which is also\n * where the lazy hydration happens for this path.\n */\nexport function useSoundCue(\n\tenabled: boolean | undefined\n): (cue: SoundCue, options?: SoundPlayOptions) => void {\n\tconst enabledRef = useLiveRef(enabled);\n\n\treturn useCallback(\n\t\t(cue: SoundCue, options?: SoundPlayOptions) => {\n\t\t\tif (!enabledRef.current) return;\n\t\t\tsound.play(cue, options);\n\t\t},\n\t\t[enabledRef]\n\t);\n}\n\n/**\n * The full store plus the imperative controls. Subscribes, and runs\n * `hydrateSound()` in an effect. For controls that RENDER the preference.\n */\nexport function useSound(): SoundSnapshot & SoundControls {\n\tconst snapshot = useSyncExternalStore(\n\t\tsubscribeSound,\n\t\tgetSoundSnapshot,\n\t\tgetSoundServerSnapshot\n\t);\n\tuseHydrateSound();\n\n\t// Memoised on the snapshot alone: `CONTROLS` never changes identity, so the\n\t// returned object changes exactly when the state a consumer renders changed.\n\treturn useMemo(() => ({ ...snapshot, ...CONTROLS }), [snapshot]);\n}\n\n/**\n * Just the master switch. Selecting the scalar rather than the snapshot is what\n * keeps a toggle from re-rendering on every cue: `status.lastPlayedAt` moves on\n * each `play()`, and `useSyncExternalStore` bails out when the selected value\n * is `Object.is`-equal.\n */\nexport function useSoundEnabled(): boolean {\n\tconst enabled = useSyncExternalStore(subscribeSound, getEnabled, getServerEnabled);\n\tuseHydrateSound();\n\treturn enabled;\n}\n\n/**\n * The live status object (identity-cached by the store).\n *\n * A reader of the WHOLE object wakes for every field, `lastCue` and\n * `lastPlayedAt` included — so a component using this re-renders on every cue\n * played anywhere on the page. Read a single field through a selector hook\n * (`useSoundEnabled`, `useSoundEngineState`) unless you genuinely render more\n * than one.\n */\nexport function useSoundStatus(): SoundStatus {\n\tconst status = useSyncExternalStore(subscribeSound, getStatus, getServerStatus);\n\tuseHydrateSound();\n\treturn status;\n}\n\n/**\n * Just the engine's state. Internal to the family — SoundToggle renders this\n * one field of `status`, and `markPlayed()` rewrites the whole status object on\n * every cue, so selecting the scalar is what keeps a toggle from re-rendering\n * because a button elsewhere on the page made a sound. Same `Object.is`\n * bail-out `useSoundEnabled` relies on.\n */\nexport function useSoundEngineState(): SoundEngineState {\n\tconst engineState = useSyncExternalStore(subscribeSound, getEngineState, getServerEngineState);\n\tuseHydrateSound();\n\treturn engineState;\n}\n","type":"registry:lib","target":"@ui/fancy/sound/use-sound.ts"}]}