@promptctl/rich-js 0.23.0 → 0.24.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.
Files changed (117) hide show
  1. package/THIRD-PARTY-NOTICES +29 -1
  2. package/dist/core/animation.d.ts +78 -0
  3. package/dist/core/animation.js +107 -0
  4. package/dist/core/cell-widths.d.ts +9 -0
  5. package/dist/core/cell-widths.js +140 -0
  6. package/dist/core/cells.d.ts +17 -11
  7. package/dist/core/cells.js +100 -25
  8. package/dist/core/clock.d.ts +9 -1
  9. package/dist/core/clock.js +22 -2
  10. package/dist/core/color.d.ts +10 -0
  11. package/dist/core/color.js +36 -1
  12. package/dist/core/console.js +5 -5
  13. package/dist/core/easing.d.ts +138 -7
  14. package/dist/core/easing.js +355 -17
  15. package/dist/core/json.js +2 -1
  16. package/dist/core/measure.js +2 -2
  17. package/dist/core/place.js +2 -1
  18. package/dist/core/pretty.js +2 -2
  19. package/dist/core/protocol.d.ts +73 -19
  20. package/dist/core/protocol.js +56 -15
  21. package/dist/core/render.js +2 -1
  22. package/dist/core/segment.d.ts +28 -1
  23. package/dist/core/segment.js +60 -44
  24. package/dist/core/strip.d.ts +107 -29
  25. package/dist/core/strip.js +242 -131
  26. package/dist/core/style.d.ts +18 -1
  27. package/dist/core/style.js +49 -9
  28. package/dist/core/text.js +26 -15
  29. package/dist/core/timeline.d.ts +84 -0
  30. package/dist/core/timeline.js +144 -0
  31. package/dist/core/tween.d.ts +49 -0
  32. package/dist/core/tween.js +69 -0
  33. package/dist/host/app.js +4 -6
  34. package/dist/host/index.d.ts +1 -1
  35. package/dist/host/input.d.ts +37 -13
  36. package/dist/host/input.js +146 -56
  37. package/dist/host/terminal-host.d.ts +16 -0
  38. package/dist/host/terminal-host.js +1 -0
  39. package/dist/index.d.ts +16 -11
  40. package/dist/index.js +15 -5
  41. package/dist/node/terminal-host.d.ts +8 -1
  42. package/dist/node/terminal-host.js +2 -0
  43. package/dist/renderables/align.d.ts +3 -2
  44. package/dist/renderables/align.js +3 -0
  45. package/dist/renderables/columns.d.ts +3 -2
  46. package/dist/renderables/columns.js +7 -2
  47. package/dist/renderables/constrain.d.ts +3 -2
  48. package/dist/renderables/constrain.js +5 -1
  49. package/dist/renderables/effect.d.ts +83 -30
  50. package/dist/renderables/effect.js +155 -57
  51. package/dist/renderables/effects.d.ts +88 -39
  52. package/dist/renderables/effects.js +444 -105
  53. package/dist/renderables/embed.d.ts +10 -0
  54. package/dist/renderables/embed.js +18 -9
  55. package/dist/renderables/flexStrip.d.ts +3 -2
  56. package/dist/renderables/flexStrip.js +6 -1
  57. package/dist/renderables/frames.d.ts +116 -0
  58. package/dist/renderables/frames.js +133 -0
  59. package/dist/renderables/group.d.ts +3 -2
  60. package/dist/renderables/group.js +5 -2
  61. package/dist/renderables/html-entities.d.ts +8 -0
  62. package/dist/renderables/html-entities.js +386 -0
  63. package/dist/renderables/layout.d.ts +3 -2
  64. package/dist/renderables/layout.js +15 -2
  65. package/dist/renderables/list-view.d.ts +3 -2
  66. package/dist/renderables/list-view.js +12 -5
  67. package/dist/renderables/live.d.ts +23 -7
  68. package/dist/renderables/live.js +69 -35
  69. package/dist/renderables/markdown-parse.d.ts +109 -0
  70. package/dist/renderables/markdown-parse.js +1421 -0
  71. package/dist/renderables/markdown-url.d.ts +19 -0
  72. package/dist/renderables/markdown-url.js +314 -0
  73. package/dist/renderables/markdown.d.ts +7 -6
  74. package/dist/renderables/markdown.js +363 -499
  75. package/dist/renderables/padding.d.ts +8 -20
  76. package/dist/renderables/padding.js +32 -50
  77. package/dist/renderables/panel.d.ts +26 -26
  78. package/dist/renderables/panel.js +67 -103
  79. package/dist/renderables/progress.d.ts +5 -3
  80. package/dist/renderables/progress.js +14 -3
  81. package/dist/renderables/progressBar.js +4 -4
  82. package/dist/renderables/python-space.d.ts +13 -0
  83. package/dist/renderables/python-space.js +24 -0
  84. package/dist/renderables/spinner.d.ts +73 -27
  85. package/dist/renderables/spinner.js +110 -51
  86. package/dist/renderables/status.d.ts +8 -3
  87. package/dist/renderables/status.js +8 -5
  88. package/dist/renderables/syntax.js +2 -1
  89. package/dist/renderables/table.d.ts +19 -11
  90. package/dist/renderables/table.js +140 -156
  91. package/dist/renderables/traceback.d.ts +1 -1
  92. package/dist/renderables/traceback.js +6 -5
  93. package/dist/renderables/tree.d.ts +6 -5
  94. package/dist/renderables/tree.js +11 -7
  95. package/dist/renderables/viewport.d.ts +13 -5
  96. package/dist/renderables/viewport.js +41 -20
  97. package/dist/template-bindings/animation-funcs.d.ts +37 -0
  98. package/dist/template-bindings/animation-funcs.js +83 -0
  99. package/dist/template-bindings/color-funcs.d.ts +13 -0
  100. package/dist/template-bindings/color-funcs.js +1 -1
  101. package/dist/template-bindings/helpers.d.ts +6 -0
  102. package/dist/template-bindings/helpers.js +12 -3
  103. package/dist/template-bindings/index.d.ts +6 -2
  104. package/dist/template-bindings/index.js +8 -3
  105. package/dist/themes/ramp.d.ts +18 -3
  106. package/dist/themes/ramp.js +23 -2
  107. package/dist/widgets/focus-manager.d.ts +21 -14
  108. package/dist/widgets/focus-manager.js +28 -29
  109. package/dist/widgets/index.d.ts +1 -1
  110. package/dist/widgets/static-item.d.ts +4 -3
  111. package/dist/widgets/static-item.js +9 -7
  112. package/dist/widgets/text-input.d.ts +28 -18
  113. package/dist/widgets/text-input.js +144 -101
  114. package/dist/widgets/widget-app.d.ts +19 -3
  115. package/dist/widgets/widget-app.js +39 -21
  116. package/dist/widgets/widget-base.js +0 -3
  117. package/package.json +2 -5
