@prenta/admin 2.5.0 → 2.6.1

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 (90) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/__tests__/views/appearance-settings.render.test.js +3 -0
  3. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
  4. package/dist/__tests__/views/mcp-connect.render.test.d.ts +2 -0
  5. package/dist/__tests__/views/mcp-connect.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/mcp-connect.render.test.js +56 -0
  7. package/dist/__tests__/views/mcp-connect.render.test.js.map +1 -0
  8. package/dist/__tests__/views/mcp-connect.test.d.ts +2 -0
  9. package/dist/__tests__/views/mcp-connect.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/mcp-connect.test.js +37 -0
  11. package/dist/__tests__/views/mcp-connect.test.js.map +1 -0
  12. package/dist/lib/appearance-api.d.ts +8 -0
  13. package/dist/lib/appearance-api.d.ts.map +1 -1
  14. package/dist/lib/appearance-api.js.map +1 -1
  15. package/dist/prenta-admin.css +1 -1
  16. package/dist/views/ApiKeys.d.ts +1 -1
  17. package/dist/views/ApiKeys.d.ts.map +1 -1
  18. package/dist/views/ApiKeys.js +2 -3
  19. package/dist/views/ApiKeys.js.map +1 -1
  20. package/dist/views/Settings.d.ts.map +1 -1
  21. package/dist/views/Settings.js +5 -3
  22. package/dist/views/Settings.js.map +1 -1
  23. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  24. package/dist/views/page-editor/EditorCanvas.js +2 -1
  25. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  26. package/dist/views/page-editor/SectionThemeRoot.d.ts +10 -0
  27. package/dist/views/page-editor/SectionThemeRoot.d.ts.map +1 -0
  28. package/dist/views/page-editor/SectionThemeRoot.js +27 -0
  29. package/dist/views/page-editor/SectionThemeRoot.js.map +1 -0
  30. package/dist/views/page-editor/sections/AuthorBioSection.js +1 -1
  31. package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -1
  32. package/dist/views/page-editor/sections/ByTheNumbersSection.js +1 -1
  33. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -1
  34. package/dist/views/page-editor/sections/CtaBannerSection.js +1 -1
  35. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -1
  36. package/dist/views/page-editor/sections/FeatureHighlightSection.js +2 -2
  37. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -1
  38. package/dist/views/page-editor/sections/HeroBannerSection.js +1 -1
  39. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -1
  40. package/dist/views/page-editor/sections/parts.d.ts.map +1 -1
  41. package/dist/views/page-editor/sections/parts.js +11 -15
  42. package/dist/views/page-editor/sections/parts.js.map +1 -1
  43. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  44. package/dist/views/post-editor/PostEditorCanvas.js +3 -2
  45. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  46. package/dist/views/settings/AppearanceTab.d.ts.map +1 -1
  47. package/dist/views/settings/AppearanceTab.js +2 -1
  48. package/dist/views/settings/AppearanceTab.js.map +1 -1
  49. package/dist/views/settings/McpConnectTab.d.ts +9 -0
  50. package/dist/views/settings/McpConnectTab.d.ts.map +1 -0
  51. package/dist/views/settings/McpConnectTab.js +71 -0
  52. package/dist/views/settings/McpConnectTab.js.map +1 -0
  53. package/dist/views/settings/SiteThemeCard.d.ts +7 -0
  54. package/dist/views/settings/SiteThemeCard.d.ts.map +1 -0
  55. package/dist/views/settings/SiteThemeCard.js +23 -0
  56. package/dist/views/settings/SiteThemeCard.js.map +1 -0
  57. package/dist/views/settings/mcp-connect.d.ts +25 -0
  58. package/dist/views/settings/mcp-connect.d.ts.map +1 -0
  59. package/dist/views/settings/mcp-connect.js +166 -0
  60. package/dist/views/settings/mcp-connect.js.map +1 -0
  61. package/dist/views/settings/useAppearanceSettings.d.ts +3 -1
  62. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
  63. package/dist/views/settings/useAppearanceSettings.js +27 -1
  64. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  65. package/package.json +3 -3
  66. package/src/__tests__/views/appearance-settings.render.test.tsx +8 -0
  67. package/src/__tests__/views/mcp-connect.render.test.tsx +65 -0
  68. package/src/__tests__/views/mcp-connect.test.ts +42 -0
  69. package/src/lib/appearance-api.ts +8 -0
  70. package/src/views/ApiKeys.tsx +4 -5
  71. package/src/views/Settings.tsx +13 -0
  72. package/src/views/page-editor/EditorCanvas.tsx +2 -1
  73. package/src/views/page-editor/SectionThemeRoot.tsx +33 -0
  74. package/src/views/page-editor/sections/AuthorBioSection.tsx +2 -2
  75. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +1 -1
  76. package/src/views/page-editor/sections/CtaBannerSection.tsx +1 -1
  77. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +3 -3
  78. package/src/views/page-editor/sections/HeroBannerSection.tsx +2 -2
  79. package/src/views/page-editor/sections/parts.tsx +12 -15
  80. package/src/views/post-editor/PostEditorCanvas.tsx +21 -18
  81. package/src/views/settings/AppearanceTab.tsx +2 -0
  82. package/src/views/settings/McpConnectTab.tsx +213 -0
  83. package/src/views/settings/SiteThemeCard.tsx +71 -0
  84. package/src/views/settings/mcp-connect.ts +209 -0
  85. package/src/views/settings/useAppearanceSettings.ts +39 -0
  86. package/dist/views/settings/McpSetupCard.d.ts +0 -2
  87. package/dist/views/settings/McpSetupCard.d.ts.map +0 -1
  88. package/dist/views/settings/McpSetupCard.js +0 -48
  89. package/dist/views/settings/McpSetupCard.js.map +0 -1
  90. package/src/views/settings/McpSetupCard.tsx +0 -95
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+
5
+ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) => {
6
+ if (endpoint === '/mcp/oauth/setup') {
7
+ return { data: { mcpUrl: 'https://example.com/mcp' }, status: 200 }
8
+ }
9
+ if (endpoint === '/mcp/oauth/grants' && options?.method === 'DELETE') {
10
+ return { data: { note: 'Disconnected' }, status: 200 }
11
+ }
12
+ if (endpoint === '/mcp/oauth/grants') {
13
+ return {
14
+ data: [
15
+ {
16
+ id: 'grant-1',
17
+ clientId: 'https://claude.ai/oauth/client',
18
+ scope: 'content',
19
+ revokedAt: null,
20
+ },
21
+ ],
22
+ status: 200,
23
+ }
24
+ }
25
+ return { data: {}, status: 200 }
26
+ })
27
+
28
+ vi.mock('../../lib/api.js', () => ({ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as never) }))
29
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
30
+
31
+ const { McpConnectTab } = await import('../../views/settings/McpConnectTab.js')
32
+
33
+ beforeEach(() => {
34
+ cmsApi.mockClear()
35
+ })
36
+
37
+ describe('McpConnectTab', () => {
38
+ it('shows a Claude Code prompt and switches to the Cursor config', async () => {
39
+ const onNavigate = vi.fn()
40
+ render(<McpConnectTab onNavigate={onNavigate} />)
41
+
42
+ expect(
43
+ await screen.findByRole('heading', { name: 'Connect this site to Claude Code' }),
44
+ ).toBeTruthy()
45
+ expect(screen.getByText(/Do not store an API key/)).toBeTruthy()
46
+ expect(screen.getByText(/send each photo as its own file/)).toBeTruthy()
47
+ expect(screen.getByText(/claude mcp add --transport http example-prenta/)).toBeTruthy()
48
+
49
+ const cursor = screen.getByRole('tab', { name: 'Cursor' })
50
+ fireEvent.mouseDown(cursor)
51
+ fireEvent.click(cursor)
52
+ expect(screen.getByRole('heading', { name: 'Connect this site to Cursor' })).toBeTruthy()
53
+ expect(screen.getByText(/PRENTA_API_KEY/)).toBeTruthy()
54
+
55
+ fireEvent.click(screen.getByRole('button', { name: 'Create a Content API key' }))
56
+ expect(onNavigate).toHaveBeenCalledWith('/settings?tab=api-keys')
57
+ })
58
+
59
+ it('lists a connected app and revokes it', async () => {
60
+ render(<McpConnectTab />)
61
+ expect(await screen.findByText('https://claude.ai/oauth/client')).toBeTruthy()
62
+ fireEvent.click(screen.getByRole('button', { name: 'Revoke' }))
63
+ expect(cmsApi).toHaveBeenCalledWith('/mcp/oauth/grants/grant-1', { method: 'DELETE' })
64
+ })
65
+ })
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { mcpClientGuides, mcpServerName } from '../../views/settings/mcp-connect.js'
4
+
5
+ const URL = 'https://example.com/mcp'
6
+
7
+ describe('mcp connection guides', () => {
8
+ it('names the server from the site host', () => {
9
+ expect(mcpServerName('https://www.example.com/mcp')).toBe('example-prenta')
10
+ expect(mcpServerName('not a url')).toBe('site-prenta')
11
+ })
12
+
13
+ it('gives Claude Code a prompt with the URL and no API key', () => {
14
+ const guide = mcpClientGuides(URL).find((item) => item.id === 'claude-code')!
15
+ expect(guide.action).toBe('Copy prompt')
16
+ expect(guide.copyText).toBe(guide.panel)
17
+ expect(guide.copyText).toContain(URL)
18
+ expect(guide.copyText).toContain('claude mcp add --transport http example-prenta')
19
+ expect(guide.copyText).not.toContain('act_sk_')
20
+ expect(guide.needsApiKey).toBe(false)
21
+ })
22
+
23
+ it('tells ChatGPT to sign in and not to use an API key', () => {
24
+ const guide = mcpClientGuides(URL).find((item) => item.id === 'chatgpt')!
25
+ expect(guide.copyText).toBe(URL)
26
+ expect(guide.summary).toMatch(/cannot use an API key/i)
27
+ expect(guide.needsApiKey).toBe(false)
28
+ })
29
+
30
+ it('gives Cursor and VS Code different configs with a placeholder key', () => {
31
+ const guides = mcpClientGuides(URL)
32
+ const cursor = guides.find((item) => item.id === 'cursor')!
33
+ const vscode = guides.find((item) => item.id === 'vscode')!
34
+ expect(cursor.copyText).toContain('"mcpServers"')
35
+ expect(cursor.copyText).toContain('"PRENTA_BASE_URL": "https://example.com"')
36
+ expect(cursor.copyText).toContain('"PRENTA_API_KEY": "act_sk_..."')
37
+ expect(cursor.copyText).not.toMatch(/act_sk_[a-z0-9]/i)
38
+ expect(vscode.copyText).toContain('"servers"')
39
+ expect(vscode.copyText).toContain('"type": "stdio"')
40
+ expect(vscode.needsApiKey).toBe(true)
41
+ })
42
+ })
@@ -18,6 +18,14 @@ export interface AppearanceApiResponse {
18
18
  sidebarDensity: SidebarDensity
19
19
  contentFont: string
20
20
  reduceMotion: boolean
21
+ publicTheme?: {
22
+ accent?: string
23
+ background?: string
24
+ foreground?: string
25
+ muted?: string
26
+ fontFamily?: string
27
+ radius?: string
28
+ }
21
29
  }
