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

16 KiB
Raw Blame History

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:

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:

{
  "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.
  • 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.