[{"content":"Welcome. This is where I keep notes, write-ups, and whatever else seems worth writing down.\n","date":"22 August 2026","externalUrl":null,"permalink":"/","section":"Hi there 👋","summary":"Welcome. This is where I keep notes, write-ups, and whatever else seems worth writing down.\n","title":"Hi there 👋","type":"page"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/tags/browser/","section":"Tags","summary":"","title":"Browser","type":"tags"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/tags/chromium/","section":"Tags","summary":"","title":"Chromium","type":"tags"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/tags/debugging/","section":"Tags","summary":"","title":"Debugging","type":"tags"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/tags/input/","section":"Tags","summary":"","title":"Input","type":"tags"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/categories/notes/","section":"Categories","summary":"","title":"Notes","type":"categories"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/tags/pointer-events/","section":"Tags","summary":"","title":"Pointer-Events","type":"tags"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"Notes from debugging pen input in a browser UI. Five behaviours that broke assumptions I held, or that I have seen repeated as fact.\nEverything below was measured twice, on two engines fifty-two versions and four years apart. That turned out to matter: one of the five does not reproduce on the current one, and I would have published it as a general truth if I had not checked.\nThe two setups # A B Engine Chromium 99, embedded (Adobe CEP 12) Chrome 151, standalone Tablet Wacom Intuos Pro L1, Bluetooth, WTabletServicePro, Windows Ink on same OS Windows 11 Pro 26200 same Page devicePixelRatio 1, single monitor same Same tablet, same driver, same machine, same session. Only the engine differs.\nBehaviour A (Chromium 99) B (Chrome 151) 1 Compatibility mouse events stop during a pen drag yes — 49 / 0 yes — 1034 / 2 2 A pen drag can be cancelled out from under you (prevented by CSS) yes — every stroke, under 0.5 s 3 Hover mouse events echo the pen exactly, 1:1 yes — 1294 / 1294 yes — 82 / 82 4 The cursor does follow the pen while hovering yes — max Δ 0 px yes — max Δ 0 px 5 maxTouchPoints is 0 with a working pen yes yes — Wheel coordinates land nowhere near the pen yes — ~700 px off no — 1 px That last row is the one to be careful with, and it comes last for that reason.\n1. Compatibility mouse events stop during a pen drag # Split one session by whether a button was held:\nChromium 99 (CEP) pointermove mousemove from the browser hovering 1294 1294 pen down, dragging 49 0 Chrome 151 pointermove mousemove from the browser hovering 82 82 pen down, dragging 1034 2 (4 strokes) Also zero mousedown and zero mouseup for the press, on both engines. So a UI built on DOM mouse events sees a stroke that never starts, or starts and never moves — while the pointer stream is complete and correct throughout. The hovering rows are the control: while the pen hovers, the echo is perfect. It is specifically the press that turns it off.\nThis is the one most likely to bite you today, and it is engine-current — not a legacy quirk.\nFix, without rewriting an existing mouse-driven UI onto pointer events. Each pointer event issues a ticket; the compatibility event the browser normally sends immediately afterwards redeems it; a ticket still unredeemed one task later is an event the browser never sent, and only those get synthesised:\nconst pending = { down: [], move: [], up: [] }; function backfill(kind, source) { const ticket = { redeemed: false }; pending[kind].push(ticket); setTimeout(() =\u0026gt; { const i = pending[kind].indexOf(ticket); if (i \u0026gt;= 0) pending[kind].splice(i, 1); if (!ticket.redeemed) synthesize(kind, source); // the browser never sent it }, 0); } function redeem(kind) { const q = pending[kind]; if (q.length) { q.shift().redeemed = true; return true; } return false; // no ticket outstanding: a genuine, independent mouse event } Mark synthesised events (e.mySynthetic = true) and skip them on the way back in, or they redeem their own tickets. When the browser behaves normally the mechanism is completely inert — every ticket is redeemed, nothing is synthesised, and there is no double handling to reason about.\n2. A pen drag can be cancelled out from under you # The Chrome 151 stroke above did not end with the pen lifting. It ended like this:\nstroke [pen id=2] 248 ms ended by pointercancel pointermove (buttons held) = 7 mousemove = 0 mousedown = 0 mouseup = 0 A quarter of a second in, the browser took the drag away as a pan gesture and fired pointercancel. My test page set no touch-action.\nThat is the documented cause, so I ran it as a controlled A/B: six identical strips on one page, differing in one CSS declaration. Two long strokes on each condition, same pen, same session:\ntouch-action strokes cancelled stroke length pointermoves auto (default) 2 2 480 ms, 131 ms 6, 6 none 2 0 9231 ms, 8842 ms 513, 509 Not a marginal effect. Under the default the drag never survived half a second; with touch-action: none both strokes ran until the pen lifted, seventy times longer, and delivered eighty-five times the events.\nTwo consequences worth handling:\n/* opt the draggable surfaces out of the gesture */ .canvas, .slider, .picker { touch-action: none; } /* a cancelled pointer never produces a mouse-up, so a drag handler stays armed and the next stray move keeps painting */ el.addEventListener(\u0026#39;pointercancel\u0026#39;, e =\u0026gt; endDragAsIfMouseUp(e)); /* and take the capture on press, so leaving the element does not end the drag */ el.addEventListener(\u0026#39;pointerdown\u0026#39;, e =\u0026gt; el.setPointerCapture(e.pointerId)); This is the \u0026ldquo;the slider dies half-way through a stroke\u0026rdquo; bug. It reproduced spontaneously on a page that was not even trying to trigger it, which is worth sitting with: the failure needs no unusual input, no gesture, no edge case — only a pen, a drag, and a surface that forgot to opt out.\n3. \u0026ldquo;Which input moved last\u0026rdquo; cannot be answered by event order # Suppose you want to know whether the pen or the mouse is the thing currently pointing. The obvious approach — remember both streams, compare timestamps or a sequence counter, most recent wins — cannot work.\nBoth engines mirror each pen pointermove with a compatibility mousemove at the same coordinates, one step later. Counting hover moves with no button held:\nChromium 99 pointer = 1294 mouse = 1294 Chrome 151 pointer = 82 mouse = 82 Exactly equal, because each is the other\u0026rsquo;s echo. So the mouse stream\u0026rsquo;s newest event is always newer than the pen\u0026rsquo;s, and a last-one-wins comparison concludes \u0026ldquo;the mouse moved last\u0026rdquo; every single time — including while the pen is the only thing being touched. The branch you wrote for the pen never runs.\nThis is not a bug. Compatibility mouse events are specified behaviour. The bug is reading them as evidence of an independent mouse.\nFix. Ask pointerType, not the clock:\nlet lastPointer = null; document.addEventListener(\u0026#39;pointermove\u0026#39;, e =\u0026gt; { lastPointer = { x: e.clientX, y: e.clientY, type: e.pointerType }; }, true); // worth handling pointerover too: a pen entering in hover reports it // before it reports any move const penIsPointing = () =\u0026gt; lastPointer \u0026amp;\u0026amp; lastPointer.type === \u0026#39;pen\u0026#39;; No bookkeeping between streams is needed, because moving the mouse emits a pointermove of its own with pointerType: \u0026quot;mouse\u0026quot; — lastPointer stops being a pen the moment the mouse moves. The question answers itself.\n4. The cursor does follow the pen while hovering # The received wisdom — I have read it in several places, and built on it myself before measuring — is that tablet drivers only move the Windows cursor when the pen touches down, so while the pen hovers the cursor stays parked wherever the mouse was last left.\nOn this hardware it is simply false, on both engines. Pairing every pointer move with the mouse move that echoed it, inside 50 ms:\nChromium 99 n = 1294 max |dx| = 0 max |dy| = 0 Chrome 151 n = 82 max |dx| = 0 max |dy| = 0 Zero divergence, not \u0026ldquo;small\u0026rdquo;. The cursor tracks the pen exactly, hover included.\nThis cost me a round of work: I had written a fix for a divergence that does not occur, and the real defect was elsewhere. If your symptom is \u0026ldquo;it acts on the wrong place under the pen\u0026rdquo;, measure the two positions before assuming they differ — the answer changes which bug you are chasing.\n5. maxTouchPoints is not a pen-capability signal # navigator.maxTouchPoints === 0 on both engines, on a machine where the pen reports pointerType: \u0026quot;pen\u0026quot; with pressure 0 – 0.57 and tilt −26 – 27 degrees, and produced a thousand hover moves in one session. A working digitiser, invisible to the capability check. Feature-detect window.PointerEvent and branch on pointerType at event time instead.\nRelated: vendor troubleshooting advice for this class of problem is routinely \u0026ldquo;turn Windows Ink off\u0026rdquo;. With Ink on, pointerType was correctly \u0026quot;pen\u0026quot;, pressure and tilt were populated, and everything above worked. Turning Ink off makes the pen report as a mouse, which is precisely what removes your ability to tell the two apart.\nAnd one that did not reproduce # I nearly published this as a general Chromium behaviour. It is not one.\nOn Chromium 99 embedded in CEP, with the pen hovering a control at (103,391) — and the system cursor at that same point, see finding 4 — every wheel notch arrived carrying:\nclientX/clientY = (1, 464) and in a second run, (0, 385) The x pinned to the viewport\u0026rsquo;s left edge, the y wandering. elementFromPoint resolved to a container nowhere near the control, so the feature silently did nothing — not the wrong control, nothing, which is much harder to debug because it looks like your handler never ran.\nOn Chrome 151, same tablet, same machine, same session: five pen-hover notches, every one of them landing within 1 px of the pen, and elementFromPoint at the wheel position returning the same element as at the pen position every time. It does not reproduce.\nSo this is specific to that old embedded engine, not something to file against Chromium. The plausible neighbourhood is that WM_MOUSEWHEEL on Win32 carries screen coordinates rather than client coordinates, unlike every other mouse message, and something in that engine\u0026rsquo;s chain treated them as the wrong space; other frameworks have hit the same class of bug (Godot, DirectXTK). I did not confirm it.\nWorth defending against anyway, if you ship into an embedded engine you do not control. The defence is the same lastPointer from finding 3, and it costs nothing on engines without the bug, because there the two positions agree:\nfunction wheelPoint(e) { if (lastPointer \u0026amp;\u0026amp; lastPointer.type === \u0026#39;pen\u0026#39;) { return { x: lastPointer.x, y: lastPointer.y }; } return { x: e.clientX, y: e.clientY }; } How to measure this yourself # Do not skip to a fix. Log one line per event and read the log:\nEvery pointer and mouse event: type, pointerType, pointerId, isPrimary, pressure, tiltX/tiltY, coordinates, target, and a flag for events you synthesised. Every wheel event: deltaY, deltaMode, wheelDeltaY, the event\u0026rsquo;s own coordinates — and then what your handler decided to use, and what that resolved to. That last part separates \u0026ldquo;the pen position was never used\u0026rdquo; from \u0026ldquo;it was used and still resolved wrong\u0026rdquo;. The event alone cannot tell you which, and they have different fixes.\nThree things to summarise across the log, because they answer the questions you actually have:\nHover move counts per device id — does the driver report hover at all. Maximum divergence between the pointer and mouse positions, paired in time — do the two streams actually disagree, or did you assume it. Counts split by whether a button was held — this is what exposes finding 1, and it is invisible in a total. Two traps I fell into while building the harness, both of which produced confident wrong numbers:\nScope your counters to one stroke. My first version bucketed mouse moves using a penDown flag set on pointerdown and cleared on pointerup. A stroke ended in pointercancel instead, the flag stuck on, and every subsequent hover move was counted as a drag — reporting 107 compatibility events during a drag that actually had none. Open the counter on pointerdown, close it on pointerup and pointercancel, and bucket from e.buttons on the event itself rather than from any flag you maintain.\nSize a ring buffer for the noisiest event. Mine kept the last 600 rows, and a few seconds of scrolling evicted every pointer row before I could read them. Keep cumulative counters separately; they stay correct after the raw rows roll off.\n","date":"22 August 2026","externalUrl":null,"permalink":"/posts/what-a-tablet-actually-sends/","section":"Posts","summary":"Five things pen input does in Chromium that break common assumptions, each measured on two engines four years apart. One of the five does not reproduce on the current engine — which is the point.","title":"What a tablet actually sends","type":"posts"},{"content":"This blog runs on Hugo and GitHub Pages, and every post is published in English, Chinese, and Japanese. The whole pipeline looks like this:\nWrite posts in Markdown under content/posts/ git push to GitHub GitHub Actions runs hugo --minify The build output is published to GitHub Pages From hitting save to seeing it live takes about a minute.\nWriting a new post # Each post is three files sharing one base name, with the language code before the extension:\ncontent/posts/some-title.en.md content/posts/some-title.zh.md content/posts/some-title.ja.md Hugo links them together automatically, so the language switcher in the header jumps between versions of the same post rather than dumping you back on the home page.\nCode blocks get syntax highlighting:\ndef greet(name: str) -\u0026gt; str: return f\u0026#34;Hello, {name}!\u0026#34; Tables, blockquotes, images — all the usual Markdown works.\nImages go in static/, and you reference them as /image-name.png.\nPreviewing locally # hugo server -D starts a local server with hot reload, so you can see changes as you type. Push once it looks right.\n","date":"22 August 2026","externalUrl":null,"permalink":"/posts/hello-world/","section":"Posts","summary":"This blog runs on Hugo and GitHub Pages, and every post is published in English, Chinese, and Japanese. The whole pipeline looks like this:\nWrite posts in Markdown under content/posts/ git push to GitHub GitHub Actions runs hugo --minify The build output is published to GitHub Pages From hitting save to seeing it live takes about a minute.\nWriting a new post # Each post is three files sharing one base name, with the language code before the extension:\n","title":"First Post: The Blog Is Live","type":"posts"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/categories/log/","section":"Categories","summary":"","title":"Log","type":"categories"},{"content":"","date":"22 August 2026","externalUrl":null,"permalink":"/tags/meta/","section":"Tags","summary":"","title":"Meta","type":"tags"}]