@actuate-media/cms-admin 0.72.3 → 0.74.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 (178) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +3 -2
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/LoginRoot.d.ts.map +1 -1
  6. package/dist/LoginRoot.js +2 -1
  7. package/dist/LoginRoot.js.map +1 -1
  8. package/dist/__tests__/components/resizable-aside.test.d.ts +2 -0
  9. package/dist/__tests__/components/resizable-aside.test.d.ts.map +1 -0
  10. package/dist/__tests__/components/resizable-aside.test.js +77 -0
  11. package/dist/__tests__/components/resizable-aside.test.js.map +1 -0
  12. package/dist/__tests__/components/theme-provider-accent.test.d.ts +2 -0
  13. package/dist/__tests__/components/theme-provider-accent.test.d.ts.map +1 -0
  14. package/dist/__tests__/components/theme-provider-accent.test.js +64 -0
  15. package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -0
  16. package/dist/__tests__/views/portal-container.test.js +13 -0
  17. package/dist/__tests__/views/portal-container.test.js.map +1 -1
  18. package/dist/__tests__/views/redirects-pane.render.test.js +6 -0
  19. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  20. package/dist/__tests__/views/seo-content-pane.render.test.js +13 -4
  21. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
  22. package/dist/actuate-admin.css +1 -1
  23. package/dist/components/CoauthorResultPopover.js +1 -1
  24. package/dist/components/EditorSeoAside.d.ts +20 -0
  25. package/dist/components/EditorSeoAside.d.ts.map +1 -0
  26. package/dist/components/EditorSeoAside.js +30 -0
  27. package/dist/components/EditorSeoAside.js.map +1 -0
  28. package/dist/components/MediaPickerModal.js +1 -1
  29. package/dist/components/SEOConfigPanel.js +1 -1
  30. package/dist/components/SchedulePublishDialog.js +1 -1
  31. package/dist/components/SharePreviewLinkDialog.js +1 -1
  32. package/dist/components/ThemeProvider.d.ts.map +1 -1
  33. package/dist/components/ThemeProvider.js +2 -1
  34. package/dist/components/ThemeProvider.js.map +1 -1
  35. package/dist/components/VersionHistory.js +1 -1
  36. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  37. package/dist/components/seo/SeoEditorPane.js +28 -8
  38. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  39. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  40. package/dist/components/seo/SeoIssueFixPanel.js +13 -3
  41. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  42. package/dist/components/seo/primitives.d.ts.map +1 -1
  43. package/dist/components/seo/primitives.js +3 -3
  44. package/dist/components/seo/primitives.js.map +1 -1
  45. package/dist/components/ui/Card.js +5 -5
  46. package/dist/components/ui/Card.js.map +1 -1
  47. package/dist/components/ui/InspectorPane.js +3 -3
  48. package/dist/components/ui/InspectorPane.js.map +1 -1
  49. package/dist/components/ui/PanelSection.d.ts +16 -0
  50. package/dist/components/ui/PanelSection.d.ts.map +1 -0
  51. package/dist/components/ui/PanelSection.js +15 -0
  52. package/dist/components/ui/PanelSection.js.map +1 -0
  53. package/dist/components/ui/ResizableAside.d.ts +38 -0
  54. package/dist/components/ui/ResizableAside.d.ts.map +1 -0
  55. package/dist/components/ui/ResizableAside.js +209 -0
  56. package/dist/components/ui/ResizableAside.js.map +1 -0
  57. package/dist/lib/admin-root-element.d.ts +9 -0
  58. package/dist/lib/admin-root-element.d.ts.map +1 -0
  59. package/dist/lib/admin-root-element.js +14 -0
  60. package/dist/lib/admin-root-element.js.map +1 -0
  61. package/dist/lib/portal-container.d.ts.map +1 -1
  62. package/dist/lib/portal-container.js +2 -3
  63. package/dist/lib/portal-container.js.map +1 -1
  64. package/dist/lib/seo-service.d.ts +89 -0
  65. package/dist/lib/seo-service.d.ts.map +1 -1
  66. package/dist/lib/seo-service.js +50 -2
  67. package/dist/lib/seo-service.js.map +1 -1
  68. package/dist/views/ApiKeys.js +2 -2
  69. package/dist/views/Dashboard.d.ts.map +1 -1
  70. package/dist/views/Dashboard.js +12 -14
  71. package/dist/views/Dashboard.js.map +1 -1
  72. package/dist/views/MediaBrowser.d.ts.map +1 -1
  73. package/dist/views/MediaBrowser.js +35 -6
  74. package/dist/views/MediaBrowser.js.map +1 -1
  75. package/dist/views/PageEditor.js +1 -1
  76. package/dist/views/Posts/PostTypeEditor.js +3 -3
  77. package/dist/views/SEO.d.ts.map +1 -1
  78. package/dist/views/SEO.js +4 -2
  79. package/dist/views/SEO.js.map +1 -1
  80. package/dist/views/ScriptTags.js +1 -1
  81. package/dist/views/ScriptTags.js.map +1 -1
  82. package/dist/views/Users.js +1 -1
  83. package/dist/views/page-editor/EditorTopBar.d.ts +4 -1
  84. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  85. package/dist/views/page-editor/EditorTopBar.js +5 -3
  86. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  87. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  88. package/dist/views/page-editor/PageSectionEditor.js +24 -1
  89. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  90. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  91. package/dist/views/page-editor/SectionInspector.js +7 -2
  92. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  93. package/dist/views/page-editor/SectionsPanel.js +1 -1
  94. package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -1
  95. package/dist/views/post-editor/PostHeaderPanel.js +2 -1
  96. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -1
  97. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  98. package/dist/views/post-editor/PostSectionEditor.js +24 -1
  99. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  100. package/dist/views/post-editor/PostTemplateEditor.js +1 -1
  101. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
  102. package/dist/views/seo/LinksTab.d.ts +4 -0
  103. package/dist/views/seo/LinksTab.d.ts.map +1 -0
  104. package/dist/views/seo/LinksTab.js +111 -0
  105. package/dist/views/seo/LinksTab.js.map +1 -0
  106. package/dist/views/seo/OverviewTab.js +1 -1
  107. package/dist/views/seo/OverviewTab.js.map +1 -1
  108. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  109. package/dist/views/seo/RedirectsTab.js +39 -6
  110. package/dist/views/seo/RedirectsTab.js.map +1 -1
  111. package/dist/views/seo/TechnicalTab.js +2 -2
  112. package/dist/views/settings/AISettingsTab.js +1 -1
  113. package/dist/views/settings/AiFeaturesCard.js +1 -1
  114. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  115. package/dist/views/settings/AppearanceTab.js +1 -1
  116. package/dist/views/settings/BrandVoiceCard.js +1 -1
  117. package/dist/views/settings/EmailSettingsTab.js +2 -2
  118. package/dist/views/settings/GeneralSettingsTab.js +1 -1
  119. package/dist/views/settings/SecurityStatusCard.js +1 -1
  120. package/dist/views/settings/SeoSettingsTab.js +4 -4
  121. package/dist/views/settings/UpdatesTab.js +2 -2
  122. package/dist/views/settings/components.js +3 -3
  123. package/dist/views/settings/components.js.map +1 -1
  124. package/package.json +2 -2
  125. package/src/AdminRoot.tsx +3 -2
  126. package/src/LoginRoot.tsx +2 -1
  127. package/src/__tests__/components/resizable-aside.test.tsx +130 -0
  128. package/src/__tests__/components/theme-provider-accent.test.tsx +84 -0
  129. package/src/__tests__/views/portal-container.test.tsx +14 -0
  130. package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
  131. package/src/__tests__/views/seo-content-pane.render.test.tsx +14 -4
  132. package/src/components/CoauthorResultPopover.tsx +1 -1
  133. package/src/components/EditorSeoAside.tsx +149 -0
  134. package/src/components/MediaPickerModal.tsx +1 -1
  135. package/src/components/SEOConfigPanel.tsx +2 -2
  136. package/src/components/SchedulePublishDialog.tsx +1 -1
  137. package/src/components/SharePreviewLinkDialog.tsx +1 -1
  138. package/src/components/ThemeProvider.tsx +3 -1
  139. package/src/components/VersionHistory.tsx +1 -1
  140. package/src/components/seo/SeoEditorPane.tsx +41 -12
  141. package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
  142. package/src/components/seo/primitives.tsx +7 -3
  143. package/src/components/ui/Card.tsx +5 -5
  144. package/src/components/ui/InspectorPane.tsx +3 -3
  145. package/src/components/ui/PanelSection.tsx +53 -0
  146. package/src/components/ui/ResizableAside.tsx +317 -0
  147. package/src/lib/admin-root-element.ts +15 -0
  148. package/src/lib/portal-container.ts +3 -2
  149. package/src/lib/seo-service.ts +143 -2
  150. package/src/views/ApiKeys.tsx +2 -2
  151. package/src/views/Dashboard.tsx +40 -53
  152. package/src/views/MediaBrowser.tsx +62 -12
  153. package/src/views/PageEditor.tsx +1 -1
  154. package/src/views/Posts/PostTypeEditor.tsx +3 -3
  155. package/src/views/SEO.tsx +6 -0
  156. package/src/views/ScriptTags.tsx +2 -2
  157. package/src/views/Users.tsx +1 -1
  158. package/src/views/page-editor/EditorTopBar.tsx +24 -0
  159. package/src/views/page-editor/PageSectionEditor.tsx +44 -4
  160. package/src/views/page-editor/SectionInspector.tsx +33 -27
  161. package/src/views/page-editor/SectionsPanel.tsx +1 -1
  162. package/src/views/post-editor/PostHeaderPanel.tsx +16 -12
  163. package/src/views/post-editor/PostSectionEditor.tsx +44 -4
  164. package/src/views/post-editor/PostTemplateEditor.tsx +1 -1
  165. package/src/views/seo/LinksTab.tsx +462 -0
  166. package/src/views/seo/OverviewTab.tsx +1 -1
  167. package/src/views/seo/RedirectsTab.tsx +97 -4
  168. package/src/views/seo/TechnicalTab.tsx +2 -2
  169. package/src/views/settings/AISettingsTab.tsx +3 -3
  170. package/src/views/settings/AiFeaturesCard.tsx +1 -1
  171. package/src/views/settings/AppearanceTab.tsx +1 -1
  172. package/src/views/settings/BrandVoiceCard.tsx +2 -2
  173. package/src/views/settings/EmailSettingsTab.tsx +2 -2
  174. package/src/views/settings/GeneralSettingsTab.tsx +3 -3
  175. package/src/views/settings/SecurityStatusCard.tsx +1 -1
  176. package/src/views/settings/SeoSettingsTab.tsx +6 -6
  177. package/src/views/settings/UpdatesTab.tsx +3 -3
  178. package/src/views/settings/components.tsx +3 -3
