feat(02-05): letter overlay + settings UI + boot save lifecycle + clock injection

- src/save/payload.ts (W2): shared buildPayloadFromStore (state, nowMs)
  + hydrateStoreFromPayload (state, payload). Two-arg signature unifies
  Settings.tsx (passes Date.now()) and PhaserGame.tsx saveSync (passes
  clock.now()). BLOCKER 3 — lastTickAt is wall-clock ms, owned by the
  application layer; the sim never writes it.
- src/ui/letter/Letter.tsx + test: D-20 full-screen overlay (UX-02);
  loads compiled letter Ink, binds plants_bloomed/fragment_titles/
  lura_was_here slots from session.pendingLetterEventBlock, dismisses
  via Tend the garden button or backdrop click. Pitfall 9 — dismiss
  calls bootstrapAudioContext synchronously inside the click handler.
- src/ui/settings/Settings.tsx + test: D-28 save-management UI
  (Export/Import/Restore). BLOCKER 2 — Import pipeline is
  importFromBase64 -> unwrap (CRC verify) -> migrate -> hydrate. No
  audio sliders, no a11y polish (Phase 8 owns those).
- src/ui/settings/persistence-toast.tsx: D-30 one-time soft toast in
  voice when navigator.storage.persist() denies. Reads
  showPersistenceToast from session slice; sets persistenceToastShown
  after the timeout fires.
- src/PhaserGame.tsx: full boot path rewrite. Clock selection
  (?devtime=fake URL flag, production-guarded by import.meta.env.PROD),
  save load (BLOCKER 1 — unwrap then migrate), silent offline catchup
  via drainTicks(silent=true), letter overlay open at >=5min absence,
  requestPersistence + showPersistenceToast wiring, Phaser start AFTER
  hydration, registerSaveLifecycleHooks with synchronous LocalStorage
  saveSync (Pitfall 7) + best-effort IDB write. W5 — lifecycle handle
  held in ref so outer cleanup detaches.
- src/store/session-slice.ts: showPersistenceToast transient flag +
  setShowPersistenceToast action.
- src/ui/journal/journal-icon.tsx: 'j' hotkey listener via
  tlg:toggle-journal CustomEvent (D-29).
- src/game/scenes/Garden.ts: formalized clock read via readClockSlot()
  helper; falls back to wallClock if window.__tlgClock missing.
- src/App.tsx: mount Letter, Settings, PersistenceToast, SettingsIcon
  (corner button); D-29 keyboard shortcuts (',' toggles Settings, 'j'
  toggles Journal via window event).
- 308/308 tests green (was 295; +13 new — 7 Letter + 6 Settings).
  npm run ci exits 0; Vite emits letter Ink as a separate lazy chunk.
