feat(02-03): journal + reveal modal + harvest pointer wiring

Task 2 of Plan 02-03: ship the Memory Journal UI surfaces (D-23/D-24/D-25)
and wire harvest + compost pointer events through the Garden scene to the
sim → store → React reveal flow.

src/ui/journal/ (new module):
- Journal.tsx — full-screen modal (D-24); fragments grouped by Season;
  DOM-rendered text with userSelect: text per MEMR-05; reads
  harvestedFragmentIds from the store; resolves ids against the eager
  `fragments` corpus (defensive — unresolvable ids skip silently).
- FragmentRevealModal.tsx — D-25 active-play reveal modal; backdrop click
  + inner Close button dismiss; event.stopPropagation on the article
  body so clicking inside the text doesn't dismiss; defensive silent
  dismiss on unresolvable id.
- journal-icon.tsx — D-23 + D-29 corner affordance; gated by
  selectJournalRevealed (`harvestedFragmentIds.length > 0`); local open
  state (no store pollution); 'j' hotkey deferred to Plan 02-05.
- index.ts — barrel.
- 16 new Vitest cases across 3 test files (Journal: 7 / FragmentRevealModal:
  6 / journal-icon: 3); all green.

src/App.tsx — mount FragmentRevealModal + JournalIcon as DOM siblings of
PhaserGame.

src/ui/index.ts — re-export ./journal.

src/game/scenes/Garden.ts — harvest/compost pointer flow:
- create() builds a SimContext from the eager `fragments` corpus filtered
  to Season 1; passed to every simulateOneTick call (Phase 4+ should swap
  to await loadSeasonFragments(currentSeason) when Season transitions land).
- handleTilePointerDown branches on tile state: empty → seed picker
  event; ready plant → enqueue 'harvest' command; immature plant → enqueue
  'compost' command (TODO Plan 02-04: render the Ink-authored compost
  acknowledgement beat from compost-acknowledgements.ink).
- update() detects newly-appended harvestedFragmentIds and sets
  fragmentRevealId so the reveal modal pops with the new fragment text.
- BLOCKER 3 invariant preserved — sim writes tickCount, never lastTickAt.

content/dialogue/season1/compost-acknowledgements.ink — authored content
for the GARD-04 + D-07 compost beat. 6 short lines in the gardener-keeper
voice (NOT Lura — she's the warmth anchor; the garden's voice is the
contrast). Plan 02-04 wires the inkjs runtime; Plan 02-03 ships the
content so the writer can iterate independently.

