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
+65 -2
View File
@@ -1,13 +1,52 @@
import { useRef } from 'react';
import { useEffect, useRef, useState } 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';
import { LuraDialogue } from './ui/dialogue';
import { Letter } from './ui/letter';
import { Settings, PersistenceToast } from './ui/settings';
import { useAppStore } from './store';
function App() {
// PhaserGame ref — Phase 2+ will use this to access the active scene from React.
const phaserRef = useRef<IRefPhaserGame | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
// D-30 — toast surfaces for one cycle when the boot path's
// requestPersistence resolves with denied. PhaserGame writes
// showPersistenceToast=true; the toast component reads it.
const showPersistenceToast = useAppStore((s) => s.showPersistenceToast);
// D-29 — keyboard shortcuts for Settings and the Memory Journal.
// Comma toggles Settings (a tasteful nod — settings is a subordinate
// concern, easy to reach, no browser conflict).
// 'j' toggles the Memory Journal via a window CustomEvent that
// JournalIcon listens for — keeps the icon's open/close state local
// (V1Payload has no journal-open flag, by design — see Plan 02-03
// SUMMARY).
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) {
return;
}
if (e.key === ',') {
e.preventDefault();
setSettingsOpen((o) => !o);
} else if (e.key === 'j' || e.key === 'J') {
e.preventDefault();
window.dispatchEvent(new CustomEvent('tlg:toggle-journal'));
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
return (
<div id="app">
@@ -17,7 +56,31 @@ function App() {
<FragmentRevealModal />
<JournalIcon />
<LuraDialogue />
{/* Plan 02-05 mounts: <Letter />, <Settings />, <PersistenceToast /> */}
<Letter />
<Settings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<PersistenceToast show={showPersistenceToast} />
<button
data-testid="settings-icon"
aria-label="Open settings"
onClick={() => setSettingsOpen(true)}
style={{
position: 'fixed',
bottom: 20,
right: 76,
zIndex: 40,
width: 44,
height: 44,
borderRadius: 22,
background: '#2a2a2e',
color: '#e8e0d0',
border: '1px solid #4d4d52',
cursor: 'pointer',
fontFamily: 'serif',
fontSize: '1.2rem',
}}
>
</button>
</div>
);
}