diff --git a/docs/superpowers/specs/2026-07-07-frontend-js-design.md b/docs/superpowers/specs/2026-07-07-frontend-js-design.md new file mode 100644 index 0000000..cca7cdf --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-frontend-js-design.md @@ -0,0 +1,184 @@ +# 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 ``. The `
` is populated by `storage.js` on `DOMContentLoaded`. +- **`src/Views/team-editor.php`** — adds ``. 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 ``. 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 ``). +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 `
` is populated with one `` 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 ``). +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 ``). +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: ` (read from the layout's ``), 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 `` 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.