@aircall/blocks 0.18.0 → 0.19.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.
@@ -2,50 +2,69 @@
2
2
  name: aircall-blocks/migrate-dashboard/tile
3
3
  description: >
4
4
  Migrate @dashboard/library Tile, TileHeader, TileValue (+ TileBody, TileActions,
5
- TileExtra, TileNumber, TileDuration, TileText) to @aircall/ds Card primitives
6
- (Card, CardHeader, CardTitle, CardAction, CardContent). Load when a file imports
7
- Tile, TileHeader, or TileValue from @dashboard/library.
5
+ TileExtra, TileNumber, TileDuration, TileText) to @aircall/blocks KpiCard
6
+ compositions (KpiCard, KpiValue, KpiDescription, KpiValueSkeleton,
7
+ KpiDescriptionSkeleton) plus DS CardHeader / CardTitle / CardContent / CardAction.
8
+ Load when a file imports Tile, TileHeader, or TileValue from @dashboard/library.
8
9
  type: sub-skill
9
10
  library: aircall-blocks
10
11
  requires:
11
12
  - aircall-blocks/setup
12
13
  - aircall-blocks/migrate-dashboard
13
14
  sources:
14
- - "aircall/hydra:packages/ds/src/index.ts"
15
+ - "aircall/hydra:packages/blocks/src/components/kpi.tsx"
16
+ - "aircall/hydra:packages/blocks/src/helpers/format-kpi.ts"
17
+ - "aircall/hydra:packages/ds/src/components/card.tsx"
15
18
  ---
16
19
 
17
20
  This skill builds on aircall-blocks/migrate-dashboard.
18
21
 
19
22
  ## 1. Component mapping
20
23
 
21
- | @dashboard/library | @aircall/ds |
24
+ | @dashboard/library | Target |
22
25
  | --- | --- |
23
- | `Tile` (root container) | `Card` |
24
- | `TileHeader` `label` prop | `CardTitle` inside `CardHeader` |
25
- | `TileHeader` `tooltipTexts` prop | inline `Tooltip` / `TooltipTrigger` / `TooltipContent` next to `CardTitle` |
26
- | `TileBody` (metric row wrapper) | `CardContent` |
27
- | `TileActions` (top-right action overlay) | `CardAction` inside `CardHeader` |
28
- | `TileValue` (number / duration / text display) | inline JSX using `Skeleton` (loading state) + plain markup |
29
- | `TileExtra` `secondaryText` prop | plain `<span>` inside `CardContent` |
30
- | `TileExtra` `trend` prop | custom trend badge no DS equivalent; keep app-side |
31
- | `Tile` `disabled` prop | `data-disabled` attribute on `Card` + `className="opacity-50 cursor-not-allowed"` |
32
- | `TileProvider` (internal context) | removed no equivalent needed |
33
-
34
- `TileValue` has no direct DS replacement — it is a formatting component (locale number, duration, text), not a structural one. Replace it with a plain `<span>` or `<p>` carrying the formatted value, optionally wrapped in a `Skeleton` for the loading state.
35
-
36
- `TILE_VALUE` enum and `TileValueSizes` type are internal to `@dashboard/library`; delete them from the import when migrating.
26
+ | `Tile` (root container) | `KpiCard` (`@aircall/blocks`) — DS `Card` wrapper, `size="sm"` by default |
27
+ | `Tile` `disabled` prop | `KpiCard disabled` applies `opacity-50` + `cursor-not-allowed` and cascades to nested `KpiValue`s |
28
+ | `TileHeader` `label` prop | DS `CardTitle` inside DS `CardHeader` |
29
+ | `TileHeader` `tooltipTexts` prop | inline DS `Tooltip` / `TooltipTrigger` / `TooltipContent` next to `CardTitle` |
30
+ | `TileBody` (metric row wrapper) | DS `CardContent` |
31
+ | `TileActions` (top-right action overlay) | DS `CardAction` inside `CardHeader` |
32
+ | `TileValue` (number / duration / text / percent) | `KpiValue` (`@aircall/blocks`) use `kind`, `durationStyle`, `unit`, `onClick`, `active` |
33
+ | `TileValue loading` | `KpiValueSkeleton` (and `KpiDescriptionSkeleton` when a comparison line loads) |
34
+ | `TileExtra` `secondaryText` prop | `KpiDescription` children |
35
+ | `TileExtra` `trend` prop | `KpiDescription` `trend` + `tone` (`up` / `down` / `neutral` × `positive` / `negative` / `neutral`) |
36
+ | `TileProvider` (internal context) | removed — `KpiCard` provides disabled context |
37
+
38
+ `TILE_VALUE` enum and `TileValueSizes` type are internal to `@dashboard/library`; delete them from the import when migrating. Map former types via `KpiValue` `kind`:
39
+
40
+ | Former `TILE_VALUE` / type | `KpiValue` props |
41
+ | --- | --- |
42
+ | number | `kind="number"` (default for numeric `value`) |
43
+ | percent | `kind="percent"` |
44
+ | duration (long / verbose) | `kind="duration"` (`durationStyle="long"` default) |
45
+ | duration clock HH:MM | `kind="duration" durationStyle="clock" durationScale="hours"` |
46
+ | duration clock MM:SS | `kind="duration" durationStyle="clock" durationScale="minutes"` |
47
+ | opaque string | string `value` (or `kind="text"`) |
37
48
 
