@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- 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
|
+
}
|
package/src/code-block.tsx
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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
|
-
|
|
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 } : {})}>{
|
|
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
|
}
|