@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
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@miliastry/quasar",
3
+ "version": "1.0.0",
4
+ "description": "Quasar Document Engine - Advanced BBCode/Markdown/HTML AST Engine",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "license": "MSL-1.0",
8
+ "sideEffects": [
9
+ "./src/Visuals/*.css",
10
+ "./dist/Visuals/*.css"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "import": {
15
+ "types": "./dist/index.d.mts",
16
+ "default": "./dist/index.mjs"
17
+ },
18
+ "require": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ }
22
+ },
23
+ "./Visuals/lyne.css": "./dist/Visuals/lyne.css",
24
+ "./Visuals/osu.css": "./dist/Visuals/osu.css",
25
+ "./dist/Visuals/*": "./dist/Visuals/*",
26
+ "./src/*": "./src/*",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup && node scripts/copy-css.mjs",
31
+ "prepublishOnly": "npm run verify && npm run build",
32
+ "test": "vitest run",
33
+ "typecheck": "tsc --noEmit -p tsconfig.json",
34
+ "verify": "npm run typecheck && npm run test"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "main": "dist/index.js",
39
+ "types": "dist/index.d.ts"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "src"
44
+ ],
45
+ "dependencies": {},
46
+ "devDependencies": {
47
+ "@miliastry/quasar-studio": "*",
48
+ "tsup": "^8.5.1",
49
+ "typescript": "5.7.3"
50
+ }
51
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Quasar Analysis Framework — Analysis Report
3
+ *
4
+ * Once all AnalyzerPasses have run, their Contributions are collected
5
+ * into an immutable AnalysisReport. This report is the sole input to
6
+ * the DecisionPass(es).
7
+ *
8
+ * We deliberately keep the aggregator simple for now — just an array
9
+ * of contributions. As more pass types emerge we may introduce a
10
+ * structured Aggregator that indexes/categorises contributions.
11
+ *
12
+ * @see DecisionPass
13
+ */
14
+
15
+ import type { Contribution } from './Contribution'
16
+
17
+ export interface AnalysisReport {
18
+ /** All contributions gathered from every AnalyzerPass. */
19
+ readonly contributions: readonly Contribution[]
20
+
21
+ /** Total number of analysis passes that ran. */
22
+ readonly passCount: number
23
+
24
+ /** Wall-clock time spent in analysis (ms). */
25
+ readonly elapsedMs: number
26
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Quasar Analysis Framework — Contribution Types
3
+ *
4
+ * Every AnalyzerPass produces an array of Contributions. A Contribution
5
+ * is a discriminated union so that downstream consumers (DecisionPass,
6
+ * AnalysisReport consumers, debug UI) can switch on `kind` and get
7
+ * perfect TypeScript narrowing.
8
+ *
9
+ * @see AnalysisReport
10
+ */
11
+
12
+ // ── Kind enum ─────────────────────────────────────────────────────
13
+
14
+ export const ContributionKind = {
15
+ Semantic: 'semantic',
16
+ Diagnostic: 'diagnostic',
17
+ Optimization: 'optimization',
18
+ Metrics: 'metrics',
19
+ } as const
20
+
21
+ export type ContributionKind = (typeof ContributionKind)[keyof typeof ContributionKind]
22
+
23
+ // ── Individual contribution shapes ────────────────────────────────
24
+
25
+ /**
26
+ * A semantic pattern recognised in the tree (gradient, wave, rainbow,…).
27
+ * These describe *meaning* of the document, not just surface syntax.
28
+ */
29
+ export interface SemanticContribution {
30
+ readonly kind: typeof ContributionKind.Semantic
31
+ readonly label: string
32
+ readonly confidence: number // 0-1
33
+ readonly range: { start: number; end: number }
34
+ readonly metadata: Record<string, unknown>
35
+ /** Human-readable description (optional). */
36
+ readonly description?: string
37
+ }
38
+
39
+ /**
40
+ * A diagnostic (warning, error, info) attached to a range in the tree.
41
+ */
42
+ export interface DiagnosticContribution {
43
+ readonly kind: typeof ContributionKind.Diagnostic
44
+ readonly severity: 'error' | 'warning' | 'info' | 'hint'
45
+ readonly message: string
46
+ readonly range?: { start: number; end: number }
47
+ readonly code?: string
48
+ }
49
+
50
+ /**
51
+ * An optimisation opportunity detected in the tree (mergeable colours,
52
+ * redundant wrapping, empty tags, …).
53
+ */
54
+ export interface OptimizationContribution {
55
+ readonly kind: typeof ContributionKind.Optimization
56
+ readonly label: string
57
+ readonly description: string
58
+ readonly estimatedImprovement?: string
59
+ readonly range: { start: number; end: number }
60
+ }
61
+
62
+ /**
63
+ * Aggregate metrics about the tree (character count, node depth, …).
64
+ */
65
+ export interface MetricsContribution {
66
+ readonly kind: typeof ContributionKind.Metrics
67
+ readonly metrics: Record<string, number | string>
68
+ }
69
+
70
+ // ── Discriminated union ───────────────────────────────────────────
71
+
72
+ export type Contribution =
73
+ | SemanticContribution
74
+ | DiagnosticContribution
75
+ | OptimizationContribution
76
+ | MetricsContribution
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Quasar Analysis Framework — Pass Contracts
3
+ *
4
+ * Each pass in the analysis/transform pipeline implements one of these
5
+ * specialized interfaces. The three kinds mirror LLVM's pass structure:
6
+ *
7
+ * Analysis — observe the tree, produce Contributions (never mutate)
8
+ * Decision — consume an AnalysisReport, produce a TransformationPlan
9
+ * Transform — mutate the tree according to a plan
10
+ *
11
+ * @see ARCHITECTURE.md (Analysis Framework)
12
+ */
13
+
14
+ import type { PipelineContext } from './PipelineContext'
15
+ import type { Contribution } from './Contribution'
16
+ import type { GreenNode } from '../../Syntax/GreenNode'
17
+ import type { AnalysisReport } from './AnalysisReport'
18
+
19
+ // ── Base ──────────────────────────────────────────────────────────
20
+
21
+ /** Every pass has an identity string and belongs to a stage. */
22
+ export interface Pass {
23
+ readonly id: string
24
+ }
25
+
26
+ // ── Analysis — observe, never mutate ──────────────────────────────
27
+
28
+ /**
29
+ * An AnalyzerPass observes the Green Tree and produces one or more
30
+ * Contributions. It MUST NOT mutate the tree or the context.
31
+ */
32
+ export interface AnalyzerPass extends Pass {
33
+ run(tree: GreenNode, context: PipelineContext): Contribution[]
34
+ }
35
+
36
+ // ── Decision — plan actions from evidence ─────────────────────────
37
+
38
+ /**
39
+ * A DecisionPass consumes an AnalysisReport (which itself is the
40
+ * aggregated output of all AnalyzerPasses) and produces a
41
+ * TransformationPlan that describes *intended* tree mutations.
42
+ */
43
+ export interface DecisionPass extends Pass {
44
+ run(report: AnalysisReport, context: PipelineContext): TransformationPlan
45
+ }
46
+
47
+ // ── Transform — execute a plan on the tree ────────────────────────
48
+
49
+ /**
50
+ * A TransformPass receives the current Green Tree together with a
51
+ * TransformationPlan and returns a **new** Green Tree. It MUST NOT
52
+ * mutate the original tree — immutability guarantees deterministic
53
+ * replay and debugging.
54
+ */
55
+ export interface TransformPass extends Pass {
56
+ run(tree: GreenNode, plan: TransformationPlan, context: PipelineContext): GreenNode
57
+ }
58
+
59
+ // ── Supporting types ──────────────────────────────────────────────
60
+
61
+ /**
62
+ * A single action described by a TransformationPlan.
63
+ * `kind` tells the TransformPass what to do; `payload` carries
64
+ * per-kind parameters.
65
+ */
66
+ export interface TransformAction {
67
+ readonly kind: string
68
+ readonly payload: Record<string, unknown>
69
+ }
70
+
71
+ /**
72
+ * Ordered set of actions that a DecisionPass produces.
73
+ */
74
+ export interface TransformationPlan {
75
+ readonly actions: readonly TransformAction[]
76
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Quasar Analysis Framework — Pipeline Context
3
+ *
4
+ * PipelineContext is **read-only by contract**. It is built *before*
5
+ * the pipeline starts and MUST NOT be modified by any pass.
6
+ *
7
+ * If a pass needs to make information available to downstream passes it
8
+ * should emit a Contribution rather than mutating the context.
9
+ *
10
+ * @immutable
11
+ */
12
+
13
+ // ── Mode ──────────────────────────────────────────────────────────
14
+
15
+ export const PipelineMode = {
16
+ Interactive: 'interactive', // TextStudio, editor integration
17
+ Batch: 'batch', // CLI, automated processing
18
+ Import: 'import', // Clipboard / file import
19
+ } as const
20
+
21
+ export type PipelineMode = (typeof PipelineMode)[keyof typeof PipelineMode]
22
+
23
+ // ── Export target ─────────────────────────────────────────────────
24
+
25
+ export const ExportTarget = {
26
+ Osu: 'osu',
27
+ Miliastry: 'miliastry',
28
+ HTML: 'html',
29
+ Markdown: 'markdown',
30
+ } as const
31
+
32
+ export type ExportTarget = (typeof ExportTarget)[keyof typeof ExportTarget]
33
+
34
+ // ── Context ───────────────────────────────────────────────────────
35
+
36
+ export interface PipelineContext {
37
+ /** Where this pipeline run originates from. */
38
+ readonly mode: PipelineMode
39
+
40
+ /** The format we intend to export to (influences decisions). */
41
+ readonly target: ExportTarget
42
+
43
+ /** Feature flags that may enable/disable certain behaviours. */
44
+ readonly featureFlags: Readonly<Record<string, boolean>>
45
+
46
+ /** Free-form metadata provided by the caller (e.g. selection range). */
47
+ readonly metadata: Readonly<Record<string, unknown>>
48
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Quasar Analysis Framework — Gradient Analyzer (v2)
3
+ *
4
+ * Detects sequences of [color=#HEX] tags that form a gradient pattern
5
+ * and reports them as SemanticContributions with a confidence score.
6
+ *
7
+ * ## Refinements over v1
8
+ *
9
+ * 1. **OKLab color space** — Uses OKLab (Bottosson 2020) for perceptual
10
+ * colour distance instead of RGB. Two colours with small OKLab distance
11
+ * look nearly identical to the human eye, making gradient detection
12
+ * much more accurate.
13
+ *
14
+ * 2. **Change-point detection** — Identifies gradient *stops* even when
15
+ * plateaus (runs of identical colour) exist, so a sequence like
16
+ * RRRR→G→BBBB is correctly parsed as a 3-stop gradient rather than
17
+ * a failed linear interpolation.
18
+ *
19
+ * 3. **Sharper sigmoid (k=6)** — Better separation between "likely
20
+ * gradient" and "maybe gradient" at the decision thresholds.
21
+ *
22
+ * ## Confidence features (weights sum to 1.0)
23
+ *
24
+ * - uniformPerceptualSpacing (+0.30)
25
+ * - contiguousWrappers (+0.25)
26
+ * - noFormattingBreaks (+0.15)
27
+ * - monotonicProgression (+0.20)
28
+ * - lowPerceptualError (+0.10)
29
+ *
30
+ * @see SemanticContribution
31
+ */
32
+
33
+ import type { AnalyzerPass } from '../../Contracts/Pass'
34
+ import type { PipelineContext } from '../../Contracts/PipelineContext'
35
+ import type { Contribution } from '../../Contracts/Contribution'
36
+ import { ContributionKind } from '../../Contracts/Contribution'
37
+ import type { GreenNode } from '../../../Syntax/GreenNode'
38
+ import { childOffsets } from '../../../Syntax/GreenNode'
39
+ import { hexToOklab, mixHexOklab, perceptualDistance, hexToRgb } from '../../../Utils/ColorMath'
40
+ import { extractHex, sigmoid, extractSequences, checkFormattingBreaks } from '../../Utils/color-utils'
41
+
42
+ // ── Constants ─────────────────────────────────────────────────────
43
+
44
+ const MIN_SEQUENCE_LENGTH = 3
45
+ const SIGMOID_STEEPNESS = 6
46
+
47
+ const WEIGHTS = {
48
+ uniformPerceptualSpacing: 0.30,
49
+ contiguousWrappers: 0.25,
50
+ noFormattingBreaks: 0.15,
51
+ monotonicProgression: 0.20,
52
+ lowPerceptualError: 0.10,
53
+ } as const
54
+
55
+ // ── Types ─────────────────────────────────────────────────────────
56
+
57
+ export interface GradientModel {
58
+ readonly colors: string[]
59
+ readonly easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'
60
+ readonly stops: GradientStop[]
61
+ readonly rangeStart: number
62
+ readonly rangeEnd: number
63
+ readonly charCount: number
64
+ readonly diagnostics: GradientDiagnostics
65
+ }
66
+
67
+ export interface GradientStop {
68
+ /** Hex colour at this stop */
69
+ readonly color: string
70
+ /** Normalised position 0-1 */
71
+ readonly position: number
72
+ }
73
+
74
+ export interface GradientDiagnostics {
75
+ readonly uniformSpacing: boolean
76
+ readonly monotonic: boolean
77
+ readonly plateauCount: number
78
+ readonly maxPerceptualError: number
79
+ readonly stopCount: number
80
+ readonly featureScores: Readonly<Record<string, number>>
81
+ }
82
+
83
+ // ── Main Analyzer ─────────────────────────────────────────────────
84
+
85
+ export class GradientAnalyzer implements AnalyzerPass {
86
+ readonly id = 'gradient-analyzer'
87
+
88
+ run(tree: GreenNode, _context: PipelineContext): Contribution[] {
89
+ const contributions: Contribution[] = []
90
+ this.findGradients(tree, contributions)
91
+ return contributions
92
+ }
93
+
94
+ // ── Sequence Detection ──────────────────────────────────────────
95
+
96
+ private findGradients(node: GreenNode, sink: Contribution[], nodeStart: number = 0): void {
97
+ // Green nodes carry widths, not positions, so a walk that reports source
98
+ // ranges accumulates them on the way down.
99
+ const offsets = childOffsets(node, nodeStart)
100
+ if (node.children.length > 0) {
101
+ const children = node.children as GreenNode[]
102
+ const sequences = extractSequences(children, 'color', extractHex)
103
+
104
+ for (const seq of sequences) {
105
+ const colors = seq.values as string[]
106
+ if (colors.length < MIN_SEQUENCE_LENGTH) continue
107
+
108
+ // Sum text length in the sequence
109
+ let textLen = 0
110
+ for (let i = seq.startIdx; i < seq.endIdx; i++) {
111
+ for (const child of children[i].children as GreenNode[]) {
112
+ if (child.kind === 'text') textLen += child.text.length
113
+ }
114
+ }
115
+
116
+ const hasBreaks = checkFormattingBreaks(children, seq, 'color')
117
+ const { stops, easing } = this.detectStops(colors)
118
+ const diag = this.buildDiagnostics(colors, stops)
119
+ const { score: rawScore, featureScores } = this.calculateRawScore(diag, hasBreaks)
120
+ const confidence = sigmoid(rawScore, SIGMOID_STEEPNESS)
121
+
122
+ sink.push({
123
+ kind: ContributionKind.Semantic,
124
+ label: 'Gradient',
125
+ confidence,
126
+ range: {
127
+ start: offsets[seq.startIdx],
128
+ end: offsets[seq.endIdx],
129
+ },
130
+ metadata: {
131
+ model: { colors, easing, stops, charCount: textLen },
132
+ diagnostics: { ...diag, featureScores },
133
+ },
134
+ })
135
+ }
136
+ }
137
+
138
+ const kids = node.children as GreenNode[]
139
+ for (let i = 0; i < kids.length; i++) {
140
+ this.findGradients(kids[i], sink, offsets[i])
141
+ }
142
+ }
143
+
144
+ // ── Change-point Detection ──────────────────────────────────────
145
+
146
+ /**
147
+ * Detect gradient stops from a sequence of hex colours, handling plateaus.
148
+ *
149
+ * Algorithm: Scan for "significant changes" in perceptual distance.
150
+ * Wherever the cumulative perceptual distance from the last stop exceeds a
151
+ * threshold, a new stop is recorded. This naturally skips over plateaus.
152
+ */
153
+ private detectStops(colors: string[]): { stops: GradientStop[]; easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' } {
154
+ const n = colors.length
155
+ const stops: GradientStop[] = [{ color: colors[0], position: 0 }]
156
+
157
+ const totalDist = perceptualDistance(colors[0], colors[n - 1])
158
+ const minStep = totalDist * 0.08 // At least 8% of total range
159
+ let accumulated = 0
160
+
161
+ for (let i = 1; i < n; i++) {
162
+ const dist = perceptualDistance(colors[i - 1], colors[i])
163
+ accumulated += dist
164
+ if (accumulated >= minStep && i < n - 1) {
165
+ stops.push({ color: colors[i], position: i / (n - 1) })
166
+ accumulated = 0
167
+ }
168
+ }
169
+
170
+ // Always include the last colour
171
+ if (stops[stops.length - 1].position < 1) {
172
+ stops.push({ color: colors[n - 1], position: 1 })
173
+ }
174
+
175
+ // Infer easing from change in perceptual distance across segments
176
+ let easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' = 'linear'
177
+ if (stops.length >= 3) {
178
+ const segDists: number[] = []
179
+ for (let i = 1; i < stops.length; i++) {
180
+ const segLen = stops[i].position - stops[i - 1].position
181
+ const segDist = perceptualDistance(stops[i - 1].color, stops[i].color)
182
+ segDists.push(segLen > 0 ? segDist / segLen : 0)
183
+ }
184
+
185
+ const earlyAvg = segDists.slice(0, Math.ceil(segDists.length / 3)).reduce((a, b) => a + b, 0) / Math.max(1, Math.ceil(segDists.length / 3))
186
+ const lateAvg = segDists.slice(-Math.ceil(segDists.length / 3)).reduce((a, b) => a + b, 0) / Math.max(1, Math.ceil(segDists.length / 3))
187
+
188
+ if (earlyAvg > lateAvg * 1.5) easing = 'easeIn'
189
+ else if (lateAvg > earlyAvg * 1.5) easing = 'easeOut'
190
+ else if (segDists.length >= 3) {
191
+ const midStart = Math.floor(segDists.length / 3)
192
+ const midEnd = Math.floor(2 * segDists.length / 3)
193
+ const midAvg = segDists.slice(midStart, midEnd).reduce((a, b) => a + b, 0) / (midEnd - midStart)
194
+ const edgeAvg = [...segDists.slice(0, midStart), ...segDists.slice(midEnd)].reduce((a, b) => a + b, 0) / Math.max(1, segDists.length - (midEnd - midStart))
195
+ if (midAvg > edgeAvg * 1.3) easing = 'easeInOut'
196
+ }
197
+ }
198
+
199
+ return { stops, easing }
200
+ }
201
+
202
+ // ── Diagnostics ─────────────────────────────────────────────────
203
+
204
+ private buildDiagnostics(
205
+ colors: string[],
206
+ stops: GradientStop[],
207
+ ): Omit<GradientDiagnostics, 'featureScores'> {
208
+ const n = colors.length
209
+ const oklabArray = colors.map(c => hexToOklab(c))
210
+
211
+ // 1. Uniform perceptual spacing
212
+ const pDiffs: number[] = []
213
+ for (let i = 1; i < n; i++) {
214
+ pDiffs.push(perceptualDistance(colors[i - 1], colors[i]))
215
+ }
216
+ const avgDiff = pDiffs.reduce((a, b) => a + b, 0) / pDiffs.length
217
+ const diffVariance = pDiffs.reduce((sum, d) => sum + (d - avgDiff) ** 2, 0) / pDiffs.length
218
+ const uniformSpacing = diffVariance < 0.001 // Tiny variance in perceptual space
219
+
220
+ // 2. Check monotonic: does each OKLab dimension change in one direction?
221
+ const first = oklabArray[0]
222
+ const last = oklabArray[n - 1]
223
+ const lDir = Math.sign(last[0] - first[0])
224
+ const aDir = Math.sign(last[1] - first[1])
225
+ const bDir = Math.sign(last[2] - first[2])
226
+
227
+ let violations = 0
228
+ for (let i = 1; i < n; i++) {
229
+ const curr = oklabArray[i]
230
+ const prev = oklabArray[i - 1]
231
+ if (lDir !== 0 && Math.sign(curr[0] - prev[0]) !== lDir && Math.abs(curr[0] - prev[0]) > 0.005) violations++
232
+ if (aDir !== 0 && Math.sign(curr[1] - prev[1]) !== aDir && Math.abs(curr[1] - prev[1]) > 0.005) violations++
233
+ if (bDir !== 0 && Math.sign(curr[2] - prev[2]) !== bDir && Math.abs(curr[2] - prev[2]) > 0.005) violations++
234
+ }
235
+ const monotonic = violations < n * 0.2
236
+
237
+ // 3. Count plateaus (consecutive perceptually identical colours)
238
+ let plateauCount = 0
239
+ for (let i = 1; i < n; i++) {
240
+ if (perceptualDistance(colors[i - 1], colors[i]) < 0.01) plateauCount++
241
+ }
242
+
243
+ // 4. Perceptual error vs ideal OKLab interpolation
244
+ let maxPerceptualError = 0
245
+ if (n >= 3 && stops.length >= 2) {
246
+ for (let i = 0; i < n; i++) {
247
+ const t = n > 1 ? i / (n - 1) : 0
248
+ const ideal = mixHexOklab(stops[0].color, stops[stops.length - 1].color, t)
249
+ const error = perceptualDistance(ideal, colors[i])
250
+ maxPerceptualError = Math.max(maxPerceptualError, error)
251
+ }
252
+ }
253
+
254
+ return { uniformSpacing, monotonic, plateauCount, maxPerceptualError, stopCount: stops.length }
255
+ }
256
+
257
+ // ── Confidence Scoring ──────────────────────────────────────────
258
+
259
+ private calculateRawScore(
260
+ diag: Omit<GradientDiagnostics, 'featureScores'>,
261
+ hasBreaks: boolean,
262
+ ): { score: number; featureScores: Record<string, number> } {
263
+ let score = 0
264
+
265
+ if (diag.uniformSpacing) score += WEIGHTS.uniformPerceptualSpacing
266
+ else score -= WEIGHTS.uniformPerceptualSpacing * 0.5
267
+
268
+ score += WEIGHTS.contiguousWrappers
269
+
270
+ if (!hasBreaks) score += WEIGHTS.noFormattingBreaks
271
+
272
+ if (diag.monotonic) score += WEIGHTS.monotonicProgression
273
+ else score -= WEIGHTS.monotonicProgression * 0.3
274
+
275
+ if (diag.maxPerceptualError < 0.02) score += WEIGHTS.lowPerceptualError
276
+ else if (diag.maxPerceptualError < 0.05) score += WEIGHTS.lowPerceptualError * 0.5
277
+ else {
278
+ const penalty = WEIGHTS.lowPerceptualError * Math.min(diag.maxPerceptualError * 20, 5)
279
+ score -= penalty
280
+ }
281
+
282
+ const featureScores = {
283
+ uniformPerceptualSpacing: diag.uniformSpacing ? WEIGHTS.uniformPerceptualSpacing : -WEIGHTS.uniformPerceptualSpacing * 0.5,
284
+ contiguousWrappers: WEIGHTS.contiguousWrappers,
285
+ noFormattingBreaks: hasBreaks ? -WEIGHTS.noFormattingBreaks : WEIGHTS.noFormattingBreaks,
286
+ monotonicProgression: diag.monotonic ? WEIGHTS.monotonicProgression : -WEIGHTS.monotonicProgression * 0.3,
287
+ lowPerceptualError: diag.maxPerceptualError < 0.05 ? WEIGHTS.lowPerceptualError : -WEIGHTS.lowPerceptualError * 0.5,
288
+ }
289
+
290
+ return { score, featureScores }
291
+ }
292
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Quasar Analysis Framework — Mergeable Color Analyzer
3
+ *
4
+ * Detects sequences of consecutive [color=#HEX] wrappers where ALL
5
+ * adjacent colours are identical. These are optimisation opportunities:
6
+ * the user could merge them into a single `[color]` tag.
7
+ *
8
+ * Example:
9
+ * [color=#FF0000]H[/color][color=#FF0000]e[/color] → mergeable
10
+ * [color=#FF0000]H[/color][color=#EE1100]e[/color] → NOT mergeable
11
+ *
12
+ * @see OptimizationContribution
13
+ */
14
+
15
+ import type { AnalyzerPass } from '../../Contracts/Pass'
16
+ import type { PipelineContext } from '../../Contracts/PipelineContext'
17
+ import type { Contribution } from '../../Contracts/Contribution'
18
+ import { ContributionKind } from '../../Contracts/Contribution'
19
+ import type { GreenNode } from '../../../Syntax/GreenNode'
20
+ import { childOffsets } from '../../../Syntax/GreenNode'
21
+ import { extractHex } from '../../Utils/color-utils'
22
+
23
+ export class MergeableColorAnalyzer implements AnalyzerPass {
24
+ readonly id = 'mergeable-color'
25
+
26
+ run(tree: GreenNode, _context: PipelineContext): Contribution[] {
27
+ const contributions: Contribution[] = []
28
+
29
+ // Walk all children of each node to find sequences of colour wrappers
30
+ this.findMergeableColorSequences(tree, contributions)
31
+
32
+ return contributions
33
+ }
34
+
35
+ // ── Private ─────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Walk the tree and find consecutive color nodes with identical colours.
39
+ */
40
+ private findMergeableColorSequences(node: GreenNode, sink: Contribution[], nodeStart: number = 0): void {
41
+ // Green nodes carry widths, not positions, so a walk that reports source
42
+ // ranges accumulates them on the way down.
43
+ const offsets = childOffsets(node, nodeStart)
44
+ // Only look at container nodes that might have colour children
45
+ if (node.children.length > 1) {
46
+ const children = node.children as GreenNode[]
47
+ let seqStart = -1
48
+ let seqHex: string | null = null
49
+
50
+ for (let i = 0; i < children.length; i++) {
51
+ const hex = extractHex(children[i])
52
+
53
+ if (hex !== null) {
54
+ if (seqStart === -1) {
55
+ // Start a new sequence
56
+ seqStart = i
57
+ seqHex = hex
58
+ } else if (hex !== seqHex) {
59
+ // Color changed — check if previous sequence was mergeable
60
+ if (i - seqStart > 1) {
61
+ this.emitMergeable(children, seqStart, i, seqHex!, sink, offsets)
62
+ }
63
+ seqStart = i
64
+ seqHex = hex
65
+ }
66
+ // If hex === seqHex, sequence continues — do nothing
67
+ } else {
68
+ // Non-color node — end any active sequence
69
+ if (seqStart !== -1 && seqStart < i - 1) {
70
+ this.emitMergeable(children, seqStart, i, seqHex!, sink, offsets)
71
+ }
72
+ seqStart = -1
73
+ seqHex = null
74
+ }
75
+ }
76
+
77
+ // Check if the last sequence is mergeable
78
+ if (seqStart !== -1 && seqStart < children.length - 1) {
79
+ this.emitMergeable(children, seqStart, children.length, seqHex!, sink, offsets)
80
+ }
81
+ }
82
+
83
+ // Recurse into children (colour nodes' children are text, so only recurse non-color nodes)
84
+ const kids = node.children as GreenNode[]
85
+ for (let i = 0; i < kids.length; i++) {
86
+ if (kids[i].kind !== 'color') {
87
+ this.findMergeableColorSequences(kids[i], sink, offsets[i])
88
+ }
89
+ }
90
+ }
91
+
92
+ private emitMergeable(
93
+ children: GreenNode[],
94
+ start: number,
95
+ end: number,
96
+ hex: string,
97
+ sink: Contribution[],
98
+ offsets: number[],
99
+ ): void {
100
+ const count = end - start
101
+ sink.push({
102
+ kind: ContributionKind.Optimization,
103
+ label: 'Merge Colors',
104
+ description: `${count} consecutive [color=${hex}] tags can be merged into one`,
105
+ estimatedImprovement: `-${(count - 1) * 100}% color tags`,
106
+ range: {
107
+ start: offsets[start],
108
+ end: offsets[end],
109
+ },
110
+ })
111
+ }
112
+ }