# SoundToggle

> Opt-in interface sound: a `sound` controller that synthesises eleven short cues (hover, press, toggle-on, toggle-off, open, close, select, success, error, tick, copy) with the Web Audio API, a SoundToggle switch that keeps the preference and volume, and a soundFeedback action that wires cues to any element — silent until the user switches it on, nothing on import, mount, navigation or scroll.

- Package: `fancy-ui-svelte` (Svelte 5 + Tailwind CSS v4)
- Category: Actions
- Tags: sound, audio, feedback, web-audio, cues, toggle, accessibility, opt-in
- Docs: https://fancy-ui.rama.app/docs/components/sound
- Source: https://github.com/RamaHerbin/fancy-ui/tree/main/src/lib/fancy-ui/sound

## Import

```svelte
<script lang="ts">
  import { SoundToggle } from 'fancy-ui-svelte';
</script>
```

Setup (once per project): `npm install fancy-ui-svelte`, then add `@import "fancy-ui-svelte/tailwind.css";` after `@import "tailwindcss";` in your main CSS file. Full guide: https://fancy-ui.rama.app/llms.txt

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Height of the control — md matches the other header-style triggers |
| `variant` | `"outline" \| "ghost"` | `"outline"` | Outline keeps a resting border; ghost shows one only on hover |
| `showLabel` | `boolean` | `false` | Renders the label and the On/Off word beside the icon; icon-only otherwise, with the label as the accessible name |
| `label` | `string` | `"Sound"` | Accessible name of the switch. Stays constant — the on/off state is announced through aria-checked |
| `labelOn` | `string` | `"On"` | Visible state word when showLabel is set |
| `labelOff` | `string` | `"Off"` | Visible state word when showLabel is set |
| `disabled` | `boolean` | `false` | Disables the control. A browser with no Web Audio also disables it, but only while sound is off, so a stored on preference can always be undone |
| `onEnabledChange` | `(enabled: boolean) => void` | — | Called after the preference flips, with the new value |
| `class` | `string` | — | Additional CSS classes for the button |
| `ref` | `HTMLButtonElement \| null` | `null` | Bindable reference to the button |
| `sound.play(cue, options?)` | `(cue: SoundCue, options?: SoundPlayOptions) => void` | — | Plays one cue. A no-op while sound is off or unsupported, so call sites never branch; options carry volume, pitch and playbackRate |
| `sound.enable() / disable() / toggle()` | `() => void \| boolean` | — | Flip the preference. Enabling creates and resumes the audio context inside the calling user gesture |
| `sound.setVolume(v)` | `(v: number) => void` | — | Master volume, 0 to 1, persisted with the preference |
| `sound.enabled / volume / status` | `boolean / number / SoundStatus` | — | Reactive preference and engine state (supported, engine, storage, lastCue, lastError) for your own UI |
| `sound.subscribe(run)` | `(run: (prefs: SoundPreferences) => void) => () => void` | — | Store contract for non-rune consumers; sound.enabled is the idiomatic path |
| `use:soundFeedback` | `SoundFeedbackOptions` | — | Action mapping element events to cues — defaults to click → press; add pointerenter → hover to opt in. Rebinds on update and unwires on destroy |
| `sound (on Button, CopyButton, Checkbox, Switch, RadioGroup, Select, DropdownMenu)` | `boolean` | `false` | Per-instance opt-in: the component plays its own matching cue (press, copy/error, toggle-on/off, select, open/close) once the user has enabled sound |

## Examples

### Sound Lab

Every cue, its volume, and when to reach for it. Nothing plays until you switch sound on.

