diff --git a/framework/compiler/jsx-plugin.ts b/framework/compiler/jsx-plugin.ts index f6f3d95..931292c 100644 --- a/framework/compiler/jsx-plugin.ts +++ b/framework/compiler/jsx-plugin.ts @@ -40,6 +40,7 @@ const COMPONENTS_VUE_VAPOR_PATH = new URL("../src/components-vue-vapor.ts", impo const CONFIG_PATH = new URL("../src/config.ts", import.meta.url).pathname; const GESTURE_PATH = new URL("../src/gesture.ts", import.meta.url).pathname; const INPUT_API_PATH = new URL("../src/input-api.ts", import.meta.url).pathname; +const KINETICS_PATH = new URL("../src/kinetics.ts", import.meta.url).pathname; const LAUNCHER_PATH = new URL("../src/launcher.ts", import.meta.url).pathname; const LIFECYCLE_PATH = new URL("../src/lifecycle.ts", import.meta.url).pathname; const LIFECYCLE_VUE_VAPOR_PATH = new URL("../src/lifecycle-vue-vapor.ts", import.meta.url).pathname; @@ -80,6 +81,7 @@ export const FRAMEWORKS: Record< config: CONFIG_PATH, gesture: GESTURE_PATH, input: INPUT_API_PATH, + kinetics: KINETICS_PATH, launcher: LAUNCHER_PATH, lifecycle: LIFECYCLE_PATH, // The system OSK carries class literals and key-cap glyphs, so pass 1 diff --git a/framework/src/kinetics.ts b/framework/src/kinetics.ts new file mode 100644 index 0000000..59c43b4 --- /dev/null +++ b/framework/src/kinetics.ts @@ -0,0 +1,374 @@ +// Kinetic scrolling: one axis, one deterministic state machine. +// +// The scroller owns a content offset and integrates it through six states: +// tracking finger-follow (gesture pan feeds drag deltas; out-of-bounds +// travel is rubber-banded with the classic iOS curve) +// fling exponential decay from the release velocity +// spring edge bounce-back — semi-implicit Euler with the engine's +// Spring constants (K=170, C=26), CARRYING the incoming velocity +// (a fling that crosses an edge keeps its momentum into the +// rubber band; this is the one place "spring with initial +// velocity" is needed, and it lives here, not in the core) +// chase per-frame ease toward a target — byte-for-byte the apps/im +// pump (0.3 of the remaining distance, snap under 0.6 px); the +// d-pad / stick-to-bottom mode +// tween programmatic scrollTo with cubic ease-out +// idle at rest +// +// The offset is a Solid signal; callers bind `translateY: -s.offset()` on +// the content canvas (translate is paint-only — one setProp per moving +// frame, no relayout) and call `s.step()` once per frame from onFrame. +// +// Determinism: fling and spring integrate PER CORE TICK (ticksPerFrame() +// iterations of fixed 1/60 s), so a 30 Hz world's trajectory is the 60 Hz +// trajectory subsampled (the clock contract). Chase is per-FRAME by design — +// hz-sensitive, matching shipped apps/im behavior exactly. Every formula is +// IEEE + − * / with literal constants: no Math.pow, no transcendentals — +// trajectories are bit-identical on every host. Decay rates are per-tick +// literals (0.998/ms and 0.99/ms at 16.667 ms, pre-baked). + +import { createSignal, type Accessor } from "solid-js"; +import { BTN, SCREEN_H } from "../../contracts/spec/spec.ts"; +import { analogY } from "./analog.ts"; +import { simulationHz, ticksPerFrame } from "./clock.ts"; +import { onFrame } from "./frame.ts"; + +export type ScrollerState = "idle" | "tracking" | "fling" | "spring" | "chase" | "tween"; + +export interface ScrollerOptions { + /** Scroll range end: max(0, contentH - viewH). Read every step, so a + * growing list needs no re-registration. */ + max: () => number; + /** Viewport extent for the rubber-band scale. Default SCREEN_H. */ + extent?: () => number; + initial?: number; + /** Max rubber-band travel in px; 0 = hard clamp at the edges. */ + overscroll?: number; + /** Fling decay preset: "normal" ≡ iOS 0.998/ms, "fast" ≡ 0.99/ms. */ + decay?: "normal" | "fast"; + /** Applied at endDrag: receives the projected rest position and release + * velocity, returns the position to tween to (paging, row alignment). */ + snap?: ((projectedRest: number, velocity: number) => number) | null; + /** A moving state reached rest. */ + onSettle?: (offset: number) => void; +} + +export interface Scroller { + /** Current offset (logical px). Bind `translateY: -offset()`. */ + offset: Accessor; + /** Instantaneous velocity in px per virtual second (fling/spring only). */ + velocity(): number; + state(): ScrollerState; + /** Finger-follow (wire to a gesture pan). */ + beginDrag(): void; + /** Content-space delta for THIS frame (a vertical list passes -c.fdy). */ + drag(deltaPx: number): void; + /** Release with the gesture's velocity (the list passes -c.vy). */ + endDrag(releaseVelocity: number): void; + /** Programmatic scroll. Default tweens over 200 ms. */ + scrollTo(to: number, opts?: { durMs?: number } | { immediate: true }): void; + scrollBy(delta: number, opts?: { durMs?: number } | { immediate: true }): void; + /** Freeze in place (no settle callback). */ + stop(): void; + /** Move the chase target by a delta — the d-pad/analog primitive. */ + nudge(delta: number): void; + /** Chase an absolute target (focus-follow, stick-to-bottom). */ + chaseTo(to: number): void; + /** Shift offset AND every in-flight anchor by delta after a prepend, so + * backfill never moves what the user is looking at (the im rebase). */ + rebase(delta: number): void; + /** At the end of the range, judged on INTENT: the chase/tween target when + * one is in flight, the position otherwise (the im at-bottom rule). */ + isAtEnd(slackPx?: number): boolean; + /** Rest position a fling from `v` would reach (for snap functions). */ + projectFling(v: number): number; + /** Advance one frame. Call once per frame from onFrame. */ + step(): void; +} + +// Fling decay per 1/60 s tick. 0.9672 ≡ UIScrollView's 0.998/ms at 16.667 ms +// (0.998^16.667); 0.846 ≡ the 0.99/ms paging rate. Literals on purpose — +// computing them at runtime would put a transcendental in the sim path. +const DECAY_NORMAL = 0.9672; +const DECAY_FAST = 0.846; +/** Fling rest threshold, px per virtual second. */ +const FLING_MIN_V = 4; +/** Rubber-band slope at the edge (the classic iOS coefficient). */ +const RUBBER_COEFF = 0.55; +const DEFAULT_OVERSCROLL = 48; +/** Edge spring — the engine's Spring preset (engine/core anim.rs), which at + * C ≈ 2√K is essentially critically damped. */ +const SPRING_K = 170; +const SPRING_C = 26; +const SPRING_SETTLE_DIST = 0.5; +const SPRING_SETTLE_V = 8; +/** The apps/im chase pump constants. */ +const CHASE_RATE = 0.3; +const CHASE_SNAP = 0.6; +const TICK_DT = 1 / 60; + +/** Displayed rubber travel for `x` px of out-of-bounds drag: asymptote d, + * slope RUBBER_COEFF at the edge. */ +function rubber(x: number, d: number, cap: number): number { + if (cap <= 0) return 0; + const r = (1 - 1 / ((x * RUBBER_COEFF) / d + 1)) * d; + return r < cap ? r : cap; +} + +/** Inverse of rubber() (uncapped) — recovers drag-space travel when a + * finger catches the content mid-bounce. */ +function rubberInv(e: number, d: number): number { + return (d * e) / (RUBBER_COEFF * (d - e)); +} + +export function createScroller(opts: ScrollerOptions): Scroller { + const extent = opts.extent ?? (() => SCREEN_H); + const overscroll = opts.overscroll ?? DEFAULT_OVERSCROLL; + const decay = opts.decay === "fast" ? DECAY_FAST : DECAY_NORMAL; + + const [offset, setOffset] = createSignal(opts.initial ?? 0); + let pos = opts.initial ?? 0; + let state: ScrollerState = "idle"; + let v = 0; // px per virtual second (fling/spring) + let dragPos = 0; // unrubbered drag-space position while tracking + let target = 0; // chase target + let springBound = 0; // the edge a spring is heading to + let tweenFrom = 0; + let tweenTo = 0; + let tweenFrames = 1; + let tweenAt = 0; + + function emit(p: number): void { + if (p !== pos) { + pos = p; + setOffset(p); + } + } + + function settle(p: number): void { + // Round to 1/64 px so settled framebuffers hash identically. + const r = Math.round(p * 64) / 64; + emit(r); + v = 0; + state = "idle"; + opts.onSettle?.(r); + } + + function clampRange(x: number): number { + const m = opts.max(); + return x < 0 ? 0 : x > m ? m : x; + } + + function displayedFromDrag(): number { + const m = opts.max(); + if (dragPos < 0) return -rubber(-dragPos, extent(), overscroll); + if (dragPos > m) return m + rubber(dragPos - m, extent(), overscroll); + return dragPos; + } + + function startTween(to: number, durMs: number): void { + tweenFrom = pos; + tweenTo = clampRange(to); + tweenFrames = Math.max(1, Math.round((durMs * simulationHz()) / 1000)); + tweenAt = 0; + state = tweenTo === pos ? "idle" : "tween"; + } + + function projectFling(v0: number): number { + // Geometric series of per-tick steps: Σ v·D^n·dt = v·dt·D/(1−D). + return pos + (v0 * TICK_DT * decay) / (1 - decay); + } + + return { + offset, + velocity: () => v, + state: () => state, + + beginDrag(): void { + const m = opts.max(); + v = 0; + state = "tracking"; + if (pos < 0) dragPos = -rubberInv(-pos, extent()); + else if (pos > m) dragPos = m + rubberInv(pos - m, extent()); + else dragPos = pos; + }, + + drag(deltaPx: number): void { + if (state !== "tracking") this.beginDrag(); + dragPos += deltaPx; + emit(displayedFromDrag()); + }, + + endDrag(releaseVelocity: number): void { + if (state !== "tracking") return; + const m = opts.max(); + if (pos < 0 || pos > m) { + springBound = pos < 0 ? 0 : m; + v = releaseVelocity; + state = "spring"; + return; + } + if (opts.snap) { + const to = opts.snap(projectFling(releaseVelocity), releaseVelocity); + const dist = to - pos; + const durMs = Math.min(450, Math.max(150, 150 + (dist < 0 ? -dist : dist) * 0.6)); + startTween(to, durMs); + return; + } + if (releaseVelocity > FLING_MIN_V || releaseVelocity < -FLING_MIN_V) { + v = releaseVelocity; + state = "fling"; + return; + } + settle(pos); + }, + + scrollTo(to: number, o?: { durMs?: number } | { immediate: true }): void { + if (o && "immediate" in o && o.immediate) { + v = 0; + state = "idle"; + emit(clampRange(to)); + return; + } + startTween(to, (o as { durMs?: number } | undefined)?.durMs ?? 200); + }, + + scrollBy(delta: number, o?: { durMs?: number } | { immediate: true }): void { + const base = state === "tween" ? tweenTo : state === "chase" ? target : pos; + this.scrollTo(base + delta, o); + }, + + stop(): void { + v = 0; + state = "idle"; + }, + + nudge(delta: number): void { + const base = state === "chase" ? target : pos; + this.chaseTo(base + delta); + }, + + chaseTo(to: number): void { + target = clampRange(to); + if (state !== "chase" && target === pos) return; + v = 0; + state = "chase"; + }, + + rebase(delta: number): void { + dragPos += delta; + target += delta; + springBound += delta; + tweenFrom += delta; + tweenTo += delta; + emit(pos + delta); + }, + + isAtEnd(slackPx = 1): boolean { + const intent = state === "chase" ? target : state === "tween" ? tweenTo : pos; + return intent >= opts.max() - slackPx; + }, + + projectFling, + + step(): void { + if (state === "idle" || state === "tracking") return; + + if (state === "chase") { + // Per FRAME (hz-sensitive by design — im parity). + target = clampRange(target); + const d = target - pos; + if (d === 0 || (d < CHASE_SNAP && d > -CHASE_SNAP)) { + emit(target); + state = "idle"; + opts.onSettle?.(pos); + return; + } + emit(pos + d * CHASE_RATE); + return; + } + + if (state === "tween") { + tweenAt++; + if (tweenAt >= tweenFrames) { + emit(tweenTo); + state = "idle"; + v = 0; + opts.onSettle?.(pos); + return; + } + const t = tweenAt / tweenFrames; + const inv = 1 - t; + const eased = 1 - inv * inv * inv; // cubic ease-out, polynomial only + emit(tweenFrom + (tweenTo - tweenFrom) * eased); + return; + } + + // fling / spring integrate PER TICK, and settle/transition checks run + // inside the tick loop — the stop tick is the same at every hz, which + // is what makes the 30 Hz trajectory the 60 Hz one subsampled. + let p = pos; + const ticks = ticksPerFrame(); + for (let i = 0; i < ticks; i++) { + if (state === "fling") { + v *= decay; + p += v * TICK_DT; + const m = opts.max(); + if (p < 0 || p > m) { + // Carry the momentum into the edge spring mid-tick. + springBound = p < 0 ? 0 : m; + state = "spring"; + continue; + } + if (v < FLING_MIN_V && v > -FLING_MIN_V) { + settle(p); + return; + } + } else { + const b = clampRange(springBound); + const a = SPRING_K * (b - p) - SPRING_C * v; + v += a * TICK_DT; + p += v * TICK_DT; + const dist = b - p; + if ( + dist < SPRING_SETTLE_DIST && + dist > -SPRING_SETTLE_DIST && + v < SPRING_SETTLE_V && + v > -SPRING_SETTLE_V + ) { + settle(b); + return; + } + } + } + emit(p); + }, + }; +} + +export interface DpadScrollOptions { + /** px per held-frame of UP/DOWN. Default 6 (apps/im SCROLL_STEP). */ + stepPx?: number; + /** px per frame at full analog deflection. Default 10 (im NUB_STEP). */ + nubPx?: number; + /** Gate (e.g. `() => !osk.isOpen()` — raw button reads are not muted by + * the OSK's modal block). Default: always on. */ + active?: () => boolean; +} + +/** + * The apps/im d-pad/analog scroll semantics over a Scroller: held UP/DOWN + * moves the chase target stepPx per frame, the nub moves it proportionally. + * Registers an onFrame hook (Solid-scoped); the caller still owns step(). + */ +export function bindDpadScroll(s: Scroller, o: DpadScrollOptions = {}): void { + const step = o.stepPx ?? 6; + const nubStep = o.nubPx ?? 10; + onFrame((buttons) => { + if (o.active && !o.active()) return; + if (buttons & BTN.UP) s.nudge(-step); + if (buttons & BTN.DOWN) s.nudge(step); + const nub = analogY(); + if (nub !== 0) s.nudge(nub * nubStep); + }); +} diff --git a/package.json b/package.json index b5c4aaa..9552927 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "./lifecycle": "./framework/src/lifecycle.ts", "./hot": "./framework/src/hot.ts", "./input": "./framework/src/input-api.ts", + "./kinetics": "./framework/src/kinetics.ts", "./launcher": "./framework/src/launcher.ts", "./manifest": "./framework/src/manifest/index.ts", "./osk": "./framework/src/osk.tsx", diff --git a/tests/kinetics.test.ts b/tests/kinetics.test.ts new file mode 100644 index 0000000..3445613 --- /dev/null +++ b/tests/kinetics.test.ts @@ -0,0 +1,275 @@ +// Kinetic scroller unit tests. No host, no renderer — the scroller is pure +// state + a Solid signal. The load-bearing assertions are the determinism +// ones: repeat runs are identical, and tick-integrated modes (fling/spring) +// obey the subsampling theorem — the 30 Hz trajectory IS the 60 Hz one +// sampled every other frame, including the stop tick. + +import { beforeEach, describe, expect, test } from "bun:test"; +import { createScroller, bindDpadScroll, type Scroller } from "../framework/src/kinetics.ts"; +import { resetClock } from "../framework/src/clock.ts"; +import { resetFrameHooks, runFrameHooks, __setAnalog, analogY } from "../framework/src/frame.ts"; +import { BTN } from "../contracts/spec/spec.ts"; + +function withHz(hz: number): void { + (globalThis as { __simHz?: number }).__simHz = hz; + resetClock(); +} + +function fling(s: Scroller, v: number): void { + s.beginDrag(); + s.endDrag(v); +} + +/** Step until idle (bounded), returning the per-frame offset trace. */ +function trace(s: Scroller, maxFrames = 600): number[] { + const out: number[] = []; + for (let i = 0; i < maxFrames && s.state() !== "idle"; i++) { + s.step(); + out.push(s.offset()); + } + return out; +} + +beforeEach(() => { + delete (globalThis as { __simHz?: number }).__simHz; + resetClock(); + resetFrameHooks(); +}); + +describe("fling", () => { + test("decays monotonically and settles to a 1/64-px-rounded rest", () => { + const s = createScroller({ max: () => 10_000 }); + fling(s, 600); + const t = trace(s); + expect(t.length).toBeGreaterThan(30); + for (let i = 1; i < t.length; i++) { + expect(t[i]).toBeGreaterThanOrEqual(t[i - 1]); // never reverses + // Deltas shrink every frame — except the final one, where the settle + // rounds to 1/64 px and may nudge the offset past the raw trajectory. + if (i > 1 && i < t.length - 1) { + expect(t[i] - t[i - 1]).toBeLessThanOrEqual(t[i - 1] - t[i - 2] + 1e-9); + } + } + const rest = t[t.length - 1]; + expect(rest * 64).toBe(Math.round(rest * 64)); // exact 1/64 rounding + expect(s.state()).toBe("idle"); + }); + + test("is deterministic across runs", () => { + const runOnce = (): number[] => { + const s = createScroller({ max: () => 10_000 }); + fling(s, 587); + return trace(s); + }; + expect(runOnce()).toEqual(runOnce()); + }); + + test("30 Hz trajectory is the 60 Hz one subsampled, same rest", () => { + withHz(60); + const s60 = createScroller({ max: () => 10_000 }); + fling(s60, 600); + const t60 = trace(s60); + + withHz(30); + const s30 = createScroller({ max: () => 10_000 }); + fling(s30, 600); + const t30 = trace(s30); + + for (let i = 0; i < t30.length; i++) { + const at60 = 2 * i + 1; // frame i at 30 Hz = tick 2i+2 = 60 Hz frame 2i+1 + expect(t30[i]).toBe(t60[Math.min(at60, t60.length - 1)]); + } + expect(t30[t30.length - 1]).toBe(t60[t60.length - 1]); + }); + + test("projectFling matches the integrated rest within the stop threshold", () => { + const s = createScroller({ max: () => 10_000 }); + const projected = s.projectFling(600); + fling(s, 600); + const t = trace(s); + // The projection is the infinite series; the integrator stops at |v|<4. + expect(Math.abs(t[t.length - 1] - projected)).toBeLessThan(2); + }); + + test("crossing an edge hands the velocity to the spring and settles ON the edge", () => { + const s = createScroller({ max: () => 100 }); + fling(s, 2000); // way past max=100 + const t = trace(s); + const peak = Math.max(...t); + expect(peak).toBeGreaterThan(100); // it overshot (rubber travel) + expect(t[t.length - 1]).toBe(100); // and came back exactly to the edge + expect(s.state()).toBe("idle"); + }); +}); + +describe("tracking + rubber-band", () => { + test("in-bounds drag is 1:1", () => { + const s = createScroller({ max: () => 1000 }); + s.beginDrag(); + s.drag(40); + expect(s.offset()).toBe(40); + s.drag(-15); + expect(s.offset()).toBe(25); + }); + + test("past the edge the iOS curve applies, capped at overscroll", () => { + const s = createScroller({ max: () => 1000, extent: () => 272 }); + s.beginDrag(); + s.drag(-50); // 50 px past the top + // (1 - 1/((50*0.55/272)+1))*272 = 24.9745… + expect(s.offset()).toBeCloseTo(-24.9745, 3); + s.drag(-10_000); + expect(s.offset()).toBe(-48); // default overscroll cap + }); + + test("overscroll: 0 clamps hard", () => { + const s = createScroller({ max: () => 1000, overscroll: 0 }); + s.beginDrag(); + s.drag(-50); + expect(s.offset()).toBe(0); + }); + + test("catching mid-bounce resumes from the same displayed position", () => { + const s = createScroller({ max: () => 1000, extent: () => 272 }); + s.beginDrag(); + s.drag(-50); + const displayed = s.offset(); + s.endDrag(0); + expect(s.state()).toBe("spring"); + s.beginDrag(); // catch it before it moves + expect(s.offset()).toBe(displayed); + s.drag(0); + expect(s.offset()).toBeCloseTo(displayed, 9); + }); + + test("releasing out of bounds springs back to the bound exactly", () => { + const s = createScroller({ max: () => 1000 }); + s.beginDrag(); + s.drag(-60); + s.endDrag(0); + const t = trace(s); + expect(t[t.length - 1]).toBe(0); + }); + + test("slow release settles without a fling", () => { + const s = createScroller({ max: () => 1000 }); + s.beginDrag(); + s.drag(40); + s.endDrag(2); // under FLING_MIN_V + expect(s.state()).toBe("idle"); + expect(s.offset()).toBe(40); + }); +}); + +describe("chase (im parity)", () => { + test("covers 0.3 of the remaining distance per frame and snaps under 0.6", () => { + const s = createScroller({ max: () => 1000 }); + s.chaseTo(100); + s.step(); + expect(s.offset()).toBe(30); + s.step(); + expect(s.offset()).toBe(51); + const t = trace(s); + expect(t[t.length - 1]).toBe(100); // exact arrival via the snap + expect(s.state()).toBe("idle"); + }); + + test("nudge moves the chase target, clamped to the range", () => { + const s = createScroller({ max: () => 50 }); + s.nudge(30); + s.nudge(40); // target would be 70 → clamps to 50 + const t = trace(s); + expect(t[t.length - 1]).toBe(50); + }); + + test("isAtEnd judges the TARGET while chasing", () => { + const s = createScroller({ max: () => 100 }); + s.chaseTo(100); + expect(s.offset()).toBe(0); // hasn't moved yet + expect(s.isAtEnd()).toBe(true); // but the intent is the bottom + }); +}); + +describe("tween + snap", () => { + test("scrollTo lands exactly at the target after round(durMs·hz/1000) frames", () => { + const s = createScroller({ max: () => 1000 }); + s.scrollTo(400, { durMs: 200 }); + const t = trace(s); + expect(t.length).toBe(12); // 200 ms at 60 Hz + expect(t[t.length - 1]).toBe(400); + }); + + test("scrollTo immediate jumps and clamps", () => { + const s = createScroller({ max: () => 300 }); + s.scrollTo(999, { immediate: true }); + expect(s.offset()).toBe(300); + expect(s.state()).toBe("idle"); + }); + + test("snap receives the projected rest and wins over the raw fling", () => { + const seen: number[] = []; + const s = createScroller({ + max: () => 10_000, + snap: (projected) => { + seen.push(projected); + return Math.round(projected / 100) * 100; + }, + }); + fling(s, 600); + expect(s.state()).toBe("tween"); + const t = trace(s); + expect(seen).toHaveLength(1); + expect(t[t.length - 1] % 100).toBe(0); + }); +}); + +describe("rebase + settle callback", () => { + test("rebase shifts the offset and in-flight anchors", () => { + const s = createScroller({ max: () => 10_000 }); + s.chaseTo(100); + s.step(); + const before = s.offset(); + s.rebase(500); + expect(s.offset()).toBe(before + 500); + const t = trace(s); + expect(t[t.length - 1]).toBe(600); // target rebased too + }); + + test("onSettle fires once per rest with the final offset", () => { + const settles: number[] = []; + const s = createScroller({ max: () => 1000, onSettle: (p) => settles.push(p) }); + s.chaseTo(80); + trace(s); + expect(settles).toEqual([80]); + }); +}); + +describe("bindDpadScroll", () => { + test("held DOWN nudges the target 6 px per frame; UP the reverse", () => { + const s = createScroller({ max: () => 1000 }); + bindDpadScroll(s); + runFrameHooks(BTN.DOWN); + runFrameHooks(BTN.DOWN); + expect(s.state()).toBe("chase"); + const t = trace(s); + expect(t[t.length - 1]).toBe(12); + runFrameHooks(BTN.UP); + expect(trace(s)[0]).toBeLessThan(12); + }); + + test("the analog nub scales by nubPx and respects the active gate", () => { + let active = true; + const s = createScroller({ max: () => 1000 }); + bindDpadScroll(s, { active: () => active }); + __setAnalog(((128 << 8) | 255) >>> 0); // full-down nub + const nub = analogY(); + expect(nub).toBeGreaterThan(0.9); + runFrameHooks(0); + const t = trace(s); + expect(t[t.length - 1]).toBeCloseTo(nub * 10, 9); + active = false; + runFrameHooks(BTN.DOWN); + expect(s.state()).toBe("idle"); // gated: no new chase + __setAnalog(undefined); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index ccf8d8b..d608b5f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ "@pocketjs/framework/components": ["./framework/src/components.ts"], "@pocketjs/framework/gesture": ["./framework/src/gesture.ts"], "@pocketjs/framework/input": ["./framework/src/input-api.ts"], + "@pocketjs/framework/kinetics": ["./framework/src/kinetics.ts"], "@pocketjs/framework/lifecycle": ["./framework/src/lifecycle.ts"], "@pocketjs/framework/manifest": ["./framework/src/manifest/index.ts"], "@pocketjs/framework/platform": ["./framework/src/platform.ts"],