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:
Vijay Janapa Reddi
2026-04-07 19:22:31 -04:00
parent da4f6cf526
commit cefa8bfe63
21 changed files with 2077 additions and 156 deletions
+7
View File
@@ -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
+122 -1
View File
@@ -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]">&#9654;</span>
Your answer
</summary>
<div className="mt-2 p-3 bg-background border border-border rounded-md font-mono text-[12px] text-textSecondary whitespace-pre-wrap leading-relaxed max-h-40 overflow-y-auto">
{userAnswer}
</div>
</details>
)}
{/* 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 36 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,
+12
View File
@@ -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; }
+4
View File
@@ -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>
+26 -11
View File
@@ -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>
+118 -61
View File
@@ -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> &mdash; {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> &mdash; {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 12 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>
);
}
+27 -8
View File
@@ -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 &ldquo;flash attention&rdquo; 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 14. Be honest — the rating is the whole signal." },
{ icon: Repeat, text: "Wrong answers come back tomorrow. Partial answers in 3 days. Nailed answers in 12 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
+144 -43
View File
@@ -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}`}
+26
View File
@@ -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.",
}
);
}
+177
View File
@@ -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 };
}
+1 -1
View File
@@ -23,5 +23,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "worker"]
}