Skip to content

feat(browser): capture mouse buttons, modifiers and wheel for input.json version 3 - #9

Merged
krassx merged 5 commits into
mainfrom
feat/mouse-capture-v3
Sep 21, 2026
Merged

krassx merged 5 commits into
mainfrom
feat/mouse-capture-v3

Conversation

@krassx

@krassx krassx commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Summary

The JavaScript half of input.json version 3, the wire contract that lets the viewer render mouse input — buttons, chords, modifiers and wheel deltas — instead of drawing every pointer event as an anonymous touch. The contract is specified once for all four components (iOS, Android, JS, viewer) in bugsee/specs#19; the other three land alongside this.

What the browser SDK now records

Field Source Notes
button PointerEvent.button The button that changed, remapped from the DOM's order to the contract's: {0:0, 1:2, 2:1, 3:3, 4:4}. The DOM calls the middle button 1 and the secondary 2; the contract is the other way round
buttonMask PointerEvent.buttons Bits of everything held, on every stage the platform can read it — begin/end, drag and hover move, and scroll
metaState the four modifier flags Android's META_* layout, so one viewer switch reads every platform
scrollX / scrollY WheelEvent.delta* Signed, accumulated per frame
scrollUnit WheelEvent.deltaMode pixel / line / page

Hover is sampled at a fixed interval rather than per event — a hovering mouse produces a move event per pixel, and the recording only needs the path.

Two decisions worth reviewing rather than skimming

button is never defaulted to 0. Zero means primary on the wire. A button we could not read is therefore omitted, never emitted as 0 — the same rule the other three SDKs follow. This is the single most repeated mistake in this contract; it appeared independently in about six places across the platforms.

Hover sampling is deliberately NOT masked-target-gated, unlike drag moves. A drag has a target to check because it opened on one; hover never opens a gesture, so it has none, and it carries only a bare position — the same as every other pointer coordinate this source already records over a masked field. This was flagged in review as looking like an oversight, and it is not; it is now documented in the file's PII discipline header and pinned by a test. That test was added late: a verification pass found that mutating the hover path to add the mask check left all 141 tests passing, so the decision was stated but not actually held in place.

scroll entries omit id — this SDK's convention of omitting an optional field rather than emitting a placeholder for one that does not apply. Noted because input.md describes id as always present; recorded in docs/design/sdk-design.md §8.4.1.

Testing

Full monorepo: 447 files, 6674 passed, 3 skipped (pre-existing), 0 failures. input-source.ts is at 100% line/branch/function/statement coverage. biome check clean on the touched files; tsc --noEmit clean across the workspace.

Every fix on this branch was then re-checked by reverting its production hunk and confirming a test goes red for the right reason — 15 behaviours, 14 already pinned, the one gap above closed.

Merge order

The viewer PR must land with or before this one. A version 3 recording read by today's viewer mislabels buttons silently — no error, just the wrong button name in the tooltip.

🤖 Generated with Claude Code

specs/sdk/reporting/bundle/input.md v3 fixes one cross-platform meaning
for mouse buttons/modifiers/scroll: `button` is the button that
CHANGED (shared numbering), `buttonMask` the buttons HELD, `metaState`
also written on a mouse begin/end, and `scrollX`/`scrollY`/`scrollUnit`
for a wheel occurrence. Add those fields to InputEvent (core/events.ts)
and correct the stale `button` doc that described the old, DOM-passthrough
web-only semantics.

input.json itself gains its own stream version, independent of the
bundle manifest version: INPUT_STREAM_VERSION = 3 (protocol/constants.ts).
bundle-assembler now wraps the `input` file type as `{ version, events }`
— the spec's own envelope — instead of a bare array, so a consumer can
key its button/scroll decoding on `version`. Every other file type is
unaffected.

docs/design/sdk-design.md's §8.4.1 table and ahead-of-contract note
updated to match; `button`/`buttonMask`/`metaState`/scroll are no
longer "ahead of contract" as of v3 — they are the spec's own fields.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Implements the browser SDK's side of the mouse-capture phase
(recording-input-presentation, mouse phase) against the version-3
wire contract:

- button: maps DOM `MouseEvent.button` onto the shared cross-platform
  numbering (0 primary, 1 secondary, 2 middle, 3 back, 4 forward) —
  the DOM numbers middle/secondary the other way round, so this is a
  map, not a passthrough. An unrecognised value is omitted, never
  sent as 0. Mouse begin/end only; a pen or touch pointer gets no
  button field at all (previously every pointer type did).
- buttonMask: the buttons held, from `MouseEvent.buttons` — the DOM
  bit layout already matches the wire's, so it passes straight
  through.
- metaState: now also written on a mouse begin/end, through the same
  Android-metaState mapping the keydown path already uses.
- mouse moves: pointermove now records two new cases, on the same
  change-gated, frame-aligned terms as a pen stroke — drag (while any
  button is held; suppressed for a gesture that began on a masked
  target) and hover (no button held), the latter sampled at ~10/s via
  a named constant (HOVER_SAMPLE_INTERVAL_MS — the rate is a guess,
  open item 31) rather than streamed. A hover run's id is freed the
  moment a real press begins, so it can never collide with — or,
  Android's own documented bug, be reused by — a press id.
- wheel: a new passive, capture-phase `wheel` listener emits
  `type: 'scroll'` with position, signed scrollX/scrollY (the DOM's
  sign convention already matches the wire's — no flip), scrollUnit
  from deltaMode, tool and metaState. Same-frame deltas are summed
  and coalesced into one entry via a frame-aligned scheduler
  (injectable for tests; requestAnimationFrame by default).

Tests cover the DOM→contract button mapping (including the
middle/secondary swap and the unknown-button omission), buttonMask,
metaState on press/release, drag moves (incl. masked-target
suppression), hover sampling and its rate limit, wheel deltas per
deltaMode and per-frame coalescing, and that a pen/touch pointer never
gains a button/buttonMask/metaState field. 100% line/branch-adjacent
coverage on the changed file (98.96% branch package-wide, driven by
pre-existing gaps elsewhere).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Two documentation-only follow-ups from review of the mouse-capture
change (input.json v3):

- Hover sampling is deliberately NOT masked-target-gated, unlike a
  drag move. A drag suppresses itself for a gesture that began on a
  masked target because it has a target to check; hover never opens a
  gesture, so it has none — and it carries only a bare position, same
  as every other pointer coordinate this source already records over
  a masked field. Documented in input-source.ts's PII discipline
  section so it reads as a decision, not an oversight.

- The `id` omission on `scroll` entries is this SDK's own convention
  (omit an optional field entirely rather than emit a placeholder for
  one that doesn't apply), not a gap against input.md, which lists
  `id` as always present. Stated plainly in sdk-design.md's §8.4.1
  field table.

No code changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
The contract was clarified after this SDK's mouse capture landed:
buttonMask is written on every mouse stage the platform can read
MouseEvent.buttons on — begin/end, a drag or sampled hover move, and
a wheel turn — not begin/end alone. button stays where it was: only
a press or release changes which button is down, so a move or a
wheel turn never carries one.

- Extracted buttonMaskFor() (mask only) out of mouseButtonFields()
  (button + mask, begin/end only), and a shared ButtonsEventLike
  interface (`buttons?: unknown`) so both PointerEventLike and
  WheelEventLike — a native WheelEvent inherits `buttons` from
  MouseEvent — can read it the same way.
- #mouseDragMove / #mouseHoverMove now spread buttonMaskFor(e) into
  their detail.
- #onWheel reads e.buttons into the WheelAccumulator (latest-event-
  wins, same as position/metaState, omitted when unreported rather
  than defaulted to 0); #flushWheel emits it when present. A
  non-zero mask on a scroll entry means only "a button happened to
  be down while the wheel turned" — never a press.
- metaState stays on scroll (kept deliberately: ctrl-plus-wheel is a
  zoom, not a scroll, worth telling apart — the contract's prose
  didn't explicitly cover this case but is being updated to say so).

Updated the three tests that pinned the old begin/end-only absence
with toStrictEqual (drag move, hover move, the wheel entry shape),
and added coverage for a chorded drag, a wheel turn with a button
held (the scenario the contract names), buttonMask omission when
unreported, and latest-event-wins semantics on scroll.

100% line/branch/fn/stmt on input-source.ts itself (verified via
`vitest run --coverage`); full suite `pnpm test`: 447 files, 6673
passed, 3 skipped (pre-existing), 0 failed; `pnpm lint` clean (24
pre-existing warnings elsewhere, untouched by this change).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
… comment

The mouse-capture branch documents (commit d44bd23) that hover sampling
is deliberately NOT gated on the pointer's target being masked, unlike a
drag move — but no test exercised a hover pointermove over a masked
target. Mechanically confirmed the gap: adding mask-gating to
#mouseHoverMove (mirroring #mouseDragMove) passed the full existing
input-source suite. Add a test that emits a hovering pointermove with a
password-input target and asserts the sample still comes through, so a
well-meaning "fix" that makes hover symmetric with drag is caught.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@krassx
krassx force-pushed the feat/mouse-capture-v3 branch from 56ad419 to cada36b Compare September 19, 2026 13:20

@krassx krassx left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A generic question for all the coordinate values in input capture - have we validated that we always get correct X and Y (and/or bounds when required) for input events when page has zoom adjusted (both up and down)?

@krassx

krassx commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Good question, and the honest answer is no — zoom is not validated, and checking it turned up a real bug that is not in the mouse code.

What is consistent

Input coordinates are clientX/clientY and viewtree rects are getBoundingClientRect(). Both are CSS pixels relative to the layout viewport, so they stay in step with each other at any zoom — page zoom and pinch zoom both leave that relationship intact (pinch zoom moves the visual viewport; neither API reports it). Target attribution and hit-testing are therefore sound.

What is not

The viewer does not draw coordinates against the viewport. It normalises them against environment.hardware.screen, which for the browser SDK is screen.width/screen.height (packages/browser/src/environment.ts:54). screen.width is itself expressed in CSS pixels, so it moves inversely with zoom while the coordinates do not. Measured, headless Chrome, one page, varying only the CSS-px-to-device-px ratio:

ratio screen.width getBoundingClientRect().left
1 800 100
1.5 534 100
2 400 100

The coordinate is stable; the frame it is divided by shrinks. A click at clientX = 600 lands at 600/800 = 75% across the frame at 100% zoom and 600/400 = 150% — off the frame entirely — at 200%.

Two things make it worse:

  1. screen.width is the monitor, not the window. Even at 100% zoom, a browser window that is not full-screen is normalised against the whole display. This is wrong in the common case, independent of zoom.
  2. It is captured once, at launch — there is no video.aux equivalent for the web SDK, so a zoom change or a window resize mid-recording is never re-stated. (This is the same class of bug as Android's B6, where a recording with no letterboffing emitted no aux event at all and the viewer never learned the screen size.)

Grepped the whole monorepo: innerWidth, innerHeight and visualViewport appear nowhere outside tests. We do not record the viewport at all.

What would fix it

Record the layout viewport (innerWidth/innerHeight) as the frame, re-stated whenever it changes — a resize and a visualViewport listener, emitted as an aux-style entry rather than a launch-time constant — and have the viewer prefer it over screen for app_type web. Pinch zoom additionally needs visualViewport.scale/offsetLeft/offsetTop if we ever want to draw against what the user actually saw rather than the layout.

That is a wire-contract change (a new field, plus a viewer branch), so I would rather agree it than slip it in. Two options:

  • Fold it into this PR — the v3 contract is still under review, which is the cheapest moment to add a field.
  • Land this and follow up — nothing here regresses: the frame is equally wrong for the touch coordinates this SDK already recorded before v3. The mouse work does not introduce it.

I lean towards folding it in, since the alternative ships a contract we already know is missing a field. Say which and I will do it.

Caveat on the evidence: the table above varies headless Chrome's device-scale-factor, which exercises the same CSS-px ratio that page zoom does. Real ctrl-zoom on a real browser is worth confirming — and screen.width behaviour under zoom differs between Chrome/Firefox and Safari, which is its own argument for not using it as the frame.

🤖 Generated with Claude Code

@krassx
krassx merged commit 1b45d47 into main Sep 21, 2026
2 checks passed
@krassx
krassx deleted the feat/mouse-capture-v3 branch September 21, 2026 07:09
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