diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 2305858..d858868 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,21 +1,56 @@ -name: Deploy to GitHub Pages +name: Test and deploy to GitHub Pages on: + pull_request: + branches: [main] push: branches: [main] workflow_dispatch: permissions: contents: read - pages: write - id-token: write concurrency: - group: pages + group: pages-${{ github.ref }} cancel-in-progress: true jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Run tests + run: npm test + + - name: Check shipped JavaScript syntax + run: | + node --check public/game.js + node --check public/theme.js + node --check public/theme-renderer.js + node --check public/theme-rain-adapter.js + node --check public/theme-rain-bootstrap.js + node --check public/theme-rain-controller.js + deploy: + name: deploy + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + needs: test + permissions: + contents: read + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -24,7 +59,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - # This project serves static files from ./public - name: Configure Pages uses: actions/configure-pages@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5228430..b0c07fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,39 @@ All notable changes to this project are recorded here. -## Unreleased -- Added title-screen checkbox to toggle "Preview next block" (localStorage key: stacklogic_preview_next_v1). -- Implemented next-piece preview rendering on canvas and prefetched next piece logic. -- Fixed preview position so it renders inside the canvas. -- Added iOS double-tap guard for on-screen controls to reduce accidental zooming. +## [Unreleased] + +## [0.2.0-beta.1] - 2026-07-25 + +### Added +- Added a title screen with Default, Matrix, CandyPop, and Dark themes. +- Added theme-aware piece rendering and persistent local theme selection. +- Added Matrix rain with single-owner animation lifecycle, resize and DPR handling, reduced-motion support, cleanup fencing, and non-interference with gameplay controls. +- Added a dedicated pause menu with synchronized theme selection and explicit resume behavior. +- Added Arrow-key and Home/End navigation with roving focus for theme radio groups. +- Added deterministic unit and real-browser acceptance coverage for themes, rain lifecycle, browser integration, and responsive controls. + +### Changed +- Matrix game pieces now use deterministic solid green shades without glyph faces. +- Dark game pieces now use deterministic solid black-to-grey shades. +- Wide touch devices such as iPad Pro now expose on-screen controls based on coarse/no-hover input capability rather than viewport width alone. + +### Fixed +- Preserved Matrix stream identity and geometry across viewport and DPR changes. +- Prevented duplicate animation ownership, stale callbacks, interaction interception, and reduced-motion resource allocation. +- Stopped and restarted Matrix rain when the operating-system reduced-motion preference changes live. +- Corrected selected Matrix and CandyPop theme-label contrast to meet WCAG AA. +- Remediated known Express transitive dependency advisories in the development/server toolchain. + +## [0.1.0-beta.1] - 2026-02-08 + +### Added +- Added a title-screen checkbox to toggle the next-block preview using localStorage key `stacklogic_preview_next_v1`. +- Added next-piece preview rendering and prefetched next-piece logic. +- Added localStorage-only high scores for GitHub Pages. +- Added GitHub Pages deployment. + +### Fixed +- Kept the next-piece preview inside the canvas. +- Added an iOS double-tap guard for on-screen controls. +- Corrected GitHub Pages paths for music and intro assets. diff --git a/README.md b/README.md index b487c42..4e92797 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # StackLogic -**Version:** v0.1.0-beta.1 +**Version:** v0.2.0-beta.1 A fast-paced, block-stacking arcade puzzler with crisp controls and escalating challenge. diff --git a/docs/releases/v0.2.0-beta.1.md b/docs/releases/v0.2.0-beta.1.md new file mode 100644 index 0000000..97d05fe --- /dev/null +++ b/docs/releases/v0.2.0-beta.1.md @@ -0,0 +1,66 @@ +# StackLogic v0.2.0-beta.1 + +Release date: 2026-07-25 + +Public URL: https://vector-sigma-works.github.io/StackLogic/ + +## Summary + +This beta minor release adds StackLogic's theme system and the first accepted Matrix experience while preserving gameplay geometry and pacing. Players can select Default, Matrix, CandyPop, or Dark from the title screen and can change themes while paused without restarting or resuming the game. + +## User-visible changes + +- Four persistent themes: Default, Matrix, CandyPop, and Dark. +- Matrix rain behind the game and UI, with input-safe stacking. +- Solid Matrix pieces in deterministic green shades. +- Solid Dark pieces in deterministic black-to-grey shades. +- Theme selection in a dedicated pause menu. +- Accessible Arrow-key and Home/End theme navigation with synchronized roving focus. +- Wide touch-device controls for iPad-class landscape and portrait viewports. +- Reduced-motion behavior that creates no Matrix rain animation resources and stops/restarts rain when the preference changes live. + +## Validated implementation chain + +1. `a38013d` — title and theme scaffolding +2. `bd9ff25` — theme-aware renderer foundation +3. `2bc4a6c` — Matrix rain lifecycle controller +4. `712d2f4` — synchronous theme-change contract +5. `a124129` — deterministic Matrix rain adapter +6. `156d81e` — browser Matrix rain integration +7. `def2ddc` — solid theme palettes, tablet controls, and pause themes + +The release is merged with history preserved so each independently validated phase remains auditable. + +## Verification + +The release candidate passed: + +- 80 canonical tests before release metadata coverage was added; +- focused renderer, lifecycle, adapter, and bootstrap tests; +- real-browser Matrix ownership, stacking, hit-testing, persistence, resize/DPR, reduced-motion, and cleanup gates; +- wide iPad-class landscape and portrait visibility/hit-testing gates; +- pause-time Dark and Matrix switching while the board remained frozen; +- an exact canvas-fill trace against `stacklogic-dev.game.lan`, proving Matrix fills belong only to the approved green palette and Dark fills only to the approved grayscale palette; +- changed-line security and scope scans; +- real-browser keyboard/focus acceptance and computed selected-label contrast above 4.5:1; +- real-browser live reduced-motion stop/restart acceptance without reload; +- `npm audit --omit=dev`: 0 vulnerabilities after lockfile-only transitive remediation; +- fresh independent semantic review. + +Final release test counts and public GitHub Pages evidence are recorded in the pull request and GitHub Release after deployment. + +## Compatibility and data + +- No gameplay board geometry, timing, scoring, or save-format migration is introduced. +- Existing local high scores and preferences remain in browser localStorage. +- No runtime dependency was added for the theme or rain implementation. + +## Known limitation + +Web applications cannot directly observe whether an iPad hardware keyboard is docked. On-screen controls use the standards-based `(hover: none) and (pointer: coarse)` input-capability proxy. Automated iPad-class viewport emulation passes; physical Safari behavior remains a hardware/browser acceptance boundary. + +## Rollback + +The pre-release production baseline is tag `v0.1.0-beta.1` at commit `07e5f1772f1daab2764bbe5c5f3f3cb4d09fca34`. + +If public verification fails, revert the release merge commit through a reviewed rollback pull request and allow the gated GitHub Pages workflow to redeploy. Do not force-reset `main` or move release tags. diff --git a/package-lock.json b/package-lock.json index 28ca346..a06a683 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stacklogic-app", - "version": "0.1.0", + "version": "0.2.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stacklogic-app", - "version": "0.1.0", + "version": "0.2.0-beta.1", "dependencies": { "express": "^4.19.2" } @@ -31,9 +31,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -44,7 +44,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -204,9 +204,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -231,14 +231,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -257,7 +257,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -383,9 +383,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -559,9 +559,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/proxy-addr": { @@ -578,12 +578,13 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -694,14 +695,14 @@ "license": "ISC" }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -713,13 +714,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index 1ea6a2e..e0b894b 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "stacklogic-app", - "version": "0.1.0", + "version": "0.2.0-beta.1", "private": true, "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..6d9f720 100644 --- a/public/game.js +++ b/public/game.js @@ -1,3 +1,5 @@ +import { createThemeRainBootstrap } from './theme-rain-bootstrap.js?v=0.2.0-beta.1'; + const COLS = 10; const ROWS = 20; const CELL = 30; @@ -34,6 +36,8 @@ const highScoresEl = document.getElementById('highScores'); const gameOverOverlay = document.getElementById('gameOver'); const gameOverText = document.getElementById('gameOverText'); const goHomeBtn = document.getElementById('goHomeBtn'); +const pauseMenu = document.getElementById('pauseMenu'); +const pauseResumeBtn = document.getElementById('pauseResumeBtn'); const pauseBtn = document.getElementById('pauseBtn'); const portraitPauseBtn = document.getElementById('portraitPauseBtn'); @@ -380,10 +384,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 +436,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); } } @@ -518,12 +518,14 @@ function togglePause() { setStatus('Paused'); pauseBtn.textContent = 'Resume'; portraitPauseBtn.textContent = 'Resume'; + showOverlay(pauseMenu); pauseMusic(); } else if (state === 'paused') { state = 'playing'; setStatus(''); pauseBtn.textContent = 'Pause'; portraitPauseBtn.textContent = 'Pause'; + hideOverlay(pauseMenu); lastTime = performance.now(); resumeMusic(); } @@ -533,6 +535,7 @@ function goHome() { state = 'home'; pauseBtn.textContent = 'Pause'; portraitPauseBtn.textContent = 'Pause'; + hideOverlay(pauseMenu); stopMusic(); showHome(); resetGameState(); @@ -541,6 +544,7 @@ function goHome() { function triggerGameOver(reason) { if (state === 'gameover') return; state = 'gameover'; + hideOverlay(pauseMenu); stopMusic(); setStatus('Game Over'); showGameOver('Game Over'); @@ -568,6 +572,7 @@ function triggerGameOver(reason) { function startGame() { hideHome(); hideOverlay(gameOverOverlay); + hideOverlay(pauseMenu); resetGameState(); state = 'playing'; pauseBtn.textContent = 'Pause'; @@ -635,6 +640,10 @@ portraitPauseBtn.addEventListener('click', () => { togglePause(); }); +pauseResumeBtn.addEventListener('click', () => { + if (state === 'paused') togglePause(); +}); + portraitRestartBtn.addEventListener('click', () => { goHome(); }); @@ -727,6 +736,81 @@ btnRotR.addEventListener('pointerdown', (e) => { tryRotate(rotateCW); }); +// ---- Theme integration ---- +(function initTheme() { + if (typeof window === 'undefined' || !window.ThemeModule) return; + + const themeRainBootstrap = createThemeRainBootstrap(window); + themeRainBootstrap.start(); + window.addEventListener('pagehide', (event) => { + if (event.persisted !== true) { + themeRainBootstrap.dispose(); + } + }); + + window.ThemeModule.init(); + + // Wire up accessible radio-group theme buttons. + var themeBtns = document.querySelectorAll('.theme-btn'); + + function syncThemeButtons(themeName) { + for (var i = 0; i < themeBtns.length; i++) { + var selected = themeBtns[i].getAttribute('data-theme') === themeName; + themeBtns[i].setAttribute('aria-checked', selected ? 'true' : 'false'); + themeBtns[i].setAttribute('tabindex', selected ? '0' : '-1'); + } + } + + function setActiveTheme(themeName) { + if (!themeName) return; + window.ThemeModule.selectTheme(themeName); + window.ThemeModule.applyTheme(themeName); + syncThemeButtons(themeName); + } + + function handleThemeKeydown(event, btn) { + var group = btn.closest('.theme-controls'); + if (!group) return; + var groupBtns = group.querySelectorAll('.theme-btn'); + var currentIndex = Array.prototype.indexOf.call(groupBtns, btn); + var targetIndex = currentIndex; + + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + targetIndex = (currentIndex + 1) % groupBtns.length; + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + targetIndex = (currentIndex - 1 + groupBtns.length) % groupBtns.length; + } else if (event.key === 'Home') { + targetIndex = 0; + } else if (event.key === 'End') { + targetIndex = groupBtns.length - 1; + } else { + return; + } + + event.preventDefault(); + var target = groupBtns[targetIndex]; + setActiveTheme(target.getAttribute('data-theme')); + target.focus(); + } + + for (var i = 0; i < themeBtns.length; i++) { + themeBtns[i].addEventListener('click', (function(btn) { + return function() { + setActiveTheme(btn.getAttribute('data-theme')); + }; + })(themeBtns[i])); + themeBtns[i].addEventListener('keydown', (function(btn) { + return function(event) { + handleThemeKeydown(event, btn); + }; + })(themeBtns[i])); + } + + // Sync selection and roving tab stops to the saved theme. + var saved = window.ThemeModule.loadSavedTheme ? window.ThemeModule.loadSavedTheme() : null; + syncThemeButtons(saved || 'Default'); +})(); + state = 'home'; resetGameState(); showHome(); diff --git a/public/index.html b/public/index.html index 49a67dd..694706e 100644 --- a/public/index.html +++ b/public/index.html @@ -3,9 +3,11 @@ + Stacklogic - + +
@@ -57,16 +59,46 @@

