initial commit (first version of game)

This commit is contained in:
Gabriel Franco 2026-09-10 10:15:02 -03:00
commit 467447b51a
10 changed files with 1774 additions and 0 deletions

112
index.html Normal file
View file

@ -0,0 +1,112 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Theme Guess — Pixel Palette</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="app">
<header class="topbar">
<div class="brand">
<span class="brand-swatches">
<i style="background:#fb4934"></i><i style="background:#b8bb26"></i>
<i style="background:#fabd2f"></i><i style="background:#83a598"></i>
</span>
<h1>Theme<span>Guess</span></h1>
</div>
<button id="help-btn" class="theme-badge" title="Change theme / how to play">
🎮 <strong id="theme-name-badge">Gruvbox Dark</strong>
</button>
<div class="topbar-actions">
<button id="mute-btn" class="icon-btn" title="Mute sound" aria-label="Mute sound">🔊</button>
</div>
</header>
<div class="progress-wrap">
<div class="progress-track"><div id="progress-fill" class="progress-fill"></div></div>
<span id="progress-label">0 / 12 TOKEN KINDS THEMED</span>
</div>
<main class="board">
<div class="canvas-wrap">
<canvas id="code-canvas"></canvas>
<div id="picker-popover" class="popover hidden">
<div class="popover-arrow"></div>
<div class="popover-label" id="popover-label">Keyword</div>
<div class="popover-row">
<input type="color" id="popover-color" value="#ffffff" />
<input type="text" id="popover-hex" maxlength="7" spellcheck="false" autocomplete="off" />
</div>
<div class="popover-actions">
<button id="popover-cancel" class="btn ghost small">Cancel</button>
<button id="popover-apply" class="btn primary small">Apply</button>
</div>
</div>
</div>
<aside class="panel-col" aria-label="Token kinds">
<div id="category-panel" class="panel"></div>
<div class="panel-actions">
<button id="reset-btn" class="btn ghost">↺ RESET COLORS</button>
<button id="reveal-btn" class="btn reveal" disabled>REVEAL MATCH ✨</button>
</div>
</aside>
</main>
</div>
<div id="intro-modal" class="modal-overlay">
<div class="modal">
<h2>🕹️ THEME GUESS</h2>
<p>A snippet of code is hiding a classic color theme.</p>
<ol>
<li>Click any word in the code — every token of that <em>kind</em> lights up together.</li>
<li>Pick a color for that kind. Watch it paint everywhere at once.</li>
<li>Repeat until all 12 kinds have a color, including the background.</li>
<li>Hit <strong>REVEAL MATCH</strong> to see how close your palette lands to the real thing.</li>
</ol>
<p class="pick-theme-label">▼ PICK YOUR TARGET THEME ▼</p>
<div id="theme-grid" class="theme-grid"></div>
<button id="start-btn" class="btn primary big">▶ PRESS START</button>
</div>
</div>
<div id="result-modal" class="modal-overlay hidden">
<div class="modal result-modal">
<h2>MATCH RESULT</h2>
<div class="score-meter-wrap">
<div class="score-meter-number" id="score-number">0%</div>
<div class="score-meter-track" id="score-meter"></div>
</div>
<div id="score-verdict" class="verdict"></div>
<div id="score-breakdown" class="breakdown"></div>
<div class="compare-toggle" id="compare-toggle">
<button data-mode="yours" class="toggle-btn active">Your Palette</button>
<button data-mode="original" class="toggle-btn">Original</button>
</div>
<div class="modal-actions">
<button id="close-result-btn" class="btn ghost">Keep Comparing</button>
<button id="change-theme-btn" class="btn ghost">🔄 Change Theme</button>
<button id="play-again-btn" class="btn primary">Play Again</button>
</div>
</div>
</div>
<div id="preview-overlay" class="modal-overlay hidden">
<div class="preview-box">
<div class="preview-countdown" id="preview-countdown">5</div>
<div class="preview-caption" id="preview-caption">MEMORIZE THE VIBE...</div>
<div class="preview-canvas-wrap">
<canvas id="preview-canvas"></canvas>
</div>
</div>
</div>
<script type="module" src="src/main.js"></script>
</body>
</html>

71
src/colorUtils.js Normal file
View file

