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.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -1
- package/neural-interface/i18n/en.json +3 -0
- package/neural-interface/i18n/pt-BR.json +3 -0
- package/neural-interface/lib/assistant-brains/codex.js +6 -1
- package/neural-interface/lib/assistant-dispatch.js +4 -1
- package/neural-interface/lib/mcp-external-servers.js +27 -3
- package/neural-interface/lib/native-loop-providers.js +3 -2
- package/neural-interface/lib/opencode-native-boundary.js +155 -21
- package/neural-interface/lib/peer-identity.js +49 -5
- package/neural-interface/package-lock.json +2 -2
- package/neural-interface/package.json +1 -1
- package/neural-interface/public/shared/assistant/asst-api.js +14 -2
- package/neural-interface/public/shared/assistant/asst-computer.js +26 -2
- package/neural-interface/public/shared/assistant/asst-motion-gate.js +80 -0
- package/neural-interface/public/shared/assistant/asst-panel.js +1 -1
- package/neural-interface/public/shared/assistant/asst-render.js +62 -7
- package/neural-interface/public/shared/assistant/asst-scene-styles.js +55 -23
- package/neural-interface/public/shared/assistant/asst-scenes.js +225 -79
- package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +4 -4
- package/neural-interface/public/shared/assistant/asst-style-cache.js +44 -0
- package/neural-interface/public/shared/assistant/asst-styles.js +24 -4
- package/neural-interface/public/shared/cp/cp-permissions.js +12 -0
- package/neural-interface/public/shared/loading-teardown.js +34 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +6 -1
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +1 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +10 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +6 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +88 -72
- package/neural-interface/public/shared/storage-retry.js +47 -0
- package/neural-interface/public/shared/storage.js +25 -4
- package/neural-interface/public/shared/styles.css +8 -1
- package/neural-interface/public/shared/synabun-mascot.js +27 -8
- package/neural-interface/public/shared/synabun-ticker.js +147 -12
- package/neural-interface/public/shared/ui-claude-panel.js +79 -61
- package/neural-interface/public/shared/ui-loading.js +5 -4
- package/neural-interface/server.js +87 -40
- package/npm-shrinkwrap.json +2 -2
- 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
|
-
|
|
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">×</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
|
-
|
|
427
|
-
|
|
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-
|
|
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-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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-
|
|
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
|
|
480
|
-
html.desktop-titlebar--win32 .ocpv2-session-pill.ocpv2-pill-running
|
|
481
|
-
|
|
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
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
|
1333
|
-
content: '';
|
|
1334
|
-
position: absolute;
|
|
1335
|
-
inset: 0;
|
|
1371
|
+
.ocpv2-part-tool.ocpv2-tool-synabun > .ocpv2-spin-ring {
|
|
1336
1372
|
border-radius: 12px;
|
|
1337
|
-
|
|
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"]
|
|
1356
|
-
.ocpv2-part-tool.ocpv2-tool-synabun[data-status="pending"]
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
|
2128
|
-
|
|
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
|
|
2149
|
-
|
|
2150
|
-
animation: ocpv2-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
95
|
-
* (never a pause glyph); the host's CSS
|
|
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.
|
|
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 `<
|
|
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
|
-
|
|
1902
|
-
|
|
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
|
}
|