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,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* components/primitives/Input.tsx -- saas-dashboard preset v1.0.0
|
|
3
|
+
*
|
|
4
|
+
* WHY FIELD WRAPS INPUT
|
|
5
|
+
* ---------------------
|
|
6
|
+
* A bare <input> has no label, no hint, no error affordance, and no
|
|
7
|
+
* icon slots. Developers who use bare inputs inevitably re-invent
|
|
8
|
+
* these around them, producing inconsistent patterns: one uses a
|
|
9
|
+
* <div> with a floating label, another uses a placeholder, a third
|
|
10
|
+
* uses a <p> beneath with a class that varies per form.
|
|
11
|
+
*
|
|
12
|
+
* The Field primitive is the contract: an accessible input group
|
|
13
|
+
* that ALWAYS has a real <label> element, MAY have a hint, and
|
|
14
|
+
* COMMUNICATES errors accessibly (role=alert, aria-invalid).
|
|
15
|
+
*
|
|
16
|
+
* PLACEHOLDER RULE (non-negotiable)
|
|
17
|
+
* ----------------------------------
|
|
18
|
+
* Placeholder text is NOT a label. It disappears when the user types,
|
|
19
|
+
* is not read by all screen readers in all modes, and fails WCAG
|
|
20
|
+
* 1.3.1 (Info and Relationships). The Field primitive structurally
|
|
21
|
+
* enforces this by requiring the `label` prop and rendering it as a
|
|
22
|
+
* real <label> element.
|
|
23
|
+
*
|
|
24
|
+
* If the visual design calls for a label-free input (search box in
|
|
25
|
+
* a toolbar), pass `labelHidden` to visually hide the label while
|
|
26
|
+
* keeping it in the DOM for screen readers.
|
|
27
|
+
*
|
|
28
|
+
* STATE MODEL
|
|
29
|
+
* -----------
|
|
30
|
+
* resting: border-default background
|
|
31
|
+
* focus: border-emphasis + shadow-focus ring (2px, 2px offset)
|
|
32
|
+
* error: border-danger + error message with role=alert
|
|
33
|
+
* disabled: opacity-50 + cursor-not-allowed on the input
|
|
34
|
+
* (focus-visible ring is also applied via globals.css :focus-visible
|
|
35
|
+
* on the input element itself for keyboard navigation)
|
|
36
|
+
*
|
|
37
|
+
* ICON SLOTS
|
|
38
|
+
* ----------
|
|
39
|
+
* Icons are absolutely positioned inside a relative wrapper.
|
|
40
|
+
* The input's padding-left/padding-right is extended via CSS
|
|
41
|
+
* custom property --input-pl when iconLeft is present (36px).
|
|
42
|
+
* This avoids the icon overlapping text on very short inputs.
|
|
43
|
+
*
|
|
44
|
+
* TRANSITION RULES
|
|
45
|
+
* ----------------
|
|
46
|
+
* Border colour change on focus: --duration-normal, --ease-in-out.
|
|
47
|
+
* Only `border-color` and `box-shadow` are transitioned.
|
|
48
|
+
* Never transition `padding`, `width`, or `height`.
|
|
49
|
+
*
|
|
50
|
+
* TEXTAREA AND SELECT VARIANTS
|
|
51
|
+
* ----------------------------
|
|
52
|
+
* The `as` prop renders a <textarea> or <select> instead of <input>.
|
|
53
|
+
* Both variants share the same visual treatment as the text input.
|
|
54
|
+
* Textarea has a min-height and resizes vertically only (resize:vertical).
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import {
|
|
58
|
+
type InputHTMLAttributes,
|
|
59
|
+
type TextareaHTMLAttributes,
|
|
60
|
+
type SelectHTMLAttributes,
|
|
61
|
+
type ReactNode,
|
|
62
|
+
forwardRef,
|
|
63
|
+
useId,
|
|
64
|
+
} from 'react';
|
|
65
|
+
|
|
66
|
+
/* ----------------------------------------------------------------
|
|
67
|
+
FIELD
|
|
68
|
+
---------------------------------------------------------------- */
|
|
69
|
+
|
|
70
|
+
export interface FieldProps {
|
|
71
|
+
/** REQUIRED: always rendered as <label>, never omitted */
|
|
72
|
+
label: string;
|
|
73
|
+
/** Hint text below input, linked via aria-describedby */
|
|
74
|
+
hint?: string;
|
|
75
|
+
/** Error message: overrides hint, sets aria-invalid on input */
|
|
76
|
+
error?: string;
|
|
77
|
+
/** Visually hides label but keeps it for screen readers */
|
|
78
|
+
labelHidden?: boolean;
|
|
79
|
+
/** Lucide icon inside input, left side */
|
|
80
|
+
iconLeft?: ReactNode;
|
|
81
|
+
/** Lucide icon or button inside input, right side */
|
|
82
|
+
iconRight?: ReactNode;
|
|
83
|
+
/** Adds asterisk to label and aria-required to input */
|
|
84
|
+
required?: boolean;
|
|
85
|
+
/** Input element type; textarea renders a <textarea> */
|
|
86
|
+
as?: 'input' | 'textarea' | 'select';
|
|
87
|
+
/** Passed to the underlying input element */
|
|
88
|
+
inputProps?: InputHTMLAttributes<HTMLInputElement> &
|
|
89
|
+
TextareaHTMLAttributes<HTMLTextAreaElement> &
|
|
90
|
+
SelectHTMLAttributes<HTMLSelectElement>;
|
|
91
|
+
/** Child elements for <select> variant (options) */
|
|
92
|
+
children?: ReactNode;
|
|
93
|
+
/** Additional class on the root Field wrapper */
|
|
94
|
+
className?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function Field({
|
|
98
|
+
label,
|
|
99
|
+
hint,
|
|
100
|
+
error,
|
|
101
|
+
labelHidden = false,
|
|
102
|
+
iconLeft,
|
|
103
|
+
iconRight,
|
|
104
|
+
required = false,
|
|
105
|
+
as: InputAs = 'input',
|
|
106
|
+
inputProps,
|
|
107
|
+
children,
|
|
108
|
+
className,
|
|
109
|
+
}: FieldProps) {
|
|
110
|
+
const id = useId();
|
|
111
|
+
const hintId = `${id}-hint`;
|
|
112
|
+
const errorId = `${id}-error`;
|
|
113
|
+
const hasError = Boolean(error);
|
|
114
|
+
const descBy = [hint ? hintId : '', hasError ? errorId : '']
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.join(' ') || undefined;
|
|
117
|
+
|
|
118
|
+
/* ---- shared input classes ---- */
|
|
119
|
+
const inputClasses = [
|
|
120
|
+
'w-full rounded-[var(--input-radius)]',
|
|
121
|
+
'bg-[var(--color-bg-sunken)]',
|
|
122
|
+
'text-[var(--type-body)] text-[var(--color-text-primary)]',
|
|
123
|
+
'placeholder:text-[var(--color-text-muted)]',
|
|
124
|
+
'border border-[var(--color-border-default)]',
|
|
125
|
+
'py-[var(--spacing-input-pad-v)]',
|
|
126
|
+
/* Horizontal padding adjusts based on icon presence */
|
|
127
|
+
iconLeft
|
|
128
|
+
? 'pl-10 pr-[var(--spacing-input-pad-h)]'
|
|
129
|
+
: iconRight
|
|
130
|
+
? 'pl-[var(--spacing-input-pad-h)] pr-10'
|
|
131
|
+
: 'px-[var(--spacing-input-pad-h)]',
|
|
132
|
+
/* Transitions: only border-color and box-shadow */
|
|
133
|
+
'transition-[border-color,box-shadow]',
|
|
134
|
+
'[transition-duration:var(--duration-normal)]',
|
|
135
|
+
'[transition-timing-function:var(--ease-in-out)]',
|
|
136
|
+
/* Focus state */
|
|
137
|
+
'focus:outline-none',
|
|
138
|
+
'focus:border-[var(--color-border-emphasis)]',
|
|
139
|
+
'focus:shadow-[var(--shadow-focus)]',
|
|
140
|
+
/* Error state */
|
|
141
|
+
hasError
|
|
142
|
+
? 'border-[var(--color-danger)] focus:border-[var(--color-danger)]'
|
|
143
|
+
: '',
|
|
144
|
+
/* Disabled state */
|
|
145
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
146
|
+
/* Textarea specific */
|
|
147
|
+
InputAs === 'textarea'
|
|
148
|
+
? 'resize-vertical min-h-[var(--space-32)] leading-[var(--type-body-lh)]'
|
|
149
|
+
: 'h-[var(--input-h)]',
|
|
150
|
+
/* Select specific */
|
|
151
|
+
InputAs === 'select' ? 'cursor-pointer appearance-none' : '',
|
|
152
|
+
]
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.join(' ');
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div className={['flex flex-col gap-[var(--space-2)]', className].filter(Boolean).join(' ')}>
|
|
158
|
+
{/* Label -- always present in DOM, never replaced by placeholder */}
|
|
159
|
+
<label
|
|
160
|
+
htmlFor={id}
|
|
161
|
+
className={[
|
|
162
|
+
'text-[var(--type-small)] font-[var(--font-weight-medium)]',
|
|
163
|
+
'text-[var(--color-text-primary)]',
|
|
164
|
+
'leading-[var(--type-small-lh)]',
|
|
165
|
+
labelHidden ? 'sr-only' : '',
|
|
166
|
+
]
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
.join(' ')}
|
|
169
|
+
>
|
|
170
|
+
{label}
|
|
171
|
+
{required && (
|
|
172
|
+
<span
|
|
173
|
+
aria-hidden="true"
|
|
174
|
+
className="ml-[var(--space-1)] text-[var(--color-danger)]"
|
|
175
|
+
>
|
|
176
|
+
*
|
|
177
|
+
</span>
|
|
178
|
+
)}
|
|
179
|
+
</label>
|
|
180
|
+
|
|
181
|
+
{/* Input wrapper: relative for icon absolute positioning */}
|
|
182
|
+
<div className="relative">
|
|
183
|
+
{iconLeft && (
|
|
184
|
+
<span
|
|
185
|
+
aria-hidden="true"
|
|
186
|
+
className={[
|
|
187
|
+
'absolute left-[var(--space-3)] top-1/2 -translate-y-1/2',
|
|
188
|
+
'text-[var(--color-text-muted)] pointer-events-none',
|
|
189
|
+
'flex items-center justify-center w-4 h-4',
|
|
190
|
+
].join(' ')}
|
|
191
|
+
>
|
|
192
|
+
{iconLeft}
|
|
193
|
+
</span>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{InputAs === 'textarea' ? (
|
|
197
|
+
<textarea
|
|
198
|
+
id={id}
|
|
199
|
+
aria-invalid={hasError || undefined}
|
|
200
|
+
aria-describedby={descBy}
|
|
201
|
+
aria-required={required || undefined}
|
|
202
|
+
className={inputClasses}
|
|
203
|
+
{...(inputProps as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
|
204
|
+
/>
|
|
205
|
+
) : InputAs === 'select' ? (
|
|
206
|
+
<select
|
|
207
|
+
id={id}
|
|
208
|
+
aria-invalid={hasError || undefined}
|
|
209
|
+
aria-describedby={descBy}
|
|
210
|
+
aria-required={required || undefined}
|
|
211
|
+
className={inputClasses}
|
|
212
|
+
{...(inputProps as SelectHTMLAttributes<HTMLSelectElement>)}
|
|
213
|
+
>
|
|
214
|
+
{children}
|
|
215
|
+
</select>
|
|
216
|
+
) : (
|
|
217
|
+
<input
|
|
218
|
+
id={id}
|
|
219
|
+
aria-invalid={hasError || undefined}
|
|
220
|
+
aria-describedby={descBy}
|
|
221
|
+
aria-required={required || undefined}
|
|
222
|
+
className={inputClasses}
|
|
223
|
+
{...(inputProps as InputHTMLAttributes<HTMLInputElement>)}
|
|
224
|
+
/>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{iconRight && (
|
|
228
|
+
<span
|
|
229
|
+
aria-hidden="true"
|
|
230
|
+
className={[
|
|
231
|
+
'absolute right-[var(--space-3)] top-1/2 -translate-y-1/2',
|
|
232
|
+
'text-[var(--color-text-muted)]',
|
|
233
|
+
'flex items-center justify-center w-4 h-4',
|
|
234
|
+
].join(' ')}
|
|
235
|
+
>
|
|
236
|
+
{iconRight}
|
|
237
|
+
</span>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
{/* Error message: role=alert ensures SR announces immediately */}
|
|
242
|
+
{hasError && (
|
|
243
|
+
<p
|
|
244
|
+
id={errorId}
|
|
245
|
+
role="alert"
|
|
246
|
+
className={[
|
|
247
|
+
'text-[var(--type-caption)] leading-[var(--type-caption-lh)]',
|
|
248
|
+
'text-[var(--color-danger)]',
|
|
249
|
+
].join(' ')}
|
|
250
|
+
>
|
|
251
|
+
{error}
|
|
252
|
+
</p>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{/* Hint text: only shown when no error (error takes priority) */}
|
|
256
|
+
{hint && !hasError && (
|
|
257
|
+
<p
|
|
258
|
+
id={hintId}
|
|
259
|
+
className={[
|
|
260
|
+
'text-[var(--type-caption)] leading-[var(--type-caption-lh)]',
|
|
261
|
+
'text-[var(--color-text-muted)]',
|
|
262
|
+
].join(' ')}
|
|
263
|
+
>
|
|
264
|
+
{hint}
|
|
265
|
+
</p>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ----------------------------------------------------------------
|
|
272
|
+
INPUT (bare, for use outside Field context when Field is overkill)
|
|
273
|
+
Forwards a ref for form library integration (react-hook-form).
|
|
274
|
+
---------------------------------------------------------------- */
|
|
275
|
+
|
|
276
|
+
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
277
|
+
/** Applies error styling without full Field wrapper */
|
|
278
|
+
hasError?: boolean;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
282
|
+
({ hasError, className, ...rest }, ref) => (
|
|
283
|
+
<input
|
|
284
|
+
ref={ref}
|
|
285
|
+
aria-invalid={hasError || undefined}
|
|
286
|
+
className={[
|
|
287
|
+
'w-full h-[var(--input-h)] rounded-[var(--input-radius)]',
|
|
288
|
+
'bg-[var(--color-bg-sunken)]',
|
|
289
|
+
'text-[var(--type-body)] text-[var(--color-text-primary)]',
|
|
290
|
+
'placeholder:text-[var(--color-text-muted)]',
|
|
291
|
+
'border border-[var(--color-border-default)]',
|
|
292
|
+
'px-[var(--spacing-input-pad-h)]',
|
|
293
|
+
'transition-[border-color,box-shadow]',
|
|
294
|
+
'[transition-duration:var(--duration-normal)]',
|
|
295
|
+
'[transition-timing-function:var(--ease-in-out)]',
|
|
296
|
+
'focus:outline-none',
|
|
297
|
+
'focus:border-[var(--color-border-emphasis)]',
|
|
298
|
+
'focus:shadow-[var(--shadow-focus)]',
|
|
299
|
+
hasError ? 'border-[var(--color-danger)]' : '',
|
|
300
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
301
|
+
className,
|
|
302
|
+
]
|
|
303
|
+
.filter(Boolean)
|
|
304
|
+
.join(' ')}
|
|
305
|
+
{...rest}
|
|
306
|
+
/>
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
Input.displayName = 'Input';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* components/primitives/Spinner.tsx -- saas-dashboard preset v1.0.0
|
|
4
|
+
*
|
|
5
|
+
* WHY SVG AND NOT A BORDER-BASED SPINNER
|
|
6
|
+
* ---------------------------------------
|
|
7
|
+
* CSS border-based spinners (border-t-transparent trick) are common but
|
|
8
|
+
* have two problems in a design-system context:
|
|
9
|
+
* 1. They rely on a border on the element itself, which conflicts with
|
|
10
|
+
* Button's own border (secondary/destructive variants).
|
|
11
|
+
* 2. They cannot easily inherit the parent's text color at different
|
|
12
|
+
* opacities for the track vs. indicator.
|
|
13
|
+
*
|
|
14
|
+
* An SVG circle with stroke-dasharray/stroke-dashoffset animation:
|
|
15
|
+
* - Is fully isolated from the surrounding layout
|
|
16
|
+
* - Inherits currentColor for both track and indicator (different opacities)
|
|
17
|
+
* - Renders at exactly the declared size (no border-box inconsistencies)
|
|
18
|
+
* - Is composable: Spinner renders inline, needs no wrapper
|
|
19
|
+
*
|
|
20
|
+
* REDUCED MOTION
|
|
21
|
+
* --------------
|
|
22
|
+
* When prefers-reduced-motion is active, the rotating animation is
|
|
23
|
+
* replaced with a gentle opacity pulse. This preserves the loading
|
|
24
|
+
* feedback without vestibular-triggering rotation.
|
|
25
|
+
* The globals.css @media block collapses animation-duration to 0.01ms
|
|
26
|
+
* which effectively freezes the spinner. We supplement with a CSS
|
|
27
|
+
* class that triggers the static pulse fallback.
|
|
28
|
+
*
|
|
29
|
+
* SIZE SYSTEM
|
|
30
|
+
* -----------
|
|
31
|
+
* sm: 16px -- inside sm/md Button, inline with body text
|
|
32
|
+
* md: 20px -- inside lg Button, standalone inline indicators
|
|
33
|
+
* lg: 32px -- full-page Suspense boundaries, large empty states
|
|
34
|
+
*
|
|
35
|
+
* COLOR
|
|
36
|
+
* -----
|
|
37
|
+
* The spinner inherits currentColor from its parent. Inside a
|
|
38
|
+
* primary Button (white text) it renders white. Inside a secondary
|
|
39
|
+
* Button (text-primary) it renders text-primary. No explicit color
|
|
40
|
+
* prop needed; the variant system handles this via text color.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const SIZES: Record<string, number> = {
|
|
44
|
+
sm: 16,
|
|
45
|
+
md: 20,
|
|
46
|
+
lg: 32,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const STROKE_WIDTH: Record<string, number> = {
|
|
50
|
+
sm: 2,
|
|
51
|
+
md: 2,
|
|
52
|
+
lg: 2.5,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export interface SpinnerProps {
|
|
56
|
+
/** Visual size */
|
|
57
|
+
size?: 'sm' | 'md' | 'lg';
|
|
58
|
+
/** Additional class names */
|
|
59
|
+
className?: string;
|
|
60
|
+
/** Aria label for standalone loading indicators */
|
|
61
|
+
'aria-label'?: string;
|
|
62
|
+
/** Explicitly suppress aria for decorative usage inside Button */
|
|
63
|
+
'aria-hidden'?: boolean | 'true' | 'false';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function Spinner({
|
|
67
|
+
size = 'md',
|
|
68
|
+
className,
|
|
69
|
+
'aria-label': ariaLabel,
|
|
70
|
+
'aria-hidden': ariaHidden,
|
|
71
|
+
}: SpinnerProps) {
|
|
72
|
+
const px = SIZES[size] ?? SIZES.md;
|
|
73
|
+
const stroke = STROKE_WIDTH[size] ?? 2;
|
|
74
|
+
const r = (px - stroke * 2) / 2;
|
|
75
|
+
const circumference = 2 * Math.PI * r;
|
|
76
|
+
/* Show ~25% of circumference as the visible arc */
|
|
77
|
+
const dasharray = `${circumference * 0.25} ${circumference * 0.75}`;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<svg
|
|
81
|
+
width={px}
|
|
82
|
+
height={px}
|
|
83
|
+
viewBox={`0 0 ${px} ${px}`}
|
|
84
|
+
fill="none"
|
|
85
|
+
aria-hidden={ariaHidden}
|
|
86
|
+
aria-label={ariaLabel}
|
|
87
|
+
role={ariaLabel ? 'status' : undefined}
|
|
88
|
+
className={['shrink-0', 'spinner-svg', className].filter(Boolean).join(' ')}
|
|
89
|
+
style={
|
|
90
|
+
{
|
|
91
|
+
/* Rotation animation: composited on GPU (transform only) */
|
|
92
|
+
animation:
|
|
93
|
+
'spinner-rotate 0.75s linear infinite',
|
|
94
|
+
} as React.CSSProperties
|
|
95
|
+
}
|
|
96
|
+
>
|
|
97
|
+
<style>{`
|
|
98
|
+
@keyframes spinner-rotate {
|
|
99
|
+
from { transform: rotate(0deg); transform-origin: center; }
|
|
100
|
+
to { transform: rotate(360deg); transform-origin: center; }
|
|
101
|
+
}
|
|
102
|
+
@keyframes spinner-pulse {
|
|
103
|
+
0%, 100% { opacity: 1; }
|
|
104
|
+
50% { opacity: 0.3; }
|
|
105
|
+
}
|
|
106
|
+
@media (prefers-reduced-motion: reduce) {
|
|
107
|
+
.spinner-svg { animation: spinner-pulse 1.4s ease-in-out infinite !important; }
|
|
108
|
+
}
|
|
109
|
+
`}</style>
|
|
110
|
+
|
|
111
|
+
{/* Track: full circle at low opacity for visual context */}
|
|
112
|
+
<circle
|
|
113
|
+
cx={px / 2}
|
|
114
|
+
cy={px / 2}
|
|
115
|
+
r={r}
|
|
116
|
+
stroke="currentColor"
|
|
117
|
+
strokeWidth={stroke}
|
|
118
|
+
opacity={0.15}
|
|
119
|
+
/>
|
|
120
|
+
|
|
121
|
+
{/* Indicator: 25% arc, full opacity */}
|
|
122
|
+
<circle
|
|
123
|
+
cx={px / 2}
|
|
124
|
+
cy={px / 2}
|
|
125
|
+
r={r}
|
|
126
|
+
stroke="currentColor"
|
|
127
|
+
strokeWidth={stroke}
|
|
128
|
+
strokeDasharray={dasharray}
|
|
129
|
+
strokeLinecap="round"
|
|
130
|
+
opacity={0.9}
|
|
131
|
+
/>
|
|
132
|
+
</svg>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* components/primitives/index.ts -- saas-dashboard preset v1.0.0
|
|
3
|
+
*
|
|
4
|
+
* WHY A BARREL EXPORT
|
|
5
|
+
* --------------------
|
|
6
|
+
* Without a barrel, consumers must know the exact file path for each
|
|
7
|
+
* primitive, leading to divergence:
|
|
8
|
+
* import { Button } from '@/components/primitives/Button'
|
|
9
|
+
* import { Card } from '@/components/primitives/card' // different casing
|
|
10
|
+
* import { Badge } from '../../primitives/Badge' // relative path
|
|
11
|
+
*
|
|
12
|
+
* The barrel enforces a single import path:
|
|
13
|
+
* import { Button, Card, CardHeader, Field, Badge } from '@/components/primitives'
|
|
14
|
+
*
|
|
15
|
+
* This means:
|
|
16
|
+
* 1. Renaming a primitive file only requires updating this file.
|
|
17
|
+
* 2. IDE autocomplete surfaces all available primitives from one location.
|
|
18
|
+
* 3. Tree-shaking works correctly -- unused primitives are eliminated.
|
|
19
|
+
*
|
|
20
|
+
* WHAT BELONGS HERE
|
|
21
|
+
* -----------------
|
|
22
|
+
* Only true primitives: components with no dependencies on other local
|
|
23
|
+
* components (only design tokens and external packages).
|
|
24
|
+
* Composite components (DataTable, StatCard, CommandPalette) have their
|
|
25
|
+
* own barrel exports in their respective directories.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/* Button + types */
|
|
29
|
+
export { Button } from './Button';
|
|
30
|
+
export type { ButtonProps } from './Button';
|
|
31
|
+
|
|
32
|
+
/* Card compound component + all sub-component types */
|
|
33
|
+
export {
|
|
34
|
+
Card,
|
|
35
|
+
CardHeader,
|
|
36
|
+
CardTitle,
|
|
37
|
+
CardDescription,
|
|
38
|
+
CardContent,
|
|
39
|
+
CardFooter,
|
|
40
|
+
} from './Card';
|
|
41
|
+
export type {
|
|
42
|
+
CardProps,
|
|
43
|
+
CardHeaderProps,
|
|
44
|
+
CardTitleProps,
|
|
45
|
+
CardDescriptionProps,
|
|
46
|
+
CardContentProps,
|
|
47
|
+
CardFooterProps,
|
|
48
|
+
} from './Card';
|
|
49
|
+
|
|
50
|
+
/* Input + Field + types */
|
|
51
|
+
export { Input, Field } from './Input';
|
|
52
|
+
export type { InputProps, FieldProps } from './Input';
|
|
53
|
+
|
|
54
|
+
/* Badge + types */
|
|
55
|
+
export { Badge } from './Badge';
|
|
56
|
+
export type { BadgeProps } from './Badge';
|
|
57
|
+
|
|
58
|
+
/* Spinner + types */
|
|
59
|
+
export { Spinner } from './Spinner';
|
|
60
|
+
export type { SpinnerProps } from './Spinner';
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* AppShell.tsx -- Application Shell Composition
|
|
3
|
+
* ============================================================
|
|
4
|
+
* WHY THIS FILE EXISTS
|
|
5
|
+
* --------------------
|
|
6
|
+
* The app shell is the outermost spatial container for every
|
|
7
|
+
* authenticated page. It establishes the two-dimensional layout
|
|
8
|
+
* that all page content lives within:
|
|
9
|
+
*
|
|
10
|
+
* [sidebar column | main column]
|
|
11
|
+
* Sidebar Topbar (sticky)
|
|
12
|
+
* <main> (scrolls)
|
|
13
|
+
* Container
|
|
14
|
+
* {children}
|
|
15
|
+
*
|
|
16
|
+
* LAYOUT APPROACH: CSS GRID + CSS VARIABLE COLUMN WIDTH
|
|
17
|
+
* ------------------------------------------------------
|
|
18
|
+
* We use CSS Grid with a single-row, two-column layout. The
|
|
19
|
+
* sidebar column width is driven by a CSS custom property
|
|
20
|
+
* (--shell-sidebar-w) that is updated by SidebarContext when
|
|
21
|
+
* the user collapses or expands. This means:
|
|
22
|
+
*
|
|
23
|
+
* - The grid reacts to state changes without any JavaScript
|
|
24
|
+
* calculating pixel positions.
|
|
25
|
+
* - The transition is applied to grid-template-columns via a
|
|
26
|
+
* CSS transition. Modern browsers animate this correctly
|
|
27
|
+
* when the value is a length.
|
|
28
|
+
* - No absolute positioning or transform-based sliding is
|
|
29
|
+
* needed on the sidebar element itself for desktop layout.
|
|
30
|
+
*
|
|
31
|
+
* RESPONSIVE BEHAVIOR
|
|
32
|
+
* -------------------
|
|
33
|
+
* Three breakpoint tiers:
|
|
34
|
+
*
|
|
35
|
+
* Mobile (<768px):
|
|
36
|
+
* - Grid collapses to a single column (sidebar column = 0).
|
|
37
|
+
* - Sidebar is removed from flow (position: fixed, off-screen
|
|
38
|
+
* via translateX(-100%)).
|
|
39
|
+
* - A backdrop overlay appears when isMobileOpen is true.
|
|
40
|
+
* - The Topbar's hamburger button calls openMobile().
|
|
41
|
+
*
|
|
42
|
+
* Tablet (768px-1023px):
|
|
43
|
+
* - Grid sidebar column exists but defaults to collapsed
|
|
44
|
+
* width (64px). User can expand.
|
|
45
|
+
*
|
|
46
|
+
* Desktop (>=1024px):
|
|
47
|
+
* - Full sidebar visible; collapse is optional.
|
|
48
|
+
*
|
|
49
|
+
* FOCUS MANAGEMENT ON ROUTE CHANGE
|
|
50
|
+
* ----------------------------------
|
|
51
|
+
* When Next.js navigates to a new route, focus is moved to the
|
|
52
|
+
* <main> element via a ref. This ensures screen reader users
|
|
53
|
+
* hear the new page content without manually navigating there.
|
|
54
|
+
* The main element receives tabIndex={-1} so it can be focused
|
|
55
|
+
* programmatically without appearing in the natural tab order.
|
|
56
|
+
*
|
|
57
|
+
* Z-INDEX SCALE (defined in globals.css)
|
|
58
|
+
* ---------------------------------------
|
|
59
|
+
* --z-sidebar: 30 (desktop: in normal flow; mobile: fixed overlay)
|
|
60
|
+
* --z-topbar: 40
|
|
61
|
+
* --z-dropdown: 50
|
|
62
|
+
* --z-tooltip: 60
|
|
63
|
+
* --z-modal: 70
|
|
64
|
+
* --z-toast: 80
|
|
65
|
+
* --z-command: 90
|
|
66
|
+
*
|
|
67
|
+
* PERFORMANCE
|
|
68
|
+
* -----------
|
|
69
|
+
* We animate only the CSS custom property change that drives
|
|
70
|
+
* grid-template-columns width. No width, height, or position
|
|
71
|
+
* properties are transitioned on individual elements.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
'use client'
|
|
75
|
+
|
|
76
|
+
import React, { useRef, useEffect, useCallback } from 'react'
|
|
77
|
+
import { usePathname } from 'next/navigation'
|
|
78
|
+
import { useSidebar } from './SidebarContext'
|
|
79
|
+
import { Sidebar } from './Sidebar'
|
|
80
|
+
import { Topbar } from './Topbar'
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Types
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
interface AppShellProps {
|
|
87
|
+
children: React.ReactNode
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Component
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export function AppShell({ children }: AppShellProps) {
|
|
95
|
+
const { isCollapsed, isMobileOpen, closeMobile } = useSidebar()
|
|
96
|
+
const mainRef = useRef<HTMLElement>(null)
|
|
97
|
+
const pathname = usePathname()
|
|
98
|
+
|
|
99
|
+
// Focus management: when the route changes, move focus to <main>.
|
|
100
|
+
// This is a11y-critical for keyboard and screen reader navigation.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
mainRef.current?.focus({ preventScroll: true })
|
|
103
|
+
}, [pathname])
|
|
104
|
+
|
|
105
|
+
// Close mobile drawer when Escape is pressed.
|
|
106
|
+
const handleKeyDown = useCallback(
|
|
107
|
+
(e: React.KeyboardEvent) => {
|
|
108
|
+
if (e.key === 'Escape' && isMobileOpen) {
|
|
109
|
+
closeMobile()
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
[isMobileOpen, closeMobile]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
/*
|
|
117
|
+
* The shell-root div uses CSS custom properties to drive the
|
|
118
|
+
* grid column width. The inline style sets the --shell-sidebar-w
|
|
119
|
+
* variable so the CSS transition in globals.css picks it up.
|
|
120
|
+
* Using an inline style for a single token change is intentional:
|
|
121
|
+
* it avoids className string manipulation while keeping the
|
|
122
|
+
* transition on the CSS side (not JS-driven).
|
|
123
|
+
*/
|
|
124
|
+
<div
|
|
125
|
+
className="shell-root"
|
|
126
|
+
style={{
|
|
127
|
+
// This CSS variable feeds grid-template-columns in globals.css.
|
|
128
|
+
// The transition is declared on the .shell-root rule there.
|
|
129
|
+
'--shell-sidebar-w': isCollapsed
|
|
130
|
+
? 'var(--spacing-sidebar-w-col)'
|
|
131
|
+
: 'var(--spacing-sidebar-w)',
|
|
132
|
+
} as React.CSSProperties}
|
|
133
|
+
onKeyDown={handleKeyDown}
|
|
134
|
+
>
|
|
135
|
+
{/*
|
|
136
|
+
* Mobile backdrop overlay. Visible only when isMobileOpen.
|
|
137
|
+
* Clicking it closes the drawer. It sits below the sidebar
|
|
138
|
+
* (z-index 29) so it does not intercept sidebar interactions.
|
|
139
|
+
* The backdrop uses opacity transition (not visibility) so
|
|
140
|
+
* reduced-motion users still see it fade correctly (opacity
|
|
141
|
+
* transitions at 80ms are preserved under reduced-motion).
|
|
142
|
+
*/}
|
|
143
|
+
{isMobileOpen && (
|
|
144
|
+
<div
|
|
145
|
+
className="shell-backdrop"
|
|
146
|
+
aria-hidden="true"
|
|
147
|
+
onClick={closeMobile}
|
|
148
|
+
/>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{/* Sidebar: fixed on mobile, in-flow grid column on desktop */}
|
|
152
|
+
<Sidebar />
|
|
153
|
+
|
|
154
|
+
{/*
|
|
155
|
+
* Main column: contains the sticky Topbar and the scrollable
|
|
156
|
+
* page content. overflow-y: auto here (not on <body>) so the
|
|
157
|
+
* sidebar never scrolls with the page content.
|
|
158
|
+
*/}
|
|
159
|
+
<div className="shell-main">
|
|
160
|
+
<Topbar />
|
|
161
|
+
<main
|
|
162
|
+
ref={mainRef}
|
|
163
|
+
id="main-content"
|
|
164
|
+
// tabIndex={-1} allows programmatic focus (route change)
|
|
165
|
+
// without placing <main> in the natural tab order.
|
|
166
|
+
tabIndex={-1}
|
|
167
|
+
className="shell-content"
|
|
168
|
+
>
|
|
169
|
+
{children}
|
|
170
|
+
</main>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|