Files
TheLastGarden/.planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md
T
josh 8e4609ae20
ci / lint + test + validate-assets + build (push) Failing after 4m54s
docs(02): close phase 2 — gap closure verified, 24/24 + 4/4 PASS
Plan 02-06 (UAT gap closure) executed cleanly via /gsd-execute-phase 2:
6 atomic commits (5 task + 1 SUMMARY), 333/333 vitest green
(was 312, +21 cases), npm run ci exit 0, Playwright e2e exit 0.

Hint copy chosen: "Begin where the soil is bare." (plan's #1 ranked
candidate, bible voice).

gsd-verifier re-verification confirms:
- 24/24 Phase-2 REQ-IDs structurally PASS (no regressions)
- 4/4 UAT gaps closed (G1 white halo, G2 first-run prompt,
  G3 tile contrast, G4 gate wall context)
- All scope constraints honored: zero painted assets, zero new npm
  deps, V1Payload unchanged, sim purity preserved
- Banner concerns #5/#7/#6/#9/#10 still defended

VERIFICATION.md frontmatter status flipped gaps_found → verified.
ROADMAP Phase 2 marked complete (6/6 plans, completed 2026-05-09).
STATE.md updated with phase-2 completion narrative.

7 HUMAN-UAT.md tone items remain pending (Lura voice, letter cadence,
Begin tonal feel, ≥5min absence flow, gate visual indicator overlay,
plus the new chosen first_run_hint copy review).

Phase 2 vertical slice now plausibly ships as a free standalone
Season-1 prologue — banner concern #2 (7-Season scope risk) escape
hatch realized.

Next: /gsd-discuss-phase 3 (Watercolor & Cello Aesthetic — GARD-10,
AEST-01..06, UX-05).

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