```svelte
<script lang="ts">
	import { Button } from "fancy-ui-svelte";
	import { Toggle } from "fancy-ui-svelte";
	import { Switch } from "fancy-ui-svelte";
	import { Slider } from "fancy-ui-svelte";
	import { CopyButton } from "fancy-ui-svelte";
	import { SoundToggle, sound, soundFeedback, type SoundCue } from "fancy-ui-svelte";

	// One row per cue, in the same order SOUND_CUES defines them. "Use"/"Avoid"
	// stay short enough to scan a grid of eleven of these at once.
	const CUES: { cue: SoundCue; label: string; use: string; avoid: string }[] = [
		{
			cue: "hover",
			label: "Hover",
			use: "Dense pointer UI: menus, docks, toolbars.",
			avoid: "Lists and anything that scrolls — it's rate-limited and silent on touch.",
		},
		{
			cue: "press",
			label: "Press",
			use: "Buttons and primary actions.",
			avoid: "Pairing with a toggle cue on the same control.",
		},
		{
			cue: "toggle-on",
			label: "Toggle on",
			use: "Switches, checkboxes, pressed toggles turning on.",
			avoid: "Navigation.",
		},
		{
			cue: "toggle-off",
			label: "Toggle off",
			use: "Switches, checkboxes, pressed toggles turning off.",
			avoid: "Navigation.",
		},
		{
			cue: "open",
			label: "Open",
			use: "Menus, dialogs, popovers opening.",
			avoid: "Tooltips and hover cards.",
		},
		{
			cue: "close",
			label: "Close",
			use: "Menus, dialogs, popovers closing.",
			avoid: "Tooltips and hover cards.",
		},
		{
			cue: "select",
			label: "Select",
			use: "Committing a choice in a list or menu.",
			avoid: "Each arrow-key step — that's tick.",
		},
		{
			cue: "success",
			label: "Success",
			use: "The outcome of an async action.",
			avoid: "Per-keystroke validation.",
		},
		{
			cue: "error",
			label: "Error",
			use: "The outcome of an async action.",
			avoid: "Per-keystroke validation.",
		},
		{
			cue: "tick",
			label: "Tick",
			use: "Steppers, sliders, scroll-snap — the only cue meant to repeat quickly.",
			avoid: "Anything louder than near-silent; it fires often.",
		},
		{
			cue: "copy",
			label: "Copy",
			use: "Clipboard confirmations, once per copy.",
			avoid: "Re-firing for the same copy without a new user action.",
		},
	];

	// Copy by engine/enabled state. Order matters: an unsupported browser wins
	// over every other state (the toggle itself is forced disabled for the
	// same reason), then the plain off/on/suspended cases.
	const statusText = $derived.by(() => {
		const status = sound.status;
		if (status.engine === "unsupported") return "This browser has no Web Audio support.";
		if (!sound.enabled) return "Sound is off. Switch it on to audition the cues.";
		if (status.engine === "suspended" || status.engine === "blocked")
			return "The browser paused audio. Any Play button resumes it.";
		return `Sound on · ${Math.round(sound.volume * 100)}%`;
	});

	function toggleChangeCue(event: Event) {
		const checked = (event.currentTarget as HTMLInputElement).checked;
		return checked ? "toggle-on" : "toggle-off";
	}

	// The click reaches this wrapper after the Toggle's own handler ran but
	// before Svelte has flushed the DOM, so aria-pressed still shows the OLD
	// state: "false" means it is turning on.
	function pressedToggleCue(event: Event) {
		const button = (event.currentTarget as HTMLElement).querySelector("[aria-pressed]");
		return button?.getAttribute("aria-pressed") === "true" ? "toggle-off" : "toggle-on";
	}
</script>

<div data-sound-lab class="w-full max-w-3xl text-left">
	<!-- Control bar. No aria-live: this example is mounted twice on the page
	     (the docs preview and the examples list), and a live region would
	     announce the same state change twice. -->
	<div class="flex flex-wrap items-center gap-4">
		<SoundToggle showLabel />

		<p class="text-muted-foreground flex items-center gap-2 text-sm">
			<span>{statusText}</span>
			<span class="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{sound.status.engine}</span>
		</p>

		<div class="flex w-full flex-col gap-1 sm:ml-auto sm:w-48">
			<div class="flex items-center justify-between text-xs">
				<span class="text-foreground font-medium">Volume</span>
				<span class="text-muted-foreground font-mono">{Math.round(sound.volume * 100)}%</span>
			</div>
			<Slider
				value={sound.volume * 100}
				min={0}
				max={100}
				step={5}
				label="Volume"
				disabled={!sound.enabled}
				onValueChange={(v) => sound.setVolume(v / 100)}
			/>
		</div>
	</div>

	<!-- Cue grid -->
	<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
		{#each CUES as c (c.cue)}
			<!-- A cue is a sound; data-played is the same information for anyone
			     who cannot hear it — the last cue played keeps a visible ring
			     until another replaces it. -->
			<div
				class="ft-sound-lab-card border-border bg-card rounded-lg border p-3"
				data-played={sound.status.lastCue === c.cue ? "true" : undefined}
			>
				<div class="flex items-center justify-between gap-2">
					<span class="text-sm font-medium">{c.label}</span>
					<Button
						size="sm"
						variant="outline"
						label={`Play ${c.cue}`}
						disabled={!sound.enabled}
						onclick={() => sound.play(c.cue)}
					>
						Play
					</Button>
				</div>
				<p class="text-muted-foreground mt-2 text-xs">
					<span class="text-foreground font-medium">Use</span>
					{c.use}
				</p>
				<p class="text-muted-foreground text-xs">
					<span class="text-foreground font-medium">Avoid</span>
					{c.avoid}
				</p>
			</div>
		{/each}
	</div>

	<!-- Try it live: wired only through use:soundFeedback, never the `sound`
	     prop those belong to the component-integration examples. -->
	<div class="mt-6">
		<p class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">Try it live</p>
		<div class="border-border bg-card mt-3 flex flex-wrap items-center gap-3 rounded-lg border p-4">
			<!-- Hover is opt-in: the default map is press only. -->
			<span use:soundFeedback={{ on: { pointerenter: "hover", click: "press" } }}>
				<Button variant="outline" size="sm">Hover + press</Button>
			</span>

			<span use:soundFeedback={{ on: { click: pressedToggleCue } }}>
				<Toggle label="Pin">📌</Toggle>
			</span>

			<span use:soundFeedback={{ on: { change: toggleChangeCue } }}>
				<Switch label="Notify" />
			</span>

			<span use:soundFeedback={{ on: { click: "copy" } }}>
				<CopyButton value="npm install fancy-ui-svelte" />
			</span>

			<span use:soundFeedback={{ on: { click: "success" } }}>
				<Button variant="ghost" size="sm">Succeed</Button>
			</span>

			<span use:soundFeedback={{ on: { click: "error" } }}>
				<Button variant="ghost" size="sm">Fail</Button>
			</span>
		</div>
	</div>
</div>

<style>
	.ft-sound-lab-card {
		--ft-sound-lab-accent: var(
			--ft-accent,
			light-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))
		);
	}
	.ft-sound-lab-card[data-played="true"] {
		box-shadow: 0 0 0 2px color-mix(in oklab, var(--ft-sound-lab-accent) 40%, transparent);
	}
	@media (prefers-reduced-motion: no-preference) {
		.ft-sound-lab-card {
			transition: box-shadow 150ms ease;
		}
	}
</style>
```

