@actuate-media/cms-admin 0.72.1 → 0.72.3

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 (65) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/__tests__/components/toggle.test.d.ts +2 -0
  3. package/dist/__tests__/components/toggle.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/toggle.test.js +17 -0
  5. package/dist/__tests__/components/toggle.test.js.map +1 -0
  6. package/dist/__tests__/views/post-fields-modal.render.test.js +38 -0
  7. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  8. package/dist/actuate-admin.css +1 -1
  9. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  10. package/dist/components/MediaPickerModal.js +1 -1
  11. package/dist/components/MediaPickerModal.js.map +1 -1
  12. package/dist/components/TagInput.js +1 -1
  13. package/dist/components/TagInput.js.map +1 -1
  14. package/dist/components/ui/Modal.d.ts +17 -1
  15. package/dist/components/ui/Modal.d.ts.map +1 -1
  16. package/dist/components/ui/Modal.js +29 -3
  17. package/dist/components/ui/Modal.js.map +1 -1
  18. package/dist/components/ui/Toggle.d.ts.map +1 -1
  19. package/dist/components/ui/Toggle.js +4 -3
  20. package/dist/components/ui/Toggle.js.map +1 -1
  21. package/dist/fields/RichTextField.d.ts +3 -1
  22. package/dist/fields/RichTextField.d.ts.map +1 -1
  23. package/dist/fields/RichTextField.js +2 -2
  24. package/dist/fields/RichTextField.js.map +1 -1
  25. package/dist/views/Dashboard.d.ts.map +1 -1
  26. package/dist/views/Dashboard.js +1 -1
  27. package/dist/views/Dashboard.js.map +1 -1
  28. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  29. package/dist/views/page-editor/EditorCanvas.js +1 -1
  30. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  31. package/dist/views/page-editor/PagePreview.js +1 -1
  32. package/dist/views/page-editor/PagePreview.js.map +1 -1
  33. package/dist/views/page-editor/SectionInspector.d.ts +4 -1
  34. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  35. package/dist/views/page-editor/SectionInspector.js +8 -2
  36. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  37. package/dist/views/post-editor/PostEditorCanvas.js +1 -1
  38. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  39. package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
  40. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  41. package/dist/views/post-editor/PostFieldsModal.js +152 -13
  42. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  43. package/dist/views/post-editor/PostPreview.js +1 -1
  44. package/dist/views/post-editor/PostPreview.js.map +1 -1
  45. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  46. package/dist/views/post-editor/PostSectionEditor.js +6 -1
  47. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/__tests__/components/toggle.test.tsx +17 -0
  50. package/src/__tests__/views/post-fields-modal.render.test.tsx +54 -0
  51. package/src/components/MediaPickerModal.tsx +6 -2
  52. package/src/components/TagInput.tsx +1 -1
  53. package/src/components/ui/Modal.tsx +46 -3
  54. package/src/components/ui/Toggle.tsx +4 -3
  55. package/src/fields/RichTextField.tsx +4 -1
  56. package/src/styles/actuate-tokens.css +7 -2
  57. package/src/styles/theme.css +34 -8
  58. package/src/views/Dashboard.tsx +2 -1
  59. package/src/views/page-editor/EditorCanvas.tsx +4 -1
  60. package/src/views/page-editor/PagePreview.tsx +1 -1
  61. package/src/views/page-editor/SectionInspector.tsx +23 -1
  62. package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
  63. package/src/views/post-editor/PostFieldsModal.tsx +412 -174
  64. package/src/views/post-editor/PostPreview.tsx +1 -1
  65. package/src/views/post-editor/PostSectionEditor.tsx +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.72.1",
3
+ "version": "0.72.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -0,0 +1,17 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import { cleanup, render, screen } from '@testing-library/react'
4
+ import { Toggle } from '../../components/ui/Toggle.js'
5
+
6
+ afterEach(cleanup)
7
+
8
+ describe('Toggle', () => {
9
+ it('positions the thumb with design-system absolute offsets', () => {
10
+ render(<Toggle checked={false} onCheckedChange={() => {}} aria-label="Test toggle" />)
11
+ const thumb = screen.getByRole('switch').querySelector('span')
12
+ expect(thumb?.className).toContain('absolute')
13
+ expect(thumb?.className).toContain('top-[2.5px]')
14
+ expect(thumb?.className).toContain('left-[2.5px]')
15
+ expect(thumb?.className).toContain('data-[state=checked]:left-[19.5px]')
16
+ })
17
+ })
@@ -21,6 +21,17 @@ vi.mock('../../fields/RichTextField.js', () => ({
21
21
  RichTextField: () => <div data-testid="rich-text" />,
22
22
  }))
