From a38013d8d62b023b22c9edf7b678790c69ab7534 Mon Sep 17 00:00:00 2001 From: Alex Geslani Date: Fri, 24 Jul 2026 11:59:29 -0400 Subject: [PATCH 01/13] feat: add title and theme scaffolding --- package.json | 3 +- public/game.js | 35 +++++++ public/index.html | 20 +++- public/style.css | 186 +++++++++++++++++++++++++++++++++++-- public/theme.js | 138 +++++++++++++++++++++++++++ tests/theme.test.mjs | 215 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 587 insertions(+), 10 deletions(-) create mode 100644 public/theme.js create mode 100644 tests/theme.test.mjs diff --git a/package.json b/package.json index 1ea6a2e..3fbfa64 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "main": "server.js", "scripts": { - "start": "node server.js" + "start": "node server.js", + "test": "node --test tests/**/*.mjs" }, "dependencies": { "express": "^4.19.2" diff --git a/public/game.js b/public/game.js index d31fe5d..b416b7f 100644 --- a/public/game.js +++ b/public/game.js @@ -727,6 +727,41 @@ btnRotR.addEventListener('pointerdown', (e) => { tryRotate(rotateCW); }); +// ---- Theme integration ---- +(function initTheme() { + if (typeof ThemeModule === 'undefined') return; + ThemeModule.init(); + + // Wire up theme buttons + var themeBtns = document.querySelectorAll('.theme-btn'); + for (var i = 0; i < themeBtns.length; i++) { + themeBtns[i].addEventListener('click', (function(btn) { + return function() { + var themeName = btn.getAttribute('data-theme'); + if (!themeName) return; + ThemeModule.selectTheme(themeName); + ThemeModule.applyTheme(themeName); + // Update aria-checked on all theme buttons + for (var j = 0; j < themeBtns.length; j++) { + themeBtns[j].setAttribute('aria-checked', 'false'); + } + btn.setAttribute('aria-checked', 'true'); + }; + })(themeBtns[i])); + } + + // Sync aria-checked to saved theme + var saved = ThemeModule.loadSavedTheme ? ThemeModule.loadSavedTheme() : null; + var activeTheme = saved || 'Default'; + for (var k = 0; k < themeBtns.length; k++) { + if (themeBtns[k].getAttribute('data-theme') === activeTheme) { + themeBtns[k].setAttribute('aria-checked', 'true'); + } else { + themeBtns[k].setAttribute('aria-checked', 'false'); + } + } +})(); + state = 'home'; resetGameState(); showHome(); diff --git a/public/index.html b/public/index.html index 49a67dd..6377607 100644 --- a/public/index.html +++ b/public/index.html @@ -57,16 +57,31 @@

Stacklogic

/> - + + +
+ + Coming Soon + + Coming Soon +
+
+ Theme: + + + + +
+

High scores

    -

    Audio starts after Start.

    +

    Audio starts after Play Solo.

    @@ -94,6 +109,7 @@

    Game Over

    + diff --git a/public/style.css b/public/style.css index 22ee155..23b0a51 100644 --- a/public/style.css +++ b/public/style.css @@ -6,6 +6,46 @@ --border: #283244; } +/* Theme: Default */ +[data-theme="Default"] { + --bg: #0b0f14; + --fg: #e6edf3; + --muted: #9aa4af; + --panel: #0f1624; + --border: #283244; + --accent: #1d4ed8; +} + +/* Theme: Matrix */ +[data-theme="Matrix"] { + --bg: #0a0a0a; + --fg: #00ff41; + --muted: #008f11; + --panel: #0d1a0d; + --border: #003b00; + --accent: #00ff41; +} + +/* Theme: CandyPop */ +[data-theme="CandyPop"] { + --bg: #1a0a2e; + --fg: #ffb6c1; + --muted: #c77dba; + --panel: #2d1b4e; + --border: #4a2c7a; + --accent: #ff69b4; +} + +/* Theme: Dark */ +[data-theme="Dark"] { + --bg: #000000; + --fg: #ffffff; + --muted: #888888; + --panel: #111111; + --border: #333333; + --accent: #555555; +} + * { box-sizing: border-box; } html, body { @@ -42,7 +82,7 @@ body { canvas#game { display: block; - background: #111826; + background: var(--bg); border: 1px solid var(--border); width: 300px; height: 600px; @@ -91,6 +131,24 @@ canvas#game { border-color: #1d4ed8; } +[data-theme="Matrix"] .btn.primary { + background: #00ff41; + border-color: #00ff41; + color: #000; +} + +[data-theme="CandyPop"] .btn.primary { + background: #ff69b4; + border-color: #ff69b4; + color: #000; +} + +[data-theme="Dark"] .btn.primary { + background: #555555; + border-color: #555555; + color: #fff; +} + .overlay { position: absolute; inset: 0; @@ -111,16 +169,31 @@ canvas#game { background: rgba(15, 22, 36, 0.95); border: 1px solid var(--border); border-radius: 10px; - padding: 14px; + padding: 10px; +} + +[data-theme="Matrix"] .card { + background: rgba(10, 10, 10, 0.95); + border-color: #003b00; +} + +[data-theme="CandyPop"] .card { + background: rgba(26, 10, 46, 0.95); + border-color: #4a2c7a; +} + +[data-theme="Dark"] .card { + background: rgba(0, 0, 0, 0.95); + border-color: #333333; } .title { - margin: 0 0 10px; - font-size: 20px; + margin: 0 0 6px; + font-size: 18px; } .subhead { - margin: 14px 0 8px; + margin: 8px 0 4px; font-size: 14px; color: var(--muted); } @@ -139,7 +212,7 @@ canvas#game { .intro-wrap { width: 100%; - margin: 6px 0 8px; + margin: 2px 0 6px; display: grid; place-items: center; } @@ -147,7 +220,7 @@ canvas#game { .intro-gif { width: 100%; max-width: 320px; - max-height: 140px; + max-height: 85px; object-fit: contain; border-radius: 8px; border: 1px solid #1e2736; @@ -156,6 +229,95 @@ canvas#game { .intro-gif { display: block; } + +/* Multiplayer section */ +.multiplayer-section { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px 8px; + margin-top: 6px; +} + +.multiplayer-section .btn[disabled] { + opacity: 0.45; + cursor: not-allowed; +} + +#joinMatchBtn { + grid-row: 1; + grid-column: 2; + margin-top: 0; + padding: 8px 6px; +} + +#joinMatchHint { + grid-row: 2; + grid-column: 2; +} + +#createMatchBtn { + grid-row: 1; + grid-column: 1; + margin-top: 0; + padding: 8px 6px; +} + +#createMatchHint { + grid-row: 2; + grid-column: 1; +} + +.coming-soon-hint { + display: block; + font-size: 11px; + color: var(--muted); + text-align: center; + margin: 0; +} + +/* Theme controls */ +.theme-controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 6px; + margin-top: 8px; +} + +.theme-label { + font-size: 12px; + color: var(--muted); + margin-bottom: 2px; + grid-column: 1 / span 2; +} + +.theme-btn { + margin-top: 0; + font-size: 13px; + padding: 8px 10px; +} + +.theme-btn[aria-checked="true"] { + border-color: var(--accent, #1d4ed8); + background: var(--accent, #1d4ed8); + color: #fff; +} + +.theme-btn[aria-checked="false"] { + border-color: var(--border); +} + +.setting { + margin-top: 8px; +} + +.setting label { + font-size: 13px; + color: var(--fg); + display: flex; + align-items: center; + gap: 6px; +} + .mobile-controls { display: none; margin-top: 10px; @@ -299,3 +461,13 @@ canvas#game { margin-top: 10px; } } + +/* Reduced motion guard */ +@media (prefers-reduced-motion: reduce) { + /* Disable any ambient animation classes or transitions */ + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/public/theme.js b/public/theme.js new file mode 100644 index 0000000..8a5a599 --- /dev/null +++ b/public/theme.js @@ -0,0 +1,138 @@ +// Theme system for StackLogic +// Provides Default, Matrix, CandyPop, Dark themes with localStorage persistence. + +(function (root) { + 'use strict'; + + var STORAGE_KEY = 'stacklogic_theme_v1'; + var REDUCED_MOTION_KEY = 'prefers-reduced-motion'; + + // Theme definitions + var themes = { + Default: { + name: 'Default', + ambientAnimation: true, + cssVars: [ + ['--bg', '#0b0f14'], + ['--fg', '#e6edf3'], + ['--muted', '#9aa4af'], + ['--panel', '#0f1624'], + ['--border', '#283244'], + ['--accent', '#1d4ed8'] + ] + }, + Matrix: { + name: 'Matrix', + ambientAnimation: true, + cssVars: [ + ['--bg', '#0a0a0a'], + ['--fg', '#00ff41'], + ['--muted', '#008f11'], + ['--panel', '#0d1a0d'], + ['--border', '#003b00'], + ['--accent', '#00ff41'] + ] + }, + CandyPop: { + name: 'CandyPop', + ambientAnimation: true, + cssVars: [ + ['--bg', '#1a0a2e'], + ['--fg', '#ffb6c1'], + ['--muted', '#c77dba'], + ['--panel', '#2d1b4e'], + ['--border', '#4a2c7a'], + ['--accent', '#ff69b4'] + ] + }, + Dark: { + name: 'Dark', + ambientAnimation: false, + cssVars: [ + ['--bg', '#000000'], + ['--fg', '#ffffff'], + ['--muted', '#888888'], + ['--panel', '#111111'], + ['--border', '#333333'], + ['--accent', '#555555'] + ] + } + }; + + var currentTheme = null; + + // Load saved theme from localStorage + function loadSavedTheme() { + try { + var saved = root.localStorage.getItem(STORAGE_KEY); + if (saved && themes[saved]) { + return saved; + } + } catch (e) { + // localStorage unavailable + } + return null; + } + + // Select and persist a theme + function selectTheme(name) { + if (!themes[name]) return false; + currentTheme = name; + try { + root.localStorage.setItem(STORAGE_KEY, name); + } catch (e) { + // localStorage unavailable + } + return true; + } + + // Apply theme CSS variables to document + function applyTheme(name) { + var theme = themes[name]; + if (!theme) return []; + currentTheme = name; + var vars = []; + for (var i = 0; i < theme.cssVars.length; i++) { + var entry = theme.cssVars[i]; + var key = entry[0]; + var value = entry[1]; + root.document.documentElement.style.setProperty(key, value); + vars.push([key, value]); + } + root.document.documentElement.setAttribute('data-theme', name); + return vars; + } + + // Check if reduced motion is preferred + function prefersReducedMotion() { + try { + if (root.window.matchMedia) { + return !root.window.matchMedia('(prefers-reduced-motion: no-preference)').matches; + } + } catch (e) { + // matchMedia unavailable + } + return false; + } + + // Initialize theme on load + function init() { + var saved = loadSavedTheme(); + if (saved) { + applyTheme(saved); + } else { + applyTheme('Default'); + } + } + + // Expose public API + root.ThemeModule = { + themes: themes, + selectTheme: selectTheme, + applyTheme: applyTheme, + init: init, + prefersReducedMotion: prefersReducedMotion, + loadSavedTheme: loadSavedTheme + }; + +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/tests/theme.test.mjs b/tests/theme.test.mjs new file mode 100644 index 0000000..e468433 --- /dev/null +++ b/tests/theme.test.mjs @@ -0,0 +1,215 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const themeSrc = readFileSync(join(__dirname, '..', 'public', 'theme.js'), 'utf-8'); +const htmlSrc = readFileSync(join(__dirname, '..', 'public', 'index.html'), 'utf-8'); +const cssSrc = readFileSync(join(__dirname, '..', 'public', 'style.css'), 'utf-8'); +const gameSrc = readFileSync(join(__dirname, '..', 'public', 'game.js'), 'utf-8'); + +// Minimal browser-like environment for Node built-in tests. +const STORAGE = {}; +const mockStyle = {}; +mockStyle.setProperty = function(key, value) { this[key] = value; }; +mockStyle.getPropertyValue = function(key) { return this[key] || ''; }; +const mockDocumentElement = { + style: mockStyle, + getAttribute: (k) => (GLOBALS.docAttrs && GLOBALS.docAttrs[k]) || null, + setAttribute: (k, v) => { GLOBALS.docAttrs = GLOBALS.docAttrs || {}; GLOBALS.docAttrs[k] = v; }, +}; +const mockDocument = { + documentElement: mockDocumentElement, + createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }), + body: { style: {} }, +}; +const mockLocalStorage = { + getItem: (k) => STORAGE[k] || null, + setItem: (k, v) => { STORAGE[k] = String(v); }, +}; +const mockWindow = { + localStorage: mockLocalStorage, + document: mockDocument, + matchMedia: (q) => ({ + matches: q.includes('no-preference'), + addEventListener: () => {}, + removeEventListener: () => {}, + }), +}; + +const GLOBALS = { + window: mockWindow, + document: mockDocument, + localStorage: mockLocalStorage, + docAttrs: {}, +}; + +// Evaluate theme.js in a function that uses the injected globals. +// The IIFE attaches ThemeModule to `root` (which is `window` here). +new Function( + 'window', 'document', 'localStorage', + themeSrc +)(mockWindow, mockDocument, mockLocalStorage); + +const themeModule = mockWindow.ThemeModule; + +describe('Theme module', () => { + it('exports Default, Matrix, CandyPop, Dark theme definitions', () => { + assert.ok(themeModule.themes, 'themes object must exist'); + assert.ok(themeModule.themes.Default, 'Default theme must exist'); + assert.ok(themeModule.themes.Matrix, 'Matrix theme must exist'); + assert.ok(themeModule.themes.CandyPop, 'CandyPop theme must exist'); + assert.ok(themeModule.themes.Dark, 'Dark theme must exist'); + for (const key of ['Default', 'Matrix', 'CandyPop', 'Dark']) { + const t = themeModule.themes[key]; + assert.ok(typeof t.name === 'string', `${key}.name must be a string`); + assert.ok(typeof t.cssVars === 'object', `${key}.cssVars must be an object`); + assert.ok(Array.isArray(t.cssVars), `${key}.cssVars must be an array of [key, value] pairs`); + } + }); + + it('theme selection persists via localStorage key', () => { + for (const k of Object.keys(STORAGE)) delete STORAGE[k]; + themeModule.selectTheme('Matrix'); + const saved = mockLocalStorage.getItem('stacklogic_theme_v1'); + assert.strictEqual(saved, 'Matrix', 'localStorage key must store selected theme'); + }); + + it('theme application function exists and returns valid CSS variable map', () => { + assert.ok(typeof themeModule.applyTheme === 'function', 'applyTheme must be a function'); + const result = themeModule.applyTheme('CandyPop'); + assert.ok(Array.isArray(result), 'applyTheme must return an array'); + assert.ok(result.length > 0, 'applyTheme must return at least one CSS variable'); + for (const entry of result) { + assert.strictEqual(entry.length, 2, 'each entry must be [name, value]'); + assert.strictEqual(typeof entry[0], 'string', 'name must be a string'); + assert.strictEqual(typeof entry[1], 'string', 'value must be a string'); + } + }); + + it('reduced-motion preference blocks ambient animation flag', () => { + for (const key of ['Default', 'Matrix', 'CandyPop', 'Dark']) { + assert.ok('ambientAnimation' in themeModule.themes[key], `${key} must have ambientAnimation`); + } + }); + + // ---- Integration assertions (RED until UI is wired) ---- + + it('index.html contains theme control buttons for all four themes', () => { + for (const name of ['Default', 'Matrix', 'CandyPop', 'Dark']) { + assert.ok( + htmlSrc.includes(`data-theme="${name}"`) || htmlSrc.includes(`data-theme='${name}'`), + `index.html must contain a theme control button for ${name}` + ); + } + }); + + it('index.html contains Coming Soon labels for Create Match and Join Match', () => { + assert.ok( + htmlSrc.includes('Coming Soon'), + 'index.html must contain Coming Soon labels for multiplayer controls' + ); + // Verify the multiplayer buttons exist but are disabled/coming soon + assert.ok( + htmlSrc.includes('createMatchBtn') || htmlSrc.includes('createMatch'), + 'index.html must contain a Create Match button element' + ); + assert.ok( + htmlSrc.includes('joinMatchBtn') || htmlSrc.includes('joinMatch'), + 'index.html must contain a Join Match button element' + ); + }); + + it('index.html Play Solo button is the primary action', () => { + // startBtn should have Play Solo label (or Start with aria-label Play Solo) + const startBtnMatch = htmlSrc.match(/id="startBtn"[^>]*>([^<]*) { + // Each theme should have a CSS class or attribute selector that sets --bg + for (const name of ['Default', 'Matrix', 'CandyPop', 'Dark']) { + const pattern = new RegExp(`--bg:\\s*#[0-9a-fA-F]{6}`, 'g'); + const matches = cssSrc.match(pattern); + assert.ok(matches && matches.length >= 4, `style.css must define --bg for all themes (found ${matches ? matches.length : 0})`); + } + }); + + it('style.css contains prefers-reduced-motion media query', () => { + assert.ok( + cssSrc.includes('prefers-reduced-motion'), + 'style.css must contain prefers-reduced-motion media query' + ); + }); + + it('game.js loads and initializes the theme module', () => { + assert.ok( + gameSrc.includes('ThemeModule') || gameSrc.includes('theme.js'), + 'game.js must reference ThemeModule or theme.js' + ); + assert.ok( + gameSrc.includes('ThemeModule.init') || gameSrc.includes('themeModule.init') || gameSrc.includes('ThemeModule.applyTheme'), + 'game.js must call ThemeModule.init or ThemeModule.applyTheme' + ); + }); + + it('theme application sets CSS variables on document root', () => { + // Verify that applyTheme actually sets properties on documentElement.style + for (const k of Object.keys(STORAGE)) delete STORAGE[k]; + const result = themeModule.applyTheme('Matrix'); + assert.strictEqual(mockDocument.documentElement.style.getPropertyValue('--bg'), '#0a0a0a', 'Matrix --bg must be set on documentElement'); + assert.strictEqual(mockDocument.documentElement.style.getPropertyValue('--fg'), '#00ff41', 'Matrix --fg must be set on documentElement'); + }); + + it('Matrix and CandyPop have distinct visual presentation', () => { + const matrixVars = themeModule.themes.Matrix.cssVars; + const candyVars = themeModule.themes.CandyPop.cssVars; + const matrixBg = matrixVars.find(v => v[0] === '--bg'); + const candyBg = candyVars.find(v => v[0] === '--bg'); + assert.ok(matrixBg, 'Matrix must define --bg'); + assert.ok(candyBg, 'CandyPop must define --bg'); + assert.notStrictEqual(matrixBg[1], candyBg[1], 'Matrix and CandyPop must have different --bg values'); + const matrixFg = matrixVars.find(v => v[0] === '--fg'); + const candyFg = candyVars.find(v => v[0] === '--fg'); + assert.notStrictEqual(matrixFg[1], candyFg[1], 'Matrix and CandyPop must have different --fg values'); + }); + + // ---- Bounded correction: data-theme attribute and themed canvas ---- + + it('applyTheme sets data-theme attribute on documentElement', () => { + for (const k of Object.keys(STORAGE)) delete STORAGE[k]; + GLOBALS.docAttrs = {}; + themeModule.applyTheme('Dark'); + assert.strictEqual( + mockDocument.documentElement.getAttribute('data-theme'), + 'Dark', + 'applyTheme must set data-theme on documentElement' + ); + themeModule.applyTheme('CandyPop'); + assert.strictEqual( + mockDocument.documentElement.getAttribute('data-theme'), + 'CandyPop', + 'applyTheme must update data-theme when theme changes' + ); + }); + + it('style.css uses a CSS variable for canvas#game background, not a hardcoded color', () => { + // The canvas background must reference a CSS variable (e.g. var(--bg)) + // rather than a literal hex color, so theme selectors can control it. + const canvasRule = cssSrc.match(/canvas#game\s*\{[^}]*background\s*:\s*([^;]+);/); + assert.ok(canvasRule, 'canvas#game must have a background declaration'); + const bgValue = canvasRule[1].trim(); + assert.ok( + bgValue.startsWith('var('), + `canvas#game background must use a CSS variable (found: ${bgValue})` + ); + }); +}); From bd9ff25ad4512b3889411a0bc18d3617fef94b96 Mon Sep 17 00:00:00 2001 From: Alex Geslani Date: Fri, 24 Jul 2026 14:14:15 -0400 Subject: [PATCH 02/13] feat: add theme-aware brick renderer foundation --- public/game.js | 26 ++-- public/index.html | 1 + public/theme-renderer.js | 245 ++++++++++++++++++++++++++++++++ public/theme.js | 3 +- tests/theme-renderer.test.mjs | 258 ++++++++++++++++++++++++++++++++++ 5 files changed, 517 insertions(+), 16 deletions(-) create mode 100644 public/theme-renderer.js create mode 100644 tests/theme-renderer.test.mjs diff --git a/public/game.js b/public/game.js index b416b7f..1a0c753 100644 --- a/public/game.js +++ b/public/game.js @@ -380,10 +380,9 @@ function updateHUD() { } function drawCell(x, y, color) { - ctx.fillStyle = color; - ctx.fillRect(x * CELL, y * CELL, CELL, CELL); - ctx.strokeStyle = 'rgba(0,0,0,0.25)'; - ctx.strokeRect(x * CELL + 0.5, y * CELL + 0.5, CELL - 1, CELL - 1); + var themeId = (typeof window !== 'undefined' && window.ThemeModule && window.ThemeModule.getCurrentTheme) + ? window.ThemeModule.getCurrentTheme() : 'Default'; + drawBrick(ctx, x, y, color, themeId, CELL); } function draw() { @@ -433,12 +432,9 @@ function draw() { for (let y = 0; y < shape.length; y++) { for (let x = 0; x < shape[y].length; x++) { if (!shape[y][x]) continue; - const px = basePx + x * small; - const py = basePy + y * small; - ctx.fillStyle = COLORS[nextType]; - ctx.fillRect(px, py, small, small); - ctx.strokeStyle = 'rgba(0,0,0,0.25)'; - ctx.strokeRect(px + 0.5, py + 0.5, small - 1, small - 1); + drawBrickAt(ctx, basePx + x * small, basePy + y * small, COLORS[nextType], + (typeof window !== 'undefined' && window.ThemeModule && window.ThemeModule.getCurrentTheme) + ? window.ThemeModule.getCurrentTheme() : 'Default', small); } } @@ -729,8 +725,8 @@ btnRotR.addEventListener('pointerdown', (e) => { // ---- Theme integration ---- (function initTheme() { - if (typeof ThemeModule === 'undefined') return; - ThemeModule.init(); + if (typeof window === 'undefined' || !window.ThemeModule) return; + window.ThemeModule.init(); // Wire up theme buttons var themeBtns = document.querySelectorAll('.theme-btn'); @@ -739,8 +735,8 @@ btnRotR.addEventListener('pointerdown', (e) => { return function() { var themeName = btn.getAttribute('data-theme'); if (!themeName) return; - ThemeModule.selectTheme(themeName); - ThemeModule.applyTheme(themeName); + window.ThemeModule.selectTheme(themeName); + window.ThemeModule.applyTheme(themeName); // Update aria-checked on all theme buttons for (var j = 0; j < themeBtns.length; j++) { themeBtns[j].setAttribute('aria-checked', 'false'); @@ -751,7 +747,7 @@ btnRotR.addEventListener('pointerdown', (e) => { } // Sync aria-checked to saved theme - var saved = ThemeModule.loadSavedTheme ? ThemeModule.loadSavedTheme() : null; + var saved = window.ThemeModule.loadSavedTheme ? window.ThemeModule.loadSavedTheme() : null; var activeTheme = saved || 'Default'; for (var k = 0; k < themeBtns.length; k++) { if (themeBtns[k].getAttribute('data-theme') === activeTheme) { diff --git a/public/index.html b/public/index.html index 6377607..214f071 100644 --- a/public/index.html +++ b/public/index.html @@ -110,6 +110,7 @@

    Game Over

    + diff --git a/public/theme-renderer.js b/public/theme-renderer.js new file mode 100644 index 0000000..1ec8404 --- /dev/null +++ b/public/theme-renderer.js @@ -0,0 +1,245 @@ +// Theme brick renderer for StackLogic +// Data-driven renderer API: drawBrick, getRenderer, getActiveRenderer +// Attaches to globalThis so it works in both browser and Node test context. + +(function (root) { + 'use strict'; + + // ---- Renderer descriptors ---- + // Each descriptor defines the visual style for one theme. + var renderers = { + Default: { + themeId: 'Default', + shape: 'rect', + corners: 0, + stroke: true, + strokeStyle: 'rgba(0,0,0,0.25)', + strokeOffset: 0.5, + glyphs: null, + cornerRadius: 0, + highlight: null, + gradient: false, + fillStyle: null, + }, + Matrix: { + themeId: 'Matrix', + shape: 'rect', + corners: 0, + stroke: true, + strokeStyle: 'rgba(0,255,65,0.4)', + strokeOffset: 0.5, + glyphs: ['\u2593', '\u2592', '\u2591', '\u2588', '\u2580', '\u2584', '\u258c', '\u2590', '\u2595', '\u2596'], + cornerRadius: 0, + highlight: null, + gradient: false, + fillStyle: null, + }, + CandyPop: { + themeId: 'CandyPop', + shape: 'rounded', + corners: 4, + stroke: true, + strokeStyle: 'rgba(0,0,0,0.15)', + strokeOffset: 0.5, + glyphs: null, + cornerRadius: 6, + highlight: { + type: 'glossy', + xRatio: 0.15, + yRatio: 0.1, + widthRatio: 0.7, + heightRatio: 0.35, + color: 'rgba(255,255,255,0.45)', + }, + gradient: true, + fillStyle: null, + }, + Dark: { + themeId: 'Dark', + shape: 'rect', + corners: 0, + stroke: true, + strokeStyle: 'rgba(255,255,255,0.15)', + strokeOffset: 0.5, + glyphs: null, + cornerRadius: 0, + highlight: null, + gradient: false, + fillStyle: null, + }, + }; + + // ---- drawBrick(ctx, x, y, color, themeId, cellSize) ---- + function drawBrick(ctx, x, y, color, themeId, cellSize) { + var r = getRenderer(themeId); + var px = x * cellSize; + var py = y * cellSize; + var size = cellSize; + + // Save context state for themes that modify it + ctx.save(); + + if (r.themeId === 'CandyPop') { + drawCandyPop(ctx, px, py, size, color, r); + } else if (r.themeId === 'Matrix') { + drawMatrix(ctx, px, py, size, color, r); + } else if (r.themeId === 'Dark') { + drawDark(ctx, px, py, size, color, r); + } else { + // Default + drawDefault(ctx, px, py, size, color, r); + } + + ctx.restore(); + } + + function drawDefault(ctx, px, py, size, color, r) { + ctx.fillStyle = color; + ctx.fillRect(px, py, size, size); + if (r.stroke) { + ctx.strokeStyle = r.strokeStyle; + ctx.strokeRect(px + r.strokeOffset, py + r.strokeOffset, size - r.strokeOffset * 2, size - r.strokeOffset * 2); + } + } + + function drawMatrix(ctx, px, py, size, color, r) { + // Dark background with green tint + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(px, py, size, size); + + // Terminal-style outline + if (r.stroke) { + ctx.strokeStyle = r.strokeStyle; + ctx.lineWidth = 1; + ctx.strokeRect(px + r.strokeOffset, py + r.strokeOffset, size - r.strokeOffset * 2, size - r.strokeOffset * 2); + } + + // ASCII glyph face in the center — deterministic selection from brick inputs + if (r.glyphs && r.glyphs.length > 0) { + ctx.fillStyle = color; + ctx.font = 'bold ' + Math.floor(size * 0.6) + 'px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + // Deterministic glyph index from pixel coordinates (no Math.random) + var glyphIndex = Math.abs(((px * 31 + py * 17) | 0) % r.glyphs.length); + var glyph = r.glyphs[glyphIndex]; + ctx.fillText(glyph, px + size / 2, py + size / 2); + } + } + + function drawCandyPop(ctx, px, py, size, color, r) { + var radius = r.cornerRadius; + var half = size / 2; + + // Rounded rect fill with gradient + ctx.beginPath(); + ctx.moveTo(px + radius, py); + ctx.lineTo(px + size - radius, py); + ctx.quadraticCurveTo(px + size, py, px + size, py + radius); + ctx.lineTo(px + size, py + size - radius); + ctx.quadraticCurveTo(px + size, py + size, px + size - radius, py + size); + ctx.lineTo(px + radius, py + size); + ctx.quadraticCurveTo(px, py + size, px, py + size - radius); + ctx.lineTo(px, py + radius); + ctx.quadraticCurveTo(px, py, px + radius, py); + ctx.closePath(); + + if (r.gradient) { + var grad = ctx.createLinearGradient(px, py, px, py + size); + grad.addColorStop(0, lightenColor(color, 30)); + grad.addColorStop(1, color); + ctx.fillStyle = grad; + } else { + ctx.fillStyle = color; + } + ctx.fill(); + + // Subtle stroke + if (r.stroke) { + ctx.strokeStyle = r.strokeStyle; + ctx.lineWidth = 1; + ctx.stroke(); + } + + // Glossy highlight + if (r.highlight) var h = r.highlight; + if (h) { + var hx = px + size * h.xRatio; + var hy = py + size * h.yRatio; + var hw = size * h.widthRatio; + var hh = size * h.heightRatio; + ctx.fillStyle = h.color; + ctx.beginPath(); + ctx.ellipse(hx + hw / 2, hy + hh / 2, hw / 2, hh / 2, 0, 0, Math.PI * 2); + ctx.fill(); + } + } + + function drawDark(ctx, px, py, size, color, r) { + // Flat fill + ctx.fillStyle = color; + ctx.fillRect(px, py, size, size); + + // High-contrast thin stroke + if (r.stroke) { + ctx.strokeStyle = r.strokeStyle; + ctx.lineWidth = 1; + ctx.strokeRect(px + r.strokeOffset, py + r.strokeOffset, size - r.strokeOffset * 2, size - r.strokeOffset * 2); + } + } + + // ---- Color utility ---- + function lightenColor(hex, percent) { + var num = parseInt(hex.replace('#', ''), 16); + var r = Math.min(255, (num >> 16) + percent); + var g = Math.min(255, ((num >> 8) & 0x00ff) + percent); + var b = Math.min(255, (num & 0x0000ff) + percent); + return 'rgb(' + r + ',' + g + ',' + b + ')'; + } + + // ---- drawBrickAt(ctx, px, py, color, themeId, cellSize) ---- + // Pixel-coordinate entry point: px/py are already in canvas pixels. + // Used for the next-piece preview where coordinates are computed in pixels. + function drawBrickAt(ctx, px, py, color, themeId, cellSize) { + var r = getRenderer(themeId); + + ctx.save(); + + if (r.themeId === 'CandyPop') { + drawCandyPop(ctx, px, py, cellSize, color, r); + } else if (r.themeId === 'Matrix') { + drawMatrix(ctx, px, py, cellSize, color, r); + } else if (r.themeId === 'Dark') { + drawDark(ctx, px, py, cellSize, color, r); + } else { + drawDefault(ctx, px, py, cellSize, color, r); + } + + ctx.restore(); + } + + // ---- getRenderer(themeId) ---- + function getRenderer(themeId) { + if (renderers[themeId]) { + return renderers[themeId]; + } + // Fallback to Default for unknown themes + return renderers.Default; + } + + // ---- getActiveRenderer() ---- + function getActiveRenderer() { + if (root.ThemeModule && typeof root.ThemeModule.getCurrentTheme === 'function') { + return getRenderer(root.ThemeModule.getCurrentTheme()); + } + return renderers.Default; + } + + // ---- Exports ---- + root.drawBrick = drawBrick; + root.drawBrickAt = drawBrickAt; + root.getRenderer = getRenderer; + root.getActiveRenderer = getActiveRenderer; + root.THEME_KEYS = ['Default', 'Matrix', 'CandyPop', 'Dark']; + +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/public/theme.js b/public/theme.js index 8a5a599..87927e3 100644 --- a/public/theme.js +++ b/public/theme.js @@ -132,7 +132,8 @@ applyTheme: applyTheme, init: init, prefersReducedMotion: prefersReducedMotion, - loadSavedTheme: loadSavedTheme + loadSavedTheme: loadSavedTheme, + getCurrentTheme: function () { return currentTheme; } }; })(typeof window !== 'undefined' ? window : globalThis); diff --git a/tests/theme-renderer.test.mjs b/tests/theme-renderer.test.mjs new file mode 100644 index 0000000..aedfe39 --- /dev/null +++ b/tests/theme-renderer.test.mjs @@ -0,0 +1,258 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +// Load the module under test. It attaches to globalThis. +import '../public/theme-renderer.js'; + +// ---- helpers ---- +function makeMockCtx() { + const calls = { + fillRect: [], + strokeRect: [], + fillStyle: [], + strokeStyle: [], + font: [], + fillText: [], + beginPath: [], + arc: [], + fill: [], + stroke: [], + linearGradient: [], + addColorStop: [], + save: [], + restore: [], + }; + const ctx = { + get fillStyle() { return calls.fillStyle[calls.fillStyle.length - 1] ?? null; }, + set fillStyle(v) { calls.fillStyle.push(v); }, + get strokeStyle() { return calls.strokeStyle[calls.strokeStyle.length - 1] ?? null; }, + set strokeStyle(v) { calls.strokeStyle.push(v); }, + get font() { return calls.font[calls.font.length - 1] ?? null; }, + set font(v) { calls.font.push(v); }, + fillRect(x, y, w, h) { calls.fillRect.push({ x, y, w, h }); }, + strokeRect(x, y, w, h) { calls.strokeRect.push({ x, y, w, h }); }, + fillText(text, x, y) { calls.fillText.push({ text, x, y }); }, + beginPath() { calls.beginPath.push(); }, + arc(x, y, r, start, end) { calls.arc.push({ x, y, r, start, end }); }, + fill() { calls.fill.push(); }, + stroke() { calls.stroke.push(); }, + createLinearGradient(x0, y0, x1, y1) { + calls.linearGradient.push({ x0, y0, x1, y1 }); + return { addColorStop: (offset, color) => calls.addColorStop.push({ offset, color }) }; + }, + save() { calls.save.push(); }, + restore() { calls.restore.push(); }, + }; + return { ctx, calls }; +} + +// ---- tests ---- + +describe('theme-renderer: exports', () => { + it('exports drawBrick as a function', () => { + assert.strictEqual(typeof globalThis.drawBrick, 'function'); + }); + + it('exports getRenderer as a function', () => { + assert.strictEqual(typeof globalThis.getRenderer, 'function'); + }); + + it('exports getActiveRenderer as a function', () => { + assert.strictEqual(typeof globalThis.getActiveRenderer, 'function'); + }); + + it('exports THEME_KEYS as an array of four theme names', () => { + assert.ok(Array.isArray(globalThis.THEME_KEYS)); + assert.strictEqual(globalThis.THEME_KEYS.length, 4); + assert.ok(globalThis.THEME_KEYS.includes('Default')); + assert.ok(globalThis.THEME_KEYS.includes('Matrix')); + assert.ok(globalThis.THEME_KEYS.includes('CandyPop')); + assert.ok(globalThis.THEME_KEYS.includes('Dark')); + }); +}); + +describe('theme-renderer: theme identity', () => { + it('Default and Matrix return distinct descriptors', () => { + const d = globalThis.getRenderer('Default'); + const m = globalThis.getRenderer('Matrix'); + assert.notStrictEqual(d, m); + assert.strictEqual(d.themeId, 'Default'); + assert.strictEqual(m.themeId, 'Matrix'); + }); + + it('CandyPop and Dark return distinct descriptors', () => { + const c = globalThis.getRenderer('CandyPop'); + const dk = globalThis.getRenderer('Dark'); + assert.notStrictEqual(c, dk); + assert.strictEqual(c.themeId, 'CandyPop'); + assert.strictEqual(dk.themeId, 'Dark'); + }); + + it('all four themes return descriptors with shape and corners fields', () => { + for (const key of globalThis.THEME_KEYS) { + const r = globalThis.getRenderer(key); + assert.ok(r.shape, `${key} missing shape`); + assert.ok(r.corners !== undefined, `${key} missing corners`); + } + }); +}); + +describe('theme-renderer: geometry preserved', () => { + it('drawBrick uses the passed cellSize for positioning', () => { + const { ctx, calls } = makeMockCtx(); + globalThis.drawBrick(ctx, 2, 3, '#ff0000', 'Default', 30); + const rect = calls.fillRect[0]; + assert.strictEqual(rect.x, 60); // 2 * 30 + assert.strictEqual(rect.y, 90); // 3 * 30 + assert.strictEqual(rect.w, 30); + assert.strictEqual(rect.h, 30); + }); + + it('drawBrick with cellSize 30 produces 30x30 bricks', () => { + const { ctx, calls } = makeMockCtx(); + globalThis.drawBrick(ctx, 0, 0, '#ff0000', 'Default', 30); + const rect = calls.fillRect[0]; + assert.strictEqual(rect.w, 30); + assert.strictEqual(rect.h, 30); + }); + + it('drawBrick respects arbitrary cellSize', () => { + const { ctx, calls } = makeMockCtx(); + globalThis.drawBrick(ctx, 1, 1, '#ff0000', 'Default', 20); + const rect = calls.fillRect[0]; + assert.strictEqual(rect.x, 20); + assert.strictEqual(rect.y, 20); + assert.strictEqual(rect.w, 20); + assert.strictEqual(rect.h, 20); + }); +}); + +describe('theme-renderer: unknown fallback', () => { + it('getRenderer("Nonexistent") returns the Default renderer', () => { + const fallback = globalThis.getRenderer('Nonexistent'); + assert.strictEqual(fallback.themeId, 'Default'); + }); +}); + +describe('theme-renderer: matrix glyphs', () => { + it('Matrix renderer descriptor includes glyph/ASCII face tokens', () => { + const m = globalThis.getRenderer('Matrix'); + assert.ok(m.glyphs, 'Matrix missing glyphs field'); + assert.ok(Array.isArray(m.glyphs), 'Matrix glyphs should be an array'); + assert.ok(m.glyphs.length > 0, 'Matrix glyphs should not be empty'); + const allAscii = m.glyphs.every((g) => typeof g === 'string' && g.length === 1); + assert.ok(allAscii, 'Matrix glyphs should be single ASCII characters'); + }); +}); + +describe('theme-renderer: candyPop rounded', () => { + it('CandyPop renderer has nonzero cornerRadius', () => { + const c = globalThis.getRenderer('CandyPop'); + assert.ok(c.cornerRadius > 0, 'CandyPop cornerRadius should be > 0'); + }); + + it('CandyPop renderer has highlight tokens', () => { + const c = globalThis.getRenderer('CandyPop'); + assert.ok(c.highlight !== undefined, 'CandyPop missing highlight'); + assert.ok(c.highlight !== null, 'CandyPop highlight should not be null'); + }); +}); + +describe('theme-renderer: getActiveRenderer', () => { + it('getActiveRenderer returns renderer for current theme', () => { + globalThis.ThemeModule = { getCurrentTheme: () => 'Matrix' }; + const r = globalThis.getActiveRenderer(); + assert.strictEqual(r.themeId, 'Matrix'); + }); + + it('getActiveRenderer falls back to Default when no ThemeModule', () => { + delete globalThis.ThemeModule; + const r = globalThis.getActiveRenderer(); + assert.strictEqual(r.themeId, 'Default'); + }); +}); + +// ---- RED tests for contract-amendment-1 required corrections ---- + +describe('amendment: index.html renderer script load order', () => { + it('theme-renderer.js appears after theme.js and before game.js in index.html', async () => { + const fs = await import('node:fs'); + const html = fs.readFileSync( + new URL('../public/index.html', import.meta.url), + 'utf8' + ); + const themeIdx = html.indexOf(' - - + + + diff --git a/public/theme-rain-adapter.js b/public/theme-rain-adapter.js index 3ce1829..be17fba 100644 --- a/public/theme-rain-adapter.js +++ b/public/theme-rain-adapter.js @@ -1,7 +1,7 @@ // Matrix rain browser adapter — wraps the accepted lifecycle controller. // Exports createRainBrowserAdapter(deps) and a frozen bounded RAIN_GLYPHS constant. -import { createRainController } from './theme-rain-controller.js'; +import { createRainController } from './theme-rain-controller.js?v=0.2.0-beta.1'; export const RAIN_GLYPHS = Object.freeze([ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', diff --git a/public/theme-rain-bootstrap.js b/public/theme-rain-bootstrap.js index c9e85c4..5189bc6 100644 --- a/public/theme-rain-bootstrap.js +++ b/public/theme-rain-bootstrap.js @@ -1,7 +1,7 @@ // Browser-only dependency wiring for the accepted Matrix rain adapter. // Importing this module has no DOM, listener, or animation side effects. -import { createRainBrowserAdapter } from './theme-rain-adapter.js'; +import { createRainBrowserAdapter } from './theme-rain-adapter.js?v=0.2.0-beta.1'; export function createThemeRainBootstrap(root) { const document = root.document; diff --git a/tests/release-metadata.test.mjs b/tests/release-metadata.test.mjs new file mode 100644 index 0000000..0723520 --- /dev/null +++ b/tests/release-metadata.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { describe, it } from 'node:test'; + +const RELEASE = '0.2.0-beta.1'; +const [indexHtml, gameJs, bootstrapJs, adapterJs, readme, changelog, packageJson, packageLock] = await Promise.all([ + readFile(new URL('../public/index.html', import.meta.url), 'utf8'), + readFile(new URL('../public/game.js', import.meta.url), 'utf8'), + readFile(new URL('../public/theme-rain-bootstrap.js', import.meta.url), 'utf8'), + readFile(new URL('../public/theme-rain-adapter.js', import.meta.url), 'utf8'), + readFile(new URL('../README.md', import.meta.url), 'utf8'), + readFile(new URL('../CHANGELOG.md', import.meta.url), 'utf8'), + readFile(new URL('../package.json', import.meta.url), 'utf8').then(JSON.parse), + readFile(new URL('../package-lock.json', import.meta.url), 'utf8').then(JSON.parse), +]); + +describe('release metadata', () => { + it('uses one v0.2.0-beta.1 version across package metadata and README', () => { + assert.equal(packageJson.version, RELEASE); + assert.equal(packageLock.version, RELEASE); + assert.equal(packageLock.packages[''].version, RELEASE); + assert.match(readme, /\*\*Version:\*\* v0\.2\.0-beta\.1/); + }); + + it('publishes a machine-readable release marker', () => { + assert.match(indexHtml, //); + }); + + it('cache-busts every shipped CSS and JavaScript entry asset with the release version', () => { + for (const asset of ['style.css', 'theme.js', 'theme-renderer.js', 'game.js']) { + assert.match(indexHtml, new RegExp(`${asset.replace('.', '\\.') }\\?v=${RELEASE.replaceAll('.', '\\.').replace('-', '\\-')}`)); + } + + assert.match(gameJs, /theme-rain-bootstrap\.js\?v=0\.2\.0-beta\.1/); + assert.match(bootstrapJs, /theme-rain-adapter\.js\?v=0\.2\.0-beta\.1/); + assert.match(adapterJs, /theme-rain-controller\.js\?v=0\.2\.0-beta\.1/); + }); + + it('documents both beta releases and retains an Unreleased section', () => { + assert.match(changelog, /## \[Unreleased\]/); + assert.match(changelog, /## \[0\.2\.0-beta\.1\] - 2026-07-25/); + assert.match(changelog, /## \[0\.1\.0-beta\.1\] - 2026-02-08/); + }); +}); diff --git a/tests/theme-rain-adapter.test.mjs b/tests/theme-rain-adapter.test.mjs index e12a981..59de8ff 100644 --- a/tests/theme-rain-adapter.test.mjs +++ b/tests/theme-rain-adapter.test.mjs @@ -482,7 +482,11 @@ describe('adapter: tests use real createRainController and stored non-auto-runni it('adapter imports createRainController from the real controller module, callbacks are stored not auto-running', () => { const adapterPath = join(__dirname, '..', 'public', 'theme-rain-adapter.js'); const source = readFileSync(adapterPath, 'utf-8'); - assert.ok(source.includes("from './theme-rain-controller.js'"), 'adapter imports real controller'); + assert.match( + source, + /from '\.\/theme-rain-controller\.js(?:\?v=[^']+)?'/, + 'adapter imports real controller' + ); assert.ok(source.includes('createRainController'), 'adapter uses createRainController'); }); }); diff --git a/tests/theme-renderer.test.mjs b/tests/theme-renderer.test.mjs index c8444dd..76818c4 100644 --- a/tests/theme-renderer.test.mjs +++ b/tests/theme-renderer.test.mjs @@ -199,9 +199,9 @@ describe('amendment: index.html renderer script load order', () => { new URL('../public/index.html', import.meta.url), 'utf8' ); - const themeIdx = html.indexOf('