synabun 2.2.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/mcp-server/package-lock.json +2 -2
  4. package/mcp-server/package.json +1 -1
  5. package/neural-interface/i18n/en.json +3 -0
  6. package/neural-interface/i18n/pt-BR.json +3 -0
  7. package/neural-interface/lib/assistant-brains/codex.js +6 -1
  8. package/neural-interface/lib/assistant-dispatch.js +4 -1
  9. package/neural-interface/lib/mcp-external-servers.js +27 -3
  10. package/neural-interface/lib/native-loop-providers.js +3 -2
  11. package/neural-interface/lib/opencode-native-boundary.js +155 -21
  12. package/neural-interface/lib/peer-identity.js +49 -5
  13. package/neural-interface/package-lock.json +2 -2
  14. package/neural-interface/package.json +1 -1
  15. package/neural-interface/public/shared/assistant/asst-api.js +14 -2
  16. package/neural-interface/public/shared/assistant/asst-computer.js +26 -2
  17. package/neural-interface/public/shared/assistant/asst-motion-gate.js +80 -0
  18. package/neural-interface/public/shared/assistant/asst-panel.js +1 -1
  19. package/neural-interface/public/shared/assistant/asst-render.js +62 -7
  20. package/neural-interface/public/shared/assistant/asst-scene-styles.js +55 -23
  21. package/neural-interface/public/shared/assistant/asst-scenes.js +225 -79
  22. package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +4 -4
  23. package/neural-interface/public/shared/assistant/asst-style-cache.js +44 -0
  24. package/neural-interface/public/shared/assistant/asst-styles.js +24 -4
  25. package/neural-interface/public/shared/cp/cp-permissions.js +12 -0
  26. package/neural-interface/public/shared/loading-teardown.js +34 -0
  27. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +6 -1
  28. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +1 -0
  29. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +10 -0
  30. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +6 -0
  31. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +88 -72
  32. package/neural-interface/public/shared/storage-retry.js +47 -0
  33. package/neural-interface/public/shared/storage.js +25 -4
  34. package/neural-interface/public/shared/styles.css +8 -1
  35. package/neural-interface/public/shared/synabun-mascot.js +27 -8
  36. package/neural-interface/public/shared/synabun-ticker.js +147 -12
  37. package/neural-interface/public/shared/ui-claude-panel.js +79 -61
  38. package/neural-interface/public/shared/ui-loading.js +5 -4
  39. package/neural-interface/server.js +87 -40
  40. package/npm-shrinkwrap.json +2 -2
  41. package/package.json +1 -1