package/src/AdminRoot.tsx CHANGED
@@ -5,6 +5,7 @@ import * as Dialog from '@radix-ui/react-dialog'
5
5
  import { toast } from 'sonner'
6
6
  import { Layout } from './layout/Layout.js'
7
7
  import { adminPortalContainer } from './lib/portal-container.js'
8
+ import { ADMIN_ROOT_ATTR, getAdminRootElement } from './lib/admin-root-element.js'
8
9
  import { useAdminRouter, parseRouteQuery } from './router/index.js'
9
10
  import { cmsApi, ensureCsrfToken } from './lib/api.js'
10
11
  import { setDocumentFavicon, BRAND_UPDATED_EVENT } from './lib/favicon.js'
@@ -856,7 +857,7 @@ function useBranding(config: any) {
856
857
  }
857
858
 
858
859
  // --- Primary color ---
859
- const adminEl = document.querySelector<HTMLElement>('.actuate-admin')
860
+ const adminEl = getAdminRootElement()
860
861
  if (branding?.primaryColor && adminEl) {
861
862
  adminEl.style.setProperty('--primary', branding.primaryColor)
862
863
  adminEl.style.setProperty('--sidebar-primary', branding.primaryColor)
@@ -938,7 +939,7 @@ export function AdminRoot(props: AdminRootProps) {
938
939
  }, [props.sentryDsn])
