Brightens OUTLINE_COLOR 0x4d4d52 → 0x5a5a60 and OUTLINE_HOVER
0x6e6e75 → 0x7a7a82, plus adds a subtle HOVER_FILL_ALPHA=0.06 fill bump
on the hit rectangle. Closes G3 first-impression UX gap from 2026-05-09
live UAT — the 4×4 grid now reads as legible interactive surfaces
against the #1a1a1a canvas background.
The hover state is pointer-driven steady-state (color + fill alpha
swap), not animation — reduced-motion-safe per Phase 8 ownership of
global motion preferences. Phase 3 watercolor deferral preserved: this
is color values + a fill alpha; no painted assets, no new sprites.
Constants OUTLINE_COLOR / OUTLINE_HOVER are exported so the test pins
the brightened values directly. drawTiles function signature unchanged —
Garden.ts continues to work without modification.
Vitest: 5 new cases green via Phaser-Scene-mock pattern (vi.mock('phaser')
short-circuits the Phaser 4 / happy-dom checkInverseAlpha boot crash;
the mocked scene's add.graphics / add.rectangle capture the call args).
npm run ci exits 0; 329/329 total green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a single bible-voice line ("Begin where the soil is bare.") that
surfaces immediately after BeginScreen dismisses on first run and
auto-dismisses when the player makes their first plant. Closes G2
first-impression UX gap from 2026-05-09 live UAT — the post-Begin state
no longer leaves a brand-new player staring at a 4×4 grid with no
instruction.
Implementation:
- content/seasons/01-soil/ui-strings.yaml: first_run_hint key added
(recommended copy from plan; bible voice — warm, specific, contemplative)
- src/content/schemas/ui-strings.ts: UiStringsSchema extended with
first_run_hint: z.string().min(1) — MANDATORY because Zod default strip
mode silently drops unknown keys from parsed.data
- src/store/session-slice.ts: firstRunHintDismissed + dismissFirstRunHint
added (session state ONLY — NOT persisted to V1Payload, no migrations[2])
- src/ui/first-run/FirstRunHint.tsx: subscribes to tiles slice, dismisses
on first plant !== null transition; renders externalized line via
uiStrings[1]?.first_run_hint
- src/ui/first-run/{index.ts}, src/ui/index.ts: barrel + re-export wired
- src/App.tsx: <FirstRunHint /> mounted between BeginScreen and SeedPicker
Vitest: 6 new behavioral cases green (hidden when Begin still up, hidden
when dismissed, renders externalized line, reads uiStrings, auto-dismisses
on first plant, stays dismissed on subsequent tile changes). 324/324
total green; npm run ci exits 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds src/index.css with body bg #1a1a1a + serif color #e8e0d0 + zero
margin + 100vh min-height + #game-container flex centering, imported
once from src/main.tsx so Vite bundles it into the entry chunk. Closes
G1 first-impression UX gap from 2026-05-09 live UAT — the dark canvas
no longer floats in a sea of white.
Phase 3 (Watercolor & Cello) layers a painted treatment over this base
without changing the structural intent. No new npm dependencies, no
painted assets.
Vitest smoke: 6/6 cases green via file-read assertion (jsdom does not
bundle CSS imports; the Playwright e2e in Task 5 proves end-to-end that
the bundled CSS actually applies in a real browser).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User dev-server walkthrough surfaced 4 functional UX gaps beneath the verifier's
24/24 structural PASS:
G1 (blocking) — no global page CSS → white halo around #1a1a1a canvas
G2 (blocking) — no first-run prompt after Begin → player confused
(A Dark Room rule needs the canonical first-prompt)
G3 (high) — tile outlines too dim → grid reads as 'gray check block'
G4 (medium) — gate visual at canvas (880, 384) reads as stray rectangle
Phase 3 watercolor deferral preserved — every fix uses Phaser primitives or
one CSS file, not painted assets. STATE.md → status: gaps_found.
Next: /gsd-plan-phase 2 --gaps
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- src/ui/settings/compost-toast.tsx: thin transient toast (D-07,
GARD-04). Reads a rotating line from uiStrings[1].post_harvest_beat
on each compost dispatch; fades out after 3.5s. Co-located with
PersistenceToast as the plan specified ('folded into the persistence-
toast UI surface').
- src/store/session-slice.ts: compostBeatTick monotonic counter +
bumpCompostBeat action. Counter (vs boolean) ensures consecutive
composts re-fire the toast without dedup.
- src/game/scenes/Garden.ts: handleTilePointerDown's compost branch
calls bumpCompostBeat after enqueueCommand.
- src/App.tsx: mounts <CompostToast />.
- 4 new compost-toast tests; 312/312 vitest green; e2e still 1.6s
green; npm run ci exits 0.
Implementation choice (per plan 'surfaced in SUMMARY'): minimum-viable
toast surface chosen over the Ink runtime path. The Ink-authored
compost-acknowledgements.ink content remains compiled + runtime-
loadable via loadInkStory + InkRenderer, so a future plan can swap
this component for the richer voice without touching sim or store.
- tests/e2e/season1-loop.spec.ts: PIPE-07 smoke covering load → Begin
→ plant rosemary → fast-forward FakeClock 3min → harvest →
fragment-reveal modal → close → journal-icon visible → open journal
→ fragment present → reload page → fragment persists. Sidesteps
Phaser canvas pixel-clicking via window.__tlgStore command dispatch
(test-only window slot, production-guarded by import.meta.env.PROD).
- playwright.config.ts: bumped webServer timeout 30s → 60s for cold
Vite startup; pinned port 5273 + --strictPort to avoid collisions
with other dev servers on the user's machine; reuseExistingServer
false so the spec always starts a fresh Vite against this project.
- package.json: added test:e2e script (npx playwright test). Not
added to npm run ci — Playwright is slower than Vitest; manual run
before /gsd-verify-work + future v1 release pipeline.
- src/content/loader.ts (Rule 3 — Blocking): replaced gray-matter
with a 15-line parseFrontmatter helper. gray-matter pulls in Node's
Buffer global which is undefined in Vite's browser bundle; the
build emits a 'Module "buffer" externalized' warning that masks
the runtime ReferenceError. Surfaced under Vite dev mode while
running the e2e — Plan 02-03's Markdown loader path (lura-first-
letter.md + winter-rose-night.md) was effectively broken in real
browsers since shipping. parseFrontmatter handles the strict
'---<yaml>---<body>' shape the .md fragments use; bundle dropped
from 2.2MB to 1.9MB as a side effect of dropping the unused dep.
- deferred-items.md: tracks the gray-matter package.json cleanup
(the dep is now unused but kept in package.json for now, scoped
out of this plan).
- npx playwright test exits 0 (1 spec, 1.5s test runtime); npm run
ci exits 0; 308/308 vitest still green. PIPE-07 satisfied
end-to-end.
- src/sim/offline/: OfflineEventBlockSchema (Zod) + EMPTY_OFFLINE_EVENTS
+ aggregateOfflineEvent pure aggregator (D-19); 14 tests green
- src/sim/garden/auto-harvest.ts: autoHarvestReadyPlants silent-mode
branch (D-10); reuses harvest() pipeline so selector + Pitfall 10
unlocks + STRY-10 Lura gate all run identically; BLOCKER 3 invariant
preserved (no lastTickAt writes); 7 tests green
- simulateOneTick: ctx.silent triggers auto-harvest sweep before tick
increment; active-play path unchanged (silent defaults false)
- content/dialogue/season1/letter-from-the-garden.ink: authored skeleton
with VAR plants_bloomed / fragment_titles / lura_was_here per D-17/D-18;
bible voice, anti-FOMO compliant, 24h cap silent in voice (D-11)
- ink-loader: loadInkStory union extended with letter-from-the-garden;
separate letterStoryGlob for lazy code-split chunk; INK_VARIABLE_MAP
gains plants_bloomed / fragment_titles / lura_was_here slots reading
from session.pendingLetterEventBlock
- src/ui/letter/letter-renderer.ts: pure buildLetterSlots helper —
prefers fragment first-sentence body for tonal weight, slugified-id
fallback; 10 tests green
- npm run compile:ink emits 5 .ink.json files (was 4); Vite emits the
letter as a separate lazy chunk (letter-from-the-garden.ink-*.js)
- 295/295 tests green (was 264; +31 new); npm run ci exits 0
- src/ui/dialogue/ink-runtime.ts: thin wrapper around inkjs Story —
nextLine() with text-message cadence (1500ms base + 20ms/char, capped
at 4000ms), skipDelay() for tap-to-advance, choice surface forwarded
to ChooseChoiceIndex. Constants exported for Plan 02-05's UX-05
reduced-motion hook + playtest tuning.
- src/ui/dialogue/ink-runtime.test.ts: 7 cases pinning the cadence
bounds, skipDelay one-shot semantics, choice forwarding (uses
vi.useFakeTimers() to validate timing without wall-clock waits).
- src/ui/dialogue/ink-renderer.tsx: drips lines into the DOM as the
runtime yields them; userSelect: 'text' for MEMR-05 copy-paste;
click-anywhere skip; choice buttons stop event propagation.
- src/ui/dialogue/LuraDialogue.tsx: D-15 — full-screen DOM overlay
driven by dialogueOverlayOpen + luraBeatProgress.pending. Loads the
compiled Ink JSON via loadInkStory, binds variables from store
snapshot, ChoosePathString into the named knot ('arrival'/'mid'/
'farewell'), then runs InkRenderer. Close button calls
resolvePendingLuraBeat to mark visited and clear pending.
- src/ui/dialogue/LuraDialogue.test.tsx: 6 cases — closed-state null,
dialog renders on open+pending, Close fires resolvePendingLuraBeat
for all three beats, loadInkStory called with correct beat name +
knot. Mocks the loadInkStory + ink-runtime layer to keep happy-dom
out of inkjs internals (Plan 02-05 e2e exercises the live path).
- src/render/garden/gate-renderer.ts: drawGate() + updateGateIndicator()
— Phaser primitive gate (body / glow / hit) at canvas (880, 384).
Glow alpha-pulses via Sine-yoyo tween when isPending=true; idempotent.
- src/game/scenes/Garden.ts: gate added in create(); pointerdown
dispatches setDialogueOverlayOpen(true) only when a beat is pending.
storeUnsubscribe also drives updateGateIndicator on luraBeatProgress
changes. update() loop now calls simAdapter.applyLuraProgress when
the sim's luraBeatProgress differs from the store's so harvests
trigger the gate indicator. destroy() cleans up the gate's tween.
- src/App.tsx: <LuraDialogue /> mounted as DOM sibling of PhaserGame.
- src/ui/index.ts + src/render/garden/index.ts: re-exports.
13 new tests across dialogue layer; 264/264 total green; npm run ci
exits 0; Vite emits 4 lazy ink-*.js chunks (compiled JSON code-split
per file); ESLint sim-purity rule still green (sim/narrative imports
no inkjs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- src/sim/narrative/beat-queue.ts: LuraBeatId / LuraBeatProgress contracts
matching V1Payload.luraBeatProgress + NarrativeSlice; INITIAL frozen.
- src/sim/narrative/lura-gate.ts: LURA_BEAT_THRESHOLDS = {1: arrival,
4: mid, 8: farewell}; advanceLuraBeatProgress / resolvePendingLuraBeat /
isLuraBeatPending — pure, no inkjs import, no Date.now (sim-purity rule
green). The gate counts harvest events, never wall-clock time, so STRY-10
holds.
- src/sim/narrative/lura-gate.test.ts: 17 cases including the load-bearing
STRY-10 case (24 hours of FakeClock advance with 0 harvests leaves all
flags + pending false). Pitfall 10 boundaries pinned at 3/4/5 and 7/8/9.
pending-set-already + already-visited carry-throughs covered.
- src/sim/garden/commands.ts: harvest() now calls advanceLuraBeatProgress
AFTER the harvest commit (Pitfall 10 — same-tick boundary). The new
luraBeatProgress field flows through the returned SimState and into the
store via the existing Garden.update() path.
- src/sim/garden/commands.test.ts: +5 cases pinning the harvest → beat
gate edges (1st→arrival, 4th→mid, 8th→farewell, between-threshold
no-fire, pending preservation when player hasn't visited).
- src/sim/index.ts: re-export ./narrative.
67/67 sim tests green; npm run lint + build exit 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- scripts/compile-ink.mjs: build-time inklecate runner using bundled binary
(BLOCKER 4 — uses node_modules/inklecate/bin, not stale -windows/-mac path strings).
Assumption A6 verified first-try on Windows; the same binary path resolution
works on macOS + Linux per the wrapper's own getInklecatePath convention.
- scripts/compile-ink.test.mjs: 3 Vitest cases proving the compiler runs +
emits valid JSON with inkVersion. wipe=false for the test path so it can
run in parallel with the ink-loader test without racing on the wipe step.
- 4 Season-1 .ink files authored in voice (Lura warmth-anchor, gardener-keeper
for compost): lura-arrival.ink, lura-mid.ink, lura-farewell.ink,
compost-acknowledgements.ink (rewrite of Plan 02-03 scaffolded version into
VAR-driven branch shape consumable by the runtime).
- src/content/ink-loader.ts: loadInkStory + bindGardenStateToInk +
INK_VARIABLE_MAP. Centralized snake_case slot mapping per Pitfall 4. UTF-8
BOM stripped before Story instantiation.
- src/content/ink-loader.test.ts: 8 cases — Story instantiation for all 4
beats, fragment_count binding, Pitfall 4 snake_case enforcement, silent
skip for stories missing declared vars.
- package.json: build now runs compile:ink first; ci chain runs compile:ink
before test so ink-loader.test.ts's precondition check passes.
- .gitignore: src/content/compiled-ink/ excluded (regenerated on every build).
npm run ci exits 0; 11 new tests green (228 total).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 3 of Plan 02-03: ship the PIPE-02 structural assertion that Season-1
content reaches the build output. Three structural checks (any one
sufficient): chunk filename slug match (fragments / season1 / 01-soil),
chunk-contents reference to /content/seasons/01-soil/ source path or to
known fragment ids, and a future-extension hook for index.html manifest
inspection.
Phase 2 ships eager-corpus loading alongside the lazy
loadSeasonFragments surface, so currently chunkContentMatch=true via
inlined ?raw content. When Plan 02-04+ switches consumers to lazy-only
and Vite emits a separate Season-1 chunk, chunkNameMatch will also
start passing — at which point either path satisfies the assertion. The
plumbing is structurally proven now; the chunk-naming side is documented
as the path of least resistance for the Phase-4 Season-2 onboarding.
scripts/check-bundle-split.mjs:
- Refactored body into export function runCheck() returning a structured
result; the CLI invocation guard wraps process.exit so Vitest can
import the module without termination (verified by the test file).
scripts/check-bundle-split.test.mjs:
- 3 cases: file exists, parses + imports without process.exit firing,
runCheck() returns the documented {ok, message, chunkNameMatch,
chunkContentMatch, files} shape. The on-disk dist/-required happy path
fires via the package.json scripts.ci chain (`npm run build &&
npm run check:bundle-split`).
package.json:
- New `check:bundle-split` script.
- `ci` chain extended: lint → test → validate:assets → build →
check:bundle-split. dist/ is populated by build before the bundle-split
assertion runs.
`npm run ci` exits 0 end-to-end. 217/217 tests green (was 214; +3 new
this task). The PIPE-02 verification step now refuses any future change
that breaks the lazy-content plumbing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 2 of Plan 02-03: ship the Memory Journal UI surfaces (D-23/D-24/D-25)
and wire harvest + compost pointer events through the Garden scene to the
sim → store → React reveal flow.
src/ui/journal/ (new module):
- Journal.tsx — full-screen modal (D-24); fragments grouped by Season;
DOM-rendered text with userSelect: text per MEMR-05; reads
harvestedFragmentIds from the store; resolves ids against the eager
`fragments` corpus (defensive — unresolvable ids skip silently).
- FragmentRevealModal.tsx — D-25 active-play reveal modal; backdrop click
+ inner Close button dismiss; event.stopPropagation on the article
body so clicking inside the text doesn't dismiss; defensive silent
dismiss on unresolvable id.
- journal-icon.tsx — D-23 + D-29 corner affordance; gated by
selectJournalRevealed (`harvestedFragmentIds.length > 0`); local open
state (no store pollution); 'j' hotkey deferred to Plan 02-05.
- index.ts — barrel.
- 16 new Vitest cases across 3 test files (Journal: 7 / FragmentRevealModal:
6 / journal-icon: 3); all green.
src/App.tsx — mount FragmentRevealModal + JournalIcon as DOM siblings of
PhaserGame.
src/ui/index.ts — re-export ./journal.
src/game/scenes/Garden.ts — harvest/compost pointer flow:
- create() builds a SimContext from the eager `fragments` corpus filtered
to Season 1; passed to every simulateOneTick call (Phase 4+ should swap
to await loadSeasonFragments(currentSeason) when Season transitions land).
- handleTilePointerDown branches on tile state: empty → seed picker
event; ready plant → enqueue 'harvest' command; immature plant → enqueue
'compost' command (TODO Plan 02-04: render the Ink-authored compost
acknowledgement beat from compost-acknowledgements.ink).
- update() detects newly-appended harvestedFragmentIds and sets
fragmentRevealId so the reveal modal pops with the new fragment text.
- BLOCKER 3 invariant preserved — sim writes tickCount, never lastTickAt.
content/dialogue/season1/compost-acknowledgements.ink — authored content
for the GARD-04 + D-07 compost beat. 6 short lines in the gardener-keeper
voice (NOT Lura — she's the warmth anchor; the garden's voice is the
contrast). Plan 02-04 wires the inkjs runtime; Plan 02-03 ships the
content so the writer can iterate independently.
214/214 tests green (was 163; +51 new this plan); npm run lint exits 0;
npm run ci exits 0; npm run build exits 0 with the expected
INEFFECTIVE_DYNAMIC_IMPORT warnings (eager `fragments` export still
imports the Season-1 yaml/md statically alongside the lazy
loadSeasonFragments path — documented in 02-02-SUMMARY.md).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- eslint.config.js block 3: no-restricted-syntax bans Date.now() and
setInterval() inside src/sim/**, with src/sim/scheduler/clock.ts as
the single allowed wall-clock owner (CONTEXT D-33, RESEARCH Pitfall 1)
- date-now-violator.ts deliberate-violation fixture (excluded from
default lint by Block 1's top-level ignores; the programmatic ESLint
test passes ignore: false to override)
- lint-firewall.test.ts gains 2 new cases: positive (rule fires on
violator) + negative (rule does NOT fire on clock.ts the one exception)
- Existing CORE-10 firewall test left untouched and remains green
- Zustand 5 vanilla createStore composes 4 slices (garden / memory /
narrative / session); useAppStore React hook re-renders on selector
change; getState() works without React (Phaser ↔ React bridge per D-32)
- simAdapter exposes drainCommands / applyTilesAndUnlocks /
applyHarvestedFragments / applyLuraProgress / applyTickCount; sim
never imports the store (CORE-10)
- V1Payload extended in place per D-34: tickCount (BLOCKER 3 monotonic
counter), unlockedPlantTypes, luraBeatProgress, offlineEvents,
settings.persistenceToastShown — CURRENT_SCHEMA_VERSION stays 1, no
migrations[2] sneaked in (regression-defense test pins this)
- migrations[1] populates all new field defaults; tickCount: 0 means
fresh sims always start at sim-tick 0
- registerSaveLifecycleHooks (UX-10): visibilitychange→hidden,
beforeunload, plus saveOnSeasonTransition() — Vitest covers all three
- Phaser EventBus singleton seeded per the Phaser 4 React-template pattern
- Install @testing-library/react as devDep so the React-hook test can
exercise the real renderHook surface
- 27 new tests across store / migrations / lifecycle all green; full
npm run ci is 126/126
Decision-coverage gate found three CONTEXT.md decisions structurally
implemented but not literally cited by their D-NN tags. Added one-line
must_haves entries citing each:
- D-12 (Lura as discrete gate visits, 3 beats this Season) → 02-04
- D-16 (all Lura dialogue authored in Ink, runtime via inkjs) → 02-04
- D-32 (Zustand 5 store as the Phaser↔React bridge; sim never imports
store, CORE-10 enforced) → 02-01
STATE.md flipped from in_progress (context gathered) to ready_to_execute
with the planning summary in stopped_at.
All 24 REQ-IDs + 34 D-XX decisions now covered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
- W1: 02-RESEARCH.md Open Questions section now flagged (RESOLVED) and each
Recommendation prefixed with RESOLVED + a pointer to the artifact that
codified the resolution.
- W8: 02-02 Plan example moves `import type { GrowthStage }` to the top of
commands.ts (alongside the other type-only imports) and drops the trailing
parenthetical apology — the executor doesn't need to fix anything.
Two distinct fields with strict separation:
- lastTickAt: wall-clock milliseconds. Written ONLY at saveSync time by
the application layer. The sim NEVER writes this field.
computeOfflineCatchup uses it as the wall-clock anchor.
- tickCount: monotonic sim-internal counter (one per simulate() call).
Used for STRY-10 narrative gating that must be immune to wall-clock
manipulation. The sim writes this field; the application layer reads
it via simAdapter.applyTickCount.
Changes:
02-01: SimState + V1Payload gain `tickCount: number`; migrations[1]
defaults to 0; GardenSlice exposes tickCount + lastTickAt + setters;
simAdapter exposes applyTickCount; tests assert the round-trip.
02-02: simulateOneTick increments next.tickCount + 1 (not lastTickAt:
currentTick); Garden scene's SimState snapshot reads lastTickAt
through from store and writes tickCount: this.currentTick locally;
acceptance_criteria forbids `lastTickAt: this.*` in the sim and scene.
02-05: buildPayloadFromStore now persists tickCount (from store);
hydrateStoreFromPayload restores it via state.setTickCount.
This unblocks the offline-catchup math: computeOfflineCatchup(payload.lastTickAt,
nowMs) now reliably reads wall-clock ms because the sim never overwrites it
with a tick counter.
- BLOCKER 1: PhaserGame.tsx boot path now runs unwrap(env) → migrate(raw, env.schemaVersion).
Casting unwrap(record.envelope) directly to V1Payload silently accepted any
future-shape payload as the current shape; only migrate() walks the schema
version chain.
- BLOCKER 2: Settings.tsx onImport now correctly orders importFromBase64 →
unwrap (CRC verify) → migrate. Previous code discarded migrate's result
and then read v1.payload as if unwrap returned an envelope rather than
the payload itself — runtime crash on every import.
- BLOCKER 3: documented the lastTickAt invariant as wall-clock milliseconds,
written ONLY at saveSync time (never by the sim). Added acceptance_criteria
greps proving (a) saveSync writes clock.now(), (b) Garden scene does not
overwrite lastTickAt with a tick counter, (c) sim/garden/Garden.ts (if it
exists; the Garden scene actually lives at src/game/scenes/Garden.ts)
contains no lastTickAt: this.* writes.
- W2: D-29 keyboard shortcut wired in App.tsx — comma toggles Settings,
'j' dispatches a window CustomEvent the JournalIcon picks up.
- W5: lifecycle handle now stored in useRef and detached in the OUTER
useLayoutEffect cleanup (the previous IIFE-internal return was a closure
return, never reaching React's effect cleanup contract).
- W6: warm-tagged pool depth raised to ≥9 (8th-harvest threshold + 1 buffer)
so a worst-case all-rosemary playthrough never exhausts. Total per-pool
targets: ≥9 warm, ≥3 contemplative, ≥3 heavy, plus the sentinel.
- W2: JournalIcon now listens for the 'tlg:toggle-journal' window event so
App.tsx can wire a 'j' hotkey without lifting open/close state into the
store. Hotkey is gated on the same revealed selector as the icon itself.
Per W9: invoking the compiler from inside ink-loader.test.ts's beforeAll
creates a filesystem race against other concurrent tests because the
script wipes src/content/compiled-ink/ at start. Compile is already part
of the npm run ci chain (via npm run build); the test should only verify
the artefact exists and fail loudly with a fix-it message otherwise.
- BLOCKER 4: inklecateBinary() now resolves node_modules/inklecate/bin/inklecate{,.exe};
the previous path (inklecate-windows/, inklecate-mac/, inklecate-linux/) does not
exist in the package — fallback verification of Assumption A6 would have masked
the wrapper-API failure.
- W3: removed lura-greeting-template.ink from files_modified (never authored).
- W4: added last_fragment_title to INK_VARIABLE_MAP (extracts first sentence of the
most-recently-harvested fragment) so the must_haves slot promise is fulfilled.
54 file targets classified (49 new, 5 modified) with 87% analog coverage.
Key patterns: V1Payload extension (not v1→v2 migration), per-layer
public barrel pattern, test colocation, Zustand vanilla store + Phaser
EventBus singleton as the dual sim↔React bridge, ESLint sim-purity rule
proposed as a defended option (not auto-locked, per minimum-viable bias).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nyquist VALIDATION.md scaffold for Phase 2. Defines test infrastructure
(Vitest + Playwright already wired by Phase 1), sampling rates (npm test
after each commit, npm run ci after each wave), Wave-0 dependency surface
(BigQty + scheduler + Zustand store + V1Payload extension), and three
manual-only verifications (AudioContext cross-browser, letter voice review,
cozy-pace playtest). The per-task verification map is intentionally empty —
the planner fills it during plan generation; nyquist_compliant flips to
true once it's complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5-plan MVP slice proposal across 3 waves: Wave 0 lands the three
deferred foundations (BigQty wrapper around break_eternity.js, Zustand
5 store wiring, tick scheduler / monotonic clock). Wave 1 ships two
parallel vertical slices (Begin+Plant+Grow, Harvest+Journal+Fragments).
Wave 2 ships the Lura gate-visit slice and the offline-letter slice
including Playwright PIPE-07 e2e. All 24 REQ-IDs addressed in the
coverage map; 10 architectural patterns enumerated; tick rate locked at
5Hz with 24h offline cap; AudioContext.resume() bootstrap pattern
documented for first-run + returning-player paths; V1Payload extension
shape locked per CONTEXT D-34 (no migrate_v1_to_v2 added). 10
assumptions logged, 8 are LOW risk; 2 are MEDIUM (canvas-DOM coord
mapping under FIT scale, inklecate Windows binary invocation) and
flagged for early verification in Plans 02-02 and 02-04.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 discuss-phase complete; STATE.md now reflects context-gathered
status, updated stopped_at narrative, and next-action pointer to
/gsd-plan-phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 (Season 1 Vertical Slice — Soil) discuss-phase output. 02-CONTEXT.md
captures 34 implementation decisions across 8 gray areas (garden geometry &
input · time density · Lura's arc · letter composition · Begin screen ·
Memory Journal · plant placeholders · Phase-2 Settings UI scope). Locks the
4×4 grid + click+inline seed picker, 2–5min growth band per plant with
auto-harvest-while-offline, 3 Lura gate visits gated by 1st/4th/8th harvest,
authored Ink letter skeleton with templated slots, full-screen letter on
≥5min absence, tasteful placeholder Begin screen (Phase 3 paints), full-
screen Journal modal revealing after first harvest, simple Phaser-primitive
plants with subtle ready-state pulse, save-management-only Settings.
Phase 2's first commits: BigQty wrapper, Zustand 5 store, tick scheduler.
Save schema is a v1 *extension*, not v1→v2.
02-DISCUSSION-LOG.md preserves the alternatives considered for human review.
Next: /gsd-plan-phase 2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User invoked planning-doctrine pushback principle on the 10-20 north-star
curation step. Two 1x1 transparent-PNG placeholders ship with provenance
sidecars marked model_id: 'placeholder' so the validator exercises at >0
assets. Full deferral rationale and resolution path in
.planning/phases/01-foundations-and-doctrine/01-05-IOU.md — to be revisited
at Phase 5 entry (curate then, or amend CONTEXT D-01 if still ceremonial).
- 01-07-ci-workflow-SUMMARY.md: structural enforcement map, Phase 2/8 handoff notes, threat T-01-08 mitigation confirmed
- 01-VALIDATION.md: per-task table populated (12/13 green, 01-05-T2 partial — checkpoint:human-verify awaiting north-star image curation); status flipped to executed
- ROADMAP.md: progress table marks Phase 1 as 7/7 with 01-05 partial annotation
- STATE.md: position advanced to Plan 7 of 7 complete; performance trend; Plan 01-05 Task 2 explicitly tracked as the only outstanding deliverable; next action = human curation pass then /gsd-verify-work
- REQUIREMENTS.md: PIPE-06 marked complete (CI workflow runs Vitest on every push/PR)
- Single-job workflow at .github/workflows/ci.yml (~49 lines including load-bearing comments)
- runs-on: ubuntu-latest, timeout-minutes: 10
- Uses actions/setup-node@v4 with cache: 'npm' (per RESEARCH CI Pitfall A — never cache node_modules/)
- Node 22 (per RESEARCH § Environment Availability)
- Triggers on push to main and pull_request to main
- Steps: checkout → setup-node → npm ci (lockfile-strict) → npm run ci (lint + test + validate-assets + build)
- Per CONTEXT user pushback: NO matrix, NO test reporters, NO Codecov, NO release automation
- Local npm run ci exits 0 (53 tests passing across 12 files); workflow will be green on push
- Structurally enforces every Phase 1 success criterion on every commit going forward