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
@@ -400,12 +400,77 @@ export function stageLabel({ scene = 'generic', phase = 'working', title = '', m
400
400
  // currentColor (the container's muted text); `sc-gold` is the one live part
401
401
  // (gold only means live). Every part sits where its still frame wants it:
402
402
  // with no animation (reduced motion, waiting, settled) the scene still reads.
403
+ //
404
+ // A scene is an HTML box (`.asst-scene`, sized by its holder) holding one SVG
405
+ // of everything that stands still (`.sc-base`) and, for every part that
406
+ // moves, an HTML layer of its own (`.sc-layer`: a box over the whole scene
407
+ // with an SVG of that part alone in the same viewBox, so the part sits
408
+ // exactly where it did). The CSS moves the layer box, transform and opacity
409
+ // only, never an SVG element: a box's animation runs on the compositor, and a
410
+ // frame the page draws anyway (a rig's) restyles one span and lays nothing
411
+ // out, where a moving SVG part re-lays its whole SVG out on every frame.
412
+ // Lengths are percentages of the box, since a scene is drawn at several sizes
413
+ // (boxPct); a part that turned round its own centre (the SVG's fill-box)
414
+ // turns round the same point here (`transform-origin`). Parts on the same
415
+ // beat share a layer (the image's tiles); a part that stood above a moving
416
+ // one stays above it in a still layer of its own (`.sc-top`: the playhead).
403
417
 
404
418
  /** The code scene's six lines, repeated twice so its stepped scroll loops seamlessly: [indent, keyword, rest]. */
405
419
  export const CODE_LINES = Object.freeze([[6, 8, 26], [12, 10, 42], [12, 6, 22], [18, 9, 35], [12, 12, 48], [6, 7, 14]]);
406
420
  export const CODE_STEP = 6; // one line, in viewBox units
421
+ /** The code scene's scrolling window and the video scene's film window (full) and the mini film's: [x, y, w, h]. */
422
+ export const CODE_WINDOW = Object.freeze([4, 4, 112, 32]);
423
+ export const FILM_WINDOW = Object.freeze([4, 4, 112, 32]);
424
+ export const FILM_MINI_WINDOW = Object.freeze([2, 2, 36, 16]);
425
+
426
+ /** The viewBox of a full scene and of a mini, in units. */
427
+ export const SCENE_BOX = Object.freeze({ full: Object.freeze({ w: 120, h: 40 }), mini: Object.freeze({ w: 40, h: 20 }) });
407
428
 
408
429
  const r1 = (v) => Math.round(v * 100) / 100;
430
+ const r3 = (v) => Math.round(v * 1000) / 1000;
431
+
432
+ /** A length in viewBox units as a percentage of the box's width or height: boxPct(14, 120) → '11.667%'. */
433
+ export function boxPct(v, of) {
434
+ return `${r3((Number(v) / of) * 100)}%`;
435
+ }
436
+
437
+ /**
438
+ * The centre of a polyline path's box (M L H V Z, absolute or relative):
439
+ * where an SVG part with transform-box: fill-box turned round (a tick, the
440
+ * zap, a triangle). Curves are not read (no moving part has one).
441
+ */
442
+ export function pathCentre(d) {
443
+ const tokens = String(d).match(/[MLHVZmlhvz]|-?\d*\.?\d+/g) || [];
444
+ const xs = [];
445
+ const ys = [];
446
+ let x = 0;
447
+ let y = 0;
448
+ let sx = 0;
449
+ let sy = 0;
450
+ let cmd = 'M';
451
+ let i = 0;
452
+ const num = () => Number(tokens[i++]);
453
+ while (i < tokens.length) {
454
+ if (/^[A-Za-z]$/.test(tokens[i])) {
455
+ cmd = tokens[i];
456
+ i += 1;
457
+ if (cmd === 'Z' || cmd === 'z') { x = sx; y = sy; continue; }
458
+ }
459
+ if (cmd === 'M') { x = num(); y = num(); sx = x; sy = y; cmd = 'L'; }
460
+ else if (cmd === 'm') { x += num(); y += num(); sx = x; sy = y; cmd = 'l'; }
461
+ else if (cmd === 'L') { x = num(); y = num(); }
462
+ else if (cmd === 'l') { x += num(); y += num(); }
463
+ else if (cmd === 'H') x = num();
464
+ else if (cmd === 'h') x += num();
465
+ else if (cmd === 'V') y = num();
466
+ else if (cmd === 'v') y += num();
467
+ else { i += 1; continue; }
468
+ xs.push(x);
469
+ ys.push(y);
470
+ }
471
+ if (!xs.length) return [0, 0];
472
+ return [r3((Math.min(...xs) + Math.max(...xs)) / 2), r3((Math.min(...ys) + Math.max(...ys)) / 2)];
473
+ }
409
474
 
410
475
  /** A gear outline: n trapezoid teeth between radius r and R round (cx, cy). */
411
476
  export function gearPath(cx, cy, r, R, n) {
@@ -426,93 +491,149 @@ export function codeCaretX(s) {
426
491
  return 4 + indent + key + 3 + rest + 2;
427
492
  }
428
493
 
429
- function codeFull() {
494
+ const SVG_ATTRS = 'fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" focusable="false"';
495
+ const svgOf = (viewBox, body) => `<svg viewBox="${viewBox}" ${SVG_ATTRS}>${body}</svg>`;
496
+
497
+ /** Everything of a scene that stands still: one SVG under the layers. */
498
+ function base(box, body) {
499
+ return `<svg class="sc-base" viewBox="0 0 ${box.w} ${box.h}" width="${box.w}" height="${box.h}" ${SVG_ATTRS}>${body}</svg>`;
500
+ }
501
+
502
+ /**
503
+ * One moving part's layer, over the whole box (or over a window: clipOf).
504
+ * `cls` names its motion (the CSS animates it); `body` is the part's SVG
505
+ * markup in the box's units, or `html` another layer inside it (a caret that
506
+ * blinks inside a caret that steps). `origin` [x, y]: what rotation and scale
507
+ * turn round (the old fill-box centre, or a view-box point); `still` [x, y,
508
+ * more]: the resting transform of a part drawn at 0,0; `i`: its beat.
509
+ */
510
+ function layerOf(box, cls, body, { origin = null, still = null, i = null, html = null } = {}) {
511
+ const style = [];
512
+ if (i != null) style.push(`--i:${i}`);
513
+ if (origin) style.push(`transform-origin:${boxPct(origin[0], box.w)} ${boxPct(origin[1], box.h)}`);
514
+ if (still) style.push(`transform:translate(${boxPct(still[0], box.w)},${boxPct(still[1], box.h)})${still[2] ? ` ${still[2]}` : ''}`);
515
+ const inner = html != null ? html : svgOf(`0 0 ${box.w} ${box.h}`, body);
516
+ return `<span class="sc-layer${cls ? ` ${cls}` : ''}"${style.length ? ` style="${style.join(';')}"` : ''}>${inner}</span>`;
517
+ }
518
+
519
+ /** A still part that stood above a moving one: it stays above it. */
520
+ const topOf = (box, body) => layerOf(box, 'sc-top', body);
521
+
522
+ /** A clipped window of the scene ([x, y, w, h] in units): what scrolls inside it is cut at its edges. */
523
+ function clipOf(box, [x, y, w, h], inner) {
524
+ return `<span class="sc-clip" style="left:${boxPct(x, box.w)};top:${boxPct(y, box.h)};width:${boxPct(w, box.w)};height:${boxPct(h, box.h)}">${inner}</span>`;
525
+ }
526
+
527
+ /** Parts that share a beat (`--i`) share one layer: [{ i, body }] → one layer per beat, in first-seen order. */
528
+ function beatLayers(box, cls, parts) {
529
+ const beats = new Map();
530
+ for (const { i, body } of parts) beats.set(i, (beats.get(i) || '') + body);
531
+ return [...beats].map(([i, body]) => layerOf(box, cls, body, { i })).join('');
532
+ }
533
+
534
+ function codeFull(box) {
430
535
  let lines = '';
431
536
  for (let i = 0; i < CODE_LINES.length * 2; i += 1) {
432
537
  const [indent, key, rest] = CODE_LINES[i % CODE_LINES.length];
433
538
  const y = 5 + CODE_STEP * i;
434
539
  lines += `<path class="sc-mid" d="M${indent} ${y}h${key}"/><path class="sc-dim" d="M${indent + key + 3} ${y}h${rest}"/>`;
435
540
  }
436
- return '<rect class="sc-dim" x="1" y="1.5" width="118" height="37" rx="5"/>'
437
- + `<svg x="4" y="4" width="112" height="32" overflow="hidden"><g class="sc-code-scroll">${lines}</g></svg>`
438
- + `<g class="sc-code-caret" style="transform:translateX(${codeCaretX(0)}px)"><rect class="sc-gold sc-fill sc-caret" x="0" y="30.5" width="2" height="5" rx="1"/></g>`;
541
+ const [, , w, h] = CODE_WINDOW;
542
+ const caret = layerOf(box, 'sc-caret', '<rect class="sc-gold sc-fill" x="0" y="30.5" width="2" height="5" rx="1"/>');
543
+ return base(box, '<rect class="sc-dim" x="1" y="1.5" width="118" height="37" rx="5"/>')
544
+ + clipOf(box, CODE_WINDOW, layerOf({ w, h }, 'sc-code-scroll', lines))
545
+ + layerOf(box, 'sc-code-caret', null, { still: [codeCaretX(0), 0], html: caret });
439
546
  }
440
547
 
441
548
  const GEAR_BIG = gearPath(66, 20, 9, 12.5, 8);
442
549
  const GEAR_SMALL = gearPath(86.5, 12.5, 5.5, 8, 6);
443
550
 
444
- function complexFull() {
445
- return '<path class="sc-dim" d="M14 7h13l5 5v21H14z"/><path class="sc-mid" d="M8 11h13l5 5v21H8zM21 11v5h5M12 23h9M12 28h11"/>'
446
- + `<g class="sc-gear-big sc-box"><path class="sc-mid" d="${GEAR_BIG}"/><circle class="sc-dim" cx="66" cy="20" r="3.2"/></g>`
447
- + `<g class="sc-gear-small sc-box"><path class="sc-gold" d="${GEAR_SMALL}"/><circle class="sc-gold" cx="86.5" cy="12.5" r="2"/></g>`
448
- + '<path class="sc-dim" d="M98 30h14M102 25h10"/>';
551
+ function complexFull(box) {
552
+ return base(box, '<path class="sc-dim" d="M14 7h13l5 5v21H14z"/><path class="sc-mid" d="M8 11h13l5 5v21H8zM21 11v5h5M12 23h9M12 28h11"/><path class="sc-dim" d="M98 30h14M102 25h10"/>')
553
+ + layerOf(box, 'sc-gear-big', `<path class="sc-mid" d="${GEAR_BIG}"/><circle class="sc-dim" cx="66" cy="20" r="3.2"/>`, { origin: [66, 20] })
554
+ + layerOf(box, 'sc-gear-small', `<path class="sc-gold" d="${GEAR_SMALL}"/><circle class="sc-gold" cx="86.5" cy="12.5" r="2"/>`, { origin: [86.5, 12.5] });
555
+ }
556
+
557
+ /** A review tick, its own layer (it pops round its own centre). */
558
+ function tickLayer(box, d, i) {
559
+ return layerOf(box, 'sc-tick', `<path class="sc-gold" d="${d}"/>`, { origin: pathCentre(d), i });
449
560
  }
450
561
 
451
- function reviewFull() {
562
+ function reviewFull(box) {
452
563
  const rows = [[9, '+', 52], [16, '-', 64], [23, '+', 40], [30, '+', 58]];
453
- let out = '<rect class="sc-dim" x="1" y="1.5" width="118" height="37" rx="5"/>';
564
+ let still = '<rect class="sc-dim" x="1" y="1.5" width="118" height="37" rx="5"/>';
565
+ let ticks = '';
454
566
  rows.forEach(([y, sign, len], i) => {
455
- out += sign === '+' ? `<path class="sc-mid" d="M6 ${y}h4M8 ${y - 2}v4"/>` : `<path class="sc-mid" d="M6 ${y}h4"/>`;
456
- out += `<path class="sc-dim" d="M15 ${y}h${len}"/>`;
457
- out += `<path class="sc-gold sc-tick sc-box" style="--i:${i}" d="M${95} ${y}l2.2 2.2 4.4-4.6"/>`;
567
+ still += sign === '+' ? `<path class="sc-mid" d="M6 ${y}h4M8 ${y - 2}v4"/>` : `<path class="sc-mid" d="M6 ${y}h4"/>`;
568
+ still += `<path class="sc-dim" d="M15 ${y}h${len}"/>`;
569
+ ticks += tickLayer(box, `M95 ${y}l2.2 2.2 4.4-4.6`, i);
458
570
  });
459
- return out;
571
+ return base(box, still) + ticks;
460
572
  }
461
573
 
462
- function researchFull() {
574
+ function researchFull(box) {
463
575
  const page = (x) => `<rect class="sc-dim" x="${x}" y="4" width="40" height="32" rx="3"/><path class="sc-dim" d="M${x + 6} 11h26M${x + 6} 17h22M${x + 6} 23h28M${x + 6} 29h16"/>`;
464
- return page(8) + page(56)
465
- + '<g class="sc-lens"><circle class="sc-gold" cx="24" cy="17" r="7"/><path class="sc-gold" d="M29.2 22.2l5 5"/></g>';
576
+ return base(box, page(8) + page(56))
577
+ + layerOf(box, 'sc-lens', '<circle class="sc-gold" cx="24" cy="17" r="7"/><path class="sc-gold" d="M29.2 22.2l5 5"/>');
466
578
  }
467
579
 
468
580
  const POINTER = 'M0 0v10.5l2.8-2.6 2 4.4 2-.9-2-4.3h3.9z';
469
581
 
470
- function browserFull() {
471
- return '<rect class="sc-dim" x="1" y="1.5" width="118" height="37" rx="5"/><path class="sc-dim" d="M1 9.5h118"/>'
582
+ /** A click's ring (a layer that starts invisible: .sc-ring) and the pointer that clicks there (drawn at 0,0, placed by its layer). */
583
+ function pointerLayers(box, kind, [cx, cy], r) {
584
+ return layerOf(box, `sc-ring sc-ripple-${kind}`, `<circle class="sc-gold" cx="${cx}" cy="${cy}" r="${r}"/>`, { origin: [cx, cy] })
585
+ + layerOf(box, `sc-point-${kind}`, `<path class="sc-gold sc-fill" d="${POINTER}"/>`, { still: [cx, cy] });
586
+ }
587
+
588
+ function browserFull(box) {
589
+ return base(box, '<rect class="sc-dim" x="1" y="1.5" width="118" height="37" rx="5"/><path class="sc-dim" d="M1 9.5h118"/>'
472
590
  + '<circle class="sc-mid sc-fill" cx="6" cy="5.5" r="1"/><circle class="sc-mid sc-fill" cx="10" cy="5.5" r="1"/><circle class="sc-mid sc-fill" cx="14" cy="5.5" r="1"/>'
473
591
  + '<rect class="sc-dim" x="22" y="3.5" width="56" height="4" rx="2"/>'
474
- + '<path class="sc-mid" d="M8 16h36"/><path class="sc-dim" d="M8 22h54M8 27h44M8 32h30"/><rect class="sc-mid" x="78" y="25" width="28" height="8" rx="2"/>'
475
- + '<circle class="sc-gold sc-ring sc-ripple-browser sc-box" cx="92" cy="29" r="5"/>'
476
- + `<g class="sc-point-browser" style="transform:translate(92px,29px)"><path class="sc-gold sc-fill" d="${POINTER}"/></g>`;
592
+ + '<path class="sc-mid" d="M8 16h36"/><path class="sc-dim" d="M8 22h54M8 27h44M8 32h30"/><rect class="sc-mid" x="78" y="25" width="28" height="8" rx="2"/>')
593
+ + pointerLayers(box, 'browser', [92, 29], 5);
477
594
  }
478
595
 
479
- function computerFull() {
480
- return '<rect class="sc-dim" x="10" y="1.5" width="100" height="30" rx="3"/><path class="sc-dim" d="M60 31.5v4M48 37.5h24"/>'
596
+ function computerFull(box) {
597
+ return base(box, '<rect class="sc-dim" x="10" y="1.5" width="100" height="30" rx="3"/><path class="sc-dim" d="M60 31.5v4M48 37.5h24"/>'
481
598
  + '<rect class="sc-mid sc-shade" x="16" y="6" width="44" height="20" rx="2"/><path class="sc-dim" d="M16 10h44M21 15h20M21 20h28"/>'
482
- + '<rect class="sc-mid sc-shade" x="52" y="11" width="50" height="17" rx="2"/><path class="sc-dim" d="M52 15h50"/><rect class="sc-mid" x="84" y="20" width="13" height="5" rx="1.5"/>'
483
- + '<circle class="sc-gold sc-ring sc-ripple-computer sc-box" cx="90" cy="22.5" r="4.5"/>'
484
- + `<g class="sc-point-computer" style="transform:translate(90px,22.5px)"><path class="sc-gold sc-fill" d="${POINTER}"/></g>`;
599
+ + '<rect class="sc-mid sc-shade" x="52" y="11" width="50" height="17" rx="2"/><path class="sc-dim" d="M52 15h50"/><rect class="sc-mid" x="84" y="20" width="13" height="5" rx="1.5"/>')
600
+ + pointerLayers(box, 'computer', [90, 22.5], 4.5);
485
601
  }
486
602
 
487
- function automationFull() {
488
- return '<circle class="sc-mid" cx="18" cy="20" r="13"/><path class="sc-dim" d="M18 9v2M18 29v2M7 20h2M27 20h2"/>'
489
- + '<path class="sc-mid" d="M18 20l5-4"/>'
490
- + '<g class="sc-hand sc-vb" style="transform-origin:18px 20px"><path class="sc-gold" d="M18 20v-9"/></g><circle class="sc-gold sc-fill" cx="18" cy="20" r="1.6"/>'
603
+ function automationFull(box) {
604
+ // The hub dot sits in the still SVG: hand and dot are the same gold, so which is on top shows nowhere.
605
+ return base(box, '<circle class="sc-mid" cx="18" cy="20" r="13"/><path class="sc-dim" d="M18 9v2M18 29v2M7 20h2M27 20h2"/>'
606
+ + '<path class="sc-mid" d="M18 20l5-4"/><circle class="sc-gold sc-fill" cx="18" cy="20" r="1.6"/>'
491
607
  + '<path class="sc-dim" d="M52 24h14M76 24h14"/><rect class="sc-mid" x="44" y="20" width="8" height="8" rx="2"/><rect class="sc-mid" x="66" y="20" width="10" height="8" rx="2"/><rect class="sc-mid" x="90" y="20" width="8" height="8" rx="2"/>'
492
- + '<path class="sc-dim" d="M94 17c0-9-46-9-46 0M48 17l-2.5-3M48 17l3-2"/>'
493
- + '<g class="sc-run-dot"><circle class="sc-gold sc-fill" cx="48" cy="24" r="2.2"/></g>';
608
+ + '<path class="sc-dim" d="M94 17c0-9-46-9-46 0M48 17l-2.5-3M48 17l3-2"/>')
609
+ + layerOf(box, 'sc-hand', '<path class="sc-gold" d="M18 20v-9"/>', { origin: [18, 20] })
610
+ + layerOf(box, 'sc-run-dot', '<circle class="sc-gold sc-fill" cx="48" cy="24" r="2.2"/>');
494
611
  }
495
612
 
496
- function designFull() {
497
- return '<rect class="sc-gold sc-fill sc-swatch" x="4" y="5" width="9" height="9" rx="2"/><rect class="sc-mid sc-fill sc-swatch-b" x="4" y="16" width="9" height="9" rx="2"/><rect class="sc-dim sc-fill sc-swatch-c" x="4" y="27" width="9" height="9" rx="2"/>'
498
- + '<rect class="sc-dim" x="20" y="2.5" width="96" height="35" rx="4"/>'
499
- + '<circle class="sc-mid sc-shape sc-box" style="--i:0" cx="40" cy="20" r="8"/>'
500
- + '<rect class="sc-mid sc-shape sc-box" style="--i:1" x="58" y="12" width="16" height="16" rx="3"/>'
501
- + '<path class="sc-mid sc-shape sc-box" style="--i:2" d="M86 28l9-16 9 16z"/>'
502
- + '<g class="sc-pen" style="transform:translate(40px,20px)"><path class="sc-gold" d="M0 0l6-6M6-6l2.5 2.5M0 0l-1.2 3.7L2.5 2.5z"/></g>';
613
+ const TRIANGLE = 'M86 28l9-16 9 16z';
614
+
615
+ function designFull(box) {
616
+ return base(box, '<rect class="sc-gold sc-fill sc-swatch" x="4" y="5" width="9" height="9" rx="2"/><rect class="sc-mid sc-fill sc-swatch-b" x="4" y="16" width="9" height="9" rx="2"/><rect class="sc-dim sc-fill sc-swatch-c" x="4" y="27" width="9" height="9" rx="2"/>'
617
+ + '<rect class="sc-dim" x="20" y="2.5" width="96" height="35" rx="4"/>')
618
+ + layerOf(box, 'sc-shape', '<circle class="sc-mid" cx="40" cy="20" r="8"/>', { origin: [40, 20], i: 0 })
619
+ + layerOf(box, 'sc-shape', '<rect class="sc-mid" x="58" y="12" width="16" height="16" rx="3"/>', { origin: [66, 20], i: 1 })
620
+ + layerOf(box, 'sc-shape', `<path class="sc-mid" d="${TRIANGLE}"/>`, { origin: pathCentre(TRIANGLE), i: 2 })
621
+ + layerOf(box, 'sc-pen', '<path class="sc-gold" d="M0 0l6-6M6-6l2.5 2.5M0 0l-1.2 3.7L2.5 2.5z"/>', { still: [40, 20] });
503
622
  }
504
623
 
505
- function imageFull() {
506
- let tiles = '';
624
+ const SPARKLE = 'M104 6l1.6 4.4L110 12l-4.4 1.6L104 18l-1.6-4.4L98 12l4.4-1.6z';
625
+
626
+ function imageFull(box) {
627
+ const tiles = [];
507
628
  for (let row = 0; row < 3; row += 1) {
508
629
  for (let col = 0; col < 4; col += 1) {
509
630
  const gold = (row === 1 && col === 2) || (row === 2 && col === 0);
510
- tiles += `<rect class="${gold ? 'sc-gold' : 'sc-mid'} sc-fill sc-tile" style="--i:${row + col}" x="${34 + col * 13.5}" y="${6 + row * 10}" width="11.5" height="8" rx="1.5"/>`;
631
+ tiles.push({ i: row + col, body: `<rect class="${gold ? 'sc-gold' : 'sc-mid'} sc-fill" x="${34 + col * 13.5}" y="${6 + row * 10}" width="11.5" height="8" rx="1.5"/>` });
511
632
  }
512
633
  }
513
- return `<rect class="sc-dim" x="30" y="2.5" width="61" height="35" rx="3"/>${tiles}`
514
- + '<g class="sc-sparkle sc-box"><path class="sc-gold sc-fill" d="M104 6l1.6 4.4L110 12l-4.4 1.6L104 18l-1.6-4.4L98 12l4.4-1.6z"/></g>'
515
- + '<path class="sc-dim" d="M100 26h12M100 31h8"/>';
634
+ return base(box, '<rect class="sc-dim" x="30" y="2.5" width="61" height="35" rx="3"/><path class="sc-dim" d="M100 26h12M100 31h8"/>')
635
+ + beatLayers(box, 'sc-tile', tiles)
636
+ + layerOf(box, 'sc-sparkle', `<path class="sc-gold sc-fill" d="${SPARKLE}"/>`, { origin: pathCentre(SPARKLE) });
516
637
  }
517
638
 
518
639
  function filmFrames(width, y, h, frameW, count) {
@@ -522,47 +643,71 @@ function filmFrames(width, y, h, frameW, count) {
522
643
  return out;
523
644
  }
524
645
 
525
- function videoFull() {
526
- return `<svg x="4" y="4" width="112" height="32" overflow="hidden"><g class="sc-film">${filmFrames(160, 2, 28, 24, 7)}</g></svg>`
527
- + '<path class="sc-gold" d="M58 2v36M55 2h6l-3 3z"/>';
646
+ function videoFull(box) {
647
+ const [, , w, h] = FILM_WINDOW;
648
+ return base(box, '')
649
+ + clipOf(box, FILM_WINDOW, layerOf({ w, h }, 'sc-film', filmFrames(160, 2, 28, 24, 7)))
650
+ + topOf(box, '<path class="sc-gold" d="M58 2v36M55 2h6l-3 3z"/>');
528
651
  }
529
652
 
530
653
  /** An open-end wrench, horizontal, its jaw round (0, 0): place it with translate(bolt) rotate(a) translate(3.5 0). */
531
654
  const WRENCH = 'M-4.6 -3.86L-1 -2V2L-4.6 3.86A6 6 0 0 0 5.5 2.4H24A2.4 2.4 0 0 0 24 -2.4H5.5A6 6 0 0 0 -4.6 -3.86Z';
532
655
  const hexagon = (cx, cy, r) => `M${[0, 1, 2, 3, 4, 5].map(i => `${r1(cx + r * Math.cos(i * Math.PI / 3))} ${r1(cy + r * Math.sin(i * Math.PI / 3))}`).join('L')}Z`;
656
+ const ZAP = 'M92 5l-8 15h7l-3 14 11-18h-7.5z';
657
+
658
+ /** Sparks off the bolt: one layer each (they flash one after another). */
659
+ const sparkLayers = (box, ds) => ds.map((d, i) => layerOf(box, 'sc-spark', `<path class="sc-gold" d="${d}"/>`, { i })).join('');
533
660
 
534
- function quickFull() {
535
- return `<path class="sc-mid" d="${hexagon(38, 24, 3.4)}"/>`
536
- + `<g class="sc-wrench sc-vb" style="transform-origin:38px 24px"><path class="sc-gold" transform="translate(38 24) rotate(-32) translate(3.5 0)" d="${WRENCH}"/></g>`
537
- + '<path class="sc-gold sc-spark" style="--i:0" d="M30.5 19.5l-3-2"/><path class="sc-gold sc-spark" style="--i:1" d="M29.5 24.5h-3.5"/><path class="sc-gold sc-spark" style="--i:2" d="M30.5 29.5l-3 2"/>'
538
- + '<g class="sc-zap sc-box"><path class="sc-gold" d="M92 5l-8 15h7l-3 14 11-18h-7.5z"/></g>';
661
+ function quickFull(box) {
662
+ return base(box, `<path class="sc-mid" d="${hexagon(38, 24, 3.4)}"/>`)
663
+ + layerOf(box, 'sc-wrench', `<path class="sc-gold" transform="translate(38 24) rotate(-32) translate(3.5 0)" d="${WRENCH}"/>`, { origin: [38, 24] })
664
+ + sparkLayers(box, ['M30.5 19.5l-3-2', 'M29.5 24.5h-3.5', 'M30.5 29.5l-3 2'])
665
+ + layerOf(box, 'sc-zap', `<path class="sc-gold" d="${ZAP}"/>`, { origin: pathCentre(ZAP) });
539
666
  }
540
667
 
541
- function genericFull() {
542
- return '<path class="sc-dim" d="M14 20h92"/><circle class="sc-mid" cx="14" cy="20" r="3"/><circle class="sc-mid" cx="60" cy="20" r="3"/><circle class="sc-mid" cx="106" cy="20" r="3"/>'
543
- + '<g class="sc-slide"><rect class="sc-gold sc-fill" x="14" y="18.5" width="24" height="3" rx="1.5"/></g>';
668
+ function genericFull(box) {
669
+ return base(box, '<path class="sc-dim" d="M14 20h92"/><circle class="sc-mid" cx="14" cy="20" r="3"/><circle class="sc-mid" cx="60" cy="20" r="3"/><circle class="sc-mid" cx="106" cy="20" r="3"/>')
670
+ + layerOf(box, 'sc-slide', '<rect class="sc-gold sc-fill" x="14" y="18.5" width="24" height="3" rx="1.5"/>');
544
671
  }
545
672
 
673
+ const MINI_TRIANGLE = 'M29 15l4.5-8 4.5 8z';
674
+
546
675
  // Minis (40×20, the tray at 32×16): one gesture each.
547
676
  const MINI = {
548
- code: () => '<path class="sc-mid" d="M5 5h20M9 10h24M5 15h14"/><rect class="sc-gold sc-fill sc-caret" x="21.5" y="12.5" width="1.8" height="5" rx=".9"/>',
549
- complex: () => `<g class="sc-gear-big sc-box"><path class="sc-mid" d="${gearPath(14, 11, 4.5, 6.6, 6)}"/></g><g class="sc-gear-small sc-box"><path class="sc-gold" d="${gearPath(26.5, 7.5, 3, 4.6, 6)}"/></g>`,
550
- review: () => '<path class="sc-dim" d="M4 5h20M4 10h24M4 15h18"/><path class="sc-gold sc-tick sc-box" style="--i:0" d="M30 5l1.6 1.6 3.2-3.4"/><path class="sc-gold sc-tick sc-box" style="--i:1" d="M32 10l1.6 1.6 3.2-3.4"/>',
551
- research: () => '<rect class="sc-dim" x="3" y="2.5" width="26" height="15" rx="2"/><path class="sc-dim" d="M7 7h16M7 11h12M7 15h14"/><g class="sc-lens-mini"><circle class="sc-gold" cx="12" cy="9" r="3.8"/><path class="sc-gold" d="M14.8 11.8l2.8 2.8"/></g>',
552
- browser: () => `<rect class="sc-dim" x="2" y="2" width="36" height="16" rx="2.5"/><path class="sc-dim" d="M2 6h36"/><rect class="sc-mid" x="24" y="11" width="10" height="4" rx="1.2"/><g class="sc-point-mini" style="transform:translate(29px,13px) scale(.6)"><path class="sc-gold sc-fill" d="${POINTER}"/></g>`,
553
- computer: () => `<rect class="sc-dim" x="5" y="1.5" width="30" height="14" rx="2"/><path class="sc-dim" d="M20 15.5v2M15 18.5h10"/><rect class="sc-mid" x="8" y="4.5" width="13" height="8" rx="1"/><g class="sc-point-mini" style="transform:translate(29px,13px) scale(.6)"><path class="sc-gold sc-fill" d="${POINTER}"/></g>`,
554
- automation: () => '<circle class="sc-mid" cx="11" cy="10" r="7.5"/><path class="sc-dim" d="M11 10l3-2.5"/><g class="sc-hand sc-vb" style="transform-origin:11px 10px"><path class="sc-gold" d="M11 10V4.5"/></g>'
555
- + '<path class="sc-mid" d="M33.5 10a5 5 0 1 1-1.5-3.6M32 3.6v2.8h-2.8"/>',
556
- design: () => '<rect class="sc-mid sc-fill" x="3" y="3" width="6" height="6" rx="1.5"/><rect class="sc-dim sc-fill" x="3" y="11" width="6" height="6" rx="1.5"/><circle class="sc-gold sc-shape sc-box" style="--i:0" cx="21" cy="10" r="5"/><path class="sc-mid sc-shape sc-box" style="--i:1" d="M29 15l4.5-8 4.5 8z"/>',
557
- image_gen: () => {
558
- let tiles = '';
559
- for (let row = 0; row < 2; row += 1) for (let col = 0; col < 3; col += 1) tiles += `<rect class="${row === 1 && col === 2 ? 'sc-gold' : 'sc-mid'} sc-fill sc-tile" style="--i:${row + col}" x="${11 + col * 6.4}" y="${5 + row * 5.6}" width="5" height="4.2" rx="1"/>`;
560
- return `<rect class="sc-dim" x="9" y="2.5" width="22" height="15" rx="2"/>${tiles}`;
677
+ code: box => base(box, '<path class="sc-mid" d="M5 5h20M9 10h24M5 15h14"/>')
678
+ + layerOf(box, 'sc-caret', '<rect class="sc-gold sc-fill" x="21.5" y="12.5" width="1.8" height="5" rx=".9"/>'),
679
+ complex: box => base(box, '')
680
+ + layerOf(box, 'sc-gear-big', `<path class="sc-mid" d="${gearPath(14, 11, 4.5, 6.6, 6)}"/>`, { origin: [14, 11] })
681
+ + layerOf(box, 'sc-gear-small', `<path class="sc-gold" d="${gearPath(26.5, 7.5, 3, 4.6, 6)}"/>`, { origin: [26.5, 7.5] }),
682
+ review: box => base(box, '<path class="sc-dim" d="M4 5h20M4 10h24M4 15h18"/>')
683
+ + tickLayer(box, 'M30 5l1.6 1.6 3.2-3.4', 0) + tickLayer(box, 'M32 10l1.6 1.6 3.2-3.4', 1),
684
+ research: box => base(box, '<rect class="sc-dim" x="3" y="2.5" width="26" height="15" rx="2"/><path class="sc-dim" d="M7 7h16M7 11h12M7 15h14"/>')
685
+ + layerOf(box, 'sc-lens-mini', '<circle class="sc-gold" cx="12" cy="9" r="3.8"/><path class="sc-gold" d="M14.8 11.8l2.8 2.8"/>'),
686
+ browser: box => base(box, '<rect class="sc-dim" x="2" y="2" width="36" height="16" rx="2.5"/><path class="sc-dim" d="M2 6h36"/><rect class="sc-mid" x="24" y="11" width="10" height="4" rx="1.2"/>')
687
+ + layerOf(box, 'sc-point-mini', `<path class="sc-gold sc-fill" d="${POINTER}"/>`, { still: [29, 13, 'scale(.6)'] }),
688
+ computer: box => base(box, '<rect class="sc-dim" x="5" y="1.5" width="30" height="14" rx="2"/><path class="sc-dim" d="M20 15.5v2M15 18.5h10"/><rect class="sc-mid" x="8" y="4.5" width="13" height="8" rx="1"/>')
689
+ + layerOf(box, 'sc-point-mini', `<path class="sc-gold sc-fill" d="${POINTER}"/>`, { still: [29, 13, 'scale(.6)'] }),
690
+ automation: box => base(box, '<circle class="sc-mid" cx="11" cy="10" r="7.5"/><path class="sc-dim" d="M11 10l3-2.5"/><path class="sc-mid" d="M33.5 10a5 5 0 1 1-1.5-3.6M32 3.6v2.8h-2.8"/>')
691
+ + layerOf(box, 'sc-hand', '<path class="sc-gold" d="M11 10V4.5"/>', { origin: [11, 10] }),
692
+ design: box => base(box, '<rect class="sc-mid sc-fill" x="3" y="3" width="6" height="6" rx="1.5"/><rect class="sc-dim sc-fill" x="3" y="11" width="6" height="6" rx="1.5"/>')
693
+ + layerOf(box, 'sc-shape', '<circle class="sc-gold" cx="21" cy="10" r="5"/>', { origin: [21, 10], i: 0 })
694
+ + layerOf(box, 'sc-shape', `<path class="sc-mid" d="${MINI_TRIANGLE}"/>`, { origin: pathCentre(MINI_TRIANGLE), i: 1 }),
695
+ image_gen: (box) => {
696
+ const tiles = [];
697
+ for (let row = 0; row < 2; row += 1) for (let col = 0; col < 3; col += 1) tiles.push({ i: row + col, body: `<rect class="${row === 1 && col === 2 ? 'sc-gold' : 'sc-mid'} sc-fill" x="${11 + col * 6.4}" y="${5 + row * 5.6}" width="5" height="4.2" rx="1"/>` });
698
+ return base(box, '<rect class="sc-dim" x="9" y="2.5" width="22" height="15" rx="2"/>') + beatLayers(box, 'sc-tile', tiles);
699
+ },
700
+ video_gen: (box) => {
701
+ const [, , w, h] = FILM_MINI_WINDOW;
702
+ return base(box, '')
703
+ + clipOf(box, FILM_MINI_WINDOW, layerOf({ w, h }, 'sc-film-mini', filmFrames(60, 1, 14, 12, 5)))
704
+ + topOf(box, '<path class="sc-gold" d="M20 1v18"/>');
561
705
  },
562
- video_gen: () => `<svg x="2" y="2" width="36" height="16" overflow="hidden"><g class="sc-film-mini">${filmFrames(60, 1, 14, 12, 5)}</g></svg><path class="sc-gold" d="M20 1v18"/>`,
563
- quick: () => `<path class="sc-mid" d="${hexagon(11, 11, 2.6)}"/><g class="sc-wrench sc-vb" style="transform-origin:11px 11px"><path class="sc-gold" stroke-width="2.4" transform="translate(11 11) rotate(-32) scale(.62) translate(3.5 0)" d="${WRENCH}"/></g>`
564
- + '<path class="sc-gold sc-spark" style="--i:0" d="M5.5 7.5l-2-1.3"/><path class="sc-gold sc-spark" style="--i:1" d="M5 11.5H2.6"/>',
565
- generic: () => '<circle class="sc-gold sc-fill sc-dot" style="--i:0" cx="11" cy="10" r="2.4"/><circle class="sc-gold sc-fill sc-dot" style="--i:1" cx="20" cy="10" r="2.4"/><circle class="sc-gold sc-fill sc-dot" style="--i:2" cx="29" cy="10" r="2.4"/>',
706
+ quick: box => base(box, `<path class="sc-mid" d="${hexagon(11, 11, 2.6)}"/>`)
707
+ + layerOf(box, 'sc-wrench', `<path class="sc-gold" stroke-width="2.4" transform="translate(11 11) rotate(-32) scale(.62) translate(3.5 0)" d="${WRENCH}"/>`, { origin: [11, 11] })
708
+ + sparkLayers(box, ['M5.5 7.5l-2-1.3', 'M5 11.5H2.6']),
709
+ generic: box => base(box, '')
710
+ + [11, 20, 29].map((cx, i) => layerOf(box, 'sc-dot', `<circle class="sc-gold sc-fill" cx="${cx}" cy="10" r="2.4"/>`, { i })).join(''),
566
711
  };
567
712
 
568
713
  const FULL = {
@@ -571,13 +716,14 @@ const FULL = {
571
716
  };
572
717
 
573
718
  /**
574
- * A scene's SVG (decorative: aria-hidden). `mini` draws the tray's 40×20
719
+ * A scene's markup (decorative: aria-hidden): an HTML box with its still SVG
720
+ * and one layer per moving part (see above). `mini` draws the tray's 40×20
575
721
  * version. `live` starts it moving (the stage and tray set data-live
576
722
  * themselves later; this is only the first state).
577
723
  */
578
724
  export function sceneSvg(scene, { mini = false, live = false, className = '' } = {}) {
579
725
  const id = Object.hasOwn(FULL, scene) ? scene : 'generic';
580
- const [w, h] = mini ? [40, 20] : [120, 40];
581
- const body = (mini ? MINI[id] : FULL[id])();
582
- return `<svg class="asst-scene${mini ? ' is-mini' : ''}${className ? ` ${className}` : ''}" data-scene="${id}"${live ? ' data-live=""' : ''} viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${body}</svg>`;
726
+ const box = mini ? SCENE_BOX.mini : SCENE_BOX.full;
727
+ const body = (mini ? MINI[id] : FULL[id])(box);
728
+ return `<span class="asst-scene${mini ? ' is-mini' : ''}${className ? ` ${className}` : ''}" data-scene="${id}"${live ? ' data-live=""' : ''} aria-hidden="true">${body}</span>`;
583
729
  }
@@ -459,15 +459,15 @@ export function injectAssistantSidepanelStyles() {
459
459
 
460
460
  body.ui-interacting .asp-placeholder .asp-mark,
461
461
  body.ui-interacting .asp-placeholder-track::after,
462
- body.ui-interacting .asp-status .syna-cameo-eye,
463
- body.ui-interacting .asp-session-pill .syna-cameo-eye { animation-play-state: paused !important; }
462
+ body.ui-interacting .asp-status .syna-cameo-eyes,
463
+ body.ui-interacting .asp-session-pill .syna-cameo-eyes { animation-play-state: paused !important; }
464
464
 
465
465
  @media (prefers-reduced-motion: reduce) {
466
466
  .assistant-panel { transition: opacity 0.15s ease; }
467
467
  .asp-placeholder .asp-mark,
468
468
  .asp-placeholder-track::after,
469
- .asp-status .syna-cameo-eye,
470
- .asp-session-pill .syna-cameo-eye,
469
+ .asp-status .syna-cameo-eyes,
470
+ .asp-session-pill .syna-cameo-eyes,
471
471
  .assistant-panel .asst-send::before { animation: none !important; }
472
472
  .asp-placeholder-track::after { left: 31%; }
473
473
  }
@@ -0,0 +1,44 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Assistant — computed styles read once
3
+ // ═══════════════════════════════════════════
4
+ // A value the stylesheet decides and the renderer needs on a hot path (the
5
+ // rack's --rk-cut, where a long subject is cut; its bubble line's
6
+ // white-space), read once and kept until the renderer says it may have
7
+ // changed. getComputedStyle() on a frame's path forces a style recalculation
8
+ // of everything the frame dirtied (a rig's SVG, a scene); these values change
9
+ // only when a container query flips, which happens only when the stage's width
10
+ // changes, and the renderer's ResizeObserver sees every such change and calls
11
+ // invalidate().
12
+ //
13
+ // createStyleCache({ read }?) → { get(el, prop), invalidate(el?), reads() }
14
+ //
15
+ // `read(el, prop)` defaults to getComputedStyle(el).getPropertyValue(prop)
16
+ // (trimmed); a read that throws counts as ''. DOM-free: tested in Node.
17
+
18
+ const defaultRead = (el, prop) => globalThis.getComputedStyle(el).getPropertyValue(prop);
19
+
20
+ export function createStyleCache({ read = defaultRead } = {}) {
21
+ let values = new WeakMap(); // element → Map(prop → value)
22
+ let count = 0;
23
+ return {
24
+ /** The element's computed `prop` (a custom property or a longhand), read once until invalidated. */
25
+ get(el, prop) {
26
+ if (!el) return '';
27
+ let props = values.get(el);
28
+ if (!props) { props = new Map(); values.set(el, props); }
29
+ if (props.has(prop)) return props.get(prop);
30
+ count += 1;
31
+ let value = '';
32
+ try { value = String(read(el, prop) ?? '').trim(); } catch { value = ''; }
33
+ props.set(prop, value);
34
+ return value;
35
+ },
36
+ /** Forget one element's values, or every value (no argument). */
37
+ invalidate(el) {
38
+ if (el) values.delete(el);
39
+ else values = new WeakMap();
40
+ },
41
+ /** How many real reads were made (tests, the idle-CPU suite). */
42
+ reads: () => count,
43
+ };
44
+ }
@@ -252,9 +252,13 @@ export function injectAssistantStyles() {
252
252
  .asst-count.attention { color: var(--asst-warn); box-shadow: inset 0 0 0 1px var(--asst-pill-warn-ring); }
253
253
  /* The running cameo: the mascot's two pill eyes, glancing ±1.2px every 1.4s.
254
254
  One form everywhere (the header status, the tray pill, run pills):
255
- mascotCameoSvg() from synabun-mascot.js, a 10×7 SVG. */
255
+ mascotCameoSvg() from synabun-mascot.js, a 10×7 SVG in two spans. The
256
+ glance moves the HTML box around the eyes (.syna-cameo-eyes), along the
257
+ pair's −12° lean (1.2 of the SVG's 10×7 units: 11.738% and 3.564% of the
258
+ box), never a part of the SVG: a box's transform runs on the compositor,
259
+ and a frame the page draws anyway restyles one span and lays nothing out. */
256
260
  .syna-cameo { display: inline-block; flex-shrink: 0; overflow: visible; color: var(--asst-brand); }
257
- .syna-cameo-eye { transform-box: fill-box; animation: asst-cameo 2.8s var(--asst-ease-emphasized) infinite; }
261
+ .syna-cameo-eyes { display: block; animation: asst-cameo 2.8s var(--asst-ease-emphasized) infinite; }
258
262
 
259
263
  /* ── Toolbar: flush under the terminal tabs ─────────────────────────
260
264
  Edge to edge like the tab bar above it (same hairline), no card and no
@@ -1088,6 +1092,8 @@ export function injectAssistantStyles() {
1088
1092
  border-radius: 50%;
1089
1093
  animation: asst-spin .8s linear infinite;
1090
1094
  }
1095
+ /* A spinner nobody can see never runs (a hidden holder still restyles a running loop every frame). */
1096
+ [hidden] .asst-spinner, .asst-spinner[hidden], [inert] .asst-spinner { animation: none; }
1091
1097
  .asst-computer-activity { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 7px 8px 7px 12px; border-bottom: 1px solid var(--asst-line); }
1092
1098
  .asst-live-dot { width: 6px; height: 6px; flex-shrink: 0; border-radius: 50%; background: var(--asst-live); animation: asst-live 1.4s var(--asst-ease-standard) infinite; }
1093
1099
  .asst-computer-activity[data-state="paused_user"] .asst-live-dot { background: var(--asst-warn); animation: none; }
@@ -2063,10 +2069,24 @@ export function injectAssistantStyles() {
2063
2069
  whole Assistant holds still: the component, its menus and modals, the
2064
2070
  sidepanel frame and its header (.asp-*), and the tray pills. */
2065
2071
  @keyframes asst-live { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.8); } }
2066
- @keyframes asst-cameo { 0%, 42% { transform: translateX(-1.2px); } 50%, 92% { transform: translateX(1.2px); } 100% { transform: translateX(-1.2px); } }
2072
+ @keyframes asst-cameo { 0%, 42% { transform: translate(-11.738%, 3.564%); } 50%, 92% { transform: translate(11.738%, -3.564%); } 100% { transform: translate(-11.738%, 3.564%); } }
2067
2073
  @keyframes asst-spin { to { transform: rotate(360deg); } }
2068
2074
 
2069
- body.ui-interacting :is(.asst-live-dot, .asst-spinner, .asst-computer-dot, .syna-cameo-eye) { animation-play-state: paused !important; }
2075
+ /* A moving box gets its layer up front (only where motion is allowed; nothing is promoted under reduced motion). */
2076
+ @media (prefers-reduced-motion: no-preference) {
2077
+ .syna-cameo-eyes { will-change: transform; }
2078
+ }
2079
+
2080
+ body.ui-interacting :is(.asst-live-dot, .asst-spinner, .asst-computer-dot, .syna-cameo-eyes) { animation-play-state: paused !important; }
2081
+
2082
+ /* Off screen, nothing loops. A holder the renderer saw leave the viewport (asst-motion-gate.js marks it
2083
+ data-offscreen: a run card, a rack or a route line scrolled out of its transcript) and a closed side
2084
+ panel (it stays in the page, moved out and faded: .assistant-panel without .open) pause every loop
2085
+ under them; each picks up where it stopped when it is back. Every frame the page draws restyles each
2086
+ running animation, composited or not, so a loop nobody sees is never free. */
2087
+ [data-offscreen], [data-offscreen]::before, [data-offscreen]::after,
2088
+ [data-offscreen] *, [data-offscreen] *::before, [data-offscreen] *::after,
2089
+ .assistant-panel:not(.open) *, .assistant-panel:not(.open) *::before, .assistant-panel:not(.open) *::after { animation-play-state: paused !important; }
2070
2090
  body.ui-interacting .asst-route-line[data-live] .asst-route-icon,
2071
2091
  body.ui-interacting .asst-route-line[data-live] .asst-route-sweep::after { animation-play-state: paused !important; }
2072
2092
 
@@ -16,6 +16,16 @@ import {
16
16
  const ICON_CHECK = '<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5l3.5 3.5 6.5-7"/></svg>';
17
17
  const ICON_X = '<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>';
18
18
 
19
+ // A waiting card's spinning border (.cp-spin-ring in ui-claude-panel.js): a box the compositor turns
20
+ // under the ring's mask while the card is .active-perm. The card's first child.
21
+ function spinRing() {
22
+ const ring = document.createElement('span');
23
+ ring.className = 'cp-spin-ring';
24
+ ring.setAttribute('aria-hidden', 'true');
25
+ ring.appendChild(document.createElement('i'));
26
+ return ring;
27
+ }
28
+
19
29
  // Modes, rule suggestions and the prompt's context are decided in
20
30
  // cp-permission-model.js (DOM-free, tested); this file renders them.
21
31
  export const PERMISSION_MODES = ALL_PERMISSION_MODES;
@@ -100,6 +110,7 @@ export function renderPermissionCard(tab, requestId, req, hooks) {
100
110
  const card = document.createElement('div');
101
111
  card.className = 'perm-card active-perm cp-perm-card';
102
112
  card.dataset.requestId = requestId;
113
+ card.appendChild(spinRing());
103
114
  if (ctx.toolUseId) card.dataset.toolUseId = ctx.toolUseId;
104
115
 
105
116
  const hdr = document.createElement('div');
@@ -283,6 +294,7 @@ export function renderElicitationCard(tab, requestId, req, hooks) {
283
294
  const card = document.createElement('div');
284
295
  card.className = 'perm-card active-perm cp-perm-card cp-elicit-card';
285
296
  card.dataset.requestId = requestId;
297
+ card.appendChild(spinRing());
286
298
  if (req.elicitation_id) card.dataset.elicitationId = req.elicitation_id;
287
299
 
288
300
  const hdr = document.createElement('div');