@miliastry/quasar 1.0.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.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. package/src/index.ts +197 -0
@@ -0,0 +1,717 @@
1
+ /**
2
+ * Quasar — Tree Transformers
3
+ *
4
+ * Apply effects (gradient, grow, rainbow, central, multi) across an entire RedNode tree.
5
+ * These functions walk the tree, track global text position,
6
+ * and wrap each text leaf in internal effect nodes
7
+ * with position metadata so the TagRegistry handlers produce
8
+ * correctly interpolated output.
9
+ *
10
+ * DESIGN:
11
+ * Two-pass approach:
12
+ * 1. Collect total text length (for normalization)
13
+ * 2. Walk again, wrapping text nodes with effect nodes
14
+ *
15
+ * Non-text nodes (img, video, audio, code blocks) are preserved unchanged.
16
+ * Structural nodes (box, quote, list) recurse into children.
17
+ * Formatting nodes (b, i, u, color) recurse — effects go INSIDE formatting.
18
+ *
19
+ * Example:
20
+ * Input: [box][b]Hello[/b] World[/box]
21
+ * Output: [box][b][gradient][b]H[/b][gradient][b]e[/b]...[/b]
22
+ * [gradient] W[/gradient]...[/gradient]
23
+ *
24
+ * Actually simpler — each text leaf gets its own gradient node with
25
+ * globalOffset metadata, and the handler does the interpolation.
26
+ */
27
+
28
+ import { greenNode, greenLeaf } from '../Syntax/GreenNode'
29
+ import { RedNode } from '../Syntax/RedNode'
30
+ import type { Easing } from './color'
31
+ import { mixMultiple, hslToHex, ease, clamp } from './color'
32
+
33
+ // ─── Types ──────────────────────────────────────────────────
34
+
35
+ export interface GradientEffect {
36
+ kind: 'gradient'
37
+ colors: string[]
38
+ unit?: 'character' | 'word' | 'line'
39
+ easing?: Easing
40
+ }
41
+
42
+ export interface GrowEffect {
43
+ kind: 'grow'
44
+ min?: number
45
+ max?: number
46
+ cycles?: number
47
+ }
48
+
49
+ export interface RainbowEffect {
50
+ kind: 'rainbow'
51
+ saturation?: number
52
+ lightness?: number
53
+ spread?: number
54
+ offset?: number
55
+ }
56
+
57
+ export interface CentralGradientEffect {
58
+ kind: 'central_gradient'
59
+ colors: string[]
60
+ unit?: 'character' | 'word' | 'line'
61
+ easing?: Easing
62
+ }
63
+
64
+ export interface MultiGradientEffect {
65
+ kind: 'multi_gradient'
66
+ colors: string[]
67
+ mode?: 'wave' | 'pulse'
68
+ unit?: 'character' | 'word' | 'line'
69
+ easing?: Easing
70
+ }
71
+
72
+ export type TreeEffect = GradientEffect | GrowEffect | RainbowEffect | CentralGradientEffect | MultiGradientEffect
73
+
74
+ // ─── Kinds that should NOT be recursed into ─────────────────
75
+ // These are leaf containers that hold content but not text nodes
76
+ // we'd want to colorize.
77
+
78
+ const LEAF_KINDS = new Set([
79
+ 'image', 'video', 'audio', 'imagemap',
80
+ 'code', 'inline_code',
81
+ ])
82
+
83
+ // ─── Helpers ────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Count total plain text length across all text leaves in a tree.
87
+ */
88
+ export function countTextLength(node: RedNode): number {
89
+ if (node.kind === 'text') return node.text.length
90
+ if (LEAF_KINDS.has(node.kind)) return 0
91
+ return node.children.reduce((sum: number, child: RedNode) => sum + countTextLength(child), 0)
92
+ }
93
+
94
+ /**
95
+ * Deep-clone a RedNode tree (preserves kind and metadata, creates new nodes).
96
+ */
97
+ function cloneTree(node: RedNode): RedNode {
98
+ const cloned = new RedNode(node.green, {
99
+ kind: node.kind,
100
+ metadata: { ...node.metadata },
101
+ })
102
+ for (const child of node.children) {
103
+ cloned.appendChild(cloneTree(child))
104
+ }
105
+ return cloned
106
+ }
107
+
108
+ /**
109
+ * Wrap a text node's characters/words in effect nodes,
110
+ * returning an array of nodes that replace the original text node.
111
+ *
112
+ * For character unit: returns N gradient nodes (one per char)
113
+ * Each node: gradient(metadata: {globalOffset, documentLength}) → text(char)
114
+ *
115
+ * For word unit: returns N gradient nodes (one per word)
116
+ * Each node: gradient(metadata: {globalOffset, documentLength}) → text(word)
117
+ */
118
+ function wrapTextWithGradient(
119
+ node: RedNode,
120
+ colors: string[],
121
+ globalOffset: number,
122
+ documentLength: number,
123
+ unit: string,
124
+ easing: Easing,
125
+ ): RedNode {
126
+ const text = node.text
127
+ if (!text) return node
128
+
129
+ const segments = unit === 'word'
130
+ ? text.split(/(\s+)/).filter((s: string) => s.length > 0)
131
+ : unit === 'line'
132
+ ? text.split('\n')
133
+ : [...text]
134
+
135
+ if (segments.length === 0) return node
136
+
137
+ // Create a parent group node to hold all the segments
138
+ const groupGreen = greenLeaf('group', '')
139
+ const group = new RedNode(groupGreen, { kind: 'group' })
140
+
141
+ let pos = globalOffset
142
+ for (const seg of segments) {
143
+ const textGreen = greenLeaf('text', seg)
144
+ const textRed = new RedNode(textGreen, { kind: 'text' })
145
+
146
+ if (/^\s+$/.test(seg)) {
147
+ group.appendChild(textRed)
148
+ } else {
149
+ const gradGreen = greenLeaf('gradient', '')
150
+ const gradRed = new RedNode(gradGreen, {
151
+ kind: 'gradient',
152
+ metadata: {
153
+ colors,
154
+ unit,
155
+ easing,
156
+ globalOffset: pos,
157
+ documentLength,
158
+ },
159
+ })
160
+ gradRed.appendChild(textRed)
161
+ group.appendChild(gradRed)
162
+ }
163
+ pos += seg.length
164
+ }
165
+
166
+ return group
167
+ }
168
+
169
+ /**
170
+ * Wrap a text node's characters in grow nodes.
171
+ */
172
+ function wrapTextWithGrow(
173
+ node: RedNode,
174
+ min: number,
175
+ max: number,
176
+ cycles: number,
177
+ globalOffset: number,
178
+ documentLength: number,
179
+ ): RedNode {
180
+ const text = node.text
181
+ if (!text) return node
182
+
183
+ const chars = [...text]
184
+ if (chars.length === 0) return node
185
+
186
+ const groupGreen = greenLeaf('group', '')
187
+ const group = new RedNode(groupGreen, { kind: 'group' })
188
+
189
+ let pos = globalOffset
190
+ for (const ch of chars) {
191
+ const textGreen = greenLeaf('text', ch)
192
+ const textRed = new RedNode(textGreen, { kind: 'text' })
193
+
194
+ if (/^\s+$/.test(ch)) {
195
+ group.appendChild(textRed)
196
+ } else {
197
+ const growGreen = greenLeaf('grow', '')
198
+ const growRed = new RedNode(growGreen, {
199
+ kind: 'grow',
200
+ metadata: { min, max, cycles, globalOffset: pos, documentLength },
201
+ })
202
+ growRed.appendChild(textRed)
203
+ group.appendChild(growRed)
204
+ }
205
+ pos += ch.length
206
+ }
207
+
208
+ return group
209
+ }
210
+
211
+ /**
212
+ * Wrap a text node's characters in rainbow nodes.
213
+ */
214
+ function wrapTextWithRainbow(
215
+ node: RedNode,
216
+ saturation: number,
217
+ lightness: number,
218
+ spread: number,
219
+ offset: number,
220
+ globalOffset: number,
221
+ documentLength: number,
222
+ ): RedNode {
223
+ const text = node.text
224
+ if (!text) return node
225
+
226
+ const chars = [...text]
227
+ if (chars.length === 0) return node
228
+
229
+ const groupGreen = greenLeaf('group', '')
230
+ const group = new RedNode(groupGreen, { kind: 'group' })
231
+
232
+ let pos = globalOffset
233
+ for (const ch of chars) {
234
+ const textGreen = greenLeaf('text', ch)
235
+ const textRed = new RedNode(textGreen, { kind: 'text' })
236
+
237
+ if (/^\s+$/.test(ch)) {
238
+ group.appendChild(textRed)
239
+ } else {
240
+ const rainbowGreen = greenLeaf('rainbow', '')
241
+ const rainbowRed = new RedNode(rainbowGreen, {
242
+ kind: 'rainbow',
243
+ metadata: {
244
+ saturation,
245
+ lightness,
246
+ spread,
247
+ offset,
248
+ globalOffset: pos,
249
+ documentLength,
250
+ },
251
+ })
252
+ rainbowRed.appendChild(textRed)
253
+ group.appendChild(rainbowRed)
254
+ }
255
+ pos += ch.length
256
+ }
257
+
258
+ return group
259
+ }
260
+
261
+ /**
262
+ * Wrap a text node's characters in gradient nodes for central mode.
263
+ * Central mode: gradient from center outward (mirrored).
264
+ */
265
+ function wrapTextWithCentralGradient(
266
+ node: RedNode,
267
+ colors: string[],
268
+ globalOffset: number,
269
+ documentLength: number,
270
+ unit: string,
271
+ easing: Easing,
272
+ ): RedNode {
273
+ const text = node.text
274
+ if (!text) return node
275
+
276
+ const segments = unit === 'word'
277
+ ? text.split(/(\s+)/).filter((s: string) => s.length > 0)
278
+ : unit === 'line'
279
+ ? text.split('\n')
280
+ : [...text]
281
+
282
+ if (segments.length === 0) return node
283
+
284
+ const groupGreen = greenLeaf('group', '')
285
+ const group = new RedNode(groupGreen, { kind: 'group' })
286
+
287
+ // For central mode, we mirror from the center
288
+ const mid = Math.floor(segments.length / 2)
289
+
290
+ let pos = globalOffset
291
+ for (let i = 0; i < segments.length; i++) {
292
+ const seg = segments[i]
293
+ const textGreen = greenLeaf('text', seg)
294
+ const textRed = new RedNode(textGreen, { kind: 'text' })
295
+
296
+ if (/^\s+$/.test(seg)) {
297
+ group.appendChild(textRed)
298
+ } else {
299
+ // Calculate position from center (0 at center, 1 at edges)
300
+ let t: number
301
+ if (i <= mid) {
302
+ t = mid === 0 ? 0 : i / mid
303
+ } else {
304
+ t = segments.length - 1 - mid === 0 ? 0 : (i - mid) / (segments.length - 1 - mid)
305
+ }
306
+
307
+ const easedT = ease(clamp(t), easing)
308
+ // For central mode, we use the colors array in forward then reverse
309
+ const colorArr = i <= mid ? colors : [...colors].reverse()
310
+ const color = mixMultiple(colorArr, easedT)
311
+
312
+ const gradGreen = greenLeaf('gradient', '')
313
+ const gradRed = new RedNode(gradGreen, {
314
+ kind: 'gradient',
315
+ metadata: {
316
+ colors: [color, color], // Single color interpolation
317
+ unit,
318
+ easing,
319
+ globalOffset: pos,
320
+ documentLength,
321
+ },
322
+ })
323
+ gradRed.appendChild(textRed)
324
+ group.appendChild(gradRed)
325
+ }
326
+ pos += seg.length
327
+ }
328
+
329
+ return group
330
+ }
331
+
332
+ /**
333
+ * Wrap a text node's characters in gradient nodes for multi mode.
334
+ * Multi mode: wave or pulse patterns across the text.
335
+ */
336
+ function wrapTextWithMultiGradient(
337
+ node: RedNode,
338
+ colors: string[],
339
+ mode: 'wave' | 'pulse',
340
+ globalOffset: number,
341
+ documentLength: number,
342
+ unit: string,
343
+ easing: Easing,
344
+ ): RedNode {
345
+ const text = node.text
346
+ if (!text) return node
347
+
348
+ const segments = unit === 'word'
349
+ ? text.split(/(\s+)/).filter((s: string) => s.length > 0)
350
+ : unit === 'line'
351
+ ? text.split('\n')
352
+ : [...text]
353
+
354
+ if (segments.length === 0) return node
355
+
356
+ const groupGreen = greenLeaf('group', '')
357
+ const group = new RedNode(groupGreen, { kind: 'group' })
358
+
359
+ let pos = globalOffset
360
+ for (let i = 0; i < segments.length; i++) {
361
+ const seg = segments[i]
362
+ const textGreen = greenLeaf('text', seg)
363
+ const textRed = new RedNode(textGreen, { kind: 'text' })
364
+
365
+ if (/^\s+$/.test(seg)) {
366
+ group.appendChild(textRed)
367
+ } else {
368
+ let t: number
369
+ if (mode === 'wave') {
370
+ // Wave pattern: sine wave across the text
371
+ const wavePosition = Math.sin((i / segments.length) * Math.PI * 3) * 0.5 + 0.5
372
+ t = ease(wavePosition, easing)
373
+ } else {
374
+ // Pulse pattern: repeating gradient segments
375
+ const pulseLength = Math.max(5, Math.floor(segments.length / 4))
376
+ const pulsePosition = (i % pulseLength) / pulseLength
377
+ t = ease(pulsePosition, easing)
378
+ }
379
+
380
+ const color = mixMultiple(colors, t)
381
+
382
+ const gradGreen = greenLeaf('gradient', '')
383
+ const gradRed = new RedNode(gradGreen, {
384
+ kind: 'gradient',
385
+ metadata: {
386
+ colors: [color, color], // Single color interpolation
387
+ unit,
388
+ easing,
389
+ globalOffset: pos,
390
+ documentLength,
391
+ },
392
+ })
393
+ gradRed.appendChild(textRed)
394
+ group.appendChild(gradRed)
395
+ }
396
+ pos += seg.length
397
+ }
398
+
399
+ return group
400
+ }
401
+
402
+ // ─── Public API ─────────────────────────────────────────────
403
+
404
+ /**
405
+ * Apply a gradient effect across an entire RedNode tree.
406
+ *
407
+ * The gradient is computed across ALL text content in the tree,
408
+ * so color transitions span across structural boundaries:
409
+ *
410
+ * [b]Hello[/b] World → [b][color=#FF0000]H[/color]...[/b][color=#00FF00]W[/color]...
411
+ *
412
+ * @param root The RedNode tree to transform
413
+ * @param colors Array of hex colors to interpolate across
414
+ * @param options.unit Split unit: 'character' (default), 'word', or 'line'
415
+ * @param options.easing Easing function: 'linear' (default), 'easeIn', 'easeOut', 'easeInOut'
416
+ * @returns A new RedNode tree with gradient applied
417
+ */
418
+ export function applyGradient(
419
+ root: RedNode,
420
+ colors: string[],
421
+ options?: {
422
+ unit?: 'character' | 'word' | 'line'
423
+ easing?: Easing
424
+ },
425
+ ): RedNode {
426
+ return RedNode.allowMutation(() => {
427
+ const unit = options?.unit || 'character'
428
+ const easing = options?.easing || 'linear'
429
+
430
+ // Pass 1: count total text length
431
+ const totalLength = countTextLength(root)
432
+ if (totalLength === 0) return cloneTree(root)
433
+
434
+ // Pass 2: walk and wrap text nodes
435
+ let globalPos = 0
436
+
437
+ function walk(node: RedNode): RedNode {
438
+ // Text leaf — wrap in gradient
439
+ if (node.kind === 'text') {
440
+ const result = wrapTextWithGradient(node, colors, globalPos, totalLength, unit, easing)
441
+ globalPos += node.text.length
442
+ return result
443
+ }
444
+
445
+ // Leaf containers (img, code, etc.) — preserve unchanged
446
+ if (LEAF_KINDS.has(node.kind)) {
447
+ return node
448
+ }
449
+
450
+ // Structural/formatting nodes — recurse into children
451
+ const cloned = cloneTree(node)
452
+ cloned.children = []
453
+ for (const child of node.children) {
454
+ cloned.appendChild(walk(child))
455
+ }
456
+ return cloned
457
+ }
458
+
459
+ return walk(root)
460
+ })
461
+ }
462
+
463
+ /**
464
+ * Apply a grow (size wave) effect across an entire RedNode tree.
465
+ *
466
+ * Size oscillates via sine wave across ALL text content:
467
+ *
468
+ * [b]Hello[/b] World → [b][size=90]H[/size]...[/b][size=160]W[/size]...
469
+ *
470
+ * @param root The RedNode tree to transform
471
+ * @param options.min Minimum size % (default: 90)
472
+ * @param options.max Maximum size % (default: 160)
473
+ * @param options.cycles Number of sine cycles across text (default: 1)
474
+ * @returns A new RedNode tree with grow applied
475
+ */
476
+ export function applyGrow(
477
+ root: RedNode,
478
+ options?: {
479
+ min?: number
480
+ max?: number
481
+ cycles?: number
482
+ },
483
+ ): RedNode {
484
+ return RedNode.allowMutation(() => {
485
+ const min = options?.min ?? 90
486
+ const max = options?.max ?? 160
487
+ const cycles = options?.cycles ?? 1
488
+
489
+ const totalLength = countTextLength(root)
490
+ if (totalLength === 0) return cloneTree(root)
491
+
492
+ let globalPos = 0
493
+
494
+ function walk(node: RedNode): RedNode {
495
+ if (node.kind === 'text') {
496
+ const result = wrapTextWithGrow(node, min, max, cycles, globalPos, totalLength)
497
+ globalPos += node.text.length
498
+ return result
499
+ }
500
+
501
+ if (LEAF_KINDS.has(node.kind)) {
502
+ return node
503
+ }
504
+
505
+ const cloned = cloneTree(node)
506
+ cloned.children = []
507
+ for (const child of node.children) {
508
+ cloned.appendChild(walk(child))
509
+ }
510
+ return cloned
511
+ }
512
+
513
+ return walk(root)
514
+ })
515
+ }
516
+
517
+ /**
518
+ * Apply a rainbow effect across an entire RedNode tree.
519
+ *
520
+ * Hue rotates across ALL text content using HSL color space:
521
+ *
522
+ * [b]Hello[/b] World → [b][color=#FF0000]H[/color]...[/b][color=#00FF00]W[/color]...
523
+ *
524
+ * @param root The RedNode tree to transform
525
+ * @param options.saturation Saturation 0-100 (default: 80)
526
+ * @param options.lightness Lightness 0-100 (default: 60)
527
+ * @param options.spread Hue spread in degrees (default: 300)
528
+ * @param options.offset Starting hue offset in degrees (default: 0)
529
+ * @returns A new RedNode tree with rainbow applied
530
+ */
531
+ export function applyRainbow(
532
+ root: RedNode,
533
+ options?: {
534
+ saturation?: number
535
+ lightness?: number
536
+ spread?: number
537
+ offset?: number
538
+ },
539
+ ): RedNode {
540
+ return RedNode.allowMutation(() => {
541
+ const saturation = ((options?.saturation ?? 80) / 100)
542
+ const lightness = ((options?.lightness ?? 60) / 100)
543
+ const spread = options?.spread ?? 300
544
+ const offset = options?.offset ?? 0
545
+
546
+ const totalLength = countTextLength(root)
547
+ if (totalLength === 0) return cloneTree(root)
548
+
549
+ let globalPos = 0
550
+
551
+ function walk(node: RedNode): RedNode {
552
+ if (node.kind === 'text') {
553
+ const result = wrapTextWithRainbow(node, saturation, lightness, spread, offset, globalPos, totalLength)
554
+ globalPos += node.text.length
555
+ return result
556
+ }
557
+
558
+ if (LEAF_KINDS.has(node.kind)) {
559
+ return node
560
+ }
561
+
562
+ const cloned = cloneTree(node)
563
+ cloned.children = []
564
+ for (const child of node.children) {
565
+ cloned.appendChild(walk(child))
566
+ }
567
+ return cloned
568
+ }
569
+
570
+ return walk(root)
571
+ })
572
+ }
573
+
574
+ /**
575
+ * Apply a central gradient across an entire RedNode tree.
576
+ *
577
+ * Gradient radiates from center outward (mirrored):
578
+ *
579
+ * [b]Hello[/b] World → [b][color=#FF0000]H[/color]...[/b][color=#00FF00]W[/color]...
580
+ *
581
+ * @param root The RedNode tree to transform
582
+ * @param colors Array of hex colors to interpolate across
583
+ * @param options.unit Split unit: 'character' (default), 'word', or 'line'
584
+ * @param options.easing Easing function: 'linear' (default), 'easeIn', 'easeOut', 'easeInOut'
585
+ * @returns A new RedNode tree with central gradient applied
586
+ */
587
+ export function applyCentralGradient(
588
+ root: RedNode,
589
+ colors: string[],
590
+ options?: {
591
+ unit?: 'character' | 'word' | 'line'
592
+ easing?: Easing
593
+ },
594
+ ): RedNode {
595
+ return RedNode.allowMutation(() => {
596
+ const unit = options?.unit || 'character'
597
+ const easing = options?.easing || 'linear'
598
+
599
+ const totalLength = countTextLength(root)
600
+ if (totalLength === 0) return cloneTree(root)
601
+
602
+ let globalPos = 0
603
+
604
+ function walk(node: RedNode): RedNode {
605
+ if (node.kind === 'text') {
606
+ const result = wrapTextWithCentralGradient(node, colors, globalPos, totalLength, unit, easing)
607
+ globalPos += node.text.length
608
+ return result
609
+ }
610
+
611
+ if (LEAF_KINDS.has(node.kind)) {
612
+ return node
613
+ }
614
+
615
+ const cloned = cloneTree(node)
616
+ cloned.children = []
617
+ for (const child of node.children) {
618
+ cloned.appendChild(walk(child))
619
+ }
620
+ return cloned
621
+ }
622
+
623
+ return walk(root)
624
+ })
625
+ }
626
+
627
+ /**
628
+ * Apply a multi gradient (wave or pulse) across an entire RedNode tree.
629
+ *
630
+ * Wave mode: sine wave pattern across colors.
631
+ * Pulse mode: repeating gradient segments.
632
+ *
633
+ * @param root The RedNode tree to transform
634
+ * @param colors Array of hex colors to interpolate across
635
+ * @param options.mode 'wave' (default) or 'pulse'
636
+ * @param options.unit Split unit: 'character' (default), 'word', or 'line'
637
+ * @param options.easing Easing function: 'linear' (default), 'easeIn', 'easeOut', 'easeInOut'
638
+ * @returns A new RedNode tree with multi gradient applied
639
+ */
640
+ export function applyMultiGradient(
641
+ root: RedNode,
642
+ colors: string[],
643
+ options?: {
644
+ mode?: 'wave' | 'pulse'
645
+ unit?: 'character' | 'word' | 'line'
646
+ easing?: Easing
647
+ },
648
+ ): RedNode {
649
+ return RedNode.allowMutation(() => {
650
+ const mode = options?.mode || 'wave'
651
+ const unit = options?.unit || 'character'
652
+ const easing = options?.easing || 'linear'
653
+
654
+ const totalLength = countTextLength(root)
655
+ if (totalLength === 0) return cloneTree(root)
656
+
657
+ let globalPos = 0
658
+
659
+ function walk(node: RedNode): RedNode {
660
+ if (node.kind === 'text') {
661
+ const result = wrapTextWithMultiGradient(node, colors, mode, globalPos, totalLength, unit, easing)
662
+ globalPos += node.text.length
663
+ return result
664
+ }
665
+
666
+ if (LEAF_KINDS.has(node.kind)) {
667
+ return node
668
+ }
669
+
670
+ const cloned = cloneTree(node)
671
+ cloned.children = []
672
+ for (const child of node.children) {
673
+ cloned.appendChild(walk(child))
674
+ }
675
+ return cloned
676
+ }
677
+
678
+ return walk(root)
679
+ })
680
+ }
681
+
682
+ /**
683
+ * Apply any supported effect across an entire RedNode tree.
684
+ */
685
+ export function applyEffect(root: RedNode, effect: TreeEffect): RedNode {
686
+ switch (effect.kind) {
687
+ case 'gradient':
688
+ return applyGradient(root, effect.colors, {
689
+ unit: effect.unit,
690
+ easing: effect.easing,
691
+ })
692
+ case 'grow':
693
+ return applyGrow(root, {
694
+ min: effect.min,
695
+ max: effect.max,
696
+ cycles: effect.cycles,
697
+ })
698
+ case 'rainbow':
699
+ return applyRainbow(root, {
700
+ saturation: effect.saturation,
701
+ lightness: effect.lightness,
702
+ spread: effect.spread,
703
+ offset: effect.offset,
704
+ })
705
+ case 'central_gradient':
706
+ return applyCentralGradient(root, effect.colors, {
707
+ unit: effect.unit,
708
+ easing: effect.easing,
709
+ })
710
+ case 'multi_gradient':
711
+ return applyMultiGradient(root, effect.colors, {
712
+ mode: effect.mode,
713
+ unit: effect.unit,
714
+ easing: effect.easing,
715
+ })
716
+ }
717
+ }