From fb2a57e12bf0450ee144ff9506653b2d161686e0 Mon Sep 17 00:00:00 2001 From: Vijay Janapa Reddi Date: Fri, 24 Apr 2026 16:10:06 -0400 Subject: [PATCH 1/3] feat(staffml): optional visual field + QuestionVisual component + build-side asset mirroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the plumbing for questions to attach an optional SVG diagram without touching any existing question data. The feature is strictly opt-in — every question without a visual: block renders exactly as before. Schema (backend): - Pydantic: new Visual model — kind (svg | mermaid), path (bare filename, path-traversal rejected), alt (required, ≤400 chars, accessibility contract), optional caption (≤120 chars). Question gains optional visual: Visual field after scenario, before details. - LinkML question_schema.yaml: matching Visual class + visual slot on the Question definition. Pattern-validated for kind and path. - legacy_export._adapt: emits visual metadata (kind/path/alt/caption) to both full corpus.json and the summary bundle. The metadata is small (≤600 chars/question) so shipping it synchronously avoids a lazy-hydration regression on first render. - legacy_export.copy_visual_assets: new bundle-build step that mirrors interviews/vault/visuals//*.svg to interviews/staffml/public/question-visuals//. Overwrites on content change, prunes files whose source is gone. Wired into vault build --legacy-json after emit_legacy_corpus. Frontend: - Question TS interface: visual?: { kind, path, alt, caption? }. - QuestionVisual component: renders an from /question-visuals// with responsive sizing (max 420px tall), a
for the caption, and a graceful error state that surfaces the alt text if the SVG fails to load. Uses rather than inline SVG so the browser handles caching and sanitization — the vault's SVG linting already runs as a pre-commit hook. Build hygiene: - staffml/.gitignore: ignore public/question-visuals/ — it's a build output mirroring the vault. Source of truth is interviews/vault/visuals/. --- interviews/staffml/.gitignore | 5 ++ .../staffml/src/components/QuestionVisual.tsx | 73 +++++++++++++++++++ interviews/staffml/src/lib/corpus.ts | 12 +++ .../vault-cli/src/vault_cli/commands/build.py | 15 +++- .../vault-cli/src/vault_cli/legacy_export.py | 63 +++++++++++++++- interviews/vault/schema.py | 69 ++++++++++++++++++ interviews/vault/schema/question_schema.yaml | 37 ++++++++++ 7 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 interviews/staffml/src/components/QuestionVisual.tsx diff --git a/interviews/staffml/.gitignore b/interviews/staffml/.gitignore index e8caf33af..1fd686f9b 100644 --- a/interviews/staffml/.gitignore +++ b/interviews/staffml/.gitignore @@ -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/ diff --git a/interviews/staffml/src/components/QuestionVisual.tsx b/interviews/staffml/src/components/QuestionVisual.tsx new file mode 100644 index 000000000..c1b24feba --- /dev/null +++ b/interviews/staffml/src/components/QuestionVisual.tsx @@ -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//` and are mirrored to + * `interviews/staffml/public/question-visuals//` at bundle + * build time (`vault build --legacy-json`). Here we just load them + * as `` 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 ( +
+ +
+ Diagram failed to load.{" "} + {visual.alt} +
+
+ ); + } + + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {visual.alt} setFailed(true)} + loading="lazy" + /> +
+ {visual.caption && ( +
+ {visual.caption} +
+ )} +
+ ); +} diff --git a/interviews/staffml/src/lib/corpus.ts b/interviews/staffml/src/lib/corpus.ts index b04259357..6e18f3f83 100644 --- a/interviews/staffml/src/lib/corpus.ts +++ b/interviews/staffml/src/lib/corpus.ts @@ -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//` — 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// + 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 diff --git a/interviews/vault-cli/src/vault_cli/commands/build.py b/interviews/vault-cli/src/vault_cli/commands/build.py index 3ebf27091..684fc8226 100644 --- a/interviews/vault-cli/src/vault_cli/commands/build.py +++ b/interviews/vault-cli/src/vault_cli/commands/build.py @@ -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//.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({ diff --git a/interviews/vault-cli/src/vault_cli/legacy_export.py b/interviews/vault-cli/src/vault_cli/legacy_export.py index 476591613..3f676ff31 100644 --- a/interviews/vault-cli/src/vault_cli/legacy_export.py +++ b/interviews/vault-cli/src/vault_cli/legacy_export.py @@ -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// 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//. + 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//*.svg` → `/public/question-visuals//`. + + The Next.js frontend serves static assets from ``public/`` at + ``/question-visuals//.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"] diff --git a/interviews/vault/schema.py b/interviews/vault/schema.py index bf5056305..cd9816034 100644 --- a/interviews/vault/schema.py +++ b/interviews/vault/schema.py @@ -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//`` 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//``. + 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 diff --git a/interviews/vault/schema/question_schema.yaml b/interviews/vault/schema/question_schema.yaml index c3dd4e4f9..6fc56b069 100644 --- a/interviews/vault/schema/question_schema.yaml +++ b/interviews/vault/schema/question_schema.yaml @@ -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//; 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//. 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// + 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 From cb0b7ea3054de048e5ecb0e372bd4875bdccc8b1 Mon Sep 17 00:00:00 2001 From: Vijay Janapa Reddi Date: Fri, 24 Apr 2026 16:10:35 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(staffml=20practice):=20restructure=20l?= =?UTF-8?q?ayout=20per=20UX=20review=20=E2=80=94=20reading+answering=20on?= =?UTF-8?q?=20one=20column,=20tools=20on=20the=20other?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to a 4-reviewer UX pass (Emma, David, Chip Huyen, plus Soumith who bounced). Consensus: ship the read-and-answer vertical flow on the left, move tools to the right — but bundle in three safeguards that address each reviewer's structural concern. Layout changes: - LEFT column: title → STICKY Your-task callout → scenario → optional QuestionVisual diagram → answer textarea → reveal → post-reveal content (user's answer, model answer, rubric, self-assessment). One vertical flow — no more 500px eye-travel from reading on the left to typing on the right. - RIGHT column: tools panel — Ask Interviewer, Hardware reference, Napkin calculator. Each is a collapsible card; together they replace the previous dense stack of widgets around the textarea. Safeguards folded in: 1. Sticky 'Your task' callout (David's concern: long scenarios push the textarea below the fold, losing the ask while typing). Negative horizontal margins span full column width so the sticky bar's background cleanly covers scrolling scenario text. 2. Right-column defaults inverted per Chip Huyen's practitioner review. HardwareRef defaultOpen=true (consulted constantly during bandwidth/FLOPS calcs); NapkinCalc defaultOpen=false (invoked, not consulted). AskInterviewer inherits its own default-closed behavior so non-AI users see a thin header, not a giant empty chat panel signalling 'you're doing this wrong'. 3. Submit-gradient safeguard (Chip Huyen's most subtle warning). Removing the eye-travel friction made the Reveal button too frictionless — users would type two sentences and reveal. handleReveal now gates on (elapsed<15s && chars<50), surfacing a one-time 'Think longer?' confirm. deliberationCalibrated ref self-turns the guard OFF once the user demonstrates normal deliberation (≥20s elapsed OR ≥80 chars) on any question. Power users see the guard at most once per session. 4. Beginner scaffolding (Emma's scaffolding request). A subtle 'Stuck? Use the Ask Interviewer panel →' nudge sits beneath the textarea, pointing to the right column. Discoverable without dominating the layout — exactly the 'teach users how to use the tools, not just access to them' pattern beginners need. Post-reveal UX: - modelAnswerRef + scrollIntoView({behavior: smooth, block: start}) after showAnswer flips. On long scenarios the model answer previously rendered below the fold; users had to hunt for it. Scroll-into-view plus scroll-mt-24 keeps the sticky header from overlapping the landing position. - Chain navigation + QuestionFeedback stay in the same column as the user's answer — vertical comparison, not horizontal. Analytics: - New 'think_guard_triggered' event fires when the submit-gradient guard pops the confirm dialog. Track this alongside the existing 'answer_response_time' event to validate that the redesign did not erode deliberation depth (Chip's predicted regression). --- interviews/staffml/src/app/practice/page.tsx | 808 +++++++++++-------- 1 file changed, 483 insertions(+), 325 deletions(-) diff --git a/interviews/staffml/src/app/practice/page.tsx b/interviews/staffml/src/app/practice/page.tsx index 702fe9c72..7ff4e14ac 100644 --- a/interviews/staffml/src/app/practice/page.tsx +++ b/interviews/staffml/src/app/practice/page.tsx @@ -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(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() { Filters - {/* 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). + */}
{current ? (
- {/* Question */} + {/* ── LEFT: problem + answer + reveal + post-reveal ── */}
-
- - -
- - - {current.competency_area} - - - {current.zone} - - - {current.track} - - - {/* Copy link */} - - {/* Report issue */} - - Report - -
- {chainInfo && !showAnswer && ( -
- setChainPreviewOpen((v) => !v)} - /> +
+ + + {/* Badges + copy-link + report */} +
+ + + {current.competency_area} + + + {current.zone} + + + {current.track} + + + + + Report +
- )} -

- {current.title} -

-
- {current.scenario ? ( -

- {cleanScenario(current.scenario)} -

- ) : ( - + + {/* Chain badge (pre-reveal only) */} + {chainInfo && !showAnswer && ( +
+ setChainPreviewOpen((v) => !v)} + /> +
)} -
- {/* - 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 ? ( -
-
- - Your task -
-

- {current.question} -

-
- ) : current.scenario && !current.scenario.trim().endsWith("?") ? ( -
-
- - - Your task (inferred) - -
-

- {inferTaskPrompt(current.zone, current.bloom_level)} -

-
- ) : null} - {chainInfo && !showAnswer && chainPreviewOpen && ( -
- -
- )} + {/* Title */} +

+ {current.title} +

-
-
+ {/* + 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. + */} +
+ {current.question ? ( +
+
+ + Your task +
+

+ {current.question} +

+
+ ) : current.scenario && !current.scenario.trim().endsWith("?") ? ( +
+
+ + + Your task (inferred) + +
+

+ {inferTaskPrompt(current.zone, current.bloom_level)} +

+
+ ) : ( + /* Scenario ends with ?; no callout needed but reserve minimal spacing */ +
+ + Your task — see scenario below +
+ )} +
+ + {/* Scenario prose */} +
+ {current.scenario ? ( +

+ {cleanScenario(current.scenario)} +

+ ) : ( + + )} +
+ + {/* Visual diagram (optional) */} + {current.visual && ( + + )} + + {/* Pre-reveal chain sibling preview (toggle from ChainBadge) */} + {chainInfo && !showAnswer && chainPreviewOpen && ( +
+ +
+ )} + + {/* ── ANSWER + REVEAL (pre-reveal) ── */} + {!showAnswer ? ( +
+
+ + + {current.details.napkin_math ? "Your napkin math" : "Your answer"} + +
+