--- phase: 02 plan: 05 type: execute wave: 2 depends_on: [02-01, 02-02, 02-03, 02-04] files_modified: - content/dialogue/season1/letter-from-the-garden.ink - src/sim/offline/events.ts - src/sim/offline/events.test.ts - src/sim/offline/index.ts - src/sim/garden/auto-harvest.ts - src/sim/garden/auto-harvest.test.ts - src/sim/garden/index.ts - src/sim/garden/commands.ts - src/save/migrations.ts - src/save/index.ts - src/ui/letter/Letter.tsx - src/ui/letter/Letter.test.tsx - src/ui/letter/letter-renderer.ts - src/ui/letter/letter-renderer.test.ts - src/ui/letter/index.ts - src/ui/settings/Settings.tsx - src/ui/settings/Settings.test.tsx - src/ui/settings/persistence-toast.tsx - src/ui/settings/index.ts - src/ui/index.ts - src/App.tsx - src/PhaserGame.tsx - src/game/scenes/Garden.ts - tests/e2e/season1-loop.spec.ts - playwright.config.ts autonomous: true requirements: [UX-02, UX-10, CORE-03, CORE-11, PIPE-07, GARD-02, GARD-04] tags: [vertical-slice, letter, settings, save-lifecycle, offline-catchup, playwright-e2e, mvp] must_haves: truths: - "Player who closes the tab and returns ≥5 minutes later sees the full-screen letter overlay; <5 minutes sees no letter (D-20)" - "Letter renders an authored Ink skeleton with templated insertions: plants_bloomed (count), fragment_titles (string), lura_was_here (bool) — populated from offlineEvents block in V1Payload (D-17, D-18, D-19)" - "One tap dismisses letter to live garden (D-20). Dismiss button calls bootstrapAudioContext (Pitfall 9 mitigation)." - "Auto-harvest during offline (D-10): the silent simulate path harvests every plant that ripened; offlineEvents records {plantsBloomedCount, harvestedFragmentIds, luraBeatPending}" - "Auto-harvest in active play does NOT fire — player chooses when to harvest active plants" - "Save lifecycle: visibilitychange→hidden, beforeunload, AND saveOnSeasonTransition() (UX-10) all serialize the current state via wrap+CRC32+IDB-or-LocalStorage" - "Settings menu (D-28): Export to Base64 (CORE-09), Import from Base64 (CORE-09), Restore previous snapshot (CORE-08); no audio sliders, no keyboard nav (Phase 8)" - "Settings access: corner icon + keyboard shortcut (D-29). Persistent." - "Persistence-result toast (D-30): one-time soft toast in voice on first save if denied; nothing if granted. State remembered via settings.persistenceToastShown." - "Boot path: on page load, read save → migrate (still v1) → compute offlineMs via computeOfflineCatchup → if elapsedMs >= 5*60*1000, run silent catch-up loop, fill offlineEvents, open letter overlay" - "URL flag ?devtime=fake injects FakeClock for Playwright; production-guarded (import.meta.env.PROD ignores the flag)" - "Playwright e2e (PIPE-07): load → dismiss begin → plant → fast-forward via window.__tlgFakeClock.advance → harvest → fragment-reveal → close → journal shows fragment → reload page → fragment persists" - "24h offline cap surfaced silently in the letter's voice (D-11); no numeric '28h' copy in any code path" - "compost tonal beat (Plan 02-04 deferral) wires here as a small toast variant or as a tiny one-line render via the dialogue overlay — implementation choice surfaced in SUMMARY" - "All 24 Phase-2 REQ-IDs visibly satisfied across the 5 plans of this phase" artifacts: - path: content/dialogue/season1/letter-from-the-garden.ink provides: "Authored Ink letter skeleton with VAR plants_bloomed, fragment_titles, lura_was_here (D-17, D-18)" - path: src/sim/offline/events.ts provides: "OfflineEventBlockSchema (Zod) + aggregateOfflineEvents(prev, next) — pure" exports: ["OfflineEventBlockSchema", "OfflineEventBlock", "aggregateOfflineEvents"] - path: src/sim/garden/auto-harvest.ts provides: "autoHarvestReadyPlants(state, currentTick, ctx) — silent-mode harvest branch (D-10)" exports: ["autoHarvestReadyPlants"] - path: src/ui/letter/Letter.tsx provides: "Full-screen letter overlay (D-20). Loads compiled letter Ink, binds slots from offlineEvents, renders one-tap-to-dismiss" exports: ["Letter"] - path: src/ui/letter/letter-renderer.ts provides: "Pure template helper: buildLetterSlots(offlineEvents, fragments) → {plants_bloomed, fragment_titles, lura_was_here}" exports: ["buildLetterSlots"] - path: src/ui/settings/Settings.tsx provides: "Settings modal (D-28): Export, Import, Restore. Save-management only — Phase 8 adds audio/a11y." exports: ["Settings"] - path: src/ui/settings/persistence-toast.tsx provides: "PersistenceToast (D-30) — one-time soft toast" exports: ["PersistenceToast"] - path: tests/e2e/season1-loop.spec.ts provides: "Playwright PIPE-07 full-loop smoke" key_links: - from: src/PhaserGame.tsx to: src/save/index.ts via: "Boot path: openSaveDB → unwrap → migrate → drainTicks(silent=true) → if absence>=5min set offlineEvents + openLetter" pattern: "computeOfflineCatchup\\|drainTicks" - from: src/PhaserGame.tsx to: src/save/lifecycle.ts via: "registerSaveLifecycleHooks({saveSync}) — wires visibilitychange + beforeunload" pattern: "registerSaveLifecycleHooks" - from: src/ui/letter/Letter.tsx to: src/content/ink-loader.ts via: "loadInkStory('letter-from-the-garden') + bindGardenStateToInk + buildLetterSlots" pattern: "loadInkStory\\('letter" - from: tests/e2e/season1-loop.spec.ts to: src/sim/scheduler/clock.ts FakeClock via: "page.goto('/?devtime=fake') → window.__tlgFakeClock.advance(...)" pattern: "__tlgFakeClock" --- **Wave 2 closing plan. Depends on Plans 02-01, 02-02, 02-03, 02-04.** This is the integration plan: it ties offline catch-up (D-10), the letter (UX-02), save lifecycle hooks (UX-10), the Settings UI (D-28..D-30), and the Playwright e2e (PIPE-07) together — proving the full Season 1 vertical slice end-to-end. After this plan ships, Phase 2 is functionally complete: a player can launch, plant, grow, harvest, meet Lura, leave the tab for hours, and return to a letter from the garden — and the Playwright e2e proves it persists. 3 tasks. Estimated context cost ~50%. The Playwright spec is the load-bearing closing artifact. Land the Letter-from-the-garden vertical slice + Settings UI + save lifecycle wiring + Playwright e2e (PIPE-07) — the final Phase-2 integration. After return-from-tab-close, the player sees an authored Ink letter (D-17, D-18, UX-02) with templated insertions describing what bloomed while away (auto-harvest per D-10), what Lura did (gate beat queued during absence), and what the wind brought; one tap dismisses to the live garden. Settings menu provides Export / Import / Restore (D-28) plus the in-voice persistence-result toast (D-30). Playwright e2e exercises the entire authored loop: load → begin → plant → fast-forward → harvest → reveal → close → journal-shows-fragment → reload → fragment-persists. Purpose: Closes Phase 2. Validates that the architecture firewall holds end-to-end on real authored content + real save round-trip + real fast-forward via FakeClock injection. The PIPE-07 Playwright spec becomes the canonical proof that Phase 2 ships — `/gsd-verify-work` runs after this plan. Output: A complete, working Phase-2-vertical-slice game that could plausibly ship as a free standalone Season 1 prologue. All 24 Phase-2 REQ-IDs structurally satisfied. `npm run ci && npx playwright test` exits 0. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @CLAUDE.md @.planning/anti-fomo-doctrine.md @.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md @.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md @.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md @.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md @.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-SUMMARY.md @.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-SUMMARY.md @.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-SUMMARY.md From src/sim/scheduler/index.ts (Plan 02-01): ```typescript export type { Clock } from './clock'; export { wallClock, FakeClock } from './clock'; export const TICK_MS: number; // 200 (5Hz) export const MAX_OFFLINE_MS: number; // 24 * 3600 * 1000 export function drainTicks(state: S, accumulatorMs: number, simulate, silent?: boolean): { state, remainderMs, ticksApplied }; export function computeOfflineCatchup(savedLastTickAt: number, nowMs: number): { elapsedMs, cappedMs, willRunCatchup, hitOfflineCap }; ``` From src/save/index.ts (Plan 02-01 extended): ```typescript export { wrap, unwrap } from './envelope'; export { migrate, CURRENT_SCHEMA_VERSION } from './migrations'; export type { V1Payload, OfflineEventBlock } from './migrations'; // OfflineEventBlock TYPE declared in migrations.ts (Plan 02-01); ZOD schema in src/sim/offline/ (this plan) export { exportToBase64, importFromBase64, MAX_IMPORT_BYTES } from './codec'; export { snapshot, listSnapshots } from './snapshots'; export { requestPersistence } from './persist'; export { openSaveDB, LocalStorageDBAdapter } from './db'; export { registerSaveLifecycleHooks, saveOnSeasonTransition } from './lifecycle'; ``` From src/store/index.ts (Plan 02-01): ```typescript session.letterOverlayOpen: boolean; session.pendingLetterEventBlock: unknown | null; openLetter(block: unknown): void; dismissLetter(): void; session.persistenceToastShown: boolean; setPersistenceToastShown(v: boolean): void; ``` From src/sim/garden/commands.ts (Plan 02-03 + 02-04): ```typescript export function harvest(state: SimState, tileIdx: number, currentTick: number, ctx: SimContext): SimState; // ^^ this plan adds an `autoHarvest` branch to simulateOneTick when called with `silent: true` ``` V1Payload offlineEvents shape (Plan 02-01 declared inline): ```typescript export interface OfflineEventBlock { plantsBloomedCount: Record; harvestedFragmentIds: string[]; luraBeatPending: 'arrival' | 'mid' | 'farewell' | null; } ``` From src/ui/dialogue (Plan 02-04): ```typescript export const InkRenderer: React.FC<{ runtime: InkRuntime; onComplete?: () => void }>; export function createInkRuntime(story: Story): InkRuntime; ``` From src/content/ink-loader.ts (Plan 02-04): ```typescript export async function loadInkStory(name: 'lura-arrival' | 'lura-mid' | 'lura-farewell' | 'compost-acknowledgements'): Promise; // ^^ this plan extends to also accept 'letter-from-the-garden' export function bindGardenStateToInk(story: Story, snapshot: AppStoreShape): void; ``` From src/PhaserGame.tsx (Plan 02-02): The boot path currently sets `unlockedPlantTypes: ['rosemary']` if empty, mounts Phaser, listens for scene-ready. THIS PLAN replaces that bootstrap with a real save-load path: read save (if present) → migrate → set initial store state from migrated V1Payload → compute offline → run silent catch-up → maybe open letter. From content/dialogue/season1/ (Plan 02-04 ships 4 .ink files): - lura-arrival.ink, lura-mid.ink, lura-farewell.ink, compost-acknowledgements.ink. THIS PLAN adds: letter-from-the-garden.ink. playwright.config.ts (already shipped Phase 1): ```typescript testDir: 'tests/e2e', use: { baseURL: 'http://localhost:5173' }, webServer: { command: 'npm run dev', url: 'http://localhost:5173', reuseExistingServer: true, timeout: 30000 }, ``` Task 1: sim/offline + auto-harvest + extended ink-loader for letter + letter-from-the-garden.ink authoring - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 6 lines 802-840 letter Ink template, Pitfall 4 line 1057 snake_case, Pitfall 9 line 1110 letter dismiss audio) - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group F lines 350-376 zod schema) - .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-10 auto-harvest, D-11 silent 24h cap, D-17/D-18/D-19/D-20 letter) - src/save/migrations.ts (OfflineEventBlock interface declared in Plan 02-01) - src/sim/garden/commands.ts (Plans 02-03 + 02-04 — harvest + Lura integration) - src/content/ink-loader.ts (Plan 02-04 — extend the union to accept 'letter-from-the-garden') - CLAUDE.md (Tone — letter must read in voice, not stat dump) src/sim/offline/events.ts, src/sim/offline/events.test.ts, src/sim/offline/index.ts, src/sim/garden/auto-harvest.ts, src/sim/garden/auto-harvest.test.ts, src/sim/garden/commands.ts, src/sim/garden/index.ts, src/sim/index.ts, content/dialogue/season1/letter-from-the-garden.ink, src/content/ink-loader.ts, src/ui/letter/letter-renderer.ts, src/ui/letter/letter-renderer.test.ts **Step 1 — `src/sim/offline/events.ts`** — Zod schema + aggregator: ```typescript import { z } from 'zod'; /** * OfflineEventBlock — captures what happened while the player was away. * Per CONTEXT D-19. Phase 2 ships the minimum slot vocabulary; * Phase 4+ may add more if playtest demands. * * Structurally compatible with the OfflineEventBlock interface declared * in src/save/migrations.ts (Plan 02-01); the Zod schema here is the * runtime validator. */ export const OfflineEventBlockSchema = z.object({ plantsBloomedCount: z.record(z.string(), z.number().int().nonnegative()), harvestedFragmentIds: z.array(z.string().regex(/^season\d+\.[a-z0-9._-]+$/)), luraBeatPending: z.enum(['arrival', 'mid', 'farewell']).nullable(), }); export type OfflineEventBlock = z.infer; export const EMPTY_OFFLINE_EVENTS: OfflineEventBlock = Object.freeze({ plantsBloomedCount: {}, harvestedFragmentIds: [], luraBeatPending: null, }); /** * Pure aggregator — combines a previous OfflineEventBlock with a new * (plantTypeId, fragmentId, luraBeatPending?) tuple from a single * silent-mode auto-harvest event. */ export function aggregateOfflineEvent( prev: OfflineEventBlock, plantTypeId: string, fragmentId: string, luraBeatPending: OfflineEventBlock['luraBeatPending'], ): OfflineEventBlock { const counts = { ...prev.plantsBloomedCount }; counts[plantTypeId] = (counts[plantTypeId] ?? 0) + 1; return { plantsBloomedCount: counts, harvestedFragmentIds: [...prev.harvestedFragmentIds, fragmentId], luraBeatPending: luraBeatPending ?? prev.luraBeatPending, }; } ``` **Step 2 — `src/sim/offline/events.test.ts`** — Vitest: - `OfflineEventBlockSchema.parse(EMPTY_OFFLINE_EVENTS)` succeeds. - Schema rejects: missing field, wrong-type field, fragment id with bad regex. - `aggregateOfflineEvent(EMPTY, 'rosemary', 'season1.soil.first-bloom', null)` returns block with plantsBloomedCount.rosemary=1. - Two consecutive aggregates increment counts correctly. - luraBeatPending overwrites only when newer is non-null AND prev was null. **Step 3 — `src/sim/offline/index.ts`:** ```typescript export { OfflineEventBlockSchema, EMPTY_OFFLINE_EVENTS, aggregateOfflineEvent } from './events'; export type { OfflineEventBlock } from './events'; ``` Add `export * from './offline'` to `src/sim/index.ts`. **Step 4 — `src/sim/garden/auto-harvest.ts`** — silent-mode harvest branch (D-10): ```typescript import type { SimState } from '../state'; import type { Tile } from './types'; import { PLANT_TYPES } from './plants'; import { advanceGrowth } from './growth'; import { harvest } from './commands'; import type { SimContext } from './commands'; import { aggregateOfflineEvent } from '../offline/events'; import { EMPTY_OFFLINE_EVENTS } from '../offline/events'; /** * D-10 — auto-harvest during offline. While the player is away, plants * that ripen are auto-harvested, populating the offlineEvents block * that the *letter* will narrate. * * Pure. Called inside drainTicks's silent-mode simulate function. */ export function autoHarvestReadyPlants( state: SimState, currentTick: number, ctx: SimContext, ): SimState { let next = state; const tiles = state.garden.tiles as Tile[]; for (let i = 0; i < tiles.length; i++) { const tile = tiles[i]; if (!tile?.plant) continue; const type = PLANT_TYPES[tile.plant.plantTypeId]; if (!type) continue; const stage = advanceGrowth(tile.plant, type, currentTick); if (stage !== 'ready') continue; // Snapshot fields we'll need to populate offlineEvents const harvestedBefore = next.harvestedFragmentIds.length; const plantTypeId = tile.plant.plantTypeId; // Reuse the standard harvest pipeline (selector + plant-unlock + Lura gate) next = harvest(next, i, currentTick, ctx); // If a fragment was actually selected, append to offline events if (next.harvestedFragmentIds.length > harvestedBefore) { const newId = next.harvestedFragmentIds[next.harvestedFragmentIds.length - 1]; const luraPending = next.luraBeatProgress.pending; const prevEvents = (next.offlineEvents as ReturnType | null) ?? EMPTY_OFFLINE_EVENTS; next = { ...next, offlineEvents: aggregateOfflineEvent(prevEvents, plantTypeId, newId, luraPending), }; } } return next; } ``` **Step 5 — `src/sim/garden/auto-harvest.test.ts`** — Vitest: - A 4×4 garden with 2 ready rosemary plants → autoHarvestReadyPlants returns state with both tiles cleared, offlineEvents.plantsBloomedCount.rosemary=2, harvestedFragmentIds.length grew by 2. - An immature plant is NOT auto-harvested. - An empty tile is a no-op. - After auto-harvest crosses the 1-fragment threshold, offlineEvents.luraBeatPending === 'arrival'. **Step 6 — Update `simulateOneTick`** in `src/sim/garden/commands.ts` to call `autoHarvestReadyPlants` when called in silent mode: In Plan 02-03, `simulateOneTick` accepted `(state, currentTick, commands, ctx)`. Add a 5th argument `silent: boolean` (or pass via ctx). Simpler: add `silent` to ctx: ```typescript export interface SimContext { fragments: readonly Fragment[]; currentSeason: number; silent?: boolean; // when true, simulateOneTick auto-harvests ready plants (D-10) } export function simulateOneTick(state, currentTick, commands, ctx): SimState { let next = state; for (const cmd of commands) { /* ... existing cases ... */ } if (ctx.silent) { next = autoHarvestReadyPlants(next, currentTick, ctx); } return { ...next, lastTickAt: currentTick }; } ``` Update `src/sim/garden/index.ts`: ```typescript export { autoHarvestReadyPlants } from './auto-harvest'; ``` **Step 7 — Author `content/dialogue/season1/letter-from-the-garden.ink`** (RESEARCH Pattern 6): ```ink // Letter from the garden — UX-02 + D-17 + D-18. // Composed from authored skeleton + templated insertions per CONTEXT D-17. // Slots populated at runtime from sim/offline/events.ts via the variable // map in src/content/ink-loader.ts. // // Per Pitfall 4: variable names are snake_case AND case-sensitive. // Per CONTEXT D-11: 24h offline cap is silent in voice — no numeric "28h" copy. // // The skeleton MUST read like authored fiction (CLAUDE.md Tone). The // slots fill in the specifics. VAR plants_bloomed = 0 VAR fragment_titles = "" VAR lura_was_here = false VAR fragment_count = 0 VAR last_plant_type = "" == letter == The garden held its breath while you were gone. { plants_bloomed > 1: {plants_bloomed} blooms came and went, each leaving the soil a little quieter than they found it. - else: { plants_bloomed == 1: One bloom came and went. The space it left feels generous, somehow. - else: Nothing bloomed. The wind carried something else, and the garden held that, too. } } { fragment_titles != "": Among what stayed: {fragment_titles}. } { lura_was_here: Lura came by once. She did not knock. She left a folded leaf on the gate post — you'll find it when you next walk past. } The light is the same as when you left. The garden is older. -> END ``` **Step 8 — Update `src/content/ink-loader.ts`** to support the letter: Extend the `loadInkStory` union AND `INK_VARIABLE_MAP`: ```typescript const luraStories = import.meta.glob('/src/content/compiled-ink/season1/lura-*.ink.json', {...}); const compostStory = import.meta.glob('/src/content/compiled-ink/season1/compost-acknowledgements.ink.json', {...}); const letterStory = import.meta.glob('/src/content/compiled-ink/season1/letter-from-the-garden.ink.json', { query: '?raw', import: 'default', }); export const INK_VARIABLE_MAP = { fragment_count: (s) => s.harvestedFragmentIds.length, last_plant_type: (s) => { /* ... unchanged ... */ }, // NEW for letter: plants_bloomed: (s) => { const counts = (s.pendingLetterEventBlock as { plantsBloomedCount?: Record } | null)?.plantsBloomedCount ?? {}; return Object.values(counts).reduce((a, b) => a + b, 0); }, fragment_titles: (s) => { const ids = (s.pendingLetterEventBlock as { harvestedFragmentIds?: string[] } | null)?.harvestedFragmentIds ?? []; if (ids.length === 0) return ''; // Convert IDs to a comma-joined human-friendly list. For Phase 2, slugify the ID's last segment. return ids.map((id) => id.replace(/^season\d+\./, '').replace(/[._-]+/g, ' ')).join(', '); }, lura_was_here: (s) => Boolean((s.pendingLetterEventBlock as { luraBeatPending?: string | null } | null)?.luraBeatPending), } as const; export async function loadInkStory( name: 'lura-arrival' | 'lura-mid' | 'lura-farewell' | 'compost-acknowledgements' | 'letter-from-the-garden', ): Promise { let path: string; let loader; if (name === 'compost-acknowledgements') { path = `/src/content/compiled-ink/season1/${name}.ink.json`; loader = compostStory[path]; } else if (name === 'letter-from-the-garden') { path = `/src/content/compiled-ink/season1/${name}.ink.json`; loader = letterStory[path]; } else { path = `/src/content/compiled-ink/season1/${name}.ink.json`; loader = luraStories[path]; } if (!loader) throw new Error(`[ink-loader] No compiled story at ${path}.`); const json = (await loader()) as string; return new Story(json); } ``` **Step 9 — `src/ui/letter/letter-renderer.ts`** — pure helper for slot building (separate from the React component for testability): ```typescript import type { OfflineEventBlock } from '../../sim/offline'; import type { Fragment } from '../../content'; /** * Build the variable slot values for letter-from-the-garden.ink from * an OfflineEventBlock + the fragment pool (for human-readable titles). * * Pure. Used by Letter.tsx via INK_VARIABLE_MAP at bind time. */ export interface LetterSlots { plants_bloomed: number; fragment_titles: string; lura_was_here: boolean; } export function buildLetterSlots( events: OfflineEventBlock | null, allFragments: readonly Fragment[], ): LetterSlots { if (!events) return { plants_bloomed: 0, fragment_titles: '', lura_was_here: false }; const total = Object.values(events.plantsBloomedCount).reduce((a, b) => a + b, 0); // For human-readable titles: use the fragment id's last segment, slugified to spaces const titles = events.harvestedFragmentIds .map((id) => { const f = allFragments.find((x) => x.id === id); // Prefer the fragment's first sentence (up to 60 chars) for tonal weight; fall back to id slug if (f) { const firstLine = f.body.split(/[.!?]/)[0]?.trim() ?? ''; if (firstLine.length > 0 && firstLine.length <= 60) return firstLine.toLowerCase(); } return id.replace(/^season\d+\./, '').replace(/[._-]+/g, ' '); }) .filter((t) => t.length > 0); return { plants_bloomed: total, fragment_titles: titles.join('; '), lura_was_here: events.luraBeatPending !== null, }; } ``` **Step 10 — `src/ui/letter/letter-renderer.test.ts`** — Vitest: - Empty events → all zeros / empty / false. - Single rosemary auto-harvest → plants_bloomed=1, fragment_titles uses the fragment's first-sentence slug. - luraBeatPending='arrival' → lura_was_here=true. - 24h cap edge case: 50 plants bloomed → plants_bloomed=50 (no truncation; the Ink template handles "many" copy). **Commit:** `feat(02-05): sim/offline + auto-harvest + letter Ink + letter-renderer`. Run `npm run lint && npm run compile:ink && npx vitest run src/sim/offline/ src/sim/garden/auto-harvest.test.ts src/ui/letter/letter-renderer.test.ts && npm run ci` before committing. - `grep -q "OfflineEventBlockSchema" src/sim/offline/events.ts` - `grep -q "aggregateOfflineEvent" src/sim/offline/events.ts` - `grep -q "autoHarvestReadyPlants" src/sim/garden/auto-harvest.ts` - `grep -q "ctx.silent" src/sim/garden/commands.ts` (silent mode triggers auto-harvest) - `test -f content/dialogue/season1/letter-from-the-garden.ink` - `grep -q "VAR plants_bloomed" content/dialogue/season1/letter-from-the-garden.ink` - `grep -q "VAR lura_was_here" content/dialogue/season1/letter-from-the-garden.ink` - `grep -q "letter-from-the-garden" src/content/ink-loader.ts` (loadInkStory accepts the union case) - `grep -q "buildLetterSlots" src/ui/letter/letter-renderer.ts` - `grep -L "Date.now\\|setInterval" src/sim/offline/events.ts src/sim/garden/auto-harvest.ts` (sim purity) - `npm run compile:ink` produces `src/content/compiled-ink/season1/letter-from-the-garden.ink.json` - `npx vitest run src/sim/offline/ src/sim/garden/auto-harvest.test.ts src/ui/letter/letter-renderer.test.ts` exits 0 - `npm run ci` exits 0 npm run compile:ink && npm run lint && npx vitest run src/sim/offline/ src/sim/garden/auto-harvest.test.ts src/ui/letter/letter-renderer.test.ts && npm run ci sim/offline ships Zod schema + aggregator. autoHarvestReadyPlants extends silent-mode simulate. letter-from-the-garden.ink authored in voice. ink-loader supports the letter. letter-renderer builds slots purely. All sim modules sim-pure. `npm run ci` green. Task 2: Letter overlay + Settings + persistence-toast UIs + boot-path save lifecycle wiring + URL-flag clock injection - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Sim-Clock Injection lines 1377-1387, Open Question 5 lines 1245-1248, AudioContext bootstrap Pattern 9, Pitfall 9 line 1110 letter dismiss audio) - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I React mounting, Group M PhaserGame.tsx hook addition lines 663-690) - .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-20 letter UX, D-28 Settings scope, D-29 access pattern, D-30 toast) - src/PhaserGame.tsx (Plan 02-02 — wire boot path here) - src/save/index.ts (Plan 02-01 — barrel of all save APIs) - src/store/session-slice.ts (Plan 02-01 — letterOverlayOpen, persistenceToastShown) - src/ui/journal/Journal.tsx (analog full-screen modal pattern) - src/ui/dialogue/LuraDialogue.tsx (analog Ink-driven overlay) src/ui/letter/Letter.tsx, src/ui/letter/Letter.test.tsx, src/ui/letter/index.ts, src/ui/settings/Settings.tsx, src/ui/settings/Settings.test.tsx, src/ui/settings/persistence-toast.tsx, src/ui/settings/index.ts, src/ui/index.ts, src/save/migrations.ts, src/PhaserGame.tsx, src/game/scenes/Garden.ts, src/App.tsx **Step 1 — `src/ui/letter/Letter.tsx`** (D-20 + Pitfall 9): ```typescript import { useEffect, useState } from 'react'; import { useAppStore } from '../../store'; import { loadInkStory, fragments as allFragments } from '../../content'; import { createInkRuntime, InkRenderer, type InkRuntime } from '../dialogue'; import { bootstrapAudioContext } from '../begin'; import { buildLetterSlots } from './letter-renderer'; import type { OfflineEventBlock } from '../../sim/offline'; /** * UX-02 + D-20 — Letter from the garden. Full-screen overlay; one tap * dismisses to the live garden. Triggered when absence ≥ 5 minutes * (the threshold check lives in the boot path in src/PhaserGame.tsx). * * Per Pitfall 9: dismiss must call bootstrapAudioContext too — a returning * player who lands directly in the letter would otherwise have no audio * gesture before reaching the live garden. */ export function Letter(): JSX.Element | null { const open = useAppStore((s) => s.letterOverlayOpen); const block = useAppStore((s) => s.pendingLetterEventBlock) as OfflineEventBlock | null; const dismissLetter = useAppStore((s) => s.dismissLetter); const [runtime, setRuntime] = useState(null); useEffect(() => { if (!open) { setRuntime(null); return; } let cancelled = false; (async () => { try { const story = await loadInkStory('letter-from-the-garden'); if (cancelled) return; const slots = buildLetterSlots(block, allFragments); try { story.variablesState['plants_bloomed'] = slots.plants_bloomed; } catch {} try { story.variablesState['fragment_titles'] = slots.fragment_titles; } catch {} try { story.variablesState['lura_was_here'] = slots.lura_was_here; } catch {} story.ChoosePathString('letter'); setRuntime(createInkRuntime(story)); } catch (err) { console.error('[Letter] failed to load', err); dismissLetter(); } })(); return () => { cancelled = true; }; }, [open, block, dismissLetter]); if (!open) return null; const onDismiss = () => { void bootstrapAudioContext(); // Pitfall 9: returning player audio gesture dismissLetter(); }; return (
e.stopPropagation()} style={{ maxWidth: 620, padding: '3rem 2.6rem', cursor: 'default', }} > {runtime ? {}} /> :

