@lovett/ui 0.0.11 → 0.2.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 (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,754 @@
1
+ /**
2
+ * markdown-spec — the editor side of the thread wire format.
3
+ *
4
+ * ADR-148 D3, D4, D13. `body_md` is markdown and stays markdown (D1): the rich
5
+ * editor is an editing surface over that string, never a second storage
6
+ * format. This file is the ONLY place the editor learns how the two syntaxes
7
+ * ADR-147 D11 / ADR-148 D13 add on top of CommonMark + GFM are spelled:
8
+ *
9
+ * @[Marcus Webb](user:usr_9f2) <-> a `mention` node (D4)
10
+ * ++underlined++ <-> the `underline` mark (D13)
11
+ *
12
+ * …plus the fail-closed detector `isRichEditable` (D3 rule 2), and the two
13
+ * helpers the composer and the tests both use, `parseRichMarkdown` /
14
+ * `serializeRichMarkdown`, so there is exactly one parse and one serialize
15
+ * path in the package.
16
+ *
17
+ * NOT re-exported from the package barrel, on purpose. Everything here imports
18
+ * TipTap statically, and the barrel must stay free of it so a thread LIST never
19
+ * pays for ProseMirror (D5, AC#10). The lazily loaded `rich-composer-impl.tsx`
20
+ * is the only production consumer; the tests import this file directly.
21
+ *
22
+ * THE UNDERLINE GRAMMAR, as agreed with the renderer side (`++text++`, no
23
+ * whitespace touching the markers, no `++` inside `++`) is deliberately
24
+ * STRICTER than the tokenizer StarterKit's own Underline ships — that one trims
25
+ * inner whitespace and spans lines — which is why StarterKit's copy is disabled
26
+ * and the mark is defined here. Same name, same commands, same `Mod-u`; only
27
+ * the markdown spelling is ours.
28
+ *
29
+ * ESCAPING. `@tiptap/markdown` backslash-escapes `` \ ` * _ [ ] ~ `` in text
30
+ * and HTML-encodes `& < >`, which already covers most of CommonMark's inline
31
+ * syntax and blockquotes. It does NOT escape `+` (so a literal `++x++` typed in
32
+ * the editor would come back as an underline) or block starters (`# `, `- `,
33
+ * `1. `, `---`), so a paragraph typed as "- not a list" would come back as a
34
+ * list. Those are handled here, before the manager runs, with a private-use
35
+ * sentinel that stands in for the backslash: the manager would double any real
36
+ * backslash it saw, and it would double-encode any entity. The sentinel is
37
+ * swapped for `\` after serialization. A U+E000 already present in user text is
38
+ * dropped first; it is a private-use code point with no meaning to keep.
39
+ */
40
+
41
+ import {
42
+ Editor,
43
+ Extension,
44
+ InputRule,
45
+ Mark,
46
+ mergeAttributes,
47
+ type Extensions,
48
+ type JSONContent,
49
+ type MarkdownToken,
50
+ type Range,
51
+ markInputRule,
52
+ } from '@tiptap/core'
53
+ import { Mention, type MentionNodeAttrs, type MentionOptions } from '@tiptap/extension-mention'
54
+ import { Markdown, type MarkdownManager } from '@tiptap/markdown'
55
+ import { Node as ProseMirrorNode, type Schema } from '@tiptap/pm/model'
56
+ import { PluginKey } from '@tiptap/pm/state'
57
+ import { StarterKit } from '@tiptap/starter-kit'
58
+ import { parseMentionHref, type MentionTargetType } from './comment-body'
59
+ import { emojiByShortcode, searchEmoji } from './emoji'
60
+
61
+ declare module '@tiptap/core' {
62
+ interface Commands<ReturnType> {
63
+ underline: {
64
+ setUnderline: () => ReturnType
65
+ toggleUnderline: () => ReturnType
66
+ unsetUnderline: () => ReturnType
67
+ }
68
+ }
69
+ }
70
+
71
+ /* ── Underline (D13) ──────────────────────────────────────────────────────── */
72
+
73
+ const UNDERLINE_MARKER = '++'
74
+
75
+ /**
76
+ * `++text++`: the inner text starts and ends with a non-space, contains no
77
+ * `++` (so no nesting) and no newline. Anything else is literal text, which is
78
+ * what the renderer side does with it too.
79
+ *
80
+ * A backslash escape is consumed as ONE unit, so the closer can never begin
81
+ * inside one: in `++C\+\+++` the escaped pluses are text and the last two
82
+ * characters are the closer. Without this the lazy match ended at the `+` of
83
+ * `\+` and the serialized form did not re-parse to itself.
84
+ */
85
+ const UNDERLINE_PATTERN = /^\+\+(?=\S)((?:\\[^\n]|(?!\+\+)[^\n\\])+?)(?<=\S)\+\+/
86
+
87
+ interface UnderlineOptions {
88
+ readonly HTMLAttributes: Record<string, unknown>
89
+ }
90
+
91
+ /**
92
+ * The `++text++` input rule's pattern. Exported so it can be tested without an
93
+ * Editor: input rules only fire on real text input, which jsdom cannot
94
+ * faithfully simulate, and the regex is where a bug would actually live. The
95
+ * end-to-end behaviour is verified in a browser.
96
+ *
97
+ * STRICTER than TipTap's own bold/strike rules, deliberately, because it has to
98
+ * agree with the RENDERER. `lib/remark-underline.ts` rejects whitespace
99
+ * immediately inside either marker; TipTap's `(?!\s+\*\*)` shape does not, so a
100
+ * copy of it fired on `++ under++` — the editor would have drawn an underline
101
+ * that the renderer then showed as literal plus signs. The lookarounds are the
102
+ * fix: `(?!\s)` after the opener, `(?<!\s)` before the closer, and a lazy
103
+ * `[^+]+?` so a run cannot swallow a second pair. (Lookbehind is ES2018,
104
+ * Safari 16.4+ — the same floor `overflow: clip` already sets.)
105
+ */
106
+ export const UNDERLINE_INPUT_RULE = /(?:^|\s)(\+\+(?!\s)([^+]+?)(?<!\s)\+\+)$/
107
+
108
+ export const ThreadUnderline = Mark.create<UnderlineOptions>({
109
+ name: 'underline',
110
+
111
+ addOptions() {
112
+ return { HTMLAttributes: {} }
113
+ },
114
+
115
+ parseHTML() {
116
+ return [
117
+ { tag: 'u' },
118
+ {
119
+ style: 'text-decoration',
120
+ consuming: false,
121
+ getAttrs: (style) =>
122
+ typeof style === 'string' && style.includes('underline') ? {} : false,
123
+ },
124
+ ]
125
+ },
126
+
127
+ renderHTML({ HTMLAttributes }) {
128
+ return ['u', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
129
+ },
130
+
131
+ addCommands() {
132
+ return {
133
+ setUnderline:
134
+ () =>
135
+ ({ commands }) =>
136
+ commands.setMark(this.name),
137
+ toggleUnderline:
138
+ () =>
139
+ ({ commands }) =>
140
+ commands.toggleMark(this.name),
141
+ unsetUnderline:
142
+ () =>
143
+ ({ commands }) =>
144
+ commands.unsetMark(this.name),
145
+ }
146
+ },
147
+
148
+ addKeyboardShortcuts() {
149
+ return {
150
+ 'Mod-u': () => this.editor.commands.toggleUnderline(),
151
+ 'Mod-U': () => this.editor.commands.toggleUnderline(),
152
+ }
153
+ },
154
+
155
+ /**
156
+ * Type `++text++` and it becomes underlined, the moment the closing `++`
157
+ * lands — the same as `**bold**` and `~~strike~~`, which StarterKit gives us
158
+ * for free. Without this the mark had commands, a shortcut and full markdown
159
+ * serialization but no way to reach it by TYPING, which is the way people
160
+ * who know markdown actually reach it. (Owner, 2026-09-07: "is it not like
161
+ * obsidian where you type them in and it autoformats as you type?")
162
+ *
163
+ * The pattern mirrors TipTap's own bold rule, with `+` swapped for `*`:
164
+ * - `(?:^|\s)` — the run starts at the line start or after whitespace, so
165
+ * `i++;` and `C++` are never rules, matching the renderer's flanking rule
166
+ * (D13) rather than fighting it.
167
+ * - `(?!\s+\+\+)` — no whitespace immediately inside the opener.
168
+ * - `[^+]+` — the content cannot itself contain `+`, so `+++` runs and
169
+ * `a++b++c++d` degrade to text instead of matching greedily.
170
+ */
171
+ addInputRules() {
172
+ return [markInputRule({ find: UNDERLINE_INPUT_RULE, type: this.type })]
173
+ },
174
+
175
+ markdownTokenizer: {
176
+ name: 'underline',
177
+ level: 'inline',
178
+ start: (src) => src.indexOf(UNDERLINE_MARKER),
179
+ tokenize: (src, _tokens, lexer) => {
180
+ const match = UNDERLINE_PATTERN.exec(src)
181
+ const inner = match?.[1]
182
+ if (match === null || inner === undefined) return undefined
183
+ return { type: 'underline', raw: match[0], text: inner, tokens: lexer.inlineTokens(inner) }
184
+ },
185
+ },
186
+
187
+ parseMarkdown: (token, helpers) =>
188
+ helpers.applyMark('underline', helpers.parseInline(token.tokens ?? [])),
189
+
190
+ renderMarkdown: (node, helpers) => `++${helpers.renderChildren(node)}++`,
191
+ })
192
+
193
+ /* ── Mention (D4) ─────────────────────────────────────────────────────────── */
194
+
195
+ /**
196
+ * The Suggestion plugin key the `@` picker (ADR-149) hangs off. Exported so
197
+ * the composer can read the plugin state — "is a Suggestion popup open?" is
198
+ * what D7 (no bubble menu while it is) and D9 (Escape closes it second) need.
199
+ */
200
+ export const THREAD_MENTION_SUGGESTION_KEY = new PluginKey('threadMentionSuggestion')
201
+
202
+ /** `@[label](scheme:id)`; the label may carry `\]` / `\[` / `\\` escapes. */
203
+ const MENTION_PATTERN = /^@\[((?:\\.|[^\]\\\n])+)\]\(([^)\s]+)\)/
204
+
205
+ function escapeMentionLabel(label: string): string {
206
+ return label.replace(/[\\[\]]/g, (char) => `\\${char}`)
207
+ }
208
+
209
+ function unescapeMentionLabel(raw: string): string {
210
+ return raw.replace(/\\(.)/g, '$1')
211
+ }
212
+
213
+ export interface ThreadMentionAttrs {
214
+ readonly id: string
215
+ readonly label: string
216
+ readonly type: MentionTargetType
217
+ }
218
+
219
+ /** Narrow a node's attrs to a mention without a cast. Null for anything malformed. */
220
+ function readMentionAttrs(attrs: Record<string, unknown> | undefined): ThreadMentionAttrs | null {
221
+ if (attrs === undefined) return null
222
+ const id = attrs['id']
223
+ const label = attrs['label']
224
+ const type = attrs['type']
225
+ if (typeof id !== 'string' || id === '') return null
226
+ if (typeof type !== 'string') return null
227
+ const target = parseMentionHref(`${type}:${id}`)
228
+ if (target === null) return null
229
+ return { id: target.id, label: typeof label === 'string' && label !== '' ? label : id, type: target.type }
230
+ }
231
+
232
+ export const ThreadMention = Mention.extend({
233
+ addAttributes() {
234
+ return {
235
+ ...this.parent?.(),
236
+ type: {
237
+ default: 'user',
238
+ parseHTML: (element) => element.getAttribute('data-mention-type'),
239
+ renderHTML: (attributes) => ({ 'data-mention-type': attributes['type'] }),
240
+ },
241
+ }
242
+ },
243
+
244
+ markdownTokenizer: {
245
+ name: 'mention',
246
+ level: 'inline',
247
+ start: (src) => src.indexOf('@['),
248
+ tokenize: (src) => {
249
+ const match = MENTION_PATTERN.exec(src)
250
+ const rawLabel = match?.[1]
251
+ const href = match?.[2]
252
+ if (match === null || rawLabel === undefined || href === undefined) return undefined
253
+ // Any other `@[…](…)` — an https link, an unknown scheme — is NOT a
254
+ // mention. Returning undefined hands it back to marked, which tokenizes
255
+ // it as a plain link, exactly as the renderer treats it.
256
+ const target = parseMentionHref(href)
257
+ if (target === null) return undefined
258
+ return {
259
+ type: 'mention',
260
+ raw: match[0],
261
+ label: unescapeMentionLabel(rawLabel),
262
+ id: target.id,
263
+ mentionType: target.type,
264
+ }
265
+ },
266
+ },
267
+
268
+ parseMarkdown: (token) => ({
269
+ type: 'mention',
270
+ attrs: {
271
+ id: token['id'],
272
+ label: token['label'],
273
+ type: token['mentionType'],
274
+ mentionSuggestionChar: '@',
275
+ },
276
+ }),
277
+
278
+ renderMarkdown: (node) => {
279
+ const mention = readMentionAttrs(node.attrs)
280
+ // A mention node without a valid target cannot reach the wire: it would
281
+ // serialize to something the server-side extractor would refuse. Its
282
+ // label survives as text so nothing the user wrote disappears.
283
+ if (mention === null) return typeof node.attrs?.['label'] === 'string' ? `@${node.attrs['label']}` : ''
284
+ return `@[${escapeMentionLabel(mention.label)}](${mention.type}:${mention.id})`
285
+ },
286
+ })
287
+
288
+ /* ── The subset (D2 / D3) ─────────────────────────────────────────────────── */
289
+
290
+ export const RICH_HEADING_LEVELS = [1, 2, 3] as const
291
+ const RICH_HEADING_LEVEL_SET: ReadonlySet<number> = new Set<number>(RICH_HEADING_LEVELS)
292
+
293
+ type MentionSuggestion = MentionOptions['suggestion']
294
+
295
+ /**
296
+ * How many characters after the sigil before the popup may open (ADR-149 D4).
297
+ *
298
+ * A bare `:` is punctuation and far more common in prose than an emoji, so it
299
+ * takes two; a bare `@` is rare enough that one is right. Below the threshold
300
+ * the plugin hands the renderer an EMPTY item list, which is what closes the
301
+ * popup — the same door "no matches" comes through, so there is one rule in
302
+ * the UI and not two.
303
+ */
304
+ export const MENTION_SUGGESTION_MIN_QUERY = 1
305
+ export const EMOJI_SUGGESTION_MIN_QUERY = 2
306
+
307
+ /**
308
+ * The `:` trigger's Suggestion plugin key, beside the `@` one above. Two keys
309
+ * because they are two plugins; ONE popup, because they are one interaction
310
+ * (D1) — the difference is data and rendering, never behaviour.
311
+ */
312
+ export const THREAD_EMOJI_SUGGESTION_KEY = new PluginKey('threadEmojiSuggestion')
313
+
314
+ /**
315
+ * A complete, typed shortcode at the end of the current textblock. The shape
316
+ * is deliberately only the lexical first pass: `EMOJI_INPUT_RULE` then asks
317
+ * the curated table whether the spelling is a real name or alias. Keywords
318
+ * are search aids, not shortcodes, and must not make prose transform.
319
+ */
320
+ const EMOJI_SHORTCODE_AT_END = /(?:^|\s)(:([^:\s]+):)$/
321
+
322
+ /**
323
+ * Slack-style closing-colon matcher, exported for a direct test. TipTap input
324
+ * rules only run on real text input, while `insertContent` bypasses them.
325
+ */
326
+ export const EMOJI_INPUT_RULE = (text: string) => {
327
+ const match = EMOJI_SHORTCODE_AT_END.exec(text)
328
+ const shortcode = match?.[1]
329
+ if (match === null || shortcode === undefined || emojiByShortcode(shortcode) === undefined) {
330
+ return null
331
+ }
332
+ return { index: match.index, text: match[0], data: { shortcode } }
333
+ }
334
+
335
+ const ThreadEmojiShortcode = Extension.create({
336
+ name: 'threadEmojiShortcode',
337
+
338
+ addInputRules() {
339
+ return [
340
+ new InputRule({
341
+ find: EMOJI_INPUT_RULE,
342
+ handler: ({ state, range, match }) => {
343
+ const shortcode = match.data?.['shortcode']
344
+ const entry = typeof shortcode === 'string' ? emojiByShortcode(shortcode) : undefined
345
+ if (entry === undefined || typeof shortcode !== 'string') return null
346
+ state.tr.insertText(entry.glyph, range.to - shortcode.length, range.to)
347
+ return undefined
348
+ },
349
+ }),
350
+ ]
351
+ },
352
+ })
353
+
354
+ /**
355
+ * What the `:` picker hands back when an option is chosen.
356
+ *
357
+ * `id` is the canonical shortcode, and it is deliberately the ONLY thing that
358
+ * crosses the seam: `@tiptap/extension-mention` types its `command` payload as
359
+ * `MentionNodeAttrs`, so an emoji-shaped payload would not type-check through
360
+ * it. Passing the shortcode and re-resolving the glyph from the table on the
361
+ * other side keeps this honest with zero casts, and means the insert can never
362
+ * write a glyph that is not in the table.
363
+ */
364
+ export interface EmojiSuggestionAttrs {
365
+ readonly id: string
366
+ readonly label: string
367
+ }
368
+
369
+ /**
370
+ * D2 — an emoji is inserted as UNICODE, never as a shortcode. `body_md` then
371
+ * contains the character, so every render site (the digest, an export, an
372
+ * agent's context, a plain-text copy) shows it without a lookup table.
373
+ *
374
+ * The trailing space, and the guard against doubling it, mirror the mention
375
+ * command exactly, so choosing from either popup leaves the caret in the same
376
+ * place.
377
+ */
378
+ function insertEmoji({
379
+ editor,
380
+ range,
381
+ props,
382
+ }: {
383
+ editor: Editor
384
+ range: Range
385
+ props: MentionNodeAttrs
386
+ }): void {
387
+ const entry = emojiByShortcode(props.id ?? '')
388
+ if (entry === undefined) return
389
+ const nodeAfter = editor.view.state.selection.$to.nodeAfter
390
+ const to = nodeAfter?.text?.startsWith(' ') === true ? range.to + 1 : range.to
391
+ editor
392
+ .chain()
393
+ .focus()
394
+ .insertContentAt({ from: range.from, to }, [{ type: 'text', text: `${entry.glyph} ` }])
395
+ .run()
396
+ }
397
+
398
+ /** The `:` picker's options, in the shape the Suggestion payload accepts. */
399
+ function emojiSuggestionItems(query: string): EmojiSuggestionAttrs[] {
400
+ return searchEmoji(query).map((entry) => ({ id: entry.name, label: entry.glyph }))
401
+ }
402
+
403
+ /**
404
+ * The two trigger configurations (D1). The DATA half — what each trigger
405
+ * offers and what choosing does — lives here; the RENDERING half is one
406
+ * `render()` per trigger, supplied by the composer, pointing at one popup.
407
+ */
408
+ export interface RichSuggestionRenderers {
409
+ /** The `@` trigger. Absent, the mention popup never opens. */
410
+ readonly mention?: Partial<MentionSuggestion> | undefined
411
+ /** The `:` trigger. Absent, the emoji popup never opens. */
412
+ readonly emoji?: Partial<MentionSuggestion> | undefined
413
+ }
414
+
415
+ /**
416
+ * The editor's extension set — the D3 subset, written down (ADR-148 D2).
417
+ *
418
+ * StarterKit is configured explicitly so a future minor cannot admit a
419
+ * construct silently. Today the only things it does not keep at default are
420
+ * the heading levels (1–3), the link options an EDITOR needs (a click must
421
+ * edit, not navigate), and `underline: false` — replaced by `ThreadUnderline`
422
+ * above, same name and commands, strict `++` grammar.
423
+ *
424
+ * BOTH triggers hang off the ONE Mention extension, through its `suggestions`
425
+ * array. That is not a trick: `@tiptap/suggestion` is a transitive dependency
426
+ * with no direct entry in `@lovett/ui`'s `package.json`, so under pnpm's
427
+ * strict layout it cannot be imported from this package, and ADR-148 D2
428
+ * deliberately did not install it ("zero new runtime dependencies",
429
+ * ADR-149 AC#1). `suggestions` is the extension's own supported way to run a
430
+ * second trigger, and it gives the `:` plugin the same match-finding,
431
+ * dismissal and floating-ui anchoring the `@` one already had. The `:` trigger
432
+ * never creates a mention node — its `command` inserts text (D2).
433
+ */
434
+ export function richSubsetExtensions(renderers: RichSuggestionRenderers = {}): Extensions {
435
+ return [
436
+ StarterKit.configure({
437
+ heading: { levels: [...RICH_HEADING_LEVELS] },
438
+ // horizontalRule: default, IN — owner, 2026-09-07.
439
+ underline: false,
440
+ link: {
441
+ openOnClick: false,
442
+ autolink: true,
443
+ linkOnPaste: true,
444
+ enableClickSelection: false,
445
+ },
446
+ }),
447
+ ThreadUnderline,
448
+ ThreadEmojiShortcode,
449
+ ThreadMention.configure({
450
+ HTMLAttributes: { class: 'ds-thread-mention' },
451
+ suggestions: [
452
+ {
453
+ char: '@',
454
+ pluginKey: THREAD_MENTION_SUGGESTION_KEY,
455
+ minQueryLength: MENTION_SUGGESTION_MIN_QUERY,
456
+ ...renderers.mention,
457
+ },
458
+ {
459
+ char: ':',
460
+ pluginKey: THREAD_EMOJI_SUGGESTION_KEY,
461
+ minQueryLength: EMOJI_SUGGESTION_MIN_QUERY,
462
+ // The plugin's own view of what it is offering. The POPUP does not
463
+ // read this — it recomputes from the query, because
464
+ // `@tiptap/suggestion` types its items as `any` and the composer
465
+ // does not take `any` — but leaving it unset would make the plugin's
466
+ // state say "no items" while eight are on screen, which is a lie any
467
+ // future reader of that state would trip over.
468
+ items: ({ query }: { query: string }) => emojiSuggestionItems(query),
469
+ command: insertEmoji,
470
+ // A shortcode typed inside code stays literal: `:tada:` in a snippet
471
+ // is text about an emoji, not an emoji. The mention trigger keeps
472
+ // the extension's own default, which asks the schema whether a
473
+ // mention node may sit here.
474
+ allow: ({ editor }: { editor: Editor }) =>
475
+ !editor.isActive('codeBlock') && !editor.isActive('code'),
476
+ ...renderers.emoji,
477
+ },
478
+ ],
479
+ }),
480
+ Markdown,
481
+ ]
482
+ }
483
+
484
+ /* ── Headless probe ───────────────────────────────────────────────────────── */
485
+
486
+ let probe: Editor | null = null
487
+
488
+ /**
489
+ * One headless editor over the subset, created on first use. It owns the
490
+ * `MarkdownManager` every parse and serialize below goes through and the
491
+ * schema the detector checks against — so the detector, the tests and the live
492
+ * composer agree by construction, not by keeping three configurations in step.
493
+ */
494
+ function getProbe(): Editor {
495
+ if (probe === null) {
496
+ probe = new Editor({ element: null, extensions: richSubsetExtensions(), content: '' })
497
+ }
498
+ return probe
499
+ }
500
+
501
+ function getManager(): MarkdownManager {
502
+ const manager = getProbe().markdown
503
+ if (manager === undefined) {
504
+ throw new Error('rich composer: the Markdown extension did not attach a manager')
505
+ }
506
+ return manager
507
+ }
508
+
509
+ function getSchema(): Schema {
510
+ return getProbe().schema
511
+ }
512
+
513
+ /* ── Parse / serialize ────────────────────────────────────────────────────── */
514
+
515
+ export function parseRichMarkdown(markdown: string): JSONContent {
516
+ return canonicalizeInlineMarks(getManager().parse(markdown))
517
+ }
518
+
519
+ /* ── Canonical mark boundaries ────────────────────────────────────────────── */
520
+
521
+ /**
522
+ * Marks that markdown cannot open or close on whitespace. `*a *` is not
523
+ * emphasis and `` ` a ` `` is `a`; a link, by contrast, keeps `[a ](url)`.
524
+ */
525
+ const BOUNDARY_MARKS: readonly string[] = ['bold', 'italic', 'underline', 'strike', 'code']
526
+
527
+ function hasMark(node: JSONContent, type: string): boolean {
528
+ return (node.marks ?? []).some((mark) => mark.type === type)
529
+ }
530
+
531
+ function withoutMark(node: JSONContent, type: string): JSONContent {
532
+ const marks = (node.marks ?? []).filter((mark) => mark.type !== type)
533
+ const { marks: _marks, ...rest } = node
534
+ return marks.length === 0 ? rest : { ...rest, marks }
535
+ }
536
+
537
+ /**
538
+ * Push the whitespace at either end of every maximal run of `type`-marked text
539
+ * OUT of the mark — into a node that keeps the run's other marks. One mark at
540
+ * a time, so `**bold with *italic inside* it**` is untouched (the bold run is
541
+ * all three nodes; the italic run has no boundary whitespace) while
542
+ * `*quarter @[…] **creative***` — italic on the space beside a mention, which
543
+ * no serializer can spell — settles into the one form that re-parses to
544
+ * itself. This is the canonicalization D3 rule 1 allows, applied where the
545
+ * manager's own output would otherwise not be a fixed point.
546
+ */
547
+ function trimMarkRuns(content: readonly JSONContent[], type: string): JSONContent[] {
548
+ const out: JSONContent[] = []
549
+ let run: JSONContent[] = []
550
+ const flush = () => {
551
+ if (run.length === 0) return
552
+ const first = run[0]
553
+ const last = run[run.length - 1]
554
+ if (first === undefined || last === undefined) return
555
+ const leading = /^\s+/.exec(first.text ?? '')?.[0] ?? ''
556
+ if (leading !== '' && leading.length < (first.text ?? '').length) {
557
+ run[0] = { ...first, text: (first.text ?? '').slice(leading.length) }
558
+ out.push(withoutMark({ ...first, text: leading }, type))
559
+ } else if (leading !== '') {
560
+ run[0] = withoutMark(first, type)
561
+ }
562
+ const tail = run[run.length - 1]
563
+ if (tail !== undefined && run.length >= 1 && !(run.length === 1 && leading !== '' && leading.length >= (first.text ?? '').length)) {
564
+ const trailing = /\s+$/.exec(tail.text ?? '')?.[0] ?? ''
565
+ if (trailing !== '' && trailing.length < (tail.text ?? '').length) {
566
+ run[run.length - 1] = { ...tail, text: (tail.text ?? '').slice(0, -trailing.length) }
567
+ out.push(...run)
568
+ out.push(withoutMark({ ...tail, text: trailing }, type))
569
+ run = []
570
+ return
571
+ }
572
+ if (trailing !== '' && run.length > 1) {
573
+ run[run.length - 1] = withoutMark(tail, type)
574
+ }
575
+ }
576
+ out.push(...run)
577
+ run = []
578
+ }
579
+ for (const node of content) {
580
+ if (node.type === 'text' && hasMark(node, type)) {
581
+ run.push(node)
582
+ continue
583
+ }
584
+ flush()
585
+ out.push(node)
586
+ }
587
+ flush()
588
+ return out
589
+ }
590
+
591
+ function canonicalizeInlineMarks(node: JSONContent): JSONContent {
592
+ const content = node.content
593
+ if (content === undefined) return node
594
+ if (node.type === 'paragraph' || node.type === 'heading') {
595
+ let inline: JSONContent[] = [...content]
596
+ for (const type of BOUNDARY_MARKS) inline = trimMarkRuns(inline, type)
597
+ return { ...node, content: inline }
598
+ }
599
+ return { ...node, content: content.map(canonicalizeInlineMarks) }
600
+ }
601
+
602
+ /**
603
+ * Run a document through the schema: attrs get their defaults, invalid
604
+ * content throws. `parse → normalize` is what `editor.setContent` produces,
605
+ * so this is what the round-trip tests compare.
606
+ */
607
+ export function normalizeRichDocument(doc: JSONContent): JSONContent {
608
+ const node = ProseMirrorNode.fromJSON(getSchema(), doc)
609
+ node.check()
610
+ return node.toJSON()
611
+ }
612
+
613
+ const ESCAPE_SENTINEL = '\u{E000}'
614
+
615
+ function hasCodeMark(node: JSONContent): boolean {
616
+ return (node.marks ?? []).some((mark) => mark.type === 'code')
617
+ }
618
+
619
+ /** Block starters the manager does not escape; `>` it already entity-encodes. */
620
+ function escapeBlockStart(text: string): string {
621
+ return text
622
+ .replace(/^(#{1,6})(?=[ \t]|$)/, (hashes) => hashes.replace(/#/g, `${ESCAPE_SENTINEL}#`))
623
+ .replace(/^([-+])(?=[ \t]|$)/, `${ESCAPE_SENTINEL}$1`)
624
+ .replace(/^(\d{1,9})([.)])(?=[ \t]|$)/, `$1${ESCAPE_SENTINEL}$2`)
625
+ .replace(/^-{3,}[ \t]*$/, (rule) => `${ESCAPE_SENTINEL}${rule}`)
626
+ }
627
+
628
+ function escapePlusRuns(text: string): string {
629
+ return text.replace(/\+{2,}/g, (run) => run.replace(/\+/g, `${ESCAPE_SENTINEL}+`))
630
+ }
631
+
632
+ /**
633
+ * Mark, in a COPY of the tree, every character the manager would let through
634
+ * unescaped. Code is left alone at every level: inside a code block or a code
635
+ * mark the characters are literal already.
636
+ */
637
+ function markLiteralSyntax(node: JSONContent): JSONContent {
638
+ if (node.type === 'codeBlock') return node
639
+ const content = node.content
640
+ if (content === undefined) return node
641
+ let lineStart = node.type === 'paragraph' || node.type === 'heading'
642
+ const next = content.map((child): JSONContent => {
643
+ if (child.type === 'text') {
644
+ const startsLine = lineStart
645
+ lineStart = false
646
+ if (hasCodeMark(child)) return child
647
+ const source = (child.text ?? '').replaceAll(ESCAPE_SENTINEL, '')
648
+ let text = escapePlusRuns(source)
649
+ if (startsLine) text = escapeBlockStart(text)
650
+ return text === child.text ? child : { ...child, text }
651
+ }
652
+ if (child.type === 'hardBreak') {
653
+ lineStart = true
654
+ return child
655
+ }
656
+ lineStart = false
657
+ return markLiteralSyntax(child)
658
+ })
659
+ return { ...node, content: next }
660
+ }
661
+
662
+ export function serializeRichMarkdown(doc: JSONContent): string {
663
+ // The same boundary rule on the way out: a mark the editor put on a trailing
664
+ // space cannot be spelled, so it is moved off the space before the manager
665
+ // sees it, and the output re-parses to exactly what was serialized.
666
+ // Trailing blank lines are not markdown: CommonMark parses `x\n\n` and `x`
667
+ // to the same tree, and the editor always ends in an empty paragraph (the
668
+ // trailing node that lets a caret land after a final list or code block).
669
+ // Trimming the end is what makes the serialized form a fixed point.
670
+ return getManager()
671
+ .serialize(markLiteralSyntax(canonicalizeInlineMarks(doc)))
672
+ .replaceAll(ESCAPE_SENTINEL, '\\')
673
+ .trimEnd()
674
+ }
675
+
676
+ /* ── Fail-closed detector (D3 rule 2) ─────────────────────────────────────── */
677
+
678
+ /**
679
+ * Every token marked's lexer may emit for a body the subset can hold. Anything
680
+ * not on this list — `table`, `html`, `image`, a token a future marked adds —
681
+ * fails closed, which is the point: the list is what we can VOUCH for, not
682
+ * what we happen to have seen.
683
+ */
684
+ const SUPPORTED_TOKENS: ReadonlySet<string> = new Set([
685
+ 'space',
686
+ 'paragraph',
687
+ 'heading',
688
+ 'hr',
689
+ 'blockquote',
690
+ 'list',
691
+ 'list_item',
692
+ 'code',
693
+ 'def',
694
+ 'text',
695
+ 'strong',
696
+ 'em',
697
+ 'del',
698
+ 'codespan',
699
+ 'link',
700
+ 'br',
701
+ 'escape',
702
+ 'underline',
703
+ 'mention',
704
+ ])
705
+
706
+ function tokensSupported(tokens: readonly MarkdownToken[]): boolean {
707
+ return tokens.every((token) => {
708
+ const type = token.type
709
+ if (type === undefined || !SUPPORTED_TOKENS.has(type)) return false
710
+ if (type === 'heading' && !RICH_HEADING_LEVEL_SET.has(token.depth ?? 1)) return false
711
+ if (type === 'list_item' && token['task'] === true) return false
712
+ const children: MarkdownToken[] = [...(token.tokens ?? []), ...(token.items ?? [])]
713
+ return tokensSupported(children)
714
+ })
715
+ }
716
+
717
+ function documentSupported(node: JSONContent): boolean {
718
+ if (node.type === 'heading') {
719
+ const level = node.attrs?.['level']
720
+ if (typeof level !== 'number' || !RICH_HEADING_LEVEL_SET.has(level)) return false
721
+ }
722
+ if (node.type === 'mention' && readMentionAttrs(node.attrs) === null) return false
723
+ return (node.content ?? []).every(documentSupported)
724
+ }
725
+
726
+ /**
727
+ * May this body open in the rich editor?
728
+ *
729
+ * Two checks, because they fail differently. The token scan catches syntax
730
+ * the schema could *absorb* but must not — raw `<u>` would become an underline
731
+ * mark and be re-serialized as `++`, which is exactly the byte change rule 2
732
+ * forbids. The schema check (`Node.fromJSON` + `check()`, plus the attrs the
733
+ * schema does not validate) catches anything the parse emits that the editor
734
+ * cannot hold. Either failing means the plain composer, source untouched.
735
+ */
736
+ export function isRichEditable(markdown: string): boolean {
737
+ if (markdown.trim() === '') return true
738
+ const manager = getManager()
739
+ let tokens: readonly MarkdownToken[]
740
+ try {
741
+ tokens = manager.instance.lexer(markdown)
742
+ } catch {
743
+ return false
744
+ }
745
+ if (!tokensSupported(tokens)) return false
746
+ try {
747
+ const doc = manager.parse(markdown)
748
+ if (!documentSupported(doc)) return false
749
+ normalizeRichDocument(doc)
750
+ return true
751
+ } catch {
752
+ return false
753
+ }
754
+ }