Files
TheLastGarden/.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-PLAN.md
T
josh a641056364 fix(02): plan revision iter 3 — BLOCKER 3 cross-plan regression + W1/W2
BLOCKER 3 — cross-plan regression: Plans 02-03 and 02-05 BOTH re-author
src/sim/garden/commands.ts but had reverted simulateOneTick to the old
defective return shape (`return { ...next, lastTickAt: currentTick };`).
Wave 1's execution of 02-03 would overwrite 02-02's correct version,
breaking the invariant for the entire phase.

  - 02-03: simulateOneTick return now matches 02-02 line 457 exactly:
    `return { ...next, tickCount: next.tickCount + 1 };`
  - 02-05: same fix for the silent-mode update (Step 6).
  - 02-03 acceptance_criteria: add negative grep
    (`! grep -E "lastTickAt:\s*(this|currentTick)" src/sim/garden/commands.ts`)
    and positive grep (`grep -q "tickCount: next.tickCount" ...`).
  - 02-05 acceptance_criteria: add the same two greps for commands.ts so
    02-05's silent-mode edits cannot silently re-introduce the regression.

W1 — App.tsx import: 02-05 Step 11 used `useEffect` without importing it.
Combined `import { useState }` and `import { useRef }` into a single
`import { useState, useEffect, useRef } from 'react';` line.

W2 — helper arity divergence: Settings.tsx (one-arg, Date.now() inline)
and PhaserGame.tsx (two-arg, clock.now() injected) had two parallel
definitions of buildPayloadFromStore / hydrateStoreFromPayload. Fix:

  - New Step 3.5 introduces `src/save/payload.ts` with the unified
    two-arg signature: `buildPayloadFromStore(state, nowMs)` and
    `hydrateStoreFromPayload(state, payload)`.
  - `src/save/index.ts` re-exports both.
  - Settings.tsx imports from save barrel; passes Date.now() at the
    call site (no clock injection on hand).
  - PhaserGame.tsx imports from save barrel; passes clock.now() (the
    injected wallClock or FakeClock).
  - Inline duplicate definitions in both files removed; replaced with
    a comment pointing to the shared module.
  - files_modified updated to include src/save/payload.ts.
  - acceptance_criteria asserts: shared file exists, both helpers
    exported, both consumers import from save barrel, no inline
    duplicate definitions remain.

VALIDATION.md not updated — no `<automated>` verify command changed;
the new greps live inside `<acceptance_criteria>` (executor-checked
per task), and VALIDATION.md is not present in the phase dir.

All iteration-1 + iteration-2 fixes preserved; no regressions.

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

73 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
02 05 execute 2
02-01
02-02
02-03
02-04
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/save/payload.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
true
UX-02
UX-10
CORE-03
CORE-11
PIPE-07
GARD-02
GARD-04
vertical-slice
letter
settings
save-lifecycle
offline-catchup
playwright-e2e
mvp
truths artifacts key_links
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
path provides
content/dialogue/season1/letter-from-the-garden.ink Authored Ink letter skeleton with VAR plants_bloomed, fragment_titles, lura_was_here (D-17, D-18)
path provides exports
src/sim/offline/events.ts OfflineEventBlockSchema (Zod) + aggregateOfflineEvents(prev, next) — pure
OfflineEventBlockSchema
OfflineEventBlock
aggregateOfflineEvents
path provides exports
src/sim/garden/auto-harvest.ts autoHarvestReadyPlants(state, currentTick, ctx) — silent-mode harvest branch (D-10)
autoHarvestReadyPlants
path provides exports
src/ui/letter/Letter.tsx Full-screen letter overlay (D-20). Loads compiled letter Ink, binds slots from offlineEvents, renders one-tap-to-dismiss
Letter
path provides exports
src/ui/letter/letter-renderer.ts Pure template helper: buildLetterSlots(offlineEvents, fragments) → {plants_bloomed, fragment_titles, lura_was_here}
buildLetterSlots
path provides exports
src/ui/settings/Settings.tsx Settings modal (D-28): Export, Import, Restore. Save-management only — Phase 8 adds audio/a11y.
Settings
path provides exports
src/ui/settings/persistence-toast.tsx PersistenceToast (D-30) — one-time soft toast
PersistenceToast
path provides
tests/e2e/season1-loop.spec.ts Playwright PIPE-07 full-loop smoke
from to via pattern
src/PhaserGame.tsx src/save/index.ts Boot path: openSaveDB → unwrap → migrate → drainTicks(silent=true) → if absence>=5min set offlineEvents + openLetter computeOfflineCatchup|drainTicks
from to via pattern
src/PhaserGame.tsx src/save/lifecycle.ts registerSaveLifecycleHooks({saveSync}) — wires visibilitychange + beforeunload registerSaveLifecycleHooks
from to via pattern
src/ui/letter/Letter.tsx src/content/ink-loader.ts loadInkStory('letter-from-the-garden') + bindGardenStateToInk + buildLetterSlots loadInkStory('letter
from to via pattern
tests/e2e/season1-loop.spec.ts src/sim/scheduler/clock.ts FakeClock page.goto('/?devtime=fake') → window.__tlgFakeClock.advance(...) __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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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):

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<S>(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):

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):

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):

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):

export interface OfflineEventBlock {
  plantsBloomedCount: Record<string, number>;
  harvestedFragmentIds: string[];
  luraBeatPending: 'arrival' | 'mid' | 'farewell' | null;
}

From src/ui/dialogue (Plan 02-04):

export const InkRenderer: React.FC<{ runtime: InkRuntime; onComplete?: () => void }>;
export function createInkRuntime(story: Story): InkRuntime;

From src/content/ink-loader.ts (Plan 02-04):

export async function loadInkStory(name: 'lura-arrival' | 'lura-mid' | 'lura-farewell' | 'compost-acknowledgements'): Promise<Story>;
//   ^^ 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):

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:
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<typeof OfflineEventBlockSchema>;

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:

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):

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<typeof aggregateOfflineEvent> | 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:

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);
  }
  // BLOCKER 3 invariant (matches Plan 02-02 line 457 and Plan 02-03):
  // the sim NEVER writes lastTickAt. saveSync owns wall-clock ms; the
  // sim owns the tickCount counter (STRY-10).
  return { ...next, tickCount: next.tickCount + 1 };
}

