feat(framework): browser-aligned touch events (W3C subset) over a pac…#177
Draft
odex21 wants to merge 6 commits into
Draft
feat(framework): browser-aligned touch events (W3C subset) over a pac…#177odex21 wants to merge 6 commits into
odex21 wants to merge 6 commits into
Conversation
…ked u32 wire Touch input becomes a first-class event stream instead of a per-frame snapshot. Hosts pack one u32 per event — phase (start/move/end/cancel), 10-bit logical x/y, and dticks (ms since previous event, clamped at 63) — and the framework dispatches W3C-aligned touchstart/touchmove/ touchend/touchcancel with implicit target capture (W3C §5.2), bubble- only propagation (§5.6), and per-frame timestamps reconstructed from dticks so sub-frame down+up pairs both fire in order. Wire format: bits[1:0] phase, bits[11:2] x, bits[21:12] y, bits[27:22] dticks. 10-bit coords (0..1023) cover viewports beyond the 9-bit era's 511-px wrap, which teleported dragged elements on 640-px-class panels. Outlier guard: a move sample jumping more than half the viewport in one frame is discarded with the contact pinned at the last known-good position. I2C partial reads and EMI bit flips on ESP32-class touch controllers produce exactly these single-frame spikes; a human finger cannot move that fast at any plausible sample rate. Baseline resets on every touchstart, so fast taps are never filtered. Hosts should still reject spikes earlier — this is the framework backstop. host-web: touch release is deferred one frame so down/up pairs arriving within the same frame are both visible to the gesture system instead of collapsing into an empty snapshot. Includes: framework touch-events module + contract tests (25 cases, W3C section references incl. bubbling order / background-target / targetTouches cases), vue-vapor/solid component props (onTouchstart...), sim host 4th frame argument, devtools replay neutralization of live touch, and a touch-lab demo app.
odex21
marked this pull request as draft
July 24, 2026 13:02
Both public component surfaces now declare the W3C-subset touch handlers
with the real event type, so ev is PocketTouchEvent (not any) in app code:
- primitives.ts (Solid): ViewProps/TextProps/ImageProps/SpriteProps extend
a shared TouchHandlers interface (TouchHandler from touch-events.ts).
JSX <View onTouchstart={fn}> now typechecks against the real signature
— wrong signatures fail, verified with a scratch tsx.
- components-vue-vapor.ts: same TouchHandlers on the four Props
interfaces; cleanProps' touch branch wraps with callbackOf<TouchHandler>
instead of (ev: unknown), closing the type gap between the template
binding (@touchstart) and the registry. The four primitives now actually
USE their own Props contracts: primitive() is overloaded per tag
(view→ViewProps, text→TextProps, image→ImageProps) and Sprite gets its
own definePocketVaporComponent<SpriteProps> — previously all four
shared ViewProps, so Text wrongly accepted focusable/onPress and
Image/Sprite accepted children and lacked src/sprite constraints.
Verified with a scratch tsx: <Text focusable>, <Image>x</Image> and
<Sprite src=...> all fail to compile.
- touch-lab demo: handlers typed with PocketTouchEvent (was any).
- tests: fix a pre-existing strict-null error exposed by the full
tsconfig run (ev.currentTarget is NodeMirror | null at dispatch type
level; toBe needed the widened expected type).
bunx tsc --noEmit clean; 25/25 touch-events tests green.
odex21
force-pushed
the
feat/touch-events-w3c-wire
branch
from
July 24, 2026 14:23
cc28cef to
16dc091
Compare
Collaborator
|
I'm not fully convinced if aligning browser spec is the best practice here. There are history legacy there. I need some research to build a |
…trices The touch-lab demo app landed without its admission entries, so the registry scans failed on the new app: - platform-contracts: add touch-lab [psp, vita, widget] = [true, true, false] (fixed-viewport demo, same shape as zoomlab — widget form does not host it) - launcher-sim: registry count 17 -> 18 - regenerate apps/launcher/registry.generated.ts + images.json via tools/launcher.ts scan (adds the touch-lab card + cover) bun run test green.
The guard filtered any move sample jumping more than a fixed pixel threshold, pinning the contact at the last known-good position. Remove it for three reasons: - It treated the wrong disease. The dragged-element jumps that motivated it were a TIMING artifact of the old per-frame snapshot model (sub-frame down+up pairs collapsing into empty snapshots), not corrupt coordinates. The event-stream + dticks design in this branch fixes that root cause; no corrupt-coordinate spike was ever the actual mechanism. - Wrong granularity for multi-touch. The baseline was a single GLOBAL last-good position. Two alternating contacts legitimately jump between two distant positions, so the filter would discard valid multi-touch samples as 'spikes'. - Single responsibility: coordinate sanitization belongs to the host, which owns the electrical context. The framework should accept the host's sample stream as authoritative. Drops the lastGoodX/Y state, OUTLIER_DX/DY_MAX, and the two spike tests. 23/23 touch-events tests green.
Promote the touch stream from single-contact to multi-touch. The packed u32 gains a contact identifier in its top nibble (bits[31:28], 0..15) — enough for every PocketJS touch host: PS Vita front panel (up to 8) and web pointer streams (10 on touchscreen laptops). Single-point controllers report id 0, which is fully backward compatible. - wire: decodeTouchSample/__packTouchSample carry id; bit 28+ no longer 'future headroom' but a live field. - dispatch: the capture table was already keyed by identifier — replace the three hardcoded 0s (has/set/get, identifier) with s.id so each contact captures, moves, and ends independently. A second Start for an already-down id is ignored (host-glitch guard). - timestamps: dticks is now PER CONTACT. The frame-boundary reconstruction keeps one running offset per id and walks backwards, so interleaved contacts each rebuild their own timeline; id-0 streams reduce to the previous shared-timeline behaviour. - host-web: pointer handlers keyed by e.pointerId (Map) instead of a single boolean + last-x/y; each pointerId gets its own dtick clock. - tests: +6 multi-touch cases — wire id round-trip, independent per-contact capture (§5.2), interleaved moves with stable touches list (§5.3), end-one-keep-one, per-contact dtick reconstruction, re-Start guard. 29/29 touch-events tests green; bun run test green.
odex21
force-pushed
the
feat/touch-events-w3c-wire
branch
from
July 25, 2026 07:20
9ddd93d to
1e1d4b3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a small touch event dispatcher (
framework/src/touch-events.ts, ~230 lines of logic). Hosts pack one u32 per event; the framework firestouchstart/touchmove/touchend/touchcancelwith bubble-only propagation, implicit target capture, and multi-touch via a per-contact identifier. One hit test per contact (at itstouchstart); after that, moves/ends walk the capture table, so an active drag costs zero FFI. Zero touch = zero per-frame cost (no events → no FFI, no allocation).The four event names and the dispatch semantics (implicit capture, bubble order, per-contact identifiers) are borrowed from W3C Touch Events — which is Apple's 2007 iPhone design, later standardized in 2013. So the model here is native-grade, not web-grade: it is the same implicit-capture + parent-chain walk that iOS
UITouchand the responder chain use.This is the raw event layer only. It does not include a gesture layer (tap / long-press / pan / pinch), and this PR does not propose one.
Validated end-to-end on real hardware (two ESP32-class targets with different touch controllers): ESP32-P4 + AXS15260 (640×226) and ESP32-S3 + CST9217 (Waveshare ESP32-S3-Touch-AMOLED-1.75). Drag, tap, long-press, and slider gestures all run on these events in the demo app.
Why not an iOS-shaped API directly?
The dispatch semantics here are the same ones iOS uses (implicit capture + parent-chain walk). This layer does not provide gestures; if a
Touchable-style surface is wanted later, it can sit on top of this stream rather than being blocked by it.Wire format
One u32 per event:
bits[1:0]phase (start/move/end/cancel)bits[11:2]x (logical px, 10-bit, 0..1023)bits[21:12]y (logical px, 10-bit, 0..1023)bits[27:22]dticks (ms since this contact's previous event, clamp 63)bits[31:28]contact identifier (0..15)10-bit coords fix the 9-bit era's wrap: at x ≥ 512 the old snapshot truncated and wrapped x=639 → 111, teleporting dragged elements on 640-px-class panels. dticks reconstruct per-event timestamps at the frame boundary, so sub-frame down+up pairs both fire in order instead of collapsing into an empty snapshot. Note 10-bit is sized for the current target hardware (logical 640×226 and smaller); a future panel with a logical axis over 1023 would need a wider field.
Semantics (borrowed subset)
touch.view): aftertouchstart, all moves/ends for that contact go to its start target regardless of position — dragging survives leaving the element.stopPropagationworks;currentTargettracks the bubbling node.targetToucheslists only contacts targeting the currentTarget (§5.3).touchmovewithout movement (integer logical coords).Deliberately not implemented:
addEventListener, and theTouchEventconstructor.radiusX/Y,rotationAngle,force. The AXS15260 / CST9217 report contact points with no area or pressure data, so these have no source on ESP32-class panels — a hardware boundary, not a scope choice.Coordinate sanitization is the host's job
The framework accepts the host's sample stream as authoritative and does not filter coordinates. Sanitization belongs to the host, which owns the electrical context. An earlier draft of this branch carried a framework-side outlier guard; it was removed because it treated the wrong disease (the dragged-element jumps were a timing artifact of the old per-frame snapshot model, fixed properly by the event-stream + dticks design), it kept a single global baseline that would mis-fire on legitimate multi-touch input, and per-host filtering is the correct layering.
What ships
framework/src/touch-events.ts— decoder, dispatcher, capture/bubble, multi-contact tracking.tests/touch-events.test.ts) with W3C section references: bubbling order, background-target dispatch,targetTouchesper-currentTarget,stopPropagationat target, and 6 multi-touch cases (independent per-contact capture, interleaved moves, end-one-keep-one, per-contact dtick reconstruction).onTouchstart/onTouchmove/onTouchend/onTouchcancel(vue-vapor + solid).pointerId(multi-touch); release deferred one frame so same-frame down+up pairs both fire.apps/touch-labdemo.Known limitations (honest list)
preventDefault()is a shape-only no-op — the framework has no default behavior to cancel; it only flipsdefaultPreventedfor API compatibility.false(altKey/ctrlKey/metaKey/shiftKey) — placeholders for shape only.claims_hitreturns true for any text run, nopointer-eventsequivalent): any text painted after an interactive element steals its touches. Apps structure around it (the demo ball became a root-level absolute element). A hit-behavior style bit would address it —claims_hitalready carries a TODO-shaped comment.Test plan
bun test tests/touch-events.test.ts— 29/29 green.