@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,416 @@
1
+ /**
2
+ * Sparkline — an inline trend at cell size. No axes, no gridlines, no frame,
3
+ * no legend, no tooltip. Line or bar.
4
+ *
5
+ * Promoted in ADR-146 D5 (chart layer). It supersedes the **chart half** of
6
+ * `allocation-sparkbar.tsx`, whose labels-under-bars affordance stays with
7
+ * that primitive until the staged migration in ADR-146 D13.
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 (1 — gate NOT MET):
13
+ * • `lenses/budget-flighting/components/cards/LineItemCard.tsx` — the
14
+ * monthly allocation strip
15
+ * PLANNED second consumer: a trend cell per row in the Keywords term table
16
+ * (`lenses/keywords/results-table.tsx`). Not written. Tracked in
17
+ * `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
18
+ *
19
+ * That one migration also COST something: the budget-flighting strip lost its
20
+ * per-month flight-group tint, because a chart primitive takes a palette slot
21
+ * and never a token name (D8). Logged as DEV-2 in
22
+ * `docs/phases/_completed/adr-146-analytics-primitives.md` and open as
23
+ * ADR-146 OQ-2 (`docs/follow-ups/_pending/adr-146-sparkline-per-bar-colour.md`).
24
+ *
25
+ * WHAT IT INHERITS FROM `AllocationSparkbar`, DELIBERATELY
26
+ * -------------------------------------------------------
27
+ * The bar variant keeps that primitive's three load-bearing behaviours so a
28
+ * migration is a swap and not a redesign:
29
+ *
30
+ * • **Max-normalisation** (`allocation-sparkbar.tsx:40`) — heights are a
31
+ * fraction of the largest value, via `normalizeToMax`. That helper drops
32
+ * the old `Math.max(...values, 1)` fudge, which silently rescaled any
33
+ * series whose max was below 1 (a set of ratios) against a phantom
34
+ * maximum and flattened the whole strip.
35
+ * • **A floor under every bar** (`:48`, `8 + (v/max) * (h - 12)`) — the
36
+ * smallest bar is still a visible mark, not a hairline. At that file's
37
+ * 36px default height this one reproduces the arithmetic exactly (floor
38
+ * 8, headroom 4, span 8..32); below it the floor and headroom scale with
39
+ * the box, because an 8px floor inside a 20px strip is 40% of the chart.
40
+ * • **Paused reads as paused, not as empty** (`:44`) — a value of exactly
41
+ * `0` renders short and dashed in a muted tone, so "we spent nothing that
42
+ * month" is distinguishable from "we have no data". Turn it off with
43
+ * `zeroAsPaused={false}` when zero is a real measurement.
44
+ *
45
+ * WHY THE BAR VARIANT IS DIVS AND THE LINE VARIANT IS SVG
46
+ * ------------------------------------------------------
47
+ * A bar strip is rectangles, and a `<div>` takes `border-radius`,
48
+ * `background` and `border` straight from tokens. An SVG `<rect>` cannot: its
49
+ * `rx` is a geometry attribute, `var()` substitution inside presentation
50
+ * attributes is not reliably implemented, and a radius that fails to parse
51
+ * fails **silently** — the exact class of defect ADR-146 D8 exists to prevent.
52
+ * So bars stay divs, as they were in the primitive this supersedes, and only
53
+ * the line — which genuinely needs path arithmetic — is SVG, where every
54
+ * colour is a plain `fill`/`stroke` value and no token has to survive an
55
+ * attribute parser.
56
+ *
57
+ * THE POINT TREATMENT INVERTS AT SMALL SIZE
58
+ * -----------------------------------------
59
+ * The extracted anatomy (`METORIC-ANALYTICS-SPEC.md` 6.2) draws the large
60
+ * chart's point as a **filled** dot with a 2px surface ring, and the 450×56
61
+ * sparkline's as the **opposite** — a surface fill with a 3px coloured
62
+ * stroke. That inversion is right and we keep it. At sparkline size the mark
63
+ * is a large fraction of the plot, so a filled dot reads as a blob and hides
64
+ * the very inflection it is marking; a hollow ring keeps the line's own path
65
+ * visible through it. The dot and its stroke scale with `height`, so the
66
+ * treatment holds at 60×20 and at 300×60.
67
+ *
68
+ * The hollow dot is filled with `--surface-card` — it is drawn to sit on a
69
+ * `Card`, which is where an inline trend lives. On a tray or a zebra band it
70
+ * reads as a very slightly lighter ring; pass `emphasizeLast={false}` there
71
+ * rather than introducing a fill-colour prop.
72
+ *
73
+ * NO MOTION, ON PURPOSE
74
+ * ---------------------
75
+ * SVG path geometry does not tween under a CSS transition, and animating it
76
+ * properly would mean a per-frame interpolator inside a primitive whose whole
77
+ * job is to be cheap enough to render once per table row. A sparkline
78
+ * redraws; it does not settle. That is reduced-motion-correct by
79
+ * construction. `RankedBars` and the full-size chart layer carry the
80
+ * `--dur-base` / `--ease-out` data tween.
81
+ *
82
+ * Token discipline: every colour is `rgb(var(--token))`; the series hue
83
+ * resolves through the frozen `SERIES` lookup in `lib/series.ts` (ADR-146 D8
84
+ * — never a constructed `--series-${i}`), and defaults to `rgb(var(--accent))`
85
+ * because a lone trend is one series and D7's monochrome default applies.
86
+ * Tints are applied with `opacity` / SVG `fill-opacity` rather than by
87
+ * editing the token string, so no call site can stack a second alpha onto a
88
+ * token that already carries one. Sizes are props applied through inline
89
+ * `style` and SVG attributes, never a template-literal Tailwind class
90
+ * (`h-[${n}px]` is statically unscannable and silently produces nothing).
91
+ */
92
+
93
+ import { cn } from './lib/utils'
94
+ import { areaPath, bandScale, linePath, linearScale, normalizeToMax } from './lib/chart'
95
+ import { slotColor, type ChartSlot, type SeriesPalette } from './lib/series'
96
+
97
+ /** Which mark the sparkline draws. */
98
+ export type SparklineVariant = 'line' | 'bar'
99
+
100
+ export interface SparklineProps {
101
+ /**
102
+ * The series, oldest first. Non-finite entries are dropped from the line
103
+ * (it closes over the gap) and read as `0` in the bar variant. Negative
104
+ * values plot normally on the line and clamp to the height floor on bars —
105
+ * a bar cannot be shorter than nothing.
106
+ */
107
+ values: readonly number[]
108
+ /** `'line'` (default) or `'bar'`. */
109
+ variant?: SparklineVariant
110
+ /** Plot width in px. Default `120`. Renders sensibly from about 60. */
111
+ width?: number
112
+ /** Plot height in px. Default `32`. Renders sensibly from about 20. */
113
+ height?: number
114
+ /**
115
+ * Optional palette slot. Omit for the monochrome accent default (ADR-146
116
+ * D7). Pass a slot only when this trend is one of several colour-identified
117
+ * entities, and derive the slot from the entity's stable key — never from
118
+ * an array index after sorting or filtering.
119
+ */
120
+ slot?: ChartSlot | undefined
121
+ /** Which palette `slot` indexes into. Default `'default'`. */
122
+ palette?: SeriesPalette
123
+ /** Line stroke width in px. Default `2`. Ignored by the bar variant. */
124
+ strokeWidth?: number
125
+ /**
126
+ * Fill the area under the line at a 12% tint. Default `false` — at cell
127
+ * size the line alone is usually clearer. Ignored by the bar variant.
128
+ */
129
+ area?: boolean
130
+ /**
131
+ * Emphasize one mark: the line gets its hollow end dot, the bar variant
132
+ * draws that bar at full strength and tints the rest. Default `true`.
133
+ */
134
+ emphasizeLast?: boolean
135
+ /**
136
+ * Which mark is emphasized. Default: the last one. Pass the peak's or the
137
+ * selected period's index when "now" is not the interesting mark. Clamped
138
+ * into range, so a stale selection still points at a real mark.
139
+ */
140
+ emphasisIndex?: number
141
+ /**
142
+ * Render a value of exactly `0` as a short dashed stub rather than as a
143
+ * floor-height bar — the `AllocationSparkbar` paused treatment. Default
144
+ * `true`. Bar variant only.
145
+ */
146
+ zeroAsPaused?: boolean
147
+ /**
148
+ * Optional pre-formatted value shown after the plot, in `tabular-nums`.
149
+ * Format through `@lovett/ui/format` — this primitive never formats a
150
+ * number.
151
+ */
152
+ valueLabel?: string
153
+ /**
154
+ * Reserved width for `valueLabel`, so a value growing from "9" to "11,227"
155
+ * cannot resize the row. Pass a token-derived length.
156
+ * Default `var(--space-10)`.
157
+ */
158
+ valueMinWidth?: string
159
+ /**
160
+ * Accessible name — e.g. `'Impressions, last 12 weeks'`. With it the plot
161
+ * is `role="img"`; without it the plot is `aria-hidden`, which is correct
162
+ * only when a neighbouring cell already states the number and the
163
+ * sparkline is decorative. Never leave a meaning-carrying trend unlabelled.
164
+ */
165
+ label?: string
166
+ /** Optional className on the outer container. */
167
+ className?: string
168
+ }
169
+
170
+ /** Clamp without ever returning a non-finite number. */
171
+ function clamp(value: number, min: number, max: number): number {
172
+ if (!Number.isFinite(value)) return min
173
+ if (value < min) return min
174
+ return value > max ? max : value
175
+ }
176
+
177
+ export function Sparkline({
178
+ values,
179
+ variant = 'line',
180
+ width = 120,
181
+ height = 32,
182
+ slot,
183
+ palette = 'default',
184
+ strokeWidth = 2,
185
+ area = false,
186
+ emphasizeLast = true,
187
+ emphasisIndex,
188
+ zeroAsPaused = true,
189
+ valueLabel,
190
+ valueMinWidth = 'var(--space-10)',
191
+ label,
192
+ className,
193
+ }: SparklineProps) {
194
+ const w = Math.max(0, Number.isFinite(width) ? width : 0)
195
+ const h = Math.max(0, Number.isFinite(height) ? height : 0)
196
+ const count = values.length
197
+ const color = slot === undefined ? 'rgb(var(--accent))' : slotColor(slot, palette)
198
+
199
+ // Default emphasis is the newest mark.
200
+ const emphasis =
201
+ count === 0
202
+ ? -1
203
+ : emphasisIndex === undefined
204
+ ? count - 1
205
+ : Math.round(clamp(emphasisIndex, 0, count - 1))
206
+
207
+ // The plot reserves its exact box, so async data cannot reflow the row it
208
+ // sits in — the layout-stability rule, at cell scale.
209
+ const plot = (
210
+ <div
211
+ role={label === undefined ? undefined : 'img'}
212
+ aria-label={label}
213
+ aria-hidden={label === undefined ? true : undefined}
214
+ className="relative shrink-0"
215
+ style={{ width: w, height: h }}
216
+ >
217
+ {variant === 'bar'
218
+ ? renderBars({ values, w, h, color, emphasis, emphasizeLast, zeroAsPaused })
219
+ : renderLine({ values, w, h, color, emphasis, emphasizeLast, strokeWidth, area })}
220
+ </div>
221
+ )
222
+
223
+ if (valueLabel === undefined) {
224
+ return <div className={cn('inline-flex', className)}>{plot}</div>
225
+ }
226
+
227
+ return (
228
+ <div className={cn('inline-flex items-center gap-2', className)}>
229
+ {plot}
230
+ <span
231
+ className="shrink-0 text-right text-[12px] font-semibold tabular-nums"
232
+ style={{ color: 'rgb(var(--foreground))', minWidth: valueMinWidth }}
233
+ >
234
+ {valueLabel}
235
+ </span>
236
+ </div>
237
+ )
238
+ }
239
+
240
+ /* ── line ────────────────────────────────────────────────────────────── */
241
+
242
+ interface LineArgs {
243
+ values: readonly number[]
244
+ w: number
245
+ h: number
246
+ color: string
247
+ emphasis: number
248
+ emphasizeLast: boolean
249
+ strokeWidth: number
250
+ area: boolean
251
+ }
252
+
253
+ function renderLine({
254
+ values,
255
+ w,
256
+ h,
257
+ color,
258
+ emphasis,
259
+ emphasizeLast,
260
+ strokeWidth,
261
+ area,
262
+ }: LineArgs) {
263
+ if (w === 0 || h === 0) return null
264
+
265
+ // The dot and its ring scale with the box: the extracted 450×56 sparkline
266
+ // uses a 12px dot with a 3px stroke (r ≈ h/9), which at 20px tall would be
267
+ // a 2px dot with a hairline — below the visibility floor. The clamps hold
268
+ // both ends of the range.
269
+ const dotRadius = clamp(h * 0.11, 2.5, 6)
270
+ const dotStroke = clamp(dotRadius * 0.5, 1.5, 3)
271
+ const stroke = Math.max(0.5, Number.isFinite(strokeWidth) ? strokeWidth : 2)
272
+
273
+ // Inset so neither the stroke nor the end dot is clipped by the viewBox.
274
+ // `Math.max` rather than a ternary: with the dot on, a caller-raised
275
+ // `strokeWidth` can exceed the dot's own inset (strokeWidth 8 at h=20 needs
276
+ // 4 of inset, the dot only reserves 3.25), and the line would be shaved
277
+ // against the viewBox edge with every gate green.
278
+ const pad = Math.max(stroke / 2, emphasizeLast ? dotRadius + dotStroke / 2 : 0)
279
+ const plotW = Math.max(0, w - pad * 2)
280
+ const plotH = Math.max(0, h - pad * 2)
281
+
282
+ const finiteValues = values.filter((value) => Number.isFinite(value))
283
+ const first = finiteValues[0]
284
+ if (first === undefined) return null
285
+
286
+ let min = first
287
+ let max = first
288
+ for (const value of finiteValues) {
289
+ if (value < min) min = value
290
+ if (value > max) max = value
291
+ }
292
+
293
+ // A flat or single-datum series maps to the midpoint of the range — see
294
+ // `linearScale`'s contract. Nothing divides by zero.
295
+ const y = linearScale([min, max], [pad + plotH, pad])
296
+ const xAt = (index: number): number =>
297
+ values.length > 1 ? pad + (index / (values.length - 1)) * plotW : pad + plotW / 2
298
+
299
+ // Non-finite entries are dropped rather than pinned to the floor: a missing
300
+ // datum has no honest position, and plotting it at zero invents a dip. A
301
+ // single plottable point still paints, as a round dot.
302
+ const points = values
303
+ .map((value, index) => ({ value, x: xAt(index), y: y(value) }))
304
+ .filter((point) => Number.isFinite(point.value))
305
+ .map((point) => ({ x: point.x, y: point.y }))
306
+
307
+ const emphasized = values[emphasis]
308
+ const showDot =
309
+ emphasizeLast && emphasis >= 0 && emphasized !== undefined && Number.isFinite(emphasized)
310
+
311
+ return (
312
+ <svg
313
+ width={w}
314
+ height={h}
315
+ viewBox={`0 0 ${w} ${h}`}
316
+ aria-hidden="true"
317
+ focusable="false"
318
+ style={{ display: 'block' }}
319
+ >
320
+ {area && (
321
+ <path d={areaPath(points, pad + plotH)} fill={color} fillOpacity={0.12} stroke="none" />
322
+ )}
323
+ <path
324
+ d={linePath(points)}
325
+ fill="none"
326
+ stroke={color}
327
+ strokeWidth={stroke}
328
+ strokeLinecap="round"
329
+ strokeLinejoin="round"
330
+ />
331
+ {showDot && (
332
+ <circle
333
+ cx={xAt(emphasis)}
334
+ cy={y(emphasized)}
335
+ r={dotRadius}
336
+ fill="rgb(var(--surface-card))"
337
+ stroke={color}
338
+ strokeWidth={dotStroke}
339
+ />
340
+ )}
341
+ </svg>
342
+ )
343
+ }
344
+
345
+ /* ── bars ────────────────────────────────────────────────────────────── */
346
+
347
+ interface BarArgs {
348
+ values: readonly number[]
349
+ w: number
350
+ h: number
351
+ color: string
352
+ emphasis: number
353
+ emphasizeLast: boolean
354
+ zeroAsPaused: boolean
355
+ }
356
+
357
+ function renderBars({
358
+ values,
359
+ w,
360
+ h,
361
+ color,
362
+ emphasis,
363
+ emphasizeLast,
364
+ zeroAsPaused,
365
+ }: BarArgs) {
366
+ if (values.length === 0 || w === 0 || h === 0) return null
367
+
368
+ const band = bandScale(values.length, w, 0.28)
369
+ const fractions = normalizeToMax(values)
370
+
371
+ // At h = 36 — `allocation-sparkbar.tsx`'s own default — the ratios resolve
372
+ // to exactly its arithmetic: floor 8, headroom 4, a bar spanning 8..32,
373
+ // i.e. `8 + f * (36 - 12)`. Below that height both scale with the box, so
374
+ // the floor never swallows the chart (an 8px floor in a 20px strip is 40%
375
+ // of the plot). The fractions are 2/9 and 1/9 rather than 0.22 and 0.11
376
+ // precisely so the 36px case lands on the integers and not on 7.92.
377
+ const floor = Math.min(8, (h * 2) / 9)
378
+ const headroom = Math.min(4, h / 9)
379
+ const cap = Math.max(floor, h - headroom)
380
+
381
+ return (
382
+ <>
383
+ {values.map((value, index) => {
384
+ const paused = zeroAsPaused && value === 0
385
+ const fraction = fractions[index] ?? 0
386
+ const barHeight = paused ? headroom : floor + fraction * (cap - floor)
387
+
388
+ // D7's scarcity rule, sized for this box: the emphasized bar is full
389
+ // strength and the rest are a tint. The tint is 0.55 rather than the
390
+ // full-size chart's 0.14 because at 60×20 a 14% fill is below the
391
+ // visibility floor — the same reason the point treatment inverts.
392
+ const tinted = emphasizeLast && index !== emphasis
393
+
394
+ return (
395
+ <div
396
+ key={index}
397
+ style={{
398
+ position: 'absolute',
399
+ left: band.at(index),
400
+ bottom: 0,
401
+ width: band.band,
402
+ height: barHeight,
403
+ // A lightly-rounded BAR, not a pill: --radius-xs on a 5px-wide
404
+ // bar clamps to a full capsule, so it is halved — the same
405
+ // correction ChartLegend makes on its 12px swatch.
406
+ borderRadius: 'calc(var(--radius-xs) / 2)',
407
+ background: paused ? 'rgb(var(--text-muted) / 0.18)' : color,
408
+ border: paused ? '1px dashed rgb(var(--text-muted) / 0.4)' : undefined,
409
+ opacity: !paused && tinted ? 0.55 : 1,
410
+ }}
411
+ />
412
+ )
413
+ })}
414
+ </>
415
+ )
416
+ }