### Programmatic Play

sound.play() from an async handler — a no-op while sound is off, so call sites never branch on it.

```svelte
<script lang="ts">
	import { Button } from "fancy-ui-svelte";
	import { SoundToggle, sound } from "fancy-ui-svelte";

	let saving = $state(false);
	let attempts = 0;
	let outcome = $state<"idle" | "success" | "error">("idle");

	async function save() {
		saving = true;
		outcome = "idle";
		// The outcome cue plays after an await, so unlock here — inside the click
		// that started the work. On a reload with sound already enabled there is
		// no AudioContext yet, and the transient user activation that lets one be
		// created may be gone by the time the promise settles.
		if (sound.enabled) void sound.unlock();
		await new Promise((resolve) => setTimeout(resolve, 600));
		attempts += 1;
		const ok = attempts % 2 === 1;
		outcome = ok ? "success" : "error";
		// sound.play() is a no-op while sound is off — this call site never
		// branches on the preference itself, on or off.
		sound.play(ok ? "success" : "error");
		saving = false;
	}
</script>

<div class="flex flex-col gap-4">
	<div class="flex flex-wrap items-center gap-3">
		<SoundToggle size="sm" showLabel label="Sound for programmatic play" />
		<span class="text-muted-foreground text-sm">
			Turn sound on to hear the outcome, or leave it off to see play() do nothing.
		</span>
	</div>

	<div class="flex items-center gap-3">
		<Button loading={saving} onclick={save}>
			{saving ? "Saving…" : "Save changes"}
		</Button>
		<!-- The outcome is always shown here too — audio is a confirmation,
		     never the only carrier of the result. -->
		{#if outcome !== "idle"}
			<span class={outcome === "success" ? "text-foreground text-sm" : "text-destructive text-sm"}>
				{outcome === "success" ? "Saved." : "Failed to save."}
			</span>
		{/if}
	</div>
</div>
```

### The soundFeedback Action

use:soundFeedback on any element, with cues swapped at runtime through the action's update path.

