@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
@@ -0,0 +1,26 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { render } from '@testing-library/react'
4
+ import { PRENTA_ACCENT, PrentaBrandLogo, PrentaMark } from '../../assets/prenta-logo.js'
5
+
6
+ describe('bundled Prenta lockup', () => {
7
+ it('renders the wordmark as Prenta with the accent ellipsis dot', () => {
8
+ const { container } = render(<PrentaBrandLogo />)
9
+ expect(container.querySelector('svg')?.getAttribute('aria-label')).toBe('Prenta')
10
+ expect(container.textContent).toContain('Prenta')
11
+ const dots = container.querySelectorAll('circle')
12
+ expect(dots).toHaveLength(3)
13
+ expect(dots[2]?.getAttribute('fill')).toBe(PRENTA_ACCENT)
14
+ expect(container.innerHTML).not.toContain('#F05E65')
15
+ })
16
+
17
+ it('renders the compact P… mark without the retired Actuate A', () => {
18
+ const { container } = render(<PrentaMark />)
19
+ expect(container.querySelector('svg')?.getAttribute('aria-label')).toBe('Prenta')
20
+ expect(container.querySelector('polygon')).toBeNull()
21
+ expect(container.innerHTML).not.toContain('#F05E65')
22
+ const dots = container.querySelectorAll('circle')
23
+ expect(dots).toHaveLength(3)
24
+ expect(dots[2]?.getAttribute('fill')).toBe(PRENTA_ACCENT)
25
+ })
26
+ })
@@ -64,6 +64,21 @@ afterEach(() => {
64
64
  * the neutral logo placeholder — NOT a filled initials badge, which reads as a
65
65
  * user avatar (the real account avatar already lives top-right).
66
66
  */
67
+ describe('Sidebar bundled Prenta fallback', () => {
68
+ it('shows the Prenta wordmark when the install is unbranded — expanded', () => {
69
+ setTheme({ initials: 'A' })
70
+ renderSidebar(false)
71
+ expect(screen.getByRole('img', { name: 'Prenta' })).toBeTruthy()
72
+ expect(screen.queryByText('Actuate Media')).toBeNull()
73
+ })
74
+
75
+ it('shows the Prenta mark when the install is unbranded — collapsed', () => {
76
+ setTheme({ initials: 'A' })
77
+ renderSidebar(true)
78
+ expect(screen.getByRole('img', { name: 'Prenta' })).toBeTruthy()
79
+ })
80
+ })
81
+
67
82
  describe('Sidebar brand placeholder', () => {
68
83
  it('renders the logo placeholder (not an initials avatar) when branded with no logo — expanded', () => {
69
84
  renderSidebar(false, { admin: { branding: { name: 'Acme Co' } } })
@@ -0,0 +1,132 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { auditPage } from '@prenta/core/seo/page-audit'
4
+ import {
5
+ chromeContainsH1,
6
+ closestAnchor,
7
+ isSameOriginPreviewSrc,
8
+ isThemeChromeEnabled,
9
+ resolveThemeChromeDir,
10
+ resolveThemeChromeLocale,
11
+ resolveThemeChromePath,
12
+ themeChromeEditPath,
13
+ } from '../../lib/theme-chrome.js'
14
+
15
+ describe('isThemeChromeEnabled', () => {
16
+ const spec = {}
17
+
18
+ it('is off when the AdminRoot prop is omitted', () => {
19
+ expect(isThemeChromeEnabled(null, undefined, 'pages')).toBe(false)
20
+ })
21
+
22
+ it('defaults to pages only', () => {
23
+ expect(isThemeChromeEnabled(spec, undefined, 'pages')).toBe(true)
24
+ expect(isThemeChromeEnabled(spec, undefined, 'posts')).toBe(false)
25
+ })
26
+
27
+ it('honours the config kill-switch', () => {
28
+ expect(isThemeChromeEnabled(spec, false, 'pages')).toBe(false)
29
+ expect(isThemeChromeEnabled(spec, { pages: false }, 'pages')).toBe(false)
30
+ expect(isThemeChromeEnabled({ surfaces: ['pages', 'posts'] }, { posts: false }, 'posts')).toBe(
31
+ false,
32
+ )
33
+ })
34
+ })
35
+
36
+ describe('isSameOriginPreviewSrc', () => {
37
+ it('accepts same-origin relative paths', () => {
38
+ expect(isSameOriginPreviewSrc('/preview/chrome/header')).toBe(true)
39
+ })
40
+
41
+ it('rejects protocol-relative and off-origin URLs', () => {
42
+ expect(isSameOriginPreviewSrc('//evil.example/x')).toBe(false)
43
+ expect(isSameOriginPreviewSrc('https://evil.example/header', 'https://cms.example')).toBe(false)
44
+ expect(isSameOriginPreviewSrc('javascript:alert(1)')).toBe(false)
45
+ expect(isSameOriginPreviewSrc('data:text/html,hi')).toBe(false)
46
+ })
47
+
48
+ it('accepts an absolute URL on the same origin', () => {
49
+ expect(
50
+ isSameOriginPreviewSrc('https://cms.example/preview/chrome/header', 'https://cms.example'),
51
+ ).toBe(true)
52
+ })
53
+ })
54
+
55
+ describe('theme chrome locale and path', () => {
56
+ it('prefers document locale, then active, then default', () => {
57
+ expect(
58
+ resolveThemeChromeLocale({
59
+ documentLocale: 'ar',
60
+ activeLocale: 'en',
61
+ defaultLocale: 'de',
62
+ }),
63
+ ).toBe('ar')
64
+ expect(resolveThemeChromeLocale({ activeLocale: 'fr', defaultLocale: 'en' })).toBe('fr')
65
+ expect(resolveThemeChromeLocale({})).toBe('en')
66
+ })
67
+
68
+ it('resolves dir from i18n locale list', () => {
69
+ expect(resolveThemeChromeDir('ar', [{ code: 'ar', dir: 'rtl' }])).toBe('rtl')
70
+ expect(resolveThemeChromeDir('en', [{ code: 'en' }])).toBe('ltr')
71
+ })
72
+
73
+ it('uses the public path, never an admin route', () => {
74
+ expect(resolveThemeChromePath('/about', 'about')).toBe('/about')
75
+ expect(resolveThemeChromePath('', 'home')).toBe('/')
76
+ expect(resolveThemeChromePath('contact')).toBe('/contact')
77
+ expect(resolveThemeChromePath('')).toBe('/')
78
+ })
79
+
80
+ it('maps edit targets to existing admin routes', () => {
81
+ expect(themeChromeEditPath('navigations')).toBe('/navigations')
82
+ expect(themeChromeEditPath('appearance')).toBe('/settings?tab=appearance')
83
+ expect(themeChromeEditPath('settings')).toBe('/settings?tab=general')
84
+ })
85
+ })
86
+
87
+ describe('chrome outline helpers', () => {
88
+ it('detects an h1 inside chrome', () => {
89
+ const root = document.createElement('div')
90
+ root.innerHTML = '<header><h1>Site</h1></header>'
91
+ expect(chromeContainsH1(root)).toBe(true)
92
+ root.innerHTML = '<header><p>Site</p></header>'
93
+ expect(chromeContainsH1(root)).toBe(false)
94
+ })
95
+
96
+ it('finds the closest anchor', () => {
97
+ const root = document.createElement('div')
98
+ root.innerHTML = '<a href="/about"><span>About</span></a>'
99
+ const span = root.querySelector('span')
100
+ expect(closestAnchor(span)?.getAttribute('href')).toBe('/about')
101
+ expect(closestAnchor(root)).toBeNull()
102
+ })
103
+ })
104
+
105
+ describe('page-audit stays body-only', () => {
106
+ it('does not pass brand-up-front just because a brand name exists', () => {
107
+ const result = auditPage({
108
+ sections: [
109
+ {
110
+ id: 's1',
111
+ sectionType: 'text',
112
+ name: 'Body',
113
+ visible: true,
114
+ content: { body: 'A long enough opening that still never names the company at all.' },
115
+ settings: {},
116
+ },
117
+ ],
118
+ seo: {
119
+ title: 'About',
120
+ description: 'About the firm',
121
+ slug: 'about',
122
+ keyword: '',
123
+ schema: 'WebPage',
124
+ robots: { index: true, snippet: true },
125
+ },
126
+ llmsTxt: true,
127
+ brand: 'Acme Legal',
128
+ })
129
+ const brand = result.geo.checks.find((c) => c.id === 'brand-up-front')
130
+ expect(brand?.outcome).toBe('fail')
131
+ })
132
+ })
@@ -241,4 +241,17 @@ describe('EditorTopBar history button', () => {
241
241
  expect(screen.getByRole('button', { name: 'Post settings' })).toBeTruthy()
242
242
  expect(screen.queryByRole('button', { name: 'Page settings' })).toBeNull()
243
243
  })
244
+
245
+ it('hides the Page / Frame toggle until theme chrome is on', () => {
246
+ render(<EditorTopBar {...baseProps} />)
247
+ expect(screen.queryByRole('group', { name: /Canvas layout/ })).toBeNull()
248
+ })
249
+
250
+ it('toggles Page and Frame when theme chrome is enabled', () => {
251
+ const onChromeMode = vi.fn()
252
+ render(<EditorTopBar {...baseProps} chromeMode="flow" onChromeMode={onChromeMode} />)
253
+ expect(screen.getByRole('button', { name: /^Page$/ }).getAttribute('aria-pressed')).toBe('true')
254
+ fireEvent.click(screen.getByRole('button', { name: /^Frame$/ }))
255
+ expect(onChromeMode).toHaveBeenCalledWith('frame')
256
+ })
244
257
  })
@@ -32,8 +32,9 @@ describe('Login — logo resolution', () => {
32
32
  render(<Login onLogin={onLogin} />)
33
33
  // Bundled SVG exposes role="img" + aria-label="Prenta".
34
34
  expect(screen.getByRole('img', { name: 'Prenta' })).toBeTruthy()
35
- // No <img> logo element.
35
+ // No <img> logo element, and the wordmark already says Prenta — no extra headline.
36
36
  expect(screen.queryByRole('img', { name: 'Prenta CMS' })).toBeNull()
37
+ expect(screen.queryByRole('heading', { name: 'Prenta CMS' })).toBeNull()
37
38
  await waitFor(() => expect(cmsApiMock).toHaveBeenCalledWith('/public/brand'))
38
39
  })
39
40
 
@@ -0,0 +1,304 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
4
+ import type { PageSection } from '../../lib/page-editor-service.js'
5
+ import { THEME_CHROME_MAIN_ID, type AdminThemeChromeProps } from '../../lib/theme-chrome.js'
6
+ import { ThemeChromeProvider } from '../../views/page-editor/theme-chrome/ThemeChromeProvider.js'
7
+ import { EditorCanvas } from '../../views/page-editor/EditorCanvas.js'
8
+
9
+ afterEach(cleanup)
10
+
11
+ const SECTION: PageSection = {
12
+ id: 's1',
13
+ sectionType: 'hero',
14
+ name: 'Hero Banner',
15
+ visible: true,
16
+ content: { heading: 'A headline' },
17
+ settings: {},
18
+ }
19
+
20
+ const FIFTH: PageSection = {
21
+ ...SECTION,
22
+ id: 's5',
23
+ name: 'Fifth',
24
+ content: { heading: 'Fifth band' },
25
+ }
26
+
27
+ function CompliantHeader(props: AdminThemeChromeProps) {
28
+ return (
29
+ <header>
30
+ <p>Acme</p>
31
+ <nav>
32
+ <a href="/about">About</a>
33
+ </nav>
34
+ <span data-testid="chrome-path">{props.document.path}</span>
35
+ <span data-testid="chrome-locale">{props.locale}</span>
36
+ </header>
37
+ )
38
+ }
39
+
40
+ function CompliantFooter() {
41
+ return <footer>© Acme</footer>
42
+ }
43
+
44
+ function H1Header() {
45
+ return (
46
+ <header>
47
+ <h1>Acme</h1>
48
+ </header>
49
+ )
50
+ }
51
+
52
+ function ThrowingHeader(): never {
53
+ throw new Error('header boom')
54
+ }
55
+
56
+ function defaultThemeChrome(
57
+ extra: Partial<NonNullable<Parameters<typeof EditorCanvas>[0]['themeChrome']>> = {},
58
+ ) {
59
+ return {
60
+ spec: extra.spec ?? {
61
+ header: CompliantHeader,
62
+ footer: CompliantFooter,
63
+ },
64
+ enabled: extra.enabled ?? true,
65
+ mode: extra.mode ?? 'flow',
66
+ locale: extra.locale ?? 'en',
67
+ dir: extra.dir ?? 'ltr',
68
+ document: extra.document ?? {
69
+ id: 'p1',
70
+ title: 'About',
71
+ path: '/about',
72
+ collection: 'pages',
73
+ },
74
+ onNavigate: extra.onNavigate ?? vi.fn(),
75
+ }
76
+ }
77
+
78
+ function canvas(overrides: Partial<Parameters<typeof EditorCanvas>[0]> = {}) {
79
+ const themeChrome = overrides.themeChrome
80
+ ? defaultThemeChrome(overrides.themeChrome)
81
+ : defaultThemeChrome()
82
+ return (
83
+ <ThemeChromeProvider value={themeChrome.spec}>
84
+ <EditorCanvas
85
+ sections={[
86
+ SECTION,
87
+ { ...SECTION, id: 's2' },
88
+ { ...SECTION, id: 's3' },
89
+ { ...SECTION, id: 's4' },
90
+ FIFTH,
91
+ ]}
92
+ selectedId={null}
93
+ breakpoint="wide"
94
+ onSelect={vi.fn()}
95
+ {...overrides}
96
+ themeChrome={overrides.themeChrome === undefined ? themeChrome : themeChrome}
97
+ />
98
+ </ThemeChromeProvider>
99
+ )
100
+ }
101
+
102
+ describe('EditorCanvas theme chrome', () => {
103
+ it('omits chrome when the prop is not enabled', () => {
104
+ render(
105
+ <EditorCanvas sections={[SECTION]} selectedId={null} breakpoint="wide" onSelect={vi.fn()} />,
106
+ )
107
+ expect(document.querySelector('[data-prenta-theme-chrome="header"]')).toBeNull()
108
+ expect(document.getElementById(THEME_CHROME_MAIN_ID)).toBeNull()
109
+ })
110
+
111
+ it('renders header then bands then footer in flow mode', () => {
112
+ const { container } = render(canvas())
113
+ const frame = container.querySelector('[data-prenta-theme-chrome-mode="flow"]')
114
+ expect(frame).toBeTruthy()
115
+ const header = frame!.querySelector('[data-prenta-theme-chrome="header"]')
116
+ const main = frame!.querySelector(`#${THEME_CHROME_MAIN_ID}`)
117
+ const footer = frame!.querySelector('[data-prenta-theme-chrome="footer"]')
118
+ expect(header).toBeTruthy()
119
+ expect(main).toBeTruthy()
120
+ expect(footer).toBeTruthy()
121
+ expect(header!.compareDocumentPosition(main!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
122
+ expect(main!.compareDocumentPosition(footer!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
123
+ expect(container.querySelector('.prenta-canvas')?.getAttribute('lang')).toBe('en')
124
+ expect(container.querySelector('.prenta-canvas')?.getAttribute('dir')).toBe('ltr')
125
+ })
126
+
127
+ it('keeps chrome mounted in frame mode after selecting a middle band', () => {
128
+ const { rerender } = render(
129
+ canvas({
130
+ selectedId: 's5',
131
+ themeChrome: {
132
+ spec: { header: CompliantHeader, footer: CompliantFooter },
133
+ enabled: true,
134
+ mode: 'frame',
135
+ locale: 'en',
136
+ dir: 'ltr',
137
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
138
+ onNavigate: vi.fn(),
139
+ },
140
+ }),
141
+ )
142
+ expect(document.querySelector('[data-prenta-theme-chrome-mode="frame"]')).toBeTruthy()
143
+ expect(document.querySelector('[data-prenta-theme-chrome="header"]')).toBeTruthy()
144
+ expect(document.querySelector('[data-prenta-theme-chrome="footer"]')).toBeTruthy()
145
+ rerender(
146
+ canvas({
147
+ selectedId: 's5',
148
+ themeChrome: {
149
+ spec: { header: CompliantHeader, footer: CompliantFooter },
150
+ enabled: true,
151
+ mode: 'frame',
152
+ locale: 'en',
153
+ dir: 'ltr',
154
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
155
+ onNavigate: vi.fn(),
156
+ },
157
+ }),
158
+ )
159
+ expect(document.querySelector('[data-prenta-theme-chrome="header"]')).toBeTruthy()
160
+ })
161
+
162
+ it('still shows chrome when every band is hidden or the list is empty', () => {
163
+ render(
164
+ canvas({
165
+ sections: [],
166
+ }),
167
+ )
168
+ expect(document.querySelector('[data-prenta-theme-chrome="header"]')).toBeTruthy()
169
+ expect(document.querySelector('[data-prenta-theme-chrome="footer"]')).toBeTruthy()
170
+ expect(screen.getByText('This template renders no sections')).toBeTruthy()
171
+ })
172
+
173
+ it('opens the edit menu and routes Navigations through onNavigate', () => {
174
+ const onNavigate = vi.fn()
175
+ render(
176
+ <div className="prenta-admin" data-prenta-admin-root="">
177
+ {canvas({
178
+ themeChrome: {
179
+ spec: { header: CompliantHeader, footer: CompliantFooter },
180
+ enabled: true,
181
+ mode: 'flow',
182
+ locale: 'en',
183
+ dir: 'ltr',
184
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
185
+ onNavigate,
186
+ },
187
+ })}
188
+ </div>,
189
+ )
190
+ // Clicking the chrome surface (logo/name) is the Fortune-500 path — not
191
+ // only the pencil. Capture-phase opens the controlled menu.
192
+ fireEvent.click(screen.getByText('Acme'))
193
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Edit navigation' }))
194
+ expect(onNavigate).toHaveBeenCalledWith('/navigations')
195
+ })
196
+
197
+ it('does not follow a nav href', () => {
198
+ render(canvas())
199
+ const link = screen.getByRole('link', { name: 'About' })
200
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true })
201
+ link.dispatchEvent(event)
202
+ expect(event.defaultPrevented).toBe(true)
203
+ expect(window.location.pathname).not.toBe('/about')
204
+ })
205
+
206
+ it('falls back when the header throws and still paints bands', () => {
207
+ render(
208
+ canvas({
209
+ themeChrome: {
210
+ spec: { header: ThrowingHeader, footer: CompliantFooter },
211
+ enabled: true,
212
+ mode: 'flow',
213
+ locale: 'en',
214
+ dir: 'ltr',
215
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
216
+ onNavigate: vi.fn(),
217
+ },
218
+ }),
219
+ )
220
+ expect(screen.getByText('Site header')).toBeTruthy()
221
+ expect(document.querySelector('[data-section-id="s1"]')).toBeTruthy()
222
+ expect(screen.getByText('© Acme')).toBeTruthy()
223
+ })
224
+
225
+ it('rejects an off-origin previewSrc and shows the placeholder', () => {
226
+ render(
227
+ canvas({
228
+ themeChrome: {
229
+ spec: {
230
+ previewSrc: { header: 'https://evil.example/header' },
231
+ footer: CompliantFooter,
232
+ },
233
+ enabled: true,
234
+ mode: 'flow',
235
+ locale: 'en',
236
+ dir: 'ltr',
237
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
238
+ onNavigate: vi.fn(),
239
+ },
240
+ }),
241
+ )
242
+ expect(document.querySelector('iframe')).toBeNull()
243
+ expect(document.querySelector('[data-prenta-theme-chrome-placeholder="header"]')).toBeTruthy()
244
+ })
245
+
246
+ it('puts locale, dir, and the public path on the frame — not an admin route', () => {
247
+ render(
248
+ canvas({
249
+ themeChrome: {
250
+ spec: { header: CompliantHeader, footer: CompliantFooter },
251
+ enabled: true,
252
+ mode: 'flow',
253
+ locale: 'ar',
254
+ dir: 'rtl',
255
+ document: { id: 'p1', title: 'About', path: '/ar/about', collection: 'pages' },
256
+ onNavigate: vi.fn(),
257
+ },
258
+ }),
259
+ )
260
+ const frame = document.querySelector('[data-prenta-theme-chrome-mode]')
261
+ expect(frame?.getAttribute('data-prenta-theme-chrome-locale')).toBe('ar')
262
+ expect(frame?.getAttribute('data-prenta-theme-chrome-dir')).toBe('rtl')
263
+ expect(frame?.getAttribute('data-prenta-theme-chrome-path')).toBe('/ar/about')
264
+ expect(screen.getByTestId('chrome-path').textContent).toBe('/ar/about')
265
+ expect(screen.getByTestId('chrome-locale').textContent).toBe('ar')
266
+ })
267
+
268
+ it('warns when the header renders an h1', () => {
269
+ render(
270
+ canvas({
271
+ themeChrome: {
272
+ spec: { header: H1Header, footer: CompliantFooter },
273
+ enabled: true,
274
+ mode: 'flow',
275
+ locale: 'en',
276
+ dir: 'ltr',
277
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
278
+ onNavigate: vi.fn(),
279
+ },
280
+ }),
281
+ )
282
+ expect(document.querySelector('[data-prenta-theme-chrome-outline-warning]')).toBeTruthy()
283
+ expect(screen.getByText(/Chrome uses H1/)).toBeTruthy()
284
+ })
285
+
286
+ it('does not warn for a compliant header', () => {
287
+ render(canvas())
288
+ expect(document.querySelector('[data-prenta-theme-chrome-outline-warning]')).toBeNull()
289
+ })
290
+
291
+ it('keeps chrome visible in review mode and does not select chrome as a section', () => {
292
+ const onSelect = vi.fn()
293
+ render(
294
+ canvas({
295
+ reviewing: true,
296
+ onSelect,
297
+ reviewBar: <div>Reviewing</div>,
298
+ }),
299
+ )
300
+ expect(document.querySelector('[data-prenta-theme-chrome="header"]')).toBeTruthy()
301
+ fireEvent.click(screen.getByText('Acme'))
302
+ expect(onSelect).not.toHaveBeenCalled()
303
+ })
304
+ })
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Default Prenta wordmark for the admin sidebar (top-left) and the login
3
- * screen: a rounded-square mark with a stencil "P" cutout plus the product
4
- * name. Everything renders in `currentColor`, so the logo follows the
5
- * surrounding theme in both light and dark mode with no baked-in palette.
2
+ * Default Prenta brand lockup for the admin sidebar (top-left) and the login
3
+ * screen: the word "Prenta" plus a three-dot ellipsis (last dot is the brand
4
+ * red). The collapsed sidebar uses the matching "P…" mark.
5
+ *
6
+ * Letterforms and the first two dots render in `currentColor` so the lockup
7
+ * follows the surrounding theme in light and dark mode. The accent dot is the
8
+ * one baked-in brand color.
6
9
  *
7
10
  * Integrators override this default via:
8
11
  * defineConfig({
@@ -20,42 +23,65 @@ export interface BrandLogoProps {
20
23
  className?: string
21
24
  }
22
25
 
26
+ /** Brand-red accent used for the final ellipsis dot. */
27
+ export const PRENTA_ACCENT = '#E30613'
28
+
29
+ const FONT = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
30
+
23
31
  /**
24
- * Inline SVG renderer for the Prenta wordmark. Ships as JSX (no bundler asset
25
- * pipeline required — works under Next.js, Vite, Webpack, esbuild, etc.).
26
- * The wordmark text inherits the admin font stack; `textLength` pins the
27
- * rendered width so the lockup stays proportioned across font fallbacks.
32
+ * Compact "P…" mark for the collapsed sidebar rail and other square slots.
28
33
  */
29
- export function PrentaBrandLogo({ className }: BrandLogoProps): ReactElement {
34
+ export function PrentaMark({ className }: BrandLogoProps): ReactElement {
30
35
  return (
31
36
  <svg
32
- viewBox="0 0 128 28"
37
+ viewBox="0 0 48 48"
33
38
  xmlns="http://www.w3.org/2000/svg"
34
39
  className={className}
35
40
  role="img"
36
41
  aria-label="Prenta"
37
- style={{ shapeRendering: 'geometricPrecision' }}
38
42
  >
39
- {/* Mark: rounded square with a stencil "P" cutout (even-odd fill). */}
40
43
  <path
41
44
  fill="currentColor"
42
45
  fillRule="evenodd"
43
- d="M6 3 H16 A6 6 0 0 1 22 9 V19 A6 6 0 0 1 16 25 H6 A6 6 0 0 1 0 19 V9 A6 6 0 0 1 6 3 Z M7.5 8.5 H13 A4.5 4.5 0 0 1 13 17.5 H10 V20.5 H7.5 Z M10 11 H12.8 A2 2 0 0 1 12.8 15 H10 Z"
46
+ d="M8 4h10.5c8.2 0 13.5 4.4 13.5 12.2 0 7.7-5.3 12.1-13.5 12.1H16v15.7H8V4zm8 6.4v11.4h2.2c4.7 0 7.4-2.3 7.4-5.7 0-3.5-2.7-5.7-7.4-5.7H16z"
44
47
  />
45
- {/* Wordmark */}
48
+ <circle cx="26" cy="43.2" r="2.7" fill="currentColor" />
49
+ <circle cx="34.2" cy="43.2" r="2.7" fill="currentColor" />
50
+ <circle cx="42.4" cy="43.2" r="2.7" fill={PRENTA_ACCENT} />
51
+ </svg>
52
+ )
53
+ }
54
+
55
+ /**
56
+ * Horizontal "Prenta…" wordmark. Ships as JSX (no bundler asset pipeline —
57
+ * works under Next.js, Vite, Webpack, esbuild). `textLength` pins the
58
+ * rendered width so the lockup stays proportioned across font fallbacks.
59
+ */
60
+ export function PrentaBrandLogo({ className }: BrandLogoProps): ReactElement {
61
+ return (
62
+ <svg
63
+ viewBox="0 0 168 32"
64
+ xmlns="http://www.w3.org/2000/svg"
65
+ className={className}
66
+ role="img"
67
+ aria-label="Prenta"
68
+ >
46
69
  <text
47
- x="30"
48
- y="20.5"
49
- fontFamily="inherit"
50
- fontSize="19"
51
- fontWeight="600"
52
- letterSpacing="-0.3"
70
+ x="0"
71
+ y="24"
72
+ fontFamily={FONT}
73
+ fontSize="22"
74
+ fontWeight="700"
75
+ letterSpacing="-0.6"
53
76
  fill="currentColor"
54
- textLength="72"
77
+ textLength="104"
55
78
  lengthAdjust="spacingAndGlyphs"
56
79
  >
57
80
  Prenta
58
81
  </text>
82
+ <circle cx="118" cy="20" r="3.1" fill="currentColor" />
83
+ <circle cx="130.5" cy="20" r="3.1" fill="currentColor" />
84
+ <circle cx="143" cy="20" r="3.1" fill={PRENTA_ACCENT} />
59
85
  </svg>
60
86
  )
61
87
  }
package/src/index.ts CHANGED
@@ -14,6 +14,23 @@ export type {
14
14
  AdminRenderImageProps,
15
15
  } from './views/page-editor/sections/index.js'
16
16
 
17
+ export type {
18
+ AdminThemeChrome,
19
+ AdminThemeChromeProps,
20
+ AdminThemeChromeRenderer,
21
+ ThemeChromeDocument,
22
+ ThemeChromeEditTarget,
23
+ ThemeChromeMode,
24
+ ThemeChromeSurface,
25
+ } from './lib/theme-chrome.js'
26
+ export {
27
+ isThemeChromeEnabled,
28
+ isSameOriginPreviewSrc,
29
+ themeChromeEditPath,
30
+ THEME_CHROME_MAIN_ID,
31
+ } from './lib/theme-chrome.js'
32
+ export { useThemeChromeContext } from './views/page-editor/theme-chrome/index.js'
33
+
17
34
  export { Layout } from './layout/Layout.js'
18
35
  export type { LayoutProps } from './layout/Layout.js'
19
36
  export { Sidebar } from './layout/Sidebar.js'