Phase 1

The Highlight Bar

01 — Render raw lines

How do you render code in React? The obvious answer is a <pre> tag:

TS
1<pre>{code}</pre>

That works for display. But now try to highlight line 5. You can't — the browser sees one big text node, not individual lines. You'd have to parse the rendered DOM, find where line 5 starts in the text, wrap it in a <span>... it's fragile and slow.

The first decision in CodeTrace is to split the code string on newlines and render each line as its own element. Look at the demo panel — no features are toggled on yet. You're seeing the result of this code:

6 annotations
TSX
1const lines = code.split("\n");
2
3return (
4 <div className="relative rounded-lg overflow-hidden bg-surface">
5 <div style={{ padding: "8px 0" }}>
6 {lines.map((line, i) => (
7 <div key={i} style={{ height: 22 }} className="flex items-center">
8 <span className="w-6 text-right pr-1.5 text-[9px] font-mono text-muted">
9 {i + 1}
10 </span>
11 <pre className="flex-1 whitespace-pre font-mono text-xs">
12 {line}
13 </pre>
14 </div>
15 ))}
16 </div>
17 </div>
18);

Let's pause on why fixed heights matter. Imagine if line heights were dynamic — a long line wraps and becomes 44px while others stay 22px. Now where does line 5 start? You'd have to measure lines 1 through 4 at runtime, summing their actual heights. Every time the container resizes (window resize, panel toggle), those heights change and you'd need to remeasure.

With fixed heights, the answer is always lineIndex × LINE_H. No measurement, no layout queries, no resize observers. The position of every line is a pure math computation. This is what makes the animated highlight bar in Step 4 possible — we can tell it exactly where to go without asking the browser.

Two constants are defined here: LINE_H = 22 (line height) and PAD_Y = 8 (vertical padding). They seem trivial, but they're load-bearing. The real CodeTrace exports them so that overlay components (like the highlight bar) can align to the same vertical rhythm without hardcoding magic numbers.

Look at the demo — plain monospace text with line numbers, nothing else. Every feature in the next five steps is layered on top of this per-line rendering. Without it, none of them work.

02 — Add Shiki tokens

Toggle Syntax Highlighting on and watch the code transform from flat white text into color-coded syntax. Keywords like const and function turn blue. Types like string[] turn green. String values turn orange.

But what actually happened? The same text is there — the characters didn't change. What changed is that each piece of text now has a color. This is what a syntax highlighter does: it reads code, understands the grammar, and assigns colors to each piece based on what it represents in the language.

Shiki (the highlighter we use) calls these pieces tokens. When you give Shiki the string const visited = new Set(), it breaks it into:

TS
1// What Shiki returns for one line:
2[
3 { content: "const", color: "#bb9af7" }, // keyword → purple
4 { content: " ", color: "#a9b1d6" }, // whitespace
5 { content: "visited", color: "#c0caf5" }, // variable → light blue
6 { content: " = ", color: "#a9b1d6" }, // operator
7 { content: "new", color: "#bb9af7" }, // keyword → purple
8 { content: " ", color: "#a9b1d6" }, // whitespace
9 { content: "Set", color: "#2ac3de" }, // type → cyan
10 { content: "()", color: "#a9b1d6" }, // punctuation
11]

Each token knows its content (the text) and its color (what hex color to render it in). Rendering is straightforward — map over tokens and wrap each in a <span>:

TS
1{tokens[i].map((token, j) => (
2 <span key={j} style={{ color: token.color }}>
3 {token.content}
4 </span>
5))}

But loading a syntax highlighter is expensive. Shiki bundles grammar definitions (rules for every language) and theme files (color mappings). Initializing it can take 50–100ms. If you create a new highlighter every time a component mounts, a page with three code viewers triggers three separate loads — 150–300ms of redundant work.

The solution is a lazy singleton — a pattern that creates exactly one instance, only when first needed:

6 annotations
TS
1let highlighterPromise: Promise<HighlighterCore> | null = null;
2
3function getHighlighter(): Promise<HighlighterCore> {
4 if (!highlighterPromise) {
5 highlighterPromise = (async () => {
6 const { createHighlighterCore } = await import("shiki/core");
7
8 const { createJavaScriptRegexEngine } = await import(
9 "shiki/engine/javascript"
10 );
11
12 return createHighlighterCore({
13 themes: [
14 import("shiki/themes/tokyo-night"),
15 import("shiki/themes/github-light"),
16 ],
17 langs: [import("shiki/langs/typescript")],
18 engine: createJavaScriptRegexEngine(),
19 });
20 })();
21 }
22 return highlighterPromise;
23}

The useCodeTokens hook wraps this singleton. It calls getHighlighter(), waits for the instance, then tokenizes the code with the current theme. But here's the important UX detail — what happens while the highlighter is loading?

2 annotations
TSX
1{tokens
2 ? tokens[i].map((token, j) => (
3 <span key={j} style={{ color: token.color }}>{token.content}</span>
4 ))
5 : <span style={{ color: "var(--color-text)" }}>{line}</span>
6}

The component never shows a loading spinner. It shows plain text that silently upgrades to highlighted text when Shiki finishes loading. A brief flash of uncolored code is far less jarring than a spinner replacing the entire block.

Toggle Syntax Highlighting off and on again in the demo. Notice the code doesn't jump or reflow — the same characters are there, only the colors change. That's progressive enhancement in action.

03 — Active line tracking

Toggle Active Line on. Click any line in the demo. The clicked line stays fully visible while everything else fades to lower opacity.

This is the simplest feature a code viewer can add, and the implementation reveals a principle that recurs throughout the series: one prop, everything derived.

Here's the entire API for this feature:

TS
1interface CodeTraceProps {
2 code: string;
3 activeLine?: number | null;
4 // That's it. One number. null = "no line active."
5 // 3 = "line index 3 (the 4th line) is active."
6 // No dimmedLines prop. No highlightedLine prop.
7 // Everything else is computed from this single value.
8}

Inside the line rendering loop, two booleans control the entire visual state:

4 annotations
TSX
1const isActive = activeLine === i;
2const isDimmed = activeLine !== null && !isActive;
3
4<div style={{
5 height: LINE_H,
6 opacity: isDimmed ? 0.55 : 1,
7 transition: "opacity 300ms ease",
8}}>

Why not track dimming as separate state? Because dimming is the absence of being active. If activeLine is set, every other line is dimmed. One source of truth, zero synchronization bugs.

Think of it like a spotlight in a theater. You don't separately track “which seats are in darkness.” You track where the spotlight points; darkness is everything else. If you tracked both — spotlightPosition = 5 and darkSeats = [1,2,3,4,6,7,8,9,10] — they could get out of sync. With one prop, they can't.

Click line 1 in the demo, then line 7, then line 14. Watch the state inspector on the right — activeLine changes, and the visual state flows entirely from that one number.

The real CodeTrace also precomputes lineTops — the cumulative Y offset of each line:

3 annotations
TS
1const lineTops: number[] = [];
2let totalLineHeight = 0;
3
4lineHeights.forEach((height) => {
5 lineTops.push(totalLineHeight);
6 totalLineHeight += height;
7});

