@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,233 @@
1
+ /**
2
+ * Quasar Analysis Framework — Rainbow Analyzer
3
+ *
4
+ * Detects sequences of [color=#HEX] tags where the HSL hue rotates
5
+ * continuously while saturation and lightness remain approximately
6
+ * constant — the classic "rainbow" pattern.
7
+ *
8
+ * ## Confidence features
9
+ *
10
+ * - hueRotation (+0.35) — hue changes monotonically (increasing or decreasing)
11
+ * - stableSaturation (+0.20) — saturation stays within a narrow band
12
+ * - stableLightness (+0.20) — lightness stays within a narrow band
13
+ * - contiguousWrappers (+0.15) — no gaps in the colour sequence
14
+ * - noFormattingBreaks (+0.10) — no formatting tags between colours
15
+ *
16
+ * @see SemanticContribution
17
+ */
18
+
19
+ import type { AnalyzerPass } from '../../Contracts/Pass'
20
+ import type { PipelineContext } from '../../Contracts/PipelineContext'
21
+ import type { Contribution } from '../../Contracts/Contribution'
22
+ import { ContributionKind } from '../../Contracts/Contribution'
23
+ import type { GreenNode } from '../../../Syntax/GreenNode'
24
+ import { childOffsets } from '../../../Syntax/GreenNode'
25
+ import { hexToHsl } from '../../../Utils/ColorMath'
26
+ import { extractHex, sigmoid, extractSequences, checkFormattingBreaks } from '../../Utils/color-utils'
27
+
28
+ // ── Constants ─────────────────────────────────────────────────────
29
+
30
+ const MIN_SEQUENCE_LENGTH = 4
31
+ const SIGMOID_STEEPNESS = 6
32
+
33
+ const WEIGHTS = {
34
+ hueRotation: 0.50,
35
+ stableSaturation: 0.25,
36
+ stableLightness: 0.20,
37
+ noFormattingBreaks: 0.05,
38
+ } as const
39
+
40
+ // ── Types ─────────────────────────────────────────────────────────
41
+
42
+ export interface RainbowModel {
43
+ readonly colors: string[]
44
+ readonly hueStart: number
45
+ readonly hueEnd: number
46
+ readonly avgSaturation: number
47
+ readonly avgLightness: number
48
+ readonly rangeStart: number
49
+ readonly rangeEnd: number
50
+ readonly charCount: number
51
+ readonly diagnostics: RainbowDiagnostics
52
+ }
53
+
54
+ export interface RainbowDiagnostics {
55
+ readonly hueDelta: number
56
+ readonly saturationDeviation: number
57
+ readonly lightnessDeviation: number
58
+ readonly isContinuous: boolean
59
+ readonly featureScores: Readonly<Record<string, number>>
60
+ }
61
+
62
+ // ── Main Analyzer ─────────────────────────────────────────────────
63
+
64
+ export class RainbowAnalyzer implements AnalyzerPass {
65
+ readonly id = 'rainbow-analyzer'
66
+
67
+ run(tree: GreenNode, _context: PipelineContext): Contribution[] {
68
+ const contributions: Contribution[] = []
69
+ this.findRainbows(tree, contributions)
70
+ return contributions
71
+ }
72
+
73
+ private findRainbows(node: GreenNode, sink: Contribution[], nodeStart: number = 0): void {
74
+ // Green nodes carry widths, not positions, so a walk that reports source
75
+ // ranges accumulates them on the way down.
76
+ const offsets = childOffsets(node, nodeStart)
77
+ if (node.children.length > 0) {
78
+ const children = node.children as GreenNode[]
79
+ const sequences = extractSequences(children, 'color', extractHex)
80
+
81
+ for (const seq of sequences) {
82
+ const colors = seq.values as string[]
83
+ if (colors.length < MIN_SEQUENCE_LENGTH) continue
84
+
85
+ let textLen = 0
86
+ for (let i = seq.startIdx; i < seq.endIdx; i++) {
87
+ for (const child of children[i].children as GreenNode[]) {
88
+ if (child.kind === 'text') textLen += child.text.length
89
+ }
90
+ }
91
+
92
+ const hasBreaks = checkFormattingBreaks(children, seq, 'color')
93
+ const diag = this.analyzeRainbow(colors)
94
+ const { score, featureScores } = this.calculateRawScore(diag, hasBreaks)
95
+ const confidence = sigmoid(score, SIGMOID_STEEPNESS)
96
+
97
+ sink.push({
98
+ kind: ContributionKind.Semantic,
99
+ label: 'Rainbow',
100
+ confidence,
101
+ range: {
102
+ start: offsets[seq.startIdx],
103
+ end: offsets[seq.endIdx],
104
+ },
105
+ metadata: {
106
+ model: {
107
+ colors,
108
+ hueStart: diag.hueDelta >= 0 ? colors[0] : colors[colors.length - 1],
109
+ hueEnd: diag.hueDelta >= 0 ? colors[colors.length - 1] : colors[0],
110
+ avgSaturation: this.extractHSL(colors).avgS,
111
+ avgLightness: this.extractHSL(colors).avgL,
112
+ charCount: textLen,
113
+ },
114
+ diagnostics: { ...diag, featureScores },
115
+ },
116
+ })
117
+ }
118
+ }
119
+
120
+ const kids = node.children as GreenNode[]
121
+ for (let i = 0; i < kids.length; i++) {
122
+ this.findRainbows(kids[i], sink, offsets[i])
123
+ }
124
+ }
125
+
126
+ // ── HSL Analysis ────────────────────────────────────────────────
127
+
128
+ private extractHSL(colors: string[]): { hues: number[]; sats: number[]; lights: number[]; avgS: number; avgL: number } {
129
+ const hues: number[] = []
130
+ const sats: number[] = []
131
+ const lights: number[] = []
132
+
133
+ for (const c of colors) {
134
+ const [h, s, l] = hexToHsl(c)
135
+ hues.push(h)
136
+ sats.push(s)
137
+ lights.push(l)
138
+ }
139
+
140
+ const avgS = sats.reduce((a, b) => a + b, 0) / sats.length
141
+ const avgL = lights.reduce((a, b) => a + b, 0) / lights.length
142
+
143
+ return { hues, sats, lights, avgS, avgL }
144
+ }
145
+
146
+ private analyzeRainbow(colors: string[]): Omit<RainbowDiagnostics, 'featureScores'> {
147
+ const { hues, sats, lights, avgS, avgL } = this.extractHSL(colors)
148
+ const n = colors.length
149
+
150
+ // 1. Hue rotation: compute signed delta, unwrapping at 360→0
151
+ let totalHueDelta = 0
152
+ let unwrapped = hues[0]
153
+ for (let i = 1; i < n; i++) {
154
+ let delta = hues[i] - hues[i - 1]
155
+ if (delta > 180) delta -= 360
156
+ else if (delta < -180) delta += 360
157
+ totalHueDelta += delta
158
+ unwrapped += delta
159
+ }
160
+
161
+ // Reset to compute direction from unwrapped
162
+ const hueDirection = Math.sign(totalHueDelta)
163
+
164
+ // Check hue monotonicity (after unwrapping)
165
+ let hueViolations = 0
166
+ unwrapped = hues[0]
167
+ for (let i = 1; i < n; i++) {
168
+ let delta = hues[i] - hues[i - 1]
169
+ if (delta > 180) delta -= 360
170
+ else if (delta < -180) delta += 360
171
+ unwrapped += delta
172
+
173
+ const prevUnwrapped = unwrapped - delta
174
+ if (Math.sign(delta) !== hueDirection && Math.abs(delta) > 15) {
175
+ hueViolations++
176
+ }
177
+ }
178
+ const isContinuous = hueViolations < n * 0.25
179
+
180
+ // 2. Saturation stability (standard deviation)
181
+ const satDev = Math.sqrt(sats.reduce((sum, s) => sum + (s - avgS) ** 2, 0) / n)
182
+
183
+ // 3. Lightness stability (standard deviation)
184
+ const litDev = Math.sqrt(lights.reduce((sum, l) => sum + (l - avgL) ** 2, 0) / n)
185
+
186
+ return {
187
+ hueDelta: totalHueDelta,
188
+ saturationDeviation: satDev,
189
+ lightnessDeviation: litDev,
190
+ isContinuous,
191
+ }
192
+ }
193
+
194
+ // ── Confidence Scoring ──────────────────────────────────────────
195
+
196
+ private calculateRawScore(
197
+ diag: Omit<RainbowDiagnostics, 'featureScores'>,
198
+ hasBreaks: boolean,
199
+ ): { score: number; featureScores: Record<string, number> } {
200
+ let score = 0
201
+
202
+ // Hue rotation: need significant hue change + continuity
203
+ const absHueDelta = Math.abs(diag.hueDelta)
204
+ if (diag.isContinuous && absHueDelta > 60) score += WEIGHTS.hueRotation
205
+ else if (absHueDelta > 30) score += WEIGHTS.hueRotation * 0.5
206
+ else score -= WEIGHTS.hueRotation * 0.5
207
+
208
+ // Stable saturation (low deviation)
209
+ if (diag.saturationDeviation < 15) score += WEIGHTS.stableSaturation
210
+ else if (diag.saturationDeviation < 30) score += WEIGHTS.stableSaturation * 0.5
211
+ else score -= WEIGHTS.stableSaturation * 0.5
212
+
213
+ // Stable lightness (low deviation)
214
+ if (diag.lightnessDeviation < 15) score += WEIGHTS.stableLightness
215
+ else if (diag.lightnessDeviation < 30) score += WEIGHTS.stableLightness * 0.5
216
+ else score -= WEIGHTS.stableLightness * 0.5
217
+
218
+ // No formatting breaks
219
+ if (!hasBreaks) score += WEIGHTS.noFormattingBreaks
220
+
221
+ const featureScores = {
222
+ hueRotation: (diag.isContinuous && absHueDelta > 60) ? WEIGHTS.hueRotation :
223
+ absHueDelta > 30 ? WEIGHTS.hueRotation * 0.5 : -WEIGHTS.hueRotation * 0.5,
224
+ stableSaturation: diag.saturationDeviation < 15 ? WEIGHTS.stableSaturation :
225
+ diag.saturationDeviation < 30 ? WEIGHTS.stableSaturation * 0.5 : -WEIGHTS.stableSaturation * 0.5,
226
+ stableLightness: diag.lightnessDeviation < 15 ? WEIGHTS.stableLightness :
227
+ diag.lightnessDeviation < 30 ? WEIGHTS.stableLightness * 0.5 : -WEIGHTS.stableLightness * 0.5,
228
+ noFormattingBreaks: hasBreaks ? -WEIGHTS.noFormattingBreaks : WEIGHTS.noFormattingBreaks,
229
+ }
230
+
231
+ return { score, featureScores }
232
+ }
233
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Quasar Analysis Framework — Wave (Size Oscillation) Analyzer
3
+ *
4
+ * Detects sequences of [size=N] tags where the sizes follow a sinusoidal
5
+ * (or other periodic) oscillation pattern — the classic "wave/grow" effect.
6
+ *
7
+ * ## Confidence features
8
+ *
9
+ * - periodicPattern (+0.35) — sizes follow a clear periodic pattern
10
+ * - boundedRange (+0.20) — sizes stay within a plausible min-max range
11
+ * - smoothTransitions (+0.20) — adjacent size differences are small
12
+ * - contiguousWrappers (+0.15) — no gaps in the size sequence
13
+ * - noFormattingBreaks (+0.10) — no formatting tags between size nodes
14
+ *
15
+ * @see SemanticContribution
16
+ */
17
+
18
+ import type { AnalyzerPass } from '../../Contracts/Pass'
19
+ import type { PipelineContext } from '../../Contracts/PipelineContext'
20
+ import type { Contribution } from '../../Contracts/Contribution'
21
+ import { ContributionKind } from '../../Contracts/Contribution'
22
+ import type { GreenNode } from '../../../Syntax/GreenNode'
23
+ import { childOffsets } from '../../../Syntax/GreenNode'
24
+ import { extractSize, sigmoid, extractSequences, checkFormattingBreaks } from '../../Utils/color-utils'
25
+
26
+ // ── Constants ─────────────────────────────────────────────────────
27
+
28
+ const MIN_SEQUENCE_LENGTH = 5
29
+ const SIGMOID_STEEPNESS = 6
30
+
31
+ const WEIGHTS = {
32
+ periodicPattern: 0.45,
33
+ boundedRange: 0.25,
34
+ smoothTransitions: 0.25,
35
+ noFormattingBreaks: 0.05,
36
+ } as const
37
+
38
+ // ── Types ─────────────────────────────────────────────────────────
39
+
40
+ export interface WaveModel {
41
+ readonly sizes: number[]
42
+ readonly minSize: number
43
+ readonly maxSize: number
44
+ readonly estimatedFrequency: number
45
+ readonly rangeStart: number
46
+ readonly rangeEnd: number
47
+ readonly charCount: number
48
+ readonly diagnostics: WaveDiagnostics
49
+ }
50
+
51
+ export interface WaveDiagnostics {
52
+ readonly periodicityScore: number // 0-1, how well it matches a sinusoid
53
+ readonly hasSymmetry: boolean
54
+ readonly isBounded: boolean
55
+ readonly transitionSmoothness: number // 0-1
56
+ readonly featureScores: Readonly<Record<string, number>>
57
+ }
58
+
59
+ // ── Main Analyzer ─────────────────────────────────────────────────
60
+
61
+ export class WaveAnalyzer implements AnalyzerPass {
62
+ readonly id = 'wave-analyzer'
63
+
64
+ run(tree: GreenNode, _context: PipelineContext): Contribution[] {
65
+ const contributions: Contribution[] = []
66
+ this.findWaves(tree, contributions)
67
+ return contributions
68
+ }
69
+
70
+ private findWaves(node: GreenNode, sink: Contribution[], nodeStart: number = 0): void {
71
+ // Green nodes carry widths, not positions, so a walk that reports source
72
+ // ranges accumulates them on the way down.
73
+ const offsets = childOffsets(node, nodeStart)
74
+ if (node.children.length > 0) {
75
+ const children = node.children as GreenNode[]
76
+ const sequences = extractSequences(children, 'font_size', extractSize)
77
+
78
+ for (const seq of sequences) {
79
+ const sizes = seq.values as number[]
80
+ if (sizes.length < MIN_SEQUENCE_LENGTH) continue
81
+
82
+ let textLen = 0
83
+ for (let i = seq.startIdx; i < seq.endIdx; i++) {
84
+ for (const child of children[i].children as GreenNode[]) {
85
+ if (child.kind === 'text') textLen += child.text.length
86
+ }
87
+ }
88
+
89
+ const hasBreaks = checkFormattingBreaks(children, seq, 'font_size')
90
+ const diag = this.analyzeWave(sizes)
91
+ const { score, featureScores } = this.calculateRawScore(diag, hasBreaks)
92
+ const confidence = sigmoid(score, SIGMOID_STEEPNESS)
93
+
94
+ sink.push({
95
+ kind: ContributionKind.Semantic,
96
+ label: 'Wave',
97
+ confidence,
98
+ range: {
99
+ start: offsets[seq.startIdx],
100
+ end: offsets[seq.endIdx],
101
+ },
102
+ metadata: {
103
+ model: {
104
+ sizes,
105
+ minSize: Math.min(...sizes),
106
+ maxSize: Math.max(...sizes),
107
+ estimatedFrequency: diag.periodicityScore,
108
+ charCount: textLen,
109
+ },
110
+ diagnostics: { ...diag, featureScores },
111
+ },
112
+ })
113
+ }
114
+ }
115
+
116
+ const kids = node.children as GreenNode[]
117
+ for (let i = 0; i < kids.length; i++) {
118
+ this.findWaves(kids[i], sink, offsets[i])
119
+ }
120
+ }
121
+
122
+ // ── Wave Analysis ───────────────────────────────────────────────
123
+
124
+ private analyzeWave(sizes: number[]): Omit<WaveDiagnostics, 'featureScores'> {
125
+ const n = sizes.length
126
+
127
+ // 1. Detect periodicity: normalise to [0,1] and check correlation with sinusoid
128
+ const min = Math.min(...sizes)
129
+ const max = Math.max(...sizes)
130
+ const range = max - min || 1
131
+ const norm = sizes.map(s => (s - min) / range)
132
+
133
+ // Try multiple frequencies, pick the best match
134
+ let bestCorrelation = 0
135
+ for (let freq = 0.5; freq <= 4; freq += 0.25) {
136
+ let correlation = 0
137
+ for (let i = 0; i < n; i++) {
138
+ const t = i / (n - 1 || 1)
139
+ const sinVal = (Math.sin(t * Math.PI * 2 * freq) + 1) / 2
140
+ correlation += 1 - Math.abs(norm[i] - sinVal)
141
+ }
142
+ correlation /= n
143
+ bestCorrelation = Math.max(bestCorrelation, correlation)
144
+ }
145
+
146
+ const periodicityScore = Math.max(0, Math.min(1, bestCorrelation))
147
+
148
+ // 2. Check symmetry: is the first half a mirror of the second half?
149
+ let symmetry = 0
150
+ for (let i = 0; i < Math.floor(n / 2); i++) {
151
+ const j = n - 1 - i
152
+ symmetry += 1 - Math.abs(norm[i] - norm[j]) / (range || 1)
153
+ }
154
+ const hasSymmetry = symmetry / Math.max(1, Math.floor(n / 2)) > 0.7
155
+
156
+ // 3. Bounded range: are min and max within a sensible range?
157
+ const isBounded = min >= 50 && max <= 250 && range >= 20
158
+
159
+ // 4. Transition smoothness: are adjacent sizes close?
160
+ let smoothness = 0
161
+ for (let i = 1; i < n; i++) {
162
+ const diff = Math.abs(sizes[i] - sizes[i - 1])
163
+ smoothness += 1 - Math.min(1, diff / (range || 1))
164
+ }
165
+ const transitionSmoothness = smoothness / Math.max(1, n - 1)
166
+
167
+ return {
168
+ periodicityScore,
169
+ hasSymmetry,
170
+ isBounded,
171
+ transitionSmoothness,
172
+ }
173
+ }
174
+
175
+ // ── Confidence Scoring ──────────────────────────────────────────
176
+
177
+ private calculateRawScore(
178
+ diag: Omit<WaveDiagnostics, 'featureScores'>,
179
+ hasBreaks: boolean,
180
+ ): { score: number; featureScores: Record<string, number> } {
181
+ let score = 0
182
+
183
+ // Periodicity
184
+ if (diag.periodicityScore > 0.7) score += WEIGHTS.periodicPattern
185
+ else if (diag.periodicityScore > 0.4) score += WEIGHTS.periodicPattern * 0.5
186
+ else score -= WEIGHTS.periodicPattern * 0.5
187
+
188
+ // Bounded range
189
+ if (diag.isBounded) score += WEIGHTS.boundedRange
190
+ else score -= WEIGHTS.boundedRange * 0.5
191
+
192
+ // Smooth transitions
193
+ if (diag.transitionSmoothness > 0.6) score += WEIGHTS.smoothTransitions
194
+ else if (diag.transitionSmoothness > 0.3) score += WEIGHTS.smoothTransitions * 0.5
195
+ else score -= WEIGHTS.smoothTransitions * 0.5
196
+
197
+ // No formatting breaks
198
+ if (!hasBreaks) score += WEIGHTS.noFormattingBreaks
199
+
200
+ const featureScores = {
201
+ periodicPattern: diag.periodicityScore > 0.7 ? WEIGHTS.periodicPattern :
202
+ diag.periodicityScore > 0.4 ? WEIGHTS.periodicPattern * 0.5 : -WEIGHTS.periodicPattern * 0.5,
203
+ boundedRange: diag.isBounded ? WEIGHTS.boundedRange : -WEIGHTS.boundedRange * 0.5,
204
+ smoothTransitions: diag.transitionSmoothness > 0.6 ? WEIGHTS.smoothTransitions :
205
+ diag.transitionSmoothness > 0.3 ? WEIGHTS.smoothTransitions * 0.5 : -WEIGHTS.smoothTransitions * 0.5,
206
+ noFormattingBreaks: hasBreaks ? -WEIGHTS.noFormattingBreaks : WEIGHTS.noFormattingBreaks,
207
+ }
208
+
209
+ return { score, featureScores }
210
+ }
211
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tests for refined GradientAnalyzer v2 (OKLab, change-point detection)
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest'
6
+ import { greenNode, greenLeaf } from '../../../../Syntax/GreenNode'
7
+ import type { GreenNode } from '../../../../Syntax/GreenNode'
8
+ import { GradientAnalyzer } from '../GradientAnalyzer'
9
+ import type { PipelineContext } from '../../../Contracts/PipelineContext'
10
+ import { PipelineMode, ExportTarget } from '../../../Contracts/PipelineContext'
11
+ import { ContributionKind } from '../../../Contracts/Contribution'
12
+
13
+ const mockContext: PipelineContext = {
14
+ mode: PipelineMode.Batch,
15
+ target: ExportTarget.Miliastry,
16
+ featureFlags: {},
17
+ metadata: {},
18
+ }
19
+
20
+ function colorNode(hex: string, text: string): GreenNode {
21
+ // Widths of the real BBCode this stands for: `[color=#RRGGBB]` and `[/color]`.
22
+ const textLeaf = greenLeaf('text', text)
23
+ return greenNode('color', `=${hex}`, [textLeaf], 8 + hex.length, 8)
24
+ }
25
+
26
+ describe('GradientAnalyzer v2', () => {
27
+ it('detects a smooth linear gradient with high confidence', () => {
28
+ // Perfectly spaced perceptual gradient: #FF0000 → #990000 → #330000
29
+ const tree = greenNode('document', '', [
30
+ colorNode('#FF0000', 'A'),
31
+ colorNode('#CC0022', 'B'),
32
+ colorNode('#990044', 'C'),
33
+ colorNode('#660066', 'D'),
34
+ colorNode('#330088', 'E'),
35
+ colorNode('#0000AA', 'F'),
36
+ ])
37
+
38
+ const analyzer = new GradientAnalyzer()
39
+ const results = analyzer.run(tree, mockContext)
40
+
41
+ expect(results.length).toBeGreaterThanOrEqual(1)
42
+ if (results[0].kind === 'semantic') {
43
+ expect(results[0].confidence).toBeGreaterThan(0.7)
44
+ }
45
+ })
46
+
47
+ it('detects change points (stops) in plateau-heavy sequences', () => {
48
+ // RRRR→G→BBBB: 4 R, 1 G, 4 B
49
+ const tree = greenNode('document', '', [
50
+ colorNode('#FF0000', 'R'),
51
+ colorNode('#FF0000', 'R'),
52
+ colorNode('#FF0000', 'R'),
53
+ colorNode('#FF0000', 'R'),
54
+ colorNode('#00FF00', 'G'),
55
+ colorNode('#0000FF', 'B'),
56
+ colorNode('#0000FF', 'B'),
57
+ colorNode('#0000FF', 'B'),
58
+ colorNode('#0000FF', 'B'),
59
+ ])
60
+
61
+ const analyzer = new GradientAnalyzer()
62
+ const results = analyzer.run(tree, mockContext)
63
+
64
+ expect(results.length).toBeGreaterThanOrEqual(1)
65
+ if (results[0].kind === 'semantic') {
66
+ // Check that stops were detected via change-point detection
67
+ const model = results[0].metadata.model as Record<string, unknown>
68
+ const stops = model.stops as Array<{ color: string; position: number }>
69
+ // Should detect 3 stops: R, G, B
70
+ expect(stops.length).toBeGreaterThanOrEqual(3)
71
+ }
72
+ })
73
+
74
+ it('gives low confidence to random colour jumps', () => {
75
+ // Red→Green→Blue: sharp perceptual jumps
76
+ const tree = greenNode('document', '', [
77
+ colorNode('#FF0000', 'A'),
78
+ colorNode('#00FF00', 'B'),
79
+ colorNode('#0000FF', 'C'),
80
+ ])
81
+
82
+ const analyzer = new GradientAnalyzer()
83
+ const results = analyzer.run(tree, mockContext)
84
+
85
+ // Should detect, but with low confidence (large perceptual error)
86
+ if (results.length > 0 && results[0].kind === 'semantic') {
87
+ expect(results[0].confidence).toBeLessThan(0.7)
88
+ }
89
+ })
90
+
91
+ it('ignores sequences shorter than minimum length', () => {
92
+ const tree = greenNode('document', '', [
93
+ colorNode('#FF0000', 'A'),
94
+ colorNode('#00FF00', 'B'),
95
+ ])
96
+
97
+ const analyzer = new GradientAnalyzer()
98
+ const results = analyzer.run(tree, mockContext)
99
+
100
+ expect(results).toHaveLength(0)
101
+ })
102
+
103
+ it('handles mixed content (non-color nodes between colors)', () => {
104
+ const tree = greenNode('document', '', [
105
+ colorNode('#FF0000', 'A'),
106
+ greenLeaf('text', ' break '),
107
+ colorNode('#EE1100', 'B'),
108
+ ])
109
+
110
+ const analyzer = new GradientAnalyzer()
111
+ const results = analyzer.run(tree, mockContext)
112
+
113
+ // The break text should separate the sequence into single nodes
114
+ expect(results).toHaveLength(0)
115
+ })
116
+
117
+ it('returns diagnostics with stop count in metadata', () => {
118
+ const tree = greenNode('document', '', [
119
+ colorNode('#FF0000', 'A'),
120
+ colorNode('#DD2222', 'B'),
121
+ colorNode('#BB4444', 'C'),
122
+ colorNode('#996666', 'D'),
123
+ colorNode('#778888', 'E'),
124
+ ])
125
+
126
+ const analyzer = new GradientAnalyzer()
127
+ const results = analyzer.run(tree, mockContext)
128
+
129
+ if (results.length > 0 && results[0].kind === 'semantic') {
130
+ const diag = results[0].metadata.diagnostics as Record<string, unknown>
131
+ expect(typeof diag.stopCount).toBe('number')
132
+ expect(typeof diag.maxPerceptualError).toBe('number')
133
+ }
134
+ })
135
+ })
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tests for MergeableColorAnalyzer
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest'
6
+ import { greenNode, greenLeaf } from '../../../../Syntax/GreenNode'
7
+ import { MergeableColorAnalyzer } from '../MergeableColorAnalyzer'
8
+ import type { PipelineContext } from '../../../Contracts/PipelineContext'
9
+ import { PipelineMode, ExportTarget } from '../../../Contracts/PipelineContext'
10
+ import { ContributionKind } from '../../../Contracts/Contribution'
11
+
12
+ const mockContext: PipelineContext = {
13
+ mode: PipelineMode.Batch,
14
+ target: ExportTarget.Osu,
15
+ featureFlags: {},
16
+ metadata: {},
17
+ }
18
+
19
+ function colorNode(hex: string, text: string): import('../../../../Syntax/GreenNode').GreenNode {
20
+ // Widths of the real BBCode this stands for: `[color=#RRGGBB]` and `[/color]`.
21
+ const textLeaf = greenLeaf('text', text)
22
+ return greenNode('color', `=${hex}`, [textLeaf], 8 + hex.length, 8)
23
+ }
24
+
25
+ describe('MergeableColorAnalyzer', () => {
26
+ it('detects mergeable consecutive identical colors', () => {
27
+ // [color=#FF0000]H[/color][color=#FF0000]e[/color][color=#FF0000]l[/color]
28
+ const tree = greenNode('document', '', [
29
+ colorNode('#FF0000', 'H'),
30
+ colorNode('#FF0000', 'e'),
31
+ colorNode('#FF0000', 'l'),
32
+ ])
33
+
34
+ const analyzer = new MergeableColorAnalyzer()
35
+ const results = analyzer.run(tree, mockContext)
36
+
37
+ expect(results).toHaveLength(1)
38
+ expect(results[0]).toMatchObject({
39
+ kind: ContributionKind.Optimization,
40
+ label: 'Merge Colors',
41
+ })
42
+ })
43
+
44
+ it('does NOT report non-identical consecutive colors', () => {
45
+ // [color=#FF0000]H[/color][color=#EE1100]e[/color][color=#DD2200]l[/color]
46
+ const tree = greenNode('document', '', [
47
+ colorNode('#FF0000', 'H'),
48
+ colorNode('#EE1100', 'e'),
49
+ colorNode('#DD2200', 'l'),
50
+ ])
51
+
52
+ const analyzer = new MergeableColorAnalyzer()
53
+ const results = analyzer.run(tree, mockContext)
54
+
55
+ expect(results).toHaveLength(0)
56
+ })
57
+
58
+ it('does NOT report single color nodes', () => {
59
+ const tree = greenNode('document', '', [
60
+ colorNode('#FF0000', 'H'),
61
+ ])
62
+
63
+ const analyzer = new MergeableColorAnalyzer()
64
+ const results = analyzer.run(tree, mockContext)
65
+
66
+ expect(results).toHaveLength(0)
67
+ })
68
+
69
+ it('detects multiple separate mergeable sequences', () => {
70
+ // [color=#FF0000]A[/color][color=#FF0000]B[/color] --- [color=#00FF00]X[/color][color=#00FF00]Y[/color][color=#00FF00]Z[/color]
71
+ const tree = greenNode('document', '', [
72
+ colorNode('#FF0000', 'A'),
73
+ colorNode('#FF0000', 'B'),
74
+ colorNode('#00FF00', 'X'),
75
+ colorNode('#00FF00', 'Y'),
76
+ colorNode('#00FF00', 'Z'),
77
+ ])
78
+
79
+ const analyzer = new MergeableColorAnalyzer()
80
+ const results = analyzer.run(tree, mockContext)
81
+
82
+ expect(results).toHaveLength(2)
83
+ })
84
+ })