bloby-bot 0.17.9 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,14 +30,12 @@
30
30
 
31
31
  var iframe = document.createElement('iframe');
32
32
  iframe.src = '/bloby/';
33
- iframe.setAttribute('loading', 'lazy');
34
33
  panel.appendChild(iframe);
35
34
 
36
35
  // ══════════════════════════════════════════════════════════════════
37
- // ── Sprite Sheet Animation (Splash + Bubble) ─────────────────────
36
+ // ── Blob Sprite Sheet Config ─────────────────────────────────────
38
37
  // ══════════════════════════════════════════════════════════════════
39
38
 
40
- // ── Sprite config ──
41
39
  var COLS = 16;
42
40
  var FRAME_W = 125;
43
41
  var FRAME_H = 120;
@@ -65,9 +63,37 @@
65
63
  var BUBBLE_SIZE = 60;
66
64
  var BUBBLE_MARGIN = 24;
67
65
 
66
+ // ══════════════════════════════════════════════════════════════════
67
+ // ── Headphones Sprite Sheet Config ───────────────────────────────
68
+ // ══════════════════════════════════════════════════════════════════
69
+
70
+ var HP_COLS = 16;
71
+ var HP_FRAME_W = 126;
72
+ var HP_FRAME_H = 84;
73
+ var HP_DISPLAY_H = 56;
74
+ var HP_DISPLAY_W = HP_DISPLAY_H * (HP_FRAME_W / HP_FRAME_H);
75
+
76
+ var HP_ACTIVATE_START = 11, HP_ACTIVATE_END = 40;
77
+ var HP_RECORD_START = 41, HP_RECORD_END = 85;
78
+ var HP_DEACTIVATE_START = 85, HP_DEACTIVATE_END = 191;
79
+ var HP_FPS = 24;
80
+ var HP_FRAME_MS = 1000 / HP_FPS;
81
+
82
+ // Expanded canvas for headphones mode
83
+ var HP_CANVAS_W = 90;
84
+ var HP_CANVAS_H = 66;
85
+
86
+ // Long-press threshold (ms)
87
+ var HP_HOLD_MS = 300;
88
+
68
89
  // ── Easing ──
69
90
  function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; }
70
91
 
92
+ // ── Splash guard (skip on soft refresh within same tab) ──
93
+ var SPLASH_KEY = 'bloby_splash_played';
94
+ var splashSeen = false;
95
+ try { splashSeen = sessionStorage.getItem(SPLASH_KEY) === '1'; } catch(e) {}
96
+
71
97
  // ── Canvas setup (starts fullscreen for splash) ──
72
98
  var canvas = document.createElement('canvas');
73
99
  canvas.id = 'bloby-widget-bubble';
@@ -82,7 +108,7 @@
82
108
  var dpr = window.devicePixelRatio || 1;
83
109
  var W = 0, H = 0;
84
110
 
85
- // ── Canvas phase: 'splash' | 'transitioning' | 'bubble' | 'disabled' ──
111
+ // ── Canvas phase: 'splash' | 'transitioning' | 'bubble' | 'headphones' | 'disabled' ──
86
112
  var canvasPhase = 'splash';
87
113
  var canvasCreatedAt = Date.now();
88
114
  var appReady = false;
@@ -93,6 +119,9 @@
93
119
  if (canvasPhase === 'bubble') {
94
120
  W = BUBBLE_SIZE;
95
121
  H = BUBBLE_SIZE;
122
+ } else if (canvasPhase === 'headphones') {
123
+ W = HP_CANVAS_W;
124
+ H = HP_CANVAS_H;
96
125
  } else {
97
126
  W = window.innerWidth;
98
127
  H = window.innerHeight;
@@ -112,7 +141,7 @@
112
141
  }
113
142
  });
114
143
 
115
- // ── Animation state ──
144
+ // ── Blob animation state ──
116
145
  var spriteSheet = null;
117
146
  var animState = 'loading';
118
147
  var currentFrame = 0;
@@ -128,7 +157,27 @@
128
157
  var travelA = { x: 0, y: 0 };
129
158
  var target = { x: 0, y: 0 };