Update src/sim/garden/index.ts:

export { autoHarvestReadyPlants } from './auto-harvest';

Step 7 — Author content/dialogue/season1/letter-from-the-garden.ink (RESEARCH Pattern 6):

// 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:

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<string, number> } | 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<Story> {
  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):

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. <acceptance_criteria> - 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 </acceptance_criteria> 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):
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<InkRuntime | null>(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 (
    <div
      role="dialog"
      aria-label="A letter from the garden"
      onClick={onDismiss}
      style={{
        position: 'fixed', inset: 0, zIndex: 95,
        background: '#0c0c0d',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        cursor: 'pointer',
        color: '#e8e0d0',
        fontFamily: 'serif',
      }}
    >
      <article
        onClick={(e) => e.stopPropagation()}
        style={{
          maxWidth: 620, padding: '3rem 2.6rem',
          cursor: 'default',
        }}
      >
        {runtime ? <InkRenderer runtime={runtime} onComplete={() => {}} /> : <p style={{ opacity: 0.4 }}>...</p>}
        <button
          onClick={onDismiss}
          style={{
            marginTop: '2rem', padding: '0.5rem 1.6rem',
            background: 'transparent', color: '#e8e0d0',
            border: '1px solid #e8e0d0', cursor: 'pointer',
            fontFamily: 'serif',
          }}
        >
          Tend the garden
        </button>
      </article>
    </div>
  );
}

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:

export { Letter } from './Letter';
export { buildLetterSlots } from './letter-renderer';
export type { LetterSlots } from './letter-renderer';

Step 3.5 — src/save/payload.ts — shared helpers (W2 fix: deduplicate Settings.tsx and PhaserGame.tsx):

The previous revision left two parallel definitions of buildPayloadFromStore / hydrateStoreFromPayload — one in Settings.tsx (one-arg, calling Date.now() inline) and one in PhaserGame.tsx (two-arg, taking the clock-derived nowMs). That arity divergence is the source of W2. The fix: extract BOTH helpers to a single module with the two-arg signature; both call sites import from it.

The nowMs parameter is the wall-clock ms to write into payload.lastTickAt. Settings.tsx (no clock injection on hand) passes Date.now(); PhaserGame.tsx passes clock.now() (the injected clock — wallClock or FakeClock). The two-arg surface unifies the contract; the value passed differs, which is correct.

import type { AppState } from '../store';
import type { V1Payload } from './schemas';

/**
 * Build a V1Payload save envelope from the current store state.
 *
 * BLOCKER 3 invariants:
 *   - lastTickAt is wall-clock ms — owned by saveSync (PhaserGame) and the
 *     Settings export path. The sim NEVER writes lastTickAt.
 *   - tickCount is the sim-internal monotonic counter (STRY-10) — read from
 *     the store; the sim writes it via simulateOneTick.
 *
 * @param s        Snapshot of the store state (`useAppStore.getState()`).
 * @param nowMs    Wall-clock milliseconds to record as `lastTickAt`.
 *                 PhaserGame.tsx passes `clock.now()` (injected clock);
 *                 Settings.tsx passes `Date.now()` (no clock on hand).
 */
export function buildPayloadFromStore(s: AppState, nowMs: number): V1Payload {
  return {
    garden: { tiles: s.tiles },
    plants: [],
    harvestedFragmentIds: s.harvestedFragmentIds,
    lastTickAt: nowMs,                    // wall-clock ms
    tickCount: s.tickCount ?? 0,          // BLOCKER 3 — sim-internal counter
    unlockedPlantTypes: s.unlockedPlantTypes,
    luraBeatProgress: s.luraBeatProgress,
    offlineEvents: null,
    settings: {
      musicVolume: 0.7,
      ambientVolume: 0.5,
      sfxVolume: 0.8,
      persistenceToastShown: s.persistenceToastShown,
    },
  };
}

/**
 * Hydrate the store from a migrated V1Payload. Defensive defaults guard
 * against partial / older payloads that survived migrate() but with
 * missing-but-compatible fields.
 */
export function hydrateStoreFromPayload(s: AppState, payload: V1Payload): void {
  s.applyTilesAndUnlocks(
    payload.garden.tiles ?? new Array(16).fill(null),
    payload.unlockedPlantTypes ?? [],
  );
  s.setHarvested(payload.harvestedFragmentIds ?? []);
  s.setLuraBeatProgress(
    payload.luraBeatProgress ?? { arrived: false, mid: false, farewell: false, pending: null },
  );
  s.setPersistenceToastShown(payload.settings?.persistenceToastShown ?? false);
  // BLOCKER 3 — restore tickCount so STRY-10 narrative gating resumes.
  s.setTickCount?.(payload.tickCount ?? 0);
}

Update src/save/index.ts to re-export the two helpers:

export { buildPayloadFromStore, hydrateStoreFromPayload } from './payload';

Step 4 — src/ui/settings/Settings.tsx (D-28 save-management only):

