From 69c25aeb208a02cac6b95fb0c81a9ce62fd71268 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 9 Jul 2026 23:03:08 -0400 Subject: [PATCH 1/4] feat: add firefly, wave and reaction-diffusion experiments --- .../gaugegap/CollectivePlaygrounds.jsx | 630 ++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 brainsnn-r3f-app/src/features/gaugegap/CollectivePlaygrounds.jsx diff --git a/brainsnn-r3f-app/src/features/gaugegap/CollectivePlaygrounds.jsx b/brainsnn-r3f-app/src/features/gaugegap/CollectivePlaygrounds.jsx new file mode 100644 index 0000000..7d6b328 --- /dev/null +++ b/brainsnn-r3f-app/src/features/gaugegap/CollectivePlaygrounds.jsx @@ -0,0 +1,630 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Copy, MousePointer2, Pause, Play, RotateCcw, Share2, Sparkles } from 'lucide-react'; + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function round(value, places = 2) { + const factor = 10 ** places; + return Math.round(value * factor) / factor; +} + +async function shareExperiment({ lab, title, text, state, onNotice }) { + const url = new URL(window.location.href); + url.searchParams.set('lab', lab); + url.searchParams.delete('run'); + url.searchParams.set('state', state); + url.hash = 'playground'; + try { + if (navigator.share) { + await navigator.share({ title, text, url: url.toString() }); + } else { + await navigator.clipboard.writeText(url.toString()); + onNotice('Challenge link copied. The exact settings are inside it.'); + } + } catch (error) { + if (error?.name !== 'AbortError') onNotice('Sharing was blocked. Use Copy challenge instead.'); + } +} + +function copyExperiment({ lab, state, onNotice }) { + const url = new URL(window.location.href); + url.searchParams.set('lab', lab); + url.searchParams.delete('run'); + url.searchParams.set('state', state); + url.hash = 'playground'; + navigator.clipboard?.writeText(url.toString()) + .then(() => onNotice('Challenge copied. Send it to someone who thinks they can do better.')) + .catch(() => onNotice('Copy was blocked by the browser. Use Share challenge.')); +} + +function parseState(lab, count) { + if (typeof window === 'undefined') return null; + const params = new URLSearchParams(window.location.search); + if (params.get('lab') !== lab) return null; + const raw = params.get('state'); + if (!raw) return null; + const values = raw.split(',').map(Number); + if (values.length !== count || !values.every(Number.isFinite)) return null; + return values; +} + +function LabToolbar({ paused, onToggle, onReset, onShare, onCopy, notice }) { + return ( +
+
+ + + + +
+

{notice}

+
+ ); +} + +function RangeControl({ label, value, min, max, step, onChange, suffix = '' }) { + return ( + + ); +} + +const FIREFLY_PRESETS = [ + { id: 'festival', label: 'Festival sync', coupling: 1.65, noise: 0.08, population: 120, speed: 1 }, + { id: 'wild', label: 'Wild meadow', coupling: 0.32, noise: 0.52, population: 170, speed: 1.1 }, + { id: 'whisper', label: 'Slow whisper', coupling: 0.82, noise: 0.04, population: 85, speed: 0.62 }, + { id: 'storm', label: 'Electric storm', coupling: 2.5, noise: 0.2, population: 210, speed: 1.55 }, +]; + +export function FireflySyncLab() { + const shared = useMemo(() => parseState('fireflies', 4), []); + const [params, setParams] = useState(() => shared ? { + coupling: clamp(shared[0], 0, 3), + noise: clamp(shared[1], 0, 1), + population: Math.round(clamp(shared[2], 30, 240)), + speed: clamp(shared[3], 0.3, 2), + } : FIREFLY_PRESETS[0]); + const [paused, setPaused] = useState(false); + const [sync, setSync] = useState(0); + const [notice, setNotice] = useState(shared ? 'Shared swarm loaded. Can you drive it into perfect sync?' : 'Raise coupling and watch a crowd discover one rhythm.'); + const canvasRef = useRef(null); + const paramsRef = useRef(params); + const pausedRef = useRef(paused); + const swarmRef = useRef([]); + const resetRef = useRef(0); + + useEffect(() => { paramsRef.current = params; }, [params]); + useEffect(() => { pausedRef.current = paused; }, [paused]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return undefined; + const context = canvas.getContext('2d', { alpha: false }); + let width = 0; + let height = 0; + let frameId = 0; + let lastReset = -1; + let lastTime = performance.now(); + let lastScore = 0; + + function resize() { + const rect = canvas.getBoundingClientRect(); + const ratio = Math.min(window.devicePixelRatio || 1, 2); + width = Math.max(320, rect.width); + height = Math.max(360, rect.height); + canvas.width = Math.floor(width * ratio); + canvas.height = Math.floor(height * ratio); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + } + + function buildSwarm() { + const count = paramsRef.current.population; + swarmRef.current = Array.from({ length: count }, (_, index) => ({ + x: Math.random(), + y: Math.random(), + phase: Math.random() * Math.PI * 2, + omega: 0.76 + Math.random() * 0.48, + size: 1.5 + Math.random() * 2.7, + drift: (index % 2 ? 1 : -1) * (0.00004 + Math.random() * 0.00014), + })); + } + + const observer = new ResizeObserver(resize); + observer.observe(canvas); + resize(); + buildSwarm(); + + function render(now) { + if (lastReset !== resetRef.current || swarmRef.current.length !== paramsRef.current.population) { + lastReset = resetRef.current; + buildSwarm(); + } + const dt = Math.min(0.04, (now - lastTime) / 1000); + lastTime = now; + const active = paramsRef.current; + const swarm = swarmRef.current; + let meanSin = 0; + let meanCos = 0; + for (const fly of swarm) { + meanSin += Math.sin(fly.phase); + meanCos += Math.cos(fly.phase); + } + meanSin /= Math.max(1, swarm.length); + meanCos /= Math.max(1, swarm.length); + const order = Math.hypot(meanSin, meanCos); + const meanPhase = Math.atan2(meanSin, meanCos); + + if (!pausedRef.current) { + for (const fly of swarm) { + const couplingForce = active.coupling * order * Math.sin(meanPhase - fly.phase); + const noiseForce = (Math.random() - 0.5) * active.noise * 2.4; + fly.phase += (fly.omega + couplingForce + noiseForce) * dt * active.speed * 3.1; + fly.x = (fly.x + fly.drift * active.speed + 1) % 1; + fly.y = clamp(fly.y + Math.sin(now * 0.00025 + fly.x * 8) * 0.000035, 0.03, 0.97); + } + } + + context.fillStyle = '#02040a'; + context.fillRect(0, 0, width, height); + const sky = context.createRadialGradient(width * 0.5, height * 0.55, 20, width * 0.5, height * 0.55, Math.max(width, height) * 0.7); + sky.addColorStop(0, `rgba(91, 33, 182, ${0.04 + order * 0.1})`); + sky.addColorStop(0.55, 'rgba(3, 17, 32, 0.25)'); + sky.addColorStop(1, 'rgba(1, 2, 6, 0)'); + context.fillStyle = sky; + context.fillRect(0, 0, width, height); + + for (const fly of swarm) { + const flash = Math.pow(Math.max(0, Math.cos(fly.phase)), 18); + const x = fly.x * width; + const y = fly.y * height; + if (flash > 0.025) { + const glow = context.createRadialGradient(x, y, 0, x, y, 18 + flash * 28); + glow.addColorStop(0, `rgba(236, 253, 120, ${0.92 * flash})`); + glow.addColorStop(0.2, `rgba(125, 249, 255, ${0.7 * flash})`); + glow.addColorStop(1, 'rgba(125, 249, 255, 0)'); + context.fillStyle = glow; + context.beginPath(); + context.arc(x, y, 18 + flash * 28, 0, Math.PI * 2); + context.fill(); + } + context.fillStyle = `rgba(224, 255, 170, ${0.15 + flash * 0.85})`; + context.beginPath(); + context.arc(x, y, fly.size + flash * 2.2, 0, Math.PI * 2); + context.fill(); + } + + if (now - lastScore > 220) { + lastScore = now; + setSync(Math.round(order * 100)); + } + frameId = requestAnimationFrame(render); + } + + frameId = requestAnimationFrame(render); + return () => { + observer.disconnect(); + cancelAnimationFrame(frameId); + }; + }, []); + + function patch(key, value) { + setParams((current) => ({ ...current, [key]: value })); + setNotice('The swarm changed. Find the smallest coupling that still creates one flash.'); + } + + function reset() { + resetRef.current += 1; + setNotice('New fireflies, same rules. Collective order has to emerge again.'); + } + + function nudge() { + const target = Math.random() * Math.PI * 2; + for (const fly of swarmRef.current) fly.phase = target + (Math.random() - 0.5) * 1.5; + setNotice('The swarm received a pulse. Watch whether the shared rhythm survives.'); + } + + const state = [params.coupling, params.noise, params.population, params.speed].map((value) => round(value, 3)).join(','); + + return ( +
+
+
+

Live experiment 002

+

Can a crowd agree without a leader?

+

Each firefly follows only its own clock and the faint rhythm of its neighbours. Synchrony emerges—or collapses—from those local rules.

+
+
Synchrony{sync}{sync > 88 ? 'One living pulse' : sync > 55 ? 'Locking together' : 'Independent clocks'}
+
+ +
+
+ +
dθᵢ/dt = ωᵢ + K r sin(ψ−θᵢ) + noise
+ +
+ +
+ setPaused((value) => !value)} onReset={reset} + onShare={() => shareExperiment({ lab: 'fireflies', state, title: `My firefly sync score: ${sync}`, text: `I reached ${sync}% synchrony in GaugeGap. Can you keep the swarm together with more noise?`, onNotice: setNotice })} + onCopy={() => copyExperiment({ lab: 'fireflies', state, onNotice: setNotice })} notice={notice} /> +
+ ); +} + +const WAVE_PRESETS = [ + { id: 'constructive', label: 'Perfect harmony', wavelength: 34, spacing: 120, phase: 0, speed: 1 }, + { id: 'cancel', label: 'Total cancellation', wavelength: 34, spacing: 120, phase: Math.PI, speed: 1 }, + { id: 'tight', label: 'Fine ripples', wavelength: 18, spacing: 88, phase: 0.7, speed: 1.25 }, + { id: 'ocean', label: 'Deep ocean', wavelength: 52, spacing: 160, phase: 1.4, speed: 0.62 }, +]; + +export function WaveInterferenceLab() { + const shared = useMemo(() => parseState('waves', 4), []); + const [params, setParams] = useState(() => shared ? { + wavelength: clamp(shared[0], 12, 64), + spacing: clamp(shared[1], 30, 190), + phase: clamp(shared[2], 0, Math.PI * 2), + speed: clamp(shared[3], 0.2, 2), + } : WAVE_PRESETS[0]); + const [paused, setPaused] = useState(false); + const [detector, setDetector] = useState({ x: 0.5, y: 0.5, intensity: 0 }); + const detectorRef = useRef({ x: 0.5, y: 0.5, intensity: 0 }); + const [notice, setNotice] = useState(shared ? 'Shared wave field loaded. Move the detector and remix it.' : 'Move through the field and find where two waves disappear.'); + const canvasRef = useRef(null); + const paramsRef = useRef(params); + const pausedRef = useRef(paused); + const timeRef = useRef(0); + const resetRef = useRef(0); + + useEffect(() => { paramsRef.current = params; }, [params]); + useEffect(() => { pausedRef.current = paused; }, [paused]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return undefined; + const context = canvas.getContext('2d', { alpha: false }); + const width = 180; + const height = 112; + canvas.width = width; + canvas.height = height; + const image = context.createImageData(width, height); + let frameId = 0; + let last = performance.now(); + let lastReset = -1; + + function intensityAt(nx, ny, active) { + const px = nx * width; + const py = ny * height; + const s1x = width * 0.5 - active.spacing * 0.22; + const s2x = width * 0.5 + active.spacing * 0.22; + const sy = height * 0.5; + const r1 = Math.hypot(px - s1x, py - sy); + const r2 = Math.hypot(px - s2x, py - sy); + const k = (Math.PI * 2) / active.wavelength; + return (Math.sin(k * r1 - timeRef.current) + Math.sin(k * r2 - timeRef.current + active.phase)) * 0.5; + } + + function render(now) { + if (lastReset !== resetRef.current) { + lastReset = resetRef.current; + timeRef.current = 0; + } + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + const active = paramsRef.current; + if (!pausedRef.current) timeRef.current += dt * active.speed * 4.2; + + const data = image.data; + let offset = 0; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const value = intensityAt(x / width, y / height, active); + const power = Math.abs(value); + const positive = value > 0; + data[offset] = positive ? 40 + power * 100 : 16 + power * 210; + data[offset + 1] = positive ? 110 + power * 145 : 20 + power * 45; + data[offset + 2] = positive ? 150 + power * 105 : 100 + power * 155; + data[offset + 3] = 255; + offset += 4; + } + } + context.putImageData(image, 0, 0); + + const activeDetector = detectorRef.current; + const dx = activeDetector.x * width; + const dy = activeDetector.y * height; + context.strokeStyle = '#ffffff'; + context.lineWidth = 0.8; + context.beginPath(); + context.arc(dx, dy, 4.5, 0, Math.PI * 2); + context.stroke(); + context.beginPath(); + context.moveTo(dx - 7, dy); + context.lineTo(dx + 7, dy); + context.moveTo(dx, dy - 7); + context.lineTo(dx, dy + 7); + context.stroke(); + + frameId = requestAnimationFrame(render); + } + + frameId = requestAnimationFrame(render); + return () => cancelAnimationFrame(frameId); + }, []); + + function moveDetector(event) { + const rect = event.currentTarget.getBoundingClientRect(); + const x = clamp((event.clientX - rect.left) / rect.width, 0, 1); + const y = clamp((event.clientY - rect.top) / rect.height, 0, 1); + const active = paramsRef.current; + const width = 180; + const height = 112; + const px = x * width; + const py = y * height; + const s1x = width * 0.5 - active.spacing * 0.22; + const s2x = width * 0.5 + active.spacing * 0.22; + const sy = height * 0.5; + const k = (Math.PI * 2) / active.wavelength; + const value = (Math.sin(k * Math.hypot(px - s1x, py - sy) - timeRef.current) + + Math.sin(k * Math.hypot(px - s2x, py - sy) - timeRef.current + active.phase)) * 0.5; + const nextDetector = { x, y, intensity: value }; + detectorRef.current = nextDetector; + setDetector(nextDetector); + } + + function patch(key, value) { + setParams((current) => ({ ...current, [key]: value })); + setNotice('The interference map changed instantly. Hunt for a silent node.'); + } + + const visibility = Math.round(Math.abs(detector.intensity) * 100); + const state = [params.wavelength, params.spacing, params.phase, params.speed].map((value) => round(value, 3)).join(','); + + return ( +
+
+
+

Live experiment 003

+

Make two waves erase each other.

+

Two sources fill the same space. Their peaks can amplify into brilliance—or meet a trough and vanish.

+
+
Detector energy{visibility}{visibility < 8 ? 'Near-perfect silence' : visibility > 85 ? 'Maximum reinforcement' : 'Mixed interference'}
+
+ +
+
+ +
I = sin(kr₁−ωt) + sin(kr₂−ωt+φ)
+
Move the detector through the field
+
+ +
+ setPaused((value) => !value)} onReset={() => { resetRef.current += 1; setNotice('Time reset. The geometry stays the same.'); }} + onShare={() => shareExperiment({ lab: 'waves', state, title: `I found a ${visibility}% wave node`, text: 'I built a two-source interference field in GaugeGap. Can you find a quieter point?', onNotice: setNotice })} + onCopy={() => copyExperiment({ lab: 'waves', state, onNotice: setNotice })} notice={notice} /> +
+ ); +} + +const REACTION_PRESETS = [ + { id: 'coral', label: 'Electric coral', feed: 0.0545, kill: 0.062, speed: 4, brush: 6 }, + { id: 'mitosis', label: 'Cell division', feed: 0.0367, kill: 0.0649, speed: 5, brush: 5 }, + { id: 'maze', label: 'Living maze', feed: 0.029, kill: 0.057, speed: 5, brush: 7 }, + { id: 'worms', label: 'Neon worms', feed: 0.078, kill: 0.061, speed: 4, brush: 4 }, +]; + +export function ReactionDiffusionLab() { + const shared = useMemo(() => parseState('reaction', 4), []); + const [params, setParams] = useState(() => shared ? { + feed: clamp(shared[0], 0.01, 0.09), + kill: clamp(shared[1], 0.045, 0.075), + speed: Math.round(clamp(shared[2], 1, 8)), + brush: Math.round(clamp(shared[3], 2, 12)), + } : REACTION_PRESETS[0]); + const [paused, setPaused] = useState(false); + const [complexity, setComplexity] = useState(0); + const [notice, setNotice] = useState(shared ? 'Shared chemistry loaded. Draw into it and force a new species.' : 'Drag across the field. A tiny chemical seed becomes a living pattern.'); + const canvasRef = useRef(null); + const paramsRef = useRef(params); + const pausedRef = useRef(paused); + const fieldRef = useRef(null); + const resetRef = useRef(0); + const drawingRef = useRef(false); + + useEffect(() => { paramsRef.current = params; }, [params]); + useEffect(() => { pausedRef.current = paused; }, [paused]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return undefined; + const context = canvas.getContext('2d', { alpha: false }); + const width = 144; + const height = 92; + canvas.width = width; + canvas.height = height; + let frameId = 0; + let lastReset = -1; + let lastScore = 0; + const image = context.createImageData(width, height); + + function initialize() { + const size = width * height; + const a = new Float32Array(size); + const b = new Float32Array(size); + const nextA = new Float32Array(size); + const nextB = new Float32Array(size); + a.fill(1); + for (let seed = 0; seed < 8; seed += 1) { + const cx = Math.floor(width * (0.2 + Math.random() * 0.6)); + const cy = Math.floor(height * (0.2 + Math.random() * 0.6)); + for (let oy = -4; oy <= 4; oy += 1) { + for (let ox = -4; ox <= 4; ox += 1) { + const x = (cx + ox + width) % width; + const y = (cy + oy + height) % height; + b[x + y * width] = 0.82 + Math.random() * 0.18; + } + } + } + fieldRef.current = { a, b, nextA, nextB }; + } + + function laplacian(field, x, y) { + const left = (x - 1 + width) % width; + const right = (x + 1) % width; + const up = (y - 1 + height) % height; + const down = (y + 1) % height; + return field[left + y * width] * 0.2 + + field[right + y * width] * 0.2 + + field[x + up * width] * 0.2 + + field[x + down * width] * 0.2 + + field[left + up * width] * 0.05 + + field[right + up * width] * 0.05 + + field[left + down * width] * 0.05 + + field[right + down * width] * 0.05 + - field[x + y * width]; + } + + function step() { + const field = fieldRef.current; + if (!field) return; + const active = paramsRef.current; + const da = 1; + const db = 0.5; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = x + y * width; + const av = field.a[index]; + const bv = field.b[index]; + const reaction = av * bv * bv; + field.nextA[index] = clamp(av + (da * laplacian(field.a, x, y) - reaction + active.feed * (1 - av)), 0, 1); + field.nextB[index] = clamp(bv + (db * laplacian(field.b, x, y) + reaction - (active.kill + active.feed) * bv), 0, 1); + } + } + [field.a, field.nextA] = [field.nextA, field.a]; + [field.b, field.nextB] = [field.nextB, field.b]; + } + + initialize(); + + function render(now) { + if (lastReset !== resetRef.current) { + lastReset = resetRef.current; + initialize(); + } + if (!pausedRef.current) { + for (let pass = 0; pass < paramsRef.current.speed; pass += 1) step(); + } + const field = fieldRef.current; + const data = image.data; + let activeCells = 0; + for (let index = 0; index < field.b.length; index += 1) { + const value = clamp(field.a[index] - field.b[index], 0, 1); + const chemical = field.b[index]; + if (chemical > 0.18 && chemical < 0.82) activeCells += 1; + const offset = index * 4; + data[offset] = 10 + (1 - value) * 210; + data[offset + 1] = 18 + chemical * 235; + data[offset + 2] = 42 + value * 195; + data[offset + 3] = 255; + } + context.putImageData(image, 0, 0); + if (now - lastScore > 260) { + lastScore = now; + setComplexity(Math.round((activeCells / field.b.length) * 180)); + } + frameId = requestAnimationFrame(render); + } + + frameId = requestAnimationFrame(render); + return () => cancelAnimationFrame(frameId); + }, []); + + function paint(event) { + if (!drawingRef.current && event.type === 'pointermove') return; + const rect = event.currentTarget.getBoundingClientRect(); + const width = 144; + const height = 92; + const cx = Math.floor(clamp((event.clientX - rect.left) / rect.width, 0, 0.999) * width); + const cy = Math.floor(clamp((event.clientY - rect.top) / rect.height, 0, 0.999) * height); + const radius = paramsRef.current.brush; + const field = fieldRef.current; + if (!field) return; + for (let oy = -radius; oy <= radius; oy += 1) { + for (let ox = -radius; ox <= radius; ox += 1) { + if (ox * ox + oy * oy > radius * radius) continue; + const x = (cx + ox + width) % width; + const y = (cy + oy + height) % height; + field.b[x + y * width] = 1; + field.a[x + y * width] = 0.15; + } + } + } + + function patch(key, value) { + setParams((current) => ({ ...current, [key]: value })); + setNotice(key === 'brush' ? 'Brush resized. Draw a new chemical seed.' : 'Reaction rules changed. The old pattern must adapt or die.'); + } + + const state = [params.feed, params.kill, params.speed, params.brush].map((value) => round(value, 4)).join(','); + + return ( +
+
+
+

Live experiment 004

+

Draw a creature from two chemicals.

+

No image is stored here. Spots, worms, cells and coral continuously build themselves from reaction and diffusion.

+
+
Pattern complexity{complexity}{complexity > 70 ? 'Dense living texture' : complexity > 35 ? 'Structures emerging' : 'Chemistry settling'}
+
+ +
+
+ { drawingRef.current = true; event.currentTarget.setPointerCapture?.(event.pointerId); paint(event); }} + onPointerMove={paint} + onPointerUp={(event) => { drawingRef.current = false; event.currentTarget.releasePointerCapture?.(event.pointerId); }} + onPointerCancel={() => { drawingRef.current = false; }} + aria-label="Interactive Gray-Scott reaction diffusion simulation" /> +
∂A/∂t = Dₐ∇²A − AB² + f(1−A)
+
Draw directly into the chemistry
+
+ +
+ setPaused((value) => !value)} onReset={() => { resetRef.current += 1; setNotice('Fresh chemistry seeded. The pattern starts from nothing again.'); }} + onShare={() => shareExperiment({ lab: 'reaction', state, title: `My living chemistry scored ${complexity}`, text: 'I grew a reaction-diffusion pattern in GaugeGap. Can you make a stranger species?', onNotice: setNotice })} + onCopy={() => copyExperiment({ lab: 'reaction', state, onNotice: setNotice })} notice={notice} /> +
+ ); +} From 3c93d8eeb7f4ca594d427905e3f3f4f7bed56551 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 9 Jul 2026 23:03:25 -0400 Subject: [PATCH 2/4] feat: add GaugeGap science arcade selector --- .../features/gaugegap/ExperimentArcade.jsx | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 brainsnn-r3f-app/src/features/gaugegap/ExperimentArcade.jsx diff --git a/brainsnn-r3f-app/src/features/gaugegap/ExperimentArcade.jsx b/brainsnn-r3f-app/src/features/gaugegap/ExperimentArcade.jsx new file mode 100644 index 0000000..713bee1 --- /dev/null +++ b/brainsnn-r3f-app/src/features/gaugegap/ExperimentArcade.jsx @@ -0,0 +1,143 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Activity, Dna, Gauge, Shuffle, Sparkles, Waves } from 'lucide-react'; +import { AttractorPlayground } from './AttractorPlayground.jsx'; +import { FireflySyncLab, ReactionDiffusionLab, WaveInterferenceLab } from './CollectivePlaygrounds.jsx'; +import '../../styles/arcade.css'; + +const EXPERIMENTS = [ + { + id: 'attractor', + number: '001', + title: 'Butterfly Effect', + short: 'Shape chaos', + description: 'Tune a Lorenz system until order and turbulence balance on the same orbit.', + icon: Gauge, + accent: 'cyan', + mechanic: 'Tune', + }, + { + id: 'fireflies', + number: '002', + title: 'Firefly Sync', + short: 'Create collective rhythm', + description: 'Give hundreds of independent clocks one weak connection and watch a shared pulse emerge.', + icon: Sparkles, + accent: 'lime', + mechanic: 'Synchronize', + }, + { + id: 'waves', + number: '003', + title: 'Wave Eraser', + short: 'Find perfect silence', + description: 'Move through two overlapping waves and hunt for the places where energy disappears.', + icon: Waves, + accent: 'violet', + mechanic: 'Explore', + }, + { + id: 'reaction', + number: '004', + title: 'Living Chemistry', + short: 'Draw a new species', + description: 'Paint two virtual chemicals and grow coral, cells, worms and mazes from local reactions.', + icon: Dna, + accent: 'pink', + mechanic: 'Create', + }, +]; + +function initialExperiment() { + if (typeof window === 'undefined') return 'attractor'; + const requested = new URLSearchParams(window.location.search).get('lab'); + return EXPERIMENTS.some((experiment) => experiment.id === requested) ? requested : 'attractor'; +} + +export function ExperimentArcade() { + const [active, setActive] = useState(initialExperiment); + const activeExperiment = useMemo(() => EXPERIMENTS.find((experiment) => experiment.id === active) || EXPERIMENTS[0], [active]); + + useEffect(() => { + function selectFromEvent(event) { + const next = event.detail?.lab; + if (EXPERIMENTS.some((experiment) => experiment.id === next)) setActive(next); + } + window.addEventListener('gaugegap:lab', selectFromEvent); + return () => window.removeEventListener('gaugegap:lab', selectFromEvent); + }, []); + + function selectExperiment(id, scroll = true) { + setActive(id); + const url = new URL(window.location.href); + url.searchParams.set('lab', id); + if (id !== 'attractor') url.searchParams.delete('run'); + url.searchParams.delete('state'); + url.hash = 'playground'; + window.history.replaceState({}, '', url); + if (scroll) { + window.requestAnimationFrame(() => document.getElementById('playground')?.scrollIntoView({ behavior: 'smooth', block: 'start' })); + } + } + + function surpriseMe() { + const available = EXPERIMENTS.filter((experiment) => experiment.id !== active); + selectExperiment(available[Math.floor(Math.random() * available.length)].id); + } + + return ( +
+
+
+

GaugeGap science arcade

+

Four experiments. Four different ways to play.

+

Tune chaos, synchronize a crowd, erase a wave or draw living chemistry. Every lab starts in seconds and hides the real model one layer underneath.

+
+ +
+ +
+ {EXPERIMENTS.map((experiment) => { + const Icon = experiment.icon; + const selected = experiment.id === active; + return ( + + ); + })} +
+ +
+
+ Experiment {activeExperiment.number} loaded + {activeExperiment.title} +
+ {active === 'attractor' ? : null} + {active === 'fireflies' ? : null} + {active === 'waves' ? : null} + {active === 'reaction' ? : null} +
+ +
+ What we borrowed from the best interactive-science sites + Immediate motion + Recognizable presets + One clear challenge + Different interaction styles + A shareable result +
+
+ ); +} From 9f3796496e7d96862c058ca1903a8ae2663078f2 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 9 Jul 2026 23:04:09 -0400 Subject: [PATCH 3/4] style: add responsive science arcade visual system --- brainsnn-r3f-app/src/styles/arcade.css | 530 +++++++++++++++++++++++++ 1 file changed, 530 insertions(+) create mode 100644 brainsnn-r3f-app/src/styles/arcade.css diff --git a/brainsnn-r3f-app/src/styles/arcade.css b/brainsnn-r3f-app/src/styles/arcade.css new file mode 100644 index 0000000..b364dbb --- /dev/null +++ b/brainsnn-r3f-app/src/styles/arcade.css @@ -0,0 +1,530 @@ +.gg-arcade { + position: relative; + z-index: 1; + width: min(100% - 36px, 1320px); + margin: 0 auto; + padding: clamp(76px, 9vw, 128px) 0 40px; + scroll-margin-top: 20px; +} + +.gg-arcade-intro, +.gg-sim-heading, +.gg-arcade-toolbar, +.gg-arcade-stage-topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.gg-arcade-intro { margin-bottom: 28px; } + +.gg-arcade-intro h2, +.gg-sim-heading h2 { + margin: 10px 0 10px; + font-family: var(--bsn-font-display); + letter-spacing: -0.035em; + line-height: 1; +} + +.gg-arcade-intro h2 { + max-width: 760px; + font-size: clamp(2.45rem, 5vw, 4.9rem); +} + +.gg-arcade-intro > div > p:last-child, +.gg-sim-heading > div > p:last-child { + max-width: 760px; + margin: 0; + color: var(--bsn-text-secondary); + font-size: 1.04rem; + line-height: 1.65; +} + +.gg-surprise-button { + flex: 0 0 auto; + min-height: 48px; + display: inline-flex; + align-items: center; + gap: 9px; + padding: 0 18px; + border: 1px solid rgba(125, 249, 255, 0.24); + border-radius: 999px; + color: #dffcff; + background: rgba(125, 249, 255, 0.055); + font-weight: 850; + cursor: pointer; +} + +.gg-surprise-button:hover { + border-color: rgba(125, 249, 255, 0.65); + background: rgba(125, 249, 255, 0.11); +} + +.gg-arcade-selector { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 13px; + margin-bottom: 22px; +} + +.gg-arcade-card { + --arcade-accent: #7df9ff; + position: relative; + min-width: 0; + min-height: 224px; + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto auto 1fr auto; + column-gap: 12px; + padding: 18px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 20px; + overflow: hidden; + color: var(--bsn-text); + background: radial-gradient(circle at 95% 0%, rgba(125, 249, 255, 0.12), transparent 38%), rgba(10, 10, 20, 0.78); + text-align: left; + cursor: pointer; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.gg-arcade-card::after { + content: ""; + position: absolute; + inset: auto 14px 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--arcade-accent), transparent); + opacity: 0; + transition: opacity 180ms ease; +} + +.gg-arcade-card:hover, +.gg-arcade-card.active { + transform: translateY(-3px); + border-color: var(--arcade-accent); + background: radial-gradient(circle at 95% 0%, rgba(125, 249, 255, 0.2), transparent 42%), rgba(12, 12, 25, 0.94); +} + +.gg-arcade-card.active::after { opacity: 1; } +.gg-arcade-card-cyan { --arcade-accent: #7df9ff; } +.gg-arcade-card-lime { --arcade-accent: #d9ff65; } +.gg-arcade-card-violet { --arcade-accent: #a78bfa; } +.gg-arcade-card-pink { --arcade-accent: #ff5edb; } + +.gg-arcade-card-number { + position: absolute; + top: 14px; + right: 15px; + color: rgba(255,255,255,0.32); + font-family: var(--bsn-font-mono); + font-size: 0.68rem; +} + +.gg-arcade-card-icon { + grid-row: 1 / span 2; + width: 42px; + height: 42px; + display: grid; + place-items: center; + border: 1px solid var(--arcade-accent); + border-radius: 13px; + color: var(--arcade-accent); + background: rgba(125, 249, 255, 0.05); + box-shadow: 0 0 24px rgba(125, 249, 255, 0.08); +} + +.gg-arcade-card strong { + align-self: end; + padding-right: 26px; + font-family: var(--bsn-font-display); + font-size: 1.12rem; +} + +.gg-arcade-card small { + color: var(--arcade-accent); + font-family: var(--bsn-font-mono); + font-size: 0.69rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.gg-arcade-card p { + grid-column: 1 / -1; + margin: 18px 0 12px; + color: var(--bsn-text-secondary); + font-size: 0.88rem; + line-height: 1.5; +} + +.gg-arcade-card em { + grid-column: 1 / -1; + align-self: end; + width: fit-content; + padding: 5px 9px; + border-radius: 999px; + color: #050509; + background: var(--arcade-accent); + font-family: var(--bsn-font-mono); + font-size: 0.67rem; + font-style: normal; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.gg-arcade-stage { + position: relative; + padding: 12px; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 28px; + background: radial-gradient(circle at 50% 0%, rgba(125, 249, 255, 0.06), transparent 38%), rgba(2, 3, 9, 0.86); + box-shadow: 0 32px 100px rgba(0,0,0,0.38); +} + +.gg-arcade-stage-topline { + min-height: 42px; + padding: 0 10px 8px; + color: var(--bsn-text-muted); + font-family: var(--bsn-font-mono); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.gg-arcade-stage-topline span, +.gg-arcade-stage-topline strong { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.gg-arcade-stage-topline i { + width: 7px; + height: 7px; + border-radius: 50%; + background: #7df9ff; + box-shadow: 0 0 14px rgba(125,249,255,0.9); +} + +.gg-arcade-stage-topline strong { color: #e7fbff; } + +.gg-arcade-stage .gg-playground { + width: 100%; + margin: 0; + padding: 18px 0 0; +} + +.gg-arcade-stage .gg-playground-head, +.gg-arcade-stage .gg-lab-frame, +.gg-arcade-stage .gg-playground-actions { width: 100%; } + +.gg-arcade-lesson { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + flex-wrap: wrap; + padding: 24px 20px 0; + color: var(--bsn-text-muted); + font-size: 0.8rem; +} + +.gg-arcade-lesson span { + color: var(--bsn-cyan); + font-family: var(--bsn-font-mono); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.gg-arcade-lesson strong { + padding: 7px 11px; + border: 1px solid rgba(255,255,255,0.08); + border-radius: 999px; + color: var(--bsn-text-secondary); + background: rgba(255,255,255,0.025); + font-weight: 750; +} + +.gg-sim-lab { + padding: clamp(18px, 3vw, 34px); + border-radius: 22px; + background: radial-gradient(circle at 75% 0%, rgba(139, 92, 246, 0.08), transparent 34%), linear-gradient(180deg, rgba(10, 10, 22, 0.96), rgba(4, 5, 12, 0.98)); +} + +.gg-sim-heading { + align-items: flex-end; + margin-bottom: 24px; +} + +.gg-sim-heading h2 { font-size: clamp(2rem, 4vw, 3.75rem); } + +.gg-sim-score { + flex: 0 0 180px; + padding: 16px 18px; + border: 1px solid rgba(125,249,255,0.16); + border-radius: 17px; + background: rgba(125,249,255,0.035); +} + +.gg-sim-score span, +.gg-sim-score small { + display: block; + color: var(--bsn-text-muted); + font-family: var(--bsn-font-mono); + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.gg-sim-score strong { + display: block; + margin: 6px 0; + color: #e8ffff; + font-family: var(--bsn-font-mono); + font-size: 2.5rem; + line-height: 1; +} + +.gg-sim-score small { + color: var(--bsn-cyan); + letter-spacing: 0; + text-transform: none; +} + +.gg-sim-frame { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(270px, 0.55fr); + gap: 18px; +} + +.gg-sim-canvas-wrap { + position: relative; + min-width: 0; + min-height: 560px; + overflow: hidden; + border: 1px solid rgba(125,249,255,0.13); + border-radius: 20px; + background: #02040a; + box-shadow: inset 0 0 65px rgba(0,0,0,0.75); +} + +.gg-sim-canvas { + width: 100%; + height: 100%; + min-height: 560px; + display: block; + touch-action: none; +} + +.gg-wave-canvas, +.gg-pixel-canvas { + image-rendering: auto; + cursor: crosshair; +} + +.gg-pixel-canvas { image-rendering: pixelated; } + +.gg-sim-canvas-label { + position: absolute; + left: 14px; + bottom: 13px; + max-width: calc(100% - 28px); + padding: 6px 9px; + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + color: rgba(220, 252, 255, 0.7); + background: rgba(2, 4, 10, 0.72); + backdrop-filter: blur(8px); + font-family: var(--bsn-font-mono); + font-size: 0.68rem; +} + +.gg-sim-float-action, +.gg-wave-hint { + position: absolute; + top: 14px; + right: 14px; + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 39px; + padding: 0 12px; + border: 1px solid rgba(217, 255, 101, 0.28); + border-radius: 999px; + color: #edffb3; + background: rgba(4, 9, 12, 0.72); + backdrop-filter: blur(8px); + font-size: 0.74rem; + font-weight: 850; +} + +.gg-sim-float-action { cursor: pointer; } + +.gg-wave-hint { + left: 14px; + right: auto; + border-color: rgba(167, 139, 250, 0.3); + color: #ddd6fe; + pointer-events: none; +} + +.gg-sim-controls { + display: grid; + align-content: start; + gap: 15px; + padding: 17px; + border: 1px solid rgba(255,255,255,0.08); + border-radius: 20px; + background: rgba(255,255,255,0.025); +} + +.gg-sim-presets { + display: grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap: 8px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(255,255,255,0.07); +} + +.gg-sim-presets button { + min-height: 38px; + padding: 7px 9px; + border: 1px solid rgba(255,255,255,0.09); + border-radius: 9px; + color: var(--bsn-text-secondary); + background: rgba(255,255,255,0.025); + font-size: 0.72rem; + font-weight: 800; + cursor: pointer; +} + +.gg-sim-presets button:hover { + border-color: rgba(125,249,255,0.32); + color: #e7ffff; + background: rgba(125,249,255,0.06); +} + +.gg-arcade-range { display: grid; gap: 9px; } + +.gg-arcade-range > span { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.gg-arcade-range strong, +.gg-arcade-range output { font-size: 0.75rem; } +.gg-arcade-range strong { color: var(--bsn-text-secondary); } +.gg-arcade-range output { color: var(--bsn-cyan); font-family: var(--bsn-font-mono); } +.gg-arcade-range input { width: 100%; accent-color: #7df9ff; } + +.gg-sim-mission { + margin-top: 4px; + padding: 13px; + border: 1px solid rgba(255, 94, 219, 0.16); + border-radius: 13px; + background: rgba(255, 94, 219, 0.035); +} + +.gg-sim-mission strong { + color: #ff9ee9; + font-family: var(--bsn-font-mono); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.gg-sim-mission p { + margin: 7px 0 0; + color: var(--bsn-text-secondary); + font-size: 0.8rem; + line-height: 1.45; +} + +.gg-arcade-toolbar { + margin-top: 17px; + padding-top: 16px; + border-top: 1px solid rgba(255,255,255,0.07); +} + +.gg-arcade-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +.gg-arcade-toolbar-actions button { + min-height: 39px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 12px; + border: 1px solid rgba(255,255,255,0.09); + border-radius: 9px; + color: var(--bsn-text-secondary); + background: rgba(255,255,255,0.025); + font-size: 0.73rem; + font-weight: 800; + cursor: pointer; +} + +.gg-arcade-toolbar-actions button:hover { + border-color: rgba(125,249,255,0.3); + color: #e7ffff; +} + +.gg-arcade-toolbar > p { + margin: 0; + max-width: 490px; + color: var(--bsn-text-muted); + font-size: 0.78rem; + line-height: 1.45; + text-align: right; +} + +@media (max-width: 1050px) { + .gg-arcade-selector { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .gg-sim-frame { grid-template-columns: 1fr; } + .gg-sim-canvas-wrap, + .gg-sim-canvas { min-height: 500px; } + .gg-sim-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .gg-sim-presets, + .gg-sim-mission { grid-column: 1 / -1; } +} + +@media (max-width: 720px) { + .gg-arcade { + width: min(100% - 22px, 1320px); + padding-top: 68px; + } + + .gg-arcade-intro, + .gg-sim-heading, + .gg-arcade-toolbar { + align-items: stretch; + flex-direction: column; + } + + .gg-surprise-button { width: fit-content; } + + .gg-arcade-selector { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + padding: 0 1px 8px; + } + + .gg-arcade-card { + flex: 0 0 min(78vw, 300px); + scroll-snap-align: start; + } + + .gg-arcade-stage { padding: 7px; border-radius: 20px; } + .gg-arcade-stage-topline { padding-inline: 7px; } + .gg-sim-lab { padding: 18px 12px; } + .gg-sim-score { flex-basis: auto; } + .gg-sim-canvas-wrap, + .gg-sim-canvas { min-height: 410px; } + .gg-sim-controls { grid-template-columns: 1fr; padding: 14px; } + .gg-sim-presets, + .gg-sim-mission { grid-column: auto; } + .gg-arcade-toolbar > p { text-align: left; } + .gg-arcade-lesson { justify-content: flex-start; padding-inline: 6px; } +} From 092cd9fedd000b910b6f0302156d907844fe0b27 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 9 Jul 2026 23:05:26 -0400 Subject: [PATCH 4/4] feat: wire science arcade into GaugeGap homepage --- brainsnn-r3f-app/src/app/GaugeGapLanding.jsx | 105 +++++++++++-------- 1 file changed, 64 insertions(+), 41 deletions(-) diff --git a/brainsnn-r3f-app/src/app/GaugeGapLanding.jsx b/brainsnn-r3f-app/src/app/GaugeGapLanding.jsx index fa64288..e9508bb 100644 --- a/brainsnn-r3f-app/src/app/GaugeGapLanding.jsx +++ b/brainsnn-r3f-app/src/app/GaugeGapLanding.jsx @@ -1,22 +1,23 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect } from 'react'; import { ArrowRight, BrainCircuit, CheckCircle2, ChevronDown, + Dna, FlaskConical, Gauge, Layers3, - LockKeyhole, Microscope, Play, Share2, Sparkles, WandSparkles, + Waves, Zap, } from 'lucide-react'; import { Button } from '../components/ui/Button.jsx'; -import { AttractorPlayground } from '../features/gaugegap/AttractorPlayground.jsx'; +import { ExperimentArcade } from '../features/gaugegap/ExperimentArcade.jsx'; import { track } from '../lib/analytics.js'; import '../styles/gaugegap.css'; @@ -25,11 +26,35 @@ const CONTENT_SAMPLE = 'Everyone says this breakthrough changes everything. Here const LABS = [ { id: 'attractor', - eyebrow: 'Live now', + eyebrow: 'Live experiment 001', title: 'Butterfly Effect Lab', - description: 'Change the rules of a chaotic system, capture the result and challenge someone to beat your discovery score.', + description: 'Tune a chaotic system until order and turbulence balance on the same orbit.', icon: Gauge, - action: 'Play now', + action: 'Shape chaos', + }, + { + id: 'fireflies', + eyebrow: 'Live experiment 002', + title: 'Firefly Sync Lab', + description: 'Connect hundreds of independent clocks and watch collective rhythm emerge without a leader.', + icon: Sparkles, + action: 'Sync the swarm', + }, + { + id: 'waves', + eyebrow: 'Live experiment 003', + title: 'Wave Eraser', + description: 'Move a detector through overlapping waves and find the exact places where energy disappears.', + icon: Waves, + action: 'Find silence', + }, + { + id: 'reaction', + eyebrow: 'Live experiment 004', + title: 'Living Chemistry', + description: 'Draw into a reaction-diffusion field and grow coral, cells, worms and mazes from two chemicals.', + icon: Dna, + action: 'Grow a species', }, { id: 'soliton', @@ -47,25 +72,17 @@ const LABS = [ icon: BrainCircuit, action: 'Scan a viral claim', }, - { - id: 'claim', - eyebrow: 'Coming next', - title: 'Claim Boundary Game', - description: 'Build the strongest claim the evidence allows. Push too far and the model breaks your argument in public.', - icon: LockKeyhole, - action: 'Join the first run', - }, ]; const LOOP = [ { number: '01', title: 'Play', text: 'Touch the variables before reading the lesson. The system teaches through response.' }, { number: '02', title: 'Discover', text: 'Find an unusual state, score it and preserve the exact parameters that produced it.' }, - { number: '03', title: 'Publish', text: 'Export the visual, the short clip, the challenge link and the explanation from one run.' }, + { number: '03', title: 'Publish', text: 'Export the visual, the challenge link and the explanation from one run.' }, ]; -export function GaugeGapLanding({ onStart, onNavigate, onOpenReconstruct }) { - const playgroundRef = useRef(null); +const ARCADE_IDS = new Set(['attractor', 'fireflies', 'waves', 'reaction']); +export function GaugeGapLanding({ onStart, onNavigate, onOpenReconstruct }) { useEffect(() => { document.title = 'GaugeGap Foundry | Play with the impossible'; track('gaugegap_landing_viewed'); @@ -78,10 +95,18 @@ export function GaugeGapLanding({ onStart, onNavigate, onOpenReconstruct }) { function openLab(id) { track('gaugegap_lab_clicked', { labId: id }); - if (id === 'attractor') scrollToPlayground(); + if (ARCADE_IDS.has(id)) { + const url = new URL(window.location.href); + url.searchParams.set('lab', id); + url.searchParams.delete('run'); + url.searchParams.delete('state'); + url.hash = 'playground'; + window.history.replaceState({}, '', url); + window.dispatchEvent(new CustomEvent('gaugegap:lab', { detail: { lab: id } })); + scrollToPlayground(); + } if (id === 'soliton') onNavigate?.('research'); if (id === 'content') onStart?.(CONTENT_SAMPLE); - if (id === 'claim') onOpenReconstruct?.(); } return ( @@ -96,7 +121,7 @@ export function GaugeGapLanding({ onStart, onNavigate, onOpenReconstruct }) { Alpha @@ -115,18 +140,18 @@ export function GaugeGapLanding({ onStart, onNavigate, onOpenReconstruct }) {

Science you can touch, remix and publish

Don’t just learn the universe. Play with it.

- GaugeGap turns difficult research into live experiments, visual challenges and shareable discoveries. - Change one rule. Watch reality respond. Publish what you find. + Four live experiments turn difficult science into visual games and shareable challenges. + Shape chaos, synchronize a crowd, erase a wave or draw a new species.

- +
-
LiveBrowser simulations
-
1-clickPosters and clips
+
4 liveDistinct experiments
+
1-clickChallenge links
ExactShareable run states
-
OpenAssumptions and limits
+
OpenEquations and limits
@@ -157,20 +182,18 @@ export function GaugeGapLanding({ onStart, onNavigate, onOpenReconstruct }) {

Find the most beautiful edge of chaos.

- Current community score to beat - 90+ + Then try three completely different systems + 4 labs
- -
- -
+
@@ -190,9 +213,9 @@ export function GaugeGapLanding({ onStart, onNavigate, onOpenReconstruct }) {
One run creates Interactive link - 6-second clip - Poster + Visual loop Score challenge + Lesson hook Research state
@@ -209,7 +232,7 @@ export function GaugeGapLanding({ onStart, onNavigate, onOpenReconstruct }) { {LABS.map((lab, index) => { const Icon = lab.icon; return ( -
+