@idle-screens/schema 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/FORMAT.md CHANGED
@@ -195,6 +195,14 @@ the format never changes the entity stream of an existing spec — a spec author
195
195
  against an older runtime renders bit-identically on a newer one
196
196
  (`determinism-baseline.test.ts` guards this with snapshot streams).
197
197
 
198
+ The same discipline applies to **meaning**, not just RNG draws: a new field may
199
+ introduce a default, but a default must never *reinterpret* values that already
200
+ exist in published specs. Ship the field opt-in first, flip the default only in
201
+ a major, and write the stored-spec migration in the same change. (Learned the
202
+ hard way when 2.3.0's `units: "viewport"` default invalidated live channels —
203
+ see idle-server's G5 spec stamping, which now heals reads but can't excuse a
204
+ repeat.)
205
+
198
206
  Positions are **analytic**: `positionAt(entity, t)` is a pure function, so
199
207
  `renderFrame(t, seed)` can seek anywhere instantly, trails sample the past for
200
208
  free, and streak headings derive from a finite difference. `ghosting` is the
@@ -214,11 +222,15 @@ for `ControlTrack` and the idlescreens.com MCP `setParam` tool.
214
222
 
215
223
  `src/perceive.ts` translates a spec into modalities a **non-vision agent** can
216
224
  reason about, computed analytically from the entity model (no canvas, no
217
- renderer, deterministic, Node-safe). Note: the analytical model approximates
218
- but does not perfectly match canvas rendering — blend modes are simplified
219
- (`screen` ≈ `lighter`), wrapped link segments use straight interpolation, and
220
- background drift is sampled at rest. These are documented trade-offs for a
221
- zero-dependency, renderer-free analysis tool.
225
+ renderer, deterministic, Node-safe). Persistence is part of the picture:
226
+ `ghosting` is modeled as a decayed sum of past-frame splats (ink from m frames
227
+ ago survives at weight gᵐ, mirroring the renderer's warm-up replay) and `trail`
228
+ mirrors the renderer's past-position sampling — so the smear an audience sees
229
+ is measurable, not invisible, in every output below. Note: the analytical model
230
+ approximates but does not perfectly match canvas rendering — blend modes are
231
+ simplified (`screen` ≈ `lighter`), wrapped link segments use straight
232
+ interpolation, and background drift is sampled at rest. These are documented
233
+ trade-offs for a zero-dependency, renderer-free analysis tool.
222
234
 
223
235
  - `perceiveScene(spec, {t?, viewport?, seed?})` — one-call bundle: everything below.
224
236
  - `luminanceGrid(spec, opts)` — an 80×48 luminance image of the composed frame
@@ -234,9 +246,10 @@ zero-dependency, renderer-free analysis tool.
234
246
  braille map smears — concentric rings, grids, text blocks. This is the sharper
235
247
  read when composition detail matters.
236
248
  - `dominanceRanking(spec, opts)` — layers ranked by estimated visual weight
237
- (area × alpha × contrast × glow/motion boosts; links count) — *where the eye
238
- goes*, normalized to shares. Thin-but-bright structures (rings, streaks, link
239
- lines) get a line-salience boost so they aren't crushed by filled discs.
249
+ (area × alpha × contrast × glow/motion boosts; links and persistence ink —
250
+ trail ribbons, ghosting smear — count) — *where the eye goes*, normalized to
251
+ shares. Thin-but-bright structures (rings, streaks, link lines) get a
252
+ line-salience boost so they aren't crushed by filled discs.
240
253
  - `motionStats(spec, opts)` — per-layer mean/max on-screen speed from analytic
241
254
  displacement — choreography as numbers.
242
255
  - `textSprites(spec, opts)` — the literal strings and rendered sizes of every
package/dist/index.d.ts CHANGED
@@ -698,9 +698,14 @@ interface LuminanceGridOptions extends PerceiveOptions {
698
698
  }
699
699
  /**
700
700
  * Sample the spec into a coarse luminance image — analytically, no renderer.
701
- * Approximations (documented, deliberate): trails and ghosting are ignored,
702
- * soft circles use a linear falloff, background drift is sampled at its rest
703
- * position. Good enough to perceive composition, focus, and balance.
701
+ * Persistence is modeled, not simulated: because positions are pure functions
702
+ * of (seed, t), `ghosting` is a decayed sum of past-frame splats (weight g^m
703
+ * for ink m frames old, mirroring the renderer's warm-up replay) and `trail`
704
+ * re-uses drawTrail's sampling — past positions with decaying alpha and
705
+ * shrinking radius. Remaining approximations (documented, deliberate): soft
706
+ * circles use a linear falloff, background drift is sampled at its rest
707
+ * position. Good enough to perceive composition, focus, balance — and now
708
+ * smear.
704
709
  */
705
710
  declare function luminanceGrid(spec: SaverSpec, opts?: LuminanceGridOptions): LuminanceGrid;
706
711
  /**
@@ -752,7 +757,9 @@ interface DominanceEntry {
752
757
  /**
753
758
  * Rank layers by estimated visual weight — where the eye goes. Weight is
754
759
  * on-screen area × alpha, scaled by contrast against the background, an
755
- * additive-glow boost, and a motion boost. Link lines count as area.
760
+ * additive-glow boost, and a motion boost. Link lines count as area, and so
761
+ * does persistence ink: trail ribbons and ghosting smear are modeled as swept
762
+ * ribbons, so a comet layer is ranked by its comet, not just its head.
756
763
  */
757
764
  declare function dominanceRanking(spec: SaverSpec, opts?: PerceiveOptions): DominanceEntry[];
758
765
  interface LayerMotionStats {
package/dist/index.js CHANGED
@@ -1187,7 +1187,7 @@ function hexToRgba(hex, alpha) {
1187
1187
  return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${alpha})`;
1188
1188
  }
1189
1189
  var FONT_PREFIX_RE = /^((?:(?:italic|oblique|bold|bolder|lighter|normal|\d{3})\s+)+)/;
1190
- var FONT_PX_RE = /(\d*\.?\d+)px/;
1190
+ var FONT_PX_RE = /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/;
1191
1191
  function scaleFontPx(font, factor) {
1192
1192
  if (factor === 1) return font;
1193
1193
  return font.replace(FONT_PX_RE, (_m, n) => `${(Number(n) * factor).toFixed(2)}px`);
@@ -1203,6 +1203,9 @@ function manifestFor(spec) {
1203
1203
  return {
1204
1204
  id: spec.id,
1205
1205
  label: spec.label,
1206
+ // Declarative specs are closed-form by construction: every entity is a
1207
+ // pure function of (seed, t), which is the whole point of the format.
1208
+ timeModel: "closed-form",
1206
1209
  passthrough: false,
1207
1210
  minBackend: "canvas2d",
1208
1211
  costTier,
@@ -1652,6 +1655,67 @@ function compileSaver(spec) {
1652
1655
 
1653
1656
  // src/advise.ts
1654
1657
  import { createRng as createRng2 } from "@idle-screens/core";
1658
+
1659
+ // src/luma.ts
1660
+ function hexLuma(hex) {
1661
+ const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
1662
+ const n = parseInt(h.slice(1), 16);
1663
+ if (Number.isNaN(n)) return 0.7;
1664
+ const r = (n >> 16 & 255) / 255;
1665
+ const g = (n >> 8 & 255) / 255;
1666
+ const b = (n & 255) / 255;
1667
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1668
+ }
1669
+ function spriteLuma(layer, e) {
1670
+ const s = layer.sprite;
1671
+ if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
1672
+ return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
1673
+ }
1674
+ if (s.kind === "text") return hexLuma(s.color ?? "#e6e8ef");
1675
+ return 0.75;
1676
+ }
1677
+ function spriteHex(layer, e) {
1678
+ const s = layer.sprite;
1679
+ if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
1680
+ return s.colors?.[e.colorIndex] ?? s.color;
1681
+ }
1682
+ if (s.kind === "text") return s.color ?? "#e6e8ef";
1683
+ return null;
1684
+ }
1685
+ function backgroundLuma(spec) {
1686
+ const bg = spec.background;
1687
+ if (!bg || bg.type === "solid") return hexLuma(bg?.color ?? "#05050a");
1688
+ return bg.stops.reduce((s, st) => s + hexLuma(st.color), 0) / bg.stops.length;
1689
+ }
1690
+ function hexRgb(hex) {
1691
+ const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
1692
+ const n = parseInt(h.slice(1), 16);
1693
+ if (Number.isNaN(n)) return { r: 0.7, g: 0.7, b: 0.7 };
1694
+ return { r: (n >> 16 & 255) / 255, g: (n >> 8 & 255) / 255, b: (n & 255) / 255 };
1695
+ }
1696
+ function backgroundRgb(spec) {
1697
+ const bg = spec.background;
1698
+ if (!bg || bg.type === "solid") return hexRgb(bg?.color ?? "#05050a");
1699
+ const acc = bg.stops.reduce(
1700
+ (s, st) => {
1701
+ const c = hexRgb(st.color);
1702
+ return { r: s.r + c.r, g: s.g + c.g, b: s.b + c.b };
1703
+ },
1704
+ { r: 0, g: 0, b: 0 }
1705
+ );
1706
+ const n = bg.stops.length || 1;
1707
+ return { r: acc.r / n, g: acc.g / n, b: acc.b / n };
1708
+ }
1709
+ function colourSeparation(layer, sprite, bg) {
1710
+ if (layer.blend === "lighter" || layer.blend === "screen") return sprite.luma;
1711
+ const dr = sprite.rgb.r - bg.rgb.r;
1712
+ const dg = sprite.rgb.g - bg.rgb.g;
1713
+ const db = sprite.rgb.b - bg.rgb.b;
1714
+ return Math.sqrt((dr * dr + dg * dg + db * db) / 3);
1715
+ }
1716
+
1717
+ // src/advise.ts
1718
+ var LOW_CONTRAST_FLOOR = 0.05;
1655
1719
  function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1656
1720
  const warnings = [];
1657
1721
  const w = viewport.width;
@@ -1661,6 +1725,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1661
1725
  const countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
1662
1726
  const rng = createRng2(spec.seed ?? 42);
1663
1727
  const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
1728
+ const bgLuma = backgroundLuma(spec);
1729
+ const bgRgb = backgroundRgb(spec);
1664
1730
  let totalEntities = 0;
1665
1731
  let textLayerCount = 0;
1666
1732
  let motionLayerCount = 0;
@@ -1704,6 +1770,22 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1704
1770
  });
1705
1771
  }
1706
1772
  }
1773
+ if (entities.length > 0 && layer.sprite.kind !== "emoji") {
1774
+ const seps = entities.map((e) => {
1775
+ const hex = spriteHex(layer, e);
1776
+ if (hex === null) return Infinity;
1777
+ return colourSeparation(layer, { rgb: hexRgb(hex), luma: hexLuma(hex) }, { rgb: bgRgb, luma: bgLuma });
1778
+ });
1779
+ const best = Math.max(...seps);
1780
+ if (best < LOW_CONTRAST_FLOOR) {
1781
+ const additive = layer.blend === "lighter" || layer.blend === "screen";
1782
+ warnings.push({
1783
+ path: `layers[${li}].sprite`,
1784
+ code: "low-contrast-layer",
1785
+ message: additive ? `additive layer adds almost no light (luma ${best.toFixed(3)}) \u2014 a "lighter"/"screen" layer is only as visible as it is bright` : `layer colour is within ${best.toFixed(3)} of the background (floor ${LOW_CONTRAST_FLOOR}) \u2014 it will read as background, not as content`
1786
+ });
1787
+ }
1788
+ }
1707
1789
  }
1708
1790
  let totalCoverage = 0;
1709
1791
  for (let li = 0; li < spec.layers.length; li++) {
@@ -1896,6 +1978,7 @@ function describeScene(spec, options = {}) {
1896
1978
  import { createRng as createRng4 } from "@idle-screens/core";
1897
1979
  var GLOW_SPREAD = 2.4;
1898
1980
  var LINE_SALIENCE = 3.5;
1981
+ var GHOST_TAPS = 12;
1899
1982
  function buildScene(spec, opts) {
1900
1983
  const { width: w, height: h } = opts.viewport ?? { width: 1920, height: 1080 };
1901
1984
  const seed = opts.seed ?? spec.seed ?? 42;
@@ -1924,23 +2007,6 @@ function posOf(scene, e, t) {
1924
2007
  }
1925
2008
  return p;
1926
2009
  }
1927
- function hexLuma(hex) {
1928
- const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
1929
- const n = parseInt(h.slice(1), 16);
1930
- if (Number.isNaN(n)) return 0.7;
1931
- const r = (n >> 16 & 255) / 255;
1932
- const g = (n >> 8 & 255) / 255;
1933
- const b = (n & 255) / 255;
1934
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1935
- }
1936
- function spriteLuma(layer, e) {
1937
- const s = layer.sprite;
1938
- if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
1939
- return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
1940
- }
1941
- if (s.kind === "text") return hexLuma(s.color ?? "#e6e8ef");
1942
- return 0.75;
1943
- }
1944
2010
  function textBox(s, e, p) {
1945
2011
  const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
1946
2012
  const fh = m ? Number(m[1]) : e.size;
@@ -1998,105 +2064,163 @@ function luminanceGrid(spec, opts = {}) {
1998
2064
  else if (blend === "multiply") cells[idx] = cur * (1 - a * (1 - lum));
1999
2065
  else cells[idx] = cur * (1 - a) + lum * a;
2000
2066
  };
2001
- for (const { layer, entities } of scene.layers) {
2002
- const lifeA = lifeAlphaAt(layer.life, t);
2003
- if (lifeA <= 0) continue;
2004
- for (const e of entities) {
2005
- const a = Math.min(1, alphaAt(e, t) * lifeA);
2006
- if (a <= 4e-3) continue;
2007
- const lum = spriteLuma(layer, e);
2008
- const p = posOf(scene, e, t);
2009
- const sz = sizeAt(e, t);
2010
- const s = layer.sprite;
2011
- if (s.kind === "streak") {
2012
- const heading = headingAt(e, t, w, h) ?? 0;
2013
- const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
2014
- const wgt = Math.min(1, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale / cellH);
2015
- for (let i = 0; i <= steps; i++) {
2016
- const k = i / steps;
2017
- const x = p.x - Math.cos(heading) * sz * (1 - k);
2018
- const y = p.y - Math.sin(heading) * sz * (1 - k);
2019
- const c = Math.floor(x / cellW);
2020
- const r = Math.floor(y / cellH);
2021
- if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
2022
- compose(r * cols + c, lum, a * k * wgt, layer.blend);
2023
- }
2024
- continue;
2025
- }
2026
- let centerX = p.x;
2027
- let centerY = p.y;
2028
- let halfX = sz / 2;
2029
- let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
2030
- if (s.kind === "text") {
2031
- const box = textBox(s, e, p);
2032
- centerX = box.cx;
2033
- centerY = box.cy;
2034
- halfX = box.halfX;
2035
- halfY = box.halfY;
2036
- }
2037
- const circular = s.kind === "circle" || s.kind === "ring";
2038
- const soft = s.kind === "circle" && !!s.soft;
2039
- const additive = layer.blend === "lighter" || layer.blend === "screen";
2040
- const glow = soft && additive;
2041
- const reachX = glow ? halfX * GLOW_SPREAD : halfX;
2042
- const reachY = glow ? halfY * GLOW_SPREAD : halfY;
2043
- const c0 = Math.max(0, Math.floor((centerX - reachX) / cellW));
2044
- const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
2045
- const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
2046
- const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
2047
- const inkWeight = s.kind === "text" || s.kind === "emoji" ? 0.55 : 1;
2067
+ const splatTrail = (layer, e, tPass, lifeA, alphaScale) => {
2068
+ const trail = layer.trail;
2069
+ if (!trail) return;
2070
+ const fade = trail.fade ?? 1;
2071
+ const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
2072
+ const headAlpha = alphaAt(e, tPass) * lifeA;
2073
+ const headSize = sizeAt(e, tPass);
2074
+ const lum = spriteLuma(layer, e);
2075
+ const soft = layer.sprite.kind === "circle" && !!layer.sprite.soft;
2076
+ const wrapOn = layer.wrap !== false;
2077
+ const head = posOf(scene, e, tPass);
2078
+ let prevX = head.x;
2079
+ let prevY = head.y;
2080
+ for (let s = 1; s <= n; s++) {
2081
+ const k = s / n;
2082
+ const pastT = tPass - k * trail.length;
2083
+ if (pastT < 0) break;
2084
+ const pos = posOf(scene, e, pastT);
2085
+ if (wrapOn && (Math.abs(pos.x - prevX) > w / 2 || Math.abs(pos.y - prevY) > h / 2)) break;
2086
+ prevX = pos.x;
2087
+ prevY = pos.y;
2088
+ const a = headAlpha * (1 - k * fade) * alphaScale;
2089
+ if (a <= 4e-3) break;
2090
+ const rad = headSize / 2 * (1 - k * 0.7);
2091
+ if (rad < 0.2) break;
2092
+ const c0 = Math.max(0, Math.floor((pos.x - rad) / cellW));
2093
+ const c1 = Math.min(cols - 1, Math.floor((pos.x + rad) / cellW));
2094
+ const r0 = Math.max(0, Math.floor((pos.y - rad) / cellH));
2095
+ const r1 = Math.min(rows - 1, Math.floor((pos.y + rad) / cellH));
2048
2096
  for (let r = r0; r <= r1; r++) {
2049
2097
  for (let c = c0; c <= c1; c++) {
2050
- const dx = (c + 0.5) * cellW - centerX;
2051
- const dy = (r + 0.5) * cellH - centerY;
2052
- if (circular) {
2053
- const d = Math.sqrt(dx * dx + dy * dy);
2054
- const reach = glow ? reachX : halfX;
2055
- if (d > reach + Math.min(cellW, cellH) / 2) continue;
2056
- if (s.kind === "ring") {
2057
- const strokeW = (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale;
2058
- if (d < halfX - strokeW) continue;
2059
- }
2060
- let wgt;
2061
- if (glow) {
2062
- const core = halfX;
2063
- if (d <= core) wgt = 1;
2064
- else {
2065
- const f = Math.max(0, 1 - (d - core) / Math.max(reachX - core, 1e-6));
2066
- wgt = f * f;
2098
+ const dx = (c + 0.5) * cellW - pos.x;
2099
+ const dy = (r + 0.5) * cellH - pos.y;
2100
+ const d = Math.sqrt(dx * dx + dy * dy);
2101
+ if (d > rad + Math.min(cellW, cellH) / 2) continue;
2102
+ const wgt = soft ? Math.max(0.1, 1 - d / Math.max(rad, 1e-6)) : 1;
2103
+ compose(r * cols + c, lum, Math.min(1, a * wgt), layer.blend);
2104
+ }
2105
+ }
2106
+ }
2107
+ };
2108
+ const splatPass = (tPass, alphaScale) => {
2109
+ for (const { layer, entities } of scene.layers) {
2110
+ const lifeA = lifeAlphaAt(layer.life, tPass);
2111
+ if (lifeA <= 0) continue;
2112
+ for (const e of entities) {
2113
+ splatTrail(layer, e, tPass, lifeA, alphaScale);
2114
+ const a = Math.min(1, alphaAt(e, tPass) * lifeA) * alphaScale;
2115
+ if (a <= 4e-3) continue;
2116
+ const lum = spriteLuma(layer, e);
2117
+ const p = posOf(scene, e, tPass);
2118
+ const sz = sizeAt(e, tPass);
2119
+ const s = layer.sprite;
2120
+ if (s.kind === "streak") {
2121
+ const heading = headingAt(e, tPass, w, h) ?? 0;
2122
+ const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
2123
+ const wgt = Math.min(1, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale / cellH);
2124
+ for (let i = 0; i <= steps; i++) {
2125
+ const k = i / steps;
2126
+ const x = p.x - Math.cos(heading) * sz * (1 - k);
2127
+ const y = p.y - Math.sin(heading) * sz * (1 - k);
2128
+ const c = Math.floor(x / cellW);
2129
+ const r = Math.floor(y / cellH);
2130
+ if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
2131
+ compose(r * cols + c, lum, a * k * wgt, layer.blend);
2132
+ }
2133
+ continue;
2134
+ }
2135
+ let centerX = p.x;
2136
+ let centerY = p.y;
2137
+ let halfX = sz / 2;
2138
+ let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
2139
+ if (s.kind === "text") {
2140
+ const box = textBox(s, e, p);
2141
+ centerX = box.cx;
2142
+ centerY = box.cy;
2143
+ halfX = box.halfX;
2144
+ halfY = box.halfY;
2145
+ }
2146
+ const circular = s.kind === "circle" || s.kind === "ring";
2147
+ const soft = s.kind === "circle" && !!s.soft;
2148
+ const additive = layer.blend === "lighter" || layer.blend === "screen";
2149
+ const glow = soft && additive;
2150
+ const reachX = glow ? halfX * GLOW_SPREAD : halfX;
2151
+ const reachY = glow ? halfY * GLOW_SPREAD : halfY;
2152
+ const c0 = Math.max(0, Math.floor((centerX - reachX) / cellW));
2153
+ const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
2154
+ const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
2155
+ const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
2156
+ const inkWeight = s.kind === "text" || s.kind === "emoji" ? 0.55 : 1;
2157
+ for (let r = r0; r <= r1; r++) {
2158
+ for (let c = c0; c <= c1; c++) {
2159
+ const dx = (c + 0.5) * cellW - centerX;
2160
+ const dy = (r + 0.5) * cellH - centerY;
2161
+ if (circular) {
2162
+ const d = Math.sqrt(dx * dx + dy * dy);
2163
+ const reach = glow ? reachX : halfX;
2164
+ if (d > reach + Math.min(cellW, cellH) / 2) continue;
2165
+ if (s.kind === "ring") {
2166
+ const strokeW = (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale;
2167
+ if (d < halfX - strokeW) continue;
2067
2168
  }
2169
+ let wgt;
2170
+ if (glow) {
2171
+ const core = halfX;
2172
+ if (d <= core) wgt = 1;
2173
+ else {
2174
+ const f = Math.max(0, 1 - (d - core) / Math.max(reachX - core, 1e-6));
2175
+ wgt = f * f;
2176
+ }
2177
+ } else {
2178
+ wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
2179
+ }
2180
+ compose(r * cols + c, lum, a * wgt, layer.blend);
2068
2181
  } else {
2069
- wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
2182
+ compose(r * cols + c, lum, a * inkWeight, layer.blend);
2070
2183
  }
2071
- compose(r * cols + c, lum, a * wgt, layer.blend);
2072
- } else {
2073
- compose(r * cols + c, lum, a * inkWeight, layer.blend);
2074
2184
  }
2075
2185
  }
2076
2186
  }
2077
- }
2078
- if (layer.links) {
2079
- const positions = entities.map((e) => posOf(scene, e, t));
2080
- const maxDistPx = layer.links.maxDist * scale;
2081
- const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
2082
- const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
2083
- const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
2084
- const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
2085
- const wgt = Math.min(1, (layer.links.width ?? 1) * scale / cellH) * la;
2086
- for (const edge of edges) {
2087
- const pi = positions[edge.i];
2088
- const pj = positions[edge.j];
2089
- const steps = Math.max(1, Math.ceil(edge.dist / Math.min(cellW, cellH)));
2090
- for (let i = 0; i <= steps; i++) {
2091
- const k = i / steps;
2092
- const c = Math.floor((pi.x + (pj.x - pi.x) * k) / cellW);
2093
- const r = Math.floor((pi.y + (pj.y - pi.y) * k) / cellH);
2094
- if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
2095
- compose(r * cols + c, lum, wgt, layer.blend);
2187
+ if (layer.links) {
2188
+ const positions = entities.map((e) => posOf(scene, e, tPass));
2189
+ const maxDistPx = layer.links.maxDist * scale;
2190
+ const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
2191
+ const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
2192
+ const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
2193
+ const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
2194
+ const wgt = Math.min(1, (layer.links.width ?? 1) * scale / cellH) * la * alphaScale;
2195
+ for (const edge of edges) {
2196
+ const pi = positions[edge.i];
2197
+ const pj = positions[edge.j];
2198
+ const steps = Math.max(1, Math.ceil(edge.dist / Math.min(cellW, cellH)));
2199
+ for (let i = 0; i <= steps; i++) {
2200
+ const k = i / steps;
2201
+ const c = Math.floor((pi.x + (pj.x - pi.x) * k) / cellW);
2202
+ const r = Math.floor((pi.y + (pj.y - pi.y) * k) / cellH);
2203
+ if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
2204
+ compose(r * cols + c, lum, wgt, layer.blend);
2205
+ }
2096
2206
  }
2097
2207
  }
2098
2208
  }
2099
- }
2209
+ };
2210
+ const ghost = spec.ghosting ?? 0;
2211
+ if (ghost > 0) {
2212
+ const dt = 1e3 / 60;
2213
+ const windowFrames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
2214
+ const taps = Math.min(GHOST_TAPS, windowFrames);
2215
+ const spacing = windowFrames / taps;
2216
+ for (let i = taps; i >= 1; i--) {
2217
+ const tPass = t - i * spacing * dt;
2218
+ if (tPass < 0) continue;
2219
+ const bracketWeight = Math.pow(ghost, (i - 1) * spacing + 1) * (1 - Math.pow(ghost, spacing)) / (1 - ghost);
2220
+ splatPass(tPass, Math.min(1, bracketWeight));
2221
+ }
2222
+ }
2223
+ splatPass(t, 1);
2100
2224
  let sum = 0;
2101
2225
  let covered = 0;
2102
2226
  let devSum = 0;
@@ -2200,11 +2324,26 @@ function dominanceRanking(spec, opts = {}) {
2200
2324
  const scene = buildScene(spec, opts);
2201
2325
  const t = opts.t ?? 5e3;
2202
2326
  const { w, h, scale } = scene;
2203
- const bgMean = (() => {
2204
- const bg = spec.background;
2205
- if (!bg || bg.type === "solid") return hexLuma(bg?.color ?? "#05050a");
2206
- return bg.stops.reduce((s, st) => s + hexLuma(st.color), 0) / bg.stops.length;
2207
- })();
2327
+ const bgMean = backgroundLuma(spec);
2328
+ const ghost = spec.ghosting ?? 0;
2329
+ let ghostWindowMs = 0;
2330
+ let ghostMeanW = 0;
2331
+ if (ghost > 0) {
2332
+ const dt = 1e3 / 60;
2333
+ const frames = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(ghost)), LIMITS.maxGhostReplayFrames);
2334
+ ghostWindowMs = frames * dt;
2335
+ ghostMeanW = ghost * (1 - Math.pow(ghost, frames)) / (1 - ghost) / frames;
2336
+ }
2337
+ const sweptDistance = (e, spanMs) => {
2338
+ const span = Math.min(spanMs, t);
2339
+ if (span <= 0) return 0;
2340
+ const p0 = posOf(scene, e, t - span);
2341
+ const p1 = posOf(scene, e, t);
2342
+ const dx = p1.x - p0.x;
2343
+ const dy = p1.y - p0.y;
2344
+ if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return 0;
2345
+ return Math.sqrt(dx * dx + dy * dy);
2346
+ };
2208
2347
  const raw = scene.layers.map(({ layer, entities }, layerIndex) => {
2209
2348
  const lifeA = lifeAlphaAt(layer.life, t);
2210
2349
  let area = 0;
@@ -2225,6 +2364,14 @@ function dominanceRanking(spec, opts = {}) {
2225
2364
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
2226
2365
  } else entArea = sz * sz * 0.55;
2227
2366
  area += entArea * a;
2367
+ if (layer.trail) {
2368
+ const fade = layer.trail.fade ?? 1;
2369
+ const span = sweptDistance(e, layer.trail.length);
2370
+ area += span * sz * 0.65 * Math.max(0, 1 - fade / 2) * a;
2371
+ }
2372
+ if (ghost > 0) {
2373
+ area += sweptDistance(e, ghostWindowMs) * sz * ghostMeanW * a;
2374
+ }
2228
2375
  lumAcc += spriteLuma(layer, e);
2229
2376
  }
2230
2377
  if (layer.links && entities.length > 1) {