@@ -0,0 +1,80 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Assistant — loops run only on screen
3
+ // ═══════════════════════════════════════════
4
+ // The transcript's decorative loops (a run card's cameo, its stage's scene and
5
+ // sweep; a live rack's sweep, ticks and scene; a route line's sweep and icon)
6
+ // run only while their holder is on screen. One IntersectionObserver per
7
+ // renderer marks a holder that is not (scrolled out of its transcript, its tab
8
+ // hidden, its window minimised to nothing) with `data-offscreen`; the
9
+ // stylesheet pauses every animation under it (asst-styles.js), and the loop
10
+ // picks up where it stopped when the holder is back. Every frame the page
11
+ // draws restyles each running animation on the main thread, composited or
12
+ // not, so a loop nobody sees is never free; the observer is the browser's own,
13
+ // so watching costs nothing while the transcript scrolls.
14
+ //
15
+ // createMotionGate({ IntersectionObserver }?) → { watch(el), unwatch(el), disconnect(), size() }
16
+ //
17
+ // No observer (an old engine, a test without one): nothing is marked, every
18
+ // loop runs as before. DOM-light on purpose: tests/assistant-motion-gate.test.mjs
19
+ // drives it with a stand-in observer.
20
+
21
+ export const OFFSCREEN_ATTR = 'data-offscreen';
22
+ const PRUNE_EVERY = 32; // watched holders between two sweeps for ones that left the document
23
+
24
+ export function createMotionGate({ IntersectionObserver: IO = globalThis.IntersectionObserver } = {}) {
25
+ const watched = new Set();
26
+ let io = null; // undefined: no observer here
27
+
28
+ function mark(el, off) {
29
+ try {
30
+ if (off) { if (!el.hasAttribute(OFFSCREEN_ATTR)) el.setAttribute(OFFSCREEN_ATTR, ''); }
31
+ else if (el.hasAttribute(OFFSCREEN_ATTR)) el.removeAttribute(OFFSCREEN_ATTR);
32
+ } catch { /* not an element */ }
33
+ }
34
+
35
+ function observer() {
36
+ if (io !== null) return io;
37
+ if (typeof IO !== 'function') { io = undefined; return io; }
38
+ try {
39
+ io = new IO((entries) => {
40
+ for (const entry of entries) {
41
+ const el = entry.target;
42
+ if (!watched.has(el)) continue;
43
+ if (el.isConnected === false) { unwatch(el); continue; }
44
+ mark(el, !entry.isIntersecting);
45
+ }
46
+ });
47
+ } catch { io = undefined; }
48
+ return io;
49
+ }
50
+
51
+ /** Holders removed while already off screen get no report: drop them now and then. */
52
+ function prune() {
53
+ for (const el of Array.from(watched)) if (el.isConnected === false) unwatch(el);
54
+ }
55
+
56
+ function watch(el) {
57
+ if (!el || watched.has(el)) return false;
58
+ const o = observer();
59
+ if (!o) return false;
60
+ watched.add(el);
61
+ try { o.observe(el); } catch { watched.delete(el); return false; }
62
+ if (watched.size % PRUNE_EVERY === 0) prune();
63
+ return true;
64
+ }
65
+
66
+ function unwatch(el) {
67
+ if (!watched.delete(el)) return;
68
+ try { io?.unobserve(el); } catch { /* already gone */ }
69
+ mark(el, false);
70
+ }
71
+
72
+ function disconnect() {
73
+ try { io?.disconnect(); } catch { /* already gone */ }
74
+ for (const el of watched) mark(el, false);
75
+ watched.clear();
76
+ if (io) io = null;
77
+ }
78
+
79
+ return { watch, unwatch, disconnect, size: () => watched.size };
80
+ }
@@ -1118,7 +1118,7 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
1118
1118
  computerPanel.applyStatus(status);
1119
1119
  applyFeatures();
1120
1120
  })
1121
- .catch(() => { st.features.computer = false; applyFeatures(); });
1121
+ .catch((err) => { st.features.computer = false; computerPanel.checkFailed?.(err); applyFeatures(); });
1122
1122
  }
1123
1123
  }
1124
1124
 
@@ -55,6 +55,8 @@ import { injectRackStyles } from './asst-rack-styles.js';
55
55
  import { injectSceneStyles } from './asst-scene-styles.js';
56
56
  import { activityScene, compactActivity, isLivePhase, runChip, sceneOfClass, sceneSvg, stagePhase } from './asst-scenes.js';
57
57
  import { STAGE_TIMING, createWorkerStage } from './asst-run-stage.js';
58
+ import { createMotionGate } from './asst-motion-gate.js';
59
+ import { createStyleCache } from './asst-style-cache.js';
58
60
  import { formatRunTokens } from './asst-usage.js';
59
61
  import { sanitizeHtmlString, sanitizeInto } from './asst-sanitize.js';
60
62
  import { renderStreamingMarkdown } from '../streaming-markdown.js';
