The Subscribe link (desktop and mobile) targeted a non-existent #subscribe
anchor. Point both at https://mlsysbook.ai/newsletter/, matching the canonical
shared/config/navbar-common.yml, which documents why an internal #subscribe
anchor is avoided (Safari content blockers hide anchors containing 'subscribe').
Fixes#1844.
Both handleStar ("Star on GitHub") and handleAlreadyStarred ("I already
starred") fired `setTimeout(onVerified, 1500)` to let the user see the
"Thank you" confirmation panel before the parent dismisses the gate.
The returned id was never stored, so if the gate unmounted within that
1.5s window (parent route change, programmatic close, anything that
removes StarGate from the tree), the timer kept running and `onVerified`
fired later on an unmounted parent.
Today the only caller (practice/page.tsx) treats a stray onVerified call
as a no-op — it just sets `showStarGate=false` which was already false —
so this was a latent leak, not a current bug. But the contract becomes
accidentally correct rather than correct-by-design, and a future caller
where onVerified mutates state on a stale tree would surface it.
- Store the timer id in `verifyTimeoutRef`.
- Add a no-deps cleanup effect that clears `verifyTimeoutRef.current` on
unmount (mirrors the existing focus-management cleanup in the same
file).
- Both handlers assign into the same ref, which also implicitly handles
the unlikely back-to-back click case (second click overwrites the ref;
the first timer still runs but we have no path to reach that since the
panel transitions to the "retired" state after the first click).
Regression test (3 cases): unmount-after-Star cancels onVerified,
unmount-after-Already-starred cancels onVerified, and the still-mounted
case still fires onVerified at 1.5s.
The global keyboard listener at practice/page.tsx is intentionally
stable — re-armed only when showAnswer/current/pickRandom change so it
doesn't churn on every keystroke. But the handlers it calls
(handleReveal, handleScore) close over state that DOES change between
listener re-arms:
- handleReveal closes over `userAnswer`. The user types in the
textarea, presses Cmd/Ctrl+Enter, and the stale listener invokes the
OLD handleReveal — which sees `userAnswer.trim().length === 0` even
after 100+ chars are typed and the think-guard (`elapsedMs < 15000
&& charsTyped < 50`) misfires with a "are you sure?" modal AFTER the
user has deliberated.
- handleScore closes over `effectiveMaxScore` (derived from
`rubricItems` / `napkinResult`) and `questionsAnswered`. If the user
ticks rubric checkboxes after revealing the answer, `effectiveMaxScore`
changes — but the digit-key listener still calls a stale handleScore
that caps the score at the OLD max.
The Reveal/score buttons are unaffected; they invoke a fresh closure
on click.
Fix uses the latest-callback ref pattern (same shape as
useVisibilityPoll from PR #1834): two refs holding the most-recent
handler, refreshed in a no-deps useEffect after every render, and the
keydown listener calls `ref.current(...)` instead of the function
directly. Listener stays stable, calls always see the latest closure.
Footer's `new Date(BUILD_DATE).toLocaleDateString("en-US", {...})` had no
`timeZone` option, so it formatted in the runtime's local timezone. With
Next's static export the HTML is generated at build time on the build
server (UTC in CI) and hydrates on the client whenever the user visits.
Near day/year boundaries the two TZs disagree on the formatted date:
BUILD_DATE = "2026-12-31T23:30:00Z"
build server (UTC): "Dec 31, 2026"
client in UTC+1: "Jan 1, 2027"
That's a hydration mismatch warning AND a wrong label (the build IS from
Dec 31 UTC, not Jan 1 in the viewer's TZ — the latter is misleading).
Same shape as PR #1843 for PaperCitationCard; this is the matching fix
for Footer.
- Add `timeZone: "UTC"` to the `toLocaleDateString` options so the label
reflects the canonical build instant, not the viewer's clock.
- Regression test mocks `@/lib/stats` with `BUILD_DATE = 2025-01-01T01:00:00Z`
(which is Dec 31 2024 in PT) and asserts the label reads "Jan 1, 2025"
even when `vi.setSystemTime` puts "now" in a different year.
Each toast scheduled a 4s setTimeout to auto-dismiss, but the timer handle
was discarded, so it was never cleared. If the provider unmounted within 4s
the timer still fired setState on a dead tree, and a manual dismiss left the
timer running to a no-op tick.
Track timers in a ref keyed by toast id, cancel the timer on manual dismiss,
and clear all pending timers when the provider unmounts.
Adds a regression test asserting timers are cleared on unmount and on manual
dismiss (and that flushing timers afterward does not throw).
(cherry picked from commit aa682c9363)
The radial explorer's center zoom-out affordance was an HTML <button>
wrapping an SVG <circle>. A <button> is not valid SVG content: it doesn't
focus reliably across browsers and exposed no accessible name. Move the
click handler onto the <circle> directly and only show the pointer/hover
affordance when there is a parent to zoom out to.
The keyboard-accessible navigation is unchanged — the Breadcrumb above the
chart and the ExplorerPanel beside it provide real <button> controls for the
same focus changes, and the SVG is exposed to assistive tech as a single
labelled role="img".
Adds a regression test asserting the SVG contains no nested HTML <button>.
(cherry picked from commit 602dc80f25)
The PrimitiveDetail overlay rendered as a plain <div>: no role="dialog",
aria-modal, or aria-labelledby, and no focus management — so screen readers
didn't announce it as a dialog and keyboard focus stayed in the dimmed page
behind it. (Escape was already handled by the page-level listener.)
Add dialog semantics labelled by the primitive name, move focus to the close
button on open and restore it on close, and trap Tab within the panel —
mirroring the existing KeyboardShortcutsOverlay / CommandPalette pattern.
PrimitiveDetail is exported so it can be tested in isolation.
Adds a regression test covering dialog attributes, labelling, mount focus,
and focus restoration.
(cherry picked from commit 0652502995)
StarGate is a full-viewport blocking overlay but rendered as a plain <div>:
no role="dialog", aria-modal, or aria-labelledby; no Escape handler; and no
focus management, so the dimmed page behind it stayed keyboard-reachable and
screen readers didn't announce it as a dialog.
Mirror the existing KeyboardShortcutsOverlay / CommandPalette pattern: add
dialog semantics labelled by the heading, move focus to the primary CTA on
mount and restore it on unmount, trap Tab within the surface, and dismiss on
Escape (counts as a dismiss).
Adds a regression test covering dialog attributes, mount focus, Escape
dismiss, and focus restoration.
(cherry picked from commit 0b62e82bba)
The Tools dropdown toggle and the mobile hamburger show/hide a menu but
exposed no open/closed state to assistive tech — the only cue was a visual
chevron rotation, so screen-reader users couldn't tell the menu was open.
Add aria-haspopup, aria-expanded, and aria-controls to both toggles, give
each controlled menu a matching id, and mark the decorative chevron/menu
icons aria-hidden.
Adds a regression test asserting the ARIA wiring toggles with state.
(cherry picked from commit ea6a46e09b)
getInitialTheme() read localStorage without a try/catch, while the matching
writer (toggleTheme) was already guarded. In browsers that block storage
(Safari 'Block all cookies', sandboxed iframes without allow-same-origin),
even accessing window.localStorage throws a SecurityError, so the mount effect
threw before reconciling and the theme stayed stuck at the SSR default.
Funnel every access through shared safeGet/safeSet helpers so reads and writes
share one guard and a future caller can't reintroduce an unguarded access.
Adds a regression test that mounts ThemeProvider with a throwing getItem.
(cherry picked from commit ddd5202c5a)
Derive a topic→chapter "Learn more" pointer for every question at build
time from schema/topic_chapter_map.yaml, closing the loop from practice
back into the textbook (issue #1822, Phase 1).
vault-cli:
- BookRefResolver joins topic→chapter, reads chapter titles from each
chapter's .qmd H1, and link-checks every mapped chapter at build time
(a missing .qmd fails the build — the old "defer until URLs stabilize"
blocker becomes "URLs are enforced valid"). book_refs is emitted
top-level so it rides into the summary bundle and renders synchronously.
- Regenerates corpus-summary.json: all 9,525 published questions now
carry book_refs (100% topic coverage).
staffml:
- BookRefCard shows the primary chapter + authored "why" line + also_see
chapters, after the attempt (go-deeper pointer, not an answer key).
- Supersedes ChapterLinks; retires chapter-map.json/chapters.ts.
Fixes live 404s: ChapterLinks built /contents/vol1/<ch>/ URLs that 404;
the verified-200 pattern is /vol1/contents/vol1/<ch>/<ch>.html.
Tests: adds test_book_refs.py (resolver + link-checker regression).
Toasts (badge unlocks, streaks, success messages) were invisible to
screen readers: the container was a plain <div> with no live region, so
nothing was announced when a toast appeared. The icon-only dismiss
button also had no accessible name — screen readers read just "button".
Make the always-mounted container a polite live region
(role="region" + aria-label + aria-live="polite", aria-atomic="false"
so only the newly-added toast is announced, not the whole stack), and
give the dismiss button an aria-label, with the X icon marked
aria-hidden. Mirrors the existing VersionDriftToast pattern.
Adds a regression test asserting the live-region attributes and the
dismiss button's accessible name.
The Cmd+K result list is scrollable (max-h-[60vh]). Arrowing past the
fold updated aria-activedescendant and the highlight class but never
scrolled the active row back into the viewport, so keyboard-only users
lost the highlight off-screen on longer result sets.
Scroll the active row into view whenever activeIdx changes, using
block:"nearest" so an already-visible row (e.g. one set active via
onMouseEnter hover) never triggers a scroll jump.
Adds a regression test covering ArrowDown/ArrowUp navigation, plus a
jsdom scrollIntoView stub in the shared test setup (jsdom doesn't
implement it).
CommandPalette and KeyboardShortcutsOverlay were added in the client-UX
overhaul (cefa8bfe63) with tests and docblocks stating they are "mounted
in app/layout.tsx" — but the mount line was never added. They have been
dead since introduction: the navbar search icon dispatches
`staffml:open-palette` into the void, and Cmd/Ctrl+K and `?` have no
listener mounted, so all three do nothing on every route.
Render both overlays once in RootLayout, inside <Providers> so the
palette can read the vault via useVault(). Adds a Playwright smoke test
covering the three previously-dead entry points (Ctrl+K, the navbar
search icon, and `?`).
The waitlist modal carried `role="dialog" aria-modal="true"` but didn't
enforce the contract: Tab/Shift+Tab escaped into background controls
behind the backdrop, and the email input wasn't auto-focused on open —
keyboard users landed in the page body and had to hunt for the input.
- Capture `document.activeElement` on mount and restore it on unmount
so focus returns to whatever the user was on when they closed the
modal (the original trigger button).
- Auto-focus the email input on open via the same `setTimeout(0)`
pattern used by CommandPalette and KeyboardShortcutsOverlay.
- Add a Tab/Shift+Tab cycle on the modal surface so focus stays inside
the dialog while it's open. Mirrors the trap in CommandPalette so the
two modals behave identically for keyboard users.
- Export `WaitlistModal` as a named export from AskInterviewer.tsx so
the regression test can render it directly without spinning up the
full AskInterviewer parent and triggering its open state.
Regression test (5 cases) covers: autofocus on open, focus restoration
on unmount, forward Tab cycle from last → first, backward Tab cycle
from first → last, and that Tab in the middle of the trap falls
through to the browser's native handling (we only block at the edges).
The BibTeX entry took its year from `new Date().getFullYear()` — the
render-time year on the client. With Next's static export the HTML is
generated at build time and hydrates on the client whenever the user
visits, so server (build) and client (hydrate) could disagree on the
year:
- around midnight UTC on Dec 31 / Jan 1, or
- any time the site is viewed in a year after the build year.
Hydration mismatch warning either way, and the wrong anchor for a
citation regardless.
- Make `buildDate` required on PaperCitationCardProps (the only call
site at app/about/page.tsx already passes it from `BUILD_DATE` in
lib/stats). Type-level guarantee that callers provide the anchor.
- Derive the BibTeX year via `new Date(buildDate).getUTCFullYear()` so
the year matches the build server's UTC reference regardless of where
the viewer sits.
- `buildDateLabel` simplifies: no `buildDate ? ... : null` ternary, just
the formatted string.
Regression test mocks the system clock to a different year than
buildDate and asserts the BibTeX still reflects buildDate. Also covers
re-render with a new buildDate and the timezone-stability case
(2025-01-01T01:00:00Z is Dec 31 2024 in PT but must read as 2025 UTC).
Nav polled getDueCount every 30s indefinitely via a bare setInterval,
even on backgrounded tabs. Cheap per call (it reads localStorage), but
unnecessary wakeups burn battery on mobile and add no value when no
one is looking.
- Extract useVisibilityPoll(callback, intervalMs) into src/lib/hooks/.
Clears the interval on visibilitychange→hidden, re-arms it on visible
with an immediate catch-up tick. The callback is held in a ref so a
fresh closure on every render is picked up without tearing down and
re-arming the interval — only intervalMs is a real dependency.
- Wrap each tick in try/catch INSIDE the hook. If the callback throws
on the mount-time synchronous tick, the exception would otherwise
propagate out of the effect body before React receives the cleanup,
leaving the already-armed setInterval orphaned and re-throwing every
intervalMs with no way to cancel. Logging in the hook means the worst
case is per-tick console errors, not a leak.
- The hook is explicit in its docs that it is NOT a cross-tab sync
primitive — it only catches up on visibility transitions, not on
concurrent visible tabs. For that case Nav also adds a `storage`
event listener so a write in one tab refreshes the badge in any
other open tab instantly.
- Replace Nav's empty `catch {}` with `console.error(...)` so a real
failure (corrupted localStorage, JSON parse error) surfaces in
devtools instead of presenting as a frozen badge. The refresh logic
is hoisted into a single useCallback so the poll callback and the
storage handler share one error-logging path.
Regression test (8 cases) covers: immediate mount fire when visible,
cadence while visible, pause when hidden, immediate catch-up on resume,
no-start when mounted while already hidden, cleanup on unmount, latest
callback closure used (ref pattern), and throw-isolation (a throwing
callback at mount does not kill the interval or skip listener wire-up).
EcosystemBar injected the bootstrap-icons stylesheet from jsDelivr with
no `integrity` attribute. If jsDelivr was ever compromised or its CDN
served modified bytes, the browser would happily apply the foreign CSS
on every staffml pageview.
- Add `integrity="sha384-..."` pinning the v1.11.3 bytes (hash computed
from the actual CDN file via `curl | openssl dgst -sha384 -binary |
openssl base64 -A`).
- Add `crossOrigin="anonymous"` — required for SRI to be enforced on
cross-origin resources.
- Add an `onerror` handler that logs to console with the recompute
command. Without it the only symptom of a stale hash (e.g. after a
version bump) would be silently missing icons.
- Comment includes the recompute recipe so future version bumps update
the hash deterministically.
Replace pointers to the private project rules/docs tree (relative .claude/rules
and .claude/docs paths) in code comments and docstrings with neutral phrasing
("the project prose style guide", etc.). Load-bearing runtime paths that the
tooling reads or writes are left intact.
- labs-validate-dev.yml: the Pyodide/WASM export smoke test imported the
pre-taxonomy-refactor path `from mlsysim.core.engine import Engine`. The
refactor moved Engine to mlsysim/engine/engine.py (exported at the package
top level). Use the canonical public import `from mlsysim import Engine`.
Fixes the "ModuleNotFoundError: No module named 'mlsysim.core.engine'" failure.
- LIVE_INTERVIEWER_PLAN.md: "pre-selects" -> "preselects" (codespell dictionary).
First-pass topic_chapter_map.yaml (87 topics → book chapters, primary +
also_see + rationale) plus the design analysis for connecting each StaffML
question back to recommended textbook reading. Seeds the future work
tracked in harvard-edge/cs249r_book#1822. Not yet wired into the build.
StaffML is an assessment instrument, not a textbook: recall is a first-class
skill and L1/L2 questions are warm-up/screening (paper sec:levels). A flat
'must reason' bar wrongly rejects valid recall items. Grade the floor by Bloom
level instead — L1/L2 pass at tp>=1, L3 at tp>=2, L4/L5/L6+ at tp>=3 — so the
gate fails only a level/depth MISMATCH (recall masquerading as L5), not recall
doing its job. Re-applied to the backfill: 2,700 below-floor (28%, concentrated
at L4-L6+), down from a misleading flat 43%.
The pipeline validated correctness only (schema/format/level-fit/coherence/math)
— nothing checked whether a question TEACHES, so ~22% of published questions are
correct-but-vacuous (recall/vocab + decorative math). Add gate_teaching_power:
passes iff tp>=3 and not vacuous, calibrated against gold-standard exemplars (not
same-cell peers, the level_fit blind spot that lets vacuity propagate). Validated
on 146 labeled questions: 90% within 1pt of the independent scorer, 9/9 trusted
golds pass. Documented the bar in AUTHORING.md.
The flagged titles were machine-generated slug stubs ("Cloud Gpu Virtualization
L3 0") leaking track prefix, level/index, and wrong acronym case. Regenerated
descriptive, scenario-grounded titles (e.g. "Partitioning an A100 MIG for 7B and
1B Models") via content-aware pass; spot-checked for accuracy against scenarios.
Asserts the actual rendered output (jsdom + testing-library), not just types:
MCQ marks correct-green/wrong-red on reveal; $...$ renders as KaTeX; bare-$
currency renders verbatim (regression guard); bold survives the math-split pass.
The MATH_SPLIT regex matched any $...$ span on a line, so cost questions
using bare-$ currency ("Maintenance ($3M) ... ($50)") rendered the text
between two dollar signs as garbled KaTeX. ~5% of published vault questions
(544/9,525) hit this. Gate math on a LaTeX-signal in the body (backslash,
caret, underscore, brace); currency has none, real math always does.
Verified: 3 currency samples render as text, 3 real-math strings as math.
A1 — MCQ self-check: new MCQOptions component renders selectable choices when a
question carries details.options + correct_index. Pre-reveal the learner picks
an option; on reveal the correct option is marked green and a wrong pick red,
keeping all distractors. Deliberately a self-check — NOT wired into the
SR/scoring pipeline; the open self-rating (Skip/Wrong/Partial/Nailed) stays
authoritative. Rendered in both practice and gauntlet flows. Non-MCQ
open-response path is unchanged.
A4 — scenario/question/answer text now routes through MarkdownText everywhere
(practice scenario was raw GlossaryText; gauntlet scenario, common_mistake,
realistic_solution, and the results-phase review were raw text), so **bold**
and $math$ render instead of showing literal markers.
Grading wiring — the live reveal path now gates numeric grading on
isNumericQuestion(current) (previously ungated) and calls gradeNapkinAnswer,
preserving the setNapkinResult shape and extending the display to show units
and an "→ equivalent" marker for unit-equivalent answers.