import { type Component, For, Show, createMemo } from 'solid-js';
import { formatTime } from '~/lib/timing';
import { SIGNAL_CSS_VAR } from '~/presets/builtIn';
import { presetState } from '~/stores/presetStore';
import { openPanel, togglePaused, triggerRespawn, uiState } from '~/stores/uiStore';
import {
Camera,
ChevDown,
Library,
Menu,
Mic,
Pause,
Play,
Record,
Reset,
Share,
Sliders,
Square,
} from './icons/Icon';
export interface TopbarActions {
fps: () => number;
agentCount: () => number;
onToggleAudio: () => void;
onToggleRecord: () => void;
onShare: () => void;
onExport: () => void;
}
type Props = TopbarActions;
const AudioMeter: Component = () => {
const bands = (): readonly [number, number, number] => [
uiState.audio.levels.bass,
uiState.audio.levels.mid,
uiState.audio.levels.high,
];
return (
<div
title={`Bass ${(bands()[0] * 100).toFixed(0)}% · Mid ${(bands()[1] * 100).toFixed(0)}% · High ${(bands()[2] * 100).toFixed(0)}%`}
style={{
display: 'flex',
'align-items': 'flex-end',
gap: '3px',
height: '20px',
padding: '0 4px',
}}
>
<For each={[0, 1, 2] as const}>
{(i) => (
<span
style={{
width: '3px',
height: `${Math.max(2, bands()[i] * 20)}px`,
background:
bands()[i] > 0.05
? 'var(--accent, #6ee7b7)'
: 'color-mix(in oklab, var(--fg-tertiary) 50%, transparent)',
'border-radius': '1px',
transition: 'height 60ms linear, background 200ms linear',
}}
/>
)}
</For>
</div>
);
};
export const Topbar: Component<Props> = (props) => {
const padX = createMemo(() => (uiState.density === 'compact' ? 10 : 14));
const gap = createMemo(() => (uiState.density === 'compact' ? 4 : 8));
const height = createMemo(() => (uiState.density === 'compact' ? 44 : 52));
const top = createMemo(() => (uiState.density === 'compact' ? 12 : 16));
const recSeconds = createMemo(() => {
const start = uiState.recording.startedAt;
return start ? Math.floor((Date.now() - start) / 1000) : 0;
});
const narrow = () => uiState.isNarrow;
return (
<div
class="phys-glass"
style={{
position: 'absolute',
top: `calc(env(safe-area-inset-top, 0px) + ${top()}px)`,
left: `calc(env(safe-area-inset-left, 0px) + ${top()}px)`,
right: `calc(env(safe-area-inset-right, 0px) + ${top()}px)`,
height: `${height()}px`,
display: 'flex',
'align-items': 'center',
padding: `0 ${padX()}px`,
gap: `${gap()}px`,
'border-radius': 'var(--radius-lg)',
'z-index': '10',
}}
>
{/* Brand — sigil only on narrow viewports to make room for the buttons. */}
<div
style={{
display: 'flex',
'align-items': 'center',
gap: '10px',
'padding-right': narrow() ? '0' : '12px',
'border-right': narrow() ? '0' : '1px solid var(--phys-glass-border)',
height: '60%',
}}
>
<svg
viewBox="0 0 22 22"
width="22"
height="22"
style={{ color: 'var(--accent)' }}
aria-hidden="true"
>
<circle
cx="11"
cy="11"
r="8.5"
stroke="currentColor"
stroke-width="1"
fill="none"
opacity="0.4"
/>
<circle
cx="11"
cy="11"
r="5"
stroke="currentColor"
stroke-width="1"
fill="none"
opacity="0.7"
/>
<circle cx="11" cy="11" r="2" fill="currentColor" />
</svg>
<Show when={!narrow()}>
<div style={{ display: 'flex', 'align-items': 'baseline', gap: '8px' }}>
<span
style={{
'font-family': 'var(--font-display)',
'font-size': '16px',
'font-weight': '600',
}}
>
Physarum
</span>
<span
class="phys-mono"
style={{
'font-size': '9px',
color: 'var(--fg-tertiary)',
'letter-spacing': '0.14em',
'text-transform': 'uppercase',
}}
>
v1.2
</span>
</div>
</Show>
</div>
{/* Preset readout */}
<div
style={{
display: 'flex',
'align-items': 'center',
gap: narrow() ? '6px' : '10px',
'padding-left': '4px',
'min-width': '0',
flex: narrow() ? '1' : 'initial',
}}
>
<Show when={!narrow()}>
<span class="phys-eyebrow" style={{ 'font-size': '9px' }}>
Preset
</span>
</Show>
<button
type="button"
onClick={() => openPanel('library')}
class="phys-mono"
style={{
background: 'transparent',
border: '1px solid var(--border-subtle)',
color: 'var(--fg-primary)',
padding: '4px 10px',
'border-radius': 'var(--radius-md)',
'font-size': '11px',
cursor: 'pointer',
display: narrow() ? 'flex' : 'inline-flex',
'align-items': 'center',
gap: '8px',
'min-width': '0',
// On narrow: take the full remaining flex space so the preset
// name has room before it gets ellipsised. inline-flex shrinks
// to intrinsic content and ignores `width: 100%` on its own.
width: narrow() ? '100%' : 'auto',
'max-width': narrow() ? '100%' : 'none',
overflow: 'hidden',
}}
>
<span
style={{
width: '7px',
height: '7px',
'flex-shrink': '0',
'border-radius': '50%',
background: SIGNAL_CSS_VAR[presetState.current.tagSignal],
'box-shadow': `0 0 8px ${SIGNAL_CSS_VAR[presetState.current.tagSignal]}`,
}}
/>
<span
style={{
overflow: 'hidden',
'text-overflow': 'ellipsis',
'white-space': 'nowrap',
'min-width': '0',
}}
>
{presetState.current.name}
</span>
<ChevDown size={12} style={{ opacity: '0.5', 'flex-shrink': '0' }} />
</button>
</div>
<Show when={uiState.recording.active}>
<div
style={{
display: 'flex',
'align-items': 'center',
gap: '8px',
'margin-left': '12px',
padding: '4px 10px',
border: '1px solid var(--phys-rec)',
'border-radius': '999px',
background: 'color-mix(in oklab, var(--signal-crimson) 10%, transparent)',
}}
>
<span
style={{
width: '8px',
height: '8px',
'border-radius': '50%',
background: 'var(--phys-rec)',
'box-shadow': '0 0 10px var(--phys-rec)',
animation: 'phys-rec-pulse 1.4s var(--ease-pulse) infinite',
}}
/>
<span
class="phys-mono"
style={{ 'font-size': '10px', 'font-weight': '700', color: 'var(--phys-rec)' }}
>
REC
</span>
<span class="phys-num" style={{ 'font-size': '11px' }}>
{formatTime(recSeconds())}
</span>
</div>
</Show>
<div style={{ flex: '1' }} />
<Show when={uiState.showFps && !narrow()}>
<div
class="phys-mono"
style={{ 'font-size': '10px', color: 'var(--fg-tertiary)', display: 'flex', gap: '10px' }}
>
<span>{props.fps().toFixed(1)} fps</span>
<span style={{ opacity: '0.4' }}>·</span>
<span>{(props.agentCount() / 1000).toFixed(0)}k agents</span>
</div>
</Show>
<Show when={!narrow()}>
<div class="phys-vr" style={{ height: '60%' }} />
<button
type="button"
class="phys-iconbtn"
onClick={() => openPanel('library')}
title="Library"
>
<Library />
</button>
</Show>
<button
type="button"
class="phys-iconbtn"
data-active={uiState.panel === 'editor'}
onClick={() => openPanel(uiState.panel === 'editor' ? 'none' : 'editor')}
title="Editor"
>
<Sliders />
</button>
<button
type="button"
class="phys-iconbtn"
onClick={() => togglePaused()}
title={uiState.paused ? 'Play (Space)' : 'Pause (Space)'}
>
{uiState.paused ? <Play /> : <Pause />}
</button>
<Show when={!narrow()}>
<button
type="button"
class="phys-iconbtn"
onClick={() => triggerRespawn()}
title="Respawn (R)"
>
<Reset />
</button>
</Show>
{/* Secondary actions — hidden on narrow viewports, exposed via the
settings menu instead. The audio meter stays visible whenever
audio is live, even on mobile, since it's a live indicator. */}
<Show when={!narrow()}>
<button
type="button"
class="phys-iconbtn"
onClick={() => props.onExport()}
title="Export PNG"
>
<Camera />
</button>
<button
type="button"
class="phys-iconbtn"
data-active={uiState.audio.active}
onClick={() => props.onToggleAudio()}
title={
uiState.audio.active
? `Audio · ${uiState.audio.source === 'tab' ? 'tab' : 'mic'} (click to stop)`
: 'Audio (mic or tab — pick the source in the editor)'
}
>
<Mic />
</button>
</Show>
<Show when={uiState.audio.active}>
<AudioMeter />
</Show>
<Show when={!narrow()}>
<button
type="button"
class="phys-iconbtn"
data-active={uiState.recording.active}
onClick={() => props.onToggleRecord()}
title="Record"
style={uiState.recording.active ? { color: 'var(--phys-rec)' } : {}}
>
{uiState.recording.active ? <Square /> : <Record />}
</button>
<button type="button" class="phys-iconbtn" onClick={() => props.onShare()} title="Share">
<Share />
</button>
<div class="phys-vr" style={{ height: '60%' }} />
</Show>
<button type="button" class="phys-iconbtn" onClick={() => openPanel('settings')} title="Menu">
<Menu />
</button>
</div>
);
};