RemiFabre commited on
Commit
843a4b2
·
0 Parent(s):

feat: initial Reachy Mini Morse Code app

Browse files

Talk to Reachy Mini in Morse: the robot transmits by clapping its antennas
(each hit = an audible click), the phone/laptop transmits with beeps, and any
device (or a second Mini) decodes incoming hits live through its mic.

- Onset-only impulse wire code (dot=1 tap, dash=2 taps) so robot clicks,
phone beeps and robot<->robot all share one detector.
- Transient-onset detector ported from the Marionette audio-sync tests
(highpass -> 5ms energy -> spectral flux -> peak pick), offline + streaming.
- Compose / Listen / Learn UI, mobile-first, host-shell + CDN SDK (sdk: static).
- 42 unit tests (codec, detector round-trip, view DOM) + browser smoke tests.

.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
2
+ *.wav filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .DS_Store
2
+ node_modules/
3
+ test-results/
4
+ playwright-report/
5
+ .env
README.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Morse Code
3
+ emoji: 📡
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: static
7
+ pinned: false
8
+ hf_oauth: true
9
+ hf_oauth_expiration_minutes: 480
10
+ hf_oauth_scopes:
11
+ - read-repos
12
+ - write-repos
13
+ - manage-repos
14
+ short_description: Talk to Reachy Mini in Morse — antenna taps, beeps, mic decode.
15
+ tags:
16
+ - reachy_mini
17
+ - reachy_mini_js_app
18
+ ---
19
+
20
+ # Morse Code
21
+
22
+ Make your Reachy Mini speak and understand **Morse code**. The robot transmits
23
+ by *clapping its antennas together* — each hit is an audible click. Your phone
24
+ or laptop can transmit too, with beeps. Either device (or a **second Reachy
25
+ Mini** sitting across the table) listens through its microphone, detects the
26
+ hits, and decodes them back to text in real time.
27
+
28
+ Open the Space URL, sign in with Hugging Face, pick your robot. No install.
29
+
30
+ ## Three tabs
31
+
32
+ - **Compose** — type a message, see its Morse, and pick who sends it: the
33
+ robot's antennas or this device's speaker. Watch it transmit.
34
+ - **Listen** — turn on the mic and decode incoming clicks/beeps live, with a
35
+ scope that drops a marker on every detected hit.
36
+ - **Learn** — the letter ↔ rhythm chart. Tap any tile to *feel* the rhythm
37
+ (a letter is just a pattern of short and long beats).
38
+
39
+ ## How it works (the impulse wire code)
40
+
41
+ An antenna hit is **impulsive** — one short click. You can't hold a click long
42
+ the way you hold a tone, so classic "short tone = dot, long tone = dash" can't
43
+ work on the robot. Instead every channel here uses one **onset-only** code so a
44
+ tapping robot, a beeping phone, and a second robot are all mutually
45
+ intelligible through a single detector:
46
+
47
+ - **dot** = one impulse
48
+ - **dash** = a quick double-tap (two impulses)
49
+ - letters and words are separated by progressively longer gaps
50
+
51
+ It's still real Morse — the Learn chart shows the standard `·–` patterns — just
52
+ carried as a rhythm of hits instead of tone length.
53
+
54
+ Detection is a transient-onset detector (high-pass → short-term energy →
55
+ spectral flux → peak pick) ported from the Marionette audio-sync tests and
56
+ validated end-to-end against synthetic audio (`npm test`). All timing and
57
+ detection thresholds live in Settings, so the robot clap and mic sensitivity
58
+ can be calibrated on real hardware without code changes.
59
+
60
+ ## Develop
61
+
62
+ ```bash
63
+ npm install
64
+ npm test # unit tests: morse codec, wire code, detector round-trip
65
+ npm run test:e2e # playwright (optional)
66
+ ```
67
+
68
+ No build step — `sdk: static`, the host shell + SDK load from jsDelivr. Local
69
+ dev: serve the folder (e.g. `python3 -m http.server`) and open with a
70
+ `?embedded=1`-capable dev token, or just push to the Space.
71
+
72
+ ### SDK pin
73
+
74
+ `@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af`, pinned in three
75
+ places that must stay in sync: the two `modulepreload` links in `index.html`,
76
+ the two import URLs in `main.js`, and the re-export in
77
+ `lib/animation-helpers.js`.
78
+
79
+ ## Credits
80
+
81
+ Built for Reachy Mini by Pollen Robotics. Antenna-collision recipe and the
82
+ transient detector adapted from the Marionette project.
index.html ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
6
+ <title>Morse Code · Reachy Mini</title>
7
+ <meta name="description" content="Talk to Reachy Mini in Morse code — antenna taps and beeps, decoded live through the mic." />
8
+ <meta name="theme-color" content="#0d1117" />
9
+ <link rel="icon" type="image/svg+xml" href="public/icon.svg" />
10
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
+ <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
13
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
14
+ <link rel="stylesheet" href="style.css" />
15
+
16
+ <!-- Modern host-shell SDK entries (standalone → host/auto = mountHost;
17
+ embedded iframe → host/embed = connectToHost). Pinned to the exact
18
+ tarball the Pollen reference apps + marionette-js ship against so
19
+ SDK / host / daemon stay in lockstep. Bumping it means editing these
20
+ two links AND the two import URLs in main.js AND lib/animation-helpers.js. -->
21
+ <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/host/dist/entry/auto.js" crossorigin />
22
+ <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/host/dist/entry/embed.js" crossorigin />
23
+
24
+ <!-- HF Spaces helper variables. In production (sdk: static + hf_oauth: true)
25
+ HF rewrites these placeholders at serve time and the SDK reads them off
26
+ window.huggingface.variables. Locally the placeholders stay raw (leading
27
+ __) so we drop the global and mountHost() falls back to the dev path. -->
28
+ <script>
29
+ (function () {
30
+ var clientId = "__OAUTH_CLIENT_ID__";
31
+ var scopes = "__OAUTH_SCOPES__";
32
+ var spaceHost = "__SPACE_HOST__";
33
+ var spaceId = "__SPACE_ID__";
34
+ var looksSubstituted = clientId && clientId.indexOf("__") !== 0;
35
+ if (looksSubstituted) {
36
+ window.huggingface = window.huggingface || {};
37
+ window.huggingface.variables = {
38
+ OAUTH_CLIENT_ID: clientId,
39
+ OAUTH_SCOPES: scopes && scopes.indexOf("__") !== 0 ? scopes : "openid profile",
40
+ SPACE_HOST: spaceHost && spaceHost.indexOf("__") !== 0 ? spaceHost : "",
41
+ SPACE_ID: spaceId && spaceId.indexOf("__") !== 0 ? spaceId : "",
42
+ };
43
+ }
44
+ })();
45
+ </script>
46
+
47
+ <!-- `process` shim: the CDN-loaded host bundle expects a bundler to have
48
+ substituted process.env.NODE_ENV; loaded raw it would throw. -->
49
+ <script>
50
+ globalThis.process ??= { env: {} };
51
+ globalThis.process.env ??= {};
52
+ globalThis.process.env.NODE_ENV ??= 'production';
53
+ </script>
54
+ </head>
55
+ <body>
56
+ <!-- Host shell mount point (standalone): sign-in, robot picker, top bar. -->
57
+ <div id="root"></div>
58
+ <!-- In-iframe app surface, revealed once connectToHost() resolves. -->
59
+ <div id="app" hidden></div>
60
+ <script type="module" src="main.js"></script>
61
+ </body>
62
+ </html>
lib/animation-helpers.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Re-export of the SDK's /animation subpath, indirected through this short
2
+ // stable path so the rest of the app imports motion helpers from one place
3
+ // AND vitest can alias this single module to a stub (Node's ESM loader can't
4
+ // resolve `https:` imports). If you bump the SDK pin here, also bump the two
5
+ // import URLs in main.js and the two modulepreload links in index.html — they
6
+ // must all match (silent protocol drift otherwise). See README "SDK pin".
7
+ export {
8
+ INIT_POSE,
9
+ INIT_ANTENNAS_RAD,
10
+ DEFAULT_SCALED_DURATION_PRESET,
11
+ distanceBetweenPoses,
12
+ scaledDuration,
13
+ safelyReturnToPose,
14
+ installShutdownHandler,
15
+ } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/dist/animation/index.js";
lib/detector.js ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Transient-onset detector — clicks (antenna hits) and short beeps.
3
+ *
4
+ * Ported from marionette/tests/audio_analysis.py `detect_transient_onsets`:
5
+ * highpass -> short-term energy (5 ms windows) -> spectral flux
6
+ * (positive energy differences) -> peak pick with min separation.
7
+ *
8
+ * Two entry points sharing the same DSP core:
9
+ * - detectTransientOnsets(samples, sr, opts): pure, offline. Used by unit
10
+ * tests (synthetic audio) and the visualizer (on recorded buffers).
11
+ * - StreamingOnsetDetector: live mic. The highpass is done by a Web Audio
12
+ * BiquadFilterNode upstream; this class consumes filtered blocks and
13
+ * emits onsets in real time with an adaptive (noise-floor) threshold.
14
+ *
15
+ * Onset times are in MILLISECONDS to match wire.js / timing.js.
16
+ */
17
+
18
+ // ──────── Pure DSP ────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Single 2nd-order Butterworth high-pass (RBJ cookbook). Cascade twice for
22
+ * a ~4th-order roll-off (matches the Python `butter(4, ...)` intent).
23
+ */
24
+ export function biquadHighpassOnce(samples, sr, fc, q = Math.SQRT1_2) {
25
+ const w0 = (2 * Math.PI * fc) / sr;
26
+ const cos = Math.cos(w0);
27
+ const sin = Math.sin(w0);
28
+ const alpha = sin / (2 * q);
29
+ const b0 = (1 + cos) / 2;
30
+ const b1 = -(1 + cos);
31
+ const b2 = (1 + cos) / 2;
32
+ const a0 = 1 + alpha;
33
+ const a1 = -2 * cos;
34
+ const a2 = 1 - alpha;
35
+ const nb0 = b0 / a0, nb1 = b1 / a0, nb2 = b2 / a0, na1 = a1 / a0, na2 = a2 / a0;
36
+ const out = new Float64Array(samples.length);
37
+ let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
38
+ for (let i = 0; i < samples.length; i++) {
39
+ const x0 = samples[i];
40
+ const y0 = nb0 * x0 + nb1 * x1 + nb2 * x2 - na1 * y1 - na2 * y2;
41
+ x2 = x1; x1 = x0; y2 = y1; y1 = y0;
42
+ out[i] = y0;
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export function highpass(samples, sr, fc) {
48
+ return biquadHighpassOnce(biquadHighpassOnce(samples, sr, fc), sr, fc);
49
+ }
50
+
51
+ /**
52
+ * Short-term energy per non-overlapping window, then positive flux.
53
+ * @returns {{flux:Float64Array, winSamples:number}}
54
+ */
55
+ export function energyFlux(filtered, sr, winMs = 5) {
56
+ const winSamples = Math.max(1, Math.round((winMs / 1000) * sr));
57
+ const nWin = Math.floor(filtered.length / winSamples);
58
+ const energy = new Float64Array(nWin);
59
+ for (let w = 0; w < nWin; w++) {
60
+ let e = 0;
61
+ const base = w * winSamples;
62
+ for (let j = 0; j < winSamples; j++) {
63
+ const v = filtered[base + j];
64
+ e += v * v;
65
+ }
66
+ energy[w] = e;
67
+ }
68
+ const flux = new Float64Array(Math.max(0, nWin - 1));
69
+ for (let i = 1; i < nWin; i++) flux[i - 1] = Math.max(0, energy[i] - energy[i - 1]);
70
+ return { flux, winSamples };
71
+ }
72
+
73
+ /**
74
+ * Offline transient detection on a full buffer.
75
+ * @returns {number[]} onset times in ms.
76
+ */
77
+ export function detectTransientOnsets(samples, sr, opts = {}) {
78
+ const { highpassHz = 2000, winMs = 5, thresholdRatio = 0.1, minSepMs = 150 } = opts;
79
+ if (!samples || samples.length < sr * (winMs / 1000) * 2) return [];
80
+ const filtered = highpass(samples, sr, highpassHz);
81
+ const { flux, winSamples } = energyFlux(filtered, sr, winMs);
82
+ if (flux.length < 2) return [];
83
+
84
+ let peak = 0;
85
+ for (let i = 0; i < flux.length; i++) if (flux[i] > peak) peak = flux[i];
86
+ if (peak < 1e-12) return [];
87
+ const threshold = peak * thresholdRatio;
88
+ const minDistWin = Math.max(1, Math.round((minSepMs / 1000) * sr / winSamples));
89
+
90
+ // local-maxima peak pick with min distance (mirrors scipy find_peaks)
91
+ const onsets = [];
92
+ let lastPeakWin = -Infinity;
93
+ for (let i = 1; i < flux.length - 1; i++) {
94
+ if (flux[i] < threshold) continue;
95
+ if (flux[i] < flux[i - 1] || flux[i] < flux[i + 1]) continue; // local max
96
+ if (i - lastPeakWin < minDistWin) {
97
+ // keep the stronger of the two within the refractory window
98
+ if (onsets.length && flux[i] > flux[lastPeakWin]) {
99
+ onsets[onsets.length - 1] = ((i * winSamples) / sr) * 1000;
100
+ lastPeakWin = i;
101
+ }
102
+ continue;
103
+ }
104
+ onsets.push(((i * winSamples) / sr) * 1000);
105
+ lastPeakWin = i;
106
+ }
107
+ return onsets;
108
+ }
109
+
110
+ // ──────── Streaming (live mic) ─────────────────────────────────────
111
+
112
+ /**
113
+ * Adaptive, real-time onset detector fed by filtered audio blocks.
114
+ *
115
+ * Threshold adapts to a slowly-tracked noise floor so it works without a
116
+ * global maximum (impossible to know live). An onset fires when the flux
117
+ * spikes well above the floor and the refractory window has elapsed.
118
+ */
119
+ export class StreamingOnsetDetector {
120
+ /**
121
+ * @param {object} o
122
+ * @param {number} o.sampleRate
123
+ * @param {(timeMs:number, strength:number)=>void} o.onOnset
124
+ * @param {number} [o.winMs] energy window (ms)
125
+ * @param {number} [o.refractoryMs] min gap between onsets
126
+ * @param {number} [o.thresholdFactor] flux must exceed floor*factor
127
+ * @param {number} [o.absMin] absolute floor on flux energy
128
+ */
129
+ constructor(o) {
130
+ this.sr = o.sampleRate;
131
+ this.onOnset = o.onOnset;
132
+ this.winMs = o.winMs ?? 5;
133
+ this.refractoryMs = o.refractoryMs ?? 120;
134
+ this.thresholdFactor = o.thresholdFactor ?? 4.0;
135
+ this.absMin = o.absMin ?? 1e-5;
136
+ this.winSamples = Math.max(1, Math.round((this.winMs / 1000) * this.sr));
137
+ this._acc = 0; // energy accumulator for current window
138
+ this._accN = 0;
139
+ this._winIndex = 0; // running window counter since start
140
+ this._prevEnergy = null;
141
+ this._floor = this.absMin; // EMA noise floor of flux
142
+ this._lastOnsetMs = -Infinity;
143
+ // EMA coefficient (~300 ms time constant on the flux floor)
144
+ const tau = 0.3;
145
+ this._alpha = Math.exp(-(this.winMs / 1000) / tau);
146
+ }
147
+
148
+ /** Feed one block of already-highpassed samples. baseTimeMs = block start. */
149
+ process(block, baseTimeMs) {
150
+ for (let i = 0; i < block.length; i++) {
151
+ const v = block[i];
152
+ this._acc += v * v;
153
+ this._accN += 1;
154
+ if (this._accN >= this.winSamples) {
155
+ const energy = this._acc;
156
+ const winTimeMs = baseTimeMs + ((i + 1) / this.sr) * 1000;
157
+ this._onWindow(energy, winTimeMs);
158
+ this._acc = 0;
159
+ this._accN = 0;
160
+ this._winIndex += 1;
161
+ }
162
+ }
163
+ }
164
+
165
+ _onWindow(energy, timeMs) {
166
+ if (this._prevEnergy !== null) {
167
+ const flux = Math.max(0, energy - this._prevEnergy);
168
+ const trigger = Math.max(this.absMin, this._floor * this.thresholdFactor);
169
+ if (flux > trigger && timeMs - this._lastOnsetMs >= this.refractoryMs) {
170
+ this._lastOnsetMs = timeMs;
171
+ this.onOnset?.(timeMs, flux);
172
+ } else {
173
+ // only let the floor track quiet/background frames
174
+ this._floor = this._alpha * this._floor + (1 - this._alpha) * flux;
175
+ }
176
+ }
177
+ this._prevEnergy = energy;
178
+ }
179
+ }
lib/mic.js ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Live microphone capture → onset stream.
3
+ *
4
+ * Web Audio graph:
5
+ * getUserMedia → MediaStreamSource → BiquadFilter(highpass) →
6
+ * ScriptProcessor → (muted gain → destination, just to keep it pumping)
7
+ *
8
+ * The ScriptProcessor receives already-high-passed blocks and feeds them to
9
+ * the StreamingOnsetDetector (lib/detector.js). Each detected onset is
10
+ * reported with a session-relative timestamp (ms) suitable for wire.js
11
+ * decoding. A level callback drives the visualizer.
12
+ *
13
+ * Browser audio "enhancements" (echo cancellation, noise suppression, auto
14
+ * gain) are explicitly DISABLED — they treat sharp clicks as noise and would
15
+ * gut exactly the signal we need.
16
+ */
17
+ import { StreamingOnsetDetector } from "./detector.js";
18
+
19
+ export class Mic {
20
+ /**
21
+ * @param {object} o
22
+ * @param {(timeMs:number, strength:number)=>void} o.onOnset
23
+ * @param {(level:number, wave:Float32Array)=>void} [o.onLevel]
24
+ * @param {number} [o.highpassHz]
25
+ * @param {object} [o.detector] extra StreamingOnsetDetector options
26
+ */
27
+ constructor(o) {
28
+ this.opts = o;
29
+ this.highpassHz = o.highpassHz ?? 2000;
30
+ this.ctx = null;
31
+ this.stream = null;
32
+ this._nodes = [];
33
+ this._detector = null;
34
+ this._elapsedMs = 0;
35
+ this.running = false;
36
+ }
37
+
38
+ async start() {
39
+ const AC = window.AudioContext || window.webkitAudioContext;
40
+ this.stream = await navigator.mediaDevices.getUserMedia({
41
+ audio: {
42
+ echoCancellation: false,
43
+ noiseSuppression: false,
44
+ autoGainControl: false,
45
+ channelCount: 1,
46
+ },
47
+ });
48
+ this.ctx = new AC();
49
+ if (this.ctx.state === "suspended") await this.ctx.resume();
50
+ const sr = this.ctx.sampleRate;
51
+
52
+ const src = this.ctx.createMediaStreamSource(this.stream);
53
+ const hp = this.ctx.createBiquadFilter();
54
+ hp.type = "highpass";
55
+ hp.frequency.value = this.highpassHz;
56
+ hp.Q.value = Math.SQRT1_2;
57
+
58
+ const bufferSize = 2048;
59
+ const proc = this.ctx.createScriptProcessor(bufferSize, 1, 1);
60
+ const mute = this.ctx.createGain();
61
+ mute.gain.value = 0;
62
+
63
+ this._detector = new StreamingOnsetDetector({
64
+ sampleRate: sr,
65
+ onOnset: (timeMs, strength) => this.opts.onOnset?.(timeMs, strength),
66
+ ...(this.opts.detector || {}),
67
+ });
68
+ this._elapsedMs = 0;
69
+ this.running = true;
70
+
71
+ proc.onaudioprocess = (e) => {
72
+ if (!this.running) return;
73
+ const block = e.inputBuffer.getChannelData(0);
74
+ const base = this._elapsedMs;
75
+ this._detector.process(block, base);
76
+ this._elapsedMs += (block.length / sr) * 1000;
77
+ if (this.opts.onLevel) {
78
+ let sum = 0;
79
+ for (let i = 0; i < block.length; i++) sum += block[i] * block[i];
80
+ this.opts.onLevel(Math.sqrt(sum / block.length), block);
81
+ }
82
+ };
83
+
84
+ src.connect(hp);
85
+ hp.connect(proc);
86
+ proc.connect(mute);
87
+ mute.connect(this.ctx.destination);
88
+ this._nodes = [src, hp, proc, mute];
89
+ }
90
+
91
+ /** Current session clock (ms since start) — handy to time out a message. */
92
+ now() {
93
+ return this._elapsedMs;
94
+ }
95
+
96
+ async stop() {
97
+ this.running = false;
98
+ this._nodes.forEach((n) => { try { n.disconnect(); } catch { /* */ } });
99
+ this._nodes = [];
100
+ if (this.stream) {
101
+ this.stream.getTracks().forEach((t) => t.stop());
102
+ this.stream = null;
103
+ }
104
+ if (this.ctx) { try { await this.ctx.close(); } catch { /* */ } this.ctx = null; }
105
+ }
106
+ }
lib/morse.js ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Morse code tables + text <-> morse-string conversion.
3
+ *
4
+ * A "morse string" here uses the conventional ASCII rendering:
5
+ * - '.' dot
6
+ * - '-' dash
7
+ * - ' ' (single space) gap between letters
8
+ * - '/' gap between words (we accept ' / ' or '/' on input)
9
+ *
10
+ * Example: "SOS" <-> "... --- ..."
11
+ *
12
+ * This module is pure (no DOM, no audio, no SDK) so it is unit-tested
13
+ * directly in Node. The physical *timing* of dots/dashes lives in
14
+ * wire.js + timing.js; this layer is purely symbolic.
15
+ */
16
+
17
+ // International Morse (ITU): letters, digits, and the common punctuation
18
+ // that survives an impulse channel. Prosigns are intentionally omitted.
19
+ export const CHAR_TO_MORSE = Object.freeze({
20
+ A: ".-", B: "-...", C: "-.-.", D: "-..", E: ".", F: "..-.",
21
+ G: "--.", H: "....", I: "..", J: ".---", K: "-.-", L: ".-..",
22
+ M: "--", N: "-.", O: "---", P: ".--.", Q: "--.-", R: ".-.",
23
+ S: "...", T: "-", U: "..-", V: "...-", W: ".--", X: "-..-",
24
+ Y: "-.--", Z: "--..",
25
+ 0: "-----", 1: ".----", 2: "..---", 3: "...--", 4: "....-",
26
+ 5: ".....", 6: "-....", 7: "--...", 8: "---..", 9: "----.",
27
+ ".": ".-.-.-", ",": "--..--", "?": "..--..", "'": ".----.",
28
+ "!": "-.-.--", "/": "-..-.", "(": "-.--.", ")": "-.--.-",
29
+ "&": ".-...", ":": "---...", ";": "-.-.-.", "=": "-...-",
30
+ "+": ".-.-.", "-": "-....-", "_": "..--.-", '"': ".-..-.",
31
+ "$": "...-..-", "@": ".--.-.",
32
+ });
33
+
34
+ // Reverse lookup: morse pattern -> character.
35
+ export const MORSE_TO_CHAR = Object.freeze(
36
+ Object.fromEntries(Object.entries(CHAR_TO_MORSE).map(([c, m]) => [m, c])),
37
+ );
38
+
39
+ /** True if `ch` (single char) can be transmitted. Case-insensitive. */
40
+ export function isEncodable(ch) {
41
+ return Object.prototype.hasOwnProperty.call(CHAR_TO_MORSE, ch.toUpperCase());
42
+ }
43
+
44
+ /**
45
+ * Encode arbitrary text to a morse string.
46
+ *
47
+ * - Case-insensitive.
48
+ * - Runs of whitespace collapse to a single word break ('/').
49
+ * - Unknown characters are dropped, but reported in `.skipped` so the UI
50
+ * can warn ("é, 😀 can't be sent in Morse").
51
+ *
52
+ * Returns { morse, skipped }.
53
+ */
54
+ export function textToMorse(text) {
55
+ const words = String(text).trim().toUpperCase().split(/\s+/).filter(Boolean);
56
+ const skipped = [];
57
+ const encodedWords = words.map((word) => {
58
+ const letters = [];
59
+ for (const ch of word) {
60
+ const code = CHAR_TO_MORSE[ch];
61
+ if (code) letters.push(code);
62
+ else skipped.push(ch);
63
+ }
64
+ return letters.join(" ");
65
+ }).filter(Boolean);
66
+ return { morse: encodedWords.join(" / "), skipped };
67
+ }
68
+
69
+ /**
70
+ * Decode a morse string back to text. Tolerant of input shape:
71
+ * "... --- ...", "...|---|...", "-.-. -.-- / -.-." etc.
72
+ * Letter separators: one or more spaces. Word separators: '/' (optionally
73
+ * space-padded). Unknown patterns become '?'.
74
+ */
75
+ export function morseToText(morse) {
76
+ return String(morse)
77
+ .trim()
78
+ .split(/\s*\/\s*|\s{2,}\/?\s*/) // word breaks
79
+ .map((word) =>
80
+ word
81
+ .trim()
82
+ .split(/\s+/)
83
+ .filter(Boolean)
84
+ .map((pat) => MORSE_TO_CHAR[pat] ?? (pat ? "?" : ""))
85
+ .join(""),
86
+ )
87
+ .filter((w) => w.length > 0)
88
+ .join(" ");
89
+ }
90
+
91
+ /**
92
+ * Flatten a morse string into the element/gap token stream the wire layer
93
+ * consumes. Tokens:
94
+ * 'dot' | 'dah' | 'elemGap' | 'letterGap' | 'wordGap'
95
+ *
96
+ * No leading/trailing gaps are emitted.
97
+ */
98
+ export function morseToTokens(morse) {
99
+ const tokens = [];
100
+ const words = String(morse).trim().split(/\s*\/\s*/).filter(Boolean);
101
+ words.forEach((word, wi) => {
102
+ if (wi > 0) tokens.push("wordGap");
103
+ const letters = word.trim().split(/\s+/).filter(Boolean);
104
+ letters.forEach((letter, li) => {
105
+ if (li > 0) tokens.push("letterGap");
106
+ [...letter].forEach((sym, si) => {
107
+ if (si > 0) tokens.push("elemGap");
108
+ tokens.push(sym === "." ? "dot" : "dah");
109
+ });
110
+ });
111
+ });
112
+ return tokens;
113
+ }
lib/robot-tapper.js ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Robot transmitter: claps the two antennas together on schedule so each
3
+ * onset produces an audible click. Consumes the same onset schedule from
4
+ * wire.js that the speaker Synth uses, so robot and device speak one wire code.
5
+ *
6
+ * Physics (from marionette/tests/test_antenna_collision.py, proven safe):
7
+ * - Right antenna held at a fixed angle.
8
+ * - Left antenna slams in past the contact point and is commanded to hold
9
+ * briefly — the low-PID antennas stall at contact (no damage) and make a
10
+ * crisp click — then returns to rest.
11
+ *
12
+ * We drive it in real time with `reachy.setAntennasDeg(right, left)` at
13
+ * ~50 Hz, shaping the left antenna's target as a function of time. All
14
+ * geometry/timing is configurable so it can be calibrated on hardware
15
+ * (contact angle, hold, command→sound latency) without touching logic.
16
+ */
17
+
18
+ export const DEFAULT_TAP_PROFILE = Object.freeze({
19
+ rightRestDeg: -39, // right antenna held here (≈ -0.68 rad)
20
+ leftRestDeg: 0, // left antenna resting position
21
+ collisionDeg: 40, // left commanded past contact (~34°) so it stalls + clicks
22
+ approachMs: 80, // ramp rest → collision
23
+ holdMs: 50, // dwell at collision (stall against right antenna)
24
+ returnMs: 80, // ramp back to rest
25
+ rateHz: 50, // command rate
26
+ leadMs: 0, // advance whole timeline to offset command→sound latency
27
+ });
28
+
29
+ export class RobotTapper {
30
+ constructor(reachy, profile = {}) {
31
+ this.reachy = reachy;
32
+ this.p = { ...DEFAULT_TAP_PROFILE, ...profile };
33
+ this._timer = null;
34
+ }
35
+
36
+ /** Left-antenna angle (deg) at time `t` ms, given the slam windows. */
37
+ _leftAngleAt(t, onsets) {
38
+ const { leftRestDeg, collisionDeg, approachMs, holdMs } = this.p;
39
+ for (const T of onsets) {
40
+ const a0 = T - approachMs; // start approaching
41
+ const hEnd = T + holdMs; // end of hold
42
+ const rEnd = hEnd + this.p.returnMs; // back to rest
43
+ if (t < a0 || t > rEnd) continue;
44
+ if (t <= T) {
45
+ const f = approachMs > 0 ? (t - a0) / approachMs : 1;
46
+ return leftRestDeg + (collisionDeg - leftRestDeg) * easeIn(f);
47
+ }
48
+ if (t <= hEnd) return collisionDeg;
49
+ const f = (t - hEnd) / this.p.returnMs;
50
+ return collisionDeg + (leftRestDeg - collisionDeg) * easeOut(f);
51
+ }
52
+ return leftRestDeg;
53
+ }
54
+
55
+ /**
56
+ * Run the clap timeline.
57
+ * @param {number[]} onsetsMs
58
+ * @param {object} [o]
59
+ * @param {(i:number)=>void} [o.onTap] fires near each impact (UI highlight)
60
+ * @param {AbortSignal} [o.signal]
61
+ * @returns {Promise<void>} resolves when the sequence completes/aborts
62
+ */
63
+ tap(onsetsMs, { onTap, signal } = {}) {
64
+ const onsets = onsetsMs.map((t) => t - this.p.leadMs);
65
+ const period = Math.max(10, Math.round(1000 / this.p.rateHz));
66
+ const endMs = (onsets.length ? onsets[onsets.length - 1] : 0)
67
+ + this.p.holdMs + this.p.returnMs + 200;
68
+
69
+ return new Promise((resolve) => {
70
+ const t0 = performance.now();
71
+ let nextTapIdx = 0;
72
+ const tapMarks = onsetsMs.slice(); // original (UI) onset times
73
+ const finish = () => {
74
+ clearInterval(this._timer);
75
+ this._timer = null;
76
+ // settle antennas back to rest
77
+ try { this.reachy.setAntennasDeg(this.p.rightRestDeg, this.p.leftRestDeg); } catch { /* ignore */ }
78
+ resolve();
79
+ };
80
+ signal?.addEventListener("abort", finish, { once: true });
81
+
82
+ this._timer = setInterval(() => {
83
+ const t = performance.now() - t0;
84
+ if (t > endMs || signal?.aborted) return finish();
85
+ const left = this._leftAngleAt(t, onsets);
86
+ try {
87
+ this.reachy.setAntennasDeg(this.p.rightRestDeg, left);
88
+ } catch { /* transient send errors are non-fatal */ }
89
+ // UI tap highlight when we cross an original onset time
90
+ while (nextTapIdx < tapMarks.length && t >= tapMarks[nextTapIdx]) {
91
+ onTap?.(nextTapIdx);
92
+ nextTapIdx += 1;
93
+ }
94
+ }, period);
95
+ });
96
+ }
97
+
98
+ /** One calibration clap right now (no schedule). */
99
+ async testClap() {
100
+ await this.tap([100]);
101
+ }
102
+
103
+ stop() {
104
+ if (this._timer) {
105
+ clearInterval(this._timer);
106
+ this._timer = null;
107
+ }
108
+ try { this.reachy.setAntennasDeg(this.p.rightRestDeg, this.p.leftRestDeg); } catch { /* ignore */ }
109
+ }
110
+ }
111
+
112
+ function easeIn(x) {
113
+ const c = Math.min(1, Math.max(0, x));
114
+ return c * c; // accelerate into the impact
115
+ }
116
+ function easeOut(x) {
117
+ const c = Math.min(1, Math.max(0, x));
118
+ return 1 - (1 - c) * (1 - c);
119
+ }
lib/synth.js ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Web Audio emitter — plays an onset schedule as audible clicks/beeps from
3
+ * the device speaker (phone or laptop). This is the "device transmits" path;
4
+ * the robot path lives in robot-tapper.js. Both consume the same schedule
5
+ * from wire.js, so a beeping phone and a tapping robot are interoperable.
6
+ *
7
+ * Each onset is a short percussive blip: a fast-decaying tone with a hard
8
+ * attack so the listener's onset detector keys cleanly off its start. The
9
+ * default tone sits in the detector's high-pass band (~2 kHz+) for a clean,
10
+ * Morse-buzzer feel.
11
+ */
12
+
13
+ export class Synth {
14
+ constructor({ freq = 2200, clickMs = 50 } = {}) {
15
+ this.freq = freq;
16
+ this.clickMs = clickMs;
17
+ this.ctx = null;
18
+ this._stopFns = [];
19
+ }
20
+
21
+ _ensureCtx() {
22
+ if (!this.ctx) {
23
+ const AC = window.AudioContext || window.webkitAudioContext;
24
+ this.ctx = new AC();
25
+ }
26
+ if (this.ctx.state === "suspended") this.ctx.resume();
27
+ return this.ctx;
28
+ }
29
+
30
+ /** Schedule one blip at AudioContext time `when` (seconds). */
31
+ _blip(when, { freq = this.freq, durMs = this.clickMs } = {}) {
32
+ const ctx = this.ctx;
33
+ const osc = ctx.createOscillator();
34
+ const gain = ctx.createGain();
35
+ osc.type = "sine";
36
+ osc.frequency.value = freq;
37
+ const dur = durMs / 1000;
38
+ // Hard attack (1 ms), exponential-ish decay → percussive click.
39
+ gain.gain.setValueAtTime(0.0001, when);
40
+ gain.gain.exponentialRampToValueAtTime(0.9, when + 0.001);
41
+ gain.gain.exponentialRampToValueAtTime(0.0001, when + dur);
42
+ osc.connect(gain).connect(ctx.destination);
43
+ osc.start(when);
44
+ osc.stop(when + dur + 0.02);
45
+ }
46
+
47
+ /**
48
+ * Play a schedule (onset times in ms from start).
49
+ * @param {number[]} onsetsMs
50
+ * @param {object} [o]
51
+ * @param {(i:number, total:number)=>void} [o.onBlip] progress per blip
52
+ * @param {()=>void} [o.onDone]
53
+ * @returns {()=>void} cancel function
54
+ */
55
+ play(onsetsMs, { onBlip, onDone, clickMs } = {}) {
56
+ const ctx = this._ensureCtx();
57
+ const t0 = ctx.currentTime + 0.08; // small lead so the first blip isn't clipped
58
+ const dur = clickMs ?? this.clickMs;
59
+ onsetsMs.forEach((ms) => this._blip(t0 + ms / 1000, { durMs: dur }));
60
+
61
+ // UI progress + completion via wall-clock timers (audio is sample-accurate;
62
+ // these are just for the highlight animation).
63
+ const startWall = performance.now();
64
+ const total = onsetsMs.length;
65
+ const timers = [];
66
+ onsetsMs.forEach((ms, i) => {
67
+ timers.push(setTimeout(() => onBlip?.(i, total), ms + 80));
68
+ });
69
+ const endMs = (onsetsMs.length ? onsetsMs[onsetsMs.length - 1] : 0) + dur + 120;
70
+ const doneTimer = setTimeout(() => onDone?.(), endMs);
71
+
72
+ const cancel = () => {
73
+ timers.forEach(clearTimeout);
74
+ clearTimeout(doneTimer);
75
+ };
76
+ this._stopFns.push(cancel);
77
+ void startWall;
78
+ return cancel;
79
+ }
80
+
81
+ /** Cancel any pending UI timers (scheduled audio blips are tiny and let ring). */
82
+ stop() {
83
+ this._stopFns.forEach((fn) => fn());
84
+ this._stopFns = [];
85
+ }
86
+ }
lib/timing.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Timing configuration for the impulse wire code.
3
+ *
4
+ * Everything is expressed as a multiple of one base `unitMs`, the single
5
+ * speed knob. The gap hierarchy must stay well-separated so the decoder can
6
+ * bin each inter-onset interval unambiguously:
7
+ *
8
+ * dahGapMs < elemGapMs < letterGapMs < wordGapMs
9
+ *
10
+ * Defaults are deliberately *slow and robust* (large gaps) — through-air
11
+ * impulse detection with room reverb needs generous spacing. Calibration on
12
+ * real hardware can tighten these; nothing in the codec hard-codes a value.
13
+ *
14
+ * Note: the detector enforces a minimum onset separation (~150-200 ms) to
15
+ * reject reverb tails, so `dahGapMs` (the *smallest* meaningful gap, between
16
+ * the two taps of a dash) must stay comfortably above that floor.
17
+ */
18
+
19
+ /**
20
+ * Build a full timing profile from a base unit.
21
+ *
22
+ * @param {number} unitMs base time unit in milliseconds
23
+ * @returns {{unitMs:number, dahGapMs:number, elemGapMs:number,
24
+ * letterGapMs:number, wordGapMs:number, clickMs:number,
25
+ * decisions:{elem:number, letter:number, word:number}}}
26
+ */
27
+ export function makeTiming(unitMs = 120) {
28
+ const u = Math.max(40, Math.round(unitMs));
29
+ const dahGapMs = 2 * u; // between the two taps of a dash
30
+ const elemGapMs = 4 * u; // between elements of one letter
31
+ const letterGapMs = 8 * u; // between letters
32
+ const wordGapMs = 14 * u; // between words
33
+ return Object.freeze({
34
+ unitMs: u,
35
+ dahGapMs,
36
+ elemGapMs,
37
+ letterGapMs,
38
+ wordGapMs,
39
+ // Nominal audible click length on the speaker (synth only). The
40
+ // detector keys off onsets, so this does not affect decoding.
41
+ clickMs: Math.min(60, Math.round(u * 0.5)),
42
+ // Decision boundaries (geometric midpoints) used by the decoder to
43
+ // classify an observed inter-onset interval into a gap class.
44
+ decisions: Object.freeze({
45
+ elem: Math.round(Math.sqrt(dahGapMs * elemGapMs)), // dah|elem split
46
+ letter: Math.round(Math.sqrt(elemGapMs * letterGapMs)),
47
+ word: Math.round(Math.sqrt(letterGapMs * wordGapMs)),
48
+ }),
49
+ });
50
+ }
51
+
52
+ /** Named speed presets (unit in ms). Smaller = faster. */
53
+ export const SPEED_PRESETS = Object.freeze({
54
+ relaxed: 160,
55
+ normal: 120,
56
+ brisk: 90,
57
+ });
58
+
59
+ export const DEFAULT_TIMING = makeTiming(SPEED_PRESETS.normal);
lib/version.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ // Deployment version label, shown in Settings so you can verify which build
2
+ // is actually loaded after a push. Format: "YYYY-MM-DD rN", bumped on push.
3
+ export const APP_VERSION = "2026-06-01 r1 initial";
lib/viz.js ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Canvas visualizer: a scrolling level meter with vertical markers at each
3
+ * detected onset, plus a recent-waveform trace. Purely presentational; it
4
+ * reads values pushed from the Mic level callback and onset events.
5
+ */
6
+ export class Scope {
7
+ constructor(canvas) {
8
+ this.canvas = canvas;
9
+ this.ctx = canvas.getContext("2d");
10
+ this.level = new Float32Array(600); // ring buffer of recent levels
11
+ this.head = 0;
12
+ this.onsets = []; // {age} markers scrolling left
13
+ this._raf = null;
14
+ this._peak = 0.02;
15
+ }
16
+
17
+ pushLevel(level) {
18
+ this.level[this.head] = level;
19
+ this.head = (this.head + 1) % this.level.length;
20
+ if (level > this._peak) this._peak = level;
21
+ else this._peak = this._peak * 0.999 + level * 0.001;
22
+ }
23
+
24
+ markOnset() {
25
+ // a marker positioned at the current write head
26
+ this.onsets.push({ pos: this.head });
27
+ if (this.onsets.length > 64) this.onsets.shift();
28
+ }
29
+
30
+ start() {
31
+ const draw = () => {
32
+ this._draw();
33
+ this._raf = requestAnimationFrame(draw);
34
+ };
35
+ draw();
36
+ }
37
+
38
+ stop() {
39
+ if (this._raf) cancelAnimationFrame(this._raf);
40
+ this._raf = null;
41
+ }
42
+
43
+ _draw() {
44
+ const { ctx, canvas } = this;
45
+ const dpr = window.devicePixelRatio || 1;
46
+ const w = canvas.clientWidth, h = canvas.clientHeight;
47
+ if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
48
+ canvas.width = w * dpr; canvas.height = h * dpr;
49
+ }
50
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
51
+ const css = getComputedStyle(canvas);
52
+ ctx.clearRect(0, 0, w, h);
53
+
54
+ const n = this.level.length;
55
+ const scale = 1 / (this._peak * 1.4 + 1e-6);
56
+ const mid = h / 2;
57
+
58
+ // level fill
59
+ ctx.beginPath();
60
+ ctx.moveTo(0, mid);
61
+ for (let i = 0; i < n; i++) {
62
+ const idx = (this.head + i) % n;
63
+ const x = (i / n) * w;
64
+ const v = Math.min(1, this.level[idx] * scale);
65
+ ctx.lineTo(x, mid - v * (h * 0.45));
66
+ }
67
+ for (let i = n - 1; i >= 0; i--) {
68
+ const idx = (this.head + i) % n;
69
+ const x = (i / n) * w;
70
+ const v = Math.min(1, this.level[idx] * scale);
71
+ ctx.lineTo(x, mid + v * (h * 0.45));
72
+ }
73
+ ctx.closePath();
74
+ ctx.fillStyle = css.getPropertyValue("--scope-fill") || "rgba(120,160,255,0.5)";
75
+ ctx.fill();
76
+
77
+ // onset markers (their stored head pos maps to a position in the ring)
78
+ ctx.strokeStyle = css.getPropertyValue("--scope-mark") || "rgba(255,90,120,0.9)";
79
+ ctx.lineWidth = 2;
80
+ for (const o of this.onsets) {
81
+ let rel = (o.pos - this.head + n) % n; // distance behind head
82
+ const x = (rel / n) * w;
83
+ ctx.beginPath();
84
+ ctx.moveTo(x, 4);
85
+ ctx.lineTo(x, h - 4);
86
+ ctx.stroke();
87
+ }
88
+ }
89
+ }
lib/wire.js ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The "wire code": the bridge between symbolic Morse and physical impulses.
3
+ *
4
+ * Encoding (emit): morse tokens -> a schedule of onset times (ms from start).
5
+ * - dot = 1 impulse
6
+ * - dash = 2 impulses spaced by `dahGapMs`
7
+ * - elem/letter/word gaps advance the cursor between elements
8
+ *
9
+ * Decoding (listen): a list of detected onset times (ms) -> morse -> text.
10
+ * - Cluster onsets separated by < the dah/elem decision boundary into one
11
+ * element (1 impulse = dot, >=2 = dash).
12
+ * - Classify the gap between clusters as elem / letter / word.
13
+ *
14
+ * Onset-only: nothing here depends on how *long* a sound is, so a robot's
15
+ * antenna click and a phone's short beep are the same on the wire. Pure
16
+ * module (no DOM / audio / SDK) — unit-tested directly.
17
+ */
18
+ import { morseToTokens, morseToText, textToMorse } from "./morse.js";
19
+ import { DEFAULT_TIMING } from "./timing.js";
20
+
21
+ /**
22
+ * morse tokens -> onset schedule.
23
+ * @returns {number[]} onset times in ms, starting at 0, sorted ascending.
24
+ */
25
+ export function tokensToOnsets(tokens, timing = DEFAULT_TIMING) {
26
+ const onsets = [];
27
+ let cursor = 0;
28
+ for (const tok of tokens) {
29
+ switch (tok) {
30
+ case "dot":
31
+ onsets.push(cursor);
32
+ break;
33
+ case "dah":
34
+ onsets.push(cursor);
35
+ cursor += timing.dahGapMs;
36
+ onsets.push(cursor);
37
+ break;
38
+ case "elemGap":
39
+ cursor += timing.elemGapMs;
40
+ break;
41
+ case "letterGap":
42
+ cursor += timing.letterGapMs;
43
+ break;
44
+ case "wordGap":
45
+ cursor += timing.wordGapMs;
46
+ break;
47
+ default:
48
+ break;
49
+ }
50
+ }
51
+ return onsets;
52
+ }
53
+
54
+ /**
55
+ * text -> everything an emitter needs.
56
+ * @returns {{morse:string, onsets:number[], durationMs:number, skipped:string[]}}
57
+ */
58
+ export function textToSchedule(text, timing = DEFAULT_TIMING) {
59
+ const { morse, skipped } = textToMorse(text);
60
+ const onsets = tokensToOnsets(morseToTokens(morse), timing);
61
+ const durationMs = onsets.length ? onsets[onsets.length - 1] + timing.clickMs : 0;
62
+ return { morse, onsets, durationMs, skipped };
63
+ }
64
+
65
+ /**
66
+ * Detected onset times (ms, any order) -> reconstructed morse string.
67
+ * Robust to jitter via the geometric decision boundaries in `timing`.
68
+ */
69
+ export function onsetsToMorse(onsetTimes, timing = DEFAULT_TIMING) {
70
+ const onsets = [...onsetTimes].sort((a, b) => a - b);
71
+ if (onsets.length === 0) return "";
72
+
73
+ // 1) Cluster impulses into elements. A gap below the dah/elem boundary
74
+ // means "same element" (the two taps of a dash).
75
+ const clusters = []; // { size, gapBefore } gapBefore = ioi from prev cluster's last onset
76
+ let size = 1;
77
+ let prevClusterLast = null;
78
+ for (let i = 1; i <= onsets.length; i++) {
79
+ const ioi = i < onsets.length ? onsets[i] - onsets[i - 1] : Infinity;
80
+ if (i < onsets.length && ioi < timing.decisions.elem) {
81
+ size += 1; // still same element
82
+ continue;
83
+ }
84
+ // close current cluster
85
+ const gapBefore = prevClusterLast === null
86
+ ? null
87
+ : onsets[i - size] - prevClusterLast;
88
+ clusters.push({ size, gapBefore });
89
+ prevClusterLast = onsets[i - 1];
90
+ size = 1;
91
+ }
92
+
93
+ // 2) Walk clusters, emitting symbols and letter/word separators.
94
+ const d = timing.decisions;
95
+ let out = "";
96
+ clusters.forEach((c, idx) => {
97
+ if (idx > 0) {
98
+ const g = c.gapBefore;
99
+ if (g >= d.word) out += " / ";
100
+ else if (g >= d.letter) out += " ";
101
+ // else elem gap -> no separator (same letter)
102
+ }
103
+ out += c.size >= 2 ? "-" : ".";
104
+ });
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * Detected onsets -> { morse, text }.
110
+ */
111
+ export function decodeOnsets(onsetTimes, timing = DEFAULT_TIMING) {
112
+ const morse = onsetsToMorse(onsetTimes, timing);
113
+ return { morse, text: morseToText(morse) };
114
+ }
main.js ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Reachy Mini — Morse Code. App entry.
3
+ *
4
+ * index.html → #root (host shell mount) + #app (in-iframe surface)
5
+ * main.js → dispatcher: standalone mountHost() | embed connectToHost()
6
+ *
7
+ * Standalone visit: mountHost() owns OAuth + robot picker + top bar, then
8
+ * iframes back here with ?embedded=1. Embed visit: connectToHost() brings up
9
+ * the WebRTC session and hands us a live `handle.reachy`. We build the UI on
10
+ * it and register onLeave for safe teardown. (Mirrors marionette-js.)
11
+ *
12
+ * The robot is optional to the app's value: Compose→"This device", Listen,
13
+ * and Learn all work with just a phone/laptop. The robot unlocks antenna
14
+ * transmission and lets two Minis talk to each other.
15
+ */
16
+ import { mountHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/host/dist/entry/auto.js";
17
+ import { connectToHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/host/dist/entry/embed.js";
18
+ import { safelyReturnToPose } from "./lib/animation-helpers.js";
19
+
20
+ import { makeTiming, SPEED_PRESETS } from "./lib/timing.js";
21
+ import { Synth } from "./lib/synth.js";
22
+ import { RobotTapper, DEFAULT_TAP_PROFILE } from "./lib/robot-tapper.js";
23
+ import { Mic } from "./lib/mic.js";
24
+ import { el, clear } from "./views/dom.js";
25
+ import { createCompose } from "./views/compose.js";
26
+ import { createListen } from "./views/listen.js";
27
+ import { createLearn } from "./views/learn.js";
28
+ import { createSettings } from "./views/settings.js";
29
+
30
+ const params = new URLSearchParams(window.location.search);
31
+ const isEmbed = params.get("embedded") === "1" || params.get("embed") === "1";
32
+
33
+ if (!isEmbed) {
34
+ mountHost({
35
+ appName: "Morse Code",
36
+ appIconUrl: "public/icon.svg",
37
+ appEmoji: "📡",
38
+ // We capture the LAPTOP/PHONE mic via getUserMedia for decoding; we do
39
+ // not need the robot's inbound audio track, so no enableMicrophone.
40
+ });
41
+ } else {
42
+ bootEmbed().catch((err) => {
43
+ console.error("[morse] bootEmbed failed:", err);
44
+ document.body.innerHTML =
45
+ `<pre style="padding:24px;color:#c00;font:14px ui-monospace">` +
46
+ `Morse Code failed to start in the iframe:\n\n${err?.message || err}\n\n` +
47
+ `Try reloading the Space; check the daemon version and the SDK pin in main.js.</pre>`;
48
+ });
49
+ }
50
+
51
+ async function bootEmbed() {
52
+ const handle = await connectToHost();
53
+ const reachy = handle.reachy;
54
+
55
+ document.documentElement.setAttribute("data-theme", handle.theme || "dark");
56
+ handle.onThemeChange?.((t) => document.documentElement.setAttribute("data-theme", t));
57
+
58
+ // ─── Shared, mutable config ───────────────────────────────────────
59
+ const config = {
60
+ speed: "normal",
61
+ unitMs: SPEED_PRESETS.normal,
62
+ emitter: reachy ? "robot" : "device",
63
+ beepFreq: 2200,
64
+ detector: { highpassHz: 2000, thresholdFactor: 4.0 },
65
+ tap: { ...DEFAULT_TAP_PROFILE },
66
+ };
67
+
68
+ const synth = new Synth({ freq: config.beepFreq });
69
+ let tapper = reachy ? new RobotTapper(reachy, config.tap) : null;
70
+
71
+ const ctx = {
72
+ reachy,
73
+ state: config,
74
+ timing: () => makeTiming(config.unitMs),
75
+ setSpeed(preset) {
76
+ config.speed = preset;
77
+ if (SPEED_PRESETS[preset]) config.unitMs = SPEED_PRESETS[preset];
78
+ },
79
+ synth: () => { synth.freq = config.beepFreq; return synth; },
80
+ tapper: () => {
81
+ if (!tapper && reachy) tapper = new RobotTapper(reachy, config.tap);
82
+ if (tapper) tapper.p = { ...DEFAULT_TAP_PROFILE, ...config.tap };
83
+ return tapper;
84
+ },
85
+ makeMic: (cbs) => new Mic({
86
+ highpassHz: config.detector.highpassHz,
87
+ detector: { thresholdFactor: config.detector.thresholdFactor },
88
+ ...cbs,
89
+ }),
90
+ toast,
91
+ };
92
+
93
+ // ─── Views ────────────────────────────────────────────────────────
94
+ const views = {
95
+ compose: createCompose(ctx),
96
+ listen: createListen(ctx),
97
+ learn: createLearn(ctx),
98
+ };
99
+ const order = ["compose", "listen", "learn"];
100
+ const labels = { compose: "Compose", listen: "Listen", learn: "Learn" };
101
+ const icons = { compose: "✏️", listen: "🎧", learn: "📖" };
102
+
103
+ const app = document.getElementById("app");
104
+ clear(app);
105
+
106
+ const viewHost = el("main.view-host");
107
+ let current = null;
108
+ function show(name) {
109
+ if (current === name) return;
110
+ if (current && views[current].onHide) views[current].onHide();
111
+ current = name;
112
+ clear(viewHost);
113
+ viewHost.append(views[name].node);
114
+ views[name].onShow?.();
115
+ [...tabbar.children].forEach((b) =>
116
+ b.classList.toggle("active", b.dataset.view === name));
117
+ }
118
+
119
+ const tabbar = el("nav.tabbar");
120
+ order.forEach((name) => {
121
+ tabbar.append(el("button.tab", {
122
+ "data-view": name,
123
+ onclick: () => show(name),
124
+ }, [el("span.tab-ico", {}, icons[name]), el("span.tab-label", {}, labels[name])]));
125
+ });
126
+
127
+ const header = el("header.appbar", {}, [
128
+ el("div.brand", {}, [el("span.brand-logo", {}, "📡"), el("span", {}, "Morse Code")]),
129
+ el("button.icon-btn", { title: "Settings", onclick: openSettings }, "⚙️"),
130
+ ]);
131
+
132
+ app.append(header, viewHost, tabbar);
133
+ app.hidden = false;
134
+ show("compose");
135
+
136
+ // ─── Settings modal ─────────────────────────────────────────────
137
+ function openSettings() {
138
+ const sheet = createSettings(ctx);
139
+ const overlay = el("div.overlay", { onclick: (e) => { if (e.target === overlay) close(); } });
140
+ const close = () => overlay.remove();
141
+ const panel = el("div.sheet", {}, [
142
+ el("div.sheet-head", {}, [el("h2", {}, "Settings"),
143
+ el("button.icon-btn", { onclick: close }, "✕")]),
144
+ sheet.node,
145
+ ]);
146
+ overlay.append(panel);
147
+ document.body.append(overlay);
148
+ }
149
+
150
+ // ─── Safe teardown ────────────────────────────────────────────────
151
+ handle.onLeave?.(() => {
152
+ try { tapper?.stop(); } catch { /* */ }
153
+ try { synth.stop(); } catch { /* */ }
154
+ if (reachy) return safelyReturnToPose(reachy);
155
+ });
156
+
157
+ window.addEventListener("error", (e) => console.error("[morse] uncaught", e.message));
158
+ }
159
+
160
+ // ─── Toast ────────────────────────────────────────────────────────────
161
+ let toastTimer = null;
162
+ function toast(msg) {
163
+ let t = document.getElementById("toast");
164
+ if (!t) {
165
+ t = el("div#toast.toast");
166
+ document.body.append(t);
167
+ }
168
+ t.textContent = msg;
169
+ t.classList.add("show");
170
+ clearTimeout(toastTimer);
171
+ toastTimer = setTimeout(() => t.classList.remove("show"), 2600);
172
+ }
package-lock.json ADDED
@@ -0,0 +1,2018 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "reachy-mini-morse-code",
3
+ "version": "0.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "reachy-mini-morse-code",
9
+ "version": "0.0.0",
10
+ "devDependencies": {
11
+ "@playwright/test": "^1.48.0",
12
+ "jsdom": "^29.1.1",
13
+ "vitest": "^2.1.0"
14
+ }
15
+ },
16
+ "node_modules/@asamuzakjp/css-color": {
17
+ "version": "5.1.11",
18
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
19
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
20
+ "dev": true,
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@asamuzakjp/generational-cache": "^1.0.1",
24
+ "@csstools/css-calc": "^3.2.0",
25
+ "@csstools/css-color-parser": "^4.1.0",
26
+ "@csstools/css-parser-algorithms": "^4.0.0",
27
+ "@csstools/css-tokenizer": "^4.0.0"
28
+ },
29
+ "engines": {
30
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
31
+ }
32
+ },
33
+ "node_modules/@asamuzakjp/dom-selector": {
34
+ "version": "7.1.1",
35
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
36
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
37
+ "dev": true,
38
+ "license": "MIT",
39
+ "dependencies": {
40
+ "@asamuzakjp/generational-cache": "^1.0.1",
41
+ "@asamuzakjp/nwsapi": "^2.3.9",
42
+ "bidi-js": "^1.0.3",
43
+ "css-tree": "^3.2.1",
44
+ "is-potential-custom-element-name": "^1.0.1"
45
+ },
46
+ "engines": {
47
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
48
+ }
49
+ },
50
+ "node_modules/@asamuzakjp/generational-cache": {
51
+ "version": "1.0.1",
52
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
53
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
54
+ "dev": true,
55
+ "license": "MIT",
56
+ "engines": {
57
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
58
+ }
59
+ },
60
+ "node_modules/@asamuzakjp/nwsapi": {
61
+ "version": "2.3.9",
62
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
63
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
64
+ "dev": true,
65
+ "license": "MIT"
66
+ },
67
+ "node_modules/@bramus/specificity": {
68
+ "version": "2.4.2",
69
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
70
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
71
+ "dev": true,
72
+ "license": "MIT",
73
+ "dependencies": {
74
+ "css-tree": "^3.0.0"
75
+ },
76
+ "bin": {
77
+ "specificity": "bin/cli.js"
78
+ }
79
+ },
80
+ "node_modules/@csstools/color-helpers": {
81
+ "version": "6.0.2",
82
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
83
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
84
+ "dev": true,
85
+ "funding": [
86
+ {
87
+ "type": "github",
88
+ "url": "https://github.com/sponsors/csstools"
89
+ },
90
+ {
91
+ "type": "opencollective",
92
+ "url": "https://opencollective.com/csstools"
93
+ }
94
+ ],
95
+ "license": "MIT-0",
96
+ "engines": {
97
+ "node": ">=20.19.0"
98
+ }
99
+ },
100
+ "node_modules/@csstools/css-calc": {
101
+ "version": "3.2.1",
102
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
103
+ "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
104
+ "dev": true,
105
+ "funding": [
106
+ {
107
+ "type": "github",
108
+ "url": "https://github.com/sponsors/csstools"
109
+ },
110
+ {
111
+ "type": "opencollective",
112
+ "url": "https://opencollective.com/csstools"
113
+ }
114
+ ],
115
+ "license": "MIT",
116
+ "engines": {
117
+ "node": ">=20.19.0"
118
+ },
119
+ "peerDependencies": {
120
+ "@csstools/css-parser-algorithms": "^4.0.0",
121
+ "@csstools/css-tokenizer": "^4.0.0"
122
+ }
123
+ },
124
+ "node_modules/@csstools/css-color-parser": {
125
+ "version": "4.1.1",
126
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
127
+ "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
128
+ "dev": true,
129
+ "funding": [
130
+ {
131
+ "type": "github",
132
+ "url": "https://github.com/sponsors/csstools"
133
+ },
134
+ {
135
+ "type": "opencollective",
136
+ "url": "https://opencollective.com/csstools"
137
+ }
138
+ ],
139
+ "license": "MIT",
140
+ "dependencies": {
141
+ "@csstools/color-helpers": "^6.0.2",
142
+ "@csstools/css-calc": "^3.2.1"
143
+ },
144
+ "engines": {
145
+ "node": ">=20.19.0"
146
+ },
147
+ "peerDependencies": {
148
+ "@csstools/css-parser-algorithms": "^4.0.0",
149
+ "@csstools/css-tokenizer": "^4.0.0"
150
+ }
151
+ },
152
+ "node_modules/@csstools/css-parser-algorithms": {
153
+ "version": "4.0.0",
154
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
155
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
156
+ "dev": true,
157
+ "funding": [
158
+ {
159
+ "type": "github",
160
+ "url": "https://github.com/sponsors/csstools"
161
+ },
162
+ {
163
+ "type": "opencollective",
164
+ "url": "https://opencollective.com/csstools"
165
+ }
166
+ ],
167
+ "license": "MIT",
168
+ "engines": {
169
+ "node": ">=20.19.0"
170
+ },
171
+ "peerDependencies": {
172
+ "@csstools/css-tokenizer": "^4.0.0"
173
+ }
174
+ },
175
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
176
+ "version": "1.1.4",
177
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
178
+ "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
179
+ "dev": true,
180
+ "funding": [
181
+ {
182
+ "type": "github",
183
+ "url": "https://github.com/sponsors/csstools"
184
+ },
185
+ {
186
+ "type": "opencollective",
187
+ "url": "https://opencollective.com/csstools"
188
+ }
189
+ ],
190
+ "license": "MIT-0",
191
+ "peerDependencies": {
192
+ "css-tree": "^3.2.1"
193
+ },
194
+ "peerDependenciesMeta": {
195
+ "css-tree": {
196
+ "optional": true
197
+ }
198
+ }
199
+ },
200
+ "node_modules/@csstools/css-tokenizer": {
201
+ "version": "4.0.0",
202
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
203
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
204
+ "dev": true,
205
+ "funding": [
206
+ {
207
+ "type": "github",
208
+ "url": "https://github.com/sponsors/csstools"
209
+ },
210
+ {
211
+ "type": "opencollective",
212
+ "url": "https://opencollective.com/csstools"
213
+ }
214
+ ],
215
+ "license": "MIT",
216
+ "engines": {
217
+ "node": ">=20.19.0"
218
+ }
219
+ },
220
+ "node_modules/@esbuild/aix-ppc64": {
221
+ "version": "0.21.5",
222
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
223
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
224
+ "cpu": [
225
+ "ppc64"
226
+ ],
227
+ "dev": true,
228
+ "license": "MIT",
229
+ "optional": true,
230
+ "os": [
231
+ "aix"
232
+ ],
233
+ "engines": {
234
+ "node": ">=12"
235
+ }
236
+ },
237
+ "node_modules/@esbuild/android-arm": {
238
+ "version": "0.21.5",
239
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
240
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
241
+ "cpu": [
242
+ "arm"
243
+ ],
244
+ "dev": true,
245
+ "license": "MIT",
246
+ "optional": true,
247
+ "os": [
248
+ "android"
249
+ ],
250
+ "engines": {
251
+ "node": ">=12"
252
+ }
253
+ },
254
+ "node_modules/@esbuild/android-arm64": {
255
+ "version": "0.21.5",
256
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
257
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
258
+ "cpu": [
259
+ "arm64"
260
+ ],
261
+ "dev": true,
262
+ "license": "MIT",
263
+ "optional": true,
264
+ "os": [
265
+ "android"
266
+ ],
267
+ "engines": {
268
+ "node": ">=12"
269
+ }
270
+ },
271
+ "node_modules/@esbuild/android-x64": {
272
+ "version": "0.21.5",
273
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
274
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
275
+ "cpu": [
276
+ "x64"
277
+ ],
278
+ "dev": true,
279
+ "license": "MIT",
280
+ "optional": true,
281
+ "os": [
282
+ "android"
283
+ ],
284
+ "engines": {
285
+ "node": ">=12"
286
+ }
287
+ },
288
+ "node_modules/@esbuild/darwin-arm64": {
289
+ "version": "0.21.5",
290
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
291
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
292
+ "cpu": [
293
+ "arm64"
294
+ ],
295
+ "dev": true,
296
+ "license": "MIT",
297
+ "optional": true,
298
+ "os": [
299
+ "darwin"
300
+ ],
301
+ "engines": {
302
+ "node": ">=12"
303
+ }
304
+ },
305
+ "node_modules/@esbuild/darwin-x64": {
306
+ "version": "0.21.5",
307
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
308
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
309
+ "cpu": [
310
+ "x64"
311
+ ],
312
+ "dev": true,
313
+ "license": "MIT",
314
+ "optional": true,
315
+ "os": [
316
+ "darwin"
317
+ ],
318
+ "engines": {
319
+ "node": ">=12"
320
+ }
321
+ },
322
+ "node_modules/@esbuild/freebsd-arm64": {
323
+ "version": "0.21.5",
324
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
325
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
326
+ "cpu": [
327
+ "arm64"
328
+ ],
329
+ "dev": true,
330
+ "license": "MIT",
331
+ "optional": true,
332
+ "os": [
333
+ "freebsd"
334
+ ],
335
+ "engines": {
336
+ "node": ">=12"
337
+ }
338
+ },
339
+ "node_modules/@esbuild/freebsd-x64": {
340
+ "version": "0.21.5",
341
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
342
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
343
+ "cpu": [
344
+ "x64"
345
+ ],
346
+ "dev": true,
347
+ "license": "MIT",
348
+ "optional": true,
349
+ "os": [
350
+ "freebsd"
351
+ ],
352
+ "engines": {
353
+ "node": ">=12"
354
+ }
355
+ },
356
+ "node_modules/@esbuild/linux-arm": {
357
+ "version": "0.21.5",
358
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
359
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
360
+ "cpu": [
361
+ "arm"
362
+ ],
363
+ "dev": true,
364
+ "license": "MIT",
365
+ "optional": true,
366
+ "os": [
367
+ "linux"
368
+ ],
369
+ "engines": {
370
+ "node": ">=12"
371
+ }
372
+ },
373
+ "node_modules/@esbuild/linux-arm64": {
374
+ "version": "0.21.5",
375
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
376
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
377
+ "cpu": [
378
+ "arm64"
379
+ ],
380
+ "dev": true,
381
+ "license": "MIT",
382
+ "optional": true,
383
+ "os": [
384
+ "linux"
385
+ ],
386
+ "engines": {
387
+ "node": ">=12"
388
+ }
389
+ },
390
+ "node_modules/@esbuild/linux-ia32": {
391
+ "version": "0.21.5",
392
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
393
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
394
+ "cpu": [
395
+ "ia32"
396
+ ],
397
+ "dev": true,
398
+ "license": "MIT",
399
+ "optional": true,
400
+ "os": [
401
+ "linux"
402
+ ],
403
+ "engines": {
404
+ "node": ">=12"
405
+ }
406
+ },
407
+ "node_modules/@esbuild/linux-loong64": {
408
+ "version": "0.21.5",
409
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
410
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
411
+ "cpu": [
412
+ "loong64"
413
+ ],
414
+ "dev": true,
415
+ "license": "MIT",
416
+ "optional": true,
417
+ "os": [
418
+ "linux"
419
+ ],
420
+ "engines": {
421
+ "node": ">=12"
422
+ }
423
+ },
424
+ "node_modules/@esbuild/linux-mips64el": {
425
+ "version": "0.21.5",
426
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
427
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
428
+ "cpu": [
429
+ "mips64el"
430
+ ],
431
+ "dev": true,
432
+ "license": "MIT",
433
+ "optional": true,
434
+ "os": [
435
+ "linux"
436
+ ],
437
+ "engines": {
438
+ "node": ">=12"
439
+ }
440
+ },
441
+ "node_modules/@esbuild/linux-ppc64": {
442
+ "version": "0.21.5",
443
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
444
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
445
+ "cpu": [
446
+ "ppc64"
447
+ ],
448
+ "dev": true,
449
+ "license": "MIT",
450
+ "optional": true,
451
+ "os": [
452
+ "linux"
453
+ ],
454
+ "engines": {
455
+ "node": ">=12"
456
+ }
457
+ },
458
+ "node_modules/@esbuild/linux-riscv64": {
459
+ "version": "0.21.5",
460
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
461
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
462
+ "cpu": [
463
+ "riscv64"
464
+ ],
465
+ "dev": true,
466
+ "license": "MIT",
467
+ "optional": true,
468
+ "os": [
469
+ "linux"
470
+ ],
471
+ "engines": {
472
+ "node": ">=12"
473
+ }
474
+ },
475
+ "node_modules/@esbuild/linux-s390x": {
476
+ "version": "0.21.5",
477
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
478
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
479
+ "cpu": [
480
+ "s390x"
481
+ ],
482
+ "dev": true,
483
+ "license": "MIT",
484
+ "optional": true,
485
+ "os": [
486
+ "linux"
487
+ ],
488
+ "engines": {
489
+ "node": ">=12"
490
+ }
491
+ },
492
+ "node_modules/@esbuild/linux-x64": {
493
+ "version": "0.21.5",
494
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
495
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
496
+ "cpu": [
497
+ "x64"
498
+ ],
499
+ "dev": true,
500
+ "license": "MIT",
501
+ "optional": true,
502
+ "os": [
503
+ "linux"
504
+ ],
505
+ "engines": {
506
+ "node": ">=12"
507
+ }
508
+ },
509
+ "node_modules/@esbuild/netbsd-x64": {
510
+ "version": "0.21.5",
511
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
512
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
513
+ "cpu": [
514
+ "x64"
515
+ ],
516
+ "dev": true,
517
+ "license": "MIT",
518
+ "optional": true,
519
+ "os": [
520
+ "netbsd"
521
+ ],
522
+ "engines": {
523
+ "node": ">=12"
524
+ }
525
+ },
526
+ "node_modules/@esbuild/openbsd-x64": {
527
+ "version": "0.21.5",
528
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
529
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
530
+ "cpu": [
531
+ "x64"
532
+ ],
533
+ "dev": true,
534
+ "license": "MIT",
535
+ "optional": true,
536
+ "os": [
537
+ "openbsd"
538
+ ],
539
+ "engines": {
540
+ "node": ">=12"
541
+ }
542
+ },
543
+ "node_modules/@esbuild/sunos-x64": {
544
+ "version": "0.21.5",
545
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
546
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
547
+ "cpu": [
548
+ "x64"
549
+ ],
550
+ "dev": true,
551
+ "license": "MIT",
552
+ "optional": true,
553
+ "os": [
554
+ "sunos"
555
+ ],
556
+ "engines": {
557
+ "node": ">=12"
558
+ }
559
+ },
560
+ "node_modules/@esbuild/win32-arm64": {
561
+ "version": "0.21.5",
562
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
563
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
564
+ "cpu": [
565
+ "arm64"
566
+ ],
567
+ "dev": true,
568
+ "license": "MIT",
569
+ "optional": true,
570
+ "os": [
571
+ "win32"
572
+ ],
573
+ "engines": {
574
+ "node": ">=12"
575
+ }
576
+ },
577
+ "node_modules/@esbuild/win32-ia32": {
578
+ "version": "0.21.5",
579
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
580
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
581
+ "cpu": [
582
+ "ia32"
583
+ ],
584
+ "dev": true,
585
+ "license": "MIT",
586
+ "optional": true,
587
+ "os": [
588
+ "win32"
589
+ ],
590
+ "engines": {
591
+ "node": ">=12"
592
+ }
593
+ },
594
+ "node_modules/@esbuild/win32-x64": {
595
+ "version": "0.21.5",
596
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
597
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
598
+ "cpu": [
599
+ "x64"
600
+ ],
601
+ "dev": true,
602
+ "license": "MIT",
603
+ "optional": true,
604
+ "os": [
605
+ "win32"
606
+ ],
607
+ "engines": {
608
+ "node": ">=12"
609
+ }
610
+ },
611
+ "node_modules/@exodus/bytes": {
612
+ "version": "1.15.1",
613
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
614
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
615
+ "dev": true,
616
+ "license": "MIT",
617
+ "engines": {
618
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
619
+ },
620
+ "peerDependencies": {
621
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
622
+ },
623
+ "peerDependenciesMeta": {
624
+ "@noble/hashes": {
625
+ "optional": true
626
+ }
627
+ }
628
+ },
629
+ "node_modules/@jridgewell/sourcemap-codec": {
630
+ "version": "1.5.5",
631
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
632
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
633
+ "dev": true,
634
+ "license": "MIT"
635
+ },
636
+ "node_modules/@playwright/test": {
637
+ "version": "1.60.0",
638
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
639
+ "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
640
+ "dev": true,
641
+ "license": "Apache-2.0",
642
+ "dependencies": {
643
+ "playwright": "1.60.0"
644
+ },
645
+ "bin": {
646
+ "playwright": "cli.js"
647
+ },
648
+ "engines": {
649
+ "node": ">=18"
650
+ }
651
+ },
652
+ "node_modules/@rollup/rollup-android-arm-eabi": {
653
+ "version": "4.61.0",
654
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz",
655
+ "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==",
656
+ "cpu": [
657
+ "arm"
658
+ ],
659
+ "dev": true,
660
+ "license": "MIT",
661
+ "optional": true,
662
+ "os": [
663
+ "android"
664
+ ]
665
+ },
666
+ "node_modules/@rollup/rollup-android-arm64": {
667
+ "version": "4.61.0",
668
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz",
669
+ "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==",
670
+ "cpu": [
671
+ "arm64"
672
+ ],
673
+ "dev": true,
674
+ "license": "MIT",
675
+ "optional": true,
676
+ "os": [
677
+ "android"
678
+ ]
679
+ },
680
+ "node_modules/@rollup/rollup-darwin-arm64": {
681
+ "version": "4.61.0",
682
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz",
683
+ "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==",
684
+ "cpu": [
685
+ "arm64"
686
+ ],
687
+ "dev": true,
688
+ "license": "MIT",
689
+ "optional": true,
690
+ "os": [
691
+ "darwin"
692
+ ]
693
+ },
694
+ "node_modules/@rollup/rollup-darwin-x64": {
695
+ "version": "4.61.0",
696
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz",
697
+ "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==",
698
+ "cpu": [
699
+ "x64"
700
+ ],
701
+ "dev": true,
702
+ "license": "MIT",
703
+ "optional": true,
704
+ "os": [
705
+ "darwin"
706
+ ]
707
+ },
708
+ "node_modules/@rollup/rollup-freebsd-arm64": {
709
+ "version": "4.61.0",
710
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz",
711
+ "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==",
712
+ "cpu": [
713
+ "arm64"
714
+ ],
715
+ "dev": true,
716
+ "license": "MIT",
717
+ "optional": true,
718
+ "os": [
719
+ "freebsd"
720
+ ]
721
+ },
722
+ "node_modules/@rollup/rollup-freebsd-x64": {
723
+ "version": "4.61.0",
724
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz",
725
+ "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==",
726
+ "cpu": [
727
+ "x64"
728
+ ],
729
+ "dev": true,
730
+ "license": "MIT",
731
+ "optional": true,
732
+ "os": [
733
+ "freebsd"
734
+ ]
735
+ },
736
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
737
+ "version": "4.61.0",
738
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz",
739
+ "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==",
740
+ "cpu": [
741
+ "arm"
742
+ ],
743
+ "dev": true,
744
+ "license": "MIT",
745
+ "optional": true,
746
+ "os": [
747
+ "linux"
748
+ ]
749
+ },
750
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
751
+ "version": "4.61.0",
752
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz",
753
+ "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==",
754
+ "cpu": [
755
+ "arm"
756
+ ],
757
+ "dev": true,
758
+ "license": "MIT",
759
+ "optional": true,
760
+ "os": [
761
+ "linux"
762
+ ]
763
+ },
764
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
765
+ "version": "4.61.0",
766
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz",
767
+ "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==",
768
+ "cpu": [
769
+ "arm64"
770
+ ],
771
+ "dev": true,
772
+ "license": "MIT",
773
+ "optional": true,
774
+ "os": [
775
+ "linux"
776
+ ]
777
+ },
778
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
779
+ "version": "4.61.0",
780
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz",
781
+ "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==",
782
+ "cpu": [
783
+ "arm64"
784
+ ],
785
+ "dev": true,
786
+ "license": "MIT",
787
+ "optional": true,
788
+ "os": [
789
+ "linux"
790
+ ]
791
+ },
792
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
793
+ "version": "4.61.0",
794
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz",
795
+ "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==",
796
+ "cpu": [
797
+ "loong64"
798
+ ],
799
+ "dev": true,
800
+ "license": "MIT",
801
+ "optional": true,
802
+ "os": [
803
+ "linux"
804
+ ]
805
+ },
806
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
807
+ "version": "4.61.0",
808
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz",
809
+ "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==",
810
+ "cpu": [
811
+ "loong64"
812
+ ],
813
+ "dev": true,
814
+ "license": "MIT",
815
+ "optional": true,
816
+ "os": [
817
+ "linux"
818
+ ]
819
+ },
820
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
821
+ "version": "4.61.0",
822
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz",
823
+ "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==",
824
+ "cpu": [
825
+ "ppc64"
826
+ ],
827
+ "dev": true,
828
+ "license": "MIT",
829
+ "optional": true,
830
+ "os": [
831
+ "linux"
832
+ ]
833
+ },
834
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
835
+ "version": "4.61.0",
836
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz",
837
+ "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==",
838
+ "cpu": [
839
+ "ppc64"
840
+ ],
841
+ "dev": true,
842
+ "license": "MIT",
843
+ "optional": true,
844
+ "os": [
845
+ "linux"
846
+ ]
847
+ },
848
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
849
+ "version": "4.61.0",
850
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz",
851
+ "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==",
852
+ "cpu": [
853
+ "riscv64"
854
+ ],
855
+ "dev": true,
856
+ "license": "MIT",
857
+ "optional": true,
858
+ "os": [
859
+ "linux"
860
+ ]
861
+ },
862
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
863
+ "version": "4.61.0",
864
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz",
865
+ "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==",
866
+ "cpu": [
867
+ "riscv64"
868
+ ],
869
+ "dev": true,
870
+ "license": "MIT",
871
+ "optional": true,
872
+ "os": [
873
+ "linux"
874
+ ]
875
+ },
876
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
877
+ "version": "4.61.0",
878
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz",
879
+ "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==",
880
+ "cpu": [
881
+ "s390x"
882
+ ],
883
+ "dev": true,
884
+ "license": "MIT",
885
+ "optional": true,
886
+ "os": [
887
+ "linux"
888
+ ]
889
+ },
890
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
891
+ "version": "4.61.0",
892
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz",
893
+ "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==",
894
+ "cpu": [
895
+ "x64"
896
+ ],
897
+ "dev": true,
898
+ "license": "MIT",
899
+ "optional": true,
900
+ "os": [
901
+ "linux"
902
+ ]
903
+ },
904
+ "node_modules/@rollup/rollup-linux-x64-musl": {
905
+ "version": "4.61.0",
906
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz",
907
+ "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==",
908
+ "cpu": [
909
+ "x64"
910
+ ],
911
+ "dev": true,
912
+ "license": "MIT",
913
+ "optional": true,
914
+ "os": [
915
+ "linux"
916
+ ]
917
+ },
918
+ "node_modules/@rollup/rollup-openbsd-x64": {
919
+ "version": "4.61.0",
920
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz",
921
+ "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==",
922
+ "cpu": [
923
+ "x64"
924
+ ],
925
+ "dev": true,
926
+ "license": "MIT",
927
+ "optional": true,
928
+ "os": [
929
+ "openbsd"
930
+ ]
931
+ },
932
+ "node_modules/@rollup/rollup-openharmony-arm64": {
933
+ "version": "4.61.0",
934
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz",
935
+ "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==",
936
+ "cpu": [
937
+ "arm64"
938
+ ],
939
+ "dev": true,
940
+ "license": "MIT",
941
+ "optional": true,
942
+ "os": [
943
+ "openharmony"
944
+ ]
945
+ },
946
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
947
+ "version": "4.61.0",
948
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz",
949
+ "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==",
950
+ "cpu": [
951
+ "arm64"
952
+ ],
953
+ "dev": true,
954
+ "license": "MIT",
955
+ "optional": true,
956
+ "os": [
957
+ "win32"
958
+ ]
959
+ },
960
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
961
+ "version": "4.61.0",
962
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz",
963
+ "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==",
964
+ "cpu": [
965
+ "ia32"
966
+ ],
967
+ "dev": true,
968
+ "license": "MIT",
969
+ "optional": true,
970
+ "os": [
971
+ "win32"
972
+ ]
973
+ },
974
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
975
+ "version": "4.61.0",
976
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz",
977
+ "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==",
978
+ "cpu": [
979
+ "x64"
980
+ ],
981
+ "dev": true,
982
+ "license": "MIT",
983
+ "optional": true,
984
+ "os": [
985
+ "win32"
986
+ ]
987
+ },
988
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
989
+ "version": "4.61.0",
990
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz",
991
+ "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==",
992
+ "cpu": [
993
+ "x64"
994
+ ],
995
+ "dev": true,
996
+ "license": "MIT",
997
+ "optional": true,
998
+ "os": [
999
+ "win32"
1000
+ ]
1001
+ },
1002
+ "node_modules/@types/estree": {
1003
+ "version": "1.0.9",
1004
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1005
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1006
+ "dev": true,
1007
+ "license": "MIT"
1008
+ },
1009
+ "node_modules/@vitest/expect": {
1010
+ "version": "2.1.9",
1011
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
1012
+ "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
1013
+ "dev": true,
1014
+ "license": "MIT",
1015
+ "dependencies": {
1016
+ "@vitest/spy": "2.1.9",
1017
+ "@vitest/utils": "2.1.9",
1018
+ "chai": "^5.1.2",
1019
+ "tinyrainbow": "^1.2.0"
1020
+ },
1021
+ "funding": {
1022
+ "url": "https://opencollective.com/vitest"
1023
+ }
1024
+ },
1025
+ "node_modules/@vitest/mocker": {
1026
+ "version": "2.1.9",
1027
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
1028
+ "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
1029
+ "dev": true,
1030
+ "license": "MIT",
1031
+ "dependencies": {
1032
+ "@vitest/spy": "2.1.9",
1033
+ "estree-walker": "^3.0.3",
1034
+ "magic-string": "^0.30.12"
1035
+ },
1036
+ "funding": {
1037
+ "url": "https://opencollective.com/vitest"
1038
+ },
1039
+ "peerDependencies": {
1040
+ "msw": "^2.4.9",
1041
+ "vite": "^5.0.0"
1042
+ },
1043
+ "peerDependenciesMeta": {
1044
+ "msw": {
1045
+ "optional": true
1046
+ },
1047
+ "vite": {
1048
+ "optional": true
1049
+ }
1050
+ }
1051
+ },
1052
+ "node_modules/@vitest/pretty-format": {
1053
+ "version": "2.1.9",
1054
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
1055
+ "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
1056
+ "dev": true,
1057
+ "license": "MIT",
1058
+ "dependencies": {
1059
+ "tinyrainbow": "^1.2.0"
1060
+ },
1061
+ "funding": {
1062
+ "url": "https://opencollective.com/vitest"
1063
+ }
1064
+ },
1065
+ "node_modules/@vitest/runner": {
1066
+ "version": "2.1.9",
1067
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
1068
+ "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
1069
+ "dev": true,
1070
+ "license": "MIT",
1071
+ "dependencies": {
1072
+ "@vitest/utils": "2.1.9",
1073
+ "pathe": "^1.1.2"
1074
+ },
1075
+ "funding": {
1076
+ "url": "https://opencollective.com/vitest"
1077
+ }
1078
+ },
1079
+ "node_modules/@vitest/snapshot": {
1080
+ "version": "2.1.9",
1081
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
1082
+ "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
1083
+ "dev": true,
1084
+ "license": "MIT",
1085
+ "dependencies": {
1086
+ "@vitest/pretty-format": "2.1.9",
1087
+ "magic-string": "^0.30.12",
1088
+ "pathe": "^1.1.2"
1089
+ },
1090
+ "funding": {
1091
+ "url": "https://opencollective.com/vitest"
1092
+ }
1093
+ },
1094
+ "node_modules/@vitest/spy": {
1095
+ "version": "2.1.9",
1096
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
1097
+ "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
1098
+ "dev": true,
1099
+ "license": "MIT",
1100
+ "dependencies": {
1101
+ "tinyspy": "^3.0.2"
1102
+ },
1103
+ "funding": {
1104
+ "url": "https://opencollective.com/vitest"
1105
+ }
1106
+ },
1107
+ "node_modules/@vitest/utils": {
1108
+ "version": "2.1.9",
1109
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
1110
+ "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
1111
+ "dev": true,
1112
+ "license": "MIT",
1113
+ "dependencies": {
1114
+ "@vitest/pretty-format": "2.1.9",
1115
+ "loupe": "^3.1.2",
1116
+ "tinyrainbow": "^1.2.0"
1117
+ },
1118
+ "funding": {
1119
+ "url": "https://opencollective.com/vitest"
1120
+ }
1121
+ },
1122
+ "node_modules/assertion-error": {
1123
+ "version": "2.0.1",
1124
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
1125
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
1126
+ "dev": true,
1127
+ "license": "MIT",
1128
+ "engines": {
1129
+ "node": ">=12"
1130
+ }
1131
+ },
1132
+ "node_modules/bidi-js": {
1133
+ "version": "1.0.3",
1134
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
1135
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
1136
+ "dev": true,
1137
+ "license": "MIT",
1138
+ "dependencies": {
1139
+ "require-from-string": "^2.0.2"
1140
+ }
1141
+ },
1142
+ "node_modules/cac": {
1143
+ "version": "6.7.14",
1144
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
1145
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
1146
+ "dev": true,
1147
+ "license": "MIT",
1148
+ "engines": {
1149
+ "node": ">=8"
1150
+ }
1151
+ },
1152
+ "node_modules/chai": {
1153
+ "version": "5.3.3",
1154
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
1155
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
1156
+ "dev": true,
1157
+ "license": "MIT",
1158
+ "dependencies": {
1159
+ "assertion-error": "^2.0.1",
1160
+ "check-error": "^2.1.1",
1161
+ "deep-eql": "^5.0.1",
1162
+ "loupe": "^3.1.0",
1163
+ "pathval": "^2.0.0"
1164
+ },
1165
+ "engines": {
1166
+ "node": ">=18"
1167
+ }
1168
+ },
1169
+ "node_modules/check-error": {
1170
+ "version": "2.1.3",
1171
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
1172
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
1173
+ "dev": true,
1174
+ "license": "MIT",
1175
+ "engines": {
1176
+ "node": ">= 16"
1177
+ }
1178
+ },
1179
+ "node_modules/css-tree": {
1180
+ "version": "3.2.1",
1181
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
1182
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
1183
+ "dev": true,
1184
+ "license": "MIT",
1185
+ "dependencies": {
1186
+ "mdn-data": "2.27.1",
1187
+ "source-map-js": "^1.2.1"
1188
+ },
1189
+ "engines": {
1190
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
1191
+ }
1192
+ },
1193
+ "node_modules/data-urls": {
1194
+ "version": "7.0.0",
1195
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
1196
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
1197
+ "dev": true,
1198
+ "license": "MIT",
1199
+ "dependencies": {
1200
+ "whatwg-mimetype": "^5.0.0",
1201
+ "whatwg-url": "^16.0.0"
1202
+ },
1203
+ "engines": {
1204
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
1205
+ }
1206
+ },
1207
+ "node_modules/debug": {
1208
+ "version": "4.4.3",
1209
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1210
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1211
+ "dev": true,
1212
+ "license": "MIT",
1213
+ "dependencies": {
1214
+ "ms": "^2.1.3"
1215
+ },
1216
+ "engines": {
1217
+ "node": ">=6.0"
1218
+ },
1219
+ "peerDependenciesMeta": {
1220
+ "supports-color": {
1221
+ "optional": true
1222
+ }
1223
+ }
1224
+ },
1225
+ "node_modules/decimal.js": {
1226
+ "version": "10.6.0",
1227
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
1228
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
1229
+ "dev": true,
1230
+ "license": "MIT"
1231
+ },
1232
+ "node_modules/deep-eql": {
1233
+ "version": "5.0.2",
1234
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
1235
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
1236
+ "dev": true,
1237
+ "license": "MIT",
1238
+ "engines": {
1239
+ "node": ">=6"
1240
+ }
1241
+ },
1242
+ "node_modules/entities": {
1243
+ "version": "8.0.0",
1244
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
1245
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
1246
+ "dev": true,
1247
+ "license": "BSD-2-Clause",
1248
+ "engines": {
1249
+ "node": ">=20.19.0"
1250
+ },
1251
+ "funding": {
1252
+ "url": "https://github.com/fb55/entities?sponsor=1"
1253
+ }
1254
+ },
1255
+ "node_modules/es-module-lexer": {
1256
+ "version": "1.7.0",
1257
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
1258
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
1259
+ "dev": true,
1260
+ "license": "MIT"
1261
+ },
1262
+ "node_modules/esbuild": {
1263
+ "version": "0.21.5",
1264
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1265
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1266
+ "dev": true,
1267
+ "hasInstallScript": true,
1268
+ "license": "MIT",
1269
+ "bin": {
1270
+ "esbuild": "bin/esbuild"
1271
+ },
1272
+ "engines": {
1273
+ "node": ">=12"
1274
+ },
1275
+ "optionalDependencies": {
1276
+ "@esbuild/aix-ppc64": "0.21.5",
1277
+ "@esbuild/android-arm": "0.21.5",
1278
+ "@esbuild/android-arm64": "0.21.5",
1279
+ "@esbuild/android-x64": "0.21.5",
1280
+ "@esbuild/darwin-arm64": "0.21.5",
1281
+ "@esbuild/darwin-x64": "0.21.5",
1282
+ "@esbuild/freebsd-arm64": "0.21.5",
1283
+ "@esbuild/freebsd-x64": "0.21.5",
1284
+ "@esbuild/linux-arm": "0.21.5",
1285
+ "@esbuild/linux-arm64": "0.21.5",
1286
+ "@esbuild/linux-ia32": "0.21.5",
1287
+ "@esbuild/linux-loong64": "0.21.5",
1288
+ "@esbuild/linux-mips64el": "0.21.5",
1289
+ "@esbuild/linux-ppc64": "0.21.5",
1290
+ "@esbuild/linux-riscv64": "0.21.5",
1291
+ "@esbuild/linux-s390x": "0.21.5",
1292
+ "@esbuild/linux-x64": "0.21.5",
1293
+ "@esbuild/netbsd-x64": "0.21.5",
1294
+ "@esbuild/openbsd-x64": "0.21.5",
1295
+ "@esbuild/sunos-x64": "0.21.5",
1296
+ "@esbuild/win32-arm64": "0.21.5",
1297
+ "@esbuild/win32-ia32": "0.21.5",
1298
+ "@esbuild/win32-x64": "0.21.5"
1299
+ }
1300
+ },
1301
+ "node_modules/estree-walker": {
1302
+ "version": "3.0.3",
1303
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
1304
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
1305
+ "dev": true,
1306
+ "license": "MIT",
1307
+ "dependencies": {
1308
+ "@types/estree": "^1.0.0"
1309
+ }
1310
+ },
1311
+ "node_modules/expect-type": {
1312
+ "version": "1.3.0",
1313
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
1314
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
1315
+ "dev": true,
1316
+ "license": "Apache-2.0",
1317
+ "engines": {
1318
+ "node": ">=12.0.0"
1319
+ }
1320
+ },
1321
+ "node_modules/fsevents": {
1322
+ "version": "2.3.2",
1323
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
1324
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
1325
+ "dev": true,
1326
+ "hasInstallScript": true,
1327
+ "license": "MIT",
1328
+ "optional": true,
1329
+ "os": [
1330
+ "darwin"
1331
+ ],
1332
+ "engines": {
1333
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1334
+ }
1335
+ },
1336
+ "node_modules/html-encoding-sniffer": {
1337
+ "version": "6.0.0",
1338
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
1339
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
1340
+ "dev": true,
1341
+ "license": "MIT",
1342
+ "dependencies": {
1343
+ "@exodus/bytes": "^1.6.0"
1344
+ },
1345
+ "engines": {
1346
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
1347
+ }
1348
+ },
1349
+ "node_modules/is-potential-custom-element-name": {
1350
+ "version": "1.0.1",
1351
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
1352
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
1353
+ "dev": true,
1354
+ "license": "MIT"
1355
+ },
1356
+ "node_modules/jsdom": {
1357
+ "version": "29.1.1",
1358
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
1359
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
1360
+ "dev": true,
1361
+ "license": "MIT",
1362
+ "dependencies": {
1363
+ "@asamuzakjp/css-color": "^5.1.11",
1364
+ "@asamuzakjp/dom-selector": "^7.1.1",
1365
+ "@bramus/specificity": "^2.4.2",
1366
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
1367
+ "@exodus/bytes": "^1.15.0",
1368
+ "css-tree": "^3.2.1",
1369
+ "data-urls": "^7.0.0",
1370
+ "decimal.js": "^10.6.0",
1371
+ "html-encoding-sniffer": "^6.0.0",
1372
+ "is-potential-custom-element-name": "^1.0.1",
1373
+ "lru-cache": "^11.3.5",
1374
+ "parse5": "^8.0.1",
1375
+ "saxes": "^6.0.0",
1376
+ "symbol-tree": "^3.2.4",
1377
+ "tough-cookie": "^6.0.1",
1378
+ "undici": "^7.25.0",
1379
+ "w3c-xmlserializer": "^5.0.0",
1380
+ "webidl-conversions": "^8.0.1",
1381
+ "whatwg-mimetype": "^5.0.0",
1382
+ "whatwg-url": "^16.0.1",
1383
+ "xml-name-validator": "^5.0.0"
1384
+ },
1385
+ "engines": {
1386
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
1387
+ },
1388
+ "peerDependencies": {
1389
+ "canvas": "^3.0.0"
1390
+ },
1391
+ "peerDependenciesMeta": {
1392
+ "canvas": {
1393
+ "optional": true
1394
+ }
1395
+ }
1396
+ },
1397
+ "node_modules/loupe": {
1398
+ "version": "3.2.1",
1399
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
1400
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
1401
+ "dev": true,
1402
+ "license": "MIT"
1403
+ },
1404
+ "node_modules/lru-cache": {
1405
+ "version": "11.5.1",
1406
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
1407
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
1408
+ "dev": true,
1409
+ "license": "BlueOak-1.0.0",
1410
+ "engines": {
1411
+ "node": "20 || >=22"
1412
+ }
1413
+ },
1414
+ "node_modules/magic-string": {
1415
+ "version": "0.30.21",
1416
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
1417
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
1418
+ "dev": true,
1419
+ "license": "MIT",
1420
+ "dependencies": {
1421
+ "@jridgewell/sourcemap-codec": "^1.5.5"
1422
+ }
1423
+ },
1424
+ "node_modules/mdn-data": {
1425
+ "version": "2.27.1",
1426
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
1427
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
1428
+ "dev": true,
1429
+ "license": "CC0-1.0"
1430
+ },
1431
+ "node_modules/ms": {
1432
+ "version": "2.1.3",
1433
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1434
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1435
+ "dev": true,
1436
+ "license": "MIT"
1437
+ },
1438
+ "node_modules/nanoid": {
1439
+ "version": "3.3.12",
1440
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
1441
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
1442
+ "dev": true,
1443
+ "funding": [
1444
+ {
1445
+ "type": "github",
1446
+ "url": "https://github.com/sponsors/ai"
1447
+ }
1448
+ ],
1449
+ "license": "MIT",
1450
+ "bin": {
1451
+ "nanoid": "bin/nanoid.cjs"
1452
+ },
1453
+ "engines": {
1454
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1455
+ }
1456
+ },
1457
+ "node_modules/parse5": {
1458
+ "version": "8.0.1",
1459
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
1460
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
1461
+ "dev": true,
1462
+ "license": "MIT",
1463
+ "dependencies": {
1464
+ "entities": "^8.0.0"
1465
+ },
1466
+ "funding": {
1467
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
1468
+ }
1469
+ },
1470
+ "node_modules/pathe": {
1471
+ "version": "1.1.2",
1472
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
1473
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
1474
+ "dev": true,
1475
+ "license": "MIT"
1476
+ },
1477
+ "node_modules/pathval": {
1478
+ "version": "2.0.1",
1479
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
1480
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
1481
+ "dev": true,
1482
+ "license": "MIT",
1483
+ "engines": {
1484
+ "node": ">= 14.16"
1485
+ }
1486
+ },
1487
+ "node_modules/picocolors": {
1488
+ "version": "1.1.1",
1489
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1490
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1491
+ "dev": true,
1492
+ "license": "ISC"
1493
+ },
1494
+ "node_modules/playwright": {
1495
+ "version": "1.60.0",
1496
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
1497
+ "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
1498
+ "dev": true,
1499
+ "license": "Apache-2.0",
1500
+ "dependencies": {
1501
+ "playwright-core": "1.60.0"
1502
+ },
1503
+ "bin": {
1504
+ "playwright": "cli.js"
1505
+ },
1506
+ "engines": {
1507
+ "node": ">=18"
1508
+ },
1509
+ "optionalDependencies": {
1510
+ "fsevents": "2.3.2"
1511
+ }
1512
+ },
1513
+ "node_modules/playwright-core": {
1514
+ "version": "1.60.0",
1515
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
1516
+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
1517
+ "dev": true,
1518
+ "license": "Apache-2.0",
1519
+ "bin": {
1520
+ "playwright-core": "cli.js"
1521
+ },
1522
+ "engines": {
1523
+ "node": ">=18"
1524
+ }
1525
+ },
1526
+ "node_modules/postcss": {
1527
+ "version": "8.5.15",
1528
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
1529
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
1530
+ "dev": true,
1531
+ "funding": [
1532
+ {
1533
+ "type": "opencollective",
1534
+ "url": "https://opencollective.com/postcss/"
1535
+ },
1536
+ {
1537
+ "type": "tidelift",
1538
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1539
+ },
1540
+ {
1541
+ "type": "github",
1542
+ "url": "https://github.com/sponsors/ai"
1543
+ }
1544
+ ],
1545
+ "license": "MIT",
1546
+ "dependencies": {
1547
+ "nanoid": "^3.3.12",
1548
+ "picocolors": "^1.1.1",
1549
+ "source-map-js": "^1.2.1"
1550
+ },
1551
+ "engines": {
1552
+ "node": "^10 || ^12 || >=14"
1553
+ }
1554
+ },
1555
+ "node_modules/punycode": {
1556
+ "version": "2.3.1",
1557
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
1558
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
1559
+ "dev": true,
1560
+ "license": "MIT",
1561
+ "engines": {
1562
+ "node": ">=6"
1563
+ }
1564
+ },
1565
+ "node_modules/require-from-string": {
1566
+ "version": "2.0.2",
1567
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
1568
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
1569
+ "dev": true,
1570
+ "license": "MIT",
1571
+ "engines": {
1572
+ "node": ">=0.10.0"
1573
+ }
1574
+ },
1575
+ "node_modules/rollup": {
1576
+ "version": "4.61.0",
1577
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz",
1578
+ "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==",
1579
+ "dev": true,
1580
+ "license": "MIT",
1581
+ "dependencies": {
1582
+ "@types/estree": "1.0.9"
1583
+ },
1584
+ "bin": {
1585
+ "rollup": "dist/bin/rollup"
1586
+ },
1587
+ "engines": {
1588
+ "node": ">=18.0.0",
1589
+ "npm": ">=8.0.0"
1590
+ },
1591
+ "optionalDependencies": {
1592
+ "@rollup/rollup-android-arm-eabi": "4.61.0",
1593
+ "@rollup/rollup-android-arm64": "4.61.0",
1594
+ "@rollup/rollup-darwin-arm64": "4.61.0",
1595
+ "@rollup/rollup-darwin-x64": "4.61.0",
1596
+ "@rollup/rollup-freebsd-arm64": "4.61.0",
1597
+ "@rollup/rollup-freebsd-x64": "4.61.0",
1598
+ "@rollup/rollup-linux-arm-gnueabihf": "4.61.0",
1599
+ "@rollup/rollup-linux-arm-musleabihf": "4.61.0",
1600
+ "@rollup/rollup-linux-arm64-gnu": "4.61.0",
1601
+ "@rollup/rollup-linux-arm64-musl": "4.61.0",
1602
+ "@rollup/rollup-linux-loong64-gnu": "4.61.0",
1603
+ "@rollup/rollup-linux-loong64-musl": "4.61.0",
1604
+ "@rollup/rollup-linux-ppc64-gnu": "4.61.0",
1605
+ "@rollup/rollup-linux-ppc64-musl": "4.61.0",
1606
+ "@rollup/rollup-linux-riscv64-gnu": "4.61.0",
1607
+ "@rollup/rollup-linux-riscv64-musl": "4.61.0",
1608
+ "@rollup/rollup-linux-s390x-gnu": "4.61.0",
1609
+ "@rollup/rollup-linux-x64-gnu": "4.61.0",
1610
+ "@rollup/rollup-linux-x64-musl": "4.61.0",
1611
+ "@rollup/rollup-openbsd-x64": "4.61.0",
1612
+ "@rollup/rollup-openharmony-arm64": "4.61.0",
1613
+ "@rollup/rollup-win32-arm64-msvc": "4.61.0",
1614
+ "@rollup/rollup-win32-ia32-msvc": "4.61.0",
1615
+ "@rollup/rollup-win32-x64-gnu": "4.61.0",
1616
+ "@rollup/rollup-win32-x64-msvc": "4.61.0",
1617
+ "fsevents": "~2.3.2"
1618
+ }
1619
+ },
1620
+ "node_modules/saxes": {
1621
+ "version": "6.0.0",
1622
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
1623
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
1624
+ "dev": true,
1625
+ "license": "ISC",
1626
+ "dependencies": {
1627
+ "xmlchars": "^2.2.0"
1628
+ },
1629
+ "engines": {
1630
+ "node": ">=v12.22.7"
1631
+ }
1632
+ },
1633
+ "node_modules/siginfo": {
1634
+ "version": "2.0.0",
1635
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
1636
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
1637
+ "dev": true,
1638
+ "license": "ISC"
1639
+ },
1640
+ "node_modules/source-map-js": {
1641
+ "version": "1.2.1",
1642
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1643
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1644
+ "dev": true,
1645
+ "license": "BSD-3-Clause",
1646
+ "engines": {
1647
+ "node": ">=0.10.0"
1648
+ }
1649
+ },
1650
+ "node_modules/stackback": {
1651
+ "version": "0.0.2",
1652
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
1653
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
1654
+ "dev": true,
1655
+ "license": "MIT"
1656
+ },
1657
+ "node_modules/std-env": {
1658
+ "version": "3.10.0",
1659
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
1660
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
1661
+ "dev": true,
1662
+ "license": "MIT"
1663
+ },
1664
+ "node_modules/symbol-tree": {
1665
+ "version": "3.2.4",
1666
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
1667
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
1668
+ "dev": true,
1669
+ "license": "MIT"
1670
+ },
1671
+ "node_modules/tinybench": {
1672
+ "version": "2.9.0",
1673
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
1674
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
1675
+ "dev": true,
1676
+ "license": "MIT"
1677
+ },
1678
+ "node_modules/tinyexec": {
1679
+ "version": "0.3.2",
1680
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
1681
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
1682
+ "dev": true,
1683
+ "license": "MIT"
1684
+ },
1685
+ "node_modules/tinypool": {
1686
+ "version": "1.1.1",
1687
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
1688
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
1689
+ "dev": true,
1690
+ "license": "MIT",
1691
+ "engines": {
1692
+ "node": "^18.0.0 || >=20.0.0"
1693
+ }
1694
+ },
1695
+ "node_modules/tinyrainbow": {
1696
+ "version": "1.2.0",
1697
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
1698
+ "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
1699
+ "dev": true,
1700
+ "license": "MIT",
1701
+ "engines": {
1702
+ "node": ">=14.0.0"
1703
+ }
1704
+ },
1705
+ "node_modules/tinyspy": {
1706
+ "version": "3.0.2",
1707
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
1708
+ "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
1709
+ "dev": true,
1710
+ "license": "MIT",
1711
+ "engines": {
1712
+ "node": ">=14.0.0"
1713
+ }
1714
+ },
1715
+ "node_modules/tldts": {
1716
+ "version": "7.4.2",
1717
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
1718
+ "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
1719
+ "dev": true,
1720
+ "license": "MIT",
1721
+ "dependencies": {
1722
+ "tldts-core": "^7.4.2"
1723
+ },
1724
+ "bin": {
1725
+ "tldts": "bin/cli.js"
1726
+ }
1727
+ },
1728
+ "node_modules/tldts-core": {
1729
+ "version": "7.4.2",
1730
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
1731
+ "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
1732
+ "dev": true,
1733
+ "license": "MIT"
1734
+ },
1735
+ "node_modules/tough-cookie": {
1736
+ "version": "6.0.1",
1737
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
1738
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
1739
+ "dev": true,
1740
+ "license": "BSD-3-Clause",
1741
+ "dependencies": {
1742
+ "tldts": "^7.0.5"
1743
+ },
1744
+ "engines": {
1745
+ "node": ">=16"
1746
+ }
1747
+ },
1748
+ "node_modules/tr46": {
1749
+ "version": "6.0.0",
1750
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
1751
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
1752
+ "dev": true,
1753
+ "license": "MIT",
1754
+ "dependencies": {
1755
+ "punycode": "^2.3.1"
1756
+ },
1757
+ "engines": {
1758
+ "node": ">=20"
1759
+ }
1760
+ },
1761
+ "node_modules/undici": {
1762
+ "version": "7.27.0",
1763
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz",
1764
+ "integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==",
1765
+ "dev": true,
1766
+ "license": "MIT",
1767
+ "engines": {
1768
+ "node": ">=20.18.1"
1769
+ }
1770
+ },
1771
+ "node_modules/vite": {
1772
+ "version": "5.4.21",
1773
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1774
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1775
+ "dev": true,
1776
+ "license": "MIT",
1777
+ "dependencies": {
1778
+ "esbuild": "^0.21.3",
1779
+ "postcss": "^8.4.43",
1780
+ "rollup": "^4.20.0"
1781
+ },
1782
+ "bin": {
1783
+ "vite": "bin/vite.js"
1784
+ },
1785
+ "engines": {
1786
+ "node": "^18.0.0 || >=20.0.0"
1787
+ },
1788
+ "funding": {
1789
+ "url": "https://github.com/vitejs/vite?sponsor=1"
1790
+ },
1791
+ "optionalDependencies": {
1792
+ "fsevents": "~2.3.3"
1793
+ },
1794
+ "peerDependencies": {
1795
+ "@types/node": "^18.0.0 || >=20.0.0",
1796
+ "less": "*",
1797
+ "lightningcss": "^1.21.0",
1798
+ "sass": "*",
1799
+ "sass-embedded": "*",
1800
+ "stylus": "*",
1801
+ "sugarss": "*",
1802
+ "terser": "^5.4.0"
1803
+ },
1804
+ "peerDependenciesMeta": {
1805
+ "@types/node": {
1806
+ "optional": true
1807
+ },
1808
+ "less": {
1809
+ "optional": true
1810
+ },
1811
+ "lightningcss": {
1812
+ "optional": true
1813
+ },
1814
+ "sass": {
1815
+ "optional": true
1816
+ },
1817
+ "sass-embedded": {
1818
+ "optional": true
1819
+ },
1820
+ "stylus": {
1821
+ "optional": true
1822
+ },
1823
+ "sugarss": {
1824
+ "optional": true
1825
+ },
1826
+ "terser": {
1827
+ "optional": true
1828
+ }
1829
+ }
1830
+ },
1831
+ "node_modules/vite-node": {
1832
+ "version": "2.1.9",
1833
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
1834
+ "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
1835
+ "dev": true,
1836
+ "license": "MIT",
1837
+ "dependencies": {
1838
+ "cac": "^6.7.14",
1839
+ "debug": "^4.3.7",
1840
+ "es-module-lexer": "^1.5.4",
1841
+ "pathe": "^1.1.2",
1842
+ "vite": "^5.0.0"
1843
+ },
1844
+ "bin": {
1845
+ "vite-node": "vite-node.mjs"
1846
+ },
1847
+ "engines": {
1848
+ "node": "^18.0.0 || >=20.0.0"
1849
+ },
1850
+ "funding": {
1851
+ "url": "https://opencollective.com/vitest"
1852
+ }
1853
+ },
1854
+ "node_modules/vite/node_modules/fsevents": {
1855
+ "version": "2.3.3",
1856
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1857
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1858
+ "dev": true,
1859
+ "hasInstallScript": true,
1860
+ "license": "MIT",
1861
+ "optional": true,
1862
+ "os": [
1863
+ "darwin"
1864
+ ],
1865
+ "engines": {
1866
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1867
+ }
1868
+ },
1869
+ "node_modules/vitest": {
1870
+ "version": "2.1.9",
1871
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
1872
+ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
1873
+ "dev": true,
1874
+ "license": "MIT",
1875
+ "dependencies": {
1876
+ "@vitest/expect": "2.1.9",
1877
+ "@vitest/mocker": "2.1.9",
1878
+ "@vitest/pretty-format": "^2.1.9",
1879
+ "@vitest/runner": "2.1.9",
1880
+ "@vitest/snapshot": "2.1.9",
1881
+ "@vitest/spy": "2.1.9",
1882
+ "@vitest/utils": "2.1.9",
1883
+ "chai": "^5.1.2",
1884
+ "debug": "^4.3.7",
1885
+ "expect-type": "^1.1.0",
1886
+ "magic-string": "^0.30.12",
1887
+ "pathe": "^1.1.2",
1888
+ "std-env": "^3.8.0",
1889
+ "tinybench": "^2.9.0",
1890
+ "tinyexec": "^0.3.1",
1891
+ "tinypool": "^1.0.1",
1892
+ "tinyrainbow": "^1.2.0",
1893
+ "vite": "^5.0.0",
1894
+ "vite-node": "2.1.9",
1895
+ "why-is-node-running": "^2.3.0"
1896
+ },
1897
+ "bin": {
1898
+ "vitest": "vitest.mjs"
1899
+ },
1900
+ "engines": {
1901
+ "node": "^18.0.0 || >=20.0.0"
1902
+ },
1903
+ "funding": {
1904
+ "url": "https://opencollective.com/vitest"
1905
+ },
1906
+ "peerDependencies": {
1907
+ "@edge-runtime/vm": "*",
1908
+ "@types/node": "^18.0.0 || >=20.0.0",
1909
+ "@vitest/browser": "2.1.9",
1910
+ "@vitest/ui": "2.1.9",
1911
+ "happy-dom": "*",
1912
+ "jsdom": "*"
1913
+ },
1914
+ "peerDependenciesMeta": {
1915
+ "@edge-runtime/vm": {
1916
+ "optional": true
1917
+ },
1918
+ "@types/node": {
1919
+ "optional": true
1920
+ },
1921
+ "@vitest/browser": {
1922
+ "optional": true
1923
+ },
1924
+ "@vitest/ui": {
1925
+ "optional": true
1926
+ },
1927
+ "happy-dom": {
1928
+ "optional": true
1929
+ },
1930
+ "jsdom": {
1931
+ "optional": true
1932
+ }
1933
+ }
1934
+ },
1935
+ "node_modules/w3c-xmlserializer": {
1936
+ "version": "5.0.0",
1937
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
1938
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
1939
+ "dev": true,
1940
+ "license": "MIT",
1941
+ "dependencies": {
1942
+ "xml-name-validator": "^5.0.0"
1943
+ },
1944
+ "engines": {
1945
+ "node": ">=18"
1946
+ }
1947
+ },
1948
+ "node_modules/webidl-conversions": {
1949
+ "version": "8.0.1",
1950
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
1951
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
1952
+ "dev": true,
1953
+ "license": "BSD-2-Clause",
1954
+ "engines": {
1955
+ "node": ">=20"
1956
+ }
1957
+ },
1958
+ "node_modules/whatwg-mimetype": {
1959
+ "version": "5.0.0",
1960
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
1961
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
1962
+ "dev": true,
1963
+ "license": "MIT",
1964
+ "engines": {
1965
+ "node": ">=20"
1966
+ }
1967
+ },
1968
+ "node_modules/whatwg-url": {
1969
+ "version": "16.0.1",
1970
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
1971
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
1972
+ "dev": true,
1973
+ "license": "MIT",
1974
+ "dependencies": {
1975
+ "@exodus/bytes": "^1.11.0",
1976
+ "tr46": "^6.0.0",
1977
+ "webidl-conversions": "^8.0.1"
1978
+ },
1979
+ "engines": {
1980
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
1981
+ }
1982
+ },
1983
+ "node_modules/why-is-node-running": {
1984
+ "version": "2.3.0",
1985
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
1986
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
1987
+ "dev": true,
1988
+ "license": "MIT",
1989
+ "dependencies": {
1990
+ "siginfo": "^2.0.0",
1991
+ "stackback": "0.0.2"
1992
+ },
1993
+ "bin": {
1994
+ "why-is-node-running": "cli.js"
1995
+ },
1996
+ "engines": {
1997
+ "node": ">=8"
1998
+ }
1999
+ },
2000
+ "node_modules/xml-name-validator": {
2001
+ "version": "5.0.0",
2002
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
2003
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
2004
+ "dev": true,
2005
+ "license": "Apache-2.0",
2006
+ "engines": {
2007
+ "node": ">=18"
2008
+ }
2009
+ },
2010
+ "node_modules/xmlchars": {
2011
+ "version": "2.2.0",
2012
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
2013
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
2014
+ "dev": true,
2015
+ "license": "MIT"
2016
+ }
2017
+ }
2018
+ }
package.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "reachy-mini-morse-code",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "description": "Talk to Reachy Mini in Morse code — antenna taps & beeps, heard through the mic.",
7
+ "scripts": {
8
+ "test": "vitest run",
9
+ "test:watch": "vitest",
10
+ "test:e2e": "playwright test"
11
+ },
12
+ "devDependencies": {
13
+ "@playwright/test": "^1.48.0",
14
+ "jsdom": "^29.1.1",
15
+ "vitest": "^2.1.0"
16
+ }
17
+ }
plan.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reachy Mini Morse Code — plan
2
+
3
+ ## Goal
4
+
5
+ A shareable browser app (HF Space, JS, bare-HTML + CDN host shell, mirroring
6
+ `marionette-js`) that lets a Reachy Mini **communicate in Morse code**:
7
+
8
+ - The **robot** transmits by *hitting its antennas together* — each hit makes
9
+ an audible click.
10
+ - A **phone / laptop** transmits by *beeping* through its speaker.
11
+ - Either device (or a second robot) **listens** through a microphone, detects
12
+ the impulses, and decodes them back to text.
13
+ - Two Reachy Minis face-to-face can therefore talk to each other.
14
+
15
+ ## Why an "impulse" wire code (the key design decision)
16
+
17
+ An antenna hit is **impulsive**: it produces one short click. You cannot make a
18
+ click "longer" the way a tone can be held, so we **cannot** use the classic
19
+ Morse discriminator (dot = short tone, dash = long tone) on the robot channel.
20
+
21
+ To keep *every* channel interoperable through *one* detector (robot-clicks,
22
+ phone-beeps, robot↔robot), the on-the-wire code is built from **onsets only**
23
+ (the instant each sound starts), and dot vs dash is carried by **rhythm**:
24
+
25
+ - **dot** = 1 impulse
26
+ - **dash** = 2 impulses spaced by `dahGapMs` (a quick double-tap)
27
+ - elements within a letter separated by `elemGapMs`
28
+ - letters separated by `letterGapMs`
29
+ - words separated by `wordGapMs`
30
+
31
+ with `dahGapMs < elemGapMs < letterGapMs < wordGapMs` (well separated so the
32
+ decoder can bin each inter-onset interval unambiguously). This is still real
33
+ Morse semantically — the learn-chart shows the standard `.-` patterns — it is
34
+ just transmitted as an impulse rhythm instead of tone length. The user
35
+ explicitly framed Morse as "an equivalence between letters and rhythm", which
36
+ is exactly this.
37
+
38
+ Beeps use the *same* impulse code (short clicks) so a beeping phone and a
39
+ tapping robot are mutually intelligible. (An "audible long-tone" cosmetic mode
40
+ can be added later; it is not the machine-decodable wire format.)
41
+
42
+ All timings live in one `timing.js` config with a single `unitMs` speed knob,
43
+ so they can be **calibrated on real hardware** (click sharpness, room
44
+ reverb, mic latency) without touching logic.
45
+
46
+ ## Detection algorithm (ported from `marionette/tests/audio_analysis.py`)
47
+
48
+ `detect_transient_onsets`, proven in the Marionette audio-sync tests:
49
+
50
+ 1. High-pass filter (~2 kHz) to isolate sharp clicks from room rumble / voice.
51
+ 2. Short-term energy in 5 ms windows.
52
+ 3. Spectral flux = positive energy differences.
53
+ 4. Normalise, peak-pick with a min-separation guard.
54
+
55
+ Ported to the Web Audio API (`BiquadFilter` highpass + per-frame energy/flux in
56
+ an `AudioWorklet`/`ScriptProcessor`) for live mic decoding, and to a pure-JS
57
+ offline function reused verbatim in unit tests against synthetic audio.
58
+
59
+ ## Architecture (bare-HTML + CDN host shell, like marionette-js)
60
+
61
+ ```
62
+ index.html #root (host shell mount) + #app (in-iframe surface), CDN SDK pin
63
+ main.js dispatcher: standalone mountHost() | embed connectToHost()
64
+ style.css mobile-first, OKLCH palette, dark/light from handle.theme
65
+ lib/
66
+ morse.js text <-> morse string ('.- ...') tables + encode/decode [pure, tested]
67
+ timing.js unit/gap config + WPM-ish speed presets [pure, tested]
68
+ wire.js morse <-> timed impulse schedule (emit) and onset-times
69
+ -> morse decode (gap binning) [pure, tested]
70
+ detector.js offline + streaming transient-onset detector (Web Audio) [DSP core tested]
71
+ synth.js Web Audio click/beep emitter (plays a wire schedule)
72
+ robot-tapper.js drives antennas to clap on schedule (setAntennasDeg)
73
+ mic.js getUserMedia -> streaming detector -> onset times
74
+ viz.js canvas waveform + live onset markers
75
+ animation-helpers.js re-export SDK /animation (safelyReturnToPose) [stubbed in tests]
76
+ views/
77
+ composer.js type text -> choose emitter (robot antennas | this device) -> send
78
+ listener.js live mic visualization + decoded text
79
+ learn.js Morse chart (letter <-> .- <-> rhythm), tap-to-hear
80
+ topbar.js / settings.js speed + detector threshold + theme
81
+ tests/unit/ morse, wire, detector (synthetic round-trip) — vitest
82
+ public/icon.svg
83
+ README.md HF Space frontmatter (sdk: static, hf_oauth, tags)
84
+ ```
85
+
86
+ ## Robot antenna "clap" (from `marionette/tests/test_antenna_collision.py`)
87
+
88
+ Right antenna held, left antenna slams in to contact and back — low-PID
89
+ antennas make this safe and audible. In the JS SDK we drive it in real time
90
+ with `setAntennasDeg(right, left)` on a timed schedule from `wire.js`. Exact
91
+ contact angles + hold time get **calibrated on hardware**; defaults seeded from
92
+ the Marionette recipe (right ≈ −39°, left rest 0° → slam ≈ +40°).
93
+
94
+ ## What needs hardware (deferred — will ping Rémi)
95
+
96
+ 1. **Mic + speaker calibration**: emit the impulse schedule from the Mac
97
+ speaker, record with the Mac mic, confirm the detector recovers the onsets
98
+ through-air and tune `unitMs` / thresholds / highpass. (Noise — needs Rémi.)
99
+ 2. **Robot antenna-click test**: confirm the clap is audible + detectable and
100
+ tune the contact angles / hold. (Needs the robot connected.)
101
+
102
+ Everything else (codec, detector DSP, full UI, synth, robot-tapper wiring) is
103
+ built and validated in software first (synthetic-audio round-trip, no
104
+ mic/speaker/robot).
105
+
106
+ ## Open questions (defaulted; will confirm with Rémi, non-blocking)
107
+
108
+ - App/Space name: `morse-code` / "Reachy Mini Morse Code". Deploy to
109
+ `RemiFabre/<name>` (user is logged in as RemiFabre, org pollen-robotics).
110
+ - Default speed: ~`unitMs = 120 ms` (slow & robust) — final value from calibration.
111
+ - Long-tone cosmetic beep mode: out of scope for v1 (impulse code only).
playwright.config.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, devices } from "@playwright/test";
2
+
3
+ // Smoke test only: serve the static folder and confirm the app's module
4
+ // graph (including the CDN host-shell + SDK imports) loads in a real browser
5
+ // without fatal errors. The full embed flow needs a live robot.
6
+ export default defineConfig({
7
+ testDir: "./tests/e2e",
8
+ timeout: 45_000,
9
+ use: {
10
+ baseURL: "http://localhost:4321",
11
+ ...devices["Desktop Chrome"],
12
+ },
13
+ webServer: {
14
+ command: "python3 -m http.server 4321",
15
+ port: 4321,
16
+ reuseExistingServer: true,
17
+ timeout: 20_000,
18
+ },
19
+ });
public/icon.svg ADDED
style.css ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Morse Code · Reachy Mini — mobile-first, dark/light themed. */
2
+
3
+ :root {
4
+ --bg: #0d1117;
5
+ --bg-soft: #161b22;
6
+ --panel: #1b222c;
7
+ --panel-2: #232c38;
8
+ --text: #e8edf3;
9
+ --muted: #93a1b3;
10
+ --line: #2b3442;
11
+ --accent: #5b8cff;
12
+ --accent-2: #8a6cff;
13
+ --danger: #ff5a78;
14
+ --good: #36d399;
15
+ --scope-fill: rgba(91, 140, 255, 0.45);
16
+ --scope-mark: rgba(255, 90, 120, 0.95);
17
+ --radius: 16px;
18
+ --shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
19
+ }
20
+
21
+ :root[data-theme="light"] {
22
+ --bg: #f4f6fb;
23
+ --bg-soft: #ffffff;
24
+ --panel: #ffffff;
25
+ --panel-2: #eef2f9;
26
+ --text: #131923;
27
+ --muted: #5d6b7e;
28
+ --line: #dde3ee;
29
+ --accent: #2f6bff;
30
+ --accent-2: #7a4dff;
31
+ --scope-fill: rgba(47, 107, 255, 0.30);
32
+ --scope-mark: rgba(214, 40, 79, 0.95);
33
+ --shadow: 0 10px 30px rgba(20, 30, 60, 0.12);
34
+ }
35
+
36
+ * { box-sizing: border-box; }
37
+
38
+ html, body {
39
+ margin: 0;
40
+ padding: 0;
41
+ background: var(--bg);
42
+ color: var(--text);
43
+ font-family: "Inter", system-ui, -apple-system, sans-serif;
44
+ -webkit-font-smoothing: antialiased;
45
+ }
46
+
47
+ #app {
48
+ min-height: 100dvh;
49
+ display: flex;
50
+ flex-direction: column;
51
+ max-width: 720px;
52
+ margin: 0 auto;
53
+ }
54
+
55
+ /* ─── App bar ─────────────────────────────────────────────── */
56
+ .appbar {
57
+ position: sticky;
58
+ top: 0;
59
+ z-index: 10;
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: space-between;
63
+ padding: 14px 18px calc(14px + env(safe-area-inset-top));
64
+ padding-top: max(14px, env(safe-area-inset-top));
65
+ background: color-mix(in srgb, var(--bg) 88%, transparent);
66
+ backdrop-filter: blur(12px);
67
+ border-bottom: 1px solid var(--line);
68
+ }
69
+ .brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 19px; letter-spacing: -0.02em; }
70
+ .brand-logo { font-size: 22px; }
71
+
72
+ .icon-btn {
73
+ appearance: none;
74
+ background: var(--panel);
75
+ border: 1px solid var(--line);
76
+ color: var(--text);
77
+ width: 40px; height: 40px;
78
+ border-radius: 12px;
79
+ font-size: 18px;
80
+ cursor: pointer;
81
+ }
82
+ .icon-btn:active { transform: scale(0.95); }
83
+
84
+ /* ─── View host + tabbar ──────────────────────────────────── */
85
+ .view-host { flex: 1; padding: 16px 16px 96px; }
86
+ .view { display: flex; flex-direction: column; gap: 16px; }
87
+ .view h2 { margin: 4px 0 0; font-size: 24px; letter-spacing: -0.02em; }
88
+ .view h3 { margin: 6px 0; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
89
+ .muted { color: var(--muted); }
90
+
91
+ .tabbar {
92
+ position: fixed;
93
+ left: 0; right: 0; bottom: 0;
94
+ z-index: 10;
95
+ display: flex;
96
+ justify-content: center;
97
+ gap: 6px;
98
+ padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
99
+ background: color-mix(in srgb, var(--bg) 92%, transparent);
100
+ backdrop-filter: blur(12px);
101
+ border-top: 1px solid var(--line);
102
+ }
103
+ .tab {
104
+ flex: 1 1 0;
105
+ max-width: 160px;
106
+ appearance: none;
107
+ background: transparent;
108
+ border: 0;
109
+ color: var(--muted);
110
+ display: flex;
111
+ flex-direction: column;
112
+ align-items: center;
113
+ gap: 3px;
114
+ padding: 6px;
115
+ border-radius: 12px;
116
+ font-size: 12px;
117
+ font-weight: 600;
118
+ cursor: pointer;
119
+ }
120
+ .tab-ico { font-size: 20px; }
121
+ .tab.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
122
+
123
+ /* ─── Fields & controls ───────────────────────────────────── */
124
+ .field { display: flex; flex-direction: column; gap: 8px; }
125
+ .field-label { font-size: 13px; font-weight: 600; color: var(--muted); }
126
+ .row { display: flex; gap: 10px; align-items: center; }
127
+ .row.between { justify-content: space-between; }
128
+ .send-row { margin-top: 4px; }
129
+
130
+ .compose-input {
131
+ width: 100%;
132
+ resize: vertical;
133
+ min-height: 84px;
134
+ padding: 14px;
135
+ font-size: 18px;
136
+ font-family: inherit;
137
+ color: var(--text);
138
+ background: var(--panel);
139
+ border: 1px solid var(--line);
140
+ border-radius: var(--radius);
141
+ outline: none;
142
+ }
143
+ .compose-input:focus { border-color: var(--accent); }
144
+
145
+ .btn {
146
+ appearance: none;
147
+ border: 1px solid var(--line);
148
+ background: var(--panel);
149
+ color: var(--text);
150
+ padding: 12px 18px;
151
+ border-radius: 14px;
152
+ font-size: 15px;
153
+ font-weight: 700;
154
+ cursor: pointer;
155
+ }
156
+ .btn:active { transform: scale(0.98); }
157
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
158
+ .btn.primary { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border: 0; color: #fff; }
159
+ .btn.ghost { background: transparent; }
160
+ .btn.big { flex: 1; padding: 16px; font-size: 17px; }
161
+ .btn.listening { background: linear-gradient(135deg, var(--danger), #ff9a5a); }
162
+
163
+ .segmented { display: flex; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 5px; }
164
+ .seg {
165
+ flex: 1;
166
+ appearance: none;
167
+ background: transparent;
168
+ border: 0;
169
+ color: var(--muted);
170
+ padding: 10px 8px;
171
+ border-radius: 10px;
172
+ font-size: 14px;
173
+ font-weight: 600;
174
+ cursor: pointer;
175
+ }
176
+ .seg.active { background: var(--panel-2); color: var(--text); box-shadow: var(--shadow); }
177
+
178
+ .range { width: 100%; accent-color: var(--accent); }
179
+ .range-val { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--text); }
180
+
181
+ .status { font-size: 13px; min-height: 18px; }
182
+ .warn { color: var(--danger); font-size: 14px; font-weight: 600; }
183
+
184
+ /* ─── Morse chips ─────────────────────────────────────────── */
185
+ .morse-out {
186
+ min-height: 48px;
187
+ display: flex;
188
+ flex-wrap: wrap;
189
+ align-items: center;
190
+ gap: 6px 12px;
191
+ padding: 14px;
192
+ background: var(--panel);
193
+ border: 1px solid var(--line);
194
+ border-radius: var(--radius);
195
+ }
196
+ .pattern { display: inline-flex; align-items: center; gap: 4px; }
197
+ .pattern .dot, .pattern .dash {
198
+ display: inline-block;
199
+ height: 12px;
200
+ border-radius: 6px;
201
+ background: var(--accent);
202
+ }
203
+ .pattern .dot { width: 12px; }
204
+ .pattern .dash { width: 30px; background: var(--accent-2); }
205
+ .wordsep { color: var(--muted); font-weight: 800; padding: 0 2px; }
206
+
207
+ /* ─── Listen ──────────────────────────────────────────────── */
208
+ .scope {
209
+ width: 100%;
210
+ height: 130px;
211
+ background: var(--panel);
212
+ border: 1px solid var(--line);
213
+ border-radius: var(--radius);
214
+ display: block;
215
+ }
216
+ .decoded-text {
217
+ font-family: "JetBrains Mono", ui-monospace, monospace;
218
+ font-size: 26px;
219
+ font-weight: 700;
220
+ letter-spacing: 0.04em;
221
+ min-height: 34px;
222
+ word-break: break-word;
223
+ }
224
+ .decoded-morse { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 14px; word-break: break-all; }
225
+ .transcript { display: flex; flex-direction: column; gap: 8px; }
226
+ .line {
227
+ display: flex; flex-direction: column; gap: 2px;
228
+ padding: 10px 12px;
229
+ background: var(--panel);
230
+ border: 1px solid var(--line);
231
+ border-radius: 12px;
232
+ }
233
+ .line-text { font-family: "JetBrains Mono", monospace; font-weight: 700; font-size: 16px; }
234
+ .line-morse { font-family: "JetBrains Mono", monospace; font-size: 12px; }
235
+
236
+ /* ─── Learn chart ─────────────────────────────────────────── */
237
+ .chart-section { display: flex; flex-direction: column; gap: 8px; }
238
+ .chart-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; }
239
+ .chart-cell {
240
+ appearance: none;
241
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
242
+ padding: 12px 8px;
243
+ background: var(--panel);
244
+ border: 1px solid var(--line);
245
+ border-radius: 14px;
246
+ color: var(--text);
247
+ cursor: pointer;
248
+ }
249
+ .chart-cell:active { transform: scale(0.96); }
250
+ .cell-char { font-size: 22px; font-weight: 800; }
251
+ .chart-cell.ping { animation: ping 0.4s ease; border-color: var(--accent); }
252
+ @keyframes ping { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); } 100% { box-shadow: 0 0 0 14px transparent; } }
253
+ .legend { display: flex; gap: 18px; flex-wrap: wrap; color: var(--muted); font-size: 14px; }
254
+ .legend-item { display: inline-flex; align-items: center; gap: 8px; }
255
+
256
+ /* ─── Settings sheet / overlay ────────────────────────────── */
257
+ .overlay {
258
+ position: fixed; inset: 0; z-index: 50;
259
+ background: rgba(0, 0, 0, 0.5);
260
+ display: flex; align-items: flex-end; justify-content: center;
261
+ }
262
+ .sheet {
263
+ width: 100%;
264
+ max-width: 720px;
265
+ max-height: 88dvh;
266
+ overflow-y: auto;
267
+ background: var(--bg-soft);
268
+ border-radius: 22px 22px 0 0;
269
+ box-shadow: var(--shadow);
270
+ padding: 16px 18px calc(28px + env(safe-area-inset-bottom));
271
+ }
272
+ .sheet-head { display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; background: var(--bg-soft); padding-bottom: 8px; }
273
+ .settings-body { display: flex; flex-direction: column; gap: 14px; }
274
+ .version { text-align: center; font-size: 12px; margin-top: 6px; }
275
+
276
+ /* ─── Toast ───────────────────────────────────────────────── */
277
+ .toast {
278
+ position: fixed; left: 50%; bottom: 110px; transform: translateX(-50%) translateY(20px);
279
+ background: var(--panel-2); color: var(--text);
280
+ border: 1px solid var(--line);
281
+ padding: 12px 18px; border-radius: 12px;
282
+ box-shadow: var(--shadow);
283
+ opacity: 0; pointer-events: none; transition: all 0.25s ease;
284
+ z-index: 100; max-width: 90vw; text-align: center;
285
+ }
286
+ .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
287
+
288
+ @media (min-width: 560px) {
289
+ .tab-label { font-size: 13px; }
290
+ }
tests/e2e/smoke.spec.js ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ // Fatal = the module graph failed to load (bad import URL, syntax error,
4
+ // missing global). We tolerate runtime warnings the host shell may emit
5
+ // before sign-in, but not these.
6
+ const FATAL = /(SyntaxError|ReferenceError|is not defined|Failed to fetch dynamically imported module|Unexpected token)/;
7
+
8
+ test("standalone page loads the module graph (host shell mounts, no fatal errors)", async ({ page }) => {
9
+ const fatals = [];
10
+ page.on("pageerror", (e) => { if (FATAL.test(String(e))) fatals.push(String(e)); });
11
+ page.on("console", (m) => { if (m.type() === "error" && FATAL.test(m.text())) fatals.push(m.text()); });
12
+
13
+ await page.goto("/");
14
+
15
+ // mountHost() renders the host shell (sign-in / picker) into #root.
16
+ // We only assert the module graph executed far enough to populate it.
17
+ await expect.poll(
18
+ async () => page.locator("#root").evaluate((n) => n.childElementCount),
19
+ { timeout: 30_000 },
20
+ ).toBeGreaterThan(0);
21
+
22
+ expect(fatals, `fatal load errors:\n${fatals.join("\n")}`).toEqual([]);
23
+ });
24
+
25
+ test("embedded boot path imports cleanly (no module-load error)", async ({ page }) => {
26
+ const fatals = [];
27
+ page.on("pageerror", (e) => { if (FATAL.test(String(e))) fatals.push(String(e)); });
28
+
29
+ // ?embedded=1 runs bootEmbed(): connectToHost() will not complete without
30
+ // a host, but importing it + our whole module graph must not throw.
31
+ await page.goto("/?embedded=1");
32
+ await page.waitForTimeout(4000);
33
+ expect(fatals, `fatal load errors:\n${fatals.join("\n")}`).toEqual([]);
34
+ });
tests/stubs/animation-helpers.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ // Test stub for lib/animation-helpers.js (the SDK /animation re-export).
2
+ // Unit tests never drive the robot; these are inert placeholders.
3
+ export const INIT_POSE = {};
4
+ export const INIT_ANTENNAS_RAD = [0, 0];
5
+ export const DEFAULT_SCALED_DURATION_PRESET = {};
6
+ export function safelyReturnToPose() {}
7
+ export function scaledDuration() {
8
+ return { duration: 1 };
9
+ }
10
+ export function installShutdownHandler() {}
tests/unit/detector.test.js ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import { detectTransientOnsets, highpass, energyFlux } from "../../lib/detector.js";
3
+ import { textToSchedule, decodeOnsets } from "../../lib/wire.js";
4
+ import { makeTiming } from "../../lib/timing.js";
5
+
6
+ const SR = 48000;
7
+ const T = makeTiming(120);
8
+
9
+ /**
10
+ * Render an onset schedule (ms) into a synthetic mono buffer.
11
+ * Each impulse is a short decaying ~4 kHz burst (sharp click, energy well
12
+ * above the 2 kHz highpass). Optionally add a low 60 Hz hum + deterministic
13
+ * pseudo-noise to prove the highpass + flux stages are doing real work.
14
+ */
15
+ function renderClicks(onsetsMs, { leadMs = 60, tailMs = 300, hum = 0, noise = 0 } = {}) {
16
+ // Lead-in silence: a real listener is already recording before the
17
+ // sender starts, so the very first click has a low-energy window before
18
+ // it for the rising-edge detector to key off.
19
+ const durMs = leadMs + (onsetsMs.length ? onsetsMs[onsetsMs.length - 1] : 0) + tailMs;
20
+ const n = Math.ceil((durMs / 1000) * SR);
21
+ const buf = new Float32Array(n);
22
+ const clickLen = Math.round(0.01 * SR); // 10 ms
23
+ const tau = 0.0015; // 1.5 ms decay
24
+ for (const t0 of onsetsMs) {
25
+ const start = Math.round(((t0 + leadMs) / 1000) * SR);
26
+ for (let j = 0; j < clickLen && start + j < n; j++) {
27
+ const tt = j / SR;
28
+ buf[start + j] += 0.6 * Math.exp(-tt / tau) * Math.sin(2 * Math.PI * 4000 * tt);
29
+ }
30
+ }
31
+ if (hum || noise) {
32
+ for (let i = 0; i < n; i++) {
33
+ const tt = i / SR;
34
+ if (hum) buf[i] += hum * Math.sin(2 * Math.PI * 60 * tt);
35
+ // deterministic low-level "noise": sum of a few incommensurate tones
36
+ if (noise) {
37
+ buf[i] += noise * (
38
+ Math.sin(2 * Math.PI * 137 * tt) +
39
+ Math.sin(2 * Math.PI * 311 * tt)
40
+ ) * 0.5;
41
+ }
42
+ }
43
+ }
44
+ return buf;
45
+ }
46
+
47
+ describe("highpass + energyFlux primitives", () => {
48
+ it("highpass removes a strong 60 Hz hum", () => {
49
+ const n = SR; // 1 s
50
+ const hum = new Float32Array(n);
51
+ for (let i = 0; i < n; i++) hum[i] = Math.sin(2 * Math.PI * 60 * (i / SR));
52
+ const filtered = highpass(hum, SR, 2000);
53
+ let rmsIn = 0, rmsOut = 0;
54
+ for (let i = 0; i < n; i++) { rmsIn += hum[i] ** 2; rmsOut += filtered[i] ** 2; }
55
+ expect(Math.sqrt(rmsOut / n)).toBeLessThan(Math.sqrt(rmsIn / n) * 0.05);
56
+ });
57
+
58
+ it("energyFlux returns positive spikes around clicks", () => {
59
+ const buf = renderClicks([100, 500]);
60
+ const { flux } = energyFlux(highpass(buf, SR, 2000), SR, 5);
61
+ let maxFlux = 0;
62
+ for (const f of flux) if (f > maxFlux) maxFlux = f;
63
+ expect(maxFlux).toBeGreaterThan(0);
64
+ });
65
+ });
66
+
67
+ describe("detectTransientOnsets — count + timing", () => {
68
+ it("finds all 12 impulses of SOS within 25 ms", () => {
69
+ const { onsets } = textToSchedule("SOS", T);
70
+ const detected = detectTransientOnsets(renderClicks(onsets), SR);
71
+ expect(detected.length).toBe(onsets.length);
72
+ // Compare spacing relative to the first onset (absorbs the constant
73
+ // lead-in offset + any fixed detection latency).
74
+ for (let i = 0; i < onsets.length; i++) {
75
+ const exp = onsets[i] - onsets[0];
76
+ const got = detected[i] - detected[0];
77
+ expect(Math.abs(got - exp)).toBeLessThan(25);
78
+ }
79
+ });
80
+ });
81
+
82
+ describe("end-to-end: text -> clicks -> detect -> decode", () => {
83
+ const phrases = ["SOS", "HI", "HELLO WORLD", "CQ CQ"];
84
+ for (const text of phrases) {
85
+ it(`recovers "${text}" from clean synthetic audio`, () => {
86
+ const { onsets } = textToSchedule(text, T);
87
+ const detected = detectTransientOnsets(renderClicks(onsets), SR);
88
+ expect(decodeOnsets(detected, T).text).toBe(text);
89
+ });
90
+
91
+ it(`recovers "${text}" through 60 Hz hum + tonal noise`, () => {
92
+ const { onsets } = textToSchedule(text, T);
93
+ const buf = renderClicks(onsets, { hum: 0.15, noise: 0.03 });
94
+ const detected = detectTransientOnsets(buf, SR);
95
+ expect(decodeOnsets(detected, T).text).toBe(text);
96
+ });
97
+ }
98
+ });
tests/unit/morse.test.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ textToMorse,
4
+ morseToText,
5
+ morseToTokens,
6
+ isEncodable,
7
+ } from "../../lib/morse.js";
8
+
9
+ describe("textToMorse", () => {
10
+ it("encodes SOS", () => {
11
+ expect(textToMorse("SOS").morse).toBe("... --- ...");
12
+ });
13
+
14
+ it("is case-insensitive", () => {
15
+ expect(textToMorse("sos").morse).toBe("... --- ...");
16
+ });
17
+
18
+ it("separates words with /", () => {
19
+ expect(textToMorse("HI YOU").morse).toBe(".... .. / -.-- --- ..-");
20
+ });
21
+
22
+ it("encodes digits and punctuation", () => {
23
+ expect(textToMorse("E1?").morse).toBe(". .---- ..--..");
24
+ });
25
+
26
+ it("reports skipped (non-encodable) characters", () => {
27
+ const r = textToMorse("Aé😀");
28
+ expect(r.morse).toBe(".-");
29
+ expect(r.skipped).toContain("É");
30
+ });
31
+ });
32
+
33
+ describe("morseToText", () => {
34
+ it("decodes SOS", () => {
35
+ expect(morseToText("... --- ...")).toBe("SOS");
36
+ });
37
+
38
+ it("decodes multi-word with /", () => {
39
+ expect(morseToText(".... .. / -.-- --- ..-")).toBe("HI YOU");
40
+ });
41
+
42
+ it("round-trips a sentence", () => {
43
+ const text = "HELLO WORLD 123";
44
+ expect(morseToText(textToMorse(text).morse)).toBe(text);
45
+ });
46
+
47
+ it("maps unknown pattern to ?", () => {
48
+ expect(morseToText("........")).toBe("?");
49
+ });
50
+ });
51
+
52
+ describe("morseToTokens", () => {
53
+ it("flattens A (.-) into dot, elemGap, dah", () => {
54
+ expect(morseToTokens(".-")).toEqual(["dot", "elemGap", "dah"]);
55
+ });
56
+
57
+ it("inserts letterGap between letters and wordGap between words", () => {
58
+ // "E E / E" -> dot, letterGap, dot, wordGap, dot
59
+ expect(morseToTokens(". . / .")).toEqual([
60
+ "dot", "letterGap", "dot", "wordGap", "dot",
61
+ ]);
62
+ });
63
+ });
64
+
65
+ describe("isEncodable", () => {
66
+ it("accepts letters/digits, rejects emoji", () => {
67
+ expect(isEncodable("a")).toBe(true);
68
+ expect(isEncodable("7")).toBe(true);
69
+ expect(isEncodable("😀")).toBe(false);
70
+ });
71
+ });
tests/unit/views.test.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { makeTiming, SPEED_PRESETS } from "../../lib/timing.js";
4
+ import { createCompose } from "../../views/compose.js";
5
+ import { createLearn } from "../../views/learn.js";
6
+ import { createSettings } from "../../views/settings.js";
7
+ import { createListen } from "../../views/listen.js";
8
+ import { DEFAULT_TAP_PROFILE } from "../../lib/robot-tapper.js";
9
+
10
+ function makeCtx(overrides = {}) {
11
+ const state = {
12
+ speed: "normal",
13
+ unitMs: SPEED_PRESETS.normal,
14
+ emitter: "device",
15
+ beepFreq: 2200,
16
+ detector: { highpassHz: 2000, thresholdFactor: 4.0 },
17
+ tap: { ...DEFAULT_TAP_PROFILE },
18
+ };
19
+ const synth = { play: vi.fn(), stop: vi.fn(), freq: 2200 };
20
+ return {
21
+ reachy: null,
22
+ state,
23
+ timing: () => makeTiming(state.unitMs),
24
+ setSpeed: (p) => { state.speed = p; if (SPEED_PRESETS[p]) state.unitMs = SPEED_PRESETS[p]; },
25
+ synth: () => synth,
26
+ tapper: () => null,
27
+ makeMic: () => ({ start: vi.fn(), stop: vi.fn(), now: () => 0 }),
28
+ toast: vi.fn(),
29
+ _synth: synth,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe("Compose view", () => {
35
+ it("renders Morse chips for the default message", () => {
36
+ const ctx = makeCtx();
37
+ const { node } = createCompose(ctx);
38
+ const input = node.querySelector(".compose-input");
39
+ expect(input.value).toBe("SOS");
40
+ // SOS = 3 + 3 + 3 = 9 symbol chips
41
+ expect(node.querySelectorAll(".morse-out .pattern").length).toBe(3);
42
+ expect(node.querySelectorAll(".morse-out .dot, .morse-out .dash").length).toBe(9);
43
+ });
44
+
45
+ it("updates preview on input and warns on non-encodable chars", () => {
46
+ const ctx = makeCtx();
47
+ const { node } = createCompose(ctx);
48
+ const input = node.querySelector(".compose-input");
49
+ input.value = "Aé";
50
+ input.dispatchEvent(new window.Event("input"));
51
+ expect(node.querySelector(".warn").hidden).toBe(false);
52
+ // 'A' -> .- = 2 chips
53
+ expect(node.querySelectorAll(".morse-out .dot, .morse-out .dash").length).toBe(2);
54
+ });
55
+
56
+ it("plays via synth when emitter is device", () => {
57
+ const ctx = makeCtx();
58
+ const { node } = createCompose(ctx);
59
+ node.querySelector(".btn.primary").click();
60
+ expect(ctx._synth.play).toHaveBeenCalled();
61
+ });
62
+ });
63
+
64
+ describe("Learn view", () => {
65
+ it("renders a tile per letter, digit and punctuation, and plays on tap", () => {
66
+ const ctx = makeCtx();
67
+ const { node } = createLearn(ctx);
68
+ const cells = node.querySelectorAll(".chart-cell");
69
+ expect(cells.length).toBe(26 + 10 + 12);
70
+ cells[0].click(); // 'A'
71
+ expect(ctx._synth.play).toHaveBeenCalled();
72
+ });
73
+ });
74
+
75
+ describe("Settings view", () => {
76
+ it("renders calibration sliders and mutates config", () => {
77
+ const ctx = makeCtx();
78
+ const { node } = createSettings(ctx);
79
+ const ranges = node.querySelectorAll("input.range");
80
+ expect(ranges.length).toBeGreaterThanOrEqual(8);
81
+ // first range is the speed unit
82
+ ranges[0].value = "200";
83
+ ranges[0].dispatchEvent(new window.Event("input"));
84
+ expect(ctx.state.unitMs).toBe(200);
85
+ });
86
+ });
87
+
88
+ describe("Listen view", () => {
89
+ it("builds without a mic and shows placeholder decode", () => {
90
+ const ctx = makeCtx();
91
+ const { node } = createListen(ctx);
92
+ expect(node.querySelector(".scope")).toBeTruthy();
93
+ expect(node.querySelector(".decoded-text").textContent).toBe("…");
94
+ expect(node.querySelector(".btn.primary").textContent).toMatch(/Start listening/);
95
+ });
96
+ });
tests/unit/wire.test.js ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ tokensToOnsets,
4
+ textToSchedule,
5
+ onsetsToMorse,
6
+ decodeOnsets,
7
+ } from "../../lib/wire.js";
8
+ import { morseToTokens } from "../../lib/morse.js";
9
+ import { makeTiming } from "../../lib/timing.js";
10
+
11
+ const T = makeTiming(120); // dah=240 elem=480 letter=960 word=1680
12
+
13
+ describe("tokensToOnsets", () => {
14
+ it("dot = 1 impulse", () => {
15
+ expect(tokensToOnsets(["dot"], T)).toEqual([0]);
16
+ });
17
+
18
+ it("dash = 2 impulses spaced by dahGap", () => {
19
+ expect(tokensToOnsets(["dah"], T)).toEqual([0, T.dahGapMs]);
20
+ });
21
+
22
+ it("A (.-) spaces the dash element after elemGap from the dot", () => {
23
+ // dot@0, elemGap, dah@(elem), dah2@(elem+dah)
24
+ expect(tokensToOnsets(morseToTokens(".-"), T)).toEqual([
25
+ 0,
26
+ T.elemGapMs,
27
+ T.elemGapMs + T.dahGapMs,
28
+ ]);
29
+ });
30
+ });
31
+
32
+ describe("onsetsToMorse round-trip (ideal timing)", () => {
33
+ const samples = ["SOS", "HELLO WORLD", "HI YOU", "CQ CQ", "E T 1 9", "ABCDE"];
34
+ for (const text of samples) {
35
+ it(`recovers "${text}"`, () => {
36
+ const { morse, onsets } = textToSchedule(text, T);
37
+ expect(onsetsToMorse(onsets, T)).toBe(morse);
38
+ expect(decodeOnsets(onsets, T).text).toBe(text);
39
+ });
40
+ }
41
+ });
42
+
43
+ describe("onsetsToMorse with timing jitter", () => {
44
+ // Deterministic pseudo-jitter (no Math.random): perturb each onset by a
45
+ // bounded sawtooth. Should still decode because gap classes are well
46
+ // separated (geometric midpoints).
47
+ function jitter(onsets, maxMs) {
48
+ return onsets.map((t, i) => t + (((i * 37) % 11) - 5) / 5 * maxMs);
49
+ }
50
+
51
+ it("decodes SOS with +-30ms jitter", () => {
52
+ const { onsets, text } = { ...textToSchedule("SOS", T) };
53
+ const jittered = jitter(onsets, 30);
54
+ expect(decodeOnsets(jittered, T).text).toBe("SOS");
55
+ expect(text).toBeUndefined; // sanity: textToSchedule has no .text
56
+ });
57
+
58
+ it("decodes a sentence with +-40ms jitter", () => {
59
+ const { onsets } = textToSchedule("HELLO WORLD", T);
60
+ expect(decodeOnsets(jitter(onsets, 40), T).text).toBe("HELLO WORLD");
61
+ });
62
+ });
63
+
64
+ describe("edge cases", () => {
65
+ it("empty onsets -> empty", () => {
66
+ expect(onsetsToMorse([], T)).toBe("");
67
+ expect(decodeOnsets([], T)).toEqual({ morse: "", text: "" });
68
+ });
69
+
70
+ it("single impulse -> E", () => {
71
+ expect(decodeOnsets([0], T).text).toBe("E");
72
+ });
73
+ });
views/compose.js ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Compose view: type a message, see its Morse, pick who transmits (the robot's
3
+ * antennas or this device's speaker), and send it.
4
+ */
5
+ import { el, clear, morsePattern } from "./dom.js";
6
+ import { textToMorse, morseToTokens } from "../lib/morse.js";
7
+ import { tokensToOnsets } from "../lib/wire.js";
8
+
9
+ export function createCompose(ctx) {
10
+ const root = el("section.view#view-compose");
11
+
12
+ const input = el("textarea.compose-input", {
13
+ placeholder: "Type a message… e.g. SOS",
14
+ rows: 3,
15
+ value: "SOS",
16
+ oninput: refresh,
17
+ });
18
+
19
+ const morseOut = el("div.morse-out");
20
+ const warn = el("div.warn", { hidden: true });
21
+ const duration = el("span.muted");
22
+
23
+ const emitter = segmented(
24
+ "Who sends it?",
25
+ [
26
+ { value: "robot", label: "🤖 Robot antennas" },
27
+ { value: "device", label: "📱 This device" },
28
+ ],
29
+ ctx.state.emitter,
30
+ (v) => { ctx.state.emitter = v; refresh(); },
31
+ );
32
+
33
+ const speed = segmented(
34
+ "Speed",
35
+ [
36
+ { value: "relaxed", label: "Relaxed" },
37
+ { value: "normal", label: "Normal" },
38
+ { value: "brisk", label: "Brisk" },
39
+ ],
40
+ ctx.state.speed,
41
+ (v) => { ctx.setSpeed(v); refresh(); },
42
+ );
43
+
44
+ const sendBtn = el("button.btn.primary.big", { onclick: send }, "Send ▶");
45
+ const stopBtn = el("button.btn.ghost", { onclick: stop, hidden: true }, "Stop ◼");
46
+ const status = el("div.status muted");
47
+
48
+ let cancelFn = null;
49
+
50
+ function currentSchedule() {
51
+ const { morse, skipped } = textToMorse(input.value);
52
+ const onsets = tokensToOnsets(morseToTokens(morse), ctx.timing());
53
+ return { morse, skipped, onsets };
54
+ }
55
+
56
+ function refresh() {
57
+ const { morse, skipped, onsets } = currentSchedule();
58
+ clear(morseOut);
59
+ if (!morse) {
60
+ morseOut.append(el("span.muted", {}, "Your Morse will appear here."));
61
+ } else {
62
+ morse.split(" / ").forEach((word, wi) => {
63
+ if (wi > 0) morseOut.append(el("span.wordsep", {}, "/"));
64
+ word.split(" ").forEach((p) => morseOut.append(morsePattern(p)));
65
+ });
66
+ }
67
+ if (skipped.length) {
68
+ warn.hidden = false;
69
+ warn.textContent = `Can't send in Morse: ${[...new Set(skipped)].join(" ")}`;
70
+ } else {
71
+ warn.hidden = true;
72
+ }
73
+ const secs = onsets.length ? (onsets[onsets.length - 1] / 1000 + 0.3) : 0;
74
+ duration.textContent = onsets.length ? `${onsets.length} taps · ~${secs.toFixed(1)}s` : "";
75
+ // reflect speed selection if changed elsewhere
76
+ speed.setValue(ctx.state.speed);
77
+ emitter.setValue(ctx.state.emitter);
78
+ }
79
+
80
+ async function send() {
81
+ const { onsets } = currentSchedule();
82
+ if (!onsets.length) return;
83
+ setSending(true);
84
+ const controller = new AbortController();
85
+ const highlight = (i) => flashTap(i, onsets.length);
86
+
87
+ try {
88
+ if (ctx.state.emitter === "robot") {
89
+ if (!ctx.reachy) { ctx.toast("No robot connected."); return; }
90
+ cancelFn = () => controller.abort();
91
+ await ctx.tapper().tap(onsets, { onTap: highlight, signal: controller.signal });
92
+ } else {
93
+ await new Promise((resolve) => {
94
+ const cancel = ctx.synth().play(onsets, {
95
+ onBlip: highlight,
96
+ onDone: resolve,
97
+ clickMs: ctx.timing().clickMs,
98
+ });
99
+ cancelFn = () => { cancel(); resolve(); };
100
+ });
101
+ }
102
+ } catch (e) {
103
+ ctx.toast(`Send failed: ${e?.message || e}`);
104
+ } finally {
105
+ setSending(false);
106
+ cancelFn = null;
107
+ }
108
+ }
109
+
110
+ function stop() {
111
+ cancelFn?.();
112
+ ctx.synth().stop();
113
+ ctx.tapper()?.stop?.();
114
+ setSending(false);
115
+ }
116
+
117
+ function setSending(on) {
118
+ sendBtn.hidden = on;
119
+ stopBtn.hidden = !on;
120
+ input.disabled = on;
121
+ }
122
+
123
+ function flashTap(i, total) {
124
+ status.textContent = `Transmitting… ${i + 1}/${total}`;
125
+ if (i + 1 >= total) setTimeout(() => { status.textContent = "Sent ✓"; }, 200);
126
+ }
127
+
128
+ root.append(
129
+ el("h2", {}, "Compose"),
130
+ el("label.field-label", {}, "Message"),
131
+ input,
132
+ el("div.row.between", {}, [el("span.muted", {}, "Morse preview"), duration]),
133
+ morseOut,
134
+ warn,
135
+ emitter.node,
136
+ speed.node,
137
+ el("div.row.send-row", {}, [sendBtn, stopBtn]),
138
+ status,
139
+ );
140
+
141
+ refresh();
142
+ return { node: root, onShow: refresh };
143
+ }
144
+
145
+ function segmented(label, options, value, onChange) {
146
+ const btns = new Map();
147
+ const group = el("div.segmented");
148
+ options.forEach((o) => {
149
+ const b = el("button.seg", {
150
+ onclick: () => { setValue(o.value); onChange(o.value); },
151
+ }, o.label);
152
+ btns.set(o.value, b);
153
+ group.append(b);
154
+ });
155
+ function setValue(v) {
156
+ value = v;
157
+ btns.forEach((b, k) => b.classList.toggle("active", k === v));
158
+ }
159
+ setValue(value);
160
+ return {
161
+ node: el("div.field", {}, [el("label.field-label", {}, label), group]),
162
+ setValue,
163
+ };
164
+ }
views/dom.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Tiny DOM helpers (no framework). */
2
+
3
+ /** el("button.cls#id", {onclick, ...attrs}, [children|string]) */
4
+ export function el(spec, attrs = {}, children = []) {
5
+ const m = spec.match(/^([a-z0-9]+)?(#[\w-]+)?((?:\.[\w-]+)*)$/i) || [];
6
+ const tag = m[1] || "div";
7
+ const node = document.createElement(tag);
8
+ if (m[2]) node.id = m[2].slice(1);
9
+ if (m[3]) node.className = m[3].split(".").filter(Boolean).join(" ");
10
+ for (const [k, v] of Object.entries(attrs)) {
11
+ if (v == null || v === false) continue;
12
+ if (k.startsWith("on") && typeof v === "function") {
13
+ node.addEventListener(k.slice(2).toLowerCase(), v);
14
+ } else if (k === "class") {
15
+ node.className = node.className ? `${node.className} ${v}` : v;
16
+ } else if (k === "html") {
17
+ node.innerHTML = v;
18
+ } else if (k in node && k !== "list") {
19
+ try { node[k] = v; } catch { node.setAttribute(k, v); }
20
+ } else {
21
+ node.setAttribute(k, v);
22
+ }
23
+ }
24
+ const kids = Array.isArray(children) ? children : [children];
25
+ for (const c of kids) {
26
+ if (c == null || c === false) continue;
27
+ node.append(c.nodeType ? c : document.createTextNode(String(c)));
28
+ }
29
+ return node;
30
+ }
31
+
32
+ export function clear(node) {
33
+ while (node.firstChild) node.removeChild(node.firstChild);
34
+ return node;
35
+ }
36
+
37
+ /** Render a morse pattern string ('.-') as styled dot/dash chips. */
38
+ export function morsePattern(pattern) {
39
+ const wrap = el("span.pattern");
40
+ for (const ch of pattern) {
41
+ wrap.append(el(`span.${ch === "." ? "dot" : "dash"}`));
42
+ }
43
+ return wrap;
44
+ }
views/learn.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Learn view: the letter ↔ rhythm chart. Tap any entry to hear (or have the
3
+ * robot tap) its rhythm — the whole point is that a letter *is* a rhythm.
4
+ */
5
+ import { el, clear, morsePattern } from "./dom.js";
6
+ import { CHAR_TO_MORSE, morseToTokens } from "../lib/morse.js";
7
+ import { tokensToOnsets } from "../lib/wire.js";
8
+
9
+ const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
10
+ const DIGITS = "0123456789".split("");
11
+ const PUNCT = [".", ",", "?", "/", "@", "=", "+", "-", "!", "'", "(", ")"];
12
+
13
+ export function createLearn(ctx) {
14
+ const root = el("section.view#view-learn");
15
+
16
+ function playChar(ch) {
17
+ const pattern = CHAR_TO_MORSE[ch];
18
+ if (!pattern) return;
19
+ const onsets = tokensToOnsets(morseToTokens(pattern), ctx.timing());
20
+ if (ctx.state.emitter === "robot" && ctx.reachy) {
21
+ ctx.tapper().tap(onsets);
22
+ } else {
23
+ ctx.synth().play(onsets, { clickMs: ctx.timing().clickMs });
24
+ }
25
+ }
26
+
27
+ function grid(title, chars) {
28
+ const g = el("div.chart-grid");
29
+ chars.forEach((ch) => {
30
+ const cell = el("button.chart-cell", { onclick: () => {
31
+ playChar(ch);
32
+ cell.classList.remove("ping"); void cell.offsetWidth; cell.classList.add("ping");
33
+ } }, [
34
+ el("span.cell-char", {}, ch),
35
+ morsePattern(CHAR_TO_MORSE[ch]),
36
+ ]);
37
+ g.append(cell);
38
+ });
39
+ return el("div.chart-section", {}, [el("h3", {}, title), g]);
40
+ }
41
+
42
+ const emitterNote = el("div.status muted");
43
+ function refresh() {
44
+ emitterNote.textContent = ctx.state.emitter === "robot" && ctx.reachy
45
+ ? "Tapping plays on the robot's antennas."
46
+ : "Tapping plays a beep on this device. (Switch sender in Compose.)";
47
+ }
48
+
49
+ root.append(
50
+ el("h2", {}, "Learn Morse"),
51
+ el("p.muted", {}, "A letter is just a rhythm of short (·) and long (—) beats. Tap any tile to feel it."),
52
+ emitterNote,
53
+ grid("Letters", LETTERS),
54
+ grid("Numbers", DIGITS),
55
+ grid("Punctuation", PUNCT),
56
+ el("div.legend", {}, [
57
+ el("span.legend-item", {}, [morsePattern("."), " dot — 1 tap"]),
58
+ el("span.legend-item", {}, [morsePattern("-"), " dash — quick double-tap"]),
59
+ ]),
60
+ );
61
+
62
+ refresh();
63
+ return { node: root, onShow: refresh };
64
+ }
65
+
66
+ void clear;
views/listen.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Listen view: turn on the mic, watch the live scope, and decode incoming
3
+ * clicks/beeps to Morse + text in real time. Works for a tapping robot, a
4
+ * beeping phone, or a second Reachy Mini across the table.
5
+ */
6
+ import { el, clear } from "./dom.js";
7
+ import { decodeOnsets } from "../lib/wire.js";
8
+ import { Scope } from "../lib/viz.js";
9
+
10
+ export function createListen(ctx) {
11
+ const root = el("section.view#view-listen");
12
+
13
+ const canvas = el("canvas.scope");
14
+ const scope = new Scope(canvas);
15
+
16
+ const liveText = el("div.decoded-text", {}, "—");
17
+ const liveMorse = el("div.decoded-morse muted");
18
+ const transcript = el("div.transcript");
19
+ const startBtn = el("button.btn.primary.big", { onclick: toggle }, "🎙 Start listening");
20
+ const clearBtn = el("button.btn.ghost", { onclick: reset }, "Clear");
21
+ const hint = el("div.status muted", {}, "Point the mic at the robot, then start.");
22
+
23
+ let mic = null;
24
+ let onsets = [];
25
+ let lastOnsetMs = 0;
26
+ let idleTimer = null;
27
+
28
+ function timing() { return ctx.timing(); }
29
+
30
+ async function toggle() {
31
+ if (mic) return stopMic();
32
+ try {
33
+ mic = ctx.makeMic({
34
+ onOnset: (t) => { onOnset(t); },
35
+ onLevel: (lvl) => scope.pushLevel(lvl),
36
+ });
37
+ await mic.start();
38
+ scope.start();
39
+ startBtn.textContent = "◼ Stop listening";
40
+ startBtn.classList.add("listening");
41
+ hint.textContent = "Listening… every detected hit drops a red marker.";
42
+ idleTimer = setInterval(checkIdle, 250);
43
+ } catch (e) {
44
+ ctx.toast(`Mic error: ${e?.message || e}`);
45
+ mic = null;
46
+ }
47
+ }
48
+
49
+ async function stopMic() {
50
+ if (idleTimer) { clearInterval(idleTimer); idleTimer = null; }
51
+ scope.stop();
52
+ if (mic) { await mic.stop(); mic = null; }
53
+ startBtn.textContent = "🎙 Start listening";
54
+ startBtn.classList.remove("listening");
55
+ hint.textContent = "Stopped. Decoded text is kept below.";
56
+ finalize();
57
+ }
58
+
59
+ function onOnset(timeMs) {
60
+ onsets.push(timeMs);
61
+ lastOnsetMs = timeMs;
62
+ scope.markOnset();
63
+ renderLive();
64
+ }
65
+
66
+ function renderLive() {
67
+ const { morse, text } = decodeOnsets(onsets, timing());
68
+ liveText.textContent = text || "…";
69
+ liveMorse.textContent = morse;
70
+ }
71
+
72
+ // After a long silence, commit the current decode as a finished line.
73
+ function checkIdle() {
74
+ if (!mic || onsets.length === 0) return;
75
+ const idle = mic.now() - lastOnsetMs;
76
+ if (idle > timing().wordGapMs * 2.5) finalize();
77
+ }
78
+
79
+ function finalize() {
80
+ if (onsets.length === 0) return;
81
+ const { morse, text } = decodeOnsets(onsets, timing());
82
+ if (text) {
83
+ transcript.prepend(el("div.line", {}, [
84
+ el("span.line-text", {}, text),
85
+ el("span.line-morse muted", {}, morse),
86
+ ]));
87
+ }
88
+ onsets = [];
89
+ renderLive();
90
+ }
91
+
92
+ function reset() {
93
+ onsets = [];
94
+ clear(transcript);
95
+ renderLive();
96
+ }
97
+
98
+ renderLive();
99
+ root.append(
100
+ el("h2", {}, "Listen"),
101
+ canvas,
102
+ el("div.row.between", {}, [startBtn, clearBtn]),
103
+ hint,
104
+ el("label.field-label", {}, "Decoding now"),
105
+ liveText,
106
+ liveMorse,
107
+ el("label.field-label", {}, "Transcript"),
108
+ transcript,
109
+ );
110
+
111
+ return {
112
+ node: root,
113
+ onShow: () => {},
114
+ onHide: () => { if (mic) stopMic(); },
115
+ };
116
+ }
views/settings.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Settings sheet: speed, beep tone, mic sensitivity, and the robot clap
3
+ * calibration knobs (contact angle / hold / latency lead). These are exactly
4
+ * the values we tune against real hardware, surfaced so calibration needs no
5
+ * code edit.
6
+ */
7
+ import { el } from "./dom.js";
8
+ import { SPEED_PRESETS, makeTiming } from "../lib/timing.js";
9
+ import { APP_VERSION } from "../lib/version.js";
10
+
11
+ export function createSettings(ctx) {
12
+ const c = ctx.state;
13
+
14
+ function rangeRow(label, get, set, { min, max, step, fmt = (v) => v }) {
15
+ const out = el("span.range-val", {}, fmt(get()));
16
+ const slider = el("input.range", {
17
+ type: "range", min, max, step, value: get(),
18
+ oninput: (e) => { set(parseFloat(e.target.value)); out.textContent = fmt(get()); },
19
+ });
20
+ return el("div.field", {}, [
21
+ el("div.row.between", {}, [el("label.field-label", {}, label), out]),
22
+ slider,
23
+ ]);
24
+ }
25
+
26
+ const body = el("div.settings-body", {}, [
27
+ el("h3", {}, "Transmission"),
28
+ rangeRow("Speed (unit)", () => c.unitMs, (v) => { c.unitMs = v; c.speed = "custom"; },
29
+ { min: 60, max: 240, step: 10, fmt: (v) => `${v} ms` }),
30
+ rangeRow("Beep tone", () => c.beepFreq, (v) => { c.beepFreq = v; },
31
+ { min: 600, max: 4000, step: 50, fmt: (v) => `${v} Hz` }),
32
+
33
+ el("h3", {}, "Microphone / detection"),
34
+ rangeRow("High-pass", () => c.detector.highpassHz, (v) => { c.detector.highpassHz = v; },
35
+ { min: 500, max: 4000, step: 100, fmt: (v) => `${v} Hz` }),
36
+ rangeRow("Sensitivity", () => c.detector.thresholdFactor,
37
+ (v) => { c.detector.thresholdFactor = v; },
38
+ { min: 1.5, max: 10, step: 0.5, fmt: (v) => `${v}×` }),
39
+
40
+ el("h3", {}, "Robot clap (calibration)"),
41
+ rangeRow("Contact angle", () => c.tap.collisionDeg, (v) => { c.tap.collisionDeg = v; },
42
+ { min: 10, max: 55, step: 1, fmt: (v) => `${v}°` }),
43
+ rangeRow("Right antenna", () => c.tap.rightRestDeg, (v) => { c.tap.rightRestDeg = v; },
44
+ { min: -55, max: 0, step: 1, fmt: (v) => `${v}°` }),
45
+ rangeRow("Hold", () => c.tap.holdMs, (v) => { c.tap.holdMs = v; },
46
+ { min: 20, max: 150, step: 10, fmt: (v) => `${v} ms` }),
47
+ rangeRow("Latency lead", () => c.tap.leadMs, (v) => { c.tap.leadMs = v; },
48
+ { min: -50, max: 250, step: 10, fmt: (v) => `${v} ms` }),
49
+ el("div.row", {}, [
50
+ el("button.btn.ghost", { onclick: () => testBeep() }, "Test beep"),
51
+ el("button.btn.ghost", { onclick: () => testClap(), disabled: !ctx.reachy },
52
+ ctx.reachy ? "Test clap" : "Test clap (no robot)"),
53
+ ]),
54
+ el("div.gap-preview muted"),
55
+ el("div.version muted", {}, APP_VERSION),
56
+ ]);
57
+
58
+ function testBeep() {
59
+ ctx.synth().play([0, makeTiming(c.unitMs).dahGapMs], { clickMs: makeTiming(c.unitMs).clickMs });
60
+ }
61
+ function testClap() {
62
+ if (ctx.reachy) ctx.tapper().testClap();
63
+ }
64
+
65
+ return { node: body };
66
+ }
vitest.config.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vitest/config";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ // Stub the SDK animation wrapper in unit tests: Node's ESM loader can't
5
+ // resolve `https:` imports, and the unit suite never touches the live SDK.
6
+ // Production loads the real wrapper from jsDelivr.
7
+ const animationStub = fileURLToPath(
8
+ new URL("./tests/stubs/animation-helpers.js", import.meta.url),
9
+ );
10
+
11
+ export default defineConfig({
12
+ test: {
13
+ include: ["tests/unit/**/*.test.js"],
14
+ environment: "node",
15
+ globals: false,
16
+ alias: {
17
+ "./animation-helpers.js": animationStub,
18
+ },
19
+ },
20
+ });