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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { BeginScreen } from './BeginScreen';
|
||||
export {
|
||||
bootstrapAudioContext,
|
||||
installFirstInteractionGestureHandler,
|
||||
} from './use-audio-bootstrap';
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user