130
159
 
131
- // ── Load sprite sheet ──
160
+ // ── Headphones animation state ──
161
+ var hpSpriteSheet = null;
162
+ var hpState = 'none'; // none | activating | recording | deactivating | activating_then_deactivate
163
+ var hpFrame = 0;
164
+ var hpPingPong = 1;
165
+ var hpLastFrameTime = 0;
166
+
167
+ // ── Headphones mic recording state ──
168
+ var hpMediaRecorder = null;
169
+ var hpAudioChunks = [];
170
+ var hpStream = null;
171
+ var hpPointerDown = false;
172
+ var hpHoldTimer = null;
173
+ var hpWasHold = false;
174
+ var hpMicPermitted = false;
175
+ var hpWhisperEnabled = false;
176
+ var hpWebSpeechCtor = window.SpeechRecognition || window.webkitSpeechRecognition || null;
177
+ var hpSpeechInstance = null;
178
+ var hpSpeechTranscript = '';
179
+
180
+ // ── Load blob sprite sheet ──
132
181
  function loadSprite(onDone, onFail) {
133
182
  var img = new Image();
134
183
  img.onload = function () {
@@ -150,7 +199,16 @@
150
199
  img.src = '/spritesheet.webp';
151
200
  }
152
201
 
153
- // ── Trigger move ──
202
+ // ── Load headphones sprite sheet (lazy, called after bubble ready) ──
203
+ function loadHpSprite(cb) {
204
+ if (hpSpriteSheet) { if (cb) cb(); return; }
205
+ var img = new Image();
206
+ img.onload = function () { hpSpriteSheet = img; if (cb) cb(); };
207
+ img.onerror = function () { console.error('[widget] headphones spritesheet failed to load'); };
208
+ img.src = '/headphones_spritesheet.webp';
209
+ }
210
+
211
+ // ── Trigger blob move ──
154
212
  function moveTo(tx, ty) {
155
213
  if (animState !== 'idle') return;
156
214
  target.x = tx;
@@ -174,9 +232,46 @@
174
232
  animState = 'melting';
175
233
  }
176
234
 
177
- // ── Update ──
235
+ // ══════════════════════════════════════════════════════════════════
236
+ // ── Update (blob + headphones) ───────────────────────────────────
237
+ // ══════════════════════════════════════════════════════════════════
238
+
178
239
  function update(now) {
179
- // Smooth position update during travel (every rAF)
240
+ // ── Headphones animation ──
241
+ if (hpState !== 'none') {
242
+ var hpDelta = now - hpLastFrameTime;
243
+ if (hpDelta >= HP_FRAME_MS) {
244
+ hpLastFrameTime = now - (hpDelta % HP_FRAME_MS);
245
+
246
+ if (hpState === 'activating' || hpState === 'activating_then_deactivate') {
247
+ hpFrame++;
248
+ if (hpFrame > HP_ACTIVATE_END) {
249
+ if (hpState === 'activating_then_deactivate') {
250
+ hpState = 'deactivating';
251
+ hpFrame = HP_DEACTIVATE_START;
252
+ } else {
253
+ hpState = 'recording';
254
+ hpFrame = HP_RECORD_START;
255
+ hpPingPong = 1;
256
+ }
257
+ }
258
+ } else if (hpState === 'recording') {
259
+ hpFrame += hpPingPong;
260
+ if (hpFrame >= HP_RECORD_END) { hpFrame = HP_RECORD_END; hpPingPong = -1; }
261
+ else if (hpFrame <= HP_RECORD_START) { hpFrame = HP_RECORD_START; hpPingPong = 1; }
262
+ } else if (hpState === 'deactivating') {
263
+ hpFrame++;
264
+ if (hpFrame > HP_DEACTIVATE_END) {
265
+ hpState = 'none';
266
+ endHpCanvasMode();
267
+ }
268
+ }
269
+ }
270
+ return; // Skip blob update while headphones active
271
+ }
272
+
273
+ // ── Blob animation ──
274
+ // Smooth position during travel (every rAF)
180
275
  if (animState === 'traveling') {
181
276
  var elapsed = now - travelStartTime;
182
277
  var progress = Math.min(1, elapsed / travelDuration);
@@ -233,6 +328,10 @@
233
328
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
234
329
  center.x = BUBBLE_SIZE / 2;
235
330
  center.y = BUBBLE_SIZE / 2;
331
+ // Mark splash as played for this session
332
+ try { sessionStorage.setItem(SPLASH_KEY, '1'); } catch(e) {}
333
+ // Preload headphones sprite in background
334
+ loadHpSprite();
236
335
  if (hideAfterTransition) {
237
336
  canvas.style.display = 'none';
238
337
  }
@@ -241,8 +340,11 @@
241
340
  }
242
341
  }
243
342
 
244
- // ── Draw ──
245
- function drawFrame(frame, x, y, rotate, flipX) {
343
+ // ══════════════════════════════════════════════════════════════════
344
+ // ── Draw (blob + headphones) ─────────────────────────────────────
345
+ // ══════════════════════════════════════════════════════════════════
346
+
347
+ function drawBlobFrame(frame, x, y, rotate, flipX) {
246
348
  if (!spriteSheet) return;
247
349
  var col = frame % COLS;
248
350
  var row = Math.floor(frame / COLS);
@@ -264,35 +366,278 @@
264
366
  ctx.restore();
265
367
  }
266
368
 
369
+ function drawHpFrame(frame) {
370
+ if (!hpSpriteSheet) return;
371
+ var col = frame % HP_COLS;
372
+ var row = Math.floor(frame / HP_COLS);
373
+ var sx = col * HP_FRAME_W;
374
+ var sy = row * HP_FRAME_H;
375
+
376
+ ctx.save();
377
+ ctx.translate(W / 2, H / 2);
378
+ ctx.imageSmoothingEnabled = true;
379
+ ctx.imageSmoothingQuality = 'high';
380
+ ctx.drawImage(
381
+ hpSpriteSheet,
382
+ sx, sy, HP_FRAME_W, HP_FRAME_H,
383
+ -HP_DISPLAY_W / 2, -HP_DISPLAY_H / 2,
384
+ HP_DISPLAY_W, HP_DISPLAY_H
385
+ );
386
+ ctx.restore();
387
+ }
388
+
267
389
  function draw(now) {
268
390
  ctx.clearRect(0, 0, W, H);
391
+
392
+ // Headphones mode
393
+ if (hpState !== 'none') {
394
+ drawHpFrame(hpFrame);
395
+ return;
396
+ }
397
+
398
+ // Blob mode
269
399
  if (!spriteSheet || animState === 'loading') return;
270
400
 
271
401
  if (animState === 'melting') {
272
402
  var p = (currentFrame - MELT_START) / (MELT_END - MELT_START);
273
403
  var eased = p * p;
274
- drawFrame(currentFrame, center.x, center.y, travelDrawAngle * eased, travelFlip);
404
+ drawBlobFrame(currentFrame, center.x, center.y, travelDrawAngle * eased, travelFlip);
275
405
  } else if (animState === 'traveling') {
276
- drawFrame(currentFrame, center.x, center.y, travelDrawAngle, travelFlip);
406
+ drawBlobFrame(currentFrame, center.x, center.y, travelDrawAngle, travelFlip);
277
407
  } else if (animState === 'reforming') {
278
408
  var p2 = (currentFrame - REFORM_START) / (REFORM_END - REFORM_START);
279
409
  var eased2 = 1 - (1 - p2) * (1 - p2);
280
- drawFrame(currentFrame, center.x, center.y, travelDrawAngle * (1 - eased2));
410
+ drawBlobFrame(currentFrame, center.x, center.y, travelDrawAngle * (1 - eased2));
281
411
  } else {
282
- drawFrame(currentFrame, center.x, center.y);
412
+ drawBlobFrame(currentFrame, center.x, center.y);
283
413
  }
284
414
  }
285
415
 
286
416
  // ── Animation loop ──
287
417
  function loop(now) {
288
- if (animState !== 'loading') {
418
+ if (animState !== 'loading' || hpState !== 'none') {
289
419
  update(now);
290
420
  draw(now);
291
421
  }
292
422
  requestAnimationFrame(loop);
293
423
  }
294
424
 
295
- // ── App-ready coordination ──
425
+ // ══════════════════════════════════════════════════════════════════
426
+ // ── Headphones Canvas Mode ───────────────────────────────────────
427
+ // ══════════════════════════════════════════════════════════════════
428
+
429
+ function enterHpCanvasMode() {
430
+ canvasPhase = 'headphones';
431
+ // Expand canvas, keeping center roughly aligned with the bubble center
432
+ var bubbleCenterFromRight = BUBBLE_MARGIN + BUBBLE_SIZE / 2;
433
+ var bubbleCenterFromBottom = BUBBLE_MARGIN + BUBBLE_SIZE / 2;
434
+ var hpRight = Math.round(bubbleCenterFromRight - HP_CANVAS_W / 2);
435
+ var hpBottom = Math.round(bubbleCenterFromBottom - HP_CANVAS_H / 2);
436
+ canvas.style.cssText = 'position:fixed;bottom:' + hpBottom + 'px;right:' + hpRight + 'px;width:' + HP_CANVAS_W + 'px;height:' + HP_CANVAS_H + 'px;z-index:99998;cursor:pointer;border-radius:12px;pointer-events:auto;';
437
+ W = HP_CANVAS_W;
438
+ H = HP_CANVAS_H;
439
+ canvas.width = HP_CANVAS_W * dpr;
440
+ canvas.height = HP_CANVAS_H * dpr;
441
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
442
+
443
+ hpState = 'activating';
444
+ hpFrame = HP_ACTIVATE_START;
445
+ hpLastFrameTime = performance.now();
446
+ }
447
+
448
+ function endHpCanvasMode() {
449
+ canvasPhase = 'bubble';
450
+ canvas.style.cssText = 'position:fixed;bottom:' + BUBBLE_MARGIN + 'px;right:' + BUBBLE_MARGIN + 'px;width:' + BUBBLE_SIZE + 'px;height:' + BUBBLE_SIZE + 'px;z-index:99998;cursor:pointer;border-radius:50%;transition:transform .15s ease;-webkit-tap-highlight-color:transparent;';
451
+ W = BUBBLE_SIZE;
452
+ H = BUBBLE_SIZE;
453
+ canvas.width = BUBBLE_SIZE * dpr;
454
+ canvas.height = BUBBLE_SIZE * dpr;
455
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
456
+ center.x = BUBBLE_SIZE / 2;
457
+ center.y = BUBBLE_SIZE / 2;
458
+ animState = 'idle';
459
+ currentFrame = IDLE_START;
460
+ idleDirection = 1;
461
+ }
462
+
463
+ // ══════════════════════════════════════════════════════════════════
464
+ // ── Headphones Mic Recording ─────────────────────────────────────
465
+ // ══════════════════════════════════════════════════════════════════
466
+
467
+ /** Check mic permission state without triggering a prompt */
468
+ function checkMicPermission() {
469
+ if (hpMicPermitted) return Promise.resolve('granted');
470
+ if (navigator.permissions && navigator.permissions.query) {
471
+ return navigator.permissions.query({ name: 'microphone' }).then(function(status) {
472
+ if (status.state === 'granted') { hpMicPermitted = true; return 'granted'; }
473
+ return status.state; // 'prompt' or 'denied'
474
+ }).catch(function() { return 'unknown'; });
475
+ }
476
+ return Promise.resolve('unknown');
477
+ }
478
+
479
+ /** Start mic capture (shared by initial recording and re-activation) */
480
+ function startMicCapture() {
481
+ if (hpWhisperEnabled) {
482
+ navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) {
483
+ // If user released while getUserMedia was pending, clean up
484
+ if (!hpPointerDown) {
485
+ stream.getTracks().forEach(function(t) { t.stop(); });
486
+ if (hpState === 'activating') hpState = 'activating_then_deactivate';
487
+ return;
488
+ }
489
+ hpStream = stream;
490
+ var mimeType = (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported('audio/webm;codecs=opus'))
491
+ ? 'audio/webm;codecs=opus' : 'audio/webm';
492
+ var recorder = new MediaRecorder(stream, { mimeType: mimeType });
493
+ hpAudioChunks = [];
494
+ recorder.ondataavailable = function(ev) {
495
+ if (ev.data.size > 0) hpAudioChunks.push(ev.data);
496
+ };
497
+ hpMediaRecorder = recorder;
498
+ recorder.start();
499
+ }).catch(function(err) {
500
+ console.error('[widget] mic start failed:', err);
501
+ if (hpState === 'activating') hpState = 'activating_then_deactivate';
502
+ });
503
+ } else if (hpWebSpeechCtor) {
504
+ var recognition = new hpWebSpeechCtor();
505
+ recognition.continuous = false;
506
+ recognition.interimResults = true;
507
+ recognition.lang = navigator.language || 'en-US';
508
+ hpSpeechTranscript = '';
509
+ hpSpeechInstance = recognition;
510
+
511
+ recognition.onresult = function(event) {
512
+ var last = event.results[event.results.length - 1];
513
+ hpSpeechTranscript = last[0].transcript;
514
+ };
515
+ recognition.onend = function() { hpSpeechInstance = null; };
516
+ recognition.onerror = function(event) {
517
+ console.error('[widget] speech error:', event.error);
518
+ hpSpeechInstance = null;
519
+ };
520
+ try { recognition.start(); } catch(e) {
521
+ console.error('[widget] speech start failed:', e);
522
+ }
523
+ }
524
+ }
525
+
526
+ /** Entry point: permission check → sprite load → begin recording */
527
+ function startHpRecording() {
528
+ // Need at least one voice backend
529
+ if (!hpWhisperEnabled && !hpWebSpeechCtor) return;
530
+
531
+ checkMicPermission().then(function(permState) {
532
+ if (permState === 'denied') return;
533
+
534
+ if (permState === 'prompt' || permState === 'unknown') {
535
+ // Request permission — this may show a dialog
536
+ navigator.mediaDevices.getUserMedia({ audio: true }).then(function(testStream) {
537
+ testStream.getTracks().forEach(function(t) { t.stop(); });
538
+ hpMicPermitted = true;
539
+ // User may have released to click Allow — check before proceeding
540
+ if (!hpPointerDown) return;
541
+ finishStartHpRecording();
542
+ }).catch(function() { /* denied */ });
543
+ return;
544
+ }
545
+
546
+ // Permission already granted
547
+ finishStartHpRecording();
548
+ });
549
+ }
550
+
551
+ function finishStartHpRecording() {
552
+ if (!hpPointerDown) return;
553
+ if (!hpSpriteSheet) {
554
+ loadHpSprite(function() {
555
+ if (!hpPointerDown) return;
556
+ enterHpCanvasMode();
557
+ startMicCapture();
558
+ });
559
+ return;
560
+ }
561
+ enterHpCanvasMode();
562
+ startMicCapture();
563
+ }
564
+
565
+ /** Stop recording and optionally send audio to iframe */
566
+ function stopHpRecording(cancelled) {
567
+ if (cancelled) {
568
+ if (hpStream) { hpStream.getTracks().forEach(function(t) { t.stop(); }); hpStream = null; }
569
+ if (hpMediaRecorder) { try { hpMediaRecorder.stop(); } catch(e) {} hpMediaRecorder = null; }
570
+ hpAudioChunks = [];
571
+ if (hpSpeechInstance) { try { hpSpeechInstance.abort(); } catch(e) {} hpSpeechInstance = null; }
572
+ hpSpeechTranscript = '';
573
+ } else {
574
+ // Normal stop — send the recorded audio/transcript to the iframe
575
+ if (hpWhisperEnabled && hpMediaRecorder && hpMediaRecorder.state !== 'inactive') {
576
+ var recorder = hpMediaRecorder;
577
+ var stream = hpStream;
578
+ recorder.onstop = function() {
579
+ if (stream) stream.getTracks().forEach(function(t) { t.stop(); });
580
+ var blob = new Blob(hpAudioChunks, { type: 'audio/webm' });
581
+ hpAudioChunks = [];
582
+ hpMediaRecorder = null;
583
+ hpStream = null;
584
+ if (blob.size < 1000) return;
585
+
586
+ var reader = new FileReader();
587
+ reader.onloadend = function() {
588
+ var dataUrl = reader.result;
589
+ var base64 = dataUrl.split(',')[1];
590
+ if (base64 && iframe.contentWindow) {
591
+ iframe.contentWindow.postMessage({ type: 'bloby:voice-record', audio: base64 }, '*');
592
+ }
593
+ };
594
+ reader.readAsDataURL(blob);
595
+ };
596
+ recorder.stop();
597
+ } else if (hpSpeechInstance) {
598
+ var instance = hpSpeechInstance;
599
+ var sent = false;
600
+ var sendTranscript = function() {
601
+ if (sent) return;
602
+ sent = true;
603
+ var text = hpSpeechTranscript.trim();
604
+ hpSpeechTranscript = '';
605
+ if (text && iframe.contentWindow) {
606
+ iframe.contentWindow.postMessage({ type: 'bloby:voice-record', transcript: text }, '*');
607
+ }
608
+ };
609
+ instance.onend = function() {
610
+ hpSpeechInstance = null;
611
+ sendTranscript();
612
+ };
613
+ try { instance.stop(); } catch(e) {
614
+ hpSpeechInstance = null;
615
+ sendTranscript();
616
+ }
617
+ // Safety timeout
618
+ setTimeout(function() {
619
+ if (hpSpeechInstance === instance) hpSpeechInstance = null;
620
+ sendTranscript();
621
+ }, 2000);
622
+ } else {
623
+ if (hpStream) { hpStream.getTracks().forEach(function(t) { t.stop(); }); hpStream = null; }
624
+ }
625
+ }
626
+
627
+ // Trigger deactivation animation
628
+ if (hpState === 'activating') {
629
+ hpState = 'activating_then_deactivate';
630
+ } else if (hpState === 'recording') {
631
+ hpState = 'deactivating';
632
+ hpFrame = HP_DEACTIVATE_START;
633
+ hpLastFrameTime = performance.now();
634
+ }
635
+ }
636
+
637
+ // ══════════════════════════════════════════════════════════════════
638
+ // ── App-ready Coordination ───────────────────────────────────────
639
+ // ══════════════════════════════════════════════════════════════════
640
+
296
641
  function maybeTransition() {
297
642
  if (!appReady || animState !== 'idle' || canvasPhase !== 'splash') return;
298
643
  var elapsed = Date.now() - canvasCreatedAt;
@@ -310,7 +655,7 @@
310
655
  moveTo(tx, ty);
311
656
  }
312
657
 
313
- // Skip splash and go straight to bubble mode (for onboard / instant load)
658
+ // Skip splash and go straight to bubble mode (onboard, soft refresh, etc.)
314
659
  function skipToBubble() {
315
660
  canvasPhase = 'bubble';
316
661
  canvas.style.cssText = 'position:fixed;bottom:' + BUBBLE_MARGIN + 'px;right:' + BUBBLE_MARGIN + 'px;width:' + BUBBLE_SIZE + 'px;height:' + BUBBLE_SIZE + 'px;z-index:99998;cursor:pointer;border-radius:50%;transition:transform .15s ease;-webkit-tap-highlight-color:transparent;';
@@ -327,6 +672,8 @@
327
672
  // Hide the HTML splash fallback
328
673
  var splash = document.getElementById('splash');
329
674
  if (splash) splash.style.display = 'none';
675
+ // Preload headphones sprite in background
676
+ loadHpSprite();
330
677
  }
331
678
 
332
679
  window.addEventListener('bloby:app-ready', function () {
@@ -337,8 +684,7 @@
337
684
  appReady = true;
338
685
  }
339
686
 
340
- // ── Check onboard status, then start ──
341
- // First-time users get the onboard wizard — skip the splash animation.
687
+ // ── Check onboard status + settings, then start ──
342
688
  var onboardActive = false;
343
689
  fetch('/api/settings')
344
690
  .then(function (r) { return r.json(); })
@@ -347,9 +693,15 @@
347
693
  onboardActive = true;
348
694
  skipSplash = true;
349
695
  }
696
+ if (s.whisper_enabled === 'true') {
697
+ hpWhisperEnabled = true;
698
+ }
699
+ // Skip splash if already played this session
700
+ if (splashSeen) skipSplash = true;
350
701
  startAnimation();
351
702
  })
