// Inline edit mode for the Project Overview record. // // Wrap any semantic block in : // - Hover shows a soft halo + pencil affordance. // - Click opens a right-side Edit panel with a form whose fields write back // to the block live (no save button — "Saved" indicator). // - Fields can declare a `source` ({ file, pages, by, ago, agent, excerpts }) // which renders as a pill under the input and a SOURCE TRACE popover on // hover. // - Fields can declare an `updateId` (also routable via `updates={{...}}`) // that ties them to an entry in the SourceUpdates context. When that // entry is pending, the chip shows a green pulsing dot; clicking the // chip reveals an inline was→now diff with Accept / Decline actions. // - When the user manually edits a field whose source value is locked in, // the chip switches to a "detached" state: muted, with an unlink icon // and a "Revert to source value" affordance that restores the original. // // Children may be either JSX or a render function (values) => JSX so that // updates flow back into the rendered content immediately. const { useState: useStateEM, useRef: useRefEM, useEffect: useEffectEM, useLayoutEffect: useLayoutEffectEM, useMemo: useMemoEM, } = React; /* ===== Icons (small, local) ===== */ function PencilIcon({ size = 13 }) { return ( ); } function FileIcon({ size = 13 }) { return ( ); } function XlsxIcon({ size = 13 }) { return ( ); } function SourceFileIcon({ kind, size = 13 }) { if (kind === 'xlsx' || kind === 'sheet') return ; return ; } function ExtIcon({ size = 13 }) { return ( ); } function RefreshIcon({ size = 13 }) { return ( ); } function CheckIcon({ size = 13 }) { return ( ); } function UnlinkIcon({ size = 13 }) { // Lucide "unlink": two C-shapes with a diagonal break return ( ); } function RevertIcon({ size = 12 }) { return ( ); } function ArrowIcon({ size = 13 }) { return ( ); } /* ===== Relative-time helper (for "Edited by you · 2m ago") ===== */ function fmtAgo(ts) { if (!ts) return ''; const d = Date.now() - ts; if (d < 8000) return 'just now'; if (d < 60_000) return Math.round(d / 1000) + 's ago'; if (d < 3_600_000) return Math.round(d / 60_000) + 'm ago'; if (d < 86_400_000) return Math.round(d / 3_600_000) + 'h ago'; return Math.round(d / 86_400_000) + 'd ago'; } /* ===== Source pages popover (hover) ===== */ function SourceTrace({ source }) { return (
e.stopPropagation()}>
SOURCE TRACE
{source.file}
{source.by} · {source.ago}
{(source.excerpts || []).map((ex, i) => (
{ex.label}
{ex.kind === 'table' ? (
{ex.headers && ( {ex.headers.map((h, j) => ( ))} )} {(ex.rows || []).map((row, ri) => ( {row.map((cell, ci) => ( ))} ))}
{h}
{cell}
) : (

{ex.text}

)}
))}
Extracted by {source.agent || 'node agent'} · {source.extractedAgo || '3 days ago'}
); } /* ===== Source badge (pill under field) ===== States: - normal: attached, no update → standard pill - updated: pending source update → standard pill + pulsing green dot, click opens inline accept/decline - detached: user has overridden → muted pill, strikethrough on filename, unlink icon, "Edited by you · ago" + Revert to source value detached + updated can stack: muted pill with the dot still showing. */ function SourceBadge({ source, detached = false, editedAt = null, updateId = null, onRevert, onAcceptUpdate, tick = 0, // re-render hint from parent for relative time }) { const [hover, setHover] = useStateEM(false); const [coords, setCoords] = useStateEM({ top: 0, left: 0 }); const [expanded, setExpanded] = useStateEM(false); const badgeRef = useRefEM(null); const wrapRef = useRefEM(null); const timer = useRefEM(null); // Pull update record from SourceUpdates context (if any) const useSourceUpdate = window.useSourceUpdate; const useSourceUpdates = window.useSourceUpdates; const update = useSourceUpdate ? useSourceUpdate(updateId) : null; const ctx = useSourceUpdates ? useSourceUpdates() : null; const hasPendingUpdate = !!(update && update.status === 'pending'); // Auto-collapse if the update gets resolved elsewhere useEffectEM(() => { if (!hasPendingUpdate && expanded) setExpanded(false); }, [hasPendingUpdate, expanded]); const POPOVER_W = 320; const GAP = 12; const computePos = () => { if (!badgeRef.current) return; const r = badgeRef.current.getBoundingClientRect(); const panel = document.querySelector('.ed-panel'); const panelLeft = panel ? panel.getBoundingClientRect().left : r.left; let left = panelLeft - GAP - POPOVER_W; if (left < 8) left = 8; let top = r.top - 24; const maxTop = window.innerHeight - 420; if (top > maxTop) top = Math.max(8, maxTop); if (top < 8) top = 8; setCoords({ top, left }); }; const openHover = () => { // Don't open popover while inline expansion is showing — avoid double UI if (expanded) return; if (timer.current) clearTimeout(timer.current); computePos(); setHover(true); }; const closeHover = () => { timer.current = setTimeout(() => setHover(false), 80); }; useEffectEM(() => () => { if (timer.current) clearTimeout(timer.current); }, []); const handleChipClick = (e) => { e.stopPropagation(); if (!hasPendingUpdate) return; setHover(false); setExpanded((v) => !v); }; const handleAccept = (e) => { e.stopPropagation(); if (!update || !ctx) return; const newVal = update.newValue; ctx.accept(update.id); if (onAcceptUpdate) onAcceptUpdate(newVal); setExpanded(false); }; const handleDecline = (e) => { e.stopPropagation(); if (!update || !ctx) return; ctx.decline(update.id); setExpanded(false); }; const handleRevert = (e) => { e.stopPropagation(); if (onRevert) onRevert(); }; // For relative-time recomputation — ref the prop so React notices the tick void tick; return (
{detached && (
Edited by you{editedAt ? ` · ${fmtAgo(editedAt)}` : ''}
)} {expanded && hasPendingUpdate && (
Source has a new value · {update.sourceBy} · {update.sourceAgo}
was {update.oldValue} now {update.newValue}
)}
{hover && ReactDOM.createPortal(
e.stopPropagation()}>
, document.body )}
); } /* ===== Auto-growing textarea (no inner scroll) ===== */ function AutoTextarea({ id, value, onChange, className, rows = 4, spellCheck = 'false' }) { const ref = useRefEM(null); const resize = () => { const el = ref.current; if (!el) return; el.style.height = 'auto'; // +2 to swallow any rounding so we never trigger an internal scrollbar el.style.height = (el.scrollHeight + 2) + 'px'; }; useLayoutEffectEM(() => { resize(); }, [value]); useEffectEM(() => { // Re-fit on window resize (width changes wrap differently) const onR = () => resize(); window.addEventListener('resize', onR); return () => window.removeEventListener('resize', onR); }, []); return (