This commit is contained in:
2026-05-09 10:57:09 -04:00
parent 26eb77a216
commit 5d58d6cc7b
15 changed files with 1156 additions and 19 deletions
+2
View File
@@ -9,3 +9,5 @@ export * from './begin';
export * from './garden';
export * from './journal';
export * from './dialogue';
export * from './letter';
export * from './settings';
+11
View File
@@ -31,6 +31,17 @@ export function JournalIcon(): JSX.Element | null {
if (!revealed && open) setOpen(false);
}, [revealed, open]);
// Plan 02-05 — D-29 'j' hotkey listens for the App-dispatched
// CustomEvent to toggle the journal modal. Keeping the open/close
// state local here (rather than lifting into the store) preserves
// V1Payload's no-journal-open-flag invariant.
useEffect(() => {
if (!revealed) return;
const onToggle = (): void => setOpen((v) => !v);
window.addEventListener('tlg:toggle-journal', onToggle);
return () => window.removeEventListener('tlg:toggle-journal', onToggle);
}, [revealed]);
if (!revealed) return null;
return (
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { appStore } from '../../store';
// Hoisted mocks. Phaser is not loaded under happy-dom; the dialogue +
// content modules transitively pull Phaser via the event-bus, so we
// mock the surfaces the Letter component touches.
vi.mock('../../game/event-bus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
removeAllListeners: vi.fn(),
},
}));
const fakeStoryProto = {
variablesState: {} as Record<string, unknown>,
ChoosePathString: vi.fn(),
};
vi.mock('../../content', () => ({
loadInkStory: vi.fn(async () => fakeStoryProto),
bindGardenStateToInk: vi.fn(),
fragments: [],
}));
vi.mock('../dialogue', () => ({
InkRenderer: () => null,
createInkRuntime: vi.fn(() => ({
nextLine: vi.fn(async () => null),
canContinue: () => false,
currentChoices: () => [],
chooseChoice: vi.fn(),
skipDelay: vi.fn(),
})),
}));
// Hoisted spy: vi.mock factories are hoisted above imports, so any
// top-level variable they reference must also be hoisted via vi.hoisted.
const { bootstrapSpy } = vi.hoisted(() => ({
bootstrapSpy: vi.fn(async () => null),
}));
vi.mock('../begin', () => ({
bootstrapAudioContext: bootstrapSpy,
}));
import { Letter } from './Letter';
describe('Letter (UX-02 + D-20 — full-screen overlay)', () => {
beforeEach(() => {
appStore.setState({
letterOverlayOpen: false,
pendingLetterEventBlock: null,
beginGateDismissed: false,
});
bootstrapSpy.mockClear();
fakeStoryProto.ChoosePathString.mockClear();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('returns null when letterOverlayOpen=false', () => {
const { container } = render(<Letter />);
expect(container.firstChild).toBeNull();
});
it('mounts the dialog when letterOverlayOpen=true (loading state, null block)', () => {
appStore.setState({
letterOverlayOpen: true,
pendingLetterEventBlock: null,
});
render(<Letter />);
expect(screen.getByRole('dialog', { name: 'A letter from the garden' })).toBeTruthy();
});
it('Tend the garden button dispatches dismissLetter AND calls bootstrapAudioContext (Pitfall 9)', () => {
appStore.setState({
letterOverlayOpen: true,
pendingLetterEventBlock: null,
});
render(<Letter />);
const btn = screen.getByRole('button', { name: 'Tend the garden' });
fireEvent.click(btn);
const post = appStore.getState();
expect(post.letterOverlayOpen).toBe(false);
expect(bootstrapSpy).toHaveBeenCalledTimes(1);
});
it('clicks on the article body do NOT dismiss the overlay', () => {
appStore.setState({
letterOverlayOpen: true,
pendingLetterEventBlock: null,
});
render(<Letter />);
const article = screen.getByRole('dialog').querySelector('article');
expect(article).not.toBeNull();
fireEvent.click(article!);
expect(appStore.getState().letterOverlayOpen).toBe(true);
});
it('clicks on the backdrop dismiss the overlay AND bootstrap audio', () => {
appStore.setState({
letterOverlayOpen: true,
pendingLetterEventBlock: null,
});
render(<Letter />);
const backdrop = screen.getByRole('dialog');
fireEvent.click(backdrop);
expect(appStore.getState().letterOverlayOpen).toBe(false);
expect(bootstrapSpy).toHaveBeenCalled();
});
it('also dismisses the Begin gate on dismiss (returning-player belt-and-braces)', () => {
appStore.setState({
letterOverlayOpen: true,
pendingLetterEventBlock: null,
beginGateDismissed: false,
});
render(<Letter />);
fireEvent.click(screen.getByRole('button', { name: 'Tend the garden' }));
expect(appStore.getState().beginGateDismissed).toBe(true);
});
it('calls loadInkStory("letter-from-the-garden") + ChoosePathString("letter") when opened', async () => {
const { loadInkStory } = await import('../../content');
appStore.setState({
letterOverlayOpen: true,
pendingLetterEventBlock: null,
});
render(<Letter />);
await waitFor(() => {
expect(loadInkStory).toHaveBeenCalledWith('letter-from-the-garden');
});
expect(fakeStoryProto.ChoosePathString).toHaveBeenCalledWith('letter');
});
});
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useState, type JSX } from 'react';
import { appStore, useAppStore } from '../../store';
import { loadInkStory, fragments as allFragments } from '../../content';
import { createInkRuntime, InkRenderer, type InkRuntime } from '../dialogue';
import { bootstrapAudioContext } from '../begin';
import { buildLetterSlots } from './letter-renderer';
import type { OfflineEventBlock } from '../../sim/offline';
/**
* Letter from the garden — UX-02 + CONTEXT D-17/D-18/D-20 + Pitfall 9.
*
* Full-screen DOM overlay. Triggered when the boot path determines a
* returning player has been away ≥5 minutes (the threshold is owned by
* src/PhaserGame.tsx; this component just reacts to the store flag).
* One tap dismisses to the live garden.
*
* Per Pitfall 9: dismiss must call bootstrapAudioContext() — a returning
* player who lands directly in the letter would otherwise have no audio
* gesture before reaching the live garden. The synchronous-inside-click
* contract from AEST-07 (Pitfall 5) applies here too.
*
* Per RESEARCH Architectural Responsibility Map: Ink runtime lives in
* the UI tier. This component reuses the same loadInkStory + InkRenderer
* surface as Plan 02-04's LuraDialogue — single source of truth for the
* Ink runtime path.
*/
export function Letter(): JSX.Element | null {
const open = useAppStore((s) => s.letterOverlayOpen);
const block = useAppStore(
(s) => s.pendingLetterEventBlock,
) as OfflineEventBlock | null;
const dismissLetter = useAppStore((s) => s.dismissLetter);
const [runtime, setRuntime] = useState<InkRuntime | null>(null);
useEffect(() => {
if (!open) {
setRuntime(null);
return;
}
let cancelled = false;
(async () => {
try {
const story = await loadInkStory('letter-from-the-garden');
if (cancelled) return;
// Build the slot values from the offline event block + fragment
// corpus (for human-readable titles). buildLetterSlots is pure;
// tested independently in letter-renderer.test.ts.
const slots = buildLetterSlots(block, allFragments);
// Bind variables before the first ChoosePathString. inkjs's
// variablesState getter throws if the variable isn't declared in
// the .ink file — wrap each set in try/catch for resilience.
const vs = story.variablesState as unknown as Record<string, unknown>;
try {
vs['plants_bloomed'] = slots.plants_bloomed;
} catch {
/* declared in .ink — should not throw */
}
try {
vs['fragment_titles'] = slots.fragment_titles;
} catch {
/* declared in .ink — should not throw */
}
try {
vs['lura_was_here'] = slots.lura_was_here;
} catch {
/* declared in .ink — should not throw */
}
story.ChoosePathString('letter');
setRuntime(createInkRuntime(story));
} catch (err) {
// Fail-soft: log + dismiss. The boot path will already have
// shown the player the live garden behind the overlay; dismissing
// returns them there without losing state.
console.error('[Letter] failed to load', err);
dismissLetter();
}
})();
return () => {
cancelled = true;
};
}, [open, block, dismissLetter]);
if (!open) return null;
const onDismiss = (): void => {
// Pitfall 9: synchronous-inside-click audio bootstrap for the
// returning-player path. Do NOT await — bootstrapAudioContext is
// async but the construction MUST happen inside the gesture stack
// frame, not after a microtask boundary.
void bootstrapAudioContext();
dismissLetter();
// Also dismiss the Begin gate so a returning player who arrived
// via the letter path doesn't see it again behind the dismissed
// overlay. (D-22 already covers save-existence-based skip; this is
// the belt-and-braces in case the boot path's dismissBeginGate did
// not fire.)
appStore.getState().dismissBeginGate();
};
return (
<div
role="dialog"
aria-label="A letter from the garden"
data-testid="letter-overlay"
onClick={onDismiss}
style={{
position: 'fixed',
inset: 0,
zIndex: 95,
background: '#0c0c0d',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: '#e8e0d0',
fontFamily: 'serif',
}}
>
<article
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: 620,
padding: '3rem 2.6rem',
cursor: 'default',
userSelect: 'text',
}}
>
{runtime ? (
<InkRenderer runtime={runtime} onComplete={() => {}} />
) : (
<p style={{ opacity: 0.4 }}>...</p>
)}
<button
onClick={onDismiss}
style={{
marginTop: '2rem',
padding: '0.5rem 1.6rem',
background: 'transparent',
color: '#e8e0d0',
border: '1px solid #e8e0d0',
cursor: 'pointer',
fontFamily: 'serif',
}}
>
Tend the garden
</button>
</article>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Public barrel for src/ui/letter/.
*
* Plan 02-05 ships:
* - Letter: full-screen DOM overlay rendering letter-from-the-garden.ink
* when a returning player has been away ≥5 minutes (UX-02, D-20).
* - buildLetterSlots: pure helper converting an OfflineEventBlock into
* the Ink template's slot values; separated for testability.
*/
export { Letter } from './Letter';
export { buildLetterSlots } from './letter-renderer';
export type { LetterSlots } from './letter-renderer';
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
// Phaser is not loadable under happy-dom — the event-bus import would
// trip checkInverseAlpha. Mock it the same way Plan 02-03's Journal test
// does.
vi.mock('../../game/event-bus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
removeAllListeners: vi.fn(),
},
}));
import { Settings } from './Settings';
describe('Settings (D-28 — save-management surfaces)', () => {
beforeEach(() => {
// Stub clipboard.writeText to avoid happy-dom permission noise.
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: vi.fn(async () => {}) },
configurable: true,
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('returns null when open=false', () => {
const { container } = render(<Settings open={false} onClose={() => {}} />);
expect(container.firstChild).toBeNull();
});
it('mounts the dialog with all four save-management buttons when open', () => {
render(<Settings open={true} onClose={() => {}} />);
expect(screen.getByRole('dialog', { name: 'Settings' })).toBeTruthy();
// From content/seasons/01-soil/ui-strings.yaml:
expect(screen.getByText('Save to a copy')).toBeTruthy();
expect(screen.getByText('Restore from a copy')).toBeTruthy();
expect(screen.getByText('Earlier garden')).toBeTruthy();
expect(screen.getByText('Close')).toBeTruthy();
});
it('Close button calls onClose exactly once', () => {
const onClose = vi.fn();
render(<Settings open={true} onClose={onClose} />);
fireEvent.click(screen.getByTestId('settings-close'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('Export button populates the textarea with a non-empty Base64 string', () => {
render(<Settings open={true} onClose={() => {}} />);
const textarea = screen.getByLabelText('Save data') as HTMLTextAreaElement;
expect(textarea.value).toBe('');
fireEvent.click(screen.getByTestId('settings-export'));
// Status line surfaces the success copy from inline string.
expect(screen.getByText('Saved to clipboard.')).toBeTruthy();
// Base64 encoded save round-tripped via wrap + lz-string is non-trivial.
expect(textarea.value.length).toBeGreaterThan(0);
});
it('Import on a malformed Base64 input shows the soft-error status', () => {
render(<Settings open={true} onClose={() => {}} />);
const textarea = screen.getByLabelText('Save data') as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: 'not-a-real-base64-payload' } });
fireEvent.click(screen.getByTestId('settings-import'));
expect(screen.getByText("That doesn't look like one of yours.")).toBeTruthy();
});
it('Export → Import round-trip keeps the status line at "Restored."', () => {
render(<Settings open={true} onClose={() => {}} />);
fireEvent.click(screen.getByTestId('settings-export'));
// After Export, the textarea has a real envelope. Importing it should restore.
fireEvent.click(screen.getByTestId('settings-import'));
expect(screen.getByText('Restored.')).toBeTruthy();
});
});
+193
View File
@@ -0,0 +1,193 @@
import { useState, type CSSProperties, type JSX } from 'react';
import { appStore } from '../../store';
import {
exportToBase64,
importFromBase64,
listSnapshots,
wrap,
unwrap,
migrate,
CURRENT_SCHEMA_VERSION,
buildPayloadFromStore,
hydrateStoreFromPayload,
type V1Payload,
} from '../../save';
import { uiStrings } from '../../content';
/**
* Settings overlay (CONTEXT D-28). Phase 2 ships save-management surfaces
* only — Export to Base64 (CORE-09), Import from Base64 (CORE-09), Restore
* previous snapshot (CORE-08). Audio sliders + keyboard nav + a11y polish
* land in Phase 8 (UX-04, UX-06, UX-07, UX-08).
*
* Per BLOCKER 2 (PLAN W2): the Import path runs the documented pipeline
* `importFromBase64 → unwrap (CRC verify) → migrate`. Skipping unwrap or
* migrate would silently accept any future-shape payload as the current
* shape; this code goes through both.
*
* Player-visible strings come from /content/seasons/01-soil/ui-strings.yaml
* (CLAUDE.md externalized-strings rule). Status-line copy (`'Saved to
* clipboard.'`, `'Restored.'`, etc.) is intentionally inline as a
* Phase-2 minimum-viable choice — Phase 8 moves these into uiStrings if
* tone review demands it.
*/
export function Settings({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}): JSX.Element | null {
const strings = uiStrings[1]?.settings;
const [base64Buf, setBase64Buf] = useState('');
const [statusLine, setStatusLine] = useState<string | null>(null);
if (!open || !strings) return null;
const onExport = (): void => {
try {
const state = appStore.getState();
// W2 — shared two-arg signature. Settings has no clock injection
// on hand; pass Date.now() for the wall-clock anchor (BLOCKER 3:
// lastTickAt is wall-clock ms, owned by the application layer).
const payload: V1Payload = buildPayloadFromStore(state, Date.now());
const env = wrap(payload, CURRENT_SCHEMA_VERSION);
const b64 = exportToBase64(env);
void navigator.clipboard?.writeText(b64).catch(() => {
/* clipboard API may be unavailable; the textarea still has the value */
});
setBase64Buf(b64);
setStatusLine('Saved to clipboard.');
} catch {
setStatusLine('Could not save.');
}
};
const onImport = (): void => {
try {
// BLOCKER 2 — full pipeline: importFromBase64 (decompress + Zod
// validate envelope shape) → unwrap (CRC verify; throws
// SaveCorruptError on mismatch) → migrate (chain forward to
// CURRENT_SCHEMA_VERSION).
const env = importFromBase64(base64Buf);
const raw = unwrap(env);
const result = migrate(raw, env.schemaVersion);
hydrateStoreFromPayload(appStore.getState(), result.payload as V1Payload);
setStatusLine('Restored.');
} catch {
setStatusLine("That doesn't look like one of yours.");
}
};
const onRestoreSnapshot = (): void => {
void (async () => {
try {
const snaps = await listSnapshots();
if (snaps.length === 0) {
setStatusLine('Nothing earlier to find.');
return;
}
// listSnapshots returns newest-first; restore the most recent.
const latest = snaps[0];
if (!latest) {
setStatusLine('Nothing earlier to find.');
return;
}
const raw = unwrap(latest.envelope);
const result = migrate(raw, latest.envelope.schemaVersion);
hydrateStoreFromPayload(
appStore.getState(),
result.payload as V1Payload,
);
setStatusLine('Earlier garden restored.');
} catch {
setStatusLine('Nothing earlier could be reached.');
}
})();
};
return (
<div
role="dialog"
aria-label={strings.title}
data-testid="settings-modal"
style={{
position: 'fixed',
inset: 0,
zIndex: 70,
background: '#1a1a1ac0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#e8e0d0',
fontFamily: 'serif',
}}
>
<div
style={{
maxWidth: 520,
background: '#1f1f23',
padding: '2rem',
borderRadius: 4,
}}
>
<h2
style={{
marginTop: 0,
fontWeight: 300,
letterSpacing: '0.1em',
}}
>
{strings.title}
</h2>
<button data-testid="settings-export" onClick={onExport} style={btnStyle}>
{strings.export}
</button>
<textarea
value={base64Buf}
onChange={(e) => setBase64Buf(e.target.value)}
rows={4}
style={{
width: '100%',
marginTop: '1rem',
fontFamily: 'monospace',
fontSize: '0.8rem',
}}
aria-label="Save data"
/>
<button data-testid="settings-import" onClick={onImport} style={btnStyle}>
{strings.import}
</button>
<button
data-testid="settings-restore"
onClick={onRestoreSnapshot}
style={btnStyle}
>
{strings.restore_snapshot}
</button>
{statusLine && (
<p style={{ opacity: 0.6, fontStyle: 'italic' }}>{statusLine}</p>
)}
<button
data-testid="settings-close"
onClick={onClose}
style={{ ...btnStyle, marginTop: '1.5rem' }}
>
Close
</button>
</div>
</div>
);
}
const btnStyle: CSSProperties = {
display: 'block',
margin: '0.5rem 0',
padding: '0.5rem 1rem',
background: 'transparent',
color: '#e8e0d0',
border: '1px solid #4d4d52',
cursor: 'pointer',
fontFamily: 'serif',
textAlign: 'left',
width: '100%',
};
+9
View File
@@ -0,0 +1,9 @@
/**
* Public barrel for src/ui/settings/.
*
* Plan 02-05 ships:
* - Settings: D-28 save-management modal (Export / Import / Restore)
* - PersistenceToast: D-30 one-time soft toast in voice
*/
export { Settings } from './Settings';
export { PersistenceToast } from './persistence-toast';
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useState, type JSX } from 'react';
import { useAppStore } from '../../store';
import { uiStrings } from '../../content';
/**
* Persistence-result toast (CONTEXT D-30 + UX-13).
*
* One-time soft toast in voice on first save if `navigator.storage.persist()`
* was denied; nothing if granted. State remembered via
* `settings.persistenceToastShown` so the toast only fires once across
* sessions.
*
* Triggered by src/PhaserGame.tsx after the boot path's requestPersistence
* call resolves with `granted=false && apiAvailable=true && !persistenceToastShown`.
*
* Anti-FOMO compliant — the copy is in the gardener-keeper voice
* (uiStrings[1].settings.persistence_denied_toast: "The garden may forget,
* if your browser asks it to."). No nag, no streak, no daily-login pressure.
*/
const TOAST_DURATION_MS = 6500;
export function PersistenceToast({ show }: { show: boolean }): JSX.Element | null {
const [visible, setVisible] = useState(show);
const setShown = useAppStore((s) => s.setPersistenceToastShown);
const strings = uiStrings[1]?.settings;
useEffect(() => {
if (!show) {
setVisible(false);
return;
}
setVisible(true);
const t = setTimeout(() => {
setVisible(false);
setShown(true);
}, TOAST_DURATION_MS);
return () => clearTimeout(t);
}, [show, setShown]);
if (!visible || !strings) return null;
return (
<div
role="status"
data-testid="persistence-toast"
style={{
position: 'fixed',
bottom: 24,
left: 24,
zIndex: 30,
maxWidth: 420,
padding: '0.8rem 1.2rem',
background: '#1f1f23ee',
color: '#e8e0d0',
border: '1px solid #4d4d52',
fontFamily: 'serif',
fontStyle: 'italic',
}}
>
{strings.persistence_denied_toast}
</div>
);
}