@ -0,0 +1,71 @@
// Color math: hex/rgb helpers, interpolation, and a perceptual (CIELAB)
// distance used to score how close a guessed color is to the real one.
export function hexToRgb(hex) {
const h = hex.replace('#', '').trim();
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
const int = parseInt(full, 16) || 0;
return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255 };
}
export function rgbToHex({ r, g, b }) {
const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
return `#${c(r)}${c(g)}${c(b)}`;
}
export function isValidHex(hex) {
return /^#?[0-9a-f]{6}$/i.test(hex);
}
export function lerpRgb(a, b, t) {
return {
r: a.r + (b.r - a.r) * t,
g: a.g + (b.g - a.g) * t,
b: a.b + (b.b - a.b) * t,
};
}
export function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
function srgbToLinear(c) {
const cs = c / 255;
return cs <= 0.04045 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4);
}
function rgbToXyz({ r, g, b }) {
const R = srgbToLinear(r);
const G = srgbToLinear(g);
const B = srgbToLinear(b);
return {
x: R * 0.4124 + G * 0.3576 + B * 0.1805,
y: R * 0.2126 + G * 0.7152 + B * 0.0722,
z: R * 0.0193 + G * 0.1192 + B * 0.9505,
};
}
const REF_WHITE = { x: 0.95047, y: 1.0, z: 1.08883 };
function xyzToLab({ x, y, z }) {
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
const fx = f(x / REF_WHITE.x);
const fy = f(y / REF_WHITE.y);
const fz = f(z / REF_WHITE.z);
return { l: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
}
export function hexToLab(hex) {
return xyzToLab(rgbToXyz(hexToRgb(hex)));
}
export function deltaE76(labA, labB) {
return Math.sqrt((labA.l - labB.l) ** 2 + (labA.a - labB.a) ** 2 + (labA.b - labB.b) ** 2);
}
// 100 at identical colors, decaying smoothly as perceptual distance grows.
export function similarityFromHex(hexA, hexB) {
const dE = deltaE76(hexToLab(hexA), hexToLab(hexB));
const sim = 100 * Math.exp(-dE / 45);
return Math.max(0, Math.min(100, sim));
}

510
src/game.js Normal file
View file

@ -0,0 +1,510 @@
import { tokenize } from './tokenizer.js';
import { CATEGORY_META, THEMES, CODE_SAMPLE } from './theme.js';
import { hexToRgb, rgbToHex, lerpRgb, easeOutCubic, isValidHex, similarityFromHex } from './colorUtils.js';
import { ParticleSystem } from './particles.js';
import { sound } from './sound.js';
const FONT_SIZE = 17;
const LINE_HEIGHT = 26;
const GUTTER = 46;
const PAD = 24;
const UNSET_BG_RGB = { r: 48, g: 44, b: 42 };
const UNSET_FG_RGB = { r: 168, g: 153, b: 132 };
const TRANSITION_MS = 550;
const METER_BLOCKS = 20;
function barColor(sim) {
if (sim < 40) return '#ff5555';
if (sim < 65) return '#ffa657';
if (sim < 82) return '#ffd23f';
return '#5fd75f';
}
function verdictFor(score) {
if (score >= 92) return '🏆 PIXEL PERFECT!';
if (score >= 80) return '🔥 SO CLOSE!';
if (score >= 60) return '🌤️ GETTING WARM';
if (score >= 40) return '🎭 BOLD REMIX';
return '😅 NEW GAME +';
}
export class ThemeGuessGame {
constructor(themeId) {
this.canvas = document.getElementById('code-canvas');
this.ctx = this.canvas.getContext('2d');
this.panel = document.getElementById('category-panel');
this.popover = document.getElementById('picker-popover');
this.popoverLabel = document.getElementById('popover-label');
this.popoverColor = document.getElementById('popover-color');
this.popoverHex = document.getElementById('popover-hex');
this.progressFill = document.getElementById('progress-fill');
this.progressLabel = document.getElementById('progress-label');
this.revealBtn = document.getElementById('reveal-btn');
this.resetBtn = document.getElementById('reset-btn');
this.resultModal = document.getElementById('result-modal');
this.scoreNumber = document.getElementById('score-number');
this.scoreMeter = document.getElementById('score-meter');
this.scoreVerdict = document.getElementById('score-verdict');
this.breakdownEl = document.getElementById('score-breakdown');
this.compareToggle = document.getElementById('compare-toggle');
this.closeResultBtn = document.getElementById('close-result-btn');
this.playAgainBtn = document.getElementById('play-again-btn');
this.themeId = themeId;
this.fx = new ParticleSystem();
this.activeCategory = null;
this.compareMode = 'yours';
this.lastTime = performance.now();
this.build();
this.bindEvents();
requestAnimationFrame(this.loop.bind(this));
}
// ---------- setup ----------
build() {
this.tokens = tokenize(CODE_SAMPLE);
this.lineCount = 1;
this.maxCols = 0;
let col = 0;
for (const t of this.tokens) {
if (t.type === 'newline') { this.lineCount++; col = 0; }
else { col += t.text.length; this.maxCols = Math.max(this.maxCols, col); }
}
const counts = {};
for (const t of this.tokens) {
if (t.type === 'whitespace' || t.type === 'newline') continue;
counts[t.type] = (counts[t.type] || 0) + 1;
}
const maxCount = Math.max(1, ...Object.values(counts));
const theme = THEMES[this.themeId];
this.categories = {};
for (const def of CATEGORY_META) {
const count = counts[def.id] || 0;
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
this.categories[def.id] = {
...def,
actualHex: theme.colors[def.id],
weight: def.id === 'background' ? maxCount : Math.max(count, 3),
count,
assignedHex: null,
currentRgb: { ...unset },
fromRgb: { ...unset },
toRgb: { ...unset },
transitionStart: 0,
pulsePhase: Math.random() * Math.PI * 2,
};
}
this.setupCanvasSize();
this.renderPanel();
this.updateProgress();
}
setupCanvasSize() {
const dpr = window.devicePixelRatio || 1;
this.ctx.font = `${FONT_SIZE}px ${this.fontStack()}`;
this.charWidth = this.ctx.measureText('M').width;
this.width = GUTTER + PAD * 2 + this.maxCols * this.charWidth;
this.height = PAD * 2 + this.lineCount * LINE_HEIGHT;
this.canvas.style.width = `${this.width}px`;
this.canvas.style.height = `${this.height}px`;
this.canvas.width = this.width * dpr;
this.canvas.height = this.height * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fontStack() {
return "'JetBrains Mono','Fira Code',ui-monospace,SFMono-Regular,Consolas,monospace";
}
renderPanel() {
this.panel.innerHTML = '';
for (const def of CATEGORY_META) {
const card = document.createElement('button');
card.type = 'button';
card.className = 'cat-card';
card.dataset.id = def.id;
card.innerHTML = `
<span class="cat-swatch" data-swatch></span>
<span class="cat-info">
<span class="cat-label">${def.icon} ${def.label}</span>
<span class="cat-count">${this.categories[def.id].count || '1'} token${this.categories[def.id].count === 1 ? '' : 's'}</span>
</span>`;
card.addEventListener('click', () => {
this.setActiveCategory(def.id);
this.openPicker(def.id, card.getBoundingClientRect());
});
this.panel.appendChild(card);
}
}
// ---------- theme switching ----------
setTheme(themeId) {
this.themeId = themeId;
const theme = THEMES[themeId];
for (const def of CATEGORY_META) {
const cat = this.categories[def.id];
cat.actualHex = theme.colors[def.id];
cat.assignedHex = null;
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
cat.currentRgb = { ...unset };
cat.fromRgb = { ...unset };
cat.toRgb = { ...unset };
cat.transitionStart = 0;
}
this.compareMode = 'yours';
this.activeCategory = null;
this.popover.classList.add('hidden');
this.resultModal.classList.add('hidden');
this.renderPanel();
this.updateProgress();
this.fx.clear();
}
// ---------- events ----------
bindEvents() {
this.canvas.addEventListener('click', (e) => this.onCanvasClick(e));
this.canvas.addEventListener('mousemove', (e) => this.onCanvasHover(e));
this.popoverColor.addEventListener('input', () => this.onPickerLiveInput(this.popoverColor.value));
this.popoverHex.addEventListener('input', () => {
let v = this.popoverHex.value.trim();
if (!v.startsWith('#')) v = `#${v}`;
if (isValidHex(v)) {
this.popoverColor.value = v.length === 4
? `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`
: v;
this.onPickerLiveInput(this.popoverColor.value);
}
});
document.getElementById('popover-apply').addEventListener('click', () => this.confirmPicker());
document.getElementById('popover-cancel').addEventListener('click', () => this.closePicker(true));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !this.popover.classList.contains('hidden')) this.closePicker(true);
});
document.addEventListener('click', (e) => {
if (this.popover.classList.contains('hidden')) return;
if (this.popover.contains(e.target) || e.target === this.canvas || e.target.closest('.cat-card')) return;
this.closePicker(true);
});
this.revealBtn.addEventListener('click', () => this.reveal());
this.resetBtn.addEventListener('click', () => this.resetColors());
this.compareToggle.addEventListener('click', (e) => {
const btn = e.target.closest('.toggle-btn');
if (!btn) return;
this.setCompareMode(btn.dataset.mode);
});
this.closeResultBtn.addEventListener('click', () => this.resultModal.classList.add('hidden'));
this.playAgainBtn.addEventListener('click', () => {
this.resultModal.classList.add('hidden');
this.resetColors();
});
}
onCanvasHover(e) {
const { x, y } = this.toCanvasCoords(e);
const token = this.tokenAt(x, y);
this.canvas.style.cursor = token ? 'pointer' : 'default';
}
onCanvasClick(e) {
const { x, y } = this.toCanvasCoords(e);
const token = this.tokenAt(x, y);
this.fx.spawnRipple(x, y, token ? rgbToHex(this.categories[token.type].currentRgb) : '#a89984');
if (!token) return;
sound.playOpen();
this.setActiveCategory(token.type);
const card = this.panel.querySelector(`[data-id="${token.type}"]`);
this.openPicker(token.type, card.getBoundingClientRect());
}
toCanvasCoords(e) {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.width / rect.width;
const scaleY = this.height / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
}
tokenAt(px, py) {
const col = Math.floor((px - GUTTER - PAD) / this.charWidth);
const line = Math.floor((py - PAD) / LINE_HEIGHT);
if (line < 0 || col < 0) return null;
for (const t of this.tokens) {
if (t.type === 'whitespace' || t.type === 'newline') continue;
if (t.line === line && col >= t.col && col < t.col + t.text.length) return t;
}
return null;
}
// ---------- category / picker ----------
setActiveCategory(id) {
this.activeCategory = id;
for (const card of this.panel.children) card.classList.toggle('active', card.dataset.id === id);
}
openPicker(id, anchorRect) {
const cat = this.categories[id];
this.pickerOrigin = { ...cat.currentRgb };
const startHex = cat.assignedHex || rgbToHex(cat.currentRgb);
this.popoverLabel.textContent = `${cat.icon} ${cat.label}`;
this.popoverColor.value = startHex;
this.popoverHex.value = startHex;
this.popover.classList.remove('hidden');
const pw = 220;
const ph = 160;
let left = anchorRect.left;
let top = anchorRect.bottom + 10;
if (left + pw > window.innerWidth - 10) left = window.innerWidth - pw - 10;
if (top + ph > window.innerHeight - 10) top = anchorRect.top - ph - 10;
this.popover.style.left = `${Math.max(10, left)}px`;
this.popover.style.top = `${Math.max(10, top)}px`;
this.popoverHex.focus();
this.popoverHex.select();
}
onPickerLiveInput(hex) {
if (!this.activeCategory) return;
this.popoverHex.value = hex;
const cat = this.categories[this.activeCategory];
cat.currentRgb = hexToRgb(hex);
cat.transitionStart = 0;
}
confirmPicker() {
if (!this.activeCategory) return;
const hex = this.popoverColor.value;
this.applyColor(this.activeCategory, hex);
this.closePicker(false);
}
closePicker(cancelled) {
if (cancelled && this.activeCategory) {
const cat = this.categories[this.activeCategory];
cat.currentRgb = cat.assignedHex ? hexToRgb(cat.assignedHex) : this.pickerOrigin;
}
this.popover.classList.add('hidden');
for (const card of this.panel.children) card.classList.remove('active');
this.activeCategory = null;
}
applyColor(id, hex) {
const cat = this.categories[id];
cat.assignedHex = hex;
cat.fromRgb = { ...cat.currentRgb };
cat.toRgb = hexToRgb(hex);
cat.transitionStart = this.lastTime;
const card = this.panel.querySelector(`[data-id="${id}"]`);
const swatch = card.querySelector('[data-swatch]');
swatch.style.background = hex;
swatch.classList.remove('pop');
void swatch.offsetWidth;
swatch.classList.add('pop');
card.classList.add('assigned');
const centers = this.tokenCentersFor(id, 10);
for (const c of centers) this.fx.spawnBurst(c.x, c.y, hex, 10);
sound.playApply();
this.updateProgress();
}
tokenCentersFor(id, max) {
const matches = this.tokens.filter((t) => t.type === id);
const sample = [];
const step = Math.max(1, Math.floor(matches.length / max));
for (let i = 0; i < matches.length && sample.length < max; i += step) {
const t = matches[i];
sample.push({
x: GUTTER + PAD + (t.col + t.text.length / 2) * this.charWidth,
y: PAD + t.line * LINE_HEIGHT + LINE_HEIGHT / 2,
});
}
return sample;
}
updateProgress() {
const total = CATEGORY_META.length;
const done = CATEGORY_META.filter((d) => this.categories[d.id].assignedHex).length;
this.progressFill.style.width = `${(done / total) * 100}%`;
this.progressLabel.textContent = `${done} / ${total} TOKEN KINDS THEMED`;
this.revealBtn.disabled = done < total;
}
resetColors() {
for (const def of CATEGORY_META) {
const cat = this.categories[def.id];
cat.assignedHex = null;
cat.fromRgb = { ...cat.currentRgb };
cat.toRgb = { ...(def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB) };
cat.transitionStart = this.lastTime;
}
this.compareMode = 'yours';
this.renderPanel();
this.updateProgress();
this.fx.clear();
}
setCompareMode(mode) {
if (mode === this.compareMode) return;
this.compareMode = mode;
for (const btn of this.compareToggle.children) btn.classList.toggle('active', btn.dataset.mode === mode);
for (const def of CATEGORY_META) {
const cat = this.categories[def.id];
const target = mode === 'original' ? cat.actualHex : cat.assignedHex;
cat.fromRgb = { ...cat.currentRgb };
cat.toRgb = hexToRgb(target);
cat.transitionStart = this.lastTime;
}
}
// ---------- scoring / reveal ----------
reveal() {
let totalWeight = 0;
let weightedSum = 0;
const rows = [];
for (const def of CATEGORY_META) {
const cat = this.categories[def.id];
const sim = similarityFromHex(cat.assignedHex, cat.actualHex);
totalWeight += cat.weight;
weightedSum += sim * cat.weight;
rows.push({ ...def, guess: cat.assignedHex, actualHex: cat.actualHex, sim });
}
const overall = Math.round(weightedSum / totalWeight);
this.renderResult(overall, rows);
this.resultModal.classList.remove('hidden');
sound.playReveal(overall);
if (overall >= 75) this.fx.spawnConfetti(160, this.width);
}
renderResult(overall, rows) {
this.breakdownEl.innerHTML = '';
rows.forEach((row, i) => {
const el = document.createElement('div');
el.className = 'breakdown-row';
el.style.animationDelay = `${i * 60}ms`;
el.innerHTML = `
<div class="swatch-pair">
<span style="background:${row.guess}"></span>
<span style="background:${row.actualHex}"></span>
</div>
<div class="breakdown-info">
<div class="breakdown-label">${row.icon} ${row.label}</div>
<div class="breakdown-bar"><div class="breakdown-bar-fill"></div></div>
</div>
<div class="breakdown-pct">${Math.round(row.sim)}%</div>`;
this.breakdownEl.appendChild(el);
requestAnimationFrame(() => {
el.querySelector('.breakdown-bar-fill').style.width = `${row.sim}%`;
el.querySelector('.breakdown-bar-fill').style.background = barColor(row.sim);
});
});
this.scoreVerdict.textContent = verdictFor(overall);
this.scoreMeter.innerHTML = '';
const litColor = barColor(overall);
for (let i = 0; i < METER_BLOCKS; i++) {
const b = document.createElement('span');
b.className = 'meter-block';
b.style.setProperty('--lit-color', litColor);
this.scoreMeter.appendChild(b);
}
const blocks = this.scoreMeter.children;
const targetLit = Math.round((overall / 100) * METER_BLOCKS);
const start = performance.now();
const duration = 1100;
const animate = (t) => {
const p = Math.min(1, (t - start) / duration);
const eased = easeOutCubic(p);
const val = Math.round(overall * eased);
this.scoreNumber.textContent = `${val}%`;
const lit = Math.round(targetLit * eased);
for (let i = 0; i < blocks.length; i++) blocks[i].classList.toggle('lit', i < lit);
if (p < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}
// ---------- render loop ----------
loop(t) {
const dt = t - this.lastTime;
this.lastTime = t;
this.tickTransitions(t);
this.fx.update(dt);
this.draw(t);
requestAnimationFrame(this.loop.bind(this));
}
tickTransitions(now) {
for (const def of CATEGORY_META) {
const cat = this.categories[def.id];
if (!cat.transitionStart) continue;
const p = Math.min(1, (now - cat.transitionStart) / TRANSITION_MS);
cat.currentRgb = lerpRgb(cat.fromRgb, cat.toRgb, easeOutCubic(p));
if (p >= 1) cat.transitionStart = 0;
}
}
draw(now) {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.width, this.height);
const bg = this.categories.background;
ctx.fillStyle = rgbToHex(bg.currentRgb);
ctx.globalAlpha = bg.assignedHex ? 1 : 0.85 + 0.1 * Math.sin(now / 500 + bg.pulsePhase);
ctx.fillRect(0, 0, this.width, this.height);
ctx.globalAlpha = 1;
ctx.fillStyle = 'rgba(0,0,0,.16)';
ctx.fillRect(0, 0, GUTTER, this.height);
if (this.activeCategory) {
const glowAlpha = 0.16 + 0.12 * Math.sin(now / 220);
ctx.fillStyle = `rgba(255,255,255,${glowAlpha})`;
for (const t of this.tokens) {
if (t.type !== this.activeCategory) continue;
const x = GUTTER + PAD + t.col * this.charWidth;
const yTop = PAD + t.line * LINE_HEIGHT;
ctx.fillRect(x - 3, yTop + 1, t.text.length * this.charWidth + 6, LINE_HEIGHT - 2);
}
}
ctx.fillStyle = '#665c54';
ctx.font = `${FONT_SIZE - 3}px ${this.fontStack()}`;
ctx.textBaseline = 'alphabetic';
for (let l = 0; l < this.lineCount; l++) {
ctx.fillText(String(l + 1).padStart(2, ' '), 8, PAD + l * LINE_HEIGHT + FONT_SIZE - 4);
}
for (const t of this.tokens) {
if (t.type === 'whitespace' || t.type === 'newline') continue;
const cat = this.categories[t.type];
const x = GUTTER + PAD + t.col * this.charWidth;
const y = PAD + t.line * LINE_HEIGHT + FONT_SIZE - 4;
ctx.font = t.type === 'comment'
? `italic ${FONT_SIZE}px ${this.fontStack()}`
: `${FONT_SIZE}px ${this.fontStack()}`;
ctx.fillStyle = rgbToHex(cat.currentRgb);
ctx.globalAlpha = cat.assignedHex ? 1 : 0.5 + 0.25 * Math.sin(now / 450 + cat.pulsePhase);
ctx.fillText(t.text, x, y);
}
ctx.globalAlpha = 1;
this.fx.draw(ctx);
}
}