@@ -10,7 +10,11 @@
10
10
  * a slow glow, sunlight moving on water, the colours of daylight, fireflies, ink
11
11
  * blooming in water, mist lifting — because a motion the eye recognises
12
12
  * reads as calm and alive, where a motion it has to learn reads as a widget.
13
- * What makes those motions familiar is what every curve here keeps:
13
+ * One effect here is not ambience but a signal, and keeps none of this on
14
+ * purpose: `steppedInk` steps its ink through colours on a beat, the news
15
+ * that something is at its limit, and jumps from slot to slot where the
16
+ * loops hold still. What makes the loops' motions familiar is what every
17
+ * curve of theirs keeps:
14
18
  *
15
19
  * - Nothing jumps. Every curve is continuous in time, built from smooth
16
20
  * noise (`core/noise.ts`) and smooth waveforms, so at one frame a second a cell
@@ -42,11 +46,13 @@
42
46
  * cards runs this file's own declarations as the program a visitor edits
43
47
  * (`examples/effects-playground/programs.ts`), on the published library.
44
48
  */
45
- import { ColorDepth, ColorRgba, contrastRatio } from "../core/color.js";
46
- import { EASES, Phase } from "../core/easing.js";
49
+ import { Animation } from "../core/animation.js";
50
+ import { ColorDepth, ColorRgba, ColorSpec, contrastRatio, relativeLuminance } from "../core/color.js";
51
+ import { EASES, Phase, beat } from "../core/easing.js";
47
52
  import { fbm, hash, noise, smoothstep } from "../core/noise.js";
48
53
  import { Memo } from "../core/memo.js";
49
- import { IDENTITY, Oklch } from "../core/oklch.js";
54
+ import { Oklch } from "../core/oklch.js";
55
+ import { ensureContrast } from "../themes/colorMath.js";
50
56
  import { Effected } from "./effect.js";
51
57
  /** The contrast no touch takes a cell below, unless it rested below it: WCAG AA for body text. */
52
58
  const LEGIBLE = 4.5;
@@ -61,6 +67,16 @@ const LEVELS = 48;
61
67
  const STRENGTHS = Array.from({ length: LEVELS }, (_, i) => (i + 1) / LEVELS);
62
68
  /** What 8-bit rounding of an OKLCH round trip can cost a contrast ratio. */
63
69
  const ROUNDING = 0.01;
70
+ /**
71
+ * The contrast no loop draws `pair` below at `depth`: its resting contrast or
72
+ * `LEGIBLE`, whichever is lower, less what truecolor's rounding can cost.
73
+ * Below truecolor every colour written is one the depth holds exactly, so
74
+ * there is nothing to forgive. [LAW:one-source-of-truth] The floor `shares`
75
+ * settles the light against and `wheel` writes its turn above.
76
+ */
77
+ function floorOf(pair, depth) {
78
+ return Math.min(contrastRatio(pair.fg, pair.bg), LEGIBLE) - (depth === ColorDepth.TRUECOLOR ? ROUNDING : 0);
79
+ }
64
80
  const SIDES = ["fg", "bg"];
65
81
  /**
66
82
  * How much of `touch` each of `colors` (hex) can take: the largest share in
@@ -86,8 +102,6 @@ export function shares({ pairs, depth, theme }, colors, touch) {
86
102
  const hex = own(pair, side);
87
103
  return hex === undefined ? [] : [[hex, pair[side]]];
88
104
  })));
89
- // Below truecolor every colour written is one the depth holds exactly, so only truecolor's rounding has a cost to forgive.
90
- const slack = depth === ColorDepth.TRUECOLOR ? ROUNDING : 0;
91
105
  // A colour no pair writes keeps no word legible, so nothing bounds its share:
92
106
  // a frame drawing a lit colour only under blanks and seams, or not at all,
93
107
  // is a frame like any other.
@@ -104,7 +118,7 @@ export function shares({ pairs, depth, theme }, colors, touch) {
104
118
  });
105
119
  // [LAW:one-source-of-truth] A pair reads in the colours `Effected` writes it in, touched at each side's share.
106
120
  const holds = (share) => facing.every(([pair, mine, theirs]) => {
107
- const floor = Math.min(contrastRatio(pair.fg, pair.bg), LEGIBLE) - slack;
121
+ const floor = floorOf(pair, depth);
108
122
  const at = (side, w) => touchedAt(touch, pair[side], (side === mine ? share : theirs) * w);
109
123
  return STRENGTHS.every((w) => {
110
124
  const shown = Effected.shows(pair, { fg: at("fg", w), bg: at("bg", w) }, depth, theme);
@@ -124,9 +138,10 @@ export function shares({ pairs, depth, theme }, colors, touch) {
124
138
  * A loop as an `Effect` on the element's own colours, by hex, wherever a cell
125
139
  * shows them, each touched at its share of the cell's strength: the share
126
140
  * `shares` settles for it against the pairs the element draws its words in
127
- * (`Drawn`), touched as `Effected` writes them at the frame's depth — below
128
- * truecolor a touched colour lands on a colour the slot can write, and the
129
- * contrast it spends is that colour's. `colors` are the ones lit, by default every colour the element
141
+ * (`Drawn`), touched as `Effected` writes them at the frame's depth — at 256
142
+ * colours a touched colour lands on a colour the slot can write, and the
143
+ * contrast it spends is that colour's; on a sixteen-colour terminal nothing
144
+ * moves (`stillOnSlots`). `colors` are the ones lit, by default every colour the element
130
145
  * sets itself. A powerline strip's fill is the ground of its cells and the
131
146
  * ink of the seam glyphs between them, so the arrow moves with the cell it
132
147
  * points out of. A colour the terminal supplies (`EffectCell.terminal`) is
@@ -140,12 +155,27 @@ export function onColors(loop, colors) {
140
155
  const settled = new WeakMap();
141
156
  const settle = (drawn) => {
142
157
  const lit = colors ?? drawn.colors;
143
- const key = [drawn.depth, ...drawn.pairs.map(({ fg, bg, terminal, reverse }) => `${fg.hex}/${bg.hex}/${terminal.fg}/${terminal.bg}/${reverse}`), "|", ...lit].join(" ");
158
+ const key = [drawnKey(drawn), "|", ...lit].join(" ");
144
159
  const memo = settled.get(drawn.theme) ?? new Memo();
145
160
  settled.set(drawn.theme, memo);
146
161
  return memo.get(key, () => shares(drawn, lit, loop.touch));
147
162
  };
148
- return atShares(loop, settle);
163
+ return stillOnSlots(atShares(loop, settle));
164
+ }
165
+ /** What a frame draws, as a key: its depth and its pairs, which its `colors` and `fills` are read off. */
166
+ function drawnKey({ depth, pairs }) {
167
+ return [depth, ...pairs.map(pairKey)].join(" ");
168
+ }
169
+ /**
170
+ * A pair as a key: two pairs the screen shows alike, from slots alike, are
171
+ * one — the spelling `Drawn.pairs` is made distinct by (`./effect.ts`), and
172
+ * what every memo here over a frame's pairs is keyed on. Spelled here rather
173
+ * than imported: a playground card runs this file on the published library
174
+ * (header), and a name this file imports from `./effect.js` is one the
175
+ * package exports.
176
+ */
177
+ function pairKey({ fg, bg, terminal, reverse }) {
178
+ return `${fg.hex}/${bg.hex}/${terminal.fg}/${terminal.bg}/${reverse}`;
149
179
  }