With fixed heights, lineTops is trivially [0, 22, 44, 66, ...]. You might wonder why we compute it at all instead of just multiplying. The answer is Phase 2: when interactive lines grow to 44px, the multiplication breaks. The prefix sum handles mixed heights correctly from day one. CodeTrace builds infrastructure for features it doesn't have yet — the lineTops array costs nothing now but saves a rewrite later.

04 — The floating bar

Toggle Highlight Bar on. Click different lines and watch the purple bar glide between positions.

Before you look at the code — how would you implement this? The bar needs to sit behind the active line and move when activeLine changes. The instinct is position: absolute; top: ${activeLine * LINE_H}px. That would work, but it has a performance problem.

When the browser animates top, it triggers a layout recalculation. Every frame of the animation, the browser recomputes the geometry of the bar and everything around it — “this element moved, does anything overlap? Do siblings need to shift?” For a simple code viewer this is fine, but in AlgoFlashcards where multiple code viewers animate simultaneously, layout thrashing becomes visible jank.

CodeTrace animates transform: translateY() instead. Transforms are composited on the GPU — the browser doesn't recalculate layout, it just moves the pixels. Here's the full bar implementation:

6 annotations
TSX
1<motion.div
2 className="absolute left-0 right-0 pointer-events-none"
3 style={{
4 borderLeft: `2px solid ${trackHex}`,
5 backgroundColor: getHighlightBackground(trackHex),
6 top: PAD_Y,
7 height: LINE_H,
8 }}
9 initial={{ y: barTop, opacity: 0 }}
10 animate={{ y: barTop, opacity: 1 }}
11 exit={{ opacity: 0 }}
12 transition={SPRING.snappy}
13/>

The barTop value is simply lineTops[activeLine] — one lookup into the array we computed in Step 3. One number, one spring animation.

Why spring physics instead of CSS transitions? A CSS transition: transform 0.3s ease moves the bar from A to B in exactly 300ms, regardless of distance. Moving 1 line takes the same time as moving 10. A spring has velocity and mass — it overshoots slightly and settles. Moving 10 lines generates more momentum than moving 1, so the bar arrives with more energy and a bigger overshoot.

Try it in the demo: click line 1, then line 14. The bar sweeps with a subtle bounce at the end. Now click line 13. The bar barely moves and settles almost instantly. Same spring, different distance, different feel. This physicality is why Framer Motion's springs are worth the library weight.

The background color uses a trick worth knowing:

2 annotations
TS
1function getHighlightBackground(trackHex: string) {
2 return trackHex.startsWith("#")
3 ? `${trackHex}12`
4 : `color-mix(in srgb, ${trackHex} 12%, transparent)`;
5}

Watch barTop in the state inspector as you click lines. It's always activeLine × 22 because all lines are still the same height. That changes in Phase 2 when interactive lines grow to 44px and the prefix sum kicks in.

05 — Track color system

Toggle Track Color on. Four colored circles appear above the code viewer. Click each one — purple, blue, green, amber. Watch what changes.

Not just the bar. Not just the left border. The line number of the active line also changes color. All three elements update simultaneously from one value.

This is a design decision that seems small but prevents a real class of bugs: one prop threads through all visual elements.

2 annotations
TSX
1<span
2 className="w-6 text-right pr-1.5 text-[9px] font-mono"
3 style={{
4 color: isActive && trackHex ? trackHex : "var(--color-muted)",
5 transition: "color 300ms ease",
6 }}
7>
8 {i + 1}
9</span>

The prop is called trackHex, not highlightColor or accentColor. The naming is intentional — it represents a track, a thematic lane that identifies which algorithm's execution you're following. In AlgoFlashcards, BFS is purple, DFS is blue, sorting is green. When a student sees purple, they know they're tracing BFS without reading a label. The color isn't decorative; it's semantic.

Why not separate props for barColor, lineNumberColor, annotationColor? Think about what happens with three props. A new developer adds a GatedCodeBridge view and sets barColor="#7c3aed" (purple) but forgets annotationColor. The annotation renders in the default gray while everything else is purple. It's a visual inconsistency that's hard to catch in code review because each prop looks reasonable in isolation.

One prop makes divergence impossible:

TS
1// This is all the consumer writes:
2<CodeTrace trackHex="#7c3aed" ... />
3// The bar, the line number, and the annotation
4// are all purple. They can never disagree.

This is a principle you'll see again in Phase 3: instead of adding knobs for every visual element, add one prop that cascades through all of them. Fewer props, fewer bugs, stronger visual coherence.

Click through the four colors in the demo. Watch how the entire visual identity of the code viewer changes with each click — because it all flows from one value.

06 — Composable annotation slot

Toggle Annotation Slot on and click different lines. Some display a text annotation that slides open below — a └─ line in the track color explaining what that code does.

You've been reading annotations throughout this entire lesson. Every time you clicked a highlighted line in these code blocks and saw a └─ explanation slide open, you were seeing the same pattern — content revealed below the active line. The demo has been teaching you about annotations by using annotations.

Here's how the slot works. It's just React children rendered below the active line:

4 annotations
TSX
1<AnimatePresence>
2 {isActive && children != null && (
3 <motion.div
4 className="overflow-hidden pl-8"
5 initial={{ height: 0, opacity: 0 }}
6 animate={{ height: "auto", opacity: 1 }}
7 exit={{ height: 0, opacity: 0 }}
8 transition={SPRING.snappy}
9 >
10 <div className="py-1.5">{children}</div>
11 </motion.div>
12 )}
13</AnimatePresence>

This is the most important architectural decision in CodeTrace. The component provides three things: positioning (below the active line), animation (enter/exit with height), and lifecycle (content only exists when a line is active). The consumer decides what goes inside:

TS
1// Simple text annotation
2<CodeTrace code={CODE} activeLine={5} trackHex="#7c3aed">
3 └─ path: [] → [1]
4</CodeTrace>
5
6// Interactive quiz — same slot, completely different content
7<CodeTrace code={CODE} activeLine={5} trackHex="#7c3aed">
8 <InlinePrediction prompt="What does path become?" options={...} />
9</CodeTrace>
10
11// No annotation at all — works fine without it
12<CodeTrace code={CODE} activeLine={5} trackHex="#7c3aed" />

Why children instead of a render prop like renderAnnotation={(lineIndex) => ...}? Because children is the simplest composition pattern in React. Every developer knows how to use it — you just put content between the tags. A render prop adds an API boundary that requires documentation, type definitions, and examples. children is self-evident.

Why position annotations below the active line instead of in a sidebar? Because the annotation is about that specific line. When you click line 5 and see "└─ Dequeue the front node", your eye moves 22px down from queue.shift() — the code that does the dequeuing. The spatial proximity creates an instant cognitive connection. A sidebar annotation would force your eyes to jump 400px horizontally, breaking the flow.

Click different lines in the demo. Some have annotations, some don't. In Phase 3, LiveCodePanel will put step-synced explanations into this slot, GatedCodeBridge will put section narration, and CodeFill will leave it empty. Three completely different interaction patterns, all using the same slot. The component accommodates all three because it committed to composability over features.

