@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,469 @@
1
+ /**
2
+ * markdown-spec — the round-trip ADR-148 actually promises.
3
+ *
4
+ * AC#2 is PROPERTY-TESTED: a generator over D3's grammar (hand-rolled, seeded —
5
+ * no fast-check, no new dependency) produces markdown, and for every sample
6
+ * `parse → serialize → parse` yields an identical editor document and
7
+ * `serialize(parse(serialized)) === serialized`. Documents are compared after
8
+ * `normalizeRichDocument`, which is what `editor.setContent` produces — attrs
9
+ * with their defaults, content validated — so this is `editor.getJSON()`
10
+ * equality without a DOM.
11
+ *
12
+ * Deterministic on purpose (CLAUDE.md §7): the PRNG is mulberry32 with a fixed
13
+ * seed, so a failure names a reproducible sample.
14
+ */
15
+
16
+ import { describe, expect, it } from 'vitest'
17
+ import {
18
+ EMOJI_INPUT_RULE,
19
+ UNDERLINE_INPUT_RULE,
20
+ isRichEditable,
21
+ normalizeRichDocument,
22
+ parseRichMarkdown,
23
+ serializeRichMarkdown,
24
+ } from '../markdown-spec'
25
+
26
+ /* ── Seeded PRNG + generator over D3's grammar ────────────────────────────── */
27
+
28
+ type Rng = () => number
29
+
30
+ function mulberry32(seed: number): Rng {
31
+ let a = seed >>> 0
32
+ return () => {
33
+ a = (a + 0x6d2b79f5) >>> 0
34
+ let t = a
35
+ t = Math.imul(t ^ (t >>> 15), t | 1)
36
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
37
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
38
+ }
39
+ }
40
+
41
+ function pick<T>(rng: Rng, items: readonly T[]): T {
42
+ const item = items[Math.floor(rng() * items.length)]
43
+ if (item === undefined) throw new Error('empty choice')
44
+ return item
45
+ }
46
+
47
+ function int(rng: Rng, min: number, max: number): number {
48
+ return min + Math.floor(rng() * (max - min + 1))
49
+ }
50
+
51
+ /** Plain words, plus words carrying LITERAL syntax already escaped as markdown. */
52
+ const WORDS = [
53
+ 'flight',
54
+ 'pacing',
55
+ 'brief',
56
+ 'client',
57
+ 'quarter',
58
+ 'creative',
59
+ 'holds',
60
+ 'a\\*b',
61
+ 'c\\_d',
62
+ 'x\\[y\\]',
63
+ 'p\\+\\+q',
64
+ '\\~t',
65
+ 'v1.2',
66
+ 'C\\+\\+',
67
+ 'a&b',
68
+ 'x<y',
69
+ ] as const
70
+
71
+ const URLS = [
72
+ 'https://example.com/plan',
73
+ 'https://example.com/a?b=1&c=2',
74
+ 'mailto:team@example.com',
75
+ ] as const
76
+
77
+ const MENTIONS = [
78
+ '@[Marcus Webb](user:usr_9f2)',
79
+ '@[Slice B migration](task_item:tit_44a)',
80
+ '@[Board](task_board:tbd_1)',
81
+ ] as const
82
+
83
+ type Wrap = 'bold' | 'italic' | 'underline' | 'strike'
84
+
85
+ function word(rng: Rng): string {
86
+ return pick(rng, WORDS)
87
+ }
88
+
89
+ function inline(rng: Rng, depth: number, open: ReadonlySet<Wrap>, inLink: boolean): string {
90
+ const count = int(rng, 1, 3)
91
+ const parts: string[] = []
92
+ for (let index = 0; index < count; index += 1) {
93
+ // Inside a wrap, the FIRST item is never another wrap: an emphasis run
94
+ // that opens with a nested one (`***a* *b***`) is valid CommonMark that
95
+ // marked mis-parses — pinned below as a known limitation, kept out of
96
+ // the property by construction rather than by luck.
97
+ const first = index === 0 && open.size > 0
98
+ parts.push(first ? word(rng) : inlineItem(rng, depth, open, inLink))
99
+ }
100
+ return parts.join(' ')
101
+ }
102
+
103
+ function inlineItem(rng: Rng, depth: number, open: ReadonlySet<Wrap>, inLink: boolean): string {
104
+ const roll = rng()
105
+ if (depth >= 2 || roll < 0.45) return word(rng)
106
+ // A code span only where no mark is open: TipTap's `code` mark excludes
107
+ // every other mark, so emphasis or a link OVER a code span is outside what
108
+ // the schema can hold — the detector fails it closed (asserted below).
109
+ if (roll < 0.55 && open.size === 0 && !inLink) return `\`${word(rng).replace(/\\/g, '')}\``
110
+ if (roll < 0.63 && !inLink) return pick(rng, MENTIONS)
111
+ if (roll < 0.72 && !inLink) return `[${inline(rng, depth + 1, open, true)}](${pick(rng, URLS)})`
112
+ const wraps: readonly Wrap[] = (['bold', 'italic', 'underline', 'strike'] as const).filter(
113
+ (wrap) => !open.has(wrap),
114
+ )
115
+ if (wraps.length === 0) return word(rng)
116
+ const wrap = pick(rng, wraps)
117
+ const next = new Set(open)
118
+ next.add(wrap)
119
+ const inner = inline(rng, depth + 1, next, inLink)
120
+ switch (wrap) {
121
+ case 'bold':
122
+ return `**${inner}**`
123
+ case 'italic':
124
+ return `*${inner}*`
125
+ case 'underline':
126
+ return `++${inner}++`
127
+ case 'strike':
128
+ return `~~${inner}~~`
129
+ }
130
+ }
131
+
132
+ function block(rng: Rng): string {
133
+ const roll = rng()
134
+ const empty = new Set<Wrap>()
135
+ if (roll < 0.35) return inline(rng, 0, empty, false)
136
+ if (roll < 0.5) return `${'#'.repeat(int(rng, 1, 3))} ${inline(rng, 0, empty, false)}`
137
+ if (roll < 0.65) {
138
+ const items = int(rng, 1, 3)
139
+ return Array.from({ length: items }, () => `- ${inline(rng, 0, empty, false)}`).join('\n')
140
+ }
141
+ if (roll < 0.75) {
142
+ const items = int(rng, 1, 3)
143
+ return Array.from(
144
+ { length: items },
145
+ (_, index) => `${String(index + 1)}. ${inline(rng, 0, empty, false)}`,
146
+ ).join('\n')
147
+ }
148
+ if (roll < 0.85) return `> ${inline(rng, 0, empty, false)}`
149
+ if (roll < 0.93) {
150
+ const lang = pick(rng, ['', 'ts', 'sql'])
151
+ return `\`\`\`${lang}\nconst ${word(rng).replace(/\\/g, '')} = 1\n\`\`\``
152
+ }
153
+ return '---'
154
+ }
155
+
156
+ function document(rng: Rng): string {
157
+ const blocks = int(rng, 1, 4)
158
+ return Array.from({ length: blocks }, () => block(rng)).join('\n\n')
159
+ }
160
+
161
+ /** parse → serialize → parse identity, and the serialized form is a fixed point. */
162
+ function assertRoundTrip(markdown: string): void {
163
+ const first = normalizeRichDocument(parseRichMarkdown(markdown))
164
+ const serialized = serializeRichMarkdown(first)
165
+ const second = normalizeRichDocument(parseRichMarkdown(serialized))
166
+ expect(second, `document drifted for:\n${markdown}\n→\n${serialized}`).toEqual(first)
167
+ expect(serializeRichMarkdown(second), `no fixed point for:\n${markdown}`).toBe(serialized)
168
+ }
169
+
170
+ describe('AC#2 — semantic round-trip is lossless for the subset (property)', () => {
171
+ it('holds for 300 generated documents', () => {
172
+ const rng = mulberry32(0x5eed)
173
+ for (let sample = 0; sample < 300; sample += 1) {
174
+ assertRoundTrip(document(rng))
175
+ }
176
+ })
177
+
178
+ it.each([
179
+ ['nested emphasis', '**bold with *italic inside* it**'],
180
+ ['italic wrapping bold', '*italic **bold** italic*'],
181
+ ['code span inside a list item', '- item with `code` in it\n- second'],
182
+ ['link text containing **', '[a **bold** label](https://example.com/plan)'],
183
+ ['a literal asterisk', 'a \\* b'],
184
+ ['++ inside **', '**bold ++underlined++ bold**'],
185
+ ['an h3 directly above a list', '### Title\n\n- one\n- two'],
186
+ ['a divider between paragraphs', 'first\n\n---\n\nsecond'],
187
+ ['a mention in running text', 'Flagging @[Priya Raman](user:u2) on the second one.'],
188
+ ['ordered list starting past one', '3. three\n4. four'],
189
+ ['blockquote with emphasis', '> a **quoted** line'],
190
+ ['hard break', 'line one \nline two'],
191
+ ['fenced code with a language', '```ts\nconst a = 1\n```'],
192
+ ['emphasis spanning a mention', '*see @[Priya Raman](user:u2) here*'],
193
+ ['nested emphasis with interior spaces', '*a **b** c*'],
194
+ ['emphasis on a trailing space', '**bold **and more'],
195
+ ['a code span with boundary spaces', 'x ` y ` z'],
196
+ ])('%s', (_label, markdown) => {
197
+ assertRoundTrip(markdown)
198
+ })
199
+
200
+ it('canonicalizes spelling rather than preserving it (D3 rule 1)', () => {
201
+ const doc = normalizeRichDocument(parseRichMarkdown('_italic_ and __bold__\n\n* item'))
202
+ expect(serializeRichMarkdown(doc)).toBe('*italic* and **bold**\n\n- item')
203
+ })
204
+
205
+ it('KNOWN LIMITATION (marked): a bold run that opens with an italic and holds a second one', () => {
206
+ // CommonMark: strong(em(a) " " em(b)). marked 17 reads `***a* *b***` as
207
+ // one em(strong(...)) run, so the EDITOR shows a single bold-italic span
208
+ // where the renderer (micromark) shows two italics inside bold. The
209
+ // serializer's output is the correct CommonMark; the editor-side parse
210
+ // is where the loss happens. This pins the current behaviour so a marked
211
+ // fix — or a custom emphasis tokenizer — is noticed the day it lands.
212
+ const doc = normalizeRichDocument(parseRichMarkdown('***a* *b***'))
213
+ const nodes = doc.content?.[0]?.content ?? []
214
+ expect(nodes).toHaveLength(1)
215
+ expect(nodes[0]?.text).toBe('a* *b')
216
+ expect(nodes[0]?.marks?.map((mark) => mark.type).sort()).toEqual(['bold', 'italic'])
217
+ })
218
+
219
+ it('keeps emphasis boundaries off whitespace, and leaves nested emphasis alone', () => {
220
+ // Italic on the space beside a mention cannot be spelled; it moves off.
221
+ const spanning = normalizeRichDocument(parseRichMarkdown('*see @[Priya Raman](user:u2) here*'))
222
+ expect(serializeRichMarkdown(spanning)).toBe('*see* @[Priya Raman](user:u2) *here*')
223
+ // Interior spaces keep their marks: the bold run is all three nodes.
224
+ const nested = normalizeRichDocument(parseRichMarkdown('**bold with *italic inside* it**'))
225
+ expect(serializeRichMarkdown(nested)).toBe('**bold with *italic inside* it**')
226
+ })
227
+ })
228
+
229
+ describe('AC#4 — mention and underline syntax round-trip', () => {
230
+ it('parses @[Name](user:id) into a mention node and serializes it back byte-for-byte', () => {
231
+ const doc = normalizeRichDocument(parseRichMarkdown('Hi @[Marcus Webb](user:usr_9f2) there'))
232
+ const paragraph = doc.content?.[0]
233
+ const mention = paragraph?.content?.find((node) => node.type === 'mention')
234
+ expect(mention?.attrs).toMatchObject({ id: 'usr_9f2', label: 'Marcus Webb', type: 'user' })
235
+ expect(serializeRichMarkdown(doc)).toBe('Hi @[Marcus Webb](user:usr_9f2) there')
236
+ })
237
+
238
+ it.each(['task_item:tit_44a', 'task_board:tbd_1'])('accepts the %s scheme', (href) => {
239
+ const markdown = `See @[Slice B](${href}).`
240
+ const doc = normalizeRichDocument(parseRichMarkdown(markdown))
241
+ expect(doc.content?.[0]?.content?.some((node) => node.type === 'mention')).toBe(true)
242
+ expect(serializeRichMarkdown(doc)).toBe(markdown)
243
+ })
244
+
245
+ it('leaves an @[…](https://…) link alone — it is a link, not a mention', () => {
246
+ const markdown = 'ping @[x](https://example.com/x) please'
247
+ const doc = normalizeRichDocument(parseRichMarkdown(markdown))
248
+ const paragraph = doc.content?.[0]
249
+ expect(paragraph?.content?.some((node) => node.type === 'mention')).toBe(false)
250
+ const linked = paragraph?.content?.find((node) =>
251
+ (node.marks ?? []).some((mark) => mark.type === 'link'),
252
+ )
253
+ expect(linked?.text).toBe('x')
254
+ expect(serializeRichMarkdown(doc)).toBe(markdown)
255
+ })
256
+
257
+ it('leaves an unknown scheme alone', () => {
258
+ const markdown = '@[x](mailto:x@example.com)'
259
+ const doc = normalizeRichDocument(parseRichMarkdown(markdown))
260
+ expect(doc.content?.[0]?.content?.some((node) => node.type === 'mention')).toBe(false)
261
+ expect(serializeRichMarkdown(doc)).toBe(markdown)
262
+ })
263
+
264
+ it('escapes brackets in a mention label and reads them back', () => {
265
+ const doc = normalizeRichDocument(
266
+ parseRichMarkdown('@[Ops \\[west\\]](task_board:tbd_9)'),
267
+ )
268
+ const mention = doc.content?.[0]?.content?.find((node) => node.type === 'mention')
269
+ expect(mention?.attrs?.['label']).toBe('Ops [west]')
270
+ expect(serializeRichMarkdown(doc)).toBe('@[Ops \\[west\\]](task_board:tbd_9)')
271
+ })
272
+
273
+ it('parses ++text++ into an underline mark and serializes it back', () => {
274
+ const doc = normalizeRichDocument(parseRichMarkdown('a ++under lined++ b'))
275
+ const underlined = doc.content?.[0]?.content?.find((node) =>
276
+ (node.marks ?? []).some((mark) => mark.type === 'underline'),
277
+ )
278
+ expect(underlined?.text).toBe('under lined')
279
+ expect(serializeRichMarkdown(doc)).toBe('a ++under lined++ b')
280
+ })
281
+
282
+ it.each([
283
+ ['whitespace touching the markers', '++ x ++'],
284
+ ['whitespace before the closer', '++x ++'],
285
+ ['a marker alone', 'a ++ b'],
286
+ ['an unclosed opener', '++unclosed'],
287
+ ])('treats %s as literal text', (_label, markdown) => {
288
+ const doc = normalizeRichDocument(parseRichMarkdown(markdown))
289
+ const marks = doc.content?.[0]?.content?.flatMap((node) => node.marks ?? []) ?? []
290
+ expect(marks.some((mark) => mark.type === 'underline')).toBe(false)
291
+ expect(doc.content?.[0]?.content?.map((node) => node.text).join('')).toBe(markdown)
292
+ })
293
+
294
+ it('does not nest: the innermost pair wins and the outer markers stay literal', () => {
295
+ const doc = normalizeRichDocument(parseRichMarkdown('++a ++b++ c++'))
296
+ const nodes = doc.content?.[0]?.content ?? []
297
+ const underlined = nodes.filter((node) =>
298
+ (node.marks ?? []).some((mark) => mark.type === 'underline'),
299
+ )
300
+ expect(underlined.map((node) => node.text)).toEqual(['b'])
301
+ expect(nodes.map((node) => node.text).join('')).toBe('++a b c++')
302
+ })
303
+ })
304
+
305
+ describe('serialization escapes what the manager does not', () => {
306
+ const paragraph = (text: string) => ({
307
+ type: 'doc',
308
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
309
+ })
310
+
311
+ it.each([
312
+ ['a literal ++ run', 'a++b++c', 'a\\+\\+b\\+\\+c'],
313
+ ['a leading #', '# not a heading', '\\# not a heading'],
314
+ ['a leading dash', '- not a list', '\\- not a list'],
315
+ ['a leading plus', '+ not a list', '\\+ not a list'],
316
+ ['a leading number', '1. not a list', '1\\. not a list'],
317
+ ['a dashed rule', '---', '\\---'],
318
+ ['a leading >', '> not a quote', '&gt; not a quote'],
319
+ ])('%s survives serialize → parse unchanged', (_label, text, expected) => {
320
+ const doc = normalizeRichDocument(paragraph(text))
321
+ const serialized = serializeRichMarkdown(doc)
322
+ expect(serialized).toBe(expected)
323
+ expect(normalizeRichDocument(parseRichMarkdown(serialized))).toEqual(doc)
324
+ })
325
+
326
+ it('escapes a block starter after a hard break, not only at the paragraph start', () => {
327
+ const doc = normalizeRichDocument({
328
+ type: 'doc',
329
+ content: [
330
+ {
331
+ type: 'paragraph',
332
+ content: [
333
+ { type: 'text', text: 'first' },
334
+ { type: 'hardBreak' },
335
+ { type: 'text', text: '# second' },
336
+ ],
337
+ },
338
+ ],
339
+ })
340
+ const serialized = serializeRichMarkdown(doc)
341
+ expect(normalizeRichDocument(parseRichMarkdown(serialized))).toEqual(doc)
342
+ })
343
+
344
+ it('leaves code alone', () => {
345
+ const doc = normalizeRichDocument({
346
+ type: 'doc',
347
+ content: [
348
+ { type: 'codeBlock', attrs: { language: null }, content: [{ type: 'text', text: '# ++x++' }] },
349
+ {
350
+ type: 'paragraph',
351
+ content: [{ type: 'text', text: '++x++', marks: [{ type: 'code' }] }],
352
+ },
353
+ ],
354
+ })
355
+ expect(serializeRichMarkdown(doc)).toBe('```\n# ++x++\n```\n\n`++x++`')
356
+ })
357
+
358
+ it('drops a stray private-use sentinel from user text instead of turning it into a backslash', () => {
359
+ const doc = normalizeRichDocument(paragraph('a\u{E000}b'))
360
+ expect(serializeRichMarkdown(doc)).toBe('ab')
361
+ })
362
+ })
363
+
364
+ describe('AC#3 — unsupported markdown never enters the rich editor', () => {
365
+ it.each([
366
+ ['a table', '| a | b |\n|---|---|\n| 1 | 2 |'],
367
+ ['an h4', '#### deep heading'],
368
+ ['an h6', '###### deeper'],
369
+ ['block raw HTML', '<div>hi</div>'],
370
+ ['inline raw HTML', 'a <u>b</u> c'],
371
+ ['a script tag', '<script>alert(1)</script>'],
372
+ ['a task list', '- [ ] todo\n- [x] done'],
373
+ ['an image', '![alt](https://example.com/x.png)'],
374
+ ['a table nested in a quote', '> | a |\n> |---|\n> | 1 |'],
375
+ ['an h4 inside a list', '- #### nope'],
376
+ // TipTap's `code` mark excludes all other marks: the schema cannot hold
377
+ // emphasis or a link over a code span, so these fail closed rather than
378
+ // silently dropping the outer mark.
379
+ ['a code span inside emphasis', '*see `x` here*'],
380
+ ['a code span inside a link', '[see `x`](https://example.com)'],
381
+ ])('%s fails closed', (_label, markdown) => {
382
+ expect(isRichEditable(markdown)).toBe(false)
383
+ })
384
+
385
+ it.each([
386
+ ['an empty body', ''],
387
+ ['a plain paragraph', 'Looks right to me.'],
388
+ ['every subset construct at once',
389
+ '# Title\n\n## Sub\n\n### Third\n\n**b** *i* ++u++ ~~s~~ `c` [l](https://example.com)\n\n- a\n- b\n\n1. one\n\n> quote\n\n```\ncode\n```\n\n---\n\n@[Marcus Webb](user:usr_1) line \nbreak'],
390
+ ['non-canonical spelling', '_italic_ and __bold__\n\n* item'],
391
+ ['malformed inline syntax (CommonMark says it is text)', 'hello [ and ](url) and `world and **bold'],
392
+ ['an unclosed fence', '```\nnever closed'],
393
+ ['an invalid mention target (it is a link)', '@[foo](user:not-valid) and @[x](nope:1)'],
394
+ ['an autolink', '<https://example.com>'],
395
+ ['an entity', 'a &lt; b'],
396
+ ])('%s opens rich', (_label, markdown) => {
397
+ expect(isRichEditable(markdown)).toBe(true)
398
+ })
399
+ })
400
+
401
+ describe('input rules — markdown that formats AS YOU TYPE', () => {
402
+ /**
403
+ * Owner, 2026-09-07: "is it not like obsidian where you type them in and it
404
+ * autoformats for you as you type?" It is, for everything StarterKit ships.
405
+ * The local underline mark (D13) had commands, a shortcut and full markdown
406
+ * serialization and NO input rule, so `++text++` was the one thing that did
407
+ * nothing as you typed.
408
+ *
409
+ * What is pinned here is the PATTERN. Input rules fire on real text input,
410
+ * which jsdom cannot faithfully simulate (`insertContent` bypasses them
411
+ * entirely), so an end-to-end test here would assert nothing. All thirteen
412
+ * shortcuts were verified in a real browser instead; the regex is where a
413
+ * bug would actually live, and that is testable.
414
+ */
415
+ const fires = (typed: string): boolean => UNDERLINE_INPUT_RULE.test(typed)
416
+
417
+ it('fires on a well-formed run, at the line start or after whitespace', () => {
418
+ expect(fires('++under++')).toBe(true)
419
+ expect(fires('see ++under++')).toBe(true)
420
+ })
421
+
422
+ it('does NOT fire on code — the flanking rule, not "no spaces"', () => {
423
+ // The defect this guards: a whitespace-only rule underlined "; j" here.
424
+ expect(fires('i++; j++;')).toBe(false)
425
+ expect(fires('C++')).toBe(false)
426
+ expect(fires('x = i++')).toBe(false)
427
+ })
428
+
429
+ it('does NOT fire with whitespace inside the markers', () => {
430
+ expect(fires('++ under++')).toBe(false)
431
+ expect(fires('++under ++')).toBe(false)
432
+ })
433
+
434
+ it('does NOT match greedily across several runs or a +++ run', () => {
435
+ expect(fires('+++x+++')).toBe(false)
436
+ // `[^+]+` stops the content swallowing a second pair.
437
+ const m = UNDERLINE_INPUT_RULE.exec('a ++one++')
438
+ expect(m?.[2]).toBe('one')
439
+ })
440
+
441
+ it('only fires at the END of the typed run — it is an input rule', () => {
442
+ expect(fires('++under++ trailing')).toBe(false)
443
+ })
444
+
445
+ describe('closing-colon emoji shortcodes', () => {
446
+ const converts = (typed: string): boolean => EMOJI_INPUT_RULE(typed) !== null
447
+
448
+ it('matches canonical names and aliases at a text boundary', () => {
449
+ expect(converts(':tada:')).toBe(true)
450
+ expect(converts('ship it :side-eye:')).toBe(true)
451
+ expect(converts(':side_eye:')).toBe(true)
452
+ expect(converts(':TADA:')).toBe(true)
453
+ })
454
+
455
+ it('does not match a shape the shortcode table does not actually hold', () => {
456
+ expect(converts(':not_a_real_emoji:')).toBe(false)
457
+ // `ship` is a SEARCH keyword for `tada`, not a declared shortcode.
458
+ expect(converts(':ship:')).toBe(false)
459
+ })
460
+
461
+ it('leaves incomplete, spaced, and embedded prose literal', () => {
462
+ expect(converts(':')).toBe(false)
463
+ expect(converts(':tada')).toBe(false)
464
+ expect(converts(':side eye:')).toBe(false)
465
+ expect(converts('prefix:tada:')).toBe(false)
466
+ expect(converts(':tada: trailing')).toBe(false)
467
+ })
468
+ })
469
+ })
@@ -0,0 +1,71 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * ADR-147 D16 — relative time is a PURE function of (value, now).
4
+ * Nothing here reads a clock, which is the whole point: a component that calls
5
+ * Date.now() while rendering cannot be frozen from outside and cannot be
6
+ * asserted on.
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest'
10
+ import {
11
+ formatAbsoluteTime,
12
+ formatRelativeTime,
13
+ toIsoTimestamp,
14
+ } from '../relative-time'
15
+ import { FIXTURE_NOW } from './fixtures/thread-fixture'
16
+
17
+ const MINUTE = 60_000
18
+ const HOUR = 60 * MINUTE
19
+ const DAY = 24 * HOUR
20
+
21
+ describe('formatRelativeTime', () => {
22
+ it('reads "now" under a minute', () => {
23
+ expect(formatRelativeTime(FIXTURE_NOW, FIXTURE_NOW, 'en-GB')).toBe('now')
24
+ expect(formatRelativeTime(FIXTURE_NOW - 59_000, FIXTURE_NOW, 'en-GB')).toBe('now')
25
+ })
26
+
27
+ it('counts minutes, hours, then days', () => {
28
+ expect(formatRelativeTime(FIXTURE_NOW - MINUTE, FIXTURE_NOW, 'en-GB')).toBe('1m')
29
+ expect(formatRelativeTime(FIXTURE_NOW - 59 * MINUTE, FIXTURE_NOW, 'en-GB')).toBe('59m')
30
+ expect(formatRelativeTime(FIXTURE_NOW - HOUR, FIXTURE_NOW, 'en-GB')).toBe('1h')
31
+ expect(formatRelativeTime(FIXTURE_NOW - 23 * HOUR, FIXTURE_NOW, 'en-GB')).toBe('23h')
32
+ expect(formatRelativeTime(FIXTURE_NOW - DAY, FIXTURE_NOW, 'en-GB')).toBe('1d')
33
+ expect(formatRelativeTime(FIXTURE_NOW - 6 * DAY, FIXTURE_NOW, 'en-GB')).toBe('6d')
34
+ })
35
+
36
+ it('crosses over to an absolute date at seven days', () => {
37
+ const label = formatRelativeTime(FIXTURE_NOW - 8 * DAY, FIXTURE_NOW, 'en-GB')
38
+ expect(label).not.toMatch(/^\d+d$/)
39
+ expect(label).toContain('Aug')
40
+ })
41
+
42
+ it('adds the year once the date is not in the current one', () => {
43
+ const label = formatRelativeTime(Date.UTC(2025, 0, 4), FIXTURE_NOW, 'en-GB')
44
+ expect(label).toContain('2025')
45
+ })
46
+
47
+ it('treats clock skew from the future as "now" rather than a negative age', () => {
48
+ expect(formatRelativeTime(FIXTURE_NOW + 5 * MINUTE, FIXTURE_NOW, 'en-GB')).toBe('now')
49
+ })
50
+
51
+ it('returns an empty string rather than NaN for unusable input', () => {
52
+ expect(formatRelativeTime(Number.NaN, FIXTURE_NOW, 'en-GB')).toBe('')
53
+ })
54
+ })
55
+
56
+ describe('toIsoTimestamp', () => {
57
+ it('carries the exact instant for the datetime attribute', () => {
58
+ expect(toIsoTimestamp(FIXTURE_NOW)).toBe('2026-09-06T12:00:00.000Z')
59
+ })
60
+
61
+ it('degrades to an empty string rather than throwing', () => {
62
+ expect(toIsoTimestamp(Number.POSITIVE_INFINITY)).toBe('')
63
+ expect(toIsoTimestamp(Number.NaN)).toBe('')
64
+ })
65
+ })
66
+
67
+ describe('formatAbsoluteTime', () => {
68
+ it('produces something unambiguous for the hover title', () => {
69
+ expect(formatAbsoluteTime(FIXTURE_NOW, 'en-GB')).toContain('2026')
70
+ })
71
+ })