@idle-screens/schema 3.1.0 → 3.2.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/dist/index.js CHANGED
@@ -42,7 +42,16 @@ var LIMITS = {
42
42
  maxPathPoints: 24,
43
43
  minPathDuration: 2e3,
44
44
  // ms — a path lap can't be faster than this
45
- maxGridColumns: 100
45
+ maxGridColumns: 100,
46
+ maxTextBlockLength: 2e3,
47
+ minTextBlockFontSize: 0.01,
48
+ maxTextBlockFontSize: 0.2,
49
+ maxTextBlockMaxWidth: 1,
50
+ maxSegments: 24,
51
+ minSegmentDuration: 1e3,
52
+ // ms — flash-safety floor: segment cuts are luminance transitions
53
+ maxTransitionDur: 5e3,
54
+ minTransitionDur: 200
46
55
  };
47
56
 
48
57
  // src/validate.ts
@@ -78,6 +87,7 @@ var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width",
78
87
  var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "colors", "colorWeights"]);
79
88
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
80
89
  var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
90
+ var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color"]);
81
91
  var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob"]);
82
92
  var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway"]);
83
93
  var KNOWN_BOUNCE = /* @__PURE__ */ new Set(["type", "speed"]);
@@ -108,6 +118,14 @@ function validateSpec(spec) {
108
118
  if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
109
119
  err("units", "must be 'px' | 'viewport'");
110
120
  }
121
+ if (spec.units === "px" && Array.isArray(spec.layers)) {
122
+ for (const l of spec.layers) {
123
+ if (isObj(l) && isObj(l.sprite) && l.sprite.kind === "textBlock") {
124
+ err("units", "textBlock dimensions are viewport fractions \u2014 units: 'px' is not supported with textBlock sprites");
125
+ break;
126
+ }
127
+ }
128
+ }
111
129
  if (spec.referenceViewport !== void 0) {
112
130
  if (!isNum(spec.referenceViewport) || spec.referenceViewport < 100 || spec.referenceViewport > 8640) {
113
131
  err("referenceViewport", "must be a number between 100 and 8640");
@@ -407,8 +425,28 @@ function validateSprite(sprite, path, err, warn) {
407
425
  }
408
426
  color(sprite.color, `${path}.color`, err);
409
427
  validatePalette(sprite, path, err);
428
+ } else if (sprite.kind === "textBlock") {
429
+ knownSet = KNOWN_TEXT_BLOCK;
430
+ if (typeof sprite.text !== "string" || sprite.text.length === 0) {
431
+ err(`${path}.text`, "must be a non-empty string");
432
+ } else if (sprite.text.length > LIMITS.maxTextBlockLength) {
433
+ err(`${path}.text`, `must be at most ${LIMITS.maxTextBlockLength} characters`);
434
+ }
435
+ if (!isNum(sprite.maxWidth) || sprite.maxWidth <= 0 || sprite.maxWidth > LIMITS.maxTextBlockMaxWidth) {
436
+ err(`${path}.maxWidth`, `must be a positive number up to ${LIMITS.maxTextBlockMaxWidth} (viewport fraction)`);
437
+ }
438
+ if (!isNum(sprite.fontSize) || sprite.fontSize < LIMITS.minTextBlockFontSize || sprite.fontSize > LIMITS.maxTextBlockFontSize) {
439
+ err(`${path}.fontSize`, `must be between ${LIMITS.minTextBlockFontSize} and ${LIMITS.maxTextBlockFontSize} (viewport fraction)`);
440
+ }
441
+ if (sprite.lineHeight !== void 0 && (!isNum(sprite.lineHeight) || sprite.lineHeight < 0.5 || sprite.lineHeight > 4)) {
442
+ err(`${path}.lineHeight`, "must be between 0.5 and 4 (multiplier)");
443
+ }
444
+ if (sprite.align !== void 0 && !["left", "center", "right"].includes(sprite.align)) {
445
+ err(`${path}.align`, "must be 'left' | 'center' | 'right'");
446
+ }
447
+ if (sprite.color !== void 0) color(sprite.color, `${path}.color`, err);
410
448
  } else {
411
- err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect");
449
+ err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect | textBlock");
412
450
  return;
413
451
  }
414
452
  for (const k of unknownKeys(sprite, knownSet)) {
@@ -576,6 +614,86 @@ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
576
614
  }
577
615
  return spec;
578
616
  }
