Stacklogic
/> - + + +High scores
Audio starts after Start.
+Audio starts after Play Solo.
+ + + + @@ -94,6 +126,8 @@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 @@
+Audio starts after Start.
+Audio starts after Play Solo.
+ + + + @@ -94,6 +126,8 @@