@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,376 @@
1
+ /**
2
+ * StatCard — the KPI card: label, value, delta, optional icon tile, optional
3
+ * footer.
4
+ *
5
+ * Promoted in ADR-146 D5 (stat layer).
6
+ *
7
+ * CONSUMERS — measured 2026-09-06 by grepping real `from '@lovett/ui'`
8
+ * imports under `apps/workspace/src`. The design-system gallery
9
+ * (`design-system/registry/demos/analytics.tsx`) is a demo harness and does
10
+ * NOT count toward the ADR-008 D3 gate.
11
+ * TODAY (2 — ADR-008 D3 gate MET, the only primitive in this set that
12
+ * clears it):
13
+ * • `components/ui/admin/stat-tile.tsx` — the analytics KPI grid
14
+ * • `lenses/keywords/keyword-stats.tsx` — volume / CPC / competition
15
+ * PLANNED, not written: creative-checker (spec pass rate), sem-spec
16
+ * (output stats). Tracked in `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
17
+ *
18
+ * `MetricCard`, `ShellStat` and `StatRow` are NOT deleted by this file —
19
+ * `StatCard` is the superset and migration of the ~26 forked tiles is
20
+ * ADR-146 D13 (`converge-stat-forks.md`).
21
+ *
22
+ * WHY THIS EXISTS AT ALL
23
+ * ----------------------
24
+ * The inventory behind ADR-146 found three shipped KPI primitives and ~26
25
+ * lens-local re-implementations of the same label+value tile. The cause is
26
+ * one missing slot: **not one shipped primitive can express "value + how it
27
+ * changed"**, which is the entire job of a KPI card. So every lens that
28
+ * needed a delta forked the tile. The delta slot is the point of this
29
+ * primitive; everything else is anatomy.
30
+ *
31
+ * ANATOMY (METORIC-ANALYTICS-SPEC §2, §4; ADR-146 D1)
32
+ * ---------------------------------------------------
33
+ * frame `.ds-card-surface[data-framed]` --surface-frame, r16, pad 4
34
+ * panel `.ds-card-tray` --surface-card, r12, pad 16
35
+ * footer on the frame, below the panel
36
+ *
37
+ * The radii are concentric by construction: 16 - 4 = 12. Both classes are
38
+ * the ones `Card`, `Modal` and `ProfileSection` already use, so this is the
39
+ * house frame, not a forked `<div>`.
40
+ *
41
+ * THE TRAY IS CONDITIONAL — this is the source kit's own rule and it is
42
+ * load-bearing. A stat card with nothing but a value has ONE panel, so it is
43
+ * a plain `.ds-card-surface` with no frame; wrapping it anyway gives every
44
+ * card in a KPI grid a redundant 4px border-in-a-border. The frame appears
45
+ * only when there is a second thing to separate — here, a footer.
46
+ *
47
+ * LINE-HEIGHT 1 ON THE VALUE
48
+ * --------------------------
49
+ * Everything else in the card is 1.5. The metric is set solid (spec §4), and
50
+ * that single difference is what stops a KPI card reading loose. Do not
51
+ * "fix" it to match its siblings.
52
+ *
53
+ * RESERVED SPACE (design principles → Layout stability)
54
+ * -----------------------------------------------------
55
+ * A KPI is the most likely thing on a page to tick. The value gets a `ch`
56
+ * min-width plus `tabular-nums` so digits cannot change the card's width,
57
+ * and BOTH branches — the value row and the pending well — render into ONE
58
+ * `--space-6` slot, so adding a delta chip or swapping in the well cannot
59
+ * change the card's height. That is why the well is padded to the chip's
60
+ * own 2px rhythm rather than a control's 8px: at 8px it stood 10px taller
61
+ * than the row it replaces, and a pending card grew its whole grid row.
62
+ * The one exception is `pendingHint`, which adds a deliberate second line.
63
+ * A stat card whose box breathes with its contents is the cheap-UI tell.
64
+ *
65
+ * PENDING IS NOT ZERO
66
+ * -------------------
67
+ * `pending` renders an explicit "Not available" well instead of a number.
68
+ * This behaviour is carried over from `admin/stat-tile.tsx` where it is
69
+ * load-bearing (ADR-088 D6): an analytics tile with no data must never
70
+ * fabricate a figure, and `0` is a fabricated figure.
71
+ *
72
+ * Token discipline: every colour is `rgb(var(--token))`; no colour literals,
73
+ * no constructed token names. Sizes come from `--space-*`, `--radius-*` and
74
+ * `--ctrl-md` (the 40px icon tile). The two value sizes are frozen static
75
+ * Tailwind classes — never interpolated, because an interpolated arbitrary
76
+ * value is statically unscannable and generates nothing.
77
+ */
78
+
79
+ import type { ReactNode } from 'react'
80
+ import { ArrowRight } from 'lucide-react'
81
+ import { cn } from './lib/utils'
82
+ import { DeltaChip, type DeltaChipProps } from './delta-chip'
83
+
84
+ /** `md` = 18px value (dense grids), `lg` = 24px (a hero KPI). Spec §4. */
85
+ export type StatCardSize = 'md' | 'lg'
86
+
87
+ /**
88
+ * Frozen static class strings. `text-[${n}px]` would type-check, lint and
89
+ * build while generating no CSS at all — Tailwind scans source statically.
90
+ */
91
+ const VALUE_CLASS: Record<StatCardSize, string> = {
92
+ md: 'text-[18px]',
93
+ lg: 'text-[24px]',
94
+ }
95
+
96
+ /**
97
+ * Default `ch` reserve per size. 6 fits "482.5K" / "12,480" — the compact
98
+ * and 5-digit forms a KPI actually ticks through. A caller whose figure runs
99
+ * longer raises `valueChars`; the value grows past the reserve rather than
100
+ * clipping, so the default is a floor, not a cap.
101
+ */
102
+ const VALUE_RESERVE: Record<StatCardSize, number> = {
103
+ md: 6,
104
+ lg: 6,
105
+ }
106
+
107
+ export interface StatCardFooterLink {
108
+ /** Visible text. Carries the accessible name — no bare icon link. */
109
+ label: string
110
+ /** Renders an `<a>`. Mutually exclusive with `onClick` in practice. */
111
+ href?: string
112
+ /** Renders a `<button>` when there is no `href`. */
113
+ onClick?: () => void
114
+ }
115
+
116
+ export interface StatCardProps {
117
+ /** Small muted caption above the value. 12px semibold (spec §4). */
118
+ label: string
119
+ /**
120
+ * The headline figure. Pass a PRE-FORMATTED string from
121
+ * `@lovett/ui/format` — the card does not format, so a caller can pick
122
+ * currency, compact or ratio without a prop explosion.
123
+ */
124
+ value: ReactNode
125
+ /**
126
+ * Period-over-period change, rendered as a `<DeltaChip>`. Typed as the
127
+ * chip's own props so a caller cannot fork the chip — passing a `ReactNode`
128
+ * here is exactly how the ~26 forks started.
129
+ */
130
+ delta?: DeltaChipProps
131
+ /** `md` (18px value) or `lg` (24px). Default `md`. */
132
+ size?: StatCardSize
133
+ /**
134
+ * Optional 20px Lucide icon, rendered in a 40x40 raised tile at the
135
+ * card's trailing edge. Pass the element (`<TrendingUp size={20} />`) so
136
+ * the caller owns the glyph; the tile owns the surface.
137
+ */
138
+ icon?: ReactNode
139
+ /**
140
+ * Secondary text below the panel. Its presence is what promotes the card
141
+ * to the framed tray anatomy — see the header.
142
+ */
143
+ footer?: ReactNode
144
+ /** Optional trailing link/arrow in the footer row. Also promotes the tray. */
145
+ footerLink?: StatCardFooterLink
146
+ /**
147
+ * No data for this metric. Renders the "Not available" well instead of a
148
+ * number, and suppresses the delta. Never render `0` for missing data.
149
+ */
150
+ pending?: boolean
151
+ /** Headline shown in the pending well. Default "Not available". */
152
+ pendingLabel?: string
153
+ /** Optional second line in the pending well — why, or what unit is missing. */
154
+ pendingHint?: string
155
+ /**
156
+ * Character slot reserved for the value so a ticking number cannot resize
157
+ * the card. Defaults to 6; raise it for figures that will exceed it.
158
+ */
159
+ valueChars?: number
160
+ /** Optional class override for the outermost element. */
161
+ className?: string
162
+ }
163
+
164
+ export function StatCard({
165
+ label,
166
+ value,
167
+ delta,
168
+ size = 'md',
169
+ icon,
170
+ footer,
171
+ footerLink,
172
+ pending = false,
173
+ pendingLabel = 'Not available',
174
+ pendingHint,
175
+ valueChars,
176
+ className,
177
+ }: StatCardProps) {
178
+ // The tray exists only when there is a second thing inside the frame.
179
+ const framed = Boolean(footer || footerLink)
180
+ const reserve = valueChars ?? VALUE_RESERVE[size]
181
+
182
+ const panel = (
183
+ <div
184
+ className="flex items-start justify-between"
185
+ style={{ gap: 'var(--space-4)' }}
186
+ >
187
+ <div
188
+ className="flex min-w-0 flex-col"
189
+ style={{ gap: 'var(--space-2)' }}
190
+ >
191
+ <span
192
+ className="text-[12px] font-semibold leading-[1.5]"
193
+ style={{ color: 'rgb(var(--text-tertiary))' }}
194
+ >
195
+ {label}
196
+ </span>
197
+
198
+ {/* ONE slot for both branches — see the header. --space-6 clears
199
+ the 24px `lg` value, the 22px chip and a single-line well, so
200
+ pending → loaded is not a layout shift. */}
201
+ <div
202
+ // `flex-wrap`: when the value plus the chip genuinely cannot fit the
203
+ // column (a 3-up KPI grid at desktop width is already that narrow),
204
+ // the chip drops to a second line. Without it the row keeps
205
+ // shrinking and the two paint ON TOP of each other — measured 12px
206
+ // of "6,140,000" under its own delta chip in the gallery's KPI row.
207
+ // A card that grows by one line in the overflow case is strictly
208
+ // better than a card that renders illegible text.
209
+ className="flex flex-wrap items-center"
210
+ style={{ gap: 'var(--space-2)', minHeight: 'var(--space-6)' }}
211
+ >
212
+ {pending ? (
213
+ <div
214
+ className="flex flex-col"
215
+ style={{
216
+ gap: 'calc(var(--space-1) / 2)',
217
+ // The chip's own 2/8 rhythm, so the well fits the reserved
218
+ // row instead of growing it.
219
+ padding: 'calc(var(--space-1) / 2) var(--space-2)',
220
+ borderRadius: 'var(--radius-xs)',
221
+ background: 'rgb(var(--muted))',
222
+ boxShadow: 'inset 0 0 0 1px rgb(var(--border))',
223
+ }}
224
+ >
225
+ <span
226
+ className="text-[12px] font-semibold leading-[1.5]"
227
+ style={{ color: 'rgb(var(--text-secondary))' }}
228
+ >
229
+ {pendingLabel}
230
+ </span>
231
+ {pendingHint && (
232
+ <span
233
+ className="text-[12px] leading-[1.5]"
234
+ // --text-tertiary, NOT --text-muted: tokens.css documents
235
+ // --text-muted as non-text/disabled chrome only (3.47:1 on
236
+ // a white card, under the 4.5:1 text floor). The hint is
237
+ // real prose a user has to read.
238
+ style={{ color: 'rgb(var(--text-tertiary))' }}
239
+ >
240
+ {pendingHint}
241
+ </span>
242
+ )}
243
+ </div>
244
+ ) : (
245
+ <>
246
+ <span
247
+ className={cn(
248
+ // Size FIRST: tailwind-merge treats font-size as conflicting
249
+ // with line-height (a Tailwind text-* utility sets both), so
250
+ // a size class placed after `leading-none` deletes it — and
251
+ // the value silently reverts to 1.5, which is the exact
252
+ // "looks loose" defect the solid setting exists to prevent.
253
+ VALUE_CLASS[size],
254
+ // Line-height 1: the metric is set solid. See the header.
255
+ 'font-semibold leading-none tabular-nums',
256
+ // `shrink-0` is what makes the documented contract true. An
257
+ // explicit `min-width` REPLACES flexbox's default
258
+ // `min-width: auto` (min-content) protection, so without
259
+ // this the span shrinks to exactly the reserve and the
260
+ // longer figure overflows its own box — painting under the
261
+ // delta chip rather than "growing past the reserve".
262
+ 'shrink-0',
263
+ )}
264
+ style={{
265
+ color: 'rgb(var(--foreground))',
266
+ minWidth: `${reserve}ch`,
267
+ }}
268
+ >
269
+ {value}
270
+ </span>
271
+ {delta && <DeltaChip {...delta} />}
272
+ </>
273
+ )}
274
+ </div>
275
+ </div>
276
+
277
+ {icon && (
278
+ <span
279
+ className="inline-flex shrink-0 items-center justify-center"
280
+ style={{
281
+ width: 'var(--ctrl-md)',
282
+ height: 'var(--ctrl-md)',
283
+ borderRadius: 'var(--radius-sm)',
284
+ // --surface-raised, NOT --surface-card: in dark the card IS
285
+ // --surface-card, so a card-coloured tile is the same colour as
286
+ // the thing it sits on and only an 8%-alpha border separates
287
+ // them. --surface-raised (ADR-063) is white in light — identical
288
+ // to today — and a real step up in dark.
289
+ background: 'rgb(var(--surface-raised))',
290
+ border: '1px solid rgb(var(--border-card))',
291
+ boxShadow: 'var(--shadow-sm)',
292
+ color: 'rgb(var(--text-tertiary))',
293
+ }}
294
+ >
295
+ {icon}
296
+ </span>
297
+ )}
298
+ </div>
299
+ )
300
+
301
+ if (!framed) {
302
+ // One panel, so no frame: a plain card at --radius-lg.
303
+ return (
304
+ <div
305
+ data-pending={pending ? 'true' : undefined}
306
+ className={cn('ds-card-surface', className)}
307
+ style={{ padding: 'var(--space-4)' }}
308
+ >
309
+ {panel}
310
+ </div>
311
+ )
312
+ }
313
+
314
+ return (
315
+ <div
316
+ data-framed="true"
317
+ data-pending={pending ? 'true' : undefined}
318
+ className={cn('ds-card-surface flex flex-col', className)}
319
+ >
320
+ <div className="ds-card-tray" style={{ padding: 'var(--space-4)' }}>
321
+ {panel}
322
+ </div>
323
+ <div
324
+ className="flex items-center justify-between text-[12px] leading-[1.5]"
325
+ style={{
326
+ gap: 'var(--space-3)',
327
+ // Left pad = tray margin (4) + tray padding (16), so the footer
328
+ // text sits on the same column as the label above it.
329
+ padding: 'var(--space-2) var(--space-5) var(--space-3)',
330
+ color: 'rgb(var(--text-tertiary))',
331
+ }}
332
+ >
333
+ <span className="min-w-0 truncate">{footer}</span>
334
+ {footerLink && <FooterLink {...footerLink} />}
335
+ </div>
336
+ </div>
337
+ )
338
+ }
339
+
340
+ function FooterLink({ label, href, onClick }: StatCardFooterLink) {
341
+ const className = cn(
342
+ 'inline-flex shrink-0 items-center text-[12px] font-semibold leading-[1.5]',
343
+ 'transition-colors duration-[var(--dur-fast)] ease-[var(--ease-out)]',
344
+ 'motion-reduce:transition-none',
345
+ // Colour lives in a class, not the style object: an inline `color`
346
+ // outranks any class, so a hover rule beside it would never paint.
347
+ '[color:rgb(var(--text-secondary))] hover:[color:rgb(var(--foreground))]',
348
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
349
+ )
350
+ const style = {
351
+ gap: 'var(--space-1)',
352
+ padding: 'calc(var(--space-1) / 2) var(--space-1)',
353
+ borderRadius: 'var(--radius-xs)',
354
+ }
355
+ const body = (
356
+ <>
357
+ {label}
358
+ <ArrowRight size={14} strokeWidth={2} aria-hidden="true" />
359
+ </>
360
+ )
361
+
362
+ if (href) {
363
+ // onClick is wired here too — an <a href> that silently dropped a
364
+ // handler the caller passed is a bug that only shows up in analytics.
365
+ return (
366
+ <a href={href} onClick={onClick} className={className} style={style}>
367
+ {body}
368
+ </a>
369
+ )
370
+ }
371
+ return (
372
+ <button type="button" onClick={onClick} className={className} style={style}>
373
+ {body}
374
+ </button>
375
+ )
376
+ }