5d58d6cc7b
- 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.
194 lines
5.7 KiB
TypeScript
194 lines
5.7 KiB
TypeScript
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%',
|
|
};
|