@miliastry/quasar 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/dist/Visuals/lyne.css +117 -6
  2. package/dist/Visuals/osu.css +36 -5
  3. package/dist/index.js +9664 -3282
  4. package/dist/index.mjs +9535 -3283
  5. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  6. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  7. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  8. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  9. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  10. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  11. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  12. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  13. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  14. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  15. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  20. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  21. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  22. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  23. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  24. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  25. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  26. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  27. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  28. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  29. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  30. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  31. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  32. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  33. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  34. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  35. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  36. package/dist/types/Analysis/index.d.ts +40 -0
  37. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +92 -0
  38. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  39. package/dist/types/BBCode/Parser.d.ts +78 -0
  40. package/dist/types/BBCode/index.d.ts +4 -0
  41. package/dist/types/Collab/positions.d.ts +55 -0
  42. package/dist/types/Commands/Command.d.ts +37 -0
  43. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  44. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  45. package/dist/types/Commands/InsertText.d.ts +8 -0
  46. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  47. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  48. package/dist/types/Commands/index.d.ts +6 -0
  49. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  50. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  51. package/dist/types/Diff/index.d.ts +2 -0
  52. package/dist/types/Edits/EditPlan.d.ts +144 -0
  53. package/dist/types/Edits/Optimizer.d.ts +78 -0
  54. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  55. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  56. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  57. package/dist/types/Edits/Rules/index.d.ts +9 -0
  58. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  59. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  60. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  61. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  62. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  63. package/dist/types/Edits/applyEdits.d.ts +36 -0
  64. package/dist/types/Edits/index.d.ts +6 -0
  65. package/dist/types/Events/EventBus.d.ts +103 -0
  66. package/dist/types/Events/index.d.ts +2 -0
  67. package/dist/types/Formatter/Formatter.d.ts +32 -0
  68. package/dist/types/Formatter/index.d.ts +2 -0
  69. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  70. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  71. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  72. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  73. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  74. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  75. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  76. package/dist/types/Incremental/index.d.ts +4 -0
  77. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  78. package/dist/types/Lexer/Lexer.d.ts +54 -0
  79. package/dist/types/Lexer/index.d.ts +4 -0
  80. package/dist/types/Linter/Linter.d.ts +58 -0
  81. package/dist/types/Linter/index.d.ts +2 -0
  82. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  83. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  84. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  85. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  86. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  87. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  88. package/dist/types/Model/DocumentModel.d.ts +318 -0
  89. package/dist/types/Model/NodeFactory.d.ts +29 -0
  90. package/dist/types/Model/TagRegistry.d.ts +165 -0
  91. package/dist/types/Model/index.d.ts +5 -0
  92. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  93. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  94. package/dist/types/Plugins/index.d.ts +3 -0
  95. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  96. package/dist/types/Queries/index.d.ts +1 -0
  97. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  98. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  99. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  100. package/dist/types/RenderPipeline/index.d.ts +4 -0
  101. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  102. package/dist/types/Repair/index.d.ts +1 -0
  103. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  104. package/dist/types/Semantic/index.d.ts +2 -0
  105. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  106. package/dist/types/Symbols/index.d.ts +1 -0
  107. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  108. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  109. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  110. package/dist/types/Syntax/RedNode.d.ts +280 -0
  111. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  112. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  113. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  114. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  116. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  117. package/dist/types/Syntax/hash.d.ts +17 -0
  118. package/dist/types/Syntax/index.d.ts +8 -0
  119. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  120. package/dist/types/Syntax/partition.d.ts +67 -0
  121. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  122. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  123. package/dist/types/Tokens/index.d.ts +1 -0
  124. package/dist/types/Transactions/Transaction.d.ts +36 -0
  125. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  126. package/dist/types/Transactions/index.d.ts +3 -0
  127. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  128. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  129. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  130. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  131. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/Transformer.d.ts +17 -0
  133. package/dist/types/Types/core.d.ts +104 -0
  134. package/dist/types/Types/diagnostics.d.ts +85 -0
  135. package/dist/types/Types/index.d.ts +11 -0
  136. package/dist/types/Types/operations.d.ts +109 -0
  137. package/dist/types/Types/queries.d.ts +67 -0
  138. package/dist/types/Types/symbols.d.ts +42 -0
  139. package/dist/types/Types/tokens.d.ts +75 -0
  140. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  141. package/dist/types/Utils/ColorMath.d.ts +92 -0
  142. package/dist/types/Utils/EffectMath.d.ts +643 -0
  143. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  144. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  145. package/dist/types/Utils/color.d.ts +29 -0
  146. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  147. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  148. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  149. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  150. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  151. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  152. package/dist/types/Visitors/DOMMorpher.d.ts +13 -0
  153. package/dist/types/Visitors/HTMLRenderer.d.ts +380 -0
  154. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  155. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  156. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  157. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  158. package/dist/types/Visitors/Visitor.d.ts +35 -0
  159. package/dist/types/Visitors/index.d.ts +9 -0
  160. package/dist/types/Visuals/BoxDrawer.d.ts +49 -0
  161. package/dist/types/Visuals/index.d.ts +38 -0
  162. package/dist/types/index.d.ts +154 -0
  163. package/package.json +14 -10
  164. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  165. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  166. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  167. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  168. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  169. package/src/Analysis/index.ts +8 -2
  170. package/src/BBCode/BBCodeDocumentModel.ts +3 -0
  171. package/src/BBCode/BBCodeToGreenNode.ts +18 -12
  172. package/src/BBCode/Parser.ts +38 -1
  173. package/src/Edits/EditPlan.ts +290 -0
  174. package/src/Edits/Optimizer.ts +185 -0
  175. package/src/Edits/Rules/Rule.ts +145 -0
  176. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  177. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  178. package/src/Edits/Rules/index.ts +28 -0
  179. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  180. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  181. package/src/Edits/Rules/shortenHex.ts +79 -0
  182. package/src/Edits/Rules/tagValue.ts +105 -0
  183. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  184. package/src/Edits/applyEdits.ts +54 -0
  185. package/src/Edits/index.ts +22 -0
  186. package/src/Events/EventBus.ts +18 -0
  187. package/src/HTML/HTMLToGreenNode.ts +185 -37
  188. package/src/Incremental/BracketIndex.ts +249 -0
  189. package/src/Incremental/IncrementalParser.ts +464 -41
  190. package/src/Lexer/BBCodeLexer.ts +25 -3
  191. package/src/Lexer/index.ts +1 -1
  192. package/src/Markdown/MarkdownAST.ts +65 -2
  193. package/src/Markdown/MarkdownLexer.ts +69 -14
  194. package/src/Markdown/MarkdownParser.ts +749 -62
  195. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  196. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  197. package/src/Model/DocumentModel.ts +138 -5
  198. package/src/Model/TagRegistry.ts +129 -134
  199. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  200. package/src/Repair/NestingRepair.ts +116 -0
  201. package/src/Repair/index.ts +1 -0
  202. package/src/Semantic/SemanticAnalyzer.ts +1802 -65
  203. package/src/Syntax/GreenNodePool.ts +12 -12
  204. package/src/Syntax/RedNode.ts +17 -3
  205. package/src/Syntax/RedNodeStore.ts +9 -1
  206. package/src/Syntax/nodeAttr.ts +172 -0
  207. package/src/Tokens/TokenResolver.ts +55 -0
  208. package/src/Tokens/index.ts +1 -0
  209. package/src/Types/core.ts +15 -0
  210. package/src/Utils/ColorMath.ts +276 -55
  211. package/src/Utils/EffectMath.ts +2462 -0
  212. package/src/Utils/TagVocabulary.ts +141 -0
  213. package/src/Utils/dom-to-svg.ts +28 -12
  214. package/src/Visitors/BBBlocksExporter.ts +15 -1
  215. package/src/Visitors/BBCodeExporter.ts +319 -32
  216. package/src/Visitors/BlockPatcher.ts +87 -11
  217. package/src/Visitors/DOMMorpher.ts +15 -5
  218. package/src/Visitors/HTMLRenderer.ts +374 -151
  219. package/src/Visitors/MarkdownExporter.ts +98 -41
  220. package/src/Visitors/TiptapExporter.ts +1 -0
  221. package/src/Visuals/index.ts +7 -4
  222. package/src/Visuals/lyne.css +117 -6
  223. package/src/Visuals/osu.css +36 -5
  224. package/src/index.ts +172 -5
  225. package/dist/index.d.mts +0 -4612
  226. package/dist/index.d.ts +0 -4612
  227. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  228. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  229. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  230. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  231. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  232. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  233. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  234. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  235. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  236. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  237. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  238. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  239. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  240. package/src/Tests/BlockPatcher.test.ts +0 -437
  241. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  242. package/src/Tests/BoxDrawer.test.ts +0 -217
  243. package/src/Tests/BoxRichTitle.test.ts +0 -105
  244. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  245. package/src/Tests/Chars500kEdits.test.ts +0 -321
  246. package/src/Tests/CollabPositions.test.ts +0 -146
  247. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  248. package/src/Tests/DOMMorpher.test.ts +0 -142
  249. package/src/Tests/DomPatchPerf.test.ts +0 -60
  250. package/src/Tests/EffectSegments.snapshot.json +0 -616
  251. package/src/Tests/EffectSegments.test.ts +0 -68
  252. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  253. package/src/Tests/Fuzzer.test.ts +0 -166
  254. package/src/Tests/GreenNodePool.test.ts +0 -153
  255. package/src/Tests/Lexer.test.ts +0 -238
  256. package/src/Tests/LyneMode.test.ts +0 -291
  257. package/src/Tests/ModelCoherence.test.ts +0 -180
  258. package/src/Tests/Partition.test.ts +0 -238
  259. package/src/Tests/PluginTags.test.ts +0 -150
  260. package/src/Tests/ProblematicSection.test.ts +0 -46
  261. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  262. package/src/Tests/RedReuse.test.ts +0 -134
  263. package/src/Tests/ReproDelete20k.test.ts +0 -62
  264. package/src/Tests/SemanticValidators.test.ts +0 -136
  265. package/src/Tests/StableNodeIds.test.ts +0 -210
  266. package/src/Tests/StudioColorBloat.test.ts +0 -25
  267. package/src/Tests/StudioDebugText.test.ts +0 -27
  268. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  269. package/src/Tests/StudioValidText.test.ts +0 -25
  270. package/src/Tests/UrlImgBug.test.ts +0 -23
  271. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  272. package/src/Tests/referenceDocument.ts +0 -119
  273. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Quasar Analysis Framework — Palette Remap Decision
