Skip to content

feat(framework): browser-aligned touch events (W3C subset) over a pac…#177

Draft
odex21 wants to merge 6 commits into
pocket-stack:mainfrom
odex21:feat/touch-events-w3c-wire
Draft

feat(framework): browser-aligned touch events (W3C subset) over a pac…#177
odex21 wants to merge 6 commits into
pocket-stack:mainfrom
odex21:feat/touch-events-w3c-wire

Conversation

@odex21

@odex21 odex21 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a small touch event dispatcher (framework/src/touch-events.ts, ~230 lines of logic). Hosts pack one u32 per event; the framework fires touchstart / touchmove / touchend / touchcancel with bubble-only propagation, implicit target capture, and multi-touch via a per-contact identifier. One hit test per contact (at its touchstart); 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 UITouch and 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)

  • Multi-touch (W3C §5.1): each contact carries an identifier (0..15) stable for its lifetime; contacts are tracked independently. 4 id bits cover every PocketJS touch host — PS Vita front panel (up to 8), web pointer streams (10 on touchscreen laptops); single-point controllers (ESP32-S3's CST9217) report id 0, which is fully backward compatible.
  • Implicit target capture (W3C §5.2 / iOS touch.view): after touchstart, all moves/ends for that contact go to its start target regardless of position — dragging survives leaving the element.
  • Bubble-only propagation (W3C §5.6, target + bubble phases only): target first, then ancestors innermost→outermost; stopPropagation works; currentTarget tracks the bubbling node.
  • Hit tests that fall through to the document still dispatch, with root as target (background taps reach root listeners).
  • targetTouches lists only contacts targeting the currentTarget (§5.3).
  • No touchmove without movement (integer logical coords).

Deliberately not implemented:

  • Spec-surface scope cuts: capture phase, passive listeners, addEventListener, and the TouchEvent constructor.
  • Hardware-dependent fields the target controllers cannot report: 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.
  • 29 contract tests (tests/touch-events.test.ts) with W3C section references: bubbling order, background-target dispatch, targetTouches per-currentTarget, stopPropagation at target, and 6 multi-touch cases (independent per-contact capture, interleaved moves, end-one-keep-one, per-contact dtick reconstruction).
  • Component props onTouchstart / onTouchmove / onTouchend / onTouchcancel (vue-vapor + solid).
  • host-web: pointer→touch stream keyed by pointerId (multi-touch); release deferred one frame so same-frame down+up pairs both fire.
  • host-sim: 4th frame argument carries the packed stream; devtools replay neutralizes live touch.
  • host-vita: touch.rs updated to the 10-bit wire (compile-checked only — no Vita hardware here).
  • apps/touch-lab demo.

Known limitations (honest list)

  • preventDefault() is a shape-only no-op — the framework has no default behavior to cancel; it only flips defaultPrevented for API compatibility.
  • Modifier keys are hard-coded false (altKey/ctrlKey/metaKey/shiftKey) — placeholders for shape only.
  • Text nodes claim hits unconditionally (claims_hit returns true for any text run, no pointer-events equivalent): 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_hit already carries a TODO-shaped comment.
  • z-index only orders siblings; a dragged element cannot be raised above an overlapping subtree from a different branch.
  • host-vita updated blind (no device to verify).

Test plan

  • bun test tests/touch-events.test.ts — 29/29 green.
  • Demo app (separate ESP32 repo): sim-driven regression asserts a dragged ball remains hittable at its new position; real-device serial observation on ESP32-P4 shows stable 28 fps with the touch stream active, and the same gesture page runs on the ESP32-S3 AMOLED target.

…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
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
odex21 force-pushed the feat/touch-events-w3c-wire branch from cc28cef to 16dc091 Compare July 24, 2026 14:23
@doodlewind

Copy link
Copy Markdown
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 Touchable API surface that possibly aligns the iOS touch system more.

odex21 added 3 commits July 25, 2026 14:19
…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
odex21 force-pushed the feat/touch-events-w3c-wire branch from 9ddd93d to 1e1d4b3 Compare July 25, 2026 07:20
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.

2 participants