@promptctl/rich-js 0.20.0 → 0.22.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.
@@ -3,7 +3,8 @@
3
3
  * long as they are drawn, and fade-in and dissolve-out, which run once and
4
4
  * are done. Each is an `Effect` for `Effected` (`./effect.ts`); the defaults
5
5
  * in `EFFECT_CURVES` are the ones signed off by eye in the effects-feel demo
6
- * (`npm run effects-feel`), at 30 fps and at one frame a second.
6
+ * (`npm run effects-feel`), at 30 fps and at one frame a second, and
7
+ * `EFFECT_CATALOGUE` makes every one of them at those, by name, one way.
7
8
  *
8
9
  * Each effect is something a first-time observer already knows from life —
9
10
  * a slow glow, sunlight moving on water, the colours of daylight, fireflies, ink
@@ -36,15 +37,17 @@
36
37
  * [LAW:no-ambient-temporal-coupling] No curve reads a clock; `t` is the
37
38
  * effect's argument, so the frame owner decides when to sample.
38
39
  *
39
- * Every import below names something the package exports, but for `noise`,
40
- * which the docs' effects playground page supplies itself: each of its
40
+ * Every import below names something the package exports, but for `noise`
41
+ * and `Memo`, which the docs' effects playground page supplies itself: each of its
41
42
  * cards runs this file's own declarations as the program a visitor edits
42
43
  * (`examples/effects-playground/programs.ts`), on the published library.
43
44
  */
44
- import { ColorRgba, contrastRatio } from "../core/color.js";
45
+ import { ColorDepth, ColorRgba, contrastRatio } from "../core/color.js";
45
46
  import { EASES, Phase } from "../core/easing.js";
46
47
  import { fbm, hash, noise, smoothstep } from "../core/noise.js";
48
+ import { Memo } from "../core/memo.js";
47
49
  import { IDENTITY, Oklch } from "../core/oklch.js";
50
+ import { Effected } from "./effect.js";
48
51
  /** The contrast no touch takes a cell below, unless it rested below it: WCAG AA for body text. */
49
52
  const LEGIBLE = 4.5;
50
53
  /**
@@ -58,13 +61,15 @@ const LEVELS = 48;
58
61
  const STRENGTHS = Array.from({ length: LEVELS }, (_, i) => (i + 1) / LEVELS);
59
62
  /** What 8-bit rounding of an OKLCH round trip can cost a contrast ratio. */
60
63
  const ROUNDING = 0.01;
64
+ const SIDES = ["fg", "bg"];
61
65
  /**
62
66
  * How much of `touch` each of `colors` (hex) can take: the largest share in
63
- * [0, 1] at which every pair in `pairs` that shows it, touched at the same
67
+ * [0, 1] at which every pair `drawn` shows it in, touched at the same
64
68
  * strength, still reads at its resting contrast or `LEGIBLE`, whichever is
65
- * lower. A colour in a pair but not in `colors` takes none, and so does one
69
+ * lower, in the colours `Effected` writes it in at `drawn`'s depth and theme.
70
+ * A colour in a pair but not in `colors` takes none, and so does one
66
71
  * the terminal supplies, whatever its hex, as in `onColors`. One of `colors`
67
- * no pair writes has nothing to be measured against, and throws.
72
+ * no pair writes has nothing to be measured against, and takes it all.
68
73
  *
69
74
  * Colours are settled lightest first, each against every partner: one
70
75
  * already settled at its share, one not yet settled as it is. So the lighter
@@ -73,28 +78,38 @@ const ROUNDING = 0.01;
73
78
  * the fill room to glow — and a darker colour left as it is always holds,
74
79
  * because its lighter partner was settled against exactly that.
75
80
  */