3
+ *
4
+ * Turns an AnalysisReport into a plan for restyling a document with a
5
+ * different palette: recolour its colour tags, swap its decorative glyphs,
6
+ * replace its divider lines.
7
+ *
8
+ * ## The plan is applied by range, not by rebuilding the tree
9
+ *
10
+ * Every other DecisionPass in this framework feeds a TransformPass that
11
+ * returns a new Green Tree, which is then serialized. That is the wrong shape
12
+ * for restyling a document someone already wrote: `BBCodeExporter` rebuilds
13
+ * BBCode from node kinds and metadata rather than replaying source bytes, so
14
+ * re-exporting a whole document also rewrites the parts nobody asked to
15
+ * change — `[B]` becomes `[b]`, `[color = #F00]` loses its spacing, CRLF
16
+ * becomes LF.
17
+ *
18
+ * So the actions here carry a `range` and a literal `replacement` string, and
19
+ * are meant to be applied directly to the source text. Everything outside
20
+ * those ranges is never re-serialized and therefore cannot drift. The Decision
21
+ * stage is still the right home — it consumes a report and describes intended
22
+ * mutations without touching the tree — but no TransformPass consumes it.
23
+ *
24
+ * Apply the actions **last range first**. They are emitted in source order,
25
+ * and rewriting front to back invalidates every offset that follows.
26
+ *
27
+ * ## What each kind of thing becomes
28
+ *
29
+ * **Colours inside a gradient** keep the gradient's shape. Each member tag is
30
+ * recoloured by sampling the new palette at the member's own position along
31
+ * the ramp, passed through the easing the original gradient was detected with.
32
+ * The result reads as the same gradient wearing a different palette, rather
33
+ * than as a new gradient.
34
+ *
35
+ * **Standalone colours** map to their nearest palette entry by perceptual
36
+ * distance in OKLab, so a pink stays the palette's pink rather than becoming
37
+ * whichever colour happens to be first.
38
+ *
39
+ * **Symbols** are substituted glyph for glyph, and **separators** wholesale.
40
+ * Both substitutions are stable across the document: a given source glyph
41
+ * always maps to the same palette glyph, so a document that used `✧` as its
42
+ * motif comes out using one motif rather than a scatter.
43
+ *
44
+ * @see ColorUsageAnalyzer — supplies the per-tag offsets this pass edits
45
+ * @see GradientAnalyzer — supplies the ramp shape those tags belong to
46
+ * @see SymbolAnalyzer — supplies the glyph runs
47
+ */
48
+
49
+ import type { DecisionPass, TransformationPlan, TransformAction } from '../../Contracts/Pass'
50
+ import type { AnalysisReport } from '../../Contracts/AnalysisReport'
51
+ import type { PipelineContext } from '../../Contracts/PipelineContext'
52
+ import type { SemanticContribution } from '../../Contracts/Contribution'
53
+ import { ContributionKind } from '../../Contracts/Contribution'
54
+ import { ease, mixHexOklab, perceptualDistance } from '../../../Utils/ColorMath'
55
+ import type { ColorUsageModel } from '../Analysis/ColorUsageAnalyzer'
56
+ import type { GradientModel } from '../Analysis/GradientAnalyzer'
57
+ import type { SymbolRunModel } from '../Analysis/SymbolAnalyzer'
58
+
59
+ // ── Configuration ─────────────────────────────────────────────────
60
+
61
+ /**
62
+ * The palette to restyle towards.
63
+ *
64
+ * Structural, not imported: Quasar has no dependency on the application's
65
+ * theme catalogue, and any object with these three lists satisfies it.
66
+ */
67
+ export interface Palette {
68
+ /** Hex colours, ordered as a ramp from first to last. */
69
+ readonly colors: readonly string[]
70
+ /** Decorative glyphs, in preference order. */
71
+ readonly symbols: readonly string[]
72
+ /** Whole divider lines. */
73
+ readonly separators: readonly string[]
74
+ }
75
+
76
+ export interface PaletteRemapOptions {
77
+ /**
78
+ * Below this confidence an action is still emitted, but marked
79
+ * `recommended: false`.
80
+ *
81
+ * Nothing is dropped. A caller that hides low-confidence findings can never
82
+ * offer them, and the glyphs that land down here — `≈` in a sum, `→` in a
83
+ * sentence — are exactly the ones a person should get to look at before
84
+ * they are rewritten. Filtering is the caller's decision; this pass only
85
+ * says which side of the line each action falls on.
86
+ */
87
+ readonly minConfidence?: number
88
+ }
89
+
90
+ const DEFAULT_MIN_CONFIDENCE = 0.7
91
+
92
+ /**
93
+ * One spelling for every hex this pass emits.
94
+ *
95
+ * `mixHexOklab` builds its result lowercase while palette entries are written
96
+ * however the caller wrote them, so a sampled ramp would otherwise alternate
97
+ * cases — `#ffb7c5` next to `#FFE4E1` — depending on whether a stop landed on
98
+ * a palette entry or between two. `extractHex` already uppercases what it
99
+ * reads, so matching it also keeps the "already this colour" check honest.
100
+ */
101
+ function canonicalHex(hex: string): string {
102
+ return hex.toUpperCase()
103
+ }
104
+
105
+ // ── Action payloads ───────────────────────────────────────────────
106
+
107
+ /** Every action this pass emits shares one shape. */
108
+ export interface RemapAction extends TransformAction {
109
+ readonly kind: 'recolor' | 'resymbol' | 'reseparator'
110
+ readonly payload: {
111
+ /** Source span to overwrite. */
112
+ readonly range: { readonly start: number; readonly end: number }
113
+ /** Literal text to write in its place. */
114
+ readonly replacement: string
115
+ /** What is there now, for previewing the change. */
116
+ readonly from: string
117
+ /** What it becomes. */
118
+ readonly to: string
119
+ readonly confidence: number
120
+ readonly recommended: boolean
121
+ /** Only on `recolor`: whether the tag belonged to a detected gradient. */
122
+ readonly gradient?: boolean
123
+ }
124
+ }
125
+
126
+ // ── Decision ──────────────────────────────────────────────────────
127
+
128
+ export class PaletteRemapDecision implements DecisionPass {
129
+ readonly id = 'palette-remap'
130
+
131
+ private readonly palette: Palette
132
+ private readonly minConfidence: number
133
+
134
+ /**
135
+ * Substitution tables, rebuilt per `run` so the pass stays reusable and
136
+ * every run over the same report produces the same plan.
137
+ */
138
+ private symbolMap = new Map<string, string>()
139
+ private separatorMap = new Map<string, string>()
140
+
141
+ constructor(palette: Palette, options: PaletteRemapOptions = {}) {
142
+ this.palette = palette
143
+ this.minConfidence = options.minConfidence ?? DEFAULT_MIN_CONFIDENCE
144
+ }
145
+
146
+ run(report: AnalysisReport, _context: PipelineContext): TransformationPlan {
147
+ this.symbolMap = new Map()
148
+ this.separatorMap = new Map()
149
+
150
+ const semantic = report.contributions.filter(
151
+ (c): c is SemanticContribution => c.kind === ContributionKind.Semantic,
152
+ )
153
+
154
+ const byLabel = (label: string) =>
155
+ semantic
156
+ .filter(c => c.label === label)
157
+ .sort((a, b) => a.range.start - b.range.start)
158
+
159
+ const actions: RemapAction[] = [
160
+ ...this.planColors(byLabel('Color'), byLabel('Gradient')),
161
+ ...this.planSymbols(byLabel('Symbol')),
162
+ ...this.planSeparators(byLabel('Separator')),
163
+ ]
164
+
165
+ // Source order, so a caller can apply them back to front by reversing.
166
+ actions.sort((a, b) => a.payload.range.start - b.payload.range.start)
167
+
168
+ return { actions: Object.freeze(actions) }
169
+ }
170
+
171
+ // ── Colours ─────────────────────────────────────────────────────
172
+
173
+ private planColors(
174
+ colors: readonly SemanticContribution[],
175
+ gradients: readonly SemanticContribution[],
176
+ ): RemapAction[] {
177
+ if (this.palette.colors.length === 0) return []
178
+
179
+ // Which gradient, if any, each colour tag belongs to. Membership is by
180
+ // containment: GradientAnalyzer reports the span its member tags cover.
181
+ const memberOf = new Map<SemanticContribution, SemanticContribution>()
182
+ const members = new Map<SemanticContribution, SemanticContribution[]>()
183
+
184
+ for (const gradient of gradients) {
185
+ // A doubtful gradient must not drive ramp sampling.
186
+ //
187
+ // GradientAnalyzer reports every colour run it *considers*, scoring the
188
+ // ones that are not ramps down rather than discarding them — a lone
189
+ // `#111111` sitting against a real gradient yields one six-colour
190
+ // candidate at 0.13 confidence. Sampling a palette across that would
191
+ // impose a ramp the document never had, so anything under the threshold
192
+ // falls through and its colours are matched individually instead.
193
+ if (gradient.confidence < this.minConfidence) continue
194
+
195
+ const inside = colors.filter(
196
+ c => c.range.start >= gradient.range.start && c.range.end <= gradient.range.end,
197
+ )
198
+ // A gradient needs at least two members before "position along it" means
199
+ // anything; a degenerate one is treated as standalone colours.
200
+ if (inside.length < 2) continue
201
+
202
+ members.set(gradient, inside)
203
+ for (const color of inside) memberOf.set(color, gradient)
204
+ }
205
+
206
+ const actions: RemapAction[] = []
207
+
208
+ for (const color of colors) {
209
+ const model = color.metadata.model as ColorUsageModel
210
+ const gradient = memberOf.get(color)
211
+
212
+ const to = gradient
213
+ ? this.gradientColorFor(color, gradient, members.get(gradient)!)
214
+ : this.nearestPaletteColor(model.hex)
215
+
216
+ // A tag already wearing its target colour is not a change. Both sides
217
+ // are canonical here: `extractHex` uppercases, and so does `to`.
218
+ if (to === model.hex) continue
219
+
220
+ actions.push({
221
+ kind: 'recolor',
222
+ payload: {
223
+ range: { start: model.openStart, end: model.openEnd },
224
+ replacement: `[color=${to}]`,
225
+ from: model.hex,
226
+ to,
227
+ confidence: gradient ? gradient.confidence : color.confidence,
228
+ recommended: (gradient ? gradient.confidence : color.confidence) >= this.minConfidence,
229
+ gradient: gradient !== undefined,
230
+ },
231
+ })
232
+ }
233
+
234
+ return actions
235
+ }
236
+
237
+ /**
238
+ * Where this tag sits along its gradient, resampled onto the new palette.
239
+ *
240
+ * Position comes from the tag's ordinal among the gradient's members rather
241
+ * than from its offset, because the members are evenly spaced *as tags* — a
242
+ * gradient over `Hello` has five of them regardless of how wide each tag's
243
+ * text is.
244
+ */
245
+ private gradientColorFor(
246
+ color: SemanticContribution,
247
+ gradient: SemanticContribution,
248
+ members: readonly SemanticContribution[],
249
+ ): string {
250
+ const model = gradient.metadata.model as GradientModel
251
+ const index = members.indexOf(color)
252
+ const t = members.length > 1 ? index / (members.length - 1) : 0
253
+
254
+ return this.samplePalette(ease(t, model.easing))
255
+ }
256
+
257
+ /**
258
+ * Sample the palette as a continuous ramp, interpolating in OKLab.
259
+ *
260
+ * `ColorMath.mixMultiple` does the same walk in RGB, which darkens and
261
+ * desaturates through the middle of a blend — the reason the gradient
262
+ * analyzers work in OKLab in the first place. Sampling rather than mapping
263
+ * palette entries one-to-one is what preserves the original stop count: a
264
+ * three-stop gradient stays three stops when restyled with a five-colour
265
+ * palette, instead of gaining two.
266
+ */
267
+ private samplePalette(t: number): string {
268
+ const colors = this.palette.colors
269
+ if (colors.length === 1) return canonicalHex(colors[0])
270
+
271
+ const clamped = Math.max(0, Math.min(1, t))
272
+ const scaled = clamped * (colors.length - 1)
273
+ const index = Math.floor(scaled)
274
+
275
+ if (index >= colors.length - 1) return canonicalHex(colors[colors.length - 1])
276
+
277
+ return canonicalHex(mixHexOklab(colors[index], colors[index + 1], scaled - index))
278
+ }
279
+
280
+ /**
281
+ * The palette entry that looks most like the colour already there.
282
+ *
283
+ * Perceptual distance rather than index order: a document's pink should come
284
+ * back as the palette's pink, not as whichever colour the palette happens to
285
+ * list first.
286
+ */
287
+ private nearestPaletteColor(hex: string): string {
288
+ let best = this.palette.colors[0]
289
+ let bestDistance = Infinity
290
+
291
+ for (const candidate of this.palette.colors) {
292
+ const distance = perceptualDistance(hex, candidate)
293
+ if (distance < bestDistance) {
294
+ bestDistance = distance
295
+ best = candidate
296
+ }
297
+ }
298
+
299
+ return canonicalHex(best)
300
+ }
301
+
302
+ // ── Glyphs ──────────────────────────────────────────────────────
303
+
304
+ private planSymbols(symbols: readonly SemanticContribution[]): RemapAction[] {
305
+ if (this.palette.symbols.length === 0) return []
306
+
307
+ const actions: RemapAction[] = []
308
+
309
+ this.reserveIdentities(
310
+ this.symbolMap,
311
+ symbols.flatMap(run => (run.metadata.model as SymbolRunModel).glyphs.map(g => g.char)),
312
+ this.palette.symbols,
313
+ )
314
+
315
+ for (const run of symbols) {
316
+ const model = run.metadata.model as SymbolRunModel
317
+ const replacement = model.glyphs
318
+ .map(glyph => this.substitute(this.symbolMap, glyph.char, this.palette.symbols))
319
+ .join('')
320
+
321
+ if (replacement === model.text) continue
322
+
323
+ actions.push({
324
+ kind: 'resymbol',
325
+ payload: {
326
+ range: run.range,
327
+ replacement,
328
+ from: model.text,
329
+ to: replacement,
330
+ confidence: run.confidence,
331
+ recommended: run.confidence >= this.minConfidence,
332
+ },
333
+ })
334
+ }
335
+
336
+ return actions
337
+ }
338
+
339
+ private planSeparators(separators: readonly SemanticContribution[]): RemapAction[] {
340
+ if (this.palette.separators.length === 0) return []
341
+
342
+ const actions: RemapAction[] = []
343
+
344
+ this.reserveIdentities(
345
+ this.separatorMap,
346
+ separators.map(run => (run.metadata.model as SymbolRunModel).text),
347
+ this.palette.separators,
348
+ )
349
+
350
+ for (const run of separators) {
351
+ const model = run.metadata.model as SymbolRunModel
352
+ const replacement = this.substitute(this.separatorMap, model.text, this.palette.separators)
353
+
354
+ if (replacement === model.text) continue
355
+
356
+ actions.push({
357
+ kind: 'reseparator',
358
+ payload: {
359
+ range: run.range,
360
+ replacement,
361
+ from: model.text,
362
+ to: replacement,
363
+ confidence: run.confidence,
364
+ recommended: run.confidence >= this.minConfidence,
365
+ },
366
+ })
367
+ }
368
+
369
+ return actions
370
+ }
371
+
372
+ /**
373
+ * Assign a replacement for `source`, remembering it.
374
+ *
375
+ * Distinct sources are handed palette entries in first-seen order.
376
+ * Recording the choice is what keeps the document coherent: every later `✧`
377
+ * gets the same replacement as the first one, so a motif stays a motif.
378
+ *
379
+ * Entries already spoken for are skipped — including the identities
380
+ * `reserveIdentities` claimed before this ran — so distinct sources keep
381
+ * distinct replacements for as long as the palette has them. Once it runs
382
+ * out the assignment wraps, and glyphs begin to share.
383
+ */
384
+ private substitute(
385
+ table: Map<string, string>,
386
+ source: string,
387
+ replacements: readonly string[],
388
+ ): string {
389
+ const existing = table.get(source)
390
+ if (existing !== undefined) return existing
391
+
392
+ const taken = new Set(table.values())
393
+ const free = replacements.find(r => !taken.has(r))
394
+ const chosen = free ?? replacements[table.size % replacements.length]
395
+
396
+ table.set(source, chosen)
397
+ return chosen
398
+ }
399
+
400
+ /**
401
+ * Let everything the palette already contains keep its own place, before
402
+ * anything else is handed out.
403
+ *
404
+ * This has to happen up front rather than as each source is reached.
405
+ * Assignment follows document order, so in `✧ then ✿` the unowned `✧` would
406
+ * otherwise claim `✿` first and the real `✿` would arrive to find its own
407
+ * identity taken — displacing it onto a glyph the document never used.
408
+ *
409
+ * Holding identities fixed is what makes restyling idempotent: a second pass
410
+ * with the same theme finds every glyph already in place and changes
411
+ * nothing.
412
+ */
413
+ private reserveIdentities(
414
+ table: Map<string, string>,
415
+ sources: readonly string[],
416
+ replacements: readonly string[],
417
+ ): void {
418
+ for (const source of sources) {
419
+ if (!table.has(source) && replacements.includes(source)) {
420
+ table.set(source, source)
421
+ }
422
+ }
423
+ }
424
+ }
425
+
@@ -35,6 +35,16 @@ export interface PipelineResult {
35
35
  readonly tree: GreenNode
36
36
  /** The aggregated analysis report. */
37
37
  readonly report: AnalysisReport
38
+ /**
39
+ * The plan the last DecisionPass produced, empty when none ran.
40
+ *
41
+ * Usually this is an intermediate the transform stage consumes and the
42
+ * caller never needs. It is surfaced because a plan is not always something
43
+ * to execute against the tree: a pass may describe edits meant to be applied
44
+ * to the source text by range, in which case the plan *is* the output and
45
+ * there is no transform stage to hand it to.
46
+ */
47
+ readonly plan: TransformationPlan
38
48
  }
39
49
 
40
50
  export class Pipeline {
@@ -61,7 +71,10 @@ export class Pipeline {
61
71
  break
62
72
 
63
73
  case 'decision': {
64
- const report = this.buildReport(allContributions, analysisPassCount, start)
74
+ // `elapsedMs` is a duration, not a timestamp: a decision pass that
75
+ // budgets work by time would otherwise read the pipeline's absolute
76
+ // origin (millions of ms into the page's life) and see no budget left.
77
+ const report = this.buildReport(allContributions, analysisPassCount, performance.now() - start)
65
78
  latestPlan = this.runDecision(entry.pass as DecisionPass, report, context)
66
79
  break
67
80
  }
@@ -74,7 +87,7 @@ export class Pipeline {
74
87
 
75
88
  const elapsed = performance.now() - start
76
89
  const report = this.buildReport(allContributions, analysisPassCount, elapsed)
77
- return { tree: currentTree, report }
90
+ return { tree: currentTree, report, plan: latestPlan }
78
91
  }
79
92
 
80
93
  /**
@@ -35,15 +35,21 @@ export { PipelineStage } from './Pipeline/PipelineStage'
35
35
  // ── Analysis Passes ───────────────────────────────────────────────
36
36
  export { CharacterCountAnalyzer } from './Passes/Utility/CharacterCountAnalyzer'
37
37
  export { MergeableColorAnalyzer } from './Passes/Analysis/MergeableColorAnalyzer'
38
- export { GradientAnalyzer } from './Passes/Analysis/GradientAnalyzer'
39
- export type { GradientModel, GradientDiagnostics, GradientStop } from './Passes/Analysis/GradientAnalyzer'
38
+ export { GradientAnalyzer, findCollapsibleGradients, formatGradientTag } from './Passes/Analysis/GradientAnalyzer'
39
+ export type { GradientModel, GradientDiagnostics, GradientStop, CollapsibleGradient } from './Passes/Analysis/GradientAnalyzer'
40
40
  export { RainbowAnalyzer } from './Passes/Analysis/RainbowAnalyzer'
41
41
  export type { RainbowModel, RainbowDiagnostics } from './Passes/Analysis/RainbowAnalyzer'
42
42
  export { WaveAnalyzer } from './Passes/Analysis/WaveAnalyzer'
43
43
  export type { WaveModel, WaveDiagnostics } from './Passes/Analysis/WaveAnalyzer'
44
+ export { SymbolAnalyzer } from './Passes/Analysis/SymbolAnalyzer'
45
+ export type { SymbolGlyph, SymbolRunModel } from './Passes/Analysis/SymbolAnalyzer'
46
+ export { ColorUsageAnalyzer } from './Passes/Analysis/ColorUsageAnalyzer'
47
+ export type { ColorUsageModel } from './Passes/Analysis/ColorUsageAnalyzer'
44
48
 
45
49
  // ── Decision Passes ───────────────────────────────────────────────
46
50
  export { DefaultDecision } from './Passes/Decision/DefaultDecision'
51
+ export { PaletteRemapDecision } from './Passes/Decision/PaletteRemapDecision'
52
+ export type { Palette, PaletteRemapOptions, RemapAction } from './Passes/Decision/PaletteRemapDecision'
47
53
 
48
54
  // ── Transform Passes ──────────────────────────────────────────────
49
55
  export { CollapseGradientTransform } from './Passes/Transform/CollapseGradientTransform'
@@ -87,6 +87,9 @@ export class BBCodeDocumentModel extends DocumentModel {
87
87
  })
88
88
  this._strictMode = options.strictMode ?? false
89
89
  this._dialect = options.dialect ?? options.mode ?? 'miliastry'
90
+ // The analyzer is built by `super()`, before this line could have run,
91
+ // so the dialect it suggests unknown-tag replacements from is set here.
92
+ this.semanticAnalyzer.dialect = this._dialect
90
93
  this._interner = options.interning ? GreenNodePool.create(options.interning) : null
91
94
  if (options.source) {
92
95
  this.rebuild(options.source)
@@ -15,6 +15,7 @@
15
15
  * → DocumentModel
16
16
  */
17
17
 
18
+ import { parseEffectParams } from '../Utils/EffectMath'
18
19
  import { GreenNode, greenNode, greenLeaf } from '../Syntax/GreenNode'
19
20
  import { RedNode } from '../Syntax/RedNode'
20
21
  import { RedNodeStore } from '../Syntax/RedNodeStore'
@@ -73,6 +74,7 @@ const MILIASTRY_TAG_TO_KIND_ENTRIES: Record<string, NodeKind> = {
73
74
  'grow': 'grow',
74
75
  'sinewave': 'sinewave',
75
76
  'rainbow': 'rainbow',
77
+ 'paint': 'paint',
76
78
  'svg': 'svg',
77
79
  'group': 'group',
78
80
  }
@@ -179,6 +181,7 @@ const LYNE_LEGACY_ALIASES: Record<string, NodeKind> = {
179
181
  // Anim legacy aliases
180
182
  'typewriter': 'anim',
181
183
  'wave': 'anim',
184
+ 'pulse': 'anim',
182
185
  'sparkle': 'anim',
183
186
  'glitch': 'anim',
184
187
  'levitate': 'anim',
@@ -233,10 +236,6 @@ export const BBCODE_TAG_NAMES: readonly string[] = Object.freeze(
233
236
  )
234
237
 
235
238
  export function getBBCodeTagNames(dialect: BBCodeDialect = 'miliastry'): readonly string[] {
236
- // Para Lyne, la superficie visible es la CANÓNICA: los legacy aliases se
237
- // siguen parseando (compatibilidad) pero no se listan — así un consumidor
238
- // (autocomplete, docs) solo ofrece las formas canónicas.
239
- if (dialect === 'lyne') return Object.freeze([...Object.keys(LYNE_CANONICAL_TAG_TO_KIND)].sort())
240
239
  const map = DIALECT_MAPS[dialect] || DIALECT_MAPS.miliastry
241
240
  return Object.freeze([...map.keys()].sort())
242
241
  }
@@ -411,7 +410,7 @@ export function extractGreenNodeMetadata(green: GreenNode): Record<string, unkno
411
410
  // `[box=Mi Caja:#FF0055]` separa el color del título; el color se guarda
412
411
  // aparte para que el renderer lo aplique como `--box-accent` y el
413
412
  // exporter lo vuelva a emitir en el round-trip.
414
- const colorMatch = /:#[0-9a-fA-F]{3,8}$/.exec(value)
413
+ const colorMatch = /:(?:#[0-9a-fA-F]{3,8}|\$[a-zA-Z0-9_.-]+)$/.exec(value)
415
414
  const color = colorMatch ? colorMatch[0].slice(1) : undefined
416
415
  const titleValue = colorMatch ? value.slice(0, colorMatch.index) : value
417
416
  return {
@@ -439,9 +438,16 @@ export function extractGreenNodeMetadata(green: GreenNode): Record<string, unkno
439
438
  case 'video': return { videoId: value || firstChildText(green) }
440
439
  case 'audio': return { src: value || firstChildText(green) }
441
440
  case 'gradient':
442
- // Parse "=#ff0000,#00ff00" → { colors: ['#FF0000', '#00FF00'] }
443
- const gradientColors = value.split(',').map(c => c.trim()).filter(c => c.startsWith('#'))
444
- return gradientColors.length > 0 ? { colors: gradientColors } : {}
441
+ case 'rainbow':
442
+ case 'grow':
443
+ case 'sinewave':
444
+ case 'paint':
445
+ // One grammar for every effect tag. `[gradient=#a,#b]` still parses
446
+ // as a bare colour list; `[gradient=#a,#b;easing=easeInOut;axis=line]`
447
+ // adds the modulation Text Studio configures. Before this, everything
448
+ // past the colours was dropped on the way in and re-invented from
449
+ // defaults on the way out, so a saved effect never came back.
450
+ return parseEffectParams(value) as Record<string, unknown>
445
451
  case 'table_th':
446
452
  case 'table_col': {
447
453
  if (!value) return {}
@@ -452,7 +458,7 @@ export function extractGreenNodeMetadata(green: GreenNode): Record<string, unkno
452
458
  res.colspan = parseInt(p, 10)
453
459
  } else if (/^(center|centre|left|right|justify)$/i.test(p)) {
454
460
  res.align = p.toLowerCase() === 'centre' ? 'center' : p.toLowerCase()
455
- } else if (/^#[0-9a-fA-F]{3,8}$/.test(p)) {
461
+ } else if (/^(?:#[0-9a-fA-F]{3,8}|\$[a-zA-Z0-9_.-]+)$/.test(p)) {
456
462
  res.color = p
457
463
  }
458
464
  }
@@ -462,11 +468,11 @@ export function extractGreenNodeMetadata(green: GreenNode): Record<string, unkno
462
468
  case 'wnotice': return value ? { color: value } : {}
463
469
  case 'tables':
464
470
  case 'columns': {
465
- // Sufijo de color `:#hex` (como en box): `[tables=striped:#FF0055]` y
466
- // `[columns=2:#FF0055]`. De ese color se deriva toda la paleta (bordes,
471
+ // Sufijo de color `:#hex` o `:$token`: `[tables=striped:#FF0055]`, `[tables=striped:$accent]`
472
+ // y `[columns=2:#FF0055]`. De ese color se deriva toda la paleta (bordes,
467
473
  // filas, encabezado) vía `--table-accent` / `--columns-accent`.
468
474
  if (!value) return {}
469
- const colorMatch = /:#[0-9a-fA-F]{3,8}$/.exec(value)
475
+ const colorMatch = /:(?:#[0-9a-fA-F]{3,8}|\$[a-zA-Z0-9_.-]+)$/.exec(value)
470
476
  const clean = colorMatch ? value.slice(0, colorMatch.index) : value
471
477
  const color = colorMatch ? colorMatch[0].slice(1) : undefined
472
478
  const base = kind === 'tables' ? { variant: clean } : { columns: clean }
@@ -82,6 +82,21 @@ export function parseTokensToGreen(
82
82
  const normalizeParagraphs = options.normalizeParagraphs ?? true;
83
83
  const extraTags = options.extraTags;
84
84
  const root: GreenNode[] = []
85
+ /**
86
+ * Etiquetas que un cierre mal emparejado cerró por su cuenta y cuyo `[/tag]`
87
+ * todavía está por llegar.
88
+ *
89
+ * Cuando `[/notice]` cierra un `[box]` que quedaba dentro, el `[/box]` que
90
+ * viene después ya no tiene a quién cerrar. osu! lo tira; subir por la pila a
91
+ * buscar otro `[box]` cierra uno que el autor no quería cerrar y saca del
92
+ * contenedor a todo lo que sigue. En `docs/ai/NyuPenyu` eso aplanaba 43 cajas
93
+ * anidadas a 9 y hacía la página un 78% más alta de lo que osu! muestra.
94
+ *
95
+ * La marca se consume al usarla y se borra si la etiqueta se vuelve a abrir,
96
+ * porque entonces el cierre sí es suyo.
97
+ */
98
+ const autoClosed = new Set<string>()
99
+
85
100
  const stack: {
86
101
  /** The literal tag name. Closing matches on THIS, not on `kind`: `[centre]`
87
102
  * and `[center]` share a kind but do not close each other, and that is
@@ -223,6 +238,7 @@ export function parseTokensToGreen(
223
238
  if (stack.length > 0 && stack[stack.length - 1].tag === '*') {
224
239
  addToParent(closeFrame(stack.pop()!, 0))
225
240
  }
241
+ autoClosed.delete(tok.tag)
226
242
  stack.push({
227
243
  tag: tok.tag,
228
244
  kind: openKind,
@@ -252,6 +268,7 @@ export function parseTokensToGreen(
252
268
  }
253
269
 
254
270
  // Push onto the stack — children will be added later.
271
+ autoClosed.delete(tok.tag)
255
272
  stack.push({
256
273
  tag: tok.tag,
257
274
  kind: openKind,
@@ -283,6 +300,18 @@ export function parseTokensToGreen(
283
300
  const errMsg = `Syntax Error: Expected /${expected}, got /${tok.tag}`
284
301
  addToParent(createNode('error', errMsg, [createLeaf('text', text)]))
285
302
  }
303
+ } else if (autoClosed.has(tok.tag)) {
304
+ // Este cierre llega tarde: su etiqueta ya se cerró sola cuando un
305
+ // cierre anterior pasó por encima de ella. No puede reclamar un
306
+ // ancestro del mismo nombre — hacerlo cerraba el contenedor de fuera
307
+ // y expulsaba de él a todo el resto del documento.
308
+ //
309
+ // Se conserva como nodo `discarded_tag`, que guarda su rango pero no
310
+ // se ve ni se exporta. Como texto volvía a salir del exportador
311
+ // convertido en etiqueta viva, y el documento dejaba de ser estable al
312
+ // reexportarlo (lo cazó `Fuzzer`).
313
+ autoClosed.delete(tok.tag)
314
+ addToParent(createLeaf('discarded_tag', source.slice(tok.start, tok.end)))
286
315
  } else {
287
316
  // LEGACY MODE: Walk backwards, auto-close inner tags, ignore orphaned closing tags.
288
317
  let found = -1
@@ -300,13 +329,18 @@ export function parseTokensToGreen(
300
329
  // trailing width of 0. This used to hand them `tok.end`, which made
301
330
  // `[b][i]x[/b]` produce an `italic` and a `bold` both ending at 10,
302
331
  // overlapping on the four characters of `[/b]`.
332
+ const autoClosedHere: string[] = []
303
333
  while (stack.length - 1 > found) {
304
334
  const inner = stack.pop()!
335
+ autoClosedHere.push(inner.tag)
305
336
  stack[stack.length - 1].children.push(closeFrame(inner, 0))
306
337
  }
307
338
 
308
339
  // Close the matched tag itself — this one does own the delimiter.
309
340
  addToParent(closeFrame(stack.pop()!, closeWidth))
341
+ // Las etiquetas que se cerraron solas quedan pendientes en el nivel
342
+ // que ha quedado abierto tras cerrar esta.
343
+ for (const tag of autoClosedHere) autoClosed.add(tag)
310
344
  } else {
311
345
  // Orphaned closing tag: no matching opener anywhere on the stack.
312
346
  //
@@ -351,7 +385,10 @@ export function parseTokensToGreen(
351
385
  for (const child of root) {
352
386
  // GreenNode.kind is a widened `string`; the kind vocabulary is shared with
353
387
  // NodeKind and every value reaching here came from tagToNodeKind().
354
- if (isBlockKind(child.kind as NodeKind) || child.kind === 'empty_line') {
388
+ // `discarded_tag` viaja suelto igual que `empty_line`: es un tramo de
389
+ // fuente que no se ve, y envolverlo en un párrafo dejaba un `<span>` vacío
390
+ // en el render por cada cierre descartado.
391
+ if (isBlockKind(child.kind as NodeKind) || child.kind === 'empty_line' || child.kind === 'discarded_tag') {
355
392
  flushParagraph()
356
393
  normalizedRoot.push(child)
357
394
  } else {