102
src/main.js Normal file
View file

@ -0,0 +1,102 @@
import { ThemeGuessGame } from './game.js';
import { THEMES } from './theme.js';
import { sound } from './sound.js';
import { renderThemePreview } from './preview.js';
const introModal = document.getElementById('intro-modal');
const themeGrid = document.getElementById('theme-grid');
const startBtn = document.getElementById('start-btn');
const helpBtn = document.getElementById('help-btn');
const muteBtn = document.getElementById('mute-btn');
const themeNameBadge = document.getElementById('theme-name-badge');
const changeThemeBtn = document.getElementById('change-theme-btn');
const resultModal = document.getElementById('result-modal');
const previewOverlay = document.getElementById('preview-overlay');
const previewCountdown = document.getElementById('preview-countdown');
const previewCaption = document.getElementById('preview-caption');
const previewCanvas = document.getElementById('preview-canvas');
const SWATCH_KEYS = ['keyword', 'string', 'function', 'type'];
const COUNTDOWN_SECONDS = 5;
let selectedTheme = 'gruvbox';
let game = null;
function renderThemeGrid() {
themeGrid.innerHTML = '';
for (const id in THEMES) {
const theme = THEMES[id];
const card = document.createElement('button');
card.type = 'button';
card.className = `theme-card${id === selectedTheme ? ' selected' : ''}`;
card.dataset.id = id;
card.style.setProperty('--card-bg', theme.colors.background);
card.innerHTML = `
<span class="theme-swatches">
${SWATCH_KEYS.map((k) => `<i style="background:${theme.colors[k]}"></i>`).join('')}
</span>
<span class="theme-name">${theme.name}</span>`;
card.addEventListener('click', () => {
selectedTheme = id;
renderThemeGrid();
sound.playOpen();
});
themeGrid.appendChild(card);
}
}
function bumpCountdown(text) {
previewCountdown.textContent = text;
previewCountdown.classList.remove('tick');
void previewCountdown.offsetWidth;
previewCountdown.classList.add('tick');
}
// Flashes a *different* snippet, fully colored with the real theme (and
// deliberately missing a few token kinds so it isn't a complete answer
// key), for a few seconds before the round actually starts.
function runThemePreview(themeId, onDone) {
renderThemePreview(previewCanvas, themeId);
previewCaption.textContent = `LOADING: ${THEMES[themeId].name.toUpperCase()}`;
previewOverlay.classList.remove('hidden');
let remaining = COUNTDOWN_SECONDS;
bumpCountdown(remaining);
sound.playOpen();
const timer = setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
clearInterval(timer);
previewOverlay.classList.add('hidden');
onDone();
return;
}
bumpCountdown(remaining);
sound.playOpen();
}, 1000);
}
function startWithSelectedTheme() {
introModal.classList.add('hidden');
runThemePreview(selectedTheme, () => {
if (game) game.setTheme(selectedTheme);
else game = new ThemeGuessGame(selectedTheme);
themeNameBadge.textContent = THEMES[selectedTheme].name;
sound.playApply();
});
}
renderThemeGrid();
startBtn.addEventListener('click', startWithSelectedTheme);
helpBtn.addEventListener('click', () => introModal.classList.remove('hidden'));
changeThemeBtn.addEventListener('click', () => {
resultModal.classList.add('hidden');
introModal.classList.remove('hidden');
});
muteBtn.addEventListener('click', () => {
const muted = sound.toggleMute();
muteBtn.textContent = muted ? '🔇' : '🔊';
});

