@clankagent/puck 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ import { defaultGestureTune } from './tune.js';
2
+ /** Experimental pulse recognizer. No timers, DOM, transport, or motion side effects. */
3
+ export function createGestures(configuration = defaultGestureTune) {
4
+ const options = 'toOptions' in configuration ? configuration.toOptions() : configuration;
5
+ const activation = options.activation ?? .35;
6
+ const release = options.release ?? .12;
7
+ const pressActivation = options.pressActivation ?? options.activation;
8
+ const pressRelease = options.pressRelease ?? options.release;
9
+ const twistActivation = options.twistActivation ?? options.activation ?? defaultGestureTune.rotation.activation;
10
+ const twistRelease = options.twistRelease ?? options.release ?? defaultGestureTune.rotation.release;
11
+ const thresholds = {
12
+ clockwise: { activation: options.clockwiseActivation ?? twistActivation, release: options.clockwiseRelease ?? twistRelease },
13
+ counterclockwise: { activation: options.counterclockwiseActivation ?? twistActivation, release: options.counterclockwiseRelease ?? twistRelease },
14
+ push: { activation: options.pushActivation ?? pressActivation ?? defaultGestureTune.push.activation, release: options.pushRelease ?? pressRelease ?? defaultGestureTune.push.release },
15
+ pull: { activation: options.pullActivation ?? pressActivation ?? defaultGestureTune.pull.activation, release: options.pullRelease ?? pressRelease ?? defaultGestureTune.pull.release },
16
+ };
17
+ const minPulseMs = options.minPulseMs ?? 35;
18
+ const maxPulseMs = options.maxPulseMs ?? 650;
19
+ const neutralMs = options.neutralMs ?? defaultGestureTune.timing.neutralMs;
20
+ const doubleMs = options.doubleMs ?? defaultGestureTune.timing.doubleMs;
21
+ const singleMode = options.singleMode ?? 'exclusive';
22
+ const dominance = options.dominance ?? 1.4;
23
+ if (![activation, release, twistActivation, twistRelease, minPulseMs, maxPulseMs, neutralMs, doubleMs, dominance].every(Number.isFinite)
24
+ || release < 0 || activation <= release || activation > 1 || minPulseMs < 0
25
+ || twistRelease < 0 || twistActivation <= twistRelease || twistActivation > 1
26
+ || Object.values(thresholds).some(v => !Number.isFinite(v.activation) || !Number.isFinite(v.release) || v.release < 0 || v.activation <= v.release || v.activation > 1)
27
+ || maxPulseMs < minPulseMs || neutralMs < 0 || doubleMs < 0 || dominance < 1
28
+ || !['exclusive', 'immediate'].includes(singleMode))
29
+ throw new RangeError('Invalid gesture options.');
30
+ let last = -Infinity;
31
+ let z = 0, rz = 0;
32
+ let phase = 'blocked';
33
+ let active = null;
34
+ let releasedAt = null;
35
+ 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;
38
+ function clock(t) {
39
+ if (!Number.isFinite(t) || t < last)
40
+ throw new RangeError('Gesture timestamps must be finite and monotonic.');
41
+ last = t;
42
+ }
43
+ function tick(t) {
44
+ const events = [];
45
+ if (phase === 'releasing' && releasedAt !== null && active && t >= releasedAt + neutralMs) {
46
+ const durationMs = releasedAt - active.start;
47
+ const completedAt = releasedAt + neutralMs;
48
+ if (durationMs >= minPulseMs && durationMs <= maxPulseMs) {
49
+ const pulse = { direction: active.direction, kind: 'single', timestamp: completedAt, durationMs };
50
+ if (pending && pending.direction === pulse.direction && completedAt - pending.timestamp <= doubleMs) {
51
+ events.push({ ...pulse, kind: 'double' });
52
+ pending = null;
53
+ }
54
+ else {
55
+ if (pending && singleMode === 'exclusive')
56
+ events.push({ ...pending, timestamp: Math.min(completedAt, pending.timestamp + doubleMs) });
57
+ pending = pulse;
58
+ if (singleMode === 'immediate')
59
+ events.push(pulse);
60
+ }
61
+ }
62
+ active = null;
63
+ releasedAt = null;
64
+ phase = 'neutral';
65
+ }
66
+ if (active && phase === 'active' && t - active.start > maxPulseMs) {
67
+ phase = 'blocked';
68
+ active = null;
69
+ }
70
+ // 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 });
74
+ pending = null;
75
+ }
76
+ return events;
77
+ }
78
+ return {
79
+ 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'; },
81
+ advance(t) { clock(t); return tick(t); },
82
+ update(input, t) {
83
+ if (!Number.isFinite(input.z) || !Number.isFinite(input.rz))
84
+ throw new RangeError('Gesture axes must be finite.');
85
+ clock(t);
86
+ const events = tick(t);
87
+ z = Math.max(-1, Math.min(1, input.z));
88
+ rz = Math.max(-1, Math.min(1, input.rz));
89
+ if (phase === 'blocked') {
90
+ if (neutral())
91
+ phase = 'neutral';
92
+ return events;
93
+ }
94
+ if (active) {
95
+ const signed = active.direction === 'push' ? z : active.direction === 'pull' ? -z : active.direction === 'clockwise' ? rz : -rz;
96
+ if (neutral()) {
97
+ if (releasedAt === null)
98
+ releasedAt = t;
99
+ phase = 'releasing';
100
+ }
101
+ else if (signed < -thresholds[active.direction].release) {
102
+ // Reversal without a neutral dwell is not a completed pulse.
103
+ active = null;
104
+ releasedAt = null;
105
+ phase = 'blocked';
106
+ }
107
+ else {
108
+ phase = 'active';
109
+ releasedAt = null;
110
+ }
111
+ }
112
+ else {
113
+ 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');
117
+ if (strong >= thresholds[direction].activation && strong >= weak * dominance) {
118
+ active = { direction, start: t };
119
+ phase = 'active';
120
+ }
121
+ }
122
+ return events.concat(tick(t));
123
+ },
124
+ };
125
+ }
@@ -0,0 +1,39 @@
1
+ import type { GestureDirection } from './gestures.js';
2
+ import type { GestureTune } from './tune.js';
3
+ import type { GestureRecording } from './recording.js';
4
+ import type { CalibrationAction } from './calibration.js';
5
+ export interface GestureGraphLane {
6
+ direction: GestureDirection;
7
+ label: string;
8
+ center: number;
9
+ low: number;
10
+ high: number;
11
+ activation: number;
12
+ release: number;
13
+ points: {
14
+ t: number;
15
+ value: number;
16
+ }[];
17
+ actions: {
18
+ start: number;
19
+ end: number;
20
+ kind: 'single' | 'double';
21
+ }[];
22
+ }
23
+ export interface GestureGraph {
24
+ start: number;
25
+ end: number;
26
+ lanes: GestureGraphLane[];
27
+ }
28
+ /** Renderer-neutral graph data. Use your charting library or the optional SVG renderer. */
29
+ export declare function createGestureGraph(recording: GestureRecording, tune?: GestureTune, config?: {
30
+ start?: number;
31
+ end?: number;
32
+ actions?: readonly CalibrationAction[];
33
+ recordingIndex?: number;
34
+ }): GestureGraph;
35
+ /** Standalone accessible SVG. No DOM, canvas, frameworks or global listeners. */
36
+ export declare function renderGestureGraphSvg(graph: GestureGraph, options?: {
37
+ width?: number;
38
+ title?: string;
39
+ }): string;
package/dist/graph.js ADDED
@@ -0,0 +1,59 @@
1
+ import { defaultGestureTune } from './tune.js';
2
+ import { validateGestureRecording } from './recording.js';
3
+ const labels = { clockwise: 'Clockwise', counterclockwise: 'Counterclockwise', push: 'Push down', pull: 'Pull up' };
4
+ /** Renderer-neutral graph data. Use your charting library or the optional SVG renderer. */
5
+ export function createGestureGraph(recording, tune = defaultGestureTune, config = {}) {
6
+ validateGestureRecording(recording);
7
+ const start = config.start ?? 0, end = config.end ?? Math.max(1, recording.durationMs);
8
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start)
9
+ throw new RangeError('Invalid graph window.');
10
+ const lanes = Object.keys(labels).map(direction => {
11
+ const vertical = direction === 'push' || direction === 'pull', sign = direction === 'clockwise' || direction === 'push' ? 1 : -1;
12
+ const band = vertical ? tune[direction] : tune.rotation;
13
+ const all = [];
14
+ for (const row of recording.timeline) {
15
+ if (row.type === 'input')
16
+ all.push({ t: row.t, value: Math.max(0, sign * row.input[vertical ? 'z' : 'rz']) });
17
+ else if (row.type === 'reset')
18
+ all.push({ t: row.t, value: 0 });
19
+ }
20
+ const preceding = [...all].reverse().find(p => p.t < start);
21
+ const points = [{ t: start, value: preceding?.value ?? 0 }, ...all.filter(p => p.t >= start && p.t <= end)];
22
+ points.push({ t: end, value: points[points.length - 1].value });
23
+ return { direction, label: labels[direction], ...band, points, actions: (config.actions ?? []).filter(a => a.recording === (config.recordingIndex ?? 0) && a.direction === direction && a.end >= start && a.start <= end).map(a => ({ start: a.start, end: a.end, kind: a.kind })) };
24
+ });
25
+ return { start, end, lanes };
26
+ }
27
+ const escape = (value) => value.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
28
+ /** Standalone accessible SVG. No DOM, canvas, frameworks or global listeners. */
29
+ export function renderGestureGraphSvg(graph, options = {}) {
30
+ const width = options.width ?? 960;
31
+ if (!Number.isFinite(width) || width < 280 || width > 4000)
32
+ throw new RangeError('SVG width must be between 280 and 4000.');
33
+ const height = graph.lanes.length * 150 + 64, left = 45, right = 20, plotWidth = width - left - right;
34
+ 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">`];
36
+ graph.lanes.forEach((lane, index) => {
37
+ const top = 30 + index * 150, y = (v) => top + 100 - Math.max(0, Math.min(1, v)) * 76;
38
+ 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
+ svg.push(`<rect x="${left}" y="${y(lane.high)}" width="${plotWidth}" height="${y(lane.low) - y(lane.high)}" fill="#edf3fc"/>`);
40
+ 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>`);
44
+ }
45
+ for (const [v, color, dash] of [[0, '#afbac8', ''], [lane.activation, '#237442', '6 4'], [lane.release, '#68778b', '2 4']])
46
+ svg.push(`<line x1="${left}" x2="${width - right}" y1="${y(v)}" y2="${y(v)}" stroke="${color}" stroke-dasharray="${dash}"/>`);
47
+ svg.push(`<text x="12" y="${y(1) + 4}">1</text><text x="12" y="${y(0) + 4}">0</text>`);
48
+ // Steps preserve held input and report silence; no invented sloping interpolation.
49
+ let path = '';
50
+ lane.points.forEach((p, i) => { path += i ? `H${x(p.t).toFixed(2)}V${y(p.value).toFixed(2)}` : `M${x(p.t).toFixed(2)},${y(p.value).toFixed(2)}`; });
51
+ svg.push(`<path d="${path}" fill="none" stroke="#1b4fce" stroke-width="1.8"/>`);
52
+ });
53
+ for (let i = 0; i <= 4; i++) {
54
+ const t = graph.start + (graph.end - graph.start) * i / 4;
55
+ svg.push(`<text x="${x(t)}" y="${height - 24}" text-anchor="${i === 0 ? 'start' : i === 4 ? 'end' : 'middle'}">${(t / 1000).toFixed(2)} s</text>`);
56
+ }
57
+ svg.push('</g></svg>');
58
+ return svg.join('');
59
+ }
package/dist/index.d.ts CHANGED
@@ -2,3 +2,11 @@ export { decodeCombinedReport, neutralInput } from './input.js';
2
2
  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
+ export { createGestures } from './gestures.js';
6
+ export type { GestureDirection, GestureOptions, GestureEvent, GestureRecognizer } from './gestures.js';
7
+ export { createGestureTune, defaultGestureTune, gesturePresets } from './tune.js';
8
+ export type { GestureTune, GestureTuneData, ForceBand } from './tune.js';
9
+ export { createGestureRecorder, validateGestureRecording } from './recording.js';
10
+ export type { GestureRecorder, GestureRecording, RecordingEntry } from './recording.js';
11
+ export { calibrateGestures, gestureDirections } from './calibration.js';
12
+ export type { GestureCalibration, CalibrationAction, CalibrationPulse, CalibrationStats, GestureActionName } from './calibration.js';
package/dist/index.js CHANGED
@@ -1,2 +1,6 @@
1
1
  export { decodeCombinedReport, neutralInput } from './input.js';
2
2
  export { createPanZoom } from './motion.js';
3
+ export { createGestures } from './gestures.js';
4
+ export { createGestureTune, defaultGestureTune, gesturePresets } from './tune.js';
5
+ export { createGestureRecorder, validateGestureRecording } from './recording.js';
6
+ export { calibrateGestures, gestureDirections } from './calibration.js';
@@ -0,0 +1,40 @@
1
+ import type { InputState } from './input.js';
2
+ import type { GestureEvent, GestureOptions } from './gestures.js';
3
+ import type { GestureTune } from './tune.js';
4
+ export type RecordingEntry = {
5
+ type: 'input';
6
+ t: number;
7
+ input: InputState;
8
+ } | {
9
+ type: 'advance' | 'reset';
10
+ t: number;
11
+ };
12
+ export interface GestureRecording {
13
+ version: 1;
14
+ source: 'device' | 'simulator';
15
+ note: string;
16
+ durationMs: number;
17
+ options: GestureOptions;
18
+ timeline: RecordingEntry[];
19
+ events: GestureEvent[];
20
+ }
21
+ export interface GestureRecorder {
22
+ readonly full: boolean;
23
+ input(input: Readonly<InputState>, timestampMs: number): void;
24
+ advance(timestampMs: number): void;
25
+ reset(timestampMs: number): void;
26
+ events(events: readonly GestureEvent[]): void;
27
+ snapshot(timestampMs: number): GestureRecording;
28
+ }
29
+ /** Transport-independent bounded recording. The app owns clock, saving and storage. */
30
+ export declare function createGestureRecorder(config: {
31
+ startTimeMs: number;
32
+ tune?: GestureTune;
33
+ options?: GestureOptions;
34
+ source?: 'device' | 'simulator';
35
+ note?: string;
36
+ maxDurationMs?: number;
37
+ maxEntries?: number;
38
+ }): GestureRecorder;
39
+ /** Validate data before calibration or graphing; recordings contain untrusted input. */
40
+ export declare function validateGestureRecording(recording: GestureRecording): void;
@@ -0,0 +1,48 @@
1
+ import { defaultGestureTune } from './tune.js';
2
+ /** Transport-independent bounded recording. The app owns clock, saving and storage. */
3
+ export function createGestureRecorder(config) {
4
+ const start = config.startTimeMs, max = config.maxDurationMs ?? 120000, limit = config.maxEntries ?? 50000;
5
+ if (!Number.isFinite(start) || !Number.isFinite(max) || max <= 0 || max > 120000 || !Number.isInteger(limit) || limit < 1 || limit > 60000)
6
+ throw new RangeError('Invalid recording bounds.');
7
+ const options = { ...(config.options ?? (config.tune ?? defaultGestureTune).toOptions()) };
8
+ const timeline = [], events = [];
9
+ let last = start, full = false;
10
+ function time(t) { if (!Number.isFinite(t) || t < last)
11
+ throw new RangeError('Recording clock must be finite and monotonic.'); last = t; return t - start; }
12
+ function add(row) { if (full)
13
+ return; if (row.t > max || timeline.length >= limit) {
14
+ full = true;
15
+ return;
16
+ } timeline.push(row); }
17
+ const copyInput = (input) => { const copy = {}; for (const key of ['x', 'y', 'z', 'rx', 'ry', 'rz']) {
18
+ if (!Number.isFinite(input[key]) || Math.abs(input[key]) > 1)
19
+ throw new RangeError('Recording axes must be in [-1,1].');
20
+ copy[key] = input[key];
21
+ } return copy; };
22
+ return {
23
+ get full() { return full; },
24
+ input(input, t) { const copied = copyInput(input); add({ type: 'input', t: time(t), input: copied }); },
25
+ advance(t) { add({ type: 'advance', t: time(t) }); }, reset(t) { add({ type: 'reset', t: time(t) }); },
26
+ events(values) { for (const e of values) {
27
+ const timestamp = e.timestamp - start;
28
+ if (!full && timestamp >= 0 && timestamp <= max && events.length < 10000)
29
+ events.push({ ...e, timestamp });
30
+ } },
31
+ snapshot(t) { const durationMs = Math.min(max, time(t)); return { version: 1, source: config.source ?? 'device', note: (config.note ?? '').slice(0, 500), durationMs, options: { ...options }, timeline: timeline.map(row => row.type === 'input' ? { ...row, input: { ...row.input } } : { ...row }), events: events.filter(e => e.timestamp <= durationMs).map(e => ({ ...e })) }; },
32
+ };
33
+ }
34
+ /** Validate data before calibration or graphing; recordings contain untrusted input. */
35
+ export function validateGestureRecording(recording) {
36
+ if (!recording || recording.version !== 1 || !Number.isFinite(recording.durationMs) || recording.durationMs < 0 || recording.durationMs > 122000 || !Array.isArray(recording.timeline) || recording.timeline.length > 60000)
37
+ throw new RangeError('Invalid recording.');
38
+ let last = 0;
39
+ for (const row of recording.timeline) {
40
+ if (!row || !Number.isFinite(row.t) || row.t < last || row.t > recording.durationMs || !['input', 'advance', 'reset'].includes(row.type))
41
+ throw new RangeError('Invalid recording timeline.');
42
+ last = row.t;
43
+ if (row.type === 'input')
44
+ for (const k of ['x', 'y', 'z', 'rx', 'ry', 'rz'])
45
+ if (!row.input || !Number.isFinite(row.input[k]) || Math.abs(row.input[k]) > 1)
46
+ throw new RangeError('Invalid recording input.');
47
+ }
48
+ }
package/dist/tune.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { GestureOptions } from './gestures.js';
2
+ export interface ForceBand {
3
+ readonly center: number;
4
+ readonly low: number;
5
+ readonly high: number;
6
+ readonly activation: number;
7
+ readonly release: number;
8
+ }
9
+ export interface GestureTuneData {
10
+ readonly version: 1;
11
+ readonly rotation: ForceBand;
12
+ readonly push: ForceBand;
13
+ readonly pull: ForceBand;
14
+ readonly timing: {
15
+ readonly minPulseMs: number;
16
+ readonly maxPulseMs: number;
17
+ readonly neutralMs: number;
18
+ readonly doubleMs: number;
19
+ };
20
+ readonly dominance: number;
21
+ }
22
+ export interface GestureTune extends GestureTuneData {
23
+ soften(amount: number): GestureTune;
24
+ harden(amount: number): GestureTune;
25
+ narrow(amount: number): GestureTune;
26
+ widen(amount: number): GestureTune;
27
+ toOptions(): GestureOptions;
28
+ toJSON(): GestureTuneData;
29
+ }
30
+ /** Restore a plain JSON tune; all nested objects are copied and frozen. */
31
+ export declare function createGestureTune(data?: GestureTuneData): GestureTune;
32
+ export declare const defaultGestureTune: GestureTune;
33
+ export declare const gesturePresets: Readonly<{
34
+ default: GestureTune;
35
+ soft: GestureTune;
36
+ hard: GestureTune;
37
+ }>;
package/dist/tune.js ADDED
@@ -0,0 +1,52 @@
1
+ const base = {
2
+ version: 1,
3
+ rotation: { center: .465, low: .349, high: .57, activation: .25, release: .15 },
4
+ push: { center: .314, low: .274, high: .354, activation: .20, release: .08 },
5
+ pull: { center: .289, low: .18, high: .357, activation: .12, release: .06 },
6
+ timing: { minPulseMs: 35, maxPulseMs: 650, neutralMs: 25, doubleMs: 400 },
7
+ dominance: 1.4,
8
+ };
9
+ const finite = (x) => typeof x === 'number' && Number.isFinite(x);
10
+ /** Restore a plain JSON tune; all nested objects are copied and frozen. */
11
+ export function createGestureTune(data = base) {
12
+ if (!data || data.version !== 1 || !finite(data.dominance) || data.dominance < 1)
13
+ throw new RangeError('Invalid tune version or dominance.');
14
+ for (const key of ['rotation', 'push', 'pull']) {
15
+ const b = data[key];
16
+ if (!b || ![b.center, b.low, b.high, b.activation, b.release].every(finite)
17
+ || b.low < 0 || b.low > b.center || b.center > b.high || b.high > 1
18
+ || b.release < 0 || b.activation <= b.release || b.activation > 1 || b.center <= 0)
19
+ throw new RangeError('Invalid force band.');
20
+ }
21
+ 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 });
26
+ const amount = (x, subtract) => { if (!finite(x) || x < 0 || (subtract && x >= 1) || x > 10)
27
+ throw new RangeError('Amount must be a non-negative fraction; soften/narrow require less than 1.'); return subtract ? 1 - x : 1 + x; };
28
+ function edit(factor, mode) {
29
+ const bands = {};
30
+ for (const key of ['rotation', 'push', 'pull']) {
31
+ const b = value[key];
32
+ if (mode === 'force') {
33
+ // Scale each band uniformly; saturation preserves ordering and hysteresis.
34
+ 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 };
36
+ }
37
+ else {
38
+ 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 };
40
+ }
41
+ }
42
+ return createGestureTune({ ...value, ...bands });
43
+ }
44
+ return Object.freeze({ ...value,
45
+ soften(x) { return edit(amount(x, true), 'force'); }, harden(x) { return edit(amount(x, false), 'force'); },
46
+ narrow(x) { return edit(amount(x, true), 'spread'); }, widen(x) { return edit(amount(x, false), 'spread'); },
47
+ 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' }; },
49
+ });
50
+ }
51
+ export const defaultGestureTune = createGestureTune();
52
+ export const gesturePresets = Object.freeze({ default: defaultGestureTune, soft: defaultGestureTune.soften(.2), hard: defaultGestureTune.harden(.2) });
package/docs/agents.md ADDED
@@ -0,0 +1,26 @@
1
+ # Agent integration guide
2
+
3
+ [Documentation](../README.md) · [API reference](api.md) · [Complete integration](quickstart.md)
4
+
5
+ This guide is for agents building applications with Puck. Repository contribution instructions live separately in [AGENTS.md](../AGENTS.md).
6
+
7
+ 1. Read the README version note. Match docs to the installed package version and its exports/declarations; experimental checkout APIs may not exist in the registry release.
8
+ 2. Read the API reference for exact entry points, axis signs, units and defaults. Use public imports only. Core is transport-independent; WebHID and graph rendering are optional.
9
+ 3. Adapt the complete session example for gestures/recording, or the motion example for cameras. The application owns timers, rendering, persistence and teardown.
10
+ 4. Verify behavior with synthetic inputs and the actual application lifecycle. Do not claim physical compatibility or subjective feel from synthetic tests.
11
+
12
+ ## Integration invariants
13
+
14
+ - Six axes are normalized deflection, not velocity or angle. Verified signs: +rz clockwise, +z push down. Gestures are cap pulses, not physical button events.
15
+ - Feed every input report to the recognizer and recorder. Call advance during report silence. Use one monotonic millisecond clock; do not mix performance.now() reports with rAF-supplied gesture timestamps.
16
+ - Ignore the adapter's exact neutralInput lifecycle sentinel in the gesture report callback; reset on onReset. Do not infer a physical release on blur/disconnect. Reset requires fresh neutral to rearm.
17
+ - Exclusive singles intentionally wait for the double window. Immediate mode produces an additive single then double. Choose consciously.
18
+ - Tune edits return new immutable values. Persist JSON data; restore through createGestureTune. Rotation shares one band for both directions. Typical low/high are not acceptance limits.
19
+ - Calibration needs at least three instances of EACH of eight actions, in any order. Check tune for null, show missing/ambiguous results, and retain raw input for inspection. Deduplicate recordings before combining. Inferred counts are not labeled intent accuracy.
20
+ - No automatic backend, upload, storage or device reconnection is supplied. Never invent those capabilities or promise unverified hardware support.
21
+
22
+ ## Verification checklist
23
+
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
+
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.
package/docs/api.md ADDED
@@ -0,0 +1,86 @@
1
+ # API reference
2
+
3
+ [Documentation](../README.md) · [Quickstart](quickstart.md) · [Tuning guide](tuning.md)
4
+
5
+ This reference describes 0.2.0, including its experimental gesture APIs. Emitted `dist/*.d.ts` files are the exact TypeScript signatures for your build. Named exports only; use the three supported entry points below, not deep imports into `dist`.
6
+
7
+ ## Imports and units
8
+
9
+ | Entry point | Runtime exports |
10
+ |---|---|
11
+ | `@clankagent/puck` | `decodeCombinedReport`, `neutralInput`, `createPanZoom`, `createGestures`, `createGestureTune`, `defaultGestureTune`, `gesturePresets`, `createGestureRecorder`, `validateGestureRecording`, `calibrateGestures`, `gestureDirections` |
12
+ | `@clankagent/puck/webhid` | `connectWebHid`, `combinedProfile` |
13
+ | `@clankagent/puck/graph` | `createGestureGraph`, `renderGestureGraphSvg` |
14
+
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
+
17
+ `decodeCombinedReport(reportId, DataView)` accepts report 1 with exactly 12 bytes, decodes six little-endian signed 16-bit values divided by 350 and clamps to [-1,1]. Other reports return `null`. `neutralInput` is a shared frozen zero input; the WebHID adapter also uses its identity as a lifecycle sentinel.
18
+
19
+ ## Motion
20
+
21
+ `createPanZoom(options?)` returns `setInput(input)`, `step(time)`, `setZoomInput('twist'|'press')`, `reset()`. Step returns `{panX, panY, zoomFactor, moving}`. Pan is screen pixels; zoom is multiplicative. First step is idle. Input is held until replaced; neutral stops on the next step.
22
+
23
+ | Option | Default |
24
+ |---|---|
25
+ | `zoomInput` | `'twist'` |
26
+ | `panSpeed`, `zoomSpeed` | 1320 pixels/s, 1.5 log-units/s |
27
+ | `panDeadzone`, `zoomDeadzone` | .05, .1 |
28
+ | `responseMs`, `maxFrameMs` | 25, 50 |
29
+
30
+ Speeds/times must be finite and nonnegative; deadzones are in [0,1). Invalid options throw `RangeError`.
31
+
32
+ ## Gestures
33
+
34
+ `createGestures(tuneOrOptions?)` returns `update(input,time)`, `advance(time)`, `reset()`, and read-only `state` (`phase`, `direction`, `pending`). Both update and advance return event arrays. Process EVERY report; advance even when reports are silent. Timestamps must be finite and nondecreasing or throw `RangeError`.
35
+
36
+ An event is `{direction, kind, timestamp, durationMs}`. Direction is `clockwise|counterclockwise|push|pull`; kind is `single|double`. Timestamp is the logical recognition deadline, which can precede dispatch. Reset cancels pending actions and requires fresh neutral input. A hold is not repeated presses; reversal without neutral cancels the action.
37
+
38
+ | Default | Rotation (both signs) | Push | Pull |
39
+ |---|---|---|---|
40
+ | Center | .465 | .314 | .289 |
41
+ | Typical low–high | .349–.570 | .274–.354 | .180–.357 |
42
+ | Activation / release | .25 / .15 | .20 / .08 | .12 / .06 |
43
+
44
+ Timing defaults: `minPulseMs:35`, `maxPulseMs:650`, `neutralMs:25`, `doubleMs:400`; `dominance:1.4`. The double window is completion-to-completion. Both gesture axes must return below their release levels for neutral dwell.
45
+
46
+ `singleMode:'exclusive'` (default) waits before emitting a single so a double can replace it. `'immediate'` emits a single first and later an additive double; no undo is emitted. Prefer exclusive when actions must not both fire.
47
+
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
+
50
+ ## Tunes
51
+
52
+ `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.
53
+
54
+ `defaultGestureTune` and `gesturePresets.default` are the baseline; `.soft` and `.hard` use 20% force adjustments. Methods `soften(x)`, `harden(x)`, `narrow(x)`, `widen(x)` return new tunes and never change timing. Amounts are fractions: .1 means 10%. Soften/narrow allow [0,1), harden/widen [0,10]. See [exact editing semantics](tuning.md#gesture-tunes).
55
+
56
+ `toOptions()` produces recognizer configuration. `toJSON()` produces frozen portable data. Restore parsed JSON with `createGestureTune(data)`; JSON alone has no methods. For nested edits, use a new object, e.g. `createGestureTune({...tune.toJSON(), timing:{...tune.timing,doubleMs:450}})`. Typical low/high are descriptive, not rejection cutoffs.
57
+
58
+ ## Recording
59
+
60
+ `createGestureRecorder({startTimeMs,tune?,options?,source?,note?,maxDurationMs?,maxEntries?})` returns `input(input,time)`, `advance(time)`, `reset(time)`, `events(events)`, `snapshot(time)` and `full`. Explicit `options` takes precedence over tune. Source defaults to `'device'`; use `'simulator'` for generated input.
61
+
62
+ Input/tick/reset calls share a finite nondecreasing clock. Samples are copied. Default bounds are 120000 ms and 50000 entries (maximum configurable entries 60000); events cap at 10000. `full` signals a rejected timeline entry due to bounds; the app owns stopping and saving. Snapshot is detached, not a stop operation.
63
+
64
+ `GestureRecording` shape: `{version:1,source,note,durationMs,options,timeline,events}`. Timeline entries are `{type:'input',t,input}` or `{type:'advance'|'reset',t}`. All times, including stored event timestamps, are relative to capture start. `validateGestureRecording(recording)` checks version, duration and timeline shape/bounds/axis values; it is not exhaustive validation of every metadata field. Invalid recording input throws `RangeError`.
65
+
66
+ ## Calibration
67
+
68
+ `calibrateGestures(recordingOrArray, settings?)` accepts 1–20 recordings. Settings: `minimumPerAction` integer 3–100 (default 3), `detectionFloor` (0,.1] (default .025), `pairGapMs` 50–500 (default 250).
69
+
70
+ Returns `{status,tune,counts,missing,stats,pulses,actions,issues}`. Status is `ready|incomplete|ambiguous`; **check `result.tune !== null` before using it** (the TypeScript interface is not a discriminated union). Counts use eight keys such as `clockwise.single` and `pull.double`. Stats hold per-direction peak count/mean/percentiles/min/max. Actions hold direction, kind, start, end, pulses and zero-based recording index. Pulses also expose peak, peakTime, valleyBefore and reset segment.
71
+
72
+ The parser infers actions from raw shape, independent of order and recognized event labels. Never claim counts are labeled accuracy. Separate captures/resets cannot form pairs. Repeated copies of a recording are NOT deduplicated by the SDK; the app must avoid duplicate evidence. See [calibration behavior](tuning.md#recording-and-custom-calibration).
73
+
74
+ ## Graphs
75
+
76
+ `createGestureGraph(recording,tune?,{start?,end?,actions?,recordingIndex?}?)` returns `{start,end,lanes}`. Each of four lanes has direction/label, band values, `{t,value}` points and action spans. Times are capture-relative; values are positive magnitudes. Default tune is the baseline. A custom time window must be finite with `0 <= start < end`. For combined analysis, pass the matching `recordingIndex` (default 0).
77
+
78
+ `renderGestureGraphSvg(model,{width?,title?}?)` returns a standalone SVG string. Width defaults to 960, range 280–4000. Blue steps are held input, green dashed is activation, gray dotted is release, pale fill is typical range; shaded spans are inferred actions. Labels, title and description supplement color. Provide a text count/status summary alongside graphs in your UI. The model can also feed your own accessible chart renderer.
79
+
80
+ ## WebHID lifecycle
81
+
82
+ `connectWebHid({onInput,onReset?,onDisconnect?,profile?,hid?,pauseOnBlur?})` returns a promise of connection or null on chooser cancellation. Connection exposes `device`, `pause()`, `resume()`, async `close()`. Default `pauseOnBlur:true` clears on blur/hidden. Pause/close/disconnect also clear; returning to foreground/resume requires a fresh report.
83
+
84
+ 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
+
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.
package/docs/lab.md ADDED
@@ -0,0 +1,27 @@
1
+ # Gesture lab
2
+
3
+ [Documentation](../README.md)
4
+
5
+ Run from a source checkout; the lab server is not shipped in the npm package.
6
+
7
+ ## Local gesture lab
8
+
9
+ ```sh
10
+ pnpm build
11
+ pnpm lab
12
+ ```
13
+
14
+ The example demonstrates the same public SDK: freeform recording, automatic
15
+ calibration, minimum-example coverage, reusable tune JSON, immutable adjustments,
16
+ and inspectable graphs. Choose **Analyze saved recording**, then select an action
17
+ to zoom into it. **Use this tune** applies the result to live input. The default,
18
+ soft and hard presets are also available. Recordings can be combined to supply
19
+ missing examples; replaying the same saved ID does not duplicate its evidence.
20
+
21
+ The example server binds to loopback. Recordings stay outside the repository in
22
+ the user's local application data directory under `Puck/recordings`
23
+ (`PUCK_RECORDINGS_DIR` overrides it), and are not in the npm package. Apps should
24
+ provide their own storage. This example uses a same-origin session token for
25
+ writes, no CORS and no user authentication: keep it on loopback or behind a
26
+ trusted private proxy. Failed saves retain a retry/download backup. `pnpm
27
+ lab:analyze` reads the newest device recording, or accepts a JSON file path.
package/docs/motion.md ADDED
@@ -0,0 +1,20 @@
1
+ # Motion and hardware
2
+
3
+ [Documentation](../README.md) · [API reference](api.md)
4
+
5
+ ## Motion behavior
6
+
7
+ - `step(timestampMs)` uses the timestamp supplied by your render loop, once per frame. The first step produces no movement. It never assumes a display refresh rate.
8
+ - A held cap requests velocity. Input report count does not determine movement distance. The latest deflection remains active between reports, including bursty delivery.
9
+ - Pan has a 0.05 normalized deadzone and full-deflection speed of 1320 screen pixels/second. Zoom has a 0.1 deadzone and log-speed of 1.5/second. Both use a 25 ms acceleration response.
10
+ - Neutral input stops on the next step, without software coasting. Reversal discards response in the old direction.
11
+ - `twist`: clockwise zooms in. `press`: downward pressure zooms in, lifting zooms out. These directions refer to the verified profile.
12
+ - The default 50 ms frame cap limits jumps after rendering stalls. It is not a timeout for input reports.
13
+ - The adapter clears input on blur, hidden state and disconnect. Connect `onReset` to `motion.reset` as above so old response and frame timing are also cleared. Returning to the foreground waits for a fresh report. Use connection `pause()` / `resume()` when another tool owns the input; use controller `reset()` if you provide your own transport.
14
+ - Use one controller per independent input stream. Do not drive both the report callback and frame loop with movement updates.
15
+
16
+ ## Hardware scope
17
+
18
+ The report layout and motion defaults were measured with vendor `0x256f`, product `0xc63a`, over Bluetooth on Windows: report 1, twelve bytes, six signed little-endian 16-bit axes, logical range ±350. Buttons and other layouts are not implemented yet. Other devices require a matching `DeviceProfile`; profile support should be backed by descriptors and captures, not guessed from the vendor alone.
19
+
20
+ WebHID requires browser support and device permission. The adapter inherits that availability; the motion core does not. [Official WebHID guide](https://developer.chrome.com/docs/capabilities/hid).