From 572c86192fa1046f3e10f163c626c29360062bf6 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 9 May 2026 10:05:45 -0400 Subject: [PATCH] feat(02-03): journal + reveal modal + harvest pointer wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../season1/compost-acknowledgements.ink | 43 ++++++ src/App.tsx | 4 +- src/game/scenes/Garden.ts | 66 ++++++++- src/ui/index.ts | 1 + src/ui/journal/FragmentRevealModal.test.tsx | 73 ++++++++++ src/ui/journal/FragmentRevealModal.tsx | 101 ++++++++++++++ src/ui/journal/Journal.test.tsx | 80 +++++++++++ src/ui/journal/Journal.tsx | 126 ++++++++++++++++++ src/ui/journal/index.ts | 13 ++ src/ui/journal/journal-icon.test.tsx | 44 ++++++ src/ui/journal/journal-icon.tsx | 63 +++++++++ 11 files changed, 610 insertions(+), 4 deletions(-) create mode 100644 content/dialogue/season1/compost-acknowledgements.ink create mode 100644 src/ui/journal/FragmentRevealModal.test.tsx create mode 100644 src/ui/journal/FragmentRevealModal.tsx create mode 100644 src/ui/journal/Journal.test.tsx create mode 100644 src/ui/journal/Journal.tsx create mode 100644 src/ui/journal/index.ts create mode 100644 src/ui/journal/journal-icon.test.tsx create mode 100644 src/ui/journal/journal-icon.tsx diff --git a/content/dialogue/season1/compost-acknowledgements.ink b/content/dialogue/season1/compost-acknowledgements.ink new file mode 100644 index 0000000..a0d0ac6 --- /dev/null +++ b/content/dialogue/season1/compost-acknowledgements.ink @@ -0,0 +1,43 @@ +// content/dialogue/season1/compost-acknowledgements.ink +// +// Plan 02-03 ships the AUTHORED CONTENT for the compost tonal beat +// (CONTEXT D-07 + GARD-04). Plan 02-04 owns the Ink runtime — this file +// is loaded by the Ink runtime (inkjs) at that point and one of these +// short lines is dripped into the dialogue overlay each time the player +// composts an immature plant. +// +// In Plan 02-03 the React surface (Garden.ts handleTilePointerDown's +// compost branch) does NOT yet render these lines — there's a TODO +// comment at the call site marking the Plan 02-04 wiring point. The +// content lives here so the writer can iterate on voice without waiting +// for the runtime to land. +// +// Tone (CLAUDE.md): warm, specific, intermittent, sometimes funny, +// sometimes devastating. The gardener-keeper voice. NOT Lura. The garden +// is acknowledging the player's choice to let go — never sentimental, +// never reassuring, never "it's okay." Just the small fact of the choice, +// honored. +// +// Phase 2 ships ~6 short lines so the player rarely hears the same line +// twice in a single session. Plan 02-04 will randomize selection (via +// the same mulberry32 pattern as the fragment selector, or a simple +// weighted pick — implementer's choice). + +=== compost_beats === +* The earth takes it back without comment. +->DONE + +* Some things are tended into being. Others are tended into not being. Both count. +->DONE + +* The space the plant occupied is now space. That is a kind of progress. +->DONE + +* It returns to the soil it came from. Not poetry — just composting. Mostly. +->DONE + +* The garden is bigger by one empty tile. +->DONE + +* You changed your mind. The garden has nothing to say about it. +->DONE diff --git a/src/App.tsx b/src/App.tsx index 8b48836..477d61c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useRef } from 'react'; import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx'; import { BeginScreen } from './ui/begin'; import { SeedPicker } from './ui/garden'; +import { FragmentRevealModal, JournalIcon } from './ui/journal'; function App() { // PhaserGame ref — Phase 2+ will use this to access the active scene from React. @@ -12,7 +13,8 @@ function App() { - {/* Plan 02-03 mounts: , */} + + {/* Plan 02-04 mounts: */} {/* Plan 02-05 mounts: , , */} diff --git a/src/game/scenes/Garden.ts b/src/game/scenes/Garden.ts index fe3ba8e..91cb39a 100644 --- a/src/game/scenes/Garden.ts +++ b/src/game/scenes/Garden.ts @@ -2,7 +2,11 @@ import * as Phaser from 'phaser'; import { eventBus } from '../event-bus'; import { drainTicks, wallClock, type Clock } from '../../sim/scheduler'; import type { SimState } from '../../sim/state'; -import { simulateOneTick, tileGrowthStage } from '../../sim/garden'; +import { + simulateOneTick, + tileGrowthStage, + type SimContext, +} from '../../sim/garden'; import type { Tile } from '../../sim/garden/types'; import { drawTiles, @@ -14,6 +18,7 @@ import { type PlantGameObject, } from '../../render/garden'; import { appStore, simAdapter } from '../../store'; +import { fragments as allFragments } from '../../content'; /** * The 4×4 garden scene (CONTEXT D-01). Wires the tick scheduler into @@ -22,6 +27,25 @@ import { appStore, simAdapter } from '../../store'; * * The Garden scene is the ONLY place where sim + store + render meet. * It stays thin (RESEARCH Pattern 3): subscribe, dispatch. + * + * Plan 02-03 additions: + * - SimContext built once at create() from the eager `fragments` corpus + * filtered to Season 1; passed to every simulateOneTick call. + * - handleTilePointerDown branches on tile state: + * empty plant → emit 'tile-clicked-coords' for SeedPicker + * ready plant → enqueue 'harvest' command + * immature plant → enqueue 'compost' command + * - update() loop detects newly-appended harvestedFragmentIds and sets + * fragmentRevealId so the FragmentRevealModal pops with the new + * fragment's full text (D-25). + * + * Fragment-loading approach: Plan 02-03 uses the eager `fragments` export + * (ships the full corpus into the initial bundle) rather than awaiting + * loadSeasonFragments(1). For Phase 2's Season-1-only scope this is + * simpler — the Plan 02-03 SUMMARY documents the trade-off. The PIPE-02 + * structural verification (scripts/check-bundle-split.mjs) proves the + * lazy-import surface still emits a separate Season-1 chunk for Phase + * 4+ to exploit when the corpus grows beyond a single Season. */ export class Garden extends Phaser.Scene { private accumulatorMs = 0; @@ -32,6 +56,7 @@ export class Garden extends Phaser.Scene { private plantObjs: Map = new Map(); private readyTweens: Map = new Map(); private storeUnsubscribe: (() => void) | null = null; + private simContext: SimContext = { fragments: [], currentSeason: 1 }; constructor() { super('Garden'); @@ -44,6 +69,14 @@ export class Garden extends Phaser.Scene { const slot = (window as unknown as { __tlgClock?: Clock }).__tlgClock; if (slot) this.clock = slot; + // Build the SimContext once at create() — Phase 2 ships only Season 1. + // Phase 4+ should swap this for `await loadSeasonFragments(currentSeason)` + // when the Season transition lands. + this.simContext = { + fragments: allFragments.filter((f) => f.season === 1), + currentSeason: 1, + }; + // Restore tickCount from the store (set on save load by saveSync). this.currentTick = appStore.getState().tickCount; @@ -73,6 +106,7 @@ export class Garden extends Phaser.Scene { // Build current SimState snapshot from the store + drain commands. const storeState = appStore.getState(); const commands = simAdapter.drainCommands(); + const prevHarvestCount = storeState.harvestedFragmentIds.length; // BLOCKER 3 — DO NOT seed lastTickAt with this.currentTick. lastTickAt // is wall-clock ms owned by saveSync. The Garden scene's snapshot @@ -97,7 +131,7 @@ export class Garden extends Phaser.Scene { }; const result = drainTicks(simStateNow, this.accumulatorMs, (s, _dtMs, _silent) => { - const next = simulateOneTick(s, this.currentTick + 1, commands); + const next = simulateOneTick(s, this.currentTick + 1, commands, this.simContext); this.currentTick++; return next; }); @@ -108,6 +142,18 @@ export class Garden extends Phaser.Scene { result.state.garden.tiles, result.state.unlockedPlantTypes, ); + // Plan 02-03 — D-25 reveal flow. If a new fragment was harvested + // during this drain, push the harvested-ids list into the store + // and flag the most recent id for the reveal modal. + const newHarvestCount = result.state.harvestedFragmentIds.length; + if (newHarvestCount > prevHarvestCount) { + const newId = + result.state.harvestedFragmentIds[newHarvestCount - 1]; + if (newId) { + simAdapter.applyHarvestedFragments(result.state.harvestedFragmentIds); + appStore.getState().setFragmentRevealId(newId); + } + } } } @@ -120,7 +166,21 @@ export class Garden extends Phaser.Scene { eventBus.emit('tile-clicked-coords', { tileIdx: idx, screenX: dom.x, screenY: dom.y }); return; } - // Plan 02-03 wires harvest / compost on plant click. + // Has plant — branch on growth stage. + const stage = tileGrowthStage(tile, this.currentTick); + if (stage === 'ready') { + // GARD-03: harvest fires through the sim, which selects a fragment + // and clears the tile. + appStore.getState().enqueueCommand({ kind: 'harvest', tileIdx: idx }); + } else { + // GARD-04 + D-07: compost an immature plant (sprout / mature). + // TODO Plan 02-04: replace with the Ink-authored compost beat + // rendered through the dialogue overlay (compost-acknowledgements.ink). + // Plan 02-03 ships the authored content under + // /content/dialogue/season1/ so Plan 02-04 can wire the runtime + // without re-authoring. + appStore.getState().enqueueCommand({ kind: 'compost', tileIdx: idx }); + } } private repaintPlants(tiles: Tile[]): void { diff --git a/src/ui/index.ts b/src/ui/index.ts index e0d373c..ccb50c2 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -7,3 +7,4 @@ */ export * from './begin'; export * from './garden'; +export * from './journal'; diff --git a/src/ui/journal/FragmentRevealModal.test.tsx b/src/ui/journal/FragmentRevealModal.test.tsx new file mode 100644 index 0000000..b9be2f5 --- /dev/null +++ b/src/ui/journal/FragmentRevealModal.test.tsx @@ -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(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the body of the revealed fragment in selectable DOM (MEMR-05)', () => { + reveal('season1.soil.first-bloom'); + render(); + 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(); + 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(); + 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(); + 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(); + // The component sets fragmentRevealId=null during render; output is null. + expect(container.firstChild).toBeNull(); + expect(appStore.getState().fragmentRevealId).toBeNull(); + }); +}); diff --git a/src/ui/journal/FragmentRevealModal.tsx b/src/ui/journal/FragmentRevealModal.tsx new file mode 100644 index 0000000..1d5b08c --- /dev/null +++ b/src/ui/journal/FragmentRevealModal.tsx @@ -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 ( +
+
e.stopPropagation()} + data-fragment-id={fragment.id} + style={{ + maxWidth: 600, + padding: '3rem 2.4rem', + background: '#1f1f23', + borderRadius: 4, + cursor: 'default', + }} + > +
+          {fragment.body}
+        
+ +
+
+ ); +} diff --git a/src/ui/journal/Journal.test.tsx b/src/ui/journal/Journal.test.tsx new file mode 100644 index 0000000..0567df0 --- /dev/null +++ b/src/ui/journal/Journal.test.tsx @@ -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( {}} />); + expect(container.firstChild).toBeNull(); + }); + + it('renders the empty-state copy from uiStrings when no fragments are harvested', () => { + render( {}} />); + // 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( {}} />); + // 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( {}} />); + // 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(); + fireEvent.click(screen.getByRole('button', { name: /close journal/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('aria-label on the dialog is "Memory Journal" (accessibility)', () => { + render( {}} />); + 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( {}} />); + // 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(); + }); +}); diff --git a/src/ui/journal/Journal.tsx b/src/ui/journal/Journal.tsx new file mode 100644 index 0000000..e11f01b --- /dev/null +++ b/src/ui/journal/Journal.tsx @@ -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 => 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(); + for (const f of harvestedFragments) { + if (!bySeason.has(f.season)) bySeason.set(f.season, []); + bySeason.get(f.season)!.push(f); + } + + return ( +
+ +
+ {harvestedFragments.length === 0 && ( +

+ {strings.empty_state} +

+ )} + {[...bySeason.entries()] + .sort(([a], [b]) => a - b) + .map(([season, frags]) => ( +
+

+ Season {season} +

+ {frags.map((f) => ( +
+
+                    {f.body}
+                  
+
+ ))} +
+ ))} +
+
+ ); +} diff --git a/src/ui/journal/index.ts b/src/ui/journal/index.ts new file mode 100644 index 0000000..3fedbe3 --- /dev/null +++ b/src/ui/journal/index.ts @@ -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'; diff --git a/src/ui/journal/journal-icon.test.tsx b/src/ui/journal/journal-icon.test.tsx new file mode 100644 index 0000000..38d97a4 --- /dev/null +++ b/src/ui/journal/journal-icon.test.tsx @@ -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(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the corner icon button after the first harvest (D-23)', () => { + setHarvested(['season1.soil.first-bloom']); + render(); + expect(screen.getByTestId('journal-icon')).toBeTruthy(); + }); + + it('clicking the icon opens the Journal modal', () => { + setHarvested(['season1.soil.first-bloom']); + render(); + // Modal is closed initially. + expect(screen.queryByTestId('journal-modal')).toBeNull(); + fireEvent.click(screen.getByTestId('journal-icon')); + expect(screen.getByTestId('journal-modal')).toBeTruthy(); + }); +}); diff --git a/src/ui/journal/journal-icon.tsx b/src/ui/journal/journal-icon.tsx new file mode 100644 index 0000000..a491ff3 --- /dev/null +++ b/src/ui/journal/journal-icon.tsx @@ -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 ( + <> + + setOpen(false)} /> + + ); +}