That's the complete CodeTrace foundation:

  • Per-line rendering — each line is an addressable element with a fixed height
  • Lazy singleton highlighting — one Shiki instance, loaded once, shared everywhere
  • Derived state — one activeLine prop, everything else computed from it
  • Transform animationGPU-composited spring physics for smooth bar movement
  • Color threading — one trackHex prop cascading through all visual elements
  • Composable slotchildren rendered below the active line with animated enter/exit

Every decision was made to enable what comes next. In Phase 2, we make these lines clickable.

Phase 2

Making Code Clickable

01 — Line actions

Phase 1 gave us a code viewer that highlights and annotates. But every line was equally clickable — clicking any line moved the highlight. Now AlgoFlashcards needs lines that do things: clicking the sort phase reveals the sorting step, clicking the iteration phase triggers a walkthrough. Some lines are interactive. Most aren't.

How would you design this API? The first instinct is a prop:

TS
1<CodeTrace interactiveLines={[1, 5, 7, 10]} />

That tells the component which lines are clickable. But what happens when they're clicked? You'd need another prop:

TS
1<CodeTrace
2 interactiveLines={[1, 5, 7, 10]}
3 onLineClick={(lineIndex) => handleClick(lineIndex)}
4/>

Now you have two props that are conceptually one thing: “what can this line do?” And it gets worse. What if each line needs a different label for screen readers? What if some lines should be clickable but disabled? You'd end up with:

TS
1<CodeTrace
2 interactiveLines={[1, 5, 7, 10]}
3 onLineClick={(lineIndex) => handleClick(lineIndex)}
4 lineLabels={{ 1: "Sort phase", 5: "Iteration", ... }}
5 disabledLines={[10]}
6/>
7// └─ Four props that are really one concept: "the action for this line."
8// They can get out of sync — a line in interactiveLines but missing
9// from lineLabels, or in disabledLines but not in interactiveLines.

CodeTrace collapses all of this into a single callback:

5 annotations
TSX
1getLineAction?: (
2 lineIndex: number,
3 line: string,
4) => CodeTraceLineAction | null;
5
6interface CodeTraceLineAction {
7 ariaLabel: string;
8
9 onClick: (lineIndex: number) => void;
10
11 disabled?: boolean;
12}

Returning null means “this line is plain text.” Returning an action bundles the click handler, the accessibility label, and the disabled state into one value. No cross-referencing between parallel arrays.

Look at the demo — four lines have badges on their right edge: “Sort phase”, “Iteration”, “Merge branch”, “New interval branch”. Click one. The highlight bar jumps to it. Now click a line without a badge — nothing happens. The callback returned null for that line, so the component ignores the click.

3 annotations
TSX
1<CodeTrace
2 code={CODE}
3 trackHex="#7c3aed"
4 getLineAction={(i, line) =>
5 SECTIONS.has(i)
6 ? { ariaLabel: `Explore line ${i + 1}`, onClick: handleClick }
7 : null
8 }
9/>

The state inspector shows activeLine and interactiveLines. Notice that interactiveLines is static here — [1, 5, 7, 10]. But in the real AlgoFlashcards, getLineAction can return different results based on the current algorithm step. A line might be interactive during the “explore” phase and locked during the “quiz” phase. A callback can express this naturally. A static array can't.

02 — Variable line heights

Before you toggle anything, look at the demo. Every line is 22px tall. Now imagine tapping one of those badge lines on a phone. A 22px touch target is about 5.8mm tall. Apple's Human Interface Guidelines recommend a minimum of 44pt (about 11.6mm). You'd miss half the time on a touchscreen.

Toggle Variable Heights on. Watch lines 2, 6, 8, and 11 — the ones with badges — grow to 44px while everything else stays at 22px.

This creates an immediate problem. In Phase 1, computing the highlight bar's position was trivial: activeLine × LINE_H. That formula assumes every line is the same height. Now line 2 is 44px, line 3 is 22px, and the formula breaks.

Where does line 8 start? It's not 8 × 22 = 176px anymore. You have to add up all the heights above it: 22 + 44 + 22 + 22 + 22 + 44 + 22 + 44 = 242px. This is a prefix sum — the same algorithm you'd use for cumulative totals in a spreadsheet:

TS
1const LINE_H = 22;
2const INTERACTIVE_LINE_H = 44;
3
4const lineHeights = lineActions.map((action) =>
5 action ? INTERACTIVE_LINE_H : LINE_H
6);
7// └─ For each line, check if getLineAction returned an action.
8// If yes → 44px (big click target). If null → 22px (read-only).
9// Result: [22, 44, 22, 22, 22, 44, 22, 44, 22, 22, ...]
10
11const lineTops: number[] = [];
12let totalLineHeight = 0;
13// └─ Running sum. Each line's "top" is the sum of all heights above it.
14
15lineHeights.forEach((height) => {
16 lineTops.push(totalLineHeight);
17 // └─ Line 0 starts at 0px.
18 // Line 1 starts at 22px (one 22px line above).
19 // Line 2 starts at 66px (22 + 44 = 66). Not 44!
20 // The 44px line 1 pushed everything below it down.
21
22 totalLineHeight += height;
23});
24// └─ lineTops = [0, 22, 66, 88, 110, 132, 176, 198, 242, ...]
25// These numbers are NOT evenly spaced anymore.

The highlight bar now animates both position and height:

TS
1<motion.div
2 initial={{ y: barTop, opacity: 0, height: barHeight }}
3 animate={{ y: barTop, opacity: 1, height: barHeight }}
4 transition={SPRING.snappy}
5/>
6// └─ barTop = lineTops[activeLine] — the line's vertical position.
7// barHeight = lineHeights[activeLine] — 22px or 44px.
8// Both are animated by the same spring. When you click from a
9// 22px line to a 44px line, the bar moves AND stretches in one
10// unified motion.

Click between an interactive line (44px) and a plain line (22px) in the demo. Watch the bar stretch and shrink as it moves. Framer Motion interpolates height and y simultaneously through the same spring, so the motion feels unified.

Check the state inspector: lineHeights shows the mixed array, and barHeight changes when you click between types. This is why Phase 1 built the lineTops prefix sum even when all lines were 22px — the infrastructure was designed for this moment.

03 — Keyboard navigation

Toggle Keyboard Nav on. Press Tab to move focus into the code viewer. Focus should land on the first interactive line (line 2, “Sort phase”). Press Tab again — focus jumps to line 6, skipping the non-interactive lines in between. Press Enter or Space to activate it.

This is accessibility. A mouse user clicks wherever they want, but a keyboard user navigates sequentially with Tab. If every line in a 15-line code viewer was focusable, you'd press Tab 15 times to reach the last interactive line. By making only interactive lines focusable, Tab takes you directly from one action to the next.

The first question is: why not use <button> elements? Each line contains a line number <span>, a <pre> block with syntax-highlighted tokens, and a badge <span>. If you wrap all of that in a <button>, you get:

