/** * TourStory — focused, single-concept reading mode for the tour. * * Complements the canvas grid: same data, one concept at a time, with * keyboard scrub, a progress rail, a flow strip, and an animated transition * between steps. The goal is to *pace* the learning — readers can't take in * 7 cards at once, but they can take in one well. */ import { useEffect, useState } from "react"; // Mirror ExploreView's palette so Story mode reads as the same product — // kept local (not imported) to avoid a cyclic import when ExploreView later // mounts TourStory as a child. const TYPE_STYLE = { class: { border: "#5B8FF9", dot: "#7DABFF", tag: "class" }, function: { border: "#FBBF24", dot: "#FCD34D", tag: "fn" }, module: { border: "#A78BFA", dot: "#C4B5FD", tag: "module" }, algorithm: { border: "#34D399", dot: "#6EE7B7", tag: "algo" }, }; const FALLBACK = { border: "#4E5E80", dot: "#8896B8", tag: "?" }; function ChevronLeft() { return ( ); } function ChevronRight() { return ( ); } function ExternalIcon() { return ( ); } export default function TourStory({ data, repo, onAskAbout, initialConceptId = null }) { // Concepts are indexed by reading_order so ← / → match the author's intended sequence const concepts = [...(data.concepts || [])].sort( (a, b) => (a.reading_order ?? 999) - (b.reading_order ?? 999) ); // When opened from a Canvas card click, jump straight to that concept's // index. Falls back to 0 if the id isn't found (e.g. tour regenerated and // the concept no longer exists). useState's initializer form runs once; // subsequent mode toggles don't re-jump because Canvas only sets a fresh // initialConceptId on click. const [idx, setIdx] = useState(() => { if (!initialConceptId) return 0; const found = concepts.findIndex(c => c.id === initialConceptId); return found >= 0 ? found : 0; }); // Bump a key on index change so the card remounts — CSS animation replays // without needing to toggle className off then on. const [animKey, setAnimKey] = useState(0); useEffect(() => { setAnimKey(k => k + 1); }, [idx]); // Clamp idx if concepts shrink (e.g. regenerate produced fewer) useEffect(() => { if (idx > concepts.length - 1) setIdx(Math.max(0, concepts.length - 1)); }, [concepts.length, idx]); // Keyboard nav. Guarded against input/textarea focus so the chat box still works. useEffect(() => { function onKey(e) { const tag = (e.target?.tagName || "").toLowerCase(); if (tag === "input" || tag === "textarea" || e.target?.isContentEditable) return; if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === "j") { e.preventDefault(); setIdx(i => Math.min(i + 1, concepts.length - 1)); } else if (e.key === "ArrowLeft" || e.key === "ArrowUp" || e.key === "k") { e.preventDefault(); setIdx(i => Math.max(i - 1, 0)); } else if (e.key === "Home") { e.preventDefault(); setIdx(0); } else if (e.key === "End") { e.preventDefault(); setIdx(concepts.length - 1); } } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [concepts.length]); if (concepts.length === 0) return null; const c = concepts[idx]; const prev = idx > 0 ? concepts[idx - 1] : null; const next = idx < concepts.length - 1 ? concepts[idx + 1] : null; const style = TYPE_STYLE[c.type] || FALLBACK; const ghUrl = `https://github.com/${repo}/blob/HEAD/${c.file}`; // id → visual reading position, so "depends on" pills show the user-visible number const idToPos = Object.fromEntries(concepts.map((cc, i) => [cc.id, i + 1])); function handleAsk() { onAskAbout?.( c.ask || `Explain "${c.name}" in ${repo} in detail — what does it do, how does it work, and what are the key methods or functions involved?` ); } return (
{/* Top flow strip — every step visible, dependency arrows drawn as lines */}
{concepts.map((cc, i) => { const s = TYPE_STYLE[cc.type] || FALLBACK; const isActive = i === idx; const isDone = i < idx; return ( ); })}
{/* Stage — peripheral prev/next hints flanking the focus card */}
{ // Feed --mx / --my to the .has-cursor-glow pseudo. // Measured against the card box — not viewport — so it works under scroll. const r = e.currentTarget.getBoundingClientRect(); e.currentTarget.style.setProperty("--mx", `${e.clientX - r.left}px`); e.currentTarget.style.setProperty("--my", `${e.clientY - r.top}px`); }} style={{ borderColor: "rgba(255,255,255,0.08)", boxShadow: `0 0 0 1px ${style.border}22, 0 30px 80px rgba(0,0,16,0.6), 0 0 120px ${style.border}22`, // Consumed by .has-cursor-glow "--glow-color": style.border, }} > {/* Dedicated clip layer — absolute overlay sitting on the non-scrolling card box. Holds the accent rail and any future fixed decorations so they respect the card's rounded corners at all times. */}
{/* Inner body owns the scroll context — keeps .ts-card itself non-scrolling so border-radius clipping and the ::after glow layer both stay bound to the visible card box. */}
{String(idx + 1).padStart(2, "0")}/{String(concepts.length).padStart(2, "0")} {idx === 0 && Start here}
{style.tag}

{c.name}

{c.subtitle &&

{c.subtitle}

} {c.description &&

{c.description}

} {c.key_items?.length > 0 && (
{c.key_items.map(item => ( {item} ))}
)} {c.depends_on?.length > 0 && (
Builds on {c.depends_on.map(depId => { const pos = idToPos[depId]; const dep = concepts.find(cc => cc.id === depId); if (!pos || !dep) return null; return ( ); })}
)}
{/* Bottom rail: progress line + clickable dots + keyboard hint */}
1 ? `${(idx / (concepts.length - 1)) * 100}%` : "100%" }} />
{concepts.map((cc, i) => (
navigate · Home/End jump · click a step
); }