Skip to content

About

A Manifest V3 Chrome DevTools extension that intercepts Canvas 2D drawing calls and replays them on a scrubbable timeline, so you can step through exactly what rendered a frame.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

CanvasLint

A Chrome extension that records what a web page draws on a canvas and lets you scrub back through it, step by step, in DevTools.

I wanted to scrub through Canvas 2D draw calls the way the Network panel lets you scrub through requests: pick a point in a page's rendering history and see exactly what the canvas looked like at that moment. This is the result. It is version 1.1.0, built and tested by one person, and the limits are listed below rather than hidden.

What is this, in plain words

A canvas is a drawing area on a web page. A program draws on it with code, line by line: "move here, draw a circle, fill it green". Games, charts and drawing apps work this way. Browser developer tools can show you a page's HTML and its network traffic, but they can't show you what was drawn on a canvas or in what order; all you get is the finished picture. CanvasLint records those drawing instructions while the page runs and puts them in a new DevTools panel, so you can drag along a timeline, like scrubbing through a video, and stop at any moment to see the canvas as it was and the instructions that made it.

Live capture A scrubbed frame, State inspector open Findings
The panel following a page live: timeline, command list, replay canvas Frame 10 replayed up to command 6, the State tab showing what that command changed The Findings tab listing an unbalanced save() and a redundant fillStyle

All three come from demo/demo.html with the real extension loaded (npm run shots, e2e/screenshots.e2e.ts). The generator adds two sloppy lines to one frame of the demo (a repeated fillStyle and a save() that is never restored) so that Findings has something real to report.

Who it's for, and who it isn't

For you if you write canvas code by hand: small games, creative coding, charts you draw yourself, or a library that draws on a canvas and you want to see what it really sends. It works on 2D canvases (getContext('2d'), and an OffscreenCanvas that the page itself draws on).

Not for you if your canvas is WebGL or WebGPU: those are not intercepted, and neither is bitmaprenderer. Canvases inside iframes and inside web workers are not captured either (all_frames: false in the manifest). It is also not a DOM or accessibility linter, despite the name; "lint" here means checks on canvas drawing calls.