HTML
1<button>
2 <span>2</span>
3 <pre>intervals.sort((a, b) => a[0] - b[0]);</pre>
4 <span>Sort phase</span>
5</button>
6<!-- └─ INVALID HTML. <button> can only contain "phrasing content"
7 (inline elements like <span>, <em>, <strong>).
8 <pre> is "flow content" (block-level). Nesting flow content
9 inside phrasing content is invalid. Screen readers might
10 ignore the nested structure or announce it incorrectly. -->

The solution is ARIA roles on the existing <div>:

5 annotations
TSX
1<div
2 role={isInteractive ? "button" : undefined}
3 aria-label={action?.ariaLabel}
4 aria-disabled={isInteractive ? action.disabled : undefined}
5 tabIndex={isInteractive && !action.disabled ? 0 : undefined}
6 onKeyDown={isInteractive ? handleKeyDown : undefined}
7>

The key handler is deceptively simple, but one line prevents a real bug:

4 annotations
TS
1function handleLineKeyDown(
2 event: KeyboardEvent,
3 lineIndex: number,
4 action: CodeTraceLineAction,
5) {
6 if (action.disabled) return;
7
8 if (event.key === "Enter" || event.key === " ") {
9 event.preventDefault();
10 action.onClick(lineIndex);
11 }
12}

The state inspector shows the tabIndex and role values applied to interactive lines. Try pressing Tab through the demo — focus moves only between the four interactive lines, skipping everything else.

04 — Line decorations and rules

Toggle Decorations on. Look at line 2 (// Sort by start time) — it now has a subtle background highlight. Look at line 5 (the empty line between the sort call and the for loop) — it's faded to near-invisible.

This is a different concern than active-line highlighting. Active-line is about what the user is doing (which line they clicked). Decorations are about what kind of line this is (comment, empty, error). They're static — comment lines are always highlighted, regardless of where the user clicked.

CodeTrace supports two decoration mechanisms. The first is line rules — pattern-matched styling applied to every line:

3 annotations
TS
1interface CodeTraceLineRule {
2 test: string | ((line: string) => boolean);
3
4 style?: CSSProperties;
5
6 tokenColor?: string;
7}

Here are the rules applied in the demo:

4 annotations
TS
1const rules: CodeTraceLineRule[] = [
2 {
3 test: (line) => line.trimStart().startsWith("//"),
4 style: { background: "var(--color-surface-2)", borderRadius: "4px" },
5 },
6 {
7 test: (line) => line.trim() === "",
8 style: { opacity: 0.3 },
9 },
10];

The second mechanism is per-line decorations — a callback for fine-grained overrides:

TS
1getLineDecoration?: (lineIndex: number, line: string) =>
2 CodeTraceLineDecoration | null;
3// └─ Called for each line, like getLineAction. Returns styling
4// overrides or null. The component checks getLineDecoration FIRST
5// (specific), then falls back to lineRules (general).
6// This is the same precedence as CSS: inline styles beat classes.

Why two systems? Because they serve different use cases. Line rules are declarative“all comments should be highlighted” is a global policy. You set it once. Per-line decorations are imperative“highlight line 7 in red because a test just failed” is a specific decision the consumer makes based on runtime state.

The tokenColor override is particularly powerful. Normally, Shiki gives each token its own syntax color — // is gray, Sort is a different gray, etc. With tokenColor: "#ef4444", the entire line turns red. Every token, same color. This lets you override syntax highlighting for emphasis — sometimes “this line is wrong” matters more than “this keyword is blue.”

05 — The variant system

Toggle Panel Variant on. Two buttons appear: default and panel. Click between them and watch the code viewer's visual treatment change.

Think about where CodeTrace appears in AlgoFlashcards. Sometimes it's the main element on screen — inside a lesson page, showing a standalone algorithm. Other times it's embedded inside another component — LiveCodePanel wraps it with a step slider, GatedCodeBridge wraps it with section buttons.

You could create separate components for each context: CodeTrace, CodeTracePanel, CodeTraceEmbed. But now you have three copies of the same rendering logic. When you fix a bug in one, you need to fix it in all three. When you add keyboard navigation, it goes in three places. This is the fork trap — copies that start identical and slowly drift apart until they're unmaintainable.

CodeTrace uses a single variant prop instead:

TS
1type CodeTraceVariant = "default" | "panel";
2// └─ Two visual modes. Same component, same logic, different chrome.

Here's what changes between them:

TS
1// Container styling
2<div style={{
3 background: variant === "panel"
4 ? "var(--color-surface-2)" // darker, recessive
5 : "var(--color-surface)", // lighter, prominent
6 // └─ Panel variant has a darker background because it's inside
7 // another component that provides the visual context.
8 // Default variant has a lighter background because it IS
9 // the visual context — it stands alone.
10
11 boxShadow: variant === "default"
12 ? "0 0 0 1px var(--color-surface-2)" // subtle ring
13 : "none",
14 // └─ Default gets a ring shadow — visual weight for a standalone
15 // element. Panel gets nothing — the wrapper provides borders.
16}}>
TS
1// Code font size
2<pre className={
3 variant === "panel" ? "text-xs" : "text-[11px]"
4}>
5// └─ Panel mode: 12px (text-xs). The wrapper provides context,
6// so the code can be slightly larger and more readable.
7// Default mode: 11px. Standalone code is smaller because it
8// needs to share the page with other content.
TS
1// Line numbers
2const shouldShowLineNumbers = showLineNumbers ?? (variant !== "panel");
3// └─ Panel mode hides line numbers by default. The wrapping
4// component often provides its own UI for line references.
5// But the consumer can override: showLineNumbers={true}
6// forces them on even in panel mode. The variant sets a
7// DEFAULT, not a rule.

Switch between the two variants in the demo. Default has a ring shadow and lighter background — it demands attention. Panel has a flat border and darker background — it recedes into its wrapper. Same code, same features, different visual identity.

One component, one renderer, two visual modes. No fork, no drift. When you add a feature to CodeTrace, both variants get it automatically.

06 — One renderer to rule them all

Before this phase existed, AlgoFlashcards had three separate code renderers:

1CodeTrace → standalone code display
2CodeBridgePanel → exploration UI with its own rendering loop
3MDX inline code → inline code blocks with separate highlighting

Each had its own line rendering loop, its own Shiki integration, its own handling of active lines. When Shiki was upgraded, three places needed updating. When the highlight bar got spring physics, only CodeTrace got it — CodeBridgePanel still used CSS transitions. When a bug was fixed in one renderer, the fix was manually copied (or forgotten) to the others.

The consolidation refactor deleted CodeBridgePanel entirely. Every feature from this phase — getLineAction, lineRules, getLineDecoration, variant — was added specifically to make one component serve all three use cases:

Before:                          After:

CodeTrace ───── display          CodeTrace ───── display
                                          │         (variant: "default")
CodeBridgePanel ── exploration            ├── exploration
        (separate render)                 │     (variant: "panel", getLineAction)
                                          │
MDX code ── inline display                └── inline display
        (yet another render)                    (variant: "panel", no line numbers)

The lesson: resist the urge to fork a component when what you really need is an extension point. getLineAction was the extension point that eliminated the second renderer. variant was the extension point that eliminated the third. Together, they reduced three code paths to one.

But extension points have a limit. Each one adds a branch to the component's internal logic — another if check, another prop to document, another combination to test. At some point (and you'll see exactly where in Phase 4), the branches multiply until the component is spending more time checking which features are enabled than actually rendering code. That's when composition fails and a rebuild becomes necessary.

