@actuate-media/cms-admin 0.49.3 → 0.50.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 (159) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/AdminRoot.js +2 -2
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/components/seo-char-count.test.d.ts +2 -0
  5. package/dist/__tests__/components/seo-char-count.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/seo-char-count.test.js +25 -0
  7. package/dist/__tests__/components/seo-char-count.test.js.map +1 -0
  8. package/dist/__tests__/lib/page-editor-service.test.js +12 -0
  9. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  10. package/dist/__tests__/lib/post-editor-service.test.js +4 -0
  11. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  12. package/dist/__tests__/views/seo-settings.render.test.js +1 -1
  13. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  14. package/dist/actuate-admin.css +1 -1
  15. package/dist/components/CoauthorResultPopover.js +1 -1
  16. package/dist/components/CoauthorResultPopover.js.map +1 -1
  17. package/dist/components/CommentSidePanel.js +1 -1
  18. package/dist/components/CommentSidePanel.js.map +1 -1
  19. package/dist/components/ContentOverviewChart.js +1 -1
  20. package/dist/components/ContentOverviewChart.js.map +1 -1
  21. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  22. package/dist/components/MediaPickerModal.js +3 -1
  23. package/dist/components/MediaPickerModal.js.map +1 -1
  24. package/dist/components/NotificationBell.js +1 -1
  25. package/dist/components/NotificationBell.js.map +1 -1
  26. package/dist/components/PresenceChips.js +1 -1
  27. package/dist/components/PresenceChips.js.map +1 -1
  28. package/dist/components/SEOConfigPanel.js +2 -2
  29. package/dist/components/SEOConfigPanel.js.map +1 -1
  30. package/dist/components/SEOPanel.d.ts +1 -0
  31. package/dist/components/SEOPanel.d.ts.map +1 -1
  32. package/dist/components/SEOPanel.js +3 -3
  33. package/dist/components/SEOPanel.js.map +1 -1
  34. package/dist/components/SEOPerformance.js +2 -2
  35. package/dist/components/SEOPerformance.js.map +1 -1
  36. package/dist/components/forms/EntriesTable.js +1 -1
  37. package/dist/components/forms/EntriesTable.js.map +1 -1
  38. package/dist/components/seo/SeoCharCount.d.ts +15 -0
  39. package/dist/components/seo/SeoCharCount.d.ts.map +1 -0
  40. package/dist/components/seo/SeoCharCount.js +25 -0
  41. package/dist/components/seo/SeoCharCount.js.map +1 -0
  42. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
  43. package/dist/components/seo/SeoEditorDrawer.js +3 -1
  44. package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
  45. package/dist/components/ui/Card.js +1 -1
  46. package/dist/components/ui/Card.js.map +1 -1
  47. package/dist/components/ui/EmptyState.js +1 -1
  48. package/dist/components/ui/EmptyState.js.map +1 -1
  49. package/dist/fields/SlugField.d.ts.map +1 -1
  50. package/dist/fields/SlugField.js +6 -2
  51. package/dist/fields/SlugField.js.map +1 -1
  52. package/dist/layout/Sidebar.js +1 -1
  53. package/dist/layout/Sidebar.js.map +1 -1
  54. package/dist/layout/primitives/PageContainer.js +1 -1
  55. package/dist/layout/primitives/PageContainer.js.map +1 -1
  56. package/dist/lib/page-editor-service.d.ts.map +1 -1
  57. package/dist/lib/page-editor-service.js +6 -0
  58. package/dist/lib/page-editor-service.js.map +1 -1
  59. package/dist/lib/post-editor-service.d.ts.map +1 -1
  60. package/dist/lib/post-editor-service.js +6 -0
  61. package/dist/lib/post-editor-service.js.map +1 -1
  62. package/dist/lib/seo-service.d.ts +3 -1
  63. package/dist/lib/seo-service.d.ts.map +1 -1
  64. package/dist/lib/seo-service.js.map +1 -1
  65. package/dist/views/CollectionList.js +1 -1
  66. package/dist/views/CollectionList.js.map +1 -1
  67. package/dist/views/Dashboard.js +8 -8
  68. package/dist/views/Dashboard.js.map +1 -1
  69. package/dist/views/DocumentEdit.d.ts.map +1 -1
  70. package/dist/views/DocumentEdit.js +2 -1
  71. package/dist/views/DocumentEdit.js.map +1 -1
  72. package/dist/views/ForgotPassword.js +1 -1
  73. package/dist/views/ForgotPassword.js.map +1 -1
  74. package/dist/views/FormEditor.d.ts.map +1 -1
  75. package/dist/views/FormEditor.js +1 -1
  76. package/dist/views/FormEditor.js.map +1 -1
  77. package/dist/views/Login.js +1 -1
  78. package/dist/views/Login.js.map +1 -1
  79. package/dist/views/MediaBrowser.d.ts.map +1 -1
  80. package/dist/views/MediaBrowser.js +3 -3
  81. package/dist/views/MediaBrowser.js.map +1 -1
  82. package/dist/views/PageEditor.js +1 -1
  83. package/dist/views/PageEditor.js.map +1 -1
  84. package/dist/views/Pages/PagesListView.js +3 -3
  85. package/dist/views/Pages/PagesListView.js.map +1 -1
  86. package/dist/views/Pages.js +1 -1
  87. package/dist/views/Pages.js.map +1 -1
  88. package/dist/views/Posts/PostsListView.js +1 -1
  89. package/dist/views/Posts/PostsListView.js.map +1 -1
  90. package/dist/views/Redirects.js +2 -2
  91. package/dist/views/Redirects.js.map +1 -1
  92. package/dist/views/ResetPassword.js +2 -2
  93. package/dist/views/ResetPassword.js.map +1 -1
  94. package/dist/views/ScriptTagEditor.js +1 -1
  95. package/dist/views/ScriptTagEditor.js.map +1 -1
  96. package/dist/views/ScriptTags.js +1 -1
  97. package/dist/views/ScriptTags.js.map +1 -1
  98. package/dist/views/SetupWizard.js +2 -2
  99. package/dist/views/SetupWizard.js.map +1 -1
  100. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  101. package/dist/views/page-editor/PageSettingsModal.js +12 -9
  102. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  103. package/dist/views/page-editor/SectionInspector.js +2 -2
  104. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  105. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  106. package/dist/views/post-editor/PostFieldsModal.js +12 -9
  107. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  108. package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
  109. package/dist/views/post-editor/PostHeader.js +2 -2
  110. package/dist/views/post-editor/PostHeader.js.map +1 -1
  111. package/dist/views/post-editor/PostHeaderPanel.js +1 -1
  112. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -1
  113. package/package.json +2 -2
  114. package/src/AdminRoot.tsx +2 -2
  115. package/src/__tests__/components/seo-char-count.test.tsx +27 -0
  116. package/src/__tests__/lib/page-editor-service.test.ts +14 -0
  117. package/src/__tests__/lib/post-editor-service.test.ts +9 -0
  118. package/src/__tests__/views/seo-settings.render.test.tsx +1 -1
  119. package/src/components/CoauthorResultPopover.tsx +1 -1
  120. package/src/components/CommentSidePanel.tsx +1 -1
  121. package/src/components/ContentOverviewChart.tsx +1 -1
  122. package/src/components/MediaPickerModal.tsx +5 -1
  123. package/src/components/NotificationBell.tsx +2 -2
  124. package/src/components/PresenceChips.tsx +1 -1
  125. package/src/components/SEOConfigPanel.tsx +3 -3
  126. package/src/components/SEOPanel.tsx +17 -6
  127. package/src/components/SEOPerformance.tsx +5 -5
  128. package/src/components/forms/EntriesTable.tsx +1 -1
  129. package/src/components/seo/SeoCharCount.tsx +44 -0
  130. package/src/components/seo/SeoEditorDrawer.tsx +27 -1
  131. package/src/components/ui/Card.tsx +1 -1
  132. package/src/components/ui/EmptyState.tsx +1 -1
  133. package/src/fields/SlugField.tsx +6 -2
  134. package/src/layout/Sidebar.tsx +1 -1
  135. package/src/layout/primitives/PageContainer.tsx +1 -1
  136. package/src/lib/page-editor-service.ts +6 -0
  137. package/src/lib/post-editor-service.ts +6 -0
  138. package/src/lib/seo-service.ts +9 -1
  139. package/src/views/CollectionList.tsx +1 -1
  140. package/src/views/Dashboard.tsx +12 -12
  141. package/src/views/DocumentEdit.tsx +5 -4
  142. package/src/views/ForgotPassword.tsx +1 -1
  143. package/src/views/FormEditor.tsx +1 -3
  144. package/src/views/Login.tsx +2 -2
  145. package/src/views/MediaBrowser.tsx +7 -9
  146. package/src/views/PageEditor.tsx +2 -2
  147. package/src/views/Pages/PagesListView.tsx +3 -3
  148. package/src/views/Pages.tsx +1 -1
  149. package/src/views/Posts/PostsListView.tsx +1 -1
  150. package/src/views/Redirects.tsx +2 -2
  151. package/src/views/ResetPassword.tsx +2 -2
  152. package/src/views/ScriptTagEditor.tsx +2 -2
  153. package/src/views/ScriptTags.tsx +2 -2
  154. package/src/views/SetupWizard.tsx +2 -2
  155. package/src/views/page-editor/PageSettingsModal.tsx +33 -14
  156. package/src/views/page-editor/SectionInspector.tsx +4 -4
  157. package/src/views/post-editor/PostFieldsModal.tsx +33 -14
  158. package/src/views/post-editor/PostHeader.tsx +2 -4
  159. package/src/views/post-editor/PostHeaderPanel.tsx +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"PostHeader.js","sourceRoot":"","sources":["../../../src/views/post-editor/PostHeader.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"PostHeader.js","sourceRoot":"","sources":["../../../src/views/post-editor/PostHeader.tsx"],"names":[],"mappings":"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"}
@@ -13,7 +13,7 @@ const TOGGLES = [
13
13
  ];
14
14
  /** Inspector panel for the post header layout (template-level design config). */
15
15
  export function PostHeaderPanel({ header, canEdit, onClose, onChange }) {
16
- return (_jsxs("aside", { role: "dialog", "aria-label": "Edit post header", className: "bg-card border-border flex h-full w-80 shrink-0 flex-col border-l", children: [_jsxs("div", { className: "border-border flex items-center justify-between border-b px-4 py-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { className: "text-foreground truncate text-sm font-medium", children: "Post header" }), _jsx("p", { className: "text-muted-foreground text-xs", children: "Layout for every post of this type" })] }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close header panel", className: "text-muted-foreground hover:text-foreground hover:bg-accent rounded p-1", children: _jsx(X, { className: "h-4 w-4", "aria-hidden": true }) })] }), _jsxs("div", { className: "flex-1 space-y-6 overflow-y-auto p-4", children: [_jsxs("fieldset", { disabled: !canEdit, className: "space-y-3", children: [_jsx("legend", { className: "text-muted-foreground mb-2 text-[10px] font-semibold tracking-wider uppercase", children: "Layout" }), _jsxs("div", { children: [_jsx("label", { className: LABEL, htmlFor: "ph-layout", children: "Header style" }), _jsxs("select", { id: "ph-layout", className: INPUT, value: header.layout, onChange: (e) => onChange({ layout: e.target.value }), children: [_jsx("option", { value: "centered", children: "Centered" }), _jsx("option", { value: "left", children: "Left-aligned" }), _jsx("option", { value: "overlay", children: "Image overlay" })] })] })] }), _jsxs("fieldset", { disabled: !canEdit, className: "space-y-2", children: [_jsx("legend", { className: "text-muted-foreground mb-2 text-[10px] font-semibold tracking-wider uppercase", children: "Show in header" }), TOGGLES.map((t) => {
16
+ return (_jsxs("aside", { role: "dialog", "aria-label": "Edit post header", className: "bg-card border-border flex h-full w-80 shrink-0 flex-col border-l", children: [_jsxs("div", { className: "border-border flex items-center justify-between border-b px-4 py-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { className: "text-foreground truncate text-sm font-medium", children: "Post header" }), _jsx("p", { className: "text-muted-foreground text-xs", children: "Layout for every post of this type" })] }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close header panel", className: "text-muted-foreground hover:text-foreground hover:bg-accent rounded p-1", children: _jsx(X, { className: "h-4 w-4", "aria-hidden": true }) })] }), _jsxs("div", { className: "flex-1 space-y-6 overflow-y-auto p-4", children: [_jsxs("fieldset", { disabled: !canEdit, className: "space-y-3", children: [_jsx("legend", { className: "text-muted-foreground mb-2 text-[10px] font-medium tracking-wider uppercase", children: "Layout" }), _jsxs("div", { children: [_jsx("label", { className: LABEL, htmlFor: "ph-layout", children: "Header style" }), _jsxs("select", { id: "ph-layout", className: INPUT, value: header.layout, onChange: (e) => onChange({ layout: e.target.value }), children: [_jsx("option", { value: "centered", children: "Centered" }), _jsx("option", { value: "left", children: "Left-aligned" }), _jsx("option", { value: "overlay", children: "Image overlay" })] })] })] }), _jsxs("fieldset", { disabled: !canEdit, className: "space-y-2", children: [_jsx("legend", { className: "text-muted-foreground mb-2 text-[10px] font-medium tracking-wider uppercase", children: "Show in header" }), TOGGLES.map((t) => {
17
17
  const value = header[t.key] === true;
18
18
  return (_jsxs("button", { type: "button", role: "switch", "aria-checked": value, disabled: !canEdit, onClick: () => onChange({ [t.key]: !value }), className: "border-border text-foreground hover:bg-accent flex w-full items-center justify-between rounded-md border px-2.5 py-1.5 text-sm disabled:opacity-50", children: [_jsx("span", { children: t.label }), _jsx("span", { "aria-hidden": true, className: `relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted'}`, children: _jsx("span", { className: `inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${value ? 'translate-x-4' : 'translate-x-0.5'}` }) })] }, t.key));
19
19
  })] })] })] }));
@@ -1 +1 @@
1
- {"version":3,"file":"PostHeaderPanel.js","sourceRoot":"","sources":["../../../src/views/post-editor/PostHeaderPanel.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"PostHeaderPanel.js","sourceRoot":"","sources":["../../../src/views/post-editor/PostHeaderPanel.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAO,EAAE,CAAC,EAAE,MAAM,cAAc,CAAA;AAUhC,MAAM,KAAK,GAAG,gDAAgD,CAAA;AAC9D,MAAM,KAAK,GACT,yKAAyK,CAAA;AAE3K,MAAM,OAAO,GAA0D;IACrE,EAAE,GAAG,EAAE,mBAAmB,EAAE,KAAK,EAAE,gBAAgB,EAAE;IACrD,EAAE,GAAG,EAAE,aAAa,EAAE,KAAK,EAAE,SAAS,EAAE;IACxC,EAAE,GAAG,EAAE,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE;IACtC,EAAE,GAAG,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE;IAClC,EAAE,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,EAAE;IAC1C,EAAE,GAAG,EAAE,iBAAiB,EAAE,KAAK,EAAE,cAAc,EAAE;CAClD,CAAA;AAED,iFAAiF;AACjF,MAAM,UAAU,eAAe,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAwB;IAC1F,OAAO,CACL,iBACE,IAAI,EAAC,QAAQ,gBACF,kBAAkB,EAC7B,SAAS,EAAC,mEAAmE,aAE7E,eAAK,SAAS,EAAC,oEAAoE,aACjF,eAAK,SAAS,EAAC,SAAS,aACtB,aAAI,SAAS,EAAC,8CAA8C,4BAAiB,EAC7E,YAAG,SAAS,EAAC,+BAA+B,mDAAuC,IAC/E,EACN,iBACE,IAAI,EAAC,QAAQ,EACb,OAAO,EAAE,OAAO,gBACL,oBAAoB,EAC/B,SAAS,EAAC,yEAAyE,YAEnF,KAAC,CAAC,IAAC,SAAS,EAAC,SAAS,wBAAe,GAC9B,IACL,EAEN,eAAK,SAAS,EAAC,sCAAsC,aACnD,oBAAU,QAAQ,EAAE,CAAC,OAAO,EAAE,SAAS,EAAC,WAAW,aACjD,iBAAQ,SAAS,EAAC,6EAA6E,uBAEtF,EACT,0BACE,gBAAO,SAAS,EAAE,KAAK,EAAE,OAAO,EAAC,WAAW,6BAEpC,EACR,kBACE,EAAE,EAAC,WAAW,EACd,SAAS,EAAE,KAAK,EAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EACpB,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,KAAmC,EAAE,CAAC,aAEnF,iBAAQ,KAAK,EAAC,UAAU,yBAAkB,EAC1C,iBAAQ,KAAK,EAAC,MAAM,6BAAsB,EAC1C,iBAAQ,KAAK,EAAC,SAAS,8BAAuB,IACvC,IACL,IACG,EAEX,oBAAU,QAAQ,EAAE,CAAC,OAAO,EAAE,SAAS,EAAC,WAAW,aACjD,iBAAQ,SAAS,EAAC,6EAA6E,+BAEtF,EACR,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;gCACjB,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,IAAI,CAAA;gCACpC,OAAO,CACL,kBAEE,IAAI,EAAC,QAAQ,EACb,IAAI,EAAC,QAAQ,kBACC,KAAK,EACnB,QAAQ,EAAE,CAAC,OAAO,EAClB,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,KAAK,EAA+B,CAAC,EACzE,SAAS,EAAC,oJAAoJ,aAE9J,yBAAO,CAAC,CAAC,KAAK,GAAQ,EACtB,oCAEE,SAAS,EAAE,qFACT,KAAK,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,UACzB,EAAE,YAEF,eACE,SAAS,EAAE,6EACT,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,iBAC5B,EAAE,GACF,GACG,KApBF,CAAC,CAAC,GAAG,CAqBH,CACV,CAAA;4BACH,CAAC,CAAC,IACO,IACP,IACA,CACT,CAAA;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.49.3",
3
+ "version": "0.50.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -89,7 +89,7 @@
89
89
  "tailwindcss": "^4.0.0",
90
90
  "typescript": "^5.7.0",
91
91
  "vitest": "^4.1.0",
92
- "@actuate-media/cms-core": "0.66.0",
92
+ "@actuate-media/cms-core": "0.67.0",
93
93
  "@actuate-media/component-blocks": "0.2.1"
94
94
  },
95
95
  "scripts": {
package/src/AdminRoot.tsx CHANGED
@@ -310,7 +310,7 @@ function AdminShell({
310
310
  return (
311
311
  <div className="bg-background flex min-h-screen items-center justify-center">
312
312
  <div className="text-center">
313
- <h1 className="text-foreground mb-2 text-xl font-semibold">Unauthorized</h1>
313
+ <h1 className="text-foreground mb-2 text-xl font-medium">Unauthorized</h1>
314
314
  <p className="text-muted-foreground text-sm">Please log in to access the admin panel.</p>
315
315
  </div>
316
316
  </div>
@@ -662,7 +662,7 @@ function AdminShell({
662
662
 
663
663
  return (
664
664
  <div className="flex min-h-[400px] flex-col items-center justify-center p-6 text-center">
665
- <h1 className="text-foreground mb-2 text-4xl font-bold">404</h1>
665
+ <h1 className="text-foreground mb-2 text-4xl font-medium">404</h1>
666
666
  <p className="text-muted-foreground mb-4">The page you are looking for does not exist.</p>
667
667
  <button
668
668
  onClick={() => navigate('/')}
@@ -0,0 +1,27 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { render, screen } from '@testing-library/react'
4
+ import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
5
+
6
+ describe('SeoCharCount', () => {
7
+ it('shows a neutral empty state', () => {
8
+ render(<SeoCharCount value="" min={30} max={60} />)
9
+ expect(screen.getByText('0/60')).toBeTruthy()
10
+ })
11
+
12
+ it('marks a value within the ideal range as good', () => {
13
+ render(<SeoCharCount value={'x'.repeat(40)} min={30} max={60} />)
14
+ expect(screen.getByText(/40\/60 · good/)).toBeTruthy()
15
+ })
16
+
17
+ it('flags a value over the max as too long (destructive)', () => {
18
+ const { container } = render(<SeoCharCount value={'x'.repeat(70)} min={30} max={60} />)
19
+ expect(screen.getByText(/70\/60 · too long/)).toBeTruthy()
20
+ expect(container.querySelector('.text-destructive')).toBeTruthy()
21
+ })
22
+
23
+ it('hints to aim for the range when below min', () => {
24
+ render(<SeoCharCount value={'x'.repeat(10)} min={30} max={60} />)
25
+ expect(screen.getByText(/aim for 30–60/)).toBeTruthy()
26
+ })
27
+ })
@@ -146,6 +146,20 @@ describe('validatePage', () => {
146
146
  const { errors } = validatePage(basePage([]), false)
147
147
  expect(errors.some((e) => e.includes('visible section'))).toBe(false)
148
148
  })
149
+
150
+ it('rejects an invalid slug (mirrors the server publish gate)', () => {
151
+ const page = basePage([])
152
+ page.slug = 'About Us!'
153
+ const { errors } = validatePage(page, false)
154
+ expect(errors.some((e) => /slug/i.test(e))).toBe(true)
155
+ })
156
+
157
+ it('rejects a reserved slug', () => {
158
+ const page = basePage([])
159
+ page.slug = 'admin'
160
+ const { errors } = validatePage(page, false)
161
+ expect(errors.some((e) => /reserved/i.test(e))).toBe(true)
162
+ })
149
163
  })
150
164
 
151
165
  describe('section type registry', () => {
@@ -52,6 +52,15 @@ describe('validatePost', () => {
52
52
  expect(r.errors).toContain('Post slug is required.')
53
53
  })
54
54
 
55
+ it('rejects an invalid or reserved slug (mirrors the server publish gate)', () => {
56
+ expect(
57
+ validatePost(basePost({ slug: 'Hello World' }), false).errors.some((e) => /slug/i.test(e)),
58
+ ).toBe(true)
59
+ expect(
60
+ validatePost(basePost({ slug: 'api' }), false).errors.some((e) => /reserved/i.test(e)),
61
+ ).toBe(true)
62
+ })
63
+
55
64
  it('does not run section validation for draft saves', () => {
56
65
  const r = validatePost(
57
66
  basePost({
@@ -39,7 +39,7 @@ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) =>
39
39
  if (endpoint === '/seo/sitemap/status' && method === 'GET') {
40
40
  return {
41
41
  data: {
42
- sitemapUrl: 'https://acme.test/api/cms/sitemap.xml',
42
+ sitemapUrl: 'https://acme.test/sitemap.xml',
43
43
  lastGeneratedAt: '2026-01-02T03:04:05.000Z',
44
44
  pagesIncluded: 12,
45
45
  pagesExcluded: 1,
@@ -84,7 +84,7 @@ export function CoauthorResultPopover({
84
84
  className="w-full max-w-2xl rounded-lg bg-white shadow-2xl outline-none"
85
85
  >
86
86
  <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
87
- <div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
87
+ <div className="flex items-center gap-2 text-sm font-medium text-gray-900">
88
88
  <Sparkles className="h-4 w-4 text-blue-600" aria-hidden />
89
89
  <span>AI {coauthorActionLabel(action)}</span>
90
90
  {loading && (
@@ -233,7 +233,7 @@ export function CommentSidePanel({
233
233
  return (
234
234
  <aside className={wrapperClass} data-testid="comment-side-panel">
235
235
  <header className="flex items-center justify-between">
236
- <h2 className="text-sm font-semibold text-gray-900">Comments</h2>
236
+ <h2 className="text-sm font-medium text-gray-900">Comments</h2>
237
237
  <label className="flex cursor-pointer items-center gap-1 text-xs text-gray-600">
238
238
  <input
239
239
  type="checkbox"
@@ -60,7 +60,7 @@ export function ContentOverviewChart({ published, drafts, scheduled }: ContentOv
60
60
  <div className="min-w-0">
61
61
  <span className="text-sm text-gray-700">{entry.name}</span>
62
62
  <div className="flex items-baseline gap-2">
63
- <span className="text-lg font-semibold text-gray-900">
63
+ <span className="text-lg font-medium text-gray-900">
64
64
  {entry.value.toLocaleString()}
65
65
  </span>
66
66
  <span className="text-xs text-gray-400">{pct}%</span>
@@ -14,6 +14,8 @@ interface MediaItem {
14
14
  storageKey: string
15
15
  /** Normalized public URL from the media list endpoint (preferred for display). */
16
16
  url?: string
17
+ /** Curated alt text from the media library (normalized as `altTag`). */
18
+ altTag?: string
17
19
  mimeType: string
18
20
  fileSize: number
19
21
  width?: number
@@ -118,7 +120,9 @@ export function MediaPickerModal({
118
120
  width: item.width,
119
121
  height: item.height,
120
122
  })
121
- onSelect(url, item.filename)
123
+ // Prefer the asset's curated alt text from the library so inserted images
124
+ // carry meaningful alt instead of a filename. Callers can still override.
125
+ onSelect(url, item.altTag?.trim() || item.filename)
122
126
  onClose()
123
127
  }
124
128
 
@@ -256,7 +256,7 @@ export function NotificationBell({
256
256
  <BellIcon />
257
257
  {unread > 0 && (
258
258
  <span
259
- className="absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-semibold text-white"
259
+ className="absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-medium text-white"
260
260
  data-testid="bell-badge"
261
261
  >
262
262
  {unread > 99 ? '99+' : unread}
@@ -270,7 +270,7 @@ export function NotificationBell({
270
270
  data-testid="bell-dropdown"
271
271
  >
272
272
  <header className="flex items-center justify-between border-b border-gray-100 px-3 py-2">
273
- <span className="text-sm font-semibold text-gray-900">Notifications</span>
273
+ <span className="text-sm font-medium text-gray-900">Notifications</span>
274
274
  <button
275
275
  type="button"
276
276
  className="text-xs text-gray-600 hover:text-gray-900 disabled:opacity-50"
@@ -125,7 +125,7 @@ function Chip({ name, color, avatarUrl }: { name: string; color: string; avatarU
125
125
  ) : (
126
126
  <span
127
127
  aria-hidden="true"
128
- className="flex h-4 w-4 items-center justify-center rounded-full text-[8px] font-semibold text-white"
128
+ className="flex h-4 w-4 items-center justify-center rounded-full text-[8px] font-medium text-white"
129
129
  style={{ backgroundColor: color }}
130
130
  >
131
131
  {initials}
@@ -242,7 +242,7 @@ export function SEOConfigPanel() {
242
242
  <section className="rounded-lg border border-gray-200 bg-white">
243
243
  <header className="flex items-center gap-2 border-b border-gray-200 px-4 py-3">
244
244
  <Globe className="h-4 w-4 text-blue-600" />
245
- <h3 className="text-sm font-semibold text-gray-900">Site-wide defaults</h3>
245
+ <h3 className="text-sm font-medium text-gray-900">Site-wide defaults</h3>
246
246
  </header>
247
247
  <div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2">
248
248
  <Field
@@ -333,7 +333,7 @@ export function SEOConfigPanel() {
333
333
  <section className="rounded-lg border border-gray-200 bg-white">
334
334
  <header className="flex items-center gap-2 border-b border-gray-200 px-4 py-3">
335
335
  <FileText className="h-4 w-4 text-blue-600" />
336
- <h3 className="text-sm font-semibold text-gray-900">Per-collection defaults</h3>
336
+ <h3 className="text-sm font-medium text-gray-900">Per-collection defaults</h3>
337
337
  <span className="text-xs text-gray-500">
338
338
  — shown placeholders are the static defaults
339
339
  </span>
@@ -346,7 +346,7 @@ export function SEOConfigPanel() {
346
346
  <div key={col.slug} className="p-4">
347
347
  <div className="mb-3 flex items-baseline justify-between gap-4">
348
348
  <div>
349
- <h4 className="text-sm font-semibold text-gray-900">{col.label}</h4>
349
+ <h4 className="text-sm font-medium text-gray-900">{col.label}</h4>
350
350
  <p className="text-xs text-gray-500">
351
351
  <code className="rounded bg-gray-100 px-1 py-0.5">{col.slug}</code>
352
352
  {col.urlPrefix !== undefined && (
@@ -26,6 +26,7 @@ export interface SEOData {
26
26
  metaTitle?: string
27
27
  metaDescription?: string
28
28
  focusKeyphrase?: string
29
+ keyTakeaway?: string
29
30
  canonical?: string
30
31
  robotsPolicy?:
31
32
  | 'inherit'
@@ -414,7 +415,7 @@ function ScoreRing({ score }: { score: number }) {
414
415
  className="transition-all duration-500"
415
416
  />
416
417
  </svg>
417
- <span className="absolute text-sm font-bold" style={{ color }}>
418
+ <span className="absolute text-sm font-medium" style={{ color }}>
418
419
  {score}
419
420
  </span>
420
421
  </div>
@@ -624,7 +625,7 @@ export function SEOPanel({
624
625
  <div className="rounded-lg border border-[var(--border)] bg-[var(--card)]">
625
626
  {/* Header */}
626
627
  <div className="flex items-center justify-between border-b border-[var(--border)] px-4 py-3">
627
- <h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
628
+ <h3 className="flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
628
629
  <Search className="h-4 w-4" />
629
630
  SEO
630
631
  </h3>
@@ -692,24 +693,26 @@ export function SEOPanel({
692
693
  <div className="grid grid-cols-2 gap-3">
693
694
  <div className="rounded-lg bg-[var(--muted)] p-2.5">
694
695
  <p className="text-xs text-[var(--muted-foreground)]">Flesch Score</p>
695
- <p className={`text-lg font-bold ${fleschColor}`}>{readability.fleschScore}</p>
696
+ <p className={`text-lg font-medium ${fleschColor}`}>{readability.fleschScore}</p>
696
697
  <p className={`text-[10px] ${fleschColor}`}>{fleschLabel}</p>
697
698
  </div>
698
699
  <div className="rounded-lg bg-[var(--muted)] p-2.5">
699
700
  <p className="text-xs text-[var(--muted-foreground)]">Word Count</p>
700
- <p className="text-lg font-bold text-[var(--foreground)]">{readability.wordCount}</p>
701
+ <p className="text-lg font-medium text-[var(--foreground)]">
702
+ {readability.wordCount}
703
+ </p>
701
704
  <p className="text-[10px] text-[var(--muted-foreground)]">words</p>
702
705
  </div>
703
706
  <div className="rounded-lg bg-[var(--muted)] p-2.5">
704
707
  <p className="text-xs text-[var(--muted-foreground)]">Avg. Sentence</p>
705
- <p className="text-lg font-bold text-[var(--foreground)]">
708
+ <p className="text-lg font-medium text-[var(--foreground)]">
706
709
  {readability.avgSentenceLength.toFixed(1)}
707
710
  </p>
708
711
  <p className="text-[10px] text-[var(--muted-foreground)]">words/sentence</p>
709
712
  </div>
710
713
  <div className="rounded-lg bg-[var(--muted)] p-2.5">
711
714
  <p className="text-xs text-[var(--muted-foreground)]">Reading Time</p>
712
- <p className="text-lg font-bold text-[var(--foreground)]">
715
+ <p className="text-lg font-medium text-[var(--foreground)]">
713
716
  {readability.readingTime}
714
717
  </p>
715
718
  <p className="text-[10px] text-[var(--muted-foreground)]">min</p>
@@ -760,6 +763,14 @@ export function SEOPanel({
760
763
  onChange={(v) => update({ focusKeyphrase: v })}
761
764
  placeholder="Primary keyword or phrase"
762
765
  />
766
+ <TextareaField
767
+ label="Key Takeaway"
768
+ value={seoData.keyTakeaway ?? ''}
769
+ onChange={(v) => update({ keyTakeaway: v })}
770
+ placeholder="One or two sentences AI answer engines can quote"
771
+ charCount={(seoData.keyTakeaway ?? '').length}
772
+ charTarget="(ideal: under 240)"
773
+ />
763
774
  <InputField
764
775
  label="Canonical URL"
765
776
  value={seoData.canonical ?? ''}
@@ -27,7 +27,7 @@ function ScoreBadge({ score }: { score: number }) {
27
27
  const bg = score >= 80 ? 'bg-green-50' : score >= 60 ? 'bg-amber-50' : 'bg-red-50'
28
28
  return (
29
29
  <span
30
- className={`inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${color} ${bg}`}
30
+ className={`inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-medium ${color} ${bg}`}
31
31
  >
32
32
  {score}
33
33
  </span>
@@ -57,7 +57,7 @@ export function SEOPerformance({ onNavigate }: SEOPerformanceProps) {
57
57
  <div className="flex items-center justify-between border-b border-gray-200 p-4">
58
58
  <div className="flex items-center gap-2">
59
59
  <Search className="h-4 w-4 text-gray-500" />
60
- <h2 className="text-sm font-semibold text-gray-900">SEO Performance</h2>
60
+ <h2 className="text-sm font-medium text-gray-900">SEO Performance</h2>
61
61
  </div>
62
62
  {totalIssues > 0 && (
63
63
  <span className="text-xs text-gray-500">
@@ -120,7 +120,7 @@ export function SEOPerformance({ onNavigate }: SEOPerformanceProps) {
120
120
  <FileWarning className="h-4 w-4 text-red-400" />
121
121
  <span className="text-sm text-gray-700">Missing meta descriptions</span>
122
122
  </div>
123
- <span className="text-sm font-semibold text-gray-900">
123
+ <span className="text-sm font-medium text-gray-900">
124
124
  {issues.missingMetaDescriptions}
125
125
  </span>
126
126
  </div>
@@ -129,7 +129,7 @@ export function SEOPerformance({ onNavigate }: SEOPerformanceProps) {
129
129
  <LinkIcon className="h-4 w-4 text-red-400" />
130
130
  <span className="text-sm text-gray-700">Broken internal links</span>
131
131
  </div>
132
- <span className="text-sm font-semibold text-gray-900">
132
+ <span className="text-sm font-medium text-gray-900">
133
133
  {issues.brokenInternalLinks}
134
134
  </span>
135
135
  </div>
@@ -138,7 +138,7 @@ export function SEOPerformance({ onNavigate }: SEOPerformanceProps) {
138
138
  <ImageOff className="h-4 w-4 text-red-400" />
139
139
  <span className="text-sm text-gray-700">Missing alt text</span>
140
140
  </div>
141
- <span className="text-sm font-semibold text-gray-900">{issues.missingAltText}</span>
141
+ <span className="text-sm font-medium text-gray-900">{issues.missingAltText}</span>
142
142
  </div>
143
143
  </div>
144
144
  {totalIssues > 0 && (
@@ -130,7 +130,7 @@ export function EntriesTable({
130
130
  />
131
131
  )}
132
132
  <span
133
- className={`text-foreground truncate ${entry.unread ? 'font-semibold' : 'font-medium'}`}
133
+ className={`text-foreground truncate ${entry.unread ? 'font-medium' : 'font-medium'}`}
134
134
  >
135
135
  {name}
136
136
  </span>
@@ -0,0 +1,44 @@
1
+ 'use client'
2
+
3
+ import { Check, AlertTriangle } from 'lucide-react'
4
+
5
+ export interface SeoCharCountProps {
6
+ value: string
7
+ /** Recommended minimum length (inclusive). */
8
+ min: number
9
+ /** Recommended/hard maximum length (inclusive). */
10
+ max: number
11
+ }
12
+
13
+ /**
14
+ * A length counter with an accessible quality band for SEO fields. Color is
15
+ * always paired with an icon + label so meaning is never conveyed by color
16
+ * alone (WCAG). "Good" = within [min, max]; "long" (over max) is the only
17
+ * destructive state; below min is a neutral hint.
18
+ */
19
+ export function SeoCharCount({ value, min, max }: SeoCharCountProps) {
20
+ const len = value.trim().length
21
+ const empty = len === 0
22
+ const long = len > max
23
+ const good = !empty && len >= min && len <= max
24
+
25
+ const tone = long ? 'text-destructive' : 'text-muted-foreground'
26
+ const label = empty
27
+ ? `0/${max}`
28
+ : long
29
+ ? `${len}/${max} · too long`
30
+ : good
31
+ ? `${len}/${max} · good`
32
+ : `${len}/${max} · aim for ${min}–${max}`
33
+
34
+ return (
35
+ <span className={`inline-flex items-center gap-1 text-xs ${tone}`}>
36
+ {long ? (
37
+ <AlertTriangle size={12} aria-hidden="true" />
38
+ ) : good ? (
39
+ <Check size={12} aria-hidden="true" />
40
+ ) : null}
41
+ {label}
42
+ </span>
43
+ )
44
+ }
@@ -16,7 +16,15 @@ import { SeoLoading, SeoErrorState, btnPrimary, btnSecondary, gradeMeta } from '
16
16
  const META_TITLE_MAX = 60
17
17
  const META_DESC_MAX = 160
18
18
 
19
- type AiField = 'metaTitle' | 'metaDescription' | 'ogTitle' | 'ogDescription' | 'focusKeyword'
19
+ type AiField =
20
+ | 'metaTitle'
21
+ | 'metaDescription'
22
+ | 'ogTitle'
23
+ | 'ogDescription'
24
+ | 'focusKeyword'
25
+ | 'keyTakeaway'
26
+
27
+ const KEY_TAKEAWAY_MAX = 240
20
28
 
21
29
  function CharCount({ value, max }: { value: string; max: number }) {
22
30
  const over = value.length > max
@@ -100,6 +108,7 @@ export function SeoEditorDrawer({
100
108
  metaDescription: rec.metaDescription ?? '',
101
109
  canonicalUrl: rec.canonicalUrl ?? '',
102
110
  focusKeyword: rec.focusKeyword ?? '',
111
+ keyTakeaway: rec.keyTakeaway ?? '',
103
112
  ogTitle: rec.ogTitle ?? '',
104
113
  ogDescription: rec.ogDescription ?? '',
105
114
  noindex: rec.noindex,
@@ -248,6 +257,23 @@ export function SeoEditorDrawer({
248
257
  />
249
258
  </Field>
250
259
 
260
+ <Field
261
+ label="Key takeaway"
262
+ hint={
263
+ <span className="flex items-center gap-2">
264
+ <CharCount value={form.keyTakeaway ?? ''} max={KEY_TAKEAWAY_MAX} />
265
+ {aiButton('keyTakeaway')}
266
+ </span>
267
+ }
268
+ >
269
+ <textarea
270
+ className={`${inputCls} min-h-16 resize-y`}
271
+ value={form.keyTakeaway ?? ''}
272
+ onChange={(e) => update({ keyTakeaway: e.target.value })}
273
+ placeholder="One or two sentences AI answer engines can quote — what is this page about?"
274
+ />
275
+ </Field>
276
+
251
277
  <Field label="Canonical URL">
252
278
  <input
253
279
  className={inputCls}
@@ -62,7 +62,7 @@ export function CardTitle({
62
62
  }: HTMLAttributes<HTMLHeadingElement>) {
63
63
  return (
64
64
  <h3
65
- className={`text-lg leading-tight font-semibold tracking-tight text-[var(--card-foreground)] ${className}`}
65
+ className={`text-lg leading-tight font-medium tracking-tight text-[var(--card-foreground)] ${className}`}
66
66
  {...rest}
67
67
  >
68
68
  {children}
@@ -13,7 +13,7 @@ export function EmptyState({ icon, title, description, actionLabel, onAction }:
13
13
  return (
14
14
  <div className="flex flex-col items-center justify-center py-16 text-center">
15
15
  {icon && <div className="mb-4 text-[var(--muted-foreground)]">{icon}</div>}
16
- <h3 className="text-lg font-semibold">{title}</h3>
16
+ <h3 className="text-lg font-medium">{title}</h3>
17
17
  {description && (
18
18
  <p className="mt-1 max-w-sm text-sm text-[var(--muted-foreground)]">{description}</p>
19
19
  )}
@@ -11,11 +11,15 @@ export interface SlugFieldProps {
11
11
  helpText?: string
12
12
  }
13
13
 
14
+ // Mirrors `normalizeSlug` in @actuate-media/cms-core so the live-edited value
15
+ // matches what the server will accept (lowercase alphanumerics + single
16
+ // hyphens; underscores and other punctuation are collapsed to hyphens).
14
17
  function toSlug(text: string): string {
15
18
  return text
16
19
  .toLowerCase()
17
- .replace(/[^\w\s-]/g, '')
18
- .replace(/[\s_]+/g, '-')
20
+ .normalize('NFKD')
21
+ .replace(/[\u0300-\u036f]/g, '')
22
+ .replace(/[^a-z0-9]+/g, '-')
19
23
  .replace(/^-+|-+$/g, '')
20
24
  }
21
25
 
@@ -390,7 +390,7 @@ function renderNavTree(items: NavItem[], ctx: NavRenderContext): ReactNode {
390
390
  </div>
391
391
  ) : (
392
392
  <div key={`g-${item.group}`} className="px-3 pt-4 pb-1">
393
- <span className="text-sidebar-foreground/55 text-[10px] font-semibold tracking-wider uppercase">
393
+ <span className="text-sidebar-foreground/55 text-[10px] font-medium tracking-wider uppercase">
394
394
  {item.group}
395
395
  </span>
396
396
  </div>
@@ -79,7 +79,7 @@ export function PageContainer({
79
79
  {(title || description) && (
80
80
  <div className="min-w-0">
81
81
  {title && (
82
- <h1 className="text-foreground truncate text-2xl font-semibold tracking-tight">
82
+ <h1 className="text-foreground truncate text-2xl font-medium tracking-tight">
83
83
  {title}
84
84
  </h1>
85
85
  )}
@@ -33,6 +33,7 @@ import {
33
33
  validateSectionContent,
34
34
  type PageSection,
35
35
  } from '@actuate-media/cms-core/page-sections'
36
+ import { validateSlug } from '@actuate-media/cms-core/client'
36
37
 
37
38
  import { missingCollectionFields } from './collection-schema.js'
38
39
 
@@ -108,6 +109,11 @@ export function validatePage(page: EditorPage, forPublish = false): ValidationRe
108
109
  if (isBlank(page.title)) errors.push('Page title is required.')
109
110
  if (isBlank(page.path)) errors.push('Page path is required.')
110
111
 
112
+ // Mirror the server publish gate so an invalid/reserved slug surfaces in the
113
+ // validation summary rather than only as a toast after the publish attempt.
114
+ const slugError = validateSlug(page.slug)
115
+ if (slugError) errors.push(slugError)
116
+
111
117
  if (forPublish) {
112
118
  const visible = page.sections.filter((s) => s.visible)
113
119
  if (visible.length === 0) {
@@ -41,6 +41,7 @@ import {
41
41
  type PageSection,
42
42
  type PostHeaderConfig,
43
43
  } from '@actuate-media/cms-core/page-sections'
44
+ import { validateSlug } from '@actuate-media/cms-core/client'
44
45
 
45
46
  export type { PageSection, PostHeaderConfig } from '@actuate-media/cms-core/page-sections'
46
47
 
@@ -139,6 +140,11 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
139
140
  if (isBlank(post.title)) errors.push('Post title is required.')
140
141
  if (isBlank(post.slug)) errors.push('Post slug is required.')
141
142
 
143
+ // Mirror the server publish gate so an invalid/reserved slug surfaces in the
144
+ // validation summary rather than only as a toast after the publish attempt.
145
+ const slugError = validateSlug(post.slug)
146
+ if (slugError) errors.push(slugError)
147
+
142
148
  if (forPublish) {
143
149
  const visible = post.sections.filter((s) => s.visible)
144
150
  // Unmanaged (externally-managed) sections have no registered field schema,
@@ -150,6 +150,7 @@ export interface SeoContentRecord {
150
150
  metaTitle: string | null
151
151
  metaDescription: string | null
152
152
  focusKeyword: string | null
153
+ keyTakeaway: string | null
153
154
  ogTitle: string | null
154
155
  ogDescription: string | null
155
156
  ogImageId: string | null
@@ -190,6 +191,7 @@ export interface SeoFieldPayload {
190
191
  metaDescription?: string
191
192
  canonicalUrl?: string
192
193
  focusKeyword?: string
194
+ keyTakeaway?: string
193
195
  ogTitle?: string
194
196
  ogDescription?: string
195
197
  ogImage?: string
@@ -620,7 +622,13 @@ function isAiUnavailable(status?: number): boolean {
620
622
  }
621
623
 
622
624
  export async function generateSeoField(
623
- field: 'metaTitle' | 'metaDescription' | 'ogTitle' | 'ogDescription' | 'focusKeyword',
625
+ field:
626
+ | 'metaTitle'
627
+ | 'metaDescription'
628
+ | 'ogTitle'
629
+ | 'ogDescription'
630
+ | 'focusKeyword'
631
+ | 'keyTakeaway',
624
632
  context: { entityType: string; entityId: string },
625
633
  ): Promise<SeoAiResult> {
626
634
  const res = await cmsApi<SeoAiResult>('/ai/seo/generate-field', {
@@ -284,7 +284,7 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
284
284
  <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
285
285
  <div>
286
286
  <h1
287
- className="text-xl font-semibold sm:text-2xl"
287
+ className="text-xl font-medium sm:text-2xl"
288
288
  style={{ color: 'var(--actuate-text, #111827)' }}
289
289
  >
290
290
  {labels.plural}