23
23
 
24
+ vi.mock('../../lib/coauthor-client.js', () => ({
25
+ coauthorText: vi.fn(async () => ({
26
+ ok: true,
27
+ result: { action: 'compress', text: 'AI excerpt' },
28
+ })),
29
+ }))
30
+
31
+ vi.mock('../../lib/seo-service.js', () => ({
32
+ generateSeoField: vi.fn(async () => ({ text: 'AI SEO title' })),
33
+ }))
34
+
24
35
  const { PostFieldsModal } = await import('../../views/post-editor/PostFieldsModal.js')
25
36
  type ModalProps = Parameters<typeof PostFieldsModal>[0]
26
37
 
@@ -123,4 +134,47 @@ describe('PostFieldsModal', () => {
123
134
  expect.objectContaining({ category: 'tutorials', tags: ['AI', 'CMS'] }),
124
135
  )
125
136
  })
137
+
138
+ it('derives slug from title until the slug field is edited', () => {
139
+ render(
140
+ <PostFieldsModal
141
+ open
142
+ post={basePost({ title: '', slug: '' })}
143
+ canEdit
144
+ onClose={() => {}}
145
+ onSave={() => {}}
146
+ />,
147
+ )
148
+ fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Hello World' } })
149
+ expect((screen.getByLabelText(/^Slug/) as HTMLInputElement).value).toBe('hello-world')
150
+ fireEvent.change(screen.getByLabelText(/^Slug/), { target: { value: 'custom-slug' } })
151
+ fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Changed Title' } })
152
+ expect((screen.getByLabelText(/^Slug/) as HTMLInputElement).value).toBe('custom-slug')
153
+ })
154
+
155
+ it('exposes AI generate controls for excerpt and SEO fields', () => {
156
+ render(<PostFieldsModal open post={basePost()} canEdit onClose={() => {}} onSave={() => {}} />)
157
+ expect(screen.getByRole('button', { name: 'Generate excerpt from content' })).toBeTruthy()
158
+ expect(screen.getByRole('button', { name: 'Generate SEO title from content' })).toBeTruthy()
159
+ expect(
160
+ screen.getByRole('button', { name: 'Generate SEO description from content' }),
161
+ ).toBeTruthy()
162
+ })
163
+
164
+ it('prompts before discarding when Cancel is clicked with unsaved edits', () => {
165
+ const onClose = vi.fn()
166
+ render(<PostFieldsModal open post={basePost()} canEdit onClose={onClose} onSave={() => {}} />)
167
+ fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Changed title' } })
168
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
169
+ expect(onClose).not.toHaveBeenCalled()
170
+ expect(screen.getByRole('dialog', { name: 'Discard unsaved changes?' })).toBeTruthy()
171
+ })
172
+
173
+ it('closes without prompting when nothing changed', () => {
174
+ const onClose = vi.fn()
175
+ render(<PostFieldsModal open post={basePost()} canEdit onClose={onClose} onSave={() => {}} />)
176
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
177
+ expect(onClose).toHaveBeenCalledTimes(1)
178
+ expect(screen.queryByRole('dialog', { name: 'Discard unsaved changes?' })).toBeNull()
179
+ })
126
180
  })
