mudra-skills 1.2.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,891 +1,997 @@
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">
6
- <title>Mudra Interactive Template</title>
7
- <style>
8
- :root {
9
- --bg: #f8fafc;
10
- --card: #ffffff;
11
- --primary: #0d9488;
12
- --accent: #14b8a6;
13
- --text: #0f172a;
14
- --text-secondary: #475569;
15
- --success: #16a34a;
16
- --warning: #ca8a04;
17
- --error: #dc2626;
18
- }
19
-
20
- * { box-sizing: border-box; }
21
-
22
- body {
23
- margin: 0;
24
- min-height: 100vh;
25
- background: var(--bg);
26
- color: var(--text);
27
- font-family: Poppins, system-ui, sans-serif;
28
- display: grid;
29
- place-items: center;
30
- padding: 20px;
31
- }
32
-
33
- .app {
34
- width: min(1100px, 100%);
35
- display: grid;
36
- gap: 14px;
37
- }
38
-
39
- .card {
40
- background: var(--card);
41
- border: 1px solid rgba(13, 148, 136, 0.18);
42
- border-radius: 16px;
43
- padding: 16px;
44
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
45
- }
46
-
47
- h1, h2, p { margin: 0; }
48
-
49
- .title { font-size: 1.24rem; }
50
- .muted { color: var(--text-secondary); margin-top: 6px; }
51
-
52
- .header-row {
53
- display: flex;
54
- justify-content: space-between;
55
- align-items: center;
56
- gap: 12px;
57
- flex-wrap: wrap;
58
- margin-top: 12px;
59
- }
60
-
61
- .status {
62
- display: inline-flex;
63
- align-items: center;
64
- gap: 8px;
65
- font-size: 0.92rem;
66
- padding: 8px 12px;
67
- border-radius: 999px;
68
- border: 1px solid rgba(255, 255, 255, 0.15);
69
- background: rgba(255, 255, 255, 0.02);
70
- }
71
-
72
- .dot {
73
- width: 9px;
74
- height: 9px;
75
- border-radius: 50%;
76
- background: var(--warning);
77
- box-shadow: 0 0 0 4px rgba(234, 179, 8, 0.15);
78
- transition: background 150ms ease;
79
- }
80
-
81
- .dot.connected {
82
- background: var(--success);
83
- box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18);
84
- }
85
-
86
- .actions {
87
- display: flex;
88
- gap: 10px;
89
- flex-wrap: wrap;
90
- }
91
-
92
- button {
93
- border: none;
94
- border-radius: 10px;
95
- padding: 10px 14px;
96
- font-weight: 700;
97
- cursor: pointer;
98
- background: var(--primary);
99
- color: #072f33;
100
- }
101
-
102
- button.secondary {
103
- background: transparent;
104
- color: var(--text);
105
- border: 1px solid rgba(148, 163, 184, 0.3);
106
- font-weight: 600;
107
- }
108
-
109
- .two-col {
110
- display: grid;
111
- gap: 14px;
112
- grid-template-columns: 1.3fr 1fr;
113
- }
114
-
115
- .stage {
116
- position: relative;
117
- min-height: 280px;
118
- border-radius: 14px;
119
- border: 1px solid rgba(148, 163, 184, 0.25);
120
- background:
121
- radial-gradient(circle at 50% 45%, rgba(45, 212, 191, 0.12), transparent 45%),
122
- linear-gradient(180deg, #0a0f12, #070b0e);
123
- overflow: hidden;
124
- cursor: grab;
125
- }
126
-
127
- .stage:active { cursor: grabbing; }
128
-
129
- .stage-grid {
130
- position: absolute;
131
- inset: 0;
132
- background-image:
133
- linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
134
- linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
135
- background-size: 24px 24px;
136
- pointer-events: none;
137
- }
138
-
139
- .orb {
140
- position: absolute;
141
- left: 50%;
142
- top: 50%;
143
- width: 88px;
144
- height: 88px;
145
- transform: translate(-50%, -50%);
146
- border-radius: 999px;
147
- background:
148
- radial-gradient(circle at 30% 28%, #c5fffd 0%, var(--primary) 26%, #0b3b40 100%);
149
- box-shadow: 0 0 24px rgba(119, 234, 233, 0.34);
150
- transition: transform 80ms linear, filter 80ms linear;
151
- }
152
-
153
- .stage-caption {
154
- position: absolute;
155
- left: 10px;
156
- bottom: 10px;
157
- font-size: 0.82rem;
158
- color: var(--text-secondary);
159
- background: rgba(0, 0, 0, 0.34);
160
- border: 1px solid rgba(148, 163, 184, 0.2);
161
- border-radius: 8px;
162
- padding: 6px 8px;
163
- }
164
-
165
- .signal-grid {
166
- display: grid;
167
- gap: 10px;
168
- grid-template-columns: repeat(2, minmax(0, 1fr));
169
- }
170
-
171
- .metric {
172
- border: 1px solid rgba(148, 163, 184, 0.2);
173
- border-radius: 12px;
174
- padding: 10px;
175
- background: rgba(8, 12, 15, 0.62);
176
- }
177
-
178
- .metric-label {
179
- font-size: 0.82rem;
180
- color: var(--text-secondary);
181
- }
182
-
183
- .metric-value {
184
- font-size: 1.24rem;
185
- margin-top: 6px;
186
- color: var(--primary);
187
- font-weight: 700;
188
- letter-spacing: 0.02em;
189
- transform-origin: left center;
190
- }
191
-
192
- .pulse {
193
- animation: pulse 150ms ease;
194
- }
195
-
196
- @keyframes pulse {
197
- 0% { transform: scale(1); }
198
- 50% { transform: scale(1.05); }
199
- 100% { transform: scale(1); }
200
- }
201
-
202
- .bar {
203
- margin-top: 8px;
204
- height: 8px;
205
- border-radius: 999px;
206
- background: rgba(148, 163, 184, 0.22);
207
- overflow: hidden;
208
- }
209
-
210
- .bar > span {
211
- display: block;
212
- height: 100%;
213
- width: 0%;
214
- background: linear-gradient(90deg, var(--accent), var(--primary));
215
- transition: width 75ms linear;
216
- }
217
-
218
- .help {
219
- font-size: 0.86rem;
220
- line-height: 1.45;
221
- color: var(--text-secondary);
222
- }
223
-
224
- pre {
225
- margin: 0;
226
- min-height: 160px;
227
- max-height: 260px;
228
- overflow: auto;
229
- padding: 12px;
230
- border-radius: 12px;
231
- border: 1px solid rgba(148, 163, 184, 0.22);
232
- background: #0b1013;
233
- font-size: 0.8rem;
234
- line-height: 1.35;
235
- color: #d8edf0;
236
- }
237
-
238
- .mudra-badge {
239
- position: fixed;
240
- bottom: 12px;
241
- right: 12px;
242
- font-size: 11px;
243
- color: var(--text-secondary, #94a3b8);
244
- opacity: 0.7;
245
- font-family: inherit;
246
- letter-spacing: 0.02em;
247
- pointer-events: none;
248
- }
249
-
250
- @media (max-width: 920px) {
251
- .two-col { grid-template-columns: 1fr; }
252
- .signal-grid { grid-template-columns: 1fr 1fr; }
253
- .stage { min-height: 240px; }
254
- }
255
-
256
- /* === Onboarding modal — feature 005-onboarding-modal ============ */
257
- .mudra-onb {
258
- border: none;
259
- padding: 0;
260
- border-radius: 14px;
261
- width: min(560px, 92vw);
262
- max-height: min(82vh, 720px);
263
- background: #fff;
264
- color: #111;
265
- font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
266
- box-shadow: 0 30px 80px rgba(0, 0, 0, .35);
267
- overflow: hidden;
268
- }
269
- .mudra-onb::backdrop { background: rgba(0, 0, 0, .55); }
270
- .mudra-onb__head {
271
- display: flex; align-items: center; justify-content: space-between;
272
- padding: 18px 22px 8px;
273
- }
274
- .mudra-onb__brand { display: inline-flex; align-items: center; gap: 8px; }
275
- .mudra-onb__logo { display: block; width: 28px; height: 28px; flex: none; }
276
- .mudra-onb__wordmark {
277
- font-size: 12px; font-weight: 700; letter-spacing: .22em;
278
- color: #0d9488; text-transform: uppercase;
279
- }
280
- .mudra-onb__title {
281
- margin: 0; padding: 0 22px 14px;
282
- font-size: 1.1rem; font-weight: 700;
283
- }
284
- .mudra-onb__close {
285
- display: inline-flex; align-items: center; justify-content: center;
286
- border: none; background: #1a1a1a; color: #fff;
287
- width: 32px; height: 32px; border-radius: 50%;
288
- font-size: 22px; line-height: 1; padding: 0; cursor: pointer;
289
- }
290
- .mudra-onb__body { padding: 4px 22px 8px; max-height: 56vh; overflow-y: auto; }
291
- .mudra-onb__lede { margin: 0 0 12px; color: #555; }
292
- .mudra-onb__actions { width: 100%; border-collapse: collapse; }
293
- .mudra-onb__actions th, .mudra-onb__actions td {
294
- text-align: left; padding: 8px 6px; border-bottom: 1px solid #eee; vertical-align: top;
295
- }
296
- .mudra-onb__actions th { font-size: 12px; text-transform: uppercase; color: #777; letter-spacing: .04em; }
297
- .mudra-onb__actions td:nth-child(2) { color: #1a73e8; font-weight: 600; }
298
- .mudra-onb__actions td:nth-child(3) { color: #444; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
299
- .mudra-onb__foot { padding: 14px 22px 20px; text-align: right; }
300
- .mudra-onb__continue {
301
- border: none; background: #1a1a1a; color: #fff;
302
- padding: 9px 18px; border-radius: 999px; font-weight: 600; cursor: pointer;
303
- }
304
- .mudra-onb__reopen {
305
- position: fixed; right: 16px; bottom: 16px; z-index: 9999;
306
- width: 36px; height: 36px; border-radius: 50%;
307
- border: none; background: #1a1a1a; color: #fff;
308
- font-size: 18px; font-weight: 700; cursor: pointer;
309
- box-shadow: 0 6px 20px rgba(0, 0, 0, .25);
310
- }
311
- .mudra-onb--hidden, .mudra-onb__reopen--hidden { display: none !important; }
312
- </style>
313
- </head>
314
- <body>
315
- <main class="app">
316
- <section class="card">
317
- <h1 class="title">Mudra Interactive Template</h1>
318
- <p class="muted">Baseline: connection-safe, multi-signal telemetry, and device-free interaction fallback.</p>
319
- <div class="header-row">
320
- <div class="status" id="status">
321
- <span class="dot" id="statusDot"></span>
322
- <span id="statusText">Connecting to Mudra Companion...</span>
323
- </div>
324
- <div class="actions">
325
- <button id="simulateTap">Simulate Tap</button>
326
- <button id="resubscribe" class="secondary">Resubscribe</button>
327
- <button id="toggleMode" class="secondary">Mode: pointer</button>
328
- <button id="getStatus" class="secondary">Get Status</button>
329
- <button id="getSubs" class="secondary">Get Subs</button>
330
- <button id="getDocs" class="secondary">Get Docs</button>
331
- <button id="togglePressureFeature" class="secondary">Pressure: enabled</button>
332
- </div>
333
- </div>
334
- </section>
335
-
336
- <section class="two-col">
337
- <article class="card">
338
- <h2 class="title">Interaction Stage</h2>
339
- <p class="muted">Drag or use keyboard fallback when no device is connected.</p>
340
- <div class="stage" id="stage">
341
- <div class="stage-grid"></div>
342
- <div class="orb" id="orb"></div>
343
- <div class="stage-caption" id="stageCaption">pointer mode</div>
344
- </div>
345
- </article>
346
-
347
- <article class="card">
348
- <h2 class="title">Signal HUD</h2>
349
- <div class="signal-grid">
350
- <div class="metric">
351
- <div class="metric-label">Gesture</div>
352
- <div class="metric-value" id="gestureValue">none</div>
353
- </div>
354
- <div class="metric">
355
- <div class="metric-label">Button</div>
356
- <div class="metric-value" id="buttonValue">released</div>
357
- </div>
358
- <div class="metric">
359
- <div class="metric-label">Pressure</div>
360
- <div class="metric-value" id="pressureValue">0%</div>
361
- <div class="bar"><span id="pressureBar"></span></div>
362
- </div>
363
- <div class="metric">
364
- <div class="metric-label">Directional / IMU</div>
365
- <div class="metric-value" id="motionValue">x:0 y:0</div>
366
- </div>
367
- <div class="metric">
368
- <div class="metric-label">SNC Energy</div>
369
- <div class="metric-value" id="sncValue">0.00</div>
370
- <div class="bar"><span id="sncBar"></span></div>
371
- </div>
372
- <div class="metric">
373
- <div class="metric-label">Nav Direction</div>
374
- <div class="metric-value" id="navDirectionValue">None</div>
375
- </div>
376
- <div class="metric">
377
- <div class="metric-label">Battery</div>
378
- <div class="metric-value" id="batteryValue">--</div>
379
- </div>
380
- </div>
381
- </article>
382
- </section>
383
-
384
- <section class="card">
385
- <h2 class="title">Fallback Controls</h2>
386
- <p class="help">
387
- Space: tap gesture | Shift hold: button press/release | [ and ]: pressure down/up | Arrow keys: pointer/direction/tilt based on mode
388
- </p>
389
- </section>
390
-
391
- <section class="card">
392
- <h2 class="title">Event Log</h2>
393
- <pre id="log"></pre>
394
- </section>
395
- </main>
396
-
397
- <div class="mudra-badge">Created by Mudra</div>
398
-
399
- <script>
400
- const WS_URL = "ws://127.0.0.1:8766";
401
- const MAX_LOG_LINES = 80;
402
- const PRESSURE_WINDOW = 5;
403
- const SNC_BUFFER_SIZE = 500;
404
-
405
- let controlMode = "pointer"; // "pointer" = navigation+button, "direction" = nav_direction, "imu" = imu_acc+imu_gyro
406
- let ws;
407
- let reconnectTimer;
408
- let pressureFeatureEnabled = true;
409
- const pressureSamples = [];
410
- const logLines = [];
411
- const sncBuffers = [[], [], []];
412
-
413
- const state = {
414
- gesture: "none",
415
- button: "released",
416
- pressure: 0,
417
- navX: 0,
418
- navY: 0,
419
- imuAcc: [0, 0, 9.81],
420
- imuGyro: [0, 0, 0],
421
- snc: [0, 0, 0],
422
- battery: null,
423
- charging: false,
424
- navDirection: "None"
425
- };
426
-
427
- const ui = {
428
- statusDot: document.getElementById("statusDot"),
429
- statusText: document.getElementById("statusText"),
430
- toggleMode: document.getElementById("toggleMode"),
431
- stage: document.getElementById("stage"),
432
- orb: document.getElementById("orb"),
433
- stageCaption: document.getElementById("stageCaption"),
434
- gestureValue: document.getElementById("gestureValue"),
435
- buttonValue: document.getElementById("buttonValue"),
436
- pressureValue: document.getElementById("pressureValue"),
437
- pressureBar: document.getElementById("pressureBar"),
438
- motionValue: document.getElementById("motionValue"),
439
- sncValue: document.getElementById("sncValue"),
440
- sncBar: document.getElementById("sncBar"),
441
- navDirectionValue: document.getElementById("navDirectionValue"),
442
- batteryValue: document.getElementById("batteryValue"),
443
- log: document.getElementById("log")
444
- };
445
-
446
- connect();
447
- updateModeUi();
448
- render();
449
-
450
- document.getElementById("simulateTap").addEventListener("click", () => {
451
- send({ command: "trigger_gesture", data: { type: "tap" } });
452
- handleGesture({ type: "tap" });
453
- });
454
-
455
- document.getElementById("resubscribe").addEventListener("click", subscribeSignals);
456
- document.getElementById("getStatus").addEventListener("click", () => send({ command: "get_status" }));
457
- document.getElementById("getSubs").addEventListener("click", () => send({ command: "get_subscriptions" }));
458
- document.getElementById("getDocs").addEventListener("click", () => send({ command: "get_docs" }));
459
- document.getElementById("togglePressureFeature").addEventListener("click", () => {
460
- pressureFeatureEnabled = !pressureFeatureEnabled;
461
- send({
462
- command: pressureFeatureEnabled ? "enable" : "disable",
463
- feature: "pressure"
464
- });
465
- document.getElementById("togglePressureFeature").textContent =
466
- `Pressure: ${pressureFeatureEnabled ? "enabled" : "disabled"}`;
467
- addLog({
468
- type: "feature_toggle",
469
- data: { feature: "pressure", enabled: pressureFeatureEnabled }
470
- });
471
- });
472
-
473
- ui.toggleMode.addEventListener("click", () => {
474
- const modes = ["pointer", "direction", "imu"];
475
- controlMode = modes[(modes.indexOf(controlMode) + 1) % modes.length];
476
- updateModeUi();
477
- resetMotionState();
478
- if (ws && ws.readyState === WebSocket.OPEN) {
479
- send({ command: "unsubscribe", signal: "navigation" });
480
- send({ command: "unsubscribe", signal: "button" });
481
- send({ command: "unsubscribe", signal: "nav_direction" });
482
- send({ command: "unsubscribe", signal: "imu_acc" });
483
- send({ command: "unsubscribe", signal: "imu_gyro" });
484
- subscribeSignals();
485
- }
486
- addLog({ type: "mode_change", data: { mode: controlMode } });
487
- });
488
-
489
- document.addEventListener("keydown", handleKeyDown);
490
- document.addEventListener("keyup", handleKeyUp);
491
- installStageFallback();
492
-
493
- function connect() {
494
- ws = new WebSocket(WS_URL);
495
-
496
- ws.onopen = () => {
497
- setStatus(true, "Connected to Mudra Companion");
498
- subscribeSignals();
499
- };
500
-
501
- ws.onclose = () => {
502
- setStatus(false, "Disconnected. Waiting for Mudra Companion...");
503
- scheduleReconnect();
504
- };
505
-
506
- ws.onerror = () => {
507
- setStatus(false, "Connection error. Check Mudra Companion.");
508
- };
509
-
510
- ws.onmessage = (event) => {
511
- const msg = parseMessage(event.data);
512
- if (!msg) return;
513
-
514
- addLog(msg);
515
-
516
- if (msg.type === "connection_status") {
517
- const connected = msg.data?.status === "connected";
518
- setStatus(connected, connected ? "Mudra Companion ready" : "Connect your Mudra Band to continue");
519
- return;
520
- }
521
-
522
- if (msg.type === "gesture") handleGesture(msg.data);
523
- if (msg.type === "pressure") handlePressure(msg.data);
524
- if (msg.type === "button") handleButton(msg.data);
525
- if (msg.type === "navigation") handleNavigation(msg.data);
526
- if (msg.type === "nav_direction") handleNavDirection(msg.data);
527
- if (msg.type === "imu_acc") handleImuAcc(msg.data);
528
- if (msg.type === "imu_gyro") handleImuGyro(msg.data);
529
- if (msg.type === "snc") handleSnc(msg.data);
530
- if (msg.type === "battery") handleBattery(msg.data);
531
- };
532
- }
533
-
534
- function getActiveSignals() {
535
- const base = ["gesture", "pressure", "snc", "battery"];
536
- if (controlMode === "pointer") return [...base, "navigation", "button"];
537
- if (controlMode === "direction") return [...base, "nav_direction"];
538
- return [...base, "imu_acc", "imu_gyro"];
539
- }
540
-
541
- function subscribeSignals() {
542
- getActiveSignals().forEach((signal) => {
543
- // Mudra protocol: one command per signal with singular "signal" key.
544
- send({ command: "subscribe", signal });
545
- });
546
- }
547
-
548
- function send(payload) {
549
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
550
- ws.send(JSON.stringify(payload));
551
- }
552
-
553
- function parseMessage(raw) {
554
- try {
555
- return JSON.parse(raw);
556
- } catch (error) {
557
- addLog({ type: "parse_error", raw });
558
- return null;
559
- }
560
- }
561
-
562
- function handleGesture(data = {}) {
563
- state.gesture = data.type || "unknown";
564
- ui.gestureValue.textContent = state.gesture;
565
- pulse(ui.gestureValue);
566
- render();
567
- }
568
-
569
- function handlePressure(data = {}) {
570
- const normalized = clamp01(Number(data.normalized ?? 0));
571
- pressureSamples.push(normalized);
572
- if (pressureSamples.length > PRESSURE_WINDOW) pressureSamples.shift();
573
- state.pressure = pressureSamples.reduce((sum, sample) => sum + sample, 0) / pressureSamples.length;
574
- ui.pressureValue.textContent = `${Math.round(state.pressure * 100)}%`;
575
- ui.pressureBar.style.width = `${Math.round(state.pressure * 100)}%`;
576
- render();
577
- }
578
-
579
- function handleButton(data = {}) {
580
- state.button = data.state || "released";
581
- ui.buttonValue.textContent = state.button;
582
- pulse(ui.buttonValue);
583
- render();
584
- }
585
-
586
- function handleNavigation(data = {}) {
587
- if (controlMode !== "pointer") return;
588
- const dx = Number(data.delta_x || 0);
589
- const dy = Number(data.delta_y || 0);
590
- state.navX = clamp(state.navX + dx * 0.9, -110, 110);
591
- state.navY = clamp(state.navY + dy * 0.9, -85, 85);
592
- ui.motionValue.textContent = `x:${state.navX.toFixed(1)} y:${state.navY.toFixed(1)}`;
593
- render();
594
- }
595
-
596
- function handleNavDirection(data = {}) {
597
- state.navDirection = data.direction || "None";
598
- ui.navDirectionValue.textContent = state.navDirection;
599
- if (state.navDirection !== "None") pulse(ui.navDirectionValue);
600
- }
601
-
602
- function handleImuAcc(data = {}) {
603
- if (controlMode !== "imu") return;
604
- const values = Array.isArray(data.values) ? data.values : [0, 0, 9.81];
605
- state.imuAcc = [Number(values[0] || 0), Number(values[1] || 0), Number(values[2] || 9.81)];
606
- projectImuToStage();
607
- render();
608
- }
609
-
610
- function handleImuGyro(data = {}) {
611
- if (controlMode !== "imu") return;
612
- const values = Array.isArray(data.values) ? data.values : [0, 0, 0];
613
- state.imuGyro = [Number(values[0] || 0), Number(values[1] || 0), Number(values[2] || 0)];
614
- projectImuToStage();
615
- render();
616
- }
617
-
618
- function handleSnc(data = {}) {
619
- // values is [[ch1_samples], [ch2_samples], [ch3_samples]] (de-interleaved channels)
620
- const channels = Array.isArray(data.values) && data.values.length === 3
621
- ? data.values : [[0], [0], [0]];
622
- // Extend rolling buffers with ALL samples (not just the latest)
623
- for (let i = 0; i < 3; i++) {
624
- sncBuffers[i].push(...channels[i]);
625
- if (sncBuffers[i].length > SNC_BUFFER_SIZE) {
626
- sncBuffers[i].splice(0, sncBuffers[i].length - SNC_BUFFER_SIZE);
627
- }
628
- }
629
- // Latest sample from each channel for HUD display
630
- state.snc = sncBuffers.map(buf => Number(buf[buf.length - 1] || 0));
631
- const energy = Math.min(1, (Math.abs(state.snc[0]) + Math.abs(state.snc[1]) + Math.abs(state.snc[2])) / 3);
632
- ui.sncValue.textContent = energy.toFixed(2);
633
- ui.sncBar.style.width = `${Math.round(energy * 100)}%`;
634
- }
635
-
636
- function handleBattery(data = {}) {
637
- if (typeof data.level !== "number") return;
638
- state.battery = data.level;
639
- state.charging = Boolean(data.charging);
640
- ui.batteryValue.textContent = `${state.battery}% ${state.charging ? "(charging)" : ""}`;
641
- }
642
-
643
- function projectImuToStage() {
644
- const x = clamp(state.imuAcc[0] * 12 + state.imuGyro[2] * 0.15, -110, 110);
645
- const y = clamp(-state.imuAcc[1] * 12 + state.imuGyro[0] * 0.15, -85, 85);
646
- state.navX = x;
647
- state.navY = y;
648
- ui.motionValue.textContent = `ax:${state.imuAcc[0].toFixed(2)} ay:${state.imuAcc[1].toFixed(2)}`;
649
- }
650
-
651
- function render() {
652
- const translate = `translate(calc(-50% + ${state.navX}px), calc(-50% + ${state.navY}px))`;
653
- const scale = 1 + state.pressure * 0.33;
654
- const glow = 0.8 + state.pressure * 0.7;
655
- const saturate = state.button === "pressed" ? 1.6 : 1;
656
- ui.orb.style.transform = `${translate} scale(${scale.toFixed(3)})`;
657
- ui.orb.style.filter = `brightness(${glow.toFixed(2)}) saturate(${saturate})`;
658
- }
659
-
660
- function updateModeUi() {
661
- ui.toggleMode.textContent = `Mode: ${controlMode}`;
662
- ui.stageCaption.textContent = `${controlMode} mode`;
663
- }
664
-
665
- function resetMotionState() {
666
- state.navX = 0;
667
- state.navY = 0;
668
- state.imuAcc = [0, 0, 9.81];
669
- state.imuGyro = [0, 0, 0];
670
- ui.motionValue.textContent = "x:0 y:0";
671
- render();
672
- }
673
-
674
- function installStageFallback() {
675
- let dragging = false;
676
-
677
- ui.stage.addEventListener("pointerdown", (event) => {
678
- dragging = true;
679
- ui.stage.setPointerCapture(event.pointerId);
680
- });
681
-
682
- ui.stage.addEventListener("pointerup", () => {
683
- dragging = false;
684
- });
685
-
686
- ui.stage.addEventListener("pointermove", (event) => {
687
- if (!dragging) return;
688
-
689
- const rect = ui.stage.getBoundingClientRect();
690
- const xNorm = ((event.clientX - rect.left) / rect.width) * 2 - 1;
691
- const yNorm = ((event.clientY - rect.top) / rect.height) * 2 - 1;
692
-
693
- if (controlMode === "pointer") {
694
- state.navX = clamp(xNorm * 110, -110, 110);
695
- state.navY = clamp(yNorm * 85, -85, 85);
696
- ui.motionValue.textContent = `x:${state.navX.toFixed(1)} y:${state.navY.toFixed(1)}`;
697
- } else if (controlMode === "imu") {
698
- handleImuAcc({ values: [xNorm * 1.4, -yNorm * 1.4, 9.81] });
699
- handleImuGyro({ values: [yNorm * 25, xNorm * 25, xNorm * 12] });
700
- }
701
-
702
- render();
703
- });
704
- }
705
-
706
- function handleKeyDown(event) {
707
- if (event.repeat) return;
708
-
709
- if (event.code === "Space") {
710
- event.preventDefault();
711
- send({ command: "trigger_gesture", data: { type: "tap" } });
712
- handleGesture({ type: "tap" });
713
- }
714
-
715
- if (event.key === "Shift") {
716
- handleButton({ state: "pressed" });
717
- }
718
-
719
- if (event.key === "[") {
720
- handlePressure({ normalized: clamp01(state.pressure - 0.08) });
721
- }
722
-
723
- if (event.key === "]") {
724
- handlePressure({ normalized: clamp01(state.pressure + 0.08) });
725
- }
726
-
727
- if (event.key.startsWith("Arrow")) {
728
- event.preventDefault();
729
- const step = 9;
730
- if (controlMode === "pointer") {
731
- if (event.key === "ArrowLeft") handleNavigation({ delta_x: -step, delta_y: 0 });
732
- if (event.key === "ArrowRight") handleNavigation({ delta_x: step, delta_y: 0 });
733
- if (event.key === "ArrowUp") handleNavigation({ delta_x: 0, delta_y: -step });
734
- if (event.key === "ArrowDown") handleNavigation({ delta_x: 0, delta_y: step });
735
- } else if (controlMode === "direction") {
736
- const dirMap = { ArrowLeft: "Left", ArrowRight: "Right", ArrowUp: "Up", ArrowDown: "Down" };
737
- handleNavDirection({ direction: dirMap[event.key] || "None" });
738
- } else {
739
- const ax = state.imuAcc[0] + (event.key === "ArrowRight" ? 0.12 : event.key === "ArrowLeft" ? -0.12 : 0);
740
- const ay = state.imuAcc[1] + (event.key === "ArrowDown" ? 0.12 : event.key === "ArrowUp" ? -0.12 : 0);
741
- handleImuAcc({ values: [ax, ay, 9.81] });
742
- handleImuGyro({ values: [ay * 18, ax * 18, ax * 9] });
743
- }
744
- }
745
- }
746
-
747
- function handleKeyUp(event) {
748
- if (event.key === "Shift") {
749
- handleButton({ state: "released" });
750
- }
751
- }
752
-
753
- function setStatus(connected, text) {
754
- ui.statusDot.classList.toggle("connected", connected);
755
- ui.statusText.textContent = text;
756
- }
757
-
758
- function scheduleReconnect() {
759
- clearTimeout(reconnectTimer);
760
- reconnectTimer = setTimeout(connect, 1200);
761
- }
762
-
763
- function addLog(value) {
764
- const line = JSON.stringify(value);
765
- logLines.unshift(line);
766
- if (logLines.length > MAX_LOG_LINES) logLines.pop();
767
- ui.log.textContent = logLines.join("\n");
768
- }
769
-
770
- function pulse(element) {
771
- element.classList.remove("pulse");
772
- void element.offsetWidth;
773
- element.classList.add("pulse");
774
- }
775
-
776
- function clamp(value, min, max) {
777
- return Math.max(min, Math.min(max, value));
778
- }
779
-
780
- function clamp01(value) {
781
- if (!Number.isFinite(value)) return 0;
782
- return clamp(value, 0, 1);
783
- }
784
- </script>
785
-
786
- <!-- ============================================================== -->
787
- <!-- Onboarding modal — feature 005-onboarding-modal -->
788
- <!-- DO NOT modify this block. Per-app variation lives only in -->
789
- <!-- the ACTIONS constant below and (optionally) the data-app-name -->
790
- <!-- attribute on #mudra-onboarding. -->
791
- <!-- ============================================================== -->
792
- <dialog id="mudra-onboarding" class="mudra-onb" data-app-name="">
793
- <header class="mudra-onb__head">
794
- <div class="mudra-onb__brand">
795
- <svg class="mudra-onb__logo" viewBox="0 0 32 32" aria-hidden="true">
796
- <ellipse cx="16" cy="16" rx="13" ry="9" fill="none" stroke="#0d9488" stroke-width="2"/>
797
- <circle cx="11" cy="16" r="1.6" fill="#14b8a6"/>
798
- <circle cx="16" cy="16" r="1.6" fill="#14b8a6"/>
799
- <circle cx="21" cy="16" r="1.6" fill="#14b8a6"/>
800
- </svg>
801
- <span class="mudra-onb__wordmark">MUDRA</span>
802
- </div>
803
- <button type="button" class="mudra-onb__close" aria-label="Close">×</button>
804
- </header>
805
- <h2 class="mudra-onb__title">Welcome to <span class="mudra-onb__name"></span></h2>
806
- <section class="mudra-onb__body">
807
- <p class="mudra-onb__lede">Use the Mudra Band — or your keyboard — to control this app.</p>
808
- <table class="mudra-onb__actions">
809
- <thead><tr><th>Action</th><th>Mudra</th><th>Manual</th></tr></thead>
810
- <tbody></tbody>
811
- </table>
812
- </section>
813
- <footer class="mudra-onb__foot">
814
- <button type="button" class="mudra-onb__continue">Got it</button>
815
- </footer>
816
- </dialog>
817
- <button type="button" id="mudra-onboarding-help" class="mudra-onb__reopen" aria-label="Show controls">?</button>
818
-
819
- <script>
820
- /* === Onboarding modal — feature 005-onboarding-modal ============ */
821
- (() => {
822
- // ACTIONS — the ONLY per-app variation in this block.
823
- // The skill replaces this constant at generation time. See
824
- // contracts/actions-array.md for shape and rules.
825
- const ACTIONS = [
826
- { label: "Tap to interact", mudra: "gesture: pinch", manual: "Space" },
827
- { label: "Hold to select", mudra: "button: hold", manual: "Shift" },
828
- { label: "Adjust pressure", mudra: "pressure (thumb-index)", manual: "[ / ]" },
829
- { label: "Move pointer", mudra: "navigation: cursor", manual: "Arrow keys" },
830
- ];
831
-
832
- const dialog = document.getElementById("mudra-onboarding");
833
- const reopen = document.getElementById("mudra-onboarding-help");
834
- if (!dialog || !reopen) return;
835
-
836
- // 1. Resolve title (data-app-name override → filename derivation → fallback).
837
- const nameEl = dialog.querySelector(".mudra-onb__name");
838
- const override = dialog.dataset.appName && dialog.dataset.appName.trim();
839
- if (override) {
840
- nameEl.textContent = override;
841
- } else {
842
- const base = (location.pathname.split("/").pop() || "").replace(/\.html?$/i, "");
843
- const pretty = base.replace(/[-_]+/g, " ").trim()
844
- .split(/\s+/).map(w => w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : "").join(" ");
845
- nameEl.textContent = pretty || "Mudra App";
846
- }
847
-
848
- // 2. Populate action rows.
849
- const tbody = dialog.querySelector(".mudra-onb__actions tbody");
850
- ACTIONS.forEach(a => {
851
- if (a.mudra == null && a.manual == null) return; // defensive
852
- const tr = document.createElement("tr");
853
- const td = (text) => { const c = document.createElement("td"); c.textContent = text; return c; };
854
- tr.appendChild(td(a.label));
855
- tr.appendChild(td(a.mudra == null ? "—" : a.mudra));
856
- tr.appendChild(td(a.manual == null ? "—" : a.manual));
857
- tbody.appendChild(tr);
858
- });
859
-
860
- // 3. Wire dismiss + reopen.
861
- const close = () => dialog.close();
862
- dialog.querySelector(".mudra-onb__close").addEventListener("click", close);
863
- dialog.querySelector(".mudra-onb__continue").addEventListener("click", close);
864
- reopen.addEventListener("click", () => {
865
- if (typeof dialog.showModal === "function") dialog.showModal();
866
- else dialog.setAttribute("open", "");
867
- });
868
-
869
- // 4. Auto-open on first paint.
870
- requestAnimationFrame(() => {
871
- if (typeof dialog.showModal === "function") dialog.showModal();
872
- else dialog.setAttribute("open", "");
873
- });
874
-
875
- // 5. Hide in immersive XR (mudra-xr only — no-op for 2D apps).
876
- // XR Blocks fires `xrsession-start` / `xrsession-end` on the renderer.
877
- // We listen at window level for any of the standard event names so the
878
- // same block works in both skills without forking.
879
- const setHidden = (h) => {
880
- dialog.classList.toggle("mudra-onb--hidden", h);
881
- reopen.classList.toggle("mudra-onb__reopen--hidden", h);
882
- if (h && dialog.open) dialog.close();
883
- };
884
- ["xrsession-start", "vr-session-start", "ar-session-start"].forEach(ev =>
885
- window.addEventListener(ev, () => setHidden(true)));
886
- ["xrsession-end", "vr-session-end", "ar-session-end"].forEach(ev =>
887
- window.addEventListener(ev, () => setHidden(false)));
888
- })();
889
- </script>
890
- </body>
891
- </html>
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">
6
+ <title>Mudra Interactive Template</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f8fafc;
10
+ --card: #ffffff;
11
+ --primary: #0d9488;
12
+ --accent: #14b8a6;
13
+ --text: #0f172a;
14
+ --text-secondary: #475569;
15
+ --success: #16a34a;
16
+ --warning: #ca8a04;
17
+ --error: #dc2626;
18
+ }
19
+
20
+ * { box-sizing: border-box; }
21
+
22
+ body {
23
+ margin: 0;
24
+ min-height: 100vh;
25
+ background: var(--bg);
26
+ color: var(--text);
27
+ font-family: Poppins, system-ui, sans-serif;
28
+ display: grid;
29
+ place-items: center;
30
+ padding: 20px;
31
+ }
32
+
33
+ .app {
34
+ width: min(1100px, 100%);
35
+ display: grid;
36
+ gap: 14px;
37
+ }
38
+
39
+ .card {
40
+ background: var(--card);
41
+ border: 1px solid rgba(13, 148, 136, 0.18);
42
+ border-radius: 16px;
43
+ padding: 16px;
44
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
45
+ }
46
+
47
+ h1, h2, p { margin: 0; }
48
+
49
+ .title { font-size: 1.24rem; }
50
+ .muted { color: var(--text-secondary); margin-top: 6px; }
51
+
52
+ .header-row {
53
+ display: flex;
54
+ justify-content: space-between;
55
+ align-items: center;
56
+ gap: 12px;
57
+ flex-wrap: wrap;
58
+ margin-top: 12px;
59
+ }
60
+
61
+ .status-row {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ gap: 8px;
65
+ flex-wrap: wrap;
66
+ }
67
+
68
+ .status {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ gap: 8px;
72
+ font-size: 0.92rem;
73
+ padding: 8px 12px;
74
+ border-radius: 999px;
75
+ border: 1px solid rgba(255, 255, 255, 0.15);
76
+ background: rgba(255, 255, 255, 0.02);
77
+ transition: background 200ms ease;
78
+ }
79
+
80
+ .status.ws-only {
81
+ background: rgba(202, 138, 4, 0.10);
82
+ border-color: rgba(202, 138, 4, 0.35);
83
+ color: var(--warning);
84
+ }
85
+
86
+ .status.in-use {
87
+ background: rgba(220, 38, 38, 0.08);
88
+ border-color: rgba(220, 38, 38, 0.3);
89
+ color: var(--error);
90
+ }
91
+
92
+ .dot {
93
+ width: 9px;
94
+ height: 9px;
95
+ border-radius: 50%;
96
+ background: var(--warning);
97
+ box-shadow: 0 0 0 4px rgba(234, 179, 8, 0.15);
98
+ transition: background 150ms ease;
99
+ flex-shrink: 0;
100
+ }
101
+
102
+ .dot.connected {
103
+ background: var(--success);
104
+ box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18);
105
+ }
106
+
107
+ .dot.reconnecting {
108
+ background: var(--text-secondary);
109
+ box-shadow: 0 0 0 4px rgba(148, 163, 184, 0.15);
110
+ animation: blink 1.2s infinite;
111
+ }
112
+
113
+ @keyframes blink {
114
+ 0%, 100% { opacity: 1; }
115
+ 50% { opacity: 0.3; }
116
+ }
117
+
118
+ .hand-chip {
119
+ font-size: 0.78rem;
120
+ padding: 3px 8px;
121
+ border-radius: 999px;
122
+ background: rgba(13, 148, 136, 0.12);
123
+ border: 1px solid rgba(13, 148, 136, 0.25);
124
+ color: var(--primary);
125
+ font-weight: 600;
126
+ letter-spacing: 0.04em;
127
+ white-space: nowrap;
128
+ }
129
+
130
+ .hand-chip.none {
131
+ background: rgba(148, 163, 184, 0.1);
132
+ border-color: rgba(148, 163, 184, 0.2);
133
+ color: var(--text-secondary);
134
+ }
135
+
136
+ .actions {
137
+ display: flex;
138
+ gap: 10px;
139
+ flex-wrap: wrap;
140
+ }
141
+
142
+ button {
143
+ border: none;
144
+ border-radius: 10px;
145
+ padding: 10px 14px;
146
+ font-weight: 700;
147
+ cursor: pointer;
148
+ background: var(--primary);
149
+ color: #072f33;
150
+ }
151
+
152
+ button.secondary {
153
+ background: transparent;
154
+ color: var(--text);
155
+ border: 1px solid rgba(148, 163, 184, 0.3);
156
+ font-weight: 600;
157
+ }
158
+
159
+ .two-col {
160
+ display: grid;
161
+ gap: 14px;
162
+ grid-template-columns: 1.3fr 1fr;
163
+ }
164
+
165
+ .stage {
166
+ position: relative;
167
+ min-height: 280px;
168
+ border-radius: 14px;
169
+ border: 1px solid rgba(148, 163, 184, 0.25);
170
+ background:
171
+ radial-gradient(circle at 50% 45%, rgba(45, 212, 191, 0.12), transparent 45%),
172
+ linear-gradient(180deg, #0a0f12, #070b0e);
173
+ overflow: hidden;
174
+ cursor: grab;
175
+ }
176
+
177
+ .stage:active { cursor: grabbing; }
178
+
179
+ .stage-grid {
180
+ position: absolute;
181
+ inset: 0;
182
+ background-image:
183
+ linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
184
+ linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
185
+ background-size: 24px 24px;
186
+ pointer-events: none;
187
+ }
188
+
189
+ .orb {
190
+ position: absolute;
191
+ left: 50%;
192
+ top: 50%;
193
+ width: 88px;
194
+ height: 88px;
195
+ transform: translate(-50%, -50%);
196
+ border-radius: 999px;
197
+ background:
198
+ radial-gradient(circle at 30% 28%, #c5fffd 0%, var(--primary) 26%, #0b3b40 100%);
199
+ box-shadow: 0 0 24px rgba(119, 234, 233, 0.34);
200
+ transition: transform 80ms linear, filter 80ms linear;
201
+ }
202
+
203
+ .stage-caption {
204
+ position: absolute;
205
+ left: 10px;
206
+ bottom: 10px;
207
+ font-size: 0.82rem;
208
+ color: var(--text-secondary);
209
+ background: rgba(0, 0, 0, 0.34);
210
+ border: 1px solid rgba(148, 163, 184, 0.2);
211
+ border-radius: 8px;
212
+ padding: 6px 8px;
213
+ }
214
+
215
+ .signal-grid {
216
+ display: grid;
217
+ gap: 10px;
218
+ grid-template-columns: repeat(2, minmax(0, 1fr));
219
+ }
220
+
221
+ .metric {
222
+ border: 1px solid rgba(148, 163, 184, 0.2);
223
+ border-radius: 12px;
224
+ padding: 10px;
225
+ background: rgba(8, 12, 15, 0.62);
226
+ }
227
+
228
+ .metric-label {
229
+ font-size: 0.82rem;
230
+ color: var(--text-secondary);
231
+ }
232
+
233
+ .metric-value {
234
+ font-size: 1.24rem;
235
+ margin-top: 6px;
236
+ color: var(--primary);
237
+ font-weight: 700;
238
+ letter-spacing: 0.02em;
239
+ transform-origin: left center;
240
+ }
241
+
242
+ .pulse {
243
+ animation: pulse 150ms ease;
244
+ }
245
+
246
+ @keyframes pulse {
247
+ 0% { transform: scale(1); }
248
+ 50% { transform: scale(1.05); }
249
+ 100% { transform: scale(1); }
250
+ }
251
+
252
+ .bar {
253
+ margin-top: 8px;
254
+ height: 8px;
255
+ border-radius: 999px;
256
+ background: rgba(148, 163, 184, 0.22);
257
+ overflow: hidden;
258
+ }
259
+
260
+ .bar > span {
261
+ display: block;
262
+ height: 100%;
263
+ width: 0%;
264
+ background: linear-gradient(90deg, var(--accent), var(--primary));
265
+ transition: width 75ms linear;
266
+ }
267
+
268
+ .help {
269
+ font-size: 0.86rem;
270
+ line-height: 1.45;
271
+ color: var(--text-secondary);
272
+ }
273
+
274
+ pre {
275
+ margin: 0;
276
+ min-height: 160px;
277
+ max-height: 260px;
278
+ overflow: auto;
279
+ padding: 12px;
280
+ border-radius: 12px;
281
+ border: 1px solid rgba(148, 163, 184, 0.22);
282
+ background: #0b1013;
283
+ font-size: 0.8rem;
284
+ line-height: 1.35;
285
+ color: #d8edf0;
286
+ }
287
+
288
+ .mudra-badge {
289
+ position: fixed;
290
+ bottom: 12px;
291
+ right: 12px;
292
+ font-size: 11px;
293
+ color: var(--text-secondary, #94a3b8);
294
+ opacity: 0.7;
295
+ font-family: inherit;
296
+ letter-spacing: 0.02em;
297
+ pointer-events: none;
298
+ }
299
+
300
+ @media (max-width: 920px) {
301
+ .two-col { grid-template-columns: 1fr; }
302
+ .signal-grid { grid-template-columns: 1fr 1fr; }
303
+ .stage { min-height: 240px; }
304
+ }
305
+
306
+ /* === Onboarding modal — feature 005-onboarding-modal ============ */
307
+ .mudra-onb {
308
+ border: none;
309
+ padding: 0;
310
+ border-radius: 14px;
311
+ width: min(560px, 92vw);
312
+ max-height: min(82vh, 720px);
313
+ background: #fff;
314
+ color: #111;
315
+ font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
316
+ box-shadow: 0 30px 80px rgba(0, 0, 0, .35);
317
+ overflow: hidden;
318
+ }
319
+ .mudra-onb::backdrop { background: rgba(0, 0, 0, .55); }
320
+ .mudra-onb__head {
321
+ display: flex; align-items: center; justify-content: space-between;
322
+ padding: 18px 22px 8px;
323
+ }
324
+ .mudra-onb__brand { display: inline-flex; align-items: center; gap: 8px; }
325
+ .mudra-onb__logo { display: block; width: 28px; height: 28px; flex: none; }
326
+ .mudra-onb__wordmark {
327
+ font-size: 12px; font-weight: 700; letter-spacing: .22em;
328
+ color: #0d9488; text-transform: uppercase;
329
+ }
330
+ .mudra-onb__title {
331
+ margin: 0; padding: 0 22px 14px;
332
+ font-size: 1.1rem; font-weight: 700;
333
+ }
334
+ .mudra-onb__close {
335
+ display: inline-flex; align-items: center; justify-content: center;
336
+ border: none; background: #1a1a1a; color: #fff;
337
+ width: 32px; height: 32px; border-radius: 50%;
338
+ font-size: 22px; line-height: 1; padding: 0; cursor: pointer;
339
+ }
340
+ .mudra-onb__body { padding: 4px 22px 8px; max-height: 56vh; overflow-y: auto; }
341
+ .mudra-onb__lede { margin: 0 0 12px; color: #555; }
342
+ .mudra-onb__actions { width: 100%; border-collapse: collapse; }
343
+ .mudra-onb__actions th, .mudra-onb__actions td {
344
+ text-align: left; padding: 8px 6px; border-bottom: 1px solid #eee; vertical-align: top;
345
+ }
346
+ .mudra-onb__actions th { font-size: 12px; text-transform: uppercase; color: #777; letter-spacing: .04em; }
347
+ .mudra-onb__actions td:nth-child(2) { color: #1a73e8; font-weight: 600; }
348
+ .mudra-onb__actions td:nth-child(3) { color: #444; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
349
+ .mudra-onb__foot { padding: 14px 22px 20px; text-align: right; }
350
+ .mudra-onb__continue {
351
+ border: none; background: #1a1a1a; color: #fff;
352
+ padding: 9px 18px; border-radius: 999px; font-weight: 600; cursor: pointer;
353
+ }
354
+ .mudra-onb__reopen {
355
+ position: fixed; right: 16px; bottom: 16px; z-index: 9999;
356
+ width: 36px; height: 36px; border-radius: 50%;
357
+ border: none; background: #1a1a1a; color: #fff;
358
+ font-size: 18px; font-weight: 700; cursor: pointer;
359
+ box-shadow: 0 6px 20px rgba(0, 0, 0, .25);
360
+ }
361
+ .mudra-onb--hidden, .mudra-onb__reopen--hidden { display: none !important; }
362
+ </style>
363
+ </head>
364
+ <body>
365
+ <main class="app">
366
+ <section class="card">
367
+ <h1 class="title">Mudra Interactive Template</h1>
368
+ <p class="muted">Baseline: connection-safe, multi-signal telemetry, and device-free interaction.</p>
369
+ <div class="header-row">
370
+ <div class="status-row">
371
+ <div class="status" id="status">
372
+ <span class="dot" id="statusDot"></span>
373
+ <span id="statusText">Connecting to Mudra Companion…</span>
374
+ </div>
375
+ <span class="hand-chip none" id="handChip">None</span>
376
+ </div>
377
+ <div class="actions">
378
+ <button id="simulateTap">Simulate Tap</button>
379
+ <button id="resubscribe" class="secondary">Resubscribe</button>
380
+ <button id="toggleMode" class="secondary">Mode: pointer</button>
381
+ <button id="getStatus" class="secondary">Get Status</button>
382
+ <button id="getSubs" class="secondary">Get Subs</button>
383
+ </div>
384
+ </div>
385
+ </section>
386
+
387
+ <section class="two-col">
388
+ <article class="card">
389
+ <h2 class="title">Interaction Stage</h2>
390
+ <p class="muted">Drag or use keyboard when no device is connected.</p>
391
+ <div class="stage" id="stage">
392
+ <div class="stage-grid"></div>
393
+ <div class="orb" id="orb"></div>
394
+ <div class="stage-caption" id="stageCaption">pointer mode</div>
395
+ </div>
396
+ </article>
397
+
398
+ <article class="card">
399
+ <h2 class="title">Signal HUD</h2>
400
+ <div class="signal-grid">
401
+ <div class="metric">
402
+ <div class="metric-label">Gesture</div>
403
+ <div class="metric-value" id="gestureValue">none</div>
404
+ </div>
405
+ <div class="metric">
406
+ <div class="metric-label">Button</div>
407
+ <div class="metric-value" id="buttonValue">released</div>
408
+ </div>
409
+ <div class="metric">
410
+ <div class="metric-label">Pressure</div>
411
+ <div class="metric-value" id="pressureValue">0%</div>
412
+ <div class="bar"><span id="pressureBar"></span></div>
413
+ </div>
414
+ <div class="metric">
415
+ <div class="metric-label">Directional / IMU</div>
416
+ <div class="metric-value" id="motionValue">x:0 y:0</div>
417
+ </div>
418
+ <div class="metric">
419
+ <div class="metric-label">SNC Energy</div>
420
+ <div class="metric-value" id="sncValue">0.00</div>
421
+ <div class="bar"><span id="sncBar"></span></div>
422
+ </div>
423
+ <div class="metric">
424
+ <div class="metric-label">Nav Direction</div>
425
+ <div class="metric-value" id="navDirectionValue">None</div>
426
+ </div>
427
+ </div>
428
+ </article>
429
+ </section>
430
+
431
+ <section class="card">
432
+ <h2 class="title">Fallback Controls</h2>
433
+ <p class="help">
434
+ Space: tap gesture | Shift hold: button press/release | [ and ]: pressure down/up | Arrow keys: pointer/direction/tilt based on mode
435
+ </p>
436
+ </section>
437
+
438
+ <section class="card">
439
+ <h2 class="title">Event Log</h2>
440
+ <pre id="log"></pre>
441
+ </section>
442
+ </main>
443
+
444
+ <div class="mudra-badge">Created by Mudra</div>
445
+
446
+ <script>
447
+ const WS_URL = "ws://127.0.0.1:8766";
448
+ const MAX_LOG_LINES = 80;
449
+ const PRESSURE_WINDOW = 5;
450
+ const SNC_BUFFER_SIZE = 500;
451
+ const STATUS_POLL_INTERVAL = 2000;
452
+ const BACKOFF_SCHEDULE = [1000, 2000, 5000, 10000];
453
+
454
+ // ─── Connection state machine ───────────────────────────────────────────
455
+ // States: "connecting" | "mudra-connected" | "ws-only" | "reconnecting" | "in-use"
456
+ let connState = "connecting";
457
+ let suppressReconnect = false;
458
+ let backoffIndex = 0;
459
+ let reconnectTimer = null;
460
+ let statusPollTimer = null;
461
+ let lastBandConnected = false;
462
+
463
+ // ─── Subscription record ────────────────────────────────────────────────
464
+ // Tracks active subscriptions so they can be replayed on band/WS reconnect.
465
+ const subscriptionRecord = new Set();
466
+
467
+ // ─── App state ──────────────────────────────────────────────────────────
468
+ let controlMode = "pointer"; // "pointer" = navigation+button, "direction" = nav_direction, "imu" = imu_acc+imu_gyro
469
+ let ws;
470
+ const pressureSamples = [];
471
+ const logLines = [];
472
+ const sncBuffers = [[], [], []];
473
+
474
+ const state = {
475
+ gesture: "none",
476
+ button: "released",
477
+ pressure: 0,
478
+ navX: 0,
479
+ navY: 0,
480
+ imuAcc: [0, 0, 9.81],
481
+ imuGyro: [0, 0, 0],
482
+ snc: [0, 0, 0],
483
+ navDirection: "None"
484
+ };
485
+
486
+ const ui = {
487
+ statusEl: document.getElementById("status"),
488
+ statusDot: document.getElementById("statusDot"),
489
+ statusText: document.getElementById("statusText"),
490
+ handChip: document.getElementById("handChip"),
491
+ toggleMode: document.getElementById("toggleMode"),
492
+ stage: document.getElementById("stage"),
493
+ orb: document.getElementById("orb"),
494
+ stageCaption: document.getElementById("stageCaption"),
495
+ gestureValue: document.getElementById("gestureValue"),
496
+ buttonValue: document.getElementById("buttonValue"),
497
+ pressureValue: document.getElementById("pressureValue"),
498
+ pressureBar: document.getElementById("pressureBar"),
499
+ motionValue: document.getElementById("motionValue"),
500
+ sncValue: document.getElementById("sncValue"),
501
+ sncBar: document.getElementById("sncBar"),
502
+ navDirectionValue: document.getElementById("navDirectionValue"),
503
+ log: document.getElementById("log")
504
+ };
505
+
506
+ connect();
507
+ updateModeUi();
508
+ render();
509
+
510
+ document.getElementById("simulateTap").addEventListener("click", () => {
511
+ send({ command: "trigger_gesture", data: { type: "tap" } });
512
+ handleGesture({ type: "tap" });
513
+ });
514
+
515
+ document.getElementById("resubscribe").addEventListener("click", subscribeActiveSignals);
516
+ document.getElementById("getStatus").addEventListener("click", () => send({ command: "get_status" }));
517
+ document.getElementById("getSubs").addEventListener("click", () => send({ command: "get_subscriptions" }));
518
+
519
+ ui.toggleMode.addEventListener("click", () => {
520
+ const modes = ["pointer", "direction", "imu"];
521
+ controlMode = modes[(modes.indexOf(controlMode) + 1) % modes.length];
522
+ updateModeUi();
523
+ resetMotionState();
524
+ if (ws && ws.readyState === WebSocket.OPEN) {
525
+ ["navigation", "button", "nav_direction", "imu_acc", "imu_gyro"].forEach(s => {
526
+ subscriptionRecord.delete(s);
527
+ send({ command: "unsubscribe", signal: s });
528
+ });
529
+ subscribeActiveSignals();
530
+ }
531
+ addLog({ type: "mode_change", data: { mode: controlMode } });
532
+ });
533
+
534
+ document.addEventListener("keydown", handleKeyDown);
535
+ document.addEventListener("keyup", handleKeyUp);
536
+ installStageFallback();
537
+
538
+ // ─── WebSocket lifecycle ─────────────────────────────────────────────────
539
+
540
+ function connect() {
541
+ ws = new WebSocket(WS_URL);
542
+
543
+ ws.onopen = () => {
544
+ backoffIndex = 0;
545
+ setConnState("connecting");
546
+ send({ command: "get_status" });
547
+ subscribeActiveSignals();
548
+ startStatusPolling();
549
+ };
550
+
551
+ ws.onclose = () => {
552
+ stopStatusPolling();
553
+ lastBandConnected = false;
554
+ if (suppressReconnect) return; // client_already_connected path — stay in "in-use" state
555
+ setConnState("reconnecting");
556
+ const delay = BACKOFF_SCHEDULE[Math.min(backoffIndex, BACKOFF_SCHEDULE.length - 1)];
557
+ backoffIndex = Math.min(backoffIndex + 1, BACKOFF_SCHEDULE.length - 1);
558
+ reconnectTimer = setTimeout(connect, delay);
559
+ };
560
+
561
+ ws.onerror = () => {
562
+ // onclose fires immediately after onerror — no separate handling needed
563
+ };
564
+
565
+ ws.onmessage = (event) => {
566
+ const msg = parseMessage(event.data);
567
+ if (!msg) return;
568
+ addLog(msg);
569
+ handleFrame(msg);
570
+ };
571
+ }
572
+
573
+ function handleFrame(msg) {
574
+ // Error frames
575
+ if (msg.type === "error") {
576
+ if (msg.data && msg.data.error === "client_already_connected") {
577
+ suppressReconnect = true;
578
+ setConnState("in-use");
579
+ }
580
+ return;
581
+ }
582
+
583
+ // Status reply — drive the connection-label state machine
584
+ if (msg.type === "status") {
585
+ const dev = (msg.data && msg.data.device) || {};
586
+ const bandConnected = Boolean(dev.firmware && dev.serial_number);
587
+ const hand = bandConnected ? (dev.hand || "—") : "None";
588
+
589
+ if (bandConnected && !lastBandConnected) {
590
+ // Band just reconnected — replay subscription record (FR-018a)
591
+ subscriptionRecord.forEach(signal => send({ command: "subscribe", signal }));
592
+ }
593
+ lastBandConnected = bandConnected;
594
+
595
+ setConnState(bandConnected ? "mudra-connected" : "ws-only", hand);
596
+ return;
597
+ }
598
+
599
+ // Signal frames
600
+ if (msg.type === "gesture") handleGesture(msg.data);
601
+ if (msg.type === "pressure") handlePressure(msg.data);
602
+ if (msg.type === "button") handleButton(msg.data);
603
+ if (msg.type === "navigation") handleNavigation(msg.data);
604
+ if (msg.type === "nav_direction") handleNavDirection(msg.data);
605
+ if (msg.type === "imu_acc") handleImuAcc(msg.data);
606
+ if (msg.type === "imu_gyro") handleImuGyro(msg.data);
607
+ if (msg.type === "snc") handleSnc(msg.data);
608
+ }
609
+
610
+ // ─── Connection label ─────────────────────────────────────────────────────
611
+
612
+ function setConnState(newState, hand) {
613
+ connState = newState;
614
+ const dot = ui.statusDot;
615
+ const text = ui.statusText;
616
+ const el = ui.statusEl;
617
+ const chip = ui.handChip;
618
+
619
+ el.classList.remove("ws-only", "in-use");
620
+ dot.classList.remove("connected", "reconnecting");
621
+
622
+ switch (newState) {
623
+ case "mudra-connected":
624
+ dot.classList.add("connected");
625
+ text.textContent = "Mudra connected";
626
+ chip.textContent = hand || "—";
627
+ chip.classList.remove("none");
628
+ break;
629
+ case "ws-only":
630
+ el.classList.add("ws-only");
631
+ text.textContent = "WebSocket only";
632
+ chip.textContent = "None";
633
+ chip.classList.add("none");
634
+ break;
635
+ case "reconnecting":
636
+ dot.classList.add("reconnecting");
637
+ text.textContent = "Reconnecting…";
638
+ chip.textContent = "None";
639
+ chip.classList.add("none");
640
+ break;
641
+ case "in-use":
642
+ el.classList.add("in-use");
643
+ dot.style.background = "var(--error)";
644
+ text.textContent = "Mudra Companion is already in use by another tab — please close it before continuing.";
645
+ chip.textContent = "None";
646
+ chip.classList.add("none");
647
+ break;
648
+ default: // "connecting"
649
+ text.textContent = "Connecting to Mudra Companion…";
650
+ chip.textContent = "None";
651
+ chip.classList.add("none");
652
+ }
653
+ }
654
+
655
+ // ─── Status polling ───────────────────────────────────────────────────────
656
+
657
+ function startStatusPolling() {
658
+ stopStatusPolling();
659
+ statusPollTimer = setInterval(() => {
660
+ if (ws && ws.readyState === WebSocket.OPEN) {
661
+ send({ command: "get_status" });
662
+ }
663
+ }, STATUS_POLL_INTERVAL);
664
+ }
665
+
666
+ function stopStatusPolling() {
667
+ if (statusPollTimer) { clearInterval(statusPollTimer); statusPollTimer = null; }
668
+ }
669
+
670
+ // ─── Signal subscriptions ─────────────────────────────────────────────────
671
+
672
+ function getActiveSignalNames() {
673
+ const base = ["gesture", "pressure", "snc"];
674
+ if (controlMode === "pointer") return [...base, "navigation", "button"];
675
+ if (controlMode === "direction") return [...base, "nav_direction"];
676
+ return [...base, "imu_acc", "imu_gyro"];
677
+ }
678
+
679
+ function subscribeActiveSignals() {
680
+ getActiveSignalNames().forEach(signal => {
681
+ subscriptionRecord.add(signal);
682
+ send({ command: "subscribe", signal });
683
+ });
684
+ }
685
+
686
+ // ─── Messaging ────────────────────────────────────────────────────────────
687
+
688
+ function send(payload) {
689
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
690
+ ws.send(JSON.stringify(payload));
691
+ }
692
+
693
+ function parseMessage(raw) {
694
+ try {
695
+ return JSON.parse(raw);
696
+ } catch {
697
+ addLog({ type: "parse_error", raw });
698
+ return null;
699
+ }
700
+ }
701
+
702
+ // ─── Signal handlers ──────────────────────────────────────────────────────
703
+
704
+ function handleGesture(data = {}) {
705
+ state.gesture = data.type || "unknown";
706
+ ui.gestureValue.textContent = state.gesture;
707
+ pulse(ui.gestureValue);
708
+ render();
709
+ }
710
+
711
+ function handlePressure(data = {}) {
712
+ const normalized = clamp01(Number(data.normalized ?? 0));
713
+ pressureSamples.push(normalized);
714
+ if (pressureSamples.length > PRESSURE_WINDOW) pressureSamples.shift();
715
+ state.pressure = pressureSamples.reduce((sum, s) => sum + s, 0) / pressureSamples.length;
716
+ ui.pressureValue.textContent = `${Math.round(state.pressure * 100)}%`;
717
+ ui.pressureBar.style.width = `${Math.round(state.pressure * 100)}%`;
718
+ render();
719
+ }
720
+
721
+ function handleButton(data = {}) {
722
+ state.button = data.state || "released";
723
+ ui.buttonValue.textContent = state.button;
724
+ pulse(ui.buttonValue);
725
+ render();
726
+ }
727
+
728
+ function handleNavigation(data = {}) {
729
+ if (controlMode !== "pointer") return;
730
+ const dx = Number(data.delta_x || 0);
731
+ const dy = Number(data.delta_y || 0);
732
+ state.navX = clamp(state.navX + dx * 0.9, -110, 110);
733
+ state.navY = clamp(state.navY + dy * 0.9, -85, 85);
734
+ ui.motionValue.textContent = `x:${state.navX.toFixed(1)} y:${state.navY.toFixed(1)}`;
735
+ render();
736
+ }
737
+
738
+ function handleNavDirection(data = {}) {
739
+ state.navDirection = data.direction || "None";
740
+ ui.navDirectionValue.textContent = state.navDirection;
741
+ if (state.navDirection !== "None") pulse(ui.navDirectionValue);
742
+ }
743
+
744
+ function handleImuAcc(data = {}) {
745
+ if (controlMode !== "imu") return;
746
+ const values = Array.isArray(data.values) ? data.values : [0, 0, 9.81];
747
+ state.imuAcc = [Number(values[0] || 0), Number(values[1] || 0), Number(values[2] || 9.81)];
748
+ projectImuToStage();
749
+ render();
750
+ }
751
+
752
+ function handleImuGyro(data = {}) {
753
+ if (controlMode !== "imu") return;
754
+ const values = Array.isArray(data.values) ? data.values : [0, 0, 0];
755
+ state.imuGyro = [Number(values[0] || 0), Number(values[1] || 0), Number(values[2] || 0)];
756
+ projectImuToStage();
757
+ render();
758
+ }
759
+
760
+ function handleSnc(data = {}) {
761
+ const channels = Array.isArray(data.values) && data.values.length === 3
762
+ ? data.values : [[0], [0], [0]];
763
+ for (let i = 0; i < 3; i++) {
764
+ sncBuffers[i].push(...channels[i]);
765
+ if (sncBuffers[i].length > SNC_BUFFER_SIZE) {
766
+ sncBuffers[i].splice(0, sncBuffers[i].length - SNC_BUFFER_SIZE);
767
+ }
768
+ }
769
+ state.snc = sncBuffers.map(buf => Number(buf[buf.length - 1] || 0));
770
+ const energy = Math.min(1, (Math.abs(state.snc[0]) + Math.abs(state.snc[1]) + Math.abs(state.snc[2])) / 3);
771
+ ui.sncValue.textContent = energy.toFixed(2);
772
+ ui.sncBar.style.width = `${Math.round(energy * 100)}%`;
773
+ }
774
+
775
+ // ─── IMU projection ───────────────────────────────────────────────────────
776
+
777
+ function projectImuToStage() {
778
+ const x = clamp(state.imuAcc[0] * 12 + state.imuGyro[2] * 0.15, -110, 110);
779
+ const y = clamp(-state.imuAcc[1] * 12 + state.imuGyro[0] * 0.15, -85, 85);
780
+ state.navX = x;
781
+ state.navY = y;
782
+ ui.motionValue.textContent = `ax:${state.imuAcc[0].toFixed(2)} ay:${state.imuAcc[1].toFixed(2)}`;
783
+ }
784
+
785
+ // ─── Rendering ────────────────────────────────────────────────────────────
786
+
787
+ function render() {
788
+ const translate = `translate(calc(-50% + ${state.navX}px), calc(-50% + ${state.navY}px))`;
789
+ const scale = 1 + state.pressure * 0.33;
790
+ const glow = 0.8 + state.pressure * 0.7;
791
+ const saturate = state.button === "pressed" ? 1.6 : 1;
792
+ ui.orb.style.transform = `${translate} scale(${scale.toFixed(3)})`;
793
+ ui.orb.style.filter = `brightness(${glow.toFixed(2)}) saturate(${saturate})`;
794
+ }
795
+
796
+ function updateModeUi() {
797
+ ui.toggleMode.textContent = `Mode: ${controlMode}`;
798
+ ui.stageCaption.textContent = `${controlMode} mode`;
799
+ }
800
+
801
+ function resetMotionState() {
802
+ state.navX = 0;
803
+ state.navY = 0;
804
+ state.imuAcc = [0, 0, 9.81];
805
+ state.imuGyro = [0, 0, 0];
806
+ ui.motionValue.textContent = "x:0 y:0";
807
+ render();
808
+ }
809
+
810
+ // ─── Stage drag fallback ──────────────────────────────────────────────────
811
+
812
+ function installStageFallback() {
813
+ let dragging = false;
814
+
815
+ ui.stage.addEventListener("pointerdown", (event) => {
816
+ dragging = true;
817
+ ui.stage.setPointerCapture(event.pointerId);
818
+ });
819
+
820
+ ui.stage.addEventListener("pointerup", () => { dragging = false; });
821
+
822
+ ui.stage.addEventListener("pointermove", (event) => {
823
+ if (!dragging) return;
824
+ const rect = ui.stage.getBoundingClientRect();
825
+ const xNorm = ((event.clientX - rect.left) / rect.width) * 2 - 1;
826
+ const yNorm = ((event.clientY - rect.top) / rect.height) * 2 - 1;
827
+ if (controlMode === "pointer") {
828
+ state.navX = clamp(xNorm * 110, -110, 110);
829
+ state.navY = clamp(yNorm * 85, -85, 85);
830
+ ui.motionValue.textContent = `x:${state.navX.toFixed(1)} y:${state.navY.toFixed(1)}`;
831
+ } else if (controlMode === "imu") {
832
+ handleImuAcc({ values: [xNorm * 1.4, -yNorm * 1.4, 9.81] });
833
+ handleImuGyro({ values: [yNorm * 25, xNorm * 25, xNorm * 12] });
834
+ }
835
+ render();
836
+ });
837
+ }
838
+
839
+ // ─── Keyboard fallback ────────────────────────────────────────────────────
840
+
841
+ function handleKeyDown(event) {
842
+ if (event.repeat) return;
843
+
844
+ if (event.code === "Space") {
845
+ event.preventDefault();
846
+ send({ command: "trigger_gesture", data: { type: "tap" } });
847
+ handleGesture({ type: "tap" });
848
+ }
849
+
850
+ if (event.key === "Shift") handleButton({ state: "pressed" });
851
+ if (event.key === "[") handlePressure({ normalized: clamp01(state.pressure - 0.08) });
852
+ if (event.key === "]") handlePressure({ normalized: clamp01(state.pressure + 0.08) });
853
+
854
+ if (event.key.startsWith("Arrow")) {
855
+ event.preventDefault();
856
+ const step = 9;
857
+ if (controlMode === "pointer") {
858
+ if (event.key === "ArrowLeft") handleNavigation({ delta_x: -step, delta_y: 0 });
859
+ if (event.key === "ArrowRight") handleNavigation({ delta_x: step, delta_y: 0 });
860
+ if (event.key === "ArrowUp") handleNavigation({ delta_x: 0, delta_y: -step });
861
+ if (event.key === "ArrowDown") handleNavigation({ delta_x: 0, delta_y: step });
862
+ } else if (controlMode === "direction") {
863
+ const dirMap = { ArrowLeft: "Left", ArrowRight: "Right", ArrowUp: "Up", ArrowDown: "Down" };
864
+ handleNavDirection({ direction: dirMap[event.key] || "None" });
865
+ } else {
866
+ const ax = state.imuAcc[0] + (event.key === "ArrowRight" ? 0.12 : event.key === "ArrowLeft" ? -0.12 : 0);
867
+ const ay = state.imuAcc[1] + (event.key === "ArrowDown" ? 0.12 : event.key === "ArrowUp" ? -0.12 : 0);
868
+ handleImuAcc({ values: [ax, ay, 9.81] });
869
+ handleImuGyro({ values: [ay * 18, ax * 18, ax * 9] });
870
+ }
871
+ }
872
+ }
873
+
874
+ function handleKeyUp(event) {
875
+ if (event.key === "Shift") handleButton({ state: "released" });
876
+ }
877
+
878
+ // ─── Utilities ────────────────────────────────────────────────────────────
879
+
880
+ function addLog(value) {
881
+ const line = JSON.stringify(value);
882
+ logLines.unshift(line);
883
+ if (logLines.length > MAX_LOG_LINES) logLines.pop();
884
+ ui.log.textContent = logLines.join("\n");
885
+ }
886
+
887
+ function pulse(element) {
888
+ element.classList.remove("pulse");
889
+ void element.offsetWidth;
890
+ element.classList.add("pulse");
891
+ }
892
+
893
+ function clamp(value, min, max) {
894
+ return Math.max(min, Math.min(max, value));
895
+ }
896
+
897
+ function clamp01(value) {
898
+ if (!Number.isFinite(value)) return 0;
899
+ return clamp(value, 0, 1);
900
+ }
901
+ </script>
902
+
903
+ <!-- ============================================================== -->
904
+ <!-- Onboarding modal — feature 005-onboarding-modal -->
905
+ <!-- DO NOT modify this block. Per-app variation lives only in -->
906
+ <!-- the ACTIONS constant below and (optionally) the data-app-name -->
907
+ <!-- attribute on #mudra-onboarding. -->
908
+ <!-- ============================================================== -->
909
+ <dialog id="mudra-onboarding" class="mudra-onb" data-app-name="">
910
+ <header class="mudra-onb__head">
911
+ <div class="mudra-onb__brand">
912
+ <svg class="mudra-onb__logo" viewBox="0 0 32 32" aria-hidden="true">
913
+ <ellipse cx="16" cy="16" rx="13" ry="9" fill="none" stroke="#0d9488" stroke-width="2"/>
914
+ <circle cx="11" cy="16" r="1.6" fill="#14b8a6"/>
915
+ <circle cx="16" cy="16" r="1.6" fill="#14b8a6"/>
916
+ <circle cx="21" cy="16" r="1.6" fill="#14b8a6"/>
917
+ </svg>
918
+ <span class="mudra-onb__wordmark">MUDRA</span>
919
+ </div>
920
+ <button type="button" class="mudra-onb__close" aria-label="Close">×</button>
921
+ </header>
922
+ <h2 class="mudra-onb__title">Welcome to <span class="mudra-onb__name"></span></h2>
923
+ <section class="mudra-onb__body">
924
+ <p class="mudra-onb__lede">Use the Mudra Band — or your keyboard — to control this app.</p>
925
+ <table class="mudra-onb__actions">
926
+ <thead><tr><th>Action</th><th>Mudra</th><th>Manual</th></tr></thead>
927
+ <tbody></tbody>
928
+ </table>
929
+ </section>
930
+ <footer class="mudra-onb__foot">
931
+ <button type="button" class="mudra-onb__continue">Got it</button>
932
+ </footer>
933
+ </dialog>
934
+ <button type="button" id="mudra-onboarding-help" class="mudra-onb__reopen" aria-label="Show controls">?</button>
935
+
936
+ <script>
937
+ /* === Onboarding modal — feature 005-onboarding-modal ============ */
938
+ (() => {
939
+ const ACTIONS = [
940
+ { label: "Tap to interact", mudra: "gesture: pinch", manual: "Space" },
941
+ { label: "Hold to select", mudra: "button: hold", manual: "Shift" },
942
+ { label: "Adjust pressure", mudra: "pressure (thumb-index)", manual: "[ / ]" },
943
+ { label: "Move pointer", mudra: "navigation: cursor", manual: "Arrow keys" },
944
+ ];
945
+
946
+ const dialog = document.getElementById("mudra-onboarding");
947
+ const reopen = document.getElementById("mudra-onboarding-help");
948
+ if (!dialog || !reopen) return;
949
+
950
+ const nameEl = dialog.querySelector(".mudra-onb__name");
951
+ const override = dialog.dataset.appName && dialog.dataset.appName.trim();
952
+ if (override) {
953
+ nameEl.textContent = override;
954
+ } else {
955
+ const base = (location.pathname.split("/").pop() || "").replace(/\.html?$/i, "");
956
+ const pretty = base.replace(/[-_]+/g, " ").trim()
957
+ .split(/\s+/).map(w => w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : "").join(" ");
958
+ nameEl.textContent = pretty || "Mudra App";
959
+ }
960
+
961
+ const tbody = dialog.querySelector(".mudra-onb__actions tbody");
962
+ ACTIONS.forEach(a => {
963
+ if (a.mudra == null && a.manual == null) return;
964
+ const tr = document.createElement("tr");
965
+ const td = (text) => { const c = document.createElement("td"); c.textContent = text; return c; };
966
+ tr.appendChild(td(a.label));
967
+ tr.appendChild(td(a.mudra == null ? "—" : a.mudra));
968
+ tr.appendChild(td(a.manual == null ? "—" : a.manual));
969
+ tbody.appendChild(tr);
970
+ });
971
+
972
+ const close = () => dialog.close();
973
+ dialog.querySelector(".mudra-onb__close").addEventListener("click", close);
974
+ dialog.querySelector(".mudra-onb__continue").addEventListener("click", close);
975
+ reopen.addEventListener("click", () => {
976
+ if (typeof dialog.showModal === "function") dialog.showModal();
977
+ else dialog.setAttribute("open", "");
978
+ });
979
+
980
+ requestAnimationFrame(() => {
981
+ if (typeof dialog.showModal === "function") dialog.showModal();
982
+ else dialog.setAttribute("open", "");
983
+ });
984
+
985
+ const setHidden = (h) => {
986
+ dialog.classList.toggle("mudra-onb--hidden", h);
987
+ reopen.classList.toggle("mudra-onb__reopen--hidden", h);
988
+ if (h && dialog.open) dialog.close();
989
+ };
990
+ ["xrsession-start", "vr-session-start", "ar-session-start"].forEach(ev =>
991
+ window.addEventListener(ev, () => setHidden(true)));
992
+ ["xrsession-end", "vr-session-end", "ar-session-end"].forEach(ev =>
993
+ window.addEventListener(ev, () => setHidden(false)));
994
+ })();
995
+ </script>
996
+ </body>
997
+ </html>