// hero-story.jsx — animated hero engine, v3.
//
// Architecture:
// HeroStory (parent) — owns cycleIdx; mounts ONE HeroStoryCycle at a
// time via a key. When a cycle completes, the
// old instance unmounts and a new one mounts —
// state and CSS animations reset cleanly.
// HeroStoryCycle (child) — plays through one conversation: ringing →
// active typewriter → callEnd → slide1 →
// emailWrite → emailHold → slideOut → gap.
// Calls onComplete at the end of gap.
//
// LiveWaveform now has a fill-in animation: a paper-colored veil covers the
// strip, then retreats from left over ~4s, revealing bars from right to left
// as the call comes online.
(function () {
const { PALETTE } = window;
// --- Tunables ---------------------------------------------------------
const BAR_W = 4;
const BAR_GAP = 5;
const BARS_PER_LOOP = 90;
const SCROLL_DURATION_S = 16;
const BUILDUP_DURATION_S = 4;
const TYPE_RATE_MS = 32;
const PUNCT_EXTRA_MS = 90;
const PAUSE_BETWEEN_LINES_MS = 750;
const PAUSE_AFTER_LAST_LINE_MS = 1600;
const RINGING_MS = 1300;
const CALL_END_MS = 1900;
const SLIDE_MS = 1000;
const POST_SLIDE_SETTLE_MS = 600;
// --- Email typewriter (character-by-character across whole email) ---
const EMAIL_CHAR_MS = 9;
const EMAIL_PUNCT_MS = 30;
const EMAIL_HOLD_MS = 1800; // pause after typing completes
const FADE_AND_SENT_MS = 900; // email text fades, "Sent!" appears
const SENT_HOLD_MS = 1200; // "Sent!" stays visible
const SLIDE_OFF_MS = 1100; // both zones slide left
const GAP_MS = 700; // empty pause before next cycle
function generateBars(count, seed = 1) {
const out = [];
for (let i = 0; i < count; i++) {
const v =
0.5 +
0.35 * Math.sin(i * 0.32 + seed) +
0.18 * Math.sin(i * 0.83 + seed * 2) +
0.12 * Math.sin(i * 1.55 + seed * 0.7);
out.push(Math.max(0.16, Math.min(0.96, v)));
}
return out;
}
// ----------------------------------------------------------------------
// LiveWaveform — continuous-scroll oscilloscope. New for v3: a "fill veil"
// covers the strip initially and retreats from left as the call goes
// active, revealing bars right-to-left.
// ----------------------------------------------------------------------
function LiveWaveform({ color = PALETTE.ink, height = 140, phase = "ringing", veilColor = PALETTE.bg }) {
const bars = React.useMemo(() => generateBars(BARS_PER_LOOP, 1.3), []);
const stripWidth = BARS_PER_LOOP * (BAR_W + BAR_GAP);
let dim = 1;
if (phase === "ringing") dim = 0.5;
else if (phase === "callEnd" || phase === "gone") dim = 0;
// Veil is fully covering during ringing; retreats once the call is active.
const veilOpen = phase === "active" || phase === "callEnd" || phase === "gone";
return (
{/* Bars strip — continuous left drift */}
{[...bars, ...bars].map((h, i) => (
))}
{/* Fill-in veil — anchored left, shrinks to width:0 to reveal bars right-to-left */}
);
}
// ----------------------------------------------------------------------
// Checkmark — SVG with stroke-dasharray draw animation
// ----------------------------------------------------------------------
function Checkmark({ color = PALETTE.ink, accent = PALETTE.accent, visible, size = 96 }) {
return (
);
}
function Cursor({ color = PALETTE.ink, size = "1em" }) {
return (
);
}
// ----------------------------------------------------------------------
// TranscriptStack — accumulating lines, last 3 visible. Agent lines get
// accent bar + brand label + large serif. Caller lines are quiet italic.
// ----------------------------------------------------------------------
function TranscriptStack({ lines, activeIdx, charIdx, isTyping, dimmed = false }) {
const windowStart = Math.max(0, activeIdx - 2);
const visibleLines = [];
for (let i = windowStart; i <= activeIdx && i < lines.length; i++) {
visibleLines.push({ ...lines[i], _idx: i, _age: activeIdx - i, _isActive: i === activeIdx });
}
return (
{visibleLines.map((line) => {
const isAgent = line.who === "agent";
const displayText = line._isActive ? line.text.slice(0, charIdx) : line.text;
const showCursor = line._isActive && isTyping && charIdx < line.text.length;
const ageOpacity = line._age === 0 ? 1 : line._age === 1 ? 0.55 : 0.28;
if (isAgent) {
return (
OpenLines
{displayText}
{showCursor ? : null}
);
}
return (
Caller
{displayText}
{showCursor ? : null}
);
})}
);
}
// ----------------------------------------------------------------------
// Email chunks — flat list of text segments that get typed character-by-
// character (just like the transcript). Each chunk is rendered in DOM
// order; revealChars is the global typing cursor.
// ----------------------------------------------------------------------
function getEmailChunks(conversation) {
if (!conversation) return [];
const { business, timestamp, duration, endReason, summary } = conversation;
const time = timestamp.split(" · ")[1] || "";
const callerText =
summary.caller.name === "Not collected" && summary.caller.phone === "Not collected"
? "Not collected — caller did not share contact info"
: `${summary.caller.name} · ${summary.caller.phone}`;
const chunks = [
{ id: "subject", text: `${business} · Call summary · ${time} · ${duration}` },
{ id: "header-name", text: business },
{ id: "header-end", text: endReason },
{ id: "sum-body", text: summary.summary, section: 0 },
{ id: "caller-body", text: callerText, section: 1 },
...summary.handled.map((h, i) => ({ id: `did-${i}`, text: h, section: 2, itemIdx: i })),
{ id: "actions", text: summary.actions, section: 3 },
{ id: "notes", text: summary.notes, section: 4 },
];
let offset = 0;
for (const c of chunks) {
c.start = offset;
offset += c.text.length;
}
return chunks;
}
function getEmailTotalChars(conversation) {
return getEmailChunks(conversation).reduce((s, c) => s + c.text.length, 0);
}
function getEmailFlatText(conversation) {
return getEmailChunks(conversation)
.map((c) => c.text)
.join("");
}
// ----------------------------------------------------------------------
// EmailDocument — borderless or hairline integration
// ----------------------------------------------------------------------
function EmailDocument({ conversation, mode = "borderless", revealChars = Infinity }) {
if (!conversation) return null;
const { business, timestamp, duration, endReason, summary } = conversation;
const urgent = /URGENT/i.test(summary.actions || "") || /priority/i.test(summary.notes || "");
const chunks = React.useMemo(() => getEmailChunks(conversation), [conversation]);
const totalChars = chunks.reduce((s, c) => s + c.text.length, 0);
const typingComplete = revealChars >= totalChars;
const isHairline = mode === "hairline";
const containerStyle = isHairline
? { border: `1px solid ${PALETTE.hairline}`, padding: "22px 24px 18px", background: "transparent" }
: { border: "none", padding: "4px 0 0", background: "transparent" };
// Per-chunk visible state
function vis(id) {
const c = chunks.find((c) => c.id === id);
if (!c) return { visible: "", isComplete: false, isActive: false, hasStarted: false };
const count = Math.max(0, Math.min(c.text.length, revealChars - c.start));
return {
visible: c.text.slice(0, count),
isComplete: count >= c.text.length,
isActive: count > 0 && count < c.text.length,
hasStarted: count > 0,
};
}
function sectionVisible(sectionIdx) {
const first = chunks.find((c) => c.section === sectionIdx);
if (!first) return false;
return revealChars > first.start;
}
function MiniCursor() {
return (
);
}
const Section = ({ idx, label, children, last }) => {
const visible = sectionVisible(idx);
return (
);
};
const subj = vis("subject");
const hdrName = vis("header-name");
const hdrEnd = vis("header-end");
const sumBody = vis("sum-body");
const callerBody = vis("caller-body");
const actionsBody = vis("actions");
const notesBody = vis("notes");
return (
{/* From / date line — instant */}
From · OpenLines
{timestamp.split(" · ")[0]}
{/* Subject — typed */}
{subj.visible}
{subj.isActive ? : null}
{/* Header row — business name + endReason */}
{hdrName.visible}
{hdrName.isActive ? : null}
{hdrEnd.visible}
{hdrEnd.isActive ? : null}
{urgent && hdrName.hasStarted ? (
Urgent
) : null}
{sumBody.visible}
{sumBody.isActive ? : null}
{summary.caller.name === "Not collected" &&
summary.caller.phone === "Not collected" ? (
{callerBody.visible}
{callerBody.isActive ? : null}
) : (
<>
{callerBody.visible}
{callerBody.isActive ? : null}
>
)}
{summary.handled.map((h, i) => {
const v = vis(`did-${i}`);
return (
-
{v.visible}
{v.isActive ? : null}
);
})}
{actionsBody.visible}
{actionsBody.isActive ? : null}
{notesBody.visible}
{notesBody.isActive ? : null}
{/* Footer — appears once typing is complete */}
Generated by OpenLines for {business}
);
}
// ----------------------------------------------------------------------
// SentCaption — fades in over the email zone once the email content
// starts fading. Italic serif oxblood "Sent." with mono sub-caption.
// ----------------------------------------------------------------------
function SentCaption({ visible }) {
return (
Sent.
Delivered to your inbox
);
}
// ----------------------------------------------------------------------
// HeroStoryCycle — runs ONE conversation through the full sequence.
// No reset logic — when done, calls onComplete. Parent remounts via key.
// ----------------------------------------------------------------------
function HeroStoryCycle({ conversation, emailMode, onComplete, isMobile = false }) {
const [phase, setPhase] = React.useState("ringing");
const [lineIdx, setLineIdx] = React.useState(-1);
const [charIdx, setCharIdx] = React.useState(0);
const [emailCharIdx, setEmailCharIdx] = React.useState(0);
const [trackPos, setTrackPos] = React.useState(0);
const emailTotalChars = React.useMemo(() => getEmailTotalChars(conversation), [conversation]);
const emailFlatText = React.useMemo(() => getEmailFlatText(conversation), [conversation]);
React.useEffect(() => {
let cancelled = false;
const t = (ms, fn) => {
const id = setTimeout(() => { if (!cancelled) fn(); }, ms);
return id;
};
let tid;
if (phase === "ringing") {
tid = t(RINGING_MS, () => {
setLineIdx(0);
setCharIdx(0);
setPhase("active");
});
} else if (phase === "active") {
const line = conversation.transcript[lineIdx];
if (!line) return;
if (charIdx < line.text.length) {
const nextChar = line.text[charIdx];
const isPunct = /[.,?!]/.test(nextChar);
tid = t(TYPE_RATE_MS + (isPunct ? PUNCT_EXTRA_MS : 0), () => setCharIdx((c) => c + 1));
} else {
if (lineIdx < conversation.transcript.length - 1) {
tid = t(PAUSE_BETWEEN_LINES_MS, () => {
setLineIdx((i) => i + 1);
setCharIdx(0);
});
} else {
tid = t(PAUSE_AFTER_LAST_LINE_MS, () => setPhase("callEnd"));
}
}
} else if (phase === "callEnd") {
tid = t(CALL_END_MS, () => {
setTrackPos(1);
setPhase("slide1");
});
} else if (phase === "slide1") {
tid = t(SLIDE_MS + POST_SLIDE_SETTLE_MS, () => {
setEmailCharIdx(0);
setPhase("emailWrite");
});
} else if (phase === "emailWrite") {
if (emailCharIdx < emailTotalChars) {
const nextChar = emailFlatText[emailCharIdx];
const isPunct = /[.,?!:]/.test(nextChar);
tid = t(EMAIL_CHAR_MS + (isPunct ? EMAIL_PUNCT_MS : 0), () => setEmailCharIdx((i) => i + 1));
} else {
tid = t(0, () => setPhase("emailHold"));
}
} else if (phase === "emailHold") {
tid = t(EMAIL_HOLD_MS, () => setPhase("fadeAndSent"));
} else if (phase === "fadeAndSent") {
tid = t(FADE_AND_SENT_MS, () => setPhase("sentHold"));
} else if (phase === "sentHold") {
tid = t(SENT_HOLD_MS, () => setPhase("slideOff"));
} else if (phase === "slideOff") {
tid = t(SLIDE_OFF_MS, () => setPhase("gap"));
} else if (phase === "gap") {
tid = t(GAP_MS, () => {
if (onComplete) onComplete();
});
}
return () => { cancelled = true; clearTimeout(tid); };
}, [phase, lineIdx, charIdx, emailCharIdx, emailTotalChars, emailFlatText, conversation, onComplete]);
const inCall = phase === "ringing" || phase === "active" || phase === "callEnd";
const slidPast = trackPos >= 1;
const inEmailPhase = phase === "slide1" || phase === "emailWrite" || phase === "emailHold";
const isSending = phase === "fadeAndSent" || phase === "sentHold";
const slidOff = phase === "slideOff" || phase === "gap";
const everythingOut = phase === "gap";
const trackOffsetPct = trackPos === 0 ? 0 : -53;
// Email content fades during fadeAndSent; gone in sentHold/slideOff/gap.
const emailContentVisible = inEmailPhase;
// "Sent." caption shows from fadeAndSent onward, slides with the zone.
const showSent = isSending || slidOff;
let wfPhase = "active";
if (phase === "ringing") wfPhase = "ringing";
else if (phase === "callEnd") wfPhase = "callEnd";
else if (slidPast) wfPhase = "gone";
const isTypingActive = phase === "active";
// ====== MOBILE LAYOUT ======
// Stacked, opacity-based phase swap. No horizontal slide.
if (isMobile) {
return (
{/* CALL VIEW — waveform on top, transcript below */}
{/* Top meta + waveform */}
{conversation.business}
>
}
right={conversation.timestamp.split(" · ")[1]}
/>
{conversation.duration}}
/>
{/* Transcript label */}
Live transcript
{/* Transcript stack — fills remaining space */}
{/* EMAIL VIEW — takes over the same space when call is done */}
Morning summary
{phase === "emailWrite" ? "· drafting" : phase === "emailHold" ? "· ready to send" : ""}
);
}
// ====== DESKTOP LAYOUT ======
return (
{/* === ZONE 1: WAVEFORM / CHECKMARK === */}
{conversation.business}
>
}
right={conversation.timestamp.split(" · ")[1]}
/>
{conversation.duration}}
/>
{/* === ZONE 2: TRANSCRIPT === */}
Live transcript{slidPast ? " · saved" : ""}
{/* === ZONE 3: EMAIL → COLLAPSE → ENVELOPE → MAILBOX → SENT === */}
Morning summary
{phase === "emailWrite"
? "· drafting"
: phase === "emailHold"
? "· ready to send"
: isSending || slidOff
? "· sent"
: ""}
{/* Email document — visible during emailWrite/Hold; fades during fadeAndSent */}
{/* "Sent." caption — fades in as email fades out */}
);
}
// ----------------------------------------------------------------------
// HeroStory — top-level. Owns cycleIdx; mounts ONE HeroStoryCycle at a
// time keyed by cycle. When cycle completes, advance and remount.
// ----------------------------------------------------------------------
function HeroStory({ conversations, emailMode = "borderless", isMobile = false }) {
const [cycleIdx, setCycleIdx] = React.useState(0);
const advance = React.useCallback(() => {
setCycleIdx((i) => i + 1);
}, []);
const conv = conversations[cycleIdx % conversations.length];
return (
);
}
// --- Zone helpers -----------------------------------------------------
function Zone({ left, children, style }) {
return (
{children}
);
}
function ZoneMeta({ left, right }) {
return (
);
}
function LiveDotInline({ color }) {
return (
);
}
Object.assign(window, {
LiveWaveform,
Checkmark,
TranscriptStack,
EmailDocument,
HeroStory,
});
})();