From 467447b51a9a2d9171c6387391eac8a4503faa94 Mon Sep 17 00:00:00 2001 From: Gabriel Franco Date: Thu, 10 Sep 2026 10:15:02 -0300 Subject: [PATCH] initial commit (first version of game) --- index.html | 112 ++++++++++ src/colorUtils.js | 71 +++++++ src/game.js | 510 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.js | 102 ++++++++++ src/particles.js | 101 +++++++++ src/preview.js | 54 +++++ src/sound.js | 60 ++++++ src/theme.js | 130 ++++++++++++ src/tokenizer.js | 125 ++++++++++++ style.css | 509 +++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1774 insertions(+) create mode 100644 index.html create mode 100644 src/colorUtils.js create mode 100644 src/game.js create mode 100644 src/main.js create mode 100644 src/particles.js create mode 100644 src/preview.js create mode 100644 src/sound.js create mode 100644 src/theme.js create mode 100644 src/tokenizer.js create mode 100644 style.css diff --git a/index.html b/index.html new file mode 100644 index 0000000..711167f --- /dev/null +++ b/index.html @@ -0,0 +1,112 @@ + + + + + +Theme Guess โ€” Pixel Palette + + + + + + +
+
+
+ + + + +

ThemeGuess

+
+ +
+ +
+
+ +
+
+ 0 / 12 TOKEN KINDS THEMED +
+ +
+
+ + + +
+ + +
+
+ + + + + + + + + + diff --git a/src/colorUtils.js b/src/colorUtils.js new file mode 100644 index 0000000..597dd74 --- /dev/null +++ b/src/colorUtils.js @@ -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)); +} diff --git a/src/game.js b/src/game.js new file mode 100644 index 0000000..98a84b8 --- /dev/null +++ b/src/game.js @@ -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 = ` + + + ${def.icon} ${def.label} + ${this.categories[def.id].count || '1'} token${this.categories[def.id].count === 1 ? '' : 's'} + `; + 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 = ` +
+ + +
+
+
${row.icon} ${row.label}
+
+
+
${Math.round(row.sim)}%
`; + 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); + } +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..5d1a9e3 --- /dev/null +++ b/src/main.js @@ -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 = ` + + ${SWATCH_KEYS.map((k) => ``).join('')} + + ${theme.name}`; + 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 ? '๐Ÿ”‡' : '๐Ÿ”Š'; +}); diff --git a/src/particles.js b/src/particles.js new file mode 100644 index 0000000..00cafab --- /dev/null +++ b/src/particles.js @@ -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; + } +} diff --git a/src/preview.js b/src/preview.js new file mode 100644 index 0000000..d7a08df --- /dev/null +++ b/src/preview.js @@ -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); + } +} diff --git a/src/sound.js b/src/sound.js new file mode 100644 index 0000000..14d359e --- /dev/null +++ b/src/sound.js @@ -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(); diff --git a/src/theme.js b/src/theme.js new file mode 100644 index 0000000..1f65a60 --- /dev/null +++ b/src/theme.js @@ -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()); +} +`; diff --git a/src/tokenizer.js b/src/tokenizer.js new file mode 100644 index 0000000..9b8f38c --- /dev/null +++ b/src/tokenizer.js @@ -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))); +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..c122211 --- /dev/null +++ b/style.css @@ -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); } +}