...

}
); } ``` **Step 2 — `src/ui/letter/Letter.test.tsx`** — Vitest: - With `letterOverlayOpen: false`, returns null. - With `open: true` and `pendingLetterEventBlock: null`, mounts the dialog (loading state). - Dismiss button click dispatches `dismissLetter()` AND calls `bootstrapAudioContext` (spy). - Click on backdrop dismisses; click on article does NOT. **Step 3 — `src/ui/letter/index.ts`:** ```typescript export { Letter } from './Letter'; export { buildLetterSlots } from './letter-renderer'; export type { LetterSlots } from './letter-renderer'; ``` **Step 4 — `src/ui/settings/Settings.tsx`** (D-28 save-management only): ```typescript import { useState } from 'react'; import { useAppStore } from '../../store'; import { exportToBase64, importFromBase64, listSnapshots, snapshot, openSaveDB, wrap, unwrap, migrate, CURRENT_SCHEMA_VERSION, type V1Payload } from '../../save'; import { uiStrings } from '../../content'; /** * D-28 — Phase 2 Settings UI. Save-management surfaces only. * Audio sliders + keyboard nav + a11y polish ship in Phase 8. */ export function Settings({ open, onClose }: { open: boolean; onClose: () => void }): JSX.Element | null { const strings = uiStrings[1]?.settings; const [base64Buf, setBase64Buf] = useState(''); const [statusLine, setStatusLine] = useState(null); if (!open || !strings) return null; const onExport = async () => { try { // Build a fresh save envelope from current store state. // (Plan 02-05 wires the same payload-build path in src/PhaserGame.tsx for save lifecycle hooks.) const state = useAppStore.getState(); const payload: V1Payload = buildPayloadFromStore(state); const env = wrap(payload, CURRENT_SCHEMA_VERSION); const b64 = await exportToBase64(env); navigator.clipboard?.writeText(b64).catch(() => {}); setBase64Buf(b64); setStatusLine('Saved to clipboard.'); } catch (e) { setStatusLine('Could not save.'); } }; const onImport = async () => { try { const env = await importFromBase64(base64Buf); const { payload } = migrate(env.payload, env.schemaVersion); const v1 = unwrap(env); const restored = v1.payload as V1Payload; // post-migrate // Apply to store hydrateStoreFromPayload(restored); setStatusLine('Restored.'); } catch (e) { setStatusLine('That doesn\'t look like one of yours.'); } }; const onRestoreSnapshot = async () => { try { const db = await openSaveDB(); const snaps = await listSnapshots(db); if (snaps.length === 0) { setStatusLine('Nothing earlier to find.'); return; } // Restore the most-recent snapshot (Phase 2 ships single-action restore; // Phase 8 may add a list selector). const last = snaps[snaps.length - 1]; const payload = unwrap(last.envelope); hydrateStoreFromPayload(payload as V1Payload); setStatusLine('Earlier garden restored.'); } catch (e) { setStatusLine('Nothing earlier could be reached.'); } }; return (

{strings.title}