@pep/term-deck 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +356 -0
  3. package/bin/term-deck.ts +45 -0
  4. package/examples/slides/01-welcome.md +9 -0
  5. package/examples/slides/02-features.md +12 -0
  6. package/examples/slides/03-colors.md +17 -0
  7. package/examples/slides/04-ascii-art.md +11 -0
  8. package/examples/slides/05-gradients.md +14 -0
  9. package/examples/slides/06-themes.md +13 -0
  10. package/examples/slides/07-markdown.md +13 -0
  11. package/examples/slides/08-controls.md +13 -0
  12. package/examples/slides/09-thanks.md +11 -0
  13. package/examples/slides/deck.config.ts +13 -0
  14. package/examples/slides-hacker/01-welcome.md +9 -0
  15. package/examples/slides-hacker/02-features.md +12 -0
  16. package/examples/slides-hacker/03-colors.md +17 -0
  17. package/examples/slides-hacker/04-ascii-art.md +11 -0
  18. package/examples/slides-hacker/05-gradients.md +14 -0
  19. package/examples/slides-hacker/06-themes.md +13 -0
  20. package/examples/slides-hacker/07-markdown.md +13 -0
  21. package/examples/slides-hacker/08-controls.md +13 -0
  22. package/examples/slides-hacker/09-thanks.md +11 -0
  23. package/examples/slides-hacker/deck.config.ts +13 -0
  24. package/examples/slides-matrix/01-welcome.md +9 -0
  25. package/examples/slides-matrix/02-features.md +12 -0
  26. package/examples/slides-matrix/03-colors.md +17 -0
  27. package/examples/slides-matrix/04-ascii-art.md +11 -0
  28. package/examples/slides-matrix/05-gradients.md +14 -0
  29. package/examples/slides-matrix/06-themes.md +13 -0
  30. package/examples/slides-matrix/07-markdown.md +13 -0
  31. package/examples/slides-matrix/08-controls.md +13 -0
  32. package/examples/slides-matrix/09-thanks.md +11 -0
  33. package/examples/slides-matrix/deck.config.ts +13 -0
  34. package/examples/slides-minimal/01-welcome.md +9 -0
  35. package/examples/slides-minimal/02-features.md +12 -0
  36. package/examples/slides-minimal/03-colors.md +17 -0
  37. package/examples/slides-minimal/04-ascii-art.md +11 -0
  38. package/examples/slides-minimal/05-gradients.md +14 -0
  39. package/examples/slides-minimal/06-themes.md +13 -0
  40. package/examples/slides-minimal/07-markdown.md +13 -0
  41. package/examples/slides-minimal/08-controls.md +13 -0
  42. package/examples/slides-minimal/09-thanks.md +11 -0
  43. package/examples/slides-minimal/deck.config.ts +13 -0
  44. package/examples/slides-neon/01-welcome.md +9 -0
  45. package/examples/slides-neon/02-features.md +12 -0
  46. package/examples/slides-neon/03-colors.md +17 -0
  47. package/examples/slides-neon/04-ascii-art.md +11 -0
  48. package/examples/slides-neon/05-gradients.md +14 -0
  49. package/examples/slides-neon/06-themes.md +13 -0
  50. package/examples/slides-neon/07-markdown.md +13 -0
  51. package/examples/slides-neon/08-controls.md +13 -0
  52. package/examples/slides-neon/09-thanks.md +11 -0
  53. package/examples/slides-neon/deck.config.ts +13 -0
  54. package/examples/slides-retro/01-welcome.md +9 -0
  55. package/examples/slides-retro/02-features.md +12 -0
  56. package/examples/slides-retro/03-colors.md +17 -0
  57. package/examples/slides-retro/04-ascii-art.md +11 -0
  58. package/examples/slides-retro/05-gradients.md +14 -0
  59. package/examples/slides-retro/06-themes.md +13 -0
  60. package/examples/slides-retro/07-markdown.md +13 -0
  61. package/examples/slides-retro/08-controls.md +13 -0
  62. package/examples/slides-retro/09-thanks.md +11 -0
  63. package/examples/slides-retro/deck.config.ts +13 -0
  64. package/package.json +66 -0
  65. package/src/cli/__tests__/errors.test.ts +201 -0
  66. package/src/cli/__tests__/help.test.ts +157 -0
  67. package/src/cli/__tests__/init.test.ts +110 -0
  68. package/src/cli/commands/export.ts +33 -0
  69. package/src/cli/commands/init.ts +125 -0
  70. package/src/cli/commands/present.ts +29 -0
  71. package/src/cli/errors.ts +77 -0
  72. package/src/core/__tests__/slide.test.ts +1759 -0
  73. package/src/core/__tests__/theme.test.ts +1103 -0
  74. package/src/core/slide.ts +509 -0
  75. package/src/core/theme.ts +388 -0
  76. package/src/export/__tests__/recorder.test.ts +566 -0
  77. package/src/export/recorder.ts +639 -0
  78. package/src/index.ts +36 -0
  79. package/src/presenter/__tests__/main.test.ts +244 -0
  80. package/src/presenter/main.ts +658 -0
  81. package/src/renderer/__tests__/screen-extended.test.ts +801 -0
  82. package/src/renderer/__tests__/screen.test.ts +525 -0
  83. package/src/renderer/screen.ts +671 -0
  84. package/src/schemas/__tests__/config.test.ts +429 -0
  85. package/src/schemas/__tests__/slide.test.ts +349 -0
  86. package/src/schemas/__tests__/theme.test.ts +970 -0
  87. package/src/schemas/__tests__/validation.test.ts +256 -0
  88. package/src/schemas/config.ts +58 -0
  89. package/src/schemas/slide.ts +56 -0
  90. package/src/schemas/theme.ts +203 -0
  91. package/src/schemas/validation.ts +64 -0
  92. package/src/themes/matrix/index.ts +53 -0
  93. package/themes/hacker.ts +53 -0
  94. package/themes/minimal.ts +53 -0
  95. package/themes/neon.ts +53 -0
  96. package/themes/retro.ts +53 -0
