@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,1139 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * Project Status Dashboard — a program / launch status tracker.
7
+ *
8
+ * Content-only (root `Layout`); the host supplies the app shell. Widgets stack
9
+ * down a content column capped at 1440px:
10
+ *
11
+ * page header (title, program status, phase filter)
12
+ * | milestone Gantt with a "today" marker
13
+ * | progress donut + scope burndown + blockers & risks, an equal-width
14
+ * three-up that steps down to two columns, then one
15
+ * | workstream table
16
+ *
17
+ * The release-phase control (All / Build / Beta / GA) filters milestones,
18
+ * workstreams and risks, each of which falls back to an empty state. Donut
19
+ * arcs are weighted by story points rather than task count. All data is
20
+ * deterministic (fixed fixtures, no clocks/random) so previews stay stable.
21
+ */
22
+
23
+ import {useMemo, useState, type CSSProperties} from 'react';
24
+ import * as stylex from '@stylexjs/stylex';
25
+ import {
26
+ VStack,
27
+ HStack,
28
+ Layout,
29
+ LayoutContent,
30
+ LayoutHeader,
31
+ } from '@astryxdesign/core/Layout';
32
+ import {Text, Heading} from '@astryxdesign/core/Text';
33
+ import {Card} from '@astryxdesign/core/Card';
34
+ import {Grid} from '@astryxdesign/core/Grid';
35
+ import {Section} from '@astryxdesign/core/Section';
36
+ import {Button} from '@astryxdesign/core/Button';
37
+ import {Icon} from '@astryxdesign/core/Icon';
38
+ import type {IconType} from '@astryxdesign/core/Icon';
39
+ import {Badge} from '@astryxdesign/core/Badge';
40
+ import {ProgressBar} from '@astryxdesign/core/ProgressBar';
41
+ import {Avatar} from '@astryxdesign/core/Avatar';
42
+ import {List, ListItem} from '@astryxdesign/core/List';
43
+ import {useHoverCard} from '@astryxdesign/core/HoverCard';
44
+ import {
45
+ SegmentedControl,
46
+ SegmentedControlItem,
47
+ } from '@astryxdesign/core/SegmentedControl';
48
+ import {Table, proportional} from '@astryxdesign/core/Table';
49
+ import type {TableColumn} from '@astryxdesign/core/Table';
50
+ import {Timestamp} from '@astryxdesign/core/Timestamp';
51
+ import {EmptyState} from '@astryxdesign/core/EmptyState';
52
+ import {
53
+ BarChart,
54
+ Bar,
55
+ Cell,
56
+ PieChart,
57
+ Pie,
58
+ ReferenceLine,
59
+ LineChart,
60
+ Line,
61
+ XAxis,
62
+ YAxis,
63
+ CartesianGrid,
64
+ Tooltip,
65
+ ResponsiveContainer,
66
+ } from 'recharts';
67
+ import {
68
+ CheckCircleIcon,
69
+ ExclamationTriangleIcon,
70
+ ArrowDownTrayIcon,
71
+ FlagIcon,
72
+ } from '@heroicons/react/24/outline';
73
+ import {StopIcon} from '@heroicons/react/24/solid';
74
+
75
+ // ============= TYPES =============
76
+
77
+ type Rag = 'onTrack' | 'atRisk' | 'blocked';
78
+ type Phase = 'all' | 'build' | 'beta' | 'ga';
79
+ type ReleasePhase = Exclude<Phase, 'all'>;
80
+
81
+ interface Milestone {
82
+ id: string;
83
+ name: string;
84
+ phase: ReleasePhase;
85
+ // Day offsets on the shared TIMELINE_DAYS scale (project day 0 = kickoff).
86
+ start: number;
87
+ end: number;
88
+ status: Rag;
89
+ dateLabel: string;
90
+ }
91
+
92
+ interface Workstream extends Record<string, unknown> {
93
+ id: string;
94
+ name: string;
95
+ owner: string;
96
+ phase: ReleasePhase;
97
+ percent: number;
98
+ due: string;
99
+ status: Rag;
100
+ }
101
+
102
+ interface Risk {
103
+ id: string;
104
+ title: string;
105
+ workstream: string;
106
+ phase: ReleasePhase;
107
+ severity: Rag;
108
+ owner: string;
109
+ detail: string;
110
+ }
111
+
112
+ interface TaskStatusSegment {
113
+ key: string;
114
+ label: string;
115
+ tasks: number;
116
+ // Effort, not head count: `points` is what sizes the donut arc.
117
+ points: number;
118
+ color: string;
119
+ }
120
+
121
+ // ============= LABEL & TONE MAPPING =============
122
+
123
+ const PHASE_LABEL: Record<ReleasePhase, string> = {
124
+ build: 'Build',
125
+ beta: 'Beta',
126
+ ga: 'GA',
127
+ };
128
+
129
+ const RAG_LABEL: Record<Rag, string> = {
130
+ onTrack: 'On track',
131
+ atRisk: 'At risk',
132
+ blocked: 'Blocked',
133
+ };
134
+
135
+ const RAG_BADGE: Record<Rag, 'green' | 'yellow' | 'red'> = {
136
+ onTrack: 'green',
137
+ atRisk: 'yellow',
138
+ blocked: 'red',
139
+ };
140
+
141
+ // Leading icon for the program-status badge in the page header, the one badge
142
+ // loud enough to earn one.
143
+ const RAG_ICON: Record<Rag, IconType> = {
144
+ onTrack: CheckCircleIcon,
145
+ atRisk: ExclamationTriangleIcon,
146
+ blocked: ExclamationTriangleIcon,
147
+ };
148
+
149
+ // Chart fills for RAG status: the data-viz semantic anchors, not the UI status
150
+ // tokens (--color-success/warning/error), so a milestone bar and a "blocked"
151
+ // donut slice land on the same red.
152
+ const RAG_FILL: Record<Rag, string> = {
153
+ onTrack: 'var(--color-data-categorical-green, #0B991F)',
154
+ atRisk: 'var(--color-data-categorical-orange, #EB6E00)',
155
+ blocked: 'var(--color-data-categorical-red, #F5394F)',
156
+ };
157
+
158
+ // ============= CHART CHROME (design tokens w/ hex fallbacks) =============
159
+
160
+ // The same slots `getChartColors()` resolves in @astryxdesign/charts, as CSS
161
+ // vars rather than the hook because page templates depend only on core +
162
+ // recharts. CHART_NEUTRAL is the reference-line / not-started tone.
163
+ const CHART_GRID = 'var(--color-border, rgba(5, 54, 89, 0.1))';
164
+ const CHART_LABEL = 'var(--color-text-secondary, #4E606F)';
165
+ const CHART_NEUTRAL = 'var(--color-data-neutral, #8494A3)';
166
+
167
+ const IDEAL_COLOR = CHART_NEUTRAL;
168
+ const ACTUAL_COLOR = 'var(--color-data-categorical-blue, #0171E3)';
169
+ const AXIS_TICK = {
170
+ fontSize: 'var(--font-size-sm, 12px)',
171
+ fill: CHART_LABEL,
172
+ };
173
+
174
+ // ============= TIMELINE SCALE =============
175
+
176
+ const TIMELINE_DAYS = 90;
177
+ const TIMELINE_TICK_STEP = 15;
178
+ const TIMELINE_TICKS = Array.from(
179
+ {length: TIMELINE_DAYS / TIMELINE_TICK_STEP + 1},
180
+ (_, i) => i * TIMELINE_TICK_STEP,
181
+ );
182
+
183
+ // "Today" marker, as a day offset on the same scale.
184
+ const PROJECT_DAY = 58;
185
+
186
+ // ============= DATA =============
187
+
188
+ // Task backlog by status. Tasks are not equally sized, so the fixture makes
189
+ // count and effort diverge deliberately; points sum to the 320 the burndown
190
+ // starts from. Statuses are categories rather than a scale, so the fills come
191
+ // from the categorical palette, reusing the Gantt's anchors so one status means
192
+ // one color everywhere on the page.
193
+ const TASK_STATUS: TaskStatusSegment[] = [
194
+ {
195
+ key: 'closed',
196
+ label: 'Closed',
197
+ tasks: 86,
198
+ points: 190,
199
+ color: 'var(--color-data-categorical-blue, #0171E3)',
200
+ },
201
+ {
202
+ key: 'inProgress',
203
+ label: 'In progress',
204
+ tasks: 24,
205
+ points: 58,
206
+ color: 'var(--color-data-categorical-green, #0B991F)',
207
+ },
208
+ {
209
+ key: 'inReview',
210
+ label: 'In review',
211
+ tasks: 12,
212
+ points: 24,
213
+ color: 'var(--color-data-categorical-orange, #EB6E00)',
214
+ },
215
+ {
216
+ key: 'blocked',
217
+ label: 'Blocked',
218
+ tasks: 5,
219
+ points: 22,
220
+ color: 'var(--color-data-categorical-red, #F5394F)',
221
+ },
222
+ {
223
+ key: 'planned',
224
+ label: 'Planned',
225
+ tasks: 21,
226
+ points: 26,
227
+ color: CHART_NEUTRAL,
228
+ },
229
+ ];
230
+
231
+ const MILESTONES: Milestone[] = [
232
+ {
233
+ id: 'm1',
234
+ name: 'Kickoff & scoping',
235
+ phase: 'build',
236
+ start: 0,
237
+ end: 8,
238
+ status: 'onTrack',
239
+ dateLabel: 'Apr 1 – Apr 9',
240
+ },
241
+ {
242
+ id: 'm2',
243
+ name: 'Architecture sign-off',
244
+ phase: 'build',
245
+ start: 8,
246
+ end: 20,
247
+ status: 'onTrack',
248
+ dateLabel: 'Apr 9 – Apr 21',
249
+ },
250
+ {
251
+ id: 'm3',
252
+ name: 'Feature complete',
253
+ phase: 'build',
254
+ start: 20,
255
+ end: 44,
256
+ status: 'atRisk',
257
+ dateLabel: 'Apr 21 – May 15',
258
+ },
259
+ {
260
+ id: 'm4',
261
+ name: 'Private beta',
262
+ phase: 'beta',
263
+ start: 44,
264
+ end: 62,
265
+ status: 'atRisk',
266
+ dateLabel: 'May 15 – Jun 2',
267
+ },
268
+ {
269
+ id: 'm5',
270
+ name: 'Bug bash & hardening',
271
+ phase: 'beta',
272
+ start: 58,
273
+ end: 72,
274
+ status: 'blocked',
275
+ dateLabel: 'May 29 – Jun 12',
276
+ },
277
+ {
278
+ id: 'm6',
279
+ name: 'GA readiness review',
280
+ phase: 'ga',
281
+ start: 72,
282
+ end: 82,
283
+ status: 'onTrack',
284
+ dateLabel: 'Jun 12 – Jun 22',
285
+ },
286
+ {
287
+ id: 'm7',
288
+ name: 'Public launch',
289
+ phase: 'ga',
290
+ start: 82,
291
+ end: 90,
292
+ status: 'onTrack',
293
+ dateLabel: 'Jun 22 – Jun 30',
294
+ },
295
+ ];
296
+
297
+ const WORKSTREAMS: Workstream[] = [
298
+ {
299
+ id: 'w1',
300
+ name: 'Core platform',
301
+ owner: 'Priya Raman',
302
+ phase: 'build',
303
+ percent: 100,
304
+ due: '2026-04-21',
305
+ status: 'onTrack',
306
+ },
307
+ {
308
+ id: 'w2',
309
+ name: 'Data pipeline',
310
+ owner: 'Marcus Webb',
311
+ phase: 'build',
312
+ percent: 92,
313
+ due: '2026-05-15',
314
+ status: 'atRisk',
315
+ },
316
+ {
317
+ id: 'w3',
318
+ name: 'Web client',
319
+ owner: 'Ana Duarte',
320
+ phase: 'build',
321
+ percent: 88,
322
+ due: '2026-05-15',
323
+ status: 'onTrack',
324
+ },
325
+ {
326
+ id: 'w4',
327
+ name: 'Beta onboarding',
328
+ owner: 'Tom Okafor',
329
+ phase: 'beta',
330
+ percent: 64,
331
+ due: '2026-06-02',
332
+ status: 'atRisk',
333
+ },
334
+ {
335
+ id: 'w5',
336
+ name: 'Load & reliability',
337
+ owner: 'Dana Whitfield',
338
+ phase: 'beta',
339
+ percent: 38,
340
+ due: '2026-06-12',
341
+ status: 'blocked',
342
+ },
343
+ {
344
+ id: 'w6',
345
+ name: 'Docs & enablement',
346
+ owner: 'Sofia Marín',
347
+ phase: 'ga',
348
+ percent: 55,
349
+ due: '2026-06-22',
350
+ status: 'onTrack',
351
+ },
352
+ {
353
+ id: 'w7',
354
+ name: 'Launch marketing',
355
+ owner: 'Kenji Watanabe',
356
+ phase: 'ga',
357
+ percent: 41,
358
+ due: '2026-06-22',
359
+ status: 'onTrack',
360
+ },
361
+ {
362
+ id: 'w8',
363
+ name: 'Go-to-market ops',
364
+ owner: 'Lena Fischer',
365
+ phase: 'ga',
366
+ percent: 30,
367
+ due: '2026-06-30',
368
+ status: 'atRisk',
369
+ },
370
+ ];
371
+
372
+ const RISKS: Risk[] = [
373
+ {
374
+ id: 'r1',
375
+ title: 'Load tests failing at 3x target concurrency',
376
+ workstream: 'Load & reliability',
377
+ phase: 'beta',
378
+ severity: 'blocked',
379
+ owner: 'Dana Whitfield',
380
+ detail:
381
+ 'p99 latency degrades past SLO above 12k concurrent sessions. Root cause traced to connection-pool saturation; fix depends on the data-pipeline retry change landing first.',
382
+ },
383
+ {
384
+ id: 'r2',
385
+ title: 'Data pipeline backfill slower than planned',
386
+ workstream: 'Data pipeline',
387
+ phase: 'build',
388
+ severity: 'atRisk',
389
+ owner: 'Marcus Webb',
390
+ detail:
391
+ 'Historical backfill is tracking two days behind. Mitigation: parallelizing the backfill workers; feature-complete date at risk if not recovered this week.',
392
+ },
393
+ {
394
+ id: 'r3',
395
+ title: 'Beta onboarding email deliverability',
396
+ workstream: 'Beta onboarding',
397
+ phase: 'beta',
398
+ severity: 'atRisk',
399
+ owner: 'Tom Okafor',
400
+ detail:
401
+ 'Invite emails landing in spam for two enterprise domains. Working with IT to allowlist sending IPs before the private beta opens.',
402
+ },
403
+ ];
404
+
405
+ // Ideal vs. actual remaining scope (story points), one entry per week. Actual
406
+ // trails ideal mid-project (scope discovery), then recovers late.
407
+ const BURNDOWN = (() => {
408
+ const total = 320;
409
+ const actualRemaining = [
410
+ 320, 300, 286, 262, 240, 226, 210, 182, 150, 132, 96, 54, 20,
411
+ ];
412
+ const idealStep = total / (actualRemaining.length - 1);
413
+ return actualRemaining.map((actual, i) => ({
414
+ week: i,
415
+ label: `W${i + 1}`,
416
+ ideal: Math.max(0, Math.round(total - idealStep * i)),
417
+ actual,
418
+ }));
419
+ })();
420
+
421
+ const BURNDOWN_TICKS = [0, 3, 6, 9, 12];
422
+
423
+ // ============= DERIVED FROM DATA =============
424
+
425
+ // Program-wide aggregates: unaffected by the phase filter, so computed once.
426
+ const TASK_POINTS_TOTAL = TASK_STATUS.reduce((sum, s) => sum + s.points, 0);
427
+ const TASK_COUNT_TOTAL = TASK_STATUS.reduce((sum, s) => sum + s.tasks, 0);
428
+
429
+ // Worst status across every workstream wins.
430
+ const PROGRAM_STATUS: Rag = WORKSTREAMS.some(w => w.status === 'blocked')
431
+ ? 'blocked'
432
+ : WORKSTREAMS.some(w => w.status === 'atRisk')
433
+ ? 'atRisk'
434
+ : 'onTrack';
435
+
436
+ function byPhase<T extends {phase: ReleasePhase}>(
437
+ items: T[],
438
+ phase: Phase,
439
+ ): T[] {
440
+ return phase === 'all' ? items : items.filter(item => item.phase === phase);
441
+ }
442
+
443
+ // ============= DONUT GEOMETRY =============
444
+
445
+ const DONUT_SIZE = 208;
446
+ const DONUT_OUTER_RADIUS = 104;
447
+ const DONUT_INNER_RADIUS = 70;
448
+
449
+ // The standard 2px gap between touching marks, which Recharts wants as an
450
+ // angle: 2px of arc at the middle of the ring.
451
+ const DONUT_GAP_ANGLE =
452
+ (2 / ((DONUT_OUTER_RADIUS + DONUT_INNER_RADIUS) / 2)) * (180 / Math.PI);
453
+
454
+ // ============= SHARED PIECES =============
455
+
456
+ const pageStyles = stylex.create({
457
+ contentBottomPad: {
458
+ paddingBlockEnd: 'var(--spacing-10)',
459
+ },
460
+ // The header and each Section run full-bleed so their dividers reach the
461
+ // viewport edges; the column inside them is what gets capped and centered.
462
+ contentColumn: {
463
+ width: '100%',
464
+ maxWidth: 1440,
465
+ marginInline: 'auto',
466
+ },
467
+ // Risk rows carry no action, so ListItem's interactive treatment (pointer
468
+ // cursor, pressed state) would be a lie. This is the hover half only — a hint
469
+ // that the hover card exists — guarded so it never sticks on touch.
470
+ riskRow: {
471
+ transitionProperty: 'background-color',
472
+ transitionDuration: 'var(--duration-fast-min)',
473
+ transitionTimingFunction: 'var(--ease-standard)',
474
+ backgroundColor: 'transparent',
475
+ ':hover': {
476
+ backgroundColor: {
477
+ default: null,
478
+ '@media (hover: hover)': 'var(--color-overlay-hover)',
479
+ },
480
+ },
481
+ },
482
+ // Rows run the full width of the card, so the hover card has to cap itself.
483
+ riskDetail: {
484
+ maxWidth: 320,
485
+ },
486
+ // Fixed frame so the ring's px radii stay exact.
487
+ donutFrame: {
488
+ width: DONUT_SIZE,
489
+ height: DONUT_SIZE,
490
+ flexShrink: 0,
491
+ },
492
+ });
493
+
494
+ // Icon's `color` prop takes semantic names only, but a legend swatch has to
495
+ // match its series color exactly, so this one reaches for an inline color.
496
+ function LegendDot({color, label}: {color: string; label: string}) {
497
+ const dotStyle: CSSProperties = {color};
498
+ return (
499
+ <HStack gap={2} vAlign="center">
500
+ <Icon icon={StopIcon} size="xsm" style={dotStyle} />
501
+ <Text type="supporting" color="secondary">
502
+ {label}
503
+ </Text>
504
+ </HStack>
505
+ );
506
+ }
507
+
508
+ function SectionHeading({title, hint}: {title: string; hint?: string}) {
509
+ return (
510
+ <HStack hAlign="between" vAlign="center" gap={3}>
511
+ <Heading level={2}>{title}</Heading>
512
+ {hint ? (
513
+ <Text type="supporting" color="secondary">
514
+ {hint}
515
+ </Text>
516
+ ) : null}
517
+ </HStack>
518
+ );
519
+ }
520
+
521
+ // Card titles are visually level 3 but sit at level 2 in the outline, as peers
522
+ // of the section headings. `hint` describes the card, `meta` is the count or
523
+ // figure that trails on the opposite edge.
524
+ function CardHeading({
525
+ title,
526
+ hint,
527
+ meta,
528
+ }: {
529
+ title: string;
530
+ hint?: string;
531
+ meta?: string;
532
+ }) {
533
+ return (
534
+ <HStack gap={3} hAlign="between" vAlign="center" wrap="wrap">
535
+ <VStack>
536
+ <Heading level={3} accessibilityLevel={2}>
537
+ {title}
538
+ </Heading>
539
+ {hint ? (
540
+ <Text type="supporting" color="secondary">
541
+ {hint}
542
+ </Text>
543
+ ) : null}
544
+ </VStack>
545
+ {meta ? (
546
+ <Text type="body" color="secondary">
547
+ {meta}
548
+ </Text>
549
+ ) : null}
550
+ </HStack>
551
+ );
552
+ }
553
+
554
+ function PhaseEmptyState({noun}: {noun: string}) {
555
+ return (
556
+ <EmptyState
557
+ title={`No ${noun} in this phase`}
558
+ description={`Switch phases to see other ${noun}.`}
559
+ icon={<Icon icon={FlagIcon} size="lg" />}
560
+ isCompact
561
+ />
562
+ );
563
+ }
564
+
565
+ // ============= PAGE HEADER =============
566
+
567
+ function ProgramHeader({
568
+ phase,
569
+ onPhaseChange,
570
+ }: {
571
+ phase: Phase;
572
+ onPhaseChange: (phase: Phase) => void;
573
+ }) {
574
+ return (
575
+ <LayoutHeader padding={6} hasDivider>
576
+ <HStack
577
+ gap={3}
578
+ vAlign="center"
579
+ hAlign="between"
580
+ wrap="wrap"
581
+ xstyle={pageStyles.contentColumn}>
582
+ <VStack gap={1}>
583
+ <Heading level={1}>Aurora Launch</Heading>
584
+ <HStack gap={2} vAlign="center">
585
+ <Text type="body" color="secondary">
586
+ Target GA ·
587
+ </Text>
588
+ <Timestamp
589
+ value="2026-06-30T00:00:00Z"
590
+ format="date"
591
+ type="body"
592
+ color="secondary"
593
+ />
594
+ <Badge
595
+ variant={RAG_BADGE[PROGRAM_STATUS]}
596
+ label={RAG_LABEL[PROGRAM_STATUS]}
597
+ icon={<Icon icon={RAG_ICON[PROGRAM_STATUS]} size="sm" />}
598
+ />
599
+ </HStack>
600
+ </VStack>
601
+ <HStack gap={2}>
602
+ <SegmentedControl
603
+ label="Release phase"
604
+ value={phase}
605
+ onChange={value => onPhaseChange(value as Phase)}>
606
+ <SegmentedControlItem label="All" value="all" />
607
+ <SegmentedControlItem label="Build" value="build" />
608
+ <SegmentedControlItem label="Beta" value="beta" />
609
+ <SegmentedControlItem label="GA" value="ga" />
610
+ </SegmentedControl>
611
+ <Button
612
+ label="Export status"
613
+ variant="secondary"
614
+ icon={<Icon icon={ArrowDownTrayIcon} size="sm" />}
615
+ />
616
+ </HStack>
617
+ </HStack>
618
+ </LayoutHeader>
619
+ );
620
+ }
621
+
622
+ // ============= GANTT (milestone timeline) =============
623
+
624
+ const GANTT_MIN_HEIGHT = 160;
625
+ const GANTT_ROW_HEIGHT = 44;
626
+
627
+ interface GanttTooltipEntry {
628
+ payload: Milestone & {offset: number; duration: number};
629
+ }
630
+
631
+ function GanttTooltip({
632
+ active,
633
+ payload,
634
+ }: {
635
+ active?: boolean;
636
+ payload?: GanttTooltipEntry[];
637
+ }) {
638
+ if (!active || !payload?.length) {
639
+ return null;
640
+ }
641
+ // The colored duration segment is stacked last, so it carries the milestone.
642
+ const milestone = payload[payload.length - 1].payload;
643
+ return (
644
+ <Card padding={3}>
645
+ <VStack gap={1}>
646
+ <Text type="body" weight="semibold">
647
+ {milestone.name}
648
+ </Text>
649
+ <Text type="supporting" color="secondary">
650
+ {milestone.dateLabel} · {PHASE_LABEL[milestone.phase]}
651
+ </Text>
652
+ </VStack>
653
+ </Card>
654
+ );
655
+ }
656
+
657
+ function GanttChart({milestones}: {milestones: Milestone[]}) {
658
+ // Recharts renders a floating bar as a transparent offset segment plus a
659
+ // colored duration segment stacked on top.
660
+ const data = milestones.map(m => ({
661
+ ...m,
662
+ offset: m.start,
663
+ duration: m.end - m.start,
664
+ }));
665
+ const height = Math.max(GANTT_MIN_HEIGHT, data.length * GANTT_ROW_HEIGHT);
666
+
667
+ return (
668
+ <VStack gap={3}>
669
+ <ResponsiveContainer width="100%" height={height}>
670
+ <BarChart
671
+ data={data}
672
+ layout="vertical"
673
+ margin={{top: 4, right: 16, left: 8, bottom: 4}}
674
+ barCategoryGap={12}>
675
+ <CartesianGrid horizontal={false} vertical stroke={CHART_GRID} />
676
+ <XAxis
677
+ type="number"
678
+ domain={[0, TIMELINE_DAYS]}
679
+ ticks={TIMELINE_TICKS}
680
+ tickFormatter={(v: number) => `D${v}`}
681
+ tick={AXIS_TICK}
682
+ axisLine={false}
683
+ tickLine={false}
684
+ />
685
+ <YAxis
686
+ type="category"
687
+ dataKey="name"
688
+ tick={AXIS_TICK}
689
+ axisLine={false}
690
+ tickLine={false}
691
+ width={148}
692
+ />
693
+ <Tooltip content={<GanttTooltip />} cursor={{fill: CHART_GRID}} />
694
+ {/* An annotation rather than a series, so it takes the neutral tone
695
+ instead of a categorical color. */}
696
+ <ReferenceLine
697
+ x={PROJECT_DAY}
698
+ stroke={CHART_NEUTRAL}
699
+ strokeDasharray="4 4"
700
+ label={{
701
+ value: 'Today',
702
+ position: 'top',
703
+ fontSize: 11,
704
+ fill: CHART_NEUTRAL,
705
+ }}
706
+ />
707
+ <Bar
708
+ dataKey="offset"
709
+ stackId="gantt"
710
+ fill="transparent"
711
+ isAnimationActive={false}
712
+ />
713
+ <Bar
714
+ dataKey="duration"
715
+ stackId="gantt"
716
+ radius={4}
717
+ isAnimationActive={false}>
718
+ {data.map(m => (
719
+ <Cell key={m.id} fill={RAG_FILL[m.status]} />
720
+ ))}
721
+ </Bar>
722
+ </BarChart>
723
+ </ResponsiveContainer>
724
+ <HStack gap={4} vAlign="center" wrap="wrap">
725
+ <LegendDot color={RAG_FILL.onTrack} label={RAG_LABEL.onTrack} />
726
+ <LegendDot color={RAG_FILL.atRisk} label={RAG_LABEL.atRisk} />
727
+ <LegendDot color={RAG_FILL.blocked} label={RAG_LABEL.blocked} />
728
+ </HStack>
729
+ </VStack>
730
+ );
731
+ }
732
+
733
+ // ============= TASK PROGRESS (weighted donut) =============
734
+
735
+ function percentOfPoints(points: number): number {
736
+ return Math.round((points / TASK_POINTS_TOTAL) * 100);
737
+ }
738
+
739
+ interface DonutTooltipEntry {
740
+ payload: TaskStatusSegment;
741
+ }
742
+
743
+ function TaskDonutTooltip({
744
+ active,
745
+ payload,
746
+ }: {
747
+ active?: boolean;
748
+ payload?: DonutTooltipEntry[];
749
+ }) {
750
+ if (!active || !payload?.length) {
751
+ return null;
752
+ }
753
+ const segment = payload[0].payload;
754
+ return (
755
+ <Card padding={3}>
756
+ <VStack gap={1}>
757
+ <LegendDot color={segment.color} label={segment.label} />
758
+ <Text type="supporting" color="secondary" hasTabularNumbers>
759
+ {percentOfPoints(segment.points)}% · {segment.points} pts ·{' '}
760
+ {segment.tasks} tasks
761
+ </Text>
762
+ </VStack>
763
+ </Card>
764
+ );
765
+ }
766
+
767
+ function TaskProgressDonut() {
768
+ return (
769
+ <HStack gap={8} vAlign="center" wrap="wrap">
770
+ {/* Fixed size rather than a ResponsiveContainer: the ring geometry is
771
+ deliberate, and the card wraps the legend under it rather than
772
+ shrinking it. */}
773
+ <div {...stylex.props(pageStyles.donutFrame)}>
774
+ <PieChart
775
+ width={DONUT_SIZE}
776
+ height={DONUT_SIZE}
777
+ margin={{top: 0, right: 0, bottom: 0, left: 0}}>
778
+ <Pie
779
+ data={TASK_STATUS}
780
+ dataKey="points"
781
+ nameKey="label"
782
+ cx="50%"
783
+ cy="50%"
784
+ innerRadius={DONUT_INNER_RADIUS}
785
+ outerRadius={DONUT_OUTER_RADIUS}
786
+ paddingAngle={DONUT_GAP_ANGLE}
787
+ // Noon start, running clockwise, which puts "Closed" at the top.
788
+ startAngle={90}
789
+ endAngle={-270}
790
+ // The gap separates the arcs; a stroke would add non-data ink.
791
+ stroke="none"
792
+ isAnimationActive={false}>
793
+ {TASK_STATUS.map(s => (
794
+ <Cell key={s.key} fill={s.color} />
795
+ ))}
796
+ </Pie>
797
+ <Tooltip content={<TaskDonutTooltip />} />
798
+ </PieChart>
799
+ </div>
800
+ {/* A key, not a table: per-segment figures live in the tooltip. */}
801
+ <VStack gap={2}>
802
+ {TASK_STATUS.map(s => (
803
+ <LegendDot key={s.key} color={s.color} label={s.label} />
804
+ ))}
805
+ </VStack>
806
+ </HStack>
807
+ );
808
+ }
809
+
810
+ // ============= BURN-DOWN CHART =============
811
+
812
+ interface BurndownTooltipEntry {
813
+ name: string;
814
+ value: number;
815
+ color: string;
816
+ }
817
+
818
+ function BurndownTooltip({
819
+ active,
820
+ payload,
821
+ label,
822
+ }: {
823
+ active?: boolean;
824
+ payload?: BurndownTooltipEntry[];
825
+ label?: number;
826
+ }) {
827
+ if (!active || !payload?.length) {
828
+ return null;
829
+ }
830
+ const week = typeof label === 'number' ? (BURNDOWN[label]?.label ?? '') : '';
831
+ return (
832
+ <Card padding={3}>
833
+ <VStack gap={1}>
834
+ <Text type="supporting" color="secondary">
835
+ {week}
836
+ </Text>
837
+ {payload.map(entry => (
838
+ <LegendDot
839
+ key={entry.name}
840
+ color={entry.color}
841
+ label={`${entry.name}: ${entry.value} pts`}
842
+ />
843
+ ))}
844
+ </VStack>
845
+ </Card>
846
+ );
847
+ }
848
+
849
+ // Matches the donut frame so the two plots sharing the summary row read as one
850
+ // band.
851
+ const BURNDOWN_HEIGHT = DONUT_SIZE;
852
+
853
+ function BurndownChart() {
854
+ return (
855
+ <VStack gap={3}>
856
+ <ResponsiveContainer width="100%" height={BURNDOWN_HEIGHT}>
857
+ <LineChart
858
+ data={BURNDOWN}
859
+ margin={{top: 16, right: 4, left: 4, bottom: 4}}>
860
+ <CartesianGrid horizontal vertical={false} stroke={CHART_GRID} />
861
+ <XAxis
862
+ dataKey="week"
863
+ type="number"
864
+ domain={[0, BURNDOWN.length - 1]}
865
+ ticks={BURNDOWN_TICKS}
866
+ tickFormatter={(v: number) => BURNDOWN[v]?.label ?? ''}
867
+ tick={AXIS_TICK}
868
+ axisLine={false}
869
+ tickLine={false}
870
+ />
871
+ <YAxis
872
+ tick={AXIS_TICK}
873
+ axisLine={false}
874
+ tickLine={false}
875
+ width={40}
876
+ unit=" pts"
877
+ />
878
+ <Tooltip
879
+ content={<BurndownTooltip />}
880
+ cursor={{stroke: CHART_GRID}}
881
+ />
882
+ <Line
883
+ type="monotone"
884
+ dataKey="ideal"
885
+ name="Ideal"
886
+ stroke={IDEAL_COLOR}
887
+ strokeWidth={1}
888
+ strokeDasharray="4 4"
889
+ dot={false}
890
+ isAnimationActive={false}
891
+ />
892
+ <Line
893
+ type="monotone"
894
+ dataKey="actual"
895
+ name="Actual"
896
+ stroke={ACTUAL_COLOR}
897
+ strokeWidth={2}
898
+ dot={false}
899
+ isAnimationActive={false}
900
+ />
901
+ </LineChart>
902
+ </ResponsiveContainer>
903
+ <HStack gap={4} vAlign="center" wrap="wrap">
904
+ <LegendDot color={ACTUAL_COLOR} label="Actual remaining" />
905
+ <LegendDot color={IDEAL_COLOR} label="Ideal burndown" />
906
+ </HStack>
907
+ </VStack>
908
+ );
909
+ }
910
+
911
+ // ============= RISKS LIST =============
912
+
913
+ function RiskRow({risk}: {risk: Risk}) {
914
+ // The `<HoverCard>` wrapper is not usable here: it wraps its trigger in a
915
+ // `display: contents` span, and a span between `<ul>` and `<li>` is invalid
916
+ // markup that drops the row out of the list in the accessibility tree. The
917
+ // hook puts the trigger ref straight on the `<li>` instead.
918
+ const hoverCard = useHoverCard({
919
+ placement: 'below',
920
+ alignment: 'start',
921
+ // The row is a plain `<li>` made focusable below, so leave nothing to
922
+ // `auto` trigger detection.
923
+ focusTrigger: 'always',
924
+ });
925
+
926
+ return (
927
+ <ListItem
928
+ ref={hoverCard.ref}
929
+ // The row has no action of its own; this tab stop exists so the detail is
930
+ // reachable without a pointer, and `aria-describedby` reads it out.
931
+ tabIndex={0}
932
+ aria-describedby={hoverCard.describedBy}
933
+ xstyle={pageStyles.riskRow}
934
+ label={risk.title}
935
+ description={`${risk.workstream} · ${PHASE_LABEL[risk.phase]} · ${risk.owner}`}
936
+ endContent={
937
+ <>
938
+ <Badge
939
+ variant={RAG_BADGE[risk.severity]}
940
+ label={RAG_LABEL[risk.severity]}
941
+ />
942
+ {/* Inside the row, where the popover `<span>` is valid markup and
943
+ out of flow; a sibling of the `<li>` would not be. */}
944
+ {hoverCard.renderHoverCard(
945
+ <VStack gap={2} xstyle={pageStyles.riskDetail}>
946
+ <Text type="body" weight="semibold">
947
+ {risk.title}
948
+ </Text>
949
+ <Text type="supporting" color="secondary">
950
+ {risk.detail}
951
+ </Text>
952
+ <HStack gap={2} vAlign="center">
953
+ <Avatar name={risk.owner} size="sm" />
954
+ <Text type="supporting" color="secondary">
955
+ {risk.owner}
956
+ </Text>
957
+ </HStack>
958
+ </VStack>,
959
+ )}
960
+ </>
961
+ }
962
+ />
963
+ );
964
+ }
965
+
966
+ function RisksPanel({risks}: {risks: Risk[]}) {
967
+ return (
968
+ <Card padding={6}>
969
+ <VStack gap={4}>
970
+ <CardHeading
971
+ title="Blockers & risks"
972
+ hint="Hover or focus a row for detail"
973
+ />
974
+ {risks.length === 0 ? (
975
+ <EmptyState
976
+ title="No open blockers or risks"
977
+ description="Nothing flagged for the selected phase. Keep it up."
978
+ icon={<Icon icon={CheckCircleIcon} size="lg" color="success" />}
979
+ isCompact
980
+ />
981
+ ) : (
982
+ <List hasDividers>
983
+ {risks.map(risk => (
984
+ <RiskRow key={risk.id} risk={risk} />
985
+ ))}
986
+ </List>
987
+ )}
988
+ </VStack>
989
+ </Card>
990
+ );
991
+ }
992
+
993
+ // ============= WORKSTREAM TABLE =============
994
+
995
+ const WORKSTREAM_COLUMNS: TableColumn<Workstream>[] = [
996
+ {
997
+ key: 'name',
998
+ header: 'Workstream',
999
+ width: proportional(1, {minWidth: 160}),
1000
+ renderCell: (item: Workstream) => (
1001
+ <VStack gap={0}>
1002
+ <Text type="body" weight="semibold">
1003
+ {item.name}
1004
+ </Text>
1005
+ <Text type="supporting" color="secondary">
1006
+ {PHASE_LABEL[item.phase]}
1007
+ </Text>
1008
+ </VStack>
1009
+ ),
1010
+ },
1011
+ {
1012
+ key: 'owner',
1013
+ header: 'Owner',
1014
+ width: proportional(1, {minWidth: 160}),
1015
+ renderCell: (item: Workstream) => (
1016
+ <HStack gap={2} vAlign="center">
1017
+ <Avatar name={item.owner} size="sm" />
1018
+ <Text type="body">{item.owner}</Text>
1019
+ </HStack>
1020
+ ),
1021
+ },
1022
+ {
1023
+ key: 'percent',
1024
+ header: '% complete',
1025
+ width: proportional(2, {minWidth: 200}),
1026
+ renderCell: (item: Workstream) => (
1027
+ <VStack gap={1}>
1028
+ <ProgressBar
1029
+ value={item.percent}
1030
+ max={100}
1031
+ label={`${item.name} progress`}
1032
+ isLabelHidden
1033
+ />
1034
+ <Text type="supporting" color="secondary">
1035
+ {item.percent}%
1036
+ </Text>
1037
+ </VStack>
1038
+ ),
1039
+ },
1040
+ {
1041
+ key: 'due',
1042
+ header: 'Due',
1043
+ width: proportional(1, {minWidth: 120}),
1044
+ renderCell: (item: Workstream) => (
1045
+ <Timestamp value={item.due} format="date" />
1046
+ ),
1047
+ },
1048
+ {
1049
+ key: 'status',
1050
+ header: 'Status',
1051
+ width: proportional(1, {minWidth: 120}),
1052
+ renderCell: (item: Workstream) => (
1053
+ <Badge variant={RAG_BADGE[item.status]} label={RAG_LABEL[item.status]} />
1054
+ ),
1055
+ },
1056
+ ];
1057
+
1058
+ // ============= MAIN =============
1059
+
1060
+ export default function ProjectStatusPage() {
1061
+ const [phase, setPhase] = useState<Phase>('all');
1062
+
1063
+ const milestones = useMemo(() => byPhase(MILESTONES, phase), [phase]);
1064
+ const workstreams = useMemo(() => byPhase(WORKSTREAMS, phase), [phase]);
1065
+ const risks = useMemo(() => byPhase(RISKS, phase), [phase]);
1066
+
1067
+ return (
1068
+ <Layout
1069
+ height="fill"
1070
+ header={<ProgramHeader phase={phase} onPhaseChange={setPhase} />}
1071
+ content={
1072
+ <LayoutContent padding={0} xstyle={pageStyles.contentBottomPad}>
1073
+ <Section padding={6} dividers={['bottom']}>
1074
+ <VStack gap={6} xstyle={pageStyles.contentColumn}>
1075
+ <SectionHeading
1076
+ title="Milestone timeline"
1077
+ hint={`Project days 0–${TIMELINE_DAYS} · dashed line marks today`}
1078
+ />
1079
+ {milestones.length === 0 ? (
1080
+ <PhaseEmptyState noun="milestones" />
1081
+ ) : (
1082
+ <GanttChart milestones={milestones} />
1083
+ )}
1084
+ </VStack>
1085
+ </Section>
1086
+
1087
+ <Section padding={6} paddingBlock={10}>
1088
+ <VStack gap={6} xstyle={pageStyles.contentColumn}>
1089
+ <Grid gap={4} columns={{minWidth: 320, max: 3}}>
1090
+ <Card padding={6}>
1091
+ <VStack gap={5}>
1092
+ <CardHeading
1093
+ title="Overall progress"
1094
+ hint={`${TASK_COUNT_TOTAL} tasks · ${TASK_POINTS_TOTAL} story points`}
1095
+ />
1096
+ <TaskProgressDonut />
1097
+ </VStack>
1098
+ </Card>
1099
+
1100
+ <Card padding={6}>
1101
+ <VStack gap={5}>
1102
+ <CardHeading
1103
+ title="Scope burndown"
1104
+ hint={`Remaining story points · ${BURNDOWN.length} weeks`}
1105
+ />
1106
+ <BurndownChart />
1107
+ </VStack>
1108
+ </Card>
1109
+
1110
+ <RisksPanel risks={risks} />
1111
+ </Grid>
1112
+
1113
+ <Card padding={6}>
1114
+ <VStack gap={4}>
1115
+ <CardHeading
1116
+ title="Workstreams"
1117
+ meta={`${workstreams.length} of ${WORKSTREAMS.length}`}
1118
+ />
1119
+ {workstreams.length === 0 ? (
1120
+ <PhaseEmptyState noun="workstreams" />
1121
+ ) : (
1122
+ <Table<Workstream>
1123
+ data={workstreams}
1124
+ columns={WORKSTREAM_COLUMNS}
1125
+ idKey="id"
1126
+ density="balanced"
1127
+ dividers="rows"
1128
+ hasHover
1129
+ />
1130
+ )}
1131
+ </VStack>
1132
+ </Card>
1133
+ </VStack>
1134
+ </Section>
1135
+ </LayoutContent>
1136
+ }
1137
+ />
1138
+ );
1139
+ }