// Prima — animated in-chat loader (v1)
// Three variants: "reasoning" (default), "indexing", "drafting" — plus a large "hero" layered version.
// Drop-in for front_end_v1: exposes on window; also mounts a hidden #prima-mark
// symbol on first render so any elsewhere reuses the same artwork.
//
// Usage in the chat screen:
// // default reasoning + caption + typing dots
// // slow, ambient-glow — for ingestion
// // reverse breathing — long deep-report generations
//
// // just the spinning mandala, no chrome
// ── Hidden mandala symbol (mounted once) ──────────────────────────────────
const PRIMA_MARK_SYMBOL = "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n ";
const PrimaMarkDefs = () => {
React.useEffect(() => {
if (document.getElementById("prima-mark-defs-root")) return;
const holder = document.createElement("div");
holder.id = "prima-mark-defs-root";
holder.setAttribute("aria-hidden", "true");
holder.style.cssText = "position:absolute; width:0; height:0; overflow:hidden;";
holder.innerHTML = "";
document.body.appendChild(holder);
}, []);
return null;
};
// ── Scoped stylesheet (mounted once) ──────────────────────────────────────
const PRIMA_LOADER_CSS = "@keyframes primaSpinSlow { to { transform:rotate(360deg); } }\n@keyframes primaSpinRev { to { transform:rotate(-360deg); } }\n@keyframes primaSpinFast { to { transform:rotate(360deg); } }\n@keyframes primaPulse { 0%,100%{transform:scale(1);} 50%{transform:scale(1.06);} }\n@keyframes primaGlow { 0%,100%{opacity:0.55;} 50%{opacity:1;} }\n@keyframes primaSparkle { 0%,100%{opacity:0.5;} 50%{opacity:1;} }\n@keyframes primaTyping { 0%,60%,100%{opacity:0.3; transform:translateY(0);} 30%{opacity:1; transform:translateY(-3px);} }\n.prima-loader-inner-spin { animation:primaSpinFast 9s linear infinite, primaPulse 2.6s ease-in-out infinite; }\n.prima-loader-shell { position:relative; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:24px; }\n.prima-loader-shell.on-dark { color:#F6EFE2; }\n.prima-loader-shell.on-light { color:#3E0E0E; }\n@media (prefers-reduced-motion: reduce) { .prima-loader-shell *, .prima-mandala * { animation:none !important; } }";
const PrimaLoaderStyles = () => {
React.useEffect(() => {
if (document.getElementById("prima-loader-styles")) return;
const s = document.createElement("style");
s.id = "prima-loader-styles";
s.textContent = PRIMA_LOADER_CSS;
document.head.appendChild(s);
}, []);
return null;
};
// ── Variant configs ──────────────────────────────────────────────────────
// `speed` is a duration multiplier applied to the whole mandala (1 = reasoning pace).
const VARIANTS = {
reasoning: { speed: 1 },
indexing: { speed: 1.8 },
drafting: { speed: 2.4 },
};
// ── Layered mandala (finalized "3A": two counter-rotating rings + breathing
// + three sparks orbiting the gap between them). Sparks auto-hide below 64px
// where they'd read as noise; prefers-reduced-motion freezes everything.
// theme="dark" (cream/rose, for maroon backgrounds) | "light" (maroon family,
// for the cream chat stream).
const MANDALA_TONES = {
dark: { outer: "#E9B8B8", outerOp: 0.45, inner: "#F6EFE2", innerShadow: "rgba(246,239,226,0.65)",
glowBg: "radial-gradient(circle, rgba(223,69,69,0.28) 0%, rgba(223,69,69,0) 65%)",
sparks: [["#E6C277", "rgba(230,194,119,0.75)"], ["#E9B8B8", "rgba(233,184,184,0.65)"], ["#F4C8A6", "rgba(244,200,166,0.6)"]] },
light: { outer: "#B0625F", outerOp: 0.55, inner: "#7A1C1C", innerShadow: "rgba(122,28,28,0.3)",
glowBg: "radial-gradient(circle, rgba(122,28,28,0.12) 0%, rgba(122,28,28,0) 65%)",
sparks: [["#B9862E", "rgba(185,134,46,0.55)"], ["#A0524F", "rgba(160,82,79,0.5)"], ["#C98A6A", "rgba(201,138,106,0.5)"]] },
};
const PrimaMandala = ({ size = 120, speed = 1, glow = true, theme = "dark", style = {} }) => {
const tone = MANDALA_TONES[theme] || MANDALA_TONES.dark;
const inner = Math.round(size * 0.55);
const pad = Math.round((size - inner) / 2);
const inset = Math.round(size * 0.11);
const showSparks = size >= 64;
const dur = (s) => (s * speed).toFixed(1) + "s";
const sparks = [
// Halved in step with the rings below — the sparks orbit the gap between them, so
// they have to keep pace or the composition comes apart at hero size.
{ anim: "primaSpinFast " + dur(4.5) + " linear infinite", color: tone.sparks[0][0], d: Math.max(4, size * 0.025), sh: tone.sparks[0][1], bottom: false },
{ anim: "primaSpinRev " + dur(7) + " linear infinite", color: tone.sparks[1][0], d: Math.max(3.5, size * 0.021), sh: tone.sparks[1][1], bottom: false },
{ anim: "primaSpinFast " + dur(10.5)+ " linear infinite", color: tone.sparks[2][0], d: Math.max(3, size * 0.017), sh: tone.sparks[2][1], bottom: true },
];
return (
{glow && (
)}
{/* Rotation only — the pulse/glow breathing above is deliberately left slower.
28s/11s read as static at the 38px in-chat size (the loader this replaced ran
at 2.5s); halved, the inner ring turns visibly while the outer stays ambient
and the ~2.5:1 ratio that makes them read as two distinct rings is kept. */}
{showSparks && sparks.map((sp, i) => (
))}
);
};
// ── Small typing-dots component ──────────────────────────────────────────
const TypingDots = ({ color = "#E6C277" }) => (
{[0, 0.2, 0.4].map((d, i) => (
))}
);
// ── Main ─────────────────────────────────────────────────
const PrimaLoader = ({
variant = "reasoning", // "reasoning" | "indexing" | "drafting" | "hero"
size = 120, // px — mandala diameter (ignored when variant="hero")
caption, // main line
sublabel, // secondary line
showDots = true, // typing dots below caption
bare = false, // return just the animated mandala, no text/chrome
theme = "dark", // "dark" | "light" — sets caption colour
style = {},
}) => {
// Hero variant: three layered mandalas + ambient glow
if (variant === "hero") {
return (
<>