@@ -0,0 +1,1103 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
2
+ import {
3
+ createTheme,
4
+ loadThemeFromFile,
5
+ loadThemeFromPackage,
6
+ createGradients,
7
+ applyGradient,
8
+ BUILTIN_COLORS,
9
+ resolveColorToken,
10
+ colorTokensToBlessedTags,
11
+ ThemeError,
12
+ formatThemeError,
13
+ } from '../theme'
14
+ import { ValidationError } from '../../schemas/validation'
15
+ import { join } from 'path'
16
+ import { mkdtemp, rm } from 'fs/promises'
17
+ import { tmpdir } from 'os'
18
+
19
+ const validThemeYaml = `
20
+ name: test
21
+ colors:
22
+ primary: "#ff0066"
23
+ accent: "#00ff66"
24
+ background: "#000000"
25
+ text: "#ffffff"
26
+ muted: "#666666"
27
+ gradients:
28
+ main:
29
+ - "#ff0066"
30
+ - "#00ff66"
31
+ glyphs: "0123456789abcdef"
32
+ animations:
33
+ revealSpeed: 1.0
34
+ matrixDensity: 50
35
+ glitchIterations: 5
36
+ lineDelay: 30
37
+ matrixInterval: 80
38
+ `
39
+
40
+ describe('createTheme', () => {
41
+ describe('valid YAML parsing', () => {
42
+ it('creates theme from valid YAML', () => {
43
+ const theme = createTheme(validThemeYaml)
44
+ expect(theme.name).toBe('test')
45
+ expect(theme.colors.primary).toBe('#ff0066')
46
+ expect(theme.colors.accent).toBe('#00ff66')
47
+ expect(theme.colors.background).toBe('#000000')
48
+ expect(theme.colors.text).toBe('#ffffff')
49
+ expect(theme.colors.muted).toBe('#666666')
50
+ })
51
+
52
+ it('parses gradients correctly', () => {
53
+ const theme = createTheme(validThemeYaml)
54
+ expect(theme.gradients.main).toEqual(['#ff0066', '#00ff66'])
55
+ })
56
+
57
+ it('parses glyphs correctly', () => {
58
+ const theme = createTheme(validThemeYaml)
59
+ expect(theme.glyphs).toBe('0123456789abcdef')
60
+ })
61
+
62
+ it('parses animations correctly', () => {
63
+ const theme = createTheme(validThemeYaml)
64
+ expect(theme.animations.revealSpeed).toBe(1.0)
65
+ expect(theme.animations.matrixDensity).toBe(50)
66
+ expect(theme.animations.glitchIterations).toBe(5)
67
+ expect(theme.animations.lineDelay).toBe(30)
68
+ expect(theme.animations.matrixInterval).toBe(80)
69
+ })
70
+
71
+ it('returns ThemeObject with extend method', () => {
72
+ const theme = createTheme(validThemeYaml)
73
+ expect(typeof theme.extend).toBe('function')
74
+ })
75
+ })
76
+
77
+ describe('invalid YAML syntax', () => {
78
+ it('throws on invalid YAML syntax (bad indentation)', () => {
79
+ const badYaml = `
80
+ name: test
81
+ colors:
82
+ primary: "#ff0066" # missing indentation
83
+ `
84
+ expect(() => createTheme(badYaml)).toThrow()
85
+ })
86
+
87
+ it('throws on invalid YAML syntax (unclosed quotes)', () => {
88
+ const badYaml = `
89
+ name: "test
90
+ colors:
91
+ primary: "#ff0066"
92
+ `
93
+ expect(() => createTheme(badYaml)).toThrow()
94
+ })
95
+ })
96
+
97
+ describe('schema validation failure', () => {
98
+ it('throws on invalid hex color', () => {
99
+ const invalidColorYaml = `
100
+ name: test
101
+ colors:
102
+ primary: "not-a-hex-color"
103
+ accent: "#00ff66"
104
+ background: "#000000"
105
+ text: "#ffffff"
106
+ muted: "#666666"
107
+ gradients:
108
+ main:
109
+ - "#ff0066"
110
+ - "#00ff66"
111
+ glyphs: "0123456789abcdef"
112
+ animations:
113
+ revealSpeed: 1.0
114
+ matrixDensity: 50
115
+ glitchIterations: 5
116
+ lineDelay: 30
117
+ matrixInterval: 80
118
+ `
119
+ expect(() => createTheme(invalidColorYaml)).toThrow(ValidationError)
120
+ })
121
+
122
+ it('throws when required fields are missing', () => {
123
+ const missingFieldsYaml = `
124
+ name: test
125
+ colors:
126
+ primary: "#ff0066"
127
+ `
128
+ expect(() => createTheme(missingFieldsYaml)).toThrow(ValidationError)
129
+ })
130
+
131
+ it('throws when gradient has less than 2 colors', () => {
132
+ const invalidGradientYaml = `
133
+ name: test
134
+ colors:
135
+ primary: "#ff0066"
136
+ accent: "#00ff66"
137
+ background: "#000000"
138
+ text: "#ffffff"
139
+ muted: "#666666"
140
+ gradients:
141
+ main:
142
+ - "#ff0066"
143
+ glyphs: "0123456789abcdef"
144
+ animations:
145
+ revealSpeed: 1.0
146
+ matrixDensity: 50
147
+ glitchIterations: 5
148
+ lineDelay: 30
149
+ matrixInterval: 80
150
+ `
151
+ expect(() => createTheme(invalidGradientYaml)).toThrow(ValidationError)
152
+ })
153
+
154
+ it('throws when glyph set is too short', () => {
155
+ const shortGlyphsYaml = `
156
+ name: test
157
+ colors:
158
+ primary: "#ff0066"
159
+ accent: "#00ff66"
160
+ background: "#000000"
161
+ text: "#ffffff"
162
+ muted: "#666666"
163
+ gradients:
164
+ main:
165
+ - "#ff0066"
166
+ - "#00ff66"
167
+ glyphs: "abc"
168
+ animations:
169
+ revealSpeed: 1.0
170
+ matrixDensity: 50
171
+ glitchIterations: 5
172
+ lineDelay: 30
173
+ matrixInterval: 80
174
+ `
175
+ expect(() => createTheme(shortGlyphsYaml)).toThrow(ValidationError)
176
+ })
177
+ })
178
+
179
+ describe('extend functionality', () => {
180
+ it('extends theme with partial overrides', () => {
181
+ const theme = createTheme(validThemeYaml)
182
+ const extended = theme.extend({
183
+ colors: { primary: '#aabbcc' },
184
+ })
185
+
186
+ expect(extended.colors.primary).toBe('#aabbcc')
187
+ // Other values unchanged
188
+ expect(extended.colors.accent).toBe('#00ff66')
189
+ expect(extended.colors.background).toBe('#000000')
190
+ expect(extended.name).toBe('test')
191
+ })
192
+
193
+ it('supports chained extensions', () => {
194
+ const theme = createTheme(validThemeYaml)
195
+ const extended = theme
196
+ .extend({ colors: { primary: '#aabbcc' } })
197
+ .extend({ animations: { revealSpeed: 0.5 } })
198
+
199
+ expect(extended.colors.primary).toBe('#aabbcc')
200
+ expect(extended.animations.revealSpeed).toBe(0.5)
201
+ // Other values unchanged
202
+ expect(extended.colors.accent).toBe('#00ff66')
203
+ expect(extended.animations.matrixDensity).toBe(50)
204
+ })
205
+
206
+ it('replaces arrays completely (not concatenate)', () => {
207
+ const theme = createTheme(validThemeYaml)
208
+ const extended = theme.extend({
209
+ gradients: {
210
+ main: ['#ff0000', '#00ff00', '#0000ff'],
211
+ },
212
+ })
213
+
214
+ expect(extended.gradients.main).toEqual(['#ff0000', '#00ff00', '#0000ff'])
215
+ })
216
+
217
+ it('extended theme also has extend method', () => {
218
+ const theme = createTheme(validThemeYaml)
219
+ const extended = theme.extend({
220
+ colors: { primary: '#aabbcc' },
221
+ })
222
+
223
+ expect(typeof extended.extend).toBe('function')
224
+ })
225
+
226
+ it('throws if extension results in invalid theme', () => {
227
+ const theme = createTheme(validThemeYaml)
228
+
229
+ expect(() =>
230
+ theme.extend({
231
+ colors: { primary: 'invalid-color' as `#${string}` },
232
+ })
233
+ ).toThrow(ValidationError)
234
+ })
235
+ })
236
+
237
+ describe('optional fields', () => {
238
+ it('includes optional description when provided', () => {
239
+ const yamlWithOptional = `
240
+ name: test
241
+ description: A test theme
242
+ author: tester
243
+ version: 1.0.0
244
+ colors:
245
+ primary: "#ff0066"
246
+ accent: "#00ff66"
247
+ background: "#000000"
248
+ text: "#ffffff"
249
+ muted: "#666666"
250
+ gradients:
251
+ main:
252
+ - "#ff0066"
253
+ - "#00ff66"
254
+ glyphs: "0123456789abcdef"
255
+ animations:
256
+ revealSpeed: 1.0
257
+ matrixDensity: 50
258
+ glitchIterations: 5
259
+ lineDelay: 30
260
+ matrixInterval: 80
261
+ `
262
+ const theme = createTheme(yamlWithOptional)
263
+ expect(theme.description).toBe('A test theme')
264
+ expect(theme.author).toBe('tester')
265
+ expect(theme.version).toBe('1.0.0')
266
+ })
267
+
268
+ it('works without optional fields', () => {
269
+ const theme = createTheme(validThemeYaml)
270
+ expect(theme.description).toBeUndefined()
271
+ expect(theme.author).toBeUndefined()
272
+ expect(theme.version).toBeUndefined()
273
+ })
274
+
275
+ it('includes optional window settings when provided', () => {
276
+ const yamlWithWindow = `
277
+ name: test
278
+ colors:
279
+ primary: "#ff0066"
280
+ accent: "#00ff66"
281
+ background: "#000000"
282
+ text: "#ffffff"
283
+ muted: "#666666"
284
+ gradients:
285
+ main:
286
+ - "#ff0066"
287
+ - "#00ff66"
288
+ glyphs: "0123456789abcdef"
289
+ animations:
290
+ revealSpeed: 1.0
291
+ matrixDensity: 50
292
+ glitchIterations: 5
293
+ lineDelay: 30
294
+ matrixInterval: 80
295
+ window:
296
+ borderStyle: double
297
+ shadow: false
298
+ padding:
299
+ top: 2
300
+ bottom: 2
301
+ left: 3
302
+ right: 3
303
+ `
304
+ const theme = createTheme(yamlWithWindow)
305
+ expect(theme.window?.borderStyle).toBe('double')
306
+ expect(theme.window?.shadow).toBe(false)
307
+ expect(theme.window?.padding?.top).toBe(2)
308
+ expect(theme.window?.padding?.left).toBe(3)
309
+ })
310
+ })
311
+ })
312
+
313
+ describe('loadThemeFromFile', () => {
314
+ let tempDir: string
315
+
316
+ beforeAll(async () => {
317
+ tempDir = await mkdtemp(join(tmpdir(), 'theme-test-'))
318
+ })
319
+
320
+ afterAll(async () => {
321
+ await rm(tempDir, { recursive: true, force: true })
322
+ })
323
+
324
+ describe('loading valid theme files', () => {
325
+ it('loads theme from filesystem path', async () => {
326
+ const themePath = join(tempDir, 'valid-theme.yml')
327
+ await Bun.write(themePath, validThemeYaml)
328
+
329
+ const theme = await loadThemeFromFile(themePath)
330
+
331
+ expect(theme.name).toBe('test')
332
+ expect(theme.colors.primary).toBe('#ff0066')
333
+ expect(theme.colors.accent).toBe('#00ff66')
334
+ })
335
+
336
+ it('returns ThemeObject with extend method', async () => {
337
+ const themePath = join(tempDir, 'theme-with-extend.yml')
338
+ await Bun.write(themePath, validThemeYaml)
339
+
340
+ const theme = await loadThemeFromFile(themePath)
341
+
342
+ expect(typeof theme.extend).toBe('function')
343
+
344
+ const extended = theme.extend({ colors: { primary: '#aabbcc' } })
345
+ expect(extended.colors.primary).toBe('#aabbcc')
346
+ expect(extended.colors.accent).toBe('#00ff66')
347
+ })
348
+
349
+ it('handles absolute paths', async () => {
350
+ const themePath = join(tempDir, 'absolute-path-theme.yml')
351
+ await Bun.write(themePath, validThemeYaml)
352
+
353
+ const theme = await loadThemeFromFile(themePath)
354
+ expect(theme.name).toBe('test')
355
+ })
356
+ })
357
+
358
+ describe('file not found handling', () => {
359
+ it('throws error for non-existent file', async () => {
360
+ const nonExistentPath = join(tempDir, 'does-not-exist.yml')
361
+
362
+ await expect(loadThemeFromFile(nonExistentPath)).rejects.toThrow(
363
+ 'Theme file not found'
364
+ )
365
+ })
366
+
367
+ it('includes path in error message', async () => {
368
+ const nonExistentPath = join(tempDir, 'missing.yml')
369
+
370
+ await expect(loadThemeFromFile(nonExistentPath)).rejects.toThrow(
371
+ nonExistentPath
372
+ )
373
+ })
374
+ })
375
+
376
+ describe('invalid theme file handling', () => {
377
+ it('throws on invalid YAML syntax', async () => {
378
+ const invalidYamlPath = join(tempDir, 'invalid-yaml.yml')
379
+ await Bun.write(
380
+ invalidYamlPath,
381
+ `
382
+ name: test
383
+ colors:
384
+ primary: "#ff0066"
385
+ `
386
+ )
387
+
388
+ await expect(loadThemeFromFile(invalidYamlPath)).rejects.toThrow()
389
+ })
390
+
391
+ it('throws ValidationError for invalid theme data', async () => {
392
+ const invalidThemePath = join(tempDir, 'invalid-theme.yml')
393
+ await Bun.write(
394
+ invalidThemePath,
395
+ `
396
+ name: test
397
+ colors:
398
+ primary: "not-a-hex-color"
399
+ accent: "#00ff66"
400
+ background: "#000000"
401
+ text: "#ffffff"
402
+ muted: "#666666"
403
+ gradients:
404
+ main:
405
+ - "#ff0066"
406
+ - "#00ff66"
407
+ glyphs: "0123456789abcdef"
408
+ animations:
409
+ revealSpeed: 1.0
410
+ matrixDensity: 50
411
+ glitchIterations: 5
412
+ lineDelay: 30
413
+ matrixInterval: 80
414
+ `
415
+ )
416
+
417
+ await expect(loadThemeFromFile(invalidThemePath)).rejects.toThrow(
418
+ ValidationError
419
+ )
420
+ })
421
+
422
+ it('throws ValidationError for missing required fields', async () => {
423
+ const incompleteThemePath = join(tempDir, 'incomplete-theme.yml')
424
+ await Bun.write(
425
+ incompleteThemePath,
426
+ `
427
+ name: test
428
+ colors:
429
+ primary: "#ff0066"
430
+ `
431
+ )
432
+
433
+ await expect(loadThemeFromFile(incompleteThemePath)).rejects.toThrow(
434
+ ValidationError
435
+ )
436
+ })
437
+ })
438
+ })
439
+
440
+ describe('loadThemeFromPackage', () => {
441
+ describe('module not found handling', () => {
442
+ it('throws helpful error for non-existent package', async () => {
443
+ await expect(
444
+ loadThemeFromPackage('@term-deck/non-existent-theme-package')
445
+ ).rejects.toThrow('Theme package "@term-deck/non-existent-theme-package" not found')
446
+ })
447
+
448
+ it('suggests bun add in error message', async () => {
449
+ await expect(
450
+ loadThemeFromPackage('@term-deck/non-existent-theme-package')
451
+ ).rejects.toThrow('bun add @term-deck/non-existent-theme-package')
452
+ })
453
+
454
+ it('handles unscoped package names', async () => {
455
+ await expect(
456
+ loadThemeFromPackage('term-deck-theme-fake')
457
+ ).rejects.toThrow('bun add term-deck-theme-fake')
458
+ })
459
+ })
460
+
461
+ describe('loading installed packages with invalid theme export', () => {
462
+ // Note: These tests verify the function works with actual installed packages.
463
+ // Dynamic import synthesizes a default export, so we test validation failure.
464
+
465
+ it('throws ValidationError when default export is not a valid theme', async () => {
466
+ // yaml package exports module contents as default, not a valid theme
467
+ await expect(loadThemeFromPackage('yaml')).rejects.toThrow(ValidationError)
468
+ })
469
+
470
+ it('error message mentions the package name', async () => {
471
+ await expect(loadThemeFromPackage('yaml')).rejects.toThrow('from yaml')
472
+ })
473
+ })
474
+ })
475
+
476
+ describe('createGradients', () => {
477
+ const themeWithMultipleGradients = createTheme(`
478
+ name: gradient-test
479
+ colors:
480
+ primary: "#ff0066"
481
+ accent: "#00ff66"
482
+ background: "#000000"
483
+ text: "#ffffff"
484
+ muted: "#666666"
485
+ gradients:
486
+ fire:
487
+ - "#ff6600"
488
+ - "#ff3300"
489
+ - "#ff0066"
490
+ cool:
491
+ - "#00ccff"
492
+ - "#0066ff"
493
+ - "#6600ff"
494
+ simple:
495
+ - "#ff0000"
496
+ - "#0000ff"
497
+ glyphs: "0123456789abcdef"
498
+ animations:
499
+ revealSpeed: 1.0
500
+ matrixDensity: 50
501
+ glitchIterations: 5
502
+ lineDelay: 30
503
+ matrixInterval: 80
504
+ `)
505
+
506
+ describe('creating gradient functions', () => {
507
+ it('creates gradient function for each theme gradient', () => {
508
+ const gradients = createGradients(themeWithMultipleGradients)
509
+
510
+ expect(gradients.fire).toBeDefined()
511
+ expect(gradients.cool).toBeDefined()
512
+ expect(gradients.simple).toBeDefined()
513
+ expect(typeof gradients.fire).toBe('function')
514
+ expect(typeof gradients.cool).toBe('function')
515
+ expect(typeof gradients.simple).toBe('function')
516
+ })
517
+
518
+ it('returns empty object for theme with no gradients', () => {
519
+ // Create a theme with minimal gradients
520
+ const minimalTheme = createTheme(`
521
+ name: minimal
522
+ colors:
523
+ primary: "#ff0066"
524
+ accent: "#00ff66"
525
+ background: "#000000"
526
+ text: "#ffffff"
527
+ muted: "#666666"
528
+ gradients:
529
+ main:
530
+ - "#ff0066"
531
+ - "#00ff66"
532
+ glyphs: "0123456789abcdef"
533
+ animations:
534
+ revealSpeed: 1.0
535
+ matrixDensity: 50
536
+ glitchIterations: 5
537
+ lineDelay: 30
538
+ matrixInterval: 80
539
+ `)
540
+ const gradients = createGradients(minimalTheme)
541
+
542
+ expect(Object.keys(gradients)).toHaveLength(1)
543
+ expect(gradients.main).toBeDefined()
544
+ })
545
+ })
546
+
547
+ describe('gradient function output', () => {
548
+ it('gradient function returns a string', () => {
549
+ const gradients = createGradients(themeWithMultipleGradients)
550
+ const result = gradients.fire('Hello World')
551
+
552
+ // Returns a string (may or may not have ANSI codes depending on TTY)
553
+ expect(typeof result).toBe('string')
554
+ // The text content should be preserved
555
+ expect(result).toContain('H')
556
+ expect(result).toContain('W')
557
+ })
558
+
559
+ it('gradient function handles empty string', () => {
560
+ const gradients = createGradients(themeWithMultipleGradients)
561
+ const result = gradients.fire('')
562
+
563
+ expect(result).toBe('')
564
+ })
565
+
566
+ it('different gradient functions are created for each gradient', () => {
567
+ const gradients = createGradients(themeWithMultipleGradients)
568
+
569
+ // Verify different function instances are created
570
+ expect(gradients.fire).not.toBe(gradients.cool)
571
+ expect(gradients.cool).not.toBe(gradients.simple)
572
+ })
573
+ })
574
+ })
575
+
576
+ describe('applyGradient', () => {
577
+ const theme = createTheme(`
578
+ name: apply-gradient-test
579
+ colors:
580
+ primary: "#ff0066"
581
+ accent: "#00ff66"
582
+ background: "#000000"
583
+ text: "#ffffff"
584
+ muted: "#666666"
585
+ gradients:
586
+ fire:
587
+ - "#ff6600"
588
+ - "#ff3300"
589
+ - "#ff0066"
590
+ cool:
591
+ - "#00ccff"
592
+ - "#0066ff"
593
+ glyphs: "0123456789abcdef"
594
+ animations:
595
+ revealSpeed: 1.0
596
+ matrixDensity: 50
597
+ glitchIterations: 5
598
+ lineDelay: 30
599
+ matrixInterval: 80
600
+ `)
601
+
602
+ describe('applying existing gradients', () => {
603
+ it('returns a string for valid gradient name', () => {
604
+ const result = applyGradient('Hello World', 'fire', theme)
605
+
606
+ expect(typeof result).toBe('string')
607
+ // The text content should be preserved (with or without ANSI codes)
608
+ expect(result).toContain('H')
609
+ expect(result).toContain('W')
610
+ })
611
+
612
+ it('processes text through gradient function', () => {
613
+ // Both should process without error
614
+ const fireResult = applyGradient('Test', 'fire', theme)
615
+ const coolResult = applyGradient('Test', 'cool', theme)
616
+
617
+ expect(typeof fireResult).toBe('string')
618
+ expect(typeof coolResult).toBe('string')
619
+ })
620
+ })
621
+
622
+ describe('fallback for missing gradients', () => {
623
+ it('returns unstyled text for non-existent gradient', () => {
624
+ const result = applyGradient('Hello World', 'nonexistent', theme)
625
+
626
+ expect(result).toBe('Hello World')
627
+ })
628
+
629
+ it('does not throw for missing gradient', () => {
630
+ expect(() => applyGradient('Test', 'missing', theme)).not.toThrow()
631
+ })
632
+ })
633
+
634
+ describe('edge cases', () => {
635
+ it('handles empty text', () => {
636
+ const result = applyGradient('', 'fire', theme)
637
+ expect(result).toBe('')
638
+ })
639
+
640
+ it('handles multiline text', () => {
641
+ const multiline = 'Line 1\nLine 2\nLine 3'
642
+ const result = applyGradient(multiline, 'fire', theme)
643
+
644
+ expect(typeof result).toBe('string')
645
+ expect(result).toContain('\n')
646
+ })
647
+
648
+ it('handles text with special characters', () => {
649
+ const special = '!@#$%^&*()[]{}|;:,.<>?'
650
+ const result = applyGradient(special, 'fire', theme)
651
+
652
+ expect(typeof result).toBe('string')
653
+ })
654
+ })
655
+ })
656
+
657
+ describe('BUILTIN_COLORS', () => {
658
+ it('contains all 6 built-in colors', () => {
659
+ expect(Object.keys(BUILTIN_COLORS)).toHaveLength(6)
660
+ expect(BUILTIN_COLORS.GREEN).toBeDefined()
661
+ expect(BUILTIN_COLORS.ORANGE).toBeDefined()
662
+ expect(BUILTIN_COLORS.CYAN).toBeDefined()
663
+ expect(BUILTIN_COLORS.PINK).toBeDefined()
664
+ expect(BUILTIN_COLORS.WHITE).toBeDefined()
665
+ expect(BUILTIN_COLORS.GRAY).toBeDefined()
666
+ })
667
+
668
+ it('has correct hex values', () => {
669
+ expect(BUILTIN_COLORS.GREEN).toBe('#00cc66')
670
+ expect(BUILTIN_COLORS.ORANGE).toBe('#ff6600')
671
+ expect(BUILTIN_COLORS.CYAN).toBe('#00ccff')
672
+ expect(BUILTIN_COLORS.PINK).toBe('#ff0066')
673
+ expect(BUILTIN_COLORS.WHITE).toBe('#ffffff')
674
+ expect(BUILTIN_COLORS.GRAY).toBe('#666666')
675
+ })
676
+ })
677
+
678
+ describe('resolveColorToken', () => {
679
+ const theme = createTheme(`
680
+ name: color-token-test
681
+ colors:
682
+ primary: "#11aa22"
683
+ secondary: "#33bb44"
684
+ accent: "#55cc66"
685
+ background: "#000000"
686
+ text: "#ffffff"
687
+ muted: "#888888"
688
+ gradients:
689
+ main:
690
+ - "#ff0066"
691
+ - "#00ff66"
692
+ glyphs: "0123456789abcdef"
693
+ animations:
694
+ revealSpeed: 1.0
695
+ matrixDensity: 50
696
+ glitchIterations: 5
697
+ lineDelay: 30
698
+ matrixInterval: 80
699
+ `)
700
+
701
+ describe('resolving theme colors', () => {
702
+ it('resolves PRIMARY to theme.colors.primary', () => {
703
+ expect(resolveColorToken('PRIMARY', theme)).toBe('#11aa22')
704
+ })
705
+
706
+ it('resolves SECONDARY to theme.colors.secondary', () => {
707
+ expect(resolveColorToken('SECONDARY', theme)).toBe('#33bb44')
708
+ })
709
+
710
+ it('resolves ACCENT to theme.colors.accent', () => {
711
+ expect(resolveColorToken('ACCENT', theme)).toBe('#55cc66')
712
+ })
713
+
714
+ it('resolves MUTED to theme.colors.muted', () => {
715
+ expect(resolveColorToken('MUTED', theme)).toBe('#888888')
716
+ })
717
+
718
+ it('resolves TEXT to theme.colors.text', () => {
719
+ expect(resolveColorToken('TEXT', theme)).toBe('#ffffff')
720
+ })
721
+
722
+ it('resolves BACKGROUND to theme.colors.background', () => {
723
+ expect(resolveColorToken('BACKGROUND', theme)).toBe('#000000')
724
+ })
725
+ })
726
+
727
+ describe('resolving built-in colors', () => {
728
+ it('resolves GREEN to built-in color', () => {
729
+ expect(resolveColorToken('GREEN', theme)).toBe('#00cc66')
730
+ })
731
+
732
+ it('resolves ORANGE to built-in color', () => {
733
+ expect(resolveColorToken('ORANGE', theme)).toBe('#ff6600')
734
+ })
735
+
736
+ it('resolves CYAN to built-in color', () => {
737
+ expect(resolveColorToken('CYAN', theme)).toBe('#00ccff')
738
+ })
739
+
740
+ it('resolves PINK to built-in color', () => {
741
+ expect(resolveColorToken('PINK', theme)).toBe('#ff0066')
742
+ })
743
+
744
+ it('resolves WHITE to built-in color', () => {
745
+ expect(resolveColorToken('WHITE', theme)).toBe('#ffffff')
746
+ })
747
+
748
+ it('resolves GRAY to built-in color', () => {
749
+ expect(resolveColorToken('GRAY', theme)).toBe('#666666')
750
+ })
751
+ })
752
+
753
+ describe('fallback for unknown tokens', () => {
754
+ it('falls back to theme.colors.text for unknown token', () => {
755
+ expect(resolveColorToken('UNKNOWN', theme)).toBe('#ffffff')
756
+ })
757
+
758
+ it('falls back to theme.colors.text for lowercase token', () => {
759
+ expect(resolveColorToken('green', theme)).toBe('#ffffff')
760
+ })
761
+ })
762
+
763
+ describe('SECONDARY fallback when not defined', () => {
764
+ const themeWithoutSecondary = createTheme(`
765
+ name: no-secondary
766
+ colors:
767
+ primary: "#aabbcc"
768
+ accent: "#ddeeff"
769
+ background: "#000000"
770
+ text: "#ffffff"
771
+ muted: "#888888"
772
+ gradients:
773
+ main:
774
+ - "#ff0066"
775
+ - "#00ff66"
776
+ glyphs: "0123456789abcdef"
777
+ animations:
778
+ revealSpeed: 1.0
779
+ matrixDensity: 50
780
+ glitchIterations: 5
781
+ lineDelay: 30
782
+ matrixInterval: 80
783
+ `)
784
+
785
+ it('falls back to primary when secondary is not defined', () => {
786
+ expect(resolveColorToken('SECONDARY', themeWithoutSecondary)).toBe('#aabbcc')
787
+ })
788
+ })
789
+ })
790
+
791
+ describe('colorTokensToBlessedTags', () => {
792
+ const theme = createTheme(`
793
+ name: blessed-tags-test
794
+ colors:
795
+ primary: "#11aa22"
796
+ secondary: "#33bb44"
797
+ accent: "#55cc66"
798
+ background: "#000000"
799
+ text: "#ffffff"
800
+ muted: "#888888"
801
+ gradients:
802
+ main:
803
+ - "#ff0066"
804
+ - "#00ff66"
805
+ glyphs: "0123456789abcdef"
806
+ animations:
807
+ revealSpeed: 1.0
808
+ matrixDensity: 50
809
+ glitchIterations: 5
810
+ lineDelay: 30
811
+ matrixInterval: 80
812
+ `)
813
+
814
+ describe('converting built-in color tokens', () => {
815
+ it('converts {GREEN} to blessed tag', () => {
816
+ const result = colorTokensToBlessedTags('{GREEN}Hello{/}', theme)
817
+ expect(result).toBe('{#00cc66-fg}Hello{/}')
818
+ })
819
+
820
+ it('converts {ORANGE} to blessed tag', () => {
821
+ const result = colorTokensToBlessedTags('{ORANGE}Hello{/}', theme)
822
+ expect(result).toBe('{#ff6600-fg}Hello{/}')
823
+ })
824
+
825
+ it('converts {CYAN} to blessed tag', () => {
826
+ const result = colorTokensToBlessedTags('{CYAN}Hello{/}', theme)
827
+ expect(result).toBe('{#00ccff-fg}Hello{/}')
828
+ })
829
+
830
+ it('converts {PINK} to blessed tag', () => {
831
+ const result = colorTokensToBlessedTags('{PINK}Hello{/}', theme)
832
+ expect(result).toBe('{#ff0066-fg}Hello{/}')
833
+ })
834
+
835
+ it('converts {WHITE} to blessed tag', () => {
836
+ const result = colorTokensToBlessedTags('{WHITE}Hello{/}', theme)
837
+ expect(result).toBe('{#ffffff-fg}Hello{/}')
838
+ })
839
+
840
+ it('converts {GRAY} to blessed tag', () => {
841
+ const result = colorTokensToBlessedTags('{GRAY}Hello{/}', theme)
842
+ expect(result).toBe('{#666666-fg}Hello{/}')
843
+ })
844
+ })
845
+
846
+ describe('converting theme color tokens', () => {
847
+ it('converts {PRIMARY} to theme primary color', () => {
848
+ const result = colorTokensToBlessedTags('{PRIMARY}Test{/}', theme)
849
+ expect(result).toBe('{#11aa22-fg}Test{/}')
850
+ })
851
+
852
+ it('converts {SECONDARY} to theme secondary color', () => {
853
+ const result = colorTokensToBlessedTags('{SECONDARY}Test{/}', theme)
854
+ expect(result).toBe('{#33bb44-fg}Test{/}')
855
+ })
856
+
857
+ it('converts {ACCENT} to theme accent color', () => {
858
+ const result = colorTokensToBlessedTags('{ACCENT}Test{/}', theme)
859
+ expect(result).toBe('{#55cc66-fg}Test{/}')
860
+ })
861
+
862
+ it('converts {MUTED} to theme muted color', () => {
863
+ const result = colorTokensToBlessedTags('{MUTED}Test{/}', theme)
864
+ expect(result).toBe('{#888888-fg}Test{/}')
865
+ })
866
+
867
+ it('converts {TEXT} to theme text color', () => {
868
+ const result = colorTokensToBlessedTags('{TEXT}Test{/}', theme)
869
+ expect(result).toBe('{#ffffff-fg}Test{/}')
870
+ })
871
+
872
+ it('converts {BACKGROUND} to theme background color', () => {
873
+ const result = colorTokensToBlessedTags('{BACKGROUND}Test{/}', theme)
874
+ expect(result).toBe('{#000000-fg}Test{/}')
875
+ })
876
+ })
877
+
878
+ describe('handling closing tags', () => {
879
+ it('preserves {/} as closing tag', () => {
880
+ const result = colorTokensToBlessedTags('{GREEN}Hello{/}', theme)
881
+ expect(result).toContain('{/}')
882
+ })
883
+
884
+ it('handles multiple closing tags', () => {
885
+ const result = colorTokensToBlessedTags('{GREEN}A{/}{ORANGE}B{/}', theme)
886
+ expect(result).toBe('{#00cc66-fg}A{/}{#ff6600-fg}B{/}')
887
+ })
888
+ })
889
+
890
+ describe('handling multiple tokens', () => {
891
+ it('converts multiple tokens in same string', () => {
892
+ const content = '{GREEN}Hello{/} {ORANGE}World{/}'
893
+ const result = colorTokensToBlessedTags(content, theme)
894
+ expect(result).toBe('{#00cc66-fg}Hello{/} {#ff6600-fg}World{/}')
895
+ })
896
+
897
+ it('handles mixed theme and built-in tokens', () => {
898
+ const content = '{PRIMARY}Hello{/} {GREEN}World{/}'
899
+ const result = colorTokensToBlessedTags(content, theme)
900
+ expect(result).toBe('{#11aa22-fg}Hello{/} {#00cc66-fg}World{/}')
901
+ })
902
+ })
903
+
904
+ describe('edge cases', () => {
905
+ it('returns empty string for empty input', () => {
906
+ expect(colorTokensToBlessedTags('', theme)).toBe('')
907
+ })
908
+
909
+ it('returns original text when no tokens present', () => {
910
+ const content = 'Hello World'
911
+ expect(colorTokensToBlessedTags(content, theme)).toBe('Hello World')
912
+ })
913
+
914
+ it('preserves other curly braces', () => {
915
+ const content = '{GREEN}Test{/} {unknown} {foo: bar}'
916
+ const result = colorTokensToBlessedTags(content, theme)
917
+ // Only recognized tokens are replaced
918
+ expect(result).toBe('{#00cc66-fg}Test{/} {unknown} {foo: bar}')
919
+ })
920
+
921
+ it('handles multiline content', () => {
922
+ const content = '{GREEN}Line 1{/}\n{ORANGE}Line 2{/}'
923
+ const result = colorTokensToBlessedTags(content, theme)
924
+ expect(result).toBe('{#00cc66-fg}Line 1{/}\n{#ff6600-fg}Line 2{/}')
925
+ })
926
+
927
+ it('does not match lowercase tokens', () => {
928
+ const content = '{green}Hello{/}'
929
+ const result = colorTokensToBlessedTags(content, theme)
930
+ expect(result).toBe('{green}Hello{/}')
931
+ })
932
+ })
933
+ })
934
+
935
+ describe('ThemeError', () => {
936
+ describe('constructor', () => {
937
+ it('creates error with message only', () => {
938
+ const error = new ThemeError('Something went wrong')
939
+
940
+ expect(error.message).toBe('Something went wrong')
941
+ expect(error.name).toBe('ThemeError')
942
+ expect(error.themeName).toBeUndefined()
943
+ expect(error.path).toBeUndefined()
944
+ })
945
+
946
+ it('creates error with themeName', () => {
947
+ const error = new ThemeError('Invalid theme', 'matrix')
948
+
949
+ expect(error.message).toBe('Invalid theme')
950
+ expect(error.themeName).toBe('matrix')
951
+ expect(error.path).toBeUndefined()
952
+ })
953
+
954
+ it('creates error with themeName and path', () => {
955
+ const error = new ThemeError('Invalid theme', 'matrix', './themes/matrix.yml')
956
+
957
+ expect(error.message).toBe('Invalid theme')
958
+ expect(error.themeName).toBe('matrix')
959
+ expect(error.path).toBe('./themes/matrix.yml')
960
+ })
961
+
962
+ it('creates error with undefined themeName but with path', () => {
963
+ const error = new ThemeError('File not found', undefined, './themes/missing.yml')
964
+
965
+ expect(error.message).toBe('File not found')
966
+ expect(error.themeName).toBeUndefined()
967
+ expect(error.path).toBe('./themes/missing.yml')
968
+ })
969
+
970
+ it('is an instance of Error', () => {
971
+ const error = new ThemeError('Test error')
972
+ expect(error).toBeInstanceOf(Error)
973
+ })
974
+
975
+ it('is an instance of ThemeError', () => {
976
+ const error = new ThemeError('Test error')
977
+ expect(error).toBeInstanceOf(ThemeError)
978
+ })
979
+ })
980
+
981
+ describe('error properties', () => {
982
+ it('has readonly themeName property', () => {
983
+ const error = new ThemeError('Test', 'myTheme')
984
+ expect(error.themeName).toBe('myTheme')
985
+ // TypeScript prevents: error.themeName = 'other'
986
+ })
987
+
988
+ it('has readonly path property', () => {
989
+ const error = new ThemeError('Test', undefined, '/path/to/theme')
990
+ expect(error.path).toBe('/path/to/theme')
991
+ // TypeScript prevents: error.path = '/other/path'
992
+ })
993
+ })
994
+ })
995
+
996
+ describe('formatThemeError', () => {
997
+ describe('formatting ValidationError', () => {
998
+ it('formats ValidationError with source', () => {
999
+ const validationError = new ValidationError('Invalid theme:\n - colors.primary: Color must be a valid hex color')
1000
+ const result = formatThemeError(validationError, './theme.yml')
1001
+
1002
+ expect(result).toBeInstanceOf(ThemeError)
1003
+ expect(result.message).toContain('Invalid theme from ./theme.yml')
1004
+ expect(result.message).toContain('colors.primary')
1005
+ expect(result.path).toBe('./theme.yml')
1006
+ })
1007
+
1008
+ it('includes full validation message', () => {
1009
+ const validationError = new ValidationError('Invalid theme:\n - name: Required\n - colors.primary: Invalid')
1010
+ const result = formatThemeError(validationError, 'my-package')
1011
+
1012
+ expect(result.message).toContain('name: Required')
1013
+ expect(result.message).toContain('colors.primary: Invalid')
1014
+ })
1015
+ })
1016
+
1017
+ describe('formatting generic Error', () => {
1018
+ it('formats generic Error with source', () => {
1019
+ const genericError = new Error('File not found')
1020
+ const result = formatThemeError(genericError, './missing.yml')
1021
+
1022
+ expect(result).toBeInstanceOf(ThemeError)
1023
+ expect(result.message).toBe('Failed to load theme from ./missing.yml: File not found')
1024
+ expect(result.path).toBe('./missing.yml')
1025
+ })
1026
+
1027
+ it('formats Error with empty message', () => {
1028
+ const emptyError = new Error('')
1029
+ const result = formatThemeError(emptyError, './theme.yml')
1030
+
1031
+ expect(result.message).toBe('Failed to load theme from ./theme.yml: ')
1032
+ })
1033
+
1034
+ it('formats TypeError', () => {
1035
+ const typeError = new TypeError('Cannot read property of undefined')
1036
+ const result = formatThemeError(typeError, '@term-deck/theme-broken')
1037
+
1038
+ expect(result).toBeInstanceOf(ThemeError)
1039
+ expect(result.message).toContain('Cannot read property of undefined')
1040
+ expect(result.path).toBe('@term-deck/theme-broken')
1041
+ })
1042
+ })
1043
+
1044
+ describe('formatting unknown errors', () => {
1045
+ it('formats string error', () => {
1046
+ const result = formatThemeError('something went wrong', './theme.yml')
1047
+
1048
+ expect(result).toBeInstanceOf(ThemeError)
1049
+ expect(result.message).toBe('Unknown error loading theme from ./theme.yml')
1050
+ })
1051
+
1052
+ it('formats null error', () => {
1053
+ const result = formatThemeError(null, './theme.yml')
1054
+
1055
+ expect(result).toBeInstanceOf(ThemeError)
1056
+ expect(result.message).toBe('Unknown error loading theme from ./theme.yml')
1057
+ })
1058
+
1059
+ it('formats undefined error', () => {
1060
+ const result = formatThemeError(undefined, './theme.yml')
1061
+
1062
+ expect(result).toBeInstanceOf(ThemeError)
1063
+ expect(result.message).toBe('Unknown error loading theme from ./theme.yml')
1064
+ })
1065
+
1066
+ it('formats object error', () => {
1067
+ const result = formatThemeError({ code: 'ERR_INVALID' }, './theme.yml')
1068
+
1069
+ expect(result).toBeInstanceOf(ThemeError)
1070
+ expect(result.message).toBe('Unknown error loading theme from ./theme.yml')
1071
+ })
1072
+
1073
+ it('formats number error', () => {
1074
+ const result = formatThemeError(404, './theme.yml')
1075
+
1076
+ expect(result).toBeInstanceOf(ThemeError)
1077
+ expect(result.message).toBe('Unknown error loading theme from ./theme.yml')
1078
+ })
1079
+ })
1080
+
1081
+ describe('source information', () => {
1082
+ it('includes path in ThemeError for file paths', () => {
1083
+ const error = new Error('Permission denied')
1084
+ const result = formatThemeError(error, '/etc/themes/protected.yml')
1085
+
1086
+ expect(result.path).toBe('/etc/themes/protected.yml')
1087
+ })
1088
+
1089
+ it('includes path in ThemeError for package names', () => {
1090
+ const error = new ValidationError('Invalid theme')
1091
+ const result = formatThemeError(error, '@company/theme-custom')
1092
+
1093
+ expect(result.path).toBe('@company/theme-custom')
1094
+ })
1095
+
1096
+ it('does not set themeName (themeName is undefined)', () => {
1097
+ const error = new Error('Test')
1098
+ const result = formatThemeError(error, './theme.yml')
1099
+
1100
+ expect(result.themeName).toBeUndefined()
1101
+ })
1102
+ })
1103
+ })