pinokiod 3.170.0 → 3.181.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.
@@ -23,7 +23,7 @@
23
23
  const HOST_ORIGIN = window.location.origin;
24
24
  const STORAGE_PREFIX = 'pinokio:layout:';
25
25
  const MIN_PANEL_SIZE = 120;
26
- const GUTTER_SIZE = 4;
26
+ const GUTTER_SIZE = 6;
27
27
 
28
28
  const state = {
29
29
  sessionId: typeof parsedConfig.sessionId === 'string' && parsedConfig.sessionId.trim() ? parsedConfig.sessionId.trim() : null,
@@ -790,4 +790,118 @@
790
790
  };
791
791
 
792
792
  window.PinokioLayout = api;
793
+ // Mobile "Tap to connect" curtain is centralized in common.js to avoid duplicates
794
+
795
+ // Top-level notification listener (indicator + optional chime) for mobile
796
+ (function initTopLevelNotificationListener() {
797
+ try { if (window.top && window.top !== window) return; } catch (_) { return; }
798
+ if (window.__pinokioTopNotifyListener) {
799
+ return;
800
+ }
801
+ window.__pinokioTopNotifyListener = true;
802
+
803
+ const ensureIndicator = (() => {
804
+ let el = null;
805
+ let styleInjected = false;
806
+ return () => {
807
+ if (!styleInjected) {
808
+ const style = document.createElement('style');
809
+ style.textContent = `
810
+ .pinokio-notify-indicator{position:fixed;top:12px;right:12px;z-index:2147483647;display:none;align-items:center;gap:8px;padding:8px 10px;border-radius:999px;background:rgba(15,23,42,0.92);color:#fff;font:600 12px/1.2 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;box-shadow:0 10px 30px rgba(0,0,0,0.35)}
811
+ .pinokio-notify-indicator .bell{font-size:14px}
812
+ .pinokio-notify-indicator.show{display:inline-flex;animation:pinokioNotifyPop 160ms ease-out, pinokioNotifyFade 1600ms ease-in 700ms forwards}
813
+ @keyframes pinokioNotifyPop{from{transform:translateY(-6px) scale(.98);opacity:0}to{transform:translateY(0) scale(1);opacity:1}}
814
+ @keyframes pinokioNotifyFade{to{opacity:0;transform:translateY(-4px)}}
815
+ @media (max-width: 768px){.pinokio-notify-indicator{top:10px;right:10px;padding:7px 9px;font-size:12px}}
816
+ `;
817
+ document.head.appendChild(style);
818
+ styleInjected = true;
819
+ }
820
+ if (!el) {
821
+ el = document.createElement('div');
822
+ el.className = 'pinokio-notify-indicator';
823
+ const icon = document.createElement('span');
824
+ icon.className = 'bell';
825
+ icon.textContent = '🔔';
826
+ const text = document.createElement('span');
827
+ text.className = 'text';
828
+ text.textContent = 'Notification received';
829
+ el.appendChild(icon);
830
+ el.appendChild(text);
831
+ document.body.appendChild(el);
832
+ }
833
+ return el;
834
+ };
835
+ })();
836
+
837
+ const flashIndicator = (message) => {
838
+ const node = ensureIndicator();
839
+ const text = node.querySelector('.text');
840
+ if (text) {
841
+ const msg = (message && typeof message === 'string' && message.trim()) ? message.trim() : 'Notification received';
842
+ text.textContent = msg.length > 80 ? (msg.slice(0,77) + '…') : msg;
843
+ }
844
+ node.classList.remove('show');
845
+ void node.offsetWidth;
846
+ node.classList.add('show');
847
+ setTimeout(() => node.classList.remove('show'), 2400);
848
+ };
849
+
850
+ const tryPlay = (url) => {
851
+ try {
852
+ const src = (typeof url === 'string' && url) ? url : '/chime.mp3';
853
+ let a = window.__pinokioChimeAudio;
854
+ if (!a) {
855
+ a = new Audio(src);
856
+ a.preload = 'auto';
857
+ a.loop = false;
858
+ a.muted = false;
859
+ window.__pinokioChimeAudio = a;
860
+ } else {
861
+ try { if (a.src && !a.src.endsWith(src)) a.src = src; } catch (_) {}
862
+ }
863
+ try { a.currentTime = 0; } catch (_) {}
864
+ const p = a.play();
865
+ if (p && typeof p.catch === 'function') { p.catch(() => {}); }
866
+ if (typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function') {
867
+ try { navigator.vibrate(80); } catch (_) {}
868
+ }
869
+ } catch (_) {}
870
+ };
871
+
872
+ const listen = () => {
873
+ const SocketCtor = typeof window.Socket === 'function' ? window.Socket : (typeof Socket === 'function' ? Socket : null);
874
+ if (!SocketCtor || typeof WebSocket === 'undefined') {
875
+ return;
876
+ }
877
+ const socket = new SocketCtor();
878
+ try {
879
+ socket.run({ method: 'kernel.notifications', mode: 'listen', device_id: (typeof window.PinokioGetDeviceId === 'function') ? window.PinokioGetDeviceId() : undefined }, (packet) => {
880
+ if (!packet || packet.id !== 'kernel.notifications' || packet.type !== 'notification') {
881
+ return;
882
+ }
883
+ const payload = packet.data || {};
884
+ // If targeted to a specific device, ignore only when our id exists and mismatches
885
+ try {
886
+ const targetId = (typeof payload.device_id === 'string' && payload.device_id.trim()) ? payload.device_id.trim() : null;
887
+ if (targetId) {
888
+ const myId = (typeof window.PinokioGetDeviceId === 'function') ? window.PinokioGetDeviceId() : null;
889
+ if (myId && myId !== targetId) return;
890
+ }
891
+ } catch (_) {}
892
+ flashIndicator(payload.message);
893
+ tryPlay(payload.sound);
894
+ }).then(() => {
895
+ // socket closed; ignore
896
+ }).catch(() => {});
897
+ window.__pinokioTopNotifySocket = socket;
898
+ } catch (_) {}
899
+ };
900
+
901
+ if (document.readyState === 'loading') {
902
+ document.addEventListener('DOMContentLoaded', listen, { once: true });
903
+ } else {
904
+ listen();
905
+ }
906
+ })();
793
907
  })();
