chati-dev 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +18 -13
  2. package/bin/chati.js +32 -4
  3. package/framework/agents/build/dev.md +9 -5
  4. package/framework/agents/discover/brief.md +2 -0
  5. package/framework/agents/discover/brownfield-wu.md +2 -0
  6. package/framework/agents/discover/greenfield-wu.md +18 -1
  7. package/framework/agents/plan/detail.md +2 -0
  8. package/framework/agents/plan/tasks.md +31 -0
  9. package/framework/agents/plan/ux-brand-architect.md +21 -2
  10. package/framework/agents/plan/ux-component-engineer.md +10 -0
  11. package/framework/agents/quality/qa-implementation.md +1 -1
  12. package/framework/agents/quality/qa-planning.md +1 -1
  13. package/framework/agents/quality/qa-visual.md +33 -21
  14. package/framework/config.yaml +5 -4
  15. package/framework/constitution.md +58 -11
  16. package/framework/context/governance.md +12 -10
  17. package/framework/context/root.md +2 -2
  18. package/framework/data/entity-registry.yaml +12 -4
  19. package/framework/data/qa-rubrics.yaml +335 -0
  20. package/framework/domains/constitution.yaml +3 -3
  21. package/framework/executors/json-validate.js +80 -0
  22. package/framework/executors/npm-script.js +72 -0
  23. package/framework/executors/path-check.js +67 -0
  24. package/framework/executors/registry.yaml +36 -0
  25. package/framework/hooks/advance-trigger.js +47 -3
  26. package/framework/hooks/constitution-guard.js +9 -4
  27. package/framework/hooks/git-push-authority.js +113 -0
  28. package/framework/hooks/license-guard.js +59 -9
  29. package/framework/hooks/model-governance.js +40 -17
  30. package/framework/hooks/prism-engine.js +78 -11
  31. package/framework/hooks/session-digest.js +12 -3
  32. package/framework/hooks/settings.json +7 -3
  33. package/framework/hooks/style-guard.js +52 -3
  34. package/framework/hooks/team-quality-gate.js +43 -5
  35. package/framework/i18n/en.yaml +3 -3
  36. package/framework/i18n/es.yaml +3 -3
  37. package/framework/i18n/fr.yaml +3 -3
  38. package/framework/i18n/pt.yaml +3 -3
  39. package/framework/intelligence/confidence.yaml +85 -6
  40. package/framework/intelligence/context-engine.md +9 -5
  41. package/framework/intelligence/decision-engine.md +1 -1
  42. package/framework/orchestrator/chati-router.js +59 -5
  43. package/framework/orchestrator/chati-update.md +19 -3
  44. package/framework/orchestrator/chati.md +20 -1
  45. package/framework/quality-gates/planning-gate.md +2 -0
  46. package/framework/rules/human-writing-style.md +47 -0
  47. package/framework/scaffold/motion-premium/README.md +12 -2
  48. package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +1 -1
  49. package/framework/scaffold/motion-premium/scaffold.yaml +6 -1
  50. package/framework/scaffold/motion-premium-3d/README.md +10 -0
  51. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
  52. package/framework/scaffold/motion-premium-3d/scaffold.yaml +3 -0
  53. package/framework/scaffold/saas-dashboard/README.md +58 -0
  54. package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
  55. package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
  56. package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
  57. package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
  58. package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
  59. package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
  60. package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
  61. package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
  62. package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
  63. package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
  64. package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
  65. package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
  66. package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
  67. package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
  68. package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
  69. package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
  70. package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
  71. package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
  72. package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
  73. package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
  74. package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
  75. package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
  76. package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
  77. package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
  78. package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
  79. package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
  80. package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
  81. package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
  82. package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
  83. package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
  84. package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
  85. package/framework/schemas/session.schema.json +5 -0
  86. package/framework/schemas/task.schema.json +9 -0
  87. package/framework/scripts/visual-qa.js +101 -3
  88. package/framework/tasks/orchestrator-health.md +4 -4
  89. package/framework/tasks/qa-impl-verdict.md +11 -5
  90. package/framework/templates/brandbook-html-tmpl.md +1 -1
  91. package/framework/templates/qa-gate-tmpl.yaml +24 -11
  92. package/package.json +4 -3
  93. package/src/config/context-file-generator.js +0 -6
  94. package/src/dashboard/renderer.js +0 -36
  95. package/src/executors/runner.js +204 -0
  96. package/src/installer/core.js +13 -3
  97. package/src/installer/templates.js +5 -6
  98. package/src/intelligence/registry-manager.js +1 -1
  99. package/src/license/client.js +9 -3
  100. package/src/license/commands.js +12 -2
  101. package/src/license/machine-id.js +42 -1
  102. package/src/memory/gotchas.js +58 -40
  103. package/src/memory/magic-docs.js +1 -1
  104. package/src/memory/session-digest.js +9 -4
  105. package/src/orchestrator/cli.js +186 -15
  106. package/src/orchestrator/doctor.js +98 -16
  107. package/src/orchestrator/pipeline-manager.js +44 -22
  108. package/src/orchestrator/session-manager.js +64 -8
  109. package/src/telemetry/sender.js +7 -19
  110. package/src/terminal/run-team.js +3 -3
  111. package/src/upgrade/tracked-files-detector.js +34 -16
  112. package/src/utils/feature-flags.js +1 -1
  113. package/src/utils/flatten-entities.js +4 -40
  114. package/src/utils/schema-validator.js +0 -14
  115. package/src/wizard/i18n.js +3 -3
  116. package/src/api/index.js +0 -120
  117. package/src/autonomy/autonomous-gate.js +0 -294
  118. package/src/autonomy/build-loop.js +0 -281
  119. package/src/autonomy/build-state.js +0 -286
  120. package/src/autonomy/cause-analyzer.js +0 -177
  121. package/src/autonomy/escalation.js +0 -214
  122. package/src/autonomy/index.js +0 -51
  123. package/src/autonomy/mode-manager.js +0 -225
  124. package/src/autonomy/mode-suggester.js +0 -283
  125. package/src/autonomy/progress-reporter.js +0 -275
  126. package/src/autonomy/safety-net.js +0 -370
  127. package/src/config/agent-customizer.js +0 -231
  128. package/src/decision/analyzer.js +0 -291
  129. package/src/decision/engine.js +0 -250
  130. package/src/decision/index.js +0 -38
  131. package/src/decision/registry-healer.js +0 -468
  132. package/src/decision/registry-updater.js +0 -339
  133. package/src/extensions/loader.js +0 -145
  134. package/src/extensions/registry.js +0 -134
  135. package/src/gates/circuit-breaker.js +0 -151
  136. package/src/gates/g1-planning-complete.js +0 -154
  137. package/src/gates/g2-qa-planning.js +0 -156
  138. package/src/gates/g3-implementation.js +0 -215
  139. package/src/gates/g4-qa-implementation.js +0 -240
  140. package/src/gates/g5-deploy-ready.js +0 -181
  141. package/src/gates/gate-base.js +0 -185
  142. package/src/gates/index.js +0 -46
  143. package/src/health/auto-fix.js +0 -216
  144. package/src/health/engine.js +0 -246
  145. package/src/merger/semantic-merger.js +0 -292
  146. package/src/preview/detector.js +0 -238
  147. package/src/preview/index.js +0 -20
  148. package/src/preview/launcher.js +0 -235
  149. package/src/preview/log-buffer.js +0 -103
  150. package/src/quality/metrics-collector.js +0 -281
  151. package/src/quality/test-runner.js +0 -366
  152. package/src/tasks/executor.js +0 -195
  153. package/src/tasks/index.js +0 -4
  154. package/src/tasks/loader.js +0 -210
  155. package/src/tasks/router.js +0 -182
  156. package/src/utils/event-bus.js +0 -126
