diff --git a/apps/launcher/images.json b/apps/launcher/images.json
index 1cd205d2..e7d6b93a 100644
--- a/apps/launcher/images.json
+++ b/apps/launcher/images.json
@@ -98,6 +98,12 @@
"covers/refl-im-main.png": {
"linear": true
},
+ "covers/cover-touch-lab-main.png": {
+ "linear": true
+ },
+ "covers/refl-touch-lab-main.png": {
+ "linear": true
+ },
"covers/cover-vue-sfc-lab-main.png": {
"linear": true
},
diff --git a/apps/launcher/registry.generated.ts b/apps/launcher/registry.generated.ts
index dc21c7aa..660c7824 100644
--- a/apps/launcher/registry.generated.ts
+++ b/apps/launcher/registry.generated.ts
@@ -33,6 +33,7 @@ export const REGISTRY: readonly RegistryApp[] = [
{ output: "music-main", id: "dev.pocket-stack.music", title: "PocketJS: Now Playing", cover: "covers/cover-music-main.png", refl: "covers/refl-music-main.png" },
{ output: "settings-main", id: "dev.pocket-stack.settings", title: "PocketJS: Settings", cover: "covers/cover-settings-main.png", refl: "covers/refl-settings-main.png" },
{ output: "im-main", id: "dev.pocket-stack.im", title: "PocketJS: Talk", cover: "covers/cover-im-main.png", refl: "covers/refl-im-main.png" },
+ { output: "touch-lab-main", id: "dev.pocket-stack.touch.lab", title: "PocketJS: Touch Events Lab", cover: "covers/cover-touch-lab-main.png", refl: "covers/refl-touch-lab-main.png" },
{ output: "vue-sfc-lab-main", id: "dev.pocket-stack.vue.sfc.lab", title: "PocketJS: Vue SFC Feature Lab", cover: "covers/cover-vue-sfc-lab-main.png", refl: "covers/refl-vue-sfc-lab-main.png" },
{ output: "zoomlab-main", id: "dev.pocket-stack.zoomlab", title: "Zoom Lab", cover: "covers/cover-zoomlab-main.png", refl: "covers/refl-zoomlab-main.png" },
] as const;
diff --git a/apps/touch-lab/app.vue b/apps/touch-lab/app.vue
new file mode 100644
index 00000000..9dc0641f
--- /dev/null
+++ b/apps/touch-lab/app.vue
@@ -0,0 +1,108 @@
+
+
+
+
+ touch-events smoke: taps={{ tapCount }} drag={{ dragPos }} bubble={{ bubbleOrder }}
+
+
+ push('cancel')"
+ >
+ TAP PAD
+
+
+
+ DRAG PAD
+
+
+
+
+ CHILD
+
+
+
+
+
+ {{ line }}
+
+
+
diff --git a/apps/touch-lab/main.ts b/apps/touch-lab/main.ts
new file mode 100644
index 00000000..6b59babe
--- /dev/null
+++ b/apps/touch-lab/main.ts
@@ -0,0 +1,4 @@
+import { mount } from "@pocketjs/framework/vue-vapor";
+import TouchLab from "./app.vue";
+
+mount(TouchLab);
diff --git a/apps/touch-lab/pocket.json b/apps/touch-lab/pocket.json
new file mode 100644
index 00000000..48553277
--- /dev/null
+++ b/apps/touch-lab/pocket.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://pocketjs.dev/schema/pocket-2.json",
+ "pocket": 2,
+ "id": "dev.pocket-stack.touch.lab",
+ "name": "pocketjs-touch-lab",
+ "title": "PocketJS: Touch Events Lab",
+ "version": "0.6.0",
+ "engine": {
+ "capabilities": {
+ "requires": ["text.glyphs.baked"]
+ }
+ },
+ "app": {
+ "entry": "apps/touch-lab/main.ts",
+ "output": "touch-lab-main",
+ "framework": "vue-vapor",
+ "viewport": {
+ "fixed": {
+ "logical": [480, 272],
+ "presentation": "integer-fit"
+ }
+ }
+ }
+}
diff --git a/framework/src/components-vue-vapor.ts b/framework/src/components-vue-vapor.ts
index a307e163..a2b2568c 100644
--- a/framework/src/components-vue-vapor.ts
+++ b/framework/src/components-vue-vapor.ts
@@ -25,6 +25,8 @@ import {
type NodeMirror,
} from "./native-tree.ts";
import { createRenderRoot, type RenderRoot } from "./renderer-vue-vapor.ts";
+import type { TouchHandler } from "./touch-events.ts";
+import type { PocketTouchEvent } from "./input-api.ts";
export type { NodeMirror } from "./renderer-vue-vapor.ts";
@@ -33,6 +35,16 @@ type NodeRef = ((node: NodeMirror | null) => void) | { current: NodeMirror | nul
type SlotFn = (...args: unknown[]) => unknown;
type SlotBag = Record | SlotFn | undefined;
+/** W3C-subset touch handlers shared by every primitive (framework/src/touch-events.ts).
+ * Vue templates bind them as @touchstart / @touchmove / @touchend / @touchcancel;
+ * JSX spells them onTouchstart etc. Both land in native-tree's touch registry. */
+export interface TouchHandlers {
+ onTouchstart?: (ev: PocketTouchEvent) => void;
+ onTouchmove?: (ev: PocketTouchEvent) => void;
+ onTouchend?: (ev: PocketTouchEvent) => void;
+ onTouchcancel?: (ev: PocketTouchEvent) => void;
+}
+
export type VNodeChild = SolidJSX.Element | (() => SolidJSX.Element);
const NO_FALLTHROUGH = { inheritAttrs: false } as const;
type VaporCtx = { slots: SlotBag; attrs: Record };
@@ -46,7 +58,7 @@ const insertVaporBlock = vaporInsert as unknown as (
parent: NodeMirror,
anchor?: NodeMirror | null,
) => void;
-export interface ViewProps {
+export interface ViewProps extends TouchHandlers {
class?: string;
className?: string;
style?: StyleObject;
@@ -56,7 +68,7 @@ export interface ViewProps {
children?: VNodeChild;
}
-export interface TextProps {
+export interface TextProps extends TouchHandlers {
class?: string;
className?: string;
style?: StyleObject;
@@ -64,7 +76,7 @@ export interface TextProps {
children?: VNodeChild;
}
-export interface ImageProps {
+export interface ImageProps extends TouchHandlers {
class?: string;
className?: string;
src?: string;
@@ -72,7 +84,7 @@ export interface ImageProps {
nodeRef?: NodeRef;
}
-export interface SpriteProps {
+export interface SpriteProps extends TouchHandlers {
class?: string;
className?: string;
sprite?: string;
@@ -206,6 +218,15 @@ function cleanProps(props: Record, omit: Set): HostProp
if (key === "class" || key === "className") out[key] = normalizeClassValue(rawValue);
else if (key === "style") out[key] = normalizeStyleValue(rawValue);
else if (key === "onPress" || key === "on:press") out[key] = callbackOf<() => void>(rawValue);
+ // Touch handlers are event callbacks, not value getters: wrap with
+ // callbackOf so Vue's invoke-for-value semantics never call them with
+ // zero arguments (which would crash on ev.changedTouches).
+ else if (
+ key === "onTouchstart" || key === "on:touchstart" ||
+ key === "onTouchmove" || key === "on:touchmove" ||
+ key === "onTouchend" || key === "on:touchend" ||
+ key === "onTouchcancel" || key === "on:touchcancel"
+ ) out[key] = callbackOf(rawValue);
// Primitive host components intentionally keep props in attrs instead of
// declaring runtime prop tables. Vue therefore leaves a bare boolean
// attribute as ""; on native components it still means true.
@@ -265,9 +286,16 @@ function createPrimitiveNode(
return node;
}
+function primitive(tag: "view"): (props: ViewProps) => SolidJSX.Element;
+function primitive(tag: "text"): (props: TextProps) => SolidJSX.Element;
+function primitive(tag: "image"): (props: ImageProps) => SolidJSX.Element;
function primitive(tag: "view" | "text" | "image") {
+ // The implementation reads attrs (untyped fallthrough bag); the overloads
+ // above pin each tag's PUBLIC props contract. Runtime behavior is identical
+ // for all three tags — only the type surface differs.
return definePocketVaporComponent(
- (_props: Record, { attrs, slots }: VaporCtx) => createPrimitiveNode(tag, attrs, slots),
+ (_props: ViewProps | TextProps | ImageProps, { attrs, slots }: VaporCtx) =>
+ createPrimitiveNode(tag, attrs, slots),
NO_FALLTHROUGH,
);
}
@@ -275,7 +303,11 @@ function primitive(tag: "view" | "text" | "image") {
export const View = primitive("view");
export const Text = primitive("text");
export const Image = primitive("image");
-export const Sprite = primitive("image");
+export const Sprite = definePocketVaporComponent(
+ (_props: SpriteProps, { attrs, slots }: VaporCtx) =>
+ createPrimitiveNode("image", attrs, slots),
+ NO_FALLTHROUGH,
+);
function resolveActive(active: unknown): boolean {
const resolved = valueOf(active);
diff --git a/framework/src/devtools.ts b/framework/src/devtools.ts
index 84f483ca..40195b4a 100644
--- a/framework/src/devtools.ts
+++ b/framework/src/devtools.ts
@@ -159,9 +159,9 @@ export function initDevtools(ops: HostOps): void {
/** Wrap the composed frame handler (render()'s input+hooks+sweep closure). */
export function wrapFrameHandler(
- h: (buttons: number, analog: number, touches?: readonly number[]) => void,
-): (buttons: number, analog?: number, touches?: readonly number[]) => void {
- return (buttons: number, analogArg?: number, touchArg?: readonly number[]) => {
+ h: (buttons: number, analog: number, touches?: readonly number[], touchEvents?: readonly number[]) => void,
+): (buttons: number, analog?: number, touches?: readonly number[], touchEvents?: readonly number[]) => void {
+ return (buttons: number, analogArg?: number, touchArg?: readonly number[], touchEventArg?: readonly number[]) => {
state.hostCalls++;
if (state.transport) {
pollTransport();
@@ -170,6 +170,7 @@ export function wrapFrameHandler(
let mask = buttons;
let analog = analogArg === undefined ? ANALOG_CENTER : analogArg & 0xffff;
let touch = touchArg;
+ let touchEvents = touchEventArg;
if (state.replayMasks) {
if (state.replayAt < state.replayMasks.length) {
mask = state.replayMasks[state.replayAt];
@@ -177,6 +178,7 @@ export function wrapFrameHandler(
// A replay that predates touch input must not leak live hardware state
// into the deterministic tape.
touch = undefined;
+ touchEvents = undefined;
state.replayAt++;
} else {
state.replayMasks = null; // tape exhausted: back to live input
@@ -192,7 +194,7 @@ export function wrapFrameHandler(
recordMask(mask, analog);
state.frame++;
try {
- h(mask, analog, touch);
+ h(mask, analog, touch, touchEvents);
} catch (e) {
send({
t: "error",
diff --git a/framework/src/index-vue-vapor.ts b/framework/src/index-vue-vapor.ts
index 81a5590f..167d77e6 100644
--- a/framework/src/index-vue-vapor.ts
+++ b/framework/src/index-vue-vapor.ts
@@ -30,6 +30,7 @@ import { registerStyles, resolveStyle } from "./styles.ts";
import { handleFrame, setInputRoot } from "./input.ts";
import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame-vue-vapor.ts";
import { __resetTouches, __setTouches } from "./touch.ts";
+import { handleTouchSamples, resetTouchEvents, setTouchHitRootProvider } from "./touch-events.ts";
import { __advanceClock, resetClock } from "./clock.ts";
import { __drainEffects, resetEffects } from "./effects.ts";
import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts";
@@ -196,15 +197,19 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v
overlayLayer = overlayRoot;
setInputRoot(appRoot);
+ setTouchHitRootProvider(() => rootMirror); // touch events see app + overlay
resetFrameHooks();
resetClock(); // clock policy + effect shell (docs/DETERMINISM.md), same as Solid
resetEffects();
initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md), same as the Solid path.
installFrameHandler(
- wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[]) => {
+ wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[], touchEvents?: readonly number[]) => {
__advanceClock();
__setAnalog(analog);
__setTouches(touches);
+ // ESP32 QuickJS delivers samples via __tev (its frame wire is 1-arg);
+ // every other host passes them as the 4th frame argument.
+ handleTouchSamples(touchEvents ?? (globalThis as { __tev?: readonly number[] }).__tev);
__drainEffects();
runFrameHooks(buttons);
handleFrame(buttons);
@@ -217,8 +222,10 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v
return () => {
removeResizeViewportHook();
__resetTouches();
+ resetTouchEvents(); // drop capture table (native nodes die with the tree)
dispose();
setInputRoot(null);
+ setTouchHitRootProvider(null);
setOverlayRoot(null);
appLayer = null;
overlayLayer = null;
diff --git a/framework/src/index.ts b/framework/src/index.ts
index fe86fb5b..86ac4d80 100644
--- a/framework/src/index.ts
+++ b/framework/src/index.ts
@@ -43,6 +43,7 @@ import { registerStyles, resolveStyle } from "./styles.ts";
import { handleFrame, setHitRoot, setInputRoot } from "./input.ts";
import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame.ts";
import { __resetTouches, __setTouches } from "./touch.ts";
+import { handleTouchSamples, resetTouchEvents, setTouchHitRootProvider } from "./touch-events.ts";
import { __advanceClock, resetClock } from "./clock.ts";
import { __drainEffects, resetEffects } from "./effects.ts";
import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts";
@@ -249,16 +250,18 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi
setInputRoot(appRoot);
setHitRoot(rootMirror); // hit tests see the overlay layer too
+ setTouchHitRootProvider(() => rootMirror); // touch events resolve against the same tree
resetFrameHooks();
resetClock(); // latches the host's __simHz clock policy (docs/DETERMINISM.md)
resetEffects();
initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md): flight recorder +
// debug channel; one branch per frame when no transport is connected.
installFrameHandler(
- wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[]) => {
+ wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[], touchEvents?: readonly number[]) => {
__advanceClock(); // virtual frame++, fire due after() timers
__setAnalog(analog); // latch the nub before any app code reads it
__setTouches(touches); // latch logical front-panel contacts for this frame
+ handleTouchSamples(touchEvents); // browser-aligned dispatch (W3C subset)
__drainEffects(); // frame-boundary deliveries enter the world first
runFrameHooks(buttons); // app lifecycle callbacks: onFrame/onButtonPress/etc.
handleFrame(buttons); // edge-detect, focus nav, onPress (runs effects)
@@ -271,9 +274,11 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi
return () => {
removeResizeViewportHook();
__resetTouches();
+ resetTouchEvents(); // drop capture table (native nodes die with the tree)
dispose(); // tears down reactivity only — universal keeps the nodes
setInputRoot(null); // drops focus state (native focus dies with the nodes)
setHitRoot(null);
+ setTouchHitRootProvider(null);
setOverlayRoot(null);
appLayer = null;
overlayLayer = null;
diff --git a/framework/src/input-api.ts b/framework/src/input-api.ts
index 8ac4ae8c..2bd9103b 100644
--- a/framework/src/input-api.ts
+++ b/framework/src/input-api.ts
@@ -2,6 +2,7 @@
export { BTN } from "../../contracts/spec/spec.ts";
export { touches, type TouchContact } from "./touch.ts";
+export type { PocketTouch, PocketTouchEvent, TouchEventType } from "./touch-events.ts";
export {
cursorX,
cursorY,
diff --git a/framework/src/native-tree.ts b/framework/src/native-tree.ts
index 175cce74..093bf802 100644
--- a/framework/src/native-tree.ts
+++ b/framework/src/native-tree.ts
@@ -4,6 +4,7 @@
import { NODE_TYPE, PROP, ROOT_ID, STYLE_ID_NONE, type PropName } from "../../contracts/spec/spec.ts";
import { encodePropValue, getHost, getOps } from "./host.ts";
import { __notifyTreeMutation, notifyDetached, registerFocusable, registerPress } from "./input.ts";
+import { registerTouchHandler, touchPhaseForProp, type TouchHandler } from "./touch-events.ts";
export interface NodeMirror {
/** Native generation-tagged node id. */
@@ -27,8 +28,7 @@ export interface NodeMirror {
/** CIRCLE handler while focused (input.ts). */
onPress?: (() => void) | undefined;
/** DevTools semantic name (`debugName` prop / wrapper). */
- debugName?: string;
-}
+ debugName?: string;}
// DevTools (docs/DEVTOOLS.md): one nullable hook, pinged on any structural or
// content mutation of the mirror tree so the shim can re-snapshot lazily.
@@ -74,6 +74,14 @@ const NATIVE_ATTRIBUTE_NAMES = new Set([
"src",
"onPress",
"on:press",
+ "onTouchstart",
+ "on:touchstart",
+ "onTouchmove",
+ "on:touchmove",
+ "onTouchend",
+ "on:touchend",
+ "onTouchcancel",
+ "on:touchcancel",
"focusable",
"debugName",
"ref",
@@ -601,6 +609,11 @@ export function setProp(node: NodeMirror, name: string, value: T, prev?: T):
if (value == null) delete domAttrs(node)[name];
else domAttrs(node)[name] = value;
}
+ const touchPhase = touchPhaseForProp(name);
+ if (touchPhase !== -1) {
+ registerTouchHandler(node, touchPhase, value as TouchHandler | undefined);
+ return value;
+ }
switch (name) {
case "class":
setClass(node, value);
diff --git a/framework/src/prelude.ts b/framework/src/prelude.ts
index 315aab36..c930d63b 100644
--- a/framework/src/prelude.ts
+++ b/framework/src/prelude.ts
@@ -24,11 +24,16 @@ if (typeof (globalThis as { clearTimeout?: unknown }).clearTimeout !== "function
}
if (typeof (globalThis as { console?: unknown }).console !== "object") {
- (globalThis as unknown as { console?: Record void> }).console = {
- log() {},
- warn() {},
- error() {},
- };
+ (globalThis as unknown as { console?: Record void> }).console = {};
+}
+{
+ // QuickJS's js_std_add_helpers ships console.log only; Vue's error
+ // handler calls console.error unconditionally. Fill any missing level,
+ // defaulting to the host's log (or a no-op) so console.* never throws.
+ const c = (globalThis as unknown as { console: Record void) | undefined> }).console;
+ for (const level of ["log", "warn", "error"] as const) {
+ if (typeof c[level] !== "function") c[level] = c.log ?? (() => {});
+ }
}
installVueVaporDom();
diff --git a/framework/src/primitives.ts b/framework/src/primitives.ts
index 7a064b5d..12601f1d 100644
--- a/framework/src/primitives.ts
+++ b/framework/src/primitives.ts
@@ -8,6 +8,7 @@
import type { JSX as SolidJSX } from "solid-js";
import { createElement, spread } from "./renderer.ts";
import type { NodeMirror } from "./renderer.ts";
+import type { TouchHandler } from "./touch-events.ts";
type StyleObject = Record;
type RefProp =
@@ -16,7 +17,17 @@ type RefProp =
| NodeMirror
| undefined;
-export interface ViewProps {
+/** W3C-subset touch handlers shared by every primitive (framework/src/touch-events.ts).
+ * JSX spells them onTouchstart etc.; Vue templates bind @touchstart. Both land in
+ * native-tree's touch registry via setProperty. */
+export interface TouchHandlers {
+ onTouchstart?: TouchHandler;
+ onTouchmove?: TouchHandler;
+ onTouchend?: TouchHandler;
+ onTouchcancel?: TouchHandler;
+}
+
+export interface ViewProps extends TouchHandlers {
class?: string;
style?: StyleObject;
onPress?: () => void;
@@ -28,7 +39,7 @@ export interface ViewProps {
children?: SolidJSX.Element;
}
-export interface TextProps {
+export interface TextProps extends TouchHandlers {
class?: string;
style?: StyleObject;
/** DevTools semantic name shown in the component tree (docs/DEVTOOLS.md). */
@@ -38,7 +49,7 @@ export interface TextProps {
children?: SolidJSX.Element;
}
-export interface ImageProps {
+export interface ImageProps extends TouchHandlers {
class?: string;
src?: string;
style?: StyleObject;
@@ -54,7 +65,7 @@ function callRef(ref: RefProp, node: NodeMirror): void {
else if ("current" in ref) ref.current = node;
}
-export interface SpriteProps {
+export interface SpriteProps extends TouchHandlers {
class?: string;
/** DevTools semantic name shown in the component tree (docs/DEVTOOLS.md). */
debugName?: string;
diff --git a/framework/src/touch-events.ts b/framework/src/touch-events.ts
new file mode 100644
index 00000000..2df53af2
--- /dev/null
+++ b/framework/src/touch-events.ts
@@ -0,0 +1,370 @@
+// Touch events: browser-aligned dispatch over the native mirror tree.
+//
+// Pipeline (see docs/prd-touch-events.md in the ESP32 port repo):
+// hardware interrupt -> host sample task -> ring buffer -> ONE packed u32
+// event stream per frame -> this module -> W3C Touch Events subset.
+//
+// Key properties, by design:
+// - SAMPLE-LEVEL dispatch: every drained sample is its own event, so a
+// sub-frame down+up can never be lost (the old per-frame snapshot model
+// in touch.ts lost exactly these).
+// - ONE hit test per gesture, at touchstart (spec op 27). move/end walk
+// the implicit-capture table instead — active dragging costs no FFI.
+// - W3C implicit target capture (§5.2): the node hit at touchstart owns
+// the whole sequence, even when the contact slides off it.
+// - Bubble phase only, with stopPropagation. No capture phase, no passive
+// listeners — both are additive later; see the PRD grill answers.
+// - Zero touch = zero cost: no drained events -> no FFI, no allocation.
+// - Deterministic: events carry hardware sample times as RELATIVE dticks;
+// timeStamp is reconstructed from the virtual clock, so input tapes
+// replay byte-identically at every simulationHz.
+
+import { virtualNow } from "./clock.ts";
+import { getOps } from "./host.ts";
+import type { NodeMirror } from "./native-tree.ts";
+
+// ---- wire format (host -> framework), one u32 per event ----------------------
+//
+// bits [1:0] phase: 0=start, 1=move, 2=end, 3=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, clamped at 63
+// bits [31:28] contact identifier (0..15)
+//
+// Single-touch controllers (ESP32-S3's CST9217) always report identifier 0,
+// which makes per-contact dticks identical to a shared timeline — fully
+// backward compatible with a host that predates the id field.
+
+export const enum TouchPhase {
+ Start = 0,
+ Move = 1,
+ End = 2,
+ Cancel = 3,
+}
+
+export interface TouchSample {
+ phase: TouchPhase;
+ x: number;
+ y: number;
+ /** Milliseconds since this contact's previous sample (clamped to 63). */
+ dticks: number;
+ /** Contact identifier (0..15); 0 on single-touch hardware. */
+ id: number;
+}
+
+export function decodeTouchSample(packed: number): TouchSample {
+ return {
+ phase: (packed & 0x3) as TouchPhase,
+ x: (packed >>> 2) & 0x3ff,
+ y: (packed >>> 12) & 0x3ff,
+ dticks: (packed >>> 22) & 0x3f,
+ id: (packed >>> 28) & 0xf,
+ };
+}
+
+/** Test/capture helper matching the wire format. */
+export function __packTouchSample(
+ phase: TouchPhase,
+ x: number,
+ y: number,
+ dticks = 0,
+ id = 0,
+): number {
+ return (
+ (((id & 0xf) << 28) |
+ ((Math.min(dticks, 63) & 0x3f) << 22) |
+ ((y & 0x3ff) << 12) |
+ ((x & 0x3ff) << 2) |
+ (phase & 0x3)) >>>
+ 0
+ );
+}
+
+// ---- event object (W3C Touch Events subset) -----------------------------------
+
+export interface PocketTouch {
+ /** Stable for the contact's lifetime; 0 on single-touch hardware. */
+ readonly identifier: number;
+ /** Logical viewport coordinates. */
+ readonly clientX: number;
+ readonly clientY: number;
+ /** No windowing on PocketJS targets: aliased to clientX/Y. */
+ readonly screenX: number;
+ readonly screenY: number;
+ /** Implicit capture target (the node hit at touchstart). */
+ readonly target: NodeMirror;
+}
+
+export type TouchEventType = "touchstart" | "touchmove" | "touchend" | "touchcancel";
+export type TouchHandler = (ev: PocketTouchEvent) => void;
+
+export interface PocketTouchEvent {
+ readonly type: TouchEventType;
+ /** Contacts still active at dispatch time (W3C §5.3). */
+ readonly touches: readonly PocketTouch[];
+ /** Active contacts whose target equals currentTarget, per bubble step. */
+ readonly targetTouches: readonly PocketTouch[];
+ /** Contacts that changed in this event. */
+ readonly changedTouches: readonly PocketTouch[];
+ /** ms of virtual time at the SAMPLE moment (hardware capture time). */
+ readonly timeStamp: number;
+ /** The implicit-capture target; constant through the bubble walk. */
+ readonly target: NodeMirror;
+ /** The node currently being invoked; walks target -> ... -> root. */
+ readonly currentTarget: NodeMirror | null;
+ readonly altKey: false;
+ readonly ctrlKey: false;
+ readonly metaKey: false;
+ readonly shiftKey: false;
+ defaultPrevented: boolean;
+ preventDefault(): void;
+ stopPropagation(): void;
+ /** Internal: set by stopPropagation; read by the bubble loop. */
+ readonly __stopped: boolean;
+}
+
+// ---- handler registry (native-tree setProperty dispatch target) ----------------
+
+const PHASE_TO_TYPE: Record = {
+ [TouchPhase.Start]: "touchstart",
+ [TouchPhase.Move]: "touchmove",
+ [TouchPhase.End]: "touchend",
+ [TouchPhase.Cancel]: "touchcancel",
+};
+
+const PROP_TO_PHASE: Record = {
+ onTouchstart: TouchPhase.Start,
+ "on:touchstart": TouchPhase.Start,
+ onTouchmove: TouchPhase.Move,
+ "on:touchmove": TouchPhase.Move,
+ onTouchend: TouchPhase.End,
+ "on:touchend": TouchPhase.End,
+ onTouchcancel: TouchPhase.Cancel,
+ "on:touchcancel": TouchPhase.Cancel,
+};
+
+/** Mirror-side handler slots, indexed by TouchPhase. */
+type HandlerSlots = (TouchHandler | undefined)[];
+
+const slots = new WeakMap();
+
+/** Returns the TouchPhase for a touch prop name, or -1 for non-touch props. */
+export function touchPhaseForProp(name: string): TouchPhase | -1 {
+ const phase = PROP_TO_PHASE[name];
+ return phase === undefined ? -1 : (phase as TouchPhase);
+}
+
+export function registerTouchHandler(
+ node: NodeMirror,
+ phase: TouchPhase,
+ fn: TouchHandler | undefined | null,
+): void {
+ let s = slots.get(node);
+ if (!s) {
+ s = [undefined, undefined, undefined, undefined];
+ slots.set(node, s);
+ }
+ s[phase] = fn ?? undefined;
+}
+
+function handlerFor(node: NodeMirror, phase: TouchPhase): TouchHandler | undefined {
+ return slots.get(node)?.[phase];
+}
+
+// ---- dispatch state --------------------------------------------------------------
+
+interface ActiveContact extends PocketTouch {
+ /** Current position (mutated by move samples; target stays fixed). */
+ clientX: number;
+ clientY: number;
+ screenX: number;
+ screenY: number;
+}
+
+/** Active contacts keyed by identifier: one entry per simultaneous touch
+ * point. Single-touch hardware only ever uses key 0. */
+const active = new Map();
+
+/** The mirror subtree root for hit resolution (app + overlay layers), set
+ * by render() alongside setInputRoot. Null falls back to input.ts's root. */
+let hitRootProvider: (() => NodeMirror | null) | null = null;
+
+/** render() injects the hit root getter (index*.ts knows app+overlay). */
+export function setTouchHitRootProvider(fn: (() => NodeMirror | null) | null): void {
+ hitRootProvider = fn;
+}
+
+/** Tests / unmount: drop all touch state without touching host ops. */
+export function resetTouchEvents(): void {
+ active.clear();
+}
+
+// findMirror walks the mirror tree by native id. Duplicated from input.ts's
+// private helper deliberately: input.ts's copy resolves the FOCUS root,
+// this one resolves the HIT root, and neither should import the other's
+// wiring at module scope.
+function findMirror(node: NodeMirror | null, id: number): NodeMirror | null {
+ if (!node || id === 0) return null;
+ if (node.id === id) return node;
+ const kids = node.children;
+ if (!Array.isArray(kids)) return null;
+ for (let i = 0; i < kids.length; i++) {
+ const found = findMirror(kids[i], id);
+ if (found) return found;
+ }
+ return null;
+}
+
+// ---- per-sample dispatch -----------------------------------------------------------
+
+function makeTouchEvent(
+ type: TouchEventType,
+ target: NodeMirror,
+ changed: PocketTouch[],
+ timeStamp: number,
+): PocketTouchEvent {
+ let stopped = false;
+ const ev: PocketTouchEvent = {
+ type,
+ touches: Object.freeze([...active.values()]),
+ targetTouches: [], // recomputed per bubble step below
+ changedTouches: Object.freeze(changed),
+ timeStamp,
+ target,
+ currentTarget: null,
+ altKey: false,
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ defaultPrevented: false,
+ preventDefault() {
+ ev.defaultPrevented = true;
+ },
+ stopPropagation() {
+ stopped = true;
+ },
+ get __stopped() {
+ return stopped;
+ },
+ } as PocketTouchEvent;
+ return ev;
+}
+
+function dispatch(phase: TouchPhase, contact: ActiveContact, timeStamp: number): void {
+ const type = PHASE_TO_TYPE[phase];
+ const changed: PocketTouch[] = [
+ {
+ identifier: contact.identifier,
+ clientX: contact.clientX,
+ clientY: contact.clientY,
+ screenX: contact.screenX,
+ screenY: contact.screenY,
+ target: contact.target,
+ },
+ ];
+ const ev = makeTouchEvent(type, contact.target, changed, timeStamp);
+
+ let n: NodeMirror | null = contact.target;
+ while (n) {
+ const h = handlerFor(n, phase);
+ if (h) {
+ (ev as { currentTarget: NodeMirror | null }).currentTarget = n;
+ (ev as { targetTouches: readonly PocketTouch[] }).targetTouches = Object.freeze(
+ [...active.values()].filter((c) => c.target === n),
+ );
+ h(ev);
+ }
+ if (ev.__stopped) break;
+ n = n.parent;
+ }
+ (ev as { currentTarget: NodeMirror | null }).currentTarget = null;
+}
+
+/** One host-drained frame of touch samples. Called once per frame from the
+ * frame handler, BEFORE the renderer sweep, with the frame's virtual end
+ * time. Samples are dispatched in arrival order; each sample's timeStamp
+ * walks backwards from frameEndMs by its own dtick. */
+export function handleTouchSamples(
+ packed: readonly number[] | undefined,
+ _frameEndMs?: number,
+): void {
+ if (!packed || packed.length === 0) return;
+ const frameEndMs = _frameEndMs ?? virtualNow() * 1000;
+
+ // Precompute absolute sample times. dticks is PER CONTACT: sample i of
+ // contact c happened at frameEndMs minus the sum of dticks of all later
+ // samples of contact c. Walk the frame backwards keeping one running
+ // offset per contact id, so interleaved contacts each reconstruct their
+ // own timeline. Single-contact streams (id always 0) reduce to a shared
+ // timeline — identical to the pre-id behaviour.
+ const times = new Array(packed.length);
+ const backByContact = new Map();
+ for (let i = packed.length - 1; i >= 0; i--) {
+ const id = (packed[i] >>> 28) & 0xf;
+ const back = (backByContact.get(id) ?? 0) + ((packed[i] >>> 22) & 0x3f);
+ backByContact.set(id, back);
+ times[i] = frameEndMs - back;
+ }
+
+ for (let i = 0; i < packed.length; i++) {
+ const s = decodeTouchSample(packed[i]);
+ const t = times[i];
+ switch (s.phase) {
+ case TouchPhase.Start: {
+ if (active.has(s.id)) break; // this contact is already down: host glitch
+ const ops = getOps();
+ const root = hitRootProvider?.() ?? null;
+ let target: NodeMirror | null = null;
+ // Hosts that pre-compute hitTest deliver it via __tevHit (ESP32:
+ // one Rust tree walk in C, no QuickJS bridge, and no need for a
+ // ui.hitTest binding at all). Otherwise use spec op 27 from JS.
+ const hitArr = (globalThis as { __tevHit?: ArrayLike }).__tevHit;
+ if (hitArr) {
+ target = findMirror(root, hitArr[i] | 0) || root;
+ } else if (ops.hitTest) {
+ target = findMirror(root, ops.hitTest(s.x, s.y)) || root;
+ } else {
+ target = root;
+ }
+ if (!target) break;
+ const contact: ActiveContact = {
+ identifier: s.id,
+ clientX: s.x,
+ clientY: s.y,
+ screenX: s.x,
+ screenY: s.y,
+ target,
+ };
+ active.set(s.id, contact);
+ dispatch(TouchPhase.Start, contact, t);
+ break;
+ }
+ case TouchPhase.Move: {
+ const contact = active.get(s.id);
+ if (!contact) break; // move without a start on this host: ignore
+ // W3C: no touchmove without movement (integer logical coords).
+ if (contact.clientX === s.x && contact.clientY === s.y) break;
+ contact.clientX = s.x;
+ contact.clientY = s.y;
+ contact.screenX = s.x;
+ contact.screenY = s.y;
+ dispatch(TouchPhase.Move, contact, t);
+ break;
+ }
+ case TouchPhase.End:
+ case TouchPhase.Cancel: {
+ const contact = active.get(s.id);
+ if (!contact) break;
+ contact.clientX = s.x;
+ contact.clientY = s.y;
+ contact.screenX = s.x;
+ contact.screenY = s.y;
+ // W3C §5.3: at touchend, `touches` no longer contains the released
+ // contact, `changedTouches` does. Remove BEFORE dispatch.
+ active.delete(s.id);
+ dispatch(s.phase, contact, t);
+ break;
+ }
+ }
+ }
+}
diff --git a/framework/src/touch.ts b/framework/src/touch.ts
index 0c253efa..68b78375 100644
--- a/framework/src/touch.ts
+++ b/framework/src/touch.ts
@@ -22,13 +22,7 @@ const EMPTY: readonly TouchContact[] = Object.freeze([]);
let snapshot: readonly TouchContact[] = EMPTY;
-/**
- * Internal host-frame hook.
- *
- * Existing hosts pack x:9, y:9, id:8 with bit 31 clear. Native viewports
- * wider than 512 use the append-only wide form: bit31=1, x:10, y:10, id:8.
- * Per-contact detection keeps every PSP/Vita tape and host byte-compatible.
- */
+/** Internal host-frame hook. Each u32 packs x:10, y:10, id:8. */
export function __setTouches(packed: readonly number[] | undefined): void {
if (!packed || packed.length === 0) {
snapshot = EMPTY;
diff --git a/hosts/sim/sim.ts b/hosts/sim/sim.ts
index 5a1a716c..9b67707b 100644
--- a/hosts/sim/sim.ts
+++ b/hosts/sim/sim.ts
@@ -209,7 +209,7 @@ export async function bootWorld(
return {
frame,
tick: wasm.tick,
- render: () => wasm.renderScaled(renderScale),
+ render: () => renderScale === 1 ? wasm.render() : wasm.renderScaled(renderScale),
ticksPerFrame: TICKS_PER_SECOND / hz,
hz,
effects,
diff --git a/hosts/vita/src/input/touch.rs b/hosts/vita/src/input/touch.rs
index 06d47220..4ec3c93e 100644
--- a/hosts/vita/src/input/touch.rs
+++ b/hosts/vita/src/input/touch.rs
@@ -48,9 +48,9 @@ impl TouchPanel {
}
pub fn pack(self, report: SceTouchReport, logical_w: i32, logical_h: i32) -> u32 {
- let x = Self::logical_axis(report.x, self.min_x, self.max_x, logical_w) & 0x1ff;
- let y = Self::logical_axis(report.y, self.min_y, self.max_y, logical_h) & 0x1ff;
- ((report.id as u32) << 18) | (y << 9) | x
+ let x = Self::logical_axis(report.x, self.min_x, self.max_x, logical_w) & 0x3ff;
+ let y = Self::logical_axis(report.y, self.min_y, self.max_y, logical_h) & 0x3ff;
+ ((report.id as u32) << 20) | (y << 10) | x
}
}
@@ -88,9 +88,9 @@ impl TouchSnapshot {
let max_x = (crate::graphics::LOGICAL_W - 1).max(0) as u32;
let max_y = (crate::graphics::LOGICAL_H - 1).max(0) as u32;
for (index, (id, x, y)) in contacts.iter().take(snapshot.len).enumerate() {
- let x = (*x as u32).min(max_x) & 0x1ff;
- let y = (*y as u32).min(max_y) & 0x1ff;
- snapshot.packed[index] = ((*id as u32) << 18) | (y << 9) | x;
+ let x = (*x as u32).min(max_x) & 0x3ff;
+ let y = (*y as u32).min(max_y) & 0x3ff;
+ snapshot.packed[index] = ((*id as u32) << 20) | (y << 10) | x;
}
snapshot
}
@@ -127,20 +127,20 @@ mod tests {
let panel = TouchPanel::from_info(panel_info());
let top_left = panel.pack(report(7, 0, 0), 480, 272);
let bottom_right = panel.pack(report(9, 1919, 1087), 480, 272);
- assert_eq!(top_left & 0x1ff, 0);
- assert_eq!((top_left >> 9) & 0x1ff, 0);
- assert_eq!(top_left >> 18, 7);
- assert_eq!(bottom_right & 0x1ff, 479);
- assert_eq!((bottom_right >> 9) & 0x1ff, 271);
- assert_eq!(bottom_right >> 18, 9);
+ assert_eq!(top_left & 0x3ff, 0);
+ assert_eq!((top_left >> 10) & 0x3ff, 0);
+ assert_eq!(top_left >> 20, 7);
+ assert_eq!(bottom_right & 0x3ff, 479);
+ assert_eq!((bottom_right >> 10) & 0x3ff, 271);
+ assert_eq!(bottom_right >> 20, 9);
}
#[test]
fn clamps_reports_outside_the_display_area() {
let panel = TouchPanel::from_info(panel_info());
let packed = panel.pack(report(1, -200, 2000), 480, 272);
- assert_eq!(packed & 0x1ff, 0);
- assert_eq!((packed >> 9) & 0x1ff, 271);
+ assert_eq!(packed & 0x3ff, 0);
+ assert_eq!((packed >> 10) & 0x3ff, 271);
}
#[test]
@@ -148,7 +148,7 @@ mod tests {
let snapshot = super::TouchSnapshot::from_logical(&[(4, 120, 80), (9, 900, 900)]);
assert_eq!(
snapshot.packed(),
- &[4 << 18 | 80 << 9 | 120, 9 << 18 | 271 << 9 | 479]
+ &[4 << 20 | 80 << 10 | 120, 9 << 20 | 271 << 10 | 479]
);
}
}
diff --git a/hosts/web/engine.js b/hosts/web/engine.js
index 4c0559bb..a5b7af4d 100644
--- a/hosts/web/engine.js
+++ b/hosts/web/engine.js
@@ -102,6 +102,19 @@ let rafId = 0;
let acc = 0;
let last = 0;
let frameCb = null;
+// Browser-aligned touch: canvas pointer events are QUEUED as timestamped
+// samples and drained at the frame boundary (framework/src/touch-events.ts
+// wire format). This replaces the per-frame snapshot + deferred release
+// workaround — sub-frame taps now dispatch both events.
+let touchSamples = []; // [{phase, x, y, id, dt}] drained by safeFrame
+const touchLastMs = new Map(); // pointerId -> performance.now() of that contact's previous sample
+function queueTouch(phase, x, y, id = 0) {
+ const now = performance.now();
+ const prev = touchLastMs.get(id) ?? 0;
+ const dt = prev > 0 ? Math.min(63, Math.max(0, Math.round(now - prev))) : 0;
+ touchLastMs.set(id, now);
+ touchSamples.push({ phase, x, y, id, dt });
+}
// Virtual clock policy (docs/DETERMINISM.md): virtual frames per second. One
// frame(buttons) transaction + 60/simHz core ticks per virtual frame, so
// ms-based animations cover the same VIRTUAL time at every rate. ?hz=2
@@ -181,7 +194,7 @@ function devtoolsScreenshot() {
shot.height = FB_H;
const sctx = shot.getContext("2d");
const img = sctx.createImageData(FB_W, FB_H);
- img.data.set(wasm.renderScaled(RENDER_SCALE));
+ img.data.set(RENDER_SCALE === 1 ? wasm.render() : wasm.renderScaled(RENDER_SCALE));
sctx.putImageData(img, 0, 0);
const frame = globalThis.__pocketDevtools ? globalThis.__pocketDevtools.frame : 0;
dtSend(JSON.stringify({ t: "screenshot", frame, data: shot.toDataURL("image/png") }));
@@ -211,8 +224,13 @@ async function devtoolsSeek(frame) {
function safeFrame() {
if (!frameCb) return;
try {
- // JS: one virtual-frame transaction (input, effects, sweep)
- frameCb(held, packedAnalog());
+ // JS: one virtual-frame transaction (input, effects, sweep).
+ // arg4: drained touch event stream (one packed u32 per sample).
+ const events = touchSamples.length > 0
+ ? touchSamples.map((s) => (((s.id & 0xf) << 28) | ((s.dt & 0x3f) << 22) | ((s.y & 0x3ff) << 12) | ((s.x & 0x3ff) << 2) | (s.phase & 0x3)) >>> 0)
+ : undefined;
+ touchSamples = [];
+ frameCb(held, packedAnalog(), undefined, events);
const ticks = 60 / simHz;
for (let t = 0; t < ticks; t++) wasm.tick(); // core catch-up: 1/60 s each
} catch (e) {
@@ -319,7 +337,47 @@ export async function mount(theCanvas, opts = {}) {
window.addEventListener("blur", () => {
held = 0;
nubHeld.clear();
+ // Held contacts whose window loses focus are cancels, not ends.
+ for (const [pid, c] of touchContacts) queueTouch(3, c.x, c.y, pid);
+ touchContacts.clear();
+ });
+ // Canvas pointer → touch sample stream (logical coords). Each pointerId is
+ // an independent contact (multi-touch); the framework keys its capture
+ // table by the same id.
+ const toLogical = (e) => {
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: Math.round((e.clientX - rect.left) * (LOGICAL_W / rect.width)),
+ y: Math.round((e.clientY - rect.top) * (LOGICAL_H / rect.height)),
+ };
+ };
+ const touchContacts = new Map(); // pointerId -> {x, y} of the last queued sample
+ canvas.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ const { x, y } = toLogical(e);
+ touchContacts.set(e.pointerId, { x, y });
+ queueTouch(0, x, y, e.pointerId); // start
+ globalThis.__pocketTouch = { x, y, phase: "down" };
+ });
+ canvas.addEventListener("pointermove", (e) => {
+ const c = touchContacts.get(e.pointerId);
+ if (!c) return;
+ const { x, y } = toLogical(e);
+ if (x === c.x && y === c.y) return; // W3C: no move without movement
+ c.x = x; c.y = y;
+ queueTouch(1, x, y, e.pointerId); // move
+ globalThis.__pocketTouch = { x, y, phase: "move" };
});
+ const markRelease = (e) => {
+ const c = touchContacts.get(e.pointerId);
+ if (!c) return;
+ touchContacts.delete(e.pointerId);
+ queueTouch(2, c.x, c.y, e.pointerId); // end at the last known position
+ if (globalThis.__pocketTouch) globalThis.__pocketTouch.phase = "up";
+ };
+ canvas.addEventListener("pointerup", markRelease);
+ canvas.addEventListener("pointercancel", markRelease);
+ canvas.addEventListener("lostpointercapture", markRelease);
const hzParam = Number(query.get("hz"));
if (VALID_HZ.includes(hzParam)) simHz = hzParam;
const res = await fetch("pocketjs.wasm");
diff --git a/tests/launcher-sim.test.ts b/tests/launcher-sim.test.ts
index 2aabab1e..c0838fd7 100644
--- a/tests/launcher-sim.test.ts
+++ b/tests/launcher-sim.test.ts
@@ -85,7 +85,7 @@ describe("launcher registry admission", () => {
test("Vita admits the same current demo set through its own target profile", () => {
expect(vitaRegistry.apps).toEqual(registry.apps);
- expect(vitaRegistry.apps).toHaveLength(17);
+ expect(vitaRegistry.apps).toHaveLength(18);
});
test("committed registry.generated.ts is fresh (re-run tools/launcher.ts scan)", async () => {
diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts
index 7eec6a06..9f13741f 100644
--- a/tests/platform-contracts.test.ts
+++ b/tests/platform-contracts.test.ts
@@ -351,6 +351,7 @@ describe("semantic resolution", () => {
notifications: [true, true, false],
settings: [true, true, false],
stats: [true, true, false],
+ "touch-lab": [true, true, false], // fixed-viewport touch demo, same shape as zoomlab
"vue-sfc-lab": [true, true, false],
zoomlab: [true, true, false],
};
diff --git a/tests/touch-events.test.ts b/tests/touch-events.test.ts
new file mode 100644
index 00000000..a5182c50
--- /dev/null
+++ b/tests/touch-events.test.ts
@@ -0,0 +1,527 @@
+// Touch events contract tests — drive framework/src/touch-events.ts against a
+// mock HostOps with a scriptable hitTest and hand-built mirror trees, no
+// reconciler in the loop (same harness as cursor.test.ts).
+//
+// Semantic oracle: W3C Touch Events (test names cite the section). The one
+// deliberate deviation is bubble-only dispatch (no capture phase, no passive
+// listeners) — see docs/prd-touch-events.md grill answers in the ESP32 repo.
+//
+// Run: bun test --conditions=browser tests/touch-events.test.ts
+
+import { beforeEach, describe, expect, test } from "bun:test";
+
+import { installHost, type Host, type HostOps } from "../framework/src/host.ts";
+import type { NodeMirror } from "../framework/src/native-tree.ts";
+import {
+ __packTouchSample,
+ handleTouchSamples,
+ registerTouchHandler,
+ resetTouchEvents,
+ setTouchHitRootProvider,
+ TouchPhase,
+ type PocketTouchEvent,
+} from "../framework/src/touch-events.ts";
+import { NODE_TYPE, ROOT_ID } from "../contracts/spec/spec.ts";
+
+type Call = [string, ...unknown[]];
+
+interface TouchMockHost extends Host {
+ calls: Call[];
+ of(...names: string[]): Call[];
+ hitResult: number;
+}
+
+function makeTouchHost(): TouchMockHost {
+ const calls: Call[] = [];
+ const rec =
+ (name: string) =>
+ (...args: unknown[]) => {
+ calls.push([name, ...args]);
+ };
+ const self: TouchMockHost = {
+ kind: "injected",
+ target: "test",
+ strict: true,
+ calls,
+ hitResult: 0,
+ of(...names: string[]) {
+ return calls.filter((c) => names.includes(c[0] as string));
+ },
+ ops: {} as HostOps,
+ };
+ self.ops = {
+ createNode: () => 0,
+ destroyNode: rec("destroyNode"),
+ insertBefore: rec("insertBefore"),
+ removeChild: rec("removeChild"),
+ setStyle: rec("setStyle"),
+ setProp: rec("setProp"),
+ setText: rec("setText"),
+ replaceText: rec("replaceText"),
+ uploadTexture: () => 1,
+ setImage: rec("setImage"),
+ setSprite: rec("setSprite"),
+ animate: () => 1,
+ cancelAnim: rec("cancelAnim"),
+ setFocus: rec("setFocus"),
+ measureText: () => 0,
+ hitTest: (x: number, y: number) => {
+ calls.push(["hitTest", x, y]);
+ return self.hitResult;
+ },
+ } as unknown as HostOps;
+ return self;
+}
+
+let host: TouchMockHost;
+
+function mirror(id: number, parent: NodeMirror | null = null): NodeMirror {
+ const node: NodeMirror = { id, type: NODE_TYPE.view, parent, children: [] };
+ if (parent) parent.children.push(node);
+ return node;
+}
+
+let root: NodeMirror;
+let child: NodeMirror;
+let grandchild: NodeMirror;
+
+beforeEach(() => {
+ host = makeTouchHost();
+ installHost(host);
+ resetTouchEvents();
+ root = mirror(ROOT_ID);
+ child = mirror(0x11, root);
+ grandchild = mirror(0x22, child);
+ setTouchHitRootProvider(() => root);
+});
+
+const FRAME_MS = 1000 / 60;
+
+describe("wire format", () => {
+ test("pack/decode round-trips phase, coords, dticks", () => {
+ const p = __packTouchSample(TouchPhase.Move, 233, 117, 42);
+ expect((p & 0x3) as TouchPhase).toBe(TouchPhase.Move);
+ expect((p >>> 2) & 0x3ff).toBe(233);
+ expect((p >>> 12) & 0x3ff).toBe(117);
+ expect((p >>> 22) & 0x3f).toBe(42);
+ });
+
+ test("dticks clamp at 63", () => {
+ expect((__packTouchSample(TouchPhase.Move, 0, 0, 500) >>> 22) & 0x3f).toBe(63);
+ });
+});
+
+describe("phase dispatch", () => {
+ test("start hits once and dispatches to the hit node (W3C §5.2 target)", () => {
+ host.hitResult = grandchild.id;
+ const seen: string[] = [];
+ registerTouchHandler(grandchild, TouchPhase.Start, () => seen.push("gc"));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS);
+ expect(seen).toEqual(["gc"]);
+ expect(host.of("hitTest")).toEqual([["hitTest", 10, 20]]);
+ });
+
+ test("sub-frame down+up dispatches BOTH events in order (regression: the old snapshot model lost these)", () => {
+ host.hitResult = child.id;
+ const seen: string[] = [];
+ registerTouchHandler(child, TouchPhase.Start, () => seen.push("start"));
+ registerTouchHandler(child, TouchPhase.End, () => seen.push("end"));
+ handleTouchSamples(
+ [
+ __packTouchSample(TouchPhase.Start, 10, 20, 8),
+ __packTouchSample(TouchPhase.End, 10, 20, 0),
+ ],
+ FRAME_MS,
+ );
+ expect(seen).toEqual(["start", "end"]);
+ });
+
+ test("no touchmove without movement (integer logical coords)", () => {
+ host.hitResult = child.id;
+ const seen: string[] = [];
+ registerTouchHandler(child, TouchPhase.Move, () => seen.push("move"));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS);
+ handleTouchSamples([__packTouchSample(TouchPhase.Move, 10, 20)], 2 * FRAME_MS);
+ expect(seen).toEqual([]);
+ handleTouchSamples([__packTouchSample(TouchPhase.Move, 11, 20)], 3 * FRAME_MS);
+ expect(seen).toEqual(["move"]);
+ });
+
+ test("move/end without a tracked start are ignored (host glitch safety)", () => {
+ const seen: string[] = [];
+ registerTouchHandler(root, TouchPhase.Move, () => seen.push("move"));
+ registerTouchHandler(root, TouchPhase.End, () => seen.push("end"));
+ handleTouchSamples([__packTouchSample(TouchPhase.Move, 5, 5)], FRAME_MS);
+ handleTouchSamples([__packTouchSample(TouchPhase.End, 5, 5)], 2 * FRAME_MS);
+ expect(seen).toEqual([]);
+ });
+
+ test("empty frames are free: no hitTest, no dispatch", () => {
+ const seen: string[] = [];
+ registerTouchHandler(root, TouchPhase.Start, () => seen.push("start"));
+ handleTouchSamples(undefined, FRAME_MS);
+ handleTouchSamples([], 2 * FRAME_MS);
+ expect(seen).toEqual([]);
+ expect(host.of("hitTest")).toEqual([]);
+ });
+});
+
+describe("implicit capture (W3C §5.2)", () => {
+ test("move/end target stays the down node after sliding onto a sibling", () => {
+ const sibling = mirror(0x33, root);
+ host.hitResult = child.id;
+ const targets: number[] = [];
+ registerTouchHandler(child, TouchPhase.Move, (ev) => targets.push((ev.target as NodeMirror).id));
+ registerTouchHandler(child, TouchPhase.End, (ev) => targets.push((ev.target as NodeMirror).id));
+ registerTouchHandler(sibling, TouchPhase.Move, () => targets.push(-1));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS);
+ host.hitResult = sibling.id; // finger now over the sibling
+ handleTouchSamples([__packTouchSample(TouchPhase.Move, 200, 20)], 2 * FRAME_MS);
+ handleTouchSamples([__packTouchSample(TouchPhase.End, 200, 20)], 3 * FRAME_MS);
+ expect(targets).toEqual([child.id, child.id]);
+ // and no hitTest after the start: the capture table answers move/end
+ expect(host.of("hitTest")).toHaveLength(1);
+ });
+
+ test("cancel delivers to the captured target", () => {
+ host.hitResult = grandchild.id;
+ const seen: string[] = [];
+ registerTouchHandler(grandchild, TouchPhase.Cancel, () => seen.push("cancel"));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS);
+ handleTouchSamples([__packTouchSample(TouchPhase.Cancel, 10, 20)], 2 * FRAME_MS);
+ expect(seen).toEqual(["cancel"]);
+ });
+});
+
+describe("bubbling (W3C §5.6)", () => {
+ test("bubbles target -> parent -> root", () => {
+ host.hitResult = grandchild.id;
+ const order: string[] = [];
+ registerTouchHandler(root, TouchPhase.Start, () => order.push("root"));
+ registerTouchHandler(child, TouchPhase.Start, () => order.push("child"));
+ registerTouchHandler(grandchild, TouchPhase.Start, () => order.push("gc"));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 1, 1)], FRAME_MS);
+ expect(order).toEqual(["gc", "child", "root"]);
+ });
+
+ test("stopPropagation truncates the walk", () => {
+ host.hitResult = grandchild.id;
+ const order: string[] = [];
+ registerTouchHandler(root, TouchPhase.Start, () => order.push("root"));
+ registerTouchHandler(child, TouchPhase.Start, (ev) => {
+ order.push("child");
+ ev.stopPropagation();
+ });
+ registerTouchHandler(grandchild, TouchPhase.Start, () => order.push("gc"));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 1, 1)], FRAME_MS);
+ expect(order).toEqual(["gc", "child"]);
+ });
+
+ test("currentTarget walks, target stays fixed", () => {
+ host.hitResult = grandchild.id;
+ const seen: Array<[number, number]> = [];
+ const log = (ev: PocketTouchEvent) =>
+ seen.push([(ev.target as NodeMirror).id, (ev.currentTarget as NodeMirror).id]);
+ registerTouchHandler(child, TouchPhase.Start, log);
+ registerTouchHandler(root, TouchPhase.Start, log);
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 1, 1)], FRAME_MS);
+ expect(seen).toEqual([
+ [grandchild.id, child.id],
+ [grandchild.id, root.id],
+ ]);
+ });
+});
+
+describe("event lists (W3C §5.3)", () => {
+ test("touchstart: touches and changedTouches contain the new contact", () => {
+ host.hitResult = child.id;
+ let ev0: PocketTouchEvent | null = null;
+ registerTouchHandler(child, TouchPhase.Start, (ev) => (ev0 = ev));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS);
+ expect(ev0!.touches).toHaveLength(1);
+ expect(ev0!.changedTouches).toHaveLength(1);
+ expect(ev0!.changedTouches[0].clientX).toBe(10);
+ expect(ev0!.changedTouches[0].clientY).toBe(20);
+ expect(ev0!.targetTouches).toHaveLength(1); // currentTarget === target here
+ expect(ev0!.touches[0].target).toBe(child);
+ });
+
+ test("touchend: touches is empty, changedTouches has the released contact", () => {
+ host.hitResult = child.id;
+ let ev0: PocketTouchEvent | null = null;
+ registerTouchHandler(child, TouchPhase.End, (ev) => (ev0 = ev));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS);
+ handleTouchSamples([__packTouchSample(TouchPhase.End, 30, 40)], 2 * FRAME_MS);
+ expect(ev0!.touches).toHaveLength(0);
+ expect(ev0!.changedTouches).toHaveLength(1);
+ expect(ev0!.changedTouches[0].clientX).toBe(30);
+ expect(ev0!.changedTouches[0].clientY).toBe(40);
+ });
+});
+
+describe("timeStamps (sample-time, tape-deterministic)", () => {
+ test("timeStamp walks backwards from frame end by dticks", () => {
+ host.hitResult = child.id;
+ const stamps: number[] = [];
+ registerTouchHandler(child, TouchPhase.Start, (ev) => stamps.push(ev.timeStamp));
+ registerTouchHandler(child, TouchPhase.Move, (ev) => stamps.push(ev.timeStamp));
+ registerTouchHandler(child, TouchPhase.End, (ev) => stamps.push(ev.timeStamp));
+ // start at t-16ms, move at t-8ms, end at t: dticks are 8, 8, 0 reading
+ // FORWARD, so the packed stream carries [start:8, move:8, end:0].
+ handleTouchSamples(
+ [
+ __packTouchSample(TouchPhase.Start, 1, 1, 8),
+ __packTouchSample(TouchPhase.Move, 2, 1, 8),
+ __packTouchSample(TouchPhase.End, 2, 1, 0),
+ ],
+ 1000,
+ );
+ expect(stamps).toEqual([984, 992, 1000]);
+ });
+});
+
+describe("handler registration via prop names", () => {
+ test("all four phases register and fire under both prop spellings", async () => {
+ host.hitResult = child.id;
+ const seen: string[] = [];
+ // Exercise the native-tree prop path, not just the internal registry.
+ const { setProp } = await import("../framework/src/native-tree.ts");
+ setProp(child, "onTouchstart", () => seen.push("start"));
+ setProp(child, "on:touchmove", () => seen.push("move"));
+ setProp(child, "onTouchend", () => seen.push("end"));
+ setProp(child, "on:touchcancel", () => seen.push("cancel"));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 1, 1)], FRAME_MS);
+ handleTouchSamples([__packTouchSample(TouchPhase.Move, 2, 1)], 2 * FRAME_MS);
+ handleTouchSamples([__packTouchSample(TouchPhase.End, 2, 1)], 3 * FRAME_MS);
+ // cancel after end: contact already released, nothing fires
+ handleTouchSamples([__packTouchSample(TouchPhase.Cancel, 2, 1)], 4 * FRAME_MS);
+ expect(seen).toEqual(["start", "move", "end"]);
+ });
+});
+
+describe("preventDefault", () => {
+ test("defaultPrevented round-trips within the event", () => {
+ host.hitResult = child.id;
+ let prevented = false;
+ registerTouchHandler(child, TouchPhase.Start, (ev) => {
+ ev.preventDefault();
+ prevented = ev.defaultPrevented;
+ });
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 1, 1)], FRAME_MS);
+ expect(prevented).toBe(true);
+ });
+});
+
+// ---- W3C canonical cases -----------------------------------------------------
+// These codify the semantics a browser developer expects. They exist because
+// the demo app exposed two real bugs the happy-path tests above missed:
+// 1. a global listener on the root saw "blank" before the element listener
+// reported — bubble ORDER was under-tested;
+// 2. touching bare background (no element) never reached a root listener —
+// root dispatch was under-tested.
+
+describe("W3C canonical bubbling order", () => {
+ test("target first, then ancestors up to root (target phase -> bubble phase)", () => {
+ host.hitResult = grandchild.id;
+ const order: string[] = [];
+ registerTouchHandler(root, TouchPhase.Start, () => order.push("root"));
+ registerTouchHandler(child, TouchPhase.Start, () => order.push("child"));
+ registerTouchHandler(grandchild, TouchPhase.Start, () => order.push("grandchild"));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS);
+ // W3C §5.6: the event target's own listeners run FIRST (target phase),
+ // then bubble ancestors from innermost to outermost.
+ expect(order).toEqual(["grandchild", "child", "root"]);
+ });
+
+ test("currentTarget tracks the bubbling node while target stays fixed", () => {
+ host.hitResult = grandchild.id;
+ const seen: [number, number][] = []; // [currentTarget.id, target.id]
+ const log = (ev: PocketTouchEvent) =>
+ seen.push([(ev.currentTarget as NodeMirror).id, (ev.target as NodeMirror).id]);
+ registerTouchHandler(root, TouchPhase.Start, log);
+ registerTouchHandler(child, TouchPhase.Start, log);
+ registerTouchHandler(grandchild, TouchPhase.Start, log);
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS);
+ expect(seen).toEqual([
+ [grandchild.id, grandchild.id],
+ [child.id, grandchild.id],
+ [root.id, grandchild.id],
+ ]);
+ });
+
+ test("root listener observes touches that hit NO element (background)", () => {
+ // The demo bug: tapping bare background never updated a root-level
+ // listener. W3C: hit tests that fall through to the document still
+ // dispatch, with the document (here: root) as target.
+ host.hitResult = 0; // no element claims the point
+ const seen: string[] = [];
+ registerTouchHandler(root, TouchPhase.Start, () => seen.push("start"));
+ registerTouchHandler(root, TouchPhase.End, () => seen.push("end"));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 500, 150)], FRAME_MS);
+ handleTouchSamples([__packTouchSample(TouchPhase.End, 500, 150)], 2 * FRAME_MS);
+ expect(seen).toEqual(["start", "end"]);
+ });
+
+ test("root-as-target bubbles nowhere above root (no crash, single dispatch)", () => {
+ host.hitResult = 0;
+ const seen: string[] = [];
+ registerTouchHandler(root, TouchPhase.Start, (ev) => {
+ seen.push("start");
+ expect(ev.target as NodeMirror | null).toBe(ev.currentTarget);
+ });
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 500, 150)], FRAME_MS);
+ expect(seen).toEqual(["start"]);
+ });
+
+ test("stopPropagation at the target prevents ALL ancestor listeners", () => {
+ host.hitResult = grandchild.id;
+ const order: string[] = [];
+ registerTouchHandler(root, TouchPhase.Start, () => order.push("root"));
+ registerTouchHandler(child, TouchPhase.Start, () => order.push("child"));
+ registerTouchHandler(grandchild, TouchPhase.Start, (ev) => {
+ order.push("grandchild");
+ ev.stopPropagation();
+ });
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS);
+ expect(order).toEqual(["grandchild"]);
+ });
+
+ test("targetTouches lists only contacts whose target is the currentTarget (W3C §5.3)", () => {
+ host.hitResult = child.id;
+ let atChild: number | undefined;
+ let atRoot: number | undefined;
+ registerTouchHandler(child, TouchPhase.Start, (ev) => {
+ atChild = ev.targetTouches.length;
+ });
+ registerTouchHandler(root, TouchPhase.Start, (ev) => {
+ atRoot = ev.targetTouches.length;
+ });
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS);
+ expect(atChild).toBe(1); // the contact targets child
+ expect(atRoot).toBe(0); // not root, even though root sees the bubble
+ });
+});
+
+// ---- multi-touch (contact identifier, W3C §5.1) -------------------------------
+// Two simultaneous contacts are independent: each captures its own target,
+// interleaved moves do not disturb each other, and the event lists group by
+// contact. Driven by hand-packed u32 streams carrying distinct id fields.
+
+describe("multi-touch (contact identifiers)", () => {
+ test("wire: id round-trips through pack/decode", () => {
+ const p = __packTouchSample(TouchPhase.Start, 10, 20, 5, 3);
+ expect((p >>> 28) & 0xf).toBe(3);
+ const s = __packTouchSample(TouchPhase.Move, 0, 0, 0, 15);
+ expect((s >>> 28) & 0xf).toBe(15);
+ });
+
+ test("two contacts capture different targets independently (W3C §5.2)", () => {
+ const sibling = mirror(0x33, root);
+ const targets: Record = { 0: [], 1: [] };
+ registerTouchHandler(child, TouchPhase.Move, (ev) =>
+ targets[ev.changedTouches[0].identifier]!.push((ev.target as NodeMirror).id),
+ );
+ registerTouchHandler(sibling, TouchPhase.Move, (ev) =>
+ targets[ev.changedTouches[0].identifier]!.push((ev.target as NodeMirror).id),
+ );
+ // Contact 0 down on child, contact 1 down on sibling.
+ host.hitResult = child.id;
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20, 0, 0)], FRAME_MS);
+ host.hitResult = sibling.id;
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 200, 150, 0, 1)], 2 * FRAME_MS);
+ // Both move elsewhere: each still reports its OWN captured target.
+ handleTouchSamples(
+ [
+ __packTouchSample(TouchPhase.Move, 300, 5, 0, 0),
+ __packTouchSample(TouchPhase.Move, 300, 6, 0, 1),
+ ],
+ 3 * FRAME_MS,
+ );
+ expect(targets[0]).toEqual([child.id]);
+ expect(targets[1]).toEqual([sibling.id]);
+ });
+
+ test("interleaved moves keep per-contact positions and touches list (W3C §5.3)", () => {
+ host.hitResult = child.id;
+ const seen: Array<[number, number, number, number]> = []; // [id, x, y, touches.length]
+ registerTouchHandler(child, TouchPhase.Move, (ev) =>
+ seen.push([
+ ev.changedTouches[0].identifier,
+ ev.changedTouches[0].clientX,
+ ev.changedTouches[0].clientY,
+ ev.touches.length,
+ ]),
+ );
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20, 0, 0)], FRAME_MS);
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 100, 120, 0, 1)], 2 * FRAME_MS);
+ handleTouchSamples(
+ [
+ __packTouchSample(TouchPhase.Move, 11, 21, 0, 0),
+ __packTouchSample(TouchPhase.Move, 101, 121, 0, 1),
+ ],
+ 3 * FRAME_MS,
+ );
+ // Both contacts active throughout: touches.length stays 2, positions independent.
+ expect(seen).toEqual([
+ [0, 11, 21, 2],
+ [1, 101, 121, 2],
+ ]);
+ });
+
+ test("ending one contact leaves the other tracked; touches shrinks to 1", () => {
+ host.hitResult = child.id;
+ const seen: Array<[string, number]> = [];
+ registerTouchHandler(child, TouchPhase.End, (ev) =>
+ seen.push(["end", ev.changedTouches[0].identifier]),
+ );
+ registerTouchHandler(child, TouchPhase.Move, (ev) =>
+ seen.push(["move", ev.touches.length]),
+ );
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20, 0, 0)], FRAME_MS);
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 100, 120, 0, 1)], 2 * FRAME_MS);
+ handleTouchSamples([__packTouchSample(TouchPhase.End, 100, 120, 0, 1)], 3 * FRAME_MS);
+ expect(seen).toEqual([["end", 1]]);
+ // Contact 0 still moves, and it is now the only active touch.
+ handleTouchSamples([__packTouchSample(TouchPhase.Move, 12, 22, 0, 0)], 4 * FRAME_MS);
+ expect(seen).toEqual([
+ ["end", 1],
+ ["move", 1],
+ ]);
+ });
+
+ test("per-contact dticks reconstruct independent timelines (interleaved)", () => {
+ host.hitResult = child.id;
+ const stamps: Array<[number, number]> = []; // [id, timeStamp]
+ const log = (ev: PocketTouchEvent) =>
+ stamps.push([ev.changedTouches[0].identifier, ev.timeStamp]);
+ registerTouchHandler(child, TouchPhase.Start, log);
+ registerTouchHandler(child, TouchPhase.Move, log);
+ registerTouchHandler(child, TouchPhase.End, log);
+ // Frame ends at t=1000. id0: start dtick 8, then move dtick 8 → move at
+ // 1000-8=992, start at 992-8=984. id1 interleaves with start dtick 4 → 996.
+ handleTouchSamples(
+ [
+ __packTouchSample(TouchPhase.Start, 1, 1, 8, 0),
+ __packTouchSample(TouchPhase.Start, 50, 50, 4, 1),
+ __packTouchSample(TouchPhase.Move, 2, 1, 8, 0),
+ ],
+ 1000,
+ );
+ expect(stamps).toEqual([
+ [0, 984],
+ [1, 996],
+ [0, 992],
+ ]);
+ });
+
+ test("a second Start for an already-down id is ignored (host glitch guard)", () => {
+ host.hitResult = child.id;
+ const seen: string[] = [];
+ registerTouchHandler(child, TouchPhase.Start, () => seen.push("start"));
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20, 0, 0)], FRAME_MS);
+ // Re-down id 0 elsewhere without an end: must not re-hitTest or re-fire.
+ handleTouchSamples([__packTouchSample(TouchPhase.Start, 400, 300, 0, 0)], 2 * FRAME_MS);
+ expect(seen).toEqual(["start"]);
+ expect(host.of("hitTest")).toEqual([["hitTest", 10, 20]]);
+ });
+});