@actuate-media/cms-admin 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/__tests__/views/appearance-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/appearance-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/appearance-settings.render.test.js +95 -0
  4. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -0
  5. package/dist/actuate-admin.css +1 -1
  6. package/dist/components/ThemeProvider.d.ts +47 -4
  7. package/dist/components/ThemeProvider.d.ts.map +1 -1
  8. package/dist/components/ThemeProvider.js +173 -27
  9. package/dist/components/ThemeProvider.js.map +1 -1
  10. package/dist/components/ui/Badge.d.ts +1 -1
  11. package/dist/components/ui/Button.d.ts +1 -1
  12. package/dist/components/ui/Card.d.ts +2 -2
  13. package/dist/components/ui/Input.d.ts +1 -1
  14. package/dist/components/ui/Select.d.ts +1 -1
  15. package/dist/layout/Sidebar.d.ts.map +1 -1
  16. package/dist/layout/Sidebar.js +47 -8
  17. package/dist/layout/Sidebar.js.map +1 -1
  18. package/dist/views/Settings.d.ts.map +1 -1
  19. package/dist/views/Settings.js +3 -7
  20. package/dist/views/Settings.js.map +1 -1
  21. package/dist/views/settings/AdminThemeCard.d.ts +6 -0
  22. package/dist/views/settings/AdminThemeCard.d.ts.map +1 -0
  23. package/dist/views/settings/AdminThemeCard.js +31 -0
  24. package/dist/views/settings/AdminThemeCard.js.map +1 -0
  25. package/dist/views/settings/AppearanceTab.d.ts +7 -0
  26. package/dist/views/settings/AppearanceTab.d.ts.map +1 -0
  27. package/dist/views/settings/AppearanceTab.js +24 -0
  28. package/dist/views/settings/AppearanceTab.js.map +1 -0
  29. package/dist/views/settings/BrandPreviewCard.d.ts +5 -0
  30. package/dist/views/settings/BrandPreviewCard.d.ts.map +1 -0
  31. package/dist/views/settings/BrandPreviewCard.js +18 -0
  32. package/dist/views/settings/BrandPreviewCard.js.map +1 -0
  33. package/dist/views/settings/BrandingCard.d.ts +6 -0
  34. package/dist/views/settings/BrandingCard.d.ts.map +1 -0
  35. package/dist/views/settings/BrandingCard.js +38 -0
  36. package/dist/views/settings/BrandingCard.js.map +1 -0
  37. package/dist/views/settings/TypographyMotionCard.d.ts +6 -0
  38. package/dist/views/settings/TypographyMotionCard.d.ts.map +1 -0
  39. package/dist/views/settings/TypographyMotionCard.js +8 -0
  40. package/dist/views/settings/TypographyMotionCard.js.map +1 -0
  41. package/dist/views/settings/useAppearanceSettings.d.ts +69 -0
  42. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -0
  43. package/dist/views/settings/useAppearanceSettings.js +344 -0
  44. package/dist/views/settings/useAppearanceSettings.js.map +1 -0
  45. package/dist/views/settings/useGeneralSettings.d.ts +4 -1
  46. package/dist/views/settings/useGeneralSettings.d.ts.map +1 -1
  47. package/dist/views/settings/useGeneralSettings.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/__tests__/views/appearance-settings.render.test.tsx +132 -0
  50. package/src/components/ThemeProvider.tsx +249 -36
  51. package/src/layout/Sidebar.tsx +81 -13
  52. package/src/styles/theme.css +32 -2
  53. package/src/views/Settings.tsx +3 -36
  54. package/src/views/settings/AdminThemeCard.tsx +145 -0
  55. package/src/views/settings/AppearanceTab.tsx +81 -0
  56. package/src/views/settings/BrandPreviewCard.tsx +125 -0
  57. package/src/views/settings/BrandingCard.tsx +332 -0
  58. package/src/views/settings/TypographyMotionCard.tsx +65 -0
  59. package/src/views/settings/useAppearanceSettings.ts +513 -0
  60. package/src/views/settings/useGeneralSettings.ts +4 -1
@@ -1,19 +1,101 @@
1
1
  'use client'
2
2
 
3
+ import {
4
+ accentTokensToCssVars,
5
+ generateAdminThemeTokens,
6
+ type ColorMode,
7
+ type SidebarDensity,
8
+ } from '@actuate-media/cms-core/appearance'
3
9
  import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
