tokenmaw 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,10 @@
1
- /** A big animated wordmark: theme-gradient block letters with a clean ping-ponging shine; the phosphor theme rains glyphs through the background instead. */
1
+ /** The big animated wordmark. Every theme plays its own one-shot opening
2
+ * act — under a second, each with a distinct mechanic — before the loop
3
+ * takes over: a ping-ponging shine for most themes, glyph rain for the
4
+ * phosphor one. All motion is a pure function of the frame, the mark is
5
+ * born from nothing (frame 0 paints nothing anywhere), each cell is
6
+ * monotonic — once painted, never erased — and the intro's final frame
7
+ * always equals the settled loop frame, so the handoff is invisible. */
2
8
  import { activeTuiTheme } from './theme.js';
3
9
  const GLYPH_LETTERS = [
4
10
  ['███╗ ███╗', '████╗ ████║', '██╔████╔██║', '██║╚██╔╝██║', '██║ ╚═╝ ██║', '╚═╝ ╚═╝'],
@@ -19,11 +25,16 @@ const LOGO_WIDTH = LOGO[0].length;
19
25
  const BRAND = 'T O K E N M A W';
20
26
  const TAGLINE = 'multi-agent coding runtime';
21
27
  const RAIN_GLYPHS = '01ハヒクシアウトナニヌネモリ';
22
- function hexToRgb(hex) {
28
+ /** The whole opening act must finish inside a second: at 20 fps that is
29
+ * exactly 20 frames. The intro covers frames 0..19; the loop's own clock
30
+ * would render frame 20 with the shine band just off the left edge —
31
+ * exactly where frame 19 hands over — so the seam is invisible. */
32
+ export const INTRO_DURATION = 20;
33
+ export function hexToRgb(hex) {
23
34
  const value = hex.replace('#', '');
24
35
  return [parseInt(value.slice(0, 2), 16), parseInt(value.slice(2, 4), 16), parseInt(value.slice(4, 6), 16)];
25
36
  }
26
- function mix(from, to, t) {
37
+ export function mix(from, to, t) {
27
38
  const a = hexToRgb(from);
28
39
  const b = hexToRgb(to);
29
40
  return '#' + a.map((channel, index) => Math.round(channel + (b[index] - channel) * t).toString(16).padStart(2, '0')).join('');
@@ -38,6 +49,387 @@ function flicker(frame, seed) {
38
49
  x ^= x >>> 16;
39
50
  return (x >>> 0) / 4294967296;
40
51
  }
52
+ /** Stateless hash → [0,1), keyed by the seed alone. Chain seeds for variety. */
53
+ export function hash01(seed) {
54
+ let x = (Math.imul(seed + 1, 2654435761) ^ Math.imul(0x9e3779b9, 97531)) >>> 0;
55
+ x ^= x >>> 16;
56
+ x = Math.imul(x, 0x45d9f3b);
57
+ x ^= x >>> 16;
58
+ x = Math.imul(x, 0x45d9f3b);
59
+ x ^= x >>> 16;
60
+ return (x >>> 0) / 4294967296;
61
+ }
62
+ /** Width of the shine's gaussian band, in diagonal cells. */
63
+ export const SHINE_BAND_WIDTH = 18;
64
+ /** Peak glow the band paints over the settled gradient. */
65
+ export const SHINE_GLOW_BOOST = 0.7;
66
+ /**
67
+ * Center of the shine band along the diagonal axis for loop-frame u. The band
68
+ * ping-pongs with a swaying rate: it lingers at the ends, then dashes across.
69
+ * At u = 0 the band sits just off the left edge — the loop always re-enters
70
+ * from the left, and the default theme's brush intro is exactly this phase's
71
+ * first sweep, so the handoff is seamless.
72
+ */
73
+ export function shineBandCenter(u, span) {
74
+ const theta = 0.04 * u + 3.5 * Math.sin(u / 170) - Math.PI / 2;
75
+ return span / 2 + Math.sin(theta) * (span / 2 + 6);
76
+ }
77
+ /** The loop's settled cell with the shine band centered at `bandCenter`. */
78
+ function cellAtBand(context, bandCenter) {
79
+ const dx = context.diagonal - bandCenter;
80
+ const glow = Math.exp(-(dx * dx) / SHINE_BAND_WIDTH);
81
+ const intensity = Math.min(0.95, glow * SHINE_GLOW_BOOST);
82
+ const color = intensity > 0.03 ? mix(context.base, context.headingStrong, intensity) : context.base;
83
+ return { glyph: context.glyph, color };
84
+ }
85
+ /** The loop state an intro must land on: the shine band where it sits at the
86
+ * moment the intro hands over. Every intro converges to this. */
87
+ function settledCell(context) {
88
+ return cellAtBand(context, shineBandCenter(context.duration, context.span));
89
+ }
90
+ /** True while the welcome intro is playing and cells should be handed to the
91
+ * intro renderer instead of the settled shine loop. */
92
+ export function introActive(frame) {
93
+ return frame < INTRO_DURATION;
94
+ }
95
+ /** Deterministic global birth order for a logo cell: 0..N-1 across the whole
96
+ * mark, from a fixed hash of the cell coordinates. Intros that key off
97
+ * "when this cell is due" add their own shaping on top of this. */
98
+ function birthOrder(context, seed) {
99
+ const cells = context.width * 6;
100
+ return Math.floor(hash01(Math.floor((context.rowIndex * context.width + context.column) * 7.13 + seed * 7919)) * cells);
101
+ }
102
+ /** Per-theme opening acts, keyed by theme name. Each is a pure function of
103
+ * the frame, paints nothing at frame 0, and converges on the settled cell by
104
+ * the handoff frame. */
105
+ const intros = {
106
+ // aurora — the shine band is the brush: a reveal front sweeps left to
107
+ // right across the mark, painting the settled palette in its wake with a
108
+ // faint trailing sheen at the edge. The front starts off the left edge so
109
+ // frame 0 is blank, and the colors are the settled ones, so the handoff
110
+ // is invisible.
111
+ aurora: {
112
+ cell(context) {
113
+ const front = (context.frame / (context.duration - 1)) * (context.span + 4) - 2;
114
+ if (context.diagonal > front)
115
+ return null; // ahead of the brush: still nothing
116
+ const dx = front - context.diagonal;
117
+ const sheen = Math.exp(-(dx * dx) / 6) * 0.45;
118
+ const settled = settledCell(context);
119
+ const color = sheen > 0.02 ? mix(settled.color, context.accent, sheen) : settled.color;
120
+ return { glyph: context.glyph, color };
121
+ },
122
+ },
123
+ // midnight — a narrow spotlight sweeps across a black stage; each cell the
124
+ // beam touches blazes white-hot at birth, then cools into the settled
125
+ // palette. Left to right, one pass.
126
+ midnight: {
127
+ cell(context) {
128
+ const front = context.progress * (context.span + 10) - 5;
129
+ if (context.diagonal > front)
130
+ return null;
131
+ const dx = context.diagonal - front;
132
+ const birth = Math.exp(-(dx * dx) / 3);
133
+ const settled = settledCell(context);
134
+ const color = birth > 0.02 ? mix(settled.color, '#ffffff', Math.min(1, birth * 1.4)) : settled.color;
135
+ return { glyph: context.glyph, color };
136
+ },
137
+ },
138
+ // nord — an aurora curtain: a cold green-white wave washes left to right
139
+ // through the mark, each row's edge slightly offset so the reveal ripples
140
+ // like light on ice.
141
+ nord: {
142
+ cell(context) {
143
+ const wave = context.progress * (context.span + 14) - 7;
144
+ const jitter = (context.rnd(1) - 0.5) * 5 + context.rowIndex * 2.2;
145
+ const edge = wave + jitter;
146
+ if (context.diagonal > edge)
147
+ return null;
148
+ const dx = context.diagonal - edge;
149
+ const glow = Math.exp(-(dx * dx) / 6);
150
+ const settled = settledCell(context);
151
+ const color = glow > 0.02 ? mix(settled.color, '#a8ffda', glow * 0.8) : settled.color;
152
+ return { glyph: context.glyph, color };
153
+ },
154
+ },
155
+ // dracula — an ink bleed: dark violet droplets surface at fixed points and
156
+ // spread outward as circles until the mark is flooded; each cell is born
157
+ // wet-glossy pink and dries into the settled gradient.
158
+ dracula: {
159
+ cell(context) {
160
+ const seeds = [3, 11, 23, 37, 53];
161
+ let born;
162
+ for (const seed of seeds) {
163
+ const cx = context.rnd(seed * 2) * context.span;
164
+ const cy = context.rnd(seed * 3) * 6;
165
+ const dist = Math.hypot(context.diagonal - cx, (context.rowIndex - cy) * 1.6);
166
+ const start = context.rnd(seed) * 0.3;
167
+ const reach = start + (dist / Math.max(1, context.span * 0.75)) * 0.6;
168
+ if (context.progress >= reach && (born === undefined || reach < born))
169
+ born = reach;
170
+ }
171
+ if (born === undefined)
172
+ return null;
173
+ const wet = Math.max(0, 1 - (context.progress - born) / 0.14);
174
+ const settled = settledCell(context);
175
+ const color = wet > 0 ? mix(settled.color, '#ff79c6', wet * 0.75) : settled.color;
176
+ return { glyph: context.glyph, color };
177
+ },
178
+ },
179
+ // dawn — a sunrise: cells wake from the bottom row upward, each igniting
180
+ // gold as the light line passes and cooling into the settled pastel.
181
+ dawn: {
182
+ cell(context) {
183
+ const due = 0.06 + ((5 - context.rowIndex) / 6) * 0.72 + (context.rnd(2) - 0.5) * 0.06;
184
+ if (context.progress < due)
185
+ return null;
186
+ const heat = Math.max(0, 1 - (context.progress - due) / 0.15);
187
+ const settled = settledCell(context);
188
+ const color = heat > 0.02 ? mix(settled.color, '#ffb347', heat * 0.7) : settled.color;
189
+ return { glyph: context.glyph, color };
190
+ },
191
+ },
192
+ // solarized — a darkroom print developing: cells surface as a faint cyan
193
+ // ghost that deepens into full contrast, center cells first like a
194
+ // vignette developing from the middle out.
195
+ solarized: {
196
+ cell(context) {
197
+ const vignette = 1 - Math.abs(context.diagonal - context.span / 2) / context.span;
198
+ const due = 0.1 + (1 - vignette) * 0.5;
199
+ if (context.progress < due)
200
+ return null;
201
+ const develop = Math.min(1, (context.progress - due) / 0.2);
202
+ const settled = settledCell(context);
203
+ return { glyph: context.glyph, color: mix(mix(context.accent, '#000000', 0.8), settled.color, develop) };
204
+ },
205
+ },
206
+ // rose-pine — petals fall: pale rose petals drift down through the mark's
207
+ // bounding box and stick where they land, filling the letterforms from
208
+ // random contact points until every cell has settled.
209
+ 'rose-pine': {
210
+ cell(context) {
211
+ const seeds = [7, 19, 31, 43, 59, 71];
212
+ let landed;
213
+ for (const seed of seeds) {
214
+ const drift = context.rnd(seed) * context.span;
215
+ const delay = context.rnd(seed * 5) * 0.24;
216
+ const fall = delay + (1 - (context.rowIndex + (context.rnd(seed * 3) - 0.5) * 2) / 6) * 0.4;
217
+ if (context.progress >= fall && Math.abs(context.diagonal - drift) < 7 + context.progress * 10) {
218
+ if (landed === undefined || fall < landed)
219
+ landed = fall;
220
+ }
221
+ }
222
+ if (landed === undefined)
223
+ return null;
224
+ const settle = Math.max(0, 1 - (context.progress - landed) / 0.12);
225
+ const settled = settledCell(context);
226
+ const color = settle > 0 ? mix(settled.color, '#eb6f92', settle * 0.65) : settled.color;
227
+ return { glyph: context.glyph, color };
228
+ },
229
+ },
230
+ // tokyo-night — neon ignition: the sign flickers on like a faulty neon
231
+ // tube — cells pop in a random order, sputtering briefly before holding
232
+ // steady.
233
+ 'tokyo-night': {
234
+ cell(context) {
235
+ const due = 0.04 + (birthOrder(context, 13) / (context.width * 6)) * 0.62;
236
+ if (context.progress < due)
237
+ return null;
238
+ const fresh = context.progress - due;
239
+ if (fresh < 0.14) {
240
+ const sputter = context.rnd(Math.floor(fresh * 140) * 13 + context.rowIndex * 31 + context.column);
241
+ const settled = settledCell(context);
242
+ // A dying tube dims to near-black instead of vanishing: the cell
243
+ // stays painted, the neon still reads as sputtering.
244
+ if (sputter < 0.4)
245
+ return { glyph: context.glyph, color: mix(settled.color, '#16161e', 0.88) };
246
+ return { glyph: context.glyph, color: mix(settled.color, '#7dcfff', 0.5) };
247
+ }
248
+ return settledCell(context);
249
+ },
250
+ },
251
+ // catppuccin-mocha — a ripple: a soft ring expands from the center of the
252
+ // mark, painting cells as it passes and leaving a brief pastel afterglow.
253
+ 'catppuccin-mocha': {
254
+ cell(context) {
255
+ const ring = context.progress * (context.span * 0.75 + 8);
256
+ const dist = Math.hypot(context.diagonal - context.span / 2, (context.rowIndex - 2.5) * 2.4);
257
+ if (dist > ring)
258
+ return null;
259
+ const afterglow = Math.max(0, 1 - (ring - dist) / 7);
260
+ const settled = settledCell(context);
261
+ const color = afterglow > 0.02 ? mix(settled.color, '#cba6f7', afterglow * 0.55) : settled.color;
262
+ return { glyph: context.glyph, color };
263
+ },
264
+ },
265
+ // catppuccin-latte — a coffee pour: the cup fills bottom-up like an
266
+ // espresso being pulled, the surface line wobbling as it rises; each
267
+ // dunked cell flashes with a steamed-milk sheen that fades back into
268
+ // the settled pastel as the surface moves on.
269
+ 'catppuccin-latte': {
270
+ cell(context) {
271
+ const level = -0.4 + context.progress * 6 + Math.sin(context.progress * 12 + context.column / 6) * 0.2;
272
+ if (context.rowIndex + (context.rnd(4) - 0.5) * 0.5 > level)
273
+ return null;
274
+ const flash = Math.exp(-Math.max(0, level - context.rowIndex) / 1.5);
275
+ const settled = settledCell(context);
276
+ const color = flash > 0.02 ? mix(settled.color, context.accent, flash * 0.6) : settled.color;
277
+ return { glyph: context.glyph, color };
278
+ },
279
+ },
280
+ // gruvbox-dark — a CRT power-on: the mark snaps in as a horizontal line
281
+ // that stretches vertically from the center outward, phosphor-bright at
282
+ // the growing edges, then relaxes into the settled warm palette.
283
+ 'gruvbox-dark': {
284
+ cell(context) {
285
+ const half = context.progress * 4.2;
286
+ if (Math.abs(context.rowIndex - 2.5) > half)
287
+ return null;
288
+ const edge = Math.abs(Math.abs(context.rowIndex - 2.5) - half);
289
+ const bloom = edge < 0.9 ? 1 - edge / 0.9 : 0;
290
+ const settled = settledCell(context);
291
+ const color = bloom > 0 ? mix(settled.color, '#fe8019', bloom * 0.8) : settled.color;
292
+ return { glyph: context.glyph, color };
293
+ },
294
+ },
295
+ // one-dark — a typewriter: the mark is typed cell by cell, left to right,
296
+ // top to bottom, each keystroke landing full-strength with no fade.
297
+ 'one-dark': {
298
+ cell(context) {
299
+ const order = context.rowIndex * context.width + context.column;
300
+ const total = 6 * context.width;
301
+ const typed = Math.floor((context.progress / 0.94) * total);
302
+ if (order >= typed)
303
+ return null; // strictly after 0: nothing is typed at frame 0
304
+ const settled = settledCell(context);
305
+ const fresh = order === typed - 1 ? 1 : 0;
306
+ const color = fresh ? mix(settled.color, '#98c379', 0.45) : settled.color;
307
+ return { glyph: context.glyph, color };
308
+ },
309
+ },
310
+ // monokai — a pixel mosaic: cells pop in as dim colored blocks in random
311
+ // order, dwell a few ticks scintillating, then resolve one by one into
312
+ // the true glyph with a brief bright flash.
313
+ monokai: {
314
+ cell(context) {
315
+ const pop = 0.05 + (birthOrder(context, 9) / (context.width * 6)) * 0.55;
316
+ if (context.progress <= pop)
317
+ return null;
318
+ const resolve = pop + 0.18;
319
+ const settled = settledCell(context);
320
+ if (context.progress <= resolve) {
321
+ const tick = Math.floor(context.progress * context.duration / 4);
322
+ const shades = ['░', '▒', '▓'];
323
+ return { glyph: shades[Math.floor(context.rnd(tick * 3 + 2) * 3)], color: mix(context.base, '#1e1f1c', 0.55 + context.rnd(5) * 0.25) };
324
+ }
325
+ const flash = Math.max(0, 1 - (context.progress - resolve) / 0.08);
326
+ const color = flash > 0 ? mix(settled.color, '#f8f8f2', flash * 0.85) : settled.color;
327
+ return { glyph: context.glyph, color };
328
+ },
329
+ },
330
+ // kanagawa — a sumi-e wash: a wide soft brush draws the mark in three
331
+ // passes (top, middle, bottom bands), each stroke wet and dark at the
332
+ // front, drying lighter toward the tail.
333
+ kanagawa: {
334
+ cell(context) {
335
+ const band = Math.floor(context.rowIndex / 2);
336
+ const strokeFront = (context.progress * 1.15 - band * 0.22) * (context.span + 8) - 4;
337
+ if (context.diagonal > strokeFront)
338
+ return null;
339
+ const dx = context.diagonal - strokeFront;
340
+ const wet = Math.exp(-(dx * dx) / 26);
341
+ const settled = settledCell(context);
342
+ const color = wet > 0.02 ? mix(settled.color, '#1f1f28', Math.min(0.5, wet * 0.5)) : settled.color;
343
+ return { glyph: context.glyph, color };
344
+ },
345
+ },
346
+ // everforest — fog lift: dense pale fog envelops nothing yet — the fog
347
+ // edge retreats to the left as time passes, and cells emerge in its wake
348
+ // from the right. Each cell brightens as the haze burns off.
349
+ everforest: {
350
+ cell(context) {
351
+ const drift = context.progress * (context.span + 16) - 8;
352
+ const wisp = (context.rnd(6) - 0.5) * 6 + context.rowIndex * 1.7;
353
+ if (context.diagonal > drift + wisp)
354
+ return null;
355
+ const thin = Math.max(0, 1 - (drift + wisp - context.diagonal) / 8);
356
+ const settled = settledCell(context);
357
+ const color = thin > 0.02 ? mix(settled.color, '#d3c6aa', thin * 0.5) : settled.color;
358
+ return { glyph: context.glyph, color };
359
+ },
360
+ },
361
+ // synthwave — VHS tear-in: the mark materializes out of tracking noise,
362
+ // rows tearing in top to bottom with chromatic aberration, stabilizing
363
+ // row by row until the tape locks.
364
+ synthwave: {
365
+ cell(context) {
366
+ const rowLock = 0.06 + (context.rowIndex / 6) * 0.6;
367
+ if (context.progress < rowLock)
368
+ return null;
369
+ const tear = Math.max(0, 1 - (context.progress - rowLock) / 0.14);
370
+ if (tear > 0 && context.rnd(Math.floor(context.progress * context.duration) * 17 + context.rowIndex * 41) < tear * 0.5)
371
+ return null;
372
+ const settled = settledCell(context);
373
+ const chroma = tear > 0 ? tear * 0.6 : 0;
374
+ const color = chroma > 0 ? mix(settled.color, chroma * 2 > 1 ? '#36f9f6' : '#fe4450', chroma) : settled.color;
375
+ return { glyph: context.glyph, color };
376
+ },
377
+ },
378
+ // matrix — rain condensation: glyph rain falls through the mark's bounding
379
+ // box and the letterforms condense where the rain strikes, as if the code
380
+ // itself is condensing out of the downpour.
381
+ matrix: {
382
+ cell(context) {
383
+ const strike = 0.06 + context.rnd(8) * 0.6;
384
+ if (context.progress < strike)
385
+ return null;
386
+ const settle = Math.max(0, 1 - (context.progress - strike) / 0.1);
387
+ const settled = settledCell(context);
388
+ const color = settle > 0 ? mix(settled.color, '#c8ffd9', settle * 0.7) : settled.color;
389
+ return { glyph: context.glyph, color };
390
+ },
391
+ },
392
+ // solarized-light — blueprint develop: a scanning bar sweeps down over
393
+ // blank paper; behind it the ink darkens rapidly from nothing into the
394
+ // finished solarized-light palette.
395
+ 'solarized-light': {
396
+ cell(context) {
397
+ const scan = -1.5 + context.progress * 9;
398
+ if (context.rowIndex > scan)
399
+ return null;
400
+ const fresh = Math.max(0, 1 - (context.rowIndex - scan) / 1.4);
401
+ const settled = settledCell(context);
402
+ const color = fresh > 0.02 ? mix(settled.color, '#268bd2', fresh * 0.6) : settled.color;
403
+ return { glyph: context.glyph, color };
404
+ },
405
+ },
406
+ // github-light — skeleton shimmer: a pale wireframe flickers in for a
407
+ // couple of beats, then the fill floods in left to right with a crisp
408
+ // leading edge, converting the skeleton in place — nothing is ever
409
+ // erased — ending at full contrast.
410
+ 'github-light': {
411
+ cell(context) {
412
+ const settled = settledCell(context);
413
+ const skeleton = { glyph: context.glyph, color: mix('#f6f8fa', '#d0d7de', 0.4) };
414
+ if (context.progress < 0.02)
415
+ return null;
416
+ if (context.progress < 0.28) {
417
+ // The wireframe breathes: bright beats and dim beats, but a cell
418
+ // stays painted once it has first appeared.
419
+ const sputter = context.rnd(Math.floor(context.progress * 100) * 29 + context.rowIndex * 17 + context.column);
420
+ if (sputter < 0.45)
421
+ return { glyph: context.glyph, color: mix('#f6f8fa', '#d0d7de', 0.75) };
422
+ return skeleton;
423
+ }
424
+ const front = (context.progress - 0.28) / 0.72 * (context.span + 6) - 3;
425
+ if (context.diagonal > front)
426
+ return skeleton; // not yet flooded: skeleton holds
427
+ const fresh = Math.max(0, 1 - (front - context.diagonal) / 4);
428
+ const color = fresh > 0.02 ? mix(settled.color, '#0969da', fresh * 0.5) : settled.color;
429
+ return { glyph: context.glyph, color };
430
+ },
431
+ },
432
+ };
41
433
  export function renderWelcome(width, height, terminalHeight = height, frame = 0) {
42
434
  width = Math.max(1, Math.floor(width));
43
435
  height = Math.max(1, Math.floor(height));
@@ -47,18 +439,11 @@ export function renderWelcome(width, height, terminalHeight = height, frame = 0)
47
439
  const cycle = frame % 80;
48
440
  const logoTop = center - 3;
49
441
  if (width >= LOGO_WIDTH + 3 && logoTop >= 0 && logoTop + LOGO.length <= height) {
50
- // One clean motion layer for every theme: the shine rides diagonal "/"
51
- // stripes (distance along u = x + rise · y) and ping-pongs end to end.
52
- // Its phase advances at a rate that itself sways, so the band lingers at
53
- // the ends, then dashes across — never a flat metronome. No other motion
54
- // touches the letterforms.
55
442
  const rise = 2;
56
443
  const span = LOGO_WIDTH + rise * (LOGO.length - 1);
57
- const theta = 0.04 * frame + 3.5 * Math.sin(frame / 170);
58
- const ride = Math.sin(theta);
59
- const bandCenter = span / 2 + ride * (span / 2 + 6);
60
- const bandWidth = 18;
61
- const glowBoost = 0.7;
444
+ // Loop phase: the shine ping-pongs with a swaying rate, re-entering from
445
+ // the left each cycle, so the band lingers at the ends then dashes across.
446
+ const bandCenter = shineBandCenter(frame, span);
62
447
  // The phosphor theme swaps the empty space around the mark for glyph
63
448
  // rain: per-column heads fall the full height of the screen, trailing a
64
449
  // fading wake. The letterforms themselves stay pristine on top.
@@ -99,21 +484,47 @@ export function renderWelcome(width, height, terminalHeight = height, frame = 0)
99
484
  return pad;
100
485
  };
101
486
  const leftCells = Math.floor((width - LOGO_WIDTH) / 2);
487
+ const themeName = activeTuiTheme().name;
488
+ const intro = intros[themeName];
102
489
  LOGO.forEach((template, rowIndex) => {
103
490
  const row = logoTop + rowIndex;
104
491
  let line = '';
105
492
  for (let column = 0; column < LOGO_WIDTH; column++) {
106
493
  const diagonal = column + rowIndex * rise;
107
- const dx = diagonal - bandCenter;
108
- const glow = Math.exp(-(dx * dx) / bandWidth);
109
- const base = mix(palette.accent, palette.heading, diagonal / span);
110
- const intensity = Math.min(0.95, glow * glowBoost);
111
- const color = intensity > 0.03 ? mix(base, palette.headingStrong, intensity) : base;
112
494
  const glyph = template[column];
113
495
  if (glyph === ' ') {
114
496
  line += rainAt(row, leftCells + column);
115
497
  continue;
116
498
  }
499
+ const base = mix(palette.accent, palette.heading, diagonal / span);
500
+ if (intro && introActive(frame)) {
501
+ const context = {
502
+ frame,
503
+ duration: INTRO_DURATION,
504
+ progress: frame / INTRO_DURATION,
505
+ rowIndex,
506
+ column,
507
+ diagonal,
508
+ span,
509
+ width: LOGO_WIDTH,
510
+ glyph,
511
+ base,
512
+ accent: palette.accent,
513
+ heading: palette.heading,
514
+ headingStrong: palette.headingStrong,
515
+ text: palette.text,
516
+ rnd: (seed) => hash01(seed * 7919 + rowIndex * 131 + column * 7 + 5),
517
+ };
518
+ const painted = intro.cell(context);
519
+ if (painted)
520
+ line += `{${painted.color}-fg}${painted.glyph}{/${painted.color}-fg}`;
521
+ continue;
522
+ }
523
+ // Loop: the settled gradient with the shine riding on top.
524
+ const dx = diagonal - bandCenter;
525
+ const glow = Math.exp(-(dx * dx) / SHINE_BAND_WIDTH);
526
+ const intensity = Math.min(0.95, glow * SHINE_GLOW_BOOST);
527
+ const color = intensity > 0.03 ? mix(base, palette.headingStrong, intensity) : base;
117
528
  line += `{${color}-fg}${glyph}{/${color}-fg}`;
118
529
  }
119
530
  rows[row] = `${rainPad(row, 0, leftCells)}${line}${rainPad(row, leftCells + LOGO_WIDTH, width)}`;
@@ -121,7 +532,7 @@ export function renderWelcome(width, height, terminalHeight = height, frame = 0)
121
532
  const brandRow = logoTop + LOGO.length + 1;
122
533
  if (brandRow < height && BRAND.length <= width) {
123
534
  const brandLeft = Math.floor((width - BRAND.length) / 2);
124
- const brand = `{${palette.headingStrong}-fg}{bold}${BRAND}{/bold}{/${palette.headingStrong}-fg}`;
535
+ const brand = `{${palette.headingStrong}-fg}{bold}{/bold}{/${palette.headingStrong}-fg}{white-fg}{bold}${BRAND}{/bold}{/white-fg}`;
125
536
  rows[brandRow] = `${rainPad(brandRow, 0, brandLeft)}${brand}${rainPad(brandRow, brandLeft + BRAND.length, width)}`;
126
537
  }
127
538
  const taglineRow = brandRow + 1;