22
30
  brand: BrandSettings
23
31
  resolved: { admin: { initials: string } }
@@ -19,7 +19,6 @@ import { useApiData } from '../lib/useApiData.js'
19
19
  import { cmsApi } from '../lib/api.js'
20
20
  import { ReauthDialog } from '../components/ui/ReauthDialog.js'
21
21
  import { SettingsCard } from './settings/components.js'
22
- import { McpSetupCard } from './settings/McpSetupCard.js'
23
22
 
24
23
  const DIALOG_INPUT_CLASS =
25
24
  'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none'
@@ -78,7 +77,7 @@ function formatDate(value: string | null): string {
78
77
  }
79
78
  }
80
79
 
81
- export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
80
+ export function ApiKeys({ embedded = false, onNavigate }: ApiKeysProps = {}) {
82
81
  const { data, loading, error, refetch } = useApiData<ApiKeyRecord[]>('/api-keys')
83
82
  const [showCreate, setShowCreate] = useState(false)
84
83
  const [createdKey, setCreatedKey] = useState<{ key: string; record: ApiKeyRecord } | null>(null)
@@ -97,15 +96,15 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
97
96
 
98
97
  return (
99
98
  <div className={`${rootPadding} space-y-4`}>
100
- <McpSetupCard />
101
99
  <SettingsCard
102
100
  headingLevel="h2"
103
101
  title="API Keys"
104
102
  description={
105
103
  <>
106
- Long-lived credentials for programmatic access. Use the{' '}
104
+ Long-lived credentials for Cursor, VS Code, and other clients that cannot sign in.
105
+ Claude, ChatGPT, and Grok on the web connect from the MCP tab. Use the{' '}
107
106
  <code className="bg-muted rounded px-1 py-0.5 text-xs">Authorization: Bearer</code>{' '}
108
- header instead of session cookies. API key requests skip CSRF.
107
+ header. API key requests skip CSRF.
109
108
  </>
110
109
  }
111
110
  actions={
@@ -10,6 +10,7 @@ import {
10
10
  Mail,
11
11
  Users as UsersIcon,
12
12
  KeyRound,
13
+ Plug,
13
14
  } from 'lucide-react'
14
15
  import { useState, useEffect, useCallback, useMemo } from 'react'
15
16
  import { toast } from 'sonner'
@@ -28,6 +29,7 @@ import { UpdatesTab } from './settings/UpdatesTab.js'
28
29
  import { WebhooksTab } from './settings/WebhooksTab.js'
29
30
  import { Users as UsersView } from './Users.js'
30
31
  import { ApiKeys as ApiKeysView } from './ApiKeys.js'
32
+ import { McpConnectTab } from './settings/McpConnectTab.js'
31
33
 
32
34
  export interface SettingsProps {
33
35
  onNavigate?: (path: string) => void
@@ -91,6 +93,7 @@ export function Settings({
91
93
  ...(hasLayoutRegions ? ['layout'] : []),
92
94
  'security',
93
95
  'users',
96
+ 'mcp',
94
97
  'api-keys',
95
98
  'seo',
96
99
  'email',
@@ -215,6 +218,12 @@ export function Settings({
215
218
  Users
216
219
  </span>
217
220
  </Tabs.Trigger>
221
+ <Tabs.Trigger value="mcp" className={tabTriggerClass}>
222
+ <span className="flex items-center gap-1.5">
223
+ <Plug className="h-4 w-4" />
224
+ MCP
225
+ </span>
226
+ </Tabs.Trigger>
218
227
  <Tabs.Trigger value="api-keys" className={tabTriggerClass}>
219
228
  <span className="flex items-center gap-1.5">
220
229
  <KeyRound className="h-4 w-4" />
@@ -346,6 +355,10 @@ export function Settings({
346
355
  <UsersView onNavigate={onNavigate} embedded currentPath={currentPath} />
347
356
  </Tabs.Content>
348
357
 
358
+ <Tabs.Content value="mcp">
359
+ <McpConnectTab onNavigate={onNavigate} />
360
+ </Tabs.Content>
361
+
349
362
  <Tabs.Content value="api-keys">
350
363
  <ApiKeysView onNavigate={onNavigate} embedded />
351
364
  </Tabs.Content>
@@ -13,6 +13,7 @@ import {
13
13
  type ThemeChromeEditTarget,
14
14
  type ThemeChromeMode,
15
15
  } from '../../lib/theme-chrome.js'
16
+ import { SectionThemeRoot } from './SectionThemeRoot.js'
16
17
  import { SectionRenderer } from './sections/index.js'
17
18
  import { StructureNotice } from './StructureNotice.js'
18
19
  import { ThemeChromeFrame, ThemeChromeHealth } from './theme-chrome/index.js'
@@ -172,7 +173,7 @@ export function EditorCanvas({
172
173
  )
173
174
  }
174
175
  >
175
- {inner}
176
+ <SectionThemeRoot>{inner}</SectionThemeRoot>
176
177
  </CanvasSurface>
177
178
  </CanvasScroll>
178
179
  )
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+
3
+ import { publicThemeCssVars, type PublicTheme } from '@prenta/core/appearance'
4
+ import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'
5
+
6
+ import { cmsApi } from '../../lib/api.js'
7
+
8
+ /**
9
+ * Paints the public section theme onto the editor canvas. The stylesheet
10
+ * defaults apply when the site has not set a theme. A failed load keeps
11
+ * those defaults.
12
+ */
13
+ export function SectionThemeRoot({ children }: { children: ReactNode }) {
14
+ const [theme, setTheme] = useState<PublicTheme>({})
15
+
16
+ useEffect(() => {
17
+ let cancelled = false
18
+ void cmsApi<{ appearance?: { publicTheme?: PublicTheme } }>('/appearance').then((res) => {
19
+ if (cancelled || !res.data?.appearance?.publicTheme) return
20
+ setTheme(res.data.appearance.publicTheme)
21
+ })
22
+ return () => {
23
+ cancelled = true
24
+ }
25
+ }, [])
26
+
27
+ const style = publicThemeCssVars(theme) as CSSProperties
28
+ return (
29
+ <div className="prenta-sections" style={style}>
30
+ {children}
31
+ </div>
32
+ )
33
+ }
@@ -36,7 +36,7 @@ export function AuthorBioSection({
36
36
  className="h-14 w-14 shrink-0 rounded-full object-cover"
37
37
  />
38
38
  ) : (
39
- <span className="bg-brand/10 text-brand flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-sm font-medium">
39
+ <span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-[var(--section-accent-soft)] text-sm font-medium text-[var(--section-accent)]">
40
40
  {initials || '?'}
41
41
  </span>
42
42
  ))}
@@ -49,7 +49,7 @@ export function AuthorBioSection({
49
49
  <p className="text-foreground mt-0.5 text-base font-medium">{name}</p>
50
50
  <p className="text-muted-foreground mt-1 text-sm leading-relaxed">{bio}</p>
51
51
  {showSocial && (
52
- <div className="text-brand mt-2 flex gap-3 text-sm">
52
+ <div className="mt-2 flex gap-3 text-sm text-[var(--section-accent)]">
53
53
  <span>Twitter</span>
54
54
  <span>LinkedIn</span>
55
55
  </div>
@@ -26,7 +26,7 @@ export function ByTheNumbersSection({ section }: { section: PageSection }) {
26
26
  <dl className="@pv-sm:grid-cols-2 @pv-lg:grid-cols-3 mt-10 grid grid-cols-1 gap-8">
27
27
  {stats.map((s, i) => (
28
28
  <div key={i}>
29
- <dt className="text-brand text-4xl font-semibold tracking-tight">
29
+ <dt className="text-4xl font-semibold tracking-tight text-[var(--section-accent)]">
30
30
  {String(s.value ?? '')}
31
31
  </dt>
32
32
  <dd className="text-foreground mt-2 text-sm font-medium">{String(s.label ?? '')}</dd>
@@ -14,7 +14,7 @@ export function CtaBannerSection({ section }: { section: PageSection }) {
14
14
  </h2>
15
15
  {body && (
16
16
  <p
17
- className={`mx-auto mt-3 max-w-xl text-base ${dark ? 'text-brand-foreground/80' : 'text-muted-foreground'}`}
17
+ className={`mx-auto mt-3 max-w-xl text-base ${dark ? 'text-white/80' : 'text-[var(--section-muted)]'}`}
18
18
  >
19
19
  {body}
20
20
  </p>
@@ -14,7 +14,7 @@ export function FeatureHighlightSection({ section }: { section: PageSection }) {
14
14
  const text = (
15
15
  <div className="flex-1">
16
16
  {label && (
17
- <span className="bg-brand mb-3 inline-block rounded px-2 py-0.5 text-xs font-medium text-white">
17
+ <span className="mb-3 inline-block rounded bg-[var(--section-accent)] px-2 py-0.5 text-xs font-medium text-[var(--section-accent-foreground)]">
18
18
  {label}
19
19
  </span>
20
20
  )}
@@ -38,8 +38,8 @@ export function FeatureHighlightSection({ section }: { section: PageSection }) {
38
38
  className="aspect-[4/3] w-full rounded-xl object-cover"
39
39
  />
40
40
  ) : (
41
- <div className="bg-brand/10 flex aspect-[4/3] w-full items-center justify-center rounded-xl">
42
- <ImageIcon className="text-brand h-10 w-10" aria-hidden />
41
+ <div className="flex aspect-[4/3] w-full items-center justify-center rounded-xl bg-[var(--section-accent-soft)]">
42
+ <ImageIcon className="h-10 w-10 text-[var(--section-accent)]" aria-hidden />
43
43
  </div>
44
44
  )}
45
45
  </div>
@@ -12,7 +12,7 @@ export function HeroBannerSection({ section }: { section: PageSection }) {
12
12
  <div className="mx-auto max-w-3xl px-6 text-center">
13
13
  {eyebrow && (
14
14
  <p
15
- className={`mb-3 text-sm font-medium tracking-wide uppercase ${dark ? 'text-brand-foreground/80' : 'text-brand'}`}
15
+ className={`mb-3 text-sm font-medium tracking-wide uppercase ${dark ? 'text-white/80' : 'text-[var(--section-accent)]'}`}
16
16
  >
17
17
  {eyebrow}
18
18
  </p>
@@ -22,7 +22,7 @@ export function HeroBannerSection({ section }: { section: PageSection }) {
22
22
  </h1>
23
23
  {body && (
24
24
  <p
25
- className={`@pv-md:text-lg mx-auto mt-5 max-w-2xl text-base ${dark ? 'text-brand-foreground/80' : 'text-muted-foreground'}`}
25
+ className={`@pv-md:text-lg mx-auto mt-5 max-w-2xl text-base ${dark ? 'text-white/80' : 'text-[var(--section-muted)]'}`}
26
26
  >
27
27
  {body}
28
28
  </p>
@@ -71,14 +71,10 @@ export function isDarkBackground(settings: SectionSettings): boolean {
71
71
  *
72
72
  * They were, and it made the canvas lie. `bg-card` and `bg-brand` follow the
73
73
  * admin's light/dark theme, so an editor working in dark mode saw a hero in
74
- * `#9D71F0` and body sections on a dark surface — while the live page renders
75
- * `violet-600` on white, whatever theme the editor happens to prefer. The
76
- * canvas exists to show the page as visitors get it; tracking the chrome's
77
- * theme is the one thing it must not do.
78
- *
79
- * In light mode this changes almost nothing: `--brand` resolves to `#7C3AED`,
80
- * which is `violet-600`. The whole difference was in dark mode, which is
81
- * exactly where it was least likely to be noticed.
74
+ * `#9D71F0` and body sections on a dark surface — while the live page paints
75
+ * from the site theme variables, defaulting to violet on white. The canvas
76
+ * exists to show the page as visitors get it; tracking the chrome's theme is
77
+ * the one thing it must not do.
82
78
  *
83
79
  * ── Why the strings are duplicated from sections-react ────────────────────
84
80
  *
@@ -91,10 +87,11 @@ export function isDarkBackground(settings: SectionSettings): boolean {
91
87
  * `section-class-parity.test.ts` asserts the strings still match the site's.
92
88
  */
93
89
  const PRESET_CLASSES: Record<BackgroundPreset, string> = {
94
- 'gradient-purple': 'bg-gradient-to-br from-violet-600 to-violet-700 text-white',
95
- 'solid-purple': 'bg-violet-600 text-white',
96
- 'solid-muted': 'bg-zinc-50 text-zinc-900',
97
- plain: 'bg-white text-zinc-900',
90
+ 'gradient-purple':
91
+ 'bg-gradient-to-br from-[var(--section-accent)] to-[var(--section-accent-hover)] text-[var(--section-accent-foreground)]',
92
+ 'solid-purple': 'bg-[var(--section-accent)] text-[var(--section-accent-foreground)]',
93
+ 'solid-muted': 'bg-zinc-50 text-[var(--section-fg)]',
94
+ plain: 'bg-[var(--section-bg)] text-[var(--section-fg)]',
98
95
  }
99
96
 
100
97
  export function backgroundClass(settings: SectionSettings): string {
@@ -122,10 +119,10 @@ export function PreviewButton({
122
119
  '@pv-sm:w-auto inline-flex w-full items-center justify-center rounded-lg px-5 py-2.5 text-sm font-medium transition-colors'
123
120
  const toneClass =
124
121
  tone === 'onDark'
125
- ? 'bg-card text-brand hover:bg-brand/10'
122
+ ? 'bg-[var(--section-bg)] text-[var(--section-accent)] hover:bg-[var(--section-accent-soft)]'
126
123
  : tone === 'ghost'
127
- ? 'border border-border text-foreground hover:bg-muted'
128
- : 'bg-brand text-white hover:bg-brand'
124
+ ? 'border border-zinc-300 text-zinc-700 hover:bg-zinc-50'
125
+ : 'bg-[var(--section-accent)] text-[var(--section-accent-foreground)] hover:bg-[var(--section-accent-hover)]'
129
126
  // Editor preview: links are inert (preventDefault) so clicking content
130
127
  // selects the section instead of navigating away.
131
128
  return (
@@ -5,6 +5,7 @@ import { CanvasEditTip } from '../../components/CanvasEditTip.js'
5
5
  import type { Zoom } from '../../lib/breakpoints.js'
6
6
  import type { PageSection, PostHeaderConfig } from '../../lib/post-editor-service.js'
7
7
  import { CanvasScroll, CanvasSurface, useSectionRefs } from '../page-editor/CanvasSurface.js'
8
+ import { SectionThemeRoot } from '../page-editor/SectionThemeRoot.js'
8
9
  import { SectionRenderer, type PostRenderContext } from '../page-editor/sections/index.js'
9
10
  import { StructureNotice } from '../page-editor/StructureNotice.js'
10
11
  import { type ViewportId } from '../page-editor/viewports.js'
@@ -88,24 +89,26 @@ export function PostEditorCanvas({
88
89
  )
89
90
  }
90
91
  >
91
- <PostHeader config={header} context={context} />
92
- {sections.length === 0 ? (
93
- <EmptyBody onAddSection={reviewing ? undefined : onAddSection} />
94
- ) : (
95
- sections.map((section) => (
96
- <SectionRenderer
97
- key={section.id}
98
- section={section}
99
- context={context}
100
- editable
101
- reviewing={reviewing}
102
- changed={changedSectionIds?.has(section.id) ?? false}
103
- selected={section.id === selectedId}
104
- onSelect={onSelect}
105
- registerRef={registerRef}
106
- />
107
- ))
108
- )}
92
+ <SectionThemeRoot>
93
+ <PostHeader config={header} context={context} />
94
+ {sections.length === 0 ? (
95
+ <EmptyBody onAddSection={reviewing ? undefined : onAddSection} />
96
+ ) : (
97
+ sections.map((section) => (
98
+ <SectionRenderer
99
+ key={section.id}
100
+ section={section}
101
+ context={context}
102
+ editable
103
+ reviewing={reviewing}
104
+ changed={changedSectionIds?.has(section.id) ?? false}
105
+ selected={section.id === selectedId}
106
+ onSelect={onSelect}
107
+ registerRef={registerRef}
108
+ />
109
+ ))
110
+ )}
111
+ </SectionThemeRoot>
109
112
  </CanvasSurface>
110
113
  </CanvasScroll>
111
114
  )
@@ -3,6 +3,7 @@
3
3
  import { AlertTriangle, Loader2 } from 'lucide-react'
4
4
 
5
5
  import { AdminThemeCard } from './AdminThemeCard.js'
6
+ import { SiteThemeCard } from './SiteThemeCard.js'
6
7
  import { BrandPreviewCard } from './BrandPreviewCard.js'
7
8
  import { BrandingCard } from './BrandingCard.js'
8
9
  import { SettingsSaveBar, SettingsValidationSummary } from './components.js'
@@ -65,6 +66,7 @@ export function AppearanceTab({ role, config }: AppearanceTabProps) {
65
66
 
66
67
  <BrandingCard s={s} canEdit={canEdit} />
67
68
  <AdminThemeCard s={s} canEdit={canEdit} />
69
+ <SiteThemeCard s={s} canEdit={canEdit} />
68
70
  <TypographyMotionCard s={s} canEdit={canEdit} />
69
71
  <BrandPreviewCard s={s} />
70
72