import { useState } from 'react';
import { useAppStore } from '../../store';
import {
  exportToBase64, importFromBase64, listSnapshots, snapshot, openSaveDB,
  wrap, unwrap, migrate, CURRENT_SCHEMA_VERSION,
  buildPayloadFromStore, hydrateStoreFromPayload,
  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<string | null>(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();
      // W2: shared two-arg signature. Settings has no injected clock, so we
      // pass Date.now() directly — PhaserGame's saveSync passes clock.now().
      const payload: V1Payload = buildPayloadFromStore(state, Date.now());
      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 {
      // BLOCKER 2 fix — pipeline order: importFromBase64 → unwrap (CRC verify)
      // → migrate. unwrap() returns the payload directly (NOT { payload }), so
      // the previous v1.payload read crashed at runtime; migrate() was also
      // called against env.payload before checksum verification. Correct order:
      const env = await importFromBase64(base64Buf);
      const raw = unwrap(env);                                  // verifies CRC
      const { payload } = migrate(raw, env.schemaVersion);      // upgrades schema
      hydrateStoreFromPayload(useAppStore.getState(), payload as V1Payload);
      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(useAppStore.getState(), payload as V1Payload);
      setStatusLine('Earlier garden restored.');
    } catch (e) {
      setStatusLine('Nothing earlier could be reached.');
    }
  };

  return (
    <div role="dialog" aria-label={strings.title} style={{
      position: 'fixed', inset: 0, zIndex: 70,
      background: '#1a1a1ac0',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      color: '#e8e0d0', fontFamily: 'serif',
    }}>
      <div style={{ maxWidth: 520, background: '#1f1f23', padding: '2rem', borderRadius: 4 }}>
        <h2 style={{ marginTop: 0, fontWeight: 300, letterSpacing: '0.1em' }}>{strings.title}</h2>
        <button onClick={onExport} style={btnStyle}>{strings.export}</button>
        <textarea
          value={base64Buf}
          onChange={(e) => setBase64Buf(e.target.value)}
          rows={4}
          style={{ width: '100%', marginTop: '1rem', fontFamily: 'monospace', fontSize: '0.8rem' }}
          aria-label="Save data"
        />
        <button onClick={onImport} style={btnStyle}>{strings.import}</button>
        <button onClick={onRestoreSnapshot} style={btnStyle}>{strings.restore_snapshot}</button>
        {statusLine && <p style={{ opacity: 0.6, fontStyle: 'italic' }}>{statusLine}</p>}
        <button onClick={onClose} style={{ ...btnStyle, marginTop: '1.5rem' }}>Close</button>
      </div>
    </div>
  );
}

const btnStyle: React.CSSProperties = {
  display: 'block', margin: '0.5rem 0', padding: '0.5rem 1rem',
  background: 'transparent', color: '#e8e0d0',
  border: '1px solid #4d4d52', cursor: 'pointer',
  fontFamily: 'serif', textAlign: 'left', width: '100%',
};

// W2: helpers live in `src/save/payload.ts` (Step 3.5) — both Settings.tsx
// and PhaserGame.tsx import from there. The unified two-arg signature
// (`(state, nowMs)` for build; `(state, payload)` for hydrate) eliminates
// the arity divergence the verifier flagged.

(Phase 2 minimum-viable Settings — clipboard read/paste UX is acceptable; Phase 8 polishes.)