```svelte
<script lang="ts">
	import { Switch } from "fancy-ui-svelte";
	import {
		SoundToggle,
		soundFeedback,
		type SoundFeedbackOptions,
	} from "fancy-ui-svelte";

	let hoverEnabled = $state(true);

	// Rebuilding the whole options object on every change is what exercises
	// the action's own `update()` path — swapping the event map at runtime,
	// not just its resolved cue.
	const feedbackOptions = $derived<SoundFeedbackOptions>(
		hoverEnabled ? { on: { click: "press", pointerenter: "hover" } } : { on: { click: "press" } }
	);
</script>

<div class="flex flex-col gap-4">
	<div class="flex flex-wrap items-center gap-3">
		<SoundToggle size="sm" showLabel label="Sound for the action demo" />
		<span class="text-muted-foreground text-sm">Turn sound on, then try the controls below.</span>
	</div>

	<Switch bind:checked={hoverEnabled} label="Also play a hover cue" />

	<div class="flex flex-wrap items-center gap-5">
		<button
			type="button"
			use:soundFeedback={feedbackOptions}
			class="border-border bg-background hover:bg-accent hover:text-accent-foreground h-9 rounded-md border px-3 text-sm transition-colors"
		>
			Plain button
		</button>

		<button
			type="button"
			use:soundFeedback={feedbackOptions}
			class="text-foreground text-sm underline underline-offset-4"
		>
			Link-styled button
		</button>

		<label class="flex items-center gap-2 text-sm">
			<span class="text-muted-foreground">Scrub</span>
			<input
				type="range"
				min="0"
				max="100"
				use:soundFeedback={{ on: { input: "tick" } }}
				class="h-1 w-32"
			/>
		</label>
	</div>
</div>
```

### With Buttons

Button and CopyButton playing their own press and copy cues through the opt-in prop.

```svelte
<script lang="ts">
	import { Button } from "fancy-ui-svelte";
	import { CopyButton } from "fancy-ui-svelte";
	import { SoundToggle } from "fancy-ui-svelte";
</script>

<div class="flex flex-col gap-4">
	<div class="flex flex-wrap items-center gap-3">
		<SoundToggle size="sm" showLabel label="Sound for the button examples" />
		<span class="text-muted-foreground text-sm">Turn sound on, then try the buttons below.</span>
	</div>

	<div class="flex flex-wrap items-center gap-3">
		<Button sound>Save changes</Button>
		<Button sound variant="outline">Continue</Button>
		<Button sound variant="destructive">Delete</Button>
		<CopyButton sound value="npm install fancy-ui-svelte" />
	</div>
</div>
```

### With Form Controls

Checkbox, Switch and RadioGroup — toggle-on, toggle-off and select.

```svelte
<script lang="ts">
	import { Checkbox } from "fancy-ui-svelte";
	import { Switch } from "fancy-ui-svelte";
	import { RadioGroup, RadioGroupItem } from "fancy-ui-svelte";
	import { SoundToggle } from "fancy-ui-svelte";

	let agreed = $state(false);
	let notifications = $state(true);
	let plan = $state("pro");
</script>

<div class="flex flex-col gap-4">
	<div class="flex flex-wrap items-center gap-3">
		<SoundToggle size="sm" showLabel label="Sound for the form controls" />
		<span class="text-muted-foreground text-sm">
			Turn sound on, then check the box, flip the switch, or pick a plan below.
		</span>
	</div>

	<div class="flex flex-col gap-4">
		<Checkbox sound bind:checked={agreed}>I agree to the terms</Checkbox>
		<Switch sound bind:checked={notifications} label="Enable notifications" />
		<RadioGroup sound bind:value={plan} label="Plan" orientation="horizontal">
			<RadioGroupItem value="free" label="Free" />
			<RadioGroupItem value="pro" label="Pro" />
			<RadioGroupItem value="team" label="Team" />
		</RadioGroup>
	</div>
</div>
```

### With Menus

Select and DropdownMenu — open, close and select, one cue per interaction.