939
940
 
940
941
  return (
941
- <div style={ISOLATION_STYLE} className="actuate-admin">
942
+ <div style={ISOLATION_STYLE} className="actuate-admin" {...{ [ADMIN_ROOT_ATTR]: '' }}>
942
943
  <ThemeProvider defaultDarkMode={defaultDarkMode}>
943
944
  <LocaleProvider config={props.config}>
944
945
  <AdminSectionRenderersProvider value={props.sectionRenderers ?? EMPTY_RENDERERS}>
package/src/LoginRoot.tsx CHANGED
@@ -4,6 +4,7 @@ import { useEffect } from 'react'
4
4
  import { useAdminRouter } from './router/index.js'
5
5
  import { ThemeProvider } from './components/ThemeProvider.js'
6
6
  import { ISOLATION_STYLE, useRobotsNoIndex } from './lib/root-chrome.js'
7
+ import { ADMIN_ROOT_ATTR } from './lib/admin-root-element.js'
7
8
  import { Login, type CaptchaConfig } from './views/Login.js'
8
9
  import { SetupWizard } from './views/SetupWizard.js'
9
10
  import { ForgotPassword } from './views/ForgotPassword.js'
@@ -79,7 +80,7 @@ export function LoginRoot({
79
80
  useRobotsNoIndex()
80
81
 
81
82
  return (
82
- <div style={ISOLATION_STYLE} className="actuate-admin">
83
+ <div style={ISOLATION_STYLE} className="actuate-admin" {...{ [ADMIN_ROOT_ATTR]: '' }}>
83
84
  <ThemeProvider defaultDarkMode={defaultDarkMode}>
84
85
  <LoginShell
85
86
  config={config}
@@ -0,0 +1,130 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+ import {
5
+ InspectorExpandButton,
6
+ ResizableAside,
7
+ SECTION_INSPECTOR_DEFAULT_WIDTH,
8
+ SECTION_INSPECTOR_MIN_WIDTH,
9
+ SECTION_INSPECTOR_WIDTH_KEY,
10
+ computeInspectorMaxWidth,
11
+ computeInspectorOverlayWidth,
12
+ readSectionInspectorWidth,
13
+ } from '../../components/ui/ResizableAside.js'
14
+
15
+ const storage = new Map<string, string>()
16
+ vi.stubGlobal('localStorage', {
17
+ getItem: (key: string) => storage.get(key) ?? null,
18
+ setItem: (key: string, value: string) => void storage.set(key, String(value)),
19
+ removeItem: (key: string) => void storage.delete(key),
20
+ clear: () => storage.clear(),
21
+ })
22
+
23
+ vi.stubGlobal(
24
+ 'ResizeObserver',
25
+ class {
26
+ constructor(private callback: ResizeObserverCallback) {}
27
+ observe(target: Element) {
28
+ const width =
29
+ target instanceof HTMLElement
30
+ ? Number.parseFloat(target.style.width) || target.clientWidth || 900
31
+ : 900
32
+ this.callback([{ contentRect: { width, height: 600 } } as ResizeObserverEntry], this)
33
+ }
34
+ unobserve() {}
35
+ disconnect() {}
36
+ },
37
+ )
38
+
39
+ afterEach(() => {
40
+ storage.clear()
41
+ })
42
+
43
+ describe('inspector width helpers', () => {
44
+ it('caps inline max width to the container minus a canvas peek', () => {
45
+ expect(computeInspectorMaxWidth(1200)).toBe(640)
46
+ expect(computeInspectorMaxWidth(500)).toBe(404)
47
+ expect(computeInspectorMaxWidth(200)).toBe(SECTION_INSPECTOR_MIN_WIDTH)
48
+ })
49
+
50
+ it('computes overlay width for narrow editor rows', () => {
51
+ expect(computeInspectorOverlayWidth(375)).toBe(343)
52
+ expect(computeInspectorOverlayWidth(768)).toBe(480)
53
+ })
54
+ })
55
+
56
+ describe('ResizableAside', () => {
57
+ it('renders a vertical resize separator with keyboard support', () => {
58
+ render(
59
+ <div style={{ width: 900 }}>
60
+ <ResizableAside aria-label="Section editor">
61
+ <p>Inspector content</p>
62
+ </ResizableAside>
63
+ </div>,
64
+ )
65
+
66
+ expect(screen.getByText('Inspector content')).toBeTruthy()
67
+ const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
68
+ expect(separator.getAttribute('aria-orientation')).toBe('vertical')
69
+ expect(separator.getAttribute('aria-valuemin')).toBe(String(SECTION_INSPECTOR_MIN_WIDTH))
70
+ })
71
+
72
+ it('widens when dragging the separator left', () => {
73
+ render(
74
+ <div style={{ width: 900 }}>
75
+ <ResizableAside aria-label="Section editor">
76
+ <p>Inspector content</p>
77
+ </ResizableAside>
78
+ </div>,
79
+ )
80
+
81
+ const aside = screen.getByLabelText('Section editor')
82
+ const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
83
+
84
+ expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH}px`)
85
+
86
+ fireEvent.pointerDown(separator, { clientX: 900, button: 0, pointerId: 1 })
87
+ fireEvent.pointerMove(separator, { clientX: 860, pointerId: 1 })
88
+ expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH + 40}px`)
89
+
90
+ fireEvent.pointerUp(separator, { pointerId: 1 })
91
+ expect(localStorage.getItem(SECTION_INSPECTOR_WIDTH_KEY)).toBe(
92
+ String(SECTION_INSPECTOR_DEFAULT_WIDTH + 40),
93
+ )
94
+ })
95
+
96
+ it('nudges width with arrow keys and persists', () => {
97
+ render(
98
+ <div style={{ width: 900 }}>
99
+ <ResizableAside aria-label="Section editor">
100
+ <p>Inspector content</p>
101
+ </ResizableAside>
102
+ </div>,
103
+ )
104
+
105
+ const aside = screen.getByLabelText('Section editor')
106
+ const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
107
+
108
+ separator.focus()
109
+ fireEvent.keyDown(separator, { key: 'ArrowLeft' })
110
+ expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH + 16}px`)
111
+ expect(readSectionInspectorWidth()).toBe(SECTION_INSPECTOR_DEFAULT_WIDTH + 16)
112
+ })
113
+
114
+ it('expands to overlay width on narrow containers', () => {
115
+ render(
116
+ <div style={{ width: 375 }}>
117
+ <ResizableAside aria-label="Section editor">
118
+ <InspectorExpandButton />
119
+ <p>Inspector content</p>
120
+ </ResizableAside>
121
+ </div>,
122
+ )
123
+
124
+ const aside = screen.getByLabelText('Section editor')
125
+ fireEvent.click(screen.getByRole('button', { name: 'Expand inspector panel' }))
126
+
127
+ expect(aside.getAttribute('data-overlay')).toBe('')
128
+ expect(aside.style.width).toBe(`${computeInspectorOverlayWidth(375)}px`)
129
+ })
130
+ })
@@ -0,0 +1,84 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, render, waitFor } from '@testing-library/react'
4
+ import { accentTokensToCssVars, generateAdminThemeTokens } from '@actuate-media/cms-core/appearance'
5
+
6
+ import { ThemeProvider } from '../../components/ThemeProvider.js'
7
+ import { ADMIN_ROOT_ATTR, getAdminRootElement } from '../../lib/admin-root-element.js'
8
+
9
+ /**
10
+ * Regression: accent overrides must land on the inner AdminRoot shell, not a
11
+ * consumer wrapper `.actuate-admin` that also defines static --acc tokens.
12
+ */
13
+ afterEach(cleanup)
14
+
15
+ const storage = new Map<string, string>()
16
+
17
+ beforeEach(() => {
18
+ storage.clear()
19
+ vi.stubGlobal('localStorage', {
20
+ getItem: (key: string) => storage.get(key) ?? null,
21
+ setItem: (key: string, value: string) => storage.set(key, value),
22
+ removeItem: (key: string) => storage.delete(key),
23
+ clear: () => storage.clear(),
24
+ })
25
+ })
26
+
27
+ function mountNestedAdminShell() {
28
+ const outer = document.createElement('div')
29
+ outer.className = 'actuate-admin'
30
+ document.body.appendChild(outer)
31
+
32
+ const inner = document.createElement('div')
33
+ inner.className = 'actuate-admin'
34
+ inner.setAttribute(ADMIN_ROOT_ATTR, '')
35
+ outer.appendChild(inner)
36
+
37
+ return { outer, inner }
38
+ }
39
+
40
+ describe('getAdminRootElement', () => {
41
+ afterEach(() => {
42
+ document.body.innerHTML = ''
43
+ })
44
+
45
+ it('prefers the marked admin shell over an outer wrapper', () => {
46
+ const { outer, inner } = mountNestedAdminShell()
47
+ expect(getAdminRootElement()).toBe(inner)
48
+ expect(getAdminRootElement()).not.toBe(outer)
49
+ })
50
+
51
+ it('falls back to .actuate-admin when no marked root exists', () => {
52
+ const root = document.createElement('div')
53
+ root.className = 'actuate-admin'
54
+ document.body.appendChild(root)
55
+ expect(getAdminRootElement()).toBe(root)
56
+ })
57
+ })
58
+
59
+ describe('ThemeProvider accent application', () => {
60
+ afterEach(() => {
61
+ document.body.innerHTML = ''
62
+ })
63
+
64
+ it('applies accent tokens to the marked inner admin root when nested', async () => {
65
+ const { outer, inner } = mountNestedAdminShell()
66
+
67
+ render(
68
+ <ThemeProvider>
69
+ <span>child</span>
70
+ </ThemeProvider>,
71
+ { container: inner },
72
+ )
73
+
74
+ const expected = accentTokensToCssVars(
75
+ generateAdminThemeTokens({ adminAccentColor: 'purple' }, 'light'),
76
+ )
77
+
78
+ await waitFor(() => {
79
+ expect(inner.style.getPropertyValue('--acc')).toBe(expected['--acc']!)
80
+ })
81
+ expect(inner.style.getPropertyValue('--primary')).toBe(expected['--primary']!)
82
+ expect(outer.style.getPropertyValue('--acc')).toBe('')
83
+ })
84
+ })
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'
3
3
  import { cleanup, render, screen } from '@testing-library/react'
4
4
  import { ConfirmDangerousSettingDialog } from '../../views/settings/components.js'
5
5
  import { adminPortalContainer } from '../../lib/portal-container.js'
6
+ import { ADMIN_ROOT_ATTR } from '../../lib/admin-root-element.js'
6
7
 
7
8
  /**
8
9
  * Regression: theme tokens only exist under `.actuate-admin` (theme.css), so
@@ -24,11 +25,24 @@ describe('adminPortalContainer', () => {
24
25
  it('resolves the admin root element', () => {
25
26
  const root = document.createElement('div')
26
27
  root.className = 'actuate-admin'
28
+ root.setAttribute(ADMIN_ROOT_ATTR, '')
27
29
  document.body.appendChild(root)
28
30
  expect(adminPortalContainer()).toBe(root)
29
31
  root.remove()
30
32
  })
31
33
 
34
+ it('prefers the marked shell when nested inside another .actuate-admin', () => {
35
+ const outer = document.createElement('div')
36
+ outer.className = 'actuate-admin'
37
+ const inner = document.createElement('div')
38
+ inner.className = 'actuate-admin'
39
+ inner.setAttribute(ADMIN_ROOT_ATTR, '')
40
+ outer.appendChild(inner)
41
+ document.body.appendChild(outer)
42
+ expect(adminPortalContainer()).toBe(inner)
43
+ outer.remove()
44
+ })
45
+
32
46
  it('returns undefined when no admin root is mounted', () => {
33
47
  document.querySelectorAll('.actuate-admin').forEach((el) => el.remove())
34
48
  expect(adminPortalContainer()).toBeUndefined()
@@ -55,6 +55,12 @@ vi.mock('../../lib/seo-service.js', () => ({
55
55
  dismissRedirectSuggestion: vi.fn(async () => ({})),
56
56
  exportRedirectsCsv: vi.fn(async () => ({ csv: '' })),
57
57
  importRedirectsCsv: vi.fn(async () => ({ imported: 0 })),
58
+ fetchRedirectRecoverySettings: vi.fn(async () => ({
59
+ aiRerank: false,
60
+ autoApply: false,
61
+ autoApplyMinConfidence: 0.85,
62
+ })),
63
+ saveRedirectRecoverySettings: vi.fn(async () => ({})),
58
64
  }))
59
65
 
60
66
  vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { beforeEach, describe, expect, it, vi } from 'vitest'
3
- import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
4
4
 
5
5
  /**
6
6
  * Content SEO tab with the inline editor pane (design split-pane pattern —
@@ -18,6 +18,9 @@ function makeRecord(over: Record<string, unknown> = {}) {
18
18
  url: 'https://example.com/',
19
19
  canonicalUrl: null,
20
20
  metaTitle: 'Home — Example',
21
+ metaTitlePage: 'Home',
22
+ siteName: 'Example',
23
+ titleTemplate: '%page% — %site%',
21
24
  metaDescription: 'Welcome to Example.',
22
25
  focusKeyword: null,
23
26
  keyTakeaway: null,
@@ -54,6 +57,11 @@ const fetchSeoContentRecord = vi.fn(async (entityType: string, entityId: string)
54
57
  })
55
58
  const updateSeoFields = vi.fn(async (_t: string, _i: string, _p: unknown) => ({}))
56
59
 
60
+ vi.mock('@actuate-media/cms-core/client', async (importOriginal) => {
61
+ const actual = await importOriginal<typeof import('@actuate-media/cms-core/client')>()
62
+ return { ...actual }
63
+ })
64
+
57
65
  vi.mock('../../lib/seo-service.js', () => ({
58
66
  fetchSeoContentRecords: () => fetchSeoContentRecords(),
59
67
  fetchSeoContentRecord: (t: string, i: string) => fetchSeoContentRecord(t, i),
@@ -99,8 +107,10 @@ describe('Content SEO inline editor pane', () => {
99
107
  expect(screen.getByRole('region', { name: 'SEO editor' })).toBeTruthy()
100
108
  expect(rowButton.getAttribute('aria-expanded')).toBe('true')
101
109
  await waitFor(() => expect(fetchSeoContentRecord).toHaveBeenCalledWith('page', 'p1'))
102
- // Loaded field values populate the form.
103
- expect(await screen.findByDisplayValue('Home — Example')).toBeTruthy()
110
+ // Form loads the stored page portion; preview applies the site title template.
111
+ expect(await screen.findByDisplayValue('Home')).toBeTruthy()
112
+ const editor = screen.getByRole('region', { name: 'SEO editor' })
113
+ expect(within(editor).getAllByText('Home — Example').length).toBeGreaterThan(0)
104
114
  })
105
115
 
106
116
  it('toggles the pane closed when the same row is clicked again', async () => {
@@ -118,7 +128,7 @@ describe('Content SEO inline editor pane', () => {
118
128
  render(<ContentTab />)
119
129
  fireEvent.click(await screen.findByRole('button', { name: /Home/ }))
120
130
 
121
- const titleInput = await screen.findByDisplayValue('Home — Example')
131
+ const titleInput = await screen.findByDisplayValue('Home')
122
132
  fireEvent.change(titleInput, { target: { value: 'New Meta Title' } })
123
133
  fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
124
134
 
@@ -120,7 +120,7 @@ export function CoauthorResultPopover({
120
120
  onClick={(e) => e.stopPropagation()}
121
121
  className="bg-card w-full max-w-2xl rounded-lg shadow-2xl outline-none"
122
122
  >
123
- <div className="border-border flex items-center justify-between border-b px-4 py-3">
123
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
124
124
  <div className="text-foreground flex items-center gap-2 text-sm font-medium">
125
125
  <Sparkles className="text-primary h-4 w-4" aria-hidden />
126
126
  <span>AI {coauthorActionLabel(action)}</span>
@@ -0,0 +1,149 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+ import { Search, X } from 'lucide-react'
5
+ import { SeoCharCount } from './seo/SeoCharCount.js'
6
+ import { SeoEditorPane } from './seo/SeoEditorPane.js'
7
+ import { InspectorExpandButton, ResizableAside } from './ui/ResizableAside.js'
8
+
9
+ const SEO_TITLE_MIN = 30
10
+ const SEO_TITLE_MAX = 60
11
+ const SEO_DESC_MIN = 120
12
+ const SEO_DESC_MAX = 160
13
+
14
+ const INSPECTOR_SHELL = 'bg-card border-border flex h-full flex-col border-l'
15
+ const LABEL = 'text-foreground mb-1 block text-xs font-medium'
16
+ const INPUT =
17
+ 'border-input bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
18
+
19
+ export interface EditorSeoAsideProps {
20
+ entityType: 'page' | 'post'
21
+ /** Saved document id — when absent, shows a draft-only meta form. */
22
+ entityId: string | null
23
+ title: string
24
+ seoTitle: string
25
+ seoDescription: string
26
+ canEdit: boolean
27
+ onClose: () => void
28
+ /** Sync basic meta fields on the in-memory editor model (draft flow). */
29
+ onDraftChange?: (patch: { seoTitle?: string; seoDescription?: string }) => void
30
+ /** Refetch the editor document after SEO API save (saved flow). */
31
+ onSaved?: () => void
32
+ }
33
+
34
+ function DraftSeoForm({
35
+ title,
36
+ seoTitle,
37
+ seoDescription,
38
+ canEdit,
39
+ onDraftChange,
40
+ }: Pick<
41
+ EditorSeoAsideProps,
42
+ 'title' | 'seoTitle' | 'seoDescription' | 'canEdit' | 'onDraftChange'
43
+ >) {
44
+ return (
45
+ <div className="flex-1 space-y-4 overflow-y-auto p-4">
46
+ <p className="border-border bg-muted text-muted-foreground rounded-md border p-4 text-sm">
47
+ Save a draft first to unlock the full SEO editor — focus keyword, social previews, schema,
48
+ robots, and AI assists. You can still set a basic title and description below; they are
49
+ saved with the document.
50
+ </p>
51
+ <div>
52
+ <div className="mb-1 flex items-center justify-between">
53
+ <label className={`${LABEL} mb-0`} htmlFor="editor-seo-title">
54
+ SEO title
55
+ </label>
56
+ <SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
57
+ </div>
58
+ <input
59
+ id="editor-seo-title"
60
+ className={INPUT}
61
+ value={seoTitle}
62
+ disabled={!canEdit}
63
+ placeholder={title || 'Defaults to the page title'}
64
+ onChange={(e) => onDraftChange?.({ seoTitle: e.target.value })}
65
+ />
66
+ </div>
67
+ <div>
68
+ <div className="mb-1 flex items-center justify-between">
69
+ <label className={`${LABEL} mb-0`} htmlFor="editor-seo-desc">
70
+ SEO description
71
+ </label>
72
+ <SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
73
+ </div>
74
+ <textarea
75
+ id="editor-seo-desc"
76
+ className={`${INPUT} min-h-[96px] resize-y`}
77
+ value={seoDescription}
78
+ disabled={!canEdit}
79
+ placeholder="Summarize this content for search results (120–160 characters)"
80
+ onChange={(e) => onDraftChange?.({ seoDescription: e.target.value })}
81
+ />
82
+ </div>
83
+ </div>
84
+ )
85
+ }
86
+
87
+ /** Right-side SEO panel for the Page / Post section editors. */
88
+ export function EditorSeoAside({
89
+ entityType,
90
+ entityId,
91
+ title,
92
+ seoTitle,
93
+ seoDescription,
94
+ canEdit,
95
+ onClose,
96
+ onDraftChange,
97
+ onSaved,
98
+ }: EditorSeoAsideProps) {
99
+ useEffect(() => {
100
+ function onKey(e: KeyboardEvent) {
101
+ if (e.key === 'Escape') onClose()
102
+ }
103
+ document.addEventListener('keydown', onKey)
104
+ return () => document.removeEventListener('keydown', onKey)
105
+ }, [onClose])
106
+
107
+ return (
108
+ <ResizableAside role="dialog" aria-label="SEO settings" className={INSPECTOR_SHELL}>
109
+ {entityId ? (
110
+ <SeoEditorPane
111
+ entityType={entityType}
112
+ entityId={entityId}
113
+ onClose={onClose}
114
+ onSaved={onSaved}
115
+ />
116
+ ) : (
117
+ <>
118
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
119
+ <div className="flex min-w-0 items-center gap-2">
120
+ <Search className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden />
121
+ <div className="min-w-0">
122
+ <h2 className="text-foreground truncate text-sm font-medium">SEO</h2>
123
+ <p className="text-muted-foreground truncate text-xs">{title || 'Untitled'}</p>
124
+ </div>
125
+ </div>
126
+ <div className="flex shrink-0 items-center gap-0.5">
127
+ <InspectorExpandButton />
128
+ <button
129
+ type="button"
130
+ onClick={onClose}
131
+ aria-label="Close SEO panel"
132
+ className="text-muted-foreground hover:text-foreground hover:bg-accent flex h-9 w-9 items-center justify-center rounded-md md:h-7 md:w-7"
133
+ >
134
+ <X className="h-4 w-4" aria-hidden />
135
+ </button>
136
+ </div>
137
+ </div>
138
+ <DraftSeoForm
139
+ title={title}
140
+ seoTitle={seoTitle}
141
+ seoDescription={seoDescription}
142
+ canEdit={canEdit}
143
+ onDraftChange={onDraftChange}
144
+ />
145
+ </>
146
+ )}
147
+ </ResizableAside>
148
+ )
149
+ }
@@ -138,7 +138,7 @@ export function MediaPickerModal({
138
138
  aria-describedby={undefined}
139
139
  className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-70 mx-4 flex max-h-[80vh] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl border shadow-2xl focus:outline-none"
140
140
  >
141
- <div className="border-border flex items-center justify-between border-b px-4 py-3">
141
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
142
142
  <Dialog.Title className="text-foreground text-lg font-medium">
143
143
  Insert Image
144
144
  </Dialog.Title>
@@ -242,7 +242,7 @@ export function SEOConfigPanel() {
242
242
 
243
243
  {/* Site-wide section */}
244
244
  <section className="border-border bg-card rounded-lg border">
245
- <header className="border-border flex items-center gap-2 border-b px-4 py-3">
245
+ <header className="border-border flex items-center gap-2 border-b px-6 py-4">
246
246
  <Globe className="text-primary h-4 w-4" />
247
247
  <h3 className="text-foreground text-sm font-medium">Site-wide defaults</h3>
248
248
  </header>
@@ -333,7 +333,7 @@ export function SEOConfigPanel() {
333
333
 
334
334
  {/* Per-collection section */}
335
335
  <section className="border-border bg-card rounded-lg border">
336
- <header className="border-border flex items-center gap-2 border-b px-4 py-3">
336
+ <header className="border-border flex items-center gap-2 border-b px-6 py-4">
337
337
  <FileText className="text-primary h-4 w-4" />
338
338
  <h3 className="text-foreground text-sm font-medium">Per-collection defaults</h3>
339
339
  <span className="text-muted-foreground text-xs">
@@ -142,7 +142,7 @@ export function SchedulePublishDialog({
142
142
  <Dialog.Portal container={adminPortalContainer()}>
143
143
  <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
144
144
  <Dialog.Content className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none">
145
- <div className="border-border flex items-center justify-between border-b px-4 py-3">
145
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
146
146
  <div className="flex items-center gap-2">
147
147
  <Calendar className="text-muted-foreground h-5 w-5" aria-hidden />
148
148
  <Dialog.Title className="text-foreground text-lg font-medium">
@@ -129,7 +129,7 @@ export function SharePreviewLinkDialog({
129
129
  <Dialog.Portal container={adminPortalContainer()}>
130
130
  <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
131
131
  <Dialog.Content className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none">
132
- <div className="border-border flex items-center justify-between border-b px-4 py-3">
132
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
133
133
  <div className="flex items-center gap-2">
134
134
  <Share2 className="text-muted-foreground h-5 w-5" aria-hidden />
135
135
  <Dialog.Title className="text-foreground text-lg font-medium">
@@ -8,6 +8,8 @@ import {
8
8
  } from '@actuate-media/cms-core/appearance'
9
9
  import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
10
10
 
11
+ import { getAdminRootElement } from '../lib/admin-root-element.js'
12
+
11
13
  /**
12
14
  * Admin appearance engine.
13
15
  *
@@ -119,7 +121,7 @@ const KEY_MOTION = 'actuate-reduce-motion'
119
121
  const KEY_BRAND = 'actuate-admin-brand'
120
122
 
121
123
  function adminRootEl(): HTMLElement | null {
122
- return document.querySelector('.actuate-admin')
124
+ return getAdminRootElement()
123
125
  }
124
126
 
125
127
  function applyColorMode(resolved: 'light' | 'dark') {
@@ -98,7 +98,7 @@ export function VersionHistory({
98
98
  aria-describedby={undefined}
99
99
  className="bg-card text-card-foreground border-border animate-in slide-in-from-right fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l shadow-xl focus:outline-none"
100
100
  >
101
- <div className="border-border flex items-center justify-between border-b px-4 py-3">
101
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
102
102
  <div className="flex items-center gap-2">
103
103
  <Clock className="text-muted-foreground h-5 w-5" aria-hidden />
104
104
  <Dialog.Title className="text-foreground text-lg font-medium">