Try toggling features off one at a time in the demo. Notice how each feature is independent — you can have decorations without variable heights, or keyboard navigation without decorations. This orthogonality is what makes extension points work: they compose without interfering. When they stop composing cleanly, you've hit the limit.

In Phase 3, we'll see how other components wrap this single renderer — adding state management, step progression, and gating logic on top of CodeTrace rather than inside it.

Phase 3

Wrapping the Primitive

01 — The wrapper spectrum

Before we build anything, here's a framework for evaluating every wrapper in this phase — and for deciding whether to wrap or rebuild in your own projects.

Composition sits on a spectrum:

Thin wrapper computes a prop and passes it down. No internal state. If the wrapped component changes its API, you update the wrapper in 5 minutes. Example: a function that maps "step 3" to "highlight line 7".

State wrapper manages state that drives the wrapped component. Has useState or useReducer, tracks user actions, computes props from that state. It's still a wrapper — it doesn't touch the rendering. Example: tracking which sections a user has explored.

Divergent wrapper can't express what it needs through the wrapped component's API. Reaches for the wrapped component's dependencies directly (in our case, Shiki) and builds rendering the original doesn't support. Example: replacing parts of Shiki tokens with interactive dropdowns.

Full rebuild the wrapper is gone. You build a new component that shares concepts but not code with the original. Example: InlineCodeBridge in Phase 4.

As you read the next five steps, ask: where does each wrapper sit on this spectrum? And what would push it further right?

Toggle LiveCodePanel in the demo. You'll see a BFS code viewer with a step slider above it — the first wrapper pattern.

CodeTrace (275 LOC) ← display + interactivity
  ├─ LiveCodePanel  (70 LOC) ← step → line mapping
  ├─ GatedCodeBridge (100 LOC) ← viewed-sections tracking
  └─ CodeFill (135 LOC) ← placeholder tokenization + answer state

Each wrapper is small because CodeTrace does the heavy lifting. The wrappers add coordination — state management, interaction patterns, progression logic — not rendering. But one of these three is already starting to diverge from CodeTrace. By Step 5, you'll be able to identify which one and why.

02 — LiveCodePanel: step-synced code

Drag the step slider in the demo. Watch the highlight bar — it doesn't slide one line at a time. It jumps from line 1 to line 2, then to line 5, then to line 6. The mapping isn't sequential.

This is the core insight behind LiveCodePanel: a step index and a line number are different things. In an algorithm lesson, step 3 might explain the loop condition on line 5, while step 4 explains the shift() call on line 6. Step 5 might jump backwards to line 2 to revisit the visited set. The relationship between “where am I in the lesson” and “which line matters” is arbitrary — it depends on the teaching narrative, not the code order.

The lineMap array encodes this relationship:

3 annotations
TSX
1interface LiveCodePanelProps {
2 code: string;
3 lineMap: (number | null)[];
4 step: number;
5 trackHex: string;
6 annotations?: (string | null)[];
7}

The entire render logic of LiveCodePanel is derivation — pure computation, no state:

4 annotations
TSX
1const activeLine = lineMap[Math.min(step, lineMap.length - 1)] ?? null;
2
3const annotation = annotations?.[step] ?? null;
4
5<CodeTrace
6 code={code}
7 activeLine={activeLine}
8 trackHex={trackHex}
9>
10 {annotation && <span>└─ {annotation}</span>}
11</CodeTrace>

No useState. No useEffect. No event handlers inside the component. The parent controls step, and LiveCodePanel translates that into CodeTrace props through pure computation. On the composition spectrum, this is all the way to the left — the thinnest possible wrapper.

Toggle Step Sync on. Now annotations appear below the active line as you drag the slider — “Define the function”, “Initialize visited set”, “Enter the loop.” Watch the state inspector: activeLine, annotation, and step all update, but only step is actual state. The other two are derived from it.

This is what makes thin wrappers powerful: they have no opinions about how step changes. A slider drives it here. In production, an animation timeline drives it. A quiz completion drives it. LiveCodePanel doesn't know or care — it just maps step line CodeTrace props.

03 — Delayed reveal with codeDelay

Toggle Code Delay on. Set the codeDelay value to 2, then drag the step slider to 0. The code viewer disappears, replaced by a “Hidden until step ≥ 2” message. Now drag past step 2 — the code viewer slides back in from the right.

Why would you ever hide a code viewer? Because showing code too early anchors the student in syntax before they understand the concept.

In AlgoFlashcards, some lessons start with visual explanations — array diagrams rearranging, tree nodes lighting up, a graph being traversed step by step. If the code panel is visible from the start, the student's eye is drawn to queue.shift() before they even understand what a queue is. The code becomes noise, not signal.

codeDelay tells LiveCodePanel: “don't show the code until the student has reached this step.” The implementation is one derived boolean:

5 annotations
TSX
1const isVisible = step >= codeDelay;
2
3<AnimatePresence>
4 {isVisible && (
5 <motion.div
6 initial={{ opacity: 0, x: 12 }}
7 animate={{ opacity: 1, x: 0 }}
8 exit={{ opacity: 0, x: 12 }}
9 transition={SPRING.gentle}
10 >
11 <CodeTrace ... />
12 </motion.div>
13 )}
14</AnimatePresence>

The entrance slides from right (x: 12 → 0) rather than fading in place. This is a deliberate choice: a slide says “new content arrived.” A fade says “content was here but hidden.” The cognitive difference matters — the slide sets the expectation that the code panel is a new addition to the screen, not something that was always there but invisible.

Try different codeDelay values and scrub the slider. The state inspector shows the visible flag toggling. Notice that LiveCodePanel is still stateless — isVisible is derived from step >= codeDelay, not tracked with useState. The wrapper added a feature (delayed visibility) without adding a single piece of internal state. That's the advantage of thin wrappers: features compose because they're all derived from the same input.

04 — GatedCodeBridge: exploration gating

Toggle GatedCodeBridge in the demo. The code viewer shows the same BFS algorithm, but below it you'll see four section buttons: Init, Loop, Visit, Explore. Click “Init” — the highlight bar jumps to line 2, and an annotation appears: “Set up visited tracking and queue with start node.”

Now look at the “Complete” button at the bottom. It says “Explore all 4 sections first” and it's grayed out. Click through all four section buttons. Once you've visited every section, the Complete button lights up.

This is exploration gating: the student must examine every section of the algorithm before they can proceed. It's not a quiz — there are no wrong answers. It's a guarantee that the student at least looked at each part before claiming they understood it.

The state model has a subtle design choice. Let's walk through it:

3 annotations
TSX
1const viewedRef = useRef<Set<number>>(new Set());
2
3const [viewedCount, setViewedCount] = useState(0);
4
5const allViewed = viewedCount >= sections.length;