617
+ function validateSequence(seq) {
618
+ const errors = [];
619
+ const warnings = [];
620
+ const err = (path, message) => void errors.push({ path, message });
621
+ if (!isObj(seq)) return { valid: false, errors: [{ path: "", message: "sequence must be an object" }], warnings: [] };
622
+ if (seq.format !== "idle-sequence") err("format", "must be 'idle-sequence'");
623
+ if (seq.schemaVersion !== SCHEMA_VERSION) err("schemaVersion", `must be ${SCHEMA_VERSION}`);
624
+ if (!isStr(seq.id) || seq.id.trim() === "") err("id", "must be a non-empty string");
625
+ if (!isStr(seq.label) || seq.label.trim() === "") err("label", "must be a non-empty string");
626
+ if (seq.seed !== void 0 && !isNum(seq.seed)) err("seed", "must be a number");
627
+ if (typeof seq.loop !== "boolean") err("loop", "must be a boolean");
628
+ if (!Array.isArray(seq.segments) || seq.segments.length === 0) {
629
+ err("segments", "must be a non-empty array");
630
+ return { valid: false, errors, warnings };
631
+ }
632
+ if (seq.segments.length > LIMITS.maxSegments) {
633
+ err("segments", `at most ${LIMITS.maxSegments} segments`);
634
+ }
635
+ const keys = /* @__PURE__ */ new Set();
636
+ let hasDurationless = false;
637
+ for (let i = 0; i < seq.segments.length; i++) {
638
+ const s = seq.segments[i];
639
+ const p = `segments[${i}]`;
640
+ if (!isObj(s)) {
641
+ err(p, "must be an object");
642
+ continue;
643
+ }
644
+ if (!isStr(s.key) || s.key.trim() === "") {
645
+ err(`${p}.key`, "must be a non-empty string");
646
+ } else if (keys.has(s.key)) {
647
+ err(`${p}.key`, `duplicate key '${s.key}'`);
648
+ } else {
649
+ keys.add(s.key);
650
+ }
651
+ if (s.duration !== void 0) {
652
+ if (hasDurationless) {
653
+ err(`${p}.duration`, "a segment after a durationless segment may not have a duration");
654
+ }
655
+ if (!isNum(s.duration) || s.duration < LIMITS.minSegmentDuration) {
656
+ err(`${p}.duration`, `must be >= ${LIMITS.minSegmentDuration} ms`);
657
+ }
658
+ } else {
659
+ if (i < seq.segments.length - 1) {
660
+ err(`${p}.duration`, "only the final segment may omit duration");
661
+ }
662
+ hasDurationless = true;
663
+ }
664
+ if (s.advance !== void 0 && !["auto", "input", "either"].includes(s.advance)) {
665
+ err(`${p}.advance`, "must be 'auto' | 'input' | 'either'");
666
+ }
667
+ if (s.transition !== void 0) {
668
+ if (!isObj(s.transition)) {
669
+ err(`${p}.transition`, "must be an object");
670
+ } else if (s.transition.type !== "cut") {
671
+ err(`${p}.transition.type`, "must be 'cut' (fade not yet supported)");
672
+ }
673
+ }
674
+ if (isObj(s.scene)) {
675
+ const sceneResult = validateSpec(s.scene);
676
+ for (const e of sceneResult.errors) errors.push({ path: `${p}.scene.${e.path}`, message: e.message });
677
+ if (sceneResult.warnings) {
678
+ for (const w of sceneResult.warnings) warnings.push({ path: `${p}.scene.${w.path}`, code: w.code, message: w.message });
679
+ }
680
+ } else {
681
+ err(`${p}.scene`, "must be a valid SaverSpec object");
682
+ }
683
+ }
684
+ if (seq.loop === true && hasDurationless) {
685
+ err("loop", "loop: true requires all segments to have a duration");
686
+ }
687
+ return { valid: errors.length === 0, errors, warnings };
688
+ }
689
+ function assertValidSequence(seq) {
690
+ const r = validateSequence(seq);
691
+ if (!r.valid) {
692
+ throw new Error(`invalid sequence:
693
+ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
694
+ }
695
+ return seq;
696
+ }
579
697
 
580
698
  // src/simulate.ts
581
699
  var WARP_NEAR = 0.08;
@@ -660,7 +778,7 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
660
778
  }
661
779
  const out = [];
662
780
  for (let i = 0; i < effectiveCount; i++) {
663
- const size = sprite.kind === "circle" || sprite.kind === "ring" ? rng.range(sprite.radius[0], sprite.radius[1]) * 2 * scale : sprite.kind === "streak" ? rng.range(sprite.length[0], sprite.length[1]) * scale : sprite.kind === "rect" ? rng.range(sprite.width[0], sprite.width[1]) * scale : rng.range(smin, smax) * scale;
781
+ const size = sprite.kind === "textBlock" ? sprite.fontSize * scale : sprite.kind === "circle" || sprite.kind === "ring" ? rng.range(sprite.radius[0], sprite.radius[1]) * 2 * scale : sprite.kind === "streak" ? rng.range(sprite.length[0], sprite.length[1]) * scale : sprite.kind === "rect" ? rng.range(sprite.width[0], sprite.width[1]) * scale : rng.range(smin, smax) * scale;
664
782
  const size2 = sprite.kind === "rect" ? size * (sprite.aspect ? rng.range(sprite.aspect[0], sprite.aspect[1]) : 1) : void 0;
665
783
  let vx = 0;
666
784
  let vy = 0;
@@ -1044,6 +1162,51 @@ function linkPairs(positions, k, maxDist, doWrap, w, h) {
1044
1162
  }
1045
1163
  return out;
1046
1164
  }
1165
+ var CHAR_NARROW = new Set(`iIljtf1!|.,;:'"()[]{}`);
1166
+ var CHAR_WIDE = new Set("mwMWGOQD@%");
1167
+ function charWidthEm(ch) {
1168
+ if (ch === " " || ch === " ") return 0.3;
1169
+ if (CHAR_NARROW.has(ch)) return 0.35;
1170
+ if (CHAR_WIDE.has(ch)) return 0.72;
1171
+ return 0.55;
1172
+ }
1173
+ function textWidthEm(str) {
1174
+ let w = 0;
1175
+ for (let i = 0; i < str.length; i++) w += charWidthEm(str[i]);
1176
+ return w;
1177
+ }
1178
+ function breakTextBlock(text, maxWidthEm) {
1179
+ const paragraphs = text.split("\n");
1180
+ const lines = [];
1181
+ for (const para of paragraphs) {
1182
+ if (para.length === 0) {
1183
+ lines.push({ text: "", widthEm: 0 });
1184
+ continue;
1185
+ }
1186
+ const words = para.split(/\s+/).filter((w) => w.length > 0);
1187
+ if (words.length === 0) {
1188
+ lines.push({ text: "", widthEm: 0 });
1189
+ continue;
1190
+ }
1191
+ let lineText = words[0];
1192
+ let lineW = textWidthEm(lineText);
1193
+ const spaceW = charWidthEm(" ");
1194
+ for (let i = 1; i < words.length; i++) {
1195
+ const word = words[i];
1196
+ const wordW = textWidthEm(word);
1197
+ if (lineW + spaceW + wordW <= maxWidthEm) {
1198
+ lineText += " " + word;
1199
+ lineW += spaceW + wordW;
1200
+ } else {
1201
+ lines.push({ text: lineText, widthEm: lineW });
1202
+ lineText = word;
1203
+ lineW = wordW;
1204
+ }
1205
+ }
1206
+ lines.push({ text: lineText, widthEm: lineW });
1207
+ }
1208
+ return lines;
1209
+ }
1047
1210
 
1048
1211
  // src/compile.ts
1049
1212
  import {
@@ -1173,12 +1336,41 @@ function structuralSignature(spec) {
1173
1336
  l.sprite.kind === "rect" ? [s.width, s.aspect] : void 0,
1174
1337
  Array.isArray(s.colors) ? s.colors.length : void 0,
1175
1338
  s.colorWeights,
1176
- l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : void 0
1339
+ l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : l.sprite.kind === "textBlock" ? [l.sprite.fontSize] : void 0
1177
1340
  ];
1178
1341
  })
1179
1342
  ]);