76
- export function shares(pairs, colors, touch) {
81
+ export function shares({ pairs, depth, theme }, colors, touch) {
77
82
  // [LAW:one-source-of-truth] A slot shows a subject colour, by hex, only
78
83
  // where the element writes it: the reading `onColors` lights by.
79
- const sides = pairs.map(({ fg, bg, terminal }) => [
80
- { color: fg, own: terminal.fg ? undefined : fg.hex },
81
- { color: bg, own: terminal.bg ? undefined : bg.hex },
82
- ]);
83
- const rgba = new Map(sides.flat().flatMap(({ color, own }) => (own === undefined ? [] : [[own, color]])));
84
- const unpaired = [...colors].filter((hex) => !rgba.has(hex));
85
- if (unpaired.length > 0)
86
- throw new RangeError(`shares: ${unpaired.join(", ")} written in no pair, so nothing to keep legible against`);
84
+ const own = (pair, side) => (pair.terminal[side] ? undefined : pair[side].hex);
85
+ const rgba = new Map(pairs.flatMap((pair) => SIDES.flatMap((side) => {
86
+ const hex = own(pair, side);
87
+ return hex === undefined ? [] : [[hex, pair[side]]];
88
+ })));
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
+ // A colour no pair writes keeps no word legible, so nothing bounds its share:
92
+ // a frame drawing a lit colour only under blanks and seams, or not at all,
93
+ // is a frame like any other.
94
+ const settled = new Map([...colors].filter((hex) => !rgba.has(hex)).map((hex) => [hex, 1]));
87
95
  const lightness = (hex) => Oklch.fromRgba(rgba.get(hex)).l;
88
- const settled = new Map();
89
- for (const hex of [...colors].sort((a, b) => lightness(b) - lightness(a))) {
90
- const partners = sides.flatMap(([fg, bg]) => {
91
- const partner = fg.own === hex ? bg : bg.own === hex ? fg : undefined;
92
- return partner === undefined ? [] : [[partner.color, partner.own === undefined ? 0 : (settled.get(partner.own) ?? 0)]];
96
+ for (const hex of [...colors].filter((hex) => rgba.has(hex)).sort((a, b) => lightness(b) - lightness(a))) {
97
+ // Each pair showing the colour, with the side it shows it on and the share its partner takes.
98
+ const facing = pairs.flatMap((pair) => {
99
+ const mine = SIDES.find((side) => own(pair, side) === hex);
100
+ if (mine === undefined)
101
+ return [];
102
+ const partner = own(pair, mine === "fg" ? "bg" : "fg");
103
+ return [[pair, mine, partner === undefined ? 0 : (settled.get(partner) ?? 0)]];
93
104
  });
94
- const color = rgba.get(hex);
95
- const holds = (share) => partners.every(([partner, theirs]) => {
96
- const floor = Math.min(contrastRatio(color, partner), LEGIBLE);
97
- return STRENGTHS.every((w) => contrastRatio(touchedAt(touch, color, share * w), touchedAt(touch, partner, theirs * w)) >= floor - ROUNDING);
105
+ // [LAW:one-source-of-truth] A pair reads in the colours `Effected` writes it in, touched at each side's share.
106
+ const holds = (share) => facing.every(([pair, mine, theirs]) => {
107
+ const floor = Math.min(contrastRatio(pair.fg, pair.bg), LEGIBLE) - slack;
108
+ const at = (side, w) => touchedAt(touch, pair[side], (side === mine ? share : theirs) * w);
109
+ return STRENGTHS.every((w) => {
110
+ const shown = Effected.shows(pair, { fg: at("fg", w), bg: at("bg", w) }, depth, theme);
111
+ return contrastRatio(shown.fg, shown.bg) >= floor;
112
+ });
98
113
  });
99
114
  let [lo, hi] = holds(1) ? [1, 1] : [0, 1];
100
115
  for (let i = 0; i < 20 && hi - lo > 1e-3; i++) {
@@ -106,18 +121,46 @@ export function shares(pairs, colors, touch) {
106
121
  return settled;
107
122
  }
108
123
  /**
109
- * A loop as an `Effect` on a subject's own colours, by hex, wherever a cell
110
- * shows them, each touched at its share of the cell's strength. A powerline
111
- * strip's fill is the ground of its cells and the ink of the seam glyphs
112
- * between them, so the arrow moves with the cell it points out of. A colour
113
- * the terminal supplies (`EffectCell.terminal`) is not the subject's, whatever
114
- * its hex, and takes no share.
124
+ * A loop as an `Effect` on the element's own colours, by hex, wherever a cell
125
+ * shows them, each touched at its share of the cell's strength: the share
126
+ * `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
130
+ * sets itself. A powerline strip's fill is the ground of its cells and the
131
+ * ink of the seam glyphs between them, so the arrow moves with the cell it
132
+ * points out of. A colour the terminal supplies (`EffectCell.terminal`) is
133
+ * not the element's, whatever its hex, and takes no share.
134
+ *
135
+ * Settling shares is costly, so it is done once for each set of colours an
136
+ * element draws: make the effect once and draw it in every frame, and a frame
137
+ * drawing what an earlier one drew costs no settling.
138
+ */
139
+ export function onColors(loop, colors) {
140
+ const settled = new WeakMap();
141
+ const settle = (drawn) => {
142
+ 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(" ");
144
+ const memo = settled.get(drawn.theme) ?? new Memo();
145
+ settled.set(drawn.theme, memo);
146
+ return memo.get(key, () => shares(drawn, lit, loop.touch));
147
+ };
148
+ return atShares(loop, settle);
149
+ }
150
+ /**
151
+ * A loop as an `Effect` on colours by hex, each touched at the share
152
+ * `share` gives it of the cell's strength in a frame drawing what `Drawn`
153
+ * says; a colour it gives none, and one the terminal supplies, stays as it is.
115
154
  */
116
- export function onColors(share, loop) {
117
- return ({ fg, bg }, cell, t) => {
118
- const w = Math.round(clamp01(loop.field(cell, t)) * LEVELS) / LEVELS;
119
- const moved = (color, terminal) => touchedAt(loop.touch, color, (terminal ? 0 : (share.get(color.hex) ?? 0)) * w);
120
- return { fg: moved(fg, cell.terminal.fg), bg: moved(bg, cell.terminal.bg) };
155
+ export function atShares(loop, share) {
156
+ return (t, drawn) => {
157
+ 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
+ };
121
164
  };
122
165
  }
123
166
  /**
@@ -246,14 +289,15 @@ export function pulse(curve, glow, z) {
246
289
  const WANDER = 0.1;
247
290
  const wander = (t) => WANDER * noise(t / (3 * curve.seconds), 0.5, 0.5 + z);
248
291
  const start = wander(0);
249
- const field = (_cell, t) => {
292
+ const field = (t) => {
250
293
  const beats = (rate * t) / curve.seconds + wander(t) - start;
251
294
  const n = Math.floor(beats);
252
295
  // Each beat's own depth, 70% to the whole of the swing; it changes where
253
296
  // the beat is at rest, so nothing jumps.
254
297
  const depth = 0.7 + 0.3 * smoothstep(-0.5, 0.5, noise(n * 0.37, 2.1, z));
255
298
  const beat = 0.5 - 0.5 * Math.cos(2 * Math.PI * (beats - n));
256
- return curve.swing * curve.ease(depth * beat);
299
+ const level = curve.swing * curve.ease(depth * beat);
300
+ return () => level;
257
301
  };
258
302
  return { touch: light(glow), field };
259
303
  }
@@ -313,35 +357,31 @@ export function shimmer(curve, span, width, glow, z) {
313
357
  const centre = q * across - width + 0.06 * across * Math.sin(Math.PI * q) * noise(q, 2.7, j + z);
314
358
  return { strength, centre };
315
359
  };
316
- // The passes on the row at one moment: a frame asks for the same moment
317
- // once per cell, so they are worked out once per moment.
318
- let passing = { t: Number.NaN, passes: [] };
319
- // Where two passes overlap the brighter wins, so light never doubles up;
320
- // the first slot is 0, so before it nothing is lit.
321
- const band = (col, t) => {
322
- if (passing.t !== t) {
323
- const last = Math.floor(t / SLOT);
324
- const passes = [];
325
- for (let j = Math.max(0, Math.ceil(last - UP)); j <= last; j++)
326
- passes.push(pass(j, t));
327
- passing = { t, passes };
328
- }
329
- let lit = 0;
330
- for (const { strength, centre } of passing.passes)
331
- lit = Math.max(lit, strength * bump((col - centre) / width));
332
- return lit;
360
+ // The passes on the row at `t`; the first slot is 0, so before it there are none.
361
+ const passing = (t) => {
362
+ const last = Math.floor(t / SLOT);
363
+ const passes = [];
364
+ for (let j = Math.max(0, Math.ceil(last - UP)); j <= last; j++)
365
+ passes.push(pass(j, t));
366
+ return passes;
333
367
  };
334
368
  // A ripple's crest: a Gaussian ridge, 1 where the field crosses zero and in
335
369
  // (0, 1] for any noise, smooth throughout, so a crest sliding through a cell
336
370
  // lights it smoothly.
337
371
  const ripple = (a, b, c) => Math.exp(-((noise(a, b, c) / 0.8) ** 2));
338
- const field = (cell, t) => {
339
- const lit = band(cell.col, t);
340
- const row = cell.row + z;
341
- const current = 0.8 * noise(cell.col * 0.05, row * 0.3 + 1.3, t * 0.02);
342
- 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)) **
343
- 2;
344
- return curve.swing * curve.ease(lit * (0.1 + 0.6 * caustic));
372
+ const field = (t) => {
373
+ const passes = passing(t);
374
+ return (cell) => {
375
+ // Where two passes overlap the brighter wins, so light never doubles up.
376
+ let lit = 0;
377
+ for (const { strength, centre } of passes)
378
+ lit = Math.max(lit, strength * bump((cell.col - centre) / width));
379
+ const row = cell.row + z;
380
+ const current = 0.8 * noise(cell.col * 0.05, row * 0.3 + 1.3, t * 0.02);
381
+ 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)) **
382
+ 2;
383
+ return curve.swing * curve.ease(lit * (0.1 + 0.6 * caustic));
384
+ };
345
385
  };
346
386
  return { touch: light(glow), field };
347
387
  }
@@ -373,67 +413,64 @@ export function sparkle(curve, span, glow, z) {
373
413
  // Out of step with every other firefly from the first moment.
374
414
  offset: hash(i, 0.7 + z) * TURN,
375
415
  }));
376
- // Where every firefly is and how bright at one moment: a frame asks for
377
- // the same moment once per cell, so it is worked out once per moment.
378
- let flown = { t: Number.NaN, flies: [] };
379
- const fliesAt = (t) => {
380
- if (flown.t !== t) {
381
- flown = {
382
- t,
383
- flies: homes.map((fly) => {
384
- // Flash `n` is one event: whether it comes, how bright, when in
385
- // its turn and where are its own. Between flashes the firefly
386
- // is dark, so where it moves to is never seen moving.
387
- const u = t + fly.offset;
388
- const n = Math.floor(u / TURN);
389
- // A dark turn is a flash with no light: it keeps its length, so
390
- // every flash is timed alike.
391
- const lit = hash(n, fly.z + 1) < 0.15 ? 0 : 1;
392
- const k = 0.5 + 0.5 * hash(n, fly.z + 2);
393
- const at = hash(n, fly.z + 3) * (1 - lasts(FLASH, k));
394
- const s = u / TURN - n - at;
395
- // While it glows it drifts a few cells one way or the other, as
396
- // fast as it is bright: quickest at its height, slowing as it
397
- // fades — the lit stroke a firefly draws on the dusk. A brighter
398
- // flash lasts longer and draws a longer stroke at the same pace.
399
- return {
400
- x: fly.home +
401
- HOP * (2 * hash(n, fly.z + 4) - 1) +
402
- GLIDE * k * (2 * hash(n, fly.z + 5) - 1) * given(FLASH, s, k) +
403
- 1.5 * noise(fly.z, 0.1, t * 0.035),
404
- y: 0.6 * noise(fly.z, 2.2, t * 0.035),
405
- glow: lit * swell(FLASH, s, k),
406
- };
407
- }),
408
- };
409
- }
410
- return flown.flies;
411
- };
412
- const field = (cell, t) => {
413
- // Where two halos overlap the brighter wins, so light never doubles up.
414
- let shine = 0;
415
- for (const fly of fliesAt(t))
416
- shine = Math.max(shine, fly.glow * bump(Math.hypot(cell.col - fly.x, 2 * (cell.row - fly.y)) / HALO));
417
- return curve.swing * curve.ease(shine);
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;
422
+ const n = Math.floor(u / TURN);
423
+ // A dark turn is a flash with no light: it keeps its length, so
424
+ // 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));
428
+ const s = u / TURN - n - at;
429
+ // While it glows it drifts a few cells one way or the other, as
430
+ // fast as it is bright: quickest at its height, slowing as it
431
+ // fades — the lit stroke a firefly draws on the dusk. A brighter
432
+ // flash lasts longer and draws a longer stroke at the same pace.
433
+ 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),
440
+ };
441
+ });
442
+ const field = (t) => {
443
+ const flies = fliesAt(t);
444
+ return (cell) => {
445
+ // Where two halos overlap the brighter wins, so light never doubles up.
446
+ 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));
449
+ return curve.swing * curve.ease(shine);
450
+ };
418
451
  };
419
452
  return { touch: light(glow), field };
420
453
  }
421
454
  /**
422
- * A cell mixed toward the terminal's `ground` by how hidden it is: its fill
423
- * toward the ground, and its ink toward that fill, so an element with fills
424
- * of its own — a powerline strip — arrives and leaves whole, fill and words
425
- * together, never as bare coloured blocks. Invisible is ink and fill equal
426
- * to the ground. `swing` scales the hiding — 1 reaches fully invisible. A
427
- * cell wholly shown is returned as it was.
455
+ * A cell mixed toward the ground of the terminal the frame is drawn on
456
+ * (`Drawn.theme`) by how hidden it is: its fill toward the ground, and its ink
457
+ * toward that fill, so an element with fills of its own — a powerline strip —
458
+ * arrives and leaves whole, fill and words together, never as bare coloured
459
+ * blocks. Invisible is ink and fill equal to the ground. `swing` scales the
460
+ * hiding — 1 reaches fully invisible. A cell wholly shown is returned as it was.
428
461
  */
429
- export function veiled(visibility, swing, ground) {
430
- const to = Oklch.fromRgba(ground);
431
- return (colors, cell, t) => {
432
- const hidden = swing * (1 - clamp01(visibility(cell, t)));
433
- if (hidden === 0)
434
- return colors;
435
- const bg = blend(Oklch.fromRgba(colors.bg), to, hidden);
436
- return { fg: blend(Oklch.fromRgba(colors.fg), bg, hidden).toRgba(), bg: bg.toRgba() };
462
+ export function veiled(visibility, swing) {
463
+ return (t, { theme }) => {
464
+ const shows = visibility(t);
465
+ // [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
+ };
437
474
  };
438
475
  }
439
476
  /**
@@ -454,9 +491,8 @@ function order(z) {
454
491
  return place;
455
492
  };
456
493
  }
457
- function transition(curve, start, effect) {
458
- const end = start + curve.seconds;
459
- return { effect, done: (t) => t >= end };
494
+ function transition(curve, effect) {
495
+ return { effect, done: (t) => t >= curve.seconds };
460
496
  }
461
497
  /**
462
498
  * Of a transition's `seconds`, how long each cell's own change takes. Under
@@ -464,26 +500,28 @@ function transition(curve, start, effect) {
464
500
  */
465
501
  const OWN = 0.45;
466
502
  /**
467
- * Each cell's progress through its own change in a transition starting at
468
- * `start`, in [0, 1]: a cell `place` along the order starts that share of the
469
- * way through the time the others leave it, and takes `OWN` of `seconds`.
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`.
470
506
  */
471
- function ownChange(curve, start, place) {
507
+ function ownChange(curve, place) {
472
508
  const own = curve.seconds * OWN;
473
509
  // One phase, shifted per cell, so no cell builds its own each frame.
474
- const change = Phase.once(start, own);
475
- return (cell, t) => change(t - place(cell) * (curve.seconds - own));
510
+ const change = Phase.once(0, own);
511
+ return (t) => (cell) => change(t - place(cell) * (curve.seconds - own));
476
512
  }
477
513
  /**
478
- * Fade-in starting at `start`, as ink blooming in water: patches of the
479
- * element surface first and the rest follows outward from them, each cell
480
- * rising smoothly over its own part of the duration. Whole at
481
- * `start + seconds`.
514
+ * Fade-in, as ink blooming in water: patches of the element surface first
515
+ * and the rest follows outward from them, each cell rising smoothly over its
516
+ * own part of the duration. Whole at `seconds`.
482
517
  */
483
- export function fadeIn(curve, start, z, ground) {
518
+ export function fadeIn(curve, z) {
484
519
  requireCurve(curve);
485
- const risen = ownChange(curve, start, order(1.7 + z));
486
- return transition(curve, start, veiled((cell, t) => curve.ease(risen(cell, t)), curve.swing, ground));
520
+ 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));
524
+ }, curve.swing));
487
525
  }
488
526
  /**
489
527
  * The rebound of a dissolve, every constant that makes it feel as it does —
@@ -496,25 +534,28 @@ export function fadeIn(curve, start, z, ground) {
496
534
  */
497
535
  const DISSOLVE_SHAPE = { depth: 0.4, from: 0.5, to: 0.95, maskFrom: 0.35, maskTo: 0.75, late: 0.6 };
498
536
  /**
499
- * Dissolve-out starting at `start`, as mist lifting: the element thins in
500
- * drifting patches, each cell fading smoothly over its own part of the
501
- * duration, until nothing is left at `start + seconds`. Mist does not lift
537
+ * Dissolve-out, as mist lifting: the element thins in drifting patches, each
538
+ * cell fading smoothly over its own part of the duration, until nothing is
539
+ * left at `seconds`. Mist does not lift
502
540
  * in one breath: towards the end, patches of what is left — the later a cell
503
541
  * goes, the likelier — rise back part way, as the last of it eddies, before
504
542
  * they thin away for good.
505
543
  */
506
- export function dissolveOut(curve, start, z, ground) {
544
+ export function dissolveOut(curve, z) {
507
545
  requireCurve(curve);
508
546
  const place = order(4.2 + z);
509
547
  const eddy = order(9.3 + z);
510
- const faded = ownChange(curve, start, place);
548
+ const faded = ownChange(curve, place);
511
549
  const { depth, from, to, maskFrom, maskTo, late } = DISSOLVE_SHAPE;
512
- return transition(curve, start, veiled((cell, t) => {
513
- const gone = curve.ease(faded(cell, t));
514
- const q = clamp01((gone - from) / (to - from));
515
- const back = depth * smoothstep(maskFrom, maskTo, eddy(cell)) * (1 - late + late * place(cell)) * Math.sin(Math.PI * q) ** 2;
516
- return 1 - gone + back;
517
- }, curve.swing, ground));
550
+ return transition(curve, veiled((t) => {
551
+ const at = faded(t);
552
+ return (cell) => {
553
+ const gone = curve.ease(at(cell));
554
+ 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;
556
+ return 1 - gone + back;
557
+ };
558
+ }, curve.swing));
518
559
  }
519
560
  /**
520
561
  * The periods, in seconds, of the two noises a segment's stray from the
@@ -541,9 +582,10 @@ const STRAY = 180;
541
582
  * its own. A segment's stray is slow noise of its own, at `STRAY_PERIODS`,
542
583
  * so no two keep step, and the row never shows one spread of hues twice.
543
584
  * Lightness and chroma are kept, so the words read as they did; the turn
544
- * only happens to `colors`, the element's own, never the terminal's.
585
+ * only happens to the element's own colours (`Drawn.colors`), never the
586
+ * terminal's.
545
587
  */
546
- export function wheel(curve, colors, fills, z) {
588
+ export function wheel(curve, z) {
547
589
  requireCurve(curve);
548
590
  const P = curve.seconds;
549
591
  // A segment's seed, from the colour it is.
@@ -555,11 +597,34 @@ export function wheel(curve, colors, fills, z) {
555
597
  const s = seed(hex);
556
598
  return (360 * t) / P + curve.swing * STRAY * ((stray(s, t) - stray(s, 0)) / 1.5);
557
599
  };
558
- return (drawn, cell, t) => {
600
+ return (t, { colors, fills }) => (shown, cell) => {
559
601
  // Whether a slot shows one of the element's colours: never the terminal's, whatever its hex.
560
- const own = { fg: !cell.terminal.fg && colors.has(drawn.fg.hex), bg: !cell.terminal.bg && colors.has(drawn.bg.hex) };
602
+ const own = { fg: !cell.terminal.fg && colors.has(shown.fg.hex), bg: !cell.terminal.bg && colors.has(shown.bg.hex) };
561
603
  // An own colour turns with its segment: itself if a fill or beside no own colour, else the one beside it.
562
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();
563
- return { fg: turn(drawn.fg, own.fg, drawn.bg, own.bg), bg: turn(drawn.bg, own.bg, drawn.fg, own.fg) };
605
+ return { fg: turn(shown.fg, own.fg, shown.bg, own.bg), bg: turn(shown.bg, own.bg, shown.fg, own.fg) };
564
606
  };
565
607
  }
608
+ /**
609
+ * Every effect in the catalogue by its name in `EFFECT_CURVES`, each made the
610
+ * one way: for an element, at its signed-off curve unless handed another,
611
+ * with the light and the width it was signed off with. A loop comes lit on
612
+ * the element's own colours (`onColors`); a transition comes as itself, to
613
+ * be told when it is done. [LAW:types-are-the-program] Keyed by the two
614
+ * name types, which `EFFECT_CURVES` is held to as well: a curve signed off
615
+ * does not compile until it is named a loop or a transition, and then not
616
+ * until it has an entry here making one. A picker walking a section's names
617
+ * offers an effect added here with no change of its own.
618
+ */
619
+ export const EFFECT_CATALOGUE = {
620
+ loops: {
621
+ shimmer: (element, curve = EFFECT_CURVES.shimmer) => onColors(shimmer(curve, element.span, SHIMMER_WIDTH, EFFECT_LIGHTS.sun, element.z)),
622
+ 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)),
624
+ wheel: (element, curve = EFFECT_CURVES.wheel) => wheel(curve, element.z),
625
+ },
626
+ transitions: {
627
+ fade: (element, curve = EFFECT_CURVES.fade) => fadeIn(curve, element.z),
628
+ dissolve: (element, curve = EFFECT_CURVES.dissolve) => dissolveOut(curve, element.z),
629
+ },
630
+ };
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { Segment } from "../core/segment.js";
19
19
  import type { Renderable, Measurable, RenderOptions } from "../core/protocol.js";
20
- import type { Joiner, StyledRenderable } from "../core/strip.js";
20
+ import { type Joiner, type StyledRenderable } from "../core/strip.js";
21
21
  export type FlexAlign = "left" | "center" | "right" | "justify";
22
22
  export interface FlexStripOptions<T extends StyledRenderable> {
23
23
  /** Joiner used between items within a line, and at line endpoints. */
@@ -16,6 +16,7 @@
16
16
  * FlexStrip reuses it verbatim. There is no second concept of "join."
17
17
  */
18
18
  import { Segment } from "../core/segment.js";
19
+ import { drawItem, drawJoin } from "../core/strip.js";
19
20
  function renderToBlock(r, options) {
20
21
  const segments = [...r.render(options)];
21
22
  let width = 0;
@@ -63,10 +64,10 @@ export class FlexStrip {
63
64
  const joiner = this.joiner;
64
65
  // Pre-render every item and every joiner form we might need. Doing this
65
66
  // up-front turns packing into a pure data computation over widths.
66
- const itemBlocks = items.map((it) => renderToBlock(it, options));
67
- const startCap = (i) => joiner ? renderToBlock(joiner.join(null, items[i]), options) : EMPTY_BLOCK;
68
- const endCap = (i) => joiner ? renderToBlock(joiner.join(items[i], null), options) : EMPTY_BLOCK;
69
- const midJoin = (i, j) => joiner ? renderToBlock(joiner.join(items[i], items[j]), options) : EMPTY_BLOCK;
67
+ const itemBlocks = items.map((it) => renderToBlock(drawItem(it), options));
68
+ const startCap = (i) => joiner ? renderToBlock(drawJoin(joiner, null, items[i], { index: i }), options) : EMPTY_BLOCK;
69
+ const endCap = (i) => joiner ? renderToBlock(drawJoin(joiner, items[i], null, { index: i + 1 }), options) : EMPTY_BLOCK;
70
+ const midJoin = (i, j) => joiner ? renderToBlock(drawJoin(joiner, items[i], items[j], { index: j }), options) : EMPTY_BLOCK;
70
71
  const lines = [];
71
72
  let line = null;
72
73
  const gapBlock = gap > 0
@@ -177,23 +178,23 @@ export class FlexStrip {
177
178
  for (let i = 0; i < items.length; i++) {
178
179
  const itemWidth = renderToBlock(items[i], options).width;
179
180
  const sc = this.joiner
180
- ? renderToBlock(this.joiner.join(null, items[i]), options).width
181
+ ? renderToBlock(drawJoin(this.joiner, null, items[i], { index: i }), options).width
181
182
  : 0;
182
183
  const ec = this.joiner
183
- ? renderToBlock(this.joiner.join(items[i], null), options).width
184
+ ? renderToBlock(drawJoin(this.joiner, items[i], null, { index: i + 1 }), options).width
184
185
  : 0;
185
186
  minimum = Math.max(minimum, itemWidth + sc + ec);
186
187
  maximum += itemWidth;
187
188
  if (i > 0) {
188
189
  maximum += this.gap * 2;
189
190
  if (this.joiner) {
190
- maximum += renderToBlock(this.joiner.join(items[i - 1], items[i]), options).width;
191
+ maximum += renderToBlock(drawJoin(this.joiner, items[i - 1], items[i], { index: i }), options).width;
191
192
  }
192
193
  }
193
194
  }
194
195
  if (this.joiner) {
195
- maximum += renderToBlock(this.joiner.join(null, items[0]), options).width;
196
- maximum += renderToBlock(this.joiner.join(items[items.length - 1], null), options).width;
196
+ maximum += renderToBlock(drawJoin(this.joiner, null, items[0], { index: 0 }), options).width;
197
+ maximum += renderToBlock(drawJoin(this.joiner, items[items.length - 1], null, { index: items.length }), options).width;
197
198
  }
198
199
  return { minimum, maximum };
199
200
  }
@@ -7,7 +7,7 @@ import { Segment } from "../core/segment.js";
7
7
  import { Style } from "../core/style.js";
8
8
  import { drawable, getStyle } from "../core/protocol.js";
9
9
  import { Effected } from "./effect.js";
10
- import { EFFECT_CURVES, onColors, shimmer } from "./effects.js";
10
+ import { EFFECT_CURVES, atShares, shimmer } from "./effects.js";
11
11
  const BAR = "━";
12
12
  const ASCII_BAR = "-";
13
13
  const HALF_BAR_RIGHT = "╸";
@@ -70,7 +70,8 @@ export class ProgressBar {
70
70
  const ink = back.drawnColors(depth).color ?? ColorSpec.default();
71
71
  const glow = getStyle(options, "bar.pulse").drawnColors(depth).color ?? ColorSpec.default();
72
72
  const loop = shimmer(PULSE_CURVE, width, PULSE_REACH, glow.getTruecolor(theme, true), 0);
73
- return onColors(new Map([[ink.getTruecolor(theme, true).hex, 1]]), loop);
73
+ const whole = new Map([[ink.getTruecolor(theme, true).hex, 1]]);
74
+ return atShares(loop, () => whole);
74
75
  }
75
76
  /** Rich 9d8f9a3 `__rich_console__`: the fill is counted in half cells, and a total of zero is a bar already full. */
76
77
  completeHalves(width) {
@@ -11,6 +11,7 @@
11
11
  * answer "what is under the pointer", so the answer cannot disagree with what
12
12
  * the user sees. Z-order is paint order — whoever painted a cell last owns it.
13
13
  */
14
+ import { drawnBy } from "../core/anchor.js";
14
15
  import { Segment } from "../core/segment.js";
15
16
  import { isScrollable } from "../core/protocol.js";
16
17
  import { WidgetBase } from "./widget-base.js";
@@ -72,10 +73,7 @@ export function originOf(frame, widget) {
72
73
  * or through a widget nested in its output.
73
74
  */
74
75
  export function drew(frame, widget, x, y) {
75
- for (let a = Segment.anchorAt(frame, x, y); a; a = a.inner)
76
- if (a.owner === widget)
77
- return true;
78
- return false;
76
+ return drawnBy(Segment.anchorAt(frame, x, y), widget);
79
77
  }
80
78
  /**
81
79
  * Where `widget`'s footprint sits in `frame`: the cell its row 0, column 0