Why this split? Because the set and the count serve different purposes:

4 annotations
TSX
1const handleToggle = (line: number) => {
2 setBridgeLine(bridgeLine === line ? null : line);
3
4 if (!viewedRef.current.has(line)) {
5 viewedRef.current.add(line);
6 setViewedCount(viewedRef.current.size);
7 }
8};

The ref absorbs repeated visits without re-rendering. The state updates only when the progress actually changes. This is a micro-optimization that matters in teaching apps where students repeatedly click back and forth between sections — each revisit would be a wasted re-render without the ref guard.

On the composition spectrum, GatedCodeBridge is a state wrapper: it manages bridgeLine and viewedCount state that drives CodeTrace through its existing API. It doesn't touch CodeTrace's internals. The annotation slot from Phase 1 now powers interactive narration:

2 annotations
TSX
1<CodeTrace code={code} activeLine={bridgeLine} trackHex={trackHex}>
2 {activeSection && <span>{activeSection.narration}</span>}
3</CodeTrace>

Watch the state inspector as you click sections: viewed shows the progress counter, and allViewed flips when you've explored everything. Click a section you already visited — notice that viewed doesn't change. The ref absorbed the duplicate.

05 — CodeFill: where composition starts to strain

Toggle CodeFill in the demo. You'll see three dashed blanks: ___data structure___, ___queue operation___, and ___visited check___. Click each blank to open a dropdown of options. Pick answers and hit Check.

Try getting one wrong on purpose — select “Array” instead of “Set” for the data structure, then click Check. The blank turns red. In the real AlgoFlashcards CodeFill, wrong answers include explanations: "Array works but .includes() is O(n) — Set gives O(1) with .has()." The WHY behind the wrong answer is the teaching moment.

Now, here's where things get architecturally interesting. The demo shows a simplified version with manually assembled layout — hardcoded <span> elements and <FillBlank> components. The real CodeFill does something more sophisticated that we can't fully demonstrate here: placeholder tokenization.

The template code contains markers embedded in real code:

TS
1const templateCode = `const visited = new ___ds___();
2const node = queue___op___;
3if (visited___check___) continue;`;

When Shiki tokenizes this string, it treats the markers as code. The string new ___ds___() becomes tokens like:

TS
1[
2 { content: "new", color: "#bb9af7" }, // keyword
3 { content: " ", color: "#a9b1d6" }, // space
4 { content: "___ds___", color: "#c0caf5" }, // (Shiki thinks it's a variable)
5 { content: "(", color: "#a9b1d6" }, // punctuation
6 { content: ")", color: "#a9b1d6" }, // punctuation
7]

The splitTokensAroundPlaceholders function walks this token stream, finds the ___id___ markers, and replaces them with interactive <BlankSlot> components — while preserving syntax highlighting on everything around them:

6 annotations
TS
1function splitTokensAroundPlaceholders(
2 tokens: ThemedToken[],
3 placeholderIds: string[],
4): TokenOrBlank[] {
5 const result: TokenOrBlank[] = [];
6
7 for (const token of tokens) {
8 const match = placeholderIds.find((id) =>
9 token.content.includes(`___${id}___`)
10 );
11
12 if (!match) {
13 result.push(token);
14 continue;
15 }
16
17 const marker = `___${match}___`;
18 const idx = token.content.indexOf(marker);
19 const before = token.content.slice(0, idx);
20 const after = token.content.slice(idx + marker.length);
21
22 if (before) result.push({ ...token, content: before });
23 result.push({ type: "blank", id: match });
24 if (after) result.push({ ...token, content: after });
25 }
26
27 return result;
28}

This is where CodeFill sits on the composition spectrum: it's a divergent wrapper. It doesn't use CodeTrace at all — it calls Shiki directly and builds its own rendering. Why? Because CodeTrace renders whole lines. CodeFill needs to render parts of a line differently — replace a substring inside a syntax token with an interactive dropdown. That's not something you can express through CodeTrace's API.

The compound component pattern uses React Context to share state between the root and the blanks:

4 annotations
TSX
1const CodeFillCtx = createContext<CodeFillState | null>(null);
2
3<CodeFillCtx.Provider value={state}>
4 {lines.map((lineTokens, i) => (
5 <div key={i} style={{ height: LINE_H }}>
6 {lineTokens.map((tok) =>
7 tok.type === "blank"
8 ? <BlankSlot key={tok.id} id={tok.id} />
9 : <span key={tok.content} style={{ color: tok.color }}>{tok.content}</span>
10 )}
11 </div>
12 ))}
13</CodeFillCtx.Provider>

Each BlankSlot reads the shared answer state from context, renders a dropdown when clicked, and writes back the selected option. The root component handles submission, validation, and the completed-code reveal. No prop drilling — every blank reads from the same context.

06 — When to wrap, when to rebuild

Toggle Pattern Matrix in the demo. The comparison table crystallizes the three patterns side by side.

Let's map each wrapper back to the spectrum from Step 1:

LiveCodePanel → Thin wrapper (leftmost). Zero internal state. Pure computation: step → activeLine → CodeTrace props. If CodeTrace changes its API, LiveCodePanel updates in minutes. It could be a plain function instead of a component.

GatedCodeBridge → State wrapper (center-left). Manages bridgeLine, viewedRef, and viewedCount. Drives CodeTrace through its public API. Uses the annotation slot for section narration. Still wrapping — it doesn't reach into CodeTrace's internals.

CodeFill → Divergent wrapper (center-right). Bypasses CodeTrace entirely. Calls Shiki directly to tokenize code, then splits tokens around placeholders. It needs sub-line-level control — replacing a substring inside a token with a dropdown. CodeTrace's API doesn't offer this, so CodeFill goes around it.

The pattern:

LiveCodePanelGatedCodeBridgeCodeFill
Wraps CodeTrace?YesYesNo — uses Shiki directly
State modelStatelessViewed-set + completionAnswers map + submitted
Uses annotation slotYesYesNo
Shiki integrationVia CodeTraceVia CodeTraceDirect

Notice the diagonal: as wrappers move right on the spectrum, they stop using more of CodeTrace's features. LiveCodePanel uses everything. GatedCodeBridge uses most things. CodeFill uses nothing — it rebuilt from Shiki up.

When is this okay? When the wrapper needs to render differently than the wrapped component. CodeFill needs inline dropdowns inside syntax tokens. Extending CodeTrace to support this would mean adding token-splitting logic, blank state management, and dropdown rendering to a component whose job is “display code with a highlight bar.” Every other consumer of CodeTrace (LiveCodePanel, GatedCodeBridge, standalone uses) would pay the complexity cost of features they don't use.

The divergent wrapper pays a different cost: code duplication. CodeFill re-implements some of CodeTrace's rendering (line layout, Shiki theming). But this duplication is bounded — it only exists in CodeFill, not in every CodeTrace consumer. It's better to duplicate 40 lines in one wrapper than to add 40 lines of optional complexity to a component used everywhere.

