@prenta/admin 1.9.1 → 1.10.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 (110) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/AdminRoot.d.ts +10 -0
  3. package/dist/AdminRoot.d.ts.map +1 -1
  4. package/dist/AdminRoot.js +2 -1
  5. package/dist/AdminRoot.js.map +1 -1
  6. package/dist/__tests__/assets/prenta-logo.test.d.ts +2 -0
  7. package/dist/__tests__/assets/prenta-logo.test.d.ts.map +1 -0
  8. package/dist/__tests__/assets/prenta-logo.test.js +26 -0
  9. package/dist/__tests__/assets/prenta-logo.test.js.map +1 -0
  10. package/dist/__tests__/layout/sidebar-brand.render.test.js +13 -0
  11. package/dist/__tests__/layout/sidebar-brand.render.test.js.map +1 -1
  12. package/dist/__tests__/lib/theme-chrome.test.d.ts +2 -0
  13. package/dist/__tests__/lib/theme-chrome.test.d.ts.map +1 -0
  14. package/dist/__tests__/lib/theme-chrome.test.js +104 -0
  15. package/dist/__tests__/lib/theme-chrome.test.js.map +1 -0
  16. package/dist/__tests__/views/editor-top-bar.render.test.js +11 -0
  17. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  18. package/dist/__tests__/views/login-branding.render.test.js +2 -1
  19. package/dist/__tests__/views/login-branding.render.test.js.map +1 -1
  20. package/dist/__tests__/views/theme-chrome-frame.render.test.d.ts +2 -0
  21. package/dist/__tests__/views/theme-chrome-frame.render.test.d.ts.map +1 -0
  22. package/dist/__tests__/views/theme-chrome-frame.render.test.js +236 -0
  23. package/dist/__tests__/views/theme-chrome-frame.render.test.js.map +1 -0
  24. package/dist/assets/prenta-logo.d.ts +15 -7
  25. package/dist/assets/prenta-logo.d.ts.map +1 -1
  26. package/dist/assets/prenta-logo.js +12 -4
  27. package/dist/assets/prenta-logo.js.map +1 -1
  28. package/dist/index.d.ts +3 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/layout/Sidebar.d.ts.map +1 -1
  33. package/dist/layout/Sidebar.js +2 -12
  34. package/dist/layout/Sidebar.js.map +1 -1
  35. package/dist/lib/theme-chrome.d.ts +87 -0
  36. package/dist/lib/theme-chrome.d.ts.map +1 -0
  37. package/dist/lib/theme-chrome.js +117 -0
  38. package/dist/lib/theme-chrome.js.map +1 -0
  39. package/dist/prenta-admin.css +1 -1
  40. package/dist/views/Login.d.ts +1 -1
  41. package/dist/views/Login.d.ts.map +1 -1
  42. package/dist/views/Login.js +4 -3
  43. package/dist/views/Login.js.map +1 -1
  44. package/dist/views/page-editor/CanvasSurface.d.ts +14 -2
  45. package/dist/views/page-editor/CanvasSurface.d.ts.map +1 -1
  46. package/dist/views/page-editor/CanvasSurface.js +30 -14
  47. package/dist/views/page-editor/CanvasSurface.js.map +1 -1
  48. package/dist/views/page-editor/EditorCanvas.d.ts +12 -1
  49. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  50. package/dist/views/page-editor/EditorCanvas.js +22 -5
  51. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  52. package/dist/views/page-editor/EditorTopBar.d.ts +5 -1
  53. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  54. package/dist/views/page-editor/EditorTopBar.js +3 -2
  55. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  56. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  57. package/dist/views/page-editor/PageSectionEditor.js +30 -2
  58. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  59. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.d.ts +21 -0
  60. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.d.ts.map +1 -0
  61. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.js +34 -0
  62. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.js.map +1 -0
  63. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.d.ts +12 -0
  64. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.d.ts.map +1 -0
  65. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.js +12 -0
  66. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.js.map +1 -0
  67. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.d.ts +11 -0
  68. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.d.ts.map +1 -0
  69. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.js +15 -0
  70. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.js.map +1 -0
  71. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.d.ts +18 -0
  72. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.d.ts.map +1 -0
  73. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.js +23 -0
  74. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.js.map +1 -0
  75. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.d.ts +11 -0
  76. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.d.ts.map +1 -0
  77. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.js +82 -0
  78. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.js.map +1 -0
  79. package/dist/views/page-editor/theme-chrome/index.d.ts +9 -0
  80. package/dist/views/page-editor/theme-chrome/index.d.ts.map +1 -0
  81. package/dist/views/page-editor/theme-chrome/index.js +6 -0
  82. package/dist/views/page-editor/theme-chrome/index.js.map +1 -0
  83. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.d.ts +3 -0
  84. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.d.ts.map +1 -0
  85. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.js +17 -0
  86. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.js.map +1 -0
  87. package/package.json +3 -3
  88. package/src/AdminRoot.tsx +14 -1
  89. package/src/__tests__/assets/prenta-logo.test.tsx +26 -0
  90. package/src/__tests__/layout/sidebar-brand.render.test.tsx +15 -0
  91. package/src/__tests__/lib/theme-chrome.test.ts +132 -0
  92. package/src/__tests__/views/editor-top-bar.render.test.tsx +13 -0
  93. package/src/__tests__/views/login-branding.render.test.tsx +2 -1
  94. package/src/__tests__/views/theme-chrome-frame.render.test.tsx +304 -0
  95. package/src/assets/prenta-logo.tsx +47 -21
  96. package/src/index.ts +17 -0
  97. package/src/layout/Sidebar.tsx +2 -27
  98. package/src/lib/theme-chrome.ts +172 -0
  99. package/src/views/Login.tsx +5 -4
  100. package/src/views/page-editor/CanvasSurface.tsx +70 -24
  101. package/src/views/page-editor/EditorCanvas.tsx +89 -15
  102. package/src/views/page-editor/EditorTopBar.tsx +10 -0
  103. package/src/views/page-editor/PageSectionEditor.tsx +42 -0
  104. package/src/views/page-editor/theme-chrome/ThemeChromeFrame.tsx +119 -0
  105. package/src/views/page-editor/theme-chrome/ThemeChromeHealth.tsx +66 -0
  106. package/src/views/page-editor/theme-chrome/ThemeChromeModeToggle.tsx +63 -0
  107. package/src/views/page-editor/theme-chrome/ThemeChromeProvider.tsx +43 -0
  108. package/src/views/page-editor/theme-chrome/ThemeChromeSlot.tsx +235 -0
  109. package/src/views/page-editor/theme-chrome/index.ts +12 -0
  110. package/src/views/page-editor/theme-chrome/useThemeChromeMode.ts +24 -0