What you get

  • Timeline: one tick per frame (what one canvas drew between two screen refreshes). Drag it, or use the arrow keys; jump to the live edge; pause and resume capture.
  • Command list: every drawing call in the selected frame. Pick one and the canvas is shown as it was right after that call.
  • State inspector with diff: the canvas's settings (colors, line width, transform, save() depth, …) after the selected command, with what that command changed shown as was → now.
  • Lint findings: seven checks on the frame you are looking at, each with a link to MDN:
    1. blurry on high-DPI screens (the canvas's pixel size doesn't match its size on screen times the screen's pixel ratio);
    2. a setting set to the value it already has, with no drawing in between;
    3. save() and restore() that don't pair up;
    4. getImageData called twice or more in a frame without telling the browser (willReadFrequently);
    5. a shadow switched on while 100+ draw calls run;
    6. drawImage at positions that aren't whole numbers;
    7. a frame of 50+ commands identical to the one before (a guess, marked as a heuristic).
  • Export / import: save one canvas's recording to a file, and open it later or on another machine.

Download and install

  1. Download canvas-lint-v1.1.0.zip from the Releases page.
  2. Unzip it.
  3. Open chrome://extensions in Chrome (114 or newer).
  4. Enable Developer mode (top right).
  5. Click Load unpacked and select the unzipped folder.

Then open a page that uses a canvas (for a quick try, demo/demo.html from this repo), open Chrome DevTools on that tab and switch to the CanvasLint panel.

What it can't do yet

From the open items in docs/LEDGER.md, in plain words (the full list with reasons is under Known limitations):

  • If a page draws one canvas onto another (drawImage with a canvas as the source, which apps use to cache layers or sprites), the panel doesn't replay that part: it draws nothing for it and says so in the warnings. An app that works this way, such as Excalidraw's scene canvas, replays empty or incomplete.
  • Each frame is replayed from a blank canvas. What earlier frames left behind (pixels, the current color, a clip) is not carried over.
  • Gradients and patterns replay as transparent. Newer canvas methods such as roundRect show up as "unknown command" and are not drawn.
  • The replay canvas has the size the source canvas had at its first drawing; a later resize is not seen.
  • Only the frame you are viewing is checked by the lint rules, not the whole history.
  • The history cap is per tab (10,000 frames or about 32 MiB), so several drawing tabs each hold their own.
  • A page restored with the browser's Back button records nothing more until you reload it (a known bug, pinned by a failing test).
  • Import takes one recording at a time, and only inline images replay from a file.
  • Video sources and images that failed to load are fetched again on every replay.
  • The panel is slower than it was before I added the lint pass and the redesign (numbers in docs/PERF.md).

Privacy

I checked each of these in the code and the manifest:

  • Nothing is sent anywhere by the extension. There is no fetch, XMLHttpRequest, WebSocket or sendBeacon in src/, no remote script or stylesheet in the panel or popup, and no font download. An end-to-end test fails if the panel or popup requests anything from another origin (e2e/privacy.e2e.ts).
  • Two things do touch the network, both because you asked for them. If a frame you replay drew an image from a web address, the panel loads that image again from the same address (only http(s): and data:image/ addresses; replayEngine.ts). And a "docs" link in Findings opens MDN when you click it.
  • Recorded drawing stays in memory. History lives in the extension's service worker memory, per tab, and is gone when the worker or the tab is. A small marker (tab ids and canvas sizes, no drawing content) is kept in chrome.storage.session. Nothing is written to disk unless you press Export, and that file contains what the page drew, including any fillText content.
  • Permissions: storage, plus access to all sites, because the capture script has to run on every page at document_start. Capture is on by default on every page.

What's new in 1.1.0

  • Frames instead of guessed "blocks". Recording is now per canvas and per animation frame; the old parser that tried to guess where a drawing started and ended is gone. Several canvases per page and several tabs work, and each tab keeps its own history.
  • Capture from the first call, before the page's own scripts run.
  • Lint findings (seven rules), a State inspector and export/import, all new.
  • A redesigned panel: dark and light, keyboard control, accessible labels.
  • Replay fixes: a clip or the current path no longer leaks from one frame into another, Path2D replays correctly, and a bad setLineDash no longer stops the rest of a frame.
  • Pause, Resume and Reset now reach the page.
  • Checks on what a page sends: the worker validates every message from the page and drops what is malformed.
  • Faster capture. On my benchmark page (7,034 mostly numeric commands per frame, Chromium 156, idle machine, medians of 3 × 30 s) a frame takes 4.10 ms with the extension and 3.76 ms without it (+9 %); the worker spends 1.69 ms of CPU per page frame, against 11.8 ms in the first version I measured; the command list draws about 35 rows instead of one per command. This is one load; strings, gradients and images were not measured. The table is in docs/PERF.md ("1.1.0 final").

Details

Lint rules

Seven rules run in the panel, on the frame you have selected, and show up in the Findings tab with an MDN link each. The thresholds are starting points, not measured values; they are constants at the top of each file in src/lint/. Each rule has a unit test with a positive and a negative fixture.

Id Severity What it flags
L001 bitmap-dpr-mismatch warning bitmap size differs by more than 2 px from CSS size × devicePixelRatio (likely blurry)
L002 redundant-state-set info a context property set to the value it already has, with no draw in between
L003 unbalanced-save-restore error restore() at depth 0, or a frame that ends with an unmatched save()
L004 readback-without-hint warning getImageData called 2+ times in a frame on a context without willReadFrequently
L005 shadow-in-hot-path warning shadowBlur > 0 while 100+ draw calls run
L006 fractional-drawimage info non-integer drawImage destination x/y
L007 static-redraw info, heuristic a frame of 50+ commands identical to the previous frame's

The severities are my choice. Rules see one frame at a time, so state set in an earlier frame is unknown to them (details in docs/DECISIONS.md "Lint engine").

State inspector

The State tab shows the context state after the selected command, read from the replay context itself rather than parsed from the call log: style, line, shadow and text properties, line dash, the transform matrix (plus translate/scale/rotate) and the save() depth. Rows that changed at this command come first, as was → now. It shows what replay produced, so it is wrong wherever replay is (see Known limitations).

Export / import

Export in the panel header writes the selected canvas as gzip'd JSON (canvaslint-canvas<id>-<timestamp>.json.gz). Import opens such a file as an "offline" canvas next to the live ones, with the same replay, lint and inspector; live capture keeps running underneath. The format and the validation rules are in docs/RECORDING_FORMAT.md. An e2e test exports, imports and compares the replay canvas's toDataURL() before and after, byte for byte.

Keyboard

←/→ one command, Shift+←/→ one frame, Home/End oldest frame / live edge, Space pause/resume. The panel follows DevTools' dark or light theme.


Build from source

Requires Node 20.19+ or 22.12+ (engines in package.json) and Chrome 114+ (minimum_chrome_version in the manifest).

npm ci
npm run build

Then load the dist/ folder as above (Load unpacked).

npm run dev runs the three Vite builds in watch mode (scripts/dev.mjs; works on Windows cmd.exe and Git Bash). Reload the unpacked extension after a change.

npm test runs the vitest unit tests, then the Playwright e2e suite in Playwright's bundled Chromium (one-time npx playwright install chromium). npm run bench is a manual benchmark, not part of CI.


How it works

page (MAIN world, document_start)   src/inject
   │  wraps every method/setter on CanvasRenderingContext2D and
   │  OffscreenCanvasRenderingContext2D; calls through; records the call
   │  into compact batches; flushes once per animation frame (numbered),
   │  or at 4,096 queued commands, which stays in the same frame
   │  window.postMessage({ sentinel, batch })
   ▼
content script (isolated world)     src/content
   │  forwards over one long-lived port per tab ('content-relay')
   ▼
service worker                      src/background
   │  checks every batch from the page (it can be forged) and drops what fails;
   │  per tab: frames = one canvas between two animation-frame flushes;
   │  oldest whole frames evicted past 10,000 frames or ~32 MiB (estimated);
   │  answers LIST_CANVASES / LIST_FRAMES / GET_FRAME / GET_RECORDING
   ▼
DevTools panel (React + Zustand)    src/panel
   │  holds one frame at a time: timeline picks a frame, the command list
   │  picks a step; lint runs on the loaded frame
   ▼
replay canvas: new context at the source canvas's bitmap size, run the
frame's commands 0..N; unresolvable refs (e.g. an image that has not loaded)
are skipped with a warning

Arguments that can't cross the message boundary (images, gradients, Path2D, ImageData) become an opaque reference (SerializedRef); an image's src, or how a Path2D was built, is kept once per ref and sent with the frame, so replay can rebuild it. Each canvas's size, DPR and context attributes are sent once as CANVAS_META, just before its first recorded command. The list of intercepted methods and setters, and what is not covered, is in docs/DECISIONS.md "Interception coverage".


Decisions and trade-offs

  • Frames instead of the render-block parser. The first design inferred "render blocks" from the call stream (a parser guessing where a path or a paint starts and ends) and showed parser snapshots in the old inspector. I dropped it rather than finish it. A frame boundary is something the inject script observes directly (the animation-frame flush), so nothing is guessed; history, replay, lint and export all use that one unit, and the inspector now reads the real replay context instead of parser state. The cost: a frame replays from a default context, so state set in earlier frames is not carried over (Known limitations). The ledger entry is B2 in docs/LEDGER.md.
  • Storage: history stays in the service worker's memory. Chrome stops an idle extension worker after 30 s. I chose not to persist: capture is on for every page from document_start, so persisting would write every canvas page's frame stream (including fillText content) to disk to protect history that is only lost while nobody is inspecting it. An open panel pings the worker every 20 s, and a worker that restarts anyway says so ("capture was interrupted", via a marker in chrome.storage.session). IndexedDB and keeping history in the panel page are rejected in docs/DECISIONS.md "Capture history and the idle-killed worker". The downside is real and listed below.
  • Replay from a command log, not pixel snapshots. Context state (transform, styles, clip, current path) can't be restored from pixels, so checkpoints were not built. Replaying a frame is exact for anything that doesn't depend on an unresolvable external reference.
  • Compact batches over per-call messages. Per-call postMessage would flood the message queue, so commands are queued in the page and flushed once per animation frame as one number array plus one array for other arguments. The worker decodes a frame only when the panel asks for it.
  • Long-lived ports instead of one-shot sendMessage. The relay and the DevTools connection are high-frequency and bidirectional. Only the panel reconnects with an exponential back-off (src/panel/useWorkerPort.ts). The content script has no reconnect loop, so a page that stopped drawing does not keep waking the worker: it re-dials on its next batch, or when the worker asks it to because a panel opened on its tab.
  • Performance. I measured before changing anything and kept only what the numbers supported; the table of what each change did, and what I tried and dropped, is in docs/PERF.md. Headline, from that file (one load: 7,034 mostly numeric commands per frame, Chromium 156, 3 × 30 s medians): page frame time 3.73 ms without the extension, 13.16 ms with the first version, 4.07 ms now (+9 %); worker CPU per page frame 11.8 → 1.55 ms; command-list rows in the DOM 7,034 → 36; frame switch 189 → 21 ms. Strings, gradients and images were not measured.

Known limitations

From docs/LEDGER.md: its open items, in plain words.

Capture

  • History recorded while DevTools is closed is lost once the tab stops drawing for 30 s and the worker is stopped; the panel then says "capture was interrupted".
  • The history cap is per tab: N drawing tabs can hold N × 32 MiB.
  • Not intercepted: getters, contexts inside workers (including transferControlToOffscreen()), iframes (all_frames: false).
  • A page restored by Back from the browser's back/forward cache records nothing more until it is reloaded, and its canvas list is lost (a known bug, pinned by a failing test).
  • Any script on the page can still post well-formed commands it never drew, or pause its own capture; that only affects its own tab. The worker drops and counts malformed messages, but the panel does not show the count. A real command over the caps (a fillText over 10,000 characters, an image data: URL over 8 Mi characters, a dash list over 1,024 entries) is dropped too.

Replay

  • A frame replays from a cleared, default context: state and pixels from earlier frames are not carried over.
  • drawImage from another canvas is not replayed: the panel draws nothing for it and lists "drawImage from a canvas is not replayed". Apps that keep layers or sprites in a second canvas (Excalidraw's scene, for one) replay empty or incomplete.
  • Gradient and pattern styles replay as transparent (so the inspector shows transparent too); methods and setters added after the replay list was written (roundRect, reset, fontKerning, …) replay as unknown-command warnings.
  • The replay canvas has the source canvas's size at its first recorded command; a later resize is not seen.
  • Video sources and images that failed to load are re-fetched on every replay (S10).

Lint and inspector

  • Only the selected frame is linted, so timeline markers cover frames you viewed or followed live, not the whole history. Rules see one frame, and L001 judges the canvas as it was at its first draw.

Import

  • One imported recording at a time, one canvas per file. Gradient, pattern, Path2D and ImageData refs replay as warnings, and only inline data:image/* images replay from a file. A recording larger than Chrome's 64 MiB port-message limit may never arrive from the worker (not tested).

Performance

  • With the panel open in live mode the bench page drops from about 245 to about 100 fps; not isolated (the panel is a second page in the same headless browser). The panel itself uses 43–46 ms/s of main thread in live mode, against 33 before the lint pass, which lints the newest frame once a second.
  • One command step in the panel takes 5.3–5.6 ms, against 3.7 ms before the lint pass and the UI redesign; measured per commit, spread over rendering, no single cause (docs/PERF.md).
  • The bench covers one numeric-heavy load; fillText, gradient and image-heavy pages are not measured. Pages that build many Path2D objects pay for recording each path's construction (a probe: 1,000 paths of 200 segments took 22 ms to build instead of 5).

Not tested: screen-reader output, a real mouse drag, the narrow layout beyond screenshots, export time and memory on a full 32 MiB history.

What's next

Stack traces (which line of page code made a call) and pixel-to-call lookup (click a pixel, find the command that drew it). Neither is started.

History

docs/AUDIT.md is the pre-1.1 audit, kept as history; docs/LEDGER.md has the current status of every item in it. Before/after screenshots of the panel are in docs/screenshots/ and the audit behind them in docs/UI-AUDIT.md.

About

A Manifest V3 Chrome DevTools extension that intercepts Canvas 2D drawing calls and replays them on a scrubbable timeline, so you can step through exactly what rendered a frame.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages