Files
BattleForge/docs/superpowers/specs/2026-07-07-frontend-js-design.md
T

185 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan 3c: JavaScript Frontend Design
## Purpose
Plan 3c of the four-plan BattleForge build. Closes the gap between the Plan 3a+3b PHP backend and the in-browser experience by adding the small JavaScript surface that Plan 3a's web layer was waiting for. Plan 3c is the third of four plans and the last one in the original "Plan 3" deliverable.
Plan 3a's web layer (the front controller, the editor handlers, the views) shipped without any JavaScript beyond a `<script type="module" src="...">` placeholder. Plan 3c delivers the JS that pre-fills the team editor from `localStorage`, runs the battlefield grid interactions, and wires the home page's "Recent scenarios" list. The web app is currently functional from the browser with HTML-only form submits; Plan 3c adds the JS-driven UX that the spec calls for.
## Success Criteria
Plan 3c succeeds when a new user, without developer assistance, can:
1. Visit `http://localhost:8000/` and see the home page with a "Recent scenarios" list populated from `localStorage`.
2. Click "New scenario", fill the team editor, save — the page reloads, the recent list shows the new scenario, and clicking the new scenario in the recent list opens the team editor pre-filled with the saved data.
3. Navigate to the battlefield editor, paint a small grid, place deployment zones, place an objective, save — the saved state is in `localStorage` under the same `scenario:{id}` key, and the home page's recent list shows the updated scenario.
4. Refresh the page or come back tomorrow and find their scenarios still in their browser.
5. Open the browser dev tools and see no console errors, no `eval`, no unhandled promise rejections, no mixed-content warnings.
## Out of Scope (deferred to Plan 4)
- The hot-seat battle interface (Plan 4 ships a real battle page that reads `match:current` from `localStorage` and renders the turn-based UI).
- Three bundled scenarios (Plan 4 ships them as JSON fixtures in `public/scenarios/`).
- An end-to-end smoke test against a real browser. The spec calls for it under Plan 4, not Plan 3c.
- Drag-and-drop on the grid (the spec says it's optional; the click-paint approach in Plan 3c is sufficient for the MVP).
- Service worker / offline support (the spec explicitly excludes this).
- Animation of combat, terrain, or transitions (Plan 4).
- The "Start match" button on the team editor navigates to the home page after a successful match start; Plan 4 adds the route that shows the match in progress.
## In Scope
### Two JavaScript files
**`public/js/storage.js`** — the localStorage helper. Exposes `window.bfStorage` with eight methods:
```js
window.bfStorage = {
// Read a scenario by id. Returns the parsed object or null if missing/corrupt.
load(id) -> object | null,
// Write a scenario to localStorage under `scenario:{id}`.
// The argument is the canonical Plan-3a scenario shape (the same shape
// ScenarioSerializer::scenarioToArray produces).
save(id, scenario) -> void,
// Remove a scenario from localStorage.
delete(id) -> void,
// List all stored scenarios, sorted by last-modified descending.
// Returns [{id, name, lastModified}].
listAll() -> [{id, name, lastModified}],
// Read/write the in-progress match.
currentMatch() -> object | null,
setCurrentMatch(match) -> void,
removeCurrentMatch() -> void,
};
```
The module handles corrupt JSON gracefully: `load(id)` returns `null` if `JSON.parse` throws, and `listAll()` skips keys that fail to parse (showing a "corrupt scenario" placeholder in the home page).
**`public/js/grid-editor.js`** — the battlefield editor grid helper. Runs on `DOMContentLoaded`. Reads `data-scenario-id` from the form element, calls `bfStorage.load(id)`, and populates the grid. Manages three modes selected by the existing `name="zoneMode"` radio buttons (alpha, bravo, objective, plus the default terrain-paint mode which is implicit when none of the zone radios are selected). On tile click, applies the current mode to that tile. Shift-click removes the paint or zone membership. The form's submit is intercepted; the JS builds the canonical scenario JSON, fetches `POST /scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token: <token>` (read from the layout's `<meta name="csrf-token">`), parses the response, and writes the returned `scenario` to `localStorage`. On 400 with `{ok: false, errors: [...]}`, displays the errors inline in the form.
### One JSON asset
**`public/assets/archetypes.json`** — the four `ArchetypeTemplate` definitions, served at `/assets/archetypes.json`. Format:
```json
{
"defender": { "minHealth": 10, "maxHealth": 14, "minAttack": 2, "maxAttack": 4, "minDefense": 3, "maxDefense": 5, "minSpeed": 1, "maxSpeed": 3, "allowedAbilities": ["buff"] },
"striker": { "minHealth": 6, "maxHealth": 10, "minAttack": 4, "maxAttack": 6, "minDefense": 1, "maxDefense": 2, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["area_damage"] },
"support": { "minHealth": 5, "maxHealth": 9, "minAttack": 1, "maxAttack": 3, "minDefense": 1, "maxDefense": 3, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["heal", "buff"] },
"scout": { "minHealth": 4, "maxHealth": 7, "minAttack": 2, "maxAttack": 4, "minDefense": 0, "maxDefense": 2, "minSpeed": 4, "maxSpeed": 6, "allowedAbilities": [] }
}
```
The `ArchetypeCatalog::templates()` from Plan 3a is the canonical source for these values. Plan 3c's JSON must stay in sync with Plan 3a's PHP enum values. A future task could generate this JSON from the PHP source, but Plan 3c ships a hand-maintained copy.
### Four placeholder images
**`public/assets/placeholders/{defender,striker,support,scout}.png`** — 64×64 (or 32×32) transparent PNGs with a simple silhouette representing each archetype. Served at `/assets/placeholders/{archetype}.png` (no `userToken` prefix). The team editor's `<img>` tags for unit art default to these URLs when no uploaded image is set. The exact pixel art is up to the implementer; the test just checks the file exists and is a valid PNG.
### Updated view templates
Three templates get new `<script type="module">` tags:
- **`src/Views/home.php`** — adds `<script type="module" src="/js/storage.js"></script>`. The `<div id="recent">` is populated by `storage.js` on `DOMContentLoaded`.
- **`src/Views/team-editor.php`** — adds `<script type="module" src="/js/storage.js"></script>`. The form fields are pre-filled by `storage.js` on `DOMContentLoaded`. Adds a "Start match" button at the bottom of the form that calls `bfStorage.load(id)`, fetches `POST /scenarios/{id}/start`, calls `bfStorage.setCurrentMatch(match)`, and navigates to `GET /`.
- **`src/Views/battlefield-editor.php`** — adds `<script type="module" src="/js/grid-editor.js"></script>`. The grid is populated by `grid-editor.js` on `DOMContentLoaded`.
### Node toolchain
- **`package.json`** — declares `eslint ^8.57.0`, `eslint-config-airbnb-base ^15.0.0`, `eslint-plugin-import ^2.29.0`, `prettier ^3.3.0`. Scripts: `lint` (`eslint public/js`), `format` (`prettier --check public/js`).
- **`.eslintrc.json`** — extends `airbnb-base`, env `browser + es2022`, sourceType `module`.
- **`.prettierrc`** — `{ "singleQuote": true, "trailingComma": "all", "printWidth": 100 }` (matches the existing PHPCS PSR-12 style).
### CI update
- **`.github/workflows/ci.yml`** — adds `npm ci`, `npm run lint`, `npm run format` steps after the existing `composer check` step.
- **`.gitignore`** — adds `var/secret.key` (Plan 3b's `FullFlowTest` creates it on first run; it shouldn't be committed) and `node_modules/`.
## System Boundaries
Plan 3c introduces no new boundaries. The Plan 3a+3b web layer's four boundaries still hold:
1. **Content library** (Domain, unchanged): `ArchetypeCatalog` is the canonical source; `archetypes.json` is a hand-maintained mirror used by the JS.
2. **Scenario editor** (web, now JS-driven): Plan 3c adds the JS layer that pre-fills forms and runs grid interactions. The form submit still goes to the same PHP handlers.
3. **Rules engine** (Domain, unchanged).
4. **Battle interface** (Plan 4).
The JS layer reads and writes the canonical scenario shape (Plan 3a's `ScenarioSerializer::scenarioToArray`). It does not introduce a separate "draft" shape or a UI-only data model. The home page's "Recent scenarios" list parses the same shape for display.
## Data and Action Flow
### Cold-start flow
1. Browser hits `GET /`. `GetHomePage` returns 200 with the home page (which now includes `<script type="module" src="/js/storage.js"></script>`).
2. `storage.js` runs. `bfStorage.listAll()` enumerates `localStorage` keys matching `scenario:*`, parses each value, returns an array sorted by `lastModified` descending. The home page's `<div id="recent">` is populated with one `<a>` per item, pointing to `/scenarios/{id}/edit/team`.
3. User clicks "New scenario" → `GET /scenarios/new/edit/team`.
### Team editor flow
1. Browser hits `GET /scenarios/{id}/edit/team`. `GetTeamEditor` returns 200 with the team editor template (which now includes `<script type="module" src="/js/storage.js"></script>`).
2. `storage.js` reads `window.location.pathname`, extracts `{id}` (or `'new'` for the new-scenario case), calls `bfStorage.load(id)`. If a scenario exists, the JS pre-fills each form field by name (e.g. `teamA[units][0][archetype]``document.querySelector('[name="teamA[units][0][archetype]"]').value = scenario.units[0].archetype`). If the id is `'new'`, the form stays empty.
3. The archetype dropdowns are populated from `/assets/archetypes.json` (fetched once on script load).
4. User edits, then presses Save. The form's submit triggers a normal `POST /scenarios/{id}/edit/team` (no JS intercept).
5. The server returns 200 with a `<script type="module">` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))`. The browser runs the script and writes.
6. On a validation error, the server returns 200 with the form re-rendered and the validator's error in `<div class="bf-errors">`. No JS write happens; the form keeps the user's in-progress values.
### Battlefield editor flow
1. Browser hits `GET /scenarios/{id}/edit/battlefield`. `GetBattlefieldEditor` returns 200 with the battlefield editor template (which now includes `<script type="module" src="/js/grid-editor.js"></script>`).
2. `grid-editor.js` reads `bfStorage.load(id)`, populates the `tileMap` from `scenario.battlefieldTerrain`, populates the deployment zones (extracted from `scenario.deploymentZones`), and populates the objective position (if `HoldObjective`).
3. The grid is repainted: each tile button's `data-paint` attribute is updated, the objective tile gets `data-objective="1"`, and the zone tiles get `data-zone="alpha"` or `data-zone="bravo"`.
4. User clicks a palette button to select terrain paint mode, then clicks tiles. Each click updates the in-memory `tileMap` and updates the button's `data-paint`. User clicks one of the `name="zoneMode"` radios (alpha / bravo / objective) and clicks tiles to add to that zone or place the objective. Shift-click in any mode removes the paint or zone membership.
5. User presses Save. The JS intercepts the form submit (`event.preventDefault()`), builds the canonical scenario JSON, POSTs to `/scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token: <token>` (read from the layout's `<meta name="csrf-token">`), parses the response, and writes the returned `scenario` to `localStorage`.
6. On 400 with `{ok: false, errors: [...]}`, the JS shows the errors inline in the form (e.g. "Deployment zone alpha includes impassable terrain at (3,4)").
### Start-match flow
1. From the team editor, user clicks "Start match" (a new button at the bottom of the form). The JS reads `bfStorage.load(id)`, fetches `POST /scenarios/{id}/start` with `X-CSRF-Token`, parses the response, calls `bfStorage.setCurrentMatch(match)`, and navigates to `GET /`.
2. The home page (when reloaded) shows a "Match in progress" banner at the top, driven by `bfStorage.currentMatch()`. Plan 4 adds a real battle page that this banner links to.
## Validation and Failure Handling
- `bfStorage.load(id)` returns `null` if the value is missing or fails to JSON-parse. The home page's `listAll()` skips keys that fail to parse, and the team editor's pre-fill gracefully no-ops on `null`.
- The team editor's form submit falls back to the server's re-render path (Plan 3a's `PostTeamEditor` re-renders the form on validation failure with the user's in-progress values and the validator's error message). The JS does not intercept the submit.
- The battlefield editor's fetch fallback path: on 400, the JS shows the validator's errors inline and keeps the user's in-progress state in the `tileMap` and zone maps. On network failure (e.g. fetch throws), the JS shows a "Network error" message and the form stays editable.
- The "Start match" button's failure path: on 400 (invalid scenario), the JS shows the validator's errors inline. On network failure, shows a "Network error" message and keeps the form editable.
## Security and Quality Requirements
Inherited from the Plan 3 spec:
- All rendered output is escaped for its output context. The PHP templates use `Escape::html` / `Escape::attr` / `Escape::url`. The JS uses `textContent` and `value` setters, never `innerHTML` (with one exception: building the home page's recent-scenarios list uses `appendChild` on `<a>` elements with `textContent`, never `innerHTML`).
- The single small JS file passes ESLint `airbnb/base` and Prettier checks. CI fails if either fails.
- `composer validate --strict` and the existing `composer check` continue to pass.
Plan 3c-specific:
- No third-party JS libraries. The two files are pure ESM, no dependencies, no `eval`, no `Function` constructor.
- The `archetypes.json` is served from the same origin. The home page's "Recent scenarios" list is read from `localStorage`, never from the network.
- `var/secret.key` is added to `.gitignore` so the Plan 3b full-flow test's generated secret file is not committed.
## Verification Strategy
### Lint and format (CI gate)
- `npm run lint` — ESLint `airbnb-base` over `public/js/*.js`. Fails on any rule violation, including unused variables, missing semicolons, and unhandled promise rejections.
- `npm run format` — Prettier `--check public/js`. Fails on any formatting drift.
### Smoke test (optional, recommended)
- One Playwright test that boots the dev server with `php -S`, loads the home page, asserts the empty "Recent scenarios" list, calls `bfStorage.save('demo', {id: 'demo', name: 'Demo'})` via `page.evaluate`, reloads, and asserts the list shows "Demo". This is the Plan 3c analog of `FullFlowTest` for Plan 3b.
- If Playwright is too heavy for Plan 3c, the spec's CI requirement (ESLint + Prettier) is sufficient, and the Playwright test lands in Plan 4 with the battle interface.
### Manual usability check
A new tester with no BattleForge context can: open the dev URL, see the home page, click "New scenario", fill the team editor, save, see the recent list show the new scenario, click it, see the team editor pre-filled, paint a small grid, save, see the battlefield state persist, and refresh to find their work still there — without leaving the browser or seeing any error from the validator that wasn't explained inline.
## Release Boundary
Plan 3c is releasable when every in-scope capability and success criterion is met, the verification suite is green (`composer check`, `npm run lint`, `npm run format`), and no out-of-scope capability (the battle interface, bundled scenarios, the end-to-end Playwright smoke test) is required to exercise the local creation flow. The local dev server runs the full app on a single port with `php -S`, and the JS layer loads from `/js/`.
The next plan (Plan 4) will replace the home page's "Match in progress" banner with the real battle interface, add the three bundled scenarios, and ship the end-to-end smoke test that this plan's spec leaves as a follow-up.