4
10
 
5
- type Theme = 'light' | 'dark' | 'system'
11
+ /**
12
+ * Admin appearance engine.
13
+ *
14
+ * Owns the live, DOM-applied admin theme: color mode (light/dark/system),
15
+ * accent color, sidebar density, content font, reduced motion, and the
16
+ * resolved admin brand logos used by the sidebar. State is mirrored to
17
+ * localStorage so a reload paints instantly without a render-blocking fetch;
18
+ * the Appearance tab pushes the DB-backed values in via `applyAppearance` on
19
+ * load and save, making the database the source of truth and localStorage the
20
+ * warm cache.
21
+ *
22
+ * Backward compatible: `useTheme()` still exposes `{ theme, resolvedTheme,
23
+ * setTheme }`; the extra fields are additive.
24
+ */
25
+
26
+ /** Resolved admin logo URLs (cached for the sidebar to paint without a fetch). */
27
+ export interface AdminBrandCache {
28
+ primaryUrl: string | null
29
+ markUrl: string | null
30
+ darkUrl: string | null
31
+ initials: string
32
+ }
6
33
 
7
34
  interface ThemeContextValue {
8
- theme: Theme
35
+ theme: ColorMode
9
36
  resolvedTheme: 'light' | 'dark'
10
- setTheme: (theme: Theme) => void
37
+ setTheme: (theme: ColorMode) => void
38
+ accent: string
39
+ setAccent: (accent: string) => void
40
+ density: SidebarDensity
41
+ setDensity: (density: SidebarDensity) => void
42
+ font: string
43
+ setFont: (font: string) => void
44
+ reduceMotion: boolean
45
+ setReduceMotion: (value: boolean) => void
46
+ adminBrand: AdminBrandCache
47
+ setAdminBrand: (brand: AdminBrandCache) => void
48
+ /** Push a full settings snapshot live (used by the Appearance tab). */
49
+ applyAppearance: (snapshot: AppearanceSnapshot) => void
50
+ }
51
+
52
+ export interface AppearanceSnapshot {
53
+ theme?: ColorMode
54
+ accent?: string
55
+ density?: SidebarDensity
56
+ font?: string
57
+ reduceMotion?: boolean
58
+ adminBrand?: AdminBrandCache
59
+ }
60
+
61
+ /** Curated content-font options. Applied as family strings (no web-font load). */
62
+ export const AVAILABLE_FONTS = ['DM Sans', 'Inter', 'Geist', 'System UI'] as const
63
+
64
+ const SYSTEM_STACK = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
65
+
66
+ const FONT_STACKS: Record<string, string> = {
67
+ 'DM Sans': `'DM Sans', ${SYSTEM_STACK}`,
68
+ Inter: `'Inter', ${SYSTEM_STACK}`,
69
+ Geist: `'Geist', ${SYSTEM_STACK}`,
70
+ 'System UI': SYSTEM_STACK,
71
+ }
72
+
73
+ function fontStack(font: string): string {
74
+ return FONT_STACKS[font] ?? `'${font}', ${SYSTEM_STACK}`
75
+ }
76
+
77
+ const DEFAULT_BRAND: AdminBrandCache = {
78
+ primaryUrl: null,
79
+ markUrl: null,
80
+ darkUrl: null,
81
+ initials: 'A',
11
82
  }
12
83
 
13
84
  const ThemeContext = createContext<ThemeContextValue>({
14
85
  theme: 'system',
15
86
  resolvedTheme: 'light',
16
87
  setTheme: () => {},
88
+ accent: 'purple',
89
+ setAccent: () => {},
90
+ density: 'default',
91
+ setDensity: () => {},
92
+ font: 'DM Sans',
93
+ setFont: () => {},
94
+ reduceMotion: false,
95
+ setReduceMotion: () => {},
96
+ adminBrand: DEFAULT_BRAND,
97
+ setAdminBrand: () => {},
98
+ applyAppearance: () => {},
17
99
  })
18
100
 