In Phase 4, we'll see what happens when even divergence isn't enough. InlineCodeBridge needs annotations between arbitrary lines, chunk-based reveal with replacement, and a 5-beat animation choreography. It's a full rebuild — the rightmost point on the spectrum. The question isn't whether rebuilding is wasteful. It's whether the contortion of not rebuilding would be worse.

Phase 4

Breaking Free

01 — Why not wrap CodeTrace?

Toggle Unified Model on. Look at the kind badges on the right edge of each line: signature, pending, active-label, close.

In Phase 3, every wrapper used CodeTrace's rendering. LiveCodePanel passed props down. GatedCodeBridge orchestrated from outside. Even CodeFill, which bypassed CodeTrace, still rendered “code line → code line → code line” — a linear sequence of syntax-highlighted text.

InlineCodeBridge needs something different. It renders one continuous code body where line numbers flow from 1 to N like a real editor — but annotations, step panels, and reveal notes are interleaved between arbitrary lines. Not below the active line. Not in a sidebar. Between lines 6 and 7. Between lines 12 and 13. At multiple positions simultaneously.

CodeTrace's annotation slot puts content below activeLine. That's one slot, one position, one piece of content. InlineCodeBridge needs:

  • Active step panels — a question card between lines 6 and 7 asking the student to solve the next chunk
  • Reveal notes"— The move: ..." appearing between the last line of chunk 1 and the first line of chunk 2, explaining the insight
  • Pending placeholders — dimmed // step 2 — pending lines that show where future code will appear, then get replaced when the student solves them
  • Replacement labels"REPLACING STEP 3" when a later chunk upgrades earlier code in place

None of these fit the “one slot below active line” model. You could try to hack it — use activeLine to point at the insertion point, render a giant children blob that contains all the interleaved content. But then you lose independent animation for each piece. You can't have the reveal note slide in from the left while the step panel slides up simultaneously.

The solution is a unified line model where annotations are first-class lines in the render order:

5 annotations
TS
1type LineKind =
2 | { type: "signature" }
3
4 | { type: "close" }
5
6 | { type: "committed"; chunkIdx: number }
7
8 | { type: "pending" }
9
10 | { type: "active-label"; chunkIdx: number }

The renderer doesn't distinguish between “real code” and “interleaved UI” at the layout level. They're all UnifiedLine entries in a flat array. Each gets a line number, a code display, and a kind badge. The kind determines styling — committed lines are at 85% opacity, pending lines at 25%, active labels in the track color.

Click “Resolve Step 1” in the demo (if Chunk Reveal is enabled) and watch the kind badges change. pending lines become committed. The active-label moves to the next chunk. The unified model recalculates, and the renderer just iterates the new array.

02 — The reducer

Toggle Reducer on. Click “Resolve Step 1” and watch the state inspector. Three values update simultaneously: revealed goes from 0/3 to 1/3, activeIdx advances from 0 to 1, and correct increments to 1.

One click, three coordinated state changes. This is why InlineCodeBridge uses useReducer instead of useState.

With useState, resolving a chunk would look like this:

TS
1// THREE separate state updates:
2setRevealed(prev => new Set([...prev, idx]));
3setActiveIdx(idx + 1 < total ? idx + 1 : null);
4setCorrect(prev => prev + 1);

With useReducer, the entire state transition is one atomic operation:

5 annotations
TS
1interface State {
2 revealed: Set<number>;
3 activeIdx: number | null;
4 correct: number;
5}
6
7type Event =
8 | { type: "resolve"; idx: number; total: number }
9 | { type: "reset" };

The reducer function processes events and returns the entire next state:

5 annotations
TS
1function reduce(state: State, event: Event): State {
2 switch (event.type) {
3 case "resolve": {
4 if (state.revealed.has(event.idx)) return state;
5
6 const next = new Set(state.revealed);
7 next.add(event.idx);
8
9 let nextActive: number | null = null;
10 for (let j = 0; j < event.total; j++) {
11 if (!next.has(j)) { nextActive = j; break; }
12 }
13
14 return {
15 revealed: next,
16 activeIdx: nextActive,
17 correct: state.correct + 1,
18 };
19 }
20
21 case "reset":
22 return initialState();
23 }
24}

Why does this matter for a code viewer? Because the rendering depends on ALL three fields simultaneously. The buildUnifiedCode function (Step 3) reads revealed to know which chunks to show as code, reads activeIdx to know which chunk gets the step panel, and the completion display reads correct to know when to show “All chunks resolved.” If these values are ever inconsistent — even for one frame — the UI glitches.

Click “Resolve” three times in the demo to complete the function. Watch all three values change atomically each time. Then click “Reset” — they all return to initial values from a single dispatch.

03 — Chunk-based reveal

Toggle Chunk Reveal on. Click “Resolve Step 1” — watch the pending placeholder on the left disappear and five lines of real code appear in its place.

The code body isn't a static string. It's rebuilt from scratch every time the reducer state changes. Here's the function that constructs it:

8 annotations
TS
1function buildUnifiedCode(
2 chunks: Chunk[],
3 revealed: Set<number>,
4 activeIdx: number | null,
5): UnifiedLine[] {
6 const lines: UnifiedLine[] = [];
7
8 lines.push({
9 text: FUNCTION_SIGNATURE,
10 kind: { type: "signature" },
11 });
12
13 for (let i = 0; i < chunks.length; i++) {
14 const chunk = chunks[i];
15
16 if (chunk.replaces !== undefined) continue;
17
18 const isRevealed = revealed.has(i);
19 const isActive = i === activeIdx;
20
21 if (isRevealed) {
22 for (const codeLine of chunk.revealCode.split("\n")) {
23 lines.push({
24 text: codeLine,
25 kind: { type: "committed", chunkIdx: i },
26 });
27 }
28
29 } else if (isActive) {
30 lines.push({
31 text: ` // ${chunk.stepNumber} — ${chunk.title.toLowerCase()}`,
32 kind: { type: "active-label", chunkIdx: i },
33 });
34
35 } else {
36 lines.push({
37 text: ` // step ${chunk.stepNumber} — pending`,
38 kind: { type: "pending" },
39 });
40 }
41 }
42
43 lines.push({
44 text: FUNCTION_CLOSE,
45 kind: { type: "close" },
46 });
47 return lines;
48}

