import { type Component, For, type JSX, Show, createEffect, createSignal, on } from 'solid-js';
import { unwrap } from 'solid-js/store';
import { type AudioSource, audioCapture } from '~/audio/AudioCapture';
import {
AUDIO_SOURCES,
type AudioBand,
type AudioRoute,
type BlendMode,
MOD_TARGETS_AGENT,
MOD_TARGETS_POST,
type ModTarget,
type PresetParams,
SIGNAL_NAMES,
type SignalName,
type SpawnPattern,
type SpeciesParams,
} from '~/engine/types';
import { SIGNAL_CSS_VAR } from '~/presets/builtIn';
import {
presetState,
resetEdited,
saveOverwrite,
updateCurrentMetadata,
updateEdited,
updateMatrixCell,
updateSpecies,
} from '~/stores/presetStore';
import {
closePanel,
openPanel,
requestSnapshot,
setAudioActive,
setAudioLevels,
setAudioPermission,
setAudioSource,
triggerRespawn,
uiState,
} from '~/stores/uiStore';
import { ColorRow } from './ColorRow';
import { InteractionMatrixGrid } from './InteractionMatrixGrid';
import { ParamSlider } from './ParamSlider';
import { SelectRow } from './SelectRow';
import { SpeciesPanel } from './SpeciesPanel';
import { ToggleRow } from './ToggleRow';
import { Camera, ChevDown, Plus, Reset, X } from './icons/Icon';
// A collapsible, visually-bounded section card. Optional per-section reset
// button restores every parameter in the section to the preset default.
const Section: Component<{
title: string;
kicker?: string;
defaultOpen?: boolean;
onReset?: () => void;
children: JSX.Element;
}> = (p) => {
const [open, setOpen] = createSignal(p.defaultOpen ?? true);
return (
<div class="phys-section">
<div style={{ display: 'flex', 'align-items': 'stretch' }}>
<button
type="button"
class="phys-section-head"
style={{ flex: '1' }}
onClick={() => setOpen(!open())}
>
<span class="phys-section-chev" data-open={open() ? 'true' : 'false'}>
<ChevDown size={12} />
</span>
<span class="phys-label" style={{ 'font-weight': '700', 'letter-spacing': '0.16em' }}>
{p.title}
</span>
<Show when={p.kicker}>
<span class="phys-section-kicker">{p.kicker}</span>
</Show>
</button>
<Show when={p.onReset && open()}>
<button
type="button"
class="phys-iconbtn"
style={{ padding: '6px 10px', 'border-radius': '0' }}
onClick={() => p.onReset?.()}
title={`Reset ${p.title} to preset default`}
>
<Reset size={12} />
</button>
</Show>
</div>
<Show when={open()}>
<div class="phys-section-body">{p.children}</div>
</Show>
</div>
);
};
const SPAWN_OPTIONS = [
{ value: 'random', label: 'Random' },
{ value: 'clusters', label: 'Clusters' },
{ value: 'center', label: 'Center' },
{ value: 'ring', label: 'Ring' },
];
const BLEND_OPTIONS: { value: BlendMode; label: string }[] = [
{ value: 'additive', label: 'Additive (glow)' },
{ value: 'paint', label: 'Paint (ink)' },
];
const AUDIO_SOURCE_OPTIONS = [
{ value: 'mic', label: 'Microphone' },
{ value: 'tab', label: 'Tab audio (share dialog)' },
];
const BAND_OPTIONS = AUDIO_SOURCES.map((b) => ({ value: b, label: b.toUpperCase() }));
const TARGET_LABELS: Record<ModTarget, string> = {
moveSpeed: 'Move speed',
turnSpeed: 'Turn speed',
depositAmount: 'Deposit',
sensorDistance: 'Sensor distance',
rhythmAmplitude: 'Rhythm amplitude',
chaosStrength: 'Chaos strength',
exposure: 'Exposure',
bloomIntensity: 'Bloom intensity',
saturation: 'Saturation',
contrast: 'Contrast',
};
// Two groups so the routes-editor's target dropdown can use <optgroup>
// — at 10 flat items the list was hard to scan; the split into "what
// it does to the agents" vs "what it does to the post stack" tracks
// the mental model of the pipeline.
const AGENT_TARGET_OPTIONS = MOD_TARGETS_AGENT.map((t) => ({ value: t, label: TARGET_LABELS[t] }));
const POST_TARGET_OPTIONS = MOD_TARGETS_POST.map((t) => ({ value: t, label: TARGET_LABELS[t] }));
// Switching the live capture source mid-session: stop, swap, restart.
async function changeAudioSource(next: AudioSource): Promise<void> {
setAudioSource(next);
if (!audioCapture.active) return;
audioCapture.stop();
setAudioLevels(0, 0, 0);
setAudioPermission('requesting');
try {
await audioCapture.start(next);
setAudioPermission('granted');
setAudioActive(true);
} catch {
setAudioPermission('denied');
setAudioActive(false);
}
}
function cssColor(rgb: readonly [number, number, number]): string {
return `#${rgb
.map((c) =>
Math.round(c * 255)
.toString(16)
.padStart(2, '0'),
)
.join('')}`;
}
export const PresetEditor: Component = () => {
const get = <K extends keyof PresetParams>(k: K): PresetParams[K] => presetState.edited[k];
const set = <K extends keyof PresetParams>(k: K, v: PresetParams[K]) => updateEdited(k, v);
// Resets one global param back to the unmodified preset value.
const reset =
<K extends keyof PresetParams>(k: K) =>
() =>
set(k, presetState.current.params[k]);
// tab 0 = "All" (default), tabs 1..3 = species R/G/B
const [tab, setTab] = createSignal(0);
const speciesIndex = () => (tab() === 0 ? 0 : tab() - 1);
const activeSpecies = () => get('species')[speciesIndex()] ?? get('species')[0];
const speciesDefault = (i: number): SpeciesParams =>
presetState.current.params.species[i] ?? presetState.current.params.species[0];
const changeSpecies = <K extends keyof SpeciesParams>(key: K, value: SpeciesParams[K]): void => {
if (tab() === 0) {
for (let i = 0; i < 3; i += 1) updateSpecies(i, key, value);
} else {
updateSpecies(tab() - 1, key, value);
}
};
const resetSpecies = <K extends keyof SpeciesParams>(key: K): void => {
if (tab() === 0) {
for (let i = 0; i < 3; i += 1) updateSpecies(i, key, speciesDefault(i)[key]);
} else {
const i = tab() - 1;
updateSpecies(i, key, speciesDefault(i)[key]);
}
};
// Per-section reset helpers. Each section gets a reset button in its header
// that restores every parameter the section owns to the preset default.
const resetKeys = (keys: readonly (keyof PresetParams)[]): void => {
const def = presetState.current.params;
for (const k of keys)
updateEdited(k, structuredClone(unwrap(def[k])) as PresetParams[typeof k]);
};
const resetGlobal = () =>
resetKeys(['agentCount', 'spawnPattern', 'backgroundColor', 'blendMode', 'exposure']);
const resetSpeciesAll = () => resetKeys(['species']);
const resetMatrix = () => resetKeys(['interactionMatrix']);
const resetPheromone = () =>
resetKeys(['decayRate', 'diffusionFreq', 'fadeStrength', 'trailSaturation']);
const resetDynamics = () => resetKeys(['chaosInterval', 'chaosStrength', 'rhythmAmplitude']);
const resetAudio = () => resetKeys(['audioReactive']);
const resetEffects = () =>
resetKeys([
'feedbackAmount',
'bloomThreshold',
'bloomIntensity',
'saturation',
'contrast',
'vignette',
'headIntensity',
'kaleidoscope',
]);
return (
<div
style={{
position: 'absolute',
top: `calc(env(safe-area-inset-top, 0px) + ${uiState.isNarrow ? '70px' : '84px'})`,
right: `calc(env(safe-area-inset-right, 0px) + ${uiState.isNarrow ? '8px' : '16px'})`,
left: uiState.isNarrow ? 'calc(env(safe-area-inset-left, 0px) + 8px)' : 'auto',
bottom: `calc(env(safe-area-inset-bottom, 0px) + ${uiState.isNarrow ? '8px' : '16px'})`,
width: uiState.isNarrow ? 'auto' : '360px',
'max-width': uiState.isNarrow ? 'none' : '360px',
'z-index': '9',
background: 'var(--surface-primary)',
border: '1px solid var(--border-subtle)',
'border-radius': 'var(--radius-lg)',
'box-shadow': 'var(--shadow-modal)',
display: 'flex',
'flex-direction': 'column',
overflow: 'hidden',
animation: 'phys-fade-in 200ms var(--ease-signal)',
}}
>
<div
style={{
padding: '16px 18px 14px',
'border-bottom': '1px solid var(--border-subtle)',
display: 'flex',
'align-items': 'center',
gap: '8px',
}}
>
<div style={{ flex: '1', display: 'flex', 'flex-direction': 'column', gap: '4px' }}>
<span class="phys-eyebrow" style={{ 'font-size': '9px' }}>
{presetState.current.builtIn ? 'Editing (built-in, save as new to keep)' : 'Editing'}
</span>
<PresetHeader />
</div>
<button
type="button"
class="phys-iconbtn"
onClick={() => resetEdited()}
title="Reset all to preset default"
>
<Reset size={14} />
</button>
<button type="button" class="phys-iconbtn" onClick={() => closePanel()} title="Close">
<X />
</button>
</div>
<div style={{ flex: '1', 'overflow-y': 'auto', padding: '2px 0 12px' }}>
<Section title="Global" kicker="field · tone" onReset={resetGlobal}>
<ParamSlider
name="Agent count"
value={get('agentCount')}
min={100}
max={500_000}
ticks={7}
onChange={(v) => set('agentCount', Math.round(v))}
onReset={reset('agentCount')}
/>
<div style={{ display: 'flex', 'align-items': 'center', gap: '6px' }}>
<div style={{ flex: '1' }}>
<SelectRow
label="Spawn pattern"
value={get('spawnPattern')}
options={SPAWN_OPTIONS}
onChange={(v) => set('spawnPattern', v as SpawnPattern)}
/>
</div>
<button
type="button"
class="phys-iconbtn"
style={{ padding: '5px 7px' }}
onClick={() => triggerRespawn()}
title="Re-seed agents with the current spawn pattern (R)"
>
<Reset size={12} />
</button>
</div>
<ColorRow
label="Background color"
color={get('backgroundColor')}
name="void"
onChange={(c) => set('backgroundColor', c)}
/>
<SelectRow
label="Blend mode"
value={get('blendMode')}
options={BLEND_OPTIONS}
onChange={(v) => set('blendMode', v as BlendMode)}
/>
<ParamSlider
name="Exposure"
value={get('exposure')}
min={0.1}
max={10}
onChange={(v) => set('exposure', v)}
onReset={reset('exposure')}
/>
<ParamSlider
name="Sim speed"
value={get('simSpeed')}
min={1}
max={8}
ticks={8}
unit="×"
format={(v) => `${Math.round(v)}`}
onChange={(v) => set('simSpeed', Math.round(v))}
onReset={reset('simSpeed')}
/>
</Section>
<Section
title="Species"
kicker="per-species behaviour"
defaultOpen={false}
onReset={resetSpeciesAll}
>
<div style={{ display: 'flex', gap: '6px' }}>
<SpeciesTab label="All" active={tab() === 0} onClick={() => setTab(0)} />
<For each={get('species')}>
{(s, i) => (
<SpeciesTab
label={['R', 'G', 'B'][i()] ?? '?'}
dot={cssColor(s.color)}
active={tab() === i() + 1}
onClick={() => setTab(i() + 1)}
/>
)}
</For>
</div>
<SpeciesPanel
species={activeSpecies()}
onChange={changeSpecies}
onReset={resetSpecies}
hidePopulationShare={tab() === 0}
/>
</Section>
<Section
title="Interaction Matrix"
kicker="row sees column"
defaultOpen={false}
onReset={resetMatrix}
>
<InteractionMatrixGrid
matrix={get('interactionMatrix')}
colors={[
cssColor(get('species')[0].color),
cssColor(get('species')[1].color),
cssColor(get('species')[2].color),
]}
onChange={(r, c, v) => updateMatrixCell(r, c, v)}
/>
</Section>
<Section
title="Pheromone"
kicker="trail medium"
defaultOpen={false}
onReset={resetPheromone}
>
<ParamSlider
name="Decay rate"
value={get('decayRate')}
min={0.85}
max={0.999}
step={0.001}
format={(v) => v.toFixed(3)}
onChange={(v) => set('decayRate', v)}
onReset={reset('decayRate')}
/>
<ParamSlider
name="Diffusion interval"
value={get('diffusionFreq')}
min={1}
max={12}
ticks={6}
unit=" frames"
onChange={(v) => set('diffusionFreq', Math.round(v))}
onReset={reset('diffusionFreq')}
/>
<ParamSlider
name="Fade strength"
value={get('fadeStrength')}
min={0}
max={0.4}
onChange={(v) => set('fadeStrength', v)}
onReset={reset('fadeStrength')}
/>
<ParamSlider
name="Trail saturation"
value={get('trailSaturation')}
min={16}
max={400}
ticks={6}
onChange={(v) => set('trailSaturation', Math.round(v))}
onReset={reset('trailSaturation')}
/>
</Section>
<Section
title="Dynamics"
kicker="interaction · motion"
defaultOpen={false}
onReset={resetDynamics}
>
<ParamSlider
name="Chaos interval"
value={get('chaosInterval')}
min={0}
max={500}
ticks={6}
unit=" frames"
onChange={(v) => set('chaosInterval', Math.round(v))}
onReset={reset('chaosInterval')}
/>
<ParamSlider
name="Chaos strength"
value={get('chaosStrength')}
min={0}
max={1}
onChange={(v) => set('chaosStrength', v)}
onReset={reset('chaosStrength')}
/>
<ParamSlider
name="Rhythm amplitude"
value={get('rhythmAmplitude')}
min={0}
max={0.3}
onChange={(v) => set('rhythmAmplitude', v)}
onReset={reset('rhythmAmplitude')}
/>
</Section>
<Section title="Audio" kicker="live reactivity" defaultOpen={false} onReset={resetAudio}>
<SelectRow
label="Input source"
value={uiState.audio.source}
options={AUDIO_SOURCE_OPTIONS}
onChange={(v) => void changeAudioSource(v as AudioSource)}
/>
<Show when={uiState.audio.active}>
<AudioLevels />
</Show>
<ToggleRow
label="Audio reactive"
sub="Apply the routes below to the simulation in real time"
checked={get('audioReactive').enabled}
onChange={(v) => set('audioReactive', { ...get('audioReactive'), enabled: v })}
/>
<ToggleRow
label="Auto-gain"
sub="Continuously normalise levels so quiet sources still drive motion"
checked={get('audioReactive').autoGain}
onChange={(v) => set('audioReactive', { ...get('audioReactive'), autoGain: v })}
/>
<ParamSlider
name="Input gain"
value={get('audioReactive').gain}
min={0.1}
max={10}
format={(v) => `${v.toFixed(2)}x`}
onChange={(v) => set('audioReactive', { ...get('audioReactive'), gain: v })}
onReset={() =>
set('audioReactive', {
...get('audioReactive'),
gain: presetState.current.params.audioReactive.gain ?? 1,
})
}
/>
<RoutesEditor
routes={get('audioReactive').routes}
onChange={(routes) => set('audioReactive', { ...get('audioReactive'), routes })}
/>
</Section>
<Section
title="Effects"
kicker="post-processing"
defaultOpen={false}
onReset={resetEffects}
>
<ParamSlider
name="Frame feedback"
value={get('feedbackAmount')}
min={0}
max={0.97}
onChange={(v) => set('feedbackAmount', v)}
onReset={reset('feedbackAmount')}
/>
<ParamSlider
name="Bloom threshold"
value={get('bloomThreshold')}
min={0}
max={2}
onChange={(v) => set('bloomThreshold', v)}
onReset={reset('bloomThreshold')}
/>
<ParamSlider
name="Bloom intensity"
value={get('bloomIntensity')}
min={0}
max={3}
onChange={(v) => set('bloomIntensity', v)}
onReset={reset('bloomIntensity')}
/>
<ParamSlider
name="Saturation"
value={get('saturation')}
min={0}
max={2}
onChange={(v) => set('saturation', v)}
onReset={reset('saturation')}
/>
<ParamSlider
name="Contrast"
value={get('contrast')}
min={0}
max={2}
onChange={(v) => set('contrast', v)}
onReset={reset('contrast')}
/>
<ParamSlider
name="Vignette"
value={get('vignette')}
min={0}
max={1}
onChange={(v) => set('vignette', v)}
onReset={reset('vignette')}
/>
<ParamSlider
name="Head highlight"
value={get('headIntensity')}
min={0}
max={3}
onChange={(v) => set('headIntensity', v)}
onReset={reset('headIntensity')}
/>
<ParamSlider
name="Kaleidoscope"
value={get('kaleidoscope')}
min={0}
max={6}
step={1}
ticks={7}
format={(v) => (Math.round(v) === 0 ? 'off' : `${Math.round(v)} axes`)}
onChange={(v) => set('kaleidoscope', Math.round(v))}
onReset={reset('kaleidoscope')}
/>
</Section>
</div>
<div
style={{
padding: '16px',
'border-top': '1px solid var(--border-subtle)',
display: 'flex',
gap: '8px',
background: 'var(--surface-vault)',
}}
>
<button
type="button"
class="phys-btn"
data-variant="ghost"
onClick={() => requestSnapshot()}
title="Capture the current frame as this preset's preview"
>
<Camera size={13} /> Preview
</button>
<Show when={!presetState.current.builtIn}>
<button
type="button"
class="phys-btn"
data-variant="primary"
style={{ flex: '1' }}
onClick={() => saveOverwrite()}
disabled={!presetState.isDirty}
title={
presetState.isDirty
? `Save changes to "${presetState.current.name}"`
: 'No unsaved changes'
}
>
Save
</button>
</Show>
<button
type="button"
class="phys-btn"
data-variant={presetState.current.builtIn ? 'primary' : 'ghost'}
style={{ flex: presetState.current.builtIn ? '1' : 'initial' }}
onClick={() => openPanel('save')}
title="Save the current edits as a new custom preset"
>
<Plus size={12} /> Save as new
</button>
</div>
</div>
);
};
// Inline-editable header — name + signal colour + tags can be patched
// directly on custom presets; built-ins render the same fields read-only
// (with the live "dirty dot" reminder if the user has edits in flight).
const PresetHeader: Component = () => {
const isCustom = () => !presetState.current.builtIn;
const signal = () => presetState.current.tagSignal;
const [tagDraft, setTagDraft] = createSignal(presetState.current.tags.join(', '));
// Keep the draft in sync if the preset is swapped underneath us.
createEffect(
on(
() => presetState.current.id,
() => setTagDraft(presetState.current.tags.join(', ')),
{ defer: true },
),
);
const commitName = (v: string) => {
const next = v.trim().slice(0, 64);
if (next && next !== presetState.current.name) {
updateCurrentMetadata({ name: next });
}
};
const commitTags = (raw: string) => {
const next = raw
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0)
.slice(0, 8);
updateCurrentMetadata({ tags: next });
};
const cycleSignal = () => {
const i = SIGNAL_NAMES.indexOf(signal());
const next = SIGNAL_NAMES[(i + 1) % SIGNAL_NAMES.length] as SignalName;
updateCurrentMetadata({ tagSignal: next });
};
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '6px' }}>
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px' }}>
<button
type="button"
onClick={() => isCustom() && cycleSignal()}
title={isCustom() ? 'Click to cycle signal colour' : 'Signal colour (read-only)'}
disabled={!isCustom()}
style={{
width: '14px',
height: '14px',
'border-radius': '50%',
background: SIGNAL_CSS_VAR[signal()],
'box-shadow': `0 0 10px ${SIGNAL_CSS_VAR[signal()]}`,
border: '0',
padding: '0',
cursor: isCustom() ? 'pointer' : 'default',
}}
/>
<Show
when={isCustom()}
fallback={
<span
style={{
'font-family': 'var(--font-display)',
'font-size': '18px',
'font-weight': '600',
}}
>
{presetState.current.name}
<Show when={presetState.isDirty}>
<span style={{ color: 'var(--fg-tertiary)' }}> ·</span>
</Show>
</span>
}
>
<input
type="text"
value={presetState.current.name}
onChange={(e) => commitName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
}}
maxLength={64}
style={{
flex: '1',
background: 'transparent',
border: '1px solid transparent',
'border-bottom': '1px dashed var(--border-subtle)',
color: 'var(--fg-primary)',
'font-family': 'var(--font-display)',
'font-size': '18px',
'font-weight': '600',
padding: '2px 4px',
outline: 'none',
'min-width': '0',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--border-default)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'transparent';
}}
/>
<Show when={presetState.isDirty}>
<span
style={{ color: 'var(--fg-tertiary)', 'font-size': '14px' }}
title="Unsaved param edits"
>
·
</span>
</Show>
</Show>
</div>
<Show when={isCustom()}>
<input
type="text"
value={tagDraft()}
onInput={(e) => setTagDraft(e.currentTarget.value)}
onChange={(e) => commitTags(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
}}
placeholder="tags, comma separated"
maxLength={120}
class="phys-mono"
style={{
background: 'transparent',
border: '1px dashed var(--border-subtle)',
'border-radius': 'var(--radius-sm)',
color: 'var(--fg-tertiary)',
'font-size': '10px',
padding: '4px 6px',
outline: 'none',
}}
/>
</Show>
</div>
);
};
// Editable list of audio-to-parameter routes. Each row pairs a band with a
// target and a signed depth multiplier. Add/remove freely; the engine sums
// all routes per target each frame.
const RoutesEditor: Component<{
routes: readonly AudioRoute[];
onChange: (routes: readonly AudioRoute[]) => void;
}> = (p) => {
const update = (i: number, patch: Partial<AudioRoute>): void => {
const next = p.routes.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
p.onChange(next);
};
const remove = (i: number): void => p.onChange(p.routes.filter((_, idx) => idx !== i));
const add = (): void =>
p.onChange([...p.routes, { source: 'bass', target: 'moveSpeed', depth: 0.5 }]);
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '6px', padding: '8px 0' }}>
<div
class="phys-eyebrow"
style={{ 'font-size': '9px', display: 'flex', 'align-items': 'center', gap: '8px' }}
>
<span style={{ flex: '1' }}>Routes ({p.routes.length})</span>
<button
type="button"
class="phys-iconbtn"
onClick={add}
title="Add route"
style={{ width: '22px', height: '22px' }}
>
<Plus size={11} />
</button>
</div>
<Show
when={p.routes.length > 0}
fallback={
<span style={{ 'font-size': '11px', color: 'var(--fg-tertiary)' }}>
No routes — audio input has no effect. Add one to wire e.g. bass → move speed.
</span>
}
>
<For each={p.routes}>
{(route, i) => (
<div
style={{
display: 'grid',
'grid-template-columns': '60px 100px 1fr 22px',
'align-items': 'center',
gap: '6px',
}}
>
<select
value={route.source}
onChange={(e) => update(i(), { source: e.currentTarget.value as AudioBand })}
class="phys-mono"
style={{
background: 'var(--surface-inset)',
color: 'var(--fg-primary)',
border: '1px solid var(--border-subtle)',
'border-radius': 'var(--radius-md)',
padding: '4px 6px',
'font-size': '11px',
}}
>
<For each={BAND_OPTIONS}>{(o) => <option value={o.value}>{o.label}</option>}</For>
</select>
<select
value={route.target}
onChange={(e) => update(i(), { target: e.currentTarget.value as ModTarget })}
style={{
background: 'var(--surface-inset)',
color: 'var(--fg-primary)',
border: '1px solid var(--border-subtle)',
'border-radius': 'var(--radius-md)',
padding: '4px 6px',
'font-size': '11px',
}}
>
<optgroup label="Agent behaviour">
<For each={AGENT_TARGET_OPTIONS}>
{(o) => <option value={o.value}>{o.label}</option>}
</For>
</optgroup>
<optgroup label="Post effects">
<For each={POST_TARGET_OPTIONS}>
{(o) => <option value={o.value}>{o.label}</option>}
</For>
</optgroup>
</select>
<ParamSlider
name="Depth"
value={route.depth}
min={-2}
max={2}
onChange={(v) => update(i(), { depth: v })}
/>
<button
type="button"
class="phys-iconbtn"
onClick={() => remove(i())}
title="Remove route"
style={{ width: '22px', height: '22px' }}
>
<X size={10} />
</button>
</div>
)}
</For>
</Show>
</div>
);
};
// Three-band level readout (bass / mid / high) — animated by uiState.audio.levels.
const AudioLevels: Component = () => {
const rows = [
{ key: 'bass' as const, label: 'Bass' },
{ key: 'mid' as const, label: 'Mid' },
{ key: 'high' as const, label: 'High' },
];
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '4px', padding: '6px 0' }}>
<For each={rows}>
{(r) => (
<div
style={{
display: 'flex',
'align-items': 'center',
gap: '8px',
'font-size': '10px',
color: 'var(--fg-tertiary)',
}}
>
<span class="phys-mono" style={{ width: '34px' }}>
{r.label}
</span>
<div
style={{
flex: '1',
height: '4px',
background: 'var(--surface-inset)',
'border-radius': '2px',
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${Math.round(uiState.audio.levels[r.key] * 100)}%`,
background: 'var(--accent)',
transition: 'width 60ms linear',
}}
/>
</div>
<span class="phys-mono" style={{ width: '32px', 'text-align': 'right' }}>
{(uiState.audio.levels[r.key] * 100).toFixed(0)}%
</span>
</div>
)}
</For>
</div>
);
};
// A single species selector tab (used for "All" and R/G/B).
const SpeciesTab: Component<{
label: string;
dot?: string;
active: boolean;
onClick: () => void;
}> = (p) => (
<button
type="button"
onClick={p.onClick}
style={{
flex: '1',
display: 'flex',
'align-items': 'center',
'justify-content': 'center',
gap: '6px',
padding: '7px 0',
background: p.active ? 'var(--surface-raised)' : 'transparent',
border: `1px solid ${p.active ? 'var(--accent)' : 'var(--border-subtle)'}`,
'border-radius': 'var(--radius-md)',
cursor: 'pointer',
color: 'var(--fg-primary)',
}}
>
<Show when={p.dot}>
<span
style={{
width: '9px',
height: '9px',
'border-radius': '50%',
background: p.dot,
}}
/>
</Show>
<span class="phys-mono" style={{ 'font-size': '10px' }}>
{p.label}
</span>
</button>
);