Files
josh e5d449095d docs(02-05): complete letter-settings-e2e plan
- 02-05-letter-settings-e2e-SUMMARY.md: full plan summary (frontmatter
  + decisions + REQ table + self-check). All 24 Phase-2 REQ-IDs
  structurally satisfied across the 5-plan set.
- STATE.md: marked Plan 02-05 complete; Phase 2 ready for
  /gsd-verify-work; progress 19% → 22%; next action set to verifier.
- ROADMAP.md: Plan 02-05 row marked [x] with duration + SUMMARY ref.
- REQUIREMENTS.md: UX-02 / UX-10 / CORE-03 / PIPE-07 marked complete
  with traceability annotations citing Plan 02-05's contribution;
  per-row Plan 02-05 references added to UX-02, UX-10, CORE-03;
  PIPE-07 traceability table row updated.
2026-05-09 11:16:02 -04:00

327 lines
32 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: 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 '---<yaml>---<body>' 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 `<pre>` + 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 `---<yaml>---<body>` 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 13 + 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