Repository navigation
feat(browser): capture mouse buttons, modifiers and wheel for input.json version 3 - #9
Conversation
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)
56ad419 to
cada36b
Compare
krassx
left a comment
There was a problem hiding this comment.
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)?
|
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 consistentInput coordinates are What is notThe viewer does not draw coordinates against the viewport. It normalises them against
The coordinate is stable; the frame it is divided by shrinks. A click at Two things make it worse:
Grepped the whole monorepo: What would fix itRecord the layout viewport ( 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:
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 🤖 Generated with Claude Code |
Summary
The JavaScript half of
input.jsonversion 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
buttonPointerEvent.button{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 roundbuttonMaskPointerEvent.buttonsbegin/end, drag and hovermove, andscrollmetaStateMETA_*layout, so one viewer switch reads every platformscrollX/scrollYWheelEvent.delta*scrollUnitWheelEvent.deltaModepixel/line/pageHover 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
buttonis never defaulted to0. 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.
scrollentries omitid— this SDK's convention of omitting an optional field rather than emitting a placeholder for one that does not apply. Noted becauseinput.mddescribesidas always present; recorded indocs/design/sdk-design.md§8.4.1.Testing
Full monorepo: 447 files, 6674 passed, 3 skipped (pre-existing), 0 failures.
input-source.tsis at 100% line/branch/function/statement coverage.biome checkclean on the touched files;tsc --noEmitclean 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