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
@@ -9,6 +9,18 @@
9
9
  // requestFrames(ms) lifts the animation subscribers (≥ 24 fps) to 60 fps for a
10
10
  // moment: a pose blend, a hop.
11
11
  //
12
+ // A frame is only requested for the vsync a subscriber is due on: a 30 fps
13
+ // rig costs 30 frames a second, not one per vsync. Every frame the page draws
14
+ // makes the browser restyle each running CSS animation on the main thread
15
+ // (composited or not), so a frame nobody needs is never free. Between due
16
+ // frames the loop sleeps on a timer that wakes half a frame before the vsync
17
+ // it wants; the frame interval is learnt from back-to-back frames (60 Hz,
18
+ // 120 Hz displays).
19
+ //
20
+ // A subscriber may name a `target` element: it runs only while that element
21
+ // is on screen (one shared IntersectionObserver), and a loop whose every
22
+ // subscriber is off screen requests nothing at all until one comes back.
23
+ //
12
24
  // The loop stops while the tab is hidden (document.hidden, body.app-hidden)
13
25
  // and freezes while the UI is dragged (body.ui-interacting): subscribers are
14
26
  // skipped, and the first frame after either hands each one a dt of at most
@@ -20,7 +32,7 @@
20
32
  // pointermove listener and one keydown listener (both capture phase, so a
21
33
  // handler that stops propagation cannot hide input from them).
22
34
  //
23
- // subscribe(fn(now, dt), { fps }) → unsubscribe() (unsubscribe.setFps(n) re-caps)
35
+ // subscribe(fn(now, dt), { fps, target }) → unsubscribe() (unsubscribe.setFps(n) re-caps)
24
36
  // requestFrames(ms) 60 fps for the next ms
25
37
  // isReducedMotion() live prefers-reduced-motion
26
38
  // onReducedMotionChange(fn(on)) → unsubscribe()
@@ -41,8 +53,10 @@
41
53
  const FRAME_60 = 1000 / 60;
42
54
  const SLOP = 4; // ms of rAF jitter a capped subscriber tolerates before it skips a frame
43
55
  const RESUME_DT = 50; // the first dt after a stop or a freeze is at most this
44
- const SLEEP_OVER = 48; // a wait longer than this sleeps on a timer instead of frames
45
56
  const POLL_MS = 250; // how often a stopped loop re-checks when no MutationObserver exists
57
+ const PHASE_MS = 1500; // a frame newer than this still tells where the vsyncs fall
58
+ const MIN_FRAME = 4; // ms: the shortest frame interval believed (240 Hz)
59
+ const MAX_FRAME = 34; // ms: the longest (30 Hz)
46
60
  const MAX_BOOST = 5000;
47
61
  const LISTEN = { passive: true, capture: true };
48
62
 
@@ -84,10 +98,73 @@ let pollId = 0;
84
98
  let boostUntil = -Infinity;
85
99
  let watching = false;
86
100
  let observer = null;
101
+ let frameMs = FRAME_60; // the display's frame interval, as back-to-back frames show it
102
+ let lastTs = -Infinity; // the last frame's timestamp: where the vsyncs fall
103
+ let inFrame = false; // frame() is running
104
+ let chained = false; // the frame in flight was requested by the previous one, with no wait between
105
+ const counts = { frames: 0, requests: 0, sleeps: 0 }; // for tests and the idle-CPU suite
87
106
 
88
107
  const capFps = fps => Math.max(1, Math.min(60, Number(fps) > 0 ? Number(fps) : 30));
89
108
  const boosted = (sub, t) => t < boostUntil && sub.fps >= 24;
90
109
  const intervalOf = (sub, t) => (boosted(sub, t) ? FRAME_60 : 1000 / sub.fps);
110
+ /** A subscriber with a target off screen is parked: it is neither called nor waited for. */
111
+ const parked = sub => sub.target && !sub.seen;
112
+
113
+ // ── Targets: one IntersectionObserver for every subscriber that names an element ──
114
+
115
+ let targetIo = null; // undefined while unavailable
116
+ const targetSubs = new Map(); // element → Set of subs
117
+ const targetSeen = new Map(); // element → on screen as last reported (absent until the first report)
118
+
119
+ function targetObserver() {
120
+ if (targetIo !== null) return targetIo;
121
+ const IO = env ? env.IntersectionObserver : globalThis.IntersectionObserver;
122
+ if (typeof IO !== 'function') { targetIo = undefined; return targetIo; }
123
+ try {
124
+ targetIo = new IO((entries) => {
125
+ let woke = false;
126
+ for (const entry of entries) {
127
+ const set = targetSubs.get(entry.target);
128
+ if (!set) continue;
129
+ const on = !!entry.isIntersecting;
130
+ targetSeen.set(entry.target, on);
131
+ for (const sub of set) {
132
+ if (sub.seen === on) continue;
133
+ sub.seen = on;
134
+ if (on) { sub.resume = true; woke = true; }
135
+ }
136
+ }
137
+ if (woke && subs.size) wakeNow();
138
+ });
139
+ } catch { targetIo = undefined; }
140
+ return targetIo;
141
+ }
142
+
143
+ function watchTarget(sub) {
144
+ const el = sub.target;
145
+ const io = targetObserver();
146
+ if (!io) { sub.target = null; return; } // no observer: the target counts as on screen
147
+ let set = targetSubs.get(el);
148
+ if (!set) {
149
+ set = new Set();
150
+ targetSubs.set(el, set);
151
+ try { io.observe(el); } catch { targetSubs.delete(el); sub.target = null; return; }
152
+ }
153
+ set.add(sub);
154
+ // Until the observer's first report the target counts as on screen (it draws at once, as before).
155
+ sub.seen = targetSeen.has(el) ? targetSeen.get(el) : true;
156
+ }
157
+
158
+ function unwatchTarget(sub) {
159
+ const el = sub.target;
160
+ const set = el ? targetSubs.get(el) : null;
161
+ if (!set) return;
162
+ set.delete(sub);
163
+ if (set.size) return;
164
+ targetSubs.delete(el);
165
+ targetSeen.delete(el);
166
+ try { targetIo?.unobserve(el); } catch { /* already gone */ }
167
+ }
91
168
 
92
169
  /** Why the loop is not running: '' (it runs), 'hidden' or 'interacting'. */
93
170
  export function tickerPaused() {
@@ -102,17 +179,20 @@ export function tickerPaused() {
102
179
 
103
180
  /**
104
181
  * Call `fn(now, dt)` from the shared loop, at most `fps` times a second
105
- * (1-60, default 30). Returns unsubscribe(); unsubscribe.setFps(n) changes the
106
- * cap in place.
182
+ * (1-60, default 30). With `target` (an element) it is called only while that
183
+ * element is on screen. Returns unsubscribe(); unsubscribe.setFps(n) changes
184
+ * the cap in place.
107
185
  */
108
- export function subscribe(fn, { fps = 30 } = {}) {
186
+ export function subscribe(fn, { fps = 30, target = null } = {}) {
109
187
  if (typeof fn !== 'function') return Object.assign(() => {}, { setFps() {} });
110
- const sub = { fn, fps: capFps(fps), last: -1, born: now(), resume: false };
188
+ const sub = { fn, fps: capFps(fps), last: -1, born: now(), resume: false, target: target && typeof target === 'object' ? target : null, seen: true };
111
189
  subs.add(sub);
190
+ if (sub.target) watchTarget(sub);
112
191
  watch();
113
192
  wakeNow();
114
193
  const unsubscribe = () => {
115
194
  if (!subs.delete(sub)) return;
195
+ unwatchTarget(sub);
116
196
  if (!subs.size) halt();
117
197
  };
118
198
  unsubscribe.setFps = (next) => {
@@ -138,31 +218,71 @@ function wakeNow() {
138
218
  schedule();
139
219
  }
140
220
 
221
+ /**
222
+ * Request the next frame a subscriber is due on, and no other: when the next
223
+ * vsync serves it, at once; otherwise a timer wakes half a frame before the
224
+ * vsync it wants. Nothing at all while every subscriber is parked.
225
+ */
141
226
  function schedule() {
142
227
  if (rafId || timerId || !subs.size) return;
143
228
  const raf = rafFn();
144
229
  if (!raf) return;
145
230
  if (tickerPaused()) { stopped(); return; }
146
231
  const t = now();
147
- let wait = Infinity;
232
+ let due = Infinity;
148
233
  for (const sub of subs) {
149
- const due = sub.last < 0 ? 0 : sub.last + intervalOf(sub, t) - SLOP - t;
150
- if (due < wait) wait = due;
234
+ if (parked(sub)) continue;
235
+ const at = sub.last < 0 ? t : sub.last + intervalOf(sub, t) - SLOP;
236
+ if (at < due) due = at;
151
237
  }
152
- if (wait > SLEEP_OVER) {
153
- timerId = later(() => { timerId = 0; schedule(); }, wait - FRAME_60);
238
+ if (!Number.isFinite(due)) return; // every subscriber is off screen: the observer wakes the loop
239
+ const phase = lastTs > -Infinity && t - lastTs < PHASE_MS ? lastTs : null;
240
+ let lead;
241
+ if (due <= t) lead = 0;
242
+ else if (phase === null) lead = due - t - frameMs; // no recent vsync to count from: the frame before `due`
243
+ else {
244
+ const k = Math.max(1, Math.ceil((due - phase) / frameMs - 1e-6));
245
+ lead = k === 1 ? 0 : phase + (k - 0.5) * frameMs - t; // half a frame before the vsync that serves `due`
246
+ }
247
+ if (lead > 1) {
248
+ counts.sleeps += 1;
249
+ timerId = later(() => { timerId = 0; schedule(); }, lead);
154
250
  return;
155
251
  }
252
+ counts.requests += 1;
253
+ chained = inFrame;
156
254
  rafId = raf(frame);
157
255
  }
158
256
 
257
+ /** Learn the display's frame interval from two frames requested back to back. */
258
+ function learnInterval(ts) {
259
+ if (!chained || !(lastTs > -Infinity)) return;
260
+ const d = ts - lastTs;
261
+ if (!(d >= MIN_FRAME && d <= MAX_FRAME)) return;
262
+ // Snap down to the shortest seen; drift back up slowly (a dropped frame is two, not a slower display).
263
+ frameMs = Math.min(d, Math.min(MAX_FRAME, frameMs + 0.05));
264
+ }
265
+
159
266
  function frame(ts) {
160
267
  rafId = 0;
161
268
  if (!subs.size) return;
162
269
  if (tickerPaused()) { stopped(); return; }
163
270
  const t = typeof ts === 'number' && Number.isFinite(ts) ? ts : now();
271
+ counts.frames += 1;
272
+ learnInterval(t);
273
+ lastTs = t;
274
+ chained = false;
275
+ inFrame = true;
276
+ try {
277
+ runSubscribers(t);
278
+ schedule(); // inside the frame: a frame requested from here is the next vsync's (it teaches the interval)
279
+ } finally { inFrame = false; }
280
+ }
281
+
282
+ function runSubscribers(t) {
164
283
  for (const sub of Array.from(subs)) {
165
284
  if (!subs.has(sub)) continue; // unsubscribed by an earlier subscriber this frame
285
+ if (parked(sub)) continue;
166
286
  const interval = intervalOf(sub, t);
167
287
  if (sub.last >= 0 && t - sub.last < interval - SLOP) continue;
168
288
  let dt = t - (sub.last >= 0 ? sub.last : sub.born);
@@ -172,7 +292,6 @@ function frame(ts) {
172
292
  sub.last = t;
173
293
  try { sub.fn(t, dt); } catch (err) { report(err); }
174
294
  }
175
- schedule();
176
295
  }
177
296
 
178
297
  /** The loop stopped (hidden) or froze (drag): mark the jump, wait for the watchers. */
@@ -334,6 +453,17 @@ function reset() {
334
453
  halt();
335
454
  subs.clear();
336
455
  boostUntil = -Infinity;
456
+ try { targetIo?.disconnect(); } catch { /* gone */ }
457
+ targetIo = null;
458
+ targetSubs.clear();
459
+ targetSeen.clear();
460
+ frameMs = FRAME_60;
461
+ lastTs = -Infinity;
462
+ inFrame = false;
463
+ chained = false;
464
+ counts.frames = 0;
465
+ counts.requests = 0;
466
+ counts.sleeps = 0;
337
467
  const q = motionQuery;
338
468
  if (q && motionFns.size) {
339
469
  if (q.removeEventListener) q.removeEventListener('change', onMotionQuery);
@@ -372,5 +502,10 @@ export const __ticker = {
372
502
  keyListeners: keyFns.size,
373
503
  keyAttached: keyOn,
374
504
  motionListeners: motionFns.size,
505
+ parked: Array.from(subs).filter(parked).length,
506
+ targets: targetSubs.size,
507
+ frameMs,
375
508
  }),
509
+ /** Frames run, frames requested and timer sleeps since the last reset (the idle-CPU suite reads them). */
510
+ counts: () => ({ ...counts }),
376
511
  };
@@ -419,6 +419,7 @@ function buildPanel() {
419
419
  </div>
420
420
  <div class="cp-input-area">
421
421
  <div class="cp-input-wrap">
422
+ <span class="cp-spin-ring" aria-hidden="true"><i></i></span>
422
423
  <div class="cp-image-preview" id="cp-image-preview"></div>
423
424
  <div class="cp-input-inner">
424
425
  <textarea class="cp-input" id="cp-input" placeholder="Message SynaBun..." rows="1" autocomplete="off" spellcheck="false"></textarea>
@@ -1061,26 +1062,17 @@ function injectStyles() {
1061
1062
  margin-top: 8px; transition: opacity 0.3s, border-color 0.2s, box-shadow 0.3s;
1062
1063
  }
1063
1064
  .perm-card { position: relative; }
1064
- .perm-card::before {
1065
- content: '';
1066
- position: absolute; inset: 0;
1067
- border-radius: 8px;
1068
- padding: 1px;
1069
- pointer-events: none;
1065
+ /* A card that waits on you turns an amber ring inside its edge: the shared .cp-spin-ring (with the
1066
+ composer's, below), turning only while the card is .active-perm. */
1067
+ .perm-card > .cp-spin-ring { border-radius: 8px; }
1068
+ .perm-card > .cp-spin-ring > i {
1070
1069
  background: conic-gradient(
1071
- from var(--cp-border-angle, 0deg),
1072
1070
  rgba(245,180,60,0.0) 0%,
1073
1071
  rgba(245,180,60,0.55) 25%,
1074
1072
  rgba(245,180,60,0.1) 50%,
1075
1073
  rgba(245,180,60,0.55) 75%,
1076
1074
  rgba(245,180,60,0.0) 100%
1077
1075
  );
1078
- -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1079
- -webkit-mask-composite: xor;
1080
- mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1081
- mask-composite: exclude;
1082
- opacity: 0;
1083
- transition: opacity 0.4s;
1084
1076
  }
1085
1077
  .perm-card.active-perm {
1086
1078
  border-color: rgba(245,180,60,0.25);
@@ -1088,9 +1080,10 @@ function injectStyles() {
1088
1080
  box-shadow: 0 0 16px rgba(245,180,60,0.08), 0 0 40px rgba(245,180,60,0.04);
1089
1081
  animation: cp-perm-glow 2.5s ease-in-out infinite;
1090
1082
  }
1091
- .perm-card.active-perm::before {
1092
- opacity: 1;
1093
- animation: cp-border-spin 3s linear infinite;
1083
+ .perm-card.active-perm > .cp-spin-ring { opacity: 1; }
1084
+ .perm-card.active-perm > .cp-spin-ring > i {
1085
+ animation: cp-spin-ring-turn 3s linear infinite;
1086
+ will-change: rotate;
1094
1087
  }
1095
1088
  .perm-card:hover { border-color: var(--b-light); }
1096
1089
  .perm-card.active-perm:hover { border-color: rgba(245,180,60,0.4); }
@@ -1234,47 +1227,64 @@ function injectStyles() {
1234
1227
  transition: background 0.4s;
1235
1228
  min-width: 0;
1236
1229
  }
1237
- .cp-input-wrap::before {
1238
- content: '';
1230
+ /* ── Spinning border ring (composer focus, a waiting permission card) ──
1231
+ A conic gradient turning inside the 1px edge, the mask keeping only the ring. The gradient is a
1232
+ square box that turns (rotate, run by the compositor) under the mask, and only while its owner's
1233
+ state is on; an animated custom property (the old --cp-border-angle, @property + a conic gradient
1234
+ from it) restyled and repainted the owner on the main thread 60 times a second. The ring is a size
1235
+ container and the square 142cqmax + 4px, wider than the owner's diagonal at any shape, so what
1236
+ the mask shows is the same conic gradient at every angle. Markup: <span class="cp-spin-ring"
1237
+ aria-hidden="true"><i></i></span>, the owner's first child. */
1238
+ .cp-spin-ring {
1239
1239
  position: absolute; inset: 0;
1240
- border-radius: 14px;
1241
1240
  padding: 1px;
1241
+ overflow: hidden;
1242
1242
  pointer-events: none;
1243
+ container-type: size;
1244
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1245
+ -webkit-mask-composite: xor;
1246
+ mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1247
+ mask-composite: exclude;
1248
+ opacity: 0;
1249
+ transition: opacity 0.4s;
1250
+ }
1251
+ .cp-spin-ring > i {
1252
+ position: absolute; left: 50%; top: 50%;
1253
+ width: calc(142cqmax + 4px);
1254
+ aspect-ratio: 1;
1255
+ translate: -50% -50%;
1256
+ }
1257
+ @keyframes cp-spin-ring-turn {
1258
+ to { rotate: 360deg; }
1259
+ }
1260
+ body.ui-interacting .cp-spin-ring > i { animation-play-state: paused !important; }
1261
+ /* Reduced motion: the ring holds still where the turn starts (the focus / waiting look stays). */
1262
+ @media (prefers-reduced-motion: reduce) {
1263
+ .cp-spin-ring > i { animation: none !important; will-change: auto !important; }
1264
+ }
1265
+ .cp-input-wrap > .cp-spin-ring { border-radius: 14px; }
1266
+ .cp-input-wrap > .cp-spin-ring > i {
1243
1267
  background: conic-gradient(
1244
- from var(--cp-border-angle, 0deg),
1245
1268
  rgba(212,162,127,0.0) 0%,
1246
1269
  rgba(212,162,127,0.35) 25%,
1247
1270
  rgba(212,162,127,0.10) 50%,
1248
1271
  rgba(212,162,127,0.35) 75%,
1249
1272
  rgba(212,162,127,0.0) 100%
1250
1273
  );
1251
- -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1252
- -webkit-mask-composite: xor;
1253
- mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
1254
- mask-composite: exclude;
1255
- opacity: 0;
1256
- transition: opacity 0.4s;
1257
1274
  }
1258
- .cp-input-wrap:focus-within::before {
1259
- opacity: 1;
1260
- animation: cp-border-spin 3s linear infinite;
1275
+ .cp-input-wrap:focus-within > .cp-spin-ring { opacity: 1; }
1276
+ .cp-input-wrap:focus-within > .cp-spin-ring > i {
1277
+ animation: cp-spin-ring-turn 3s linear infinite;
1278
+ will-change: rotate;
1261
1279
  }
1262
1280
  .cp-input-wrap:focus-within {
1263
1281
  background: rgba(212,162,127,0.03);
1264
1282
  box-shadow: 0 0 20px rgba(212,162,127,0.04), 0 0 60px rgba(212,162,127,0.015);
1265
1283
  }
1266
- @keyframes cp-border-spin {
1267
- to { --cp-border-angle: 360deg; }
1268
- }
1269
1284
  @keyframes cp-perm-glow {
1270
1285
  0%, 100% { box-shadow: 0 0 16px rgba(245,180,60,0.08), 0 0 40px rgba(245,180,60,0.04); }
1271
1286
  50% { box-shadow: 0 0 20px rgba(245,180,60,0.14), 0 0 50px rgba(245,180,60,0.07); }
1272
1287
  }
1273
- @property --cp-border-angle {
1274
- syntax: '<angle>';
1275
- initial-value: 0deg;
1276
- inherits: false;
1277
- }
1278
1288
 
1279
1289
  .cp-input-inner {
1280
1290
  display: flex; align-items: flex-end;
@@ -2301,52 +2311,53 @@ function injectStyles() {
2301
2311
  }
2302
2312
 
2303
2313
  /* ── Claude session pills in shared tray ── */
2304
- @property --cp-pill-angle {
2305
- syntax: '<angle>';
2306
- initial-value: 0deg;
2307
- inherits: false;
2308
- }
2309
2314
  .cp-session-pill {
2310
2315
  position: relative;
2311
2316
  overflow: hidden;
2312
2317
  border-color: rgba(212, 162, 127, 0.14);
2313
2318
  transition: border-color 0.2s;
2314
2319
  }
2315
- .cp-session-pill::before {
2316
- content: '';
2320
+ /* A working pill's ring: a conic gradient turning inside the 1px border (the mask keeps only the
2321
+ ring). The gradient is a square box that turns (rotate, run by the compositor) under the mask; an
2322
+ animated custom property (the old --cp-pill-angle) repainted the pill on the main thread every
2323
+ frame for as long as a session worked. The square is wider than the pill's diagonal, so the ring
2324
+ is the same conic gradient at every angle. */
2325
+ .cp-pill-ring {
2317
2326
  position: absolute; inset: 0;
2327
+ display: none;
2318
2328
  border-radius: 8px;
2319
2329
  padding: 1px;
2330
+ overflow: hidden;
2320
2331
  pointer-events: none;
2321
- background: transparent;
2322
2332
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
2323
2333
  -webkit-mask-composite: xor;
2324
2334
  mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
2325
2335
  mask-composite: exclude;
2326
- opacity: 0;
2327
- }
2328
- .cp-session-pill.cp-pill-running {
2329
- border-color: transparent;
2330
2336
  }
2331
- .cp-session-pill.cp-pill-running::before {
2337
+ .cp-pill-ring > i {
2338
+ position: absolute; left: 50%; top: 50%;
2339
+ width: calc(100% + 32px);
2340
+ aspect-ratio: 1;
2341
+ translate: -50% -50%;
2332
2342
  background: conic-gradient(
2333
- from var(--cp-pill-angle, 0deg),
2334
2343
  rgba(212,162,127,0.0) 0%,
2335
2344
  rgba(212,162,127,0.6) 25%,
2336
2345
  rgba(212,162,127,0.12) 50%,
2337
2346
  rgba(212,162,127,0.6) 75%,
2338
2347
  rgba(212,162,127,0.0) 100%
2339
2348
  );
2340
- animation: cp-pill-border-spin 2.4s linear infinite;
2341
- opacity: 1;
2349
+ animation: cp-pill-ring-turn 2.4s linear infinite;
2342
2350
  }
2343
- @keyframes cp-pill-border-spin {
2344
- to { --cp-pill-angle: 360deg; }
2351
+ .cp-session-pill.cp-pill-running {
2352
+ border-color: transparent;
2353
+ }
2354
+ .cp-session-pill.cp-pill-running .cp-pill-ring { display: block; }
2355
+ @keyframes cp-pill-ring-turn {
2356
+ to { rotate: 360deg; }
2345
2357
  }
2346
2358
  .cp-session-pill:hover { border-color: rgba(212, 162, 127, 0.28); }
2347
- .cp-session-pill:hover.cp-pill-running::before {
2359
+ .cp-session-pill:hover .cp-pill-ring > i {
2348
2360
  background: conic-gradient(
2349
- from var(--cp-pill-angle, 0deg),
2350
2361
  rgba(212,162,127,0.0) 0%,
2351
2362
  rgba(212,162,127,0.8) 25%,
2352
2363
  rgba(212,162,127,0.20) 50%,
@@ -2362,11 +2373,11 @@ function injectStyles() {
2362
2373
  html.desktop-titlebar--win32 .cp-session-pill.cp-pill-running:hover {
2363
2374
  border-color: rgba(212, 162, 127, 0.62);
2364
2375
  }
2365
- html.platform-win32 .cp-session-pill.cp-pill-running::before,
2366
- html.desktop-titlebar--win32 .cp-session-pill.cp-pill-running::before {
2367
- animation: none;
2368
- opacity: 0;
2376
+ html.platform-win32 .cp-session-pill.cp-pill-running .cp-pill-ring,
2377
+ html.desktop-titlebar--win32 .cp-session-pill.cp-pill-running .cp-pill-ring {
2378
+ display: none;
2369
2379
  }
2380
+ body.ui-interacting .cp-pill-ring > i { animation-play-state: paused; }
2370
2381
  .cp-session-pill .term-minimized-pill-icon { color: rgba(212, 162, 127, 0.45); transition: color 0.2s; }
2371
2382
  .cp-session-pill.cp-pill-running .term-minimized-pill-icon { color: rgba(212, 162, 127, 1); }
2372
2383
  .cp-session-pill:hover .term-minimized-pill-icon { color: rgba(212, 162, 127, 0.85); }
@@ -2401,6 +2412,12 @@ function injectStyles() {
2401
2412
  pointer-events: none;
2402
2413
  }
2403
2414
  @keyframes cp-pill-pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
2415
+ /* Reduced motion: the ring, the dot and the edge hold still (the working look stays). */
2416
+ @media (prefers-reduced-motion: reduce) {
2417
+ .cp-pill-ring > i,
2418
+ .cp-session-pill.cp-pill-running .term-minimized-pill-label::before,
2419
+ .cp-session-pill.cp-pill-running::after { animation: none; }
2420
+ }
2404
2421
 
2405
2422
  /* ── Think intensity toggle ── */
2406
2423
  /* ── Shared toggle base ── */
@@ -3238,6 +3255,7 @@ function _createTrayPill(tab) {
3238
3255
  pill.className = 'term-minimized-pill cp-session-pill';
3239
3256
  pill.dataset.tabId = tab.id;
3240
3257
  pill.innerHTML = `
3258
+ <span class="cp-pill-ring" aria-hidden="true"><i></i></span>
3241
3259
  <span class="term-minimized-pill-icon">${_CP_ICON_SVG}</span>
3242
3260
  <span class="term-minimized-pill-label">${escH(tab.label)}</span>
3243
3261
  <button class="term-minimized-pill-close" data-tooltip="Close" data-tooltip-pos="top">&times;</button>
@@ -7,6 +7,7 @@
7
7
  import { fetchHealth, startHealth } from './api.js';
8
8
  import { emit, on } from './state.js';
9
9
  import { t } from './i18n.js';
10
+ import { concealOverlay, revealOverlay } from './loading-teardown.js';
10
11
  import {
11
12
  START_LIMITS, START_STORAGE, createStartBridge, fetchStartBeacon, isMacPlatform, isWindowsPlatform,
12
13
  manualStartCommands, openStartLink, startBeaconTarget, startButtonUsable, startStatusView,
@@ -127,7 +128,7 @@ export function showLoadingError(title, sub, canStart, serverOffline) {
127
128
  */
128
129
  export function hideLoading(delay = 400) {
129
130
  const $overlay = $('loading-overlay');
130
- if ($overlay) setTimeout(() => $overlay.classList.add('hidden'), delay);
131
+ if ($overlay) setTimeout(() => concealOverlay($overlay), delay);
131
132
  }
132
133
 
133
134
  /**
@@ -135,7 +136,7 @@ export function hideLoading(delay = 400) {
135
136
  */
136
137
  export function showLoading() {
137
138
  const $overlay = $('loading-overlay');
138
- if ($overlay) $overlay.classList.remove('hidden');
139
+ if ($overlay) revealOverlay($overlay);
139
140
  }
140
141
 
141
142
  /**
@@ -479,7 +480,7 @@ function beginRestart({ request = null, announced = false, boot = '' } = {}) {
479
480
  _offlineTitle = t('loading.serverOffline');
480
481
  // `over-app`: no fade-in and above whatever is open. The screen is there in the frame of the click.
481
482
  $overlay.classList.add('over-app', 'error', 'server-offline');
482
- $overlay.classList.remove('hidden');
483
+ revealOverlay($overlay);
483
484
  // Nothing behind the screen keeps the keyboard (a terminal, an editor).
484
485
  try { document.activeElement?.blur?.(); } catch {}
485
486
  if (_statusDot) _statusDot.classList.add('error');
@@ -503,7 +504,7 @@ function dismissRestart() {
503
504
  if (_restartTitle !== null) document.title = _restartTitle;
504
505
  _restartTitle = null;
505
506
  const $overlay = $('loading-overlay');
506
- if ($overlay) $overlay.classList.add('hidden');
507
+ if ($overlay) concealOverlay($overlay);
507
508
  }
508
509
 
509
510
  // ═══════════════════════════════════════════