synabun 2.2.0 → 2.2.1

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 (41) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/mcp-server/package-lock.json +2 -2
  4. package/mcp-server/package.json +1 -1
  5. package/neural-interface/i18n/en.json +3 -0
  6. package/neural-interface/i18n/pt-BR.json +3 -0
  7. package/neural-interface/lib/assistant-brains/codex.js +6 -1
  8. package/neural-interface/lib/assistant-dispatch.js +4 -1
  9. package/neural-interface/lib/mcp-external-servers.js +27 -3
  10. package/neural-interface/lib/native-loop-providers.js +3 -2
  11. package/neural-interface/lib/opencode-native-boundary.js +155 -21
  12. package/neural-interface/lib/peer-identity.js +49 -5
  13. package/neural-interface/package-lock.json +2 -2
  14. package/neural-interface/package.json +1 -1
  15. package/neural-interface/public/shared/assistant/asst-api.js +14 -2
  16. package/neural-interface/public/shared/assistant/asst-computer.js +26 -2
  17. package/neural-interface/public/shared/assistant/asst-motion-gate.js +80 -0
  18. package/neural-interface/public/shared/assistant/asst-panel.js +1 -1
  19. package/neural-interface/public/shared/assistant/asst-render.js +62 -7
  20. package/neural-interface/public/shared/assistant/asst-scene-styles.js +55 -23
  21. package/neural-interface/public/shared/assistant/asst-scenes.js +225 -79
  22. package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +4 -4
  23. package/neural-interface/public/shared/assistant/asst-style-cache.js +44 -0
  24. package/neural-interface/public/shared/assistant/asst-styles.js +24 -4
  25. package/neural-interface/public/shared/cp/cp-permissions.js +12 -0
  26. package/neural-interface/public/shared/loading-teardown.js +34 -0
  27. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +6 -1
  28. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +1 -0
  29. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +10 -0
  30. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +6 -0
  31. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +88 -72
  32. package/neural-interface/public/shared/storage-retry.js +47 -0
  33. package/neural-interface/public/shared/storage.js +25 -4
  34. package/neural-interface/public/shared/styles.css +8 -1
  35. package/neural-interface/public/shared/synabun-mascot.js +27 -8
  36. package/neural-interface/public/shared/synabun-ticker.js +147 -12
  37. package/neural-interface/public/shared/ui-claude-panel.js +79 -61
  38. package/neural-interface/public/shared/ui-loading.js +5 -4
  39. package/neural-interface/server.js +87 -40
  40. package/npm-shrinkwrap.json +2 -2
  41. package/package.json +1 -1
