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';