@@ -1,4 +1,8 @@
1
1
  document.addEventListener("DOMContentLoaded", () => {
2
+ // Logging disabled for production
3
+ const log = () => {};
4
+ const rectInfo = () => null;
5
+
2
6
  const newWindowButton = document.querySelector("#new-window");
3
7
  if (newWindowButton) {
4
8
  newWindowButton.addEventListener("click", (event) => {
@@ -14,10 +18,70 @@ document.addEventListener("DOMContentLoaded", () => {
14
18
  const header = document.querySelector("header.navheader");
15
19
  const minimizeButton = document.querySelector("#minimize-header");
16
20
  const homeLink = header ? header.querySelector(".home") : null;
17
- if (!header || !minimizeButton || !homeLink) {
21
+ log("init:elements", { hasHeader: !!header, hasMinimize: !!minimizeButton, hasHome: !!homeLink });
22
+ // Only require the header; other controls may be missing on some views
23
+ if (!header) {
24
+ log("init:abort:no-header", {});
18
25
  return;
19
26
  }
20
27
 
28
+ // Helper functions used during initial restore must be defined before use
29
+ const MIN_MARGIN = 8;
30
+
31
+ function clampPosition(left, top, sizeOverride) {
32
+ const rect = header.getBoundingClientRect();
33
+ const width = sizeOverride && Number.isFinite(sizeOverride.width) ? sizeOverride.width : rect.width;
34
+ const height = sizeOverride && Number.isFinite(sizeOverride.height) ? sizeOverride.height : rect.height;
35
+ const maxLeft = Math.max(0, window.innerWidth - width);
36
+ const maxTop = Math.max(0, window.innerHeight - height);
37
+ return {
38
+ left: Math.min(Math.max(0, left), maxLeft),
39
+ top: Math.min(Math.max(0, top), maxTop),
40
+ };
41
+ }
42
+
43
+ function applyPosition(left, top) {
44
+ header.style.left = `${left}px`;
45
+ header.style.top = `${top}px`;
46
+ header.style.right = "auto";
47
+ header.style.bottom = "auto";
48
+ log("pos:apply", { left, top });
49
+ }
50
+
51
+ function measureRect(configureClone) {
52
+ const clone = header.cloneNode(true);
53
+ // Avoid duplicate-IDs while preserving #refresh-page so measurement matches visible controls
54
+ clone.querySelectorAll("[id]").forEach((node) => {
55
+ if (node.id !== "refresh-page") {
56
+ node.removeAttribute("id");
57
+ }
58
+ });
59
+ Object.assign(clone.style, {
60
+ transition: "none",
61
+ transform: "none",
62
+ position: "fixed",
63
+ visibility: "hidden",
64
+ pointerEvents: "none",
65
+ margin: "0",
66
+ left: "0",
67
+ top: "0",
68
+ right: "auto",
69
+ bottom: "auto",
70
+ width: "auto",
71
+ height: "auto",
72
+ });
73
+ document.body.appendChild(clone);
74
+ if (typeof configureClone === "function") {
75
+ configureClone(clone);
76
+ }
77
+ clone.style.right = "auto";
78
+ clone.style.bottom = "auto";
79
+ const rect = clone.getBoundingClientRect();
80
+ log("measure", { minimized: !!clone.classList.contains("minimized"), rect: rectInfo(rect) });
81
+ clone.remove();
82
+ return rect;
83
+ }
84
+
21
85
  const dispatchHeaderState = (minimized, detail = {}) => {
22
86
  if (typeof window === "undefined" || typeof window.CustomEvent !== "function") {
23
87
  return;
@@ -36,8 +100,49 @@ document.addEventListener("DOMContentLoaded", () => {
36
100
  dragHandle.setAttribute("aria-hidden", "true");
37
101
  dragHandle.setAttribute("title", "Drag minimized header");
38
102
  headerTitle.insertBefore(dragHandle, homeLink ? homeLink.nextSibling : headerTitle.firstChild);
103
+ log("init:drag-handle:created", {});
39
104
  }
40
105
 
106
+ const STORAGE_KEY = () => `pinokio:header-state:v1:${location.pathname}`;
107
+ const RESTORE_ONCE_KEY = () => `pinokio:header-restore-once:${location.pathname}`;
108
+ const storage = (() => {
109
+ try { return window.sessionStorage; } catch (_) { return null; }
110
+ })();
111
+ const readPersisted = () => {
112
+ if (!storage) return null;
113
+ try {
114
+ const raw = storage.getItem(STORAGE_KEY());
115
+ if (!raw) return null;
116
+ const data = JSON.parse(raw);
117
+ if (!data || typeof data !== "object") return null;
118
+ const left = Number.isFinite(data.left) ? data.left : null;
119
+ const top = Number.isFinite(data.top) ? data.top : null;
120
+ const minimized = !!data.minimized;
121
+ const out = { minimized, left, top };
122
+ log("storage:read", { key: STORAGE_KEY(), value: out });
123
+ return out;
124
+ } catch (_) {
125
+ return null;
126
+ }
127
+ };
128
+ const writePersisted = (data) => {
129
+ if (!storage) return;
130
+ try {
131
+ const prev = readPersisted() || {};
132
+ const next = { ...prev, ...data };
133
+ storage.setItem(STORAGE_KEY(), JSON.stringify(next));
134
+ log("storage:write", { key: STORAGE_KEY(), value: next });
135
+ } catch (_) {}
136
+ };
137
+ const readRestoreOnce = () => {
138
+ if (!storage) return false;
139
+ try {
140
+ const raw = storage.getItem(RESTORE_ONCE_KEY());
141
+ storage.removeItem(RESTORE_ONCE_KEY());
142
+ return raw === "1";
143
+ } catch (_) { return false; }
144
+ };
145
+
41
146
  const state = {
42
147
  minimized: header.classList.contains("minimized"),
43
148
  pointerId: null,
@@ -54,27 +159,42 @@ document.addEventListener("DOMContentLoaded", () => {
54
159
  },
55
160
  transitionHandler: null,
56
161
  };
162
+ log("init:state", {
163
+ minimizedClass: header.classList.contains("minimized"),
164
+ style: { left: header.style.left, top: header.style.top, right: header.style.right, bottom: header.style.bottom },
165
+ rect: rectInfo(header.getBoundingClientRect())
166
+ });
57
167
 
58
- dispatchHeaderState(state.minimized, { phase: "init" });
59
-
60
- const MIN_MARGIN = 8;
168
+ // Restore persisted or respect DOM state on load (per path, per session)
169
+ const persisted = readPersisted();
170
+ const restoreFromStorage = !!(persisted && persisted.minimized);
171
+ const domIsMinimized = header.classList.contains("minimized");
172
+ if (restoreFromStorage || domIsMinimized) {
173
+ header.classList.add("minimized");
174
+ // Use minimized size for clamping/positioning
175
+ const size = measureRect((clone) => { clone.classList.add("minimized"); });
176
+ const fallbackLeft = Math.max(MIN_MARGIN, window.innerWidth - size.width - MIN_MARGIN);
177
+ const fallbackTop = Math.max(MIN_MARGIN, window.innerHeight - size.height - MIN_MARGIN);
178
+ const left = restoreFromStorage && Number.isFinite(persisted.left) ? persisted.left : fallbackLeft;
179
+ const top = restoreFromStorage && Number.isFinite(persisted.top) ? persisted.top : fallbackTop;
180
+ const clamped = clampPosition(left, top, size);
181
+ state.lastLeft = clamped.left;
182
+ state.lastTop = clamped.top;
183
+ state.hasCustomPosition = restoreFromStorage;
184
+ state.minimized = true;
185
+ // Apply immediately and once after layout settles
186
+ applyPosition(clamped.left, clamped.top);
187
+ requestAnimationFrame(() => applyPosition(clamped.left, clamped.top));
188
+ log("init:restore", { restoreFromStorage, domIsMinimized, measured: { width: size.width, height: size.height }, fallback: { left: fallbackLeft, top: fallbackTop }, chosen: { left, top }, clamped });
189
+ } else {
190
+ // Leave DOM/styles as rendered (expanded)
191
+ state.minimized = false;
192
+ log("init:expanded", {});
193
+ }
61
194
 
62
- const clampPosition = (left, top) => {
63
- const rect = header.getBoundingClientRect();
64
- const maxLeft = Math.max(0, window.innerWidth - rect.width);
65
- const maxTop = Math.max(0, window.innerHeight - rect.height);
66
- return {
67
- left: Math.min(Math.max(0, left), maxLeft),
68
- top: Math.min(Math.max(0, top), maxTop),
69
- };
70
- };
195
+ dispatchHeaderState(state.minimized, { phase: "init" });
71
196
 
72
- const applyPosition = (left, top) => {
73
- header.style.left = `${left}px`;
74
- header.style.top = `${top}px`;
75
- header.style.right = "auto";
76
- header.style.bottom = "auto";
77
- };
197
+ // MIN_MARGIN is already declared above
78
198
 
79
199
  const rememberOriginalPosition = () => {
80
200
  state.originalPosition = {
@@ -85,33 +205,7 @@ document.addEventListener("DOMContentLoaded", () => {
85
205
  };
86
206
  };
87
207
 
88
- const measureRect = (configureClone) => {
89
- const clone = header.cloneNode(true);
90
- clone.querySelectorAll("[id]").forEach((node) => node.removeAttribute("id"));
91
- Object.assign(clone.style, {
92
- transition: "none",
93
- transform: "none",
94
- position: "fixed",
95
- visibility: "hidden",
96
- pointerEvents: "none",
97
- margin: "0",
98
- left: "0",
99
- top: "0",
100
- right: "auto",
101
- bottom: "auto",
102
- width: "auto",
103
- height: "auto",
104
- });
105
- document.body.appendChild(clone);
106
- if (typeof configureClone === "function") {
107
- configureClone(clone);
108
- }
109
- clone.style.right = "auto";
110
- clone.style.bottom = "auto";
111
- const rect = clone.getBoundingClientRect();
112
- clone.remove();
113
- return rect;
114
- };
208
+ // measureRect declared above for early use
115
209
 
116
210
  const stopTransition = () => {
117
211
  if (state.transitionHandler) {
@@ -124,6 +218,7 @@ document.addEventListener("DOMContentLoaded", () => {
124
218
  header.style.transformOrigin = "";
125
219
  header.style.opacity = "";
126
220
  header.style.willChange = "";
221
+ log("transition:stop", {});
127
222
  };
128
223
 
129
224
  const minimize = () => {
@@ -132,6 +227,7 @@ document.addEventListener("DOMContentLoaded", () => {
132
227
  }
133
228
 
134
229
  rememberOriginalPosition();
230
+ log("minimize:start", { rect: rectInfo(header.getBoundingClientRect()), original: { ...state.originalPosition } });
135
231
 
136
232
  const firstRect = header.getBoundingClientRect();
137
233
  const minimizedSize = measureRect((clone) => {
@@ -143,13 +239,17 @@ document.addEventListener("DOMContentLoaded", () => {
143
239
  const targetLeft = state.hasCustomPosition ? state.lastLeft : defaultLeft;
144
240
  const targetTop = state.hasCustomPosition ? state.lastTop : defaultTop;
145
241
 
146
- state.lastLeft = targetLeft;
147
- state.lastTop = targetTop;
242
+ // Clamp final position using minimized size so it anchors bottom-right correctly
243
+ const clamped = clampPosition(targetLeft, targetTop, minimizedSize);
244
+ state.lastLeft = clamped.left;
245
+ state.lastTop = clamped.top;
246
+ log("minimize:computed", { minimizedSize, default: { left: defaultLeft, top: defaultTop }, target: { left: targetLeft, top: targetTop }, clamped });
148
247
 
149
248
  stopTransition();
150
249
 
151
250
  header.classList.add("minimized");
152
- applyPosition(targetLeft, targetTop);
251
+ applyPosition(clamped.left, clamped.top);
252
+ writePersisted({ minimized: true, left: clamped.left, top: clamped.top });
153
253
 
154
254
  dispatchHeaderState(true, { phase: "start" });
155
255
 
@@ -169,6 +269,19 @@ document.addEventListener("DOMContentLoaded", () => {
169
269
  header.classList.add("transitioning");
170
270
  header.style.transition = "transform 520ms cubic-bezier(0.22, 1, 0.36, 1)";
171
271
  header.style.transform = "";
272
+ // Failsafe: clear transitioning if the event never fires
273
+ setTimeout(() => {
274
+ if (header.classList.contains("transitioning")) {
275
+ log("minimize:failsafe", {});
276
+ stopTransition();
277
+ }
278
+ }, 900);
279
+ // Failsafe: clear transitioning if the event never fires
280
+ setTimeout(() => {
281
+ if (header.classList.contains("transitioning")) {
282
+ stopTransition();
283
+ }
284
+ }, 900);
172
285
 
173
286
  state.transitionHandler = (event) => {
174
287
  if (event.propertyName !== "transform") {
@@ -178,18 +291,21 @@ document.addEventListener("DOMContentLoaded", () => {
178
291
  state.transitionHandler = null;
179
292
  stopTransition();
180
293
  state.minimized = true;
294
+ writePersisted({ minimized: true, left: state.lastLeft, top: state.lastTop });
181
295
  dispatchHeaderState(true, { phase: "settled" });
296
+ log("minimize:done", { lastLeft: state.lastLeft, lastTop: state.lastTop });
182
297
  };
183
298
 
184
299
  header.addEventListener("transitionend", state.transitionHandler);
185
300
  };
186
301
 
187
302
  const restore = () => {
188
- if (!state.minimized || header.classList.contains("transitioning")) {
303
+ if (!header.classList.contains("minimized") || header.classList.contains("transitioning")) {
189
304
  return;
190
305
  }
191
306
 
192
307
  const firstRect = header.getBoundingClientRect();
308
+ log("restore:start", { rect: rectInfo(firstRect), original: { ...state.originalPosition } });
193
309
 
194
310
  stopTransition();
195
311
 
@@ -218,6 +334,19 @@ document.addEventListener("DOMContentLoaded", () => {
218
334
 
219
335
  header.style.transition = "transform 560ms cubic-bezier(0.18, 0.85, 0.4, 1)";
220
336
  header.style.transform = "";
337
+ // Failsafe: clear transitioning if the event never fires
338
+ setTimeout(() => {
339
+ if (header.classList.contains("transitioning")) {
340
+ log("restore:failsafe", {});
341
+ stopTransition();
342
+ }
343
+ }, 900);
344
+ // Failsafe: clear transitioning if the event never fires
345
+ setTimeout(() => {
346
+ if (header.classList.contains("transitioning")) {
347
+ stopTransition();
348
+ }
349
+ }, 900);
221
350
 
222
351
  state.transitionHandler = (event) => {
223
352
  if (event.propertyName !== "transform") {
@@ -230,27 +359,36 @@ document.addEventListener("DOMContentLoaded", () => {
230
359
  state.hasCustomPosition = false;
231
360
  state.lastLeft = parseFloat(header.style.left) || 0;
232
361
  state.lastTop = parseFloat(header.style.top) || 0;
362
+ writePersisted({ minimized: false, left: state.lastLeft, top: state.lastTop });
233
363
  dispatchHeaderState(false, { phase: "settled" });
364
+ log("restore:done", { left: state.lastLeft, top: state.lastTop });
234
365
  };
235
366
 
236
367
  header.addEventListener("transitionend", state.transitionHandler);
237
368
  };
238
369
 
239
- minimizeButton.addEventListener("click", (event) => {
240
- event.preventDefault();
241
- minimize();
242
- });
370
+ if (minimizeButton) {
371
+ minimizeButton.addEventListener("click", (event) => {
372
+ log("click:minimize", {});
373
+ event.preventDefault();
374
+ minimize();
375
+ });
376
+ }
243
377
 
244
- homeLink.addEventListener("click", (event) => {
245
- if (!state.minimized) {
246
- return;
247
- }
248
- event.preventDefault();
249
- restore();
250
- });
378
+ if (homeLink) {
379
+ homeLink.addEventListener("click", (event) => {
380
+ const minimizedNow = header.classList.contains("minimized");
381
+ log("click:home", { minimizedNow });
382
+ if (!minimizedNow) {
383
+ return;
384
+ }
385
+ event.preventDefault();
386
+ restore();
387
+ });
388
+ }
251
389
 
252
390
  const onPointerDown = (event) => {
253
- if (!state.minimized || header.classList.contains("transitioning")) {
391
+ if (!header.classList.contains("minimized") || header.classList.contains("transitioning")) {
254
392
  return;
255
393
  }
256
394
  state.pointerId = event.pointerId;
@@ -263,11 +401,13 @@ document.addEventListener("DOMContentLoaded", () => {
263
401
  } catch (error) {}
264
402
  }
265
403
  dragHandle.classList.add("dragging");
404
+ log("drag:start", { pointerId: state.pointerId, rect: rectInfo(rect), offsetX: Math.round(state.offsetX), offsetY: Math.round(state.offsetY) });
266
405
  event.preventDefault();
267
406
  };
268
407
 
408
+ let __lastPointerLog = 0;
269
409
  const onPointerMove = (event) => {
270
- if (!state.minimized || state.pointerId !== event.pointerId) {
410
+ if (!header.classList.contains("minimized") || state.pointerId !== event.pointerId) {
271
411
  return;
272
412
  }
273
413
  const left = event.clientX - state.offsetX;
@@ -277,6 +417,12 @@ document.addEventListener("DOMContentLoaded", () => {
277
417
  state.lastTop = clamped.top;
278
418
  state.hasCustomPosition = true;
279
419
  applyPosition(clamped.left, clamped.top);
420
+ writePersisted({ minimized: true, left: clamped.left, top: clamped.top });
421
+ const t = Date.now();
422
+ if (t - __lastPointerLog > 150) {
423
+ __lastPointerLog = t;
424
+ log("drag:move", { left, top, clamped });
425
+ }
280
426
  };
281
427
 
282
428
  const onPointerEnd = (event) => {
@@ -289,6 +435,7 @@ document.addEventListener("DOMContentLoaded", () => {
289
435
  } catch (error) {}
290
436
  }
291
437
  dragHandle.classList.remove("dragging");
438
+ log("drag:end", { pointerId: event.pointerId, lastLeft: state.lastLeft, lastTop: state.lastTop });
292
439
  state.pointerId = null;
293
440
  };
294
441
 
@@ -296,14 +443,30 @@ document.addEventListener("DOMContentLoaded", () => {
296
443
  dragHandle.addEventListener("pointermove", onPointerMove);
297
444
  dragHandle.addEventListener("pointerup", onPointerEnd);
298
445
  dragHandle.addEventListener("pointercancel", onPointerEnd);
446
+ // Fallback: allow dragging by grabbing empty areas of the minimized header
447
+ const isInteractive = (el) => !!el.closest("a, button, [role='button'], input, select, textarea");
448
+ header.addEventListener("pointerdown", (event) => {
449
+ if (!header.classList.contains("minimized") || isInteractive(event.target) || event.target.closest(".header-drag-handle")) {
450
+ // Interactive controls and the dedicated drag handle use their own handlers
451
+ return;
452
+ }
453
+ log("drag:fallback:pointerdown", { target: event.target && (event.target.id || event.target.className || event.target.nodeName) });
454
+ onPointerDown(event);
455
+ });
456
+ header.addEventListener("pointermove", onPointerMove);
457
+ header.addEventListener("pointerup", onPointerEnd);
458
+ header.addEventListener("pointercancel", onPointerEnd);
299
459
 
300
460
  window.addEventListener("resize", () => {
301
- if (!state.minimized || header.classList.contains("transitioning")) {
461
+ if (!header.classList.contains("minimized") || header.classList.contains("transitioning")) {
302
462
  return;
303
463
  }
464
+ const before = { left: state.lastLeft, top: state.lastTop };
304
465
  const { left, top } = clampPosition(state.lastLeft, state.lastTop);
305
466
  state.lastLeft = left;
306
467
  state.lastTop = top;
307
468
  applyPosition(left, top);
469
+ writePersisted({ minimized: state.minimized, left, top });
470
+ log("resize:clamp", { before, after: { left, top } });
308
471
  });
309
472
  });
@@ -821,6 +821,10 @@ body {
821
821
  color: var(--light-color);
822
822
  position: relative;
823
823
  }
824
+ /* Reserve scrollbar space to prevent header layout shift */
825
+ html {
826
+ scrollbar-gutter: stable both-edges;
827
+ }
824
828
  body.dark {
825
829
  color: var(--dark-color);
826
830
  background: var(--dark-bg);
@@ -2371,7 +2375,7 @@ body.dark .mode-selector .btn2.selected {
2371
2375
  justify-content: center;
2372
2376
  padding: 5px 10px;
2373
2377
  box-sizing: border-box;
2374
- width: 100px;
2378
+ width: 70px;
2375
2379
  gap: 5px;
2376
2380
  border-radius: 5px;
2377
2381
  }
@@ -2379,6 +2383,8 @@ body.dark .mode-selector .btn2.selected {
2379
2383
 
2380
2384
 
2381
2385
  @media only screen and (max-width: 768px) {
2386
+ /* Hide QR block on small screens */
2387
+ aside .qr { display: none !important; }
2382
2388
  body {
2383
2389
  display: flex;
2384
2390
  align-items: stretch;
@@ -2451,6 +2457,14 @@ body.dark .mode-selector .btn2.selected {
2451
2457
  overflow: auto;
2452
2458
  flex-wrap: nowrap;
2453
2459
  }
2460
+ /* Keep minimized header horizontal on small screens */
2461
+ header.navheader.minimized,
2462
+ header.navheader.minimized h1 {
2463
+ display: inline-flex;
2464
+ flex-direction: row;
2465
+ }
2466
+ /* Avoid stretching buttons to full width inside minimized pill */
2467
+ header.navheader.minimized h1 .btn2 { width: auto; }
2454
2468
  .mode-selector {
2455
2469
  flex-direction: column;
2456
2470
  padding: 0;
@@ -2535,8 +2549,18 @@ header.navheader.minimized h1 {
2535
2549
  padding: 0;
2536
2550
  height: auto;
2537
2551
  overflow: visible;
2538
- }
2539
- header.navheader.minimized h1 > *:not(.home):not(.header-drag-handle) {
2552
+ flex-wrap: nowrap;
2553
+ justify-content: flex-start;
2554
+ }
2555
+ /* Ensure item order inside minimized pill: home, refresh, dragger */
2556
+ header.navheader.minimized h1 .home { order: 0; }
2557
+ header.navheader.minimized h1 #refresh-page { order: 1; }
2558
+ header.navheader.minimized h1 .header-drag-handle { order: 2; }
2559
+ /* Prevent items from stretching */
2560
+ header.navheader.minimized h1 > .home,
2561
+ header.navheader.minimized h1 > #refresh-page,
2562
+ header.navheader.minimized h1 > .header-drag-handle { flex: 0 0 auto; }
2563
+ header.navheader.minimized h1 > *:not(.home):not(.header-drag-handle):not(#refresh-page) {
2540
2564
  display: none !important;
2541
2565
  }
2542
2566
  header.navheader .header-drag-handle {
@@ -492,6 +492,9 @@ const ensureTabAccessories = aggregateDebounce(() => {
492
492
  message,
493
493
  timeout: 60,
494
494
  sound: true,
495
+ // Target this notification to this browser/device only
496
+ audience: 'device',
497
+ device_id: (typeof window !== 'undefined' && typeof window.PinokioGetDeviceId === 'function') ? window.PinokioGetDeviceId() : undefined,
495
498
  };
496
499
  if (image) {
497
500
  payload.image = image;