--- phase: 02-season-1-vertical-slice-soil plan: 05 subsystem: letter-settings-e2e-vertical-slice-closeout tags: [vertical-slice, letter, settings, save-lifecycle, offline-catchup, playwright-e2e, compost-toast, mvp, wave-2] # Dependency graph requires: - phase: 02-01 provides: Zustand store + V1Payload extension fields + tick scheduler (drainTicks/computeOfflineCatchup) + save lifecycle hooks (registerSaveLifecycleHooks) + Phaser EventBus singleton - phase: 02-02 provides: sim/garden core + Garden Phaser scene (clock-via-window-slot read pattern) + BeginScreen + audio bootstrap + UI strings - phase: 02-03 provides: 17 Season-1 fragments + sim/memory selector + harvest/compost commands + Memory Journal + JournalIcon (D-23 first-harvest gate) - phase: 02-04 provides: inklecate compile pipeline + 4 authored Ink files + ink-loader (loadInkStory + INK_VARIABLE_MAP) + InkRenderer drip + LuraDialogue overlay + gate-renderer provides: - sim/offline module — OfflineEventBlockSchema (Zod) + EMPTY_OFFLINE_EVENTS + aggregateOfflineEvent pure aggregator (CONTEXT D-19) - sim/garden/auto-harvest — autoHarvestReadyPlants silent-mode harvest branch (D-10) reusing the standard harvest() pipeline so selector + Pitfall 10 unlocks + STRY-10 Lura gate run identically; BLOCKER 3 invariant preserved (no lastTickAt writes) - simulateOneTick silent mode — ctx.silent triggers auto-harvest sweep before tick increment; active-play path unchanged (silent defaults false) - content/dialogue/season1/letter-from-the-garden.ink — authored Ink 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 (D-11) - ink-loader extended — loadInkStory union accepts 'letter-from-the-garden' (separate letterStoryGlob for lazy code-split chunk); INK_VARIABLE_MAP gains plants_bloomed / fragment_titles / lura_was_here slots reading from session.pendingLetterEventBlock - src/save/payload.ts — buildPayloadFromStore(state, nowMs) + hydrateStoreFromPayload(state, payload). Two-arg signature (W2 fix) unifies Settings.tsx and PhaserGame.tsx saveSync without arity divergence. - src/ui/letter/Letter.tsx — D-20 full-screen DOM overlay (UX-02). Loads compiled letter Ink, binds slots from offlineEvents, dismisses via Tend the garden button or backdrop click. Pitfall 9 — synchronous-inside-click bootstrapAudioContext call. - src/ui/letter/letter-renderer.ts — pure buildLetterSlots helper (testable without happy-dom + Ink runtime). - src/ui/settings/Settings.tsx — D-28 save-management modal (Export to Base64 / Import / Restore previous snapshot). BLOCKER 2 — Import pipeline is importFromBase64 → unwrap → migrate → hydrate. - src/ui/settings/persistence-toast.tsx — D-30 one-time soft toast in voice when navigator.storage.persist() denies. Reads showPersistenceToast transient flag from session slice; sets persistenceToastShown=true after timeout. - src/ui/settings/compost-toast.tsx — D-07 + GARD-04 thin transient compost beat toast (Plan 02-04 deferral). Cycles through uiStrings.post_harvest_beat lines on each compost dispatch; fades after 3.5s. - PhaserGame.tsx full boot path rewrite — clock selection (?devtime=fake, production-guarded), save load (BLOCKER 1: unwrap → migrate), silent offline catchup via drainTicks(silent=true), letter overlay open at ≥5min absence, requestPersistence + showPersistenceToast wiring, Phaser start AFTER hydration, registerSaveLifecycleHooks with synchronous LocalStorage saveSync (Pitfall 7) + best-effort IDB write. W5 — lifecycle handle held in ref so outer cleanup detaches. - App.tsx — mounts Letter, Settings, PersistenceToast, CompostToast, SettingsIcon (corner button); D-29 keyboard shortcuts (',' toggles Settings, 'j' toggles Journal via window event). - tests/e2e/season1-loop.spec.ts — Playwright PIPE-07 smoke covering load → Begin → plant → fast-forward → harvest → reveal → journal → reload → persist. Sidesteps Phaser canvas pixel-clicking via window.__tlgStore command dispatch (production-guarded). - playwright.config.ts — pinned port 5273 + --strictPort to avoid dev-server collisions; reuseExistingServer false; webServer timeout bumped 30s → 60s. - src/content/loader.ts — gray-matter replaced with parseFrontmatter (15-line regex-based YAML frontmatter splitter). Rule 3 — Blocking auto-fix: gray-matter pulls in Node Buffer global which is undefined in the browser; the build emitted a 'Module buffer externalized' warning that masked the runtime ReferenceError surfacing only in real browsers (caught by the e2e). Bundle size dropped 2.2MB → 1.9MB as a side effect. - PIPE-07 SATISFIED — full Phase-2 vertical slice exercised end-to-end in a real Chromium build under FakeClock injection. affects: [/gsd-verify-work (Phase 2 verification consumes this plan's e2e + SUMMARY for sign-off), Phase 3 (Watercolor & Cello — paints over the working loop)] # Tech tracking tech-stack: added: [] patterns: - "Boot path as the binding layer (src/PhaserGame.tsx): clock selection → save load → unwrap → migrate → hydrate → silent offline catchup → maybe-open-letter → start Phaser → register save lifecycle hooks. Two useLayoutEffect blocks; lifecycle handle held in a ref so the outer cleanup can detach across the async IIFE boundary (W5)." - "Silent-mode simulate (D-10): ctx.silent flips on for the offline catchup loop; simulateOneTick auto-harvests every ready-stage tile via autoHarvestReadyPlants. The harvest pipeline is reused identically — selector + Pitfall 10 unlocks + STRY-10 Lura gate all run; the only difference is who initiates (sim vs. player command)." - "OfflineEventBlock as the letter's slot vocabulary (D-17/D-19): the silent catchup accumulates plantsBloomedCount + harvestedFragmentIds + luraBeatPending; buildLetterSlots converts to Ink VAR slots; letter Ink renders the authored skeleton. Pure data flow; no Date.now leaks." - "Save-payload helpers extracted to src/save/payload.ts (W2 fix): single source of truth for buildPayloadFromStore(state, nowMs) + hydrateStoreFromPayload(state, payload). Two-arg signature lets PhaserGame's saveSync pass clock.now() and Settings.tsx pass Date.now() — same shape, different value, BLOCKER 3 invariant preserved (lastTickAt is wall-clock ms, owned by the application layer)." - "Test-only window slots (__tlgStore + __tlgFakeClock + __tlgClock) gated by import.meta.env.PROD. Production builds silently ignore the ?devtime=fake URL flag; the slots themselves are never assigned. Playwright e2e exploits this to dispatch sim commands without pixel-precise canvas clicks (which Phaser doesn't make easy in headless)." - "Compost toast as a thin transient surface (Plan 02-04 deferral): bumpCompostBeat monotonic counter in session slice → CompostToast watches the tick value via useEffect → cycles through uiStrings.post_harvest_beat lines. The Ink-authored richer voice in compost-acknowledgements.ink stays compiled + runtime-loadable for Phase 4+ to swap in if branching is needed." - "Frontmatter parsing without gray-matter (Rule 3 auto-fix): 15-line parseFrontmatter regex handles the strict '------' shape under Vite's browser bundle without pulling in Node Buffer global. Bundle dropped 2.2MB → 1.9MB." key-files: created: - src/sim/offline/events.ts (OfflineEventBlockSchema + EMPTY_OFFLINE_EVENTS + aggregateOfflineEvent — D-19) - src/sim/offline/events.test.ts (14 tests covering schema acceptance/rejection + aggregator immutability) - src/sim/offline/index.ts (barrel) - src/sim/garden/auto-harvest.ts (autoHarvestReadyPlants — D-10 silent-mode harvest) - src/sim/garden/auto-harvest.test.ts (7 tests — single/multi-harvest, immature exclusion, BLOCKER 3 lastTickAt invariant, Lura gate threading) - content/dialogue/season1/letter-from-the-garden.ink (authored letter Ink with VAR plants_bloomed / fragment_titles / lura_was_here) - src/save/payload.ts (buildPayloadFromStore + hydrateStoreFromPayload shared helpers) - src/ui/letter/Letter.tsx (D-20 full-screen overlay — loads letter Ink + binds slots + Pitfall 9 audio bootstrap on dismiss) - src/ui/letter/Letter.test.tsx (7 tests — null-when-closed, dialog mounts, dismiss bootstraps audio + dismisses Begin gate, click-on-article does NOT dismiss, calls loadInkStory + ChoosePathString correctly) - src/ui/letter/letter-renderer.ts (buildLetterSlots pure helper) - src/ui/letter/letter-renderer.test.ts (10 tests — empty / single / multi / long-line slug fallback / missing-fragment fallback / lura_was_here flag / 50-bloom edge / zero-bloom path) - src/ui/letter/index.ts (barrel) - src/ui/settings/Settings.tsx (D-28 save-management modal) - src/ui/settings/Settings.test.tsx (6 tests — null-when-closed, all 4 buttons mount, Close fires onClose, Export populates textarea + status, Import on bad payload shows soft error, Export→Import round-trip) - src/ui/settings/persistence-toast.tsx (D-30 one-time soft toast) - src/ui/settings/compost-toast.tsx (D-07 transient compost beat toast — Plan 02-04 deferral) - src/ui/settings/compost-toast.test.tsx (4 tests — null at initial state, appears on bump, fades after timeout, re-fires on second bump) - src/ui/settings/index.ts (barrel) - tests/e2e/season1-loop.spec.ts (Playwright PIPE-07 full-loop smoke) - .planning/phases/02-season-1-vertical-slice-soil/deferred-items.md (gray-matter package.json cleanup tracked) modified: - src/sim/garden/commands.ts (SimContext extended with `silent?: boolean`; simulateOneTick calls autoHarvestReadyPlants when ctx.silent; benign circular import with auto-harvest.ts is ESM-safe — neither needs the other at module-init time) - src/sim/garden/index.ts (re-export autoHarvestReadyPlants) - src/sim/index.ts (re-export ./offline) - src/content/ink-loader.ts (extended union with 'letter-from-the-garden'; separate letterStoryGlob for lazy code-split; INK_VARIABLE_MAP gains plants_bloomed / fragment_titles / lura_was_here) - src/save/index.ts (re-export buildPayloadFromStore + hydrateStoreFromPayload) - src/store/session-slice.ts (showPersistenceToast + setShowPersistenceToast + compostBeatTick + bumpCompostBeat) - src/ui/index.ts (re-export ./letter and ./settings) - src/ui/journal/journal-icon.tsx (window 'tlg:toggle-journal' CustomEvent listener for D-29 'j' hotkey) - src/PhaserGame.tsx (full boot path rewrite — clock selection + save load + silent catchup + lifecycle hooks) - src/game/scenes/Garden.ts (formalized clock read via readClockSlot helper; compost branch calls bumpCompostBeat) - src/App.tsx (mounts Letter, Settings, PersistenceToast, CompostToast, SettingsIcon; D-29 keyboard shortcuts) - src/content/loader.ts (gray-matter replaced with parseFrontmatter; Rule 3 blocking-issue auto-fix) - playwright.config.ts (port 5273 + strictPort; reuseExistingServer false; webServer timeout 60s) - package.json (test:e2e script) removed: [] key-decisions: - "URL-flag FakeClock injection landed cleanly first-try via window.__tlgClock + __tlgFakeClock + __tlgStore slots, all gated by import.meta.env.PROD. Production builds silently ignore ?devtime=fake. Verified by Playwright running successfully with the flag and structurally by the production guard in PhaserGame.tsx's first useLayoutEffect." - "Compost-beat UI wired as a thin transient toast (CompostToast) rather than the full Ink runtime surface. Implementation choice surfaced per the plan's must_have: minimum-viable bias keeps Phase 2 closing tight; the Ink-authored compost-acknowledgements.ink content stays compiled + runtime-loadable so Phase 4+ can swap in richer voice without touching sim or store." - "Save-payload helpers extracted to src/save/payload.ts (W2 fix). Two-arg signature buildPayloadFromStore(state, nowMs) unifies Settings.tsx (passes Date.now()) and PhaserGame.tsx saveSync (passes clock.now()) without arity divergence. BLOCKER 3 — lastTickAt is the wall-clock anchor; the application layer owns the value." - "5-minute absence threshold (D-20) lives as ABSENCE_LETTER_THRESHOLD_MS in src/PhaserGame.tsx (line ~76 of the constants block, exported via grep-able literal). Below 5min: silent resume, no overlay. ≥5min: letter Ink loads + slots bind + overlay opens. Verified by structural code review; the e2e exercises the <5min path implicitly (the spec's reload happens in <1s wall-clock so the overlay does NOT fire on returning-player reload — fragment persistence is what we assert there)." - "Compost-beat compostBeatTick is a monotonic counter (vs. boolean) so consecutive composts re-fire the toast without dedup. Boolean would have required a manual reset after the timeout; the counter pattern is simpler + matches React's useEffect dep-array semantics for re-firing on every change." - "Silent-mode auto-harvest reuses the standard harvest() pipeline (vs. duplicating the selector + unlock logic). The cycle (auto-harvest.ts imports harvest from commands.ts; commands.ts imports autoHarvestReadyPlants from auto-harvest.ts) is benign in ESM — neither function references the other at module-init time. Verified empirically by all 312 tests passing." - "gray-matter package.json entry left in place as a deferred-items cleanup task. The dep is no longer imported anywhere under src/ but removing it is a separate maintenance commit (out of Plan 02-05 scope, which only auto-fixed the runtime block)." - "Playwright dev port pinned to 5273 (not the 5173 default) because the user's machine has another Vite project bound to 5173 (Apothecary). reuseExistingServer: false ensures the spec always launches a fresh Vite against this project's vite.config.ts. --strictPort makes a port collision fail loudly rather than silently latching onto another app." patterns-established: - "Boot path = the binding layer pattern. src/PhaserGame.tsx is the only place where save layer + scheduler + sim + store + Phaser all meet. It runs synchronously inside a useLayoutEffect (the async IIFE inside is for the await pattern only). Reusable for Phase 4+ Season-transition save-on-prestige logic." - "Silent-mode simulate (D-10) — pure boolean flag on SimContext that flips behavior without changing function signatures. Reusable for Phase 4+ Memory Storms (Season 4) which may want a 'storm-tick' branch of similar shape." - "Test-only window slots gated by import.meta.env.PROD. Reusable for Phase 8's visual-regression toolkit (which may want to expose render-tier internals to a test harness without polluting production builds)." - "Thin transient toast pattern (CompostToast / PersistenceToast): tick counter or boolean in the session slice → component watches via useEffect → renders for a few seconds → fades. Reusable for any Phase 4+ Season-transition acknowledgement, Memory Storm warning, etc." - "Frontmatter parsing without gray-matter (parseFrontmatter): 15-line regex handles strict YAML frontmatter under Vite's browser bundle. Reusable anywhere a project wants Markdown-with-frontmatter content without pulling in Node Buffer global." requirements-completed: [UX-02, UX-10, CORE-03, CORE-11, PIPE-07, GARD-02, GARD-04] # Metrics duration: 20min completed: 2026-05-09 --- # Phase 2 Plan 05: Letter, Settings, Save Lifecycle, e2e Summary ## One-liner Phase 2 closes — sim/offline + auto-harvest silent-mode branch (D-10), letter-from-the-garden Ink (UX-02 with the slot vocabulary plants_bloomed/fragment_titles/lura_was_here populated from offlineEvents), full-screen Letter overlay (D-20 with Pitfall 9 audio bootstrap on dismiss), Settings save-management UI (D-28 Export/Import/Restore with BLOCKER 2 unwrap→migrate pipeline), persistence-result toast (D-30) and a thin compost-beat toast (Plan 02-04 deferral), full PhaserGame.tsx boot path rewrite wiring clock selection (URL-flag FakeClock injection production-guarded by import.meta.env.PROD) + save lifecycle (UX-10) + offline catchup, and the Playwright PIPE-07 spec exercising the entire authored loop end-to-end (load → Begin → plant → fast-forward → harvest → reveal → journal → reload → persist). The Phase-2 vertical slice could plausibly ship as a free standalone Season-1 prologue. ## Performance - **Duration:** ~20 min (sequential executor) - **Started:** 2026-05-09T14:44:16Z - **Completed:** 2026-05-09T15:08:00Z (approximate; this commit fires) - **Tasks:** 3 main + 1 deferral-fold-in (compost toast) - **Files created:** 19 (incl. tests + .ink + barrel files) - **Files modified:** 14 ## Task Commits Each task was committed atomically: 1. **Task 1: sim/offline + auto-harvest + letter Ink + letter-renderer** — `26eb77a` (feat) 2. **Task 2: Letter overlay + Settings UI + boot save lifecycle + clock injection** — `5d58d6c` (feat) 3. **Task 3: Playwright e2e for PIPE-07 — full Phase-2 loop** — `dd48696` (test) 4. **Compost beat toast wiring (Plan 02-04 deferral)** — `31f8ede` (feat) **Plan metadata:** _(this commit)_ — `docs(02-05): complete letter-settings-e2e plan` ## Accomplishments - **Phase 2 vertical slice closed end-to-end on real authored content + real save round-trip + real offline catchup.** A player can launch, plant rosemary, watch it grow, harvest a Season-1 fragment authored in voice, see it filed in the Memory Journal, meet Lura at the gate (Plan 02-04), close the tab, return ≥5min later, see the letter from the garden in voice, dismiss to the live garden — and everything persists across reload. - **Banner Concern 4 (system-clock cheating) defended at every layer.** The boot path's computeOfflineCatchup clamps elapsed ms at MAX_OFFLINE_MS (24h); drainTicks refuses negative deltas; STRY-10 narrative gating counts harvest events not wall time (Plan 02-04); the ESLint sim-purity rule (Plan 02-01 Block 3) prevents Date.now/setInterval inside src/sim/. Plan 02-05 inherits all of these and adds nothing that breaks them. - **PIPE-07 PASSES.** Playwright spec runs in 1.5s test-runtime, 4s end-to-end including dev-server cold start, well under the <30s budget. The spec is the canonical proof that Phase 2 is shippable: it actually loads the dev build in Chromium, dispatches sim commands, exercises the full loop, and asserts persistence. - **24/24 Phase-2 REQ-IDs structurally satisfied across the 5-plan set.** See the table at the end of this summary; every requirement has a plan that owned it and a SUMMARY documenting the satisfaction. - **Bundle size DROPPED.** Removing gray-matter (Rule 3 auto-fix during the e2e) brought the entry chunk from 2.2MB → 1.9MB without changing any feature surface. The Markdown loader path now uses a 15-line parseFrontmatter regex helper. ## Files Created/Modified See frontmatter `key-files` for the full list (19 created + 14 modified). ## Decisions Made See `key-decisions` in frontmatter (8 entries). Headlines: 1. URL-flag FakeClock injection landed cleanly first-try, production-guarded by `import.meta.env.PROD`. 2. Compost-beat UI wired as a thin transient toast (CompostToast) — minimum-viable; Ink runtime path stays available for Phase 4+ to swap in richer voice. 3. Save-payload helpers extracted to `src/save/payload.ts` (W2) — two-arg `(state, nowMs)` signature unifies Settings.tsx (passes `Date.now()`) and PhaserGame.tsx saveSync (passes `clock.now()`). 4. 5-minute absence threshold lives as `ABSENCE_LETTER_THRESHOLD_MS` constant (CONTEXT D-20). 5. `compostBeatTick` is a monotonic counter (not boolean) so consecutive composts re-fire the toast without dedup. 6. Silent-mode auto-harvest reuses the standard `harvest()` pipeline; the benign ESM circular import is verified by all 312 tests passing. 7. `gray-matter` package.json entry left in `package.json` for a separate cleanup commit (deferred-items.md tracks it). 8. Playwright dev port pinned to 5273 + `--strictPort` to avoid collisions with another Vite project on the user's machine. ## Compost-Beat UI Wiring Approach **Chosen: thin transient CompostToast** (`src/ui/settings/compost-toast.tsx`) reading from `uiStrings[1].post_harvest_beat` (3 short authored lines that rotate per compost). **Trade-off vs. Ink runtime path**: Phase 2 is closing tight; the user has been pushing back on ceremony. The Ink-authored richer voice in `content/dialogue/season1/compost-acknowledgements.ink` (6 short lines in the gardener-keeper voice, branched on `fragment_count`) IS: - Compiled to JSON at every build (`npm run compile:ink` emits 5 .ink.json files now: 4 Lura + 1 letter; the compost compile output is also there). - Runtime-loadable via `loadInkStory('compost-acknowledgements')` which Plan 02-04 wired. - Sitting at the wiring point — `src/ui/settings/compost-toast.tsx` could be replaced wholesale with an Ink-driven component without touching the sim, store, or App.tsx mount. The thin-toast surface satisfies D-07 (post-harvest acknowledgement beat) + GARD-04 (compost yields a tonal beat) for Phase 2's minimum-viable closeout. Phase 4+ may upgrade to the Ink runtime path if playtest demands richer voice. ## URL-Flag FakeClock Injection — Verification **Landed cleanly first-try.** No iteration was needed on the production-guard or the slot-exposure mechanics. Verification: - `window.__tlgFakeClock` and `window.__tlgStore` are written ONLY when `!isProd && devtime === 'fake'`. The production guard reads `import.meta.env.PROD` (Vite injects `true` for `vite build`, `false` for `vite dev`). - Playwright spec uses `?devtime=fake` → both slots become available → spec dispatches `enqueueCommand` directly via `__tlgStore.getState().enqueueCommand({...})` and advances time via `__tlgFakeClock.advance(ms)`. - Garden scene reads the clock via `readClockSlot()` which falls back to `wallClock` if no slot is set (covers the production code path + the unit-test path that instantiates the scene without going through `PhaserGame.tsx`). ## Playwright Run Time - **Test runtime:** 1.5s (single spec, single test, single browser). - **End-to-end including dev-server cold start:** ~4s. - **Goal:** <30s per VALIDATION.md sampling rate row. Achieved with significant headroom. ## Manual Smoke Test Confirmation **Not performed in this execution session** (sequential automated executor; user has not yet run `npm run dev`). Structural verification is comprehensive: - 312/312 Vitest cases green (was 264 before this plan; +48 new — 14 sim/offline + 7 sim/garden auto-harvest + 10 letter-renderer + 7 Letter + 6 Settings + 4 CompostToast). - `npm run lint` exits 0 (zero ESLint sim-purity violations; sim/offline + sim/garden/auto-harvest contain zero Date.now / setInterval). - `npm run compile:ink` emits 5 .ink.json files (Plan 02-04's 4 + this plan's letter). - `npm run build` exits 0; entry bundle 1.9MB (down from 2.2MB after gray-matter removal); Vite emits 5 lazy code-split chunks for the compiled Ink. - `npm run check:bundle-split` exits 0 (PIPE-02 OK — Season-1 content reachable via build output). - `npm run ci` exits 0 end-to-end with all six gates green. - `npx playwright test tests/e2e/season1-loop.spec.ts` exits 0 in 4s. The Plan 02-05 Playwright e2e IS the manual-smoke-equivalent for the active-play loop end-to-end. The user can run `npm run dev` to drive it interactively at any point. ## Final Tally — All 24 Phase-2 REQ-IDs | REQ-ID | Plan | Status | |--------|------|--------| | CORE-02 | 02-01 (drainTicks fixed-timestep) + 02-02 (Garden update loop) | ✓ | | CORE-03 | 02-01 (computeOfflineCatchup 24h cap) + 02-05 (boot path threads it) | ✓ | | CORE-11 | 02-01 (drainTicks negative refusal) | ✓ | | GARD-01 | 02-02 (plantSeed + SeedPicker) | ✓ | | GARD-02 | 02-02 (growth state machine) + 02-05 (PIPE-07 verifies save round-trip) | ✓ | | GARD-03 | 02-03 (harvest + reveal modal) | ✓ | | GARD-04 | 02-03 (compost command) + 02-04 (compost.ink content) + 02-05 (CompostToast wired) | ✓ | | MEMR-01 | 02-03 (selector returns exactly one fragment per harvest) | ✓ | | MEMR-02 | 02-03 (17 fragments authored under /content/seasons/01-soil/) | ✓ | | MEMR-03 | 02-03 (FragmentSchema regex enforces stable string ids) | ✓ | | MEMR-04 | 02-03 (Memory Journal modal grouped by Season) | ✓ | | MEMR-05 | 02-03 (DOM-rendered selectable text via `
` + userSelect:'text') | ✓ |
| MEMR-06 | 02-03 (mulberry32-seeded selector + gating + no-dup + sentinel fallback) | ✓ |
| STRY-01 | 02-04 (3 Lura beats authored + LuraDialogue overlay) | ✓ |
| STRY-06 | 02-04 (compile-ink.mjs + 4 Lura beats) + 02-05 (letter Ink uses same pipeline) | ✓ |
| STRY-07 | 02-04 (vacuously satisfied — zero Keeper-spoken lines in Phase-2 .ink files) | ✓ |
| STRY-10 | 02-04 (lura-gate counts harvest events not wall time; FakeClock-24h-no-harvest test) | ✓ |
| AEST-07 | 02-02 (BeginScreen + bootstrapAudioContext synchronous-inside-click) | ✓ |
| UX-01 | 02-02 (Begin no-clutter overlay) + 02-03 (Journal reveals after first harvest) | ✓ |
| UX-02 | **02-05 (Letter overlay loads letter-from-the-garden.ink + binds slots from offlineEvents + Pitfall 9 audio bootstrap)** | ✓ |
| UX-10 | 02-01 (registerSaveLifecycleHooks + saveOnSeasonTransition) + 02-05 (PhaserGame.tsx boot wiring) | ✓ |
| UX-11 | 02-01 (formatHumanReadable / BigQty.format K/M/B/T/scientific) | ✓ |
| PIPE-02 | 02-02 (loadSeasonFragments lazy surface) + 02-03 (check-bundle-split.mjs structural verifier) | ✓ |
| PIPE-07 | **02-05 (Playwright e2e — full Phase-2 loop end-to-end in Chromium)** | ✓ |

**24 / 24 covered.**

## Total Test Count Across Phase 1 + Phase 2

- Phase 1 baseline: 53 tests
- Plan 02-01 (Wave 0): +75 (≈) → 128
- Plan 02-02 (Wave 1): +35 → 163
- Plan 02-03 (Wave 1): +54 → 217
- Plan 02-04 (Wave 2): +47 → 264
- **Plan 02-05 (Wave 2): +48 → 312**

312/312 tests green; 39 test files. `npm run ci` runs all of them in ~5s on this machine (Vitest only; Playwright is not in `ci` per minimum-viable doctrine — runs separately via `npm run test:e2e` before /gsd-verify-work and on release).

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 — Blocking] gray-matter pulls in Node Buffer global which is undefined under Vite's browser bundle**

- **Found during:** Task 3 — running the Playwright e2e for the first time. Vite dev mode surfaced `ReferenceError: Buffer is not defined` from `gray-matter/lib/utils.js`. The `vite build` step had been emitting a `Module "buffer" has been externalized for browser compatibility` warning since Plan 02-03 shipped; the warning masked a real runtime error that surfaces only in real browsers (Vitest + happy-dom never exercised the Markdown loader path because the existing tests use the test-only `loadFragmentsFromGlob` helper with mocked input).
- **Issue:** The Markdown fragment loader (lura-first-letter.md, winter-rose-night.md from Plan 02-03) was effectively broken in production browsers since its initial commit. Players running the dev or production build would have seen the React app crash at module-eval time when `loadMdFragments()` ran inside `src/content/loader.ts`.
- **Fix:** Replaced `gray-matter` with a 15-line `parseFrontmatter` regex helper in `src/content/loader.ts`. Handles the strict `------` shape the .md files use; anything else falls through cleanly. No new dependencies; the existing `yaml` package already does the YAML parse.
- **Files modified:** src/content/loader.ts
- **Verification:** `npm run dev` no longer throws Buffer ReferenceError; Playwright e2e plant→harvest→reveal round-trip works end-to-end; bundle size dropped 2.2MB → 1.9MB as a tree-shake side effect; 13 content tests still green.
- **Committed in:** dd48696 (Task 3)
- **Deferred follow-up:** `gray-matter` package.json entry could be removed in a maintenance commit (no code references it). Tracked in `.planning/phases/02-season-1-vertical-slice-soil/deferred-items.md`.

### Tightenings (within plan author's discretion)

1. **Compost-beat UI wired as a CompostToast** with 4 dedicated tests (`src/ui/settings/compost-toast.test.tsx`). The plan said "implementation choice surfaced in SUMMARY"; chose the minimum-viable thin-toast surface to keep Phase 2 closing tight. Surface choice documented in this SUMMARY's Compost-Beat UI Wiring Approach section above.
2. **Playwright dev port + strictPort** — pinned to 5273 (not the default 5173) because the user's machine has another Vite project bound to 5173. Documented in playwright.config.ts comment block.
3. **Boot path's two-stage Phaser start** — start Phaser AFTER state hydration so the Garden scene's create() reads the correct initial tickCount + tiles. The plan's draft sketched this; the implementation formalized it as the canonical ordering (await save load → hydrate → start Phaser → register lifecycle hooks).

## Issues Encountered

The gray-matter Buffer issue was the only substantive friction point. Beyond that, the plan was unusually well-specified — the 4 commits (3 main tasks + 1 compost-toast wiring) implemented as drafted with only minor cosmetic adjustments (e.g., `vi.hoisted` for the bootstrapSpy in Letter.test.tsx since Vitest hoists vi.mock factories above imports).

## TDD Gate Compliance

This plan is `type: execute`, not `type: tdd`. No RED → GREEN → REFACTOR commit-sequence gating applies. Tests landed alongside implementation in Tasks 1–3 + the compost-toast follow-up.

## User Setup Required

None — no external service configuration required. All work is in-tree TypeScript / authored content / a single Playwright spec.

## Phase 2 Readiness for Verification

- Phase 2's 5 plans are all complete:
  - 02-01-foundations (Wave 0) — DONE
  - 02-02-begin-plant-grow (Wave 1) — DONE
  - 02-03-harvest-journal-fragments (Wave 1) — DONE
  - 02-04-lura-gate-beats (Wave 2) — DONE
  - **02-05-letter-settings-e2e (Wave 2) — DONE (this commit)**
- All 24 Phase-2 REQ-IDs satisfied across the 5-plan set; the table above maps each.
- `npm run ci` exits 0 (lint + compile:ink + 312/312 vitest + validate:assets + build + check:bundle-split).
- `npm run test:e2e` exits 0 (Playwright PIPE-07 spec; ~4s end-to-end).
- Phase 1's 53 tests + Phase 2's 259 new tests = 312 total green.
- The vertical slice could plausibly ship as a free standalone Season-1 prologue: a player can launch, plant, grow, harvest, meet Lura, leave, return to a letter, dismiss, and the save round-trip survives all of it. The 7-Season scope risk's defended-by-an-escape-hatch is realized.

**No blockers, no IOUs, no carried-over technical debt this plan produced** beyond the gray-matter dep cleanup tracked in deferred-items.md.

## Self-Check: PASSED

Verification performed at SUMMARY-write time:

- src/sim/offline/events.ts: FOUND
- src/sim/offline/events.test.ts: FOUND
- src/sim/offline/index.ts: FOUND
- src/sim/garden/auto-harvest.ts: FOUND
- src/sim/garden/auto-harvest.test.ts: FOUND
- content/dialogue/season1/letter-from-the-garden.ink: FOUND
- src/save/payload.ts: FOUND
- src/ui/letter/Letter.tsx: FOUND
- src/ui/letter/Letter.test.tsx: FOUND
- src/ui/letter/letter-renderer.ts: FOUND
- src/ui/letter/letter-renderer.test.ts: FOUND
- src/ui/letter/index.ts: FOUND
- src/ui/settings/Settings.tsx: FOUND
- src/ui/settings/Settings.test.tsx: FOUND
- src/ui/settings/persistence-toast.tsx: FOUND
- src/ui/settings/compost-toast.tsx: FOUND
- src/ui/settings/compost-toast.test.tsx: FOUND
- src/ui/settings/index.ts: FOUND
- tests/e2e/season1-loop.spec.ts: FOUND
- .planning/phases/02-season-1-vertical-slice-soil/deferred-items.md: FOUND
- Commit 26eb77a (Task 1 — sim/offline + auto-harvest + letter Ink + letter-renderer): FOUND in `git log --oneline --all`
- Commit 5d58d6c (Task 2 — Letter overlay + Settings + boot save lifecycle + clock injection): FOUND in `git log --oneline --all`
- Commit dd48696 (Task 3 — Playwright e2e for PIPE-07): FOUND in `git log --oneline --all`
- Commit 31f8ede (compost-toast wiring — Plan 02-04 deferral): FOUND in `git log --oneline --all`
- `npm run ci` exits 0: VERIFIED
- 312/312 vitest tests pass: VERIFIED
- `npx playwright test tests/e2e/season1-loop.spec.ts` exits 0 (1.5s test runtime, ~4s end-to-end): VERIFIED
- ESLint sim-purity rule: zero violations (`npm run lint` exits 0)
- Build: `npm run build` exits 0; entry bundle 1.9MB (down from 2.2MB after gray-matter removal)
- 5 lazy code-split Ink chunks emitted: lura-arrival, lura-mid, lura-farewell, compost-acknowledgements, letter-from-the-garden
- All 24 Phase-2 REQ-IDs structurally satisfied across the 5-plan set