{c.name}
{c.subtitle &&{c.subtitle}
} {c.description &&{c.description}
} {c.key_items?.length > 0 && ({item}
))}
/** * 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 (
{c.subtitle}
} {c.description &&{c.description}
} {c.key_items?.length > 0 && ({item}
))}