@clankagent/puck 0.2.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.
package/AGENTS.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Package development
2
2
 
3
+ For requested Puck updates, complete implementation, tests, linked changelog/docs,
4
+ commit and push, CI verification, versioned npm publication, and a clean install
5
+ check before reporting completion. Stop only for an actual blocker or an explicit
6
+ source-only request.
7
+
3
8
  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
9
 
5
10
  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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
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
4
 
5
+ ## 0.3.0 — 2026-09-19
6
+
7
+ - Added opt-in [standalone rx/ry singles and doubles and push/pull + tilt](docs/press-tilt.md).
8
+ Combined modes support pressure relaxation and preserve ordinary doubles.
9
+ Faster execution is accepted; timing allowances are upper bounds.
10
+ - Added `calibrateTilts`, `calibratePressTilts`, matching graph constructors,
11
+ and immutable, serializable tuning fields. Each family needs three examples
12
+ of its eight action types; freeform order and multiple captures are supported.
13
+ - Updated the lab with automatic family detection, an explicit analysis selector,
14
+ standalone controls, per-action inspection and reusable tune export.
15
+ - Fixed empty/disconnected captures being accepted as usable recordings. Partial
16
+ captures survive disconnect; simulator capture requires explicit test mode.
17
+ - Defined [1.0 readiness criteria](docs/roadmap.md). APIs remain experimental.
18
+
19
+ Existing gesture behavior is unchanged unless enabled. The TypeScript direction
20
+ union expands and combined events have an optional tilt field; see the
21
+ [upgrade guide](docs/upgrading.md#from-02-to-03). Tune/recording format stays 1,
22
+ and old tune JSON is supported. [Compare versions](https://github.com/clankagent/puck/compare/v0.2.0...v0.3.0).
23
+
5
24
  ## 0.2.0 — 2026-09-19
6
25
 
7
26
  ### Added
@@ -35,4 +54,4 @@ This is a source-history reference, not a claim that an npm release was publishe
35
54
  - Optional WebHID connection, foreground lifecycle resets and cleanup.
36
55
  - ESM and TypeScript declarations, no runtime dependencies.
37
56
 
38
- [Baseline source](https://github.com/clankagent/puck/tree/f065d4a) · [Motion behavior and hardware](docs/motion.md)
57
+ [Baseline source](https://github.com/clankagent/puck/tree/f065d4a) · [Motion behavior and hardware](docs/motion.md)
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
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
- **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.
5
+ **Version note:** these docs target 0.3.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 adopting updates.
6
6
 
7
7
  ## Start here
8
8
 
@@ -16,6 +16,8 @@ Six-axis input, responsive pan/zoom, and tunable single/double gestures for Spac
16
16
  | Fix input or calibration problems | [Troubleshooting](docs/troubleshooting.md) |
17
17
  | Integrate using an agent | [Agent integration guide](docs/agents.md), [plain-text index](llms.txt) |
18
18
  | Try and record real input | [Gesture lab](docs/lab.md) |
19
+ | Understand the next release and 1.0 criteria | [Roadmap](docs/roadmap.md) |
20
+ | Add standalone or pressure-first tilts | [Push/pull + tilt](docs/press-tilt.md) |
19
21
 
20
22
  For a registry release, install with `pnpm add @clankagent/puck`. For this checkout:
21
23
 
@@ -41,11 +43,11 @@ const gestures = createGestures(tune);
41
43
  |---|---|
42
44
  | Core `@clankagent/puck` | Decode reports; process motion and gestures; record samples; derive tunes. No browser globals, timers or storage. |
43
45
  | Optional `/webhid` | Device chooser, reports and connection lifecycle. |
44
- | Optional `/graph` | Four-lane graph data and a standalone SVG renderer. |
46
+ | Optional `/graph` | Gesture-family graph data and a standalone SVG renderer. |
45
47
  | Your app | Render loop, action handling, camera, capture controls, persistence and UI. |
46
48
 
47
49
  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.
48
50
 
49
51
  ## Development
50
52
 
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.
53
+ 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.
package/VERIFICATION.md CHANGED
@@ -20,7 +20,7 @@
20
20
  - Fixed mixed report/rAF clock timestamps by using performance.now() in both
21
21
  callbacks. Lifecycle neutral sentinels cancel rather than complete gestures.
22
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.
23
+ verification is recorded below; inferred replay does not establish subjective feel.
24
24
 
25
25
  ## Freeform recording (2026-09-18)
26
26
 
@@ -33,7 +33,7 @@
33
33
  CLI. Replayed double matched the recorded event; a pending final single was
34
34
  reported correctly. Simulator evidence is not physical gesture calibration.
35
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.
36
+ Capture raw data first, then infer suitable separate thresholds from natural use.
37
37
 
38
38
  ## Direction threshold refinement
39
39
 
@@ -44,7 +44,7 @@
44
44
  - 33 tests pass. Synthetic regressions cover gentle vertical input, independent
45
45
  signs, cross-axis pressure during twist, partial release and invalid thresholds.
46
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.
47
+ profile. These are inferred gesture groups, not ground-truth accuracy labels.
48
48
 
49
49
  ## Reusable tune / calibration SDK
50
50
 
@@ -76,3 +76,17 @@
76
76
  reusable examples ship, while the lab server and raw recordings stay excluded.
77
77
  - Gesture APIs remain experimental and are not yet an npm release. Source delivery
78
78
  includes the gesture lab; captured device recordings remain outside the repository.
79
+
80
+ ## 0.3 gesture scope
81
+
82
+ Synthetic tests cover four standalone directions, singles/doubles, brief neutral
83
+ valleys, pressure-first vs tilt-first ownership, reversals, holds, lifecycle
84
+ resets, multi-capture calibration and graph overlays. Combined recognition was
85
+ replayed against physical freeform input at original, 2× and 3× timestamps;
86
+ 39 inferred combinations and four plain actions were preserved. The original
87
+ 35-action simple reference remained unchanged with both families enabled.
88
+ Two standalone captures supply at least three inferred singles and doubles per
89
+ direction; interrupted/outlying excursions are excluded. Inferred labels are
90
+ not ground truth or an accuracy score. No new hardware compatibility is claimed.
91
+ Ordinary pan/zoom false-positive rates and multi-session robustness remain
92
+ 1.0 validation work. Raw captures and user metadata are not distributed.
@@ -1,4 +1,4 @@
1
- import type { GestureDirection } from './gestures.js';
1
+ import type { PulseDirection as GestureDirection } from './gestures.js';
2
2
  import type { GestureTune } from './tune.js';
3
3
  import type { GestureRecording } from './recording.js';
4
4
  export declare const gestureDirections: readonly ['clockwise', 'counterclockwise', 'push', 'pull'];
@@ -2,7 +2,7 @@ import { createGestureTune } from './tune.js';
2
2
  import { validateGestureRecording } from './recording.js';
3
3
  export const gestureDirections = ['clockwise', 'counterclockwise', 'push', 'pull'];
4
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;
5
+ const mean = (values) => values[0] + values.reduce((a, b) => a + (b - values[0]), 0) / values.length;
6
6
  const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
7
7
  /** Infer actions from raw shape and timing, independent of event labels and action order.
8
8
  * Closely spaced singles can be indistinguishable from doubles: inferred counts are not ground truth.
@@ -18,6 +18,10 @@ export function calibrateGestures(input, settings = {}) {
18
18
  function episode(points, direction, recording, segment) {
19
19
  if (points.length < 3 || points[0].v > floor || points[points.length - 1].v > floor)
20
20
  return;
21
+ if ((direction === 'push' || direction === 'pull') && points.some(p => p.tilt >= .25)) {
22
+ issues.push('Excluded a press with tilt; use calibratePressTilts for combined gestures.');
23
+ return;
24
+ }
21
25
  const peaks = [];
22
26
  for (let i = 1; i < points.length - 1; i++)
23
27
  if (points[i].v >= Math.max(.06, floor * 2.5) && points[i].v >= points[i - 1].v && points[i].v > points[i + 1].v)
@@ -74,7 +78,7 @@ export function calibrateGestures(input, settings = {}) {
74
78
  }
75
79
  if (row.type !== 'input')
76
80
  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) };
81
+ 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), tilt: Math.max(Math.abs(row.input.rx), Math.abs(row.input.ry)) };
78
82
  if (point.v > floor) {
79
83
  if (!run.length)
80
84
  run = previous ? [{ ...previous, t: point.t }] : [];
@@ -1,7 +1,30 @@
1
1
  import type { InputState } from './input.js';
2
2
  import type { GestureTune } from './tune.js';
3
- export type GestureDirection = 'clockwise' | 'counterclockwise' | 'push' | 'pull';
3
+ export type PulseDirection = 'clockwise' | 'counterclockwise' | 'push' | 'pull';
4
+ export type TiltDirection = 'rx+' | 'rx-' | 'ry+' | 'ry-';
5
+ export type GestureDirection = PulseDirection | TiltDirection;
6
+ export type PressMode = 'simple' | 'tilt' | 'auto';
4
7
  export interface GestureOptions {
8
+ standaloneTilt?: boolean;
9
+ tiltXActivation?: number;
10
+ tiltXRelease?: number;
11
+ tiltYActivation?: number;
12
+ tiltYRelease?: number;
13
+ standaloneMinPulseMs?: number;
14
+ standaloneMaxPulseMs?: number;
15
+ standaloneNeutralMs?: number;
16
+ standaloneDoubleMs?: number;
17
+ /** Simple preserves 0.2 behavior; tilt suppresses plain singles; auto chooses. Plain doubles work in all modes. */
18
+ pressMode?: PressMode;
19
+ pushMode?: PressMode;
20
+ pullMode?: PressMode;
21
+ tiltActivation?: number;
22
+ tiltRelease?: number;
23
+ tiltMinMs?: number;
24
+ tiltArmMs?: number;
25
+ tiltRelaxMs?: number;
26
+ tiltMaxMs?: number;
27
+ tiltDominance?: number;
5
28
  activation?: number;
6
29
  release?: number;
7
30
  /** Optional axis-specific thresholds; fall back to activation/release. */
@@ -28,6 +51,8 @@ export interface GestureOptions {
28
51
  }
29
52
  export interface GestureEvent {
30
53
  direction: GestureDirection;
54
+ /** Present for a combined push/pull + tilt. Such events have kind single. */
55
+ tilt?: TiltDirection;
31
56
  kind: 'single' | 'double';
32
57
  timestamp: number;
33
58
  durationMs: number;
package/dist/gestures.js CHANGED
@@ -8,18 +8,44 @@ export function createGestures(configuration = defaultGestureTune) {
8
8
  const pressRelease = options.pressRelease ?? options.release;
9
9
  const twistActivation = options.twistActivation ?? options.activation ?? defaultGestureTune.rotation.activation;
10
10
  const twistRelease = options.twistRelease ?? options.release ?? defaultGestureTune.rotation.release;
11
+ const standalone = options.standaloneTilt ?? false;
12
+ const st = defaultGestureTune.standaloneTilt;
13
+ const tx = { activation: options.tiltXActivation ?? st.rx.activation, release: options.tiltXRelease ?? st.rx.release };
14
+ const ty = { activation: options.tiltYActivation ?? st.ry.activation, release: options.tiltYRelease ?? st.ry.release };
11
15
  const thresholds = {
12
16
  clockwise: { activation: options.clockwiseActivation ?? twistActivation, release: options.clockwiseRelease ?? twistRelease },
13
17
  counterclockwise: { activation: options.counterclockwiseActivation ?? twistActivation, release: options.counterclockwiseRelease ?? twistRelease },
14
18
  push: { activation: options.pushActivation ?? pressActivation ?? defaultGestureTune.push.activation, release: options.pushRelease ?? pressRelease ?? defaultGestureTune.push.release },
15
19
  pull: { activation: options.pullActivation ?? pressActivation ?? defaultGestureTune.pull.activation, release: options.pullRelease ?? pressRelease ?? defaultGestureTune.pull.release },
20
+ 'rx+': tx, 'rx-': tx, 'ry+': ty, 'ry-': ty,
16
21
  };
17
22
  const minPulseMs = options.minPulseMs ?? 35;
18
23
  const maxPulseMs = options.maxPulseMs ?? 650;
19
24
  const neutralMs = options.neutralMs ?? defaultGestureTune.timing.neutralMs;
20
25
  const doubleMs = options.doubleMs ?? defaultGestureTune.timing.doubleMs;
21
26
  const singleMode = options.singleMode ?? 'exclusive';
27
+ const standaloneMin = options.standaloneMinPulseMs ?? st.timing.minPulseMs, standaloneMax = options.standaloneMaxPulseMs ?? st.timing.maxPulseMs;
28
+ const standaloneNeutral = options.standaloneNeutralMs ?? st.timing.neutralMs, standaloneDouble = options.standaloneDoubleMs ?? st.timing.doubleMs;
29
+ if (typeof standalone !== 'boolean' || ![standaloneMin, standaloneMax, standaloneNeutral, standaloneDouble].every(Number.isFinite) || standaloneMin < 0 || standaloneMax < standaloneMin || standaloneNeutral < 0 || standaloneDouble < 0)
30
+ throw new RangeError('Invalid standalone tilt options.');
31
+ const isTilt = (d) => d.startsWith('rx') || d.startsWith('ry');
32
+ const dwell = (d) => isTilt(d) ? standaloneNeutral : neutralMs;
33
+ const doubleWindow = (d) => isTilt(d) ? standaloneDouble : doubleMs;
22
34
  const dominance = options.dominance ?? 1.4;
35
+ const modes = { push: options.pushMode ?? options.pressMode ?? 'simple', pull: options.pullMode ?? options.pressMode ?? 'simple' };
36
+ const tiltActivation = options.tiltActivation ?? defaultGestureTune.pressTilt.force.activation, tiltRelease = options.tiltRelease ?? defaultGestureTune.pressTilt.force.release;
37
+ const tiltMinMs = options.tiltMinMs ?? 25, tiltArmMs = options.tiltArmMs ?? 450;
38
+ const tiltRelaxMs = options.tiltRelaxMs ?? 180, tiltMaxMs = options.tiltMaxMs ?? 1000;
39
+ const tiltDominance = options.tiltDominance ?? 1.25;
40
+ const combinedEnabled = modes.push !== 'simple' || modes.pull !== 'simple';
41
+ const tiltEnabled = combinedEnabled || standalone;
42
+ const tiltMode = (d) => (d === 'push' || d === 'pull') && modes[d] !== 'simple';
43
+ if ([options.pressMode, options.pushMode, options.pullMode].some(v => v !== undefined && !['simple', 'tilt', 'auto'].includes(v))
44
+ || ![tiltActivation, tiltRelease, tiltMinMs, tiltArmMs, tiltRelaxMs, tiltMaxMs, tiltDominance].every(Number.isFinite)
45
+ || tiltRelease < 0 || tiltActivation <= tiltRelease || tiltActivation > 1 || tiltDominance <= 1
46
+ || tiltMinMs < 0 || tiltArmMs < tiltMinMs || tiltArmMs > tiltMaxMs || tiltMaxMs > 10000
47
+ || tiltRelaxMs < 0 || tiltRelaxMs > tiltArmMs || (combinedEnabled && singleMode !== 'exclusive'))
48
+ throw new RangeError('Invalid press-tilt options; combined gestures require exclusive single mode.');
23
49
  if (![activation, release, twistActivation, twistRelease, minPulseMs, maxPulseMs, neutralMs, doubleMs, dominance].every(Number.isFinite)
24
50
  || release < 0 || activation <= release || activation > 1 || minPulseMs < 0
25
51
  || twistRelease < 0 || twistActivation <= twistRelease || twistActivation > 1
@@ -28,13 +54,28 @@ export function createGestures(configuration = defaultGestureTune) {
28
54
  || !['exclusive', 'immediate'].includes(singleMode))
29
55
  throw new RangeError('Invalid gesture options.');
30
56
  let last = -Infinity;
31
- let z = 0, rz = 0;
57
+ let z = 0, rz = 0, rx = 0, ry = 0;
32
58
  let phase = 'blocked';
33
59
  let active = null;
34
60
  let releasedAt = null;
35
61
  let pending = null;
36
- const neutral = () => Math.abs(z) <= thresholds[z >= 0 ? 'push' : 'pull'].release
37
- && Math.abs(rz) <= thresholds[rz >= 0 ? 'clockwise' : 'counterclockwise'].release;
62
+ const tiltNeutral = () => Math.max(Math.abs(rx), Math.abs(ry)) <= tiltRelease;
63
+ const standaloneRest = () => Math.abs(rx) <= tx.release && Math.abs(ry) <= ty.release;
64
+ const neutral = () => active && isTilt(active.direction) ? standaloneRest() : Math.abs(z) <= thresholds[z >= 0 ? 'push' : 'pull'].release
65
+ && Math.abs(rz) <= thresholds[rz >= 0 ? 'clockwise' : 'counterclockwise'].release
66
+ && (!(active ? tiltMode(active.direction) : combinedEnabled) || tiltNeutral())
67
+ && (Boolean(active) || !standalone || standaloneRest());
68
+ const allowSingle = (e) => e.direction !== 'push' && e.direction !== 'pull' || modes[e.direction] !== 'tilt';
69
+ const pendingStart = () => pending.timestamp - neutralMs - pending.durationMs;
70
+ const canResume = (t) => pending && tiltMode(pending.direction) && t <= pending.timestamp - neutralMs + tiltRelaxMs && t <= pendingStart() + tiltArmMs;
71
+ const deadline = (e) => e.timestamp + (tiltMode(e.direction) ? Math.max(doubleMs, tiltRelaxMs + tiltMinMs) : doubleWindow(e.direction));
72
+ function tiltDirection() {
73
+ if (Math.abs(rx) >= tiltActivation && Math.abs(rx) >= Math.abs(ry) * tiltDominance)
74
+ return rx > 0 ? 'rx+' : 'rx-';
75
+ if (Math.abs(ry) >= tiltActivation && Math.abs(ry) >= Math.abs(rx) * tiltDominance)
76
+ return ry > 0 ? 'ry+' : 'ry-';
77
+ return undefined;
78
+ }
38
79
  function clock(t) {
39
80
  if (!Number.isFinite(t) || t < last)
40
81
  throw new RangeError('Gesture timestamps must be finite and monotonic.');
@@ -42,20 +83,36 @@ export function createGestures(configuration = defaultGestureTune) {
42
83
  }
43
84
  function tick(t) {
44
85
  const events = [];
45
- if (phase === 'releasing' && releasedAt !== null && active && t >= releasedAt + neutralMs) {
86
+ if (active?.candidate && active.candidateAt !== undefined && t >= active.candidateAt + tiltMinMs
87
+ && (!active.tilt || active.candidate === active.tilt || active.candidatePeak > active.tiltPeak * tiltDominance)) {
88
+ if (canResume(active.candidateAt) && pending.direction === active.direction) {
89
+ active.start = pendingStart();
90
+ pending = null;
91
+ }
92
+ active.tilt = active.candidate;
93
+ active.tiltPeak = Math.max(active.tiltPeak ?? 0, active.candidatePeak ?? 0);
94
+ }
95
+ if (phase === 'releasing' && releasedAt !== null && active && t >= releasedAt + dwell(active.direction)) {
46
96
  const durationMs = releasedAt - active.start;
47
- const completedAt = releasedAt + neutralMs;
48
- if (durationMs >= minPulseMs && durationMs <= maxPulseMs) {
97
+ const completedAt = releasedAt + dwell(active.direction);
98
+ if (active.tilt && durationMs <= tiltMaxMs) {
99
+ // Combined gestures consume one excursion; repeats are separate actions, not tilt doubles.
100
+ if (pending && allowSingle(pending))
101
+ events.push({ ...pending, timestamp: Math.min(completedAt, deadline(pending)) });
102
+ pending = null;
103
+ events.push({ direction: active.direction, tilt: active.tilt, kind: 'single', timestamp: completedAt, durationMs });
104
+ }
105
+ else if (!active.borrowed && !active.attemptedTilt && durationMs >= (isTilt(active.direction) ? standaloneMin : minPulseMs) && durationMs <= (isTilt(active.direction) ? standaloneMax : maxPulseMs)) {
49
106
  const pulse = { direction: active.direction, kind: 'single', timestamp: completedAt, durationMs };
50
- if (pending && pending.direction === pulse.direction && completedAt - pending.timestamp <= doubleMs) {
107
+ if (pending && pending.direction === pulse.direction && completedAt - pending.timestamp <= doubleWindow(pulse.direction)) {
51
108
  events.push({ ...pulse, kind: 'double' });
52
109
  pending = null;
53
110
  }
54
111
  else {
55
- if (pending && singleMode === 'exclusive')
56
- events.push({ ...pending, timestamp: Math.min(completedAt, pending.timestamp + doubleMs) });
112
+ if (pending && singleMode === 'exclusive' && allowSingle(pending))
113
+ events.push({ ...pending, timestamp: Math.min(completedAt, deadline(pending)) });
57
114
  pending = pulse;
58
- if (singleMode === 'immediate')
115
+ if (singleMode === 'immediate' && allowSingle(pulse))
59
116
  events.push(pulse);
60
117
  }
61
118
  }
@@ -63,36 +120,65 @@ export function createGestures(configuration = defaultGestureTune) {
63
120
  releasedAt = null;
64
121
  phase = 'neutral';
65
122
  }
66
- if (active && phase === 'active' && t - active.start > maxPulseMs) {
123
+ if (active && phase === 'active' && t - active.start > (isTilt(active.direction) ? standaloneMax : active.tilt || active.candidate ? tiltMaxMs : maxPulseMs)) {
67
124
  phase = 'blocked';
68
125
  active = null;
69
126
  }
70
127
  // A double is defined by completion-to-completion time, including neutral dwell.
71
- if (pending && t > pending.timestamp + doubleMs) {
72
- if (singleMode === 'exclusive')
73
- events.push({ ...pending, timestamp: pending.timestamp + doubleMs });
128
+ if (pending && t > deadline(pending)) {
129
+ if (singleMode === 'exclusive' && allowSingle(pending))
130
+ events.push({ ...pending, timestamp: deadline(pending) });
74
131
  pending = null;
75
132
  }
76
133
  return events;
77
134
  }
78
135
  return {
79
136
  get state() { return { phase, direction: active?.direction ?? null, pending: pending?.direction ?? null }; },
80
- reset() { last = -Infinity; z = rz = 0; active = pending = null; releasedAt = null; phase = 'blocked'; },
137
+ reset() { last = -Infinity; z = rz = rx = ry = 0; active = pending = null; releasedAt = null; phase = 'blocked'; },
81
138
  advance(t) { clock(t); return tick(t); },
82
139
  update(input, t) {
83
- if (!Number.isFinite(input.z) || !Number.isFinite(input.rz))
140
+ if (![input.z, input.rz, ...(tiltEnabled ? [input.rx, input.ry] : [])].every(Number.isFinite))
84
141
  throw new RangeError('Gesture axes must be finite.');
85
142
  clock(t);
86
143
  const events = tick(t);
144
+ const tiltWasBelowActivation = Math.max(Math.abs(rx), Math.abs(ry)) < tiltActivation;
87
145
  z = Math.max(-1, Math.min(1, input.z));
88
146
  rz = Math.max(-1, Math.min(1, input.rz));
147
+ if (tiltEnabled) {
148
+ rx = Math.max(-1, Math.min(1, input.rx));
149
+ ry = Math.max(-1, Math.min(1, input.ry));
150
+ }
89
151
  if (phase === 'blocked') {
90
152
  if (neutral())
91
153
  phase = 'neutral';
92
154
  return events;
93
155
  }
156
+ const side = tiltEnabled ? tiltDirection() : undefined;
157
+ if (!active && side && canResume(t)) {
158
+ active = { direction: pending.direction, start: pendingStart(), borrowed: true, tiltEligible: true };
159
+ phase = 'active';
160
+ }
94
161
  if (active) {
95
- const signed = active.direction === 'push' ? z : active.direction === 'pull' ? -z : active.direction === 'clockwise' ? rz : -rz;
162
+ if (tiltMode(active.direction) && active.tiltEligible) {
163
+ if (Math.max(Math.abs(rx), Math.abs(ry)) >= tiltActivation)
164
+ active.attemptedTilt = true;
165
+ if (side && (active.tilt || active.candidate === side || t - active.start <= tiltArmMs)) {
166
+ const strength = Math.abs(side.startsWith('rx') ? rx : ry);
167
+ if (active.candidate !== side) {
168
+ active.candidate = side;
169
+ active.candidateAt = t;
170
+ active.candidatePeak = strength;
171
+ }
172
+ else
173
+ active.candidatePeak = Math.max(active.candidatePeak ?? 0, strength);
174
+ }
175
+ else {
176
+ active.candidate = undefined;
177
+ active.candidateAt = undefined;
178
+ active.candidatePeak = undefined;
179
+ }
180
+ }
181
+ const signed = active.direction === 'push' ? z : active.direction === 'pull' ? -z : active.direction === 'clockwise' ? rz : active.direction === 'counterclockwise' ? -rz : (active.direction.startsWith('rx') ? rx : ry) * (active.direction.endsWith('+') ? 1 : -1);
96
182
  if (neutral()) {
97
183
  if (releasedAt === null)
98
184
  releasedAt = t;
@@ -111,11 +197,30 @@ export function createGestures(configuration = defaultGestureTune) {
111
197
  }
112
198
  else {
113
199
  const vertical = Math.abs(z) >= Math.abs(rz);
114
- const strong = vertical ? Math.abs(z) : Math.abs(rz);
115
- const weak = vertical ? Math.abs(rz) : Math.abs(z);
116
- const direction = vertical ? (z > 0 ? 'push' : 'pull') : (rz > 0 ? 'clockwise' : 'counterclockwise');
200
+ let strong = vertical ? Math.abs(z) : Math.abs(rz);
201
+ let weak = vertical ? Math.abs(rz) : Math.abs(z);
202
+ let direction = vertical ? (z > 0 ? 'push' : 'pull') : (rz > 0 ? 'clockwise' : 'counterclockwise');
203
+ if (standalone) {
204
+ const axis = Math.abs(rx) >= Math.abs(ry) ? 'rx' : 'ry';
205
+ const sideStrength = Math.abs(axis === 'rx' ? rx : ry);
206
+ if (sideStrength >= (axis === 'rx' ? tx.activation : ty.activation)) {
207
+ if (sideStrength > strong) {
208
+ weak = Math.max(strong, Math.abs(axis === 'rx' ? ry : rx));
209
+ strong = sideStrength;
210
+ direction = (axis + ((axis === 'rx' ? rx : ry) > 0 ? '+' : '-'));
211
+ }
212
+ else
213
+ weak = Math.max(weak, sideStrength);
214
+ }
215
+ }
117
216
  if (strong >= thresholds[direction].activation && strong >= weak * dominance) {
118
- active = { direction, start: t };
217
+ active = { direction, start: t, tiltEligible: tiltWasBelowActivation };
218
+ if (tiltMode(direction) && active.tiltEligible && side) {
219
+ active.candidate = side;
220
+ active.candidateAt = t;
221
+ active.candidatePeak = Math.abs(side.startsWith('rx') ? rx : ry);
222
+ active.attemptedTilt = true;
223
+ }
119
224
  phase = 'active';
120
225
  }
121
226
  }
package/dist/graph.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- import type { GestureDirection } from './gestures.js';
1
+ import type { PulseDirection as GestureDirection, TiltDirection } from './gestures.js';
2
+ import type { PressTiltAction } from './press-tilt-calibration.js';
3
+ import type { TiltCalibrationAction } from './tilt-calibration.js';
2
4
  import type { GestureTune } from './tune.js';
3
5
  import type { GestureRecording } from './recording.js';
4
6
  import type { CalibrationAction } from './calibration.js';
5
7
  export interface GestureGraphLane {
6
- direction: GestureDirection;
8
+ direction: GestureDirection | TiltDirection;
7
9
  label: string;
8
10
  center: number;
9
11
  low: number;
@@ -18,6 +20,7 @@ export interface GestureGraphLane {
18
20
  start: number;
19
21
  end: number;
20
22
  kind: 'single' | 'double';
23
+ label?: string;
21
24
  }[];
22
25
  }
23
26
  export interface GestureGraph {
@@ -32,6 +35,20 @@ export declare function createGestureGraph(recording: GestureRecording, tune?: G
32
35
  actions?: readonly CalibrationAction[];
33
36
  recordingIndex?: number;
34
37
  }): GestureGraph;
38
+ /** Pressure and tilt in six separate lanes, with each inferred combination labeled on both. */
39
+ export declare function createPressTiltGraph(recording: GestureRecording, tune?: GestureTune, config?: {
40
+ start?: number;
41
+ end?: number;
42
+ actions?: readonly PressTiltAction[];
43
+ recordingIndex?: number;
44
+ }): GestureGraph;
45
+ /** Four standalone tilt lanes with independent rx/ry bands and single/double action spans. */
46
+ export declare function createTiltGraph(recording: GestureRecording, tune?: GestureTune, config?: {
47
+ start?: number;
48
+ end?: number;
49
+ actions?: readonly TiltCalibrationAction[];
50
+ recordingIndex?: number;
51
+ }): GestureGraph;
35
52
  /** Standalone accessible SVG. No DOM, canvas, frameworks or global listeners. */
36
53
  export declare function renderGestureGraphSvg(graph: GestureGraph, options?: {
37
54
  width?: number;
package/dist/graph.js CHANGED
@@ -24,7 +24,59 @@ export function createGestureGraph(recording, tune = defaultGestureTune, config
24
24
  });
25
25
  return { start, end, lanes };
26
26
  }
27
+ /** Pressure and tilt in six separate lanes, with each inferred combination labeled on both. */
28
+ export function createPressTiltGraph(recording, tune = defaultGestureTune, config = {}) {
29
+ const graph = createGestureGraph(recording, tune, { start: config.start, end: config.end });
30
+ const actions = (config.actions ?? []).filter(a => a.recording === (config.recordingIndex ?? 0) && a.end >= graph.start && a.start <= graph.end);
31
+ graph.lanes = graph.lanes.filter(l => l.direction === 'push' || l.direction === 'pull');
32
+ for (const direction of ['rx+', 'rx-', 'ry+', 'ry-']) {
33
+ const axis = direction.startsWith('rx') ? 'rx' : 'ry', sign = direction.endsWith('+') ? 1 : -1;
34
+ const points = [];
35
+ let held = 0;
36
+ for (const row of recording.timeline) {
37
+ if (row.t > graph.end)
38
+ break;
39
+ if (row.type === 'input')
40
+ held = Math.max(0, row.input[axis] * sign);
41
+ else if (row.type === 'reset')
42
+ held = 0;
43
+ else
44
+ continue;
45
+ if (row.t < graph.start)
46
+ continue;
47
+ if (!points.length)
48
+ points.push({ t: graph.start, value: 0 });
49
+ points.push({ t: row.t, value: held });
50
+ }
51
+ // Seed the left edge with the held sample preceding the requested window.
52
+ let before = 0;
53
+ for (const row of recording.timeline) {
54
+ if (row.t >= graph.start)
55
+ break;
56
+ if (row.type === 'input')
57
+ before = Math.max(0, row.input[axis] * sign);
58
+ else if (row.type === 'reset')
59
+ before = 0;
60
+ }
61
+ if (!points.length)
62
+ points.push({ t: graph.start, value: before });
63
+ else
64
+ points[0].value = before;
65
+ points.push({ t: graph.end, value: held });
66
+ graph.lanes.push({ direction, label: `Tilt ${direction} (device axis)`, ...(tune.pressTilt ?? defaultGestureTune.pressTilt).force, points, actions: [] });
67
+ }
68
+ for (const lane of graph.lanes)
69
+ lane.actions = actions.filter(a => a.direction === lane.direction || a.tilt === lane.direction).map(a => ({ start: a.start, end: a.end, kind: 'single', label: `${a.direction} + ${a.tilt}` }));
70
+ return graph;
71
+ }
27
72
  const escape = (value) => value.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
73
+ /** Four standalone tilt lanes with independent rx/ry bands and single/double action spans. */
74
+ export function createTiltGraph(recording, tune = defaultGestureTune, config = {}) {
75
+ const graph = createPressTiltGraph(recording, tune, { start: config.start, end: config.end });
76
+ const bands = tune.standaloneTilt ?? defaultGestureTune.standaloneTilt;
77
+ graph.lanes = graph.lanes.filter(l => l.direction.startsWith('r')).map(l => ({ ...l, ...(l.direction.startsWith('rx') ? bands.rx : bands.ry), actions: (config.actions ?? []).filter(a => a.recording === (config.recordingIndex ?? 0) && a.direction === l.direction && a.end >= graph.start && a.start <= graph.end).map(a => ({ start: a.start, end: a.end, kind: a.kind })) }));
78
+ return graph;
79
+ }
28
80
  /** Standalone accessible SVG. No DOM, canvas, frameworks or global listeners. */
29
81
  export function renderGestureGraphSvg(graph, options = {}) {
30
82
  const width = options.width ?? 960;
@@ -32,15 +84,15 @@ export function renderGestureGraphSvg(graph, options = {}) {
32
84
  throw new RangeError('SVG width must be between 280 and 4000.');
33
85
  const height = graph.lanes.length * 150 + 64, left = 45, right = 20, plotWidth = width - left - right;
34
86
  const x = (t) => left + (Math.max(graph.start, Math.min(graph.end, t)) - graph.start) / (graph.end - graph.start) * plotWidth;
35
- const svg = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escape(options.title ?? 'Gesture recording: four independent directions')}"><desc>Blue is input strength, green dashed is activation, gray dotted is release, pale blue is the typical peak range. Shaded time spans mark inferred actions. Stronger inputs remain accepted above the typical range.</desc><rect width="100%" height="100%" fill="white"/><g font-family="Segoe UI,Arial,sans-serif" font-size="12" fill="#283c55">`];
87
+ const svg = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escape(options.title ?? 'Gesture recording: independent input directions')}"><desc>Blue is input strength, green dashed is activation, gray dotted is release, pale blue is the typical peak range. Shaded time spans mark inferred actions. Stronger inputs remain accepted above the typical range.</desc><rect width="100%" height="100%" fill="white"/><g font-family="Segoe UI,Arial,sans-serif" font-size="12" fill="#283c55">`];
36
88
  graph.lanes.forEach((lane, index) => {
37
89
  const top = 30 + index * 150, y = (v) => top + 100 - Math.max(0, Math.min(1, v)) * 76;
38
90
  svg.push(`<text x="${left}" y="${top}" font-size="15" font-weight="600">${escape(lane.label)}</text><text x="${width - right}" y="${top}" text-anchor="end" font-size="11">peak ${lane.center.toFixed(2)}</text><text x="${left}" y="${top + 19}">Activate ${lane.activation.toFixed(2)} · release ${lane.release.toFixed(2)}</text>`);
39
91
  svg.push(`<rect x="${left}" y="${y(lane.high)}" width="${plotWidth}" height="${y(lane.low) - y(lane.high)}" fill="#edf3fc"/>`);
40
92
  for (const a of lane.actions) {
41
- svg.push(`<rect x="${x(a.start)}" y="${top + 24}" width="${Math.max(2, x(a.end) - x(a.start))}" height="76" fill="#cfdfef" opacity=".45"/>`);
42
- if (x(a.end) - x(a.start) > 42)
43
- svg.push(`<text x="${x(a.start) + 3}" y="${top + 116}" font-size="11">${a.kind}</text>`);
93
+ svg.push(`<rect x="${x(a.start)}" y="${top + 24}" width="${Math.max(2, x(a.end) - x(a.start))}" height="76" fill="#cfdfef" opacity=".45"><title>${escape(a.label ?? a.kind)}</title></rect>`);
94
+ if (x(a.end) - x(a.start) > (a.label ? 90 : 42))
95
+ svg.push(`<text x="${x(a.start) + 3}" y="${top + 116}" font-size="11">${escape(a.label ?? a.kind)}</text>`);
44
96
  }
45
97
  for (const [v, color, dash] of [[0, '#afbac8', ''], [lane.activation, '#237442', '6 4'], [lane.release, '#68778b', '2 4']])
46
98
  svg.push(`<line x1="${left}" x2="${width - right}" y1="${y(v)}" y2="${y(v)}" stroke="${color}" stroke-dasharray="${dash}"/>`);
package/dist/index.d.ts CHANGED
@@ -3,10 +3,14 @@ export type { InputState } from './input.js';
3
3
  export { createPanZoom } from './motion.js';
4
4
  export type { PanZoomController, PanZoomOptions, MotionDelta, ZoomInput } from './motion.js';
5
5
  export { createGestures } from './gestures.js';
6
- export type { GestureDirection, GestureOptions, GestureEvent, GestureRecognizer } from './gestures.js';
6
+ export type { GestureDirection, PulseDirection, TiltDirection, PressMode, GestureOptions, GestureEvent, GestureRecognizer } from './gestures.js';
7
7
  export { createGestureTune, defaultGestureTune, gesturePresets } from './tune.js';
8
- export type { GestureTune, GestureTuneData, ForceBand } from './tune.js';
8
+ export type { GestureTune, GestureTuneData, ForceBand, PressTiltTune, StandaloneTiltTune } from './tune.js';
9
9
  export { createGestureRecorder, validateGestureRecording } from './recording.js';
10
10
  export type { GestureRecorder, GestureRecording, RecordingEntry } from './recording.js';
11
11
  export { calibrateGestures, gestureDirections } from './calibration.js';
12
12
  export type { GestureCalibration, CalibrationAction, CalibrationPulse, CalibrationStats, GestureActionName } from './calibration.js';
13
+ export { calibratePressTilts, tiltDirections } from './press-tilt-calibration.js';
14
+ export type { PressTiltCalibration, PressTiltAction, PressTiltActionName } from './press-tilt-calibration.js';
15
+ export { calibrateTilts } from './tilt-calibration.js';
16
+ export type { TiltCalibration, TiltCalibrationAction, TiltActionName } from './tilt-calibration.js';
package/dist/index.js CHANGED
@@ -4,3 +4,5 @@ export { createGestures } from './gestures.js';
4
4
  export { createGestureTune, defaultGestureTune, gesturePresets } from './tune.js';
5
5
  export { createGestureRecorder, validateGestureRecording } from './recording.js';
6
6
  export { calibrateGestures, gestureDirections } from './calibration.js';
7
+ export { calibratePressTilts, tiltDirections } from './press-tilt-calibration.js';
8
+ export { calibrateTilts } from './tilt-calibration.js';
@@ -0,0 +1,32 @@
1
+ import type { TiltDirection } from './gestures.js';
2
+ import type { GestureRecording } from './recording.js';
3
+ import type { GestureTune } from './tune.js';
4
+ export declare const tiltDirections: readonly ['rx+', 'rx-', 'ry+', 'ry-'];
5
+ export type PressTiltActionName = `${'push' | 'pull'}.${TiltDirection}`;
6
+ export interface PressTiltAction {
7
+ direction: 'push' | 'pull';
8
+ tilt: TiltDirection;
9
+ kind: 'single';
10
+ recording: number;
11
+ start: number;
12
+ end: number;
13
+ pressurePeak: number;
14
+ tiltPeak: number;
15
+ pressureAtTiltPeak: number;
16
+ onsetDelayMs: number;
17
+ }
18
+ export interface PressTiltCalibration {
19
+ status: 'ready' | 'incomplete' | 'ambiguous';
20
+ tune: GestureTune | null;
21
+ counts: Record<PressTiltActionName, number>;
22
+ missing: PressTiltActionName[];
23
+ actions: PressTiltAction[];
24
+ issues: string[];
25
+ }
26
+ /** Infer combined single excursions from raw z/rx/ry. Preserves the base simple/double tune.
27
+ * Coverage is inferred evidence, not ground-truth labels or measured classification accuracy.
28
+ */
29
+ export declare function calibratePressTilts(input: GestureRecording | readonly GestureRecording[], settings?: {
30
+ baseTune?: GestureTune;
31
+ minimumPerAction?: number;
32
+ }): PressTiltCalibration;