@@ -34,34 +34,9 @@ import {
34
34
  Trash2,
35
35
  } from 'lucide-react'
36
36
  import type { LucideIcon } from 'lucide-react'
37
- import { PrentaBrandLogo } from '../assets/prenta-logo.js'
37
+ import { PrentaBrandLogo, PrentaMark } from '../assets/prenta-logo.js'
38
38
  import { LogoPlaceholder } from '../components/LogoPlaceholder.js'
39
39
 
40
- /**
41
- * Compact mark used in the collapsed sidebar — just the "C" symbol from the
42
- * full lockup, drawn as its own simple SVG so it stays crisp at 32×32.
43
- */
44
- function PrentaMark({ className }: { className?: string }) {
45
- return (
46
- <svg
47
- viewBox="0 0 40 44"
48
- fill="none"
49
- xmlns="http://www.w3.org/2000/svg"
50
- className={className}
51
- aria-hidden="true"
52
- >
53
- <polygon points="20,2 6,18 12,18 20,8 28,18 34,18" fill="#F05E65" />
54
- <rect x="11" y="20" width="4" height="22" rx="1" fill="#F05E65" />
55
- <rect x="18" y="20" width="4" height="22" rx="1" fill="#F05E65" />
56
- <rect x="25" y="20" width="4" height="22" rx="1" fill="#F05E65" />
57
- </svg>
58
- )
59
- }
60
-
61
- function PrentaWordmark({ className }: { className?: string }) {
62
- return <PrentaBrandLogo className={className} />
63
- }
64
-
65
40
  const ICON_MAP: Record<string, LucideIcon> = {
66
41
  file: File,
67
42
  'file-text': FileText,
@@ -180,7 +155,7 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
180
155
  )
181
156
  }
182
157
 
183
- return <PrentaWordmark className="h-9" />
158
+ return <PrentaBrandLogo className="h-8 w-auto" />
184
159
  }
185
160
 