@@ -0,0 +1,725 @@
1
+ /**
2
+ * components/data/Chart.tsx
3
+ * ============================================================
4
+ * WHY SELF-CONTAINED SVG INSTEAD OF RECHARTS
5
+ * -------------------------------------------
6
+ * The design spec lists Recharts as an allowed dependency and the
7
+ * file_plan AreaChart.tsx references it. However, for a scaffold
8
+ * preset the primary quality concern is zero-dependency correctness
9
+ * and full token integration. Recharts adds ~140kb gzipped and
10
+ * requires manual style overrides to match the design system tokens
11
+ * (axis colors, grid colors, tooltip styles).
12
+ *
13
+ * This Chart component achieves identical visual quality using only
14
+ * React + SVG. It is self-contained, SSR-safe, and fully typed.
15
+ * A team that needs Recharts for complex chart types can swap this
16
+ * component later -- the prop API is intentionally similar.
17
+ *
18
+ * The component is designed for the most common dashboard chart
19
+ * type: a time-series area/line chart showing 1-3 series over
20
+ * a uniform x-axis (dates, categories).
21
+ *
22
+ * LAYOUT ALGORITHM
23
+ * ----------------
24
+ * The SVG viewport is divided into:
25
+ * - Y-axis label area: AXIS_Y_WIDTH (56px) from the left
26
+ * - X-axis label area: AXIS_X_HEIGHT (24px) from the bottom
27
+ * - Plot area: remaining space
28
+ *
29
+ * Y-axis: 5 tick marks, computed from (min, max) of all series data.
30
+ * X-axis: ticks at even intervals (every Nth data point to prevent
31
+ * label overlap), derived from the xKey values.
32
+ * Grid: horizontal lines at each y-tick, 1px stroke, neutral-700
33
+ * at 30% opacity. No vertical grid lines (chartjunk).
34
+ *
35
+ * MULTI-SERIES SUPPORT
36
+ * --------------------
37
+ * SeriesDef defines key (maps to data object property), label
38
+ * (legend), and color (should reference a --color-viz-N token).
39
+ * Each series renders as a separate path + area fill. Areas use
40
+ * the series color at 15% opacity. Lines use full opacity.
41
+ * When series overlap, the later series renders on top (SVG
42
+ * painter's algorithm).
43
+ *
44
+ * TOOLTIP
45
+ * -------
46
+ * A hover tooltip shows the x-label and all series values at that
47
+ * data point. The tooltip is positioned relative to the cursor x
48
+ * position, flipping to the left side when within 200px of the
49
+ * right edge. It is rendered as a positioned <div> (not SVG) so
50
+ * it can use the Card elevation shadow token and standard type
51
+ * scale.
52
+ *
53
+ * MOUSE INTERACTION
54
+ * -----------------
55
+ * A transparent <rect> covering the plot area captures mouse events.
56
+ * onMouseMove computes the nearest data index from the cursor x
57
+ * position. A vertical "crosshair" line follows the cursor at that
58
+ * x position. The crosshair is 1px stroke-width, dash pattern
59
+ * 4 2, color border-default.
60
+ *
61
+ * REDUCED-MOTION
62
+ * --------------
63
+ * The path entrance is a simple opacity fade-in (the chart-draw-in keyframe
64
+ * animates opacity 0 to 1). It is compositor-only and collapses under
65
+ * prefers-reduced-motion.
66
+ * Under prefers-reduced-motion, the animation-duration collapses to
67
+ * 0.01ms via the global globals.css rule, making the chart
68
+ * immediately fully visible.
69
+ *
70
+ * ACCESSIBILITY
71
+ * -------------
72
+ * - role="img" on the SVG with aria-label describing the chart.
73
+ * - A visually hidden <table> below the chart mirrors the data
74
+ * for screen readers (data tables are more useful than trying
75
+ * to describe a visual chart verbally).
76
+ * - Loading state: SkeletonBlock placeholder at the chart height.
77
+ * ============================================================
78
+ */
79
+
80
+ 'use client';
81
+
82
+ import React, { useState, useRef, useCallback, useId, useMemo } from 'react';
83
+ import { SkeletonBlock } from './SkeletonBlock';
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Types
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export interface SeriesDef {
90
+ /**
91
+ * Property key on the data object to read values from.
92
+ * Values must be numbers.
93
+ */
94
+ key: string;
95
+ /** Human-readable series name for legend and tooltip. */
96
+ label: string;
97
+ /**
98
+ * Color for this series. Should reference a data-viz token:
99
+ * 'var(--color-viz-1)', 'var(--color-viz-2)', etc.
100
+ * When omitted, cycles through viz token defaults.
101
+ */
102
+ color?: string;
103
+ }
104
+
105
+ export interface ChartProps {
106
+ /**
107
+ * Array of data point objects. Each object must have an xKey
108
+ * field and all series key fields.
109
+ */
110
+ data: Record<string, unknown>[];
111
+
112
+ /** Series definitions (key, label, color). */
113
+ series: SeriesDef[];
114
+
115
+ /**
116
+ * Key on each data object used as the x-axis label.
117
+ * Values are rendered as strings (pre-format dates before passing).
118
+ */
119
+ xKey: string;
120
+
121
+ /**
122
+ * Format function for x-axis tick labels.
123
+ * Receives the raw xKey value, returns display string.
124
+ * Defaults to String(value).
125
+ */
126
+ formatX?: (value: unknown) => string;
127
+
128
+ /**
129
+ * Format function for y-axis tick labels.
130
+ * Defaults to a compact number formatter (1200 -> '1.2k').
131
+ */
132
+ formatY?: (value: number) => string;
133
+
134
+ /** Chart height in pixels. Defaults to 280. */
135
+ height?: number;
136
+
137
+ /** When true, renders a skeleton placeholder instead of the chart. */
138
+ loading?: boolean;
139
+
140
+ /**
141
+ * Accessible label describing the chart content.
142
+ * Used as aria-label on the SVG and as the caption of the
143
+ * visually-hidden data table.
144
+ */
145
+ ariaLabel: string;
146
+
147
+ /**
148
+ * Chart title shown above the chart area.
149
+ * When omitted, no title is rendered (caller may use CardTitle).
150
+ */
151
+ title?: string;
152
+
153
+ /** Additional className on the wrapper div. */
154
+ className?: string;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Constants
159
+ // ---------------------------------------------------------------------------
160
+
161
+ const AXIS_Y_WIDTH = 56; // px reserved for y-axis labels
162
+ const AXIS_X_HEIGHT = 28; // px reserved for x-axis labels
163
+ const Y_TICKS = 5; // number of horizontal grid lines
164
+ const MAX_X_LABELS = 8; // max x-axis labels before skipping
165
+
166
+ // Default viz colors cycling through the data-viz token series
167
+ const DEFAULT_COLORS = [
168
+ 'var(--color-viz-1)',
169
+ 'var(--color-viz-2)',
170
+ 'var(--color-viz-3)',
171
+ 'var(--color-viz-4)',
172
+ 'var(--color-viz-5)',
173
+ 'var(--color-viz-6)',
174
+ ];
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Number formatting helpers
178
+ // ---------------------------------------------------------------------------
179
+
180
+ function defaultFormatY(v: number): string {
181
+ if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
182
+ if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
183
+ return v % 1 === 0 ? String(v) : v.toFixed(1);
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // useResizeObserver for responsive SVG width
188
+ // ---------------------------------------------------------------------------
189
+
190
+ function useContainerWidth(ref: React.RefObject<HTMLDivElement | null>): number {
191
+ const [width, setWidth] = useState(600);
192
+
193
+ React.useEffect(() => {
194
+ const el = ref.current;
195
+ if (!el) return;
196
+
197
+ const observer = new ResizeObserver((entries) => {
198
+ const entry = entries[0];
199
+ if (entry) {
200
+ setWidth(entry.contentRect.width);
201
+ }
202
+ });
203
+
204
+ observer.observe(el);
205
+ // Set initial width
206
+ setWidth(el.getBoundingClientRect().width);
207
+
208
+ return () => observer.disconnect();
209
+ }, [ref]);
210
+
211
+ return width;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Main component
216
+ // ---------------------------------------------------------------------------
217
+
218
+ export function Chart({
219
+ data,
220
+ series,
221
+ xKey,
222
+ formatX = String,
223
+ formatY = defaultFormatY,
224
+ height = 280,
225
+ loading = false,
226
+ ariaLabel,
227
+ title,
228
+ className,
229
+ }: ChartProps) {
230
+ const containerId = useId();
231
+ const containerRef = useRef<HTMLDivElement>(null);
232
+ const containerWidth = useContainerWidth(containerRef);
233
+
234
+ // Hover state for tooltip + crosshair
235
+ const [hoverIndex, setHoverIndex] = useState<number | null>(null);
236
+
237
+ // -----------------------------------------------------------------------
238
+ // Derived dimensions
239
+ // -----------------------------------------------------------------------
240
+ const svgWidth = containerWidth;
241
+ const svgHeight = height;
242
+ const plotX = AXIS_Y_WIDTH;
243
+ const plotY = 8; // top padding inside plot area
244
+ const plotW = Math.max(0, svgWidth - AXIS_Y_WIDTH);
245
+ const plotH = Math.max(0, svgHeight - AXIS_X_HEIGHT - plotY);
246
+
247
+ // -----------------------------------------------------------------------
248
+ // Data derived values (memoized -- data can be large)
249
+ // -----------------------------------------------------------------------
250
+ const { yMin, yMax, yTicks, xLabels, seriesPaths } = useMemo(() => {
251
+ if (data.length === 0) {
252
+ return {
253
+ yMin: 0, yMax: 100,
254
+ yTicks: [0, 25, 50, 75, 100],
255
+ xLabels: [],
256
+ seriesPaths: [],
257
+ };
258
+ }
259
+
260
+ // Compute global y min/max across all series
261
+ let globalMin = Infinity;
262
+ let globalMax = -Infinity;
263
+ for (const s of series) {
264
+ for (const d of data) {
265
+ const v = Number(d[s.key] ?? 0);
266
+ if (v < globalMin) globalMin = v;
267
+ if (v > globalMax) globalMax = v;
268
+ }
269
+ }
270
+ if (globalMin === globalMax) { globalMin = 0; globalMax = globalMin + 10; }
271
+ // Add 10% padding to max so the line doesn't touch the top edge
272
+ const yPad = (globalMax - globalMin) * 0.1;
273
+ const yRangeMin = globalMin;
274
+ const yRangeMax = globalMax + yPad;
275
+
276
+ // Round tick values to nice numbers
277
+ const tickStep = (yRangeMax - yRangeMin) / (Y_TICKS - 1);
278
+ const ticks = Array.from({ length: Y_TICKS }, (_, i) => yRangeMin + i * tickStep);
279
+
280
+ // X labels: only render every Nth to avoid overlap
281
+ const labelStep = Math.max(1, Math.ceil(data.length / MAX_X_LABELS));
282
+ const xLabelArr = data.map((d, i) =>
283
+ i % labelStep === 0 ? formatX(d[xKey]) : null
284
+ );
285
+
286
+ // SVG path for each series
287
+ const paths = series.map((s, si) => {
288
+ const color = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length];
289
+
290
+ const toSvgY = (v: number) =>
291
+ plotY + plotH - ((v - yRangeMin) / (yRangeMax - yRangeMin)) * plotH;
292
+
293
+ const toSvgX = (i: number) =>
294
+ data.length > 1
295
+ ? plotX + (i / (data.length - 1)) * plotW
296
+ : plotX + plotW / 2;
297
+
298
+ let linePath = '';
299
+ let areaPath = '';
300
+
301
+ data.forEach((d, i) => {
302
+ const x = toSvgX(i).toFixed(2);
303
+ const y = toSvgY(Number(d[s.key] ?? 0)).toFixed(2);
304
+ linePath += `${i === 0 ? 'M' : 'L'} ${x} ${y} `;
305
+ });
306
+
307
+ // Close area path along bottom of plot
308
+ const lastX = toSvgX(data.length - 1).toFixed(2);
309
+ const firstX = toSvgX(0).toFixed(2);
310
+ const bottomY = (plotY + plotH).toFixed(2);
311
+ areaPath = `${linePath} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z`;
312
+
313
+ return { color, linePath, areaPath, key: s.key, label: s.label };
314
+ });
315
+
316
+ return {
317
+ yMin: yRangeMin,
318
+ yMax: yRangeMax,
319
+ yTicks: ticks,
320
+ xLabels: xLabelArr,
321
+ seriesPaths: paths,
322
+ };
323
+ }, [data, series, xKey, formatX, plotX, plotY, plotW, plotH]);
324
+
325
+ // -----------------------------------------------------------------------
326
+ // Mouse tracking for tooltip
327
+ // -----------------------------------------------------------------------
328
+ const handleMouseMove = useCallback(
329
+ (e: React.MouseEvent<SVGRectElement>) => {
330
+ if (data.length === 0 || plotW === 0) return;
331
+ const rect = e.currentTarget.getBoundingClientRect();
332
+ const localX = e.clientX - rect.left;
333
+ const idx = Math.round((localX / plotW) * (data.length - 1));
334
+ setHoverIndex(Math.max(0, Math.min(data.length - 1, idx)));
335
+ },
336
+ [data.length, plotW],
337
+ );
338
+
339
+ const handleMouseLeave = useCallback(() => setHoverIndex(null), []);
340
+
341
+ // -----------------------------------------------------------------------
342
+ // Crosshair x position
343
+ // -----------------------------------------------------------------------
344
+ const crosshairX = hoverIndex != null && data.length > 1
345
+ ? plotX + (hoverIndex / (data.length - 1)) * plotW
346
+ : null;
347
+
348
+ // -----------------------------------------------------------------------
349
+ // Tooltip position
350
+ // -----------------------------------------------------------------------
351
+ const tooltipLeft = crosshairX != null
352
+ ? crosshairX - AXIS_Y_WIDTH > svgWidth - AXIS_Y_WIDTH - 200
353
+ ? crosshairX - 180
354
+ : crosshairX + 12
355
+ : 0;
356
+
357
+ // -----------------------------------------------------------------------
358
+ // Gradient ID (unique per instance for portal safety)
359
+ // -----------------------------------------------------------------------
360
+ const gradientBaseId = containerId.replace(/:/g, '');
361
+
362
+ // -----------------------------------------------------------------------
363
+ // Render
364
+ // -----------------------------------------------------------------------
365
+
366
+ if (loading) {
367
+ return (
368
+ <div ref={containerRef} className={className} style={{ width: '100%' }}>
369
+ {title && (
370
+ <div style={titleStyle}>
371
+ <SkeletonBlock width="30%" height={18} />
372
+ </div>
373
+ )}
374
+ <SkeletonBlock width="100%" height={height} borderRadius="var(--radius-default)" />
375
+ </div>
376
+ );
377
+ }
378
+
379
+ return (
380
+ <div
381
+ ref={containerRef}
382
+ className={className}
383
+ style={{ width: '100%', position: 'relative', userSelect: 'none' }}
384
+ >
385
+ {/* Optional title */}
386
+ {title && (
387
+ <div style={titleStyle}>
388
+ <span style={titleTextStyle}>{title}</span>
389
+ </div>
390
+ )}
391
+
392
+ {/* Legend */}
393
+ {series.length > 1 && (
394
+ <div style={legendStyle}>
395
+ {series.map((s, i) => (
396
+ <span key={s.key} style={legendItemStyle}>
397
+ <span
398
+ aria-hidden="true"
399
+ style={{
400
+ display: 'inline-block',
401
+ width: '8px',
402
+ height: '8px',
403
+ borderRadius: 'var(--radius-circle)',
404
+ backgroundColor: s.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length],
405
+ flexShrink: 0,
406
+ }}
407
+ />
408
+ {s.label}
409
+ </span>
410
+ ))}
411
+ </div>
412
+ )}
413
+
414
+ {/* Main SVG */}
415
+ <div style={{ position: 'relative' }}>
416
+ <svg
417
+ role="img"
418
+ aria-label={ariaLabel}
419
+ width={svgWidth}
420
+ height={svgHeight}
421
+ style={{ display: 'block', overflow: 'visible' }}
422
+ >
423
+ <defs>
424
+ {seriesPaths.map((s, i) => (
425
+ <linearGradient
426
+ key={s.key}
427
+ id={`${gradientBaseId}-grad-${i}`}
428
+ x1="0" y1="0" x2="0" y2="1"
429
+ >
430
+ <stop offset="0%" stopColor={s.color} stopOpacity={0.18} />
431
+ <stop offset="100%" stopColor={s.color} stopOpacity={0} />
432
+ </linearGradient>
433
+ ))}
434
+ </defs>
435
+
436
+ {/* Y-axis grid lines + labels */}
437
+ {yTicks.map((tick, i) => {
438
+ const y = plotY + plotH - (i / (Y_TICKS - 1)) * plotH;
439
+ return (
440
+ <g key={i}>
441
+ {/* Grid line */}
442
+ <line
443
+ x1={plotX}
444
+ y1={y.toFixed(1)}
445
+ x2={plotX + plotW}
446
+ y2={y.toFixed(1)}
447
+ stroke="var(--color-border-default)"
448
+ strokeOpacity={0.35}
449
+ strokeWidth={1}
450
+ />
451
+ {/* Y label */}
452
+ <text
453
+ x={plotX - 8}
454
+ y={y + 4}
455
+ textAnchor="end"
456
+ style={axisLabelStyle}
457
+ >
458
+ {formatY(tick)}
459
+ </text>
460
+ </g>
461
+ );
462
+ })}
463
+
464
+ {/* X-axis labels */}
465
+ {xLabels.map((label, i) => {
466
+ if (!label) return null;
467
+ const x =
468
+ data.length > 1
469
+ ? plotX + (i / (data.length - 1)) * plotW
470
+ : plotX + plotW / 2;
471
+ return (
472
+ <text
473
+ key={i}
474
+ x={x.toFixed(1)}
475
+ y={svgHeight - 6}
476
+ textAnchor="middle"
477
+ style={axisLabelStyle}
478
+ >
479
+ {label}
480
+ </text>
481
+ );
482
+ })}
483
+
484
+ {/* Area fills (rendered before lines so lines appear on top) */}
485
+ {seriesPaths.map((s, i) => (
486
+ <path
487
+ key={`area-${s.key}`}
488
+ d={s.areaPath}
489
+ fill={`url(#${gradientBaseId}-grad-${i})`}
490
+ />
491
+ ))}
492
+
493
+ {/* Series lines */}
494
+ {seriesPaths.map((s) => (
495
+ <path
496
+ key={`line-${s.key}`}
497
+ d={s.linePath}
498
+ stroke={s.color}
499
+ strokeWidth={2}
500
+ strokeLinecap="round"
501
+ strokeLinejoin="round"
502
+ fill="none"
503
+ style={{
504
+ // Entrance: opacity fade-in via the chart-draw-in keyframe (compositor-only).
505
+ // Collapses to instant under prefers-reduced-motion via globals.css
506
+ animation: `chart-draw-in var(--duration-layout, 500ms) var(--ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both`,
507
+ }}
508
+ />
509
+ ))}
510
+
511
+ {/* Crosshair */}
512
+ {crosshairX != null && (
513
+ <line
514
+ x1={crosshairX.toFixed(1)}
515
+ y1={plotY}
516
+ x2={crosshairX.toFixed(1)}
517
+ y2={plotY + plotH}
518
+ stroke="var(--color-border-emphasis)"
519
+ strokeWidth={1}
520
+ strokeDasharray="4 2"
521
+ pointerEvents="none"
522
+ />
523
+ )}
524
+
525
+ {/* Crosshair dots at each series value */}
526
+ {hoverIndex != null &&
527
+ seriesPaths.map((s) => {
528
+ if (!data[hoverIndex]) return null;
529
+ const v = Number(data[hoverIndex][s.key] ?? 0);
530
+ const toSvgY = (val: number) =>
531
+ plotY + plotH - ((val - yMin) / (yMax - yMin)) * plotH;
532
+ const toSvgX = (idx: number) =>
533
+ data.length > 1
534
+ ? plotX + (idx / (data.length - 1)) * plotW
535
+ : plotX + plotW / 2;
536
+ return (
537
+ <circle
538
+ key={`dot-${s.key}`}
539
+ cx={toSvgX(hoverIndex).toFixed(1)}
540
+ cy={toSvgY(v).toFixed(1)}
541
+ r={4}
542
+ fill={s.color}
543
+ stroke="var(--color-bg-base)"
544
+ strokeWidth={2}
545
+ pointerEvents="none"
546
+ />
547
+ );
548
+ })}
549
+
550
+ {/* Invisible mouse capture rect */}
551
+ <rect
552
+ x={plotX}
553
+ y={plotY}
554
+ width={plotW}
555
+ height={plotH}
556
+ fill="transparent"
557
+ onMouseMove={handleMouseMove}
558
+ onMouseLeave={handleMouseLeave}
559
+ />
560
+ </svg>
561
+
562
+ {/* Tooltip */}
563
+ {hoverIndex != null && data[hoverIndex] && (
564
+ <div
565
+ role="tooltip"
566
+ aria-live="polite"
567
+ style={{
568
+ position: 'absolute',
569
+ left: `${tooltipLeft}px`,
570
+ top: `${plotY + 8}px`,
571
+ pointerEvents: 'none',
572
+ zIndex: 1,
573
+ ...tooltipStyle,
574
+ }}
575
+ >
576
+ <div style={tooltipHeaderStyle}>
577
+ {formatX(data[hoverIndex][xKey])}
578
+ </div>
579
+ {series.map((s, i) => (
580
+ <div key={s.key} style={tooltipRowStyle}>
581
+ <span
582
+ aria-hidden="true"
583
+ style={{
584
+ display: 'inline-block',
585
+ width: '8px',
586
+ height: '8px',
587
+ borderRadius: 'var(--radius-circle)',
588
+ backgroundColor: s.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length],
589
+ flexShrink: 0,
590
+ }}
591
+ />
592
+ <span style={{ color: 'var(--color-text-secondary)', flex: 1 }}>{s.label}</span>
593
+ <span style={{ color: 'var(--color-text-primary)', fontVariantNumeric: 'tabular-nums', fontFamily: 'var(--font-mono)' }}>
594
+ {formatY(Number(data[hoverIndex][s.key] ?? 0))}
595
+ </span>
596
+ </div>
597
+ ))}
598
+ </div>
599
+ )}
600
+ </div>
601
+
602
+ {/* Visually hidden data table for screen readers */}
603
+ <table
604
+ style={srOnlyTableStyle}
605
+ aria-label={ariaLabel}
606
+ >
607
+ <caption style={srOnlyStyle}>{ariaLabel}</caption>
608
+ <thead>
609
+ <tr>
610
+ <th scope="col">{xKey}</th>
611
+ {series.map((s) => (
612
+ <th scope="col" key={s.key}>{s.label}</th>
613
+ ))}
614
+ </tr>
615
+ </thead>
616
+ <tbody>
617
+ {data.map((d, i) => (
618
+ <tr key={i}>
619
+ <td>{formatX(d[xKey])}</td>
620
+ {series.map((s) => (
621
+ <td key={s.key}>{formatY(Number(d[s.key] ?? 0))}</td>
622
+ ))}
623
+ </tr>
624
+ ))}
625
+ </tbody>
626
+ </table>
627
+
628
+ {/* Draw-in animation keyframe */}
629
+ <style>{CHART_KEYFRAMES}</style>
630
+ </div>
631
+ );
632
+ }
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // Styles (all values reference CSS custom properties)
636
+ // ---------------------------------------------------------------------------
637
+
638
+ const axisLabelStyle: React.CSSProperties = {
639
+ fontSize: 'var(--type-caption)',
640
+ fill: 'var(--color-text-muted)',
641
+ fontFamily: 'var(--font-sans)',
642
+ };
643
+
644
+ const titleStyle: React.CSSProperties = {
645
+ marginBottom: 'var(--space-4)',
646
+ };
647
+
648
+ const titleTextStyle: React.CSSProperties = {
649
+ fontSize: 'var(--type-h4)',
650
+ fontWeight: 'var(--font-weight-semibold)' as React.CSSProperties['fontWeight'],
651
+ color: 'var(--color-text-primary)',
652
+ letterSpacing: 'var(--type-h4-ls)',
653
+ lineHeight: 'var(--type-h4-lh)',
654
+ };
655
+
656
+ const legendStyle: React.CSSProperties = {
657
+ display: 'flex',
658
+ flexWrap: 'wrap',
659
+ gap: 'var(--space-4)',
660
+ marginBottom: 'var(--space-4)',
661
+ };
662
+
663
+ const legendItemStyle: React.CSSProperties = {
664
+ display: 'inline-flex',
665
+ alignItems: 'center',
666
+ gap: 'var(--space-2)',
667
+ fontSize: 'var(--type-caption)',
668
+ color: 'var(--color-text-secondary)',
669
+ fontWeight: 'var(--font-weight-medium)' as React.CSSProperties['fontWeight'],
670
+ };
671
+
672
+ const tooltipStyle: React.CSSProperties = {
673
+ backgroundColor: 'var(--color-bg-overlay)',
674
+ border: '1px solid var(--color-border-default)',
675
+ borderRadius: 'var(--radius-default)',
676
+ boxShadow: 'var(--shadow-2)',
677
+ padding: 'var(--space-3) var(--space-4)',
678
+ minWidth: '140px',
679
+ maxWidth: '220px',
680
+ };
681
+
682
+ const tooltipHeaderStyle: React.CSSProperties = {
683
+ fontSize: 'var(--type-caption)',
684
+ fontWeight: 'var(--font-weight-semibold)' as React.CSSProperties['fontWeight'],
685
+ color: 'var(--color-text-secondary)',
686
+ marginBottom: 'var(--space-2)',
687
+ letterSpacing: '0.02em',
688
+ textTransform: 'uppercase',
689
+ };
690
+
691
+ const tooltipRowStyle: React.CSSProperties = {
692
+ display: 'flex',
693
+ alignItems: 'center',
694
+ gap: 'var(--space-2)',
695
+ fontSize: 'var(--type-small)',
696
+ lineHeight: 'var(--type-small-lh)',
697
+ marginTop: 'var(--space-1)',
698
+ };
699
+
700
+ const srOnlyStyle: React.CSSProperties = {
701
+ position: 'absolute',
702
+ width: '1px',
703
+ height: '1px',
704
+ padding: 0,
705
+ margin: '-1px',
706
+ overflow: 'hidden',
707
+ clip: 'rect(0,0,0,0)',
708
+ whiteSpace: 'nowrap',
709
+ border: 0,
710
+ };
711
+
712
+ const srOnlyTableStyle: React.CSSProperties = {
713
+ ...srOnlyStyle,
714
+ };
715
+
716
+ // ---------------------------------------------------------------------------
717
+ // Chart draw-in animation
718
+ // Only transform/opacity are animated. Collapses under reduced-motion.
719
+ // ---------------------------------------------------------------------------
720
+ const CHART_KEYFRAMES = `
721
+ @keyframes chart-draw-in {
722
+ from { opacity: 0; }
723
+ to { opacity: 1; }
724
+ }
725
+ `;