Files
TheLastGarden/content
josh 572c86192f feat(02-03): journal + reveal modal + harvest pointer wiring
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>
2026-05-09 10:05:45 -04:00
..

/content/ — authored content tree

All player-visible strings, memory fragments, and dialogue live here, never in src/. The build pipeline (src/content/loader.ts) reads this tree at build time, validates against Zod schemas, and emits typed values into the runtime bundle.

This is the contract. Phase 2's writer can author against it without reading any TypeScript.

Directory shape

/content/
├── seasons/
│   ├── 00-demo/                          # Phase 1 only; removed in Phase 2
│   │   └── fragments.yaml
│   ├── 01-soil/                          # Phase 2 fills this
│   │   ├── fragments.yaml                # bulk-authored fragments
│   │   └── fragments/                    # one-per-file long-form fragments (.md with frontmatter)
│   │       └── lura-first-letter.md
│   ├── 02-roots/                         # Phase 4
│   └── ...                               # Seasons 37 added in Phase 5+
├── dialogue/                             # Phase 2+ Ink (.ink) files
│   └── (empty in Phase 1)
└── README.md (this file)

Fragment ID convention (locked — see CLAUDE.md)

Fragment IDs are stable strings of the shape:

season<N>.<id>

where <N> is 0..7 and <id> matches [a-z0-9._-]+. Examples:

  • season1.soil.first-bloom
  • season3.canopy.lura_07.vignette

Never use numeric IDs. Renames are forbidden once a fragment ships; re-authoring an existing fragment changes its body, never its ID.

The exact regex enforced by src/content/schemas/fragment.ts is:

^season\d+\.[a-z0-9._-]+$

Adding fragments

Option A — bulk YAML (preferred for short fragments)

Add an entry to /content/seasons/<slug>/fragments.yaml:

fragments:
  - id: season1.soil.first-bloom
    season: 1
    body: |
      Multi-line text here.

Option B — one-per-file Markdown with frontmatter (for longer pieces)

Create /content/seasons/<slug>/fragments/<slug>.md:

---
id: season1.soil.lura-first-letter
season: 1
---

The body of the fragment goes here as Markdown. Frontmatter holds the
structured fields; the body is everything after the closing `---`.

The loader (src/content/loader.ts) merges frontmatter + body into the same Fragment shape as the YAML form.

Validation (PIPE-01)

Every fragment is validated by the Zod schema in src/content/schemas/fragment.ts. A schema violation throws at module-eval time, which fails npm run build.

Test coverage in src/content/loader.test.ts proves the schema rejects:

  • numeric IDs (violates the stable-string rule)
  • season values outside [0, 7]
  • Markdown frontmatter missing required fields

If your edit causes the build or tests to fail with a [content] schema violation error, the message includes the offending file path.

Ink dialogue

Phase 1 installs inkjs + inklecate and ships a no-op npm run compile:ink script. Phase 2 begins authoring .ink files under /content/dialogue/ and replaces the no-op with inklecate -o src/content/compiled-ink/ content/dialogue/*.ink.

Deferred (Phase 2+)

  • Per-Season lazy loading: Phase 2 switches to { eager: false } for Seasons 27 so the initial bundle contains only Season 1 (PIPE-02).
  • Tag/keyword indices: Phase 5+ may add fragment tagging if the Memory Storm UI needs filtered queries.
  • Season-range narrowing: Phase 2 narrows the season field to [1, 7] when the demo fragment is removed.