@actuate-media/cms-admin 0.72.0 → 0.72.2

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 (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/__tests__/lib/cv.test.js +9 -0
  3. package/dist/__tests__/lib/cv.test.js.map +1 -1
  4. package/dist/__tests__/views/page-section-editor.test.js +11 -0
  5. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  6. package/dist/__tests__/views/post-section-editor.test.js +14 -0
  7. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  8. package/dist/actuate-admin.css +1 -1
  9. package/dist/components/ui/Modal.d.ts +8 -2
  10. package/dist/components/ui/Modal.d.ts.map +1 -1
  11. package/dist/components/ui/Modal.js +9 -2
  12. package/dist/components/ui/Modal.js.map +1 -1
  13. package/dist/lib/cv.d.ts.map +1 -1
  14. package/dist/lib/cv.js +9 -1
  15. package/dist/lib/cv.js.map +1 -1
  16. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  17. package/dist/views/page-editor/EditorCanvas.js +1 -1
  18. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  19. package/dist/views/page-editor/PagePreview.js +1 -1
  20. package/dist/views/page-editor/PagePreview.js.map +1 -1
  21. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  22. package/dist/views/page-editor/PageSectionEditor.js +4 -0
  23. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  24. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  25. package/dist/views/page-editor/PageSettingsModal.js +8 -2
  26. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  27. package/dist/views/post-editor/PostEditorCanvas.js +1 -1
  28. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  29. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  30. package/dist/views/post-editor/PostFieldsModal.js +4 -2
  31. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  32. package/dist/views/post-editor/PostPreview.js +1 -1
  33. package/dist/views/post-editor/PostPreview.js.map +1 -1
  34. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  35. package/dist/views/post-editor/PostSectionEditor.js +5 -1
  36. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/__tests__/lib/cv.test.ts +10 -0
  39. package/src/__tests__/views/page-section-editor.test.tsx +15 -0
  40. package/src/__tests__/views/post-section-editor.test.tsx +26 -0
  41. package/src/components/ui/Modal.tsx +29 -3
  42. package/src/lib/cv.ts +8 -1
  43. package/src/styles/theme.css +34 -8
  44. package/src/views/page-editor/EditorCanvas.tsx +4 -1
  45. package/src/views/page-editor/PagePreview.tsx +1 -1
  46. package/src/views/page-editor/PageSectionEditor.tsx +4 -0
  47. package/src/views/page-editor/PageSettingsModal.tsx +27 -4
  48. package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
  49. package/src/views/post-editor/PostFieldsModal.tsx +6 -1
  50. package/src/views/post-editor/PostPreview.tsx +1 -1
  51. package/src/views/post-editor/PostSectionEditor.tsx +6 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
4
  import { X } from 'lucide-react'
5
- import { type ReactNode } from 'react'
5
+ import { type ReactNode, type RefObject } from 'react'
6
6
  import { adminPortalContainer } from '../../lib/portal-container.js'
7
7
 
8
8
  export interface ModalProps {
@@ -16,6 +16,12 @@ export interface ModalProps {
16
16
  * omit when the body content is self-explanatory.
17
17
  */
18
18
  description?: string
19
+ /**
20
+ * Element to focus when the dialog opens, instead of Radix's default
21
+ * (the first tabbable element, which is the header close button). Use for
22
+ * form dialogs where the user should land in the first input.
23
+ */
24
+ initialFocusRef?: RefObject<HTMLElement | null>
19
25
  }
20
26
 
21
27
  /**
@@ -29,12 +35,32 @@ export interface ModalProps {
29
35
  * dialog inherits theme tokens and dark mode instead of rendering unstyled on
30
36
  * `document.body`.
31
37
  */
32
- export function Modal({ open, onClose, title, children, actions, description }: ModalProps) {
38
+ export function Modal({
39
+ open,
40
+ onClose,
41
+ title,
42
+ children,
43
+ actions,
44
+ description,
45
+ initialFocusRef,
46
+ }: ModalProps) {
33
47
  return (
34
48
  <Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
35
49
  <Dialog.Portal container={adminPortalContainer()}>
36
50
  <Dialog.Overlay className="fixed inset-0 z-60 bg-black/50" />
37
- <Dialog.Content 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">
51
+ <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"
53
+ onOpenAutoFocus={
54
+ initialFocusRef
55
+ ? (event) => {
56
+ if (initialFocusRef.current) {
57
+ event.preventDefault()
58
+ initialFocusRef.current.focus()
59
+ }
60
+ }
61
+ : undefined
62
+ }
63
+ >
38
64
  <div className="border-border flex items-center justify-between border-b px-6 py-4">
39
65
  <Dialog.Title className="text-foreground text-lg font-medium">{title}</Dialog.Title>
40
66
  <Dialog.Close asChild>
package/src/lib/cv.ts CHANGED
@@ -63,7 +63,14 @@ export function cv<V extends VariantsConfig>(base: ClassValue, config: CvConfig<
63
63
  return function variantClass(
64
64
  props?: VariantSelection<V> & { class?: ClassValue; className?: ClassValue },
65
65
  ): string {
66
- const selected = { ...config.defaultVariants, ...props } as Record<string, unknown>
66
+ // Per the cva contract, an explicit `undefined` for a variant key must
67
+ // fall back to the default — spreading props naively would clobber the
68
+ // default and drop the variant's classes entirely (e.g. a Button without
69
+ // a `size` prop losing all height/padding).
70
+ const selected = { ...config.defaultVariants } as Record<string, unknown>
71
+ for (const [key, value] of Object.entries(props ?? {})) {
72
+ if (value !== undefined) selected[key] = value
73
+ }
67
74
 
68
75
  const variantClasses: ClassValue[] = []
69
76
  for (const [key, valuesMap] of Object.entries(config.variants)) {
@@ -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,
@@ -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.
@@ -145,6 +145,10 @@ export function PageSectionEditor({
145
145
  if (!documentId) {
146
146
  setPage(emptyPage(initialParentId ?? null))
147
147
  setLoading(false)
148
+ // Start a brand-new page the way WordPress does: name it first. The
149
+ // settings modal opens with the title focused and the slug/path
150
+ // deriving live, so the required fields are done before building.
151
+ setSettingsOpen(true)
148
152
  return
149
153
  }
150
154
 
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState } from 'react'
3
+ import { useEffect, useRef, useState } from 'react'
4
4
  import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
5
5
  import { Modal } from '../../components/ui/Modal.js'
6
6
  import { Button } from '../../components/ui/Button.js'
@@ -49,6 +49,9 @@ export function PageSettingsModal({
49
49
  // Auto-derive slug/path from title until the user edits slug manually.
50
50
  const [slugTouched, setSlugTouched] = useState(Boolean(page.slug))
51
51
  const [parentOptions, setParentOptions] = useState<ParentOption[] | null>(null)
52
+ // Land the user in the Title field (WordPress-style "name it first"),
53
+ // rather than Radix's default focus on the header close button.
54
+ const titleRef = useRef<HTMLInputElement | null>(null)
52
55
 
53
56
  useEffect(() => {
54
57
  if (!open) return
@@ -118,29 +121,49 @@ export function PageSettingsModal({
118
121
  open={open}
119
122
  onClose={onClose}
120
123
  title="Page settings"
124
+ initialFocusRef={titleRef}
121
125
  actions={
122
126
  <>
123
127
  <Button variant="ghost" onClick={onClose}>
124
128
  Cancel
125
129
  </Button>
126
- <Button variant="primary" onClick={handleSave} disabled={!canEdit || Boolean(slugError)}>
130
+ <Button
131
+ variant="primary"
132
+ form="page-settings-form"
133
+ type="submit"
134
+ disabled={!canEdit || Boolean(slugError)}
135
+ >
127
136
  Done
128
137
  </Button>
129
138
  </>
130
139
  }
131
140
  >
132
- <div className="space-y-3">
141
+ {/* A real form so Enter in any field confirms the dialog — matters
142
+ for the new-page flow where this modal is the first stop. */}
143
+ <form
144
+ id="page-settings-form"
145
+ className="space-y-3"
146
+ onSubmit={(e) => {
147
+ e.preventDefault()
148
+ handleSave()
149
+ }}
150
+ >
133
151
  <div>
134
152
  <label className={LABEL} htmlFor="ps-title">
135
153
  Title <span className="text-destructive">*</span>
136
154
  </label>
137
155
  <input
138
156
  id="ps-title"
157
+ ref={titleRef}
139
158
  className={INPUT}
140
159
  value={title}
141
160
  disabled={!canEdit}
161
+ placeholder="e.g. About Us"
142
162
  onChange={(e) => handleTitle(e.target.value)}
143
163
  />
164
+ <p className="text-muted-foreground mt-1 text-xs">
165
+ The slug and path below follow the title until you edit them.
166
+ </p>
144
167
  </div>
145
168
  <div className="grid grid-cols-2 gap-3">
146
169
  <div>
@@ -240,7 +263,7 @@ export function PageSettingsModal({
240
263
  onChange={(e) => setSeoDescription(e.target.value)}
241
264
  />
242
265
  </div>
243
- </div>
266
+ </form>
244
267
  </Modal>
245
268
  )
246
269
  }
@@ -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} />
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState } from 'react'
3
+ import { useEffect, useRef, useState } from 'react'
4
4
  import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
5
5
  import { Modal } from '../../components/ui/Modal.js'
6
6
  import { Button } from '../../components/ui/Button.js'
@@ -62,6 +62,8 @@ export function PostFieldsModal({
62
62
  const [seoDescription, setSeoDescription] = useState(post.seoDescription)
63
63
  const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
64
64
  const [mediaOpen, setMediaOpen] = useState(false)
65
+ // Land the user in the Title field (name-it-first flow for new posts).
66
+ const titleRef = useRef<HTMLInputElement | null>(null)
65
67
 
66
68
  const categoryField = fields?.category
67
69
  const categoryOptions =
@@ -103,6 +105,7 @@ export function PostFieldsModal({
103
105
  open={open}
104
106
  onClose={onClose}
105
107
  title="Post details"
108
+ initialFocusRef={titleRef}
106
109
  actions={
107
110
  <>
108
111
  <Button variant="ghost" onClick={onClose}>
@@ -121,9 +124,11 @@ export function PostFieldsModal({
121
124
  </label>
122
125
  <input
123
126
  id="pf-title"
127
+ ref={titleRef}
124
128
  className={INPUT}
125
129
  value={title}
126
130
  disabled={!canEdit}
131
+ placeholder="e.g. Announcing our new product"
127
132
  onChange={(e) => handleTitle(e.target.value)}
128
133
  />
129
134
  </div>
@@ -114,7 +114,7 @@ export function PostPreview({ postType, documentId, session, onNavigate }: PostP
114
114
  </button>
115
115
  </div>
116
116
 
117
- <div className="bg-card flex-1 overflow-auto">
117
+ <div className="actuate-canvas bg-card flex-1 overflow-auto">
118
118
  <PostHeader config={header} context={context} />
119
119
  {visible.map((section) => (
120
120
  <SectionRenderer key={section.id} section={section} context={context} />
@@ -191,7 +191,12 @@ export function PostSectionEditor({
191
191
  if (cancelled) return
192
192
  setPost(p)
193
193
  setSelectedId(null)
194
- if (!documentId) setDirty(true)
194
+ if (!documentId) {
195
+ setDirty(true)
196
+ // Name-it-first flow (matches the Page editor): a new post opens
197
+ // straight into the fields modal so title/slug are set up front.
198
+ setFieldsOpen(true)
199
+ }
195
200
  })
196
201
  .catch((err) => {
197
202
  if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load post')