@mars-stack/cli 0.2.0 → 1.0.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/dist/index.js +137 -12
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- 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 +375 -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 +15 -0
- package/template/src/styles/globals.css +7 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# Skill: Configure Dark Mode
|
|
2
|
+
|
|
3
|
+
Set up a dark mode toggle with system preference detection and persistence in a MARS application.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add dark mode, a theme toggle, light/dark switching, or system theme preference support.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- MARS design tokens configured (the token system already supports `.dark` class on `<html>`)
|
|
12
|
+
- Tailwind CSS configured with `darkMode: 'class'`
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
MARS uses OKLCH-based design tokens with semantic names like `--color-surface-primary`. The `.dark` class on `<html>` swaps all token values to their dark variants. This skill adds:
|
|
17
|
+
|
|
18
|
+
1. **Theme context provider** — manages current theme state
|
|
19
|
+
2. **Persistence** — saves preference to `localStorage`
|
|
20
|
+
3. **System detection** — respects `prefers-color-scheme` media query
|
|
21
|
+
4. **Flash prevention** — inline script prevents FOUC (flash of unstyled content)
|
|
22
|
+
5. **Toggle component** — UI control for switching themes
|
|
23
|
+
|
|
24
|
+
## Step 1: Theme Configuration
|
|
25
|
+
|
|
26
|
+
Add theme settings to `src/config/app.config.ts`:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
ui: {
|
|
30
|
+
theme: {
|
|
31
|
+
default: 'system' as 'light' | 'dark' | 'system',
|
|
32
|
+
enableToggle: true,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Step 2: Prevent Flash of Wrong Theme
|
|
38
|
+
|
|
39
|
+
Add an inline script to `src/app/layout.tsx` that runs before React hydration:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// src/app/layout.tsx
|
|
43
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
44
|
+
return (
|
|
45
|
+
<html lang="en" suppressHydrationWarning>
|
|
46
|
+
<head>
|
|
47
|
+
<script
|
|
48
|
+
dangerouslySetInnerHTML={{
|
|
49
|
+
__html: `
|
|
50
|
+
(function() {
|
|
51
|
+
try {
|
|
52
|
+
var stored = localStorage.getItem('mars-theme');
|
|
53
|
+
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
54
|
+
var theme = stored || '${appConfig.ui.theme.default}';
|
|
55
|
+
var isDark = theme === 'dark' || (theme === 'system' && prefersDark);
|
|
56
|
+
document.documentElement.classList.toggle('dark', isDark);
|
|
57
|
+
} catch (e) {}
|
|
58
|
+
})();
|
|
59
|
+
`,
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
<ThemeProvider>{children}</ThemeProvider>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Step 3: Theme Context Provider
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
// src/lib/shared/components/providers/ThemeProvider.tsx
|
|
75
|
+
'use client';
|
|
76
|
+
|
|
77
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
78
|
+
|
|
79
|
+
type Theme = 'light' | 'dark' | 'system';
|
|
80
|
+
|
|
81
|
+
interface ThemeContextValue {
|
|
82
|
+
theme: Theme;
|
|
83
|
+
resolvedTheme: 'light' | 'dark';
|
|
84
|
+
setTheme: (theme: Theme) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
|
88
|
+
|
|
89
|
+
const STORAGE_KEY = 'mars-theme';
|
|
90
|
+
|
|
91
|
+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
92
|
+
const [theme, setThemeState] = useState<Theme>('system');
|
|
93
|
+
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
|
94
|
+
|
|
95
|
+
const applyTheme = useCallback((newTheme: Theme) => {
|
|
96
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
97
|
+
const isDark = newTheme === 'dark' || (newTheme === 'system' && prefersDark);
|
|
98
|
+
document.documentElement.classList.toggle('dark', isDark);
|
|
99
|
+
setResolvedTheme(isDark ? 'dark' : 'light');
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
const setTheme = useCallback(
|
|
103
|
+
(newTheme: Theme) => {
|
|
104
|
+
setThemeState(newTheme);
|
|
105
|
+
localStorage.setItem(STORAGE_KEY, newTheme);
|
|
106
|
+
applyTheme(newTheme);
|
|
107
|
+
},
|
|
108
|
+
[applyTheme],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
|
|
113
|
+
const initial = stored || 'system';
|
|
114
|
+
setThemeState(initial);
|
|
115
|
+
applyTheme(initial);
|
|
116
|
+
|
|
117
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
118
|
+
function handleChange() {
|
|
119
|
+
const current = localStorage.getItem(STORAGE_KEY) as Theme | null;
|
|
120
|
+
if (!current || current === 'system') {
|
|
121
|
+
applyTheme('system');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
126
|
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
127
|
+
}, [applyTheme]);
|
|
128
|
+
|
|
129
|
+
const value = useMemo(
|
|
130
|
+
() => ({ theme, resolvedTheme, setTheme }),
|
|
131
|
+
[theme, resolvedTheme, setTheme],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function useTheme(): ThemeContextValue {
|
|
138
|
+
const context = useContext(ThemeContext);
|
|
139
|
+
if (!context) {
|
|
140
|
+
throw new Error('useTheme must be used within a ThemeProvider');
|
|
141
|
+
}
|
|
142
|
+
return context;
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Step 4: Theme Toggle Component
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
// src/lib/shared/components/patterns/ThemeToggle.tsx
|
|
150
|
+
'use client';
|
|
151
|
+
|
|
152
|
+
import { useTheme } from '@/lib/shared/components/providers/ThemeProvider';
|
|
153
|
+
|
|
154
|
+
export function ThemeToggle() {
|
|
155
|
+
const { theme, setTheme, resolvedTheme } = useTheme();
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="flex items-center gap-1 rounded-lg bg-surface-secondary p-1">
|
|
159
|
+
<button
|
|
160
|
+
onClick={() => setTheme('light')}
|
|
161
|
+
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
|
162
|
+
theme === 'light'
|
|
163
|
+
? 'bg-surface-primary text-content-primary shadow-sm'
|
|
164
|
+
: 'text-content-tertiary hover:text-content-secondary'
|
|
165
|
+
}`}
|
|
166
|
+
aria-label="Light theme"
|
|
167
|
+
>
|
|
168
|
+
<SunIcon className="h-4 w-4" />
|
|
169
|
+
</button>
|
|
170
|
+
<button
|
|
171
|
+
onClick={() => setTheme('system')}
|
|
172
|
+
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
|
173
|
+
theme === 'system'
|
|
174
|
+
? 'bg-surface-primary text-content-primary shadow-sm'
|
|
175
|
+
: 'text-content-tertiary hover:text-content-secondary'
|
|
176
|
+
}`}
|
|
177
|
+
aria-label="System theme"
|
|
178
|
+
>
|
|
179
|
+
<MonitorIcon className="h-4 w-4" />
|
|
180
|
+
</button>
|
|
181
|
+
<button
|
|
182
|
+
onClick={() => setTheme('dark')}
|
|
183
|
+
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
|
184
|
+
theme === 'dark'
|
|
185
|
+
? 'bg-surface-primary text-content-primary shadow-sm'
|
|
186
|
+
: 'text-content-tertiary hover:text-content-secondary'
|
|
187
|
+
}`}
|
|
188
|
+
aria-label="Dark theme"
|
|
189
|
+
>
|
|
190
|
+
<MoonIcon className="h-4 w-4" />
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function SunIcon({ className }: { className?: string }) {
|
|
197
|
+
return (
|
|
198
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={className}>
|
|
199
|
+
<path d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zm0 13a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zm8-5a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0118 10zM5 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 015 10zm10.657-5.657a.75.75 0 010 1.06l-1.06 1.061a.75.75 0 11-1.061-1.06l1.06-1.061a.75.75 0 011.061 0zm-9.193 9.193a.75.75 0 010 1.06l-1.06 1.061a.75.75 0 11-1.061-1.06l1.06-1.061a.75.75 0 011.061 0zm9.193 0a.75.75 0 01-1.06 0l-1.061-1.06a.75.75 0 011.06-1.061l1.061 1.06a.75.75 0 010 1.061zM5.464 6.525a.75.75 0 01-1.06 0L3.343 5.464a.75.75 0 011.06-1.06l1.061 1.06a.75.75 0 010 1.061zM10 6.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z" />
|
|
200
|
+
</svg>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function MoonIcon({ className }: { className?: string }) {
|
|
205
|
+
return (
|
|
206
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={className}>
|
|
207
|
+
<path fillRule="evenodd" d="M7.455 2.004a.75.75 0 01.26.77 7 7 0 009.958 7.967.75.75 0 011.067.853A8.5 8.5 0 116.647 1.921a.75.75 0 01.808.083z" clipRule="evenodd" />
|
|
208
|
+
</svg>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function MonitorIcon({ className }: { className?: string }) {
|
|
213
|
+
return (
|
|
214
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={className}>
|
|
215
|
+
<path fillRule="evenodd" d="M2 4.25A2.25 2.25 0 014.25 2h11.5A2.25 2.25 0 0118 4.25v8.5A2.25 2.25 0 0115.75 15h-3.105a3.501 3.501 0 001.1 1.677A.75.75 0 0113.26 18H6.74a.75.75 0 01-.484-1.323A3.501 3.501 0 007.355 15H4.25A2.25 2.25 0 012 12.75v-8.5zm1.5 0a.75.75 0 01.75-.75h11.5a.75.75 0 01.75.75v7.5a.75.75 0 01-.75.75H4.25a.75.75 0 01-.75-.75v-7.5z" clipRule="evenodd" />
|
|
216
|
+
</svg>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Step 5: Simple Toggle (Alternative)
|
|
222
|
+
|
|
223
|
+
For a minimal sun/moon toggle button:
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
// src/lib/shared/components/patterns/ThemeToggleSimple.tsx
|
|
227
|
+
'use client';
|
|
228
|
+
|
|
229
|
+
import { useTheme } from '@/lib/shared/components/providers/ThemeProvider';
|
|
230
|
+
|
|
231
|
+
export function ThemeToggleSimple() {
|
|
232
|
+
const { resolvedTheme, setTheme } = useTheme();
|
|
233
|
+
|
|
234
|
+
function toggle() {
|
|
235
|
+
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<button
|
|
240
|
+
onClick={toggle}
|
|
241
|
+
className="rounded-md p-2 text-content-secondary hover:bg-surface-secondary hover:text-content-primary transition-colors"
|
|
242
|
+
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
|
|
243
|
+
>
|
|
244
|
+
{resolvedTheme === 'dark' ? <SunIcon className="h-5 w-5" /> : <MoonIcon className="h-5 w-5" />}
|
|
245
|
+
</button>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Step 6: Add to Navbar
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import { ThemeToggle } from '@/lib/shared/components/patterns/ThemeToggle';
|
|
254
|
+
|
|
255
|
+
// In your navbar component:
|
|
256
|
+
<nav className="flex items-center gap-4">
|
|
257
|
+
{/* ... other nav items */}
|
|
258
|
+
<ThemeToggle />
|
|
259
|
+
</nav>
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Step 7: Tailwind Configuration
|
|
263
|
+
|
|
264
|
+
Ensure `tailwind.config.ts` uses class-based dark mode:
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import type { Config } from 'tailwindcss';
|
|
268
|
+
|
|
269
|
+
const config: Config = {
|
|
270
|
+
darkMode: 'class',
|
|
271
|
+
// ... rest of config
|
|
272
|
+
};
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The design token CSS variables already swap under `.dark`:
|
|
276
|
+
|
|
277
|
+
```css
|
|
278
|
+
:root {
|
|
279
|
+
--color-surface-primary: oklch(1 0 0);
|
|
280
|
+
--color-content-primary: oklch(0.15 0 0);
|
|
281
|
+
/* ... */
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.dark {
|
|
285
|
+
--color-surface-primary: oklch(0.15 0 0);
|
|
286
|
+
--color-content-primary: oklch(0.95 0 0);
|
|
287
|
+
/* ... */
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Testing
|
|
292
|
+
|
|
293
|
+
1. Click the toggle — verify the theme switches and the `.dark` class toggles on `<html>`.
|
|
294
|
+
2. Refresh the page — verify the theme persists (no flash).
|
|
295
|
+
3. Set to "system", change OS preference — verify the theme follows.
|
|
296
|
+
4. Clear `localStorage`, reload — verify it defaults to the `app.config.ts` setting.
|
|
297
|
+
5. Verify all semantic token colors update (surfaces, content, borders, interactive).
|
|
298
|
+
|
|
299
|
+
## Checklist
|
|
300
|
+
|
|
301
|
+
- [ ] `darkMode: 'class'` set in `tailwind.config.ts`
|
|
302
|
+
- [ ] Flash-prevention script added to `<head>` in root layout
|
|
303
|
+
- [ ] `ThemeProvider` wraps the app in root layout
|
|
304
|
+
- [ ] `useTheme` hook exports `theme`, `resolvedTheme`, `setTheme`
|
|
305
|
+
- [ ] `localStorage` persistence with `mars-theme` key
|
|
306
|
+
- [ ] System preference listener for `prefers-color-scheme`
|
|
307
|
+
- [ ] Theme toggle component placed in navbar/header
|
|
308
|
+
- [ ] No raw color classes used — only semantic tokens
|
|
309
|
+
- [ ] `suppressHydrationWarning` on `<html>` element
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Skill: Configure Email Service
|
|
2
|
+
|
|
3
|
+
Set up and switch between email providers in a MARS application.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to configure email, set up SendGrid, add Resend, switch email providers, or send transactional emails.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
MARS uses a pluggable email service re-exported via `@/lib/mars`. The active provider is set in `appConfig.services.email.provider`.
|
|
12
|
+
|
|
13
|
+
Available providers:
|
|
14
|
+
- `console` -- logs emails to terminal (default, no setup needed)
|
|
15
|
+
- `sendgrid` -- SendGrid API
|
|
16
|
+
- `resend` -- Resend API (to be added)
|
|
17
|
+
|
|
18
|
+
## Switching Providers
|
|
19
|
+
|
|
20
|
+
### Step 1: Update `app.config.ts`
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
services: {
|
|
24
|
+
email: { provider: 'sendgrid' },
|
|
25
|
+
// ...
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Step 2: Set Environment Variables
|
|
30
|
+
|
|
31
|
+
**SendGrid:**
|
|
32
|
+
```bash
|
|
33
|
+
SENDGRID_API_KEY="SG.your-api-key"
|
|
34
|
+
SENDGRID_FROM_EMAIL="noreply@yourdomain.com"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Resend:**
|
|
38
|
+
```bash
|
|
39
|
+
RESEND_API_KEY="re_your-api-key"
|
|
40
|
+
RESEND_FROM_EMAIL="noreply@yourdomain.com"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Step 3: Update Env Validation
|
|
44
|
+
|
|
45
|
+
In `src/core/env/index.ts`, the schema already conditionally validates email vars:
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
if (appConfig.services.email.provider !== 'console') {
|
|
49
|
+
base.SENDGRID_API_KEY = z.string().min(1).optional();
|
|
50
|
+
base.SENDGRID_FROM_EMAIL = z.string().email().optional();
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Adding a New Provider (Resend)
|
|
55
|
+
|
|
56
|
+
### Step 1: Install the SDK
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
yarn add resend
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Step 2: Add the Provider
|
|
63
|
+
|
|
64
|
+
In the email service module (wired through `@/lib/mars`):
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
async function sendWithResend(params: SendEmailParams): Promise<void> {
|
|
68
|
+
const { Resend } = await import('resend');
|
|
69
|
+
|
|
70
|
+
const apiKey = process.env.RESEND_API_KEY;
|
|
71
|
+
const fromEmail = process.env.RESEND_FROM_EMAIL;
|
|
72
|
+
|
|
73
|
+
if (!apiKey) throw new Error('RESEND_API_KEY is not set');
|
|
74
|
+
if (!fromEmail) throw new Error('RESEND_FROM_EMAIL is not set');
|
|
75
|
+
|
|
76
|
+
const resend = new Resend(apiKey);
|
|
77
|
+
|
|
78
|
+
await resend.emails.send({
|
|
79
|
+
from: `${appConfig.name} <${fromEmail}>`,
|
|
80
|
+
to: params.to,
|
|
81
|
+
subject: params.subject,
|
|
82
|
+
html: params.html,
|
|
83
|
+
text: params.text,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function sendEmail(params: SendEmailParams): Promise<void> {
|
|
88
|
+
const provider = appConfig.services.email.provider;
|
|
89
|
+
|
|
90
|
+
switch (provider) {
|
|
91
|
+
case 'sendgrid':
|
|
92
|
+
return sendWithSendGrid(params);
|
|
93
|
+
case 'resend':
|
|
94
|
+
return sendWithResend(params);
|
|
95
|
+
case 'console':
|
|
96
|
+
default:
|
|
97
|
+
return sendWithConsole(params);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Step 3: Update the Config Type
|
|
103
|
+
|
|
104
|
+
In `app.config.ts`, add the new provider to the union:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
email: { provider: 'console' as 'sendgrid' | 'resend' | 'console' },
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Sending Emails
|
|
111
|
+
|
|
112
|
+
All email sending goes through `sendEmail()`:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
import { sendEmail } from '@/lib/mars';
|
|
116
|
+
import { appConfig } from '@/config/app.config';
|
|
117
|
+
|
|
118
|
+
await sendEmail({
|
|
119
|
+
to: user.email,
|
|
120
|
+
subject: `Welcome to ${appConfig.name}`,
|
|
121
|
+
text: `Hello ${user.name}, welcome!`,
|
|
122
|
+
html: `<h1>Hello ${user.name}</h1><p>Welcome to ${appConfig.name}!</p>`,
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Email Templates
|
|
127
|
+
|
|
128
|
+
For complex emails, create template functions:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// src/features/auth/server/email-templates.ts
|
|
132
|
+
import { appConfig } from '@/config/app.config';
|
|
133
|
+
|
|
134
|
+
export function welcomeEmail(name: string, verifyUrl: string) {
|
|
135
|
+
return {
|
|
136
|
+
subject: `Welcome to ${appConfig.name} - Verify Your Email`,
|
|
137
|
+
text: `Hi ${name},\n\nVerify your email: ${verifyUrl}`,
|
|
138
|
+
html: `
|
|
139
|
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
140
|
+
<h2>Welcome to ${appConfig.name}!</h2>
|
|
141
|
+
<p>Hi ${name},</p>
|
|
142
|
+
<p>Click the button below to verify your email:</p>
|
|
143
|
+
<div style="text-align: center; margin: 30px 0;">
|
|
144
|
+
<a href="${verifyUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
|
|
145
|
+
Verify Email
|
|
146
|
+
</a>
|
|
147
|
+
</div>
|
|
148
|
+
<p style="color: #6b7280; font-size: 14px;">This link expires in 24 hours.</p>
|
|
149
|
+
</div>
|
|
150
|
+
`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Development Workflow
|
|
156
|
+
|
|
157
|
+
1. Start with `console` provider -- emails are logged to terminal.
|
|
158
|
+
2. Switch to `sendgrid` or `resend` when ready for real delivery.
|
|
159
|
+
3. Use the console output to verify email content during development.
|
|
160
|
+
|
|
161
|
+
## Checklist
|
|
162
|
+
|
|
163
|
+
- [ ] Provider set in `appConfig.services.email.provider`
|
|
164
|
+
- [ ] API key and from email in `.env`
|
|
165
|
+
- [ ] Env validation updated for the provider's variables
|
|
166
|
+
- [ ] Provider function added to the email service module
|
|
167
|
+
- [ ] `sendEmail` switch statement updated
|
|
168
|
+
- [ ] Email templates use `appConfig.name` for branding
|
|
169
|
+
- [ ] Tested with `console` provider first
|
|
170
|
+
- [ ] No secrets hardcoded in templates
|