@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,874 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useMemo, useState, type CSSProperties} from 'react';
6
+ import * as stylex from '@stylexjs/stylex';
7
+ import {
8
+ VStack,
9
+ HStack,
10
+ StackItem,
11
+ Layout,
12
+ LayoutContent,
13
+ LayoutHeader,
14
+ LayoutPanel,
15
+ } from '@astryxdesign/core/Layout';
16
+ import {useMediaQuery} from '@astryxdesign/core/hooks';
17
+ import {Grid} from '@astryxdesign/core/Grid';
18
+ import {Text, Heading} from '@astryxdesign/core/Text';
19
+ import {Card} from '@astryxdesign/core/Card';
20
+ import {Button} from '@astryxdesign/core/Button';
21
+ import {Icon} from '@astryxdesign/core/Icon';
22
+ import {Divider} from '@astryxdesign/core/Divider';
23
+ import {Avatar} from '@astryxdesign/core/Avatar';
24
+ import {ProgressBar} from '@astryxdesign/core/ProgressBar';
25
+ import {
26
+ SegmentedControl,
27
+ SegmentedControlItem,
28
+ } from '@astryxdesign/core/SegmentedControl';
29
+ import {Timestamp} from '@astryxdesign/core/Timestamp';
30
+ import {
31
+ AreaChart,
32
+ Area,
33
+ LineChart,
34
+ Line,
35
+ XAxis,
36
+ YAxis,
37
+ CartesianGrid,
38
+ Tooltip,
39
+ ResponsiveContainer,
40
+ } from 'recharts';
41
+ import {ArrowDownTrayIcon} from '@heroicons/react/24/outline';
42
+ import {
43
+ ArrowUpIcon,
44
+ ArrowDownIcon,
45
+ ArrowRightIcon,
46
+ StopIcon,
47
+ } from '@heroicons/react/24/solid';
48
+
49
+ // ============= TYPES =============
50
+
51
+ type Period = 'WoW' | 'MoM' | 'QoQ';
52
+ type Rag = 'green' | 'amber' | 'red';
53
+
54
+ interface Kpi {
55
+ key: string;
56
+ label: string;
57
+ value: string;
58
+ unit?: string;
59
+ // Delta percentage per period; sign drives arrow + RAG.
60
+ delta: Record<Period, number>;
61
+ // Whether an increase is good (revenue) or bad (churn, cost).
62
+ higherIsBetter: boolean;
63
+ }
64
+
65
+ interface Okr {
66
+ objective: string;
67
+ owner: string;
68
+ actual: number;
69
+ target: number;
70
+ unit: string;
71
+ }
72
+
73
+ interface TrendSeries {
74
+ key: string;
75
+ title: string;
76
+ unit: string;
77
+ kind: 'area' | 'line';
78
+ color: string;
79
+ // 12 periods of [current, prior] pairs.
80
+ data: {t: number; label: string; current: number; prior: number}[];
81
+ }
82
+
83
+ // ============= RAG COLOR MAPPING =============
84
+
85
+ const RAG_ICON: Record<Rag, 'success' | 'warning' | 'error'> = {
86
+ green: 'success',
87
+ amber: 'warning',
88
+ red: 'error',
89
+ };
90
+
91
+ // A delta becomes RAG by magnitude + whether the direction is favorable.
92
+ function ragFor(deltaPct: number, higherIsBetter: boolean): Rag {
93
+ const favorable = higherIsBetter ? deltaPct >= 0 : deltaPct <= 0;
94
+ const mag = Math.abs(deltaPct);
95
+ if (favorable) {
96
+ return mag >= 1 ? 'green' : 'amber';
97
+ }
98
+ return mag >= 3 ? 'red' : 'amber';
99
+ }
100
+
101
+ // ============= CHART COLORS (design tokens w/ hex fallbacks) =============
102
+
103
+ const COLORS = {
104
+ blue: 'var(--color-data-categorical-blue, #0171E3)',
105
+ green: 'var(--color-data-categorical-green, #0B991F)',
106
+ orange: 'var(--color-data-categorical-orange, #EB6E00)',
107
+ purple: 'var(--color-data-categorical-purple, #6B1EFD)',
108
+ prior: 'var(--color-text-secondary, #737373)',
109
+ };
110
+ const GRID_STROKE = 'var(--color-border, rgba(5, 54, 89, 0.1))';
111
+ const AXIS_TICK = {
112
+ fontSize: 'var(--font-size-sm, 12px)',
113
+ fill: 'var(--color-text-secondary, #4E606F)',
114
+ };
115
+
116
+ // ============= SCORECARD DATA =============
117
+
118
+ const KPIS: Kpi[] = [
119
+ {
120
+ key: 'revenue',
121
+ label: 'Net revenue',
122
+ value: '$4.82M',
123
+ delta: {WoW: 2.4, MoM: 6.1, QoQ: 14.3},
124
+ higherIsBetter: true,
125
+ },
126
+ {
127
+ key: 'arr',
128
+ label: 'ARR',
129
+ value: '$58.1M',
130
+ delta: {WoW: 1.1, MoM: 3.8, QoQ: 11.2},
131
+ higherIsBetter: true,
132
+ },
133
+ {
134
+ key: 'nrr',
135
+ label: 'Net revenue retention',
136
+ value: '112%',
137
+ delta: {WoW: 0.3, MoM: -0.8, QoQ: 2.1},
138
+ higherIsBetter: true,
139
+ },
140
+ {
141
+ key: 'churn',
142
+ label: 'Gross churn',
143
+ value: '1.9%',
144
+ delta: {WoW: 0.4, MoM: 1.6, QoQ: -0.7},
145
+ higherIsBetter: false,
146
+ },
147
+ {
148
+ key: 'cac',
149
+ label: 'CAC payback',
150
+ value: '14.2 mo',
151
+ delta: {WoW: -0.2, MoM: -3.4, QoQ: -8.1},
152
+ higherIsBetter: false,
153
+ },
154
+ {
155
+ key: 'nps',
156
+ label: 'NPS',
157
+ value: '48',
158
+ delta: {WoW: 1.0, MoM: 4.0, QoQ: 6.0},
159
+ higherIsBetter: true,
160
+ },
161
+ ];
162
+
163
+ // The scorecard is one flat grid of 6 tiles: 1 column (small), 2 (medium),
164
+ // 3 (large) — i.e. 1x6, 2x3, 3x2. The tiers are pinned to viewport width
165
+ // rather than derived from tile width via auto-fill, so the breakpoints are
166
+ // explicit. The ranges are non-overlapping, so exactly one ever matches and
167
+ // the result can't depend on StyleX's at-rule ordering or on source order.
168
+ const SCORECARD_MEDIUM = '@media (min-width: 768px) and (max-width: 1199.98px)';
169
+ const SCORECARD_LARGE = '@media (min-width: 1200px)';
170
+
171
+ // Overrides Grid's own grid-template-columns. Grid routes its track template
172
+ // through a CSS var and applies `xstyle` last, so this wins.
173
+ const scorecardStyles = stylex.create({
174
+ grid: {
175
+ gridTemplateColumns: {
176
+ default: '1fr',
177
+ [SCORECARD_MEDIUM]: 'repeat(2, 1fr)',
178
+ [SCORECARD_LARGE]: 'repeat(3, 1fr)',
179
+ },
180
+ },
181
+ });
182
+
183
+ // ============= OKR / GOAL ATTAINMENT =============
184
+
185
+ const OKRS: Okr[] = [
186
+ {
187
+ objective: 'Reach $60M ARR',
188
+ owner: 'Dana Whitfield · CRO',
189
+ actual: 58.1,
190
+ target: 60,
191
+ unit: 'M',
192
+ },
193
+ {
194
+ objective: 'Expand into 3 new enterprise verticals',
195
+ owner: 'Marcus Lin · VP Sales',
196
+ actual: 2,
197
+ target: 3,
198
+ unit: '',
199
+ },
200
+ {
201
+ objective: 'Ship AI copilot GA',
202
+ owner: 'Priya Raman · VP Product',
203
+ actual: 82,
204
+ target: 100,
205
+ unit: '%',
206
+ },
207
+ {
208
+ objective: 'Improve gross margin to 78%',
209
+ owner: 'Tom Okafor · CFO',
210
+ actual: 75.4,
211
+ target: 78,
212
+ unit: '%',
213
+ },
214
+ ];
215
+
216
+ function okrRag(pct: number): Rag {
217
+ if (pct >= 90) {
218
+ return 'green';
219
+ }
220
+ if (pct >= 70) {
221
+ return 'amber';
222
+ }
223
+ return 'red';
224
+ }
225
+
226
+ const RAG_PROGRESS: Record<Rag, 'success' | 'warning' | 'error'> = {
227
+ green: 'success',
228
+ amber: 'warning',
229
+ red: 'error',
230
+ };
231
+
232
+ // ============= TREND DATA (period-over-period) =============
233
+
234
+ const WEEK_LABELS = [
235
+ 'W1',
236
+ 'W2',
237
+ 'W3',
238
+ 'W4',
239
+ 'W5',
240
+ 'W6',
241
+ 'W7',
242
+ 'W8',
243
+ 'W9',
244
+ 'W10',
245
+ 'W11',
246
+ 'W12',
247
+ ];
248
+
249
+ function makeTrend(
250
+ base: number,
251
+ growth: number,
252
+ priorGap: number,
253
+ wobble: number,
254
+ ): {t: number; label: string; current: number; prior: number}[] {
255
+ return WEEK_LABELS.map((label, i) => {
256
+ const drift = base * (1 + (growth * i) / 11);
257
+ const wob = Math.sin(i * 1.3) * base * wobble;
258
+ const current = Math.round(drift + wob);
259
+ const prior = Math.round((drift + wob) * (1 - priorGap));
260
+ return {t: i, label, current, prior};
261
+ });
262
+ }
263
+
264
+ const TRENDS: TrendSeries[] = [
265
+ {
266
+ key: 'revenue',
267
+ title: 'Net revenue',
268
+ unit: 'k',
269
+ kind: 'area',
270
+ color: COLORS.blue,
271
+ data: makeTrend(980, 0.24, 0.12, 0.03),
272
+ },
273
+ {
274
+ key: 'pipeline',
275
+ title: 'Qualified pipeline',
276
+ unit: 'k',
277
+ kind: 'area',
278
+ color: COLORS.green,
279
+ data: makeTrend(3200, 0.31, 0.18, 0.05),
280
+ },
281
+ {
282
+ key: 'activation',
283
+ title: 'Activation rate',
284
+ unit: '%',
285
+ kind: 'line',
286
+ color: COLORS.purple,
287
+ data: makeTrend(42, 0.28, 0.09, 0.04),
288
+ },
289
+ {
290
+ key: 'support',
291
+ title: 'Support CSAT',
292
+ unit: '%',
293
+ kind: 'line',
294
+ color: COLORS.orange,
295
+ data: makeTrend(88, 0.06, 0.03, 0.02),
296
+ },
297
+ ];
298
+
299
+ // ============= AUTO-GENERATED NARRATIVE =============
300
+
301
+ type CalloutStatus = 'success' | 'warning' | 'info';
302
+
303
+ interface Callout {
304
+ status: CalloutStatus;
305
+ title: string;
306
+ detail: string;
307
+ }
308
+
309
+ const NARRATIVE: Record<Period, Callout[]> = {
310
+ WoW: [
311
+ {
312
+ status: 'success',
313
+ title: 'Net revenue up 2.4% week-over-week',
314
+ detail:
315
+ 'Driven by three enterprise renewals closing early in the East region; expansion ARR contributed 60% of the lift.',
316
+ },
317
+ {
318
+ status: 'warning',
319
+ title: 'Gross churn ticked up 0.4 points',
320
+ detail:
321
+ 'Two mid-market logos churned on price; both flagged in QBRs last month. Save-play in motion for the remaining at-risk cohort.',
322
+ },
323
+ {
324
+ status: 'info',
325
+ title: 'CAC payback holding at 14.2 months',
326
+ detail:
327
+ 'Paid acquisition efficiency steady; the shift toward partner-sourced pipeline is starting to show in blended CAC.',
328
+ },
329
+ ],
330
+ MoM: [
331
+ {
332
+ status: 'success',
333
+ title: 'Revenue accelerating: +6.1% month-over-month',
334
+ detail:
335
+ 'Second consecutive month of accelerating growth. New-logo bookings and expansion both above plan; net-new pipeline covers 3.2x of next quarter target.',
336
+ },
337
+ {
338
+ status: 'warning',
339
+ title: 'NRR dipped 0.8 points to 112%',
340
+ detail:
341
+ 'A seasonal downgrade cycle in the SMB segment pulled retention down slightly. Enterprise NRR remains above 120%.',
342
+ },
343
+ {
344
+ status: 'success',
345
+ title: 'CAC payback improved 3.4 points',
346
+ detail:
347
+ 'Sales efficiency gains from the new SDR playbook are compounding; payback now inside the 15-month board target.',
348
+ },
349
+ ],
350
+ QoQ: [
351
+ {
352
+ status: 'success',
353
+ title: 'Strong quarter: revenue +14.3%, ARR +11.2%',
354
+ detail:
355
+ 'Best quarter on record for net-new ARR. Two of three new verticals landed anchor customers; the AI copilot beta is converting at 2x the baseline trial rate.',
356
+ },
357
+ {
358
+ status: 'success',
359
+ title: 'Churn down 0.7 points quarter-over-quarter',
360
+ detail:
361
+ 'Retention initiatives launched in Q1 are paying off; gross churn at a two-year low and NRR up 2.1 points.',
362
+ },
363
+ {
364
+ status: 'warning',
365
+ title: 'Gross margin at 75.4%, short of the 78% goal',
366
+ detail:
367
+ 'Infrastructure cost from the AI copilot GA ramp is the primary drag. Finance projects margin recovery once usage-based pricing lands next quarter.',
368
+ },
369
+ ],
370
+ };
371
+
372
+ const PERIOD_SUBTITLE: Record<Period, string> = {
373
+ WoW: 'Week of Jun 23 – Jun 29, 2026 vs. prior week',
374
+ MoM: 'June 2026 vs. May 2026',
375
+ QoQ: 'Q2 2026 vs. Q1 2026',
376
+ };
377
+
378
+ // ============= SHARED PIECES =============
379
+
380
+ // Icon's `color` prop only accepts semantic names, but the legend swatch must
381
+ // match the exact data-categorical stroke of its chart line — so an inline
382
+ // color is required here (same pattern as the shipped dashboard templates).
383
+ function LegendDot({color, label}: {color: string; label: string}) {
384
+ const dotStyle: CSSProperties = {color};
385
+ return (
386
+ <HStack gap={2} vAlign="center">
387
+ <Icon icon={StopIcon} size="xsm" style={dotStyle} />
388
+ <Text type="supporting" color="secondary">
389
+ {label}
390
+ </Text>
391
+ </HStack>
392
+ );
393
+ }
394
+
395
+ // Up / down for movement, right for flat — the solid arrow set keeps the delta
396
+ // marker as visually weighty as the value it sits next to.
397
+ const DELTA_ICON = {
398
+ up: ArrowUpIcon,
399
+ down: ArrowDownIcon,
400
+ flat: ArrowRightIcon,
401
+ };
402
+
403
+ function DeltaIndicator({deltaPct, rag}: {deltaPct: number; rag: Rag}) {
404
+ const sign = deltaPct > 0 ? '+' : '';
405
+ const direction = deltaPct === 0 ? 'flat' : deltaPct < 0 ? 'down' : 'up';
406
+ return (
407
+ <HStack gap={2} vAlign="center">
408
+ <Text type="body" color="secondary">
409
+ {sign}
410
+ {deltaPct.toFixed(1)}%
411
+ </Text>
412
+ <Icon icon={DELTA_ICON[direction]} size="xsm" color={RAG_ICON[rag]} />
413
+ </HStack>
414
+ );
415
+ }
416
+
417
+ interface TrendTooltipEntry {
418
+ name: string;
419
+ value: number;
420
+ color: string;
421
+ }
422
+
423
+ function makeTrendTooltip(unit: string) {
424
+ return function TrendTooltip({
425
+ active,
426
+ payload,
427
+ label,
428
+ }: {
429
+ active?: boolean;
430
+ payload?: TrendTooltipEntry[];
431
+ label?: number;
432
+ }) {
433
+ if (!active || !payload?.length) {
434
+ return null;
435
+ }
436
+ const wk =
437
+ typeof label === 'number'
438
+ ? (WEEK_LABELS[label] ?? '')
439
+ : String(label ?? '');
440
+ return (
441
+ <Card padding={3}>
442
+ <VStack gap={1}>
443
+ <Text type="supporting" color="secondary">
444
+ {wk}
445
+ </Text>
446
+ {payload.map(entry => (
447
+ <LegendDot
448
+ key={entry.name}
449
+ color={entry.color}
450
+ label={`${entry.name}: ${entry.value.toLocaleString()}${unit}`}
451
+ />
452
+ ))}
453
+ </VStack>
454
+ </Card>
455
+ );
456
+ };
457
+ }
458
+
459
+ // ============= SCORECARD =============
460
+
461
+ function ScorecardTile({kpi, period}: {kpi: Kpi; period: Period}) {
462
+ const deltaPct = kpi.delta[period];
463
+ const rag = ragFor(deltaPct, kpi.higherIsBetter);
464
+ return (
465
+ <Card padding={5}>
466
+ <VStack gap={1}>
467
+ <Text type="label" color="secondary">
468
+ {kpi.label}
469
+ </Text>
470
+ <HStack gap={4} vAlign="center">
471
+ <Heading level={2}>{kpi.value}</Heading>
472
+ <DeltaIndicator deltaPct={deltaPct} rag={rag} />
473
+ </HStack>
474
+ </VStack>
475
+ </Card>
476
+ );
477
+ }
478
+
479
+ // ============= OKR ATTAINMENT =============
480
+
481
+ function OkrRow({okr}: {okr: Okr}) {
482
+ const pct = Math.round((okr.actual / okr.target) * 100);
483
+ const rag = okrRag(pct);
484
+ // `owner` is "Name · Title"; the avatar wants the person, not the title.
485
+ const ownerName = okr.owner.split('·')[0].trim();
486
+ return (
487
+ <VStack gap={2}>
488
+ <HStack hAlign="between" vAlign="center" gap={3}>
489
+ <StackItem size="fill">
490
+ <Heading level={4}>{okr.objective}</Heading>
491
+ </StackItem>
492
+ <Text type="supporting" color="secondary">
493
+ {pct}%
494
+ </Text>
495
+ </HStack>
496
+ <ProgressBar
497
+ value={okr.actual}
498
+ max={okr.target}
499
+ variant={RAG_PROGRESS[rag]}
500
+ label={okr.objective}
501
+ isLabelHidden
502
+ />
503
+ <HStack hAlign="between" vAlign="center" gap={4}>
504
+ <HStack gap={2} vAlign="center">
505
+ <Avatar size="sm" name={ownerName} />
506
+ <Text type="supporting" color="secondary">
507
+ {okr.owner}
508
+ </Text>
509
+ </HStack>
510
+ <Text type="supporting" color="secondary">
511
+ {okr.actual}
512
+ {okr.unit} / {okr.target}
513
+ {okr.unit} target
514
+ </Text>
515
+ </HStack>
516
+ </VStack>
517
+ );
518
+ }
519
+
520
+ // ============= TREND CHART =============
521
+
522
+ function TrendChart({trend}: {trend: TrendSeries}) {
523
+ const gradientId = `grad-${trend.key}`;
524
+ const TrendTooltip = useMemo(
525
+ () => makeTrendTooltip(trend.unit),
526
+ [trend.unit],
527
+ );
528
+ const latest = trend.data[trend.data.length - 1];
529
+ const deltaPct = Math.round(
530
+ ((latest.current - latest.prior) / latest.prior) * 100,
531
+ );
532
+ return (
533
+ <Card padding={5}>
534
+ <VStack gap={6}>
535
+ <HStack hAlign="between" vAlign="center">
536
+ <HStack gap={2}>
537
+ <Heading level={3}>{trend.title}</Heading>
538
+ <Heading level={3} color="secondary">
539
+ {latest.current.toLocaleString()}
540
+ {trend.unit}
541
+ </Heading>
542
+ </HStack>
543
+ <Text type="supporting" color="secondary">
544
+ {deltaPct >= 0 ? '+' : ''}
545
+ {deltaPct}% vs prior
546
+ </Text>
547
+ </HStack>
548
+ <ResponsiveContainer width="100%" height={200}>
549
+ {trend.kind === 'area' ? (
550
+ <AreaChart
551
+ data={trend.data}
552
+ margin={{top: 5, right: 8, left: 0, bottom: 0}}>
553
+ <defs>
554
+ <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
555
+ <stop offset="5%" stopColor={trend.color} stopOpacity={0.3} />
556
+ <stop
557
+ offset="95%"
558
+ stopColor={trend.color}
559
+ stopOpacity={0.04}
560
+ />
561
+ </linearGradient>
562
+ </defs>
563
+ <CartesianGrid horizontal vertical={false} stroke={GRID_STROKE} />
564
+ <XAxis
565
+ dataKey="t"
566
+ type="number"
567
+ domain={[0, 11]}
568
+ ticks={[0, 3, 7, 11]}
569
+ tickFormatter={(v: number) => WEEK_LABELS[v] ?? ''}
570
+ tick={AXIS_TICK}
571
+ axisLine={false}
572
+ tickLine={false}
573
+ />
574
+ <YAxis
575
+ tick={AXIS_TICK}
576
+ axisLine={false}
577
+ tickLine={false}
578
+ width={40}
579
+ />
580
+ <Tooltip
581
+ content={<TrendTooltip />}
582
+ cursor={{stroke: GRID_STROKE}}
583
+ />
584
+ <Area
585
+ type="monotone"
586
+ dataKey="prior"
587
+ name="Prior"
588
+ stroke={COLORS.prior}
589
+ strokeWidth={1}
590
+ strokeDasharray="4 4"
591
+ fill="none"
592
+ dot={false}
593
+ isAnimationActive={false}
594
+ />
595
+ <Area
596
+ type="monotone"
597
+ dataKey="current"
598
+ name="Current"
599
+ stroke={trend.color}
600
+ strokeWidth={2}
601
+ fill={`url(#${gradientId})`}
602
+ dot={false}
603
+ isAnimationActive={false}
604
+ />
605
+ </AreaChart>
606
+ ) : (
607
+ <LineChart
608
+ data={trend.data}
609
+ margin={{top: 5, right: 8, left: 0, bottom: 0}}>
610
+ <CartesianGrid horizontal vertical={false} stroke={GRID_STROKE} />
611
+ <XAxis
612
+ dataKey="t"
613
+ type="number"
614
+ domain={[0, 11]}
615
+ ticks={[0, 3, 7, 11]}
616
+ tickFormatter={(v: number) => WEEK_LABELS[v] ?? ''}
617
+ tick={AXIS_TICK}
618
+ axisLine={false}
619
+ tickLine={false}
620
+ />
621
+ <YAxis
622
+ tick={AXIS_TICK}
623
+ axisLine={false}
624
+ tickLine={false}
625
+ width={40}
626
+ />
627
+ <Tooltip
628
+ content={<TrendTooltip />}
629
+ cursor={{stroke: GRID_STROKE}}
630
+ />
631
+ <Line
632
+ type="monotone"
633
+ dataKey="prior"
634
+ name="Prior"
635
+ stroke={COLORS.prior}
636
+ strokeWidth={1}
637
+ strokeDasharray="4 4"
638
+ dot={false}
639
+ isAnimationActive={false}
640
+ />
641
+ <Line
642
+ type="monotone"
643
+ dataKey="current"
644
+ name="Current"
645
+ stroke={trend.color}
646
+ strokeWidth={2}
647
+ dot={false}
648
+ isAnimationActive={false}
649
+ />
650
+ </LineChart>
651
+ )}
652
+ </ResponsiveContainer>
653
+ <HStack gap={5} vAlign="center">
654
+ <LegendDot color={trend.color} label="Current" />
655
+ <LegendDot color={COLORS.prior} label="Prior" />
656
+ </HStack>
657
+ </VStack>
658
+ </Card>
659
+ );
660
+ }
661
+
662
+ // ============= NARRATIVE =============
663
+
664
+ // Each callout is a discrete, independently-removable observation, so Card is
665
+ // the right container. Card variants are for categorization, not status — the
666
+ // status reads from the leading icon (glyph + semantic color + accessible
667
+ // name), never from the card surface.
668
+ const CALLOUT_ICON: Record<CalloutStatus, 'success' | 'warning' | 'info'> = {
669
+ success: 'success',
670
+ warning: 'warning',
671
+ info: 'info',
672
+ };
673
+
674
+ const CALLOUT_ICON_COLOR: Record<
675
+ CalloutStatus,
676
+ 'success' | 'warning' | 'accent'
677
+ > = {
678
+ success: 'success',
679
+ warning: 'warning',
680
+ info: 'accent',
681
+ };
682
+
683
+ const CALLOUT_LABEL: Record<CalloutStatus, string> = {
684
+ success: 'Positive',
685
+ warning: 'Needs attention',
686
+ info: 'Context',
687
+ };
688
+
689
+ function NarrativeCard({callout}: {callout: Callout}) {
690
+ return (
691
+ <Card padding={5}>
692
+ <HStack gap={3} vAlign="start">
693
+ <Icon
694
+ icon={CALLOUT_ICON[callout.status]}
695
+ color={CALLOUT_ICON_COLOR[callout.status]}
696
+ size="md"
697
+ label={CALLOUT_LABEL[callout.status]}
698
+ />
699
+ <StackItem size="fill">
700
+ <VStack gap={1}>
701
+ <Heading level={4}>{callout.title}</Heading>
702
+ <Text type="body" color="secondary">
703
+ {callout.detail}
704
+ </Text>
705
+ </VStack>
706
+ </StackItem>
707
+ </HStack>
708
+ </Card>
709
+ );
710
+ }
711
+
712
+ function NarrativeBlock({period}: {period: Period}) {
713
+ return (
714
+ <VStack gap={2}>
715
+ {NARRATIVE[period].map(callout => (
716
+ <NarrativeCard key={callout.title} callout={callout} />
717
+ ))}
718
+ </VStack>
719
+ );
720
+ }
721
+
722
+ // ============= SECTION HEADING =============
723
+
724
+ function SectionHeading({title, hint}: {title: string; hint?: string}) {
725
+ return (
726
+ <HStack hAlign="between" vAlign="center" gap={3}>
727
+ <Heading level={2}>{title}</Heading>
728
+ {hint ? (
729
+ <Text type="supporting" color="secondary">
730
+ {hint}
731
+ </Text>
732
+ ) : null}
733
+ </HStack>
734
+ );
735
+ }
736
+
737
+ // ============= PAGE CHROME =============
738
+
739
+ const pageStyles = stylex.create({
740
+ // Deepen the scroll container's bottom gutter past the padding={6} default so
741
+ // the last section doesn't butt against the edge. The matching container var
742
+ // is updated too, since bleed children (Divider, Section) read it to pull
743
+ // themselves back out to the content edge.
744
+ contentBottomPad: {
745
+ paddingBlockEnd: 'var(--spacing-10)',
746
+ '--container-padding-block-end': 'var(--spacing-10)',
747
+ },
748
+ });
749
+
750
+ // ============= MAIN =============
751
+
752
+ export default function ExecutiveReviewPage() {
753
+ const [period, setPeriod] = useState<Period>('WoW');
754
+ // The narrative rail needs ~400px of its own; below that the content column
755
+ // would be too cramped for the 2-up trend grid, so the rail folds inline.
756
+ const isNarrow = useMediaQuery('(max-width: 1024px)');
757
+
758
+ const okrSummary = useMemo(() => {
759
+ const pcts = OKRS.map(o => (o.actual / o.target) * 100);
760
+ const onTrack = pcts.filter(p => p >= 90).length;
761
+ return `${onTrack} of ${OKRS.length} on track`;
762
+ }, []);
763
+
764
+ return (
765
+ <Layout
766
+ height="fill"
767
+ contentWidth={1440}
768
+ header={
769
+ <LayoutHeader padding={6} hasDivider>
770
+ <HStack gap={3} vAlign="center" hAlign="between" wrap="wrap">
771
+ <VStack gap={1}>
772
+ <Heading level={1}>Weekly Business Review</Heading>
773
+ <HStack gap={2} vAlign="center">
774
+ <Text type="body" color="secondary">
775
+ {PERIOD_SUBTITLE[period]}
776
+ </Text>
777
+ <Text type="body" color="secondary">
778
+ · Generated
779
+ </Text>
780
+ <Timestamp
781
+ value="2026-06-30T08:00:00Z"
782
+ format="date"
783
+ type="body"
784
+ color="secondary"
785
+ />
786
+ </HStack>
787
+ </VStack>
788
+ <HStack gap={3}>
789
+ <SegmentedControl
790
+ label="Comparison period"
791
+ value={period}
792
+ onChange={value => setPeriod(value as Period)}>
793
+ <SegmentedControlItem label="WoW" value="WoW" />
794
+ <SegmentedControlItem label="MoM" value="MoM" />
795
+ <SegmentedControlItem label="QoQ" value="QoQ" />
796
+ </SegmentedControl>
797
+ <Button
798
+ label="Export"
799
+ variant="secondary"
800
+ icon={<Icon icon={ArrowDownTrayIcon} size="sm" />}
801
+ />
802
+ </HStack>
803
+ </HStack>
804
+ </LayoutHeader>
805
+ }
806
+ content={
807
+ <LayoutContent padding={6} xstyle={pageStyles.contentBottomPad}>
808
+ <VStack gap={10}>
809
+ {/* Narrative lives in the end rail; it folds in here when narrow. */}
810
+ {isNarrow && (
811
+ <VStack gap={6}>
812
+ <SectionHeading title="What changed & why" hint="AI summary" />
813
+ <NarrativeBlock period={period} />
814
+ </VStack>
815
+ )}
816
+
817
+ {/* Scorecard row */}
818
+ <VStack gap={6}>
819
+ <SectionHeading title="Scorecards" hint="Performance metrics" />
820
+ <Grid gap={2} xstyle={scorecardStyles.grid}>
821
+ {KPIS.map(kpi => (
822
+ <ScorecardTile key={kpi.key} kpi={kpi} period={period} />
823
+ ))}
824
+ </Grid>
825
+ </VStack>
826
+
827
+ {/* OKR attainment */}
828
+ <VStack gap={6}>
829
+ <SectionHeading title="Goal attainment" hint={okrSummary} />
830
+ <Card padding={6}>
831
+ <VStack gap={6}>
832
+ {OKRS.map((okr, i) => (
833
+ <VStack gap={6} key={okr.objective}>
834
+ {i > 0 && <Divider />}
835
+ <OkrRow okr={okr} />
836
+ </VStack>
837
+ ))}
838
+ </VStack>
839
+ </Card>
840
+ </VStack>
841
+
842
+ {/* Trend section: 2x2 grid */}
843
+ <VStack gap={6}>
844
+ <SectionHeading
845
+ title="Trends"
846
+ hint="Current vs. prior period · trailing 12 weeks"
847
+ />
848
+ <Grid columns={{minWidth: 330, repeat: 'fit'}} gap={3}>
849
+ {TRENDS.map(trend => (
850
+ <TrendChart key={trend.key} trend={trend} />
851
+ ))}
852
+ </Grid>
853
+ </VStack>
854
+ </VStack>
855
+ </LayoutContent>
856
+ }
857
+ end={
858
+ isNarrow ? undefined : (
859
+ <LayoutPanel
860
+ width={400}
861
+ padding={6}
862
+ hasDivider
863
+ role="complementary"
864
+ label="What changed and why">
865
+ <VStack gap={6}>
866
+ <SectionHeading title="What changed & why" hint="AI summary" />
867
+ <NarrativeBlock period={period} />
868
+ </VStack>
869
+ </LayoutPanel>
870
+ )
871
+ }
872
+ />
873
+ );
874
+ }