@liminis/editor 0.1.0-rc.1

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 (219) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +376 -0
  3. package/dist/annotations/anchor-align.d.ts +50 -0
  4. package/dist/annotations/anchor-align.js +74 -0
  5. package/dist/annotations/anchor-model.d.ts +81 -0
  6. package/dist/annotations/anchor-model.js +80 -0
  7. package/dist/annotations/anchor-resolver.d.ts +92 -0
  8. package/dist/annotations/anchor-resolver.js +231 -0
  9. package/dist/annotations/block-structure.d.ts +65 -0
  10. package/dist/annotations/block-structure.js +113 -0
  11. package/dist/annotations/types.d.ts +159 -0
  12. package/dist/annotations/types.js +50 -0
  13. package/dist/annotations.d.ts +25 -0
  14. package/dist/annotations.js +27 -0
  15. package/dist/app/App.d.ts +64 -0
  16. package/dist/app/App.js +180 -0
  17. package/dist/app/editor/AmbientCorrectionPlugin.d.ts +18 -0
  18. package/dist/app/editor/AmbientCorrectionPlugin.js +185 -0
  19. package/dist/app/editor/AnchorScrollPlugin.d.ts +16 -0
  20. package/dist/app/editor/AnchorScrollPlugin.js +134 -0
  21. package/dist/app/editor/AnnotationMarkerPlugin.d.ts +24 -0
  22. package/dist/app/editor/AnnotationMarkerPlugin.js +224 -0
  23. package/dist/app/editor/AnnotationPlugin.d.ts +42 -0
  24. package/dist/app/editor/AnnotationPlugin.js +135 -0
  25. package/dist/app/editor/AnnotationSurface.d.ts +34 -0
  26. package/dist/app/editor/AnnotationSurface.js +115 -0
  27. package/dist/app/editor/AssetContext.d.ts +20 -0
  28. package/dist/app/editor/AssetContext.js +81 -0
  29. package/dist/app/editor/BlockClickPlugin.d.ts +8 -0
  30. package/dist/app/editor/BlockClickPlugin.js +126 -0
  31. package/dist/app/editor/CodeBlockPlugin.d.ts +1 -0
  32. package/dist/app/editor/CodeBlockPlugin.js +219 -0
  33. package/dist/app/editor/CorrectionPanelPlugin.d.ts +14 -0
  34. package/dist/app/editor/CorrectionPanelPlugin.js +333 -0
  35. package/dist/app/editor/DragHandlePlugin.d.ts +1 -0
  36. package/dist/app/editor/DragHandlePlugin.js +300 -0
  37. package/dist/app/editor/Editor.d.ts +84 -0
  38. package/dist/app/editor/Editor.js +539 -0
  39. package/dist/app/editor/FrontmatterPlugin.d.ts +19 -0
  40. package/dist/app/editor/FrontmatterPlugin.js +301 -0
  41. package/dist/app/editor/ImageModal.d.ts +13 -0
  42. package/dist/app/editor/ImageModal.js +267 -0
  43. package/dist/app/editor/ImagePlugin.d.ts +3 -0
  44. package/dist/app/editor/ImagePlugin.js +187 -0
  45. package/dist/app/editor/LinkClickPlugin.d.ts +23 -0
  46. package/dist/app/editor/LinkClickPlugin.js +90 -0
  47. package/dist/app/editor/MarkdownShortcutsPlugin.d.ts +49 -0
  48. package/dist/app/editor/MarkdownShortcutsPlugin.js +373 -0
  49. package/dist/app/editor/OrderedTaskListPlugin.d.ts +21 -0
  50. package/dist/app/editor/OrderedTaskListPlugin.js +78 -0
  51. package/dist/app/editor/SearchPlugin.d.ts +1 -0
  52. package/dist/app/editor/SearchPlugin.js +379 -0
  53. package/dist/app/editor/SelectionContextMenuPlugin.d.ts +29 -0
  54. package/dist/app/editor/SelectionContextMenuPlugin.js +160 -0
  55. package/dist/app/editor/SlashMenu.d.ts +11 -0
  56. package/dist/app/editor/SlashMenu.js +494 -0
  57. package/dist/app/editor/SlashMenuPlugin.d.ts +1 -0
  58. package/dist/app/editor/SlashMenuPlugin.js +168 -0
  59. package/dist/app/editor/TableActionsPlugin.d.ts +1 -0
  60. package/dist/app/editor/TableActionsPlugin.js +398 -0
  61. package/dist/app/editor/TogglePlugin.d.ts +1 -0
  62. package/dist/app/editor/TogglePlugin.js +159 -0
  63. package/dist/app/editor/Toolbar.d.ts +10 -0
  64. package/dist/app/editor/Toolbar.js +263 -0
  65. package/dist/app/editor/WikiLinkExistencePlugin.d.ts +17 -0
  66. package/dist/app/editor/WikiLinkExistencePlugin.js +104 -0
  67. package/dist/app/editor/WikiLinkFormatPlugin.d.ts +17 -0
  68. package/dist/app/editor/WikiLinkFormatPlugin.js +203 -0
  69. package/dist/app/editor/annotation-marks.d.ts +177 -0
  70. package/dist/app/editor/annotation-marks.js +750 -0
  71. package/dist/app/editor/annotationCommands.d.ts +13 -0
  72. package/dist/app/editor/annotationCommands.js +11 -0
  73. package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +35 -0
  74. package/dist/app/editor/c4/C4InteractiveRenderer.js +299 -0
  75. package/dist/app/editor/c4/edge-clipping.d.ts +24 -0
  76. package/dist/app/editor/c4/edge-clipping.js +139 -0
  77. package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +38 -0
  78. package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +112 -0
  79. package/dist/app/editor/c4/layout.d.ts +25 -0
  80. package/dist/app/editor/c4/layout.js +839 -0
  81. package/dist/app/editor/c4/parser.d.ts +19 -0
  82. package/dist/app/editor/c4/parser.js +410 -0
  83. package/dist/app/editor/c4/render-to-string.d.ts +24 -0
  84. package/dist/app/editor/c4/render-to-string.js +34 -0
  85. package/dist/app/editor/c4/renderer.d.ts +64 -0
  86. package/dist/app/editor/c4/renderer.js +569 -0
  87. package/dist/app/editor/c4/types.d.ts +203 -0
  88. package/dist/app/editor/c4/types.js +43 -0
  89. package/dist/app/editor/correction-yaml.d.ts +10 -0
  90. package/dist/app/editor/correction-yaml.js +40 -0
  91. package/dist/app/editor/editorNodes.d.ts +9 -0
  92. package/dist/app/editor/editorNodes.js +36 -0
  93. package/dist/app/editor/index.d.ts +1 -0
  94. package/dist/app/editor/index.js +1 -0
  95. package/dist/app/editor/mark-overlap-resolver.d.ts +6 -0
  96. package/dist/app/editor/mark-overlap-resolver.js +45 -0
  97. package/dist/app/editor/nodes/C4Component.d.ts +14 -0
  98. package/dist/app/editor/nodes/C4Component.js +284 -0
  99. package/dist/app/editor/nodes/C4Node.d.ts +32 -0
  100. package/dist/app/editor/nodes/C4Node.js +112 -0
  101. package/dist/app/editor/nodes/CalloutNode.d.ts +23 -0
  102. package/dist/app/editor/nodes/CalloutNode.js +94 -0
  103. package/dist/app/editor/nodes/CustomLinkNode.d.ts +57 -0
  104. package/dist/app/editor/nodes/CustomLinkNode.js +209 -0
  105. package/dist/app/editor/nodes/CustomListItemNode.d.ts +46 -0
  106. package/dist/app/editor/nodes/CustomListItemNode.js +120 -0
  107. package/dist/app/editor/nodes/CustomListNode.d.ts +27 -0
  108. package/dist/app/editor/nodes/CustomListNode.js +54 -0
  109. package/dist/app/editor/nodes/DefinitionListNode.d.ts +44 -0
  110. package/dist/app/editor/nodes/DefinitionListNode.js +151 -0
  111. package/dist/app/editor/nodes/DiagramContextMenu.d.ts +45 -0
  112. package/dist/app/editor/nodes/DiagramContextMenu.js +116 -0
  113. package/dist/app/editor/nodes/EquationComponent.d.ts +17 -0
  114. package/dist/app/editor/nodes/EquationComponent.js +130 -0
  115. package/dist/app/editor/nodes/EquationNode.d.ts +43 -0
  116. package/dist/app/editor/nodes/EquationNode.js +179 -0
  117. package/dist/app/editor/nodes/FootnoteNode.d.ts +40 -0
  118. package/dist/app/editor/nodes/FootnoteNode.js +151 -0
  119. package/dist/app/editor/nodes/FrontmatterNode.d.ts +24 -0
  120. package/dist/app/editor/nodes/FrontmatterNode.js +88 -0
  121. package/dist/app/editor/nodes/HorizontalRuleNode.d.ts +17 -0
  122. package/dist/app/editor/nodes/HorizontalRuleNode.js +57 -0
  123. package/dist/app/editor/nodes/HtmlNode.d.ts +32 -0
  124. package/dist/app/editor/nodes/HtmlNode.js +121 -0
  125. package/dist/app/editor/nodes/ImageComponent.d.ts +11 -0
  126. package/dist/app/editor/nodes/ImageComponent.js +228 -0
  127. package/dist/app/editor/nodes/ImageNode.d.ts +53 -0
  128. package/dist/app/editor/nodes/ImageNode.js +183 -0
  129. package/dist/app/editor/nodes/ListItemParagraphBreakNode.d.ts +42 -0
  130. package/dist/app/editor/nodes/ListItemParagraphBreakNode.js +95 -0
  131. package/dist/app/editor/nodes/MermaidComponent.d.ts +11 -0
  132. package/dist/app/editor/nodes/MermaidComponent.js +216 -0
  133. package/dist/app/editor/nodes/MermaidNode.d.ts +27 -0
  134. package/dist/app/editor/nodes/MermaidNode.js +96 -0
  135. package/dist/app/editor/nodes/ToggleNode.d.ts +56 -0
  136. package/dist/app/editor/nodes/ToggleNode.js +237 -0
  137. package/dist/app/editor/nodes/diagram-context-menu.d.ts +11 -0
  138. package/dist/app/editor/nodes/diagram-context-menu.js +70 -0
  139. package/dist/app/editor/nodes/diagram-utils.d.ts +13 -0
  140. package/dist/app/editor/nodes/diagram-utils.js +29 -0
  141. package/dist/app/editor/nodes/index.d.ts +30 -0
  142. package/dist/app/editor/nodes/index.js +17 -0
  143. package/dist/app/editor/utils.d.ts +2 -0
  144. package/dist/app/editor/utils.js +17 -0
  145. package/dist/app/mapper/index.d.ts +3 -0
  146. package/dist/app/mapper/index.js +2 -0
  147. package/dist/app/mapper/lexicalToMdast.d.ts +62 -0
  148. package/dist/app/mapper/lexicalToMdast.js +1937 -0
  149. package/dist/app/mapper/mdastToLexical.d.ts +31 -0
  150. package/dist/app/mapper/mdastToLexical.js +1075 -0
  151. package/dist/contract.d.ts +8 -0
  152. package/dist/contract.js +8 -0
  153. package/dist/headless.d.ts +40 -0
  154. package/dist/headless.js +45 -0
  155. package/dist/host/context.d.ts +20 -0
  156. package/dist/host/context.js +24 -0
  157. package/dist/host/defaults.d.ts +21 -0
  158. package/dist/host/defaults.js +47 -0
  159. package/dist/host/messages.d.ts +26 -0
  160. package/dist/host/messages.js +47 -0
  161. package/dist/host/types.d.ts +75 -0
  162. package/dist/host/types.js +13 -0
  163. package/dist/index.d.ts +35 -0
  164. package/dist/index.js +51 -0
  165. package/dist/markdown/annotate-sentinels.d.ts +42 -0
  166. package/dist/markdown/annotate-sentinels.js +86 -0
  167. package/dist/markdown/parse.d.ts +56 -0
  168. package/dist/markdown/parse.js +516 -0
  169. package/dist/markdown/stringify.d.ts +7 -0
  170. package/dist/markdown/stringify.js +439 -0
  171. package/dist/markdown/vendor/mdast-util-wiki-link/LICENSE +21 -0
  172. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +41 -0
  173. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +72 -0
  174. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +127 -0
  175. package/dist/markdown/vendor/mdast-util-wiki-link/index.d.ts +8 -0
  176. package/dist/markdown/vendor/mdast-util-wiki-link/index.js +6 -0
  177. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +17 -0
  178. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +33 -0
  179. package/dist/markdown.d.ts +28 -0
  180. package/dist/markdown.js +44 -0
  181. package/dist/mathjax-config.d.ts +89 -0
  182. package/dist/mathjax-config.js +133 -0
  183. package/dist/nodes.d.ts +34 -0
  184. package/dist/nodes.js +34 -0
  185. package/dist/stores/correctionStore.d.ts +15 -0
  186. package/dist/stores/correctionStore.js +12 -0
  187. package/dist/stores/editorStore.d.ts +16 -0
  188. package/dist/stores/editorStore.js +27 -0
  189. package/dist/styles.css +2477 -0
  190. package/dist/types.d.ts +296 -0
  191. package/dist/types.js +91 -0
  192. package/dist/utils/file-types.d.ts +10 -0
  193. package/dist/utils/file-types.js +46 -0
  194. package/docs/annotations.md +134 -0
  195. package/docs/decisions/README.md +72 -0
  196. package/docs/decisions/adr-002.md +27 -0
  197. package/docs/decisions/adr-007.md +27 -0
  198. package/docs/decisions/adr-010.md +26 -0
  199. package/docs/decisions/adr-024.md +124 -0
  200. package/docs/decisions/adr-024b.md +0 -0
  201. package/docs/decisions/adr-025.md +103 -0
  202. package/docs/decisions/adr-027.md +66 -0
  203. package/docs/decisions/adr-057.md +67 -0
  204. package/docs/decisions/adr-070.md +131 -0
  205. package/docs/decisions/adr-075.md +410 -0
  206. package/docs/decisions/adr-076.md +183 -0
  207. package/docs/decisions/adr-077.md +410 -0
  208. package/docs/decisions/adr-078.md +243 -0
  209. package/docs/decisions/adr-079.md +167 -0
  210. package/docs/decisions/adr-080.md +325 -0
  211. package/docs/decisions/adr-081.md +108 -0
  212. package/docs/decisions/adr-082.md +123 -0
  213. package/docs/decisions/adr-083.md +204 -0
  214. package/docs/decisions/adr-084.md +179 -0
  215. package/docs/editor-api.md +106 -0
  216. package/docs/markdown-pipeline.md +282 -0
  217. package/docs/provenance.md +106 -0
  218. package/docs/zusammen-editor-capability-map.md +232 -0
  219. package/package.json +175 -0
