@digithings-ai/hero-banner 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,38 @@
1
- # Temporary Holding Version
1
+ # @digithings-ai/hero-banner
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One banner definition, rendered to a terminal (half-blocks) and to the web
4
+ (canvas). The definition — cells, animation, palette — is declared once in
5
+ `src/definitions/`; the renderers only decide how a cell becomes output.
6
+
7
+ ```ts
8
+ import { BANNERS } from "@digithings-ai/hero-banner/definitions";
9
+ ```
10
+
11
+ | Entry | What it is |
12
+ |---|---|
13
+ | `@digithings-ai/hero-banner` | The engine: `sampleBanner`, `canonicalLayout`, `fitTerminal`, the animation builders and the layout maths |
14
+ | `@digithings-ai/hero-banner/definitions` | The shipped banner definitions (`BANNERS`), one per module |
15
+ | `@digithings-ai/hero-banner/web` | The canvas renderer for React surfaces (`react` is an optional peer) |
16
+
17
+ `src/tui.ts` and `src/ssr.ts` are the terminal and server-side renderers. The
18
+ spec is [`docs/architecture/hero-banner-spec.md`](../../docs/architecture/hero-banner-spec.md).
19
+
20
+ ## Publishing
21
+
22
+ This package is published to npm as part of the digithings R0 release. Its
23
+ `exports` point at **TypeScript source**, not a build output, so the published
24
+ tarball is the same code the workspace consumes.
25
+
26
+ A Next.js consumer must therefore transpile it — add the package to
27
+ `transpilePackages` in `next.config.mjs`:
28
+
29
+ ```js
30
+ const nextConfig = {
31
+ transpilePackages: ["@digithings-ai/hero-banner"],
32
+ };
33
+ ```
34
+
35
+ A bundler that already compiles TS (Vite, tsup, Bun) resolves the source
36
+ directly and needs no extra configuration.
37
+
38
+ Tests: `bun test`. Build (type declarations): `npm run build --workspace @digithings-ai/hero-banner`.
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@digithings-ai/hero-banner",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "license": "MIT",
4
+ "homepage": "https://digithings.ai",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/digithings-ai/digithings.git",
8
+ "directory": "packages/hero-banner"
9
+ },
10
+ "version": "0.1.0",
11
+ "description": "One banner definition, rendered to a terminal (half-blocks, OpenTUI) and to the web (canvas). Spec: docs/architecture/hero-banner-spec.md.",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./definitions": "./src/definitions/index.ts",
16
+ "./web": "./src/web.tsx"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "test": "bun test",
27
+ "lint": "eslint .",
28
+ "build": "tsup"
29
+ },
30
+ "peerDependencies": {
31
+ "react": "^19.2.4"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "react": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "devDependencies": {
39
+ "tsup": "^8.5.0"
40
+ }
41
+ }
@@ -0,0 +1,114 @@
1
+ import { emptyFrame, litCells, mulberry32, setPixel } from "../layout";
2
+ import type { Animation, Frame, Layout } from "../types";
3
+
4
+ /**
5
+ * BUILD (digithings). LOADING: each cell flickers through three opacity steps
6
+ * and lands on its own final step; strays blink once at 0.25. LIVE: a few cells
7
+ * glint in the accent on long, uneven periods.
8
+ * Port of apps/digithings-web/components/landing/PixelWordmark.tsx and its
9
+ * pixel-build / pixel-stray / pixel-glint keyframes: same seed, same draw order.
10
+ */
11
+
12
+ export type BuildOptions = {
13
+ /** Opacity steps a cell can land on. */
14
+ steps: readonly number[];
15
+ glints: number;
16
+ strays: number;
17
+ /** Stray columns are drawn from [0, strayWidth). Defaults to the layout width. */
18
+ strayWidth?: number;
19
+ /** Share of the glint period the accent shows. */
20
+ glintDuty: number;
21
+ };
22
+
23
+ type BuildCell = {
24
+ x: number;
25
+ y: number;
26
+ d: number;
27
+ t: number;
28
+ a: number;
29
+ b: number;
30
+ c: number;
31
+ f: number;
32
+ glint: { g: number; gp: number } | null;
33
+ };
34
+
35
+ type Stray = { x: number; y: number; d: number; t: number };
36
+
37
+ export type BuildState = { width: number; height: number; cells: BuildCell[]; strays: Stray[]; duty: number; ms: number };
38
+
39
+ export function buildAnimation(options: BuildOptions): Animation<BuildState> {
40
+ return {
41
+ kind: "build",
42
+ liveTickMs: 100,
43
+ prepare(layout: Layout, def) {
44
+ const rand = mulberry32(def.seed);
45
+ const pick = () => options.steps[Math.floor(rand() * options.steps.length)] ?? 1;
46
+ const used = new Set<string>();
47
+ const lit = litCells(layout);
48
+ for (const cell of lit) used.add(`${cell.x},${cell.y}`);
49
+ const glint = new Set<number>();
50
+ while (glint.size < Math.min(options.glints, lit.length)) glint.add(Math.floor(rand() * lit.length));
51
+ const cells: BuildCell[] = lit.map((cell, index) => {
52
+ const isGlint = glint.has(index);
53
+ const d = (rand() * 692 + 8) | 0;
54
+ const t = (rand() * 380 + 240) | 0;
55
+ const a = pick();
56
+ const b = pick();
57
+ const c = pick();
58
+ const f = pick();
59
+ const g = isGlint ? { g: (rand() * 6500 + 2500) | 0, gp: (rand() * 8000 + 7000) | 0 } : null;
60
+ return { x: cell.x, y: cell.y, d, t, a, b, c, f, glint: g };
61
+ });
62
+ const strays: Stray[] = [];
63
+ const strayWidth = options.strayWidth ?? layout.width;
64
+ while (strays.length < options.strays) {
65
+ const x = Math.floor(rand() * strayWidth);
66
+ const y = Math.floor(rand() * layout.height);
67
+ const key = `${x},${y}`;
68
+ if (used.has(key)) continue;
69
+ used.add(key);
70
+ strays.push({ x, y, d: (rand() * 1100) | 0, t: (rand() * 140 + 60) | 0 });
71
+ }
72
+ const ms = Math.max(0, ...cells.map((c) => c.d + c.t), ...strays.map((s) => s.d + s.t));
73
+ return { width: layout.width, height: layout.height, cells, strays, duty: options.glintDuty, ms };
74
+ },
75
+ loadingMs: (state) => state.ms,
76
+ loading(state, t) {
77
+ const frame = emptyFrame(state.width, state.height);
78
+ for (const cell of state.cells) {
79
+ const p = (t - cell.d) / cell.t;
80
+ if (p < 0) continue;
81
+ const alpha = p < 1 / 3 ? cell.a : p < 2 / 3 ? cell.b : p < 1 ? cell.c : cell.f;
82
+ setPixel(frame, cell.x, cell.y, { tone: "ink", alpha });
83
+ }
84
+ for (const stray of state.strays) {
85
+ if (t >= stray.d && t < stray.d + stray.t) setPixel(frame, stray.x, stray.y, { tone: "ink", alpha: 0.25 });
86
+ }
87
+ paintGlints(frame, state, t);
88
+ return frame;
89
+ },
90
+ live(state, _tLive, tMount) {
91
+ const frame = settled(state);
92
+ paintGlints(frame, state, tMount);
93
+ return frame;
94
+ },
95
+ still: settled,
96
+ };
97
+ }
98
+
99
+ function settled(state: BuildState): Frame {
100
+ const frame = emptyFrame(state.width, state.height);
101
+ for (const cell of state.cells) setPixel(frame, cell.x, cell.y, { tone: "ink", alpha: cell.f });
102
+ return frame;
103
+ }
104
+
105
+ /** `pixel-glint var(--gp) step-end var(--g) infinite`: accent for the first
106
+ * `duty` of each period once the delay has passed. */
107
+ function paintGlints(frame: Frame, state: BuildState, t: number): void {
108
+ for (const cell of state.cells) {
109
+ if (!cell.glint || t < cell.glint.g) continue;
110
+ if ((t - cell.glint.g) % cell.glint.gp < cell.glint.gp * state.duty) {
111
+ setPixel(frame, cell.x, cell.y, { tone: "accent", alpha: 1 });
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,346 @@
1
+ import { emptyFrame, litCells, mulberry32, setPixel } from "../layout";
2
+ import type { Animation, Frame, Glyph, Layout, Pixel, PixelFont } from "../types";
3
+
4
+ /**
5
+ * CLAW (digibot). An arcade claw machine picks the word out of a pile.
6
+ * The font carries `HEADROOM` blank rows over each glyph; that is the claw's
7
+ * gantry. LOADING: the glyphs lie sunk in a pile at the bottom (dim `accent`,
8
+ * seeded depths). A claw (`accent`) hangs from a faint rail, travels to each
9
+ * slot, drops, closes on the glyph, lifts it into place and lets go; the
10
+ * glyph flashes `glint` and settles to `ink`. The claw starts slow and gets
11
+ * quicker. One seeded grab slips: the glyph falls back to the pile and the
12
+ * claw tries again. Then the claw leaves and the rail fades.
13
+ * LIVE: every so often the claw drops on one glyph, lifts it into the
14
+ * headroom, holds it and sets it back down. One event in three it slips: the
15
+ * glyph drops back with a small bounce.
16
+ */
17
+
18
+ export const HEADROOM = 4;
19
+
20
+ /** `font` with `rows` blank rows on top of every glyph (rows stays even). */
21
+ export function withHeadroom(font: PixelFont, rows = HEADROOM): PixelFont {
22
+ const blank = ".".repeat(font.cols);
23
+ const glyphs: Record<string, Glyph> = {};
24
+ for (const [ch, g] of Object.entries(font.glyphs)) glyphs[ch] = [...Array(rows).fill(blank), ...g];
25
+ return { cols: font.cols, rows: font.rows + rows, glyphs };
26
+ }
27
+
28
+ export type ClawOptions = {
29
+ /** LOADING: ms the claw takes to enter from the left. */
30
+ enterMs: number;
31
+ /** ms to travel to the next slot (rising back to the rail). */
32
+ travelMs: number;
33
+ /** ms to drop onto a glyph. */
34
+ dropMs: number;
35
+ /** ms the claw takes to close. */
36
+ gripMs: number;
37
+ /** ms to lift a glyph into place. */
38
+ liftMs: number;
39
+ /** ms the claw holds before it travels on. */
40
+ releaseMs: number;
41
+ /** ms a released glyph shows `glint`. */
42
+ flashMs: number;
43
+ /** Pace of the first grab; later grabs speed up to `paceMin`. */
44
+ paceStart: number;
45
+ paceMin: number;
46
+ /** ms for the claw to leave and the rail to fade. */
47
+ exitMs: number;
48
+ /** LIVE: ms between claw events. */
49
+ everyMs: number;
50
+ };
51
+
52
+ /** A value that moves from `a` to `b` over [t0, t1]. */
53
+ type Seg = { t0: number; t1: number; a: number; b: number; ease: (p: number) => number };
54
+
55
+ /** Piecewise track: holds `init` until the first segment, then the last value reached. */
56
+ export type Track = { init: number; segs: Seg[] };
57
+
58
+ export type Script = {
59
+ /** Claw centre column and head row (head = the 3-wide bar; prongs hang 2 rows under it). */
60
+ cx: Track;
61
+ head: Track;
62
+ /** Claw open/closed toggles, in time order. Starts open. */
63
+ closed: { t: number; closed: boolean }[];
64
+ /** Claw shown over [from, to). */
65
+ from: number;
66
+ to: number;
67
+ /** Per glyph slot: rows below home (positive: sunk; negative: lifted into headroom). */
68
+ offset: Track[];
69
+ /** Per glyph slot: when it stops being "pile" (gripped), and when it settles (released). */
70
+ gripped: (number | null)[];
71
+ placed: (number | null)[];
72
+ /** Slot that drops back without a grip after a slip, from `t`: tone back to pile. */
73
+ dropped: { slot: number; t: number }[];
74
+ /** How an ungripped glyph looks: the pile (LOADING) or the settled ink (LIVE). */
75
+ rest: Pixel;
76
+ };
77
+
78
+ export type ClawState = {
79
+ width: number;
80
+ height: number;
81
+ layout: Layout;
82
+ cells: { x: number; y: number }[][];
83
+ centers: number[];
84
+ sinks: number[];
85
+ /** Slot whose first LOADING grab slips. */
86
+ slipSlot: number;
87
+ script: Script;
88
+ seed: number;
89
+ options: ClawOptions;
90
+ ms: number;
91
+ };
92
+
93
+ const easeOut = (p: number) => 1 - (1 - p) * (1 - p);
94
+ const easeIn = (p: number) => p * p;
95
+ const easeInOut = (p: number) => (p < 0.5 ? 2 * p * p : 1 - 2 * (1 - p) * (1 - p));
96
+
97
+ export function valueAt(track: Track, t: number): number {
98
+ let v = track.init;
99
+ for (const s of track.segs) {
100
+ if (t < s.t0) break;
101
+ if (t >= s.t1) {
102
+ v = s.b;
103
+ continue;
104
+ }
105
+ return s.a + (s.b - s.a) * s.ease((t - s.t0) / (s.t1 - s.t0));
106
+ }
107
+ return v;
108
+ }
109
+
110
+ /** The head row at which the prong tips sit one row into a glyph at `offset`. */
111
+ const gripHead = (offset: number) => HEADROOM - 1 + offset;
112
+ const RAIL_HEAD = 1;
113
+
114
+ /** Builds a claw script: moves are appended in time order. */
115
+ class Director {
116
+ t = 0;
117
+ cx: Track;
118
+ head: Track;
119
+ closed: Script["closed"] = [];
120
+ offset: Track[];
121
+ gripped: (number | null)[];
122
+ placed: (number | null)[];
123
+ dropped: Script["dropped"] = [];
124
+ constructor(cx: number, head: number, offsets: number[]) {
125
+ this.cx = { init: cx, segs: [] };
126
+ this.head = { init: head, segs: [] };
127
+ this.offset = offsets.map((o) => ({ init: o, segs: [] }));
128
+ this.gripped = offsets.map(() => null);
129
+ this.placed = offsets.map(() => null);
130
+ }
131
+ private last(track: Track) {
132
+ return track.segs.at(-1)?.b ?? track.init;
133
+ }
134
+ move(ms: number, to: { cx?: number; head?: number }, ease = easeInOut) {
135
+ const t0 = this.t;
136
+ const t1 = t0 + Math.max(1, Math.round(ms));
137
+ if (to.cx != null) this.cx.segs.push({ t0, t1, a: this.last(this.cx), b: to.cx, ease });
138
+ if (to.head != null) this.head.segs.push({ t0, t1, a: this.last(this.head), b: to.head, ease });
139
+ return { t0, t1 };
140
+ }
141
+ shift(slot: number, t0: number, t1: number, to: number, ease: (p: number) => number) {
142
+ const tr = this.offset[slot];
143
+ tr.segs.push({ t0, t1, a: this.last(tr), b: to, ease });
144
+ }
145
+ grip(closed: boolean) {
146
+ this.closed.push({ t: this.t, closed });
147
+ }
148
+ wait(ms: number) {
149
+ this.t += Math.round(ms);
150
+ }
151
+ /** Run `move`, then advance time to its end. */
152
+ go(ms: number, to: { cx?: number; head?: number }, ease = easeInOut) {
153
+ const span = this.move(ms, to, ease);
154
+ this.t = span.t1;
155
+ return span;
156
+ }
157
+ script(from: number, to: number, rest: Pixel): Script {
158
+ const { cx, head, closed, offset, gripped, placed, dropped } = this;
159
+ return { cx, head, closed, from, to, offset, gripped, placed, dropped, rest };
160
+ }
161
+ }
162
+
163
+ export function clawAnimation(options: ClawOptions): Animation<ClawState> {
164
+ return {
165
+ kind: "claw",
166
+ liveTickMs: 40,
167
+ prepare(layout: Layout, def) {
168
+ const { width, height } = layout;
169
+ const rand = mulberry32(def.seed);
170
+ const n = layout.text.length;
171
+ const cells: { x: number; y: number }[][] = Array.from({ length: n }, () => []);
172
+ for (const c of litCells(layout)) cells[c.glyph].push({ x: c.x, y: c.y });
173
+ const half = Math.floor(layout.font.cols / 2);
174
+ const centers = layout.starts.map((s) => s + half);
175
+ // Sunk depths: the glyph tops sit in the bottom few rows, uneven like a pile.
176
+ const sinks = centers.map(() => 5 + Math.floor(rand() * 3));
177
+ const slipSlot = n > 3 ? 2 + Math.floor(rand() * (n - 3)) : -1;
178
+
179
+ const o = options;
180
+ const d = new Director(-half - 2, RAIL_HEAD, sinks);
181
+ d.go(o.enterMs, { cx: centers[0] }, easeOut);
182
+ for (let slot = 0; slot < n; slot += 1) {
183
+ const pace = Math.max(o.paceMin, o.paceStart - (o.paceStart - o.paceMin) * (slot / Math.max(1, n - 2)));
184
+ if (slot > 0) d.go(o.travelMs * pace, { cx: centers[slot], head: RAIL_HEAD });
185
+ const attempts = slot === slipSlot ? 2 : 1;
186
+ for (let a = 0; a < attempts; a += 1) {
187
+ const sink = sinks[slot];
188
+ d.go(o.dropMs * pace, { head: gripHead(sink) }, easeIn);
189
+ d.grip(true);
190
+ if (d.gripped[slot] == null) d.gripped[slot] = d.t;
191
+ d.wait(o.gripMs * pace);
192
+ if (a < attempts - 1) {
193
+ // The slip: halfway up the glyph falls back, the claw springs open and rises empty.
194
+ const mid = Math.ceil(sink / 2);
195
+ const up = d.go((o.liftMs * pace) / 2, { head: gripHead(mid) }, easeOut);
196
+ d.shift(slot, up.t0, up.t1, mid, easeOut);
197
+ d.grip(false);
198
+ d.dropped.push({ slot, t: d.t });
199
+ d.shift(slot, d.t, d.t + o.dropMs * 0.8, sink, easeIn);
200
+ d.go(o.dropMs, { head: RAIL_HEAD }, easeOut);
201
+ d.wait(o.releaseMs);
202
+ continue;
203
+ }
204
+ const up = d.go(o.liftMs * pace, { head: gripHead(0) }, easeOut);
205
+ d.shift(slot, up.t0, up.t1, 0, easeOut);
206
+ d.grip(false);
207
+ d.placed[slot] = d.t;
208
+ d.wait(o.releaseMs * pace);
209
+ }
210
+ }
211
+ d.go(o.exitMs, { cx: width + half + 2, head: RAIL_HEAD - 4 }, easeIn);
212
+ const ms = Math.max(d.t, (d.placed.at(-1) ?? 0) + o.flashMs);
213
+ const script = d.script(0, d.t, PILE);
214
+ return { width, height, layout, cells, centers, sinks, slipSlot, script, seed: def.seed, options, ms };
215
+ },
216
+ loadingMs: (state) => state.ms,
217
+ loading,
218
+ live,
219
+ still: settled,
220
+ };
221
+ }
222
+
223
+ const PILE: Pixel = { tone: "accent", alpha: 0.35 };
224
+ const HELD: Pixel = { tone: "ink", alpha: 0.6 };
225
+ const CLAW: Pixel = { tone: "accent", alpha: 1 };
226
+ const CABLE: Pixel = { tone: "accent", alpha: 0.55 };
227
+ const GLINT: Pixel = { tone: "glint", alpha: 1 };
228
+ const INK: Pixel = { tone: "ink", alpha: 1 };
229
+
230
+ function isClosed(script: Script, t: number): boolean {
231
+ let closed = false;
232
+ for (const c of script.closed) {
233
+ if (c.t > t) break;
234
+ closed = c.closed;
235
+ }
236
+ return closed;
237
+ }
238
+
239
+ /** The claw: cable from the rail, a 3-wide head bar, two prongs (open: splayed;
240
+ * closed: tips turned in). */
241
+ export function drawClaw(frame: Frame, cx: number, head: number, closed: boolean) {
242
+ for (let y = 0; y < head; y += 1) setPixel(frame, cx, y, CABLE);
243
+ for (let dx = -1; dx <= 1; dx += 1) setPixel(frame, cx + dx, head, CLAW);
244
+ setPixel(frame, cx - 2, head + 1, CLAW);
245
+ setPixel(frame, cx + 2, head + 1, CLAW);
246
+ const tip = closed ? 1 : 3;
247
+ setPixel(frame, cx - tip, head + 2, CLAW);
248
+ setPixel(frame, cx + tip, head + 2, CLAW);
249
+ }
250
+
251
+ function glyphPixel(state: ClawState, script: Script, slot: number, t: number): Pixel {
252
+ const placed = script.placed[slot];
253
+ if (placed != null && t >= placed) return t < placed + state.options.flashMs ? GLINT : INK;
254
+ const drop = script.dropped.find((d) => d.slot === slot && d.t <= t);
255
+ const regrip = script.closed.find((c) => c.closed && drop && c.t > drop.t && c.t <= t);
256
+ const gripped = script.gripped[slot];
257
+ if (drop && !regrip) return script.rest;
258
+ return gripped != null && t >= gripped ? HELD : script.rest;
259
+ }
260
+
261
+ function paint(state: ClawState, script: Script, t: number, frame: Frame) {
262
+ state.cells.forEach((cells, slot) => {
263
+ const off = Math.round(valueAt(script.offset[slot], t));
264
+ const px = glyphPixel(state, script, slot, t);
265
+ for (const c of cells) setPixel(frame, c.x, c.y + off, px);
266
+ });
267
+ if (t >= script.from && t < script.to) {
268
+ drawClaw(frame, Math.round(valueAt(script.cx, t)), Math.round(valueAt(script.head, t)), isClosed(script, t));
269
+ }
270
+ }
271
+
272
+ function loading(state: ClawState, t: number): Frame {
273
+ const frame = emptyFrame(state.width, state.height);
274
+ // The rail: faint, fading out as the claw leaves.
275
+ const exitAt = state.script.to - state.options.exitMs;
276
+ const rail = t < exitAt ? 0.22 : 0.22 * Math.max(0, 1 - (t - exitAt) / state.options.exitMs);
277
+ if (rail > 0.01) for (let x = 0; x < state.width; x += 1) setPixel(frame, x, 0, { tone: "accent", alpha: rail });
278
+ paint(state, state.script, t, frame);
279
+ return frame;
280
+ }
281
+
282
+ function settled(state: ClawState): Frame {
283
+ const frame = emptyFrame(state.width, state.height);
284
+ for (const slot of state.cells) for (const c of slot) setPixel(frame, c.x, c.y, INK);
285
+ return frame;
286
+ }
287
+
288
+ export type ClawEvent = { at: number; kind: "pluck" | "slip"; slot: number };
289
+
290
+ /** LIVE event `i`, seeded per index so any moment can be sampled. */
291
+ export function liveEvent(state: ClawState, i: number): ClawEvent {
292
+ const rand = mulberry32((state.seed ^ Math.imul(i + 1, 0x9e3779b1)) >>> 0);
293
+ const { everyMs } = state.options;
294
+ const at = i * everyMs + Math.floor(rand() * everyMs * 0.4);
295
+ const kind = rand() < 1 / 3 ? "slip" : "pluck";
296
+ const slot = Math.floor(rand() * state.cells.length);
297
+ return { at, kind, slot };
298
+ }
299
+
300
+ /** The script of one LIVE event, in ms from its start. */
301
+ export function liveScript(state: ClawState, ev: ClawEvent): Script {
302
+ const o = state.options;
303
+ const n = state.cells.length;
304
+ const d = new Director(state.centers[ev.slot], RAIL_HEAD - 4, Array(n).fill(0));
305
+ d.go(o.dropMs * 1.6, { head: gripHead(0) }, easeIn);
306
+ d.grip(true);
307
+ d.gripped[ev.slot] = d.t;
308
+ d.wait(o.gripMs);
309
+ const lift = ev.kind === "slip" ? 3 : 2;
310
+ const up = d.go(o.liftMs, { head: gripHead(-lift) }, easeOut);
311
+ d.shift(ev.slot, up.t0, up.t1, -lift, easeOut);
312
+ d.wait(o.releaseMs * 2);
313
+ if (ev.kind === "slip") {
314
+ d.grip(false);
315
+ // Falls home, bounces one row, lands.
316
+ const fall = o.dropMs * 0.7;
317
+ d.shift(ev.slot, d.t, d.t + fall, 0, easeIn);
318
+ d.shift(ev.slot, d.t + fall, d.t + fall + 70, -1, easeOut);
319
+ d.shift(ev.slot, d.t + fall + 70, d.t + fall + 140, 0, easeIn);
320
+ d.placed[ev.slot] = d.t + fall + 140;
321
+ d.go(o.dropMs * 1.6, { head: RAIL_HEAD - 4 }, easeIn);
322
+ return d.script(0, d.t, INK);
323
+ }
324
+ const down = d.go(o.liftMs, { head: gripHead(0) }, easeInOut);
325
+ d.shift(ev.slot, down.t0, down.t1, 0, easeInOut);
326
+ d.grip(false);
327
+ d.placed[ev.slot] = d.t;
328
+ d.wait(o.releaseMs);
329
+ d.go(o.dropMs * 1.6, { head: RAIL_HEAD - 4 }, easeIn);
330
+ return d.script(0, d.t, INK);
331
+ }
332
+
333
+ function live(state: ClawState, tLive: number): Frame {
334
+ const i = Math.floor(tLive / state.options.everyMs);
335
+ for (const j of [i, i - 1]) {
336
+ if (j < 0) continue;
337
+ const ev = liveEvent(state, j);
338
+ const dt = tLive - ev.at;
339
+ const script = liveScript(state, ev);
340
+ if (dt < 0 || dt >= Math.max(script.to, (script.placed[ev.slot] ?? 0) + state.options.flashMs)) continue;
341
+ const frame = emptyFrame(state.width, state.height);
342
+ paint(state, script, dt, frame);
343
+ return frame;
344
+ }
345
+ return settled(state);
346
+ }
@@ -0,0 +1,59 @@
1
+ import { emptyFrame, litCells, setPixel } from "../layout";
2
+ import type { Animation, Frame, Layout } from "../types";
3
+
4
+ /**
5
+ * EQUALIZER (digivoice). Every pixel of the word is lit in the rest tone; a
6
+ * level rises and falls per column and brightens pixels from the bottom up.
7
+ * LOADING: the level swells from silence to full over `buildMs`. LIVE: the
8
+ * level keeps moving. The settled mark is the frame at `buildMs`, the moment
9
+ * digivoice/tui/src/hero.js draws by default.
10
+ * Port of digivoice/tui/src/hero.js (#4965 / #4959): same level function.
11
+ */
12
+
13
+ export type EqualizerOptions = { buildMs: number };
14
+
15
+ type Column = { x: number; ys: number[] };
16
+
17
+ export type EqualizerState = { width: number; height: number; columns: Column[]; buildMs: number };
18
+
19
+ /** 0..1 level of `column` at `tMs`. Two sines, slow and fast. */
20
+ export function levelFrac(column: number, tMs: number): number {
21
+ const t = Math.max(0, Number.isFinite(tMs) ? tMs : 0);
22
+ const slow = Math.sin(t / 380 + column * 0.73);
23
+ const fast = Math.sin(t / 170 + column * 1.37);
24
+ return (slow * 0.65 + fast * 0.35 + 1) / 2;
25
+ }
26
+
27
+ function draw(state: EqualizerState, tMs: number, envelope: number): Frame {
28
+ const frame = emptyFrame(state.width, state.height);
29
+ for (const column of state.columns) {
30
+ const reach = levelFrac(column.x, tMs) * column.ys.length * envelope;
31
+ column.ys.forEach((y, index) => {
32
+ setPixel(frame, column.x, y, { tone: index < reach ? "lit" : "rest", alpha: 1 });
33
+ });
34
+ }
35
+ return frame;
36
+ }
37
+
38
+ export function equalizerAnimation(options: EqualizerOptions): Animation<EqualizerState> {
39
+ return {
40
+ kind: "equalizer",
41
+ liveTickMs: 60,
42
+ prepare(layout: Layout) {
43
+ const byX = new Map<number, number[]>();
44
+ for (const cell of litCells(layout)) {
45
+ const ys = byX.get(cell.x) ?? [];
46
+ ys.push(cell.y);
47
+ byX.set(cell.x, ys);
48
+ }
49
+ const columns = [...byX.entries()]
50
+ .sort((a, b) => a[0] - b[0])
51
+ .map(([x, ys]) => ({ x, ys: ys.sort((a, b) => b - a) }));
52
+ return { width: layout.width, height: layout.height, columns, buildMs: options.buildMs };
53
+ },
54
+ loadingMs: (state) => state.buildMs,
55
+ loading: (state, t) => draw(state, t, Math.min(1, Math.max(0, t / state.buildMs))),
56
+ live: (state, _tLive, tMount) => draw(state, tMount, 1),
57
+ still: (state) => draw(state, state.buildMs, 1),
58
+ };
59
+ }