chati-dev 4.3.1 → 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 (147) hide show
  1. package/README.md +18 -13
  2. package/bin/chati.js +12 -4
  3. package/framework/agents/build/dev.md +5 -1
  4. package/framework/agents/discover/greenfield-wu.md +16 -1
  5. package/framework/agents/plan/tasks.md +31 -0
  6. package/framework/agents/plan/ux-brand-architect.md +21 -2
  7. package/framework/agents/plan/ux-component-engineer.md +10 -0
  8. package/framework/agents/quality/qa-implementation.md +1 -1
  9. package/framework/agents/quality/qa-planning.md +1 -1
  10. package/framework/agents/quality/qa-visual.md +24 -21
  11. package/framework/config.yaml +5 -4
  12. package/framework/constitution.md +58 -11
  13. package/framework/context/governance.md +12 -10
  14. package/framework/context/root.md +2 -2
  15. package/framework/data/entity-registry.yaml +12 -4
  16. package/framework/data/qa-rubrics.yaml +335 -0
  17. package/framework/domains/constitution.yaml +3 -3
  18. package/framework/executors/json-validate.js +80 -0
  19. package/framework/executors/npm-script.js +72 -0
  20. package/framework/executors/path-check.js +67 -0
  21. package/framework/executors/registry.yaml +36 -0
  22. package/framework/hooks/advance-trigger.js +47 -3
  23. package/framework/hooks/constitution-guard.js +9 -4
  24. package/framework/hooks/git-push-authority.js +113 -0
  25. package/framework/hooks/license-guard.js +59 -9
  26. package/framework/hooks/model-governance.js +40 -17
  27. package/framework/hooks/prism-engine.js +78 -11
  28. package/framework/hooks/session-digest.js +12 -3
  29. package/framework/hooks/settings.json +7 -3
  30. package/framework/hooks/style-guard.js +52 -3
  31. package/framework/hooks/team-quality-gate.js +43 -5
  32. package/framework/i18n/en.yaml +3 -3
  33. package/framework/i18n/es.yaml +3 -3
  34. package/framework/i18n/fr.yaml +3 -3
  35. package/framework/i18n/pt.yaml +3 -3
  36. package/framework/intelligence/confidence.yaml +85 -6
  37. package/framework/intelligence/context-engine.md +9 -5
  38. package/framework/intelligence/decision-engine.md +1 -1
  39. package/framework/orchestrator/chati-router.js +1 -1
  40. package/framework/orchestrator/chati-update.md +19 -3
  41. package/framework/orchestrator/chati.md +19 -1
  42. package/framework/quality-gates/planning-gate.md +2 -0
  43. package/framework/scaffold/motion-premium/scaffold.yaml +2 -1
  44. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
  45. package/framework/scaffold/saas-dashboard/README.md +58 -0
  46. package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
  47. package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
  48. package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
  49. package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
  50. package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
  51. package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
  52. package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
  53. package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
  54. package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
  55. package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
  56. package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
  57. package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
  58. package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
  59. package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
  60. package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
  61. package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
  62. package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
  63. package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
  64. package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
  65. package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
  66. package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
  67. package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
  68. package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
  69. package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
  70. package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
  71. package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
  72. package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
  73. package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
  74. package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
  75. package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
  76. package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
  77. package/framework/schemas/session.schema.json +5 -0
  78. package/framework/schemas/task.schema.json +9 -0
  79. package/framework/scripts/visual-qa.js +87 -1
  80. package/framework/tasks/orchestrator-health.md +4 -4
  81. package/framework/tasks/qa-impl-verdict.md +11 -5
  82. package/framework/templates/qa-gate-tmpl.yaml +24 -11
  83. package/package.json +3 -3
  84. package/src/config/context-file-generator.js +0 -6
  85. package/src/dashboard/renderer.js +0 -36
  86. package/src/executors/runner.js +204 -0
  87. package/src/installer/core.js +2 -1
  88. package/src/installer/templates.js +5 -6
  89. package/src/intelligence/registry-manager.js +1 -1
  90. package/src/license/client.js +9 -3
  91. package/src/license/commands.js +12 -2
  92. package/src/license/machine-id.js +42 -1
  93. package/src/memory/gotchas.js +58 -40
  94. package/src/memory/magic-docs.js +1 -1
  95. package/src/memory/session-digest.js +9 -4
  96. package/src/orchestrator/cli.js +174 -14
  97. package/src/orchestrator/doctor.js +98 -16
  98. package/src/orchestrator/pipeline-manager.js +44 -22
  99. package/src/orchestrator/session-manager.js +56 -8
  100. package/src/telemetry/sender.js +7 -19
  101. package/src/terminal/run-team.js +3 -3
  102. package/src/upgrade/tracked-files-detector.js +34 -16
  103. package/src/utils/feature-flags.js +1 -1
  104. package/src/utils/flatten-entities.js +4 -40
  105. package/src/utils/schema-validator.js +0 -14
  106. package/src/wizard/i18n.js +3 -3
  107. package/src/api/index.js +0 -120
  108. package/src/autonomy/autonomous-gate.js +0 -294
  109. package/src/autonomy/build-loop.js +0 -281
  110. package/src/autonomy/build-state.js +0 -286
  111. package/src/autonomy/cause-analyzer.js +0 -177
  112. package/src/autonomy/escalation.js +0 -214
  113. package/src/autonomy/index.js +0 -51
  114. package/src/autonomy/mode-manager.js +0 -225
  115. package/src/autonomy/mode-suggester.js +0 -283
  116. package/src/autonomy/progress-reporter.js +0 -275
  117. package/src/autonomy/safety-net.js +0 -370
  118. package/src/config/agent-customizer.js +0 -231
  119. package/src/decision/analyzer.js +0 -291
  120. package/src/decision/engine.js +0 -250
  121. package/src/decision/index.js +0 -38
  122. package/src/decision/registry-healer.js +0 -468
  123. package/src/decision/registry-updater.js +0 -339
  124. package/src/extensions/loader.js +0 -145
  125. package/src/extensions/registry.js +0 -134
  126. package/src/gates/circuit-breaker.js +0 -151
  127. package/src/gates/g1-planning-complete.js +0 -154
  128. package/src/gates/g2-qa-planning.js +0 -156
  129. package/src/gates/g3-implementation.js +0 -215
  130. package/src/gates/g4-qa-implementation.js +0 -240
  131. package/src/gates/g5-deploy-ready.js +0 -181
  132. package/src/gates/gate-base.js +0 -185
  133. package/src/gates/index.js +0 -46
  134. package/src/health/auto-fix.js +0 -216
  135. package/src/health/engine.js +0 -246
  136. package/src/merger/semantic-merger.js +0 -292
  137. package/src/preview/detector.js +0 -238
  138. package/src/preview/index.js +0 -20
  139. package/src/preview/launcher.js +0 -235
  140. package/src/preview/log-buffer.js +0 -103
  141. package/src/quality/metrics-collector.js +0 -281
  142. package/src/quality/test-runner.js +0 -366
  143. package/src/tasks/executor.js +0 -195
  144. package/src/tasks/index.js +0 -4
  145. package/src/tasks/loader.js +0 -210
  146. package/src/tasks/router.js +0 -182
  147. package/src/utils/event-bus.js +0 -126
