@idle-screens/schema 3.2.0 → 3.4.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
@@ -47,6 +47,10 @@ var LIMITS = {
47
47
  minTextBlockFontSize: 0.01,
48
48
  maxTextBlockFontSize: 0.2,
49
49
  maxTextBlockMaxWidth: 1,
50
+ maxRevealSpeed: 120,
51
+ // graphemes/sec — faster than any readable typing
52
+ maxCaretBlinkHz: 3,
53
+ // full blink cycles/sec — WCAG 2.3.1 flash-safety cap
50
54
  maxSegments: 24,
51
55
  minSegmentDuration: 1e3,
52
56
  // ms — flash-safety floor: segment cuts are luminance transitions
@@ -54,8 +58,137 @@ var LIMITS = {
54
58
  minTransitionDur: 200
55
59
  };
56
60
 
61
+ // src/steer.ts
62
+ var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
63
+ function resolveSpecPath(spec, path) {
64
+ if (!spec || typeof spec !== "object" || !path) return null;
65
+ const parts = path.split(".");
66
+ if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
67
+ const s = spec;
68
+ if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
69
+ const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
70
+ if (idx === -1) return null;
71
+ parts.splice(0, 1, "layers", String(idx));
72
+ }
73
+ let node = spec;
74
+ for (let i = 0; i < parts.length - 1; i++) {
75
+ if (node === null || typeof node !== "object") return null;
76
+ node = node[parts[i]];
77
+ }
78
+ if (node === null || typeof node !== "object") return null;
79
+ const last = parts[parts.length - 1];
80
+ const key = Array.isArray(node) ? Number(last) : last;
81
+ const exists = Array.isArray(node) ? Number.isInteger(key) && key >= 0 && key < node.length : last in node;
82
+ return exists ? { parent: node, key } : null;
83
+ }
84
+ function applyDeltasToSpec(spec, deltas) {
85
+ const copy = JSON.parse(JSON.stringify(spec));
86
+ for (const d of deltas) {
87
+ const loc = resolveSpecPath(copy, d.path);
88
+ if (loc) loc.parent[loc.key] = d.value;
89
+ }
90
+ return copy;
91
+ }
92
+ var HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
93
+ function hexToRgb(hex) {
94
+ const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
95
+ const n = parseInt(h.slice(1), 16);
96
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
97
+ }
98
+ function lerpHex(a, b, k) {
99
+ const ca = hexToRgb(a);
100
+ const cb = hexToRgb(b);
101
+ const mix = ca.map((v, i) => Math.round(v + (cb[i] - v) * k));
102
+ return `#${mix.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
103
+ }
104
+ function lerpSpec(from, to, k) {
105
+ const kk = Math.max(0, Math.min(1, k));
106
+ const walk = (a, b, key) => {
107
+ if (typeof a === "number" && typeof b === "number") {
108
+ const v = a + (b - a) * kk;
109
+ return key === "count" ? Math.max(1, Math.round(v)) : v;
110
+ }
111
+ if (typeof a === "string" && typeof b === "string" && HEX.test(a) && HEX.test(b)) {
112
+ return lerpHex(a, b, kk);
113
+ }
114
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
115
+ return b.map((bv, i) => walk(a[i], bv, i));
116
+ }
117
+ if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
118
+ const out = {};
119
+ for (const kName of Object.keys(b)) {
120
+ out[kName] = walk(a[kName], b[kName], kName);
121
+ }
122
+ return out;
123
+ }
124
+ return kk > 0 ? b : a;
125
+ };
126
+ return walk(from, to);
127
+ }
128
+ function steerablePaths(spec) {
129
+ if (!spec || typeof spec !== "object") return [];
130
+ const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
131
+ const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
132
+ const out = [];
133
+ const walk = (node, prefix, key) => {
134
+ if (Array.isArray(node) && INDEXED.has(key)) {
135
+ node.forEach((child, i) => walk(child, `${prefix}.${i}`, ""));
136
+ return;
137
+ }
138
+ if (Array.isArray(node)) {
139
+ if (prefix) out.push(prefix);
140
+ return;
141
+ }
142
+ if (node && typeof node === "object") {
143
+ for (const [k, v] of Object.entries(node)) {
144
+ if (SKIP.has(k)) continue;
145
+ walk(v, prefix ? `${prefix}.${k}` : k, k);
146
+ }
147
+ return;
148
+ }
149
+ if (prefix) out.push(prefix);
150
+ };
151
+ walk(spec, "", "");
152
+ return out;
153
+ }
154
+ function easeSmooth(k) {
155
+ const c = Math.max(0, Math.min(1, k));
156
+ return c * c * (3 - 2 * c);
157
+ }
158
+ function structuralSignature(spec) {
159
+ return JSON.stringify([
160
+ spec.units,
161
+ spec.layers.map((l) => {
162
+ const s = l.sprite;
163
+ return [
164
+ l.count,
165
+ l.size,
166
+ l.region,
167
+ l.position,
168
+ l.motion,
169
+ l.wrap,
170
+ l.flip,
171
+ l.alpha,
172
+ l.pulse,
173
+ l.spin,
174
+ l.grow,
175
+ l.layout,
176
+ l.sprite.kind,
177
+ // Dimensional draws baked into entities: radius (circle/ring), length
178
+ // (streak), width+aspect (rect), palette pick (all shaped sprites).
179
+ s.radius,
180
+ s.length,
181
+ l.sprite.kind === "rect" ? [s.width, s.aspect] : void 0,
182
+ Array.isArray(s.colors) ? s.colors.length : void 0,
183
+ s.colorWeights,
184
+ 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
185
+ ];
186
+ })
187
+ ]);
188
+ }
189
+
57
190
  // src/validate.ts
58
- var HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
191
+ var HEX2 = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
59
192
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
60
193
  var isStr = (v) => typeof v === "string";
61
194
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
@@ -87,7 +220,9 @@ var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width",
87
220
  var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "colors", "colorWeights"]);
88
221
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
89
222
  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"]);
223
+ var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color", "reveal"]);
224
+ var KNOWN_REVEAL = /* @__PURE__ */ new Set(["progress", "mode", "speed", "caret"]);
225
+ var KNOWN_REVEAL_CARET = /* @__PURE__ */ new Set(["blink", "color"]);
91
226
  var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob"]);
92
227
  var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway"]);
93
228
  var KNOWN_BOUNCE = /* @__PURE__ */ new Set(["type", "speed"]);
@@ -152,7 +287,7 @@ function validateSpec(spec) {
152
287
  return { valid: errors.length === 0, errors, warnings };
153
288
  }
154
289
  function color(v, path, err) {
155
- if (!isStr(v) || !HEX.test(v)) err(path, "must be a hex colour like #0a3a52");
290
+ if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
156
291
  }
157
292
  function validateBackground(bg, err, warn) {
158
293
  if (!isObj(bg)) return err("background", "must be an object");
@@ -445,6 +580,39 @@ function validateSprite(sprite, path, err, warn) {
445
580
  err(`${path}.align`, "must be 'left' | 'center' | 'right'");
446
581
  }
447
582
  if (sprite.color !== void 0) color(sprite.color, `${path}.color`, err);
583
+ if (sprite.reveal !== void 0) {
584
+ const rv = sprite.reveal;
585
+ if (typeof rv !== "object" || rv === null || Array.isArray(rv)) {
586
+ err(`${path}.reveal`, "must be an object");
587
+ } else {
588
+ for (const k of Object.keys(rv)) {
589
+ if (!KNOWN_REVEAL.has(k)) err(`${path}.reveal.${k}`, "unknown reveal field");
590
+ }
591
+ if (rv.progress !== void 0 && (!isNum(rv.progress) || rv.progress < 0 || rv.progress > 1)) {
592
+ err(`${path}.reveal.progress`, "must be a number between 0 and 1");
593
+ }
594
+ if (rv.mode !== void 0 && !["typewriter", "word", "line"].includes(rv.mode)) {
595
+ err(`${path}.reveal.mode`, "must be 'typewriter' | 'word' | 'line'");
596
+ }
597
+ if (rv.speed !== void 0 && (!isNum(rv.speed) || rv.speed < 0 || rv.speed > LIMITS.maxRevealSpeed)) {
598
+ err(`${path}.reveal.speed`, `must be between 0 and ${LIMITS.maxRevealSpeed} (graphemes/sec)`);
599
+ }
600
+ if (rv.caret !== void 0 && typeof rv.caret !== "boolean") {
601
+ const caret = rv.caret;
602
+ if (typeof caret !== "object" || caret === null || Array.isArray(caret)) {
603
+ err(`${path}.reveal.caret`, "must be a boolean or an object");
604
+ } else {
605
+ for (const k of Object.keys(caret)) {
606
+ if (!KNOWN_REVEAL_CARET.has(k)) err(`${path}.reveal.caret.${k}`, "unknown caret field");
607
+ }
608
+ if (caret.blink !== void 0 && (!isNum(caret.blink) || caret.blink <= 0 || caret.blink > LIMITS.maxCaretBlinkHz)) {
609
+ err(`${path}.reveal.caret.blink`, `must be between 0 (exclusive) and ${LIMITS.maxCaretBlinkHz} Hz (flash safety)`);
610
+ }
611
+ if (caret.color !== void 0) color(caret.color, `${path}.reveal.caret.color`, err);
612
+ }
613
+ }
614
+ }
615
+ }
448
616
  } else {
449
617
  err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect | textBlock");
450
618
  return;
@@ -667,8 +835,12 @@ function validateSequence(seq) {
667
835
  if (s.transition !== void 0) {
668
836
  if (!isObj(s.transition)) {
669
837
  err(`${p}.transition`, "must be an object");
838
+ } else if (s.transition.type === "morph") {
839
+ if (!isNum(s.transition.dur) || s.transition.dur < LIMITS.minTransitionDur || s.transition.dur > LIMITS.maxTransitionDur) {
840
+ err(`${p}.transition.dur`, `must be a number between ${LIMITS.minTransitionDur} and ${LIMITS.maxTransitionDur}`);
841
+ }
670
842
  } else if (s.transition.type !== "cut") {
671
- err(`${p}.transition.type`, "must be 'cut' (fade not yet supported)");
843
+ err(`${p}.transition.type`, "must be 'cut' or 'morph'");
672
844
  }
673
845
  }
674
846
  if (isObj(s.scene)) {
@@ -684,6 +856,23 @@ function validateSequence(seq) {
684
856
  if (seq.loop === true && hasDurationless) {
685
857
  err("loop", "loop: true requires all segments to have a duration");
686
858
  }
859
+ if (errors.length === 0 && Array.isArray(seq.segments)) {
860
+ for (let i = 0; i < seq.segments.length; i++) {
861
+ const s = seq.segments[i];
862
+ if (!isObj(s) || !isObj(s.transition) || s.transition.type !== "morph") continue;
863
+ const next = seq.segments[i + 1];
864
+ if (!next || !isObj(next) || !isObj(s.scene) || !isObj(next.scene)) continue;
865
+ const sigA = structuralSignature(s.scene);
866
+ const sigB = structuralSignature(next.scene);
867
+ if (sigA !== sigB) {
868
+ warnings.push({
869
+ path: `segments[${i}].transition`,
870
+ code: "morph-structural-mismatch",
871
+ message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
872
+ });
873
+ }
874
+ }
875
+ }
687
876
  return { valid: errors.length === 0, errors, warnings };
688
877
  }
689
878
  function assertValidSequence(seq) {
@@ -1207,141 +1396,99 @@ function breakTextBlock(text, maxWidthEm) {
1207
1396
  }
1208
1397
  return lines;
1209
1398
  }
1210
-
1211
- // src/compile.ts
1212
- import {
1213
- createRng
1214
- } from "@idle-screens/core";
1215
-
1216
- // src/steer.ts
1217
- var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1218
- function resolveSpecPath(spec, path) {
1219
- if (!spec || typeof spec !== "object" || !path) return null;
1220
- const parts = path.split(".");
1221
- if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
1222
- const s = spec;
1223
- if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
1224
- const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
1225
- if (idx === -1) return null;
1226
- parts.splice(0, 1, "layers", String(idx));
1227
- }
1228
- let node = spec;
1229
- for (let i = 0; i < parts.length - 1; i++) {
1230
- if (node === null || typeof node !== "object") return null;
1231
- node = node[parts[i]];
1232
- }
1233
- if (node === null || typeof node !== "object") return null;
1234
- const last = parts[parts.length - 1];
1235
- const key = Array.isArray(node) ? Number(last) : last;
1236
- const exists = Array.isArray(node) ? Number.isInteger(key) && key >= 0 && key < node.length : last in node;
1237
- return exists ? { parent: node, key } : null;
1238
- }
1239
- function applyDeltasToSpec(spec, deltas) {
1240
- const copy = JSON.parse(JSON.stringify(spec));
1241
- for (const d of deltas) {
1242
- const loc = resolveSpecPath(copy, d.path);
1243
- if (loc) loc.parent[loc.key] = d.value;
1244
- }
1245
- return copy;
1246
- }
1247
- var HEX2 = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
1248
- function hexToRgb(hex) {
1249
- const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
1250
- const n = parseInt(h.slice(1), 16);
1251
- return [n >> 16 & 255, n >> 8 & 255, n & 255];
1399
+ function isClusterExtend(cp) {
1400
+ return cp >= 768 && cp <= 879 || // combining diacritics
1401
+ cp >= 6832 && cp <= 6911 || cp >= 8400 && cp <= 8447 || cp >= 65024 && cp <= 65039 || // variation selectors
1402
+ cp >= 127995 && cp <= 127999 || // skin tones
1403
+ cp === 8419;
1252
1404
  }
1253
- function lerpHex(a, b, k) {
1254
- const ca = hexToRgb(a);
1255
- const cb = hexToRgb(b);
1256
- const mix = ca.map((v, i) => Math.round(v + (cb[i] - v) * k));
1257
- return `#${mix.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
1405
+ function isRegionalIndicator(cp) {
1406
+ return cp >= 127462 && cp <= 127487;
1258
1407
  }
1259
- function lerpSpec(from, to, k) {
1260
- const kk = Math.max(0, Math.min(1, k));
1261
- const walk = (a, b, key) => {
1262
- if (typeof a === "number" && typeof b === "number") {
1263
- const v = a + (b - a) * kk;
1264
- return key === "count" ? Math.max(1, Math.round(v)) : v;
1265
- }
1266
- if (typeof a === "string" && typeof b === "string" && HEX2.test(a) && HEX2.test(b)) {
1267
- return lerpHex(a, b, kk);
1268
- }
1269
- if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
1270
- return b.map((bv, i) => walk(a[i], bv, i));
1271
- }
1272
- if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
1273
- const out = {};
1274
- for (const kName of Object.keys(b)) {
1275
- out[kName] = walk(a[kName], b[kName], kName);
1408
+ var ZWJ = 8205;
1409
+ function graphemeClusters(text) {
1410
+ const out = [];
1411
+ let i = 0;
1412
+ while (i < text.length) {
1413
+ const cp = text.codePointAt(i);
1414
+ let end = i + (cp > 65535 ? 2 : 1);
1415
+ if (isRegionalIndicator(cp) && end < text.length) {
1416
+ const next = text.codePointAt(end);
1417
+ if (isRegionalIndicator(next)) end += next > 65535 ? 2 : 1;
1418
+ }
1419
+ while (end < text.length) {
1420
+ const next = text.codePointAt(end);
1421
+ if (isClusterExtend(next)) {
1422
+ end += next > 65535 ? 2 : 1;
1423
+ } else if (next === ZWJ && end + 1 < text.length) {
1424
+ const after = text.codePointAt(end + 1);
1425
+ end += 1 + (after > 65535 ? 2 : 1);
1426
+ } else {
1427
+ break;
1276
1428
  }
1277
- return out;
1278
1429
  }
1279
- return kk > 0 ? b : a;
1280
- };
1281
- return walk(from, to);
1430
+ out.push(text.slice(i, end));
1431
+ i = end;
1432
+ }
1433
+ return out;
1282
1434
  }
1283
- function steerablePaths(spec) {
1284
- if (!spec || typeof spec !== "object") return [];
1285
- const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
1286
- const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
1287
- const out = [];
1288
- const walk = (node, prefix, key) => {
1289
- if (Array.isArray(node) && INDEXED.has(key)) {
1290
- node.forEach((child, i) => walk(child, `${prefix}.${i}`, ""));
1291
- return;
1292
- }
1293
- if (Array.isArray(node)) {
1294
- if (prefix) out.push(prefix);
1295
- return;
1435
+ function revealState(lines, reveal, tMs) {
1436
+ const lineClusters = lines.map((l) => graphemeClusters(l.text));
1437
+ const totalGraphemes = lineClusters.reduce((n, c) => n + c.length, 0);
1438
+ const authored = Math.max(0, Math.min(1, reveal.progress ?? 1));
1439
+ const speed = reveal.speed ?? 0;
1440
+ const timed = speed > 0 && totalGraphemes > 0 ? Math.min(1, speed * tMs / 1e3 / totalGraphemes) : 1;
1441
+ const progress = Math.min(authored, timed);
1442
+ const mode = reveal.mode ?? "typewriter";
1443
+ let fullLines = 0;
1444
+ let partialText = "";
1445
+ if (mode === "line") {
1446
+ fullLines = Math.round(progress * lines.length);
1447
+ } else if (mode === "word") {
1448
+ const lineWords = lines.map((l) => l.text.length === 0 ? [] : l.text.split(" "));
1449
+ const totalWords = lineWords.reduce((n, ws) => n + ws.length, 0);
1450
+ let remaining = Math.round(progress * totalWords);
1451
+ for (const ws of lineWords) {
1452
+ if (remaining >= ws.length) {
1453
+ remaining -= ws.length;
1454
+ fullLines++;
1455
+ continue;
1456
+ }
1457
+ if (remaining > 0) partialText = ws.slice(0, remaining).join(" ");
1458
+ break;
1296
1459
  }
1297
- if (node && typeof node === "object") {
1298
- for (const [k, v] of Object.entries(node)) {
1299
- if (SKIP.has(k)) continue;
1300
- walk(v, prefix ? `${prefix}.${k}` : k, k);
1460
+ } else {
1461
+ let remaining = Math.round(progress * totalGraphemes);
1462
+ for (const clusters of lineClusters) {
1463
+ if (remaining >= clusters.length) {
1464
+ remaining -= clusters.length;
1465
+ fullLines++;
1466
+ continue;
1301
1467
  }
1302
- return;
1468
+ if (remaining > 0) partialText = clusters.slice(0, remaining).join("");
1469
+ break;
1303
1470
  }
1304
- if (prefix) out.push(prefix);
1305
- };
1306
- walk(spec, "", "");
1307
- return out;
1308
- }
1309
- function easeSmooth(k) {
1310
- const c = Math.max(0, Math.min(1, k));
1311
- return c * c * (3 - 2 * c);
1312
- }
1313
- function structuralSignature(spec) {
1314
- return JSON.stringify([
1315
- spec.units,
1316
- spec.layers.map((l) => {
1317
- const s = l.sprite;
1318
- return [
1319
- l.count,
1320
- l.size,
1321
- l.region,
1322
- l.position,
1323
- l.motion,
1324
- l.wrap,
1325
- l.flip,
1326
- l.alpha,
1327
- l.pulse,
1328
- l.spin,
1329
- l.grow,
1330
- l.layout,
1331
- l.sprite.kind,
1332
- // Dimensional draws baked into entities: radius (circle/ring), length
1333
- // (streak), width+aspect (rect), palette pick (all shaped sprites).
1334
- s.radius,
1335
- s.length,
1336
- l.sprite.kind === "rect" ? [s.width, s.aspect] : void 0,
1337
- Array.isArray(s.colors) ? s.colors.length : void 0,
1338
- s.colorWeights,
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
1340
- ];
1341
- })
1342
- ]);
1471
+ }
1472
+ let caretLine;
1473
+ let caretPrefix;
1474
+ if (partialText.length > 0) {
1475
+ caretLine = fullLines;
1476
+ caretPrefix = partialText;
1477
+ } else if (fullLines > 0) {
1478
+ caretLine = fullLines - 1;
1479
+ caretPrefix = lines[fullLines - 1].text;
1480
+ } else {
1481
+ caretLine = 0;
1482
+ caretPrefix = "";
1483
+ }
1484
+ return { progress, fullLines, partialText, caretLine, caretPrefix };
1343
1485
  }
1344
1486
 
1487
+ // src/compile.ts
1488
+ import {
1489
+ createRng
1490
+ } from "@idle-screens/core";
1491
+
1345
1492
  // src/sequence.ts
1346
1493
  function resolveSegment(seq, T) {
1347
1494
  const { segments, loop } = seq;
@@ -1675,6 +1822,9 @@ var SpecInstance = class {
1675
1822
  const maxWEm = maxWPx / fsPx;
1676
1823
  const lines = breakTextBlock(sprite.text, maxWEm);
1677
1824
  const align = sprite.align ?? "left";
1825
+ const reveal = sprite.reveal;
1826
+ const rs = reveal ? revealState(lines, reveal, t) : null;
1827
+ const fullLines = rs ? rs.fullLines : lines.length;
1678
1828
  ctx.save();
1679
1829
  ctx.translate(p.x, p.y);
1680
1830
  if (rot) ctx.rotate(rot);
@@ -1683,9 +1833,23 @@ var SpecInstance = class {
1683
1833
  ctx.textBaseline = "top";
1684
1834
  ctx.textAlign = align;
1685
1835
  const xOff = align === "center" ? maxWPx / 2 : align === "right" ? maxWPx : 0;
1686
- for (let li = 0; li < lines.length; li++) {
1836
+ for (let li = 0; li < fullLines; li++) {
1687
1837
  ctx.fillText(lines[li].text, xOff, li * lh);
1688
1838
  }
1839
+ if (rs && rs.partialText.length > 0) {
1840
+ ctx.fillText(rs.partialText, xOff, fullLines * lh);
1841
+ }
1842
+ if (rs && reveal.caret) {
1843
+ const cfg = reveal.caret === true ? {} : reveal.caret;
1844
+ const hz = Math.min(3, cfg.blink ?? 1.2);
1845
+ const on = hz <= 0 || Math.floor(t / 1e3 * hz * 2) % 2 === 0;
1846
+ if (on) {
1847
+ const pw = ctx.measureText(rs.caretPrefix).width;
1848
+ const cx = align === "center" ? xOff + pw / 2 : align === "right" ? xOff : pw;
1849
+ ctx.fillStyle = cfg.color ?? sprite.color ?? "#e6e8ef";
1850
+ ctx.fillRect(cx + fsPx * 0.06, rs.caretLine * lh, Math.max(1, fsPx * 0.08), fsPx);
1851
+ }
1852
+ }
1689
1853
  ctx.restore();
1690
1854
  return;
1691
1855
  }
@@ -1856,6 +2020,26 @@ var SpecInstance = class {
1856
2020
  this.rebuild();
1857
2021
  if (this.paused) this.renderFrame(0, this.seed);
1858
2022
  }
2023
+ /**
2024
+ * Replace the rendered spec without triggering a transition glide.
2025
+ * Checks structural signature — use `hotSwapPaint` when the caller
2026
+ * already knows the signature is unchanged (e.g. mid-morph lerp).
2027
+ */
2028
+ hotSwapSpec(spec) {
2029
+ this.effSpec = spec;
2030
+ const sig = structuralSignature(this.effSpec);
2031
+ if (sig !== this.lastStructural) this.rebuild();
2032
+ else this.layers.forEach((b, i) => {
2033
+ b.layer = this.effSpec.layers[i] ?? b.layer;
2034
+ });
2035
+ }
2036
+ /** Paint-only hot-swap: skips structuralSignature (caller guarantees match). */
2037
+ hotSwapPaint(spec) {
2038
+ this.effSpec = spec;
2039
+ this.layers.forEach((b, i) => {
2040
+ b.layer = this.effSpec.layers[i] ?? b.layer;
2041
+ });
2042
+ }
1859
2043
  dispose() {
1860
2044
  this.stop();
1861
2045
  if (this.ownsCanvas && typeof HTMLCanvasElement !== "undefined" && this.canvas instanceof HTMLCanvasElement) this.canvas.remove();
@@ -1876,6 +2060,17 @@ var SequenceInstance = class {
1876
2060
  children;
1877
2061
  activeIndex = -1;
1878
2062
  paused = false;
2063
+ /**
2064
+ * When a morph is active, the child at `morphFromIndex` renders with a
2065
+ * lerped spec. The child stays keyed to the *outgoing* segment's slot so
2066
+ * its seed (and entity placement) is continuous.
2067
+ */
2068
+ morphFromIndex = -1;
2069
+ seed;
2070
+ frameId = null;
2071
+ startT = 0;
2072
+ baseT = 0;
2073
+ lastT = 0;
1879
2074
  constructor(seq, ctx) {
1880
2075
  this.seq = seq;
1881
2076
  let surface = ctx.surface ?? null;
@@ -1888,8 +2083,15 @@ var SequenceInstance = class {
1888
2083
  surface = canvas;
1889
2084
  }
1890
2085
  this.canvas = canvas;
2086
+ this.seed = (seq.seed ?? ctx.seed ?? 0) >>> 0 || 1;
1891
2087
  this.childCtx = { ...ctx, surface, reducedMotion: true };
1892
2088
  this.children = new Array(seq.segments.length).fill(null);
2089
+ this.paused = ctx.reducedMotion;
2090
+ if (this.paused) this.renderFrame(0, this.seed);
2091
+ else this.start();
2092
+ }
2093
+ childSeed(index, fallback) {
2094
+ return this.seq.segments[index].scene.seed ?? this.seq.seed ?? fallback;
1893
2095
  }
1894
2096
  childScene(index) {
1895
2097
  const seg = this.seq.segments[index];
@@ -1897,13 +2099,31 @@ var SequenceInstance = class {
1897
2099
  if (this.seq.seed != null) return { ...seg.scene, seed: this.seq.seed + index };
1898
2100
  return seg.scene;
1899
2101
  }
2102
+ /** Whether the boundary from `from` to `from+1` should morph. */
2103
+ canMorph(from) {
2104
+ const seg = this.seq.segments[from];
2105
+ if (!seg || seg.transition?.type !== "morph") return false;
2106
+ const next = this.seq.segments[from + 1];
2107
+ if (!next) return false;
2108
+ return structuralSignature(seg.scene) === structuralSignature(next.scene);
2109
+ }
2110
+ /** Walk back through consecutive morph boundaries to find the chain origin. */
2111
+ morphChainRoot(index) {
2112
+ let i = index;
2113
+ while (i > 0 && this.canMorph(i - 1)) i--;
2114
+ return i;
2115
+ }
2116
+ morphDur(from) {
2117
+ const tr = this.seq.segments[from]?.transition;
2118
+ return tr?.type === "morph" ? tr.dur : 0;
2119
+ }
1900
2120
  ensureChild(index) {
1901
2121
  if (index < 0 || index >= this.seq.segments.length) index = 0;
1902
2122
  let child = this.children[index];
1903
2123
  if (!child) {
1904
2124
  child = new SpecInstance(this.childScene(index), this.childCtx);
1905
2125
  this.children[index] = child;
1906
- if (this.paused) child.setPaused(true);
2126
+ child.setPaused(true);
1907
2127
  }
1908
2128
  return child;
1909
2129
  }
@@ -1914,23 +2134,79 @@ var SequenceInstance = class {
1914
2134
  this.children[index] = null;
1915
2135
  }
1916
2136
  }
2137
+ start() {
2138
+ if (this.frameId !== null || typeof requestAnimationFrame === "undefined") return;
2139
+ this.startT = 0;
2140
+ this.frameId = requestAnimationFrame((now) => this.loop(now));
2141
+ }
2142
+ stop() {
2143
+ if (this.frameId !== null) {
2144
+ cancelAnimationFrame(this.frameId);
2145
+ this.frameId = null;
2146
+ }
2147
+ this.baseT = this.lastT;
2148
+ }
2149
+ loop(now) {
2150
+ this.frameId = requestAnimationFrame((n) => this.loop(n));
2151
+ if (this.startT === 0) this.startT = now;
2152
+ this.lastT = now - this.startT + this.baseT;
2153
+ this.renderFrame(this.lastT, this.seed);
2154
+ }
1917
2155
  renderFrame(T, seed) {
1918
2156
  const resolved = resolveSegment(this.seq, T);
1919
2157
  const { index, localT } = resolved;
1920
- if (index !== this.activeIndex) {
2158
+ const prevIdx = index > 0 ? index - 1 : -1;
2159
+ const morphActive = prevIdx >= 0 && this.canMorph(prevIdx) && localT < this.morphDur(prevIdx);
2160
+ if (morphActive) {
2161
+ const chainRoot = this.morphChainRoot(index);
2162
+ this.morphFromIndex = prevIdx;
2163
+ this.activeIndex = index;
1921
2164
  for (let i = 0; i < this.children.length; i++) {
1922
- if (i !== index) this.releaseChild(i);
2165
+ if (i !== chainRoot) this.releaseChild(i);
1923
2166
  }
1924
- this.activeIndex = index;
2167
+ const child = this.ensureChild(chainRoot);
2168
+ const dur = this.morphDur(prevIdx);
2169
+ const k = easeSmooth(localT / dur);
2170
+ const specA = this.childScene(prevIdx);
2171
+ const specB = this.childScene(index);
2172
+ child.hotSwapPaint(lerpSpec(specA, specB, k));
2173
+ child.renderFrame(localT, this.childSeed(chainRoot, seed));
2174
+ } else {
2175
+ if (this.morphFromIndex >= 0) {
2176
+ const oldRoot = this.morphChainRoot(this.morphFromIndex);
2177
+ this.releaseChild(oldRoot);
2178
+ if (oldRoot !== this.morphFromIndex) this.releaseChild(this.morphFromIndex);
2179
+ this.morphFromIndex = -1;
2180
+ }
2181
+ if (index !== this.activeIndex) {
2182
+ for (let i = 0; i < this.children.length; i++) {
2183
+ if (i !== index) this.releaseChild(i);
2184
+ }
2185
+ this.activeIndex = index;
2186
+ }
2187
+ const chainRoot = this.morphChainRoot(index);
2188
+ const useMorphSeed = chainRoot < index;
2189
+ const effSeed = useMorphSeed ? this.childSeed(chainRoot, seed) : this.childSeed(index, seed);
2190
+ let child;
2191
+ if (useMorphSeed && !this.children[index]) {
2192
+ const rootScene = this.childScene(chainRoot);
2193
+ child = new SpecInstance(rootScene, this.childCtx);
2194
+ this.children[index] = child;
2195
+ child.setPaused(true);
2196
+ child.hotSwapSpec(this.childScene(index));
2197
+ } else {
2198
+ child = this.ensureChild(index);
2199
+ if (useMorphSeed) child.hotSwapSpec(this.childScene(index));
2200
+ }
2201
+ child.renderFrame(localT, effSeed);
1925
2202
  }
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
2203
  }
1930
2204
  setPaused(paused) {
1931
2205
  this.paused = paused;
2206
+ if (paused) this.stop();
2207
+ else this.start();
1932
2208
  for (const child of this.children) {
1933
- child?.setPaused(paused);
2209
+ child?.setPaused(true);
1934
2210
  }
1935
2211
  }
1936
2212
  resize(width, height, dpr) {
@@ -1959,6 +2235,7 @@ var SequenceInstance = class {
1959
2235
  }
1960
2236
  }
1961
2237
  dispose() {
2238
+ this.stop();
1962
2239
  for (let i = 0; i < this.children.length; i++) {
1963
2240
  this.releaseChild(i);
1964
2241
  }
@@ -2231,6 +2508,40 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2231
2508
  }
2232
2509
  return warnings;
2233
2510
  }
2511
+ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
2512
+ const warnings = [];
2513
+ for (let i = 0; i < seq.segments.length; i++) {
2514
+ const segWarnings = adviseSpec(seq.segments[i].scene, viewport);
2515
+ for (const w of segWarnings) {
2516
+ warnings.push({ path: `segments[${i}].scene.${w.path}`, code: w.code, message: w.message });
2517
+ }
2518
+ }
2519
+ for (let i = 0; i < seq.segments.length - 1; i++) {
2520
+ const lumaA = backgroundLuma(seq.segments[i].scene);
2521
+ const lumaB = backgroundLuma(seq.segments[i + 1].scene);
2522
+ const delta = Math.abs(lumaA - lumaB);
2523
+ if (delta > 0.5) {
2524
+ warnings.push({
2525
+ path: `segments[${i}]`,
2526
+ code: "boundary-luminance-jump",
2527
+ message: `background luminance jumps ${delta.toFixed(2)} at boundary ${i}\u2192${i + 1} \u2014 may flash on cut`
2528
+ });
2529
+ }
2530
+ const tr = seq.segments[i].transition;
2531
+ if (tr?.type === "morph") {
2532
+ const sigA = structuralSignature(seq.segments[i].scene);
2533
+ const sigB = structuralSignature(seq.segments[i + 1].scene);
2534
+ if (sigA !== sigB) {
2535
+ warnings.push({
2536
+ path: `segments[${i}].transition`,
2537
+ code: "morph-structural-mismatch",
2538
+ message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
2539
+ });
2540
+ }
2541
+ }
2542
+ }
2543
+ return warnings;
2544
+ }
2234
2545
 
2235
2546
  // src/describe.ts
2236
2547
  import { createRng as createRng3 } from "@idle-screens/core";
@@ -2390,6 +2701,13 @@ function textBlockBox(s, _e, p, w, h) {
2390
2701
  halfY: totalH / 2
2391
2702
  };
2392
2703
  }
2704
+ function textBlockRevealFraction(s, w, h, t) {
2705
+ if (!s.reveal) return 1;
2706
+ const unitScale = Math.min(w, h);
2707
+ const maxWEm = s.maxWidth * unitScale / (s.fontSize * unitScale);
2708
+ const lines = breakTextBlock(s.text, maxWEm);
2709
+ return revealState(lines, s.reveal, t).progress;
2710
+ }
2393
2711
  function luminanceGrid(spec, opts = {}) {
2394
2712
  const scene = buildScene(spec, opts);
2395
2713
  const t = opts.t ?? 5e3;
@@ -2528,7 +2846,7 @@ function luminanceGrid(spec, opts = {}) {
2528
2846
  const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
2529
2847
  const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
2530
2848
  const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
2531
- const inkWeight = s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1;
2849
+ const inkWeight = (s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1) * (s.kind === "textBlock" ? textBlockRevealFraction(s, w, h, t) : 1);
2532
2850
  for (let r = r0; r <= r1; r++) {
2533
2851
  for (let c = c0; c <= c1; c++) {
2534
2852
  const dx = (c + 0.5) * cellW - centerX;
@@ -2691,7 +3009,9 @@ function textSprites(spec, opts = {}) {
2691
3009
  const s2 = layer.sprite;
2692
3010
  const unitScale = Math.min(scene.w, scene.h);
2693
3011
  const sizePx2 = s2.fontSize * unitScale;
2694
- out.push({ layerIndex, key: layer.key, strings: [s2.text], count: entities.length, sizePx: Math.round(sizePx2) });
3012
+ const info = { layerIndex, key: layer.key, strings: [s2.text], count: entities.length, sizePx: Math.round(sizePx2) };
3013
+ if (s2.reveal) info.revealed = Math.round(textBlockRevealFraction(s2, scene.w, scene.h, t) * 100) / 100;
3014
+ out.push(info);
2695
3015
  return;
2696
3016
  }
2697
3017
  if (layer.sprite.kind !== "text") return;
@@ -2746,7 +3066,7 @@ function dominanceRanking(spec, opts = {}) {
2746
3066
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
2747
3067
  } else if (s.kind === "textBlock") {
2748
3068
  const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
2749
- entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
3069
+ entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t);
2750
3070
  } else entArea = sz * sz * 0.55;
2751
3071
  area += entArea * a;
2752
3072
  if (layer.trail) {
@@ -4351,6 +4671,7 @@ export {
4351
4671
  SNOWFALL_SPEC,
4352
4672
  WARP_NEAR,
4353
4673
  WARP_TUNNEL_SPEC,
4674
+ adviseSequence,
4354
4675
  adviseSpec,
4355
4676
  alphaAt,
4356
4677
  applyDeltasToSpec,
@@ -4364,6 +4685,7 @@ export {
4364
4685
  diffScenes,
4365
4686
  dominanceRanking,
4366
4687
  easeSmooth,
4688
+ graphemeClusters,
4367
4689
  headingAt,
4368
4690
  lerpSpec,
4369
4691
  lifeAlphaAt,
@@ -4378,6 +4700,7 @@ export {
4378
4700
  renderDensityMap,
4379
4701
  resolveSegment,
4380
4702
  resolveSpecPath,
4703
+ revealState,
4381
4704
  rotationAt,
4382
4705
  sizeAt,
4383
4706
  spriteIndexAt,