0ed79b0eb1
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>
1462 lines
74 KiB
Markdown
1462 lines
74 KiB
Markdown
---
|
||
phase: 02
|
||
plan: 06
|
||
type: execute
|
||
wave: 0
|
||
depends_on: [02-01, 02-02, 02-03, 02-04, 02-05]
|
||
gap_closure: true
|
||
files_modified:
|
||
- src/index.css
|
||
- src/main.tsx
|
||
- src/index.css.test.ts
|
||
- src/store/session-slice.ts
|
||
- src/content/schemas/ui-strings.ts
|
||
- content/seasons/01-soil/ui-strings.yaml
|
||
- src/ui/first-run/FirstRunHint.tsx
|
||
- src/ui/first-run/FirstRunHint.test.tsx
|
||
- src/ui/first-run/index.ts
|
||
- src/ui/index.ts
|
||
- src/App.tsx
|
||
- src/render/garden/tile-renderer.ts
|
||
- src/render/garden/tile-renderer.test.ts
|
||
- src/render/garden/gate-renderer.ts
|
||
- src/render/garden/gate-renderer.test.ts
|
||
- tests/e2e/season1-loop.spec.ts
|
||
autonomous: true
|
||
requirements: [GARD-01, AEST-07, UX-01]
|
||
tags: [gap-closure, uat, css, first-run-hint, tile-contrast, gate-context, mvp]
|
||
|
||
must_haves:
|
||
truths:
|
||
- "G1 closed: <body> has background #1a1a1a + serif color #e8e0d0 + zero margin from frame one — no white halo around the dark canvas (ROADMAP SC1 supplemental — UX-01 lived experience)"
|
||
- "G2 closed: after BeginScreen dismisses on first run, a single bible-voice line is visible (e.g. 'Begin where the soil is bare.') from `season1.ui_strings.first_run_hint` in ui-strings.yaml — never hardcoded (CLAUDE.md tone constraint, GARD-01 lived experience)"
|
||
- "G2 closed: FirstRunHint auto-dismisses on the first successful plantSeed dispatch and stays dismissed for the session; reload shows it again until first plant (A-Dark-Room first-run-of-this-tab UX, NOT save-state)"
|
||
- "G2 closed: `firstRunHintDismissed` lives in src/store/session-slice.ts (NOT in V1Payload — no migrations[2]); resets on hard reload by design"
|
||
- "G3 closed: empty-tile outline is brighter than 0x4d4d52 (final value ~0x5a5a60) AND the hover state contrasts the resting state (~0x7a7a82 outline + slight fill alpha bump) so the 4×4 grid reads as legible interactive surfaces against #1a1a1a — no painted assets (Phase 3 deferral preserved, GARD-01 lived experience)"
|
||
- "G4 closed: gate-renderer.ts adds a faint vertical wall band (Phaser primitive — alpha 0.15-0.20 against #1a1a1a) at the gate's column connecting top-to-bottom of the canvas, so the gate reads as part of a wall rather than a floating rectangle (Bible 'walled garden', AEST-07 supplemental coverage)"
|
||
- "Phase 3 watercolor + cello deferral preserved: every fix uses Phaser primitives or one CSS file. NO painted assets. NO new image loads. NO new npm dependencies."
|
||
- "Tests landed for all 4 gaps: src/index.css.test.ts (G1), src/ui/first-run/FirstRunHint.test.tsx (G2), src/render/garden/tile-renderer.test.ts (G3), src/render/garden/gate-renderer.test.ts (G4)."
|
||
- "Playwright tests/e2e/season1-loop.spec.ts extended with two assertions: (a) document.body computed style backgroundColor is rgb(26, 26, 26) after navigation, (b) the FirstRunHint line is visible after Begin dismiss, (c) the FirstRunHint is gone after the first plantSeed dispatches."
|
||
- "After execution: gsd-verifier handoff is unblocked. The 4 gaps in 02-VERIFICATION.md frontmatter `gaps:` block clear (status gaps_found → verified). The 6 HUMAN-UAT.md tone items remain pending (out of scope for this plan)."
|
||
- "All 24 Phase-2 REQ-IDs remain structurally PASS — none of these gap-fix changes regress any existing test (full `npm run ci` exits 0 + Playwright e2e exits 0)."
|
||
|
||
artifacts:
|
||
- path: src/index.css
|
||
provides: "Global page styles — body bg #1a1a1a, color #e8e0d0, zero margin, full viewport height, serif family, #game-container centered. Imported once from src/main.tsx so Vite bundles it into the entry chunk. ~15 lines."
|
||
- path: src/index.css.test.ts
|
||
provides: "Vitest smoke test asserting the CSS rules are present in the source file (file-read assertion; sufficient for a single-file static stylesheet)."
|
||
- path: src/ui/first-run/FirstRunHint.tsx
|
||
provides: "FirstRunHint component — renders a single bible-voice line when session.firstRunHintDismissed is false AND session.beginGateDismissed is true; null otherwise. Reads the line from uiStrings[1].first_run_hint (externalized per STRY-09)."
|
||
exports: ["FirstRunHint"]
|
||
- path: src/ui/first-run/FirstRunHint.test.tsx
|
||
provides: "Vitest cases: hidden when beginGateDismissed=false; visible after Begin dismiss; hidden when firstRunHintDismissed=true; renders the externalized string (not hardcoded); auto-dismisses when a plantSeed command commits (selectFirstPlantHasOccurred subscription)."
|
||
- path: content/seasons/01-soil/ui-strings.yaml
|
||
provides: "first_run_hint: <single bible-voice line> added under the season-1 UI strings tree. Candidate copy ranked per Step 1: 'Begin where the soil is bare.' (Recommended — bible-voice) / 'The soil is waiting.' (alternative — quieter) / 'Click a tile to plant.' (functional fallback)."
|
||
- path: src/store/session-slice.ts
|
||
provides: "Adds `firstRunHintDismissed: boolean` + `dismissFirstRunHint()` action. Session-state only — NEVER added to V1Payload (per scope_constraint #3 — no migrations[2])."
|
||
- path: src/render/garden/tile-renderer.ts
|
||
provides: "OUTLINE_COLOR brightened to ~0x5a5a60; OUTLINE_HOVER brightened to ~0x7a7a82; hover adds a slight fill alpha bump on the hit rectangle (no animation noise — pointer-driven, reduced-motion-friendly)."
|
||
- path: src/render/garden/tile-renderer.test.ts
|
||
provides: "Vitest cases: drawTiles produces 16 tile groups; outline draw call uses OUTLINE_COLOR=0x5a5a60 (assert via mocked Phaser.Scene.add.graphics call args); pointerover handler uses OUTLINE_HOVER=0x7a7a82 (assert via stubbed event)."
|
||
- path: src/render/garden/gate-renderer.ts
|
||
provides: "drawGate() additionally adds a faint vertical wall band Phaser primitive at the gate's column (x ≈ GATE_X) spanning the canvas height (y=0 → y=768), color GATE_COLOR with alpha ~0.18. Stored on GateGameObjects as `wall: Phaser.GameObjects.Rectangle`."
|
||
- path: src/render/garden/gate-renderer.test.ts
|
||
provides: "Vitest case: drawGate adds the wall primitive at the expected x with low alpha (assert via mocked Phaser.Scene.add.rectangle call args matching the wall band signature: x near GATE_X, full canvas height, GATE_COLOR, alpha ~0.18)."
|
||
- path: tests/e2e/season1-loop.spec.ts
|
||
provides: "Three new assertions threaded into the existing PIPE-07 spec: (a) body computed bg is rgb(26, 26, 26) on first nav, (b) FirstRunHint visible after Begin click, (c) FirstRunHint gone after the first plantSeed enqueue."
|
||
|
||
key_links:
|
||
- from: src/main.tsx
|
||
to: src/index.css
|
||
via: "import './index.css'; — Vite bundles the CSS into the entry chunk so body styles apply before React mounts"
|
||
pattern: "import.*index\\.css"
|
||
- from: src/App.tsx
|
||
to: src/ui/first-run/FirstRunHint.tsx
|
||
via: "<FirstRunHint /> mounted alongside Letter/Settings/etc."
|
||
pattern: "<FirstRunHint"
|
||
- from: src/ui/first-run/FirstRunHint.tsx
|
||
to: src/store/session-slice.ts
|
||
via: "useAppStore subscriptions to firstRunHintDismissed + beginGateDismissed; dismiss action wired to plantSeed-commit signal"
|
||
pattern: "firstRunHintDismissed"
|
||
- from: src/render/garden/tile-renderer.ts
|
||
to: src/render/garden/tile-renderer.test.ts
|
||
via: "OUTLINE_COLOR + OUTLINE_HOVER constants — exported (or asserted via re-read) so the test pins the brightened values"
|
||
pattern: "0x5a5a60|0x7a7a82"
|
||
- from: src/render/garden/gate-renderer.ts
|
||
to: src/render/garden/gate-renderer.test.ts
|
||
via: "drawGate produces wall primitive at the gate column with alpha ~0.18 — Phaser.Scene mock captures the rectangle args"
|
||
pattern: "wall.*alpha"
|
||
---
|
||
|
||
<note>
|
||
**Gap-closure plan. Depends on Plans 02-01..02-05 (all already executed).**
|
||
|
||
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).
|
||
</note>
|
||
|
||
<objective>
|
||
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.
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<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.md
|
||
|
||
<interfaces>
|
||
<!-- Current state of files this plan touches. Extracted from the live codebase 2026-05-09. -->
|
||
<!-- Executor should NOT explore further — these excerpts are sufficient. -->
|
||
|
||
From src/main.tsx (currently 14 lines, no CSS import):
|
||
```typescript
|
||
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):
|
||
```html
|
||
<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'):
|
||
```typescript
|
||
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):
|
||
```typescript
|
||
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):
|
||
```typescript
|
||
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):
|
||
```typescript
|
||
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):
|
||
```typescript
|
||
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):
|
||
```typescript
|
||
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):
|
||
```yaml
|
||
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):
|
||
```typescript
|
||
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.
|
||
</interfaces>
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto">
|
||
<name>Task 1 (G1 — BLOCKING): Add src/index.css imported from main.tsx — close the white-halo gap</name>
|
||
<read_first>
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md (frontmatter `gaps:` G1 entry — surface, evidence, fix_shape verbatim)
|
||
- src/main.tsx (current 14-line entry — needs an `import './index.css';` line added after the StrictMode/createRoot imports)
|
||
- src/game/main.ts (Phaser canvas backgroundColor #1a1a1a — body must match for tonal coherence)
|
||
- index.html (currently no `<style>` tag, no global CSS — body uses browser default white)
|
||
- src/ui/begin/BeginScreen.tsx (existing #1a1a1a / #e8e0d0 / serif palette — match these exactly so BeginScreen sits flush against the body)
|
||
- CLAUDE.md (banner concern #6 — anti-FOMO; nothing in CSS should suggest urgency or nag)
|
||
</read_first>
|
||
<files>
|
||
src/index.css,
|
||
src/main.tsx,
|
||
src/index.css.test.ts
|
||
</files>
|
||
<action>
|
||
**Step 1 — Create `src/index.css`** with the following content EXACTLY (~15 lines; matches the BeginScreen palette so the body and the canvas share tonal register):
|
||
|
||
```css
|
||
/* 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:
|
||
- `body` background color: `#1a1a1a` (matches Phaser config + BeginScreen)
|
||
- `body` text color: `#e8e0d0` (matches BeginScreen typography)
|
||
- `body` margin: `0` AND padding: `0` (kill browser default 8px margin)
|
||
- `body` min-height: `100vh` (so the dark bg fills viewport even on a short page)
|
||
- `body` font-family: `serif` (matches BeginScreen)
|
||
- `#game-container` flex 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:
|
||
|
||
```typescript
|
||
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):
|
||
|
||
```typescript
|
||
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.
|
||
</action>
|
||
<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>
|
||
<verify>
|
||
<automated>npm run lint && npx vitest run src/index.css.test.ts && npm run build</automated>
|
||
</verify>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>Task 2 (G2 — BLOCKING): Add FirstRunHint component + ui-strings.yaml first_run_hint copy + session flag — close the no-first-run-prompt gap</name>
|
||
<read_first>
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md (frontmatter `gaps:` G2 entry — surface, evidence, fix_shape verbatim)
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-22 — beginGateDismissed; D-23 — first-harvest journal reveal pattern; analog patterns to follow)
|
||
- src/store/session-slice.ts (current SessionSlice interface — Plan 02-05 last edit; ADD firstRunHintDismissed + dismissFirstRunHint here)
|
||
- src/ui/begin/BeginScreen.tsx (analog component: subscribes to a session flag, returns null when dismissed, fixed-position overlay; copy this shape but make it MUCH smaller — single line of text, no full-screen dialog)
|
||
- src/ui/journal/journal-icon.tsx (analog corner-affordance pattern — Plan 02-03 mounts only when revealed; same null-when-not-needed pattern)
|
||
- content/seasons/01-soil/ui-strings.yaml (current shape — Plan 02-05 last edit; ADD first_run_hint key)
|
||
- src/content/schemas/ui-strings.ts (UiStringsSchema definition — Zod default is "strip" mode, so unknown YAML keys SILENTLY DROP from parsed.data even though parse succeeds. The schema MUST be extended unconditionally — see Step 2.)
|
||
- src/content/loader.ts (uiStrings export — confirm new key flows through)
|
||
- CLAUDE.md (Tone — bible voice, warm, specific, intermittent; STRY-09 — externalized strings only)
|
||
- .planning/anti-fomo-doctrine.md (rule out any nag/urgency copy)
|
||
</read_first>
|
||
<files>
|
||
content/seasons/01-soil/ui-strings.yaml,
|
||
src/content/schemas/ui-strings.ts,
|
||
src/store/session-slice.ts,
|
||
src/ui/first-run/FirstRunHint.tsx,
|
||
src/ui/first-run/FirstRunHint.test.tsx,
|
||
src/ui/first-run/index.ts,
|
||
src/ui/index.ts,
|
||
src/App.tsx
|
||
</files>
|
||
<action>
|
||
**Step 1 — Add `first_run_hint` to `content/seasons/01-soil/ui-strings.yaml`.**
|
||
|
||
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):
|
||
|
||
1. `"Begin where the soil is bare."` ← **Recommended** — bible-voice; specific (`soil`, `bare`), contemplative, honors the project's tonal register
|
||
2. `"The soil is waiting."` ← alternative — quieter, even more bible-voice; consider for HUMAN-UAT review
|
||
3. `"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:
|
||
|
||
```yaml
|
||
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](https://zod.dev/?id=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 `})`):
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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`:**
|
||
|
||
```typescript
|
||
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`:**
|
||
|
||
```typescript
|
||
export { FirstRunHint } from './FirstRunHint';
|
||
```
|
||
|
||
**Step 6 — Extend `src/ui/index.ts` to re-export the new directory:**
|
||
|
||
```typescript
|
||
// Add this line near the other re-exports:
|
||
export * from './first-run';
|
||
```
|
||
|
||
**Step 7 — Create `src/ui/first-run/FirstRunHint.test.tsx`** — Vitest cases:
|
||
|
||
```typescript
|
||
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:
|
||
```typescript
|
||
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):
|
||
|
||
```typescript
|
||
// 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`.
|
||
</action>
|
||
<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>
|
||
<verify>
|
||
<automated>npm run lint && npx vitest run src/ui/first-run/ && npm run ci</automated>
|
||
</verify>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>Task 3 (G3 — HIGH): Brighten tile outline + hover state in tile-renderer.ts — close the dim-grid gap</name>
|
||
<read_first>
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md (frontmatter `gaps:` G3 entry — surface, evidence, fix_shape verbatim including the exact color hex bumps 0x4d4d52 → 0x5a5a60 and 0x6e6e75 → 0x7a7a82)
|
||
- src/render/garden/tile-renderer.ts (current 50-line file with OUTLINE_COLOR=0x4d4d52, OUTLINE_HOVER=0x6e6e75, OUTLINE_ALPHA=0.6, drawTiles + drawOutline; G3 ONLY changes the two color constants and adds a hover fill alpha bump)
|
||
- src/render/garden/tile-coords.ts (analog — for understanding GRID_LAYOUT.tileSize used by drawOutline)
|
||
- src/game/scenes/Garden.ts (consumer of drawTiles — confirm Phase 2 nothing else needs to change beyond the renderer's internals)
|
||
- src/render/garden/gate-renderer.ts (analog — for the GateGameObjects pattern with extra primitives, useful structural reference for Task 4's wall band)
|
||
- CLAUDE.md (Phase 3 watercolor deferral — G3 fix MUST stay color-only, no painted assets, no new sprites)
|
||
</read_first>
|
||
<files>
|
||
src/render/garden/tile-renderer.ts,
|
||
src/render/garden/tile-renderer.test.ts
|
||
</files>
|
||
<action>
|
||
**Step 1 — Modify `src/render/garden/tile-renderer.ts`:**
|
||
|
||
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`):
|
||
|
||
```typescript
|
||
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.
|
||
|
||
```typescript
|
||
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`.
|
||
</action>
|
||
<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>
|
||
<verify>
|
||
<automated>npm run lint && npx vitest run src/render/garden/tile-renderer.test.ts && npm run ci</automated>
|
||
</verify>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>Task 4 (G4 — MEDIUM): Add faint vertical wall band primitive in gate-renderer.ts — close the floating-gate gap</name>
|
||
<read_first>
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md (frontmatter `gaps:` G4 entry — surface, evidence, fix_shape verbatim: "Add a faint vertical line/band in gate-renderer connecting top-to-bottom of the canvas at the gate's column (Phaser primitive — alpha ~0.15-0.20 against #1a1a1a). Phase 3 paints over without changing structural intent.")
|
||
- src/render/garden/gate-renderer.ts (current ~95-line file with GATE_X=880, GATE_Y=384, GATE_COLOR=0x6e6e75, GATE_HIT_W=80, GATE_HIT_H=120; drawGate creates body/glow/hit rectangles + setBlendMode ADD on glow)
|
||
- src/game/main.ts (Phaser canvas height — 768px; the wall band must span 0 → 768 vertically)
|
||
- src/game/scenes/Garden.ts (consumer of drawGate — confirm wall band integration won't break existing call sites; the wall is added behind the body in z-order)
|
||
- src/render/garden/tile-renderer.ts (analog — for testability mock pattern from Task 3)
|
||
- CLAUDE.md (Phase 3 deferral — primitive only, no painted texture, no animation)
|
||
</read_first>
|
||
<files>
|
||
src/render/garden/gate-renderer.ts,
|
||
src/render/garden/gate-renderer.test.ts
|
||
</files>
|
||
<action>
|
||
**Step 1 — Modify `src/render/garden/gate-renderer.ts`** to add a faint vertical wall band primitive in `drawGate` and expose it on `GateGameObjects`:
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
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`.
|
||
</action>
|
||
<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>
|
||
<verify>
|
||
<automated>npm run lint && npx vitest run src/render/garden/gate-renderer.test.ts && npm run ci</automated>
|
||
</verify>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>Task 5 (Integration): Extend Playwright e2e + run full ci + test:e2e to verify all 4 gap fixes hold end-to-end</name>
|
||
<read_first>
|
||
- tests/e2e/season1-loop.spec.ts (Plan 02-05 — current 220-line spec; Task 5 threads 3 new assertions into the existing happy-path flow without rewriting the structure)
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md (frontmatter `gaps:` block — confirm all 4 gap fixes ship in Tasks 1-4)
|
||
- playwright.config.ts (Plan 02-05 — port 5273 + strictPort; baseURL is what page.goto('/') resolves against)
|
||
- src/ui/first-run/FirstRunHint.tsx (Task 2 output — data-testid="first-run-hint" is the e2e selector)
|
||
- src/index.css (Task 1 output — body bg #1a1a1a is what e2e asserts via document.body computed style)
|
||
</read_first>
|
||
<files>
|
||
tests/e2e/season1-loop.spec.ts
|
||
</files>
|
||
<action>
|
||
**Step 1 — Extend `tests/e2e/season1-loop.spec.ts`** with 3 new assertions threaded into the existing happy-path test. DO NOT rewrite the spec; ADD assertions at the natural points.
|
||
|
||
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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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).
|
||
</action>
|
||
<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>
|
||
<verify>
|
||
<automated>npm run ci && npm run test:e2e</automated>
|
||
</verify>
|
||
<done>
|
||
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).
|
||
</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<verification>
|
||
After all 5 tasks land:
|
||
|
||
1. `npm run ci` exits 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).
|
||
2. `npm run test:e2e` exits 0 (Playwright PIPE-07 spec + 3 new gap-closure assertions).
|
||
3. Manually: `npm run dev` and 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.)
|
||
4. The 4 gap entries in `.planning/phases/02-season-1-vertical-slice-soil/02-VERIFICATION.md` frontmatter `gaps:` block are structurally closed:
|
||
- G1: `src/index.css` exists, `src/main.tsx` imports it, body bg is `#1a1a1a`.
|
||
- G2: `FirstRunHint` component exists, ui-strings.yaml has `first_run_hint`, session slice has `firstRunHintDismissed`.
|
||
- 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_X` with `WALL_BAND_ALPHA=0.18`.
|
||
|
||
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.
|
||
</verification>
|
||
|
||
<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 ci` exits 0 (lint + compile:ink + vitest 312+ green + validate:assets + build + check:bundle-split).
|
||
- `npm run test:e2e` exits 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.md` is ready for re-verification to flip status from `gaps_found` → `verified`.
|
||
- 6 HUMAN-UAT.md tone items remain pending (out of scope for this plan; addressed by separate workflow).
|
||
</success_criteria>
|
||
|
||
<output>
|
||
After completion, create `.planning/phases/02-season-1-vertical-slice-soil/02-06-uat-gap-closure-SUMMARY.md` documenting:
|
||
|
||
1. The 4 gap fixes shipped (G1 src/index.css, G2 FirstRunHint, G3 tile-renderer brightening, G4 gate-renderer wall band).
|
||
2. The exact ui-strings.yaml `first_run_hint` copy chosen by the executor (and rationale if it deviates from the Step 1 recommendation "Begin where the soil is bare.").
|
||
3. Test counts before/after this plan (existing ~312 vitest + new ~16 = ~328) and the Playwright assertion count (existing 16 + new 3 = 19).
|
||
4. Confirmation that V1Payload is unchanged and migrations remain a single key (no migrations[2]).
|
||
5. Confirmation that no new npm dependencies were added and no painted assets were introduced.
|
||
6. Confirmation that the 4 gap entries in 02-VERIFICATION.md frontmatter `gaps:` block are structurally closed (cite the file evidence per gap).
|
||
7. 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.
|
||
8. requirements-completed: [GARD-01 (supplemental), AEST-07 (supplemental), UX-01 (supplemental)] — these REQ-IDs were already PASS structurally; this plan reinforces lived UX.
|
||
</output>
|