@lovett/ui 0.0.10 → 0.1.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 (158) 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 +6064 -80
  4. package/dist/index.js +21561 -9696
  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 +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. package/src/tooltip.tsx +297 -0
@@ -0,0 +1,397 @@
1
+ /**
2
+ * DonutChart — the named edge cases from ADR-146 acceptance criterion 8.
3
+ *
4
+ * A donut is the chart where the arithmetic edge cases bite hardest: an
5
+ * empty series divides by zero, a single category is a degenerate arc, a
6
+ * sub-1% slice is a sub-pixel dash, and a ninth entity has no hue to be
7
+ * drawn in. Each of those is pinned below.
8
+ *
9
+ * The suite runs with `css: false`, so a test cannot measure a painted
10
+ * colour. What it CAN assert is the value handed to the browser — a
11
+ * dasharray, a token expression, a rendered percentage — which is where
12
+ * every one of these defects actually lives.
13
+ */
14
+ import { describe, expect, it, vi } from 'vitest'
15
+ import { fireEvent, render, screen } from '@testing-library/react'
16
+
17
+ import {
18
+ DONUT_OTHER_KEY,
19
+ DonutChart,
20
+ foldDonutData,
21
+ withMinimumArc,
22
+ type DonutChartDatum,
23
+ } from '../donut-chart'
24
+ import { SERIES_SLOT_COUNT, isSeriesSlot } from '../lib/series'
25
+
26
+ const EPS = 1e-9
27
+
28
+ function datum(key: string, value: number): DonutChartDatum {
29
+ return { key, label: key, value }
30
+ }
31
+
32
+ /** Every arc segment in the plot, in draw order. The track and the focus
33
+ * halo are excluded — they are the circles WITHOUT a dasharray. */
34
+ function arcs(container: HTMLElement): SVGCircleElement[] {
35
+ return Array.from(
36
+ container.querySelectorAll<SVGCircleElement>('circle[stroke-dasharray]'),
37
+ ).filter((el) => el.getAttribute('aria-hidden') !== 'true')
38
+ }
39
+
40
+ describe('foldDonutData — the series cap (ADR-146 D2)', () => {
41
+ it('never assigns a ninth hue', () => {
42
+ const data = Array.from({ length: 12 }, (_, i) => datum(`k${i}`, 10))
43
+ const { slices, foldedCount } = foldDonutData(data)
44
+
45
+ expect(slices).toHaveLength(SERIES_SLOT_COUNT)
46
+ expect(foldedCount).toBe(12 - (SERIES_SLOT_COUNT - 1))
47
+ for (const slice of slices) {
48
+ if (slice.slot === 'other') continue
49
+ expect(isSeriesSlot(slice.slot)).toBe(true)
50
+ }
51
+ })
52
+
53
+ it('folds the remainder into one Other slice carrying its total', () => {
54
+ const data = [
55
+ datum('a', 50),
56
+ datum('b', 30),
57
+ datum('c', 7),
58
+ datum('d', 3),
59
+ datum('e', 2),
60
+ ]
61
+ const { slices, foldedCount } = foldDonutData(data, 3)
62
+
63
+ expect(foldedCount).toBe(3)
64
+ expect(slices).toHaveLength(3)
65
+ const other = slices[2]!
66
+ expect(other.key).toBe(DONUT_OTHER_KEY)
67
+ expect(other.slot).toBe('other')
68
+ expect(other.value).toBeCloseTo(12, 10)
69
+ expect(other.foldedCount).toBe(3)
70
+ })
71
+
72
+ it('does not fold when the data fits, and keeps a pinned slot', () => {
73
+ const { slices, foldedCount } = foldDonutData([
74
+ { key: 'a', label: 'A', value: 1, slot: 5 },
75
+ datum('b', 1),
76
+ ])
77
+ expect(foldedCount).toBe(0)
78
+ // Colour follows the entity, never its rank (D7).
79
+ expect(slices[0]!.slot).toBe(5)
80
+ expect(slices[1]!.slot).toBe(1)
81
+ })
82
+
83
+ it('clamps an out-of-range cap instead of asking for a hue that does not exist', () => {
84
+ const data = Array.from({ length: 20 }, (_, i) => datum(`k${i}`, 1))
85
+ expect(foldDonutData(data, 99).slices).toHaveLength(SERIES_SLOT_COUNT)
86
+ expect(foldDonutData(data, 0).slices).toHaveLength(1)
87
+ })
88
+ })
89
+
90
+ describe('withMinimumArc — a sub-1% slice is still visible', () => {
91
+ it('lifts a tiny slice to the floor and still closes the ring', () => {
92
+ const lifted = withMinimumArc([0.997, 0.003], 0.012)
93
+ expect(lifted[1]!).toBeCloseTo(0.012, 10)
94
+ expect(lifted[0]! + lifted[1]!).toBeCloseTo(1, 10)
95
+ })
96
+
97
+ it('leaves fractions that already clear the floor untouched', () => {
98
+ const lifted = withMinimumArc([0.5, 0.3, 0.2], 0.012)
99
+ expect(lifted[0]! - 0.5).toBeLessThan(EPS)
100
+ expect(lifted[2]! - 0.2).toBeLessThan(EPS)
101
+ })
102
+
103
+ it('does not lift a genuine zero into a visible arc', () => {
104
+ const lifted = withMinimumArc([1, 0], 0.012)
105
+ expect(lifted[1]!).toBe(0)
106
+ })
107
+
108
+ it('degrades to an even split rather than demanding more ring than exists', () => {
109
+ const many = Array.from({ length: 200 }, () => 1 / 200)
110
+ const lifted = withMinimumArc(many, 0.05)
111
+ const total = lifted.reduce((sum, f) => sum + f, 0)
112
+ expect(total).toBeCloseTo(1, 8)
113
+ })
114
+
115
+ it('reports all zeroes for an all-zero series instead of dividing by zero', () => {
116
+ expect(withMinimumArc([0, 0, 0], 0.012)).toEqual([0, 0, 0])
117
+ })
118
+ })
119
+
120
+ describe('DonutChart edge cases', () => {
121
+ it('draws one complete ring and no gaps for a single category', () => {
122
+ const { container } = render(
123
+ <DonutChart data={[datum('only', 42)]} label="Solo" />,
124
+ )
125
+ const segments = arcs(container)
126
+ expect(segments).toHaveLength(1)
127
+ // A full turn: the whole circumference painted, nothing left over.
128
+ expect(segments[0]!.getAttribute('stroke-dasharray')).toMatch(/ 0$/)
129
+ expect(segments[0]!.getAttribute('stroke-dashoffset')).toBe('0')
130
+ // One cut in an unbroken ring reads as a rendering fault.
131
+ expect(container.querySelectorAll('line')).toHaveLength(0)
132
+ })
133
+
134
+ it('renders an empty ring plus an empty state for all-zero values', () => {
135
+ const { container } = render(
136
+ <DonutChart
137
+ data={[datum('a', 0), datum('b', 0)]}
138
+ emptyMessage="Nothing yet."
139
+ />,
140
+ )
141
+ expect(arcs(container)).toHaveLength(0)
142
+ expect(screen.getByText('Nothing yet.')).toBeInTheDocument()
143
+ // The track still paints, so the widget reads as an empty ring rather
144
+ // than as a missing element.
145
+ const track = container.querySelector('circle:not([stroke-dasharray])')
146
+ expect(track).not.toBeNull()
147
+ expect(track!.getAttribute('stroke')).toBe('rgb(var(--muted))')
148
+ })
149
+
150
+ it('gives a <1% slice a visible arc while the legend reports the true share', () => {
151
+ const { container } = render(
152
+ <DonutChart data={[datum('big', 997), datum('tiny', 3)]} />,
153
+ )
154
+ const segments = arcs(container)
155
+ expect(segments).toHaveLength(2)
156
+
157
+ const dash = Number(
158
+ segments[1]!.getAttribute('stroke-dasharray')!.split(' ')[0],
159
+ )
160
+ const circumference =
161
+ dash + Number(segments[1]!.getAttribute('stroke-dasharray')!.split(' ')[1])
162
+ // Lifted to the 1.2% floor, well above the 0.3% it would otherwise get.
163
+ expect(dash / circumference).toBeGreaterThan(0.01)
164
+
165
+ // ...and the number the reader compares is still the TRUE fraction —
166
+ // reported as "<1%" rather than "0%", because a slice that is visibly
167
+ // drawn and labelled as nothing reads as a rendering fault.
168
+ expect(screen.getByText('tiny (<1%)')).toBeInTheDocument()
169
+ expect(screen.getByText('big (100%)')).toBeInTheDocument()
170
+ })
171
+
172
+ it('scales the sub-rounding label to the requested precision', () => {
173
+ render(
174
+ <DonutChart
175
+ data={[datum('big', 99970), datum('tiny', 30)]}
176
+ percentDecimals={1}
177
+ />,
178
+ )
179
+ expect(screen.getByText('tiny (<0.1%)')).toBeInTheDocument()
180
+ })
181
+
182
+ it('puts the percentage inside the legend label, not in a third column', () => {
183
+ render(
184
+ <DonutChart
185
+ data={[datum('Website', 50), datum('Referral', 50)]}
186
+ formatValue={(v) => `${v}`}
187
+ />,
188
+ )
189
+ expect(screen.getByText('Website (50%)')).toBeInTheDocument()
190
+ expect(screen.getByText('Referral (50%)')).toBeInTheDocument()
191
+ })
192
+
193
+ it('makes the fold explicit in the legend', () => {
194
+ const data = Array.from({ length: 11 }, (_, i) => datum(`k${i}`, 10))
195
+ render(<DonutChart data={data} />)
196
+ expect(screen.getByText(/^Other \(/)).toBeInTheDocument()
197
+ expect(screen.getByText('4 smaller categories')).toBeInTheDocument()
198
+ })
199
+ })
200
+
201
+ describe('DonutChart interaction', () => {
202
+ const data = [datum('a', 60), datum('b', 40)]
203
+
204
+ it('raises the hovered segment and shows a tooltip', () => {
205
+ const { container } = render(<DonutChart data={data} />)
206
+ const segments = arcs(container)
207
+ const base = segments[0]!.getAttribute('stroke-width')
208
+
209
+ expect(container.querySelector('[role="tooltip"]')).toBeNull()
210
+ fireEvent.mouseEnter(segments[0]!.parentElement!)
211
+
212
+ const raised = arcs(container)[0]!.getAttribute('stroke-width')
213
+ expect(Number(raised)).toBeGreaterThan(Number(base))
214
+ expect(container.querySelector('[role="tooltip"]')).not.toBeNull()
215
+
216
+ fireEvent.mouseLeave(segments[0]!.parentElement!)
217
+ expect(container.querySelector('[role="tooltip"]')).toBeNull()
218
+ })
219
+
220
+ it('is keyboard reachable and activates on Enter', () => {
221
+ const onSelect = vi.fn()
222
+ render(<DonutChart data={data} onSelect={onSelect} />)
223
+
224
+ const marks = screen.getAllByRole('button', { name: /^a,/ })
225
+ const mark = marks[0]!
226
+ expect(mark.getAttribute('tabindex')).toBe('0')
227
+
228
+ fireEvent.focus(mark)
229
+ fireEvent.keyDown(mark, { key: 'Enter' })
230
+ expect(onSelect).toHaveBeenCalledWith('a')
231
+ })
232
+
233
+ it('cuts deep enough to still divide a RAISED segment', () => {
234
+ // The hover "raise" grows the stroke around the same centreline, so a cut
235
+ // sized for the resting stroke leaves a coloured nub at each end of the
236
+ // hovered segment. The cut has to reach past the widest stroke a segment
237
+ // can paint.
238
+ const { container } = render(<DonutChart data={data} />)
239
+ const strokeWidth = Number(arcs(container)[0]!.getAttribute('stroke-width'))
240
+
241
+ fireEvent.mouseEnter(arcs(container)[0]!.parentElement!)
242
+ const raised = Number(arcs(container)[0]!.getAttribute('stroke-width'))
243
+ expect(raised).toBeGreaterThan(strokeWidth)
244
+
245
+ const cut = container.querySelector('line')!
246
+ const length = Math.hypot(
247
+ Number(cut.getAttribute('x2')) - Number(cut.getAttribute('x1')),
248
+ Number(cut.getAttribute('y2')) - Number(cut.getAttribute('y1')),
249
+ )
250
+ expect(length).toBeGreaterThanOrEqual(raised)
251
+ })
252
+
253
+ it('dims nothing when a controlled activeKey no longer names a slice', () => {
254
+ // A filter can drop the series that was hovered. The chart must not grey
255
+ // itself out with no segment raised to explain why.
256
+ const { container } = render(
257
+ <DonutChart data={data} activeKey="removed-by-a-filter" />,
258
+ )
259
+ for (const arc of arcs(container)) {
260
+ expect(arc.getAttribute('opacity')).toBe('1')
261
+ }
262
+ expect(container.querySelector('[role="tooltip"]')).toBeNull()
263
+ })
264
+
265
+ it('is inert — no button role — when no onSelect is given', () => {
266
+ const { container } = render(<DonutChart data={data} />)
267
+ expect(container.querySelectorAll('g[role="button"]')).toHaveLength(0)
268
+ expect(container.querySelectorAll('g[role="img"]')).toHaveLength(2)
269
+ })
270
+
271
+ it('swaps the centre readout to the hovered segment without unmounting it', () => {
272
+ const { container } = render(
273
+ <DonutChart
274
+ data={data}
275
+ centerValue="100"
276
+ centerCaption="Sessions"
277
+ formatValue={(v) => `${v}`}
278
+ />,
279
+ )
280
+ expect(screen.getByText('100')).toBeInTheDocument()
281
+ expect(screen.getByText('Sessions')).toBeInTheDocument()
282
+
283
+ fireEvent.mouseEnter(arcs(container)[0]!.parentElement!)
284
+ // "60" now reads in two places — the centre and the tooltip — which is
285
+ // the point: they are the same number resolved the same way.
286
+ expect(screen.getAllByText('60').length).toBeGreaterThanOrEqual(1)
287
+ expect(screen.queryByText('100')).toBeNull()
288
+ expect(screen.getAllByText('a').length).toBeGreaterThanOrEqual(1)
289
+ })
290
+ })
291
+
292
+ /**
293
+ * The two narrow-width defects measured in a real browser on 2026-09-06,
294
+ * light and dark, at a 390px viewport.
295
+ *
296
+ * WHAT jsdom CAN SEE, AND WHAT IT CANNOT
297
+ * --------------------------------------
298
+ * These tests assert the MECHANISM — the geometry handed to the browser and
299
+ * the layout contract the fix depends on. They cannot prove the fix, because
300
+ * jsdom has no layout: every `getBoundingClientRect()` is zero, no flex line
301
+ * ever wraps, and the measured type-fit therefore stays at scale 1 here.
302
+ *
303
+ * The proof is a browser measurement, and it is the one that gates this:
304
+ * - legend labels: `width` > 0 and `scrollWidth <= width` at 390 / 900 /
305
+ * 1440 in both themes;
306
+ * - centre readout: its client rect sits inside the hole's rect for the
307
+ * resting value AND for every value a focused segment can swap in.
308
+ * Both were run against the running app; neither is expressible below.
309
+ */
310
+ describe('narrow widths — the legend and the centre (browser-measured)', () => {
311
+ const data = [datum('a', 60), datum('b', 40)]
312
+
313
+ it('lays the row out so the legend can WRAP rather than be starved', () => {
314
+ // The plot is `shrink-0`, so without `flex-wrap` a root narrower than
315
+ // plot + gap + legend gives the legend 0px and every label renders at
316
+ // `width: 0` — five series identified by colour alone. Measured at 390:
317
+ // legend column 0px wide, labels 0px against scrollWidths of 73-124px.
318
+ const { container } = render(<DonutChart data={data} />)
319
+ const root = container.firstElementChild
320
+ expect(root?.className).toContain('flex-wrap')
321
+ })
322
+
323
+ it('does not put a wrap basis on the column layout, where it would size height', () => {
324
+ // `flex-basis` in a column container is a HEIGHT, so the row layout's
325
+ // basis must not leak into `orientation="column"`.
326
+ const { container } = render(<DonutChart data={data} orientation="column" />)
327
+ const legend = container.querySelector('ul[aria-label$="legend"]')
328
+ const column = legend?.parentElement
329
+ expect(column?.getAttribute('style')).toContain('flex: 1 1 auto')
330
+ })
331
+
332
+ it('gives every legend label a width floor and a recoverable full label', () => {
333
+ // The floor is what a starved flex column runs into instead of crushing
334
+ // the label to zero, and `min(..., 100%)` is what keeps the floor from
335
+ // overflowing its container instead. `title` makes a truncated label
336
+ // recoverable — identity is never colour alone (ADR-146 D10.1).
337
+ const { container } = render(<DonutChart data={data} />)
338
+ const rows = container.querySelectorAll<HTMLElement>(
339
+ 'ul[aria-label$="legend"] li',
340
+ )
341
+ expect(rows.length).toBe(2)
342
+ for (const row of rows) {
343
+ const label = row.querySelector<HTMLElement>('span[title]')
344
+ expect(label).not.toBeNull()
345
+ expect(label?.title).not.toBe('')
346
+ const wrapper = label?.parentElement
347
+ expect(wrapper?.style.minWidth).toBe(
348
+ 'min(calc(var(--space-12) * 2), 100%)',
349
+ )
350
+ expect(row.firstElementChild?.className).toContain('flex-wrap')
351
+ }
352
+ })
353
+
354
+ it('reserves the centre against the HOLE, not the plot box', () => {
355
+ // size 160, default thickness -> stroke 27; outerRadius 80 - EDGE_RESERVE
356
+ // (4) = 76; centreline radius 76 - 27/2 = 62.5; hole at the RAISED stroke
357
+ // = 2*62.5 - 27 - HOVER_BUMP(4) = 94. Before the fix the readout was
358
+ // `inset-0` on the 160px plot box, and "$116,120" painted onto the arcs
359
+ // at both ends.
360
+ const { container } = render(
361
+ <DonutChart data={data} centerValue="$116,120" centerCaption="Spend" />,
362
+ )
363
+ const reserve = container.querySelector<HTMLElement>(
364
+ '.pointer-events-none.absolute > div',
365
+ )
366
+ expect(reserve).not.toBeNull()
367
+ expect(reserve?.style.width).toBe('94px')
368
+ expect(reserve?.style.width).not.toBe('160px')
369
+ })
370
+
371
+ it('tracks the hole through size and thickness', () => {
372
+ // 120 / stroke 12: outerRadius 60 - 4 = 56; radius 56 - 6 = 50;
373
+ // hole = 100 - 12 - 4 = 84.
374
+ const { container } = render(
375
+ <DonutChart data={data} size={120} thickness={12} centerValue="1" />,
376
+ )
377
+ const reserve = container.querySelector<HTMLElement>(
378
+ '.pointer-events-none.absolute > div',
379
+ )
380
+ expect(reserve?.style.width).toBe('84px')
381
+ })
382
+
383
+ it('keeps the measurement gauges out of the readable tree', () => {
384
+ // The fit is measured by filling ONE empty span per line, not by
385
+ // rendering every candidate as a hidden node — hidden duplicates of the
386
+ // value would put the same string in the DOM twice.
387
+ render(
388
+ <DonutChart
389
+ data={data}
390
+ centerValue="100"
391
+ centerCaption="Sessions"
392
+ formatValue={(v) => `${v}`}
393
+ />,
394
+ )
395
+ expect(screen.getAllByText('100')).toHaveLength(1)
396
+ })
397
+ })