101
src/particles.js Normal file
View file

@ -0,0 +1,101 @@
// Lightweight juice: click ripples, color-apply bursts, and a confetti
// rain for a strong reveal. All drawn straight onto the game canvas.
const CONFETTI_COLORS = ['#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#fe8019'];
export class ParticleSystem {
constructor() {
this.ripples = [];
this.particles = [];
}
spawnRipple(x, y, color = '#ebdbb2') {
this.ripples.push({ x, y, r: 2, maxR: 46, life: 1, color });
}
spawnBurst(x, y, color, count = 14) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 50 + Math.random() * 110;
this.particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1,
decay: 1.1 + Math.random() * 0.7,
size: 1.5 + Math.random() * 2.5,
gravity: 90,
color,
});
}
}
spawnConfetti(count, width) {
for (let i = 0; i < count; i++) {
this.particles.push({
x: Math.random() * width,
y: -20 - Math.random() * 240,
vx: (Math.random() - 0.5) * 70,
vy: 70 + Math.random() * 90,
life: 1,
decay: 0.12 + Math.random() * 0.1,
confetti: true,
size: 4 + Math.random() * 4,
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 7,
gravity: 40,
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
});
}
}
clear() {
this.particles.length = 0;
this.ripples.length = 0;
}
update(dtMs) {
const s = dtMs / 1000;
for (const r of this.ripples) {
r.r += (r.maxR - r.r) * 0.18;
r.life -= s * 1.6;
}
this.ripples = this.ripples.filter((r) => r.life > 0);
for (const p of this.particles) {
p.x += p.vx * s;
p.y += p.vy * s;
p.vy += (p.gravity || 100) * s;
if (p.confetti) p.rot += p.vr * s;
p.life -= p.decay * s;
}
this.particles = this.particles.filter((p) => p.life > 0);
}
draw(ctx) {
for (const r of this.ripples) {
ctx.globalAlpha = Math.max(r.life, 0) * 0.6;
ctx.strokeStyle = r.color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(r.x, r.y, r.r, 0, Math.PI * 2);
ctx.stroke();
}
for (const p of this.particles) {
ctx.globalAlpha = Math.max(p.life, 0);
ctx.fillStyle = p.color;
if (p.confetti) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
ctx.restore();
} else {
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
}

54
src/preview.js Normal file
View file

@ -0,0 +1,54 @@
import { tokenize } from './tokenizer.js';
import { THEMES, PREVIEW_SAMPLE } from './theme.js';
const FONT_SIZE = 16;
const LINE_HEIGHT = 24;
const PAD = 20;
function fontStack() {
return "'JetBrains Mono','Fira Code',ui-monospace,SFMono-Regular,Consolas,monospace";
}
// Draws the (different, intentionally incomplete) preview snippet fully
// colored with a theme's *real* colors — no guessing, just a flash of
// what the target theme actually looks like.
export function renderThemePreview(canvas, themeId) {
const ctx = canvas.getContext('2d');
const tokens = tokenize(PREVIEW_SAMPLE);
ctx.font = `${FONT_SIZE}px ${fontStack()}`;
const charWidth = ctx.measureText('M').width;
let lineCount = 1;
let maxCols = 0;
let col = 0;
for (const t of tokens) {
if (t.type === 'newline') { lineCount++; col = 0; }
else { col += t.text.length; maxCols = Math.max(maxCols, col); }
}
const dpr = window.devicePixelRatio || 1;
const width = PAD * 2 + maxCols * charWidth;
const height = PAD * 2 + lineCount * LINE_HEIGHT;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const theme = THEMES[themeId];
ctx.fillStyle = theme.colors.background;
ctx.fillRect(0, 0, width, height);
ctx.textBaseline = 'alphabetic';
for (const t of tokens) {
if (t.type === 'whitespace' || t.type === 'newline') continue;
const x = PAD + t.col * charWidth;
const y = PAD + t.line * LINE_HEIGHT + FONT_SIZE - 3;
ctx.font = t.type === 'comment'
? `italic ${FONT_SIZE}px ${fontStack()}`
: `${FONT_SIZE}px ${fontStack()}`;
ctx.fillStyle = theme.colors[t.type] || theme.colors.punctuation;
ctx.fillText(t.text, x, y);
}
}

60
src/sound.js Normal file
View file

@ -0,0 +1,60 @@
// Tiny synthesized SFX — no audio assets, just oscillators with a
// quick exponential envelope. Created lazily on first user gesture.
class Sound {
constructor() {
this.ctx = null;
this.muted = false;
}
ensure() {
if (!this.ctx) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return null;
this.ctx = new Ctx();
}
if (this.ctx.state === 'suspended') this.ctx.resume();
return this.ctx;
}
tone(freq, dur = 0.12, type = 'sine', gain = 0.05, when = 0) {
if (this.muted) return;
const ctx = this.ensure();
if (!ctx) return;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
g.gain.value = 0.0001;
osc.connect(g);
g.connect(ctx.destination);
const t0 = ctx.currentTime + when;
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.start(t0);
osc.stop(t0 + dur + 0.03);
}
playOpen() { this.tone(660, 0.07, 'triangle', 0.035); }
playApply() {
this.tone(523.25, 0.09, 'sine', 0.05);
this.tone(783.99, 0.12, 'sine', 0.045, 0.06);
}
playReveal(score) {
const notes = score >= 85
? [523.25, 659.25, 783.99, 1046.5]
: score >= 60
? [523.25, 659.25, 783.99]
: [392, 349.23];
notes.forEach((f, i) => this.tone(f, 0.2, 'triangle', 0.05, i * 0.1));
}
toggleMute() {
this.muted = !this.muted;
return this.muted;
}
}
export const sound = new Sound();

