Skip to content

Bring main up to date: CDN probe, console.table, shell fixes, element split - #1

Merged
jerrychan7 merged 26 commits into
mainfrom
sync/main-2026-11-02
Nov 2, 2026
Merged

jerrychan7 merged 26 commits into
mainfrom
sync/main-2026-11-02

Conversation

@jerrychan7

Copy link
Copy Markdown
Owner
  • refactor(cdp-agent): move snapDOM rendering out of the screencast into a shared render
  • feat(cdp-agent): take screenshots with Page.captureScreenshot and report Page.getLayoutMetrics
  • docs: describe screenshots and their limits
  • fix(cdp-agent): scale screenshots down only when the canvas cannot hold them
  • feat(cdp-agent): answer the emulation commands device mode sends
  • feat(shell): device mode in shell mode, with DevTools laying out the page as in Chrome
  • docs: describe device mode and the frontend's layout in shell mode
  • ci(release): require npm 11.21.0, which npm dist-tag needs under trusted publishing
  • fix(shell): clear device mode with the frontend and tidy the shell layout
  • fix(cdp-agent): retry a screenshot smaller only when drawing fails
  • fix(cdp-agent): report the deprecated layout metrics in device pixels as Chrome does
  • test(cdp-agent): cover the screenshot retry and the layout metrics on a scaled screen
  • test(e2e): make the device mode tests check what they claim
  • docs: describe the branch model and the development and release flow
  • docs: move the design out of the README into docs/design.md
  • feat(shell): resize docked DevTools with a grip, shared by the panel and the shell
  • docs: a short README to start with, and a usage guide to look things up in
  • fix(runtime): preview console.table's rows so the frontend can draw the table
  • feat(probe): one script tag from jsDelivr or unpkg, shell mode included
  • docs(changelog): note the console.table fix
  • fix(shell): restore the page when the shell's element goes, and narrow device mode's page
  • test: restore what the tests patch even when they fail, and prove the CDN probe ran
  • refactor(element): split element.ts into an element/ directory

…o a shared render

The screencast loaded snapDOM, picked the page background, hid DevTools' own elements and
encoded the canvas itself. Move that into page/render.ts as createRender and encode, so that
Page.captureScreenshot can render with the same loaded snapDOM. The render also takes a
page-coordinate clip besides 'viewport'. createCdpSession builds the render once from
loadSnapdom; the screencast keeps its own error when snapDOM is not configured.
…ort Page.getLayoutMetrics

Tools and agents that drive the page over CDP can already read the DOM, but had no image of
it. Page.captureScreenshot renders with snapDOM, like the screencast, and follows Chrome's
parameters as checked against real Chrome: clip is in page coordinates with or without
captureBeyondViewport, the image has device pixels (devicePixelRatio times clip.scale), and
captureBeyondViewport without a clip takes the whole page. The response adds approximate: true,
a field Chrome does not send, since the image is the DOM redrawn rather than painted pixels.
Images over 4096 x 4096 pixels, Safari's canvas limit, are scaled down to fit.

Page.getLayoutMetrics returns the viewport, visual viewport and content size in CSS pixels in
both sets of fields, as Chrome does on an unzoomed page; DevTools uses it for node screenshots.

The conformance tests compare image sizes and the clipped content with Chrome's, at device
scale factor 2 too, and getLayoutMetrics field by field.
README lists screenshots under the limitations next to the screencast: rendered by snapDOM,
marked approximate, Chrome's parameters otherwise, scaled down past Safari's canvas limit, PNG
where WebP cannot be encoded. The changelog notes the two new methods under Unreleased.
…ld them

A screenshot was capped at 4096 x 4096 pixels, the smallest canvas limit of any browser, so a
long page came out scaled down in Chrome too. Pass the size asked for to snapDOM instead: it
already scales an image past the browser's canvas limits down to fit (32767 pixels a side,
16384 on Safari, 16384 x 16384 in area). Should drawing still fail, by throwing or by encoding
an empty data URL, the screenshot is halved and tried again; once it fits in 4096 x 4096 the
failure is reported.

The conformance tests check that a 2000 x 20000 page is taken at full size and that a
2000 x 200000 page is scaled down to the longest side Chrome allows. README states the limits.
Device mode sends Emulation.setSafeAreaInsetsOverride, resetPageScaleFactor and
setUserAgentOverride, Network.setUserAgentOverride, and Overlay.setShowHinge and
setShowDisplayCutout. A page cannot do any of them, and an error from one stops the frontend's
device mode from updating, so they are answered with empty results like the other commands
without an in-page equivalent.
…page as in Chrome

