feat(02-02): begin screen + seed picker + ui-strings + lazy content split

- content/seasons/01-soil/ui-strings.yaml: player-visible Phase-2 copy externalized per CLAUDE.md (Begin / seed picker / post-harvest beat / journal / settings / plant display names); voice reviewed against bible + anti-fomo-doctrine.md
- content/seasons/01-soil/fragments.yaml: placeholder Season-1 fragment file (Plan 02-03 expands to ≥10 authored)
- content/seasons/00-demo/: deleted (Phase-1 demo replaced)
- src/content/schemas/ui-strings.ts: UiStringsSchema (Zod) — validates structure of every season's ui-strings.yaml at load time
- src/content/schemas/index.ts + src/content/index.ts: re-export UiStringsSchema/UiStrings
- src/content/loader.ts: eager `uiStrings` glob + PIPE-02 lazy `loadSeasonFragments(seasonId)` (Plan 02-03+ exploit)
- src/ui/begin/use-audio-bootstrap.ts: bootstrapAudioContext() lazy-creates + resumes (RESEARCH Pattern 9; Pitfall 5 mitigation — context construction inside the gesture for iOS Safari) + installFirstInteractionGestureHandler() one-shot for D-22 returning players + __resetAudioBootstrapForTest()
- src/ui/begin/BeginScreen.tsx: D-21 typographic Begin screen — title + subtitle + CTA from uiStrings[1].begin; onClick calls bootstrapAudioContext synchronously inside the click event then dismisses the session gate (D-22)
- src/ui/begin/BeginScreen.test.tsx: 4 tests — render / D-22 skip / click bootstraps + dismisses / subtitle string
- src/ui/garden/SeedPicker.tsx: D-02 inline DOM popover; subscribes to 'tile-clicked-coords'; renders one button per unlocked plant type from uiStrings[1].plants; click enqueues plantSeed command via store.enqueueCommand
- src/ui/garden/SeedPicker.test.tsx: 6 tests — initial-null / coords-positioned / unlocked-only / enqueue / dismiss / multi-plant; mocks game/event-bus to avoid Phaser canvas init under happy-dom (deviation Rule 3)
- src/ui/{begin,garden,index}.ts: barrels
- src/App.tsx: mount BeginScreen + SeedPicker as overlay siblings to PhaserGame
- src/PhaserGame.tsx: bootstrap unlockedPlantTypes=['rosemary'] for first-run; install gesture handler + scene-ready listener
- npm run ci exits 0; 163/163 tests pass (10 new this commit + 25 from Task 1 + 128 baseline)
This commit is contained in:
2026-05-09 09:43:47 -04:00
parent 537016b48f
commit 414a554549
17 changed files with 685 additions and 26 deletions
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { BeginScreen } from './BeginScreen';
import { appStore } from '../../store';
import { __resetAudioBootstrapForTest } from './use-audio-bootstrap';
// Mock the audio bootstrap module so happy-dom (which lacks AudioContext)
// doesn't blow up; also gives us a spy to assert the click invokes it.
vi.mock('./use-audio-bootstrap', async () => {
const actual =
await vi.importActual<typeof import('./use-audio-bootstrap')>('./use-audio-bootstrap');
return {
...actual,
bootstrapAudioContext: vi.fn().mockResolvedValue(null),
};
});
import { bootstrapAudioContext } from './use-audio-bootstrap';
describe('BeginScreen (AEST-07, D-21, D-22)', () => {
beforeEach(() => {
cleanup();
appStore.setState({ beginGateDismissed: false });
__resetAudioBootstrapForTest();
vi.mocked(bootstrapAudioContext).mockClear();
});
it('renders the title and Begin CTA when not dismissed', () => {
render(<BeginScreen />);
expect(screen.getByText('The Last Garden')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Begin' })).toBeTruthy();
});
it('renders nothing when beginGateDismissed=true (D-22 returning-player skip)', () => {
appStore.setState({ beginGateDismissed: true });
const { container } = render(<BeginScreen />);
expect(container.firstChild).toBeNull();
});
it('dismisses the gate and triggers audio bootstrap on click', () => {
render(<BeginScreen />);
fireEvent.click(screen.getByRole('button', { name: 'Begin' }));
expect(bootstrapAudioContext).toHaveBeenCalledTimes(1);
expect(appStore.getState().beginGateDismissed).toBe(true);
});
it('subtitle is the externalized "tend" string (CLAUDE.md tone)', () => {
render(<BeginScreen />);
expect(screen.getByText('tend')).toBeTruthy();
});
});
+69
View File
@@ -0,0 +1,69 @@
import { type JSX } from 'react';
import { useAppStore } from '../../store';
import { uiStrings } from '../../content';
import { bootstrapAudioContext } from './use-audio-bootstrap';
/**
* D-21 + AEST-07: tasteful typographic Begin screen. Phase 3 swaps in
* the painted gesture-gate without changing this file's behavior.
*
* D-22: shown on first run only — gated by session.beginGateDismissed.
* Once dismissed, the gate stays down for the session; persistence
* across sessions is owned by the save lifecycle (Plan 02-05).
*
* CLAUDE.md banner concern #7 (Web Audio user-gesture): the click
* handler calls `bootstrapAudioContext()` SYNCHRONOUSLY (not inside a
* useEffect), so iOS Safari sees the AudioContext construction inside
* the gesture (Pitfall 5).
*/
export function BeginScreen(): JSX.Element | null {
const dismissed = useAppStore((s) => s.beginGateDismissed);
const dismissBeginGate = useAppStore((s) => s.dismissBeginGate);
if (dismissed) return null;
const strings = uiStrings[1]?.begin;
if (!strings) return null;
const onBegin = (): void => {
void bootstrapAudioContext(); // synchronous-inside-click; do NOT move into useEffect (Pitfall 5)
dismissBeginGate();
};
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
background: '#1a1a1a',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#e8e0d0',
fontFamily: 'serif',
}}
role="dialog"
aria-label={strings.title}
>
<h1 style={{ fontSize: '3rem', margin: 0, fontWeight: 300 }}>{strings.title}</h1>
<p style={{ marginTop: '1rem', opacity: 0.7, letterSpacing: '0.2em' }}>{strings.subtitle}</p>
<button
onClick={onBegin}
style={{
marginTop: '4rem',
padding: '0.6rem 2.4rem',
fontSize: '1.1rem',
background: 'transparent',
color: '#e8e0d0',
border: '1px solid #e8e0d0',
cursor: 'pointer',
fontFamily: 'serif',
}}
>
{strings.cta}
</button>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { BeginScreen } from './BeginScreen';
export {
bootstrapAudioContext,
installFirstInteractionGestureHandler,
} from './use-audio-bootstrap';
+71
View File
@@ -0,0 +1,71 @@
/**
* Audio bootstrap (AEST-07 + RESEARCH Pattern 9 + CLAUDE.md banner concern #7).
*
* Web Audio cannot start until the user has interacted with the page; iOS
* Safari further requires the AudioContext to be CONSTRUCTED inside the
* gesture, not merely resumed (Pitfall 5). This module is the single
* owner of that contract.
*
* Usage:
* - BeginScreen onClick → call `bootstrapAudioContext()` synchronously
* inside the click handler (NOT inside a useEffect).
* - Returning players (D-22) skip the Begin screen → PhaserGame.tsx
* installs `installFirstInteractionGestureHandler()` so the next
* click / touch / keypress bootstraps audio.
*
* This module is in src/ui/ — it is allowed to touch the DOM and
* AudioContext freely (the CORE-10 firewall only forbids src/sim/ from
* importing src/ui/, not the other way).
*/
let _ctx: AudioContext | null = null;
let _resumed = false;
export async function bootstrapAudioContext(): Promise<AudioContext | null> {
if (_resumed && _ctx) return _ctx;
if (!_ctx) {
try {
const Ctor =
typeof AudioContext !== 'undefined'
? AudioContext
: (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return null;
_ctx = new Ctor();
} catch {
return null;
}
}
try {
await _ctx.resume();
_resumed = true;
return _ctx;
} catch {
return null;
}
}
/**
* For returning players (D-22): no Begin screen, but the next click,
* touch, or keypress must bootstrap audio. One-shot — removes itself
* from all three event types after the first fire.
*/
export function installFirstInteractionGestureHandler(): void {
const handler = (): void => {
void bootstrapAudioContext();
document.removeEventListener('click', handler);
document.removeEventListener('touchstart', handler);
document.removeEventListener('keydown', handler);
};
document.addEventListener('click', handler);
document.addEventListener('touchstart', handler);
document.addEventListener('keydown', handler);
}
/**
* Test-only: reset the module-level cache between tests.
* NOT part of the public surface; internal to the test harness.
*/
export function __resetAudioBootstrapForTest(): void {
_ctx = null;
_resumed = false;
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, act } from '@testing-library/react';
// Phaser does not initialize under happy-dom (canvas.getContext('2d')
// returns null, which trips its checkInverseAlpha boot probe). The
// SeedPicker only needs an event-emitter surface — swap in a lightweight
// EventTarget-like shim instead of pulling Phaser into the test runtime.
vi.mock('../../game/event-bus', () => {
type Listener = (...args: unknown[]) => void;
const listeners = new Map<string, Set<Listener>>();
return {
eventBus: {
on(event: string, fn: Listener): void {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)!.add(fn);
},
off(event: string, fn: Listener): void {
listeners.get(event)?.delete(fn);
},
emit(event: string, ...args: unknown[]): void {
listeners.get(event)?.forEach((fn) => fn(...args));
},
removeAllListeners(): void {
listeners.clear();
},
},
};
});
import { SeedPicker } from './SeedPicker';
import { eventBus } from '../../game/event-bus';
import { appStore } from '../../store';
function resetStore(unlocked: string[] = ['rosemary']): void {
appStore.setState({
tiles: new Array(16).fill(null),
unlockedPlantTypes: unlocked,
pendingCommands: [],
tickCount: 0,
lastTickAt: 0,
});
}
describe('SeedPicker (D-02 inline DOM popover)', () => {
beforeEach(() => {
cleanup();
resetStore();
eventBus.removeAllListeners();
});
afterEach(() => {
eventBus.removeAllListeners();
});
it('returns null on initial render (not visible until tile-clicked-coords fires)', () => {
const { container } = render(<SeedPicker />);
expect(container.firstChild).toBeNull();
});
it('appears positioned at the emitted screen coords when tile-clicked-coords fires', () => {
render(<SeedPicker />);
act(() => {
eventBus.emit('tile-clicked-coords', { tileIdx: 5, screenX: 200, screenY: 300 });
});
const popover = screen.getByTestId('seed-picker');
expect(popover).toBeTruthy();
// left = x - 80, top = y - 120
expect(popover.style.left).toBe('120px');
expect(popover.style.top).toBe('180px');
});
it('renders one button per unlocked plant type', () => {
resetStore(['rosemary']);
render(<SeedPicker />);
act(() => {
eventBus.emit('tile-clicked-coords', { tileIdx: 0, screenX: 0, screenY: 0 });
});
expect(screen.getByRole('button', { name: 'Rosemary' })).toBeTruthy();
// Yarrow is locked — no button.
expect(screen.queryByRole('button', { name: 'Yarrow' })).toBeNull();
});
it('clicking a plant button enqueues a plantSeed command into the store', () => {
resetStore(['rosemary']);
render(<SeedPicker />);
act(() => {
eventBus.emit('tile-clicked-coords', { tileIdx: 7, screenX: 0, screenY: 0 });
});
fireEvent.click(screen.getByRole('button', { name: 'Rosemary' }));
const cmds = appStore.getState().pendingCommands;
expect(cmds).toEqual([
{ kind: 'plantSeed', tileIdx: 7, plantTypeId: 'rosemary' },
]);
});
it('dismisses (returns null) after a plant is selected', () => {
resetStore(['rosemary']);
const { container } = render(<SeedPicker />);
act(() => {
eventBus.emit('tile-clicked-coords', { tileIdx: 0, screenX: 0, screenY: 0 });
});
fireEvent.click(screen.getByRole('button', { name: 'Rosemary' }));
expect(container.firstChild).toBeNull();
});
it('shows multiple plants when unlocked', () => {
resetStore(['rosemary', 'yarrow', 'winter-rose']);
render(<SeedPicker />);
act(() => {
eventBus.emit('tile-clicked-coords', { tileIdx: 0, screenX: 0, screenY: 0 });
});
expect(screen.getByRole('button', { name: 'Rosemary' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Yarrow' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Winter-rose' })).toBeTruthy();
});
});
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useState, type JSX } from 'react';
import { eventBus } from '../../game/event-bus';
import { useAppStore } from '../../store';
import { uiStrings } from '../../content';
import { PLANT_TYPES } from '../../sim/garden';
import type { PlantTypeId } from '../../sim/garden/types';
interface PickerState {
visible: boolean;
tileIdx: number;
x: number;
y: number;
}
interface TileClickPayload {
tileIdx: number;
screenX: number;
screenY: number;
}
/**
* D-02 — inline DOM popover positioned over the Phaser canvas.
*
* Listens for 'tile-clicked-coords' from the Garden scene; mounts itself
* absolutely-positioned at those screen coords. Click outside dismisses.
*
* Architectural note: the popover is a DOM overlay rather than a Phaser
* UI scene because (a) it needs HTML form-element accessibility (button
* focus / tab order / screen-reader semantics) and (b) RESEARCH Pattern
* 4 — the inline-DOM-popover-over-canvas pattern — keeps the SeedPicker
* decoupled from the watercolor render pipeline that lands in Phase 3.
*/
export function SeedPicker(): JSX.Element | null {
const [picker, setPicker] = useState<PickerState>({
visible: false,
tileIdx: -1,
x: 0,
y: 0,
});
const unlocked = useAppStore((s) => s.unlockedPlantTypes);
const enqueueCommand = useAppStore((s) => s.enqueueCommand);
const strings = uiStrings[1]?.seed_picker;
const plantStrings = uiStrings[1]?.plants ?? {};
useEffect(() => {
const onCoords = (payload: TileClickPayload): void => {
setPicker({
visible: true,
tileIdx: payload.tileIdx,
x: payload.screenX,
y: payload.screenY,
});
};
eventBus.on('tile-clicked-coords', onCoords);
return () => {
eventBus.off('tile-clicked-coords', onCoords);
};
}, []);
// Click-outside dismiss. Defer one tick so the click that opened the
// picker isn't itself interpreted as the "outside" click.
useEffect(() => {
if (!picker.visible) return;
const t = setTimeout(() => {
const onClick = (): void => setPicker((p) => ({ ...p, visible: false }));
document.addEventListener('click', onClick, { once: true });
}, 0);
return () => clearTimeout(t);
}, [picker.visible]);
if (!picker.visible || !strings) return null;
const onSelect = (plantTypeId: PlantTypeId): void => {
enqueueCommand({ kind: 'plantSeed', tileIdx: picker.tileIdx, plantTypeId });
setPicker((p) => ({ ...p, visible: false }));
};
// Translate screen coords → picker top-left (centered above tile).
const left = picker.x - 80;
const top = picker.y - 120;
return (
<div
data-testid="seed-picker"
onClick={(e) => e.stopPropagation()}
style={{
position: 'fixed',
left,
top,
zIndex: 50,
background: '#2a2a2e',
color: '#e8e0d0',
padding: '0.6rem 0.8rem',
borderRadius: 4,
boxShadow: '0 6px 18px rgba(0,0,0,0.4)',
fontFamily: 'serif',
minWidth: 160,
}}
>
<div style={{ fontSize: '0.85rem', marginBottom: '0.5rem', opacity: 0.7 }}>
{strings.title}
</div>
{unlocked.length === 0 && (
<div style={{ fontSize: '0.85rem', fontStyle: 'italic', opacity: 0.6 }}></div>
)}
{unlocked.map((id) => {
const type = PLANT_TYPES[id as PlantTypeId];
if (!type) return null;
const display = plantStrings[id] ?? type.fallbackName;
return (
<button
key={id}
onClick={() => onSelect(id as PlantTypeId)}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
padding: '0.4rem 0.6rem',
margin: '0.1rem 0',
background: 'transparent',
color: '#e8e0d0',
border: '1px solid transparent',
cursor: 'pointer',
fontFamily: 'serif',
}}
>
{display}
</button>
);
})}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { SeedPicker } from './SeedPicker';
+9
View File
@@ -0,0 +1,9 @@
/**
* Top-level barrel for src/ui/. App code imports from here.
*
* Per CORE-10: src/sim/** must NOT import from this module. The sim
* stays UI-agnostic; only src/App.tsx + src/PhaserGame.tsx consume the
* UI layer.
*/
export * from './begin';
export * from './garden';