Step 5 — src/ui/settings/Settings.test.tsx — Vitest:

  • With open: false, returns null.
  • With open: true, mounts the dialog with all four buttons.
  • Close button calls onClose callback once.
  • (Skip the round-trip via real save layer — covered by Phase 1's existing round-trip test + Playwright e2e here.)

Step 6 — src/ui/settings/persistence-toast.tsx (D-30):

import { useEffect, useState } from 'react';
import { useAppStore } from '../../store';
import { uiStrings } from '../../content';

/**
 * D-30 — one-time soft toast in voice on first save if persistence was
 * denied; nothing if granted. State remembered via settings.persistenceToastShown.
 *
 * Triggered by src/PhaserGame.tsx after registerSaveLifecycleHooks +
 * requestPersistence resolves (Plan 02-05 boot path).
 */
export function PersistenceToast({ show }: { show: boolean }): JSX.Element | null {
  const [visible, setVisible] = useState(show);
  const setShown = useAppStore((s) => s.setPersistenceToastShown);
  const strings = uiStrings[1]?.settings;

  useEffect(() => {
    if (!show) return;
    const t = setTimeout(() => {
      setVisible(false);
      setShown(true);
    }, 6500);
    return () => clearTimeout(t);
  }, [show, setShown]);

  if (!visible || !strings) return null;
  return (
    <div
      role="status"
      style={{
        position: 'fixed', bottom: 24, left: 24, zIndex: 30,
        maxWidth: 420, padding: '0.8rem 1.2rem',
        background: '#1f1f23ee', color: '#e8e0d0',
        border: '1px solid #4d4d52', fontFamily: 'serif',
        fontStyle: 'italic',
      }}
    >
      {strings.persistence_denied_toast}
    </div>
  );
}

(A small SettingsIcon sibling component opens Settings — minimum-viable: a corner button. The executor adds this inline in App.tsx for Phase 2; Phase 8 styles it properly.)

Step 7 — src/ui/settings/index.ts:

export { Settings } from './Settings';
export { PersistenceToast } from './persistence-toast';

Update src/ui/index.ts:

export * from './begin';
export * from './garden';
export * from './journal';
export * from './dialogue';
export * from './letter';
export * from './settings';

Step 8 — Update src/save/migrations.ts to import the runtime Zod schema for OfflineEventBlock validation. Actually, leave the type alone (declared inline in Plan 02-01); the runtime Zod check happens in src/sim/offline/events.ts. No edits needed here unless the executor finds a structural mismatch.

Step 9 — Wire boot path in src/PhaserGame.tsx — replaces Plan 02-02's placeholder:

import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react';
import StartGame from './game/main.ts';
import type * as Phaser from 'phaser';
import { eventBus } from './game/event-bus';
import { appStore } from './store';
import { installFirstInteractionGestureHandler } from './ui/begin';
import {
  openSaveDB, requestPersistence, wrap, unwrap, migrate,
  CURRENT_SCHEMA_VERSION, registerSaveLifecycleHooks,
  buildPayloadFromStore, hydrateStoreFromPayload,
  type V1Payload, type SavedRecord,
} from './save';
import {
  wallClock, FakeClock, drainTicks, computeOfflineCatchup, TICK_MS,
} from './sim/scheduler';
import { simulateOneTick } from './sim/garden';
import { fragments as allFragments } from './content';

const ABSENCE_LETTER_THRESHOLD_MS = 5 * 60 * 1000;

export interface IRefPhaserGame {
  game: Phaser.Game | null;
  scene: Phaser.Scene | null;
}

interface IProps {
  currentActiveScene?: (sceneInstance: Phaser.Scene) => void;
}

export const PhaserGame = forwardRef<IRefPhaserGame, IProps>(function PhaserGame(props, ref) {
  const game = useRef<Phaser.Game | null>(null);
  const sceneRef = useRef<Phaser.Scene | null>(null);
  // W5 — hold the lifecycle handle so the OUTER useLayoutEffect cleanup can
  // call detach(). The async IIFE that registers the hooks cannot return its
  // own cleanup to the effect.
  const lifecycleRef = useRef<{ detach: () => void } | null>(null);
  const [persistenceToastShow, setPersistenceToastShow] = useState(false);

  // Select clock: dev-time URL flag + non-prod build → FakeClock; otherwise wallClock
  useLayoutEffect(() => {
    const isProd = (typeof import.meta.env !== 'undefined' && (import.meta.env as { PROD?: boolean }).PROD === true);
    const params = new URLSearchParams(window.location.search);
    const devtime = params.get('devtime');
    const useFake = !isProd && devtime === 'fake';
    const fake = new FakeClock(Date.now());
    (window as unknown as { __tlgClock?: unknown; __tlgFakeClock?: FakeClock }).__tlgClock = useFake ? fake : wallClock;
    if (useFake) (window as unknown as { __tlgFakeClock?: FakeClock }).__tlgFakeClock = fake;
  }, []);

  // Boot path: load save → migrate → catch up offline → maybe open letter
  useLayoutEffect(() => {
    if (game.current !== null) return;
    let cancelled = false;
    (async () => {
      const clock = ((window as unknown as { __tlgClock?: { now: () => number } }).__tlgClock) ?? wallClock;
      const nowMs = clock.now();
      try {
        const db = await openSaveDB();
        const record: SavedRecord | undefined = await db.get('saves', 'main');
        if (cancelled) return;
        if (record) {
          // Returning player path
          // BLOCKER 1 fix — boot path must run unwrap (CRC verify) THEN migrate.
          // Casting unwrap(record.envelope) directly to V1Payload silently
          // accepts any future-shape payload as the current shape; migrate()
          // is the only place that walks the schema version chain.
          const env = record.envelope;
          const raw = unwrap(env);
          const { payload: migratedPayload } = migrate(raw, env.schemaVersion);
          const payload = migratedPayload as V1Payload;
          appStore.getState().dismissBeginGate(); // D-22: skip Begin
          hydrateStoreFromPayload(appStore.getState(), payload);

          // Offline catch-up
          const off = computeOfflineCatchup(payload.lastTickAt, nowMs);
          if (off.willRunCatchup) {
            // Build a SimContext for silent mode and drain ticks
            const ctx = { fragments: allFragments, currentSeason: 1, silent: true } as const;
            const baseSim = payload as unknown as Parameters<typeof simulateOneTick>[0];
            const result = drainTicks(baseSim, off.cappedMs, (s, _dt, silent) => {
              return simulateOneTick(s as never, (s as { lastTickAt: number }).lastTickAt + 1, [], { ...ctx, silent });
            }, true);
            const finalState = result.state as unknown as V1Payload;
            hydrateStoreFromPayload(appStore.getState(), finalState);

            if (off.cappedMs >= ABSENCE_LETTER_THRESHOLD_MS) {
              appStore.getState().openLetter(finalState.offlineEvents);
            }
          }
        } else {
          // First-run path — initialize unlocks
          if (appStore.getState().unlockedPlantTypes.length === 0) {
            appStore.setState({ unlockedPlantTypes: ['rosemary'] });
          }
        }

        // Persistence request (CORE-05) — surface toast iff denied AND not previously shown
        const persistResult = await requestPersistence();
        if (
          persistResult.apiAvailable && !persistResult.granted &&
          !appStore.getState().persistenceToastShown
        ) {
          setPersistenceToastShow(true);
        }
      } catch (err) {
        console.error('[boot] save load failed; starting fresh', err);
      }

      // Start Phaser AFTER state hydration so the Garden scene reads correct initial tiles
      game.current = StartGame('game-container');
      if (typeof ref === 'function') ref({ game: game.current, scene: null });
      else if (ref) ref.current = { game: game.current, scene: null };

      // Register save lifecycle hooks (UX-10).
      // W5 fix — lifecycle.detach() must reach the useLayoutEffect cleanup,
      // but the registration happens inside an async IIFE so the inner
      // `return () => lifecycle.detach()` is the IIFE's return value, not
      // the effect's cleanup. Hold the handle in a useRef declared at the
      // top of the component, so the OUTER cleanup (already returned below)
      // can call detach on the ref's current value.
      lifecycleRef.current = registerSaveLifecycleHooks({
        saveSync: () => {
          try {
            const state = appStore.getState();
            const payload: V1Payload = buildPayloadFromStore(state, clock.now());
            const env = wrap(payload, CURRENT_SCHEMA_VERSION);
            // Synchronous LocalStorage path (Pitfall 7 — no await on beforeunload)
            try { localStorage.setItem('tlg.saves.main', JSON.stringify(env)); } catch {}
            // Best-effort IDB
            void db.put('saves', { id: 'main', envelope: env });
          } catch (e) {
            console.warn('[saveSync] failed', e);
          }
        },
      });
    })();
    return () => {
      cancelled = true;
      lifecycleRef.current?.detach();
      lifecycleRef.current = null;
    };
  }, [ref]);

  useEffect(() => {
    const onSceneReady = (scene: Phaser.Scene) => {
      sceneRef.current = scene;
      props.currentActiveScene?.(scene);
    };
    eventBus.on('scene-ready', onSceneReady);
    installFirstInteractionGestureHandler();
    return () => { eventBus.off('scene-ready', onSceneReady); };
  }, [props]);

  useImperativeHandle(ref, () => ({
    game: game.current,
    scene: sceneRef.current,
  }));

  return (
    <>
      <div id="game-container" />
      {persistenceToastShow && <PersistenceToastWrapper />}
    </>
  );
});

// Lazily import the toast to avoid circular deps
function PersistenceToastWrapper(): JSX.Element | null {
  // Late-import to avoid a circular import via src/ui/index.ts
  const ToastModule = require('./ui/settings/persistence-toast') as { PersistenceToast: React.FC<{ show: boolean }> };
  return <ToastModule.PersistenceToast show={true} />;
}

// W2: helpers live in `src/save/payload.ts` (Step 3.5) — both Settings.tsx
// and PhaserGame.tsx import from there. The unified two-arg signature
// eliminates the arity divergence the verifier flagged. saveSync above
// passes `clock.now()` for lastTickAt; Settings.tsx passes `Date.now()`.

(NOTE: The require inside PersistenceToastWrapper will fail in ESM — replace with a top-level import. Reorganize the file so PersistenceToast imports cleanly. The above is a sketch; the executor finalizes the import order to avoid circular deps. Acceptable approach: move PersistenceToast rendering up to App.tsx and pass the show boolean via the store — see Step 11 below.)

Step 10 — Update src/game/scenes/Garden.ts to read from the externally-provided clock (no local fallback to wallClock anymore — the boot path injects via window.__tlgClock):

import { wallClock, type Clock } from '../../sim/scheduler';

// In Garden:
private get clock(): Clock {
  return ((window as unknown as { __tlgClock?: Clock }).__tlgClock) ?? wallClock;
}

(Plan 02-02 already had a similar read; this plan formalizes via getter. No semantic change.)

Step 11 — Update src/App.tsx to mount Letter, Settings, PersistenceToast, and a SettingsIcon:

import { useState, useEffect, useRef } from 'react';
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
import { BeginScreen } from './ui/begin';
import { SeedPicker } from './ui/garden';
import { JournalIcon, FragmentRevealModal } from './ui/journal';
import { LuraDialogue } from './ui/dialogue';
import { Letter } from './ui/letter';
import { Settings, PersistenceToast } from './ui/settings';
import { useAppStore } from './store';

function App() {
  const phaserRef = useRef<IRefPhaserGame | null>(null);
  const [settingsOpen, setSettingsOpen] = useState(false);
  const persistenceToastShown = useAppStore((s) => s.persistenceToastShown);

  // W2 fix — D-29 keyboard shortcuts. Comma toggles Settings (a tasteful nod
  // to settings being a subordinate concern; easy to reach; no browser conflict).
  // 'j' toggles the Memory Journal (Plan 02-03 wires the open/close state via
  // the JournalIcon's local useState — for the hotkey, dispatch a custom event
  // that JournalIcon listens for, OR lift the journal-open state into the
  // store. Minimum-viable: expose a window-scoped toggler from JournalIcon
  // and call it here. Pick whichever feels least invasive when implementing;
  // record the choice in SUMMARY.md.)
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      // Don't trigger when the user is typing into an input/textarea
      const target = e.target as HTMLElement | null;
      if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
      if (e.key === ',') {
        e.preventDefault();
        setSettingsOpen((o) => !o);
      } else if (e.key === 'j' || e.key === 'J') {
        e.preventDefault();
        // Dispatch a window event the JournalIcon listens for (kept decoupled
        // so JournalIcon owns its open/close state). JournalIcon adds a
        // matching addEventListener('tlg:toggle-journal', ...) handler.
        window.dispatchEvent(new CustomEvent('tlg:toggle-journal'));
      }
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, []);

  return (
    <div id="app">
      <PhaserGame ref={phaserRef} />
      <BeginScreen />
      <SeedPicker />
      <FragmentRevealModal />
      <JournalIcon />
      <LuraDialogue />
      <Letter />
      <Settings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
      <PersistenceToast show={!persistenceToastShown && false /* PhaserGame controls show via state via store */} />
      <button
        data-testid="settings-icon"
        aria-label="Open settings"
        onClick={() => setSettingsOpen(true)}
        style={{
          position: 'fixed', bottom: 20, right: 76, zIndex: 40,
          width: 44, height: 44, borderRadius: 22,
          background: '#2a2a2e', color: '#e8e0d0',
          border: '1px solid #4d4d52', cursor: 'pointer',
          fontFamily: 'serif', fontSize: '1.2rem',
        }}
      >
        
      </button>
    </div>
  );
}

