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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* components/primitives/Button.tsx -- saas-dashboard preset v1.0.0
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS A PRIMITIVE
|
|
5
|
+
* -----------------------
|
|
6
|
+
* Every interactive affordance in the UI eventually traces back to a
|
|
7
|
+
* button: form submission, navigation, dialog open, action trigger.
|
|
8
|
+
* If each feature builds its own button-shaped element, subtle
|
|
9
|
+
* inconsistencies accumulate: slightly different padding here, missing
|
|
10
|
+
* active state there, focus ring forgotten on a ghost variant.
|
|
11
|
+
*
|
|
12
|
+
* The Button primitive is the shared language. All interactive affordances
|
|
13
|
+
* that are not links inside prose use this component. Features that need
|
|
14
|
+
* a custom shape (icon-only circular button) extend this via the 'icon'
|
|
15
|
+
* size variant.
|
|
16
|
+
*
|
|
17
|
+
* STATE MODEL
|
|
18
|
+
* -----------
|
|
19
|
+
* Every variant has exactly 5 states:
|
|
20
|
+
* resting: default appearance
|
|
21
|
+
* hover: one perceptible property change (background, border, or shadow)
|
|
22
|
+
* active: scale(0.97) + slightly darker background -- physical press feel
|
|
23
|
+
* disabled: opacity-50 + pointer-events:none + aria-disabled
|
|
24
|
+
* loading: spinner replaces iconLeft, text persists, min-width locked
|
|
25
|
+
* (focus is handled globally via :focus-visible in globals.css)
|
|
26
|
+
*
|
|
27
|
+
* TRANSITION RULES (enforced here, no exceptions)
|
|
28
|
+
* ------------------------------------------------
|
|
29
|
+
* - Never use `transition-all` or `transition: all`.
|
|
30
|
+
* - Each transition names exactly the property being transitioned.
|
|
31
|
+
* - Duration tokens from --duration-*; easing from --ease-*.
|
|
32
|
+
* - Active scale: --duration-instant (80ms) --ease-spring for tactile feel.
|
|
33
|
+
* - Background/color changes: --duration-normal (200ms) --ease-in-out.
|
|
34
|
+
*
|
|
35
|
+
* VARIANT PHILOSOPHY
|
|
36
|
+
* ------------------
|
|
37
|
+
* primary: High-emphasis CTA. Accent background. One per view, maximum.
|
|
38
|
+
* secondary: Medium-emphasis. Raised surface with border. Multiple OK.
|
|
39
|
+
* ghost: Low-emphasis. Transparent. Used for icon buttons, toolbars.
|
|
40
|
+
* destructive: For irreversible actions. Danger tint; text danger color.
|
|
41
|
+
* link: Prose-inline or navigation. No background, no border.
|
|
42
|
+
*
|
|
43
|
+
* SIZE PHILOSOPHY
|
|
44
|
+
* ---------------
|
|
45
|
+
* sm: 32px height. Tight contexts: table action cells, compact toolbars.
|
|
46
|
+
* md: 40px height. Default. Most UI buttons.
|
|
47
|
+
* lg: 48px height. Hero CTAs, prominent forms.
|
|
48
|
+
* icon: Square. Width = height of the selected size. Icon-only buttons.
|
|
49
|
+
* MUST include a <span className="sr-only">label</span> child for
|
|
50
|
+
* screen reader accessibility.
|
|
51
|
+
*
|
|
52
|
+
* HREF PROP
|
|
53
|
+
* ---------
|
|
54
|
+
* When href is provided, the component renders as a Next.js <Link>
|
|
55
|
+
* instead of a <button>. All variants are supported. The semantics
|
|
56
|
+
* are correct: navigation actions should be anchors, not buttons.
|
|
57
|
+
*
|
|
58
|
+
* LOADING STATE
|
|
59
|
+
* -------------
|
|
60
|
+
* The loading prop:
|
|
61
|
+
* 1. Replaces iconLeft with a Spinner component.
|
|
62
|
+
* 2. Adds aria-busy="true" and aria-disabled="true".
|
|
63
|
+
* 3. Adds a min-width equal to the button's natural width to prevent
|
|
64
|
+
* the button from collapsing when text width changes. This is
|
|
65
|
+
* implemented via inline style measuring the rendered width on mount.
|
|
66
|
+
* Simpler approach: the parent should give the button a fixed width
|
|
67
|
+
* when the loading state is predictable (form submit buttons).
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import { type ButtonHTMLAttributes, type ReactNode, forwardRef } from 'react';
|
|
71
|
+
import Link from 'next/link';
|
|
72
|
+
import { Spinner } from './Spinner';
|
|
73
|
+
|
|
74
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
75
|
+
/** Visual style variant */
|
|
76
|
+
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'link';
|
|
77
|
+
/** Height, padding, and font-size tier */
|
|
78
|
+
size?: 'sm' | 'md' | 'lg' | 'icon';
|
|
79
|
+
/** Replaces iconLeft with a spinner; keeps width stable via min-width */
|
|
80
|
+
loading?: boolean;
|
|
81
|
+
/** Lucide icon rendered before children */
|
|
82
|
+
iconLeft?: ReactNode;
|
|
83
|
+
/** Lucide icon rendered after children */
|
|
84
|
+
iconRight?: ReactNode;
|
|
85
|
+
/** Stretches to container width */
|
|
86
|
+
fullWidth?: boolean;
|
|
87
|
+
/** Renders as Next.js Link instead of button */
|
|
88
|
+
href?: string;
|
|
89
|
+
/** External link target (only relevant with href) */
|
|
90
|
+
target?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ----------------------------------------------------------------
|
|
94
|
+
CLASS MAP
|
|
95
|
+
All Tailwind classes are defined in this map, not inline in JSX.
|
|
96
|
+
This makes the variant contract explicit and auditable.
|
|
97
|
+
---------------------------------------------------------------- */
|
|
98
|
+
|
|
99
|
+
const BASE = [
|
|
100
|
+
'relative inline-flex items-center justify-center gap-2',
|
|
101
|
+
'rounded-[var(--btn-radius)] font-medium',
|
|
102
|
+
'select-none whitespace-nowrap',
|
|
103
|
+
'transition-[background-color,box-shadow,transform,border-color,opacity]',
|
|
104
|
+
'[transition-duration:var(--duration-normal)]',
|
|
105
|
+
'[transition-timing-function:var(--ease-in-out)]',
|
|
106
|
+
'focus-visible:outline-none focus-visible:ring-2',
|
|
107
|
+
'focus-visible:ring-[var(--color-focus-ring)] focus-visible:ring-offset-2',
|
|
108
|
+
'focus-visible:ring-offset-[var(--color-bg-base)]',
|
|
109
|
+
'disabled:pointer-events-none disabled:opacity-50',
|
|
110
|
+
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
|
|
111
|
+
'active:[transition-duration:var(--duration-instant)]',
|
|
112
|
+
'active:[transition-timing-function:var(--ease-spring)]',
|
|
113
|
+
'active:scale-[0.97]',
|
|
114
|
+
].join(' ');
|
|
115
|
+
|
|
116
|
+
const VARIANT_CLASSES: Record<NonNullable<ButtonProps['variant']>, string> = {
|
|
117
|
+
primary: [
|
|
118
|
+
'bg-[var(--color-accent)] text-white',
|
|
119
|
+
'shadow-[var(--shadow-1)]',
|
|
120
|
+
'hover:bg-[var(--color-accent-hover)]',
|
|
121
|
+
'hover:shadow-[var(--shadow-2)]',
|
|
122
|
+
'active:bg-[var(--color-accent-active)]',
|
|
123
|
+
].join(' '),
|
|
124
|
+
|
|
125
|
+
secondary: [
|
|
126
|
+
'bg-[var(--color-bg-raised)] text-[var(--color-text-primary)]',
|
|
127
|
+
'border border-[var(--color-border-default)]',
|
|
128
|
+
'shadow-[var(--shadow-1)]',
|
|
129
|
+
'hover:border-[var(--color-border-emphasis)]',
|
|
130
|
+
'hover:bg-[var(--color-bg-raised-hover)]',
|
|
131
|
+
'active:bg-[var(--color-bg-raised-active)]',
|
|
132
|
+
].join(' '),
|
|
133
|
+
|
|
134
|
+
ghost: [
|
|
135
|
+
'bg-transparent text-[var(--color-text-secondary)]',
|
|
136
|
+
'hover:bg-[var(--color-bg-highlight)] hover:text-[var(--color-text-primary)]',
|
|
137
|
+
'active:bg-[var(--color-accent-subtle)]',
|
|
138
|
+
].join(' '),
|
|
139
|
+
|
|
140
|
+
destructive: [
|
|
141
|
+
'bg-[var(--color-danger-subtle)] text-[var(--color-danger)]',
|
|
142
|
+
'border border-[var(--color-danger-subtle)]',
|
|
143
|
+
'hover:bg-[var(--color-danger-hover-bg)] hover:border-[var(--color-danger)]',
|
|
144
|
+
'active:bg-[var(--color-danger-active-bg)]',
|
|
145
|
+
].join(' '),
|
|
146
|
+
|
|
147
|
+
link: [
|
|
148
|
+
'bg-transparent text-[var(--color-text-accent)]',
|
|
149
|
+
'underline-offset-4 hover:underline',
|
|
150
|
+
'active:opacity-70',
|
|
151
|
+
'h-auto! px-0! py-0!',
|
|
152
|
+
].join(' '),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const SIZE_CLASSES: Record<NonNullable<ButtonProps['size']>, string> = {
|
|
156
|
+
sm: 'h-[var(--btn-h-sm)] px-[var(--btn-px-sm)] text-[var(--type-small)]',
|
|
157
|
+
md: 'h-[var(--btn-h-md)] px-[var(--btn-px-md)] text-[var(--type-small)]',
|
|
158
|
+
lg: 'h-[var(--btn-h-lg)] px-[var(--btn-px-lg)] text-[var(--type-body)]',
|
|
159
|
+
icon: 'h-[var(--btn-h-md)] w-[var(--btn-h-md)] px-0',
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const SIZE_ICON_SIZE: Record<NonNullable<ButtonProps['size']>, number> = {
|
|
163
|
+
sm: 14,
|
|
164
|
+
md: 16,
|
|
165
|
+
lg: 18,
|
|
166
|
+
icon: 16,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/* ----------------------------------------------------------------
|
|
170
|
+
COMPONENT
|
|
171
|
+
---------------------------------------------------------------- */
|
|
172
|
+
|
|
173
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
174
|
+
(
|
|
175
|
+
{
|
|
176
|
+
variant = 'primary',
|
|
177
|
+
size = 'md',
|
|
178
|
+
loading = false,
|
|
179
|
+
iconLeft,
|
|
180
|
+
iconRight,
|
|
181
|
+
fullWidth = false,
|
|
182
|
+
href,
|
|
183
|
+
target,
|
|
184
|
+
disabled,
|
|
185
|
+
children,
|
|
186
|
+
className,
|
|
187
|
+
...rest
|
|
188
|
+
},
|
|
189
|
+
ref,
|
|
190
|
+
) => {
|
|
191
|
+
const isDisabled = disabled || loading;
|
|
192
|
+
const iconSize = SIZE_ICON_SIZE[size];
|
|
193
|
+
|
|
194
|
+
const classes = [
|
|
195
|
+
BASE,
|
|
196
|
+
VARIANT_CLASSES[variant],
|
|
197
|
+
SIZE_CLASSES[size],
|
|
198
|
+
fullWidth ? 'w-full' : '',
|
|
199
|
+
className,
|
|
200
|
+
]
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.join(' ');
|
|
203
|
+
|
|
204
|
+
const content = (
|
|
205
|
+
<>
|
|
206
|
+
{/* Loading spinner replaces iconLeft, preserving layout */}
|
|
207
|
+
{loading ? (
|
|
208
|
+
<Spinner size={size === 'lg' ? 'md' : 'sm'} aria-hidden="true" />
|
|
209
|
+
) : iconLeft ? (
|
|
210
|
+
<span aria-hidden="true" className="shrink-0">
|
|
211
|
+
{iconLeft}
|
|
212
|
+
</span>
|
|
213
|
+
) : null}
|
|
214
|
+
|
|
215
|
+
{/* Children: hidden from SR during loading for clean state */}
|
|
216
|
+
{children && (
|
|
217
|
+
<span className={loading ? 'opacity-0 select-none' : undefined}>
|
|
218
|
+
{children}
|
|
219
|
+
</span>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{iconRight && !loading && (
|
|
223
|
+
<span aria-hidden="true" className="shrink-0">
|
|
224
|
+
{iconRight}
|
|
225
|
+
</span>
|
|
226
|
+
)}
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
/* Render as Next.js Link for navigation actions */
|
|
231
|
+
if (href) {
|
|
232
|
+
return (
|
|
233
|
+
<Link
|
|
234
|
+
href={href}
|
|
235
|
+
target={target}
|
|
236
|
+
className={classes}
|
|
237
|
+
aria-disabled={isDisabled || undefined}
|
|
238
|
+
tabIndex={isDisabled ? -1 : undefined}
|
|
239
|
+
>
|
|
240
|
+
{content}
|
|
241
|
+
</Link>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<button
|
|
247
|
+
ref={ref}
|
|
248
|
+
type="button"
|
|
249
|
+
disabled={isDisabled}
|
|
250
|
+
aria-disabled={isDisabled || undefined}
|
|
251
|
+
aria-busy={loading || undefined}
|
|
252
|
+
className={classes}
|
|
253
|
+
{...rest}
|
|
254
|
+
>
|
|
255
|
+
{content}
|
|
256
|
+
</button>
|
|
257
|
+
);
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
Button.displayName = 'Button';
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* components/primitives/Card.tsx -- saas-dashboard preset v1.0.0
|
|
3
|
+
*
|
|
4
|
+
* WHY A COMPOUND COMPONENT
|
|
5
|
+
* ------------------------
|
|
6
|
+
* A single <Card> component with a content slot is flexible but
|
|
7
|
+
* loses structural meaning: every usage would re-invent its own
|
|
8
|
+
* header/footer layout, producing inconsistent internal spacing.
|
|
9
|
+
*
|
|
10
|
+
* The compound component pattern (<Card>, <CardHeader>, <CardTitle>,
|
|
11
|
+
* etc.) gives consumers a shared grammar:
|
|
12
|
+
* - CardHeader has a defined height, flex-row layout, and icon slot
|
|
13
|
+
* - CardTitle and CardDescription have defined type scale tokens
|
|
14
|
+
* - CardContent handles the padding relationship with CardHeader
|
|
15
|
+
* - CardFooter has a border-top and consistent action layout
|
|
16
|
+
*
|
|
17
|
+
* This means QA can verify the card pattern in one place, and any
|
|
18
|
+
* change to spacing or typography propagates to every usage.
|
|
19
|
+
*
|
|
20
|
+
* ELEVATION SYSTEM
|
|
21
|
+
* ----------------
|
|
22
|
+
* Elevation 1 (default): --shadow-1
|
|
23
|
+
* Use for: standard cards, stat cards, panels.
|
|
24
|
+
* The inset top border simulates a light source from above.
|
|
25
|
+
*
|
|
26
|
+
* Elevation 2: --shadow-2
|
|
27
|
+
* Use for: dropdowns, popovers, hover-elevated cards.
|
|
28
|
+
* More ambient shadow; adds a 1px outer ring.
|
|
29
|
+
*
|
|
30
|
+
* VARIANT MODIFIERS
|
|
31
|
+
* -----------------
|
|
32
|
+
* featured: 2px accent-color top border.
|
|
33
|
+
* Use for: primary plan in a pricing grid, pinned items.
|
|
34
|
+
* ONE featured card per visible group maximum.
|
|
35
|
+
*
|
|
36
|
+
* interactive: hover translateY(-2px) + shadow-2.
|
|
37
|
+
* Use for: clickable resource cards, navigation tiles.
|
|
38
|
+
* Add onRowClick or an onClick handler alongside this prop.
|
|
39
|
+
*
|
|
40
|
+
* noPadding: Removes CardContent internal padding.
|
|
41
|
+
* Use for: DataTable, full-bleed images, embedded charts.
|
|
42
|
+
* Sub-components are still padded unless also given noPadding.
|
|
43
|
+
*
|
|
44
|
+
* stretch: height: 100% for use inside flex/grid containers.
|
|
45
|
+
*
|
|
46
|
+
* TRANSITION RULES
|
|
47
|
+
* ----------------
|
|
48
|
+
* Interactive card hover:
|
|
49
|
+
* transform: translateY(-2px) -- duration-normal ease-out (entering)
|
|
50
|
+
* box-shadow: from shadow-1 to shadow-2 -- duration-normal ease-out
|
|
51
|
+
* Hover exit:
|
|
52
|
+
* transform: translateY(0) -- duration-normal ease-in
|
|
53
|
+
* box-shadow: back to shadow-1 -- duration-normal ease-in
|
|
54
|
+
* Both transitions name exactly the properties being transitioned.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import React, { type HTMLAttributes, forwardRef } from 'react';
|
|
58
|
+
|
|
59
|
+
/* ----------------------------------------------------------------
|
|
60
|
+
CARD ROOT
|
|
61
|
+
---------------------------------------------------------------- */
|
|
62
|
+
|
|
63
|
+
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
|
64
|
+
/** Shadow elevation level */
|
|
65
|
+
elevation?: 1 | 2;
|
|
66
|
+
/** Adds 2px accent top border for featured/primary cards */
|
|
67
|
+
featured?: boolean;
|
|
68
|
+
/** Removes internal padding for full-bleed content */
|
|
69
|
+
noPadding?: boolean;
|
|
70
|
+
/** Adds hover lift (translateY -2px, shadow-2) for clickable cards */
|
|
71
|
+
interactive?: boolean;
|
|
72
|
+
/** Fills height of flex/grid container */
|
|
73
|
+
stretch?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
|
77
|
+
(
|
|
78
|
+
{
|
|
79
|
+
elevation = 1,
|
|
80
|
+
featured = false,
|
|
81
|
+
noPadding = false,
|
|
82
|
+
interactive = false,
|
|
83
|
+
stretch = false,
|
|
84
|
+
children,
|
|
85
|
+
className,
|
|
86
|
+
style,
|
|
87
|
+
...rest
|
|
88
|
+
},
|
|
89
|
+
ref,
|
|
90
|
+
) => {
|
|
91
|
+
const shadowVar =
|
|
92
|
+
elevation === 2
|
|
93
|
+
? 'var(--shadow-2)'
|
|
94
|
+
: 'var(--shadow-1)';
|
|
95
|
+
|
|
96
|
+
const classes = [
|
|
97
|
+
/* Surface */
|
|
98
|
+
'relative rounded-[var(--radius-lg)] overflow-hidden',
|
|
99
|
+
'bg-[var(--color-bg-raised)]',
|
|
100
|
+
'border border-[var(--color-border-default)]',
|
|
101
|
+
/* Featured: accent border replaces top edge */
|
|
102
|
+
featured
|
|
103
|
+
? 'border-t-2 border-t-[var(--color-accent)]'
|
|
104
|
+
: '',
|
|
105
|
+
/* Padding */
|
|
106
|
+
noPadding ? '' : 'p-[var(--spacing-card-pad)]',
|
|
107
|
+
/* Interactive hover */
|
|
108
|
+
interactive
|
|
109
|
+
? [
|
|
110
|
+
'cursor-pointer',
|
|
111
|
+
'transition-[transform,box-shadow]',
|
|
112
|
+
'[transition-duration:var(--duration-normal)]',
|
|
113
|
+
'[transition-timing-function:var(--ease-out)]',
|
|
114
|
+
'hover:-translate-y-0.5',
|
|
115
|
+
'hover:shadow-[var(--shadow-2)]',
|
|
116
|
+
'active:translate-y-0 active:shadow-[var(--shadow-1)]',
|
|
117
|
+
'active:[transition-timing-function:var(--ease-in)]',
|
|
118
|
+
'active:[transition-duration:var(--duration-instant)]',
|
|
119
|
+
].join(' ')
|
|
120
|
+
: '',
|
|
121
|
+
/* Stretch */
|
|
122
|
+
stretch ? 'h-full' : '',
|
|
123
|
+
className,
|
|
124
|
+
]
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.join(' ');
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
ref={ref}
|
|
131
|
+
style={{ boxShadow: shadowVar, ...style }}
|
|
132
|
+
className={classes}
|
|
133
|
+
{...rest}
|
|
134
|
+
>
|
|
135
|
+
{children}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
Card.displayName = 'Card';
|
|
142
|
+
|
|
143
|
+
/* ----------------------------------------------------------------
|
|
144
|
+
CARD HEADER
|
|
145
|
+
flex row, space-between, consistent vertical padding.
|
|
146
|
+
---------------------------------------------------------------- */
|
|
147
|
+
|
|
148
|
+
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {}
|
|
149
|
+
|
|
150
|
+
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
|
151
|
+
({ children, className, ...rest }, ref) => (
|
|
152
|
+
<div
|
|
153
|
+
ref={ref}
|
|
154
|
+
className={[
|
|
155
|
+
'flex flex-row items-center justify-between',
|
|
156
|
+
'px-[var(--spacing-card-pad)] pt-[var(--spacing-card-pad)]',
|
|
157
|
+
'pb-[var(--space-4)]',
|
|
158
|
+
className,
|
|
159
|
+
]
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.join(' ')}
|
|
162
|
+
{...rest}
|
|
163
|
+
>
|
|
164
|
+
{children}
|
|
165
|
+
</div>
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
CardHeader.displayName = 'CardHeader';
|
|
170
|
+
|
|
171
|
+
/* ----------------------------------------------------------------
|
|
172
|
+
CARD TITLE
|
|
173
|
+
h4-level type scale; semibold; primary text color.
|
|
174
|
+
---------------------------------------------------------------- */
|
|
175
|
+
|
|
176
|
+
export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
|
|
177
|
+
/** Heading level -- defaults to h3 for document outline. Override
|
|
178
|
+
* only when the card is inside an existing h2 section. */
|
|
179
|
+
as?: 'h2' | 'h3' | 'h4';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
|
|
183
|
+
({ as: Tag = 'h3', children, className, ...rest }, ref) => (
|
|
184
|
+
<Tag
|
|
185
|
+
ref={ref as React.Ref<HTMLHeadingElement>}
|
|
186
|
+
className={[
|
|
187
|
+
'text-[var(--type-h4)] leading-[var(--type-h4-lh)]',
|
|
188
|
+
'tracking-[var(--type-h4-ls)]',
|
|
189
|
+
'font-[var(--font-weight-semibold)]',
|
|
190
|
+
'text-[var(--color-text-primary)]',
|
|
191
|
+
className,
|
|
192
|
+
]
|
|
193
|
+
.filter(Boolean)
|
|
194
|
+
.join(' ')}
|
|
195
|
+
{...rest}
|
|
196
|
+
>
|
|
197
|
+
{children}
|
|
198
|
+
</Tag>
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
CardTitle.displayName = 'CardTitle';
|
|
203
|
+
|
|
204
|
+
/* ----------------------------------------------------------------
|
|
205
|
+
CARD DESCRIPTION
|
|
206
|
+
small type scale; secondary text color.
|
|
207
|
+
---------------------------------------------------------------- */
|
|
208
|
+
|
|
209
|
+
export interface CardDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {}
|
|
210
|
+
|
|
211
|
+
export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
|
|
212
|
+
({ children, className, ...rest }, ref) => (
|
|
213
|
+
<p
|
|
214
|
+
ref={ref}
|
|
215
|
+
className={[
|
|
216
|
+
'text-[var(--type-small)] leading-[var(--type-small-lh)]',
|
|
217
|
+
'text-[var(--color-text-secondary)]',
|
|
218
|
+
'mt-[var(--space-1)]',
|
|
219
|
+
className,
|
|
220
|
+
]
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.join(' ')}
|
|
223
|
+
{...rest}
|
|
224
|
+
>
|
|
225
|
+
{children}
|
|
226
|
+
</p>
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
CardDescription.displayName = 'CardDescription';
|
|
231
|
+
|
|
232
|
+
/* ----------------------------------------------------------------
|
|
233
|
+
CARD CONTENT
|
|
234
|
+
Removes top padding when it follows a CardHeader.
|
|
235
|
+
---------------------------------------------------------------- */
|
|
236
|
+
|
|
237
|
+
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {}
|
|
238
|
+
|
|
239
|
+
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
|
|
240
|
+
({ children, className, ...rest }, ref) => (
|
|
241
|
+
<div
|
|
242
|
+
ref={ref}
|
|
243
|
+
className={[
|
|
244
|
+
'px-[var(--spacing-card-pad)] pb-[var(--spacing-card-pad)]',
|
|
245
|
+
/* Remove top padding: CardHeader already provides bottom padding */
|
|
246
|
+
'[.card-has-header_+_&]:pt-0 pt-[var(--spacing-card-pad)]',
|
|
247
|
+
className,
|
|
248
|
+
]
|
|
249
|
+
.filter(Boolean)
|
|
250
|
+
.join(' ')}
|
|
251
|
+
{...rest}
|
|
252
|
+
>
|
|
253
|
+
{children}
|
|
254
|
+
</div>
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
CardContent.displayName = 'CardContent';
|
|
259
|
+
|
|
260
|
+
/* ----------------------------------------------------------------
|
|
261
|
+
CARD FOOTER
|
|
262
|
+
Border-top divider; flex row for action buttons.
|
|
263
|
+
---------------------------------------------------------------- */
|
|
264
|
+
|
|
265
|
+
export interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {}
|
|
266
|
+
|
|
267
|
+
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
|
|
268
|
+
({ children, className, ...rest }, ref) => (
|
|
269
|
+
<div
|
|
270
|
+
ref={ref}
|
|
271
|
+
className={[
|
|
272
|
+
'flex flex-row items-center gap-[var(--space-2)]',
|
|
273
|
+
'px-[var(--spacing-card-pad)] py-[var(--space-4)]',
|
|
274
|
+
'border-t border-[var(--color-border-default)]',
|
|
275
|
+
className,
|
|
276
|
+
]
|
|
277
|
+
.filter(Boolean)
|
|
278
|
+
.join(' ')}
|
|
279
|
+
{...rest}
|
|
280
|
+
>
|
|
281
|
+
{children}
|
|
282
|
+
</div>
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
CardFooter.displayName = 'CardFooter';
|
|
287
|
+
|