@actuate-media/cms-admin 0.73.0 → 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 (161) hide show
  1. package/CHANGELOG.md +16 -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/theme-provider-accent.test.d.ts +2 -0
  9. package/dist/__tests__/components/theme-provider-accent.test.d.ts.map +1 -0
  10. package/dist/__tests__/components/theme-provider-accent.test.js +64 -0
  11. package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -0
  12. package/dist/__tests__/views/portal-container.test.js +13 -0
  13. package/dist/__tests__/views/portal-container.test.js.map +1 -1
  14. package/dist/__tests__/views/redirects-pane.render.test.js +6 -0
  15. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  16. package/dist/__tests__/views/seo-content-pane.render.test.js +13 -4
  17. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
  18. package/dist/actuate-admin.css +1 -1
  19. package/dist/components/CoauthorResultPopover.js +1 -1
  20. package/dist/components/EditorSeoAside.d.ts +20 -0
  21. package/dist/components/EditorSeoAside.d.ts.map +1 -0
  22. package/dist/components/EditorSeoAside.js +30 -0
  23. package/dist/components/EditorSeoAside.js.map +1 -0
  24. package/dist/components/MediaPickerModal.js +1 -1
  25. package/dist/components/SEOConfigPanel.js +1 -1
  26. package/dist/components/SchedulePublishDialog.js +1 -1
  27. package/dist/components/SharePreviewLinkDialog.js +1 -1
  28. package/dist/components/ThemeProvider.d.ts.map +1 -1
  29. package/dist/components/ThemeProvider.js +2 -1
  30. package/dist/components/ThemeProvider.js.map +1 -1
  31. package/dist/components/VersionHistory.js +1 -1
  32. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  33. package/dist/components/seo/SeoEditorPane.js +28 -8
  34. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  35. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  36. package/dist/components/seo/SeoIssueFixPanel.js +13 -3
  37. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  38. package/dist/components/seo/primitives.d.ts.map +1 -1
  39. package/dist/components/seo/primitives.js +3 -3
  40. package/dist/components/seo/primitives.js.map +1 -1
  41. package/dist/components/ui/Card.js +5 -5
  42. package/dist/components/ui/Card.js.map +1 -1
  43. package/dist/components/ui/InspectorPane.js +3 -3
  44. package/dist/components/ui/InspectorPane.js.map +1 -1
  45. package/dist/components/ui/PanelSection.d.ts +16 -0
  46. package/dist/components/ui/PanelSection.d.ts.map +1 -0
  47. package/dist/components/ui/PanelSection.js +15 -0
  48. package/dist/components/ui/PanelSection.js.map +1 -0
  49. package/dist/lib/admin-root-element.d.ts +9 -0
  50. package/dist/lib/admin-root-element.d.ts.map +1 -0
  51. package/dist/lib/admin-root-element.js +14 -0
  52. package/dist/lib/admin-root-element.js.map +1 -0
  53. package/dist/lib/portal-container.d.ts.map +1 -1
  54. package/dist/lib/portal-container.js +2 -3
  55. package/dist/lib/portal-container.js.map +1 -1
  56. package/dist/lib/seo-service.d.ts +89 -0
  57. package/dist/lib/seo-service.d.ts.map +1 -1
  58. package/dist/lib/seo-service.js +50 -2
  59. package/dist/lib/seo-service.js.map +1 -1
  60. package/dist/views/ApiKeys.js +2 -2
  61. package/dist/views/Dashboard.d.ts.map +1 -1
  62. package/dist/views/Dashboard.js +12 -14
  63. package/dist/views/Dashboard.js.map +1 -1
  64. package/dist/views/MediaBrowser.d.ts.map +1 -1
  65. package/dist/views/MediaBrowser.js +35 -6
  66. package/dist/views/MediaBrowser.js.map +1 -1
  67. package/dist/views/PageEditor.js +1 -1
  68. package/dist/views/Posts/PostTypeEditor.js +3 -3
  69. package/dist/views/SEO.d.ts.map +1 -1
  70. package/dist/views/SEO.js +4 -2
  71. package/dist/views/SEO.js.map +1 -1
  72. package/dist/views/ScriptTags.js +1 -1
  73. package/dist/views/ScriptTags.js.map +1 -1
  74. package/dist/views/Users.js +1 -1
  75. package/dist/views/page-editor/EditorTopBar.d.ts +4 -1
  76. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  77. package/dist/views/page-editor/EditorTopBar.js +5 -3
  78. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  79. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  80. package/dist/views/page-editor/PageSectionEditor.js +24 -1
  81. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  82. package/dist/views/page-editor/SectionInspector.js +2 -2
  83. package/dist/views/page-editor/SectionsPanel.js +1 -1
  84. package/dist/views/post-editor/PostHeaderPanel.js +1 -1
  85. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  86. package/dist/views/post-editor/PostSectionEditor.js +24 -1
  87. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  88. package/dist/views/seo/LinksTab.d.ts +4 -0
  89. package/dist/views/seo/LinksTab.d.ts.map +1 -0
  90. package/dist/views/seo/LinksTab.js +111 -0
  91. package/dist/views/seo/LinksTab.js.map +1 -0
  92. package/dist/views/seo/OverviewTab.js +1 -1
  93. package/dist/views/seo/OverviewTab.js.map +1 -1
  94. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  95. package/dist/views/seo/RedirectsTab.js +39 -6
  96. package/dist/views/seo/RedirectsTab.js.map +1 -1
  97. package/dist/views/seo/TechnicalTab.js +2 -2
  98. package/dist/views/settings/AISettingsTab.js +1 -1
  99. package/dist/views/settings/AiFeaturesCard.js +1 -1
  100. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  101. package/dist/views/settings/AppearanceTab.js +1 -1
  102. package/dist/views/settings/BrandVoiceCard.js +1 -1
  103. package/dist/views/settings/EmailSettingsTab.js +2 -2
  104. package/dist/views/settings/GeneralSettingsTab.js +1 -1
  105. package/dist/views/settings/SecurityStatusCard.js +1 -1
  106. package/dist/views/settings/SeoSettingsTab.js +4 -4
  107. package/dist/views/settings/UpdatesTab.js +2 -2
  108. package/dist/views/settings/components.js +3 -3
  109. package/dist/views/settings/components.js.map +1 -1
  110. package/package.json +2 -2
  111. package/src/AdminRoot.tsx +3 -2
  112. package/src/LoginRoot.tsx +2 -1
  113. package/src/__tests__/components/theme-provider-accent.test.tsx +84 -0
  114. package/src/__tests__/views/portal-container.test.tsx +14 -0
  115. package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
  116. package/src/__tests__/views/seo-content-pane.render.test.tsx +14 -4
  117. package/src/components/CoauthorResultPopover.tsx +1 -1
  118. package/src/components/EditorSeoAside.tsx +149 -0
  119. package/src/components/MediaPickerModal.tsx +1 -1
  120. package/src/components/SEOConfigPanel.tsx +2 -2
  121. package/src/components/SchedulePublishDialog.tsx +1 -1
  122. package/src/components/SharePreviewLinkDialog.tsx +1 -1
  123. package/src/components/ThemeProvider.tsx +3 -1
  124. package/src/components/VersionHistory.tsx +1 -1
  125. package/src/components/seo/SeoEditorPane.tsx +41 -12
  126. package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
  127. package/src/components/seo/primitives.tsx +7 -3
  128. package/src/components/ui/Card.tsx +5 -5
  129. package/src/components/ui/InspectorPane.tsx +3 -3
  130. package/src/components/ui/PanelSection.tsx +53 -0
  131. package/src/lib/admin-root-element.ts +15 -0
  132. package/src/lib/portal-container.ts +3 -2
  133. package/src/lib/seo-service.ts +143 -2
  134. package/src/views/ApiKeys.tsx +2 -2
  135. package/src/views/Dashboard.tsx +40 -53
  136. package/src/views/MediaBrowser.tsx +62 -12
  137. package/src/views/PageEditor.tsx +1 -1
  138. package/src/views/Posts/PostTypeEditor.tsx +3 -3
  139. package/src/views/SEO.tsx +6 -0
  140. package/src/views/ScriptTags.tsx +2 -2
  141. package/src/views/Users.tsx +1 -1
  142. package/src/views/page-editor/EditorTopBar.tsx +24 -0
  143. package/src/views/page-editor/PageSectionEditor.tsx +43 -3
  144. package/src/views/page-editor/SectionInspector.tsx +2 -2
  145. package/src/views/page-editor/SectionsPanel.tsx +1 -1
  146. package/src/views/post-editor/PostHeaderPanel.tsx +1 -1
  147. package/src/views/post-editor/PostSectionEditor.tsx +43 -3
  148. package/src/views/seo/LinksTab.tsx +462 -0
  149. package/src/views/seo/OverviewTab.tsx +1 -1
  150. package/src/views/seo/RedirectsTab.tsx +97 -4
  151. package/src/views/seo/TechnicalTab.tsx +2 -2
  152. package/src/views/settings/AISettingsTab.tsx +3 -3
  153. package/src/views/settings/AiFeaturesCard.tsx +1 -1
  154. package/src/views/settings/AppearanceTab.tsx +1 -1
  155. package/src/views/settings/BrandVoiceCard.tsx +2 -2
  156. package/src/views/settings/EmailSettingsTab.tsx +2 -2
  157. package/src/views/settings/GeneralSettingsTab.tsx +3 -3
  158. package/src/views/settings/SecurityStatusCard.tsx +1 -1
  159. package/src/views/settings/SeoSettingsTab.tsx +6 -6
  160. package/src/views/settings/UpdatesTab.tsx +3 -3
  161. package/src/views/settings/components.tsx +3 -3
