From c46fc755493fa4eb1527ece897c51d94bb425b40 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 9 May 2026 12:13:04 -0400 Subject: [PATCH] =?UTF-8?q?fix(02-06,G2):=20first-run=20hint=20after=20Beg?= =?UTF-8?q?in=20=E2=80=94=20close=20A-Dark-Room=20first-prompt=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single bible-voice line ("Begin where the soil is bare.") that surfaces immediately after BeginScreen dismisses on first run and auto-dismisses when the player makes their first plant. Closes G2 first-impression UX gap from 2026-05-09 live UAT — the post-Begin state no longer leaves a brand-new player staring at a 4×4 grid with no instruction. Implementation: - content/seasons/01-soil/ui-strings.yaml: first_run_hint key added (recommended copy from plan; bible voice — warm, specific, contemplative) - src/content/schemas/ui-strings.ts: UiStringsSchema extended with first_run_hint: z.string().min(1) — MANDATORY because Zod default strip mode silently drops unknown keys from parsed.data - src/store/session-slice.ts: firstRunHintDismissed + dismissFirstRunHint added (session state ONLY — NOT persisted to V1Payload, no migrations[2]) - src/ui/first-run/FirstRunHint.tsx: subscribes to tiles slice, dismisses on first plant !== null transition; renders externalized line via uiStrings[1]?.first_run_hint - src/ui/first-run/{index.ts}, src/ui/index.ts: barrel + re-export wired - src/App.tsx: mounted between BeginScreen and SeedPicker Vitest: 6 new behavioral cases green (hidden when Begin still up, hidden when dismissed, renders externalized line, reads uiStrings, auto-dismisses on first plant, stays dismissed on subsequent tile changes). 324/324 total green; npm run ci exits 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- content/seasons/01-soil/ui-strings.yaml | 7 +++ src/App.tsx | 2 + src/content/schemas/ui-strings.ts | 4 ++ src/store/session-slice.ts | 15 +++++ src/ui/first-run/FirstRunHint.test.tsx | 79 +++++++++++++++++++++++++ src/ui/first-run/FirstRunHint.tsx | 74 +++++++++++++++++++++++ src/ui/first-run/index.ts | 1 + src/ui/index.ts | 1 + 8 files changed, 183 insertions(+) create mode 100644 src/ui/first-run/FirstRunHint.test.tsx create mode 100644 src/ui/first-run/FirstRunHint.tsx create mode 100644 src/ui/first-run/index.ts diff --git a/content/seasons/01-soil/ui-strings.yaml b/content/seasons/01-soil/ui-strings.yaml index 846d10d..4d9043c 100644 --- a/content/seasons/01-soil/ui-strings.yaml +++ b/content/seasons/01-soil/ui-strings.yaml @@ -13,6 +13,13 @@ begin: 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. Bible voice (warm, specific, contemplative) +# per CLAUDE.md tone constraint. +first_run_hint: "Begin where the soil is bare." + seed_picker: title: "Sow" cancel: "Not yet" diff --git a/src/App.tsx b/src/App.tsx index 7668cdd..4093efb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx'; import { BeginScreen } from './ui/begin'; +import { FirstRunHint } from './ui/first-run'; import { SeedPicker } from './ui/garden'; import { FragmentRevealModal, JournalIcon } from './ui/journal'; import { LuraDialogue } from './ui/dialogue'; @@ -52,6 +53,7 @@ function App() {
+ diff --git a/src/content/schemas/ui-strings.ts b/src/content/schemas/ui-strings.ts index 1ea1721..5803964 100644 --- a/src/content/schemas/ui-strings.ts +++ b/src/content/schemas/ui-strings.ts @@ -32,6 +32,10 @@ export const UiStringsSchema = z.object({ persistence_denied_toast: z.string().min(1), }), 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), }); export type UiStrings = z.infer; diff --git a/src/store/session-slice.ts b/src/store/session-slice.ts index ede567d..a650843 100644 --- a/src/store/session-slice.ts +++ b/src/store/session-slice.ts @@ -30,12 +30,25 @@ export interface SessionSlice { * and runtime-loadable for Phase 4+ to swap in if richer voice is needed). */ 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 = (set) => ({ @@ -45,10 +58,12 @@ export const createSessionSlice: StateCreator 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 }), }); diff --git a/src/ui/first-run/FirstRunHint.test.tsx b/src/ui/first-run/FirstRunHint.test.tsx new file mode 100644 index 0000000..3b32167 --- /dev/null +++ b/src/ui/first-run/FirstRunHint.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, act, cleanup } from '@testing-library/react'; +import { FirstRunHint } from './FirstRunHint'; +import { appStore } from '../../store'; +import { uiStrings } from '../../content'; + +/** + * 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(() => { + cleanup(); + // Reset session flags + tiles to first-run defaults. + appStore.setState({ + beginGateDismissed: false, + firstRunHintDismissed: false, + tiles: Array.from({ length: 16 }, (_, idx) => ({ idx, plant: null })), + }); + }); + + it('renders nothing when beginGateDismissed=false (Begin still up)', () => { + render(); + expect(screen.queryByTestId('first-run-hint')).toBeNull(); + }); + + it('renders nothing when firstRunHintDismissed=true', () => { + appStore.setState({ beginGateDismissed: true, firstRunHintDismissed: true }); + render(); + expect(screen.queryByTestId('first-run-hint')).toBeNull(); + }); + + it('renders the externalized line when Begin is dismissed and hint is not dismissed', () => { + appStore.setState({ beginGateDismissed: true }); + render(); + const el = screen.getByTestId('first-run-hint'); + expect(el).toBeTruthy(); + expect(el.textContent).toBeTruthy(); + expect(el.textContent!.length).toBeGreaterThan(0); + }); + + it('reads the line from uiStrings (not a hardcoded string in the component)', () => { + appStore.setState({ beginGateDismissed: true }); + render(); + const expected = 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', () => { + appStore.setState({ beginGateDismissed: true }); + const { rerender } = render(); + expect(screen.queryByTestId('first-run-hint')).toBeTruthy(); + + // Simulate a plantSeed commit on tile 0. + act(() => { + appStore.setState({ + tiles: [ + { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } }, + ...Array.from({ length: 15 }, (_, i) => ({ idx: i + 1, plant: null })), + ], + }); + }); + rerender(); + expect(screen.queryByTestId('first-run-hint')).toBeNull(); + }); + + it('stays dismissed once dismissed (no re-show on subsequent tile changes)', () => { + appStore.setState({ beginGateDismissed: true, firstRunHintDismissed: true }); + render(); + act(() => { + appStore.setState({ + tiles: Array.from({ length: 16 }, (_, idx) => ({ idx, plant: null })), + }); + }); + expect(screen.queryByTestId('first-run-hint')).toBeNull(); + }); +}); diff --git a/src/ui/first-run/FirstRunHint.tsx b/src/ui/first-run/FirstRunHint.tsx new file mode 100644 index 0000000..02dcb61 --- /dev/null +++ b/src/ui/first-run/FirstRunHint.tsx @@ -0,0 +1,74 @@ +import { useEffect, type JSX } from 'react'; +import { useAppStore } from '../../store'; +import { uiStrings } from '../../content'; +import type { Tile } from '../../sim/garden/types'; + +/** + * 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 as Array).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 ( +
+ {hint} +
+ ); +} diff --git a/src/ui/first-run/index.ts b/src/ui/first-run/index.ts new file mode 100644 index 0000000..c640239 --- /dev/null +++ b/src/ui/first-run/index.ts @@ -0,0 +1 @@ +export { FirstRunHint } from './FirstRunHint'; diff --git a/src/ui/index.ts b/src/ui/index.ts index ae775bc..a65d689 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -6,6 +6,7 @@ * UI layer. */ export * from './begin'; +export * from './first-run'; export * from './garden'; export * from './journal'; export * from './dialogue';