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/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"} + +
+