{
	"$schema": "https://shadcn-svelte.com/schema/registry-item.json",
	"name": "voice-nav-01",
	"title": "Voice Nav 01",
	"type": "registry:block",
	"description": "A voice-driven site navigation block: speak a destination and an embedded frame navigates to it. Provider-agnostic intent resolver (keyword matching by default; swap in an LLM), with a Web Speech API demo.",
	"dependencies": [
		"@lucide/svelte"
	],
	"registryDependencies": [
		"card",
		"https://sv11.ui.twango.dev/r/voice-button.json"
	],
	"files": [
		{
			"content": "<script lang=\"ts\" module>\n\timport type { VoiceNavDestination } from \"./destinations.js\";\n\n\texport type VoiceNavResolver = (\n\t\ttranscript: string,\n\t\tdestinations: VoiceNavDestination[]\n\t) => Promise<string | null> | string | null;\n\n\texport type VoiceNav01Props = {\n\t\t/** Pages the user can navigate to by voice. */\n\t\tdestinations?: VoiceNavDestination[];\n\t\t/**\n\t\t * Maps a spoken transcript to a destination URL (or `null`). Defaults to\n\t\t * keyword matching; pass an LLM-backed resolver for fuzzy intent.\n\t\t */\n\t\tresolve?: VoiceNavResolver;\n\t\t/** Initial URL shown in the frame. */\n\t\tinitialUrl?: string;\n\t\tclass?: string;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport { onDestroy, onMount } from \"svelte\";\n\timport { cn } from \"$UTILS$.js\";\n\timport {\n\t\tCard,\n\t\tCardContent,\n\t\tCardDescription,\n\t\tCardHeader,\n\t\tCardTitle,\n\t} from \"$UI$/card/index.js\";\n\timport { VoiceButton, type VoiceButtonState } from \"$UI$/voice-button/index.js\";\n\timport { DEFAULT_DESTINATIONS, matchDestination } from \"./destinations.js\";\n\timport { OneShotRecognizer, isSpeechRecognitionSupported } from \"./speech.js\";\n\n\tlet {\n\t\tdestinations = DEFAULT_DESTINATIONS,\n\t\tresolve = matchDestination,\n\t\tinitialUrl = \"/docs/components\",\n\t\tclass: className,\n\t}: VoiceNav01Props = $props();\n\n\tlet voiceState = $state<VoiceButtonState>(\"idle\");\n\tlet url = $state(initialUrl);\n\tlet frameKey = $state(0);\n\tlet error = $state(\"\");\n\tlet lastHeard = $state(\"\");\n\n\t// Optimistic until mounted so prerendered HTML doesn't flash the \"unsupported\"\n\t// note before the client can feature-detect.\n\tlet mounted = $state(false);\n\tonMount(() => (mounted = true));\n\tconst supported = $derived(!mounted || isSpeechRecognitionSupported());\n\tconst recognizer = new OneShotRecognizer();\n\tlet revertTimer: ReturnType<typeof setTimeout> | null = null;\n\n\t// A custom resolve() is untrusted input. Only load http(s) URLs or same-origin\n\t// paths into the preview frame — never javascript:/data:/blob: schemes.\n\tfunction isSafeUrl(value: string): boolean {\n\t\ttry {\n\t\t\tconst { protocol } = new URL(value, window.location.origin);\n\t\t\treturn protocol === \"http:\" || protocol === \"https:\";\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tfunction revertSoon() {\n\t\tif (revertTimer) clearTimeout(revertTimer);\n\t\trevertTimer = setTimeout(() => {\n\t\t\tif (voiceState === \"success\" || voiceState === \"error\") voiceState = \"idle\";\n\t\t}, 1800);\n\t}\n\n\tasync function handlePress() {\n\t\tif (!supported) return;\n\t\tif (voiceState === \"recording\" || voiceState === \"processing\") {\n\t\t\trecognizer.abort();\n\t\t\tvoiceState = \"idle\";\n\t\t\treturn;\n\t\t}\n\n\t\terror = \"\";\n\t\tvoiceState = \"recording\";\n\t\ttry {\n\t\t\tconst transcript = await recognizer.start(navigator.language || \"en-US\");\n\t\t\tif (!transcript) {\n\t\t\t\tvoiceState = \"idle\";\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tlastHeard = transcript;\n\t\t\tvoiceState = \"processing\";\n\t\t\tconst dest = await Promise.resolve(resolve(transcript, destinations));\n\t\t\tif (!dest) {\n\t\t\t\terror = `Couldn't match \"${transcript}\" to a page.`;\n\t\t\t\tvoiceState = \"error\";\n\t\t\t} else if (!isSafeUrl(dest)) {\n\t\t\t\terror = `Refused to open an unsafe URL.`;\n\t\t\t\tvoiceState = \"error\";\n\t\t\t} else {\n\t\t\t\turl = dest;\n\t\t\t\tframeKey += 1;\n\t\t\t\tvoiceState = \"success\";\n\t\t\t}\n\t\t} catch (err) {\n\t\t\terror = err instanceof Error ? err.message : \"Voice navigation failed.\";\n\t\t\tvoiceState = \"error\";\n\t\t} finally {\n\t\t\trevertSoon();\n\t\t}\n\t}\n\n\tfunction onKeydown(event: KeyboardEvent) {\n\t\t// ⌥Space toggles voice nav — but not while typing in a field.\n\t\tconst target = event.target as HTMLElement | null;\n\t\tif (target?.tagName === \"INPUT\" || target?.tagName === \"TEXTAREA\" || target?.isContentEditable)\n\t\t\treturn;\n\t\tif (event.altKey && event.code === \"Space\") {\n\t\t\tevent.preventDefault();\n\t\t\tvoid handlePress();\n\t\t}\n\t}\n\n\tonDestroy(() => {\n\t\trecognizer.abort();\n\t\tif (revertTimer) clearTimeout(revertTimer);\n\t});\n</script>\n\n<svelte:window onkeydown={onKeydown} />\n\n<Card class={cn(\"mx-auto flex w-full max-w-3xl flex-col overflow-hidden\", className)}>\n\t<CardHeader>\n\t\t<div class=\"flex items-start justify-between gap-4\">\n\t\t\t<div class=\"space-y-1\">\n\t\t\t\t<CardTitle>Voice Navigation</CardTitle>\n\t\t\t\t<CardDescription>\n\t\t\t\t\tSpeak to navigate. Try <span class=\"text-foreground\">“take me to the orb”</span> or\n\t\t\t\t\t<span class=\"text-foreground\">“show me the blocks”</span>.\n\t\t\t\t</CardDescription>\n\t\t\t</div>\n\t\t\t<VoiceButton\n\t\t\t\tstate={voiceState}\n\t\t\t\tonPress={handlePress}\n\t\t\t\tdisabled={!supported}\n\t\t\t\tlabel=\"Voice Nav\"\n\t\t\t\ttrailing=\"⌥Space\"\n\t\t\t\ttitle=\"Voice Navigation\"\n\t\t\t/>\n\t\t</div>\n\t\t{#if error}\n\t\t\t<p class=\"text-destructive text-sm\">{error}</p>\n\t\t{:else if lastHeard}\n\t\t\t<p class=\"text-muted-foreground text-sm\">Heard: “{lastHeard}”</p>\n\t\t{/if}\n\t</CardHeader>\n\t<CardContent class=\"p-0\">\n\t\t<div class=\"bg-muted/40 text-muted-foreground border-y px-4 py-2 font-mono text-xs\">\n\t\t\t{url}\n\t\t</div>\n\t\t{#key frameKey}\n\t\t\t<iframe src={url} title=\"Voice navigation preview\" class=\"h-[440px] w-full bg-white\"></iframe>\n\t\t{/key}\n\t\t{#if !supported}\n\t\t\t<p class=\"text-muted-foreground p-4 text-xs\">\n\t\t\t\tThis demo uses the browser Web Speech API (Chromium-based browsers). Provide a custom\n\t\t\t\t<code class=\"bg-muted rounded px-1 py-0.5\">resolve</code> backend for production intent matching.\n\t\t\t</p>\n\t\t{/if}\n\t</CardContent>\n</Card>\n",
			"type": "registry:block",
			"target": "components/blocks/voice-nav-01/voice-nav-01.svelte"
		},
		{
			"content": "export type VoiceNavDestination = {\n\tlabel: string;\n\t/** URL to navigate the embedded frame to. Same-origin paths embed cleanly. */\n\turl: string;\n\t/** Lowercase phrases that should route here. Multi-word phrases score higher. */\n\tkeywords: string[];\n};\n\n// Default demo destinations point at this site's own routes so they embed\n// without cross-origin frame restrictions. Replace with your own.\nexport const DEFAULT_DESTINATIONS: VoiceNavDestination[] = [\n\t{ label: \"Home\", url: \"/\", keywords: [\"home\", \"start\", \"introduction\", \"intro\"] },\n\t{\n\t\tlabel: \"Components\",\n\t\turl: \"/docs/components\",\n\t\tkeywords: [\"components\", \"component\", \"browse\"],\n\t},\n\t{ label: \"Blocks\", url: \"/blocks\", keywords: [\"blocks\", \"examples\", \"gallery\"] },\n\t{\n\t\tlabel: \"Orb\",\n\t\turl: \"/docs/components/orb\",\n\t\tkeywords: [\"orb\", \"sphere\", \"3d\", \"visualizer\", \"agent\"],\n\t},\n\t{\n\t\tlabel: \"Audio Player\",\n\t\turl: \"/docs/components/audio-player\",\n\t\tkeywords: [\"audio player\", \"audio\", \"music\", \"playback\"],\n\t},\n\t{\n\t\tlabel: \"Theming\",\n\t\turl: \"/docs/theming\",\n\t\tkeywords: [\"theme\", \"theming\", \"colors\", \"customize\", \"style\"],\n\t},\n\t{\n\t\tlabel: \"Setup\",\n\t\turl: \"/docs/setup\",\n\t\tkeywords: [\"setup\", \"install\", \"installation\", \"getting started\", \"get started\"],\n\t},\n];\n\n/**\n * Default intent resolver: scores each destination by keyword/label overlap and\n * returns the best match's URL, or `null` when nothing matches. Swap in an\n * LLM-backed resolver (e.g. structured output over your sitemap) via the\n * `resolve` prop for fuzzy, real-world navigation.\n */\nexport function matchDestination(\n\ttranscript: string,\n\tdestinations: VoiceNavDestination[]\n): string | null {\n\tconst text = transcript.toLowerCase();\n\tlet best: VoiceNavDestination | null = null;\n\tlet bestScore = 0;\n\tfor (const dest of destinations) {\n\t\tlet score = 0;\n\t\tif (text.includes(dest.label.toLowerCase())) score += 2;\n\t\tfor (const keyword of dest.keywords) {\n\t\t\tif (text.includes(keyword)) score += keyword.includes(\" \") ? 2 : 1;\n\t\t}\n\t\tif (score > bestScore) {\n\t\t\tbestScore = score;\n\t\t\tbest = dest;\n\t\t}\n\t}\n\treturn best && bestScore > 0 ? best.url : null;\n}\n",
			"type": "registry:block",
			"target": "components/blocks/voice-nav-01/destinations.ts"
		},
		{
			"content": "// Minimal Web Speech API surface (not in the default TS DOM lib). Used as the\n// zero-backend demo speech source; supply a `transcribe` prop for production.\n\ntype SpeechRecognitionAlternative = { transcript: string };\ntype SpeechRecognitionResult = { 0: SpeechRecognitionAlternative };\ntype SpeechRecognitionEvent = {\n\tresults: { length: number; [index: number]: SpeechRecognitionResult };\n};\ninterface SpeechRecognitionLike {\n\tcontinuous: boolean;\n\tinterimResults: boolean;\n\tlang: string;\n\tmaxAlternatives: number;\n\tstart(): void;\n\tabort(): void;\n\tonresult: ((event: SpeechRecognitionEvent) => void) | null;\n\tonerror: ((event: { error: string }) => void) | null;\n\tonend: (() => void) | null;\n}\ntype SpeechRecognitionCtor = new () => SpeechRecognitionLike;\n\nexport function getSpeechRecognition(): SpeechRecognitionCtor | null {\n\tif (typeof window === \"undefined\") return null;\n\tconst w = window as unknown as {\n\t\tSpeechRecognition?: SpeechRecognitionCtor;\n\t\twebkitSpeechRecognition?: SpeechRecognitionCtor;\n\t};\n\treturn w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;\n}\n\nexport const isSpeechRecognitionSupported = () => getSpeechRecognition() !== null;\n\n/**\n * One-shot recognizer: resolves with a single utterance's transcript. `abort()`\n * cancels an in-flight recognition (used when the user taps to stop).\n */\nexport class OneShotRecognizer {\n\t#recognition: SpeechRecognitionLike | null = null;\n\n\tstart(lang = \"en-US\"): Promise<string> {\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tconst Ctor = getSpeechRecognition();\n\t\t\tif (!Ctor) {\n\t\t\t\treject(new Error(\"SpeechRecognition is not supported in this browser.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst recognition = new Ctor();\n\t\t\trecognition.continuous = false;\n\t\t\trecognition.interimResults = false;\n\t\t\trecognition.lang = lang;\n\t\t\trecognition.maxAlternatives = 1;\n\n\t\t\tlet transcript = \"\";\n\t\t\tlet failed = false;\n\t\t\trecognition.onresult = (event) => {\n\t\t\t\ttranscript = event.results[0]?.[0]?.transcript ?? \"\";\n\t\t\t};\n\t\t\trecognition.onerror = (event) => {\n\t\t\t\tif (event.error === \"no-speech\" || event.error === \"aborted\") return;\n\t\t\t\tfailed = true;\n\t\t\t\treject(new Error(`Speech recognition error: ${event.error}`));\n\t\t\t};\n\t\t\trecognition.onend = () => {\n\t\t\t\tthis.#recognition = null;\n\t\t\t\tif (!failed) resolve(transcript.trim());\n\t\t\t};\n\n\t\t\tthis.#recognition = recognition;\n\t\t\trecognition.start();\n\t\t});\n\t}\n\n\tabort(): void {\n\t\tthis.#recognition?.abort();\n\t\tthis.#recognition = null;\n\t}\n}\n",
			"type": "registry:block",
			"target": "components/blocks/voice-nav-01/speech.ts"
		},
		{
			"content": "import VoiceNav01 from \"./voice-nav-01.svelte\";\n\nexport { VoiceNav01, VoiceNav01 as default };\nexport type { VoiceNav01Props, VoiceNavResolver } from \"./voice-nav-01.svelte\";\nexport { DEFAULT_DESTINATIONS, matchDestination } from \"./destinations.js\";\nexport type { VoiceNavDestination } from \"./destinations.js\";\nexport { OneShotRecognizer, getSpeechRecognition, isSpeechRecognitionSupported } from \"./speech.js\";\n",
			"type": "registry:block",
			"target": "components/blocks/voice-nav-01/index.ts"
		}
	]
}