@@ -0,0 +1,410 @@
1
+ # ADR-077: Comments and Corrections Are Two Kinds of One Annotation Mechanism
2
+
3
+ **Date:** 2026-08-04
4
+ **Status:** Accepted (amended by ADR-080 — see the two notes marked *Amended by ADR-080* below)
5
+ **Supersedes:** none
6
+ **Amends:** ADR-075 (adds a sixth export subpath to the surface it fixed at five)
7
+ **Amended by:** ADR-080 (#964 supplies the Liminis consumer this ADR said did not exist, and wires `onCreateAnnotation`)
8
+ **Issue:** #939
9
+
10
+ > Authored as ADR-076. Renumbered to 077 when this branch rebased onto `main`,
11
+ > because #943 landed its own ADR-076 first. No numbering collision is left
12
+ > behind — unlike ADR-024/024b, which the index still records as historical.
13
+
14
+ ## Context
15
+
16
+ Two near-identical editor trees had been diverging. Zusammen accreted a review
17
+ **commenting** feature — durable, text-anchored comments rendered as live
18
+ `@lexical/mark` MarkNodes, transparent on export. Liminis accreted an AI
19
+ **correction** feature — a context-menu entry, a suggestion panel, and a
20
+ term-substitution scan.
21
+
22
+ ADR-075 (#938) stopped the divergence for the *core* by making the editor one
23
+ package with a curated public surface and a persistence-drawn seam. It
24
+ deliberately left the two annotation-shaped features unreconciled. That left a
25
+ live problem: the package shipped a correction-only code path, and Zusammen
26
+ could not adopt the package without forking it back — the exact failure ADR-075
27
+ set out to end.
28
+
29
+ The two features are the same shape. Both are a range-anchored marker over
30
+ document text, with data flowing in from a host and events flowing out to it.
31
+ Only their storage, lifecycle and presentation differ, and all three of those
32
+ are already on the host's side of ADR-075's seam.
33
+
34
+ A complicating fact, found while implementing: **Liminis's correction feature
35
+ has no marks, no anchors and no resolution.** The issue's premise — that
36
+ corrections are host-injected annotations with inline accept/reject — described
37
+ a feature that does not exist. A literal reading of "corrections must use the
38
+ one marker renderer" and "no user-visible correction change" cannot both hold,
39
+ because giving corrections in-document markers *is* a visible change.
40
+
41
+ ## Decision
42
+
43
+ **One annotation mechanism, baked into the package, with comments and
44
+ corrections as two configured usages of it.**
45
+
46
+ An annotation is `{ id, kind, anchor, outcome?, presentation?, payload? }`. The
47
+ package owns the mechanics: the durable anchor model, selection→anchor capture,
48
+ the fuzzy anchor resolver, MarkNode marker rendering, mark-transparency on
49
+ export, the live-mark placement policy, and the in-document affordances. A host
50
+ owns everything else.
51
+
52
+ ### The kind configuration is the only difference
53
+
54
+ `AnnotationKindConfig` — marker style, create affordance, live-mark policy, and
55
+ whether capture retains its mark — is the sole thing distinguishing a comment
56
+ from a correction. There is exactly one anchor model, one resolver, and one
57
+ marker renderer, with no kind-specific variant of any of the three.
58
+
59
+ ### Corrections render no live mark
60
+
61
+ The `correction` kind is configured `markerStyle: 'none'`, `livemarkPolicy:
62
+ () => false`, `retainMarkOnCreate: false`. Correction authoring is *wired* to
63
+ the same capture primitive comments use: `SelectionContextMenuPlugin`'s
64
+ "Correction…" entry dispatches `OPEN_ANNOTATION_COMPOSER_COMMAND`, whose
65
+ handler wraps the selection in a transient MarkNode, reads the anchor back
66
+ from it, and — because `retainMarkOnCreate` is false — discards the mark
67
+ again, so nothing ever paints. That handler is not mounted in Liminis today;
68
+ see "What Liminis actually runs today" below, which is the authoritative
69
+ statement and takes precedence over any looser reading of this paragraph.
70
+
71
+ This is what resolves the contradiction above. Corrections are expressed
72
+ through the mechanism — their authoring entry point dispatches its command,
73
+ their behaviour is kind configuration — while rendering exactly what they
74
+ rendered before: nothing.
75
+ Parity is preserved, and the reviewer's test for "is this really unified?" —
76
+ point at the kind-config objects as the only difference — holds.
77
+
78
+ `markerStyle: 'none'` is enforced structurally rather than by convention:
79
+ `deriveMarkerTargets` drops such annotations outright, ahead of and in
80
+ precedence over `livemarkPolicy`. A marker target is not a styling hint — it
81
+ is what causes a real `MarkNode`, and therefore a `<mark>` element, to be
82
+ placed. The marker plugin skips decoration for `none`, so that element would
83
+ carry no `annotation-mark-*` class, and the package's stylesheet neutralizes
84
+ the UA's default yellow `<mark>` background only under those classes. Without
85
+ the gate, a host that configured `none` and left `livemarkPolicy` at its
86
+ default would get a persistent uncontrolled highlight — the opposite of what
87
+ `none` promises. Liminis's explicit `livemarkPolicy: () => false` is now
88
+ belt-and-braces rather than load-bearing.
89
+
90
+ **What Liminis actually runs today.** The create handler lives in
91
+ `AnnotationPlugin`, which the annotation surface mounts only when a host
92
+ supplies `onCreateAnnotation`. `liminis-app` supplies `annotationKinds` but
93
+ *not* `onCreateAnnotation` — it has nothing to do with a correction anchor,
94
+ since corrections have no durable anchors (below). So in Liminis the
95
+ "Correction…" dispatch currently finds no listener and is inert: the correction
96
+ panel opens exactly as it always has, and no capture runs. This is deliberate
97
+ — running a wrap/read/unwrap cycle purely to discard the result would be cost
98
+ and FR-006 risk on a user-visible path for no product value — but it means the
99
+ claim "corrections exercise the capture primitive" is true of the *wiring*, not
100
+ of a running Liminis session.
101
+
102
+ > **Amended by ADR-080 (#964).** No longer true. `EditorColumn` now supplies
103
+ > `onCreateAnnotation` for the `comment` kind, and `AnnotationPlugin` registers
104
+ > one handler for *every* configured kind — so the "Correction…" dispatch does
105
+ > find a listener, and the wrap/read/unwrap cycle this paragraph declined to
106
+ > pay for now runs on the correction path, with its result discarded by the
107
+ > host handler. The cost was accepted rather than chosen: removing the
108
+ > correction affordance would change correction behaviour, and suppressing the
109
+ > capture would need a package change. User-visible behaviour is unchanged and
110
+ > guarded. See ADR-080 §6. FR-001's substantive guarantee is unaffected: the
111
+ package contains exactly one anchor model, one resolver and one marker
112
+ renderer, and no correction-specific variant of any of them. A host that does
113
+ want correction anchors turns them on by passing `onCreateAnnotation`, with no
114
+ package change.
115
+
116
+ **What that costs.** `annotationsEnabled` is true for any non-empty
117
+ `annotationKinds`, so configuring the `correction` kind makes the editor load
118
+ the lazy `AnnotationSurface` chunk (~49 KB) and its annotation modules at
119
+ mount — machinery Liminis gains no behaviour from today. Measured live on a
120
+ running app during Validate: the annotation code stays in its own chunk and
121
+ never reaches windows that host no editor, so #938's bundle win survives
122
+ (graph window +417 bytes against `main`). We accept the editor-window cost for
123
+ this phase: registering the kind keeps the host-side wiring on the real path
124
+ rather than dead, and dropping the config to reclaim the chunk would trade that
125
+ away for a saving that only matters until the callback is supplied.
126
+
127
+ Corrections do **not** exercise the resolver. They have no cross-session
128
+ anchors; their lifetime and storage remain the app-side correction store. This
129
+ is a refactor, not a data-model change for corrections.
130
+
131
+ ### A sixth export subpath: `./annotations`
132
+
133
+ ADR-075 fixed the surface at five subpaths and permitted adding a *considered*
134
+ export. This is one.
135
+
136
+ The headless annotation surface — anchor model, resolver, block structure, span
137
+ alignment, and the annotation types — must be callable outside a rendered
138
+ editor (Zusammen resolves anchors in its main process). It therefore cannot
139
+ live on the root barrel. It does not belong on `./headless` either, whose
140
+ `mathjax-config` re-export drags ~1.9 MB of side-effectful imports; and
141
+ `./markdown` is contractually pure-mdast, while the anchor schema needs `zod`.
142
+
143
+ A dedicated subpath also gives the "annotations are off by default" requirement
144
+ a crisp assertion target. Its import graph is tested to reach no Lexical, React,
145
+ DOM, MathJax, Mermaid, C4 or Prism module, and nothing outside `src/annotations/`.
146
+
147
+ ### The mechanism is opt-in, behind a lazy boundary
148
+
149
+ A consumer that configures no kinds gets nothing: no marker, no affordance, no
150
+ registered command — and, more importantly, no annotation module in its import
151
+ graph. `Editor.tsx` reaches the annotation surface only through
152
+ `lazy(() => import('./AnnotationSurface'))`, and imports the annotation prop
153
+ *types* only as `import type`, which the compiler erases.
154
+
155
+ Two exceptions are deliberate and scoped:
156
+
157
+ - **`MarkNode` registration** and **mapper mark-transparency** are
158
+ unconditional. The mapper is shared and cannot be kind-aware; making it so
159
+ would fork the round-trip path, which is the exact divergence #938 ended.
160
+ `MarkNode` is an inert node when nothing places one.
161
+ - **`annotationCommands.ts`** stays statically importable. It declares one
162
+ Lexical command and imports nothing but `lexical`, carrying no anchor, mark or
163
+ resolver machinery. That is what lets the selection context menu enter the
164
+ mechanism without dragging any of it into the disabled graph. A test pins the
165
+ module to that single import.
166
+
167
+ ### Marker styling ships with the package
168
+
169
+ `AnnotationMarkerPlugin` decorates a live mark's element with
170
+ `annotation-mark-<markerStyle>` (`highlight` / `squiggle`), plus
171
+ `annotation-mark-active` and `annotation-mark-pulse` for state. Default rules
172
+ for all of them are in `packages/editor/src/styles.css`.
173
+
174
+ They ship in the package rather than being left to the host because the
175
+ mechanism is a baked-in feature, not an extension point (see the Context above):
176
+ a host that enables the `comment` kind should get a visible, working marker
177
+ without first having to discover and reimplement those exact class names. The
178
+ failure mode is otherwise silent and invisible from inside Liminis — anchors
179
+ place, activation fires, and nothing renders.
180
+
181
+ Hosts that want a different look override the rules, or supply a per-annotation
182
+ class through `presentation.className`, which the plugin applies alongside the
183
+ kind's own style. A test asserts every `AnnotationMarkerStyle` other than `none`
184
+ has a rule, cross-checked against the union declaration so a new style cannot be
185
+ added without one.
186
+
187
+ **Shipping the rules is not the same as delivering them.** Nothing in the
188
+ package injects the stylesheet: neither the root barrel nor `<Editor>` imports
189
+ `styles.css`, deliberately — a component that imports CSS forces it on every
190
+ consumer, including the markdown-only ones that take `./markdown` precisely to
191
+ stay clear of the React surface. So a host that enables a marker-visible kind
192
+ **must** import the stylesheet itself:
193
+
194
+ ```ts
195
+ import '@liminis/editor/styles.css'
196
+ ```
197
+
198
+ Miss it and the failure is the silent one described above, one step earlier:
199
+ anchors place, activation fires, and nothing renders. `liminis-app` does this in
200
+ `src/renderer/App.tsx`, and `editor-package-wiring.test.ts` pins all three
201
+ halves of the contract — the subpath is declared, the app performs the import,
202
+ and the package still does not perform it on the host's behalf.
203
+
204
+ ### The seam stays at persistence
205
+
206
+ The package contains no persistence, actor/identity, or annotation-lifecycle
207
+ logic. Comment storage, correction storage, resolve/reopen, accept/reject, and
208
+ the semantic-relocation strategy stay in the consuming apps, reached only via
209
+ injected services or callbacks. `resolveAnchors` is async precisely because the
210
+ relocation seam may be host-supplied and slow; with no strategy supplied, an
211
+ unmatchable anchor yields `orphaned` rather than erroring.
212
+
213
+ Two of Zusammen's helpers are deliberately **not** ported:
214
+ `deriveMarkerTargets` and `anchorOutcomeOf` take Zusammen's `Comment`/`Thread`
215
+ types, which are app-domain. The package accepts already-derived annotations
216
+ instead and re-expresses `deriveMarkerTargets` over them.
217
+
218
+ ## Consequences
219
+
220
+ **Good:**
221
+
222
+ - Zusammen can adopt the package without forking it. Every editor-domain
223
+ capability it has today maps to a package export (see
224
+ `docs/project_notes/zusammen-editor-capability-map.md`).
225
+ - One anchor model, one resolver, one marker renderer. A third annotation-shaped
226
+ feature is a configuration entry, not a new code path.
227
+ - Zusammen's anchor test suites carry over and pass unmodified, so the
228
+ classification outcomes and thresholds are preserved case-for-case rather
229
+ than reimplemented and hoped-for.
230
+ - Mark transparency is now asserted across the whole round-trip fixture corpus,
231
+ not just hand-built cases.
232
+
233
+ **Bad / accepted:**
234
+
235
+ - The resolver has **no Liminis consumer**. It exists for Zusammen and for the
236
+ ported tests. This is deliberate, and stated here so it isn't later mistaken
237
+ for dead code and deleted.
238
+ > **Amended by ADR-080 (#964).** It has one now:
239
+ > `liminis-app/src/main/comments/comment-anchoring.ts` calls `resolveAnchors`
240
+ > on every document open.
241
+ - Liminis now configures an annotation kind, which turns the mechanism on and
242
+ switches its import path to the offset-collecting variant. Invisible to users,
243
+ but it is no longer true that Liminis takes the annotations-disabled path.
244
+ - The sixth subpath is a small widening of a surface ADR-075 deliberately kept
245
+ narrow.
246
+
247
+ **Neutral:**
248
+
249
+ - The correction term-substitution scan remains separate from the resolver.
250
+ They answer different questions: the scan is exact word-boundary *candidate
251
+ discovery*; the resolver re-locates an anchor captured against an older
252
+ document version. "Fuzzy matching in exactly one module" constrains the
253
+ resolver, not the scan.
254
+
255
+ ## Implementation Notes
256
+
257
+ Two constraints worth recording, both found by tests rather than by reading:
258
+
259
+ **A Lexical command handler runs inside an active update.** The nested update
260
+ that places a mark is queued, not applied, so reading the anchor back inline
261
+ always yields null. The anchor read is deferred to a microtask, which runs after
262
+ the outer update flushes. Zusammen reaches the same split by reading the anchor
263
+ at composer-submit time.
264
+
265
+ **`convertLinkNode`'s child loop silently drops unrecognized node types.** An
266
+ unflattened MarkNode over link text therefore deleted that text from the export
267
+ entirely (`[note](#x)` → `[](#x)`). Fourteen corpus fixtures caught this; it had
268
+ no equivalent in Zusammen's copy.
269
+
270
+ **Mid-resolution staleness needs no new guard.** `placeMarkForAnchor` already
271
+ re-verifies the anchor's target text against the current offset spans and
272
+ refuses to place on a mismatch, so a stale anchor no-ops and retries when the
273
+ next parse produces fresh spans. This is now a tested property rather than an
274
+ assumption.
275
+
276
+ ### Overlapping annotations (#970)
277
+
278
+ **Placement resolves against the pristine tree, in absolute text offsets.**
279
+ `OffsetSpan[]` describes the parse that produced it, and placing a mark splits
280
+ the `TextNode` it lands in — Lexical's `splitText` keeps the original key on the
281
+ first segment, so after one placement the table can name offsets past the end of
282
+ what that key now holds. An earlier fix ordered a batch back to front, which
283
+ keeps *disjoint* entries valid but silently declined any entry whose range
284
+ overlapped, nested inside, or coincided with another's. `placeMarksForAnchors`
285
+ now runs two phases inside one `editor.update()`: resolve every entry against the
286
+ untouched tree into a character offset across all text nodes, then apply each
287
+ against the live tree. Marking changes no text, so those offsets are invariant
288
+ under every placement — the same invariant `$withPreservedSelection` relies on to
289
+ restore the caret. Placement order therefore no longer affects the outcome.
290
+
291
+ **Overlap is represented as id-unioned sibling marks, not nested ones.**
292
+ `$wrapSelectionInMarkNode` creates a nested `MarkNode` when a selection lands
293
+ inside an existing mark and never merges ids; its own source defers un-nesting to
294
+ `registerNestedElementResolver<MarkNode>`. Nothing registered one, so the shared
295
+ region of two overlapping annotations carried only the inner id and a click there
296
+ fired twice as it bubbled — while `AnnotationMarkerPlugin` was already written
297
+ for the opposite shape. `mark-overlap-resolver.ts` registers that resolver, so an
298
+ overlap becomes a run of siblings whose shared part carries both ids. It is a
299
+ node transform, so it runs inside the update that created the nesting and the
300
+ single-reconciliation guarantee is unaffected.
301
+
302
+ **A range over a whole inline construct recovers the whole construct.** The
303
+ guarantee is deliberately scoped that way — a boundary never *splits* a run of
304
+ inline syntax, whatever the mark covers, but only whole coverage moves it
305
+ outside the construct (see the paragraph after next). The annotated-serialize
306
+ sentinel used to be spliced into a mark's first/last *text leaf*, and that walk
307
+ recursed into inline element children — so a mark over
308
+ `it [rests](https://example.com)` recovered as `it [rests`. `lexicalToMdast.ts`
309
+ now hoists a boundary token outside every inline construct whose whole rendered
310
+ content the mark covers, emitting it as its own mdast text node beside the
311
+ construct; nested constructs resolve to the outermost.
312
+
313
+ The rule is *whole* coverage, not any coverage. A mark covering only part of a
314
+ construct's text keeps today's splice, because widening it would change what the
315
+ annotation means: `big` inside `**big world**` would recover as `**big world**`
316
+ and re-place onto `big world`. Whole coverage has no such problem — re-placing
317
+ `[project docs](…)` snaps back inside the syntax and highlights `project docs`
318
+ again. This is a deliberate deviation from #970's FR-014, whose second clause
319
+ would widen partial coverage too; the first clause (no boundary strictly inside a
320
+ delimiter run) holds either way, since boundaries always sit at text-leaf edges.
321
+
322
+ **Annotate mode must not be visible to any *decision* the export makes.** The
323
+ whole range calculation is offset arithmetic against a plain export of the same
324
+ state, so the annotated form has to differ by its tokens and nothing else. Two
325
+ pre-existing violations surfaced when that property was given a corpus-wide test
326
+ (`annotated-serialize-corpus.test.ts`): the list-item explicit-task-marker check
327
+ and stringify's paragraph-ends-with-colon join rule both matched raw text values
328
+ that a token had been spliced into. Both now read the sentinel-free form via
329
+ `markdown/annotate-sentinels.ts`. Any new decision made from a text value has the
330
+ same obligation.
331
+
332
+ **Host-side mitigations in `liminis-app` became dead on merge, and were
333
+ removed.** *(Written before either PR merged, and kept as the reasoning behind
334
+ the checklist — see the authoritative status note below for what actually
335
+ happened.)* Both defects were live long enough for #964 (PR #967) to ship around
336
+ them, and neither mitigation was visible from this worktree at the time — PR #967
337
+ was open against the same `main` this branch forked from, so the files named
338
+ below did not exist here and could not be edited as part of this change.
339
+ Whichever of the two PRs merged **second** had to carry the removals; if that
340
+ were #967, this list was the checklist.
341
+
342
+ **Tracked as issue #972**, because an ADR paragraph is not a reminder — if the
343
+ second PR had merged without the removals, the write guard would have kept
344
+ declining legitimate suffix deletions and the release notes would have stayed
345
+ wrong.
346
+
347
+ > **Status, 2026-08-13.** PR #967 (issue #964) merged first, so PR #971 is the
348
+ > second and carries the removals. Done here: the `comment-markdown-integrity`
349
+ > pins now assert all seven ids and the full `it [rests](https://example.com)`
350
+ > span; `canRefreshAnchorText`, both call sites and its tests are deleted, with
351
+ > the two call sites reduced to a plain "nothing moved" equality check so a
352
+ > genuine edit — including a suffix deletion — refreshes again.
353
+ >
354
+ > **Not done, and deliberately:** `findOverlappingThreadIds` and its comment-list
355
+ > notice. The notice is untrue now, but it is app-side presentation, which
356
+ > #970's spec places out of its own scope, and removing it means unpicking a
357
+ > prop threaded through `EditorColumn` into `CommentList` plus ~90 lines of
358
+ > tests. #972 must not be closed as fully absorbed until that and the
359
+ > `RELEASE_NOTES.md` / ADR-080 known-limitations wording are dealt with.
360
+
361
+ Part 1 (overlapping marks) shipped as a documented limitation rather than a
362
+ guard, so only descriptions go:
363
+
364
+ - `renderer/components/comments/useDocumentComments.ts` — `findOverlappingThreadIds`
365
+ and the comment-list notice *"Shares a passage with another comment — only one
366
+ of them shows a highlight."* The notice is now untrue; drop the helper and its
367
+ tests.
368
+ - `renderer/components/__tests__/comment-markdown-integrity.test.tsx` — pins
369
+ 5 of 7 annotations placing, and the exact spans they cover. It is written to
370
+ fail when this lands. Update it to the full set and to
371
+ `covered['over-link'] === 'it [rests](https://example.com)'` — that failure is
372
+ the signal, not a regression.
373
+ - The corresponding *Known limitations at first release* entries in
374
+ `RELEASE_NOTES.md` and ADR-080.
375
+
376
+ Part 2 (truncated span over an inline link) shipped a real write guard, because
377
+ FR-019's refresh would otherwise have persisted an anchor ending mid-syntax.
378
+ That guard is `canRefreshAnchorText` in `shared/comment-types.ts` — *refuse to
379
+ replace a stored `targetText` with text contained within it* — applied in
380
+ `main/ipc/comments-handlers.ts` (`comments:refreshAnchors`, the authoritative
381
+ check) and pre-filtered in `useDocumentComments.ts`. It is deliberately blunt
382
+ and has a standing cost: a user who genuinely deletes the tail of an anchored
383
+ passage gets no re-capture either, because the host cannot tell that apart from
384
+ the truncation without re-implementing the mapper's knowledge of inline syntax —
385
+ which is what now lives here instead. Delete the helper, both call sites, and
386
+ the tests that pin it:
387
+
388
+ - `shared/__tests__/comment-types.test.ts` → `describe('canRefreshAnchorText — the FR-019 write guard (#970)')`
389
+ - `main/ipc/__tests__/comments-handlers.test.ts` → *"refuses to truncate a stored
390
+ anchor that spans an inline link"* and *"declines to shrink a stored anchor,
391
+ which is the guard's accepted cost"*
392
+ - `renderer/components/comments/__tests__/useDocumentComments.test.ts` → *"sends
393
+ nothing when a mark over an inline link reports a truncated span"*
394
+
395
+ Leaving the guard in after the fix keeps declining legitimate suffix deletions
396
+ for no reason. What makes the removal safe is
397
+ `annotation-inline-construct-ranges.test.ts`: for an unedited document the
398
+ recovered slice now equals the captured `targetText` across every inline
399
+ construct the editor round-trips, so the refresh has no spurious difference left
400
+ to act on.
401
+
402
+ ## References
403
+
404
+ - Issue #939 (this phase), #938 (Phase 1 — package carve-out), #940 (Phase 3 — OSS hardening)
405
+ - ADR-075 (the `@liminis/editor` package boundary), ADR-057 (knowledge
406
+ corrections pipeline), ADR-070 (dedicated Lexical node types), ADR-042 (MCP
407
+ stdio serialization)
408
+ - Zusammen ADR-008 (comment anchor resolution), ADR-012 and its #47 amendment
409
+ (comment anchor as live mark) — the design sources for the ported machinery
410
+ - `docs/project_notes/zusammen-editor-capability-map.md`
@@ -0,0 +1,243 @@
1
+ # ADR-078: `@liminis/editor` Is Delivered by a Non-Bundling `tsc` Emit Behind `publishConfig`
2
+
3
+ **Date:** 2026-08-05
4
+ **Status:** Accepted
5
+ **Supersedes:** none
6
+ **Amends:** ADR-075 (closes three of its deferrals; re-points the other two)
7
+ **Issue:** #940
8
+
9
+ ## Context
10
+
11
+ ADR-075 carved `@liminis/editor` out of `liminis-app` and fixed its public
12
+ surface; ADR-077 added the unified annotation mechanism and a sixth export
13
+ subpath. Both left the package consumed **in-workspace as raw TypeScript**:
14
+ `main`/`types` point at `src/index.ts`, there is no build, `react`/`react-dom`/
15
+ `lexical` are plain dependencies, and wiki-link behaviour depends on a pnpm
16
+ `patchedDependencies` entry that exists only in this repository.
17
+
18
+ That is enough for `liminis-app` and not enough for anyone else. #940's job was
19
+ to make the package genuinely installable outside the monorepo — for Zusammen
20
+ first, and for open-sourcing generally — without changing what it contains.
21
+
22
+ Four things had to be decided, and each has a failure mode that is silent rather
23
+ than loud.
24
+
25
+ ## Decision
26
+
27
+ ### 1. The build is a structure-preserving `tsc` emit. No bundler.
28
+
29
+ `tsc -p tsconfig.build.json` emits ESM plus `.d.ts` module-for-module into
30
+ `dist/`, followed by `tsc-alias --resolve-full-paths` to turn `tsc`'s
31
+ extensionless relative specifiers into Node-ESM-resolvable `./x.js` ones. A
32
+ small `scripts/copy-assets.mjs` places `styles.css` and the vendored module's
33
+ license, which `tsc` does not copy.
34
+
35
+ **Why not a bundler.** ADR-075 §4's `ReferenceError: Prism is not defined`
36
+ reproduction — 138 e2e failures, blank renderer — was a *rolldown whole-graph
37
+ chunking* artifact. Introducing a second rolldown here (via `tsdown`, or `vite
38
+ build` in library mode) would recreate exactly that risk surface, and would make
39
+ the six subpaths' isolation depend on chunking configuration whose failure mode
40
+ is a silently heavier bundle rather than an error.
41
+
42
+ With a non-bundling emit the isolation holds **by construction**:
43
+
44
+ - `./markdown` cannot acquire `./headless`'s ~1.9 MB MathJax configuration
45
+ graph, because no chunker exists to hoist it there.
46
+ - `./contract` cannot acquire *renderer* code, because the module boundary is
47
+ preserved verbatim. Note it does **not** make the entry zod-free: `contract.js`
48
+ re-exports `./types.js`, which does `import { z } from 'zod'`, so a *value*
49
+ import of `./contract` pulls zod in. What keeps zod out of the preload
50
+ boundary is `import type` erasure, not the subpath — as ADR-075 §1 says
51
+ plainly, and as `editor-package-wiring.test.ts` is the only thing pinning.
52
+ Emitting module-for-module preserves that property exactly as it was; it does
53
+ not strengthen it.
54
+ - `lazy(() => import('./AnnotationSurface'))` survives as a real dynamic import
55
+ to a real file, so the annotation boundary is still a boundary.
56
+
57
+ It also adds no bundler dependency: `typescript` was already a devDependency,
58
+ and `tsc-alias` is a small, mature post-pass.
59
+
60
+ Rejected alternatives: `tsdown` (0.x, and it pulls in the exact rolldown hazard);
61
+ `vite build` lib mode (chunking config that fails silently on the
62
+ `./markdown` ↔ `./headless` boundary).
63
+
64
+ ### 2. `publishConfig` swaps the manifest at pack time. `liminis-app` keeps consuming source.
65
+
66
+ The checked-in `package.json` still has `main`/`types`/`exports` pointing at
67
+ `src/`. A `publishConfig` block carries the `dist/` versions, and pnpm applies it
68
+ at `pnpm pack` / `pnpm publish` — verified, including for a `private: true`
69
+ package, so no `prepack` manifest-swap fallback was needed.
70
+
71
+ **Why this rather than switching the app to built output.** `liminis-app`
72
+ carries two pieces of configuration that exist *solely* because the package is
73
+ raw TypeScript, both asserted by `editor-package-wiring.test.ts`:
74
+
75
+ - `electron.vite.config.ts` — `externalizeDepsPlugin({ exclude: ['@liminis/editor'] })`
76
+ on `main` and `preload`.
77
+ - `renderer/styles/main.css` — `@source "../../../../packages/editor/src"`,
78
+ because Tailwind v4's automatic source detection skips `node_modules`.
79
+
80
+ Switching the app to built output invalidates **both at the same moment**, and
81
+ they must then change in lockstep with the tests asserting them. The Tailwind
82
+ half is the dangerous one: its failure mode is unstyled output, not an error, and
83
+ the existing guard ("the `@source` path exists") would still pass against a path
84
+ that exists but contains no class-bearing source.
85
+
86
+ `publishConfig` makes that whole class of problem not arise. The cost is that
87
+ the built path is exercised only in CI — which is what SC-005 mandates anyway.
88
+
89
+ `private: true` is kept: publishing is out of scope for #940, and `private`
90
+ blocks `publish` without blocking `pack`.
91
+
92
+ ### 3. `mdast-util-wiki-link` is vendored, not merely un-patched.
93
+
94
+ The upstream package is vendored under
95
+ `src/markdown/vendor/mdast-util-wiki-link/` (MIT, Mark Hudnall, attribution and
96
+ license preserved and shipped in `dist/`), with the trailing-backslash fix from
97
+ [#347](https://github.com/verveguy/liminis/issues/347) applied inline.
98
+
99
+ **Why vendor rather than drop the patch.** Three reasons, in increasing order of
100
+ how easy they are to miss:
101
+
102
+ 1. The strip must happen **before** `pageResolver` runs, so `data.permalink` and
103
+ `data.exists` derive from the cleaned target. `parse.ts`'s existing
104
+ `stripEscapedPipeFromWikiLinks` post-pass fixes only `node.value` and cannot
105
+ reproduce that. (It is kept, as defense-in-depth.)
106
+ 2. `liminis-app/src/main/canonical-chunker.ts` is a **second, independent
107
+ consumer** with no post-pass at all. It feeds the knowledge graph, and a
108
+ wiki-link parse change there is invisible to the editor suite *and* to e2e —
109
+ it surfaces only as subtly different indexed chunk text. It now imports
110
+ `wikiLinkFromMarkdown`/`wikiLinkToMarkdown` from `@liminis/editor/markdown`,
111
+ so the two consumers cannot drift onto *different wiki-link extensions*.
112
+
113
+ That is narrower than "the two parses cannot diverge", and the difference
114
+ matters. Sharing the extension closes the vendoring gap only. It does not
115
+ close a **pre-existing** divergence that predates this ADR: `parseMarkdown`
116
+ escapes the alias divider before parsing and carries an `_emptyAlias`
117
+ sentinel, while the chunker calls the extension directly. Without the escape,
118
+ GFM's table parser reaches the `|` in `[[target|alias]]` first and splits the
119
+ row — measured at 5 cells / 0 wiki-links where `parseMarkdown` gives 4 / 1.
120
+ So an aliased wiki-link inside a table is still chunked differently from how
121
+ the editor parses it. Closing that changes the text fed to the knowledge
122
+ graph, so it is deliberately left for a follow-up rather than folded into
123
+ #940's delivery work. `canonical-chunker.ts` carries the same caveat at the
124
+ import site.
125
+ 3. Upstream's `to-markdown` deep-imports `mdast-util-to-markdown/lib/util/safe`
126
+ from **v0.6.5** — a v0 duplicate of the v2 the editor already uses, plus
127
+ `@babel/runtime`, both pulled in to serve one function. The vendored copy uses
128
+ v2's `state.safe()` and drops both.
129
+
130
+ The root `patchedDependencies` entry, the patch file, and the dependency are
131
+ gone. `liminis-app/pnpm-workspace.yaml` carried a second, vestigial
132
+ `patchedDependencies` block that pnpm never read; it is removed and the file now
133
+ says plainly that nothing in it takes effect.
134
+
135
+ ### 4. The whole Lexical surface is peered, not just bare `lexical`.
136
+
137
+ `react`, `react-dom`, `lexical` and all eleven `@lexical/*` packages move to
138
+ `peerDependencies`, mirrored into `devDependencies` so the workspace install is
139
+ unchanged.
140
+
141
+ Peering bare `lexical` while shipping `@lexical/react` as a hard dependency still
142
+ permits two Lexical registries in a consumer's tree — the precise failure peering
143
+ exists to prevent. React is the same story with `Invalid hook call`. pnpm v8+ and
144
+ npm v7+ auto-install peers, so the consumer burden is small.
145
+
146
+ ### 5. `sideEffects` stays undeclared. The question is closed, not deferred.
147
+
148
+ ADR-075 §4 prohibits the field and deferred a re-decision to #940, on the theory
149
+ that an external consumer of *built* output would have a different module graph
150
+ and might legitimately need it.
151
+
152
+ That theory does not survive decision 1. Because the build performs no chunking
153
+ of its own, an external consumer's bundler sees the **same module graph**
154
+ `liminis-app`'s bundler sees today. The prismjs hazard is therefore identical,
155
+ not different, and the narrowing FR-011 would have permitted is not available.
156
+
157
+ The measurement the field was wanted for does not need it either: FR-006 points
158
+ at the `React.lazy()` boundary on `AnnotationSurface`, which is structural.
159
+
160
+ ADR-075 §4's prohibition stands unchanged, and its guard in
161
+ `editor-package-wiring.test.ts` stays. It is now closed rather than open.
162
+
163
+ ## Consequences
164
+
165
+ - **`pnpm build:editor` and `pnpm verify:package`** exist at the repository root.
166
+ `verify:package` builds, packs, installs the tarball into
167
+ `examples/external-consumer` with `--ignore-workspace`, type-checks it under
168
+ both `moduleResolution: "bundler"` and `"nodenext"`, builds three measurement
169
+ arms, and asserts the entry-graph boundaries. A new `package-build` CI job runs
170
+ `verify:package` — and only that, since it builds the package itself as its
171
+ first step. `build:editor` is the standalone convenience alias for a local
172
+ build without the whole verification cycle.
173
+
174
+ `--ignore-workspace` is load-bearing: without it pnpm links the package by
175
+ symlink and the tarball — the thing under test — is never resolved.
176
+
177
+ - **The measurements read a per-chunk `modules.json`, not the emitted
178
+ JavaScript.** Grepping bundled output for identifier substrings does not work:
179
+ minification loses the names, and a chunk *containing* the string `annotation`
180
+ says nothing about whether that chunk is ever loaded. What is asserted is the
181
+ **eager closure** — the entry chunk plus static imports, excluding dynamic
182
+ ones, because a `React.lazy()` chunk being separate is the mechanism under
183
+ test.
184
+
185
+ As measured when this landed: the `./markdown`-only arm is 91 modules with no
186
+ MathJax, no Lexical and no `react-dom`. The annotations-disabled arm loads no
187
+ annotation module eagerly across 655; `AnnotationSurface` appears only as an
188
+ async chunk. The enabled arm's identical probe finds four — the control that
189
+ keeps the first result from being a probe pointed in the wrong place.
190
+
191
+ Per ADR-077 the scope is `AnnotationSurface` and `annotations/` specifically,
192
+ *not* "the string `annotation` does not appear": unconditional `MarkNode`
193
+ registration and a statically-importable `annotationCommands.ts` are deliberate
194
+ exceptions and are expected in both arms.
195
+
196
+ - **Two manifests now exist for one package**, and they can drift. The wiring
197
+ test asserts that every subpath in `exports` is mirrored into
198
+ `publishConfig.exports` pointing at `dist/`; without that, adding a seventh
199
+ subpath would leave external consumers silently unable to import it while every
200
+ in-repo check stayed green.
201
+
202
+ - **`examples/` is outside the `packages/*` workspace glob** on purpose. A
203
+ fixture placed under `packages/` becomes a workspace member — pnpm links the
204
+ editor by symlink, and `pnpm -r typecheck`/`lint` pick it up — at which point
205
+ "external consumer" stops being true. `examples/demo/` and
206
+ `examples/external-consumer/` install the packed tarball instead.
207
+
208
+ - **The package ships a LICENSE (MIT) and consumer documentation** —
209
+ `README.md` plus `docs/{editor-api,markdown-pipeline,annotations}.md`, inside
210
+ the tarball. The MIT declaration is scoped to `packages/editor` alone; the rest
211
+ of the repository remains unlicensed and `liminis-app` remains `UNLICENSED`.
212
+
213
+ Two silent failure modes are documented explicitly because nothing in the
214
+ package can catch them: not importing `@liminis/editor/styles.css` (unstyled
215
+ editor, invisible annotation markers), and Tailwind v4 skipping `node_modules`
216
+ in source detection (the editor's utility classes simply absent). An external
217
+ consumer has no `@source` directive handed to them the way `liminis-app` does,
218
+ so the README tells them what to write.
219
+
220
+ - **An emitted-declaration global-leak probe** runs in `verify:package`.
221
+ `src/ambient/jsx.d.ts` performs a `declare global { namespace JSX }`
222
+ augmentation; had anything of that class reached `dist/`, every consumer would
223
+ inherit it. ADR-075 records the same class of defect caught once before, when
224
+ an `acquireVsCodeApi` global leaked through `.` and `./contract`.
225
+
226
+ - **Two of ADR-075's #940 deferrals are not addressed here** and are re-pointed
227
+ at a follow-up: a DOM-free tsconfig project enforcing `./headless`'s contract,
228
+ and splitting `mathjax-config.ts` into lite and browser halves. Neither appears
229
+ in #940's requirements, and both are independent of delivery. Leaving ADR-075's
230
+ text claiming #940 covers them would be wrong, so it no longer does.
231
+
232
+ - **Not done: publishing.** #940 proves publishability. The registry, versioning
233
+ and release-automation decisions are separate, and `private: true` remains as
234
+ the guard against doing it by accident.
235
+
236
+ ## References
237
+
238
+ - ADR-075 — the package boundary, the six-subpath inventory, the `sideEffects`
239
+ landmine and its reproduction
240
+ - ADR-077 — the annotation mechanism and its lazy boundary; the
241
+ host-must-import-`styles.css` contract
242
+ - Issue #940 (this ADR), #938 (carve the package), #939 (annotation mechanism),
243
+ #347 (wiki links inside markdown tables — the origin of the vendored fix)