Files
josh 7f39cf6d31 docs(02-06): complete uat-gap-closure plan
5 tasks executed sequentially; all 4 first-impression UX gaps from
2026-05-09 live UAT structurally closed (G1 BLOCKING white halo, G2
BLOCKING no first-run prompt, G3 HIGH dim tile grid, G4 MEDIUM floating
gate). 21 new Vitest cases (312 → 333 green); 3 new Playwright assertions
(16 → 19); npm run ci + npm run test:e2e both exit 0. Phase 3 watercolor
deferral preserved (no painted assets, no new dependencies); V1Payload
unchanged (firstRunHintDismissed is session-state only, no migrations[2]).

Hint copy chosen: "Begin where the soil is bare." (plan's #1 ranked
candidate; bible voice — warm, specific, contemplative). Externalized in
content/seasons/01-soil/ui-strings.yaml; UiStringsSchema extended with
first_run_hint: z.string().min(1) so Zod strip mode does not silently
drop the YAML key from parsed.data.

Verifier handoff unblocked: 02-VERIFICATION.md frontmatter `gaps:` block
ready to flip status from gaps_found → verified. The 6 HUMAN-UAT.md tone
items remain pending (out of scope; addressed by separate workflow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:21:42 -04:00

207 lines
21 KiB
Markdown
Raw Permalink 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.
---
phase: 02-season-1-vertical-slice-soil
plan: 06
subsystem: uat-gap-closure
tags: [gap-closure, uat, css, first-run-hint, tile-contrast, gate-context, mvp, wave-0]
# Dependency graph
requires:
- phase: 02-01
provides: Zustand store + V1Payload + session slice (extended in this plan with firstRunHintDismissed)
- phase: 02-02
provides: BeginScreen (analog component for FirstRunHint shape) + tile-renderer (G3 modifies its constants) + ui-strings.yaml shape
- phase: 02-03
provides: JournalIcon (analog corner-affordance pattern) + Journal modal
- phase: 02-04
provides: gate-renderer (G4 adds wall band primitive) + Lura gate location at canvas (880, 384)
- phase: 02-05
provides: tests/e2e/season1-loop.spec.ts (Playwright PIPE-07 full-loop smoke — Task 5 threads 3 new gap-closure assertions into it) + App.tsx render tree (FirstRunHint mounts alongside Letter / Settings / etc.)
provides:
- src/index.css — global page styles (body bg #1a1a1a, color #e8e0d0, zero margin, 100vh, serif, #game-container flex centering). Imported once from src/main.tsx so Vite bundles it into the entry chunk; body styles apply before React mounts.
- src/ui/first-run/FirstRunHint.tsx — single-line bible-voice hint surfaced after BeginScreen dismisses, auto-dismisses on first plant !== null transition. Reads externalized line from uiStrings[1]?.first_run_hint per STRY-09.
- src/store/session-slice.ts extended — firstRunHintDismissed: boolean + dismissFirstRunHint() action. Session state ONLY; NEVER added to V1Payload (no migrations[2]).
- src/content/schemas/ui-strings.ts extended — UiStringsSchema gains first_run_hint: z.string().min(1) so Zod's default strip mode does NOT silently drop the YAML key from parsed.data at runtime.
- content/seasons/01-soil/ui-strings.yaml — first_run_hint key added with bible-voice copy "Begin where the soil is bare." (the plan's #1 ranked candidate; rationale documented in §Decisions Made).
- src/render/garden/tile-renderer.ts — OUTLINE_COLOR brightened 0x4d4d52 → 0x5a5a60 + OUTLINE_HOVER 0x6e6e75 → 0x7a7a82 + HOVER_FILL_ALPHA=0.06 fill bump on the hit rectangle. Constants exported for testability.
- src/render/garden/gate-renderer.ts — adds 4th Phaser primitive (wall band) at GATE_X column spanning the full 768px canvas height with alpha=0.18 (mid of 0.15-0.20 fix_shape range). GateGameObjects interface gains a wall field — additive, Garden.ts unchanged.
- tests/e2e/season1-loop.spec.ts — extended with 3 new assertions covering G1 + G2 end-to-end (body bg = rgb(26, 26, 26), FirstRunHint visible after Begin dismiss, FirstRunHint gone after first plant).
- 21 new Vitest cases across 4 test files (G1: 6 file-read smoke, G2: 6 behavioral, G3: 5 phaser-mocked, G4: 4 phaser-mocked).
- 4 first-impression UX gaps from 2026-05-09 live UAT structurally closed (G1 BLOCKING, G2 BLOCKING, G3 HIGH, G4 MEDIUM).
affects: [/gsd-verify-work (re-verifier consumes this SUMMARY to flip 02-VERIFICATION.md status from gaps_found → verified), Phase 3 (Watercolor & Cello — paints over the structural primitives without changing the layout intent)]
# Tech tracking
tech-stack:
added: []
patterns:
- "Single CSS file imported from main.tsx as the global-style anchor: Vite bundles plain CSS imports natively, no build-config change needed. body bg + color + font-family all match the BeginScreen overlay so there is no tonal break at any frame."
- "Session-state-only first-run gate: firstRunHintDismissed lives in src/store/session-slice.ts (NOT V1Payload). The hint reappears on hard reload until the first plantSeed commits — that is the correct A-Dark-Room first-run UX (re-prompt on a fresh tab, dismiss on first action)."
- "Schema extension MANDATORY when adding YAML keys: Zod's default z.object() strip mode silently drops unknown keys from parsed.data. Without extending UiStringsSchema, the runtime would have rendered first_run_hint as undefined and FirstRunHint would have rendered null — production-only failure that unit tests mocking the store directly would not catch."
- "Phaser-mock pattern for renderer unit tests: vi.mock('phaser', ...) short-circuits the Phaser bundle import so the renderer module loads under happy-dom (Phaser 4's checkInverseAlpha boot probe crashes on canvas.getContext returning null). Combined with a mocked Phaser.Scene surface (add.graphics + add.rectangle returning vi.fn() spies), the test pins constants and call args without needing a real Chromium canvas. Reusable for plant-renderer and ready-pulse coverage in future phases."
- "Tile hover as steady-state outline + fill swap (NOT animation): pointerover swaps OUTLINE_COLOR → OUTLINE_HOVER and bumps the hit rectangle's fill alpha from 0 → 0.06; pointerout reverses. No tweens, no setInterval. Reduced-motion-safe by construction; Phase 8's global motion-preference owner has no work to do here."
- "Gate wall band as structural primitive (Phase 3 deferral preserved): a single Phaser Rectangle at GATE_X with alpha=0.18 spans the canvas height to give the gate visual context (the bible's 'walled garden' framing). Phase 3 paints the watercolor wall over this primitive without changing the geometry or interaction surface."
key-files:
created:
- src/index.css (G1 — global page styles, ~25 lines)
- src/index.css.test.ts (G1 — 6 file-read smoke cases pinning the load-bearing CSS rules)
- src/ui/first-run/FirstRunHint.tsx (G2 — single-line hint component, externalized copy, auto-dismiss subscription)
- src/ui/first-run/FirstRunHint.test.tsx (G2 — 6 behavioral cases: hidden when Begin still up, hidden when dismissed, renders externalized line, reads uiStrings, auto-dismisses on first plant, stays dismissed)
- src/ui/first-run/index.ts (G2 — barrel)
- src/render/garden/tile-renderer.test.ts (G3 — 5 cases via Phaser-Scene-mock pattern: constants pinned, 16 tile groups, initial draw uses OUTLINE_COLOR, pointerover swaps to OUTLINE_HOVER + fill bump)
- src/render/garden/gate-renderer.test.ts (G4 — 4 cases via Phaser-Scene-mock pattern: constants in fix_shape range, wall is first rectangle with full canvas height, 4 total rectangles, GateGameObjects exposes wall handle)
- .planning/phases/02-season-1-vertical-slice-soil/02-06-uat-gap-closure-SUMMARY.md (this file)
modified:
- src/main.tsx (G1 — single import './index.css'; line added)
- src/store/session-slice.ts (G2 — firstRunHintDismissed + dismissFirstRunHint added; session state only, NOT in V1Payload)
- src/content/schemas/ui-strings.ts (G2 — UiStringsSchema gains first_run_hint: z.string().min(1) so Zod strip mode does not drop the YAML key)
- content/seasons/01-soil/ui-strings.yaml (G2 — first_run_hint key with bible-voice copy)
- src/ui/index.ts (G2 — re-exports ./first-run)
- src/App.tsx (G2 — <FirstRunHint /> mounted between BeginScreen and SeedPicker)
- src/render/garden/tile-renderer.ts (G3 — OUTLINE_COLOR + OUTLINE_HOVER brightened, HOVER_FILL_ALPHA=0.06 added; constants exported)
- src/render/garden/gate-renderer.ts (G4 — wall band primitive added; WALL_BAND_X / WALL_BAND_WIDTH / WALL_BAND_HEIGHT / WALL_BAND_ALPHA / WALL_BAND_COLOR exported; GateGameObjects gains wall field)
- tests/e2e/season1-loop.spec.ts (Task 5 — 3 new assertions threaded into PIPE-07 happy path: body bg, FirstRunHint visible after Begin, FirstRunHint gone after first plant)
decisions:
- id: 02-06-D1
decision: "First-run hint copy: 'Begin where the soil is bare.' (plan's #1 ranked candidate)"
rationale: "CLAUDE.md tone constraint says player-visible copy must match the bible's voice — warm, specific, intermittent, sometimes funny, sometimes devastating. Of the three ranked candidates, #1 has all four bible markers — soil + bare are specific and contemplative; the imperative 'Begin' echoes the BeginScreen CTA without redundancy; the construction is intermittent (one beat, no follow-on). #2 ('The soil is waiting.') is quieter but more elliptical for a brand-new player on frame one. #3 ('Click a tile to plant.') is the functional fallback and would only be chosen if HUMAN-UAT review surfaced #1 as too elliptical. The plan's recommended choice was #1 and there was no reason to deviate."
- id: 02-06-D2
decision: "Session-state for firstRunHintDismissed (NOT V1Payload — no migrations[2])"
rationale: "Plan scope_constraint #3 (also CLAUDE.md hard constraint). The hint is a first-run-of-this-tab affordance, like A Dark Room's '...the room is empty' or '...the fire is dead' surfaces. The player should see it again if they hard-reload before planting; once they plant it stays down for the session. Persisting to save would (1) require migrations[2] which Phase 1 has shipped zero v1 saves to migrate forward and is structurally premature; (2) force a one-time 'permanent dismissal' UX that loses the cozy re-onboarding signal across reloads. Session state is the cleaner shape."
- id: 02-06-D3
decision: "UiStringsSchema extended with first_run_hint: z.string().min(1) — schema edit was MANDATORY, not optional"
rationale: "Zod's default object mode is 'strip' — unknown keys parse SUCCESSFULLY but are SILENTLY DROPPED from parsed.data. Without the schema edit, content/seasons/01-soil/ui-strings.yaml could carry the first_run_hint key but uiStrings[1].first_run_hint would be undefined at runtime, FirstRunHint would render null in production, and only the unit tests that mock the store directly would catch it. The plan's Step 2 calls this out explicitly. The edit is one line in src/content/schemas/ui-strings.ts; the cost of skipping it is a production-only failure mode that unit tests cannot detect. .min(1) defends against an accidental empty-string in YAML."
- id: 02-06-D4
decision: "Phaser-mock pattern via vi.mock('phaser') for tile-renderer + gate-renderer tests"
rationale: "First attempt at tile-renderer test imported the source file directly; Phaser 4's checkInverseAlpha boot probe (canvas.getContext('2d') returning null under happy-dom) crashed the test setup. The plan acknowledged this risk via the SeedPicker mock pattern reference. vi.mock('phaser', () => ({ default: {} })) at module top short-circuits the bundle load entirely; the test then mocks the Scene's add.graphics + add.rectangle surface to capture call args. For gate-renderer, BlendModes.ADD is mocked as the sentinel value 1 so setBlendMode receives a non-undefined argument. The pattern is reusable for plant-renderer + ready-pulse coverage in future phases."
- id: 02-06-D5
decision: "WALL_BAND_ALPHA = 0.18 (mid of the 0.15-0.20 fix_shape range)"
rationale: "The plan's fix_shape says alpha 0.15-0.20. 0.18 is the mid of that range — low enough that the gate body remains the visual focal point (the load-bearing element), high enough that the wall actually reads against the #1a1a1a canvas. Lower (0.15) would be invisible at the edge of the gate; higher (0.20) would compete with the body. Phase 3 paints over without changing this geometry."
metrics:
duration: ~30 min (5 tasks: ~6 min/task average; G1 fastest at ~3 min; G2 longest at ~10 min due to 7-step shape)
completed: 2026-05-09
tests-added: 21 (was 312 → 333)
tests-green: 333/333
e2e-assertions-added: 3 (was 16 → 19)
e2e-runtime: 1.7s (was 1.6s — 0.1s growth from 3 cheap evaluations + 1 visibility + 1 negation)
ci-runtime: ~30s (lint + compile:ink + 333 vitest + validate:assets + build + check:bundle-split)
bundle-size: 1.9MB (unchanged — no new dependencies, no new image assets)
commits: 5 (one per task; conventional-commit format with `fix(02-06,GN):` / `test(02-06):` scopes)
requirements-completed: [GARD-01, AEST-07, UX-01]
---
# Phase 2 Plan 06: UAT Gap Closure (G1G4) Summary
Closed the 4 first-impression UX gaps that the 2026-05-09 live UAT walkthrough surfaced — the dark canvas no longer floats in a white viewport (G1), a first-time player sees a single bible-voice instructional line after Begin dismisses (G2), the 4×4 tile grid reads as legible interactive surfaces (G3), and the gate has structural wall context instead of floating as a stray gray rectangle (G4). All fixes use Phaser primitives or one CSS file; Phase 3 watercolor deferral preserved.
## Tasks Executed
| # | Gap | Severity | Files | Commit | Tests |
|---|-----|----------|-------|--------|-------|
| 1 | G1 — white halo | BLOCKING | src/index.css, src/main.tsx, src/index.css.test.ts | f52de0b | 6 file-read smoke |
| 2 | G2 — no first-run prompt | BLOCKING | content/seasons/01-soil/ui-strings.yaml, src/content/schemas/ui-strings.ts, src/store/session-slice.ts, src/ui/first-run/{FirstRunHint.tsx, FirstRunHint.test.tsx, index.ts}, src/ui/index.ts, src/App.tsx | c46fc75 | 6 behavioral |
| 3 | G3 — dim tile grid | HIGH | src/render/garden/tile-renderer.ts, src/render/garden/tile-renderer.test.ts | ab48c7e | 5 phaser-mocked |
| 4 | G4 — floating gate | MEDIUM | src/render/garden/gate-renderer.ts, src/render/garden/gate-renderer.test.ts | 88adc4f | 4 phaser-mocked |
| 5 | Integration | — | tests/e2e/season1-loop.spec.ts | 47b5b8d | 3 e2e assertions |
## Hint Copy Chosen
**`Begin where the soil is bare.`**
This is the plan's #1 ranked candidate (recommended). Rationale documented in decision 02-06-D1: bible voice (warm + specific + contemplative), echoes the BeginScreen CTA without redundancy, intermittent construction (one beat, no follow-on). The candidate was committed unchanged; no deviation from the plan's recommendation.
## Test & Gate Results
- **Vitest:** 312 → 333 (+21 new cases) — 333/333 green.
- **Playwright e2e:** 16 → 19 assertions (+3 gap-closure) — 1.6s → 1.7s runtime; 1 passed in 4.7s end-to-end.
- **`npm run ci`:** Exit 0 (lint + compile:ink + 333 vitest + validate:assets + build + check:bundle-split).
- **`npm run test:e2e`:** Exit 0 (Playwright PIPE-07 with all 3 new assertions green).
- **Bundle size:** 1.9MB unchanged — no new dependencies, no new image assets.
- **V1Payload:** Unchanged — `firstRunHintDismissed` is session-state only; `migrations[2]` does NOT exist; no `migrations.ts` edits.
## Constraint Compliance Confirmation
| Constraint | Verification | Status |
|------------|--------------|--------|
| No painted assets (Phase 3 watercolor deferral) | `git diff main~5 HEAD -- '*.png' '*.jpg' '*.webp'` is empty | ✓ |
| No new npm dependencies | `git diff main~5 HEAD -- package.json package-lock.json` is empty | ✓ |
| firstRunHintDismissed is session-state, not save-state | `grep -c firstRunHintDismissed src/save/migrations.ts` = 0 | ✓ |
| No migrations[2] entry | `grep -E 'migrations\[2\]\s*=' src/save/migrations.ts` returns no match | ✓ |
| Hint copy externalized (not hardcoded) | `grep -L "Begin where the soil is bare\|The soil is waiting\|Click a tile to plant" src/ui/first-run/FirstRunHint.tsx` matches the file (i.e. the candidate strings do NOT appear in the component) | ✓ |
| UiStringsSchema extended for first_run_hint | `grep -E 'first_run_hint:\s*z\.string\(\)' src/content/schemas/ui-strings.ts` matches | ✓ |
| Tile outline brightened to 0x5a5a60 / 0x7a7a82 | tile-renderer.ts exports OUTLINE_COLOR=0x5a5a60 + OUTLINE_HOVER=0x7a7a82 (old hex literals appear ONLY in comment annotations documenting the change, not in active code paths) | ✓ |
| Wall band alpha in 0.15-0.20 range | gate-renderer.ts exports WALL_BAND_ALPHA=0.18 | ✓ |
| Wall band height = canvas height | gate-renderer.ts exports WALL_BAND_HEIGHT=768 | ✓ |
| Sim purity preserved (no edits in src/sim/**) | `git diff main~5 HEAD -- 'src/sim/**'` is empty | ✓ |
| No motion-only affordances | Tile hover is pointer-driven steady-state (color + alpha swap, no tweens); wall band is steady-state alpha, no pulse | ✓ |
## Gap Closure Evidence (vs 02-VERIFICATION.md frontmatter `gaps:` block)
| Gap | Fix verification |
|-----|------------------|
| **G1** white halo | `src/index.css` exists with the 6 required rules (body bg #1a1a1a, color #e8e0d0, margin 0, min-height 100vh, serif, #game-container flex). `src/main.tsx` imports it (line 4). Playwright Assertion A confirms `document.body.backgroundColor === 'rgb(26, 26, 26)'` from frame one in real Chromium. |
| **G2** no first-run prompt | `src/ui/first-run/FirstRunHint.tsx` exists; mounted in App.tsx between BeginScreen and SeedPicker. `content/seasons/01-soil/ui-strings.yaml` carries `first_run_hint: "Begin where the soil is bare."`. `src/store/session-slice.ts` carries `firstRunHintDismissed` + `dismissFirstRunHint`. `src/content/schemas/ui-strings.ts` extended with `first_run_hint: z.string().min(1)`. Playwright Assertion B confirms the hint is visible after Begin click; Assertion C confirms it auto-dismisses after the first plantSeed lands. 6 unit tests pin behavior. |
| **G3** dim tile grid | `src/render/garden/tile-renderer.ts` exports `OUTLINE_COLOR=0x5a5a60` (was 0x4d4d52) + `OUTLINE_HOVER=0x7a7a82` (was 0x6e6e75) + `HOVER_FILL_ALPHA=0.06` (new). 5 unit tests pin the constants and the pointerover behavior via Phaser-Scene-mock. |
| **G4** floating gate | `src/render/garden/gate-renderer.ts` exports `WALL_BAND_X=880` + `WALL_BAND_HEIGHT=768` + `WALL_BAND_ALPHA=0.18` + `WALL_BAND_COLOR=0x6e6e75` + `WALL_BAND_WIDTH=44`. `drawGate` adds the wall as the first rectangle (z-order: behind body / glow / hit). `GateGameObjects` exposes the new `wall` handle. 4 unit tests pin constants in fix_shape range + first-rectangle geometry + 4-rectangle count + wall handle exposure. |
## Deviations from Plan
**None — plan executed exactly as written.**
The plan's 5 tasks landed in order with no Rule 1-4 deviations triggered. The plan's anticipated risks were all addressed by the plan's own structure:
- Phaser 4 / happy-dom incompatibility (G3 + G4 tests) — plan called out via SeedPicker analog reference; Phaser-mock pattern (`vi.mock('phaser', () => ({ default: {} }))`) landed cleanly first try.
- Schema-strip mode silently dropping unknown keys (G2 plan Step 2) — plan called out as MANDATORY; schema edit landed first try, content/loader.test.ts continued green.
- Garden.ts integration breakage from additive GateGameObjects.wall field — plan called out as risk; verified by reading Garden.ts line 110 (`this.gate = drawGate(this)`) which stores the whole returned object so the additive field is structurally safe; npm run ci confirmed end-to-end.
## Auth Gates
None — the plan introduces no auth surfaces; all 5 tasks ran fully autonomously.
## Decisions Made
(Captured in frontmatter `decisions:` block above — 02-06-D1 through 02-06-D5.)
## Handoff to Verifier
The 4 gap entries in `.planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md` frontmatter `gaps:` block are structurally closed. The verifier (`gsd-verifier`) consumes this SUMMARY + re-runs verification to flip status from `gaps_found``verified`.
**Out of scope for this plan (carried forward):**
- 6 HUMAN-UAT.md tone items (Lura voice in the .ink files, letter cadence, Begin tonal feel, ≥5min absence flow, gate visual indicator + LuraDialogue overlay flow). These are inherently subjective and remain pending. They are addressed by the user's tone-review workflow at the next merge / playtest, not by code.
- 3 INEFFECTIVE_DYNAMIC_IMPORT build warnings (inherited from Plan 02-02's eager-corpus + lazy-glob co-existence). Phase 4+ resolves these when consumers move to lazy-only.
- gray-matter package.json cleanup (tracked in `.planning/phases/02-season-1-vertical-slice-soil/deferred-items.md`).
**REQ-IDs reinforced (not flipped — those were already structurally PASS in 02-VERIFICATION.md):**
- GARD-01 (supplemental — first-frame legibility of the planting affordance)
- AEST-07 (supplemental — tonal coherence between body and canvas; Begin dismissal lands on a guided rather than empty surface)
- UX-01 (supplemental — first-run prompt presence honors the A-Dark-Room rule the bible cites)
The Phase-2 vertical slice that "could plausibly ship as a free standalone Season-1 prologue" now actually feels like one to a brand-new player on frame one.
## Self-Check: PASSED
All claimed files exist:
- src/index.css ✓
- src/index.css.test.ts ✓
- src/ui/first-run/FirstRunHint.tsx ✓
- src/ui/first-run/FirstRunHint.test.tsx ✓
- src/ui/first-run/index.ts ✓
- src/render/garden/tile-renderer.test.ts ✓
- src/render/garden/gate-renderer.test.ts ✓
All claimed commits exist (verified via `git log --oneline -8`):
- f52de0b fix(02-06,G1): add src/index.css and import from main.tsx ✓
- c46fc75 fix(02-06,G2): first-run hint after Begin ✓
- ab48c7e fix(02-06,G3): brighten tile outline and hover state ✓
- 88adc4f fix(02-06,G4): add wall band primitive in gate-renderer ✓
- 47b5b8d test(02-06): playwright e2e assertions for G1+G2 ✓
Final gates:
- `npm run ci`: exit 0, 333/333 vitest green ✓
- `npm run test:e2e`: exit 0, 1 passed in 4.7s ✓
- No new npm deps: ✓
- V1Payload unchanged: ✓
- No painted assets: ✓