const { useState, useEffect, useRef, useMemo } = React;
const STATUS = {
pending: { label: "Pending review", c: "#8b96ad", dim: "rgba(139,150,173,.14)" },
approved: { label: "Hot", c: "#34d399", dim: "rgba(52,211,153,.14)" },
rejected: { label: "Not it", c: "#f87171", dim: "rgba(248,113,113,.14)" },
revision: { label: "Needs work", c: "#fbbf24", dim: "rgba(251,191,36,.14)" },
superseded: { label: "Superseded", c: "#a78bfa", dim: "rgba(167,139,250,.14)" },
active: { label: "Product-active", c: "#38bdf8", dim: "rgba(56,189,248,.14)" }
};
const OWN = {
original: { label: "Original", c: "#8b96ad" },
proto: { label: "Prototype-only", c: "#fbbf24" },
licensed: { label: "Licensed", c: "#38bdf8" },
source: { label: "Source-needed", c: "#f87171" }
};
const OWN_NOTE = {
original: "Made in-house. Clear to ship once marked Hot.",
proto: "Prototype use only — must be replaced or licensed before product use.",
licensed: "Third-party licence on file. Verify scope covers this surface.",
source: "No usable source yet. Blocked from implementation regardless of visual quality."
};
function springLoop(from, to, onFrame, stiff = 170, damp = 22) {
let s = { x: from, v: 0 }, raf, dead = false;
const dt = 1 / 60;
const step = () => {
if (dead) return;
s.v += (stiff * (to - s.x) - damp * s.v) * dt; s.x += s.v * dt;
if (Math.abs(to - s.x) < 0.001 && Math.abs(s.v) < 0.001) { onFrame(to); return; }
onFrame(s.x); raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => { dead = true; cancelAnimationFrame(raf); };
}
function useSlideIn(key, dir) {
const [t, setT] = useState(1);
const first = useRef(true);
useEffect(() => {
if (first.current) { first.current = false; return; }
setT(0); return springLoop(0, 1, setT);
}, [key]);
const p = Math.max(0, Math.min(1, t));
return { transform: `translateX(${(1 - t) * (dir || 1) * 56}px)`, opacity: p };
}
function useEnterSpring() {
const [t, setT] = useState(0);
useEffect(() => springLoop(0, 1, setT), []);
return t;
}
function Art({ a, kind, repl }) {
if (kind === "c" && repl) return <div className="art"><img src={repl.url} alt="" /><span className="art-tag">CANDIDATE · REPLACED</span></div>;
const src = kind === "o" ? a.ref : a.cand;
if (src) return <div className={"art" + (kind === "o" ? " art-ref" : "")}><img src={src} alt="" /><span className="art-tag">{kind === "o" ? "REF · MOCK CROP" : "CANDIDATE"}</span></div>;
const h = a.h, ref = kind === "o";
const bg = ref
? `radial-gradient(120% 90% at 30% 20%, hsl(${h} 48% 34% / .9), transparent 60%), linear-gradient(165deg, hsl(${h} 42% 24%), hsl(${h - 18} 50% 9%))`
: `radial-gradient(110% 100% at 70% 15%, hsl(${h} 62% 42% / .85), transparent 62%), linear-gradient(200deg, hsl(${h} 55% 30%), hsl(${h - 14} 48% 12%))`;
return (
<div className={"art" + (ref ? " art-ref" : "")} style={{ background: bg }}>
<span className="art-glyph">{a.glyph}</span>
<span className="art-tag">{ref ? "REF · MOCK CROP" : "CANDIDATE"}</span>
</div>
);
}
function StatusChip({ s }) { const m = STATUS[s]; return <span className="chip" style={{ color: m.c, background: m.dim, borderColor: m.c + "44" }}><i style={{ background: m.c }}></i>{m.label}</span>; }
function OwnChip({ o }) { const m = OWN[o]; return <span className="chip chip-own" style={{ color: m.c }}>{m.label}</span>; }
function ConfBar({ v }) {
const c = v >= 80 ? "#34d399" : v >= 65 ? "#fbbf24" : "#f87171";
return <span className="conf"><span className="conf-track"><span style={{ width: v + "%", background: c }}></span></span><b style={{ color: c }}>{v}</b></span>;
}
function ActionBar({ a, store, size, onSet }) {
const st = store.statuses[a.id];
const btn = (key, label, cls) => (
<button className={"act " + cls + (st === key ? " on" : "")} onClick={() => { store.setStatus(a.id, st === key ? "pending" : key); if (onSet && st !== key) onSet(key); }}>{label}</button>
);
return (
<div className={"actbar" + (size === "lg" ? " actbar-lg" : "")}>
{btn("rejected", "Not it", "act-rej")}
{btn("revision", "Needs work", "act-rev")}
{btn("approved", "Hot", "act-app")}
</div>
);
}
function UploadReplace({ a, store }) {
const ref = useRef();
return (
<span>
<input ref={ref} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { const f = e.target.files[0]; if (f) store.stageReplacement(a.id, URL.createObjectURL(f), f.name); e.target.value = ""; }} />
<button className="ghost-btn" onClick={() => ref.current.click()}>↑ Upload replacement</button>
</span>
);
}
function NoteComposer({ a, store }) {
const [text, setText] = useState("");
const [tg, setTg] = useState([]);
const [refFile, setRefFile] = useState(null);
const fref = useRef();
const toggle = (t) => setTg(tg.includes(t) ? tg.filter(x => x !== t) : [...tg, t]);
const submit = () => {
if (!text.trim()) return;
store.addNote(a.id, { text: text.trim(), targets: tg, refName: refFile ? refFile.name : null, refUrl: refFile ? refFile.url : null });
setText(""); setTg([]); setRefFile(null);
};
return (
<div className="composer">
<textarea value={text} onChange={(e) => setText(e.target.value)} placeholder="What's wrong, and what needs changing…" rows={3}></textarea>
<div className="tg-row">{CTM_DATA.targets.map(t => <button key={t} className={"tg" + (tg.includes(t) ? " on" : "")} onClick={() => toggle(t)}>{t}</button>)}</div>
<div className="composer-foot">
<input ref={fref} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { const f = e.target.files[0]; if (f) setRefFile({ name: f.name, url: URL.createObjectURL(f) }); e.target.value = ""; }} />
<button className="ghost-btn" onClick={() => fref.current.click()}>{refFile ? "◈ " + refFile.name : "◈ Attach reference"}</button>
<button className="blue-btn" disabled={!text.trim()} onClick={submit}>Log note</button>
</div>
</div>
);
}
function NoteList({ notes }) {
if (!notes.length) return <div className="empty-notes">No notes yet. First pass is yours.</div>;
return (
<div className="notes">
{notes.slice().reverse().map(n => (
<div className="note" key={n.id}>
<div className="note-head"><b>{n.by || "Reviewer"}</b><span>{new Date(n.ts).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}</span></div>
<p>{n.text}</p>
{n.annotation && <div className="note-ref">◎ {n.annotation.shape} mark · {n.annotation.view} view · {Math.round(n.annotation.box.w)}×{Math.round(n.annotation.box.h)}%</div>}
{n.targets && n.targets.length > 0 && <div className="note-tgs">{n.targets.map(t => <span key={t}>{t}</span>)}</div>}
{n.refName && <div className="note-ref">◈ {n.refName}{n.refUrl && <img src={n.refUrl} alt="" />}</div>}
</div>
))}
</div>
);
}
const VIEWS = [["split", "Side by side"], ["overlay", "Overlay"], ["cand", "Candidate"], ["ref", "Original"]];
function CompareStage({ a, store, dir, onNav }) {
const [view, setView] = useState("split");
const [op, setOp] = useState(0.5);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const drag = useRef(null);
const stageRef = useRef(null);
const [dx, setDx] = useState(0);
const [markMode, setMarkMode] = useState(null);
const [draftMark, setDraftMark] = useState(null);
const slide = useSlideIn(a.id, dir);
useEffect(() => { setZoom(1); setPan({ x: 0, y: 0 }); setDx(0); setMarkMode(null); setDraftMark(null); }, [a.id]);
const repl = store.replacements[a.id];
const pct = (e) => {
const r = stageRef.current.getBoundingClientRect();
return { x: Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100)), y: Math.max(0, Math.min(100, ((e.clientY - r.top) / r.height) * 100)) };
};
const rectFrom = (a, b) => ({ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) });
const down = (e) => {
if (markMode) {
const p = pct(e);
drag.current = { annotate: true, start: p };
setDraftMark({ shape: markMode, box: { x: p.x, y: p.y, w: 0, h: 0 }, view });
e.currentTarget.setPointerCapture(e.pointerId);
return;
}
drag.current = { x: e.clientX, y: e.clientY, pan: { ...pan }, moved: false }; e.currentTarget.setPointerCapture(e.pointerId);
};
const move = (e) => {
if (!drag.current) return;
if (drag.current.annotate) {
setDraftMark({ shape: markMode, box: rectFrom(drag.current.start, pct(e)), view });
return;
}
const ddx = e.clientX - drag.current.x, ddy = e.clientY - drag.current.y;
if (Math.abs(ddx) + Math.abs(ddy) > 4) drag.current.moved = true;
if (zoom > 1) setPan({ x: drag.current.pan.x + ddx, y: drag.current.pan.y + ddy });
else setDx(ddx);
};
const up = () => {
if (drag.current && drag.current.annotate) {
const mark = draftMark;
drag.current = null;
if (mark && mark.box.w > 2 && mark.box.h > 2) {
const note = window.prompt("Note for this marked area:");
if (note && note.trim()) {
store.addNote(a.id, { text: note.trim(), targets: ["Annotation"], annotation: mark });
store.setStatus(a.id, "revision");
}
}
setDraftMark(null);
return;
}
if (drag.current && zoom === 1) { if (dx < -90) onNav(1); else if (dx > 90) onNav(-1); }
drag.current = null;
if (zoom === 1) springLoop(dx, 0, setDx);
};
const inner = { transform: `scale(${zoom}) translate(${pan.x / zoom}px,${pan.y / zoom}px)` };
const annotations = (store.notes[a.id] || []).filter(n => n.annotation && n.annotation.view === view).map(n => n.annotation);
const marks = draftMark ? [...annotations, draftMark] : annotations;
return (
<div className="stage-wrap">
<div className="stage-tools">
<div className="seg">{VIEWS.map(([k, l]) => <button key={k} className={view === k ? "on" : ""} onClick={() => setView(k)}>{l}</button>)}</div>
<div className="seg annotate-seg">
<button className={markMode === "circle" ? "on" : ""} onClick={() => setMarkMode(markMode === "circle" ? null : "circle")}>Circle note</button>
<button className={markMode === "box" ? "on" : ""} onClick={() => setMarkMode(markMode === "box" ? null : "box")}>Box note</button>
</div>
<div className="zoom-ctl">
<span className="mono-lab">ZOOM</span>
{[1, 1.5, 2.5].map(z => <button key={z} className={zoom === z ? "on" : ""} onClick={() => { setZoom(z); if (z === 1) setPan({ x: 0, y: 0 }); }}>{z}×</button>)}
</div>
</div>
{markMode && <div className="annotate-hint">Drag on the asset to mark an area, then type the note.</div>}
<div ref={stageRef} className={"stage" + (markMode ? " annotating" : "")} onPointerDown={markMode ? undefined : down} onPointerMove={markMode ? undefined : move} onPointerUp={markMode ? undefined : up} onPointerCancel={markMode ? undefined : up} style={{ cursor: markMode ? "crosshair" : "grab", touchAction: markMode ? "none" : "pan-y" }}>
<div className="stage-slide" style={{ ...slide, transform: `translateX(${(parseFloat(slide.transform.match(/-?[\d.]+/)[0]) + dx * 0.9)}px)` }}>
{view === "split" && <div className="pair" style={inner}><Art a={a} kind="o" /><Art a={a} kind="c" repl={repl} /></div>}
{view === "overlay" && <div className="overlay-box" style={inner}><Art a={a} kind="o" /><div className="overlay-top" style={{ opacity: op }}><Art a={a} kind="c" repl={repl} /></div></div>}
{view === "cand" && <div className="solo" style={inner}><Art a={a} kind="c" repl={repl} /></div>}
{view === "ref" && <div className="solo" style={inner}><Art a={a} kind="o" /></div>}
</div>
<div className="annotation-layer">
{marks.map((m, i) => <span key={i} className={"annotation-mark " + m.shape} style={{ left: m.box.x + "%", top: m.box.y + "%", width: m.box.w + "%", height: m.box.h + "%" }} />)}
</div>
{markMode && <div className="annotation-capture" onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up} />}
</div>
{view === "overlay" && <div className="op-row"><span className="mono-lab">REF</span><input type="range" min="0" max="1" step="0.01" value={op} onChange={(e) => setOp(+e.target.value)} /><span className="mono-lab">CAND</span></div>}
{repl && <div className="repl-note">Replacement staged this session: <b>{repl.name}</b> — status reset to pending.</div>}
</div>
);
}
function MetaPanel({ a, store }) {
const g = CTM_DATA.groups.find(g => g.id === a.g);
const notes = store.notes[a.id] || [];
return (
<div className="meta">
<div className="meta-row"><span>Layer group</span><b>{g.name}</b></div>
<div className="meta-row"><span>Purpose</span><b>{a.purpose}</b></div>
<div className="meta-row"><span>Ownership</span><OwnChip o={a.own} /></div>
<div className="meta-row"><span>Confidence</span><ConfBar v={a.conf} /></div>
<div className="meta-row"><span>Status</span><StatusChip s={store.statuses[a.id]} /></div>
<div className="meta-row"><span>Notes</span><b>{notes.length}</b></div>
<div className="meta-row"><span>Asset id</span><b className="mono">{a.id.toUpperCase()}</b></div>
<div className="own-note">{OWN_NOTE[a.own]}</div>
{store.statuses[a.id] !== "approved" && store.statuses[a.id] !== "active" && <div className="lock-note">⌀ Not implementation-ready — needs a Hot mark.</div>}
</div>
);
}
function WorkPacketPanel({ packet, assetId }) {
if (!packet || packet.asset.id !== assetId) {
return (
<div className="work-feed muted">
<div className="mono-lab">CODEX BUILD FEED</div>
<p>No focused packet sent for this asset yet. Add notes or annotations, then use <b>Send notes to Codex</b>.</p>
</div>
);
}
const noteCount = (packet.asset.notes || []).length;
const annotationCount = (packet.asset.annotations || []).length;
return (
<div className="work-feed active">
<textarea data-af1-active-work-json="" readOnly value={JSON.stringify(packet, null, 2)} style={{ display: "none" }} />
<div className="work-feed-head">
<div>
<div className="mono-lab">CODEX BUILD FEED</div>
<h3>{packet.asset.name}</h3>
</div>
<span className="work-pill">Queued</span>
</div>
<div className="work-feed-grid">
<span><b>{noteCount}</b><small>notes</small></span>
<span><b>{annotationCount}</b><small>marks</small></span>
<span><b>{packet.asset.statusLabel}</b><small>status</small></span>
</div>
<ol>
<li className="done">Packet exposed at <code>window.AF1_ACTIVE_WORK_PACKET</code>.</li>
<li className="live">Codex reads this packet and builds/applies the candidate.</li>
<li>Replacement appears in the candidate pane for Hot / Not review.</li>
</ol>
</div>
);
}
function ReviewModal({ list, idx, setIdx, close, store, present, onForge }) {
const a = list[idx];
const dirRef = useRef(1);
const enter = useEnterSpring();
const nav = (d) => { if (idx + d < 0 || idx + d >= list.length) return; dirRef.current = d; setIdx(idx + d); };
useEffect(() => {
const kb = (e) => {
if (e.target.tagName === "TEXTAREA" || e.target.tagName === "INPUT") return;
if (e.key === "ArrowRight") nav(1); else if (e.key === "ArrowLeft") nav(-1);
else if (e.key === "Escape") close();
else if (e.key === "a") store.setStatus(a.id, "approved");
else if (e.key === "r") store.setStatus(a.id, "rejected");
else if (e.key === "n") store.setStatus(a.id, "revision");
else if (e.key === "p" && present) present();
};
window.addEventListener("keydown", kb); return () => window.removeEventListener("keydown", kb);
}, [idx, a.id]);
if (!a) return null;
return (
<div className="modal-veil" style={{ opacity: Math.min(1, enter * 1.4) }}>
<div className="modal" style={{ transform: `translateY(${(1 - enter) * 24}px)`, opacity: enter }}>
<div className="modal-head">
<div>
<div className="mono-lab">{CTM_DATA.groups.find(g => g.id === a.g).name.toUpperCase()} · {idx + 1} / {list.length}</div>
<h2>{a.name}</h2>
</div>
<div className="modal-head-r">
{present && <button className="ghost-btn" onClick={present}>▸ Hot or Not from here</button>}
<button className="blue-btn codex-btn" onClick={() => store.submitAssetWork(a.id)}>Send notes to Codex</button>
{onForge && <button className="blue-btn" onClick={() => onForge(a.id)}>Forge candidate</button>}
<button className="icon-btn" onClick={() => nav(-1)} disabled={idx === 0}>←</button>
<button className="icon-btn" onClick={() => nav(1)} disabled={idx === list.length - 1}>→</button>
<button className="icon-btn" onClick={close}>✕</button>
</div>
</div>
<div className="modal-body">
<div className="modal-main">
<CompareStage a={a} store={store} dir={dirRef.current} onNav={nav} />
<WorkPacketPanel packet={store.workPacket} assetId={a.id} />
<div className="modal-verbs">
<ActionBar a={a} store={store} size="lg" />
<button className="blue-btn codex-btn" onClick={() => store.submitAssetWork(a.id)}>Send notes to Codex</button>
{onForge && <button className="blue-btn" onClick={() => onForge(a.id)}>Forge candidate</button>}
<UploadReplace a={a} store={store} />
</div>
</div>
<div className="modal-side">
<div className="side-sec"><div className="mono-lab">METADATA</div><MetaPanel a={a} store={store} /></div>
<div className="side-sec">
<div className="mono-lab">NOTES ({(store.notes[a.id] || []).length})</div>
<NoteComposer a={a} store={store} />
<button className="blue-btn codex-btn full" onClick={() => store.submitAssetWork(a.id)}>Send notes to Codex</button>
<NoteList notes={store.notes[a.id] || []} />
</div>
</div>
</div>
<div className="modal-foot mono-lab">← → NAVIGATE · SWIPE ON TOUCH · A HOT · R NOT IT · N NEEDS WORK · ESC CLOSE</div>
</div>
</div>
);
}
function PresentMode({ list, idx, setIdx, close, store }) {
const a = list[idx];
const dirRef = useRef(1);
const slide = useSlideIn(a && a.id, dirRef.current);
const nav = (d) => { if (idx + d < 0 || idx + d >= list.length) return; dirRef.current = d; setIdx(idx + d); };
useEffect(() => {
const mark = (s) => { store.setStatus(a.id, s); setTimeout(() => nav(1), 180); };
const kb = (e) => {
if (e.key === "ArrowRight" || e.key === " ") nav(1); else if (e.key === "ArrowLeft") nav(-1);
else if (e.key === "Escape") close();
else if (e.key === "a") mark("approved");
else if (e.key === "r") mark("rejected");
else if (e.key === "n") mark("revision");
};
window.addEventListener("keydown", kb); return () => window.removeEventListener("keydown", kb);
}, [idx, a && a.id]);
const mark = (s) => { store.setStatus(a.id, s); setTimeout(() => nav(1), 180); };
const drag = useRef(null);
const [hint, setHint] = useState(null);
const down = (e) => { if (e.target.closest("button")) return; drag.current = { x: e.clientX, y: e.clientY }; };
const move = (e) => {
if (!drag.current) return;
const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y;
setHint(dy > 50 && dy > Math.abs(dx) ? "revision" : dx < -50 ? "rejected" : dx > 50 ? "approved" : null);
};
const up = (e) => {
if (drag.current == null) return;
const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y;
drag.current = null; setHint(null);
if (dy > 70 && dy > Math.abs(dx)) mark("revision");
else if (dx < -70) mark("rejected");
else if (dx > 70) mark("approved");
};
if (!a) return null;
const notes = store.notes[a.id] || [];
const last = notes[notes.length - 1];
const g = CTM_DATA.groups.find(g => g.id === a.g);
return (
<div className="present" onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={() => { drag.current = null; setHint(null); }}>
{hint && <div className="swipe-hint" style={{ color: STATUS[hint].c, borderColor: STATUS[hint].c }}>{STATUS[hint].label}</div>}
<div className="present-prog"><span style={{ width: ((idx + 1) / list.length * 100) + "%" }}></span></div>
<div className="present-top">
<span className="mono-lab">{g.name.toUpperCase()}</span>
<span className="mono-lab">{idx + 1} OF {list.length}</span>
<button className="icon-btn" onClick={close}>✕</button>
</div>
<div className="present-stage" style={slide}>
<div className="present-pair"><Art a={a} kind="o" /><Art a={a} kind="c" repl={store.replacements[a.id]} /></div>
<h1>{a.name}</h1>
<p className="present-purpose">{a.purpose}</p>
{last && <p className="present-note">“{last.text}”</p>}
</div>
<div className="present-foot">
<button className="icon-btn" onClick={() => nav(-1)} disabled={idx === 0}>←</button>
<div className="present-status"><StatusChip s={store.statuses[a.id]} /><ActionBar a={a} store={store} onSet={(s) => { if (s !== "pending") setTimeout(() => nav(1), 180); }} /></div>
<button className="icon-btn" onClick={() => nav(1)} disabled={idx === list.length - 1}>→</button>
</div>
</div>
);
}
Object.assign(window, { STATUS, OWN, springLoop, useEnterSpring, Art, StatusChip, OwnChip, ConfBar, ActionBar, UploadReplace, ReviewModal, PresentMode });