@@ -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">
@@ -7,10 +7,19 @@
7
7
  * assists, and `updateSeoFields` save path as the drawer; only the
8
8
  * presentation pattern changed.
9
9
  */
10
- import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
10
+ import {
11
+ cloneElement,
12
+ isValidElement,
13
+ useEffect,
14
+ useId,
15
+ useMemo,
16
+ useState,
17
+ type ReactElement,
18
+ } from 'react'
11
19
  import { Sparkles, Check, Loader2, Wand2 } from 'lucide-react'
12
20
  import { toast } from 'sonner'
13
21
  import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
22
+ import { resolveRenderedMetaTitle } from '@actuate-media/cms-core/client'
14
23
  import {
15
24
  fetchSeoContentRecord,
16
25
  updateSeoFields,
@@ -148,7 +157,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
148
157
  }
149
158
  setRecord(rec)
150
159
  setForm({
151
- metaTitle: rec.metaTitle ?? '',
160
+ metaTitle: rec.metaTitlePage ?? '',
152
161
  metaDescription: rec.metaDescription ?? '',
153
162
  canonicalUrl: rec.canonicalUrl ?? '',
154
163
  focusKeyword: rec.focusKeyword ?? '',
@@ -182,11 +191,20 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
182
191
  return
183
192
  }
184
193
  if (result.text) {
185
- update({ [field]: result.text } as Partial<SeoFieldPayload>)
194
+ const targetField =
195
+ field === 'ogTitle' ? 'metaTitle' : field === 'ogDescription' ? 'metaDescription' : field
196
+ const patch: Partial<SeoFieldPayload> = { [targetField]: result.text }
197
+ if (field === 'ogTitle') patch.ogTitle = ''
198
+ if (field === 'ogDescription') patch.ogDescription = ''
199
+ update(patch)
186
200
  if (result.brandAlignment) {
187
- setAiAlignments((prev) => ({ ...prev, [field]: result.brandAlignment }))
201
+ setAiAlignments((prev) => ({ ...prev, [targetField]: result.brandAlignment }))
188
202
  }
189
- toast.success('AI suggestion applied — review before saving.')
203
+ toast.success(
204
+ field === 'ogTitle' || field === 'ogDescription'
205
+ ? 'AI suggestion applied to meta tags — OG inherits from meta unless you override.'
206
+ : 'AI suggestion applied — review before saving.',
207
+ )
190
208
  } else {
191
209
  toast.error('No suggestion was returned.')
192
210
  }
@@ -256,7 +274,18 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
256
274
  onSaved?.()
257
275
  }
