@thesimonharms/basa 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.
@@ -0,0 +1,281 @@
1
+ import { BaseComponent } from '@mudah-cli/tui';
2
+ import { detectCapabilities, type KeyEvent } from '@mudah-cli/terminal';
3
+ import { paint, visibleLength } from '@mudah-cli/ui';
4
+ import type { Grade, ReviewCard } from '../flashcards/types.js';
5
+ import { renderSide, type RenderContext, type RenderedSide } from '../flashcards/render.js';
6
+ import {
7
+ confettiRows,
8
+ shakeRows,
9
+ typeOnRows,
10
+ type ConfettiSpec,
11
+ type ShakeState,
12
+ type TypeOnState,
13
+ } from './effects.js';
14
+
15
+ export interface CardViewOptions {
16
+ deckPath: string;
17
+ cellsWidth: number;
18
+ cellsHeight: number;
19
+ onGraded: (grade: Grade, typed: string) => void;
20
+ onSkip: () => void;
21
+ onDone?: () => void;
22
+ }
23
+
24
+ export interface SessionStats {
25
+ reviewed: number;
26
+ again: number;
27
+ hard: number;
28
+ good: number;
29
+ easy: number;
30
+ }
31
+
32
+ interface EffectFrame {
33
+ /** Centered, padded text content. */
34
+ rows: string[];
35
+ /** Image rows to draw below the text. */
36
+ imageRows: string[];
37
+ }
38
+
39
+ /**
40
+ * The flashcard face. Renders the front; on space, animates the back in.
41
+ * Keys 1-4 grade. `n` skips (no grade recorded). `enter` submits typed answer.
42
+ *
43
+ * Owns its own animation state — tick() is called by the host every frame.
44
+ */
45
+ export class CardView extends BaseComponent {
46
+ readonly focusable = true;
47
+ private typed = '';
48
+
49
+ private front: RenderedSide = { text: [], imageRows: [] };
50
+ private back: RenderedSide = { text: [], imageRows: [] };
51
+ private doneStats: SessionStats | null = null;
52
+
53
+ private revealed = false;
54
+ private revealAnim: TypeOnState = { durationFrames: 8, frame: 0 };
55
+ private shake: ShakeState | null = null;
56
+ private confetti: ConfettiSpec | null = null;
57
+
58
+ private readonly ctx: RenderContext;
59
+ private current: ReviewCard | undefined;
60
+
61
+ private readonly options: CardViewOptions;
62
+
63
+ constructor(options: CardViewOptions) {
64
+ super();
65
+ this.options = options;
66
+ this.ctx = {
67
+ deckPath: options.deckPath,
68
+ cellsWidth: options.cellsWidth,
69
+ cellsHeight: options.cellsHeight,
70
+ imageCache: new Map(),
71
+ };
72
+ }
73
+
74
+ /** Update the active card. Resets reveal + animation state. */
75
+ async setCard(card: ReviewCard | undefined, stats?: SessionStats): Promise<void> {
76
+ this.current = card;
77
+ this.revealed = false;
78
+ this.typed = '';
79
+ this.shake = null;
80
+ this.confetti = null;
81
+ this.revealAnim = { durationFrames: 8, frame: 0 };
82
+ if (card === undefined) {
83
+ this.doneStats = stats ?? null;
84
+ this.front = { text: this.doneViewText(), imageRows: [] };
85
+ this.back = { text: [], imageRows: [] };
86
+ this.options.onDone?.();
87
+ return;
88
+ }
89
+ this.doneStats = null;
90
+ this.front = await renderSide(card.card.front, this.ctx);
91
+ this.back = await renderSide(card.card.back, this.ctx);
92
+ }
93
+
94
+ private doneViewText(): string[] {
95
+ if (this.doneStats === null) {
96
+ return ['All caught up!', 'Press Esc to quit.'];
97
+ }
98
+ const s = this.doneStats;
99
+ return [
100
+ '',
101
+ ' ╭───────────────────────────────────────╮',
102
+ ' │ │',
103
+ ' │ session complete! │',
104
+ ' │ │',
105
+ ` │ reviewed ${pad(s.reviewed, 3)} cards │`,
106
+ ` │ again ${pad(s.again, 3)} hard ${pad(s.hard, 3)} good ${pad(s.good, 3)} easy ${pad(s.easy, 3)} │`,
107
+ ' │ │',
108
+ ' │ press esc to exit │',
109
+ ' │ │',
110
+ ' ╰───────────────────────────────────────╯',
111
+ '',
112
+ ];
113
+ }
114
+
115
+ /** Drive animations. Called by the host once per repaint. */
116
+ tick(): void {
117
+ if (this.revealed && this.revealAnim.frame < this.revealAnim.durationFrames) {
118
+ this.revealAnim.frame++;
119
+ }
120
+ if (this.shake !== null && this.shake.frame < this.shake.durationFrames) {
121
+ this.shake.frame++;
122
+ if (this.shake.frame >= this.shake.durationFrames) this.shake = null;
123
+ }
124
+ if (this.confetti !== null && this.confetti.frame < this.confetti.durationFrames) {
125
+ this.confetti.frame++;
126
+ if (this.confetti.frame >= this.confetti.durationFrames) this.confetti = null;
127
+ }
128
+ }
129
+
130
+ /** Resize the rendering area (after a terminal resize). */
131
+ resize(cellsWidth: number, cellsHeight: number): void {
132
+ this.ctx.cellsWidth = cellsWidth;
133
+ this.ctx.cellsHeight = cellsHeight;
134
+ this.ctx.imageCache.clear();
135
+ if (this.current !== undefined) {
136
+ // Re-render in the background. Until the new rows arrive, the existing
137
+ // rows still display — no flicker, no jump.
138
+ void this.setCard(this.current);
139
+ }
140
+ }
141
+
142
+ override onKey(event: KeyEvent): boolean {
143
+ if (event.name === 'space' || event.name === 'enter') {
144
+ this.revealed = true;
145
+ this.revealAnim = { durationFrames: 8, frame: 0 };
146
+ return true;
147
+ }
148
+ if (event.name === 'backspace') {
149
+ this.typed = this.typed.slice(0, -1);
150
+ return true;
151
+ }
152
+ if (this.revealed) {
153
+ switch (event.name) {
154
+ case '1':
155
+ this.grade(0);
156
+ return true;
157
+ case '2':
158
+ this.grade(1);
159
+ return true;
160
+ case '3':
161
+ this.grade(2);
162
+ return true;
163
+ case '4':
164
+ this.grade(3);
165
+ return true;
166
+ }
167
+ }
168
+ if (event.name === 'n') {
169
+ this.options.onSkip();
170
+ return true;
171
+ }
172
+ if (event.ch !== undefined && event.ch >= ' ' && event.ch !== 'n' && (event.ch < '0' || event.ch > '9')) {
173
+ this.typed += event.ch;
174
+ return true;
175
+ }
176
+ return false;
177
+ }
178
+
179
+ private grade(g: Grade): void {
180
+ this.options.onGraded(g, this.typed);
181
+ if (g === 0) {
182
+ this.shake = { durationFrames: 10, frame: 0 };
183
+ } else if (g === 3) {
184
+ this.confetti = {
185
+ frame: 0,
186
+ durationFrames: 24,
187
+ seed: Math.floor(Math.random() * 1e6),
188
+ width: this.options.cellsWidth,
189
+ palette: ['\x1b[38;5;213m', '\x1b[38;5;215m', '\x1b[38;5;220m', '\x1b[38;5;156m', '\x1b[38;5;123m'],
190
+ };
191
+ }
192
+ this.typed = '';
193
+ }
194
+
195
+ render(): string[] {
196
+ const out: string[] = [];
197
+
198
+ // Header line: prompt + type input
199
+ const lvl = detectCapabilities().colorLevel;
200
+ const promptText = this.revealed
201
+ ? paint('#fbbf24', 'Type your answer, then press 1-4 to grade', lvl)
202
+ : paint('#94a3b8', 'Press Space to reveal', lvl);
203
+ const typed = this.typed.length > 0 ? paint('#e2e8f0', ` > ${this.typed}▏`, lvl) : '';
204
+ out.push(centerLine(`${promptText}${typed}`, this.options.cellsWidth));
205
+
206
+ // Spacer
207
+ out.push('');
208
+
209
+ // Card front
210
+ const frontRows = this.front.text;
211
+ const centeredFront = frontRows.map((row) => centerLine(row, this.options.cellsWidth));
212
+ out.push(...this.applyEffects(centeredFront, 'front'));
213
+
214
+ // Spacer
215
+ out.push('');
216
+
217
+ // Divider
218
+ if (this.revealed) {
219
+ out.push(centerLine(paint('#475569', '─'.repeat(Math.min(60, this.options.cellsWidth - 4)), lvl), this.options.cellsWidth));
220
+ out.push('');
221
+
222
+ const backRows = this.back.text;
223
+ const typedBack = this.revealed ? typeOnRows(this.revealAnim, backRows) : [];
224
+ const centeredBack = typedBack.map((row) => centerLine(row, this.options.cellsWidth));
225
+ out.push(...this.applyEffects(centeredBack, 'back'));
226
+
227
+ // Hint, if any
228
+ if (this.current?.card.hint !== undefined && this.revealed) {
229
+ out.push('');
230
+ out.push(centerLine(paint('#64748b', `hint: ${this.current.card.hint}`, lvl), this.options.cellsWidth));
231
+ }
232
+ }
233
+
234
+ // Image rows (always rendered below the text).
235
+ const imageRows = this.revealed
236
+ ? [...this.front.imageRows, ...this.back.imageRows]
237
+ : this.front.imageRows;
238
+ for (const row of imageRows) {
239
+ out.push(centerLine(row, this.options.cellsWidth));
240
+ }
241
+
242
+ // Confetti overlay
243
+ if (this.confetti !== null) {
244
+ const overlay = confettiRows(this.confetti, Math.min(8, this.options.cellsHeight));
245
+ for (let i = 0; i < overlay.length; i++) {
246
+ out[out.length - overlay.length + i] = overlay[i] ?? '';
247
+ }
248
+ }
249
+
250
+ // Pad / truncate to fit the cell height so the screen layout is stable.
251
+ while (out.length < this.options.cellsHeight) out.push('');
252
+ if (out.length > this.options.cellsHeight) out.length = this.options.cellsHeight;
253
+
254
+ return out;
255
+ }
256
+
257
+ private applyEffects(rows: string[], seedKey: string): string[] {
258
+ let out = rows;
259
+ if (this.shake !== null) {
260
+ out = shakeRows(this.shake, out, hashString(`${seedKey}:${this.current?.card.front ?? ''}`));
261
+ }
262
+ return out;
263
+ }
264
+ }
265
+
266
+ function centerLine(text: string, width: number): string {
267
+ const visible = visibleLength(text);
268
+ if (visible >= width) return text;
269
+ const pad = Math.floor((width - visible) / 2);
270
+ return ' '.repeat(pad) + text;
271
+ }
272
+
273
+ function hashString(s: string): number {
274
+ let h = 0;
275
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
276
+ return h;
277
+ }
278
+
279
+ function pad(n: number, width: number): string {
280
+ return String(n).padStart(width, ' ');
281
+ }
@@ -0,0 +1,65 @@
1
+ import { BaseComponent } from '@mudah-cli/tui';
2
+ import { detectCapabilities } from '@mudah-cli/terminal';
3
+ import { paint } from '@mudah-cli/ui';
4
+
5
+ const KEYS: ReadonlyArray<{ key: string; label: string; tone: 'good' | 'meh' | 'bad' | 'neutral' }> = [
6
+ { key: '1', label: 'Again', tone: 'bad' },
7
+ { key: '2', label: 'Hard', tone: 'meh' },
8
+ { key: '3', label: 'Good', tone: 'good' },
9
+ { key: '4', label: 'Easy', tone: 'good' },
10
+ ];
11
+
12
+ const TONES: Record<'good' | 'meh' | 'bad' | 'neutral', string> = {
13
+ good: '#4ade80',
14
+ meh: '#fbbf24',
15
+ bad: '#f87171',
16
+ neutral: '#94a3b8',
17
+ };
18
+
19
+ /** The key legend. Centered, three rows: title, four key chips, hints. */
20
+ export class Footer extends BaseComponent {
21
+ readonly focusable = false;
22
+
23
+ private width: number;
24
+
25
+ constructor(width: number) {
26
+ super();
27
+ this.width = width;
28
+ }
29
+
30
+ resize(width: number): void {
31
+ this.width = width;
32
+ }
33
+
34
+ render(): string[] {
35
+ const lvl = detectCapabilities().colorLevel;
36
+ const chips = KEYS.map((k) => {
37
+ const color = TONES[k.tone];
38
+ return `${paint('#0f172a', ` ${k.key} `, lvl)}${paint(color, ` ${k.label} `, lvl)}`;
39
+ }).join(' ');
40
+ const row1 = centerLine(chips, this.width);
41
+ const row2 = centerLine(
42
+ paint('#64748b', 'space/enter: reveal n: skip esc: quit', lvl),
43
+ this.width,
44
+ );
45
+ return ['', row1, row2];
46
+ }
47
+ }
48
+
49
+ function centerLine(text: string, width: number): string {
50
+ return ' '.repeat(Math.max(0, Math.floor((width - visibleOf(text)) / 2))) + text;
51
+ }
52
+
53
+ function visibleOf(text: string): number {
54
+ let visible = 0;
55
+ let inEscape = false;
56
+ for (const ch of text) {
57
+ if (ch === '\x1b') { inEscape = true; continue; }
58
+ if (inEscape) {
59
+ if (ch === 'm') inEscape = false;
60
+ continue;
61
+ }
62
+ visible++;
63
+ }
64
+ return visible;
65
+ }
@@ -0,0 +1,58 @@
1
+ import { BaseComponent } from '@mudah-cli/tui';
2
+ import { detectCapabilities } from '@mudah-cli/terminal';
3
+ import { paint } from '@mudah-cli/ui';
4
+
5
+ export interface HeaderOptions {
6
+ deckName: string;
7
+ reviewed: number;
8
+ total: number;
9
+ width: number;
10
+ }
11
+
12
+ /** Deck name + progress bar. One row, centered. */
13
+ export class Header extends BaseComponent {
14
+ readonly focusable = false;
15
+
16
+ private readonly options: HeaderOptions;
17
+
18
+ constructor(options: HeaderOptions) {
19
+ super();
20
+ this.options = options;
21
+ }
22
+
23
+ update(options: HeaderOptions): void {
24
+ Object.assign(this.options, options);
25
+ }
26
+
27
+ render(): string[] {
28
+ const lvl = detectCapabilities().colorLevel;
29
+ const barWidth = Math.max(10, Math.min(40, this.options.width - 30));
30
+ const ratio = this.options.total > 0 ? this.options.reviewed / this.options.total : 0;
31
+ const filled = Math.round(ratio * barWidth);
32
+ const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
33
+ const left = paint('#e2e8f0', this.options.deckName, lvl);
34
+ const right = paint('#94a3b8', ` ${this.options.reviewed}/${this.options.total}`, lvl);
35
+ const progress = paint(ratio >= 1 ? '#4ade80' : '#60a5fa', bar, lvl);
36
+ const line = `${left} ${progress}${right}`;
37
+ return [centerLine(line, this.options.width)];
38
+ }
39
+ }
40
+
41
+ function centerLine(text: string, width: number): string {
42
+ // Use a simple visible-length estimate: count chars, ignore ANSI.
43
+ return ' '.repeat(Math.max(0, Math.floor((width - visibleOf(text)) / 2))) + text;
44
+ }
45
+
46
+ function visibleOf(text: string): number {
47
+ let visible = 0;
48
+ let inEscape = false;
49
+ for (const ch of text) {
50
+ if (ch === '\x1b') { inEscape = true; continue; }
51
+ if (inEscape) {
52
+ if (ch === 'm') inEscape = false;
53
+ continue;
54
+ }
55
+ visible++;
56
+ }
57
+ return visible;
58
+ }
@@ -0,0 +1,147 @@
1
+ import { Container } from '@mudah-cli/tui';
2
+ import type { Deck, Grade, ReviewCard } from '../flashcards/types.js';
3
+ import { grade, pickNext } from '../flashcards/srs.js';
4
+ import { saveReviewCards } from '../flashcards/deck.js';
5
+ import { BasaFx } from '../flashcards/sound.js';
6
+ import { CardView } from './CardView.js';
7
+ import { Header } from './Header.js';
8
+ import { Footer } from './Footer.js';
9
+
10
+ export interface StudyAppOptions {
11
+ deck: Deck;
12
+ deckPath: string;
13
+ cards: ReviewCard[];
14
+ width: number;
15
+ height: number;
16
+ fx: BasaFx;
17
+ }
18
+
19
+ interface SessionStats {
20
+ reviewed: number;
21
+ again: number;
22
+ hard: number;
23
+ good: number;
24
+ easy: number;
25
+ }
26
+
27
+ /**
28
+ * The study session. Owns SRS state, the focused card, the session stats.
29
+ * Exposes a `Container` for the TUI program to mount, plus `tick()` and
30
+ * `resize()` methods the host calls every frame and on terminal resize.
31
+ */
32
+ export class StudyApp {
33
+ readonly root: Container;
34
+ private readonly cardView: CardView;
35
+ private readonly header: Header;
36
+ private readonly footer: Footer;
37
+ private cards: ReviewCard[];
38
+ private current: ReviewCard | undefined;
39
+ private stats: SessionStats = { reviewed: 0, again: 0, hard: 0, good: 0, easy: 0 };
40
+ private width: number;
41
+ private height: number;
42
+
43
+ private readonly options: StudyAppOptions;
44
+
45
+ constructor(options: StudyAppOptions) {
46
+ this.options = options;
47
+ this.cards = options.cards;
48
+ this.width = options.width;
49
+ this.height = options.height;
50
+
51
+ const cardHeight = Math.max(8, options.height - 8);
52
+ this.cardView = new CardView({
53
+ deckPath: options.deckPath,
54
+ cellsWidth: options.width,
55
+ cellsHeight: cardHeight,
56
+ onGraded: (g, typed) => void this.handleGrade(g, typed),
57
+ onSkip: () => this.handleSkip(),
58
+ });
59
+ this.header = new Header({
60
+ deckName: options.deck.name,
61
+ reviewed: 0,
62
+ total: this.cards.length,
63
+ width: options.width,
64
+ });
65
+ this.footer = new Footer(options.width);
66
+
67
+ this.root = new Container().add(this.header).add(this.cardView).add(this.footer);
68
+ void this.advance();
69
+ }
70
+
71
+ /** Drive animations. Called by the host on every frame. */
72
+ tick(): void {
73
+ this.cardView.tick();
74
+ }
75
+
76
+ /** Apply a terminal resize. */
77
+ resize(width: number, height: number): void {
78
+ this.width = width;
79
+ this.height = height;
80
+ this.header.update({
81
+ deckName: this.options.deck.name,
82
+ reviewed: this.stats.reviewed,
83
+ total: this.cards.length,
84
+ width,
85
+ });
86
+ this.footer.resize(width);
87
+ const cardHeight = Math.max(8, height - 8);
88
+ this.cardView.resize(width, cardHeight);
89
+ }
90
+
91
+ /** Persist state on shutdown. Safe to call multiple times. */
92
+ async persist(): Promise<void> {
93
+ await saveReviewCards(this.options.deckPath, this.cards).catch(() => {});
94
+ }
95
+
96
+ private async advance(): Promise<void> {
97
+ this.current = pickNext(this.cards, Date.now());
98
+ if (this.current === undefined) {
99
+ await this.cardView.setCard(undefined, this.stats);
100
+ return;
101
+ }
102
+ await this.cardView.setCard(this.current);
103
+ }
104
+
105
+ private async handleGrade(gradeValue: Grade, _typed: string): Promise<void> {
106
+ if (this.current === undefined) return;
107
+ const idx = this.cards.indexOf(this.current);
108
+ if (idx < 0) return;
109
+ const next = grade(this.current.state, gradeValue);
110
+ this.cards[idx] = { card: this.current.card, state: next };
111
+ this.stats = {
112
+ reviewed: this.stats.reviewed + 1,
113
+ again: this.stats.again + (gradeValue === 0 ? 1 : 0),
114
+ hard: this.stats.hard + (gradeValue === 1 ? 1 : 0),
115
+ good: this.stats.good + (gradeValue === 2 ? 1 : 0),
116
+ easy: this.stats.easy + (gradeValue === 3 ? 1 : 0),
117
+ };
118
+ this.header.update({
119
+ deckName: this.options.deck.name,
120
+ reviewed: this.stats.reviewed,
121
+ total: this.cards.length,
122
+ width: this.width,
123
+ });
124
+
125
+ if (gradeValue === 0) {
126
+ await this.options.fx.playIncorrect();
127
+ } else {
128
+ await this.options.fx.playCorrect(gradeValue);
129
+ }
130
+
131
+ // Persist after every grade so a crash doesn't lose progress.
132
+ await saveReviewCards(this.options.deckPath, this.cards).catch(() => {});
133
+
134
+ await this.advance();
135
+ }
136
+
137
+ private handleSkip(): void {
138
+ if (this.current === undefined) return;
139
+ const idx = this.cards.indexOf(this.current);
140
+ if (idx < 0) return;
141
+ this.cards[idx] = {
142
+ card: this.current.card,
143
+ state: { ...this.current.state, due: Date.now() + 30_000 },
144
+ };
145
+ void this.advance();
146
+ }
147
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Visual effect helpers. Each is a small mutable object that the card view
3
+ * ticks every frame. They never touch the terminal directly — they just
4
+ * transform strings the component is about to render.
5
+ */
6
+
7
+ export interface TypeOnState {
8
+ /** Total frames the effect should run for. */
9
+ durationFrames: number;
10
+ /** Frame counter, incremented by the host. */
11
+ frame: number;
12
+ }
13
+
14
+ /** Reveal a string array row-by-row. 0 → 0 rows, 1 → all rows. */
15
+ export function typeOnRows(state: TypeOnState, rows: string[]): string[] {
16
+ const t = Math.min(1, state.frame / Math.max(1, state.durationFrames));
17
+ const visible = Math.max(1, Math.ceil(rows.length * t));
18
+ return rows.slice(0, visible);
19
+ }
20
+
21
+ export interface ShakeState {
22
+ durationFrames: number;
23
+ frame: number;
24
+ }
25
+
26
+ /** Apply a per-row horizontal jitter that decays. The component pads each
27
+ * row with random leading spaces within a window that shrinks over time. */
28
+ export function shakeRows(state: ShakeState, rows: string[], seed: number): string[] {
29
+ const t = Math.min(1, state.frame / Math.max(1, state.durationFrames));
30
+ const amplitude = Math.round((1 - t) * 3);
31
+ if (amplitude === 0) return rows;
32
+ return rows.map((row, i) => {
33
+ // Deterministic pseudo-random per row+frame so the diff renderer still works.
34
+ const x = pseudo(seed + i * 7 + state.frame * 13);
35
+ const offset = Math.floor(x * (amplitude * 2 + 1)) - amplitude;
36
+ if (offset === 0) return row;
37
+ if (offset > 0) return ' '.repeat(offset) + row;
38
+ return row.slice(-offset);
39
+ });
40
+ }
41
+
42
+ function pseudo(n: number): number {
43
+ const x = Math.sin(n * 12.9898) * 43758.5453;
44
+ return x - Math.floor(x);
45
+ }
46
+
47
+ export interface ConfettiSpec {
48
+ /** Frame counter, incremented by the host. */
49
+ frame: number;
50
+ /** Total frames. */
51
+ durationFrames: number;
52
+ /** Pseudo-random seed. */
53
+ seed: number;
54
+ /** Width in cells. */
55
+ width: number;
56
+ /** Palette: 24-bit color codes as strings. */
57
+ palette: string[];
58
+ }
59
+
60
+ /** Render a confetti overlay as N rows. Each row is a string. */
61
+ export function confettiRows(spec: ConfettiSpec, height: number): string[] {
62
+ const out: string[] = [];
63
+ for (let row = 0; row < height; row++) {
64
+ let line = '';
65
+ for (let col = 0; col < spec.width; col++) {
66
+ const x = pseudo(spec.seed + row * 91 + col * 17 + spec.frame * 5);
67
+ const visible = x > 0.65;
68
+ if (!visible) {
69
+ line += ' ';
70
+ continue;
71
+ }
72
+ const colorIndex = Math.floor(pseudo(spec.seed + row + col + spec.frame * 3) * spec.palette.length);
73
+ const color = spec.palette[colorIndex] ?? spec.palette[0]!;
74
+ const glyphs = ['✦', '✧', '•', '·', '◆', '◇'];
75
+ const glyph = glyphs[Math.floor(pseudo(spec.seed + col + row) * glyphs.length)] ?? '·';
76
+ line += `${color}${glyph}\x1b[0m`;
77
+ }
78
+ out.push(line);
79
+ }
80
+ return out;
81
+ }