From 42d9505a6bb923fd75f92de34dcf1aabc78f79de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20=C5=A0pa=C4=8Dek?= Date: Tue, 27 Jan 2026 13:44:30 +0100 Subject: [PATCH] Improved UI --- README.md | 10 +- index.html | 193 ++++--- music.js.old | 496 ------------------ musical_note.png => src/musical_note.png | Bin .../scripts/additive-processor.js | 0 .../scripts/granular-processor.js | 0 music.js => src/scripts/music.js | 0 recording.js => src/scripts/recording.js | 14 +- script.js => src/scripts/script.js | 12 +- ui.js => src/scripts/ui.js | 32 +- src/style.css | 244 +++++++++ style.css | 179 ------- 12 files changed, 400 insertions(+), 780 deletions(-) delete mode 100644 music.js.old rename musical_note.png => src/musical_note.png (100%) rename additive-processor.js => src/scripts/additive-processor.js (100%) rename granular-processor.js => src/scripts/granular-processor.js (100%) rename music.js => src/scripts/music.js (100%) rename recording.js => src/scripts/recording.js (96%) rename script.js => src/scripts/script.js (96%) rename ui.js => src/scripts/ui.js (71%) create mode 100644 src/style.css delete mode 100644 style.css diff --git a/README.md b/README.md index 4793c1d..d2ff31f 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,25 @@ -Conway's Game of Life - Frontend only +# Conway's Game of Life - Frontend only Files: + - index.html — main page - style.css — styles - script.js — game logic and UI How to run: + 1. Open `index.html` in your browser (double-click or use a simple static file server). 2. Use the controls to Start/Stop, Step, Randomize, or Clear. 3. Click or drag on the canvas to toggle cells. Adjust speed and cell size with sliders. Notes: + - No backend required. - For best results, open the page via a local server (e.g., `python3 -m http.server`) if your browser blocks local file scripts. -New features: +Features: + - Presets: select common patterns (Glider, Lightweight spaceship, Pulsar, Gosper glider gun) from the Presets dropdown. - Wrap edges: enable "Wrap edges" to use toroidal neighbor rules (cells at edges consider opposite edges as neighbors). - Save / Load: Save the current grid to your browser's localStorage (client-side only) and load it back later. The save is stored per-browser and per-machine. -- Speed slider: slider direction matches natural expectation: left = slower, right = faster. \ No newline at end of file +- Speed slider: slider direction matches natural expectation: left = slower, right = faster. diff --git a/index.html b/index.html index 1b71126..fc18885 100644 --- a/index.html +++ b/index.html @@ -5,104 +5,127 @@ Music of Life - - - + +

Music of Life