1180
1343
  }
1181
1344
 
1345
+ // src/sequence.ts
1346
+ function resolveSegment(seq, T) {
1347
+ const { segments, loop } = seq;
1348
+ if (segments.length === 0) return { index: 0, localT: 0, startT: 0 };
1349
+ let totalTimed = 0;
1350
+ for (const seg of segments) {
1351
+ if (seg.duration != null) totalTimed += seg.duration;
1352
+ else break;
1353
+ }
1354
+ let t = Math.max(0, T);
1355
+ if (loop && totalTimed > 0) {
1356
+ t = t % totalTimed;
1357
+ }
1358
+ let cumulative = 0;
1359
+ for (let i = 0; i < segments.length; i++) {
1360
+ const seg = segments[i];
1361
+ if (seg.duration == null) {
1362
+ return { index: i, localT: t - cumulative, startT: cumulative };
1363
+ }
1364
+ if (t < cumulative + seg.duration) {
1365
+ return { index: i, localT: t - cumulative, startT: cumulative };
1366
+ }
1367
+ cumulative += seg.duration;
1368
+ }
1369
+ const last = segments.length - 1;
1370
+ const lastStart = cumulative - (segments[last].duration ?? 0);
1371
+ return { index: last, localT: t - lastStart, startT: lastStart };
1372
+ }
1373
+
1182
1374
  // src/compile.ts
1183
1375
  var DEFAULT_STEER_DUR = 1e3;
1184
1376
  function hexToRgba(hex, alpha) {
@@ -1226,12 +1418,14 @@ var SpecInstance = class {
1226
1418
  let canvas;
1227
1419
  if (ctx.surface) {
1228
1420
  canvas = ctx.surface;
1421
+ this.ownsCanvas = false;
1229
1422
  } else {
1230
1423
  const el = document.createElement("canvas");
1231
1424
  el.style.cssText = "display:block;width:100%;height:100%";
1232
1425
  el.setAttribute("aria-hidden", "true");
1233
1426
  ctx.host.appendChild(el);
1234
1427
  canvas = el;
1428
+ this.ownsCanvas = true;
1235
1429
  }
1236
1430
  this.canvas = canvas;
1237
1431
  const c2d = canvas.getContext("2d", { alpha: false });
@@ -1247,6 +1441,7 @@ var SpecInstance = class {
1247
1441
  }
1248
1442
  spec;
1249
1443
  canvas;
1444
+ ownsCanvas;
1250
1445
  ctx;
1251
1446
  saverCtx;
1252
1447
  seed;
@@ -1365,7 +1560,7 @@ var SpecInstance = class {
1365
1560
  const headAlpha = alphaAt(e, t) * lifeA;
1366
1561
  const headSize = sizeAt(e, t);
1367
1562
  const sprite = built.layer.sprite;
1368
- const resolvedColor = sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect" ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
1563
+ const resolvedColor = sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect" ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" || sprite.kind === "textBlock" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
1369
1564
  const isSoft = sprite.kind === "circle" && sprite.soft;
1370
1565
  const wrap2 = built.layer.wrap !== false;
1371
1566
  const head = this.entityPos(e, t, parentE);
@@ -1472,6 +1667,28 @@ var SpecInstance = class {
1472
1667
  ctx.restore();
1473
1668
  return;
1474
1669
  }
1670
+ if (sprite.kind === "textBlock") {
1671
+ const unitScale2 = Math.min(this.w, this.h);
1672
+ const fsPx = sprite.fontSize * unitScale2;
1673
+ const lh = (sprite.lineHeight ?? 1.4) * fsPx;
1674
+ const maxWPx = sprite.maxWidth * unitScale2;
1675
+ const maxWEm = maxWPx / fsPx;
1676
+ const lines = breakTextBlock(sprite.text, maxWEm);
1677
+ const align = sprite.align ?? "left";
1678
+ ctx.save();
1679
+ ctx.translate(p.x, p.y);
1680
+ if (rot) ctx.rotate(rot);
1681
+ ctx.font = `${fsPx}px system-ui, sans-serif`;
1682
+ ctx.fillStyle = sprite.color ?? "#e6e8ef";
1683
+ ctx.textBaseline = "top";
1684
+ ctx.textAlign = align;
1685
+ const xOff = align === "center" ? maxWPx / 2 : align === "right" ? maxWPx : 0;
1686
+ for (let li = 0; li < lines.length; li++) {
1687
+ ctx.fillText(lines[li].text, xOff, li * lh);
1688
+ }
1689
+ ctx.restore();
1690
+ return;
1691
+ }
1475
1692
  ctx.save();
1476
1693
  ctx.translate(p.x, p.y);
1477
1694
  if (rot) ctx.rotate(rot);
@@ -1641,7 +1858,7 @@ var SpecInstance = class {
1641
1858
  }
1642
1859
  dispose() {
1643
1860
  this.stop();
1644
- if (typeof HTMLCanvasElement !== "undefined" && this.canvas instanceof HTMLCanvasElement) this.canvas.remove();
1861
+ if (this.ownsCanvas && typeof HTMLCanvasElement !== "undefined" && this.canvas instanceof HTMLCanvasElement) this.canvas.remove();
1645
1862
  }
1646
1863
  };
1647
1864
  function compileSaver(spec) {
@@ -1652,6 +1869,129 @@ function compileSaver(spec) {
1652
1869
  spec: valid
1653
1870
  };
1654
1871
  }
1872
+ var SequenceInstance = class {
1873
+ seq;
1874
+ childCtx;
1875
+ canvas;
1876
+ children;
1877
+ activeIndex = -1;
1878
+ paused = false;
1879
+ constructor(seq, ctx) {
1880
+ this.seq = seq;
1881
+ let surface = ctx.surface ?? null;
1882
+ let canvas = null;
1883
+ if (!surface && typeof document !== "undefined") {
1884
+ canvas = document.createElement("canvas");
1885
+ canvas.style.cssText = "display:block;width:100%;height:100%";
1886
+ canvas.setAttribute("aria-hidden", "true");
1887
+ ctx.host.appendChild(canvas);
1888
+ surface = canvas;
1889
+ }
1890
+ this.canvas = canvas;
1891
+ this.childCtx = { ...ctx, surface, reducedMotion: true };
1892
+ this.children = new Array(seq.segments.length).fill(null);
1893
+ }
1894
+ childScene(index) {
1895
+ const seg = this.seq.segments[index];
1896
+ if (seg.scene.seed != null) return seg.scene;
1897
+ if (this.seq.seed != null) return { ...seg.scene, seed: this.seq.seed + index };
1898
+ return seg.scene;
1899
+ }
1900
+ ensureChild(index) {
1901
+ if (index < 0 || index >= this.seq.segments.length) index = 0;
1902
+ let child = this.children[index];
1903
+ if (!child) {
1904
+ child = new SpecInstance(this.childScene(index), this.childCtx);
1905
+ this.children[index] = child;
1906
+ if (this.paused) child.setPaused(true);
1907
+ }
1908
+ return child;
1909
+ }
1910
+ releaseChild(index) {
1911
+ const child = this.children[index];
1912
+ if (child) {
1913
+ child.dispose();
1914
+ this.children[index] = null;
1915
+ }
1916
+ }
1917
+ renderFrame(T, seed) {
1918
+ const resolved = resolveSegment(this.seq, T);
1919
+ const { index, localT } = resolved;
1920
+ if (index !== this.activeIndex) {
1921
+ for (let i = 0; i < this.children.length; i++) {
1922
+ if (i !== index) this.releaseChild(i);
1923
+ }
1924
+ this.activeIndex = index;
1925
+ }
1926
+ const child = this.ensureChild(index);
1927
+ const childSeed = this.seq.segments[index].scene.seed ?? this.seq.seed ?? seed;
1928
+ child.renderFrame(localT, childSeed);
1929
+ }
1930
+ setPaused(paused) {
1931
+ this.paused = paused;
1932
+ for (const child of this.children) {
1933
+ child?.setPaused(paused);
1934
+ }
1935
+ }
1936
+ resize(width, height, dpr) {
1937
+ for (const child of this.children) {
1938
+ child?.resize(width, height, dpr);
1939
+ }
1940
+ }
1941
+ applyTrack(track) {
1942
+ const deltas = track?.deltas ?? [];
1943
+ const segDelta = deltas.find((d) => d.path === "sequence.segment");
1944
+ if (segDelta !== void 0 && typeof segDelta.value === "number") {
1945
+ const idx = Math.max(0, Math.min(this.seq.segments.length - 1, Math.round(segDelta.value)));
1946
+ if (idx !== this.activeIndex) {
1947
+ for (let i = 0; i < this.children.length; i++) {
1948
+ if (i !== idx) this.releaseChild(i);
1949
+ }
1950
+ this.activeIndex = idx;
1951
+ const child = this.ensureChild(idx);
1952
+ child.renderFrame(0, this.seq.segments[idx].scene.seed ?? this.seq.seed ?? 0);
1953
+ }
1954
+ }
1955
+ const childDeltas = deltas.filter((d) => d.path !== "sequence.segment");
1956
+ if (childDeltas.length > 0 && this.activeIndex >= 0) {
1957
+ const child = this.children[this.activeIndex];
1958
+ child?.applyTrack({ ...track, deltas: childDeltas });
1959
+ }
1960
+ }
1961
+ dispose() {
1962
+ for (let i = 0; i < this.children.length; i++) {
1963
+ this.releaseChild(i);
1964
+ }
1965
+ if (this.canvas) this.canvas.remove();
1966
+ }
1967
+ };
1968
+ function sequenceManifest(seq) {
1969
+ const maxTotal = seq.segments.reduce((max, s) => {
1970
+ const t = s.scene.layers.reduce((n, l) => n + l.count, 0);
1971
+ return Math.max(max, t);
1972
+ }, 0);
1973
+ const costTier = maxTotal < 30 ? "idle" : maxTotal < 150 ? "low" : maxTotal < 400 ? "medium" : "high";
1974
+ return {
1975
+ id: seq.id,
1976
+ label: seq.label,
1977
+ timeModel: "closed-form",
1978
+ passthrough: false,
1979
+ minBackend: "canvas2d",
1980
+ costTier,
1981
+ motionIntensity: "moderate",
1982
+ reducedMotionFallback: "static",
1983
+ a11y: { flashSafe: true },
1984
+ workerReady: false
1985
+ };
1986
+ }
1987
+ function compileSequence(spec) {
1988
+ const valid = assertValidSequence(spec);
1989
+ return {
1990
+ manifest: sequenceManifest(valid),
1991
+ mount: (ctx) => new SequenceInstance(valid, ctx),
1992
+ spec: valid
1993
+ };
1994
+ }
1655
1995
 
1656
1996
  // src/advise.ts
1657
1997
  import { createRng as createRng2 } from "@idle-screens/core";
@@ -1671,7 +2011,7 @@ function spriteLuma(layer, e) {
1671
2011
  if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
1672
2012
  return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
1673
2013
  }
1674
- if (s.kind === "text") return hexLuma(s.color ?? "#e6e8ef");
2014
+ if (s.kind === "text" || s.kind === "textBlock") return hexLuma(s.color ?? "#e6e8ef");
1675
2015
  return 0.75;
1676
2016
  }
1677
2017
  function spriteHex(layer, e) {
@@ -1679,7 +2019,7 @@ function spriteHex(layer, e) {
1679
2019
  if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
1680
2020
  return s.colors?.[e.colorIndex] ?? s.color;
1681
2021
  }
1682
- if (s.kind === "text") return s.color ?? "#e6e8ef";
2022
+ if (s.kind === "text" || s.kind === "textBlock") return s.color ?? "#e6e8ef";
1683
2023
  return null;
1684
2024
  }
1685
2025
  function backgroundLuma(spec) {
@@ -1734,7 +2074,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1734
2074
  const layer = spec.layers[li];
1735
2075
  const entities = allEntities[li];
1736
2076
  totalEntities += entities.length;
1737
- const isText = layer.sprite.kind === "text";
2077
+ const isText = layer.sprite.kind === "text" || layer.sprite.kind === "textBlock";
1738
2078
  const isStaticText = isText && layer.motion.type === "static";
1739
2079
  if (isStaticText) textLayerCount++;
1740
2080
  if (layer.motion.type !== "static") motionLayerCount++;
@@ -1793,7 +2133,18 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1793
2133
  const entities = allEntities[li];
1794
2134
  for (const e of entities) {
1795
2135
  const r = e.size / 2;
1796
- const pixArea = layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
2136
+ let pixArea;
2137
+ if (layer.sprite.kind === "circle") {
2138
+ pixArea = Math.PI * r * r;
2139
+ } else if (layer.sprite.kind === "textBlock") {
2140
+ const fsPx = layer.sprite.fontSize * scale;
2141
+ const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
2142
+ const maxWPx = layer.sprite.maxWidth * scale;
2143
+ const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx);
2144
+ pixArea = maxWPx * lines.length * lh * 0.55;
2145
+ } else {
2146
+ pixArea = e.size * e.size;
2147
+ }
1797
2148
  totalCoverage += pixArea * e.alpha / (w * h);
1798
2149
  }
1799
2150
  if (layer.links) {
@@ -2021,6 +2372,24 @@ function textBox(s, e, p) {
2021
2372
  halfY: fh * 0.6
2022
2373
  };
2023
2374
  }
2375
+ function textBlockBox(s, _e, p, w, h) {
2376
+ const unitScale = Math.min(w, h);
2377
+ const fsPx = s.fontSize * unitScale;
2378
+ const lh = (s.lineHeight ?? 1.4) * fsPx;
2379
+ const maxWPx = s.maxWidth * unitScale;
2380
+ const maxWEm = maxWPx / fsPx;
2381
+ const lines = breakTextBlock(s.text, maxWEm);
2382
+ const totalH = lines.length * lh;
2383
+ const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
2384
+ const align = s.align ?? "left";
2385
+ const cx = align === "center" ? p.x + maxWPx / 2 : align === "right" ? p.x + maxWPx - maxLineW / 2 : p.x + maxLineW / 2;
2386
+ return {
2387
+ cx,
2388
+ cy: p.y + totalH / 2,
2389
+ halfX: maxLineW / 2,
2390
+ halfY: totalH / 2
2391
+ };
2392
+ }
2024
2393
  function luminanceGrid(spec, opts = {}) {
2025
2394
  const scene = buildScene(spec, opts);
2026
2395
  const t = opts.t ?? 5e3;
@@ -2142,6 +2511,12 @@ function luminanceGrid(spec, opts = {}) {
2142
2511
  centerY = box.cy;
2143
2512
  halfX = box.halfX;
2144
2513
  halfY = box.halfY;
2514
+ } else if (s.kind === "textBlock") {
2515
+ const box = textBlockBox(s, e, p, w, h);
2516
+ centerX = box.cx;
2517
+ centerY = box.cy;
2518
+ halfX = box.halfX;
2519
+ halfY = box.halfY;
2145
2520
  }
2146
2521
  const circular = s.kind === "circle" || s.kind === "ring";
2147
2522
  const soft = s.kind === "circle" && !!s.soft;
@@ -2153,7 +2528,7 @@ function luminanceGrid(spec, opts = {}) {
2153
2528
  const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
2154
2529
  const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
2155
2530
  const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
2156
- const inkWeight = s.kind === "text" || s.kind === "emoji" ? 0.55 : 1;
2531
+ const inkWeight = s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1;
2157
2532
  for (let r = r0; r <= r1; r++) {
2158
2533
  for (let c = c0; c <= c1; c++) {
2159
2534
  const dx = (c + 0.5) * cellW - centerX;
@@ -2312,6 +2687,13 @@ function textSprites(spec, opts = {}) {
2312
2687
  const t = opts.t ?? 5e3;
2313
2688
  const out = [];
2314
2689
  scene.layers.forEach(({ layer, entities }, layerIndex) => {
2690
+ if (layer.sprite.kind === "textBlock") {
2691
+ const s2 = layer.sprite;
2692
+ const unitScale = Math.min(scene.w, scene.h);
2693
+ const sizePx2 = s2.fontSize * unitScale;
2694
+ out.push({ layerIndex, key: layer.key, strings: [s2.text], count: entities.length, sizePx: Math.round(sizePx2) });
2695
+ return;
2696
+ }
2315
2697
  if (layer.sprite.kind !== "text") return;
2316
2698
  const s = layer.sprite;
2317
2699
  const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
@@ -2362,6 +2744,9 @@ function dominanceRanking(spec, opts = {}) {
2362
2744
  } else if (s.kind === "text") {
2363
2745
  const box = textBox(s, e, { x: 0, y: 0 });
2364
2746
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
2747
+ } else if (s.kind === "textBlock") {
2748
+ const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
2749
+ entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
2365
2750
  } else entArea = sz * sz * 0.55;
2366
2751
  area += entArea * a;
2367
2752
  if (layer.trail) {
@@ -3213,6 +3598,36 @@ var DASHBOARD_SPEC = {
3213
3598
  ]
3214
3599
  };
3215
3600
 
3601
+ // src/examples/haiku.ts
3602
+ var HAIKU_SPEC = {
3603
+ schemaVersion: 1,
3604
+ id: "haiku",
3605
+ label: "Haiku",
3606
+ seed: 42,
3607
+ background: { type: "solid", color: "#080812" },
3608
+ layers: [
3609
+ {
3610
+ count: 1,
3611
+ sprite: {
3612
+ kind: "textBlock",
3613
+ text: "An old silent pond\nA frog jumps into the pond \u2014\nSplash! Silence again.",
3614
+ maxWidth: 0.8,
3615
+ fontSize: 0.05,
3616
+ lineHeight: 1.8,
3617
+ align: "center",
3618
+ color: "#c8cad4"
3619
+ },
3620
+ motion: { type: "static" },
3621
+ position: { x: 0.1, y: 0.35 }
3622
+ },
3623
+ {
3624
+ count: 30,
3625
+ sprite: { kind: "circle", radius: [5e-4, 15e-4], color: "#3a3a5c" },
3626
+ motion: { type: "drift", speed: [2e-3, 6e-3], angle: 270 }
3627
+ }
3628
+ ]
3629
+ };
3630
+
3216
3631
  // src/examples/lanterns.ts
3217
3632
  var LANTERNS_SPEC = {
3218
3633
  schemaVersion: 1,
@@ -3906,7 +4321,8 @@ var SCHEMA_EXAMPLES = [
3906
4321
  { id: "polygons", label: "Polygons", spec: POLYGONS_SPEC },
3907
4322
  { id: "matrix-rain", label: "Matrix Rain", spec: MATRIX_RAIN_SPEC },
3908
4323
  { id: "procession", label: "Night Procession", spec: PROCESSION_SPEC },
3909
- { id: "nostalghia-candle", label: "Nostalghia's Candle", spec: NOSTALGHIA_CANDLE_SPEC }
4324
+ { id: "nostalghia-candle", label: "Nostalghia's Candle", spec: NOSTALGHIA_CANDLE_SPEC },
4325
+ { id: "haiku", label: "Haiku", spec: HAIKU_SPEC }
3910
4326
  ];
3911
4327
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
3912
4328
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -3920,6 +4336,7 @@ export {
3920
4336
  DASHBOARD_SPEC,
3921
4337
  EXAMPLE_BY_ID,
3922
4338
  EXAMPLE_SPECS,
4339
+ HAIKU_SPEC,
3923
4340
  LANTERNS_SPEC,
3924
4341
  LIMITS,
3925
4342
  MATRIX_RAIN_SPEC,
@@ -3937,9 +4354,12 @@ export {
3937
4354
  adviseSpec,
3938
4355
  alphaAt,
3939
4356
  applyDeltasToSpec,
4357
+ assertValidSequence,
3940
4358
  assertValidSpec,
4359
+ breakTextBlock,
3941
4360
  buildEntities,
3942
4361
  compileSaver,
4362
+ compileSequence,
3943
4363
  describeScene,
3944
4364
  diffScenes,
3945
4365
  dominanceRanking,
@@ -3956,6 +4376,7 @@ export {
3956
4376
  positionAt,
3957
4377
  renderBrailleMap,
3958
4378
  renderDensityMap,
4379
+ resolveSegment,
3959
4380
  resolveSpecPath,
3960
4381
  rotationAt,
3961
4382
  sizeAt,
@@ -3964,6 +4385,7 @@ export {
3964
4385
  steerablePaths,
3965
4386
  structuralSignature,
3966
4387
  textSprites,
4388
+ validateSequence,
3967
4389
  validateSpec
3968
4390
  };
3969
4391
  //# sourceMappingURL=index.js.map