@@ -574,6 +576,16 @@ export function createRenderer(transcriptEl, hooks = {}) {
574
576
  const stageWidths = new WeakMap(); // stage element → { rack, width }
575
577
  const toRecut = new Set();
576
578
  let recutTimer = 0;
579
+ // What the stylesheet decides about a bubble (--rk-cut, the line's white-space) changes only with a container
580
+ // query, so only with the stage's width: read once, forgotten when the width changes (here) or the fonts load.
581
+ const styleCache = createStyleCache();
582
+ // A web font that arrives changes every measured width: the next paint of each bubble measures again.
583
+ const onFontsLoaded = () => { styleCache.invalidate(); for (const rack of racks) rack.budgetMemo = null; };
584
+ const fontSet = typeof document !== 'undefined' ? document.fonts : null;
585
+ try { fontSet?.addEventListener?.('loadingdone', onFontsLoaded); } catch { /* no FontFaceSet events */ }
586
+ const stopFontWatch = () => { try { fontSet?.removeEventListener?.('loadingdone', onFontsLoaded); } catch { /* gone */ } };
587
+ // The transcript's loops run only while their holder is on screen (run cards, racks, route lines).
588
+ const motion = createMotionGate();
577
589
  const stageResizes = typeof ResizeObserver === 'function' ? new ResizeObserver((entries) => {
578
590
  for (const entry of entries) {
579
591
  const seen = stageWidths.get(entry.target);
@@ -582,6 +594,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
582
594
  if (seen.width === null) { seen.width = width; continue; } // the first report: the width it was cut at
583
595
  if (width === seen.width) continue;
584
596
  seen.width = width;
597
+ forgetBubbleStyle(seen.rack);
585
598
  toRecut.add(seen.rack);
586
599
  }
587
600
  if (!toRecut.size) return;
@@ -1601,6 +1614,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
1601
1614
  sr: { last: 0, timer: 0, pending: '' },
1602
1615
  };
1603
1616
  row._rack = rack;
1617
+ motion.watch(row); // its sweep, ticks and scene loop only while it is on screen
1604
1618
  rack.receipt.btn.addEventListener('click', () => setRackOpen(rack, !rack.open));
1605
1619
  row.querySelector('.asst-rack-view').addEventListener('click', (e) => {
1606
1620
  const btn = e.target.closest('button[data-view]');
@@ -2079,6 +2093,22 @@ export function createRenderer(transcriptEl, hooks = {}) {
2079
2093
  * head (subjectCut).
2080
2094
  */
2081
2095
  function subjectBudget(rack, verb, cut = 'middle') {
2096
+ const s = rack.stage;
2097
+ // The same inputs give the same budget: the 1 Hz clock tick reuses it instead of measuring the stage again
2098
+ // (a measure on the frame's path forces style and layout of whatever the frame dirtied). The key holds every
2099
+ // input the measure reads: the stage's width (its ResizeObserver's), the scene beside the mascot, whether the
2100
+ // bubble shows, the verb, the clock's length (tabular digits: same length, same width), the running reserve, the cut.
2101
+ const seen = stageResizes ? stageWidths.get(s.el) : null;
2102
+ const key = seen && seen.width != null
2103
+ ? `${seen.width}|${rack.sceneId}|${s.bubble.hidden ? 0 : 1}|${verb}|${s.meta.textContent.length}|${rack.bubble.step?.state === 'running' ? 1 : 0}|${cut}`
2104
+ : '';
2105
+ if (key && rack.budgetMemo?.key === key) return rack.budgetMemo.value;
2106
+ const value = measureSubjectBudget(rack, verb, cut);
2107
+ rack.budgetMemo = key ? { key, value } : null;
2108
+ return value;
2109
+ }
2110
+
2111
+ function measureSubjectBudget(rack, verb, cut) {
2082
2112
  const s = rack.stage;
2083
2113
  const width = s.el.clientWidth;
2084
2114
  if (!width) return 96;
@@ -2095,14 +2125,30 @@ export function createRenderer(transcriptEl, hooks = {}) {
2095
2125
  // Two lines, less what wrapping at token boundaries wastes (a head cut is one tail: close to two full
2096
2126
  // lines, a path's pieces wrapping at its separators); a guess on the generous side, since fitSubject
2097
2127
  // cuts what the clamp would hide or a piece would push past the edge.
2098
- const lines = getComputedStyle(s.line).whiteSpace === 'nowrap' ? 1 : cut === 'head' ? 2 : 1.85;
2128
+ const lines = bubbleStyle(rack, s.line, 'white-space') === 'nowrap' ? 1 : cut === 'head' ? 2 : 1.85;
2099
2129
  const taken = Math.ceil((verbPx + 7 + (numbersPx ? numbersPx + 7 : 0)) / 7);
2100
2130
  return Math.max(24, Math.min(240, Math.floor(perLine * lines) - taken - 1));
2101
2131
  }
2102
2132
 
2103
- /** 'head' where the stylesheet says so (≤320 px: keep the tail), else 'middle'. */
2133
+ /**
2134
+ * A computed style of the bubble, read once while the stage keeps its width (styleCache; the stage's
2135
+ * ResizeObserver forgets it). Without a ResizeObserver nothing would say when to forget: read it each time.
2136
+ */
2137
+ function bubbleStyle(rack, node, prop) {
2138
+ if (stageResizes && stageWidths.has(rack.stage.el)) return styleCache.get(node, prop);
2139
+ try { return getComputedStyle(node).getPropertyValue(prop).trim(); } catch { return ''; }
2140
+ }
2141
+
2142
+ /** The stage's width changed (or the fonts did): its cut, its line's wrap and its budget are read again. */
2143
+ function forgetBubbleStyle(rack) {
2144
+ styleCache.invalidate(rack.stage.subject);
2145
+ styleCache.invalidate(rack.stage.line);
2146
+ rack.budgetMemo = null;
2147
+ }
2148
+
2149
+ /** 'head' where the stylesheet says so (≤320 px: keep the tail), else 'middle'. Never read per frame: see bubbleStyle. */
2104
2150
  function subjectCut(rack) {
2105
- try { return getComputedStyle(rack.stage.subject).getPropertyValue('--rk-cut').trim() === 'head' ? 'head' : 'middle'; } catch { return 'middle'; }
2151
+ return bubbleStyle(rack, rack.stage.subject, '--rk-cut') === 'head' ? 'head' : 'middle';
2106
2152
  }
2107
2153
 
2108
2154
  /**
@@ -2861,9 +2907,11 @@ export function createRenderer(transcriptEl, hooks = {}) {
2861
2907
  }
2862
2908
 
2863
2909
  // ── Rack upkeep ──
2910
+ // The 1 Hz beats run only while the transcript is on screen (a hidden tab, a closed panel): they paint clocks
2911
+ // and bubbles nobody would see. Back on screen, the ticker runs them at once.
2864
2912
  function needTicker() {
2865
2913
  if (unsubTick) return;
2866
- try { unsubTick = subscribe(tickRacks, { fps: 1 }); } catch { unsubTick = null; }
2914
+ try { unsubTick = subscribe(tickRacks, { fps: 1, target: $msgs }); } catch { unsubTick = null; }
2867
2915
  }
2868
2916
 
2869
2917
  function stopTicker() {
@@ -2888,6 +2936,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
2888
2936
  const stage = rack.stage.el;
2889
2937
  if (on && !stageWidths.has(stage)) {
2890
2938
  stageWidths.set(stage, { rack, width: null });
2939
+ forgetBubbleStyle(rack); // read again from here on, while the observer watches the width
2891
2940
  stageResizes.observe(stage);
2892
2941
  } else if (!on && stageWidths.has(stage)) {
2893
2942
  stageWidths.delete(stage);
@@ -2914,6 +2963,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
2914
2963
 
2915
2964
  function dropRack(rack) {
2916
2965
  watchStageWidth(rack, false);
2966
+ motion.unwatch(rack.row);
2917
2967
  clearTimeout(rack.stageTimer);
2918
2968
  clearTimeout(rack.clearTimer);
2919
2969
  clearTimeout(rack.sr.timer);
@@ -3178,6 +3228,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
3178
3228
  runCards.set(run.runId, card);
3179
3229
  const { wrap } = assistantRow(rootScope);
3180
3230
  wrap.appendChild(card);
3231
+ motion.watch(card); // its cameo, stage, scene and sweep loop only while it is on screen
3181
3232
  syncStage(run.runId, card, run);
3182
3233
  return card;
3183
3234
  }
@@ -3220,7 +3271,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
3220
3271
 
3221
3272
  function needStageTicker() {
3222
3273
  if (unsubStageTick) return;
3223
- try { unsubStageTick = subscribe(tickStages, { fps: 1 }); } catch { unsubStageTick = null; }
3274
+ try { unsubStageTick = subscribe(tickStages, { fps: 1, target: $msgs }); } catch { unsubStageTick = null; }
3224
3275
  }
3225
3276
 
3226
3277
  function stopStageTicker() {
@@ -3399,6 +3450,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
3399
3450
  const line = createRouteLine(route, routeLineOpts(phase));
3400
3451
  routeLines.set(routeId, line);
3401
3452
  append(line.el);
3453
+ motion.watch(line.el); // its sweep and icon loop only while it is on screen
3402
3454
  syncRouteLive(routeId);
3403
3455
  enterRow(line.el);
3404
3456
  return line;
@@ -3409,6 +3461,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
3409
3461
  if (!routeId || !line) return;
3410
3462
  const fresh = routeLines.get(routeId) !== line;
3411
3463
  routeLines.set(routeId, line);
3464
+ if (line.el) motion.watch(line.el);
3412
3465
  syncRouteLive(routeId);
3413
3466
  if (fresh && line.el?.isConnected) enterRow(line.el);
3414
3467
  }
@@ -3430,7 +3483,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
3430
3483
 
3431
3484
  function removeRouteLine(routeId) {
3432
3485
  const line = routeLines.get(routeId);
3433
- if (line) { line.el.remove(); routeLines.delete(routeId); }
3486
+ if (line) { motion.unwatch(line.el); line.el.remove(); routeLines.delete(routeId); }
3434
3487
  }
3435
3488
 
3436
3489
  // ── The automation trace ──
@@ -4094,6 +4147,8 @@ export function createRenderer(transcriptEl, hooks = {}) {
4094
4147
  runActivity.clear();
4095
4148
  runCards.clear();
4096
4149
  routeLines.clear();
4150
+ motion.disconnect(); // every holder it watched goes with the transcript
4151
+ styleCache.invalidate();
4097
4152
  autoRows.clear();
4098
4153
  turnFrames = [];
4099
4154
  rootScope.stream = null;
@@ -4122,7 +4177,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
4122
4177
  return {
4123
4178
  el: $msgs,
4124
4179
  clear,
4125
- destroy: () => { clear(); stageResizes?.disconnect(); limitDock.destroy(); },
4180
+ destroy: () => { clear(); stageResizes?.disconnect(); limitDock.destroy(); stopFontWatch(); },
4126
4181
  appendUser,
4127
4182
  appendAssistantMarkdown,
4128
4183
  appendStatus,
@@ -15,31 +15,52 @@
15
15
  // - the agents tray's mini scenes and lanes (asst-agents-dock.js).
16
16
  //
17
17
  // Rules as the rack's: gold only means live; infinite loops pause while the
18
- // UI is dragged (body.ui-interacting) and stop under prefers-reduced-motion,
18
+ // UI is dragged (body.ui-interacting), off screen (data-offscreen on a holder,
19
+ // a closed side panel: asst-styles.js) and stop under prefers-reduced-motion,
19
20
  // where every scene is its still frame and the sweep is not drawn. Tokens
20
21
  // come from asst-styles.js, always with a fallback. No backdrop blur and no
21
- // standing layer hints.
22
+ // standing layer hints: a moving layer is promoted (will-change) only while
23
+ // its scene is live and on screen.
24
+ //
25
+ // A scene's moving parts are HTML layers (asst-scenes.js), so every motion is
26
+ // a box's transform or opacity, run by the compositor. Lengths are written in
27
+ // the scene's own units (the SVG's px) and turned into percentages of the box
28
+ // here (sc() below): a scene is drawn at several sizes, and a percentage of
29
+ // the box is the same distance in units at every one of them.
22
30
 
23
- import { CODE_LINES, CODE_STEP, codeCaretX } from './asst-scenes.js';
31
+ import { CODE_LINES, CODE_STEP, CODE_WINDOW, FILM_MINI_WINDOW, FILM_WINDOW, SCENE_BOX, boxPct, codeCaretX } from './asst-scenes.js';
24
32
 
25
33
  export const SCENE_STYLE_ID = 'asst-scene-styles';
26
34
 
27
- /** The code scene's stepped scroll (one line per step) and its caret, which lands on each new bottom line's end. */
35
+ const FULL = SCENE_BOX.full;
36
+ const MINI = SCENE_BOX.mini;
37
+ const win = ([, , w, h]) => ({ w, h });
38
+
39
+ /** A move of (x, y) scene units in a box: `translate(…%, …%)`, plus any scale or rotation after it. */
40
+ function sc(box, x, y, more = '') {
41
+ return `transform: translate(${boxPct(x, box.w)}, ${boxPct(y, box.h)})${more ? ` ${more}` : ''};`;
42
+ }
43
+
44
+ /** The code scene's stepped scroll (one line per step, inside its window) and its caret, which lands on each new bottom line's end. */
28
45
  function codeFrames() {
29
46
  const n = CODE_LINES.length;
30
47
  const at = (s) => Math.round((s * 100000) / n) / 1000;
31
48
  const hold = (s) => Math.round(((s * 100) / n + (100 / n) * 0.72) * 1000) / 1000;
49
+ const view = win(CODE_WINDOW);
32
50
  let scroll = '';
33
51
  let caret = '';
34
52
  for (let s = 0; s < n; s += 1) {
35
- scroll += ` ${at(s)}%, ${hold(s)}% { transform: translateY(${-CODE_STEP * s}px); }`;
36
- caret += ` ${at(s)}%, ${hold(s)}% { transform: translateX(${codeCaretX(s)}px); }`;
53
+ scroll += ` ${at(s)}%, ${hold(s)}% { ${sc(view, 0, -CODE_STEP * s)} }`;
54
+ caret += ` ${at(s)}%, ${hold(s)}% { ${sc(FULL, codeCaretX(s), 0)} }`;
37
55
  }
38
- scroll += ` 100% { transform: translateY(${-CODE_STEP * n}px); }`;
39
- caret += ` 100% { transform: translateX(${codeCaretX(n)}px); }`;
56
+ scroll += ` 100% { ${sc(view, 0, -CODE_STEP * n)} }`;
57
+ caret += ` 100% { ${sc(FULL, codeCaretX(n), 0)} }`;
40
58
  return `@keyframes asst-sc-code {${scroll} }\n @keyframes asst-sc-code-caret {${caret} }`;
41
59
  }
42
60
 
61
+ /** The pointer scenes: rest on the button, travel to the page, back (units of their own box). */
62
+ const pointFrames = (name, box, [x0, y0], [x1, y1], more = '', [a, b, c, d] = [22, 46, 72, 96]) => `@keyframes ${name} { 0%, ${a}% { ${sc(box, x0, y0, more)} } ${b}%, ${c}% { ${sc(box, x1, y1, more)} } ${d}%, 100% { ${sc(box, x0, y0, more)} } }`;
63
+
43
64
  export const SCENE_CSS = `
44
65
  /* ── The stage in a run card ──────────────────────────────────────── */
45
66
  .asst-run-stage-wrap {
@@ -110,8 +131,15 @@ export const SCENE_CSS = `
110
131
  .asst-run.is-revealed { background: var(--asst-fill-1, rgba(255,255,255,.04)); box-shadow: 0 0 0 1px var(--asst-line-2, rgba(255,255,255,.12)); }
111
132
 
112
133
  /* ── Scenes ─────────────────────────────────────────────────────────── */
113
- /* --sc-gold is set by a holder that neutralizes it (waiting, settled); unset, it is the brand gold. */
114
- .asst-scene { display: block; overflow: visible; }
134
+ /* --sc-gold is set by a holder that neutralizes it (waiting, settled); unset, it is the brand gold.
135
+ The box takes its holder's size (120×40 and 40×20 by default); its still SVG and every layer cover
136
+ it, in the same viewBox; strokes are 1.5 units unless the holder says otherwise (they inherit). */
137
+ .asst-scene { position: relative; display: block; overflow: visible; stroke-width: 1.5; }
138
+ :where(.asst-scene) { width: 120px; height: 40px; }
139
+ :where(.asst-scene.is-mini) { width: 40px; height: 20px; }
140
+ .asst-scene svg { position: absolute; inset: 0; display: block; width: 100%; height: 100%; overflow: visible; }
141
+ .asst-scene .sc-layer { position: absolute; inset: 0; display: block; transform-origin: 0 0; pointer-events: none; }
142
+ .asst-scene .sc-clip { position: absolute; display: block; overflow: hidden; pointer-events: none; }
115
143
  .asst-scene .sc-dim { stroke-opacity: .5; }
116
144
  .asst-scene .sc-mid { stroke-opacity: .72; }
117
145
  .asst-scene .sc-gold { stroke: var(--sc-gold, var(--asst-brand, rgba(255,215,0,.85))); }
@@ -120,9 +148,13 @@ export const SCENE_CSS = `
120
148
  .asst-scene .sc-fill.sc-mid { fill-opacity: .55; }
121
149
  .asst-scene .sc-fill.sc-gold { fill: var(--sc-gold, var(--asst-brand, rgba(255,215,0,.85))); }
122
150
  .asst-scene .sc-shade { fill: var(--sc-shade, #131316); }
123
- .asst-scene .sc-ring { fill: none; opacity: 0; }
124
- .asst-scene .sc-box { transform-box: fill-box; transform-origin: center; }
125
- .asst-scene .sc-vb { transform-box: view-box; }
151
+ .asst-scene .sc-ring { opacity: 0; }
152
+ /* A layer that only turns or travels gets its compositor layer up front, and only while its scene is
153
+ live and on screen and motion is allowed (no standing hints). Layers that scale are left to the
154
+ browser, which rasterises them at the largest scale they reach (a hint would fix it at rest). */
155
+ @media (prefers-reduced-motion: no-preference) {
156
+ .asst-scene[data-live]:not([data-offscreen] *) :is(.sc-gear-big, .sc-gear-small, .sc-hand, .sc-wrench, .sc-code-scroll, .sc-code-caret, .sc-lens, .sc-lens-mini, .sc-point-browser, .sc-point-computer, .sc-point-mini, .sc-run-dot, .sc-pen, .sc-film, .sc-film-mini, .sc-slide) { will-change: transform; }
157
+ }
126
158
  .asst-scene[data-live] .sc-code-scroll { animation: asst-sc-code 6.6s cubic-bezier(.45, 0, .55, 1) infinite; }
127
159
  .asst-scene[data-live] .sc-code-caret { animation: asst-sc-code-caret 6.6s cubic-bezier(.45, 0, .55, 1) infinite; }
128
160
  .asst-scene[data-live] .sc-caret { animation: asst-rk-caret 1.06s steps(1) infinite; }
@@ -229,23 +261,23 @@ export const SCENE_CSS = `
229
261
  ${codeFrames()}
230
262
  @keyframes asst-sc-spin { to { transform: rotate(360deg); } }
231
263
  @keyframes asst-sc-tick { 0%, 8% { opacity: 0; transform: scale(.4); } 16%, 80% { opacity: 1; transform: scale(1); } 92%, 100% { opacity: 0; transform: scale(1); } }
232
- @keyframes asst-sc-lens { 0%, 100% { transform: translate(0, 0); } 25% { transform: translate(14px, 4px); } 50% { transform: translate(48px, 0); } 75% { transform: translate(34px, 5px); } }
233
- @keyframes asst-sc-lens-mini { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(12px, 2px); } }
234
- @keyframes asst-sc-point-browser { 0%, 22% { transform: translate(92px, 29px); } 46%, 72% { transform: translate(26px, 16px); } 96%, 100% { transform: translate(92px, 29px); } }
235
- @keyframes asst-sc-point-computer { 0%, 22% { transform: translate(90px, 22.5px); } 46%, 72% { transform: translate(32px, 9px); } 96%, 100% { transform: translate(90px, 22.5px); } }
236
- @keyframes asst-sc-point-mini { 0%, 24% { transform: translate(29px, 13px) scale(.6); } 48%, 74% { transform: translate(12px, 8px) scale(.6); } 98%, 100% { transform: translate(29px, 13px) scale(.6); } }
264
+ @keyframes asst-sc-lens { 0%, 100% { ${sc(FULL, 0, 0)} } 25% { ${sc(FULL, 14, 4)} } 50% { ${sc(FULL, 48, 0)} } 75% { ${sc(FULL, 34, 5)} } }
265
+ @keyframes asst-sc-lens-mini { 0%, 100% { ${sc(MINI, 0, 0)} } 50% { ${sc(MINI, 12, 2)} } }
266
+ ${pointFrames('asst-sc-point-browser', FULL, [92, 29], [26, 16])}
267
+ ${pointFrames('asst-sc-point-computer', FULL, [90, 22.5], [32, 9])}
268
+ ${pointFrames('asst-sc-point-mini', MINI, [29, 13], [12, 8], 'scale(.6)', [24, 48, 74, 98])}
237
269
  @keyframes asst-sc-ripple { 0% { opacity: .9; transform: scale(.3); } 16% { opacity: 0; transform: scale(1.5); } 100% { opacity: 0; transform: scale(1.5); } }
238
- @keyframes asst-sc-run { 0% { opacity: 0; transform: translateX(0); } 8%, 12% { opacity: 1; transform: translateX(0); } 45%, 55% { transform: translateX(23px); } 88% { opacity: 1; transform: translateX(46px); } 100% { opacity: 0; transform: translateX(46px); } }
270
+ @keyframes asst-sc-run { 0% { opacity: 0; ${sc(FULL, 0, 0)} } 8%, 12% { opacity: 1; ${sc(FULL, 0, 0)} } 45%, 55% { ${sc(FULL, 23, 0)} } 88% { opacity: 1; ${sc(FULL, 46, 0)} } 100% { opacity: 0; ${sc(FULL, 46, 0)} } }
239
271
  @keyframes asst-sc-shape { 0%, 6% { opacity: 0; transform: scale(.4); } 16%, 84% { opacity: 1; transform: scale(1); } 94%, 100% { opacity: 0; transform: scale(.9); } }
240
- @keyframes asst-sc-pen { 0%, 8% { transform: translate(47px, 13px); } 18%, 24% { transform: translate(73px, 13px); } 32%, 84% { transform: translate(100px, 20px); } 100% { transform: translate(47px, 13px); } }
272
+ @keyframes asst-sc-pen { 0%, 8% { ${sc(FULL, 47, 13)} } 18%, 24% { ${sc(FULL, 73, 13)} } 32%, 84% { ${sc(FULL, 100, 20)} } 100% { ${sc(FULL, 47, 13)} } }
241
273
  @keyframes asst-sc-develop { 0% { opacity: .08; } 30%, 82% { opacity: 1; } 100% { opacity: .08; } }
242
274
  @keyframes asst-sc-twinkle { 0%, 100% { opacity: .35; transform: scale(.7) rotate(0deg); } 50% { opacity: 1; transform: scale(1.1) rotate(45deg); } }
243
275
  @keyframes asst-sc-zap { 0%, 100% { opacity: .45; transform: scale(.92); } 40% { opacity: 1; transform: scale(1.06); } }
244
- @keyframes asst-sc-film { to { transform: translateX(-24px); } }
245
- @keyframes asst-sc-film-mini { to { transform: translateX(-12px); } }
276
+ @keyframes asst-sc-film { to { ${sc(win(FILM_WINDOW), -24, 0)} } }
277
+ @keyframes asst-sc-film-mini { to { ${sc(win(FILM_MINI_WINDOW), -12, 0)} } }
246
278
  @keyframes asst-sc-wrench { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(16deg); } }
247
279
  @keyframes asst-sc-spark { 0%, 38% { opacity: 0; } 50% { opacity: 1; } 72%, 100% { opacity: 0; } }
248
- @keyframes asst-sc-slide { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(68px); } }
280
+ @keyframes asst-sc-slide { 0%, 100% { ${sc(FULL, 0, 0)} } 50% { ${sc(FULL, 68, 0)} } }
249
281
  @keyframes asst-sc-dot { 0%, 100% { opacity: .3; } 40% { opacity: 1; } }
250
282
  body.ui-interacting .asst-scene *,
251
283
  body.ui-interacting .asst-run-stage-sweep::after { animation-play-state: paused !important; }