export default App;

(The PersistenceToast wiring above is sketch. To resolve cleanly: the toast is shown when boot determines persistence was denied. Since the boot path lives in PhaserGame.tsx, the cleanest decoupling is: PhaserGame writes a transient flag into the store (e.g., session.showPersistenceToast: boolean), and App reads from the store. Add this slot to src/store/session-slice.ts as part of THIS task or surface in SUMMARY.md. Minimum-viable acceptable: PhaserGame returns a <PersistenceToast show={persistShow} /> directly inline in its return JSX — see Step 9 sketch.)

Commit: feat(02-05): letter overlay + settings UI + boot save lifecycle + clock injection. Run npm run lint && npm run ci before committing. <acceptance_criteria> - grep -q "Letter from the garden" content/dialogue/season1/letter-from-the-garden.ink (header comment) - grep -q "<Letter />" src/App.tsx and grep -q "<Settings " src/App.tsx - grep -q "loadInkStory('letter-from-the-garden')" src/ui/letter/Letter.tsx - grep -q "void bootstrapAudioContext()" src/ui/letter/Letter.tsx (Pitfall 9 mitigation) - grep -q "exportToBase64" src/ui/settings/Settings.tsx - grep -q "importFromBase64" src/ui/settings/Settings.tsx - grep -q "listSnapshots" src/ui/settings/Settings.tsx - grep -q "computeOfflineCatchup" src/PhaserGame.tsx - grep -q "registerSaveLifecycleHooks" src/PhaserGame.tsx - grep -q "ABSENCE_LETTER_THRESHOLD_MS = 5" src/PhaserGame.tsx - grep -q "__tlgFakeClock" src/PhaserGame.tsx (URL flag wired) - grep -q "import.meta.env" src/PhaserGame.tsx (production guard for FakeClock) - grep -q "migrate(" src/PhaserGame.tsx (BLOCKER 1: boot path runs migrate, not just unwrap) - grep -q "migrate(" src/ui/settings/Settings.tsx (BLOCKER 2: import path runs migrate) - grep -E "unwrap\\(.*\\)|migrate\\(" src/ui/settings/Settings.tsx | head -5 (BLOCKER 2: both unwrap and migrate appear) - grep -q "lastTickAt: clock.now()" src/PhaserGame.tsx (BLOCKER 3: saveSync writes wall-clock ms via clock.now) - test -f src/save/payload.ts (W2: shared payload helpers extracted) - grep -q "export function buildPayloadFromStore" src/save/payload.ts - grep -q "export function hydrateStoreFromPayload" src/save/payload.ts - grep -E "from '\\.\\./save'" src/ui/settings/Settings.tsx (W2: Settings imports from save barrel — buildPayloadFromStore + hydrateStoreFromPayload) - grep -E "from '\\./save'" src/PhaserGame.tsx (W2: PhaserGame imports from save barrel — buildPayloadFromStore + hydrateStoreFromPayload) - grep -q "buildPayloadFromStore" src/save/index.ts (W2: barrel re-exports the shared helpers) - ! grep -E "^function buildPayloadFromStore" src/ui/settings/Settings.tsx (W2: no inline duplicate definition in Settings) - ! grep -E "^function buildPayloadFromStore" src/PhaserGame.tsx (W2: no inline duplicate definition in PhaserGame) - ! grep -q "lastTickAt: this\\." src/sim/garden/Garden.ts || true (BLOCKER 3: sim does NOT write lastTickAt — Garden lives under src/game/scenes/, not src/sim/, but this guards against accidental sim-side writes) - ! grep -E "lastTickAt: this\\.(currentTick|tickCount)" src/game/scenes/Garden.ts (BLOCKER 3: Garden scene does NOT overwrite lastTickAt with a tick counter — saveSync owns it) - ! grep -E "lastTickAt:\\s*(this|currentTick)" src/sim/garden/commands.ts (BLOCKER 3 — 02-05's silent-mode edits to commands.ts must not re-introduce sim-side lastTickAt writes) - grep -q "tickCount: next.tickCount" src/sim/garden/commands.ts (BLOCKER 3 — tickCount increment from 02-02 must survive 02-05's silent-mode edits) - grep -q "addEventListener('keydown'" src/App.tsx (W2: D-29 keyboard shortcut wired) - grep -q "tlg:toggle-journal" src/App.tsx (W2: journal hotkey dispatches the toggle event) - grep -q "lifecycleRef.current?.detach()" src/PhaserGame.tsx (W5: lifecycle handle reaches outer effect cleanup) - npx vitest run src/ui/letter/ src/ui/settings/ exits 0 with all tests green - npm run ci exits 0 </acceptance_criteria> npm run lint && npx vitest run src/ui/letter/ src/ui/settings/ && npm run ci Letter overlay loads compiled letter Ink, binds slots, dismisses to live garden + bootstraps audio. Settings ships save-management surface (Export/Import/Restore). Persistence-result toast wired. PhaserGame.tsx boot path: load save → migrate → silent catch-up → maybe open letter. URL ?devtime=fake injects FakeClock; production-guarded. App.tsx mounts all overlays.

