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.
- package/README.md +18 -13
- package/bin/chati.js +32 -4
- package/framework/agents/build/dev.md +9 -5
- package/framework/agents/discover/brief.md +2 -0
- package/framework/agents/discover/brownfield-wu.md +2 -0
- package/framework/agents/discover/greenfield-wu.md +18 -1
- package/framework/agents/plan/detail.md +2 -0
- package/framework/agents/plan/tasks.md +31 -0
- package/framework/agents/plan/ux-brand-architect.md +21 -2
- package/framework/agents/plan/ux-component-engineer.md +10 -0
- package/framework/agents/quality/qa-implementation.md +1 -1
- package/framework/agents/quality/qa-planning.md +1 -1
- package/framework/agents/quality/qa-visual.md +33 -21
- package/framework/config.yaml +5 -4
- package/framework/constitution.md +58 -11
- package/framework/context/governance.md +12 -10
- package/framework/context/root.md +2 -2
- package/framework/data/entity-registry.yaml +12 -4
- package/framework/data/qa-rubrics.yaml +335 -0
- package/framework/domains/constitution.yaml +3 -3
- package/framework/executors/json-validate.js +80 -0
- package/framework/executors/npm-script.js +72 -0
- package/framework/executors/path-check.js +67 -0
- package/framework/executors/registry.yaml +36 -0
- package/framework/hooks/advance-trigger.js +47 -3
- package/framework/hooks/constitution-guard.js +9 -4
- package/framework/hooks/git-push-authority.js +113 -0
- package/framework/hooks/license-guard.js +59 -9
- package/framework/hooks/model-governance.js +40 -17
- package/framework/hooks/prism-engine.js +78 -11
- package/framework/hooks/session-digest.js +12 -3
- package/framework/hooks/settings.json +7 -3
- package/framework/hooks/style-guard.js +52 -3
- package/framework/hooks/team-quality-gate.js +43 -5
- package/framework/i18n/en.yaml +3 -3
- package/framework/i18n/es.yaml +3 -3
- package/framework/i18n/fr.yaml +3 -3
- package/framework/i18n/pt.yaml +3 -3
- package/framework/intelligence/confidence.yaml +85 -6
- package/framework/intelligence/context-engine.md +9 -5
- package/framework/intelligence/decision-engine.md +1 -1
- package/framework/orchestrator/chati-router.js +59 -5
- package/framework/orchestrator/chati-update.md +19 -3
- package/framework/orchestrator/chati.md +20 -1
- package/framework/quality-gates/planning-gate.md +2 -0
- package/framework/rules/human-writing-style.md +47 -0
- package/framework/scaffold/motion-premium/README.md +12 -2
- package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +1 -1
- package/framework/scaffold/motion-premium/scaffold.yaml +6 -1
- package/framework/scaffold/motion-premium-3d/README.md +10 -0
- package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
- package/framework/scaffold/motion-premium-3d/scaffold.yaml +3 -0
- package/framework/scaffold/saas-dashboard/README.md +58 -0
- package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
- package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
- package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
- package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
- package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
- package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
- package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
- package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
- package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
- package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
- package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
- package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
- package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
- package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
- package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
- package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
- package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
- package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
- package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
- package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
- package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
- package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
- package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
- package/framework/schemas/session.schema.json +5 -0
- package/framework/schemas/task.schema.json +9 -0
- package/framework/scripts/visual-qa.js +101 -3
- package/framework/tasks/orchestrator-health.md +4 -4
- package/framework/tasks/qa-impl-verdict.md +11 -5
- package/framework/templates/brandbook-html-tmpl.md +1 -1
- package/framework/templates/qa-gate-tmpl.yaml +24 -11
- package/package.json +4 -3
- package/src/config/context-file-generator.js +0 -6
- package/src/dashboard/renderer.js +0 -36
- package/src/executors/runner.js +204 -0
- package/src/installer/core.js +13 -3
- package/src/installer/templates.js +5 -6
- package/src/intelligence/registry-manager.js +1 -1
- package/src/license/client.js +9 -3
- package/src/license/commands.js +12 -2
- package/src/license/machine-id.js +42 -1
- package/src/memory/gotchas.js +58 -40
- package/src/memory/magic-docs.js +1 -1
- package/src/memory/session-digest.js +9 -4
- package/src/orchestrator/cli.js +186 -15
- package/src/orchestrator/doctor.js +98 -16
- package/src/orchestrator/pipeline-manager.js +44 -22
- package/src/orchestrator/session-manager.js +64 -8
- package/src/telemetry/sender.js +7 -19
- package/src/terminal/run-team.js +3 -3
- package/src/upgrade/tracked-files-detector.js +34 -16
- package/src/utils/feature-flags.js +1 -1
- package/src/utils/flatten-entities.js +4 -40
- package/src/utils/schema-validator.js +0 -14
- package/src/wizard/i18n.js +3 -3
- package/src/api/index.js +0 -120
- package/src/autonomy/autonomous-gate.js +0 -294
- package/src/autonomy/build-loop.js +0 -281
- package/src/autonomy/build-state.js +0 -286
- package/src/autonomy/cause-analyzer.js +0 -177
- package/src/autonomy/escalation.js +0 -214
- package/src/autonomy/index.js +0 -51
- package/src/autonomy/mode-manager.js +0 -225
- package/src/autonomy/mode-suggester.js +0 -283
- package/src/autonomy/progress-reporter.js +0 -275
- package/src/autonomy/safety-net.js +0 -370
- package/src/config/agent-customizer.js +0 -231
- package/src/decision/analyzer.js +0 -291
- package/src/decision/engine.js +0 -250
- package/src/decision/index.js +0 -38
- package/src/decision/registry-healer.js +0 -468
- package/src/decision/registry-updater.js +0 -339
- package/src/extensions/loader.js +0 -145
- package/src/extensions/registry.js +0 -134
- package/src/gates/circuit-breaker.js +0 -151
- package/src/gates/g1-planning-complete.js +0 -154
- package/src/gates/g2-qa-planning.js +0 -156
- package/src/gates/g3-implementation.js +0 -215
- package/src/gates/g4-qa-implementation.js +0 -240
- package/src/gates/g5-deploy-ready.js +0 -181
- package/src/gates/gate-base.js +0 -185
- package/src/gates/index.js +0 -46
- package/src/health/auto-fix.js +0 -216
- package/src/health/engine.js +0 -246
- package/src/merger/semantic-merger.js +0 -292
- package/src/preview/detector.js +0 -238
- package/src/preview/index.js +0 -20
- package/src/preview/launcher.js +0 -235
- package/src/preview/log-buffer.js +0 -103
- package/src/quality/metrics-collector.js +0 -281
- package/src/quality/test-runner.js +0 -366
- package/src/tasks/executor.js +0 -195
- package/src/tasks/index.js +0 -4
- package/src/tasks/loader.js +0 -210
- package/src/tasks/router.js +0 -182
- 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
|
+
`;
|