// components/Scaffold.jsx — honest-skeleton helpers · v2 "Liner Notes". // Todo: a clearly-marked slot for writing Merijn will do himself. // data-noword keeps prompts out of the 3000-word count. // EvidenceSlot: a drag-and-drop figure slot (image-slot) with caption. // Figure numbering — an unfilled caption auto-labels itself "Figure N" by its // position in the reading flow (Index → Past → Present → Future), so the // placeholders stay correctly ordered no matter how many slots exist. The // fixed section slots bracket the expertise figures pulled from data.jsx; // image and video slots share one continuous sequence. Once a real caption // is written it overrides the number entirely. let _figureMap = null; function figureMap() { if (_figureMap) return _figureMap; const flow = ['vision-fig', 'pi-fig', 'course-fig-1', 'course-fig-2', 'course-fig-3', 'past-origin']; ((window.EXPERTISE_LONG) || []).forEach((area) => { (area.figures || []).forEach((f) => flow.push(f.id)); }); flow.push('process-fig-1', 'process-fig-2', 'skills-fig-1', 'future-fig-1'); _figureMap = {}; flow.forEach((id, i) => {_figureMap[id] = i + 1;}); return _figureMap; } function figureLabel(id) { const n = figureMap()[id]; return n ? 'Figure ' + n : 'Figure'; } // Per-figure caption placeholder styling (faint amber prompt when empty). if (typeof document !== 'undefined' && !document.getElementById('caption-ph-style')) { const st = document.createElement('style'); st.id = 'caption-ph-style'; st.textContent = '.fig-cap:empty::before{content:attr(data-ph);color:var(--ink-butter);}'; document.head.appendChild(st); } // CaptionField: "Figure N: ". The number is automatic (from the // document flow); the description is an editable, per-slot field persisted in // localStorage under 'evidence-caption:' — mirroring how dropped images // persist. Each figure owns its own caption, so edits never collide with a // shared placeholder (the bug that previously ate the typed titles). function CaptionField({ id, fallback }) { const ref = React.useRef(null); const editable = !!(window.omelette && window.omelette.writeFile); const read = () => { try {const s = localStorage.getItem('evidence-caption:' + id);return s != null ? s : fallback || '';} catch (e) {return fallback || '';} }; const [hasText, setHasText] = React.useState(() => read().trim().length > 0); React.useEffect(() => { const sync = () => { const v = read(); if (ref.current && document.activeElement !== ref.current) ref.current.textContent = v; setHasText(v.trim().length > 0); }; sync(); const onSync = (e) => {if (e.detail && e.detail.id === id) sync();}; window.addEventListener('evidence-caption', onSync); return () => window.removeEventListener('evidence-caption', onSync); }, [id]); const commit = () => { const v = ref.current ? ref.current.textContent.trim() : ''; try {if (v) localStorage.setItem('evidence-caption:' + id, v);else localStorage.removeItem('evidence-caption:' + id);} catch (e) {} if (ref.current) ref.current.textContent = v; setHasText(v.length > 0); window.dispatchEvent(new CustomEvent('evidence-caption', { detail: { id } })); }; const showColon = hasText || editable; return (
{figureLabel(id)}{showColon ? ':' : ''}{showColon ? ' ' : ''} {if (e.key === 'Enter') {e.preventDefault();e.currentTarget.blur();}}} style={{ outline: 'none', cursor: editable ? 'text' : 'default' }}>
); } function Todo({ children, label }) { return (
{label || 'To write'} {children}
); } // Aspect handling: by default ('AUTO') the slot resizes itself to the // natural aspect ratio of whatever image is dropped in — no cropping. // A small hover picker lets you lock any slot to a fixed ratio instead // (the image then cover-crops; double-click it to reframe). The per-slot // choice persists in localStorage under 'evidence-aspect:'. const EVIDENCE_RATIOS = [ ['auto', 'AUTO'], ['16/9', '16:9'], ['3/2', '3:2'], ['4/3', '4:3'], ['1/1', '1:1'], ['4/5', '4:5']]; function EvidenceSlot({ id, caption, height = 240, aspect }) { const slotRef = React.useRef(null); const [pick, setPick] = React.useState(() => { try {return localStorage.getItem('evidence-aspect:' + id) || 'auto';} catch (e) {return 'auto';} }); const [naturalRatio, setNaturalRatio] = React.useState(null); const [hover, setHover] = React.useState(false); const editable = !!(window.omelette && window.omelette.writeFile); // Watch the slot's internal to learn the dropped image's natural // dimensions (and forget them when the image is removed). React.useEffect(() => { const el = slotRef.current; if (!el || !el.shadowRoot) return; const img = el.shadowRoot.querySelector('.frame img'); if (!img) return; const update = () => { const ok = img.getAttribute('src') && img.naturalWidth > 0 && img.naturalHeight > 0; setNaturalRatio(ok ? img.naturalWidth / img.naturalHeight : null); }; update(); img.addEventListener('load', update); const mo = new MutationObserver(update); mo.observe(img, { attributes: true, attributeFilter: ['src'] }); return () => {img.removeEventListener('load', update);mo.disconnect();}; }, []); const choose = (v) => { setPick(v); try {localStorage.setItem('evidence-aspect:' + id, v);} catch (e) {} // The page can mount the same slot twice (desktop/mobile trees) — // broadcast so every copy stays in sync. window.dispatchEvent(new CustomEvent('evidence-aspect', { detail: { id, v } })); }; React.useEffect(() => { const onSync = (e) => {if (e.detail && e.detail.id === id) setPick(e.detail.v);}; window.addEventListener('evidence-aspect', onSync); return () => window.removeEventListener('evidence-aspect', onSync); }, [id]); const slotStyle = { width: '100%', display: 'block', border: '1px dashed #C8D2DD', background: '#F2F5F9' }; // NB: has an internal default height (160px) that wins // unless an inline height is set — so every aspect-ratio branch must // also set height:'auto' for the ratio to actually take effect. if (pick !== 'auto') { slotStyle.aspectRatio = pick; slotStyle.height = 'auto'; } else if (naturalRatio) { // Adapt to the image's true ratio — never crop. Tall images are // scaled DOWN to fit a max height instead: width shrinks to // height-cap × ratio and the slot centers in the column. const MAX_H = 640; slotStyle.aspectRatio = String(naturalRatio); slotStyle.height = 'auto'; slotStyle.width = 'min(100%, ' + Math.round(MAX_H * naturalRatio) + 'px)'; slotStyle.marginLeft = 'auto'; slotStyle.marginRight = 'auto'; } else if (aspect) { slotStyle.aspectRatio = aspect; slotStyle.height = 'auto'; } else { slotStyle.height = height; } return (
setHover(true)} onMouseLeave={() => setHover(false)}> {editable &&
{EVIDENCE_RATIOS.map(([v, label]) => )}
}
); } // ── VideoSlot ─────────────────────────────────────────────────────────── // Drag-and-drop video figure. Images are small enough to live in // localStorage as data URLs (image-slot's approach), but a video would blow // the quota — so clips are kept as Blobs in IndexedDB, keyed by slot id, and // re-hydrated into an object URL on load. Persists across reloads. const VIDEO_STORE = 'evidence-clips'; function videoIDB(mode, id, blob) { return new Promise((resolve, reject) => { let req; try {req = indexedDB.open('portfolio-evidence', 1);} catch (e) {reject(e);return;} req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(VIDEO_STORE)) db.createObjectStore(VIDEO_STORE); }; req.onerror = () => reject(req.error); req.onsuccess = () => { const db = req.result; const write = mode !== 'get'; const tx = db.transaction(VIDEO_STORE, write ? 'readwrite' : 'readonly'); const store = tx.objectStore(VIDEO_STORE); const op = mode === 'put' ? store.put(blob, id) : mode === 'del' ? store.delete(id) : store.get(id); op.onsuccess = () => resolve(op.result); op.onerror = () => reject(op.error); }; }); } const vsBtn = { appearance: 'none', border: 0, padding: '3px 7px', cursor: 'pointer', fontFamily: 'var(--font-body)', fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', background: 'transparent', color: 'var(--ink-sky)' }; function VideoSlot({ id, caption, aspect = '16/9', src }) { const [url, setUrl] = React.useState(null); const [err, setErr] = React.useState(null); const [over, setOver] = React.useState(false); const [hover, setHover] = React.useState(false); const inputRef = React.useRef(null); const urlRef = React.useRef(null); const editable = !!(window.omelette && window.omelette.writeFile); // A dropped clip lives as a Blob in IndexedDB (object URL). When there is // none, fall back to the committed `src` file so the video shows for // everyone, not just the browser that dropped it. urlRef only tracks // object URLs — the static src path is never revoked. const apply = (blob) => { if (urlRef.current) URL.revokeObjectURL(urlRef.current); if (blob) {const u = URL.createObjectURL(blob);urlRef.current = u;setUrl(u);} else {urlRef.current = null;setUrl(src || null);} }; const load = React.useCallback(() => { videoIDB('get', id).then((blob) => {if (blob) apply(blob);else {urlRef.current = null;setUrl(src || null);}}).catch(() => {if (src) setUrl(src);}); }, [id, src]); React.useEffect(() => { load(); // The same slot can mount twice (e.g. responsive trees) — keep copies synced. const onSync = (e) => {if (e.detail && e.detail.id === id) load();}; window.addEventListener('evidence-video', onSync); return () => { window.removeEventListener('evidence-video', onSync); if (urlRef.current) URL.revokeObjectURL(urlRef.current); }; }, [id, load]); const ingest = async (file) => { setErr(null); if (!file || !file.type.startsWith('video/')) {setErr('Drop a video file — .mp4, .webm, .mov');return;} try { await videoIDB('put', id, file); apply(file); window.dispatchEvent(new CustomEvent('evidence-video', { detail: { id } })); } catch (e) {setErr('Could not store this video (too large?).');} }; const clear = async () => { try {await videoIDB('del', id);} catch (e) {} apply(null); window.dispatchEvent(new CustomEvent('evidence-video', { detail: { id } })); }; return (
setHover(true)} onMouseLeave={() => setHover(false)}>
{e.preventDefault();setOver(true);}} onDragOver={(e) => {e.preventDefault();}} onDragLeave={(e) => {e.preventDefault();setOver(false);}} onDrop={(e) => {e.preventDefault();setOver(false);const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];if (f) ingest(f);}} onClick={() => {if (!url) inputRef.current && inputRef.current.click();}} style={{ width: '100%', aspectRatio: aspect, display: 'block', position: 'relative', border: url ? '1px solid var(--c-border)' : '1px dashed ' + (over ? 'var(--ink-coral)' : '#C8D2DD'), background: url ? '#000' : over ? 'rgba(228,138,106,0.08)' : '#F2F5F9', overflow: 'hidden', cursor: url ? 'default' : 'pointer' }}> {url ? :
{err || 'Drop a video — .mp4, .webm'}
}
{editable && url &&
} {const f = e.target.files && e.target.files[0];if (f) ingest(f);e.target.value = '';}} />
); } Object.assign(window, { Todo, EvidenceSlot, VideoSlot, CaptionField });