The function reads top-to-bottom as: signature chunk slots close brace. Each chunk slot renders one of three ways depending on state:

  • Pending one dim placeholder line (// step 2 — pending at 25% opacity)
  • Active a step label + question panel (// 1 — count frequencies with a purple card below)
  • Committed all code lines from revealCode + a reveal note at the bottom

The function body grows as the student resolves chunks. Initially it's about 5 lines (signature, 3 placeholders, close brace). After resolving chunk 1, it jumps to ~9 lines (signature, 5 committed lines, 2 placeholders, close brace). After all three chunks, it's 22+ lines of real code.

Check totalLines in the state inspector as you resolve chunks — watch it grow.

This is a fundamentally different rendering model than CodeTrace. CodeTrace takes a static code string and highlights a line within it. The code exists from the start. InlineCodeBridge takes a set of chunks and constructs the code from them. The function body doesn't exist until the student builds it, step by step.

04 — Motion choreography

Toggle Motion on. Click Reset first so all chunks are pending. Now click “Resolve Step 1” — watch the animation.

It happens fast. Now toggle the 0.25× slow-motion button (top right of the controls) and click “Resolve Step 2.” At quarter speed, you can see every beat of the animation sequence.

The resolve animation follows a 5-beat storyboard:

1t = 0ms SHELL — active panel repositions (CSS layout animation)
2t = 0ms CARD — old step content slides up, new slides in from below
3t = 80ms CODE — new code lines begin height-expanding, one by one
4t = 320ms NOTE — reveal note slides in from the left margin
5t ≈ 600ms settled — all animations complete

Beat 3 (CODE) is the signature move. Each committed code line starts at height: 0 and springs open to LINE_H, with a staggered delay:

4 annotations
TS
1const CHOREO = {
2 codeStart: 0.08,
3 codeStep: 0.04,
4 noteDelay: 0.32,
5 noteSlide: 12,
6};

Each committed line uses these values to compute its animation:

3 annotations
TSX
1<motion.div
2 initial={{ height: 0, opacity: 0 }}
3 animate={{ height: LINE_H, opacity: 0.85 }}
4 transition={{
5 ...SPRING.snappy,
6 delay: CHOREO.codeStart + lineIndex * CHOREO.codeStep,
7 }}
8/>

At full speed, this cascade is a subtle “unfurl” — you perceive the code appearing all at once, but with a pleasant directional flow. At 0.25× slow motion, you can see each line individually spring open, with the characteristic overshoot-and-settle of spring physics.

Direction discipline: all vertical content enters by growing downward (height expansion). Horizontal content slides from the left margin. Exiting content fades in place. These constraints prevent “where did that come from?” confusion — the answer is always the same direction.

The slow-motion toggle multiplies all delays by 4×. It's the most useful tool for debugging animation choreography — timing bugs that are invisible at full speed become obvious at quarter speed. Try resolving each chunk at 0.25× and studying the beat sequence.

05 — The replaces mechanism

Toggle Replaces on. Click Reset to start fresh. Now resolve steps 1, 2, and 3 normally — the function builds up as before.

After step 3 (“Sweep Top-K”) commits, something new happens. A fourth step appears — but it looks different. The active label is amber instead of purple. The resolve button says "Replace Step 3" instead of “Resolve Step 4.”

This is the replaces mechanism. Some chunks don't add new code to the function — they upgrade earlier code in place:

4 annotations
TS
1interface Chunk {
2 id: string;
3 stepNumber: number;
4 title: string;
5 revealCode: string;
6 revealNote: string;
7
8 replaces?: number;
9}

Here's what's happening. The original Step 3 sweeps buckets with result.push(...buckets[i]). This has a subtle bug: if a bucket has 3 elements and you only need 1 more to reach k, you overshoot — result.length ends up as k + 2. The replaces chunk fixes this with an inner loop and early return that stops at exactly k.

The buildUnifiedCode function handles this with a replacer map:

5 annotations
TS
1const replacerMap = new Map<number, number>();
2for (let i = 0; i < chunks.length; i++) {
3 if (chunks[i].replaces !== undefined) {
4 replacerMap.set(chunks[i].replaces!, i);
5 }
6}
7
8if (chunk.replaces !== undefined) continue;
9
10const replacerIdx = replacerMap.get(i);
11const replacerRevealed = replacerIdx !== undefined
12 && revealed.has(replacerIdx);
13
14if (replacerRevealed) {
15 // Show the REPLACER's code at the ORIGINAL's position.
16 // Step 3's code vanishes. Step 4's code appears in its place.
17} else if (isRevealed) {
18 // Show the original code (step 3's version).
19 // If the replacer is active, also show its label below.
20}

Click “Replace Step 3” now. Watch the code swap. Step 3's five lines disappear and step 4's seven lines appear in the same position. The reveal note changes from purple "— The move:" to amber "— The upgrade:" — a visual signal that this was a refinement, not an addition.

This powers a real educational pattern in AlgoFlashcards: the student first writes a working-but-naive solution, then upgrades specific parts. The code viewer reflects this — the function's structure stays stable while individual implementations get refined. The student sees refactoring happen visually, not as a diff in a PR review.

Check the state inspector — the replaces field tracks the replacement status. After replacing, revealed shows 4/4 chunks complete even though the function only has 3 slots — because the replacer took over an existing slot.

06 — Lessons for your own primitives

Toggle Full Demo on. All features are active. Resolve all four chunks — including the replacement — to see the complete upgraded function.

This series traced a component through four architectural phases. Let's map the journey:

Phase 1 — The Atom. CodeTrace: 275 lines of pure rendering. Split code into lines, highlight with Shiki, track one active line, animate a bar with spring physics, thread color through all elements, provide a composable annotation slot. The smallest useful unit — it does one thing well.

Phase 2 — Extension Points. Instead of forking CodeTrace when new use cases appeared, we added extension points: getLineAction for clickable lines, lineRules for pattern-matched decorations, variant for different visual contexts. One component grew to serve three use cases without forking.

Phase 3 — Composition. Three wrappers added state management on top of CodeTrace. LiveCodePanel: thin wrapper, zero state. GatedCodeBridge: state wrapper, tracks exploration progress. CodeFill: divergent wrapper, bypasses CodeTrace to work directly with Shiki tokens. Each wrapper is small because CodeTrace handles the rendering.

Phase 4 — The Rebuild. InlineCodeBridge: 1000+ lines. A new component that shares concepts with CodeTrace (per-line rendering, spring animation, Shiki tokens) but not code. New architecture: unified line model, reducer for atomic state transitions, chunk-based reveal with dynamic code construction, 5-beat motion choreography, and in-place code replacement.

The pattern across all four phases: compose for as long as you can, then rebuild when the architecture hits its limits.

The signal isn't complexity — it's contortion. When you're writing workarounds to force content into a slot that wasn't designed for it, the workaround cost exceeds the rebuild cost. InlineCodeBridge needed annotations between arbitrary lines. CodeTrace's slot model only supports one annotation below one active line. You could hack it — use a virtual scroll with invisible insertion points, or override the render loop with a custom line factory — but each hack fights the component's design. At some point, a clean rebuild is cheaper than accumulated hacks.

CodeTrace survived intact through all four phases. It still powers LiveCodePanel and GatedCodeBridge in production today. InlineCodeBridge didn't replace it — it went around it. Both coexist in the same codebase, used in different contexts. The lesson isn't “rebuild everything.” It's “know when composition has reached its ceiling, and have the courage to build a new floor.”

1234
Features
1
function bfs(graph: Map<string, string[]>, start: string) {
2
  const visited = new Set<string>();
3
  const queue: string[] = [start];
4
5
  while (queue.length > 0) {
6
    const node = queue.shift()!;
7
    if (visited.has(node)) continue;
8
    visited.add(node);
9
10
    for (const neighbor of graph.get(node) ?? []) {
11
      queue.push(neighbor);
12
    }
13
  }
14
  return visited;
15
}
CodeTrace
lineCount:15