258
276
 
259
- const title = form.metaTitle || record?.title || 'Untitled'
277
+ const renderedTitle = useMemo(() => {
278
+ if (!record) return form.metaTitle?.trim() || 'Untitled'
279
+ return resolveRenderedMetaTitle({
280
+ metaTitlePage: form.metaTitle,
281
+ documentTitle: record.title,
282
+ site: {
283
+ siteName: record.siteName ?? undefined,
284
+ titleTemplate: record.titleTemplate ?? undefined,
285
+ },
286
+ })
287
+ }, [form.metaTitle, record])
288
+
260
289
  const desc = form.metaDescription || 'No meta description set. Search engines may generate one.'
261
290
  const url = record?.url ?? ''
262
291
 
@@ -285,11 +314,11 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
285
314
 
286
315
  <InspectorPaneBody>
287
316
  {loading ? (
288
- <div className="px-4 py-3.5">
317
+ <div className="px-5 py-4">
289
318
  <SeoLoading />
290
319
  </div>
291
320
  ) : error ? (
292
- <div className="px-4 py-3.5">
321
+ <div className="px-5 py-4">
293
322
  <SeoErrorState message={error} />
294
323
  </div>
295
324
  ) : record ? (
@@ -297,7 +326,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
297
326
  <InspectorPaneSection title="Google Preview">
298
327
  <div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
299
328
  <p className="truncate text-[11.5px] text-[var(--suc)]">{url}</p>
300
- <p className="text-[15px] leading-snug text-[var(--acc)]">{title}</p>
329
+ <p className="text-[15px] leading-snug text-[var(--acc)]">{renderedTitle}</p>
301
330
  <p className="line-clamp-2 text-[12px] text-[var(--sub)]">{desc}</p>
302
331
  </div>
303
332
  </InspectorPaneSection>
@@ -316,7 +345,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
316
345
  className={inputCls}
317
346
  value={form.metaTitle ?? ''}
318
347
  onChange={(e) => update({ metaTitle: e.target.value })}
319
- placeholder="Page title for search results"
348
+ placeholder="Page-specific title (%page% in your site template)"
320
349
  />
321
350
  </PaneField>
322
351
  <PaneField
@@ -401,10 +430,10 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
401
430
  {url.replace(/^https?:\/\//, '').split('/')[0]}
402
431
  </p>
403
432
  <p className="truncate text-[12.5px] font-medium text-[var(--txt)]">
404
- {form.ogTitle || title}
433
+ {form.ogTitle || renderedTitle}
405
434
  </p>
406
435
  <p className="line-clamp-1 text-[12px] text-[var(--sub)]">
407
- {form.ogDescription || desc}
436
+ {form.ogDescription || form.metaDescription || desc}
408
437
  </p>
409
438
  </div>
410
439
  </div>
@@ -321,7 +321,11 @@ export function SeoIssueFixPanel({
321
321
  toast.error(res.error)
322
322
  return
323
323
  }
324
- toast.success('Fix applied and issue resolved.')
324
+ toast.success(
325
+ suggestion?.fixStrategy === 'fix-link'
326
+ ? 'Link updated in content and issue resolved.'
327
+ : 'Redirect created and issue resolved.',
328
+ )
325
329
  onChanged?.()
326
330
  }
327
331
 
@@ -408,11 +412,15 @@ export function SeoIssueFixPanel({
408
412
  {suggestion.source === 'ai' && !suggestion.aiUnavailable ? (
409
413
  <span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium">
410
414
  <Sparkles className="h-3 w-3" aria-hidden />
411
- AI suggestion
415
+ {suggestion.fixStrategy === 'fix-link'
416
+ ? 'Fix link in content'
417
+ : 'AI suggestion'}
412
418
  </span>
413
419
  ) : suggestion.source === 'deterministic' ? (
414
420
  <span className="bg-muted text-muted-foreground rounded-full px-2.5 py-0.5 text-xs font-medium">
415
- Rule-based fix
421
+ {suggestion.fixStrategy === 'fix-link'
422
+ ? 'Content link fix'
423
+ : 'Rule-based fix'}
416
424
  </span>
417
425
  ) : null}
418
426
  </div>
@@ -483,7 +491,11 @@ export function SeoIssueFixPanel({
483
491
  disabled={busy}
484
492
  >
485
493
  <Check className="h-4 w-4" aria-hidden />
486
- {busy ? 'Applying…' : 'Approve fix'}
494
+ {busy
495
+ ? 'Applying…'
496
+ : suggestion.fixStrategy === 'fix-link'
497
+ ? 'Fix link in content'
498
+ : 'Approve redirect'}
487
499
  </button>
488
500
  ) : inlineFix && suggestion?.autoFixable && !inlineApplyEnabled ? (
489
501
  <PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
@@ -153,14 +153,18 @@ export function SectionCard({
153
153
  {action}
154
154
  </CardHeader>
155
155
  )}
156
- <CardContent className={title || action ? 'pt-0' : ''}>{children}</CardContent>
156
+ <CardContent className={title || action ? '' : 'pt-6!'}>{children}</CardContent>
157
157
  </Card>
158
158
  )
159
159
  }
160
160
 
161
161
  export function SeoLoading({ label = 'Loading…' }: { label?: string }) {
162
162
  return (
163
- <div className="flex h-64 items-center justify-center" role="status" aria-live="polite">
163
+ <div
164
+ className="flex min-h-32 items-center justify-center py-12"
165
+ role="status"
166
+ aria-live="polite"
167
+ >
164
168
  <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
165
169
  <span className="sr-only">{label}</span>
166
170
  </div>
@@ -195,7 +199,7 @@ export function SeoEmptyState({
195
199
  export function SeoErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
196
200
  return (
197
201
  <div
198
- className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-3"
202
+ className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-4"
199
203
  role="alert"
200
204
  >
201
205
  <AlertTriangle className="text-destructive h-5 w-5 shrink-0" aria-hidden />
@@ -10,8 +10,8 @@ const card = cv(
10
10
  /** Stat / compact surfaces (`.dstat`). */
11
11
  sm: 'px-4 py-3.5',
12
12
  /** Simple padded panel (`.dcard-b`). */
13
- md: 'px-[18px] py-4',
14
- lg: 'px-[18px] py-6',
13
+ md: 'px-6 py-4',
14
+ lg: 'px-6 py-6',
15
15
  },
16
16
  elevation: {
17
17
  flat: 'shadow-none',
@@ -55,7 +55,7 @@ export function CardHeader({
55
55
  }: HTMLAttributes<HTMLDivElement> & { bordered?: boolean }) {
56
56
  return (
57
57
  <div
58
- className={[bordered ? 'border-b border-[var(--bdr)] px-[18px] py-[14px]' : '', className]
58
+ className={[bordered ? 'border-b border-[var(--bdr)] px-6 pt-6 pb-4' : '', className]
59
59
  .filter(Boolean)
60
60
  .join(' ')}
61
61
  {...rest}
@@ -96,7 +96,7 @@ export function CardDescription({
96
96
  export function CardContent({ className = '', children, ...rest }: HTMLAttributes<HTMLDivElement>) {
97
97
  return (
98
98
  <div
99
- className={`px-[18px] py-4 text-[length:var(--fs-body)] leading-[1.55] text-[var(--sub)] ${className}`}
99
+ className={`px-6 pt-4 pb-6 text-[length:var(--fs-body)] leading-[1.55] text-[var(--sub)] ${className}`}
100
100
  {...rest}
101
101
  >
102
102
  {children}
@@ -107,7 +107,7 @@ export function CardContent({ className = '', children, ...rest }: HTMLAttribute
107
107
  export function CardFooter({ className = '', children, ...rest }: HTMLAttributes<HTMLDivElement>) {
108
108
  return (
109
109
  <div
110
- className={`flex items-center justify-end gap-2 border-t border-[var(--bdr)] px-[18px] py-3 ${className}`}
110
+ className={`flex items-center justify-end gap-2 border-t border-[var(--bdr)] px-6 py-4 ${className}`}
111
111
  {...rest}
112
112
  >
113
113
  {children}
@@ -86,7 +86,7 @@ export function InspectorPaneHeader({
86
86
  closeLabel?: string
87
87
  }) {
88
88
  return (
89
- <div className="flex items-start gap-2 border-b border-[var(--bdr)] px-4 py-3.5">
89
+ <div className="flex items-start gap-2 border-b border-[var(--bdr)] px-5 py-4">
90
90
  <div className="min-w-0 flex-1">{children}</div>
91
91
  <button
92
92
  type="button"
@@ -115,7 +115,7 @@ export function InspectorPaneSection({
115
115
  children: ReactNode
116
116
  }) {
117
117
  return (
118
- <div className="border-b border-[var(--bdr)] px-4 py-3.5 last:border-b-0">
118
+ <div className="border-b border-[var(--bdr)] px-5 py-4 last:border-b-0">
119
119
  {title && (
120
120
  <div className="mb-2.5 flex items-baseline gap-2">
121
121
  <span className="text-[10.5px] font-semibold tracking-[0.6px] text-[var(--muted)] uppercase">
@@ -130,5 +130,5 @@ export function InspectorPaneSection({
130
130
  }
131
131
 
132
132
  export function InspectorPaneFooter({ children }: { children: ReactNode }) {
133
- return <div className="border-t border-[var(--bdr)] px-4 py-3.5">{children}</div>
133
+ return <div className="border-t border-[var(--bdr)] px-5 py-4">{children}</div>
134
134
  }
@@ -0,0 +1,53 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+
5
+ /**
6
+ * Bordered panel with a header divider — Dashboard, modals, and list-heavy views.
7
+ * Matches Settings/Section card rhythm: `px-6` gutters, `py-4` header, body flush
8
+ * below the divider with its own padding.
9
+ */
10
+ export function PanelSection({
11
+ title,
12
+ description,
13
+ action,
14
+ children,
15
+ className = '',
16
+ }: {
17
+ title?: ReactNode
18
+ description?: ReactNode
19
+ action?: ReactNode
20
+ children: ReactNode
21
+ className?: string
22
+ }) {
23
+ return (
24
+ <section
25
+ className={`bg-card border-border overflow-hidden rounded-xl border shadow-sm ${className}`}
26
+ >
27
+ {(title || action) && (
28
+ <header className="border-border flex items-center justify-between gap-3 border-b px-6 py-4">
29
+ <div className="min-w-0">
30
+ {title &&
31
+ (typeof title === 'string' ? (
32
+ <h2 className="text-foreground text-sm font-medium">{title}</h2>
33
+ ) : (
34
+ title
35
+ ))}
36
+ {description &&
37
+ (typeof description === 'string' ? (
38
+ <p className="text-muted-foreground mt-0.5 text-xs">{description}</p>
39
+ ) : (
40
+ description
41
+ ))}
42
+ </div>
43
+ {action}
44
+ </header>
45
+ )}
46
+ {children}
47
+ </section>
48
+ )
49
+ }
50
+
51
+ /** Standard inset for list rows / table bodies inside a {@link PanelSection}. */
52
+ export const PANEL_LIST_ROW_CLASS =
53
+ 'hover:bg-accent/50 focus-visible:bg-accent/50 w-full px-6 py-4 text-left transition-colors'