From 58b3bc8ece4be25b1189123e89ccd731810841cb Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 21:10:59 -0500 Subject: [PATCH] docs: add Plan 3b implementation plan (handlers, views, front controller) --- .../2026-07-06-persistence-editors-ui.md | 1818 +++++++++++++++++ 1 file changed, 1818 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-persistence-editors-ui.md diff --git a/docs/superpowers/plans/2026-07-06-persistence-editors-ui.md b/docs/superpowers/plans/2026-07-06-persistence-editors-ui.md new file mode 100644 index 0000000..a50eb63 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-persistence-editors-ui.md @@ -0,0 +1,1818 @@ +# Plan 3b: Editor Handlers, Views, and Front Controller 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:** Build the six editor HTTP handlers, six view templates, the real front controller, and the full-flow integration test that complete the Plan 3 web layer. + +**Architecture:** A front controller (`public/index.php`) dispatches every request to a `Http\Router` that routes by HTTP method + path to one of eight `Http\Handlers\*` classes. Handlers parse the request through a `Http\Request` value object, do their work through `Application\*` services (which know the `Domain`), and return a `Http\Response` rendered by `Views\*` PHP templates. CSRF and per-session upload tokens are issued by the front controller and validated by the handlers. + +**Tech Stack:** PHP 8.3, PHPUnit 11, PHPStan level 6, PHP_CodeSniffer PSR-12, plain `php -S` for local dev. + +## Global Constraints + +These apply to every task and are copied verbatim from the design spec (which is a delta on the Plan 3 spec). + +- Every state-changing form or request uses and verifies a CSRF token before mutation. 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. +- All uploaded images are content-validated by reading the first 12 bytes and checking the magic number against an allowlist (PNG, JPEG, WebP, GIF), then `getimagesize()` confirms dimensions are ≤ 512×512, then size is checked at ≤ 2 MB. The original filename and declared MIME are discarded after validation. +- `var/uploads/` is denied by `.htaccess` (Apache) and `index.php` (built-in server). +- The dev server is the built-in `php -S 0.0.0.0:8000 -t public` (documented in README). +- 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. +- The 4 `@phpstan-ignore function.alreadyNarrowedType` annotations from Plan 1+2+3a reference "Plan 4" in their comments; the actual JSON sources land in Plan 3. The Plan 3b front controller does not introduce a new annotation; the front controller sits below the threshold and the handlers and templates are pure routing and rendering. +- Plan 3b-specific: the front controller's `userToken` validation in `GetAssets` uses a strict format regex (`/^[a-f0-9]{32,}$/`) to harden the path-traversal surface. The `BATTLEFORGE_SECRET` env var takes precedence over the per-install file `var/secret.key` (a 32-byte binary file created on first run with `random_bytes(32)` and `chmod 0600`; git-ignored). +- Domain code has no dependency on HTTP, sessions, storage, or browser code (unchanged from Plans 1, 2, 3a). + +## File Structure + +```text +src/Http/Handlers/ +├── GetTeamEditor.php (NEW) — renders the team editor form +├── PostTeamEditor.php (NEW) — processes the team editor form POST +├── GetBattlefieldEditor.php (NEW) — renders the battlefield editor page +├── PostBattlefieldEditor.php (NEW) — processes the JSON body +├── PostImageUpload.php (NEW) — multipart upload, returns {url} JSON +└── PostStartMatch.php (NEW) — JSON body, returns initial MatchState + +src/Views/ +├── layout.php (NEW) — shared chrome +├── home.php (NEW) — the home page (currently inline in GetHomePage) +├── team-editor.php (NEW) — the team editor form +├── battlefield-editor.php (NEW) — the battlefield editor with the empty grid +├── match-stub.php (NEW) — the Plan 4 placeholder +└── upload-result.php (NEW) — iframe-style response for the upload form + +public/ +├── index.php (MODIFY) — replace the Task 1 stub with the real front controller +└── assets/ + └── (placeholders land in Plan 3c) + +tests/Integration/ +├── GetTeamEditorTest.php +├── PostTeamEditorTest.php +├── GetBattlefieldEditorTest.php +├── PostBattlefieldEditorTest.php +├── PostImageUploadTest.php +├── PostStartMatchTest.php +└── FullFlowTest.php +``` + +## Delivery Sequence + +Plan 3b is the fourth of four plans, picking up where Plan 3a left off. It is the last plan in the original "Plan 3" deliverable. + +1. View templates — establish the rendering foundation (Tasks 1-3). +2. Editor handlers — wire the templates to the domain (Tasks 4-6). +3. Front controller — wire the handlers to the URL space (Task 7). +4. Full-flow integration test — exercise the whole stack (Task 8). +5. CI verification and final review (Tasks 9-10). + +## Execution Preflight + +Execute this plan in an isolated worktree on `feature/editor-handlers-and-views`, branched from `develop`. The implementation branch will be submitted as a pull request into `develop` only after every completion check is green. + +### Task 1: Shared `layout.php` chrome + +**Files:** +- Create: `src/Views/layout.php` + +**Interfaces:** +- Produces the shared template that all other templates `require` to set up the document chrome. The layout is invoked by a template that has already started buffering its body content; the layout's signature is `render_layout(callable $body, string $csrf, string $title, string $extraHead = ''): void`. + +The layout prints: +- The HTML5 doctype. +- ``. +- `` with charset, viewport, ``, the stylesheet link, the CSRF meta tag, and any extra head content from the template. +- `<body>` followed by a call to `$body()` to inject the template's body, then `</body></html>`. +- All output escaped via `Escape::html`, `Escape::attr`. + +- [ ] **Step 1: Implement `layout.php`** + +Create `src/Views/layout.php`: + +```php +<?php + +declare(strict_types=1); + +use BattleForge\Http\Escape; + +/** + * Render the shared document chrome around a body closure. + * + * @param callable(): void $body Emits the template's body content. + * @param string $csrf The pre-issued CSRF token (HMAC-verified by the front controller). + * @param string $title Page title; HTML-escaped by the layout. + * @param string $extraHead Optional extra `<head>` content (e.g. a per-template script tag). + */ +function render_layout(callable $body, string $csrf, string $title, string $extraHead = ''): void +{ + $e = static fn (string $v): string => Escape::html($v); + $a = static fn (string $v): string => Escape::attr($v); + ?><!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title><?= $e($title) ?> + + + + + + + + + Escape::html($v)`) for the escape closures. The function is in the global namespace (not a class) so templates can call it without imports. + +- [ ] **Step 2: Verify the file is syntactically valid** + +Run: `php -l src/Views/layout.php` +Expected: `No syntax errors detected in src/Views/layout.php`. + +- [ ] **Step 3: Run the full quality suite** + +Run: `vendor/bin/phpunit` +Expected: 177/177 tests pass (Plan 1+2+3a baseline unchanged). PHPCS may emit a warning on the long line in the ` Escape::html($v); + $a = static fn (string $v): string => Escape::attr($v); + ?> +