19
101
  export function useTheme() {
@@ -25,74 +107,205 @@ function getSystemTheme(): 'light' | 'dark' {
25
107
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
26
108
  }
27
109
 
28
- function resolveTheme(theme: Theme): 'light' | 'dark' {
110
+ function resolveTheme(theme: ColorMode): 'light' | 'dark' {
29
111
  return theme === 'system' ? getSystemTheme() : theme
30
112
  }
31
113
 
32
- const STORAGE_KEY = 'actuate-theme'
114
+ const KEY_THEME = 'actuate-theme'
115
+ const KEY_ACCENT = 'actuate-accent'
116
+ const KEY_DENSITY = 'actuate-density'
117
+ const KEY_FONT = 'actuate-font'
118
+ const KEY_MOTION = 'actuate-reduce-motion'
119
+ const KEY_BRAND = 'actuate-admin-brand'
120
+
121
+ function adminRootEl(): HTMLElement | null {
122
+ return document.querySelector('.actuate-admin')
123
+ }
124
+
125
+ function applyColorMode(resolved: 'light' | 'dark') {
126
+ const el = adminRootEl()
127
+ if (!el) return
128
+ el.classList.toggle('dark', resolved === 'dark')
129
+ }
130
+
131
+ function applyAccent(accent: string, resolved: 'light' | 'dark') {
132
+ const el = adminRootEl()
133
+ if (!el) return
134
+ const vars = accentTokensToCssVars(
135
+ generateAdminThemeTokens({ adminAccentColor: accent }, resolved),
136
+ )
137
+ for (const [name, value] of Object.entries(vars)) el.style.setProperty(name, value)
138
+ }
139
+
140
+ function applyDensity(density: SidebarDensity) {
141
+ const el = adminRootEl()
142
+ if (!el) return
143
+ el.setAttribute('data-density', density)
144
+ }
145
+
146
+ function applyFont(font: string) {
147
+ const el = adminRootEl()
148
+ if (!el) return
149
+ el.style.setProperty('--actuate-admin-font', fontStack(font))
150
+ }
151
+
152
+ function applyReduceMotion(value: boolean) {
153
+ const el = adminRootEl()
154
+ if (!el) return
155
+ el.classList.toggle('reduce-motion', value)
156
+ }
33
157
 
34
158
  interface ThemeProviderProps {
35
159
  children: ReactNode
36
- /** When true, default to dark mode if the user hasn't explicitly chosen a theme. */
160
+ /** When true, default to dark mode if the user hasn't explicitly chosen one. */
37
161
  defaultDarkMode?: boolean
38
162
  }
39
163
 
40
164
  export function ThemeProvider({ children, defaultDarkMode }: ThemeProviderProps) {
41
- const [theme, setThemeState] = useState<Theme>('system')
165
+ const [theme, setThemeState] = useState<ColorMode>('system')
42
166
  const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light')
167
+ const [accent, setAccentState] = useState('purple')
168
+ const [density, setDensityState] = useState<SidebarDensity>('default')
169
+ const [font, setFontState] = useState('DM Sans')
170
+ const [reduceMotion, setReduceMotionState] = useState(false)
171
+ const [adminBrand, setAdminBrandState] = useState<AdminBrandCache>(DEFAULT_BRAND)
43
172
 
173
+ // Hydrate from localStorage on mount and apply everything to the DOM.
44
174
  useEffect(() => {
45
- const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
46
- let initial: Theme
47
- if (stored && ['light', 'dark', 'system'].includes(stored)) {
48
- initial = stored
175
+ const storedTheme = localStorage.getItem(KEY_THEME) as ColorMode | null
176
+ let initialTheme: ColorMode
177
+ if (storedTheme && ['light', 'dark', 'system'].includes(storedTheme)) {
178
+ initialTheme = storedTheme
49
179
  } else if (defaultDarkMode === true) {
50
- initial = 'dark'
180
+ initialTheme = 'dark'
51
181
  } else if (defaultDarkMode === false) {
52
- initial = 'light'
182
+ initialTheme = 'light'
53
183
  } else {
54
- initial = 'system'
184
+ initialTheme = 'system'
55
185
  }
56
- setThemeState(initial)
57
- const resolved = resolveTheme(initial)
186
+ const resolved = resolveTheme(initialTheme)
187
+
188
+ const storedAccent = localStorage.getItem(KEY_ACCENT) || 'purple'
189
+ const storedDensity = (localStorage.getItem(KEY_DENSITY) as SidebarDensity | null) || 'default'
190
+ const storedFont = localStorage.getItem(KEY_FONT) || 'DM Sans'
191
+ const storedMotion = localStorage.getItem(KEY_MOTION) === 'true'
192
+ let storedBrand = DEFAULT_BRAND
193
+ try {
194
+ const raw = localStorage.getItem(KEY_BRAND)
195
+ if (raw) storedBrand = { ...DEFAULT_BRAND, ...JSON.parse(raw) }
196
+ } catch {
197
+ // Ignore malformed cache.
198
+ }
199
+
200
+ setThemeState(initialTheme)
58
201
  setResolvedTheme(resolved)
59
- applyTheme(resolved)
202
+ setAccentState(storedAccent)
203
+ setDensityState(storedDensity)
204
+ setFontState(storedFont)
205
+ setReduceMotionState(storedMotion)
206
+ setAdminBrandState(storedBrand)
207
+
208
+ applyColorMode(resolved)
209
+ applyAccent(storedAccent, resolved)
210
+ applyDensity(storedDensity)
211
+ applyFont(storedFont)
212
+ applyReduceMotion(storedMotion)
60
213
  }, [defaultDarkMode])
61
214
 
215
+ // Follow the OS color scheme while in `system` mode (re-applies accent too,
216
+ // since presets differ between light and dark).
62
217
  useEffect(() => {
63
218
  const mq = window.matchMedia('(prefers-color-scheme: dark)')
64
219
  const handler = () => {
65
220
  if (theme === 'system') {
66
221
  const resolved = getSystemTheme()
67
222
  setResolvedTheme(resolved)
68
- applyTheme(resolved)
223
+ applyColorMode(resolved)
224
+ applyAccent(accent, resolved)
69
225
  }
70
226
  }
71
227
  mq.addEventListener('change', handler)
72
228
  return () => mq.removeEventListener('change', handler)
73
- }, [theme])
229
+ }, [theme, accent])
74
230
 
75
- const setTheme = useCallback((next: Theme) => {
76
- setThemeState(next)
77
- localStorage.setItem(STORAGE_KEY, next)
78
- const resolved = resolveTheme(next)
79
- setResolvedTheme(resolved)
80
- applyTheme(resolved)
231
+ const setTheme = useCallback(
232
+ (next: ColorMode) => {
233
+ setThemeState(next)
234
+ localStorage.setItem(KEY_THEME, next)
235
+ const resolved = resolveTheme(next)
236
+ setResolvedTheme(resolved)
237
+ applyColorMode(resolved)
238
+ applyAccent(accent, resolved)
239
+ },
240
+ [accent],
241
+ )
242
+
243
+ const setAccent = useCallback(
244
+ (next: string) => {
245
+ setAccentState(next)
246
+ localStorage.setItem(KEY_ACCENT, next)
247
+ applyAccent(next, resolvedTheme)
248
+ },
249
+ [resolvedTheme],
250
+ )
251
+
252
+ const setDensity = useCallback((next: SidebarDensity) => {
253
+ setDensityState(next)
254
+ localStorage.setItem(KEY_DENSITY, next)
255
+ applyDensity(next)
256
+ }, [])
257
+
258
+ const setFont = useCallback((next: string) => {
259
+ setFontState(next)
260
+ localStorage.setItem(KEY_FONT, next)
261
+ applyFont(next)
81
262
  }, [])
82
263
 
264
+ const setReduceMotion = useCallback((next: boolean) => {
265
+ setReduceMotionState(next)
266
+ localStorage.setItem(KEY_MOTION, String(next))
267
+ applyReduceMotion(next)
268
+ }, [])
269
+
270
+ const setAdminBrand = useCallback((next: AdminBrandCache) => {
271
+ setAdminBrandState(next)
272
+ localStorage.setItem(KEY_BRAND, JSON.stringify(next))
273
+ }, [])
274
+
275
+ // Apply a full snapshot live (Appearance tab on load/save). Persists to
276
+ // localStorage so the choice survives reloads even before the next fetch.
277
+ const applyAppearance = useCallback(
278
+ (snapshot: AppearanceSnapshot) => {
279
+ if (snapshot.theme !== undefined) setTheme(snapshot.theme)
280
+ if (snapshot.accent !== undefined) setAccent(snapshot.accent)
281
+ if (snapshot.density !== undefined) setDensity(snapshot.density)
282
+ if (snapshot.font !== undefined) setFont(snapshot.font)
283
+ if (snapshot.reduceMotion !== undefined) setReduceMotion(snapshot.reduceMotion)
284
+ if (snapshot.adminBrand !== undefined) setAdminBrand(snapshot.adminBrand)
285
+ },
286
+ [setTheme, setAccent, setDensity, setFont, setReduceMotion, setAdminBrand],
287
+ )
288
+
83
289
  return (
84
- <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
290
+ <ThemeContext.Provider
291
+ value={{
292
+ theme,
293
+ resolvedTheme,
294
+ setTheme,
295
+ accent,
296
+ setAccent,
297
+ density,
298
+ setDensity,
299
+ font,
300
+ setFont,
301
+ reduceMotion,
302
+ setReduceMotion,
303
+ adminBrand,
304
+ setAdminBrand,
305
+ applyAppearance,
306
+ }}
307
+ >
85
308
  {children}
86
309
  </ThemeContext.Provider>
87
310
  )
88
311
  }
89
-
90
- function applyTheme(resolved: 'light' | 'dark') {
91
- const adminRoot = document.querySelector('.actuate-admin')
92
- if (!adminRoot) return
93
- if (resolved === 'dark') {
94
- adminRoot.classList.add('dark')
95
- } else {
96
- adminRoot.classList.remove('dark')
97
- }
98
- }
@@ -76,26 +76,55 @@ const ICON_MAP: Record<string, LucideIcon> = {
76
76
  Layers: Layers,
77
77
  }
78
78
 
79
+ /** Square site-initials badge — the final fallback before the Actuate mark. */
80
+ function InitialsBadge({ initials, size }: { initials: string; size: 'sm' | 'lg' }) {
81
+ return (
82
+ <span
83
+ className={`bg-sidebar-primary text-sidebar-primary-foreground inline-flex shrink-0 items-center justify-center rounded-md font-medium ${
84
+ size === 'lg' ? 'h-8 w-8 text-sm' : 'h-8 w-8 text-sm'
85
+ }`}
86
+ aria-hidden="true"
87
+ >
88
+ {initials}
89
+ </span>
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Sidebar brand lockup.
95
+ *
96
+ * Resolution order follows the Appearance spec, sourcing from the live
97
+ * appearance context (DB-backed brand, cached in localStorage) and falling
98
+ * back to legacy `config.admin.branding`, then site initials, then the bundled
99
+ * Actuate mark/wordmark for un-branded installs. Dark-mode uses the dark logo
100
+ * when one is configured. Logos are never stretched/cropped (object-contain).
101
+ */
79
102
  function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean }) {
103
+ const { adminBrand, resolvedTheme } = useTheme()
80
104
  const branding = config?.admin?.branding
81
- const customLogo = branding?.logo
105
+ const configLogo = branding?.logo
82
106
  const brandName = branding?.name
107
+ const initials = adminBrand.initials
108
+ const hasInitials = Boolean(initials) && initials !== 'A'
109
+
110
+ // Prefer the dark logo on dark backgrounds when supplied.
111
+ const primary =
112
+ resolvedTheme === 'dark' && adminBrand.darkUrl ? adminBrand.darkUrl : adminBrand.primaryUrl
113
+ const mark = adminBrand.markUrl
114
+ const alt = brandName ?? 'Admin'
83
115
 
84
116
  if (collapsed) {
85
- if (customLogo) {
86
- return <img src={customLogo} alt={brandName ?? 'Admin'} className="h-8 w-8 object-contain" />
87
- }
117
+ if (mark) return <img src={mark} alt={alt} className="h-8 w-8 object-contain" />
118
+ if (primary) return <img src={primary} alt={alt} className="h-8 w-8 object-contain" />
119
+ if (configLogo) return <img src={configLogo} alt={alt} className="h-8 w-8 object-contain" />
120
+ if (hasInitials) return <InitialsBadge initials={initials} size="sm" />
88
121
  return <ActuateMark className="h-8 w-8" />
89
122
  }
90
123
 
91
- if (customLogo) {
124
+ if (primary) {
92
125
  return (
93
126
  <div className="flex min-w-0 items-center gap-2.5">
94
- <img
95
- src={customLogo}
96
- alt={brandName ?? 'Admin'}
97
- className="h-8 w-auto shrink-0 object-contain"
98
- />
127
+ <img src={primary} alt={alt} className="h-8 w-auto shrink-0 object-contain" />
99
128
  {brandName && (
100
129
  <span className="text-sidebar-foreground truncate text-sm font-medium">{brandName}</span>
101
130
  )}
@@ -103,6 +132,32 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
103
132
  )
104
133
  }
105
134
 
135
+ if (configLogo) {
136
+ return (
137
+ <div className="flex min-w-0 items-center gap-2.5">
138
+ <img src={configLogo} alt={alt} className="h-8 w-auto shrink-0 object-contain" />
139
+ {brandName && (
140
+ <span className="text-sidebar-foreground truncate text-sm font-medium">{brandName}</span>
141
+ )}
142
+ </div>
143
+ )
144
+ }
145
+
146
+ if (mark || hasInitials) {
147
+ return (
148
+ <div className="flex min-w-0 items-center gap-2.5">
149
+ {mark ? (
150
+ <img src={mark} alt={alt} className="h-8 w-8 shrink-0 object-contain" />
151
+ ) : (
152
+ <InitialsBadge initials={initials} size="lg" />
153
+ )}
154
+ <span className="text-sidebar-foreground truncate text-sm font-medium">
155
+ {brandName ?? alt}
156
+ </span>
157
+ </div>
158
+ )
159
+ }
160
+
106
161
  if (brandName) {
107
162
  return (
108
163
  <div className="flex min-w-0 items-center gap-2.5">
@@ -180,6 +235,8 @@ export function Sidebar({
180
235
  }: SidebarProps) {
181
236
  const formsSidebar = useFormsSidebar()
182
237
  const navItems = applyFormsNav(buildNavItems(config), formsSidebar)
238
+ const { density } = useTheme()
239
+ const compact = density === 'compact'
183
240
 
184
241
  return (
185
242
  <aside
@@ -196,8 +253,11 @@ export function Sidebar({
196
253
  <BrandLogo config={config} collapsed={collapsed} />
197
254
  </div>
198
255
 
199
- <nav className="flex-1 space-y-1 overflow-y-auto p-3" aria-label="Primary">
200
- {renderNavTree(navItems, { collapsed, currentPath, onNavigate })}
256
+ <nav
257
+ className={`flex-1 overflow-y-auto ${compact ? 'space-y-0.5 p-2' : 'space-y-1 p-3'}`}
258
+ aria-label="Primary"
259
+ >
260
+ {renderNavTree(navItems, { collapsed, currentPath, onNavigate, compact })}
201
261
  </nav>
202
262
 
203
263
  {/* Footer collapse control. Sits at the bottom of the sidebar so it
@@ -303,6 +363,8 @@ interface NavRenderContext {
303
363
  collapsed: boolean
304
364
  currentPath: string
305
365
  onNavigate: (path: string) => void
366
+ /** Compact density tightens nav row vertical padding. */
367
+ compact?: boolean
306
368
  }
307
369
 
308
370
  function isPathActive(currentPath: string, itemPath: string): boolean {
@@ -472,7 +534,13 @@ function NavLink({
472
534
  <button
473
535
  onClick={() => ctx.onNavigate(item.path)}
474
536
  className={`flex w-full items-center gap-3 rounded-lg text-left transition-colors ${
475
- nested ? 'py-1.5 pr-3 pl-9' : 'px-3 py-2.5'
537
+ nested
538
+ ? ctx.compact
539
+ ? 'py-1 pr-3 pl-9'
540
+ : 'py-1.5 pr-3 pl-9'
541
+ : ctx.compact
542
+ ? 'px-3 py-1.5'
543
+ : 'px-3 py-2.5'
476
544
  } ${
477
545
  isActive
478
546
  ? 'bg-sidebar-accent text-sidebar-primary'
@@ -166,13 +166,23 @@
166
166
 
167
167
  .actuate-admin {
168
168
  @apply bg-background text-foreground;
169
- font-family:
169
+ /*
170
+ * Content font is var-driven so the Appearance tab can switch it at
171
+ * runtime, but defaults to the system-ui stack. We do NOT load web fonts
172
+ * here (no Google Fonts / @font-face) — the chosen family is applied as a
173
+ * family string and falls back to system-ui when not installed, keeping
174
+ * the admin dependency-free per the design system. This is the single
175
+ * permitted `!important` (it must beat consumer global resets).
176
+ */
177
+ font-family: var(
178
+ --actuate-admin-font,
170
179
  system-ui,
171
180
  -apple-system,
172
181
  BlinkMacSystemFont,
173
182
  'Segoe UI',
174
183
  Roboto,
175
- sans-serif !important;
184
+ sans-serif
185
+ ) !important;
176
186
  font-size: var(--font-size);
177
187
  line-height: 1.5;
178
188
  isolation: isolate;
@@ -241,3 +251,23 @@
241
251
  line-height: 1.5;
242
252
  }
243
253
  }
254
+
255
+ /*
256
+ * Reduce-motion (admin-level Appearance toggle).
257
+ *
258
+ * Intentionally UNLAYERED: unlayered rules beat Tailwind's `@layer utilities`
259
+ * transition/animation classes without needing `!important`, so the toggle can
260
+ * neutralize utility-driven motion. We shorten (not delete) durations and never
261
+ * touch outlines/focus rings, so state changes and focus visibility remain
262
+ * intact. The OS `prefers-reduced-motion` is still respected app-wide via
263
+ * `motion-safe:`/`motion-reduce:`; this class lets users opt in regardless.
264
+ */
265
+ .actuate-admin.reduce-motion,
266
+ .actuate-admin.reduce-motion *,
267
+ .actuate-admin.reduce-motion *::before,
268
+ .actuate-admin.reduce-motion *::after {
269
+ animation-duration: 0.001ms;
270
+ animation-iteration-count: 1;
271
+ transition-duration: 0.001ms;
272
+ scroll-behavior: auto;
273
+ }
@@ -26,10 +26,10 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
26
26
  import { toast } from 'sonner'
27
27
  import { useApiData } from '../lib/useApiData.js'
28
28
  import { cmsApi } from '../lib/api.js'
29
- import { useTheme } from '../components/ThemeProvider.js'
30
29
  import { RelationshipField } from '../fields/RelationshipField.js'
31
30
  import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
32
31
  import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
32
+ import { AppearanceTab } from './settings/AppearanceTab.js'
33
33
  import { Users as UsersView } from './Users.js'
34
34
  import { ApiKeys as ApiKeysView } from './ApiKeys.js'
35
35
 
@@ -44,7 +44,7 @@ export interface SettingsProps {
44
44
  }
45
45
 
46
46
  /** Tabs that drive their own persistence (no global "Save Changes" footer). */
47
- const SELF_MANAGED_TABS = new Set(['general', 'users', 'api-keys'])
47
+ const SELF_MANAGED_TABS = new Set(['general', 'appearance', 'users', 'api-keys'])
48
48
 
49
49
  function readSettingsTabFromUrl(): string | null {
50
50
  if (typeof window === 'undefined') return null
@@ -275,25 +275,7 @@ export function Settings({
275
275
  </Tabs.Content>
276
276
 
277
277
  <Tabs.Content value="appearance" className="space-y-4">
278
- <div className="rounded-lg border border-gray-200 bg-white p-4">
279
- <h3 className="mb-4 text-sm font-semibold text-gray-900">Theme</h3>
280
- <div className="space-y-4">
281
- <div>
282
- <label className="mb-1 block text-sm font-medium text-gray-700">Color Scheme</label>
283
- <ThemeSelect />
284
- </div>
285
- <div>
286
- <label className="mb-1 block text-sm font-medium text-gray-700">
287
- Primary Color
288
- </label>
289
- <input
290
- type="color"
291
- defaultValue="#3b82f6"
292
- className="h-10 w-full rounded-lg border border-gray-300 px-3 py-2"
293
- />
294
- </div>
295
- </div>
296
- </div>
278
+ <AppearanceTab role={session?.user?.role} config={config} />
297
279
  </Tabs.Content>
298
280
 
299
281
  {hasLayoutRegions && (
@@ -622,21 +604,6 @@ export function Settings({
622
604
  )
623
605
  }
624
606
 
625
- function ThemeSelect() {
626
- const { theme, setTheme } = useTheme()
627
- return (
628
- <select
629
- value={theme}
630
- onChange={(e) => setTheme(e.target.value as 'light' | 'dark' | 'system')}
631
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
632
- >
633
- <option value="light">Light</option>
634
- <option value="dark">Dark</option>
635
- <option value="system">Auto (System)</option>
636
- </select>
637
- )
638
- }
639
-
640
607
  function ToggleSetting({
641
608
  label,
642
609
  description,