@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,304 @@
1
+ /**
2
+ * ChartLegend — swatch + label + optional value, horizontal or vertical.
3
+ *
4
+ * Promoted in ADR-146 D5 (chart layer). Rendered standalone by `DonutChart`,
5
+ * and passed into `ChartFrame`'s `legend` slot by the axis charts for any
6
+ * chart with two or more series.
7
+ *
8
+ * CONSUMERS — measured 2026-09-06.
9
+ * TODAY, inside `@lovett/ui` (3): `line-chart.tsx`, `bar-chart.tsx`,
10
+ * `donut-chart.tsx`.
11
+ * `RankedBars` does NOT import it — its rows carry their own labels — and
12
+ * neither does `ChartFrame`, which takes an already-rendered `legend?:
13
+ * ReactNode` from its caller. The earlier claim naming both was wrong.
14
+ * TODAY, in product code (0): no file under `apps/workspace/src` imports
15
+ * `ChartLegend`. Tracked in
16
+ * `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
17
+ *
18
+ * THE ONE RULE THIS PRIMITIVE EXISTS TO HOLD (ADR-146 D10.4)
19
+ * ----------------------------------------------------------
20
+ * **Text wears text tokens, never the series colour.** Labels and values are
21
+ * `--foreground`, secondary meta is `--text-tertiary`, and only the swatch
22
+ * carries the hue. This
23
+ * is the same rule as `tokens.css`'s folder hues — a series colour is a
24
+ * *fill*, not an *ink* — and it is exactly where the source kit is wrong: its
25
+ * tooltip inks its label in the series violet, which at 14px is a contrast failure on both
26
+ * its own surfaces. We do not copy that. If coloured text is ever genuinely
27
+ * required it owes a measured `*-ink` companion token first.
28
+ *
29
+ * The swatch is a small **rounded square**, not a circle: it reads as a data
30
+ * mark rather than a bullet, which is what the source kit's 10×10 `Point`
31
+ * does. A circle at this size is indistinguishable from a list bullet.
32
+ *
33
+ * Identity is never colour alone (D10.1) — every swatch sits beside its own
34
+ * text label, and the swatch itself is `aria-hidden` because the label
35
+ * already carries the meaning.
36
+ *
37
+ * WHICH IS EXACTLY WHAT A FLEX COLUMN CAN TAKE AWAY (fixed 2026-09-06)
38
+ * -------------------------------------------------------------------
39
+ * `min-w-0` on the label wrapper is what lets a long label truncate — and it
40
+ * is also what lets a starved container shrink it to NOTHING. Measured in a
41
+ * browser at a 390px viewport, both themes, inside `DonutChart`: the legend
42
+ * column was allotted 0px, and all five labels rendered at `width: 0` against
43
+ * `scrollWidth`s of 73-124px. Swatch and value survived; the names did not.
44
+ * Five series, identified by colour alone — the one thing D10.1 forbids, and
45
+ * a chart a colour-blind reader loses entirely.
46
+ *
47
+ * The row now carries `flex-wrap` and the label a `labelMinWidth` floor,
48
+ * bounded by `min(floor, 100%)`. When label and value cannot both fit, the
49
+ * VALUE drops to a second line; the label never gives up its width. Beyond
50
+ * that the label truncates with a `title`, which is a readable loss rather
51
+ * than a silent one.
52
+ *
53
+ * Token discipline: every colour is `rgb(var(--token))`; series hues resolve
54
+ * through the frozen `SERIES` lookup in `lib/series.ts` (D8 — never a
55
+ * constructed `--series-${i}`). Every size, gap and radius is a token. The
56
+ * swatch radius is `calc(var(--radius-xs) / 2)` rather than a literal: at a
57
+ * 12px swatch, `--radius-xs` (6px) is exactly half the box, so the browser
58
+ * clamps it to a full circle and the "rounded square, not a bullet" intent is
59
+ * lost. Halving the token keeps the mark square-ish AND keeps it on the
60
+ * scale. (A `--radius-2xs` token would be the tidier answer — logged as a
61
+ * follow-up, not invented here.)
62
+ */
63
+
64
+ import { cn } from './lib/utils'
65
+ import { slotColor, type ChartSlot, type SeriesPalette } from './lib/series'
66
+
67
+ // The palette is re-exported from here so the barrel has one ADR-146 block
68
+ // and consumers never reach into `lib/`. `lib/series.ts` stays the canonical
69
+ // definition — see its header for why it is a frozen array (D8).
70
+ export {
71
+ SERIES,
72
+ SERIES_SCATTER,
73
+ SERIES_SLOT_COUNT,
74
+ SCATTER_SLOT_COUNT,
75
+ OTHER_SLOT_COLOR,
76
+ isSeriesSlot,
77
+ isScatterSlot,
78
+ seriesColor,
79
+ scatterColor,
80
+ paletteColor,
81
+ slotColor,
82
+ } from './lib/series'
83
+ export type {
84
+ SeriesSlot,
85
+ ScatterSlot,
86
+ SeriesPalette,
87
+ ChartSlot,
88
+ } from './lib/series'
89
+
90
+ export interface ChartLegendItem {
91
+ /**
92
+ * Stable identity key. Also the React key and the `onSelect` payload.
93
+ *
94
+ * ADR-146 D7: **colour follows the entity, never its rank.** Assign the
95
+ * slot from this key, not from the array index after filtering — otherwise
96
+ * removing a series repaints every survivor.
97
+ */
98
+ key: string
99
+ /** The series name. Always rendered — identity is never colour alone. */
100
+ label: string
101
+ /** Palette slot, or `'other'` for the folded remainder. */
102
+ slot: ChartSlot
103
+ /**
104
+ * Optional pre-formatted value at the row end (vertical orientation) or
105
+ * after the label (horizontal). Format through `@lovett/ui/format` — this
106
+ * primitive never formats a number, it only sets it in `tabular-nums`.
107
+ */
108
+ value?: string
109
+ /** Optional muted secondary text under the label. Vertical only. */
110
+ meta?: string
111
+ }
112
+
113
+ export interface ChartLegendProps {
114
+ /** The series, in draw order. */
115
+ items: readonly ChartLegendItem[]
116
+ /**
117
+ * `'horizontal'` — an inline row for a chart header.
118
+ * `'vertical'` — a divided list for a donut or ranked-bar panel.
119
+ * Default `'horizontal'`.
120
+ */
121
+ orientation?: 'horizontal' | 'vertical'
122
+ /**
123
+ * Which palette the slots index into. `'scatter'` is the all-pairs,
124
+ * three-slot palette; a slot beyond its cap renders as `'other'` rather
125
+ * than wrapping around onto a hue already in use. Default `'default'`.
126
+ */
127
+ palette?: SeriesPalette
128
+ /**
129
+ * Makes each row a button. Receives the item's `key`. Omit for a static
130
+ * legend — an inert row must not advertise an interaction it does not have.
131
+ */
132
+ onSelect?: (key: string) => void
133
+ /**
134
+ * The emphasized series. Every other row dims. `null`/omitted = no
135
+ * emphasis. Dimming is opacity only, so nothing reflows.
136
+ */
137
+ activeKey?: string | null
138
+ /**
139
+ * Reserved width for the value column, so a value that grows from "9" to
140
+ * "11,227" does not resize the row (layout stability). Pass a token-derived
141
+ * length. Default `var(--space-12)`.
142
+ */
143
+ valueMinWidth?: string
144
+ /**
145
+ * Floor for the LABEL column, so a starved container cannot crush the label
146
+ * to zero width and leave the reader identifying series by colour alone
147
+ * (D10.1). Measured 2026-09-06: inside `DonutChart` at a 390px viewport the
148
+ * legend column was allotted 0px and every label rendered at `width: 0`
149
+ * while its `scrollWidth` was 73-124px — swatch and value only.
150
+ *
151
+ * `min-width: auto` is flexbox's default for a flex item and would be the
152
+ * right answer, except the label must also be allowed to TRUNCATE, which
153
+ * needs an explicit floor instead. The floor is bounded by `min(..., 100%)`
154
+ * so it can never push the row wider than its container; when the label and
155
+ * the value cannot both fit, the row wraps and the value drops to a second
156
+ * line rather than the label giving up its width.
157
+ *
158
+ * Pass a token-derived length. Default `calc(var(--space-12) * 2)`.
159
+ */
160
+ labelMinWidth?: string
161
+ /** Accessible name for the list. Default `'Chart legend'`. */
162
+ label?: string
163
+ /** Optional className on the list container. */
164
+ className?: string
165
+ }
166
+
167
+ export function ChartLegend({
168
+ items,
169
+ orientation = 'horizontal',
170
+ palette = 'default',
171
+ onSelect,
172
+ activeKey = null,
173
+ valueMinWidth = 'var(--space-12)',
174
+ labelMinWidth = 'calc(var(--space-12) * 2)',
175
+ label = 'Chart legend',
176
+ className,
177
+ }: ChartLegendProps) {
178
+ const vertical = orientation === 'vertical'
179
+ const interactive = onSelect !== undefined
180
+
181
+ return (
182
+ <ul
183
+ aria-label={label}
184
+ className={cn(
185
+ vertical ? 'flex flex-col' : 'flex flex-wrap items-center',
186
+ className,
187
+ )}
188
+ style={vertical ? undefined : { gap: 'var(--space-4)' }}
189
+ >
190
+ {items.map((item, index) => {
191
+ const dimmed = activeKey !== null && activeKey !== item.key
192
+ const color = slotColor(item.slot, palette)
193
+
194
+ const body = (
195
+ <>
196
+ <span
197
+ aria-hidden="true"
198
+ className="shrink-0"
199
+ style={{
200
+ width: 'var(--space-3)',
201
+ height: 'var(--space-3)',
202
+ background: color,
203
+ // See the header note: half of --radius-xs, because
204
+ // --radius-xs on a 12px box clamps to a circle.
205
+ borderRadius: 'calc(var(--radius-xs) / 2)',
206
+ }}
207
+ />
208
+ <span
209
+ className={cn('min-w-0', vertical && 'flex-1')}
210
+ style={{ minWidth: `min(${labelMinWidth}, 100%)` }}
211
+ >
212
+ <span
213
+ className="block truncate text-[14px] font-medium"
214
+ style={{ color: 'rgb(var(--foreground))' }}
215
+ title={item.label}
216
+ >
217
+ {item.label}
218
+ </span>
219
+ {vertical && item.meta !== undefined && (
220
+ <span
221
+ className="block truncate text-[12px]"
222
+ style={{
223
+ marginTop: 'var(--space-1)',
224
+ color: 'rgb(var(--text-tertiary))',
225
+ }}
226
+ title={item.meta}
227
+ >
228
+ {item.meta}
229
+ </span>
230
+ )}
231
+ </span>
232
+ {item.value !== undefined && (
233
+ <span
234
+ className={cn(
235
+ 'shrink-0 text-[14px] font-semibold tabular-nums',
236
+ vertical ? 'text-right' : '',
237
+ )}
238
+ style={{
239
+ color: 'rgb(var(--foreground))',
240
+ minWidth: vertical ? valueMinWidth : undefined,
241
+ // Keeps the value on the right edge of whichever line it
242
+ // lands on, so a wrapped value still reads as the value
243
+ // column rather than as a stray second label.
244
+ marginInlineStart: vertical ? 'auto' : undefined,
245
+ }}
246
+ >
247
+ {item.value}
248
+ </span>
249
+ )}
250
+ </>
251
+ )
252
+
253
+ // `flex-wrap` is the escape valve that makes `labelMinWidth` safe: when
254
+ // the label floor plus the reserved value column exceed the row, the
255
+ // VALUE drops to a second line and the label keeps its width. Without
256
+ // it the floor would just overflow the container instead.
257
+ const rowClass = cn(
258
+ 'flex w-full flex-wrap items-center text-left',
259
+ vertical ? 'gap-3' : 'gap-2',
260
+ )
261
+ const rowStyle = {
262
+ paddingBlock: vertical ? 'var(--space-4)' : undefined,
263
+ opacity: dimmed ? 0.55 : 1,
264
+ }
265
+
266
+ return (
267
+ <li
268
+ key={item.key}
269
+ className="min-w-0"
270
+ style={{
271
+ borderTop:
272
+ vertical && index > 0 ? '1px solid rgb(var(--border))' : undefined,
273
+ }}
274
+ >
275
+ {interactive ? (
276
+ <button
277
+ type="button"
278
+ aria-pressed={activeKey === item.key}
279
+ onClick={() => onSelect(item.key)}
280
+ className={cn(
281
+ rowClass,
282
+ 'cursor-pointer rounded-[var(--radius-xs)]',
283
+ 'motion-safe:transition-opacity',
284
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
285
+ )}
286
+ style={{
287
+ ...rowStyle,
288
+ transitionDuration: 'var(--dur-fast)',
289
+ transitionTimingFunction: 'var(--ease-out)',
290
+ }}
291
+ >
292
+ {body}
293
+ </button>
294
+ ) : (
295
+ <span className={rowClass} style={rowStyle}>
296
+ {body}
297
+ </span>
298
+ )}
299
+ </li>
300
+ )
301
+ })}
302
+ </ul>
303
+ )
304
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * ChartTooltip — the floating readout over a plot.
3
+ *
4
+ * Promoted in ADR-146 D5 (chart layer); it is the kit's `Information` card.
5
+ *
6
+ * CONSUMERS — measured 2026-09-06.
7
+ * TODAY, inside `@lovett/ui` (3): `line-chart.tsx` (`LineChart` /
8
+ * `AreaChart` — the hover band's readout), `bar-chart.tsx`,
9
+ * `donut-chart.tsx`.
10
+ * TODAY, in product code (0): no file under `apps/workspace/src` imports
11
+ * `ChartTooltip` directly; it reaches the product only through those
12
+ * charts. Tracked in
13
+ * `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
14
+ *
15
+ * ADR-146 D9 makes the hover layer part of the primitive rather than the
16
+ * caller's problem: an SVG chart is interactive by nature, and shipping one
17
+ * without a readout is shipping it unfinished.
18
+ *
19
+ * WHAT THIS PRIMITIVE IS RESPONSIBLE FOR
20
+ * --------------------------------------
21
+ * 1. **Never overflowing its plot.** It flips to the other side of the anchor
22
+ * near the right edge (and back near the left), and clamps vertically. The
23
+ * flip decision needs the tooltip's real size, so the box is measured in a
24
+ * layout effect — before paint, so there is no visible jump.
25
+ * 2. **Never re-laying out the plot.** Positioning is a `transform` on an
26
+ * absolutely-positioned box with `pointer-events: none`. It cannot push a
27
+ * mark, cannot resize the plot, and cannot steal a hover from the chart
28
+ * underneath it. `visible={false}` keeps it mounted and merely hides it, so
29
+ * showing it costs no measurement and no reflow.
30
+ * 3. **Text wears text tokens** (D10.4). The swatch carries the hue; the label
31
+ * is `--text-secondary` and the value `--foreground`. The source kit paints
32
+ * its tooltip label in the series colour — that is the one thing from its
33
+ * `Information` card we deliberately do not copy.
34
+ *
35
+ * THE PARENT MUST BE `position: relative`. `ChartFrame`'s plot area already
36
+ * is; a bespoke plot has to opt in, and the coordinates are relative to that
37
+ * box's top-left.
38
+ *
39
+ * Token discipline: panel is `--surface-card` + 1px `--border` + `--shadow-md`
40
+ * + `--radius-sm`, padded `--space-2` / `--space-3`. Series hues come from the
41
+ * frozen `SERIES` lookup (D8), never a constructed `--series-${i}`. The only
42
+ * bare numbers are geometry — pixel arithmetic cannot read a CSS custom
43
+ * property, and `offset` documents its token equivalent.
44
+ */
45
+
46
+ import { useLayoutEffect, useRef, useState } from 'react'
47
+
48
+ import { cn } from './lib/utils'
49
+ import { slotColor, type ChartSlot, type SeriesPalette } from './lib/series'
50
+
51
+ export interface ChartTooltipRow {
52
+ /** Stable identity key — also the React key. */
53
+ key: string
54
+ /** Series name. Wears `--text-secondary`, never the series colour. */
55
+ label: string
56
+ /**
57
+ * Pre-formatted value. Format through `@lovett/ui/format` at the chart
58
+ * level; the tooltip only sets it in `tabular-nums`.
59
+ */
60
+ value: string
61
+ /**
62
+ * Palette slot for the swatch, or `'other'`. Omit for a single-series
63
+ * readout, where a swatch would be noise — the title already names it.
64
+ */
65
+ slot?: ChartSlot
66
+ }
67
+
68
+ export interface ChartTooltipProps {
69
+ /** Anchor x, in pixels from the plot box's left edge. */
70
+ x: number
71
+ /** Anchor y, in pixels from the plot box's top edge. */
72
+ y: number
73
+ /** Plot box width in px — the right-edge flip and clamp bound. */
74
+ plotWidth: number
75
+ /** Plot box height in px — the vertical clamp bound. */
76
+ plotHeight: number
77
+ /** Optional heading — usually the x value ("Mar 14"). */
78
+ title?: string
79
+ /** One row per series at this x. */
80
+ rows: readonly ChartTooltipRow[]
81
+ /** Which palette `slot` indexes into. Default `'default'`. */
82
+ palette?: SeriesPalette
83
+ /**
84
+ * Gap in px between the anchor and the tooltip's near edge. Default `12`,
85
+ * the pixel value of `--space-3`; it is a number because the flip/clamp is
86
+ * arithmetic and cannot read a custom property.
87
+ */
88
+ offset?: number
89
+ /**
90
+ * Whether the readout is showing. Default `true`. `false` hides it without
91
+ * unmounting, so the measured size survives and re-showing costs no reflow.
92
+ */
93
+ visible?: boolean
94
+ /**
95
+ * Reserved width for the value column so a value growing from "9" to
96
+ * "11,227" does not resize the tooltip mid-hover. Default `var(--space-10)`.
97
+ */
98
+ valueMinWidth?: string
99
+ /** Optional className on the tooltip box. */
100
+ className?: string
101
+ }
102
+
103
+ interface Size {
104
+ readonly width: number
105
+ readonly height: number
106
+ }
107
+
108
+ const UNMEASURED: Size = { width: 0, height: 0 }
109
+
110
+ /**
111
+ * Clamp `value` into `[0, max]`, tolerating a `max` below zero (a tooltip
112
+ * wider than its plot pins to the left edge rather than going negative).
113
+ */
114
+ function clamp(value: number, max: number): number {
115
+ if (!Number.isFinite(value)) return 0
116
+ return Math.max(0, Math.min(value, Math.max(0, max)))
117
+ }
118
+
119
+ /**
120
+ * Where the tooltip box sits, given the anchor, the plot bounds and the
121
+ * measured box. Exported for tests — the flip/clamp is the whole contract and
122
+ * it is far easier to assert as arithmetic than as a rendered transform.
123
+ *
124
+ * - Prefers the right of the anchor; flips left when the right side would
125
+ * overflow **and** the left side actually fits (otherwise flipping would
126
+ * just overflow the other way).
127
+ * - Vertically centres on the anchor, then clamps inside the plot.
128
+ * - An unmeasured box (`0 × 0`) simply sits at the anchor; the layout effect
129
+ * corrects it before paint.
130
+ */
131
+ export function tooltipPosition(
132
+ anchor: { x: number; y: number },
133
+ plot: Size,
134
+ box: Size,
135
+ offset: number,
136
+ ): { left: number; top: number } {
137
+ const x = Number.isFinite(anchor.x) ? anchor.x : 0
138
+ const y = Number.isFinite(anchor.y) ? anchor.y : 0
139
+
140
+ const right = x + offset
141
+ const left = x - offset - box.width
142
+ const overflowsRight = right + box.width > plot.width
143
+ const fitsLeft = left >= 0
144
+ const chosen = overflowsRight && fitsLeft ? left : right
145
+
146
+ return {
147
+ left: clamp(chosen, plot.width - box.width),
148
+ top: clamp(y - box.height / 2, plot.height - box.height),
149
+ }
150
+ }
151
+
152
+ export function ChartTooltip({
153
+ x,
154
+ y,
155
+ plotWidth,
156
+ plotHeight,
157
+ title,
158
+ rows,
159
+ palette = 'default',
160
+ offset = 12,
161
+ visible = true,
162
+ valueMinWidth = 'var(--space-10)',
163
+ className,
164
+ }: ChartTooltipProps) {
165
+ const boxRef = useRef<HTMLDivElement>(null)
166
+ const [size, setSize] = useState<Size>(UNMEASURED)
167
+
168
+ // Re-measure exactly when the rendered content changes. A content signature
169
+ // rather than `[rows]` because `rows` is a fresh array on every parent
170
+ // render, which would make this effect run forever.
171
+ const signature = `${title ?? ''}\u0000${rows
172
+ .map((row) => `${row.key}\u0001${row.label}\u0001${row.value}`)
173
+ .join('\u0002')}`
174
+
175
+ useLayoutEffect(() => {
176
+ const el = boxRef.current
177
+ if (el === null) return
178
+ const next: Size = { width: el.offsetWidth, height: el.offsetHeight }
179
+ setSize((prev) =>
180
+ prev.width === next.width && prev.height === next.height ? prev : next,
181
+ )
182
+ }, [signature])
183
+
184
+ const { left, top } = tooltipPosition(
185
+ { x, y },
186
+ { width: plotWidth, height: plotHeight },
187
+ size,
188
+ offset,
189
+ )
190
+
191
+ return (
192
+ <div
193
+ ref={boxRef}
194
+ role="tooltip"
195
+ aria-hidden={!visible}
196
+ className={cn(
197
+ 'pointer-events-none absolute top-0 left-0 z-10 w-max max-w-full',
198
+ 'motion-safe:transition-transform',
199
+ className,
200
+ )}
201
+ style={{
202
+ transform: `translate3d(${left}px, ${top}px, 0)`,
203
+ transitionDuration: 'var(--dur-fast)',
204
+ // Data changes tween with ease-out. Never ease-spring: overshoot on a
205
+ // readout that tracks the cursor reads as imprecise (ADR-146 D9).
206
+ transitionTimingFunction: 'var(--ease-out)',
207
+ visibility: visible ? 'visible' : 'hidden',
208
+ background: 'rgb(var(--surface-card))',
209
+ border: '1px solid rgb(var(--border))',
210
+ borderRadius: 'var(--radius-sm)',
211
+ boxShadow: 'var(--shadow-md)',
212
+ padding: 'var(--space-2) var(--space-3)',
213
+ }}
214
+ >
215
+ {title !== undefined && (
216
+ <div
217
+ className="text-[12px] font-semibold tabular-nums"
218
+ style={{
219
+ marginBottom: 'var(--space-1)',
220
+ color: 'rgb(var(--foreground))',
221
+ }}
222
+ >
223
+ {title}
224
+ </div>
225
+ )}
226
+ <div className="flex flex-col" style={{ gap: 'var(--space-1)' }}>
227
+ {rows.map((row) => (
228
+ <div
229
+ key={row.key}
230
+ className="flex items-center gap-2"
231
+ >
232
+ {row.slot !== undefined && (
233
+ <span
234
+ aria-hidden="true"
235
+ className="shrink-0"
236
+ style={{
237
+ width: 'var(--space-2)',
238
+ height: 'var(--space-2)',
239
+ background: slotColor(row.slot, palette),
240
+ // Half of --radius-xs: at 8px the full token clamps to a
241
+ // circle, and a circle reads as a bullet, not a data mark.
242
+ borderRadius: 'calc(var(--radius-xs) / 2)',
243
+ }}
244
+ />
245
+ )}
246
+ <span
247
+ className="text-[12px] whitespace-nowrap"
248
+ style={{ color: 'rgb(var(--text-secondary))' }}
249
+ >
250
+ {row.label}
251
+ </span>
252
+ <span
253
+ className="ml-auto text-[12px] font-semibold tabular-nums whitespace-nowrap"
254
+ style={{
255
+ color: 'rgb(var(--foreground))',
256
+ minWidth: valueMinWidth,
257
+ textAlign: 'right',
258
+ }}
259
+ >
260
+ {row.value}
261
+ </span>
262
+ </div>
263
+ ))}
264
+ </div>
265
+ </div>
266
+ )
267
+ }
@@ -9,7 +9,7 @@
9
9
  * Promoted from brand-ai-extension (ADR-036 D20).
10
10
  */
11
11
 
12
- import { useEffect, useState, type HTMLAttributes } from 'react'
12
+ import { createContext, useContext, useEffect, useState, type HTMLAttributes, type ReactNode } from 'react'
13
13
  import { Check, Copy } from 'lucide-react'
14
14
  import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'
15
15
  import { vscDarkPlus, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
@@ -207,14 +207,54 @@ export function CodeBlock({ language, children }: CodeBlockProps) {
207
207
  }
208
208
 
209
209
  /**
210
- * MarkdownCode react-markdown code element adapter.
210
+ * Whether the code being rendered sits inside react-markdown's `pre` element,
211
+ * i.e. is a fenced or indented code BLOCK.
211
212
  *
212
- * Inline code (single backtick, no className) renders as a small chip.
213
- * Fenced code (triple backtick, className="language-xxx") delegates to
214
- * CodeBlock for syntax highlighting + copy button.
213
+ * The signal is structural, not textual. `remark-rehype` always emits block
214
+ * code as `<pre><code>` (its `code` handler wraps unconditionally) and inline
215
+ * code as a bare `<code>` (its `inlineCode` handler never wraps). react-markdown
216
+ * 10 hands every component the hast `node`, but a hast Element carries no
217
+ * parent link, so the `code` component cannot see `pre` from `node` alone —
218
+ * and react-markdown exposes `parent` only to `allowElement` / `urlTransform`,
219
+ * never to components. So the `pre` component announces itself here and
220
+ * `MarkdownCode` reads it.
221
+ *
222
+ * The previous discriminator was "has a className", which is only set when
223
+ * the fence names a language. That made every language-less fence — the rich
224
+ * composer's default (ADR-148 D3) — render as an inline chip. This is right
225
+ * for a bare fence, an empty fence, and a code span that wraps onto a second
226
+ * line (CommonMark folds its newline to a space, but nothing here relies on
227
+ * that).
228
+ */
229
+ const PreContext = createContext(false)
230
+
231
+ /**
232
+ * MarkdownPre — react-markdown `pre` element adapter.
233
+ *
234
+ * Renders no element of its own: `CodeBlock` owns its container, and a real
235
+ * `<pre>` here would double-wrap every fenced block. Its only job is to tell
236
+ * the `code` inside it that it is a block. Register together with
237
+ * `MarkdownCode` (as `markdownComponents` does).
238
+ */
239
+ export function MarkdownPre({ children }: HTMLAttributes<HTMLElement>) {
240
+ return <PreContext.Provider value={true}>{children}</PreContext.Provider>
241
+ }
242
+
243
+ /**
244
+ * MarkdownCode — react-markdown `code` element adapter.
245
+ *
246
+ * A BLOCK is a `code` inside react-markdown's `pre` (see `PreContext`) — or one
247
+ * handed a className, which is how `<MarkdownCode className="language-json">`
248
+ * is used directly outside react-markdown. Blocks delegate to `CodeBlock`: a
249
+ * `language-*` capture picks the highlighter; no capture (a bare fence, or a
250
+ * class that is not a language) gets the plain preformatted surface with the
251
+ * same header and copy button. Everything else is inline code and renders as
252
+ * a small chip.
215
253
  */
216
254
  export function MarkdownCode({ className, children }: HTMLAttributes<HTMLElement>) {
217
- if (!className) {
255
+ const inPre = useContext(PreContext)
256
+ const block = inPre || Boolean(className)
257
+ if (!block) {
218
258
  return (
219
259
  <code
220
260
  className="rounded px-1.5 py-0.5 font-mono text-[0.85em]"
@@ -229,7 +269,21 @@ export function MarkdownCode({ className, children }: HTMLAttributes<HTMLElement
229
269
  )
230
270
  }
231
271
 
232
- const match = /language-([\w-]+)/.exec(className)
272
+ const match = className === undefined ? null : /language-([\w-]+)/.exec(className)
233
273
  const language = match?.[1]
234
- return <CodeBlock {...(language !== undefined ? { language } : {})}>{String(children)}</CodeBlock>
274
+ return <CodeBlock {...(language !== undefined ? { language } : {})}>{sourceOf(children)}</CodeBlock>
275
+ }
276
+
277
+ /**
278
+ * The code text react-markdown hands a block: one string for a fence with
279
+ * content, and NOTHING for an empty fence — `hast-util-to-jsx-runtime`'s
280
+ * `addChildren` only sets `props.children` when the child is truthy, so the
281
+ * empty-string text node never arrives and `String(children)` would render
282
+ * the word "undefined".
283
+ */
284
+ function sourceOf(children: ReactNode): string {
285
+ if (typeof children === 'string') return children
286
+ if (typeof children === 'number') return String(children)
287
+ if (children === undefined || children === null || typeof children === 'boolean') return ''
288
+ return String(children)
235
289
  }