@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.
@@ -0,0 +1,88 @@
1
+ import { validateGestureRecording } from './recording.js';
2
+ import { createGestureTune, defaultGestureTune } from './tune.js';
3
+ export const tiltDirections = ['rx+', 'rx-', 'ry+', 'ry-'];
4
+ const quantile = (v, p) => { const a = [...v].sort((x, y) => x - y), at = (a.length - 1) * p, i = Math.floor(at); return a[i] + (a[Math.ceil(at)] - a[i]) * (at - i); };
5
+ /** Infer combined single excursions from raw z/rx/ry. Preserves the base simple/double tune.
6
+ * Coverage is inferred evidence, not ground-truth labels or measured classification accuracy.
7
+ */
8
+ export function calibratePressTilts(input, settings = {}) {
9
+ const recordings = Array.isArray(input) ? input : [input];
10
+ const minimum = settings.minimumPerAction ?? 3, base = settings.baseTune ?? defaultGestureTune;
11
+ if (!Number.isInteger(minimum) || minimum < 3 || minimum > 100 || recordings.length < 1 || recordings.length > 20)
12
+ throw new RangeError('Use 1–20 recordings and at least three examples per action.');
13
+ const counts = {};
14
+ for (const d of ['push', 'pull'])
15
+ for (const tilt of tiltDirections)
16
+ counts[`${d}.${tilt}`] = 0;
17
+ const actions = [], issues = [];
18
+ let ambiguous = false;
19
+ function finish(rows, recording) {
20
+ if (!rows.length)
21
+ return;
22
+ const peak = (k) => rows.reduce((a, b) => Math.abs(b.input[k]) > Math.abs(a.input[k]) ? b : a);
23
+ const px = peak('rx'), py = peak('ry'), pz = peak('z');
24
+ const axis = Math.abs(px.input.rx) >= Math.abs(py.input.ry) ? 'rx' : 'ry', top = axis === 'rx' ? px : py;
25
+ const strength = Math.abs(top.input[axis]), other = Math.max(...rows.map(r => Math.abs(r.input[axis === 'rx' ? 'ry' : 'rx'])));
26
+ if (strength < .25)
27
+ return; // Plain presses are evidence for the separate simple/double calibration.
28
+ const direction = pz.input.z > 0 ? 'push' : 'pull', sign = direction === 'push' ? 1 : -1;
29
+ const pressure = rows.find(r => r.input.z * sign >= base[direction].activation);
30
+ const onset = rows.find(r => Math.abs(r.input[axis]) >= .25);
31
+ if (!pressure || !onset || onset.t < pressure.t || rows[rows.length - 1].t - rows[0].t > 1500 || rows.some(r => r.input.z * sign < -base[direction === 'push' ? 'pull' : 'push'].activation) || strength < other * 1.25) {
32
+ ambiguous = true;
33
+ issues.push('Excluded a tilt with ambiguous direction, pressure order, reversal or duration.');
34
+ return;
35
+ }
36
+ const tilt = (axis + (top.input[axis] > 0 ? '+' : '-'));
37
+ actions.push({ direction, tilt, kind: 'single', recording, start: pressure.t, end: rows[rows.length - 1].t, pressurePeak: Math.abs(pz.input.z), tiltPeak: strength, pressureAtTiltPeak: Math.abs(top.input.z), onsetDelayMs: onset.t - pressure.t });
38
+ counts[`${direction}.${tilt}`]++;
39
+ }
40
+ recordings.forEach((recording, index) => {
41
+ validateGestureRecording(recording);
42
+ let run = [], quietAt = null, armed = false;
43
+ for (const row of recording.timeline) {
44
+ if (row.type === 'reset') {
45
+ if (run.length)
46
+ issues.push('Discarded an interrupted excursion.');
47
+ run = [];
48
+ quietAt = null;
49
+ armed = false;
50
+ continue;
51
+ }
52
+ if (row.type !== 'input')
53
+ continue;
54
+ const magnitude = Math.max(Math.abs(row.input.z), Math.abs(row.input.rx), Math.abs(row.input.ry));
55
+ if (magnitude <= .06) {
56
+ armed = true;
57
+ quietAt ?? (quietAt = row.t);
58
+ continue;
59
+ }
60
+ if (run.length && quietAt !== null && row.t - quietAt > 180) {
61
+ finish(run, index);
62
+ run = [];
63
+ }
64
+ if (armed)
65
+ run.push(row);
66
+ quietAt = null;
67
+ }
68
+ const inputs = recording.timeline.filter(r => r.type === 'input'), last = inputs[inputs.length - 1];
69
+ if (run.length) {
70
+ if (last?.type === 'input' && Math.max(Math.abs(last.input.z), Math.abs(last.input.rx), Math.abs(last.input.ry)) <= .06)
71
+ finish(run, index);
72
+ else
73
+ issues.push('Discarded an unfinished excursion at recording end.');
74
+ }
75
+ });
76
+ const missing = Object.keys(counts).filter(k => counts[k] < minimum);
77
+ let tune = null;
78
+ if (!missing.length && !ambiguous) {
79
+ const peaks = actions.map(a => a.tiltPeak), activation = Math.min(.25, Math.min(...peaks) * .8);
80
+ const center = peaks[0] + peaks.reduce((sum, v) => sum + v - peaks[0], 0) / peaks.length;
81
+ tune = createGestureTune({ ...base.toJSON(), pressTilt: {
82
+ force: { center, low: Math.min(center, quantile(peaks, .1)), high: Math.max(center, quantile(peaks, .9)), activation, release: activation * .48 },
83
+ minMs: 25, armMs: Math.max(450, Math.ceil((Math.max(...actions.map(a => a.onsetDelayMs)) + 80) / 50) * 50), relaxMs: 180,
84
+ maxMs: Math.max(1000, Math.ceil(Math.max(...actions.map(a => a.end - a.start)) * 1.5 / 50) * 50), dominance: 1.25,
85
+ } });
86
+ }
87
+ return { status: ambiguous ? 'ambiguous' : missing.length ? 'incomplete' : 'ready', tune, counts, missing, actions, issues: [...new Set(issues)] };
88
+ }
@@ -0,0 +1,26 @@
1
+ import type { CalibrationStats } from './calibration.js';
2
+ import type { TiltDirection } from './gestures.js';
3
+ import type { GestureRecording } from './recording.js';
4
+ import type { GestureTune } from './tune.js';
5
+ export type TiltActionName = `${TiltDirection}.${'single' | 'double'}`;
6
+ export interface TiltCalibrationAction {
7
+ direction: TiltDirection;
8
+ kind: 'single' | 'double';
9
+ start: number;
10
+ end: number;
11
+ recording: number;
12
+ }
13
+ export interface TiltCalibration {
14
+ status: 'ready' | 'incomplete' | 'ambiguous';
15
+ tune: GestureTune | null;
16
+ counts: Record<TiltActionName, number>;
17
+ missing: TiltActionName[];
18
+ actions: TiltCalibrationAction[];
19
+ stats: Partial<Record<TiltDirection, CalibrationStats>>;
20
+ issues: string[];
21
+ }
22
+ /** Reuse the raw pulse parser on rx/ry; never pair across capture/reset boundaries. */
23
+ export declare function calibrateTilts(input: GestureRecording | readonly GestureRecording[], settings?: {
24
+ baseTune?: GestureTune;
25
+ minimumPerAction?: number;
26
+ }): TiltCalibration;
@@ -0,0 +1,46 @@
1
+ import { calibrateGestures } from './calibration.js';
2
+ import { validateGestureRecording } from './recording.js';
3
+ import { createGestureTune, defaultGestureTune } from './tune.js';
4
+ const names = { clockwise: 'rx+', counterclockwise: 'rx-', push: 'ry+', pull: 'ry-' };
5
+ /** Reuse the raw pulse parser on rx/ry; never pair across capture/reset boundaries. */
6
+ export function calibrateTilts(input, settings = {}) {
7
+ const captures = Array.isArray(input) ? input : [input];
8
+ const base = settings.baseTune ?? defaultGestureTune;
9
+ const mapped = captures.map(c => {
10
+ validateGestureRecording(c);
11
+ let pressureOwned = false;
12
+ const timeline = c.timeline.map(row => {
13
+ if (row.type === 'reset') {
14
+ pressureOwned = false;
15
+ return row;
16
+ }
17
+ if (row.type !== 'input')
18
+ return row;
19
+ const v = row.input;
20
+ if (Math.max(Math.abs(v.z), Math.abs(v.rz), Math.abs(v.rx), Math.abs(v.ry)) <= .06)
21
+ pressureOwned = false;
22
+ if (Math.max(Math.abs(v.rx), Math.abs(v.ry)) < .15 && (v.z >= base.push.activation || -v.z >= base.pull.activation || Math.abs(v.rz) >= base.rotation.activation))
23
+ pressureOwned = true;
24
+ if (pressureOwned)
25
+ return { type: 'reset', t: row.t };
26
+ return { ...row, input: { x: 0, y: 0, z: v.ry, rz: v.rx, rx: 0, ry: 0 } };
27
+ });
28
+ return { ...c, timeline };
29
+ });
30
+ const result = calibrateGestures(mapped, { minimumPerAction: settings.minimumPerAction, detectionFloor: .06 });
31
+ const rename = (key) => { const [direction, kind] = key.split('.'); return `${names[direction]}.${kind}`; };
32
+ const counts = Object.fromEntries(Object.entries(result.counts).map(([k, v]) => [rename(k), v]));
33
+ let tune = null;
34
+ if (result.tune) {
35
+ const t = result.tune;
36
+ const rxActivation = Math.min(t.rotation.activation, Math.min(result.stats.clockwise.min, result.stats.counterclockwise.min) * .85);
37
+ const rx = { ...t.rotation, activation: rxActivation, release: Math.min(t.rotation.release, rxActivation * .8) };
38
+ const ry = Object.fromEntries(['center', 'low', 'high', 'activation', 'release'].map(k => [k, (t.push[k] + t.pull[k]) / 2]));
39
+ tune = createGestureTune({ ...base.toJSON(), standaloneTilt: { rx, ry, timing: { ...t.timing, minPulseMs: Math.min(25, t.timing.minPulseMs), neutralMs: 10 } } });
40
+ }
41
+ return { status: result.status, tune, counts, missing: result.missing.map(rename),
42
+ actions: result.actions.map(a => ({ direction: names[a.direction], kind: a.kind, start: a.start, end: a.end, recording: a.recording })),
43
+ stats: Object.fromEntries(Object.entries(result.stats).map(([k, v]) => [names[k], v])),
44
+ issues: result.issues.map(issue => issue.replace(/counterclockwise|clockwise|push|pull/g, k => names[k])),
45
+ };
46
+ }
package/dist/tune.d.ts CHANGED
@@ -6,6 +6,24 @@ export interface ForceBand {
6
6
  readonly activation: number;
7
7
  readonly release: number;
8
8
  }