38
49
  ## 2. Imports
39
50
 
40
51
  ```tsx
41
- // DS primitives tile shell
52
+ // BlocksKPI composition
42
53
  import {
43
- Card,
54
+ KpiCard,
55
+ KpiValue,
56
+ KpiDescription,
57
+ KpiValueSkeleton,
58
+ KpiDescriptionSkeleton
59
+ } from '@aircall/blocks';
60
+
61
+ // DS — card slots + optional tooltip / actions
62
+ import {
63
+ Button,
44
64
  CardAction,
45
65
  CardContent,
46
66
  CardHeader,
47
67
  CardTitle,
48
- Skeleton,
49
68
  Tooltip,
50
69
  TooltipContent,
51
70
  TooltipProvider,
@@ -53,9 +72,6 @@ import {
53
72
  } from '@aircall/ds';
54
73
  ```
55
74
 
56
- No `@aircall/blocks` import is required for the tile container migration. Blocks are only
57
- needed if the tile sits inside a larger page layout from that package.
58
-
59
75
  ## 3. Before / After
60
76
 
61
77
  ### 3a. Basic tile — number value
@@ -76,29 +92,30 @@ function AnsweredTile() {
76
92
  }
77
93
  ```
78
94
 
79
- **After (`@aircall/ds`):**
95
+ **After (`@aircall/blocks` + `@aircall/ds`):**
80
96
  ```tsx
81
- import { Card, CardContent, CardHeader, CardTitle } from '@aircall/ds';
97
+ import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
98
+ import { KpiCard, KpiValue } from '@aircall/blocks';
82
99
 
83
100
  function AnsweredTile() {
84
101
  return (
85
- <Card className="min-w-36">
102
+ <KpiCard className="min-w-36">
86
103
  <CardHeader>
87
- <CardTitle className="text-xs font-medium text-muted-foreground">Answered</CardTitle>
104
+ <CardTitle>Answered</CardTitle>
88
105
  </CardHeader>
89
106
  <CardContent>
90
- <span className="text-2xl font-bold tabular-nums">123</span>
107
+ <KpiValue value={123} kind="number" />
91
108
  </CardContent>
92
- </Card>
109
+ </KpiCard>
93
110
  );
94
111
  }
