Spaces:
Sleeping
Sleeping
| /** | |
| * 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 ( | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| <path d="M9.78 12.78a.75.75 0 0 1-1.06 0L4.47 8.53a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 1.06L6.06 8l3.72 3.72a.75.75 0 0 1 0 1.06Z"/> | |
| </svg> | |
| ); | |
| } | |
| function ChevronRight() { | |
| return ( | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| <path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/> | |
| </svg> | |
| ); | |
| } | |
| function ExternalIcon() { | |
| return ( | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style={{ opacity: 0.7 }}> | |
| <path d="M3.75 2a.75.75 0 0 0 0 1.5h5.19L2.22 10.22a.75.75 0 1 0 1.06 1.06L10 4.56v5.19a.75.75 0 0 0 1.5 0v-7a.75.75 0 0 0-.75-.75h-7Z"/> | |
| <path d="M12.5 8.75a.75.75 0 0 0-1.5 0v3.5a.25.25 0 0 1-.25.25h-7.5a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25h3.5a.75.75 0 0 0 0-1.5h-3.5A1.75 1.75 0 0 0 1.5 4.75v7.5A1.75 1.75 0 0 0 3.25 14h7.5a1.75 1.75 0 0 0 1.75-1.75v-3.5Z"/> | |
| </svg> | |
| ); | |
| } | |
| 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 ( | |
| <div | |
| className="ts-root ambient-tint" | |
| // Drive the ambient tint from the active concept's type colour. | |
| // Consumed by .ambient-tint primitive — interpolates via @property. | |
| style={{ "--ambient-tint": style.border }} | |
| > | |
| {/* Top flow strip — every step visible, dependency arrows drawn as lines */} | |
| <div className="ts-flow" role="tablist" aria-label="Tour steps"> | |
| {concepts.map((cc, i) => { | |
| const s = TYPE_STYLE[cc.type] || FALLBACK; | |
| const isActive = i === idx; | |
| const isDone = i < idx; | |
| return ( | |
| <button | |
| key={cc.id} | |
| role="tab" | |
| aria-selected={isActive} | |
| className={`ts-flow-step${isActive ? " is-active" : ""}${isDone ? " is-done" : ""}`} | |
| onClick={() => setIdx(i)} | |
| title={cc.name} | |
| style={isActive ? { borderColor: s.border, color: s.dot } : undefined} | |
| > | |
| <span className="ts-flow-num">{String(i + 1).padStart(2, "0")}</span> | |
| <span className="ts-flow-label">{cc.name}</span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {/* Stage — peripheral prev/next hints flanking the focus card */} | |
| <div className="ts-stage"> | |
| <button | |
| className="ts-nav ts-nav-prev" | |
| onClick={() => setIdx(i => Math.max(i - 1, 0))} | |
| disabled={!prev} | |
| aria-label={prev ? `Previous: ${prev.name}` : "At start"} | |
| > | |
| <ChevronLeft /> | |
| {prev && <span className="ts-nav-peek">{prev.name}</span>} | |
| </button> | |
| <article | |
| key={animKey} | |
| className="ts-card has-cursor-glow" | |
| onMouseMove={(e) => { | |
| // 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. */} | |
| <div className="ts-card-clip"> | |
| <div className="ts-rail-accent" style={{ background: `linear-gradient(180deg, ${style.dot} 0%, ${style.border} 100%)` }} /> | |
| </div> | |
| {/* 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. */} | |
| <div className="ts-card-body"> | |
| <header className="ts-card-head"> | |
| <div className="ts-head-left"> | |
| <span className="ts-num"> | |
| {String(idx + 1).padStart(2, "0")}<span className="ts-num-sep">/</span>{String(concepts.length).padStart(2, "0")} | |
| </span> | |
| {idx === 0 && <span className="ts-entry-badge">Start here</span>} | |
| </div> | |
| <span className="ts-type" style={{ color: style.dot, borderColor: `${style.dot}44` }}>{style.tag}</span> | |
| </header> | |
| <h2 className="ts-title display-serif">{c.name}</h2> | |
| {c.subtitle && <p className="ts-subtitle">{c.subtitle}</p>} | |
| {c.description && <p className="ts-desc">{c.description}</p>} | |
| {c.key_items?.length > 0 && ( | |
| <div className="ts-items"> | |
| {c.key_items.map(item => ( | |
| <code key={item} className="ts-item">{item}</code> | |
| ))} | |
| </div> | |
| )} | |
| {c.depends_on?.length > 0 && ( | |
| <div className="ts-deps"> | |
| <span className="ts-deps-label">Builds on</span> | |
| {c.depends_on.map(depId => { | |
| const pos = idToPos[depId]; | |
| const dep = concepts.find(cc => cc.id === depId); | |
| if (!pos || !dep) return null; | |
| return ( | |
| <button | |
| key={depId} | |
| className="ts-dep-pill" | |
| onClick={() => setIdx(pos - 1)} | |
| title={dep.name} | |
| > | |
| <span className="ts-dep-num">{String(pos).padStart(2, "0")}</span> | |
| {dep.name} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| <footer className="ts-card-foot"> | |
| <a href={ghUrl} target="_blank" rel="noreferrer" className="ts-file" title="Open file on GitHub"> | |
| <svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.55 }} aria-hidden="true"> | |
| <path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 8.75 4.25V1.5Zm6.75.56v2.19c0 .138.112.25.25.25h2.19Z"/> | |
| </svg> | |
| <span className="ts-file-path">{c.file}</span> | |
| <ExternalIcon /> | |
| </a> | |
| <button className="ts-ask" onClick={handleAsk}>Ask about this →</button> | |
| </footer> | |
| </div> | |
| </article> | |
| <button | |
| className="ts-nav ts-nav-next" | |
| onClick={() => setIdx(i => Math.min(i + 1, concepts.length - 1))} | |
| disabled={!next} | |
| aria-label={next ? `Next: ${next.name}` : "At end"} | |
| > | |
| {next && <span className="ts-nav-peek">{next.name}</span>} | |
| <ChevronRight /> | |
| </button> | |
| </div> | |
| {/* Bottom rail: progress line + clickable dots + keyboard hint */} | |
| <div className="ts-rail"> | |
| <div className="ts-rail-track"> | |
| <div | |
| className="ts-rail-fill" | |
| style={{ width: concepts.length > 1 ? `${(idx / (concepts.length - 1)) * 100}%` : "100%" }} | |
| /> | |
| <div className="ts-rail-dots"> | |
| {concepts.map((cc, i) => ( | |
| <button | |
| key={cc.id} | |
| className={`ts-rail-dot${i === idx ? " is-active" : ""}${i < idx ? " is-done" : ""}`} | |
| onClick={() => setIdx(i)} | |
| aria-label={`Go to step ${i + 1}: ${cc.name}`} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="ts-rail-hint"> | |
| <kbd>←</kbd><kbd>→</kbd> navigate · <kbd>Home</kbd>/<kbd>End</kbd> jump · click a step | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |