From 839747304ef8f97f7fc46904b900f8b165f3ec05 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 24 May 2026 10:58:21 +0100 Subject: [PATCH] Wire up garden controls --- src/index.ts | 314 ++++++++++++++----- src/page/collapsible-panel-animator.ts | 70 ++++- src/page/color-reaction-matrix-control.ts | 187 +++++++++++ src/page/config-pane.ts | 365 ++++++++++++++++++++++ src/page/eraser-size-control.ts | 84 +++++ src/page/error-presenter.ts | 62 ++++ src/page/full-screen-handler.ts | 51 +-- src/page/menu-hider.ts | 144 ++++++++- src/page/mirror-segment-control.ts | 66 ++++ src/page/palette-control.ts | 76 +++++ src/page/set-up-settings-page.ts | 115 ------- src/page/settings-slider.ts | 146 --------- src/page/splash-screen.ts | 61 ++++ src/page/vibe-navigator.ts | 85 +++++ src/utils/dom.ts | 24 ++ 15 files changed, 1457 insertions(+), 393 deletions(-) create mode 100644 src/page/color-reaction-matrix-control.ts create mode 100644 src/page/config-pane.ts create mode 100644 src/page/eraser-size-control.ts create mode 100644 src/page/error-presenter.ts create mode 100644 src/page/mirror-segment-control.ts create mode 100644 src/page/palette-control.ts delete mode 100644 src/page/set-up-settings-page.ts delete mode 100644 src/page/settings-slider.ts create mode 100644 src/page/splash-screen.ts create mode 100644 src/page/vibe-navigator.ts create mode 100644 src/utils/dom.ts diff --git a/src/index.ts b/src/index.ts index 1e2c2d9..74b98d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,107 +1,251 @@ -import { isProduction } from './constants'; import GameLoop from './game-loop/game-loop'; -import { GameRules } from './game-loop/game-rules'; import './index.scss'; +import { initAnalytics, trackExport, trackStart, trackVibeChange } from './analytics'; +import { preloadPianoSamples } from './audio/piano-samples'; +import { AudioControl } from './page/audio-control'; import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; +import { ConfigPane } from './page/config-pane'; +import { EraserSizeControl } from './page/eraser-size-control'; +import { ErrorPresenter } from './page/error-presenter'; import { FullScreenHandler } from './page/full-screen-handler'; import { MenuHider } from './page/menu-hider'; -import { setUpSettingsPage } from './page/set-up-settings-page'; -import { SettingsSlider } from './page/settings-slider'; -import { resetSettings } from './settings'; +import { MirrorSegmentControl } from './page/mirror-segment-control'; +import { PaletteControl } from './page/palette-control'; +import { SplashScreen } from './page/splash-screen'; +import { VibeNavigator } from './page/vibe-navigator'; +import { getMaxSupportedAgentCount } from './pipelines/agents/agent-limits'; +import { activeVibe } from './settings'; import { DeltaTimeCalculator } from './utils/delta-time-calculator'; +import { queryRequiredElement } from './utils/dom'; import { ErrorHandler, Severity } from './utils/error-handler'; import { initializeGpu } from './utils/graphics/initialize-gpu'; -const elements = { - aside: document.querySelector('aside') as HTMLDivElement, - infoButton: document.querySelector('button.info') as HTMLButtonElement, - infoElement: document.querySelector('.info-page') as HTMLDivElement, - settingsPage: document.querySelector('.settings-page') as HTMLDivElement, - settingsContent: document.querySelector('.settings-content') as HTMLDivElement, - applyDefaults: document.querySelector('#apply-defaults') as HTMLButtonElement, - minimizeFullScreenButton: document.querySelector( - 'button.minimize-full-screen' - ) as HTMLButtonElement, - maximizeFullScreenButton: document.querySelector( - 'button.maximize-full-screen' - ) as HTMLButtonElement, - settingsButton: document.querySelector('button.settings') as HTMLButtonElement, - restartButton: document.querySelector('button.restart') as HTMLButtonElement, - canvas: document.querySelector('canvas') as HTMLCanvasElement, - errorContainer: document.querySelector('.errors-container') as HTMLDivElement, -}; - const main = async () => { + let hasRuntimeErrorListener = false; try { + initAnalytics(); + let shouldStop = false; + let hasStarted = false; let game: GameLoop | null = null; - - ErrorHandler.addOnErrorListener((error, _metadata) => { - elements.errorContainer.innerHTML += ` -
${error.message}
-      `;
-      game?.destroy();
-      shouldStop = true;
-    });
-
-    const infoPageHandler = new CollapsiblePanelAnimator(
-      elements.infoButton,
-      elements.infoElement,
-      elements.aside
-    );
-    const settingsPageHandler = new CollapsiblePanelAnimator(
-      elements.settingsButton,
-      elements.settingsPage,
-      elements.aside
-    );
-    settingsPageHandler.onOpen = infoPageHandler.close.bind(infoPageHandler);
-    infoPageHandler.onOpen = settingsPageHandler.close.bind(settingsPageHandler);
-
-    if (isProduction) {
-      infoPageHandler.open();
-    }
-
-    new MenuHider(
-      elements.aside,
-      () =>
-        FullScreenHandler.isInFullScreenMode() &&
-        !settingsPageHandler.isOpen &&
-        !infoPageHandler.isOpen
-    );
-    new FullScreenHandler(
-      elements.minimizeFullScreenButton,
-      elements.maximizeFullScreenButton,
-      document.body
-    );
-
-    const gpu = await initializeGpu();
-
-    elements.restartButton.addEventListener('click', () => game?.destroy());
-
-    const deltaTimeCalculator = new DeltaTimeCalculator();
-    let sliders: Array> = [];
-
-    elements.applyDefaults.addEventListener('click', () => {
-      resetSettings();
-      sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
-    });
-
-    while (!shouldStop) {
-      const gameRules = new GameRules(performance.now() / 1000);
-      game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
-
-      if (sliders.length === 0) {
-        sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount);
+    let configPane: ConfigPane | null = null;
+    const getGame = () => game;
+    const destroyCurrentGame = async () => {
+      const currentGame = game;
+      if (!currentGame) {
+        return;
       }
 
-      await game.start();
+      game = null;
+      await currentGame.destroy();
+    };
+
+    const errorPresenter = new ErrorPresenter(
+      queryRequiredElement('.errors-container', HTMLElement)
+    );
+    ErrorHandler.addOnErrorListener((error) => {
+      errorPresenter.render(error);
+      if (error.severity === Severity.ERROR) {
+        document.body.classList.remove('is-loading');
+        void destroyCurrentGame();
+        shouldStop = true;
+      }
+    });
+    hasRuntimeErrorListener = true;
+
+    const aside = queryRequiredElement('aside', HTMLElement);
+    const canvas = queryRequiredElement('canvas', HTMLCanvasElement);
+    const toolbarRow = queryRequiredElement('.toolbar-row', HTMLElement);
+    const eraserPreview = queryRequiredElement('.eraser-preview', HTMLDivElement);
+    const grainOverlay = queryRequiredElement('.garden-grain', HTMLDivElement);
+    const promptElement = queryRequiredElement('.garden-prompt', HTMLDivElement);
+    const exportStatus = queryRequiredElement('.export-status', HTMLSpanElement);
+    const settingsButton = queryRequiredElement(
+      '[data-control="settings"]',
+      HTMLButtonElement
+    );
+    const restartButton = queryRequiredElement(
+      '[data-control="restart"]',
+      HTMLButtonElement
+    );
+    const infoButton = queryRequiredElement('[data-control="info"]', HTMLButtonElement);
+    const infoElement = queryRequiredElement('.info-page', HTMLElement);
+    const fullScreenButton = queryRequiredElement(
+      '[data-control="full-screen"]',
+      HTMLButtonElement
+    );
+    const export4kButton = queryRequiredElement(
+      '[data-control="export"]',
+      HTMLButtonElement
+    );
+
+    const splash = new SplashScreen();
+    let eraserSizeControl: EraserSizeControl | null = null;
+    const paletteControl = new PaletteControl({
+      getGame,
+      onChange: () => configPane?.refresh(),
+      onModeChange: (isEraserActive) => eraserSizeControl?.setActive(isEraserActive),
+    });
+    eraserSizeControl = new EraserSizeControl({
+      getGame,
+      onActivate: () => paletteControl.setEraserActive(true),
+      onChange: () => configPane?.refresh(),
+    });
+    const mirrorSegmentControl = new MirrorSegmentControl({
+      onChange: () => {
+        paletteControl.setEraserActive(false);
+        configPane?.refresh();
+      },
+    });
+    const audioControl = new AudioControl({
+      getGame,
+      hasStarted: () => hasStarted,
+      startButton: splash.startButton,
+    });
+
+    const syncRuntimeUi = () => {
+      eraserSizeControl?.render();
+      eraserSizeControl?.setActive(paletteControl.isEraserActive);
+      mirrorSegmentControl.render();
+      paletteControl.render();
+    };
+
+    const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
+    new MenuHider(
+      aside,
+      () =>
+        FullScreenHandler.isInFullScreenMode() &&
+        !configPane?.isOpen &&
+        !infoPageHandler.isOpen
+    );
+    new FullScreenHandler(fullScreenButton, document.documentElement);
+
+    new VibeNavigator({
+      onChange: ({ vibeId, vibeName, source, userGesture }) => {
+        trackVibeChange({ vibeId, vibeName, source });
+        game?.onVibeChanged();
+        syncRuntimeUi();
+        configPane?.refresh();
+        game?.playVibeChangeAudio(userGesture);
+      },
+    });
+
+    restartButton.addEventListener('click', () => void destroyCurrentGame());
+
+    export4kButton.addEventListener('click', async () => {
+      const currentGame = game;
+      if (!currentGame || export4kButton.disabled) {
+        return;
+      }
+
+      export4kButton.disabled = true;
+      try {
+        await currentGame.exportSnapshot();
+        trackExport({ vibeId: activeVibe.id });
+      } catch (error) {
+        ErrorHandler.addException(error, { severity: Severity.WARNING });
+      } finally {
+        export4kButton.disabled = false;
+      }
+    });
+
+    // Samples load before Start is enabled so the first audible piano note
+    // always uses the sampler. The Start tap still unlocks the AudioContext.
+    splash.showLoadingBar();
+    const fontsReady = document.fonts.ready.catch((error) => {
+      ErrorHandler.addException(error, {
+        fallbackMessage: 'Could not load fonts.',
+        severity: Severity.WARNING,
+      });
+    });
+    const gpuPromise = initializeGpu();
+
+    const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => {
+      const ratio = totalCount > 0 ? loadedCount / totalCount : 0;
+      splash.setLoadingStage(
+        `Loading piano samples ${loadedCount}/${totalCount}…`,
+        ratio
+      );
+    }).then(
+      () => {
+        splash.setLoadingStage('Ready', 1);
+      },
+      (error: unknown) => {
+        splash.setLoadingStage('Piano unavailable', 1);
+        ErrorHandler.addException(error, {
+          fallbackMessage: 'Could not preload piano samples.',
+          severity: Severity.WARNING,
+        });
+      }
+    );
+
+    const gpu = await gpuPromise;
+    const gpuNavigator = navigator.gpu;
+    if (!gpuNavigator) {
+      throw new Error('WebGPU is no longer available after initialization.');
+    }
+    const canvasFormat = gpuNavigator.getPreferredCanvasFormat();
+    configPane = new ConfigPane({
+      maxSupportedAgentCount: getMaxSupportedAgentCount(gpu),
+      settingsButton,
+      onOpen: () => infoPageHandler.close(),
+      onConfigChange: () => {
+        game?.onVibeChanged();
+        syncRuntimeUi();
+      },
+      onRuntimeChange: syncRuntimeUi,
+    });
+    infoPageHandler.onOpen = configPane.close.bind(configPane);
+    await fontsReady;
+    await preloadPromise;
+    splash.hideLoadingBar();
+
+    const deltaTimeCalculator = new DeltaTimeCalculator();
+
+    let isFirstStart = true;
+    while (!shouldStop) {
+      const loop = new GameLoop(canvas, gpu, canvasFormat, deltaTimeCalculator, {
+        toolbar: toolbarRow,
+        prompt: promptElement,
+        eraserPreview,
+        grainOverlay,
+        exportStatus,
+      });
+      game = loop;
+      syncRuntimeUi();
+      audioControl.render();
+
+      if (isFirstStart) {
+        isFirstStart = false;
+
+        // Splash is in the DOM by default; enable the button now that the
+        // audio system (GameLoop) is constructed and ready to be unlocked.
+        await splash.awaitStart(() => {
+          hasStarted = true;
+          game?.startAudio(true);
+          trackStart();
+        });
+
+        requestAnimationFrame(() =>
+          requestAnimationFrame(() => document.body.classList.remove('is-loading'))
+        );
+      }
+      loop.attachPointerInput();
+      await loop.start();
+      if (game === loop) {
+        game = null;
+      }
     }
   } catch (e) {
-    const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
-    ErrorHandler.addError(Severity.ERROR, message);
-    console.error(e);
+    document.body.classList.remove('is-loading');
+    if (hasRuntimeErrorListener) {
+      ErrorHandler.addException(e);
+    } else {
+      ErrorPresenter.renderStartup(e);
+      ErrorHandler.addException(e);
+    }
   }
 };
 
