@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,988 @@
1
+ /**
2
+ * DonutChart — arc segments plus a divided legend list.
3
+ *
4
+ * Promoted in ADR-146 D5 (chart layer). It consumes `arcSegments` from
5
+ * `lib/chart.ts` rather than writing a fourth copy of the cumulative
6
+ * dasharray accumulator that `spec/shell-charts.tsx:63-91` and
7
+ * `completion-ring.tsx:58-64` already write (ADR-146 D6).
8
+ *
9
+ * CONSUMERS — measured 2026-09-06 (real `from '@lovett/ui'` imports under
10
+ * `apps/workspace/src`; the design-system gallery is a demo harness and does
11
+ * NOT count toward the ADR-008 D3 gate).
12
+ * TODAY (0 — gate NOT MET).
13
+ * PLANNED: an admin usage-mix panel (`components/ui/admin/analytics.tsx`
14
+ * renders that share as `RankedBars` today) and a discovery traffic-source
15
+ * panel. Neither is written. Tracked in
16
+ * `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
17
+ *
18
+ * ANATOMY (Metoric spec §6.3, real measurements)
19
+ * ----------------------------------------------
20
+ * plot square, 150-160px, stroke-based arcs on ONE centreline circle
21
+ * gap 2px of the SURFACE colour between adjacent segments, so the
22
+ * ring reads as separate marks rather than one continuous band
23
+ * centre optional: value large and **set solid** (line-height 1 — the
24
+ * kit's own rule, and what stops a metric looking loose), a
25
+ * muted caption below it, both `tabular-nums`
26
+ * legend swatch + label + value, 1px dividers between rows,
27
+ * `--space-4` vertical padding, and the **percentage inside the
28
+ * label string** ("Website (50%)") rather than in a third
29
+ * column. That is the kit's choice and it is the right one: a
30
+ * third column makes the row scan as a table, not a key.
31
+ *
32
+ * WHAT THIS PRIMITIVE DELIBERATELY DOES NOT DO
33
+ * --------------------------------------------
34
+ * It does **not** compose `ChartFrame`. A donut has no axis, no gridlines and
35
+ * no baseline, and `ChartFrame` always draws its darker `--chart-axis`
36
+ * baseline under the plot box — correct for every other chart in the set and
37
+ * wrong under a ring. Its plot box is also a single full-width column, which
38
+ * cannot hold the kit's plot-beside-legend layout. So `DonutChart` renders
39
+ * the plot and its legend only, and a caller who wants a titled panel with a
40
+ * table view wraps it in a `Card`. (`chart-frame.tsx`'s header currently
41
+ * lists `DonutChart` among its consumers; that line is stale — logged as a
42
+ * follow-up rather than edited here, since another phase owns that file.)
43
+ *
44
+ * THE SERIES CAP (ADR-146 D2) — WHY THE FOLD IS A FUNCTION
45
+ * --------------------------------------------------------
46
+ * Beyond eight entities the remainder folds into ONE "Other" segment in a
47
+ * neutral grey. We never generate a ninth hue: two entities drawn in one
48
+ * colour is a wrong chart that no gate can see. The fold is `foldDonutData`,
49
+ * exported and pure, so the folded count is available to a caller ("and 4
50
+ * more") without re-deriving it, and so the cap is unit-testable without a
51
+ * DOM. It is also visible in the legend, as a `meta` line under the "Other"
52
+ * row — a silent fold would be a data loss the reader cannot detect.
53
+ *
54
+ * Colour follows the ENTITY, never its rank (D7). Slot defaults to the
55
+ * datum's position in `data`, and a datum may pin its own `slot` so a filter
56
+ * that removes a series cannot repaint the survivors.
57
+ *
58
+ * EDGE CASES, ALL DELIBERATE
59
+ * --------------------------
60
+ * - **One category** → a single complete ring, and no separators. A lone cut
61
+ * in a full ring reads as a rendering fault.
62
+ * - **All values zero (or empty `data`)** → the track ring paints in `--muted`
63
+ * and the legend is replaced by `emptyMessage`. `arcSegments` returns
64
+ * fractions of `0` here rather than dividing by zero, and it does not
65
+ * split the ring evenly, which would invent data that is not there.
66
+ * - **A slice under 1%** → it still gets a **minimum visible arc**
67
+ * (`minSliceFraction`, default `0.012` ≈ 4.3°). Without this a 0.3% slice
68
+ * is a sub-pixel dash: present in the legend, invisible in the ring, which
69
+ * reads as a bug. The lift is taken proportionally out of the slices that
70
+ * are above the floor, so the ring still closes exactly. **The legend
71
+ * percentage is always the TRUE fraction** — the lift is a drawing
72
+ * correction and must never leak into a number the reader compares.
73
+ *
74
+ * TWO NARROW-WIDTH DEFECTS, FOUND BY MEASURING A BROWSER (fixed 2026-09-06)
75
+ * -------------------------------------------------------------------------
76
+ * Both were measured at a 390px viewport in BOTH themes, and neither is
77
+ * visible to a jsdom test — the source read correctly in both cases.
78
+ *
79
+ * 1. **The legend labels collapsed to zero width.** The plot is `shrink-0`,
80
+ * so on a 164px-wide root it kept all 160px and the `flex-1` legend beside
81
+ * it got 0. Every label rendered at `width: 0` (`scrollWidth` 73-124),
82
+ * leaving swatch + value and five series told apart by hue alone. The row
83
+ * now wraps (`flex-wrap` + `LEGEND_BASIS`): below ~372px the legend moves
84
+ * UNDER the plot at full width instead of being starved.
85
+ *
86
+ * 2. **The centre readout overflowed the hole and painted on the arcs.** It
87
+ * was `inset-0` on the whole 160px plot box, so "$116,120" spanned x
88
+ * 96-212 across a hole of x 100-208 — the "$" on one arc, the trailing "0"
89
+ * on another. It is now sized to the hole (inner diameter at the RAISED
90
+ * stroke, less `--space-1`), and its type is scaled once, from a measured
91
+ * gauge, against the WIDEST string the slot can ever hold — the resting
92
+ * total and every value a hover can swap in. That is what makes the slot
93
+ * genuinely reserved: hovering changes the string, never the size.
94
+ *
95
+ * Token discipline: every colour is `rgb(var(--token))`. Series hues resolve
96
+ * through `slotColor` and the frozen `SERIES` lookup in `lib/series.ts` (D8 —
97
+ * never a constructed `--series-${i}`), which is also what makes the arc and
98
+ * its legend swatch the same colour by construction. The "Other" fold takes
99
+ * that module's shared `OTHER_SLOT_COLOR` neutral for the same reason. Every
100
+ * spacing, radius, duration and easing is a token. The only bare numbers are
101
+ * SVG geometry — a viewBox cannot read a CSS custom property — and each one
102
+ * is named.
103
+ */
104
+
105
+ import { useEffect, useRef, useState } from 'react'
106
+
107
+ import { ChartLegend, type ChartLegendItem } from './chart-legend'
108
+ import { ChartTooltip } from './chart-tooltip'
109
+ import { formatNumber, formatPercent } from './format'
110
+ import { arcGeometry, arcSegments } from './lib/chart'
111
+ import {
112
+ SERIES_SLOT_COUNT,
113
+ isSeriesSlot,
114
+ slotColor,
115
+ type ChartSlot,
116
+ type SeriesPalette,
117
+ type SeriesSlot,
118
+ } from './lib/series'
119
+ import { cn } from './lib/utils'
120
+
121
+ /* ── geometry constants ──────────────────────────────────────────────────
122
+ * Pixel arithmetic in the SVG's own coordinate space. These cannot be
123
+ * tokens: a `viewBox` is unitless and cannot resolve `var()`.
124
+ */
125
+
126
+ /** Extra stroke a hovered/focused segment gains, in viewBox units. */
127
+ const HOVER_BUMP = 4
128
+ /** Extra stroke the focus halo gains over the base stroke. */
129
+ const FOCUS_HALO = 8
130
+ /**
131
+ * Half the widest extra stroke, reserved inside the square so a raised or
132
+ * focused segment cannot paint outside the plot box. This is the layout-
133
+ * stability rule applied to a ring: the box never grows, the mark does.
134
+ */
135
+ const EDGE_RESERVE = FOCUS_HALO / 2
136
+ /** Stroke width of the surface-coloured cut drawn between adjacent segments. */
137
+ const SEGMENT_GAP = 2
138
+ /** Opacity of a non-active segment while another one is active. Matches
139
+ * `ChartLegend`'s row dim, so plot and legend fade together. */
140
+ const DIM_OPACITY = 0.55
141
+
142
+ /**
143
+ * Base type size of the centre value, in px, before the fit scale below is
144
+ * applied. On the type ramp (24 / 18 / 16 / 14 / 12); it is a number rather
145
+ * than a `text-[24px]` class because the fit multiplies it.
146
+ */
147
+ const CENTER_VALUE_FONT_PX = 24
148
+ /**
149
+ * Floor for the centre fit. Below this the readout would be smaller than the
150
+ * caption under it and the hierarchy would invert; no plausible formatted
151
+ * value reaches it (at 12px a tabular string clears a 94px hole up to ~15
152
+ * characters).
153
+ */
154
+ const CENTER_MIN_SCALE = 0.5
155
+ /** Base type size of the centre caption, in px, before its own fit scale. */
156
+ const CENTER_CAPTION_FONT_PX = 12
157
+ /**
158
+ * Floor for the caption fit. A caption is prose and may ellipsis as a last
159
+ * resort; it must not shrink past legibility to avoid one.
160
+ */
161
+ const CAPTION_MIN_SCALE = 0.8
162
+
163
+ /**
164
+ * Flex basis of the legend column in the `row` layout. Below this the legend
165
+ * WRAPS under the plot instead of being starved.
166
+ *
167
+ * Measured 2026-09-06, light and dark, 390px viewport: the plot is
168
+ * `shrink-0` at `size` px, so on a 164px-wide root it kept all 160px and the
169
+ * `flex-1` legend beside it was allotted 0 — every label rendered at
170
+ * `width: 0` against a `scrollWidth` of 73-124px, leaving five series
171
+ * identified by colour alone (ADR-146 D10.1). `flex-wrap` plus this basis is
172
+ * what makes the row reflow instead.
173
+ *
174
+ * Token-derived, and sized from the legend row it has to hold: swatch
175
+ * (`--space-3`) + two `--space-3` gaps + a readable label + the reserved
176
+ * value column (`--space-12`).
177
+ */
178
+ const LEGEND_BASIS = 'calc(var(--space-12) * 4)'
179
+
180
+ /** The synthetic key of the folded "Other" slice. */
181
+ export const DONUT_OTHER_KEY = 'donut:other'
182
+
183
+ /** Which surface the inter-segment gap is cut in. */
184
+ export type DonutGapSurface = 'card' | 'frame' | 'none'
185
+
186
+ /**
187
+ * Frozen literal lookup, the same shape as `metric-card.tsx`'s `TONE_COLOR`.
188
+ * The gap has to be painted in whatever surface the donut actually sits on,
189
+ * and a caller cannot be trusted with a raw colour — so it picks a surface by
190
+ * name and the token is resolved here.
191
+ */
192
+ const GAP_COLOR: Record<Exclude<DonutGapSurface, 'none'>, string> = {
193
+ card: 'rgb(var(--surface-card))',
194
+ frame: 'rgb(var(--surface-frame))',
195
+ }
196
+
197
+ export interface DonutChartDatum {
198
+ /**
199
+ * Stable identity key. Also the React key, the `onSelect` payload, and —
200
+ * per ADR-146 D7 — what colour follows. Keep it stable across filters.
201
+ */
202
+ key: string
203
+ /** Category name. Rendered in the legend and the segment's accessible
204
+ * name; identity is never colour alone (D10.1). */
205
+ label: string
206
+ /** The measure. Negative and non-finite values read as `0` — an arc
207
+ * cannot sweep backwards, and a share chart with negative shares is a
208
+ * different chart. */
209
+ value: number
210
+ /**
211
+ * Pin this entity's palette slot. Omit and the slot is the datum's
212
+ * position in `data`. Pass it when the same entity must keep its hue
213
+ * across a filter that changes its position (D7 — colour follows the
214
+ * entity, never its rank).
215
+ */
216
+ slot?: SeriesSlot
217
+ /** Optional muted second line under the legend label. */
218
+ meta?: string
219
+ }
220
+
221
+ /** One rendered slice: a datum, or the folded "Other" remainder. */
222
+ export interface DonutSlice {
223
+ readonly key: string
224
+ readonly label: string
225
+ readonly value: number
226
+ readonly slot: ChartSlot
227
+ readonly meta: string | undefined
228
+ /** `0` for a real entity; the number of categories folded in for "Other". */
229
+ readonly foldedCount: number
230
+ }
231
+
232
+ /** The result of applying the ADR-146 D2 series cap. */
233
+ export interface DonutFold {
234
+ /** Slices to draw, in order. At most `maxSlices` long. */
235
+ readonly slices: readonly DonutSlice[]
236
+ /** How many categories were folded into "Other". `0` when nothing folded. */
237
+ readonly foldedCount: number
238
+ }
239
+
240
+ /**
241
+ * Apply the series cap: keep the first `maxSlices - 1` categories and fold
242
+ * every remaining one into a single neutral "Other" slice (ADR-146 D2).
243
+ *
244
+ * Exported and pure so a caller can read `foldedCount` without a DOM, and so
245
+ * the cap is testable on its own. `maxSlices` is clamped into
246
+ * `1..SERIES_SLOT_COUNT`: a cap above eight would ask the palette for a hue
247
+ * it does not have, and a cap below one has no meaning.
248
+ *
249
+ * Input order is preserved, and a datum's own `slot` always wins over its
250
+ * position.
251
+ */
252
+ export function foldDonutData(
253
+ data: readonly DonutChartDatum[],
254
+ maxSlices: number = SERIES_SLOT_COUNT,
255
+ otherLabel = 'Other',
256
+ ): DonutFold {
257
+ const cap = Number.isFinite(maxSlices)
258
+ ? Math.max(1, Math.min(Math.floor(maxSlices), SERIES_SLOT_COUNT))
259
+ : SERIES_SLOT_COUNT
260
+
261
+ const toSlice = (datum: DonutChartDatum, index: number): DonutSlice => ({
262
+ key: datum.key,
263
+ label: datum.label,
264
+ value: datum.value,
265
+ slot: datum.slot ?? (isSeriesSlot(index) ? index : 'other'),
266
+ meta: datum.meta,
267
+ foldedCount: 0,
268
+ })
269
+
270
+ if (data.length <= cap) {
271
+ return { slices: data.map(toSlice), foldedCount: 0 }
272
+ }
273
+
274
+ const kept = data.slice(0, cap - 1).map(toSlice)
275
+ const folded = data.slice(cap - 1)
276
+ let total = 0
277
+ for (const datum of folded) {
278
+ // Same clamp `arcSegments` applies, so the "Other" arc and the "Other"
279
+ // value can never disagree.
280
+ if (Number.isFinite(datum.value) && datum.value > 0) total += datum.value
281
+ }
282
+
283
+ return {
284
+ slices: [
285
+ ...kept,
286
+ {
287
+ key: DONUT_OTHER_KEY,
288
+ label: otherLabel,
289
+ value: total,
290
+ slot: 'other',
291
+ meta: undefined,
292
+ foldedCount: folded.length,
293
+ },
294
+ ],
295
+ foldedCount: folded.length,
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Lift every non-zero fraction to at least `minFraction`, taking the lift
301
+ * proportionally out of the slices that sit above the floor so the total
302
+ * stays exactly `1` and the ring still closes.
303
+ *
304
+ * Exported for tests: "a 0.3% slice is still visible AND the ring is still
305
+ * full" is the contract, and it is far easier to assert as arithmetic than as
306
+ * a rendered dasharray.
307
+ *
308
+ * The floor is itself capped at `1 / n`, so a chart of many tiny slices
309
+ * degrades to an even split rather than demanding more ring than exists.
310
+ */
311
+ export function withMinimumArc(
312
+ fractions: readonly number[],
313
+ minFraction: number,
314
+ ): number[] {
315
+ const positives = fractions.filter((f) => f > 0).length
316
+ if (positives === 0) return fractions.map(() => 0)
317
+ if (!Number.isFinite(minFraction) || minFraction <= 0) return [...fractions]
318
+
319
+ const floor = Math.min(minFraction, 1 / positives)
320
+ const lifted = fractions.map((f) => (f > 0 && f < floor ? floor : f))
321
+
322
+ let total = 0
323
+ for (const f of lifted) total += f
324
+ const deficit = total - 1
325
+ if (deficit <= 0) return lifted
326
+
327
+ let surplus = 0
328
+ for (const f of lifted) if (f > floor) surplus += f - floor
329
+ if (surplus <= 0) return lifted.map((f) => (total > 0 ? f / total : 0))
330
+
331
+ const ratio = Math.min(1, deficit / surplus)
332
+ return lifted.map((f) => (f > floor ? f - (f - floor) * ratio : f))
333
+ }
334
+
335
+ export interface DonutChartProps {
336
+ /** The categories, in draw order (twelve o'clock, clockwise). */
337
+ data: readonly DonutChartDatum[]
338
+ /**
339
+ * Coloured slices before the "Other" fold. Clamped to `1..8`; default `8`
340
+ * (ADR-146 D2 — the palette has eight slots and never generates a ninth).
341
+ */
342
+ maxSlices?: number
343
+ /** Label for the folded slice. Default `'Other'`. */
344
+ otherLabel?: string
345
+ /**
346
+ * Meta line under the "Other" legend row, so the fold is visible rather
347
+ * than silent. Receives the folded count. Default
348
+ * `` (n) => `${n} smaller categories` ``.
349
+ */
350
+ otherMeta?: (foldedCount: number) => string
351
+ /** Plot edge in px. Default `160` — the kit's donut is 150-160. */
352
+ size?: number
353
+ /** Ring stroke in px. Default `size / 6`, floored at 6. */
354
+ thickness?: number
355
+ /**
356
+ * The smallest fraction of the ring any non-zero slice may occupy, so a
357
+ * sub-1% category is still a visible mark. Default `0.012` (≈4.3°). Set
358
+ * `0` to draw true fractions only. Never affects the legend percentage.
359
+ */
360
+ minSliceFraction?: number
361
+ /** Which palette the slots index into. Default `'default'`. */
362
+ palette?: SeriesPalette
363
+ /**
364
+ * Surface the 2px inter-segment gap is cut in — it has to match whatever
365
+ * the donut sits on. Default `'card'`; `'none'` draws one continuous ring.
366
+ */
367
+ gapSurface?: DonutGapSurface
368
+ /**
369
+ * Formats a value for the legend, the tooltip and the centre. Default
370
+ * `formatNumber`. Format through `@lovett/ui/format`.
371
+ */
372
+ formatValue?: (value: number) => string
373
+ /** Decimal places on the legend/tooltip percentage. Default `0`. */
374
+ percentDecimals?: number
375
+ /**
376
+ * Centre value when nothing is hovered — usually the total. Omit for a
377
+ * plain ring. While a segment is active the centre shows THAT segment
378
+ * instead, which is the whole reason the slot is reserved rather than
379
+ * conditionally rendered.
380
+ */
381
+ centerValue?: string
382
+ /** Muted caption under the centre value. Often the measure's name. */
383
+ centerCaption?: string
384
+ /** Renders the legend. Default `true`. */
385
+ showLegend?: boolean
386
+ /** Legend beside the plot (`'row'`, default) or under it (`'column'`). */
387
+ orientation?: 'row' | 'column'
388
+ /** Reserved width of the legend's value column. Default `var(--space-12)`. */
389
+ legendValueMinWidth?: string
390
+ /**
391
+ * Controlled active (hovered/focused) key. Omit for uncontrolled. `null`
392
+ * means nothing is active.
393
+ */
394
+ activeKey?: string | null
395
+ /** Fires whenever the active segment changes, controlled or not. */
396
+ onActiveChange?: (key: string | null) => void
397
+ /**
398
+ * Makes segments and legend rows activatable. Receives the slice key —
399
+ * `DONUT_OTHER_KEY` for the fold. Omit for a read-only chart: an inert
400
+ * mark must not advertise an interaction it does not have.
401
+ */
402
+ onSelect?: (key: string) => void
403
+ /** Shown in place of the legend when nothing has a positive value. */
404
+ emptyMessage?: string
405
+ /** Accessible name for the plot. Default `'Donut chart'`. */
406
+ label?: string
407
+ /** Optional className on the outer container. */
408
+ className?: string
409
+ }
410
+
411
+ export function DonutChart({
412
+ data,
413
+ maxSlices = SERIES_SLOT_COUNT,
414
+ otherLabel = 'Other',
415
+ otherMeta = (count) => `${count} smaller categories`,
416
+ size = 160,
417
+ thickness,
418
+ minSliceFraction = 0.012,
419
+ palette = 'default',
420
+ gapSurface = 'card',
421
+ formatValue = (value) => formatNumber(value),
422
+ percentDecimals = 0,
423
+ centerValue,
424
+ centerCaption,
425
+ showLegend = true,
426
+ orientation = 'row',
427
+ legendValueMinWidth = 'var(--space-12)',
428
+ activeKey,
429
+ onActiveChange,
430
+ onSelect,
431
+ emptyMessage = 'No data to chart.',
432
+ label = 'Donut chart',
433
+ className,
434
+ }: DonutChartProps) {
435
+ const [internalActive, setInternalActive] = useState<string | null>(null)
436
+ const controlled = activeKey !== undefined
437
+ const active = controlled ? activeKey : internalActive
438
+
439
+ function setActive(next: string | null) {
440
+ if (!controlled) setInternalActive(next)
441
+ onActiveChange?.(next)
442
+ }
443
+
444
+ /* ── geometry ─────────────────────────────────────────────────────── */
445
+
446
+ const box = Math.max(0, size)
447
+ const stroke = Math.max(6, thickness ?? Math.round(box / 6))
448
+ // The raised/focused stroke has to fit, so the ring is inset by half the
449
+ // widest extra stroke. The box never changes size; the mark does.
450
+ const outerRadius = Math.max(0, box / 2 - EDGE_RESERVE)
451
+ const { radius } = arcGeometry(outerRadius, stroke)
452
+ const center = box / 2
453
+
454
+ const { slices, foldedCount } = foldDonutData(data, maxSlices, otherLabel)
455
+
456
+ // TRUE fractions — what the legend and the tooltip report. `arcSegments`
457
+ // clamps negatives to 0 and returns 0 for every slice when the total is 0,
458
+ // so there is no divide-by-zero to guard here.
459
+ const trueSegments = arcSegments(
460
+ slices.map((slice) => slice.value),
461
+ outerRadius,
462
+ stroke,
463
+ )
464
+ const positiveCount = trueSegments.filter((seg) => seg.fraction > 0).length
465
+ const isEmpty = positiveCount === 0
466
+
467
+ // DISPLAY fractions — the same shares with a minimum visible arc applied.
468
+ // Drawn geometry only; never reported as a number.
469
+ const drawnSegments = arcSegments(
470
+ withMinimumArc(
471
+ trueSegments.map((seg) => seg.fraction),
472
+ minSliceFraction,
473
+ ),
474
+ outerRadius,
475
+ stroke,
476
+ )
477
+
478
+ /* ── the plot's rendered width, for tooltip anchoring ─────────────── */
479
+
480
+ const plotRef = useRef<HTMLDivElement>(null)
481
+ const [plotPx, setPlotPx] = useState(box)
482
+
483
+ useEffect(() => {
484
+ const el = plotRef.current
485
+ if (el === null) return
486
+
487
+ function measure() {
488
+ if (el === null) return
489
+ const width = el.getBoundingClientRect().width
490
+ setPlotPx(width > 0 ? width : box)
491
+ }
492
+
493
+ measure()
494
+ // jsdom ships no ResizeObserver (the suite stubs a no-op one), and a
495
+ // fixed-size donut never resizes — the measurement is only here so the
496
+ // tooltip still lands on its segment when the plot shrinks under
497
+ // `max-width: 100%` on a narrow viewport.
498
+ if (typeof ResizeObserver === 'undefined') return
499
+ const observer = new ResizeObserver(measure)
500
+ observer.observe(el)
501
+ return () => observer.disconnect()
502
+ }, [box])
503
+
504
+ const scale = box > 0 ? plotPx / box : 1
505
+
506
+ /* ── the hole, and the centre readout that has to fit inside it ─────── */
507
+
508
+ // The hole is the ring's INNER diameter, and it is measured against the
509
+ // RAISED stroke, not the resting one: a hovered segment grows inward by
510
+ // HOVER_BUMP / 2, so a reserve computed from the resting ring would let the
511
+ // centre collide with an arc at exactly the moment a value is being read
512
+ // off it. Using the raised figure makes the reserve constant in every
513
+ // state, which is the point of reserving it at all.
514
+ //
515
+ // Measured 2026-09-06 at 390px, before this: the plot spanned x 74-234 with
516
+ // a hole of x 100-208, while "$116,120" spanned x 96-212 — the leading "$"
517
+ // sat on the violet arc and the trailing "0" on the blue one, because the
518
+ // readout was sized `inset-0` against the whole 160px plot box.
519
+ const holePx = Math.max(0, 2 * radius - stroke - HOVER_BUMP) * scale
520
+
521
+ /* ── derived text ─────────────────────────────────────────────────── */
522
+
523
+ // formatPercent takes PERCENT UNITS, not a 0-1 fraction (ADR-146 D11).
524
+ const zeroPercent = formatPercent(0, { decimals: percentDecimals })
525
+ const percentOf = (fraction: number) => {
526
+ const pct = fraction * 100
527
+ const text = formatPercent(pct, { decimals: percentDecimals })
528
+ // A slice that is DRAWN — it cleared `minSliceFraction`, so the reader can
529
+ // see it — but whose share rounds to "0%" reads as a rendering fault: a
530
+ // visible arc labelled as nothing. Report the true share as "<1%" (or
531
+ // "<0.1%" at one decimal) instead. Still true, never rounded UP into a
532
+ // share the slice does not have, and it is the number the reader compares.
533
+ if (pct > 0 && text === zeroPercent) {
534
+ const smallest = 10 ** -percentDecimals
535
+ return `<${formatPercent(smallest, { decimals: percentDecimals })}`
536
+ }
537
+ return text
538
+ }
539
+
540
+ const activeIndex = active === null
541
+ ? -1
542
+ : slices.findIndex((slice) => slice.key === active)
543
+ const activeSlice = activeIndex >= 0 ? slices[activeIndex] : undefined
544
+ const activeFraction = trueSegments[activeIndex]?.fraction ?? 0
545
+ // A controlled `activeKey` can outlive the datum it names — a filter drops
546
+ // the series while it is hovered. Resolve emphasis through the slice we
547
+ // actually found, so a stale key dims nothing rather than dimming the whole
548
+ // chart and its legend with no segment raised to explain why.
549
+ const resolvedActive = activeSlice !== undefined ? active : null
550
+
551
+ const legendItems: ChartLegendItem[] = slices.map((slice, index) => {
552
+ const fraction = trueSegments[index]?.fraction ?? 0
553
+ const meta =
554
+ slice.foldedCount > 0 ? otherMeta(slice.foldedCount) : slice.meta
555
+ return {
556
+ key: slice.key,
557
+ label: `${slice.label} (${percentOf(fraction)})`,
558
+ slot: slice.slot,
559
+ value: formatValue(slice.value),
560
+ ...(meta !== undefined ? { meta } : {}),
561
+ }
562
+ })
563
+
564
+ const showCenter = centerValue !== undefined || activeSlice !== undefined
565
+ const centerMain =
566
+ activeSlice !== undefined ? formatValue(activeSlice.value) : centerValue
567
+ const centerSub =
568
+ activeSlice !== undefined ? activeSlice.label : centerCaption
569
+
570
+ /* ── centre fit ───────────────────────────────────────────────────────
571
+ * Every string the centre slot can EVER show: the resting value plus the
572
+ * value of every slice a hover can swap in. The type size is chosen from
573
+ * the widest of them and then never changes, which is what makes the slot
574
+ * genuinely reserved — hovering swaps the string, never the size.
575
+ */
576
+
577
+ const valueSet = new Set<string>()
578
+ if (centerValue !== undefined && centerValue !== '') valueSet.add(centerValue)
579
+ for (const slice of slices) {
580
+ const text = formatValue(slice.value)
581
+ if (text !== '') valueSet.add(text)
582
+ }
583
+
584
+ const captionSet = new Set<string>()
585
+ if (centerCaption !== undefined && centerCaption !== '') {
586
+ captionSet.add(centerCaption)
587
+ }
588
+ for (const slice of slices) {
589
+ if (slice.label !== '') captionSet.add(slice.label)
590
+ }
591
+
592
+ // The candidates travel into the effect as ONE string rather than an array:
593
+ // an array literal is a new identity on every render and would re-run the
594
+ // effect forever, and a \u0000-joined key is both the dependency and the
595
+ // payload. (No formatter can emit a NUL, so the split is lossless.)
596
+ const valueKey = [...valueSet].join('\u0000')
597
+ const captionKey = [...captionSet].join('\u0000')
598
+
599
+ const fitRef = useRef<HTMLDivElement>(null)
600
+ const valueGaugeRef = useRef<HTMLSpanElement>(null)
601
+ const captionGaugeRef = useRef<HTMLSpanElement>(null)
602
+ const [centerScale, setCenterScale] = useState(1)
603
+ const [captionScale, setCaptionScale] = useState(1)
604
+
605
+ useEffect(() => {
606
+ const fit = fitRef.current
607
+ if (fit === null) return
608
+
609
+ // The gauges are rendered EMPTY and filled one candidate at a time here,
610
+ // rather than rendering every candidate as its own hidden node. Hidden
611
+ // duplicates of the value would put the same string in the DOM twice,
612
+ // which is a trap for anything that reads the tree by text — a test, a
613
+ // find-in-page, a scraper — for no benefit.
614
+ function widestOf(gauge: HTMLSpanElement | null, key: string): number {
615
+ if (gauge === null || key === '') return 0
616
+ let widest = 0
617
+ for (const candidate of key.split('\u0000')) {
618
+ gauge.textContent = candidate
619
+ widest = Math.max(widest, gauge.getBoundingClientRect().width)
620
+ }
621
+ gauge.textContent = ''
622
+ return widest
623
+ }
624
+
625
+ function recompute() {
626
+ if (fit === null) return
627
+ const available = fit.getBoundingClientRect().width
628
+ const valueWidest = widestOf(valueGaugeRef.current, valueKey)
629
+ const captionWidest = widestOf(captionGaugeRef.current, captionKey)
630
+
631
+ // jsdom reports 0 for every box; leaving the scales at 1 there is
632
+ // correct — there is nothing to fit to.
633
+ const fit1 =
634
+ available > 0 && valueWidest > 0
635
+ ? Math.max(CENTER_MIN_SCALE, Math.min(1, available / valueWidest))
636
+ : 1
637
+ const fit2 =
638
+ available > 0 && captionWidest > 0
639
+ ? Math.max(CAPTION_MIN_SCALE, Math.min(1, available / captionWidest))
640
+ : 1
641
+
642
+ // Quantized so sub-pixel resize noise cannot chase its own tail — and
643
+ // quantized DOWNWARD, never to nearest. Rounding 0.7465 up to 0.75 put
644
+ // "$116,120" at 86.4px inside an 86px slot: no overflow, no failing
645
+ // `scrollWidth` check, and a visible "$116,1…" in the screenshot. A
646
+ // number that ellipsises is a number the reader cannot trust.
647
+ const nextValue = Math.floor(fit1 * 100) / 100
648
+ const nextCaption = Math.floor(fit2 * 100) / 100
649
+ setCenterScale((prev) => (Math.abs(prev - nextValue) < 0.005 ? prev : nextValue))
650
+ setCaptionScale((prev) =>
651
+ Math.abs(prev - nextCaption) < 0.005 ? prev : nextCaption,
652
+ )
653
+ }
654
+
655
+ recompute()
656
+ if (typeof ResizeObserver === 'undefined') return
657
+ const observer = new ResizeObserver(recompute)
658
+ observer.observe(fit)
659
+ return () => observer.disconnect()
660
+ // `showCenter` is load-bearing, not decoration: on a donut with no
661
+ // `centerValue` the whole readout is unmounted at rest and only appears
662
+ // when a segment becomes active. Without this dep the effect would have
663
+ // run once against a null ref and never again, and that donut's value
664
+ // would render unscaled at the base size — measured, before this: a 120px
665
+ // donut showed "48,250" at 24px, ellipsised inside a 60px slot.
666
+ }, [valueKey, captionKey, holePx, showCenter])
667
+
668
+ /* ── tooltip anchor ───────────────────────────────────────────────── */
669
+
670
+ const anchorFraction =
671
+ activeIndex >= 0
672
+ ? (drawnSegments[activeIndex]?.startFraction ?? 0) +
673
+ (drawnSegments[activeIndex]?.fraction ?? 0) / 2
674
+ : 0
675
+ const anchorAngle = anchorFraction * Math.PI * 2 - Math.PI / 2
676
+ const anchorX = (center + radius * Math.cos(anchorAngle)) * scale
677
+ const anchorY = (center + radius * Math.sin(anchorAngle)) * scale
678
+
679
+ /* ── render ───────────────────────────────────────────────────────── */
680
+
681
+ const interactive = onSelect !== undefined
682
+ const gapColor = gapSurface === 'none' ? null : GAP_COLOR[gapSurface]
683
+
684
+ return (
685
+ <div
686
+ className={cn(
687
+ 'flex',
688
+ // `flex-wrap` in the row layout is what stops the legend being
689
+ // starved: the plot cannot shrink (it is `shrink-0`), so on a narrow
690
+ // root the legend either wraps under it or gets nothing. See
691
+ // LEGEND_BASIS for the measurement.
692
+ orientation === 'row' ? 'flex-row flex-wrap items-center' : 'flex-col',
693
+ className,
694
+ )}
695
+ style={{ gap: 'var(--space-5)' }}
696
+ >
697
+ <div
698
+ ref={plotRef}
699
+ className="relative shrink-0"
700
+ // Geometry, not spacing: the plot is a square of `size` px that may
701
+ // shrink on a narrow viewport. `aspect-ratio` is what reserves its
702
+ // height so async data cannot reflow the page (ADR-146 D9).
703
+ style={{ width: box, maxWidth: '100%', aspectRatio: '1' }}
704
+ >
705
+ <svg
706
+ viewBox={`0 0 ${box} ${box}`}
707
+ className="h-full w-full"
708
+ role="group"
709
+ aria-label={label}
710
+ >
711
+ {/* Track. Always painted, so an all-zero chart still reads as a
712
+ ring rather than as a missing element. */}
713
+ <circle
714
+ aria-hidden="true"
715
+ cx={center}
716
+ cy={center}
717
+ r={radius}
718
+ fill="none"
719
+ stroke="rgb(var(--muted))"
720
+ strokeWidth={stroke}
721
+ />
722
+
723
+ {/* Segments. Rotated -90° so slice one starts at twelve o'clock —
724
+ the same convention both hand-rolled accumulators used. */}
725
+ <g transform={`rotate(-90 ${center} ${center})`}>
726
+ {!isEmpty &&
727
+ drawnSegments.map((segment, index) => {
728
+ const slice = slices[index]
729
+ if (slice === undefined || segment.fraction <= 0) return null
730
+
731
+ const trueFraction = trueSegments[index]?.fraction ?? 0
732
+ const raised = active === slice.key
733
+ const color = slotColor(slice.slot, palette)
734
+ const name = [
735
+ slice.label,
736
+ formatValue(slice.value),
737
+ percentOf(trueFraction),
738
+ slice.foldedCount > 0 ? otherMeta(slice.foldedCount) : null,
739
+ ]
740
+ .filter((part) => part !== null)
741
+ .join(', ')
742
+
743
+ return (
744
+ <g
745
+ key={slice.key}
746
+ // No `focus-visible:[box-shadow:var(--ring-focus)]` here:
747
+ // `box-shadow` does not paint on an SVG element — it has
748
+ // no CSS box — so the class would be an inert claim to a
749
+ // focus ring with every gate green. The visible ring is
750
+ // the halo <circle> below, which has the added virtue of
751
+ // tracing THIS arc rather than boxing the whole plot.
752
+ className={cn(
753
+ 'group cursor-default outline-none',
754
+ interactive && 'cursor-pointer',
755
+ )}
756
+ tabIndex={0}
757
+ role={interactive ? 'button' : 'img'}
758
+ aria-label={name}
759
+ onMouseEnter={() => setActive(slice.key)}
760
+ onMouseLeave={() => setActive(null)}
761
+ onFocus={() => setActive(slice.key)}
762
+ onBlur={() => setActive(null)}
763
+ onClick={
764
+ onSelect !== undefined
765
+ ? () => onSelect(slice.key)
766
+ : undefined
767
+ }
768
+ onKeyDown={
769
+ onSelect !== undefined
770
+ ? (event) => {
771
+ if (event.key !== 'Enter' && event.key !== ' ') {
772
+ return
773
+ }
774
+ event.preventDefault()
775
+ onSelect(slice.key)
776
+ }
777
+ : undefined
778
+ }
779
+ >
780
+ {/* Focus halo. Same radius and same dash as the arc, so
781
+ it traces exactly this segment and not the whole
782
+ ring. Painted first, therefore behind. Kept in the
783
+ DOM at zero opacity so focusing costs no layout. */}
784
+ <circle
785
+ aria-hidden="true"
786
+ className="opacity-0 group-focus-visible:opacity-100"
787
+ cx={center}
788
+ cy={center}
789
+ r={radius}
790
+ fill="none"
791
+ stroke="rgb(var(--ring))"
792
+ strokeWidth={stroke + FOCUS_HALO}
793
+ strokeDasharray={segment.dasharray}
794
+ strokeDashoffset={segment.dashoffset}
795
+ />
796
+ <circle
797
+ className="motion-safe:transition-[stroke-width,opacity]"
798
+ cx={center}
799
+ cy={center}
800
+ r={radius}
801
+ fill="none"
802
+ stroke={color}
803
+ // The "raise": the stroke grows around the SAME
804
+ // centreline, so the dash geometry stays valid and the
805
+ // segment thickens on both edges instead of moving.
806
+ strokeWidth={raised ? stroke + HOVER_BUMP : stroke}
807
+ strokeDasharray={segment.dasharray}
808
+ strokeDashoffset={segment.dashoffset}
809
+ opacity={
810
+ activeSlice === undefined || raised ? 1 : DIM_OPACITY
811
+ }
812
+ style={{
813
+ transitionDuration: 'var(--dur-fast)',
814
+ // Data and hover changes tween with ease-out. Never
815
+ // ease-spring: overshoot reads as imprecise.
816
+ transitionTimingFunction: 'var(--ease-out)',
817
+ }}
818
+ />
819
+ </g>
820
+ )
821
+ })}
822
+
823
+ {/* The 2px surface-coloured cuts between adjacent segments. Drawn
824
+ last so they sit above every arc, and skipped entirely for a
825
+ single slice — one cut in a full ring reads as a fault. */}
826
+ {gapColor !== null &&
827
+ positiveCount > 1 &&
828
+ drawnSegments.map((segment, index) => {
829
+ const slice = slices[index]
830
+ if (slice === undefined || segment.fraction <= 0) return null
831
+ const angle = segment.startFraction * Math.PI * 2
832
+ // Reach past the RAISED stroke, not just the resting one. A
833
+ // hovered/focused segment grows by HOVER_BUMP around the same
834
+ // centreline, so a cut sized for the base stroke leaves a
835
+ // coloured nub at each radial end of it the moment the reader
836
+ // hovers. The overshoot paints surface colour onto surface
837
+ // colour, so covering the widest case costs nothing.
838
+ const reach = stroke / 2 + HOVER_BUMP / 2 + 1
839
+ const inner = radius - reach
840
+ const outer = radius + reach
841
+ const cos = Math.cos(angle)
842
+ const sin = Math.sin(angle)
843
+ return (
844
+ <line
845
+ key={`gap-${slice.key}`}
846
+ aria-hidden="true"
847
+ x1={center + inner * cos}
848
+ y1={center + inner * sin}
849
+ x2={center + outer * cos}
850
+ y2={center + outer * sin}
851
+ stroke={gapColor}
852
+ strokeWidth={SEGMENT_GAP}
853
+ />
854
+ )
855
+ })}
856
+ </g>
857
+ </svg>
858
+
859
+ {/* Centre readout. Absolutely positioned and pointer-events-none so
860
+ it can never steal a hover from a segment underneath it, and both
861
+ lines always occupy their slot so swapping total ↔ hovered
862
+ segment does not resize anything.
863
+
864
+ The reserve is the HOLE, not the plot box: the readout is sized to
865
+ `holePx` (inner diameter at the raised stroke) less the inline
866
+ padding, and the value's type is scaled once to the widest string
867
+ the slot can ever hold. */}
868
+ {showCenter && (
869
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
870
+ <div
871
+ className="flex flex-col items-center text-center"
872
+ style={{
873
+ width: holePx,
874
+ maxWidth: '100%',
875
+ // --space-1, not --space-2: the reserve already uses the
876
+ // RAISED hole, which is 2px tighter on each side than the
877
+ // resting one, so 4px here is 6px of real clearance from the
878
+ // arc as drawn at rest — and it buys the value line enough
879
+ // width to stay near its full type size.
880
+ paddingInline: 'var(--space-1)',
881
+ boxSizing: 'border-box',
882
+ }}
883
+ >
884
+ {/* `fitRef` measures the available content width; the gauges
885
+ below render every candidate at the BASE size, off-flow and
886
+ invisible, so the fit is measured rather than estimated. */}
887
+ <div ref={fitRef} className="relative w-full">
888
+ <span
889
+ ref={valueGaugeRef}
890
+ aria-hidden="true"
891
+ className="absolute top-0 left-0 block leading-none font-semibold tabular-nums whitespace-nowrap opacity-0"
892
+ style={{ fontSize: `${CENTER_VALUE_FONT_PX}px` }}
893
+ />
894
+ <span
895
+ ref={captionGaugeRef}
896
+ aria-hidden="true"
897
+ className="absolute top-0 left-0 block leading-tight whitespace-nowrap opacity-0"
898
+ style={{ fontSize: `${CENTER_CAPTION_FONT_PX}px` }}
899
+ />
900
+ <span
901
+ className="block truncate leading-none font-semibold tabular-nums"
902
+ style={{
903
+ color: 'rgb(var(--foreground))',
904
+ maxWidth: '100%',
905
+ fontSize: `${CENTER_VALUE_FONT_PX * centerScale}px`,
906
+ }}
907
+ >
908
+ {centerMain}
909
+ </span>
910
+ <span
911
+ className="block truncate leading-tight"
912
+ style={{
913
+ marginTop: 'var(--space-1)',
914
+ // Reserved even when empty, so the value never jumps.
915
+ minHeight: 'var(--space-4)',
916
+ maxWidth: '100%',
917
+ color: 'rgb(var(--text-tertiary))',
918
+ fontSize: `${CENTER_CAPTION_FONT_PX * captionScale}px`,
919
+ visibility: centerSub === undefined ? 'hidden' : 'visible',
920
+ }}
921
+ >
922
+ {centerSub ?? ''}
923
+ </span>
924
+ </div>
925
+ </div>
926
+ </div>
927
+ )}
928
+
929
+ {activeSlice !== undefined && (
930
+ <ChartTooltip
931
+ x={anchorX}
932
+ y={anchorY}
933
+ plotWidth={plotPx}
934
+ plotHeight={plotPx}
935
+ title={activeSlice.label}
936
+ palette={palette}
937
+ rows={[
938
+ {
939
+ key: activeSlice.key,
940
+ label: percentOf(activeFraction),
941
+ value: formatValue(activeSlice.value),
942
+ slot: activeSlice.slot,
943
+ },
944
+ ]}
945
+ />
946
+ )}
947
+ </div>
948
+
949
+ {showLegend && (
950
+ <div
951
+ className="min-w-0"
952
+ style={
953
+ orientation === 'row'
954
+ ? { flex: `1 1 ${LEGEND_BASIS}` }
955
+ : { flex: '1 1 auto' }
956
+ }
957
+ >
958
+ {isEmpty ? (
959
+ <p
960
+ className="text-[14px]"
961
+ style={{ color: 'rgb(var(--text-tertiary))' }}
962
+ >
963
+ {emptyMessage}
964
+ </p>
965
+ ) : (
966
+ <ChartLegend
967
+ items={legendItems}
968
+ orientation="vertical"
969
+ palette={palette}
970
+ activeKey={resolvedActive}
971
+ valueMinWidth={legendValueMinWidth}
972
+ label={`${label} legend`}
973
+ {...(onSelect !== undefined ? { onSelect } : {})}
974
+ />
975
+ )}
976
+ </div>
977
+ )}
978
+
979
+ {/* The fold has to be readable, not just visible: a screen reader hears
980
+ this even though the legend's "Other" row already shows it. */}
981
+ {foldedCount > 0 && !isEmpty && (
982
+ <span className="sr-only">
983
+ {`${foldedCount} smaller categories are grouped as ${otherLabel}.`}
984
+ </span>
985
+ )}
986
+ </div>
987
+ )
988
+ }