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,424 @@
1
+ /**
2
+ * components/interaction/Form.tsx
3
+ * SaaS Dashboard Preset -- D_interaction phase
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * Form composition is one of the highest-friction areas in dashboard UI.
8
+ * Three recurring failures:
9
+ *
10
+ * 1. Error summary placement: errors appear in field-level tooltips only,
11
+ * so keyboard users navigating linearly miss them. A centralized error
12
+ * summary at the top of the form, announced via role=alert and focus-
13
+ * managed on submit failure, is the accessible pattern.
14
+ *
15
+ * 2. Responsive layout inconsistency: developers reach for ad-hoc grid
16
+ * utilities, producing forms that look great on desktop but stack
17
+ * badly on mobile or create awkward widths on tablet. FormRow
18
+ * encapsulates the 1-column mobile / 2-column desktop pattern once.
19
+ *
20
+ * 3. Action bar stickiness on mobile: a long form with a floating submit
21
+ * button that disappears into the scroll area is a conversion killer
22
+ * on mobile. FormActions sticks to the bottom of the viewport on
23
+ * narrow screens but flows inline at desktop widths.
24
+ *
25
+ * COMPONENT MAP
26
+ * -------------
27
+ * Form -- <form> wrapper, handles onSubmit + error summary
28
+ * FormSection -- Labeled group (<fieldset> + <legend> or <div> + heading)
29
+ * FormRow -- CSS grid: 1-col mobile, 2-col desktop
30
+ * FormActions -- Action bar with primary + cancel
31
+ *
32
+ * USAGE EXAMPLE
33
+ * -------------
34
+ * <Form onSubmit={handleSubmit} errors={formErrors}>
35
+ * <FormSection label="Personal details">
36
+ * <FormRow>
37
+ * <Field label="First name" required inputProps={{ name: 'firstName' }} />
38
+ * <Field label="Last name" required inputProps={{ name: 'lastName' }} />
39
+ * </FormRow>
40
+ * <Field label="Email address" required inputProps={{ type: 'email', name: 'email' }} />
41
+ * </FormSection>
42
+ * <FormActions primaryLabel="Save changes" onCancel={handleCancel} />
43
+ * </Form>
44
+ *
45
+ * TOKEN USAGE
46
+ * -----------
47
+ * All spacing, colors, and radii reference CSS custom properties.
48
+ */
49
+
50
+ 'use client';
51
+
52
+ import React, {
53
+ useRef,
54
+ useEffect,
55
+ type FormHTMLAttributes,
56
+ type ReactNode,
57
+ } from 'react';
58
+ import { AlertCircle } from 'lucide-react';
59
+ import { Button } from '@/components/primitives/Button';
60
+ import { Field } from '@/components/interaction/Field';
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Form root
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export interface FormError {
67
+ field?: string;
68
+ message: string;
69
+ }
70
+
71
+ export interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'> {
72
+ onSubmit: (e: React.FormEvent<HTMLFormElement>) => void | Promise<void>;
73
+ /** Top-level errors rendered in the error summary block. */
74
+ errors?: FormError[];
75
+ children: ReactNode;
76
+ }
77
+
78
+ export function Form({ onSubmit, errors, children, ...rest }: FormProps) {
79
+ const errorSummaryRef = useRef<HTMLDivElement>(null);
80
+
81
+ // Move focus to error summary on new errors (accessibility: SR users hear it)
82
+ useEffect(() => {
83
+ if (errors && errors.length > 0) {
84
+ errorSummaryRef.current?.focus();
85
+ }
86
+ }, [errors]);
87
+
88
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
89
+ e.preventDefault();
90
+ await onSubmit(e);
91
+ }
92
+
93
+ return (
94
+ <form
95
+ onSubmit={handleSubmit}
96
+ noValidate
97
+ style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-section)' }}
98
+ {...rest}
99
+ >
100
+ {/* Error summary */}
101
+ {errors && errors.length > 0 && (
102
+ <div
103
+ ref={errorSummaryRef}
104
+ role="alert"
105
+ aria-live="assertive"
106
+ tabIndex={-1}
107
+ style={{
108
+ display: 'flex',
109
+ gap: 'var(--space-3)',
110
+ padding: 'var(--space-4)',
111
+ backgroundColor: 'var(--color-danger-subtle)',
112
+ border: '1px solid var(--color-danger)',
113
+ borderRadius: 'var(--radius-default)',
114
+ outline: 'none',
115
+ }}
116
+ >
117
+ <AlertCircle
118
+ size={18}
119
+ aria-hidden="true"
120
+ style={{ color: 'var(--color-danger)', flexShrink: 0, marginTop: '1px' }}
121
+ />
122
+ <div style={{ flex: 1 }}>
123
+ <p
124
+ style={{
125
+ fontSize: 'var(--type-small)',
126
+ fontWeight: 600,
127
+ color: 'var(--color-danger)',
128
+ margin: '0 0 var(--space-1)',
129
+ }}
130
+ >
131
+ Please fix the following errors:
132
+ </p>
133
+ <ul
134
+ style={{
135
+ margin: 0,
136
+ paddingLeft: 'var(--space-4)',
137
+ fontSize: 'var(--type-small)',
138
+ color: 'var(--color-danger)',
139
+ }}
140
+ >
141
+ {errors.map((err, i) => (
142
+ <li key={i}>{err.message}</li>
143
+ ))}
144
+ </ul>
145
+ </div>
146
+ </div>
147
+ )}
148
+
149
+ {children}
150
+ </form>
151
+ );
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // FormSection
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export interface FormSectionProps {
159
+ /** Rendered as a <legend> inside <fieldset> when useFieldset=true,
160
+ * otherwise as a heading above the group. */
161
+ label: string;
162
+ /** Optional description below the label. */
163
+ description?: string;
164
+ /** Use semantic <fieldset> + <legend>. Default true for radio/checkbox groups. */
165
+ useFieldset?: boolean;
166
+ children: ReactNode;
167
+ }
168
+
169
+ export function FormSection({
170
+ label,
171
+ description,
172
+ useFieldset = false,
173
+ children,
174
+ }: FormSectionProps) {
175
+ const content = (
176
+ <>
177
+ <div
178
+ style={{
179
+ paddingBottom: 'var(--space-5)',
180
+ borderBottom: '1px solid var(--color-border-default)',
181
+ marginBottom: 'var(--space-6)',
182
+ }}
183
+ >
184
+ {useFieldset ? (
185
+ <legend
186
+ style={{
187
+ fontSize: 'var(--type-h4)',
188
+ lineHeight: 'var(--type-h4-lh)',
189
+ fontWeight: 600,
190
+ color: 'var(--color-text-primary)',
191
+ letterSpacing: 'var(--type-h4-ls)',
192
+ padding: 0,
193
+ }}
194
+ >
195
+ {label}
196
+ </legend>
197
+ ) : (
198
+ <h3
199
+ style={{
200
+ fontSize: 'var(--type-h4)',
201
+ lineHeight: 'var(--type-h4-lh)',
202
+ fontWeight: 600,
203
+ color: 'var(--color-text-primary)',
204
+ letterSpacing: 'var(--type-h4-ls)',
205
+ margin: 0,
206
+ }}
207
+ >
208
+ {label}
209
+ </h3>
210
+ )}
211
+ {description && (
212
+ <p
213
+ style={{
214
+ fontSize: 'var(--type-small)',
215
+ lineHeight: 'var(--type-small-lh)',
216
+ color: 'var(--color-text-secondary)',
217
+ margin: 'var(--space-1) 0 0',
218
+ }}
219
+ >
220
+ {description}
221
+ </p>
222
+ )}
223
+ </div>
224
+ <div
225
+ style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-card-gap)' }}
226
+ >
227
+ {children}
228
+ </div>
229
+ </>
230
+ );
231
+
232
+ if (useFieldset) {
233
+ return (
234
+ <fieldset
235
+ style={{
236
+ border: 'none',
237
+ padding: 0,
238
+ margin: 0,
239
+ }}
240
+ >
241
+ {content}
242
+ </fieldset>
243
+ );
244
+ }
245
+
246
+ return <div>{content}</div>;
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // FormRow (responsive 2-col grid)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ export function FormRow({ children }: { children: ReactNode }) {
254
+ return (
255
+ <div
256
+ style={{
257
+ display: 'grid',
258
+ gap: 'var(--spacing-card-gap)',
259
+ // CSS custom property trick: Tailwind-free responsive grid via
260
+ // container query when available, viewport query as fallback.
261
+ gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 240px), 1fr))',
262
+ }}
263
+ >
264
+ {children}
265
+ </div>
266
+ );
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // FormActions
271
+ // ---------------------------------------------------------------------------
272
+
273
+ export interface FormActionsProps {
274
+ /** Primary action label (submit button). */
275
+ primaryLabel?: string;
276
+ /** Called when cancel is clicked. Renders cancel button only when provided. */
277
+ onCancel?: () => void;
278
+ /** Set true when form submission is in progress. */
279
+ loading?: boolean;
280
+ /** Disables primary button. */
281
+ disabled?: boolean;
282
+ }
283
+
284
+ export function FormActions({
285
+ primaryLabel = 'Save changes',
286
+ onCancel,
287
+ loading = false,
288
+ disabled = false,
289
+ }: FormActionsProps) {
290
+ return (
291
+ <div
292
+ style={{
293
+ display: 'flex',
294
+ alignItems: 'center',
295
+ justifyContent: 'flex-end',
296
+ gap: 'var(--space-3)',
297
+ paddingTop: 'var(--space-6)',
298
+ borderTop: '1px solid var(--color-border-default)',
299
+ // Sticky on mobile: stays visible at bottom of viewport
300
+ position: 'sticky',
301
+ bottom: 0,
302
+ backgroundColor: 'var(--color-bg-base)',
303
+ zIndex: 10,
304
+ marginInline: 'calc(-1 * var(--spacing-card-pad))',
305
+ paddingInline: 'var(--spacing-card-pad)',
306
+ paddingBottom: 'var(--space-4)',
307
+ }}
308
+ >
309
+ {onCancel && (
310
+ <Button
311
+ variant="secondary"
312
+ size="md"
313
+ type="button"
314
+ onClick={onCancel}
315
+ disabled={loading}
316
+ >
317
+ Cancel
318
+ </Button>
319
+ )}
320
+ <Button
321
+ variant="primary"
322
+ size="md"
323
+ type="submit"
324
+ loading={loading}
325
+ disabled={disabled}
326
+ >
327
+ {primaryLabel}
328
+ </Button>
329
+ </div>
330
+ );
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // FormExample (demonstration component, not part of the public API)
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /**
338
+ * FormExample: a complete, self-contained form demonstrating:
339
+ * - Real labels (never placeholder-only)
340
+ * - Validation error states
341
+ * - Required field marking
342
+ * - 2-column responsive row
343
+ * - FormActions with loading state
344
+ *
345
+ * Import path: @/components/interaction/Form (named export)
346
+ */
347
+ export function FormExample() {
348
+ return (
349
+ <Form
350
+ onSubmit={async (e) => {
351
+ // Replace with your submit handler
352
+ await new Promise((r) => setTimeout(r, 1200));
353
+ }}
354
+ errors={[
355
+ { field: 'email', message: 'Email address is not valid' },
356
+ ]}
357
+ >
358
+ <FormSection
359
+ label="Account details"
360
+ description="Update your personal information and contact details."
361
+ >
362
+ <FormRow>
363
+ <Field
364
+ label="First name"
365
+ required
366
+ inputProps={{
367
+ name: 'firstName',
368
+ autoComplete: 'given-name',
369
+ placeholder: 'Ada',
370
+ }}
371
+ />
372
+ <Field
373
+ label="Last name"
374
+ required
375
+ inputProps={{
376
+ name: 'lastName',
377
+ autoComplete: 'family-name',
378
+ placeholder: 'Lovelace',
379
+ }}
380
+ />
381
+ </FormRow>
382
+
383
+ <Field
384
+ label="Email address"
385
+ required
386
+ error="Email address is not valid"
387
+ hint="Used for login and notifications"
388
+ inputProps={{
389
+ type: 'email',
390
+ name: 'email',
391
+ autoComplete: 'email',
392
+ placeholder: 'ada@example.com',
393
+ }}
394
+ />
395
+
396
+ <Field
397
+ label="Role"
398
+ as="select"
399
+ hint="Determines permissions and dashboard access."
400
+ selectProps={{ name: 'role', defaultValue: '' }}
401
+ >
402
+ <option value="" disabled>
403
+ Select a role...
404
+ </option>
405
+ <option value="admin">Admin</option>
406
+ <option value="editor">Editor</option>
407
+ <option value="viewer">Viewer</option>
408
+ </Field>
409
+
410
+ <Field
411
+ label="Notes"
412
+ as="textarea"
413
+ hint="Optional context for the team."
414
+ textareaProps={{
415
+ name: 'notes',
416
+ placeholder: 'Anything the team should know...',
417
+ }}
418
+ />
419
+ </FormSection>
420
+
421
+ <FormActions primaryLabel="Save changes" onCancel={() => {}} />
422
+ </Form>
423
+ );
424
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * components/primitives/Badge.tsx -- saas-dashboard preset v1.0.0
3
+ *
4
+ * WHY BADGES NEED A PRIMITIVE
5
+ * ----------------------------
6
+ * Status indicators, count bubbles, category tags, and removable
7
+ * filter chips all share the same fundamental anatomy:
8
+ * - Pill or rounded-rectangle shape
9
+ * - Small type scale (caption or small)
10
+ * - Semantic color that communicates intent (success=green, etc.)
11
+ * - Optional leading dot for additional visual emphasis
12
+ * - Optional trailing X for removable tags
13
+ *
14
+ * Without a shared primitive, these proliferate as ad-hoc div+span
15
+ * combinations with inconsistent padding, varying font weights, and
16
+ * colour values that reference raw hex instead of semantic tokens.
17
+ * The Badge primitive centralises this into one contract.
18
+ *
19
+ * VARIANT SEMANTICS
20
+ * -----------------
21
+ * default: Neutral tint. Use for categories, labels.
22
+ * success: Green tint. Use for active, complete, healthy.
23
+ * warning: Amber tint. Use for pending, at-risk, expiring.
24
+ * danger: Red tint. Use for error, critical, overdue.
25
+ * info: Sky tint. Use for informational, new, beta.
26
+ * accent: Violet tint. Use for featured, promoted, selected.
27
+ *
28
+ * Each variant uses the semantic *-subtle token for background and
29
+ * the full-saturation token for text/dot. This ensures the badge
30
+ * reads clearly on all surface levels (bg-base, bg-raised, bg-overlay).
31
+ *
32
+ * SIZE SYSTEM
33
+ * -----------
34
+ * sm: 12px type (caption), 2px/8px padding -- for table cells, tight grids
35
+ * md: 14px type (small), 4px/10px padding -- default, standalone usage
36
+ *
37
+ * DOT VARIANT
38
+ * -----------
39
+ * Renders a 6px filled circle before the label text, color-matched
40
+ * to the variant's full-saturation token. Communicates "status"
41
+ * more emphatically than text alone.
42
+ *
43
+ * REMOVABLE VARIANT
44
+ * -----------------
45
+ * Appends an X icon button. The button has its own focus ring and
46
+ * is keyboard-accessible. The badge animates out via framer-motion
47
+ * AnimatePresence when removed (opacity fade, scale, --duration-fast).
48
+ *
49
+ * The parent is responsible for removing the badge from the list on
50
+ * onRemove; the badge itself only handles the visual transition.
51
+ *
52
+ * TRANSITIONS
53
+ * -----------
54
+ * Mount/unmount animation via framer-motion AnimatePresence:
55
+ * enter: opacity 0 -> 1, scale 0.85 -> 1, ease-out, duration-fast
56
+ * exit: opacity 1 -> 0, scale 1 -> 0.85, ease-in, duration-fast
57
+ * Hover on removable X: opacity 0.7 -> 1, duration-instant.
58
+ */
59
+
60
+ import { type HTMLAttributes, type MouseEventHandler } from 'react';
61
+ import { AnimatePresence, motion } from 'framer-motion';
62
+ import { X } from 'lucide-react';
63
+ import { DURATION, EASING } from '@/lib/brand';
64
+
65
+ export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
66
+ /** Color intent */
67
+ variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'accent';
68
+ /** Size */
69
+ size?: 'sm' | 'md';
70
+ /** Renders a 6px status dot before label */
71
+ dot?: boolean;
72
+ /** Makes badge a removable tag with X icon button */
73
+ removable?: boolean;
74
+ /** Called when X is clicked (requires removable=true) */
75
+ onRemove?: MouseEventHandler<HTMLButtonElement>;
76
+ /** Controls AnimatePresence for exit animation */
77
+ visible?: boolean;
78
+ }
79
+
80
+ /* ----------------------------------------------------------------
81
+ CLASS MAPS
82
+ ---------------------------------------------------------------- */
83
+
84
+ const SIZE_CLASSES: Record<NonNullable<BadgeProps['size']>, string> = {
85
+ sm: 'px-[var(--space-2)] py-[var(--space-px)] text-[var(--type-caption)] leading-[var(--type-caption-lh)] tracking-[var(--type-caption-ls)]',
86
+ md: 'px-[var(--space-3)] py-[var(--space-1)] text-[var(--type-small)] leading-[var(--type-small-lh)]',
87
+ };
88
+
89
+ const DOT_COLOR: Record<NonNullable<BadgeProps['variant']>, string> = {
90
+ default: 'bg-[var(--color-neutral-400)]',
91
+ success: 'bg-[var(--color-success)]',
92
+ warning: 'bg-[var(--color-warning)]',
93
+ danger: 'bg-[var(--color-danger)]',
94
+ info: 'bg-[var(--color-info)]',
95
+ accent: 'bg-[var(--color-accent)]',
96
+ };
97
+
98
+ const VARIANT_CLASSES: Record<NonNullable<BadgeProps['variant']>, string> = {
99
+ default: 'bg-[var(--color-bg-raised)] text-[var(--color-text-secondary)] border border-[var(--color-border-default)]',
100
+ success: 'bg-[var(--color-success-subtle)] text-[var(--color-success)]',
101
+ warning: 'bg-[var(--color-warning-subtle)] text-[var(--color-warning)]',
102
+ danger: 'bg-[var(--color-danger-subtle)] text-[var(--color-danger)]',
103
+ info: 'bg-[var(--color-info-subtle)] text-[var(--color-info)]',
104
+ accent: 'bg-[var(--color-accent-subtle)] text-[var(--color-accent-hover)]',
105
+ };
106
+
107
+ /* ----------------------------------------------------------------
108
+ COMPONENT
109
+ ---------------------------------------------------------------- */
110
+
111
+ export function Badge({
112
+ variant = 'default',
113
+ size = 'md',
114
+ dot = false,
115
+ removable = false,
116
+ onRemove,
117
+ visible = true,
118
+ children,
119
+ className,
120
+ ...rest
121
+ }: BadgeProps) {
122
+ const classes = [
123
+ 'inline-flex items-center gap-[var(--space-1)]',
124
+ 'rounded-[var(--radius-pill)]',
125
+ 'font-[var(--font-weight-medium)]',
126
+ 'whitespace-nowrap',
127
+ VARIANT_CLASSES[variant],
128
+ SIZE_CLASSES[size],
129
+ className,
130
+ ]
131
+ .filter(Boolean)
132
+ .join(' ');
133
+
134
+ const badgeContent = (
135
+ <span className={classes} {...rest}>
136
+ {dot && (
137
+ <span
138
+ aria-hidden="true"
139
+ className={[
140
+ 'inline-block rounded-full w-1.5 h-1.5 shrink-0',
141
+ DOT_COLOR[variant],
142
+ ].join(' ')}
143
+ />
144
+ )}
145
+
146
+ <span>{children}</span>
147
+
148
+ {removable && (
149
+ <button
150
+ type="button"
151
+ onClick={onRemove}
152
+ aria-label={`Remove ${typeof children === 'string' ? children : 'item'}`}
153
+ className={[
154
+ 'inline-flex items-center justify-center',
155
+ 'w-3.5 h-3.5 rounded-full',
156
+ 'opacity-60 hover:opacity-100',
157
+ 'transition-opacity [transition-duration:var(--duration-instant)]',
158
+ '[transition-timing-function:var(--ease-in-out)]',
159
+ 'focus-visible:outline-none focus-visible:ring-1',
160
+ 'focus-visible:ring-[var(--color-focus-ring)]',
161
+ 'cursor-pointer',
162
+ ].join(' ')}
163
+ >
164
+ <X size={10} aria-hidden="true" />
165
+ </button>
166
+ )}
167
+ </span>
168
+ );
169
+
170
+ /* For removable badges, wrap in AnimatePresence for exit animation */
171
+ if (removable) {
172
+ return (
173
+ <AnimatePresence>
174
+ {visible && (
175
+ <motion.span
176
+ initial={{ opacity: 0, scale: 0.85 }}
177
+ animate={{ opacity: 1, scale: 1 }}
178
+ exit={{ opacity: 0, scale: 0.85 }}
179
+ transition={{
180
+ duration: DURATION.fast / 1000,
181
+ ease: EASING.out,
182
+ }}
183
+ style={{ display: 'inline-flex' }}
184
+ >
185
+ {badgeContent}
186
+ </motion.span>
187
+ )}
188
+ </AnimatePresence>
189
+ );
190
+ }
191
+
192
+ return badgeContent;
193
+ }