@@ -129,10 +129,14 @@ export function MediaPickerModal({
129
129
  return (
130
130
  <Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
131
131
  <Dialog.Portal container={adminPortalContainer()}>
132
- <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
132
+ {/*
133
+ z-70 so this picker stacks above the z-60 form modals that embed it
134
+ (PostFieldsModal featured image, TipTap insert image, etc.).
135
+ */}
136
+ <Dialog.Overlay className="fixed inset-0 z-70 bg-black/40" />
133
137
  <Dialog.Content
134
138
  aria-describedby={undefined}
135
- className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 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"
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"
136
140
  >
137
141
  <div className="border-border flex items-center justify-between border-b px-4 py-3">
138
142
  <Dialog.Title className="text-foreground text-lg font-medium">
@@ -35,7 +35,7 @@ interface FieldValuesResponse {
35
35
  }
36
36
 
37
37
  const INPUT =
38
- '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'
38
+ 'border-border 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'
39
39
 
40
40
  export function TagInput({
41
41
  id,
@@ -5,12 +5,23 @@ import { X } from 'lucide-react'
5
5
  import { type ReactNode, type RefObject } from 'react'
6
6
  import { adminPortalContainer } from '../../lib/portal-container.js'
7
7
 
8
+ const MODAL_SIZE_CLASS = {
9
+ md: 'max-w-lg',
10
+ lg: 'max-w-3xl',
11
+ /** Wide form dialogs (post/page field editors) — body scrolls inside the shell. */
12
+ xl: 'flex max-h-[min(92vh,880px)] max-w-5xl flex-col',
13
+ } as const
14
+
15
+ export type ModalSize = keyof typeof MODAL_SIZE_CLASS
16
+
8
17
  export interface ModalProps {
9
18
  open: boolean
10
19
  onClose: () => void
11
20
  title: string
12
21
  children: ReactNode
13
22
  actions?: ReactNode
23
+ /** Dialog width preset. Defaults to `md` (512px). */
24
+ size?: ModalSize
14
25
  /**
15
26
  * Accessible description for the dialog, announced after the title. Optional —
16
27
  * omit when the body content is self-explanatory.
@@ -22,6 +33,12 @@ export interface ModalProps {
22
33
  * form dialogs where the user should land in the first input.
23
34
  */
24
35
  initialFocusRef?: RefObject<HTMLElement | null>
36
+ /**
37
+ * When set, overlay click, Escape, and the header X invoke this instead of
38
+ * closing immediately — use to confirm before discarding unsaved edits.
39
+ * The dialog stays open until the parent calls `onClose()`.
40
+ */
41
+ onDismiss?: () => void
25
42
  }
26
43
 
27
44
  /**
@@ -41,15 +58,39 @@ export function Modal({
41
58
  title,
42
59
  children,
43
60
  actions,
61
+ size = 'md',
44
62
  description,
45
63
  initialFocusRef,
64
+ onDismiss,
46
65
  }: ModalProps) {
66
+ const scrollBody = size === 'xl'
67
+
68
+ function handleDismissAttempt() {
69
+ if (onDismiss) onDismiss()
70
+ else onClose()
71
+ }
72
+
47
73
  return (
48
- <Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
74
+ <Dialog.Root
75
+ open={open}
76
+ onOpenChange={(next) => {
77
+ if (!next) handleDismissAttempt()
78
+ }}
79
+ >
49
80
  <Dialog.Portal container={adminPortalContainer()}>
50
81
  <Dialog.Overlay className="fixed inset-0 z-60 bg-black/50" />
51
82
  <Dialog.Content
52
- className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none"
83
+ className={`bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none ${MODAL_SIZE_CLASS[size]}`}
84
+ onInteractOutside={(event) => {
85
+ if (!onDismiss) return
86
+ event.preventDefault()
87
+ handleDismissAttempt()
88
+ }}
89
+ onEscapeKeyDown={(event) => {
90
+ if (!onDismiss) return
91
+ event.preventDefault()
92
+ handleDismissAttempt()
93
+ }}
53
94
  onOpenAutoFocus={
54
95
  initialFocusRef
55
96
  ? (event) => {
@@ -78,7 +119,9 @@ export function Modal({
78
119
  {description}
79
120
  </Dialog.Description>
80
121
  )}
81
- <div className="px-6 py-4">{children}</div>
122
+ <div className={scrollBody ? 'min-h-0 flex-1 overflow-y-auto px-6 py-4' : 'px-6 py-4'}>
123
+ {children}
124
+ </div>
82
125
  {actions && (
83
126
  <div className="border-border flex justify-end gap-2 border-t px-6 py-4">{actions}</div>
84
127
  )}
@@ -4,7 +4,7 @@ import * as Switch from '@radix-ui/react-switch'
4
4
  import { cv, type VariantProps } from '../../lib/cv.js'
5
5
 
6
6
  const toggleTrack = cv(
7
- 'relative inline-flex shrink-0 cursor-pointer rounded-[var(--r-pill)] transition-colors duration-[var(--motion-base)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
7
+ 'relative inline-block shrink-0 cursor-pointer rounded-[var(--r-pill)] border-0 p-0 transition-colors duration-[var(--motion-base)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
8
8
  {
9
9
  variants: {
10
10
  variant: {
@@ -26,11 +26,12 @@ const toggleTrack = cv(
26
26
  )
27
27
 
28
28
  const toggleThumb = cv(
29
- 'pointer-events-none block rounded-full bg-[var(--toggle-thumb)] shadow-[0_1px_3px_rgba(0,0,0,0.2)] transition-transform duration-[var(--motion-base)]',
29
+ 'pointer-events-none absolute top-[2.5px] left-[2.5px] block rounded-full bg-[var(--toggle-thumb)] shadow-[0_1px_3px_rgba(0,0,0,0.2)] transition-[left] duration-[var(--motion-base)] data-[state=checked]:left-[19.5px]',
30
30
  {
31
31
  variants: {
32
32
  size: {
33
- md: 'h-4 w-4 translate-x-0.5 data-[state=checked]:translate-x-[17.5px]',
33
+ /** Matches `.tog-thumb` in the design system handoff (16×16). */
34
+ md: 'h-4 w-4',
34
35
  },
35
36
  },
36
37
  defaultVariants: {
@@ -8,6 +8,8 @@ export interface RichTextFieldProps {
8
8
  onChange: (value: string) => void
9
9
  required?: boolean
10
10
  helpText?: string
11
+ /** Passed through to the underlying editor shell (e.g. max-height). */
12
+ className?: string
11
13
  }
12
14
 
13
15
  export function RichTextField({
@@ -16,6 +18,7 @@ export function RichTextField({
16
18
  onChange,
17
19
  required,
18
20
  helpText,
21
+ className,
19
22
  }: RichTextFieldProps) {
20
23
  return (
21
24
  <div>
@@ -23,7 +26,7 @@ export function RichTextField({
23
26
  {label}
24
27
  {required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
25
28
  </label>
26
- <TipTapEditor content={value} onChange={onChange} />
29
+ <TipTapEditor content={value} onChange={onChange} className={className} />
27
30
  {helpText && <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>}
28
31
  </div>
29
32
  )
@@ -14,7 +14,11 @@
14
14
  /* Text */
15
15
  --txt: #1a1827;
16
16
  --sub: #6b6878;
17
- --muted: #b0adbe;
17
+ /* Deviates from tokens.json's #B0ADBE (2.2:1 on white — illegible). The
18
+ admin uses --muted as real text (section labels, hints, icons), so it
19
+ must meet WCAG AA (>=4.5:1) on --card and --bg while staying one step
20
+ lighter than --sub to preserve the text hierarchy. */
21
+ --muted: #716e83;
18
22
 
19
23
  /* Accent */
20
24
  --acc: #7c3aed;
@@ -79,7 +83,8 @@
79
83
 
80
84
  --txt: #eae8f5;
81
85
  --sub: #8e8ba8;
82
- --muted: #504e68;
86
+ /* Same AA deviation as light mode (tokens.json #504E68 is 2.1:1 on card). */
87
+ --muted: #8784a2;
83
88
 
84
89
  --acc: #9d71f0;
85
90
  --acc-l: #231950;
@@ -137,7 +137,19 @@
137
137
  @apply border-border outline-ring/50;
138
138
  }
139
139
 
140
- .actuate-admin {
140
+ /*
141
+ * Base typography is written with doubled `.actuate-admin.actuate-admin`
142
+ * specificity on purpose. Consumer apps commonly import their site
143
+ * stylesheet into the admin route (for the page-editor canvas preview) and
144
+ * those sheets sometimes style `.actuate-admin` or plain `h1`/`body` in
145
+ * their own `@layer base` — with equal specificity, whichever file loads
146
+ * last wins, and the admin shell inherits site fonts/colors ("washed out"
147
+ * admin). Doubling the class wins those same-layer ties without `!important`
148
+ * while Tailwind utilities (a later layer) still override as usual.
149
+ * Consumer styles that should reach the canvas preview belong under
150
+ * `.actuate-admin .actuate-canvas` — see EditorCanvas.
151
+ */
152
+ .actuate-admin.actuate-admin {
141
153
  @apply bg-background text-foreground;
142
154
  font-family: var(--actuate-admin-font, var(--font-sans)) !important;
143
155
  font-size: var(--font-size);
@@ -148,44 +160,44 @@
148
160
  -moz-osx-font-smoothing: grayscale;
149
161
  }
150
162
 
151
- .actuate-admin h1 {
163
+ .actuate-admin.actuate-admin h1 {
152
164
  font-size: var(--fs-page);
153
165
  font-weight: 700;
154
166
  letter-spacing: -0.5px;
155
167
  line-height: 1.5;
156
168
  }
157
169
 
158
- .actuate-admin h2 {
170
+ .actuate-admin.actuate-admin h2 {
159
171
  font-size: var(--fs-card);
160
172
  font-weight: 600;
161
173
  line-height: 1.5;
162
174
  }
163
175
 
164
- .actuate-admin h3 {
176
+ .actuate-admin.actuate-admin h3 {
165
177
  font-size: var(--fs-card);
166
178
  font-weight: 600;
167
179
  line-height: 1.5;
168
180
  }
169
181
 
170
- .actuate-admin h4 {
182
+ .actuate-admin.actuate-admin h4 {
171
183
  font-size: var(--fs-body);
172
184
  font-weight: 600;
173
185
  line-height: 1.5;
174
186
  }
175
187
 
176
- .actuate-admin label {
188
+ .actuate-admin.actuate-admin label {
177
189
  font-size: var(--fs-body);
178
190
  font-weight: var(--font-weight-medium);
179
191
  line-height: 1.5;
180
192
  }
181
193
 
182
- .actuate-admin button {
194
+ .actuate-admin.actuate-admin button {
183
195
  font-size: var(--fs-body);
184
196
  font-weight: var(--font-weight-medium);
185
197
  line-height: 1.5;
186
198
  }
187
199
 
188
- .actuate-admin input {
200
+ .actuate-admin.actuate-admin input {
189
201
  font-size: var(--fs-body);
190
202
  font-weight: var(--font-weight-normal);
191
203
  line-height: 1.5;
@@ -198,6 +210,20 @@
198
210
  text-transform: uppercase;
199
211
  color: var(--sub);
200
212
  }
213
+
214
+ /*
215
+ * The sanctioned hook for consumer brand styles inside the editor canvas
216
+ * preview. Anything the site stylesheet must render in the preview should
217
+ * be scoped `.actuate-admin .actuate-canvas …` instead of `.actuate-admin`.
218
+ * The canvas re-establishes the site's typographic context: headings and
219
+ * body text fall back to inheritance rather than the admin scale above.
220
+ */
221
+ .actuate-admin .actuate-canvas :is(h1, h2, h3, h4) {
222
+ font-size: revert-layer;
223
+ font-weight: revert-layer;
224
+ letter-spacing: revert-layer;
225
+ line-height: revert-layer;
226
+ }
201
227
  }
202
228
 
203
229
  .actuate-admin.reduce-motion,
@@ -585,7 +585,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
585
585
  <h1 className="text-foreground">
586
586
  {greeting}, {userName} <span aria-hidden>👋</span>
587
587
  </h1>
588
- <p className="text-muted-foreground text-sm">
588
+ {/* data-volatile: the literal date changes daily — visual tests mask it. */}
589
+ <p className="text-muted-foreground text-sm" data-volatile>
589
590
  {dateStr}
590
591
  {totalIssues > 0 && (
591
592
  <span className="hidden sm:inline">
@@ -88,9 +88,12 @@ export function EditorCanvas({
88
88
  className="mx-auto"
89
89
  style={{ width: vp.width * scale, height: surfaceHeight ?? undefined }}
90
90
  >
91
+ {/* `actuate-canvas` is the public hook for consumer brand CSS:
92
+ site styles that should render in this preview scope to
93
+ `.actuate-admin .actuate-canvas` instead of the admin root. */}
91
94
  <div
92
95
  ref={surfaceRef}
93
- className="border-border bg-card origin-top overflow-hidden rounded-xl border shadow-sm"
96
+ className="actuate-canvas border-border bg-card origin-top overflow-hidden rounded-xl border shadow-sm"
94
97
  style={{
95
98
  width: vp.width,
96
99
  transform: `scale(${scale})`,
@@ -91,7 +91,7 @@ export function PagePreview({ documentId, onNavigate }: PagePreviewProps) {
91
91
 
92
92
  {/* The canvas is intentionally white regardless of the admin theme:
93
93
  it emulates the public page surface, not admin chrome. */}
94
- <div className="bg-card flex-1 overflow-auto">
94
+ <div className="actuate-canvas bg-card flex-1 overflow-auto">
95
95
  {visible.length === 0 ? (
96
96
  <div className="text-muted-foreground flex h-full items-center justify-center text-sm">
97
97
  This page has no visible sections yet.
@@ -20,6 +20,7 @@ import {
20
20
  import { CSS } from '@dnd-kit/utilities'
21
21
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
22
22
  import { Toggle } from '../../components/ui/Toggle.js'
23
+ import { RichTextField } from '../../fields/RichTextField.js'
23
24
  import type { PageSection } from '../../lib/page-editor-service.js'
24
25
  import type { ResolvedRepeater, SectionSettings } from './section-types.js'
25
26
  import {
@@ -37,6 +38,9 @@ interface SectionInspectorProps {
37
38
  onSettingsChange: (patch: Partial<SectionSettings>) => void
38
39
  onMetaChange: (patch: { name?: string; internalLabel?: string }) => void
39
40
  onToggleVisibility: () => void
41
+ /** Post body field — shown when editing an Article Body section bound to the post field. */
42
+ postBody?: string
43
+ onPostBodyChange?: (html: string) => void
40
44
  }
41
45
 
42
46
  const LABEL = 'text-foreground mb-1 block text-xs font-medium'
@@ -51,8 +55,17 @@ export function SectionInspector({
51
55
  onSettingsChange,
52
56
  onMetaChange,
53
57
  onToggleVisibility,
58
+ postBody,
59
+ onPostBodyChange,
54
60
  }: SectionInspectorProps) {
55
61
  const def = getSectionType(section.sectionType)
62
+ const bodySource = String(section.content.source ?? 'field')
63
+ const editPostBodyField =
64
+ section.sectionType === 'article-body' &&
65
+ bodySource === 'field' &&
66
+ onPostBodyChange !== undefined
67
+ const contentFields =
68
+ def?.fields.filter((field) => !(editPostBodyField && field.key === 'body')) ?? []
56
69
 
57
70
  // Escape closes the inspector.
58
71
  useEffect(() => {
@@ -143,7 +156,16 @@ export function SectionInspector({
143
156
  <legend className="text-muted-foreground mb-2 text-[10px] font-medium tracking-wider uppercase">
144
157
  Content
145
158
  </legend>
146
- {def?.fields.map((field) => (
159
+ {editPostBodyField && (
160
+ <RichTextField
161
+ label="Body"
162
+ value={postBody ?? ''}
163
+ onChange={onPostBodyChange}
164
+ className="[&_.ProseMirror]:min-h-[240px]"
165
+ helpText="Main article copy — rendered in the canvas preview and on the live site."
166
+ />
167
+ )}
168
+ {contentFields.map((field) => (
147
169
  <ContentField
148
170
  key={field.key}
149
171
  field={field}
@@ -87,7 +87,7 @@ export function PostEditorCanvas({
87
87
  >
88
88
  <div
89
89
  ref={surfaceRef}
90
- className="border-border bg-card origin-top overflow-hidden rounded-xl border shadow-sm"
90
+ className="actuate-canvas border-border bg-card origin-top overflow-hidden rounded-xl border shadow-sm"
91
91
  style={{ width: vp.width, transform: `scale(${scale})`, transformOrigin: 'top left' }}
92
92
  >
93
93
  <PostHeader config={header} context={context} />