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:
@@ -13,6 +13,13 @@ begin:
|
|||||||
subtitle: "tend"
|
subtitle: "tend"
|
||||||
cta: "Begin"
|
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:
|
seed_picker:
|
||||||
title: "Sow"
|
title: "Sow"
|
||||||
cancel: "Not yet"
|
cancel: "Not yet"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
|
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
|
||||||
import { BeginScreen } from './ui/begin';
|
import { BeginScreen } from './ui/begin';
|
||||||
|
import { FirstRunHint } from './ui/first-run';
|
||||||
import { SeedPicker } from './ui/garden';
|
import { SeedPicker } from './ui/garden';
|
||||||
import { FragmentRevealModal, JournalIcon } from './ui/journal';
|
import { FragmentRevealModal, JournalIcon } from './ui/journal';
|
||||||
import { LuraDialogue } from './ui/dialogue';
|
import { LuraDialogue } from './ui/dialogue';
|
||||||
@@ -52,6 +53,7 @@ function App() {
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
<PhaserGame ref={phaserRef} />
|
<PhaserGame ref={phaserRef} />
|
||||||
<BeginScreen />
|
<BeginScreen />
|
||||||
|
<FirstRunHint />
|
||||||
<SeedPicker />
|
<SeedPicker />
|
||||||
<FragmentRevealModal />
|
<FragmentRevealModal />
|
||||||
<JournalIcon />
|
<JournalIcon />
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export const UiStringsSchema = z.object({
|
|||||||
persistence_denied_toast: z.string().min(1),
|
persistence_denied_toast: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
plants: z.record(z.string(), 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>;
|
export type UiStrings = z.infer<typeof UiStringsSchema>;
|
||||||
|
|||||||
@@ -30,12 +30,25 @@ export interface SessionSlice {
|
|||||||
* and runtime-loadable for Phase 4+ to swap in if richer voice is needed).
|
* and runtime-loadable for Phase 4+ to swap in if richer voice is needed).
|
||||||
*/
|
*/
|
||||||
compostBeatTick: number;
|
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;
|
dismissBeginGate: () => void;
|
||||||
setPersistenceToastShown: (v: boolean) => void;
|
setPersistenceToastShown: (v: boolean) => void;
|
||||||
setShowPersistenceToast: (v: boolean) => void;
|
setShowPersistenceToast: (v: boolean) => void;
|
||||||
openLetter: (block: unknown) => void;
|
openLetter: (block: unknown) => void;
|
||||||
dismissLetter: () => void;
|
dismissLetter: () => void;
|
||||||
bumpCompostBeat: () => void;
|
bumpCompostBeat: () => void;
|
||||||
|
dismissFirstRunHint: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createSessionSlice: StateCreator<SessionSlice, [], [], SessionSlice> = (set) => ({
|
export const createSessionSlice: StateCreator<SessionSlice, [], [], SessionSlice> = (set) => ({
|
||||||
@@ -45,10 +58,12 @@ export const createSessionSlice: StateCreator<SessionSlice, [], [], SessionSlice
|
|||||||
letterOverlayOpen: false,
|
letterOverlayOpen: false,
|
||||||
pendingLetterEventBlock: null,
|
pendingLetterEventBlock: null,
|
||||||
compostBeatTick: 0,
|
compostBeatTick: 0,
|
||||||
|
firstRunHintDismissed: false, // Plan 02-06 G2 — session state only
|
||||||
dismissBeginGate: () => set({ beginGateDismissed: true }),
|
dismissBeginGate: () => set({ beginGateDismissed: true }),
|
||||||
setPersistenceToastShown: (v) => set({ persistenceToastShown: v }),
|
setPersistenceToastShown: (v) => set({ persistenceToastShown: v }),
|
||||||
setShowPersistenceToast: (v) => set({ showPersistenceToast: v }),
|
setShowPersistenceToast: (v) => set({ showPersistenceToast: v }),
|
||||||
openLetter: (block) => set({ letterOverlayOpen: true, pendingLetterEventBlock: block }),
|
openLetter: (block) => set({ letterOverlayOpen: true, pendingLetterEventBlock: block }),
|
||||||
dismissLetter: () => set({ letterOverlayOpen: false, pendingLetterEventBlock: null }),
|
dismissLetter: () => set({ letterOverlayOpen: false, pendingLetterEventBlock: null }),
|
||||||
bumpCompostBeat: () => set((s) => ({ compostBeatTick: s.compostBeatTick + 1 })),
|
bumpCompostBeat: () => set((s) => ({ compostBeatTick: s.compostBeatTick + 1 })),
|
||||||
|
dismissFirstRunHint: () => set({ firstRunHintDismissed: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FirstRunHint } from './FirstRunHint';
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
* UI layer.
|
* UI layer.
|
||||||
*/
|
*/
|
||||||
export * from './begin';
|
export * from './begin';
|
||||||
|
export * from './first-run';
|
||||||
export * from './garden';
|
export * from './garden';
|
||||||
export * from './journal';
|
export * from './journal';
|
||||||
export * from './dialogue';
|
export * from './dialogue';
|
||||||
|
|||||||
Reference in New Issue
Block a user