130
src/theme.js Normal file
View file

@ -0,0 +1,130 @@
// Token "kinds" the player can paint (order drives the panel layout),
// and a handful of real syntax-theme palettes mapped onto those kinds.
// Hex values are sourced from each project's own theme definitions:
// Gruvbox (morhetz/gruvbox accent palette), VS Code Dark+ (dark_vs.json /
// dark_plus.json), GitHub Dark (primer/github-vscode-theme tokenColors),
// Tokyo Night (enkia/tokyo-night-vscode palette), Dracula and Catppuccin
// Mocha (official published palettes).
export const CATEGORY_META = [
{ id: 'background', label: 'Background', icon: '🌑' },
{ id: 'keyword', label: 'Keywords', icon: '🔑' },
{ id: 'string', label: 'Strings', icon: '🧵' },
{ id: 'number', label: 'Numbers', icon: '🔢' },
{ id: 'constant', label: 'Constants', icon: '⭐' },
{ id: 'function', label: 'Functions', icon: '🛠️' },
{ id: 'variable', label: 'Variables', icon: '📦' },
{ id: 'property', label: 'Properties', icon: '🏷️' },
{ id: 'type', label: 'Types / Classes', icon: '🧩' },
{ id: 'operator', label: 'Operators', icon: '➕' },
{ id: 'punctuation', label: 'Punctuation', icon: '•' },
{ id: 'comment', label: 'Comments', icon: '💬' },
];
export const THEMES = {
gruvbox: {
name: 'Gruvbox Dark',
colors: {
background: '#282828', keyword: '#fb4934', string: '#b8bb26', number: '#d3869b',
constant: '#d3869b', function: '#fabd2f', variable: '#83a598', property: '#8ec07c',
type: '#fe8019', operator: '#ebdbb2', punctuation: '#a89984', comment: '#928374',
},
},
tokyoNight: {
name: 'Tokyo Night',
colors: {
background: '#1a1b26', keyword: '#bb9af7', string: '#9ece6a', number: '#ff9e64',
constant: '#ff9e64', function: '#7aa2f7', variable: '#c0caf5', property: '#73daca',
type: '#2ac3de', operator: '#89ddff', punctuation: '#a9b1d6', comment: '#565f89',
},
},
vscode: {
name: 'VS Code Dark+',
colors: {
background: '#1e1e1e', keyword: '#569cd6', string: '#ce9178', number: '#b5cea8',
constant: '#569cd6', function: '#dcdcaa', variable: '#9cdcfe', property: '#9cdcfe',
type: '#4ec9b0', operator: '#d4d4d4', punctuation: '#d4d4d4', comment: '#6a9955',
},
},
github: {
name: 'GitHub Dark',
colors: {
background: '#0d1117', keyword: '#ff7b72', string: '#a5d6ff', number: '#79c0ff',
constant: '#79c0ff', function: '#d2a8ff', variable: '#ffa657', property: '#79c0ff',
type: '#ffa657', operator: '#c9d1d9', punctuation: '#c9d1d9', comment: '#8b949e',
},
},
dracula: {
name: 'Dracula',
colors: {
background: '#282a36', keyword: '#ff79c6', string: '#f1fa8c', number: '#bd93f9',
constant: '#bd93f9', function: '#50fa7b', variable: '#f8f8f2', property: '#ffb86c',
type: '#8be9fd', operator: '#ff79c6', punctuation: '#f8f8f2', comment: '#6272a4',
},
},
catppuccin: {
name: 'Catppuccin Mocha',
colors: {
background: '#1e1e2e', keyword: '#cba6f7', string: '#a6e3a1', number: '#fab387',
constant: '#fab387', function: '#89b4fa', variable: '#cdd6f4', property: '#b4befe',
type: '#f9e2af', operator: '#89dceb', punctuation: '#9399b2', comment: '#6c7086',
},
},
};
export const CODE_SAMPLE = `// Theme Guess demo file
class ColorMixer {
constructor(name, hue) {
this.name = name;
this.hue = hue;
this.locked = false;
}
blend(other, amount = 0.5) {
const mixed = this.hue * (1 - amount) + other.hue * amount;
return new ColorMixer(\`\${this.name}+\${other.name}\`, mixed);
}
}
function buildPalette(seed) {
const colors = [];
for (let i = 0; i < 8; i++) {
const hue = (seed + i * 45) % 360;
colors.push(new ColorMixer(\`swatch-\${i}\`, hue));
}
return colors;
}
const palette = buildPalette(20);
const primary = palette[0];
const accent = palette[palette.length - 1];
if (accent.hue > 180 && !primary.locked) {
primary.locked = true;
console.log(\`Locked \${primary.name} at hue \${primary.hue}\`);
}
export default palette;
`;
// A short, different snippet used for the pre-game theme reveal. It's
// deliberately missing some token kinds the real game asks about (no
// numeric literals, no true/false/null/undefined/this) so the preview
// doesn't hand the player every answer up front.
export const PREVIEW_SAMPLE = `// Rendering theme preview...
import { formatName } from "./utils";
function greetUser(user) {
const label = formatName(user.first, user.last);
return \`Hello, \${label}!\`;
}
class Notifier {
send(message) {
console.log(message.body);
}
}
export function broadcast(list) {
list.forEach(item => item.send());
}
`;

