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,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
+ `;