Improved UI

main
František Špaček 2 weeks ago
parent c49b69d9a7
commit 42d9505a6b

@ -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.
- Speed slider: slider direction matches natural expectation: left = slower, right = faster.

@ -5,104 +5,127 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Music of Life</title>
<link rel="icon" href="musical_note.png">
<link rel="stylesheet" href="style.css">
<link rel="icon" href="src/musical_note.png">
<link rel="stylesheet" href="src/style.css">
</head>
<body>
<main>
<header>
<h1>Music of Life</h1>
<div id="status">
<span id="runState">stopped</span>
<span id="generation">Generation: 0</span>
<span id="runState">stopped</span>
</div>
</header>
<section class="controls">
<div class="panel primary">
<button id="startBtn">Start</button>
<button id="stepBtn">Step</button>
<button id="randomBtn">Random</button>
<button id="clearBtn">Clear</button>
<select id="presetSelect">
<option value="">Presets</option>
<option value="glider">Glider</option>
<option value="lwss">Lightweight spaceship</option>
<option value="gosper">Gosper glider gun</option>
</select>
<button id="saveBtn">Save</button>
<button id="loadBtn">Load</button>
<div class="record-controls">
<button id="recordBtn">Start Recording</button>
<button id="stopRecordBtn">Stop Recording</button>
</div>
</div>
<div class="panel secondary">
<label>
Speed
<input id="speedRange" type="range" min="50" max="2000" value="800">
<output id="speedValue"></output>
</label>
<label>
Cell size
<input id="cellSizeRange" type="range" min="4" max="64" value="24">
<output id="cellSizeValue"></output>
</label>
<label>
Base frequency
<input id="baseFrequencyRange" type="range" min="20" max="600" value="100">
<output id="baseFrequencyValue"></output>
</label>
<label>
Volume
<input id="volumeRange" type="range" min="0" max="100" value="50">
<output id="volumeValue"></output>
</label>
<label class="inline">
<input type="checkbox" id="wrapCheckbox" checked>
Wrap edges
</label>
<label>
Music mode
<select id="musicModeSelect">
<option value="additive">Additive</option>
<option value="frequencyGrid">Frequency Grid</option>
<option value="granular">Granular</option>
<option value="polyrhythm">Polyrhythm</option>
</select>
</label>
<small id="modeHint"></small>
</div>
</section>
<canvas id="lifeCanvas"></canvas>
<footer>
<small>
Click or drag to draw · Right click to erase · Space = start/stop
</small>
</footer>
<div class="app-grid">
<aside class="sidebar">
<section class="panel secondary collapsible">
<button class="collapse-toggle" type="button">Controls</button>
<div class="collapsible-content">
<section class="panel primary">
<div class="gap-row">
<button id="startBtn">Start</button>
<button id="stepBtn">Step</button>
<button id="randomBtn">Random</button>
<button id="clearBtn">Clear</button>
</div>
<div class="right-controls">
<select id="presetSelect">
<option value="">Presets</option>
<option value="glider">Glider</option>
<option value="lwss">Lightweight spaceship</option>
<option value="gosper">Gosper glider gun</option>
</select>
<button id="saveBtn">Save</button>
<button id="loadBtn">Load</button>
</div>
</section>
<section class="panel secondary controls">
<label class="slider-row">Speed
<div class="slider-control">
<input id="speedRange" type="range" min="0.5" max="50" value="5" step="1">
<output id="speedValue"></output>
</div>
</label>
<label class="slider-row">Cell size
<div class="slider-control">
<input id="cellSizeRange" type="range" min="4" max="64" value="24">
<output id="cellSizeValue"></output>
</div>
</label>
<label class="slider-row">Base frequency
<div class="slider-control">
<input id="baseFrequencyRange" type="range" min="20" max="600" value="100">
<output id="baseFrequencyValue"></output>
</div>
</label>
<label class="slider-row">Volume
<div class="slider-control">
<input id="volumeRange" type="range" min="0" max="100" value="50">
<output id="volumeValue"></output>
</div>
</label>
<label class="inline">
<input type="checkbox" id="wrapCheckbox" checked>
<span>Wrap edges</span>
</label>
<label>Music mode
<select id="musicModeSelect">
<option value="additive">Additive</option>
<option value="frequencyGrid">Frequency Grid</option>
<option value="granular">Granular</option>
<option value="polyrhythm">Polyrhythm</option>
</select>
</label>
<small id="modeHint" class="muted"></small>
</section>
</div>
</section>
<section class="panel secondary collapsible collapsed">
<button class="collapse-toggle" type="button">Recording</button>
<div class="collapsible-content">
<section class="panel secondary">
<div class="gap-row">
<button id="recordBtn">Start Recording</button>
<button id="stopRecordBtn">Stop Recording</button>
<span id="recordDot" class="recording-indicator hidden">Recording</span>
</div>
<canvas id="waveformCanvas" width="400" height="80"></canvas>
<div id="timerDisplay">00:00</div>
</section>
</div>
</section>
</aside>
<section class="canvas-area">
<canvas id="lifeCanvas"></canvas>
<button id="fullscreenToggle" class="fullscreen-toggle" type="button">Full screen ⛶</button>
<footer>
<small>Click or drag to draw · Right click to erase · Space = start/stop</small>
</footer>
</section>
</div>
</main>
<!-- order matters -->
<script src="recording.js"></script>
<script src="music.js"></script>
<script src="script.js"></script>
<script src="ui.js"></script>
<script src="src/scripts/recording.js"></script>
<script src="src/scripts/music.js"></script>
<script src="src/scripts/script.js"></script>
<script src="src/scripts/ui.js"></script>
</body>
</html>

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

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

@ -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) {

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

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

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

@ -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 }
}
Loading…
Cancel
Save

Powered by TurnKey Linux.