cartographer / ui /src /components /TourStory.jsx
umanggarg's picture
Canvas/Story product split + URL routing (Tier 1)
8d0110a
/**
* 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 primitiveinterpolates 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>
);
}