Course progress Course outline 18 of 18 lessons available
Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates
Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools
- 05 Chapter 5: Give the Pixel World a Registry available now
- 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
- 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
- 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
- 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now
Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery
- 10 Chapter 10: Make the Editor Feel Right available now
- 11 Chapter 11: Drawn Text Is Not Editable Text available now
- 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
- 13 Chapter 13: Time Machines and Old Boxes available now
- 14 Chapter 14: Looking Correct Is Not Being Correct Current lesson
Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI
Start with a game a five-year-old can understand
The one truth in this chapter: a Canvas screenshot that looks right proves only that the pixels were similar at that moment. The model, interaction, visuals, accessibility, and production behavior must each be proved separately.
Take a beautiful toy fire truck. Before touching it, predict five things: can its wheels turn? Will it fall apart if it drops from the table? Can you find the brake with your eyes closed? Can you open the door with one hand and without dragging? If it breaks after the teacher leaves, can you tell when it broke? Its bright red body answers none of these questions.
Now create five inspection stations. At the first, take it apart and inspect the gear rules. At the second, press the buttons in a fixed sequence. At the third, drive it on different tables and under different lights. At the fourth, wear a blindfold and replace the remote with only a keyboard. At the fifth, install a black box that records only “what category of failure occurred and how long it took.” Each station produces different failure evidence and requires a different fix.
- Part rulesRules hold however they turn
- Button sequenceCancel never commits by mistake
- Real tabletopBrowser and pixels
- No mouseThe task still works
- Check its temperature in useNever spy on private content
A test pyramid does not mean that higher layers are “more advanced.” Lower-level rules run quickly and can explore many cases. Browser tests prove real input and platform behavior. Visual tests prove appearance. Performance regression tests prove budgets. Accessibility is not a label added at the end; it is an alternative focusable, describable, keyboard-operable path into the same Document.
Translate the toys into Canvas
| Toy fire-truck inspection | Canvas Lab evidence | Question it answers |
|---|---|---|
| Gauge for gear dimensions | Geometry / Model Unit Test | Do the mathematics and invariants hold? |
| Turn wheels at random | Property-Based Test | Do properties still hold across many valid inputs? |
| Script of button actions | Event Replay / State Assertion | Which Transition ran? |
| Controllable stopwatch | Deterministic Clock | Are double-click, long-press, and animation independent of real time? |
| Test drive on a real table | Browser Interaction Test | Do Pointer, IME, and Clipboard actually work? |
| Compare photos from the same angle | Visual Regression | Did DPR, fonts, or drawing change unexpectedly? |
| Feel raised markers while blindfolded | Fallback Semantics / Focus Mapping | Can a Screen Reader understand and locate objects? |
| Remote control with arrow keys | Keyboard Move / Resize | Can the task be completed without Drag? |
| Larger buttons | Target Size / Non-Drag Alternative | Does low-precision input work? |
| Black box | Privacy-Safe Telemetry | Where did production fail, and how much did it affect? |
Where the analogy stops: real assistive technology is not as simple as “using it blindfolded.” Users may have visual, motor, cognitive, or multiple differences. One experience cannot represent Screen Reader, keyboard, voice, and high-contrast needs. The black box must not record canvas text, screenshots, exact trajectories, or asset URLs either. A Correlation ID connects one failure chain; it is not a way to track a person.
Kill the wrong intuitions first
- “I clicked through it once, so testing is done.” People cannot replay Pointer Cancel, races, DPR, and IME composition consistently. Manual exploration matters, but it cannot replace repeatable evidence.
- “100% coverage means correctness.” Executing a line does not prove Matrix Round Trip, Command Inversion, or Migration properties.
- “Matching screenshots mean the interaction is correct.” The same final pixels can result from broken History, broken Focus, or an irreversible action.
- “Canvas cannot be accessible, so one
aria-labelis enough.” One large black-box label cannot let a user select shape-7, understand its connections, move it, or resize it. - “Keyboard support means simulating Drag with many Pointer Moves.” A keyboard should emit the same domain Command, not fake browser events. History, permissions, and auditing then remain consistent.
- “The more detailed Telemetry is, the easier debugging becomes.” Raw text, images, complete coordinate sequences, and user identities leak business content. Collect only categories, durations, counts, coarse environment data, and random correlation IDs.
- “Tests only need to run in Chromium.” Clipboard, IME, fonts, touch, and graphics paths differ across browsers. Choose a Browser Matrix from real users and risk.
Production backpack
Prerequisites
Chapter 8’s GeometryKernel must not depend on Canvas Context. Chapter 9’s ToolMachine must accept a Fake Input Stream. Chapter 13’s Command, History, Migration, and Persistence need pure-function boundaries. If these modules cannot be imported after deleting the Renderer, the architecture has glued facts to pixels. Separate that boundary before adding tests.
Formal knowledge
Unit and property tests. Matrix Round Trip checks inverse(transform(p)) ≈ p. Geometry Invariants check that Bounds contain their geometry, distances are nonnegative, and intersection is symmetric. Transformed Bounds covers rotation, negative scale, and epsilon. Command Inversion must satisfy apply(invert(apply(doc, c))) = doc while ignoring legitimate revision metadata. Migration tests fixtures, idempotence, and corruption isolation. Serialization tests stable fields, not accidental object-property order. Property-Based Testing generates many finite numbers, valid Documents, and event sequences, then shrinks a failure to its smallest counterexample.
State and event tests. Event Replay sends arrays of normalized events from Chapter 7 into ToolMachine. State Transition Assertions verify {from,event,to,effect} together instead of checking only final pixels. Pointer Cancel, Lost Capture, Blur, a remotely deleted object, and an Undo request form Race Sequences. Tests advance a Deterministic Clock manually, avoiding flaky setTimeout(300) failures. A Fake Input Stream does not construct DOM Events, so it can run in Node.
Browser tests. Playwright or an equivalent tool verifies real Pointer, Keyboard, Clipboard permissions, IME Composition, Drag/Drop, and Export downloads. Visual Regression fixes the browser version, fonts, viewport, DPR, color scheme, and animation clock. Every difference threshold must be explainable; do not update the baseline whenever a test fails. Based on user data, the Browser Matrix should cover at least Chromium, Firefox, and WebKit, with real touch and pen devices for paths that cannot be simulated reliably.
Accessibility. Build a DOM Inspector as an Alternate Interface outside the Canvas. Every operable Shape has a semantic role, name, relationship description, and stable data-shape-id. Focus Mapping synchronizes Canvas selection and the DOM row in both directions; remote changes do not steal focus. Keyboard Selection supports Tab, arrow keys, and multi-selection. Keyboard Move/Resize emits the same Commands as Pointer input. Provide numeric inputs or buttons as a Non-Drag Alternative. Separate the visual size of a clickable Handle from its interactive Target Size. A Screen-Reader Description explains type, name, position, connections, and state rather than reading pixels aloud. Reduced Motion stops nonessential interpolation. High Contrast does not identify Selection with color alone.
Observability. Capture Uncaught Error, unhandled rejection, Render Time, Slow Interaction, and Save/Migration/Asset Failure, together with Object Count, Visible Count, Zoom bucket, Input Device, Browser Version, and Correlation ID. Long Task entries and custom performance marks divide an interaction into input, model, geometry, render, and present stages. Privacy-Safe Telemetry forbids Shape text, screenshots, full URLs, user names, and exact trajectories. Sampling rate, retention period, and opt-out require a product policy.
The complete Test Pyramid is ordered by feedback speed: Geometry/Model Unit → State Machine → Engine Integration → Browser Interaction → Visual Regression → Performance Regression. It is not built only by adding layers upward. Every production failure should gain a regression test at the lowest layer that can reproduce it early and reliably.
Evidence and compatibility (verified 2026-08-29)
For Canvas fallback content and accessibility considerations, follow the WHATWG Canvas element. For keyboard operation and visible focus, see WCAG 2.2 Keyboard and Focus Visible; for target dimensions, see WCAG 2.2 Target Size (Minimum). Reduced Motion uses prefers-reduced-motion. Runtime metrics can be obtained through PerformanceObserver, but feature-detect each entry type first. Automated accessibility scans cannot replace Screen Reader and task-level manual testing.
Engineering increment for this chapter
Starting point: Chapter 13 has a recoverable Document, but validation still consists of a developer dragging a few things and looking at screenshots. Finish line: six layers of automated evidence plus a DOM Inspector; users can select, move, resize, and delete without a mouse; production failures have privacy-safe stage metrics.
Add these files and interfaces:
src/engine/testing/FakeInputStream.tsandManualClock.ts: deterministic events;src/engine/__tests__/properties.test.ts: matrix, geometry, Command, and Migration properties;src/ui/CanvasAccessibility.ts: DOM Inspector and Focus Mapping;tests/browser/editor-a11y.spec.ts: Keyboard, Clipboard, IME, and DPR;tests/visual/canvas.spec.ts: screenshots in a fixed environment;src/observability/telemetry.ts: stage marks, error categories, and redaction.
The HTML/TypeScript below gives Canvas an operable alternative interface. It does not turn every pixel into DOM. It projects business objects from the Document into a list. The buttons issue domain Commands directly, so Pointer and Keyboard share History and permissions. Note that the build tool compiles TypeScript and loads it as an external module; you cannot put type Shape = ... directly in a browser-native <script>:
<main class="canvas-shell">
<canvas id="canvas" tabindex="0" aria-label="Workflow canvas" aria-describedby="canvas-help"></canvas>
<p id="canvas-help">Press Tab to enter the object list. Use arrow keys to move, Shift to move faster, +/- to resize, and Delete to remove.</p>
<section aria-labelledby="objects-title">
<h2 id="objects-title">Canvas objects</h2>
<ul id="object-list"></ul>
<p id="canvas-status" role="status" aria-live="polite"></p>
</section>
</main>
<script type="module" src="/src/ui/CanvasAccessibility.ts"></script>
type Shape = {
id: string;
type: 'rect' | 'text';
name: string;
x: number;
y: number;
w: number;
h: number;
};
type Command =
| { type: 'MoveShape'; id: string; dx: number; dy: number }
| { type: 'ResizeShape'; id: string; dw: number; dh: number }
| { type: 'DeleteShape'; id: string };
const shapes: Shape[] = [
{ id: 'shape-start', type: 'rect', name: 'Start', x: 20, y: 40, w: 120, h: 64 },
{ id: 'shape-review', type: 'text', name: 'Manual review', x: 220, y: 40, w: 160, h: 64 },
];
const selected = new Set<string>(),
undoStack: Shape[][] = [];
const list = document.querySelector<HTMLUListElement>('#object-list')!;
const status = document.querySelector<HTMLElement>('#canvas-status')!;
const canvas = document.querySelector<HTMLCanvasElement>('#canvas')!;
function execute(command: Command) {
const i = shapes.findIndex((s) => s.id === command.id);
if (i < 0) return;
const before = structuredClone(shapes);
if (command.type === 'DeleteShape') {
shapes.splice(i, 1);
selected.delete(command.id);
} else if (command.type === 'MoveShape')
shapes[i] = { ...shapes[i], x: shapes[i].x + command.dx, y: shapes[i].y + command.dy };
else
shapes[i] = {
...shapes[i],
w: Math.max(24, shapes[i].w + command.dw),
h: Math.max(24, shapes[i].h + command.dh),
};
undoStack.push(before);
status.textContent = `${command.type} completed; you can undo it`;
renderInspector(command.type === 'DeleteShape' ? undefined : command.id);
drawCanvas();
}
function undo() {
const before = undoStack.pop();
if (!before) return;
const focusedId = (document.activeElement as HTMLElement | null)?.dataset.shapeId;
shapes.splice(0, shapes.length, ...before);
status.textContent = 'Undid the previous action';
renderInspector(focusedId);
drawCanvas();
}
function description(s: Shape) {
return `${s.type === 'text' ? 'Text' : 'Rectangle'} “${s.name}”, position ${s.x}, ${s.y}, size ${s.w} × ${s.h}`;
}
function renderInspector(preferredFocusId?: string) {
const focusedId =
preferredFocusId ?? (document.activeElement as HTMLElement | null)?.dataset.shapeId;
list.replaceChildren(
...shapes.map((s) => {
const li = document.createElement('li'),
button = document.createElement('button');
button.type = 'button';
button.dataset.shapeId = s.id;
button.textContent = s.name;
button.setAttribute('aria-pressed', String(selected.has(s.id)));
button.setAttribute('aria-label', description(s));
button.onclick = () => {
selected.clear();
selected.add(s.id);
renderInspector(s.id);
drawCanvas();
};
button.onkeydown = (event) => {
const step = event.shiftKey ? 10 : 1;
const delta: Record<string, [number, number] | undefined> = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
if (delta[event.key]) {
event.preventDefault();
const [dx, dy] = delta[event.key]!;
execute({ type: 'MoveShape', id: s.id, dx, dy });
} else if (event.key === 'Delete') {
event.preventDefault();
execute({ type: 'DeleteShape', id: s.id });
} else if (event.key === '+' || event.key === '=') {
event.preventDefault();
execute({ type: 'ResizeShape', id: s.id, dw: step, dh: step });
} else if (event.key === '-') {
event.preventDefault();
execute({ type: 'ResizeShape', id: s.id, dw: -step, dh: -step });
}
};
li.append(button);
return li;
}),
);
if (focusedId)
list.querySelector<HTMLElement>(`[data-shape-id="${CSS.escape(focusedId)}"]`)?.focus();
}
function drawCanvas() {
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const s of shapes) {
ctx.strokeStyle = selected.has(s.id) ? '#2563eb' : '#334155';
ctx.strokeRect(s.x, s.y, s.w, s.h);
ctx.fillText(s.name, s.x + 8, s.y + 24);
}
}
addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault();
undo();
}
});
matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', drawCanvas);
renderInspector();
drawCanvas();
Back to the toy: the DOM list is the set of tactile bumps and named buttons on the truck, not a different truck. It operates on the same shapes, so sighted users and Screen Reader users do not enter two drifting sets of business logic.
Property tests and browser tests prove mathematics and tasks separately:
import fc from 'fast-check';
import { expect, test } from 'vitest';
test('every invertible translation/rotation/scale in the pure math kernel round-trips', () => {
fc.assert(
fc.property(
fc.record({
x: fc.double({ noNaN: true, min: -1e6, max: 1e6 }),
y: fc.double({ noNaN: true, min: -1e6, max: 1e6 }),
}),
fc.double({ noNaN: true, min: 0.01, max: 100 }),
fc.double({ noNaN: true, min: -Math.PI, max: Math.PI }),
(point, scale, angle) => {
const c = Math.cos(angle),
s = Math.sin(angle);
const m = { a: c * scale, b: s * scale, c: -s * scale, d: c * scale, e: 37, f: -19 };
const apply = (p: { x: number; y: number }, n: typeof m) => ({
x: n.a * p.x + n.c * p.y + n.e,
y: n.b * p.x + n.d * p.y + n.f,
});
const det = m.a * m.d - m.b * m.c;
const inverse = {
a: m.d / det,
b: -m.b / det,
c: -m.c / det,
d: m.a / det,
e: (m.c * m.f - m.d * m.e) / det,
f: (m.b * m.e - m.a * m.f) / det,
};
const screen = apply(point, m),
roundTrip = apply(screen, inverse);
expect(Math.abs(roundTrip.x - point.x)).toBeLessThan(1e-6);
expect(Math.abs(roundTrip.y - point.y)).toBeLessThan(1e-6);
},
),
{ numRuns: 2_000 },
);
});
import { expect, test } from '@playwright/test';
test('selects and moves an object with keyboard only while preserving focus and History', async ({ page }) => {
await page.goto('/lab');
const start = page.getByRole('button', { name: /Rectangle “Start”/ });
await start.focus();
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Shift+ArrowDown');
await expect(start).toHaveAttribute('aria-label', /position 21, 50/);
await page.keyboard.press('ControlOrMeta+z');
await expect(start).toHaveAttribute('aria-label', /position 21, 40/);
await page.keyboard.press('ControlOrMeta+z');
await expect(start).toHaveAttribute('aria-label', /position 20, 40/);
await expect(start).toBeFocused();
});
Telemetry must also pass through a runtime allowlist. TypeScript types cannot stop as any, old clients, or third-party SDKs. The serializer below accepts only categories, durations, counts, and coarse environment data and rejects unknown fields. That prevents shapeText, Screenshot, URL, coordinates, and user ID from slipping in “for convenience”:
const allowed = new Set([
'name',
'correlationId',
'durationMs',
'objectCount',
'visibleCount',
'zoomBucket',
'inputDevice',
'browserFamily',
'browserMajor',
]);
const eventNames = new Set([
'uncaught_error',
'slow_interaction',
'save_failure',
'migration_failure',
'asset_failure',
]);
export function serializeTelemetry(raw: unknown) {
if (!raw || typeof raw !== 'object') throw new Error('INVALID_TELEMETRY');
const event = raw as Record<string, unknown>;
for (const key of Object.keys(event))
if (!allowed.has(key)) throw new Error(`SENSITIVE_OR_UNKNOWN_FIELD:${key}`);
if (!eventNames.has(String(event.name)) || !/^[a-f0-9-]{36}$/.test(String(event.correlationId)))
throw new Error('INVALID_TELEMETRY');
for (const key of ['durationMs', 'objectCount', 'visibleCount', 'browserMajor']) {
const value = event[key];
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value < 0))
throw new Error('INVALID_TELEMETRY_NUMBER');
}
return JSON.stringify(event);
}
correlationId is a random, short-lived ID generated for each failure chain. It must never be derived from an account, document, or Shape ID. Schema tests must verify that { ...safe, shapeText: 'confidential' }, screenshot, url, points, and userId are all rejected. The server must enforce the same allowlist again and apply sampling, retention, access-control, and deletion policies.
Run npm exec vitest run src/engine. Expect all 2,000 Matrix/Geometry property cases, Command Inversion, Migration, and Serialization tests to pass. Run npm exec playwright test --project=chromium --project=firefox --project=webkit. Expect Pointer, Keyboard, denied Clipboard access, IME, Drag/Drop, Export, and accessibility tasks to pass. When running npm run test:visual, expect zero baseline difference; every PR that updates a baseline must state the visual reason. npm run benchmark should report p50/p95/p99 and compare them with budgets.
Break it on purpose
| Injected failure | Symptom | Evidence | Fix | Regression test | Recovery |
|---|---|---|---|---|---|
| Unplug the mouse | Cannot select or move | Keyboard task stalls | DOM Inspector + same-source Command | Keyboard-only Playwright | Preserve Focus and Selection |
| Browser at 200% Zoom | Handle/Overlay misaligns | Screenshot + bounding box | Unify Screen/CSS coordinates | 100/200/400% projects | Recalculate overlay |
| Reduced Motion | Camera still flies | Media query and clock trace | Set nonessential animation duration to 0 | Emulation test | Jump to final state |
| Font missing | Wrapping and Selection shift | Font status + visual diff | Explicit fallback; relayout after font ready | Block font route | Deterministic fallback |
| Change test DPR | Pixels blur or snapshots drift | Backing-size report | DPR-aware resize | DPR 1/2/3 screenshots | Rebuild bitmap |
| Clipboard permission denied | Unhandled Promise | clipboard_denied telemetry | Provide text/button fallback | Permission-denial test | Leave Document unchanged |
| Migration Payload corrupt | Blank page | migration_failure + version | Quarantine and open recovery UI | Corrupt fixtures | Read old committed state |
| Screen Reader sees only Canvas | Announces only “canvas” | Accessibility tree | Focusable object list and descriptions | VoiceOver/NVDA task | Focus Mapping |
Pass with evidence
| Automated evidence | Manual evidence | Passing condition |
|---|---|---|
| Unit, Property, Replay, Browser, Visual, and Performance CI | VoiceOver/NVDA keyboard tasks, 200% Zoom, High Contrast, and touch-target checks | Every automated layer passes; manual tasks complete Select/Move/Resize/Delete without a mouse |
| Telemetry Schema tests and sensitive-field rejection tests | Inspect one real Correlation chain in the test environment | The stage and failure category are locatable, with no text, screenshot, exact path, or identity |
| Layer | Passing evidence | Evidence that cannot replace it |
|---|---|---|
| Model/Geometry | Property invariants and minimal counterexamples | One screenshot |
| State Machine | Every Transition in Replay | Final Shape position |
| Browser | Real Pointer/IME/Clipboard/Drag | Unit tests with handwritten DOM Events |
| Visual | Pixel diff in a fixed environment | “It looks fine to me” |
| Accessibility | Complete keyboard and Screen Reader task | Zero automated-scan errors |
| Production | Staged p95, categorized error, and correlated chain | Development-machine console |
- After deleting the Renderer, Geometry, Document, History, Migration, State Machine, and Commands tests still import and run.
- Pointer Cancel, Race Sequence, Fake Input Stream, and Deterministic Clock all have tests.
- Keyboard can Select, Move, Resize, and Delete, with a Non-Drag Alternative.
- Focus is not lost without cause during redraw or remote updates; Selection and DOM Inspector stay synchronized.
- Reduced Motion, High Contrast, 200% Zoom, and reasonable Target Size pass manual tasks.
- Telemetry contains no Shape text, screenshots, full URLs, exact trajectories, or identity information.
Explain it to a five-year-old
Do not use the words “unit test,” “accessibility,” or “Telemetry.” Explain why a beautiful photo of the fire truck does not prove that its wheels and brakes work or that it can be used without sight. Why can none of the inspection stations be removed? If the truck breaks at a real school, how can it leave useful clues without reading a child’s secrets?
A good jargon-free answer
A photo tells us only what the shell looks like at that moment. We must measure the parts separately, press every kind of button, and test-drive it on a real table. We must also make sure that someone who cannot use a mouse or see the screen can still find named things and perform the same actions. If the truck slows down or breaks at school, record only “which part took how long, what kind of failure happened, and which records belong to the same incident”—never what the child drew or wrote. That gives every kind of “really correct” its own evidence.