Single Wave 0 plan addressing the 4 first-impression UX gaps from the 2026-05-09 live UAT: - G1 (BLOCKING) src/index.css imported from main.tsx — body bg #1a1a1a, serif color #e8e0d0 — closes the white halo around the dark canvas - G2 (BLOCKING) FirstRunHint component reading externalized 'Begin where the soil is bare.' from ui-strings.yaml + UiStringsSchema extension (Zod default strip mode would otherwise drop the key) + session-slice firstRunHintDismissed flag (NOT V1Payload) - G3 (HIGH) tile-renderer outline brightening 0x4d4d52 → 0x5a5a60 + hover bump 0x7a7a82 - G4 (MEDIUM) gate-renderer wall-band Phaser primitive at gate column with alpha 0.15-0.20 Phase 3 watercolor + cello deferral preserved: zero painted assets, zero new npm dependencies, V1Payload unchanged. Plan-checker found 1 BLOCKER (Zod schema strip mode breaking G2 silently) + 1 WARNING (hint copy ranking pushed non-bible-voice option first); planner revised; residual frontmatter + 3 copy refs fixed inline. Plan: 5 tasks, 16 files_modified, depends_on [02-01..02-05], requirements [GARD-01, AEST-07, UX-01] supplemental coverage. ROADMAP.md annotated with Wave 1/Wave 2 headers. Next: /gsd-execute-phase 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 KiB
phase, plan, type, wave, depends_on, gap_closure, files_modified, autonomous, requirements, tags, must_haves
| phase | plan | type | wave | depends_on | gap_closure | files_modified | autonomous | requirements | tags | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02 | 06 | execute | 0 |
|
true |
|
true |
|
|
|
This plan closes the 4 first-impression UX gaps surfaced by the 2026-05-09 live UAT walkthrough on the dev server. All 24 Phase-2 REQ-IDs are structurally PASS; the test suite cannot detect "what does a new player see on frame one?" — the gaps are minimum-viable functional UX, NOT Phase-3 aesthetic deferrals.
Scope discipline (from 02-VERIFICATION.md frontmatter gaps: block):
- G1 BLOCKING — no global page CSS — fix shape: src/index.css ~15 lines.
- G2 BLOCKING — no first-run prompt after Begin — fix shape: tiny FirstRunHint component + session flag.
- G3 HIGH — tile outlines too dim — fix shape: brighten OUTLINE_COLOR + clearer hover in tile-renderer.ts.
- G4 MEDIUM — gate visual stands alone — fix shape: faint vertical wall band primitive in gate-renderer.ts.
What this plan must NOT do:
- No painted assets (Phase 3 watercolor deferral preserved).
- No new npm dependencies (CSS is plain CSS imported via Vite native).
- No V1Payload changes / no migrations[2] (firstRunHintDismissed is session state, not save state).
- No re-litigation of typographic Begin screen tone (HUMAN-UAT.md item 4 covers that — out of scope here).
- No tone judgment on Lura's voice or letter cadence (HUMAN-UAT.md items 1-2 — out of scope).
Wave structure: single wave (Wave 0) since all 4 gap-fix tasks are file-disjoint and parallel-safe within the plan. G1, G2, G3, G4 do not overlap on files_modified:
- G1 owns: src/index.css, src/main.tsx, src/index.css.test.ts.
- G2 owns: content/seasons/01-soil/ui-strings.yaml, src/store/session-slice.ts, src/ui/first-run/* (new files), src/ui/index.ts, src/App.tsx.
- G3 owns: src/render/garden/tile-renderer.ts, src/render/garden/tile-renderer.test.ts.
- G4 owns: src/render/garden/gate-renderer.ts, src/render/garden/gate-renderer.test.ts.
- The Playwright e2e extension touches a shared file (tests/e2e/season1-loop.spec.ts) and runs LAST as Task 5 — the integration/verification task.
Tasks: 5 (one per gap + a final integration task that extends the e2e and runs full ci). Estimated context cost ~30-40% (well within budget for a gap-closure plan).
Close the 4 first-impression UX gaps that the 2026-05-09 live UAT surfaced. Each fix uses Phaser primitives or a single CSS file — no painted assets, no new dependencies, no V1Payload changes. After execution: the dark canvas no longer floats in a white viewport (G1), a first-time player sees one bible-voice instructional line after Begin dismisses (G2), the 4×4 tile grid reads as legible interactive surfaces with a clearer hover state (G3), and the gate reads as part of a wall via a faint vertical primitive band (G4).Purpose: Unblocks /gsd-verify-work Phase 2 sign-off. The 4 gaps in 02-VERIFICATION.md frontmatter gaps: block clear; the 6 HUMAN-UAT.md tone items remain pending (separate workflow). The Phase-2 vertical slice that "could plausibly ship as a free standalone Season-1 prologue" actually feels like one to a brand-new player — the loop is intuitive on frame one.
Output: 4 gap fixes + 4 unit tests + 3 e2e assertions threaded into tests/e2e/season1-loop.spec.ts. npm run ci && npm run test:e2e exits 0. 02-VERIFICATION.md ready for re-verifier pass to flip status from gaps_found → verified.
<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-PATTERNS.md @.planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md @.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-SUMMARY.mdFrom src/main.tsx (currently 14 lines, no CSS import):
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Root element #root not found in index.html');
}
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>
);
From index.html (the body has only <div id="root"></div> — Phaser parents to #game-container rendered by <PhaserGame /> which renders <div id="game-container" /> inside <div id="app"> from App.tsx):
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
From src/game/main.ts (Phaser config — backgroundColor #1a1a1a; parent 'game-container'):
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: 1024,
height: 768,
parent: 'game-container',
backgroundColor: '#1a1a1a',
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
scene: [Boot, Garden],
};
From src/store/session-slice.ts (Plan 02-01 + 02-05 — current SessionSlice):
export interface SessionSlice {
beginGateDismissed: boolean;
persistenceToastShown: boolean;
showPersistenceToast: boolean;
letterOverlayOpen: boolean;
pendingLetterEventBlock: unknown | null;
compostBeatTick: number;
// ... actions
dismissBeginGate: () => void;
setPersistenceToastShown: (v: boolean) => void;
setShowPersistenceToast: (v: boolean) => void;
openLetter: (block: unknown) => void;
dismissLetter: () => void;
bumpCompostBeat: () => void;
}
// Default state: beginGateDismissed=false, ...
From src/App.tsx (Plan 02-05 — currently mounts BeginScreen, SeedPicker, FragmentRevealModal, JournalIcon, LuraDialogue, Letter, Settings, PersistenceToast, CompostToast):
return (
<div id="app">
<PhaserGame ref={phaserRef} />
<BeginScreen />
<SeedPicker />
<FragmentRevealModal />
<JournalIcon />
<LuraDialogue />
<Letter />
<Settings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<PersistenceToast show={showPersistenceToast} />
<CompostToast />
<button data-testid="settings-icon" /* ... corner cog ... */>⚙</button>
</div>
);
From src/ui/begin/BeginScreen.tsx (Plan 02-02 — single fixed-position dialog covering canvas; dismissed via session.beginGateDismissed; existing pattern to copy for FirstRunHint shape):
export function BeginScreen(): JSX.Element | null {
const dismissed = useAppStore((s) => s.beginGateDismissed);
const dismissBeginGate = useAppStore((s) => s.dismissBeginGate);
if (dismissed) return null;
// ... fixed-position dialog with title/subtitle/Begin button
}
From src/render/garden/tile-renderer.ts (Plan 02-02 — current dim values that G3 fixes):
const OUTLINE_COLOR = 0x4d4d52; // ← G3: brighten to 0x5a5a60
const OUTLINE_HOVER = 0x6e6e75; // ← G3: brighten to 0x7a7a82
const OUTLINE_ALPHA = 0.6;
// drawTiles creates 16 tile graphics + hit rectangles; pointerover/pointerout
// swap the outline color via drawOutline().
From src/render/garden/gate-renderer.ts (Plan 02-04 — current gate primitives; G4 adds a wall band):
const GATE_X = 880;
const GATE_Y = 384;
const GATE_COLOR = 0x6e6e75;
const GATE_HIT_W = 80;
const GATE_HIT_H = 120;
// drawGate adds: body (rectangle), glow (rectangle, alpha 0), hit (transparent
// interactive rectangle). G4 adds a 4th: wall (vertical band at GATE_X,
// full canvas height 768, low alpha ~0.18).
From content/seasons/01-soil/ui-strings.yaml (Plan 02-02 + 02-05 current shape — G2 adds first_run_hint):
season: 1
begin:
title: "The Last Garden"
subtitle: "tend"
cta: "Begin"
seed_picker:
title: "Sow"
cancel: "Not yet"
post_harvest_beat:
- "The earth remembers."
- "Something stayed."
- "It rests where it grew."
journal:
empty_state: "Nothing yet. Plant something."
back: "Close"
settings:
title: "Settings"
export: "Save to a copy"
import: "Restore from a copy"
restore_snapshot: "Earlier garden"
persistence_denied_toast: "The garden may forget, if your browser asks it to."
plants:
rosemary: "Rosemary"
yarrow: "Yarrow"
winter-rose: "Winter-rose"
# ← G2 ADDS:
# first_run_hint: "<one bible-voice line — see Task 2 candidate copy>"
From src/content/schemas/* (Plan 01-04 — UiStringsSchema validates ui-strings.yaml at build time):
The Zod schema is currently typed for the structure above. Task 2 may need to add first_run_hint: z.string() to the schema if strict-typed parsing rejects unknown keys. Read src/content/schemas/index.ts to confirm whether the schema is strict (z.object().strict()) or lenient (default — extra keys ignored). Almost certainly lenient, in which case no schema change is needed; if strict, Task 2 must extend the schema.
From src/store/index.ts (Plan 02-01 + 02-05 barrel — actions exposed to React via useAppStore):
useAppStore is a vanilla zustand store; subscribing to a single field (e.g. useAppStore(s => s.beginGateDismissed)) is the canonical pattern. The Garden scene also subscribes via appStore.subscribe(selector, callback) for non-React tick-driven repaints.
From src/sim/garden/commands.ts (Plan 02-02/02-03/02-04 — plantSeed dispatch path):
The store enqueues a command via enqueueCommand({kind: 'plantSeed', tileIdx, plantTypeId}). The Garden scene's update loop drains the queue once per tick. Detection of "first plant has occurred" can be done by subscribing to a derived selector — simplest reliable signal: subscribe to harvestedFragmentIds.length OR tiles.some(t => t?.plant !== null). The cleanest path: subscribe to tiles and dismiss FirstRunHint the first time any tile has plant !== null. (Subscribing to plantSeed enqueues directly is brittle; the queue may apply asynchronously.)
From tests/e2e/season1-loop.spec.ts (Plan 02-05 — current full-loop spec; Task 5 extends with 3 assertions):
test('load → begin → plant → fast-forward → harvest → reveal → journal → reload → persist', async ({ page }) => {
await page.goto('/?devtime=fake');
await ensureFreshSave(page);
await page.goto('/?devtime=fake');
// Begin button visible, click it → bootstrap audio → tiles drain.
// ... existing 16-step assertion chain.
});
Task 5 threads:
- After step 1 (initial nav): assert document.body computed background-color is
rgb(26, 26, 26). - After step 3 (Begin dismissed): assert the FirstRunHint element is visible.
- After step 5/6 (plantSeed enqueued + applied to tile 0): assert the FirstRunHint is gone.
From .planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md frontmatter gaps: block:
- G1: src/main.tsx + no src/index.css; fix CSS body bg #1a1a1a + zero margin + serif color #e8e0d0.
- G2: post-BeginScreen state — no instruction; fix tiny FirstRunHint with copy from ui-strings.yaml; auto-dismiss on first plant; session-state only (no migrations[2]).
- G3: tile outlines too dim — brighten OUTLINE_COLOR (~0x4d4d52 → ~0x5a5a60) + add clearer hover (~0x7a7a82 + slight fill alpha bump).
- G4: gate stands alone — add faint vertical band Phaser primitive (alpha 0.15-0.20 against #1a1a1a) at the gate's column connecting top-to-bottom of canvas.
/* Global page styles. Phase-2 minimum-viable tonal coherence — body bg
* matches the Phaser canvas backgroundColor (#1a1a1a) and the BeginScreen
* overlay so there is no white halo around the dark canvas at any moment.
*
* Phase 3 (Watercolor & Cello) layers a painted treatment on top of this
* base; this file establishes the foundation that the painted treatment
* layers over.
*
* Per CLAUDE.md tone constraint and anti-FOMO doctrine — calm, contemplative,
* no animation, no urgency.
*/
html, body {
margin: 0;
padding: 0;
min-height: 100vh;
background: #1a1a1a;
color: #e8e0d0;
font-family: serif;
}
#game-container {
display: flex;
justify-content: center;
align-items: center;
}
EXACT values required:
bodybackground color:#1a1a1a(matches Phaser config + BeginScreen)bodytext color:#e8e0d0(matches BeginScreen typography)bodymargin:0AND padding:0(kill browser default 8px margin)bodymin-height:100vh(so the dark bg fills viewport even on a short page)bodyfont-family:serif(matches BeginScreen)#game-containerflex centering (so Phaser canvas sits centered)
Step 2 — Modify src/main.tsx to import the CSS:
Add a single line after the existing imports, BEFORE the const rootEl line:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css'; // Plan 02-06 G1 — global page styles (body bg, font, margin)
Step 3 — Create src/index.css.test.ts as a Vitest smoke test (Vitest does NOT bundle CSS in jsdom mode; we test by reading the file content):
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* G1 (gap closure 02-06) — assert src/index.css contains the load-bearing
* tonal-coherence rules. We test by file-read because Vitest jsdom does
* not bundle CSS imports; the e2e (tests/e2e/season1-loop.spec.ts) is the
* end-to-end proof that the bundled CSS actually applies in a real browser.
*/
describe('src/index.css (Plan 02-06 G1 closure)', () => {
const cssPath = join(__dirname, 'index.css');
const css = readFileSync(cssPath, 'utf8');
it('sets body background to #1a1a1a (matches Phaser canvas)', () => {
expect(css).toMatch(/background:\s*#1a1a1a/);
});
it('sets body color to #e8e0d0 (matches BeginScreen palette)', () => {
expect(css).toMatch(/color:\s*#e8e0d0/);
});
it('zeroes body margin (kills browser default white halo)', () => {
expect(css).toMatch(/margin:\s*0/);
});
it('sets body min-height to 100vh (dark bg fills viewport)', () => {
expect(css).toMatch(/min-height:\s*100vh/);
});
it('uses serif font-family (matches BeginScreen)', () => {
expect(css).toMatch(/font-family:\s*serif/);
});
it('main.tsx imports the CSS', () => {
const mainPath = join(__dirname, 'main.tsx');
const main = readFileSync(mainPath, 'utf8');
expect(main).toMatch(/import\s+['"]\.\/index\.css['"]/);
});
});
Commit: fix(02-06,G1): add src/index.css and import from main.tsx — close white-halo gap. Run npm run lint && npx vitest run src/index.css.test.ts && npm run build to verify CSS bundles cleanly.
<acceptance_criteria>
- test -f src/index.css (file exists)
- grep -q 'background:\s*#1a1a1a' src/index.css (body bg literal present)
- grep -q 'color:\s*#e8e0d0' src/index.css (body color literal present)
- grep -q 'margin:\s*0' src/index.css (zero margin present)
- grep -q 'min-height:\s*100vh' src/index.css (min-height present)
- grep -q 'font-family:\s*serif' src/index.css (serif present)
- grep -qE "import\s+['\"]\\./index\\.css['\"]" src/main.tsx (CSS imported from main.tsx)
- npx vitest run src/index.css.test.ts exits 0 (all 6 cases green)
- npm run build exits 0 (CSS bundles into entry chunk; no Vite warning about missing file)
- npm run lint exits 0 (no ESLint regressions; CSS file is excluded from lint by default)
</acceptance_criteria>
npm run lint && npx vitest run src/index.css.test.ts && npm run build
src/index.css exists with the 6 required CSS rules. src/main.tsx imports it. Vitest smoke test green. npm run build bundles the CSS into the entry chunk without warning. The white-halo gap is structurally closed; G1 cleared in 02-VERIFICATION.md frontmatter.
Pick ONE of these candidate lines (executor judgment — choose the most bible-voice). Listed in order of recommended preference (ranked by bible-voice fidelity per CLAUDE.md tone constraint):
"Begin where the soil is bare."← Recommended — bible-voice; specific (soil,bare), contemplative, honors the project's tonal register"The soil is waiting."← alternative — quieter, even more bible-voice; consider for HUMAN-UAT review"Click a tile to plant."← functional fallback — only if the bible-voice options fail HUMAN-UAT review for being too elliptical for a brand-new player
Recommended: #1 ("Begin where the soil is bare."). Bible voice is the standard for every player-visible string per CLAUDE.md ("anything player-facing — UI text, error messages, tooltips, the 'while you were away' letter — should match the bible's voice: warm, specific, intermittent, sometimes funny, sometimes devastating"). The candidate ordering above ranks specifically by bible-voice fidelity (warm, specific, intermittent). The functional fallback is included only as a safety net if the bible-voice candidates fail HUMAN-UAT review for being too elliptical for first-frame guidance.
Add the key under the season-1 root, near the seed_picker section since the hint is about planting:
season: 1
begin:
title: "The Last Garden"
subtitle: "tend"
cta: "Begin"
# Plan 02-06 G2 — first-run instructional hint shown after BeginScreen
# dismisses on the first run of a tab. Auto-dismisses on first plant.
# Per the A Dark Room rule: one prompt at a time, minimal but always
# present until acted upon.
first_run_hint: "Begin where the soil is bare."
seed_picker:
title: "Sow"
cancel: "Not yet"
# ... rest of the file unchanged ...
Step 2 — Extend src/content/schemas/ui-strings.ts to accept the new key. THIS IS MANDATORY, NOT CONDITIONAL.
Why mandatory: UiStringsSchema is declared as z.object({...}) with NO .strict() modifier. Zod's DEFAULT behavior for plain z.object is "strip" mode — unknown keys parse SUCCESSFULLY but are SILENTLY DROPPED from parsed.data (see Zod docs: object > unknownKeys). The downstream type UiStrings = z.infer<typeof UiStringsSchema> has no first_run_hint field, and src/content/loader.ts line 162 calls UiStringsSchema.safeParse(data) then assigns result[parsed.data.season] = parsed.data — so the key vanishes between YAML and runtime.
If we ADD first_run_hint to content/seasons/01-soil/ui-strings.yaml but DO NOT extend the schema, then at runtime uiStrings[1]?.first_run_hint is undefined, FirstRunHint does if (!hint) return null; → component ALWAYS renders null in production, G2 never closes structurally, and the integration test case 4 ((await import('../../content')).uiStrings[1]?.first_run_hint) FAILS in CI. The unit tests that mock the store directly (cases 1, 2, 3, 5, 6) would pass, masking the failure.
The fix: ADD first_run_hint: z.string().min(1) to UiStringsSchema at the same nesting level as begin, seed_picker, post_harvest_beat, journal, settings, plants (i.e. directly inside the top-level z.object({...})). Without .min(1), an empty-string accident would slip through; with it, the loader rejects an empty hint at build time.
The exact edit to src/content/schemas/ui-strings.ts (insert near the bottom of the inner record, after the plants line, before the closing })):
export const UiStringsSchema = z.object({
season: z.number().int().min(0).max(7),
begin: z.object({ /* ... */ }),
seed_picker: z.object({ /* ... */ }),
post_harvest_beat: z.array(z.string().min(1)).min(1),
journal: z.object({ /* ... */ }),
settings: z.object({ /* ... */ }),
plants: z.record(z.string(), z.string().min(1)),
// Plan 02-06 G2 — first-run instructional hint, externalized per STRY-09.
// Required because Zod default strip mode would silently drop this key
// from parsed.data and FirstRunHint would render null in production.
first_run_hint: z.string().min(1),
});
The UiStrings type re-export updates automatically (it's z.infer<typeof UiStringsSchema>); no change to src/content/schemas/index.ts is needed (it just re-exports the symbol).
After the schema change, src/content/loader.test.ts should still pass — the existing season-1 fixtures already include the YAML key from Step 1, and the loader test exercises UiStringsSchema.safeParse against the real fixture. Run npx vitest run src/content/loader.test.ts as a quick sanity check before moving on.
Step 3 — Extend src/store/session-slice.ts:
Add firstRunHintDismissed: boolean AND dismissFirstRunHint: () => void:
export interface SessionSlice {
beginGateDismissed: boolean;
persistenceToastShown: boolean;
showPersistenceToast: boolean;
letterOverlayOpen: boolean;
pendingLetterEventBlock: unknown | null;
compostBeatTick: number;
/**
* Plan 02-06 G2 — first-run instructional hint dismissal.
*
* Session state ONLY (NOT persisted to V1Payload — no migrations[2]).
* The hint re-appears on hard reload until the player makes their
* first plant in this tab; that is the correct A-Dark-Room first-run
* UX. Once dismissed for the session, it stays down.
*
* Auto-dismissed by FirstRunHint.tsx when it observes a tile transition
* from null → plant !== null (the first successful plantSeed commit).
*/
firstRunHintDismissed: boolean;
dismissBeginGate: () => void;
setPersistenceToastShown: (v: boolean) => void;
setShowPersistenceToast: (v: boolean) => void;
openLetter: (block: unknown) => void;
dismissLetter: () => void;
bumpCompostBeat: () => void;
dismissFirstRunHint: () => void;
}
export const createSessionSlice: StateCreator<SessionSlice, [], [], SessionSlice> = (set) => ({
beginGateDismissed: false,
persistenceToastShown: false,
showPersistenceToast: false,
letterOverlayOpen: false,
pendingLetterEventBlock: null,
compostBeatTick: 0,
firstRunHintDismissed: false, // Plan 02-06 G2 — session state only
dismissBeginGate: () => set({ beginGateDismissed: true }),
setPersistenceToastShown: (v) => set({ persistenceToastShown: v }),
setShowPersistenceToast: (v) => set({ showPersistenceToast: v }),
openLetter: (block) => set({ letterOverlayOpen: true, pendingLetterEventBlock: block }),
dismissLetter: () => set({ letterOverlayOpen: false, pendingLetterEventBlock: null }),
bumpCompostBeat: () => set((s) => ({ compostBeatTick: s.compostBeatTick + 1 })),
dismissFirstRunHint: () => set({ firstRunHintDismissed: true }),
});
CRITICAL — DO NOT touch src/save/migrations.ts. firstRunHintDismissed is session state, not save state (per scope_constraint #3).
Step 4 — Create src/ui/first-run/FirstRunHint.tsx:
import { useEffect, type JSX } from 'react';
import { useAppStore } from '../../store';
import { uiStrings } from '../../content';
/**
* G2 (gap closure 02-06) — first-run instructional hint.
*
* Visible when:
* - beginGateDismissed === true (player has clicked Begin)
* - firstRunHintDismissed === false (player has not yet planted)
*
* Auto-dismisses when the player makes their first plant — detected by
* subscribing to the tiles slice and dismissing on the first transition
* to any tile having plant !== null.
*
* Per CLAUDE.md tone constraint: copy is externalized in
* content/seasons/01-soil/ui-strings.yaml (key: first_run_hint), never
* hardcoded.
*
* Per scope_constraint #3 of the gap-closure plan: this is session state
* (lives in src/store/session-slice.ts), NOT save state. A hard reload
* shows the hint again until first plant.
*
* Per scope_constraint #4 (a11y / reduced-motion): no animation; the
* hint is a steady-state DOM element. Pointer-driven dismissal only.
*/
export function FirstRunHint(): JSX.Element | null {
const beginGateDismissed = useAppStore((s) => s.beginGateDismissed);
const firstRunHintDismissed = useAppStore((s) => s.firstRunHintDismissed);
const dismissFirstRunHint = useAppStore((s) => s.dismissFirstRunHint);
const tiles = useAppStore((s) => s.tiles);
// Auto-dismiss on first plant (any tile transitions to plant !== null).
useEffect(() => {
if (firstRunHintDismissed) return;
if (!Array.isArray(tiles)) return;
const anyPlanted = tiles.some(
(t) => t !== null && t !== undefined && t.plant !== null && t.plant !== undefined,
);
if (anyPlanted) dismissFirstRunHint();
}, [tiles, firstRunHintDismissed, dismissFirstRunHint]);
if (!beginGateDismissed) return null;
if (firstRunHintDismissed) return null;
const hint = uiStrings[1]?.first_run_hint;
if (!hint) return null;
return (
<div
data-testid="first-run-hint"
role="status"
aria-live="polite"
style={{
position: 'fixed',
top: 24,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 30,
padding: '0.5rem 1.25rem',
background: 'transparent',
color: '#e8e0d0',
fontFamily: 'serif',
fontSize: '1rem',
opacity: 0.85,
letterSpacing: '0.05em',
pointerEvents: 'none',
}}
>
{hint}
</div>
);
}
NOTE on the tiles selector: confirm the appStore actually exposes tiles directly (Plan 02-02 added it; the Playwright spec reads __tlgStore.getState().tiles[0]?.plant?.plantTypeId). If the field is named differently, adjust accordingly. The principle is: subscribe to the simplest signal that reliably fires on first successful plantSeed commit.
Step 5 — Create src/ui/first-run/index.ts:
export { FirstRunHint } from './FirstRunHint';
Step 6 — Extend src/ui/index.ts to re-export the new directory:
// Add this line near the other re-exports:
export * from './first-run';
Step 7 — Create src/ui/first-run/FirstRunHint.test.tsx — Vitest cases:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { FirstRunHint } from './FirstRunHint';
import { useAppStore } from '../../store';
/**
* G2 (gap closure 02-06) — FirstRunHint behavioral coverage.
*
* Session-state only — beforeEach resets the relevant slice fields.
*/
describe('FirstRunHint (Plan 02-06 G2 closure)', () => {
beforeEach(() => {
// Reset session flags to first-run defaults.
useAppStore.setState({
beginGateDismissed: false,
firstRunHintDismissed: false,
tiles: Array.from({ length: 16 }, () => ({ plant: null })) as never,
});
});
it('renders nothing when beginGateDismissed=false (Begin still up)', () => {
render(<FirstRunHint />);
expect(screen.queryByTestId('first-run-hint')).toBeNull();
});
it('renders nothing when firstRunHintDismissed=true', () => {
useAppStore.setState({ beginGateDismissed: true, firstRunHintDismissed: true });
render(<FirstRunHint />);
expect(screen.queryByTestId('first-run-hint')).toBeNull();
});
it('renders the externalized line when Begin is dismissed and hint is not dismissed', () => {
useAppStore.setState({ beginGateDismissed: true });
render(<FirstRunHint />);
const el = screen.getByTestId('first-run-hint');
expect(el).toBeInTheDocument();
// Assert it is the externalized string (not hardcoded). Read the
// ui-strings to confirm the rendered text matches.
expect(el.textContent).toBeTruthy();
expect(el.textContent!.length).toBeGreaterThan(0);
});
it('reads the line from uiStrings (not a hardcoded string in the component)', () => {
useAppStore.setState({ beginGateDismissed: true });
render(<FirstRunHint />);
// The component module must NOT contain the hint copy — the line
// lives in ui-strings.yaml. We assert by reading the .tsx file:
// (executor: this assertion can be a static-grep inside the test
// OR the dev can verify by inspection. A weaker but cleaner test:
// assert that uiStrings[1].first_run_hint is non-empty AND equals
// the rendered text.)
const expected = (await import('../../content')).uiStrings[1]?.first_run_hint;
expect(expected).toBeTruthy();
expect(screen.getByTestId('first-run-hint').textContent).toBe(expected);
});
it('auto-dismisses when a tile transitions to plant !== null', () => {
useAppStore.setState({ beginGateDismissed: true });
const { rerender } = render(<FirstRunHint />);
expect(screen.queryByTestId('first-run-hint')).toBeInTheDocument();
// Simulate a plantSeed commit on tile 0.
act(() => {
useAppStore.setState({
tiles: [
{ plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } },
...Array.from({ length: 15 }, () => ({ plant: null })),
] as never,
});
});
rerender(<FirstRunHint />);
expect(screen.queryByTestId('first-run-hint')).toBeNull();
});
it('stays dismissed once dismissed (no re-show on subsequent tile changes)', () => {
useAppStore.setState({ beginGateDismissed: true, firstRunHintDismissed: true });
render(<FirstRunHint />);
act(() => {
useAppStore.setState({
tiles: Array.from({ length: 16 }, () => ({ plant: null })) as never,
});
});
expect(screen.queryByTestId('first-run-hint')).toBeNull();
});
});
NOTE — the dynamic import in the 4th test may need adjustment if the test file uses await import syntactically. Convert that test to async:
it('reads the line from uiStrings (not a hardcoded string in the component)', async () => {
// ...
});
Step 8 — Mount <FirstRunHint /> in src/App.tsx:
Add the import and render. Place AFTER <BeginScreen /> and BEFORE <SeedPicker /> (so Begin still owns the visual top and the hint surfaces only when Begin is gone):
// In imports:
import { FirstRunHint } from './ui/first-run';
// In the return JSX:
<PhaserGame ref={phaserRef} />
<BeginScreen />
<FirstRunHint /> {/* Plan 02-06 G2 — first-run instructional hint */}
<SeedPicker />
{/* ... rest unchanged ... */}
Commit: fix(02-06,G2): first-run hint after Begin — close A-Dark-Room first-prompt gap. Run npm run lint && npx vitest run src/ui/first-run/ && npm run ci.
<acceptance_criteria>
- grep -q 'first_run_hint:' content/seasons/01-soil/ui-strings.yaml (key added)
- grep -qE 'first_run_hint:\s*"[^"]+\."' content/seasons/01-soil/ui-strings.yaml (non-empty quoted string with sentence-ending punctuation)
- grep -E '^\s*first_run_hint:\s*z\.string\(\)' src/content/schemas/ui-strings.ts (schema extended — without this, Zod strip mode drops the YAML key from parsed.data and FirstRunHint renders null in production; see Step 2 for the full rationale)
- grep -q 'firstRunHintDismissed' src/store/session-slice.ts (session field added)
- grep -q 'dismissFirstRunHint' src/store/session-slice.ts (action added)
- ! grep -q 'firstRunHintDismissed' src/save/migrations.ts (NOT in V1Payload — must NOT match)
- test -f src/ui/first-run/FirstRunHint.tsx (component exists)
- test -f src/ui/first-run/FirstRunHint.test.tsx (test exists)
- test -f src/ui/first-run/index.ts (barrel exists)
- grep -q "from './first-run'" src/ui/index.ts (re-export wired)
- grep -q '<FirstRunHint />' src/App.tsx (mounted in App)
- grep -q "uiStrings\[1\]" src/ui/first-run/FirstRunHint.tsx (component reads externalized strings — no hardcoded copy)
- grep -L 'Begin where the soil is bare\\|The soil is waiting\\|Click a tile to plant' src/ui/first-run/FirstRunHint.tsx (the candidate copy strings do NOT appear in the component file — they live ONLY in ui-strings.yaml)
- npx vitest run src/ui/first-run/ exits 0 (all 6 cases green)
- npm run ci exits 0 (full pipeline including content schema validation, lint, all tests, build)
</acceptance_criteria>
npm run lint && npx vitest run src/ui/first-run/ && npm run ci
FirstRunHint renders the externalized bible-voice line after BeginScreen dismisses; auto-dismisses on first plant; session-state only (NO V1Payload changes). 6 Vitest cases green. App.tsx mounts the component. The A-Dark-Room first-prompt gap is structurally closed; G2 cleared in 02-VERIFICATION.md frontmatter.
Change the two outline color constants AND add a slight fill alpha bump on hover. Final file should look like this (changes annotated with // ← Plan 02-06 G3):
import * as Phaser from 'phaser';
import { GRID_SIZE } from '../../sim/garden/types';
import { tileTopLeftCanvas, GRID_LAYOUT } from './tile-coords';
/**
* Empty-tile look: outlined rounded rectangle with subtle hover.
* Per CONTEXT D-06; Phase 3 paints the watercolor treatment over this
* primitive without changing the function signature.
*
* Plan 02-06 G3 — outline + hover values brightened so the 4×4 grid
* reads as legible interactive surfaces against the #1a1a1a canvas
* background. No painted assets (Phase 3 deferral preserved).
*/
export const OUTLINE_COLOR = 0x5a5a60; // ← Plan 02-06 G3 (was 0x4d4d52 — too dim against #1a1a1a)
export const OUTLINE_HOVER = 0x7a7a82; // ← Plan 02-06 G3 (was 0x6e6e75 — needed clearer contrast in resting vs hover)
const OUTLINE_ALPHA = 0.6;
const HOVER_FILL_ALPHA = 0.06; // ← Plan 02-06 G3 — slight fill on hover to reinforce the affordance (no animation noise, reduced-motion-safe)
export interface TileGameObjects {
/** Hit-area rectangle (interactive). */
hit: Phaser.GameObjects.Rectangle;
/** Outline graphic. */
outline: Phaser.GameObjects.Graphics;
}
function drawOutline(g: Phaser.GameObjects.Graphics, tlX: number, tlY: number, color: number): void {
g.clear();
g.lineStyle(2, color, OUTLINE_ALPHA);
g.strokeRoundedRect(tlX, tlY, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 6);
}
export function drawTiles(scene: Phaser.Scene): TileGameObjects[] {
const tiles: TileGameObjects[] = [];
for (let i = 0; i < GRID_SIZE; i++) {
const tl = tileTopLeftCanvas(i);
const cx = tl.x + GRID_LAYOUT.tileSize / 2;
const cy = tl.y + GRID_LAYOUT.tileSize / 2;
const g = scene.add.graphics();
drawOutline(g, tl.x, tl.y, OUTLINE_COLOR);
// Hit rectangle (interactive). Holds a faint hover fill to reinforce
// the click affordance — Plan 02-06 G3.
const hit = scene.add.rectangle(cx, cy, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 0xffffff, 0);
hit.setInteractive({ useHandCursor: true });
hit.on('pointerover', () => {
drawOutline(g, tl.x, tl.y, OUTLINE_HOVER);
hit.setFillStyle(0xffffff, HOVER_FILL_ALPHA); // ← Plan 02-06 G3 — slight fill bump
});
hit.on('pointerout', () => {
drawOutline(g, tl.x, tl.y, OUTLINE_COLOR);
hit.setFillStyle(0xffffff, 0); // ← Plan 02-06 G3 — reset
});
// Tag the hit object with its index for handler dispatch.
hit.setData('tileIdx', i);
tiles.push({ hit, outline: g });
}
return tiles;
}
CRITICAL EXPORTS — OUTLINE_COLOR and OUTLINE_HOVER are now export const so the test can pin the values directly. Do NOT change the function signature of drawTiles; existing call sites in Garden.ts continue to work unchanged.
EXACT values required:
OUTLINE_COLOR = 0x5a5a60(brightened from 0x4d4d52)OUTLINE_HOVER = 0x7a7a82(brightened from 0x6e6e75)HOVER_FILL_ALPHA = 0.06(slight fill on hover)
Step 2 — Create src/render/garden/tile-renderer.test.ts — Vitest cases.
The challenge: tile-renderer uses Phaser primitives directly, and Phaser cannot import under happy-dom (per Plan 02-02 SUMMARY — checkInverseAlpha calls canvas.getContext('2d') which returns null). The test must mock the Phaser scene API surface that drawTiles uses.
import { describe, it, expect, vi } from 'vitest';
import { drawTiles, OUTLINE_COLOR, OUTLINE_HOVER } from './tile-renderer';
/**
* G3 (gap closure 02-06) — assert tile-renderer uses the brightened
* outline colors and the hover fill bump.
*
* We mock Phaser.Scene's add.graphics + add.rectangle surface (avoids the
* Phaser 4 / happy-dom canvas.getContext incompatibility per Plan 02-02
* SUMMARY).
*/
describe('tile-renderer (Plan 02-06 G3 closure)', () => {
it('exports OUTLINE_COLOR=0x5a5a60 (brightened from 0x4d4d52)', () => {
expect(OUTLINE_COLOR).toBe(0x5a5a60);
});
it('exports OUTLINE_HOVER=0x7a7a82 (brightened from 0x6e6e75)', () => {
expect(OUTLINE_HOVER).toBe(0x7a7a82);
});
it('drawTiles creates 16 tile groups with outline graphics + hit rectangles', () => {
const graphics = {
clear: vi.fn(),
lineStyle: vi.fn(),
strokeRoundedRect: vi.fn(),
};
const rectangle = {
setInteractive: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
setData: vi.fn().mockReturnThis(),
setFillStyle: vi.fn().mockReturnThis(),
};
const scene = {
add: {
graphics: vi.fn(() => graphics),
rectangle: vi.fn(() => rectangle),
},
} as unknown as Phaser.Scene;
const tiles = drawTiles(scene);
expect(tiles).toHaveLength(16);
// 16 graphics + 16 rectangles created
expect(scene.add.graphics).toHaveBeenCalledTimes(16);
expect(scene.add.rectangle).toHaveBeenCalledTimes(16);
});
it('initial draw uses OUTLINE_COLOR (resting state)', () => {
const graphics = {
clear: vi.fn(),
lineStyle: vi.fn(),
strokeRoundedRect: vi.fn(),
};
const rectangle = {
setInteractive: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
setData: vi.fn().mockReturnThis(),
setFillStyle: vi.fn().mockReturnThis(),
};
const scene = {
add: {
graphics: vi.fn(() => graphics),
rectangle: vi.fn(() => rectangle),
},
} as unknown as Phaser.Scene;
drawTiles(scene);
// Each of the 16 tiles called lineStyle with OUTLINE_COLOR + OUTLINE_ALPHA
const calls = (graphics.lineStyle as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.length).toBeGreaterThan(0);
// Every initial call uses OUTLINE_COLOR; we assert the first.
expect(calls[0][1]).toBe(OUTLINE_COLOR);
});
it('pointerover handler swaps to OUTLINE_HOVER and adds fill alpha bump', () => {
const graphics = {
clear: vi.fn(),
lineStyle: vi.fn(),
strokeRoundedRect: vi.fn(),
};
let pointerOverHandler: (() => void) | null = null;
const rectangle = {
setInteractive: vi.fn().mockReturnThis(),
on: vi.fn((evt: string, fn: () => void) => {
if (evt === 'pointerover') pointerOverHandler = fn;
return rectangle;
}),
setData: vi.fn().mockReturnThis(),
setFillStyle: vi.fn().mockReturnThis(),
};
const scene = {
add: {
graphics: vi.fn(() => graphics),
rectangle: vi.fn(() => rectangle),
},
} as unknown as Phaser.Scene;
drawTiles(scene);
expect(pointerOverHandler).not.toBeNull();
pointerOverHandler!();
// After pointerover, lineStyle was called with OUTLINE_HOVER
const lineStyleCalls = (graphics.lineStyle as ReturnType<typeof vi.fn>).mock.calls;
const lastLineCall = lineStyleCalls[lineStyleCalls.length - 1];
expect(lastLineCall[1]).toBe(OUTLINE_HOVER);
// setFillStyle was called with the hover alpha bump
const fillCalls = (rectangle.setFillStyle as ReturnType<typeof vi.fn>).mock.calls;
const fillBumpCall = fillCalls.find((c) => c[1] && c[1] > 0);
expect(fillBumpCall).toBeDefined();
expect(fillBumpCall![1]).toBeGreaterThan(0);
expect(fillBumpCall![1]).toBeLessThanOrEqual(0.1); // sanity: subtle bump, not a flash
});
});
NOTE: importing Phaser in this test path — we use import type only or skip the type annotation entirely. The mock object satisfies the runtime contract drawTiles needs. If TypeScript complains about the cast as unknown as Phaser.Scene, that is the canonical happy-dom-safe pattern (already used in Plan 02-02's SeedPicker test per the SUMMARY).
Commit: fix(02-06,G3): brighten tile outline and hover state — close dim-grid gap. Run npm run lint && npx vitest run src/render/garden/ && npm run ci.
<acceptance_criteria>
- grep -q '0x5a5a60' src/render/garden/tile-renderer.ts (new OUTLINE_COLOR value present)
- grep -q '0x7a7a82' src/render/garden/tile-renderer.ts (new OUTLINE_HOVER value present)
- ! grep -q '0x4d4d52' src/render/garden/tile-renderer.ts (old dim value gone — must NOT match)
- ! grep -q '0x6e6e75' src/render/garden/tile-renderer.ts (old dim hover value gone — must NOT match)
- grep -q 'export const OUTLINE_COLOR' src/render/garden/tile-renderer.ts (constant exported for testability)
- grep -q 'export const OUTLINE_HOVER' src/render/garden/tile-renderer.ts (constant exported for testability)
- grep -q 'setFillStyle.*HOVER_FILL_ALPHA\\|setFillStyle.*0\\.06' src/render/garden/tile-renderer.ts (hover fill bump present)
- test -f src/render/garden/tile-renderer.test.ts (test file exists)
- npx vitest run src/render/garden/tile-renderer.test.ts exits 0 (all 5 cases green)
- npm run ci exits 0 (no regressions in the existing suite)
</acceptance_criteria>
npm run lint && npx vitest run src/render/garden/tile-renderer.test.ts && npm run ci
OUTLINE_COLOR=0x5a5a60 and OUTLINE_HOVER=0x7a7a82 brightened from the dim Plan-02-02 values; hover state additionally adds HOVER_FILL_ALPHA=0.06 fill bump. 5 Vitest cases green via Phaser-Scene-mock pattern. The dim-grid gap is structurally closed; G3 cleared in 02-VERIFICATION.md frontmatter.
import * as Phaser from 'phaser';
/**
* Phaser primitive gate visual + indicator (D-15) + wall context (G4).
*
* The gate sits at the right edge of the 4×4 garden (canvas pixel
* coordinates). When a Lura beat is pending — luraBeatProgress.pending
* is non-null — the glow rectangle alpha-pulses to telegraph the visit.
*
* Plan 02-06 G4 — additionally renders a faint vertical wall band at
* the gate's column connecting top-to-bottom of the canvas. Phaser
* primitive only (no painted texture, no animation). Phase 3 paints
* the watercolor wall over this band without changing the structural
* intent.
*/
const GATE_X = 880;
const GATE_Y = 384;
const GATE_COLOR = 0x6e6e75;
const GATE_GLOW_COLOR = 0xe8d8b6;
const GATE_HIT_W = 80;
const GATE_HIT_H = 120;
// Plan 02-06 G4 — wall band geometry. Spans the canvas height vertically
// and is centered on the gate's column. Faint alpha so the gate body
// reads as the load-bearing element; the wall is structural context only.
export const WALL_BAND_X = GATE_X;
export const WALL_BAND_WIDTH = GATE_HIT_W * 0.55; // narrower than the gate hit so the gate body still reads
export const WALL_BAND_HEIGHT = 768; // matches Phaser canvas height in src/game/main.ts
export const WALL_BAND_ALPHA = 0.18; // mid of the 0.15-0.20 fix_shape range
export const WALL_BAND_COLOR = GATE_COLOR; // same hue as gate body, low alpha distinguishes them
export interface GateGameObjects {
/** Plan 02-06 G4 — faint vertical wall band primitive (no animation). */
wall: Phaser.GameObjects.Rectangle;
hit: Phaser.GameObjects.Rectangle;
body: Phaser.GameObjects.Rectangle;
glow: Phaser.GameObjects.Rectangle;
glowTween: Phaser.Tweens.Tween | null;
}
/**
* drawGate — adds the four rectangles (wall / body / glow / hit) to the
* scene and returns handles. Z-order: wall (behind) → body → glow → hit.
*/
export function drawGate(scene: Phaser.Scene): GateGameObjects {
// Plan 02-06 G4 — wall band first (drawn behind everything else).
const wall = scene.add.rectangle(
WALL_BAND_X,
WALL_BAND_HEIGHT / 2, // y-centered on the canvas
WALL_BAND_WIDTH,
WALL_BAND_HEIGHT,
WALL_BAND_COLOR,
WALL_BAND_ALPHA,
);
const body = scene.add.rectangle(
GATE_X,
GATE_Y,
GATE_HIT_W * 0.7,
GATE_HIT_H,
GATE_COLOR,
);
const glow = scene.add.rectangle(
GATE_X,
GATE_Y,
GATE_HIT_W * 0.9,
GATE_HIT_H * 1.05,
GATE_GLOW_COLOR,
0,
);
glow.setBlendMode(Phaser.BlendModes.ADD);
// Hit rectangle: invisible, sits on top of the visual rectangles to
// capture pointer input.
const hit = scene.add.rectangle(
GATE_X,
GATE_Y,
GATE_HIT_W,
GATE_HIT_H,
0xffffff,
0,
);
hit.setInteractive({ useHandCursor: true });
hit.setData('isGate', true);
return { wall, hit, body, glow, glowTween: null };
}
/**
* updateGateIndicator — start/stop the soft alpha pulse based on
* whether a beat is pending. Idempotent. Plan 02-06 G4 leaves this
* function untouched — the wall band does NOT pulse.
*/
export function updateGateIndicator(
scene: Phaser.Scene,
gate: GateGameObjects,
isPending: boolean,
): void {
if (isPending && !gate.glowTween) {
gate.glowTween = scene.tweens.add({
targets: gate.glow,
alpha: { from: 0.0, to: 0.4 },
duration: 1200,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
} else if (!isPending && gate.glowTween) {
gate.glowTween.stop();
gate.glowTween = null;
gate.glow.setAlpha(0);
}
}
EXACT values required:
WALL_BAND_X = GATE_X(= 880; same column as gate body)WALL_BAND_WIDTH = GATE_HIT_W * 0.55(= 44; narrower than gate)WALL_BAND_HEIGHT = 768(full canvas height — matches src/game/main.ts width/height config)WALL_BAND_ALPHA = 0.18(mid-range of the 0.15-0.20 fix_shape spec)WALL_BAND_COLOR = GATE_COLOR(= 0x6e6e75; same hue as gate body, low alpha distinguishes)
Step 2 — Update GateGameObjects consumers in src/game/scenes/Garden.ts if needed.
Read Garden.ts for the shape it expects from drawGate(). The interface change is additive (added wall field). If Garden.ts destructures only { hit, body, glow } it will continue to work unchanged. If it does gate = drawGate(scene) and stores the whole object, no change needed. Inspect and confirm — DO NOT introduce a Garden.ts change unless required.
Step 3 — Create src/render/garden/gate-renderer.test.ts — Vitest cases following the same Phaser-Scene-mock pattern from Task 3:
import { describe, it, expect, vi } from 'vitest';
import {
drawGate,
WALL_BAND_X,
WALL_BAND_WIDTH,
WALL_BAND_HEIGHT,
WALL_BAND_ALPHA,
WALL_BAND_COLOR,
} from './gate-renderer';
/**
* G4 (gap closure 02-06) — assert gate-renderer adds a faint vertical
* wall band primitive at the gate's column.
*
* Phaser-Scene-mock pattern from Plan 02-06 Task 3 (avoids the Phaser 4
* / happy-dom canvas.getContext incompatibility per Plan 02-02 SUMMARY).
*/
describe('gate-renderer (Plan 02-06 G4 closure)', () => {
it('exports the wall band geometry constants with expected values', () => {
expect(WALL_BAND_X).toBe(880); // matches GATE_X
expect(WALL_BAND_HEIGHT).toBe(768); // matches Phaser canvas height
expect(WALL_BAND_ALPHA).toBeGreaterThanOrEqual(0.15); // fix_shape range
expect(WALL_BAND_ALPHA).toBeLessThanOrEqual(0.20); // fix_shape range
expect(WALL_BAND_COLOR).toBe(0x6e6e75); // same hue as GATE_COLOR
expect(WALL_BAND_WIDTH).toBeGreaterThan(0);
});
it('drawGate adds the wall primitive at the gate column with low alpha', () => {
const rectangle = {
setInteractive: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
setData: vi.fn().mockReturnThis(),
setBlendMode: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
};
const scene = {
add: {
rectangle: vi.fn(() => rectangle),
},
tweens: { add: vi.fn() },
} as unknown as Phaser.Scene;
drawGate(scene);
// First scene.add.rectangle call is the wall band (per drawGate
// implementation order — wall is drawn behind everything else).
const firstCall = (scene.add.rectangle as ReturnType<typeof vi.fn>).mock.calls[0];
// Signature: (x, y, width, height, fillColor, fillAlpha)
expect(firstCall[0]).toBe(WALL_BAND_X); // x
expect(firstCall[1]).toBe(WALL_BAND_HEIGHT / 2); // y-centered
expect(firstCall[2]).toBe(WALL_BAND_WIDTH); // width
expect(firstCall[3]).toBe(WALL_BAND_HEIGHT); // height = canvas height (full vertical span)
expect(firstCall[4]).toBe(WALL_BAND_COLOR); // color
expect(firstCall[5]).toBe(WALL_BAND_ALPHA); // alpha — low (0.18)
});
it('drawGate creates 4 rectangles total (wall + body + glow + hit)', () => {
const rectangle = {
setInteractive: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
setData: vi.fn().mockReturnThis(),
setBlendMode: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
};
const scene = {
add: {
rectangle: vi.fn(() => rectangle),
},
tweens: { add: vi.fn() },
} as unknown as Phaser.Scene;
drawGate(scene);
expect(scene.add.rectangle).toHaveBeenCalledTimes(4); // wall + body + glow + hit
});
it('returned GateGameObjects exposes the wall handle', () => {
const rectangle = {
setInteractive: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
setData: vi.fn().mockReturnThis(),
setBlendMode: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
};
const scene = {
add: { rectangle: vi.fn(() => rectangle) },
tweens: { add: vi.fn() },
} as unknown as Phaser.Scene;
const gate = drawGate(scene);
expect(gate.wall).toBeDefined();
expect(gate.body).toBeDefined();
expect(gate.glow).toBeDefined();
expect(gate.hit).toBeDefined();
expect(gate.glowTween).toBeNull();
});
});
Commit: fix(02-06,G4): add wall band primitive in gate-renderer — close floating-gate gap. Run npm run lint && npx vitest run src/render/garden/ && npm run ci.
<acceptance_criteria>
- grep -q 'WALL_BAND_X' src/render/garden/gate-renderer.ts (constant present)
- grep -q 'WALL_BAND_HEIGHT' src/render/garden/gate-renderer.ts (constant present)
- grep -q 'WALL_BAND_ALPHA' src/render/garden/gate-renderer.ts (constant present)
- grep -qE 'WALL_BAND_ALPHA\s*=\s*0\.(15|16|17|18|19|20)' src/render/garden/gate-renderer.ts (alpha in fix_shape range 0.15-0.20)
- grep -qE 'WALL_BAND_HEIGHT\s*=\s*768' src/render/garden/gate-renderer.ts (full canvas height)
- grep -q 'wall:\s*Phaser.GameObjects.Rectangle' src/render/garden/gate-renderer.ts (interface field added)
- test -f src/render/garden/gate-renderer.test.ts (test file exists)
- npx vitest run src/render/garden/gate-renderer.test.ts exits 0 (all 4 cases green)
- npm run ci exits 0 (no regressions in the existing suite — Garden.ts integration unbroken)
</acceptance_criteria>
npm run lint && npx vitest run src/render/garden/gate-renderer.test.ts && npm run ci
drawGate adds a 4th primitive (wall band, full canvas height, alpha 0.18) at the gate's column. GateGameObjects exposes the new wall handle. 4 Vitest cases green via the Phaser-Scene-mock pattern. The floating-gate gap is structurally closed; G4 cleared in 02-VERIFICATION.md frontmatter.
The existing spec has these numbered steps:
- Step 1: page.goto('/?devtime=fake') → ensureFreshSave → reload
- Step 2-3: Begin button visible → click
- Step 4: wait for __tlgStore + __tlgFakeClock
- Step 5-6: enqueue plantSeed → advance clock 1s
- Step 7+: harvest, reveal, journal, reload, persist...
Add 3 new assertions:
Assertion A (after Step 1 reload, before Step 2 — body background):
After the second await page.goto('/?devtime=fake'); and before the Begin button visibility check, add:
// ASSERTION A (Plan 02-06 G1) — body background is #1a1a1a from frame
// one. The dark canvas no longer floats in a sea of white.
const bodyBg = await page.evaluate(() => {
return window.getComputedStyle(document.body).backgroundColor;
});
expect(bodyBg).toBe('rgb(26, 26, 26)'); // #1a1a1a in computed-style form
Assertion B (after Step 3 — Begin dismissed; before Step 4 — wait for store):
After await expect(beginButton).not.toBeVisible(...) and before the page.waitForFunction for the test slots, add:
// ASSERTION B (Plan 02-06 G2) — first-run hint is visible immediately
// after Begin dismisses (the A-Dark-Room first-prompt). Player sees
// the externalized line from ui-strings.yaml.
await expect(page.getByTestId('first-run-hint')).toBeVisible({ timeout: 5000 });
const hintText = await page.getByTestId('first-run-hint').textContent();
expect(hintText).toBeTruthy();
expect(hintText!.length).toBeGreaterThan(0);
Assertion C (after Step 6 — plantSeed applied to tile 0; before Step 7 — fast-forward growth):
After the page.waitForFunction that confirms tiles[0]?.plant?.plantTypeId === 'rosemary', add:
// ASSERTION C (Plan 02-06 G2) — first-run hint auto-dismisses on the
// first plant. The component subscribes to the tiles slice and
// dismisses when any tile transitions to plant !== null.
await expect(page.getByTestId('first-run-hint')).not.toBeVisible({ timeout: 5000 });
CRITICAL — these are ADDITIONS, not replacements. The spec's existing 16-step assertion chain MUST continue to pass unchanged. The 3 new assertions slot into the existing flow at the labeled positions.
Step 2 — Run the full pipeline + e2e to prove all 4 gaps closed end-to-end:
npm run ci && npm run test:e2e
The e2e must exit 0 with the 3 new assertions green AND the existing 16 steps green.
Step 3 — Update .planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md is the verifier's job, NOT this plan's. The verifier consumes the SUMMARY this plan emits and re-runs verification to flip status from gaps_found → verified. This plan ONLY closes the gaps in code; the verifier records the closure.
Commit: test(02-06): playwright e2e assertions for G1+G2 — phase-2 gap closure complete. Run npm run test:e2e to verify the spec stays green; the existing PIPE-07 1.6s test runtime will grow only fractionally (3 cheap evaluations + 1 visibility check + 1 negation).
<acceptance_criteria>
- grep -q 'rgb(26, 26, 26)' tests/e2e/season1-loop.spec.ts (Assertion A — body bg check present)
- grep -q "getByTestId\\('first-run-hint'\\)" tests/e2e/season1-loop.spec.ts (Assertion B + C — first-run-hint selector present)
- grep -q "toBeVisible" tests/e2e/season1-loop.spec.ts (Assertion B — visibility check present)
- grep -q "not.toBeVisible.*first-run-hint\\|first-run-hint.*not.toBeVisible" tests/e2e/season1-loop.spec.ts (Assertion C — negative visibility check after plant; this regex is loose because formatting may insert newlines; if it fails simply confirm by reading the spec)
- npm run ci exits 0 (vitest 312+ tests + lint + build + asset validate + bundle split — full Phase-2 pipeline green with all 4 gap fixes)
- npm run test:e2e exits 0 (Playwright PIPE-07 spec + 3 new gap-closure assertions, all green)
- All 4 gaps in .planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md frontmatter gaps: block are structurally closed (executor confirms by reading the gap entries against the corresponding files)
</acceptance_criteria>
npm run ci && npm run test:e2e
Playwright spec extended with 3 new assertions covering G1 + G2 end-to-end in a real Chromium browser. npm run ci && npm run test:e2e exits 0. All 4 first-impression UX gaps are structurally closed; the verifier handoff is unblocked. The Phase-2 vertical slice is shippable as a free standalone Season-1 prologue (modulo the 6 HUMAN-UAT.md tone items, which remain pending and are out of scope for this plan).
npm run ciexits 0 (full Phase-2 pipeline + Phase-2 gap fixes; vitest test count grows by ~16 cases — 6 G1/G2 + 5 G3 + 4 G4 + 1 minor housekeeping; final count ~328 tests).npm run test:e2eexits 0 (Playwright PIPE-07 spec + 3 new gap-closure assertions).- Manually:
npm run devand verify the dark canvas no longer floats in a white viewport, the FirstRunHint line appears after Begin, the 4×4 grid is legible, and the gate reads as part of a wall. (Optional — the e2e covers G1 + G2 structurally; G3 + G4 are color-value changes that the unit tests pin.) - The 4 gap entries in
.planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.mdfrontmattergaps:block are structurally closed:- G1:
src/index.cssexists,src/main.tsximports it, body bg is#1a1a1a. - G2:
FirstRunHintcomponent exists, ui-strings.yaml hasfirst_run_hint, session slice hasfirstRunHintDismissed. - G3:
OUTLINE_COLOR=0x5a5a60,OUTLINE_HOVER=0x7a7a82, hover fill bump in tile-renderer. - G4: wall band primitive in gate-renderer at
WALL_BAND_X=GATE_XwithWALL_BAND_ALPHA=0.18.
- G1:
The verifier (gsd-verifier) consumes this plan's SUMMARY and re-runs verification. Status flips from gaps_found → verified. The 6 HUMAN-UAT.md tone items remain pending below the now-cleared structural gaps and are addressed by a separate workflow.
<success_criteria>
- All 4 first-impression UX gaps from 2026-05-09 live UAT are structurally closed:
- G1 BLOCKING: white halo gone — body bg #1a1a1a from frame one (CSS smoke test + e2e Assertion A green).
- G2 BLOCKING: A-Dark-Room first-prompt present — FirstRunHint shows after Begin, externalized copy, auto-dismiss on first plant (6 unit tests + e2e Assertions B + C green).
- G3 HIGH: tile grid legible — OUTLINE_COLOR=0x5a5a60, OUTLINE_HOVER=0x7a7a82, hover fill bump (5 unit tests green).
- G4 MEDIUM: gate has wall context — vertical band primitive at GATE_X with alpha 0.18 spanning canvas height (4 unit tests green).
- Phase 3 watercolor + cello deferral preserved: zero painted assets added, zero new npm dependencies, all fixes use Phaser primitives or one CSS file.
- V1Payload unchanged (no migrations[2]); firstRunHintDismissed lives in src/store/session-slice.ts as session-state only.
npm run ciexits 0 (lint + compile:ink + vitest 312+ green + validate:assets + build + check:bundle-split).npm run test:e2eexits 0 (Playwright PIPE-07 + 3 new gap-closure assertions).- 24/24 Phase-2 REQ-IDs remain structurally PASS (no regressions). GARD-01, AEST-07, UX-01 receive lived-experience reinforcement (no REQ-ID flip — those were already structurally PASS).
- Verifier handoff unblocked:
.planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.mdis ready for re-verification to flip status fromgaps_found→verified. - 6 HUMAN-UAT.md tone items remain pending (out of scope for this plan; addressed by separate workflow). </success_criteria>
- The 4 gap fixes shipped (G1 src/index.css, G2 FirstRunHint, G3 tile-renderer brightening, G4 gate-renderer wall band).
- The exact ui-strings.yaml
first_run_hintcopy chosen by the executor (and rationale if it deviates from the Step 1 recommendation "Begin where the soil is bare."). - Test counts before/after this plan (existing ~312 vitest + new ~16 = ~328) and the Playwright assertion count (existing 16 + new 3 = 19).
- Confirmation that V1Payload is unchanged and migrations remain a single key (no migrations[2]).
- Confirmation that no new npm dependencies were added and no painted assets were introduced.
- Confirmation that the 4 gap entries in 02-VERIFICATION.md frontmatter
gaps:block are structurally closed (cite the file evidence per gap). - Handoff note: 6 HUMAN-UAT.md tone items (Lura voice, letter cadence, Begin tonal feel, etc.) remain pending and are addressed by a separate workflow.
- requirements-completed: [GARD-01 (supplemental), AEST-07 (supplemental), UX-01 (supplemental)] — these REQ-IDs were already PASS structurally; this plan reinforces lived UX.