PageSourceSearch

https://deskaioffice-izpa6x3b.manus.space/living-signal.js?v=20260726d

js deskaioffice-izpa6x3b.manus.space collected 2026-10-03 05:01:50 UTC 38,629 bytes, 857 lines download raw bytes

1/* ==========================================================================
2   Living Signal — vanilla ES5-compatible IIFE. Exposes window.LivingSignal.
3   ========================================================================== */
4(function (global) {
5  "use strict";
6
7  var TAU = Math.PI * 2;
8
9  /* ---------------------------------------------------------------- random */
10  /** Deterministic PRNG. Same seed -> same sequence. */
11  function mulberry32(seed) {
12    var a = seed >>> 0;
13    return function () {
14      a += 0x6d2b79f5;
15      var t = a;
16      t = Math.imul(t ^ (t >>> 15), t | 1);
17      t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
18      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
19    };
20  }
21
22  /* ----------------------------------------------------------------- noise */
23  var GRAD3 = [
24    [1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],
25    [1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],
26    [0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]
27  ];
28  var F3 = 1 / 3, G3 = 1 / 6;
29
30  /** Seeded 3D simplex noise (after Gustavson's public-domain reference). */
31  function createNoise3(seed) {
32    var rand = mulberry32(seed);
33    var p = new Uint8Array(256), i, j, tmp;
34    for (i = 0; i < 256; i++) p[i] = i;
35    for (i = 255; i > 0; i--) {
36      j = Math.floor(rand() * (i + 1));
37      tmp = p[i]; p[i] = p[j]; p[j] = tmp;
38    }
39    var perm = new Uint8Array(512), permMod12 = new Uint8Array(512);
40    for (i = 0; i < 512; i++) { perm[i] = p[i & 255]; permMod12[i] = perm[i] % 12; }
41
42    return function sample(xin, yin, zin) {
43      var s = (xin + yin + zin) * F3;
44      var i0 = Math.floor(xin + s), j0 = Math.floor(yin + s), k0 = Math.floor(zin + s);
45      var t = (i0 + j0 + k0) * G3;
46      var x0 = xin - (i0 - t), y0 = yin - (j0 - t), z0 = zin - (k0 - t);
47      var i1, j1, k1, i2, j2, k2;
48      if (x0 >= y0) {
49        if (y0 >= z0)      { i1=1;j1=0;k1=0; i2=1;j2=1;k2=0; }
50        else if (x0 >= z0) { i1=1;j1=0;k1=0; i2=1;j2=0;k2=1; }
51        else               { i1=0;j1=0;k1=1; i2=1;j2=0;k2=1; }
52      } else {
53        if (y0 < z0)       { i1=0;j1=0;k1=1; i2=0;j2=1;k2=1; }
54        else if (x0 < z0)  { i1=0;j1=1;k1=0; i2=0;j2=1;k2=1; }
55        else               { i1=0;j1=1;k1=0; i2=1;j2=1;k2=0; }
56      }
57      var x1=x0-i1+G3, y1=y0-j1+G3, z1=z0-k1+G3;
58      var x2=x0-i2+2*G3, y2=y0-j2+2*G3, z2=z0-k2+2*G3;
59      var x3=x0-1+3*G3, y3=y0-1+3*G3, z3=z0-1+3*G3;
60      var ii=i0&255, jj=j0&255, kk=k0&255, n=0, g, t0, t1, t2, t3;
61      t0 = 0.6-x0*x0-y0*y0-z0*z0;
62      if (t0>0){ g=GRAD3[permMod12[ii+perm[jj+perm[kk]]]]; t0*=t0; n+=t0*t0*(g[0]*x0+g[1]*y0+g[2]*z0); }
63      t1 = 0.6-x1*x1-y1*y1-z1*z1;
64      if (t1>0){ g=GRAD3[permMod12[ii+i1+perm[jj+j1+perm[kk+k1]]]]; t1*=t1; n+=t1*t1*(g[0]*x1+g[1]*y1+g[2]*z1); }
65      t2 = 0.6-x2*x2-y2*y2-z2*z2;
66      if (t2>0){ g=GRAD3[permMod12[ii+i2+perm[jj+j2+perm[kk+k2]]]]; t2*=t2; n+=t2*t2*(g[0]*x2+g[1]*y2+g[2]*z2); }
67      t3 = 0.6-x3*x3-y3*y3-z3*z3;
68      if (t3>0){ g=GRAD3[permMod12[ii+1+perm[jj+1+perm[kk+1]]]]; t3*=t3; n+=t3*t3*(g[0]*x3+g[1]*y3+g[2]*z3); }
69      return 32 * n;
70    };
71  }
72
73  /* ================================== TIMELINE ============================ */
74  /*
75     One conversational turn: listen -> think -> speak -> settle.
76     Every envelope is C0-continuous at the loop seam (value at u=1 equals value
77     at u=0). That is load-bearing: a discontinuity is a visible pop each loop.
78  */
79  var INTRO_MS = 1400;
80  var LOOP_MS  = 11000;
81  var MAX_DT_MS = 80;
82
83  /* Motion rate limits. Kept narrow on purpose: a wide span plus a fast return
84     to normal speed reads as the animation "catching up". Deceleration reads as
85     calming; sudden acceleration reads as a glitch. */
86  var SETTLE_SLOW  = 0.18;
87  var ENERGY_SPEED = 0.28;
88
89  function smoothstep(e0, e1, x) {
90    var t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
91    return t * t * (3 - 2 * t);
92  }
93  /** Smooth trapezoid: rises a->b, holds b->c, falls c->d. */
94  function bump(u, a, b, c, d) { return smoothstep(a, b, u) * (1 - smoothstep(c, d, u)); }
95
96  function energyAt(u) { return 0.2 + 0.7 * bump(u, 0.24, 0.42, 0.82, 0.99); }
97  /* The down-ramp (0.91 -> 1.0) is the return to normal speed and needs the
98     whole tail of the loop to happen in. */
99  function settleAt(u) { return bump(u, 0.84, 0.91, 0.93, 1.0); }
100  /** Global motion rate. Strictly positive, so motionT can never stall. */
101  function speedAt(u) {
102    var active = (energyAt(u) - 0.2) / 0.7;
103    return (1 - SETTLE_SLOW * settleAt(u)) * (1 + ENERGY_SPEED * active);
104  }
105
106  /* Cumulative integral of speed over one loop, sampled once.
107     WHY: motionT must be the INTEGRAL of the rate, never elapsedTime * rate.
108     The latter scales total elapsed time by a rate that changes, which
109     retroactively rewrites the angle's whole history — measured at 182x nominal
110     angular velocity three minutes in, worsening forever. A table keeps the
111     integral O(1) and keeps the timeline a pure function of time, which is what
112     makes seek() reproducible and pause/resume seamless. */
113  var SPEED_LUT_N = 128;
114  var speedLut = (function () {
115    var lut = new Float64Array(SPEED_LUT_N + 1), step = 1 / SPEED_LUT_N, acc = 0;
116    for (var i = 1; i <= SPEED_LUT_N; i++) {
117      acc += ((speedAt((i - 1) * step) + speedAt(i * step)) / 2) * step;
118      lut[i] = acc;
119    }
120    return lut;
121  })();
122
123  function speedIntegral(u) {
124    var x = Math.min(1, Math.max(0, u)) * SPEED_LUT_N;
125    var i = Math.min(SPEED_LUT_N - 1, Math.floor(x));
126    return speedLut[i] + (speedLut[i + 1] - speedLut[i]) * (x - i);
127  }
128
129  /** Integrated motion time in seconds. Monotonic; never stalls or reverses. */
130  function motionSeconds(tMs) {
131    var t = Math.max(0, tMs) / 1000, introS = INTRO_MS / 1000;
132    if (t <= introS) return t;
133    var loopS = LOOP_MS / 1000, rel = t - introS;
134    var loops = Math.floor(rel / loopS);
135    var u = (rel - loops * loopS) / loopS;
136    return introS + (loops * speedLut[SPEED_LUT_N] + speedIntegral(u)) * loopS;
137  }
138
139  /* Speech energy: a phrase swell carrying a syllable train at ~2.8/4.3/6.6 Hz.
140     Weighted toward the MID and UPPER rates — articulation lives there; a train
141     dominated by its slowest component reads as a swell, not as speech. Deep
142     modulation is safe ONLY because `voice` drives light alone. */
143  function speechEnergy(tSec) {
144    var s1 = 0.5 + 0.5 * Math.sin(tSec * 17.3);
145    var s2 = 0.5 + 0.5 * Math.sin(tSec * 26.9 + 1.3);
146    var s3 = 0.5 + 0.5 * Math.sin(tSec * 41.7 + 2.6);
147    var syllables = 0.45 * s1 + 0.35 * s2 + 0.2 * s3;
148    var raw = Math.min(1, Math.max(0, Math.sin(tSec * 1.9) * 0.72 + 0.5));
149    var phrase = raw * raw * (3 - 2 * raw);
150    return phrase * (0.2 + 0.8 * syllables);
151  }
152
153  function sampleTimeline(tMs) {
154    var t = Math.max(0, tMs);
155    var inIntro = t < INTRO_MS;
156    var intro = inIntro ? smoothstep(0, 1, t / INTRO_MS) : 1;
157    var loopT = inIntro ? 0 : (t - INTRO_MS) % LOOP_MS;
158    var loopIndex = inIntro ? -1 : Math.floor((t - INTRO_MS) / LOOP_MS);
159    var u = loopT / LOOP_MS;
160    var speaking = bump(u, 0.34, 0.44, 0.78, 0.9);
161    return {
162      tMs: t, loopIndex: loopIndex, u: u, intro: intro,
163      energy: energyAt(u),
164      voice: speaking * speechEnergy(t / 1000),
165      speaking: speaking,
166      focus: 0.1 + 0.6 * bump(u, 0.22, 0.38, 0.84, 0.98),
167      breath: 1 - 0.45 * bump(u, 0.26, 0.46, 0.86, 0.99),
168      settle: settleAt(u),
169      motionT: motionSeconds(t)
170    };
171  }
172
173  /* Static frame: energy is up but `speaking` is exactly 0, so it is never
174     caught mid-syllable. */
175  var RESOLVED_SNAPSHOT_MS = INTRO_MS + LOOP_MS * 0.32;
176
177  function clampDt(dtMs) {
178    if (!isFinite(dtMs) || dtMs < 0) return 0;
179    return Math.min(dtMs, MAX_DT_MS);
180  }
181
182  /* ================================== RENDERER ============================ */
183  var MAX_DPR = 2;
184  var R_FRAC = 0.264;         /* orb radius / min(width, height) */
185  var BOX_R = 2.5;            /* working box around the orb, in radii */
186  var BLUR_DIV = [6, 9];
187  /* Fallback path (no working ctx.filter, e.g. Safari): a smear pass does the
188     real blurring, so this no longer has to be extreme to hide hard edges. */
189  var BLUR_DIV_FALLBACK = [9, 13];
190  /* Taps for the fallback smear, as a fraction of the blur buffer width. */
191  var SMEAR_RADIUS = 0.055;
192  var BLUR_FRAC = 0.2;        /* Gaussian radius / orb radius */
193  var LOBE_SAMPLES = 48;
194
195  /* Silhouette morph. Set MORPH_STRENGTH to 0 for a perfect circle.
196     Amplitudes are MODULATED by per-harmonic LFOs — fixed amplitudes give a
197     slowly rotating lumpy outline that reads as a defective circle rather than
198     a living form. Orders stay low (2-3); higher orders read as dents. */
199  var MORPH_STRENGTH = 1;
200  var MORPH_SAMPLES = 128;
201  var MORPH_ORDERS = [2, 3];
202  var MORPH_AMP = [0.058, 0.034];
203  var MORPH_NOISE = 0.005;
204  var BREATH_AMP = 0.03;
205
206  /* Value ramp in blue. `navy` matches a typical dark-UI surface tone. */
207  var DEFAULT_PALETTE = {
208    deep: "#060b1a",
209    navy: "#0c1b3a",
210    blue: "#1e5bff",
211    sky: "#7fb4ee",
212    purple: "#bf5af2",
213    coreWhite: "#dceeff",
214    border: "rgba(229, 229, 234, 0.14)",
215    surfaceDeep: "#0b1b35",
216    highlight: "#aab7c8"
217  };
218
219  function hexToRgb(hex) {
220    var h = hex.replace("#", "");
221    if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
222    var n = parseInt(h, 16);
223    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
224  }
225  function rgba(hex, a) {
226    var c = hexToRgb(hex);
227    return "rgba(" + c[0] + ", " + c[1] + ", " + c[2] + ", " + a + ")";
228  }
229
230  function createRenderer(canvas, palette, seed) {
231    var ctx = canvas.getContext("2d");
232    if (!ctx) return null;
233    var noise = createNoise3(seed ^ 0x5f3759df);
234
235    /* Masses live OUTSIDE the circle and only bleed in: radius ~1.55R centred
236       ~1.75R out, so the near edge stops around 0.2R. Sizing them to cover the
237       circle floods the dark centre and the depth disappears.
238       Ordered as a value ramp, darkest first, weights descending as tiers
239       brighten so the pale tier reads as a light source. */
240    var masses = [
241      { color: palette.navy,   orbit: 1.50, orbitSpeed: -0.15, orbitPhase: Math.PI*0.20, size: 1.75, lobes: 3, depth: 0.12, spin:  0.24, weight: 1.00 },
242      { color: palette.blue,   orbit: 1.72, orbitSpeed:  0.27, orbitPhase: Math.PI*0.62, size: 1.55, lobes: 5, depth: 0.16, spin:  0.56, weight: 0.90 },
243      { color: palette.sky,    orbit: 1.86, orbitSpeed:  0.35, orbitPhase: Math.PI*1.05, size: 1.50, lobes: 4, depth: 0.13, spin:  0.68, weight: 0.66, gated: true },
244      { color: palette.purple, orbit: 1.80, orbitSpeed: -0.20, orbitPhase: Math.PI*1.68, size: 1.60, lobes: 6, depth: 0.18, spin: -0.44, weight: 0.34 }
245    ];
246
247    /* Grain: white with per-pixel ALPHA so it composites with plain
248       source-over. Mid-grey noise under `overlay` is more faithful but costs a
249       separable blend over ~2M device px per frame. */
250    var grainPattern = (function () {
251      var N = 128, c = document.createElement("canvas");
252      c.width = N; c.height = N;
253      var g = c.getContext("2d");
254      if (!g) return null;
255      var img = g.createImageData(N, N), r = mulberry32(seed ^ 0x27d4eb2f);
256      for (var i = 0; i < N * N; i++) {
257        img.data[i*4] = 255; img.data[i*4+1] = 255; img.data[i*4+2] = 255;
258        img.data[i*4+3] = r() * 255;
259      }
260      g.putImageData(img, 0, 0);
261      return c;
262    })();
263    var grainFill = null;
264
265    var morphRand = mulberry32(seed ^ 0x1b873593);
266    var morphPhase = [], morphSpeed = [], morphLfoRate = [], morphLfoPhase = [];
267    for (var k = 0; k < MORPH_ORDERS.length; k++) {
268      morphPhase[k] = morphRand() * TAU;
269      morphSpeed[k] = (0.11 + morphRand() * 0.09) * (k % 2 === 0 ? 1 : -1);
270      morphLfoRate[k] = 0.21 + morphRand() * 0.22;
271      morphLfoPhase[k] = morphRand() * TAU;
272    }
273    var morphX = new Float32Array(MORPH_SAMPLES), morphY = new Float32Array(MORPH_SAMPLES);
274
275    var bufBlur = document.createElement("canvas");
276    var bufOrb = document.createElement("canvas");
277    var blurCtx = bufBlur.getContext("2d");
278    var orbCtx = bufOrb.getContext("2d");
279    /* Ping-pong pair for the no-ctx.filter fallback (see progressiveUpscale). */
280    var tmpA = document.createElement("canvas"), tmpB = document.createElement("canvas");
281    var tmpACtx = tmpA.getContext("2d"), tmpBCtx = tmpB.getContext("2d");
282
283    /**
284     * Upscale in repeated 2x steps rather than one giant jump.
285     *
286     * Only used when ctx.filter is unavailable. A single 16x bilinear step
287     * leaves large linear ramps between source texels, which read as blocks —
288     * and the masses are hard-edged polygons, so there is nothing soft to hide
289     * it. Chaining 2x steps compounds the filtering into something much closer
290     * to a real blur. This is the difference between "soft" and "pixelated" on
291     * Safari 16 and older WebViews.
292     */
293    /**
294     * Nine-tap box smear over the blur buffer — the fallback's actual blur.
295     *
296     * Alpha is 1/(i+1) per tap, which makes each draw an exact running mean of
297     * the taps so far. Using a constant 1/n instead would let the last taps
298     * dominate and skew the result off-centre.
299     */
300    var SMEAR_TAPS = [[0,0],[1,0],[-1,0],[0,1],[0,-1],[0.7,0.7],[-0.7,-0.7],[0.7,-0.7],[-0.7,0.7]];
301    var SMEAR_PASSES = 3;
302    /**
303     * @param sigmaBuf target Gaussian sigma, in blur-buffer pixels.
304     *
305     * Tap radius is derived from the sigma the ctx.filter path would have used,
306     * NOT from a fixed fraction of the buffer. A fixed 5.5% happened to land at
307     * roughly a third of the needed radius, which left the mass polygons'
308     * overlap creases clearly visible. Repeated box passes converge on a
309     * Gaussian: for n passes of radius r, sigma = r * sqrt(n/3), so n=3 gives
310     * r = sigma directly.
311     */
312    function smearFallback(sigmaBuf) {
313      if (!blurCtx || !tmpACtx) return;
314      var w = bufBlur.width;
315      if (tmpA.width !== w) { tmpA.width = w; tmpA.height = w; }
316      var r = Math.max(1, sigmaBuf);
317      for (var pass = 0; pass < SMEAR_PASSES; pass++) {
318        tmpACtx.setTransform(1, 0, 0, 1, 0, 0);
319        tmpACtx.globalCompositeOperation = "source-over";
320        tmpACtx.globalAlpha = 1;
321        tmpACtx.clearRect(0, 0, w, w);
322        tmpACtx.drawImage(bufBlur, 0, 0);
323
324        blurCtx.setTransform(1, 0, 0, 1, 0, 0);
325        blurCtx.globalCompositeOperation = "source-over";
326        blurCtx.clearRect(0, 0, w, w);
327        for (var i = 0; i < SMEAR_TAPS.length; i++) {
328          /* 1/(i+1) makes each draw an exact running mean of the taps so far;
329             a constant 1/n would let the last taps dominate and skew it. */
330          blurCtx.globalAlpha = 1 / (i + 1);
331          blurCtx.drawImage(tmpA, SMEAR_TAPS[i][0] * r, SMEAR_TAPS[i][1] * r);
332        }
333        blurCtx.globalAlpha = 1;
334      }
335    }
336
337    function progressiveUpscale(src, targetPx) {
338      var cur = src, w = src.width, useA = true;
339      while (w * 2 < targetPx) {
340        w *= 2;
341        var dst = useA ? tmpA : tmpB;
342        var dctx = useA ? tmpACtx : tmpBCtx;
343        if (!dctx) break;
344        if (dst.width !== w) { dst.width = w; dst.height = w; }
345        dctx.imageSmoothingEnabled = true;
346        dctx.clearRect(0, 0, w, w);
347        dctx.drawImage(cur, 0, 0, w, w);
348        cur = dst;
349        useA = !useA;
350      }
351      return cur;
352    }
353
354    /**
355     * Does ctx.filter ACTUALLY blur? Probe it functionally, never by property.
356     *
357     * Safari reports `ctx.filter` support — the property exists and round-trips
358     * the string — but does not apply the filter under the conditions this
359     * renderer uses it (a scaled transform with `lighter` compositing). A
360     * property check therefore returns true, the sharp path is taken, and the
361     * masses land as hard-edged polygons at 1/6 resolution. That is precisely
362     * the "works in Chrome, not blurred in Safari" symptom.
363     *
364     * So: replicate the real drawing conditions, then read back a pixel well
365     * outside the shape. If light did not spread there, the filter is a no-op
366     * regardless of what the property says.
367     */
368    var supportsFilter = (function () {
369      try {
370        var c = document.createElement("canvas");
371        c.width = 64; c.height = 64;
372        var g = c.getContext("2d");
373        if (!g || typeof g.filter !== "string") return false;
374        g.setTransform(2, 0, 0, 2, 0, 0);             /* scaled, as in real use */
375        g.globalCompositeOperation = "lighter";        /* additive, as in real use */
376        g.filter = "blur(4px)";
377        if (g.filter !== "blur(4px)") return false;
378        g.fillStyle = "#ffffff";
379        g.fillRect(8, 8, 16, 16);                      /* device px 16..48 */
380        g.filter = "none";
381        g.setTransform(1, 0, 0, 1, 0, 0);
382        /* Sample just outside the hard edge (device x=16). A large source and a
383           close sample keep this well clear of the noise floor — an earlier
384           version probed 8px out from an 8px rect and the true signal was only
385           ~1-3/255, so it read as "unsupported" on browsers that work fine. */
386        var spread = g.getImageData(11, 32, 1, 1).data[3];
387        var centre = g.getImageData(32, 32, 1, 1).data[3];
388        return centre > 0 && spread > 8;
389      } catch (e) {
390        return false;   /* tainted canvas / getImageData blocked -> assume no */
391      }
392    })();
393    /* Escape hatch for testing the fallback path on a browser that has a
394       working filter: LivingSignal.forceFallback = true, before mount. */
395    if (global.LivingSignal && global.LivingSignal.forceFallback) supportsFilter = false;
396    var divTable = supportsFilter ? BLUR_DIV : BLUR_DIV_FALLBACK;
397
398    var wCss = 0, hCss = 0, dpr = 1, orbR = 0, boxCss = 0;
399    var lastTMs = -1, currentTier = 0, pendingTier = 0, lastLoopIndex = -2;
400    var emaSpacingMs = 16.7, emaCpuMs = 4, slowFrames = 0, fastFrames = 0;
401    var frameParity = 0, disposed = false;
402
403    function resizeBuffers() {
404      var boxPx = Math.max(8, Math.round(boxCss * dpr));
405      if (bufOrb.width !== boxPx) {
406        bufOrb.width = boxPx; bufOrb.height = boxPx;
407        grainFill = null;   /* resizing resets context state */
408      }
409      var blurPx = Math.max(4, Math.round(boxPx / divTable[currentTier]));
410      if (bufBlur.width !== blurPx) { bufBlur.width = blurPx; bufBlur.height = blurPx; }
411      /* Resizing a canvas resets context state; smoothing must be re-asserted
412         or every upscale in this renderer turns into nearest-neighbour blocks. */
413      if (blurCtx) blurCtx.imageSmoothingEnabled = true;
414      if (orbCtx) orbCtx.imageSmoothingEnabled = true;
415      if (ctx) ctx.imageSmoothingEnabled = true;
416    }
417    function applyTier() { currentTier = pendingTier; resizeBuffers(); }
418
419    function resize(w, h, ratio) {
420      wCss = Math.max(0, w); hCss = Math.max(0, h);
421      dpr = Math.min(MAX_DPR, Math.max(1, ratio));
422      var bw = Math.round(wCss * dpr), bh = Math.round(hCss * dpr);
423      if (canvas.width !== bw || canvas.height !== bh) { canvas.width = bw; canvas.height = bh; }
424      orbR = Math.min(wCss, hCss) * R_FRAC;
425      boxCss = orbR * BOX_R;
426      resizeBuffers();
427    }
428
429    /* Solid fill is load-bearing: a radial-gradient fill desaturates once blurred. */
430    function drawMass(target, m, mt, c, r, focus) {
431      /* Keep the inward pull small — past ~10% the near edges cross the centre,
432         the dark middle floods, and the speaking beat blows out to white. */
433      var orbit = m.orbit * (1 - 0.1 * focus);
434      var oa = m.orbitPhase + mt * m.orbitSpeed;
435      var mx = c + Math.cos(oa) * r * orbit;
436      var my = c + Math.sin(oa) * r * orbit;
437      var spin = mt * m.spin, base = r * m.size;
438      target.fillStyle = m.color;
439      target.beginPath();
440      for (var i = 0; i <= LOBE_SAMPLES; i++) {
441        var a = (i / LOBE_SAMPLES) * TAU;
442        var wob = 1 + m.depth * Math.sin(m.lobes * (a + spin));
443        var x = mx + Math.cos(a) * base * wob;
444        var y = my + Math.sin(a) * base * wob;
445        if (i === 0) target.moveTo(x, y); else target.lineTo(x, y);
446      }
447      target.closePath();
448      target.fill();
449    }
450
451    function drawBlurLayer(sample) {
452      if (!blurCtx) return;
453      var s = bufBlur.width / boxCss, mt = sample.motionT, c = boxCss / 2;
454      var introA = sample.intro * sample.intro;
455
456      blurCtx.setTransform(s, 0, 0, s, 0, 0);
457      blurCtx.globalCompositeOperation = "source-over";
458      blurCtx.globalAlpha = 1;
459      blurCtx.filter = "none";
460      /* Graded navy base — the shadows still carry blue, never dead black. */
461      var bg = blurCtx.createLinearGradient(0, 0, boxCss * 0.6, boxCss);
462      bg.addColorStop(0, palette.deep);
463      bg.addColorStop(0.55, rgba(palette.navy, 0.55));
464      bg.addColorStop(1, palette.deep);
465      blurCtx.fillStyle = bg;
466      blurCtx.fillRect(0, 0, boxCss, boxCss);
467
468      blurCtx.globalCompositeOperation = "lighter";
469      if (supportsFilter) blurCtx.filter = "blur(" + (orbR * BLUR_FRAC * s).toFixed(2) + "px)";
470      /* Capped below 1: saturated masses at full alpha under `lighter` sum to
471         white wherever they overlap, collapsing the value ramp. */
472      var gain = (0.64 + 0.3 * sample.energy) * (0.86 + 0.14 * sample.voice) * introA;
473      for (var i = 0; i < masses.length; i++) {
474        var m = masses[i];
475        var w = m.gated ? m.weight * (0.55 + 0.45 * sample.energy) : m.weight;
476        blurCtx.globalAlpha = Math.min(1, gain * w);
477        drawMass(blurCtx, m, mt, c, orbR, sample.focus);
478      }
479      blurCtx.filter = "none";
480
481      /* Without a working ctx.filter the masses are still hard polygons at this
482         point — smear them before anything soft is layered on top. */
483      if (!supportsFilter) {
484        smearFallback(orbR * BLUR_FRAC * s);
485        blurCtx.setTransform(s, 0, 0, s, 0, 0);
486        blurCtx.globalCompositeOperation = "lighter";
487      }
488
489      /* Centre bloom — the "speaking" tell. Brightness, not geometry. The voice
490         term dominates: this is the syllable-rate flicker that reads as talking. */
491      var bloomR = orbR * (0.72 + 0.2 * sample.energy);
492      var bloom = blurCtx.createRadialGradient(c, c, 0, c, c, bloomR);
493      var peak = (0.02 + 0.05 * sample.energy + 0.26 * sample.voice) * introA;
494      bloom.addColorStop(0, rgba(palette.coreWhite, peak));
495      bloom.addColorStop(0.4, rgba(palette.sky, peak * 0.5));
496      bloom.addColorStop(1, rgba(palette.sky, 0));
497      blurCtx.globalAlpha = 1;
498      blurCtx.fillStyle = bloom;
499      blurCtx.fillRect(0, 0, boxCss, boxCss);
500      blurCtx.globalCompositeOperation = "source-over";
501    }
502
503    /* Noise is sampled at (cos t, sin t) rather than at t — walking the unit
504       circle guarantees the value at 0 and 2PI matches, so the closed path has
505       no seam. Sampling at the angle directly leaves a kink at 3 o'clock. */
506    function buildOrbPath(sample, cx, cy, r) {
507      var mt = sample.motionT, amp = 1 - 0.35 * sample.settle, j, kk;
508      for (j = 0; j < MORPH_SAMPLES; j++) {
509        var th = (j / MORPH_SAMPLES) * TAU, ct = Math.cos(th), st = Math.sin(th), d = 0;
510        for (kk = 0; kk < MORPH_ORDERS.length; kk++) {
511          /* Amplitude never fully vanishes (floor 0.18), or the shape snaps to a
512             circle and back, which reads as a glitch rather than a breath. */
513          var lfo = 0.18 + 0.82 * (0.5 + 0.5 * Math.sin(mt * morphLfoRate[kk] + morphLfoPhase[kk]));
514          d += MORPH_AMP[kk] * lfo * Math.sin(MORPH_ORDERS[kk] * th + morphPhase[kk] + mt * morphSpeed[kk]);
515        }
516        d += MORPH_NOISE * noise(ct * 1.5, st * 1.5, mt * 0.06);
517        d *= MORPH_STRENGTH;
518        var rr = r * (1 + d * amp);
519        morphX[j] = cx + ct * rr;
520        morphY[j] = cy + st * rr;
521      }
522      var p = new Path2D();
523      p.moveTo((morphX[0] + morphX[MORPH_SAMPLES-1]) / 2, (morphY[0] + morphY[MORPH_SAMPLES-1]) / 2);
524      for (j = 0; j < MORPH_SAMPLES; j++) {
525        var n = (j + 1) % MORPH_SAMPLES;
526        p.quadraticCurveTo(morphX[j], morphY[j], (morphX[j]+morphX[n])/2, (morphY[j]+morphY[n])/2);
527      }
528      p.closePath();
529      return p;
530    }
531
532    function composite(sample) {
533      if (!orbCtx) return;
534      var box = bufOrb.width;
535      orbCtx.setTransform(1, 0, 0, 1, 0, 0);
536      orbCtx.globalCompositeOperation = "source-over";
537      orbCtx.globalAlpha = 1;
538      orbCtx.clearRect(0, 0, box, box);
539      orbCtx.imageSmoothingEnabled = true;
540      var src = supportsFilter ? bufBlur : progressiveUpscale(bufBlur, box);
541      orbCtx.drawImage(src, 0, 0, src.width, src.height, 0, 0, box, box);
542
543      /* Grain BEFORE the mask, so it never dirties the page behind the orb. */
544      if (grainPattern) {
545        if (!grainFill) grainFill = orbCtx.createPattern(grainPattern, "repeat");
546        if (grainFill) {
547          var inset = box * 0.1;
548          orbCtx.globalAlpha = 0.05;
549          orbCtx.fillStyle = grainFill;
550          orbCtx.fillRect(inset, inset, box - inset * 2, box - inset * 2);
551          orbCtx.globalAlpha = 1;
552        }
553      }
554
555      /* Breathe on RAW time, morph on MOTION time: two independent rhythms read
556         organic where one shared clock reads as a metronome. Neither reads voice. */
557      var t = sample.tMs / 1000;
558      var breathe = 1 + BREATH_AMP * sample.breath * Math.sin(t * 0.34);
559      var rPx = (orbR / boxCss) * box * breathe * (0.55 + 0.45 * sample.intro);
560
561      /* destination-in against a filled path, NOT ctx.clip(): clip() is not
562         reliably antialiased and a stair-stepped edge kills this look. */
563      orbCtx.globalCompositeOperation = "destination-in";
564      orbCtx.fillStyle = "#fff";
565      orbCtx.fill(buildOrbPath(sample, box / 2, box / 2, rPx));
566      orbCtx.globalCompositeOperation = "source-over";
567    }
568
569    function frame(sample, pointerX, pointerY) {
570      if (disposed || wCss <= 2 || hCss <= 2 || orbR <= 2 || !blurCtx || !orbCtx) return;
571      var started = (performance && performance.now) ? performance.now() : 0;
572      var advancing = lastTMs >= 0 && sample.tMs !== lastTMs;
573      var settleWindow = sample.settle > 0.5 && sample.loopIndex >= 1;
574
575      if (sample.loopIndex !== lastLoopIndex) {
576        lastLoopIndex = sample.loopIndex;
577        if (currentTier !== pendingTier) applyTier();
578      } else if (sample.settle > 0.92 && currentTier !== pendingTier) {
579        applyTier();
580      }
581
582      /* Half-rate during the calm settle — held frames, not dropped content. A
583         frozen render (seek, paused resize) must always draw or it blanks. */
584      frameParity ^= 1;
585      if (settleWindow && frameParity === 1 && advancing) return;
586
587      var dtSec = lastTMs < 0 ? 1/60 : Math.max(0, Math.min(0.1, (sample.tMs - lastTMs) / 1000));
588      lastTMs = sample.tMs;
589
590      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
591      ctx.clearRect(0, 0, wCss, hCss);
592
593      var cx = wCss / 2 + pointerX * wCss * 0.014;
594      var cy = hCss / 2 + pointerY * hCss * 0.012;
595      var mt = sample.motionT;
596
597      drawBlurLayer(sample);
598      composite(sample);
599
600      /* Two-color outer glow, slowly counter-rotating. Blue leads. */
601      var glowGain = (0.42 + 0.45 * sample.energy + 0.55 * sample.voice) * sample.intro;
602      var ga = mt * 0.1 + noise(0.3, mt * 0.04, 1.7) * 0.5;
603      var gr = orbR * 1.9;
604      ctx.globalCompositeOperation = "lighter";
605      for (var i = 0; i < 2; i++) {
606        var col = i === 0 ? palette.blue : palette.purple;
607        var strength = i === 0 ? 1 : 0.55;
608        var ang = ga + i * Math.PI;
609        var gx = cx + Math.cos(ang) * orbR * 0.34;
610        var gy = cy + Math.sin(ang) * orbR * 0.34;
611        var g = ctx.createRadialGradient(gx, gy, orbR * 0.5, gx, gy, gr);
612        g.addColorStop(0, rgba(col, 0.3 * glowGain * strength));
613        g.addColorStop(0.45, rgba(col, 0.1 * glowGain * strength));
614        g.addColorStop(1, rgba(col, 0));
615        ctx.fillStyle = g;
616        ctx.fillRect(cx - gr * 1.1, cy - gr * 1.1, gr * 2.2, gr * 2.2);
617      }
618      ctx.globalCompositeOperation = "source-over";
619      ctx.drawImage(bufOrb, cx - boxCss / 2, cy - boxCss / 2, boxCss, boxCss);
620
621      /* Adaptive quality. Spacing, not CPU time, is the primary signal: canvas
622         rasterizes off the main thread, so a CPU timer stays green while the
623         page janks. Tier switches are applied only at seams, above. */
624      if (advancing && !settleWindow && started) {
625        var cost = performance.now() - started;
626        emaSpacingMs = emaSpacingMs * 0.9 + dtSec * 1000 * 0.1;
627        emaCpuMs = emaCpuMs * 0.9 + cost * 0.1;
628        if (emaSpacingMs > 26) { slowFrames++; fastFrames = 0; }
629        else if (emaSpacingMs < 17.5 && emaCpuMs < 8) { fastFrames++; slowFrames = 0; }
630        else { slowFrames = 0; fastFrames = 0; }
631        if (slowFrames > 30) pendingTier = 1;
632        if (fastFrames > 360) pendingTier = 0;
633      }
634    }
635
636    function seek() {
637      lastTMs = -1; lastLoopIndex = -2; frameParity = 0; pendingTier = 0;
638      if (currentTier !== 0) applyTier();
639      emaSpacingMs = 16.7; emaCpuMs = 4; slowFrames = 0; fastFrames = 0;
640    }
641
642    return {
643      resize: resize, frame: frame, seek: seek,
644      dispose: function () { disposed = true; },
645      diagnostics: function () {
646        return {
647          cssSize: Math.round(wCss) + " x " + Math.round(hCss),
648          devicePixelRatio: window.devicePixelRatio || 1,
649          dprUsed: dpr,
650          canvasBacking: canvas.width + " x " + canvas.height,
651          backingPerCssPx: wCss ? +(canvas.width / wCss).toFixed(2) : 0,
652          orbRadiusCss: Math.round(orbR),
653          orbBufferPx: bufOrb.width,
654          blurBufferPx: bufBlur.width,
655          ctxFilterSupported: supportsFilter,
656          qualityTier: currentTier,
657          imageSmoothing: orbCtx ? orbCtx.imageSmoothingEnabled : null
658        };
659      }
660    };
661  }
662
663  /* ==================================== MOUNT ============================= */
664  function mount(el, options) {
665    if (!el) throw new Error("LivingSignal.mount: no element");
666    options = options || {};
667    var palette = {};
668    for (var key in DEFAULT_PALETTE) palette[key] = DEFAULT_PALETTE[key];
669    if (options.palette) for (key in options.palette) palette[key] = options.palette[key];
670
671    var pauseControl = options.pauseControl || "top-right";
672
673    if (getComputedStyle(el).position === "static") el.style.position = "relative";
674
675    var canvas = document.createElement("canvas");
676    canvas.setAttribute("aria-hidden", "true");
677    /* image-rendering is set explicitly: a host stylesheet with
678       `canvas { image-rendering: pixelated }` (common in resets aimed at sprite
679       art) turns every upscaled buffer into visible blocks. */
680    canvas.style.cssText =
681      "position:absolute;inset:0;width:100%;height:100%;display:block;image-rendering:auto;pointer-events:none";
682    el.appendChild(canvas);
683    el.setAttribute("role", "img");
684    el.setAttribute("aria-label",
685      "Ambient animation: a luminous form listens, brightens as it speaks, and settles again");
686
687    var renderer = createRenderer(canvas, palette, 20260726);
688    if (!renderer) return { destroy: function () {}, setPaused: function () {}, seek: function () {} };
689
690    var tRef = options.startAtMs || 0;
691    var lastNow = null, raf = 0, sized = false, running = false;
692    var inView = false, docVisible = true, userPaused = false;
693    var ptr = { x: 0, y: 0 }, ptrTarget = { x: 0, y: 0 };
694
695    var mqReduce = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
696    function reducedNow() {
697      if (typeof options.reduced === "boolean") return options.reduced;
698      return mqReduce ? mqReduce.matches : false;
699    }
700
701    /* WCAG 2.2.2: motion runs longer than 5s, so a pause control ships with the
702       component. Hide it only if the host supplies its own. */
703    var btn = null;
704    if (pauseControl !== "hidden") {
705      btn = document.createElement("button");
706      btn.type = "button";
707      btn.style.cssText =
708        "position:absolute;top:16px;" + (pauseControl === "top-left" ? "left:16px;" : "right:16px;") +
709        "width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;" +
710        "border-radius:999px;border:1px solid " + palette.border + ";background:rgba(11,27,53,0.55);" +
711        "color:" + palette.highlight + ";cursor:pointer;padding:0;z-index:1";
712      el.appendChild(btn);
713      btn.addEventListener("click", function () { setPaused(!userPaused); });
714    }
715    function paintButton() {
716      if (!btn) return;
717      btn.setAttribute("aria-pressed", String(userPaused));
718      btn.setAttribute("aria-label", userPaused ? "Play animation" : "Pause animation");
719      btn.innerHTML = userPaused
720        ? '<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 1.5 L10.5 6 L3 10.5 Z" fill="currentColor"/></svg>'
721        : '<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><rect x="2.5" y="1.5" width="2.6" height="9" rx="0.8" fill="currentColor"/><rect x="6.9" y="1.5" width="2.6" height="9" rx="0.8" fill="currentColor"/></svg>';
722      btn.style.display = reducedNow() ? "none" : "inline-flex";
723    }
724    paintButton();
725
726    function drawStill(tMs) {
727      if (!sized) return;
728      renderer.frame(sampleTimeline(tMs), ptr.x, ptr.y);
729    }
730
731    function tick(now) {
732      /* Clamped dt: a hidden tab or a long frame must never fast-forward. */
733      var dt = lastNow === null ? 0 : clampDt(now - lastNow);
734      lastNow = now;
735      tRef += dt;
736      var k = 1 - Math.exp((-3.2 * dt) / 1000);
737      ptr.x += (ptrTarget.x - ptr.x) * k;
738      ptr.y += (ptrTarget.y - ptr.y) * k;
739      renderer.frame(sampleTimeline(tRef), ptr.x, ptr.y);
740      raf = requestAnimationFrame(tick);
741    }
742
743    function evaluate() {
744      var reduced = reducedNow();
745      var shouldRun = inView && docVisible && !userPaused && !reduced;
746      if (shouldRun === running) return;
747      running = shouldRun;
748      if (running) { lastNow = null; raf = requestAnimationFrame(tick); }
749      else {
750        cancelAnimationFrame(raf);
751        lastNow = null;
752        /* Reduced motion shows the static frame, but NEVER by mutating the live
753           clock — a later resume must continue from the right instant. */
754        if (reduced && options.startAtMs === undefined) {
755          renderer.seek(RESOLVED_SNAPSHOT_MS);
756          drawStill(RESOLVED_SNAPSHOT_MS);
757        } else {
758          drawStill(tRef);
759        }
760      }
761      paintButton();
762    }
763
764    /* Observe the CANVAS, not the container.
765       The canvas is positioned inset:0, which resolves against the container's
766       PADDING box, while ResizeObserver's contentRect reports the CONTENT box.
767       If the host gives the container any padding or border, those two differ —
768       the backing store is then sized smaller than the element it paints into
769       and the browser stretches it. That reads as "pixelated" and it is the
770       most likely cause of it in an unfamiliar host layout. Measuring the
771       canvas itself makes the mismatch impossible. */
772    var ro = new ResizeObserver(function () {
773      var r = canvas.getBoundingClientRect();
774      renderer.resize(r.width, r.height, window.devicePixelRatio || 1);
775      sized = r.width > 2 && r.height > 2;
776      if (!running) drawStill(tRef);
777    });
778    ro.observe(canvas);
779
780    /* A monitor change can alter DPR with no CSS-size change, so ResizeObserver
781       stays silent — re-arm a resolution query to catch it. */
782    var dprMql = null;
783    function onDpr() {
784      var r = el.getBoundingClientRect();
785      renderer.resize(r.width, r.height, window.devicePixelRatio || 1);
786      if (!running) drawStill(tRef);
787      armDpr();
788    }
789    function armDpr() {
790      if (dprMql && dprMql.removeEventListener) dprMql.removeEventListener("change", onDpr);
791      dprMql = window.matchMedia("(resolution: " + (window.devicePixelRatio || 1) + "dppx)");
792      if (dprMql.addEventListener) dprMql.addEventListener("change", onDpr);
793    }
794    armDpr();
795
796    var io = new IntersectionObserver(function (entries) {
797      inView = entries[0].isIntersecting;
798      evaluate();
799    }, { threshold: 0.12 });
800    io.observe(el);
801
802    function onVisibility() { docVisible = document.visibilityState === "visible"; evaluate(); }
803    document.addEventListener("visibilitychange", onVisibility);
804    onVisibility();
805
806    function onReduce() { evaluate(); paintButton(); }
807    if (mqReduce && mqReduce.addEventListener) mqReduce.addEventListener("change", onReduce);
808
809    function onMove(e) {
810      var r = el.getBoundingClientRect();
811      if (r.width < 2 || r.height < 2) return;
812      ptrTarget.x = Math.max(-1, Math.min(1, ((e.clientX - r.left) / r.width) * 2 - 1));
813      ptrTarget.y = Math.max(-1, Math.min(1, ((e.clientY - r.top) / r.height) * 2 - 1));
814    }
815    function onLeave() { ptrTarget.x = 0; ptrTarget.y = 0; }
816    el.addEventListener("pointermove", onMove);
817    el.addEventListener("pointerleave", onLeave);
818
819    function setPaused(v) { userPaused = !!v; evaluate(); paintButton(); }
820
821    return {
822      setPaused: setPaused,
823      /** Run this in the console if it renders wrong. See README notes below. */
824      diagnostics: function () {
825        var d = renderer.diagnostics();
826        var cs = getComputedStyle(canvas);
827        d.cssImageRendering = cs.imageRendering;
828        d.canvasClientSize = Math.round(canvas.clientWidth) + " x " + Math.round(canvas.clientHeight);
829        /* Any transform on an ancestor scales the painted result without
830           changing layout size, so the backing store no longer matches pixels. */
831        var scaled = null, n = el;
832        while (n && n !== document.body) {
833          var tr = getComputedStyle(n).transform;
834          if (tr && tr !== "none") { scaled = (n.tagName || "?") + "." + (n.className || ""); break; }
835          n = n.parentElement;
836        }
837        d.ancestorTransform = scaled;
838        return d;
839      },
840      seek: function (ms) { tRef = ms; renderer.seek(ms); if (!running) drawStill(tRef); },
841      destroy: function () {
842        cancelAnimationFrame(raf);
843        ro.disconnect(); io.disconnect();
844        document.removeEventListener("visibilitychange", onVisibility);
845        if (mqReduce && mqReduce.removeEventListener) mqReduce.removeEventListener("change", onReduce);
846        if (dprMql && dprMql.removeEventListener) dprMql.removeEventListener("change", onDpr);
847        el.removeEventListener("pointermove", onMove);
848        el.removeEventListener("pointerleave", onLeave);
849        renderer.dispose();
850        if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
851        if (btn && btn.parentNode) btn.parentNode.removeChild(btn);
852      }
853    };
854  }
855
856  global.LivingSignal = { mount: mount, DEFAULT_PALETTE: DEFAULT_PALETTE };
857})(window);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.