@@ -0,0 +1,34 @@
1
+ // SynaBun — taking the loading overlay out of rendering once it has faded (ui-loading.js). No imports, no DOM globals:
2
+ // the element and the timer are handed in (unit-tested under node --test).
3
+ //
4
+ // `hidden` fades the overlay to opacity 0 (0.8 s). A transparent overlay still renders, and its `#loading-text` pulse
5
+ // kept the page drawing 60 frames a second forever. After the fade the overlay also gets `gone` (display: none), which
6
+ // stops every animation inside it; showing it again removes both classes in the same call.
7
+
8
+ export const FADE_MS = 800;
9
+
10
+ /** Fade `el` out, then take it out of rendering. Returns a cancel function. */
11
+ export function concealOverlay(el, { setTimer = setTimeout, clearTimer = clearTimeout, fadeMs = FADE_MS } = {}) {
12
+ if (!el) return () => {};
13
+ el.classList.add('hidden');
14
+ let timer = null;
15
+ const done = () => {
16
+ timer = null;
17
+ el.removeEventListener?.('transitionend', onEnd);
18
+ if (el.classList.contains('hidden')) el.classList.add('gone');
19
+ };
20
+ const onEnd = (event) => { if (!event || event.target === el) { if (timer) clearTimer(timer); done(); } };
21
+ el.addEventListener?.('transitionend', onEnd);
22
+ // A transition that never runs (reduced motion, a background tab, display already none) still ends here.
23
+ timer = setTimer(done, fadeMs + 100);
24
+ el._synabunConceal = () => { if (timer) clearTimer(timer); timer = null; el.removeEventListener?.('transitionend', onEnd); };
25
+ return el._synabunConceal;
26
+ }
27
+
28
+ /** Show `el` again (cancels a pending conceal). */
29
+ export function revealOverlay(el) {
30
+ if (!el) return;
31
+ el._synabunConceal?.();
32
+ el._synabunConceal = null;
33
+ el.classList.remove('gone', 'hidden');
34
+ }
@@ -517,7 +517,12 @@ export function createChildPanel({
517
517
  close.setAttribute('data-tooltip', 'Close');
518
518
  close.setAttribute('data-tooltip-pos', 'left');
519
519
  close.textContent = '×';
520
- pill.append(icon, _pillLabel, _pillBadge, close);
520
+ // The working ring (ocp-v2-styles.js .ocpv2-pill-ring): a box the compositor turns under a mask.
521
+ const ring = document.createElement('span');
522
+ ring.className = 'ocpv2-pill-ring';
523
+ ring.setAttribute('aria-hidden', 'true');
524
+ ring.appendChild(document.createElement('i'));
525
+ pill.append(ring, icon, _pillLabel, _pillBadge, close);
521
526
  // A click shows this sub-agent's panel; on the pill of the panel that is
522
527
  // on screen it minimizes it (as the main panel's pill does).
523
528
  pill.addEventListener('click', (event) => {
@@ -2271,6 +2271,7 @@ function createTabPill(sessionId) {
2271
2271
  pill.dataset.owner = PANEL_OWNER;
2272
2272
  pill.dataset.sessionId = sessionId;
2273
2273
  pill.innerHTML = `
2274
+ <span class="ocpv2-pill-ring" aria-hidden="true"><i></i></span>
2274
2275
  <span class="term-minimized-pill-icon">${OPENCODE_ICON}</span>
2275
2276
  <span class="term-minimized-pill-label">${escapeHtml(tabLabelFor(sessionId))}</span>
2276
2277
  <button class="term-minimized-pill-close" data-tooltip="Close" data-tooltip-pos="top">&times;</button>
@@ -853,6 +853,14 @@ function synabunSummaryText(name, input) {
853
853
  const _expandedToolIds = new Set();
854
854
  const SYNABUN_CHEVRON_SVG = '<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
855
855
 
856
+ function spinRing() {
857
+ const ring = document.createElement('span');
858
+ ring.className = 'ocpv2-spin-ring';
859
+ ring.setAttribute('aria-hidden', 'true');
860
+ ring.appendChild(document.createElement('i'));
861
+ return ring;
862
+ }
863
+
856
864
  function renderSynaBunToolPart(part, toolName, stateStatus) {
857
865
  const callId = part.callID || part.id || part.toolCallId || part.tool_use_id || `sb-${toolName}`;
858
866
  const expanded = _expandedToolIds.has(callId);
@@ -861,6 +869,8 @@ function renderSynaBunToolPart(part, toolName, stateStatus) {
861
869
  wrap.className = 'ocpv2-part ocpv2-part-tool ocpv2-tool-synabun' + (expanded ? ' ocpv2-tool-expanded' : '');
862
870
  wrap.dataset.toolId = callId;
863
871
  wrap.dataset.status = stateStatus;
872
+ // The working ring (.ocpv2-spin-ring): a box the compositor turns while the call runs or waits.
873
+ wrap.appendChild(spinRing());
864
874
 
865
875
  const head = document.createElement('div');
866
876
  head.className = 'ocpv2-tool-head ocpv2-sb-head';
@@ -211,6 +211,12 @@ export function mountCompose(rootEl, store = getDefaultStore(), opts = {}) {
211
211
  const wrap = document.createElement('div');
212
212
  wrap.className = 'ocpv2-input-wrap';
213
213
  _wrap = wrap;
214
+ // The focus ring (.ocpv2-spin-ring): a box the compositor turns while the composer has focus.
215
+ const ring = document.createElement('span');
216
+ ring.className = 'ocpv2-spin-ring';
217
+ ring.setAttribute('aria-hidden', 'true');
218
+ ring.appendChild(document.createElement('i'));
219
+ wrap.appendChild(ring);
214
220
 
215
221
  const inner = document.createElement('div');
216
222
  inner.className = 'ocpv2-input-inner';
@@ -412,55 +412,56 @@ html.desktop-titlebar--win32 .ocpv2-panel { backdrop-filter: none; -webkit-backd
412
412
  }
413
413
 
414
414
  /* ── Tray pill (minimize-to-pill) ───────────────────────────────────────── */
415
- @property --ocpv2-pill-angle {
416
- syntax: '<angle>';
417
- initial-value: 0deg;
418
- inherits: false;
419
- }
420
415
  .ocpv2-session-pill {
421
416
  position: relative;
422
417
  overflow: hidden;
423
418
  border-color: rgba(232, 224, 220, 0.12);
424
419
  transition: border-color 0.2s;
425
420
  }
426
- .ocpv2-session-pill::before {
427
- content: '';
421
+ /* A working pill's ring: a conic gradient turning inside the 1px border (the mask keeps only the
422
+ ring). The gradient is a square box that turns (rotate, run by the compositor) under the mask; an
423
+ animated custom property (the old --ocpv2-pill-angle) repainted the pill on the main thread every
424
+ frame for as long as the session worked. The square is wider than the pill's diagonal, so the
425
+ ring is the same conic gradient at every angle. */
426
+ .ocpv2-pill-ring {
428
427
  position: absolute; inset: 0;
428
+ display: none;
429
429
  border-radius: 8px;
430
430
  padding: 1px;
431
+ overflow: hidden;
431
432
  pointer-events: none;
432
- background: transparent;
433
433
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
434
434
  -webkit-mask-composite: xor;
435
435
  mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
436
436
  mask-composite: exclude;
437
- opacity: 0;
438
- }
439
- .ocpv2-session-pill.ocpv2-pill-running {
440
- border-color: transparent;
441
437
  }
442
- .ocpv2-session-pill.ocpv2-pill-running::before {
438
+ .ocpv2-pill-ring > i {
439
+ position: absolute; left: 50%; top: 50%;
440
+ width: calc(100% + 32px);
441
+ aspect-ratio: 1;
442
+ translate: -50% -50%;
443
443
  background: conic-gradient(
444
- from var(--ocpv2-pill-angle, 0deg),
445
444
  rgba(232,224,220,0.0) 0%,
446
445
  rgba(232,224,220,0.55) 25%,
447
446
  rgba(232,224,220,0.10) 50%,
448
447
  rgba(232,224,220,0.55) 75%,
449
448
  rgba(232,224,220,0.0) 100%
450
449
  );
451
- animation: ocpv2-pill-border-spin 2.4s linear infinite;
452
- opacity: 1;
450
+ animation: ocpv2-pill-ring-turn 2.4s linear infinite;
451
+ }
452
+ .ocpv2-session-pill.ocpv2-pill-running {
453
+ border-color: transparent;
453
454
  }
454
- @keyframes ocpv2-pill-border-spin {
455
- to { --ocpv2-pill-angle: 360deg; }
455
+ .ocpv2-session-pill.ocpv2-pill-running .ocpv2-pill-ring { display: block; }
456
+ @keyframes ocpv2-pill-ring-turn {
457
+ to { rotate: 360deg; }
456
458
  }
457
459
  .ocpv2-session-pill .term-minimized-pill-icon { color: rgba(232, 224, 220, 0.45); transition: color 0.2s; }
458
460
  .ocpv2-session-pill.ocpv2-pill-running .term-minimized-pill-icon { color: rgba(232, 224, 220, 1); }
459
461
  .ocpv2-session-pill:hover { border-color: rgba(232, 224, 220, 0.24); }
460
462
  .ocpv2-session-pill:hover .term-minimized-pill-icon { color: rgba(232, 224, 220, 0.85); }
461
- .ocpv2-session-pill:hover.ocpv2-pill-running::before {
463
+ .ocpv2-session-pill:hover .ocpv2-pill-ring > i {
462
464
  background: conic-gradient(
463
- from var(--ocpv2-pill-angle, 0deg),
464
465
  rgba(232,224,220,0.0) 0%,
465
466
  rgba(232,224,220,0.75) 25%,
466
467
  rgba(232,224,220,0.20) 50%,
@@ -476,11 +477,11 @@ html.platform-win32 .ocpv2-session-pill.ocpv2-pill-running:hover,
476
477
  html.desktop-titlebar--win32 .ocpv2-session-pill.ocpv2-pill-running:hover {
477
478
  border-color: rgba(232, 224, 220, 0.58);
478
479
  }
479
- html.platform-win32 .ocpv2-session-pill.ocpv2-pill-running::before,
480
- html.desktop-titlebar--win32 .ocpv2-session-pill.ocpv2-pill-running::before {
481
- animation: none;
482
- opacity: 0;
480
+ html.platform-win32 .ocpv2-session-pill.ocpv2-pill-running .ocpv2-pill-ring,
481
+ html.desktop-titlebar--win32 .ocpv2-session-pill.ocpv2-pill-running .ocpv2-pill-ring {
482
+ display: none;
483
483
  }
484
+ body.ui-interacting .ocpv2-pill-ring > i { animation-play-state: paused; }
484
485
  .ocpv2-session-pill.ocpv2-pill-running .term-minimized-pill-label::before {
485
486
  content: '';
486
487
  display: inline-block;
@@ -509,6 +510,12 @@ html.desktop-titlebar--win32 .ocpv2-session-pill.ocpv2-pill-running::before {
509
510
  0%, 100% { opacity: 0.45; }
510
511
  50% { opacity: 1; }
511
512
  }
513
+ /* Reduced motion: the ring, the dot and the edge hold still (the working look stays). */
514
+ @media (prefers-reduced-motion: reduce) {
515
+ .ocpv2-pill-ring > i,
516
+ .ocpv2-session-pill.ocpv2-pill-running .term-minimized-pill-label::before,
517
+ .ocpv2-session-pill.ocpv2-pill-running::after { animation: none; }
518
+ }
512
519
 
513
520
  /* Parent pill marker: sub-agent attached → arrow appears after the label.
514
521
  Brightens + pulses when the child is actively running. Without this a
@@ -1309,12 +1316,44 @@ html.desktop-titlebar--win32 .ocpv2-session-pill.ocpv2-pill-running::before {
1309
1316
  }
1310
1317
 
1311
1318
  /* ── SynaBun-branded tool cards (OpenCode cream palette) ───────────────── */
1312
- @property --ocpv2-sb-angle {
1313
- syntax: '<angle>';
1314
- initial-value: 0deg;
1315
- inherits: false;
1319
+ /* ── Spinning border ring (a SynaBun tool card at work, the composer with focus) ──
1320
+ A conic gradient turning inside the 1px edge, the mask keeping only the ring. The gradient is a
1321
+ square box that turns (rotate, run by the compositor) under the mask, and only while its owner's
1322
+ state is on; an animated custom property (the old --ocpv2-sb-angle / --ocpv2-border-angle,
1323
+ @property + a conic gradient from it) restyled and repainted the owner on the main thread 60 times
1324
+ a second. The ring is a size container and the square 142cqmax + 4px, wider than the owner's
1325
+ diagonal at any shape, so what the mask shows is the same conic gradient at every angle. Markup:
1326
+ <span class="ocpv2-spin-ring" aria-hidden="true"><i></i></span>, the owner's first child. */
1327
+ .ocpv2-spin-ring {
1328
+ position: absolute;
1329
+ inset: 0;
1330
+ padding: 1px;
1331
+ overflow: hidden;
1332
+ pointer-events: none;
1333
+ container-type: size;
1334
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1335
+ -webkit-mask-composite: xor;
1336
+ mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1337
+ mask-composite: exclude;
1338
+ opacity: 0;
1339
+ transition: opacity 0.4s;
1340
+ }
1341
+ .ocpv2-spin-ring > i {
1342
+ position: absolute;
1343
+ left: 50%;
1344
+ top: 50%;
1345
+ width: calc(142cqmax + 4px);
1346
+ aspect-ratio: 1;
1347
+ translate: -50% -50%;
1348
+ }
1349
+ @keyframes ocpv2-spin-ring-turn {
1350
+ to { rotate: 360deg; }
1351
+ }
1352
+ body.ui-interacting .ocpv2-spin-ring > i { animation-play-state: paused !important; }
1353
+ /* Reduced motion: the ring holds still where the turn starts (the working / focus look stays). */
1354
+ @media (prefers-reduced-motion: reduce) {
1355
+ .ocpv2-spin-ring > i { animation: none !important; will-change: auto !important; }
1316
1356
  }
1317
- @keyframes ocpv2-sb-spin { to { --ocpv2-sb-angle: 360deg; } }
1318
1357
 
1319
1358
  .ocpv2-part-tool.ocpv2-tool-synabun {
1320
1359
  position: relative;
@@ -1329,33 +1368,27 @@ html.desktop-titlebar--win32 .ocpv2-session-pill.ocpv2-pill-running::before {
1329
1368
  .ocpv2-part-tool.ocpv2-tool-synabun[data-status="error"] {
1330
1369
  border-left-color: rgba(220,140,140,0.5);
1331
1370
  }
1332
- .ocpv2-part-tool.ocpv2-tool-synabun::before {
1333
- content: '';
1334
- position: absolute;
1335
- inset: 0;
1371
+ .ocpv2-part-tool.ocpv2-tool-synabun > .ocpv2-spin-ring {
1336
1372
  border-radius: 12px;
1337
- padding: 1px;
1373
+ z-index: 1;
1374
+ }
1375
+ .ocpv2-part-tool.ocpv2-tool-synabun > .ocpv2-spin-ring > i {
1338
1376
  background: conic-gradient(
1339
- from var(--ocpv2-sb-angle, 0deg),
1340
1377
  rgba(232,224,220,0.0) 0%,
1341
1378
  rgba(232,224,220,0.35) 25%,
1342
1379
  rgba(232,224,220,0.06) 50%,
1343
1380
  rgba(232,224,220,0.35) 75%,
1344
1381
  rgba(232,224,220,0.0) 100%
1345
1382
  );
1346
- -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1347
- -webkit-mask-composite: xor;
1348
- mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1349
- mask-composite: exclude;
1350
- opacity: 0;
1351
- transition: opacity 0.4s;
1352
- pointer-events: none;
1353
- z-index: 1;
1354
1383
  }
1355
- .ocpv2-part-tool.ocpv2-tool-synabun[data-status="running"]::before,
1356
- .ocpv2-part-tool.ocpv2-tool-synabun[data-status="pending"]::before {
1384
+ .ocpv2-part-tool.ocpv2-tool-synabun[data-status="running"] > .ocpv2-spin-ring,
1385
+ .ocpv2-part-tool.ocpv2-tool-synabun[data-status="pending"] > .ocpv2-spin-ring {
1357
1386
  opacity: 1;
1358
- animation: ocpv2-sb-spin 3s linear infinite;
1387
+ }
1388
+ .ocpv2-part-tool.ocpv2-tool-synabun[data-status="running"] > .ocpv2-spin-ring > i,
1389
+ .ocpv2-part-tool.ocpv2-tool-synabun[data-status="pending"] > .ocpv2-spin-ring > i {
1390
+ animation: ocpv2-spin-ring-turn 3s linear infinite;
1391
+ will-change: rotate;
1359
1392
  }
1360
1393
 
1361
1394
  .ocpv2-tool-synabun .ocpv2-sb-head {
@@ -2105,15 +2138,7 @@ img.ocpv2-tool-screenshot {
2105
2138
  cursor: zoom-in;
2106
2139
  }
2107
2140
 
2108
- /* Animated conic-gradient border wrapper — cream highlight on focus */
2109
- @property --ocpv2-border-angle {
2110
- syntax: '<angle>';
2111
- initial-value: 0deg;
2112
- inherits: false;
2113
- }
2114
- @keyframes ocpv2-border-spin {
2115
- to { --ocpv2-border-angle: 360deg; }
2116
- }
2141
+ /* Composer wrapper: a cream ring turns inside its edge on focus (the shared .ocpv2-spin-ring) */
2117
2142
  .ocpv2-input-wrap {
2118
2143
  flex: 1;
2119
2144
  position: relative;
@@ -2124,30 +2149,20 @@ img.ocpv2-tool-screenshot {
2124
2149
  min-width: 0;
2125
2150
  overflow: hidden;
2126
2151
  }
2127
- .ocpv2-input-wrap::before {
2128
- content: '';
2129
- position: absolute; inset: 0;
2130
- border-radius: 14px;
2131
- padding: 1px;
2132
- pointer-events: none;
2152
+ .ocpv2-input-wrap > .ocpv2-spin-ring { border-radius: 14px; }
2153
+ .ocpv2-input-wrap > .ocpv2-spin-ring > i {
2133
2154
  background: conic-gradient(
2134
- from var(--ocpv2-border-angle, 0deg),
2135
2155
  rgba(232,224,220,0.0) 0%,
2136
2156
  rgba(232,224,220,0.45) 25%,
2137
2157
  rgba(232,224,220,0.10) 50%,
2138
2158
  rgba(232,224,220,0.45) 75%,
2139
2159
  rgba(232,224,220,0.0) 100%
2140
2160
  );
2141
- -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
2142
- -webkit-mask-composite: xor;
2143
- mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
2144
- mask-composite: exclude;
2145
- opacity: 0;
2146
- transition: opacity 0.4s;
2147
2161
  }
2148
- .ocpv2-input-wrap:focus-within::before {
2149
- opacity: 1;
2150
- animation: ocpv2-border-spin 3s linear infinite;
2162
+ .ocpv2-input-wrap:focus-within > .ocpv2-spin-ring { opacity: 1; }
2163
+ .ocpv2-input-wrap:focus-within > .ocpv2-spin-ring > i {
2164
+ animation: ocpv2-spin-ring-turn 3s linear infinite;
2165
+ will-change: rotate;
2151
2166
  }
2152
2167
  .ocpv2-input-wrap:focus-within {
2153
2168
  background: rgba(232,224,220,0.04);
@@ -3346,7 +3361,8 @@ const PARITY_CSS = `
3346
3361
  background: rgba(232,180,80,0.22);
3347
3362
  box-shadow: 0 0 20px rgba(232,180,80,0.04);
3348
3363
  }
3349
- .ocpv2-input-wrap.ocpv2-shell-mode::before { opacity: 0; animation: none; }
3364
+ .ocpv2-input-wrap.ocpv2-shell-mode > .ocpv2-spin-ring { opacity: 0; }
3365
+ .ocpv2-input-wrap.ocpv2-shell-mode > .ocpv2-spin-ring > i { animation: none; will-change: auto; }
3350
3366
  .ocpv2-input-wrap.ocpv2-shell-mode .ocpv2-input-inner::before {
3351
3367
  content: '!';
3352
3368
  align-self: center;
@@ -0,0 +1,47 @@
1
+ // SynaBun — when storage.js tries a refused flush again. DOM-free (unit-tested under node --test).
2
+ //
3
+ // A failure that may pass by itself (network, 5xx, 408, 429) waits base·2^(attempt-1), capped, with full jitter in
4
+ // the upper half. A 401 / 403 is the server saying this client is not allowed: nothing is retried on a timer until
5
+ // something changes (a new write, the window regaining focus, the network coming back). Unsent values stay queued.
6
+
7
+ export const RETRY_BASE_MS = 1000;
8
+ export const RETRY_CAP_MS = 60_000;
9
+
10
+ /** Whether an HTTP status (or null for a network error) is a refusal that a timer will not fix. */
11
+ export function isAuthRefusal(status) {
12
+ return status === 401 || status === 403;
13
+ }
14
+
15
+ /** The wait before retry number `attempt` (1-based): between half and all of min(cap, base·2^(attempt-1)). */
16
+ export function retryDelay(attempt, { base = RETRY_BASE_MS, cap = RETRY_CAP_MS, random = Math.random } = {}) {
17
+ const n = Math.max(1, Math.floor(Number(attempt) || 1));
18
+ const ceiling = Math.min(cap, base * 2 ** Math.min(n - 1, 30));
19
+ const r = Math.min(1, Math.max(0, Number(random()) || 0));
20
+ return Math.round(ceiling / 2 + r * (ceiling / 2));
21
+ }
22
+
23
+ /**
24
+ * The retry state of one flusher. `fail(status)` → { blocked, delay } (delay null when blocked);
25
+ * `ok()` resets; `poke()` (a write or a reconnect) lifts a block and gives the wait the next try still owes.
26
+ */
27
+ export function createRetryState({ base = RETRY_BASE_MS, cap = RETRY_CAP_MS, random = Math.random, now = () => Date.now() } = {}) {
28
+ let attempt = 0;
29
+ let blocked = false;
30
+ let notBefore = 0;
31
+ return {
32
+ get attempt() { return attempt; },
33
+ get blocked() { return blocked; },
34
+ fail(status) {
35
+ attempt++;
36
+ const delay = retryDelay(attempt, { base, cap, random });
37
+ notBefore = now() + delay;
38
+ blocked = isAuthRefusal(status);
39
+ return { blocked, delay: blocked ? null : delay };
40
+ },
41
+ ok() { attempt = 0; blocked = false; notBefore = 0; },
42
+ /** How long a flush asked for now must still wait (0 when none is owed). Lifts an auth block. */
43
+ poke() { blocked = false; return Math.max(0, notBefore - now()); },
44
+ /** How long a flush must still wait, without lifting a block (Infinity while blocked). */
45
+ wait() { return blocked ? Infinity : Math.max(0, notBefore - now()); },
46
+ };
47
+ }
@@ -1,4 +1,5 @@
1
1
  import { hostedSidepanelId, sidepanelHost } from './ui-sidepanel-runtime.js';
2
+ import { createRetryState } from './storage-retry.js';
2
3
  // ═══════════════════════════════════════════
3
4
  // STORAGE — Server-synced persistent state
4
5
  // ═══════════════════════════════════════════
@@ -19,6 +20,8 @@ let _dirty = {};
19
20
  let _flushTimer = null;
20
21
  let _hydrated = false;
21
22
  const DEBOUNCE_MS = 500;
23
+ // A refused flush is tried again with backoff (storage-retry.js); a 401 / 403 waits for a write or a reconnect.
24
+ const _retry = createRetryState();
22
25
  // Keys that migrated to the per-entry blob store (see blob-store.js and the
23
26
  // server's FAT_KEY_ROUTES). Excluded from hydration so they can never re-enter
24
27
  // the whole-map PATCH cycle.
@@ -129,10 +132,20 @@ export const storage = (hostedSidepanelId && sidepanelHost()?.storageFor(hostedS
129
132
 
130
133
  // ── Debounced flush to server ────────────────────────
131
134
 
132
- function _scheduleDebouncedFlush() {
135
+ /** `fromWrite`: a new write or a reconnect, which lifts a 401 / 403 block (the backoff still applies). */
136
+ function _scheduleDebouncedFlush(fromWrite = true) {
133
137
  if (SHOWCASE_MODE) { _dirty = {}; return; }
138
+ const owed = fromWrite ? _retry.poke() : _retry.wait();
139
+ if (!Number.isFinite(owed)) return; // blocked: values stay in _dirty until something changes
134
140
  if (_flushTimer) clearTimeout(_flushTimer);
135
- _flushTimer = setTimeout(_flushToServer, DEBOUNCE_MS);
141
+ _flushTimer = setTimeout(_flushToServer, Math.max(DEBOUNCE_MS, owed));
142
+ }
143
+
144
+ if (!SHOWCASE_MODE) {
145
+ const _reconnected = () => { if (Object.keys(_dirty).length) _scheduleDebouncedFlush(true); };
146
+ window.addEventListener('online', _reconnected);
147
+ window.addEventListener('focus', _reconnected);
148
+ window.addEventListener('synabun:auth-changed', _reconnected);
136
149
  }
137
150
 
138
151
  async function _flushToServer() {
@@ -150,20 +163,28 @@ async function _flushToServer() {
150
163
  _dirty = {};
151
164
  _flushTimer = null;
152
165
  if (Object.keys(payload).length === 0) return;
166
+ let status = null;
153
167
  try {
154
168
  const res = await fetch('/api/ui-state', {
155
169
  method: 'PATCH',
156
170
  headers: { 'Content-Type': 'application/json' },
157
171
  body: JSON.stringify(payload),
158
172
  });
173
+ status = res.status;
159
174
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
175
+ _retry.ok();
160
176
  } catch (err) {
161
- console.warn('[storage] Flush failed:', err.message, '— will retry');
162
177
  // Re-queue failed keys (don't overwrite newer dirty values)
163
178
  for (const [k, v] of Object.entries(payload)) {
164
179
  if (!(k in _dirty)) _dirty[k] = v;
165
180
  }
166
- _scheduleDebouncedFlush();
181
+ const { blocked, delay } = _retry.fail(status);
182
+ if (blocked) {
183
+ if (_retry.attempt === 1 || _retry.attempt % 20 === 0) console.warn('[storage] Flush refused:', err.message, '— kept; will try again on the next change or reconnect');
184
+ } else {
185
+ if (_retry.attempt <= 3 || _retry.attempt % 20 === 0) console.warn('[storage] Flush failed:', err.message, `— will retry in ${Math.round(delay / 1000)} s`);
186
+ _scheduleDebouncedFlush(false);
187
+ }
167
188
  }
168
189
  }
169
190
 
@@ -16744,7 +16744,9 @@
16744
16744
  border: 1px solid var(--fe-menu-border);
16745
16745
  border-radius: var(--r-card);
16746
16746
  box-shadow: var(--fe-menu-shadow);
16747
- z-index: 300;
16747
+ /* Same tier as .fe-ctx-flyout: a float anchored to the panel, appended to body to escape it, must still
16748
+ outrank what is drawn beside it (#topright-controls at 400) and above it (a floating terminal at 10000). */
16749
+ z-index: 100010;
16748
16750
  padding: 10px;
16749
16751
  opacity: 0;
16750
16752
  transform: translateX(-8px) scale(0.96);
@@ -18574,6 +18576,11 @@
18574
18576
  opacity: 0;
18575
18577
  pointer-events: none;
18576
18578
  }
18579
+ /* Faded out: nothing inside keeps animating (loading-teardown.js adds `gone` once the fade ends). */
18580
+ #loading-overlay.hidden #loading-text,
18581
+ #loading-overlay.hidden #loading-mascot,
18582
+ #loading-overlay.hidden #loading-mascot * { animation: none !important; }
18583
+ #loading-overlay.gone { display: none !important; }
18577
18584
 
18578
18585
  .loading-logo {
18579
18586
  height: 28px;
@@ -90,9 +90,10 @@
90
90
  * - `paintMascot(svg, frame)` — draw a frame on any `mascotSvg()`. A still
91
91
  * frame (`stillFrame`, or a rig that is not looping) draws its prop at
92
92
  * ≥ .7 opacity with strokes of ≥ 1.5 CSS px, so it reads at 28×14.
93
- * - `mascotCameoSvg()` — two 3×7 px pill eyes (`syna-cameo` /
94
- * `syna-cameo-eye`) for running pills, leaning −12° like the two-pill mark
95
- * (never a pause glyph); the host's CSS animates them.
93
+ * - `mascotCameoSvg()` — two 3×7 px pill eyes (`syna-cameo` >
94
+ * `syna-cameo-eyes` > svg > `syna-cameo-eye` ×2) for running pills,
95
+ * leaning −12° like the two-pill mark (never a pause glyph); the host's CSS
96
+ * moves the `syna-cameo-eyes` box (an HTML element, so the compositor runs it).
96
97
  * - `mascotPropSvg(kind, { width, height })` + `paintMascotProp(svg, kind)`
97
98
  * — a kind's prop alone, framed to its own box: the ledger's row glyphs.
98
99
  * - `mascotBounds(pose)` — the face-unit box a pose can draw in (the eyes over
@@ -167,14 +168,18 @@ export function blinkAmount(elapsed) {
167
168
  /**
168
169
  * Two 3×7 px pill eyes for a running pill; the host's CSS moves them. The
169
170
  * pair leans −12° like the two-pill mark (the right eye sits higher), so it
170
- * never reads as a pause glyph. The eyes stay untransformed for the CSS glance.
171
+ * never reads as a pause glyph. What moves is the HTML box around the eyes
172
+ * (`syna-cameo-eyes`), never a part of the SVG: a box's transform runs on
173
+ * the compositor and, when the page draws a frame anyway, restyles one
174
+ * element without laying the SVG out again.
171
175
  */
172
176
  export function mascotCameoSvg({ className = '' } = {}) {
173
- return `<svg class="syna-cameo${className ? ` ${className}` : ''}" viewBox="0 0 10 7" width="10" height="7" style="overflow:visible" aria-hidden="true" focusable="false">`
177
+ return `<span class="syna-cameo${className ? ` ${className}` : ''}" aria-hidden="true"><span class="syna-cameo-eyes">`
178
+ + '<svg viewBox="0 0 10 7" width="10" height="7" style="display:block;overflow:visible" focusable="false">'
174
179
  + '<g class="syna-cameo-pair" transform="rotate(-12 5 3.5)">'
175
180
  + '<rect class="syna-cameo-eye" x="0" y="0" width="3" height="7" rx="1.5" fill="currentColor"/>'
176
181
  + '<rect class="syna-cameo-eye" x="7" y="0" width="3" height="7" rx="1.5" fill="currentColor"/>'
177
- + '</g></svg>';
182
+ + '</g></svg></span></span>';
178
183
  }
179
184
 
180
185
  /** Each prop's own box in face units, to draw it alone (a ledger row's glyph, a suggestion's). */
@@ -1842,6 +1847,14 @@ function createPainter(svg, uid) {
1842
1847
  if (value == null) el.removeAttribute(attr);
1843
1848
  else el.setAttribute(attr, value);
1844
1849
  };
1850
+ /** A prop's writes for this frame: the last value per attribute wins, put() writes it once (paint). */
1851
+ const staged = new Map();
1852
+ const stage = (el, attr, value) => {
1853
+ if (!el) return;
1854
+ let attrs = staged.get(el);
1855
+ if (!attrs) { attrs = new Map(); staged.set(el, attrs); }
1856
+ attrs.set(attr, value);
1857
+ };
1845
1858
  /** CSS px per face unit: the SVG's box over its viewBox (xMidYMid meet: the tighter axis). */
1846
1859
  const unitPx = () => {
1847
1860
  const w = Number(svg.getAttribute?.('width'));
@@ -1898,8 +1911,14 @@ function createPainter(svg, uid) {
1898
1911
  put(entry.g, 'display', null);
1899
1912
  put(entry.g, 'opacity', op < 0.999 ? n(op) : null);
1900
1913
  put(entry.g, 'transform', p.dx || p.dy ? `translate(${n(p.dx || 0)},${n(p.dy || 0)})` : null);
1901
- if (!f.still) for (const b of entry.base) put(b.el, b.attr, b.value); // live: the drawn values
1902
- PROPS[name].update(entry.r, p, put);
1914
+ // Live: the drawn values, then the frame's own over them. Both are staged and each attribute is
1915
+ // written once with its final value, so a frame that changes nothing writes nothing (an attribute
1916
+ // written twice a frame, back to its base and on again, restyles and re-lays the SVG out every frame).
1917
+ staged.clear();
1918
+ if (!f.still) for (const b of entry.base) stage(b.el, b.attr, b.value);
1919
+ PROPS[name].update(entry.r, p, stage);
1920
+ for (const [el, attrs] of staged) for (const [attr, value] of attrs) put(el, attr, value);
1921
+ staged.clear();
1903
1922
  if (f.still) legible(entry);
1904
1923
  }
1905
1924
  }