Task 3: Playwright e2e (PIPE-07) — full Phase-2 loop smoke test - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Sim-Clock Injection lines 1377-1387, Validation Architecture e2e row PIPE-07) - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group N lines 695-727) - playwright.config.ts (Phase 1 — already configured) - src/PhaserGame.tsx (FakeClock injection — confirms `window.__tlgFakeClock.advance` works) - src/ui/begin/BeginScreen.tsx (data-testids and button text for click selectors) - src/ui/garden/SeedPicker.tsx (`data-testid="seed-picker"`) - src/ui/journal/Journal.tsx (`data-fragment-id` attribute on each fragment article) - src/game/scenes/Garden.ts (tile pointerdown — Playwright cannot easily click into a Phaser canvas; alternative: dispatch synthetic pointer events on the canvas element OR enqueueCommand directly via `appStore` exposed on window) tests/e2e/season1-loop.spec.ts, playwright.config.ts, src/PhaserGame.tsx, package.json **Step 1 — Add a small test-only window hook to `src/PhaserGame.tsx`** to enable Playwright to dispatch sim commands without needing pixel-precise canvas clicks:
// Inside the dev-clock useLayoutEffect block:
if (!isProd && devtime === 'fake') {
  // Expose the store + sim helpers for Playwright tests
  (window as unknown as Record<string, unknown>).__tlgStore = appStore;
}

(This exposes window.__tlgStore.getState().enqueueCommand({...}) to Playwright, sidestepping the difficulty of clicking into a Phaser canvas.)

Step 2 — tests/e2e/season1-loop.spec.ts — full PIPE-07 smoke:

import { test, expect } from '@playwright/test';

