initial commit (first version of game)
This commit is contained in:
commit
467447b51a
10 changed files with 1774 additions and 0 deletions
112
index.html
Normal file
112
index.html
Normal 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
71
src/colorUtils.js
Normal 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
510
src/game.js
Normal 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
102
src/main.js
Normal 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
101
src/particles.js
Normal 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
54
src/preview.js
Normal 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
60
src/sound.js
Normal 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
130
src/theme.js
Normal 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
125
src/tokenizer.js
Normal 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
509
style.css
Normal 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); }
|
||||
}
|
||||
Loading…
Reference in a new issue