@astryxdesign/cli 0.4.5 → 0.4.6-canary.cea9ecd

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 (72) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/api/component/_adapter.d.mts +22 -5
  3. package/api/component/_adapter.mjs +51 -3
  4. package/api/docs/_adapter.d.mts +37 -8
  5. package/api/docs/_adapter.mjs +70 -41
  6. package/api/docs/detail/detail.d.mts +2 -0
  7. package/api/docs/detail/detail.mjs +12 -10
  8. package/api/docs/detail/section/section.d.mts +2 -0
  9. package/api/docs/detail/section/section.mjs +1 -0
  10. package/api/docs/docs.d.mts +2 -0
  11. package/api/docs/docs.doc.mjs +11 -2
  12. package/api/docs/docs.mjs +2 -1
  13. package/api/docs/docs.type.d.mts +15 -0
  14. package/api/docs/docs.type.mjs +6 -0
  15. package/api/docs/integrationDocs.test.mjs +208 -0
  16. package/api/docs/list/list.d.mts +5 -1
  17. package/api/docs/list/list.mjs +30 -14
  18. package/api/init/run/run.mjs +9 -4
  19. package/api/integration/validate-integration.mjs +2 -1
  20. package/api/integration/validate-integration.type.d.mts +1 -0
  21. package/api/integration/validate-integration.type.mjs +1 -0
  22. package/api/search/search.mjs +26 -15
  23. package/api/upgrade/_adapter.d.mts +2 -2
  24. package/api/upgrade/_adapter.mjs +7 -3
  25. package/api/upgrade/run/run.mjs +1 -1
  26. package/assets/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +245 -0
  27. package/assets/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +139 -13
  28. package/assets/docs/cli-integrations.doc.mjs +41 -3
  29. package/assets/docs/layout.doc.dense.mjs +275 -30
  30. package/assets/docs/layout.doc.mjs +412 -78
  31. package/assets/docs/theme.doc.mjs +3 -3
  32. package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.doc.mjs +15 -0
  33. package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.tsx +47 -0
  34. package/assets/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.doc.mjs +1 -1
  35. package/assets/templates/blocks/components/Breadcrumbs/BreadcrumbsCustomSeparator.tsx +18 -5
  36. package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.doc.mjs +14 -0
  37. package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.tsx +76 -0
  38. package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +914 -0
  39. package/assets/templates/pages/dashboard-cohort-funnel/template.doc.mjs +12 -0
  40. package/assets/templates/pages/dashboard-data/page.tsx +894 -0
  41. package/assets/templates/pages/dashboard-data/template.doc.mjs +13 -0
  42. package/assets/templates/pages/dashboard-executive-summary/page.tsx +874 -0
  43. package/assets/templates/pages/dashboard-executive-summary/template.doc.mjs +13 -0
  44. package/assets/templates/pages/dashboard-project-status/page.tsx +1139 -0
  45. package/assets/templates/pages/dashboard-project-status/template.doc.mjs +13 -0
  46. package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1590 -0
  47. package/assets/templates/pages/dashboard-service-monitoring/template.doc.mjs +12 -0
  48. package/authoring/doctypes/_schema.d.mts +2 -0
  49. package/authoring/doctypes/_schema.mjs +5 -0
  50. package/authoring/doctypes/reference/reference.doc.mjs +14 -0
  51. package/authoring/doctypes/reference/type.ts +12 -0
  52. package/authoring/doctypes/template/type.ts +2 -0
  53. package/authoring/integration/integration.doc.mjs +10 -1
  54. package/authoring/integration/parse.d.mts +1 -0
  55. package/authoring/integration/parse.mjs +1 -0
  56. package/authoring/integration/parse.test.mjs +1 -0
  57. package/authoring/integration/type.ts +5 -0
  58. package/clients/cli/commands/component-ownership.test.mjs +63 -3
  59. package/clients/cli/commands/theme-build.doc.mjs +1 -1
  60. package/foundation/agent-docs/agent-docs.d.mts +19 -3
  61. package/foundation/agent-docs/agent-docs.mjs +30 -11
  62. package/foundation/agent-docs/agent-docs.test.mjs +38 -0
  63. package/foundation/config/project.d.mts +16 -0
  64. package/foundation/config/project.mjs +65 -4
  65. package/foundation/config/project.test.mjs +66 -0
  66. package/foundation/discovery/docs-discovery.d.mts +185 -0
  67. package/foundation/discovery/docs-discovery.mjs +544 -0
  68. package/foundation/discovery/docs-discovery.test.mjs +341 -0
  69. package/foundation/integrations/integrations.d.mts +8 -6
  70. package/foundation/integrations/integrations.mjs +6 -4
  71. package/foundation/integrations/validate-contributions.mjs +30 -2
  72. package/package.json +9 -9