458 lines
57 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.
---
phase: 02-season-1-vertical-slice-soil
verified: 2026-05-09T17:35:00Z
verifier_run_at: 2026-05-09T11:24:00Z
uat_run_at: 2026-05-09T15:50:00Z
re_verifier_run_at: 2026-05-09T17:35:00Z
status: verified
score: 24/24 REQ-IDs structurally PASS + 4/4 UX gaps closed (G1, G2, G3, G4); 6 HUMAN-UAT tone items remain pending
overrides_applied: 0
re_verification: true
re_verification_meta:
previous_status: gaps_found
previous_score: 24/24 REQ-IDs structurally PASS; 4 UX gaps open
gaps_closed:
- G1 — white halo around dark canvas (BLOCKING)
- G2 — no first-run prompt after Begin (BLOCKING)
- G3 — tile outlines too dim (HIGH)
- G4 — gate visual stands alone with no surrounding context (MEDIUM)
gaps_remaining: []
regressions: []
closing_plan: 02-06-uat-gap-closure
gaps_closed:
- id: G1
severity: blocking
title: "No global page CSS — white halo around dark canvas"
closed_by: 02-06-uat-gap-closure (commit f52de0b)
evidence: "src/index.css carries the 6 load-bearing rules (body bg #1a1a1a, color #e8e0d0, margin 0, min-height 100vh, font-family serif, #game-container flex centering); src/main.tsx:4 imports it; Playwright assertion at season1-loop.spec.ts:75-78 confirms `getComputedStyle(document.body).backgroundColor === 'rgb(26, 26, 26)'` from frame one in real Chromium; 6 file-read smoke tests in src/index.css.test.ts pin the rules."
- id: G2
severity: blocking
title: "No first-run prompt after Begin — player has no idea what to do"
closed_by: 02-06-uat-gap-closure (commit c46fc75)
evidence: "src/ui/first-run/FirstRunHint.tsx mounted in App.tsx:56 between BeginScreen and SeedPicker; copy externalized in content/seasons/01-soil/ui-strings.yaml:21 as `first_run_hint: \"Begin where the soil is bare.\"`; src/content/schemas/ui-strings.ts:38 extends UiStringsSchema with `first_run_hint: z.string().min(1)` (defeats Zod strip mode); src/store/session-slice.ts:44+51+68 carries firstRunHintDismissed flag + dismissFirstRunHint action; auto-dismisses on first plant !== null transition via tiles-slice subscription; grep confirms zero candidate strings hardcoded in FirstRunHint.tsx; firstRunHintDismissed does NOT appear in src/save/migrations.ts (session-state only — V1Payload uncontaminated, no migrations[2]); 6 behavioral tests in FirstRunHint.test.tsx; Playwright assertions B+C at season1-loop.spec.ts:91+133 confirm the live-loop visibility/dismissal flow."
- id: G3
severity: high
title: "Tile outlines too dim — 4×4 grid reads as 'gray check block'"
closed_by: 02-06-uat-gap-closure (commit ab48c7e)
evidence: "src/render/garden/tile-renderer.ts:14 OUTLINE_COLOR=0x5a5a60 (was 0x4d4d52); :15 OUTLINE_HOVER=0x7a7a82 (was 0x6e6e75); :17 HOVER_FILL_ALPHA=0.06 added; pointerover handler swaps outline + bumps hit rectangle's fill alpha; pointerout reverses; constants exported for testability; 5 phaser-mocked tests in tile-renderer.test.ts pin constants and pointerover behavior. NO new sprites, NO painted assets — Phase 3 watercolor deferral preserved."
- id: G4
severity: medium
title: "Gate visual stands alone with no surrounding context"
closed_by: 02-06-uat-gap-closure (commit 88adc4f)
evidence: "src/render/garden/gate-renderer.ts:34-38 exports WALL_BAND_X=880 (matches GATE_X) + WALL_BAND_HEIGHT=768 (full canvas height) + WALL_BAND_ALPHA=0.18 (mid of 0.15-0.20 fix_shape range) + WALL_BAND_COLOR=0x6e6e75 + WALL_BAND_WIDTH=44; drawGate adds the wall as the FIRST rectangle (z-order: behind body / glow / hit) so the gate body remains the focal element; GateGameObjects interface gains a `wall` field (additive — Garden.ts unchanged); 4 phaser-mocked tests in gate-renderer.test.ts pin the alpha range, the first-rectangle geometry, the 4-rectangle total, and the GateGameObjects exposure. NO painted asset — Phaser primitive only — Phase 3 watercolor deferral preserved."
per_req:
CORE-02: PASS
CORE-03: PASS
CORE-11: PASS
GARD-01: PASS
GARD-02: PASS
GARD-03: PASS
GARD-04: PASS
MEMR-01: PASS
MEMR-02: PASS
MEMR-03: PASS
MEMR-04: PASS
MEMR-05: PASS
MEMR-06: PASS
STRY-01: PASS (structural; tone needs human read)
STRY-06: PASS
STRY-07: PASS (vacuous — Phase 2 ships zero Keeper-spoken lines)
STRY-10: PASS
AEST-07: PASS
UX-01: PASS
UX-02: PASS (structural; letter tone needs human read)
UX-10: PASS
UX-11: PASS
PIPE-02: PASS (structural; chunkContentMatch=true; chunkNameMatch deferred to Phase 4+ when consumers move to lazy-only)
PIPE-07: PASS
human_verification:
- test: "Read the three Lura .ink files in voice"
expected: "Lura reads as warmth-anchor / contrast / not co-griever; specific + intermittent + sometimes funny; the farewell carries 'The garden persists.' as the load-bearing turn"
why_human: "Tone quality is inherently subjective; the bible voice cannot be programmatically scored. The author already noted in 02-04 SUMMARY that 'user reviews the .ink files at next merge.' This is that review."
- test: "Read the letter-from-the-garden.ink in voice"
expected: "Letter is contemplative, anti-FOMO compliant, never 'you missed X come back tomorrow', honors D-11 (24h cap silent in voice — no numeric '28h' copy), reads like short fiction not stat dump"
why_human: "UX-02 explicitly forbids stat-dump framing; tonal compliance is a human judgment. Code structurally enforces slot-based composition but the words themselves need eyes."
- test: "Run npm run dev and exercise the loop manually"
expected: "Begin screen appears with no clutter → click Begin → AudioContext bootstraps → garden visible → click empty tile → SeedPicker popover appears → choose rosemary → wait ~2min for growth → click ready plant → fragment reveal modal → close → journal-icon appears → click → modal lists fragment → reload → fragment persists. Compose ~9 harvests to fire all 3 Lura beats in sequence and confirm cadence + visual gate indicator + DOM-rendered selectable text."
why_human: "Visual layout, ready-pulse cadence, gate alpha-pulse, dialogue drip cadence (1500ms base + 20ms/char), and overall feel are not testable without live eyes. Plan SUMMARYs explicitly state 'Manual smoke test: not performed in this execution session.'"
- test: "Verify the Begin screen feels A-Dark-Room-clean"
expected: "First-load shows ONLY 'The Last Garden' / 'tend' / Begin button — no HUD, no settings icon visible behind, no journal, no seed picker. After clicking Begin, garden tiles fade in. Returning-player path (D-22) skips Begin entirely."
why_human: "AEST-07 + UX-01 are about visual restraint; the typographic placeholder gets a human pass on whether it lands tonally."
- test: "Verify offline catchup → letter overlay flow on a returning save"
expected: "Plant a seed, close tab, return after ≥5 minutes (or simulate via clock manipulation in dev), letter overlay appears with composed Ink text reflecting plants_bloomed / fragment_titles / lura_was_here slots; a single tap dismisses to live garden; audio bootstraps on dismiss (Pitfall 9)."
why_human: "The Playwright e2e exercises the <5min path (no letter); the ≥5min letter path is structurally tested (Letter.test.tsx + buildLetterSlots.test.ts) but the user-facing flow needs eyes."
- test: "Confirm the gate visual indicator + LuraDialogue overlay flow"
expected: "After 1st harvest, soft alpha-pulse appears on the gate at canvas (880, 384); click → React DOM dialogue overlay opens; lines drip with text-message cadence; close → resolvePendingLuraBeat marks visited; second click on gate (no pending) is a soft no-op."
why_human: "Phaser canvas rendering and pulse cadence are not unit-tested (Phaser scenes need a real canvas; covered by Plan 02-05 e2e but only structurally for plant rendering, not gate)."
- test: "Read the chosen first_run_hint copy in context — 'Begin where the soil is bare.'"
expected: "Copy lands in bible voice — warm, specific, contemplative, intermittent (one beat, no follow-on); echoes the BeginScreen CTA without redundancy; not a nag, not a tutorial, not a FOMO surface; player feels guided not instructed. The plan's #1 ranked candidate; if tone-review surfaces it as too elliptical, fallback to candidate #2 ('The soil is waiting.') or #3 ('Click a tile to plant.')."
why_human: "Tonal compliance of player-visible copy is inherently subjective; the line is now structurally externalized + visible after Begin. Banner concern #9 (tonal failure) requires the user's eyes on the words themselves."
---
# Phase 2: Season 1 Vertical Slice (Soil) — Verification Report
**Phase Goal:** Player can launch the game, plant a seed, watch it grow, harvest a memory fragment authored in real Season 1 content, meet Lura at the gate, leave the tab for hours, and return to a letter-from-the-garden describing what bloomed — the entire core loop and content pipeline proven on Season 1 with no aesthetic polish required.
**Verified:** 2026-05-09T11:24:00Z (automated) → 2026-05-09T15:50:00Z (live UAT update) → 2026-05-09T17:35:00Z (re-verification after Plan 02-06 gap closure)
**Status:** VERIFIED — all 24 REQ-IDs structurally PASS, all 4 first-impression UX gaps now closed by Plan 02-06 (commits f52de0b, c46fc75, ab48c7e, 88adc4f, 47b5b8d). 6 HUMAN-UAT tone items remain pending below the now-cleared structural surfaces.
**Re-verification:** Yes — initial verification at 11:24:00Z found 0 structural gaps; live UAT at 15:50:00Z surfaced 4 first-impression UX gaps; Plan 02-06 closed all 4 in ~30 min; this re-verification at 17:35:00Z confirms closure with no regressions.
**Overall verdict:** PHASE STRUCTURALLY COMPLETE AND SHIPPABLE — code passes every automated gate (333/333 vitest + Playwright e2e + lint + build + asset provenance + bundle-split), the four first-impression UX gaps are closed at the file-evidence level, no Phase-2 banner concerns regressed, no painted assets added (Phase 3 deferral preserved), no V1Payload contamination, no new npm dependencies, no edits in src/sim/**. Tonal sign-off on Lura's voice + letter cadence + Begin tone + first_run_hint copy remains the user's call at next merge.
---
## Gaps Found in Live UAT (2026-05-09T15:50:00Z) — NOW CLOSED
The 5 plans + automated verifier all passed; human live-loop walkthrough on a fresh dev server surfaced first-impression UX gaps NOT visible in the test suite. See frontmatter `gaps_closed:` for the structured list. Summary:
| Gap | Severity | What user saw | Fix shape (one-line) | Status (2026-05-09T17:35:00Z) |
|-----|----------|---------------|----------------------|-------------------------------|
| G1 | blocking | Dark canvas floats in a sea of white = visually broken on every page load | Add `src/index.css` with body bg `#1a1a1a`, import in `main.tsx` | CLOSED (commit f52de0b) |
| G2 | blocking | After dismissing Begin, no instruction visible — player confused | Add `FirstRunHint` overlay with one bible-voice line, auto-dismiss on first plant | CLOSED (commit c46fc75) |
| G3 | high | 4×4 grid reads as "gray check block" — outlines too dim against canvas | Brighten empty-tile outline + hover state contrast in `tile-renderer.ts` | CLOSED (commit ab48c7e) |
| G4 | medium | Gate visual at canvas (880, 384) reads as stray gray rectangle | Add faint vertical wall primitive in `gate-renderer.ts` for Phase-2 context | CLOSED (commit 88adc4f) |
**Why the automated verifier missed all 4:** the 312 vitest cases pin behavioral correctness (state transitions, schema, determinism, save round-trip); the Playwright e2e drives the loop programmatically (it doesn't *look* at the screen). First-impression "what does a new player see?" is a category the test suite cannot cover. The HUMAN-UAT.md tone items capture the next layer (Lura's voice, letter cadence) — the gaps above were a layer beneath those, structurally simpler but visually load-bearing.
**Phase 3 deferral preserved:** the watercolor + cello + painted plants the bible describes remain Phase 3 scope. Every fix uses Phaser primitives or a single CSS file, no painted assets.
---
---
## Verification Gates (Actual Runs at 11:18-11:23)
| Gate | Command | Result |
|------|---------|--------|
| Tests | `npm test` | 39 test files, **312/312 tests** passed (5.54s) |
| Lint | `npm run lint` | Exit 0 (2 informational boundaries-plugin deprecation notices about a v5→v6 rename — not lint warnings; informational stderr only) |
| Build | `npm run build` | Exit 0; entry chunk 1.9MB; 5 lazy code-split Ink chunks (lura-arrival, lura-mid, lura-farewell, compost-acknowledgements, letter-from-the-garden) |
| Bundle split | `npm run check:bundle-split` | Exit 0; PIPE-02 OK — `chunkNameMatch=false, chunkContentMatch=true` (eager-corpus mode for Phase 2; Phase 4+ moves consumers to lazy-only) |
| Asset provenance | `node scripts/validate-assets.mjs` | Exit 0; `[provenance] all 2 assets carry valid provenance.` |
| Compiled Ink | `ls src/content/compiled-ink/season1/` | 5 .ink.json files (4 Lura beats + 1 letter; matches /content/dialogue/season1/) |
| Playwright e2e | `npx playwright test tests/e2e/season1-loop.spec.ts` | Exit 0; **1 passed** in 4.0s (test runtime 1.6s) |
All automated gates green.
---
## Goal Achievement
### Observable Truths (mapped to ROADMAP Success Criteria)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| SC1 | Begin gate honored from frame one — single hand-painted "Tend the garden / Begin", AudioContext.resume on user gesture, no UI clutter on initial load | VERIFIED (structural) | `src/ui/begin/BeginScreen.tsx:28` calls `bootstrapAudioContext()` SYNCHRONOUSLY inside the click handler (Pitfall 5 — iOS Safari construction-inside-gesture); `src/ui/begin/use-audio-bootstrap.ts:24-45` creates the AudioContext lazily and calls `_ctx.resume()`. UI is a single fixed-position overlay with `zIndex: 100` covering the canvas, only title + subtitle + Begin CTA from `uiStrings[1].begin`. D-22 returning-player path: `src/PhaserGame.tsx:159` calls `appStore.getState().dismissBeginGate()` when a save record exists. Tone-quality of the typographic placeholder needs a human read. |
| SC2 | Plant → grow → harvest → fragment → journal flow with selectable copy-pasteable text and stable string fragment IDs; fragment authored in /content/ Markdown+frontmatter; growth state persists across browser refresh | VERIFIED | Plant: `src/sim/garden/commands.ts` `plantSeed()` is pure with D-05 unlock-gate + occupied-tile silent no-op. Grow: `src/sim/garden/growth.ts` `advanceGrowth()` state machine sprout→mature@33%→ready@100% per `GROWTH_THRESHOLDS`. Harvest: `commands.ts` `harvest()` calls `selectFragment()`, empties tile, appends to `harvestedFragmentIds`, runs Pitfall 10 unlock recompute. Fragment: `src/sim/memory/selector.ts` deterministic mulberry32 PRNG seeded from sim state. Journal: `src/ui/journal/Journal.tsx` full-screen modal renders fragment bodies inside `<pre>` with `userSelect: 'text'` (DOM, not canvas — MEMR-05 mechanically defended). Stable IDs: 17 yaml fragments + 2 markdown fragments under `content/seasons/01-soil/`, all matching `/^season1\.[a-z0-9._-]+$/`. Refresh persistence: PIPE-07 e2e at `tests/e2e/season1-loop.spec.ts:188-218` reloads page after harvest and asserts fragment still in store + still in journal. **PASSED in 1.6s.** |
| SC3 | Compost an immature plant yields tonal beat acknowledgement; deterministic fragment selector never duplicates within playthrough until pool exhausted; respects Season/story-state gating; Lura appears at gate with text-message-cadence Ink dialogue compiled to JSON | VERIFIED (structural; Lura tone needs human read) | Compost: `commands.ts` `compost()` empties tile, no fragment yield (D-07), no resource refund (D-04 infinite seeds); `src/ui/settings/compost-toast.tsx` cycles through `uiStrings[1].post_harvest_beat` (3 quiet authored lines) on each compost dispatch via `bumpCompostBeat`. Selector no-dup: `src/sim/memory/pool.ts` `filterPool()` excludes already-harvested ids; `selector.ts` 16 tests cover determinism + gating + sentinel exhaustion fallback (`season1.soil._exhaustion`, tagged `_meta`, excluded from normal pool). Lura: `src/sim/narrative/lura-gate.ts` gates on `state.harvestedFragmentIds.length` reaching 1/4/8 thresholds (D-14); `src/ui/dialogue/LuraDialogue.tsx` renders inkjs Story via `InkRenderer` with text-message cadence (1500ms base + 20ms/char, capped 4000ms). 4 Ink files compile to 4 JSON files via `scripts/compile-ink.mjs` (BLOCKER 4 — uses `node_modules/inklecate/bin/inklecate{.exe}` directly, not stale per-platform path strings). |
| SC4 | Tab close + return ≤24h: garden progresses by elapsed real time (not setInterval), refuses negative deltas, caps offline catchup at 24h; return screen is the *letter from the garden* (not a stat dump); saves fire on visibilitychange + beforeunload + Season transitions | VERIFIED (structural; letter tone + ≥5min flow need human read) | Elapsed real time: `src/sim/scheduler/tick.ts` `drainTicks()` is a pure fixed-timestep accumulator; the boot path in `src/PhaserGame.tsx:163-211` calls `computeOfflineCatchup(payload.lastTickAt, nowMs)` then drains via silent simulate. Negative refusal: `tick.ts:53-55` returns the original state with `ticksApplied=0` if `accumulatorMs < 0`. 24h cap: `tick.ts:32` `MAX_OFFLINE_MS = 24 * 3600 * 1000`; `tick.ts:56` clamps via `Math.min(accumulatorMs, MAX_OFFLINE_MS)`; `catchup.ts:36` clamps `cappedMs = raw < 0 ? 0 : Math.min(raw, MAX_OFFLINE_MS)`. Letter: `src/ui/letter/Letter.tsx` loads `letter-from-the-garden.ink`, binds plants_bloomed / fragment_titles / lura_was_here slots, opens at ≥5min absence (D-20: `ABSENCE_LETTER_THRESHOLD_MS = 5 * 60 * 1000` at `PhaserGame.tsx:69`); content is anti-FOMO compliant (no numeric "28h" copy in any branch — verified in `letter-from-the-garden.ink:7-15`). Save lifecycle: `src/save/lifecycle.ts:29-42` registers visibilitychange→hidden + beforeunload synchronous handlers; `saveOnSeasonTransition()` callable for Phase 4+. PhaserGame.tsx wires saveSync via clock.now() (BLOCKER 3 wall-clock anchor) + synchronous LocalStorage write (Pitfall 7) + best-effort IDB. |
| SC5 | Playwright e2e smoke passes: load → dismiss begin → plant → fast-forward growth → harvest → verify journal → refresh page → verify persistence; story progression gates on tick count NOT wall time (system-clock cheat resistance) | VERIFIED | `tests/e2e/season1-loop.spec.ts` exercises all 16 steps under URL flag `?devtime=fake` (production-guarded by `import.meta.env.PROD`). Test runs in 1.6s end-to-end (4.0s including dev-server cold start). Fast-forward via `__tlgFakeClock.advance(ms)`. STRY-10: `src/sim/narrative/lura-gate.ts:47-50` `advanceLuraBeatProgress(progress, harvestCount)` takes ONLY harvest count — no clock parameter. STRY-10 test in `lura-gate.test.ts` advances FakeClock 24h with 0 harvests and confirms `progress.pending === null`. ESLint sim-purity rule (`eslint.config.js` Block 3) bans Date.now/setInterval inside `src/sim/**` with `clock.ts` as the single exception; lint exits 0. |
**Score: 5/5 ROADMAP success criteria structurally satisfied.** Subjective tone-quality items routed to human verification (see frontmatter `human_verification`).
---
## REQ-ID Coverage (24/24)
| REQ-ID | Owner Plan(s) | Status | Evidence |
|--------|---------------|--------|----------|
| CORE-02 | 02-01 + 02-02 | PASS | `drainTicks` fixed-timestep accumulator at `src/sim/scheduler/tick.ts`; TICK_MS=200 (5Hz); 7 scheduler tests green; Garden.ts update() loop drives it via injected clock. |
| CORE-03 | 02-01 + 02-05 | PASS | MAX_OFFLINE_MS=24h clamp at `tick.ts:32`; `computeOfflineCatchup` reports `hitOfflineCap=true` on excess; PhaserGame.tsx boot path threads catchup → silent drainTicks → letter overlay open at ≥5min. 5 catchup tests green. |
| CORE-11 | 02-01 | PASS | `drainTicks` returns original state with `ticksApplied=0` on negative `accumulatorMs` (tick.ts:53-55); ESLint sim-purity rule enforces no Date.now inside `src/sim/**` outside `clock.ts`. Lint exits 0; 1 test pins the negative-refusal behavior. |
| GARD-01 | 02-02 | PASS | `plantSeed` at `commands.ts` (D-05 unlock-gate + occupied silent no-op + immutability via map-spread); SeedPicker DOM popover; Garden scene `pointerdown` enqueues. 14 commands.test.ts cases. **Plan 02-06 G3 supplemental:** tile-renderer brightens OUTLINE_COLOR + adds hover fill bump so the planting affordance is visually legible from frame one. |
| GARD-02 | 02-02 + 02-05 | PASS | `advanceGrowth` pure function with 3-stage state machine; `plant-renderer.ts` primitives per stage; Garden scene `appStore.subscribe` drives reactive `repaintPlants`. PIPE-07 e2e verifies save round-trip restores tile state. |
| GARD-03 | 02-03 | PASS | `harvest()` pure command refuses immature plants, calls `selectFragment()`, empties tile, recomputes Pitfall 10 unlocks. Garden.ts `handleTilePointerDown` enqueues `'harvest'` on a ready-stage click. |
| GARD-04 | 02-03 + 02-04 + 02-05 | PASS | `compost()` pure command empties tile, no yield (D-07), no refund (D-04). Garden.ts compost branch enqueues + bumps `compostBeatTick`; CompostToast cycles `uiStrings[1].post_harvest_beat`. The Ink-authored richer voice in `compost-acknowledgements.ink` is compiled + runtime-loadable for Phase 4+ to swap in. |
| MEMR-01 | 02-03 | PASS | `harvest()` calls `selectFragment()` exactly once per ready-stage harvest; result appended to `harvestedFragmentIds`. Pinned by 16 selector tests + commands harvest tests. |
| MEMR-02 | 02-03 | PASS | 17 fragments under `/content/seasons/01-soil/` (16 named yaml + 1 sentinel) plus 2 long-form Markdown (lura-first-letter.md, winter-rose-night.md); PIPE-01 enforced (build fails on schema violation). **Note (info-level):** the 02-03 SUMMARY claims "14 yaml entries (9 warm + 3 contemplative + 2 heavy + 1 _meta)" — the actual count is 17 yaml (9 warm + 4 contemplative + 3 heavy + 1 _meta). Substantive constraint "warm pool depth ≥9" holds; documentation undercounts but does not affect goal achievement. |
| MEMR-03 | 02-03 | PASS | All 17 yaml + 2 markdown fragment ids match `/^season1\.[a-z0-9._-]+$/`; FragmentSchema regex enforces stable string IDs; `loader.test.ts` has the numeric-id rejection case. |
| MEMR-04 | 02-03 | PASS | `Journal.tsx` full-screen modal grouped by Season; `JournalIcon` corner affordance gated by `selectJournalRevealed` (D-23 first-harvest reveal). 7 Journal.test.tsx + 3 journal-icon tests. |
| MEMR-05 | 02-03 | PASS | `Journal.tsx` + `FragmentRevealModal.tsx` both render fragment bodies inside `<pre>` with `userSelect: 'text'` (DOM, not canvas). Pinned by computed-style assertions. |
| MEMR-06 | 02-03 | PASS | `selector.ts` mulberry32 PRNG seeded from sim state (no Date.now); gating by Season + plant-type tonal-register tag; no-dup; sentinel fallback `season1.soil._exhaustion` for Pitfall 8. 16 selector tests. |
| STRY-01 | 02-04 | PASS (structural; tone needs human read) | 3 Ink beats authored at `/content/dialogue/season1/lura-{arrival,mid,farewell}.ink`; gated at 1/4/8 harvests via `lura-gate.ts`; `LuraDialogue.tsx` renders inkjs Story; gate-renderer at `(880, 384)` with soft alpha-pulse. 17 sim tests + 13 dialogue tests. **Tone-quality (warmth-anchor / contrast / not co-griever / specific-intermittent-funny) is structurally believable from the .ink content read but needs author confirmation.** |
| STRY-06 | 02-04 + 02-05 | PASS | `scripts/compile-ink.mjs` invokes bundled inklecate binary at build time; 5 .ink → .ink.json deterministically; `src/content/ink-loader.ts` lazy-loads compiled JSON; `npm run ci` runs compile:ink before tests + before build. RESEARCH Assumption A6 verified first-try on Windows. |
| STRY-07 | 02-04 | PASS (vacuous) | Phase 2 ships zero Keeper-spoken lines. The Keeper is the player; only Lura speaks (and the gardener-keeper voice acknowledges in compost beats, but is never personified as a named character). Phase 7 lands the binary choice surface. |
| STRY-10 | 02-04 | PASS | `lura-gate.ts:47-50` `advanceLuraBeatProgress(progress, harvestCount)` takes ONLY the harvest count — no clock parameter exists. STRY-10 test case advances FakeClock by 24 hours with zero harvests and confirms no beat fires. ESLint sim-purity rule mechanically prevents Date.now inside `src/sim/narrative/`. |
| AEST-07 | 02-02 | PASS | `BeginScreen.tsx:28` calls `bootstrapAudioContext()` synchronously inside the click handler; `use-audio-bootstrap.ts` constructs AudioContext + calls `resume()` (Pitfall 5 — iOS Safari construction-inside-gesture defended). 4 BeginScreen tests + first-interaction one-shot for D-22 returning players. **Plan 02-06 G1 supplemental:** body bg now matches BeginScreen overlay so there is no tonal break at any moment of the gesture flow. |
| UX-01 | 02-02 + 02-03 | PASS | BeginScreen mounts as a single fixed-position dialog covering the canvas with only title + subtitle + Begin CTA; no HUD, no journal pre-first-harvest (D-23), no settings clutter. **Plan 02-06 G2 supplemental:** after Begin dismisses, FirstRunHint surfaces a single bible-voice line ("Begin where the soil is bare.") so the A-Dark-Room first-prompt rule is honored — the player sees one prompt at a time, minimal but always present until acted upon. |
| UX-02 | 02-05 | PASS (structural; letter tone needs human read) | `letter-from-the-garden.ink` authored skeleton with VAR plants_bloomed / fragment_titles / lura_was_here per D-17/D-18; bible voice; anti-FOMO compliant (24h cap silent in voice per D-11 — verified zero numeric "28h" copy in any branch); `Letter.tsx` full-screen overlay (D-20 ≥5min trigger, single-tap dismiss with Pitfall 9 audio bootstrap); `buildLetterSlots` pure helper + 10 tests; Letter overlay 7 tests. Boot path threads silent catchup → offlineEvents → openLetter. |
| UX-10 | 02-01 + 02-05 | PASS | `registerSaveLifecycleHooks` synchronous handlers for visibilitychange→hidden + beforeunload (lifecycle.ts:29-42); `saveOnSeasonTransition()` callable. 6 lifecycle tests green. PhaserGame.tsx boot path wires saveSync via `clock.now()` (BLOCKER 3 wall-clock anchor) + synchronous LocalStorage write (Pitfall 7) + best-effort IDB write. W5 — lifecycle handle held in ref so the outer cleanup detaches across the async IIFE boundary. |
| UX-11 | 02-01 | PASS | `formatHumanReadable` handles K/M/B/T thresholds + 1e15 scientific + negative-sign branch; 11 format tests green. `BigQty.format()` delegates so all currency-grade numbers in the HUD route through this. |
| PIPE-02 | 02-02 + 02-03 | PASS (structural) | `loadSeasonFragments(seasonId)` lazy `import.meta.glob` surface in `src/content/loader.ts`; `scripts/check-bundle-split.mjs` exits 0 after build (chunkContentMatch=true). **Caveat (info-level):** the build emits 3 INEFFECTIVE_DYNAMIC_IMPORT warnings for `fragments.yaml`, `lura-first-letter.md`, `winter-rose-night.md` because Phase 2 keeps the eager `fragments` export alongside the lazy `loadSeasonFragments` for back-compat with Phase-1 loader tests. Phase 4+ will switch consumers to lazy-only when Season 2 onboards; the warnings will resolve naturally then. The current `chunkContentMatch=true` heuristic is structurally OK but `chunkNameMatch=false` is the expected eager-mode state, not a regression. **Bundle stays at 1.9MB; gate doesn't fire on size as that lands in Phase 8.** |
| PIPE-07 | 02-05 | PASS | `tests/e2e/season1-loop.spec.ts` covers load → Begin → plant rosemary → fast-forward FakeClock 3min → harvest → fragment-reveal modal → close → journal-icon visible → open journal → fragment present → reload → fragment persists. 1.6s test runtime. URL-flag FakeClock injection production-guarded by `import.meta.env.PROD`. |
**Coverage:** 24/24 Phase-2 REQ-IDs structurally PASS. Zero orphaned requirements; the requirement-ID set in REQUIREMENTS.md table-of-contents row exactly matches the union of `requirements-completed:` arrays across the 5 plans' frontmatter.
---
## Banner Concern Carry-Forward Checks
| # | Banner Concern | Status | Evidence |
|---|----------------|--------|----------|
| 4 | System-clock cheating | DEFENDED | `tick.ts:53-55` refuses negative `accumulatorMs`; `catchup.ts:36` clamps `cappedMs` at 0 for negative deltas; `lura-gate.ts:47-50` gates on harvest count never wall time; `eslint.config.js` Block 3 mechanically prevents Date.now inside `src/sim/**` (only `clock.ts` and the deliberate `__test_violation__` fixture violate); STRY-10 test pins behavior. |
| 7 | Web Audio user-gesture | DEFENDED | `BeginScreen.tsx:28` calls `bootstrapAudioContext()` synchronously inside the click stack frame (Pitfall 5 — iOS Safari construction-inside-gesture); `use-audio-bootstrap.ts` constructs AudioContext lazily inside the gesture (no useEffect indirection); `installFirstInteractionGestureHandler` covers returning-player path; `Letter.tsx:90` calls `bootstrapAudioContext()` on dismiss for the returning-player-via-letter path (Pitfall 9). **Plan 02-06 verification:** FirstRunHint mounts AFTER BeginScreen in App.tsx render tree (App.tsx:55-56) and uses `pointerEvents: 'none'`; Begin → audio-bootstrap path is unaltered. |
| 6 | Anti-FOMO | DEFENDED | `letter-from-the-garden.ink` is contemplative, slot-based, no numeric "28h" copy, no nag, no streak, no daily-login pressure (verified by reading the .ink file); `uiStrings[1].settings.persistence_denied_toast` is "The garden may forget, if your browser asks it to." (in voice, not a stat); CompostToast lines are quiet acknowledgements ("The earth remembers.", "Something stayed.", "It rests where it grew."); `.planning/anti-fomo-doctrine.md` exists from Phase 1 and is review-enforced. **Plan 02-06 verification:** the chosen first_run_hint copy ("Begin where the soil is bare.") is one quiet imperative — no nag, no streak, no time pressure, no urgency; tonal sign-off remains a human-verification item. |
| 10 | Authored content / code divergence | DEFENDED | All player-visible strings live in `/content/seasons/01-soil/ui-strings.yaml` + 17 yaml fragments + 2 markdown fragments + 5 .ink files. Stable-string fragment IDs (`/^season1\.[a-z0-9._-]+$/` regex enforced by FragmentSchema). Spot-check of `BeginScreen.tsx`, `SeedPicker.tsx`, `Letter.tsx`, `Settings.tsx`, `LuraDialogue.tsx`, `FirstRunHint.tsx` shows zero hardcoded English strings outside CSS values, ARIA roles, command kinds, and event names. **Plan 02-06 verification:** grep for the three candidate hint strings inside FirstRunHint.tsx returns ZERO matches; copy lives in ui-strings.yaml + UiStringsSchema is extended with `first_run_hint: z.string().min(1)` to defeat Zod strip mode. |
| 1 | Story ends but the loop doesn't | NOT EXERCISED IN PHASE 2 | Phase 1 landed `season-7-end-state.md` doctrine doc; Roothold ceiling lands in Phase 4; credits/coda rest state lands in Phase 7. Phase 2 introduces nothing that forecloses the Season 7 end-state design. |
| 2 | 7-Season scope | DEFENDED VIA STANDALONE-PROLOGUE ESCAPE HATCH | The Phase 2 vertical slice now satisfies the "could plausibly ship as a free standalone Season 1 prologue" contract from ROADMAP overview. Plan 02-05's e2e proves the loop end-to-end on real authored content with real save round-trip. **Plan 02-06 strengthens this** — first-impression UX gaps closed, page-bg coherent, first-prompt present, grid legible, gate has wall context. The vertical slice now actually feels like a shippable prologue to a brand-new player on frame one. |
| 5 | AI asset style drift | NOT EXERCISED IN PHASE 2 | Phase 2 ships zero PNG assets — plant rendering uses Phaser primitive shapes (D-26). The provenance gate from Phase 1 is in place (validate-assets.mjs exits 0 with 2 placeholder assets); Phase 5+ first exercises it at production volume. **Plan 02-06 verification:** `git log f52de0b~1..HEAD --diff-filter=A --name-only -- '*.png' '*.jpg' '*.webp' '*.svg' '*.gif' '*.bmp'` returns empty — gap-closure plan added zero painted assets. Phase 3 watercolor deferral preserved. |
| 8 | Tab throttling | DEFENDED | Sim advances by elapsed-time accumulator, never `setInterval` (banned by ESLint sim-purity rule). Save fires on `visibilitychange` to hidden + `beforeunload` (lifecycle.ts:29-42) + `saveOnSeasonTransition` callable. |
| 9 | Tonal failure | NEEDS HUMAN VERIFICATION | Lura's three Ink beats and the letter-from-the-garden Ink are structurally in voice based on a code-side read, but ROADMAP's "external readers gate every Season's tone" is the user's review responsibility. Plan 02-04 SUMMARY explicitly defers this to "next merge"; this verification surfaces it as a human_needed item. **Plan 02-06 adds one more line for review:** the chosen first_run_hint copy "Begin where the soil is bare." (now player-visible) joins the queue for tonal sign-off. |
| 3 | Browser save fragility | DEFENDED | IDB primary path + LocalStorage synchronous fallback (Pitfall 7); `navigator.storage.persist()` always called from the boot path (D-30 toast on denied); CRC-32 checksum + canonical JSON; Base64 export/import in Settings; last-3 snapshot retention from Phase 1. **Plan 02-06 verification:** firstRunHintDismissed lives in src/store/session-slice.ts (NOT V1Payload); migrations.ts is unchanged; no migrations[2] entry; the new flag is session-state only as the doctrine requires. |
---
## Anti-Pattern Scan (Phase 2 Files)
| File | Pattern | Severity | Notes |
|------|---------|----------|-------|
| `src/PhaserGame.tsx:152, 220, 250` | `console.error` / `console.warn` for boot-path failures | INFO | Defensive logging only; the actual UX is "fall through to first-run init" or "show toast." Not a stub. |
| `src/ui/letter/Letter.tsx:74` | `console.error('[Letter] failed to load', err)` | INFO | Fail-soft on Ink load failure with explicit `dismissLetter()` recovery. |
| `src/ui/letter/Letter.tsx:131` | Loading state renders `<p style={{ opacity: 0.4 }}>...</p>` | INFO | Genuine loading-state placeholder while `loadInkStory` resolves; replaced by InkRenderer once runtime is ready. Not a permanent stub. |
| `src/sim/garden/auto-harvest.ts` (cyclic import with commands.ts) | Benign ESM cycle | INFO | Documented at `auto-harvest.ts:32-37` and `commands.ts`; verified by all 312 tests passing. |
| Build: `INEFFECTIVE_DYNAMIC_IMPORT` warnings | 3 warnings on `fragments.yaml`, `lura-first-letter.md`, `winter-rose-night.md` | INFO | Inherited from Plan 02-02's eager-corpus + lazy-glob co-existence; documented as a Phase-4+ resolution path when consumers move to lazy-only. PIPE-02 structural verifier confirms `chunkContentMatch=true` so the lazy plumbing is genuinely there. |
| Bundle size 1.9MB > 500kB Vite warning | INFO | Acknowledged in 02-05 SUMMARY; tracked for Phase 3 (watercolor) or later when code-splitting becomes meaningful. |
| `gray-matter` package.json entry no longer used by code | INFO | Tracked in `.planning/phases/02-season-1-vertical-slice-soil/deferred-items.md`; cleanup-only, not blocking. |
| `src/sim/__test_violation__/date-now-violator.ts:13` | Deliberate `Date.now()` violation | EXPECTED FIXTURE | Excluded from `npm run lint` via Block 1's top-level ignores; the programmatic ESLint test in `lint-firewall.test.ts` overrides via `ignore: false` to verify the Block 3 sim-purity rule fires. |
**No blockers found. No warnings rise to gap-level. All info-level items are either documented deferrals or expected-by-design.**
---
## Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Vitest suite | `npm test` | 39 files, 312/312 passed (5.54s) | PASS |
| Lint | `npm run lint` | Exit 0; 0 errors, 0 warnings (2 informational stderr deprecation notices about boundaries v5→v6 plugin rename — non-blocking) | PASS |
| Build | `npm run build` | Exit 0; 1.9MB entry chunk; 5 lazy Ink chunks | PASS |
| Bundle split | `npm run check:bundle-split` | Exit 0; PIPE-02 OK | PASS |
| Asset provenance | `node scripts/validate-assets.mjs` | Exit 0; 2 valid assets | PASS |
| Compiled Ink output | `ls src/content/compiled-ink/season1/` | 5 .ink.json files | PASS |
| Playwright e2e | `npx playwright test tests/e2e/season1-loop.spec.ts` | 1 passed in 4.0s; test runtime 1.6s | PASS |
| Sim purity (no Date.now outside clock.ts) | `grep -rn "Date.now" src/sim/ --include="*.ts"` | Only matches: `clock.ts` (1 actual call, the documented exception) + deliberate `__test_violation__/date-now-violator.ts` fixture + doc-comment mentions in growth.ts/types.ts/etc. | PASS |
| Sim purity (no render/ui imports) | `grep -rn "from.*src/render\|from.*src/ui" src/sim/` | Only matches: deliberate `__test_violation__/violator.ts` fixture + a doc-comment | PASS |
All 9 spot-checks PASS.
---
## Human Verification Required (7 items)
See frontmatter `human_verification` for full structure. Headlines:
1. **Read the three Lura .ink files in voice** — confirm warmth-anchor / contrast / not co-griever; specific + intermittent + sometimes funny; "The garden persists." carries the farewell turn.
2. **Read letter-from-the-garden.ink in voice** — confirm contemplative, anti-FOMO compliant, not a stat dump.
3. **Run `npm run dev` and exercise the loop manually** — Begin → plant → grow → harvest → reveal → journal → reload → persist. Compose ~9 harvests to fire all 3 Lura beats and confirm cadence + visual indicator.
4. **Verify the Begin screen feels A-Dark-Room-clean** — single typographic placeholder, no clutter, returning-player path skips it.
5. **Verify offline catchup → letter overlay flow on a real ≥5min absence** — letter Ink composes correctly from offlineEvents block; Pitfall 9 audio bootstrap fires on dismiss.
6. **Confirm the gate visual indicator + LuraDialogue overlay flow** — soft alpha-pulse on pending beat, click → DOM dialogue overlay → drip cadence → close → resolved.
7. **Read the chosen first_run_hint copy in context — "Begin where the soil is bare."** — bible voice; warm, specific, contemplative, intermittent; not a nag, not a tutorial. Plan's #1 candidate; fallbacks #2 ("The soil is waiting.") and #3 ("Click a tile to plant.") available if tone-review surfaces #1 as too elliptical.
These are the items that the SUMMARY documents call out as "user reviews at next merge" or "Manual smoke test: not performed in this execution session." All are inherently subjective (tonal voice, visual cadence, A-Dark-Room-feel) and cannot be programmatically scored.
---
## Notes on Documentation Inaccuracies (Info-Level)
These are SUMMARY documentation errors that do NOT affect goal achievement:
1. **Plan 02-03 SUMMARY claims "14 yaml entries (9 warm + 3 contemplative + 2 heavy + 1 _meta)".** Actual count is 17 yaml entries (9 warm + 4 contemplative + 3 heavy + 1 _meta). The substantive constraint "warm pool depth ≥9" holds; the documentation undercounts other registers. Fix: tighten the SUMMARY count if/when next visited; not blocking.
2. **Plan 02-04 SUMMARY claims compile:ink emits "4 deterministic .ink.json files".** Actual count after Plan 02-05 lands is 5 files (the +1 is letter-from-the-garden.ink, added by Plan 02-05). Plan 02-05 SUMMARY corrects this to 5. The 02-04 SUMMARY is stale relative to the post-Plan-02-05 codebase. Not blocking; Plan 02-04 was correct at write-time.
3. **3 INEFFECTIVE_DYNAMIC_IMPORT build warnings** in `src/content/loader.ts` (fragments.yaml, lura-first-letter.md, winter-rose-night.md). These warnings indicate the dynamic-import path doesn't actually create a separate chunk because the same files are also statically imported. **PIPE-02 satisfaction**: `check-bundle-split.mjs` reports `chunkNameMatch=false, chunkContentMatch=true`, confirming the lazy plumbing is structurally there but eager-mode is the active code path. Phase 4+ will switch consumers to lazy-only when Season 2 onboarding lands; the warnings will resolve naturally then. The PIPE-02 verifier is structurally lenient on Day 1 (OR-of-three checks) by design — documented in Plan 02-03 SUMMARY. **The lazy-load contract is genuinely partial today; not "broken" but "not yet exercised." Verifier flags it as info-level for awareness but it does NOT block phase sign-off.**
---
## Phase 1 Regression Check
Phase 1 was verified at 16/16 PASS on 2026-05-09T00:15:00Z. Re-verifying invariants Phase 2 might have disturbed:
- ESLint boundary rule (CORE-10) — `npm run lint` exits 0; programmatic test `src/sim/__test_violation__/lint-firewall.test.ts` still green.
- Save layer (CORE-04 through CORE-09) — 9 envelope tests + 6 migration tests + 6 lifecycle tests all green; round-trip via Settings UI tested in Settings.test.tsx.
- Content pipeline (PIPE-01) — fragments.yaml (17 entries) + 2 .md files + ui-strings.yaml all parse via Vite's import.meta.glob; build fails on schema violation.
- Asset provenance (PIPE-03) — `validate-assets.mjs` still exits 0 with 2 valid assets.
- Doctrine docs (PIPE-05) — `.planning/anti-fomo-doctrine.md` + `.planning/season-7-end-state.md` still present; doc-lint test still green (in vitest run).
- CI workflow (PIPE-06) — `npm run ci` exits 0 end-to-end (lint + compile:ink + 312 tests + validate:assets + build + check:bundle-split).
**No Phase 1 regressions detected.**
---
## Verdict
**Phase 2 is STRUCTURALLY COMPLETE.** All 24 Phase-2 REQ-IDs PASS, all 5 ROADMAP success criteria are structurally satisfied, all banner-concern carry-forwards are defended in code, and every automated gate exits 0.
**6 human-verification items remain** — all subjective tone-quality and live-loop-feel checks that the SUMMARY documents already flagged as "user reviews at next merge" / "Manual smoke test: not performed in this execution session." These are not gaps or blockers; they are the canonical handoff points where the executor's structural verification ends and the developer's tone judgment begins.
The Phase-2 vertical slice could plausibly ship as a free standalone Season-1 prologue once items 13 in `human_verification` clear: a player can launch, plant, grow, harvest, meet Lura, leave, return to a letter, dismiss, and the save round-trip survives all of it. That's the project's escape hatch against the 7-Season scope risk (banner concern #2) realized.
---
_Verified: 2026-05-09T11:24:00Z_
_Verifier: Claude (gsd-verifier)_
---
## Gap Closure Verification (2026-05-09T17:35:00Z re-verification)
**Re-verifier run at:** 2026-05-09T17:35:00Z
**Closing plan:** `02-06-uat-gap-closure-PLAN.md``02-06-uat-gap-closure-SUMMARY.md`
**Closing commits:** `f52de0b` (G1) → `c46fc75` (G2) → `ab48c7e` (G3) → `88adc4f` (G4) → `47b5b8d` (e2e integration) → `7f39cf6` (docs)
**Mode:** goal-backward — start from each gap's `fix_shape`, verify codebase satisfies it.
---
### Gap closure: G1 — white halo around dark canvas (BLOCKING)
**Fix shape:** "Add `src/index.css` imported from `main.tsx`. body { margin: 0; min-height: 100vh; background: #1a1a1a; color: #e8e0d0; font-family: serif; } #game-container centered. ~15 lines."
| Check | Evidence | Status |
|-------|----------|--------|
| `src/index.css` exists | File present, 27 lines (close to ~15 estimate; the extra are explanatory comments) | PASS |
| body bg = #1a1a1a | `src/index.css:17` `background: #1a1a1a;` (inside the `html, body` rule) | PASS |
| body color = #e8e0d0 | `src/index.css:18` `color: #e8e0d0;` | PASS |
| body margin = 0 | `src/index.css:14` `margin: 0;` | PASS |
| body min-height = 100vh | `src/index.css:16` `min-height: 100vh;` | PASS |
| body font-family = serif | `src/index.css:19` `font-family: serif;` | PASS |
| #game-container centered | `src/index.css:22-26` `#game-container { display: flex; justify-content: center; align-items: center; }` | PASS |
| `src/main.tsx` imports it | `src/main.tsx:4` `import './index.css';` (with explanatory comment "Plan 02-06 G1") | PASS |
| Playwright e2e proves the bundled CSS applies in real Chromium | `tests/e2e/season1-loop.spec.ts:75-78` evaluates `getComputedStyle(document.body).backgroundColor` and asserts it equals `'rgb(26, 26, 26)'` (= #1a1a1a) from frame one | PASS |
| File-read smoke tests | `src/index.css.test.ts` — 6 cases pinning each load-bearing rule | PASS |
**G1 verdict:** CLOSED. The dark canvas no longer floats in a sea of white at any frame.
---
### Gap closure: G2 — no first-run prompt after Begin (BLOCKING)
**Fix shape:** "Tiny FirstRunHint component — single bible-voice line ('Click a tile to plant', or similar from ui-strings.yaml). Auto-dismisses on first plant. New `firstRunHintDismissed` flag in session-slice."
| Check | Evidence | Status |
|-------|----------|--------|
| `src/ui/first-run/FirstRunHint.tsx` exists | File present, 75 lines | PASS |
| Component reads externalized line via `uiStrings[1]?.first_run_hint` | `FirstRunHint.tsx:47` `const hint = uiStrings[1]?.first_run_hint;` — no hardcoded English in component | PASS |
| **No hardcoded candidate strings in component** | `grep "Begin where the soil is bare\|The soil is waiting\|Click a tile to plant" src/ui/first-run/FirstRunHint.tsx` → 0 matches | PASS |
| `content/seasons/01-soil/ui-strings.yaml` carries `first_run_hint` key | Line 21: `first_run_hint: "Begin where the soil is bare."` (the plan's #1 ranked candidate, unchanged) | PASS |
| `src/content/schemas/ui-strings.ts` extends UiStringsSchema | Line 38: `first_run_hint: z.string().min(1),` — defeats Zod default strip mode (without this, the YAML key would silently drop from parsed.data and FirstRunHint would render null in production) | PASS |
| `src/store/session-slice.ts` adds `firstRunHintDismissed` + `dismissFirstRunHint` action | Line 44: `firstRunHintDismissed: boolean;` (interface), Line 51: `dismissFirstRunHint: () => void;` (interface), Line 61: `firstRunHintDismissed: false,` (initial state), Line 68: `dismissFirstRunHint: () => set({ firstRunHintDismissed: true }),` (action) | PASS |
| **NO V1Payload contamination** (CRITICAL doctrine check) | `grep firstRunHintDismissed src/save/migrations.ts` → 0 matches; `git diff f52de0b~1 HEAD -- src/save/migrations.ts` → empty diff; flag is session-state ONLY | PASS |
| **NO migrations[2] entry added** (CRITICAL doctrine check) | `git diff f52de0b~1 HEAD -- src/save/` → empty diff; the only `migrations[2]` mentions in migrations.ts are doc-comments confirming "no migrations[2]" | PASS |
| FirstRunHint mounted in App.tsx between BeginScreen and SeedPicker | `src/App.tsx:55` `<BeginScreen />`, `:56` `<FirstRunHint />`, `:57` `<SeedPicker />` — exactly the spec | PASS |
| Auto-dismiss on first plant (subscribe to tiles slice) | `FirstRunHint.tsx:35-42` `useEffect` checks `tiles.some((t) => t?.plant !== null)` and calls `dismissFirstRunHint()` when true | PASS |
| Re-shows on hard reload (session state, not save state) | `firstRunHintDismissed: false` is the initial value in `createSessionSlice`; on reload the slice resets, so a fresh tab pre-first-plant sees the hint again — correct A-Dark-Room first-run UX | PASS |
| Behavioral test coverage | `FirstRunHint.test.tsx` — 6 cases: hidden when Begin still up, hidden when dismissed, renders externalized line, reads from uiStrings (not hardcoded), auto-dismisses on first plant, stays dismissed on subsequent tile changes | PASS |
| Playwright e2e proves the live-loop visibility/dismissal | `season1-loop.spec.ts:91` asserts `getByTestId('first-run-hint').toBeVisible()` after Begin click; `:133` asserts `.not.toBeVisible()` after first plant lands | PASS |
| `src/ui/index.ts` re-exports `./first-run` | Line 9: `export * from './first-run';` | PASS |
| `pointerEvents: 'none'` on the hint root | `FirstRunHint.tsx:68` — hint doesn't intercept pointer events, so the underlying canvas receives clicks for tile interaction; banner concern #7 (Web Audio user-gesture) preserved | PASS |
| Component uses `aria-live="polite"` + `role="status"` | `FirstRunHint.tsx:53-54` — accessible to screen readers without interrupting | PASS |
**G2 verdict:** CLOSED with all doctrine constraints honored. firstRunHintDismissed is session-state only; copy is externalized; schema is extended to defeat Zod strip mode; FirstRunHint mounts between BeginScreen and SeedPicker; banner concern #7 (Web Audio user-gesture) is preserved by `pointerEvents: 'none'`.
---
### Gap closure: G3 — tile outlines too dim (HIGH)
**Fix shape:** "Brighten empty-tile outline color (~#3a3a40 → ~#5a5a60); add a clearer hover state (~#7a7a82 outline + slight fill alpha bump). No visual style change beyond contrast — Phase 3 watercolor still owns the painted look."
| Check | Evidence | Status |
|-------|----------|--------|
| `src/render/garden/tile-renderer.ts` exists | File present, 62 lines | PASS |
| `OUTLINE_COLOR` brightened to ~0x5a5a60 | Line 14: `export const OUTLINE_COLOR = 0x5a5a60;` (was 0x4d4d52) — exactly matches fix_shape | PASS |
| `OUTLINE_HOVER` brightened to ~0x7a7a82 | Line 15: `export const OUTLINE_HOVER = 0x7a7a82;` (was 0x6e6e75) — exactly matches fix_shape | PASS |
| Hover fill alpha bump | Line 17: `const HOVER_FILL_ALPHA = 0.06;` — slight bump exactly as fix_shape specifies "slight fill alpha bump" | PASS |
| Pointerover swaps outline + bumps fill | Lines 46-49: `hit.on('pointerover', () => { drawOutline(g, ..., OUTLINE_HOVER); hit.setFillStyle(0xffffff, HOVER_FILL_ALPHA); });` | PASS |
| Pointerout reverses | Lines 50-53: `hit.on('pointerout', () => { drawOutline(g, ..., OUTLINE_COLOR); hit.setFillStyle(0xffffff, 0); });` | PASS |
| **NO new sprites or painted assets** | `git log f52de0b~1..HEAD --diff-filter=A --name-only -- '*.png' '*.jpg' '*.webp' '*.svg' '*.gif' '*.bmp'` → empty; only color + alpha values changed; Phase 3 deferral preserved | PASS |
| Constants exported for testability | `OUTLINE_COLOR` and `OUTLINE_HOVER` are `export const`, allowing the test file to import and assert them | PASS |
| Test coverage | `tile-renderer.test.ts` — 5 cases via Phaser-mock pattern: constants pinned, 16 tile groups created, initial draw uses OUTLINE_COLOR, pointerover swaps to OUTLINE_HOVER + fill bump (>0, ≤0.1) | PASS |
| Reduced-motion-safe | Hover is steady-state outline + fill swap — no tweens, no animations; banner-concern adjacent UX restraint preserved | PASS |
**G3 verdict:** CLOSED. The 4×4 grid now reads as legible interactive surfaces against the #1a1a1a canvas; hover state contrasts the resting state visibly without animation noise.
---
### Gap closure: G4 — gate visual stands alone with no surrounding context (MEDIUM)
**Fix shape:** "Add a faint vertical line/band in gate-renderer connecting top-to-bottom of the canvas at the gate's column (Phaser primitive — alpha ~0.15-0.20 against #1a1a1a). Phase 3 paints over without changing the structural intent."
| Check | Evidence | Status |
|-------|----------|--------|
| `src/render/garden/gate-renderer.ts` adds wall band | Lines 34-38: WALL_BAND_X / WALL_BAND_WIDTH / WALL_BAND_HEIGHT / WALL_BAND_ALPHA / WALL_BAND_COLOR all exported | PASS |
| Wall is a Phaser Rectangle primitive (not a painted asset) | Lines 56-64: `scene.add.rectangle(WALL_BAND_X, WALL_BAND_HEIGHT / 2, WALL_BAND_WIDTH, WALL_BAND_HEIGHT, WALL_BAND_COLOR, WALL_BAND_ALPHA)` | PASS |
| Wall at gate's column | `WALL_BAND_X = GATE_X = 880` (the gate column) | PASS |
| Wall spans full canvas height | `WALL_BAND_HEIGHT = 768` (matches Phaser canvas height in `src/game/main.ts`) — top-to-bottom span as fix_shape requires | PASS |
| Alpha in 0.15-0.20 range | `WALL_BAND_ALPHA = 0.18` — mid of the fix_shape range | PASS |
| Wall drawn FIRST (z-order: behind body / glow / hit) | `drawGate` adds `wall` first (lines 56-64), then `body` (66-72), `glow` (73-80), `hit` (84-91); the gate body remains the visual focal point | PASS |
| `GateGameObjects` exposes `wall` field (additive, Garden.ts unchanged) | Line 42: `wall: Phaser.GameObjects.Rectangle;` — the destructuring at the call site captures the whole returned object, so the new field is structurally safe | PASS |
| **NO painted asset added** | `git log f52de0b~1..HEAD --diff-filter=A --name-only -- '*.png' '*.jpg' '*.webp' '*.svg'` → empty; Phaser primitive only; Phase 3 watercolor deferral preserved | PASS |
| Wall does NOT pulse | Lines 99-122 `updateGateIndicator` is unchanged from Phase 2; only `glow` pulses; wall is steady-state alpha — reduced-motion-safe | PASS |
| Test coverage | `gate-renderer.test.ts` — 4 cases via Phaser-mock pattern: constants in fix_shape range (alpha 0.15-0.20), wall is FIRST rectangle with full canvas height, 4 total rectangles (wall + body + glow + hit), GateGameObjects exposes `wall` handle | PASS |
**G4 verdict:** CLOSED. The gate now has structural wall context — it reads as part of a wall, not a free-floating element — using only a single Phaser Rectangle primitive at alpha 0.18. Phase 3 paints the watercolor wall over this primitive without changing the structural intent.
---
### Constraint compliance (CRITICAL_CONSTRAINTS from re-verification request)
| Constraint | Verification command | Status |
|------------|---------------------|--------|
| **No painted assets added in 02-06 commits** (Phase 3 deferral) | `git log f52de0b~1..HEAD --diff-filter=A --name-only -- '*.png' '*.jpg' '*.webp' '*.svg' '*.gif' '*.bmp'` → empty | PASS |
| **No new npm dependencies** | `git diff f52de0b~1 HEAD -- package.json package-lock.json` → empty | PASS |
| **No edits in src/sim/** (sim purity preserved) | `git diff f52de0b~1 HEAD -- 'src/sim/**'` → empty | PASS |
| **firstRunHintDismissed is session-state only** | `grep firstRunHintDismissed src/save/migrations.ts` → 0 matches | PASS |
| **No migrations[2] entry** | `git diff f52de0b~1 HEAD -- src/save/migrations.ts` → empty | PASS |
| **Hint copy externalized (not hardcoded)** | `grep "Begin where the soil is bare\|The soil is waiting\|Click a tile to plant" src/ui/first-run/FirstRunHint.tsx` → 0 matches | PASS |
| **UiStringsSchema extended** (defeats Zod strip mode) | `grep -E 'first_run_hint:\s*z\.string\(\)' src/content/schemas/ui-strings.ts` → matches at line 38 | PASS |
---
### Re-run gates (post-closure)
| Gate | Command | Result | Status |
|------|---------|--------|--------|
| Vitest | `npm test` (run via `npm run ci`) | **43 test files, 333/333 passed** (was 312 → +21 new cases for G1/G2/G3/G4) | PASS |
| Lint | `npm run lint` (run via `npm run ci`) | Exit 0 | PASS |
| Compile Ink | `npm run compile:ink` | 5 .ink → 5 .ink.json (unchanged) | PASS |
| Asset provenance | `node scripts/validate-assets.mjs` | Exit 0; 2 valid assets (unchanged) | PASS |
| Build | `npm run build` | Exit 0; 1.9MB entry chunk (unchanged — no new deps, no new image assets) | PASS |
| Bundle split | `npm run check:bundle-split` | Exit 0; PIPE-02 OK; chunkContentMatch=true | PASS |
| Playwright e2e | `npm run test:e2e` | 1 passed in 1.51.6s (test runtime; was 1.6s, plan SUMMARY noted 1.7s with the +3 new assertions); confirmed across 2 consecutive runs | PASS |
**Note on first-run e2e flake:** the very first `npm run test:e2e` after a long-idle dev server hit a 30s timeout on the `waitForFunction` for the plantSeed dispatch. Two immediate consecutive re-runs both passed in 1.51.6s. This matches the documented dev-server cold-start pattern (Playwright config `webServer` with `reuseExistingServer: false` triggers Vite's first-load module graph build). It is not a regression introduced by Plan 02-06; it is a pre-existing cold-start timing characteristic of the test harness. Recorded as info-level — not actionable.
---
### Phase-2 banner concerns — re-checked under Plan 02-06's diff
| # | Banner concern | Closure-plan effect | Verdict |
|---|----------------|----------------------|---------|
| 5 | AI asset style drift | Plan adds 0 new image assets (only Phaser primitives + 1 CSS file) | DEFENDED — no provenance bypass risk |
| 7 | Web Audio user-gesture | FirstRunHint mounts AFTER BeginScreen (App.tsx:55-56) and uses `pointerEvents: 'none'`; Begin → audio-bootstrap path is unaltered | DEFENDED — bootstrapAudioContext still synchronous-inside-click |
| 6 | Anti-FOMO | Chosen first_run_hint copy "Begin where the soil is bare." is one quiet imperative — no nag, no streak, no time pressure, no urgency | DEFENDED — tonal sign-off remains a HUMAN-UAT item but the structural shape is anti-FOMO compliant |
| 9 | Tonal failure | The chosen first_run_hint copy is now player-visible and joins the queue for tonal sign-off — added as item #7 in `human_verification` | NEEDS HUMAN VERIFICATION (added to the 6→7 HUMAN-UAT item list) |
| 10 | Authored content / code divergence | All player-visible Plan-02-06 copy lives in `content/seasons/01-soil/ui-strings.yaml`; UiStringsSchema is extended so the YAML key actually reaches runtime; FirstRunHint reads `uiStrings[1]?.first_run_hint` and renders null if missing — no hardcoded English | DEFENDED |
---
### Re-verification verdict
**ALL 4 first-impression UX gaps are CLOSED.** The 24 Phase-2 REQ-IDs remain structurally PASS with no regressions detected (sim purity preserved, V1Payload uncontaminated, no new dependencies, no painted assets, no migrations[2]).
The Phase-2 vertical slice now actually delivers the "could plausibly ship as a free standalone Season-1 prologue" contract that ROADMAP cites as the project's escape hatch against the 7-Season scope risk (banner concern #2). A brand-new player launching `npm run dev` on frame one sees:
1. The dark canvas in a tonally-coherent dark viewport (no white halo).
2. The Begin gate as a single typographic placeholder.
3. After Begin clicks, a single bible-voice instructional line.
4. A legible 4×4 tile grid against the canvas background.
5. The gate as part of a wall, not a free-floating gray rectangle.
**6 → 7 HUMAN-UAT tone items remain pending** below the now-cleared structural surfaces — the chosen first_run_hint copy "Begin where the soil is bare." joins the queue. These remain the user's call at next merge / playtest.
**Phase 2 is structurally complete and shippable.**
---
_Re-verified: 2026-05-09T17:35:00Z_
_Re-verifier: Claude (gsd-verifier)_