JEPA4Japan · tutorials

Chapter 14: Looking Correct Is Not Being Correct

3,413 words 16 min read #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Prove a Canvas editor across model, state-machine, browser, visual, accessibility, performance, and production-telemetry layers.

Course progress Course outline 18 of 18 lessons available

Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates

  1. 01 Chapter 1: Do Not Draw Yet—Canvas Is Not a Product Architecture available now
  2. 02 Chapter 2: A Sheet of Pixels That Forgets available now
  3. 03 Chapter 3: Turn Drawing into a Replayable Recipe available now
  4. 04 Chapter 4: Four Maps and a Camera available now

Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools

  1. 05 Chapter 5: Give the Pixel World a Registry available now
  2. 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
  3. 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
  4. 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
  5. 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

  1. 10 Chapter 10: Make the Editor Feel Right available now
  2. 11 Chapter 11: Drawn Text Is Not Editable Text available now
  3. 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
  4. 13 Chapter 13: Time Machines and Old Boxes available now
  5. 14 Chapter 14: Looking Correct Is Not Being Correct Current lesson

Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI

  1. 15 Chapter 15: Do Not Search Ten Thousand Children One by One available now
  2. 16 Chapter 16: Keep the Front Desk Out of the Kitchen—Worker and GPU Upgrades available now
  3. 17 Chapter 17: Build the Car or Buy a Proven Chassis? available now
  4. 18 Chapter 18: People and AI Edit the Same Ledger available now

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.

  1. Part rulesRules hold however they turn
  2. Button sequenceCancel never commits by mistake
  3. Real tabletopBrowser and pixels
  4. No mouseThe task still works
  5. Check its temperature in useNever spy on private content
Predict first: if every visual snapshot passes but pressing Escape still commits the move, is the product correct? No. It passed only one station.

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 inspectionCanvas Lab evidenceQuestion it answers
Gauge for gear dimensionsGeometry / Model Unit TestDo the mathematics and invariants hold?
Turn wheels at randomProperty-Based TestDo properties still hold across many valid inputs?
Script of button actionsEvent Replay / State AssertionWhich Transition ran?
Controllable stopwatchDeterministic ClockAre double-click, long-press, and animation independent of real time?
Test drive on a real tableBrowser Interaction TestDo Pointer, IME, and Clipboard actually work?
Compare photos from the same angleVisual RegressionDid DPR, fonts, or drawing change unexpectedly?
Feel raised markers while blindfoldedFallback Semantics / Focus MappingCan a Screen Reader understand and locate objects?
Remote control with arrow keysKeyboard Move / ResizeCan the task be completed without Drag?
Larger buttonsTarget Size / Non-Drag AlternativeDoes low-precision input work?
Black boxPrivacy-Safe TelemetryWhere 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-label is 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.ts and ManualClock.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 failureSymptomEvidenceFixRegression testRecovery
Unplug the mouseCannot select or moveKeyboard task stallsDOM Inspector + same-source CommandKeyboard-only PlaywrightPreserve Focus and Selection
Browser at 200% ZoomHandle/Overlay misalignsScreenshot + bounding boxUnify Screen/CSS coordinates100/200/400% projectsRecalculate overlay
Reduced MotionCamera still fliesMedia query and clock traceSet nonessential animation duration to 0Emulation testJump to final state
Font missingWrapping and Selection shiftFont status + visual diffExplicit fallback; relayout after font readyBlock font routeDeterministic fallback
Change test DPRPixels blur or snapshots driftBacking-size reportDPR-aware resizeDPR 1/2/3 screenshotsRebuild bitmap
Clipboard permission deniedUnhandled Promiseclipboard_denied telemetryProvide text/button fallbackPermission-denial testLeave Document unchanged
Migration Payload corruptBlank pagemigration_failure + versionQuarantine and open recovery UICorrupt fixturesRead old committed state
Screen Reader sees only CanvasAnnounces only “canvas”Accessibility treeFocusable object list and descriptionsVoiceOver/NVDA taskFocus Mapping

Pass with evidence

Automated evidenceManual evidencePassing condition
Unit, Property, Replay, Browser, Visual, and Performance CIVoiceOver/NVDA keyboard tasks, 200% Zoom, High Contrast, and touch-target checksEvery automated layer passes; manual tasks complete Select/Move/Resize/Delete without a mouse
Telemetry Schema tests and sensitive-field rejection testsInspect one real Correlation chain in the test environmentThe stage and failure category are locatable, with no text, screenshot, exact path, or identity
LayerPassing evidenceEvidence that cannot replace it
Model/GeometryProperty invariants and minimal counterexamplesOne screenshot
State MachineEvery Transition in ReplayFinal Shape position
BrowserReal Pointer/IME/Clipboard/DragUnit tests with handwritten DOM Events
VisualPixel diff in a fixed environment“It looks fine to me”
AccessibilityComplete keyboard and Screen Reader taskZero automated-scan errors
ProductionStaged p95, categorized error, and correlated chainDevelopment-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.