@mars-stack/cli 0.2.0 → 0.2.2
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/package.json +2 -2
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
# Skill: Add Command Palette
|
|
2
|
+
|
|
3
|
+
Set up a Cmd+K command palette with fuzzy search, action registry, and keyboard navigation in a MARS application.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add a command palette, Cmd+K menu, keyboard shortcuts, quick search, or a spotlight-like interface.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- MARS template app with React
|
|
12
|
+
- A modal/dialog primitive available (or use the native `<dialog>` element)
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
The command palette consists of:
|
|
17
|
+
1. **Keyboard shortcut listener** — `Cmd+K` (Mac) / `Ctrl+K` (Windows/Linux) opens the palette
|
|
18
|
+
2. **Action registry** — centralized list of commands (navigation, theme toggle, user actions)
|
|
19
|
+
3. **Fuzzy search** — filters actions as the user types
|
|
20
|
+
4. **Command palette UI** — search input, results list, keyboard navigation
|
|
21
|
+
5. **`cmdk` library** — recommended for accessible, production-quality implementation
|
|
22
|
+
|
|
23
|
+
## Step 1: Install `cmdk`
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
yarn add cmdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The `cmdk` library provides an unstyled, composable command palette component with built-in keyboard navigation, fuzzy search, and accessibility.
|
|
30
|
+
|
|
31
|
+
## Step 2: Define the Action Registry
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// src/features/command-palette/actions.ts
|
|
35
|
+
import type { ReactNode } from 'react';
|
|
36
|
+
|
|
37
|
+
export interface CommandAction {
|
|
38
|
+
id: string;
|
|
39
|
+
label: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
icon?: ReactNode;
|
|
42
|
+
keywords?: string[];
|
|
43
|
+
shortcut?: string[];
|
|
44
|
+
section: string;
|
|
45
|
+
onSelect: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createNavigationActions(router: { push: (path: string) => void }): CommandAction[] {
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
id: 'nav-dashboard',
|
|
52
|
+
label: 'Go to Dashboard',
|
|
53
|
+
keywords: ['home', 'overview'],
|
|
54
|
+
section: 'Navigation',
|
|
55
|
+
shortcut: ['G', 'D'],
|
|
56
|
+
onSelect: () => router.push('/dashboard'),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'nav-settings',
|
|
60
|
+
label: 'Go to Settings',
|
|
61
|
+
keywords: ['preferences', 'account'],
|
|
62
|
+
section: 'Navigation',
|
|
63
|
+
shortcut: ['G', 'S'],
|
|
64
|
+
onSelect: () => router.push('/settings'),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'nav-profile',
|
|
68
|
+
label: 'Go to Profile',
|
|
69
|
+
keywords: ['account', 'user'],
|
|
70
|
+
section: 'Navigation',
|
|
71
|
+
onSelect: () => router.push('/settings/profile'),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'nav-blog',
|
|
75
|
+
label: 'Go to Blog',
|
|
76
|
+
keywords: ['posts', 'articles'],
|
|
77
|
+
section: 'Navigation',
|
|
78
|
+
onSelect: () => router.push('/blog'),
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createThemeActions(setTheme: (theme: string) => void): CommandAction[] {
|
|
84
|
+
return [
|
|
85
|
+
{
|
|
86
|
+
id: 'theme-light',
|
|
87
|
+
label: 'Switch to Light Mode',
|
|
88
|
+
keywords: ['theme', 'appearance'],
|
|
89
|
+
section: 'Theme',
|
|
90
|
+
onSelect: () => setTheme('light'),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'theme-dark',
|
|
94
|
+
label: 'Switch to Dark Mode',
|
|
95
|
+
keywords: ['theme', 'appearance'],
|
|
96
|
+
section: 'Theme',
|
|
97
|
+
onSelect: () => setTheme('dark'),
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'theme-system',
|
|
101
|
+
label: 'Use System Theme',
|
|
102
|
+
keywords: ['theme', 'appearance', 'auto'],
|
|
103
|
+
section: 'Theme',
|
|
104
|
+
onSelect: () => setTheme('system'),
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createUserActions(callbacks: {
|
|
110
|
+
onLogout: () => void;
|
|
111
|
+
onCopyUserId?: () => void;
|
|
112
|
+
}): CommandAction[] {
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
id: 'user-logout',
|
|
116
|
+
label: 'Sign Out',
|
|
117
|
+
keywords: ['logout', 'sign out', 'exit'],
|
|
118
|
+
section: 'Account',
|
|
119
|
+
onSelect: callbacks.onLogout,
|
|
120
|
+
},
|
|
121
|
+
...(callbacks.onCopyUserId
|
|
122
|
+
? [
|
|
123
|
+
{
|
|
124
|
+
id: 'user-copy-id',
|
|
125
|
+
label: 'Copy User ID',
|
|
126
|
+
keywords: ['id', 'identifier'],
|
|
127
|
+
section: 'Account',
|
|
128
|
+
onSelect: callbacks.onCopyUserId,
|
|
129
|
+
},
|
|
130
|
+
]
|
|
131
|
+
: []),
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Step 3: Command Palette Component
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
// src/features/command-palette/components/CommandPalette.tsx
|
|
140
|
+
'use client';
|
|
141
|
+
|
|
142
|
+
import { Command } from 'cmdk';
|
|
143
|
+
import { useEffect, useState, useCallback } from 'react';
|
|
144
|
+
import { useRouter } from 'next/navigation';
|
|
145
|
+
import { createNavigationActions, createThemeActions, createUserActions, type CommandAction } from '../actions';
|
|
146
|
+
|
|
147
|
+
export function CommandPalette() {
|
|
148
|
+
const [open, setOpen] = useState(false);
|
|
149
|
+
const [search, setSearch] = useState('');
|
|
150
|
+
const router = useRouter();
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
154
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
setOpen((prev) => !prev);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (e.key === 'Escape') {
|
|
160
|
+
setOpen(false);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
165
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
const handleSelect = useCallback(
|
|
169
|
+
(action: CommandAction) => {
|
|
170
|
+
setOpen(false);
|
|
171
|
+
setSearch('');
|
|
172
|
+
action.onSelect();
|
|
173
|
+
},
|
|
174
|
+
[],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const navigationActions = createNavigationActions(router);
|
|
178
|
+
const themeActions = createThemeActions((theme) => {
|
|
179
|
+
localStorage.setItem('mars-theme', theme);
|
|
180
|
+
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
181
|
+
document.documentElement.classList.toggle('dark', isDark);
|
|
182
|
+
});
|
|
183
|
+
const userActions = createUserActions({
|
|
184
|
+
onLogout: () => router.push('/api/auth/logout'),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const allActions = [...navigationActions, ...themeActions, ...userActions];
|
|
188
|
+
const sections = [...new Set(allActions.map((a) => a.section))];
|
|
189
|
+
|
|
190
|
+
if (!open) return null;
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<div className="fixed inset-0 z-50">
|
|
194
|
+
{/* Backdrop */}
|
|
195
|
+
<div
|
|
196
|
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
|
197
|
+
onClick={() => setOpen(false)}
|
|
198
|
+
/>
|
|
199
|
+
|
|
200
|
+
{/* Palette */}
|
|
201
|
+
<div className="absolute left-1/2 top-[20%] w-full max-w-lg -translate-x-1/2">
|
|
202
|
+
<Command
|
|
203
|
+
className="overflow-hidden rounded-xl border border-border-primary bg-surface-primary shadow-2xl"
|
|
204
|
+
shouldFilter={true}
|
|
205
|
+
>
|
|
206
|
+
<div className="flex items-center border-b border-border-primary px-4">
|
|
207
|
+
<SearchIcon className="mr-2 h-4 w-4 shrink-0 text-content-tertiary" />
|
|
208
|
+
<Command.Input
|
|
209
|
+
value={search}
|
|
210
|
+
onValueChange={setSearch}
|
|
211
|
+
placeholder="Type a command or search..."
|
|
212
|
+
className="flex h-12 w-full bg-transparent text-content-primary placeholder:text-content-tertiary focus:outline-none"
|
|
213
|
+
/>
|
|
214
|
+
<kbd className="ml-2 shrink-0 rounded border border-border-primary bg-surface-secondary px-1.5 py-0.5 text-xs text-content-tertiary">
|
|
215
|
+
ESC
|
|
216
|
+
</kbd>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<Command.List className="max-h-80 overflow-y-auto p-2">
|
|
220
|
+
<Command.Empty className="px-4 py-8 text-center text-sm text-content-tertiary">
|
|
221
|
+
No results found.
|
|
222
|
+
</Command.Empty>
|
|
223
|
+
|
|
224
|
+
{sections.map((section) => (
|
|
225
|
+
<Command.Group
|
|
226
|
+
key={section}
|
|
227
|
+
heading={section}
|
|
228
|
+
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-content-tertiary"
|
|
229
|
+
>
|
|
230
|
+
{allActions
|
|
231
|
+
.filter((a) => a.section === section)
|
|
232
|
+
.map((action) => (
|
|
233
|
+
<Command.Item
|
|
234
|
+
key={action.id}
|
|
235
|
+
value={`${action.label} ${action.keywords?.join(' ') || ''}`}
|
|
236
|
+
onSelect={() => handleSelect(action)}
|
|
237
|
+
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-content-primary aria-selected:bg-surface-secondary"
|
|
238
|
+
>
|
|
239
|
+
{action.icon && (
|
|
240
|
+
<span className="flex h-5 w-5 items-center justify-center text-content-tertiary">
|
|
241
|
+
{action.icon}
|
|
242
|
+
</span>
|
|
243
|
+
)}
|
|
244
|
+
<div className="flex-1">
|
|
245
|
+
<span>{action.label}</span>
|
|
246
|
+
{action.description && (
|
|
247
|
+
<span className="ml-2 text-content-tertiary">{action.description}</span>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
{action.shortcut && (
|
|
251
|
+
<div className="flex items-center gap-1">
|
|
252
|
+
{action.shortcut.map((key) => (
|
|
253
|
+
<kbd
|
|
254
|
+
key={key}
|
|
255
|
+
className="rounded border border-border-primary bg-surface-secondary px-1.5 py-0.5 text-xs text-content-tertiary"
|
|
256
|
+
>
|
|
257
|
+
{key}
|
|
258
|
+
</kbd>
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
</Command.Item>
|
|
263
|
+
))}
|
|
264
|
+
</Command.Group>
|
|
265
|
+
))}
|
|
266
|
+
</Command.List>
|
|
267
|
+
|
|
268
|
+
<div className="flex items-center justify-between border-t border-border-primary px-4 py-2 text-xs text-content-tertiary">
|
|
269
|
+
<span>Navigate with ↑↓ keys</span>
|
|
270
|
+
<span>Select with ↵</span>
|
|
271
|
+
</div>
|
|
272
|
+
</Command>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function SearchIcon({ className }: { className?: string }) {
|
|
279
|
+
return (
|
|
280
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={className}>
|
|
281
|
+
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
|
282
|
+
</svg>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Step 4: Recent Items Support
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
// src/features/command-palette/recent.ts
|
|
291
|
+
const RECENT_KEY = 'mars-command-palette-recent';
|
|
292
|
+
const MAX_RECENT = 5;
|
|
293
|
+
|
|
294
|
+
export function getRecentActions(): string[] {
|
|
295
|
+
if (typeof window === 'undefined') return [];
|
|
296
|
+
const stored = localStorage.getItem(RECENT_KEY);
|
|
297
|
+
return stored ? JSON.parse(stored) : [];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function addRecentAction(actionId: string): void {
|
|
301
|
+
const recent = getRecentActions().filter((id) => id !== actionId);
|
|
302
|
+
recent.unshift(actionId);
|
|
303
|
+
localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, MAX_RECENT)));
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Integrate into the `handleSelect` callback:
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
const handleSelect = useCallback(
|
|
311
|
+
(action: CommandAction) => {
|
|
312
|
+
addRecentAction(action.id);
|
|
313
|
+
setOpen(false);
|
|
314
|
+
setSearch('');
|
|
315
|
+
action.onSelect();
|
|
316
|
+
},
|
|
317
|
+
[],
|
|
318
|
+
);
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Then render a "Recent" section at the top of the command list when the search is empty:
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
{!search && recentIds.length > 0 && (
|
|
325
|
+
<Command.Group heading="Recent">
|
|
326
|
+
{recentIds
|
|
327
|
+
.map((id) => allActions.find((a) => a.id === id))
|
|
328
|
+
.filter(Boolean)
|
|
329
|
+
.map((action) => (
|
|
330
|
+
<Command.Item key={action!.id} /* ... */ />
|
|
331
|
+
))}
|
|
332
|
+
</Command.Group>
|
|
333
|
+
)}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Step 5: Register Custom Actions
|
|
337
|
+
|
|
338
|
+
Allow features to register their own commands:
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
// src/features/command-palette/registry.ts
|
|
342
|
+
import type { CommandAction } from './actions';
|
|
343
|
+
|
|
344
|
+
const customActions: CommandAction[] = [];
|
|
345
|
+
|
|
346
|
+
export function registerAction(action: CommandAction): void {
|
|
347
|
+
const exists = customActions.findIndex((a) => a.id === action.id);
|
|
348
|
+
if (exists >= 0) {
|
|
349
|
+
customActions[exists] = action;
|
|
350
|
+
} else {
|
|
351
|
+
customActions.push(action);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function getCustomActions(): CommandAction[] {
|
|
356
|
+
return [...customActions];
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Use from any feature:
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
import { registerAction } from '@/features/command-palette/registry';
|
|
364
|
+
|
|
365
|
+
registerAction({
|
|
366
|
+
id: 'billing-upgrade',
|
|
367
|
+
label: 'Upgrade Plan',
|
|
368
|
+
keywords: ['billing', 'subscription', 'pro'],
|
|
369
|
+
section: 'Billing',
|
|
370
|
+
onSelect: () => router.push('/billing/upgrade'),
|
|
371
|
+
});
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
## Step 6: Add to Root Layout
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
// src/app/(protected)/layout.tsx
|
|
378
|
+
import { CommandPalette } from '@/features/command-palette/components/CommandPalette';
|
|
379
|
+
|
|
380
|
+
export default function ProtectedLayout({ children }: { children: React.ReactNode }) {
|
|
381
|
+
return (
|
|
382
|
+
<>
|
|
383
|
+
<CommandPalette />
|
|
384
|
+
{children}
|
|
385
|
+
</>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
## Step 7: Trigger Button (Optional)
|
|
391
|
+
|
|
392
|
+
For discoverability, add a search bar that opens the palette:
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
// src/features/command-palette/components/CommandTrigger.tsx
|
|
396
|
+
'use client';
|
|
397
|
+
|
|
398
|
+
export function CommandTrigger() {
|
|
399
|
+
return (
|
|
400
|
+
<button
|
|
401
|
+
onClick={() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))}
|
|
402
|
+
className="flex items-center gap-2 rounded-lg border border-border-primary bg-surface-secondary px-3 py-1.5 text-sm text-content-tertiary transition-colors hover:border-interactive-primary hover:text-content-secondary"
|
|
403
|
+
>
|
|
404
|
+
<SearchIcon className="h-4 w-4" />
|
|
405
|
+
<span>Search...</span>
|
|
406
|
+
<kbd className="rounded border border-border-primary bg-surface-primary px-1.5 py-0.5 text-xs">
|
|
407
|
+
⌘K
|
|
408
|
+
</kbd>
|
|
409
|
+
</button>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
## Testing
|
|
415
|
+
|
|
416
|
+
1. Press `Cmd+K` — verify the palette opens with focus in the search input.
|
|
417
|
+
2. Type a query — verify results filter with fuzzy matching.
|
|
418
|
+
3. Use arrow keys — verify keyboard navigation highlights items.
|
|
419
|
+
4. Press Enter — verify the selected action executes and the palette closes.
|
|
420
|
+
5. Press Escape — verify the palette closes.
|
|
421
|
+
6. Click the backdrop — verify the palette closes.
|
|
422
|
+
7. Select an action — verify it appears in "Recent" on the next open.
|
|
423
|
+
8. Register a custom action — verify it appears in the palette.
|
|
424
|
+
|
|
425
|
+
## Checklist
|
|
426
|
+
|
|
427
|
+
- [ ] `cmdk` library installed
|
|
428
|
+
- [ ] Keyboard shortcut (`Cmd+K` / `Ctrl+K`) opens and closes the palette
|
|
429
|
+
- [ ] Action registry with navigation, theme, and user actions
|
|
430
|
+
- [ ] Fuzzy search filters actions as user types
|
|
431
|
+
- [ ] Keyboard navigation with arrow keys and Enter
|
|
432
|
+
- [ ] Recent items tracked in `localStorage`
|
|
433
|
+
- [ ] Action sections with headings
|
|
434
|
+
- [ ] Keyboard shortcuts displayed on action items
|
|
435
|
+
- [ ] Custom action registration via `registerAction`
|
|
436
|
+
- [ ] Palette placed in protected layout
|
|
437
|
+
- [ ] Uses semantic design tokens (no raw colors)
|
|
438
|
+
- [ ] Accessible — focus management, aria labels, screen reader support via `cmdk`
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Skill: Add a UI Component
|
|
2
|
+
|
|
3
|
+
Create a new React component following the MARS design system conventions.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to create a new UI component, button variant, form element, or reusable UI pattern.
|
|
8
|
+
|
|
9
|
+
## Decision: Primitive, Pattern, or Feature Component?
|
|
10
|
+
|
|
11
|
+
| Type | Location | Business Logic? | Example |
|
|
12
|
+
|------|----------|----------------|---------|
|
|
13
|
+
| Primitive | `@mars-stack/ui` (primitives) | No | Button, Input, Badge, Toggle |
|
|
14
|
+
| Pattern | `@mars-stack/ui` (patterns) | No | Card, Modal, DataTable, Pagination |
|
|
15
|
+
| Feature | `src/features/<name>/components/` | Yes | BillingCard, UserProfileEditor |
|
|
16
|
+
|
|
17
|
+
## Design Token System
|
|
18
|
+
|
|
19
|
+
MARS uses a three-layer token system defined in `src/styles/`. **Never use raw Tailwind colours** like `text-gray-900` or `bg-blue-500`. Always use semantic tokens.
|
|
20
|
+
|
|
21
|
+
### Token Quick Reference
|
|
22
|
+
|
|
23
|
+
**Surfaces** (backgrounds):
|
|
24
|
+
- `bg-surface-background` -- page background
|
|
25
|
+
- `bg-surface-card` -- card/panel backgrounds
|
|
26
|
+
- `bg-surface-elevated` -- elevated elements (dropdowns, popovers)
|
|
27
|
+
- `bg-surface-input` -- form input backgrounds
|
|
28
|
+
- `bg-surface-overlay` -- modal/dialog backdrops
|
|
29
|
+
|
|
30
|
+
**Text**:
|
|
31
|
+
- `text-text-primary` -- main body text
|
|
32
|
+
- `text-text-secondary` -- supporting text
|
|
33
|
+
- `text-text-muted` -- placeholder, disabled text
|
|
34
|
+
- `text-text-link` / `hover:text-text-link-hover` -- links
|
|
35
|
+
- `text-text-on-brand` -- text on brand-coloured backgrounds
|
|
36
|
+
- `text-text-error` / `text-text-success` / `text-text-warning` / `text-text-info`
|
|
37
|
+
|
|
38
|
+
**Borders**:
|
|
39
|
+
- `border-border-default` -- default borders
|
|
40
|
+
- `border-border-input` -- form input borders
|
|
41
|
+
- `border-border-focus` -- focus state borders
|
|
42
|
+
- `border-border-error` -- error state borders
|
|
43
|
+
- `border-border-divider` -- horizontal/vertical dividers
|
|
44
|
+
|
|
45
|
+
**Brand / Interactive**:
|
|
46
|
+
- `bg-brand-primary` / `hover:bg-brand-primary-hover` -- primary actions
|
|
47
|
+
- `bg-ghost-hover` / `bg-ghost-active` -- ghost/subtle button states
|
|
48
|
+
- `bg-danger-bg` / `hover:bg-danger-hover` -- destructive actions
|
|
49
|
+
- `focus:ring-ring-focus` -- focus rings
|
|
50
|
+
|
|
51
|
+
**Feedback**:
|
|
52
|
+
- `bg-success-muted` / `bg-error-muted` / `bg-warning-muted` / `bg-info-muted` -- subtle feedback backgrounds
|
|
53
|
+
|
|
54
|
+
## Primitive Component Template
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import { clsx } from 'clsx';
|
|
58
|
+
import { type ComponentPropsWithoutRef, forwardRef } from 'react';
|
|
59
|
+
|
|
60
|
+
export interface ToggleProps extends Omit<ComponentPropsWithoutRef<'button'>, 'onChange'> {
|
|
61
|
+
checked: boolean;
|
|
62
|
+
onChange: (checked: boolean) => void;
|
|
63
|
+
label?: string;
|
|
64
|
+
size?: 'sm' | 'md';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(
|
|
68
|
+
({ checked, onChange, label, size = 'md', className, disabled, ...rest }, ref) => {
|
|
69
|
+
const trackSize = size === 'sm' ? 'h-5 w-9' : 'h-6 w-11';
|
|
70
|
+
const thumbSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4';
|
|
71
|
+
const thumbTranslate = size === 'sm' ? 'translate-x-4' : 'translate-x-5';
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<label className={clsx('inline-flex items-center gap-2', className)}>
|
|
75
|
+
<button
|
|
76
|
+
ref={ref}
|
|
77
|
+
role="switch"
|
|
78
|
+
type="button"
|
|
79
|
+
aria-checked={checked}
|
|
80
|
+
disabled={disabled}
|
|
81
|
+
onClick={() => onChange(!checked)}
|
|
82
|
+
className={clsx(
|
|
83
|
+
'relative inline-flex shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200',
|
|
84
|
+
trackSize,
|
|
85
|
+
checked ? 'bg-brand-primary' : 'bg-surface-active',
|
|
86
|
+
disabled && 'opacity-(--disabled-opacity) cursor-not-allowed',
|
|
87
|
+
'focus:outline-none focus:ring-2 focus:ring-ring-focus focus:ring-offset-2',
|
|
88
|
+
)}
|
|
89
|
+
{...rest}
|
|
90
|
+
>
|
|
91
|
+
<span
|
|
92
|
+
className={clsx(
|
|
93
|
+
'pointer-events-none inline-block transform rounded-full bg-white shadow-sm transition-transform duration-200',
|
|
94
|
+
thumbSize,
|
|
95
|
+
checked ? thumbTranslate : 'translate-x-0.5',
|
|
96
|
+
)}
|
|
97
|
+
/>
|
|
98
|
+
</button>
|
|
99
|
+
{label && (
|
|
100
|
+
<span className={clsx('text-text-primary', size === 'sm' ? 'text-sm' : 'text-base')}>
|
|
101
|
+
{label}
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
104
|
+
</label>
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
Toggle.displayName = 'Toggle';
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Key Conventions
|
|
113
|
+
|
|
114
|
+
1. Use `forwardRef` for primitives that wrap native elements.
|
|
115
|
+
2. Accept `className` prop for overrides, merge with `clsx`.
|
|
116
|
+
3. Export named (never default).
|
|
117
|
+
4. Support common variants via props (`size`, `variant`).
|
|
118
|
+
5. Use semantic tokens for all colours.
|
|
119
|
+
6. Include proper `aria-*` attributes for accessibility.
|
|
120
|
+
|
|
121
|
+
### Input Adornments (leading/trailing overlay buttons)
|
|
122
|
+
|
|
123
|
+
When adding overlay buttons inside an input (e.g. password visibility toggle):
|
|
124
|
+
|
|
125
|
+
- Wrap the input in a `relative` container; position the button with `absolute top-0 right-0 bottom-0`.
|
|
126
|
+
- Give the button explicit dimensions: `h-full w-10` (40px matches input `pr-10`).
|
|
127
|
+
- Center the icon: `flex items-center justify-center p-0` (reset button padding to avoid misalignment).
|
|
128
|
+
- Add `shrink-0` to the button and icon so flex does not distort them.
|
|
129
|
+
- Reserve input padding: add `pr-10` to the input when the adornment is present.
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
<div className="relative">
|
|
133
|
+
<input className={clsx(showPasswordToggle && 'pr-10')} ... />
|
|
134
|
+
{showPasswordToggle && (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className="absolute top-0 right-0 bottom-0 flex h-full w-10 shrink-0 items-center justify-center p-0 text-text-muted hover:text-text-secondary"
|
|
138
|
+
>
|
|
139
|
+
<svg className="h-4 w-4 shrink-0" ... />
|
|
140
|
+
</button>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## After Creating
|
|
146
|
+
|
|
147
|
+
1. Add the export to the `@mars-stack/ui` package barrel file.
|
|
148
|
+
2. Export both the component and its props type.
|
|
149
|
+
|
|
150
|
+
## Checklist
|
|
151
|
+
|
|
152
|
+
- [ ] Correct directory (primitive / pattern / feature)
|
|
153
|
+
- [ ] All colours use semantic tokens (no raw Tailwind colours)
|
|
154
|
+
- [ ] `className` prop accepted and merged with `clsx`
|
|
155
|
+
- [ ] Proper TypeScript interface exported
|
|
156
|
+
- [ ] Accessibility attributes included
|
|
157
|
+
- [ ] Dark mode works automatically (via token system)
|
|
158
|
+
- [ ] Added to barrel export
|