// app.jsx — main App with the cinematic swap state machine const { useState, useEffect, useRef, useCallback } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "activeTapePalette": "default", "swapStyle": "cinematic", "caseOpen": true, "editorialStyle": "serif" }/*EDITMODE-END*/; // NOTE: editorialStyle is kept for compatibility but the invented-quote // variants it used to toggle have been removed with the placeholder content. const PALETTE_MAP = { default: null, /* keep section accent */ coral: '#F2876B', sky: '#7FB5E8', sage: '#93CE8F', lilac: '#B9A3E3', /* legacy stored values from the dark era → nearest new pastel */ orange: '#F2876B', red: '#F2876B', blue: '#7FB5E8', amber: '#F2D98A', }; function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); // Apply tape palette override const accentOverride = PALETTE_MAP[t.activeTapePalette]; const [active, setActive] = useState(() => localStorage.getItem('mv-section') || null); // null = landing const [swapState, setSwapState] = useState('idle'); // idle | ejecting | loading const [pendingTarget, setPendingTarget] = useState(null); const [openProject, setOpenProject] = useState(null); const [counter, setCounter] = useState('00:00:00'); const timersRef = useRef([]); const startTimeRef = useRef(Date.now()); useEffect(() => { localStorage.setItem('mv-section', active || ''); }, [active]); // Reset counter when section changes useEffect(() => { startTimeRef.current = Date.now(); if (!active) { setCounter('--:--:--'); } }, [active]); // Counter tick — only when idle and a tape is loaded useEffect(() => { if (swapState !== 'idle' || !active) return; const tick = () => { const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000); const h = String(Math.floor(elapsed / 3600)).padStart(2, '0'); const m = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0'); const s = String(elapsed % 60).padStart(2, '0'); setCounter(`${h}:${m}:${s}`); }; tick(); const id = setInterval(tick, 1000); return () => clearInterval(id); }, [active, swapState]); // Clear any pending timers const clearTimers = () => { timersRef.current.forEach(clearTimeout); timersRef.current = []; }; useEffect(() => () => clearTimers(), []); const goTo = useCallback((nextId) => { if (nextId === active) return; clearTimers(); const style = t.swapStyle; const ejectDuration = style === 'cinematic' ? 480 : style === 'subtle' ? 200 : 0; const loadDuration = style === 'cinematic' ? 600 : style === 'subtle' ? 240 : 0; if (style === 'instant') { setActive(nextId); setSwapState('idle'); return; } // If a tape is currently loaded — eject first if (active) { setSwapState('ejecting'); setPendingTarget(nextId); timersRef.current.push(setTimeout(() => { setActive(nextId); setSwapState(nextId ? 'loading' : 'idle'); if (nextId) { timersRef.current.push(setTimeout(() => setSwapState('idle'), loadDuration)); } }, ejectDuration)); } else { // No tape loaded → just load setActive(nextId); if (nextId) { setSwapState('loading'); timersRef.current.push(setTimeout(() => setSwapState('idle'), loadDuration)); } else { setSwapState('idle'); } } }, [active, t.swapStyle]); const eject = useCallback(() => goTo(null), [goTo]); const goHome = useCallback(() => goTo(null), [goTo]); // Scroll to top when active changes useEffect(() => { if (swapState === 'loading' || swapState === 'idle') { window.scrollTo({ top: 0, behavior: 'smooth' }); } }, [active]); const currentSection = SECTIONS.find(s => s.id === active); // Apply palette override to deck only const deckSection = currentSection && accentOverride ? { ...currentSection, accent: accentOverride } : currentSection; const contentVisible = swapState === 'idle'; return ( <> {/* Persistent Tape Deck */}
inside a [data-prose] wrapper would otherwise be counted twice in // auto mode — strict mode never picks
directly, so it's only an issue
// when [data-prose] wraps another [data-prose]; closest() guards both.)
if (strictNodes.length > 0 && n.parentElement && n.parentElement.closest('[data-prose]')) return;
const txt = n.innerText || n.textContent || '';
words += (txt.match(/\S+/g) || []).length;
});
return { words, mode: strictNodes.length > 0 ? 'strict' : 'auto' };
};
function Footer() {
const [stats, setStats] = useState({ words: 0, minutes: 0, mode: 'auto' });
const measureRef = useRef(null);
useEffect(() => {
const compute = () => {
const { words, mode } = countWordsIn(measureRef.current);
setStats({ words, mode, minutes: Math.max(1, Math.round(words / 220)) });
};
compute();
const t1 = setTimeout(compute, 400);
const t2 = setTimeout(compute, 1500);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, []);
const pct = Math.min(100, Math.round((stats.words / WORD_LIMIT) * 100));
const over = stats.words > WORD_LIMIT;
const strict = stats.mode === 'strict';
return (
);
}
ReactDOM.createRoot(document.getElementById('root')).render(