diff --git a/src/page/collapsible-panel-animator.ts b/src/page/collapsible-panel-animator.ts
index d4c91fa..a86a637 100644
--- a/src/page/collapsible-panel-animator.ts
+++ b/src/page/collapsible-panel-animator.ts
@@ -1,44 +1,90 @@
 export class CollapsiblePanelAnimator {
   private _isOpen = false;
-
-  public onOpen: () => unknown = () => {};
-  public onClose: () => unknown = () => {};
+  private focusBeforeOpen: HTMLElement | null = null;
+  private readonly abortController = new AbortController();
+  public onOpen?: () => void;
 
   public constructor(
     private readonly toggleButton: HTMLButtonElement,
     private readonly collapsibleContent: HTMLElement,
     ignoreForCloseOnClick: HTMLElement
   ) {
-    toggleButton.addEventListener('click', this.toggle.bind(this));
+    const { signal } = this.abortController;
+    toggleButton.addEventListener('click', this.toggle, { signal });
     window.addEventListener(
       'click',
-      (event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close()
+      (event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close(),
+      { signal }
     );
+    window.addEventListener(
+      'keydown',
+      (event) => {
+        if (this._isOpen && event.key === 'Escape') {
+          event.preventDefault();
+          this.close();
+        }
+      },
+      { signal }
+    );
+    this.syncAccessibility();
   }
 
   public open() {
+    if (this._isOpen) {
+      return;
+    }
+
+    this.focusBeforeOpen =
+      document.activeElement instanceof HTMLElement ? document.activeElement : null;
     this._isOpen = true;
-    this.collapsibleContent.classList.remove('hidden');
-    this.toggleButton.classList.add('active');
-    this.onOpen();
+    this.onOpen?.();
+    this.syncAccessibility();
+    this.focusPanel();
   }
 
   public close() {
+    if (!this._isOpen) {
+      return;
+    }
+
+    const focusWasInside = this.collapsibleContent.contains(document.activeElement);
     this._isOpen = false;
-    this.collapsibleContent.classList.add('hidden');
-    this.toggleButton.classList.remove('active');
-    this.onClose();
+    this.syncAccessibility();
+
+    if (focusWasInside) {
+      (this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });
+    }
   }
 
-  public toggle() {
+  public readonly toggle = () => {
     if (this._isOpen) {
       this.close();
     } else {
       this.open();
     }
+  };
+
+  public destroy(): void {
+    this.abortController.abort();
   }
 
   public get isOpen() {
     return this._isOpen;
   }
+
+  private syncAccessibility() {
+    this.collapsibleContent.classList.toggle('hidden', !this._isOpen);
+    this.toggleButton.classList.toggle('active', this._isOpen);
+    this.toggleButton.setAttribute('aria-expanded', String(this._isOpen));
+    this.collapsibleContent.setAttribute('aria-hidden', String(!this._isOpen));
+    this.collapsibleContent.inert = !this._isOpen;
+  }
+
+  private focusPanel() {
+    requestAnimationFrame(() => {
+      if (this._isOpen) {
+        this.collapsibleContent.focus({ preventScroll: true });
+      }
+    });
+  }
 }
diff --git a/src/page/color-reaction-matrix-control.ts b/src/page/color-reaction-matrix-control.ts
new file mode 100644
index 0000000..ca78cbb
--- /dev/null
+++ b/src/page/color-reaction-matrix-control.ts
@@ -0,0 +1,187 @@
+import type { FolderApi } from '@tweakpane/core';
+
+import { appConfig, normalizeNumberControlValue } from '../config';
+import { activeVibe, settings } from '../settings';
+import { rgbColorToCss } from '../utils/rgb-color';
+
+type PaneContainer = Pick;
+type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
+
+const COLOR_REACTION_LABELS = ['Color 1', 'Color 2', 'Color 3'] as const;
+const COLOR_REACTION_STATES = [
+  { id: 'follow', label: 'Move Toward', value: 1 },
+  { id: 'ignore', label: 'Ignore', value: 0 },
+  { id: 'avoid', label: 'Move Away', value: -1 },
+] as const;
+
+const colorReactionRows = [
+  {
+    colorIndex: 0,
+    label: COLOR_REACTION_LABELS[0],
+    keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
+  },
+  {
+    colorIndex: 1,
+    label: COLOR_REACTION_LABELS[1],
+    keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
+  },
+  {
+    colorIndex: 2,
+    label: COLOR_REACTION_LABELS[2],
+    keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
+  },
+] as const;
+
+const getColorReactionStateIndex = (value: number): number =>
+  COLOR_REACTION_STATES.findIndex((state) => state.value === value);
+
+const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] =>
+  COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1];
+
+const getNextColorReactionState = (
+  value: number
+): (typeof COLOR_REACTION_STATES)[number] => {
+  const index = getColorReactionStateIndex(value);
+  return COLOR_REACTION_STATES[
+    ((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length
+  ];
+};
+
+export class ColorReactionMatrixControl {
+  private readonly buttons = new Map<
+    ColorReactionKey,
+    {
+      element: HTMLButtonElement;
+      sourceColorIndex: number;
+      targetColorIndex: number;
+    }
+  >();
+  private readonly swatches: Array<{
+    colorIndex: number;
+    element: HTMLElement;
+  }> = [];
+
+  public constructor(private readonly onRuntimeChange: () => void) {}
+
+  public addTo(container: PaneContainer): void {
+    const folder = container.addFolder({
+      title: 'Color Behavior',
+      expanded: true,
+    });
+
+    const matrix = document.createElement('div');
+    matrix.className = 'color-reaction-matrix';
+
+    matrix.appendChild(this.createCorner());
+    colorReactionRows.forEach((row) => {
+      matrix.appendChild(this.createHeader(row.colorIndex, row.label));
+    });
+
+    colorReactionRows.forEach((row) => {
+      matrix.appendChild(this.createHeader(row.colorIndex, row.label));
+      row.keys.forEach((key, columnIndex) => {
+        matrix.appendChild(this.createCell(key, row.colorIndex, columnIndex));
+      });
+    });
+
+    const matrixBlade = folder.addBlade({ view: 'separator' });
+    matrixBlade.element.classList.add('color-reaction-matrix-blade');
+    matrixBlade.element.replaceChildren(matrix);
+    this.sync();
+  }
+
+  public sync(): void {
+    this.buttons.forEach(({ element, sourceColorIndex, targetColorIndex }, key) => {
+      this.syncButton(element, key, sourceColorIndex, targetColorIndex);
+    });
+
+    this.swatches.forEach(({ colorIndex, element }) => {
+      element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
+    });
+  }
+
+  private createCorner(): HTMLDivElement {
+    const corner = document.createElement('div');
+    corner.className = 'color-reaction-matrix__corner';
+    return corner;
+  }
+
+  private createHeader(colorIndex: number, label: string): HTMLDivElement {
+    const header = document.createElement('div');
+    header.className = 'color-reaction-matrix__header';
+    header.setAttribute('aria-label', label);
+    header.title = label;
+
+    const swatch = document.createElement('span');
+    swatch.className = 'color-reaction-matrix__swatch';
+    this.swatches.push({ colorIndex, element: swatch });
+    header.appendChild(swatch);
+
+    return header;
+  }
+
+  private createCell(
+    key: ColorReactionKey,
+    sourceColorIndex: number,
+    targetColorIndex: number
+  ): HTMLDivElement {
+    const cell = document.createElement('div');
+    cell.className = 'color-reaction-matrix__cell';
+
+    const config = appConfig.runtimeSettings.controls[key];
+    if (!config) {
+      return cell;
+    }
+
+    const button = document.createElement('button');
+    button.className = 'color-reaction-matrix__button';
+    button.type = 'button';
+
+    const icon = document.createElement('span');
+    icon.className = 'color-reaction-matrix__icon';
+    button.appendChild(icon);
+
+    button.addEventListener('click', () => {
+      const currentValue = normalizeNumberControlValue(settings[key], config);
+      const nextState = getNextColorReactionState(currentValue);
+      settings[key] = nextState.value;
+      this.syncButton(button, key, sourceColorIndex, targetColorIndex);
+      this.onRuntimeChange();
+    });
+
+    this.buttons.set(key, {
+      element: button,
+      sourceColorIndex,
+      targetColorIndex,
+    });
+    cell.appendChild(button);
+
+    return cell;
+  }
+
+  private syncButton(
+    button: HTMLButtonElement,
+    key: ColorReactionKey,
+    sourceColorIndex: number,
+    targetColorIndex: number
+  ): void {
+    const config = appConfig.runtimeSettings.controls[key];
+    if (!config) {
+      return;
+    }
+
+    settings[key] = normalizeNumberControlValue(settings[key], config);
+
+    const state = getColorReactionState(settings[key]);
+    const nextState = getNextColorReactionState(settings[key]);
+    const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex];
+    const targetLabel = COLOR_REACTION_LABELS[targetColorIndex];
+
+    button.dataset.reaction = state.id;
+    button.setAttribute(
+      'aria-label',
+      `${sourceLabel} ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
+    );
+    button.title = `${sourceLabel}: ${state.label} ${targetLabel} trails`;
+  }
+}
diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts
new file mode 100644
index 0000000..3e6c397
--- /dev/null
+++ b/src/page/config-pane.ts
@@ -0,0 +1,365 @@
+import type { BindingParams, FolderApi } from '@tweakpane/core';
+import { Pane } from 'tweakpane';
+
+import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
+import {
+  appConfig,
+  normalizeNumberControlValue,
+  type GardenRuntimeSettings,
+  type NumberControlConfig,
+} from '../config';
+import { activeVibe, settings } from '../settings';
+import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
+import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
+
+type PaneContainer = Pick;
+type RuntimeControlKey = keyof GardenRuntimeSettings & string;
+type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
+type NumberPropertyKey = {
+  [Key in keyof T]-?: T[Key] extends number ? Key : never;
+}[keyof T] &
+  string;
+type VibeNumberKey = NumberPropertyKey;
+
+interface PaneState extends GardenAudioVibeSettings {
+  backgroundColor: string;
+  color1: string;
+  color2: string;
+  color3: string;
+}
+
+const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
+
+const MUSIC_CONTROLS: ReadonlyArray<{
+  key: VibeNumberKey;
+  label: string;
+  min: number;
+  max: number;
+  step: number;
+}> = [
+  { key: 'idleIntensity', label: 'Ambient Notes', min: 0, max: 1, step: 0.01 },
+  { key: 'bpm', label: 'Tempo', min: 48, max: 150, step: 1 },
+  { key: 'rampUpIntensity', label: 'Touch Energy', min: 0, max: 2, step: 0.01 },
+  { key: 'rampUpTime', label: 'Response Time', min: 0.01, max: 0.4, step: 0.01 },
+  { key: 'noteLength', label: 'Note Length', min: 0.1, max: 1.8, step: 0.01 },
+  { key: 'notePitchOffset', label: 'Pitch Shift', min: -12, max: 12, step: 1 },
+  { key: 'brightness', label: 'Tone Brightness', min: 0.5, max: 1.5, step: 0.01 },
+];
+
+interface ConfigPaneOptions {
+  maxSupportedAgentCount: number;
+  onConfigChange: () => void;
+  onOpen?: () => void;
+  onRuntimeChange: () => void;
+  settingsButton: HTMLButtonElement;
+}
+
+const getRuntimeControlKeys = (folder: string): Array =>
+  (
+    Object.entries(appConfig.runtimeSettings.controls) as Array<
+      [RuntimeControlKey, NumberControlConfig | undefined]
+    >
+  )
+    .filter(([, config]) => config?.folder === folder)
+    .map(([key]) => key);
+
+const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
+  const params: BindingParams = {
+    label: config.label,
+    options: config.options,
+    step: config.step,
+  };
+  if (config.format !== undefined) {
+    params.format = config.format;
+  }
+  if (config.min !== undefined) {
+    params.min = config.min;
+  }
+  if (config.max !== undefined) {
+    params.max = config.max;
+  }
+  return params;
+};
+
+export class ConfigPane {
+  private readonly container: HTMLDivElement;
+  private readonly closeButton: HTMLButtonElement;
+  private readonly colorReactionMatrix: ColorReactionMatrixControl;
+  private readonly pane: Pane;
+  private readonly state: PaneState = {
+    backgroundColor: rgbColorToHex(activeVibe.backgroundColor),
+    color1: rgbColorToHex(activeVibe.colors[0]),
+    color2: rgbColorToHex(activeVibe.colors[1]),
+    color3: rgbColorToHex(activeVibe.colors[2]),
+    ...activeVibe.audio,
+  };
+
+  public constructor(private readonly options: ConfigPaneOptions) {
+    this.colorReactionMatrix = new ColorReactionMatrixControl(
+      this.options.onRuntimeChange
+    );
+    this.container = document.createElement('div');
+    this.container.className = 'config-pane-container';
+
+    this.closeButton = document.createElement('button');
+    this.closeButton.className = 'config-pane-close';
+    this.closeButton.type = 'button';
+    this.closeButton.setAttribute('aria-label', 'Hide config overlay');
+    this.closeButton.title = 'Hide config overlay';
+    this.closeButton.addEventListener('click', () => this.setHidden(true));
+    this.container.appendChild(this.closeButton);
+
+    document.body.appendChild(this.container);
+
+    this.pane = new Pane({
+      container: this.container,
+      title: appConfig.tuningPane.title,
+      expanded: true,
+    });
+    this.pane.hidden = appConfig.tuningPane.startHidden;
+    this.pane.element.classList.add('config-pane');
+    this.pane.element.id = 'config-pane';
+
+    this.options.settingsButton.setAttribute('aria-controls', this.pane.element.id);
+    this.options.settingsButton.addEventListener('click', this.toggle);
+    document.addEventListener('pointerdown', this.dismissOnOutsidePointerDown, {
+      passive: true,
+    });
+    document.addEventListener('keydown', this.dismissOnEscape);
+
+    this.setUpTuningPane(this.pane);
+    this.syncOpenState();
+  }
+
+  public get isOpen(): boolean {
+    return !this.pane.hidden;
+  }
+
+  public refresh(): void {
+    this.syncVibeState();
+    this.pane.refresh();
+    this.colorReactionMatrix.sync();
+    this.syncOpenState();
+  }
+
+  public close(): void {
+    this.setHidden(true);
+  }
+
+  private readonly toggle = () => {
+    this.setHidden(!this.pane.hidden);
+  };
+
+  private readonly dismissOnOutsidePointerDown = (event: PointerEvent) => {
+    if (!this.isOpen || !(event.target instanceof Node)) {
+      return;
+    }
+
+    if (
+      this.container.contains(event.target) ||
+      this.options.settingsButton.contains(event.target)
+    ) {
+      return;
+    }
+
+    this.setHidden(true);
+  };
+
+  private readonly dismissOnEscape = (event: KeyboardEvent) => {
+    if (event.key === 'Escape' && this.isOpen) {
+      this.setHidden(true);
+    }
+  };
+
+  private setHidden(isHidden: boolean): void {
+    const wasOpen = this.isOpen;
+    this.pane.hidden = isHidden;
+    this.syncOpenState();
+    if (!wasOpen && this.isOpen) {
+      this.options.onOpen?.();
+    }
+  }
+
+  private setUpTuningPane(container: PaneContainer): void {
+    this.setUpVibeSection(container);
+    this.addRuntimeSection(container, runtimeFolderOrder[0], true);
+    this.addRuntimeSection(container, runtimeFolderOrder[1], true);
+    this.colorReactionMatrix.addTo(container);
+    this.addRuntimeSection(container, runtimeFolderOrder[2], true);
+    const performanceFolder = this.addRuntimeSection(
+      container,
+      runtimeFolderOrder[3],
+      true
+    );
+    this.addFpsOverlayBinding(performanceFolder);
+    this.setUpMusicSection(container);
+    this.colorReactionMatrix.sync();
+  }
+
+  private setUpVibeSection(container: PaneContainer): void {
+    const folder = container.addFolder({
+      title: 'Colors',
+      expanded: true,
+    });
+
+    this.addColorBinding(folder, 'color1', '', (color) => {
+      activeVibe.colors[0] = color;
+    });
+    this.addColorBinding(folder, 'color2', '', (color) => {
+      activeVibe.colors[1] = color;
+    });
+    this.addColorBinding(folder, 'color3', '', (color) => {
+      activeVibe.colors[2] = color;
+    });
+    this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => {
+      activeVibe.backgroundColor = color;
+    });
+
+    if (import.meta.env.DEV) {
+      folder
+        .addButton({ title: 'Copy Vibe Preset' })
+        .on('click', () => void this.copyVibePresetToClipboard());
+    }
+  }
+
+  private addColorBinding(
+    container: PaneContainer,
+    key: VibeColorKey,
+    label: string,
+    updateColor: (color: RgbColor) => void
+  ): void {
+    container
+      .addBinding(this.state, key, {
+        label,
+        view: 'color',
+      } as BindingParams)
+      .on('change', ({ value }) => {
+        const color = hexColorToRgbColor(String(value));
+        if (!color) {
+          this.syncVibeState();
+          this.pane.refresh();
+          return;
+        }
+
+        updateColor(color);
+        this.colorReactionMatrix.sync();
+        this.options.onConfigChange();
+      });
+  }
+
+  private addRuntimeSection(
+    container: PaneContainer,
+    title: string,
+    expanded: boolean
+  ): PaneContainer {
+    const folder = container.addFolder({ title, expanded });
+    getRuntimeControlKeys(title).forEach((key) => this.addRuntimeBinding(folder, key));
+    return folder;
+  }
+
+  private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void {
+    const config = this.getRuntimeControlConfig(key);
+    if (!config) {
+      return;
+    }
+
+    settings[key] = normalizeNumberControlValue(settings[key], config);
+
+    container
+      .addBinding(settings, key, getNumberBindingParams(config))
+      .on('change', () => {
+        const nextValue = normalizeNumberControlValue(settings[key], config);
+        if (nextValue !== settings[key]) {
+          settings[key] = nextValue;
+          this.pane.refresh();
+        }
+        this.options.onRuntimeChange();
+      });
+  }
+
+  private getRuntimeControlConfig(
+    key: RuntimeControlKey
+  ): NumberControlConfig | undefined {
+    const config = appConfig.runtimeSettings.controls[key];
+    if (!config || key !== 'maxAgentCount') {
+      return config;
+    }
+
+    return {
+      ...config,
+      max: Math.max(config.min ?? 0, Math.floor(this.options.maxSupportedAgentCount)),
+    };
+  }
+
+  private addFpsOverlayBinding(container: PaneContainer): void {
+    container
+      .addBinding(appConfig.tuningPane, 'showFpsOverlay', {
+        label: 'Show FPS',
+      })
+      .on('change', () => this.options.onConfigChange());
+  }
+
+  private setUpMusicSection(container: PaneContainer): void {
+    const folder = container.addFolder({ title: 'Music', expanded: true });
+    MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
+      this.addVibeNumberBinding(folder, key, { folder: 'Music', label, min, max, step });
+    });
+  }
+
+  private addVibeNumberBinding(
+    container: PaneContainer,
+    key: VibeNumberKey,
+    config: NumberControlConfig
+  ): void {
+    this.state[key] = normalizeNumberControlValue(this.state[key], config);
+
+    container
+      .addBinding(this.state, key, getNumberBindingParams(config))
+      .on('change', () => {
+        const nextValue = normalizeNumberControlValue(this.state[key], config);
+        if (nextValue !== this.state[key]) {
+          this.state[key] = nextValue;
+          this.pane.refresh();
+        }
+        activeVibe.audio[key] = nextValue;
+        this.options.onConfigChange();
+      });
+  }
+
+  private async copyVibePresetToClipboard(): Promise {
+    const settingKeys = Object.keys(activeVibe.settings) as Array<
+      keyof typeof activeVibe.settings
+    >;
+    const preset = {
+      name: `${activeVibe.name} Copy`,
+      colors: activeVibe.colors,
+      backgroundColor: activeVibe.backgroundColor,
+      settings: Object.fromEntries(settingKeys.map((key) => [key, settings[key]])),
+      audio: activeVibe.audio,
+    };
+    try {
+      await navigator.clipboard.writeText(JSON.stringify(preset, null, 2));
+    } catch (error) {
+      console.warn('Could not copy vibe preset to clipboard.', error);
+    }
+  }
+
+  private syncVibeState(): void {
+    this.state.color1 = rgbColorToHex(activeVibe.colors[0]);
+    this.state.color2 = rgbColorToHex(activeVibe.colors[1]);
+    this.state.color3 = rgbColorToHex(activeVibe.colors[2]);
+    this.state.backgroundColor = rgbColorToHex(activeVibe.backgroundColor);
+    Object.assign(this.state, activeVibe.audio);
+  }
+
+  private syncOpenState(): void {
+    const { settingsButton } = this.options;
+    const label = this.isOpen ? 'Hide config overlay' : 'Show config overlay';
+    settingsButton.classList.toggle('active', this.isOpen);
+    settingsButton.setAttribute('aria-expanded', String(this.isOpen));
+    settingsButton.setAttribute('aria-label', label);
+    settingsButton.title = label;
+    this.container.classList.toggle('config-pane-container--open', this.isOpen);
+    this.closeButton.hidden = !this.isOpen;
+  }
+}
diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts
new file mode 100644
index 0000000..1099821
--- /dev/null
+++ b/src/page/eraser-size-control.ts
@@ -0,0 +1,84 @@
+import { appConfig } from '../config';
+import type GameLoop from '../game-loop/game-loop';
+import { settings } from '../settings';
+import { queryRequiredElement } from '../utils/dom';
+
+const clampEraserSize = (value: number): number => {
+  const { default: defaultSize, max, min } = appConfig.toolbar.eraser;
+  const safeValue = Number.isFinite(value) ? value : defaultSize;
+  return Math.min(max, Math.max(min, Math.round(safeValue)));
+};
+
+const getEraserSizeRatio = (size: number): number => {
+  const { max, min } = appConfig.toolbar.eraser;
+  return (size - min) / (max - min);
+};
+
+interface EraserSizeControlOptions {
+  getGame: () => GameLoop | null;
+  onActivate: () => void;
+  onChange: () => void;
+}
+
+export class EraserSizeControl {
+  private readonly control = queryRequiredElement(
+    '.eraser-size-control',
+    HTMLLabelElement
+  );
+  private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
+  private isActive = false;
+
+  public constructor(private readonly options: EraserSizeControlOptions) {
+    this.control.addEventListener('pointerdown', this.activate);
+    this.control.addEventListener('click', this.activate);
+    this.slider.addEventListener('focus', this.activate);
+    this.slider.addEventListener('input', () => {
+      settings.eraserSize = clampEraserSize(Number(this.slider.value));
+      this.activate();
+      this.render();
+      this.options.onChange();
+    });
+  }
+
+  public render(): void {
+    const size = clampEraserSize(settings.eraserSize);
+    if (settings.eraserSize !== size) {
+      settings.eraserSize = size;
+    }
+
+    this.slider.min = appConfig.toolbar.eraser.min.toString();
+    this.slider.max = appConfig.toolbar.eraser.max.toString();
+    this.slider.step = appConfig.toolbar.eraser.step.toString();
+    this.slider.value = size.toString();
+    this.slider.setAttribute('aria-valuetext', `${size}px`);
+
+    const ratio = getEraserSizeRatio(size);
+    const scale =
+      appConfig.toolbar.eraser.controlScaleMin +
+      (appConfig.toolbar.eraser.controlScaleMax -
+        appConfig.toolbar.eraser.controlScaleMin) *
+        ratio;
+    this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
+    this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
+    this.syncActiveState();
+    this.options.getGame()?.updateEraserPreview();
+  }
+
+  public setActive(isActive: boolean): void {
+    this.isActive = isActive;
+    this.syncActiveState();
+  }
+
+  private readonly activate = () => {
+    this.setActive(true);
+    this.options.onActivate();
+  };
+
+  private syncActiveState(): void {
+    this.control.classList.toggle('active', this.isActive);
+    this.slider.setAttribute(
+      'aria-label',
+      this.isActive ? 'Eraser size, active' : 'Eraser size'
+    );
+  }
+}
diff --git a/src/page/error-presenter.ts b/src/page/error-presenter.ts
new file mode 100644
index 0000000..835c902
--- /dev/null
+++ b/src/page/error-presenter.ts
@@ -0,0 +1,62 @@
+import {
+  ErrorHandler,
+  getErrorMessage,
+  RuntimeError,
+  Severity,
+} from '../utils/error-handler';
+
+type RuntimeUiError = Parameters<
+  Parameters[0]
+>[0];
+
+const ERROR_CONTAINER_SELECTOR = '.errors-container';
+const ERROR_CONTAINER_CLASS = 'errors-container';
+
+const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError): void => {
+  const message = document.createElement('pre');
+  message.className = error.severity;
+  message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
+  message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status');
+  message.setAttribute(
+    'aria-live',
+    error.severity === Severity.ERROR ? 'assertive' : 'polite'
+  );
+  container.append(message);
+
+  if (error.severity === Severity.ERROR) {
+    message.tabIndex = -1;
+    message.focus({ preventScroll: true });
+  }
+};
+
+const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({
+  severity: Severity.ERROR,
+  message: getErrorMessage(exception),
+  ...(exception instanceof RuntimeError ? { code: exception.code } : {}),
+});
+
+export class ErrorPresenter {
+  public constructor(private readonly container: HTMLElement) {
+    container.setAttribute('aria-live', 'assertive');
+  }
+
+  public render(error: RuntimeUiError): void {
+    renderRuntimeMessage(this.container, error);
+  }
+
+  public static renderStartup(exception: unknown): void {
+    const existingContainer = document.querySelector(ERROR_CONTAINER_SELECTOR);
+    const container =
+      existingContainer instanceof HTMLElement
+        ? existingContainer
+        : document.createElement('div');
+
+    if (!(existingContainer instanceof HTMLElement)) {
+      container.className = ERROR_CONTAINER_CLASS;
+      document.body.append(container);
+    }
+
+    container.setAttribute('aria-live', 'assertive');
+    renderRuntimeMessage(container, getRuntimeUiError(exception));
+  }
+}
diff --git a/src/page/full-screen-handler.ts b/src/page/full-screen-handler.ts
index 84c9150..498c08c 100644
--- a/src/page/full-screen-handler.ts
+++ b/src/page/full-screen-handler.ts
@@ -1,43 +1,46 @@
 export class FullScreenHandler {
+  private readonly abortController = new AbortController();
+
   public constructor(
-    private readonly minimizeButton: HTMLElement,
-    private readonly maximizeButton: HTMLElement,
+    private readonly toggleButton: HTMLElement,
     target: HTMLElement
   ) {
-    if (!document.fullscreenEnabled) {
-      minimizeButton.style.display = 'none';
-      maximizeButton.style.display = 'none';
+    if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
+      toggleButton.hidden = true;
       return;
     }
 
     this.updateButtons();
 
-    addEventListener('keydown', (e) => {
-      // on full screen request, only apply it to the target
-      if (e.key === 'F11') {
-        e.preventDefault();
+    const { signal } = this.abortController;
+    addEventListener('fullscreenchange', this.updateButtons, { signal });
+    toggleButton.addEventListener(
+      'click',
+      () => {
         if (FullScreenHandler.isInFullScreenMode()) {
-          document.exitFullscreen();
-        } else {
-          target.requestFullscreen();
+          void document.exitFullscreen();
+          return;
         }
-      }
-    });
-    addEventListener('fullscreenchange', this.updateButtons.bind(this));
-    maximizeButton.addEventListener('click', () => target.requestFullscreen());
-    minimizeButton.addEventListener('click', () => document.exitFullscreen());
+
+        void target.requestFullscreen().catch(() => undefined);
+      },
+      { signal }
+    );
   }
 
   public static isInFullScreenMode(): boolean {
     return document.fullscreenElement !== null;
   }
 
-  private updateButtons() {
-    this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode()
-      ? 'block'
-      : 'none';
-    this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
-      ? 'none'
-      : 'block';
+  public destroy(): void {
+    this.abortController.abort();
   }
+
+  private readonly updateButtons = (): void => {
+    const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
+    const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen';
+    this.toggleButton.classList.toggle('active', isInFullScreenMode);
+    this.toggleButton.setAttribute('aria-label', label);
+    this.toggleButton.title = label;
+  };
 }
diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts
index 173a5b6..ed8aa59 100644
--- a/src/page/menu-hider.ts
+++ b/src/page/menu-hider.ts
@@ -1,17 +1,139 @@
+import { appConfig } from '../config';
+
 export class MenuHider {
-  private static readonly DEFAULT_TIME_TO_LIVE = 3500;
-  private static readonly INTERVAL = 50;
-  private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
+  private readonly desktopMediaQuery = window.matchMedia(
+    appConfig.menuHider.desktopMediaQuery
+  );
+  private hideTimeout: number | undefined;
+  private isHidden = false;
+  private pointerInside = false;
 
-  public constructor(element: HTMLElement, shouldBeHidden: () => boolean) {
-    setInterval(() => {
-      this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
-      element.style.opacity = this.timeToLive == 0 && shouldBeHidden() ? '0' : '1';
-    }, MenuHider.INTERVAL);
+  public constructor(
+    private readonly element: HTMLElement,
+    private readonly shouldBeHidden: () => boolean
+  ) {
+    element.addEventListener('pointerenter', this.onPointerEnter);
+    element.addEventListener('pointerleave', this.onPointerLeave);
+    element.addEventListener('focusin', this.onFocusIn);
+    element.addEventListener('focusout', this.onFocusOut);
+    window.addEventListener('pointermove', this.onPointerMove, { passive: true });
+    document.addEventListener('fullscreenchange', this.onVisibilityContextChange);
+    this.desktopMediaQuery.addEventListener('change', this.onVisibilityContextChange);
 
-    element.addEventListener(
-      'mouseover',
-      () => (this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE)
+    this.reveal();
+  }
+
+  private get canAutoHide(): boolean {
+    return (
+      this.desktopMediaQuery.matches &&
+      this.shouldBeHidden() &&
+      !this.pointerInside &&
+      !this.element.contains(document.activeElement)
     );
   }
+
+  private readonly onPointerEnter = () => {
+    this.pointerInside = true;
+    this.reveal();
+  };
+
+  private readonly onPointerLeave = () => {
+    this.pointerInside = false;
+    this.scheduleHide();
+  };
+
+  private readonly onFocusIn = () => {
+    this.reveal();
+  };
+
+  private readonly onFocusOut = () => {
+    window.setTimeout(() => this.scheduleHide(), 0);
+  };
+
+  private readonly onPointerMove = (event: PointerEvent) => {
+    if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) {
+      this.reveal();
+      return;
+    }
+
+    if (this.isPointerOverDock(event.clientX, event.clientY)) {
+      this.pointerInside = true;
+      this.reveal();
+      return;
+    }
+
+    this.pointerInside = false;
+
+    if (this.isHidden) {
+      if (this.isNearViewportBottom(event.clientY)) {
+        this.reveal();
+        this.scheduleHide();
+      }
+      return;
+    }
+
+    this.scheduleHide();
+  };
+
+  private readonly onVisibilityContextChange = () => {
+    this.scheduleHide();
+  };
+
+  private scheduleHide(): void {
+    if (!this.canAutoHide) {
+      this.clearHideTimeout();
+      this.reveal();
+      return;
+    }
+
+    if (this.hideTimeout !== undefined) {
+      return;
+    }
+
+    this.hideTimeout = window.setTimeout(() => {
+      this.hideTimeout = undefined;
+      if (this.canAutoHide) {
+        this.hide();
+      }
+    }, appConfig.menuHider.hideDelayMs);
+  }
+
+  private reveal(): void {
+    if (!this.isHidden && this.hideTimeout === undefined) {
+      return;
+    }
+
+    this.clearHideTimeout();
+    this.isHidden = false;
+    this.element.classList.remove('menu-hidden');
+  }
+
+  private hide(): void {
+    this.isHidden = true;
+    this.element.classList.add('menu-hidden');
+  }
+
+  private clearHideTimeout(): void {
+    if (this.hideTimeout === undefined) {
+      return;
+    }
+
+    window.clearTimeout(this.hideTimeout);
+    this.hideTimeout = undefined;
+  }
+
+  private isPointerOverDock(clientX: number, clientY: number): boolean {
+    const rect = this.element.getBoundingClientRect();
+    return (
+      clientX >= rect.left &&
+      clientX <= rect.right &&
+      clientY >= rect.top &&
+      clientY <= rect.bottom
+    );
+  }
+
+  private isNearViewportBottom(clientY: number): boolean {
+    const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
+    return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx;
+  }
 }
diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts
new file mode 100644
index 0000000..1ea8ddc
--- /dev/null
+++ b/src/page/mirror-segment-control.ts
@@ -0,0 +1,66 @@
+import { appConfig } from '../config';
+import { settings } from '../settings';
+import { queryRequiredElement } from '../utils/dom';
+
+const clampMirrorSegmentCount = (value: number): number => {
+  const { default: defaultCount, max, min } = appConfig.toolbar.mirror;
+  const safeValue = Number.isFinite(value) ? value : defaultCount;
+  return Math.min(max, Math.max(min, Math.round(safeValue)));
+};
+
+const getMirrorSegmentRatio = (count: number): number => {
+  const { max, min } = appConfig.toolbar.mirror;
+  return (count - min) / (max - min);
+};
+
+const formatMirrorSegmentCount = (count: number): string =>
+  count <= 1
+    ? appConfig.toolbar.mirror.offLabel
+    : `${count} ${
+        appConfig.toolbar.mirror.names[
+          count as keyof typeof appConfig.toolbar.mirror.names
+        ] ?? appConfig.toolbar.mirror.fallbackSegmentName
+      }`;
+
+interface MirrorSegmentControlOptions {
+  onChange: () => void;
+}
+
+export class MirrorSegmentControl {
+  private readonly control = queryRequiredElement(
+    '.mirror-segment-control',
+    HTMLLabelElement
+  );
+  private readonly slider = queryRequiredElement(
+    '.mirror-segment-slider',
+    HTMLInputElement
+  );
+
+  public constructor(private readonly options: MirrorSegmentControlOptions) {
+    this.slider.addEventListener('input', () => {
+      settings.mirrorSegmentCount = clampMirrorSegmentCount(Number(this.slider.value));
+      this.render();
+      this.options.onChange();
+    });
+  }
+
+  public render(): void {
+    const count = clampMirrorSegmentCount(settings.mirrorSegmentCount);
+    if (settings.mirrorSegmentCount !== count) {
+      settings.mirrorSegmentCount = count;
+    }
+
+    this.slider.min = appConfig.toolbar.mirror.min.toString();
+    this.slider.max = appConfig.toolbar.mirror.max.toString();
+    this.slider.step = appConfig.toolbar.mirror.step.toString();
+    this.slider.value = count.toString();
+
+    const label = formatMirrorSegmentCount(count);
+    const ratio = getMirrorSegmentRatio(count);
+    this.slider.setAttribute('aria-valuetext', label);
+    this.control.title = label;
+    this.control.classList.toggle('active', count > 1);
+    this.control.style.setProperty('--mirror-progress', `${ratio * 100}%`);
+    this.control.style.setProperty('--mirror-angle', `${(360 / count).toFixed(3)}deg`);
+  }
+}
diff --git a/src/page/palette-control.ts b/src/page/palette-control.ts
new file mode 100644
index 0000000..9007d8f
--- /dev/null
+++ b/src/page/palette-control.ts
@@ -0,0 +1,76 @@
+import type GameLoop from '../game-loop/game-loop';
+import { activeVibe, settings } from '../settings';
+import { ErrorCode, RuntimeError } from '../utils/error-handler';
+import { rgbColorToCss } from '../utils/rgb-color';
+
+interface PaletteControlOptions {
+  getGame: () => GameLoop | null;
+  onChange: () => void;
+  onModeChange?: (isEraserActive: boolean) => void;
+}
+
+export class PaletteControl {
+  private readonly swatches = queryRequiredColorSwatches();
+  private isEraserActiveState = false;
+
+  public constructor(private readonly options: PaletteControlOptions) {
+    this.swatches.forEach((swatch, index) => {
+      swatch.addEventListener('click', () => {
+        settings.selectedColorIndex = index;
+        this.isEraserActiveState = false;
+        this.render();
+        this.options.onModeChange?.(false);
+        this.options.onChange();
+      });
+    });
+  }
+
+  public get isEraserActive(): boolean {
+    return this.isEraserActiveState;
+  }
+
+  public setEraserActive(active: boolean): void {
+    this.isEraserActiveState = active;
+    this.render();
+    this.options.onModeChange?.(active);
+  }
+
+  public render(): void {
+    this.swatches.forEach((swatch, index) => {
+      swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
+      const isActive = settings.selectedColorIndex === index && !this.isEraserActiveState;
+      swatch.classList.toggle('active', isActive);
+      swatch.setAttribute('aria-pressed', String(isActive));
+    });
+    this.options.getGame()?.setEraseMode(this.isEraserActiveState);
+    document.documentElement.style.setProperty(
+      '--garden-background',
+      rgbColorToCss(activeVibe.backgroundColor)
+    );
+  }
+}
+
+const queryRequiredColorSwatches = (): Array => {
+  const selector = '.color-swatch';
+  const swatches = Array.from(document.querySelectorAll(selector));
+  const expectedCount = activeVibe.colors.length;
+  const hasExpectedSwatches =
+    swatches.length === expectedCount &&
+    swatches.every((swatch) => swatch instanceof HTMLButtonElement);
+
+  if (!hasExpectedSwatches) {
+    throw new RuntimeError(
+      ErrorCode.DOM_ELEMENT_MISSING,
+      `Expected ${expectedCount} color swatches.`,
+      {
+        details: {
+          actualCount: swatches.length,
+          expectedCount,
+          selector,
+        },
+      }
+    );
+  }
+
+  return swatches as Array;
+};
diff --git a/src/page/set-up-settings-page.ts b/src/page/set-up-settings-page.ts
deleted file mode 100644
index 238d704..0000000
--- a/src/page/set-up-settings-page.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { isProduction } from '../constants';
-import { settings } from '../settings';
-import { SettingsSlider, ValueScaling } from './settings-slider';
-
-export const setUpSettingsPage = (
-  settingsPage: HTMLDivElement,
-  maxAgentCount: number
-): Array> => {
-  const sliders: Array> = [
-    ...(isProduction
-      ? []
-      : [
-          new SettingsSlider(settings, 'renderSpeed', {
-            min: 1,
-            max: 10,
-            rounding: Math.round,
-          }),
-        ]),
-
-    new SettingsSlider(settings, 'agentCount', {
-      min: 1,
-      max: maxAgentCount,
-      scaling: ValueScaling.Quadratic,
-      rounding: Math.round,
-    }),
-
-    new SettingsSlider(settings, 'currentGenerationAggression', {
-      min: -5,
-      max: 5,
-    }),
-
-    new SettingsSlider(settings, 'nextGenerationAggression', {
-      min: -5,
-      max: 5,
-    }),
-
-    new SettingsSlider(settings, 'moveSpeed', {
-      min: 10,
-      max: 500,
-      scaling: ValueScaling.Quadratic,
-      rounding: Math.round,
-    }),
-
-    new SettingsSlider(settings, 'turnSpeed', {
-      min: 1,
-      max: 200,
-      scaling: ValueScaling.Quadratic,
-      rounding: Math.round,
-    }),
-
-    new SettingsSlider(settings, 'sensorOffsetAngle', {
-      min: 0,
-      max: 90,
-      step: 1,
-    }),
-
-    new SettingsSlider(settings, 'sensorOffsetDistance', {
-      min: 0,
-      max: 200,
-      scaling: ValueScaling.Quadratic,
-      rounding: Math.round,
-    }),
-
-    new SettingsSlider(settings, 'turnWhenLost', {
-      min: 0,
-      max: 1,
-    }),
-
-    new SettingsSlider(settings, 'individualTrailWeight', {
-      min: 0,
-      max: 1,
-    }),
-
-    new SettingsSlider(settings, 'diffusionRateTrails', {
-      min: 0,
-      max: 2,
-    }),
-
-    new SettingsSlider(settings, 'decayRateTrails', {
-      min: 0.1,
-      max: 5000,
-      scaling: ValueScaling.Quadratic,
-    }),
-
-    new SettingsSlider(settings, 'diffusionRateBrush', {
-      min: 0.001,
-      max: 1,
-    }),
-
-    new SettingsSlider(settings, 'decayRateBrush', {
-      min: 0.1,
-      max: 100,
-    }),
-
-    new SettingsSlider(settings, 'brushSize', {
-      min: 1,
-      max: 30,
-    }),
-
-    new SettingsSlider(settings, 'clarity', {
-      min: 0.00001,
-      max: 1,
-    }),
-  ];
-
-  const sliderContainerElement = document.createElement('div');
-
-  sliders.forEach((slider) => {
-    sliderContainerElement.appendChild(slider.element);
-  });
-
-  settingsPage.appendChild(sliderContainerElement);
-
-  return sliders;
-};
diff --git a/src/page/settings-slider.ts b/src/page/settings-slider.ts
deleted file mode 100644
index d7ad26b..0000000
--- a/src/page/settings-slider.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import { formatNumber } from '../utils/format-number';
-
-export enum ValueScaling {
-  Linear,
-  Quadratic,
-  Logarithmic,
-}
-
-export interface SliderConfiguration {
-  min: number;
-  max: number;
-  unit?: string;
-  step?: number;
-  onChangeCallback?: (value: number) => unknown;
-  scaling: ValueScaling;
-  rounding: (value: number) => number;
-}
-
-export class SettingsSlider> {
-  private static readonly DEFAULT_STEP_COUNT = 20000;
-
-  private readonly slider: HTMLInputElement;
-  private readonly valueDisplay: HTMLSpanElement;
-  private readonly sliderWrapper: HTMLDivElement;
-  private readonly config: SliderConfiguration = {
-    min: 0,
-    max: 1,
-    scaling: ValueScaling.Linear,
-    rounding: (value) => value,
-  };
-
-  public constructor(
-    private readonly settings: T,
-    private readonly settingName: keyof T & string,
-    config: Partial = {}
-  ) {
-    this.slider = SettingsSlider.createSlider();
-    this.valueDisplay = SettingsSlider.createValueDisplay();
-    this.sliderWrapper = SettingsSlider.createSliderWrapper(
-      this.settingName,
-      this.slider,
-      this.valueDisplay
-    );
-
-    this.slider.addEventListener('input', this.onChange.bind(this));
-
-    this.updateConfig(config);
-  }
-
-  private static createSlider() {
-    const input = document.createElement('input');
-    input.type = 'range';
-    return input;
-  }
-
-  private static createValueDisplay() {
-    return document.createElement('span');
-  }
-
-  private static createSliderWrapper(
-    name: string,
-    slider: HTMLInputElement,
-    valueDisplay: HTMLSpanElement
-  ) {
-    const wrapper = document.createElement('div');
-    wrapper.classList.add('slider');
-    const label = document.createElement('label');
-
-    const title = document.createElement('p');
-    title.innerText = SettingsSlider.formatLabel(name);
-    title.appendChild(valueDisplay);
-
-    label.appendChild(title);
-    label.appendChild(slider);
-    wrapper.appendChild(label);
-
-    return wrapper;
-  }
-
-  private static formatLabel(value: string): string {
-    const formatted = value.replace(/([A-Z])/g, ' $1');
-
-    return (
-      formatted.charAt(0).toLocaleUpperCase() + formatted.slice(1).toLocaleLowerCase()
-    );
-  }
-
-  private onChange() {
-    this.settings[this.settingName] = this.config.rounding(
-      this.inverseScaling(Number(this.slider.value))
-    ) as any;
-
-    this.config.onChangeCallback?.(this.settings[this.settingName]);
-    this.valueDisplay.innerText = formatNumber(
-      this.settings[this.settingName],
-      this.config.unit
-    );
-  }
-
-  public updateSliderValueBasedOnSource() {
-    this.slider.value = this.scaling(this.settings[this.settingName]).toString();
-    this.onChange();
-  }
-
-  public updateConfig(config: Partial) {
-    Object.assign(this.config, config);
-
-    if (this.config.step === undefined) {
-      this.config.step =
-        (this.scaling(this.config.max) - this.scaling(this.config.min)) /
-        SettingsSlider.DEFAULT_STEP_COUNT;
-    }
-
-    this.slider.min = this.scaling(this.config.min).toString();
-    this.slider.max = this.scaling(this.config.max).toString();
-    this.slider.step = this.config.step.toString();
-    this.slider.value = this.scaling(this.settings[this.settingName]).toString();
-    this.onChange();
-  }
-
-  public get element(): HTMLElement {
-    return this.sliderWrapper;
-  }
-
-  private get scaling(): (value: number) => number {
-    switch (this.config.scaling) {
-      case ValueScaling.Linear:
-        return (value) => value;
-      case ValueScaling.Quadratic:
-        return (value) => Math.sqrt(value);
-      case ValueScaling.Logarithmic:
-        return (value) => Math.log10(value);
-    }
-  }
-
-  private get inverseScaling(): (value: number) => number {
-    switch (this.config.scaling) {
-      case ValueScaling.Linear:
-        return (value) => value;
-      case ValueScaling.Quadratic:
-        return (value) => Math.pow(value, 2);
-      case ValueScaling.Logarithmic:
-        return (value) => Math.pow(10, value);
-    }
-  }
-}
diff --git a/src/page/splash-screen.ts b/src/page/splash-screen.ts
new file mode 100644
index 0000000..00d44a4
--- /dev/null
+++ b/src/page/splash-screen.ts
@@ -0,0 +1,61 @@
+import { queryRequiredElement } from '../utils/dom';
+import { clamp01 } from '../utils/math';
+
+export class SplashScreen {
+  public readonly startButton = queryRequiredElement('.start-button', HTMLButtonElement);
+  private readonly splash = queryRequiredElement('.splash', HTMLDivElement);
+  private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement);
+  private readonly loadingStatus = queryRequiredElement(
+    '.loading-status',
+    HTMLDivElement
+  );
+  private readonly loadingProgress = queryRequiredElement(
+    '.loading-progress',
+    HTMLDivElement
+  );
+
+  private setVisible(element: HTMLElement, isVisible: boolean): void {
+    element.dataset.visible = String(isVisible);
+    element.setAttribute('aria-hidden', String(!isVisible));
+    element.inert = !isVisible;
+  }
+
+  public setLoadingStage(label: string, ratio: number): void {
+    const percent = Math.round(clamp01(ratio) * 100);
+    this.loadingStatus.textContent = label;
+    this.loadingProgress.style.setProperty('--loading-progress', `${percent}%`);
+    this.loadingProgress.setAttribute('aria-valuenow', String(percent));
+  }
+
+  public awaitStart(onStart: () => void): Promise {
+    this.startButton.disabled = false;
+    return new Promise((resolve) => {
+      const onKeyDown = (event: KeyboardEvent) => {
+        if (event.key !== 'Enter' || event.defaultPrevented) {
+          return;
+        }
+
+        event.preventDefault();
+        this.startButton.click();
+      };
+      const onClick = () => {
+        this.startButton.removeEventListener('click', onClick);
+        document.removeEventListener('keydown', onKeyDown);
+        onStart();
+        this.setVisible(this.splash, false);
+        resolve();
+      };
+
+      this.startButton.addEventListener('click', onClick);
+      document.addEventListener('keydown', onKeyDown);
+    });
+  }
+
+  public showLoadingBar(): void {
+    this.setVisible(this.loadingBar, true);
+  }
+
+  public hideLoadingBar(): void {
+    this.setVisible(this.loadingBar, false);
+  }
+}
diff --git a/src/page/vibe-navigator.ts b/src/page/vibe-navigator.ts
new file mode 100644
index 0000000..fc6ecc0
--- /dev/null
+++ b/src/page/vibe-navigator.ts
@@ -0,0 +1,85 @@
+import { activeVibe, applyVibeSettings, rememberActiveVibeSelection } from '../settings';
+import { queryRequiredElement } from '../utils/dom';
+import { getCurrentUriVibeId, writeCurrentVibeUri } from '../vibe-uri';
+import { getVibeById, VIBE_PRESETS, type VibeId } from '../vibes';
+
+interface VibeSelection {
+  source: string;
+  userGesture: boolean;
+  vibeId: VibeId;
+  vibeName: string;
+}
+
+interface VibeNavigatorOptions {
+  onChange: (selection: VibeSelection) => void;
+}
+
+export class VibeNavigator {
+  private readonly abortController = new AbortController();
+  private readonly previousButton = queryRequiredElement(
+    '.previous-vibe',
+    HTMLButtonElement
+  );
+  private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement);
+
+  public constructor(private readonly options: VibeNavigatorOptions) {
+    rememberActiveVibeSelection();
+    writeCurrentVibeUri(activeVibe.id, 'replace');
+
+    const { signal } = this.abortController;
+    this.previousButton.addEventListener(
+      'click',
+      () => this.select(-1, 'previous-button'),
+      { signal }
+    );
+    this.nextButton.addEventListener('click', () => this.select(1, 'next-button'), {
+      signal,
+    });
+    window.addEventListener('popstate', this.selectFromCurrentUri, { signal });
+  }
+
+  public destroy(): void {
+    this.abortController.abort();
+  }
+
+  private select(offset: number, source: string): void {
+    const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
+    const currentIndex = current >= 0 ? current : 0;
+    const vibe =
+      VIBE_PRESETS[(currentIndex + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
+    const activePreset = applyVibeSettings(vibe);
+    writeCurrentVibeUri(activePreset.id, 'push');
+    this.notifyChange(activePreset, source, true);
+  }
+
+  private readonly selectFromCurrentUri = (): void => {
+    const vibeId = getCurrentUriVibeId();
+    if (!vibeId || vibeId === activeVibe.id) {
+      writeCurrentVibeUri(activeVibe.id, 'replace');
+      return;
+    }
+
+    const vibe = getVibeById(vibeId);
+    if (!vibe) {
+      writeCurrentVibeUri(activeVibe.id, 'replace');
+      return;
+    }
+
+    const activePreset = applyVibeSettings(vibe);
+    writeCurrentVibeUri(activePreset.id, 'replace');
+    this.notifyChange(activePreset, 'uri-popstate', false);
+  };
+
+  private notifyChange(
+    activePreset: typeof activeVibe,
+    source: string,
+    userGesture: boolean
+  ): void {
+    this.options.onChange({
+      userGesture,
+      vibeId: activePreset.id,
+      vibeName: activePreset.name,
+      source,
+    });
+  }
+}
diff --git a/src/utils/dom.ts b/src/utils/dom.ts
new file mode 100644
index 0000000..b2cbcd2
--- /dev/null
+++ b/src/utils/dom.ts
@@ -0,0 +1,24 @@
+import { ErrorCode, RuntimeError } from './error-handler';
+
+type ElementConstructor = abstract new () => T;
+
+export const queryRequiredElement = (
+  selector: string,
+  constructor: ElementConstructor
+): T => {
+  const element = document.querySelector(selector);
+  if (!(element instanceof constructor)) {
+    throw new RuntimeError(
+      ErrorCode.DOM_ELEMENT_MISSING,
+      `Missing required DOM element: ${selector}`,
+      {
+        details: {
+          expectedType: constructor.name,
+          selector,
+        },
+      }
+    );
+  }
+
+  return element;
+};