@@ -0,0 +1,393 @@
1
+ /**
2
+ * components/data/StatCard.tsx
3
+ * ============================================================
4
+ * WHY THIS COMPONENT EXISTS
5
+ * -------------------------
6
+ * The KPI stat card is the single most-viewed element on any
7
+ * dashboard. It sets the visual tone for the entire product.
8
+ * A well-crafted StatCard communicates:
9
+ * 1. The metric name (label)
10
+ * 2. The current value (large, high-contrast, legible at a glance)
11
+ * 3. The direction of change (trend badge with semantic color)
12
+ * 4. The recent trajectory (sparkline)
13
+ *
14
+ * Building this as a shared primitive ensures every metric in the
15
+ * product uses identical visual weight, consistent delta semantics
16
+ * (green up / red down vs. red up / green down for different
17
+ * metric types), and the same loading skeleton.
18
+ *
19
+ * TYPOGRAPHY DECISIONS
20
+ * --------------------
21
+ * Value: --type-h1 (clamp 1.5rem -> 2.25rem), font-weight bold,
22
+ * letter-spacing --type-h1-ls (-0.025em). At dashboard scale,
23
+ * the value competes with chart axes and table numbers for
24
+ * attention, so it needs display weight, not just size.
25
+ *
26
+ * Label: --type-caption, font-weight semibold, letter-spacing
27
+ * 0.04em, uppercase. The micro-label treatment creates clear
28
+ * visual hierarchy without needing a divider.
29
+ *
30
+ * DELTA BADGE DESIGN
31
+ * ------------------
32
+ * The delta is rendered as an inline badge (pill shape, --radius-pill)
33
+ * using the semantic status token pairs:
34
+ * success: --color-success / --color-success-subtle
35
+ * danger: --color-danger / --color-danger-subtle
36
+ * neutral: --color-text-muted / --color-bg-sunken
37
+ *
38
+ * The `trend` prop is separate from the delta calculation because
39
+ * some metrics are inverse (a lower bounce rate is good -- trend
40
+ * is 'up' for improvement even though the number went down). The
41
+ * parent computes trend from business semantics; StatCard only
42
+ * renders it.
43
+ *
44
+ * SPARKLINE DESIGN
45
+ * ----------------
46
+ * The sparkline is a 60x24 SVG path with no axes, no labels, and
47
+ * no interaction. Its sole purpose is to show the shape of the
48
+ * recent time series so the viewer can assess trend direction
49
+ * in a peripheral glance. The area fill uses the accent color at
50
+ * 15% opacity to tie it to the brand without overpowering the value.
51
+ *
52
+ * The SVG path is computed by normalizing the data array to the
53
+ * 0-24px height range, then constructing a smooth polyline using
54
+ * simple lineTo commands. A smooth cubic Bezier would look nicer
55
+ * but adds complexity without meaningful quality gain at 60x24px.
56
+ *
57
+ * MOTION
58
+ * ------
59
+ * No entry animation on StatCard itself -- the StatCardGrid handles
60
+ * stagger via CSS animation-delay on nth-child.
61
+ * The loading skeleton uses SkeletonBlock shimmer.
62
+ * ============================================================
63
+ */
64
+
65
+ 'use client';
66
+
67
+ import React from 'react';
68
+ import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
69
+ import { SkeletonBlock } from './SkeletonBlock';
70
+
71
+ export interface StatCardProps {
72
+ /** The metric name displayed above the value. Always visible. */
73
+ label: string;
74
+
75
+ /**
76
+ * The current metric value as a formatted string.
77
+ * Pre-format before passing: '$12,400', '94.2%', '1.2k'
78
+ */
79
+ value: string;
80
+
81
+ /**
82
+ * The previous period value as a formatted string.
83
+ * When provided alongside `delta`, both are shown in the badge.
84
+ */
85
+ previousValue?: string;
86
+
87
+ /**
88
+ * Pre-formatted delta string: '+12.4%', '-$200', '+0.8pp'.
89
+ * When omitted, no delta badge is rendered.
90
+ */
91
+ delta?: string;
92
+
93
+ /** Unit suffix shown after the value at reduced size: 'users', 'MRR', '/mo'. */
94
+ unit?: string;
95
+
96
+ /** Lucide icon displayed in the card header corner. */
97
+ icon?: React.ReactNode;
98
+
99
+ /**
100
+ * Semantic trend direction. Controls badge color, not computed from delta.
101
+ * Use 'up' for positive outcomes (revenue up = good).
102
+ * Use 'down' for inverse metrics (churn down = good -- pass trend='up').
103
+ * Defaults to 'neutral'.
104
+ */
105
+ trend?: 'up' | 'down' | 'neutral';
106
+
107
+ /** When true, renders skeleton shimmer in place of all content. */
108
+ loading?: boolean;
109
+
110
+ /**
111
+ * Array of data points for the sparkline (last N values, any scale).
112
+ * The component normalizes them to the SVG viewport.
113
+ * Min 2 points required; otherwise sparkline is omitted.
114
+ */
115
+ sparklineData?: number[];
116
+
117
+ /** Additional className on the outer card wrapper. */
118
+ className?: string;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Sparkline SVG
123
+ // ---------------------------------------------------------------------------
124
+
125
+ interface SparklineProps {
126
+ data: number[];
127
+ width?: number;
128
+ height?: number;
129
+ color?: string;
130
+ }
131
+
132
+ function Sparkline({ data, width = 80, height = 28, color = 'var(--color-accent)' }: SparklineProps) {
133
+ if (data.length < 2) return null;
134
+
135
+ const min = Math.min(...data);
136
+ const max = Math.max(...data);
137
+ const range = max - min || 1; // avoid divide-by-zero for flat lines
138
+
139
+ // Normalize y values: flip because SVG y=0 is top
140
+ const normalize = (v: number) => height - ((v - min) / range) * (height - 4) - 2;
141
+
142
+ const step = width / (data.length - 1);
143
+
144
+ // Build path string: M x0 y0 L x1 y1 L x2 y2 ...
145
+ const linePath = data
146
+ .map((v, i) => `${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${normalize(v).toFixed(1)}`)
147
+ .join(' ');
148
+
149
+ // Area fill: close the path along the bottom
150
+ const lastX = ((data.length - 1) * step).toFixed(1);
151
+ const areaPath = `${linePath} L ${lastX} ${height} L 0 ${height} Z`;
152
+
153
+ // Stable across SSR and CSR (a per-render random id would mismatch on
154
+ // hydration). Colons from useId are stripped so the id is safe inside an
155
+ // inline SVG on all browsers.
156
+ const gradientId = `sparkline-gradient-${React.useId().replace(/:/g, '')}`;
157
+
158
+ return (
159
+ <svg
160
+ width={width}
161
+ height={height}
162
+ viewBox={`0 0 ${width} ${height}`}
163
+ fill="none"
164
+ aria-hidden="true"
165
+ style={{ display: 'block', flexShrink: 0 }}
166
+ >
167
+ <defs>
168
+ <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
169
+ <stop offset="0%" stopColor={color} stopOpacity={0.22} />
170
+ <stop offset="100%" stopColor={color} stopOpacity={0} />
171
+ </linearGradient>
172
+ </defs>
173
+ {/* Area fill */}
174
+ <path d={areaPath} fill={`url(#${gradientId})`} />
175
+ {/* Line */}
176
+ <path
177
+ d={linePath}
178
+ stroke={color}
179
+ strokeWidth={1.5}
180
+ strokeLinecap="round"
181
+ strokeLinejoin="round"
182
+ fill="none"
183
+ />
184
+ </svg>
185
+ );
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Delta badge
190
+ // ---------------------------------------------------------------------------
191
+
192
+ interface DeltaBadgeProps {
193
+ delta: string;
194
+ trend: 'up' | 'down' | 'neutral';
195
+ }
196
+
197
+ function DeltaBadge({ delta, trend }: DeltaBadgeProps) {
198
+ const colors = {
199
+ up: {
200
+ bg: 'var(--color-success-subtle)',
201
+ text: 'var(--color-success)',
202
+ },
203
+ down: {
204
+ bg: 'var(--color-danger-subtle)',
205
+ text: 'var(--color-danger)',
206
+ },
207
+ neutral: {
208
+ bg: 'var(--color-bg-sunken)',
209
+ text: 'var(--color-text-muted)',
210
+ },
211
+ };
212
+
213
+ const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus;
214
+
215
+ return (
216
+ <span
217
+ style={{
218
+ display: 'inline-flex',
219
+ alignItems: 'center',
220
+ gap: 'var(--space-1)',
221
+ padding: '2px var(--space-2)',
222
+ borderRadius: 'var(--radius-pill)',
223
+ backgroundColor: colors[trend].bg,
224
+ color: colors[trend].text,
225
+ fontSize: 'var(--type-caption)',
226
+ fontWeight: 'var(--font-weight-medium)' as React.CSSProperties['fontWeight'],
227
+ lineHeight: 'var(--type-caption-lh)',
228
+ letterSpacing: '0.01em',
229
+ transition: `opacity var(--duration-fast) var(--ease-out)`,
230
+ flexShrink: 0,
231
+ }}
232
+ >
233
+ <TrendIcon size={11} aria-hidden="true" />
234
+ {delta}
235
+ </span>
236
+ );
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // StatCard
241
+ // ---------------------------------------------------------------------------
242
+
243
+ export function StatCard({
244
+ label,
245
+ value,
246
+ previousValue,
247
+ delta,
248
+ unit,
249
+ icon,
250
+ trend = 'neutral',
251
+ loading = false,
252
+ sparklineData,
253
+ className,
254
+ }: StatCardProps) {
255
+ return (
256
+ <article
257
+ className={className}
258
+ style={{
259
+ backgroundColor: 'var(--color-bg-raised)',
260
+ border: '1px solid var(--color-border-default)',
261
+ borderRadius: 'var(--radius-lg)',
262
+ padding: 'var(--space-6)',
263
+ boxShadow: 'var(--shadow-1)',
264
+ display: 'flex',
265
+ flexDirection: 'column',
266
+ gap: 'var(--space-4)',
267
+ // Reserve space to prevent layout shift when sparkline loads
268
+ minHeight: 'var(--space-14)',
269
+ position: 'relative',
270
+ overflow: 'hidden',
271
+ }}
272
+ >
273
+ {/* Header row: label + icon */}
274
+ <div
275
+ style={{
276
+ display: 'flex',
277
+ alignItems: 'flex-start',
278
+ justifyContent: 'space-between',
279
+ gap: 'var(--space-3)',
280
+ }}
281
+ >
282
+ <span
283
+ style={{
284
+ fontSize: 'var(--type-caption)',
285
+ fontWeight: 'var(--font-weight-semibold)' as React.CSSProperties['fontWeight'],
286
+ letterSpacing: '0.04em',
287
+ textTransform: 'uppercase',
288
+ color: 'var(--color-text-secondary)',
289
+ lineHeight: 'var(--type-caption-lh)',
290
+ }}
291
+ >
292
+ {loading ? <SkeletonBlock width="60%" height={12} /> : label}
293
+ </span>
294
+
295
+ {icon && !loading && (
296
+ <span
297
+ style={{
298
+ color: 'var(--color-text-muted)',
299
+ flexShrink: 0,
300
+ display: 'flex',
301
+ alignItems: 'center',
302
+ }}
303
+ aria-hidden="true"
304
+ >
305
+ {icon}
306
+ </span>
307
+ )}
308
+ </div>
309
+
310
+ {/* Value row */}
311
+ <div
312
+ style={{
313
+ display: 'flex',
314
+ alignItems: 'flex-end',
315
+ justifyContent: 'space-between',
316
+ gap: 'var(--space-3)',
317
+ }}
318
+ >
319
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
320
+ {loading ? (
321
+ <SkeletonBlock width={120} height={32} />
322
+ ) : (
323
+ <span
324
+ style={{
325
+ fontSize: 'var(--type-h1)',
326
+ fontWeight: 'var(--font-weight-bold)' as React.CSSProperties['fontWeight'],
327
+ letterSpacing: 'var(--type-h1-ls)',
328
+ lineHeight: 'var(--type-h1-lh)',
329
+ color: 'var(--color-text-primary)',
330
+ fontFamily: 'var(--font-mono)', // tabular numerals
331
+ fontVariantNumeric: 'tabular-nums',
332
+ }}
333
+ >
334
+ {value}
335
+ {unit && (
336
+ <span
337
+ style={{
338
+ fontSize: 'var(--type-small)',
339
+ fontWeight: 'var(--font-weight-regular)' as React.CSSProperties['fontWeight'],
340
+ color: 'var(--color-text-muted)',
341
+ marginLeft: 'var(--space-1)',
342
+ fontFamily: 'var(--font-sans)',
343
+ }}
344
+ >
345
+ {unit}
346
+ </span>
347
+ )}
348
+ </span>
349
+ )}
350
+
351
+ {/* Delta badge */}
352
+ {!loading && delta && (
353
+ <div
354
+ style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}
355
+ >
356
+ <DeltaBadge delta={delta} trend={trend} />
357
+ {previousValue && (
358
+ <span
359
+ style={{
360
+ fontSize: 'var(--type-caption)',
361
+ color: 'var(--color-text-muted)',
362
+ }}
363
+ >
364
+ vs {previousValue}
365
+ </span>
366
+ )}
367
+ </div>
368
+ )}
369
+
370
+ {loading && <SkeletonBlock width={64} height={18} borderRadius="var(--radius-pill)" />}
371
+ </div>
372
+
373
+ {/* Sparkline */}
374
+ {!loading && sparklineData && sparklineData.length >= 2 && (
375
+ <Sparkline
376
+ data={sparklineData}
377
+ color={
378
+ trend === 'up'
379
+ ? 'var(--color-success)'
380
+ : trend === 'down'
381
+ ? 'var(--color-danger)'
382
+ : 'var(--color-accent)'
383
+ }
384
+ />
385
+ )}
386
+
387
+ {loading && (
388
+ <SkeletonBlock width={80} height={28} borderRadius="var(--radius-sharp)" />
389
+ )}
390
+ </div>
391
+ </article>
392
+ );
393
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * components/data/StatCardGrid.tsx
3
+ * ============================================================
4
+ * WHY THIS COMPONENT EXISTS
5
+ * -------------------------
6
+ * StatCards are almost always rendered in a row. This grid wrapper
7
+ * provides three things that would otherwise be repeated at every
8
+ * call site:
9
+ *
10
+ * 1. Responsive auto-fit columns: cards reflow from 4-column to
11
+ * 2-column to 1-column automatically based on viewport width,
12
+ * without media query breakpoints that need to be kept in sync
13
+ * across multiple files.
14
+ *
15
+ * 2. Consistent gap between cards (--spacing-card-gap, 20px) that
16
+ * matches the card-to-section spacing rhythm.
17
+ *
18
+ * 3. Entry stagger: each card fades and slides up on mount with a
19
+ * CSS animation-delay calculated from --stagger-base (40ms).
20
+ * CSS-only stagger scales to any number of children and requires
21
+ * zero JavaScript. nth-child selectors set the delay variable;
22
+ * the animation reads it.
23
+ *
24
+ * STAGGER IMPLEMENTATION
25
+ * ----------------------
26
+ * The stagger is implemented via a CSS custom property --stagger-i
27
+ * on each child, set by :nth-child(n) selectors. The StatCard
28
+ * animation uses calc(var(--stagger-i, 0) * var(--stagger-base))
29
+ * as its animation-delay.
30
+ *
31
+ * Why nth-child instead of framer-motion staggerChildren:
32
+ * nth-child requires zero JavaScript and works during SSR, which
33
+ * is critical for a Next.js App Router preset where the first
34
+ * paint should not require hydration. framer-motion stagger would
35
+ * add client-side JS overhead and an invisible-then-visible flash
36
+ * before hydration.
37
+ *
38
+ * Why not inline style stagger from React:
39
+ * The component accepts `children` as ReactNode, so it cannot
40
+ * clone elements to inject style props without casting, which
41
+ * loses type safety and complicates custom child components.
42
+ *
43
+ * REDUCED-MOTION
44
+ * --------------
45
+ * The global globals.css reduced-motion rule collapses all
46
+ * animation-duration to 0.01ms, which effectively makes the
47
+ * stagger instantaneous without removing the visual end state.
48
+ * ============================================================
49
+ */
50
+
51
+ import React from 'react';
52
+
53
+ export interface StatCardGridProps {
54
+ children: React.ReactNode;
55
+ /** Additional className for vertical margin overrides. */
56
+ className?: string;
57
+ }
58
+
59
+ export function StatCardGrid({ children, className }: StatCardGridProps) {
60
+ return (
61
+ <>
62
+ {/* Scoped stagger keyframe + nth-child delay rules */}
63
+ <style>{STAGGER_STYLES}</style>
64
+ <div
65
+ className={[GRID_CLASS, className].filter(Boolean).join(' ')}
66
+ style={gridStyle}
67
+ >
68
+ {children}
69
+ </div>
70
+ </>
71
+ );
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // CSS class name (for global :nth-child selectors to target)
76
+ // ---------------------------------------------------------------------------
77
+ const GRID_CLASS = 'stat-card-grid';
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Grid layout
81
+ // ---------------------------------------------------------------------------
82
+ const gridStyle: React.CSSProperties = {
83
+ display: 'grid',
84
+ // auto-fit: fills the row, then wraps. minmax(240px, 1fr) means
85
+ // 4 columns on 1280px, 2 on 640px, 1 on mobile.
86
+ gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
87
+ gap: 'var(--spacing-card-gap, 20px)',
88
+ };
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Stagger styles injected once via <style> tag
92
+ // ---------------------------------------------------------------------------
93
+ const STAGGER_STYLES = `
94
+ @keyframes stat-card-enter {
95
+ from {
96
+ opacity: 0;
97
+ transform: translateY(8px);
98
+ }
99
+ to {
100
+ opacity: 1;
101
+ transform: translateY(0);
102
+ }
103
+ }
104
+
105
+ /* Each card gets a stagger delay based on its position.
106
+ The animation is 300ms (component duration) with ease-out
107
+ (decelerate on enter -- the card arrives and settles).
108
+ 8px translateY: subtle enough not to distract, enough to
109
+ communicate top-down sequential reveal. */
110
+ .stat-card-grid > * {
111
+ animation: stat-card-enter 300ms cubic-bezier(0.16, 1, 0.3, 1) both;
112
+ }
113
+ .stat-card-grid > *:nth-child(1) { animation-delay: calc(0 * var(--stagger-base, 40ms)); }
114
+ .stat-card-grid > *:nth-child(2) { animation-delay: calc(1 * var(--stagger-base, 40ms)); }
115
+ .stat-card-grid > *:nth-child(3) { animation-delay: calc(2 * var(--stagger-base, 40ms)); }
116
+ .stat-card-grid > *:nth-child(4) { animation-delay: calc(3 * var(--stagger-base, 40ms)); }
117
+ .stat-card-grid > *:nth-child(5) { animation-delay: calc(4 * var(--stagger-base, 40ms)); }
118
+ .stat-card-grid > *:nth-child(6) { animation-delay: calc(5 * var(--stagger-base, 40ms)); }
119
+ .stat-card-grid > *:nth-child(7) { animation-delay: calc(6 * var(--stagger-base, 40ms)); }
120
+ .stat-card-grid > *:nth-child(8) { animation-delay: calc(7 * var(--stagger-base, 40ms)); }
121
+ `;