95
112
  ```
96
113
 
97
114
  Key changes:
98
- - `Tile` → `Card`. The `minWidth` prop (default 144 px) becomes `className="min-w-36"`.
99
- - `TileHeader label` → `CardTitle` inside `CardHeader`. Apply `text-xs font-medium text-muted-foreground` to match the original `supportingMediumXS` / `text-interactive-neutral` visual style.
100
- - `TileBody` → `CardContent`. Flex alignment was internal; use `className="flex items-center gap-1"` on `CardContent` if you need the row layout.
101
- - `TileValue` → inline `<span>` with the formatted value. The `size="regular"` style maps to `text-2xl font-bold tabular-nums`.
115
+ - `Tile` → `KpiCard`. Optional `minWidth` (default 144 px) becomes `className="min-w-36"`.
116
+ - `TileHeader label` → `CardTitle` inside `CardHeader`.
117
+ - `TileBody` → `CardContent`.
118
+ - `TileValue` → `KpiValue` (formatting + typography owned by the block).
102
119
 
103
120
  ### 3b. Tile with loading state
104
121
 
@@ -118,26 +135,27 @@ function LoadingTile() {
118
135
  }
119
136
  ```
120
137
 
121
- **After (`@aircall/ds`):**
138
+ **After:**
122
139
  ```tsx
123
- import { Card, CardContent, CardHeader, CardTitle, Skeleton } from '@aircall/ds';
140
+ import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
141
+ import { KpiCard, KpiValueSkeleton } from '@aircall/blocks';
124
142
 
125
143
  function LoadingTile() {
126
144
  return (
127
- <Card className="min-w-36">
145
+ <KpiCard className="min-w-36">
128
146
  <CardHeader>
129
- <CardTitle className="text-xs font-medium text-muted-foreground">Answered</CardTitle>
147
+ <CardTitle>Answered</CardTitle>
130
148
  </CardHeader>
131
- <CardContent className="flex h-8 items-center">
132
- <Skeleton className="h-6 w-18" />
149
+ <CardContent>
150
+ <KpiValueSkeleton />
133
151
  </CardContent>
134
- </Card>
152
+ </KpiCard>
135
153
  );
136
154
  }
137
155
  ```
138
156
 
139
- `TileValue loading` rendered a `Skeleton` at a fixed height container (32 px for `size="regular"`).
140
- Replicate with `className="flex h-8 items-center"` on `CardContent` and `className="h-6 w-18"` on `Skeleton`.
157
+ When a comparison line also loads, pair with `KpiDescriptionSkeleton` inside
158
+ `CardContent className="flex flex-col gap-1"`.
141
159
 
142
160
  ### 3c. Tile with tooltip on header
143
161
 
@@ -157,10 +175,9 @@ function InfoTile() {
157
175
  }
158
176
  ```
159
177
 
160
- **After (`@aircall/ds`):**
178
+ **After:**
161
179
  ```tsx