@@ -0,0 +1,894 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * Data Dashboard — a generic user-analytics dashboard.
7
+ *
8
+ * Content-only (root `Layout`); the host supplies the app shell. Modeled after
9
+ * dense metric-monitoring dashboards: a filter bar and grids of small metric
10
+ * cards — each with a sparkline and multi-period deltas (d/d, w/w, m/m, y/y)
11
+ * with red/green direction coloring.
12
+ *
13
+ * filter bar | key metrics | devices trend | demographics | acquisition
14
+ *
15
+ * Every metric is generic and self-contained (no real users, no proprietary
16
+ * data). All figures are deterministic (fixed fixtures, no clocks/random) so
17
+ * previews stay stable.
18
+ */
19
+
20
+ import {useState, type CSSProperties} from 'react';
21
+ import * as stylex from '@stylexjs/stylex';
22
+ import {
23
+ VStack,
24
+ HStack,
25
+ Layout,
26
+ LayoutContent,
27
+ LayoutHeader,
28
+ } from '@astryxdesign/core/Layout';
29
+ import {Grid} from '@astryxdesign/core/Grid';
30
+ import {AspectRatio} from '@astryxdesign/core/AspectRatio';
31
+ import {Text, Heading} from '@astryxdesign/core/Text';
32
+ import {Card} from '@astryxdesign/core/Card';
33
+ import {Button} from '@astryxdesign/core/Button';
34
+ import {Icon} from '@astryxdesign/core/Icon';
35
+ import {Divider} from '@astryxdesign/core/Divider';
36
+ import {Badge} from '@astryxdesign/core/Badge';
37
+ import {ProgressBar} from '@astryxdesign/core/ProgressBar';
38
+ import {Selector} from '@astryxdesign/core/Selector';
39
+ import {Table, proportional, pixel} from '@astryxdesign/core/Table';
40
+ import type {TableColumn} from '@astryxdesign/core/Table';
41
+ import {
42
+ BarChart,
43
+ Bar,
44
+ LineChart,
45
+ Line,
46
+ AreaChart,
47
+ Area,
48
+ XAxis,
49
+ YAxis,
50
+ CartesianGrid,
51
+ Tooltip,
52
+ ResponsiveContainer,
53
+ } from 'recharts';
54
+ import {
55
+ ArrowUpIcon,
56
+ ArrowDownIcon,
57
+ ArrowDownTrayIcon,
58
+ UsersIcon,
59
+ CursorArrowRaysIcon,
60
+ } from '@heroicons/react/24/outline';
61
+ import {StopIcon} from '@heroicons/react/24/solid';
62
+
63
+ // ============= TYPES =============
64
+
65
+ type Segment = 'all' | 'new' | 'returning';
66
+ type DateRange = '7d' | '30d' | '90d';
67
+ type DeviceFilter = 'all' | 'mobile' | 'desktop' | 'tablet';
68
+
69
+ // Period-over-period change, in percent: day, week, month, and year.
70
+ interface Deltas {
71
+ dd: number;
72
+ ww: number;
73
+ mm: number;
74
+ yy: number;
75
+ }
76
+
77
+ // One point of a sparkline, pre-shaped for recharts so the fixtures do the
78
+ // work once at module load instead of on every render of every card.
79
+ interface SparkPoint {
80
+ i: number;
81
+ v: number;
82
+ }
83
+
84
+ interface Metric {
85
+ key: string;
86
+ label: string;
87
+ value: string;
88
+ // Whether a rising value is good (engagement) or bad (bounce rate).
89
+ higherIsBetter: boolean;
90
+ deltas: Deltas;
91
+ spark: SparkPoint[];
92
+ }
93
+
94
+ // ============= COLORS (design tokens w/ hex fallbacks) =============
95
+
96
+ const COLORS = {
97
+ blue: 'var(--color-data-categorical-blue, #0171E3)',
98
+ green: 'var(--color-data-categorical-green, #0B991F)',
99
+ orange: 'var(--color-data-categorical-orange, #EB6E00)',
100
+ purple: 'var(--color-data-categorical-purple, #6B1EFD)',
101
+ teal: 'var(--color-data-categorical-teal, #008E80)',
102
+ neutral: 'var(--color-data-neutral, #8494A3)',
103
+ };
104
+ const GRID_STROKE = 'var(--color-border, rgba(5, 54, 89, 0.1))';
105
+ const AXIS_TICK = {
106
+ fontSize: 'var(--font-size-sm, 12px)',
107
+ fill: 'var(--color-text-secondary, #4E606F)',
108
+ };
109
+
110
+ // ============= DETERMINISTIC SERIES =============
111
+
112
+ // A full quarter. The sparklines and the active-users trend both span this
113
+ // window, so the card-level and chart-level views of the data agree.
114
+ const TREND_DAYS = 90;
115
+
116
+ // Hash-based noise in [0, 1) — deterministic, so previews never shift.
117
+ // Shared by the metric sparklines and the active-users trend below.
118
+ function noise(i: number, seed: number): number {
119
+ const x = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453;
120
+ return x - Math.floor(x);
121
+ }
122
+
123
+ // Build a stable but spiky sparkline series: a trend line, a weekly rhythm, a
124
+ // mean-reverting drift, per-point jitter, and the occasional outlier burst.
125
+ function spark(seed: number, trend: number): SparkPoint[] {
126
+ const out: SparkPoint[] = [];
127
+ let drift = 0;
128
+ for (let i = 0; i < TREND_DAYS; i++) {
129
+ const base = 50 + (trend * i * 24) / TREND_DAYS;
130
+ const weekly = Math.sin((i * Math.PI * 2) / 7 + seed) * 2.5;
131
+ drift = drift * 0.72 + (noise(i, seed) - 0.5) * 7;
132
+ const jitter = (noise(i, seed + 17) - 0.5) * 8;
133
+ const burst =
134
+ noise(i, seed + 91) > 0.9 ? (noise(i, seed + 43) - 0.4) * 20 : 0;
135
+ out.push({i, v: Math.round(base + weekly + drift + jitter + burst)});
136
+ }
137
+ return out;
138
+ }
139
+
140
+ // ============= OVERVIEW METRICS =============
141
+
142
+ const OVERVIEW_METRICS: Metric[] = [
143
+ {
144
+ key: 'active-users',
145
+ label: 'Active users',
146
+ value: '1.28M',
147
+ higherIsBetter: true,
148
+ deltas: {dd: 0.4, ww: 2.1, mm: 6.4, yy: 18.2},
149
+ spark: spark(1, 1.1),
150
+ },
151
+ {
152
+ key: 'new-users',
153
+ label: 'New users',
154
+ value: '184.2K',
155
+ higherIsBetter: true,
156
+ deltas: {dd: 1.2, ww: 3.8, mm: 9.1, yy: 24.6},
157
+ spark: spark(2, 1.4),
158
+ },
159
+ {
160
+ key: 'sessions',
161
+ label: 'Sessions',
162
+ value: '4.62M',
163
+ higherIsBetter: true,
164
+ deltas: {dd: 0.2, ww: 1.4, mm: 5.2, yy: 15.9},
165
+ spark: spark(3, 0.9),
166
+ },
167
+ {
168
+ key: 'avg-session',
169
+ label: 'Avg. session',
170
+ value: '6m 42s',
171
+ higherIsBetter: true,
172
+ deltas: {dd: -0.3, ww: 0.8, mm: 2.1, yy: -4.3},
173
+ spark: spark(4, 0.3),
174
+ },
175
+ {
176
+ key: 'pages-session',
177
+ label: 'Pages / session',
178
+ value: '5.3',
179
+ higherIsBetter: true,
180
+ deltas: {dd: 0.1, ww: -0.6, mm: 1.2, yy: 3.7},
181
+ spark: spark(5, 0.4),
182
+ },
183
+ {
184
+ key: 'bounce',
185
+ label: 'Bounce rate',
186
+ value: '38.6%',
187
+ higherIsBetter: false,
188
+ deltas: {dd: -0.2, ww: -1.1, mm: -2.4, yy: -6.8},
189
+ spark: spark(6, -0.5),
190
+ },
191
+ {
192
+ key: 'dau-mau',
193
+ label: 'DAU / MAU',
194
+ value: '52.1%',
195
+ higherIsBetter: true,
196
+ deltas: {dd: 0.3, ww: 1.0, mm: 3.2, yy: 8.4},
197
+ spark: spark(7, 0.7),
198
+ },
199
+ {
200
+ key: 'conversion',
201
+ label: 'Conversion rate',
202
+ value: '3.84%',
203
+ higherIsBetter: true,
204
+ deltas: {dd: -0.1, ww: 0.9, mm: 2.6, yy: 11.2},
205
+ spark: spark(8, 0.6),
206
+ },
207
+ {
208
+ key: 'activation',
209
+ label: 'Activation rate',
210
+ value: '61.4%',
211
+ higherIsBetter: true,
212
+ deltas: {dd: 0.2, ww: 1.3, mm: 4.1, yy: 12.7},
213
+ spark: spark(9, 0.8),
214
+ },
215
+ {
216
+ key: 'retention-30',
217
+ label: '30-day retention',
218
+ value: '27.4%',
219
+ higherIsBetter: true,
220
+ deltas: {dd: 0.1, ww: -0.4, mm: 1.8, yy: 5.3},
221
+ spark: spark(10, -0.3),
222
+ },
223
+ {
224
+ key: 'churn',
225
+ label: 'Churn rate',
226
+ value: '4.7%',
227
+ higherIsBetter: false,
228
+ deltas: {dd: -0.1, ww: -0.5, mm: -1.3, yy: -3.9},
229
+ spark: spark(11, -0.6),
230
+ },
231
+ {
232
+ key: 'load-time',
233
+ label: 'Avg. load time',
234
+ value: '1.4s',
235
+ higherIsBetter: false,
236
+ deltas: {dd: 0.2, ww: 0.6, mm: -2.2, yy: -8.1},
237
+ spark: spark(12, 0.35),
238
+ },
239
+ ];
240
+
241
+ // Active-users trend: the desktop/mobile/tablet split across TREND_DAYS.
242
+ // Weekends swing traffic from desktop to mobile, and each day carries its own
243
+ // jitter plus the occasional spike, so the series reads like real telemetry.
244
+ const ACTIVE_TREND = (() => {
245
+ const out: {
246
+ t: number;
247
+ label: string;
248
+ desktop: number;
249
+ mobile: number;
250
+ tablet: number;
251
+ }[] = [];
252
+ for (let i = 0; i < TREND_DAYS; i++) {
253
+ const weekend = i % 7 === 5 || i % 7 === 6;
254
+ const spike = noise(i, 61) > 0.88 ? 1 : 0;
255
+ // Slopes are expressed as total drift across the window, so the shape
256
+ // holds if TREND_DAYS changes.
257
+ out.push({
258
+ t: i,
259
+ label: `Day ${i + 1}`,
260
+ desktop: Math.round(
261
+ 520 +
262
+ (i * 90) / TREND_DAYS +
263
+ (weekend ? -110 : 20) +
264
+ (noise(i, 11) - 0.5) * 80,
265
+ ),
266
+ mobile: Math.round(
267
+ 680 +
268
+ (i * 150) / TREND_DAYS +
269
+ (weekend ? 85 : -15) +
270
+ (noise(i, 23) - 0.5) * 125 +
271
+ spike * 130,
272
+ ),
273
+ tablet: Math.round(
274
+ 140 + (weekend ? 24 : -5) + (noise(i, 37) - 0.5) * 40 + spike * 18,
275
+ ),
276
+ });
277
+ }
278
+ return out;
279
+ })();
280
+
281
+ // Four evenly spaced labels (roughly month boundaries), derived so the axis
282
+ // stays readable if TREND_DAYS changes.
283
+ const ACTIVE_TICK_COUNT = 4;
284
+ const ACTIVE_TICKS = Array.from({length: ACTIVE_TICK_COUNT}, (_, i) =>
285
+ Math.round((i * (TREND_DAYS - 1)) / (ACTIVE_TICK_COUNT - 1)),
286
+ );
287
+
288
+ // ============= DEMOGRAPHICS =============
289
+
290
+ interface BreakdownSegment {
291
+ label: string;
292
+ value: number;
293
+ color: string;
294
+ }
295
+
296
+ const AGE_DATA: BreakdownSegment[] = [
297
+ {label: '18–24', value: 26, color: COLORS.blue},
298
+ {label: '25–34', value: 34, color: COLORS.teal},
299
+ {label: '35–44', value: 21, color: COLORS.green},
300
+ {label: '45–54', value: 12, color: COLORS.orange},
301
+ {label: '55+', value: 7, color: COLORS.neutral},
302
+ ];
303
+
304
+ const GENDER_DATA: BreakdownSegment[] = [
305
+ {label: 'Female', value: 48, color: COLORS.purple},
306
+ {label: 'Male', value: 46, color: COLORS.blue},
307
+ {label: 'Other / undisclosed', value: 6, color: COLORS.neutral},
308
+ ];
309
+
310
+ const DEVICE_DATA: BreakdownSegment[] = [
311
+ {label: 'Mobile', value: 58, color: COLORS.blue},
312
+ {label: 'Desktop', value: 34, color: COLORS.teal},
313
+ {label: 'Tablet', value: 8, color: COLORS.orange},
314
+ ];
315
+
316
+ interface AcqRow extends Record<string, unknown> {
317
+ id: string;
318
+ channel: string;
319
+ users: number;
320
+ share: number;
321
+ delta: number;
322
+ }
323
+
324
+ const ACQUISITION: AcqRow[] = [
325
+ {id: 'a1', channel: 'Organic search', users: 486200, share: 38, delta: 4.2},
326
+ {id: 'a2', channel: 'Direct', users: 295400, share: 23, delta: 1.1},
327
+ {id: 'a3', channel: 'Social', users: 217800, share: 17, delta: 8.6},
328
+ {id: 'a4', channel: 'Referral', users: 141900, share: 11, delta: -2.3},
329
+ {id: 'a5', channel: 'Email', users: 89200, share: 7, delta: 3.4},
330
+ {id: 'a6', channel: 'Paid ads', users: 51300, share: 4, delta: -1.8},
331
+ ];
332
+
333
+ const ACQ_MAX = Math.max(...ACQUISITION.map(a => a.users));
334
+
335
+ // ============= FILTER OPTIONS =============
336
+
337
+ const RANGE_OPTIONS = [
338
+ {value: '7d', label: 'Last 7 days'},
339
+ {value: '30d', label: 'Last 30 days'},
340
+ {value: '90d', label: 'Last 90 days'},
341
+ ];
342
+ const SEGMENT_OPTIONS = [
343
+ {value: 'all', label: 'All users'},
344
+ {value: 'new', label: 'New users'},
345
+ {value: 'returning', label: 'Returning users'},
346
+ ];
347
+ const DEVICE_OPTIONS = [
348
+ {value: 'all', label: 'All devices'},
349
+ {value: 'mobile', label: 'Mobile'},
350
+ {value: 'desktop', label: 'Desktop'},
351
+ {value: 'tablet', label: 'Tablet'},
352
+ ];
353
+
354
+ // ============= SHARED PIECES =============
355
+
356
+ // Icon's `color` prop only takes semantic names, but the legend swatch must
357
+ // match the exact chart series color — inline color required (same pattern as
358
+ // the shipped dashboard templates).
359
+ function LegendDot({color, label}: {color: string; label: string}) {
360
+ const dotStyle: CSSProperties = {color};
361
+ return (
362
+ <HStack gap={2} vAlign="center">
363
+ <Icon icon={StopIcon} size="xsm" style={dotStyle} />
364
+ <Text type="supporting" color="secondary">
365
+ {label}
366
+ </Text>
367
+ </HStack>
368
+ );
369
+ }
370
+
371
+ // Locale is pinned: this is a client component, and letting the server and the
372
+ // browser pick different default locales produces a hydration mismatch (and
373
+ // breaks the deterministic previews this template promises).
374
+ function formatNumber(n: number): string {
375
+ return n.toLocaleString('en-US');
376
+ }
377
+
378
+ // ============= METRIC MINI-CARD (with sparkline + 4 deltas) =============
379
+
380
+ // Direction is carried by the arrow next to the metric value; the delta
381
+ // figures themselves stay neutral so a card with four of them doesn't turn
382
+ // into a wall of red and green.
383
+ function DeltaValue({label, value}: {label: string; value: number}) {
384
+ const flat = value === 0;
385
+ const sign = value > 0 ? '+' : '';
386
+ return (
387
+ <Text type="body" weight="bold" color="primary" hasTabularNumbers>
388
+ {flat ? '—' : `${sign}${value.toFixed(1)}%`}{' '}
389
+ <Text type="inherit" weight="normal" color="secondary">
390
+ {label}
391
+ </Text>
392
+ </Text>
393
+ );
394
+ }
395
+
396
+ // The sparkline restates the trend the value and arrow already convey, so it
397
+ // is decorative: hidden from assistive tech rather than announced as an
398
+ // unlabelled chart.
399
+ function MiniSparkline({data, color}: {data: SparkPoint[]; color: string}) {
400
+ return (
401
+ <div aria-hidden="true">
402
+ <ResponsiveContainer width="100%" height={48}>
403
+ <LineChart data={data} margin={{top: 4, right: 0, left: 0, bottom: 0}}>
404
+ <Line
405
+ type="linear"
406
+ dataKey="v"
407
+ stroke={color}
408
+ strokeWidth={1.25}
409
+ strokeLinecap="butt"
410
+ strokeLinejoin="miter"
411
+ dot={false}
412
+ isAnimationActive={false}
413
+ />
414
+ </LineChart>
415
+ </ResponsiveContainer>
416
+ </div>
417
+ );
418
+ }
419
+
420
+ // Deltas sit in a 2x2 grid rather than a wrapping row so the four figures line
421
+ // up across every card regardless of how wide the number renders.
422
+ function MetricCard({metric}: {metric: Metric}) {
423
+ const trendUp = metric.deltas.ww >= 0;
424
+ const favorable = metric.higherIsBetter ? trendUp : !trendUp;
425
+ const sparkColor = favorable ? COLORS.green : COLORS.orange;
426
+ return (
427
+ <Card padding={4}>
428
+ <VStack gap={4}>
429
+ <VStack gap={1}>
430
+ <Text type="label" color="secondary">
431
+ {metric.label}
432
+ </Text>
433
+ <HStack gap={2} vAlign="center">
434
+ {/* Sized as the card's hero number, but demoted in the document
435
+ outline: these sit under the section's h2, not beside the
436
+ page's h1. */}
437
+ <Heading level={1} accessibilityLevel={3}>
438
+ {metric.value}
439
+ </Heading>
440
+ <Icon
441
+ icon={trendUp ? ArrowUpIcon : ArrowDownIcon}
442
+ size="sm"
443
+ color={favorable ? 'success' : 'error'}
444
+ />
445
+ </HStack>
446
+ </VStack>
447
+ <MiniSparkline data={metric.spark} color={sparkColor} />
448
+ <Grid columns={2} gap={2}>
449
+ <DeltaValue label="d/d" value={metric.deltas.dd} />
450
+ <DeltaValue label="w/w" value={metric.deltas.ww} />
451
+ <DeltaValue label="m/m" value={metric.deltas.mm} />
452
+ <DeltaValue label="y/y" value={metric.deltas.yy} />
453
+ </Grid>
454
+ </VStack>
455
+ </Card>
456
+ );
457
+ }
458
+
459
+ // Grid's `columns` prop carries a single minWidth, so the roomier track floor
460
+ // for wide viewports rides in as an xstyle override: Grid routes its track
461
+ // template through a CSS var and applies `xstyle` last, so this wins. Below
462
+ // the breakpoint the value is left unset and the `columns` prop stands.
463
+ const METRICS_WIDE = '@media (min-width: 900px)';
464
+
465
+ const metricsGridStyles = stylex.create({
466
+ grid: {
467
+ gridTemplateColumns: {
468
+ default: null,
469
+ [METRICS_WIDE]: 'repeat(auto-fit, minmax(320px, 1fr))',
470
+ },
471
+ },
472
+ });
473
+
474
+ // ============= ACTIVE USERS TREND =============
475
+
476
+ interface TrendTooltipEntry {
477
+ name: string;
478
+ value: number;
479
+ color: string;
480
+ }
481
+
482
+ function ActiveTrendTooltip({
483
+ active,
484
+ payload,
485
+ label,
486
+ }: {
487
+ active?: boolean;
488
+ payload?: TrendTooltipEntry[];
489
+ label?: number;
490
+ }) {
491
+ if (!active || !payload?.length) {
492
+ return null;
493
+ }
494
+ const day =
495
+ typeof label === 'number' ? (ACTIVE_TREND[label]?.label ?? '') : '';
496
+ return (
497
+ <Card padding={3}>
498
+ <VStack gap={1}>
499
+ <Text type="supporting" color="secondary">
500
+ {day}
501
+ </Text>
502
+ {payload.map(entry => (
503
+ <LegendDot
504
+ key={entry.name}
505
+ color={entry.color}
506
+ label={`${entry.name}: ${formatNumber(entry.value)}K`}
507
+ />
508
+ ))}
509
+ </VStack>
510
+ </Card>
511
+ );
512
+ }
513
+
514
+ function ActiveUsersTrend() {
515
+ return (
516
+ <VStack gap={3}>
517
+ <ResponsiveContainer width="100%" height={280}>
518
+ <AreaChart
519
+ data={ACTIVE_TREND}
520
+ margin={{top: 5, right: 12, left: 0, bottom: 5}}>
521
+ <defs>
522
+ <linearGradient id="au-mobile" x1="0" y1="0" x2="0" y2="1">
523
+ <stop offset="5%" stopColor={COLORS.blue} stopOpacity={0.3} />
524
+ <stop offset="95%" stopColor={COLORS.blue} stopOpacity={0.04} />
525
+ </linearGradient>
526
+ <linearGradient id="au-desktop" x1="0" y1="0" x2="0" y2="1">
527
+ <stop offset="5%" stopColor={COLORS.teal} stopOpacity={0.3} />
528
+ <stop offset="95%" stopColor={COLORS.teal} stopOpacity={0.04} />
529
+ </linearGradient>
530
+ </defs>
531
+ <CartesianGrid horizontal vertical={false} stroke={GRID_STROKE} />
532
+ <XAxis
533
+ dataKey="t"
534
+ type="number"
535
+ domain={[0, TREND_DAYS - 1]}
536
+ ticks={ACTIVE_TICKS}
537
+ tickFormatter={(v: number) => ACTIVE_TREND[v]?.label ?? ''}
538
+ tick={AXIS_TICK}
539
+ axisLine={false}
540
+ tickLine={false}
541
+ />
542
+ <YAxis
543
+ tick={AXIS_TICK}
544
+ axisLine={false}
545
+ tickLine={false}
546
+ width={40}
547
+ unit="K"
548
+ />
549
+ <Tooltip
550
+ content={<ActiveTrendTooltip />}
551
+ cursor={{stroke: GRID_STROKE}}
552
+ />
553
+ <Area
554
+ type="linear"
555
+ dataKey="mobile"
556
+ name="Mobile"
557
+ stroke={COLORS.blue}
558
+ strokeWidth={1.5}
559
+ strokeLinejoin="miter"
560
+ fill="url(#au-mobile)"
561
+ dot={false}
562
+ isAnimationActive={false}
563
+ />
564
+ <Area
565
+ type="linear"
566
+ dataKey="desktop"
567
+ name="Desktop"
568
+ stroke={COLORS.teal}
569
+ strokeWidth={1.5}
570
+ strokeLinejoin="miter"
571
+ fill="url(#au-desktop)"
572
+ dot={false}
573
+ isAnimationActive={false}
574
+ />
575
+ <Area
576
+ type="linear"
577
+ dataKey="tablet"
578
+ name="Tablet"
579
+ stroke={COLORS.orange}
580
+ strokeWidth={1.5}
581
+ strokeLinejoin="miter"
582
+ fill="none"
583
+ dot={false}
584
+ isAnimationActive={false}
585
+ />
586
+ </AreaChart>
587
+ </ResponsiveContainer>
588
+ <HStack gap={5} vAlign="center" wrap="wrap">
589
+ <LegendDot color={COLORS.blue} label="Mobile" />
590
+ <LegendDot color={COLORS.teal} label="Desktop" />
591
+ <LegendDot color={COLORS.orange} label="Tablet" />
592
+ </HStack>
593
+ </VStack>
594
+ );
595
+ }
596
+
597
+ // ============= BREAKDOWN (horizontal stacked bar) =============
598
+
599
+ function BreakdownCard({
600
+ title,
601
+ data,
602
+ }: {
603
+ title: string;
604
+ data: BreakdownSegment[];
605
+ }) {
606
+ const total = data.reduce((s, d) => s + d.value, 0);
607
+ const chartData = [Object.fromEntries(data.map(d => [d.label, d.value]))];
608
+ return (
609
+ <Card>
610
+ <VStack gap={4}>
611
+ {/* Card-title sizing, but h3 in the outline — these sit under the
612
+ "Audience demographics" h2, so level 4 would skip a rank. */}
613
+ <Heading level={4} accessibilityLevel={3}>
614
+ {title}
615
+ </Heading>
616
+ <ResponsiveContainer width="100%" height={24}>
617
+ <BarChart
618
+ data={chartData}
619
+ layout="vertical"
620
+ margin={{top: 0, right: 0, bottom: 0, left: 0}}
621
+ barCategoryGap={0}>
622
+ <XAxis type="number" hide domain={[0, total]} />
623
+ <YAxis type="category" hide />
624
+ {data.map((d, i) => (
625
+ <Bar
626
+ key={d.label}
627
+ dataKey={d.label}
628
+ stackId="s"
629
+ fill={d.color}
630
+ isAnimationActive={false}
631
+ radius={
632
+ i === 0
633
+ ? [4, 0, 0, 4]
634
+ : i === data.length - 1
635
+ ? [0, 4, 4, 0]
636
+ : [0, 0, 0, 0]
637
+ }
638
+ />
639
+ ))}
640
+ </BarChart>
641
+ </ResponsiveContainer>
642
+ <VStack gap={2}>
643
+ {data.map(d => (
644
+ <HStack key={d.label} hAlign="between" vAlign="center">
645
+ <LegendDot color={d.color} label={d.label} />
646
+ <Text type="supporting" weight="semibold" hasTabularNumbers>
647
+ {d.value}%
648
+ </Text>
649
+ </HStack>
650
+ ))}
651
+ </VStack>
652
+ </VStack>
653
+ </Card>
654
+ );
655
+ }
656
+
657
+ // ============= TABLES =============
658
+
659
+ function DeltaBadge({value}: {value: number}) {
660
+ const flat = value === 0;
661
+ return (
662
+ <Badge
663
+ variant={flat ? 'neutral' : value > 0 ? 'green' : 'red'}
664
+ label={`${value > 0 ? '+' : ''}${value.toFixed(1)}%`}
665
+ />
666
+ );
667
+ }
668
+
669
+ const acquisitionColumns: TableColumn<AcqRow>[] = [
670
+ {
671
+ key: 'channel',
672
+ header: 'Channel',
673
+ width: pixel(160),
674
+ renderCell: (item: AcqRow) => (
675
+ <Text type="body" weight="semibold">
676
+ {item.channel}
677
+ </Text>
678
+ ),
679
+ },
680
+ {
681
+ key: 'users',
682
+ header: 'Users',
683
+ width: proportional(1),
684
+ renderCell: (item: AcqRow) => (
685
+ <VStack gap={1}>
686
+ <ProgressBar
687
+ value={item.users}
688
+ max={ACQ_MAX}
689
+ label={`${item.channel} users`}
690
+ isLabelHidden
691
+ />
692
+ <Text type="supporting" color="secondary" hasTabularNumbers>
693
+ {formatNumber(item.users)}
694
+ </Text>
695
+ </VStack>
696
+ ),
697
+ },
698
+ {
699
+ key: 'share',
700
+ header: 'Share',
701
+ width: pixel(90),
702
+ renderCell: (item: AcqRow) => (
703
+ <Text type="body" hasTabularNumbers>
704
+ {item.share}%
705
+ </Text>
706
+ ),
707
+ },
708
+ {
709
+ key: 'delta',
710
+ header: 'w/w',
711
+ width: pixel(100),
712
+ renderCell: (item: AcqRow) => <DeltaBadge value={item.delta} />,
713
+ },
714
+ ];
715
+
716
+ // ============= SECTION HEADING =============
717
+
718
+ // AspectRatio takes its width from the container, and as a flex child its own
719
+ // `width: 100%` would resolve against the whole header row. Pinning a width
720
+ // here gives it the definite box it needs and makes the tile a 32px square.
721
+ const sectionIconStyles = stylex.create({
722
+ tile: {
723
+ width: 'var(--spacing-8)',
724
+ backgroundColor: 'var(--color-background-muted)',
725
+ borderRadius: 'var(--radius-element)',
726
+ },
727
+ });
728
+
729
+ function SectionHeading({
730
+ title,
731
+ hint,
732
+ icon,
733
+ }: {
734
+ title: string;
735
+ hint?: string;
736
+ icon?: typeof UsersIcon;
737
+ }) {
738
+ return (
739
+ <HStack hAlign="between" vAlign="center" gap={3}>
740
+ <HStack gap={3} vAlign="center">
741
+ {icon ? (
742
+ <AspectRatio ratio={1} fit="center" xstyle={sectionIconStyles.tile}>
743
+ <Icon icon={icon} size="sm" color="secondary" />
744
+ </AspectRatio>
745
+ ) : null}
746
+ <Heading level={2}>{title}</Heading>
747
+ </HStack>
748
+ {hint ? (
749
+ <Text type="supporting" color="secondary">
750
+ {hint}
751
+ </Text>
752
+ ) : null}
753
+ </HStack>
754
+ );
755
+ }
756
+
757
+ // ============= MAIN =============
758
+
759
+ // The filters are presentational: the fixtures are a fixed snapshot, so the
760
+ // controls annotate the view rather than re-slicing the data. Swapping the
761
+ // fixtures for a query is the seam a consumer replaces.
762
+ function labelFor(
763
+ options: readonly {value: string; label: string}[],
764
+ value: string,
765
+ ): string {
766
+ return options.find(o => o.value === value)?.label ?? '';
767
+ }
768
+
769
+ export default function DataDashboardPage() {
770
+ // Matches TREND_DAYS — the fixtures render a full quarter.
771
+ const [range, setRange] = useState<DateRange>('90d');
772
+ const [segment, setSegment] = useState<Segment>('all');
773
+ const [device, setDevice] = useState<DeviceFilter>('all');
774
+
775
+ const summary = [
776
+ labelFor(SEGMENT_OPTIONS, segment),
777
+ labelFor(DEVICE_OPTIONS, device),
778
+ labelFor(RANGE_OPTIONS, range),
779
+ ].join(' · ');
780
+
781
+ return (
782
+ <Layout
783
+ height="fill"
784
+ contentWidth={1440}
785
+ header={
786
+ <LayoutHeader hasDivider>
787
+ <HStack gap={3} vAlign="center" hAlign="between" wrap="wrap">
788
+ <VStack gap={0}>
789
+ <Heading level={1}>Data Dashboard</Heading>
790
+ <Text type="supporting" color="secondary">
791
+ User analytics overview · {summary}
792
+ </Text>
793
+ </VStack>
794
+ <HStack gap={1}>
795
+ <Selector
796
+ label="Date range"
797
+ isLabelHidden
798
+ options={RANGE_OPTIONS}
799
+ value={range}
800
+ onChange={value => setRange(value as DateRange)}
801
+ />
802
+ <Selector
803
+ label="Segment"
804
+ isLabelHidden
805
+ options={SEGMENT_OPTIONS}
806
+ value={segment}
807
+ onChange={value => setSegment(value as Segment)}
808
+ />
809
+ <Selector
810
+ label="Device"
811
+ isLabelHidden
812
+ options={DEVICE_OPTIONS}
813
+ value={device}
814
+ onChange={value => setDevice(value as DeviceFilter)}
815
+ />
816
+ <Button
817
+ label="Export"
818
+ variant="secondary"
819
+ icon={<Icon icon={ArrowDownTrayIcon} size="sm" />}
820
+ />
821
+ </HStack>
822
+ </HStack>
823
+ </LayoutHeader>
824
+ }
825
+ content={
826
+ <LayoutContent padding={6}>
827
+ <VStack gap={10}>
828
+ <VStack gap={6}>
829
+ <SectionHeading
830
+ title="Key metrics"
831
+ hint="Change vs. prior day, week, month, and year"
832
+ icon={UsersIcon}
833
+ />
834
+ <Grid
835
+ columns={{minWidth: 240, repeat: 'fit'}}
836
+ gap={3}
837
+ xstyle={metricsGridStyles.grid}>
838
+ {OVERVIEW_METRICS.map(metric => (
839
+ <MetricCard key={metric.key} metric={metric} />
840
+ ))}
841
+ </Grid>
842
+ </VStack>
843
+
844
+ <VStack gap={6}>
845
+ <SectionHeading
846
+ title="Devices"
847
+ hint="Daily active users (thousands)"
848
+ icon={CursorArrowRaysIcon}
849
+ />
850
+ <Card padding={6}>
851
+ <VStack gap={6}>
852
+ <Text type="label">Daily active users by device type</Text>
853
+ <ActiveUsersTrend />
854
+ </VStack>
855
+ </Card>
856
+ </VStack>
857
+
858
+ <Divider />
859
+
860
+ <VStack gap={6}>
861
+ <SectionHeading
862
+ title="Audience demographics"
863
+ hint="Share of active users"
864
+ icon={UsersIcon}
865
+ />
866
+ <Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
867
+ <BreakdownCard title="Age" data={AGE_DATA} />
868
+ <BreakdownCard title="Gender" data={GENDER_DATA} />
869
+ <BreakdownCard title="Device" data={DEVICE_DATA} />
870
+ </Grid>
871
+ </VStack>
872
+
873
+ <VStack gap={6}>
874
+ <SectionHeading
875
+ title="Acquisition channels"
876
+ hint="How users find the product"
877
+ />
878
+ <Card>
879
+ <Table<AcqRow>
880
+ data={ACQUISITION}
881
+ columns={acquisitionColumns}
882
+ idKey="id"
883
+ density="balanced"
884
+ dividers="rows"
885
+ hasHover
886
+ />
887
+ </Card>
888
+ </VStack>
889
+ </VStack>
890
+ </LayoutContent>
891
+ }
892
+ />
893
+ );
894
+ }