@clankagent/puck 0.1.0 → 0.3.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.
@@ -0,0 +1,44 @@
1
+ # Toward Puck 1.0
2
+
3
+ [Documentation](../README.md) · [Changelog](../CHANGELOG.md)
4
+
5
+ 1.0 means the public contracts and interaction behavior are dependable enough
6
+ for applications to adopt without chasing API changes. It does not mean every
7
+ SpaceMouse model or every possible gesture is supported.
8
+
9
+ ## 0.3: tilt families, still experimental
10
+
11
+ 0.3 adds push/pull + rx/ry tilt and standalone tilt singles/doubles, with
12
+ explicit arbitration and ordinary doubles preserved. Defaults use freeform
13
+ physical evidence, supplemented by synthetic boundary tests. This is enough
14
+ for an experimental release, not for 1.0: next evaluate unintended activations
15
+ during ordinary pan/zoom, repeat sessions, and real application adoption.
16
+ The contracts and calibration heuristics remain open to refinement.
17
+
18
+ ## Requirements for 1.0
19
+
20
+ - **Stable contracts:** settle event shapes, press modes, tune editing and
21
+ persisted recording/tune formats. Define backward-compatible restoration and
22
+ errors, and publish a migration guide for any pre-1.0 changes.
23
+ - **Predictable interactions:** cover boundary timing, mixed gestures, diagonal
24
+ input, incidental cross-axis motion, holds, reversals, report silence,
25
+ cancellation and double presses. Identical reports must produce identical
26
+ events regardless of render cadence.
27
+ - **Physical evidence:** evaluate ordinary and combined gestures on real
28
+ recordings, including unintended activations during pan/zoom. Separate
29
+ inferred intent from known intended actions; publish the tested scope and
30
+ remaining uncertainty without shipping private captures.
31
+ - **Honest calibration:** provide useful coverage and ambiguity feedback for
32
+ every gesture family advertised as automatically tunable. Do not treat the
33
+ existing eight-action calibration as evidence for directional gestures.
34
+ - **Usable integration:** ship complete browser and transport-independent
35
+ examples, typed configuration, accessible recording graphs, lifecycle tests,
36
+ and clear guidance for keeping gesture actions and camera movement exclusive.
37
+ - **Release reliability:** verify CI, the packaged documentation and public
38
+ exports, then install and exercise the actual registry release. Keep changelog
39
+ entries linked to adoption documentation.
40
+
41
+ Additional device profiles, physical buttons, framework adapters and built-in
42
+ rendering are not prerequisites. Add them only for concrete, verified use cases.
43
+ Until these requirements are met, keep gesture APIs experimental and use 0.x
44
+ releases to refine them. There is no date-based promise for 1.0.
@@ -0,0 +1,27 @@
1
+ # Troubleshooting
2
+
3
+ [Documentation](../README.md) · [API reference](api.md)
4
+
5
+ | Symptom | Check / fix |
6
+ |---|---|
7
+ | Import or graph entry point missing | Gesture and graph APIs require 0.2.0 or later. Check your installed version and package exports; see the upgrade guide. |
8
+ | Bare import fails in browser | Resolve package imports with a bundler or import map. A browser cannot resolve npm package names by itself. |
9
+ | WebHID unavailable / chooser fails | Use a supporting browser and secure context; connect from a user click and display the caught error. Core processing works without WebHID. |
10
+ | Chooser cancelled | A null connection is expected; allow another click. |
11
+ | Device selected but no usable input | Verify the exact profile/report format. Matching vendor alone does not establish support. |
12
+ | First gesture ignored | Startup/reset is blocked until a fresh neutral input. Start recording before moving the cap. |
13
+ | Single feels delayed | Exclusive mode waits for the double window (default 400 ms). Immediate mode fires a single first and then an additive double; it does not undo the single. |
14
+ | Double becomes two singles | Inspect the completion interval and neutral valley. Check doubleMs and release/dwell settings. Do not blindly increase the activation threshold. |
15
+ | Stronger gesture rejected | Typical high is not a rejection cutoff. Check hold duration, competing axis dominance, and neutral return. |
16
+ | Push/pull harder than twist | Use the separate push/pull bands or calibrate; do not force identical thresholds on all axes. |
17
+ | Nothing fires during report silence | Keep calling advance using the same monotonic clock as update. |
18
+ | Clock throws RangeError | Use performance.now() in both report and frame callbacks for gestures. rAF's supplied timestamp can be older than a report already processed. |
19
+ | Actions fire when leaving the page | Treat the WebHID lifecycle sentinel as reset, not a physical release. See the complete session example. |
20
+ | Calibration incomplete | Show missing/counts; add at least three singles and three doubles per direction. Combining distinct captures is supported. |
21
+ | Calibration ambiguous | Close singles may resemble a double; runs of three or more fast pulses are ambiguous. Inspect raw graph and repeat only unclear actions with pauses between independent actions. |
22
+ | Saved tune has no methods | JSON stores data only. Call createGestureTune(JSON.parse(saved)). |
23
+ | Tune edit throws on assignment | Tunes and toJSON data are frozen. Copy nested objects before editing, or use the immutable methods. |
24
+ | Recorder stops adding entries | Inspect full; stop/save and start another capture. Defaults are two minutes / 50000 timeline entries. |
25
+ | Graph overlays wrong session | Pass the zero-based recordingIndex used in the combined calibration. |
26
+
27
+ When reporting a problem, include package version, runtime/browser, OS, device IDs and connection type, relevant configuration, expected/actual events and a minimal synthetic reproduction when possible. Remove personal information from any raw capture. See [contribution guidance](../CONTRIBUTING.md) for evidence needed for a new profile.
package/docs/tuning.md ADDED
@@ -0,0 +1,146 @@
1
+ # Tunes, recording and calibration
2
+
3
+ [Documentation](../README.md) · [Complete integration](quickstart.md) · [API reference](api.md)
4
+
5
+ The snippets below explain individual operations. Use the complete integration example for connection, capture start/stop and cleanup.
6
+
7
+ ## Gesture tunes
8
+
9
+ The experimental gesture API uses an immutable tune object. The default profile
10
+ has a shared rotation center of .465, push .314, and pull .289. It uses rotation
11
+ activation/release .25/.15, push .20/.08, pull .12/.06, neutral dwell 25 ms, and a
12
+ 400 ms double window. These are useful starting defaults, not universal hardware
13
+ calibration. Pan/zoom behavior is unchanged.
14
+
15
+ ```js
16
+ import {
17
+ createGestures, createGestureTune, defaultGestureTune, gesturePresets,
18
+ } from '@clankagent/puck';
19
+
20
+ const tune = defaultGestureTune.soften(0.1).widen(0.15);
21
+ const gestures = createGestures(tune);
22
+ // createGestures() uses defaultGestureTune.
23
+ // Also available: gesturePresets.default, .soft (20% softer), .hard (20% harder).
24
+
25
+ const saved = JSON.stringify(tune);
26
+ const restored = createGestureTune(JSON.parse(saved));
27
+ const another = restored.harden(0.05).narrow(0.1);
28
+ ```
29
+
30
+ Every edit returns a new deeply frozen object, including its nested bands.
31
+ Amounts are fractions: `.soften(.1)` lowers force levels by 10%; `.harden(.1)`
32
+ raises them by 10%. Levels saturate at normalized full scale. `.narrow(.1)`
33
+ reduces distances from the center by 10%, moving the activation gate toward the
34
+ center; `.widen(.1)` expands them and lowers the activation gate. Release remains
35
+ proportional to activation. These methods edit force only; timing remains unchanged.
36
+ Softening and narrowing require amounts in [0,1); hardening and widening accept
37
+ [0,10]. Edits are composable, not algebraic inverses: softening by 10% and then
38
+ hardening by 10% gives 99% of the original force.
39
+
40
+ Each `rotation`, `push`, and `pull` band contains `center`, `low`, `high`,
41
+ `activation`, and `release`. Low/high describe typical peak variation; they are
42
+ not rejection boundaries. Stronger-than-typical input still counts. Rotation
43
+ always shares a band between clockwise and counterclockwise. `toJSON()` returns
44
+ portable versioned data; `toOptions()` returns the low-level recognizer options.
45
+ For precise timing edits, copy both `toJSON()` and its nested `timing` object, then call
46
+ `createGestureTune(data)` to validate it.
47
+
48
+ The low-level `createGestures(options)` overload remains available. Missing
49
+ settings use the default tune; `createGestures({})` matches `createGestures()`.
50
+ Explicit signed-direction gates override axis gates, which override shared
51
+ activation/release values. Prefer a tune object for complete configuration.
52
+ ## Recording and custom calibration
53
+
54
+ A complete calibration needs at least **three of each of eight actions**:
55
+ clockwise single/double, counterclockwise single/double, push single/double,
56
+ and pull single/double. Grouping examples is convenient but not required.
57
+ Use short pulses and leave a pause between separate actions. The parser uses
58
+ raw deflection and timing, not the order of action groups or recorded event labels.
59
+
60
+ ```js
61
+ import {
62
+ createGestureRecorder, createGestures, defaultGestureTune,
63
+ calibrateGestures, neutralInput,
64
+ } from '@clankagent/puck';
65
+
66
+ const gestures = createGestures(defaultGestureTune);
67
+ const recorder = createGestureRecorder({
68
+ startTimeMs: performance.now(), tune: defaultGestureTune, source: 'device',
69
+ });
70
+
71
+ // Call for every report; don't discard reports between render frames.
72
+ function onInput(input) {
73
+ const now = performance.now();
74
+ if (input === neutralInput) { // WebHID lifecycle sentinel, not physical release
75
+ gestures.reset(); recorder.reset(now); return;
76
+ }
77
+ recorder.input(input, now);
78
+ const events = gestures.update(input, now);
79
+ recorder.events(events);
80
+ consume(events); // Your application handles recognized actions.
81
+ }
82
+ function frame() {
83
+ const now = performance.now(); // Same clock used by onInput.
84
+ recorder.advance(now);
85
+ const events = gestures.advance(now);
86
+ recorder.events(events);
87
+ consume(events);
88
+ requestAnimationFrame(frame);
89
+ }
90
+ // Wire WebHID onReset to reset both processors as well.
91
+ // Start your frame loop and pass onInput to your transport.
92
+
93
+ const recording = recorder.snapshot(performance.now()); // At the end of capture.
94
+ // Your app can download JSON.stringify(recording) or save it to its own backend.
95
+ const result = calibrateGestures(recording);
96
+ // Or combine sessions: calibrateGestures([recordingA, recordingB]).
97
+ if (result.tune !== null) {
98
+ const tunedGestures = createGestures(result.tune);
99
+ // Save JSON.stringify(result.tune) for the next session.
100
+ }
101
+ ```
102
+
103
+ The SDK owns no timers, listeners, storage or network access. The recorder copies
104
+ samples, has a default two-minute / 50,000-entry bound, reports `full`, and
105
+ returns detached snapshots. Apps own start/stop and storage. Raw records include
106
+ six axes, relative timestamps, frame ticks, resets, and optional recognized
107
+ events. The latest input remains held during report silence. Reset cancels
108
+ recognition and requires a fresh neutral sample. Use `performance.now()` in both
109
+ callbacks; rAF's supplied timestamp can precede a report already processed.
110
+
111
+ Calibration returns `status`, `tune`, `counts`, `missing`, `stats`, `pulses`,
112
+ `actions`, and `issues`. Incomplete or ambiguous input returns `tune: null`;
113
+ counts never claim labeled ground-truth accuracy. Quick same-direction singles
114
+ can be indistinguishable from an intended double. Runs of three or more closely
115
+ spaced pulses are flagged as ambiguous. Separate sessions and resets are never
116
+ paired. Long holds, unfinished excursions, and very short spikes are excluded.
117
+
118
+ The initial parser uses a normalized .025 detection floor, local peak/valley
119
+ shape and a 250 ms maximum inter-pulse gap for pairs. These can be configured
120
+ with `detectionFloor` and `pairGapMs`. `minimumPerAction` can raise the requirement
121
+ but cannot go below three. Candidate centers are per-direction mean peak force;
122
+ rotation uses the unweighted average of the two direction means. Typical bands
123
+ use interpolated 10th/90th percentiles. Gates and timing are derived conservatively
124
+ from observed weaker peaks, return valleys, pulse lengths and double intervals.
125
+ Calibration infers intended actions; it is not evidence of subjective feel or
126
+ new hardware compatibility.
127
+
128
+ ## Graphs for consuming applications
129
+
130
+ ```js
131
+ import { createGestureGraph, renderGestureGraphSvg } from '@clankagent/puck/graph';
132
+
133
+ const model = createGestureGraph(recording, result.tune ?? defaultGestureTune, {
134
+ actions: result.actions,
135
+ // Optional start/end in milliseconds to inspect an individual action.
136
+ });
137
+ container.innerHTML = renderGestureGraphSvg(model, { width: 900 });
138
+ ```
139
+
140
+ `createGestureGraph` returns renderer-neutral data for four separate positive
141
+ magnitude lanes. The optional SVG renderer has no DOM dependencies: it labels
142
+ directions, typical peaks, activation and release, preserves held samples as
143
+ steps, and highlights inferred action intervals. Its text is escaped. For
144
+ combined calibrations, pass the corresponding recording and `recordingIndex`.
145
+ Apps can use the model with their own chart library instead. Only graph a tune
146
+ when available; incomplete calibration can be inspected with the default tune.
@@ -0,0 +1,59 @@
1
+ # Upgrading and adopting updates
2
+
3
+ [Changelog](../CHANGELOG.md) · [Documentation](../README.md) · [API reference](api.md)
4
+
5
+ ## Identify the version first
6
+
7
+ Read the changelog for the version you are adopting, then use docs from that same tag or installed package. Main can contain unreleased features. `pnpm list @clankagent/puck` shows your app's installed version; its exports and declarations determine which APIs are available.
8
+
9
+ Gesture, tune, recording, calibration and graph APIs are included in 0.2.0. In your consuming app:
10
+
11
+ ```sh
12
+ pnpm add @clankagent/puck@0.3.0
13
+ pnpm list @clankagent/puck
14
+ ```
15
+
16
+ Commit your app's updated manifest and lockfile. Use the documentation shipped with that package or the v0.3.0 source tag. The new gesture APIs remain experimental; existing motion integrations retain their behavior.
17
+
18
+ To test future unreleased source, build a chosen revision with `pnpm install --frozen-lockfile`, `pnpm check` and `pnpm pack --pack-destination artifacts`, then install the generated tarball into your app with `pnpm add /path/to/package.tgz`. Record the source commit alongside it; a working-tree package version alone does not identify unreleased changes.
19
+
20
+ ## From 0.2 to 0.3
21
+
22
+ No new gesture family is enabled automatically. Add `standaloneTilt:true` for
23
+ rx/ry singles and doubles; add `pressMode:'auto'` or `'tilt'` for push/pull +
24
+ tilt combinations. Keep `singleMode:'exclusive'` when combined modes are enabled.
25
+ See the [tilt integration guide](press-tilt.md) for event examples, default gates,
26
+ freeform calibration and graphs. Feed every physical report and advance the clock
27
+ as before; camera arbitration remains application-owned.
28
+
29
+ `GestureDirection` now also includes four rx/ry directions. TypeScript consumers
30
+ with exhaustive direction maps must extend them, even if the feature stays off.
31
+ Use exported `PulseDirection` for APIs intentionally limited to the original four.
32
+ Combined events add an optional `tilt`; route those before handling plain pressure.
33
+ Tune version stays 1 with optional new fields; old tune JSON is supported.
34
+
35
+ ## Existing pan/zoom applications
36
+
37
+ No migration is required for the new opt-in features: decoding, `createPanZoom` and `/webhid` behavior and defaults are unchanged from the original baseline. Keep your existing camera, input ownership and render loop.
38
+
39
+ ## Add only what your app needs
40
+
41
+ | Goal | Add | Application responsibility |
42
+ |---|---|---|
43
+ | React to single/double gestures | `createGestures(tune)` | Feed every report, advance the same clock, reset on lifecycle changes, consume events. [Complete integration](quickstart.md) |
44
+ | Offer softer/harder controls | Presets and immutable tune methods | Retain the returned tune and create a new recognizer to apply it. [Tune editing](tuning.md#gesture-tunes) |
45
+ | Save a personal tune | `createGestureTune` | Save JSON; restore through the constructor. [Tune API](api.md#tunes) |
46
+ | Learn a tune from normal input | Recorder plus `calibrateGestures` | Own capture/storage; show missing/ambiguous evidence and apply only a non-null tune. [Calibration](tuning.md#recording-and-custom-calibration) |
47
+ | Explain the learned result | Optional `/graph` imports | Show a text status/count summary and select the matching capture for overlays. [Graphs](api.md#graphs) |
48
+
49
+ To add gestures alongside motion, reuse the existing connection and frame loop. Fan physical reports out to both processors. Ignore the exact `neutralInput` lifecycle sentinel for gestures and reset both processors in `onReset`. Use `performance.now()` in both gesture report and frame callbacks. The [session example](../examples/gesture-session.mjs) demonstrates lifecycle handling; adapt it to your existing connection/loop rather than starting duplicate ones.
50
+
51
+ ## Behavior to choose explicitly
52
+
53
+ - **Single versus double:** exclusive singles wait for the double window. Immediate mode is additive: a double also produces the earlier single. It does not undo an action.
54
+ - **Gesture versus camera:** both processors may react to the same movement. Your app decides whether a gesture tool pauses camera motion or permits both.
55
+ - **Applying a tune:** changing a variable does not retune an existing recognizer. Replace the recognizer or reconnect the example session with the new tune; pending actions are discarded and fresh neutral is required.
56
+ - **Calibration quality:** require three singles and three doubles in each direction. Group order does not matter. Closely spaced singles can look like a double; inferred counts are not labeled accuracy. Avoid duplicate captures as new evidence.
57
+ - **Data versions:** package versions are separate from tune/recording `version: 1`. Validate restored data through public APIs; do not manually change a format version to bypass validation.
58
+
59
+ Before shipping, check a single, double, held input, neutral rearming, blur/disconnect and teardown. Check JSON restoration and incomplete calibration if used. [Troubleshooting](troubleshooting.md) maps common symptoms to fixes.
@@ -0,0 +1,56 @@
1
+ import { createGestures, createGestureRecorder, defaultGestureTune, neutralInput } from '@clankagent/puck';
2
+ import { connectWebHid } from '@clankagent/puck/webhid';
3
+
4
+ /** Call from a click handler. Returns null when the chooser is cancelled. */
5
+ export async function connectGestureSession({ tune = defaultGestureTune, options = {}, onEvents = console.log, onDisconnect = () => {} } = {}) {
6
+ const configuration = {...tune.toOptions(),...options};
7
+ const gestures = createGestures(configuration);
8
+ let recorder = null;
9
+ let frameId;
10
+ let stopped = false;
11
+ function emit(events) { recorder?.events(events); if (events.length) onEvents(events); }
12
+ function reset() { gestures.reset(); recorder?.reset(performance.now()); }
13
+ const connection = await connectWebHid({
14
+ onInput(input) {
15
+ // Lifecycle sentinel is followed by onReset, not a physical release.
16
+ if (input === neutralInput) return;
17
+ const now = performance.now();
18
+ recorder?.input(input, now);
19
+ emit(gestures.update(input, now));
20
+ },
21
+ onReset: reset,
22
+ onDisconnect() { stopped = true; cancelAnimationFrame(frameId); onDisconnect(); },
23
+ });
24
+ if (!connection) return null;
25
+ function frame() {
26
+ if (stopped) return;
27
+ // Same current clock as reports, not rAF's earlier frame timestamp.
28
+ const now = performance.now();
29
+ recorder?.advance(now);
30
+ emit(gestures.advance(now));
31
+ frameId = requestAnimationFrame(frame);
32
+ }
33
+ frameId = requestAnimationFrame(frame);
34
+ return {
35
+ startRecording() {
36
+ if (stopped) throw new Error('Session has ended.');
37
+ if (recorder) throw new Error('Stop the current recording first.');
38
+ gestures.reset();
39
+ recorder = createGestureRecorder({ startTimeMs: performance.now(), options:configuration, source: 'device' });
40
+ },
41
+ stopRecording() {
42
+ if (!recorder) return null;
43
+ const recording = recorder.snapshot(performance.now());
44
+ recorder = null;
45
+ return recording;
46
+ },
47
+ get recordingFull() { return recorder?.full ?? false; },
48
+ pause: connection.pause,
49
+ resume: connection.resume,
50
+ async close() {
51
+ stopped = true;
52
+ cancelAnimationFrame(frameId);
53
+ await connection.close();
54
+ },
55
+ };
56
+ }
package/llms.txt ADDED
@@ -0,0 +1,29 @@
1
+ # Puck
2
+
3
+ > TypeScript/ESM six-axis input, pan/zoom, tunable cap gestures and freeform calibration. Zero runtime dependencies.
4
+
5
+ Documentation targets 0.3.0, including experimental gesture/tune/recording/calibration/graph APIs. Match documentation to your installed version.
6
+
7
+ ## Documentation
8
+
9
+ - [Overview and version status](README.md)
10
+ - [Changelog and compatibility](CHANGELOG.md)
11
+ - [Upgrade and integration paths](docs/upgrading.md)
12
+ - [Complete integration](docs/quickstart.md)
13
+ - [API reference: imports, units, defaults, lifecycle](docs/api.md)
14
+ - [Tunes, recording and calibration](docs/tuning.md)
15
+ - [Motion and hardware scope](docs/motion.md)
16
+ - [Troubleshooting](docs/troubleshooting.md)
17
+ - [Agent integration invariants](docs/agents.md)
18
+ - [Runnable session wrapper](examples/gesture-session.mjs)
19
+ - [Camera arithmetic](examples/camera.mjs)
20
+
21
+ ## Source development
22
+
23
+ - [Contribution workflow](CONTRIBUTING.md)
24
+ - [Repository agent instructions](AGENTS.md)
25
+ - [Local gesture lab](docs/lab.md)
26
+ - [Next release and 1.0 criteria](docs/roadmap.md)
27
+ - [Standalone and combined tilt API](docs/press-tilt.md)
28
+
29
+ Exact build signatures are in dist/index.d.ts, dist/webhid.d.ts and dist/graph.d.ts. Import from @clankagent/puck, @clankagent/puck/webhid and @clankagent/puck/graph only.
package/package.json CHANGED
@@ -1,15 +1,27 @@
1
1
  {
2
2
  "name": "@clankagent/puck",
3
- "version": "0.1.0",
4
- "description": "Frame-independent six-axis input and pan/zoom motion for the web",
3
+ "version": "0.3.0",
4
+ "description": "Frame-independent six-axis input, pan/zoom and tunable gestures",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "files": [
8
8
  "dist",
9
9
  "README.md",
10
- "examples/camera.mjs"
10
+ "CHANGELOG.md",
11
+ "docs",
12
+ "llms.txt",
13
+ "examples/camera.mjs",
14
+ "examples/gesture-session.mjs",
15
+ "AGENTS.md",
16
+ "CONTRIBUTING.md",
17
+ "DESIGN.md",
18
+ "VERIFICATION.md"
11
19
  ],
12
20
  "exports": {
21
+ "./graph": {
22
+ "types": "./dist/graph.d.ts",
23
+ "import": "./dist/graph.js"
24
+ },
13
25
  ".": {
14
26
  "types": "./dist/index.d.ts",
15
27
  "import": "./dist/index.js"
@@ -42,6 +54,8 @@
42
54
  "input"
43
55
  ],
44
56
  "scripts": {
57
+ "lab": "node examples/gestures/serve.mjs",
58
+ "lab:analyze": "node examples/gestures/analyze-recording.mjs",
45
59
  "build": "tsc -p tsconfig.json",
46
60
  "test": "node --test tests/*.test.mjs",
47
61
  "check": "pnpm run build && pnpm test"