9
+ export interface PressTiltTune {
10
+ readonly force: ForceBand;
11
+ readonly minMs: number;
12
+ readonly armMs: number;
13
+ readonly relaxMs: number;
14
+ readonly maxMs: number;
15
+ readonly dominance: number;
16
+ }
17
+ export interface StandaloneTiltTune {
18
+ readonly rx: ForceBand;
19
+ readonly ry: ForceBand;
20
+ readonly timing: {
21
+ readonly minPulseMs: number;
22
+ readonly maxPulseMs: number;
23
+ readonly neutralMs: number;
24
+ readonly doubleMs: number;
25
+ };
26
+ }
9
27
  export interface GestureTuneData {
10
28
  readonly version: 1;
11
29
  readonly rotation: ForceBand;
@@ -18,6 +36,9 @@ export interface GestureTuneData {
18
36
  readonly doubleMs: number;
19
37
  };
20
38
  readonly dominance: number;
39
+ /** Optional for backward-compatible restoration of 0.2 tunes. Does not enable tilt mode by itself. */
40
+ readonly pressTilt?: PressTiltTune;
41
+ readonly standaloneTilt?: StandaloneTiltTune;
21
42
  }
22
43
  export interface GestureTune extends GestureTuneData {
23
44
  soften(amount: number): GestureTune;
package/dist/tune.js CHANGED
@@ -5,47 +5,51 @@ const base = {
5
5
  pull: { center: .289, low: .18, high: .357, activation: .12, release: .06 },
6
6
  timing: { minPulseMs: 35, maxPulseMs: 650, neutralMs: 25, doubleMs: 400 },
7
7
  dominance: 1.4,
8
+ pressTilt: { force: { center: .596, low: .401, high: .803, activation: .24, release: .115 }, minMs: 25, armMs: 450, relaxMs: 180, maxMs: 1000, dominance: 1.25 },
9
+ standaloneTilt: { rx: { center: .458, low: .319, high: .601, activation: .13, release: .104 }, ry: { center: .671, low: .536, high: .806, activation: .28, release: .16 }, timing: { minPulseMs: 25, maxPulseMs: 650, neutralMs: 10, doubleMs: 400 } },
8
10
  };
9
11
  const finite = (x) => typeof x === 'number' && Number.isFinite(x);
10
12
  /** Restore a plain JSON tune; all nested objects are copied and frozen. */
11
13
  export function createGestureTune(data = base) {
12
14
  if (!data || data.version !== 1 || !finite(data.dominance) || data.dominance < 1)
13
15
  throw new RangeError('Invalid tune version or dominance.');
14
- for (const key of ['rotation', 'push', 'pull']) {
15
- const b = data[key];
16
+ for (const b of [data.rotation, data.push, data.pull, ...(data.pressTilt ? [data.pressTilt.force] : []), ...(data.standaloneTilt ? [data.standaloneTilt.rx, data.standaloneTilt.ry] : [])]) {
16
17
  if (!b || ![b.center, b.low, b.high, b.activation, b.release].every(finite)
17
18
  || b.low < 0 || b.low > b.center || b.center > b.high || b.high > 1
18
19
  || b.release < 0 || b.activation <= b.release || b.activation > 1 || b.center <= 0)
19
20
  throw new RangeError('Invalid force band.');
20
21
  }
21
22
  const t = data.timing;
22
- if (!t || ![t.minPulseMs, t.maxPulseMs, t.neutralMs, t.doubleMs].every(v => finite(v) && v >= 0)
23
- || t.minPulseMs > t.maxPulseMs || t.maxPulseMs > 10000 || t.doubleMs > 10000 || t.neutralMs > 1000)
24
- throw new RangeError('Invalid tune timing.');
25
- const value = Object.freeze({ version: 1, rotation: Object.freeze({ ...data.rotation }), push: Object.freeze({ ...data.push }), pull: Object.freeze({ ...data.pull }), timing: Object.freeze({ ...t }), dominance: data.dominance });
23
+ const p = data.pressTilt;
24
+ if (p && (![p.minMs, p.armMs, p.relaxMs, p.maxMs, p.dominance].every(finite) || p.minMs < 0 || p.armMs < p.minMs || p.relaxMs < 0 || p.relaxMs > p.armMs || p.armMs > p.maxMs || p.maxMs > 10000 || p.dominance <= 1))
25
+ throw new RangeError('Invalid press-tilt tune.');
26
+ for (const timing of [t, ...(data.standaloneTilt ? [data.standaloneTilt.timing] : [])])
27
+ if (!timing || ![timing.minPulseMs, timing.maxPulseMs, timing.neutralMs, timing.doubleMs].every(v => finite(v) && v >= 0)
28
+ || timing.minPulseMs > timing.maxPulseMs || timing.maxPulseMs > 10000 || timing.doubleMs > 10000 || timing.neutralMs > 1000)
29
+ throw new RangeError('Invalid tune timing.');
30
+ const s = data.standaloneTilt;
31
+ const value = Object.freeze({ version: 1, rotation: Object.freeze({ ...data.rotation }), push: Object.freeze({ ...data.push }), pull: Object.freeze({ ...data.pull }), timing: Object.freeze({ ...t }), dominance: data.dominance, ...(p ? { pressTilt: Object.freeze({ ...p, force: Object.freeze({ ...p.force }) }) } : {}), ...(s ? { standaloneTilt: Object.freeze({ rx: Object.freeze({ ...s.rx }), ry: Object.freeze({ ...s.ry }), timing: Object.freeze({ ...s.timing }) }) } : {}) });
26
32
  const amount = (x, subtract) => { if (!finite(x) || x < 0 || (subtract && x >= 1) || x > 10)
27
33
  throw new RangeError('Amount must be a non-negative fraction; soften/narrow require less than 1.'); return subtract ? 1 - x : 1 + x; };
28
34
  function edit(factor, mode) {
29
- const bands = {};
30
- for (const key of ['rotation', 'push', 'pull']) {
31
- const b = value[key];
35
+ function band(b) {
32
36
  if (mode === 'force') {
33
37
  // Scale each band uniformly; saturation preserves ordering and hysteresis.
34
38
  const f = Math.min(factor, 1 / Math.max(b.high, b.activation));
35
- bands[key] = { center: b.center * f, low: b.low * f, high: b.high * f, activation: b.activation * f, release: b.release * f };
39
+ return { center: b.center * f, low: b.low * f, high: b.high * f, activation: b.activation * f, release: b.release * f };
36
40
  }
37
41
  else {
38
42
  const activation = Math.max(.001, b.center - (b.center - b.activation) * factor);
39
- bands[key] = { center: b.center, low: Math.max(0, b.center - (b.center - b.low) * factor), high: Math.min(1, b.center + (b.high - b.center) * factor), activation, release: activation * b.release / b.activation };
43
+ return { center: b.center, low: Math.max(0, b.center - (b.center - b.low) * factor), high: Math.min(1, b.center + (b.high - b.center) * factor), activation, release: activation * b.release / b.activation };
40
44
  }
41
45
  }
42
- return createGestureTune({ ...value, ...bands });
46
+ return createGestureTune({ ...value, rotation: band(value.rotation), push: band(value.push), pull: band(value.pull), ...(value.pressTilt ? { pressTilt: { ...value.pressTilt, force: band(value.pressTilt.force) } } : {}), ...(value.standaloneTilt ? { standaloneTilt: { ...value.standaloneTilt, rx: band(value.standaloneTilt.rx), ry: band(value.standaloneTilt.ry) } } : {}) });
43
47
  }
44
48
  return Object.freeze({ ...value,
45
49
  soften(x) { return edit(amount(x, true), 'force'); }, harden(x) { return edit(amount(x, false), 'force'); },
46
50
  narrow(x) { return edit(amount(x, true), 'spread'); }, widen(x) { return edit(amount(x, false), 'spread'); },
47
51
  toJSON() { return value; },
48
- toOptions() { return { twistActivation: value.rotation.activation, twistRelease: value.rotation.release, pushActivation: value.push.activation, pushRelease: value.push.release, pullActivation: value.pull.activation, pullRelease: value.pull.release, ...value.timing, dominance: value.dominance, singleMode: 'exclusive' }; },
52
+ toOptions() { const p = value.pressTilt, s = value.standaloneTilt; return { twistActivation: value.rotation.activation, twistRelease: value.rotation.release, pushActivation: value.push.activation, pushRelease: value.push.release, pullActivation: value.pull.activation, pullRelease: value.pull.release, ...value.timing, dominance: value.dominance, singleMode: 'exclusive', ...(p ? { tiltActivation: p.force.activation, tiltRelease: p.force.release, tiltMinMs: p.minMs, tiltArmMs: p.armMs, tiltRelaxMs: p.relaxMs, tiltMaxMs: p.maxMs, tiltDominance: p.dominance } : {}), ...(s ? { tiltXActivation: s.rx.activation, tiltXRelease: s.rx.release, tiltYActivation: s.ry.activation, tiltYRelease: s.ry.release, standaloneMinPulseMs: s.timing.minPulseMs, standaloneMaxPulseMs: s.timing.maxPulseMs, standaloneNeutralMs: s.timing.neutralMs, standaloneDoubleMs: s.timing.doubleMs } : {}) }; },
49
53
  });
50
54
  }
51
55
  export const defaultGestureTune = createGestureTune();
package/docs/agents.md CHANGED
@@ -24,3 +24,11 @@ This guide is for agents building applications with Puck. Repository contributio
24
24
  Exercise a single, a double, neutral rearming, a long hold, a reversal, report silence, blur/disconnect and teardown. Confirm one action handler invocation per expected event and no completed gesture caused by disconnect. Check incomplete calibration and JSON tune restoration. Use performance.now() consistently in a browser and an explicit monotonic clock in deterministic tests.
25
25
 
26
26
  For library contributions, run `pnpm check` from the source checkout. The tests use built ESM modules. Read CONTRIBUTING before proposing API/profile changes. Do not edit generated dist files as the source of truth.
27
+
28
+ ## Tilt integrations (0.3)
29
+
30
+ Read [standalone and combined tilt](press-tilt.md) before enabling these families.
31
+ Use `calibrateTilts` for standalone singles/doubles and `calibratePressTilts` for
32
+ pressure-first combinations; do not interpret one family's coverage as another.
33
+ Handle expanded direction unions and optional event.tilt. Keep modes separate
34
+ from persisted tune data, and never claim inferred labels are ground truth.
package/docs/api.md CHANGED
@@ -8,9 +8,9 @@ This reference describes 0.2.0, including its experimental gesture APIs. Emitted
8
8
 
9
9
  | Entry point | Runtime exports |
10
10
  |---|---|
11
- | `@clankagent/puck` | `decodeCombinedReport`, `neutralInput`, `createPanZoom`, `createGestures`, `createGestureTune`, `defaultGestureTune`, `gesturePresets`, `createGestureRecorder`, `validateGestureRecording`, `calibrateGestures`, `gestureDirections` |
11
+ | `@clankagent/puck` | `decodeCombinedReport`, `neutralInput`, `createPanZoom`, `createGestures`, `createGestureTune`, `defaultGestureTune`, `gesturePresets`, `createGestureRecorder`, `validateGestureRecording`, `calibrateGestures`, `gestureDirections`, `calibratePressTilts`, `calibrateTilts`, `tiltDirections` |
12
12
  | `@clankagent/puck/webhid` | `connectWebHid`, `combinedProfile` |
13
- | `@clankagent/puck/graph` | `createGestureGraph`, `renderGestureGraphSvg` |
13
+ | `@clankagent/puck/graph` | `createGestureGraph`, `createPressTiltGraph`, `createTiltGraph`, `renderGestureGraphSvg` |
14
14
 
15
15
  `InputState` has six finite normalized axes: `x,y,z,rx,ry,rz`, each in [-1,1]. They represent deflection, not angles. For the verified profile, positive `rz` is clockwise and positive `z` is push down; negative values are counterclockwise and pull up. Force bands use positive magnitudes [0,1]. Times are milliseconds. Recorder and calibration times are relative to capture start; live recognizer event times use the supplied clock.
16
16
 
@@ -47,6 +47,13 @@ Timing defaults: `minPulseMs:35`, `maxPulseMs:650`, `neutralMs:25`, `doubleMs:40
47
47
 
48
48
  Low-level gates are `activation/release`, `pressActivation/pressRelease`, `twistActivation/twistRelease`, and `clockwiseActivation/clockwiseRelease` (likewise `counterclockwise`, `push`, `pull`). Precedence: direction → axis → shared → default tune. `createGestures({})` matches the no-argument form. Gates require `0 <= release < activation <= 1`; invalid options throw `RangeError`.
49
49
 
50
+ For opt-in standalone rx/ry and combined pressure/tilt options, event shapes,
51
+ timing, calibration results and graph constructors, see the [tilt API](press-tilt.md).
52
+ `PulseDirection` names the original four; `TiltDirection` names rx+/rx-/ry+/ry-;
53
+ `GestureDirection` is their union. `GestureEvent.tilt` is optional and only present
54
+ for a combined single. `PressMode` is simple/auto/tilt. Exported tune types include
55
+ `PressTiltTune` and `StandaloneTiltTune`.
56
+
50
57
  ## Tunes
51
58
 
52
59
  `createGestureTune(data?)` validates and copies a version-1 `GestureTuneData`: `{version, rotation, push, pull, timing, dominance}`. Each band contains `{center,low,high,activation,release}`; timing contains the four millisecond fields above. Returned objects are deeply frozen.
@@ -83,4 +90,4 @@ The parser infers actions from raw shape, independent of order and recognized ev
83
90
 
84
91
  A clear calls `onInput(neutralInput)` then `onReset()`. For gestures, ignore that exact sentinel in onInput and reset the recognizer in onReset, so a lifecycle change cannot finish a press. Real zero reports are separate objects. Custom decoders should return fresh input objects for physical reports. Motion can consume the sentinel directly. Close removes listeners; your app must cancel its own frame loop.
85
92
 
86
- `DeviceProfile` contains vendorId, productId, optional usagePage/usage, and `decode(reportId,data)`. An injected `hid` implements `HidAccess` for tests/custom transport integration. The default `combinedProfile` uses the verified IDs from the README. Do not infer additional device support from vendor ID alone.
93
+ `DeviceProfile` contains vendorId, productId, optional usagePage/usage, and `decode(reportId,data)`. An injected `hid` implements `HidAccess` for tests/custom transport integration. The default `combinedProfile` uses the verified IDs from the README. Do not infer additional device support from vendor ID alone.
package/docs/lab.md CHANGED
@@ -25,3 +25,38 @@ provide their own storage. This example uses a same-origin session token for
25
25
  writes, no CORS and no user authentication: keep it on loopback or behind a
26
26
  trusted private proxy. Failed saves retain a retry/download backup. `pnpm
27
27
  lab:analyze` reads the newest device recording, or accepts a JSON file path.
28
+
29
+ ## Combined-gesture research
30
+
31
+ Open the lab with `?capture=press-move` for freeform push-move/pull-move capture.
32
+ Use natural pressure, including relaxation while rotating or tilting the cap.
33
+ Aim for three examples per push/pull × movement direction, plus ordinary single and
34
+ double presses for comparison; no required order or guided sequence. Multiple
35
+ captures are fine if two minutes is too short.
36
+
37
+ The recorder saves all six raw axes regardless of existing recognition gates.
38
+ The combined-tilt parser shows eight-direction coverage and six lanes for
39
+ pressure and tilt. Its tune preserves the base simple/double-press settings.
40
+ See the [combined gesture guide](press-tilt.md) and [1.0 criteria](roadmap.md).
41
+
42
+ For standalone tilt research, use `?capture=tilt`. Record at least three
43
+ singles and three doubles for each of the four rx/ry directions, without
44
+ deliberate push/pull or twist. Any order is fine. These captures are marked
45
+ separately and analyzed with the standalone tuner. Choose the Analyze selector
46
+ to override automatic family detection. Enable Combine with loaded recordings
47
+ before loading another partial capture. Use this tune enables the matching
48
+ family. The standalone toggle and push/pull mode can also be set independently.
49
+
50
+ ## Verify capture before starting
51
+
52
+ Connect the device and move the cap once. Start recording remains disabled
53
+ until an actual device movement report arrives. During capture the report and
54
+ movement counts must rise when you move; all six axes are retained independently
55
+ of the gesture gates. Captures without movement are not saved as usable
56
+ recordings. Disconnect ends and saves a partial capture if movement was received.
57
+ The download backup remains available even for an empty diagnostic capture.
58
+
59
+ Simulator recording is only enabled explicitly with `?source=simulator` (or
60
+ `&source=simulator` after an existing query). It is labeled as simulator input
61
+ and is not physical calibration evidence. Reload after lab updates; a reload
62
+ requires reconnecting the device.
@@ -0,0 +1,131 @@
1
+ # Standalone and combined tilts (0.3.0)
2
+
3
+ [Documentation](../README.md) · [Roadmap](roadmap.md)
4
+
5
+ These experimental APIs are opt-in. Existing simple gestures remain the default.
6
+
7
+ ```js
8
+ import {createGestures, defaultGestureTune} from '@clankagent/puck';
9
+ const gestures = createGestures({...defaultGestureTune.toOptions(), pressMode:'auto'});
10
+ // A combined event: {direction:'push', tilt:'rx+', kind:'single', timestamp, durationMs}
11
+ ```
12
+
13
+ `pressMode` is `simple` (existing default), `auto` (choose plain or combined), or
14
+ `tilt` (combined actions plus plain doubles; suppress plain singles). `pushMode`
15
+ and `pullMode` override it independently. Combined modes require
16
+ `singleMode:'exclusive'`. The `rx+`, `rx-`, `ry+`, `ry-` labels are device-axis
17
+ directions; applications map these to their own screen orientation.
18
+
19
+ Pressure arms tilt detection. The cap may relax while tilting; it need not stay
20
+ above the pressure activation gate. A brief full release can still be joined
21
+ to a subsequent tilt. Sustained tilt on the strongest direction chooses the
22
+ action; a substantially stronger later direction can replace a preparatory
23
+ tilt. A pressure reversal cancels the excursion. Pre-existing tilt cannot arm a
24
+ combination. Neutral, interruption and maximum duration prevent repeated holds.
25
+
26
+ | Option | Default | Meaning |
27
+ |---|---|---|
28
+ | `tiltActivation`, `tiltRelease` | .24, .115 | Normalized force gates |
29
+ | `tiltMinMs` | 25 | Short noise dwell, not a target execution speed |
30
+ | `tiltArmMs` | 450 | Maximum time from pressure to tilt onset |
31
+ | `tiltRelaxMs` | 180 | Maximum gap from pressure release to resumed tilt |
32
+ | `tiltMaxMs` | 1000 | Maximum combined excursion duration |
33
+ | `tiltDominance` | 1.25 | Required directional separation |
34
+
35
+ The reference input is treated as the slower end of normal. Faster movements
36
+ are accepted; the upper allowances are not mandatory waits. Combined events
37
+ emit on full release plus neutral dwell, without waiting for a plain double.
38
+ Repeated combinations are separate single actions; the recognizer does not pair
39
+ them into combined doubles. Ordinary doubles retain completion-to-completion
40
+ timing. With an unusually short `doubleMs`, plain singles wait at least
41
+ `tiltRelaxMs + tiltMinMs` to leave time for a possible combination.
42
+
43
+ ## Tunes, recordings and graphs
44
+
45
+ Tune JSON version 1 accepts optional `pressTilt` containing `force` (a standard
46
+ force band), `minMs`, `armMs`, `relaxMs`, `maxMs`, and `dominance`. Old tunes
47
+ remain restorable. The default tune includes these settings, but does not
48
+ enable combined recognition. Immutable force edits also edit the tilt band;
49
+ timings stay unchanged. Save the mode separately from the tune.
50
+
51
+ `calibratePressTilts(recordingOrArray, {baseTune?, minimumPerAction?})` derives
52
+ tilt settings from raw z/rx/ry, independent of event labels and group order. It
53
+ requires at least three inferred examples of all eight push/pull × tilt
54
+ combinations. The result has `status`, `tune`, `counts`, `missing`, `actions`
55
+ and `issues`; only use a non-null tune. The base defaults to
56
+ `defaultGestureTune`; ordinary press/twist force and double timing are preserved.
57
+ No inferred count is a claim of labeled accuracy. Simulator input is useful
58
+ for tests, not physical evidence. The parser uses fixed exploratory boundaries
59
+ (.06 neutral, .25 tilt detection, 180 ms quiet gap); very different motion may
60
+ need manual review. It does not learn every timing value from one capture.
61
+
62
+ From `@clankagent/puck/graph`, `createPressTiltGraph(recording,tune,{actions,
63
+ recordingIndex?,start?,end?})` produces six lanes (push, pull, four tilts) for
64
+ `renderGestureGraphSvg`. Each combination marks both its pressure and tilt lane.
65
+ Select a short window to see pressure relaxation instead of comparing an
66
+ entire minute at once.
67
+
68
+ The session example accepts `options:{pressMode:'auto'}` and records the exact
69
+ configuration. Lifecycle reset still cancels all pending actions. Apps retain
70
+ ownership of camera arbitration, recording storage, clocks and rendering.
71
+
72
+ ## Standalone directional singles and doubles
73
+
74
+ Enable `standaloneTilt:true` to recognize rx+/rx−/ry+/ry− without a deliberate
75
+ push, pull or twist. Events use `direction:'rx+'` (or another tilt direction)
76
+ and `kind:'single'|'double'`, with no `tilt` field. Signs are device axes, not
77
+ screen directions. Map them in the consuming app.
78
+
79
+ ```js
80
+ const gestures = createGestures({
81
+ ...defaultGestureTune.toOptions(),
82
+ standaloneTilt: true,
83
+ pressMode: 'auto', // optional: also recognize pressure-first combinations
84
+ });
85
+ ```
86
+
87
+ The first qualifying motion owns the excursion: pressure-first can become a
88
+ combination; tilt-first remains standalone even if incidental pressure follows.
89
+ Neutral rx/ry completes a standalone pulse. Reversals without neutral cancel.
90
+ A double pairs completions within its own double window. Exclusive singles
91
+ wait; immediate singles are additive, as with ordinary presses. Combined modes
92
+ still require exclusive singles. Long holds and lifecycle resets do not emit taps.
93
+
94
+ | Standalone option | Default |
95
+ |---|---|
96
+ | `tiltXActivation`, `tiltXRelease` | .13, .104 |
97
+ | `tiltYActivation`, `tiltYRelease` | .28, .16 |
98
+ | `standaloneMinPulseMs`, `standaloneMaxPulseMs` | 25, 650 |
99
+ | `standaloneNeutralMs`, `standaloneDoubleMs` | 10, 400 |
100
+
101
+ Tune version 1 also accepts optional `standaloneTilt:{rx,ry,timing}`. Each axis
102
+ has a force band shared by its positive/negative directions; timing contains
103
+ `minPulseMs,maxPulseMs,neutralMs,doubleMs`. Old JSON remains valid. Presets and
104
+ immutable edits include these bands; `toOptions()` supplies thresholds without
105
+ enabling recognition. Store your mode choices alongside the tune.
106
+
107
+ ```js
108
+ import {calibrateTilts, createGestureTune} from '@clankagent/puck';
109
+ import {createTiltGraph, renderGestureGraphSvg} from '@clankagent/puck/graph';
110
+ const result = calibrateTilts(recordings, {baseTune: defaultGestureTune});
111
+ if (result.tune) {
112
+ const saved = JSON.stringify(result.tune);
113
+ const restored = createGestureTune(JSON.parse(saved));
114
+ const gestures = createGestures({...restored.toOptions(), standaloneTilt:true});
115
+ }
116
+ // Display result.status, counts, missing and issues even when tune is null.
117
+ const model = createTiltGraph(recordings[0], result.tune ?? defaultGestureTune,
118
+ {actions:result.actions, recordingIndex:0});
119
+ const svg = renderGestureGraphSvg(model);
120
+ ```
121
+
122
+ `calibrateTilts(recordingOrArray,{baseTune?,minimumPerAction?})` requires three
123
+ singles and three doubles per direction (eight action types), in any order.
124
+ It accepts 1–20 captures, preserves capture/reset boundaries, and ignores
125
+ pressure-first excursions. The result includes status, tune, counts, missing,
126
+ actions, stats and issues. Other gesture families retain their base settings.
127
+ Use one copy of each capture; duplicates are not new evidence. The parser uses
128
+ .06 exploratory neutral/detection and .15 early-pressure separation; review
129
+ unusual motion manually. Closely spaced singles may be inferred as doubles.
130
+ The graph shows four signed tilt lanes, force bands and inferred action spans.
131
+ These inferred labels are useful tuning evidence, not measured recognition accuracy.
@@ -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.
@@ -24,4 +24,4 @@
24
24
  | Recorder stops adding entries | Inspect full; stop/save and start another capture. Defaults are two minutes / 50000 timeline entries. |
25
25
  | Graph overlays wrong session | Pass the zero-based recordingIndex used in the combined calibration. |
26
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.
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/upgrading.md CHANGED
@@ -9,14 +9,29 @@ Read the changelog for the version you are adopting, then use docs from that sam
9
9
  Gesture, tune, recording, calibration and graph APIs are included in 0.2.0. In your consuming app:
10
10
 
11
11
  ```sh
12
- pnpm add @clankagent/puck@0.2.0
12
+ pnpm add @clankagent/puck@0.3.0
13
13
  pnpm list @clankagent/puck
14
14
  ```
15
15
 
16
- Commit your app's updated manifest and lockfile. Use the documentation shipped with that package or the v0.2.0 source tag. The new gesture APIs remain experimental; existing motion integrations retain their behavior.
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
17
 
18
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
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
+
20
35
  ## Existing pan/zoom applications
21
36
 
22
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.
@@ -41,4 +56,4 @@ To add gestures alongside motion, reuse the existing connection and frame loop.
41
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.
42
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.
43
58
 
44
- 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.
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.
@@ -2,8 +2,9 @@ import { createGestures, createGestureRecorder, defaultGestureTune, neutralInput
2
2
  import { connectWebHid } from '@clankagent/puck/webhid';
3
3
 
4
4
  /** Call from a click handler. Returns null when the chooser is cancelled. */
5
- export async function connectGestureSession({ tune = defaultGestureTune, onEvents = console.log, onDisconnect = () => {} } = {}) {
6
- const gestures = createGestures(tune);
5
+ export async function connectGestureSession({ tune = defaultGestureTune, options = {}, onEvents = console.log, onDisconnect = () => {} } = {}) {
6
+ const configuration = {...tune.toOptions(),...options};
7
+ const gestures = createGestures(configuration);
7
8
  let recorder = null;
8
9
  let frameId;
9
10
  let stopped = false;
@@ -35,7 +36,7 @@ export async function connectGestureSession({ tune = defaultGestureTune, onEvent
35
36
  if (stopped) throw new Error('Session has ended.');
36
37
  if (recorder) throw new Error('Stop the current recording first.');
37
38
  gestures.reset();
38
- recorder = createGestureRecorder({ startTimeMs: performance.now(), tune, source: 'device' });
39
+ recorder = createGestureRecorder({ startTimeMs: performance.now(), options:configuration, source: 'device' });
39
40
  },
40
41
  stopRecording() {
41
42
  if (!recorder) return null;
package/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > TypeScript/ESM six-axis input, pan/zoom, tunable cap gestures and freeform calibration. Zero runtime dependencies.
4
4
 
5
- Documentation targets 0.2.0, including experimental gesture/tune/recording/calibration/graph APIs. Match documentation to your installed version.
5
+ Documentation targets 0.3.0, including experimental gesture/tune/recording/calibration/graph APIs. Match documentation to your installed version.
6
6
 
7
7
  ## Documentation
8
8
 
@@ -23,5 +23,7 @@ Documentation targets 0.2.0, including experimental gesture/tune/recording/calib
23
23
  - [Contribution workflow](CONTRIBUTING.md)
24
24
  - [Repository agent instructions](AGENTS.md)
25
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)
26
28
 
27
- 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.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@clankagent/puck",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Frame-independent six-axis input, pan/zoom and tunable gestures",
5
5
  "type": "module",
6
6
  "sideEffects": false,