diff --git a/docs/superpowers/plans/2026-07-07-frontend-js.md b/docs/superpowers/plans/2026-07-07-frontend-js.md new file mode 100644 index 0000000..d10173e --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-frontend-js.md @@ -0,0 +1,912 @@ +# Plan 3c: JavaScript Frontend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the small JavaScript surface that lets the home page list recent scenarios from `localStorage`, the team editor pre-fill from `localStorage`, and the battlefield editor run click-paint grid interactions with JSON fetch save. + +**Architecture:** Two pure-ESM JavaScript files (`public/js/storage.js` and `public/js/grid-editor.js`) attach `window.bfStorage` and run `DOMContentLoaded` on their respective pages. The team editor's form continues to POST to the existing Plan 3b `PostTeamEditor` handler; the battlefield editor's form submit is intercepted by the JS to send a JSON fetch. The Node toolchain (ESLint `airbnb-base` + Prettier) is added with a new `npm run lint` and `npm run format` step in CI. + +**Tech Stack:** PHP 8.3 (unchanged), vanilla ESM JavaScript (no bundler, no transpiler), Node 20+ with ESLint `^8.57.0`, `eslint-config-airbnb-base ^15.0.0`, `eslint-plugin-import ^2.29.0`, Prettier `^3.3.0`. + +## Global Constraints + +These apply to every task and are copied verbatim from the design spec. + +- Anonymous browser persistence: scenarios and matches live in the browser's `localStorage` (`scenario:{id}` and `match:current`); the server never stores game state. +- Every state-changing form or request uses and verifies a CSRF token before mutation. The token is per-session, signed with the app's secret using `hash_hmac('sha256', …)`. Forms use a hidden `_csrf` field; fetch uses an `X-CSRF-Token` header. +- All rendered output is escaped for its output context via the `Escape::html`, `Escape::attr`, and `Escape::url` helpers. Templates do not interpolate user input without one of these helpers. The JS uses `textContent` and `value` setters, never `innerHTML` (with one exception: building the home page's recent-scenarios list uses `appendChild` on `` elements with `textContent`, never `innerHTML`). +- Bundled placeholder images are committed under `public/assets/placeholders/`; user uploads live in `var/uploads/` (git-ignored). +- The single small JS file (`public/js/grid-editor.js` and `public/js/storage.js`) passes ESLint `airbnb/base` and Prettier. +- PHP follows the repository PHPCS rules and passes PHPStan at level 6. +- Composer configuration passes `composer validate --strict`. +- Lint, static analysis, unit and integration tests, the end-to-end smoke test, and Composer validation are required CI checks. +- No third-party JS libraries. The two JS files are pure ESM, no dependencies, no `eval`, no `Function` constructor. +- The data shape for scenarios in `localStorage` is the same canonical shape that `BattleForge\Application\ScenarioSerializer::scenarioToArray` produces. +- `var/secret.key` is added to `.gitignore` (the Plan 3b `FullFlowTest` creates it on first run). + +## File Structure + +```text +public/ +├── assets/ +│ ├── archetypes.json (NEW) — 4 ArchetypeTemplate definitions +│ ├── placeholders/ (NEW) +│ │ ├── defender.png +│ │ ├── striker.png +│ │ ├── support.png +│ │ └── scout.png +│ ├── styles.css (unchanged) +│ └── js/ (NEW) +│ ├── storage.js (NEW) — bfStorage module +│ └── grid-editor.js (NEW) — battlefield grid interactions + +src/Views/ (updated — add ) + +package.json (NEW) +.eslintrc.json (NEW) +.prettierrc (NEW) +.github/workflows/ci.yml (updated — add npm ci, lint, format steps) +.gitignore (updated — add var/secret.key, node_modules/) +``` + +## Delivery Sequence + +Plan 3c is the third of four plans. It is the last plan in the original "Plan 3" deliverable. It depends on Plan 3a+3b's PHP web layer being merged (which it is on `develop`). + +1. Toolchain + assets (Task 1) +2. `storage.js` and template ``) +- Modify: `src/Views/team-editor.php` (add `` and a "Start match" button) + +**Interfaces:** +- Produces `public/js/storage.js`, an ESM module that attaches `window.bfStorage` with eight methods: `load(id)`, `save(id, scenario)`, `delete(id)`, `listAll()`, `currentMatch()`, `setCurrentMatch(match)`, `removeCurrentMatch()`. The module's top-level code runs `populateRecentScenarios()` on `DOMContentLoaded` (for the home page) and `prefillTeamEditor()` on `DOMContentLoaded` (for the team editor), based on which root element is present in the DOM. The "Start match" button on the team editor POSTs to `/scenarios/{id}/start` and stores the response in `localStorage`. + +- [ ] **Step 1: Implement `storage.js`** + +Create `public/js/storage.js`: + +```js +const STORAGE_PREFIX = 'scenario:'; +const MATCH_KEY = 'match:current'; + +function load(id) { + try { + const raw = localStorage.getItem(STORAGE_PREFIX + id); + if (raw === null) { + return null; + } + return JSON.parse(raw); + } catch (e) { + return null; + } +} + +function save(id, scenario) { + const stamped = Object.assign({}, scenario, { lastModified: Date.now() }); + localStorage.setItem(STORAGE_PREFIX + id, JSON.stringify(stamped)); +} + +function remove(id) { + localStorage.removeItem(STORAGE_PREFIX + id); +} + +function listAll() { + const items = []; + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (key === null || !key.startsWith(STORAGE_PREFIX)) { + continue; + } + const id = key.slice(STORAGE_PREFIX.length); + const scenario = load(id); + if (scenario === null) { + continue; + } + items.push({ + id, + name: typeof scenario.name === 'string' ? scenario.name : id, + lastModified: typeof scenario.lastModified === 'number' ? scenario.lastModified : 0, + }); + } + items.sort((a, b) => b.lastModified - a.lastModified); + return items; +} + +function currentMatch() { + try { + const raw = localStorage.getItem(MATCH_KEY); + if (raw === null) { + return null; + } + return JSON.parse(raw); + } catch (e) { + return null; + } +} + +function setCurrentMatch(match) { + localStorage.setItem(MATCH_KEY, JSON.stringify(match)); +} + +function removeCurrentMatch() { + localStorage.removeItem(MATCH_KEY); +} + +function getCsrfToken() { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute('content') : ''; +} + +function populateRecentScenarios() { + const root = document.getElementById('recent'); + if (!root) { + return; + } + while (root.firstChild) { + root.removeChild(root.firstChild); + } + const items = listAll(); + if (items.length === 0) { + const p = document.createElement('p'); + p.className = 'bf-empty'; + p.appendChild(document.createTextNode('No saved scenarios yet.')); + root.appendChild(p); + return; + } + const ul = document.createElement('ul'); + for (const item of items) { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.setAttribute('href', '/scenarios/' + encodeURIComponent(item.id) + '/edit/team'); + a.appendChild(document.createTextNode(item.name + ' (' + item.id + ')')); + li.appendChild(a); + ul.appendChild(li); + } + root.appendChild(ul); +} + +async function fetchArchetypes() { + const res = await fetch('/assets/archetypes.json'); + return res.json(); +} + +function prefillTeamEditor() { + const form = document.querySelector('form[action*="/edit/team"]'); + if (!form) { + return; + } + const id = decodeURIComponent(window.location.pathname.split('/').slice(-2, -1)[0] || 'new'); + if (id === 'new') { + return; + } + const scenario = load(id); + if (scenario === null) { + return; + } + const fields = form.querySelectorAll('input[name], select[name]'); + fields.forEach((field) => { + const name = field.getAttribute('name'); + if (!name) { + return; + } + const value = resolveField(scenario, name); + if (value !== undefined) { + field.value = String(value); + } + }); +} + +function resolveField(scenario, path) { + const parts = parsePath(path); + if (parts === null) { + return undefined; + } + let cursor = scenario; + for (const part of parts) { + if (cursor === null || cursor === undefined) { + return undefined; + } + cursor = cursor[part]; + } + return cursor; +} + +function parsePath(name) { + const match = name.match(/^(teamA|teamB)\[units\]\[(\d+)\]\[(\w+)\]$/); + if (match) { + const [, team, indexStr, key] = match; + const teamKey = team === 'teamA' ? 'teamA' : 'teamB'; + return [teamKey, 'units', Number(indexStr), key]; + } + const simple = [ + 'id', + 'name', + 'battlefieldWidth', + 'battlefieldHeight', + 'victoryCondition', + 'holdRoundsRequired', + ]; + if (simple.includes(name)) { + return [name]; + } + return null; +} + +async function startMatch() { + const id = decodeURIComponent(window.location.pathname.split('/').slice(-2, -1)[0] || 'new'); + if (id === 'new') { + return; + } + const scenario = load(id); + if (scenario === null) { + return; + } + const csrf = getCsrfToken(); + const res = await fetch('/scenarios/' + encodeURIComponent(id) + '/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + }, + body: JSON.stringify(scenario), + }); + if (!res.ok) { + alert('Start match failed: HTTP ' + res.status); + return; + } + const data = await res.json(); + if (data.match) { + setCurrentMatch(data.match); + window.location.href = '/'; + } else { + alert('Start match failed: ' + (data.errors ? data.errors.join(', ') : 'unknown')); + } +} + +window.bfStorage = { + load, + save, + delete: remove, + listAll, + currentMatch, + setCurrentMatch, + removeCurrentMatch, +}; + +document.addEventListener('DOMContentLoaded', () => { + populateRecentScenarios(); + prefillTeamEditor(); + const startBtn = document.getElementById('bf-start-match'); + if (startBtn) { + startBtn.addEventListener('click', (event) => { + event.preventDefault(); + startMatch(); + }); + } +}); +``` + +The module: +- Attaches `window.bfStorage` with the 8 methods. +- On `DOMContentLoaded`, populates the home page's `
`, pre-fills the team editor's form fields, and wires the "Start match" button. +- The "Start match" button calls `POST /scenarios/{id}/start` with the loaded scenario as JSON and `X-CSRF-Token` from the layout's ``. On success, stores the match in `localStorage` and navigates to `/`. +- Uses `textContent` and `value` setters throughout. Never `innerHTML` (the recent-scenarios list uses `appendChild` on `` elements with `textContent`). +- Handles corrupt JSON gracefully: `load()` returns `null`, `listAll()` skips keys that fail to parse. + +- [ ] **Step 2: Update `src/Views/home.php` to import the JS** + +Edit `src/Views/home.php`. After the existing `
` block, add the script import. The exact location depends on the existing template; locate the closing `` tag and add the script before it (or add it after `
` if the template is structured that way). + +Add this line at the end of the template body, before the closure that calls `render_layout`: + +```php + +``` + +The exact context-dependent insertion point will be clear from the file. Use `Escape::attr` to escape the URL attribute. + +- [ ] **Step 3: Update `src/Views/team-editor.php` to import the JS and add the Start match button** + +Two changes to `src/Views/team-editor.php`: + +1. Add the script import at the end of the template body (before the `render_layout` closure): + +```php + +``` + +2. Add a "Start match" button at the bottom of the form, just after the existing "Save" submit button: + +```php + + + +``` + +The "Start match" button is a `