@clankagent/puck 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,9 @@
1
+ # Package development
2
+
3
+ Puck is a public library hosted at https://github.com/clankagent/puck, published as @clankagent/puck. Maintain the focused library scope: source, tests, documentation and small code examples. Do not add a website, test page, local server or router setup unless explicitly requested.
4
+
5
+ Keep the package self-contained. Do not introduce references to private applications, internal servers, local recordings, machine paths, or user metadata. No dependencies on surrounding workspaces. Preserve the established feel when extracting or optimizing; validate timing and neutral behavior with tests.
6
+
7
+ Input transport, motion processing, and application rendering are separate responsibilities. The core owns no DOM, global listeners or render loop. Browser lifecycle belongs to the optional adapter; camera limits and zoom anchors belong to the consuming application. Avoid framework adapters and additional device abstractions without a concrete use case.
8
+
9
+ Use pnpm. This library uses the TypeScript compiler and Node's test runner; no Vite application is being created. Run `pnpm check` before delivery. Additional device profiles require evidence; do not claim untested compatibility. Synthetic fixtures are generated from documented report layouts and physical observations, without original capture metadata.
package/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ User-facing changes, compatibility impact, and links for adopting them. **Unreleased** describes source on main, not an available npm version. Package versions and tune/recording JSON format versions are independent.
4
+
5
+ ## 0.2.0 — 2026-09-19
6
+
7
+ ### Added
8
+
9
+ | Feature | What it enables | Integration docs |
10
+ |---|---|---|
11
+ | Four-direction single/double cap gestures | Clockwise, counterclockwise, push down and pull up actions | [Browser quickstart](docs/quickstart.md), [event and timing API](docs/api.md#gestures) |
12
+ | Immutable gesture tunes | Shared rotation thresholds, independent push/pull thresholds, default/soft/hard presets and chainable edits | [Tune guide](docs/tuning.md#gesture-tunes), [tune API](docs/api.md#tunes) |
13
+ | Bounded recording SDK | Capture reports, clock ticks, resets and events using the app's storage | [Session example](examples/gesture-session.mjs), [recording API](docs/api.md#recording) |
14
+ | Automatic custom calibration | Infer a tune from freeform captures with three examples of each of eight actions, in any order | [Calibration guide](docs/tuning.md#recording-and-custom-calibration), [result handling](docs/api.md#calibration) |
15
+ | Optional graph entry point | Four direction lanes, inferred action spans and an accessible SVG renderer | [Graph guide](docs/tuning.md#graphs-for-consuming-applications), [graph API](docs/api.md#graphs) |
16
+ | Source-only gesture lab | Record, combine captures, inspect actions, edit tunes and export JSON | [Run the lab](docs/lab.md) |
17
+ | User and agent documentation | API reference, troubleshooting, complete integration and a plain-text index | [Documentation index](README.md#start-here), [agent guide](docs/agents.md), [llms.txt](llms.txt) |
18
+
19
+ ### Compatibility and adoption
20
+
21
+ - Existing `createPanZoom`, decoding and WebHID APIs/defaults are unchanged from the original 0.1.0 source baseline. Existing motion integrations need no migration for these additions.
22
+ - Gesture APIs are experimental and opt-in. The app still owns its clock, render loop, actions and storage. Adding gestures does not automatically bind actions or suppress pan/zoom.
23
+ - Exclusive singles wait for the double window; immediate mode emits a single followed by an additive double. Choose deliberately.
24
+ - Tune and recording JSON formats start at `version: 1`. Restore tunes through `createGestureTune`; handle a null calibration tune before applying results.
25
+ - Hardware scope is unchanged. Cap gestures do not add physical button decoding or support for unverified devices.
26
+
27
+ **Adopt the update:** [Upgrade and integration guide](docs/upgrading.md). [Source comparison with the original baseline](https://github.com/clankagent/puck/compare/f065d4a...v0.2.0).
28
+
29
+ ## Original 0.1.0 source baseline
30
+
31
+ This is a source-history reference, not a claim that an npm release was published on a particular date.
32
+
33
+ - Normalized six-axis decoding for the verified combined report profile.
34
+ - Frame-independent pan/zoom with held input, neutral stopping and application-owned camera/rendering.
35
+ - Optional WebHID connection, foreground lifecycle resets and cleanup.
36
+ - ESM and TypeScript declarations, no runtime dependencies.
37
+
38
+ [Baseline source](https://github.com/clankagent/puck/tree/f065d4a) · [Motion behavior and hardware](docs/motion.md)
@@ -0,0 +1,20 @@
1
+ # Contributing
2
+
3
+ Use Node 24 and the pnpm version in package.json. Run pnpm install and pnpm check.
4
+ Small fixes can go straight to a pull request. For a new API or device profile,
5
+ open an issue describing the use case first so we can agree on the scope.
6
+
7
+ Keep transport, processing and rendering separate. Preserve neutral stopping,
8
+ held input between reports and frame-rate independence. Include a regression
9
+ test for behavioral changes. Avoid adding dependencies or per-frame work without
10
+ a measured need.
11
+
12
+ For device support, include vendor/product IDs, connection type, browser/OS,
13
+ report descriptor and a short sequence of report IDs and hexadecimal payloads
14
+ for neutral, each single-axis gesture, hold and release. Relative timestamps
15
+ are sufficient. Remove serial numbers, device names, account information and
16
+ other personal metadata. Do not infer support from a shared vendor ID.
17
+
18
+ Only describe physical behavior as tested when someone has actually tested it.
19
+ Synthetic report tests establish decoding and lifecycle behavior, not hardware
20
+ compatibility or subjective feel.
package/DESIGN.md ADDED
@@ -0,0 +1,42 @@
1
+ # Architecture and release preparation
2
+
3
+ The product goal is to make responsive six-axis interaction easy to implement correctly. Correct defaults and clear ownership matter more than exposing many switches.
4
+
5
+ ## Boundaries
6
+
7
+ The core consumes normalized deflection and frame timestamps. It produces screen-space pan deltas and a multiplicative zoom factor. It does not own a view transform, renderer, event loop, browser or device. Incoming reports update a held sample; they never directly advance a camera.
8
+
9
+ The optional WebHID adapter decodes one verified device profile, handles explicit device permission and connection lifetime, and forwards samples. It clears input on interruption. A consuming application can pause input when a modal/tool takes ownership, or use the core with another transport entirely.
10
+
11
+ The example camera demonstrates the application boundary: apply the zoom multiplier within app limits, preserve the chosen screen-space anchor using the actual clamped zoom ratio, then apply pan deltas in pixels. This avoids accidentally scaling pan speed with zoom or moving the zoom anchor when a limit is reached.
12
+
13
+ ## Defaults backed by the working prototype
14
+
15
+ Six-axis combined report: six signed little-endian words, logical range ±350. Device profile 256f:c63a. Clockwise twist and downward pressure zoom in. Independent pan/zoom deadzones .05/.1; acceleration response 25 ms; full pan speed 1320 px/s; full log-zoom speed 1.5/s. Integrate the response analytically over elapsed frame time. Neutral stops without coasting; reversal discards old-direction response. Cap unusually long frame integration at 50 ms; do not expire held input based on report silence.
16
+
17
+ These are verified defaults for the tested hardware and interaction. They are not a claim that every device uses the same report layout or that everyone prefers the same axis directions.
18
+
19
+ ## Package shape
20
+
21
+ One ESM TypeScript package, core and `/webhid` exports, no runtime dependencies. Emitted declarations use explicit `.js` relative specifiers and a Node-compatible module resolution mode. Tests import built output, keeping package behavior visible outside the compiler. A renderer can use the core without importing browser glue. [TypeScript library compilation guidance](https://www.typescriptlang.org/docs/handbook/modules/guides/choosing-compiler-options.html#im-writing-a-library), [WebHID lifecycle and reports](https://developer.chrome.com/docs/capabilities/hid).
22
+
23
+ ## Release scope
24
+
25
+ Puck ships a core and an optional WebHID adapter. No website, renderer or server
26
+ is part of the library. The first release documents the measured hardware scope
27
+ and distinguishes prototype physical testing from extracted-adapter tests.
28
+
29
+ ## Tune and calibration API
30
+
31
+ Tune data is immutable, versioned JSON with shared rotation and independent
32
+ push/pull force bands. Method edits copy rather than mutate and do not affect
33
+ timing. Gesture recording is a bounded transport-independent utility; apps
34
+ retain responsibility for storage and permissions. Calibration consumes raw
35
+ samples independently of the old recognizer event log or the order of action
36
+ groups, and requires three examples per action. Uncertain trains and missing
37
+ coverage cannot produce a ready tune.
38
+
39
+ The optional /graph export provides renderer-neutral data and a standalone SVG
40
+ renderer. It draws four unsigned direction lanes instead of overlaying two
41
+ signed axes and four gates. It owns no DOM, camera, subscriptions or frame loop.
42
+ The lab adds selection/zoom and tune editing as application responsibilities.
package/README.md CHANGED
@@ -1,88 +1,51 @@
1
1
  # Puck
2
2
 
3
- A small TypeScript library for responsive SpaceMouse pan and zoom. Zero runtime dependencies.
3
+ Six-axis input, responsive pan/zoom, and tunable single/double gestures for SpaceMouse applications. TypeScript, ESM, zero runtime dependencies. Your app owns its camera, rendering and storage.
4
4
 
5
- Incoming reports update input state. Your animation loop consumes time-based motion. Your application owns the camera and renderer.
5
+ **Version note:** these docs target 0.2.0. Gesture recognition, tunes, recording, calibration and graphs are included, with experimental APIs. See the [changelog](CHANGELOG.md) and [upgrade guide](docs/upgrading.md) when moving from 0.1.0.
6
6
 
7
- ```sh
8
- pnpm add @clankagent/puck
9
- ```
7
+ ## Start here
10
8
 
11
- ```js
12
- import { createPanZoom } from '@clankagent/puck';
13
- import { connectWebHid } from '@clankagent/puck/webhid';
9
+ | You want to… | Read |
10
+ |---|---|
11
+ | See what changed and adopt an update | [Changelog](CHANGELOG.md), [upgrade guide](docs/upgrading.md) |
12
+ | Connect a device and recognize gestures | [Complete browser integration](docs/quickstart.md) |
13
+ | Add smooth pan and zoom | [Motion and hardware](docs/motion.md), [camera arithmetic](examples/camera.mjs) |
14
+ | Set sensitivity or learn a personal tune | [Tunes, recording and calibration](docs/tuning.md) |
15
+ | Look up imports, methods, defaults or units | [API reference](docs/api.md) |
16
+ | Fix input or calibration problems | [Troubleshooting](docs/troubleshooting.md) |
17
+ | Integrate using an agent | [Agent integration guide](docs/agents.md), [plain-text index](llms.txt) |
18
+ | Try and record real input | [Gesture lab](docs/lab.md) |
14
19
 
15
- const motion = createPanZoom({ zoomInput: 'press' });
20
+ For a registry release, install with `pnpm add @clankagent/puck`. For this checkout:
16
21
 
17
- connectButton.onclick = async () => {
18
- connectButton.disabled = true;
19
- try {
20
- connection = await connectWebHid({
21
- onInput: motion.setInput,
22
- onReset: motion.reset,
23
- onDisconnect() { connection = null; connectButton.disabled = false; },
24
- });
25
- } finally {
26
- connectButton.disabled = Boolean(connection);
27
- }
28
- };
22
+ ```sh
23
+ pnpm install
24
+ pnpm check
25
+ ```
29
26
 
30
- function frame(timestamp) {
31
- const delta = motion.step(timestamp);
32
- if (delta.moving) {
33
- // Apply zoom about your chosen anchor, then pan in screen pixels.
34
- camera.zoomAround(viewCenter, delta.zoomFactor);
35
- camera.panBy(delta.panX, delta.panY);
36
- render(camera);
37
- }
38
- requestAnimationFrame(frame);
39
- }
40
- let connection = null;
41
- requestAnimationFrame(frame);
27
+ The build emits `dist/*.js` and matching TypeScript declarations. Examples use package imports; a bundler or browser import map must resolve them. No CommonJS build is provided.
42
28
 
43
- // A control can change this live. Pan and camera position are unaffected.
44
- motion.setZoomInput('twist');
29
+ ```js
30
+ import { createGestures, gesturePresets } from '@clankagent/puck';
45
31
 
46
- // On application teardown:
47
- // await connection?.close();
48
- // Cancel the application's animation frame as part of its own cleanup.
32
+ const tune = gesturePresets.default.soften(0.1).widen(0.15);
33
+ const gestures = createGestures(tune);
34
+ // Feed every report to update(input, time), and tick advance(time).
35
+ // See the complete integration for clocks, lifecycle and cleanup.
49
36
  ```
50
37
 
51
- `camera`, `render`, `viewCenter` and `connectButton` above belong to your app. There is no camera implementation or framework dependency in the package. See `examples/camera.mjs` for the complete anchor calculation, including zoom limits.
52
-
53
- ## Responsibilities
38
+ ## What Puck owns
54
39
 
55
- | Part | Responsibility |
40
+ | Layer | Responsibility |
56
41
  |---|---|
57
- | Decoder | Convert a supported motion report to normalized six-axis cap deflection; ignore status packets. |
58
- | WebHID adapter | Device selection, report delivery, foreground policy and connection cleanup. |
59
- | Pan/zoom controller | Hold the latest input, apply deadzones, integrate response over frame time, emit movement deltas. |
60
- | Your application | Own the render loop, input ownership, camera, zoom anchor, limits, and rendering. |
42
+ | Core `@clankagent/puck` | Decode reports; process motion and gestures; record samples; derive tunes. No browser globals, timers or storage. |
43
+ | Optional `/webhid` | Device chooser, reports and connection lifecycle. |
44
+ | Optional `/graph` | Four-lane graph data and a standalone SVG renderer. |
45
+ | Your app | Render loop, action handling, camera, capture controls, persistence and UI. |
61
46
 
62
- The core imports in Node without a browser. The browser adapter is a separate entry point and accesses browser APIs only when connecting. It starts no animation loop, timers, server, storage, or telemetry.
63
-
64
- ## Motion behavior
65
-
66
- - `step(timestampMs)` uses the timestamp supplied by your render loop, once per frame. The first step produces no movement. It never assumes a display refresh rate.
67
- - A held cap requests velocity. Input report count does not determine movement distance. The latest deflection remains active between reports, including bursty delivery.
68
- - Pan has a 0.05 normalized deadzone and full-deflection speed of 1320 screen pixels/second. Zoom has a 0.1 deadzone and log-speed of 1.5/second. Both use a 25 ms acceleration response.
69
- - Neutral input stops on the next step, without software coasting. Reversal discards response in the old direction.
70
- - `twist`: clockwise zooms in. `press`: downward pressure zooms in, lifting zooms out. These directions refer to the verified profile.
71
- - The default 50 ms frame cap limits jumps after rendering stalls. It is not a timeout for input reports.
72
- - The adapter clears input on blur, hidden state and disconnect. Connect `onReset` to `motion.reset` as above so old response and frame timing are also cleared. Returning to the foreground waits for a fresh report. Use connection `pause()` / `resume()` when another tool owns the input; use controller `reset()` if you provide your own transport.
73
- - Use one controller per independent input stream. Do not drive both the report callback and frame loop with movement updates.
74
-
75
- ## Hardware scope
76
-
77
- The report layout and motion defaults were measured with vendor `0x256f`, product `0xc63a`, over Bluetooth on Windows: report 1, twelve bytes, six signed little-endian 16-bit axes, logical range ±350. Buttons and other layouts are not implemented yet. Other devices require a matching `DeviceProfile`; profile support should be backed by descriptors and captures, not guessed from the vendor alone.
78
-
79
- WebHID requires browser support and device permission. The adapter inherits that availability; the motion core does not. [Official WebHID guide](https://developer.chrome.com/docs/capabilities/hid).
47
+ The verified device profile is vendor `0x256f`, product `0xc63a`, Bluetooth on Windows. Other layouts and physical buttons are not implemented. Gestures are **cap pulses**, not button clicks. See [hardware scope](docs/motion.md#hardware-scope) before adding a profile.
80
48
 
81
49
  ## Development
82
50
 
83
- ```sh
84
- pnpm install
85
- pnpm check
86
- ```
87
-
88
- The build emits ESM and declarations. Tests run against those emitted modules. Runtime dependencies: none. Licensed under MIT. See [CONTRIBUTING.md](CONTRIBUTING.md) for development and device support.
51
+ Use Node 24 and the pnpm version pinned in `package.json`. `pnpm check` builds and runs tests. Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing an API or device profile; [AGENTS.md](AGENTS.md) describes repository development constraints. [DESIGN.md](DESIGN.md) explains architecture and [VERIFICATION.md](VERIFICATION.md) records validation. MIT licensed.
@@ -0,0 +1,78 @@
1
+ # Local preparation checks
2
+
3
+ - TypeScript build and 13 tests pass. Tests cover report bounds/signs, zero and non-motion handling, frame-rate independence, held input through report gaps, reversal, zoom-axis switching, snapshots, render-owned zoom anchors/limits, adapter pause/resume/disconnect, foreground interruption and cleanup.
4
+ - A separate local comparison against the working prototype evaluated 3,558 frames at 60, 144 and 240 Hz, both zoom mappings, neutral/reversal and mixed-axis input. Pan and camera zoom matched exactly (maximum difference zero). The comparison harness remains outside this package.
5
+ - Packed artifact installs into a separate ESM TypeScript consumer. Both public entry points resolve, declarations type-check, and importing the adapter in Node does not access browser globals. The packed artifact contains emitted code/declarations, package metadata and README; original captures and workspace documentation are excluded.
6
+ - The extracted adapter has not yet been exercised with physical hardware. Physical testing established the report layout and motion defaults in a prototype; adapter lifecycle coverage is currently synthetic.
7
+ - Package name: @clankagent/puck. License: MIT. Publication status is available from GitHub releases and the npm registry.
8
+
9
+ ## Gesture experiment (2026-09-18)
10
+
11
+ - `pnpm check`: 26 tests pass. Added four-direction single/double coverage,
12
+ additive behavior, completion-window boundaries, pulse limits, hysteresis,
13
+ neutral dwell, reversal rejection, dominance, cancellation, timestamp validation,
14
+ report bursts, frame-rate independence, and WebHID lifecycle cancellation.
15
+ - Browser QA over HTTPS: pointer double rotation, keyboard single pull, preset
16
+ changes, save/restore comparison, and clear history verified. Zero minimum
17
+ pulse/dwell were used for instantaneous automation; balanced defaults restored.
18
+ - Desktop and 390px phone layouts visually inspected. Input trace and threshold
19
+ lines render; event table fits the phone layout.
20
+ - Fixed mixed report/rAF clock timestamps by using performance.now() in both
21
+ callbacks. Lifecycle neutral sentinels cancel rather than complete gestures.
22
+ - At this stage the gesture work was a local experiment. Later SDK and delivery
23
+ verification is recorded below; inferred replay does not establish subjective feel.
24
+
25
+ ## Freeform recording (2026-09-18)
26
+
27
+ - Recording is the first section. No guided capture or prescribed gesture order.
28
+ - Stores input reports, frame timing, resets and detected events; settings locked
29
+ during capture, two-minute cap, retry/download fallback, and saved session list.
30
+ - 29 tests pass, including schema validation, session-token enforcement,
31
+ save/list/readback, and deterministic replay.
32
+ - Browser simulator recording was saved and then read directly by the analysis
33
+ CLI. Replayed double matched the recorded event; a pending final single was
34
+ reported correctly. Simulator evidence is not physical gesture calibration.
35
+ - User feedback: vertical push/pull activation should be lower than rotation.
36
+ Capture raw data first, then infer suitable separate thresholds from natural use.
37
+
38
+ ## Direction threshold refinement
39
+
40
+ - Added validated axis/direction overrides without changing existing defaults.
41
+ - Lab has one symmetric rotation gate and independent push/pull gates.
42
+ - Recording tuned preset uses rotation activation/release .25/.15, push .20/.08,
43
+ pull .12/.06, neutral dwell 25 ms, and double window 400 ms.
44
+ - 33 tests pass. Synthetic regressions cover gentle vertical input, independent
45
+ signs, cross-axis pressure during twist, partial release and invalid thresholds.
46
+ - Physical freeform replay yields 18 doubles and 17 singles under the candidate
47
+ profile. These are inferred gesture groups, not ground-truth accuracy labels.
48
+
49
+ ## Reusable tune / calibration SDK
50
+
51
+ - Added immutable versioned tune objects, default/soft/hard presets, fractional
52
+ soften/harden/narrow/widen transformations and JSON restoration. Missing
53
+ recognizer configuration now uses the complete default tune consistently.
54
+ - Added bounded recording independent of transport, browser, timers and storage.
55
+ - Added raw-shape calibration, independent of recorded event labels and action
56
+ ordering. At least three of each of eight actions are required; incomplete or
57
+ ambiguous evidence produces no tune. Multiple recordings can be combined;
58
+ reset/session boundaries prevent accidental pairing.
59
+ - 42 tests pass, including shuffled action groups, ambiguity, missing evidence,
60
+ immutable transforms, JSON round trips, recording bounds, default parity and
61
+ graph output. Existing pan/zoom, decoding and lifecycle tests continue to pass.
62
+ - The physical freeform capture passes all eight quotas; the automatically
63
+ generated tune reproduces 18 double and 17 single actions on replay. This is
64
+ consistency with inferred intent, not a claim of labeled accuracy.
65
+ - Browser QA: analyze saved capture, zoom a double, soften/reset/apply tune,
66
+ deduplicate combined captures, desktop/phone graph inspection. Mobile graph
67
+ labels and width were adjusted after visual inspection.
68
+
69
+ ## Documentation and repository delivery (2026-09-18)
70
+
71
+ - 43 tests pass, including the public integration example's recording lifecycle,
72
+ pending-action cancellation and frame cleanup.
73
+ - README navigation, API reference, browser integration, calibration guide,
74
+ troubleshooting and agent guidance are included in the packed artifact.
75
+ - Checked 48 relative documentation links. Package inspection confirms docs and
76
+ reusable examples ship, while the lab server and raw recordings stay excluded.
77
+ - Gesture APIs remain experimental and are not yet an npm release. Source delivery
78
+ includes the gesture lab; captured device recordings remain outside the repository.
@@ -0,0 +1,49 @@
1
+ import type { GestureDirection } from './gestures.js';
2
+ import type { GestureTune } from './tune.js';
3
+ import type { GestureRecording } from './recording.js';
4
+ export declare const gestureDirections: readonly ['clockwise', 'counterclockwise', 'push', 'pull'];
5
+ export type GestureActionName = `${GestureDirection}.${'single' | 'double'}`;
6
+ export interface CalibrationPulse {
7
+ direction: GestureDirection;
8
+ start: number;
9
+ end: number;
10
+ peak: number;
11
+ peakTime: number;
12
+ valleyBefore: number;
13
+ recording: number;
14
+ segment: number;
15
+ }
16
+ export interface CalibrationAction {
17
+ direction: GestureDirection;
18
+ kind: 'single' | 'double';
19
+ start: number;
20
+ end: number;
21
+ pulses: CalibrationPulse[];
22
+ recording: number;
23
+ }
24
+ export interface CalibrationStats {
25
+ count: number;
26
+ center: number;
27
+ low: number;
28
+ high: number;
29
+ min: number;
30
+ max: number;
31
+ }
32
+ export interface GestureCalibration {
33
+ status: 'ready' | 'incomplete' | 'ambiguous';
34
+ tune: GestureTune | null;
35
+ counts: Record<GestureActionName, number>;
36
+ missing: GestureActionName[];
37
+ pulses: CalibrationPulse[];
38
+ actions: CalibrationAction[];
39
+ stats: Partial<Record<GestureDirection, CalibrationStats>>;
40
+ issues: string[];
41
+ }
42
+ /** Infer actions from raw shape and timing, independent of event labels and action order.
43
+ * Closely spaced singles can be indistinguishable from doubles: inferred counts are not ground truth.
44
+ */
45
+ export declare function calibrateGestures(input: GestureRecording | readonly GestureRecording[], settings?: {
46
+ minimumPerAction?: number;
47
+ detectionFloor?: number;
48
+ pairGapMs?: number;
49
+ }): GestureCalibration;
@@ -0,0 +1,135 @@
1
+ import { createGestureTune } from './tune.js';
2
+ import { validateGestureRecording } from './recording.js';
3
+ export const gestureDirections = ['clockwise', 'counterclockwise', 'push', 'pull'];
4
+ const quantile = (values, p) => { const a = [...values].sort((x, y) => x - y); const at = (a.length - 1) * p, lo = Math.floor(at), hi = Math.ceil(at); return a[lo] + (a[hi] - a[lo]) * (at - lo); };
5
+ const mean = (values) => values.reduce((a, b) => a + b, 0) / values.length;
6
+ const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
7
+ /** Infer actions from raw shape and timing, independent of event labels and action order.
8
+ * Closely spaced singles can be indistinguishable from doubles: inferred counts are not ground truth.
9
+ */
10
+ export function calibrateGestures(input, settings = {}) {
11
+ const recordings = Array.isArray(input) ? input : [input];
12
+ const minimum = settings.minimumPerAction ?? 3, floor = settings.detectionFloor ?? .025, pairGap = settings.pairGapMs ?? 250;
13
+ if (!Number.isInteger(minimum) || minimum < 3 || minimum > 100 || !Number.isFinite(floor) || floor <= 0 || floor > .1 || !Number.isFinite(pairGap) || pairGap < 50 || pairGap > 500)
14
+ throw new RangeError('Invalid calibration limits; at least three examples per action are required.');
15
+ if (recordings.length < 1 || recordings.length > 20)
16
+ throw new RangeError('Use one to twenty recordings.');
17
+ const pulses = [], issues = [];
18
+ function episode(points, direction, recording, segment) {
19
+ if (points.length < 3 || points[0].v > floor || points[points.length - 1].v > floor)
20
+ return;
21
+ const peaks = [];
22
+ for (let i = 1; i < points.length - 1; i++)
23
+ if (points[i].v >= Math.max(.06, floor * 2.5) && points[i].v >= points[i - 1].v && points[i].v > points[i + 1].v)
24
+ peaks.push(i);
25
+ const selected = [];
26
+ for (const peak of peaks) {
27
+ if (!selected.length) {
28
+ selected.push(peak);
29
+ continue;
30
+ }
31
+ const prev = selected[selected.length - 1];
32
+ let valley = Infinity;
33
+ for (let j = prev; j <= peak; j++)
34
+ valley = Math.min(valley, points[j].v);
35
+ // Ignore tiny ripples on one excursion. A clear relaxation separates pulses.
36
+ if (valley > Math.min(points[prev].v, points[peak].v) * .45 || points[peak].t - points[prev].t < 70) {
37
+ if (points[peak].v > points[prev].v)
38
+ selected[selected.length - 1] = peak;
39
+ }
40
+ else
41
+ selected.push(peak);
42
+ }
43
+ const bounds = [0];
44
+ for (let i = 1; i < selected.length; i++) {
45
+ let index = selected[i - 1];
46
+ for (let j = index; j <= selected[i]; j++)
47
+ if (points[j].v < points[index].v)
48
+ index = j;
49
+ bounds.push(index);
50
+ }
51
+ bounds.push(points.length - 1);
52
+ selected.forEach((peak, i) => {
53
+ const from = points[bounds[i]], to = points[bounds[i + 1]], top = points[peak], duration = to.t - from.t;
54
+ if (top.v < top.other * 1.4)
55
+ return; // Incidental pressure while twisting, or vice versa.
56
+ if (duration < 45 || duration > 1000) {
57
+ issues.push(`Excluded ${direction} excursion outside 45–1000 ms.`);
58
+ return;
59
+ }
60
+ pulses.push({ direction, start: from.t, end: to.t, peak: top.v, peakTime: top.t, valleyBefore: from.v, recording, segment });
61
+ });
62
+ }
63
+ recordings.forEach((recording, index) => {
64
+ validateGestureRecording(recording);
65
+ for (const direction of gestureDirections) {
66
+ const vertical = direction === 'push' || direction === 'pull', sign = direction === 'push' || direction === 'clockwise' ? 1 : -1;
67
+ let run = [], previous, segment = 0;
68
+ for (const row of recording.timeline) {
69
+ if (row.type === 'reset') {
70
+ run = [];
71
+ previous = undefined;
72
+ segment++;
73
+ continue;
74
+ }
75
+ if (row.type !== 'input')
76
+ continue;
77
+ const point = { t: row.t, v: Math.max(0, sign * (vertical ? row.input.z : row.input.rz)), other: Math.abs(vertical ? row.input.rz : row.input.z) };
78
+ if (point.v > floor) {
79
+ if (!run.length)
80
+ run = previous ? [{ ...previous, t: point.t }] : [];
81
+ run.push(point);
82
+ }
83
+ else if (run.length) {
84
+ run.push(point);
85
+ episode(run, direction, index, segment);
86
+ run = [];
87
+ }
88
+ previous = point;
89
+ }
90
+ if (run.length)
91
+ issues.push(`Unfinished ${direction} excursion at recording end.`);
92
+ }
93
+ });
94
+ pulses.sort((a, b) => a.recording - b.recording || a.start - b.start);
95
+ const actions = [], counts = {};
96
+ for (const d of gestureDirections)
97
+ for (const kind of ['single', 'double'])
98
+ counts[`${d}.${kind}`] = 0;
99
+ const paired = (a, b) => a.recording === b.recording && a.segment === b.segment && a.direction === b.direction && b.start - a.end <= pairGap && b.end - a.end <= 650;
100
+ let ambiguous = false;
101
+ for (let i = 0; i < pulses.length;) {
102
+ let end = i + 1;
103
+ while (end < pulses.length && paired(pulses[end - 1], pulses[end]))
104
+ end++;
105
+ if (end - i > 2) {
106
+ ambiguous = true;
107
+ issues.push(`Ambiguous run of ${end - i} ${pulses[i].direction} pulses near ${(pulses[i].start / 1000).toFixed(1)} s; leave a longer pause between actions.`);
108
+ i = end;
109
+ continue;
110
+ }
111
+ const group = pulses.slice(i, end), kind = group.length === 2 ? 'double' : 'single';
112
+ const action = { direction: group[0].direction, kind, start: group[0].start, end: group[group.length - 1].end, pulses: group, recording: group[0].recording };
113
+ actions.push(action);
114
+ counts[`${action.direction}.${kind}`]++;
115
+ i = end;
116
+ }
117
+ const stats = {};
118
+ for (const d of gestureDirections) {
119
+ const values = pulses.filter(p => p.direction === d).map(p => p.peak);
120
+ if (values.length)
121
+ stats[d] = { count: values.length, center: mean(values), low: quantile(values, .1), high: quantile(values, .9), min: Math.min(...values), max: Math.max(...values) };
122
+ }
123
+ const missing = Object.keys(counts).filter(key => counts[key] < minimum);
124
+ let tune = null;
125
+ if (!missing.length && !ambiguous) {
126
+ function band(d) { const s = stats[d]; const activation = clamp(s.low * .7, .04, s.min * .85); const returns = pulses.filter(p => p.direction === d).map(p => p.valleyBefore); const release = clamp(Math.max(activation * .5, ...returns.map(v => v * 1.2)), .01, activation * .8); return { center: s.center, low: s.low, high: s.high, activation, release }; }
127
+ const cw = band('clockwise'), ccw = band('counterclockwise');
128
+ const rotation = { center: (cw.center + ccw.center) / 2, low: (cw.low + ccw.low) / 2, high: (cw.high + ccw.high) / 2, activation: (cw.activation + ccw.activation) / 2, release: Math.max(cw.release, ccw.release) };
129
+ // Equal rotation gates, while still accommodating the shallower inter-pulse return.
130
+ rotation.release = Math.min(rotation.release, rotation.activation * .85);
131
+ const lengths = pulses.map(p => p.end - p.start), doubles = actions.filter(a => a.kind === 'double').map(a => a.pulses[1].end - a.pulses[0].end);
132
+ tune = createGestureTune({ version: 1, rotation, push: band('push'), pull: band('pull'), dominance: 1.4, timing: { minPulseMs: Math.round(clamp(quantile(lengths, .1) * .2, 15, 60)), maxPulseMs: Math.round(clamp(quantile(lengths, .9) * 2, 350, 1000)), neutralMs: 20, doubleMs: Math.round(clamp(Math.max(...doubles) + 40, 200, 650)) } });
133
+ }
134
+ return { status: ambiguous ? 'ambiguous' : missing.length ? 'incomplete' : 'ready', tune, counts, missing, pulses, actions, stats, issues: [...new Set(issues)] };
135
+ }
@@ -0,0 +1,49 @@
1
+ import type { InputState } from './input.js';
2
+ import type { GestureTune } from './tune.js';
3
+ export type GestureDirection = 'clockwise' | 'counterclockwise' | 'push' | 'pull';
4
+ export interface GestureOptions {
5
+ activation?: number;
6
+ release?: number;
7
+ /** Optional axis-specific thresholds; fall back to activation/release. */
8
+ pressActivation?: number;
9
+ pressRelease?: number;
10
+ twistActivation?: number;
11
+ twistRelease?: number;
12
+ clockwiseActivation?: number;
13
+ clockwiseRelease?: number;
14
+ counterclockwiseActivation?: number;
15
+ counterclockwiseRelease?: number;
16
+ pushActivation?: number;
17
+ pushRelease?: number;
18
+ pullActivation?: number;
19
+ pullRelease?: number;
20
+ minPulseMs?: number;
21
+ maxPulseMs?: number;
22
+ neutralMs?: number;
23
+ doubleMs?: number;
24
+ /** Exclusive waits for a double; immediate emits single, then double (additive). */
25
+ singleMode?: 'exclusive' | 'immediate';
26
+ /** Strongest axis must exceed the other by this ratio. */
27
+ dominance?: number;
28
+ }
29
+ export interface GestureEvent {
30
+ direction: GestureDirection;
31
+ kind: 'single' | 'double';
32
+ timestamp: number;
33
+ durationMs: number;
34
+ }
35
+ export interface GestureRecognizer {
36
+ /** Process every report with a monotonic timestamp; do not discard reports between frames. */
37
+ update(input: Readonly<InputState>, timestampMs: number): GestureEvent[];
38
+ /** Advance pending single/release deadlines, even when no reports arrive. */
39
+ advance(timestampMs: number): GestureEvent[];
40
+ /** Cancel everything. Fresh neutral input is required before rearming. */
41
+ reset(): void;
42
+ readonly state: {
43
+ phase: 'neutral' | 'active' | 'releasing' | 'blocked';
44
+ direction: GestureDirection | null;
45
+ pending: GestureDirection | null;
46
+ };
47
+ }
48
+ /** Experimental pulse recognizer. No timers, DOM, transport, or motion side effects. */
49
+ export declare function createGestures(configuration?: GestureOptions | GestureTune): GestureRecognizer;