@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,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
|
+
}
|