186
161
  /**
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Theme chrome — site header/footer in the page editor canvas.
3
+ *
4
+ * Pure helpers and the public contract. React lives in
5
+ * `views/page-editor/theme-chrome/`. Do not put renderers on
6
+ * `prenta.config.ts` — this file is imported from the admin bundle only.
7
+ */
8
+
9
+ import type { ReactNode } from 'react'
10
+ import type { BreakpointId } from './breakpoints.js'
11
+
12
+ export type ThemeChromeMode = 'flow' | 'frame'
13
+ export type ThemeChromeSurface = 'pages' | 'posts'
14
+ export type ThemeChromeEditTarget = 'navigations' | 'appearance' | 'settings'
15
+ export type ThemeChromeSlotStatus = 'ok' | 'preview' | 'fallback' | 'placeholder'
16
+
17
+ export interface ThemeChromeDocument {
18
+ id: string | null
19
+ title: string
20
+ path: string
21
+ collection: string
22
+ }
23
+
24
+ export interface AdminThemeChromeProps {
25
+ region: 'header' | 'footer'
26
+ breakpoint: BreakpointId
27
+ mode: ThemeChromeMode
28
+ preview: true
29
+ locale: string
30
+ dir: 'ltr' | 'rtl'
31
+ document: ThemeChromeDocument
32
+ onEditChrome: (target: ThemeChromeEditTarget) => void
33
+ }
34
+
35
+ export type AdminThemeChromeRenderer = (props: AdminThemeChromeProps) => ReactNode
36
+
37
+ export interface AdminThemeChrome {
38
+ header?: AdminThemeChromeRenderer
39
+ footer?: AdminThemeChromeRenderer
40
+ /** Shown when header/footer throw or are omitted but chrome is enabled. */
41
+ fallback?: AdminThemeChromeRenderer
42
+ /**
43
+ * Same-origin URL for hosts whose real chrome is server-only.
44
+ * Rendered in a sandboxed iframe inside the chrome slot; never the whole page.
45
+ */
46
+ previewSrc?: { header?: string; footer?: string }
47
+ /** Surfaces that mount chrome. Default: pages only. */
48
+ surfaces?: ThemeChromeSurface[]
49
+ /** Default canvas mode. Default: flow (real document scroll). */
50
+ defaultMode?: ThemeChromeMode
51
+ /**
52
+ * Optional health-strip extras. Hosts that already know nav/brand state
53
+ * can pass them; the canvas will not fetch on their behalf.
54
+ */
55
+ diagnostics?: { navLinkCount?: number; brandPresent?: boolean }
56
+ }
57
+
58
+ export const THEME_CHROME_MODE_KEY = 'prenta-theme-chrome-mode'
59
+ export const THEME_CHROME_MAIN_ID = 'prenta-canvas-main'
60
+
61
+ export function isThemeChromeEnabled(
62
+ spec: AdminThemeChrome | null | undefined,
63
+ killSwitch: boolean | { pages?: boolean; posts?: boolean } | undefined,
64
+ surface: ThemeChromeSurface,
65
+ ): boolean {
66
+ if (!spec) return false
67
+ const surfaces = spec.surfaces ?? ['pages']
68
+ if (!surfaces.includes(surface)) return false
69
+ if (killSwitch === false) return false
70
+ if (killSwitch && typeof killSwitch === 'object' && killSwitch[surface] === false) {
71
+ return false
72
+ }
73
+ return true
74
+ }
75
+
76
+ export function readStoredChromeMode(): ThemeChromeMode | null {
77
+ if (typeof window === 'undefined') return null
78
+ try {
79
+ const value = window.localStorage.getItem(THEME_CHROME_MODE_KEY)
80
+ return value === 'flow' || value === 'frame' ? value : null
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ export function writeStoredChromeMode(mode: ThemeChromeMode): void {
87
+ if (typeof window === 'undefined') return
88
+ try {
89
+ window.localStorage.setItem(THEME_CHROME_MODE_KEY, mode)
90
+ } catch {
91
+ // Private mode / quota — mode still works for this session.
92
+ }
93
+ }
94
+
95
+ export function resolveThemeChromeLocale(opts: {
96
+ documentLocale?: string | null
97
+ activeLocale?: string | null
98
+ defaultLocale?: string | null
99
+ }): string {
100
+ const picked = opts.documentLocale || opts.activeLocale || opts.defaultLocale || 'en'
101
+ return picked.trim() || 'en'
102
+ }
103
+
104
+ export function resolveThemeChromeDir(
105
+ locale: string,
106
+ locales: Array<{ code: string; dir?: 'ltr' | 'rtl' }>,
107
+ ): 'ltr' | 'rtl' {
108
+ return locales.find((entry) => entry.code === locale)?.dir === 'rtl' ? 'rtl' : 'ltr'
109
+ }
110
+
111
+ /**
112
+ * Public path for chrome context. Serving-path first; never an admin route.
113
+ */
114
+ export function resolveThemeChromePath(path: string | null | undefined, slug?: string): string {
115
+ const trimmed = (path ?? '').trim()
116
+ if (trimmed) return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
117
+ const s = (slug ?? '').trim().replace(/^\/+/, '')
118
+ if (!s || s === 'home' || s === 'index') return '/'
119
+ return `/${s}`
120
+ }
121
+
122
+ export function themeChromeEditPath(target: ThemeChromeEditTarget): string {
123
+ switch (target) {
124
+ case 'navigations':
125
+ return '/navigations'
126
+ case 'appearance':
127
+ return '/settings?tab=appearance'
128
+ case 'settings':
129
+ return '/settings?tab=general'
130
+ default: {
131
+ const _exhaustive: never = target
132
+ return _exhaustive
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Same-origin chrome-only preview URLs. Relative paths starting with `/`
139
+ * (not `//`) are allowed. Absolute URLs must match `origin`. Protocol-relative,
140
+ * data:, javascript:, and off-origin URLs are rejected.
141
+ */
142
+ export function isSameOriginPreviewSrc(src: string, origin?: string): boolean {
143
+ const trimmed = src.trim()
144
+ if (!trimmed) return false
145
+ if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
146
+ return !trimmed.toLowerCase().startsWith('/\\') && !/^[a-z][a-z0-9+.-]*:/i.test(trimmed)
147
+ }
148
+ let parsed: URL
149
+ try {
150
+ parsed = new URL(trimmed)
151
+ } catch {
152
+ return false
153
+ }
154
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
155
+ const expected = origin ?? (typeof window !== 'undefined' ? window.location.origin : '')
156
+ if (!expected) return false
157
+ try {
158
+ return parsed.origin === new URL(expected).origin
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
164
+ export function chromeContainsH1(root: ParentNode | null): boolean {
165
+ return Boolean(root?.querySelector('h1'))
166
+ }
167
+
168
+ export function closestAnchor(target: EventTarget | null): HTMLAnchorElement | null {
169
+ if (!(target instanceof Element)) return null
170
+ const found = target.closest('a[href]')
171
+ return found instanceof HTMLAnchorElement ? found : null
172
+ }
@@ -53,7 +53,7 @@ export interface LoginProps {
53
53
  captchaConfig?: CaptchaConfig
54
54
  /**
55
55
  * Custom branding for the login screen. Falls back to the bundled Prenta
56
- * Media wordmark + "Prenta CMS" headline when omitted.
56
+ * wordmark when omitted.
57
57
  */
58
58
  branding?: LoginBrandingConfig
59
59
  }
@@ -377,9 +377,10 @@ export function Login({
377
377
  const hasInitials = (value: string | null | undefined) => Boolean(value) && value !== 'A'
378
378
  const isBranded =
379
379
  Boolean(branding?.name) || hasInitials(adminBrand.initials) || hasInitials(publicInitials)
380
- // When a logo image is shown it already carries the brand mark — a separate
381
- // headline duplicates it (see Settings Appearance uploads).
382
- const showBrandName = !effectiveLogo
380
+ // A logo image or the bundled wordmark already carries the brand — a
381
+ // separate headline would duplicate it. Keep the name only for the
382
+ // branded-but-logo-less placeholder.
383
+ const showBrandName = !effectiveLogo && isBranded
383
384
 
384
385
  return (
385
386
  <div className="bg-muted flex min-h-screen items-center justify-center px-4">
@@ -21,6 +21,17 @@ export interface CanvasSurfaceProps {
21
21
  onCanvasElement?: (el: HTMLElement | null) => void
22
22
  /** Tips, notices, or the review bar — rendered above the meta line. */
23
23
  chrome?: React.ReactNode
24
+ /** Theme-chrome health strip — locale, slot status, outline warning. */
25
+ health?: React.ReactNode
26
+ /** `lang` on `.prenta-canvas` when theme chrome is mounted. */
27
+ lang?: string
28
+ /** `dir` on `.prenta-canvas` when theme chrome is mounted. */
29
+ dir?: 'ltr' | 'rtl'
30
+ /**
31
+ * `frame` fills the remaining pane and expects children to scroll internally.
32
+ * `flow` (default) sizes the canvas from content height, as today.
33
+ */
34
+ fillMode?: 'flow' | 'frame'
24
35
  /** The page itself. Rendered inside the scaled, container-queried surface. */
25
36
  children: React.ReactNode
26
37
  }
@@ -57,14 +68,22 @@ export function CanvasSurface({
57
68
  onScaleChange,
58
69
  onCanvasElement,
59
70
  chrome,
71
+ health,
72
+ lang,
73
+ dir,
74
+ fillMode = 'flow',
60
75
  children,
61
76
  }: CanvasSurfaceProps) {
62
77
  const bp = getBreakpoint(breakpoint)
63
78
  const sentinelRef = useRef<HTMLDivElement>(null)
64
79
  const surfaceRef = useRef<HTMLDivElement>(null)
80
+ const areaRef = useRef<HTMLDivElement>(null)
65
81
 
66
82
  const [available, setAvailable] = useState(0)
67
83
  const [pageHeight, setPageHeight] = useState(0)
84
+ const [areaHeight, setAreaHeight] = useState(0)
85
+
86
+ const isFrame = fillMode === 'frame'
68
87
 
69
88
  // No dependency array: page height changes on edits that resize nothing the
70
89
  // observer watches. Both setters bail when the value is unchanged, so this
@@ -76,6 +95,9 @@ export function CanvasSurface({
76
95
 
77
96
  const height = surfaceRef.current?.offsetHeight ?? 0
78
97
  if (height > 0) setPageHeight((prev) => (prev === height ? prev : height))
98
+
99
+ const area = areaRef.current?.clientHeight ?? 0
100
+ if (area > 0) setAreaHeight((prev) => (prev === area ? prev : area))
79
101
  }
80
102
 
81
103
  measure()
@@ -83,10 +105,12 @@ export function CanvasSurface({
83
105
  const ro = new ResizeObserver(measure)
84
106
  if (sentinelRef.current) ro.observe(sentinelRef.current)
85
107
  if (surfaceRef.current) ro.observe(surfaceRef.current)
108
+ if (areaRef.current) ro.observe(areaRef.current)
86
109
  return () => ro.disconnect()
87
110
  })
88
111
 
89
112
  const scale = fitScale(available, bp.width, zoom)
113
+ const frameUnscaledHeight = isFrame && scale > 0 && areaHeight > 0 ? areaHeight / scale : 0
90
114
 
91
115
  useEffect(() => {
92
116
  onScaleChange?.(scale)
@@ -104,6 +128,7 @@ export function CanvasSurface({
104
128
 
105
129
  {chrome}
106
130
  <CanvasMetaLine breakpoint={bp} pageHeight={pageHeight} scale={scale} />
131
+ {health}
107
132
 
108
133
  {/*
109
134
  * Fit box: reserves the scaled footprint so the surrounding scroll area
@@ -112,32 +137,41 @@ export function CanvasSurface({
112
137
  * that is not visible never finishes one) reports a bogus height and
113
138
  * corrupts the next measurement.
114
139
  */}
115
- <div
116
- className="prenta-canvas-fit mx-auto block"
117
- style={{
118
- width: Math.round(bp.width * scale),
119
- height: pageHeight ? Math.round(pageHeight * scale) : undefined,
120
- }}
121
- >
122
- {/* `prenta-canvas` is the public hook for consumer brand CSS: site
123
- styles that should render in this preview scope to
124
- `.prenta-admin .prenta-canvas` instead of the admin root. It also
125
- carries `container-type: inline-size` (theme.css) — that is what
126
- makes the `@pv-*` steps in the section renderers resolve against
127
- the simulated width instead of the viewport. */}
140
+ <div ref={areaRef} className={isFrame ? 'relative min-h-0 flex-1' : undefined}>
128
141
  <div
129
- ref={(el) => {
130
- surfaceRef.current = el
131
- onCanvasElement?.(el)
132
- }}
133
- className="prenta-canvas border-border bg-card origin-top-left overflow-hidden rounded-lg text-left shadow-[0_2px_20px_rgba(0,0,0,0.12)] motion-safe:transition-[width,transform] motion-safe:duration-(--mo-slow) motion-safe:ease-(--ease)"
142
+ className="prenta-canvas-fit mx-auto block"
134
143
  style={{
135
- width: bp.width,
136
- transform: `scale(${scale})`,
137
- transformOrigin: 'top left',
144
+ width: Math.round(bp.width * scale),
145
+ height: isFrame
146
+ ? areaHeight || undefined
147
+ : pageHeight
148
+ ? Math.round(pageHeight * scale)
149
+ : undefined,
138
150
  }}
139
151
  >
140
- {children}
152
+ {/* `prenta-canvas` is the public hook for consumer brand CSS: site
153
+ styles that should render in this preview scope to
154
+ `.prenta-admin .prenta-canvas` instead of the admin root. It also
155
+ carries `container-type: inline-size` (theme.css) — that is what
156
+ makes the `@pv-*` steps in the section renderers resolve against
157
+ the simulated width instead of the viewport. */}
158
+ <div
159
+ ref={(el) => {
160
+ surfaceRef.current = el
161
+ onCanvasElement?.(el)
162
+ }}
163
+ lang={lang}
164
+ dir={dir}
165
+ className="prenta-canvas border-border bg-card origin-top-left overflow-hidden rounded-lg text-left shadow-[0_2px_20px_rgba(0,0,0,0.12)] motion-safe:transition-[width,transform] motion-safe:duration-(--mo-slow) motion-safe:ease-(--ease)"
166
+ style={{
167
+ width: bp.width,
168
+ height: isFrame && frameUnscaledHeight ? frameUnscaledHeight : undefined,
169
+ transform: `scale(${scale})`,
170
+ transformOrigin: 'top left',
171
+ }}
172
+ >
173
+ {children}
174
+ </div>
141
175
  </div>
142
176
  </div>
143
177
  </>
@@ -145,9 +179,21 @@ export function CanvasSurface({
145
179
  }
146
180
 
147
181
  /** The scroll container both editors wrap their surface in. */
148
- export function CanvasScroll({ children }: { children: React.ReactNode }) {
182
+ export function CanvasScroll({
183
+ children,
184
+ layout = 'flow',
185
+ }: {
186
+ children: React.ReactNode
187
+ layout?: 'flow' | 'frame'
188
+ }) {
149
189
  return (
150
- <div className="prenta-canvas-scroll bg-muted h-full overflow-auto px-6 pt-4.5 pb-10 text-center">
190
+ <div
191
+ className={
192
+ layout === 'frame'
193
+ ? 'prenta-canvas-scroll bg-muted flex h-full min-h-0 flex-col overflow-hidden px-6 pt-4.5 pb-10 text-center'
194
+ : 'prenta-canvas-scroll bg-muted h-full overflow-auto px-6 pt-4.5 pb-10 text-center'
195
+ }
196
+ >
151
197
  {children}
152
198
  </div>
153
199
  )
@@ -1,12 +1,22 @@
1
1
  'use client'
2
2
 
3
+ import { useCallback, useMemo, useState } from 'react'
3
4
  import { LayoutTemplate } from 'lucide-react'
4
5
  import { CanvasEditTip } from '../../components/CanvasEditTip.js'
5
6
  import { CanvasScroll, CanvasSurface, useSectionRefs } from './CanvasSurface.js'
6
7
  import type { BreakpointId, Zoom } from '../../lib/breakpoints.js'
7
8
  import type { PageSection } from '../../lib/page-editor-service.js'
9
+ import {
10
+ themeChromeEditPath,
11
+ type AdminThemeChrome,
12
+ type ThemeChromeDocument,
13
+ type ThemeChromeEditTarget,
14
+ type ThemeChromeMode,
15
+ } from '../../lib/theme-chrome.js'
8
16
  import { SectionRenderer } from './sections/index.js'
9
17
  import { StructureNotice } from './StructureNotice.js'
18
+ import { ThemeChromeFrame, ThemeChromeHealth } from './theme-chrome/index.js'
19
+ import type { ThemeChromeHealthState } from './theme-chrome/ThemeChromeFrame.js'
10
20
 
11
21
  interface EditorCanvasProps {
12
22
  sections: PageSection[]
@@ -31,6 +41,16 @@ interface EditorCanvasProps {
31
41
  * to the editor's history state, which the canvas knows nothing about.
32
42
  */
33
43
  reviewBar?: React.ReactNode
44
+ /** When set and enabled, wraps bands in site header/footer. */
45
+ themeChrome?: {
46
+ spec: AdminThemeChrome
47
+ enabled: boolean
48
+ mode: ThemeChromeMode
49
+ locale: string
50
+ dir: 'ltr' | 'rtl'
51
+ document: ThemeChromeDocument
52
+ onNavigate: (path: string) => void
53
+ }
34
54
  }
35
55
 
36
56
  /**
@@ -52,13 +72,44 @@ export function EditorCanvas({
52
72
  reviewing = false,
53
73
  changedSectionIds,
54
74
  reviewBar,
75
+ themeChrome,
55
76
  }: EditorCanvasProps) {
56
77
  const registerRef = useSectionRefs(selectedId)
78
+ const [health, setHealth] = useState<ThemeChromeHealthState | null>(null)
79
+
80
+ const chromeOn = Boolean(themeChrome?.enabled)
81
+ const mode = themeChrome?.mode ?? 'flow'
82
+
83
+ const onEditChrome = useCallback(
84
+ (target: ThemeChromeEditTarget) => {
85
+ themeChrome?.onNavigate(themeChromeEditPath(target))
86
+ },
87
+ [themeChrome],
88
+ )
89
+
90
+ const documentIdentity = themeChrome?.document
91
+
92
+ const bands = useMemo(() => {
93
+ if (sections.length === 0) return <EmptyCanvas />
94
+ return sections.map((section) => (
95
+ <SectionRenderer
96
+ key={section.id}
97
+ section={section}
98
+ editable
99
+ reviewing={reviewing}
100
+ changed={changedSectionIds?.has(section.id) ?? false}
101
+ selected={section.id === selectedId}
102
+ onSelect={onSelect}
103
+ registerRef={registerRef}
104
+ />
105
+ ))
106
+ }, [sections, reviewing, changedSectionIds, selectedId, onSelect, registerRef])
57
107
 
58
108
  // A design-owned page always ships the template's sections, so an empty
59
109
  // canvas means the template resolved to nothing — a configuration problem,
60
- // not a page to scale and measure.
61
- if (sections.length === 0) {
110
+ // not a page to scale and measure. Theme chrome still mounts so editors
111
+ // can see and edit the site shell.
112
+ if (sections.length === 0 && !chromeOn) {
62
113
  return (
63
114
  <CanvasScroll>
64
115
  <EmptyCanvas />
@@ -66,13 +117,47 @@ export function EditorCanvas({
66
117
  )
67
118
  }
68
119
 
120
+ const inner =
121
+ chromeOn && themeChrome && documentIdentity ? (
122
+ <ThemeChromeFrame
123
+ spec={themeChrome.spec}
124
+ mode={mode}
125
+ breakpoint={breakpoint}
126
+ locale={themeChrome.locale}
127
+ dir={themeChrome.dir}
128
+ document={documentIdentity}
129
+ onEditChrome={onEditChrome}
130
+ onHealthChange={setHealth}
131
+ >
132
+ {bands}
133
+ </ThemeChromeFrame>
134
+ ) : (
135
+ bands
136
+ )
137
+
69
138
  return (
70
- <CanvasScroll>
139
+ <CanvasScroll layout={chromeOn && mode === 'frame' ? 'frame' : 'flow'}>
71
140
  <CanvasSurface
72
141
  breakpoint={breakpoint}
73
142
  zoom={zoom}
74
143
  onScaleChange={onScaleChange}
75
144
  onCanvasElement={onCanvasElement}
145
+ lang={chromeOn ? themeChrome?.locale : undefined}
146
+ dir={chromeOn ? themeChrome?.dir : undefined}
147
+ fillMode={chromeOn && mode === 'frame' ? 'frame' : 'flow'}
148
+ health={
149
+ chromeOn && themeChrome && health ? (
150
+ <ThemeChromeHealth
151
+ locale={themeChrome.locale}
152
+ dir={themeChrome.dir}
153
+ header={health.header}
154
+ footer={health.footer}
155
+ outlineWarning={health.outlineWarning}
156
+ navLinkCount={themeChrome.spec.diagnostics?.navLinkCount}
157
+ brandPresent={themeChrome.spec.diagnostics?.brandPresent}
158
+ />
159
+ ) : undefined
160
+ }
76
161
  chrome={
77
162
  // The review bar REPLACES the editing chrome rather than joining it:
78
163
  // a tip about clicking sections to edit them is actively wrong while
@@ -87,18 +172,7 @@ export function EditorCanvas({
87
172
  )
88
173
  }
89
174
  >
90
- {sections.map((section) => (
91
- <SectionRenderer
92
- key={section.id}
93
- section={section}
94
- editable
95
- reviewing={reviewing}
96
- changed={changedSectionIds?.has(section.id) ?? false}
97
- selected={section.id === selectedId}
98
- onSelect={onSelect}
99
- registerRef={registerRef}
100
- />
101
- ))}
175
+ {inner}
102
176
  </CanvasSurface>
103
177
  </CanvasScroll>
104
178
  )
@@ -30,6 +30,8 @@ import {
30
30
  type WorkflowStage,
31
31
  } from '../../lib/workflow-chrome.js'
32
32
  import { type ViewportId } from './viewports.js'
33
+ import type { ThemeChromeMode } from '../../lib/theme-chrome.js'
34
+ import { ThemeChromeModeToggle } from './theme-chrome/ThemeChromeModeToggle.js'
33
35
 
34
36
  export type SaveState = 'idle' | 'saving' | 'saved' | 'error'
35
37
 
@@ -56,6 +58,9 @@ interface EditorTopBarProps {
56
58
  /** Breadcrumb root label + back-button text (e.g. "Pages" or "Posts"). */
57
59
  backLabel?: string
58
60
  onViewport: (v: ViewportId) => void
61
+ /** Theme-chrome Page / Frame toggle. Hidden when theme chrome is off. */
62
+ chromeMode?: ThemeChromeMode
63
+ onChromeMode?: (mode: ThemeChromeMode) => void
59
64
  onBack: () => void
60
65
  /**
61
66
  * Open the public site in a new tab. Called with `preview-draft` or
@@ -129,6 +134,8 @@ export function EditorTopBar({
129
134
  onZoom,
130
135
  backLabel = 'Pages',
131
136
  onViewport,
137
+ chromeMode,
138
+ onChromeMode,
132
139
  onBack,
133
140
  onViewOnSite,
134
141
  onShare,
@@ -281,6 +288,9 @@ export function EditorTopBar({
281
288
  {zoom !== undefined && onZoom && (
282
289
  <ZoomChip zoom={zoom} scale={canvasScale ?? 1} onChange={onZoom} />
283
290
  )}
291
+ {chromeMode && onChromeMode ? (
292
+ <ThemeChromeModeToggle mode={chromeMode} onChange={onChromeMode} />
293
+ ) : null}
284
294
  </div>
285
295
 
286
296
  <button
@@ -55,6 +55,15 @@ import { hasCanvasRenderer, useAdminSectionRenderers } from './sections/index.js
55
55
  import { EditorTopBar, type SaveState } from './EditorTopBar.js'
56
56
  import { SectionsList } from './SectionsPanel.js'
57
57
  import { EditorCanvas } from './EditorCanvas.js'
58
+ import { useLocale } from '../../components/LocaleProvider.js'
59
+ import {
60
+ isThemeChromeEnabled,
61
+ resolveThemeChromeDir,
62
+ resolveThemeChromeLocale,
63
+ resolveThemeChromePath,
64
+ } from '../../lib/theme-chrome.js'
65
+ import { useThemeChromeSpec } from './theme-chrome/ThemeChromeProvider.js'
66
+ import { useThemeChromeMode } from './theme-chrome/useThemeChromeMode.js'
58
67
  import { ReflowContext } from './reflow-context.js'
59
68
  import { SectionEditForm } from './SectionEditForm.js'
60
69
  import { PageSettingsModal } from './PageSettingsModal.js'
@@ -135,6 +144,19 @@ export function PageSectionEditor({
135
144
  const [pendingFocusPath, setPendingFocusPath] = useState<string | null>(null)
136
145
  const [viewport, setViewport] = useState<ViewportId>(DEFAULT_VIEWPORT)
137
146
  const [zoom, setZoom] = useState<Zoom>('fit')
147
+ const themeChromeSpec = useThemeChromeSpec()
148
+ const themeChromeEnabled = isThemeChromeEnabled(
149
+ themeChromeSpec,
150
+ config?.admin?.editor?.themeChrome,
151
+ 'pages',
152
+ )
153
+ const [chromeMode, setChromeMode] = useThemeChromeMode(themeChromeSpec?.defaultMode ?? 'flow')
154
+ const { activeLocale, locales } = useLocale()
155
+ const chromeLocale = resolveThemeChromeLocale({
156
+ activeLocale,
157
+ defaultLocale: config?.i18n?.defaultLocale,
158
+ })
159
+ const chromeDir = resolveThemeChromeDir(chromeLocale, locales)
138
160
  /**
139
161
  * Live audit reported by the SEO pane, built from unsaved form state. Null
140
162
  * until that pane has mounted — `audit` below falls back to the saved-record
@@ -881,6 +903,8 @@ export function PageSectionEditor({
881
903
  zoom={zoom}
882
904
  canvasScale={canvasScale}
883
905
  onZoom={setZoom}
906
+ chromeMode={themeChromeEnabled ? chromeMode : undefined}
907
+ onChromeMode={themeChromeEnabled ? setChromeMode : undefined}
884
908
  onBack={() => guardedNavigate('/pages')}
885
909
  onViewOnSite={page.id && page.slug ? handleViewOnSite : undefined}
886
910
  onShare={page.id ? handleShare : undefined}
@@ -1050,6 +1074,24 @@ export function PageSectionEditor({
1050
1074
  structuralTypes={structuralTypes}
1051
1075
  reviewing={reviewing}
1052
1076
  changedSectionIds={reviewChangedIds}
1077
+ themeChrome={
1078
+ themeChromeSpec
1079
+ ? {
1080
+ spec: themeChromeSpec,
1081
+ enabled: themeChromeEnabled,
1082
+ mode: chromeMode,
1083
+ locale: chromeLocale,
1084
+ dir: chromeDir,
1085
+ document: {
1086
+ id: page.id,
1087
+ title: page.title,
1088
+ path: resolveThemeChromePath(page.path, page.slug),
1089
+ collection: PAGES_COLLECTION,
1090
+ },
1091
+ onNavigate,
1092
+ }
1093
+ : undefined
1094
+ }
1053
1095
  reviewBar={
1054
1096
  reviewVersion ? (
1055
1097
  <ReviewBar