pinokiod 3.180.0 → 3.182.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.
Files changed (51) hide show
  1. package/kernel/favicon.js +91 -34
  2. package/kernel/peer.js +73 -0
  3. package/kernel/util.js +28 -4
  4. package/package.json +1 -1
  5. package/server/index.js +237 -35
  6. package/server/public/common.js +677 -240
  7. package/server/public/files-app/app.css +64 -0
  8. package/server/public/files-app/app.js +87 -0
  9. package/server/public/install.js +8 -1
  10. package/server/public/layout.js +124 -0
  11. package/server/public/nav.js +227 -64
  12. package/server/public/sound/beep.mp3 +0 -0
  13. package/server/public/sound/bell.mp3 +0 -0
  14. package/server/public/sound/bright-ring.mp3 +0 -0
  15. package/server/public/sound/clap.mp3 +0 -0
  16. package/server/public/sound/deep-ring.mp3 +0 -0
  17. package/server/public/sound/gasp.mp3 +0 -0
  18. package/server/public/sound/hehe.mp3 +0 -0
  19. package/server/public/sound/levelup.mp3 +0 -0
  20. package/server/public/sound/light-pop.mp3 +0 -0
  21. package/server/public/sound/light-ring.mp3 +0 -0
  22. package/server/public/sound/meow.mp3 +0 -0
  23. package/server/public/sound/piano.mp3 +0 -0
  24. package/server/public/sound/pop.mp3 +0 -0
  25. package/server/public/sound/uhoh.mp3 +0 -0
  26. package/server/public/sound/whistle.mp3 +0 -0
  27. package/server/public/style.css +195 -4
  28. package/server/public/tab-idle-notifier.js +700 -4
  29. package/server/public/terminal-settings.js +1131 -0
  30. package/server/public/urldropdown.css +28 -1
  31. package/server/socket.js +71 -4
  32. package/server/views/{terminals.ejs → agents.ejs} +108 -32
  33. package/server/views/app.ejs +321 -104
  34. package/server/views/bootstrap.ejs +8 -0
  35. package/server/views/connect.ejs +10 -1
  36. package/server/views/d.ejs +172 -18
  37. package/server/views/editor.ejs +8 -0
  38. package/server/views/file_browser.ejs +4 -0
  39. package/server/views/index.ejs +10 -1
  40. package/server/views/init/index.ejs +18 -3
  41. package/server/views/install.ejs +8 -0
  42. package/server/views/layout.ejs +2 -0
  43. package/server/views/net.ejs +10 -1
  44. package/server/views/network.ejs +10 -1
  45. package/server/views/pro.ejs +8 -0
  46. package/server/views/prototype/index.ejs +8 -0
  47. package/server/views/screenshots.ejs +10 -2
  48. package/server/views/settings.ejs +10 -2
  49. package/server/views/shell.ejs +8 -0
  50. package/server/views/terminal.ejs +8 -0
  51. package/server/views/tools.ejs +10 -2
@@ -19,6 +19,20 @@
19
19
  const TAB_DETAILS_CLASS = 'tab-details';
20
20
  const PREF_STORAGE_KEY = 'pinokio:idle-prefs';
21
21
  const notifyPreferences = new Map();
22
+ const SOUND_PREF_STORAGE_KEY = 'pinokio:idle-sound';
23
+ const SOUND_DEFAULT_CHOICE = '__default__';
24
+ const SOUND_SILENT_CHOICE = '__silent__';
25
+ const SOUND_LIST_ENDPOINT = '/pinokio/notification-sounds';
26
+ const DEFAULT_SOUND_URL = '/chime.mp3';
27
+ let globalSoundPreference = { choice: SOUND_DEFAULT_CHOICE };
28
+ let soundOptionsCache = null;
29
+ let soundOptionsPromise = null;
30
+ let previewAudio = null;
31
+ let soundMenuNode = null;
32
+ let soundMenuContent = null;
33
+ let openMenuContext = null;
34
+ const MENU_KEY_TOGGLE = 'toggle';
35
+ let dismissOverlay = null;
22
36
 
23
37
  const hydratePreferences = () => {
24
38
  try {
@@ -54,6 +68,566 @@
54
68
  }
55
69
  };
56
70
 
