# 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 `