@idle-screens/schema 3.3.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/FORMAT.md CHANGED
@@ -31,7 +31,8 @@ Additions to version 1 so far, all optional and backward compatible:
31
31
  sprites, `colorWeights`, `pulse.wave`, `layout` (grid), `life`,
32
32
  `links.mode/falloff/closed`, `blend: screen|multiply`, orbit layer-parents
33
33
  (2026-07-21 — "the v1 ceiling");
34
- `textBlock` sprite — deterministic multi-line text with viewport-unit sizing.
34
+ `textBlock` sprite — deterministic multi-line text with viewport-unit sizing;
35
+ `textBlock.reveal` — animated typing/deleting via one steerable paint param.
35
36
 
36
37
  ## Safety invariants
37
38
 
@@ -169,6 +170,25 @@ with distance, removing pop-in at the cutoff.
169
170
  layout target, not a hard clip.
170
171
  Use with `count: 1`, `motion: { type: "static" }`, and `position`.
171
172
 
173
+ textBlock also accepts `reveal` — animated typing/deleting:
174
+ `{ "reveal": { "progress": 1, "mode": "typewriter", "speed": 0, "caret": true } }`.
175
+ Layout always runs on the **full** text; reveal only masks which glyphs are
176
+ painted, so lines never reflow and the block never resizes while typing.
177
+ `progress` (0–1, default 1) is the frontier — it is a numeric **paint**
178
+ param, so steering it with `dur`/`ease` animates typing in a single
179
+ `setParam` call, and gliding it back toward 0 deletes. `mode` quantizes the
180
+ frontier per grapheme (`typewriter`, default), per `word`, or per `line`.
181
+ `speed` (graphemes/sec, max 120) makes the block type itself from scene
182
+ start: effective progress is `min(progress, speed·t/total)`, so a steered
183
+ `progress` can still hold or delete a self-typing block. `caret` draws a
184
+ blinking caret at the frontier (`true`, or `{ "blink": 1.2, "color": "#fff" }`;
185
+ blink is in full cycles/sec, capped at 3 for flash safety, and is a square
186
+ wave of `t` like every other animation). With `align: "center"`/`"right"`
187
+ the revealing line stays anchored to its alignment as it types — only
188
+ left-aligned text reads as a classic typewriter. To "edit" text live:
189
+ glide `reveal.progress` to 0, swap `text` while nothing is visible, glide
190
+ back to 1.
191
+
172
192
  `circle`, `ring`, `streak`, and `rect` all accept `colors: [...]` (seeded
173
193
  per-entity palette pick) and `colorWeights: [...]` (relative weights, same
174
194
  length — "mostly cool tones, occasional ember").
package/dist/index.d.ts CHANGED
@@ -274,7 +274,42 @@ type SpriteSpec = {
274
274
  lineHeight?: number;
275
275
  align?: 'left' | 'center' | 'right';
276
276
  color?: string;
277
+ reveal?: TextRevealSpec;
277
278
  };
279
+ /**
280
+ * Animated typing/deleting for `textBlock`. Layout always runs on the FULL
281
+ * text — reveal only masks which glyphs are painted, so lines never reflow
282
+ * and the block's box never changes size. `progress` is a numeric paint
283
+ * param (excluded from the structural signature), so steering it with
284
+ * `dur`/`ease` animates typing client-side in one call; gliding it back to 0
285
+ * deletes. With center/right `align`, the revealing line stays anchored to
286
+ * its alignment as it types (only left-aligned text reads as a classic
287
+ * typewriter).
288
+ */
289
+ interface TextRevealSpec {
290
+ /**
291
+ * 0..1 fraction of the text revealed (default 1 = fully shown). Quantized
292
+ * by `mode`. Steerable paint — glide it to animate typing or deleting.
293
+ */
294
+ progress?: number;
295
+ /** Reveal granularity: per-grapheme (default), per-word, or per-line. */
296
+ mode?: 'typewriter' | 'word' | 'line';
297
+ /**
298
+ * Self-typing rate in graphemes/second from scene start (media time). The
299
+ * effective progress is `min(progress, speed·t/total)` — so a steered
300
+ * `progress` can hold or delete even while `speed` is set. 0 = off.
301
+ */
302
+ speed?: number;
303
+ /**
304
+ * Caret at the reveal frontier. `true` for defaults, or configure blink
305
+ * rate (Hz, full cycles/sec, capped at 3 for flash safety) and color.
306
+ * Blink is a square wave of `t` — deterministic like everything else.
307
+ */
308
+ caret?: boolean | {
309
+ blink?: number;
310
+ color?: string;
311
+ };
312
+ }
278
313
  /** Rotate through sprite variants over time. Each entity offsets by its seeded phase. */
279
314
  interface CycleSpec {
280
315
  period: number;
@@ -426,6 +461,8 @@ declare const LIMITS: {
426
461
  readonly minTextBlockFontSize: 0.01;
427
462
  readonly maxTextBlockFontSize: 0.2;
428
463
  readonly maxTextBlockMaxWidth: 1;
464
+ readonly maxRevealSpeed: 120;
465
+ readonly maxCaretBlinkHz: 3;
429
466
  readonly maxSegments: 24;
430
467
  readonly minSegmentDuration: 1000;
431
468
  readonly maxTransitionDur: 5000;
@@ -617,6 +654,36 @@ interface TextBlockLine {
617
654
  * Explicit `\n` always forces a break. Pure, deterministic, no canvas needed.
618
655
  */
619
656
  declare function breakTextBlock(text: string, maxWidthEm: number): TextBlockLine[];
657
+ /**
658
+ * Split a string into paint-safe grapheme clusters: surrogate pairs stay
659
+ * whole, combining marks / variation selectors / skin tones attach to their
660
+ * base, ZWJ joins sequences (emoji families), regional indicators pair up
661
+ * (flags). Deterministic across engines by construction.
662
+ */
663
+ declare function graphemeClusters(text: string): string[];
664
+ interface TextRevealInput {
665
+ progress?: number;
666
+ mode?: 'typewriter' | 'word' | 'line';
667
+ speed?: number;
668
+ }
669
+ interface RevealState {
670
+ /** Effective 0..1 progress after combining authored progress and speed at t. */
671
+ progress: number;
672
+ /** Number of fully visible lines. */
673
+ fullLines: number;
674
+ /** Grapheme-safe visible prefix of the next line ('' when none). */
675
+ partialText: string;
676
+ /** Caret anchor: line index and the visible prefix on that line. */
677
+ caretLine: number;
678
+ caretPrefix: string;
679
+ }
680
+ /**
681
+ * Resolve which glyphs of a broken textBlock are visible at time `tMs`.
682
+ * Pure function of (lines, reveal, tMs) — the renderer paints exactly this,
683
+ * and perceive models exactly this, so agents and pixels agree. `speed` is
684
+ * graphemes/sec regardless of mode; mode only quantizes the frontier.
685
+ */
686
+ declare function revealState(lines: TextBlockLine[], reveal: TextRevealInput, tMs: number): RevealState;
620
687
 
621
688
  /** Derive a manifest so a compiled spec composes with @idle-screens/capabilities. */
622
689
  declare function manifestFor(spec: SaverSpec): SaverManifest;
@@ -806,6 +873,8 @@ interface TextSpriteInfo {
806
873
  count: number;
807
874
  /** Representative rendered glyph height in px at the sample viewport. */
808
875
  sizePx: number;
876
+ /** For a revealing textBlock: effective 0..1 fraction shown at sample t. */
877
+ revealed?: number;
809
878
  }
810
879
  /**
811
880
  * List every text layer's actual strings and rendered size. No grid resolution
@@ -1086,4 +1155,4 @@ declare const EXAMPLE_SPECS: SaverSpec[];
1086
1155
  /** Lookup by saver id. */
1087
1156
  declare const EXAMPLE_BY_ID: Readonly<Record<string, SaverSpec>>;
1088
1157
 
1089
- export { AQUARIUM_SPEC, AURORA_SPEC, type BackgroundDrift, type BackgroundSpec, type BandSpec, COMETS_SPEC, CONSTELLATION_SPEC, type CycleSpec, DASHBOARD_SPEC, type DominanceEntry, EXAMPLE_BY_ID, EXAMPLE_SPECS, type Entity, type GradientStop, HAIKU_SPEC, type IdleSequence, LANTERNS_SPEC, LIMITS, type LayerMotionStats, type LayerSnapshot, type LayerSpec, type LinkEdge, type LuminanceGrid, type LuminanceGridOptions, MATRIX_RAIN_SPEC, type MotionSpec, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, type PerceiveOptions, type Placed, RAIN_SPEC, type ResolvedSegment, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SNOWFALL_SPEC, type SaverSpec, type SceneDescription, type SceneDiff, type ScenePerception, type SceneSnapshot, type SchemaExample, type SequenceSegment, type SequenceTransition, type SpecError, type SpecWarning, type SpriteSpec, type SteerDelta, type TextBlockLine, type TextSpriteInfo, type ValidationResult, WARP_NEAR, WARP_TUNNEL_SPEC, type WanderOsc, adviseSequence, adviseSpec, alphaAt, applyDeltasToSpec, assertValidSequence, assertValidSpec, breakTextBlock, buildEntities, compileSaver, compileSequence, describeScene, diffScenes, dominanceRanking, easeSmooth, headingAt, lerpSpec, lifeAlphaAt, linkEdges, linkPairs, luminanceGrid, manifestFor, motionStats, perceiveScene, positionAt, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, rotationAt, sizeAt, spriteIndexAt, spriteVariants, steerablePaths, structuralSignature, textSprites, validateSequence, validateSpec };
1158
+ export { AQUARIUM_SPEC, AURORA_SPEC, type BackgroundDrift, type BackgroundSpec, type BandSpec, COMETS_SPEC, CONSTELLATION_SPEC, type CycleSpec, DASHBOARD_SPEC, type DominanceEntry, EXAMPLE_BY_ID, EXAMPLE_SPECS, type Entity, type GradientStop, HAIKU_SPEC, type IdleSequence, LANTERNS_SPEC, LIMITS, type LayerMotionStats, type LayerSnapshot, type LayerSpec, type LinkEdge, type LuminanceGrid, type LuminanceGridOptions, MATRIX_RAIN_SPEC, type MotionSpec, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, type PerceiveOptions, type Placed, RAIN_SPEC, type ResolvedSegment, type RevealState, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SNOWFALL_SPEC, type SaverSpec, type SceneDescription, type SceneDiff, type ScenePerception, type SceneSnapshot, type SchemaExample, type SequenceSegment, type SequenceTransition, type SpecError, type SpecWarning, type SpriteSpec, type SteerDelta, type TextBlockLine, type TextRevealInput, type TextRevealSpec, type TextSpriteInfo, type ValidationResult, WARP_NEAR, WARP_TUNNEL_SPEC, type WanderOsc, adviseSequence, adviseSpec, alphaAt, applyDeltasToSpec, assertValidSequence, assertValidSpec, breakTextBlock, buildEntities, compileSaver, compileSequence, describeScene, diffScenes, dominanceRanking, easeSmooth, graphemeClusters, headingAt, lerpSpec, lifeAlphaAt, linkEdges, linkPairs, luminanceGrid, manifestFor, motionStats, perceiveScene, positionAt, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, revealState, rotationAt, sizeAt, spriteIndexAt, spriteVariants, steerablePaths, structuralSignature, textSprites, validateSequence, validateSpec };
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
@@ -216,7 +220,9 @@ var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width",
216
220
  var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "colors", "colorWeights"]);
217
221
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
218
222
  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"]);
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"]);
220
226
  var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob"]);
221
227
  var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway"]);
222
228
  var KNOWN_BOUNCE = /* @__PURE__ */ new Set(["type", "speed"]);
@@ -574,6 +580,39 @@ function validateSprite(sprite, path, err, warn) {
574
580
  err(`${path}.align`, "must be 'left' | 'center' | 'right'");
575
581
  }
576
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
+ }
577
616
  } else {
578
617
  err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect | textBlock");
579
618
  return;
@@ -1357,6 +1396,93 @@ function breakTextBlock(text, maxWidthEm) {
1357
1396
  }
1358
1397
  return lines;
1359
1398
  }
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;
1404
+ }
1405
+ function isRegionalIndicator(cp) {
1406
+ return cp >= 127462 && cp <= 127487;
1407
+ }
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;
1428
+ }
1429
+ }
1430
+ out.push(text.slice(i, end));
1431
+ i = end;
1432
+ }
1433
+ return out;
1434
+ }
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;
1459
+ }
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;
1467
+ }
1468
+ if (remaining > 0) partialText = clusters.slice(0, remaining).join("");
1469
+ break;
1470
+ }
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 };
1485
+ }
1360
1486
 
1361
1487
  // src/compile.ts
1362
1488
  import {
@@ -1696,6 +1822,9 @@ var SpecInstance = class {
1696
1822
  const maxWEm = maxWPx / fsPx;
1697
1823
  const lines = breakTextBlock(sprite.text, maxWEm);
1698
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;
1699
1828
  ctx.save();
1700
1829
  ctx.translate(p.x, p.y);
1701
1830
  if (rot) ctx.rotate(rot);
@@ -1704,9 +1833,23 @@ var SpecInstance = class {
1704
1833
  ctx.textBaseline = "top";
1705
1834
  ctx.textAlign = align;
1706
1835
  const xOff = align === "center" ? maxWPx / 2 : align === "right" ? maxWPx : 0;
1707
- for (let li = 0; li < lines.length; li++) {
1836
+ for (let li = 0; li < fullLines; li++) {
1708
1837
  ctx.fillText(lines[li].text, xOff, li * lh);
1709
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
+ }
1710
1853
  ctx.restore();
1711
1854
  return;
1712
1855
  }
@@ -1923,6 +2066,11 @@ var SequenceInstance = class {
1923
2066
  * its seed (and entity placement) is continuous.
1924
2067
  */
1925
2068
  morphFromIndex = -1;
2069
+ seed;
2070
+ frameId = null;
2071
+ startT = 0;
2072
+ baseT = 0;
2073
+ lastT = 0;
1926
2074
  constructor(seq, ctx) {
1927
2075
  this.seq = seq;
1928
2076
  let surface = ctx.surface ?? null;
@@ -1935,8 +2083,12 @@ var SequenceInstance = class {
1935
2083
  surface = canvas;
1936
2084
  }
1937
2085
  this.canvas = canvas;
2086
+ this.seed = (seq.seed ?? ctx.seed ?? 0) >>> 0 || 1;
1938
2087
  this.childCtx = { ...ctx, surface, reducedMotion: true };
1939
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();
1940
2092
  }
1941
2093
  childSeed(index, fallback) {
1942
2094
  return this.seq.segments[index].scene.seed ?? this.seq.seed ?? fallback;
@@ -1971,7 +2123,7 @@ var SequenceInstance = class {
1971
2123
  if (!child) {
1972
2124
  child = new SpecInstance(this.childScene(index), this.childCtx);
1973
2125
  this.children[index] = child;
1974
- if (this.paused) child.setPaused(true);
2126
+ child.setPaused(true);
1975
2127
  }
1976
2128
  return child;
1977
2129
  }
@@ -1982,6 +2134,24 @@ var SequenceInstance = class {
1982
2134
  this.children[index] = null;
1983
2135
  }
1984
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
+ }
1985
2155
  renderFrame(T, seed) {
1986
2156
  const resolved = resolveSegment(this.seq, T);
1987
2157
  const { index, localT } = resolved;
@@ -2022,7 +2192,7 @@ var SequenceInstance = class {
2022
2192
  const rootScene = this.childScene(chainRoot);
2023
2193
  child = new SpecInstance(rootScene, this.childCtx);
2024
2194
  this.children[index] = child;
2025
- if (this.paused) child.setPaused(true);
2195
+ child.setPaused(true);
2026
2196
  child.hotSwapSpec(this.childScene(index));
2027
2197
  } else {
2028
2198
  child = this.ensureChild(index);
@@ -2033,8 +2203,10 @@ var SequenceInstance = class {
2033
2203
  }
2034
2204
  setPaused(paused) {
2035
2205
  this.paused = paused;
2206
+ if (paused) this.stop();
2207
+ else this.start();
2036
2208
  for (const child of this.children) {
2037
- child?.setPaused(paused);
2209
+ child?.setPaused(true);
2038
2210
  }
2039
2211
  }
2040
2212
  resize(width, height, dpr) {
@@ -2063,6 +2235,7 @@ var SequenceInstance = class {
2063
2235
  }
2064
2236
  }
2065
2237
  dispose() {
2238
+ this.stop();
2066
2239
  for (let i = 0; i < this.children.length; i++) {
2067
2240
  this.releaseChild(i);
2068
2241
  }
@@ -2528,6 +2701,13 @@ function textBlockBox(s, _e, p, w, h) {
2528
2701
  halfY: totalH / 2
2529
2702
  };
2530
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
+ }
2531
2711
  function luminanceGrid(spec, opts = {}) {
2532
2712
  const scene = buildScene(spec, opts);
2533
2713
  const t = opts.t ?? 5e3;
@@ -2666,7 +2846,7 @@ function luminanceGrid(spec, opts = {}) {
2666
2846
  const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
2667
2847
  const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
2668
2848
  const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
2669
- 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);
2670
2850
  for (let r = r0; r <= r1; r++) {
2671
2851
  for (let c = c0; c <= c1; c++) {
2672
2852
  const dx = (c + 0.5) * cellW - centerX;
@@ -2829,7 +3009,9 @@ function textSprites(spec, opts = {}) {
2829
3009
  const s2 = layer.sprite;
2830
3010
  const unitScale = Math.min(scene.w, scene.h);
2831
3011
  const sizePx2 = s2.fontSize * unitScale;
2832
- 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);
2833
3015
  return;
2834
3016
  }
2835
3017
  if (layer.sprite.kind !== "text") return;
@@ -2884,7 +3066,7 @@ function dominanceRanking(spec, opts = {}) {
2884
3066
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
2885
3067
  } else if (s.kind === "textBlock") {
2886
3068
  const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
2887
- entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
3069
+ entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t);
2888
3070
  } else entArea = sz * sz * 0.55;
2889
3071
  area += entArea * a;
2890
3072
  if (layer.trail) {
@@ -4503,6 +4685,7 @@ export {
4503
4685
  diffScenes,
4504
4686
  dominanceRanking,
4505
4687
  easeSmooth,
4688
+ graphemeClusters,
4506
4689
  headingAt,
4507
4690
  lerpSpec,
4508
4691
  lifeAlphaAt,
@@ -4517,6 +4700,7 @@ export {
4517
4700
  renderDensityMap,
4518
4701
  resolveSegment,
4519
4702
  resolveSpecPath,
4703
+ revealState,
4520
4704
  rotationAt,
4521
4705
  sizeAt,
4522
4706
  spriteIndexAt,