-
- stopped Generation: 0 + stopped
-
-
- - - - - - - - - - -
- - -
- -
- -
- - - - - - - - - - - - - -
-
- - - -
- - Click or drag to draw · Right click to erase · Space = start/stop - -
+
+ + +
+ + + +
+ Click or drag to draw · Right click to erase · Space = start/stop +
+
+
- - - - - + + + + + \ No newline at end of file diff --git a/music.js.old b/music.js.old deleted file mode 100644 index 2f667c4..0000000 --- a/music.js.old +++ /dev/null @@ -1,496 +0,0 @@ -// Music synthesis from Conway's Game of Life -// Base class and multiple synthesis approaches - -/** - * Base class for all music synthesis approaches - */ -class MusicSynthesizer { - constructor() { - this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); - this.isPlaying = false; - this.masterGain = this.audioContext.createGain(); - this.masterGain.connect(this.audioContext.destination); - this.masterGain.gain.value = 0.3; // Prevent clipping - - this.baseFrequency = 100; // Hz - } - - /** - * Generate music based on grid state - override in subclasses - */ - generateFromGrid(grid, cols, rows) { - // To be implemented by subclasses - } - - play() { - if (this.isPlaying) return; - this.isPlaying = true; - this.masterGain.gain.setValueAtTime(0.3, this.audioContext.currentTime); - } - - stop() { - this.isPlaying = false; - this.masterGain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1); - this.cleanup(); - } - - setBaseFrequency(frequency) { - this.baseFrequency = frequency; - } - - cleanup() { - // To be implemented by subclasses - } -} - -/** - * Additive Synthesis: Each live cell generates a sine oscillator - * Frequency based on cell position, creating harmonic textures - */ -class AdditiveSynthesizer extends MusicSynthesizer { - constructor(superCellThreshold = 100, superCellMaxOscillators = 100) { - super(); - this.oscillators = new Map(); // Map of "key" -> { osc, gain, frequency } - this.lastUpdate = 0; - this.liveCells = new Set(); // Tracks currently alive cells as "x,y" - this.superCellThreshold = superCellThreshold; - this.superCellMaxOscillators = superCellMaxOscillators; - } - - calculateFrequency(x, y, cols, rows) { - const normalizedX = 1 - x / (cols - 1 || 1); - const normalizedY = 1 - y / (rows - 1 || 1); - - const octaveOffset = normalizedY * 3; - const noteOffset = normalizedX * 12; - - return this.baseFrequency * Math.pow(2, octaveOffset + noteOffset / 12); - } - - generateFromGrid(grid, cols, rows) { - if (!this.isPlaying) return; - - // Throttle updates ~40Hz - const now = performance.now(); - if (now - this.lastUpdate < 25) return; - this.lastUpdate = now; - - const newLiveCells = new Set(); - - // Only iterate the sparse live cells - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - if (grid[y][x]) newLiveCells.add(`${x},${y}`); - } - } - - const activeCellCount = newLiveCells.size; - const useSuperCells = activeCellCount > this.superCellThreshold; - - let superCellSize = 1; - const superCells = new Map(); - - if (useSuperCells) { - // Determine super-cell size to limit total oscillators - superCellSize = Math.ceil(Math.sqrt((cols * rows) / this.superCellMaxOscillators)); - - for (const cellKey of newLiveCells) { - const [x, y] = cellKey.split(',').map(Number); - const sx = Math.floor(x / superCellSize); - const sy = Math.floor(y / superCellSize); - const sKey = `${sx},${sy}`; - superCells.set(sKey, (superCells.get(sKey) || 0) + 1); - } - } else { - // In sparse mode, each live cell becomes its own "super cell" - for (const cellKey of newLiveCells) { - superCells.set(cellKey, 1); - } - } - - // Stop oscillators for empty keys - const toDelete = []; - for (const [key, { osc, gain }] of this.oscillators) { - if (!superCells.has(key)) { - gain.gain.cancelScheduledValues(this.audioContext.currentTime); - gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05); - osc.stop(this.audioContext.currentTime + 0.05); - toDelete.push(key); - } - } - for (const key of toDelete) this.oscillators.delete(key); - - // Create/update oscillators - for (const [key, count] of superCells) { - const [sx, sy] = key.split(',').map(Number); - - const cx = useSuperCells - ? sx * superCellSize + superCellSize / 2 - : sx; - const cy = useSuperCells - ? sy * superCellSize + superCellSize / 2 - : sy; - - const frequency = this.calculateFrequency(cx, cy, cols, rows); - - const gainValue = useSuperCells - ? Math.min(0.3, Math.sqrt(count / (superCellSize * superCellSize)) * 0.3) - : 0.3 / Math.sqrt(activeCellCount); - - if (!this.oscillators.has(key)) { - const osc = this.audioContext.createOscillator(); - const gain = this.audioContext.createGain(); - - osc.type = 'sine'; - osc.frequency.value = frequency; - - gain.gain.setValueAtTime(0, this.audioContext.currentTime); - gain.gain.setTargetAtTime(gainValue, this.audioContext.currentTime, 0.05); - - osc.connect(gain); - gain.connect(this.masterGain); - osc.start(); - - this.oscillators.set(key, { osc, gain, frequency }); - } else { - const { osc, gain } = this.oscillators.get(key); - - if (Math.abs(osc.frequency.value - frequency) > 0.1) { - osc.frequency.setTargetAtTime(frequency, this.audioContext.currentTime, 0.05); - this.oscillators.get(key).frequency = frequency; - } - if (Math.abs(gain.gain.value - gainValue) > 0.01) { - gain.gain.setTargetAtTime(gainValue, this.audioContext.currentTime, 0.05); - } - } - } - - // Update liveCells set - this.liveCells = newLiveCells; - } - - cleanup() { - for (const [, { osc, gain }] of this.oscillators) { - gain.gain.cancelScheduledValues(this.audioContext.currentTime); - gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1); - osc.stop(this.audioContext.currentTime + 0.1); - } - this.oscillators.clear(); - this.liveCells.clear(); - } -} - - - -/** - * Frequency Grid: Map rows to frequencies, columns to amplitude modulation - * Creates a more melodic, wavetable-like sound - */ -class FrequencyGridSynthesizer extends MusicSynthesizer { - constructor() { - super(); - this.oscillators = new Map(); // Map of row index -> { osc, gain } - } - - generateFromGrid(grid, cols, rows) { - if (!this.isPlaying) return; - - const activeCells = new Set(); - - // Find which rows have active cells - for (let y = 0; y < rows; y++) { - let rowHasLife = false; - for (let x = 0; x < cols; x++) { - if (grid[y][x]) { - rowHasLife = true; - activeCells.add(y); - break; - } - } - } - - // Stop oscillators for rows that are now empty - for (const [rowKey, { osc, gain }] of this.oscillators) { - if (!activeCells.has(parseInt(rowKey))) { - gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05); - setTimeout(() => { - osc.stop(); - }, 100); - this.oscillators.delete(rowKey); - } - } - - // global grid density - const density = activeCells.size / (rows * cols); - - // compensation: louder when overall density is low, neutral when medium - const globalBoost = Math.min(1 / Math.sqrt(density), 10); - - // Create or update oscillators for rows with life - for (const row of activeCells) { - const frequency = this.baseFrequency * Math.pow(2, (1 - row / (rows - 1 || 1)) * 3); - - // Count density in this row for amplitude - let cellCount = 0; - for (let x = 0; x < cols; x++) { - if (grid[row][x]) cellCount++; - } - const amplitude = Math.min((cellCount / cols) * globalBoost, 0.1); - - if (!this.oscillators.has(row)) { - const osc = this.audioContext.createOscillator(); - const gain = this.audioContext.createGain(); - - osc.type = 'triangle'; - osc.frequency.value = frequency; - - gain.gain.setValueAtTime(0, this.audioContext.currentTime); - gain.gain.setTargetAtTime(amplitude, this.audioContext.currentTime, 0.1); - - osc.connect(gain); - gain.connect(this.masterGain); - osc.start(); - - this.oscillators.set(row, { osc, gain }); - } else { - const { osc, gain } = this.oscillators.get(row); - osc.frequency.setTargetAtTime(frequency, this.audioContext.currentTime, 0.1); - gain.gain.setTargetAtTime(amplitude, this.audioContext.currentTime, 0.1); - } - } - } - - cleanup() { - for (const [, { osc, gain }] of this.oscillators) { - gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1); - setTimeout(() => { - osc.stop(); - }, 150); - } - this.oscillators.clear(); - } -} - -/** - * Granular Synthesis: Each cell generates a brief burst/grain - * Creates glitchy, textured, evolving sounds - */ -class GranularSynthesizer extends MusicSynthesizer { - constructor() { - super(); - this.liveCells = new Set(); // currently alive cells - } - - generateFromGrid(grid, cols, rows) { - if (!this.isPlaying) return; - - const newLiveCells = new Set(); - const triggeredCells = []; - - // Only loop over the entire grid to detect transitions - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - const key = `${x},${y}`; - const isAlive = grid[y][x] === 1; - - if (isAlive) newLiveCells.add(key); - - const wasAlive = this.liveCells.has(key); - if (isAlive && !wasAlive) triggeredCells.push([x, y]); - } - } - - // Calculate per-grain volume based on number of grains this update - const grainCount = triggeredCells.length || 1; // avoid division by zero - const gainPerGrain = Math.min(0.2, 0.5 / Math.sqrt(grainCount)); - - for (const [x, y] of triggeredCells) { - this.triggerGrain(x, y, cols, rows, gainPerGrain); - } - - // Update live cells set - this.liveCells = newLiveCells; - } - - triggerGrain(x, y, cols, rows, gainValue) { - const frequency = this.baseFrequency * Math.pow( - 2, - (1 - y / (rows - 1 || 1)) * 3 + (1 - x / (cols - 1 || 1)) - ); - const grainDuration = 0.1; // 100ms - - const osc = this.audioContext.createOscillator(); - const gain = this.audioContext.createGain(); - const now = this.audioContext.currentTime; - - osc.frequency.value = frequency; - osc.type = 'sine'; - - // Envelope: fade in, hold, fade out - gain.gain.setValueAtTime(0, now); - gain.gain.linearRampToValueAtTime(gainValue, now + 0.01); - gain.gain.linearRampToValueAtTime(gainValue, now + grainDuration - 0.02); - gain.gain.linearRampToValueAtTime(0, now + grainDuration); - - osc.connect(gain); - gain.connect(this.masterGain); - osc.start(now); - osc.stop(now + grainDuration); - } - - cleanup() { - this.liveCells.clear(); - } -} - -class PolyrhythmSynthesizer extends MusicSynthesizer { - constructor() { - super(); - this.oscillatorPool = []; - this.activeOscillators = []; - this.maxOscillators = 50; - this._initOscillatorPool(); - - // Simple delay for echo effect - this.delayNode = this.audioContext.createDelay(); - this.delayNode.delayTime.value = 0.2; // 200ms echo - this.delayGain = this.audioContext.createGain(); - this.delayGain.gain.value = 0.25; // echo volume - this.delayNode.connect(this.delayGain); - this.delayGain.connect(this.masterGain); - } - - _initOscillatorPool() { - for (let i = 0; i < this.maxOscillators; i++) { - const osc = this.audioContext.createOscillator(); - const gain = this.audioContext.createGain(); - osc.type = 'sine'; - gain.gain.setValueAtTime(0, this.audioContext.currentTime); - osc.connect(gain); - gain.connect(this.masterGain); - osc.start(); - this.oscillatorPool.push({ osc, gain, inUse: false }); - } - } - - generateFromGrid(grid, cols, rows) { - if (!this.isPlaying) return; - - for (let y = 0; y < rows; y++) { - const rowCells = []; - for (let x = 0; x < cols; x++) if (grid[y][x]) rowCells.push(x); - if (!rowCells.length) continue; - - const rowLength = (rows - y) + 2; - for (const x of rowCells) { - const triggerProb = Math.min(1, 0.3 * rowLength / rowCells.length); - if (Math.random() < triggerProb) this.triggerNote(y, rowCells.length); - } - } - } - - triggerNote(row, activeCellsInRow) { - const freeOsc = this.oscillatorPool.find(o => !o.inUse); - if (!freeOsc) return; - freeOsc.inUse = true; - - const now = this.audioContext.currentTime; - const freq = this.baseFrequency * Math.pow(2, (1 - row / 8) * 4); // wider pitch spread - freeOsc.osc.frequency.setValueAtTime(freq, now); - - const gainValue = Math.min(0.3, 0.5 / Math.sqrt(activeCellsInRow)); - - // Calculate attack and release times based on frequency - const noteDuration = 1 + (6 - row) * 0.2; // Longer duration for lower notes - const attackTime = 0.05; - const releaseTime = noteDuration - attackTime; - - // Smoother fade-in/out with dynamic times - freeOsc.gain.gain.cancelScheduledValues(now); - freeOsc.gain.gain.setValueAtTime(0, now); - freeOsc.gain.gain.linearRampToValueAtTime(gainValue, now + attackTime); - freeOsc.gain.gain.exponentialRampToValueAtTime(0.01, now + releaseTime); - - // Optional echo: duplicate into delay node - const delayGain = this.audioContext.createGain(); - delayGain.gain.value = 0.2; - freeOsc.osc.connect(delayGain); - delayGain.connect(this.delayNode); - - // Free oscillator after envelope finishes - setTimeout(() => { - freeOsc.inUse = false; - delayGain.disconnect(); - }, noteDuration * 1000); // Adjusted to match the new decay time - } - /*triggerNote(row, activeCellsInRow) { - const freeOsc = this.oscillatorPool.find(o => !o.inUse); - if (!freeOsc) return; - freeOsc.inUse = true; - - const now = this.audioContext.currentTime; - const freq = this.baseFrequency * Math.pow(2, (1 - row / 8) * 4); // wider pitch spread - freeOsc.osc.frequency.setValueAtTime(freq, now); - - const gainValue = Math.min(0.3, 0.5 / Math.sqrt(activeCellsInRow)); - - // Long envelope with smoother fade in/out - freeOsc.gain.gain.cancelScheduledValues(now); - freeOsc.gain.gain.setValueAtTime(0, now); - freeOsc.gain.gain.linearRampToValueAtTime(gainValue, now + 0.05); - freeOsc.gain.gain.linearRampToValueAtTime(0.01, now + 0.4); // ~400ms note duration - - // Optional echo: duplicate into delay node - const delayGain = this.audioContext.createGain(); - delayGain.gain.value = 0.2; - freeOsc.osc.connect(delayGain); - delayGain.connect(this.delayNode); - - // Free oscillator after envelope finishes - setTimeout(() => { - freeOsc.inUse = false; - delayGain.disconnect(); - }, 450); - }*/ - - cleanup() { - for (const oscObj of this.oscillatorPool) { - oscObj.gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05); - oscObj.inUse = false; - } - } -} - - -// Create global instance using Additive Synthesis by default -let lifeMusic = new AdditiveSynthesizer(); - -// Function to switch synthesis approaches -function setSynthesisMode(mode) { - const wasPlaying = lifeMusic.isPlaying; - - if (wasPlaying) { - lifeMusic.stop(); - } - - switch (mode) { - case 'additive': - lifeMusic = new AdditiveSynthesizer(); - break; - case 'frequencyGrid': - lifeMusic = new FrequencyGridSynthesizer(); - break; - case 'granular': - lifeMusic = new GranularSynthesizer(); - break; - case 'polyrhythm': - lifeMusic = new PolyrhythmSynthesizer(); - break; - default: - lifeMusic = new AdditiveSynthesizer(); - } - - if (wasPlaying) { - lifeMusic.play(); - } -} diff --git a/musical_note.png b/src/musical_note.png similarity index 100% rename from musical_note.png rename to src/musical_note.png diff --git a/additive-processor.js b/src/scripts/additive-processor.js similarity index 100% rename from additive-processor.js rename to src/scripts/additive-processor.js diff --git a/granular-processor.js b/src/scripts/granular-processor.js similarity index 100% rename from granular-processor.js rename to src/scripts/granular-processor.js diff --git a/music.js b/src/scripts/music.js similarity index 100% rename from music.js rename to src/scripts/music.js diff --git a/recording.js b/src/scripts/recording.js similarity index 96% rename from recording.js rename to src/scripts/recording.js index 17ee41a..d1a6799 100644 --- a/recording.js +++ b/src/scripts/recording.js @@ -317,20 +317,10 @@ let elapsedTime = 0; // total elapsed seconds let lastUpdate = 0; // for pausing/resuming - const waveformCanvas = document.createElement('canvas'); - waveformCanvas.width = 400; - waveformCanvas.height = 80; - waveformCanvas.style.border = '1px solid #ccc'; - waveformCanvas.style.display = 'block'; - waveformCanvas.style.marginTop = '6px'; - document.body.appendChild(waveformCanvas); + const waveformCanvas = document.getElementById('waveformCanvas'); const ctx = waveformCanvas.getContext('2d'); - const timerDisplay = document.createElement('div'); - timerDisplay.style.color = '#cfe7ff'; - timerDisplay.style.margin = '4px 0'; - timerDisplay.textContent = '00:00'; - document.body.appendChild(timerDisplay); + const timerDisplay = document.getElementById('timerDisplay'); function ensureAnalyser(audioContext) { if (analyser && analyser.source) { diff --git a/script.js b/src/scripts/script.js similarity index 96% rename from script.js rename to src/scripts/script.js index ee12e42..bdf5f54 100644 --- a/script.js +++ b/src/scripts/script.js @@ -128,13 +128,17 @@ function runInterval() { const loop = () => { if (!running) return; step(); - const v = parseInt(speedRange.value, 10); - const ms = Math.max(10, Math.round(1550 - v)); + const stepsPerSecond = parseFloat(speedRange.value, 10); + const ms = Math.max(10, Math.round(1000 / stepsPerSecond)); + //const v = parseInt(speedRange.value, 10); + //const ms = Math.max(10, Math.round(1550 - v)); timer = setTimeout(loop, ms); }; - const v = parseInt(speedRange.value, 10); - const ms = Math.max(10, Math.round(1550 - v)); + const stepsPerSecond = parseFloat(speedRange.value, 10); + const ms = Math.max(10, Math.round(1000 / stepsPerSecond)); + //const v = parseInt(speedRange.value, 10); + //const ms = Math.max(10, Math.round(1550 - v)); timer = setTimeout(loop, ms); } diff --git a/ui.js b/src/scripts/ui.js similarity index 71% rename from ui.js rename to src/scripts/ui.js index 19e464d..cd57db2 100644 --- a/ui.js +++ b/src/scripts/ui.js @@ -73,11 +73,19 @@ window.addEventListener("keydown", e => { startBtn.click(); break; case "KeyR": - document.getElementById("randomBtn").click(); + //document.getElementById("randomBtn").click(); break; case "KeyC": document.getElementById("clearBtn").click(); break; + case "Escape": + if (document.body.classList.contains('canvas-only')) { + document.body.classList.toggle('canvas-only'); + fsBtn.textContent = 'Full screen ⛶'; + var resizeEvent = new Event('resize'); + window.dispatchEvent(resizeEvent); + } + break; } }); @@ -101,3 +109,25 @@ document.getElementById("stopRecordBtn").addEventListener("click", async () => { await window.recording.stopAndMerge("music-of-life.wav"); window.recording.visualize.stop(); }); + +// Collapsible panels +document.querySelectorAll('.collapsible').forEach(section => { + const toggle = section.querySelector('.collapse-toggle'); + toggle.addEventListener('click', () => { + section.classList.toggle('collapsed'); + }); +}); + +// Full canvas mode +const fsBtn = document.getElementById('fullscreenToggle'); +fsBtn.addEventListener('click', () => { + document.body.classList.toggle('canvas-only'); + fsBtn.textContent = document.body.classList.contains('canvas-only') + ? 'Exit full screen ↻' + : 'Full screen ⛶'; + + // keep rendering sharp + //window.resizeCanvas?.(); + var resizeEvent = new Event('resize'); + window.dispatchEvent(resizeEvent); +}); diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..67498e3 --- /dev/null +++ b/src/style.css @@ -0,0 +1,244 @@ +:root { + --bg: #0f172a; + --canvas-bg: #071020; + --muted: rgba(255, 255, 255, 0.06); + --text: #e6eef8; + --accent: #7cb7ff; + --panel: rgba(255, 255, 255, 0.02); + --radius: 12px; + --gap: 14px; + --max-width: 1200px; +} + +* { + box-sizing: border-box +} + +html, +body { + height: 100% +} + +body { + margin: 0; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial; + background: var(--bg); + color: var(--text); + display: flex; + justify-content: center; + padding: 22px; +} + +main { + width: 100%; + max-width: var(--max-width); + display: flex; + flex-direction: column; + gap: var(--gap) +} + +header { + display: flex; + justify-content: space-between; + align-items: baseline +} + +h1 { + margin: 0; + font-size: 20px +} + +.app-grid { + display: grid; + grid-template-columns: 360px 1fr; + gap: 20px +} + +.sidebar { + display: flex; + flex-direction: column; + gap: var(--gap) +} + +@media(min-width:980px) { + .sidebar { + position: sticky; + top: 22px; + height: calc(100vh - 44px); + overflow: auto + } +} + +.canvas-area { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0 +} + +#runState::before, .recording-indicator::before { + content: "● "; +} + +#runState.running { + color: #7cb7ff; +} + +.panel { + background: var(--panel); + border: 1px solid var(--muted); + border-radius: var(--radius); + padding: 12px; +} + +.panel.primary { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center +} + +.panel.secondary { + display: flex; + flex-direction: column; + gap: 10px +} + +button { + background: transparent; + color: var(--text); + border: 1px solid var(--muted); + padding: 8px 12px; + border-radius: 999px; + cursor: pointer; +} + +label { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + font-size: 13px +} + +label.inline { + display: flex; + gap: 8px +} + +#lifeCanvas { + width: 100%; + height: min(72vh, 900px); + background: var(--canvas-bg); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.04); +} + +footer { + text-align: center; + font-size: 12px; + opacity: .8 +} + +.recording-indicator { + margin-left: 8px; + font-size: 13px; + color: #ff5f5f; + animation: pulse 1.2s infinite; +} + +.hidden { + display: none; +} + +@keyframes pulse { + 0% { opacity: 1 } + 50% { opacity: 0.4 } + 100% { opacity: 1 } +} + + +@media(max-width:980px) { + .app-grid { + grid-template-columns: 1fr + } + + #lifeCanvas { + height: 60vh + } +} + +/* ---------- Collapsible panels ---------- */ + +.collapsible .collapse-toggle { + background: none; + border: none; + color: var(--accent); + font-weight: 600; + text-align: left; + padding: 0; + cursor: pointer; +} + +.collapsible .collapsible-content { + margin-top: 10px; +} + +.collapsible.collapsed .collapsible-content { + display: none; +} + +.collapse-toggle::before { + content: "↑ "; +} + +.collapsible.collapsed .collapse-toggle::before { + content: "↓ "; +} + +/* ---------- Full canvas mode ---------- */ + +.fullscreen-toggle { + align-self: flex-end; + margin-bottom: 8px; +} + +body.canvas-only .sidebar, +body.canvas-only header, +body.canvas-only footer { + display: none; +} + +body.canvas-only .canvas-area { + width: 100%; +} + +body.canvas-only #lifeCanvas { + height: calc(100vh - 80px); +} + +body.canvas-only .app-grid { + display: block; +} + +/* Slider alignment */ +.slider-row { + display: grid; + grid-template-columns: 6em 1fr; + align-items: center; + gap: 5px; +} + +.slider-control { + display: grid; + grid-template-columns: 1fr 3ch; + align-items: center; + gap: 8px; +} + +/* Prevent output from resizing */ +.slider-control output { + display: inline-block; + width: 2ch; + text-align: right; + font-variant-numeric: tabular-nums; +} diff --git a/style.css b/style.css deleted file mode 100644 index c2eb692..0000000 --- a/style.css +++ /dev/null @@ -1,179 +0,0 @@ -:root { - --bg: #0f172a; - --canvas-bg: #071020; - --text: #e6eef8; - --label: #cfe7ff; - --button-bg: #0b1220; - --footer: #9bb8d9; - --border: rgba(255, 255, 255, 0.06); -} - -* { - box-sizing: border-box; - font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial; -} - -body { - margin: 0; - min-height: 100vh; - background: var(--bg); - color: var(--text); - display: flex; - justify-content: center; - align-items: center; -} - -main { - width: 100%; - max-width: 1100px; - padding: 22px; -} - -/* ---------- Header ---------- */ - -header { - display: flex; - justify-content: space-between; - align-items: baseline; - margin-bottom: 12px; -} - -h1 { - margin: 0; - font-size: 24px; -} - -#status { - display: flex; - gap: 14px; - font-size: 13px; - opacity: 0.85; -} - -#runState::before { - content: "● "; -} - -#runState.running { - color: #7cb7ff; -} - -/* ---------- Controls ---------- */ - -.controls { - display: flex; - gap: 12px; - margin-bottom: 12px; - flex-wrap: wrap; -} - -.panel { - border-radius: 10px; - padding: 10px; - border: 1px solid var(--border); -} - -.panel.primary { - display: flex; - gap: 6px; - flex-wrap: wrap; - background: rgba(255,255,255,0.02); -} - -.panel.secondary { - display: flex; - flex-direction: column; - gap: 8px; - background: rgba(255,255,255,0.015); - min-width: 260px; -} - -/* ---------- Inputs ---------- */ - -button { - background: var(--button-bg); - color: var(--text); - border: 1px solid var(--border); - padding: 8px 12px; - border-radius: 6px; - cursor: pointer; -} - -button:hover { - filter: brightness(1.05); -} - -button:disabled { - opacity: 0.4; - cursor: default; -} - -select, -input[type="range"] { - background: var(--button-bg); - color: var(--text); -} - -label { - display: grid; - grid-template-columns: 1fr auto; - gap: 6px; - font-size: 14px; - color: var(--label); -} - -label.inline { - display: flex; - gap: 6px; - align-items: center; -} - -output { - font-size: 12px; - opacity: 0.7; - align-self: center; -} - -#modeHint { - font-size: 12px; - opacity: 0.7; - line-height: 1.3; -} - -/* ---------- Canvas ---------- */ - -#lifeCanvas { - width: 100%; - height: 70vh; - display: block; - background: var(--canvas-bg); - border: 1px solid var(--border); - cursor: crosshair; -} - -/* ---------- Footer ---------- */ - -footer { - margin-top: 8px; - text-align: center; - color: var(--footer); - font-size: 12px; -} - -/* ---------- Recording -------- */ -.recording-indicator { - margin-left: 8px; - font-size: 13px; - color: #ff5f5f; - animation: pulse 1.2s infinite; -} - -.hidden { - display: none; -} - -@keyframes pulse { - 0% { opacity: 1 } - 50% { opacity: 0.4 } - 100% { opacity: 1 } -}