150
180
  /**
151
181
  * A loop as an `Effect` on colours by hex, each touched at the share
@@ -153,16 +183,45 @@ export function onColors(loop, colors) {
153
183
  * says; a colour it gives none, and one the terminal supplies, stays as it is.
154
184
  */
155
185
  export function atShares(loop, share) {
156
- return (t, drawn) => {
186
+ return (drawn) => {
157
187
  const shared = share(drawn);
158
- const field = loop.field(t);
159
- return ({ fg, bg }, cell) => {
160
- const w = Math.round(clamp01(field(cell)) * LEVELS) / LEVELS;
161
- const moved = (color, terminal) => touchedAt(loop.touch, color, (terminal ? 0 : (shared.get(color.hex) ?? 0)) * w);
162
- return { fg: moved(fg, cell.terminal.fg), bg: moved(bg, cell.terminal.bg) };
163
- };
188
+ return Animation.continuous((t) => {
189
+ const field = loop.field(t, drawn);
190
+ return ({ fg, bg }, cell) => {
191
+ const w = Math.round(clamp01(field(cell)) * LEVELS) / LEVELS;
192
+ const moved = (color, terminal) => touchedAt(loop.touch, color, (terminal ? 0 : (shared.get(color.hex) ?? 0)) * w);
193
+ return { fg: moved(fg, cell.terminal.fg), bg: moved(bg, cell.terminal.bg) };
194
+ };
195
+ });
164
196
  };
165
197
  }
198
+ /**
199
+ * `loop` as drawn at the frame's depth: where the depth writes only the
200
+ * sixteen ANSI slots, or no colour, a move that shows at all is a jump to
201
+ * another slot, and a light on an element never jumps — so there it
202
+ * holds every cell still, and anywhere else it moves. [LAW:single-enforcer]
203
+ * `onColors` and `wheel` draw through this, so every loop in the catalogue
204
+ * meets the sixteen-colour terminal here. `atShares` does not: a progress
205
+ * bar's pulse is the news that work is going on, and flashes a slot as
206
+ * Rich's does; nor does `steppedInk`, a signal, which steps slot to slot.
207
+ * A frame held still never changes, and says so, so a host drawing the
208
+ * loop on such a terminal draws it once.
209
+ */
210
+ function stillOnSlots(loop) {
211
+ return (drawn) => (onlySlots(drawn.depth) ? Animation.constant((colors) => colors) : loop(drawn));
212
+ }
213
+ /** Whether `depth` writes nothing between the slots it has: the sixteen, at either sixteen-colour depth, or none. */
214
+ function onlySlots(depth) {
215
+ switch (depth) {
216
+ case ColorDepth.DEFAULT:
217
+ case ColorDepth.STANDARD:
218
+ case ColorDepth.WINDOWS:
219
+ return true;
220
+ case ColorDepth.EIGHT_BIT:
221
+ case ColorDepth.TRUECOLOR:
222
+ return false;
223
+ }
224
+ }
166
225
  /**
167
226
  * `color` under `touch` at strength `w`, and at strength 0 the colour itself,
168
227
  * exactly: an OKLCH round trip can land a step off, and at 16 colours a step
@@ -313,8 +372,9 @@ const bump = (d) => (Math.abs(d) >= 1 ? 0 : (1 - d * d) ** 3);
313
372
  /**
314
373
  * Shimmer, as sunlight moving across water. Swing: how far into `light` the
315
374
  * brightest glint goes, 0–1. A soft band of light, `width` columns either
316
- * side of its centre, crosses columns 0 to `span`, entering and leaving
317
- * fully off the row, `STRIDE` columns in about three quarters of a period. Its passes
375
+ * side of its centre, crosses the element from the first column a frame
376
+ * lists (`Drawn.cells`) to the last, entering and leaving fully off it,
377
+ * `STRIDE` columns in about three quarters of a period. Its passes
318
378
  * come the way light comes and goes on water as clouds move: while the sun
319
379
  * is out they are bright and follow close on one another, one sometimes
320
380
  * catching the last; under cloud they thin to faint ones with the row still
@@ -324,26 +384,26 @@ const bump = (d) => (Math.abs(d) >= 1 ? 0 : (1 - d * d) ** 3);
324
384
  * on a slow current and re-forming as they go, so the glints dance while
325
385
  * the band glides. Before the first pass enters, the row is untouched.
326
386
  */
327
- export function shimmer(curve, span, width, glow, z) {
387
+ export function shimmer(curve, width, glow, z) {
328
388
  requireCurve(curve);
329
389
  const P = curve.seconds;
330
- const across = span + 2 * width;
331
390
  // Passes are events on slots of half a period. Whether slot `j` holds one,
332
391
  // and how bright, is the sky's: slow noise over the slots, so sunny and
333
392
  // cloudy spells each last a few periods, and a pass is likelier and
334
393
  // brighter the clearer the sky.
335
394
  const SLOT = P / 2;
336
395
  const sky = (j) => smoothstep(-0.4, 0.4, noise(j / 7, 3.3, 5.5 + z));
337
- // Pass `j` at `t`: how bright its band is and where its centre stands, off
338
- // the row before it sets off and once it is done. It sets off somewhere in
339
- // the first `SETS_OFF` of its slot and carries its centre `STRIDE` columns
340
- // in `LEAST` to `LEAST + SPREAD` periods, give or take its drift, and every
341
- // one is off the row `UP` slots after its own began.
396
+ // Pass `j` at `t`, over a crossing `across` columns long — the element's
397
+ // and the band's either side: how bright its band is and where its centre
398
+ // stands from the element's first column, off the element before it sets
399
+ // off and once it is done. It sets off somewhere in the first `SETS_OFF`
400
+ // of its slot and carries its centre `STRIDE` columns in `LEAST` to
401
+ // `LEAST + SPREAD` periods, give or take its drift, so a pass's centre moves
402
+ // at the same pace however long the crossing.
342
403
  const SETS_OFF = 0.7;
343
404
  const LEAST = 0.51;
344
405
  const SPREAD = 0.4;
345
- const UP = SETS_OFF + ((LEAST + SPREAD) * P * across) / STRIDE / SLOT;
346
- const pass = (j, t) => {
406
+ const pass = (j, t, across) => {
347
407
  const clear = sky(j);
348
408
  // Even under cloud about one slot in three holds a pass, a faint one, so a
349
409
  // still spell lasts a period or so, the way a cloud's shadow does, and
@@ -353,29 +413,35 @@ export function shimmer(curve, span, width, glow, z) {
353
413
  const crossing = ((P * across) / STRIDE) * (LEAST + SPREAD * hash(j, 5.5 + z));
354
414
  const q = clamp01((t - (j + SETS_OFF * hash(j, 6.5 + z)) * SLOT) / crossing);
355
415
  // It drifts ahead and behind on slow noise, by nothing at either edge of
356
- // the row and never enough to turn it back.
416
+ // the element and never enough to turn it back.
357
417
  const centre = q * across - width + 0.06 * across * Math.sin(Math.PI * q) * noise(q, 2.7, j + z);
358
418
  return { strength, centre };
359
419
  };
360
- // The passes on the row at `t`; the first slot is 0, so before it there are none.
361
- const passing = (t) => {
420
+ // The passes on the element at `t`, every one off it `up` slots after its
421
+ // own began; the first slot is 0, so before it there are none.
422
+ const passing = (t, across) => {
423
+ const up = SETS_OFF + ((LEAST + SPREAD) * P * across) / STRIDE / SLOT;
362
424
  const last = Math.floor(t / SLOT);
363
425
  const passes = [];
364
- for (let j = Math.max(0, Math.ceil(last - UP)); j <= last; j++)
365
- passes.push(pass(j, t));
426
+ for (let j = Math.max(0, Math.ceil(last - up)); j <= last; j++)
427
+ passes.push(pass(j, t, across));
366
428
  return passes;
367
429
  };
368
430
  // A ripple's crest: a Gaussian ridge, 1 where the field crosses zero and in
369
431
  // (0, 1] for any noise, smooth throughout, so a crest sliding through a cell
370
432
  // lights it smoothly.
371
433
  const ripple = (a, b, c) => Math.exp(-((noise(a, b, c) / 0.8) ** 2));
372
- const field = (t) => {
373
- const passes = passing(t);
434
+ const field = (t, drawn) => {
435
+ // [LAW:one-source-of-truth] The element is as wide as the cells its frame
436
+ // lists, never as a width its caller states: a padded, wrapped or
437
+ // confined element is crossed where it shows.
438
+ const [left, right] = extent(drawn.cells().map((cell) => cell.col));
439
+ const passes = passing(t, right - left + 1 + 2 * width);
374
440
  return (cell) => {
375
441
  // Where two passes overlap the brighter wins, so light never doubles up.
376
442
  let lit = 0;
377
443
  for (const { strength, centre } of passes)
378
- lit = Math.max(lit, strength * bump((cell.col - centre) / width));
444
+ lit = Math.max(lit, strength * bump((cell.col - left - centre) / width));
379
445
  const row = cell.row + z;
380
446
  const current = 0.8 * noise(cell.col * 0.05, row * 0.3 + 1.3, t * 0.02);
381
447
  const caustic = (ripple(cell.col * 0.21 + current, row * 0.9, t * 0.022) * ripple(cell.col * 0.33 + 9.1 - current, row * 0.7, t * 0.018 + 4.2)) **
@@ -387,8 +453,14 @@ export function shimmer(curve, span, width, glow, z) {
387
453
  }
388
454
  /**
389
455
  * Sparkle, as fireflies. Swing: how far into the firefly's `light` a glow
390
- * goes at its brightest, 0–1. About one firefly to every eight columns of
391
- * `span`, each keeping near a home of its own. A firefly flashes — kindles,
456
+ * goes at its brightest, 0–1. Fireflies keep to a lattice laid from the
457
+ * element's first column and top row as a frame lists its cells
458
+ * (`Drawn.cells`): one every `PER` columns along a row of the lattice, a row
459
+ * of the lattice every `ROWS` rows, each firefly near a home of its own.
460
+ * The lattice does not hang on how wide or deep the element is, so one that
461
+ * grows or shrinks at its last column or bottom row moves no firefly; one
462
+ * whose first column moves, as centred words that change length do, carries
463
+ * its fireflies with it. A firefly flashes — kindles,
392
464
  * drifts a few cells glowing, slowing as it fades — then flies on in the
393
465
  * dark and flashes again somewhere a few cells off, the way fireflies at
394
466
  * dusk are seen: a short lit stroke, then gone, each time a little
@@ -399,53 +471,78 @@ export function shimmer(curve, span, width, glow, z) {
399
471
  * turn: its brightest flash, rise and fall, takes `FLASH` of it, and a
400
472
  * dimmer one less.
401
473
  */
402
- export function sparkle(curve, span, glow, z) {
474
+ export function sparkle(curve, glow, z) {
403
475
  requireCurve(curve);
404
- const count = Math.max(3, Math.round(span / 8));
476
+ // The columns a firefly has to itself along a row of the lattice, and the
477
+ // rows between two rows of it: more than a halo's reach, so a one-row
478
+ // element is lit by one row of fireflies.
479
+ const PER = 8;
480
+ const ROWS = 3;
405
481
  const HALO = 5;
406
482
  // How far from home a flash may begin, and how far the brightest drifts while lit.
407
483
  const HOP = 5;
408
484
  const GLIDE = 5;
409
485
  const TURN = curve.seconds;
410
- const homes = Array.from({ length: count }, (_, i) => ({
411
- home: (i + 0.5 + 0.7 * noise(i * 1.7 + 0.2, 0.3, 0.5 + z)) * (span / count),
412
- z: i * 4.9 + 0.3 + z,
486
+ // How far from its slot's middle a firefly's light can reach a cell: its
487
+ // home's jitter, a hop, a glide, its wander, and its halo; across rows,
488
+ // its wander and its halo, which counts a row as two columns.
489
+ const REACH = 0.7 * PER + HOP + GLIDE + 1.5 + HALO;
490
+ const REACH_ROWS = 0.6 + HALO / 2;
491
+ // The firefly of slot `i` along lattice row `k` at `t`, from the element's
492
+ // first column and top row: where it is, and how bright. Flash `n` is one
493
+ // event: whether it comes, how bright, when in its turn and where are its
494
+ // own. Between flashes the firefly is dark, so where it moves to is never
495
+ // seen moving.
496
+ const firefly = (i, k, t, left, top) => {
497
+ const id = i * 4.9 + k * 31.7 + 0.3 + z;
498
+ const home = i + 0.5 + 0.7 * noise(i * 1.7 + 0.2, 0.3 + k * 2.9, 0.5 + z);
413
499
  // Out of step with every other firefly from the first moment.
414
- offset: hash(i, 0.7 + z) * TURN,
415
- }));
416
- // Where every firefly is at `t`, and how bright.
417
- const fliesAt = (t) => homes.map((fly) => {
418
- // Flash `n` is one event: whether it comes, how bright, when in
419
- // its turn and where are its own. Between flashes the firefly
420
- // is dark, so where it moves to is never seen moving.
421
- const u = t + fly.offset;
500
+ const u = t + hash(i + k * 1013, 0.7 + z) * TURN;
422
501
  const n = Math.floor(u / TURN);
423
502
  // A dark turn is a flash with no light: it keeps its length, so
424
503
  // every flash is timed alike.
425
- const lit = hash(n, fly.z + 1) < 0.15 ? 0 : 1;
426
- const k = 0.5 + 0.5 * hash(n, fly.z + 2);
427
- const at = hash(n, fly.z + 3) * (1 - lasts(FLASH, k));
504
+ const lit = hash(n, id + 1) < 0.15 ? 0 : 1;
505
+ const strength = 0.5 + 0.5 * hash(n, id + 2);
506
+ const at = hash(n, id + 3) * (1 - lasts(FLASH, strength));
428
507
  const s = u / TURN - n - at;
429
508
  // While it glows it drifts a few cells one way or the other, as
430
509
  // fast as it is bright: quickest at its height, slowing as it
431
510
  // fades — the lit stroke a firefly draws on the dusk. A brighter
432
511
  // flash lasts longer and draws a longer stroke at the same pace.
433
512
  return {
434
- x: fly.home +
435
- HOP * (2 * hash(n, fly.z + 4) - 1) +
436
- GLIDE * k * (2 * hash(n, fly.z + 5) - 1) * given(FLASH, s, k) +
437
- 1.5 * noise(fly.z, 0.1, t * 0.035),
438
- y: 0.6 * noise(fly.z, 2.2, t * 0.035),
439
- glow: lit * swell(FLASH, s, k),
513
+ x: left +
514
+ home * PER +
515
+ HOP * (2 * hash(n, id + 4) - 1) +
516
+ GLIDE * strength * (2 * hash(n, id + 5) - 1) * given(FLASH, s, strength) +
517
+ 1.5 * noise(id, 0.1, t * 0.035),
518
+ y: top + k * ROWS + 0.6 * noise(id, 2.2, t * 0.035),
519
+ glow: lit * swell(FLASH, s, strength),
440
520
  };
441
- });
442
- const field = (t) => {
443
- const flies = fliesAt(t);
521
+ };
522
+ const field = (t, drawn) => {
523
+ // [LAW:one-source-of-truth] The element is where its frame lists cells,
524
+ // never a width its caller states: a padded, wrapped or confined element
525
+ // has its fireflies over it. Its extent only bounds which slots are
526
+ // worked out; where each firefly is never depends on it.
527
+ const cells = drawn.cells();
528
+ const [left, right] = extent(cells.map((cell) => cell.col));
529
+ const [top, bottom] = extent(cells.map((cell) => cell.row));
530
+ const slot = (col) => (col - left) / PER - 0.5;
531
+ const band = (row) => (row - top) / ROWS;
532
+ const [i0, i1] = [Math.ceil(slot(left - REACH)), Math.floor(slot(right + REACH))];
533
+ const [k0, k1] = [Math.ceil(band(top - REACH_ROWS)), Math.floor(band(bottom + REACH_ROWS))];
534
+ const flies = Array.from({ length: k1 - k0 + 1 }, (_, k) => Array.from({ length: i1 - i0 + 1 }, (_, i) => firefly(i0 + i, k0 + k, t, left, top)));
444
535
  return (cell) => {
445
- // Where two halos overlap the brighter wins, so light never doubles up.
536
+ // A cell is asked only of the fireflies whose light can reach it, and
537
+ // where two halos overlap the brighter wins, so light never doubles up.
446
538
  let shine = 0;
447
- for (const fly of flies)
448
- shine = Math.max(shine, fly.glow * bump(Math.hypot(cell.col - fly.x, 2 * (cell.row - fly.y)) / HALO));
539
+ const [ia, ib] = [Math.max(i0, Math.ceil(slot(cell.col - REACH))), Math.min(i1, Math.floor(slot(cell.col + REACH)))];
540
+ const [ka, kb] = [Math.max(k0, Math.ceil(band(cell.row - REACH_ROWS))), Math.min(k1, Math.floor(band(cell.row + REACH_ROWS)))];
541
+ for (let k = ka; k <= kb; k++)
542
+ for (let i = ia; i <= ib; i++) {
543
+ const fly = flies[k - k0][i - i0];
544
+ shine = Math.max(shine, fly.glow * bump(Math.hypot(cell.col - fly.x, 2 * (cell.row - fly.y)) / HALO));
545
+ }
449
546
  return curve.swing * curve.ease(shine);
450
547
  };
451
548
  };
@@ -460,17 +557,19 @@ export function sparkle(curve, span, glow, z) {
460
557
  * hiding — 1 reaches fully invisible. A cell wholly shown is returned as it was.
461
558
  */
462
559
  export function veiled(visibility, swing) {
463
- return (t, { theme }) => {
464
- const shows = visibility(t);
560
+ return (drawn) => {
465
561
  // [LAW:one-source-of-truth] The ground is the terminal `Effected` draws on, never a second one handed here.
466
- const ground = Oklch.fromRgba(theme.backgroundColor);
467
- return (colors, cell) => {
468
- const hidden = swing * (1 - clamp01(shows(cell)));
469
- if (hidden === 0)
470
- return colors;
471
- const bg = blend(Oklch.fromRgba(colors.bg), ground, hidden);
472
- return { fg: blend(Oklch.fromRgba(colors.fg), bg, hidden).toRgba(), bg: bg.toRgba() };
473
- };
562
+ const ground = Oklch.fromRgba(drawn.theme.backgroundColor);
563
+ return Animation.continuous((t) => {
564
+ const shows = visibility(t, drawn);
565
+ return (colors, cell) => {
566
+ const hidden = swing * (1 - clamp01(shows(cell)));
567
+ if (hidden === 0)
568
+ return colors;
569
+ const bg = blend(Oklch.fromRgba(colors.bg), ground, hidden);
570
+ return { fg: blend(Oklch.fromRgba(colors.fg), bg, hidden).toRgba(), bg: bg.toRgba() };
571
+ };
572
+ });
474
573
  };
475
574
  }
476
575
  /**
@@ -492,7 +591,19 @@ function order(z) {
492
591
  };
493
592
  }
494
593
  function transition(curve, effect) {
495
- return { effect, done: (t) => t >= curve.seconds };
594
+ const done = (t) => t >= curve.seconds;
595
+ return {
596
+ effect: (drawn) => {
597
+ const moving = effect(drawn);
598
+ return {
599
+ at: (t) => moving.at(t),
600
+ nextChange: (t) => (done(t) ? Number.POSITIVE_INFINITY : moving.nextChange(t)),
601
+ // Done, the frame settled when the transition did; until then it is the effect's.
602
+ lastChange: (t) => (done(t) ? curve.seconds : moving.lastChange(t)),
603
+ };
604
+ },
605
+ done,
606
+ };
496
607
  }
497
608
  /**
498
609
  * Of a transition's `seconds`, how long each cell's own change takes. Under
@@ -500,15 +611,51 @@ function transition(curve, effect) {
500
611
  */
501
612
  const OWN = 0.45;
502
613
  /**
503
- * Each cell's progress through its own change in a transition, in [0, 1]: a
504
- * cell `place` along the order starts that share of the way through the time
505
- * the others leave it, and takes `OWN` of `seconds`.
614
+ * Each cell's own change in a transition, in a frame listing `cells`: a cell
615
+ * starts as far through the time the others leave it as its `place` falls
616
+ * between the element's first and last, so on any element, however little of
617
+ * the order its cells span, the first starts at 0 and the last settles at
618
+ * `seconds`. [LAW:one-source-of-truth] That is what lets `done` read
619
+ * `seconds` alone. An element whose cells all share one place has no order to
620
+ * stagger, and its change takes the whole of `seconds`.
621
+ *
622
+ * A cell's change is settled in the first frame that lists it and kept, as
623
+ * its place is, so an element whose cells change while it moves — a label
624
+ * that gains a column as it leaves — spreads only its new cells over the
625
+ * order it has then, and never sends a cell that has gone back to a later
626
+ * start. [LAW:no-ambient-temporal-coupling] The transition owns that record;
627
+ * it is why a transition is made for one element.
506
628
  */
507
629
  function ownChange(curve, place) {
508
- const own = curve.seconds * OWN;
509
- // One phase, shifted per cell, so no cell builds its own each frame.
510
- const change = Phase.once(0, own);
511
- return (t) => (cell) => change(t - place(cell) * (curve.seconds - own));
630
+ const changes = new Map();
631
+ const key = (cell) => `${cell.row}:${cell.col}`;
632
+ return (cells) => {
633
+ const [first, last] = extent(cells.map(place));
634
+ const stagger = (last > first ? 1 - OWN : 0) * curve.seconds;
635
+ // One phase a frame, shifted per cell, so no cell builds its own.
636
+ const change = Phase.once(0, curve.seconds - stagger);
637
+ // A share of the way from the first place to the last, held to the order for a cell the frame does not list.
638
+ const settled = (cell) => {
639
+ const along = last > first ? clamp01((place(cell) - first) / (last - first)) : 0;
640
+ return { along, progress: (t) => change(t - along * stagger) };
641
+ };
642
+ for (const cell of cells) {
643
+ const at = key(cell);
644
+ changes.set(at, changes.get(at) ?? settled(cell));
645
+ }
646
+ // A cell the frame hands but does not list shows nothing yet, so it is timed for this frame alone.
647
+ return (cell) => changes.get(key(cell)) ?? settled(cell);
648
+ };
649
+ }
650
+ /** The least and greatest of `values`; of none, a frame with no cell to place, both 0. */
651
+ function extent(values) {
652
+ let lo = values[0] ?? 0;
653
+ let hi = lo;
654
+ for (const v of values) {
655
+ lo = Math.min(lo, v);
656
+ hi = Math.max(hi, v);
657
+ }
658
+ return [lo, hi];
512
659
  }
513
660
  /**
514
661
  * Fade-in, as ink blooming in water: patches of the element surface first
@@ -518,9 +665,9 @@ function ownChange(curve, place) {
518
665
  export function fadeIn(curve, z) {
519
666
  requireCurve(curve);
520
667
  const risen = ownChange(curve, order(1.7 + z));
521
- return transition(curve, veiled((t) => {
522
- const at = risen(t);
523
- return (cell) => curve.ease(at(cell));
668
+ return transition(curve, veiled((t, drawn) => {
669
+ const of = risen(drawn.cells());
670
+ return (cell) => curve.ease(of(cell).progress(t));
524
671
  }, curve.swing));
525
672
  }
526
673
  /**
@@ -543,16 +690,16 @@ const DISSOLVE_SHAPE = { depth: 0.4, from: 0.5, to: 0.95, maskFrom: 0.35, maskTo
543
690
  */
544
691
  export function dissolveOut(curve, z) {
545
692
  requireCurve(curve);
546
- const place = order(4.2 + z);
547
693
  const eddy = order(9.3 + z);
548
- const faded = ownChange(curve, place);
694
+ const faded = ownChange(curve, order(4.2 + z));
549
695
  const { depth, from, to, maskFrom, maskTo, late } = DISSOLVE_SHAPE;
550
- return transition(curve, veiled((t) => {
551
- const at = faded(t);
696
+ return transition(curve, veiled((t, drawn) => {
697
+ const of = faded(drawn.cells());
552
698
  return (cell) => {
553
- const gone = curve.ease(at(cell));
699
+ const { along, progress } = of(cell);
700
+ const gone = curve.ease(progress(t));
554
701
  const q = clamp01((gone - from) / (to - from));
555
- const back = depth * smoothstep(maskFrom, maskTo, eddy(cell)) * (1 - late + late * place(cell)) * Math.sin(Math.PI * q) ** 2;
702
+ const back = depth * smoothstep(maskFrom, maskTo, eddy(cell)) * (1 - late + late * along) * Math.sin(Math.PI * q) ** 2;
556
703
  return 1 - gone + back;
557
704
  };
558
705
  }, curve.swing));
@@ -581,9 +728,13 @@ const STRAY = 180;
581
728
  * together; on the terminal's ground, a run of text's ink is a segment of
582
729
  * its own. A segment's stray is slow noise of its own, at `STRAY_PERIODS`,
583
730
  * so no two keep step, and the row never shows one spread of hues twice.
584
- * Lightness and chroma are kept, so the words read as they did; the turn
585
- * only happens to the element's own colours (`Drawn.colors`), never the
586
- * terminal's.
731
+ * A colour keeps its luminance as it turns (`hueTurned`), so every pair of
732
+ * words reads at the contrast it rested at; at 256 colours a turned colour
733
+ * lands on a colour the depth writes, one that keeps every pair of words it
734
+ * is drawn in at its floor (`floorOf`) — its resting contrast or WCAG AA,
735
+ * whichever is lower — as `shares` keeps the light's (`turnedBounds`). The
736
+ * turn only happens to the element's own colours (`Drawn.colors`), never the
737
+ * terminal's. On a sixteen-colour terminal nothing turns (`stillOnSlots`).
587
738
  */
588
739
  export function wheel(curve, z) {
589
740
  requireCurve(curve);
@@ -597,13 +748,201 @@ export function wheel(curve, z) {
597
748
  const s = seed(hex);
598
749
  return (360 * t) / P + curve.swing * STRAY * ((stray(s, t) - stray(s, 0)) / 1.5);
599
750
  };
600
- return (t, { colors, fills }) => (shown, cell) => {
751
+ // A turned colour's bounds rest on what a frame draws, never on `t`: settled once for each.
752
+ const settled = new WeakMap();
753
+ return stillOnSlots((drawn) => {
754
+ const { colors, fills, depth, theme } = drawn;
601
755
  // Whether a slot shows one of the element's colours: never the terminal's, whatever its hex.
602
- const own = { fg: !cell.terminal.fg && colors.has(shown.fg.hex), bg: !cell.terminal.bg && colors.has(shown.bg.hex) };
603
- // An own colour turns with its segment: itself if a fill or beside no own colour, else the one beside it.
604
- const turn = (color, mine, other, theirs) => !mine ? color : Oklch.fromRgba(color).applyKey({ ...IDENTITY, hueShift: turned(fills.has(color.hex) || !theirs ? color.hex : other.hex, t) }).toRgba();
605
- return { fg: turn(shown.fg, own.fg, shown.bg, own.bg), bg: turn(shown.bg, own.bg, shown.fg, own.fg) };
756
+ const own = (terminal, color) => !terminal && colors.has(color.hex);
757
+ // The segment an own colour turns with: itself if a fill or beside no own colour, else the one beside it.
758
+ const segmentOf = (color, other, theirs) => (fills.has(color.hex) || !theirs ? color.hex : other.hex);
759
+ // A colour as it turns with a segment: written one way wherever it is drawn in a frame.
760
+ const keyOf = (color, segment) => `${color.hex} ${segment}`;
761
+ const memo = settled.get(theme) ?? new Memo();
762
+ settled.set(theme, memo);
763
+ const bounds = memo.get(drawnKey(drawn), () => turnedBounds(drawn.pairs, depth, (pair, side) => {
764
+ const other = side === "fg" ? "bg" : "fg";
765
+ return own(pair.terminal[side], pair[side]) ? keyOf(pair[side], segmentOf(pair[side], pair[other], own(pair.terminal[other], pair[other]))) : undefined;
766
+ }));
767
+ // 256 colours writes a turned colour as one it has an index for; truecolor
768
+ // as one of the two either side of its luminance.
769
+ const indexed = measuredIndexed(depth, theme);
770
+ return Animation.continuous((t) => {
771
+ const written = new Map();
772
+ const choose = (color, segment) => {
773
+ const turn = hueTurned(color, turned(segment, t));
774
+ return nearestWithin(turn.looks, color, depth === ColorDepth.TRUECOLOR ? turn.either.map(measured) : indexed, bounds.get(keyOf(color, segment)) ?? UNBOUNDED);
775
+ };
776
+ const write = (color, segment) => {
777
+ const found = written.get(keyOf(color, segment)) ?? choose(color, segment);
778
+ written.set(keyOf(color, segment), found);
779
+ return found;
780
+ };
781
+ return (shown, cell) => {
782
+ const mine = { fg: own(cell.terminal.fg, shown.fg), bg: own(cell.terminal.bg, shown.bg) };
783
+ const turn = (color, ours, other, theirs) => (ours ? write(color, segmentOf(color, other, theirs)) : color);
784
+ return { fg: turn(shown.fg, mine.fg, shown.bg, mine.bg), bg: turn(shown.bg, mine.bg, shown.fg, mine.fg) };
785
+ };
786
+ });
787
+ });
788
+ }
789
+ /**
790
+ * `color` turned `degrees` round the hue circle at its own relative
791
+ * luminance, and at its own chroma where the gamut has it. WCAG contrast is
792
+ * a ratio of luminances, so a pair of colours each turned this way reads at
793
+ * the contrast it did; one turned at its OKLCH lightness instead does not,
794
+ * since equal lightness is not equal luminance across hues. The lightness
795
+ * is found by bisection, as `ensureContrastToward` finds one, and 8-bit
796
+ * rounding lands the luminance between two colours, so both are kept. At 0
797
+ * degrees the colour itself, exactly: an OKLCH round trip can land a step
798
+ * off (`touchedAt`).
799
+ */
800
+ function hueTurned(color, degrees) {
801
+ if (degrees === 0)
802
+ return { looks: color, either: [color] };
803
+ const from = Oklch.fromRgba(color);
804
+ const luminance = relativeLuminance(color);
805
+ const at = (l) => new Oklch(l, from.c, from.h + degrees, from.alpha).toRgba();
806
+ let [dim, bright] = [0, 1];
807
+ for (let i = 0; i < 24; i++) {
808
+ const mid = (dim + bright) / 2;
809
+ [dim, bright] = relativeLuminance(at(mid)) < luminance ? [mid, bright] : [dim, mid];
810
+ }
811
+ return { looks: at(bright), either: [at(dim), at(bright)] };
812
+ }
813
+ /**
814
+ * Where a colour sits on WCAG's contrast scale: the log of its contrast with
815
+ * black. Two colours' contrast ratio is `exp` of the gap between their
816
+ * stops, so a contrast to keep is a distance a colour may move.
817
+ */
818
+ const stop = (color) => Math.log(contrastRatio(color, new ColorRgba(0, 0, 0)));
819
+ const UNBOUNDED = { lo: -Infinity, hi: Infinity };
820
+ /** Bounds that admit no turn: the colour is written as it rests. */
821
+ const STILL = { lo: Infinity, hi: -Infinity };
822
+ /**
823
+ * The widest 8-bit rounding parts `hueTurned`'s two colours, in stops. They
824
+ * are one point of the hue rounded two ways, so they differ by at most a
825
+ * step in each channel, and a channel of a colour that dark or light can
826
+ * take a step only so large: worst near a luminance of 0.05, at 3.03% of
827
+ * contrast. Bounds at least this wide admit one of the two whatever the
828
+ * hue; narrower, a colour would turn at some hues and snap back to rest at
829
+ * the next, so it holds still.
830
+ */
831
+ const ROUNDING_GAP = Math.log(1.031);
832
+ /**
833
+ * How far each turned colour's luminance may land from the one it rests at,
834
+ * keyed as `turnedAs` keys each side of a pair — none for a side that holds
835
+ * still — so that every pair of `pairs` reads at its floor (`floorOf`) at
836
+ * `depth` however each of its colours lands within its bounds. Bounds
837
+ * narrower than `ROUNDING_GAP` are `STILL`.
838
+ *
839
+ * A pair's contrast to spare above its floor, as a gap between stops, is
840
+ * shared out evenly between its sides that turn: the lighter may land that
841
+ * much darker, the darker that much lighter, and never further. So a colour
842
+ * written once for every pair it is drawn in is bounded by each of them at
843
+ * once, and a frame writes it one way wherever it falls. A pair whose floor
844
+ * is 1:1 holds however it is drawn, and bounds nothing.
845
+ */
846
+ function turnedBounds(pairs, depth, turnedAs) {
847
+ const bounds = new Map();
848
+ for (const pair of pairs.filter((pair) => floorOf(pair, depth) > 1)) {
849
+ const spare = Math.log(contrastRatio(pair.fg, pair.bg)) - Math.log(floorOf(pair, depth));
850
+ const share = spare / SIDES.filter((side) => turnedAs(pair, side) !== undefined).length;
851
+ for (const side of SIDES) {
852
+ const key = turnedAs(pair, side);
853
+ if (key === undefined)
854
+ continue;
855
+ const [mine, theirs] = [stop(pair[side]), stop(pair[side === "fg" ? "bg" : "fg"])];
856
+ const { lo, hi } = bounds.get(key) ?? UNBOUNDED;
857
+ bounds.set(key, mine >= theirs ? { lo: Math.max(lo, mine - share), hi } : { lo, hi: Math.min(hi, mine + share) });
858
+ }
859
+ }
860
+ return new Map([...bounds].map(([key, { lo, hi }]) => [key, hi - lo < ROUNDING_GAP ? STILL : { lo, hi }]));
861
+ }
862
+ const measured = (color) => ({ color, looks: Oklch.fromRgba(color), stop: stop(color) });
863
+ /** `ColorSpec.indexed`'s colours, measured once for each theme and depth. */
864
+ const measuredMemo = new WeakMap();
865
+ function measuredIndexed(depth, theme) {
866
+ const indexed = ColorSpec.indexed(depth, theme);
867
+ const listed = measuredMemo.get(indexed) ?? indexed.map(([, color]) => measured(color));
868
+ measuredMemo.set(indexed, listed);
869
+ return listed;
870
+ }
871
+ /**
872
+ * The colour that looks nearest `color` (OKLab's ΔE): `rest`, or one of
873
+ * `candidates` whose luminance is within `bounds`. `rest` is what the bounds
874
+ * are measured from, so it is always offered; ties go to it, then to the
875
+ * earlier.
876
+ */
877
+ function nearestWithin(color, rest, candidates, { lo, hi }) {
878
+ return nearestOf(color, [measured(rest), ...candidates.filter(({ stop }) => stop >= lo && stop <= hi)]).color;
879
+ }
880
+ /** Of `offered`, the one that looks nearest `color` (OKLab's ΔE), ties to the earlier; none when nothing is offered. */
881
+ function nearestOf(color, offered) {
882
+ const looks = Oklch.fromRgba(color);
883
+ let best;
884
+ for (const candidate of offered) {
885
+ const apart = looks.deltaE(candidate.looks);
886
+ if (best === undefined || apart < best[1])
887
+ best = [candidate, apart];
888
+ }
889
+ return best?.[0];
890
+ }
891
+ /**
892
+ * A signal: every cell's ink stepping through `steps`' colours on a beat,
893
+ * one colour a hold, looping, for the time it is handed — a gauge's
894
+ * chevrons in bright changing colours. The ground is left as the cell draws
895
+ * it, and the words stay legible on it: each step's ink is written as the
896
+ * colour nearest the step's (OKLab's ΔE) that the depth draws and that
897
+ * reads on the cell's ground, as `Effected` writes it, at the cell's floor
898
+ * (`floorOf`) — its resting contrast or `LEGIBLE`, whichever is lower, the
899
+ * floor every loop here keeps. In truecolor that is the step itself, or its
900
+ * hue and chroma at the lightness nearest it that reaches the floor
901
+ * (`ensureContrast`, the one slide a template's `readableOn` makes too);
902
+ * below truecolor, the nearest of the colours the depth has an index for, so on a
903
+ * sixteen-colour terminal it steps from slot to slot, the one effect here
904
+ * that does (`stillOnSlots`). A cell whose ground no offered colour reads
905
+ * on keeps the ink it rested in.
906
+ *
907
+ * The search is settled once for each ink, ground and step, so a frame
908
+ * inside a step redoes none, and nor does a later pass of the beat: at sixty
909
+ * frames a second a one-second step is sixty identical frames, and the
910
+ * frame changes when the beat does: the signal is the beat mapped to inks.
911
+ * [LAW:no-ambient-temporal-coupling] The beat is `core/easing.ts`'s, and
912
+ * nothing here reads a clock.
913
+ */
914
+ export function steppedInk(steps) {
915
+ const rhythm = beat(steps.map((step) => step.seconds));
916
+ // A copy, as `beat` keeps of the holds: what the signal plays stays what
917
+ // `steps` said whatever the caller does to its array.
918
+ const inks = steps.map((step) => step.color);
919
+ const settled = new WeakMap();
920
+ return ({ depth, theme }) => {
921
+ const memo = settled.get(theme) ?? new Memo();
922
+ settled.set(theme, memo);
923
+ return Animation.map(rhythm, (i) => {
924
+ const step = inks[i];
925
+ return (colors, cell) => {
926
+ const pair = { ...colors, terminal: cell.terminal, reverse: cell.reverse };
927
+ return { fg: memo.get(`${depth} ${pairKey(pair)} ${step.hex}`, () => legibleInk(step, pair, depth, theme)), bg: colors.bg };
928
+ };
929
+ });
930
+ };
931
+ }
932
+ /**
933
+ * The colour nearest `step` the depth writes that reads on `pair`'s ground
934
+ * at `pair`'s floor, as `Effected` writes it (`shows`), or `pair`'s own ink
935
+ * when none does. [LAW:one-source-of-truth] Measured the way `shares` and
936
+ * `wheel` measure: the written colours, at the catalogue's floor.
937
+ */
938
+ function legibleInk(step, pair, depth, theme) {
939
+ const floor = floorOf(pair, depth);
940
+ const offered = depth === ColorDepth.TRUECOLOR ? [step, ensureContrast(step, pair.bg, floor)].map(measured) : measuredIndexed(depth, theme);
941
+ const reads = ({ color }) => {
942
+ const shown = Effected.shows(pair, { fg: color, bg: pair.bg }, depth, theme);
943
+ return contrastRatio(shown.fg, shown.bg) >= floor;
606
944
  };
945
+ return nearestOf(step, offered.filter(reads))?.color ?? pair.fg;
607
946
  }
608
947
  /**
609
948
  * Every effect in the catalogue by its name in `EFFECT_CURVES`, each made the
@@ -618,9 +957,9 @@ export function wheel(curve, z) {
618
957
  */
619
958
  export const EFFECT_CATALOGUE = {
620
959
  loops: {
621
- shimmer: (element, curve = EFFECT_CURVES.shimmer) => onColors(shimmer(curve, element.span, SHIMMER_WIDTH, EFFECT_LIGHTS.sun, element.z)),
960
+ shimmer: (element, curve = EFFECT_CURVES.shimmer) => onColors(shimmer(curve, SHIMMER_WIDTH, EFFECT_LIGHTS.sun, element.z)),
622
961
  pulse: (element, curve = EFFECT_CURVES.pulse) => onColors(pulse(curve, EFFECT_LIGHTS.sun, element.z)),
623
- sparkle: (element, curve = EFFECT_CURVES.sparkle) => onColors(sparkle(curve, element.span, EFFECT_LIGHTS.firefly, element.z)),
962
+ sparkle: (element, curve = EFFECT_CURVES.sparkle) => onColors(sparkle(curve, EFFECT_LIGHTS.firefly, element.z)),
624
963
  wheel: (element, curve = EFFECT_CURVES.wheel) => wheel(curve, element.z),
625
964
  },
626
965
  transitions: {