---
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 (