```svelte
<script lang="ts">
	import { Select } from "fancy-ui-svelte";
	import {
		DropdownMenu,
		DropdownMenuTrigger,
		DropdownMenuContent,
		DropdownMenuItem,
		DropdownMenuSeparator,
	} from "fancy-ui-svelte";
	import { SoundToggle } from "fancy-ui-svelte";

	const frameworks = [
		{ value: "svelte", label: "Svelte 5" },
		{ value: "react", label: "React" },
		{ value: "vue", label: "Vue" },
	];

	let framework = $state("svelte");
	let lastAction = $state("");
</script>

<div class="flex flex-col gap-4">
	<SoundToggle size="sm" showLabel label="Sound for the menus" />

	<div class="flex flex-wrap items-center gap-4">
		<Select
			options={frameworks}
			bind:value={framework}
			label="Framework"
			placeholder="Choose a framework"
			class="w-[220px]"
			sound
		/>

		<DropdownMenu sound>
			<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
			<DropdownMenuContent>
				<DropdownMenuItem onSelect={() => (lastAction = "Renamed")}>Rename</DropdownMenuItem>
				<DropdownMenuItem onSelect={() => (lastAction = "Duplicated")}>Duplicate</DropdownMenuItem>
				<DropdownMenuSeparator />
				<DropdownMenuItem variant="destructive" onSelect={() => (lastAction = "Deleted")}>
					Delete
				</DropdownMenuItem>
			</DropdownMenuContent>
		</DropdownMenu>
	</div>

	<!-- The cue is never the only carrier: the chosen action is also written out. -->
	<p class="text-muted-foreground text-sm">
		{lastAction ? `${lastAction} · framework: ${framework}` : `Framework: ${framework}`}
	</p>
</div>
```

### With Slider

Slider and NumberInput — one tick per committed step, never one per pixel of a drag.

```svelte
<script lang="ts">
	import { Slider } from "fancy-ui-svelte";
	import { NumberInput } from "fancy-ui-svelte";
	import { SoundToggle } from "fancy-ui-svelte";

	let volume = $state(60);
	let copies = $state<number | null>(2);
</script>

<div class="flex flex-col gap-4">
	<div class="flex flex-wrap items-center gap-3">
		<SoundToggle size="sm" showLabel label="Sound for the stepped controls" />
		<span class="text-muted-foreground text-sm">
			Turn sound on, then drag the slider or step the number.
		</span>
	</div>

	<!-- One tick per committed value — on drag release, a keyboard step, or a
	     stepper press — never one per pixel of a drag. -->
	<div class="flex w-full max-w-md flex-col gap-6">
		<Slider sound bind:value={volume} label="Volume" showValue />
		<NumberInput sound bind:value={copies} label="Copies" min={1} max={10} class="w-40" />
	</div>
</div>
```

### With Overlays

Dialog open/close and Toaster outcomes — success and error chime, info and loading stay silent.

```svelte
<script lang="ts">
	import { Dialog } from "fancy-ui-svelte";
	import { Button } from "fancy-ui-svelte";
	import { Toaster, toast } from "fancy-ui-svelte";
	import { SoundToggle } from "fancy-ui-svelte";

	function showSuccess() {
		toast({ title: "Draft published", description: "Live in every region.", variant: "success" });
	}

	function showError() {
		toast({
			title: "Publish failed",
			description: "The build did not come back.",
			variant: "error",
		});
	}

	function showInfo() {
		// `info` and `loading` toasts stay silent by design — only outcomes chime.
		toast({ title: "New version available", variant: "info" });
	}
</script>

{#snippet trigger()}
	<Button>Open dialog</Button>
{/snippet}

<!-- `sound` lives on <Toaster>, never on toast()'s own options: the page that
     mounts the region decides once whether outcomes chime. -->
<Toaster sound />

<div class="flex flex-col gap-4">
	<div class="flex flex-wrap items-center gap-3">
		<SoundToggle size="sm" showLabel label="Sound for the overlays" />
		<span class="text-muted-foreground text-sm">
			Turn sound on, then open the dialog or fire a toast.
		</span>
	</div>

	<div class="flex flex-wrap items-center gap-3">
		<Dialog
			sound
			{trigger}
			title="Rename the workspace"
			description="Opening plays one cue; closing plays its counterpart."
		>
			<p class="text-muted-foreground text-sm">
				However this dialog is dismissed — the close button, Escape, or the backdrop — it plays the
				same close cue. A parent writing <code>open</code> through <code>bind:open</code> stays silent
				by design; see the sound section of the dialog README.
			</p>
		</Dialog>
		<Button variant="outline" onclick={showSuccess}>Success toast</Button>
		<Button variant="outline" onclick={showError}>Error toast</Button>
		<Button variant="outline" onclick={showInfo}>Info toast</Button>
	</div>
</div>
```