Stacklogic

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

High scores

    -

    Audio starts after Start.

    +

    Audio starts after Play Solo.

    + + + +
    +
    +

    Paused

    + + +
    + Theme: + + + + +
    @@ -94,6 +126,8 @@

    Game Over

    - + + + diff --git a/public/style.css b/public/style.css index 22ee155..bd4687a 100644 --- a/public/style.css +++ b/public/style.css @@ -6,6 +6,50 @@ --border: #283244; } +/* Theme: Default */ +[data-theme="Default"] { + --bg: #0b0f14; + --fg: #e6edf3; + --muted: #9aa4af; + --panel: #0f1624; + --border: #283244; + --accent: #1d4ed8; + --accent-fg: #ffffff; +} + +/* Theme: Matrix */ +[data-theme="Matrix"] { + --bg: #0a0a0a; + --fg: #00ff41; + --muted: #008f11; + --panel: #0d1a0d; + --border: #003b00; + --accent: #00ff41; + --accent-fg: #001a07; +} + +/* Theme: CandyPop */ +[data-theme="CandyPop"] { + --bg: #1a0a2e; + --fg: #ffb6c1; + --muted: #c77dba; + --panel: #2d1b4e; + --border: #4a2c7a; + --accent: #ff69b4; + --accent-fg: #2d0018; +} + +/* Theme: Dark */ +[data-theme="Dark"] { + --bg: #000000; + --fg: #ffffff; + --muted: #888888; + --panel: #111111; + --border: #333333; + --accent: #555555; + --accent-fg: #ffffff; +} + * { box-sizing: border-box; } html, body { @@ -25,7 +69,17 @@ body { -webkit-tap-highlight-color: transparent; } +.matrix-rain-layer { + position: fixed; + inset: 0; + display: block; + pointer-events: none; + z-index: 0; +} + .wrap { + position: relative; + z-index: 1; display: grid; grid-template-columns: 220px 1fr; gap: 16px; @@ -42,7 +96,7 @@ body { canvas#game { display: block; - background: #111826; + background: var(--bg); border: 1px solid var(--border); width: 300px; height: 600px; @@ -91,6 +145,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 +183,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 +226,7 @@ canvas#game { .intro-wrap { width: 100%; - margin: 6px 0 8px; + margin: 2px 0 6px; display: grid; place-items: center; } @@ -147,7 +234,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 +243,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: var(--accent-fg, #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; @@ -219,6 +395,17 @@ canvas#game { } } +@media (hover: none) and (pointer: coarse) { + .mobile-controls { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .mobile-status { + display: block; + } +} + @media (max-width: 520px) and (orientation: portrait) { .wrap { padding: 12px; @@ -299,3 +486,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-rain-adapter.js b/public/theme-rain-adapter.js new file mode 100644 index 0000000..be17fba --- /dev/null +++ b/public/theme-rain-adapter.js @@ -0,0 +1,188 @@ +// 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?v=0.2.0-beta.1'; + +export const RAIN_GLYPHS = Object.freeze([ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + 'ア', 'イ', 'ウ', 'エ', 'オ', + 'カ', 'キ', 'ク', 'ケ', 'コ', + 'サ', 'シ', 'ス', 'セ', 'ソ', + 'タ', 'チ', 'ツ', 'テ', 'ト', + 'ナ', 'ニ', 'ヌ', 'ネ', 'ノ', + 'ハ', 'ヒ', 'フ', 'ヘ', 'ホ', + 'マ', 'ミ', 'ム', 'メ', 'モ', + 'ヤ', 'ユ', 'ヨ', + 'ラ', 'リ', 'ル', 'レ', 'ロ', + 'ワ', 'ヲ', 'ン', +]); + +export function createRainBrowserAdapter(deps) { + const { + createCanvasLayer, + removeCanvasLayer, + getViewportCssSize, + getDevicePixelRatio, + requestFrame, + cancelFrame, + addResizeListener, + removeResizeListener, + random, + } = deps; + + let controller = null; + let layer = null; + let drops = []; + let cachedViewport = null; + let cachedDpr = null; + + function configureBackingStore() { + cachedViewport = getViewportCssSize(); + cachedDpr = getDevicePixelRatio(); + const { width, height } = cachedViewport; + const dpr = cachedDpr; + const backingWidth = Math.round(width * dpr); + const backingHeight = Math.round(height * dpr); + layer.canvas.width = backingWidth; + layer.canvas.height = backingHeight; + layer.canvas.style.width = width + 'px'; + layer.canvas.style.height = height + 'px'; + layer.context.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + function createDrops() { + const { width, height } = cachedViewport; + const cellSize = 14; + const maxColumns = Math.ceil(width / cellSize); + drops = []; + for (let i = 0; i < maxColumns; i++) { + drops.push({ + column: i, + x: Math.min((i + 0.5) * cellSize, Math.max(0, width - 1)), + y: random() * (height - 100), + speed: 1 + random() * 3, + glyph: RAIN_GLYPHS[Math.floor(random() * RAIN_GLYPHS.length)], + }); + } + } + + function reconcileDrops() { + const { width, height } = cachedViewport; + const cellSize = 14; + const maxColumns = Math.ceil(width / cellSize); + + const dropMap = new Map(drops.map(d => [d.column, d])); + const next = []; + + for (let col = 0; col < maxColumns; col++) { + const drop = dropMap.get(col); + if (drop) { + drop.x = Math.min((col + 0.5) * cellSize, Math.max(0, width - 1)); + next.push(drop); + } else { + next.push({ + column: col, + x: Math.min((col + 0.5) * cellSize, Math.max(0, width - 1)), + y: random() * (height - 100), + speed: 1 + random() * 3, + glyph: RAIN_GLYPHS[Math.floor(random() * RAIN_GLYPHS.length)], + }); + } + } + + drops = next; + } + + function drawFrame() { + const ctx = layer.context; + const { width, height } = cachedViewport; + + // Semi-transparent black overlay to retain trails + ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; + ctx.fillRect(0, 0, width, height); + + for (let i = 0; i < drops.length; i++) { + const drop = drops[i]; + drop.y += drop.speed; + if (drop.y > height) { + drop.y = -20; + drop.speed = 1 + random() * 3; + drop.glyph = RAIN_GLYPHS[Math.floor(random() * RAIN_GLYPHS.length)]; + } + ctx.fillStyle = '#0f0'; + ctx.font = '14px monospace'; + ctx.fillText(drop.glyph, drop.x, drop.y); + } + } + + function handleTheme(theme) { + if (theme === 'Matrix') { + // Evaluate reduced motion before starting (call deps directly, not captured ref) + if (deps.reducedMotionMatches()) { + // If already running, stop it and clear controller reference + if (controller && controller.getState().state === 'RUNNING') { + controller.stop(); + controller = null; + } + return; // reduced motion: stay IDLE + } + // Check if already running via controller state (no private active flag) + if (controller && controller.getState().state === 'RUNNING') { + return; // already running, idempotent + } + controller = createRainController({ + requestFrame, + cancelFrame, + addResizeListener, + removeResizeListener, + reducedMotionMatches: false, // already checked in adapter + createLayer: () => { + layer = createCanvasLayer(); + configureBackingStore(); + createDrops(); + return layer; + }, + removeLayer: (l) => { + removeCanvasLayer(l); + layer = null; + }, + resizeLayer: () => { + configureBackingStore(); + reconcileDrops(); + }, + drawFrame, + }); + + controller.start(); + } else if (theme !== 'Matrix') { + if (controller && controller.getState().state === 'RUNNING') { + controller.stop(); + controller = null; + } + } + } + + function dispose() { + if (controller && controller.getState().state === 'RUNNING') { + controller.stop(); + controller = null; + } + } + + function getState() { + if (controller) return controller.getState(); + return { + state: 'IDLE', + generation: 0, + layer: null, + pendingFrameId: null, + listenerRef: null, + }; + } + + return { handleTheme, dispose, getState }; +} diff --git a/public/theme-rain-bootstrap.js b/public/theme-rain-bootstrap.js new file mode 100644 index 0000000..1a65730 --- /dev/null +++ b/public/theme-rain-bootstrap.js @@ -0,0 +1,139 @@ +// 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?v=0.2.0-beta.1'; + +export function createThemeRainBootstrap(root) { + const document = root.document; + let reducedMotionQuery = null; + + function getReducedMotionQuery() { + if (!reducedMotionQuery) { + reducedMotionQuery = root.matchMedia('(prefers-reduced-motion: reduce)'); + } + return reducedMotionQuery; + } + + const adapter = createRainBrowserAdapter({ + createCanvasLayer() { + const canvas = document.createElement('canvas'); + canvas.className = 'matrix-rain-layer'; + canvas.setAttribute('aria-hidden', 'true'); + const context = canvas.getContext('2d'); + document.body.appendChild(canvas); + return { canvas, context }; + }, + + removeCanvasLayer(layer) { + const canvas = layer && layer.canvas; + if (canvas && canvas.parentNode === document.body) { + document.body.removeChild(canvas); + } + }, + + getViewportCssSize() { + return { + width: root.innerWidth, + height: root.innerHeight, + }; + }, + + getDevicePixelRatio() { + const dpr = root.devicePixelRatio; + return Number.isFinite(dpr) && dpr > 0 ? dpr : 1; + }, + + requestFrame(callback) { + return root.requestAnimationFrame(callback); + }, + + cancelFrame(frameId) { + root.cancelAnimationFrame(frameId); + }, + + addResizeListener(handler) { + root.addEventListener('resize', handler); + return handler; + }, + + removeResizeListener(handler) { + root.removeEventListener('resize', handler); + }, + + reducedMotionMatches() { + return getReducedMotionQuery().matches; + }, + + random() { + return root.Math.random(); + }, + }); + + let started = false; + + function handleReducedMotionChange() { + if (!started) return; + const themeModule = root.ThemeModule; + const currentTheme = themeModule && typeof themeModule.getCurrentTheme === 'function' + ? themeModule.getCurrentTheme() + : null; + if (typeof currentTheme === 'string') { + adapter.handleTheme(currentTheme); + } + } + + function addReducedMotionListener() { + const query = getReducedMotionQuery(); + if (typeof query.addEventListener === 'function') { + query.addEventListener('change', handleReducedMotionChange); + } else if (typeof query.addListener === 'function') { + query.addListener(handleReducedMotionChange); + } + } + + function removeReducedMotionListener() { + if (!reducedMotionQuery) return; + if (typeof reducedMotionQuery.removeEventListener === 'function') { + reducedMotionQuery.removeEventListener('change', handleReducedMotionChange); + } else if (typeof reducedMotionQuery.removeListener === 'function') { + reducedMotionQuery.removeListener(handleReducedMotionChange); + } + } + + function handleThemeChange(event) { + if (!started) return; + const theme = event && event.detail && event.detail.theme; + if (typeof theme === 'string') { + adapter.handleTheme(theme); + } + } + + function start() { + if (started) return; + started = true; + document.addEventListener('themechange', handleThemeChange); + addReducedMotionListener(); + + const themeModule = root.ThemeModule; + const currentTheme = themeModule && typeof themeModule.getCurrentTheme === 'function' + ? themeModule.getCurrentTheme() + : null; + if (typeof currentTheme === 'string') { + adapter.handleTheme(currentTheme); + } + } + + function dispose() { + if (!started) return; + started = false; + document.removeEventListener('themechange', handleThemeChange); + removeReducedMotionListener(); + adapter.dispose(); + } + + function getState() { + return adapter.getState(); + } + + return { start, dispose, getState }; +} diff --git a/public/theme-rain-controller.js b/public/theme-rain-controller.js new file mode 100644 index 0000000..f3d622b --- /dev/null +++ b/public/theme-rain-controller.js @@ -0,0 +1,111 @@ +// DOM-free Matrix rain lifecycle controller. +// Exports createRainController(dependencies) returning {start, stop, resize, isActive, getState}. + +export function createRainController(dependencies) { + let generation = 0; + let layer = null; + let pendingFrameId = null; + let listenerRef = null; + let state = 'IDLE'; + + const { + requestFrame, + cancelFrame, + addResizeListener, + removeResizeListener, + reducedMotionMatches, + createLayer, + removeLayer, + resizeLayer, + drawFrame, + } = dependencies; + + function start() { + // Guard: if reduced motion, short-circuit before any dependency call + if (reducedMotionMatches) { + return; + } + + // Idempotent: if already running with same generation, do nothing + if (generation > 0 && state === 'RUNNING') { + return; + } + + // Increment generation (monotonic counter) + generation++; + + // Create owned layer + layer = createLayer(); + + // Attach owned resize listener + const handler = function () { + resizeLayer(layer); + }; + listenerRef = addResizeListener(handler); + + // Schedule first frame + const activeGen = generation; + const frameCallback = function () { + // Generation fence: if generation changed (stop was called), no-op + if (generation !== activeGen) { + return; + } + // Draw the frame + drawFrame(); + // Schedule exactly one successor only while still active + pendingFrameId = requestFrame(frameCallback); + }; + pendingFrameId = requestFrame(frameCallback); + + state = 'RUNNING'; + } + + function stop() { + // Cancel the owned pending frame + if (pendingFrameId !== null) { + cancelFrame(pendingFrameId); + pendingFrameId = null; + } + + // Invalidate generation (fences stale callbacks) + generation++; + + // Remove owned resize listener + if (listenerRef !== null) { + removeResizeListener(listenerRef); + listenerRef = null; + } + + // Remove owned layer + if (layer !== null) { + removeLayer(layer); + layer = null; + } + + state = 'IDLE'; + } + + function resize() { + // Only delegate while active + if (generation <= 0 || state !== 'RUNNING') { + return; + } + resizeLayer(layer); + } + + function isActive() { + return generation > 0 && state === 'RUNNING'; + } + + function getState() { + return { + state: state, + generation: generation, + layer: layer, + pendingFrameId: pendingFrameId, + listenerRef: listenerRef, + }; + } + + return { start, stop, resize, isActive, getState }; +} diff --git a/public/theme-renderer.js b/public/theme-renderer.js new file mode 100644 index 0000000..fb2340b --- /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'; + + var standardPieceColors = ['#67e8f9', '#fde047', '#c084fc', '#86efac', '#fda4af', '#93c5fd', '#fdba74']; + var matrixShades = ['#006b2b', '#008f39', '#00a844', '#00bd4d', '#00d657', '#16e968', '#3df27f']; + var darkShades = ['#101010', '#1c1c1c', '#282828', '#343434', '#424242', '#525252', '#646464']; + + // ---- 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: null, + 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) { + ctx.fillStyle = shadeForColor(color, matrixShades); + ctx.fillRect(px, py, size, size); + + 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); + } + } + + 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) { + ctx.fillStyle = shadeForColor(color, darkShades); + ctx.fillRect(px, py, size, size); + + 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); + } + } + + function shadeForColor(color, palette) { + var normalized = String(color || '').toLowerCase(); + var index = standardPieceColors.indexOf(normalized); + if (index < 0) { + index = 0; + for (var i = 0; i < normalized.length; i++) { + index = (index * 31 + normalized.charCodeAt(i)) >>> 0; + } + } + return palette[index % palette.length]; + } + + // ---- 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 new file mode 100644 index 0000000..daeb119 --- /dev/null +++ b/public/theme.js @@ -0,0 +1,142 @@ +// 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); + if (typeof root.CustomEvent === 'function' && typeof root.document.dispatchEvent === 'function') { + root.document.dispatchEvent(new root.CustomEvent('themechange', { detail: { 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, + getCurrentTheme: function () { return currentTheme; } + }; + +})(typeof window !== 'undefined' ? window : globalThis); 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-accessibility.test.mjs b/tests/theme-accessibility.test.mjs new file mode 100644 index 0000000..5f375e5 --- /dev/null +++ b/tests/theme-accessibility.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { describe, it } from 'node:test'; + +const [css, html, game] = await Promise.all([ + readFile(new URL('../public/style.css', import.meta.url), 'utf8'), + readFile(new URL('../public/index.html', import.meta.url), 'utf8'), + readFile(new URL('../public/game.js', import.meta.url), 'utf8'), +]); + +function relativeLuminance(hex) { + const channels = [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255); + const linear = channels.map((value) => ( + value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 + )); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; +} + +function contrastRatio(a, b) { + const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); + return (lighter + 0.05) / (darker + 0.05); +} + +function themeVariable(theme, variable) { + const block = css.match(new RegExp(`\\[data-theme="${theme}"\\] \\{([\\s\\S]*?)\\}`)); + assert.ok(block, `${theme} CSS block exists`); + const value = block[1].match(new RegExp(`--${variable}:\\s*(#[0-9a-fA-F]{6})`)); + assert.ok(value, `${theme} defines --${variable}`); + return value[1]; +} + +describe('theme control accessibility', () => { + it('selected labels meet WCAG AA contrast in every theme', () => { + assert.match(css, /color:\s*var\(--accent-fg/); + for (const theme of ['Default', 'Matrix', 'CandyPop', 'Dark']) { + const ratio = contrastRatio(themeVariable(theme, 'accent-fg'), themeVariable(theme, 'accent')); + assert.ok(ratio >= 4.5, `${theme} selected label contrast ${ratio.toFixed(3)} >= 4.5`); + } + }); + + it('each radio group starts with one roving tab stop', () => { + const groups = [...html.matchAll(/
    ]*role="radiogroup"[\s\S]*?<\/div>/g)]; + assert.equal(groups.length, 2); + for (const group of groups) { + assert.equal((group[0].match(/tabindex="0"/g) || []).length, 1); + assert.equal((group[0].match(/tabindex="-1"/g) || []).length, 3); + } + }); + + it('implements Arrow and Home/End keyboard selection within each radio group', () => { + for (const key of ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End']) { + assert.match(game, new RegExp(`['"]${key}['"]`)); + } + assert.match(game, /closest\(['"]\.theme-controls['"]\)/); + assert.match(game, /\.focus\(\)/); + assert.match(game, /setAttribute\(['"]tabindex['"]/); + }); +}); diff --git a/tests/theme-event.test.mjs b/tests/theme-event.test.mjs new file mode 100644 index 0000000..76842d2 --- /dev/null +++ b/tests/theme-event.test.mjs @@ -0,0 +1,115 @@ +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'); + +// Minimal mock environment for themechange event tests. +function createMockEnv() { + const STORAGE = {}; + const mockStyle = {}; + mockStyle.setProperty = function (key, value) { this[key] = value; }; + mockStyle.getPropertyValue = function (key) { return this[key] || ''; }; + + const docAttrs = {}; + const mockDocumentElement = { + style: mockStyle, + getAttribute: function (k) { return docAttrs[k] || null; }, + setAttribute: function (k, v) { docAttrs[k] = v; }, + }; + + const dispatchedEvents = []; + const mockDocument = { + documentElement: mockDocumentElement, + createElement: function () { return { style: {}, classList: { add: function () { }, remove: function () { } } }; }, + body: { style: {} }, + dispatchEvent: function (evt) { dispatchedEvents.push(evt); }, + }; + + const mockLocalStorage = { + getItem: function (k) { return STORAGE[k] || null; }, + setItem: function (k, v) { STORAGE[k] = String(v); }, + }; + + function CustomEvent(type, options) { + this.type = type; + this.detail = options && options.detail ? options.detail : null; + } + + const mockWindow = { + localStorage: mockLocalStorage, + document: mockDocument, + CustomEvent: CustomEvent, + matchMedia: function (q) { + return { + matches: q.includes('no-preference'), + addEventListener: function () { }, + removeEventListener: function () { }, + }; + }, + }; + + return { mockWindow, mockDocument, mockLocalStorage, dispatchedEvents }; +} + +describe('themechange event', function () { + it('valid theme dispatches exactly one themechange event with detail.theme', function () { + const { mockWindow, mockDocument, mockLocalStorage, dispatchedEvents } = createMockEnv(); + new Function('window', 'document', 'localStorage', themeSrc)( + mockWindow, mockDocument, mockLocalStorage + ); + const result = mockWindow.ThemeModule.applyTheme('Dark'); + assert.ok(Array.isArray(result), 'applyTheme must return an array'); + assert.strictEqual(dispatchedEvents.length, 1, 'exactly one event must be dispatched'); + assert.strictEqual(dispatchedEvents[0].type, 'themechange', 'event type must be themechange'); + assert.deepStrictEqual(dispatchedEvents[0].detail, { theme: 'Dark' }, 'detail must be {theme: name}'); + }); + + it('invalid theme does not dispatch any event', function () { + const { mockWindow, mockDocument, mockLocalStorage, dispatchedEvents } = createMockEnv(); + new Function('window', 'document', 'localStorage', themeSrc)( + mockWindow, mockDocument, mockLocalStorage + ); + const result = mockWindow.ThemeModule.applyTheme('invalid'); + assert.deepStrictEqual(result, [], 'applyTheme must return empty array for invalid theme'); + assert.strictEqual(dispatchedEvents.length, 0, 'no event must be dispatched for invalid theme'); + }); + + it('missing document.dispatchEvent does not throw', function () { + const { mockWindow, mockLocalStorage } = createMockEnv(); + delete mockWindow.document.dispatchEvent; + new Function('window', 'document', 'localStorage', themeSrc)( + mockWindow, mockWindow.document, mockLocalStorage + ); + assert.doesNotThrow(function () { + mockWindow.ThemeModule.applyTheme('Matrix'); + }, 'applyTheme must not throw when dispatchEvent is missing'); + }); + + it('missing root.CustomEvent does not throw', function () { + const { mockWindow, mockDocument, mockLocalStorage } = createMockEnv(); + delete mockWindow.CustomEvent; + new Function('window', 'document', 'localStorage', themeSrc)( + mockWindow, mockDocument, mockLocalStorage + ); + assert.doesNotThrow(function () { + mockWindow.ThemeModule.applyTheme('Default'); + }, 'applyTheme must not throw when CustomEvent is missing'); + }); + + it('consecutive calls emit separate events', function () { + const { mockWindow, mockDocument, mockLocalStorage, dispatchedEvents } = createMockEnv(); + new Function('window', 'document', 'localStorage', themeSrc)( + mockWindow, mockDocument, mockLocalStorage + ); + mockWindow.ThemeModule.applyTheme('Dark'); + mockWindow.ThemeModule.applyTheme('Matrix'); + assert.strictEqual(dispatchedEvents.length, 2, 'two events must be dispatched'); + assert.strictEqual(dispatchedEvents[0].detail.theme, 'Dark', 'first event theme must be Dark'); + assert.strictEqual(dispatchedEvents[1].detail.theme, 'Matrix', 'second event theme must be Matrix'); + }); +}); diff --git a/tests/theme-rain-adapter-columns.test.mjs b/tests/theme-rain-adapter-columns.test.mjs new file mode 100644 index 0000000..06c4517 --- /dev/null +++ b/tests/theme-rain-adapter-columns.test.mjs @@ -0,0 +1,92 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { createRainBrowserAdapter } from '../public/theme-rain-adapter.js'; + +describe('theme-rain-adapter columns invariant', () => { + it('viewport 42x1, random 0.99: per-frame 3 fillText, 3 unique x in [0,42)', () => { + // --- fake deps --- + const fillTextCalls = []; + const fakeContext = { + setTransform() {}, + fillStyle: null, + font: null, + fillRect() {}, + fillText(glyph, x, y) { + fillTextCalls.push({ glyph, x, y }); + }, + }; + + const fakeLayer = { canvas: { width: 0, height: 0, style: {} }, context: fakeContext }; + + const storedCallbacks = []; + let pendingFrameId = 0; + function requestFrame(cb) { + const id = pendingFrameId++; + storedCallbacks.push({ id, cb }); + return id; + } + function cancelFrame() {} + function addResizeListener() { return {}; } + function removeResizeListener() {} + + const deps = { + createCanvasLayer() { return fakeLayer; }, + removeCanvasLayer() {}, + getViewportCssSize() { return { width: 42, height: 1 }; }, + getDevicePixelRatio() { return 1; }, + requestFrame, + cancelFrame, + addResizeListener, + removeResizeListener, + random() { return 0.99; }, + reducedMotionMatches() { return false; }, + }; + + // --- build adapter --- + const adapter = createRainBrowserAdapter(deps); + + // --- start Matrix theme --- + adapter.handleTheme('Matrix'); + + // --- verify controller is running --- + const state = adapter.getState(); + assert.strictEqual(state.state, 'RUNNING', 'controller should be RUNNING'); + + // --- manually run two successive RAF callbacks --- + const cb0 = storedCallbacks.find(c => c.id === 0); + assert.ok(cb0, 'first callback should exist'); + cb0.cb(); + + const cb1 = storedCallbacks.find(c => c.id === 1); + assert.ok(cb1, 'second callback should exist'); + cb1.cb(); + + // --- per-frame assertions --- + // Frame 0: calls 0..2 + const frame0 = fillTextCalls.slice(0, 3); + assert.equal(frame0.length, 3, 'frame 0: exactly 3 fillText calls'); + const x0 = frame0.map(c => c.x); + const uniqueX0 = [...new Set(x0)]; + assert.equal(uniqueX0.length, 3, 'frame 0: 3 unique x values'); + for (const x of x0) { + assert.ok(x >= 0 && x < 42, `frame 0: x=${x} must be in [0,42)`); + } + + // Frame 1: calls 3..5 + const frame1 = fillTextCalls.slice(3, 6); + assert.equal(frame1.length, 3, 'frame 1: exactly 3 fillText calls'); + const x1 = frame1.map(c => c.x); + const uniqueX1 = [...new Set(x1)]; + assert.equal(uniqueX1.length, 3, 'frame 1: 3 unique x values'); + for (const x of x1) { + assert.ok(x >= 0 && x < 42, `frame 1: x=${x} must be in [0,42)`); + } + + // Verify columns are stable: x should be 7, 21, 35 for columns 0,1,2 + const expectedX = [7, 21, 35]; + for (let i = 0; i < 3; i++) { + assert.strictEqual(x0[i], expectedX[i], `frame 0 column ${i} x should be ${expectedX[i]}`); + assert.strictEqual(x1[i], expectedX[i], `frame 1 column ${i} x should be ${expectedX[i]}`); + } + }); +}); diff --git a/tests/theme-rain-adapter-platform.test.mjs b/tests/theme-rain-adapter-platform.test.mjs new file mode 100644 index 0000000..927ee5f --- /dev/null +++ b/tests/theme-rain-adapter-platform.test.mjs @@ -0,0 +1,126 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRainBrowserAdapter } from '../public/theme-rain-adapter.js'; + +function makeHarness({ width, height, dpr }) { + const viewport = { width, height }; + let currentDpr = dpr; + let resizeHandler = null; + let nextFrameId = 1; + const callbacks = new Map(); + const fillTextCalls = []; + const fillRectCalls = []; + const transformCalls = []; + const context = { + fillStyle: '', + font: '', + setTransform(...args) { transformCalls.push(args); }, + fillRect(...args) { fillRectCalls.push(args); }, + fillText(glyph, x, y) { fillTextCalls.push({ glyph, x, y }); }, + }; + const layer = { canvas: { width: 0, height: 0, style: {} }, context }; + let randomCall = 0; + const deps = { + createCanvasLayer: () => layer, + removeCanvasLayer: () => {}, + getViewportCssSize: () => ({ ...viewport }), + getDevicePixelRatio: () => currentDpr, + requestFrame(cb) { + const id = nextFrameId++; + callbacks.set(id, cb); + return id; + }, + cancelFrame: () => {}, + addResizeListener(handler) { + resizeHandler = handler; + return { type: 'resize-listener' }; + }, + removeResizeListener: () => {}, + random() { + const slot = randomCall % 3; + const column = Math.floor(randomCall / 3) % 100; + randomCall++; + if (slot === 0) return 0; + if (slot === 1) return (column + 1) / 100; + return column / 100; + }, + reducedMotionMatches: () => false, + }; + const adapter = createRainBrowserAdapter(deps); + return { + adapter, + layer, + viewport, + setDpr(value) { currentDpr = value; }, + resize() { + assert.equal(typeof resizeHandler, 'function'); + resizeHandler(); + }, + runFrame() { + const before = fillTextCalls.length; + const id = adapter.getState().pendingFrameId; + const cb = callbacks.get(id); + assert.equal(typeof cb, 'function'); + cb(); + return fillTextCalls.slice(before); + }, + fillRectCalls, + transformCalls, + }; +} + +function expectedX(column, width) { + return Math.min((column + 0.5) * 14, Math.max(0, width - 1)); +} + +function assertUniqueInRange(frame, width, count) { + assert.equal(frame.length, count); + const xs = frame.map(({ x }) => x); + assert.equal(new Set(xs).size, count); + for (const x of xs) assert.ok(x >= 0 && x < width, `x=${x} outside [0,${width})`); + assert.deepEqual(xs, Array.from({ length: count }, (_, column) => expectedX(column, width))); +} + +describe('Matrix rain platform geometry', () => { + it('reconciles exact stable columns and recomputes x across 400→200→800 resize', () => { + const h = makeHarness({ width: 400, height: 100, dpr: 2 }); + h.adapter.handleTheme('Matrix'); + const wide = h.runFrame(); + assertUniqueInRange(wide, 400, Math.ceil(400 / 14)); + + h.viewport.width = 200; + h.setDpr(3); + h.resize(); + const narrow = h.runFrame(); + assertUniqueInRange(narrow, 200, Math.ceil(200 / 14)); + for (let column = 0; column < narrow.length; column++) { + const speed = wide[column].y; + assert.ok(Math.abs((narrow[column].y - wide[column].y) - speed) < 1e-9, + `column ${column} did not retain y/speed across shrink`); + } + + h.viewport.width = 800; + h.resize(); + const expanded = h.runFrame(); + assertUniqueInRange(expanded, 800, Math.ceil(800 / 14)); + for (let column = 0; column < narrow.length; column++) { + const speed = narrow[column].y - wide[column].y; + assert.ok(Math.abs((expanded[column].y - narrow[column].y) - speed) < 1e-9, + `column ${column} did not retain y/speed across expansion`); + } + }); + + it('uses DPR only for backing dimensions/transform and draws in CSS pixels', () => { + const h = makeHarness({ width: 200, height: 100, dpr: 3 }); + h.adapter.handleTheme('Matrix'); + assert.equal(h.layer.canvas.width, 600); + assert.equal(h.layer.canvas.height, 300); + assert.equal(h.layer.canvas.style.width, '200px'); + assert.equal(h.layer.canvas.style.height, '100px'); + assert.deepEqual(h.transformCalls.at(-1), [3, 0, 0, 3, 0, 0]); + + const frame = h.runFrame(); + assert.deepEqual(h.fillRectCalls.at(-1), [0, 0, 200, 100]); + assertUniqueInRange(frame, 200, Math.ceil(200 / 14)); + }); +}); diff --git a/tests/theme-rain-adapter.test.mjs b/tests/theme-rain-adapter.test.mjs new file mode 100644 index 0000000..59de8ff --- /dev/null +++ b/tests/theme-rain-adapter.test.mjs @@ -0,0 +1,689 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { createRainController } from '../public/theme-rain-controller.js'; +import { createRainBrowserAdapter, RAIN_GLYPHS } from '../public/theme-rain-adapter.js'; +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); + +// ---- helpers ---- + +function makeSpies() { + const calls = { + createCanvasLayer: [], + removeCanvasLayer: [], + getViewportCssSize: [], + getDevicePixelRatio: [], + requestFrame: [], + cancelFrame: [], + addResizeListener: [], + removeResizeListener: [], + reducedMotionMatches: [], + random: [], + setTransform: [], + fillRect: [], + fillText: [], + clearRect: [], + fillStyleValues: [], + fillStyleAtFillRect: [], + }; + + const viewportSize = { width: 400, height: 600 }; + const dpr = { value: 2 }; + + let capturedFillStyle = null; + + const deps = { + createCanvasLayer: (...args) => { + calls.createCanvasLayer.push(args); + const canvas = { + width: 0, + height: 0, + style: { width: '', height: '' }, + }; + const ctx = { + get fillStyle() { return capturedFillStyle; }, + set fillStyle(val) { + capturedFillStyle = val; + calls.fillStyleValues.push(val); + }, + fillRect: (...a) => { + calls.fillRect.push(a); + calls.fillStyleAtFillRect.push(capturedFillStyle); + }, + fillText: (...a) => calls.fillText?.push(a), + setTransform: (...a) => calls.setTransform?.push(a), + font: null, + textAlign: null, + textBaseline: null, + save: () => {}, + restore: () => {}, + clearRect: (...a) => calls.clearRect?.push(a), + }; + return { canvas, context: ctx }; + }, + removeCanvasLayer: (...args) => calls.removeCanvasLayer.push(args), + getViewportCssSize: (...args) => { + calls.getViewportCssSize.push(args); + return { ...viewportSize }; + }, + getDevicePixelRatio: (...args) => { + calls.getDevicePixelRatio.push(args); + return dpr.value; + }, + requestFrame: (...args) => { + calls.requestFrame.push(args); + const token = `frame-${calls.requestFrame.length}`; + return token; + }, + cancelFrame: (...args) => calls.cancelFrame.push(args), + addResizeListener: (...args) => { + calls.addResizeListener.push(args); + return args[0]; + }, + removeResizeListener: (...args) => calls.removeResizeListener.push(args), + reducedMotionMatches: () => { + calls.reducedMotionMatches.push([]); + return false; + }, + random: () => { + calls.random.push([]); + return 0.5; + }, + }; + + return { deps, calls, viewportSize, dpr }; +} + +// ---- tests ---- + +describe('adapter: import has zero global/browser side effects', () => { + it('importing the module does not call any injected platform functions', () => { + assert.ok(Array.isArray(RAIN_GLYPHS), 'RAIN_GLYPHS is an array'); + assert.ok(Object.isFrozen(RAIN_GLYPHS), 'RAIN_GLYPHS is frozen'); + assert.strictEqual(typeof createRainBrowserAdapter, 'function', 'createRainBrowserAdapter is exported'); + }); +}); + +describe('adapter: Matrix creates exactly one owned layer listener and pending frame', () => { + it('handleTheme("Matrix") creates one layer, one listener, one frame, and one requestFrame call', () => { + const { deps, calls } = makeSpies(); + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + assert.strictEqual(calls.createCanvasLayer.length, 1, 'one layer created'); + assert.strictEqual(calls.addResizeListener.length, 1, 'one listener added'); + assert.strictEqual(calls.requestFrame.length, 1, 'one frame scheduled'); + assert.strictEqual(calls.getViewportCssSize.length, 1, 'viewport size read once'); + assert.strictEqual(calls.getDevicePixelRatio.length, 1, 'DPR read once'); + const state = adapter.getState(); + assert.strictEqual(state.state, 'RUNNING', 'controller state is RUNNING'); + assert.strictEqual(state.generation, 1, 'generation is 1'); + }); +}); + +describe('adapter: repeated Matrix creates no duplicate resources', () => { + it('calling handleTheme("Matrix") twice creates only one layer, one listener, one frame', () => { + const { deps, calls } = makeSpies(); + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + adapter.handleTheme('Matrix'); + assert.strictEqual(calls.createCanvasLayer.length, 1, 'only one layer created'); + assert.strictEqual(calls.addResizeListener.length, 1, 'only one listener added'); + assert.strictEqual(calls.requestFrame.length, 1, 'only one frame scheduled'); + const state = adapter.getState(); + assert.strictEqual(state.state, 'RUNNING', 'still RUNNING'); + }); +}); + +describe('adapter: non-Matrix stops and removes exact owned frame listener and layer', () => { + it('handleTheme("Default") after Matrix cancels frame, removes listener, removes layer, state IDLE', () => { + const { deps, calls } = makeSpies(); + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + const stateBefore = adapter.getState(); + const ownedToken = stateBefore.pendingFrameId; + const ownedListener = stateBefore.listenerRef; + const ownedLayer = stateBefore.layer; + adapter.handleTheme('Default'); + assert.strictEqual(calls.cancelFrame.length, 1, 'cancelFrame called once'); + assert.strictEqual(calls.cancelFrame[0][0], ownedToken, 'cancelFrame received exact owned token'); + assert.strictEqual(calls.removeResizeListener.length, 1, 'removeResizeListener called once'); + assert.strictEqual(calls.removeResizeListener[0][0], ownedListener, 'removeResizeListener received exact listener'); + assert.strictEqual(calls.removeCanvasLayer.length, 1, 'removeCanvasLayer called once'); + assert.strictEqual(calls.removeCanvasLayer[0][0], ownedLayer, 'removeCanvasLayer received exact layer'); + const state = adapter.getState(); + assert.strictEqual(state.state, 'IDLE', 'state is IDLE'); + }); +}); + +describe('adapter: non-Matrix before start and repeated dispose are no-ops', () => { + it('handleTheme("Default") before Matrix does nothing; repeated dispose is no-op', () => { + const { deps, calls } = makeSpies(); + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Default'); + assert.strictEqual(calls.createCanvasLayer.length, 0, 'no layer created'); + assert.strictEqual(calls.requestFrame.length, 0, 'no frame scheduled'); + adapter.dispose(); + adapter.dispose(); + assert.strictEqual(calls.cancelFrame.length, 0, 'no cancelFrame calls'); + const state = adapter.getState(); + assert.strictEqual(state.state, 'IDLE', 'state remains IDLE'); + }); +}); + +describe('adapter: reduced motion creates zero layer listener frame or draw effects', () => { + it('inject reducedMotionMatches returning true; handleTheme("Matrix") creates no resources', () => { + const { deps, calls } = makeSpies(); + deps.reducedMotionMatches = () => true; + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + assert.strictEqual(calls.createCanvasLayer.length, 0, 'no layer created'); + assert.strictEqual(calls.addResizeListener.length, 0, 'no listener added'); + assert.strictEqual(calls.requestFrame.length, 0, 'no frame scheduled'); + const state = adapter.getState(); + assert.strictEqual(state.state, 'IDLE', 'state is IDLE'); + }); +}); + +describe('adapter: reduced motion false permits start, then true blocks later start', () => { + it('reducedMotionMatches=false starts Matrix; then reducedMotionMatches=true blocks subsequent start', () => { + const { deps, calls } = makeSpies(); + // First: reduced motion is false, start should work + deps.reducedMotionMatches = () => false; + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + assert.strictEqual(calls.createCanvasLayer.length, 1, 'layer created when reduced motion is false'); + assert.strictEqual(calls.requestFrame.length, 1, 'frame scheduled when reduced motion is false'); + + // Now stop + adapter.handleTheme('Default'); + assert.strictEqual(adapter.getState().state, 'IDLE', 'stopped'); + + // Reset calls + calls.createCanvasLayer.length = 0; + calls.requestFrame.length = 0; + calls.addResizeListener.length = 0; + + // Now reduced motion is true, start should be blocked + deps.reducedMotionMatches = () => true; + adapter.handleTheme('Matrix'); + assert.strictEqual(calls.createCanvasLayer.length, 0, 'no layer when reduced motion is true'); + assert.strictEqual(calls.requestFrame.length, 0, 'no frame when reduced motion is true'); + assert.strictEqual(calls.addResizeListener.length, 0, 'no listener when reduced motion is true'); + }); +}); + +describe('adapter: reduced motion true->false recovery', () => { + it('initial true blocks Matrix; switching to false permits start with one layer/listener/frame', () => { + const { deps, calls } = makeSpies(); + // Initial: reduced motion is true — should be no-op + deps.reducedMotionMatches = () => true; + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + assert.strictEqual(calls.createCanvasLayer.length, 0, 'no layer with reduced motion true'); + assert.strictEqual(calls.addResizeListener.length, 0, 'no listener with reduced motion true'); + assert.strictEqual(calls.requestFrame.length, 0, 'no frame with reduced motion true'); + assert.strictEqual(adapter.getState().state, 'IDLE', 'state is IDLE after blocked start'); + + // Switch to reduced motion false and start again + deps.reducedMotionMatches = () => false; + adapter.handleTheme('Matrix'); + assert.strictEqual(calls.createCanvasLayer.length, 1, 'layer created after switching to false'); + assert.strictEqual(calls.addResizeListener.length, 1, 'listener added after switching to false'); + assert.strictEqual(calls.requestFrame.length, 1, 'frame scheduled after switching to false'); + assert.strictEqual(adapter.getState().state, 'RUNNING', 'state is RUNNING after recovery'); + }); +}); + +describe('adapter: reduced motion false->true while RUNNING stops controller and cleans up exact resources', () => { + it('start Matrix with reducedMotion=false, invoke first frame callback, capture state, switch reducedMotion=true, call handleTheme(Matrix); verify exact successor cancelled, exact listener/layer removed, controller IDLE, no new resources, stale successor is inert', () => { + const { deps, calls } = makeSpies(); + calls.fillText = []; + calls.fillRect = []; + + // Step 1: Start Matrix with reducedMotion=false + deps.reducedMotionMatches = () => false; + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + assert.strictEqual(calls.createCanvasLayer.length, 1, 'layer created'); + assert.strictEqual(calls.addResizeListener.length, 1, 'listener added'); + assert.strictEqual(calls.requestFrame.length, 1, 'one frame scheduled'); + + // Step 2: Manually invoke the first stored frame callback so a successor exists + const firstFrameCallback = calls.requestFrame[0][0]; + firstFrameCallback(); + assert.strictEqual(calls.requestFrame.length, 2, 'one successor frame scheduled after first callback'); + assert.ok(calls.fillText.length > 0, 'drawFrame executed (glyphs drawn)'); + + // Step 3: Capture the controller's exact current state + const stateBefore = adapter.getState(); + const exactPendingFrameId = stateBefore.pendingFrameId; + const exactListenerRef = stateBefore.listenerRef; + const exactLayer = stateBefore.layer; + const fillTextCountBefore = calls.fillText.length; + const requestFrameCountBefore = calls.requestFrame.length; + assert.strictEqual(stateBefore.state, 'RUNNING', 'controller is RUNNING'); + assert.ok(exactPendingFrameId !== null, 'pendingFrameId is set (successor exists)'); + assert.ok(exactListenerRef !== null, 'listenerRef is set'); + assert.ok(exactLayer !== null, 'layer is set'); + + // Step 4: Change reducedMotionMatches to true + deps.reducedMotionMatches = () => true; + + // Step 5: Call handleTheme('Matrix') while RUNNING + adapter.handleTheme('Matrix'); + + // GREEN: exact captured successor is cancelled + assert.strictEqual(calls.cancelFrame.length, 1, 'one cancelFrame call'); + assert.strictEqual(calls.cancelFrame[0][0], exactPendingFrameId, 'cancelFrame received exact successor token'); + + // GREEN: exact listener removed + assert.strictEqual(calls.removeResizeListener.length, 1, 'one removeResizeListener call'); + assert.strictEqual(calls.removeResizeListener[0][0], exactListenerRef, 'removeResizeListener received exact listener'); + + // GREEN: exact layer removed + assert.strictEqual(calls.removeCanvasLayer.length, 1, 'one removeCanvasLayer call'); + assert.strictEqual(calls.removeCanvasLayer[0][0], exactLayer, 'removeCanvasLayer received exact layer'); + + // GREEN: controller IDLE + const stateAfter = adapter.getState(); + assert.strictEqual(stateAfter.state, 'IDLE', 'controller is IDLE after reduced-motion stop'); + assert.strictEqual(stateAfter.pendingFrameId, null, 'pendingFrameId cleared'); + assert.strictEqual(stateAfter.listenerRef, null, 'listenerRef cleared'); + assert.strictEqual(stateAfter.layer, null, 'layer cleared'); + + // GREEN: no new resources created + assert.strictEqual(calls.requestFrame.length, requestFrameCountBefore, 'no new requestFrame calls'); + assert.strictEqual(calls.createCanvasLayer.length, 1, 'no new layer created'); + assert.strictEqual(calls.addResizeListener.length, 1, 'no new listener added'); + + // GREEN: no new draw + assert.strictEqual(calls.fillText.length, fillTextCountBefore, 'no new fillText calls after reduced-motion stop'); + + // Step 6: Manually invoke the stale successor and assert no new draw and no new scheduling + const staleCallback = calls.requestFrame[1][0]; + const fillTextCountBeforeStale = calls.fillText.length; + const requestFrameCountBeforeStale = calls.requestFrame.length; + staleCallback(); + assert.strictEqual(calls.fillText.length, fillTextCountBeforeStale, 'no new fillText after stale callback'); + assert.strictEqual(calls.requestFrame.length, requestFrameCountBeforeStale, 'no new requestFrame after stale callback'); + }); +}); + +describe('adapter: manual first RAF callback draws once and schedules exactly one successor', () => { + it('invoke stored frame callback; drawFrame called once, one successor frame scheduled', () => { + const { deps, calls } = makeSpies(); + calls.fillText = []; + calls.fillRect = []; + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + // Get the frame callback that was scheduled + const frameCallback = calls.requestFrame[0][0]; + // Invoke it manually + frameCallback(); + // drawFrame draws all drops (one pass) — check fillText was called + assert.ok(calls.fillText.length > 0, 'drawFrame called (glyphs drawn)'); + // One successor frame scheduled + assert.strictEqual(calls.requestFrame.length, 2, 'one successor frame scheduled'); + const state = adapter.getState(); + assert.strictEqual(state.state, 'RUNNING', 'still RUNNING'); + }); +}); + +describe('adapter: semi-transparent overlay retains trails', () => { + it('fillStyle spy captures rgba black with numeric alpha between 0 and 1 at fillRect', () => { + const { deps, calls } = makeSpies(); + calls.fillText = []; + calls.fillRect = []; + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + const frameCallback = calls.requestFrame[0][0]; + frameCallback(); + // The spy captures fillStyle at each fillRect call + assert.ok(calls.fillStyleAtFillRect.length > 0, 'fillStyle captured at fillRect'); + const overlayStyle = calls.fillStyleAtFillRect[0]; + assert.ok(overlayStyle.startsWith('rgba(0, 0, 0,'), 'overlay uses rgba black'); + const alphaMatch = overlayStyle.match(/rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*([0-9.]+)\)/); + assert.ok(alphaMatch, 'overlay has numeric alpha'); + const alpha = parseFloat(alphaMatch[1]); + assert.ok(Number.isFinite(alpha), 'alpha is a finite number'); + assert.ok(alpha > 0 && alpha < 1, `alpha ${alpha} is strictly between 0 and 1`); + }); +}); + +describe('adapter: DPR changes propagate to setTransform on resize', () => { + it('changing DPR and resizing updates setTransform with new DPR', () => { + const { deps, calls, dpr } = makeSpies(); + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + // Get the resize handler that was registered + const resizeHandler = calls.addResizeListener[0][0]; + // First resize with initial DPR=2 + resizeHandler(); + assert.strictEqual(calls.setTransform.length, 2, 'setTransform called twice (start + resize)'); + assert.strictEqual(calls.setTransform[0][0], 2, 'initial setTransform DPR x is 2'); + assert.strictEqual(calls.setTransform[0][3], 2, 'initial setTransform DPR y is 2'); + // Change DPR to 3 via mutable object + dpr.value = 3; + // Second resize with DPR=3 + resizeHandler(); + assert.strictEqual(calls.setTransform.length, 3, 'setTransform called three times'); + assert.strictEqual(calls.setTransform[2][0], 3, 'second resize setTransform DPR x is 3'); + assert.strictEqual(calls.setTransform[2][3], 3, 'second resize setTransform DPR y is 3'); + }); +}); + +describe('adapter: resize callback dynamically rereads viewport and DPR and configures backing/CSS dimensions and context transform', () => { + it('resizeLayer triggers configureBackingStore with new viewport and DPR', () => { + const { deps, calls, viewportSize, dpr } = makeSpies(); + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + // Get the resize handler that was registered + const resizeHandler = calls.addResizeListener[0][0]; + // Simulate resize + resizeHandler(); + // Verify backing store was configured + assert.strictEqual(calls.getViewportCssSize.length, 2, 'viewport size read twice (start + resize)'); + assert.strictEqual(calls.getDevicePixelRatio.length, 2, 'DPR read twice'); + // Check setTransform was called with DPR + assert.ok(calls.setTransform.length > 0, 'setTransform called'); + assert.strictEqual(calls.setTransform[calls.setTransform.length - 1][0], dpr.value, 'setTransform DPR x'); + assert.strictEqual(calls.setTransform[calls.setTransform.length - 1][1], 0, 'setTransform DPR y skew'); + assert.strictEqual(calls.setTransform[calls.setTransform.length - 1][2], 0, 'setTransform x skew'); + assert.strictEqual(calls.setTransform[calls.setTransform.length - 1][3], dpr.value, 'setTransform DPR y'); + assert.strictEqual(calls.setTransform[calls.setTransform.length - 1][4], 0, 'setTransform x offset'); + assert.strictEqual(calls.setTransform[calls.setTransform.length - 1][5], 0, 'setTransform y offset'); + }); +}); + +describe('adapter: drops persist and advance across two manually invoked frames', () => { + it('two manual frame invocations advance drop.y for surviving columns, not cumulative call count', () => { + const { deps, calls } = makeSpies(); + calls.fillText = []; + calls.fillRect = []; + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + + // First frame + const frameCallback = calls.requestFrame[0][0]; + frameCallback(); + const firstCount = calls.fillText.length; + const frame1Calls = calls.fillText.slice(0, firstCount); + + // Second frame + const successorCallback = calls.requestFrame[1][0]; + successorCallback(); + const frame2Calls = calls.fillText.slice(firstCount); + + // Same number of columns drawn in both frames + assert.strictEqual(frame1Calls.length, frame2Calls.length, 'same column count across frames'); + + // Group frame 1 by x coordinate + const xCoords1 = new Map(); + for (const call of frame1Calls) { + const x = call[1]; + const y = call[2]; + if (!xCoords1.has(x) || y > xCoords1.get(x)) { + xCoords1.set(x, y); + } + } + + // Group frame 2 by x coordinate + const xCoords2 = new Map(); + for (const call of frame2Calls) { + const x = call[1]; + const y = call[2]; + if (!xCoords2.has(x) || y > xCoords2.get(x)) { + xCoords2.set(x, y); + } + } + + // Same x columns in both frames + assert.strictEqual(xCoords1.size, xCoords2.size, 'same x column count'); + for (const [x] of xCoords1) { + assert.ok(xCoords2.has(x), `column x=${x} present in both frames`); + } + + // At least some columns advanced y + let advancedCount = 0; + for (const [x, y1] of xCoords1) { + const y2 = xCoords2.get(x); + if (y2 !== undefined && y2 > y1) { + advancedCount++; + } + } + assert.ok(advancedCount > 0, `at least ${advancedCount} columns advanced y across frames`); + }); +}); + +describe('adapter: every fillText glyph belongs to the exported bounded glyph set', () => { + it('all fillText calls use glyphs from RAIN_GLYPHS', () => { + const { deps, calls } = makeSpies(); + calls.fillText = []; + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + // Invoke multiple frames to get multiple glyphs + const frameCallback = calls.requestFrame[0][0]; + frameCallback(); + const successorCallback = calls.requestFrame[1][0]; + successorCallback(); + // Check every fillText call's glyph argument + for (const fillTextCall of calls.fillText) { + const glyph = fillTextCall[0]; + assert.ok(RAIN_GLYPHS.includes(glyph), `glyph "${glyph}" belongs to RAIN_GLYPHS`); + } + }); +}); + +describe('adapter: tests use real createRainController and stored non-auto-running callbacks', () => { + 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.match( + source, + /from '\.\/theme-rain-controller\.js(?:\?v=[^']+)?'/, + 'adapter imports real controller' + ); + assert.ok(source.includes('createRainController'), 'adapter uses createRainController'); + }); +}); + +describe('adapter: source contains no #game access and no browser bootstrap', () => { + it('adapter source has no #game selector and no bootstrap/index/CSS', () => { + const adapterPath = join(__dirname, '..', 'public', 'theme-rain-adapter.js'); + const source = readFileSync(adapterPath, 'utf-8'); + assert.ok(!source.includes('#game'), 'no #game selector'); + assert.ok(!source.includes('bootstrap'), 'no bootstrap'); + assert.ok(!source.includes('index.html'), 'no index reference'); + assert.ok(!source.includes(' { + it('RAIN_GLYPHS is frozen, non-empty, single chars, contains digits/Latin/katakana', () => { + assert.ok(Object.isFrozen(RAIN_GLYPHS), 'RAIN_GLYPHS is frozen'); + assert.ok(RAIN_GLYPHS.length > 0, 'RAIN_GLYPHS is non-empty'); + for (const glyph of RAIN_GLYPHS) { + assert.strictEqual(typeof glyph, 'string', 'each glyph is a string'); + assert.strictEqual(glyph.length, 1, 'each glyph is a single character'); + } + // Verify presence of digits + const hasDigits = RAIN_GLYPHS.some(g => g >= '0' && g <= '9'); + assert.ok(hasDigits, 'RAIN_GLYPHS contains digits'); + // Verify presence of Latin + const hasLatin = RAIN_GLYPHS.some(g => (g >= 'A' && g <= 'Z') || (g >= 'a' && g <= 'z')); + assert.ok(hasLatin, 'RAIN_GLYPHS contains Latin characters'); + // Verify presence of katakana (Unicode range 0x30A0-0x30FF) + const hasKatakana = RAIN_GLYPHS.some(g => { + const code = g.charCodeAt(0); + return code >= 0x30A0 && code <= 0x30FF; + }); + assert.ok(hasKatakana, 'RAIN_GLYPHS contains katakana characters'); + }); +}); + +describe('adapter: dispose after Matrix is idempotent', () => { + it('handleTheme("Matrix"), dispose, dispose; only one cancelFrame call', () => { + const { deps, calls } = makeSpies(); + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + adapter.dispose(); + adapter.dispose(); + assert.strictEqual(calls.cancelFrame.length, 1, 'only one cancelFrame call'); + const state = adapter.getState(); + assert.strictEqual(state.state, 'IDLE', 'state is IDLE'); + }); +}); + +describe('adapter: getState reports controller state without creating resources', () => { + it('getState before start returns IDLE without calling any platform functions', () => { + const { deps, calls } = makeSpies(); + const adapter = createRainBrowserAdapter(deps); + const state = adapter.getState(); + assert.strictEqual(state.state, 'IDLE', 'state is IDLE'); + assert.strictEqual(state.generation, 0, 'generation is 0'); + assert.strictEqual(state.layer, null, 'layer is null'); + assert.strictEqual(state.pendingFrameId, null, 'pendingFrameId is null'); + assert.strictEqual(state.listenerRef, null, 'listenerRef is null'); + }); +}); + +describe('adapter: resize retains surviving drop state, truncates on shrink, creates on expansion', () => { + it('shrink width drops columns, expand width adds columns, surviving columns retain/advance state', () => { + const { deps, calls, viewportSize, dpr } = makeSpies(); + calls.fillText = []; + calls.fillRect = []; + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + + // Initial state: width=400, dpr=2 + // Columns = ceil(400/14) = 29 + const initialColumns = Math.ceil(400 / 14); + assert.strictEqual(initialColumns, 29, 'initial column count is 29'); + + // Advance one frame to establish drops + const frameCallback = calls.requestFrame[0][0]; + frameCallback(); + const glyphCountBefore = calls.fillText.length; + assert.strictEqual(glyphCountBefore, initialColumns, `initial frame drew ${initialColumns} glyphs`); + + // Get resize handler + const resizeHandler = calls.addResizeListener[0][0]; + + // --- Shrink: width 400 -> 200, DPR 2 -> 3 --- + // Mutate the values returned by the already-injected dependency functions. + viewportSize.width = 200; + viewportSize.height = 600; + dpr.value = 3; + + resizeHandler(); + + // Verify backing store configured with new dimensions + const shrinkState = adapter.getState(); + const shrinkLayer = shrinkState.layer; + assert.ok(shrinkLayer, 'layer exists after shrink'); + assert.strictEqual(shrinkLayer.canvas.width, 600, 'backing width after shrink (200*3)'); + assert.strictEqual(shrinkLayer.canvas.height, 1800, 'backing height after shrink (600*3)'); + assert.strictEqual(shrinkLayer.canvas.style.width, '200px', 'CSS width after shrink'); + assert.strictEqual(shrinkLayer.canvas.style.height, '600px', 'CSS height after shrink'); + + // Verify setTransform with DPR=3 + assert.ok(calls.setTransform.length > 0, 'setTransform called after shrink'); + const lastShrinkTransform = calls.setTransform[calls.setTransform.length - 1]; + assert.strictEqual(lastShrinkTransform[0], 3, 'setTransform DPR x after shrink'); + assert.strictEqual(lastShrinkTransform[3], 3, 'setTransform DPR y after shrink'); + + // Verify viewport/DPR re-read + assert.strictEqual(calls.getViewportCssSize.length, 2, 'viewport re-read on shrink'); + assert.strictEqual(calls.getDevicePixelRatio.length, 2, 'DPR re-read on shrink'); + + // Advance frame after shrink — should have fewer columns + const shrinkFrameCallback = calls.requestFrame[1][0]; + const shrinkFrameStart = calls.fillText.length; + shrinkFrameCallback(); + const shrinkFrameCalls = calls.fillText.slice(shrinkFrameStart); + const glyphCountAfterShrink = shrinkFrameCalls.length; + const shrinkColumns = Math.ceil(200 / 14); + assert.strictEqual(shrinkColumns, 15, 'shrink column count is 15'); + assert.strictEqual(glyphCountAfterShrink, shrinkColumns, `shrink frame drew ${shrinkColumns} glyphs`); + assert.ok(glyphCountAfterShrink < glyphCountBefore, `columns dropped: ${glyphCountAfterShrink} < ${glyphCountBefore}`); + + // Capture surviving logical columns by deterministic draw order. Rendered x is + // geometry, not identity: an edge-clamped column must move when width changes. + const survivingYs = shrinkFrameCalls.map((call) => call[2]); + + // --- Expand: width 200 -> 800, DPR stays 3 --- + viewportSize.width = 800; + + resizeHandler(); + + // Verify backing store configured with expanded dimensions + const expandState = adapter.getState(); + const expandLayer = expandState.layer; + assert.ok(expandLayer, 'layer exists after expand'); + assert.strictEqual(expandLayer.canvas.width, 2400, 'backing width after expand (800*3)'); + assert.strictEqual(expandLayer.canvas.height, 1800, 'backing height after expand (600*3)'); + assert.strictEqual(expandLayer.canvas.style.width, '800px', 'CSS width after expand'); + assert.strictEqual(expandLayer.canvas.style.height, '600px', 'CSS height after expand'); + + // Verify setTransform still DPR=3 + const lastExpandTransform = calls.setTransform[calls.setTransform.length - 1]; + assert.strictEqual(lastExpandTransform[0], 3, 'setTransform DPR x after expand'); + assert.strictEqual(lastExpandTransform[3], 3, 'setTransform DPR y after expand'); + + // Verify viewport/DPR re-read + assert.strictEqual(calls.getViewportCssSize.length, 3, 'viewport re-read on expand'); + assert.strictEqual(calls.getDevicePixelRatio.length, 3, 'DPR re-read on expand'); + + // Advance frame after expand — should have more columns + const expandFrameCallback = calls.requestFrame[2][0]; + const expandFrameStart = calls.fillText.length; + expandFrameCallback(); + const expandFrameCalls = calls.fillText.slice(expandFrameStart); + const glyphCountAfterExpand = expandFrameCalls.length; + const expandColumns = Math.ceil(800 / 14); + assert.strictEqual(expandColumns, 58, 'expand column count is 58'); + assert.strictEqual(glyphCountAfterExpand, expandColumns, `expand frame drew ${expandColumns} glyphs`); + assert.ok(glyphCountAfterExpand > glyphCountAfterShrink, `columns added: ${glyphCountAfterExpand} > ${glyphCountAfterShrink}`); + + // Every surviving logical column occupies the same ordered prefix and advances y. + for (let column = 0; column < survivingYs.length; column++) { + const y1 = survivingYs[column]; + const y2 = expandFrameCalls[column][2]; + assert.ok(y2 > y1, `surviving column ${column} advanced y: ${y1} -> ${y2}`); + } + assert.strictEqual(survivingYs.length, shrinkColumns, `all ${shrinkColumns} surviving columns retained and advanced`); + }); +}); + +describe('adapter: controller remains sole RAF owner and exact cleanup passes', () => { + it('controller owns all frames; stop cancels exact owned frame and removes exact owned resources', () => { + const { deps, calls } = makeSpies(); + const adapter = createRainBrowserAdapter(deps); + adapter.handleTheme('Matrix'); + + const stateBefore = adapter.getState(); + assert.strictEqual(stateBefore.state, 'RUNNING', 'running'); + + // Advance one frame to get a successor + const frameCallback = calls.requestFrame[0][0]; + frameCallback(); + + // Now stop + adapter.handleTheme('Default'); + + // Verify exact cleanup + assert.strictEqual(calls.cancelFrame.length, 1, 'one cancelFrame'); + assert.strictEqual(calls.removeResizeListener.length, 1, 'one removeResizeListener'); + assert.strictEqual(calls.removeCanvasLayer.length, 1, 'one removeCanvasLayer'); + + const state = adapter.getState(); + assert.strictEqual(state.state, 'IDLE', 'state is IDLE'); + assert.strictEqual(state.pendingFrameId, null, 'pendingFrameId cleared'); + assert.strictEqual(state.listenerRef, null, 'listenerRef cleared'); + }); +}); diff --git a/tests/theme-rain-bootstrap.test.mjs b/tests/theme-rain-bootstrap.test.mjs new file mode 100644 index 0000000..bd00dc1 --- /dev/null +++ b/tests/theme-rain-bootstrap.test.mjs @@ -0,0 +1,318 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { createThemeRainBootstrap } from '../public/theme-rain-bootstrap.js'; + +function createFakeRoot(options = {}) { + let currentTheme = options.currentTheme ?? null; + let reducedMotion = options.reducedMotion ?? false; + const records = { + appended: [], + removed: [], + documentAdds: [], + documentRemoves: [], + windowAdds: [], + windowRemoves: [], + mediaAdds: [], + mediaRemoves: [], + requestedFrames: [], + cancelledFrames: [], + transforms: [], + fills: [], + }; + const documentListeners = new Map(); + const windowListeners = new Map(); + const mediaListeners = new Map(); + const frameCallbacks = new Map(); + let nextFrameId = 40; + + const context = { + fillStyle: '', + font: '', + setTransform(...args) { + records.transforms.push(args); + }, + fillRect(...args) { + records.fills.push(['rect', ...args]); + }, + fillText(...args) { + records.fills.push(['text', ...args]); + }, + }; + + const body = { + appendChild(node) { + node.parentNode = body; + records.appended.push(node); + return node; + }, + removeChild(node) { + assert.equal(node.parentNode, body); + node.parentNode = null; + records.removed.push(node); + return node; + }, + }; + + const document = { + body, + createElement(tag) { + assert.equal(tag, 'canvas'); + const attributes = new Map(); + return { + nodeName: 'CANVAS', + className: '', + style: {}, + width: 0, + height: 0, + parentNode: null, + setAttribute(name, value) { + attributes.set(name, String(value)); + }, + getAttribute(name) { + return attributes.get(name) ?? null; + }, + getContext(kind) { + assert.equal(kind, '2d'); + return context; + }, + }; + }, + addEventListener(type, handler) { + records.documentAdds.push([type, handler]); + documentListeners.set(type, handler); + }, + removeEventListener(type, handler) { + records.documentRemoves.push([type, handler]); + if (documentListeners.get(type) === handler) documentListeners.delete(type); + }, + }; + + const mediaQueryList = { + matches: reducedMotion, + addEventListener(type, handler) { + records.mediaAdds.push([type, handler]); + mediaListeners.set(type, handler); + }, + removeEventListener(type, handler) { + records.mediaRemoves.push([type, handler]); + if (mediaListeners.get(type) === handler) mediaListeners.delete(type); + }, + }; + + const root = { + document, + innerWidth: options.width ?? 320, + innerHeight: options.height ?? 240, + devicePixelRatio: options.dpr ?? 2, + ThemeModule: { + getCurrentTheme() { + return currentTheme; + }, + }, + Math: { random: () => 0.5 }, + matchMedia(query) { + assert.equal(query, '(prefers-reduced-motion: reduce)'); + mediaQueryList.matches = reducedMotion; + return mediaQueryList; + }, + requestAnimationFrame(callback) { + const id = ++nextFrameId; + records.requestedFrames.push([id, callback]); + frameCallbacks.set(id, callback); + return id; + }, + cancelAnimationFrame(id) { + records.cancelledFrames.push(id); + frameCallbacks.delete(id); + }, + addEventListener(type, handler) { + records.windowAdds.push([type, handler]); + windowListeners.set(type, handler); + }, + removeEventListener(type, handler) { + records.windowRemoves.push([type, handler]); + if (windowListeners.get(type) === handler) windowListeners.delete(type); + }, + }; + + return { + root, + context, + records, + documentListeners, + windowListeners, + mediaListeners, + frameCallbacks, + setCurrentTheme(value) { currentTheme = value; }, + setReducedMotion(value) { + reducedMotion = value; + mediaQueryList.matches = value; + }, + dispatchReducedMotion(value) { + reducedMotion = value; + mediaQueryList.matches = value; + const handler = mediaListeners.get('change'); + assert.equal(typeof handler, 'function'); + handler({ matches: value }); + }, + dispatchTheme(detail) { + const handler = documentListeners.get('themechange'); + assert.equal(typeof handler, 'function'); + handler(detail === undefined ? { type: 'themechange' } : { type: 'themechange', detail }); + }, + }; +} + +describe('Matrix rain browser bootstrap', () => { + test('module/factory have zero browser side effects before start', () => { + const fake = createFakeRoot(); + const bootstrap = createThemeRainBootstrap(fake.root); + assert.deepEqual(Object.keys(bootstrap).sort(), ['dispose', 'getState', 'start']); + assert.equal(fake.records.appended.length, 0); + assert.equal(fake.records.documentAdds.length, 0); + assert.equal(fake.records.windowAdds.length, 0); + assert.equal(fake.records.requestedFrames.length, 0); + }); + + test('start is idempotent, installs one theme listener, and ignores malformed events', () => { + const fake = createFakeRoot(); + const bootstrap = createThemeRainBootstrap(fake.root); + bootstrap.start(); + bootstrap.start(); + assert.equal(fake.records.documentAdds.length, 1); + assert.equal(fake.records.documentAdds[0][0], 'themechange'); + assert.equal(fake.records.appended.length, 0); + fake.dispatchTheme(undefined); + fake.dispatchTheme({ theme: 42 }); + assert.equal(fake.records.appended.length, 0); + assert.equal(fake.records.requestedFrames.length, 0); + }); + + test('Matrix creates one exact accessible canvas, resize owner, and frame at CSS viewport/DPR size', () => { + const fake = createFakeRoot({ width: 401, height: 277, dpr: 1.5 }); + const bootstrap = createThemeRainBootstrap(fake.root); + bootstrap.start(); + fake.dispatchTheme({ theme: 'Matrix' }); + + assert.equal(fake.records.appended.length, 1); + const canvas = fake.records.appended[0]; + assert.equal(canvas.parentNode, fake.root.document.body); + assert.equal(canvas.className, 'matrix-rain-layer'); + assert.equal(canvas.getAttribute('aria-hidden'), 'true'); + assert.equal(canvas.width, 602); + assert.equal(canvas.height, 416); + assert.equal(canvas.style.width, '401px'); + assert.equal(canvas.style.height, '277px'); + assert.deepEqual(fake.records.transforms, [[1.5, 0, 0, 1.5, 0, 0]]); + assert.equal(fake.records.windowAdds.length, 1); + assert.equal(fake.records.windowAdds[0][0], 'resize'); + assert.equal(fake.records.requestedFrames.length, 1); + assert.equal(bootstrap.getState().state, 'RUNNING'); + + fake.dispatchTheme({ theme: 'Matrix' }); + assert.equal(fake.records.appended.length, 1); + assert.equal(fake.records.windowAdds.length, 1); + assert.equal(fake.records.requestedFrames.length, 1); + }); + + test('non-Matrix removes the exact live canvas, resize handler, and pending frame; stale frame is inert', () => { + const fake = createFakeRoot(); + const bootstrap = createThemeRainBootstrap(fake.root); + bootstrap.start(); + fake.dispatchTheme({ theme: 'Matrix' }); + const canvas = fake.records.appended[0]; + const resizeHandler = fake.records.windowAdds[0][1]; + const [frameId, staleFrame] = fake.records.requestedFrames[0]; + + fake.dispatchTheme({ theme: 'CandyPop' }); + assert.deepEqual(fake.records.removed, [canvas]); + assert.deepEqual(fake.records.windowRemoves, [['resize', resizeHandler]]); + assert.deepEqual(fake.records.cancelledFrames, [frameId]); + assert.equal(bootstrap.getState().state, 'IDLE'); + + const requestCount = fake.records.requestedFrames.length; + const drawCount = fake.records.fills.length; + staleFrame(); + assert.equal(fake.records.requestedFrames.length, requestCount); + assert.equal(fake.records.fills.length, drawCount); + }); + + test('a current persisted Matrix theme is forwarded once during start', () => { + const fake = createFakeRoot({ currentTheme: 'Matrix' }); + const bootstrap = createThemeRainBootstrap(fake.root); + bootstrap.start(); + bootstrap.start(); + assert.equal(fake.records.appended.length, 1); + assert.equal(fake.records.requestedFrames.length, 1); + }); + + test('reduced motion creates zero canvas, resize, or frame resources for Matrix', () => { + const fake = createFakeRoot({ currentTheme: 'Matrix', reducedMotion: true }); + const bootstrap = createThemeRainBootstrap(fake.root); + bootstrap.start(); + fake.dispatchTheme({ theme: 'Matrix' }); + assert.equal(fake.records.appended.length, 0); + assert.equal(fake.records.windowAdds.length, 0); + assert.equal(fake.records.requestedFrames.length, 0); + assert.equal(bootstrap.getState().state, 'IDLE'); + }); + + test('live reduced-motion changes stop and restart current Matrix ownership', () => { + const fake = createFakeRoot({ currentTheme: 'Matrix', reducedMotion: false }); + const bootstrap = createThemeRainBootstrap(fake.root); + bootstrap.start(); + bootstrap.start(); + + assert.equal(fake.records.mediaAdds.length, 1); + assert.equal(fake.records.mediaAdds[0][0], 'change'); + assert.equal(bootstrap.getState().state, 'RUNNING'); + const firstCanvas = fake.records.appended[0]; + const firstFrame = fake.records.requestedFrames[0][0]; + + fake.dispatchReducedMotion(true); + assert.equal(bootstrap.getState().state, 'IDLE'); + assert.deepEqual(fake.records.removed, [firstCanvas]); + assert.deepEqual(fake.records.cancelledFrames, [firstFrame]); + + fake.dispatchReducedMotion(false); + assert.equal(bootstrap.getState().state, 'RUNNING'); + assert.equal(fake.records.appended.length, 2); + + const mediaHandler = fake.records.mediaAdds[0][1]; + bootstrap.dispose(); + bootstrap.dispose(); + assert.deepEqual(fake.records.mediaRemoves, [['change', mediaHandler]]); + }); + + test('dispose is idempotent, removes exact ownership once, and captured stale theme events are inert', () => { + const fake = createFakeRoot({ currentTheme: 'Matrix' }); + const bootstrap = createThemeRainBootstrap(fake.root); + bootstrap.start(); + const staleThemeHandler = fake.records.documentAdds[0][1]; + const canvas = fake.records.appended[0]; + + bootstrap.dispose(); + bootstrap.dispose(); + assert.equal(fake.records.documentRemoves.length, 1); + assert.deepEqual(fake.records.documentRemoves[0], ['themechange', staleThemeHandler]); + assert.deepEqual(fake.records.removed, [canvas]); + assert.equal(bootstrap.getState().state, 'IDLE'); + + staleThemeHandler({ detail: { theme: 'Matrix' } }); + assert.equal(fake.records.appended.length, 1); + assert.equal(fake.records.requestedFrames.length, 1); + }); + + test('invalid or non-positive DPR falls back to one', () => { + for (const dpr of [0, -2, Number.NaN, Number.POSITIVE_INFINITY]) { + const fake = createFakeRoot({ dpr, currentTheme: 'Matrix', width: 111, height: 77 }); + const bootstrap = createThemeRainBootstrap(fake.root); + bootstrap.start(); + const canvas = fake.records.appended[0]; + assert.equal(canvas.width, 111); + assert.equal(canvas.height, 77); + assert.deepEqual(fake.records.transforms, [[1, 0, 0, 1, 0, 0]]); + bootstrap.dispose(); + } + }); +}); diff --git a/tests/theme-rain-controller.test.mjs b/tests/theme-rain-controller.test.mjs new file mode 100644 index 0000000..65e25bf --- /dev/null +++ b/tests/theme-rain-controller.test.mjs @@ -0,0 +1,206 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { createRainController } from '../public/theme-rain-controller.js'; + +// ---- helpers ---- + +function makeSpies() { + const calls = { + createLayer: [], + removeLayer: [], + addResizeListener: [], + removeResizeListener: [], + requestFrame: [], + cancelFrame: [], + resizeLayer: [], + drawFrame: [], + }; + const deps = { + createLayer: (...args) => { + calls.createLayer.push(args); + return { id: `layer-${calls.createLayer.length}` }; + }, + removeLayer: (...args) => calls.removeLayer.push(args), + addResizeListener: (...args) => { + calls.addResizeListener.push(args); + return args[0]; // return the handler ref + }, + removeResizeListener: (...args) => calls.removeResizeListener.push(args), + requestFrame: (...args) => { + calls.requestFrame.push(args); + const token = `frame-${calls.requestFrame.length}`; + return token; + }, + cancelFrame: (...args) => calls.cancelFrame.push(args), + resizeLayer: (...args) => calls.resizeLayer.push(args), + drawFrame: (...args) => calls.drawFrame.push(args), + reducedMotionMatches: false, + }; + return { deps, calls }; +} + +// ---- tests ---- + +describe('rain-controller: start idempotence and single-owner scheduling', () => { + it('calling start() twice creates only one layer, one listener, one frame', () => { + const { deps, calls } = makeSpies(); + const ctrl = createRainController(deps); + ctrl.start(); + ctrl.start(); + assert.strictEqual(calls.createLayer.length, 1, 'only one layer created'); + assert.strictEqual(calls.addResizeListener.length, 1, 'only one listener added'); + assert.strictEqual(calls.requestFrame.length, 1, 'only one frame scheduled'); + const state = ctrl.getState(); + assert.strictEqual(state.state, 'RUNNING'); + assert.strictEqual(state.generation, 1); + }); +}); + +describe('rain-controller: stop cleanup with exact owned handles', () => { + it('stop cancels the owned frame, removes exact listener, removes owned layer, state returns IDLE', () => { + const { deps, calls } = makeSpies(); + const ctrl = createRainController(deps); + ctrl.start(); + const stateBefore = ctrl.getState(); + const ownedToken = stateBefore.pendingFrameId; + const ownedListener = stateBefore.listenerRef; + const ownedLayer = stateBefore.layer; + ctrl.stop(); + assert.strictEqual(calls.cancelFrame.length, 1, 'cancelFrame called once'); + assert.strictEqual(calls.cancelFrame[0][0], ownedToken, 'cancelFrame received exact owned frame token'); + assert.strictEqual(calls.removeResizeListener.length, 1, 'removeResizeListener called once'); + assert.strictEqual(calls.removeResizeListener[0][0], ownedListener, 'removeResizeListener received exact owned listenerRef'); + assert.strictEqual(calls.removeLayer.length, 1, 'removeLayer called once'); + assert.strictEqual(calls.removeLayer[0][0], ownedLayer, 'removeLayer received exact owned layer'); + const state = ctrl.getState(); + assert.strictEqual(state.state, 'IDLE'); + assert.strictEqual(state.generation, 2); + }); +}); + +describe('rain-controller: reduced-motion zero-side-effect behavior', () => { + it('inject reducedMotionMatches=true; start() never calls createLayer/addResizeListener/requestFrame, state stays IDLE', () => { + const { deps, calls } = makeSpies(); + deps.reducedMotionMatches = true; + const ctrl = createRainController(deps); + ctrl.start(); + assert.strictEqual(calls.createLayer.length, 0, 'createLayer never called'); + assert.strictEqual(calls.addResizeListener.length, 0, 'addResizeListener never called'); + assert.strictEqual(calls.requestFrame.length, 0, 'requestFrame never called'); + const state = ctrl.getState(); + assert.strictEqual(state.state, 'IDLE'); + assert.strictEqual(state.generation, 0); + }); +}); + +describe('rain-controller: stale callback fencing after stop', () => { + it('start, invoke pending frame callback, stop, invoke stale successor; drawFrame NOT called by stale callback, no new frame scheduled', () => { + const { deps, calls } = makeSpies(); + const ctrl = createRainController(deps); + ctrl.start(); + // Invoke the pending frame callback (simulates the frame firing) + // This legitimately calls drawFrame once and schedules a successor + const frameCallback = calls.requestFrame[0][0]; + frameCallback(); + // Assert exactly two scheduled calls before invoking stale successor + assert.strictEqual(calls.requestFrame.length, 2, 'exactly two requestFrame calls before stale invocation'); + // Record drawFrame count after first callback + const drawCountAfterFirst = calls.drawFrame.length; + // Now stop — this invalidates the generation + ctrl.stop(); + // Invoke the stale successor callback (scheduled by the first frame callback) + const staleCallback = calls.requestFrame[1][0]; + staleCallback(); + // drawFrame should NOT have been called by the stale callback + assert.strictEqual(calls.drawFrame.length, drawCountAfterFirst, 'drawFrame not called by stale callback'); + // No new frame should have been scheduled after stale callback + assert.strictEqual(calls.requestFrame.length, 2, 'no new frame scheduled after stale callback'); + }); +}); + +describe('rain-controller: stale callback fenced after restart', () => { + it('start, fire callback (schedules successor), stop, start again, invoke old successor; drawFrame NOT called', () => { + const { deps, calls } = makeSpies(); + const ctrl = createRainController(deps); + // First start + ctrl.start(); + // Fire the pending frame callback — schedules a successor + const firstCallback = calls.requestFrame[0][0]; + firstCallback(); + assert.strictEqual(calls.requestFrame.length, 2, 'successor scheduled'); + const staleCallback = calls.requestFrame[1][0]; + // Stop — invalidates generation 1 + ctrl.stop(); + // Start again — new generation 3, schedules new first frame + ctrl.start(); + // After second start: 3 requestFrame calls (1 initial + 1 successor + 1 new first frame) + assert.strictEqual(calls.requestFrame.length, 3, 'second start scheduled new first frame'); + // Invoke the old successor callback from generation 1 + staleCallback(); + // drawFrame should NOT have been called — generation fence + assert.strictEqual(calls.drawFrame.length, 1, 'drawFrame not called by stale successor after restart'); + // No new frame should have been scheduled by stale callback + assert.strictEqual(calls.requestFrame.length, 3, 'no new frame from stale successor after restart'); + }); +}); + +describe('rain-controller: getState.pendingFrameId transfers to successor', () => { + it('after frame callback fires, getState.pendingFrameId equals the successor token', () => { + const { deps, calls } = makeSpies(); + const ctrl = createRainController(deps); + ctrl.start(); + // Before callback: pendingFrameId is the first token + const stateBefore = ctrl.getState(); + const firstToken = stateBefore.pendingFrameId; + assert.strictEqual(firstToken, 'frame-1', 'pendingFrameId is first frame token'); + // Fire the callback + const frameCallback = calls.requestFrame[0][0]; + frameCallback(); + // After callback: pendingFrameId should be the successor token + const stateAfter = ctrl.getState(); + assert.strictEqual(stateAfter.pendingFrameId, 'frame-2', 'pendingFrameId transferred to successor'); + assert.notStrictEqual(stateAfter.pendingFrameId, firstToken, 'pendingFrameId changed from first to successor'); + }); +}); + +describe('rain-controller: resize delegation only while active', () => { + it('resize() while IDLE is no-op; start then resize delegates once; stop then resize is no-op', () => { + const { deps, calls } = makeSpies(); + const ctrl = createRainController(deps); + // While IDLE + ctrl.resize(); + assert.strictEqual(calls.resizeLayer.length, 0, 'resizeLayer not called while IDLE'); + // Start + ctrl.start(); + const ownedLayer = ctrl.getState().layer; + // Resize while active + ctrl.resize(); + assert.strictEqual(calls.resizeLayer.length, 1, 'resizeLayer called once after start'); + assert.strictEqual(calls.resizeLayer[0][0], ownedLayer, 'resizeLayer received exact owned layer'); + // Stop + ctrl.stop(); + // Resize while IDLE again + ctrl.resize(); + assert.strictEqual(calls.resizeLayer.length, 1, 'resizeLayer not called after stop'); + }); +}); + +describe('rain-controller: three repeated start-stop cycles leak no handles', () => { + it('cycle start/stop three times; createLayer 3/removeLayer 3, addResizeListener 3/removeResizeListener 3, requestFrame 3/cancelFrame 3, final state IDLE generation=6', () => { + const { deps, calls } = makeSpies(); + const ctrl = createRainController(deps); + for (let i = 0; i < 3; i++) { + ctrl.start(); + ctrl.stop(); + } + assert.strictEqual(calls.createLayer.length, 3, 'createLayer called 3 times'); + assert.strictEqual(calls.removeLayer.length, 3, 'removeLayer called 3 times'); + assert.strictEqual(calls.addResizeListener.length, 3, 'addResizeListener called 3 times'); + assert.strictEqual(calls.removeResizeListener.length, 3, 'removeResizeListener called 3 times'); + assert.strictEqual(calls.requestFrame.length, 3, 'requestFrame called 3 times'); + assert.strictEqual(calls.cancelFrame.length, 3, 'cancelFrame called 3 times'); + const state = ctrl.getState(); + assert.strictEqual(state.state, 'IDLE'); + assert.strictEqual(state.generation, 6); + }); +}); diff --git a/tests/theme-renderer.test.mjs b/tests/theme-renderer.test.mjs new file mode 100644 index 0000000..76818c4 --- /dev/null +++ b/tests/theme-renderer.test.mjs @@ -0,0 +1,241 @@ +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, fillStyle: ctx.fillStyle }); }, + 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: solid Matrix and Dark palettes', () => { + const pieceColors = ['#67e8f9', '#fde047', '#c084fc', '#86efac', '#fda4af', '#93c5fd', '#fdba74']; + const matrixShades = ['#006b2b', '#008f39', '#00a844', '#00bd4d', '#00d657', '#16e968', '#3df27f']; + const darkShades = ['#101010', '#1c1c1c', '#282828', '#343434', '#424242', '#525252', '#646464']; + + it('Matrix draws each standard piece as one deterministic solid green shade with no glyph', () => { + pieceColors.forEach((color, index) => { + const { ctx, calls } = makeMockCtx(); + globalThis.drawBrick(ctx, index, 0, color, 'Matrix', 30); + assert.strictEqual(calls.fillRect.length, 1); + assert.strictEqual(calls.fillRect[0].fillStyle, matrixShades[index]); + assert.strictEqual(calls.fillText.length, 0); + }); + assert.strictEqual(new Set(matrixShades).size, pieceColors.length); + assert.strictEqual(globalThis.getRenderer('Matrix').glyphs, null); + }); + + it('Dark draws each standard piece as one deterministic solid black-to-grey shade', () => { + pieceColors.forEach((color, index) => { + const { ctx, calls } = makeMockCtx(); + globalThis.drawBrick(ctx, index, 0, color, 'Dark', 30); + assert.strictEqual(calls.fillRect.length, 1); + assert.strictEqual(calls.fillRect[0].fillStyle, darkShades[index]); + assert.strictEqual(calls.fillText.length, 0); + }); + assert.strictEqual(new Set(darkShades).size, pieceColors.length); + }); +}); + +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('