docs: add visual design reference (TUI mockups)

This commit is contained in:
2026-06-01 23:23:29 +04:00
parent 91ededa4ca
commit 198cf95725
8 changed files with 2225 additions and 0 deletions
+317
View File
@@ -0,0 +1,317 @@
// app.jsx — Correx TUI shell: layout, live state, keybind engine, theme + tweaks.
const { useState, useEffect, useRef, useCallback } = React;
const D = window.CORREX;
const { applyTheme, THEMES, ACCENTS } = window.CORREX_THEMES;
const DIR_ORDER = ["boxed", "soft", "bare"];
const ACC_ORDER = ["cyan", "amber", "green", "magenta", "neutral"];
const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"direction": "boxed",
"accent": "cyan",
"fontSize": 13,
"motion": true,
"scanlines": false
}/*EDITMODE-END*/;
function Panel({ n, title, right, active, className, footer, bodyRef, children }) {
return (
<div className={"panel" + (active ? " active" : "") + (className ? " " + className : "")}>
<div className="panel-h">
{n && <span className="pn">{n}</span>}
<span className="pt">{title}</span>
{right && <span className="ph-r">{right}</span>}
</div>
<div className="panel-b" ref={bodyRef}>{children}</div>
{footer}
</div>
);
}
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const rootRef = useRef(null);
const evRef = useRef(null);
const [overlay, setOverlay] = useState(null); // 'cmd'|'events'|'context'|'graph'|'models'|'tools'|'approval'|null
const [focusPanel, setFocusPanel] = useState("conv"); // 'conv'|'events'
const [sessionState, setSessionState] = useState("awaiting_approval");
const [activeModel, setActiveModel] = useState(D.SESSION.model);
const [tools, setTools] = useState(D.TOOLS);
const [autoApprove, setAutoApprove] = useState(false);
const [approvalResolved, setApprovalResolved] = useState(false);
const [msgs, setMsgs] = useState(D.CONVERSATION);
const [events, setEvents] = useState(D.EVENTS);
const [stages, setStages] = useState(D.STAGES);
const [toast, setToast] = useState(null);
const [streamTarget, setStreamTarget] = useState(D.CONVERSATION.length - 1);
const [streamLen, setStreamLen] = useState(0);
const [frame, setFrame] = useState(0);
const [draft, setDraft] = useState("");
const [promptFocus, setPromptFocus] = useState(false);
const promptRef = useRef(null);
const convRef = useRef(null);
// theme application
useEffect(() => { if (rootRef.current) applyTheme(rootRef.current, t.direction, t.accent); }, [t.direction, t.accent]);
// spinner / clock frames
useEffect(() => {
if (!t.motion) return;
const id = setInterval(() => setFrame((f) => f + 1), 110);
return () => clearInterval(id);
}, [t.motion]);
// streaming reveal
useEffect(() => {
if (streamTarget == null) return;
const full = (msgs[streamTarget] || {}).text || "";
if (streamLen >= full.length) return;
if (!t.motion) { setStreamLen(full.length); return; }
const id = setTimeout(() => setStreamLen((n) => Math.min(n + 3, full.length)), 16);
return () => clearTimeout(id);
}, [streamTarget, streamLen, msgs, t.motion]);
// autoscroll event panel
useEffect(() => { if (evRef.current) evRef.current.scrollTop = evRef.current.scrollHeight; }, [events]);
// autoscroll conversation
useEffect(() => { if (convRef.current) convRef.current.scrollTop = convRef.current.scrollHeight; }, [msgs, streamLen]);
const flash = (m) => { setToast(m); setTimeout(() => setToast(null), 2600); };
const pushEvent = (e) => setEvents((arr) => [...arr, { id: "x" + arr.length + Math.random().toString(36).slice(2, 5), ...e }]);
const addMsg = (m) => { setMsgs((arr) => { const ni = arr.length; setStreamTarget(ni); setStreamLen(0); return [...arr, m]; }); };
const appendMsg = (m) => setMsgs((arr) => [...arr, m]);
// user steering input → routes back through Correx
const hm = () => new Date().toTimeString().slice(0, 5);
const replyFor = (q) => {
const s = q.toLowerCase();
if (/test|spec|cover/.test(s)) return "Queued — I'll add SegmentStoreTest with an append→read round-trip and a dedup assertion (identical bytes ⇒ one object). Runs at the validation gate.";
if (/perf|bench|fast|speed|throughput/.test(s)) return "Noted. I'll add a JMH harness for append throughput; the mmap read path stays zero-copy so reads aren't affected by compression.";
if (/revert|undo|stop|cancel|rollback/.test(s)) return "Holding edits. Nothing destructive ran — the gated gradle resolve was the only external op. Awaiting your next instruction.";
if (/compress|zstd|gzip|lz4|codec/.test(s)) return "Compression is pluggable behind a SegmentCodec — zstd for cold, raw for hot. I can swap lz4 for lower CPU at a worse ratio; say the word.";
if (/index|offset|seek|lookup/.test(s)) return "SegmentIndex maps logical offset ranges → CAS keys with a binary search over segment bounds; O(log n) seeks. I'll memo the hot range.";
return "Understood — folding that into the implementation plan. I'll surface a revised ImplementationArtifact at the validation gate for review.";
};
const send = () => {
const text = draft.trim(); if (!text) return;
setDraft("");
setStreamTarget(null);
appendMsg({ role: "user", t: hm(), text });
const at = (ms, fn) => seqRef.current.push(setTimeout(fn, ms));
at(320, () => pushEvent({ t: hm() + ":02", cat: "Context", type: "steer.captured", text: "router → agent context (L1)" }));
at(760, () => addMsg({ role: "kernel", kind: "route", text: "Steering synthesized → injected at L1 · agent context updated" }));
at(1500, () => addMsg({ role: "assistant", t: hm(), text: replyFor(text) }));
};
// resolve approval → drive the live sequence
const seqRef = useRef([]);
const clearSeq = () => { seqRef.current.forEach(clearTimeout); seqRef.current = []; };
useEffect(() => () => clearSeq(), []);
const resolveApproval = useCallback((kind, note) => {
setOverlay(null);
setApprovalResolved(true);
setSessionState("active");
setEvents((arr) => arr.map((e) => e.pending ? { ...e, pending: false } : e));
const at = (ms, fn) => seqRef.current.push(setTimeout(fn, ms));
if (kind === "reject") {
flash("Rejected — agent re-plans without the network tool");
pushEvent({ t: "14:12:31", cat: "Approval", type: "approval.rejected", text: "operator rejected T3 gate" });
at(700, () => pushEvent({ t: "14:12:33", cat: "Inference", type: "infer.start", text: "re-planning · no-network constraint" }));
at(1600, () => addMsg({ role: "assistant", t: "14:12", text: "Understood — dropping zstd. I'll compress cold segments with the JDK Deflater instead: no new dependency, no network. Slightly worse ratio, zero supply-chain surface." }));
at(3400, () => pushEvent({ t: "14:12:48", cat: "Domain", type: "artifact.emitted", text: "ImplementationArtifact ✓ (deflater path)" }));
at(4200, () => transition());
return;
}
// approve / auto / steer
const auto = kind === "auto";
if (auto) setAutoApprove(true);
flash(kind === "steer" ? "Steered + approved — note injected into agent context" : auto ? "Auto-approve enabled for session" : "Approved");
pushEvent({ t: "14:12:14", cat: "Approval", type: kind === "steer" ? "approval.steered" : "approval.approved", text: note ? "“" + note + "”" : "operator approved" + (auto ? " · auto-session ON" : "") });
at(500, () => pushEvent({ t: "14:12:15", cat: "Tool", type: "shell.exec", text: "gradle resolving zstd-jni …", running: true }));
at(2100, () => setEvents((arr) => arr.map((e) => e.running ? { id: e.id, t: "14:12:19", cat: "Tool", type: "shell.exec", text: "gradle resolve OK (4.1s) · +1 dep" } : e)));
at(2600, () => addMsg({ role: "assistant", t: "14:12", text: (note ? "Locking the version as asked. " : "") + "Resolved zstd-jni 1.5.6-3. Cold segments now zstd-compress on write; hot segments stay raw for mmap. Emitting the implementation artifact." }));
at(4600, () => pushEvent({ t: "14:12:34", cat: "Domain", type: "artifact.emitted", text: "ImplementationArtifact ✓ valid" }));
at(5400, () => transition());
}, []);
const transition = () => {
pushEvent({ t: "14:12:36", cat: "Lifecycle", type: "stage.transition", text: "implementation → validation" });
setStages((arr) => arr.map((s) => s.id === "implementation" ? { ...s, status: "done", note: "artifact accepted" } : s.id === "validation" ? { ...s, status: "active", enter: "14:12", note: "schema + semantic checks" } : s));
pushEvent({ t: "14:12:36", cat: "Lifecycle", type: "stage.enter", text: "validation (role=reviewer)" });
};
// auto-open the approval gate once (modal interrupt demonstration)
const raised = useRef(false);
useEffect(() => {
if (raised.current) return; raised.current = true;
const id = setTimeout(() => { if (!approvalResolved) setOverlay("approval"); }, 1100);
return () => clearTimeout(id);
}, []);
// ── keybind engine ──
const cycle = (key, order) => setTweak(key, order[(order.indexOf(t[key]) + 1) % order.length]);
const setDirection = (dir) => setTweak({ direction: dir, accent: THEMES[dir].defaultAccent });
const cycleDir = () => setDirection(DIR_ORDER[(DIR_ORDER.indexOf(t.direction) + 1) % DIR_ORDER.length]);
const open = (id) => {
if (id === "appearance") { cycleDir(); return; }
if (id === "approval") { if (approvalResolved) { flash("No pending approvals"); return; } setOverlay("approval"); return; }
setOverlay(id);
};
useEffect(() => {
const onKey = (e) => {
const typing = /^(INPUT|TEXTAREA)$/.test(document.activeElement && document.activeElement.tagName);
// approval modal chords
if (overlay === "approval") {
if (typing) return;
if (e.key === "a") { resolveApproval("approve"); e.preventDefault(); }
else if (e.key === "A") { resolveApproval("auto"); e.preventDefault(); }
else if (e.key === "r") { resolveApproval("reject"); e.preventDefault(); }
else if (e.key === "Escape") setOverlay(null);
return;
}
if (e.key === "Escape") { if (overlay) { setOverlay(null); e.preventDefault(); } return; }
if (typing) return;
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { setOverlay("cmd"); e.preventDefault(); return; }
if (overlay === "cmd") return; // palette owns keys
if (overlay) return; // other overlays: only esc (above)
switch (e.key) {
case "p": case ":": case "/": setOverlay("cmd"); e.preventDefault(); break;
case "e": open("events"); break;
case "c": open("context"); break;
case "g": open("graph"); break;
case "m": open("models"); break;
case "t": open("tools"); break;
case "i": promptRef.current && promptRef.current.focus(); e.preventDefault(); break;
case "a": open("approval"); break;
case "F2": cycleDir(); e.preventDefault(); break;
case "F3": cycle("accent", ACC_ORDER); e.preventDefault(); break;
case "Tab": setFocusPanel((p) => p === "conv" ? "events" : "conv"); e.preventDefault(); break;
default: break;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [overlay, approvalResolved, t.direction, t.accent, resolveApproval]);
// ── footer hints (contextual) ──
const hints = (() => {
if (overlay === "approval") return [["a", "approve"], ["A", "auto-session"], ["s", "steer"], ["r", "reject"], ["esc", "later"]];
if (overlay === "cmd") return [["↑↓", "move"], ["↵", "run"], ["esc", "close"]];
if (overlay) return [["↑↓", "navigate"], ["↵", "select"], ["esc", "back"]];
if (promptFocus) return [["↵", "send to correx"], ["esc", "blur"]];
return [["i", "message"], ["p", "commands"], ["a", "approval"], ["e", "events"], ["c", "context"], ["g", "graph"], ["m", "models"], ["t", "tools"], ["F2", "skin"]];
})();
const tokPct = Math.min(100, Math.round((D.CONTEXT_PACK.used / D.CONTEXT_PACK.budget) * 100) + (approvalResolved ? 4 : 0));
const spin = SPIN[frame % SPIN.length];
return (
<div className="crx" ref={rootRef} style={{ fontSize: t.fontSize, "--scan": t.scanlines ? 1 : 0 }}>
{t.scanlines && <div style={{ position: "absolute", inset: 0, zIndex: 30, pointerEvents: "none", background: "repeating-linear-gradient(0deg, rgb(0 0 0 / 0.16) 0 1px, transparent 1px 3px)" }} />}
{/* top status line */}
<div className="crx-top">
<span className="crx-brand">corre<b>x</b></span>
<span className="dot">·</span>
<span className="seg">session <b>{D.SESSION.name}</b></span>
<span className="dot">·</span>
<span className="seg"><b>{D.SESSION.branch}</b></span>
<span className="dot">·</span>
<span className="seg">{activeModel} <span style={{ color: "var(--faint)" }}>({D.MODELS.find((m) => m.id === activeModel).provider})</span></span>
{autoApprove && <span className="pill live">auto-approve</span>}
<span className="crx-spacer" />
<span className="crx-budget">ctx <div className="crx-meter"><i style={{ width: tokPct + "%" }} /></div> {tokPct}%</span>
<span className={"crx-state"} style={sessionState === "active" ? { color: "var(--ok)", borderColor: "color-mix(in oklab,var(--ok),transparent 60%)", background: "color-mix(in oklab,var(--ok),transparent 88%)" } : null}>
{sessionState === "awaiting_approval" ? "⏸ awaiting approval" : "▸ active"}
</span>
</div>
{/* main split */}
<div className="crx-main">
<Panel n="[1]" title="router · conversation" right={D.SESSION.project} active={focusPanel === "conv"} bodyRef={convRef}
footer={
<div className={"prompt" + (promptFocus ? " foc" : "")} onClick={() => promptRef.current && promptRef.current.focus()}>
<span className="pr">{promptFocus ? "" : ""}</span>
<input ref={promptRef} value={draft} placeholder="message correx — steer the run…"
onFocus={() => setPromptFocus(true)} onBlur={() => setPromptFocus(false)}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") send(); else if (e.key === "Escape") promptRef.current.blur(); }} />
{!promptFocus && !draft && <span className="ph"><kbd>i</kbd> focus · <kbd></kbd> send</span>}
</div>
}>
{msgs.map((m, i) => {
const streaming = i === streamTarget;
const text = streaming ? m.text.slice(0, streamLen) : m.text;
const who = m.role === "user" ? "you" : m.role === "kernel" ? "correx" : "agent";
return (
<div className={"msg " + m.role} key={i}>
<span className="msg-gut"><span className="who">{who}</span>{m.t}</span>
<span className="msg-body">{text}{streaming && streamLen < m.text.length && <span className="cursor" />}{streaming && streamLen >= m.text.length && sessionState === "awaiting_approval" && <span className="cursor" />}</span>
</div>
);
})}
</Panel>
<Panel n="[2]" title="event stream" active={focusPanel === "events"}
right={sessionState === "awaiting_approval" ? <span style={{ color: "var(--warn)" }}>{spin} paused</span> : <span style={{ color: "var(--ok)" }}>{spin} live</span>}>
<div ref={evRef} style={{ height: "100%", overflow: "auto" }}>
{events.map((e) => (
<div className={"ev" + (e.pending ? " pending" : "")} key={e.id} onClick={() => setOverlay("events")}>
<span className="ev-t">{e.t}</span>
<span className={"ev-cat cat-" + e.cat}>{e.cat}</span>
<span className="ev-tx">{e.running && <span className="spin">{spin} </span>}<b>{e.type}</b> {e.text}{e.pending && <span className="kbd-inline" style={{ marginLeft: 6 }}>a</span>}</span>
</div>
))}
</div>
</Panel>
</div>
{/* footer keybind bar */}
<div className="crx-foot">
{hints.map(([k, l], i) => (
<span className="kb" key={i} onClick={() => {
const map = { p: () => setOverlay("cmd"), a: () => open("approval"), e: () => open("events"), c: () => open("context"), g: () => open("graph"), m: () => open("models"), t: () => open("tools"), i: () => promptRef.current && promptRef.current.focus(), F2: () => cycleDir() };
map[k] && map[k]();
}}>
<kbd>{k}</kbd><b>{l}</b>
</span>
))}
<span className="foot-mode">{THEMES[t.direction].label} · <b>{t.accent}</b></span>
</div>
{toast && <div style={{ position: "absolute", bottom: 46, left: "50%", transform: "translateX(-50%)", zIndex: 50, background: "var(--panel-2)", border: "1px solid var(--accent)", color: "var(--fg-strong)", padding: "8px 16px", borderRadius: 8, fontSize: 12.5, boxShadow: "0 14px 40px -16px #000" }}>{toast}</div>}
{/* overlays */}
{overlay === "cmd" && <CommandPalette commands={D.COMMANDS} onRun={(id) => { setOverlay(null); setTimeout(() => open(id), 0); }} onClose={() => setOverlay(null)} />}
{overlay === "approval" && <ApprovalModal data={D.APPROVAL} onApprove={(auto) => resolveApproval(auto ? "auto" : "approve")} onReject={() => resolveApproval("reject")} onSteer={(n) => resolveApproval("steer", n)} onClose={() => setOverlay(null)} />}
{overlay === "events" && <EventInspector events={events} onClose={() => setOverlay(null)} />}
{overlay === "context" && <ContextPackInspector pack={D.CONTEXT_PACK} onClose={() => setOverlay(null)} />}
{overlay === "graph" && <StageGraph stages={stages} transitions={D.TRANSITIONS} onClose={() => setOverlay(null)} />}
{overlay === "models" && <ModelRegistry models={D.MODELS} active={activeModel} onSwitch={(id) => { setActiveModel(id); setOverlay(null); flash("Switched → " + id + " · re-synthesizing Context Pack"); pushEvent({ t: "14:12", cat: "Context", type: "pack.resynth", text: "for " + id }); }} onClose={() => setOverlay(null)} />}
{overlay === "tools" && <ToolPalette tools={tools} onToggle={(id) => setTools((arr) => arr.map((x) => x.id === id ? { ...x, enabled: !x.enabled } : x))} onClose={() => setOverlay(null)} />}
{/* tweaks */}
<TweaksPanel>
<TweakSection label="Aesthetic direction" />
<TweakRadio label="Chrome" value={t.direction} options={["boxed", "soft", "bare"]} onChange={(v) => setDirection(v)} />
<TweakColor label="Accent" value={t.accent === "cyan" ? "#56c8d8" : t.accent === "amber" ? "#e8b765" : t.accent === "green" ? "#8fd96a" : t.accent === "magenta" ? "#c98fd9" : "#b9c0c9"}
options={["#56c8d8", "#e8b765", "#8fd96a", "#c98fd9", "#b9c0c9"]}
onChange={(hex) => { const map = { "#56c8d8": "cyan", "#e8b765": "amber", "#8fd96a": "green", "#c98fd9": "magenta", "#b9c0c9": "neutral" }; setTweak("accent", map[hex] || "cyan"); }} />
<TweakSection label="Display" />
<TweakSlider label="Font size" value={t.fontSize} min={11} max={17} step={1} unit="px" onChange={(v) => setTweak("fontSize", v)} />
<TweakToggle label="Live motion" value={t.motion} onChange={(v) => setTweak("motion", v)} />
<TweakToggle label="CRT scanlines" value={t.scanlines} onChange={(v) => setTweak("scanlines", v)} />
</TweaksPanel>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
+239
View File
@@ -0,0 +1,239 @@
/* correx.css — Correx TUI. Chrome modes driven by [data-chrome] on the root. */
* { box-sizing: border-box; margin: 0; padding: 0; }
.crx {
position: absolute; inset: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: 13px;
line-height: var(--line);
font-variant-ligatures: none;
-webkit-font-smoothing: antialiased;
display: grid;
grid-template-rows: auto 1fr auto;
overflow: hidden;
user-select: none;
}
.crx ::selection { background: var(--accent); color: var(--bg-deep); }
/* faint global texture for warmth (subtle, not slop) */
.crx::after {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(120% 90% at 50% -10%, rgb(var(--glow) / 0.05), transparent 60%);
z-index: 0;
}
/* ───────────────────────── top status line ───────────────────────── */
.crx-top {
position: relative; z-index: 2;
display: flex; align-items: center; gap: 16px;
padding: 8px 14px;
background: var(--bg-deep);
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.crx[data-chrome="soft"] .crx-top { background: var(--panel); border-bottom: none; }
.crx[data-chrome="bare"] .crx-top { background: transparent; border-bottom: 1px solid var(--border); }
.crx-brand { color: var(--accent); font-weight: 700; letter-spacing: 0.06em; }
.crx-brand b { color: var(--fg-strong); }
.crx-top .dot { color: var(--faint); }
.crx-top .seg { color: var(--dim); white-space: nowrap; }
.crx-top .seg b { color: var(--fg); font-weight: 500; }
.crx-spacer { flex: 1; }
.crx-state {
padding: 1px 8px; border-radius: 999px; font-size: 11px; letter-spacing: 0.04em;
color: var(--warn); border: 1px solid color-mix(in oklab, var(--warn), transparent 60%);
background: color-mix(in oklab, var(--warn), transparent 88%);
}
.crx-budget { display: flex; align-items: center; gap: 7px; color: var(--dim); }
.crx-meter { width: 84px; height: 6px; border-radius: 3px; background: var(--faint); overflow: hidden; }
.crx-meter > i { display: block; height: 100%; background: var(--accent); }
/* ───────────────────────── main grid ───────────────────────── */
.crx-main {
position: relative; z-index: 1;
display: grid; grid-template-columns: 1.7fr 1fr;
gap: var(--gap); padding: var(--gap);
min-height: 0; overflow: hidden;
}
/* ───────────────────────── panel chrome ───────────────────────── */
.panel {
position: relative; display: flex; flex-direction: column; min-height: 0;
background: var(--panel); border-radius: var(--radius);
}
.panel-h {
display: flex; align-items: center; gap: 8px;
padding: 5px 11px; flex: 0 0 auto;
color: var(--dim); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
}
.panel-h .pn { color: var(--faint); }
.panel-h .pt { color: var(--fg); letter-spacing: 0.08em; }
.panel-h .ph-r { margin-left: auto; text-transform: none; letter-spacing: 0; color: var(--faint); }
.panel-b { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 4px 11px 10px; }
.panel-b::-webkit-scrollbar { width: 7px; }
.panel-b::-webkit-scrollbar-thumb { background: var(--faint); border-radius: 4px; }
.panel-b::-webkit-scrollbar-track { background: transparent; }
/* boxed: full border, active = accent border, lazygit label tab */
.crx[data-chrome="boxed"] .panel { border: 1px solid var(--border); }
.crx[data-chrome="boxed"] .panel.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent), transparent 70%); }
.crx[data-chrome="boxed"] .panel-h { border-bottom: 1px solid var(--border); background: var(--bg-deep); }
.crx[data-chrome="boxed"] .panel.active .panel-h { color: var(--accent); }
/* soft: rounded, padded, lighter panel, no hard header rule */
.crx[data-chrome="soft"] .panel { border: 1px solid var(--border); padding: var(--panel-pad); }
.crx[data-chrome="soft"] .panel.active { border-color: color-mix(in oklab, var(--accent), transparent 35%); box-shadow: 0 0 0 1px color-mix(in oklab,var(--accent),transparent 55%), 0 6px 28px -18px rgb(var(--glow)/0.6); }
.crx[data-chrome="soft"] .panel-h { padding: 8px 12px 4px; }
.crx[data-chrome="soft"] .panel.active .panel-h .pt { color: var(--accent); }
.crx[data-chrome="soft"] .panel-b { padding: 2px 12px 12px; }
/* bare: no box; just a header with a thin bottom rule */
.crx[data-chrome="bare"] .panel { background: transparent; }
.crx[data-chrome="bare"] .panel-h { border-bottom: 1px solid var(--border); padding-left: 2px; padding-right: 2px; }
.crx[data-chrome="bare"] .panel.active .panel-h { color: var(--accent); }
.crx[data-chrome="bare"] .panel.active .panel-h::before { content: "▌"; color: var(--accent); margin-right: -3px; }
.crx[data-chrome="bare"] .panel-b { padding-left: 2px; padding-right: 6px; }
/* ───────────────────────── conversation ───────────────────────── */
.msg { padding: 7px 0; display: grid; grid-template-columns: 64px 1fr; gap: 10px; align-items: start; }
.msg + .msg { border-top: 1px solid color-mix(in oklab, var(--border), transparent 30%); }
.crx[data-chrome="bare"] .msg + .msg { border-top: none; }
.msg-gut { font-size: 11px; color: var(--faint); text-align: right; padding-top: 1px; }
.msg-gut .who { display: block; font-weight: 600; letter-spacing: 0.02em; }
.msg.user .who { color: var(--fg); }
.msg.assistant .who { color: var(--accent); }
.msg.kernel .who { color: var(--accent-2); }
.msg-body { color: var(--fg); text-wrap: pretty; }
.msg.kernel .msg-body { color: var(--dim); font-size: 12px; }
.msg.kernel .msg-body::before { content: "» "; color: var(--accent-2); }
.msg .cursor { display: inline-block; width: 7px; height: 1.05em; vertical-align: text-bottom; background: var(--accent); margin-left: 2px; animation: blink 1.05s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
/* ───────────────────────── event stream ───────────────────────── */
.ev { display: grid; grid-template-columns: auto auto 1fr; gap: 8px; align-items: baseline; padding: 2.5px 0; font-size: 12px; cursor: pointer; border-radius: 4px; }
.ev:hover { background: var(--sel); }
.ev.sel { background: var(--sel); box-shadow: inset 2px 0 0 var(--accent); }
.ev-t { color: var(--faint); font-size: 11px; }
.ev-cat { font-size: 10px; letter-spacing: 0.03em; padding: 0 5px; border-radius: 3px; white-space: nowrap; }
.ev-tx { color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ev-tx b { color: var(--dim); font-weight: 400; }
.ev.pending .ev-tx { color: var(--warn); }
.cat-Lifecycle { color: var(--accent-2); background: color-mix(in oklab,var(--accent-2),transparent 86%); }
.cat-Context { color: #c98fd9; background: color-mix(in oklab,#c98fd9,transparent 86%); }
.cat-Inference { color: var(--accent); background: color-mix(in oklab,var(--accent),transparent 86%); }
.cat-Tool { color: var(--ok); background: color-mix(in oklab,var(--ok),transparent 86%); }
.cat-Domain { color: #e8b765; background: color-mix(in oklab,#e8b765,transparent 86%); }
.cat-Approval { color: var(--warn); background: color-mix(in oklab,var(--warn),transparent 84%); }
.spin { display: inline-block; color: var(--accent); }
/* ───────────────────────── footer keybind bar ───────────────────────── */
.crx-foot {
position: relative; z-index: 2;
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
padding: 6px 12px; background: var(--bg-deep);
border-top: 1px solid var(--border); font-size: 12px;
}
.crx[data-chrome="soft"] .crx-foot { background: var(--panel); border-top: none; }
.kb { display: inline-flex; align-items: baseline; gap: 5px; padding: 1px 8px; border-radius: 5px; color: var(--dim); }
.kb:hover { background: var(--sel); color: var(--fg); }
.kb kbd {
font: inherit; font-size: 11px; color: var(--bg-deep); background: var(--accent);
padding: 0 5px; border-radius: 3px; font-weight: 600; min-width: 14px; text-align: center;
}
.crx[data-chrome="bare"] .kb kbd { background: transparent; color: var(--accent); padding: 0; }
.crx[data-chrome="bare"] .kb kbd::before { content: "["; } .crx[data-chrome="bare"] .kb kbd::after { content: "]"; }
.kb b { color: var(--fg); font-weight: 400; }
.foot-mode { margin-left: auto; color: var(--faint); letter-spacing: 0.08em; }
.foot-mode b { color: var(--accent); }
/* ───────────────────────── overlays ───────────────────────── */
.scrim { position: absolute; inset: 0; z-index: 40; background: rgb(0 0 0 / 0.55); backdrop-filter: blur(1.5px); display: flex; align-items: flex-start; justify-content: center; padding: 7vh 16px 16px; }
.scrim.center { align-items: center; padding-top: 16px; }
.ovl {
position: relative; width: min(760px, 94%); max-height: 82vh; display: flex; flex-direction: column;
background: var(--panel); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 24px 80px -28px #000, 0 0 0 1px color-mix(in oklab,var(--accent),transparent 80%);
animation: pop 0.14s cubic-bezier(.2,.9,.3,1);
}
.crx[data-chrome="soft"] .ovl { border-radius: 14px; }
@keyframes pop { from { transform: translateY(-8px) scale(0.99); } }
.ovl-h { display: flex; align-items: center; gap: 10px; padding: 11px 15px; border-bottom: 1px solid var(--border); }
.ovl-h .tag { color: var(--accent); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; font-size: 11px; }
.ovl-h .ti { color: var(--fg-strong); }
.ovl-h .esc { margin-left: auto; color: var(--faint); font-size: 11px; }
.ovl-b { padding: 13px 15px; overflow: auto; }
.ovl-b::-webkit-scrollbar { width: 8px; } .ovl-b::-webkit-scrollbar-thumb { background: var(--faint); border-radius: 4px; }
/* command palette */
.cmd-input { display: flex; align-items: center; gap: 9px; padding: 12px 15px; border-bottom: 1px solid var(--border); }
.cmd-input .pr { color: var(--accent); font-weight: 700; }
.cmd-input input { flex: 1; background: transparent; border: none; outline: none; color: var(--fg-strong); font: inherit; font-size: 14px; }
.cmd-input input::placeholder { color: var(--faint); }
.cmd-list { padding: 6px; overflow: auto; max-height: 50vh; }
.cmd-it { display: flex; align-items: center; gap: 11px; padding: 8px 11px; border-radius: 7px; cursor: pointer; }
.cmd-it.sel { background: var(--sel); }
.cmd-it.sel .ci-l { color: var(--fg-strong); }
.crx[data-chrome="boxed"] .cmd-it { border-radius: 0; } .crx[data-chrome="boxed"] .cmd-it.sel { box-shadow: inset 2px 0 0 var(--accent); }
.ci-k { font-size: 11px; color: var(--bg-deep); background: var(--accent); padding: 1px 6px; border-radius: 3px; min-width: 22px; text-align: center; font-weight: 600; }
.ci-l { flex: 1; color: var(--fg); }
.ci-h { color: var(--dim); font-size: 11px; }
/* approval modal */
.appr { width: min(620px, 94%); }
.appr-tier { display: flex; align-items: center; gap: 12px; padding: 13px 16px; background: color-mix(in oklab,var(--warn),transparent 90%); border-bottom: 1px solid color-mix(in oklab,var(--warn),transparent 70%); }
.appr-badge { font-size: 22px; font-weight: 800; color: var(--warn); letter-spacing: 0.04em; }
.appr-tier .lab { color: var(--fg-strong); } .appr-tier .lab b { color: var(--warn); }
.appr-tier .sub { color: var(--dim); font-size: 12px; }
.appr-cmd { margin: 13px 0; padding: 11px 13px; background: var(--bg-deep); border-radius: 6px; border: 1px solid var(--border); color: var(--fg-strong); font-size: 13px; white-space: pre-wrap; word-break: break-all; }
.appr-cmd .pr { color: var(--accent); }
.kv { display: grid; grid-template-columns: 96px 1fr; gap: 4px 12px; font-size: 12.5px; margin: 9px 0; }
.kv dt { color: var(--dim); } .kv dd { color: var(--fg); }
.risk { color: var(--warn); font-size: 12.5px; padding: 1.5px 0; }
.risk::before { content: "▲ "; font-size: 10px; }
.steer { margin-top: 12px; display: flex; gap: 8px; align-items: center; }
.steer input { flex: 1; background: var(--bg-deep); border: 1px solid var(--border); border-radius: 6px; padding: 8px 11px; color: var(--fg-strong); font: inherit; font-size: 12.5px; outline: none; }
.steer input:focus { border-color: var(--accent); }
.appr-acts { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); flex-wrap: wrap; }
.btn { font: inherit; font-size: 12.5px; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel-2); color: var(--fg); cursor: pointer; display: inline-flex; gap: 7px; align-items: center; }
.btn:hover { border-color: var(--accent); color: var(--fg-strong); }
.btn kbd { font: inherit; font-size: 10px; color: var(--dim); }
.btn.ok { background: color-mix(in oklab,var(--ok),transparent 84%); border-color: color-mix(in oklab,var(--ok),transparent 55%); color: var(--ok); }
.btn.ok:hover { background: color-mix(in oklab,var(--ok),transparent 72%); }
.btn.bad { color: var(--bad); border-color: color-mix(in oklab,var(--bad),transparent 60%); }
.btn.ghost { background: transparent; }
.btn .sp { margin-left: auto; }
/* generic rows used in inspectors */
.row { display: grid; gap: 10px; align-items: baseline; padding: 6px 0; border-bottom: 1px solid color-mix(in oklab,var(--border),transparent 40%); }
.row:last-child { border-bottom: none; }
.mono-bar { height: 7px; border-radius: 3px; background: var(--faint); overflow: hidden; }
.mono-bar > i { display: block; height: 100%; background: var(--accent); }
.tag-tier { font-size: 10px; padding: 0 5px; border-radius: 3px; font-weight: 600; }
.tier-T0,.tier-T1 { color: var(--ok); background: color-mix(in oklab,var(--ok),transparent 85%); }
.tier-T2 { color: var(--accent-2); background: color-mix(in oklab,var(--accent-2),transparent 85%); }
.tier-T3 { color: var(--warn); background: color-mix(in oklab,var(--warn),transparent 84%); }
.tier-T4 { color: var(--bad); background: color-mix(in oklab,var(--bad),transparent 84%); }
.pill { font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--dim); }
.pill.on { color: var(--ok); border-color: color-mix(in oklab,var(--ok),transparent 55%); }
.pill.off { color: var(--faint); }
.pill.live { color: var(--accent); border-color: color-mix(in oklab,var(--accent),transparent 50%); }
/* ───────────────────────── router prompt bar ───────────────────────── */
.prompt { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; padding: 8px 11px; border-top: 1px solid var(--border); }
.crx[data-chrome="bare"] .prompt { padding-left: 2px; padding-right: 6px; }
.crx[data-chrome="soft"] .prompt { margin: 2px; border-top: 1px solid var(--border); }
.prompt .pr { color: var(--accent); font-weight: 700; }
.prompt.foc { box-shadow: inset 0 1px 0 var(--accent); }
.crx[data-chrome="soft"] .prompt.foc { box-shadow: inset 0 0 0 1px color-mix(in oklab,var(--accent),transparent 55%); border-radius: 8px; border-top-color: transparent; }
.prompt input { flex: 1; background: transparent; border: none; outline: none; color: var(--fg-strong); font: inherit; font-size: 13px; }
.prompt input::placeholder { color: var(--faint); }
.prompt .ph { color: var(--faint); font-size: 11px; white-space: nowrap; }
.prompt .ph kbd { font: inherit; font-size: 10px; color: var(--accent); }
.hint-empty { color: var(--faint); font-size: 12px; padding: 14px 4px; text-align: center; }
.kbd-inline { font-size: 11px; color: var(--bg-deep); background: var(--accent); padding: 0 5px; border-radius: 3px; font-weight: 600; }
.crx[data-chrome="bare"] .kbd-inline { background: transparent; color: var(--accent); padding: 0; }
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>correx — agent harness TUI</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="correx.css" />
<style>
html, body { height: 100%; margin: 0; background: #070a0e; }
#root { position: fixed; inset: 0; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="themes.jsx"></script>
<script type="text/babel" src="overlays.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>
+114
View File
@@ -0,0 +1,114 @@
// data.jsx — Correx scenario fixture
// Session: implementing CAS-based storage for segments (Kafka-style) of domain data.
// The kernel ("Correx") is the orchestration harness; the model is a stateless worker.
const SESSION = {
id: "01J9-CAS-SEG-7F2A",
name: "cas-segment-storage",
project: "correx/segment-store",
branch: "feat/cas-segments",
state: "awaiting_approval",
started: "14:02:11",
model: "qwen_coder_14b",
provider: "local",
};
// Stage / transition graph. current = implementation.
const STAGES = [
{ id: "intake", role: "router", status: "done", enter: "14:02", note: "parsed request → plan" },
{ id: "planning", role: "architect", status: "done", enter: "14:04", note: "segment layout + CAS index" },
{ id: "implementation", role: "coder", status: "active", enter: "14:11", note: "writing SegmentStore.kt" },
{ id: "validation", role: "reviewer", status: "pending", enter: "—", note: "schema + semantic checks" },
{ id: "integration", role: "coder", status: "pending", enter: "—", note: "wire into DomainLog" },
{ id: "done", role: "—", status: "pending", enter: "—", note: "" },
];
const TRANSITIONS = [
{ when: 'artifact.status == "success"', goto: "validation" },
{ when: "retries > 3", goto: "failed" },
{ when: 'semantic.contradiction', goto: "retry" },
];
// Router conversation. The last assistant turn is mid-stream.
const CONVERSATION = [
{ role: "user", t: "14:02", text: "Implement CAS-based storage for segments, Kafka-style. Domain data lands as an append-only log; segments should be content-addressed so dedup is free." },
{ role: "kernel", t: "14:02", kind: "route", text: "Routed → planning (role: architect). Synthesized Context Pack [4 sources, 6.1k tok]." },
{ role: "assistant", t: "14:05", text: "Plan: each segment is an immutable, size-bounded run of records keyed by SHA-256 of its bytes. A SegmentIndex maps logical offset ranges → CAS keys. Compaction rewrites overlapping segments; identical segments collapse to one object. Writing SegmentStore.kt, CasKey.kt, SegmentIndex.kt." },
{ role: "kernel", t: "14:11", kind: "route", text: "Transition planning → implementation (role: coder). Tools: filesystem, git, shell." },
{ role: "assistant", t: "14:12", streaming: true, text: "SegmentStore.append() now hashes the framed buffer with a streaming SHA-256 and writes <key>.seg under .correx/cas/. To pull zstd compression for cold segments I need com.github.luben:zstd-jni — resolving it touches the network, so I've raised a T3 approval before running gradle" },
];
// Live event stream (newest at bottom). category drives color + filter.
const EVENTS = [
{ id: "e41", t: "14:11:02", cat: "Lifecycle", type: "stage.enter", text: "implementation (role=coder)" },
{ id: "e42", t: "14:11:02", cat: "Context", type: "pack.synthesized", text: "4 sources · 6,120 tok · dedup 38%" },
{ id: "e43", t: "14:11:03", cat: "Inference", type: "infer.start", text: "qwen_coder_14b · t=0.2 · ctx 6.1k/32k" },
{ id: "e44", t: "14:11:19", cat: "Tool", type: "fs.write", text: "SegmentStore.kt (+212)" },
{ id: "e45", t: "14:11:20", cat: "Tool", type: "fs.write", text: "CasKey.kt (+47)" },
{ id: "e46", t: "14:11:24", cat: "Domain", type: "artifact.partial", text: "ImplementationArtifact draft" },
{ id: "e47", t: "14:11:31", cat: "Tool", type: "shell.exec", text: "./gradlew compileKotlin → OK (3.2s)" },
{ id: "e48", t: "14:11:48", cat: "Inference", type: "infer.token", text: "streaming … 1,204 tok" },
{ id: "e49", t: "14:12:02", cat: "Approval", type: "approval.raised", text: "T3 · gradle network resolve", pending: true },
];
// Pending approval (modal interrupt).
const APPROVAL = {
tier: "T3",
tierLabel: "external / network",
tool: "shell",
intent: "Resolve a new dependency over the network",
command: "./gradlew :segment-store:dependencies --refresh-dependencies",
detail: "Adds com.github.luben:zstd-jni:1.5.6-3 for cold-segment compression. Touches Maven Central (network egress).",
reversible: false,
policy: "stages.implementation.approvals.tool_tier_3 = required",
risks: [
"Network egress to repo1.maven.org",
"Mutates gradle.lockfile",
"New transitive: org.lz4? (none detected)",
],
};
// Context Pack — what the model actually receives. Layered L0..L4.
const CONTEXT_PACK = {
budget: 32768,
used: 6120,
layers: [
{ id: "L0", name: "live execution", tok: 1840, mode: "verbatim", items: ["current diff: SegmentStore.kt", "last tool result: compileKotlin OK"] },
{ id: "L1", name: "stage-local", tok: 1310, mode: "verbatim", items: ["stage=implementation", "role=coder card", "allowed tools (3)"] },
{ id: "L2", name: "compressed session", tok: 1490, mode: "semantic_summary", items: ["plan summary", "CasKey design decision", "rejected: per-record hashing"] },
{ id: "L3", name: "project memory", tok: 980, mode: "ranked", items: ["DomainLog.kt interface", "build conventions", "ADR-014 immutability"] },
{ id: "L4", name: "archival", tok: 500, mode: "latest_only", items: ["prior CAS spike (summary)"] },
],
dropped: ["12 stale tool logs (summarized)", "router chit-chat (excluded)", "duplicate file reads (deduped)"],
};
// Model registry.
const MODELS = [
{ id: "qwen_coder_14b", provider: "local", active: true, caps: ["coding", "tool_calling", "reasoning"], ctx: 32768, temp: 0.2, gpu: "48 layers · q8_0 kv", status: "warm", parallel: "1/2" },
{ id: "qwen_coder_32b", provider: "local", active: false, caps: ["coding", "tool_calling", "reasoning"], ctx: 32768, temp: 0.2, gpu: "unloaded", status: "cold", parallel: "0/1" },
{ id: "gpt-oss-120b", provider: "remote", active: false, caps: ["coding", "reasoning"], ctx: 131072, temp: 0.3, gpu: "—", status: "ready", parallel: "0/8" },
{ id: "claude-sonnet", provider: "remote", active: false, caps: ["coding", "tool_calling", "reasoning"], ctx: 200000, temp: 0.2, gpu: "—", status: "ready", parallel: "0/8" },
];
// Tool palette.
const TOOLS = [
{ id: "filesystem", tier: "T2", enabled: true, mode: "auto", desc: "read/write workspace files" },
{ id: "git", tier: "T2", enabled: true, mode: "auto", desc: "stage, commit, branch" },
{ id: "shell", tier: "T2", enabled: true, mode: "prompt", desc: "run bounded commands" },
{ id: "gradle", tier: "T3", enabled: true, mode: "prompt", desc: "build · may resolve deps (network)" },
{ id: "curl", tier: "T3", enabled: false, mode: "deny", desc: "raw network egress" },
{ id: "rm", tier: "T4", enabled: false, mode: "deny", desc: "destructive delete" },
];
// Command palette entries — id maps to an overlay/action in app.jsx.
const COMMANDS = [
{ id: "events", label: "Inspect event stream", hint: "filter · drill payload", key: "e" },
{ id: "context", label: "Inspect Context Pack", hint: "what the model sees", key: "c" },
{ id: "graph", label: "Stage / transition graph", hint: "workflow DAG", key: "g" },
{ id: "models", label: "Model registry", hint: "switch provider/model", key: "m" },
{ id: "tools", label: "Tool palette", hint: "enable · tiers · approvals", key: "t" },
{ id: "approval", label: "Review pending approval", hint: "T3 gate", key: "a" },
{ id: "appearance", label: "Cycle aesthetic direction", hint: "Boxed · Soft · Bare", key: "F2" },
];
window.CORREX = { SESSION, STAGES, TRANSITIONS, CONVERSATION, EVENTS, APPROVAL, CONTEXT_PACK, MODELS, TOOLS, COMMANDS };
+243
View File
@@ -0,0 +1,243 @@
// overlays.jsx — Correx overlays: command palette, approval gate, and inspectors.
const { useState, useEffect, useRef, useMemo } = React;
/* ── generic overlay shell ── */
function Overlay({ tag, title, esc = "esc", center, width, onClose, children }) {
return (
<div className={"scrim" + (center ? " center" : "")} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="ovl" style={width ? { width } : null} onMouseDown={(e) => e.stopPropagation()}>
<div className="ovl-h">
<span className="tag">{tag}</span>
<span className="ti">{title}</span>
<span className="esc">{esc} to close</span>
</div>
<div className="ovl-b">{children}</div>
</div>
</div>
);
}
/* ── command palette ── */
function CommandPalette({ commands, onRun, onClose }) {
const [q, setQ] = useState("");
const [i, setI] = useState(0);
const inp = useRef(null);
useEffect(() => { inp.current && inp.current.focus(); }, []);
const score = (c) => {
const s = (c.label + " " + c.hint).toLowerCase(), t = q.toLowerCase().trim();
if (!t) return 1;
if (s.includes(t)) return 2;
let qi = 0; for (const ch of s) if (ch === t[qi]) qi++;
return qi === t.length ? 1 : 0;
};
const list = useMemo(() => commands.map((c) => [c, score(c)]).filter(([, s]) => s > 0)
.sort((a, b) => b[1] - a[1]).map(([c]) => c), [q]);
useEffect(() => { setI(0); }, [q]);
const key = (e) => {
if (e.key === "ArrowDown") { setI((x) => Math.min(x + 1, list.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setI((x) => Math.max(x - 1, 0)); e.preventDefault(); }
else if (e.key === "Enter") { list[i] && onRun(list[i].id); e.preventDefault(); }
else if (e.key === "Escape") { onClose(); }
e.stopPropagation();
};
return (
<div className="scrim" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="ovl" style={{ width: "min(560px,94%)" }} onMouseDown={(e) => e.stopPropagation()}>
<div className="cmd-input">
<span className="pr"></span>
<input ref={inp} value={q} placeholder="Jump to a capability…" onChange={(e) => setQ(e.target.value)} onKeyDown={key} />
</div>
<div className="cmd-list">
{list.length === 0 && <div className="hint-empty">no matches</div>}
{list.map((c, n) => (
<div key={c.id} className={"cmd-it" + (n === i ? " sel" : "")} onMouseEnter={() => setI(n)} onClick={() => onRun(c.id)}>
<span className="ci-k">{c.key}</span>
<span className="ci-l">{c.label}</span>
<span className="ci-h">{c.hint}</span>
</div>
))}
</div>
</div>
</div>
);
}
/* ── approval gate (modal interrupt) ── */
function ApprovalModal({ data, onApprove, onReject, onSteer, onClose }) {
const [steer, setSteer] = useState("");
const sref = useRef(null);
return (
<div className="scrim center" onMouseDown={(e) => e.stopPropagation()}>
<div className="ovl appr" onMouseDown={(e) => e.stopPropagation()}>
<div className="appr-tier">
<span className="appr-badge">{data.tier}</span>
<div>
<div className="lab">Approval required · <b>{data.tierLabel}</b></div>
<div className="sub">{data.intent}</div>
</div>
<span className="esc" style={{ marginLeft: "auto" }}>execution paused</span>
</div>
<div className="ovl-b">
<div className="appr-cmd"><span className="pr">$ </span>{data.command}</div>
<div style={{ color: "var(--dim)", fontSize: 12.5, marginBottom: 6 }}>{data.detail}</div>
<dl className="kv">
<dt>tool</dt><dd>{data.tool} <span className="tag-tier tier-T3">{data.tier}</span></dd>
<dt>reversible</dt><dd>{data.reversible ? "yes" : "no"}</dd>
<dt>policy</dt><dd style={{ color: "var(--dim)" }}>{data.policy}</dd>
</dl>
<div style={{ margin: "10px 0 4px", color: "var(--dim)", fontSize: 11, letterSpacing: ".08em", textTransform: "uppercase" }}>semantic risk scan</div>
{data.risks.map((r, n) => <div className="risk" key={n}>{r}</div>)}
<div className="steer">
<span style={{ color: "var(--accent)" }}></span>
<input ref={sref} value={steer} placeholder='steer + approve, e.g. "ok, but lock the version"'
onChange={(e) => setSteer(e.target.value)}
onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter" && steer.trim()) onSteer(steer.trim()); if (e.key === "Escape") sref.current.blur(); }} />
</div>
</div>
<div className="appr-acts">
<button className="btn ok" onClick={() => onApprove(false)}>approve <kbd>a</kbd></button>
<button className="btn ghost" onClick={() => onApprove(true)}>auto-approve session <kbd>A</kbd></button>
<button className="btn" onClick={() => steer.trim() ? onSteer(steer.trim()) : sref.current.focus()}>steer <kbd>s</kbd></button>
<button className="btn bad" onClick={onReject}>reject <kbd>r</kbd></button>
</div>
</div>
</div>
);
}
/* ── event stream inspector ── */
function EventInspector({ events, onClose }) {
const cats = ["All", "Lifecycle", "Context", "Inference", "Tool", "Domain", "Approval"];
const [cat, setCat] = useState("All");
const [sel, setSel] = useState(events[events.length - 1]);
const list = events.filter((e) => cat === "All" || e.cat === cat);
const payload = {
id: sel.id, type: sel.type, category: sel.cat, ts: "2026-05-29T" + sel.t,
session_id: window.CORREX.SESSION.id, correlation_id: "cor-7f2a", causation_id: sel.id === "e49" ? "e48" : null,
payload: { detail: sel.text },
};
return (
<Overlay tag="events" title="Event stream inspector" onClose={onClose} width="min(820px,95%)">
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 10 }}>
{cats.map((c) => <span key={c} className={"pill" + (c === cat ? " live" : "")} style={{ cursor: "pointer" }} onClick={() => setCat(c)}>{c}</span>)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1.3fr 1fr", gap: 14 }}>
<div>
{list.map((e) => (
<div key={e.id} className={"ev" + (e === sel ? " sel" : "")} onClick={() => setSel(e)}>
<span className="ev-t">{e.t}</span>
<span className={"ev-cat cat-" + e.cat}>{e.cat}</span>
<span className="ev-tx"><b>{e.type}</b> {e.text}</span>
</div>
))}
</div>
<div className="appr-cmd" style={{ margin: 0, alignSelf: "start", fontSize: 12 }}>
<pre style={{ whiteSpace: "pre-wrap", margin: 0, color: "var(--fg)" }}>{JSON.stringify(payload, null, 2)}</pre>
</div>
</div>
</Overlay>
);
}
/* ── context pack inspector ── */
function ContextPackInspector({ pack, onClose }) {
const pct = Math.round((pack.used / pack.budget) * 100);
return (
<Overlay tag="context" title="Context Pack — what the model receives" onClose={onClose} width="min(760px,95%)">
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 12 }}>
<div style={{ color: "var(--fg-strong)", fontSize: 15 }}>{pack.used.toLocaleString()} <span style={{ color: "var(--dim)", fontSize: 12 }}>/ {pack.budget.toLocaleString()} tok</span></div>
<div className="mono-bar" style={{ flex: 1 }}><i style={{ width: pct + "%" }} /></div>
<div style={{ color: "var(--accent)" }}>{pct}%</div>
</div>
{pack.layers.map((l) => (
<div className="row" key={l.id} style={{ gridTemplateColumns: "38px 150px 1fr 64px" }}>
<span style={{ color: "var(--accent)", fontWeight: 600 }}>{l.id}</span>
<span style={{ color: "var(--fg)" }}>{l.name}<br /><span style={{ color: "var(--faint)", fontSize: 11 }}>{l.mode}</span></span>
<span style={{ color: "var(--dim)", fontSize: 12 }}>{l.items.join(" · ")}</span>
<span style={{ color: "var(--dim)", textAlign: "right" }}>{l.tok}t</span>
</div>
))}
<div style={{ marginTop: 12, color: "var(--dim)", fontSize: 11, letterSpacing: ".08em", textTransform: "uppercase" }}>excluded by synthesis</div>
{pack.dropped.map((d, n) => <div key={n} style={{ color: "var(--faint)", fontSize: 12.5, padding: "2px 0" }}> {d}</div>)}
</Overlay>
);
}
/* ── stage / transition graph ── */
function StageGraph({ stages, transitions, onClose }) {
const glyph = { done: "✔", active: "▸", pending: "·" };
return (
<Overlay tag="graph" title="Stage / transition graph" onClose={onClose} width="min(720px,95%)">
<div style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 18 }}>
<div>
{stages.map((s, n) => (
<div key={s.id}>
<div style={{ display: "flex", alignItems: "baseline", gap: 10, padding: "5px 0" }}>
<span style={{ width: 16, color: s.status === "active" ? "var(--accent)" : s.status === "done" ? "var(--ok)" : "var(--faint)" }}>{glyph[s.status]}</span>
<span style={{ color: s.status === "pending" ? "var(--dim)" : "var(--fg-strong)", fontWeight: s.status === "active" ? 600 : 400, minWidth: 120 }}>{s.id}</span>
<span style={{ color: "var(--faint)", fontSize: 11 }}>role:{s.role}</span>
{s.status === "active" && <span className="pill live">running · {s.enter}</span>}
<span style={{ color: "var(--dim)", fontSize: 12, marginLeft: "auto" }}>{s.note}</span>
</div>
{n < stages.length - 1 && <div style={{ marginLeft: 7, color: "var(--faint)", lineHeight: 1 }}></div>}
</div>
))}
</div>
<div>
<div style={{ color: "var(--dim)", fontSize: 11, letterSpacing: ".08em", textTransform: "uppercase", marginBottom: 8 }}>transition rules</div>
<div style={{ color: "var(--faint)", fontSize: 11, marginBottom: 10 }}>no hardcoded graph rules eval per artifact</div>
{transitions.map((t, n) => (
<div key={n} className="appr-cmd" style={{ margin: "0 0 8px", fontSize: 12 }}>
<span style={{ color: "var(--accent-2)" }}>when</span> {t.when}<br />
<span style={{ color: "var(--accent)" }}> goto</span> {t.goto}
</div>
))}
</div>
</div>
</Overlay>
);
}
/* ── model registry / provider switch ── */
function ModelRegistry({ models, active, onSwitch, onClose }) {
return (
<Overlay tag="models" title="Model registry" onClose={onClose} width="min(800px,95%)">
<div style={{ color: "var(--faint)", fontSize: 12, marginBottom: 8 }}>models are stateless engines switching re-synthesizes the Context Pack for the new window.</div>
{models.map((m) => (
<div className={"row"} key={m.id} style={{ gridTemplateColumns: "1fr auto", cursor: m.id === active ? "default" : "pointer", opacity: m.status === "cold" ? 0.7 : 1 }} onClick={() => m.id !== active && onSwitch(m.id)}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<span style={{ color: m.id === active ? "var(--accent)" : "var(--fg-strong)", fontWeight: 600 }}>{m.id}</span>
<span className={"pill " + (m.provider === "local" ? "live" : "")}>{m.provider}</span>
{m.id === active && <span className="pill on">active</span>}
<span className="pill off">{m.status}</span>
</div>
<div style={{ color: "var(--dim)", fontSize: 11.5, marginTop: 3 }}>{m.caps.join(" · ")} ctx {m.ctx.toLocaleString()} · t={m.temp} · {m.gpu}</div>
</div>
<div style={{ textAlign: "right", color: "var(--faint)", fontSize: 11 }}>
sessions {m.parallel}{m.id !== active && <div style={{ color: "var(--accent)" }}>switch </div>}
</div>
</div>
))}
</Overlay>
);
}
/* ── tool palette ── */
function ToolPalette({ tools, onToggle, onClose }) {
return (
<Overlay tag="tools" title="Tool palette" onClose={onClose} width="min(720px,95%)">
<div style={{ color: "var(--faint)", fontSize: 12, marginBottom: 8 }}>config-driven · capability-scoped. tier sets the approval gate; disabled tools are invisible to the agent.</div>
{tools.map((t) => (
<div className="row" key={t.id} style={{ gridTemplateColumns: "auto 1fr auto auto", cursor: "pointer", opacity: t.enabled ? 1 : 0.55 }} onClick={() => onToggle(t.id)}>
<span className={"tag-tier tier-" + t.tier}>{t.tier}</span>
<span><b style={{ color: "var(--fg-strong)", fontWeight: 600 }}>{t.id}</b> <span style={{ color: "var(--dim)", fontSize: 12 }}> {t.desc}</span></span>
<span className="pill" style={{ color: t.mode === "deny" ? "var(--bad)" : t.mode === "prompt" ? "var(--warn)" : "var(--ok)" }}>{t.mode}</span>
<span className={"pill " + (t.enabled ? "on" : "off")}>{t.enabled ? "enabled" : "disabled"}</span>
</div>
))}
</Overlay>
);
}
Object.assign(window, { Overlay, CommandPalette, ApprovalModal, EventInspector, ContextPackInspector, StageGraph, ModelRegistry, ToolPalette });
+114
View File
@@ -0,0 +1,114 @@
// themes.jsx — three aesthetic directions for the Correx TUI.
// Each theme is a set of CSS variables + a `chrome` mode that changes how
// panels render. Palette accent is independently swappable.
const ACCENTS = {
cyan: { accent: "#56c8d8", accent2: "#7aa2f7", glow: "86 200 216" },
amber: { accent: "#e8b765", accent2: "#d98a5b", glow: "232 183 101" },
green: { accent: "#8fd96a", accent2: "#5dd1a0", glow: "143 217 106" },
magenta: { accent: "#c98fd9", accent2: "#d98ab0", glow: "201 143 217" },
neutral: { accent: "#b9c0c9", accent2: "#8a929c", glow: "185 192 201" },
};
const THEMES = {
// 1. Boxed — lazygit / k9s. Sharp single-line borders, numbered panels,
// active panel highlighted, cool slate ground.
boxed: {
label: "Boxed",
blurb: "lazygit · k9s",
chrome: "boxed",
defaultAccent: "cyan",
vars: {
"--bg": "#0b0f14",
"--bg-deep": "#070a0e",
"--panel": "#0e141b",
"--panel-2": "#111922",
"--fg": "#c6d0da",
"--fg-strong": "#eef3f8",
"--dim": "#5b6873",
"--faint": "#36424d",
"--border": "#1d2935",
"--border-active": "var(--accent)",
"--sel": "#16222e",
"--radius": "0px",
"--panel-pad": "0px",
"--gap": "6px",
"--font": "'JetBrains Mono','SF Mono',ui-monospace,monospace",
"--line": "1.45",
"--ok": "#7fd88f",
"--warn": "#e8c06a",
"--bad": "#e87f7f",
},
},
// 2. Soft — Charm / Bubble Tea. Rounded panels, generous padding, gentle
// separators. Neutral charcoal ground so the look isn't tinted — warmth
// lives only in the (swappable) accent.
soft: {
label: "Soft",
blurb: "charm · bubbletea",
chrome: "soft",
defaultAccent: "amber",
vars: {
"--bg": "#14161a",
"--bg-deep": "#0f1115",
"--panel": "#181b21",
"--panel-2": "#1f232b",
"--fg": "#ced3da",
"--fg-strong": "#eef1f5",
"--dim": "#838b96",
"--faint": "#434a55",
"--border": "#262b33",
"--border-active": "var(--accent)",
"--sel": "#21262e",
"--radius": "10px",
"--panel-pad": "4px",
"--gap": "10px",
"--font": "'JetBrains Mono','SF Mono',ui-monospace,monospace",
"--line": "1.6",
"--ok": "#86d39a",
"--warn": "#e8b765",
"--bad": "#e3938a",
},
},
// 3. Bare — classic monochrome phosphor. Almost no chrome: thin rules,
// whitespace, green-on-black. Minimal, fast, text-forward.
bare: {
label: "Bare",
blurb: "classic phosphor",
chrome: "bare",
defaultAccent: "green",
vars: {
"--bg": "#080b08",
"--bg-deep": "#050705",
"--panel": "#080b08",
"--panel-2": "#0b100b",
"--fg": "#a7c99a",
"--fg-strong": "#e6f6df",
"--dim": "#5a7050",
"--faint": "#2f3d2b",
"--border": "#1a241799",
"--border-active": "var(--accent)",
"--sel": "#12200f",
"--radius": "0px",
"--panel-pad": "0px",
"--gap": "2px",
"--font": "'JetBrains Mono','SF Mono',ui-monospace,monospace",
"--line": "1.55",
"--ok": "#8fd96a",
"--warn": "#cbd96a",
"--bad": "#d98a8a",
},
},
};
function applyTheme(el, themeKey, accentKey) {
const th = THEMES[themeKey] || THEMES.boxed;
const acc = ACCENTS[accentKey] || ACCENTS[th.defaultAccent];
const vars = { ...th.vars, "--accent": acc.accent, "--accent-2": acc.accent2, "--glow": acc.glow };
for (const [k, v] of Object.entries(vars)) el.style.setProperty(k, v);
el.setAttribute("data-chrome", th.chrome);
}
window.CORREX_THEMES = { THEMES, ACCENTS, applyTheme };
+540
View File
@@ -0,0 +1,540 @@
/* BEGIN USAGE */
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// TweakRadio is the segmented control for 23 short options (auto-falls-back to
// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
// options are many or long. For color tweaks always curate 3-4 options rather than
// a free picker; an option can also be a whole 25 color palette (the stored value
// is the array). The Tweak* controls are a floor, not a ceiling — build custom
// controls inside the panel if a tweak calls for UI they don't cover.
/* END USAGE */
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});