// 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 = "" + PRIMA_MARK_SYMBOL + ""; 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 (