71
+ const normaliseSoundAssetPath = (value) => {
72
+ if (typeof value !== 'string') {
73
+ return null;
74
+ }
75
+ const trimmed = value.trim();
76
+ if (!trimmed) {
77
+ return null;
78
+ }
79
+ const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
80
+ if (!withLeading.startsWith('/sound/')) {
81
+ return null;
82
+ }
83
+ try {
84
+ const decoded = decodeURIComponent(withLeading);
85
+ if (decoded.includes('..')) {
86
+ return null;
87
+ }
88
+ } catch (_) {
89
+ if (withLeading.includes('..')) {
90
+ return null;
91
+ }
92
+ }
93
+ return withLeading;
94
+ };
95
+
96
+ const normaliseSoundChoice = (value) => {
97
+ if (value === SOUND_SILENT_CHOICE) {
98
+ return SOUND_SILENT_CHOICE;
99
+ }
100
+ if (value === SOUND_DEFAULT_CHOICE) {
101
+ return SOUND_DEFAULT_CHOICE;
102
+ }
103
+ const asset = normaliseSoundAssetPath(value);
104
+ if (asset) {
105
+ return asset;
106
+ }
107
+ return SOUND_DEFAULT_CHOICE;
108
+ };
109
+
110
+ const hydrateSoundPreference = () => {
111
+ globalSoundPreference = { choice: SOUND_DEFAULT_CHOICE };
112
+ try {
113
+ const raw = localStorage.getItem(SOUND_PREF_STORAGE_KEY);
114
+ if (!raw) {
115
+ return;
116
+ }
117
+ const parsed = JSON.parse(raw);
118
+ if (parsed && typeof parsed === 'object' && parsed !== null) {
119
+ globalSoundPreference.choice = normaliseSoundChoice(parsed.choice);
120
+ }
121
+ } catch (error) {
122
+ log('Failed to hydrate sound preference', error);
123
+ globalSoundPreference = { choice: SOUND_DEFAULT_CHOICE };
124
+ }
125
+ };
126
+
127
+ const persistSoundPreference = () => {
128
+ try {
129
+ const choice = globalSoundPreference?.choice;
130
+ if (!choice || choice === SOUND_DEFAULT_CHOICE) {
131
+ localStorage.removeItem(SOUND_PREF_STORAGE_KEY);
132
+ return;
133
+ }
134
+ localStorage.setItem(SOUND_PREF_STORAGE_KEY, JSON.stringify({ choice }));
135
+ } catch (error) {
136
+ log('Failed to persist sound preference', error);
137
+ }
138
+ };
139
+
140
+ const escapeHtml = (value) => {
141
+ if (typeof value !== 'string') {
142
+ return '';
143
+ }
144
+ return value
145
+ .replace(/&/g, '&')
146
+ .replace(/</g, '&lt;')
147
+ .replace(/>/g, '&gt;')
148
+ .replace(/"/g, '&quot;')
149
+ .replace(/'/g, '&#39;');
150
+ };
151
+
152
+ const resolveNotificationSound = () => {
153
+ const choice = globalSoundPreference?.choice;
154
+ if (choice === SOUND_SILENT_CHOICE) {
155
+ return false;
156
+ }
157
+ const asset = normaliseSoundAssetPath(choice);
158
+ if (asset) {
159
+ return asset;
160
+ }
161
+ return true;
162
+ };
163
+
164
+ const baseSoundOptions = () => ([
165
+ { value: SOUND_DEFAULT_CHOICE, label: 'Default Chime', preview: DEFAULT_SOUND_URL },
166
+ { value: SOUND_SILENT_CHOICE, label: 'Silent', preview: null },
167
+ ]);
168
+
169
+ const loadSoundOptions = () => {
170
+ if (soundOptionsCache) {
171
+ return Promise.resolve(soundOptionsCache.map((option) => ({ ...option })));
172
+ }
173
+ if (!soundOptionsPromise) {
174
+ soundOptionsPromise = fetch(SOUND_LIST_ENDPOINT, { credentials: 'include' })
175
+ .then((response) => {
176
+ if (!response || !response.ok) {
177
+ throw new Error(`Failed to load sound list (${response ? response.status : 'no response'})`);
178
+ }
179
+ return response.json();
180
+ })
181
+ .then((data) => {
182
+ const dynamic = Array.isArray(data?.sounds) ? data.sounds : [];
183
+ const mapped = dynamic
184
+ .map((item) => {
185
+ if (!item || typeof item.url !== 'string') {
186
+ return null;
187
+ }
188
+ const asset = normaliseSoundAssetPath(item.url || item.id || item.filename);
189
+ if (!asset) {
190
+ return null;
191
+ }
192
+ const label = typeof item.label === 'string' && item.label.trim()
193
+ ? item.label.trim()
194
+ : (typeof item.filename === 'string' && item.filename.trim()
195
+ ? item.filename.trim()
196
+ : asset.replace(/^\/+/, ''));
197
+ return {
198
+ value: asset,
199
+ label,
200
+ preview: asset,
201
+ };
202
+ })
203
+ .filter((option) => option && option.value && option.label);
204
+
205
+ const deduped = new Map();
206
+ baseSoundOptions().forEach((option) => {
207
+ deduped.set(option.value, option);
208
+ });
209
+ mapped.forEach((option) => {
210
+ deduped.set(option.value, option);
211
+ });
212
+ soundOptionsCache = Array.from(deduped.values());
213
+ return soundOptionsCache.map((option) => ({ ...option }));
214
+ })
215
+ .catch((error) => {
216
+ log('Failed to load notification sound list', error);
217
+ return baseSoundOptions().map((option) => ({ ...option }));
218
+ })
219
+ .finally(() => {
220
+ soundOptionsPromise = null;
221
+ });
222
+ }
223
+ return soundOptionsPromise.then((options) => options.map((option) => ({ ...option })));
224
+ };
225
+
226
+ const getPreviewUrlForChoice = (choice) => {
227
+ if (choice === SOUND_SILENT_CHOICE) {
228
+ return null;
229
+ }
230
+ const asset = normaliseSoundAssetPath(choice);
231
+ if (asset) {
232
+ return asset;
233
+ }
234
+ return DEFAULT_SOUND_URL;
235
+ };
236
+
237
+ const playSoundPreview = (choice) => {
238
+ const url = getPreviewUrlForChoice(choice);
239
+ if (!url) {
240
+ return;
241
+ }
242
+ try {
243
+ if (!previewAudio) {
244
+ previewAudio = new Audio();
245
+ previewAudio.preload = 'auto';
246
+ previewAudio.loop = false;
247
+ previewAudio.muted = false;
248
+ }
249
+ const resolved = new URL(url, window.location.origin).toString();
250
+ if (previewAudio.src !== resolved) {
251
+ previewAudio.src = resolved;
252
+ }
253
+ try {
254
+ previewAudio.currentTime = 0;
255
+ } catch (_) {}
256
+ const result = previewAudio.play();
257
+ if (result && typeof result.catch === 'function') {
258
+ result.catch(() => {});
259
+ }
260
+ } catch (error) {
261
+ log('Failed to play sound preview', error);
262
+ }
263
+ };
264
+
265
+ const getMenuKeyForSoundValue = (value) => `sound:${value}`;
266
+
267
+ const getMenuItems = () => {
268
+ if (!soundMenuContent) {
269
+ return [];
270
+ }
271
+ return Array.from(soundMenuContent.querySelectorAll('[data-menu-item="true"]'));
272
+ };
273
+
274
+ const positionSoundMenu = (menu, anchor) => {
275
+ if (!menu || !anchor || typeof anchor.getBoundingClientRect !== 'function') {
276
+ return;
277
+ }
278
+ const rect = anchor.getBoundingClientRect();
279
+ const scrollX = window.scrollX || window.pageXOffset || 0;
280
+ const scrollY = window.scrollY || window.pageYOffset || 0;
281
+ const top = rect.bottom + scrollY + 6;
282
+ let left = rect.left + scrollX;
283
+ const menuWidth = menu.offsetWidth || 0;
284
+ const viewportRight = scrollX + window.innerWidth;
285
+ if (left + menuWidth > viewportRight - 12) {
286
+ left = Math.max(scrollX + 12, viewportRight - menuWidth - 12);
287
+ }
288
+ if (left < scrollX + 12) {
289
+ left = scrollX + 12;
290
+ }
291
+ menu.style.top = `${Math.round(top)}px`;
292
+ menu.style.left = `${Math.round(left)}px`;
293
+ };
294
+
295
+ const updateMenuPosition = () => {
296
+ if (!openMenuContext || !soundMenuNode) {
297
+ return;
298
+ }
299
+ positionSoundMenu(soundMenuNode, openMenuContext.toggle);
300
+ };
301
+
302
+ const closeSoundMenu = (focusAnchor = false) => {
303
+ if (!openMenuContext) {
304
+ return;
305
+ }
306
+ const { toggle } = openMenuContext;
307
+ if (soundMenuNode) {
308
+ soundMenuNode.classList.remove('is-open');
309
+ soundMenuNode.setAttribute('aria-hidden', 'true');
310
+ soundMenuNode.style.top = '-9999px';
311
+ soundMenuNode.style.left = '-9999px';
312
+ }
313
+ if (dismissOverlay && dismissOverlay.parentNode) {
314
+ dismissOverlay.parentNode.removeChild(dismissOverlay);
315
+ }
316
+ dismissOverlay = null;
317
+ if (toggle) {
318
+ toggle.setAttribute('aria-expanded', 'false');
319
+ }
320
+ const shouldFocus = focusAnchor && toggle && typeof toggle.focus === 'function';
321
+ openMenuContext = null;
322
+ if (shouldFocus) {
323
+ try { toggle.focus(); } catch (_) {}
324
+ }
325
+ };
326
+
327
+ const renderSoundMenu = (context, { options, loading } = {}) => {
328
+ if (!context) {
329
+ return;
330
+ }
331
+ const effectiveOptions = Array.isArray(options) && options.length > 0
332
+ ? options
333
+ : (soundOptionsCache && soundOptionsCache.length > 0 ? soundOptionsCache : baseSoundOptions());
334
+ if (!soundMenuContent) {
335
+ return;
336
+ }
337
+ const selectedChoice = globalSoundPreference?.choice || SOUND_DEFAULT_CHOICE;
338
+ const tabEnabled = context.state ? Boolean(context.state.notifyEnabled) : true;
339
+ const previousActive = (soundMenuContent.contains(document.activeElement) && document.activeElement instanceof HTMLElement)
340
+ ? document.activeElement.getAttribute('data-menu-key')
341
+ : context.focusKey || null;
342
+
343
+ const soundItems = effectiveOptions.map((option) => {
344
+ const value = option.value;
345
+ const key = getMenuKeyForSoundValue(value);
346
+ const isSelected = value === selectedChoice
347
+ || (value === SOUND_DEFAULT_CHOICE && (selectedChoice === SOUND_DEFAULT_CHOICE || !selectedChoice));
348
+ const label = option.label || 'Sound';
349
+ const meta = value === SOUND_SILENT_CHOICE ? 'No sound' : (value === SOUND_DEFAULT_CHOICE ? 'Default' : null);
350
+ const safeValue = escapeHtml(value);
351
+ const safeLabel = escapeHtml(label);
352
+ const safeMeta = meta ? escapeHtml(meta) : '';
353
+ const safeKey = escapeHtml(key);
354
+ return `
355
+ <button type="button" class="pinokio-notify-item" data-menu-item="true" data-role="sound-option" data-sound-value="${safeValue}" data-menu-key="${safeKey}" role="menuitemradio" aria-checked="${isSelected ? 'true' : 'false'}" ${isSelected ? 'data-selected="true"' : ''}>
356
+ <span class="pinokio-notify-item-icon">${isSelected ? '<i class="fa-solid fa-check"></i>' : ''}</span>
357
+ <span class="pinokio-notify-item-label">${safeLabel}</span>
358
+ ${meta ? `<span class="pinokio-notify-item-meta">${safeMeta}</span>` : ''}
359
+ </button>
360
+ `;
361
+ }).join('');
362
+
363
+ const loadingRow = loading ? '<div class="pinokio-notify-loading">Loading sounds…</div>' : '';
364
+
365
+ soundMenuContent.innerHTML = `
366
+ <div class="pinokio-notify-section">
367
+ <button type="button" class="pinokio-notify-item" data-menu-item="true" data-role="toggle" data-menu-key="${MENU_KEY_TOGGLE}" role="menuitemcheckbox" aria-checked="${tabEnabled ? 'true' : 'false'}">
368
+ <span class="pinokio-notify-item-icon"><i class="fa-solid ${tabEnabled ? 'fa-bell' : 'fa-bell-slash'}"></i></span>
369
+ <span class="pinokio-notify-item-label">Notifications ${tabEnabled ? 'on' : 'off'}</span>
370
+ <span class="pinokio-notify-item-meta">This tab</span>
371
+ </button>
372
+ </div>
373
+ <div class="pinokio-notify-divider" role="presentation"></div>
374
+ <div class="pinokio-notify-section" role="group" aria-label="Notification sound">${soundItems}${loadingRow}</div>
375
+ <p class="pinokio-notify-hint">Sound applies to all tabs</p>
376
+ `;
377
+
378
+ const items = getMenuItems();
379
+ if (!items.length) {
380
+ return;
381
+ }
382
+
383
+ let focusTarget = items.find((item) => item.getAttribute('data-menu-key') === previousActive);
384
+ if (!focusTarget) {
385
+ focusTarget = items[0];
386
+ }
387
+ items.forEach((item) => {
388
+ item.setAttribute('tabindex', item === focusTarget ? '0' : '-1');
389
+ });
390
+ const shouldFocus = context.menuJustOpened
391
+ || !soundMenuContent.contains(document.activeElement)
392
+ || (focusTarget && document.activeElement !== focusTarget);
393
+ if (shouldFocus && focusTarget && typeof focusTarget.focus === 'function') {
394
+ focusTarget.focus();
395
+ }
396
+ context.menuJustOpened = false;
397
+ context.focusKey = focusTarget ? focusTarget.getAttribute('data-menu-key') : null;
398
+ context.focusIndex = items.indexOf(focusTarget);
399
+ };
400
+
401
+ const applySoundSelection = (value) => {
402
+ const choice = normaliseSoundChoice(value);
403
+ const previous = globalSoundPreference.choice;
404
+ globalSoundPreference.choice = choice;
405
+ if (previous !== choice) {
406
+ persistSoundPreference();
407
+ }
408
+ if (openMenuContext) {
409
+ openMenuContext.focusKey = getMenuKeyForSoundValue(choice);
410
+ }
411
+ playSoundPreview(choice);
412
+ if (openMenuContext) {
413
+ renderSoundMenu(openMenuContext);
414
+ }
415
+ };
416
+
417
+ const handleMenuClick = (event) => {
418
+ if (!openMenuContext) {
419
+ return;
420
+ }
421
+ const target = event.target instanceof HTMLElement ? event.target : null;
422
+ if (!target) {
423
+ return;
424
+ }
425
+ const item = target.closest('[data-menu-item="true"]');
426
+ if (!(item instanceof HTMLElement) || !soundMenuContent || !soundMenuContent.contains(item)) {
427
+ return;
428
+ }
429
+ const role = item.getAttribute('data-role');
430
+ if (role === 'toggle') {
431
+ event.preventDefault();
432
+ event.stopPropagation();
433
+ if (typeof openMenuContext.onToggle === 'function') {
434
+ openMenuContext.onToggle();
435
+ }
436
+ closeSoundMenu(true);
437
+ return;
438
+ }
439
+ if (role === 'sound-option') {
440
+ event.preventDefault();
441
+ const value = item.getAttribute('data-sound-value');
442
+ if (value) {
443
+ applySoundSelection(value);
444
+ }
445
+ }
446
+ };
447
+
448
+ const focusMenuItemByIndex = (index) => {
449
+ const items = getMenuItems();
450
+ if (!items.length) {
451
+ return;
452
+ }
453
+ let nextIndex = index;
454
+ if (!Number.isInteger(nextIndex)) {
455
+ nextIndex = 0;
456
+ }
457
+ if (nextIndex < 0) {
458
+ nextIndex = 0;
459
+ }
460
+ if (nextIndex >= items.length) {
461
+ nextIndex = items.length - 1;
462
+ }
463
+ const target = items[nextIndex];
464
+ items.forEach((item, idx) => {
465
+ item.setAttribute('tabindex', idx === nextIndex ? '0' : '-1');
466
+ });
467
+ openMenuContext.focusIndex = nextIndex;
468
+ openMenuContext.focusKey = target ? target.getAttribute('data-menu-key') : null;
469
+ if (target && typeof target.focus === 'function') {
470
+ target.focus();
471
+ }
472
+ };
473
+
474
+ const focusMenuItemByDelta = (delta) => {
475
+ const items = getMenuItems();
476
+ if (!items.length) {
477
+ return;
478
+ }
479
+ const current = document.activeElement && items.includes(document.activeElement)
480
+ ? items.indexOf(document.activeElement)
481
+ : (Number.isInteger(openMenuContext?.focusIndex) ? openMenuContext.focusIndex : 0);
482
+ let nextIndex = current + delta;
483
+ if (nextIndex < 0) {
484
+ nextIndex = items.length - 1;
485
+ } else if (nextIndex >= items.length) {
486
+ nextIndex = 0;
487
+ }
488
+ focusMenuItemByIndex(nextIndex);
489
+ };
490
+
491
+ const focusMenuItemByKey = (key) => {
492
+ const items = getMenuItems();
493
+ if (!items.length) {
494
+ return;
495
+ }
496
+ const target = items.find((item) => item.getAttribute('data-menu-key') === key);
497
+ if (target) {
498
+ focusMenuItemByIndex(items.indexOf(target));
499
+ }
500
+ };
501
+
502
+ const handleMenuKeydown = (event) => {
503
+ if (!openMenuContext) {
504
+ return;
505
+ }
506
+ if (event.key === 'ArrowDown') {
507
+ event.preventDefault();
508
+ focusMenuItemByDelta(1);
509
+ } else if (event.key === 'ArrowUp') {
510
+ event.preventDefault();
511
+ focusMenuItemByDelta(-1);
512
+ } else if (event.key === 'Home') {
513
+ event.preventDefault();
514
+ focusMenuItemByIndex(0);
515
+ } else if (event.key === 'End') {
516
+ event.preventDefault();
517
+ focusMenuItemByIndex(getMenuItems().length - 1);
518
+ } else if (event.key === 'Escape') {
519
+ event.preventDefault();
520
+ closeSoundMenu(true);
521
+ }
522
+ };
523
+
524
+ const ensureSoundMenuNode = () => {
525
+ if (soundMenuNode && soundMenuNode.parentNode) {
526
+ return soundMenuNode;
527
+ }
528
+ soundMenuNode = document.createElement('div');
529
+ soundMenuNode.className = 'pinokio-notify-popover';
530
+ soundMenuNode.id = 'pinokio-notify-popover';
531
+ soundMenuNode.setAttribute('aria-hidden', 'true');
532
+ soundMenuNode.style.top = '-9999px';
533
+ soundMenuNode.style.left = '-9999px';
534
+ soundMenuContent = document.createElement('div');
535
+ soundMenuContent.className = 'pinokio-notify-menu';
536
+ soundMenuContent.setAttribute('role', 'menu');
537
+ soundMenuContent.setAttribute('tabindex', '-1');
538
+ soundMenuNode.appendChild(soundMenuContent);
539
+ document.body.appendChild(soundMenuNode);
540
+ soundMenuNode.addEventListener('click', handleMenuClick);
541
+ soundMenuContent.addEventListener('keydown', handleMenuKeydown);
542
+ return soundMenuNode;
543
+ };
544
+
545
+ const openSoundMenu = (toggle, frameName, state, onToggle) => {
546
+ if (!toggle) {
547
+ return;
548
+ }
549
+ if (openMenuContext && openMenuContext.toggle === toggle) {
550
+ closeSoundMenu();
551
+ return;
552
+ }
553
+ closeSoundMenu();
554
+ const menu = ensureSoundMenuNode();
555
+ openMenuContext = {
556
+ toggle,
557
+ frameName,
558
+ state,
559
+ onToggle,
560
+ focusKey: MENU_KEY_TOGGLE,
561
+ focusIndex: 0,
562
+ menuJustOpened: true,
563
+ };
564
+ toggle.setAttribute('aria-expanded', 'true');
565
+ menu.classList.add('is-open');
566
+ menu.setAttribute('aria-hidden', 'false');
567
+ menu.style.visibility = 'hidden';
568
+ if (!dismissOverlay) {
569
+ dismissOverlay = document.createElement('div');
570
+ dismissOverlay.className = 'pinokio-notify-overlay';
571
+ dismissOverlay.setAttribute('role', 'presentation');
572
+ dismissOverlay.setAttribute('aria-hidden', 'true');
573
+ dismissOverlay.addEventListener('click', () => {
574
+ closeSoundMenu();
575
+ });
576
+ dismissOverlay.addEventListener('mousedown', (event) => {
577
+ event.preventDefault();
578
+ });
579
+ }
580
+ if (!dismissOverlay.parentNode) {
581
+ document.body.appendChild(dismissOverlay);
582
+ }
583
+ if (dismissOverlay instanceof HTMLElement) {
584
+ dismissOverlay.style.pointerEvents = 'auto';
585
+ }
586
+ renderSoundMenu(openMenuContext, {
587
+ options: soundOptionsCache && soundOptionsCache.length ? soundOptionsCache : baseSoundOptions(),
588
+ loading: !soundOptionsCache,
589
+ });
590
+ positionSoundMenu(menu, toggle);
591
+ menu.style.visibility = '';
592
+ updateMenuPosition();
593
+ loadSoundOptions().then((options) => {
594
+ if (!openMenuContext || openMenuContext.toggle !== toggle) {
595
+ return;
596
+ }
597
+ renderSoundMenu(openMenuContext, { options, loading: false });
598
+ updateMenuPosition();
599
+ }).catch(() => {});
600
+ };
601
+
602
+ const handleDocumentPointerDown = (event) => {
603
+ if (!openMenuContext) {
604
+ return;
605
+ }
606
+ const target = event.target;
607
+ if (!(target instanceof Node)) {
608
+ return;
609
+ }
610
+ if (soundMenuNode && soundMenuNode.contains(target)) {
611
+ return;
612
+ }
613
+ const toggle = openMenuContext.toggle;
614
+ if (toggle && toggle.contains && toggle.contains(target)) {
615
+ return;
616
+ }
617
+ closeSoundMenu();
618
+ };
619
+
620
+ const handleDocumentKeydown = (event) => {
621
+ if (event.key === 'Escape' && openMenuContext) {
622
+ event.preventDefault();
623
+ closeSoundMenu(true);
624
+ }
625
+ };
626
+
627
+ const handleViewportChange = () => {
628
+ updateMenuPosition();
629
+ };
630
+
57
631
  const getPreference = (frameName) => {
58
632
  if (!frameName) {
59
633
  return true;
@@ -110,6 +684,7 @@
110
684
  };
111
685
 
112
686
  hydratePreferences();
687
+ hydrateSoundPreference();
113
688
 
114
689
  let ensureIndicatorObservers;
115
690
 
@@ -289,6 +864,93 @@
289
864
  outline: 2px solid var(--pinokio-focus-color, #4c9afe);
290
865
  outline-offset: 2px;
291
866
  }
867
+ .${TOGGLE_CLASS}[aria-expanded="true"] {
868
+ color: var(--pinokio-focus-color, #4c9afe);
869
+ }
870
+ .pinokio-notify-popover {
871
+ position: absolute;
872
+ z-index: 2147482000;
873
+ min-width: 220px;
874
+ max-width: 280px;
875
+ color: #f8fafc;
876
+ background: rgba(15, 23, 42, 0.97);
877
+ border-radius: 10px;
878
+ box-shadow: 0 16px 32px rgba(15, 23, 42, 0.45);
879
+ padding: 8px;
880
+ display: none;
881
+ }
882
+ .pinokio-notify-popover.is-open {
883
+ display: block;
884
+ }
885
+ .pinokio-notify-menu {
886
+ display: flex;
887
+ flex-direction: column;
888
+ gap: 4px;
889
+ outline: none;
890
+ }
891
+ .pinokio-notify-section {
892
+ display: flex;
893
+ flex-direction: column;
894
+ gap: 4px;
895
+ }
896
+ .pinokio-notify-divider {
897
+ height: 1px;
898
+ background: rgba(148, 163, 184, 0.15);
899
+ margin: 4px 0;
900
+ }
901
+ .pinokio-notify-item {
902
+ display: flex;
903
+ align-items: center;
904
+ gap: 10px;
905
+ width: 100%;
906
+ padding: 8px 10px;
907
+ border: none;
908
+ border-radius: 7px;
909
+ background: transparent;
910
+ color: inherit;
911
+ text-align: left;
912
+ font: inherit;
913
+ cursor: pointer;
914
+ }
915
+ .pinokio-notify-item:hover,
916
+ .pinokio-notify-item:focus-visible {
917
+ background: rgba(148, 163, 184, 0.12);
918
+ }
919
+ .pinokio-notify-item[data-selected="true"] {
920
+ background: rgba(148, 163, 184, 0.18);
921
+ }
922
+ .pinokio-notify-item .pinokio-notify-item-icon {
923
+ width: 16px;
924
+ height: 16px;
925
+ display: flex;
926
+ align-items: center;
927
+ justify-content: center;
928
+ font-size: 13px;
929
+ }
930
+ .pinokio-notify-item .pinokio-notify-item-meta {
931
+ margin-left: auto;
932
+ font-size: 12px;
933
+ opacity: 0.75;
934
+ }
935
+ .pinokio-notify-hint {
936
+ margin: 2px 2px 0;
937
+ font-size: 11px;
938
+ color: rgba(148, 163, 184, 0.75);
939
+ }
940
+ .pinokio-notify-loading {
941
+ display: flex;
942
+ align-items: center;
943
+ gap: 8px;
944
+ padding: 6px 10px;
945
+ font-size: 12px;
946
+ color: rgba(148, 163, 184, 0.9);
947
+ }
948
+ .pinokio-notify-overlay {
949
+ position: fixed;
950
+ inset: 0;
951
+ z-index: 2147481995;
952
+ background: transparent;
953
+ }
292
954
  `; // style injection for notify toggle
293
955
  document.head.appendChild(style);
294
956
  toggleStylesInjected = true;
@@ -304,6 +966,8 @@ const syncToggleAppearance = (toggle, enabled) => {
304
966
  icon.classList.toggle('fa-bell-slash', !enabled);
305
967
  toggle.dataset.enabled = enabled ? 'true' : 'false';
306
968
  toggle.setAttribute('aria-pressed', enabled ? 'true' : 'false');
969
+ toggle.setAttribute('aria-haspopup', 'menu');
970
+ toggle.setAttribute('aria-expanded', (openMenuContext && openMenuContext.toggle === toggle) ? 'true' : 'false');
307
971
  toggle.setAttribute('title', enabled ? 'Desktop notifications enabled' : 'Desktop notifications disabled');
308
972
  toggle.setAttribute('aria-label', enabled ? 'Disable desktop notifications for this tab' : 'Enable desktop notifications for this tab');
309
973
  };
@@ -360,6 +1024,7 @@ const syncToggleAppearance = (toggle, enabled) => {
360
1024
  toggle.className = TOGGLE_CLASS;
361
1025
  toggle.setAttribute('role', 'button');
362
1026
  toggle.setAttribute('tabindex', '0');
1027
+ toggle.setAttribute('aria-controls', 'pinokio-notify-popover');
363
1028
  const icon = document.createElement('i');
364
1029
  toggle.appendChild(icon);
365
1030
  (tab.querySelector(`.${TAB_MAIN_CLASS}`) || tab).appendChild(toggle);
@@ -376,19 +1041,33 @@ const syncToggleAppearance = (toggle, enabled) => {
376
1041
  log('Notification preference changed', { frameName, enabled: next });
377
1042
  };
378
1043
 
379
- toggle.addEventListener('click', (event) => {
1044
+ toggle._pinokioToggleActivate = activate;
1045
+
1046
+ const handleToggleInteraction = (event) => {
380
1047
  event.preventDefault();
381
1048
  event.stopPropagation();
382
- activate();
1049
+ const current = getOrCreateState(frameName);
1050
+ if (!current) {
1051
+ return;
1052
+ }
1053
+ openSoundMenu(toggle, frameName, current, activate);
1054
+ };
1055
+
1056
+ toggle.addEventListener('click', (event) => {
1057
+ handleToggleInteraction(event);
383
1058
  });
384
1059
 
385
1060
  toggle.addEventListener('keydown', (event) => {
386
1061
  if (event.key === 'Enter' || event.key === ' ') {
387
1062
  event.preventDefault();
388
- activate();
1063
+ handleToggleInteraction(event);
1064
+ } else if (event.key === 'Escape' && openMenuContext && openMenuContext.toggle === toggle) {
1065
+ event.preventDefault();
1066
+ closeSoundMenu(true);
389
1067
  }
390
1068
  });
391
1069
  }
1070
+ toggle.setAttribute('aria-controls', 'pinokio-notify-popover');
392
1071
  positionToggleWithinTab(tab, toggle);
393
1072
  const container = tab.querySelector(`.${TAB_MAIN_CLASS}`) || tab;
394
1073
  if (!containerObservers.has(container)) {
@@ -411,6 +1090,9 @@ const detachToggleForLink = (link) => {
411
1090
  }
412
1091
  const toggle = tab.querySelector(`.${TOGGLE_CLASS}`);
413
1092
  if (toggle && toggle.parentNode) {
1093
+ if (openMenuContext && openMenuContext.toggle === toggle) {
1094
+ closeSoundMenu();
1095
+ }
414
1096
  toggle.parentNode.removeChild(toggle);
415
1097
  }
416
1098
  const container = tab.querySelector(`.${TAB_MAIN_CLASS}`) || tab;
@@ -491,7 +1173,10 @@ const ensureTabAccessories = aggregateDebounce(() => {
491
1173
  //subtitle,
492
1174
  message,
493
1175
  timeout: 60,
494
- sound: true,
1176
+ sound: resolveNotificationSound(),
1177
+ // Target this notification to this browser/device only
1178
+ audience: 'device',
1179
+ device_id: (typeof window !== 'undefined' && typeof window.PinokioGetDeviceId === 'function') ? window.PinokioGetDeviceId() : undefined,
495
1180
  };
496
1181
  if (image) {
497
1182
  payload.image = image;
@@ -682,6 +1367,11 @@ const ensureTabAccessories = aggregateDebounce(() => {
682
1367
  ensureTabAccessories();
683
1368
  treeObserver.observe(document.body, { childList: true, subtree: true });
684
1369
  window.addEventListener('message', handleMessageEvent, true);
1370
+ document.addEventListener('mousedown', handleDocumentPointerDown, true);
1371
+ document.addEventListener('touchstart', handleDocumentPointerDown, true);
1372
+ document.addEventListener('keydown', handleDocumentKeydown, true);
1373
+ window.addEventListener('resize', handleViewportChange);
1374
+ window.addEventListener('scroll', handleViewportChange, true);
685
1375
  window.addEventListener('storage', (event) => {
686
1376
  if (event.key === DEBUG_STORAGE_KEY) {
687
1377
  updateDebugFlag();
@@ -694,6 +1384,12 @@ const ensureTabAccessories = aggregateDebounce(() => {
694
1384
  });
695
1385
  ensureTabAccessories();
696
1386
  log('Notification preferences refreshed from storage event');
1387
+ } else if (event.key === SOUND_PREF_STORAGE_KEY) {
1388
+ hydrateSoundPreference();
1389
+ if (openMenuContext) {
1390
+ renderSoundMenu(openMenuContext);
1391
+ }
1392
+ log('Sound preference refreshed from storage event');
697
1393
  }
698
1394
  });
699
1395
  };