Zum Inhalt

src/ui/Topbar.tsx

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>
  );
};