### With Composer

One sound prop on the root: sending plays press, the model picker and command menu play their own cues.

```svelte
<script lang="ts">
	import {
		Composer,
		ComposerInput,
		ComposerSubmit,
		ComposerToolbar,
		ComposerModelPicker,
		ComposerCommandMenu,
	} from "fancy-ui-svelte";
	import { SoundToggle } from "fancy-ui-svelte";
	import type { CommandItemData, ModelOptionData } from "fancy-ui-svelte";

	/** Generic tiers, so the fixture says nothing about anyone's product line-up. */
	const MODELS: ModelOptionData[] = [
		{ id: "mini", label: "Mini", badge: "Fast", description: "Short answers, small context." },
		{ id: "pro", label: "Pro", description: "Deeper reasoning, slower." },
	];

	const COMMANDS: CommandItemData[] = [
		{ id: "deploy", label: "/deploy", description: "Ship the current branch" },
		{ id: "tests", label: "/tests", description: "Run the affected suites" },
	];

	let draft = $state("");
	let model = $state("pro");
	let lastSent = $state("");

	function send(payload: { text: string }) {
		lastSent = payload.text;
	}
</script>

<div class="flex w-full max-w-xl flex-col gap-4">
	<div class="flex flex-wrap items-center gap-3">
		<SoundToggle size="sm" showLabel label="Sound for the composer" />
		<span class="text-muted-foreground text-sm">
			Turn sound on, then send a draft, switch the model, or type <code>/</code>.
		</span>
	</div>

	<!-- One `sound` on the root covers every part: sending plays press, the model
	     picker plays open, close and select, the command menu plays select. -->
	<Composer sound bind:value={draft} onSubmit={send}>
		{#snippet children()}
			<ComposerInput placeholder="Ask anything — / for commands" maxRows={4} />
			<ComposerToolbar class="mt-2">
				<ComposerModelPicker models={MODELS} bind:value={model} />
				<div class="flex-1"></div>
				<ComposerSubmit />
			</ComposerToolbar>
			<ComposerCommandMenu trigger="/" items={COMMANDS} />
		{/snippet}
	</Composer>

	<!-- The cue is never the only carrier: what was sent is also written out. -->
	<p class="text-muted-foreground text-sm">
		{lastSent ? `Sent: ${lastSent} · model: ${model}` : `Model: ${model}`}
	</p>
</div>
```

### With Agent Cards

ApprovalCard and RecommendationCard — approve and deny share one select cue; dismiss closes.

```svelte
<script lang="ts">
	import { ApprovalCard } from "fancy-ui-svelte";
	import type { ApprovalState } from "fancy-ui-svelte";
	import { RecommendationCard } from "fancy-ui-svelte";
	import type { RecommendationState } from "fancy-ui-svelte";
	import { SoundToggle } from "fancy-ui-svelte";

	let gate = $state<ApprovalState>("pending");
	let suggestion = $state<RecommendationState>("open");

	const settled = $derived(gate !== "pending" || suggestion !== "open");

	function reset() {
		gate = "pending";
		suggestion = "open";
	}
</script>

<div class="flex w-full max-w-xl flex-col gap-4">
	<div class="flex flex-wrap items-center gap-3">
		<SoundToggle size="sm" showLabel label="Sound for the agent cards" />
		<span class="text-muted-foreground text-sm">
			Turn sound on, then decide the gate or answer the suggestion.
		</span>
	</div>

	<!-- Approve and deny share one select cue — a refusal is a decision, not an
	     error. On the recommendation, accept plays select and dismiss plays close. -->
	<ApprovalCard
		sound
		title="Restart the staging cluster"
		description="Drains both nodes, then brings them back one at a time. About two minutes of downtime."
		bind:state={gate}
	/>

	<RecommendationCard
		sound
		badge="Suggestion"
		title="Batch the nightly export"
		description="Four exports run within the same ten minutes and each opens its own connection pool."
		confidence={0.81}
		acceptLabel="Batch them"
		bind:state={suggestion}
	/>

	{#if settled}
		<p class="text-muted-foreground text-sm">
			<button
				type="button"
				class="text-foreground underline decoration-dotted underline-offset-2"
				onclick={reset}
			>
				Reset both
			</button>
		</p>
	{/if}
</div>
```
