@idle-screens/schema 3.1.1 → 3.3.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,11 +42,149 @@ 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
 
57
+ // src/steer.ts
58
+ var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
59
+ function resolveSpecPath(spec, path) {
60
+ if (!spec || typeof spec !== "object" || !path) return null;
61
+ const parts = path.split(".");
62
+ if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
63
+ const s = spec;
64
+ if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
65
+ const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
66
+ if (idx === -1) return null;
67
+ parts.splice(0, 1, "layers", String(idx));
68
+ }
69
+ let node = spec;
70
+ for (let i = 0; i < parts.length - 1; i++) {
71
+ if (node === null || typeof node !== "object") return null;
72
+ node = node[parts[i]];
73
+ }
74
+ if (node === null || typeof node !== "object") return null;
75
+ const last = parts[parts.length - 1];
76
+ const key = Array.isArray(node) ? Number(last) : last;
77
+ const exists = Array.isArray(node) ? Number.isInteger(key) && key >= 0 && key < node.length : last in node;
78
+ return exists ? { parent: node, key } : null;
79
+ }
80
+ function applyDeltasToSpec(spec, deltas) {
81
+ const copy = JSON.parse(JSON.stringify(spec));
82
+ for (const d of deltas) {
83
+ const loc = resolveSpecPath(copy, d.path);
84
+ if (loc) loc.parent[loc.key] = d.value;
85
+ }
86
+ return copy;
87
+ }
88
+ var HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
89
+ function hexToRgb(hex) {
90
+ const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
91
+ const n = parseInt(h.slice(1), 16);
92
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
93
+ }
94
+ function lerpHex(a, b, k) {
95
+ const ca = hexToRgb(a);
96
+ const cb = hexToRgb(b);
97
+ const mix = ca.map((v, i) => Math.round(v + (cb[i] - v) * k));
98
+ return `#${mix.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
99
+ }
100
+ function lerpSpec(from, to, k) {
101
+ const kk = Math.max(0, Math.min(1, k));
102
+ const walk = (a, b, key) => {
103
+ if (typeof a === "number" && typeof b === "number") {
104
+ const v = a + (b - a) * kk;
105
+ return key === "count" ? Math.max(1, Math.round(v)) : v;
106
+ }
107
+ if (typeof a === "string" && typeof b === "string" && HEX.test(a) && HEX.test(b)) {
108
+ return lerpHex(a, b, kk);
109
+ }
110
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
111
+ return b.map((bv, i) => walk(a[i], bv, i));
112
+ }
113
+ if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
114
+ const out = {};
115
+ for (const kName of Object.keys(b)) {
116
+ out[kName] = walk(a[kName], b[kName], kName);
117
+ }
118
+ return out;
119
+ }
120
+ return kk > 0 ? b : a;
121
+ };
122
+ return walk(from, to);
123
+ }
124
+ function steerablePaths(spec) {
125
+ if (!spec || typeof spec !== "object") return [];
126
+ const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
127
+ const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
128
+ const out = [];
129
+ const walk = (node, prefix, key) => {
130
+ if (Array.isArray(node) && INDEXED.has(key)) {
131
+ node.forEach((child, i) => walk(child, `${prefix}.${i}`, ""));
132
+ return;
133
+ }
134
+ if (Array.isArray(node)) {
135
+ if (prefix) out.push(prefix);
136
+ return;
137
+ }
138
+ if (node && typeof node === "object") {
139
+ for (const [k, v] of Object.entries(node)) {
140
+ if (SKIP.has(k)) continue;
141
+ walk(v, prefix ? `${prefix}.${k}` : k, k);
142
+ }
143
+ return;
144
+ }
145
+ if (prefix) out.push(prefix);
146
+ };
147
+ walk(spec, "", "");
148
+ return out;
149
+ }
150
+ function easeSmooth(k) {
151
+ const c = Math.max(0, Math.min(1, k));
152
+ return c * c * (3 - 2 * c);
153
+ }
154
+ function structuralSignature(spec) {
155
+ return JSON.stringify([
156
+ spec.units,
157
+ spec.layers.map((l) => {
158
+ const s = l.sprite;
159
+ return [
160
+ l.count,
161
+ l.size,
162
+ l.region,
163
+ l.position,
164
+ l.motion,
165
+ l.wrap,
166
+ l.flip,
167
+ l.alpha,
168
+ l.pulse,
169
+ l.spin,
170
+ l.grow,
171
+ l.layout,
172
+ l.sprite.kind,
173
+ // Dimensional draws baked into entities: radius (circle/ring), length
174
+ // (streak), width+aspect (rect), palette pick (all shaped sprites).
175
+ s.radius,
176
+ s.length,
177
+ l.sprite.kind === "rect" ? [s.width, s.aspect] : void 0,
178
+ Array.isArray(s.colors) ? s.colors.length : void 0,
179
+ s.colorWeights,
180
+ 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
181
+ ];
182
+ })
183
+ ]);
184
+ }
185
+
48
186
  // src/validate.ts
49
- var HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
187
+ var HEX2 = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
50
188
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
51
189
  var isStr = (v) => typeof v === "string";
52
190
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
@@ -78,6 +216,7 @@ var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width",
78
216
  var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "colors", "colorWeights"]);
79
217
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
80
218
  var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
219
+ var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color"]);
81
220
  var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob"]);
82
221
  var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway"]);
83
222
  var KNOWN_BOUNCE = /* @__PURE__ */ new Set(["type", "speed"]);
@@ -108,6 +247,14 @@ function validateSpec(spec) {
108
247
  if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
109
248
  err("units", "must be 'px' | 'viewport'");
110
249
  }
250
+ if (spec.units === "px" && Array.isArray(spec.layers)) {
251
+ for (const l of spec.layers) {
252
+ if (isObj(l) && isObj(l.sprite) && l.sprite.kind === "textBlock") {
253
+ err("units", "textBlock dimensions are viewport fractions \u2014 units: 'px' is not supported with textBlock sprites");
254
+ break;
255
+ }
256
+ }
257
+ }
111
258
  if (spec.referenceViewport !== void 0) {
112
259
  if (!isNum(spec.referenceViewport) || spec.referenceViewport < 100 || spec.referenceViewport > 8640) {
113
260
  err("referenceViewport", "must be a number between 100 and 8640");
@@ -134,7 +281,7 @@ function validateSpec(spec) {
134
281
  return { valid: errors.length === 0, errors, warnings };
135
282
  }
136
283
  function color(v, path, err) {
137
- if (!isStr(v) || !HEX.test(v)) err(path, "must be a hex colour like #0a3a52");
284
+ if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
138
285
  }
139
286
  function validateBackground(bg, err, warn) {
140
287
  if (!isObj(bg)) return err("background", "must be an object");
@@ -407,8 +554,28 @@ function validateSprite(sprite, path, err, warn) {
407
554
  }
408
555
  color(sprite.color, `${path}.color`, err);
409
556
  validatePalette(sprite, path, err);
557
+ } else if (sprite.kind === "textBlock") {
558
+ knownSet = KNOWN_TEXT_BLOCK;
559
+ if (typeof sprite.text !== "string" || sprite.text.length === 0) {
560
+ err(`${path}.text`, "must be a non-empty string");
561
+ } else if (sprite.text.length > LIMITS.maxTextBlockLength) {
562
+ err(`${path}.text`, `must be at most ${LIMITS.maxTextBlockLength} characters`);
563
+ }
564
+ if (!isNum(sprite.maxWidth) || sprite.maxWidth <= 0 || sprite.maxWidth > LIMITS.maxTextBlockMaxWidth) {
565
+ err(`${path}.maxWidth`, `must be a positive number up to ${LIMITS.maxTextBlockMaxWidth} (viewport fraction)`);
566
+ }
567
+ if (!isNum(sprite.fontSize) || sprite.fontSize < LIMITS.minTextBlockFontSize || sprite.fontSize > LIMITS.maxTextBlockFontSize) {
568
+ err(`${path}.fontSize`, `must be between ${LIMITS.minTextBlockFontSize} and ${LIMITS.maxTextBlockFontSize} (viewport fraction)`);
569
+ }
570
+ if (sprite.lineHeight !== void 0 && (!isNum(sprite.lineHeight) || sprite.lineHeight < 0.5 || sprite.lineHeight > 4)) {
571
+ err(`${path}.lineHeight`, "must be between 0.5 and 4 (multiplier)");
572
+ }
573
+ if (sprite.align !== void 0 && !["left", "center", "right"].includes(sprite.align)) {
574
+ err(`${path}.align`, "must be 'left' | 'center' | 'right'");
575
+ }
576
+ if (sprite.color !== void 0) color(sprite.color, `${path}.color`, err);
410
577
  } else {
411
- err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect");
578
+ err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect | textBlock");
412
579
  return;
413
580
  }
414
581
  for (const k of unknownKeys(sprite, knownSet)) {
@@ -576,6 +743,107 @@ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
576
743
  }
577
744
  return spec;
578
745
  }
746
+ function validateSequence(seq) {
747
+ const errors = [];
748
+ const warnings = [];
749
+ const err = (path, message) => void errors.push({ path, message });
750
+ if (!isObj(seq)) return { valid: false, errors: [{ path: "", message: "sequence must be an object" }], warnings: [] };
751
+ if (seq.format !== "idle-sequence") err("format", "must be 'idle-sequence'");
752
+ if (seq.schemaVersion !== SCHEMA_VERSION) err("schemaVersion", `must be ${SCHEMA_VERSION}`);
753
+ if (!isStr(seq.id) || seq.id.trim() === "") err("id", "must be a non-empty string");
754
+ if (!isStr(seq.label) || seq.label.trim() === "") err("label", "must be a non-empty string");
755
+ if (seq.seed !== void 0 && !isNum(seq.seed)) err("seed", "must be a number");
756
+ if (typeof seq.loop !== "boolean") err("loop", "must be a boolean");
757
+ if (!Array.isArray(seq.segments) || seq.segments.length === 0) {
758
+ err("segments", "must be a non-empty array");
759
+ return { valid: false, errors, warnings };
760
+ }
761
+ if (seq.segments.length > LIMITS.maxSegments) {
762
+ err("segments", `at most ${LIMITS.maxSegments} segments`);
763
+ }
764
+ const keys = /* @__PURE__ */ new Set();
765
+ let hasDurationless = false;
766
+ for (let i = 0; i < seq.segments.length; i++) {
767
+ const s = seq.segments[i];
768
+ const p = `segments[${i}]`;
769
+ if (!isObj(s)) {
770
+ err(p, "must be an object");
771
+ continue;
772
+ }
773
+ if (!isStr(s.key) || s.key.trim() === "") {
774
+ err(`${p}.key`, "must be a non-empty string");
775
+ } else if (keys.has(s.key)) {
776
+ err(`${p}.key`, `duplicate key '${s.key}'`);
777
+ } else {
778
+ keys.add(s.key);
779
+ }
780
+ if (s.duration !== void 0) {
781
+ if (hasDurationless) {
782
+ err(`${p}.duration`, "a segment after a durationless segment may not have a duration");
783
+ }
784
+ if (!isNum(s.duration) || s.duration < LIMITS.minSegmentDuration) {
785
+ err(`${p}.duration`, `must be >= ${LIMITS.minSegmentDuration} ms`);
786
+ }
787
+ } else {
788
+ if (i < seq.segments.length - 1) {
789
+ err(`${p}.duration`, "only the final segment may omit duration");
790
+ }
791
+ hasDurationless = true;
792
+ }
793
+ if (s.advance !== void 0 && !["auto", "input", "either"].includes(s.advance)) {
794
+ err(`${p}.advance`, "must be 'auto' | 'input' | 'either'");
795
+ }
796
+ if (s.transition !== void 0) {
797
+ if (!isObj(s.transition)) {
798
+ err(`${p}.transition`, "must be an object");
799
+ } else if (s.transition.type === "morph") {
800
+ if (!isNum(s.transition.dur) || s.transition.dur < LIMITS.minTransitionDur || s.transition.dur > LIMITS.maxTransitionDur) {
801
+ err(`${p}.transition.dur`, `must be a number between ${LIMITS.minTransitionDur} and ${LIMITS.maxTransitionDur}`);
802
+ }
803
+ } else if (s.transition.type !== "cut") {
804
+ err(`${p}.transition.type`, "must be 'cut' or 'morph'");
805
+ }
806
+ }
807
+ if (isObj(s.scene)) {
808
+ const sceneResult = validateSpec(s.scene);
809
+ for (const e of sceneResult.errors) errors.push({ path: `${p}.scene.${e.path}`, message: e.message });
810
+ if (sceneResult.warnings) {
811
+ for (const w of sceneResult.warnings) warnings.push({ path: `${p}.scene.${w.path}`, code: w.code, message: w.message });
812
+ }
813
+ } else {
814
+ err(`${p}.scene`, "must be a valid SaverSpec object");
815
+ }
816
+ }
817
+ if (seq.loop === true && hasDurationless) {
818
+ err("loop", "loop: true requires all segments to have a duration");
819
+ }
820
+ if (errors.length === 0 && Array.isArray(seq.segments)) {
821
+ for (let i = 0; i < seq.segments.length; i++) {
822
+ const s = seq.segments[i];
823
+ if (!isObj(s) || !isObj(s.transition) || s.transition.type !== "morph") continue;
824
+ const next = seq.segments[i + 1];
825
+ if (!next || !isObj(next) || !isObj(s.scene) || !isObj(next.scene)) continue;
826
+ const sigA = structuralSignature(s.scene);
827
+ const sigB = structuralSignature(next.scene);
828
+ if (sigA !== sigB) {
829
+ warnings.push({
830
+ path: `segments[${i}].transition`,
831
+ code: "morph-structural-mismatch",
832
+ message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
833
+ });
834
+ }
835
+ }
836
+ }
837
+ return { valid: errors.length === 0, errors, warnings };
838
+ }
839
+ function assertValidSequence(seq) {
840
+ const r = validateSequence(seq);
841
+ if (!r.valid) {
842
+ throw new Error(`invalid sequence:
843
+ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
844
+ }
845
+ return seq;
846
+ }
579
847
 
580
848
  // src/simulate.ts
581
849
  var WARP_NEAR = 0.08;
@@ -660,7 +928,7 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
660
928
  }
661
929
  const out = [];
662
930
  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;
931
+ 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
932
  const size2 = sprite.kind === "rect" ? size * (sprite.aspect ? rng.range(sprite.aspect[0], sprite.aspect[1]) : 1) : void 0;
665
933
  let vx = 0;
666
934
  let vy = 0;
@@ -1044,139 +1312,84 @@ function linkPairs(positions, k, maxDist, doWrap, w, h) {
1044
1312
  }
1045
1313
  return out;
1046
1314
  }
1315
+ var CHAR_NARROW = new Set(`iIljtf1!|.,;:'"()[]{}`);
1316
+ var CHAR_WIDE = new Set("mwMWGOQD@%");
1317
+ function charWidthEm(ch) {
1318
+ if (ch === " " || ch === " ") return 0.3;
1319
+ if (CHAR_NARROW.has(ch)) return 0.35;
1320
+ if (CHAR_WIDE.has(ch)) return 0.72;
1321
+ return 0.55;
1322
+ }
1323
+ function textWidthEm(str) {
1324
+ let w = 0;
1325
+ for (let i = 0; i < str.length; i++) w += charWidthEm(str[i]);
1326
+ return w;
1327
+ }
1328
+ function breakTextBlock(text, maxWidthEm) {
1329
+ const paragraphs = text.split("\n");
1330
+ const lines = [];
1331
+ for (const para of paragraphs) {
1332
+ if (para.length === 0) {
1333
+ lines.push({ text: "", widthEm: 0 });
1334
+ continue;
1335
+ }
1336
+ const words = para.split(/\s+/).filter((w) => w.length > 0);
1337
+ if (words.length === 0) {
1338
+ lines.push({ text: "", widthEm: 0 });
1339
+ continue;
1340
+ }
1341
+ let lineText = words[0];
1342
+ let lineW = textWidthEm(lineText);
1343
+ const spaceW = charWidthEm(" ");
1344
+ for (let i = 1; i < words.length; i++) {
1345
+ const word = words[i];
1346
+ const wordW = textWidthEm(word);
1347
+ if (lineW + spaceW + wordW <= maxWidthEm) {
1348
+ lineText += " " + word;
1349
+ lineW += spaceW + wordW;
1350
+ } else {
1351
+ lines.push({ text: lineText, widthEm: lineW });
1352
+ lineText = word;
1353
+ lineW = wordW;
1354
+ }
1355
+ }
1356
+ lines.push({ text: lineText, widthEm: lineW });
1357
+ }
1358
+ return lines;
1359
+ }
1047
1360
 
1048
1361
  // src/compile.ts
1049
1362
  import {
1050
1363
  createRng
1051
1364
  } from "@idle-screens/core";
1052
1365
 
1053
- // src/steer.ts
1054
- var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1055
- function resolveSpecPath(spec, path) {
1056
- if (!spec || typeof spec !== "object" || !path) return null;
1057
- const parts = path.split(".");
1058
- if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
1059
- const s = spec;
1060
- if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
1061
- const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
1062
- if (idx === -1) return null;
1063
- parts.splice(0, 1, "layers", String(idx));
1366
+ // src/sequence.ts
1367
+ function resolveSegment(seq, T) {
1368
+ const { segments, loop } = seq;
1369
+ if (segments.length === 0) return { index: 0, localT: 0, startT: 0 };
1370
+ let totalTimed = 0;
1371
+ for (const seg of segments) {
1372
+ if (seg.duration != null) totalTimed += seg.duration;
1373
+ else break;
1064
1374
  }
1065
- let node = spec;
1066
- for (let i = 0; i < parts.length - 1; i++) {
1067
- if (node === null || typeof node !== "object") return null;
1068
- node = node[parts[i]];
1069
- }
1070
- if (node === null || typeof node !== "object") return null;
1071
- const last = parts[parts.length - 1];
1072
- const key = Array.isArray(node) ? Number(last) : last;
1073
- const exists = Array.isArray(node) ? Number.isInteger(key) && key >= 0 && key < node.length : last in node;
1074
- return exists ? { parent: node, key } : null;
1075
- }
1076
- function applyDeltasToSpec(spec, deltas) {
1077
- const copy = JSON.parse(JSON.stringify(spec));
1078
- for (const d of deltas) {
1079
- const loc = resolveSpecPath(copy, d.path);
1080
- if (loc) loc.parent[loc.key] = d.value;
1375
+ let t = Math.max(0, T);
1376
+ if (loop && totalTimed > 0) {
1377
+ t = t % totalTimed;
1081
1378
  }
1082
- return copy;
1083
- }
1084
- var HEX2 = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
1085
- function hexToRgb(hex) {
1086
- const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
1087
- const n = parseInt(h.slice(1), 16);
1088
- return [n >> 16 & 255, n >> 8 & 255, n & 255];
1089
- }
1090
- function lerpHex(a, b, k) {
1091
- const ca = hexToRgb(a);
1092
- const cb = hexToRgb(b);
1093
- const mix = ca.map((v, i) => Math.round(v + (cb[i] - v) * k));
1094
- return `#${mix.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
1095
- }
1096
- function lerpSpec(from, to, k) {
1097
- const kk = Math.max(0, Math.min(1, k));
1098
- const walk = (a, b, key) => {
1099
- if (typeof a === "number" && typeof b === "number") {
1100
- const v = a + (b - a) * kk;
1101
- return key === "count" ? Math.max(1, Math.round(v)) : v;
1102
- }
1103
- if (typeof a === "string" && typeof b === "string" && HEX2.test(a) && HEX2.test(b)) {
1104
- return lerpHex(a, b, kk);
1105
- }
1106
- if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
1107
- return b.map((bv, i) => walk(a[i], bv, i));
1379
+ let cumulative = 0;
1380
+ for (let i = 0; i < segments.length; i++) {
1381
+ const seg = segments[i];
1382
+ if (seg.duration == null) {
1383
+ return { index: i, localT: t - cumulative, startT: cumulative };
1108
1384
  }
1109
- if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
1110
- const out = {};
1111
- for (const kName of Object.keys(b)) {
1112
- out[kName] = walk(a[kName], b[kName], kName);
1113
- }
1114
- return out;
1385
+ if (t < cumulative + seg.duration) {
1386
+ return { index: i, localT: t - cumulative, startT: cumulative };
1115
1387
  }
1116
- return kk > 0 ? b : a;
1117
- };
1118
- return walk(from, to);
1119
- }
1120
- function steerablePaths(spec) {
1121
- if (!spec || typeof spec !== "object") return [];
1122
- const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
1123
- const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
1124
- const out = [];
1125
- const walk = (node, prefix, key) => {
1126
- if (Array.isArray(node) && INDEXED.has(key)) {
1127
- node.forEach((child, i) => walk(child, `${prefix}.${i}`, ""));
1128
- return;
1129
- }
1130
- if (Array.isArray(node)) {
1131
- if (prefix) out.push(prefix);
1132
- return;
1133
- }
1134
- if (node && typeof node === "object") {
1135
- for (const [k, v] of Object.entries(node)) {
1136
- if (SKIP.has(k)) continue;
1137
- walk(v, prefix ? `${prefix}.${k}` : k, k);
1138
- }
1139
- return;
1140
- }
1141
- if (prefix) out.push(prefix);
1142
- };
1143
- walk(spec, "", "");
1144
- return out;
1145
- }
1146
- function easeSmooth(k) {
1147
- const c = Math.max(0, Math.min(1, k));
1148
- return c * c * (3 - 2 * c);
1149
- }
1150
- function structuralSignature(spec) {
1151
- return JSON.stringify([
1152
- spec.units,
1153
- spec.layers.map((l) => {
1154
- const s = l.sprite;
1155
- return [
1156
- l.count,
1157
- l.size,
1158
- l.region,
1159
- l.position,
1160
- l.motion,
1161
- l.wrap,
1162
- l.flip,
1163
- l.alpha,
1164
- l.pulse,
1165
- l.spin,
1166
- l.grow,
1167
- l.layout,
1168
- l.sprite.kind,
1169
- // Dimensional draws baked into entities: radius (circle/ring), length
1170
- // (streak), width+aspect (rect), palette pick (all shaped sprites).
1171
- s.radius,
1172
- s.length,
1173
- l.sprite.kind === "rect" ? [s.width, s.aspect] : void 0,
1174
- Array.isArray(s.colors) ? s.colors.length : void 0,
1175
- 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
1177
- ];
1178
- })
1179
- ]);
1388
+ cumulative += seg.duration;
1389
+ }
1390
+ const last = segments.length - 1;
1391
+ const lastStart = cumulative - (segments[last].duration ?? 0);
1392
+ return { index: last, localT: t - lastStart, startT: lastStart };
1180
1393
  }
1181
1394
 
1182
1395
  // src/compile.ts
@@ -1226,12 +1439,14 @@ var SpecInstance = class {
1226
1439
  let canvas;
1227
1440
  if (ctx.surface) {
1228
1441
  canvas = ctx.surface;
1442
+ this.ownsCanvas = false;
1229
1443
  } else {
1230
1444
  const el = document.createElement("canvas");
1231
1445
  el.style.cssText = "display:block;width:100%;height:100%";
1232
1446
  el.setAttribute("aria-hidden", "true");
1233
1447
  ctx.host.appendChild(el);
1234
1448
  canvas = el;
1449
+ this.ownsCanvas = true;
1235
1450
  }
1236
1451
  this.canvas = canvas;
1237
1452
  const c2d = canvas.getContext("2d", { alpha: false });
@@ -1247,6 +1462,7 @@ var SpecInstance = class {
1247
1462
  }
1248
1463
  spec;
1249
1464
  canvas;
1465
+ ownsCanvas;
1250
1466
  ctx;
1251
1467
  saverCtx;
1252
1468
  seed;
@@ -1365,7 +1581,7 @@ var SpecInstance = class {
1365
1581
  const headAlpha = alphaAt(e, t) * lifeA;
1366
1582
  const headSize = sizeAt(e, t);
1367
1583
  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";
1584
+ 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
1585
  const isSoft = sprite.kind === "circle" && sprite.soft;
1370
1586
  const wrap2 = built.layer.wrap !== false;
1371
1587
  const head = this.entityPos(e, t, parentE);
@@ -1472,6 +1688,28 @@ var SpecInstance = class {
1472
1688
  ctx.restore();
1473
1689
  return;
1474
1690
  }
1691
+ if (sprite.kind === "textBlock") {
1692
+ const unitScale2 = Math.min(this.w, this.h);
1693
+ const fsPx = sprite.fontSize * unitScale2;
1694
+ const lh = (sprite.lineHeight ?? 1.4) * fsPx;
1695
+ const maxWPx = sprite.maxWidth * unitScale2;
1696
+ const maxWEm = maxWPx / fsPx;
1697
+ const lines = breakTextBlock(sprite.text, maxWEm);
1698
+ const align = sprite.align ?? "left";
1699
+ ctx.save();
1700
+ ctx.translate(p.x, p.y);
1701
+ if (rot) ctx.rotate(rot);
1702
+ ctx.font = `${fsPx}px system-ui, sans-serif`;
1703
+ ctx.fillStyle = sprite.color ?? "#e6e8ef";
1704
+ ctx.textBaseline = "top";
1705
+ ctx.textAlign = align;
1706
+ const xOff = align === "center" ? maxWPx / 2 : align === "right" ? maxWPx : 0;
1707
+ for (let li = 0; li < lines.length; li++) {
1708
+ ctx.fillText(lines[li].text, xOff, li * lh);
1709
+ }
1710
+ ctx.restore();
1711
+ return;
1712
+ }
1475
1713
  ctx.save();
1476
1714
  ctx.translate(p.x, p.y);
1477
1715
  if (rot) ctx.rotate(rot);
@@ -1639,9 +1877,29 @@ var SpecInstance = class {
1639
1877
  this.rebuild();
1640
1878
  if (this.paused) this.renderFrame(0, this.seed);
1641
1879
  }
1880
+ /**
1881
+ * Replace the rendered spec without triggering a transition glide.
1882
+ * Checks structural signature — use `hotSwapPaint` when the caller
1883
+ * already knows the signature is unchanged (e.g. mid-morph lerp).
1884
+ */
1885
+ hotSwapSpec(spec) {
1886
+ this.effSpec = spec;
1887
+ const sig = structuralSignature(this.effSpec);
1888
+ if (sig !== this.lastStructural) this.rebuild();
1889
+ else this.layers.forEach((b, i) => {
1890
+ b.layer = this.effSpec.layers[i] ?? b.layer;
1891
+ });
1892
+ }
1893
+ /** Paint-only hot-swap: skips structuralSignature (caller guarantees match). */
1894
+ hotSwapPaint(spec) {
1895
+ this.effSpec = spec;
1896
+ this.layers.forEach((b, i) => {
1897
+ b.layer = this.effSpec.layers[i] ?? b.layer;
1898
+ });
1899
+ }
1642
1900
  dispose() {
1643
1901
  this.stop();
1644
- if (typeof HTMLCanvasElement !== "undefined" && this.canvas instanceof HTMLCanvasElement) this.canvas.remove();
1902
+ if (this.ownsCanvas && typeof HTMLCanvasElement !== "undefined" && this.canvas instanceof HTMLCanvasElement) this.canvas.remove();
1645
1903
  }
1646
1904
  };
1647
1905
  function compileSaver(spec) {
@@ -1652,6 +1910,192 @@ function compileSaver(spec) {
1652
1910
  spec: valid
1653
1911
  };
1654
1912
  }
1913
+ var SequenceInstance = class {
1914
+ seq;
1915
+ childCtx;
1916
+ canvas;
1917
+ children;
1918
+ activeIndex = -1;
1919
+ paused = false;
1920
+ /**
1921
+ * When a morph is active, the child at `morphFromIndex` renders with a
1922
+ * lerped spec. The child stays keyed to the *outgoing* segment's slot so
1923
+ * its seed (and entity placement) is continuous.
1924
+ */
1925
+ morphFromIndex = -1;
1926
+ constructor(seq, ctx) {
1927
+ this.seq = seq;
1928
+ let surface = ctx.surface ?? null;
1929
+ let canvas = null;
1930
+ if (!surface && typeof document !== "undefined") {
1931
+ canvas = document.createElement("canvas");
1932
+ canvas.style.cssText = "display:block;width:100%;height:100%";
1933
+ canvas.setAttribute("aria-hidden", "true");
1934
+ ctx.host.appendChild(canvas);
1935
+ surface = canvas;
1936
+ }
1937
+ this.canvas = canvas;
1938
+ this.childCtx = { ...ctx, surface, reducedMotion: true };
1939
+ this.children = new Array(seq.segments.length).fill(null);
1940
+ }
1941
+ childSeed(index, fallback) {
1942
+ return this.seq.segments[index].scene.seed ?? this.seq.seed ?? fallback;
1943
+ }
1944
+ childScene(index) {
1945
+ const seg = this.seq.segments[index];
1946
+ if (seg.scene.seed != null) return seg.scene;
1947
+ if (this.seq.seed != null) return { ...seg.scene, seed: this.seq.seed + index };
1948
+ return seg.scene;
1949
+ }
1950
+ /** Whether the boundary from `from` to `from+1` should morph. */
1951
+ canMorph(from) {
1952
+ const seg = this.seq.segments[from];
1953
+ if (!seg || seg.transition?.type !== "morph") return false;
1954
+ const next = this.seq.segments[from + 1];
1955
+ if (!next) return false;
1956
+ return structuralSignature(seg.scene) === structuralSignature(next.scene);
1957
+ }
1958
+ /** Walk back through consecutive morph boundaries to find the chain origin. */
1959
+ morphChainRoot(index) {
1960
+ let i = index;
1961
+ while (i > 0 && this.canMorph(i - 1)) i--;
1962
+ return i;
1963
+ }
1964
+ morphDur(from) {
1965
+ const tr = this.seq.segments[from]?.transition;
1966
+ return tr?.type === "morph" ? tr.dur : 0;
1967
+ }
1968
+ ensureChild(index) {
1969
+ if (index < 0 || index >= this.seq.segments.length) index = 0;
1970
+ let child = this.children[index];
1971
+ if (!child) {
1972
+ child = new SpecInstance(this.childScene(index), this.childCtx);
1973
+ this.children[index] = child;
1974
+ if (this.paused) child.setPaused(true);
1975
+ }
1976
+ return child;
1977
+ }
1978
+ releaseChild(index) {
1979
+ const child = this.children[index];
1980
+ if (child) {
1981
+ child.dispose();
1982
+ this.children[index] = null;
1983
+ }
1984
+ }
1985
+ renderFrame(T, seed) {
1986
+ const resolved = resolveSegment(this.seq, T);
1987
+ const { index, localT } = resolved;
1988
+ const prevIdx = index > 0 ? index - 1 : -1;
1989
+ const morphActive = prevIdx >= 0 && this.canMorph(prevIdx) && localT < this.morphDur(prevIdx);
1990
+ if (morphActive) {
1991
+ const chainRoot = this.morphChainRoot(index);
1992
+ this.morphFromIndex = prevIdx;
1993
+ this.activeIndex = index;
1994
+ for (let i = 0; i < this.children.length; i++) {
1995
+ if (i !== chainRoot) this.releaseChild(i);
1996
+ }
1997
+ const child = this.ensureChild(chainRoot);
1998
+ const dur = this.morphDur(prevIdx);
1999
+ const k = easeSmooth(localT / dur);
2000
+ const specA = this.childScene(prevIdx);
2001
+ const specB = this.childScene(index);
2002
+ child.hotSwapPaint(lerpSpec(specA, specB, k));
2003
+ child.renderFrame(localT, this.childSeed(chainRoot, seed));
2004
+ } else {
2005
+ if (this.morphFromIndex >= 0) {
2006
+ const oldRoot = this.morphChainRoot(this.morphFromIndex);
2007
+ this.releaseChild(oldRoot);
2008
+ if (oldRoot !== this.morphFromIndex) this.releaseChild(this.morphFromIndex);
2009
+ this.morphFromIndex = -1;
2010
+ }
2011
+ if (index !== this.activeIndex) {
2012
+ for (let i = 0; i < this.children.length; i++) {
2013
+ if (i !== index) this.releaseChild(i);
2014
+ }
2015
+ this.activeIndex = index;
2016
+ }
2017
+ const chainRoot = this.morphChainRoot(index);
2018
+ const useMorphSeed = chainRoot < index;
2019
+ const effSeed = useMorphSeed ? this.childSeed(chainRoot, seed) : this.childSeed(index, seed);
2020
+ let child;
2021
+ if (useMorphSeed && !this.children[index]) {
2022
+ const rootScene = this.childScene(chainRoot);
2023
+ child = new SpecInstance(rootScene, this.childCtx);
2024
+ this.children[index] = child;
2025
+ if (this.paused) child.setPaused(true);
2026
+ child.hotSwapSpec(this.childScene(index));
2027
+ } else {
2028
+ child = this.ensureChild(index);
2029
+ if (useMorphSeed) child.hotSwapSpec(this.childScene(index));
2030
+ }
2031
+ child.renderFrame(localT, effSeed);
2032
+ }
2033
+ }
2034
+ setPaused(paused) {
2035
+ this.paused = paused;
2036
+ for (const child of this.children) {
2037
+ child?.setPaused(paused);
2038
+ }
2039
+ }
2040
+ resize(width, height, dpr) {
2041
+ for (const child of this.children) {
2042
+ child?.resize(width, height, dpr);
2043
+ }
2044
+ }
2045
+ applyTrack(track) {
2046
+ const deltas = track?.deltas ?? [];
2047
+ const segDelta = deltas.find((d) => d.path === "sequence.segment");
2048
+ if (segDelta !== void 0 && typeof segDelta.value === "number") {
2049
+ const idx = Math.max(0, Math.min(this.seq.segments.length - 1, Math.round(segDelta.value)));
2050
+ if (idx !== this.activeIndex) {
2051
+ for (let i = 0; i < this.children.length; i++) {
2052
+ if (i !== idx) this.releaseChild(i);
2053
+ }
2054
+ this.activeIndex = idx;
2055
+ const child = this.ensureChild(idx);
2056
+ child.renderFrame(0, this.seq.segments[idx].scene.seed ?? this.seq.seed ?? 0);
2057
+ }
2058
+ }
2059
+ const childDeltas = deltas.filter((d) => d.path !== "sequence.segment");
2060
+ if (childDeltas.length > 0 && this.activeIndex >= 0) {
2061
+ const child = this.children[this.activeIndex];
2062
+ child?.applyTrack({ ...track, deltas: childDeltas });
2063
+ }
2064
+ }
2065
+ dispose() {
2066
+ for (let i = 0; i < this.children.length; i++) {
2067
+ this.releaseChild(i);
2068
+ }
2069
+ if (this.canvas) this.canvas.remove();
2070
+ }
2071
+ };
2072
+ function sequenceManifest(seq) {
2073
+ const maxTotal = seq.segments.reduce((max, s) => {
2074
+ const t = s.scene.layers.reduce((n, l) => n + l.count, 0);
2075
+ return Math.max(max, t);
2076
+ }, 0);
2077
+ const costTier = maxTotal < 30 ? "idle" : maxTotal < 150 ? "low" : maxTotal < 400 ? "medium" : "high";
2078
+ return {
2079
+ id: seq.id,
2080
+ label: seq.label,
2081
+ timeModel: "closed-form",
2082
+ passthrough: false,
2083
+ minBackend: "canvas2d",
2084
+ costTier,
2085
+ motionIntensity: "moderate",
2086
+ reducedMotionFallback: "static",
2087
+ a11y: { flashSafe: true },
2088
+ workerReady: false
2089
+ };
2090
+ }
2091
+ function compileSequence(spec) {
2092
+ const valid = assertValidSequence(spec);
2093
+ return {
2094
+ manifest: sequenceManifest(valid),
2095
+ mount: (ctx) => new SequenceInstance(valid, ctx),
2096
+ spec: valid
2097
+ };
2098
+ }
1655
2099
 
1656
2100
  // src/advise.ts
1657
2101
  import { createRng as createRng2 } from "@idle-screens/core";
@@ -1671,7 +2115,7 @@ function spriteLuma(layer, e) {
1671
2115
  if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
1672
2116
  return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
1673
2117
  }
1674
- if (s.kind === "text") return hexLuma(s.color ?? "#e6e8ef");
2118
+ if (s.kind === "text" || s.kind === "textBlock") return hexLuma(s.color ?? "#e6e8ef");
1675
2119
  return 0.75;
1676
2120
  }
1677
2121
  function spriteHex(layer, e) {
@@ -1679,7 +2123,7 @@ function spriteHex(layer, e) {
1679
2123
  if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
1680
2124
  return s.colors?.[e.colorIndex] ?? s.color;
1681
2125
  }
1682
- if (s.kind === "text") return s.color ?? "#e6e8ef";
2126
+ if (s.kind === "text" || s.kind === "textBlock") return s.color ?? "#e6e8ef";
1683
2127
  return null;
1684
2128
  }
1685
2129
  function backgroundLuma(spec) {
@@ -1734,7 +2178,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1734
2178
  const layer = spec.layers[li];
1735
2179
  const entities = allEntities[li];
1736
2180
  totalEntities += entities.length;
1737
- const isText = layer.sprite.kind === "text";
2181
+ const isText = layer.sprite.kind === "text" || layer.sprite.kind === "textBlock";
1738
2182
  const isStaticText = isText && layer.motion.type === "static";
1739
2183
  if (isStaticText) textLayerCount++;
1740
2184
  if (layer.motion.type !== "static") motionLayerCount++;
@@ -1793,7 +2237,18 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1793
2237
  const entities = allEntities[li];
1794
2238
  for (const e of entities) {
1795
2239
  const r = e.size / 2;
1796
- const pixArea = layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
2240
+ let pixArea;
2241
+ if (layer.sprite.kind === "circle") {
2242
+ pixArea = Math.PI * r * r;
2243
+ } else if (layer.sprite.kind === "textBlock") {
2244
+ const fsPx = layer.sprite.fontSize * scale;
2245
+ const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
2246
+ const maxWPx = layer.sprite.maxWidth * scale;
2247
+ const lines = breakTextBlock(layer.sprite.text, maxWPx / fsPx);
2248
+ pixArea = maxWPx * lines.length * lh * 0.55;
2249
+ } else {
2250
+ pixArea = e.size * e.size;
2251
+ }
1797
2252
  totalCoverage += pixArea * e.alpha / (w * h);
1798
2253
  }
1799
2254
  if (layer.links) {
@@ -1880,6 +2335,40 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1880
2335
  }
1881
2336
  return warnings;
1882
2337
  }
2338
+ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
2339
+ const warnings = [];
2340
+ for (let i = 0; i < seq.segments.length; i++) {
2341
+ const segWarnings = adviseSpec(seq.segments[i].scene, viewport);
2342
+ for (const w of segWarnings) {
2343
+ warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message });
2344
+ }
2345
+ }
2346
+ for (let i = 0; i < seq.segments.length - 1; i++) {
2347
+ const lumaA = backgroundLuma(seq.segments[i].scene);
2348
+ const lumaB = backgroundLuma(seq.segments[i + 1].scene);
2349
+ const delta = Math.abs(lumaA - lumaB);
2350
+ if (delta > 0.5) {
2351
+ warnings.push({
2352
+ path: `segments[${i}]`,
2353
+ code: "boundary-luminance-jump",
2354
+ message: `background luminance jumps ${delta.toFixed(2)} at boundary ${i}\u2192${i + 1} \u2014 may flash on cut`
2355
+ });
2356
+ }
2357
+ const tr = seq.segments[i].transition;
2358
+ if (tr?.type === "morph") {
2359
+ const sigA = structuralSignature(seq.segments[i].scene);
2360
+ const sigB = structuralSignature(seq.segments[i + 1].scene);
2361
+ if (sigA !== sigB) {
2362
+ warnings.push({
2363
+ path: `segments[${i}].transition`,
2364
+ code: "morph-structural-mismatch",
2365
+ message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
2366
+ });
2367
+ }
2368
+ }
2369
+ }
2370
+ return warnings;
2371
+ }
1883
2372
 
1884
2373
  // src/describe.ts
1885
2374
  import { createRng as createRng3 } from "@idle-screens/core";
@@ -2021,6 +2510,24 @@ function textBox(s, e, p) {
2021
2510
  halfY: fh * 0.6
2022
2511
  };
2023
2512
  }
2513
+ function textBlockBox(s, _e, p, w, h) {
2514
+ const unitScale = Math.min(w, h);
2515
+ const fsPx = s.fontSize * unitScale;
2516
+ const lh = (s.lineHeight ?? 1.4) * fsPx;
2517
+ const maxWPx = s.maxWidth * unitScale;
2518
+ const maxWEm = maxWPx / fsPx;
2519
+ const lines = breakTextBlock(s.text, maxWEm);
2520
+ const totalH = lines.length * lh;
2521
+ const maxLineW = lines.reduce((m, l) => Math.max(m, l.widthEm), 0) * fsPx;
2522
+ const align = s.align ?? "left";
2523
+ const cx = align === "center" ? p.x + maxWPx / 2 : align === "right" ? p.x + maxWPx - maxLineW / 2 : p.x + maxLineW / 2;
2524
+ return {
2525
+ cx,
2526
+ cy: p.y + totalH / 2,
2527
+ halfX: maxLineW / 2,
2528
+ halfY: totalH / 2
2529
+ };
2530
+ }
2024
2531
  function luminanceGrid(spec, opts = {}) {
2025
2532
  const scene = buildScene(spec, opts);
2026
2533
  const t = opts.t ?? 5e3;
@@ -2142,6 +2649,12 @@ function luminanceGrid(spec, opts = {}) {
2142
2649
  centerY = box.cy;
2143
2650
  halfX = box.halfX;
2144
2651
  halfY = box.halfY;
2652
+ } else if (s.kind === "textBlock") {
2653
+ const box = textBlockBox(s, e, p, w, h);
2654
+ centerX = box.cx;
2655
+ centerY = box.cy;
2656
+ halfX = box.halfX;
2657
+ halfY = box.halfY;
2145
2658
  }
2146
2659
  const circular = s.kind === "circle" || s.kind === "ring";
2147
2660
  const soft = s.kind === "circle" && !!s.soft;
@@ -2153,7 +2666,7 @@ function luminanceGrid(spec, opts = {}) {
2153
2666
  const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
2154
2667
  const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
2155
2668
  const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
2156
- const inkWeight = s.kind === "text" || s.kind === "emoji" ? 0.55 : 1;
2669
+ const inkWeight = s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1;
2157
2670
  for (let r = r0; r <= r1; r++) {
2158
2671
  for (let c = c0; c <= c1; c++) {
2159
2672
  const dx = (c + 0.5) * cellW - centerX;
@@ -2312,6 +2825,13 @@ function textSprites(spec, opts = {}) {
2312
2825
  const t = opts.t ?? 5e3;
2313
2826
  const out = [];
2314
2827
  scene.layers.forEach(({ layer, entities }, layerIndex) => {
2828
+ if (layer.sprite.kind === "textBlock") {
2829
+ const s2 = layer.sprite;
2830
+ const unitScale = Math.min(scene.w, scene.h);
2831
+ const sizePx2 = s2.fontSize * unitScale;
2832
+ out.push({ layerIndex, key: layer.key, strings: [s2.text], count: entities.length, sizePx: Math.round(sizePx2) });
2833
+ return;
2834
+ }
2315
2835
  if (layer.sprite.kind !== "text") return;
2316
2836
  const s = layer.sprite;
2317
2837
  const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
@@ -2362,6 +2882,9 @@ function dominanceRanking(spec, opts = {}) {
2362
2882
  } else if (s.kind === "text") {
2363
2883
  const box = textBox(s, e, { x: 0, y: 0 });
2364
2884
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
2885
+ } else if (s.kind === "textBlock") {
2886
+ const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
2887
+ entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
2365
2888
  } else entArea = sz * sz * 0.55;
2366
2889
  area += entArea * a;
2367
2890
  if (layer.trail) {
@@ -3213,6 +3736,36 @@ var DASHBOARD_SPEC = {
3213
3736
  ]
3214
3737
  };
3215
3738
 
3739
+ // src/examples/haiku.ts
3740
+ var HAIKU_SPEC = {
3741
+ schemaVersion: 1,
3742
+ id: "haiku",
3743
+ label: "Haiku",
3744
+ seed: 42,
3745
+ background: { type: "solid", color: "#080812" },
3746
+ layers: [
3747
+ {
3748
+ count: 1,
3749
+ sprite: {
3750
+ kind: "textBlock",
3751
+ text: "An old silent pond\nA frog jumps into the pond \u2014\nSplash! Silence again.",
3752
+ maxWidth: 0.8,
3753
+ fontSize: 0.05,
3754
+ lineHeight: 1.8,
3755
+ align: "center",
3756
+ color: "#c8cad4"
3757
+ },
3758
+ motion: { type: "static" },
3759
+ position: { x: 0.1, y: 0.35 }
3760
+ },
3761
+ {
3762
+ count: 30,
3763
+ sprite: { kind: "circle", radius: [5e-4, 15e-4], color: "#3a3a5c" },
3764
+ motion: { type: "drift", speed: [2e-3, 6e-3], angle: 270 }
3765
+ }
3766
+ ]
3767
+ };
3768
+
3216
3769
  // src/examples/lanterns.ts
3217
3770
  var LANTERNS_SPEC = {
3218
3771
  schemaVersion: 1,
@@ -3906,7 +4459,8 @@ var SCHEMA_EXAMPLES = [
3906
4459
  { id: "polygons", label: "Polygons", spec: POLYGONS_SPEC },
3907
4460
  { id: "matrix-rain", label: "Matrix Rain", spec: MATRIX_RAIN_SPEC },
3908
4461
  { id: "procession", label: "Night Procession", spec: PROCESSION_SPEC },
3909
- { id: "nostalghia-candle", label: "Nostalghia's Candle", spec: NOSTALGHIA_CANDLE_SPEC }
4462
+ { id: "nostalghia-candle", label: "Nostalghia's Candle", spec: NOSTALGHIA_CANDLE_SPEC },
4463
+ { id: "haiku", label: "Haiku", spec: HAIKU_SPEC }
3910
4464
  ];
3911
4465
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
3912
4466
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -3920,6 +4474,7 @@ export {
3920
4474
  DASHBOARD_SPEC,
3921
4475
  EXAMPLE_BY_ID,
3922
4476
  EXAMPLE_SPECS,
4477
+ HAIKU_SPEC,
3923
4478
  LANTERNS_SPEC,
3924
4479
  LIMITS,
3925
4480
  MATRIX_RAIN_SPEC,
@@ -3934,12 +4489,16 @@ export {
3934
4489
  SNOWFALL_SPEC,
3935
4490
  WARP_NEAR,
3936
4491
  WARP_TUNNEL_SPEC,
4492
+ adviseSequence,
3937
4493
  adviseSpec,
3938
4494
  alphaAt,
3939
4495
  applyDeltasToSpec,
4496
+ assertValidSequence,
3940
4497
  assertValidSpec,
4498
+ breakTextBlock,
3941
4499
  buildEntities,
3942
4500
  compileSaver,
4501
+ compileSequence,
3943
4502
  describeScene,
3944
4503
  diffScenes,
3945
4504
  dominanceRanking,
@@ -3956,6 +4515,7 @@ export {
3956
4515
  positionAt,
3957
4516
  renderBrailleMap,
3958
4517
  renderDensityMap,
4518
+ resolveSegment,
3959
4519
  resolveSpecPath,
3960
4520
  rotationAt,
3961
4521
  sizeAt,
@@ -3964,6 +4524,7 @@ export {
3964
4524
  steerablePaths,
3965
4525
  structuralSignature,
3966
4526
  textSprites,
4527
+ validateSequence,
3967
4528
  validateSpec
3968
4529
  };
3969
4530
  //# sourceMappingURL=index.js.map