214/214 tests green (was 163; +51 new this plan); npm run lint exits 0;
npm run ci exits 0; npm run build exits 0 with the expected
INEFFECTIVE_DYNAMIC_IMPORT warnings (eager `fragments` export still
imports the Season-1 yaml/md statically alongside the lazy
loadSeasonFragments path — documented in 02-02-SUMMARY.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 10:05:45 -04:00
parent f192e8298c
commit 572c86192f
11 changed files with 610 additions and 4 deletions
+1
View File
@@ -7,3 +7,4 @@
*/
export * from './begin';
export * from './garden';
export * from './journal';
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { FragmentRevealModal } from './FragmentRevealModal';
import { appStore } from '../../store';
vi.mock('../../game/event-bus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
removeAllListeners: vi.fn(),
},
}));
function reveal(id: string | null): void {
appStore.setState({ fragmentRevealId: id });
}
describe('FragmentRevealModal (D-25)', () => {
beforeEach(() => {
cleanup();
reveal(null);
});
it('returns null when fragmentRevealId is null', () => {
const { container } = render(<FragmentRevealModal />);
expect(container.firstChild).toBeNull();
});
it('renders the body of the revealed fragment in selectable DOM (MEMR-05)', () => {
reveal('season1.soil.first-bloom');
render(<FragmentRevealModal />);
expect(screen.getByText(/The first thing that grew was rosemary/)).toBeTruthy();
// Inner pre is selectable.
const article = document.querySelector('article[data-fragment-id="season1.soil.first-bloom"]');
expect(article).not.toBeNull();
const pre = (article as HTMLElement).querySelector('pre');
expect(pre).not.toBeNull();
expect((pre as HTMLElement).style.userSelect).toBe('text');
});
it('clicking the backdrop dismisses (clears fragmentRevealId in the store)', () => {
reveal('season1.soil.first-bloom');
render(<FragmentRevealModal />);
const dialog = screen.getByRole('dialog');
fireEvent.click(dialog);
expect(appStore.getState().fragmentRevealId).toBeNull();
});
it('clicking the article body does NOT dismiss (event.stopPropagation)', () => {
reveal('season1.soil.first-bloom');
render(<FragmentRevealModal />);
const article = document.querySelector('article[data-fragment-id="season1.soil.first-bloom"]');
expect(article).not.toBeNull();
fireEvent.click(article as HTMLElement);
expect(appStore.getState().fragmentRevealId).toBe('season1.soil.first-bloom');
});
it('clicking the inner Close button dismisses', () => {
reveal('season1.soil.first-bloom');
render(<FragmentRevealModal />);
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expect(appStore.getState().fragmentRevealId).toBeNull();
});
it('an unresolvable fragment id silently dismisses (defensive)', () => {
reveal('season9.bogus.never-shipped');
const { container } = render(<FragmentRevealModal />);
// The component sets fragmentRevealId=null during render; output is null.
expect(container.firstChild).toBeNull();
expect(appStore.getState().fragmentRevealId).toBeNull();
});
});
+101
View File
@@ -0,0 +1,101 @@
import { type JSX } from 'react';
import { useAppStore } from '../../store';
import { fragments as allFragments } from '../../content';
/**
* D-25 — fragment reveal modal in active play.
*
* Surfaces the just-harvested fragment in full text immediately; clicking
* the backdrop OR the inner Close button dismisses, which clears
* `fragmentRevealId` in the store and files the fragment into the
* journal under its Season.
*
* Triggered by Garden.ts's update() loop: after drainTicks runs and a new
* id is appended to harvestedFragmentIds, the scene calls
* setFragmentRevealId(newId). The store change triggers this component's
* re-render.
*
* Defensive — if the id doesn't resolve to a known Fragment (corrupted
* save / future Season fragment loaded but not in the eager corpus), the
* modal silently dismisses to avoid stranding the player on a dead modal.
*/
export function FragmentRevealModal(): JSX.Element | null {
const fragmentRevealId = useAppStore((s) => s.fragmentRevealId);
const setFragmentRevealId = useAppStore((s) => s.setFragmentRevealId);
if (!fragmentRevealId) return null;
const fragment = allFragments.find((f) => f.id === fragmentRevealId);
if (!fragment) {
// Defensive dismiss; the next render will return null because we just
// cleared the id in the store. (React will warn about state-update-
// during-render only if this fires synchronously every render — it
// does not, because after the setState the next render reads
// fragmentRevealId === null and exits at the guard above.)
setFragmentRevealId(null);
return null;
}
const onDismiss = (): void => setFragmentRevealId(null);
return (
<div
role="dialog"
aria-label="A new memory"
data-testid="fragment-reveal-modal"
onClick={onDismiss}
style={{
position: 'fixed',
inset: 0,
zIndex: 90,
background: '#0c0c0deb',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: '#e8e0d0',
fontFamily: 'serif',
}}
>
<article
onClick={(e) => e.stopPropagation()}
data-fragment-id={fragment.id}
style={{
maxWidth: 600,
padding: '3rem 2.4rem',
background: '#1f1f23',
borderRadius: 4,
cursor: 'default',
}}
>
<pre
style={{
fontFamily: 'serif',
fontSize: '1.1rem',
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
userSelect: 'text',
margin: 0,
}}
>
{fragment.body}
</pre>
<button
onClick={onDismiss}
aria-label="Close"
style={{
marginTop: '2rem',
padding: '0.5rem 1.4rem',
background: 'transparent',
color: '#e8e0d0',
border: '1px solid #e8e0d0',
cursor: 'pointer',
fontFamily: 'serif',
}}
>
Close
</button>
</article>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { Journal } from './Journal';
import { appStore } from '../../store';
// Phaser does not initialize under happy-dom. The Journal does not import
// Phaser directly, but the @testing-library cleanup chain can transitively
// pull modules that do — and we want to keep these tests isolated from
// the Phaser runtime regardless.
vi.mock('../../game/event-bus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
removeAllListeners: vi.fn(),
},
}));
function resetStore(harvested: string[] = []): void {
appStore.setState({ harvestedFragmentIds: harvested });
}
describe('Journal (D-24 full-screen modal, MEMR-04, MEMR-05)', () => {
beforeEach(() => {
cleanup();
resetStore();
});
it('returns null when open=false', () => {
const { container } = render(<Journal open={false} onClose={() => {}} />);
expect(container.firstChild).toBeNull();
});
it('renders the empty-state copy from uiStrings when no fragments are harvested', () => {
render(<Journal open={true} onClose={() => {}} />);
// From content/seasons/01-soil/ui-strings.yaml: "Nothing yet. Plant something."
expect(screen.getByText('Nothing yet. Plant something.')).toBeTruthy();
});
it('renders the body of a harvested fragment in selectable DOM (MEMR-05)', () => {
resetStore(['season1.soil.first-bloom']);
render(<Journal open={true} onClose={() => {}} />);
// The body in fragments.yaml starts with "The first thing that grew was rosemary."
expect(screen.getByText(/The first thing that grew was rosemary/)).toBeTruthy();
// And the article wrapper carries userSelect: text (MEMR-05).
const article = document.querySelector('article[data-fragment-id="season1.soil.first-bloom"]');
expect(article).not.toBeNull();
expect((article as HTMLElement).style.userSelect).toBe('text');
});
it('groups fragments by Season (D-24)', () => {
resetStore(['season1.soil.first-bloom', 'season1.soil.the-cat']);
render(<Journal open={true} onClose={() => {}} />);
// Section header text "Season 1"
expect(screen.getByText('Season 1')).toBeTruthy();
// Both fragments rendered.
expect(screen.getByText(/The first thing that grew was rosemary/)).toBeTruthy();
expect(screen.getByText(/The cat is missing now too/)).toBeTruthy();
});
it('the close button invokes the onClose callback exactly once', () => {
const onClose = vi.fn();
render(<Journal open={true} onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: /close journal/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('aria-label on the dialog is "Memory Journal" (accessibility)', () => {
render(<Journal open={true} onClose={() => {}} />);
expect(screen.getByLabelText('Memory Journal')).toBeTruthy();
});
it('silently skips harvested ids that do not resolve to a known Fragment (defensive)', () => {
resetStore(['season1.soil.first-bloom', 'season9.bogus.never-shipped']);
render(<Journal open={true} onClose={() => {}} />);
// Real one renders; bogus one does not crash, just doesn't appear.
expect(screen.getByText(/The first thing that grew was rosemary/)).toBeTruthy();
expect(screen.queryByText(/season9.bogus/)).toBeNull();
});
});
+126
View File
@@ -0,0 +1,126 @@
import { type JSX } from 'react';
import { useAppStore } from '../../store';
import { fragments as allFragments, uiStrings } from '../../content';
/**
* D-24 — full-screen Memory Journal modal.
*
* Renders all harvested fragments grouped by Season as DOM text per
* MEMR-05 (selectable, copy-pasteable — never canvas). The journal
* resolves fragment ids against the eager `fragments` corpus loaded at
* module-eval time; ids that don't resolve are silently skipped (defensive
* — the only way an id could fail to resolve is if a save predates the
* authored fragment, which the migration chain prevents).
*
* Visibility is controlled by the parent (JournalIcon owns local state).
* Phase 2 has only Season 1; Phase 4+ Journal will need pagination /
* collapse — out of scope here.
*
* Architectural note: Journal is DOM, not Phaser. MEMR-05 is the load-
* bearing requirement; canvas rendering would foreclose copy-paste from
* day one.
*/
export function Journal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}): JSX.Element | null {
const harvested = useAppStore((s) => s.harvestedFragmentIds);
const strings = uiStrings[1]?.journal;
if (!open || !strings) return null;
// Resolve fragment objects in harvest order (player's reading order).
const harvestedFragments = harvested
.map((id) => allFragments.find((f) => f.id === id))
.filter((f): f is NonNullable<typeof f> => f !== undefined);
// Group by season for D-24 "fragments grouped by Season" requirement.
// Map preserves insertion order; we sort the key list before render.
const bySeason = new Map<number, typeof harvestedFragments>();
for (const f of harvestedFragments) {
if (!bySeason.has(f.season)) bySeason.set(f.season, []);
bySeason.get(f.season)!.push(f);
}
return (
<div
role="dialog"
aria-label="Memory Journal"
data-testid="journal-modal"
style={{
position: 'fixed',
inset: 0,
zIndex: 80,
background: '#1a1a1aee',
overflow: 'auto',
padding: '3rem 2rem',
color: '#e8e0d0',
fontFamily: 'serif',
}}
>
<button
onClick={onClose}
aria-label="Close journal"
style={{
position: 'fixed',
top: 16,
right: 16,
background: 'transparent',
color: '#e8e0d0',
border: '1px solid #e8e0d0',
padding: '0.4rem 1rem',
cursor: 'pointer',
fontFamily: 'serif',
zIndex: 90,
}}
>
{strings.back}
</button>
<div style={{ maxWidth: 720, margin: '0 auto' }}>
{harvestedFragments.length === 0 && (
<p style={{ fontStyle: 'italic', opacity: 0.6, userSelect: 'text' }}>
{strings.empty_state}
</p>
)}
{[...bySeason.entries()]
.sort(([a], [b]) => a - b)
.map(([season, frags]) => (
<section key={season}>
<h2
style={{
fontSize: '1.2rem',
opacity: 0.6,
fontWeight: 300,
letterSpacing: '0.1em',
}}
>
Season {season}
</h2>
{frags.map((f) => (
<article
key={f.id}
data-fragment-id={f.id}
style={{ margin: '2rem 0', userSelect: 'text' }}
>
<pre
style={{
fontFamily: 'serif',
fontSize: '1rem',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
userSelect: 'text',
margin: 0,
}}
>
{f.body}
</pre>
</article>
))}
</section>
))}
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Public barrel for src/ui/journal/.
*
* Plan 02-03 ships:
* - Journal: full-screen modal listing all collected fragments grouped
* by Season (D-24, MEMR-04, MEMR-05).
* - FragmentRevealModal: active-play reveal modal (D-25, MEMR-01).
* - JournalIcon: corner button that opens Journal; hidden until first
* harvest (D-23, D-29).
*/
export { Journal } from './Journal';
export { FragmentRevealModal } from './FragmentRevealModal';
export { JournalIcon } from './journal-icon';
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { JournalIcon } from './journal-icon';
import { appStore } from '../../store';
vi.mock('../../game/event-bus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
removeAllListeners: vi.fn(),
},
}));
function setHarvested(ids: string[]): void {
appStore.setState({ harvestedFragmentIds: ids });
}
describe('JournalIcon (D-23 reveal-after-first-harvest, D-29 corner affordance)', () => {
beforeEach(() => {
cleanup();
setHarvested([]);
});
it('returns null when no fragments have been harvested (D-23)', () => {
const { container } = render(<JournalIcon />);
expect(container.firstChild).toBeNull();
});
it('renders the corner icon button after the first harvest (D-23)', () => {
setHarvested(['season1.soil.first-bloom']);
render(<JournalIcon />);
expect(screen.getByTestId('journal-icon')).toBeTruthy();
});
it('clicking the icon opens the Journal modal', () => {
setHarvested(['season1.soil.first-bloom']);
render(<JournalIcon />);
// Modal is closed initially.
expect(screen.queryByTestId('journal-modal')).toBeNull();
fireEvent.click(screen.getByTestId('journal-icon'));
expect(screen.getByTestId('journal-modal')).toBeTruthy();
});
});
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState, type JSX } from 'react';
import { useAppStore, selectJournalRevealed } from '../../store';
import { Journal } from './Journal';
/**
* D-23 + D-29 — corner Memory Journal affordance.
*
* Pre-first-harvest: returns null. Post-first-harvest (revealed via
* `selectJournalRevealed` selector — `harvestedFragmentIds.length > 0`):
* renders a small fixed-position icon button that opens the Journal
* modal when clicked.
*
* The 'j' hotkey toggle (D-29) is intentionally NOT wired in Plan 02-03 —
* the keyboard-shortcut surface lands with the wider Settings hotkey work
* in Plan 02-05. Plan 02-03 provides the icon affordance only; the
* journal can also be opened via this component's onClick.
*
* Internal `open` state lives here rather than in the store so the
* affordance owns its visibility lifecycle without polluting the
* persisted save shape (V1Payload has no journal-open flag, by design).
*/
export function JournalIcon(): JSX.Element | null {
const revealed = useAppStore(selectJournalRevealed);
const [open, setOpen] = useState(false);
// Defensive: if the player somehow has the journal open and then their
// harvest list becomes empty (shouldn't happen normally — there's no
// un-harvest path — but a save-import to an earlier snapshot via
// Plan 02-05's Settings could land them here), close the journal.
useEffect(() => {
if (!revealed && open) setOpen(false);
}, [revealed, open]);
if (!revealed) return null;
return (
<>
<button
data-testid="journal-icon"
aria-label="Open memory journal"
onClick={() => setOpen(true)}
style={{
position: 'fixed',
bottom: 20,
right: 20,
zIndex: 40,
width: 44,
height: 44,
borderRadius: 22,
background: '#2a2a2e',
color: '#e8e0d0',
border: '1px solid #4d4d52',
cursor: 'pointer',
fontFamily: 'serif',
fontSize: '1.2rem',
}}
>
</button>
<Journal open={open} onClose={() => setOpen(false)} />
</>
);
}