test.describe('PIPE-07 — Season 1 full loop', () => {
  test('load → begin → plant → fast-forward → harvest → reveal → journal → reload → fragment persists', async ({ page }) => {
    // 1) Load with FakeClock injected (URL flag)
    await page.goto('/?devtime=fake');

    // 2) Begin screen visible (first run)
    const beginButton = page.getByRole('button', { name: 'Begin' });
    await expect(beginButton).toBeVisible({ timeout: 10000 });

    // 3) Press Begin to dismiss + bootstrap audio
    await beginButton.click();
    await expect(beginButton).not.toBeVisible({ timeout: 5000 });

    // 4) Plant a rosemary on tile 0 via the test-exposed store
    await page.waitForFunction(() => Boolean((window as any).__tlgStore));
    await page.evaluate(() => {
      const store = (window as any).__tlgStore;
      store.getState().enqueueCommand({ kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary' });
    });

    // 5) Wait for the sim to apply the plantSeed command (one tick at 5Hz = ~200ms wall, but FakeClock makes it instant)
    await page.evaluate(() => (window as any).__tlgFakeClock.advance(1000));
    await page.waitForFunction(
      () => (window as any).__tlgStore.getState().tiles[0]?.plant?.plantTypeId === 'rosemary',
      undefined,
      { timeout: 5000 },
    );

    // 6) Fast-forward growth past 600 ticks (5Hz × 600 = 120s = 2 minutes)
    await page.evaluate(() => (window as any).__tlgFakeClock.advance(3 * 60 * 1000));
    await page.waitForTimeout(500);

    // 7) Enqueue harvest on tile 0
    await page.evaluate(() => {
      const store = (window as any).__tlgStore;
      store.getState().enqueueCommand({ kind: 'harvest', tileIdx: 0 });
    });
    await page.evaluate(() => (window as any).__tlgFakeClock.advance(1000));

    // 8) Fragment reveal modal appears with text from a Season 1 fragment
    await expect(page.getByRole('dialog', { name: 'A new memory' })).toBeVisible({ timeout: 5000 });

    // 9) Capture the harvested fragment id via store
    const harvestedId = await page.evaluate(() => {
      const store = (window as any).__tlgStore;
      const ids = store.getState().harvestedFragmentIds;
      return ids[ids.length - 1] as string;
    });
    expect(harvestedId).toMatch(/^season1\.soil\./);

    // 10) Close reveal modal
    await page.getByRole('button', { name: 'Close' }).first().click();

    // 11) Journal icon appears in corner (D-23: reveals after first harvest)
    await expect(page.getByTestId('journal-icon')).toBeVisible();

    // 12) Open Journal modal and confirm fragment text is there + selectable
    await page.getByTestId('journal-icon').click();
    const journal = page.getByRole('dialog', { name: 'Memory Journal' });
    await expect(journal).toBeVisible();
    await expect(journal.locator(`[data-fragment-id="${harvestedId}"]`)).toBeVisible();

    // 13) Reload — fragment must persist (CORE-04 + Phase-2 save lifecycle)
    await page.reload();
    // Wait for boot to complete; the begin screen should NOT appear (returning player)
    await page.waitForFunction(() => Boolean((window as any).__tlgStore));
    await expect(page.getByRole('button', { name: 'Begin' })).not.toBeVisible({ timeout: 5000 });

    // 14) The harvested fragment is still in the store
    const harvestedAfterReload = await page.evaluate(() => {
      const store = (window as any).__tlgStore;
      return store.getState().harvestedFragmentIds as string[];
    });
    expect(harvestedAfterReload).toContain(harvestedId);

    // 15) Journal still shows the fragment after reload
    await page.getByTestId('journal-icon').click();
    await expect(page.getByRole('dialog', { name: 'Memory Journal' }).locator(`[data-fragment-id="${harvestedId}"]`)).toBeVisible();
  });
});

Step 3 — Verify playwright.config.ts — already configured Phase 1 with testDir: 'tests/e2e' + dev server. Confirm the dev server is npm run dev and that timeout is reasonable for first-time Vite startup. (No edits expected.)

Step 4 — Update package.json to surface a quick e2e command:

"test:e2e": "playwright test"

(Do NOT add to ci — Playwright is slower; it's checked manually before /gsd-verify-work and runs on the v1 release pipeline. Surface in SUMMARY.md.)

Step 5 — Run the spec.

npx playwright install chromium  # if first run on this machine
npx playwright test

Iterate until green. Common adjustments:

  • data-testid attributes may need to be added on certain components (executor adds as needed).
  • Timing assertions may need tuning if FakeClock advance + sim tick rate produce delays.
  • The first-time Vite dev server startup may exceed the 30s default webServer.timeout in playwright.config.ts; bump if needed.

Commit: test(02-05): playwright e2e for PIPE-07 — full Phase-2 loop. Run the spec to green before committing. <acceptance_criteria> - test -f tests/e2e/season1-loop.spec.ts - grep -q "PIPE-07" tests/e2e/season1-loop.spec.ts - grep -q "__tlgFakeClock.advance" tests/e2e/season1-loop.spec.ts - grep -q "page.reload()" tests/e2e/season1-loop.spec.ts (CORE-04 round-trip portion) - grep -q "test:e2e" package.json - npx playwright test tests/e2e/season1-loop.spec.ts exits 0 (full loop green) - __tlgFakeClock exposed on window only when ?devtime=fake AND non-prod (production guard verified by import.meta.env.PROD check) </acceptance_criteria> npm run ci && npx playwright test tests/e2e/season1-loop.spec.ts PIPE-07 Playwright spec green end-to-end. Loads game with FakeClock injected, dismisses Begin, plants rosemary, fast-forwards 3 minutes, harvests, asserts reveal modal + journal contains fragment, reloads, asserts fragment persists. npm run ci && npx playwright test exits 0. Phase 2 vertical slice is functionally complete.

<threat_model>

Trust Boundaries

Boundary Description
Save round-trip boundary wrap → CRC32 → IDB-or-LocalStorage → unwrap → CRC32 verify → migrate. Phase 1 layer. Phase 2's extended V1Payload participates without changes.
Boot-path offline catch-up boundary computeOfflineCatchup clamps elapsed at 24h + refuses negative; drainTicks runs the silent simulate loop; offlineEvents accumulates. Pure pipeline.
FakeClock URL-flag boundary ?devtime=fake activates only in non-prod builds. Production-guarded via import.meta.env.PROD === true. Verified by PIPE-07 dev test + manual Phase-8 prod sanity.
Letter Ink content boundary Repo-controlled .ink → inklecate → JSON → inkjs.Story. React renders strings. No XSS surface.
Settings clipboard boundary exportToBase64 writes to navigator.clipboard via the await writeText API. User must grant clipboard permission; falls back gracefully if denied.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-02-05-01 Tampering URL flag ?devtime=fake exposed in production builds mitigate Boot path checks import.meta.env.PROD === true and silently ignores the flag. Asserted by PIPE-07 test guard + structural code review.
T-02-05-02 Tampering Letter Ink slot interpolation injecting HTML mitigate inkjs renders strings; React renders strings; no dangerouslySetInnerHTML. Slots are typed (`number
T-02-05-03 Denial-of-service Massive Base64 import via Settings mitigate importFromBase64 already enforces 50MB cap (Phase 1 codec.ts). Settings UI clipboard input is bounded by the textarea size.
T-02-05-04 Tampering beforeunload save race losing recent state mitigate Synchronous LocalStorage write fires inside the unload handler (Pitfall 7). Best-effort IDB write may lose ~1s but next-load reads whichever has more recent lastTickAt.
T-02-05-05 Tampering Compiled Ink JSON tampered post-build but pre-runtime accept Compiled JSON is gitignored + regenerated each build. Single-player game; no server validation.
T-02-05-06 Information disclosure Settings export reveals save data via clipboard accept User-initiated; the user already has DevTools access to the store.
T-02-05-07 Tampering Returning player skipping Begin via D-22 logic spoofs save existence accept (await db.get('saves', 'main')) === undefined is the gate. If a player edits localStorage to inject a fake save, the migrate path validates structure; corrupt envelope hits SaveCorruptError.
T-02-05-08 Denial-of-service 24h offline cap bypass via system-clock manipulation mitigate computeOfflineCatchup clamps cappedMs at MAX_OFFLINE_MS; D-11 keeps the cap silent in voice.

No high severity threats. </threat_model>

After all 3 tasks committed:

  1. Linter: npm run lint exits 0.
  2. Tests: npx vitest run exits 0; new test files: src/sim/offline/events.test.ts, src/sim/garden/auto-harvest.test.ts, src/ui/letter/letter-renderer.test.ts, src/ui/letter/Letter.test.tsx, src/ui/settings/Settings.test.tsx. Combined Phase-1+Phase-2 test count ≥200.
  3. Ink compile: npm run compile:ink produces 5 .ink.json (Plan 02-04's 4 + this plan's letter).
  4. Build: npm run build exits 0.
  5. PIPE-02 verify: node scripts/check-bundle-split.mjs after build exits 0.
  6. Full CI: npm run ci exits 0.
  7. Playwright e2e: npx playwright test tests/e2e/season1-loop.spec.ts exits 0 — proves the full Phase-2 loop end-to-end.
  8. Manual smoke (executor performs once after all 3 tasks): npm run dev, plant + harvest 3+ rosemary across active play; close the tab; wait 5+ minutes (or restart with system clock advanced for testing); reopen → letter overlay displays in voice; one tap dismisses to live garden with the gate glowing if a Lura beat queued during absence.

24 Phase-2 REQ-IDs satisfied across the 5 plans:

REQ-ID Plan Status after 02-05
CORE-02 02-01 (scheduler) + 02-02 (Garden update loop)
CORE-03 02-01 (catchup 24h cap) + 02-05 (boot path)
CORE-11 02-01 (drainTicks negative refusal)
GARD-01 02-02 (plantSeed + SeedPicker)
GARD-02 02-02 (growth state machine) + 02-05 (save round-trip via PIPE-07)
GARD-03 02-03 (harvest + reveal)
GARD-04 02-03 (compost) + 02-04 (compost-acknowledgements.ink content) ✓ (UI wiring polished here)
MEMR-01 02-03 (selector returns exactly one)
MEMR-02 02-03 (≥10 fragments authored)
MEMR-03 02-03 (FragmentSchema regex)
MEMR-04 02-03 (Journal modal)
MEMR-05 02-03 (DOM selectable)
MEMR-06 02-03 (deterministic + gating + no-dup)
STRY-01 02-04 (Lura Ink + dialogue overlay)
STRY-06 02-04 (compile-ink.mjs + 4 .ink files)
STRY-07 (vacuous; no Keeper lines anywhere)
STRY-10 02-04 (lura-gate ticks-not-time)
AEST-07 02-02 (BeginScreen + bootstrapAudioContext)
UX-01 02-02 (Begin no clutter) + 02-03 (Journal reveals)
UX-02 02-05 (Letter overlay)
UX-10 02-01 (lifecycle hooks) + 02-05 (boot wiring)
UX-11 02-01 (BigQty.format / formatHumanReadable)
PIPE-02 02-02 (lazy split) + 02-03 (check-bundle-split.mjs)
PIPE-07 02-05 (Playwright spec)

24 / 24 covered.

<success_criteria>

Plan 02-05 is complete when:

  • All 3 tasks committed.
  • npm run ci exits 0.
  • npx playwright test exits 0 — PIPE-07 green end-to-end.
  • Returning player flow: close tab → wait ≥5min → reopen → see letter in voice → tap → live garden continues.
  • Save lifecycle hooks fire on visibilitychange + beforeunload (UX-10 satisfied).
  • Settings ships Export / Import / Restore (D-28).
  • Persistence-result toast fires once if denied (D-30).
  • FakeClock URL-flag injection works in dev; production-guarded.
  • All 24 Phase-2 REQ-IDs structurally satisfied across the 5-plan set.
  • Phase 2 vertical slice could plausibly ship as a free standalone Season 1 prologue.

</success_criteria>

Create `.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-SUMMARY.md` per template. Document: - Final 5-minute absence threshold (D-20) — verify in PhaserGame.tsx const. - Whether the URL-flag FakeClock injection landed cleanly (or needed work). - Compost-beat UI wiring approach (re-used dialogue overlay vs new toast vs persistent TODO). - Playwright run time on the dev machine (informational; goal is <30s). - Manual smoke test confirmation — full Phase-2 loop in dev build. - Final tally of all 24 Phase-2 REQ-IDs (with the plan that owned each). - Any deviations / Phase-3 follow-ups discovered during integration (e.g., Settings clipboard UX polish, audio bus stub). - Confirmation that Phase 1's 53 tests + all Phase-2 additions all run green: total test count.