In Chrome the device toolbar is part of DevTools' window: its app (AdvancedApp) lays out the
page and DevTools, and tells the browser where the page goes (setInspectedPageBounds). In shell
mode the frontend now does the same. The shell passes #?shell=1, the entrypoint sets
can_emulate_device and loads the emulation module, and the frontend fills the shell. Its
setInspectedPageBounds goes to the shell (page-bounds), which places the page iframe there on
top, and the shell answers Emulation.setDeviceMetricsOverride and clearDeviceMetricsOverride by
sizing and scaling the iframe (shell-layout.ts). The frontend fills the shell from the start:
device mode does not recover from laying out in a small frame first.

Hidden, the page fills the shell at its own size; shown again, device mode is back. On touch
screens a 28px handle straddles the edge between the page and DevTools (the frontend reports
its split size), and dragging it sets DevTools' size (sidebar-size notice).

Undocked, Chrome draws device mode into device_mode_emulation_frame.html in the tab. The
frontend asks the shell to show that page (toolbox) instead of opening a window; the shell shows
it in an iframe under the floating panel, and the frontend finds it among its parent's frames
and hands its document to AdvancedApp. The build keeps the page, bundled as one file.

patches/device-mode-in-shell.patch adds the can_emulate_device condition to device mode,
keeps the screencast out of shell mode, opens device mode's page next to anywhere_app.html, and
drops the device pixel ratio and device type from the toolbar's menu, since a page cannot change
either. Outside shell mode nothing changes: the simple app and the screencast stay.

device-mode.test.ts runs the built frontend in shell mode: the page goes where the frontend's
placeholder is, a device preset sizes and scales it, the screenshot command saves it at the
device's size, hiding and showing, undocking into device mode's page, and the touch handle.
README: in shell mode DevTools lays out the page as in Chrome, with device mode on the
toolbar's device button, undocked too; device mode sets the page's size only, not its pixel
ratio, user agent or touch input. DEV: how the entrypoint, the shell and the patch fit together,
and device mode's page bundled as one file. The changelog notes it under Unreleased.
…ted publishing

npm dist-tag works with trusted publishing once each package's trusted
publisher allows it, from npm 11.21.0. The publish job installed npm@11,
which happens to resolve to a later release today; it now asks for
^11.21.0 so that the Chrome milestone tag cannot fall back to an older
npm that has no credentials for it.
…yout

- A frontend loaded in place of another (its URL changed) no longer
  inherits the previous one's device metrics: the page iframe is no longer
  sized, scaled and clipped for a device mode the new frontend never
  turned on. Chrome clears the override with the frontend too.
- The shell layout is disposed when its element leaves the shell: its
  resize listener, touch handle and device mode page are removed, so that
  an element replaced and connected again does not stack them up.
- A toolbox request whose URL does not parse is ignored instead of
  throwing in the message handler.
- The frontend looks for device mode's page for its latest request only,
  so undocking twice in a row no longer hands the page over twice; it
  waits up to 60 seconds instead of 10 for a slow load, and says in the
  console when the page never loaded.
Page.captureScreenshot halved the image and tried again on any error,
until it was small enough to report the failure, so a snapDOM that failed
to load was loaded again several times before the error came back.
Failures before drawing (loading snapDOM, reading the page) are now a
CaptureError and reported at once; only a failure to draw the canvas is
tried again at half the size.
… as Chrome does

Page.getLayoutMetrics returned CSS pixels in the deprecated layoutViewport,
visualViewport and contentSize as well as in the css* fields, which only
matches Chrome on a screen with a scale factor of 1. Measured in Chrome
on a screen scaled by 2: the layout viewport's position and size, the
visual viewport's size and the content size are in device pixels; the
visual viewport's position stays in CSS pixels. Ours now multiplies the
same fields by devicePixelRatio.

The conformance tests compare both on a screen scaled by 2, for which
launch() can now leave pages Chrome's own viewport (defaultViewport: null)
rather than Puppeteer's 800 x 600 at a scale of 1.
… a scaled screen

- captureScreenshot with a stand-in snapDOM that fails to draw until a
  quarter of the size: it draws at 1, 0.5 and 0.25; and with a snapDOM
  that fails to load: it loads once and reports that error.
- getLayoutMetrics matches Chrome on a screen with a scale factor of 2,
  scrolled.
- Picking a device that is not in the list now fails: the undocked test
  picked "iPhone SE" again in the toolbox (the page kept its size from the
  previous test, so a broken toolbox toolbar would have passed); it now
  picks "iPhone 16", which only that toolbar can set.
- The stand-ins for convertToBlob, anchors' click and matchMedia are
  restored once used.
