Drawing overlay¶
A freehand layer the learner can switch on over any page, backed by Excalidraw. Strokes are pinned to the content underneath and saved locally per page. This guide is the current contract; the IndexedDB persistence design records the rationale and failure analysis behind it.
The contract¶
- One drawing per page. The page key is the route pathname, narrowed by a scope when a container pages through content under a stable URL (see below).
- A scene coordinate is a position inside the composed content column. Excalidraw's
scrollX/scrollYare driven from the owning scroller on every scroll, resize, and Excalidraw-initiated pan, measured from the column's own top-left rather than the overlay's, and itszoomis held equal to the column'sContentScalefactor. A stroke therefore stays over the words it was drawn on when the sidebar collapses, when the page is docked into a narrower pane, and when a split is undone. - The reading column does not reflow into its pane.
ContentScale(src/components/ui/ContentScale.client.tsx) composes the column at a fixed reference width and scales it into the available width with CSSzoom, so line breaks are identical at every pane width. Text that rewrapped would move out from under strokes that a 2D canvas cannot follow. The reference is the text measure plus its gutters, not the whole column: the column's remaining width only ever holds the inspector aside, and composing against it would scale prose down for space nothing reads. Content wider than the measure still reflows between panes wider than the reference. BelowMIN_SCALEthe column stops shrinking and reflows again, and a viewport narrower than 768px renders unscaled. - Every visible pane places its own strokes. Each stack carries an overlay and each pane
publishes its scroller through
publishPaneScrollElement. The shell must not look those elements up in the document: tab buttons carry the samedata-workspace-pane-idand come first. - Drawing mode is a mode. Outside it the layer is inert: no toolbars, no pointer events, just
the strokes.
Escapeleaves the mode. - iPad Pencil ink has a dedicated live path. In drawing mode, iPad (including Safari's desktop user agent) Pencil pen/highlighter strokes paint a lightweight SVG preview once per animation frame, collecting real pressure and coalesced samples where the browser exposes them. Pointer moves do not update the full editor scene or persistence. Pencil lift commits one standard Excalidraw freehand element with one undo step, using the same outline renderer for live and saved ink. Dots, cancelled strokes, lost capture, app switching and teardown preserve received samples. Mouse, shapes and eraser continue through Excalidraw.
- The host owns touch navigation. Pencil and mouse input produce ink.
Once a Pencil has been detected, one finger scrolls the owning page and two fingers pan and
pinch-zoom the composed content and ink together. Before detection, one finger still draws for
touch-only devices and a second finger transfers the gesture to page navigation. On iPad,
Automatic already uses fingers for navigation before the first Pencil contact; explicitly
choose Touch draws to use finger ink. Palm contacts
are suppressed during Pencil contact and for 250 ms after its latest event. The router is scoped
to
[data-drawing-surface], so an idle finger can still operate the custom toolbar. A rejected contact stays rejected until lifted, even if the Pencil has lifted and its grace period expires. - User zoom is temporary and page-local.
ContentScalecombines its responsive fit with user zoom, clamps the effective result to 0.5–2.5×, and compensates both scroll axes around the pinch midpoint. Its composed page width stays fixed during zoom so text does not rewrap under ink. Navigation to another drawing page resets to the responsive fit. - The chrome is ours, not Excalidraw's. Every Excalidraw island is hidden and replaced by
DrawingToolbar, which lives in the page margin instead of on top of the text. - Nothing is uploaded. Drawings live in IndexedDB database
tue-study-hub-drawings, version 1, in thepagesobject store keyed bypageKey. - Images are outside the contract. Pasting files or image elements is refused synchronously. The repository also filters unsupported image elements defensively, so it never persists an Excalidraw image without its separate binary file.
Enforced by src/components/DrawingOverlay.tsx, src/components/DrawingToolbar.client.tsx,
src/course-os/services/drawings/, and the .drawing-overlay rules in src/app/globals.css.
Storage, loading, and saving¶
src/course-os/services/drawings/repository.ts is the only application-facing persistence
boundary. It opens IndexedDB lazily, loads a page asynchronously, and populates an in-memory
revisioned cache. DrawingOverlay does not mount a canvas until that load settles. Excalidraw's
initialData remains a function, but it reads the latest cache entry through
peekPageDrawing(pageKey) rather than performing storage I/O during render.
The function form is load-bearing. Erasing the final stroke unmounts the canvas; if a later mount replayed the original load snapshot, it would resurrect the erased scene and write it back. The cache is updated as soon as a scene changes, so it remains the truth while IndexedDB catches up.
Writes are scheduled per page with a 400 ms trailing debounce and a non-resetting 2 second maximum wait. Continuous drawing therefore cannot postpone persistence forever. Each page has its own promise chain; a failed write leaves that page dirty and the next flush retries it instead of poisoning future saves. The overlay requests an immediate flush:
- before its page or repository data version changes;
- when the document becomes hidden (
visibilitychange); - on
pagehide; - when the canvas disappears after its last stroke is erased; and
- during overlay cleanup.
visibilitychange is the primary mobile lifecycle signal; pagehide is best-effort coverage on
top of it.
Replacement barrier and backups¶
Import and reset replace the whole drawing collection, so normal canvas writes and cold reads cannot be allowed to cross that operation:
- The repository opens the database, flushes dirty cache entries, and snapshots the complete collection for rollback.
- Its private
writeEpochadvances before replacement starts. Saves from an already-mounted canvas carry the previous epoch and are ignored before they can touch the cache. - Volatile reads, timers, and cache state are cleared while the replacement runs. A cold read that meets the barrier waits and then reports itself stale.
- The public
dataVersionadvances only after the replacement succeeds or rollback finishes. The overlay subscribes to it and remounts under the settled collection; intermediate data is never published.
Replacements are serialized with each other and with exports. A failed replacement attempts to restore the snapshot before consumers are notified; a rollback persistence failure remains visible as a save error.
Settings exports backup schema v4. It contains progress, preferences, annotations, the complete drawing record, and a typed workspace snapshot covering theme, font, accent, sidebar state, drawing-toolbar position, and pen slots. Export waits for pending drawing writes and fails rather than presenting an incomplete backup when IndexedDB is unavailable.
Import compatibility is replacement by explicit instruction, preservation by omission:
- v4 replaces drawings and workspace state with the snapshot in the backup;
- v3 replaces drawings but preserves the current workspace state; and
- v1 and v2 preserve both current drawings and workspace state.
For legacy imports, backup freshness is calculated from a complete v4 state assembled before the import: imported progress, preferences, and annotations plus whichever drawings and workspace the legacy schema preserves. This makes a successful import immediately up to date. If required preserved state cannot be captured or fingerprinted, the import still applies, no potentially false metadata is written, and Settings reports that backup status is unavailable.
Category resets follow the same ownership boundaries: drawings go through the replacement barrier; progress, review history, notes, and appearance/layout clear only their named records. Reset all attempts every category independently, preserves backup history and unrelated browser data, and reports named partial failures instead of claiming everything was cleared.
Legacy migration¶
The first successful repository open migrates the old tue-study-hub-drawings-v1 localStorage
record. Valid page scenes are written into IndexedDB, unsupported images are dropped, and an
existing IndexedDB page wins unless the legacy page has a strictly newer valid timestamp. The
migration then reads committed pages back in a separate transaction before deleting the legacy
key. Individually malformed pages are counted and dropped when the remaining valid pages migrate
successfully. An unreadable envelope, an unresolved conflict, a write failure, or failed read-back
leaves the legacy value in place so a later repository open can retry safely.
Failure states¶
Drawing remains usable in memory when IndexedDB cannot be opened. The app-shell action row reports
Drawings kept for this session only; a failed save reports Drawings not saved. While drawing,
the toolbar also shows Last change not saved with a Retry action. Settings export, import, and
reset surface their own failures and do not announce success before their asynchronous drawing
operation settles.
The toolbar¶
Touch-capable devices use 44px minimum control boxes. The palette is bounded by its owning drawing area and its controls scroll when the chosen edge cannot fit the whole set. Save-error feedback stays outside that scroller, and colour/slot popovers retain their portal positioning. The saved dock edge and all tool, slot, zoom, undo, redo, and clear actions are preserved.
A bar docked to one of four edges of the drawing area. Dragging its grip snaps it to whichever edge
the pointer is nearest, live rather than on release, and the choice is remembered under
tue-study-hub-drawing-toolbar-v1. Left and right render it vertically, top and bottom
horizontally. Right is the default: it stays clear of the centred content column, and it suits a
left-handed user whose hand would otherwise cover a left-docked bar.
Tools are pen, highlighter, eraser, rectangle and arrow, plus page zoom controls, one active-colour trigger, three pen slots, undo/redo and clear-page. Zoom out/in move in 10% steps and the percentage button resets to the responsive page fit. Activating the colour control temporarily swaps the controls for an inline tray with Back, seven high-value presets (ink, white, red, yellow, green, blue and pink), and Custom colour. The tray follows the dock direction: horizontal at the top and bottom, vertical at the left and right. The trigger is a thin spectrum rim around the colour in use, seated in a 3px well of popover colour: the rim says the control is about colour, the chip says which one, and the well is what stops the pair reading as a rainbow donut with a hole in it. Presets are chips of the same size, and the one in use carries a centred tick whose colour flips with the swatch's lightness — a ring cut into a chip turns it into a donut and fights dark colours. Custom colour carries the same mark as the trigger, from the same component, so the two cannot drift apart. Choosing a preset returns to the tools immediately, and hands back to the tool you were last drawing with: Excalidraw reverts to selection once a shape is finished, and the eraser has no colour of its own, so the tool at the moment of picking is often not the one you want. Custom colour instead keeps the tray open — it opens an inward popover with a picker and accessible hex input whose changes apply live, so leaving on the first change would fight the drag; those changes are not persisted as preferences. Back or Escape restores the normal controls and focus to the colour trigger.
Pen slots¶
Each drawing mode owns three slots, and a slot holds a colour, a width and an opacity. Selecting a mode restores that mode's active slot in one scene update, so the pen stays black at 2px while the highlighter stays yellow at 6px and 40%, and neither disturbs the other. Clicking an unselected slot switches to it; clicking the one already in use opens an inward popover with width and opacity sliders. The colour trigger writes into the active slot of the mode on show, which is why colour survives a mode switch in the mode it was chosen for and nowhere else.
On iPad, the pen-slot popover also offers Fine, Medium, and Bold handwriting widths (0.5, 1, and 2). These large tap targets make a fine writing pen reachable without manipulating a small slider. They update only the active pen slot and preserve its colour and opacity.
The eraser has no slots of its own, so it keeps showing the last drawing mode's. Clicking one there selects it and hands back to that mode: reaching for a pen while erasing means you are done erasing.
src/course-os/services/drawings/penSlots.ts owns the shape, the defaults and the storage, under
tue-study-hub-drawing-slots-v1. It parses per mode: one malformed highlighter falls back to
the default highlighter and leaves configured pens alone, and a mode added in a later version cannot
discard the modes stored before it existed. Within a mode, one unusable slot resets that whole mode
— a half-restored set is harder to make sense of than a reset one.
Three things about the toolbar are not obvious:
- The highlighter is not an Excalidraw tool. It is
freedrawat partial opacity, so Excalidraw cannot tell it from a pen.toModeinfers it from opacity, which stopped being sufficient once a pen slot could be faint: a 70% pen would light up the highlighter button. So the toolbar records the mode it last chose, andresolveModekeeps it for as long as the tool it implies is still active. Any other tool means Excalidraw moved underneath us — its shortcuts, or the automatic revert to selection — and its answer is the only one there is. - There is no imperative undo.
ExcalidrawImperativeAPIexposes onlyhistory.clear(), so undo and redo replay theCtrl+Z/Ctrl+Shift+Zkeydown at the Excalidraw container. captureUpdatedecides what is undoable. Clear-page passesIMMEDIATELYso it can be undone; every viewport sync and tool change passesNEVER, otherwise scrolling the page would fill the undo stack.
Most of Excalidraw's chrome has no public switch and is hidden via .layer-ui__wrapper. That
selector is the one piece of this coupled to Excalidraw internals — check it after an upgrade.
Scoping a paged container¶
PagedBlockList shows one block at a time behind one URL, so the pathname alone would smear a
drawing across every question in an exam paper. Such a container publishes the id of the item it is
showing:
import { publishDrawingScope } from "@/course-os/services/drawings/scope";
useEffect(() => {
if (!isPaged) return;
publishDrawingScope(items[active].id);
}, [isPaged, items, active]);
useEffect(() => {
if (!isPaged) return;
return () => publishDrawingScope(""); // clear on unmount
}, [isPaged]);
The scope is a retained store rather than an event, because publishers mount deeper in the tree than the overlay and so always announce themselves first.
Do not key drawings on location.hash directly. Plain anchor links — the Contents navigator —
also change it, and that must not swap a drawing out from under the learner.
Aligning a new layout¶
The overlay measures [data-content-column] inside the app shell's content area and offsets the
scene by its left edge. Any centered page wrapper should carry that attribute, as
src/components/ui/page-container.tsx and src/app/[courseId]/layout.tsx do. Without it the
overlay falls back to its own left edge, which drifts from centered content when the sidebar moves.
Known limits¶
- No server sync. IndexedDB is local to one browser profile. Use Settings export/import to move drawings to another device or preserve them before clearing site data.
- No image elements. IndexedDB has capacity for larger scenes, but the persistence model still stores Excalidraw elements without its separate files map, so image pastes are refused.
- Zoom is page-local. It intentionally resets on navigation and is not written to preferences or backups. The responsive fit remains the baseline underneath the temporary user zoom.
- No text tool. Deliberate; add it to
MODESinDrawingToolbar.client.tsxif that changes. A new mode also needs an entry inSLOTTED_MODESand a default slot set, or it has no slots. - Slot count is fixed at three.
SLOTS_PER_MODEis validated on read, so changing it invalidates every stored set and resets everyone to the defaults. - No stroke styles. Excalidraw renders freedraw through
perfect-freehand, which honours colour, width and opacity and ignoresstrokeStyleandroughness. Dashed or textured pens would mean patching its renderer, which is why slots carry those three properties and no more.
Verifying a change¶
npm run check— covers IndexedDB transactions, migration, the cache and scheduler, repository replacement barriers, overlay lifecycle behavior, backup v4 and v1-v3 compatibility, and Settings actions.- In the app: draw, scroll, and confirm the stroke keeps its position relative to the text.
- Reload — the stroke returns; navigate away — it does not follow.
- Collapse the sidebar and confirm the stroke stays on the same words.
- Drag the toolbar to each edge, reload, and confirm it returns to the last one.
- Set the pen to a thin red slot and the highlighter to a wide green one, switch between them, and confirm neither takes the other's colour or width. Reload and confirm both survive.
- Erase everything, toggle drawing off and on, and confirm nothing comes back.
- Paste an image and confirm Excalidraw refuses it.
- Export in Settings, clear the page, import the backup, and confirm the drawing returns.
- On iPad Safari and the Home Screen PWA, make the first Pencil stroke and confirm the one-time Pencil-mode message appears; rest a palm before and during another stroke and confirm it makes no mark, scroll, click, selection, or context menu.
- With Pencil lifted, use one finger to scroll and two fingers to pan and zoom around their midpoint. Check ink alignment at 0.5×, 2.5×, and Fit, then navigate and confirm zoom resets while the drawing remains saved.
- Write small letters, dots and fast curves with varied pressure. Undo and redo individual strokes, erase them, reload and export/import. Switch apps during a stroke and verify the received ink survives. Actual Pencil latency and iPadOS palm classification require a physical iPad; desktop pointer simulations are not device acceptance.
The device input record lives separately under tue-study-hub-drawing-input-v1. Automatic,
Pen only, and Touch draws are intentionally local to the browser. The old synchronized
palmRejectionEnabled field is still parsed for imported backups and migrates true to Pen only,
but new capability detection is never synchronized.