fix(02-06,G2): first-run hint after Begin — close A-Dark-Room first-prompt gap

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: <FirstRunHint /> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 12:13:04 -04:00
parent f52de0bdbb
commit c46fc75549
8 changed files with 183 additions and 0 deletions
+7
View File
@@ -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"
+2
View File
@@ -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() {
<div id="app">
<PhaserGame ref={phaserRef} />
<BeginScreen />
<FirstRunHint />
<SeedPicker />
<FragmentRevealModal />
<JournalIcon />
+4
View File
@@ -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<typeof UiStringsSchema>;
+15
View File
@@ -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<SessionSlice, [], [], SessionSlice> = (set) => ({
@@ -45,10 +58,12 @@ export const createSessionSlice: StateCreator<SessionSlice, [], [], SessionSlice
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 }),
});
+79
View File
@@ -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(<FirstRunHint />);
expect(screen.queryByTestId('first-run-hint')).toBeNull();
});
it('renders nothing when firstRunHintDismissed=true', () => {
appStore.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', () => {
appStore.setState({ beginGateDismissed: true });
render(<FirstRunHint />);
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(<FirstRunHint />);
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(<FirstRunHint />);
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(<FirstRunHint />);
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(<FirstRunHint />);
act(() => {
appStore.setState({
tiles: Array.from({ length: 16 }, (_, idx) => ({ idx, plant: null })),
});
});
expect(screen.queryByTestId('first-run-hint')).toBeNull();
});
});
+74
View File
@@ -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<Tile | null | undefined>).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>
);
}
+1
View File
@@ -0,0 +1 @@
export { FirstRunHint } from './FirstRunHint';
+1
View File
@@ -6,6 +6,7 @@
* UI layer.
*/
export * from './begin';
export * from './first-run';
export * from './garden';
export * from './journal';
export * from './dialogue';