BattleForge

+

New scenario

+

Recent scenarios

+

No saved scenarios yet.

+ + ` blocks to print HTML. The layout prints the document chrome; the closure prints the body content. The `$csrf` variable is captured by the closure via `use ($csrf)`. + +The home page's `

Recent scenarios

` and `
` are placeholders that Plan 3c's `storage.js` populates from `localStorage`. + +- [ ] **Step 2: Implement `team-editor.php`** + +Create `src/Views/team-editor.php`: + +```php + $old Optional old form values, keyed by field name; HTML-escaped on output. + * @var array $post Raw form data (for repopulating per-unit fields). + */ +$e = static fn (string $v): string => Escape::html($v); +$a = static fn (string $v): string => Escape::attr($v); +$archetypes = ArchetypeCatalog::templates(); +?> +

Team editor

+ +

+ +
+ +
+ Scenario + + + + +
+
+ Team A + +
+ + + + + + + + + + +
+ +
+
+ Team B + +
+ + + + + + + + + + +
+ +
+
+ Victory + + + +
+ +
+ Escape::html($v); +$a = static fn (string $v): string => Escape::attr($v); +$terrain = ['open', 'forest', 'rough', 'water', 'blocking']; +?> +

Battlefield editor

+

Scenario:

+
+ +
+ Battlefield + + +
+
+ Terrain palette + + + +
+
+ Grid ( × ) + + + + + + + + +
+
+
+ Deployment zones + + + +
+ +
+ + Escape::html($v); +$matchInfo = $matchJson !== null ? "Match loaded." : "No match in progress."; +?> +

Match

+

+ +
+ Match state (debug) +
+
+ +

Battle interface coming in Plan 4.

+

Back to home

+ Escape::html($v); +$json = json_encode(['url' => $url], JSON_THROW_ON_ERROR); +?> +

Uploaded:

+ + post`, calls `toScenario()`, runs `ScenarioValidator::validate()`. On success, renders a "Saved" template that includes a ` + + +HTML; + + return Response::html(200, $body); + } + + /** @param array $post */ + private function renderForm(Request $request, string $scenarioId, string $csrf, string $error, array $post): Response + { + $error = Escape::html($error); + $csrf = Escape::html($csrf); + $scenarioId = Escape::html($scenarioId); + + $teamA = $post['teamA']['units'] ?? []; + $teamB = $post['teamB']['units'] ?? []; + + ob_start(); + require __DIR__ . '/../../Views/team-editor.php'; + $body = (string) ob_get_clean(); + + return Response::html(200, $body); + } + + /** @return array */ + private function scenarioToArray(\BattleForge\Domain\Scenario $scenario): array + { + return \BattleForge\Application\ScenarioSerializer::scenarioToArray($scenario); + } +} +``` + +The `scenarioToArray` private method is a thin wrapper that calls the Plan 3a `ScenarioSerializer::scenarioToArray`. Both methods exist in the same `BattleForge\Application` namespace; the wrapper is just to keep the handler self-contained. + +- [ ] **Step 8: Fix the test typo in `validPost`** + +The Step 5 `validPost` has a duplicate `'speed' => '2'` row for team B unit 2. Remove one. (Plan 3a's Task 9 had a similar typo in test data; the implementer should fix it on first run, mark `DONE_WITH_CONCERNS`, and document.) + +- [ ] **Step 9: Run the test to verify it passes** + +Run: `vendor/bin/phpunit tests/Integration/PostTeamEditorTest.php` +Expected: 4/4 tests pass. + +- [ ] **Step 10: Run the full quality suite and commit** + +Run: `composer check` +Expected: all checks green. (PHPCS may emit line-length warnings on the team editor's long `