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>
/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 3–7 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-bloomseason3.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 2–7 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
seasonfield to[1, 7]when the demo fragment is removed.