125
src/tokenizer.js Normal file
View file

@ -0,0 +1,125 @@
// Minimal hand-rolled JS-ish lexer. It doesn't need to be a real parser —
// just consistent enough to bucket every word in the sample into the
// same "kind" categories a syntax theme would color.
const KEYWORDS = new Set([
'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while',
'of', 'in', 'new', 'class', 'extends', 'constructor', 'import', 'from',
'export', 'default', 'static', 'async', 'await', 'try', 'catch', 'finally',
'throw', 'typeof', 'instanceof', 'break', 'continue', 'switch', 'case',
'yield', 'super', 'do',
]);
const CONSTANTS = new Set(['true', 'false', 'null', 'undefined', 'this']);
const OPS3 = ['===', '!==', '**='];
const OPS2 = ['=>', '==', '!=', '<=', '>=', '&&', '||', '+=', '-=', '*=', '/=', '++', '--'];
const PUNCT = '{}()[];,.';
const OP_CHARS = '+-*/%=<>!?:&|^~';
function scan(src) {
const raw = [];
let i = 0;
const n = src.length;
while (i < n) {
const ch = src[i];
if (ch === '\n') { raw.push({ type: 'newline', text: '\n' }); i++; continue; }
if (ch === ' ' || ch === '\t') {
let j = i;
while (j < n && (src[j] === ' ' || src[j] === '\t')) j++;
raw.push({ type: 'whitespace', text: src.slice(i, j) }); i = j; continue;
}
if (ch === '/' && src[i + 1] === '/') {
let j = i;
while (j < n && src[j] !== '\n') j++;
raw.push({ type: 'comment', text: src.slice(i, j) }); i = j; continue;
}
if (ch === '/' && src[i + 1] === '*') {
let j = i + 2;
while (j < n && !(src[j] === '*' && src[j + 1] === '/')) j++;
j = Math.min(j + 2, n);
raw.push({ type: 'comment', text: src.slice(i, j) }); i = j; continue;
}
if (ch === '"' || ch === "'" || ch === '`') {
const quote = ch;
let j = i + 1;
while (j < n && src[j] !== quote) { if (src[j] === '\\') j++; j++; }
j = Math.min(j + 1, n);
raw.push({ type: 'string', text: src.slice(i, j) }); i = j; continue;
}
if (/[0-9]/.test(ch)) {
let j = i;
while (j < n && /[0-9.]/.test(src[j])) j++;
raw.push({ type: 'number', text: src.slice(i, j) }); i = j; continue;
}
if (/[A-Za-z_$]/.test(ch)) {
let j = i;
while (j < n && /[A-Za-z0-9_$]/.test(src[j])) j++;
raw.push({ type: 'identifier', text: src.slice(i, j) }); i = j; continue;
}
const three = src.slice(i, i + 3);
const two = src.slice(i, i + 2);
if (OPS3.includes(three)) { raw.push({ type: 'operator', text: three }); i += 3; continue; }
if (OPS2.includes(two)) { raw.push({ type: 'operator', text: two }); i += 2; continue; }
if (PUNCT.includes(ch)) { raw.push({ type: 'punctuation', text: ch }); i++; continue; }
if (OP_CHARS.includes(ch)) { raw.push({ type: 'operator', text: ch }); i++; continue; }
raw.push({ type: 'punctuation', text: ch }); i++;
}
return raw;
}
// Defensive: split any token that smuggled in a newline (block comments,
// multi-line strings) so layout math stays a simple line/col grid.
function expandNewlines(raw) {
const tokens = [];
for (const t of raw) {
if (t.type !== 'newline' && t.text.includes('\n')) {
const parts = t.text.split('\n');
parts.forEach((p, idx) => {
if (p.length) tokens.push({ type: t.type, text: p });
if (idx < parts.length - 1) tokens.push({ type: 'newline', text: '\n' });
});
} else {
tokens.push(t);
}
}
return tokens;
}
function classify(tokens) {
let line = 0;
let col = 0;
for (let k = 0; k < tokens.length; k++) {
const t = tokens[k];
if (t.type === 'newline') { line++; col = 0; continue; }
if (t.type === 'identifier') {
const word = t.text;
let p = k - 1;
while (p >= 0 && (tokens[p].type === 'whitespace' || tokens[p].type === 'newline')) p--;
let nx = k + 1;
while (nx < tokens.length && (tokens[nx].type === 'whitespace' || tokens[nx].type === 'newline')) nx++;
const prev = p >= 0 ? tokens[p] : null;
const next = nx < tokens.length ? tokens[nx] : null;
const isCall = !!next && next.type === 'punctuation' && next.text === '(';
const isProp = !!prev && prev.type === 'punctuation' && prev.text === '.';
const isClassCtx = !!prev && prev.type === 'keyword' && ['class', 'new', 'extends'].includes(prev.text);
if (KEYWORDS.has(word)) t.type = 'keyword';
else if (CONSTANTS.has(word)) t.type = 'constant';
else if (/^[A-Z]/.test(word) && (isClassCtx || !isCall)) t.type = 'type';
else if (isCall) t.type = 'function';
else if (isProp) t.type = 'property';
else t.type = 'variable';
}
t.line = line;
t.col = col;
col += t.text.length;
}
return tokens;
}
export function tokenize(source) {
return classify(expandNewlines(scan(source)));
}

