Merge feat/staffml-visual-questions into dev

Visual-question infrastructure + practice-page layout restructure,
shipped together because the redesign exists specifically to make
room for visuals in the reading flow (context -> diagram -> ask).

Three commits:
  fb2a57e12 - Schema (Pydantic + LinkML) gains optional Visual model
              with path-traversal guard and a11y-required alt.
              Legacy exporter passes metadata to the summary bundle;
              vault build --legacy-json mirrors SVG assets from
              interviews/vault/visuals/<track>/ to
              interviews/staffml/public/question-visuals/<track>/.
              QuestionVisual React component renders the diagram
              between scenario and answer with graceful fallback.

  cb0b7ea30 - Practice-page layout restructure per 4-reviewer UX
              pass (Emma beginner, David power user, Chip Huyen
              practitioner; Soumith's agent bounced). Left column
              is now the read-answer-reveal flow; right column is
              the tools panel. Four safeguards folded in by default:
              (1) sticky Your-task callout that stays visible while
              the user scrolls to type (David's long-scenario fix),
              (2) HardwareRef defaultOpen + NapkinCalc defaultClosed
              per Chip's consulted-vs-invoked distinction,
              (3) submit-gradient guard -- a Think-longer? confirm
              fires on (elapsed<15s && chars<50) reveals, and
              self-calibrates off once the user has demonstrated
              normal deliberation (Chip's prediction was the subtle
              headline risk), (4) a Stuck?-ask-the-Interviewer nudge
              below the textarea (Emma's scaffolding request).
              modelAnswerRef + scrollIntoView keeps the post-reveal
              comparison on-screen.

  1898fe8c9 - First visual exemplar: cloud Ring AllReduce on 4 ranks.
              SVG follows .claude/rules/svg-style.md, YAML wires the
              visual block and pairs it with a question asking for
              the full AllReduce time. AUTHORING.md documents when a
              visual earns its place, the workflow, accessibility
              requirements, and anti-patterns.
This commit is contained in:
Vijay Janapa Reddi
2026-04-24 16:11:24 -04:00
11 changed files with 990 additions and 327 deletions

View File

@@ -9,3 +9,8 @@ node_modules/
worker/.wrangler/
worker/wrangler.toml.bak
tsconfig.tsbuildinfo
# Build output — `vault build --legacy-json` mirrors SVG assets from
# interviews/vault/visuals/ into public/question-visuals/. The vault
# is the source of truth; don't commit the copy.
public/question-visuals/

View File

@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
Target, CheckCircle2, XCircle, Terminal, SkipForward,
Calculator, SlidersHorizontal, X
Calculator, SlidersHorizontal, X, Sparkles, Lightbulb, Clock
} from "lucide-react";
import clsx from "clsx";
import HardwareRef from "@/components/HardwareRef";
@@ -37,6 +37,7 @@ import { Calendar, ArrowLeft, Flag, LinkIcon } from "lucide-react";
import Link from "next/link";
import { buildReportUrl } from "@/lib/issue-url";
import QuestionFeedback from "@/components/QuestionFeedback";
import QuestionVisual from "@/components/QuestionVisual";
import { track } from "@/lib/analytics";
/**
@@ -169,6 +170,22 @@ function PracticePage() {
const [dailyDone, setDailyDone] = useState(false);
const [sourceTopic, setSourceTopic] = useState<{ id: string; name: string } | null>(null);
const [showStarGate, setShowStarGate] = useState(false);
// Submit-gradient safeguard (per Chip Huyen's UX review).
// Rationale: with the restructured layout, the Reveal button sits
// directly below the answer textarea — removing the eye-travel
// friction that previously enforced deliberation. Without this
// guard, users type two sentences and reveal. We detect
// low-effort reveals (<15s elapsed AND <50 chars typed) and
// surface a one-time "Think longer?" confirm so the UX doesn't
// silently erode learning depth. Once confirmed (or once the user
// has demonstrated normal deliberation on any question), the guard
// stays dismissed for the session.
const [thinkConfirmOpen, setThinkConfirmOpen] = useState(false);
const deliberationCalibrated = useRef(false);
// Post-reveal model-answer anchor so we can scroll-into-view when
// showAnswer flips. Prevents the model answer from rendering below
// the fold on long scenarios.
const modelAnswerRef = useRef<HTMLDivElement>(null);
// Chain tracking — update when current question changes
const chainInfo = current ? getChainForQuestion(current.id) : null;
@@ -368,7 +385,33 @@ function PracticePage() {
setRubricItems([]);
}, [pool, current, showAnswer]);
const handleReveal = () => {
// Submit-gradient guard: intercept reveals that look like
// "didn't really try." The restructured layout puts the Reveal
// button directly below the textarea, so the eye-travel friction
// that previously enforced deliberation is gone. Without a
// deliberate pause, median time-before-reveal drops and
// self-assessed scores inflate without actual learning. We surface
// a one-time confirm, and once the user has demonstrated normal
// deliberation on any question (≥20s OR ≥80 chars typed), the
// guard calibrates itself off for the session. `force=true` skips
// the check — used by the confirm dialog's "Reveal anyway" button.
const handleReveal = (force: boolean = false) => {
if (!force && !deliberationCalibrated.current) {
const elapsedMs = Date.now() - questionShownAt.current;
const charsTyped = userAnswer.trim().length;
if (elapsedMs < 15000 && charsTyped < 50) {
setThinkConfirmOpen(true);
track({ type: 'think_guard_triggered' });
return;
}
// Any reveal that passes the threshold marks this user as
// deliberating normally — don't pester them again this session.
if (elapsedMs >= 20000 || charsTyped >= 80) {
deliberationCalibrated.current = true;
}
}
setThinkConfirmOpen(false);
// Star gate check
if (shouldShowGate()) {
setShowStarGate(true);
@@ -400,6 +443,15 @@ function PracticePage() {
}
setShowAnswer(true);
// After the reveal state commits, smoothly scroll the model
// answer into view. This is the primary pedagogical comparison
// moment — the user's answer should still be visible above, the
// model answer enters from below. Without this scroll, on long
// scenarios the model answer renders below the fold and the
// user has to hunt for it.
setTimeout(() => {
modelAnswerRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 50);
// Track answer reveal with response time and napkin grade
if (current) {
@@ -829,132 +881,374 @@ function PracticePage() {
<span className="text-sm">Filters</span>
</button>
{/* Main content */}
{/*
Main content — restructured 2026-04-24 based on UX feedback from
Emma (beginner), David (power user), and Chip Huyen (practitioner).
Key shifts:
- LEFT column = problem + answer + reveal + post-reveal (one
vertical reading/typing flow). No more 500px eye-travel from
scenario to textarea.
- RIGHT column = tools panel (Ask Interviewer, Hardware ref,
Napkin calc) — each a collapsible card so non-AI users don't
see wasted pixels shouting "use the AI."
- Your-task callout is STICKY at the top of the left scroll
container: scroll the scenario away, the question stays
visible (David's fix for long-scenario context loss).
- Submit-gradient safeguard: reveals triggered with <15s
elapsed AND <50 chars typed pop a "Think longer?" confirm.
Once the user demonstrates normal deliberation, the guard
self-calibrates off for the session (Chip's warning about
the new layout making premature reveals too frictionless).
- Post-reveal model answer scrolls into view via modelAnswerRef
so it doesn't render below the fold.
- "Stuck? Ask the Interviewer →" nudge beneath the textarea
so beginners discover the AI without it dominating the page
(Emma's scaffolding request + Chip's "don't advertise
unused features" rule).
*/}
<div className="flex-1 flex flex-col overflow-hidden">
{current ? (
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
{/* Question */}
{/* ── LEFT: problem + answer + reveal + post-reveal ── */}
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto px-8 lg:px-12 py-10">
<div className="max-w-3xl mx-auto">
<AnimatePresence mode="wait">
<motion.div
key={current.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
>
<div className="flex items-center gap-3 mb-4">
<LevelBadge level={current.level} />
<span className="text-[10px] font-mono text-textTertiary uppercase px-2 py-0.5 rounded border border-border bg-surface">
{current.competency_area}
</span>
<span className="text-[10px] font-mono text-textTertiary uppercase px-2 py-0.5 rounded border border-accentBlue/20 bg-accentBlue/5">
{current.zone}
</span>
<span className="text-[10px] font-mono text-textTertiary uppercase">
{current.track}
</span>
<span className="flex-1" />
{/* Copy link */}
<button
onClick={() => {
const url = `${window.location.origin}/practice?q=${current.id}`;
navigator.clipboard.writeText(url);
showToast({ type: 'badge', title: 'Link copied', description: 'Share this question with others' });
}}
// p-2 -m-2 extends the tap target to 30x30px on mobile
// while keeping the visual icon at 14x14 (Apple HIG
// "extend hit target without changing appearance").
className="text-textMuted hover:text-textSecondary transition-colors p-2 -m-2"
aria-label="Copy question link"
title="Copy question link"
>
<LinkIcon className="w-3.5 h-3.5" />
</button>
{/* Report issue */}
<a
href={buildReportUrl(current)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] text-textSecondary hover:text-accentRed transition-colors"
title="Report an issue with this question"
>
<Flag className="w-3.5 h-3.5" /> Report
</a>
</div>
{chainInfo && !showAnswer && (
<div className="mb-3">
<ChainBadge
chainId={chainInfo.chainId}
position={chainInfo.position + 1} /* 1-indexed for display */
total={chainInfo.total}
onClick={() => setChainPreviewOpen((v) => !v)}
/>
<div className="max-w-3xl mx-auto">
<AnimatePresence mode="wait">
<motion.div
key={current.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
>
{/* Badges + copy-link + report */}
<div className="flex items-center gap-3 mb-4">
<LevelBadge level={current.level} />
<span className="text-[10px] font-mono text-textTertiary uppercase px-2 py-0.5 rounded border border-border bg-surface">
{current.competency_area}
</span>
<span className="text-[10px] font-mono text-textTertiary uppercase px-2 py-0.5 rounded border border-accentBlue/20 bg-accentBlue/5">
{current.zone}
</span>
<span className="text-[10px] font-mono text-textTertiary uppercase">
{current.track}
</span>
<span className="flex-1" />
<button
onClick={() => {
const url = `${window.location.origin}/practice?q=${current.id}`;
navigator.clipboard.writeText(url);
showToast({ type: 'badge', title: 'Link copied', description: 'Share this question with others' });
}}
className="text-textMuted hover:text-textSecondary transition-colors p-2 -m-2"
aria-label="Copy question link"
title="Copy question link"
>
<LinkIcon className="w-3.5 h-3.5" />
</button>
<a
href={buildReportUrl(current)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] text-textSecondary hover:text-accentRed transition-colors"
title="Report an issue with this question"
>
<Flag className="w-3.5 h-3.5" /> Report
</a>
</div>
)}
<h2 className="text-2xl lg:text-3xl font-bold text-textPrimary mb-6 tracking-tight">
{current.title}
</h2>
<div className="prose max-w-none">
{current.scenario ? (
<p className="text-textSecondary leading-relaxed text-base">
{cleanScenario(current.scenario)}
</p>
) : (
<ScenarioSkeleton />
{/* Chain badge (pre-reveal only) */}
{chainInfo && !showAnswer && (
<div className="mb-3">
<ChainBadge
chainId={chainInfo.chainId}
position={chainInfo.position + 1}
total={chainInfo.total}
onClick={() => setChainPreviewOpen((v) => !v)}
/>
</div>
)}
</div>
{/*
Your-task callout. Three states, in priority order:
1. `current.question` present → render it as the
primary ask (post-backfill state).
2. Scenario already ends with `?` → render nothing
extra; the interrogative is already in the prose.
3. Neither → render a zone-aware inferred fallback
so the reader at least knows the shape of the
expected answer. Marked "inferred" so it's
clearly distinct from an authored question.
Placed AFTER the scenario because the scenario sets
context that the question refers to ("given the
above…"). A prompt above an unread scenario would
be meaningless.
*/}
{current.question ? (
<div className="mt-5 p-4 rounded-lg border-l-4 border-accentBlue bg-accentBlue/5">
<div className="flex items-center gap-2 mb-1.5">
<Target className="w-3.5 h-3.5 text-accentBlue" />
<span className="text-[10px] font-mono text-accentBlue uppercase tracking-widest">Your task</span>
</div>
<p className="text-textPrimary leading-relaxed text-base font-medium">
{current.question}
</p>
</div>
) : current.scenario && !current.scenario.trim().endsWith("?") ? (
<div className="mt-5 p-4 rounded-lg border border-dashed border-border bg-surface/40">
<div className="flex items-center gap-2 mb-1.5">
<Target className="w-3.5 h-3.5 text-textTertiary" />
<span className="text-[10px] font-mono text-textTertiary uppercase tracking-widest">
Your task <span className="text-textMuted normal-case">(inferred)</span>
</span>
</div>
<p className="text-textSecondary leading-relaxed text-sm">
{inferTaskPrompt(current.zone, current.bloom_level)}
</p>
</div>
) : null}
{chainInfo && !showAnswer && chainPreviewOpen && (
<div className="mt-6" data-testid="chain-preview-prereveal">
<ChainStrip chain={chainInfo} onNavigate={handleChainNavigate} />
</div>
)}
{/* Title */}
<h2 className="text-2xl lg:text-3xl font-bold text-textPrimary mb-6 tracking-tight">
{current.title}
</h2>
</motion.div>
</AnimatePresence>
{/*
STICKY Your-task callout. Pins to the top of the
scroll container so the question stays visible
while the user scrolls to read more scenario or
compare their answer with the model. Negative
horizontal margins + re-added padding make the
sticky bar span the full left-column width so the
background covers scrolling text cleanly.
*/}
<div className="sticky top-0 z-20 -mx-8 lg:-mx-12 px-8 lg:px-12 py-3 bg-background border-b border-border">
{current.question ? (
<div className="p-4 rounded-lg border-l-4 border-accentBlue bg-accentBlue/5">
<div className="flex items-center gap-2 mb-1.5">
<Target className="w-3.5 h-3.5 text-accentBlue" />
<span className="text-[10px] font-mono text-accentBlue uppercase tracking-widest">Your task</span>
</div>
<p className="text-textPrimary leading-relaxed text-base font-medium">
{current.question}
</p>
</div>
) : current.scenario && !current.scenario.trim().endsWith("?") ? (
<div className="p-4 rounded-lg border border-dashed border-border bg-surface/40">
<div className="flex items-center gap-2 mb-1.5">
<Target className="w-3.5 h-3.5 text-textTertiary" />
<span className="text-[10px] font-mono text-textTertiary uppercase tracking-widest">
Your task <span className="text-textMuted normal-case">(inferred)</span>
</span>
</div>
<p className="text-textSecondary leading-relaxed text-sm">
{inferTaskPrompt(current.zone, current.bloom_level)}
</p>
</div>
) : (
/* Scenario ends with ?; no callout needed but reserve minimal spacing */
<div className="flex items-center gap-2 text-[10px] font-mono text-textTertiary uppercase tracking-widest">
<Target className="w-3.5 h-3.5" />
Your task see scenario below
</div>
)}
</div>
{/* Scenario prose */}
<div className="prose max-w-none mt-6">
{current.scenario ? (
<p className="text-textSecondary leading-relaxed text-base">
{cleanScenario(current.scenario)}
</p>
) : (
<ScenarioSkeleton />
)}
</div>
{/* Visual diagram (optional) */}
{current.visual && (
<QuestionVisual track={current.track} visual={current.visual} />
)}
{/* Pre-reveal chain sibling preview (toggle from ChainBadge) */}
{chainInfo && !showAnswer && chainPreviewOpen && (
<div className="mt-6" data-testid="chain-preview-prereveal">
<ChainStrip chain={chainInfo} onNavigate={handleChainNavigate} />
</div>
)}
{/* ── ANSWER + REVEAL (pre-reveal) ── */}
{!showAnswer ? (
<div className="mt-8">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] font-mono text-textTertiary uppercase tracking-widest flex items-center gap-1.5">
<Calculator className="w-3 h-3" />
{current.details.napkin_math ? "Your napkin math" : "Your answer"}
</span>
</div>
<textarea
value={userAnswer}
onChange={(e) => setUserAnswer(e.target.value)}
placeholder={
current.details.napkin_math
? "Type your napkin math here...\n\nExample:\nBandwidth: 3.35 TB/s\nModel size: 140 GB\nTime = 140 / 3350 ≈ 42 ms\n\n=> 42 ms (mark your final answer with =>)"
: "Type your answer or reasoning here..."
}
className="w-full min-h-[220px] bg-surface border border-border rounded-md p-5 font-mono text-[13px] text-textPrimary resize-y focus:outline-none focus:border-accentBlue/50 placeholder:text-textTertiary/40 leading-relaxed"
spellCheck="false"
/>
<button
onClick={() => handleReveal()}
className="mt-4 w-full bg-textPrimary text-background font-bold py-3 rounded-lg hover:opacity-90 transition-all flex items-center justify-center gap-2"
>
Reveal Answer <span className="text-[10px] opacity-50 ml-1"></span>
</button>
{/*
Beginner scaffolding nudge (Emma's UX review).
Subtle link below the textarea that points
discoverable users toward the Ask Interviewer
feature in the right column. Arrow implies
spatial direction. Kept small so it doesn't
compete with the Reveal button for attention.
*/}
<div className="mt-3 flex items-center justify-center gap-1.5 text-[11px] text-textTertiary">
<Lightbulb className="w-3 h-3" />
<span>Stuck? Use the <span className="text-accentBlue font-medium">Ask Interviewer</span> panel on the right </span>
</div>
</div>
) : (
/* ── POST-REVEAL — model answer lands below the user's input ── */
<motion.div
ref={modelAnswerRef}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-8 space-y-5 scroll-mt-24"
>
{userAnswer.trim() && (
<details className="group" open>
<summary className="text-[10px] font-mono text-textTertiary uppercase cursor-pointer select-none flex items-center gap-1.5">
<span className="group-open:rotate-90 transition-transform text-[8px]">&#9654;</span>
Your answer
</summary>
<div className="mt-2 p-3 bg-surface border border-border rounded-md font-mono text-[12px] text-textSecondary whitespace-pre-wrap leading-relaxed max-h-56 overflow-y-auto">
{userAnswer}
</div>
</details>
)}
{napkinResult && (
<div className={clsx(
"p-4 rounded-lg border",
napkinResult.grade === 'exact' || napkinResult.grade === 'close'
? "bg-accentGreen/10 border-accentGreen/30"
: napkinResult.grade === 'ballpark'
? "bg-accentAmber/10 border-accentAmber/30"
: "bg-accentRed/10 border-accentRed/30"
)}>
<div className="flex items-center gap-2 mb-2">
{(napkinResult.grade === 'exact' || napkinResult.grade === 'close') ? (
<CheckCircle2 className="w-4 h-4 text-accentGreen" />
) : napkinResult.grade === 'ballpark' ? (
<CheckCircle2 className="w-4 h-4 text-accentAmber" />
) : (
<XCircle className="w-4 h-4 text-accentRed" />
)}
<span className={clsx(
"text-sm font-bold",
(napkinResult.grade === 'exact' || napkinResult.grade === 'close') ? "text-accentGreen"
: napkinResult.grade === 'ballpark' ? "text-accentAmber"
: "text-accentRed"
)}>
{napkinResult.label}
</span>
</div>
<p className="text-xs text-textSecondary font-mono">
Your answer: {napkinResult.userNum.toLocaleString()} |
Model answer: {napkinResult.modelNum.toLocaleString()} |
Off by: {(napkinResult.ratio * 100).toFixed(0)}%
</p>
{napkinResult.maxSelfScore < 3 && (
<p className="text-[10px] text-textTertiary mt-2">
Self-assessment capped at "{napkinResult.maxSelfScore === 2 ? 'Partial' : 'Wrong'}" based on napkin math accuracy
</p>
)}
</div>
)}
{current.details.common_mistake && (
<div className="border-l-4 border-accentRed pl-4">
<span className="text-[10px] font-mono text-accentRed uppercase mb-1 flex items-center gap-1">
<XCircle className="w-3 h-3" /> Common Mistake
</span>
<p className="text-sm text-textSecondary leading-relaxed">{current.details.common_mistake}</p>
</div>
)}
<div className="border-l-4 border-accentGreen pl-4">
<span className="text-[10px] font-mono text-accentGreen uppercase mb-1 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Model Answer
</span>
<p className="text-sm text-textPrimary leading-relaxed">{current.details.realistic_solution}</p>
</div>
{current.details.napkin_math && (
<div className="bg-surface border border-border p-4 rounded-lg">
<span className="text-[10px] font-mono text-accentBlue uppercase mb-3 block">Napkin Math</span>
<NapkinMathDisplay text={current.details.napkin_math} />
</div>
)}
{rubricItems.length > 0 && (
<div className="border-t border-border pt-5">
<span className="text-[10px] font-mono text-textTertiary uppercase block mb-3">
Did your answer cover? <span className="text-textTertiary/50 ml-1">{rubricItems.filter(i => i.checked).length}/{rubricItems.length}</span>
</span>
<div className="space-y-2">
{rubricItems.map((item, idx) => (
<label
key={idx}
className={clsx(
"flex items-start gap-3 p-2.5 rounded-lg border cursor-pointer transition-all text-xs",
item.checked
? "border-accentGreen/30 bg-accentGreen/5"
: "border-border hover:border-borderHighlight"
)}
>
<input
type="checkbox"
checked={item.checked}
onChange={() => {
const updated = [...rubricItems];
updated[idx] = { ...updated[idx], checked: !updated[idx].checked };
setRubricItems(updated);
}}
className="mt-0.5 accent-accentGreen"
/>
<span className={clsx(
"leading-relaxed",
item.checked ? "text-textPrimary" : "text-textSecondary"
)}>
{item.text}
</span>
</label>
))}
</div>
{rubricScore !== null && (
<div className="mt-2 text-[10px] font-mono text-textTertiary">
Rubric score: {rubricScore}/3 {['Skip', 'Wrong', 'Partial', 'Nailed It'][rubricScore]}
</div>
)}
</div>
)}
<div className="border-t border-border pt-5">
<span className="text-[10px] font-mono text-textTertiary uppercase block mb-3">
{rubricItems.length > 0 ? 'Confirm or override' : 'Rate yourself'}
<span className="text-textTertiary/50 ml-2">Press 1-4</span>
</span>
<div className="grid grid-cols-4 gap-2">
{[
{ score: 0, label: "Skip", color: "border-border text-textTertiary hover:border-borderHighlight" },
{ score: 1, label: "Wrong", color: "border-accentRed/30 text-accentRed hover:bg-accentRed/10" },
{ score: 2, label: "Partial", color: "border-accentAmber/30 text-accentAmber hover:bg-accentAmber/10" },
{ score: 3, label: "Nailed It", color: "border-accentGreen/30 text-accentGreen hover:bg-accentGreen/10" },
].map(({ score, label, color }) => {
const disabled = score > effectiveMaxScore;
const isRubricSuggested = rubricScore !== null && score === rubricScore;
return (
<button
key={score}
onClick={() => handleScore(Math.min(score, effectiveMaxScore))}
disabled={disabled}
aria-label={`Rate yourself: ${label} (${score} of 3)`}
className={clsx(
"px-3 py-2.5 rounded-lg border text-xs font-medium transition-all",
disabled
? "opacity-30 cursor-not-allowed border-border text-textTertiary"
: isRubricSuggested
? `${color} ring-1 ring-accentBlue/50`
: color
)}
>
{label}
</button>
);
})}
</div>
<QuestionFeedback question={current} />
{chainInfo && (
<ChainStrip chain={chainInfo} onNavigate={handleChainNavigate} />
)}
</div>
</motion.div>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
{/* Sticky bottom bar */}
{/* Sticky bottom bar: pool count + Next Question */}
<div className="shrink-0 border-t border-border bg-background px-8 lg:px-12 py-3 flex items-center justify-between">
<span className="text-[11px] font-mono text-textTertiary">
{pool.length} in pool
@@ -969,17 +1263,14 @@ function PracticePage() {
</div>
</div>
{/* Answer panel */}
<div className="w-full lg:w-[460px] border-t lg:border-t-0 lg:border-l border-border bg-surface flex flex-col">
<div className="h-10 border-b border-border flex items-center px-4 bg-background/50 justify-between">
{/* ── RIGHT: tools panel ── */}
<div className="w-full lg:w-[400px] border-t lg:border-t-0 lg:border-l border-border bg-surface flex flex-col overflow-y-auto">
<div className="h-10 border-b border-border flex items-center px-4 bg-background/50 justify-between shrink-0">
<span className="text-[10px] font-mono text-textTertiary uppercase tracking-widest flex items-center gap-2">
<Calculator className="w-3 h-3" /> {current.details.napkin_math ? "napkin_math.py" : "answer.md"}
<Sparkles className="w-3 h-3" /> Tools
</span>
<button
onClick={() => pickRandom()}
// Extend tap target to ~32x52px on mobile while keeping
// visual text inline-sized. Negative margin cancels the
// padding so layout doesn't shift.
className="text-[10px] font-mono text-textTertiary hover:text-textPrimary transition-colors flex items-center gap-1 py-2 -my-2 px-2 -mx-2"
aria-label="Skip to a random question"
>
@@ -987,212 +1278,31 @@ function PracticePage() {
</button>
</div>
<HardwareRef />
<NapkinCalc />
{/* Pre-Reveal: Socratic clarifier (interview persona).
Post-Reveal: Tutor with canonical answer injected.
The `key` forces a remount when mode flips so the
transcript resets — a pre-reveal "don't tell me the
answer" exchange would otherwise pollute the tutor's
context when we start a fresh post-reveal dialogue. */}
{/*
Right-column order follows Chip Huyen's feedback:
- Ask Interviewer first (the novel feature; beginners
discover it via the nudge under the textarea). Keep
it collapsible — non-AI users see a thin header, not
a giant empty chat panel shouting "you're doing this
wrong." The `key` prop force-remounts on persona
swap so pre-reveal and post-reveal transcripts stay
separate.
- Hardware reference defaults OPEN. Practitioners
doing bandwidth/FLOPS calcs consult it constantly;
forcing a click every 15 seconds is the #1 friction
point Chip flagged.
- Napkin calculator defaults CLOSED. It is an invoked
tool, not a consulted reference — only expand when
actively computing something.
*/}
<AskInterviewer
key={`${current.id}-${showAnswer ? "study" : "interview"}`}
questionContext={current.scenario}
mode={showAnswer ? "study" : "interview"}
canonicalAnswer={showAnswer ? current.details.realistic_solution : undefined}
/>
<div className="flex-1 p-5 flex flex-col overflow-y-auto">
{!showAnswer ? (
<>
<textarea
value={userAnswer}
onChange={(e) => setUserAnswer(e.target.value)}
placeholder={
current.details.napkin_math
? "Type your napkin math here...\n\nExample:\nBandwidth: 3.35 TB/s\nModel size: 140 GB\nTime = 140 / 3350 ≈ 42 ms\n\n=> 42 ms (mark your final answer with =>)"
: "Type your answer or reasoning here..."
}
className="flex-1 min-h-[200px] w-full bg-background border border-border rounded-md p-5 font-mono text-[13px] text-textPrimary resize-none focus:outline-none focus:border-accentBlue/50 placeholder:text-textTertiary/40 leading-relaxed"
spellCheck="false"
autoFocus
/>
<button
onClick={handleReveal}
className="mt-4 w-full bg-textPrimary text-background font-bold py-3 rounded-lg hover:opacity-90 transition-all flex items-center justify-center gap-2"
>
Reveal Answer <span className="text-[10px] opacity-50 ml-1"></span>
</button>
</>
) : (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-5"
>
{/* User's answer (preserved for comparison) */}
{userAnswer.trim() && (
<details className="group">
<summary className="text-[10px] font-mono text-textTertiary uppercase cursor-pointer select-none flex items-center gap-1.5">
<span className="group-open:rotate-90 transition-transform text-[8px]">&#9654;</span>
Your answer
</summary>
<div className="mt-2 p-3 bg-background border border-border rounded-md font-mono text-[12px] text-textSecondary whitespace-pre-wrap leading-relaxed max-h-40 overflow-y-auto">
{userAnswer}
</div>
</details>
)}
{/* Napkin math result — gradient feedback */}
{napkinResult && (
<div className={clsx(
"p-4 rounded-lg border",
napkinResult.grade === 'exact' || napkinResult.grade === 'close'
? "bg-accentGreen/10 border-accentGreen/30"
: napkinResult.grade === 'ballpark'
? "bg-accentAmber/10 border-accentAmber/30"
: "bg-accentRed/10 border-accentRed/30"
)}>
<div className="flex items-center gap-2 mb-2">
{(napkinResult.grade === 'exact' || napkinResult.grade === 'close') ? (
<CheckCircle2 className="w-4 h-4 text-accentGreen" />
) : napkinResult.grade === 'ballpark' ? (
<CheckCircle2 className="w-4 h-4 text-accentAmber" />
) : (
<XCircle className="w-4 h-4 text-accentRed" />
)}
<span className={clsx(
"text-sm font-bold",
(napkinResult.grade === 'exact' || napkinResult.grade === 'close') ? "text-accentGreen"
: napkinResult.grade === 'ballpark' ? "text-accentAmber"
: "text-accentRed"
)}>
{napkinResult.label}
</span>
</div>
<p className="text-xs text-textSecondary font-mono">
Your answer: {napkinResult.userNum.toLocaleString()} |
Model answer: {napkinResult.modelNum.toLocaleString()} |
Off by: {(napkinResult.ratio * 100).toFixed(0)}%
</p>
{napkinResult.maxSelfScore < 3 && (
<p className="text-[10px] text-textTertiary mt-2">
Self-assessment capped at "{napkinResult.maxSelfScore === 2 ? 'Partial' : 'Wrong'}" based on napkin math accuracy
</p>
)}
</div>
)}
{current.details.common_mistake && (
<div className="border-l-4 border-accentRed pl-4">
<span className="text-[10px] font-mono text-accentRed uppercase mb-1 block flex items-center gap-1">
<XCircle className="w-3 h-3" /> Common Mistake
</span>
<p className="text-sm text-textSecondary leading-relaxed">{current.details.common_mistake}</p>
</div>
)}
<div className="border-l-4 border-accentGreen pl-4">
<span className="text-[10px] font-mono text-accentGreen uppercase mb-1 block flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Model Answer
</span>
<p className="text-sm text-textPrimary leading-relaxed">{current.details.realistic_solution}</p>
</div>
{current.details.napkin_math && (
<div className="bg-background border border-border p-4 rounded-lg">
<span className="text-[10px] font-mono text-accentBlue uppercase mb-3 block">Napkin Math</span>
<NapkinMathDisplay text={current.details.napkin_math} />
</div>
)}
{/* Rubric checkboxes */}
{rubricItems.length > 0 && (
<div className="border-t border-border pt-5">
<span className="text-[10px] font-mono text-textTertiary uppercase block mb-3">
Did your answer cover? <span className="text-textTertiary/50 ml-1">{rubricItems.filter(i => i.checked).length}/{rubricItems.length}</span>
</span>
<div className="space-y-2">
{rubricItems.map((item, idx) => (
<label
key={idx}
className={clsx(
"flex items-start gap-3 p-2.5 rounded-lg border cursor-pointer transition-all text-xs",
item.checked
? "border-accentGreen/30 bg-accentGreen/5"
: "border-border hover:border-borderHighlight"
)}
>
<input
type="checkbox"
checked={item.checked}
onChange={() => {
const updated = [...rubricItems];
updated[idx] = { ...updated[idx], checked: !updated[idx].checked };
setRubricItems(updated);
}}
className="mt-0.5 accent-accentGreen"
/>
<span className={clsx(
"leading-relaxed",
item.checked ? "text-textPrimary" : "text-textSecondary"
)}>
{item.text}
</span>
</label>
))}
</div>
{rubricScore !== null && (
<div className="mt-2 text-[10px] font-mono text-textTertiary">
Rubric score: {rubricScore}/3 {['Skip', 'Wrong', 'Partial', 'Nailed It'][rubricScore]}
</div>
)}
</div>
)}
{/* Self-assessment */}
<div className="border-t border-border pt-5">
<span className="text-[10px] font-mono text-textTertiary uppercase block mb-3">
{rubricItems.length > 0 ? 'Confirm or override' : 'Rate yourself'}
<span className="text-textTertiary/50 ml-2">Press 1-4</span>
</span>
<div className="grid grid-cols-4 gap-2">
{[
{ score: 0, label: "Skip", color: "border-border text-textTertiary hover:border-borderHighlight" },
{ score: 1, label: "Wrong", color: "border-accentRed/30 text-accentRed hover:bg-accentRed/10" },
{ score: 2, label: "Partial", color: "border-accentAmber/30 text-accentAmber hover:bg-accentAmber/10" },
{ score: 3, label: "Nailed It", color: "border-accentGreen/30 text-accentGreen hover:bg-accentGreen/10" },
].map(({ score, label, color }) => {
const disabled = score > effectiveMaxScore;
const isRubricSuggested = rubricScore !== null && score === rubricScore;
return (
<button
key={score}
onClick={() => handleScore(Math.min(score, effectiveMaxScore))}
disabled={disabled}
aria-label={`Rate yourself: ${label} (${score} of 3)`}
className={clsx(
"px-3 py-2.5 rounded-lg border text-xs font-medium transition-all",
disabled
? "opacity-30 cursor-not-allowed border-border text-textTertiary"
: isRubricSuggested
? `${color} ring-1 ring-accentBlue/50`
: color
)}
>
{label}
</button>
);
})}
</div>
{/* Feedback: thumbs, difficulty, report, suggest */}
<QuestionFeedback question={current} />
{/* Chain navigation — go deeper */}
{chainInfo && (
<ChainStrip chain={chainInfo} onNavigate={handleChainNavigate} />
)}
</div>
</motion.div>
)}
</div>
<HardwareRef defaultOpen={true} />
<NapkinCalc defaultOpen={false} />
</div>
</div>
) : (
@@ -1202,6 +1312,54 @@ function PracticePage() {
)}
</div>
{/*
Submit-gradient confirm dialog (per Chip Huyen's UX review).
Fires when a user clicks Reveal with <15s elapsed AND <50
chars typed — indicating they likely didn't really try. One
dismissal (either choice) calibrates the guard off for the
rest of the session so power users are not pestered. Uses a
full-viewport overlay so the dialog is unmissable.
*/}
{thinkConfirmOpen && (
<div
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-6"
onClick={() => setThinkConfirmOpen(false)}
role="dialog"
aria-modal="true"
aria-labelledby="think-confirm-title"
>
<div
className="bg-surface border border-border rounded-xl p-6 max-w-md w-full shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 mb-3">
<Clock className="w-4 h-4 text-accentAmber" />
<h3 id="think-confirm-title" className="text-sm font-bold text-textPrimary uppercase tracking-widest">Think longer?</h3>
</div>
<p className="text-sm text-textSecondary leading-relaxed mb-5">
You&apos;ve been on this question for less than 15 seconds and haven&apos;t typed much. The practice works better when you commit to an answer first even a wrong one sharpens what you learn from the reveal.
</p>
<div className="flex gap-3">
<button
onClick={() => setThinkConfirmOpen(false)}
className="flex-1 py-2.5 rounded-lg border border-accentBlue/30 bg-accentBlue/5 text-accentBlue font-medium text-sm hover:bg-accentBlue/10 transition-colors"
>
Keep thinking
</button>
<button
onClick={() => {
deliberationCalibrated.current = true;
handleReveal(true);
}}
className="flex-1 py-2.5 rounded-lg border border-border text-textSecondary font-medium text-sm hover:bg-surfaceHover transition-colors"
>
Reveal anyway
</button>
</div>
</div>
</div>
)}
{/* Star gate overlay */}
{showStarGate && (
<StarGate onVerified={() => { setShowStarGate(false); track({ type: 'star_gate_verified' }); }} />

View File

@@ -0,0 +1,73 @@
"use client";
/**
* Renders the optional diagram attached to a question.
*
* Architecture: visuals live as static SVG assets under
* `interviews/vault/visuals/<track>/` and are mirrored to
* `interviews/staffml/public/question-visuals/<track>/` at bundle
* build time (`vault build --legacy-json`). Here we just load them
* as `<img>` elements — browser caching, no inline-SVG sanitization,
* and the HTTP request is a single static asset hit.
*
* The component is deliberately minimal. The SVG itself carries the
* semantic content (see `.claude/rules/svg-style.md` for the book's
* SVG style system, which authors should follow). Styling of the
* frame, caption, and responsive sizing lives here.
*/
import { useState } from "react";
import { ImageOff } from "lucide-react";
export interface QuestionVisualProps {
track: string;
visual: {
kind: "svg" | "mermaid";
path: string;
alt: string;
caption?: string;
};
}
export default function QuestionVisual({ track, visual }: QuestionVisualProps) {
const [failed, setFailed] = useState(false);
// Mermaid kind is reserved for a future inline-text path — MVP only
// supports svg file references. Render nothing rather than throw so
// a forward-compat YAML on an old frontend degrades gracefully.
if (visual.kind !== "svg") return null;
const src = `/question-visuals/${track}/${visual.path}`;
if (failed) {
return (
<figure className="mt-5 p-4 rounded-lg border border-dashed border-accentRed/30 bg-accentRed/5 flex items-start gap-3">
<ImageOff className="w-4 h-4 text-accentRed shrink-0 mt-0.5" />
<div className="text-sm text-textSecondary">
<span className="font-medium text-accentRed">Diagram failed to load.</span>{" "}
{visual.alt}
</div>
</figure>
);
}
return (
<figure className="mt-5">
<div className="rounded-lg border border-border bg-surface/40 p-3 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={visual.alt}
className="max-w-full max-h-[420px] h-auto w-auto"
onError={() => setFailed(true)}
loading="lazy"
/>
</div>
{visual.caption && (
<figcaption className="mt-2 text-[11px] font-mono text-textTertiary text-center">
{visual.caption}
</figcaption>
)}
</figure>
);
}

View File

@@ -27,6 +27,18 @@ export interface Question {
* back to a zone-based inferred-task label.
*/
question?: string;
/**
* Optional diagram attached to the question. The SVG file lives at
* `/question-visuals/<track>/<visual.path>` — copied from the vault
* by the build step. Rendered between the scenario and the Your
* task callout so the reading order is context → diagram → ask.
*/
visual?: {
kind: "svg" | "mermaid";
path: string; // bare filename, resolves under /question-visuals/<track>/
alt: string; // a11y-required description
caption?: string;
};
topic: string; // one of 87 curated topic IDs
zone: string; // one of 11 ikigai zones
competency_area: string; // one of 13 canonical areas

View File

@@ -11,7 +11,7 @@ from rich.table import Table
from vault_cli.compiler import build as compile_build
from vault_cli.exit_codes import ExitCode
from vault_cli.legacy_export import emit_legacy_corpus
from vault_cli.legacy_export import copy_visual_assets, emit_legacy_corpus
from vault_cli.loader import load_all
console = Console()
@@ -72,6 +72,19 @@ def register(app: typer.Typer) -> None:
f"[dim]legacy corpus.json: {legacy_result['count']} questions → "
f"{legacy_result['output']}[/dim]"
)
# Mirror visual assets alongside the JSON. The frontend
# references /question-visuals/<track>/<file>.svg directly
# from Next.js's public/ tree — no hydration or worker
# round-trip, just static assets cached at the edge.
visuals_out = Path("interviews/staffml/public")
visuals_result = copy_visual_assets(vault_dir, visuals_out)
result["visual_assets"] = visuals_result
console.print(
f"[dim]visual assets: {visuals_result.get('total_assets', 0)} total, "
f"{visuals_result.get('copied', 0)} copied, "
f"{visuals_result.get('deleted', 0)} pruned → "
f"{visuals_out}/question-visuals/[/dim]"
)
if as_json:
print(json.dumps({

View File

@@ -42,6 +42,21 @@ def _adapt(lq: LoadedQuestion) -> dict[str, Any]:
# renders it synchronously, so lazy-hydration would be a regression.
if q.question:
legacy["question"] = q.question
# Visual metadata. The SVG file itself lives under
# interviews/vault/visuals/<track>/ and is copied to
# interviews/staffml/public/question-visuals/ by `copy_visual_assets`
# below; this JSON only carries the metadata the frontend needs
# (kind + filename + alt + optional caption) to build the asset
# URL at /question-visuals/<track>/<path>.
if q.visual is not None:
visual_out: dict[str, Any] = {
"kind": q.visual.kind,
"path": q.visual.path,
"alt": q.visual.alt,
}
if q.visual.caption:
visual_out["caption"] = q.visual.caption
legacy["visual"] = visual_out
# Chain — legacy shape: chain_ids (list) + chain_positions (dict).
# v1.0 schema already carries multi-chain membership natively.
@@ -159,4 +174,50 @@ def _to_summary(item: dict[str, Any]) -> dict[str, Any]:
return summary
__all__ = ["emit_legacy_corpus"]
def copy_visual_assets(vault_dir: Path, staffml_public_dir: Path) -> dict[str, Any]:
"""Copy `interviews/vault/visuals/<track>/*.svg` → `<staffml>/public/question-visuals/<track>/`.
The Next.js frontend serves static assets from ``public/`` at
``/question-visuals/<track>/<file>.svg``. This function mirrors
the track-sharded directory layout so the same relative filename
used in the YAML's ``visual.path`` field resolves without
transformation.
Overwrites destination files on every run — the vault is the
source of truth. Removes destination files whose source no longer
exists so renames/deletions propagate. Returns counts for the
build summary.
"""
import shutil
source = vault_dir / "visuals"
dest = staffml_public_dir / "question-visuals"
copied = 0
deleted = 0
if not source.exists():
return {"copied": 0, "deleted": 0, "note": "no visuals directory"}
# Mirror from source into dest.
dest.mkdir(parents=True, exist_ok=True)
source_files: set[Path] = set()
for svg in source.rglob("*.svg"):
rel = svg.relative_to(source)
source_files.add(rel)
target = dest / rel
target.parent.mkdir(parents=True, exist_ok=True)
if not target.exists() or target.read_bytes() != svg.read_bytes():
shutil.copy2(svg, target)
copied += 1
# Prune destination files that no longer have a source.
for existing in dest.rglob("*.svg"):
rel = existing.relative_to(dest)
if rel not in source_files:
existing.unlink()
deleted += 1
return {"copied": copied, "deleted": deleted, "total_assets": len(source_files)}
__all__ = ["emit_legacy_corpus", "copy_visual_assets"]

View File

@@ -0,0 +1,52 @@
schema_version: '1.0'
id: cloud-visual-001
track: cloud
level: L3
zone: analyze
topic: data-parallelism
competency_area: parallelism
bloom_level: apply
phase: training
title: Ring AllReduce Latency on 4 Ranks
scenario: >-
Four GPU ranks are participating in a Ring AllReduce to aggregate gradients
after a training step. The gradient tensor is 400 MB in total, split evenly
into 4 chunks of 100 MB each. Every inter-rank link runs at 200 GB/s. The
ring passes chunks clockwise: each rank sends one chunk to the next rank
while receiving a chunk from the previous rank.
question: "Using the diagram, calculate the total time to complete the full AllReduce across all 4 ranks, and justify the formula you used."
visual:
kind: svg
path: cloud-visual-001.svg
alt: >-
Four rank boxes arranged in a square: Rank 0 top-left, Rank 1 top-right,
Rank 2 bottom-right, Rank 3 bottom-left. Clockwise arrows between adjacent
ranks are labelled chunk 0 through chunk 3, each carrying 100 MB at
200 GB/s. A legend below notes total gradient 400 MB, N equals 4 ranks,
and that the full AllReduce comprises reduce-scatter plus all-gather.
caption: Ring AllReduce on 4 ranks passing 100 MB chunks clockwise at 200 GB/s.
details:
realistic_solution: >-
Total AllReduce time = 2(N1)/N × data / bw = 2(3)/4 × 400 MB / 200 GB/s
= 1.5 × 0.002 s = 3 ms. The Ring AllReduce has two phases: a reduce-scatter
(each rank ends up owning a reduced shard) and an all-gather (each rank
collects the full reduced tensor). Each phase completes in (N1)/N × data
/ bw = 1.5 ms, yielding 3 ms total. The key insight is that only one chunk
(data/N = 100 MB) traverses each link per step, so wall-time is set by
the number of steps (2(N1) = 6) times step-time (100 MB / 200 GB/s = 0.5 ms).
common_mistake: >-
Dividing the full 400 MB gradient by 200 GB/s to get 2 ms misses that
Ring AllReduce requires 2(N1) = 6 sequential steps, not one. Alternatively,
some learners compute (N1)/N × data / bw = 1.5 ms and forget to double
it for the all-gather phase, giving 1.5 ms instead of 3 ms. The 2(N1)/N
factor captures both the parallelism benefit (only 1/N of data per step)
and the phase count.
napkin_math: >-
data = 400 MB, N = 4, bw = 200 GB/s. Step time = (data/N)/bw =
100 MB / 200 GB/s = 0.5 ms. Reduce-scatter takes N1 = 3 steps = 1.5 ms.
All-gather also takes N1 = 3 steps = 1.5 ms. Total = 3 ms.
status: draft
provenance: llm-draft
expected_time_minutes: 5
requires_explanation: false
validated: false

View File

@@ -61,6 +61,74 @@ class Resource(BaseModel):
return v
class Visual(BaseModel):
"""Optional diagram/figure attached to a question.
Visuals live as separate asset files under
``interviews/vault/visuals/<track>/<path>`` so the SVG text does
not contaminate YAML diffs and existing SVG tooling (Inkscape,
formatters, linters) works unchanged. The bundle-build step copies
these into the Next.js ``public/question-visuals/`` tree. The
practice page renders them between the scenario and the
``question`` callout — context → diagram → ask, mirroring how an
interviewer would flow the question in person.
"""
kind: str = "svg"
"""Renderer kind. MVP supports `svg` only. Future: `mermaid`
(inline text), `roofline` (parameterized React component), etc.
The renderer dispatches on this field."""
path: str
"""Asset filename relative to ``interviews/vault/visuals/<track>/``.
Must end in ``.svg`` for ``kind=svg``. No path traversal."""
alt: str
"""Accessibility description for screen readers and fallback when
the SVG fails to load. Required — a visual with no alt is an
accessibility regression, not an optional add-on."""
caption: Optional[str] = None
"""Author-facing caption rendered below the figure. Short — max
120 chars. Optional; the alt text handles the semantic payload."""
@field_validator("kind")
@classmethod
def valid_kind(cls, v: str) -> str:
if v not in {"svg", "mermaid"}:
raise ValueError(f"Visual.kind must be 'svg' or 'mermaid' (got {v!r})")
return v
@field_validator("path")
@classmethod
def safe_path(cls, v: str) -> str:
if not v.strip():
raise ValueError("Visual.path must be non-empty")
if "/" in v or "\\" in v or ".." in v:
raise ValueError(
f"Visual.path must be a bare filename, no traversal (got {v!r})"
)
if len(v) > 120:
raise ValueError(f"Visual.path too long ({len(v)} chars, max 120)")
return v
@field_validator("alt")
@classmethod
def alt_non_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Visual.alt must be non-empty (accessibility requirement)")
if len(v) > 400:
raise ValueError(f"Visual.alt too long ({len(v)} chars, max 400)")
return v
@field_validator("caption")
@classmethod
def caption_length(cls, v: Optional[str]) -> Optional[str]:
if v is not None and len(v) > 120:
raise ValueError(f"Visual.caption too long ({len(v)} chars, max 120)")
return v
class ChainRef(BaseModel):
"""Structured chain reference with position (plural chains list item)."""
@@ -140,6 +208,7 @@ class Question(BaseModel):
# uniformly (without it, 71% of questions had no explicit ask — the
# scenario just set context and the reader had to guess).
question: Optional[str] = None
visual: Optional[Visual] = None
details: QuestionDetails
# Workflow

View File

@@ -157,6 +157,33 @@ classes:
range: string
description: Free-form reviewer notes.
Visual:
description: |
Optional diagram attached to a question. Stored as a separate
asset file under interviews/vault/visuals/<track>/; the YAML
only carries metadata (kind, filename, alt text, caption). This
keeps SVG markup out of YAML diffs and lets authors use real
SVG editors instead of YAML multiline scalars.
attributes:
kind:
range: string
required: true
pattern: "^(svg|mermaid)$"
description: Renderer kind. MVP ships `svg` only; `mermaid` reserved for future inline-text diagrams.
path:
range: string
required: true
pattern: "^[A-Za-z0-9_.-]+\\.(svg|mmd)$"
description: Bare filename under visuals/<track>/. No path traversal. ≤120 chars.
alt:
range: string
required: true
description: Accessibility description for screen readers and SVG-load failure. ≤400 chars. Required — a visual with no alt is an a11y regression.
caption:
range: string
required: false
description: Short author-facing caption rendered below the figure. ≤120 chars. Alt text handles the semantic payload; caption is for labelling only.
Details:
description: Rich-text body of the question.
attributes:
@@ -238,6 +265,16 @@ classes:
50 GB/s chip interconnect and a 33 ms frame deadline; the
question field reads "Which parallelism strategy (tensor vs.
pipeline) would you choose and why?"
visual:
range: Visual
required: false
description: >-
Optional diagram attached to the question. Stored as a
separate file under interviews/vault/visuals/<track>/<path>
so the SVG text does not contaminate YAML diffs and existing
SVG tooling works unchanged. Rendered between the scenario
and the question callout on the practice page — context →
diagram → ask.
details:
range: Details
required: true

View File

@@ -0,0 +1,115 @@
# Authoring visual questions
StaffML questions can optionally attach a diagram. The practice page renders
it between the scenario prose and the answer textarea, and the `question`
field always stays sticky at the top — reading flow is **scenario →
diagram → answer**.
Use visuals sparingly. A good visual earns its place; a bad one is
noise that slows the reader down.
## When a visual earns its place
A visual earns its place when **all three** hold:
1. **The ask requires reading the diagram.** If the question can be
answered from the scenario alone, the visual is decorative — omit it.
2. **The visual encodes information that text cannot.** Topology,
memory layouts, roofline curves, pipeline timelines, dataflow —
things where spatial structure *is* the payload.
3. **A static image suffices.** Animation, interactivity, and
multi-step reveals are out of scope.
### High-value candidate topics
Target these first; each one repeats across many chain positions, so
one good diagram earns its keep across dozens of questions.
- Ring / tree AllReduce topologies (show the ring, ask for latency)
- Parameter-server vs. AllReduce dataflow
- Roofline diagrams with workload points plotted
- KV-cache growth vs. sequence length
- Pipeline parallelism bubble / Gantt charts
- Memory hierarchy: HBM + SRAM + host DRAM + disk
- TinyML MCU memory map (flash + SRAM + model footprint)
- Systolic array dataflow (weight-stationary vs. output-stationary)
- Attention computation graph (Q·Kᵀ then softmax then ·V)
- MoE all-to-all shuffle topology
## Authoring workflow
1. **Draft the SVG** following `.claude/rules/svg-style.md` (the book's
SVG system). Non-negotiables:
- `viewBox="0 0 680 460"` default (widen only when content demands).
- `font-family="Helvetica Neue, Helvetica, Arial, sans-serif"` on
the root `<svg>`.
- Semantic palette — compute blue `#cfe2f3`/`#4a90c4`, data green
`#d4edda`/`#3d9e5a`, routing orange `#fdebd0`/`#c87b2a`, error
red `#f9d6d5`/`#c44`, MIT red accent `#a31f34`.
- Orthogonal routing (no diagonals except genuine topology diagrams).
- Arrows anchor at box edges, route around obstacles with ≥10px
clearance.
- Integer coordinates on a 10-px grid.
- Text in `<text>` elements (not paths) — selectable + accessible.
2. **Save** to `interviews/vault/visuals/<track>/<id>.svg` where
`<track>` matches the question's track (cloud, edge, mobile, tinyml,
global) and `<id>` matches the question's YAML id. Bare filename
only — no subdirectories, no path traversal.
3. **Add the `visual:` block** to the matching YAML:
```yaml
visual:
kind: svg
path: <id>.svg
alt: >-
Full accessibility description — objective, concrete, ≤400
chars. Describe what the diagram SHOWS, not why it matters.
caption: "Short caption rendered below the figure. ≤120 chars. Optional."
```
4. **Build** — `vault build --legacy-json`. This copies the SVG to
`interviews/staffml/public/question-visuals/<track>/` and surfaces
the `visual` metadata in the corpus bundle.
5. **Preview** at `/practice?q=<id>` on the dev server.
## Reference exemplar
`cloud/cloud-visual-001.yaml` + `cloud/cloud-visual-001.svg` — Ring
AllReduce on 4 ranks. Diagram shows the ring topology + chunk labels +
bandwidth annotations; scenario gives concrete numbers; question asks
for the total AllReduce time. Solution walks through the
2(N1)/N × data / bw formula. Copy this pattern.
## Accessibility requirements (non-negotiable)
- **`alt` is required** and is enforced by the schema. A visual
without alt fails Pydantic validation; `vault build` will reject it.
- Colour is never the sole semantic channel — pair colour with label
text, line style, or shape. A colour-blind reader must still be able
to parse the diagram.
- Text in SVG `<text>` elements (not baked into paths) so it's
selectable and screen-reader friendly.
- WCAG AA contrast on any label over a coloured fill: `#333` text on
`#cfe2f3` compute-blue passes; `#999` text on the same fill fails.
## Anti-patterns to reject
- Inline `<svg>` markup inside YAML. The schema's `path` field is a
bare filename — path traversal is rejected. Use the file-reference
pattern.
- Mermaid source for anything other than simple node-edge graphs. The
renderer MVP supports SVG only; `kind: mermaid` is reserved for a
future inline-text path and is currently a no-op.
- Label text that duplicates the scenario prose. The diagram should
encode *additional* information, not restate the scenario.
- Decorative gradients, fake 3-D, beveled edges, drop shadows. Every
mark earns its place (Tufte's data-ink principle — see the SVG
style guide for the full chart-junk policy).
- Icons or emoji in the SVG. Use neutral shapes + labels.
- Watermarks, signatures, tool branding. The caption handles attribution.
## Build artifacts
The directory `interviews/staffml/public/question-visuals/` is a
build output (written by `copy_visual_assets` during `vault build
--legacy-json`). Source files live under `interviews/vault/visuals/`.
Do not edit the build output directly — changes will be overwritten
on the next build.

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680 460"
font-family="Helvetica Neue, Helvetica, Arial, sans-serif">
<rect width="680" height="460" fill="#fff" rx="4"/>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L8,3 L0,6 Z" fill="#555"/>
</marker>
</defs>
<!-- ===== Title ===== -->
<text x="340" y="32" text-anchor="middle" font-size="13" font-weight="700" fill="#333">
Ring AllReduce — 4 ranks, one reduce-scatter step
</text>
<!-- ===== Ranks (compute blue) ===== -->
<!-- Rank 0: top-left -->
<rect x="110" y="110" width="140" height="80" rx="4"
fill="#cfe2f3" stroke="#4a90c4" stroke-width="1.5"/>
<text x="180" y="156" text-anchor="middle" font-size="12" font-weight="700" fill="#333">Rank 0</text>
<!-- Rank 1: top-right -->
<rect x="430" y="110" width="140" height="80" rx="4"
fill="#cfe2f3" stroke="#4a90c4" stroke-width="1.5"/>
<text x="500" y="156" text-anchor="middle" font-size="12" font-weight="700" fill="#333">Rank 1</text>
<!-- Rank 2: bottom-right -->
<rect x="430" y="290" width="140" height="80" rx="4"
fill="#cfe2f3" stroke="#4a90c4" stroke-width="1.5"/>
<text x="500" y="336" text-anchor="middle" font-size="12" font-weight="700" fill="#333">Rank 2</text>
<!-- Rank 3: bottom-left -->
<rect x="110" y="290" width="140" height="80" rx="4"
fill="#cfe2f3" stroke="#4a90c4" stroke-width="1.5"/>
<text x="180" y="336" text-anchor="middle" font-size="12" font-weight="700" fill="#333">Rank 3</text>
<!-- ===== Ring arrows (clockwise) ===== -->
<!-- R0 → R1 (top edge) -->
<line x1="252" y1="150" x2="428" y2="150" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="340" y="140" text-anchor="middle" font-size="10" font-weight="700" fill="#333">chunk 0</text>
<text x="340" y="172" text-anchor="middle" font-size="9" fill="#555">100 MB @ 200 GB/s</text>
<!-- R1 → R2 (right edge) -->
<line x1="500" y1="192" x2="500" y2="288" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="514" y="234" text-anchor="start" font-size="10" font-weight="700" fill="#333">chunk 1</text>
<text x="514" y="250" text-anchor="start" font-size="9" fill="#555">100 MB @ 200 GB/s</text>
<!-- R2 → R3 (bottom edge) -->
<line x1="428" y1="330" x2="252" y2="330" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="340" y="322" text-anchor="middle" font-size="10" font-weight="700" fill="#333">chunk 2</text>
<text x="340" y="352" text-anchor="middle" font-size="9" fill="#555">100 MB @ 200 GB/s</text>
<!-- R3 → R0 (left edge) -->
<line x1="180" y1="288" x2="180" y2="192" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="166" y="234" text-anchor="end" font-size="10" font-weight="700" fill="#333">chunk 3</text>
<text x="166" y="250" text-anchor="end" font-size="9" fill="#555">100 MB @ 200 GB/s</text>
<!-- ===== Context legend ===== -->
<rect x="190" y="395" width="300" height="40" rx="5"
fill="#f7f7f7" stroke="#bbb" stroke-width="1"/>
<text x="340" y="412" text-anchor="middle" font-size="10" font-weight="700" fill="#333">
Total gradient: 400 MB · N = 4 ranks · 200 GB/s links
</text>
<text x="340" y="426" text-anchor="middle" font-size="9" fill="#555">
Full AllReduce = reduce-scatter (N1 steps) + all-gather (N1 steps)
</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB