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"
|
||||
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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
export * from './begin';
|
||||
export * from './first-run';
|
||||
export * from './garden';
|
||||
export * from './journal';
|
||||
export * from './dialogue';
|
||||
|
||||
Reference in New Issue
Block a user