509
style.css Normal file
View file

@ -0,0 +1,509 @@
:root {
--ink: #0a0a12;
--panel: #1b1b2e;
--panel-2: #24243c;
--panel-3: #32324f;
--fg: #e9e6ff;
--fg-dim: #9a97c2;
--yellow: #ffd23f;
--pink: #ff5da2;
--cyan: #4cd4ff;
--green: #5fd75f;
--red: #ff5555;
--orange: #ffa657;
--radius: 0px;
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Consolas, monospace;
--pixel: 'Press Start 2P', var(--mono);
--sans: 'Segoe UI', system-ui, -apple-system, Roboto, sans-serif;
--shadow-off: 4px;
}
* { box-sizing: border-box; image-rendering: pixelated; }
html, body { height: 100%; margin: 0; }
body {
min-height: 100vh;
color: var(--fg);
font-family: var(--sans);
display: flex;
justify-content: center;
padding: 14px 16px;
overflow-y: auto;
background:
repeating-linear-gradient(rgba(0,0,0,.18) 0 1px, transparent 1px 3px),
radial-gradient(ellipse at top, #232347, #0f0f1e 72%);
}
#app {
width: 100%;
max-width: 1400px;
height: calc(100vh - 28px);
max-height: calc(100vh - 28px);
display: flex;
flex-direction: column;
gap: 10px;
}
/* ---------- pixel button primitive ---------- */
.btn, .icon-btn, .theme-badge, .cat-card, .theme-card {
border-radius: var(--radius);
}
.btn {
border: 3px solid var(--ink);
padding: 12px 16px;
font-family: var(--pixel);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .04em;
cursor: pointer;
color: var(--ink);
background: var(--fg);
box-shadow: var(--shadow-off) var(--shadow-off) 0 var(--ink);
transition: transform .08s ease, box-shadow .08s ease, filter .1s ease;
}
.btn:hover:not(:disabled) { filter: brightness(1.08); transform: translate(-1px, -1px); box-shadow: 5px 5px 0 var(--ink); }
.btn:active:not(:disabled) { transform: translate(var(--shadow-off), var(--shadow-off)); box-shadow: 0 0 0 var(--ink); }
.btn:disabled { opacity: .4; cursor: not-allowed; box-shadow: 2px 2px 0 var(--ink); }
.btn.small { padding: 7px 10px; font-size: 9px; box-shadow: 2px 2px 0 var(--ink); }
.btn.ghost { background: var(--panel-2); color: var(--fg); }
.btn.primary { background: var(--cyan); }
.btn.reveal { background: linear-gradient(90deg, var(--yellow), var(--orange)); }
.btn.reveal:not(:disabled) { animation: revealPulse 1.6s steps(2) infinite; }
.btn.big { padding: 16px 22px; font-size: 13px; }
@keyframes revealPulse {
0%, 100% { box-shadow: var(--shadow-off) var(--shadow-off) 0 var(--ink), 0 0 0 0 rgba(255,210,63,.6); }
50% { box-shadow: var(--shadow-off) var(--shadow-off) 0 var(--ink), 0 0 0 6px rgba(255,210,63,0); }
}
/* ---------- topbar ---------- */
.topbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 12px;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-swatches { display: flex; gap: 3px; }
.brand-swatches i {
display: block; width: 8px; height: 20px;
border: 2px solid var(--ink);
}
.brand h1 {
font-family: var(--pixel);
font-size: 15px;
margin: 0;
color: var(--fg);
text-shadow: 2px 2px 0 var(--ink);
}
.brand h1 span { color: var(--yellow); }
.theme-badge {
margin-left: auto;
font-family: var(--pixel);
font-size: 9px;
color: var(--fg);
background: var(--panel-2);
border: 3px solid var(--ink);
padding: 8px 12px;
cursor: pointer;
box-shadow: 3px 3px 0 var(--ink);
transition: transform .08s ease;
}
.theme-badge:hover { transform: translate(-1px, -1px); }
.theme-badge strong { color: var(--orange); }
.topbar-actions { display: flex; gap: 8px; flex-shrink: 0; }
.icon-btn {
width: 38px; height: 38px;
border: 3px solid var(--ink);
background: var(--panel-2);
color: var(--fg);
cursor: pointer;
font-size: 15px;
box-shadow: 3px 3px 0 var(--ink);
transition: transform .08s ease;
}
.icon-btn:hover { transform: translate(-1px, -1px); }
/* ---------- progress ---------- */
.progress-wrap {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 12px;
}
.progress-track {
flex: 1;
height: 16px;
background: var(--panel-2);
border: 3px solid var(--ink);
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: repeating-linear-gradient(90deg, var(--green) 0 8px, #4bc44b 8px 16px);
transition: width .5s steps(12);
}
#progress-label { font-family: var(--pixel); font-size: 9px; color: var(--fg-dim); white-space: nowrap; }
/* ---------- board layout ---------- */
.board {
flex: 1 1 auto;
min-height: 0;
display: flex;
gap: 14px;
}
.canvas-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
background: var(--panel);
border: 3px solid var(--ink);
box-shadow: 5px 5px 0 var(--ink);
padding: 6px;
overflow: auto;
}
#code-canvas {
display: block;
cursor: default;
}
.panel-col {
width: 330px;
flex: 0 0 330px;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.panel {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-content: start;
padding: 2px 6px 2px 2px;
}
.panel-actions {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.panel-actions .btn { width: 100%; }
.cat-card {
background: var(--panel-2);
border: 3px solid var(--ink);
padding: 8px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
box-shadow: 3px 3px 0 var(--ink);
transition: transform .08s ease, box-shadow .08s ease;
position: relative;
min-width: 0;
}
.cat-card:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--ink); }
.cat-card.active {
border-color: var(--yellow);
box-shadow: 3px 3px 0 var(--yellow);
}
.cat-card.assigned::after {
content: '✓';
position: absolute;
top: 2px; right: 4px;
font-size: 9px;
color: var(--green);
font-family: var(--pixel);
}
.cat-swatch {
width: 24px; height: 24px;
border: 2px solid var(--ink);
flex-shrink: 0;
background: repeating-conic-gradient(#4b4540 0% 25%, #302f2a 0% 50%) 0 0/8px 8px;
transition: transform .15s ease;
}
.cat-swatch.pop { animation: pop .4s steps(4); }
@keyframes pop {
0% { transform: scale(1); }
40% { transform: scale(1.3); }
100% { transform: scale(1); }
}
.cat-info { min-width: 0; }
.cat-label {
font-size: 8px;
line-height: 1.5;
font-family: var(--pixel);
color: var(--fg);
white-space: normal;
display: block;
margin-bottom: 4px;
}
.cat-count { font-size: 10px; color: var(--fg-dim); }
/* ---------- popover ---------- */
.popover {
position: fixed;
z-index: 50;
background: var(--panel-2);
border: 3px solid var(--ink);
padding: 12px;
box-shadow: 5px 5px 0 var(--ink);
width: 220px;
animation: popIn .15s steps(3);
}
.popover.hidden { display: none; }
@keyframes popIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.popover-label {
font-family: var(--pixel);
font-size: 10px;
color: var(--yellow);
margin-bottom: 10px;
text-transform: uppercase;
}
.popover-row { display: flex; gap: 8px; margin-bottom: 10px; }
.popover-row input[type="color"] {
width: 40px; height: 34px;
border: 2px solid var(--ink);
background: none; cursor: pointer;
padding: 0;
}
.popover-row input[type="text"] {
flex: 1;
min-width: 0;
background: var(--panel);
border: 2px solid var(--ink);
color: var(--fg);
font-family: var(--mono);
padding: 0 8px;
font-size: 13px;
}
.popover-actions { display: flex; justify-content: flex-end; gap: 6px; }
/* ---------- modals ---------- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(6,6,14,.65);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
}
.modal-overlay.hidden { display: none; }
.modal {
background: var(--panel);
border: 4px solid var(--ink);
box-shadow: 8px 8px 0 var(--ink);
padding: 26px 28px;
max-width: 480px;
width: 100%;
max-height: 90vh;
overflow: auto;
animation: modalIn .2s steps(4);
}
@keyframes modalIn {
from { opacity: 0; transform: scale(.9) translateY(14px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal h2 {
margin-top: 0;
font-family: var(--pixel);
font-size: 16px;
text-shadow: 2px 2px 0 var(--ink);
color: var(--yellow);
}
.modal ol { padding-left: 20px; line-height: 1.7; color: var(--fg-dim); font-size: 13px; }
.modal p { color: var(--fg-dim); font-size: 13px; }
.pick-theme-label {
font-family: var(--pixel);
font-size: 9px;
color: var(--cyan);
text-align: center;
margin: 18px 0 10px;
}
.theme-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.theme-card {
background: var(--card-bg, var(--panel-2));
border: 3px solid var(--ink);
padding: 10px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
box-shadow: 3px 3px 0 var(--ink);
transition: transform .08s ease;
}
.theme-card:hover { transform: translate(-1px, -1px); }
.theme-card.selected { border-color: var(--yellow); box-shadow: 3px 3px 0 var(--yellow); }
.theme-swatches { display: flex; gap: 3px; }
.theme-swatches i { width: 12px; height: 12px; border: 1px solid rgba(0,0,0,.5); display: block; }
.theme-name {
font-family: var(--pixel);
font-size: 8px;
color: var(--fg);
text-shadow: 1px 1px 0 var(--ink);
text-align: center;
}
/* ---------- theme preview countdown ---------- */
.preview-box {
background: var(--panel);
border: 4px solid var(--ink);
box-shadow: 8px 8px 0 var(--ink);
padding: 26px 30px;
max-width: 92vw;
text-align: center;
animation: modalIn .2s steps(4);
}
.preview-countdown {
font-family: var(--pixel);
font-size: 56px;
line-height: 1;
color: var(--yellow);
text-shadow: 4px 4px 0 var(--ink);
margin-bottom: 10px;
}
.preview-countdown.tick { animation: countdownPulse .45s steps(6); }
@keyframes countdownPulse {
0% { transform: scale(1.7); color: var(--pink); }
100% { transform: scale(1); color: var(--yellow); }
}
.preview-caption {
font-family: var(--pixel);
font-size: 10px;
color: var(--cyan);
margin-bottom: 18px;
letter-spacing: .05em;
}
.preview-canvas-wrap {
display: inline-block;
border: 3px solid var(--ink);
box-shadow: 4px 4px 0 var(--ink);
padding: 4px;
max-width: 100%;
max-height: 60vh;
overflow: auto;
}
#preview-canvas { display: block; }
/* ---------- score meter ---------- */
.score-meter-wrap { text-align: center; margin: 10px 0 4px; }
.score-meter-number {
font-family: var(--pixel);
font-size: 34px;
color: var(--yellow);
text-shadow: 3px 3px 0 var(--ink);
margin-bottom: 10px;
}
.score-meter-track {
display: flex;
gap: 3px;
justify-content: center;
flex-wrap: wrap;
}
.meter-block {
width: 14px; height: 20px;
background: var(--panel-2);
border: 2px solid var(--ink);
}
.meter-block.lit { background: var(--lit-color, var(--green)); }
.verdict {
text-align: center;
font-family: var(--pixel);
font-size: 12px;
margin-bottom: 16px;
min-height: 16px;
color: var(--fg);
}
.breakdown { display: flex; flex-direction: column; gap: 7px; max-height: 240px; overflow-y: auto; padding-right: 4px; }
.breakdown-row {
display: flex;
align-items: center;
gap: 10px;
opacity: 0;
animation: rowIn .3s steps(4) forwards;
}
@keyframes rowIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
.swatch-pair { display: flex; flex-shrink: 0; }
.swatch-pair span { width: 14px; height: 14px; display: block; border: 2px solid var(--ink); }
.swatch-pair span + span { margin-left: 2px; }
.breakdown-info { flex: 1; min-width: 0; }
.breakdown-label { font-size: 10px; color: var(--fg-dim); margin-bottom: 3px; }
.breakdown-bar { height: 6px; background: var(--panel-2); border: 1px solid var(--ink); overflow: hidden; }
.breakdown-bar-fill { height: 100%; width: 0%; transition: width .6s steps(10); }
.breakdown-pct { font-size: 11px; font-weight: 700; width: 34px; text-align: right; color: var(--fg); }
.compare-toggle {
display: flex;
margin: 16px 0 6px;
background: var(--panel-2);
border: 2px solid var(--ink);
padding: 3px;
gap: 3px;
}
.toggle-btn {
flex: 1;
border: none;
background: transparent;
color: var(--fg-dim);
padding: 8px 6px;
font-family: var(--pixel);
font-size: 8px;
cursor: pointer;
}
.toggle-btn.active { background: var(--panel-3); color: var(--fg); }
.modal-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.modal-actions .btn { flex: 1 1 auto; min-width: 110px; }
@media (max-width: 900px) {
body { height: auto; overflow-y: auto; }
#app { height: auto; max-height: none; }
.board { flex-direction: column; }
.canvas-wrap { max-height: 60vh; }
.panel-col { width: 100%; flex: 0 0 auto; }
.panel { grid-template-columns: repeat(3, 1fr); max-height: 40vh; }
.theme-grid { grid-template-columns: repeat(2, 1fr); }
}