- waitFor says what it waited for when it times out.
- New: docked to the right, the page goes where the frontend says; leaving
  device mode gives the page the room the frontend lays out for it.
- The file says that its tests run in order, from where the previous one
  left the page.
DEV.md and DEV.zh-CN.md now say how work reaches main and how releases
are cut from it:

- main is the only long-lived branch, changed through pull requests with
  CI passing; there is no develop branch, since merging releases nothing
  and main itself holds what is merged and not yet released.
- Short-lived branches are named after the commit type (feat/..., fix/...,
  ...), and a change users notice adds its changelog line in the same pull
  request.
- A release is prepared on release/x.y.z through a pull request, then
  tagged on main; releases are cut when there is something worth
  releasing, not on every merge. A new feature is a minor.
- An older minor is patched on release/x.y, branched from its tag, with
  fixes merged into main first and cherry-picked; what that still needs
  (CI on release/** and publishing without moving latest) is listed as
  not set up yet.
- ci is listed among the commit types.
- The API report paragraph no longer says the repository has no CI:
  GitHub Actions sets CI=true, so CI fails on a report left uncommitted.
The README spent most of its length on how the project works before
saying how to use it. It now answers what the project is, why, how to
use it, what it cannot do and which packages there are, in that order;
the design moves to docs/design.md and docs/design.zh-CN.md:

- the technology choices and the relation to Chii and chobitsu;
- the architecture: the probe, shell mode and injection;
- why the packages are split as they are (the package table stays in the
  README).

The text is moved, not rewritten. Links into the moved sections (shell
mode, injection) now point at the design document, and its links to
DEV.md and experiments/ are relative to docs/. DEV.md and DEV.zh-CN.md
point to the design document, list docs/ in the repository layout and
ask for it to be kept up to date with the README.
…and the shell

A docked panel was resized by a bar across its whole edge, and in shell
mode by the frontend's own resizer, a few pixels wide and invisible (with
a wider handle on touch screens only, which covered the top of DevTools).
Both now get the same grip: a small pill with three dots against the
middle of DevTools' edge, on the page's side, like a tab of the panel. It
takes input only where it is seen, so it covers none of DevTools and only
as much of the page as it shows; it is larger on touch screens.

- grip.ts is the reusable piece: a Grip placed against an edge of its
  parent, across it, inside or outside, with its size for a mouse and for
  a finger, drag callbacks, and colors from --grip-* custom properties.
- The panel puts it outside its inner edge; the bar now only shows on the
  undocked window, which it moves.
- The shell puts it inside the page's area, against its edge next to
  DevTools, as the frontend lays the page out; it takes DevTools' toolbar
  colors like the panel's.
- The device mode tests find the grip by its title, check its size and
  place, and wait for the undocked page's width as well as its position,
  which raced.
- In the shell the grip sits on the page's side of the split, which the
  frontend now reports (`split`, its rect) in place of the sidebar size:
  the page bounds are only the device's screen in device mode, which put
  the grip on the screen's edge, away from DevTools.
…up in

The README spent its length on long sentences that readers had to dig
through for the one option they needed. It now says what devtools-anywhere
is, how it compares, how to start (Vite, a CDN script tag, a phone from a
computer), the common options, what works, and the packages, with two
screenshots of WebMC (https://github.com/jerrychan7/WebMC): device mode in
landscape on a computer, and a phone, the Elements panel dark next to the
light page. The images are lossless WebP.

docs/usage.md and docs/usage.zh-CN.md are the reference, in tables and short
lines: which setup for which scenario, the script tag and its attributes
with their Vite options and defaults, the Vite plugin, an element placed by
the page, the panel's controls and togglers, shell mode (what it changes,
when it is off, its caveats, the Service Worker), remote debugging (CLI
options, behavior, Node.js API and endpoints), the limitations, the
screenshots' parameters, and the packages. Everything the README said
before is kept, and corrected where it was wrong: shell mode needs the
bundle and frontend on the probe's origin (not the page's), which is why a
CDN setup turns it off; the Vite plugin defaults to the installed frontend;
its serviceWorker option is on / off only.

The design documents link to the usage guide, the Chinese one gains the
half sentence it was missing on the network buffers and says DevTools
throughout, and two long English sentences are split. DEV.md, the
packages' READMEs and a comment in screenshot.ts point to the usage guide.
…he table

console.table of an array of objects showed a single "Value" column, since
the rows' previews had no fields of their own. Like V8, the data is now
previewed as a table: up to 1000 rows, each with a preview one level deep
and no limit on its columns. Columns given as an array filter the rows, and
only the data is sent, as Chrome does.

The conformance tests compare the whole arguments with Chrome's, for rows of
objects and arrays, an object of objects, columns, many columns and rows,
and a primitive.
From an npm CDN the probe needed the frontend's URL, and shell mode had
to be turned off: shell configs were trusted on the probe's origin, which
on a CDN is everyone's package, so a page's script could point the shell
to any frontend published there.

The URL of the script that reads a shell config is now its trust root
(trust.ts). On jsDelivr or unpkg it trusts only this package and the
frontend's, at its own version; URLs are compared once parsed, so dot
segments are resolved, and escaped slashes or backslashes are refused.
Elsewhere it still trusts its origin. With that, the probe from such a
CDN loads the frontend of its version there by default, and shell mode
works, without the Service Worker, which must be on the page's origin.

data-no-panel goes without the panel and shell mode, now that a frontend
may be found without data-devtools-frontend-url: a page from such a CDN
only debugged remotely needs it, or it gets a panel too.

Unit tests cover the default frontend and the trust rules, path tricks
included; e2e tests play the CDN through request interception: one tag
enters shell mode with the frontend of the same version, a config
pointing to another package on the CDN is dropped (it was trusted
before), data-no-panel mounts nothing, and the built frontend from the
CDN lays out the page. The README, usage guide, design notes and
changelogs say so.
66a2a30 fixed console.table of objects, which showed a single "Value" column, but did not say so in the changelogs.
…w device mode's page

Found in review:

- Removed from the shell for good, the element left the page iframe as
  the layout placed it (fixed, maybe at a device's size, scaled and
  clipped). ShellLayout.dispose now clears that, so the page fills the
  document again.
- Device mode's page (the toolbox) was accepted from anywhere on the
  frontend's origin, which on an npm CDN is every package's. It must now
  sit next to the frontend's entry page, compared as trust.ts compares
  shell configs.
- A grip's drag ends on lostpointercapture, which follows pointerup and
  pointercancel and also fires when the grip is removed mid-drag, so the
  drag always ends once.
- The probe's and bundle's comments said shell configs must come from
  the script's origin, no longer the rule on a CDN.

Device mode's e2e tests check that the element removed from the shell
leaves the page unplaced, and that a page of the frontend's origin but
not next to it is refused as the toolbox; both fail without the fix.
… CDN probe ran

Found in review:

- Device mode's tests patched OffscreenCanvas and anchor clicks, the
  shell's matchMedia and the dock side, and restored them only once
  their checks passed: a failure left the later tests on a patched page.
  They restore in finally now.
- The CDN tests that check nothing happens (a config to another package
  dropped, data-no-panel) also passed when the probe never ran. They now
  wait for the probe's element, or for the bundle with data-remote,
  instead of a fixed delay. A CDN file that fails to load aborts its
  request rather than leaving it unanswered.
- The trust rules are checked on unpkg and at a dist-tag too.
element.ts had grown to 1103 lines, past the size check's warning. Its parts
that do not need the element's private state now live next to it:

- style.ts: the shadow root's style sheet.
- registry.ts: which element is which (global, the shell's, its F12 button
  in the shell's page, placed by the page) and the internal events between
  them; elements of this window's class are now known by a WeakSet filled in
  the constructor instead of instanceof, so the togglers need not import
  the class.
- togglers.ts: elements with data-devtools-anywhere-toggle.
- panel.ts: the panel's remembered sizes and position, and dragging the bar,
  the grip and the corner.
- request.ts: the checks on requests from the frontend.

element/index.ts keeps the element itself (690 lines). No change in
behavior; DEV.md and comments point to the new path.
…emulation

On CI, Chrome sometimes answers Input.dispatchMouseEvent's mouseReleased with touch emulation for the mouse just enabled by sending the page a pointerup and a mouseup, as for a real mouse: 'a release or a move without a touch sends nothing' failed twice in a row on PR #1 and once on main before, and never locally. Chrome's side now ignores those as it already did the hover's pointerover and mouseover; ours still has to send nothing at all.
On CI, Chrome sometimes sends no pointerout for the field the test before clicked, where ours does: 'a middle click fires auxclick' failed twice in three runs on PR #1. What it checks is the press and auxclick: the hover's pointerover/out and mouseover are left out of the comparison.
On CI the Watch pane's prompt was sometimes in the DOM but not laid out yet when clicked, and Puppeteer threw 'Node is either not clickable or not an Element'. The test now waits for a box before each click there (add watch, the prompt, the line number).
@jerrychan7
jerrychan7 merged commit 3e3f6ea into main Nov 2, 2026
1 check passed
@jerrychan7
jerrychan7 deleted the sync/main-2026-11-02 branch November 2, 2026 05:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant