@idle-screens/schema 3.4.5 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/FORMAT.md CHANGED
@@ -127,7 +127,7 @@ trails, long-exposure light). 0.85–0.95 is the useful range; 0 (default) is of
127
127
  | `pulse` | `{amp ≤ 0.5, period ≥ 500, wave?}` | none | opacity breathing; `wave: {wavelength, angle?}` turns it into a traveling wave across the field |
128
128
  | `spin` | number \| `[min,max]` ±360 deg/sec | none | per-entity rotation (seeded start angle); a range gives each entity a seeded speed (confetti, tumbling debris) |
129
129
  | `grow` | `{amp ≤ 0.8, period ≥ 500}` | none | size breathing (seeded phase) |
130
- | `trail` | `{length ≤ 5000, fade?}` | none | analytic afterglow trail (ms of history) |
130
+ | `trail` | `{length ≤ 5000, fade?}` | none | analytic afterglow trail; `length` is **milliseconds** of history (max 5000), `fade` a number 0..1 (not a boolean — `links.falloff` in the next row is the boolean) |
131
131
  | `links` | see below | none | inter-entity lines |
132
132
  | `layout` | `{type: "grid", columns?, jitter?}` | scatter | grid placement; `jitter` scalar or `{x?, y?}` 0..1 per axis |
133
133
  | `life` | `{enter?, exit?, fade?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit` |
@@ -180,7 +180,14 @@ with distance, removing pop-in at the cutoff.
180
180
  frontier per grapheme (`typewriter`, default), per `word`, or per `line`.
181
181
  `speed` (graphemes/sec, max 120) makes the block type itself from scene
182
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
183
+ `progress` can still hold or delete a self-typing block. A fourth mode,
184
+ `glyphFade`, fades each glyph in over a staggered alpha ramp instead of
185
+ popping it whole (the caption look); `fade` (0–1 exclusive of 0, default
186
+ 0.15, `glyphFade` only) is each glyph's fade window as a fraction of
187
+ progress — small reads as a soft typewriter, large as a wave of
188
+ overlapping fades. Still a pure function of `progress`, so it glides and
189
+ deletes exactly like the other modes. Native clients render `glyphFade`
190
+ as `typewriter` until they gain per-glyph alpha. `caret` draws a
184
191
  blinking caret at the frontier (`true`, or `{ "blink": 1.2, "color": "#fff" }`;
185
192
  blink is in full cycles/sec, capped at 3 for flash safety, and is a square
186
193
  wave of `t` like every other animation). With `align: "center"`/`"right"`
@@ -290,6 +297,14 @@ trade-offs for a zero-dependency, renderer-free analysis tool.
290
297
  text layer. Glyphs are invisible in the luminance maps, so this is the only
291
298
  way to confirm *what words* are on screen and how big. (Also on
292
299
  `perceiveScene().text`.)
300
+ - `adviseSpec(spec)` — non-blocking advisories (also on
301
+ `perceiveScene().advisories`). Two of them are **spatial**, for static text:
302
+ `text-off-screen` (a text/textBlock box crosses the viewport edge by more
303
+ than 1%) and `text-overlap` (two static text layers share more than 10% of
304
+ the smaller box). Boxes come from the same character-class width table the
305
+ textBlock line-breaker uses, so they are estimates of the renderer's own
306
+ layout — a caption that fails these will look wrong on the wall; one that
307
+ passes may still sit a few px off.
293
308
  - `diffScenes(a, b, opts)` — **relative sight**: coverage/luminance deltas,
294
309
  visual-balance shift, 3×3 region deltas, dominance-rank movement, and
295
310
  advisory codes added/removed. Agents judge "is B better than A" far more
@@ -330,8 +345,12 @@ timeline. Discriminated from SaverSpec by `format: 'idle-sequence'`.
330
345
  be unique. Duration is in milliseconds (minimum 1000 ms for flash safety). Only
331
346
  the final segment may omit duration (holds indefinitely).
332
347
 
333
- **Advance mode:** `auto` (default), `input`, or `either`. Validated but not
334
- wired to runtime behavior — timer/input drivers are planned for a follow-up.
348
+ **Advance mode:** `auto` (default) and `either` advance on the timer. `input`
349
+ makes a timed segment **hold** at the end of its `duration` until a
350
+ `sequence.segment` steer releases it — the clicker (see Steering below). The
351
+ held scene keeps animating (its `localT` keeps growing), so a slide waiting for
352
+ the presenter never freezes. A durationless final segment holds regardless of
353
+ `advance`.
335
354
 
336
355
  **Transitions:** `{ type: 'cut' }` (default) performs a hard switch.
337
356
  `{ type: 'morph', dur: number }` smoothly interpolates paint properties
@@ -345,7 +364,8 @@ seed is unused. `dur` must be between 200 and 5000 ms.
345
364
  **Time mapping:** global clock `T` maps to `(segmentIndex, localT)` via prefix
346
365
  sums of durations. Half-open segments: `[start, start+duration)`. With
347
366
  `loop: true`, `T` wraps at the sum of all durations (loop is incompatible with
348
- a durationless final segment).
367
+ a durationless final segment). An unreleased `advance: 'input'` hold blocks the
368
+ wrap; after a wrap every hold is armed again.
349
369
 
350
370
  **Compilation:** `compileSequence()` returns an ordinary `SaverPlugin` — the
351
371
  viewer needs zero changes. All children share a single canvas; only the active
@@ -353,8 +373,20 @@ segment's `SpecInstance` is alive at any time. `workerReady` is `false` (the
353
373
  worker compile-hook does not dispatch sequences).
354
374
 
355
375
  **Steering:** segment switching uses the `sequence.segment` delta path via
356
- `applyTrack`. The `SequenceInstance` intercepts this path before delegation.
357
- Remaining deltas are forwarded to the active child's `applyTrack`.
376
+ `applyTrack` (`setParam("sequence.segment", n)` over MCP). The
377
+ `SequenceInstance` intercepts this path before delegation; remaining deltas are
378
+ forwarded to the active child's `applyTrack`.
379
+
380
+ The steer **moves the clock, not the frame**: it displaces the timeline so the
381
+ target segment starts at its own `localT` 0 (its `life.enter` build replays)
382
+ and then runs on the timer from there — the next animation frame resolves to
383
+ the same segment instead of snapping back to the wall clock. A steer to
384
+ segment `n` also counts as the presenter clicking past every `advance: 'input'`
385
+ hold before `n`; holds at and after `n` stay armed, so steering backwards
386
+ re-arms the ones in between. This is the whole clicker: a deck is a sequence
387
+ whose slides carry `advance: 'input'`, and "next" is one `setParam`. Pinned by
388
+ `sequence.test.ts` → "sequence.segment steering is sticky" and
389
+ "advance: 'input' holds until released".
358
390
 
359
391
  **Seed:** `seq.seed` is forwarded to children that lack a scene-level seed
360
392
  (offset by segment index for independence). Children with their own seed are
package/dist/index.d.ts CHANGED
@@ -292,8 +292,12 @@ interface TextRevealSpec {
292
292
  * by `mode`. Steerable paint — glide it to animate typing or deleting.
293
293
  */
294
294
  progress?: number;
295
- /** Reveal granularity: per-grapheme (default), per-word, or per-line. */
296
- mode?: 'typewriter' | 'word' | 'line';
295
+ /**
296
+ * Reveal granularity: per-grapheme (default), per-word, per-line, or
297
+ * `glyphFade` — each glyph fades in over a staggered alpha ramp instead of
298
+ * appearing whole (the caption look). Still a pure function of `progress`.
299
+ */
300
+ mode?: 'typewriter' | 'word' | 'line' | 'glyphFade';
297
301
  /**
298
302
  * Self-typing rate in graphemes/second from scene start (media time). The
299
303
  * effective progress is `min(progress, speed·t/total)` — so a steered
@@ -309,6 +313,12 @@ interface TextRevealSpec {
309
313
  blink?: number;
310
314
  color?: string;
311
315
  };
316
+ /**
317
+ * `glyphFade` only: each glyph's fade window as a fraction of progress
318
+ * (0 exclusive to 1, default 0.15). Small = soft typewriter; large = a
319
+ * wave of overlapping fades. Ignored by the other modes.
320
+ */
321
+ fade?: number;
312
322
  }
313
323
  /** Rotate through sprite variants over time. Each entity offsets by its seeded phase. */
314
324
  interface CycleSpec {
@@ -637,6 +647,8 @@ declare function linkPairs(positions: Array<{
637
647
  x: number;
638
648
  y: number;
639
649
  }>, k: number, maxDist: number, doWrap: boolean, w: number, h: number): Array<[number, number]>;
650
+ /** Em-width of a string under the same character-class table `breakTextBlock` uses. */
651
+ declare function textWidthEm(str: string): number;
640
652
  interface TextBlockLine {
641
653
  text: string;
642
654
  widthEm: number;
@@ -656,8 +668,9 @@ declare function breakTextBlock(text: string, maxWidthEm: number): TextBlockLine
656
668
  declare function graphemeClusters(text: string): string[];
657
669
  interface TextRevealInput {
658
670
  progress?: number;
659
- mode?: 'typewriter' | 'word' | 'line';
671
+ mode?: 'typewriter' | 'word' | 'line' | 'glyphFade';
660
672
  speed?: number;
673
+ fade?: number;
661
674
  }
662
675
  interface RevealState {
663
676
  /** Effective 0..1 progress after combining authored progress and speed at t. */
@@ -669,6 +682,12 @@ interface RevealState {
669
682
  /** Caret anchor: line index and the visible prefix on that line. */
670
683
  caretLine: number;
671
684
  caretPrefix: string;
685
+ /**
686
+ * `glyphFade` mode only: per-line per-grapheme alpha (0..1), indexed
687
+ * parallel to `graphemeClusters(lines[i].text)`. Alphas only decrease in
688
+ * reading order, so a renderer may stop at the first zero.
689
+ */
690
+ glyphAlphas?: number[][];
672
691
  }
673
692
  /**
674
693
  * Resolve which glyphs of a broken textBlock are visible at time `tMs`.
@@ -993,18 +1012,44 @@ interface ResolvedSegment {
993
1012
  localT: number;
994
1013
  /** Cumulative start time of this segment in the global timeline. */
995
1014
  startT: number;
1015
+ /**
1016
+ * Present (true) when the timeline has run past this segment's duration but
1017
+ * the segment has `advance: 'input'` and no input has released it yet — the
1018
+ * scene keeps animating (`localT` keeps growing) while the show waits for
1019
+ * the presenter.
1020
+ */
1021
+ held?: true;
996
1022
  }
1023
+ interface ResolveOptions {
1024
+ /**
1025
+ * Segments below this index have been released by input: a `sequence.segment`
1026
+ * steer to index `n` counts as the presenter clicking past every hold before
1027
+ * `n`. `SequenceInstance` maintains this; callers resolving a bare timeline
1028
+ * leave it at 0, so every `advance: 'input'` hold is honoured.
1029
+ */
1030
+ releasedBelow?: number;
1031
+ }
1032
+ /** Global start time of segment `index` — the prefix sum of the durations before it. */
1033
+ declare function segmentStart(seq: IdleSequence, index: number): number;
997
1034
  /**
998
1035
  * Map global time T to (segmentIndex, localT) via prefix sums of durations.
999
1036
  * Segments are half-open: [start, start+duration). The final segment may omit
1000
1037
  * duration, in which case it holds indefinitely (T past the last timed segment
1001
1038
  * always resolves to the final one).
1002
1039
  *
1003
- * With `loop: true`, T wraps modulo the total duration of all segments. If the
1004
- * final segment has no duration, looping is over the timed prefix only — the
1005
- * durationless tail is unreachable in loop mode (validated elsewhere).
1040
+ * `advance: 'input'` makes a timed segment hold at its end instead of yielding
1041
+ * to the next one — the clicker. `auto` (default) and `either` advance on the
1042
+ * timer. A hold is released by a `sequence.segment` steer (see
1043
+ * `ResolveOptions.releasedBelow`); the held scene keeps its clock running, so
1044
+ * `localT` grows past `duration` rather than freezing.
1045
+ *
1046
+ * With `loop: true`, T wraps modulo the total duration of all segments — but
1047
+ * only once every hold on the current lap has been released; after a wrap
1048
+ * every hold is armed again. If the final segment has no duration, looping is
1049
+ * over the timed prefix only — the durationless tail is unreachable in loop
1050
+ * mode (validated elsewhere).
1006
1051
  */
1007
- declare function resolveSegment(seq: IdleSequence, T: number): ResolvedSegment;
1052
+ declare function resolveSegment(seq: IdleSequence, T: number, opts?: ResolveOptions): ResolvedSegment;
1008
1053
 
1009
1054
  /**
1010
1055
  * Reproduces the hand-coded Fish Aquarium saver as pure DATA — the discriminating test
@@ -1148,4 +1193,4 @@ declare const EXAMPLE_SPECS: SaverSpec[];
1148
1193
  /** Lookup by saver id. */
1149
1194
  declare const EXAMPLE_BY_ID: Readonly<Record<string, SaverSpec>>;
1150
1195
 
1151
- 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 };
1196
+ 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 ResolveOptions, 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, segmentStart, sizeAt, spriteIndexAt, spriteVariants, steerablePaths, structuralSignature, textSprites, textWidthEm, validateSequence, validateSpec };
package/dist/index.js CHANGED
@@ -221,7 +221,7 @@ var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "c
221
221
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
222
222
  var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
223
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"]);
224
+ var KNOWN_REVEAL = /* @__PURE__ */ new Set(["progress", "mode", "speed", "caret", "fade"]);
225
225
  var KNOWN_REVEAL_CARET = /* @__PURE__ */ new Set(["blink", "color"]);
226
226
  var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob"]);
227
227
  var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway"]);
@@ -604,8 +604,11 @@ function validateSprite(sprite, path, err, warn) {
604
604
  if (rv.progress !== void 0 && (!isNum(rv.progress) || rv.progress < 0 || rv.progress > 1)) {
605
605
  err(`${path}.reveal.progress`, "must be a number between 0 and 1");
606
606
  }
607
- if (rv.mode !== void 0 && !["typewriter", "word", "line"].includes(rv.mode)) {
608
- err(`${path}.reveal.mode`, "must be 'typewriter' | 'word' | 'line'");
607
+ if (rv.mode !== void 0 && !["typewriter", "word", "line", "glyphFade"].includes(rv.mode)) {
608
+ err(`${path}.reveal.mode`, "must be 'typewriter' | 'word' | 'line' | 'glyphFade'");
609
+ }
610
+ if (rv.fade !== void 0 && (!isNum(rv.fade) || rv.fade <= 0 || rv.fade > 1)) {
611
+ err(`${path}.reveal.fade`, "must be between 0 (exclusive) and 1 (fraction of progress)");
609
612
  }
610
613
  if (rv.speed !== void 0 && (!isNum(rv.speed) || rv.speed < 0 || rv.speed > LIMITS.maxRevealSpeed)) {
611
614
  err(`${path}.reveal.speed`, `must be between 0 and ${LIMITS.maxRevealSpeed} (graphemes/sec)`);
@@ -1462,6 +1465,17 @@ function revealState(lines, reveal, tMs) {
1462
1465
  const mode = reveal.mode ?? "typewriter";
1463
1466
  let fullLines = 0;
1464
1467
  let partialText = "";
1468
+ let glyphAlphas;
1469
+ if (mode === "glyphFade") {
1470
+ const f = Math.min(1, Math.max(0.01, reveal.fade ?? 0.15));
1471
+ let g = 0;
1472
+ glyphAlphas = lineClusters.map(
1473
+ (clusters) => clusters.map(() => {
1474
+ const start = totalGraphemes > 0 ? g++ / totalGraphemes * (1 - f) : 0;
1475
+ return Math.max(0, Math.min(1, (progress - start) / f));
1476
+ })
1477
+ );
1478
+ }
1465
1479
  if (mode === "line") {
1466
1480
  fullLines = Math.round(progress * lines.length);
1467
1481
  } else if (mode === "word") {
@@ -1501,7 +1515,7 @@ function revealState(lines, reveal, tMs) {
1501
1515
  caretLine = 0;
1502
1516
  caretPrefix = "";
1503
1517
  }
1504
- return { progress, fullLines, partialText, caretLine, caretPrefix };
1518
+ return { progress, fullLines, partialText, caretLine, caretPrefix, glyphAlphas };
1505
1519
  }
1506
1520
 
1507
1521
  // src/compile.ts
@@ -1510,7 +1524,13 @@ import {
1510
1524
  } from "@idle-screens/core";
1511
1525
 
1512
1526
  // src/sequence.ts
1513
- function resolveSegment(seq, T) {
1527
+ function segmentStart(seq, index) {
1528
+ let t = 0;
1529
+ const n = Math.max(0, Math.min(index, seq.segments.length - 1));
1530
+ for (let i = 0; i < n; i++) t += seq.segments[i].duration ?? 0;
1531
+ return t;
1532
+ }
1533
+ function resolveSegment(seq, T, opts = {}) {
1514
1534
  const { segments, loop } = seq;
1515
1535
  if (segments.length === 0) return { index: 0, localT: 0, startT: 0 };
1516
1536
  let totalTimed = 0;
@@ -1518,24 +1538,33 @@ function resolveSegment(seq, T) {
1518
1538
  if (seg.duration != null) totalTimed += seg.duration;
1519
1539
  else break;
1520
1540
  }
1521
- let t = Math.max(0, T);
1522
- if (loop && totalTimed > 0) {
1523
- t = t % totalTimed;
1524
- }
1525
- let cumulative = 0;
1526
- for (let i = 0; i < segments.length; i++) {
1527
- const seg = segments[i];
1528
- if (seg.duration == null) {
1529
- return { index: i, localT: t - cumulative, startT: cumulative };
1530
- }
1531
- if (t < cumulative + seg.duration) {
1532
- return { index: i, localT: t - cumulative, startT: cumulative };
1541
+ const t0 = Math.max(0, T);
1542
+ const walk = (t, releasedBelow) => {
1543
+ let cumulative = 0;
1544
+ for (let i = 0; i < segments.length; i++) {
1545
+ const seg = segments[i];
1546
+ if (seg.duration == null) {
1547
+ return loop ? null : { index: i, localT: t - cumulative, startT: cumulative };
1548
+ }
1549
+ if (t < cumulative + seg.duration) {
1550
+ return { index: i, localT: t - cumulative, startT: cumulative };
1551
+ }
1552
+ if (seg.advance === "input" && i >= releasedBelow) {
1553
+ return { index: i, localT: t - cumulative, startT: cumulative, held: true };
1554
+ }
1555
+ cumulative += seg.duration;
1533
1556
  }
1534
- cumulative += seg.duration;
1557
+ return null;
1558
+ };
1559
+ const first = walk(t0, opts.releasedBelow ?? 0);
1560
+ if (first) return first;
1561
+ if (loop && totalTimed > 0) {
1562
+ const wrapped = walk(t0 % totalTimed, 0);
1563
+ if (wrapped) return wrapped;
1535
1564
  }
1536
1565
  const last = segments.length - 1;
1537
- const lastStart = cumulative - (segments[last].duration ?? 0);
1538
- return { index: last, localT: t - lastStart, startT: lastStart };
1566
+ const lastStart = totalTimed - (segments[last].duration ?? 0);
1567
+ return { index: last, localT: t0 - lastStart, startT: lastStart };
1539
1568
  }
1540
1569
 
1541
1570
  // src/compile.ts
@@ -1853,11 +1882,39 @@ var SpecInstance = class {
1853
1882
  ctx.textBaseline = "top";
1854
1883
  ctx.textAlign = align;
1855
1884
  const xOff = align === "center" ? maxWPx / 2 : align === "right" ? maxWPx : 0;
1856
- for (let li = 0; li < fullLines; li++) {
1857
- ctx.fillText(lines[li].text, xOff, li * lh);
1858
- }
1859
- if (rs && rs.partialText.length > 0) {
1860
- ctx.fillText(rs.partialText, xOff, fullLines * lh);
1885
+ if (rs?.glyphAlphas) {
1886
+ ctx.textAlign = "left";
1887
+ const baseAlpha = ctx.globalAlpha;
1888
+ for (let li = 0; li < lines.length; li++) {
1889
+ const alphas = rs.glyphAlphas[li] ?? [];
1890
+ if (alphas.length > 0 && alphas[0] <= 0) break;
1891
+ const clusters = graphemeClusters(lines[li].text);
1892
+ const lw = ctx.measureText(lines[li].text).width;
1893
+ const x0 = align === "center" ? (maxWPx - lw) / 2 : align === "right" ? maxWPx - lw : 0;
1894
+ let opaque = 0;
1895
+ while (opaque < clusters.length && (alphas[opaque] ?? 0) >= 1) opaque++;
1896
+ let prefix = "";
1897
+ if (opaque > 0) {
1898
+ prefix = clusters.slice(0, opaque).join("");
1899
+ ctx.globalAlpha = baseAlpha;
1900
+ ctx.fillText(prefix, x0, li * lh);
1901
+ }
1902
+ for (let gi = opaque; gi < clusters.length; gi++) {
1903
+ const a = alphas[gi] ?? 0;
1904
+ if (a <= 0) break;
1905
+ ctx.globalAlpha = baseAlpha * a;
1906
+ ctx.fillText(clusters[gi], x0 + ctx.measureText(prefix).width, li * lh);
1907
+ prefix += clusters[gi];
1908
+ }
1909
+ }
1910
+ ctx.globalAlpha = baseAlpha;
1911
+ } else {
1912
+ for (let li = 0; li < fullLines; li++) {
1913
+ ctx.fillText(lines[li].text, xOff, li * lh);
1914
+ }
1915
+ if (rs && rs.partialText.length > 0) {
1916
+ ctx.fillText(rs.partialText, xOff, fullLines * lh);
1917
+ }
1861
1918
  }
1862
1919
  if (rs && reveal.caret) {
1863
1920
  const cfg = reveal.caret === true ? {} : reveal.caret;
@@ -2091,6 +2148,18 @@ var SequenceInstance = class {
2091
2148
  startT = 0;
2092
2149
  baseT = 0;
2093
2150
  lastT = 0;
2151
+ /** The T most recently passed to renderFrame — the clock a steer displaces. */
2152
+ renderedT = 0;
2153
+ /**
2154
+ * The clicker's two pieces of state. `clockOffset` is added to every T so
2155
+ * a `sequence.segment` steer lands the timeline at the target segment's
2156
+ * start and STAYS there as the wall clock keeps ticking — the steer moves
2157
+ * the clock instead of fighting it. `releasedBelow` records that the steer
2158
+ * counts as the presenter clicking past every `advance: 'input'` hold
2159
+ * before the target (see `resolveSegment`).
2160
+ */
2161
+ clockOffset = 0;
2162
+ releasedBelow = 0;
2094
2163
  constructor(seq, ctx) {
2095
2164
  this.seq = seq;
2096
2165
  let surface = ctx.surface ?? null;
@@ -2173,7 +2242,8 @@ var SequenceInstance = class {
2173
2242
  this.renderFrame(this.lastT, this.seed);
2174
2243
  }
2175
2244
  renderFrame(T, seed) {
2176
- const resolved = resolveSegment(this.seq, T);
2245
+ this.renderedT = T;
2246
+ const resolved = resolveSegment(this.seq, T + this.clockOffset, { releasedBelow: this.releasedBelow });
2177
2247
  const { index, localT } = resolved;
2178
2248
  const prevIdx = index > 0 ? index - 1 : -1;
2179
2249
  const morphActive = prevIdx >= 0 && this.canMorph(prevIdx) && localT < this.morphDur(prevIdx);
@@ -2242,14 +2312,9 @@ var SequenceInstance = class {
2242
2312
  const segDelta = deltas.find((d) => d.path === "sequence.segment");
2243
2313
  if (segDelta !== void 0 && typeof segDelta.value === "number") {
2244
2314
  const idx = Math.max(0, Math.min(this.seq.segments.length - 1, Math.round(segDelta.value)));
2245
- if (idx !== this.activeIndex) {
2246
- for (let i = 0; i < this.children.length; i++) {
2247
- if (i !== idx) this.releaseChild(i);
2248
- }
2249
- this.activeIndex = idx;
2250
- const child = this.ensureChild(idx);
2251
- child.renderFrame(0, this.seq.segments[idx].scene.seed ?? this.seq.seed ?? 0);
2252
- }
2315
+ this.clockOffset = segmentStart(this.seq, idx) - this.renderedT;
2316
+ this.releasedBelow = idx;
2317
+ this.renderFrame(this.renderedT, this.seed);
2253
2318
  }
2254
2319
  const childDeltas = deltas.filter((d) => d.path !== "sequence.segment");
2255
2320
  if (childDeltas.length > 0 && this.activeIndex >= 0) {
@@ -2529,8 +2594,88 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2529
2594
  });
2530
2595
  }
2531
2596
  }
2597
+ const textBoxes = [];
2598
+ for (let li = 0; li < spec.layers.length; li++) {
2599
+ const layer = spec.layers[li];
2600
+ const s = layer.sprite;
2601
+ if (layer.motion.type !== "static") continue;
2602
+ if (s.kind !== "text" && s.kind !== "textBlock") continue;
2603
+ const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
2604
+ for (const e of allEntities[li]) {
2605
+ const p = positionAt(e, 0, w, h);
2606
+ const box = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
2607
+ textBoxes.push({ li, label, ...box });
2608
+ }
2609
+ }
2610
+ const tolX = w * 0.01;
2611
+ const tolY = h * 0.01;
2612
+ const offFlagged = /* @__PURE__ */ new Set();
2613
+ for (const b of textBoxes) {
2614
+ if (offFlagged.has(b.li)) continue;
2615
+ const edges = [];
2616
+ if (b.x0 < -tolX) edges.push(`left edge by ~${Math.round(-b.x0)}px`);
2617
+ if (b.x1 > w + tolX) edges.push(`right edge by ~${Math.round(b.x1 - w)}px`);
2618
+ if (b.y0 < -tolY) edges.push(`top edge by ~${Math.round(-b.y0)}px`);
2619
+ if (b.y1 > h + tolY) edges.push(`bottom edge by ~${Math.round(b.y1 - h)}px`);
2620
+ if (edges.length === 0) continue;
2621
+ offFlagged.add(b.li);
2622
+ warnings.push({
2623
+ path: `layers[${b.li}]`,
2624
+ code: "text-off-screen",
2625
+ message: `${b.label} text runs off the ${edges.join(" and the ")} at ${w}\xD7${h} \u2014 move position, or shrink fontSize / maxWidth`
2626
+ });
2627
+ }
2628
+ const pairFlagged = /* @__PURE__ */ new Set();
2629
+ for (let i = 0; i < textBoxes.length; i++) {
2630
+ for (let j = i + 1; j < textBoxes.length; j++) {
2631
+ const a = textBoxes[i];
2632
+ const b = textBoxes[j];
2633
+ if (a.li === b.li) continue;
2634
+ const pair = `${Math.min(a.li, b.li)}:${Math.max(a.li, b.li)}`;
2635
+ if (pairFlagged.has(pair)) continue;
2636
+ const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
2637
+ const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
2638
+ if (ix <= 0 || iy <= 0) continue;
2639
+ const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
2640
+ if (smaller <= 0) continue;
2641
+ const share = ix * iy / smaller;
2642
+ if (share <= 0.1) continue;
2643
+ pairFlagged.add(pair);
2644
+ const [first, second] = a.li < b.li ? [a, b] : [b, a];
2645
+ warnings.push({
2646
+ path: `layers[${first.li}]`,
2647
+ code: "text-overlap",
2648
+ message: `${first.label} and ${second.label} text boxes overlap (~${Math.round(share * 100)}% of the smaller one) \u2014 they will paint over each other; move one or shrink it`
2649
+ });
2650
+ }
2651
+ }
2532
2652
  return warnings;
2533
2653
  }
2654
+ function textBoxAt(s, e, p, spec, w, h) {
2655
+ const m = s.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(s.font) : null;
2656
+ const fontScale = spec.units === "px" ? 1 : Math.min(w, h) / (spec.referenceViewport ?? LIMITS.referenceViewport);
2657
+ const fh = m ? Number(m[1]) * fontScale : e.size;
2658
+ const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
2659
+ let fw = textWidthEm(str) * fh;
2660
+ if (s.maxWidth) fw = Math.min(fw, s.maxWidth * (spec.units === "px" ? 1 : Math.min(w, h)));
2661
+ const align = s.align ?? "center";
2662
+ const baseline = s.baseline ?? "middle";
2663
+ const x0 = align === "left" ? p.x : align === "right" ? p.x - fw : p.x - fw / 2;
2664
+ const y0 = baseline === "top" ? p.y : baseline === "bottom" ? p.y - fh : p.y - fh / 2;
2665
+ return { x0, y0, x1: x0 + fw, y1: y0 + fh };
2666
+ }
2667
+ function textBlockBoxAt(s, p, w, h) {
2668
+ const unit = Math.min(w, h);
2669
+ const fsPx = s.fontSize * unit;
2670
+ const lh = (s.lineHeight ?? 1.4) * fsPx;
2671
+ const maxWPx = s.maxWidth * unit;
2672
+ const lines = breakTextBlock(s.text, maxWPx / fsPx);
2673
+ const maxLineW = lines.reduce((mx, l) => Math.max(mx, l.widthEm), 0) * fsPx;
2674
+ const totalH = lines.length * lh;
2675
+ const align = s.align ?? "left";
2676
+ const x0 = align === "center" ? p.x + (maxWPx - maxLineW) / 2 : align === "right" ? p.x + maxWPx - maxLineW : p.x;
2677
+ return { x0, y0: p.y, x1: x0 + maxLineW, y1: p.y + totalH };
2678
+ }
2534
2679
  function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
2535
2680
  const warnings = [];
2536
2681
  for (let i = 0; i < seq.segments.length; i++) {
@@ -2729,7 +2874,17 @@ function textBlockRevealFraction(s, w, h, t) {
2729
2874
  const unitScale = Math.min(w, h);
2730
2875
  const maxWEm = s.maxWidth * unitScale / (s.fontSize * unitScale);
2731
2876
  const lines = breakTextBlock(s.text, maxWEm);
2732
- return revealState(lines, s.reveal, t).progress;
2877
+ const rs = revealState(lines, s.reveal, t);
2878
+ if (rs.glyphAlphas) {
2879
+ let sum = 0;
2880
+ let n = 0;
2881
+ for (const line of rs.glyphAlphas) for (const a of line) {
2882
+ sum += a;
2883
+ n++;
2884
+ }
2885
+ return n > 0 ? sum / n : 1;
2886
+ }
2887
+ return rs.progress;
2733
2888
  }
2734
2889
  function luminanceGrid(spec, opts = {}) {
2735
2890
  const scene = buildScene(spec, opts);
@@ -4725,12 +4880,14 @@ export {
4725
4880
  resolveSpecPath,
4726
4881
  revealState,
4727
4882
  rotationAt,
4883
+ segmentStart,
4728
4884
  sizeAt,
4729
4885
  spriteIndexAt,
4730
4886
  spriteVariants,
4731
4887
  steerablePaths,
4732
4888
  structuralSignature,
4733
4889
  textSprites,
4890
+ textWidthEm,
4734
4891
  validateSequence,
4735
4892
  validateSpec
4736
4893
  };