352
703
  .catch(function () {
704
+ if (splashSeen) skipSplash = true;
353
705
  startAnimation();
354
706
  });
355
707
 
@@ -371,7 +723,7 @@
371
723
  }
372
724
 
373
725
  // ══════════════════════════════════════════════════════════════════
374
- // ── Widget Interaction (Panel, Toggle, Messages) ─────────────────
726
+ // ── Widget Interaction (Panel, Toggle, Bubble Pointer) ───────────
375
727
  // ══════════════════════════════════════════════════════════════════
376
728
 
377
729
  var isOpen = false;
@@ -384,9 +736,51 @@
384
736
  canvas.style.display = isOpen ? 'none' : 'block';
385
737
  }
386
738
 
387
- bubble.addEventListener('click', function () {
388
- if (canvasPhase === 'bubble') toggle();
739
+ // ── Bubble pointer handlers (tap → open panel, long-press → headphones mic) ──
740
+ bubble.addEventListener('pointerdown', function(e) {
741
+ // Allow re-activation during headphones deactivation
742
+ if (canvasPhase === 'headphones' && hpState === 'deactivating') {
743
+ hpPointerDown = true;
744
+ hpWasHold = true;
745
+ hpState = 'activating';
746
+ hpFrame = HP_ACTIVATE_START;
747
+ hpLastFrameTime = performance.now();
748
+ startMicCapture();
749
+ return;
750
+ }
751
+
752
+ if (canvasPhase !== 'bubble' || hpState !== 'none') return;
753
+ hpPointerDown = true;
754
+ hpWasHold = false;
755
+
756
+ hpHoldTimer = setTimeout(function() {
757
+ hpWasHold = true;
758
+ startHpRecording();
759
+ }, HP_HOLD_MS);
760
+ });
761
+
762
+ bubble.addEventListener('pointerup', function() {
763
+ hpPointerDown = false;
764
+ if (hpHoldTimer) { clearTimeout(hpHoldTimer); hpHoldTimer = null; }
765
+
766
+ if (hpState === 'activating' || hpState === 'recording') {
767
+ stopHpRecording(false);
768
+ } else if (hpState === 'none' && !hpWasHold && canvasPhase === 'bubble') {
769
+ toggle();
770
+ }
389
771
  });
772
+
773
+ bubble.addEventListener('pointercancel', function() {
774
+ hpPointerDown = false;
775
+ if (hpHoldTimer) { clearTimeout(hpHoldTimer); hpHoldTimer = null; }
776
+ if (hpState !== 'none' && hpState !== 'deactivating') stopHpRecording(true);
777
+ });
778
+
779
+ // Prevent context menu on long-press (mobile)
780
+ bubble.addEventListener('contextmenu', function(e) {
781
+ e.preventDefault();
782
+ });
783
+
390
784
  backdrop.addEventListener('click', toggle);
391
785
 
392
786
  // Close on Escape
@@ -356,7 +356,7 @@ Do things, don't describe them. When asked to build something, build it. When as
356
356
  Always read code before changing it. Understand what exists. Never propose changes to code you haven't read. If your human asks about or wants you to modify a file, read it first.
357
357
 
358
358
  ## Simplicity
359
- No over-engineering. Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused.
359
+ No over-engineering. Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused. Before writing a new integration or skill, check the marketplace. If an audited version exists, recommend it — even if building it would be straightforward.
360
360
  - Don't add features, refactoring, or "improvements" beyond what was asked
361
361
  - Don't add docstrings, comments, or type annotations to code you didn't change
362
362
  - Don't add error handling for scenarios that can't happen
@@ -1 +0,0 @@
1
- import{i as e}from"./bloby-Cnt49fF0.js";export{e as Mermaid};