Files
josh 0ed79b0eb1 docs(02-06): plan UAT gap closure (G1-G4)
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>
2026-05-09 12:05:52 -04:00

74 KiB
Raw Permalink Blame History

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
02-01
02-02
02-03
02-04
02-05
true
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
true
GARD-01
AEST-07
UX-01
gap-closure
uat
css
first-run-hint
tile-contrast
gate-context
mvp
truths artifacts key_links
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).
path provides
src/index.css 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 provides
src/index.css.test.ts Vitest smoke test asserting the CSS rules are present in the source file (file-read assertion; sufficient for a single-file static stylesheet).
path provides exports
src/ui/first-run/FirstRunHint.tsx 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).
FirstRunHint
path provides
src/ui/first-run/FirstRunHint.test.tsx 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 provides
content/seasons/01-soil/ui-strings.yaml 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 provides
src/store/session-slice.ts Adds `firstRunHintDismissed: boolean` + `dismissFirstRunHint()` action. Session-state only — NEVER added to V1Payload (per scope_constraint #3 — no migrations[2]).
path provides
src/render/garden/tile-renderer.ts 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 provides
src/render/garden/tile-renderer.test.ts 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 provides
src/render/garden/gate-renderer.ts 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 provides
src/render/garden/gate-renderer.test.ts 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 provides
tests/e2e/season1-loop.spec.ts 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.
from to via pattern
src/main.tsx src/index.css import './index.css'; — Vite bundles the CSS into the entry chunk so body styles apply before React mounts import.*index.css
from to via pattern
src/App.tsx src/ui/first-run/FirstRunHint.tsx <FirstRunHint /> mounted alongside Letter/Settings/etc. <FirstRunHint
from to via pattern
src/ui/first-run/FirstRunHint.tsx src/store/session-slice.ts useAppStore subscriptions to firstRunHintDismissed + beginGateDismissed; dismiss action wired to plantSeed-commit signal firstRunHintDismissed
from to via pattern
src/render/garden/tile-renderer.ts src/render/garden/tile-renderer.test.ts OUTLINE_COLOR + OUTLINE_HOVER constants — exported (or asserted via re-read) so the test pins the brightened values 0x5a5a60|0x7a7a82
from to via pattern
src/render/garden/gate-renderer.ts src/render/garden/gate-renderer.test.ts drawGate produces wall primitive at the gate column with alpha ~0.18 — Phaser.Scene mock captures the rectangle args wall.*alpha
**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).

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.md

From 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.
Task 1 (G1 — BLOCKING): Add src/index.css imported from main.tsx — close the white-halo gap - .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) src/index.css, src/main.tsx, src/index.css.test.ts **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):
/* 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:

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.

Task 2 (G2 — BLOCKING): Add FirstRunHint component + ui-strings.yaml first_run_hint copy + session flag — close the no-first-run-prompt gap - .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) 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 **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:

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.

Task 3 (G3 — HIGH): Brighten tile outline + hover state in tile-renderer.ts — close the dim-grid gap - .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) src/render/garden/tile-renderer.ts, src/render/garden/tile-renderer.test.ts **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):

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.

Task 4 (G4 — MEDIUM): Add faint vertical wall band primitive in gate-renderer.ts — close the floating-gate gap - .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) src/render/garden/gate-renderer.ts, src/render/garden/gate-renderer.test.ts **Step 1 — Modify `src/render/garden/gate-renderer.ts`** to add a faint vertical wall band primitive in `drawGate` and expose it on `GateGameObjects`:
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.

Task 5 (Integration): Extend Playwright e2e + run full ci + test:e2e to verify all 4 gap fixes hold end-to-end - 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) tests/e2e/season1-loop.spec.ts **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:

    // 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_foundverified. 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).

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_foundverified. 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 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_foundverified.
  • 6 HUMAN-UAT.md tone items remain pending (out of scope for this plan; addressed by separate workflow). </success_criteria>
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.