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