162
180
  import {
163
- Card,
164
181
  CardContent,
165
182
  CardHeader,
166
183
  CardTitle,
@@ -170,16 +187,19 @@ import {
170
187
  TooltipTrigger
171
188
  } from '@aircall/ds';
172
189
  import { InformationCircleIcon } from '@aircall/react-icons';
190
+ import { KpiCard, KpiValue } from '@aircall/blocks';
173
191
 
174
192
  function InfoTile() {
175
193
  return (
176
- <Card className="min-w-36">
194
+ <KpiCard className="min-w-36">
177
195
  <CardHeader>
178
- <CardTitle className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
196
+ <CardTitle className="flex items-center gap-1">
179
197
  Answered
180
198
  <TooltipProvider>
181
199
  <Tooltip>
182
- <TooltipTrigger asChild>
200
+ <TooltipTrigger
201
+ render={<button type="button" className="inline-flex" aria-label="More info" />}
202
+ >
183
203
  <InformationCircleIcon className="h-3 w-3 cursor-default" aria-hidden />
184
204
  </TooltipTrigger>
185
205
  <TooltipContent>Calls answered in the period</TooltipContent>
@@ -188,17 +208,14 @@ function InfoTile() {
188
208
  </CardTitle>
189
209
  </CardHeader>
190
210
  <CardContent>
191
- <span className="text-2xl font-bold tabular-nums">123</span>
211
+ <KpiValue value={123} />
192
212
  </CardContent>
193
- </Card>
213
+ </KpiCard>
194
214
  );
195
215
  }
196
216
  ```
197
217
 
198
- `TileHeader` showed the tooltip icon only on hover (`hovering` from `TileProvider`). The DS
199
- `Tooltip` is hover-driven by default — no extra state needed.
200
-
201
- ### 3d. Tile with actions (TileActions)
218
+ ### 3d. Tile with actions + clickable value
202
219
 
203
220
  **Before (`@dashboard/library`):**
204
221
  ```tsx
@@ -224,50 +241,37 @@ function ActionTile() {
224
241
  }
225
242
  ```
226
243
 
227
- **After (`@aircall/ds`):**
244
+ **After:**
228
245
  ```tsx
229
- import {
230
- Button,
231
- Card,
232
- CardAction,
233
- CardContent,
234
- CardHeader,
235
- CardTitle
236
- } from '@aircall/ds';
237
- import { SettingsIcon, MoreVerticalIcon } from '@aircall/react-icons';
246
+ import { Button, CardAction, CardContent, CardHeader, CardTitle } from '@aircall/ds';
247
+ import { EllipsisVertical, Settings } from '@aircall/react-icons';
248
+ import { KpiCard, KpiValue } from '@aircall/blocks';
238
249
 
239
250
  function ActionTile() {
240
251
  return (
241
- <Card className="min-w-36">
252
+ <KpiCard className="min-w-36">
242
253
  <CardHeader>
243
- <CardTitle className="text-xs font-medium text-muted-foreground">Answered</CardTitle>
254
+ <CardTitle>Answered</CardTitle>
244
255
  <CardAction className="flex items-center gap-1">
245
- <Button variant="ghost" size="icon" aria-label="Settings" onClick={() => null}>
246
- <SettingsIcon className="h-4 w-4" />
256
+ <Button variant="ghost" size="icon-sm" aria-label="Settings" onClick={() => null}>
257
+ <Settings />
247
258
  </Button>
248
- <Button variant="ghost" size="icon" aria-label="More options" onClick={() => null}>
249
- <MoreVerticalIcon className="h-4 w-4" />
259
+ <Button variant="ghost" size="icon-sm" aria-label="More options" onClick={() => null}>
260
+ <EllipsisVertical />
250
261
  </Button>
251
262
  </CardAction>
252
263
  </CardHeader>
253
264
  <CardContent>
254
- <button
255
- type="button"
256
- className="text-2xl font-bold tabular-nums underline decoration-dashed"
257
- onClick={() => null}
258
- >
259
- 123
260
- </button>
265
+ <KpiValue value={123} onClick={() => null} />
261
266
  </CardContent>
262
- </Card>
267
+ </KpiCard>
263
268
  );
264
269
  }
265
270
  ```
266
271
 
267
272
  Key changes:
268
- - `TileActions` → `CardAction` inside `CardHeader`. `CardAction` carries `data-slot="card-action"` and is pinned top-right by the header grid automatically.
269
- - `Gap` + `IconButton` from tractor `Button variant="ghost" size="icon"` from `@aircall/ds`.
270
- - `TileValue onClick` (clickable value) → plain `<button>` element styled to match.
273
+ - `TileActions` → `CardAction` inside `CardHeader`.
274
+ - `TileValue onClick` `KpiValue onClick` (dotted underline; `active` for solid underline).
271
275
 
272
276
  ### 3e. Disabled tile
273
277
 
@@ -278,7 +282,7 @@ import { Tile, TileBody, TileHeader, TileValue } from '@dashboard/library';
278
282
  function DisabledTile() {
279
283
  return (
280
284
  <Tile disabled>
281
- <TileHeader label="Answered" tooltipTexts={{ help: 'Feature disabled' }} />
285
+ <TileHeader label="Answered" />
282
286
  <TileBody>
283
287
  <TileValue onClick={() => null} value={123} />
284
288
  </TileBody>
@@ -287,142 +291,189 @@ function DisabledTile() {
287
291
  }
288
292
  ```
289
293
 
290
- **After (`@aircall/ds`):**
294
+ **After:**
291
295
  ```tsx
292
- import { Card, CardContent, CardHeader, CardTitle } from '@aircall/ds';
296
+ import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
297
+ import { KpiCard, KpiValue } from '@aircall/blocks';
293
298
 
294
299
  function DisabledTile() {
295
300
  return (
296
- <Card className="min-w-36 opacity-50" data-disabled aria-disabled>
301
+ <KpiCard disabled className="min-w-36">
297
302
  <CardHeader>
298
- <CardTitle className="text-xs font-medium text-muted-foreground">Answered</CardTitle>
303
+ <CardTitle>Answered</CardTitle>
299
304
  </CardHeader>
300
305
  <CardContent>
301
- <span className="cursor-not-allowed text-2xl font-bold tabular-nums">123</span>
306
+ <KpiValue value={123} onClick={() => null} />
307
+ </CardContent>
308
+ </KpiCard>
309
+ );
310
+ }
311
+ ```
312
+
313
+ `KpiCard disabled` applies `opacity-50` + `cursor-not-allowed` and cascades so nested
314
+ `KpiValue` buttons cannot be activated — no need to strip `onClick` or style the value by hand.
315
+
316
+ ### 3f. Comparison / trend line (TileExtra)
317
+
318
+ **Before:** `TileExtra` with `secondaryText` + `trend`.
319
+
320
+ **After:**
321
+ ```tsx
322
+ import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
323
+ import { KpiCard, KpiDescription, KpiValue } from '@aircall/blocks';
324
+
325
+ function OutboundCallsTile() {
326
+ return (
327
+ <KpiCard className="min-w-36">
328
+ <CardHeader>
329
+ <CardTitle>Outbound calls</CardTitle>
330
+ </CardHeader>
331
+ <CardContent className="flex flex-col gap-1">
332
+ <KpiValue value={87} onClick={() => null} />
333
+ <KpiDescription trend="up" tone="positive">
334
+ +16.3% vs previous period
335
+ </KpiDescription>
302
336
  </CardContent>
303
- </Card>
337
+ </KpiCard>
304
338
  );
305
339
  }
306
340
  ```
307
341
 
308
- `Tile disabled` set `disabled` on `TileProvider` context, which `TileValue` read to disable its
309
- internal button. Replace by setting `opacity-50` + `aria-disabled` on `Card` and
310
- `cursor-not-allowed` on the value element; remove the `onClick` handler.
342
+ `trend` is icon direction only; `tone` is color only an upward change that is bad uses
343
+ `trend="up" tone="negative"`.
344
+
345
+ ### 3g. Duration values
346
+
347
+ ```tsx
348
+ {/* Long: "1h 2min 3s" */}
349
+ <KpiValue value={3723} kind="duration" />
350
+
351
+ {/* Clock hours: "01:38 hr/min" */}
352
+ <KpiValue value={5880} kind="duration" durationStyle="clock" durationScale="hours" />
353
+
354
+ {/* Clock minutes: "05:04 min/sec" */}
355
+ <KpiValue value={304} kind="duration" durationStyle="clock" durationScale="minutes" />
356
+
357
+ {/* Duration + unit label */}
358
+ <KpiValue value={112} kind="duration" unit="avg" />
359
+ ```
360
+
361
+ Do not reimplement `formatSecondsToDuration` in the app — `KpiValue` owns formatting.
311
362
 
312
363
  ---
313
364
 
314
365
  ## 4. Common mistakes
315
366
 
316
- ### Mistake 1 — Passing Tile/TileBody flex props to Card/CardContent
367
+ ### Mistake 1 — Using raw DS `Card` instead of `KpiCard`
317
368
 
318
369
  ```tsx
319
- // ❌ Wrong — tractor FlexProps (px, py, minWidth, flexDirection…) on Card
320
- <Card minWidth="228px" px="xxs" py="xxs" flexDirection="column">
321
- <CardContent alignItems="center" gap="xxxs">…</CardContent>
370
+ // ❌ Wrong — loses disabled cascade and KPI defaults (size="sm", gap)
371
+ <Card size="sm">
372
+ <CardHeader>
373
+ <CardTitle>Answered</CardTitle>
374
+ </CardHeader>
375
+ <CardContent>
376
+ <KpiValue value={123} />
377
+ </CardContent>
322
378
  </Card>
323
379
 
324
- // ✅ Correct — use className with Tailwind utilities
325
- <Card className="min-w-[228px]">
326
- <CardContent className="flex items-center gap-1">…</CardContent>
327
- </Card>
380
+ // ✅ Correct
381
+ <KpiCard>
382
+ <CardHeader>
383
+ <CardTitle>Answered</CardTitle>
384
+ </CardHeader>
385
+ <CardContent>
386
+ <KpiValue value={123} />
387
+ </CardContent>
388
+ </KpiCard>
328
389
  ```
329
390
 
330
- `Tile` extended `FlexProps` from `@aircall/tractor` (spacing tokens like `px`, `py`, `minWidth`).
331
- `Card` and `CardContent` extend `React.ComponentProps<'div'>` — passing tractor tokens results in
332
- unknown HTML attribute warnings at runtime and no styling effect.
391
+ ### Mistake 2 Hand-formatting values or using raw `Skeleton`
392
+
393
+ ```tsx
394
+ // ❌ Wrong
395
+ <span className="text-3xl font-bold">{formatDuration(123)}</span>
396
+ <Skeleton className="h-6 w-18" />
397
+
398
+ // ✅ Correct
399
+ <KpiValue value={123} kind="duration" />
400
+ <KpiValueSkeleton />
401
+ ```
402
+
403
+ ### Mistake 3 — Passing Tile/TileBody flex props to KpiCard / CardContent
404
+
405
+ ```tsx
406
+ // ❌ Wrong — tractor FlexProps on the card
407
+ <KpiCard minWidth="228px" px="xxs" py="xxs">
408
+ <CardContent alignItems="center" gap="xxxs">…</CardContent>
409
+ </KpiCard>
333
410
 
334
- Source: `packages/ds/src/components/card.tsx`
411
+ // ✅ Correct — Tailwind via className
412
+ <KpiCard className="min-w-[228px]">
413
+ <CardContent className="flex flex-col gap-1">…</CardContent>
414
+ </KpiCard>
415
+ ```
335
416
 
336
- ### Mistake 2 — Placing CardAction outside CardHeader
417
+ ### Mistake 4 — Placing CardAction outside CardHeader
337
418
 
338
419
  ```tsx
339
- // ❌ Wrong — action renders in document flow; loses top-right pinning
340
- <Card>
341
- <CardAction>
342
- <Button variant="ghost" size="icon">…</Button>
343
- </CardAction>
420
+ // ❌ Wrong
421
+ <KpiCard>
422
+ <CardAction>…</CardAction>
344
423
  <CardHeader>
345
424
  <CardTitle>Answered</CardTitle>
346
425
  </CardHeader>
347
426
  <CardContent>…</CardContent>
348
- </Card>
427
+ </KpiCard>
349
428
 
350
429
  // ✅ Correct — CardAction must be a child of CardHeader
351
- <Card>
430
+ <KpiCard>
352
431
  <CardHeader>
353
432
  <CardTitle>Answered</CardTitle>
354
- <CardAction>
355
- <Button variant="ghost" size="icon">…</Button>
356
- </CardAction>
433
+ <CardAction>…</CardAction>
357
434
  </CardHeader>
358
435
  <CardContent>…</CardContent>
359
- </Card>
436
+ </KpiCard>
360
437
  ```
361
438
 
362
- `CardHeader` uses `grid auto-rows-min has-data-[slot=card-action]:grid-cols-[1fr_auto]`.
363
- `CardAction` carries `data-slot="card-action"` and sits at `col-start-2 row-span-2`. Outside
364
- `CardHeader` there is no grid parent; the action renders in document flow at full width.
365
-
366
- Source: `packages/ds/src/components/card.tsx`
367
-
368
- ### Mistake 3 — Importing TILE_VALUE enum or TileValue from @dashboard/library after migration
439
+ ### Mistake 5 Keeping TILE_VALUE / TileValue imports after migration
369
440
 
370
441
  ```tsx
371
- // ❌ Wrong — keeping the enum import after TileValue is removed
442
+ // ❌ Wrong
372
443
  import { TILE_VALUE, Tile, TileHeader, TileValue } from '@dashboard/library';
373
-
374
444
  <TileValue type={TILE_VALUE.DURATION} value={123} />
375
445
 
376
- // ✅ Correct — format the value in the consuming component; no TILE_VALUE needed
377
- import { Card, CardContent, CardHeader, CardTitle } from '@aircall/ds';
378
-
379
- // Duration formatting (seconds → "2m 03s") — implement inline or extract to a util
380
- function formatDuration(seconds: number): string {
381
- const m = Math.floor(seconds / 60);
382
- const s = seconds % 60;
383
- return `${m}m ${String(s).padStart(2, '0')}s`;
384
- }
446
+ // ✅ Correct
447
+ import { KpiCard, KpiValue } from '@aircall/blocks';
448
+ import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
385
449
 
386
- <Card className="min-w-36">
450
+ <KpiCard>
387
451
  <CardHeader>
388
- <CardTitle className="text-xs font-medium text-muted-foreground">AHT</CardTitle>
452
+ <CardTitle>AHT</CardTitle>
389
453
  </CardHeader>
390
454
  <CardContent>
391
- <span className="text-2xl font-bold tabular-nums">{formatDuration(123)}</span>
455
+ <KpiValue value={123} kind="duration" />
392
456
  </CardContent>
393
- </Card>
457
+ </KpiCard>
394
458
  ```
395
459
 
396
- `TileValue` with `type={TILE_VALUE.DURATION}` delegated to `TileDuration`, which formatted
397
- seconds via an internal `formatSecondsToDuration` helper. That helper is not exported from
398
- `@dashboard/library`'s public API. Extract the formatting logic to a local utility function.
399
-
400
- Source: `packages/ds/src/components/card.tsx`
401
-
402
- ### Mistake 4 — Using CardTitle for the metric value instead of the label
460
+ ### Mistake 6 Using CardTitle for the metric value instead of the label
403
461
 
404
462
  ```tsx
405
- // ❌ Wrong — CardTitle used for the big number; label lost
406
- <Card>
463
+ // ❌ Wrong — CardTitle used for the big number
464
+ <KpiCard>
407
465
  <CardHeader>
408
466
  <CardTitle>123</CardTitle>
409
467
  </CardHeader>
410
- </Card>
468
+ </KpiCard>
411
469
 
412
- // ✅ Correct — CardTitle holds the label; value sits in CardContent
413
- <Card className="min-w-36">
470
+ // ✅ Correct — CardTitle = label; KpiValue = metric
471
+ <KpiCard>
414
472
  <CardHeader>
415
- <CardTitle className="text-xs font-medium text-muted-foreground">Answered</CardTitle>
473
+ <CardTitle>Answered</CardTitle>
416
474
  </CardHeader>
417
475
  <CardContent>
418
- <span className="text-2xl font-bold tabular-nums">123</span>
476
+ <KpiValue value={123} />
419
477
  </CardContent>
420
- </Card>
478
+ </KpiCard>
421
479
  ```
422
-
423
- In `@dashboard/library`, `TileHeader` rendered the human-readable label ("Answered") and
424
- `TileValue` rendered the metric number. In the DS mapping, `CardTitle` takes the label and
425
- `CardContent` holds the value element. Swapping them produces an inverted visual hierarchy
426
- and breaks assistive-technology reading order.
427
-
428
- Source: `packages/ds/src/components/card.tsx`