{
	"$schema": "https://shadcn-svelte.com/schema/registry-item.json",
	"name": "speech-input",
	"title": "Speech Input",
	"type": "registry:ui",
	"description": "A push-to-record speech input with preview, cancel, and a pluggable transcription adapter.",
	"dependencies": [
		"@lucide/svelte",
		"tailwind-variants"
	],
	"devDependencies": [
		"@lucide/svelte@^1.7.0",
		"tailwind-variants@^1.0.0"
	],
	"registryDependencies": [
		"button",
		"skeleton"
	],
	"files": [
		{
			"content": "<script lang=\"ts\" module>\n\timport type { HTMLAttributes } from \"svelte/elements\";\n\timport type { Snippet } from \"svelte\";\n\timport type { TranscriptionAdapter } from \"./types.js\";\n\timport type { SpeechInputData, ButtonSize } from \"./context.svelte.js\";\n\n\texport type SpeechInputProps = Omit<HTMLAttributes<HTMLDivElement>, \"children\" | \"onerror\"> & {\n\t\t/**\n\t\t * STT backend bridge that owns the transcription session. Conforms to\n\t\t * [`TranscriptionAdapter`](/docs/providers#transcriptionadapter).\n\t\t */\n\t\tadapter: TranscriptionAdapter;\n\t\t/**\n\t\t * Shared size applied to `SpeechInputRecordButton` and\n\t\t * `SpeechInputCancelButton` via context.\n\t\t * @default \"default\"\n\t\t */\n\t\tsize?: ButtonSize;\n\t\t/** Fired once the adapter reports the connection is ready for audio. */\n\t\tonStart?: (data: SpeechInputData) => void;\n\t\t/**\n\t\t * Fired when the user stops recording. Receives a snapshot of the\n\t\t * transcript — any in-flight partial is preserved.\n\t\t */\n\t\tonStop?: (data: SpeechInputData) => void;\n\t\t/**\n\t\t * Fired when the user cancels recording. Receives the snapshot taken\n\t\t * before partial + committed state is cleared.\n\t\t */\n\t\tonCancel?: (data: SpeechInputData) => void;\n\t\t/** Fired on every partial or committed transcript update. */\n\t\tonChange?: (data: SpeechInputData) => void;\n\t\t/** Fired when the adapter surfaces an error or `start()` rejects. */\n\t\tonError?: (error: Error) => void;\n\t\t/**\n\t\t * Compound children — typically `SpeechInputRecordButton`,\n\t\t * `SpeechInputPreview`, and `SpeechInputCancelButton` in any order.\n\t\t */\n\t\tchildren?: Snippet;\n\t\t/** Bindable ref to the root `<div>` element. */\n\t\tref?: HTMLDivElement | null;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport { onDestroy } from \"svelte\";\n\timport { cn } from \"$UTILS$.js\";\n\timport { setSpeechInput } from \"./context.svelte.js\";\n\n\tlet {\n\t\tadapter,\n\t\tsize = \"default\",\n\t\tonStart,\n\t\tonStop,\n\t\tonCancel,\n\t\tonChange,\n\t\tonError,\n\t\tclass: className,\n\t\tchildren,\n\t\tref = $bindable(null),\n\t\t...rest\n\t}: SpeechInputProps = $props();\n\n\tconst state = setSpeechInput();\n\n\t// Sync props → state on every render. Adapter reference is stored via\n\t// configure() as a plain private field, not reactive state — swapping\n\t// adapters mid-recording is explicitly unsupported and does nothing\n\t// until the next start() call.\n\t$effect(() => {\n\t\tstate.configure({\n\t\t\tadapter,\n\t\t\tcallbacks: { onStart, onStop, onCancel, onChange, onError },\n\t\t});\n\t\tstate.size = size;\n\t});\n\n\t// Teardown on unmount only. We deliberately use onDestroy here instead of\n\t// $effect(() => () => ...). $effect cleanup runs on every re-run (e.g.,\n\t// dev-mode HMR, parent re-mounts), which would cancel recording mid-session.\n\t// onDestroy fires only when the component is destroyed.\n\tonDestroy(() => {\n\t\tif (state.status !== \"idle\") state.cancel();\n\t});\n</script>\n\n<div\n\tbind:this={ref}\n\tdata-slot=\"speech-input-root\"\n\tclass={cn(\n\t\t\"relative inline-flex items-center overflow-hidden rounded-md transition-all duration-200\",\n\t\tstate.isConnected &&\n\t\t\t\"bg-background dark:bg-muted shadow-[inset_0_0_0_1px_var(--color-input),0_1px_2px_0_rgba(0,0,0,0.05)]\",\n\t\tclassName\n\t)}\n\t{...rest}\n>\n\t{@render children?.()}\n</div>\n",
			"type": "registry:ui",
			"target": "speech-input/speech-input.svelte"
		},
		{
			"content": "<script lang=\"ts\" module>\n\timport type { ComponentProps } from \"svelte\";\n\timport { Button } from \"$UI$/button/index.js\";\n\n\texport type SpeechInputCancelButtonProps = Omit<\n\t\tComponentProps<typeof Button>,\n\t\t\"size\" | \"onclick\"\n\t> & {\n\t\tonclick?: (e: MouseEvent) => void;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport XIcon from \"@lucide/svelte/icons/x\";\n\timport { cn } from \"$UTILS$.js\";\n\timport { useSpeechInput } from \"./context.svelte.js\";\n\timport { buttonSizeVariants } from \"./variants.js\";\n\n\tlet {\n\t\tclass: className,\n\t\tonclick,\n\t\tvariant = \"ghost\",\n\t\t...rest\n\t}: SpeechInputCancelButtonProps = $props();\n\n\tconst state = useSpeechInput();\n</script>\n\n<Button\n\ttype=\"button\"\n\t{variant}\n\tinert={!state.isConnected}\n\tonclick={(e) => {\n\t\tstate.cancel();\n\t\tonclick?.(e);\n\t}}\n\taria-label=\"Cancel recording\"\n\tclass={cn(\n\t\tbuttonSizeVariants({ size: state.size }),\n\t\t\"transition-[opacity,transform,width] duration-200 ease-out\",\n\t\tstate.isConnected ? \"scale-[80%] opacity-100\" : \"pointer-events-none w-0 scale-100 opacity-0\",\n\t\tclassName\n\t)}\n\tdata-slot=\"speech-input-cancel-button\"\n\t{...rest}\n>\n\t<XIcon class=\"h-3 w-3\" />\n</Button>\n",
			"type": "registry:ui",
			"target": "speech-input/speech-input-cancel-button.svelte"
		},
		{
			"content": "<script lang=\"ts\" module>\n\timport type { HTMLAttributes } from \"svelte/elements\";\n\n\texport type SpeechInputPreviewProps = HTMLAttributes<HTMLDivElement> & {\n\t\t/** Text shown when no transcript yet. Defaults to \"Listening...\". */\n\t\tplaceholder?: string;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport { cn } from \"$UTILS$.js\";\n\timport { useSpeechInput } from \"./context.svelte.js\";\n\n\tlet {\n\t\tclass: className,\n\t\tplaceholder = \"Listening...\",\n\t\t...rest\n\t}: SpeechInputPreviewProps = $props();\n\n\tconst state = useSpeechInput();\n\n\tconst displayText = $derived(state.transcript || placeholder);\n\tconst showPlaceholder = $derived(!state.transcript.trim());\n</script>\n\n<div\n\tinert={!state.isConnected}\n\taria-hidden={!state.isConnected}\n\ttitle={displayText}\n\tclass={cn(\n\t\t\"relative self-stretch text-sm transition-[opacity,transform,width] duration-200 ease-out\",\n\t\tshowPlaceholder ? \"text-muted-foreground italic\" : \"text-muted-foreground\",\n\t\tstate.isConnected ? \"w-28 opacity-100\" : \"w-0 opacity-0\",\n\t\tclassName\n\t)}\n\tdata-slot=\"speech-input-preview\"\n\t{...rest}\n>\n\t<div\n\t\tclass=\"absolute inset-y-0 -right-1 -left-1 [mask-image:linear-gradient(to_right,transparent,black_10px,black_calc(100%-10px),transparent)]\"\n\t>\n\t\t<p\n\t\t\tclass=\"absolute top-0 right-0 bottom-0 flex h-full min-w-full items-center px-1 whitespace-nowrap\"\n\t\t>\n\t\t\t{displayText}\n\t\t</p>\n\t</div>\n</div>\n",
			"type": "registry:ui",
			"target": "speech-input/speech-input-preview.svelte"
		},
		{
			"content": "<script lang=\"ts\" module>\n\timport type { ComponentProps } from \"svelte\";\n\timport { Button } from \"$UI$/button/index.js\";\n\n\texport type SpeechInputRecordButtonProps = Omit<\n\t\tComponentProps<typeof Button>,\n\t\t\"size\" | \"onclick\"\n\t> & {\n\t\tonclick?: (e: MouseEvent) => void;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport MicIcon from \"@lucide/svelte/icons/mic\";\n\timport SquareIcon from \"@lucide/svelte/icons/square\";\n\timport { Skeleton } from \"$UI$/skeleton/index.js\";\n\timport { cn } from \"$UTILS$.js\";\n\timport { useSpeechInput } from \"./context.svelte.js\";\n\timport { buttonSizeVariants } from \"./variants.js\";\n\n\tlet {\n\t\tclass: className,\n\t\tonclick,\n\t\tvariant = \"ghost\",\n\t\tdisabled,\n\t\t...rest\n\t}: SpeechInputRecordButtonProps = $props();\n\n\tconst state = useSpeechInput();\n</script>\n\n<Button\n\ttype=\"button\"\n\t{variant}\n\tdisabled={disabled ?? state.isConnecting}\n\tonclick={(e) => {\n\t\tif (state.isConnected) {\n\t\t\tstate.stop();\n\t\t} else {\n\t\t\tvoid state.start();\n\t\t}\n\t\tonclick?.(e);\n\t}}\n\taria-label={state.isConnected ? \"Stop recording\" : \"Start recording\"}\n\tclass={cn(\n\t\tbuttonSizeVariants({ size: state.size }),\n\t\t\"relative flex items-center justify-center transition-all\",\n\t\tstate.isConnected && \"scale-[80%]\",\n\t\tclassName\n\t)}\n\tdata-slot=\"speech-input-record-button\"\n\t{...rest}\n>\n\t<Skeleton\n\t\tclass={cn(\n\t\t\t\"absolute h-4 w-4 rounded-full transition-all duration-200\",\n\t\t\tstate.isConnecting ? \"bg-primary scale-90\" : \"scale-[60%] bg-transparent\"\n\t\t)}\n\t/>\n\t<SquareIcon\n\t\tclass={cn(\n\t\t\t\"text-destructive absolute h-4 w-4 fill-current transition-all duration-200\",\n\t\t\t!state.isConnecting && state.isConnected ? \"scale-100 opacity-100\" : \"scale-[60%] opacity-0\"\n\t\t)}\n\t/>\n\t<MicIcon\n\t\tclass={cn(\n\t\t\t\"absolute h-4 w-4 transition-all duration-200\",\n\t\t\t!state.isConnecting && !state.isConnected ? \"scale-100 opacity-100\" : \"scale-[60%] opacity-0\"\n\t\t)}\n\t/>\n</Button>\n",
			"type": "registry:ui",
			"target": "speech-input/speech-input-record-button.svelte"
		},
		{
			"content": "import { getContext, setContext } from \"svelte\";\nimport type { TranscriptionAdapter } from \"./types.js\";\n\nexport type SpeechInputStatus = \"idle\" | \"connecting\" | \"connected\" | \"error\";\nexport type ButtonSize = \"default\" | \"sm\" | \"lg\";\n\nexport interface SpeechInputData {\n\tpartialTranscript: string;\n\tcommittedTranscripts: string[];\n\ttranscript: string;\n}\n\nexport interface SpeechInputCallbacks {\n\tonStart?: (data: SpeechInputData) => void;\n\tonStop?: (data: SpeechInputData) => void;\n\tonCancel?: (data: SpeechInputData) => void;\n\tonChange?: (data: SpeechInputData) => void;\n\tonError?: (error: Error) => void;\n}\n\nconst SPEECH_INPUT_CONTEXT_KEY = Symbol(\"sv11-speech-input\");\n\nexport class SpeechInputState {\n\t// Reactive state — written by methods, read by sub-components via context.\n\tstatus: SpeechInputStatus = $state(\"idle\");\n\tpartialTranscript = $state(\"\");\n\tcommittedTranscripts: string[] = $state([]);\n\terror: string | null = $state(null);\n\tsize: ButtonSize = $state(\"default\");\n\n\t// Derived fields\n\tisConnected = $derived(this.status === \"connected\");\n\tisConnecting = $derived(this.status === \"connecting\");\n\ttranscript = $derived.by(() => {\n\t\tconst committed = this.committedTranscripts.join(\" \").trim();\n\t\tconst partial = this.partialTranscript.trim();\n\t\tif (committed && partial) return `${committed} ${partial}`;\n\t\treturn committed || partial;\n\t});\n\n\t// Non-reactive refs. Stored as plain private fields because the adapter\n\t// reference must NOT create a reactive dependency — swapping adapters\n\t// mid-recording is unsupported by design.\n\t#adapter: TranscriptionAdapter | null = null;\n\t#requestId = 0;\n\t#callbacks: SpeechInputCallbacks = {};\n\n\t/**\n\t * Imperatively wire the adapter + user callbacks. Called from the root\n\t * component's `$effect` on every render so fresh closures are captured.\n\t */\n\tconfigure(params: { adapter: TranscriptionAdapter; callbacks: SpeechInputCallbacks }): void {\n\t\tthis.#adapter = params.adapter;\n\t\tthis.#callbacks = params.callbacks;\n\t}\n\n\tstart = async (): Promise<void> => {\n\t\t// Allow restart from \"idle\" or \"error\" — but not while actively recording.\n\t\t// The error path leaves status === \"error\"; without this, users would be\n\t\t// stuck and unable to retry after a failed start.\n\t\tif (!this.#adapter || this.status === \"connecting\" || this.status === \"connected\") return;\n\t\tconst id = ++this.#requestId;\n\n\t\tthis.partialTranscript = \"\";\n\t\tthis.committedTranscripts = [];\n\t\tthis.error = null;\n\t\tthis.status = \"connecting\";\n\n\t\ttry {\n\t\t\tawait this.#adapter.start({\n\t\t\t\tonConnect: () => {\n\t\t\t\t\tif (this.#requestId !== id) return;\n\t\t\t\t\tthis.status = \"connected\";\n\t\t\t\t\tthis.#callbacks.onStart?.(this.#data());\n\t\t\t\t},\n\t\t\t\tonPartialTranscript: (text) => {\n\t\t\t\t\tif (this.#requestId !== id) return;\n\t\t\t\t\tthis.partialTranscript = text;\n\t\t\t\t\tthis.#callbacks.onChange?.(this.#data());\n\t\t\t\t},\n\t\t\t\tonCommittedTranscript: (text) => {\n\t\t\t\t\tif (this.#requestId !== id) return;\n\t\t\t\t\tthis.committedTranscripts = [...this.committedTranscripts, text];\n\t\t\t\t\tthis.partialTranscript = \"\";\n\t\t\t\t\tthis.#callbacks.onChange?.(this.#data());\n\t\t\t\t},\n\t\t\t\tonDisconnect: () => {\n\t\t\t\t\tif (this.#requestId !== id) return;\n\t\t\t\t\tif (this.status === \"connected\") {\n\t\t\t\t\t\tthis.partialTranscript = \"\";\n\t\t\t\t\t\tthis.status = \"idle\";\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tonError: (err) => {\n\t\t\t\t\tif (this.#requestId !== id) return;\n\t\t\t\t\tthis.error = err.message;\n\t\t\t\t\tthis.status = \"error\";\n\t\t\t\t\tthis.#callbacks.onError?.(err);\n\t\t\t\t},\n\t\t\t});\n\t\t} catch (err) {\n\t\t\tif (this.#requestId !== id) return;\n\t\t\tconst error = err instanceof Error ? err : new Error(String(err));\n\t\t\tthis.error = error.message;\n\t\t\tthis.status = \"error\";\n\t\t\tthis.#callbacks.onError?.(error);\n\t\t}\n\t};\n\n\tstop = (): void => {\n\t\t++this.#requestId; // invalidate in-flight adapter callbacks\n\t\tthis.#adapter?.stop();\n\t\tif (this.status !== \"idle\") this.status = \"idle\";\n\t\tthis.#callbacks.onStop?.(this.#data());\n\t};\n\n\tcancel = (): void => {\n\t\t++this.#requestId;\n\t\tconst data = this.#data(); // snapshot BEFORE clearing, for the callback\n\t\tthis.#adapter?.cancel();\n\t\tthis.partialTranscript = \"\";\n\t\tthis.committedTranscripts = [];\n\t\tthis.status = \"idle\";\n\t\tthis.#callbacks.onCancel?.(data);\n\t};\n\n\t#data = (): SpeechInputData => ({\n\t\tpartialTranscript: this.partialTranscript,\n\t\tcommittedTranscripts: [...this.committedTranscripts],\n\t\ttranscript: this.transcript,\n\t});\n}\n\nexport function setSpeechInput(): SpeechInputState {\n\tconst state = new SpeechInputState();\n\tsetContext(SPEECH_INPUT_CONTEXT_KEY, state);\n\treturn state;\n}\n\nexport function useSpeechInput(): SpeechInputState {\n\tconst ctx = getContext<SpeechInputState | undefined>(SPEECH_INPUT_CONTEXT_KEY);\n\tif (!ctx) {\n\t\tthrow new Error(\"useSpeechInput must be called within a <SpeechInput>\");\n\t}\n\treturn ctx;\n}\n",
			"type": "registry:ui",
			"target": "speech-input/context.svelte.ts"
		},
		{
			"content": "export interface TranscriptionAdapterCallbacks {\n\tonPartialTranscript?: (text: string) => void;\n\tonCommittedTranscript?: (text: string) => void;\n\tonConnect?: () => void;\n\tonDisconnect?: () => void;\n\tonError?: (error: Error) => void;\n}\n\nexport interface TranscriptionAdapter {\n\t/**\n\t * Open connection and start capturing audio. Resolves when ready\n\t * (i.e. when `onConnect` has fired or the connection is established).\n\t * Rejects on authentication, permission, or initialization errors.\n\t */\n\tstart(callbacks: TranscriptionAdapterCallbacks): Promise<void>;\n\t/**\n\t * Close the connection cleanly. Any in-flight partial transcript is\n\t * preserved by the component and passed to the user's `onStop` callback.\n\t */\n\tstop(): void;\n\t/**\n\t * Close the connection AND discard any in-flight partial transcript.\n\t * The component clears partial + committed state before firing `onCancel`.\n\t */\n\tcancel(): void;\n}\n",
			"type": "registry:ui",
			"target": "speech-input/types.ts"
		},
		{
			"content": "import { tv } from \"tailwind-variants\";\n\n/**\n * Size variants for the speech-input record + cancel buttons. Both buttons\n * read `state.size` from context and apply the same sizing so the compound\n * component looks consistent regardless of which children the user renders.\n */\nexport const buttonSizeVariants = tv({\n\tbase: \"!px-0\",\n\tvariants: {\n\t\tsize: {\n\t\t\tdefault: \"h-9 w-9\",\n\t\t\tsm: \"h-8 w-8\",\n\t\t\tlg: \"h-10 w-10\",\n\t\t},\n\t},\n\tdefaultVariants: {\n\t\tsize: \"default\",\n\t},\n});\n",
			"type": "registry:ui",
			"target": "speech-input/variants.ts"
		},
		{
			"content": "import Root from \"./speech-input.svelte\";\nimport RecordButton from \"./speech-input-record-button.svelte\";\nimport Preview from \"./speech-input-preview.svelte\";\nimport CancelButton from \"./speech-input-cancel-button.svelte\";\n\nexport {\n\tRoot,\n\tRecordButton,\n\tPreview,\n\tCancelButton,\n\t//\n\tRoot as SpeechInput,\n\tRecordButton as SpeechInputRecordButton,\n\tPreview as SpeechInputPreview,\n\tCancelButton as SpeechInputCancelButton,\n};\n\nexport { setSpeechInput, useSpeechInput, SpeechInputState } from \"./context.svelte.js\";\n\nexport type {\n\tSpeechInputStatus,\n\tSpeechInputData,\n\tSpeechInputCallbacks,\n\tButtonSize,\n} from \"./context.svelte.js\";\n\nexport type { TranscriptionAdapter, TranscriptionAdapterCallbacks } from \"./types.js\";\n\nexport type { SpeechInputProps } from \"./speech-input.svelte\";\nexport type { SpeechInputRecordButtonProps } from \"./speech-input-record-button.svelte\";\nexport type { SpeechInputPreviewProps } from \"./speech-input-preview.svelte\";\nexport type { SpeechInputCancelButtonProps } from \"./speech-input-cancel-button.svelte\";\n",
			"type": "registry:ui",
			"target": "speech-input/index.ts"
		}
	]
}