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(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 (

{strings.title}