@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,612 @@
1
+ /**
2
+ * LineChart / AreaChart — the edge cases ADR-146 acceptance criterion 8 names,
3
+ * plus the two defects this primitive exists to not re-ship.
4
+ *
5
+ * The suite runs with `css: false`, so nothing here measures a painted pixel.
6
+ * What it CAN assert is the shape of what reaches the browser: how many path
7
+ * segments a gap produces, whether two charts on one page collide on a
8
+ * gradient id, and whether the keyboard actually moves the readout.
9
+ */
10
+ import { describe, expect, it } from 'vitest'
11
+ import { render, screen } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+
14
+ import { AreaChart, LineChart, type LineChartSeries } from '../line-chart'
15
+
16
+ const xLabels = ['Mar 1', 'Mar 2', 'Mar 3', 'Mar 4']
17
+
18
+ function series(values: readonly (number | null)[]): LineChartSeries[] {
19
+ return [{ key: 'visits', label: 'Visits', slot: 0, values }]
20
+ }
21
+
22
+ /** The plot itself, scoped away from the frame's chrome (the table toggle's
23
+ * Lucide icon is an SVG with paths of its own). */
24
+ function plot(container: HTMLElement): Element {
25
+ const node = container.querySelector('[role="group"] svg')
26
+ if (node === null) throw new Error('no plot rendered')
27
+ return node
28
+ }
29
+
30
+ /** Every stroked line path in the plot — one per unbroken run of points. */
31
+ function linePaths(container: HTMLElement): Element[] {
32
+ return Array.from(plot(container).querySelectorAll('path')).filter(
33
+ (path) => path.getAttribute('fill') === 'none',
34
+ )
35
+ }
36
+
37
+ /** Every filled area path in the plot — one per unbroken run, area variant. */
38
+ function areaPaths(container: HTMLElement): Element[] {
39
+ return Array.from(plot(container).querySelectorAll('path')).filter(
40
+ (path) => path.getAttribute('fill') === null,
41
+ )
42
+ }
43
+
44
+ describe('LineChart edge cases', () => {
45
+ it('renders a single point as a dot, with no empty plot', () => {
46
+ const { container } = render(
47
+ <LineChart title="One" xLabels={['Mar 1']} series={series([42])} />,
48
+ )
49
+ const paths = linePaths(container)
50
+ expect(paths).toHaveLength(1)
51
+ // A zero-length segment: with a round cap this paints as a dot.
52
+ expect(paths[0]?.getAttribute('d')).toMatch(/^M[\d.]+,[\d.]+ L[\d.]+,[\d.]+$/)
53
+ expect(plot(container).querySelectorAll('circle')).toHaveLength(1)
54
+ })
55
+
56
+ it('renders two points as one segment', () => {
57
+ const { container } = render(
58
+ <LineChart xLabels={['a', 'b']} series={series([1, 2])} />,
59
+ )
60
+ expect(linePaths(container)).toHaveLength(1)
61
+ })
62
+
63
+ it('breaks the line at a gap instead of interpolating through it', () => {
64
+ const { container } = render(
65
+ <LineChart xLabels={xLabels} series={series([1, null, 3, 4])} />,
66
+ )
67
+ // Two runs: [Mar 1] and [Mar 3, Mar 4]. One path would be a lie.
68
+ expect(linePaths(container)).toHaveLength(2)
69
+ })
70
+
71
+ it('survives an all-zero series without dividing by zero', () => {
72
+ const { container } = render(
73
+ <LineChart
74
+ xLabels={xLabels}
75
+ series={series([0, 0, 0, 0])}
76
+ directLabels={false}
77
+ />,
78
+ )
79
+ const d = linePaths(container)[0]?.getAttribute('d') ?? ''
80
+ expect(d).not.toMatch(/NaN|Infinity/)
81
+ // A flat domain maps to the middle of the range, and the axis agrees:
82
+ // ONE tick label, not a ladder of zeroes.
83
+ expect(screen.getAllByText('0')).toHaveLength(1)
84
+ })
85
+
86
+ it('draws a zero rule when the data crosses zero', () => {
87
+ const { container } = render(
88
+ <LineChart xLabels={xLabels} series={series([-40, -10, 20, 50])} />,
89
+ )
90
+ const rule = plot(container).querySelector('line')
91
+ expect(rule).not.toBeNull()
92
+ expect(rule?.getAttribute('style')).toContain('--chart-axis')
93
+ })
94
+
95
+ it('has no zero rule when the domain does not span zero', () => {
96
+ const { container } = render(
97
+ <LineChart xLabels={xLabels} series={series([10, 20, 30, 40])} />,
98
+ )
99
+ expect(plot(container).querySelector('line')).toBeNull()
100
+ })
101
+
102
+ it('falls back to the empty state when no datum is finite', () => {
103
+ render(
104
+ <LineChart
105
+ title="Nothing"
106
+ xLabels={xLabels}
107
+ series={series([null, null, null, null])}
108
+ />,
109
+ )
110
+ expect(screen.getByText('No data for this range.')).toBeInTheDocument()
111
+ })
112
+
113
+ it('emits no NaN in any path or attribute', () => {
114
+ const { container } = render(
115
+ <AreaChart xLabels={xLabels} series={series([null, 5, 0, -3])} />,
116
+ )
117
+ expect(container.innerHTML).not.toMatch(/NaN|Infinity/)
118
+ })
119
+ })
120
+
121
+ describe('LineChart mark placement', () => {
122
+ it('closes an area fill on the zero line, not the floor of the plot', () => {
123
+ // Domain -40..20, so zero sits inside it. Closing on the floor would
124
+ // paint every negative point down to -40 — a tall block of ink for a
125
+ // small negative number, which is the area-chart lie in reverse.
126
+ const { container } = render(
127
+ <AreaChart xLabels={xLabels} series={series([-40, -10, 0, 20])} />,
128
+ )
129
+ const rule = plot(container).querySelector('line')
130
+ const zeroY = Number(rule?.getAttribute('y1'))
131
+ expect(Number.isFinite(zeroY)).toBe(true)
132
+
133
+ const d = areaPaths(container)[0]?.getAttribute('d') ?? ''
134
+ // The two closing coordinates are the baseline, and they are the zero
135
+ // rule's y — not the bottom of the plot.
136
+ const closing = [...d.matchAll(/L[\d.-]+,([\d.-]+)/g)].map((m) => Number(m[1]))
137
+ expect(closing[closing.length - 1]).toBeCloseTo(zeroY, 1)
138
+ expect(closing[closing.length - 2]).toBeCloseTo(zeroY, 1)
139
+ })
140
+
141
+ it('closes an all-positive area on the floor, as before', () => {
142
+ const { container } = render(
143
+ <AreaChart xLabels={xLabels} series={series([10, 20, 30, 40])} />,
144
+ )
145
+ const d = areaPaths(container)[0]?.getAttribute('d') ?? ''
146
+ const closing = [...d.matchAll(/L[\d.-]+,([\d.-]+)/g)].map((m) => Number(m[1]))
147
+ // includeZero puts zero at the bottom of the domain, so y(0) IS the floor.
148
+ expect(closing[closing.length - 1]).toBeGreaterThan(0)
149
+ expect(d).not.toMatch(/NaN/)
150
+ })
151
+
152
+ it('never paints a direct label above the top of the plot', () => {
153
+ // niceTicks rounds 1..4 to exactly [1,2,3,4], so the peak sits at y = 0.
154
+ // An unflipped label would render its glyphs outside the plot, over the
155
+ // legend, because the plot SVG is deliberately overflow: visible.
156
+ const { container } = render(
157
+ <LineChart title="Peak" xLabels={xLabels} series={series([1, 2, 3, 4])} />,
158
+ )
159
+ const ys = Array.from(plot(container).querySelectorAll('text')).map((node) =>
160
+ Number(node.getAttribute('y')),
161
+ )
162
+ expect(ys.length).toBeGreaterThan(0)
163
+ for (const y of ys) expect(y).toBeGreaterThanOrEqual(0)
164
+ })
165
+ })
166
+
167
+ describe('LineChart axis labels', () => {
168
+ it('thins the x-axis rather than rendering one label per day', () => {
169
+ const days = Array.from({ length: 90 }, (_, i) => `Day ${i + 1}`)
170
+ render(
171
+ <LineChart
172
+ title="Sessions"
173
+ xLabels={days}
174
+ series={series(days.map((_, i) => i))}
175
+ />,
176
+ )
177
+ // 90 labels in a justify-between row is five pixels of ellipsis each.
178
+ expect(screen.queryByText('Day 2')).toBeNull()
179
+ // First and last are always kept — they anchor the range.
180
+ expect(screen.getByText('Day 1')).toBeInTheDocument()
181
+ expect(screen.getByText('Day 90')).toBeInTheDocument()
182
+ })
183
+
184
+ it('keeps every label in the table view, thinned axis or not', async () => {
185
+ const user = userEvent.setup()
186
+ const days = Array.from({ length: 90 }, (_, i) => `Day ${i + 1}`)
187
+ render(
188
+ <LineChart
189
+ title="Sessions"
190
+ xLabels={days}
191
+ series={series(days.map((_, i) => i))}
192
+ />,
193
+ )
194
+ await user.click(screen.getByRole('button', { name: 'Table view' }))
195
+ expect(screen.getByRole('rowheader', { name: 'Day 2' })).toBeInTheDocument()
196
+ })
197
+
198
+ it('formats the whole y tick ladder to one precision', () => {
199
+ render(
200
+ <LineChart
201
+ title="Rate"
202
+ xLabels={xLabels}
203
+ series={series([0, 0.25, 0.5, 1])}
204
+ // The tick COUNT is fitted to the plot's height, so a six-rung
205
+ // ladder needs a plot tall enough to hold one: at the default 16/5
206
+ // the nominal plot is 143px and the axis correctly thins to three.
207
+ // This case is about the FORMATTING of a six-rung ladder, so it asks
208
+ // for a plot that has room for six.
209
+ aspectRatio={2}
210
+ />,
211
+ )
212
+ // The ladder is 0, 0.2 … 1. Per-value formatting renders that as
213
+ // "1 / 0.8 / 0.6 / 0.4 / 0.2 / 0" — two shapes in one tabular column.
214
+ // One precision for the whole ladder means the ends carry it too.
215
+ for (const tick of ['1.0', '0.8', '0.6', '0.4', '0.2', '0.0']) {
216
+ expect(screen.getByText(tick)).toBeInTheDocument()
217
+ }
218
+ })
219
+ })
220
+
221
+ describe('LineChart axis legibility (the 2026-09-06 browser findings)', () => {
222
+ /**
223
+ * These four assert the SHAPE of the fix, which is all jsdom can see: the
224
+ * inline `left` percentage, which indices survive thinning, how many tick
225
+ * rows exist, and whether both members of a colliding pair are in the DOM.
226
+ * Whether the result LOOKS right — no painted overlap, the label under its
227
+ * bar — is a browser measurement, and is recorded in the fix report.
228
+ */
229
+
230
+ /** The x-axis label row, which mirrors the plot row below the gutter. */
231
+ function xLabelRow(container: HTMLElement): HTMLElement {
232
+ const rows = container.querySelectorAll('figure > div[aria-hidden="true"]')
233
+ const row = rows[rows.length - 1]
234
+ if (!(row instanceof HTMLElement)) throw new Error('no x-label row')
235
+ return row
236
+ }
237
+
238
+ function readXLabels(container: HTMLElement): { text: string; left: string }[] {
239
+ return Array.from(xLabelRow(container).querySelectorAll('span')).map((span) => ({
240
+ text: span.textContent ?? '',
241
+ left: span.style.left,
242
+ }))
243
+ }
244
+
245
+ it('thins 12 weeks by a uniform stride, not by rounding a fraction', () => {
246
+ // The shipped axis read "Wk 1, Wk 3, Wk 4, Wk 6, Wk 7, Wk 9, Wk 10,
247
+ // Wk 12": 3&4, 6&7 and 9&10 adjacent, 2, 5, 8 and 11 skipped. A reader
248
+ // sees an irregular axis and concludes the DATA is irregular.
249
+ const weeks = Array.from({ length: 12 }, (_, i) => `Wk ${i + 1}`)
250
+ const { container } = render(
251
+ <LineChart
252
+ title="Weekly spend"
253
+ xLabels={weeks}
254
+ series={series(weeks.map((_, i) => i * 100))}
255
+ />,
256
+ )
257
+ const texts = readXLabels(container).map((label) => label.text)
258
+ expect(texts).toEqual(['Wk 1', 'Wk 3', 'Wk 5', 'Wk 7', 'Wk 9', 'Wk 12'])
259
+ // The three the rounding kept ADJACENT to a neighbour are gone.
260
+ expect(texts).not.toContain('Wk 4')
261
+ expect(texts).not.toContain('Wk 6')
262
+ expect(texts).not.toContain('Wk 10')
263
+ // No two kept labels are one week apart — that adjacency is what made
264
+ // the old axis read as irregular data.
265
+ const kept = texts.map((text) => Number(text.replace('Wk ', '')))
266
+ for (let i = 1; i < kept.length; i++) {
267
+ expect((kept[i] ?? 0) - (kept[i - 1] ?? 0)).toBeGreaterThan(1)
268
+ }
269
+ })
270
+
271
+ it('positions each x label at its own data fraction, not an even spread', () => {
272
+ const weeks = Array.from({ length: 12 }, (_, i) => `Wk ${i + 1}`)
273
+ const { container } = render(
274
+ <LineChart
275
+ title="Weekly spend"
276
+ xLabels={weeks}
277
+ series={series(weeks.map((_, i) => i * 100))}
278
+ />,
279
+ )
280
+ const byText = new Map(readXLabels(container).map((l) => [l.text, l.left]))
281
+ // Position 4 of 12 is 4/11 of the way across — not 2/5, which is where
282
+ // an even spread of six labels would have put it.
283
+ expect(byText.get('Wk 1')).toBe('0%')
284
+ expect(byText.get('Wk 5')).toBe(`${(4 / 11) * 100}%`)
285
+ expect(byText.get('Wk 12')).toBe('100%')
286
+ })
287
+
288
+ it('shortens the y ladder for a short plot instead of stacking labels', () => {
289
+ // A 21px label needs 42px of room. The nominal plot is 459px wide, so a
290
+ // 16:5 panel is 143px tall (four rungs) and a 16:1 panel is 29px (two).
291
+ const tall = render(
292
+ <LineChart title="Spend" xLabels={xLabels} series={series([0, 25000, 40000, 50000])} />,
293
+ )
294
+ const short = render(
295
+ <LineChart
296
+ title="Spend"
297
+ xLabels={xLabels}
298
+ series={series([0, 25000, 40000, 50000])}
299
+ aspectRatio={16}
300
+ />,
301
+ )
302
+ const count = (view: ReturnType<typeof render>): number =>
303
+ view.container.querySelectorAll('figure > div > div[aria-hidden="true"] > span')
304
+ .length
305
+ expect(count(short)).toBeLessThan(count(tall))
306
+ expect(count(short)).toBe(2)
307
+ })
308
+
309
+ it('drops one of two value labels that would land on top of each other', () => {
310
+ // The measured defect: AreaChart's "Spend, filled" demo painted
311
+ // "$17,460" and "$17,120" over each other as "$17,46O$17,120". Two
312
+ // series ending 340 apart on a ~20,000 domain are ~2px apart in the
313
+ // plot, so exactly one of the pair may be drawn.
314
+ const { container } = render(
315
+ <AreaChart
316
+ title="Spend"
317
+ xLabels={xLabels}
318
+ series={[
319
+ { key: 'a', label: 'A', slot: 0, values: [8000, 12000, 15000, 17460] },
320
+ { key: 'b', label: 'B', slot: 1, values: [8100, 12100, 15100, 17120] },
321
+ ]}
322
+ />,
323
+ )
324
+ const drawn = Array.from(plot(container).querySelectorAll('text')).map(
325
+ (node) => node.textContent,
326
+ )
327
+ const collided = drawn.filter(
328
+ (text) => text === '17,460' || text === '17,120',
329
+ )
330
+ expect(collided).toHaveLength(1)
331
+ // The survivor is the one that is also a peak — priority peak > last.
332
+ expect(collided[0]).toBe('17,460')
333
+ })
334
+ })
335
+
336
+ describe('LineChart identity rules (ADR-146 D10)', () => {
337
+ it('renders no legend for a single series — the title names it', () => {
338
+ render(<LineChart title="Visits" xLabels={xLabels} series={series([1, 2, 3, 4])} />)
339
+ expect(screen.queryByRole('list', { name: /legend/i })).toBeNull()
340
+ })
341
+
342
+ it('always renders a legend for two or more series', () => {
343
+ render(
344
+ <LineChart
345
+ title="Traffic"
346
+ xLabels={xLabels}
347
+ series={[
348
+ { key: 'a', label: 'Direct', slot: 0, values: [1, 2, 3, 4] },
349
+ { key: 'b', label: 'Search', slot: 1, values: [4, 3, 2, 1] },
350
+ ]}
351
+ />,
352
+ )
353
+ expect(screen.getByRole('list', { name: /legend/i })).toBeInTheDocument()
354
+ expect(screen.getByText('Direct')).toBeInTheDocument()
355
+ expect(screen.getByText('Search')).toBeInTheDocument()
356
+ })
357
+
358
+ it('offers the table view as the accessible representation', async () => {
359
+ const user = userEvent.setup()
360
+ render(<LineChart title="Visits" xLabels={xLabels} series={series([1, 2, 3, 4])} />)
361
+ await user.click(screen.getByRole('button', { name: 'Table view' }))
362
+ expect(screen.getByRole('table')).toBeInTheDocument()
363
+ expect(screen.getByRole('columnheader', { name: 'Visits' })).toBeInTheDocument()
364
+ // A gap reads as an em dash in the table, never as a zero.
365
+ })
366
+
367
+ it('writes an em dash, not a zero, for a gap in the table', async () => {
368
+ const user = userEvent.setup()
369
+ render(<LineChart title="Visits" xLabels={xLabels} series={series([1, null, 3, 4])} />)
370
+ await user.click(screen.getByRole('button', { name: 'Table view' }))
371
+ expect(screen.getByText('—')).toBeInTheDocument()
372
+ })
373
+ })
374
+
375
+ describe('LineChart gradient ids', () => {
376
+ it('does not collide when two area charts share a page', () => {
377
+ const { container } = render(
378
+ <>
379
+ <AreaChart xLabels={xLabels} series={series([1, 2, 3, 4])} />
380
+ <AreaChart
381
+ xLabels={xLabels}
382
+ series={[{ key: 'b', label: 'Other', slot: 1, values: [4, 3, 2, 1] }]}
383
+ />
384
+ </>,
385
+ )
386
+ const ids = Array.from(container.querySelectorAll('linearGradient')).map((node) =>
387
+ node.getAttribute('id'),
388
+ )
389
+ expect(ids).toHaveLength(2)
390
+ expect(new Set(ids).size).toBe(2)
391
+ // And the ids are usable in a url() fragment — React's useId ships colons.
392
+ for (const id of ids) expect(id).not.toContain(':')
393
+ })
394
+
395
+ it('defines one gradient per series, and only for the area variant', () => {
396
+ const twoSeries: LineChartSeries[] = [
397
+ { key: 'a', label: 'Direct', slot: 0, values: [1, 2, 3, 4] },
398
+ { key: 'b', label: 'Search', slot: 1, values: [4, 3, 2, 1] },
399
+ ]
400
+ const area = render(<AreaChart xLabels={xLabels} series={twoSeries} />)
401
+ expect(area.container.querySelectorAll('linearGradient')).toHaveLength(2)
402
+ // One filled region per unbroken run, over the two stroked lines.
403
+ expect(areaPaths(area.container)).toHaveLength(2)
404
+ expect(linePaths(area.container)).toHaveLength(2)
405
+
406
+ // The line variant defines no gradient at all — nothing to collide with.
407
+ const line = render(<LineChart xLabels={xLabels} series={twoSeries} />)
408
+ expect(line.container.querySelectorAll('linearGradient')).toHaveLength(0)
409
+ expect(linePaths(line.container)).toHaveLength(2)
410
+ })
411
+ })
412
+
413
+ describe('LineChart keyboard interaction (ADR-146 D9)', () => {
414
+ it('moves the readout with the arrow keys and clears on Escape', async () => {
415
+ const user = userEvent.setup()
416
+ render(<LineChart title="Visits" xLabels={xLabels} series={series([1, 2, 3, 4])} />)
417
+
418
+ const plot = screen.getByRole('group', { name: 'Visits' })
419
+ plot.focus()
420
+
421
+ await user.keyboard('{ArrowRight}')
422
+ expect(screen.getByRole('tooltip')).toHaveTextContent('Mar 1')
423
+
424
+ await user.keyboard('{ArrowRight}')
425
+ expect(screen.getByRole('tooltip')).toHaveTextContent('Mar 2')
426
+
427
+ await user.keyboard('{End}')
428
+ expect(screen.getByRole('tooltip')).toHaveTextContent('Mar 4')
429
+
430
+ await user.keyboard('{Escape}')
431
+ expect(screen.queryByRole('tooltip')).toBeNull()
432
+ })
433
+
434
+ it('drops a hover index that new, shorter data no longer has', async () => {
435
+ const user = userEvent.setup()
436
+ const { rerender } = render(
437
+ <LineChart title="Visits" xLabels={xLabels} series={series([1, 2, 3, 4])} />,
438
+ )
439
+ screen.getByRole('group', { name: 'Visits' }).focus()
440
+ await user.keyboard('{End}')
441
+ expect(screen.getByRole('tooltip')).toHaveTextContent('Mar 4')
442
+
443
+ // A range switch: the same chart, fewer points. A stale index would read
444
+ // past the end of every series and paint a readout of nothing.
445
+ rerender(
446
+ <LineChart title="Visits" xLabels={['Mar 1', 'Mar 2']} series={series([1, 2])} />,
447
+ )
448
+ expect(screen.queryByRole('tooltip')).toBeNull()
449
+ })
450
+
451
+ it('is keyboard reachable', () => {
452
+ render(<LineChart title="Visits" xLabels={xLabels} series={series([1, 2, 3, 4])} />)
453
+ expect(screen.getByRole('group', { name: 'Visits' })).toHaveAttribute('tabindex', '0')
454
+ })
455
+ })
456
+
457
+ describe('LineChart — every datum is plotted (Truthful axis)', () => {
458
+ it('plots values that run past the last x label instead of discarding them', async () => {
459
+ const user = userEvent.setup()
460
+ // Three values, two labels. Deriving the position count from `xLabels`
461
+ // drops the third: the domain reads 1..2, the point never draws, and the
462
+ // table view — the chart's accessible representation — never mentions it.
463
+ render(
464
+ <LineChart title="Visits" xLabels={['a', 'b']} series={series([1, 2, 999])} />,
465
+ )
466
+
467
+ // The domain must cover the data that exists. niceTicks(1, 999) tops out
468
+ // at 1,000; a domain truncated to 1..2 tops out at "2.0".
469
+ expect(screen.getByText('1,000')).toBeInTheDocument()
470
+ expect(screen.queryByText('2.0')).toBeNull()
471
+
472
+ await user.click(screen.getByRole('button', { name: 'Table view' }))
473
+ expect(screen.getByRole('cell', { name: '999' })).toBeInTheDocument()
474
+ })
475
+
476
+ it('keeps every position in the table when the labels run short', async () => {
477
+ const user = userEvent.setup()
478
+ // The real shape of this bug: a 30-day series arriving with 7 labels.
479
+ const values = Array.from({ length: 30 }, (_, i) => i + 1)
480
+ render(
481
+ <LineChart
482
+ title="Sessions"
483
+ xLabels={['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7']}
484
+ series={series(values)}
485
+ />,
486
+ )
487
+ await user.click(screen.getByRole('button', { name: 'Table view' }))
488
+ // Header + one row per VALUE, not one row per label.
489
+ expect(screen.getAllByRole('row')).toHaveLength(values.length + 1)
490
+ expect(screen.getByRole('cell', { name: '30' })).toBeInTheDocument()
491
+ })
492
+
493
+ it('takes the longest series, not the first, as the position count', () => {
494
+ const { container } = render(
495
+ <LineChart
496
+ title="Traffic"
497
+ xLabels={['a']}
498
+ series={[
499
+ { key: 'a', label: 'Direct', slot: 0, values: [1] },
500
+ { key: 'b', label: 'Search', slot: 1, values: [1, 2, 3, 4] },
501
+ ]}
502
+ markers="all"
503
+ />,
504
+ )
505
+ // 1 marker for Direct + 4 for Search. Counting from `xLabels` gives 2.
506
+ expect(plot(container).querySelectorAll('circle')).toHaveLength(5)
507
+ })
508
+
509
+ it('leaves an unlabelled position blank on the axis rather than dropping it', () => {
510
+ render(
511
+ <LineChart title="Visits" xLabels={['a', 'b']} series={series([1, 2, 3])} />,
512
+ )
513
+ // The axis row still names what it can; the position with no label is
514
+ // rendered empty, not deleted (deleting it would shift every label left).
515
+ expect(screen.getByText('a')).toBeInTheDocument()
516
+ expect(screen.getByText('b')).toBeInTheDocument()
517
+ })
518
+ })
519
+
520
+ /**
521
+ * The plot box unmounts and remounts — `ChartFrame` renders `children` only in
522
+ * the `loaded` state, and the table view swaps the whole plot row out. The
523
+ * measurement hooks used to key their effect on `[ref]`, and a ref object is
524
+ * stable for the component's life, so the observer bound once to whatever node
525
+ * existed then and never rebound.
526
+ *
527
+ * That made the ORDINARY async path the broken one: a chart that first paints
528
+ * in `loading` (every TanStack Query flow) stayed unmeasured for the rest of
529
+ * its life and silently fell back to the nominal plot size — defeating the
530
+ * measured axis fit that most of ADR-146's axis work exists to provide.
531
+ *
532
+ * jsdom has no layout, so these cannot assert a measured WIDTH. What they can
533
+ * assert is the thing that actually regressed: that the box carrying the
534
+ * observer is a live node in the document after each remount, so the effect
535
+ * has something to re-observe.
536
+ */
537
+ describe('LineChart re-measures after the plot subtree remounts', () => {
538
+ /**
539
+ * Instrument `ResizeObserver` and record which nodes get observed. Asserting
540
+ * the plot node is merely PRESENT would pass in the broken version too — the
541
+ * node always mounts; what regressed is that the observer never rebound to
542
+ * it. So the assertion has to be on `observe()`.
543
+ */
544
+ function trackObserved() {
545
+ const observed: Element[] = []
546
+ const Original = globalThis.ResizeObserver
547
+ class Tracking {
548
+ observe(el: Element) {
549
+ observed.push(el)
550
+ }
551
+ unobserve() {}
552
+ disconnect() {}
553
+ }
554
+ globalThis.ResizeObserver = Tracking as unknown as typeof ResizeObserver
555
+ return {
556
+ observed,
557
+ restore: () => {
558
+ globalThis.ResizeObserver = Original
559
+ },
560
+ }
561
+ }
562
+
563
+ it('observes the plot box when loading resolves to loaded', () => {
564
+ const { observed, restore } = trackObserved()
565
+ try {
566
+ const { container, rerender } = render(
567
+ <LineChart
568
+ state="loading"
569
+ xLabels={xLabels}
570
+ series={series([10, 20, 30, 40])}
571
+ />,
572
+ )
573
+ const before = observed.length
574
+
575
+ rerender(
576
+ <LineChart
577
+ state="loaded"
578
+ xLabels={xLabels}
579
+ series={series([10, 20, 30, 40])}
580
+ />,
581
+ )
582
+
583
+ const plot = container.querySelector('svg circle')
584
+ expect(plot).not.toBeNull()
585
+ // The regression: zero NEW observations after the plot mounted, because
586
+ // the effect was keyed on a ref object that never changes.
587
+ expect(observed.length).toBeGreaterThan(before)
588
+ expect(observed.some((el) => el.isConnected)).toBe(true)
589
+ } finally {
590
+ restore()
591
+ }
592
+ })
593
+
594
+ it('re-observes the plot box after a table-view round trip', async () => {
595
+ const user = userEvent.setup()
596
+ const { observed, restore } = trackObserved()
597
+ try {
598
+ render(
599
+ <LineChart xLabels={xLabels} series={series([10, 20, 30, 40])} />,
600
+ )
601
+
602
+ await user.click(screen.getByRole('button', { name: /table/i }))
603
+ expect(screen.getByRole('table')).toBeTruthy()
604
+ const beforeReturn = observed.length
605
+
606
+ await user.click(screen.getByRole('button', { name: /chart/i }))
607
+ expect(observed.length).toBeGreaterThan(beforeReturn)
608
+ } finally {
609
+ restore()
610
+ }
611
+ })
612
+ })