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,767 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* components/interaction/CommandPalette.tsx
|
|
3
|
+
* SaaS Dashboard Preset -- D_interaction phase
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* ---------------
|
|
7
|
+
* A command palette is the power-user navigation contract. It lets a user
|
|
8
|
+
* navigate to any route, trigger any action, and search any entity without
|
|
9
|
+
* lifting their hands from the keyboard. Without it, dense dashboard UIs
|
|
10
|
+
* force too many pointer round-trips; WITH it, experienced users can operate
|
|
11
|
+
* entirely via keyboard.
|
|
12
|
+
*
|
|
13
|
+
* ARCHITECTURE DECISIONS
|
|
14
|
+
* ----------------------
|
|
15
|
+
* 1. Hand-rolled, no `cmdk` package: cmdk is excellent but adds a non-trivial
|
|
16
|
+
* peer-dep surface. This implementation mirrors cmdk semantics (Command,
|
|
17
|
+
* CommandInput, CommandGroup, CommandItem) purely in React + framer-motion,
|
|
18
|
+
* keeping the preset self-contained. Any team that prefers cmdk can drop
|
|
19
|
+
* this file and re-wrap in ~30 lines.
|
|
20
|
+
*
|
|
21
|
+
* 2. Portal rendering via React createPortal: the palette must visually escape
|
|
22
|
+
* any overflow:hidden ancestor (sidebar, main scroll area). Portaling to
|
|
23
|
+
* document.body is the only reliable solution; React 18's concurrent portal
|
|
24
|
+
* maintains the context tree so SidebarContext and theme tokens remain live.
|
|
25
|
+
*
|
|
26
|
+
* 3. Overlay blur for depth: a semi-opaque backdrop with backdrop-filter:blur
|
|
27
|
+
* gives the palette "above everything" depth without a hard black overlay
|
|
28
|
+
* that would feel oppressive in an already-dark UI.
|
|
29
|
+
*
|
|
30
|
+
* 4. Fuzzy filter: a simple substring match across label + keywords. For
|
|
31
|
+
* production, swap the `filterItems` function with a Fuse.js call or a
|
|
32
|
+
* server-side search endpoint -- the interface is identical.
|
|
33
|
+
*
|
|
34
|
+
* 5. Focus trap: while open, Tab and Shift+Tab cycle through the input and
|
|
35
|
+
* results. Escape closes. Arrow Up/Down navigate results. Enter activates.
|
|
36
|
+
* All keyboard affordances are listed in the sr-only description so screen
|
|
37
|
+
* reader users know the contract on first open.
|
|
38
|
+
*
|
|
39
|
+
* 6. Reduced-motion: framer-motion variants collapse to opacity-only when
|
|
40
|
+
* prefers-reduced-motion is detected via the useReducedMotion hook.
|
|
41
|
+
*
|
|
42
|
+
* ANIMATION RATIONALE
|
|
43
|
+
* -------------------
|
|
44
|
+
* Entering: scale 0.96 -> 1 + opacity 0 -> 1, ease-out (decelerate to rest).
|
|
45
|
+
* Exiting: scale 1 -> 0.96 + opacity 1 -> 0, ease-in (accelerate away).
|
|
46
|
+
* Duration: 200ms enter (duration-normal), 150ms exit (duration-fast).
|
|
47
|
+
* Backdrop: opacity only, duration-normal.
|
|
48
|
+
*
|
|
49
|
+
* TOKEN USAGE
|
|
50
|
+
* -----------
|
|
51
|
+
* All colors: CSS custom properties from globals.css semantic layer.
|
|
52
|
+
* All durations: CSS custom properties (mirrored in DURATION from brand.ts).
|
|
53
|
+
* No raw hex, no raw millisecond literals.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
'use client';
|
|
57
|
+
|
|
58
|
+
import React, {
|
|
59
|
+
useState,
|
|
60
|
+
useEffect,
|
|
61
|
+
useCallback,
|
|
62
|
+
useRef,
|
|
63
|
+
useId,
|
|
64
|
+
createContext,
|
|
65
|
+
useContext,
|
|
66
|
+
type KeyboardEvent,
|
|
67
|
+
} from 'react';
|
|
68
|
+
import { createPortal } from 'react-dom';
|
|
69
|
+
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
|
70
|
+
import {
|
|
71
|
+
Search,
|
|
72
|
+
LayoutDashboard,
|
|
73
|
+
Users,
|
|
74
|
+
BarChart2,
|
|
75
|
+
Settings,
|
|
76
|
+
FileText,
|
|
77
|
+
Bell,
|
|
78
|
+
LogOut,
|
|
79
|
+
ChevronRight,
|
|
80
|
+
Zap,
|
|
81
|
+
} from 'lucide-react';
|
|
82
|
+
import { DURATION, EASING } from '@/lib/brand';
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Types
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export interface CommandItem {
|
|
89
|
+
id: string;
|
|
90
|
+
label: string;
|
|
91
|
+
/** Short description shown below label */
|
|
92
|
+
description?: string;
|
|
93
|
+
/** Lucide React node, rendered at 16px */
|
|
94
|
+
icon?: React.ReactNode;
|
|
95
|
+
/** Group this item belongs to */
|
|
96
|
+
group: string;
|
|
97
|
+
/** Extra tokens for fuzzy matching that don't appear in UI */
|
|
98
|
+
keywords?: string[];
|
|
99
|
+
/** Called on selection */
|
|
100
|
+
onSelect: () => void;
|
|
101
|
+
/** Optional keyboard shortcut hint (display only) */
|
|
102
|
+
shortcut?: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface CommandGroup {
|
|
106
|
+
id: string;
|
|
107
|
+
label: string;
|
|
108
|
+
items: CommandItem[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Context (allows Topbar / anywhere to open the palette)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
interface CommandPaletteContextValue {
|
|
116
|
+
open: () => void;
|
|
117
|
+
close: () => void;
|
|
118
|
+
isOpen: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const CommandPaletteContext = createContext<CommandPaletteContextValue>({
|
|
122
|
+
open: () => undefined,
|
|
123
|
+
close: () => undefined,
|
|
124
|
+
isOpen: false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export function useCommandPalette(): CommandPaletteContextValue {
|
|
128
|
+
return useContext(CommandPaletteContext);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Fuzzy filter
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
function filterItems(items: CommandItem[], query: string): CommandItem[] {
|
|
136
|
+
if (!query.trim()) return items;
|
|
137
|
+
const q = query.toLowerCase();
|
|
138
|
+
return items.filter((item) => {
|
|
139
|
+
const haystack = [
|
|
140
|
+
item.label,
|
|
141
|
+
item.description ?? '',
|
|
142
|
+
...(item.keywords ?? []),
|
|
143
|
+
]
|
|
144
|
+
.join(' ')
|
|
145
|
+
.toLowerCase();
|
|
146
|
+
return haystack.includes(q);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function groupByGroup(
|
|
151
|
+
items: CommandItem[],
|
|
152
|
+
allGroups: CommandGroup[],
|
|
153
|
+
): CommandGroup[] {
|
|
154
|
+
const grouped = new Map<string, CommandItem[]>();
|
|
155
|
+
for (const item of items) {
|
|
156
|
+
const arr = grouped.get(item.group) ?? [];
|
|
157
|
+
arr.push(item);
|
|
158
|
+
grouped.set(item.group, arr);
|
|
159
|
+
}
|
|
160
|
+
// Preserve original group ordering
|
|
161
|
+
return allGroups
|
|
162
|
+
.map((g) => ({ ...g, items: grouped.get(g.id) ?? [] }))
|
|
163
|
+
.filter((g) => g.items.length > 0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Default command set (demo data; replace or extend per project)
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const DEFAULT_GROUPS: CommandGroup[] = [
|
|
171
|
+
{
|
|
172
|
+
id: 'navigation',
|
|
173
|
+
label: 'Navigation',
|
|
174
|
+
items: [
|
|
175
|
+
{
|
|
176
|
+
id: 'nav-dashboard',
|
|
177
|
+
label: 'Dashboard',
|
|
178
|
+
description: 'Overview and KPIs',
|
|
179
|
+
icon: <LayoutDashboard size={16} aria-hidden="true" />,
|
|
180
|
+
group: 'navigation',
|
|
181
|
+
keywords: ['home', 'overview', 'metrics'],
|
|
182
|
+
onSelect: () => { window.location.href = '/dashboard'; },
|
|
183
|
+
shortcut: ['G', 'D'],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'nav-users',
|
|
187
|
+
label: 'Users',
|
|
188
|
+
description: 'Manage team members',
|
|
189
|
+
icon: <Users size={16} aria-hidden="true" />,
|
|
190
|
+
group: 'navigation',
|
|
191
|
+
keywords: ['people', 'team', 'members', 'accounts'],
|
|
192
|
+
onSelect: () => { window.location.href = '/dashboard/users'; },
|
|
193
|
+
shortcut: ['G', 'U'],
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: 'nav-analytics',
|
|
197
|
+
label: 'Analytics',
|
|
198
|
+
description: 'Charts and reports',
|
|
199
|
+
icon: <BarChart2 size={16} aria-hidden="true" />,
|
|
200
|
+
group: 'navigation',
|
|
201
|
+
keywords: ['charts', 'reports', 'data', 'graphs'],
|
|
202
|
+
onSelect: () => { window.location.href = '/dashboard/analytics'; },
|
|
203
|
+
shortcut: ['G', 'A'],
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: 'actions',
|
|
209
|
+
label: 'Quick Actions',
|
|
210
|
+
items: [
|
|
211
|
+
{
|
|
212
|
+
id: 'action-new-report',
|
|
213
|
+
label: 'New Report',
|
|
214
|
+
description: 'Create a custom analytics report',
|
|
215
|
+
icon: <FileText size={16} aria-hidden="true" />,
|
|
216
|
+
group: 'actions',
|
|
217
|
+
keywords: ['create', 'report', 'analytics', 'export'],
|
|
218
|
+
onSelect: () => { /* trigger report modal */ },
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: 'action-invite',
|
|
222
|
+
label: 'Invite Team Member',
|
|
223
|
+
description: 'Send an invitation email',
|
|
224
|
+
icon: <Zap size={16} aria-hidden="true" />,
|
|
225
|
+
group: 'actions',
|
|
226
|
+
keywords: ['invite', 'add user', 'team', 'email'],
|
|
227
|
+
onSelect: () => { /* trigger invite modal */ },
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 'action-notifications',
|
|
231
|
+
label: 'Notifications',
|
|
232
|
+
description: 'View recent alerts',
|
|
233
|
+
icon: <Bell size={16} aria-hidden="true" />,
|
|
234
|
+
group: 'actions',
|
|
235
|
+
keywords: ['alerts', 'updates', 'messages'],
|
|
236
|
+
onSelect: () => { window.location.href = '/dashboard/notifications'; },
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: 'settings',
|
|
242
|
+
label: 'Settings',
|
|
243
|
+
items: [
|
|
244
|
+
{
|
|
245
|
+
id: 'settings-general',
|
|
246
|
+
label: 'General Settings',
|
|
247
|
+
description: 'Account and workspace preferences',
|
|
248
|
+
icon: <Settings size={16} aria-hidden="true" />,
|
|
249
|
+
group: 'settings',
|
|
250
|
+
keywords: ['preferences', 'account', 'workspace', 'config'],
|
|
251
|
+
onSelect: () => { window.location.href = '/dashboard/settings'; },
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: 'settings-logout',
|
|
255
|
+
label: 'Sign Out',
|
|
256
|
+
description: 'End your current session',
|
|
257
|
+
icon: <LogOut size={16} aria-hidden="true" />,
|
|
258
|
+
group: 'settings',
|
|
259
|
+
keywords: ['logout', 'signout', 'exit', 'session'],
|
|
260
|
+
onSelect: () => { /* trigger sign-out */ },
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
const ALL_ITEMS: CommandItem[] = DEFAULT_GROUPS.flatMap((g) => g.items);
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// CommandPaletteProvider (wraps layout)
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
export function CommandPaletteProvider({
|
|
273
|
+
children,
|
|
274
|
+
}: {
|
|
275
|
+
children: React.ReactNode;
|
|
276
|
+
}) {
|
|
277
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
278
|
+
|
|
279
|
+
const open = useCallback(() => setIsOpen(true), []);
|
|
280
|
+
const close = useCallback(() => setIsOpen(false), []);
|
|
281
|
+
|
|
282
|
+
// Global Cmd+K / Ctrl+K handler
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
function handleKeyDown(e: globalThis.KeyboardEvent) {
|
|
285
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
setIsOpen((prev) => !prev);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
291
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
292
|
+
}, []);
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<CommandPaletteContext.Provider value={{ open, close, isOpen }}>
|
|
296
|
+
{children}
|
|
297
|
+
<AnimatePresence>
|
|
298
|
+
{isOpen && <CommandPaletteImpl onClose={close} />}
|
|
299
|
+
</AnimatePresence>
|
|
300
|
+
</CommandPaletteContext.Provider>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Inner implementation (rendered in portal)
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
function CommandPaletteImpl({ onClose }: { onClose: () => void }) {
|
|
309
|
+
const [query, setQuery] = useState('');
|
|
310
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
311
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
312
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
313
|
+
const shouldReduceMotion = useReducedMotion();
|
|
314
|
+
const descriptionId = useId();
|
|
315
|
+
const inputId = useId();
|
|
316
|
+
|
|
317
|
+
const filtered = filterItems(ALL_ITEMS, query);
|
|
318
|
+
const visibleGroups = groupByGroup(filtered, DEFAULT_GROUPS);
|
|
319
|
+
|
|
320
|
+
// Flatten for arrow-key navigation
|
|
321
|
+
const flatItems = visibleGroups.flatMap((g) => g.items);
|
|
322
|
+
|
|
323
|
+
// Reset active index when results change
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
setActiveIndex(0);
|
|
326
|
+
}, [query]);
|
|
327
|
+
|
|
328
|
+
// Focus input on open
|
|
329
|
+
useEffect(() => {
|
|
330
|
+
inputRef.current?.focus();
|
|
331
|
+
}, []);
|
|
332
|
+
|
|
333
|
+
// Scroll active item into view
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
const activeEl = listRef.current?.querySelector(
|
|
336
|
+
'[data-active="true"]',
|
|
337
|
+
) as HTMLElement | null;
|
|
338
|
+
activeEl?.scrollIntoView({ block: 'nearest' });
|
|
339
|
+
}, [activeIndex]);
|
|
340
|
+
|
|
341
|
+
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
|
342
|
+
switch (e.key) {
|
|
343
|
+
case 'ArrowDown':
|
|
344
|
+
e.preventDefault();
|
|
345
|
+
setActiveIndex((i) => Math.min(i + 1, flatItems.length - 1));
|
|
346
|
+
break;
|
|
347
|
+
case 'ArrowUp':
|
|
348
|
+
e.preventDefault();
|
|
349
|
+
setActiveIndex((i) => Math.max(i - 1, 0));
|
|
350
|
+
break;
|
|
351
|
+
case 'Enter':
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
if (flatItems[activeIndex]) {
|
|
354
|
+
flatItems[activeIndex].onSelect();
|
|
355
|
+
onClose();
|
|
356
|
+
}
|
|
357
|
+
break;
|
|
358
|
+
case 'Escape':
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
onClose();
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Framer-motion variants
|
|
366
|
+
const overlayVariants = {
|
|
367
|
+
hidden: { opacity: 0 },
|
|
368
|
+
visible: {
|
|
369
|
+
opacity: 1,
|
|
370
|
+
transition: {
|
|
371
|
+
duration: shouldReduceMotion ? 0 : DURATION.normal / 1000,
|
|
372
|
+
ease: EASING.out,
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
exit: {
|
|
376
|
+
opacity: 0,
|
|
377
|
+
transition: {
|
|
378
|
+
duration: shouldReduceMotion ? 0 : DURATION.fast / 1000,
|
|
379
|
+
ease: EASING.in,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const panelVariants = {
|
|
385
|
+
hidden: { opacity: 0, scale: 0.96, y: -8 },
|
|
386
|
+
visible: {
|
|
387
|
+
opacity: 1,
|
|
388
|
+
scale: 1,
|
|
389
|
+
y: 0,
|
|
390
|
+
transition: {
|
|
391
|
+
duration: shouldReduceMotion ? 0 : DURATION.normal / 1000,
|
|
392
|
+
ease: EASING.out,
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
exit: {
|
|
396
|
+
opacity: 0,
|
|
397
|
+
scale: 0.96,
|
|
398
|
+
y: -8,
|
|
399
|
+
transition: {
|
|
400
|
+
duration: shouldReduceMotion ? 0 : DURATION.fast / 1000,
|
|
401
|
+
ease: EASING.in,
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const palette = (
|
|
407
|
+
<>
|
|
408
|
+
{/* Backdrop */}
|
|
409
|
+
<motion.div
|
|
410
|
+
aria-hidden="true"
|
|
411
|
+
variants={overlayVariants}
|
|
412
|
+
initial="hidden"
|
|
413
|
+
animate="visible"
|
|
414
|
+
exit="exit"
|
|
415
|
+
onClick={onClose}
|
|
416
|
+
style={{
|
|
417
|
+
position: 'fixed',
|
|
418
|
+
inset: 0,
|
|
419
|
+
backgroundColor: 'var(--color-overlay-scrim)',
|
|
420
|
+
backdropFilter: 'blur(12px)',
|
|
421
|
+
WebkitBackdropFilter: 'blur(12px)',
|
|
422
|
+
zIndex: 'var(--z-command)',
|
|
423
|
+
}}
|
|
424
|
+
/>
|
|
425
|
+
|
|
426
|
+
{/* Panel */}
|
|
427
|
+
<motion.div
|
|
428
|
+
role="dialog"
|
|
429
|
+
aria-modal="true"
|
|
430
|
+
aria-label="Command palette"
|
|
431
|
+
aria-describedby={descriptionId}
|
|
432
|
+
variants={panelVariants}
|
|
433
|
+
initial="hidden"
|
|
434
|
+
animate="visible"
|
|
435
|
+
exit="exit"
|
|
436
|
+
onKeyDown={handleKeyDown}
|
|
437
|
+
style={{
|
|
438
|
+
position: 'fixed',
|
|
439
|
+
top: '15%',
|
|
440
|
+
left: '50%',
|
|
441
|
+
transform: 'translateX(-50%)',
|
|
442
|
+
width: 'min(560px, calc(100vw - 2rem))',
|
|
443
|
+
zIndex: 'calc(var(--z-command) + 1)',
|
|
444
|
+
backgroundColor: 'var(--color-bg-overlay)',
|
|
445
|
+
border: '1px solid var(--color-border-default)',
|
|
446
|
+
borderRadius: 'var(--radius-lg)',
|
|
447
|
+
boxShadow: 'var(--shadow-3)',
|
|
448
|
+
overflow: 'hidden',
|
|
449
|
+
display: 'flex',
|
|
450
|
+
flexDirection: 'column',
|
|
451
|
+
maxHeight: 'min(560px, 70vh)',
|
|
452
|
+
}}
|
|
453
|
+
>
|
|
454
|
+
{/* SR-only description */}
|
|
455
|
+
<p id={descriptionId} className="sr-only">
|
|
456
|
+
Type to search. Use arrow keys to navigate results. Press Enter to
|
|
457
|
+
select. Press Escape to close.
|
|
458
|
+
</p>
|
|
459
|
+
|
|
460
|
+
{/* Search input row */}
|
|
461
|
+
<div
|
|
462
|
+
style={{
|
|
463
|
+
display: 'flex',
|
|
464
|
+
alignItems: 'center',
|
|
465
|
+
gap: 'var(--space-3)',
|
|
466
|
+
padding: 'var(--space-4) var(--space-5)',
|
|
467
|
+
borderBottom: '1px solid var(--color-border-default)',
|
|
468
|
+
flexShrink: 0,
|
|
469
|
+
}}
|
|
470
|
+
>
|
|
471
|
+
<Search
|
|
472
|
+
size={18}
|
|
473
|
+
aria-hidden="true"
|
|
474
|
+
style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}
|
|
475
|
+
/>
|
|
476
|
+
<input
|
|
477
|
+
ref={inputRef}
|
|
478
|
+
id={inputId}
|
|
479
|
+
type="text"
|
|
480
|
+
role="combobox"
|
|
481
|
+
aria-expanded={filtered.length > 0}
|
|
482
|
+
aria-autocomplete="list"
|
|
483
|
+
aria-controls="command-results"
|
|
484
|
+
aria-activedescendant={
|
|
485
|
+
flatItems[activeIndex]
|
|
486
|
+
? `cmd-item-${flatItems[activeIndex].id}`
|
|
487
|
+
: undefined
|
|
488
|
+
}
|
|
489
|
+
value={query}
|
|
490
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
491
|
+
placeholder="Search commands, pages, actions..."
|
|
492
|
+
style={{
|
|
493
|
+
flex: 1,
|
|
494
|
+
background: 'transparent',
|
|
495
|
+
border: 'none',
|
|
496
|
+
outline: 'none',
|
|
497
|
+
fontSize: 'var(--type-body)',
|
|
498
|
+
lineHeight: 'var(--type-body-lh)',
|
|
499
|
+
color: 'var(--color-text-primary)',
|
|
500
|
+
}}
|
|
501
|
+
/>
|
|
502
|
+
<kbd
|
|
503
|
+
aria-label="Escape to close"
|
|
504
|
+
style={{
|
|
505
|
+
display: 'inline-flex',
|
|
506
|
+
alignItems: 'center',
|
|
507
|
+
justifyContent: 'center',
|
|
508
|
+
height: '20px',
|
|
509
|
+
minWidth: '20px',
|
|
510
|
+
padding: '0 var(--space-1)',
|
|
511
|
+
borderRadius: 'var(--radius-sharp)',
|
|
512
|
+
border: '1px solid var(--color-border-default)',
|
|
513
|
+
backgroundColor: 'var(--color-bg-sunken)',
|
|
514
|
+
fontSize: 'var(--type-caption)',
|
|
515
|
+
color: 'var(--color-text-muted)',
|
|
516
|
+
fontFamily: 'var(--font-mono)',
|
|
517
|
+
lineHeight: '1',
|
|
518
|
+
flexShrink: 0,
|
|
519
|
+
}}
|
|
520
|
+
>
|
|
521
|
+
Esc
|
|
522
|
+
</kbd>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
{/* Results */}
|
|
526
|
+
<ul
|
|
527
|
+
ref={listRef}
|
|
528
|
+
id="command-results"
|
|
529
|
+
role="listbox"
|
|
530
|
+
aria-label="Search results"
|
|
531
|
+
style={{
|
|
532
|
+
overflowY: 'auto',
|
|
533
|
+
flex: 1,
|
|
534
|
+
padding: 'var(--space-2) 0',
|
|
535
|
+
listStyle: 'none',
|
|
536
|
+
margin: 0,
|
|
537
|
+
}}
|
|
538
|
+
>
|
|
539
|
+
{visibleGroups.length === 0 && (
|
|
540
|
+
<li
|
|
541
|
+
role="option"
|
|
542
|
+
aria-selected="false"
|
|
543
|
+
style={{
|
|
544
|
+
padding: 'var(--space-8) var(--space-5)',
|
|
545
|
+
textAlign: 'center',
|
|
546
|
+
fontSize: 'var(--type-small)',
|
|
547
|
+
color: 'var(--color-text-muted)',
|
|
548
|
+
}}
|
|
549
|
+
>
|
|
550
|
+
No results for “{query}”
|
|
551
|
+
</li>
|
|
552
|
+
)}
|
|
553
|
+
|
|
554
|
+
{visibleGroups.map((group) => {
|
|
555
|
+
let itemIndex = 0;
|
|
556
|
+
// Compute offset for this group in flatItems
|
|
557
|
+
const groupOffset = flatItems.findIndex((fi) =>
|
|
558
|
+
group.items.some((gi) => gi.id === fi.id),
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
return (
|
|
562
|
+
<li key={group.id} role="presentation">
|
|
563
|
+
{/* Group label */}
|
|
564
|
+
<div
|
|
565
|
+
role="presentation"
|
|
566
|
+
style={{
|
|
567
|
+
padding: 'var(--space-1) var(--space-5)',
|
|
568
|
+
fontSize: 'var(--type-caption)',
|
|
569
|
+
lineHeight: 'var(--type-caption-lh)',
|
|
570
|
+
letterSpacing: '0.06em',
|
|
571
|
+
textTransform: 'uppercase',
|
|
572
|
+
color: 'var(--color-text-muted)',
|
|
573
|
+
fontWeight: 600,
|
|
574
|
+
userSelect: 'none',
|
|
575
|
+
marginTop: 'var(--space-1)',
|
|
576
|
+
}}
|
|
577
|
+
>
|
|
578
|
+
{group.label}
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
{/* Group items */}
|
|
582
|
+
<ul role="presentation" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
|
583
|
+
{group.items.map((item) => {
|
|
584
|
+
const flatIdx = groupOffset + (itemIndex++);
|
|
585
|
+
const isActive = flatIdx === activeIndex;
|
|
586
|
+
|
|
587
|
+
return (
|
|
588
|
+
<li
|
|
589
|
+
key={item.id}
|
|
590
|
+
id={`cmd-item-${item.id}`}
|
|
591
|
+
role="option"
|
|
592
|
+
aria-selected={isActive}
|
|
593
|
+
data-active={isActive ? 'true' : 'false'}
|
|
594
|
+
onClick={() => {
|
|
595
|
+
item.onSelect();
|
|
596
|
+
onClose();
|
|
597
|
+
}}
|
|
598
|
+
onMouseMove={() => setActiveIndex(flatIdx)}
|
|
599
|
+
style={{
|
|
600
|
+
display: 'flex',
|
|
601
|
+
alignItems: 'center',
|
|
602
|
+
gap: 'var(--space-3)',
|
|
603
|
+
padding: 'var(--space-2) var(--space-5)',
|
|
604
|
+
cursor: 'pointer',
|
|
605
|
+
backgroundColor: isActive
|
|
606
|
+
? 'var(--color-bg-highlight)'
|
|
607
|
+
: 'transparent',
|
|
608
|
+
transition: `background-color var(--duration-instant) var(--ease-out)`,
|
|
609
|
+
minHeight: '36px',
|
|
610
|
+
borderRadius: 0,
|
|
611
|
+
}}
|
|
612
|
+
>
|
|
613
|
+
{/* Icon */}
|
|
614
|
+
{item.icon && (
|
|
615
|
+
<span
|
|
616
|
+
aria-hidden="true"
|
|
617
|
+
style={{ color: isActive ? 'var(--color-text-accent)' : 'var(--color-text-muted)', flexShrink: 0, display: 'flex' }}
|
|
618
|
+
>
|
|
619
|
+
{item.icon}
|
|
620
|
+
</span>
|
|
621
|
+
)}
|
|
622
|
+
|
|
623
|
+
{/* Label + description */}
|
|
624
|
+
<span style={{ flex: 1, minWidth: 0 }}>
|
|
625
|
+
<span
|
|
626
|
+
style={{
|
|
627
|
+
display: 'block',
|
|
628
|
+
fontSize: 'var(--type-small)',
|
|
629
|
+
lineHeight: 'var(--type-small-lh)',
|
|
630
|
+
color: isActive
|
|
631
|
+
? 'var(--color-text-primary)'
|
|
632
|
+
: 'var(--color-text-secondary)',
|
|
633
|
+
fontWeight: isActive ? 500 : 400,
|
|
634
|
+
overflow: 'hidden',
|
|
635
|
+
textOverflow: 'ellipsis',
|
|
636
|
+
whiteSpace: 'nowrap',
|
|
637
|
+
}}
|
|
638
|
+
>
|
|
639
|
+
{item.label}
|
|
640
|
+
</span>
|
|
641
|
+
{item.description && (
|
|
642
|
+
<span
|
|
643
|
+
style={{
|
|
644
|
+
display: 'block',
|
|
645
|
+
fontSize: 'var(--type-caption)',
|
|
646
|
+
lineHeight: 'var(--type-caption-lh)',
|
|
647
|
+
color: 'var(--color-text-muted)',
|
|
648
|
+
overflow: 'hidden',
|
|
649
|
+
textOverflow: 'ellipsis',
|
|
650
|
+
whiteSpace: 'nowrap',
|
|
651
|
+
}}
|
|
652
|
+
>
|
|
653
|
+
{item.description}
|
|
654
|
+
</span>
|
|
655
|
+
)}
|
|
656
|
+
</span>
|
|
657
|
+
|
|
658
|
+
{/* Shortcut hint */}
|
|
659
|
+
{item.shortcut && (
|
|
660
|
+
<span
|
|
661
|
+
aria-hidden="true"
|
|
662
|
+
style={{ display: 'flex', gap: 'var(--space-1)', flexShrink: 0 }}
|
|
663
|
+
>
|
|
664
|
+
{item.shortcut.map((key) => (
|
|
665
|
+
<kbd
|
|
666
|
+
key={key}
|
|
667
|
+
style={{
|
|
668
|
+
display: 'inline-flex',
|
|
669
|
+
alignItems: 'center',
|
|
670
|
+
justifyContent: 'center',
|
|
671
|
+
height: '18px',
|
|
672
|
+
minWidth: '18px',
|
|
673
|
+
padding: '0 var(--space-1)',
|
|
674
|
+
borderRadius: 'var(--radius-sharp)',
|
|
675
|
+
border: '1px solid var(--color-border-default)',
|
|
676
|
+
backgroundColor: 'var(--color-bg-sunken)',
|
|
677
|
+
fontSize: '11px',
|
|
678
|
+
color: 'var(--color-text-muted)',
|
|
679
|
+
fontFamily: 'var(--font-mono)',
|
|
680
|
+
}}
|
|
681
|
+
>
|
|
682
|
+
{key}
|
|
683
|
+
</kbd>
|
|
684
|
+
))}
|
|
685
|
+
</span>
|
|
686
|
+
)}
|
|
687
|
+
|
|
688
|
+
{/* Active indicator chevron */}
|
|
689
|
+
{isActive && (
|
|
690
|
+
<ChevronRight
|
|
691
|
+
size={14}
|
|
692
|
+
aria-hidden="true"
|
|
693
|
+
style={{ color: 'var(--color-text-accent)', flexShrink: 0 }}
|
|
694
|
+
/>
|
|
695
|
+
)}
|
|
696
|
+
</li>
|
|
697
|
+
);
|
|
698
|
+
})}
|
|
699
|
+
</ul>
|
|
700
|
+
</li>
|
|
701
|
+
);
|
|
702
|
+
})}
|
|
703
|
+
</ul>
|
|
704
|
+
|
|
705
|
+
{/* Footer hint */}
|
|
706
|
+
<div
|
|
707
|
+
aria-hidden="true"
|
|
708
|
+
style={{
|
|
709
|
+
display: 'flex',
|
|
710
|
+
alignItems: 'center',
|
|
711
|
+
gap: 'var(--space-4)',
|
|
712
|
+
padding: 'var(--space-2) var(--space-5)',
|
|
713
|
+
borderTop: '1px solid var(--color-border-default)',
|
|
714
|
+
flexShrink: 0,
|
|
715
|
+
}}
|
|
716
|
+
>
|
|
717
|
+
{[
|
|
718
|
+
{ keys: ['arrowup', 'arrowdown'], label: 'Navigate' },
|
|
719
|
+
{ keys: ['enter'], label: 'Select' },
|
|
720
|
+
{ keys: ['esc'], label: 'Close' },
|
|
721
|
+
].map(({ keys, label }) => (
|
|
722
|
+
<span
|
|
723
|
+
key={label}
|
|
724
|
+
style={{
|
|
725
|
+
display: 'flex',
|
|
726
|
+
alignItems: 'center',
|
|
727
|
+
gap: 'var(--space-1)',
|
|
728
|
+
fontSize: 'var(--type-caption)',
|
|
729
|
+
color: 'var(--color-text-muted)',
|
|
730
|
+
}}
|
|
731
|
+
>
|
|
732
|
+
{keys.map((k) => (
|
|
733
|
+
<kbd
|
|
734
|
+
key={k}
|
|
735
|
+
style={{
|
|
736
|
+
display: 'inline-flex',
|
|
737
|
+
alignItems: 'center',
|
|
738
|
+
justifyContent: 'center',
|
|
739
|
+
height: '16px',
|
|
740
|
+
minWidth: '16px',
|
|
741
|
+
padding: '0 3px',
|
|
742
|
+
borderRadius: 'var(--radius-sharp)',
|
|
743
|
+
border: '1px solid var(--color-border-default)',
|
|
744
|
+
backgroundColor: 'var(--color-bg-sunken)',
|
|
745
|
+
fontSize: '10px',
|
|
746
|
+
fontFamily: 'var(--font-mono)',
|
|
747
|
+
color: 'var(--color-text-muted)',
|
|
748
|
+
}}
|
|
749
|
+
>
|
|
750
|
+
{k === 'arrowup' ? '↑' : k === 'arrowdown' ? '↓' : k === 'enter' ? '⏎' : k}
|
|
751
|
+
</kbd>
|
|
752
|
+
))}
|
|
753
|
+
<span>{label}</span>
|
|
754
|
+
</span>
|
|
755
|
+
))}
|
|
756
|
+
</div>
|
|
757
|
+
</motion.div>
|
|
758
|
+
</>
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
// Portal to body to escape any overflow/stacking context
|
|
762
|
+
return typeof document !== 'undefined'
|
|
763
|
+
? createPortal(palette, document.body)
|
|
764
|
+
: null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export default CommandPaletteProvider;
|