mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-07-21 00:23:30 -05:00
feat(staffml): client UX overhaul — drawer, palette, shortcuts, AI interviewer panel, polish
Six passes of UX work consolidated into one client commit. Each section below is reviewable as an independent unit. PASS A — bug fixes from initial UX review * Gate napkin grader so it can never fire on non-numeric questions (defensive isNumericQuestion heuristic in lib/corpus.ts; the actual fix is a corpus answer_type field, marked TODO) * Vault topic detail rebuilt as a right-anchored slide-over drawer with role=dialog, aria-modal, Escape, focus management, backdrop click-out * Mock Interview now preserves the user's typed answer after reveal so they can compare against the model answer * Explicit ::selection CSS rule for visible mouse text selection on the dark terminal palette * New URL classifier (lib/refs.ts) labels deep-dive references honestly per source (book / arxiv / pytorch / etc.) instead of hard-coding "Read on MLSysBook.ai" everywhere * "May be unavailable" indicator for book-source links until the mlsysbook.ai chapter routes are redeployed PASS B/C — UX features and infra * FirstRunExplainer panel for Practice and Mock Interview modes, empty-state pattern, persisted in localStorage * Cmd+K command palette with three sections (Pages / Topics / Questions), keyboard nav, focus trap, debounced corpus search * Global ? KeyboardShortcutsOverlay listing every shortcut in the app * Nav reorganization: Plans promoted to primary, Tools renamed to Lab, About / Contribute / Dashboard moved to a new "More" user menu, ⌘K hint button added in the top-right * Mock Interview Strict / Standard / Open realism toggle that gates the Hardware Reference, Napkin Calc, and Ask Interviewer panels (persisted in localStorage) * Per-question clarifications log surfaced in the gauntlet results phase as "you asked N clarifications on this problem" PASS D — Ask Interviewer client * Three behavior modes: journal (no endpoint), hosted (LLM via worker), copy-as-prompt fallback (always available) * Reads NEXT_PUBLIC_INTERVIEWER_ENDPOINT at module load * Inline attribution returned by the worker (vendor + model + privacy note) so attribution updates automatically when the deployed provider changes * Four user-facing security/privacy notices wired into the panel PASS E — Gemini-reviewed robustness * AskInterviewer: AbortController on questionContext change to prevent stale fetches injecting into next question, setBusy(false) on reset to prevent input lockout, stable message ids for aria-live correctness, insecure-context clipboard guard, conditional auto-scroll * CommandPalette: searchQuestions debounced (120 ms) to keep main thread responsive on a 9k-question corpus, focus trap (Tab/Shift+Tab cycling), Escape stopPropagation to avoid bubbling to the global ? listener * KeyboardShortcutsOverlay: focus trap, SELECT added to typing guard * Nav: navigator.userAgent (not deprecated navigator.platform) for Mac detection PASS F — Polish * MetaTooltip component (CSS-only, role=tooltip, focus-within, native title fallback) wrapping LevelBadge so every L1-L6+ badge in the app reveals Bloom name + role + verb + sample question on hover/focus * meta-descriptions.ts: single source of truth for level / track / competency tooltip text * Practice: track and competency filter buttons get title= tooltips with the same descriptions * Inline "(how does this affect drilling?)" disclosure next to the Rate yourself buttons explaining the SR scheduling intervals * Progress page empty state now shows three on-ramps (Drill 5 easy / Daily challenge / Mock interview) instead of forcing brand-new users into the highest-friction action Other: * tsconfig.json excludes worker/ so the client doesn't try to typecheck the worker's Cloudflare-typed files * .gitignore adds the link checker JSON report and the wrangler cache Typecheck: clean across all of src/ (only the pre-existing stale .next/ framework reference remains, unchanged from before).
This commit is contained in:
@@ -4,3 +4,10 @@ out/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Link-checker report (generated by scripts/check-deep-dive-links.py)
|
||||
scripts/_deep_dive_link_report.json
|
||||
|
||||
# Cloudflare Wrangler cache (worker dev/deploy)
|
||||
worker/.wrangler/
|
||||
worker/wrangler.toml.bak
|
||||
|
||||
@@ -18,6 +18,10 @@ import { track } from "@/lib/analytics";
|
||||
import { saveAttempt, saveGauntletResult, AttemptRecord, recordActivity, updateSRCard } from "@/lib/progress";
|
||||
import NapkinMathDisplay from "@/components/NapkinMathDisplay";
|
||||
import QuestionFeedback from "@/components/QuestionFeedback";
|
||||
import HardwareRef from "@/components/HardwareRef";
|
||||
import NapkinCalc from "@/components/NapkinCalc";
|
||||
import AskInterviewer from "@/components/AskInterviewer";
|
||||
import FirstRunExplainer from "@/components/FirstRunExplainer";
|
||||
|
||||
type Phase = "setup" | "active" | "review" | "results";
|
||||
|
||||
@@ -38,6 +42,26 @@ export default function GauntletPage() {
|
||||
const [selectedDuration, setSelectedDuration] = useState(1); // index into DURATIONS
|
||||
const [availableCount, setAvailableCount] = useState(0);
|
||||
|
||||
// Realism: how interview-like is the session?
|
||||
// strict = no Hardware Reference, no Napkin Calc, no Ask Interviewer
|
||||
// standard = tools available, collapsed by default (current default)
|
||||
// open = tools available, expanded by default
|
||||
// Persisted in localStorage so the user's choice survives reloads.
|
||||
type Realism = "strict" | "standard" | "open";
|
||||
const [realism, setRealism] = useState<Realism>("standard");
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("staffml_gauntlet_realism");
|
||||
if (saved === "strict" || saved === "standard" || saved === "open") {
|
||||
setRealism(saved);
|
||||
}
|
||||
} catch { /* localStorage may be unavailable */ }
|
||||
}, []);
|
||||
const updateRealism = (r: Realism) => {
|
||||
setRealism(r);
|
||||
try { localStorage.setItem("staffml_gauntlet_realism", r); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// Active state
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [currentIdx, setCurrentIdx] = useState(0);
|
||||
@@ -50,6 +74,17 @@ export default function GauntletPage() {
|
||||
const [scores, setScores] = useState<number[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Per-question clarifications log — surfaced in the results phase as
|
||||
// "you asked N clarifications on this problem." Powers the metacognitive
|
||||
// post-mortem ritual for both Journal and Hosted AskInterviewer modes.
|
||||
const [clarifications, setClarifications] = useState<Record<string, string[]>>({});
|
||||
const recordClarification = (qId: string, q: string) => {
|
||||
setClarifications((prev) => ({
|
||||
...prev,
|
||||
[qId]: [...(prev[qId] || []), q],
|
||||
}));
|
||||
};
|
||||
|
||||
const tracks = getTracks().filter(t => t !== "global");
|
||||
const levels = getLevels();
|
||||
|
||||
@@ -167,6 +202,7 @@ export default function GauntletPage() {
|
||||
setShowAnswer(false);
|
||||
setUserAnswer("");
|
||||
setScores([]);
|
||||
setClarifications({});
|
||||
setTimeRemaining(dur.minutes * 60);
|
||||
setPhase("active");
|
||||
track({ type: 'gauntlet_started', track: selectedTrack, level: selectedLevel, questionCount: selected.length });
|
||||
@@ -242,7 +278,11 @@ export default function GauntletPage() {
|
||||
// ─── SETUP PHASE ─────────────────────────────────────
|
||||
if (phase === "setup") {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-6 py-16">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="max-w-2xl w-full mx-auto px-6 pt-6">
|
||||
<FirstRunExplainer mode="gauntlet" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-6 py-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -335,6 +375,33 @@ export default function GauntletPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Realism — controls which helper tools appear during the interview */}
|
||||
<div className="mb-8">
|
||||
<label className="text-[10px] font-mono text-textTertiary uppercase tracking-widest block mb-3">Realism</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
{ id: "strict", label: "Strict", desc: "No tools. Closest to a real whiteboard." },
|
||||
{ id: "standard", label: "Standard", desc: "Tools available, collapsed by default." },
|
||||
{ id: "open", label: "Open", desc: "Tools open. Use any time." },
|
||||
] as { id: Realism; label: string; desc: string }[]).map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => updateRealism(opt.id)}
|
||||
aria-pressed={realism === opt.id}
|
||||
className={clsx(
|
||||
"px-3 py-3 rounded-lg border text-left transition-all",
|
||||
realism === opt.id
|
||||
? "border-accentBlue bg-accentBlue/10"
|
||||
: "border-border bg-surface hover:border-borderHighlight"
|
||||
)}
|
||||
>
|
||||
<div className="text-sm font-bold text-textPrimary">{opt.label}</div>
|
||||
<div className="text-[10px] text-textTertiary mt-0.5 leading-relaxed">{opt.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available count + start */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-textTertiary font-mono">
|
||||
@@ -356,6 +423,7 @@ export default function GauntletPage() {
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -423,6 +491,22 @@ export default function GauntletPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tools — gated by the user's realism choice on the setup screen.
|
||||
Strict = no tools at all (closest to a real whiteboard)
|
||||
Standard = tools mounted, collapsed by default
|
||||
Open = tools mounted, expanded by default */}
|
||||
{realism !== "strict" && (
|
||||
<>
|
||||
<HardwareRef defaultOpen={realism === "open"} />
|
||||
<NapkinCalc defaultOpen={realism === "open"} />
|
||||
<AskInterviewer
|
||||
questionContext={q.scenario}
|
||||
defaultOpen={realism === "open"}
|
||||
onAsk={(question) => recordClarification(q.id, question)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1 p-5 flex flex-col overflow-y-auto">
|
||||
{!showAnswer ? (
|
||||
<>
|
||||
@@ -449,6 +533,20 @@ export default function GauntletPage() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-5"
|
||||
>
|
||||
{/* User's answer — preserved for self-comparison against the model.
|
||||
Collapsed by default so the model answer stays the visual focus. */}
|
||||
{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]">▶</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>
|
||||
)}
|
||||
|
||||
{/* Model answer */}
|
||||
{q.details.common_mistake && (
|
||||
<div className="border-l-4 border-accentRed pl-4">
|
||||
@@ -568,6 +666,7 @@ export default function GauntletPage() {
|
||||
{questions.map((q, i) => {
|
||||
const s = scores[i] ?? 0;
|
||||
const labels = ['Skipped', 'Wrong', 'Partial', 'Nailed'];
|
||||
const askedClarifications = clarifications[q.id] || [];
|
||||
return (
|
||||
<details key={q.id} className="group rounded-lg border border-borderSubtle bg-surface">
|
||||
<summary className="flex items-center gap-3 px-3 py-2.5 cursor-pointer text-sm">
|
||||
@@ -578,6 +677,11 @@ export default function GauntletPage() {
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-textPrimary truncate flex-1">{q.title}</span>
|
||||
{askedClarifications.length > 0 && (
|
||||
<span className="text-[9px] font-mono text-accentBlue shrink-0" title={`You asked ${askedClarifications.length} clarification${askedClarifications.length === 1 ? "" : "s"} on this problem`}>
|
||||
?{askedClarifications.length}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-mono text-textTertiary shrink-0">{labels[s]}</span>
|
||||
</summary>
|
||||
<div className="px-3 pb-3 pt-1 border-t border-borderSubtle space-y-2">
|
||||
@@ -585,6 +689,23 @@ export default function GauntletPage() {
|
||||
{q.details.common_mistake && (
|
||||
<p className="text-[11px] text-accentRed/80"><span className="font-bold">Common mistake:</span> {q.details.common_mistake}</p>
|
||||
)}
|
||||
{askedClarifications.length > 0 && (
|
||||
<div className="pt-2 mt-2 border-t border-borderSubtle">
|
||||
<p className="text-[10px] font-mono text-accentBlue uppercase mb-1.5">
|
||||
Your clarifications ({askedClarifications.length})
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{askedClarifications.map((c, j) => (
|
||||
<li key={j} className="text-[11px] text-textSecondary leading-relaxed">
|
||||
<span className="text-textTertiary mr-1.5">{j + 1}.</span>{c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-[10px] text-textTertiary italic mt-2">
|
||||
Senior interviewees typically ask 3–6 clarifications before solving. Compare against the model answer's assumptions.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<QuestionFeedback question={{
|
||||
id: q.id, title: q.title, level: q.level,
|
||||
track: q.track, topic: q.topic, zone: q.zone,
|
||||
|
||||
@@ -95,6 +95,18 @@ h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'JetBrains Mono', 'IBM Plex Mono', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
/* Text selection — explicit, high-contrast on dark background.
|
||||
Browser defaults are nearly invisible against the StaffML terminal palette,
|
||||
so we use the accent blue at high opacity with a forced foreground color. */
|
||||
::selection {
|
||||
background-color: rgba(56, 139, 253, 0.55);
|
||||
color: #ffffff;
|
||||
}
|
||||
::-moz-selection {
|
||||
background-color: rgba(56, 139, 253, 0.55);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Prose blocks */
|
||||
.prose { color: var(--text-secondary); font-size: 0.95rem; line-height: 1.65; }
|
||||
.prose p { margin-bottom: 1em; }
|
||||
|
||||
@@ -3,6 +3,8 @@ import "./globals.css";
|
||||
import Nav from "@/components/Nav";
|
||||
import EcosystemBar from "@/components/EcosystemBar";
|
||||
import Providers from "@/components/Providers";
|
||||
import CommandPalette from "@/components/CommandPalette";
|
||||
import KeyboardShortcutsOverlay from "@/components/KeyboardShortcutsOverlay";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://staffml.ai"),
|
||||
@@ -65,6 +67,8 @@ export default function RootLayout({
|
||||
<EcosystemBar />
|
||||
<Nav />
|
||||
<main className="flex-1 flex flex-col">{children}</main>
|
||||
<CommandPalette />
|
||||
<KeyboardShortcutsOverlay />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -439,24 +439,39 @@ function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail panel */}
|
||||
<AnimatePresence>
|
||||
{selectedTopic && selectedStyle && (
|
||||
</div>
|
||||
|
||||
{/* Desktop detail drawer (right-anchored slide-over) */}
|
||||
<AnimatePresence>
|
||||
{selectedTopic && selectedStyle && (
|
||||
<div className="hidden lg:block">
|
||||
{/* Backdrop — click anywhere outside to close */}
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 420, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="shrink-0 overflow-hidden border-l border-border hidden lg:block h-full"
|
||||
key="desktop-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-40 bg-black/30"
|
||||
onClick={() => setSelectedTopic(null)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<motion.div
|
||||
key="desktop-drawer"
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", damping: 32, stiffness: 320 }}
|
||||
className="fixed top-0 right-0 bottom-0 z-50 w-[480px] max-w-[90vw] border-l border-border bg-background shadow-2xl"
|
||||
>
|
||||
<TopicDetail topic={selectedTopic}
|
||||
areaName={selectedArea?.name || ""} style={selectedStyle}
|
||||
selectedTrack={selectedTrack}
|
||||
onClose={() => setSelectedTopic(null)} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Mobile detail sheet + backdrop */}
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Target, CheckCircle2, XCircle, Terminal, SkipForward,
|
||||
BookOpen, Calculator
|
||||
BookOpen, Calculator, FileText, ExternalLink as ExternalLinkIcon
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import HardwareRef from "@/components/HardwareRef";
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
getTracks, getLevels, getCompetencyAreas, getZones, getQuestionsByFilter,
|
||||
getQuestions, getQuestionsByTopic,
|
||||
Question, checkNapkinMath, extractFinalNumber, cleanScenario,
|
||||
NapkinResult
|
||||
NapkinResult, isNumericQuestion
|
||||
} from "@/lib/corpus";
|
||||
import { saveAttempt, getAttempts, updateSRCard, getDueQuestionIds, getDueCount, recordActivity } from "@/lib/progress";
|
||||
import { extractRubric, rubricToScore, RubricItem } from "@/lib/rubric";
|
||||
import { classifyRef } from "@/lib/refs";
|
||||
import { trackTooltip, competencyTooltip } from "@/lib/meta-descriptions";
|
||||
import { getQuestionById } from "@/lib/corpus";
|
||||
import { getTopicById, getZoneDefinition } from "@/lib/taxonomy";
|
||||
import { getLevelDef } from "@/lib/levels";
|
||||
@@ -35,6 +37,7 @@ import Link from "next/link";
|
||||
import { buildReportUrl } from "@/lib/issue-url";
|
||||
import QuestionFeedback from "@/components/QuestionFeedback";
|
||||
import { track } from "@/lib/analytics";
|
||||
import FirstRunExplainer from "@/components/FirstRunExplainer";
|
||||
|
||||
export default function PracticePageWrapper() {
|
||||
return (
|
||||
@@ -250,12 +253,15 @@ function PracticePage() {
|
||||
}
|
||||
incrementReveals();
|
||||
|
||||
// Try napkin math check if the question has napkin_math and user typed something
|
||||
// Try napkin math check ONLY when (a) the question is actually numeric
|
||||
// (so we never grade a recall question like "what does NPU stand for?")
|
||||
// and (b) the user typed something containing at least one digit.
|
||||
// Falls back to self-rate when either condition fails.
|
||||
let napkinGrade: string | undefined;
|
||||
if (current?.details.napkin_math && userAnswer.trim()) {
|
||||
if (current && isNumericQuestion(current) && userAnswer.trim() && /\d/.test(userAnswer)) {
|
||||
const userNum = extractFinalNumber(userAnswer);
|
||||
const modelNum = extractFinalNumber(current.details.napkin_math);
|
||||
if (userNum !== null && modelNum !== null && modelNum > 0) {
|
||||
const modelNum = extractFinalNumber(current.details.napkin_math || '');
|
||||
if (userNum !== null && modelNum !== null && modelNum > 0 && !isNaN(userNum) && isFinite(userNum)) {
|
||||
const result = checkNapkinMath(userNum, modelNum, current.track);
|
||||
setNapkinResult({ ...result, userNum, modelNum });
|
||||
napkinGrade = result.grade;
|
||||
@@ -364,7 +370,9 @@ function PracticePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col lg:flex-row">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<FirstRunExplainer mode="practice" />
|
||||
<div className="flex-1 flex flex-col lg:flex-row min-h-0">
|
||||
{/* Sidebar filters */}
|
||||
<aside className="w-full lg:w-64 border-b lg:border-b-0 lg:border-r border-border bg-surface/50 p-4 lg:p-5 flex flex-col gap-4 lg:gap-6 lg:overflow-y-auto">
|
||||
{/* Back to vault link */}
|
||||
@@ -473,21 +481,25 @@ function PracticePage() {
|
||||
<div>
|
||||
<label className="text-[10px] font-mono text-textTertiary uppercase tracking-widest block mb-2">Track</label>
|
||||
<div className="flex flex-wrap gap-1 lg:flex-col lg:gap-1">
|
||||
{tracks.map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setSelectedTrack(t)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 lg:py-2 lg:w-full rounded-md text-sm font-medium capitalize transition-all",
|
||||
"lg:text-left",
|
||||
selectedTrack === t
|
||||
? "bg-accentBlue/10 text-accentBlue"
|
||||
: "text-textSecondary hover:bg-surfaceHover"
|
||||
)}
|
||||
>
|
||||
{t === "tinyml" ? "TinyML" : t}
|
||||
</button>
|
||||
))}
|
||||
{tracks.map(t => {
|
||||
const tip = trackTooltip(t);
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setSelectedTrack(t)}
|
||||
title={`${tip.title}\n${tip.body}`}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 lg:py-2 lg:w-full rounded-md text-sm font-medium capitalize transition-all",
|
||||
"lg:text-left",
|
||||
selectedTrack === t
|
||||
? "bg-accentBlue/10 text-accentBlue"
|
||||
: "text-textSecondary hover:bg-surfaceHover"
|
||||
)}
|
||||
>
|
||||
{t === "tinyml" ? "TinyML" : t}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -540,20 +552,24 @@ function PracticePage() {
|
||||
>
|
||||
All areas
|
||||
</button>
|
||||
{areas.map(a => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setSelectedArea(a)}
|
||||
className={clsx(
|
||||
"w-full text-left px-3 py-1.5 rounded text-xs font-medium capitalize transition-all",
|
||||
selectedArea === a
|
||||
? "bg-accentBlue/10 text-accentBlue"
|
||||
: "text-textSecondary hover:bg-surfaceHover"
|
||||
)}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
{areas.map(a => {
|
||||
const tip = competencyTooltip(a);
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setSelectedArea(a)}
|
||||
title={`${tip.title}\n${tip.body}`}
|
||||
className={clsx(
|
||||
"w-full text-left px-3 py-1.5 rounded text-xs font-medium capitalize transition-all",
|
||||
selectedArea === a
|
||||
? "bg-accentBlue/10 text-accentBlue"
|
||||
: "text-textSecondary hover:bg-surfaceHover"
|
||||
)}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -671,17 +687,25 @@ function PracticePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{current.details.deep_dive_title && (
|
||||
<a
|
||||
href={current.details.deep_dive_url ? current.details.deep_dive_url.replace('https://mlsysbook.ai', ECOSYSTEM_BASE) : '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 mt-6 px-3 py-2 text-[13px] text-accentBlue hover:bg-accentBlue/5 border border-accentBlue/20 rounded-lg transition-colors"
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
{current.details.deep_dive_title}
|
||||
</a>
|
||||
)}
|
||||
{current.details.deep_dive_title && current.details.deep_dive_url && (() => {
|
||||
const refInfo = classifyRef(current.details.deep_dive_url);
|
||||
const href = refInfo.isBook
|
||||
? current.details.deep_dive_url!.replace('https://mlsysbook.ai', ECOSYSTEM_BASE)
|
||||
: current.details.deep_dive_url!;
|
||||
const Icon = refInfo.isBook ? BookOpen : refInfo.source === "arxiv" || refInfo.source === "paper" ? FileText : ExternalLinkIcon;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={refInfo.label}
|
||||
className="inline-flex items-center gap-2 mt-6 px-3 py-2 text-[13px] text-accentBlue hover:bg-accentBlue/5 border border-accentBlue/20 rounded-lg transition-colors"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{current.details.deep_dive_title}
|
||||
</a>
|
||||
);
|
||||
})()}
|
||||
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
@@ -706,7 +730,7 @@ function PracticePage() {
|
||||
<div className="w-full lg:w-[460px] border-t lg:border-t-0 lg:border-l border-border bg-surface/90 flex flex-col">
|
||||
<div className="h-10 border-b border-border flex items-center px-4 bg-background/50 justify-between">
|
||||
<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"}
|
||||
<Calculator className="w-3 h-3" /> {isNumericQuestion(current) ? "napkin_math.py" : "answer.md"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => pickRandom()}
|
||||
@@ -725,7 +749,7 @@ function PracticePage() {
|
||||
value={userAnswer}
|
||||
onChange={(e) => setUserAnswer(e.target.value)}
|
||||
placeholder={
|
||||
current.details.napkin_math
|
||||
isNumericQuestion(current)
|
||||
? "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..."
|
||||
}
|
||||
@@ -820,18 +844,31 @@ function PracticePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deep-dive link to MLSysBook.ai */}
|
||||
{current.details.deep_dive_title && (
|
||||
<a
|
||||
href={current.details.deep_dive_url ? current.details.deep_dive_url.replace('https://mlsysbook.ai', ECOSYSTEM_BASE) : '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2.5 text-[12px] text-accentBlue hover:bg-accentBlue/5 border border-accentBlue/20 rounded-lg transition-colors"
|
||||
>
|
||||
<BookOpen className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>Learn more on <span className="font-semibold">MLSysBook.ai</span> — {current.details.deep_dive_title}</span>
|
||||
</a>
|
||||
)}
|
||||
{/* Deep-dive reference — labelled honestly per destination
|
||||
(book / paper / vendor docs / blog / external) so users
|
||||
know what they're clicking into. See lib/refs.ts. */}
|
||||
{current.details.deep_dive_title && current.details.deep_dive_url && (() => {
|
||||
const refInfo = classifyRef(current.details.deep_dive_url);
|
||||
const href = refInfo.isBook
|
||||
? current.details.deep_dive_url!.replace('https://mlsysbook.ai', ECOSYSTEM_BASE)
|
||||
: current.details.deep_dive_url!;
|
||||
const Icon = refInfo.isBook ? BookOpen : refInfo.source === "arxiv" || refInfo.source === "paper" ? FileText : ExternalLinkIcon;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={refInfo.mayBeUnavailable ? "This link may be temporarily unavailable while the book site is being redeployed." : refInfo.label}
|
||||
className="flex items-center gap-2 px-3 py-2.5 text-[12px] text-accentBlue hover:bg-accentBlue/5 border border-accentBlue/20 rounded-lg transition-colors"
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 shrink-0" />
|
||||
<span><span className="font-semibold">{refInfo.label}</span> — {current.details.deep_dive_title}</span>
|
||||
{refInfo.mayBeUnavailable && (
|
||||
<span className="ml-auto text-accentAmber text-[10px] font-mono" aria-label="May be unavailable">⚠</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Rubric checkboxes */}
|
||||
{rubricItems.length > 0 && (
|
||||
@@ -882,6 +919,25 @@ function PracticePage() {
|
||||
<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>
|
||||
<details className="inline-block ml-2 group">
|
||||
<summary className="cursor-pointer text-accentBlue/70 hover:text-accentBlue text-[10px] normal-case font-sans select-none">
|
||||
(how does this affect drilling?)
|
||||
</summary>
|
||||
<div className="mt-2 p-3 rounded-md border border-borderSubtle bg-surface/50 text-[10px] text-textSecondary leading-relaxed normal-case font-sans">
|
||||
<p className="mb-2">
|
||||
Your honest rating drives spaced repetition. The system schedules the next time you'll see this question based on how you did:
|
||||
</p>
|
||||
<ul className="space-y-1 mb-2">
|
||||
<li><span className="text-accentRed font-semibold">Wrong</span> → comes back tomorrow</li>
|
||||
<li><span className="text-accentAmber font-semibold">Partial</span> → comes back in 3 days</li>
|
||||
<li><span className="text-accentGreen font-semibold">Nailed</span> → comes back in 1–2 weeks (intervals lengthen each time you nail it)</li>
|
||||
<li><span className="text-textTertiary font-semibold">Skip</span> → no schedule change, doesn't count toward your streak</li>
|
||||
</ul>
|
||||
<p className="text-textTertiary italic">
|
||||
The "due" counter in the nav shows how many cards are waiting for you. Be honest — over-rating "Nailed" means you'll forget; under-rating wastes your time on stuff you already know.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</span>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
@@ -949,6 +1005,7 @@ function PracticePage() {
|
||||
{showStarGate && (
|
||||
<StarGate onVerified={() => { setShowStarGate(false); track({ type: 'star_gate_verified' }); }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,15 +225,34 @@ export default function ProgressPage() {
|
||||
<BarChart3 className="w-8 h-8 text-textTertiary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-textPrimary mb-2">No data yet</h2>
|
||||
<p className="text-sm text-textSecondary mb-6 max-w-md">
|
||||
Complete a Gauntlet or drill some questions to see your readiness heat map populate.
|
||||
<p className="text-sm text-textSecondary mb-6 max-w-md leading-relaxed">
|
||||
Your progress page lights up after you've drilled questions or completed a mock interview. Pick a starting point — there's no wrong choice.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Link
|
||||
href="/practice?level=L1"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-border text-textPrimary hover:border-accentBlue/60 rounded-lg transition-all text-sm font-medium"
|
||||
title="Start with the easiest level — recall questions to warm up."
|
||||
>
|
||||
<Target className="w-4 h-4 text-accentGreen" /> Drill 5 easy
|
||||
</Link>
|
||||
<Link
|
||||
href="/practice?daily=1"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-border text-textPrimary hover:border-accentBlue/60 rounded-lg transition-all text-sm font-medium"
|
||||
title="3 hand-picked questions, same for everyone, takes ~5 min."
|
||||
>
|
||||
<Target className="w-4 h-4 text-accentBlue" /> Daily challenge
|
||||
</Link>
|
||||
<Link
|
||||
href="/gauntlet"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 bg-textPrimary text-background font-bold rounded-lg hover:opacity-90 transition-all text-sm"
|
||||
>
|
||||
<Crosshair className="w-4 h-4" /> Mock interview
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-[11px] text-textTertiary italic mt-6 max-w-md">
|
||||
Tip: every question you answer feeds the heat map below, so you can see at a glance which competency areas need more work.
|
||||
</p>
|
||||
<Link
|
||||
href="/gauntlet"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-textPrimary text-background font-bold rounded-lg hover:opacity-90 transition-all text-sm"
|
||||
>
|
||||
<Crosshair className="w-4 h-4" /> Start a Gauntlet
|
||||
</Link>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Ask Interviewer panel — clarification ritual for Mock Interview mode.
|
||||
*
|
||||
* Three behaviors that auto-detect at runtime:
|
||||
*
|
||||
* 1. JOURNAL — no NEXT_PUBLIC_INTERVIEWER_ENDPOINT configured.
|
||||
* User types clarifications, they're logged for post-mortem,
|
||||
* no AI call happens. Works for everyone, no infra needed.
|
||||
*
|
||||
* 2. HOSTED — NEXT_PUBLIC_INTERVIEWER_ENDPOINT is set. Each clarification
|
||||
* is POSTed to the Worker, which forwards to whichever LLM
|
||||
* provider is configured (default: Cloudflare Workers AI
|
||||
* Llama 3.1 8B). Inline attribution shows the actual model
|
||||
* that answered.
|
||||
*
|
||||
* 3. FALLBACK — Hosted mode was configured but failed (rate-limited, 503,
|
||||
* network error). Shows the friendly error inline AND
|
||||
* surfaces the Copy-as-prompt button prominently. The user's
|
||||
* question is still logged for post-mortem.
|
||||
*
|
||||
* Copy-as-prompt is ALWAYS available as a button regardless of mode. It's
|
||||
* the universal safety net — works with any LLM the user has access to,
|
||||
* including local Ollama, ChatGPT, Claude, etc.
|
||||
*
|
||||
* The Socratic system prompt is enforced server-side in the Worker (so it
|
||||
* can't be bypassed) but is also embedded in the Copy-as-prompt output so
|
||||
* users pasting into other LLMs get the same constraint.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
ChevronDown, ChevronRight, MessageCircle, Send, Info,
|
||||
ClipboardCopy, Check, AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const INTERVIEWER_ENDPOINT =
|
||||
process.env.NEXT_PUBLIC_INTERVIEWER_ENDPOINT?.replace(/\/+$/, "") || "";
|
||||
|
||||
// The same Socratic constraint enforced in the Worker. Embedded here so the
|
||||
// Copy-as-prompt output carries it into whatever LLM the user pastes into.
|
||||
const SOCRATIC_PROMPT_FOR_COPY = `You are a senior ML systems interviewer running a clarification round. Your only job is to answer the candidate's clarifying questions about constraints, scale, latency budgets, SLOs, traffic patterns, hardware availability, team size, and timeline.
|
||||
|
||||
You must NOT solve the problem. You must NOT propose architectures, algorithms, frameworks, or implementations. If the candidate asks "how should I do X" or "what's the right approach," redirect with: "That's the part I want to see you reason through. What constraint do you need from me first?"
|
||||
|
||||
Keep answers under 60 words. Be specific and concrete — give numbers when reasonable. Use a senior interviewer's tone: direct, no fluff, no apologies.`;
|
||||
|
||||
interface Message {
|
||||
/** Stable id for React keys + aria-live announcements. Generated client-side
|
||||
* per message; never rely on array index because we reset and append. */
|
||||
id: string;
|
||||
role: "user" | "interviewer";
|
||||
text: string;
|
||||
// For interviewer messages: provenance metadata returned by the Worker
|
||||
vendorLabel?: string;
|
||||
modelLabel?: string;
|
||||
privacyNote?: string;
|
||||
}
|
||||
|
||||
let _msgIdCounter = 0;
|
||||
function nextMessageId(): string {
|
||||
_msgIdCounter += 1;
|
||||
return `m${_msgIdCounter}`;
|
||||
}
|
||||
|
||||
interface AskInterviewerProps {
|
||||
/** The current question's scenario text. Sent as context to the LLM. */
|
||||
questionContext: string;
|
||||
/** Open by default (e.g. when realism = "open"). */
|
||||
defaultOpen?: boolean;
|
||||
/** Notified every time the user submits a clarification. Used by the
|
||||
* gauntlet results phase to surface "you asked N clarifications." */
|
||||
onAsk?: (question: string) => void;
|
||||
}
|
||||
|
||||
const HOSTED_AVAILABLE = INTERVIEWER_ENDPOINT.length > 0;
|
||||
|
||||
export default function AskInterviewer({ questionContext, defaultOpen = false, onAsk }: AskInterviewerProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
// AbortController for the in-flight fetch. Cleared on questionContext change
|
||||
// so a stale response can never inject into the next question's transcript.
|
||||
const inFlightRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Reset transcript when the question changes. CRITICAL: also clear `busy`
|
||||
// and abort any in-flight fetch — otherwise navigating mid-fetch leaves
|
||||
// the input permanently disabled until the user reopens the panel.
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
if (inFlightRef.current) {
|
||||
inFlightRef.current.abort();
|
||||
inFlightRef.current = null;
|
||||
}
|
||||
}, [questionContext]);
|
||||
|
||||
// Cleanup any in-flight fetch on unmount as well
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
inFlightRef.current?.abort();
|
||||
inFlightRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll the transcript on new messages — but only if the user is
|
||||
// already near the bottom. Don't yank them away from older messages they're
|
||||
// actively reading.
|
||||
useEffect(() => {
|
||||
const el = transcriptRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (distanceFromBottom < 64) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const submit = async () => {
|
||||
const question = draft.trim();
|
||||
if (!question || busy) return;
|
||||
|
||||
// Always log the user's clarification for post-mortem aggregation
|
||||
setMessages((prev) => [...prev, { id: nextMessageId(), role: "user", text: question }]);
|
||||
onAsk?.(question);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
|
||||
if (!HOSTED_AVAILABLE) {
|
||||
// Journal mode — no AI call. The clarification is the educational act.
|
||||
// Show a one-time inline note so users understand why there's no answer.
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nextMessageId(),
|
||||
role: "interviewer",
|
||||
text:
|
||||
"(journal mode) Your clarification is logged for the post-mortem. " +
|
||||
"Use the Copy-as-prompt button below to ask in your own LLM.",
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tear down any older in-flight request before starting a new one.
|
||||
inFlightRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
inFlightRef.current = controller;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch(`${INTERVIEWER_ENDPOINT}/ask`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
question,
|
||||
context: questionContext,
|
||||
history: messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.text,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { message?: string; error?: string };
|
||||
const msg = data.message || data.error || `interviewer service returned ${res.status}`;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: nextMessageId(), role: "interviewer", text: `⚠ ${msg}` },
|
||||
]);
|
||||
setError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
answer: string;
|
||||
provider: string;
|
||||
vendorLabel: string;
|
||||
modelLabel: string;
|
||||
privacyNote: string;
|
||||
};
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nextMessageId(),
|
||||
role: "interviewer",
|
||||
text: data.answer,
|
||||
vendorLabel: data.vendorLabel,
|
||||
modelLabel: data.modelLabel,
|
||||
privacyNote: data.privacyNote,
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
// AbortError from a stale request is expected — swallow silently.
|
||||
if (e instanceof DOMException && e.name === "AbortError") return;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: nextMessageId(), role: "interviewer", text: `⚠ Failed to reach interviewer service: ${msg}` },
|
||||
]);
|
||||
setError(msg);
|
||||
} finally {
|
||||
// Only clear busy/inFlightRef if THIS controller is still the active one.
|
||||
// If a newer submit started (or questionContext changed), don't unset.
|
||||
if (inFlightRef.current === controller) {
|
||||
setBusy(false);
|
||||
inFlightRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Copy-as-prompt: build a self-contained prompt for any LLM ─────
|
||||
const copyAsPrompt = async () => {
|
||||
const userTurns = messages.filter((m) => m.role === "user");
|
||||
const numbered = userTurns.length > 0
|
||||
? userTurns.map((m, i) => `${i + 1}. ${m.text}`).join("\n")
|
||||
: draft.trim()
|
||||
? `1. ${draft.trim()}`
|
||||
: "(no clarifying questions yet — write yours below this prompt)";
|
||||
|
||||
const text = `${SOCRATIC_PROMPT_FOR_COPY}
|
||||
|
||||
---
|
||||
|
||||
Scenario:
|
||||
${questionContext}
|
||||
|
||||
My clarifying questions so far:
|
||||
${numbered}
|
||||
|
||||
Please answer each as the interviewer.`;
|
||||
|
||||
// navigator.clipboard is undefined on insecure-context (http on non-localhost)
|
||||
// and may throw on permission denial. Handle both cleanly.
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard) {
|
||||
setError("Clipboard API not available — try using a secure (https) context.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2500);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "permission denied";
|
||||
setError(`Could not write to clipboard: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const userClarificationCount = messages.filter((m) => m.role === "user").length;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
aria-controls="ask-interviewer-body"
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-[10px] font-mono text-textTertiary uppercase tracking-widest hover:text-textSecondary transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MessageCircle className="w-3 h-3" /> Ask Interviewer
|
||||
{userClarificationCount > 0 && (
|
||||
<span className="ml-1 px-1 text-[9px] font-bold bg-accentBlue/20 text-accentBlue rounded">
|
||||
{userClarificationCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div id="ask-interviewer-body" className="px-4 pb-4">
|
||||
{/* Mode banner (journal vs hosted) — shown once at the top */}
|
||||
{messages.length === 0 && (
|
||||
HOSTED_AVAILABLE ? (
|
||||
<div className="flex items-start gap-2 mb-3 p-2.5 rounded-md bg-accentBlue/5 border border-accentBlue/20">
|
||||
<Info className="w-3.5 h-3.5 text-accentBlue shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-textSecondary leading-relaxed">
|
||||
Practice the clarification ritual real interviews reward. Your questions go to a
|
||||
small AI interviewer with a Socratic constraint — it can answer constraints, never
|
||||
solve the problem. <span className="font-semibold">AI may be wrong — verify against the model answer.</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 mb-3 p-2.5 rounded-md bg-accentAmber/10 border border-accentAmber/30">
|
||||
<Info className="w-3.5 h-3.5 text-accentAmber shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-textSecondary leading-relaxed">
|
||||
Journal mode. Your clarifying questions are logged for the post-mortem but no
|
||||
AI is wired. Use the <span className="font-semibold">Copy as prompt</span> button
|
||||
below to ask in your own LLM.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Transcript */}
|
||||
{messages.length > 0 && (
|
||||
<div
|
||||
ref={transcriptRef}
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="Clarification transcript"
|
||||
className="mb-3 max-h-56 overflow-y-auto space-y-2.5 p-2.5 rounded-md border border-border bg-background"
|
||||
>
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className="text-[11px] leading-relaxed">
|
||||
<span
|
||||
className={clsx(
|
||||
"font-mono text-[9px] uppercase mr-2",
|
||||
m.role === "user" ? "text-accentBlue" : "text-accentGreen",
|
||||
)}
|
||||
>
|
||||
{m.role === "user" ? "you" : "interviewer"}
|
||||
</span>
|
||||
<span className={m.role === "user" ? "text-textPrimary" : "text-textSecondary"}>
|
||||
{m.text}
|
||||
</span>
|
||||
{m.role === "interviewer" && m.modelLabel && (
|
||||
<div className="mt-1 ml-1 text-[9px] font-mono text-textTertiary/70 italic">
|
||||
{m.modelLabel} via {m.vendorLabel} · AI may be wrong · check the model answer
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<label className="sr-only" htmlFor="ask-interviewer-input">
|
||||
Ask a clarifying question
|
||||
</label>
|
||||
<textarea
|
||||
id="ask-interviewer-input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask a clarifying question — e.g. 'what's the latency budget?' or 'how many concurrent users?'"
|
||||
rows={2}
|
||||
className="w-full bg-background border border-border rounded-md p-2 font-mono text-[11px] text-textPrimary resize-none focus:outline-none focus:border-accentBlue/50 placeholder:text-textTertiary/60 leading-relaxed"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mt-2">
|
||||
<span className="text-[10px] font-mono text-textTertiary">⌘↵ to send</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={copyAsPrompt}
|
||||
disabled={busy}
|
||||
title="Copy a self-contained prompt for use in any LLM (ChatGPT, Claude, Ollama, etc.)"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-[10px] font-medium text-textSecondary border border-border rounded hover:bg-surfaceHover transition-all disabled:opacity-40"
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3 text-accentGreen" /> : <ClipboardCopy className="w-3 h-3" />}
|
||||
{copied ? "Copied" : "Copy as prompt"}
|
||||
</button>
|
||||
{HOSTED_AVAILABLE && (
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !draft.trim()}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-bold bg-accentBlue text-white rounded transition-all hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-3 h-3" />
|
||||
{busy ? "Asking…" : "Ask"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy footer — shows the active provider's privacy note when present,
|
||||
otherwise the journal-mode default. Always visible so the social
|
||||
contract is in front of the user at the point of action. */}
|
||||
<div className="mt-3 pt-2 border-t border-borderSubtle flex items-start gap-1.5 text-[9px] text-textTertiary leading-relaxed">
|
||||
<AlertTriangle className="w-2.5 h-2.5 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{HOSTED_AVAILABLE ? (
|
||||
<>
|
||||
Questions you type are sent to an external LLM service via StaffML's relay.
|
||||
The relay does not log requests. Don't paste anything sensitive to your employer.
|
||||
Your model answer (below) is the source of truth.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Journal mode — nothing leaves your browser. Use Copy-as-prompt and paste into the
|
||||
LLM of your choice if you want answers.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && !messages.find((m) => m.text.includes(error)) && (
|
||||
<p className="mt-2 text-[10px] text-accentRed" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Cmd+K / Ctrl+K command palette.
|
||||
*
|
||||
* Single global instance mounted in app/layout.tsx. Three sections:
|
||||
* 1. Pages — fixed list of routes (Vault, Practice, Mock Interview, etc.)
|
||||
* 2. Topics — fuzzy search across the taxonomy
|
||||
* 3. Questions — fuzzy search across all 9,200 question titles
|
||||
*
|
||||
* Keyboard:
|
||||
* Cmd/Ctrl+K open/close
|
||||
* Esc close
|
||||
* ↑↓ navigate
|
||||
* Enter commit selected result
|
||||
*
|
||||
* No external library — `cmdk` adds 30kB and we already need only this
|
||||
* one component. Roughly 250 lines, zero deps beyond what staffml has.
|
||||
*
|
||||
* a11y notes:
|
||||
* role="dialog" + aria-modal on the surface
|
||||
* role="listbox" on the result list, role="option" on items
|
||||
* aria-selected on the active row
|
||||
* focus on the input on open, restore on close
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Search, X, Target, Crosshair, BookOpen, BarChart3, Info,
|
||||
FileText, Cpu, ArrowRight,
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { searchTopics } from "@/lib/taxonomy";
|
||||
import { searchQuestions } from "@/lib/corpus";
|
||||
|
||||
type Result =
|
||||
| { kind: "page"; title: string; subtitle: string; href: string; icon: React.ComponentType<{ className?: string }> }
|
||||
| { kind: "topic"; title: string; subtitle: string; topicId: string }
|
||||
| { kind: "question"; title: string; subtitle: string; questionId: string };
|
||||
|
||||
const PAGES: Result[] = [
|
||||
{ kind: "page", title: "Vault", subtitle: "Browse all topics", href: "/", icon: BookOpen },
|
||||
{ kind: "page", title: "Practice", subtitle: "Untimed, with helpers", href: "/practice", icon: Target },
|
||||
{ kind: "page", title: "Mock Interview", subtitle: "Timed, the gauntlet", href: "/gauntlet", icon: Crosshair },
|
||||
{ kind: "page", title: "Progress", subtitle: "Stats and weak spots", href: "/progress", icon: BarChart3 },
|
||||
{ kind: "page", title: "Plans", subtitle: "Curated study tracks", href: "/plans", icon: FileText },
|
||||
{ kind: "page", title: "Roofline", subtitle: "Compute vs bandwidth", href: "/roofline", icon: Cpu },
|
||||
{ kind: "page", title: "Simulator", subtitle: "Hardware sandbox", href: "/simulator", icon: Cpu },
|
||||
{ kind: "page", title: "About", subtitle: "What is StaffML", href: "/about", icon: Info },
|
||||
];
|
||||
|
||||
function pageMatches(p: Result, q: string): boolean {
|
||||
if (p.kind !== "page") return false;
|
||||
const needle = q.toLowerCase().trim();
|
||||
if (!needle) return true;
|
||||
return (p.title + " " + p.subtitle).toLowerCase().includes(needle);
|
||||
}
|
||||
|
||||
// Debounce window for the corpus search. The 9k-question search is fast in
|
||||
// absolute terms but on every keystroke it competes with input rendering for
|
||||
// the main thread; debouncing keeps the input responsive.
|
||||
const SEARCH_DEBOUNCE_MS = 120;
|
||||
|
||||
export default function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
// searchQuery lags `query` by SEARCH_DEBOUNCE_MS — used for the expensive
|
||||
// searchQuestions / searchTopics calls so we don't re-scan 9k questions
|
||||
// on every keystroke.
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
// ─── Global keyboard: open with Cmd+K / Ctrl+K ───────
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen(o => !o);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// ─── Open: focus input, capture previously-focused element ──
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||
// small timeout so the input exists in the DOM
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
setActiveIdx(0);
|
||||
setQuery("");
|
||||
setSearchQuery("");
|
||||
} else {
|
||||
previouslyFocused.current?.focus?.();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// ─── Debounce: update searchQuery after the user stops typing ──
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = setTimeout(() => setSearchQuery(query), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [query, open]);
|
||||
|
||||
// ─── Compute results ─────────────────────────────────
|
||||
// Pages filter on the live `query` (cheap, in-memory list filter).
|
||||
// Topics + questions filter on the debounced `searchQuery` because they
|
||||
// walk the full corpus.
|
||||
const results = useMemo<Result[]>(() => {
|
||||
const out: Result[] = [];
|
||||
|
||||
const pages = PAGES.filter(p => pageMatches(p, query));
|
||||
out.push(...pages);
|
||||
|
||||
if (searchQuery.trim().length >= 2) {
|
||||
try {
|
||||
const topics = searchTopics(searchQuery).slice(0, 8);
|
||||
for (const t of topics) {
|
||||
out.push({
|
||||
kind: "topic",
|
||||
title: t.name,
|
||||
subtitle: `${t.questionCount} question${t.questionCount === 1 ? "" : "s"}`,
|
||||
topicId: t.id,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* searchTopics may throw on cold start; swallow */
|
||||
}
|
||||
|
||||
try {
|
||||
const questions = searchQuestions(searchQuery, 12);
|
||||
for (const q of questions) {
|
||||
out.push({
|
||||
kind: "question",
|
||||
title: q.title,
|
||||
subtitle: `${q.level} · ${q.track} · ${q.competency_area}`,
|
||||
questionId: q.id,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* same */
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [query, searchQuery]);
|
||||
|
||||
// Reset active index when results change
|
||||
useEffect(() => {
|
||||
setActiveIdx(0);
|
||||
}, [query, searchQuery]);
|
||||
|
||||
// ─── Commit a result ─────────────────────────────────
|
||||
const commit = (r: Result) => {
|
||||
setOpen(false);
|
||||
if (r.kind === "page") router.push(r.href);
|
||||
else if (r.kind === "topic") router.push(`/?topic=${r.topicId}`);
|
||||
else if (r.kind === "question") router.push(`/practice?q=${r.questionId}`);
|
||||
};
|
||||
|
||||
// ─── Local keyboard inside the palette ───────────────
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // don't bubble to global ? overlay listener
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIdx(i => Math.min(i + 1, results.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIdx(i => Math.max(i - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const r = results[activeIdx];
|
||||
if (r) commit(r);
|
||||
return;
|
||||
}
|
||||
// Focus trap: cycle Tab/Shift+Tab within the surface so keyboard users
|
||||
// can't escape into the dimmed background. Without this they'd Tab into
|
||||
// arbitrary background controls.
|
||||
if (e.key === "Tab" && surfaceRef.current) {
|
||||
const focusables = surfaceRef.current.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Group result indexes by kind for section headers
|
||||
const sections: { label: string; items: { result: Result; idx: number }[] }[] = [];
|
||||
let cursor = 0;
|
||||
const pages = results.filter(r => r.kind === "page");
|
||||
if (pages.length) {
|
||||
sections.push({
|
||||
label: "Pages",
|
||||
items: pages.map((r, i) => ({ result: r, idx: cursor + i })),
|
||||
});
|
||||
cursor += pages.length;
|
||||
}
|
||||
const topics = results.filter(r => r.kind === "topic");
|
||||
if (topics.length) {
|
||||
sections.push({
|
||||
label: "Topics",
|
||||
items: topics.map((r, i) => ({ result: r, idx: cursor + i })),
|
||||
});
|
||||
cursor += topics.length;
|
||||
}
|
||||
const questions = results.filter(r => r.kind === "question");
|
||||
if (questions.length) {
|
||||
sections.push({
|
||||
label: "Questions",
|
||||
items: questions.map((r, i) => ({ result: r, idx: cursor + i })),
|
||||
});
|
||||
cursor += questions.length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Surface */}
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
className="relative w-full max-w-2xl bg-background border border-border rounded-xl shadow-2xl overflow-hidden"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{/* Input row */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
|
||||
<Search className="w-4 h-4 text-textTertiary shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search pages, topics, or questions…"
|
||||
className="flex-1 bg-transparent text-[14px] text-textPrimary placeholder:text-textTertiary focus:outline-none"
|
||||
aria-label="Command palette query"
|
||||
aria-controls="command-palette-results"
|
||||
aria-activedescendant={results[activeIdx] ? `cmdk-row-${activeIdx}` : undefined}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<kbd className="text-[10px] font-mono text-textTertiary border border-border rounded px-1.5 py-0.5 bg-surface">
|
||||
ESC
|
||||
</kbd>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close command palette"
|
||||
className="p-1 text-textTertiary hover:text-textPrimary rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<ul
|
||||
id="command-palette-results"
|
||||
role="listbox"
|
||||
aria-label="Command palette results"
|
||||
className="max-h-[60vh] overflow-y-auto"
|
||||
>
|
||||
{sections.length === 0 && (
|
||||
<li className="px-4 py-8 text-center text-[13px] text-textTertiary">
|
||||
No results. Try a topic name like “flash attention” or a question keyword.
|
||||
</li>
|
||||
)}
|
||||
{sections.map((section) => (
|
||||
<li key={section.label}>
|
||||
<div className="px-4 pt-3 pb-1 text-[10px] font-mono text-textTertiary uppercase tracking-widest">
|
||||
{section.label}
|
||||
</div>
|
||||
<ul>
|
||||
{section.items.map(({ result, idx }) => {
|
||||
const isActive = idx === activeIdx;
|
||||
const Icon =
|
||||
result.kind === "page"
|
||||
? result.icon
|
||||
: result.kind === "topic"
|
||||
? Cpu
|
||||
: Target;
|
||||
return (
|
||||
<li
|
||||
key={`${result.kind}-${idx}`}
|
||||
id={`cmdk-row-${idx}`}
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveIdx(idx)}
|
||||
onClick={() => commit(result)}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors",
|
||||
isActive
|
||||
? "bg-accentBlue/15 text-textPrimary"
|
||||
: "text-textSecondary hover:bg-surfaceHover"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0 text-accentBlue/80" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium truncate">{result.title}</div>
|
||||
<div className="text-[11px] text-textTertiary truncate">{result.subtitle}</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<ArrowRight className="w-3.5 h-3.5 shrink-0 text-accentBlue/70" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="px-4 py-2 border-t border-border bg-surface/50 flex items-center gap-3 text-[10px] font-mono text-textTertiary">
|
||||
<span><kbd className="border border-border rounded px-1 bg-background">↑</kbd><kbd className="border border-border rounded px-1 bg-background ml-0.5">↓</kbd> navigate</span>
|
||||
<span><kbd className="border border-border rounded px-1 bg-background">↵</kbd> open</span>
|
||||
<span><kbd className="border border-border rounded px-1 bg-background">esc</kbd> close</span>
|
||||
<span className="ml-auto">Cmd+K from anywhere</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* First-run explainer panel.
|
||||
*
|
||||
* One inline panel per mode (Vault / Practice / Mock Interview). Shown the
|
||||
* first time a user lands on a mode, dismissed on first interaction or by
|
||||
* the explicit "Got it" button. Persisted in localStorage by mode key.
|
||||
*
|
||||
* Why empty-state and not a tour:
|
||||
* - Tour libraries (Shepherd, Intro.js) have terrible a11y stories.
|
||||
* - Empty-state inline panels are fully keyboard-accessible by default.
|
||||
* - The user can re-read by clearing localStorage; we never trap them.
|
||||
*
|
||||
* Add a new mode by adding an entry to MODE_CONTENT and rendering
|
||||
* <FirstRunExplainer mode="your-mode" /> at the top of the mode's page.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Target, Crosshair, Calculator, Cpu, Clock, Repeat } from "lucide-react";
|
||||
|
||||
export type ModeKey = "practice" | "gauntlet";
|
||||
|
||||
interface ModeContent {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
tagline: string;
|
||||
bullets: { icon: React.ComponentType<{ className?: string }>; text: string }[];
|
||||
}
|
||||
|
||||
const MODE_CONTENT: Record<ModeKey, ModeContent> = {
|
||||
practice: {
|
||||
icon: Target,
|
||||
title: "Practice mode",
|
||||
tagline: "Untimed. Bring tools. Self-rate honestly. Build a learning loop.",
|
||||
bullets: [
|
||||
{ icon: Calculator, text: "The Napkin Calc panel handles common back-of-envelope formulas (model memory, training time, KV cache, ridge point)." },
|
||||
{ icon: Cpu, text: "Hardware Reference has GPU specs, latency hierarchy, and interconnects — open it any time." },
|
||||
{ icon: Target, text: "After revealing the answer, rate yourself 1–4. Be honest — the rating is the whole signal." },
|
||||
{ icon: Repeat, text: "Wrong answers come back tomorrow. Partial answers in 3 days. Nailed answers in 1–2 weeks. The 'due' counter in the nav tells you what's waiting." },
|
||||
],
|
||||
},
|
||||
gauntlet: {
|
||||
icon: Crosshair,
|
||||
title: "Mock Interview",
|
||||
tagline: "Timed. The clock matters. Tools are available but you should know when to reach for them.",
|
||||
bullets: [
|
||||
{ icon: Clock, text: "Pick a length (5 / 10 / 15 questions, or one deep design problem). The clock starts on Begin." },
|
||||
{ icon: Calculator, text: "Hardware Ref and Napkin Calc are collapsed by default. Open them only when a real interview would let you peek." },
|
||||
{ icon: Target, text: "After each question: type your answer, reveal, compare against the model, then self-rate Skip / Wrong / Partial / Nailed." },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = "staffml_firstrun_";
|
||||
|
||||
function isDismissed(mode: ModeKey): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_PREFIX + mode) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss(mode: ModeKey) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + mode, "1");
|
||||
} catch {
|
||||
/* localStorage may be disabled — fail silently */
|
||||
}
|
||||
}
|
||||
|
||||
export default function FirstRunExplainer({ mode }: { mode: ModeKey }) {
|
||||
// Always start hidden on the server to avoid hydration mismatch.
|
||||
// Then check localStorage on the client and reveal if needed.
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDismissed(mode)) setVisible(true);
|
||||
}, [mode]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const content = MODE_CONTENT[mode];
|
||||
const Icon = content.icon;
|
||||
|
||||
const close = () => {
|
||||
dismiss(mode);
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mx-4 mt-4 mb-2 lg:mx-6 rounded-xl border border-accentBlue/30 bg-accentBlue/5 p-5"
|
||||
role="region"
|
||||
aria-label={`Welcome to ${content.title}`}
|
||||
>
|
||||
<button
|
||||
onClick={close}
|
||||
aria-label="Dismiss welcome panel"
|
||||
className="absolute top-3 right-3 p-1.5 text-textTertiary hover:text-textPrimary hover:bg-surfaceHover rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-accentBlue/50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-accentBlue/15 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-5 h-5 text-accentBlue" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pr-8">
|
||||
<h3 className="text-[15px] font-bold text-textPrimary">{content.title}</h3>
|
||||
<p className="text-[13px] text-textSecondary mt-0.5">{content.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 mb-4">
|
||||
{content.bullets.map((b, i) => {
|
||||
const BulletIcon = b.icon;
|
||||
return (
|
||||
<li key={i} className="flex items-start gap-2.5 text-[13px] text-textSecondary leading-relaxed">
|
||||
<BulletIcon className="w-3.5 h-3.5 mt-1 shrink-0 text-accentBlue/70" />
|
||||
<span>{b.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={close}
|
||||
className="text-[12px] font-mono text-accentBlue hover:text-textPrimary transition-colors focus:outline-none focus:ring-2 focus:ring-accentBlue/50 rounded px-2 py-1 -ml-2"
|
||||
>
|
||||
Got it →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { ChevronDown, ChevronRight, Cpu } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { HARDWARE_SPECS, INTERCONNECTS, LATENCY_HIERARCHY, HardwareSpec } from "@/lib/hardware";
|
||||
|
||||
export default function HardwareRef() {
|
||||
const [open, setOpen] = useState(false);
|
||||
export default function HardwareRef({ defaultOpen = false }: { defaultOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [activeTab, setActiveTab] = useState<'specs' | 'latency' | 'interconnects'>('specs');
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Global keyboard-shortcuts cheat sheet.
|
||||
*
|
||||
* Triggered by `?` from anywhere (except inside text inputs). Mounted once
|
||||
* in app/layout.tsx alongside the command palette. Uses the same a11y
|
||||
* primitives: role="dialog", aria-modal, Escape to close, focus management.
|
||||
*
|
||||
* Adding a shortcut: append to SECTIONS below. The overlay is the single
|
||||
* source of truth that the rest of the app should reference when adding
|
||||
* new keybindings.
|
||||
*
|
||||
* Why an overlay and not a /help page:
|
||||
* - Lower friction (no navigation, no route change)
|
||||
* - Matches the Linear / GitHub / Notion `?` convention
|
||||
* - Persists current page context (you can compare what you're doing
|
||||
* against the relevant shortcuts without losing your place)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { X, Keyboard } from "lucide-react";
|
||||
|
||||
interface Shortcut {
|
||||
keys: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
label: string;
|
||||
shortcuts: Shortcut[];
|
||||
}
|
||||
|
||||
const SECTIONS: Section[] = [
|
||||
{
|
||||
label: "Global",
|
||||
shortcuts: [
|
||||
{ keys: ["⌘", "K"], description: "Open command palette (search anything)" },
|
||||
{ keys: ["?"], description: "Show this keyboard shortcuts overlay" },
|
||||
{ keys: ["Esc"], description: "Close any open overlay or drawer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Practice",
|
||||
shortcuts: [
|
||||
{ keys: ["⌘", "↵"], description: "Reveal answer (when typing in the answer box)" },
|
||||
{ keys: ["1"], description: "Self-rate Wrong" },
|
||||
{ keys: ["2"], description: "Self-rate Partial" },
|
||||
{ keys: ["3"], description: "Self-rate Nailed it" },
|
||||
{ keys: ["N"], description: "Next question" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Mock Interview",
|
||||
shortcuts: [
|
||||
{ keys: ["⌘", "↵"], description: "Reveal answer (when typing in the answer box)" },
|
||||
{ keys: ["1"], description: "Self-rate Skip" },
|
||||
{ keys: ["2"], description: "Self-rate Wrong" },
|
||||
{ keys: ["3"], description: "Self-rate Partial" },
|
||||
{ keys: ["4"], description: "Self-rate Nailed it" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Vault",
|
||||
shortcuts: [
|
||||
{ keys: ["Esc"], description: "Close the topic detail drawer" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function KeyChip({ k }: { k: string }) {
|
||||
return (
|
||||
<kbd className="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 bg-surface border border-border rounded font-mono text-[11px] text-textPrimary">
|
||||
{k}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KeyboardShortcutsOverlay() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
const closeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Global `?` listener — guarded against text inputs and selects
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Allow Escape to close even when overlay holds focus
|
||||
if (e.key === "Escape" && open) {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
// Don't open when the user is typing or has a select focused
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (
|
||||
t && (
|
||||
t.tagName === "INPUT" ||
|
||||
t.tagName === "TEXTAREA" ||
|
||||
t.tagName === "SELECT" ||
|
||||
(t as HTMLElement).isContentEditable
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
setOpen(o => !o);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// Focus trap: cycle Tab/Shift+Tab within the surface while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab" || !surfaceRef.current) return;
|
||||
const focusables = surfaceRef.current.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// Focus management on open/close
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||
setTimeout(() => closeBtnRef.current?.focus(), 0);
|
||||
} else {
|
||||
previouslyFocused.current?.focus?.();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-start justify-center pt-[12vh] px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="keyboard-shortcuts-title"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div ref={surfaceRef} className="relative w-full max-w-2xl bg-background border border-border rounded-xl shadow-2xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard className="w-4 h-4 text-accentBlue" />
|
||||
<h2 id="keyboard-shortcuts-title" className="text-[14px] font-bold text-textPrimary">
|
||||
Keyboard shortcuts
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
ref={closeBtnRef}
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close keyboard shortcuts"
|
||||
className="p-1.5 text-textTertiary hover:text-textPrimary rounded transition-colors focus:outline-none focus:ring-2 focus:ring-accentBlue/50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 max-h-[70vh] overflow-y-auto">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-6">
|
||||
{SECTIONS.map((section) => (
|
||||
<div key={section.label}>
|
||||
<div className="text-[10px] font-mono text-textTertiary uppercase tracking-widest mb-2">
|
||||
{section.label}
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{section.shortcuts.map((s, i) => (
|
||||
<li key={i} className="flex items-center justify-between gap-3">
|
||||
<span className="text-[12px] text-textSecondary">{s.description}</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
{s.keys.map((k, j) => (
|
||||
<span key={j} className="flex items-center gap-1">
|
||||
{j > 0 && <span className="text-[10px] text-textTertiary">+</span>}
|
||||
<KeyChip k={k} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-2 border-t border-border bg-surface/50 text-[10px] font-mono text-textTertiary">
|
||||
Press <KeyChip k="?" /> from anywhere to toggle this overlay.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { getLevelDef } from "@/lib/levels";
|
||||
import { levelTooltip } from "@/lib/meta-descriptions";
|
||||
import MetaTooltip from "@/components/MetaTooltip";
|
||||
|
||||
export default function LevelBadge({ level, size = "sm" }: { level: string; size?: "sm" | "md" }) {
|
||||
export default function LevelBadge({
|
||||
level,
|
||||
size = "sm",
|
||||
withTooltip = true,
|
||||
}: {
|
||||
level: string;
|
||||
size?: "sm" | "md";
|
||||
/** Set false to opt out of the tooltip — useful inside other tooltipped controls */
|
||||
withTooltip?: boolean;
|
||||
}) {
|
||||
const def = getLevelDef(level);
|
||||
const isSm = size === "sm";
|
||||
|
||||
return (
|
||||
const badge = (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 font-semibold rounded-md border ${
|
||||
isSm ? "text-[10px] px-1.5 py-0.5" : "text-[12px] px-2 py-1"
|
||||
@@ -21,4 +32,12 @@ export default function LevelBadge({ level, size = "sm" }: { level: string; size
|
||||
<span className="opacity-70">{def.name}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!withTooltip) return badge;
|
||||
const tip = levelTooltip(level);
|
||||
return (
|
||||
<MetaTooltip title={tip.title} body={tip.body}>
|
||||
{badge}
|
||||
</MetaTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MetaTooltip — small accessible hover/focus tooltip for metadata badges.
|
||||
*
|
||||
* Wraps any inline element with a styled tooltip that appears on hover OR
|
||||
* keyboard focus. Pure CSS (no positioning library), so it can overflow
|
||||
* narrow containers; uses max-width to bound that.
|
||||
*
|
||||
* Accessibility:
|
||||
* - The trigger gets `tabIndex={0}` and `role="button"` if it's not
|
||||
* already a button
|
||||
* - The tooltip body is hidden visually until hover/focus but always in
|
||||
* the DOM, with `role="tooltip"` and an aria-describedby link
|
||||
* - Also sets a native `title=` attribute as a fallback for screen
|
||||
* readers that don't support live tooltip semantics
|
||||
*
|
||||
* Usage:
|
||||
* <MetaTooltip title="L4 — Analyze (Senior)" body="Can you compare trade-offs and diagnose?">
|
||||
* <span>L4</span>
|
||||
* </MetaTooltip>
|
||||
*/
|
||||
|
||||
import { useId, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function MetaTooltip({
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
side = "top",
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
body?: string;
|
||||
children: ReactNode;
|
||||
side?: "top" | "bottom";
|
||||
className?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
const fallbackTitle = body ? `${title}\n${body}` : title;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx("group relative inline-flex", className)}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-describedby={id}
|
||||
title={fallbackTitle}
|
||||
>
|
||||
{children}
|
||||
<span
|
||||
id={id}
|
||||
role="tooltip"
|
||||
className={clsx(
|
||||
// hidden by default, shown on group hover/focus-within
|
||||
"pointer-events-none absolute left-1/2 -translate-x-1/2 z-50",
|
||||
"w-64 max-w-[80vw] p-2.5 rounded-md bg-background border border-border shadow-lg",
|
||||
"opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible",
|
||||
"transition-opacity duration-150",
|
||||
side === "top" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
)}
|
||||
>
|
||||
<span className="block text-[11px] font-semibold text-textPrimary mb-1">{title}</span>
|
||||
{body && (
|
||||
<span className="block text-[10px] text-textSecondary leading-relaxed whitespace-pre-line">
|
||||
{body}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,8 @@ const CALC_MODES: { id: CalcMode; label: string; desc: string }[] = [
|
||||
{ id: 'kv_cache', label: 'KV Cache Size', desc: 'Inference memory for attention' },
|
||||
];
|
||||
|
||||
export default function NapkinCalc() {
|
||||
const [open, setOpen] = useState(false);
|
||||
export default function NapkinCalc({ defaultOpen = false }: { defaultOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [mode, setMode] = useState<CalcMode>('model_memory');
|
||||
|
||||
// Inputs
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ECOSYSTEM_BASE } from "../lib/env";
|
||||
import {
|
||||
Library, Target, Crosshair, BarChart3, BookOpen, Github,
|
||||
Menu, X, Sun, Moon, Map, Cpu, Server, ChevronDown, Info,
|
||||
Star, Bug, Send, Atom,
|
||||
Star, Bug, Send, Atom, MoreHorizontal, Search,
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import StreakBadge from "@/components/StreakBadge";
|
||||
@@ -15,30 +15,53 @@ import { buildSiteIssueUrl } from "@/lib/issue-url";
|
||||
import { getDueCount } from "@/lib/progress";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
|
||||
// IA reorganization (per dev-tools UX review):
|
||||
// - About moved out of primary into the "More" user menu
|
||||
// - Plans promoted from Tools dropdown to primary nav
|
||||
// - "Tools" renamed to "Lab" (now contains only the calculator-style sandboxes)
|
||||
// - Contribute, Dashboard, About moved to "More" user menu (top-right)
|
||||
const primaryLinks = [
|
||||
{ href: "/", label: "Vault", icon: Library },
|
||||
{ href: "/practice", label: "Practice", icon: Target },
|
||||
{ href: "/gauntlet", label: "Mock Interview", icon: Crosshair },
|
||||
{ href: "/plans", label: "Plans", icon: Map },
|
||||
{ href: "/progress", label: "Progress", icon: BarChart3 },
|
||||
{ href: "/about", label: "About", icon: Info },
|
||||
];
|
||||
|
||||
const toolLinks = [
|
||||
{ href: "/plans", label: "Study Plans", icon: Map },
|
||||
// "Lab" is the renamed Tools dropdown — only sandbox calculators belong here.
|
||||
// /framework was added on dev in parallel and belongs alongside the other
|
||||
// sandbox tools.
|
||||
const labLinks = [
|
||||
{ href: "/framework", label: "Framework", icon: Atom },
|
||||
{ href: "/contribute", label: "Contribute", icon: Send },
|
||||
{ href: "/roofline", label: "Roofline", icon: Cpu },
|
||||
{ href: "/simulator", label: "Simulator", icon: Server },
|
||||
{ href: "/dashboard", label: "Dashboard", icon: BarChart3 },
|
||||
];
|
||||
|
||||
// "More" is the new top-right user menu — secondary destinations
|
||||
const moreLinks = [
|
||||
{ href: "/about", label: "About", icon: Info, kind: "internal" as const },
|
||||
{ href: "/contribute", label: "Contribute", icon: Send, kind: "internal" as const },
|
||||
{ href: "/dashboard", label: "Dashboard", icon: BarChart3, kind: "internal" as const },
|
||||
];
|
||||
|
||||
export default function Nav() {
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
const [labOpen, setLabOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [dueCount, setDueCount] = useState(0);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const toolsRef = useRef<HTMLDivElement>(null);
|
||||
const labRef = useRef<HTMLDivElement>(null);
|
||||
const moreRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Detect Mac vs other for ⌘K hint
|
||||
// Detect Mac vs other for ⌘K hint. navigator.platform is deprecated;
|
||||
// navigator.userAgent is the recommended replacement and is reliable
|
||||
// enough for the binary "show ⌘K vs Ctrl K" decision.
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
useEffect(() => {
|
||||
setIsMac(/Mac|iPhone|iPad|iPod/i.test(navigator.userAgent));
|
||||
}, []);
|
||||
|
||||
// Check for due SR cards periodically
|
||||
useEffect(() => {
|
||||
@@ -49,11 +72,14 @@ export default function Nav() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Close tools dropdown on outside click
|
||||
// Close dropdowns on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (toolsRef.current && !toolsRef.current.contains(e.target as Node)) {
|
||||
setToolsOpen(false);
|
||||
if (labRef.current && !labRef.current.contains(e.target as Node)) {
|
||||
setLabOpen(false);
|
||||
}
|
||||
if (moreRef.current && !moreRef.current.contains(e.target as Node)) {
|
||||
setMoreOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
@@ -102,28 +128,31 @@ export default function Nav() {
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Tools dropdown */}
|
||||
{/* Lab dropdown — sandbox calculators only */}
|
||||
<div className="w-px h-4 bg-border mx-1.5" />
|
||||
<div ref={toolsRef} className="relative">
|
||||
<div ref={labRef} className="relative">
|
||||
<button
|
||||
onClick={() => setToolsOpen(!toolsOpen)}
|
||||
onClick={() => setLabOpen(!labOpen)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={labOpen}
|
||||
className={clsx(
|
||||
"flex items-center gap-1 px-2.5 py-1.5 rounded-md text-[13px] font-medium transition-colors",
|
||||
toolLinks.some(l => isActive(l.href))
|
||||
labLinks.some(l => isActive(l.href))
|
||||
? "bg-surface text-textPrimary border border-border"
|
||||
: "text-textTertiary hover:text-textSecondary hover:bg-surface/50"
|
||||
)}
|
||||
>
|
||||
Tools
|
||||
<ChevronDown className={clsx("w-3 h-3 transition-transform", toolsOpen && "rotate-180")} />
|
||||
Lab
|
||||
<ChevronDown className={clsx("w-3 h-3 transition-transform", labOpen && "rotate-180")} />
|
||||
</button>
|
||||
{toolsOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-48 bg-background border border-border rounded-lg shadow-lg py-1 z-50">
|
||||
{toolLinks.map(({ href, label, icon: Icon }) => (
|
||||
{labOpen && (
|
||||
<div role="menu" className="absolute top-full left-0 mt-1 w-48 bg-background border border-border rounded-lg shadow-lg py-1 z-50">
|
||||
{labLinks.map(({ href, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setToolsOpen(false)}
|
||||
role="menuitem"
|
||||
onClick={() => setLabOpen(false)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
|
||||
isActive(href)
|
||||
@@ -141,8 +170,23 @@ export default function Nav() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<StreakBadge />
|
||||
|
||||
{/* ⌘K command palette hint — clicks dispatch the same keyboard event */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, ctrlKey: !isMac }));
|
||||
}}
|
||||
className="hidden md:inline-flex items-center gap-2 px-2.5 py-1 text-[11px] font-medium text-textTertiary hover:text-textSecondary border border-border rounded-md bg-surface/50 hover:bg-surface transition-colors"
|
||||
aria-label="Open command palette"
|
||||
title="Search anything (Cmd+K)"
|
||||
>
|
||||
<Search className="w-3 h-3" />
|
||||
<span>Search</span>
|
||||
<kbd className="ml-1 font-mono text-[9px] text-textTertiary">{isMac ? "⌘K" : "Ctrl K"}</kbd>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-textTertiary hover:text-textSecondary transition-colors"
|
||||
@@ -150,25 +194,63 @@ export default function Nav() {
|
||||
>
|
||||
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
<a
|
||||
href={buildSiteIssueUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden md:flex text-textTertiary hover:text-textSecondary items-center gap-1.5 text-xs transition-colors"
|
||||
title="Report an issue"
|
||||
>
|
||||
<Bug className="w-3.5 h-3.5" />
|
||||
Report Issue
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/harvard-edge/cs249r_book"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden lg:inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-border rounded-lg text-textSecondary hover:text-textPrimary hover:bg-surface transition-colors"
|
||||
>
|
||||
<Star className="w-3.5 h-3.5" />
|
||||
Star on GitHub
|
||||
</a>
|
||||
|
||||
{/* More menu — About / Contribute / Dashboard / Star / Report */}
|
||||
<div ref={moreRef} className="relative hidden md:block">
|
||||
<button
|
||||
onClick={() => setMoreOpen(!moreOpen)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={moreOpen}
|
||||
aria-label="More menu"
|
||||
className="p-2 text-textTertiary hover:text-textSecondary transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
{moreOpen && (
|
||||
<div role="menu" className="absolute top-full right-0 mt-1 w-52 bg-background border border-border rounded-lg shadow-lg py-1 z-50">
|
||||
{moreLinks.map(({ href, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
role="menuitem"
|
||||
onClick={() => setMoreOpen(false)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
|
||||
isActive(href)
|
||||
? "text-textPrimary bg-surface"
|
||||
: "text-textSecondary hover:text-textPrimary hover:bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="my-1 border-t border-border" />
|
||||
<a
|
||||
href="https://github.com/harvard-edge/cs249r_book"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
role="menuitem"
|
||||
onClick={() => setMoreOpen(false)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-textSecondary hover:text-textPrimary hover:bg-surface/50 transition-colors"
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
Star on GitHub
|
||||
</a>
|
||||
<a
|
||||
href={buildSiteIssueUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
role="menuitem"
|
||||
onClick={() => setMoreOpen(false)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-textSecondary hover:text-textPrimary hover:bg-surface/50 transition-colors"
|
||||
>
|
||||
<Bug className="w-4 h-4" />
|
||||
Report Issue
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
@@ -200,8 +282,27 @@ export default function Nav() {
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-2 mt-2 border-t border-border">
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase tracking-widest px-3 block mb-2">Tools</span>
|
||||
{toolLinks.map(({ href, label, icon: Icon }) => (
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase tracking-widest px-3 block mb-2">Lab</span>
|
||||
{labLinks.map(({ href, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors",
|
||||
isActive(href)
|
||||
? "bg-accentBlue/10 text-accentBlue"
|
||||
: "text-textSecondary hover:text-textPrimary hover:bg-surfaceHover"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-2 mt-2 border-t border-border">
|
||||
<span className="text-[10px] font-mono text-textTertiary uppercase tracking-widest px-3 block mb-2">More</span>
|
||||
{moreLinks.map(({ href, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, ChevronLeft, X, BookOpen, ExternalLink, Target, Play } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ChevronRight, ChevronLeft, X, BookOpen, ExternalLink, FileText, Target, Play } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import type { Topic, AreaStyle } from "@/lib/taxonomy";
|
||||
import { classifyRef } from "@/lib/refs";
|
||||
import { LEVELS as LEVEL_DEFS } from "@/lib/levels";
|
||||
import LevelBadge from "@/components/LevelBadge";
|
||||
import SectionDivider from "./SectionDivider";
|
||||
@@ -21,6 +22,8 @@ export default function TopicDetail({ topic, areaName, style, onClose, selectedT
|
||||
}) {
|
||||
const [drillLevel, setDrillLevel] = useState<string | null>(null);
|
||||
const Icon = style.icon;
|
||||
const closeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const headingId = `topic-detail-${topic.id}`;
|
||||
|
||||
const [prevId, setPrevId] = useState(topic.id);
|
||||
if (topic.id !== prevId) { setPrevId(topic.id); setDrillLevel(null); }
|
||||
@@ -30,10 +33,37 @@ export default function TopicDetail({ topic, areaName, style, onClose, selectedT
|
||||
|
||||
const levelQs = drillLevel ? topic.questionsByLevel[drillLevel] || [] : [];
|
||||
|
||||
// ─── A11y: Escape to close, focus management ─────────────
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
closeBtnRef.current?.focus();
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
// Restore focus to whatever invoked the drawer
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
// onClose is stable from parent; we intentionally only wire this once per mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-[420px] h-full flex flex-col bg-background">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={headingId}
|
||||
className="w-full h-full flex flex-col bg-background"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-border"
|
||||
<div className="p-6 border-b border-border"
|
||||
style={{ background: `linear-gradient(180deg, ${style.primary}08 0%, transparent 100%)` }}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -42,10 +72,10 @@ export default function TopicDetail({ topic, areaName, style, onClose, selectedT
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wide"
|
||||
style={{ color: style.primary }}>{areaName}</span>
|
||||
</div>
|
||||
<h2 className="text-[20px] font-bold text-textPrimary leading-tight">{topic.name}</h2>
|
||||
<h2 id={headingId} className="text-[20px] font-bold text-textPrimary leading-tight">{topic.name}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="Close"
|
||||
className="p-2.5 -mr-1 text-textTertiary hover:text-textPrimary hover:bg-surfaceHover rounded-lg transition-colors">
|
||||
<button ref={closeBtnRef} onClick={onClose} aria-label="Close topic detail"
|
||||
className="p-2.5 -mr-1 text-textTertiary hover:text-textPrimary hover:bg-surfaceHover rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accentBlue/50">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -60,7 +90,7 @@ export default function TopicDetail({ topic, areaName, style, onClose, selectedT
|
||||
exit={{ x: 40, opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
|
||||
{/* Pinned sub-header */}
|
||||
<div className="p-5 pb-3 border-b border-borderSubtle sticky top-0 bg-background z-10">
|
||||
<div className="p-6 pb-3 border-b border-borderSubtle sticky top-0 bg-background z-10">
|
||||
<button onClick={() => setDrillLevel(null)}
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium text-textSecondary hover:text-textPrimary mb-4 transition-colors">
|
||||
<ChevronLeft className="w-4 h-4" /> Back to overview
|
||||
@@ -82,7 +112,7 @@ export default function TopicDetail({ topic, areaName, style, onClose, selectedT
|
||||
</div>
|
||||
|
||||
{/* Question list */}
|
||||
<div className="p-5 space-y-2">
|
||||
<div className="p-6 space-y-2">
|
||||
{levelQs.map((q) => (
|
||||
<Link key={q.id} href={`/practice?q=${q.id}`}
|
||||
className="block p-3.5 rounded-xl border border-borderSubtle bg-surface hover:bg-surfaceElevated hover:border-borderHighlight transition-all group">
|
||||
@@ -106,7 +136,7 @@ export default function TopicDetail({ topic, areaName, style, onClose, selectedT
|
||||
<motion.div key="overview"
|
||||
initial={{ x: -40, opacity: 0 }} animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -40, opacity: 0 }} transition={{ duration: 0.15 }}
|
||||
className="p-5 space-y-6">
|
||||
className="p-6 space-y-6">
|
||||
|
||||
{topic.description && (
|
||||
<p className="text-[14px] text-textSecondary leading-relaxed">{topic.description}</p>
|
||||
@@ -141,21 +171,34 @@ export default function TopicDetail({ topic, areaName, style, onClose, selectedT
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deep dive — show learning resource before drill */}
|
||||
{topic.chapterUrl && (
|
||||
<div>
|
||||
<SectionDivider label="Learn First" />
|
||||
<a href={topic.chapterUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-4 mt-3 rounded-xl border border-borderSubtle bg-surface hover:bg-surfaceElevated hover:border-borderHighlight transition-all group">
|
||||
<BookOpen className="w-5 h-5 text-textTertiary group-hover:text-accentBlue shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[14px] font-semibold text-textPrimary">{topic.name}</p>
|
||||
<p className="text-[12px] text-textTertiary mt-0.5">Read on MLSysBook.ai</p>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 text-textMuted group-hover:text-textSecondary shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{/* Deep dive — show learning resource before drill.
|
||||
Label is derived from the URL so we don't lie about destination.
|
||||
Book-source links currently show a "may be unavailable" warning
|
||||
pending the mlsysbook.ai chapter-route deploy fix. */}
|
||||
{topic.chapterUrl && (() => {
|
||||
const refInfo = classifyRef(topic.chapterUrl);
|
||||
const Icon = refInfo.isBook ? BookOpen : refInfo.source === "arxiv" || refInfo.source === "paper" ? FileText : ExternalLink;
|
||||
return (
|
||||
<div>
|
||||
<SectionDivider label="Learn First" />
|
||||
<a href={topic.chapterUrl} target="_blank" rel="noopener noreferrer"
|
||||
title={refInfo.mayBeUnavailable ? "This link may be temporarily unavailable while the book site is being redeployed." : refInfo.label}
|
||||
className="flex items-center gap-3 p-4 mt-3 rounded-xl border border-borderSubtle bg-surface hover:bg-surfaceElevated hover:border-borderHighlight transition-all group">
|
||||
<Icon className="w-5 h-5 text-textTertiary group-hover:text-accentBlue shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[14px] font-semibold text-textPrimary">{topic.name}</p>
|
||||
<p className="text-[12px] text-textTertiary mt-0.5 flex items-center gap-1.5">
|
||||
{refInfo.label}
|
||||
{refInfo.mayBeUnavailable && (
|
||||
<span className="text-accentAmber text-[10px]" aria-label="May be unavailable">⚠ may be down</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 text-textMuted group-hover:text-textSecondary shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Drill all CTA */}
|
||||
<Link href={`/practice?topic=${topic.id}${trackParam}`}
|
||||
|
||||
@@ -244,6 +244,32 @@ export function cleanScenario(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ─── Answer-type inference ──────────────────────────────────
|
||||
// TODO(answer_type): replace this heuristic with an explicit
|
||||
// `answer_type: 'numeric' | 'recall' | 'conceptual' | 'design'`
|
||||
// field on every question in the corpus. Until then, infer from
|
||||
// the scenario text. The bias is conservative: only classify as
|
||||
// numeric when we're confident, so the grader can never fire on
|
||||
// a recall question like "What does NPU stand for?".
|
||||
const QUANT_VERBS = /\b(estimate|calculate|compute|how (?:many|much|long|fast|big)|what(?:'s| is) the (?:size|bandwidth|throughput|latency|memory|cost|time|number|ratio)|derive|approximate)\b/i;
|
||||
const HAS_DIGIT = /\d/;
|
||||
const QUESTION_MARK = /\?/;
|
||||
|
||||
export function isNumericQuestion(question: { scenario: string; details: { napkin_math?: string } }): boolean {
|
||||
// Required: the corpus has napkin math AND that napkin math contains a number
|
||||
if (!question.details.napkin_math) return false;
|
||||
if (extractFinalNumber(question.details.napkin_math) === null) return false;
|
||||
|
||||
// Required: the prompt itself either contains a quantitative verb,
|
||||
// or shows a digit alongside a question mark (a typical numeric ask).
|
||||
const scenario = question.scenario || '';
|
||||
if (QUANT_VERBS.test(scenario)) return true;
|
||||
if (HAS_DIGIT.test(scenario) && QUESTION_MARK.test(scenario)) return true;
|
||||
|
||||
// Defensive default: not numeric. Falls back to self-rate.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the user's final answer number
|
||||
export function extractFinalNumber(text: string): number | null {
|
||||
const markerMatch = text.match(/(?:^|\n)\s*(?:=>|answer:|final:)\s*([\d,]+(?:\.\d+)?)/im);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Human-readable definitions for the jargon a new StaffML user encounters.
|
||||
*
|
||||
* "L4 Analyze" means nothing to a first-time visitor. Neither does "tinyml"
|
||||
* or "operations" or "data". This module is the single source of truth for
|
||||
* tooltip text on every level / track / competency / zone badge in the app.
|
||||
*
|
||||
* Pure data, zero deps. Edit here to update tooltips everywhere.
|
||||
*/
|
||||
|
||||
import { getLevelDef } from "./levels";
|
||||
|
||||
// ─── Level descriptions ─────────────────────────────────────
|
||||
// Levels already have name + role + verb + example in lib/levels.ts.
|
||||
// This helper composes them into a single tooltip string.
|
||||
export function levelTooltip(id: string): { title: string; body: string } {
|
||||
const def = getLevelDef(id);
|
||||
return {
|
||||
title: `${def.id} — ${def.name} (${def.role})`,
|
||||
body: `${def.verb}\nExample: ${def.example}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Track descriptions ─────────────────────────────────────
|
||||
// Tracks are the deployment context. They aren't in a typed lib because
|
||||
// they're just strings in the corpus, so this is the only place they're
|
||||
// defined for the UI.
|
||||
const TRACK_DESCRIPTIONS: Record<string, { title: string; body: string }> = {
|
||||
cloud: {
|
||||
title: "Cloud track",
|
||||
body: "Datacenter ML systems: 8-GPU servers, multi-node training, racks of accelerators, hundreds of GB of HBM. Latency budgets in milliseconds, throughput in thousands of QPS.",
|
||||
},
|
||||
edge: {
|
||||
title: "Edge track",
|
||||
body: "On-prem and industrial ML: single-box deployments, embedded GPUs, intermittent connectivity. Power and thermal constraints matter as much as throughput.",
|
||||
},
|
||||
mobile: {
|
||||
title: "Mobile track",
|
||||
body: "On-device ML on phones and tablets: NPUs, NEON/AMX, model compression, battery life. Models must be small, latency must hide behind 60fps frames.",
|
||||
},
|
||||
tinyml: {
|
||||
title: "TinyML track",
|
||||
body: "Microcontroller ML: kilobytes of RAM, milliwatt power budgets, no operating system. Quantization and pruning aren't optimizations — they're requirements.",
|
||||
},
|
||||
global: {
|
||||
title: "Cross-track",
|
||||
body: "Concepts that apply across deployment targets — fundamentals you'll see whether you serve at scale or on a microcontroller.",
|
||||
},
|
||||
};
|
||||
|
||||
export function trackTooltip(track: string): { title: string; body: string } {
|
||||
return (
|
||||
TRACK_DESCRIPTIONS[track] ?? {
|
||||
title: track,
|
||||
body: "Deployment track for this question.",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Competency area descriptions ───────────────────────────
|
||||
// The 11 ikigai zones from the StaffML taxonomy. These are the high-level
|
||||
// "what does a senior ML systems engineer need to know" categories.
|
||||
const COMPETENCY_DESCRIPTIONS: Record<string, { title: string; body: string }> = {
|
||||
compute: {
|
||||
title: "Compute",
|
||||
body: "Accelerator architecture, FLOPs, arithmetic intensity, kernel design — the silicon side of inference and training.",
|
||||
},
|
||||
memory: {
|
||||
title: "Memory",
|
||||
body: "HBM, KV cache, model state, gradient checkpointing, paging — anything where size or bandwidth bites.",
|
||||
},
|
||||
fluency: {
|
||||
title: "Fluency",
|
||||
body: "The vocabulary and mental model of ML systems. Knowing what an NPU is, what FlashAttention does, what 'prefill vs decode' means.",
|
||||
},
|
||||
architecture: {
|
||||
title: "Architecture",
|
||||
body: "How model designs (attention, MoE, retrieval, hybrid) map onto real hardware constraints.",
|
||||
},
|
||||
latency: {
|
||||
title: "Latency",
|
||||
body: "TTFT, TPOT, p50/p99 budgets, scheduling, request shaping. The user-facing time domain.",
|
||||
},
|
||||
"cross-cutting": {
|
||||
title: "Cross-cutting",
|
||||
body: "System-level skills that span every layer: profiling, debugging, capacity planning, incident response.",
|
||||
},
|
||||
data: {
|
||||
title: "Data",
|
||||
body: "Data engineering, validation, versioning, drift detection — the part of ML systems that isn't model code.",
|
||||
},
|
||||
networking: {
|
||||
title: "Networking",
|
||||
body: "NVLink, InfiniBand, AllReduce, network bottlenecks, multi-node communication patterns.",
|
||||
},
|
||||
power: {
|
||||
title: "Power",
|
||||
body: "Energy per inference, thermal budgets, datacenter PUE, sustainability — the constraint that ends up dominating at scale.",
|
||||
},
|
||||
optimization: {
|
||||
title: "Optimization",
|
||||
body: "Quantization, sparsity, distillation, pruning, compilation — making models faster, smaller, cheaper without breaking them.",
|
||||
},
|
||||
precision: {
|
||||
title: "Precision",
|
||||
body: "FP32 / FP16 / BF16 / FP8 / INT8 numerics, accumulator widths, calibration. Where bits meet accuracy.",
|
||||
},
|
||||
reliability: {
|
||||
title: "Reliability",
|
||||
body: "Failure modes, fault tolerance, checkpointing, replay, redundancy. The 'what happens when something breaks' axis.",
|
||||
},
|
||||
parallelism: {
|
||||
title: "Parallelism",
|
||||
body: "Data, tensor, pipeline, expert parallelism. How big models actually run on multiple devices.",
|
||||
},
|
||||
};
|
||||
|
||||
export function competencyTooltip(area: string): { title: string; body: string } {
|
||||
const key = area.toLowerCase();
|
||||
return (
|
||||
COMPETENCY_DESCRIPTIONS[key] ?? {
|
||||
title: area,
|
||||
body: "Competency area in the StaffML taxonomy.",
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* URL classifier for "deep dive" / chapter / paper references.
|
||||
*
|
||||
* The corpus has 4,000+ deep_dive_url values pointing at heterogeneous
|
||||
* destinations: ~40% mlsysbook.ai, ~24% the harvard-edge GitHub mirror,
|
||||
* ~10% arxiv, and the rest distributed across PyTorch docs, NVIDIA blogs,
|
||||
* vendor pages, papers, and personal blogs. Until this code existed every
|
||||
* link was hard-labeled "Read on MLSysBook.ai" — wrong on ~60% of links.
|
||||
*
|
||||
* This module returns a structured `RefInfo` so the UI can label each
|
||||
* reference honestly with the right source name and a sensible verb.
|
||||
*/
|
||||
|
||||
export type RefSource =
|
||||
| "book" // mlsysbook.ai (the textbook)
|
||||
| "arxiv" // arxiv.org papers
|
||||
| "github" // GitHub repos / blobs / issues
|
||||
| "pytorch" // pytorch.org docs/blog
|
||||
| "nvidia" // developer.nvidia.com / docs.nvidia.com
|
||||
| "google" // research.google / cloud.google / developers.google
|
||||
| "huggingface" // huggingface.co
|
||||
| "vendor-docs" // aws, microsoft, azure, intel, amd, etc.
|
||||
| "blog" // personal/company engineering blogs
|
||||
| "paper" // ACM, IEEE, USENIX, NeurIPS, etc.
|
||||
| "wiki" // wikipedia, wikichip
|
||||
| "external"; // catch-all
|
||||
|
||||
export interface RefInfo {
|
||||
/** Short, user-facing label, e.g. "Read on MLSysBook.ai" or "arXiv paper". */
|
||||
label: string;
|
||||
/** Stable identifier for routing, icons, and analytics. */
|
||||
source: RefSource;
|
||||
/** Whether this points at the textbook itself (vs. an external reference). */
|
||||
isBook: boolean;
|
||||
/** Hostname extracted from the URL, useful for fallback display. */
|
||||
host: string;
|
||||
/** True when the destination is currently known-broken or known-flaky.
|
||||
* Used to show a "may be unavailable" indicator next to the link.
|
||||
* Remove the flag once mlsysbook.ai chapter routes are back online. */
|
||||
mayBeUnavailable: boolean;
|
||||
}
|
||||
|
||||
/** Internal: pull a hostname out of a URL without throwing on garbage. */
|
||||
function safeHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a deep-dive URL into a `RefInfo`. Pure function, no I/O.
|
||||
*
|
||||
* The dispatch goes from most specific to least specific. New hostnames
|
||||
* just need a new branch — there is no remote lookup, no cache, nothing
|
||||
* to invalidate.
|
||||
*/
|
||||
export function classifyRef(url: string | undefined | null, titleHint?: string): RefInfo {
|
||||
if (!url) {
|
||||
return { label: "Reference", source: "external", isBook: false, host: "", mayBeUnavailable: false };
|
||||
}
|
||||
const host = safeHost(url);
|
||||
|
||||
// The textbook itself.
|
||||
// NOTE: as of 2026-04, mlsysbook.ai chapter routes return 404 even though
|
||||
// the homepage links to them. Mark as "may be unavailable" until the
|
||||
// deployment is fixed. Remove the flag once verified working.
|
||||
if (host === "mlsysbook.ai" || host.endsWith(".mlsysbook.ai")) {
|
||||
return { label: "Read on MLSysBook.ai", source: "book", isBook: true, host, mayBeUnavailable: true };
|
||||
}
|
||||
// The GitHub-hosted dev mirror of the same book — fully retired, all 404.
|
||||
if (host === "harvard-edge.github.io") {
|
||||
return { label: "Read on MLSysBook.ai", source: "book", isBook: true, host, mayBeUnavailable: true };
|
||||
}
|
||||
|
||||
// Papers
|
||||
if (host === "arxiv.org") {
|
||||
return { label: "Read paper on arXiv", source: "arxiv", isBook: false, host, mayBeUnavailable: false };
|
||||
}
|
||||
if (
|
||||
host === "dl.acm.org" ||
|
||||
host === "ieeexplore.ieee.org" ||
|
||||
host === "www.usenix.org" ||
|
||||
host === "papers.nips.cc" ||
|
||||
host === "proceedings.neurips.cc" ||
|
||||
host === "proceedings.mlr.press" ||
|
||||
host === "aclanthology.org" ||
|
||||
host === "jmlr.csail.mit.edu" ||
|
||||
host === "www.jmlr.org" ||
|
||||
host === "eprint.iacr.org"
|
||||
) {
|
||||
return { label: "Research paper", source: "paper", isBook: false, host, mayBeUnavailable: false };
|
||||
}
|
||||
|
||||
// Frameworks and accelerator vendors
|
||||
if (host === "pytorch.org" || host.endsWith(".pytorch.org")) {
|
||||
return { label: "PyTorch docs", source: "pytorch", isBook: false, host, mayBeUnavailable: false };
|
||||
}
|
||||
if (host === "developer.nvidia.com" || host === "docs.nvidia.com" ||
|
||||
host === "blogs.nvidia.com" || host === "www.nvidia.com" ||
|
||||
host === "images.nvidia.com" || host === "enterprise-support.nvidia.com") {
|
||||
return { label: "NVIDIA documentation", source: "nvidia", isBook: false, host, mayBeUnavailable: false };
|
||||
}
|
||||
if (host === "research.google" || host === "research.google.com" ||
|
||||
host === "cloud.google.com" || host === "developers.google.com" ||
|
||||
host === "sre.google" || host === "openxla.org") {
|
||||
return { label: "Google reference", source: "google", isBook: false, host, mayBeUnavailable: false };
|
||||
}
|
||||
if (host === "huggingface.co") {
|
||||
return { label: "Hugging Face", source: "huggingface", isBook: false, host, mayBeUnavailable: false };
|
||||
}
|
||||
|
||||
// GitHub (repos, blobs, raw, gists, pages)
|
||||
if (host === "github.com" || host === "raw.githubusercontent.com" ||
|
||||
host === "gist.github.com") {
|
||||
return { label: "GitHub", source: "github", isBook: false, host, mayBeUnavailable: false };
|
||||
}
|
||||
|
||||
// Vendor docs
|
||||
if (host === "aws.amazon.com" || host === "docs.aws.amazon.com" ||
|
||||
host.endsWith(".microsoft.com") || host === "learn.microsoft.com" ||
|
||||
host.endsWith(".intel.com") || host === "intel.github.io" ||
|
||||
host === "www.tsmc.com" || host === "www.broadcom.com" ||
|
||||
host === "www.cisco.com" || host === "www.arista.com" ||
|
||||
host === "www.databricks.com") {
|
||||
return { label: "Vendor documentation", source: "vendor-docs", isBook: false, host, mayBeUnavailable: false };
|
||||
}
|
||||
|
||||
// Wikis
|
||||
if (host === "en.wikipedia.org" || host === "en.wikichip.org" ||
|
||||
host === "fuse.wikichip.org") {
|
||||
return { label: "Wikipedia", source: "wiki", isBook: false, host, mayBeUnavailable: false };
|
||||
}
|
||||
|
||||
// Engineering blogs (a partial list of the ones that show up in the corpus)
|
||||
const blogHosts = new Set([
|
||||
"engineering.fb.com", "ai.meta.com", "research.facebook.com",
|
||||
"netflixtechblog.com", "eng.lyft.com", "engineering.linkedin.com",
|
||||
"blog.vllm.ai", "vllm.ai", "docs.vllm.ai", "vllm.readthedocs.io",
|
||||
"kipp.ly", "horace.io", "huyenchip.com", "lilianweng.github.io",
|
||||
"colah.github.io", "finbarr.ca", "eugeneyan.com", "blog.eleuther.ai",
|
||||
"bair.berkeley.edu", "blog.apnic.net", "queue.acm.org", "cacm.acm.org",
|
||||
"spectrum.ieee.org", "a16z.com", "openai.com", "machinelearning.apple.com",
|
||||
"petewarden.com", "martinfowler.com", "martin.kleppmann.com",
|
||||
"horace.io", "andrew.gibiansky.com", "mwburke.github.io",
|
||||
"colin-scott.github.io", "ethernetalliance.org",
|
||||
]);
|
||||
if (blogHosts.has(host)) {
|
||||
return { label: "Engineering blog", source: "blog", isBook: false, host, mayBeUnavailable: false };
|
||||
}
|
||||
|
||||
// Catch-all: name it after the host so the user at least knows where it goes.
|
||||
return {
|
||||
label: `Read on ${host || "external site"}`,
|
||||
source: "external",
|
||||
isBook: false,
|
||||
host,
|
||||
mayBeUnavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a "Learn more" line that tells the user honestly where the link
|
||||
* goes. Used by both Practice and the Vault topic detail.
|
||||
*
|
||||
* Returns a tuple of (prefix, host-or-source-name, suffix) so the caller
|
||||
* can style the host span differently.
|
||||
*/
|
||||
export function refLearnMore(url: string | undefined | null, title?: string): {
|
||||
info: RefInfo;
|
||||
text: string;
|
||||
} {
|
||||
const info = classifyRef(url);
|
||||
const text = title ? `${info.label} — ${title}` : info.label;
|
||||
return { info, text };
|
||||
}
|
||||
@@ -23,5 +23,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "worker"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user