Appearance
Notes & Annotation System
The learning workspace at /subjects/:subject (SubjectLayout) pairs a YouTube lecture player with the subject's Docy notes, and layers a full annotation system on top. It's the most rewrite-scarred part of the codebase — this page documents both the current architecture and why it looks the way it does.
Content flow
Notes markdown lives in the private Docy repo, fetched at runtime through two Pages Functions — list-notes (directory listing for the subject's chapter list) and get-notes (file body) — which proxy GitHub's Contents API with a server-side GITHub_TOKEN. Editing a note in Docy updates the live site on the next fetch with no website redeploy. See Docy.
Rendering is the shared markdown pipeline (GFM + KaTeX + highlight). The workspace chrome — header and sidebar — auto-hides while reading/watching, reclaiming the space for an immersive layout; it reappears on hover/interaction.
Annotations: the architecture that survived
Users can highlight note text and attach notes to it, persisted per user in users/{uid}/annotations. The first implementation stored DOM ranges and mutated the rendered HTML directly. It failed in every way that approach always fails:
- DOM mutation broke React reconciliation — highlights vanished or duplicated on re-render.
- KaTeX offsets — math renders to markup whose text content doesn't match the source, so character offsets computed against the DOM drifted whenever a formula appeared upstream.
- Multi-block selections corrupted ranges when a highlight spanned paragraph/list boundaries.
- A macOS-specific black-screen crash on certain selections.
The rewrite inverted the model: annotations are data, anchored to the source markdown, not to the DOM.
- Each annotation stores block-level anchors + text offsets computed against the source content, with the selected text stored alongside as a fuzzy fallback if content shifts.
- Highlights are applied during render as spans emitted by the pipeline — React owns every node; nothing mutates the DOM after mount.
- Multi-block selections decompose into per-block segments, each anchored independently, so one annotation can span structures safely.
- KaTeX regions are treated as atomic — selections snap around them instead of measuring through them.
useAnnotations owns load/save/merge; the toolbar and popover are pure consumers.
The popover clipping fix
The annotation popover originally rendered inside the notes scroll container, whose overflow: hidden clipped it near edges. The fix is the standard one worth naming: the popover renders through createPortal to document.body and positions against the selection rect — the same pattern later reused by other floating UI in the app.
ShortNotes + rich formatting
A floating ShortNotes panel lets users keep running per-subject notes beside the lecture (persisted via useShortNotes), with a rich formatting toolbar (bold/italic/code/lists/headings) that edits the underlying markdown rather than HTML — keeping storage format identical to everything else.
Revision integration
Lecture notes and annotations feed the spaced-repetition system: marking a note section or annotation for revision creates an entry in users/{uid}/revisions that revision.js schedules through expanding intervals, surfacing due items on /revision and the dashboard. The learning workspace is therefore an input to retention, not a dead-end reader.
Failure modes to know
| Symptom | Cause |
|---|---|
| Highlights slightly misplaced after a Docy edit | anchors drifted; fuzzy text fallback re-locates, exact offsets re-save on next interaction |
| Notes pane empty, console 500 | GITHUB_TOKEN missing/expired server-side (tokens are rotated; the original one was revoked after an exposure — see Docy) |
| Popover appears at wrong corner on zoomed displays | selection rect vs portal coordinate spaces — recompute on scroll/resize is already wired; check for a missing listener before re-deriving math |