@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,343 @@
1
+ /**
2
+ * RankedBars — ADR-146 D5.
3
+ *
4
+ * What is pinned here is the arithmetic and the structure, not the paint.
5
+ * Three of these behaviours are the ones a "simplification" would quietly
6
+ * break:
7
+ *
8
+ * 1. **The track is bordered.** ADR-146's whole argument for this shape is
9
+ * that a 3% row is unreadable without it. A refactor that drops the
10
+ * border for a "cleaner" look loses the low-value rows and no other gate
11
+ * can see it.
12
+ * 2. **The fold sums, it does not sample.** `topN` must fold the remainder
13
+ * into one summed row, or the chart quietly under-reports.
14
+ * 3. **Degenerate data is a chart, not a crash.** Empty, all-zero, negative
15
+ * and single-row inputs each have a documented answer (acceptance
16
+ * criterion 8).
17
+ *
18
+ * `css: false` in the runner means imported stylesheets are not applied, but
19
+ * inline `style` attributes are — which is exactly what this primitive uses
20
+ * for colour and geometry, so `toHaveStyle` is meaningful here.
21
+ */
22
+ import { describe, expect, it, vi } from 'vitest'
23
+ import { render, screen } from '@testing-library/react'
24
+ import userEvent from '@testing-library/user-event'
25
+ import { RankedBars } from '../ranked-bars'
26
+
27
+ const ITEMS = [
28
+ { key: 'direct', label: 'Direct', value: 200 },
29
+ { key: 'organic', label: 'Organic search', value: 800 },
30
+ { key: 'social', label: 'Social', value: 400 },
31
+ ]
32
+
33
+ /** Row labels, top to bottom. */
34
+ function labels(): string[] {
35
+ return screen
36
+ .getAllByRole('listitem')
37
+ .map((li) => li.querySelector('span')?.textContent ?? '')
38
+ }
39
+
40
+ /**
41
+ * The fill inside each track, top to bottom. The row is `[label/value row,
42
+ * track]`, and the track's only child is the fill — so `div:last-child > div`
43
+ * is the fill without needing a test-only attribute on the primitive.
44
+ */
45
+ function fills(container: HTMLElement): HTMLElement[] {
46
+ return Array.from(container.querySelectorAll<HTMLElement>('li > * > div:last-child > div'))
47
+ }
48
+
49
+ /** The bordered track of each row. */
50
+ function tracks(container: HTMLElement): HTMLElement[] {
51
+ return Array.from(container.querySelectorAll<HTMLElement>('li > * > div:last-child'))
52
+ }
53
+
54
+ describe('RankedBars ordering', () => {
55
+ it('sorts descending by default', () => {
56
+ render(<RankedBars items={ITEMS} />)
57
+ expect(labels()).toEqual(['Organic search', 'Social', 'Direct'])
58
+ })
59
+
60
+ it('sorts ascending on request', () => {
61
+ render(<RankedBars items={ITEMS} sort="asc" />)
62
+ expect(labels()).toEqual(['Direct', 'Social', 'Organic search'])
63
+ })
64
+
65
+ it('preserves caller order when sorting is off', () => {
66
+ render(<RankedBars items={ITEMS} sort="none" />)
67
+ expect(labels()).toEqual(['Direct', 'Organic search', 'Social'])
68
+ })
69
+
70
+ it('does not mutate the caller array', () => {
71
+ const items = [...ITEMS]
72
+ render(<RankedBars items={items} />)
73
+ expect(items.map((i) => i.key)).toEqual(['direct', 'organic', 'social'])
74
+ })
75
+ })
76
+
77
+ describe('RankedBars top-N fold', () => {
78
+ it('folds the remainder into one summed Other row', () => {
79
+ render(<RankedBars items={ITEMS} topN={1} />)
80
+ expect(labels()).toEqual(['Organic search', 'Other'])
81
+ // 400 + 200 — a fold sums, it never samples.
82
+ expect(screen.getByText('600')).toBeInTheDocument()
83
+ })
84
+
85
+ it('renames the folded row on request', () => {
86
+ render(<RankedBars items={ITEMS} topN={2} otherLabel="All other sources" />)
87
+ expect(labels()).toEqual(['Organic search', 'Social', 'All other sources'])
88
+ })
89
+
90
+ it('does not fold when there is nothing left over', () => {
91
+ render(<RankedBars items={ITEMS} topN={3} />)
92
+ expect(labels()).toHaveLength(3)
93
+ expect(screen.queryByText('Other')).not.toBeInTheDocument()
94
+ })
95
+
96
+ it('formats the folded sum through formatValue', () => {
97
+ render(<RankedBars items={ITEMS} topN={1} formatValue={(v) => `${v} visits`} />)
98
+ expect(screen.getByText('600 visits')).toBeInTheDocument()
99
+ })
100
+ })
101
+
102
+ describe('RankedBars fill widths', () => {
103
+ it('normalises against the largest row by default', () => {
104
+ const { container } = render(<RankedBars items={ITEMS} />)
105
+ expect(fills(container).map((el) => el.style.width)).toEqual(['100%', '50%', '25%'])
106
+ })
107
+
108
+ it('normalises against an explicit max when given', () => {
109
+ // 2000 = the true total, so the tracks read as share-of-total.
110
+ const { container } = render(<RankedBars items={ITEMS} max={2000} />)
111
+ expect(fills(container).map((el) => el.style.width)).toEqual(['40%', '20%', '10%'])
112
+ })
113
+
114
+ it('clamps a value above an explicit max to a full track', () => {
115
+ const { container } = render(<RankedBars items={ITEMS} max={100} />)
116
+ expect(fills(container).map((el) => el.style.width)).toEqual(['100%', '100%', '100%'])
117
+ })
118
+
119
+ it('yields empty tracks rather than dividing by zero on a non-positive max', () => {
120
+ const { container } = render(<RankedBars items={ITEMS} max={0} />)
121
+ expect(fills(container).map((el) => el.style.width)).toEqual(['0%', '0%', '0%'])
122
+ })
123
+ })
124
+
125
+ describe('RankedBars degenerate data', () => {
126
+ it('renders an empty list rather than crashing', () => {
127
+ render(<RankedBars items={[]} />)
128
+ expect(screen.getByRole('list')).toBeInTheDocument()
129
+ expect(screen.queryAllByRole('listitem')).toHaveLength(0)
130
+ })
131
+
132
+ it('renders a single row at a full track', () => {
133
+ const { container } = render(<RankedBars items={[{ key: 'a', label: 'A', value: 5 }]} />)
134
+ expect(fills(container).map((el) => el.style.width)).toEqual(['100%'])
135
+ })
136
+
137
+ it('renders an all-zero series as empty tracks, not a full one', () => {
138
+ const { container } = render(
139
+ <RankedBars
140
+ items={[
141
+ { key: 'a', label: 'A', value: 0 },
142
+ { key: 'b', label: 'B', value: 0 },
143
+ ]}
144
+ />,
145
+ )
146
+ expect(fills(container).map((el) => el.style.width)).toEqual(['0%', '0%'])
147
+ })
148
+
149
+ it('clamps a negative value to an empty track', () => {
150
+ const { container } = render(
151
+ <RankedBars
152
+ items={[
153
+ { key: 'a', label: 'A', value: 10 },
154
+ { key: 'b', label: 'B', value: -4 },
155
+ ]}
156
+ />,
157
+ )
158
+ expect(fills(container).map((el) => el.style.width)).toEqual(['100%', '0%'])
159
+ })
160
+
161
+ it('absorbs a non-finite value as zero rather than emitting NaN%', () => {
162
+ const { container } = render(
163
+ <RankedBars
164
+ items={[
165
+ { key: 'a', label: 'A', value: 10 },
166
+ { key: 'b', label: 'B', value: Number.NaN },
167
+ ]}
168
+ />,
169
+ )
170
+ const widths = fills(container).map((el) => el.style.width)
171
+ expect(widths).toEqual(['100%', '0%'])
172
+ expect(widths.join()).not.toContain('NaN')
173
+ })
174
+ })
175
+
176
+ describe('RankedBars structure', () => {
177
+ it('gives every track a border — the thing that makes a low row readable', () => {
178
+ const { container } = render(<RankedBars items={ITEMS} />)
179
+ for (const track of tracks(container)) {
180
+ expect(track.style.border).toBe('1px solid rgb(var(--border))')
181
+ expect(track.style.height).toBe('12px')
182
+ expect(track.style.borderRadius).toBe('var(--radius-full)')
183
+ }
184
+ })
185
+
186
+ it('honours a caller track height', () => {
187
+ const { container } = render(<RankedBars items={ITEMS} trackHeight={20} />)
188
+ expect(tracks(container)[0]?.style.height).toBe('20px')
189
+ })
190
+
191
+ it('divides every row after the first, and none when dividers are off', () => {
192
+ const { container, rerender } = render(<RankedBars items={ITEMS} />)
193
+ const divided = Array.from(container.querySelectorAll<HTMLElement>('li')).map(
194
+ (li) => li.style.borderTop,
195
+ )
196
+ expect(divided[0]).toBe('')
197
+ expect(divided[1]).toBe('1px solid rgb(var(--border))')
198
+ expect(divided[2]).toBe('1px solid rgb(var(--border))')
199
+
200
+ rerender(<RankedBars items={ITEMS} dividers={false} />)
201
+ for (const li of container.querySelectorAll<HTMLElement>('li')) {
202
+ expect(li.style.borderTop).toBe('')
203
+ }
204
+ })
205
+
206
+ it('fills with the accent by default and with a series slot when asked', () => {
207
+ const { container } = render(
208
+ <RankedBars
209
+ sort="none"
210
+ items={[
211
+ { key: 'a', label: 'A', value: 1 },
212
+ { key: 'b', label: 'B', value: 1, slot: 1 },
213
+ { key: 'c', label: 'C', value: 1, slot: 'other' },
214
+ ]}
215
+ />,
216
+ )
217
+ expect(fills(container).map((el) => el.style.background)).toEqual([
218
+ 'rgb(var(--accent))',
219
+ 'rgb(var(--series-2))',
220
+ 'rgb(var(--text-muted))',
221
+ ])
222
+ })
223
+
224
+ it('tweens the fill with ease-out under motion-safe, never a spring', () => {
225
+ const { container } = render(<RankedBars items={ITEMS} />)
226
+ const fill = fills(container)[0]
227
+ expect(fill).toHaveClass('motion-safe:transition-[width,opacity]')
228
+ expect(fill?.style.transitionDuration).toBe('var(--dur-base)')
229
+ expect(fill?.style.transitionTimingFunction).toBe('var(--ease-out)')
230
+ expect(fill?.style.transitionTimingFunction).not.toContain('spring')
231
+ })
232
+
233
+ it('kills the transition outright under reduced motion', () => {
234
+ // The duration is inline and unconditional, and `transition-property`
235
+ // defaults to `all` — so without this class a reduce user gets MORE
236
+ // motion, not less. Every animated element in this primitive carries it.
237
+ const { container } = render(<RankedBars items={ITEMS} onSelect={() => {}} />)
238
+ for (const fill of fills(container)) {
239
+ expect(fill).toHaveClass('motion-reduce:transition-none')
240
+ }
241
+ for (const row of container.querySelectorAll<HTMLElement>('li > *')) {
242
+ expect(row).toHaveClass('motion-reduce:transition-none')
243
+ }
244
+ })
245
+
246
+ it('sets numbers in tabular figures and reserves the value column', () => {
247
+ const { container } = render(<RankedBars items={ITEMS} />)
248
+ const value = container.querySelector<HTMLElement>('li span:last-of-type')
249
+ expect(value).toHaveClass('tabular-nums')
250
+ expect(value?.style.minWidth).toBe('var(--space-12)')
251
+ })
252
+ })
253
+
254
+ describe('RankedBars interaction', () => {
255
+ it('is inert by default — no button advertises an absent interaction', () => {
256
+ render(<RankedBars items={ITEMS} />)
257
+ expect(screen.queryAllByRole('button')).toHaveLength(0)
258
+ })
259
+
260
+ it('reports the row key, not its index, when selected', async () => {
261
+ const onSelect = vi.fn()
262
+ render(<RankedBars items={ITEMS} onSelect={onSelect} />)
263
+ // Top row after the default descending sort is 'organic'.
264
+ await userEvent.click(screen.getAllByRole('button')[0] as HTMLElement)
265
+ expect(onSelect).toHaveBeenCalledWith('organic')
266
+ })
267
+
268
+ it('carries a visible focus ring on every interactive row', () => {
269
+ render(<RankedBars items={ITEMS} onSelect={() => {}} />)
270
+ for (const button of screen.getAllByRole('button')) {
271
+ expect(button).toHaveClass('focus-visible:[box-shadow:var(--ring-focus)]')
272
+ }
273
+ })
274
+
275
+ it('dims by opacity so an emphasis change cannot reflow the list', () => {
276
+ const { container } = render(<RankedBars items={ITEMS} activeKey="organic" />)
277
+ const rows = Array.from(container.querySelectorAll<HTMLElement>('li > div'))
278
+ // 0.7, not 0.55: at 0.55 a 14px --foreground label composites to 4.21:1 on
279
+ // a light card, under AA. A de-emphasized row is not a disabled one.
280
+ expect(rows.map((row) => row.style.opacity)).toEqual(['1', '0.7', '0.7'])
281
+ })
282
+
283
+ it('puts the rest of the de-emphasis on the mark, not on the label', () => {
284
+ const { container } = render(<RankedBars items={ITEMS} activeKey="organic" />)
285
+ expect(fills(container).map((el) => el.style.opacity)).toEqual(['1', '0.55', '0.55'])
286
+ })
287
+
288
+ it('shows a hover affordance on an interactive row', () => {
289
+ render(<RankedBars items={ITEMS} onSelect={() => {}} />)
290
+ for (const button of screen.getAllByRole('button')) {
291
+ expect(button).toHaveClass('hover:bg-[rgb(var(--surface-overlay))]')
292
+ }
293
+ })
294
+
295
+ it('claims aria-pressed only when the caller uses the emphasis model', () => {
296
+ const { rerender } = render(<RankedBars items={ITEMS} onSelect={() => {}} />)
297
+ // No activeKey prop at all: these are not toggles, and must not say they
298
+ // are. `null` means "the model is in use, nothing chosen yet".
299
+ for (const button of screen.getAllByRole('button')) {
300
+ expect(button).not.toHaveAttribute('aria-pressed')
301
+ }
302
+
303
+ rerender(<RankedBars items={ITEMS} onSelect={() => {}} activeKey={null} />)
304
+ for (const button of screen.getAllByRole('button')) {
305
+ expect(button).toHaveAttribute('aria-pressed', 'false')
306
+ }
307
+
308
+ rerender(<RankedBars items={ITEMS} onSelect={() => {}} activeKey="organic" />)
309
+ expect(screen.getAllByRole('button')[0]).toHaveAttribute('aria-pressed', 'true')
310
+ })
311
+
312
+ it('names the list for assistive tech', () => {
313
+ render(<RankedBars items={ITEMS} label="Traffic source" />)
314
+ expect(screen.getByRole('list', { name: 'Traffic source' })).toBeInTheDocument()
315
+ })
316
+ })
317
+
318
+ /**
319
+ * The label/value line here is the same shape as `ChartLegend`'s row — a
320
+ * `flex-1 min-w-0` label beside a `shrink-0` value with a reserved width —
321
+ * which is the shape that let `DonutChart`'s legend crush its labels to
322
+ * `width: 0` at a 390px viewport on 2026-09-06.
323
+ *
324
+ * Measured in a real browser at 390 / 900 / 1440 in both themes, the ranked
325
+ * rows themselves did NOT reproduce it: they are full-width, with no plot
326
+ * competing for the row, and no label came back at zero width or overflowing.
327
+ * The floor below is prophylactic, and only its MECHANISM is assertable here
328
+ * — jsdom has no layout, so it cannot show a crushed label either way.
329
+ */
330
+ describe('RankedBars label floor (browser-measured: not reproduced, hardened)', () => {
331
+ it('floors the label width, bounds the floor, and keeps the full text recoverable', () => {
332
+ const { container } = render(<RankedBars items={ITEMS} />)
333
+ const labels = container.querySelectorAll<HTMLElement>('span[title]')
334
+ expect(labels.length).toBe(ITEMS.length)
335
+ for (const label of labels) {
336
+ expect(label.title).not.toBe('')
337
+ expect(label.style.minWidth).toBe('min(calc(var(--space-12) * 2), 100%)')
338
+ // The wrap is what makes the floor safe: the VALUE drops to a second
339
+ // line instead of the label giving up its width.
340
+ expect(label.parentElement?.className).toContain('flex-wrap')
341
+ }
342
+ })
343
+ })
@@ -0,0 +1,194 @@
1
+ /**
2
+ * remark-underline — the `++text++` tokenizer (ADR-148 D13).
3
+ *
4
+ * Drives the plugin through `react-markdown` exactly as the product does:
5
+ * `packages/ui` cannot import `unified` / `remark-parse` (transitives pnpm
6
+ * keeps out of reach — the same fact that makes the plugin hand-rolled), so
7
+ * `react-markdown` is the only door into the parser. `renderToStaticMarkup`
8
+ * with NO component overrides yields the bare hast as HTML, so every
9
+ * assertion here is about the tokenizer and the mdast handler, not styling.
10
+ *
11
+ * The marker is spelled as an escape (`PP`) throughout, per the test-file
12
+ * rule: the file never contains the syntax literally and stays plain UTF-8
13
+ * for the CLAUDE.md section 8 greps. Non-ASCII content is escaped for the same
14
+ * reason.
15
+ */
16
+
17
+ import { createElement } from 'react'
18
+ import { renderToStaticMarkup } from 'react-dom/server'
19
+ import ReactMarkdown from 'react-markdown'
20
+ import remarkGfm from 'remark-gfm'
21
+ import { describe, expect, it } from 'vitest'
22
+ import { remarkUnderline } from '../lib/remark-underline'
23
+ import { MarkdownRenderer } from '../markdown'
24
+
25
+ /** `++` — two U+002B PLUS SIGN. */
26
+ const PP = '\u{2B}\u{2B}'
27
+ const EMOJI = '\u{1F600}'
28
+ const E_ACUTE = '\u{E9}'
29
+
30
+ function html(markdown: string): string {
31
+ return renderToStaticMarkup(
32
+ createElement(ReactMarkdown, { remarkPlugins: [remarkGfm, remarkUnderline] }, markdown),
33
+ )
34
+ }
35
+
36
+ /** `[name, markdown, expected html]` — exact output, whitespace included. */
37
+ type Case = readonly [name: string, input: string, expected: string]
38
+
39
+ const HAPPY: readonly Case[] = [
40
+ ['a single run', `${PP}text${PP}`, '<p><u>text</u></p>'],
41
+ ['inside a sentence', `a ${PP}b${PP} c`, '<p>a <u>b</u> c</p>'],
42
+ ['two runs', `${PP}a${PP} and ${PP}b${PP}`, '<p><u>a</u> and <u>b</u></p>'],
43
+ ['at line start and line end', `${PP}start${PP} mid ${PP}end${PP}`, '<p><u>start</u> mid <u>end</u></p>'],
44
+ ['intraword', `foo${PP}bar${PP}baz`, '<p>foo<u>bar</u>baz</p>'],
45
+ ['multi-word content', `${PP}two words${PP}`, '<p><u>two words</u></p>'],
46
+ ['punctuation just inside the markers', `${PP}"x"${PP}`, '<p><u>&quot;x&quot;</u></p>'],
47
+ ['across a soft line break', `${PP}a\nb${PP}`, '<p><u>a\nb</u></p>'],
48
+ ['non-ascii content', `${PP}caf${E_ACUTE}${PP}`, `<p><u>caf${E_ACUTE}</u></p>`],
49
+ ['emoji directly after the closer', `${PP}x${PP}${EMOJI}`, `<p><u>x</u>${EMOJI}</p>`],
50
+ ['emoji directly before the opener', `${EMOJI}${PP}x${PP}`, `<p>${EMOJI}<u>x</u></p>`],
51
+ ['in a heading', `# ${PP}x${PP}`, '<h1><u>x</u></h1>'],
52
+ ['in a list item', `- ${PP}x${PP}`, '<ul>\n<li><u>x</u></li>\n</ul>'],
53
+ ['in a blockquote', `> ${PP}x${PP}`, '<blockquote>\n<p><u>x</u></p>\n</blockquote>'],
54
+ ]
55
+
56
+ const LITERAL: readonly Case[] = [
57
+ ['an unclosed run', `${PP}unclosed`, `<p>${PP}unclosed</p>`],
58
+ ['a closer with no opener', `unopened${PP}`, `<p>unopened${PP}</p>`],
59
+ ['a lone marker pair', PP, `<p>${PP}</p>`],
60
+ ['a lone marker pair between words', `a ${PP} b`, `<p>a ${PP} b</p>`],
61
+ ['a space just inside the opener', `${PP} text${PP}`, `<p>${PP} text${PP}</p>`],
62
+ ['a space just inside the closer', `${PP}text ${PP}`, `<p>${PP}text ${PP}</p>`],
63
+ ['spaces just inside both', `${PP} text ${PP}`, `<p>${PP} text ${PP}</p>`],
64
+ ['a tab just inside the opener', `${PP}\ttext${PP}`, `<p>${PP}\ttext${PP}</p>`],
65
+ ['a single plus', '+x+', '<p>+x+</p>'],
66
+ ['three pluses', `+${PP}x+${PP}`, `<p>+${PP}x+${PP}</p>`],
67
+ ['an escaped opener', `\\${PP}x${PP}`, `<p>${PP}x${PP}</p>`],
68
+ ['C++ in prose', 'C++ and C++ again', '<p>C++ and C++ again</p>'],
69
+ ['an unclosed run followed by a real pair', `${PP}a ${PP}b${PP}`, `<p>${PP}a <u>b</u></p>`],
70
+ ]
71
+
72
+ /**
73
+ * The flanking rule — identical to `~~` (and `**` minus the underscore
74
+ * exception): a run cannot open when it is preceded by a letter and followed
75
+ * by punctuation, and cannot close in the mirror case. The point is code in
76
+ * prose: this team writes `i++;` in comments.
77
+ */
78
+ const FLANKING: readonly Case[] = [
79
+ ['i++; j++; stays literal', `i${PP}; j${PP};`, `<p>i${PP}; j${PP};</p>`],
80
+ ['post-increment in an assignment stays literal', `x = i${PP}; y = j${PP};`, `<p>x = i${PP}; y = j${PP};</p>`],
81
+ ['C++ alone stays literal', `C${PP}`, `<p>C${PP}</p>`],
82
+ ['C++ before punctuation stays literal', `C${PP}, C${PP}.`, `<p>C${PP}, C${PP}.</p>`],
83
+ ['closer-only runs stay literal', `a${PP} b${PP}`, `<p>a${PP} b${PP}</p>`],
84
+ ['opener-only runs stay literal', `${PP}a ${PP}b`, `<p>${PP}a ${PP}b</p>`],
85
+ // Intraword is allowed, exactly as `~~` and `**` allow it.
86
+ ['intraword underlines, like ~~ and **', `word${PP}x${PP}word`, `<p>word<u>x</u>word</p>`],
87
+ ['intraword with punctuation just inside stays literal, like ~~', `word${PP}"x"${PP}word`, `<p>word${PP}&quot;x&quot;${PP}word</p>`],
88
+ ['a parenthesised run after a letter stays literal', `a${PP}(note)${PP}b`, `<p>a${PP}(note)${PP}b</p>`],
89
+ ['a parenthesised run between spaces underlines', `see ${PP}(note)${PP} here`, `<p>see <u>(note)</u> here</p>`],
90
+ ['a run inside parentheses underlines', `(${PP}x${PP})`, `<p>(<u>x</u>)</p>`],
91
+ ['a run before a full stop underlines', `${PP}x${PP}.`, `<p><u>x</u>.</p>`],
92
+ ['a run inside quotes underlines', `"${PP}x${PP}"`, `<p>&quot;<u>x</u>&quot;</p>`],
93
+ ['a run after a colon underlines', `note: ${PP}x${PP}`, `<p>note: <u>x</u></p>`],
94
+ ]
95
+
96
+ const CODE: readonly Case[] = [
97
+ ['inside a code span', `\`${PP}x${PP}\``, `<p><code>${PP}x${PP}</code></p>`],
98
+ ['inside a fence', `\`\`\`\n${PP}x${PP}\n\`\`\``, `<pre><code>${PP}x${PP}\n</code></pre>`],
99
+ ['inside an indented code block', ` ${PP}x${PP}`, `<pre><code>${PP}x${PP}\n</code></pre>`],
100
+ ['a code span inside the run', `${PP}a \`b\` c${PP}`, '<p><u>a <code>b</code> c</u></p>'],
101
+ ['a code span that itself holds the marker, inside a run', `${PP}a \`${PP}\` c${PP}`, `<p><u>a <code>${PP}</code> c</u></p>`],
102
+ ]
103
+
104
+ const NESTING: readonly Case[] = [
105
+ ['inside strong', `**${PP}x${PP}**`, '<p><strong><u>x</u></strong></p>'],
106
+ ['around strong', `${PP}**x**${PP}`, '<p><u><strong>x</strong></u></p>'],
107
+ ['inside emphasis', `*${PP}x${PP}*`, '<p><em><u>x</u></em></p>'],
108
+ ['around emphasis', `${PP}*x*${PP}`, '<p><u><em>x</em></u></p>'],
109
+ ['inside strikethrough', `~~${PP}x${PP}~~`, '<p><del><u>x</u></del></p>'],
110
+ ['around strikethrough', `${PP}~~x~~${PP}`, '<p><u><del>x</del></u></p>'],
111
+ ['directly after strong', `**a**${PP}b${PP}`, '<p><strong>a</strong><u>b</u></p>'],
112
+ ['directly before strong', `${PP}a${PP}**b**`, '<p><u>a</u><strong>b</strong></p>'],
113
+ ['directly after strikethrough', `~~a~~${PP}b${PP}`, '<p><del>a</del><u>b</u></p>'],
114
+ ['directly before strikethrough', `${PP}a${PP}~~b~~`, '<p><u>a</u><del>b</del></p>'],
115
+ // Only passes because `+` is registered as an attention marker: without
116
+ // that, `**` followed by `+` is not left-flanking after a letter, and the
117
+ // output would be `a**<u>x</u>**b`.
118
+ ['intraword strong around a run', `a**${PP}x${PP}**b`, '<p>a<strong><u>x</u></strong>b</p>'],
119
+ // Strikethrough is the contrast: GFM's `~~` uses its own flanking rule and
120
+ // never reads attention markers, so after a letter and before punctuation it
121
+ // cannot open — plain GFM `a~~"x"~~b` behaves the same. The run inside
122
+ // still pairs; only the `~~` stays literal. Pinned, not a defect.
123
+ ['intraword strikethrough around a run', `a~~${PP}x${PP}~~b`, `<p>a~~<u>x</u>~~b</p>`],
124
+ ['strong and emphasis inside the run', `${PP}**a** and *b*${PP}`, '<p><u><strong>a</strong> and <em>b</em></u></p>'],
125
+ ['inside link text', `[${PP}x${PP}](https://example.com)`, '<p><a href="https://example.com"><u>x</u></a></p>'],
126
+ ['around a link', `${PP}[x](https://example.com)${PP}`, '<p><u><a href="https://example.com">x</a></u></p>'],
127
+ ['a marker in the link destination is not a run', '[x](https://example.com/a++b++c)', '<p><a href="https://example.com/a++b++c">x</a></p>'],
128
+ // Structural nesting of two pairs, exactly as `~~` behaves. The composer
129
+ // never emits it (marks cannot nest) and it renders as one underline; it is
130
+ // pinned so a future "fix" is a deliberate change, not drift.
131
+ ['two pairs nest structurally', `${PP}a ${PP}b${PP} c${PP}`, '<p><u>a <u>b</u> c</u></p>'],
132
+ ]
133
+
134
+ function run(cases: readonly Case[]): void {
135
+ for (const [name, input, expected] of cases) {
136
+ it(name, () => {
137
+ expect(html(input)).toBe(expected)
138
+ })
139
+ }
140
+ }
141
+
142
+ describe('remarkUnderline', () => {
143
+ describe('renders a <u> for', () => run(HAPPY))
144
+ describe('stays literal text for', () => run(LITERAL))
145
+ describe('flanking, as ~~ does it', () => run(FLANKING))
146
+ describe('code', () => run(CODE))
147
+ describe('nesting with other inline constructs', () => run(NESTING))
148
+
149
+ describe('through MarkdownRenderer', () => {
150
+ it('renders the styled <u> from the shared component map', () => {
151
+ const out = renderToStaticMarkup(createElement(MarkdownRenderer, { children: `a ${PP}b${PP} c` }))
152
+ expect(out).toContain('<u class="underline-offset-2" style="color:rgb(var(--foreground));text-decoration-color:rgb(var(--text-tertiary))">b</u>')
153
+ expect(out).not.toContain(PP)
154
+ })
155
+ })
156
+
157
+ describe('does nothing without the plugin', () => {
158
+ it('leaves the markers as text', () => {
159
+ const bare = renderToStaticMarkup(
160
+ createElement(ReactMarkdown, { remarkPlugins: [remarkGfm] }, `${PP}x${PP}`),
161
+ )
162
+ expect(bare).toBe(`<p>${PP}x${PP}</p>`)
163
+ })
164
+ })
165
+
166
+ describe('bounded work on adversarial input', () => {
167
+ it('a 2,000-character line of closers with no opener stays literal', () => {
168
+ // Every run can close (preceded by `a`) and none can open (followed by
169
+ // a space): the resolver walks back from each of 500 closers.
170
+ const line = `a${PP} `.repeat(500)
171
+ expect(line).toHaveLength(2000)
172
+ expect(html(line)).toBe(`<p>${line.trimEnd()}</p>`)
173
+ })
174
+
175
+ it('a 2,000-character line of openers with no closer stays literal', () => {
176
+ const line = `${PP}a `.repeat(500)
177
+ expect(line).toHaveLength(2000)
178
+ expect(html(line)).toBe(`<p>${line.trimEnd()}</p>`)
179
+ })
180
+
181
+ it('a 2,000-character line of runs renders every one', () => {
182
+ // 333 six-character runs plus two trailing letters.
183
+ const line = `${PP}a${PP} `.repeat(333) + 'aa'
184
+ expect(line).toHaveLength(2000)
185
+ expect(html(line)).toBe(`<p>${'<u>a</u> '.repeat(333)}aa</p>`)
186
+ })
187
+
188
+ it('a 2,000-character run of bare markers stays literal', () => {
189
+ const line = PP.repeat(1000)
190
+ expect(line).toHaveLength(2000)
191
+ expect(html(line)).toBe(`<p>${line}</p>`)
192
+ })
193
+ })
194
+ })