@prenta/admin 2.6.1 → 2.7.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 (794) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/design-system/tokens.css +179 -85
  3. package/design-system/tokens.json +276 -85
  4. package/design-system/tokens.scss +148 -44
  5. package/design-system/tokens.tailwind.cjs +50 -1
  6. package/dist/AdminRoot.d.ts.map +1 -1
  7. package/dist/AdminRoot.js +42 -20
  8. package/dist/AdminRoot.js.map +1 -1
  9. package/dist/LoginRoot.d.ts.map +1 -1
  10. package/dist/LoginRoot.js +2 -1
  11. package/dist/LoginRoot.js.map +1 -1
  12. package/dist/__tests__/components/author-avatar.test.js +3 -17
  13. package/dist/__tests__/components/author-avatar.test.js.map +1 -1
  14. package/dist/__tests__/components/notification-bell.test.js +86 -15
  15. package/dist/__tests__/components/notification-bell.test.js.map +1 -1
  16. package/dist/__tests__/components/responsive-tiers.render.test.d.ts +2 -0
  17. package/dist/__tests__/components/responsive-tiers.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/components/responsive-tiers.render.test.js +313 -0
  19. package/dist/__tests__/components/responsive-tiers.render.test.js.map +1 -0
  20. package/dist/__tests__/components/serp-preview.render.test.js +6 -3
  21. package/dist/__tests__/components/serp-preview.render.test.js.map +1 -1
  22. package/dist/__tests__/components/shell-primitives.render.test.d.ts +2 -0
  23. package/dist/__tests__/components/shell-primitives.render.test.d.ts.map +1 -0
  24. package/dist/__tests__/components/shell-primitives.render.test.js +716 -0
  25. package/dist/__tests__/components/shell-primitives.render.test.js.map +1 -0
  26. package/dist/__tests__/components/theme-provider-accent.test.js +49 -7
  27. package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -1
  28. package/dist/__tests__/components/toggle.test.js +10 -3
  29. package/dist/__tests__/components/toggle.test.js.map +1 -1
  30. package/dist/__tests__/components/ui-primitives.test.js +3 -7
  31. package/dist/__tests__/components/ui-primitives.test.js.map +1 -1
  32. package/dist/__tests__/layout/build-nav-items.test.js +1 -1
  33. package/dist/__tests__/layout/build-nav-items.test.js.map +1 -1
  34. package/dist/__tests__/layout/shell-nav.render.test.d.ts +2 -0
  35. package/dist/__tests__/layout/shell-nav.render.test.d.ts.map +1 -0
  36. package/dist/__tests__/layout/shell-nav.render.test.js +736 -0
  37. package/dist/__tests__/layout/shell-nav.render.test.js.map +1 -0
  38. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +8 -4
  39. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -1
  40. package/dist/__tests__/layout/sidebar-groups.render.test.d.ts +2 -0
  41. package/dist/__tests__/layout/sidebar-groups.render.test.d.ts.map +1 -0
  42. package/dist/__tests__/layout/sidebar-groups.render.test.js +56 -0
  43. package/dist/__tests__/layout/sidebar-groups.render.test.js.map +1 -0
  44. package/dist/__tests__/layout/sidebar-menus.render.test.d.ts +2 -0
  45. package/dist/__tests__/layout/sidebar-menus.render.test.d.ts.map +1 -0
  46. package/dist/__tests__/layout/sidebar-menus.render.test.js +82 -0
  47. package/dist/__tests__/layout/sidebar-menus.render.test.js.map +1 -0
  48. package/dist/__tests__/layout/sidebar-plugin-nav.test.js +1 -1
  49. package/dist/__tests__/layout/sidebar-plugin-nav.test.js.map +1 -1
  50. package/dist/__tests__/layout/sidebar-posts.test.js +48 -38
  51. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  52. package/dist/__tests__/layout/sidebar-submenu.render.test.js +26 -10
  53. package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -1
  54. package/dist/__tests__/layout/topbar.render.test.d.ts +2 -0
  55. package/dist/__tests__/layout/topbar.render.test.d.ts.map +1 -0
  56. package/dist/__tests__/layout/topbar.render.test.js +406 -0
  57. package/dist/__tests__/layout/topbar.render.test.js.map +1 -0
  58. package/dist/__tests__/lib/api.test.js +2 -0
  59. package/dist/__tests__/lib/api.test.js.map +1 -1
  60. package/dist/__tests__/lib/job-drawer.test.d.ts +2 -0
  61. package/dist/__tests__/lib/job-drawer.test.d.ts.map +1 -0
  62. package/dist/__tests__/lib/job-drawer.test.js +393 -0
  63. package/dist/__tests__/lib/job-drawer.test.js.map +1 -0
  64. package/dist/__tests__/lib/job-view.test.d.ts +2 -0
  65. package/dist/__tests__/lib/job-view.test.d.ts.map +1 -0
  66. package/dist/__tests__/lib/job-view.test.js +387 -0
  67. package/dist/__tests__/lib/job-view.test.js.map +1 -0
  68. package/dist/__tests__/lib/jobs-service.test.d.ts +2 -0
  69. package/dist/__tests__/lib/jobs-service.test.d.ts.map +1 -0
  70. package/dist/__tests__/lib/jobs-service.test.js +88 -0
  71. package/dist/__tests__/lib/jobs-service.test.js.map +1 -0
  72. package/dist/__tests__/router/admin-route-order.test.d.ts +2 -0
  73. package/dist/__tests__/router/admin-route-order.test.d.ts.map +1 -0
  74. package/dist/__tests__/router/admin-route-order.test.js +36 -0
  75. package/dist/__tests__/router/admin-route-order.test.js.map +1 -0
  76. package/dist/__tests__/styles/ai-gradient-contrast.test.d.ts +2 -0
  77. package/dist/__tests__/styles/ai-gradient-contrast.test.d.ts.map +1 -0
  78. package/dist/__tests__/styles/ai-gradient-contrast.test.js +110 -0
  79. package/dist/__tests__/styles/ai-gradient-contrast.test.js.map +1 -0
  80. package/dist/__tests__/styles/breakpoint-order.test.d.ts +2 -0
  81. package/dist/__tests__/styles/breakpoint-order.test.d.ts.map +1 -0
  82. package/dist/__tests__/styles/breakpoint-order.test.js +52 -0
  83. package/dist/__tests__/styles/breakpoint-order.test.js.map +1 -0
  84. package/dist/__tests__/styles/responsive-layer.test.js +303 -12
  85. package/dist/__tests__/styles/responsive-layer.test.js.map +1 -1
  86. package/dist/__tests__/styles/tailwind-preset.test.d.ts +2 -0
  87. package/dist/__tests__/styles/tailwind-preset.test.d.ts.map +1 -0
  88. package/dist/__tests__/styles/tailwind-preset.test.js +147 -0
  89. package/dist/__tests__/styles/tailwind-preset.test.js.map +1 -0
  90. package/dist/__tests__/styles/theme-token-scope.test.js +9 -3
  91. package/dist/__tests__/styles/theme-token-scope.test.js.map +1 -1
  92. package/dist/__tests__/styles/tiers.test.d.ts +2 -0
  93. package/dist/__tests__/styles/tiers.test.d.ts.map +1 -0
  94. package/dist/__tests__/styles/tiers.test.js +121 -0
  95. package/dist/__tests__/styles/tiers.test.js.map +1 -0
  96. package/dist/__tests__/styles/tokens-drift.test.js +57 -6
  97. package/dist/__tests__/styles/tokens-drift.test.js.map +1 -1
  98. package/dist/__tests__/views/admin-lazy-route.render.test.js +27 -1
  99. package/dist/__tests__/views/admin-lazy-route.render.test.js.map +1 -1
  100. package/dist/__tests__/views/ai-settings.render.test.js +4 -4
  101. package/dist/__tests__/views/appearance-menus-route.render.test.d.ts +2 -0
  102. package/dist/__tests__/views/appearance-menus-route.render.test.d.ts.map +1 -0
  103. package/dist/__tests__/views/appearance-menus-route.render.test.js +50 -0
  104. package/dist/__tests__/views/appearance-menus-route.render.test.js.map +1 -0
  105. package/dist/__tests__/views/appearance-settings.render.test.js +8 -6
  106. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
  107. package/dist/__tests__/views/appearance-theme-provider.render.test.d.ts +2 -0
  108. package/dist/__tests__/views/appearance-theme-provider.render.test.d.ts.map +1 -0
  109. package/dist/__tests__/views/appearance-theme-provider.render.test.js +286 -0
  110. package/dist/__tests__/views/appearance-theme-provider.render.test.js.map +1 -0
  111. package/dist/__tests__/views/dashboard.test.js +37 -0
  112. package/dist/__tests__/views/dashboard.test.js.map +1 -1
  113. package/dist/__tests__/views/document-edit-navigation.render.test.d.ts +2 -0
  114. package/dist/__tests__/views/document-edit-navigation.render.test.d.ts.map +1 -0
  115. package/dist/__tests__/views/document-edit-navigation.render.test.js +101 -0
  116. package/dist/__tests__/views/document-edit-navigation.render.test.js.map +1 -0
  117. package/dist/__tests__/views/document-edit-routes.render.test.d.ts +2 -0
  118. package/dist/__tests__/views/document-edit-routes.render.test.d.ts.map +1 -0
  119. package/dist/__tests__/views/document-edit-routes.render.test.js +113 -0
  120. package/dist/__tests__/views/document-edit-routes.render.test.js.map +1 -0
  121. package/dist/__tests__/views/email-settings.render.test.js +2 -2
  122. package/dist/__tests__/views/form-entries.render.test.js +15 -0
  123. package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
  124. package/dist/__tests__/views/form-submissions.render.test.js +18 -0
  125. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
  126. package/dist/__tests__/views/forms-list.render.test.js +12 -0
  127. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  128. package/dist/__tests__/views/general-settings.render.test.js +26 -10
  129. package/dist/__tests__/views/general-settings.render.test.js.map +1 -1
  130. package/dist/__tests__/views/jobs-drawer.render.test.d.ts +2 -0
  131. package/dist/__tests__/views/jobs-drawer.render.test.d.ts.map +1 -0
  132. package/dist/__tests__/views/jobs-drawer.render.test.js +908 -0
  133. package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -0
  134. package/dist/__tests__/views/jobs-list.render.test.d.ts +2 -0
  135. package/dist/__tests__/views/jobs-list.render.test.d.ts.map +1 -0
  136. package/dist/__tests__/views/jobs-list.render.test.js +695 -0
  137. package/dist/__tests__/views/jobs-list.render.test.js.map +1 -0
  138. package/dist/__tests__/views/kpi-grid.test.d.ts +2 -0
  139. package/dist/__tests__/views/kpi-grid.test.d.ts.map +1 -0
  140. package/dist/__tests__/views/kpi-grid.test.js +26 -0
  141. package/dist/__tests__/views/kpi-grid.test.js.map +1 -0
  142. package/dist/__tests__/views/post-section-editor.test.js +3 -1
  143. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  144. package/dist/__tests__/views/script-tags.render.test.js +25 -0
  145. package/dist/__tests__/views/script-tags.render.test.js.map +1 -1
  146. package/dist/__tests__/views/security-settings.render.test.js +9 -8
  147. package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
  148. package/dist/__tests__/views/seo-settings.render.test.js +7 -7
  149. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  150. package/dist/__tests__/views/settings-save-bar.render.test.d.ts +2 -0
  151. package/dist/__tests__/views/settings-save-bar.render.test.d.ts.map +1 -0
  152. package/dist/__tests__/views/settings-save-bar.render.test.js +68 -0
  153. package/dist/__tests__/views/settings-save-bar.render.test.js.map +1 -0
  154. package/dist/__tests__/views/settings-save-footer.render.test.js +23 -15
  155. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
  156. package/dist/__tests__/views/settings-tabs.render.test.d.ts +2 -0
  157. package/dist/__tests__/views/settings-tabs.render.test.d.ts.map +1 -0
  158. package/dist/__tests__/views/settings-tabs.render.test.js +319 -0
  159. package/dist/__tests__/views/settings-tabs.render.test.js.map +1 -0
  160. package/dist/__tests__/views/updates-tab.render.test.js +4 -3
  161. package/dist/__tests__/views/updates-tab.render.test.js.map +1 -1
  162. package/dist/components/Breadcrumbs.d.ts +8 -1
  163. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  164. package/dist/components/Breadcrumbs.js +6 -0
  165. package/dist/components/Breadcrumbs.js.map +1 -1
  166. package/dist/components/CommandPalette.d.ts.map +1 -1
  167. package/dist/components/CommandPalette.js +40 -4
  168. package/dist/components/CommandPalette.js.map +1 -1
  169. package/dist/components/LocaleSwitcher.d.ts.map +1 -1
  170. package/dist/components/LocaleSwitcher.js +1 -1
  171. package/dist/components/LocaleSwitcher.js.map +1 -1
  172. package/dist/components/NotificationBell.d.ts.map +1 -1
  173. package/dist/components/NotificationBell.js +44 -3
  174. package/dist/components/NotificationBell.js.map +1 -1
  175. package/dist/components/OfflineStatus.js +1 -1
  176. package/dist/components/OfflineStatus.js.map +1 -1
  177. package/dist/components/PresenceIndicator.d.ts.map +1 -1
  178. package/dist/components/PresenceIndicator.js +2 -1
  179. package/dist/components/PresenceIndicator.js.map +1 -1
  180. package/dist/components/ReflowMeter.js +2 -2
  181. package/dist/components/ReflowMeter.js.map +1 -1
  182. package/dist/components/SEOPanel.js +2 -2
  183. package/dist/components/SEOPanel.js.map +1 -1
  184. package/dist/components/ThemeProvider.d.ts +24 -4
  185. package/dist/components/ThemeProvider.d.ts.map +1 -1
  186. package/dist/components/ThemeProvider.js +80 -22
  187. package/dist/components/ThemeProvider.js.map +1 -1
  188. package/dist/components/TipTapEditor.d.ts.map +1 -1
  189. package/dist/components/TipTapEditor.js +3 -4
  190. package/dist/components/TipTapEditor.js.map +1 -1
  191. package/dist/components/forms/EmbedPanel.js +1 -1
  192. package/dist/components/forms/EmbedPanel.js.map +1 -1
  193. package/dist/components/forms/FormEntryDetailPane.js +6 -1
  194. package/dist/components/forms/FormEntryDetailPane.js.map +1 -1
  195. package/dist/components/forms/ShortcodeGlossary.d.ts.map +1 -1
  196. package/dist/components/forms/ShortcodeGlossary.js +6 -1
  197. package/dist/components/forms/ShortcodeGlossary.js.map +1 -1
  198. package/dist/components/forms/primitives.d.ts +15 -5
  199. package/dist/components/forms/primitives.d.ts.map +1 -1
  200. package/dist/components/forms/primitives.js +11 -11
  201. package/dist/components/forms/primitives.js.map +1 -1
  202. package/dist/components/pages/tag-badges.d.ts +2 -1
  203. package/dist/components/pages/tag-badges.d.ts.map +1 -1
  204. package/dist/components/pages/tag-badges.js +4 -3
  205. package/dist/components/pages/tag-badges.js.map +1 -1
  206. package/dist/components/seo/PageAuditPanel.js +2 -2
  207. package/dist/components/seo/PageAuditPanel.js.map +1 -1
  208. package/dist/components/seo/SerpPreview.d.ts +4 -4
  209. package/dist/components/seo/SerpPreview.js +1 -1
  210. package/dist/components/seo/SerpPreview.js.map +1 -1
  211. package/dist/components/seo/primitives.js +2 -2
  212. package/dist/components/seo/primitives.js.map +1 -1
  213. package/dist/components/ui/AuthorAvatar.d.ts +4 -4
  214. package/dist/components/ui/AuthorAvatar.d.ts.map +1 -1
  215. package/dist/components/ui/AuthorAvatar.js +11 -10
  216. package/dist/components/ui/AuthorAvatar.js.map +1 -1
  217. package/dist/components/ui/Badge.d.ts +1 -1
  218. package/dist/components/ui/Badge.js +1 -1
  219. package/dist/components/ui/Badge.js.map +1 -1
  220. package/dist/components/ui/Breadcrumb.d.ts +27 -0
  221. package/dist/components/ui/Breadcrumb.d.ts.map +1 -0
  222. package/dist/components/ui/Breadcrumb.js +41 -0
  223. package/dist/components/ui/Breadcrumb.js.map +1 -0
  224. package/dist/components/ui/Button.d.ts +2 -2
  225. package/dist/components/ui/Button.d.ts.map +1 -1
  226. package/dist/components/ui/Button.js +31 -16
  227. package/dist/components/ui/Button.js.map +1 -1
  228. package/dist/components/ui/Card.d.ts +8 -4
  229. package/dist/components/ui/Card.d.ts.map +1 -1
  230. package/dist/components/ui/Card.js +21 -13
  231. package/dist/components/ui/Card.js.map +1 -1
  232. package/dist/components/ui/CardListControls.d.ts +34 -0
  233. package/dist/components/ui/CardListControls.d.ts.map +1 -0
  234. package/dist/components/ui/CardListControls.js +25 -0
  235. package/dist/components/ui/CardListControls.js.map +1 -0
  236. package/dist/components/ui/CountChip.d.ts +20 -0
  237. package/dist/components/ui/CountChip.d.ts.map +1 -0
  238. package/dist/components/ui/CountChip.js +48 -0
  239. package/dist/components/ui/CountChip.js.map +1 -0
  240. package/dist/components/ui/FilterPill.d.ts +2 -1
  241. package/dist/components/ui/FilterPill.d.ts.map +1 -1
  242. package/dist/components/ui/FilterPill.js +3 -2
  243. package/dist/components/ui/FilterPill.js.map +1 -1
  244. package/dist/components/ui/Input.d.ts +1 -1
  245. package/dist/components/ui/InspectorPane.d.ts +1 -1
  246. package/dist/components/ui/InspectorPane.d.ts.map +1 -1
  247. package/dist/components/ui/InspectorPane.js +15 -4
  248. package/dist/components/ui/InspectorPane.js.map +1 -1
  249. package/dist/components/ui/Modal.d.ts.map +1 -1
  250. package/dist/components/ui/Modal.js +10 -2
  251. package/dist/components/ui/Modal.js.map +1 -1
  252. package/dist/components/ui/PageHeader.d.ts +28 -0
  253. package/dist/components/ui/PageHeader.d.ts.map +1 -0
  254. package/dist/components/ui/PageHeader.js +18 -0
  255. package/dist/components/ui/PageHeader.js.map +1 -0
  256. package/dist/components/ui/RowActions.d.ts +5 -0
  257. package/dist/components/ui/RowActions.d.ts.map +1 -1
  258. package/dist/components/ui/RowActions.js +7 -2
  259. package/dist/components/ui/RowActions.js.map +1 -1
  260. package/dist/components/ui/Segmented.d.ts +21 -0
  261. package/dist/components/ui/Segmented.d.ts.map +1 -0
  262. package/dist/components/ui/Segmented.js +41 -0
  263. package/dist/components/ui/Segmented.js.map +1 -0
  264. package/dist/components/ui/Toaster.d.ts +12 -0
  265. package/dist/components/ui/Toaster.d.ts.map +1 -0
  266. package/dist/components/ui/Toaster.js +103 -0
  267. package/dist/components/ui/Toaster.js.map +1 -0
  268. package/dist/components/ui/Toggle.d.ts +4 -3
  269. package/dist/components/ui/Toggle.d.ts.map +1 -1
  270. package/dist/components/ui/Toggle.js +16 -12
  271. package/dist/components/ui/Toggle.js.map +1 -1
  272. package/dist/components/ui/UnderlineTabs.d.ts +56 -0
  273. package/dist/components/ui/UnderlineTabs.d.ts.map +1 -0
  274. package/dist/components/ui/UnderlineTabs.js +205 -0
  275. package/dist/components/ui/UnderlineTabs.js.map +1 -0
  276. package/dist/components/ui/UnsavedBar.d.ts +25 -0
  277. package/dist/components/ui/UnsavedBar.d.ts.map +1 -0
  278. package/dist/components/ui/UnsavedBar.js +22 -0
  279. package/dist/components/ui/UnsavedBar.js.map +1 -0
  280. package/dist/components/ui/admin-links.d.ts +11 -0
  281. package/dist/components/ui/admin-links.d.ts.map +1 -0
  282. package/dist/components/ui/admin-links.js +10 -0
  283. package/dist/components/ui/admin-links.js.map +1 -0
  284. package/dist/components/ui/card-table.d.ts +43 -0
  285. package/dist/components/ui/card-table.d.ts.map +1 -0
  286. package/dist/components/ui/card-table.js +31 -0
  287. package/dist/components/ui/card-table.js.map +1 -0
  288. package/dist/components/virtual-window.d.ts +7 -0
  289. package/dist/components/virtual-window.d.ts.map +1 -1
  290. package/dist/components/virtual-window.js +5 -2
  291. package/dist/components/virtual-window.js.map +1 -1
  292. package/dist/fields/RelationshipField.js +1 -1
  293. package/dist/fields/RelationshipField.js.map +1 -1
  294. package/dist/files/dm-mono-latin-400-normal.woff +0 -0
  295. package/dist/files/dm-mono-latin-400-normal.woff2 +0 -0
  296. package/dist/files/dm-mono-latin-500-normal.woff +0 -0
  297. package/dist/files/dm-mono-latin-500-normal.woff2 +0 -0
  298. package/dist/files/dm-mono-latin-ext-400-normal.woff +0 -0
  299. package/dist/files/dm-mono-latin-ext-400-normal.woff2 +0 -0
  300. package/dist/files/dm-mono-latin-ext-500-normal.woff +0 -0
  301. package/dist/files/dm-mono-latin-ext-500-normal.woff2 +0 -0
  302. package/dist/files/dm-sans-latin-400-normal.woff +0 -0
  303. package/dist/files/dm-sans-latin-400-normal.woff2 +0 -0
  304. package/dist/files/dm-sans-latin-500-normal.woff +0 -0
  305. package/dist/files/dm-sans-latin-500-normal.woff2 +0 -0
  306. package/dist/files/dm-sans-latin-600-normal.woff +0 -0
  307. package/dist/files/dm-sans-latin-600-normal.woff2 +0 -0
  308. package/dist/files/dm-sans-latin-700-normal.woff +0 -0
  309. package/dist/files/dm-sans-latin-700-normal.woff2 +0 -0
  310. package/dist/files/dm-sans-latin-ext-400-normal.woff +0 -0
  311. package/dist/files/dm-sans-latin-ext-400-normal.woff2 +0 -0
  312. package/dist/files/dm-sans-latin-ext-500-normal.woff +0 -0
  313. package/dist/files/dm-sans-latin-ext-500-normal.woff2 +0 -0
  314. package/dist/files/dm-sans-latin-ext-600-normal.woff +0 -0
  315. package/dist/files/dm-sans-latin-ext-600-normal.woff2 +0 -0
  316. package/dist/files/dm-sans-latin-ext-700-normal.woff +0 -0
  317. package/dist/files/dm-sans-latin-ext-700-normal.woff2 +0 -0
  318. package/dist/hooks/useAppearanceBootstrap.d.ts.map +1 -1
  319. package/dist/hooks/useAppearanceBootstrap.js +9 -3
  320. package/dist/hooks/useAppearanceBootstrap.js.map +1 -1
  321. package/dist/index.d.ts +15 -0
  322. package/dist/index.d.ts.map +1 -1
  323. package/dist/index.js +8 -0
  324. package/dist/index.js.map +1 -1
  325. package/dist/layout/Header.d.ts +14 -5
  326. package/dist/layout/Header.d.ts.map +1 -1
  327. package/dist/layout/Header.js +78 -13
  328. package/dist/layout/Header.js.map +1 -1
  329. package/dist/layout/Layout.d.ts +5 -3
  330. package/dist/layout/Layout.d.ts.map +1 -1
  331. package/dist/layout/Layout.js +45 -54
  332. package/dist/layout/Layout.js.map +1 -1
  333. package/dist/layout/MobileTabBar.d.ts +20 -0
  334. package/dist/layout/MobileTabBar.d.ts.map +1 -0
  335. package/dist/layout/MobileTabBar.js +29 -0
  336. package/dist/layout/MobileTabBar.js.map +1 -0
  337. package/dist/layout/Sidebar.d.ts +8 -26
  338. package/dist/layout/Sidebar.d.ts.map +1 -1
  339. package/dist/layout/Sidebar.js +309 -345
  340. package/dist/layout/Sidebar.js.map +1 -1
  341. package/dist/layout/nav-config.d.ts +101 -0
  342. package/dist/layout/nav-config.d.ts.map +1 -0
  343. package/dist/layout/nav-config.js +297 -0
  344. package/dist/layout/nav-config.js.map +1 -0
  345. package/dist/layout/primitives/AdminShell.d.ts +10 -2
  346. package/dist/layout/primitives/AdminShell.d.ts.map +1 -1
  347. package/dist/layout/primitives/AdminShell.js +114 -6
  348. package/dist/layout/primitives/AdminShell.js.map +1 -1
  349. package/dist/layout/topbar-classes.d.ts +8 -0
  350. package/dist/layout/topbar-classes.d.ts.map +1 -0
  351. package/dist/layout/topbar-classes.js +8 -0
  352. package/dist/layout/topbar-classes.js.map +1 -0
  353. package/dist/layout/use-nav-items.d.ts +10 -0
  354. package/dist/layout/use-nav-items.d.ts.map +1 -0
  355. package/dist/layout/use-nav-items.js +39 -0
  356. package/dist/layout/use-nav-items.js.map +1 -0
  357. package/dist/lib/api.d.ts +6 -0
  358. package/dist/lib/api.d.ts.map +1 -1
  359. package/dist/lib/api.js +1 -1
  360. package/dist/lib/api.js.map +1 -1
  361. package/dist/lib/appearance-api.d.ts +3 -1
  362. package/dist/lib/appearance-api.d.ts.map +1 -1
  363. package/dist/lib/appearance-api.js +1 -0
  364. package/dist/lib/appearance-api.js.map +1 -1
  365. package/dist/lib/job-drawer.d.ts +128 -0
  366. package/dist/lib/job-drawer.d.ts.map +1 -0
  367. package/dist/lib/job-drawer.js +351 -0
  368. package/dist/lib/job-drawer.js.map +1 -0
  369. package/dist/lib/job-view.d.ts +187 -0
  370. package/dist/lib/job-view.d.ts.map +1 -0
  371. package/dist/lib/job-view.js +363 -0
  372. package/dist/lib/job-view.js.map +1 -0
  373. package/dist/lib/jobs-service.d.ts +134 -0
  374. package/dist/lib/jobs-service.d.ts.map +1 -0
  375. package/dist/lib/jobs-service.js +179 -0
  376. package/dist/lib/jobs-service.js.map +1 -0
  377. package/dist/lib/sheet-focus.d.ts +24 -0
  378. package/dist/lib/sheet-focus.d.ts.map +1 -0
  379. package/dist/lib/sheet-focus.js +61 -0
  380. package/dist/lib/sheet-focus.js.map +1 -0
  381. package/dist/lib/tiers.d.ts +30 -0
  382. package/dist/lib/tiers.d.ts.map +1 -0
  383. package/dist/lib/tiers.js +43 -0
  384. package/dist/lib/tiers.js.map +1 -0
  385. package/dist/prenta-admin.css +1 -1
  386. package/dist/router/admin-routes.d.ts.map +1 -1
  387. package/dist/router/admin-routes.js +8 -0
  388. package/dist/router/admin-routes.js.map +1 -1
  389. package/dist/router/index.d.ts +5 -1
  390. package/dist/router/index.d.ts.map +1 -1
  391. package/dist/router/index.js +7 -0
  392. package/dist/router/index.js.map +1 -1
  393. package/dist/views/ApiKeys.js +2 -2
  394. package/dist/views/ApiKeys.js.map +1 -1
  395. package/dist/views/AppearanceChrome.d.ts +6 -0
  396. package/dist/views/AppearanceChrome.d.ts.map +1 -0
  397. package/dist/views/AppearanceChrome.js +25 -0
  398. package/dist/views/AppearanceChrome.js.map +1 -0
  399. package/dist/views/CollectionList.d.ts.map +1 -1
  400. package/dist/views/CollectionList.js +73 -53
  401. package/dist/views/CollectionList.js.map +1 -1
  402. package/dist/views/Dashboard.d.ts.map +1 -1
  403. package/dist/views/Dashboard.js +14 -8
  404. package/dist/views/Dashboard.js.map +1 -1
  405. package/dist/views/DocumentEdit.d.ts +6 -1
  406. package/dist/views/DocumentEdit.d.ts.map +1 -1
  407. package/dist/views/DocumentEdit.js +44 -8
  408. package/dist/views/DocumentEdit.js.map +1 -1
  409. package/dist/views/FormDeliveries.d.ts.map +1 -1
  410. package/dist/views/FormDeliveries.js +7 -1
  411. package/dist/views/FormDeliveries.js.map +1 -1
  412. package/dist/views/FormEntries.d.ts.map +1 -1
  413. package/dist/views/FormEntries.js +11 -2
  414. package/dist/views/FormEntries.js.map +1 -1
  415. package/dist/views/FormSubmissions.d.ts +1 -1
  416. package/dist/views/FormSubmissions.d.ts.map +1 -1
  417. package/dist/views/FormSubmissions.js +15 -4
  418. package/dist/views/FormSubmissions.js.map +1 -1
  419. package/dist/views/Forms.d.ts.map +1 -1
  420. package/dist/views/Forms.js +6 -4
  421. package/dist/views/Forms.js.map +1 -1
  422. package/dist/views/FormsSettings.d.ts +2 -0
  423. package/dist/views/FormsSettings.d.ts.map +1 -0
  424. package/dist/views/FormsSettings.js +37 -0
  425. package/dist/views/FormsSettings.js.map +1 -0
  426. package/dist/views/Jobs/BusyButton.d.ts +15 -0
  427. package/dist/views/Jobs/BusyButton.d.ts.map +1 -0
  428. package/dist/views/Jobs/BusyButton.js +26 -0
  429. package/dist/views/Jobs/BusyButton.js.map +1 -0
  430. package/dist/views/Jobs/CancelJobDialog.d.ts +21 -0
  431. package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -0
  432. package/dist/views/Jobs/CancelJobDialog.js +66 -0
  433. package/dist/views/Jobs/CancelJobDialog.js.map +1 -0
  434. package/dist/views/Jobs/DrawerActivity.d.ts +16 -0
  435. package/dist/views/Jobs/DrawerActivity.d.ts.map +1 -0
  436. package/dist/views/Jobs/DrawerActivity.js +59 -0
  437. package/dist/views/Jobs/DrawerActivity.js.map +1 -0
  438. package/dist/views/Jobs/DrawerOverview.d.ts +28 -0
  439. package/dist/views/Jobs/DrawerOverview.d.ts.map +1 -0
  440. package/dist/views/Jobs/DrawerOverview.js +32 -0
  441. package/dist/views/Jobs/DrawerOverview.js.map +1 -0
  442. package/dist/views/Jobs/DrawerResults.d.ts +25 -0
  443. package/dist/views/Jobs/DrawerResults.d.ts.map +1 -0
  444. package/dist/views/Jobs/DrawerResults.js +50 -0
  445. package/dist/views/Jobs/DrawerResults.js.map +1 -0
  446. package/dist/views/Jobs/DrawerReview.d.ts +44 -0
  447. package/dist/views/Jobs/DrawerReview.d.ts.map +1 -0
  448. package/dist/views/Jobs/DrawerReview.js +110 -0
  449. package/dist/views/Jobs/DrawerReview.js.map +1 -0
  450. package/dist/views/Jobs/JobDrawer.d.ts +31 -0
  451. package/dist/views/Jobs/JobDrawer.d.ts.map +1 -0
  452. package/dist/views/Jobs/JobDrawer.js +353 -0
  453. package/dist/views/Jobs/JobDrawer.js.map +1 -0
  454. package/dist/views/Jobs/JobsTable.d.ts +19 -0
  455. package/dist/views/Jobs/JobsTable.d.ts.map +1 -0
  456. package/dist/views/Jobs/JobsTable.js +80 -0
  457. package/dist/views/Jobs/JobsTable.js.map +1 -0
  458. package/dist/views/Jobs/WorkerStrip.d.ts +22 -0
  459. package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -0
  460. package/dist/views/Jobs/WorkerStrip.js +63 -0
  461. package/dist/views/Jobs/WorkerStrip.js.map +1 -0
  462. package/dist/views/Jobs/atoms.d.ts +19 -0
  463. package/dist/views/Jobs/atoms.d.ts.map +1 -0
  464. package/dist/views/Jobs/atoms.js +52 -0
  465. package/dist/views/Jobs/atoms.js.map +1 -0
  466. package/dist/views/Jobs/focus.d.ts +23 -0
  467. package/dist/views/Jobs/focus.d.ts.map +1 -0
  468. package/dist/views/Jobs/focus.js +54 -0
  469. package/dist/views/Jobs/focus.js.map +1 -0
  470. package/dist/views/Jobs/icons.d.ts +22 -0
  471. package/dist/views/Jobs/icons.d.ts.map +1 -0
  472. package/dist/views/Jobs/icons.js +23 -0
  473. package/dist/views/Jobs/icons.js.map +1 -0
  474. package/dist/views/Jobs/placeholders.d.ts +6 -0
  475. package/dist/views/Jobs/placeholders.d.ts.map +1 -0
  476. package/dist/views/Jobs/placeholders.js +20 -0
  477. package/dist/views/Jobs/placeholders.js.map +1 -0
  478. package/dist/views/Jobs/useJobDetail.d.ts +21 -0
  479. package/dist/views/Jobs/useJobDetail.d.ts.map +1 -0
  480. package/dist/views/Jobs/useJobDetail.js +39 -0
  481. package/dist/views/Jobs/useJobDetail.js.map +1 -0
  482. package/dist/views/Jobs/useJobsFeed.d.ts +35 -0
  483. package/dist/views/Jobs/useJobsFeed.d.ts.map +1 -0
  484. package/dist/views/Jobs/useJobsFeed.js +231 -0
  485. package/dist/views/Jobs/useJobsFeed.js.map +1 -0
  486. package/dist/views/Jobs/useReviewItems.d.ts +39 -0
  487. package/dist/views/Jobs/useReviewItems.d.ts.map +1 -0
  488. package/dist/views/Jobs/useReviewItems.js +140 -0
  489. package/dist/views/Jobs/useReviewItems.js.map +1 -0
  490. package/dist/views/Jobs.d.ts +10 -1
  491. package/dist/views/Jobs.d.ts.map +1 -1
  492. package/dist/views/Jobs.js +126 -35
  493. package/dist/views/Jobs.js.map +1 -1
  494. package/dist/views/MediaBrowser.d.ts.map +1 -1
  495. package/dist/views/MediaBrowser.js +37 -9
  496. package/dist/views/MediaBrowser.js.map +1 -1
  497. package/dist/views/Pages/PagesListView.d.ts +1 -1
  498. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  499. package/dist/views/Pages/PagesListView.js +34 -5
  500. package/dist/views/Pages/PagesListView.js.map +1 -1
  501. package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -1
  502. package/dist/views/Posts/NewPostTypeInstructions.js +12 -2
  503. package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -1
  504. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  505. package/dist/views/Posts/PostTypesView.js +10 -1
  506. package/dist/views/Posts/PostTypesView.js.map +1 -1
  507. package/dist/views/Posts/PostsListView.d.ts +1 -2
  508. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  509. package/dist/views/Posts/PostsListView.js +31 -3
  510. package/dist/views/Posts/PostsListView.js.map +1 -1
  511. package/dist/views/SEO.d.ts.map +1 -1
  512. package/dist/views/SEO.js +3 -3
  513. package/dist/views/SEO.js.map +1 -1
  514. package/dist/views/ScriptTags.d.ts.map +1 -1
  515. package/dist/views/ScriptTags.js +20 -15
  516. package/dist/views/ScriptTags.js.map +1 -1
  517. package/dist/views/Settings.d.ts.map +1 -1
  518. package/dist/views/Settings.js +115 -51
  519. package/dist/views/Settings.js.map +1 -1
  520. package/dist/views/SpamFolder.d.ts +2 -0
  521. package/dist/views/SpamFolder.d.ts.map +1 -0
  522. package/dist/views/SpamFolder.js +14 -0
  523. package/dist/views/SpamFolder.js.map +1 -0
  524. package/dist/views/Trash.d.ts +6 -1
  525. package/dist/views/Trash.d.ts.map +1 -1
  526. package/dist/views/Trash.js +7 -2
  527. package/dist/views/Trash.js.map +1 -1
  528. package/dist/views/Users.js +2 -2
  529. package/dist/views/Users.js.map +1 -1
  530. package/dist/views/WidgetsPlaceholder.d.ts +2 -0
  531. package/dist/views/WidgetsPlaceholder.d.ts.map +1 -0
  532. package/dist/views/WidgetsPlaceholder.js +15 -0
  533. package/dist/views/WidgetsPlaceholder.js.map +1 -0
  534. package/dist/views/dashboard/kpi-grid.d.ts +18 -0
  535. package/dist/views/dashboard/kpi-grid.d.ts.map +1 -0
  536. package/dist/views/dashboard/kpi-grid.js +36 -0
  537. package/dist/views/dashboard/kpi-grid.js.map +1 -0
  538. package/dist/views/lazy-views.d.ts +4 -0
  539. package/dist/views/lazy-views.d.ts.map +1 -1
  540. package/dist/views/lazy-views.js +4 -0
  541. package/dist/views/lazy-views.js.map +1 -1
  542. package/dist/views/page-editor/PageTab.js +1 -1
  543. package/dist/views/page-editor/PageTab.js.map +1 -1
  544. package/dist/views/page-editor/ReviewBar.js +1 -1
  545. package/dist/views/page-editor/ReviewBar.js.map +1 -1
  546. package/dist/views/page-editor/VersionHistoryPanel.js +1 -1
  547. package/dist/views/page-editor/VersionHistoryPanel.js.map +1 -1
  548. package/dist/views/seo/AuditTab.js +1 -1
  549. package/dist/views/seo/AuditTab.js.map +1 -1
  550. package/dist/views/seo/OverviewTab.d.ts.map +1 -1
  551. package/dist/views/seo/OverviewTab.js +2 -2
  552. package/dist/views/seo/OverviewTab.js.map +1 -1
  553. package/dist/views/seo/ProposalsTab.js +1 -1
  554. package/dist/views/seo/ProposalsTab.js.map +1 -1
  555. package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
  556. package/dist/views/settings/AISettingsTab.js +4 -2
  557. package/dist/views/settings/AISettingsTab.js.map +1 -1
  558. package/dist/views/settings/AccessProtectionCard.js +1 -1
  559. package/dist/views/settings/AccessProtectionCard.js.map +1 -1
  560. package/dist/views/settings/AdminThemeCard.d.ts.map +1 -1
  561. package/dist/views/settings/AdminThemeCard.js +63 -15
  562. package/dist/views/settings/AdminThemeCard.js.map +1 -1
  563. package/dist/views/settings/AppearanceTab.d.ts.map +1 -1
  564. package/dist/views/settings/AppearanceTab.js +4 -2
  565. package/dist/views/settings/AppearanceTab.js.map +1 -1
  566. package/dist/views/settings/BrandPreviewCard.js +1 -1
  567. package/dist/views/settings/BrandPreviewCard.js.map +1 -1
  568. package/dist/views/settings/BrandVoiceCard.js +1 -1
  569. package/dist/views/settings/BrandVoiceCard.js.map +1 -1
  570. package/dist/views/settings/EmailSettingsTab.d.ts.map +1 -1
  571. package/dist/views/settings/EmailSettingsTab.js +5 -3
  572. package/dist/views/settings/EmailSettingsTab.js.map +1 -1
  573. package/dist/views/settings/GeneralSettingsTab.d.ts.map +1 -1
  574. package/dist/views/settings/GeneralSettingsTab.js +4 -2
  575. package/dist/views/settings/GeneralSettingsTab.js.map +1 -1
  576. package/dist/views/settings/ProviderConnectionCard.js +1 -1
  577. package/dist/views/settings/ProviderConnectionCard.js.map +1 -1
  578. package/dist/views/settings/SearchDataCard.js +1 -1
  579. package/dist/views/settings/SearchDataCard.js.map +1 -1
  580. package/dist/views/settings/SecurityStatusCard.js +2 -2
  581. package/dist/views/settings/SecurityStatusCard.js.map +1 -1
  582. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  583. package/dist/views/settings/SecurityTab.js +4 -2
  584. package/dist/views/settings/SecurityTab.js.map +1 -1
  585. package/dist/views/settings/SeoAutopilotCard.js +2 -2
  586. package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
  587. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  588. package/dist/views/settings/SeoSettingsTab.js +5 -3
  589. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  590. package/dist/views/settings/SiteIndexStatusCard.js +1 -1
  591. package/dist/views/settings/SiteIndexStatusCard.js.map +1 -1
  592. package/dist/views/settings/SiteThemeCard.js +1 -1
  593. package/dist/views/settings/UpdatesTab.d.ts.map +1 -1
  594. package/dist/views/settings/UpdatesTab.js +6 -5
  595. package/dist/views/settings/UpdatesTab.js.map +1 -1
  596. package/dist/views/settings/components.d.ts +7 -11
  597. package/dist/views/settings/components.d.ts.map +1 -1
  598. package/dist/views/settings/components.js +18 -12
  599. package/dist/views/settings/components.js.map +1 -1
  600. package/dist/views/settings/useAppearanceSettings.d.ts +7 -1
  601. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
  602. package/dist/views/settings/useAppearanceSettings.js +89 -22
  603. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  604. package/dist/views/users/UserDetailDrawer.js +1 -1
  605. package/dist/views/users/UserDetailDrawer.js.map +1 -1
  606. package/package.json +10 -8
  607. package/src/AdminRoot.tsx +88 -30
  608. package/src/LoginRoot.tsx +2 -1
  609. package/src/__tests__/components/author-avatar.test.tsx +3 -20
  610. package/src/__tests__/components/notification-bell.test.tsx +146 -14
  611. package/src/__tests__/components/responsive-tiers.render.test.tsx +461 -0
  612. package/src/__tests__/components/serp-preview.render.test.tsx +6 -3
  613. package/src/__tests__/components/shell-primitives.render.test.tsx +951 -0
  614. package/src/__tests__/components/theme-provider-accent.test.tsx +83 -8
  615. package/src/__tests__/components/toggle.test.tsx +11 -3
  616. package/src/__tests__/components/ui-primitives.test.tsx +5 -8
  617. package/src/__tests__/layout/build-nav-items.test.ts +1 -1
  618. package/src/__tests__/layout/shell-nav.render.test.tsx +983 -0
  619. package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +8 -4
  620. package/src/__tests__/layout/sidebar-groups.render.test.tsx +116 -0
  621. package/src/__tests__/layout/sidebar-menus.render.test.tsx +116 -0
  622. package/src/__tests__/layout/sidebar-plugin-nav.test.ts +1 -1
  623. package/src/__tests__/layout/sidebar-posts.test.ts +48 -40
  624. package/src/__tests__/layout/sidebar-submenu.render.test.tsx +35 -10
  625. package/src/__tests__/layout/topbar.render.test.tsx +464 -0
  626. package/src/__tests__/lib/api.test.ts +2 -0
  627. package/src/__tests__/lib/job-drawer.test.ts +493 -0
  628. package/src/__tests__/lib/job-view.test.ts +437 -0
  629. package/src/__tests__/lib/jobs-service.test.ts +98 -0
  630. package/src/__tests__/router/admin-route-order.test.ts +39 -0
  631. package/src/__tests__/styles/ai-gradient-contrast.test.ts +135 -0
  632. package/src/__tests__/styles/breakpoint-order.test.ts +68 -0
  633. package/src/__tests__/styles/responsive-layer.test.ts +367 -12
  634. package/src/__tests__/styles/tailwind-preset.test.ts +154 -0
  635. package/src/__tests__/styles/theme-token-scope.test.ts +10 -3
  636. package/src/__tests__/styles/tiers.test.ts +136 -0
  637. package/src/__tests__/styles/tokens-drift.test.ts +67 -6
  638. package/src/__tests__/views/admin-lazy-route.render.test.tsx +41 -1
  639. package/src/__tests__/views/ai-settings.render.test.tsx +4 -4
  640. package/src/__tests__/views/appearance-menus-route.render.test.tsx +58 -0
  641. package/src/__tests__/views/appearance-settings.render.test.tsx +10 -8
  642. package/src/__tests__/views/appearance-theme-provider.render.test.tsx +347 -0
  643. package/src/__tests__/views/dashboard.test.tsx +48 -0
  644. package/src/__tests__/views/document-edit-navigation.render.test.tsx +130 -0
  645. package/src/__tests__/views/document-edit-routes.render.test.tsx +130 -0
  646. package/src/__tests__/views/email-settings.render.test.tsx +2 -2
  647. package/src/__tests__/views/form-entries.render.test.tsx +22 -0
  648. package/src/__tests__/views/form-submissions.render.test.tsx +22 -0
  649. package/src/__tests__/views/forms-list.render.test.tsx +13 -0
  650. package/src/__tests__/views/general-settings.render.test.tsx +38 -11
  651. package/src/__tests__/views/jobs-drawer.render.test.tsx +1047 -0
  652. package/src/__tests__/views/jobs-list.render.test.tsx +850 -0
  653. package/src/__tests__/views/kpi-grid.test.ts +29 -0
  654. package/src/__tests__/views/post-section-editor.test.tsx +5 -1
  655. package/src/__tests__/views/script-tags.render.test.tsx +29 -0
  656. package/src/__tests__/views/security-settings.render.test.tsx +9 -10
  657. package/src/__tests__/views/seo-settings.render.test.tsx +7 -9
  658. package/src/__tests__/views/settings-save-bar.render.test.tsx +91 -0
  659. package/src/__tests__/views/settings-save-footer.render.test.tsx +25 -15
  660. package/src/__tests__/views/settings-tabs.render.test.tsx +371 -0
  661. package/src/__tests__/views/updates-tab.render.test.tsx +4 -3
  662. package/src/components/Breadcrumbs.tsx +7 -1
  663. package/src/components/CommandPalette.tsx +51 -3
  664. package/src/components/LocaleSwitcher.tsx +5 -6
  665. package/src/components/NotificationBell.tsx +74 -18
  666. package/src/components/OfflineStatus.tsx +1 -1
  667. package/src/components/PresenceIndicator.tsx +2 -1
  668. package/src/components/ReflowMeter.tsx +3 -3
  669. package/src/components/SEOPanel.tsx +2 -2
  670. package/src/components/ThemeProvider.tsx +122 -42
  671. package/src/components/TipTapEditor.tsx +3 -4
  672. package/src/components/forms/EmbedPanel.tsx +1 -1
  673. package/src/components/forms/FormEntryDetailPane.tsx +7 -1
  674. package/src/components/forms/ShortcodeGlossary.tsx +7 -1
  675. package/src/components/forms/primitives.tsx +37 -21
  676. package/src/components/pages/tag-badges.tsx +4 -3
  677. package/src/components/seo/PageAuditPanel.tsx +2 -2
  678. package/src/components/seo/SerpPreview.tsx +5 -5
  679. package/src/components/seo/primitives.tsx +2 -2
  680. package/src/components/ui/AuthorAvatar.tsx +11 -10
  681. package/src/components/ui/Badge.tsx +1 -1
  682. package/src/components/ui/Breadcrumb.tsx +109 -0
  683. package/src/components/ui/Button.tsx +40 -15
  684. package/src/components/ui/Card.tsx +35 -32
  685. package/src/components/ui/CardListControls.tsx +90 -0
  686. package/src/components/ui/CountChip.tsx +90 -0
  687. package/src/components/ui/FilterPill.tsx +5 -3
  688. package/src/components/ui/InspectorPane.tsx +21 -7
  689. package/src/components/ui/Modal.tsx +20 -6
  690. package/src/components/ui/PageHeader.tsx +63 -0
  691. package/src/components/ui/RowActions.tsx +7 -2
  692. package/src/components/ui/Segmented.tsx +97 -0
  693. package/src/components/ui/Toaster.tsx +139 -0
  694. package/src/components/ui/Toggle.tsx +18 -14
  695. package/src/components/ui/UnderlineTabs.tsx +373 -0
  696. package/src/components/ui/UnsavedBar.tsx +119 -0
  697. package/src/components/ui/admin-links.tsx +20 -0
  698. package/src/components/ui/card-table.ts +34 -0
  699. package/src/components/virtual-window.tsx +13 -2
  700. package/src/fields/RelationshipField.tsx +1 -1
  701. package/src/hooks/useAppearanceBootstrap.ts +9 -3
  702. package/src/index.ts +19 -0
  703. package/src/layout/Header.tsx +210 -135
  704. package/src/layout/Layout.tsx +61 -108
  705. package/src/layout/MobileTabBar.tsx +92 -0
  706. package/src/layout/Sidebar.tsx +555 -593
  707. package/src/layout/nav-config.tsx +425 -0
  708. package/src/layout/primitives/AdminShell.tsx +154 -6
  709. package/src/layout/topbar-classes.ts +8 -0
  710. package/src/layout/use-nav-items.ts +40 -0
  711. package/src/lib/api.ts +7 -1
  712. package/src/lib/appearance-api.ts +4 -0
  713. package/src/lib/job-drawer.ts +424 -0
  714. package/src/lib/job-view.ts +456 -0
  715. package/src/lib/jobs-service.ts +290 -0
  716. package/src/lib/sheet-focus.ts +71 -0
  717. package/src/lib/tiers.ts +43 -0
  718. package/src/router/admin-routes.ts +8 -0
  719. package/src/router/index.ts +9 -1
  720. package/src/styles/prenta-tokens.css +263 -82
  721. package/src/styles/responsive.css +460 -22
  722. package/src/styles/theme.css +177 -28
  723. package/src/views/ApiKeys.tsx +2 -2
  724. package/src/views/AppearanceChrome.tsx +74 -0
  725. package/src/views/CollectionList.tsx +264 -205
  726. package/src/views/Dashboard.tsx +26 -13
  727. package/src/views/DocumentEdit.tsx +111 -56
  728. package/src/views/FormDeliveries.tsx +30 -17
  729. package/src/views/FormEntries.tsx +28 -20
  730. package/src/views/FormSubmissions.tsx +32 -34
  731. package/src/views/Forms.tsx +55 -34
  732. package/src/views/FormsSettings.tsx +94 -0
  733. package/src/views/Jobs/BusyButton.tsx +56 -0
  734. package/src/views/Jobs/CancelJobDialog.tsx +127 -0
  735. package/src/views/Jobs/DrawerActivity.tsx +142 -0
  736. package/src/views/Jobs/DrawerOverview.tsx +139 -0
  737. package/src/views/Jobs/DrawerResults.tsx +155 -0
  738. package/src/views/Jobs/DrawerReview.tsx +464 -0
  739. package/src/views/Jobs/JobDrawer.tsx +556 -0
  740. package/src/views/Jobs/JobsTable.tsx +260 -0
  741. package/src/views/Jobs/WorkerStrip.tsx +144 -0
  742. package/src/views/Jobs/atoms.tsx +93 -0
  743. package/src/views/Jobs/focus.ts +56 -0
  744. package/src/views/Jobs/icons.tsx +111 -0
  745. package/src/views/Jobs/placeholders.tsx +66 -0
  746. package/src/views/Jobs/useJobDetail.ts +64 -0
  747. package/src/views/Jobs/useJobsFeed.ts +249 -0
  748. package/src/views/Jobs/useReviewItems.ts +187 -0
  749. package/src/views/Jobs.tsx +282 -105
  750. package/src/views/MediaBrowser.tsx +71 -21
  751. package/src/views/Pages/PagesListView.tsx +115 -39
  752. package/src/views/Posts/NewPostTypeInstructions.tsx +15 -10
  753. package/src/views/Posts/PostTypesView.tsx +28 -6
  754. package/src/views/Posts/PostsListView.tsx +130 -40
  755. package/src/views/SEO.tsx +23 -8
  756. package/src/views/ScriptTags.tsx +233 -153
  757. package/src/views/Settings.tsx +215 -256
  758. package/src/views/SpamFolder.tsx +32 -0
  759. package/src/views/Trash.tsx +12 -1
  760. package/src/views/Users.tsx +2 -2
  761. package/src/views/WidgetsPlaceholder.tsx +33 -0
  762. package/src/views/dashboard/kpi-grid.ts +38 -0
  763. package/src/views/lazy-views.tsx +12 -0
  764. package/src/views/page-editor/PageTab.tsx +2 -2
  765. package/src/views/page-editor/ReviewBar.tsx +1 -1
  766. package/src/views/page-editor/VersionHistoryPanel.tsx +1 -1
  767. package/src/views/seo/AuditTab.tsx +1 -1
  768. package/src/views/seo/OverviewTab.tsx +6 -5
  769. package/src/views/seo/ProposalsTab.tsx +1 -1
  770. package/src/views/settings/AISettingsTab.tsx +10 -6
  771. package/src/views/settings/AccessProtectionCard.tsx +1 -1
  772. package/src/views/settings/AdminThemeCard.tsx +228 -35
  773. package/src/views/settings/AppearanceTab.tsx +10 -6
  774. package/src/views/settings/BrandPreviewCard.tsx +1 -1
  775. package/src/views/settings/BrandVoiceCard.tsx +1 -1
  776. package/src/views/settings/EmailSettingsTab.tsx +11 -7
  777. package/src/views/settings/GeneralSettingsTab.tsx +10 -6
  778. package/src/views/settings/ProviderConnectionCard.tsx +1 -1
  779. package/src/views/settings/SearchDataCard.tsx +1 -1
  780. package/src/views/settings/SecurityStatusCard.tsx +2 -2
  781. package/src/views/settings/SecurityTab.tsx +10 -6
  782. package/src/views/settings/SeoAutopilotCard.tsx +2 -2
  783. package/src/views/settings/SeoSettingsTab.tsx +13 -9
  784. package/src/views/settings/SiteIndexStatusCard.tsx +1 -1
  785. package/src/views/settings/SiteThemeCard.tsx +1 -1
  786. package/src/views/settings/UpdatesTab.tsx +107 -101
  787. package/src/views/settings/components.tsx +26 -62
  788. package/src/views/settings/useAppearanceSettings.ts +116 -25
  789. package/src/views/users/UserDetailDrawer.tsx +1 -1
  790. package/dist/components/ui/StatPill.d.ts +0 -18
  791. package/dist/components/ui/StatPill.d.ts.map +0 -1
  792. package/dist/components/ui/StatPill.js +0 -5
  793. package/dist/components/ui/StatPill.js.map +0 -1
  794. package/src/components/ui/StatPill.tsx +0 -37
@@ -0,0 +1,1047 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
4
+ import type { Job, JobEvent, JobReviewItem } from '@prenta/core'
5
+
6
+ /*
7
+ * The job drawer (P-18): a modal dialog with Overview, Review or Results,
8
+ * Activity and Details, on the real endpoints. The list's refresh is
9
+ * answered by the `fetch` stub; the drawer's reads and decisions by the
10
+ * `cmsApi` mock. Both read `server` below.
11
+ */
12
+
13
+ const MIN = 60_000
14
+
15
+ interface Reply {
16
+ data?: unknown
17
+ body?: unknown
18
+ error?: string
19
+ status: number
20
+ }
21
+
22
+ interface Call {
23
+ endpoint: string
24
+ method: string
25
+ body: unknown
26
+ }
27
+
28
+ interface Server {
29
+ jobs: Job[]
30
+ details: Record<string, Partial<Job>>
31
+ events: Record<string, JobEvent[]>
32
+ items: Record<string, JobReviewItem[]>
33
+ /** `GET /jobs/:id/review` fails with this status. */
34
+ reviewError: { status: number; message: string } | null
35
+ decide: (jobId: string, itemId: string, body: Record<string, unknown>) => Reply | Promise<Reply>
36
+ approveAll: (jobId: string) => Reply | Promise<Reply>
37
+ calls: Call[]
38
+ }
39
+
40
+ const server: Server = {
41
+ jobs: [],
42
+ details: {},
43
+ events: {},
44
+ items: {},
45
+ reviewError: null,
46
+ decide: () => ({ error: 'not stubbed', status: 500 }),
47
+ approveAll: () => ({ error: 'not stubbed', status: 500 }),
48
+ calls: [],
49
+ }
50
+
51
+ function ok(data: unknown, extra: Record<string, unknown> = {}): Reply {
52
+ return { data, body: { data, ...extra }, status: 200 }
53
+ }
54
+
55
+ function cmsApiStub(endpoint: string, init: RequestInit = {}): Reply | Promise<Reply> {
56
+ const method = (init.method ?? 'GET').toUpperCase()
57
+ const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined
58
+ server.calls.push({ endpoint, method, body })
59
+ const url = new URL(endpoint, 'http://x')
60
+ const [, , jobId, part, itemId] = url.pathname.split('/')
61
+ const job = server.jobs.find((entry) => entry.id === jobId)
62
+ if (method === 'GET' && part === undefined) {
63
+ return job ? ok({ ...job, ...server.details[job.id] }) : { error: 'Job not found', status: 404 }
64
+ }
65
+ if (method === 'GET' && part === 'events') return ok({ events: server.events[jobId!] ?? [] })
66
+ if (method === 'GET' && part === 'review') {
67
+ if (server.reviewError)
68
+ return { error: server.reviewError.message, status: server.reviewError.status }
69
+ const limit = Number(url.searchParams.get('limit'))
70
+ const cursor = url.searchParams.get('cursor')
71
+ const all = [...(server.items[jobId!] ?? [])].sort((a, b) => a.id.localeCompare(b.id))
72
+ const after = cursor ? all.filter((item) => item.id > cursor) : all
73
+ const items = after.slice(0, limit)
74
+ const last = items.at(-1)
75
+ return ok({ items, nextCursor: items.length === limit && last ? last.id : null })
76
+ }
77
+ if (method === 'POST' && part === 'review' && itemId === 'approve-all')
78
+ return server.approveAll(jobId!)
79
+ if (method === 'POST' && part === 'review') return server.decide(jobId!, itemId!, body)
80
+ return { error: `unexpected ${method} ${endpoint}`, status: 404 }
81
+ }
82
+
83
+ vi.mock('../../lib/api.js', () => ({
84
+ getApiBase: () => '/api/cms',
85
+ cmsApi: vi.fn(async (endpoint: string, init?: RequestInit) => cmsApiStub(endpoint, init)),
86
+ }))
87
+
88
+ const toast = vi.hoisted(() => Object.assign(vi.fn(), { error: vi.fn() }))
89
+ vi.mock('sonner', () => ({ toast }))
90
+
91
+ /** The list's refresh reads through fetch (it needs response headers). */
92
+ function fetchStub(url: string) {
93
+ const endpoint = url.replace(/^\/api\/cms/, '')
94
+ const data = endpoint.endsWith('/jobs/worker')
95
+ ? { lastTickAt: null, lastTickMs: null, nextTickAt: null, budgetMs: 20000, schedules: [] }
96
+ : { jobs: server.jobs }
97
+ return Promise.resolve({
98
+ ok: true,
99
+ status: 200,
100
+ headers: { get: () => null },
101
+ json: async () => ({ data }),
102
+ })
103
+ }
104
+
105
+ const { Jobs } = await import('../../views/Jobs.js')
106
+
107
+ function iso(offsetMs: number): string {
108
+ return new Date(Date.now() + offsetMs).toISOString()
109
+ }
110
+
111
+ function batch(overrides: Partial<Job> & { done?: number; total?: number }): Job {
112
+ const { done = 19, total = 42, ...rest } = overrides
113
+ return {
114
+ id: 'job_417',
115
+ type: 'batch-ai',
116
+ status: 'running',
117
+ title: 'Rewrite meta descriptions',
118
+ scope: '42 posts with a missing or over-length description',
119
+ runAt: iso(0),
120
+ attempts: 0,
121
+ maxAttempts: 3,
122
+ progress: { done, total, unit: 'documents', cursor: String(done), completed: done },
123
+ lastError: null,
124
+ explain: null,
125
+ summary: null,
126
+ lockedAt: iso(-5_000),
127
+ cancelRequestedAt: null,
128
+ cancelledBy: null,
129
+ createdBy: { id: 'u1', name: 'Brandon West', initials: 'BW' },
130
+ createdAt: iso(-19 * MIN),
131
+ updatedAt: iso(0),
132
+ finishedAt: null,
133
+ usage: { tokensIn: 11_500, tokensOut: 5_800 },
134
+ payload: {
135
+ operation: 'meta',
136
+ instruction: 'Plain, specific, under 155 characters.',
137
+ source: 'blog',
138
+ statusFilter: 'all',
139
+ documentIds: ['d1', 'd2', 'd3'],
140
+ review: 'review',
141
+ onApprove: 'draft',
142
+ },
143
+ review: { pending: 0, approved: 0, rejected: 0 },
144
+ ...rest,
145
+ }
146
+ }
147
+
148
+ function item(id: string, overrides: Partial<JobReviewItem> = {}): JobReviewItem {
149
+ return {
150
+ id,
151
+ jobId: 'job_417',
152
+ documentId: `doc-${id}`,
153
+ documentTitle: `Post ${id}`,
154
+ url: `/blog/${id}`,
155
+ field: 'metaDescription',
156
+ before: '',
157
+ after: `Suggested description for ${id}.`,
158
+ editedAfter: null,
159
+ status: 'pending',
160
+ decidedBy: null,
161
+ decidedAt: null,
162
+ appliedRevisionId: null,
163
+ ...overrides,
164
+ }
165
+ }
166
+
167
+ /** Items r01…rNN on a job, all pending. */
168
+ function pending(jobId: string, count: number): JobReviewItem[] {
169
+ return Array.from({ length: count }, (_, index) =>
170
+ item(`r${String(index + 1).padStart(2, '0')}`, { jobId }),
171
+ )
172
+ }
173
+
174
+ function decided(entry: JobReviewItem, status: 'approved' | 'rejected'): JobReviewItem {
175
+ return {
176
+ ...entry,
177
+ status,
178
+ decidedAt: iso(0),
179
+ decidedBy: { id: 'u1', name: 'Brandon West', initials: 'BW' },
180
+ }
181
+ }
182
+
183
+ beforeEach(() => {
184
+ vi.stubGlobal('fetch', vi.fn(fetchStub))
185
+ Object.assign(server, {
186
+ jobs: [],
187
+ details: {},
188
+ events: {},
189
+ items: {},
190
+ reviewError: null,
191
+ calls: [],
192
+ approveAll: () => ({ error: 'not stubbed', status: 500 }),
193
+ })
194
+ // By default a decision goes through: the change takes the new status.
195
+ server.decide = (jobId, itemId, body) => {
196
+ const list = server.items[jobId] ?? []
197
+ const index = list.findIndex((entry) => entry.id === itemId)
198
+ const current = list[index]!
199
+ const next =
200
+ body.action === 'undo'
201
+ ? { ...current, status: 'pending' as const, decidedAt: null, decidedBy: null }
202
+ : decided(
203
+ body.after ? { ...current, editedAfter: String(body.after) } : current,
204
+ body.action === 'approve' ? 'approved' : 'rejected',
205
+ )
206
+ list[index] = next
207
+ return ok(next, { documentStatus: body.onApprove === 'publish' ? 'PUBLISHED' : 'DRAFT' })
208
+ }
209
+ toast.mockClear()
210
+ toast.error.mockClear()
211
+ })
212
+ afterEach(() => {
213
+ vi.useRealTimers()
214
+ vi.unstubAllGlobals()
215
+ })
216
+
217
+ function rowButton(id: string): HTMLElement {
218
+ const button = document.getElementById(`job-open-${id}`)
219
+ if (!button) throw new Error(`no row ${id}`)
220
+ return button
221
+ }
222
+
223
+ async function renderJobs(onNavigate: (path: string) => void = () => {}) {
224
+ render(<Jobs onNavigate={onNavigate} />)
225
+ await screen.findByRole('list', { name: 'Jobs' })
226
+ }
227
+
228
+ async function openJob(id: string, tab?: string): Promise<HTMLElement> {
229
+ const row = rowButton(id)
230
+ row.focus()
231
+ fireEvent.click(row)
232
+ const dialog = await screen.findByRole('dialog')
233
+ if (tab) fireEvent.click(within(dialog).getByRole('tab', { name: new RegExp(`^${tab}`) }))
234
+ return dialog
235
+ }
236
+
237
+ function card(id: string): HTMLElement {
238
+ const element = document.querySelector<HTMLElement>(`[data-review-item="${id}"]`)
239
+ if (!element) throw new Error(`no change ${id}`)
240
+ return element
241
+ }
242
+
243
+ /** Radix hides its positioned menu in happy-dom, so the items are queried with `hidden`. */
244
+ async function approveWith(entry: HTMLElement, option: string) {
245
+ const trigger = within(entry).getByRole('button', { name: /^Approve/ })
246
+ fireEvent.keyDown(trigger, { key: 'Enter' })
247
+ await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('true'))
248
+ fireEvent.click(screen.getByRole('menuitem', { name: option, hidden: true }))
249
+ }
250
+
251
+ async function menuOptions(entry: HTMLElement): Promise<string[]> {
252
+ const trigger = within(entry).getByRole('button', { name: /^Approve/ })
253
+ fireEvent.keyDown(trigger, { key: 'Enter' })
254
+ await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('true'))
255
+ const options = screen
256
+ .getAllByRole('menuitem', { hidden: true })
257
+ .map((option) => option.textContent ?? '')
258
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' })
259
+ await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('false'))
260
+ return options
261
+ }
262
+
263
+ const posts = (method: string, part: string) =>
264
+ server.calls.filter((call) => call.method === method && call.endpoint.includes(part))
265
+
266
+ /** A change's note (not a live region). */
267
+ function noteOf(entry: HTMLElement): string {
268
+ return entry.querySelector('[data-review-note]')?.textContent ?? ''
269
+ }
270
+
271
+ /** What the drawer's live region last announced. */
272
+ function announced(): string {
273
+ return document.querySelector('[data-job-drawer] [aria-live="polite"]')?.textContent ?? ''
274
+ }
275
+
276
+ /** Let "Approve all" act: it ignores activations for 600ms after Review opens. */
277
+ async function waitOutArming() {
278
+ await act(async () => {
279
+ await vi.advanceTimersByTimeAsync(700)
280
+ })
281
+ }
282
+
283
+ describe('Jobs drawer', () => {
284
+ it('opens from the row and returns focus to it when Escape closes it', async () => {
285
+ server.jobs = [batch({})]
286
+ await renderJobs()
287
+ const row = rowButton('job_417')
288
+ expect(row.getAttribute('aria-haspopup')).toBe('dialog')
289
+ const dialog = await openJob('job_417')
290
+ expect(dialog.getAttribute('aria-modal')).toBe('true')
291
+ expect(screen.getByRole('dialog', { name: 'Rewrite meta descriptions' })).toBe(dialog)
292
+ expect(row.closest('li')?.className).toContain('bg-acc-l')
293
+ fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape' })
294
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
295
+ await waitFor(() => expect(document.activeElement).toBe(row))
296
+ expect(row.closest('li')?.className).not.toContain('bg-acc-l')
297
+ })
298
+
299
+ it('focuses the row when the opener never took focus (Safari clicks)', async () => {
300
+ server.jobs = [batch({})]
301
+ await renderJobs()
302
+ const row = rowButton('job_417')
303
+ ;(document.activeElement as HTMLElement | null)?.blur()
304
+ fireEvent.click(row)
305
+ await screen.findByRole('dialog')
306
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }))
307
+ await waitFor(() => expect(document.activeElement).toBe(row))
308
+ })
309
+
310
+ it('shows the overview: status sentence, progress, facts and instruction', async () => {
311
+ server.jobs = [batch({ review: { pending: 6, approved: 0, rejected: 0 } })]
312
+ server.items.job_417 = pending('job_417', 6)
313
+ await renderJobs()
314
+ const dialog = await openJob('job_417')
315
+ expect(
316
+ within(dialog).getByRole('tab', { name: 'Overview' }).getAttribute('aria-selected'),
317
+ ).toBe('true')
318
+ expect(dialog.textContent).toContain(
319
+ 'Running slice 20 of 42 now. Each slice applies the operation to one document.',
320
+ )
321
+ expect(dialog.textContent).toContain('19 / 42 documents')
322
+ const facts = within(dialog).getByText('Attempts').closest('div')!
323
+ expect(facts.textContent).toBe('Attempts1 of 3')
324
+ expect(within(dialog).getByText('17,300 tokens')).toBeTruthy()
325
+ expect(within(dialog).getByText('Rewrite meta descriptions', { selector: 'dd' })).toBeTruthy()
326
+ expect(dialog.textContent).toContain('“Plain, specific, under 155 characters.”')
327
+ expect(within(dialog).getByRole('button', { name: 'Review 6 changes' })).toBeTruthy()
328
+ })
329
+
330
+ it('shows the full error with Copy and the AI explanation on a failed job', async () => {
331
+ const stack = 'ProviderError: 429 Too Many Requests\n at callModel (lib/ai/provider.ts:88)'
332
+ server.jobs = [
333
+ batch({
334
+ id: 'job_415',
335
+ status: 'dead',
336
+ attempts: 3,
337
+ done: 7,
338
+ total: 30,
339
+ lockedAt: null,
340
+ lastError: 'ProviderError: 429 Too Many Requests',
341
+ explain: 'Your AI provider was rate-limiting requests.',
342
+ }),
343
+ ]
344
+ server.details.job_415 = { lastError: stack }
345
+ const writeText = vi.fn(async () => {})
346
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
347
+ await renderJobs()
348
+ const dialog = await openJob('job_415')
349
+ expect(dialog.textContent).toContain('Stopped after 3 attempts at document 8.')
350
+ await waitFor(() => expect(dialog.querySelector('pre')?.textContent).toBe(stack))
351
+ expect(within(dialog).getByText('What happened')).toBeTruthy()
352
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Copy' }))
353
+ await waitFor(() => expect(toast).toHaveBeenCalledWith('Error copied'))
354
+ expect(writeText).toHaveBeenCalledWith(stack)
355
+ })
356
+
357
+ it('counts from the API: badge and footer use review.pending, and changes load 20 at a time', async () => {
358
+ server.jobs = [batch({ review: { pending: 30, approved: 0, rejected: 0 } })]
359
+ server.items.job_417 = pending('job_417', 30)
360
+ await renderJobs()
361
+ const dialog = await openJob('job_417', 'Review')
362
+ await waitFor(() => expect(dialog.querySelectorAll('[data-review-item]')).toHaveLength(20))
363
+ expect(within(dialog).getByRole('tab', { name: /^Review/ }).textContent).toBe(
364
+ 'Review30 waiting',
365
+ )
366
+ expect(within(dialog).getByRole('button', { name: 'Approve all 30' })).toBeTruthy()
367
+ expect(dialog.textContent).toContain('30 waiting · 0 approved · 0 rejected')
368
+ const reads = posts('GET', '/review')
369
+ expect(reads.at(-1)?.endpoint).toBe('/jobs/job_417/review?limit=20')
370
+
371
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Show 10 more' }))
372
+ // Loading and drawing 10 more changes takes about 0.2s here, but CI runs
373
+ // the suite 9-13 times slower with coverage on, past waitFor's 1s default.
374
+ await waitFor(() => expect(dialog.querySelectorAll('[data-review-item]')).toHaveLength(30), {
375
+ timeout: 5_000,
376
+ })
377
+ expect(posts('GET', '/review').at(-1)?.endpoint).toBe(
378
+ '/jobs/job_417/review?limit=20&cursor=r20',
379
+ )
380
+ expect(within(dialog).queryByRole('button', { name: /^Show/ })).toBeNull()
381
+ await waitFor(() => expect(document.activeElement).toBe(card('r21')), { timeout: 5_000 })
382
+ })
383
+
384
+ it('keeps focus on the footer button as Review n changes becomes Approve all', async () => {
385
+ server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
386
+ server.items.job_417 = pending('job_417', 2)
387
+ await renderJobs()
388
+ const dialog = await openJob('job_417')
389
+ const footer = within(dialog).getByRole('button', { name: 'Review 2 changes' })
390
+ footer.focus()
391
+ fireEvent.click(footer)
392
+ await waitFor(() => expect(footer.textContent).toBe('Approve all 2'))
393
+ expect(document.activeElement).toBe(footer)
394
+ expect(
395
+ within(dialog)
396
+ .getByRole('tab', { name: /^Review/ })
397
+ .getAttribute('aria-selected'),
398
+ ).toBe('true')
399
+ })
400
+
401
+ it('offers Save as draft and Publish, and publishes when asked', async () => {
402
+ server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
403
+ server.items.job_417 = pending('job_417', 2)
404
+ await renderJobs()
405
+ await openJob('job_417', 'Review')
406
+ await waitFor(() => card('r01'))
407
+ expect(await menuOptions(card('r01'))).toEqual(['Save as draft', 'Publish'])
408
+ await approveWith(card('r01'), 'Publish')
409
+ const undo = await within(card('r01')).findByRole('button', { name: 'Undo' })
410
+ expect(posts('POST', '/review/r01').at(-1)?.body).toEqual({
411
+ action: 'approve',
412
+ onApprove: 'publish',
413
+ })
414
+ expect(within(card('r01')).getByText('Approved')).toBeTruthy()
415
+ await waitFor(() => expect(document.activeElement).toBe(undo))
416
+ })
417
+
418
+ it('reads Keep current status on a published document and leaves its change in review', async () => {
419
+ server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
420
+ server.items.job_417 = pending('job_417', 2)
421
+ server.decide = (_jobId, _itemId, body) =>
422
+ body.onApprove === 'publish'
423
+ ? ok(decided(server.items.job_417![0]!, 'approved'), { documentStatus: 'PUBLISHED' })
424
+ : {
425
+ error:
426
+ 'This document is published, so the change stays in review. Approve it with Publish to apply it live.',
427
+ status: 409,
428
+ }
429
+ await renderJobs()
430
+ await openJob('job_417', 'Review')
431
+ await waitFor(() => card('r01'))
432
+ await approveWith(card('r01'), 'Save as draft')
433
+ await waitFor(() => expect(noteOf(card('r01'))).toContain('so the change stays in review'))
434
+ // The note isn't a live region (it appears with its text); the drawer announces it.
435
+ expect(card('r01').querySelector('[role="status"], [role="alert"]')).toBeNull()
436
+ expect(announced()).toBe(
437
+ 'Post r01: This document is published, so the change stays in review. Approve it with Publish to apply it live.',
438
+ )
439
+ expect(within(card('r01')).queryByText('Approved')).toBeNull()
440
+ expect(await menuOptions(card('r01'))).toEqual(['Keep current status', 'Publish'])
441
+ // Another document isn't known to be published.
442
+ expect(await menuOptions(card('r02'))).toEqual(['Save as draft', 'Publish'])
443
+ })
444
+
445
+ it('assumes published documents when the job only took published ones', async () => {
446
+ server.jobs = [
447
+ batch({
448
+ review: { pending: 1, approved: 0, rejected: 0 },
449
+ payload: { ...(batch({}).payload as object), statusFilter: 'published' } as Job['payload'],
450
+ }),
451
+ ]
452
+ server.items.job_417 = pending('job_417', 1)
453
+ await renderJobs()
454
+ await openJob('job_417', 'Review')
455
+ await waitFor(() => card('r01'))
456
+ expect(await menuOptions(card('r01'))).toEqual(['Keep current status', 'Publish'])
457
+ })
458
+
459
+ it.each([
460
+ [
461
+ 'changed while saving',
462
+ 'The document changed while this was being saved, so nothing was written. Try again.',
463
+ ],
464
+ [
465
+ 'the publish state moved',
466
+ 'This document is published, so the change stays in review. Approve it with Publish to apply it live.',
467
+ ],
468
+ ])('explains a 409 when %s, and keeps the change pending', async (_, message) => {
469
+ server.jobs = [batch({ review: { pending: 1, approved: 0, rejected: 0 } })]
470
+ server.items.job_417 = pending('job_417', 1)
471
+ server.decide = () => ({ error: message, status: 409 })
472
+ await renderJobs()
473
+ await openJob('job_417', 'Review')
474
+ await waitFor(() => card('r01'))
475
+ await approveWith(card('r01'), 'Save as draft')
476
+ await waitFor(() => expect(card('r01').textContent).toContain(message))
477
+ expect(within(card('r01')).getByRole('button', { name: /^Approve/ })).toBeTruthy()
478
+ })
479
+
480
+ it('shows link changes as text, and explains a link whose page is gone', async () => {
481
+ const markup = 'Compare <b>plans</b> & pricing <img src=x onerror=alert(1)> on the page.'
482
+ server.jobs = [
483
+ batch({
484
+ id: 'job_413',
485
+ status: 'succeeded',
486
+ lockedAt: null,
487
+ title: 'Add internal links',
488
+ review: { pending: 1, approved: 0, rejected: 1 },
489
+ }),
490
+ ]
491
+ server.items.job_413 = [
492
+ item('l1', {
493
+ jobId: 'job_413',
494
+ field: 'body',
495
+ before: 'Learn more about us before you get in touch.',
496
+ after: 'Learn more <a href="/about">about us</a> before you get in touch.',
497
+ }),
498
+ item('l2', {
499
+ jobId: 'job_413',
500
+ field: 'body',
501
+ status: 'rejected',
502
+ before: markup,
503
+ after: markup.replace('the page', '<a href="/platform">the page</a>'),
504
+ }),
505
+ ]
506
+ server.decide = () => ({
507
+ error: "The page this links to, /about, isn't published at that address any more.",
508
+ status: 409,
509
+ })
510
+ await renderJobs()
511
+ await openJob('job_413', 'Review')
512
+ await waitFor(() => card('l2'))
513
+ expect(card('l2').querySelector('img, b, a[href="/platform"]')).toBeNull()
514
+ expect(card('l2').textContent).toContain('<b>plans</b>')
515
+ expect(card('l1').querySelector('mark')?.textContent).toBe('about us')
516
+ expect(card('l1').textContent).toContain('→ /about')
517
+ expect(within(card('l1')).queryByRole('button', { name: 'Edit' })).toBeNull()
518
+ await approveWith(card('l1'), 'Publish')
519
+ await waitFor(() =>
520
+ expect(noteOf(card('l1'))).toContain("isn't published at that address any more"),
521
+ )
522
+ expect(announced()).toContain("isn't published at that address any more")
523
+ })
524
+
525
+ it('shows the notice when undo leaves a document published', async () => {
526
+ const approvedItem = decided(item('r01'), 'approved')
527
+ server.jobs = [batch({ review: { pending: 0, approved: 1, rejected: 0 } })]
528
+ server.items.job_417 = [approvedItem]
529
+ const notice =
530
+ 'The document stays published. Undo put back the previous text, and the page shows it now.'
531
+ server.decide = () =>
532
+ ok({ ...approvedItem, status: 'pending' }, { documentStatus: 'PUBLISHED', notice })
533
+ await renderJobs()
534
+ await openJob('job_417', 'Review')
535
+ await waitFor(() => card('r01'))
536
+ fireEvent.click(within(card('r01')).getByRole('button', { name: 'Undo' }))
537
+ await waitFor(() => expect(noteOf(card('r01'))).toContain(notice))
538
+ expect(announced()).toBe(`Post r01 is back in review. ${notice}`)
539
+ await waitFor(() =>
540
+ expect(document.activeElement).toBe(
541
+ within(card('r01')).getByRole('button', { name: /^Approve/ }),
542
+ ),
543
+ )
544
+ // The document is now known to be published.
545
+ expect(await menuOptions(card('r01'))).toEqual(['Keep current status', 'Publish'])
546
+ })
547
+
548
+ it('keeps an edit in progress across a poll and a tab switch, then approves the edit', async () => {
549
+ vi.useFakeTimers({ shouldAdvanceTime: true })
550
+ server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
551
+ server.items.job_417 = pending('job_417', 2)
552
+ await renderJobs()
553
+ const dialog = await openJob('job_417', 'Review')
554
+ await waitFor(() => card('r01'))
555
+ fireEvent.click(within(card('r01')).getByRole('button', { name: 'Edit' }))
556
+ const box = within(card('r01')).getByRole('textbox', { name: 'Edit suggestion for Post r01' })
557
+ await waitFor(() => expect(document.activeElement).toBe(box))
558
+ fireEvent.change(box, { target: { value: 'Edited by hand.' } })
559
+
560
+ // A slice adds a suggestion: the list refresh sees the job change and the changes are read again.
561
+ server.items.job_417!.push(item('r03'))
562
+ server.jobs = [
563
+ batch({ updatedAt: iso(5_000), review: { pending: 3, approved: 0, rejected: 0 } }),
564
+ ]
565
+ const before = posts('GET', '/review').length
566
+ await act(async () => {
567
+ await vi.advanceTimersByTimeAsync(6_000)
568
+ })
569
+ await waitFor(() => expect(posts('GET', '/review').length).toBeGreaterThan(before))
570
+ await waitFor(() => card('r03'))
571
+ expect((within(card('r01')).getByRole('textbox') as HTMLTextAreaElement).value).toBe(
572
+ 'Edited by hand.',
573
+ )
574
+
575
+ fireEvent.click(within(dialog).getByRole('tab', { name: 'Overview' }))
576
+ fireEvent.click(within(dialog).getByRole('tab', { name: /^Review/ }))
577
+ await waitFor(() => card('r01'))
578
+ expect((within(card('r01')).getByRole('textbox') as HTMLTextAreaElement).value).toBe(
579
+ 'Edited by hand.',
580
+ )
581
+
582
+ await approveWith(card('r01'), 'Save as draft')
583
+ await within(card('r01')).findByRole('button', { name: 'Undo' })
584
+ expect(posts('POST', '/review/r01').at(-1)?.body).toEqual({
585
+ action: 'approve',
586
+ onApprove: 'draft',
587
+ after: 'Edited by hand.',
588
+ })
589
+ expect(card('r01').textContent).toContain('Edited by hand.')
590
+ })
591
+
592
+ it('re-reads the changes when one was decided elsewhere', async () => {
593
+ server.jobs = [batch({ review: { pending: 1, approved: 0, rejected: 0 } })]
594
+ server.items.job_417 = pending('job_417', 1)
595
+ let answer: (reply: Reply) => void = () => {}
596
+ server.decide = () =>
597
+ new Promise<Reply>((resolve) => {
598
+ answer = resolve
599
+ })
600
+ await renderJobs()
601
+ await openJob('job_417', 'Review')
602
+ await waitFor(() => card('r01'))
603
+ await approveWith(card('r01'), 'Save as draft')
604
+ const trigger = within(card('r01')).getByRole('button', { name: /^Approve/ })
605
+ await waitFor(() => expect(document.activeElement).toBe(trigger))
606
+ server.items.job_417![0] = decided(server.items.job_417![0]!, 'rejected')
607
+ answer({ error: 'This change was already decided.', status: 409 })
608
+ await waitFor(() => expect(within(card('r01')).getByText('Rejected')).toBeTruthy())
609
+ expect(card('r01').textContent).toContain('This change was already decided.')
610
+ expect(announced()).toBe('Post r01: This change was already decided.')
611
+ // Approve went with the re-read; focus goes to the Undo that replaced it.
612
+ await waitFor(() =>
613
+ expect(document.activeElement).toBe(
614
+ within(card('r01')).getByRole('button', { name: 'Undo' }),
615
+ ),
616
+ )
617
+ })
618
+
619
+ it('puts focus on the panel when the change pressed is gone', async () => {
620
+ server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
621
+ server.items.job_417 = pending('job_417', 2)
622
+ server.decide = () => {
623
+ server.items.job_417 = server.items.job_417!.filter((entry) => entry.id !== 'r01')
624
+ return { error: 'This change no longer exists.', status: 409 }
625
+ }
626
+ await renderJobs()
627
+ const dialog = await openJob('job_417', 'Review')
628
+ await waitFor(() => card('r01'))
629
+ const reject = within(card('r01')).getByRole('button', { name: 'Reject' })
630
+ reject.focus()
631
+ fireEvent.click(reject)
632
+ await waitFor(() => expect(dialog.querySelector('[data-review-item="r01"]')).toBeNull())
633
+ await waitFor(() => expect(document.activeElement).toBe(within(dialog).getByRole('tabpanel')))
634
+ })
635
+
636
+ it('leaves focus where the reviewer moved it while a decision was out', async () => {
637
+ server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
638
+ server.items.job_417 = pending('job_417', 2)
639
+ let answer: (reply: Reply) => void = () => {}
640
+ server.decide = () =>
641
+ new Promise<Reply>((resolve) => {
642
+ answer = resolve
643
+ })
644
+ await renderJobs()
645
+ await openJob('job_417', 'Review')
646
+ await waitFor(() => card('r01'))
647
+ await approveWith(card('r01'), 'Publish')
648
+ // The closed menu hands focus back to Approve (on a timer), while the request is out.
649
+ const trigger = within(card('r01')).getByRole('button', { name: /^Approve/ })
650
+ await waitFor(() => expect(document.activeElement).toBe(trigger))
651
+ // Meanwhile they start editing the next change.
652
+ fireEvent.click(within(card('r02')).getByRole('button', { name: 'Edit' }))
653
+ const box = within(card('r02')).getByRole('textbox')
654
+ await waitFor(() => expect(document.activeElement).toBe(box))
655
+ answer(ok(decided(server.items.job_417![0]!, 'approved'), { documentStatus: 'PUBLISHED' }))
656
+ await within(card('r01')).findByRole('button', { name: 'Undo' })
657
+ await new Promise((resolve) => setTimeout(resolve, 50))
658
+ expect(document.activeElement).toBe(box)
659
+ })
660
+
661
+ it('doesn’t approve all on a second click, a second Enter or a held key', async () => {
662
+ vi.useFakeTimers({ shouldAdvanceTime: true })
663
+ server.jobs = [batch({ review: { pending: 3, approved: 0, rejected: 0 } })]
664
+ server.items.job_417 = pending('job_417', 3)
665
+ server.approveAll = () => ok({ approved: 3, skipped: 0, remaining: 0 })
666
+ await renderJobs()
667
+ const dialog = await openJob('job_417')
668
+ const footer = within(dialog).getByRole('button', { name: 'Review 3 changes' })
669
+ footer.focus()
670
+ // A double-click: the first opens Review, the second lands on Approve all.
671
+ fireEvent.click(footer)
672
+ fireEvent.click(footer)
673
+ await waitFor(() => card('r01'))
674
+ expect(footer.textContent).toBe('Approve all 3')
675
+ // Enter again, quickly.
676
+ fireEvent.keyDown(footer, { key: 'Enter' })
677
+ fireEvent.click(footer)
678
+ expect(posts('POST', '/approve-all')).toHaveLength(0)
679
+
680
+ await waitOutArming()
681
+ // A held Enter: its repeats click too.
682
+ fireEvent.keyDown(footer, { key: 'Enter', repeat: true })
683
+ fireEvent.click(footer)
684
+ expect(posts('POST', '/approve-all')).toHaveLength(0)
685
+ fireEvent.keyUp(footer, { key: 'Enter' })
686
+
687
+ fireEvent.keyDown(footer, { key: 'Enter' })
688
+ fireEvent.click(footer)
689
+ await waitFor(() => expect(posts('POST', '/approve-all')).toHaveLength(1))
690
+ })
691
+
692
+ it('approves an edit still open as edited when approving all', async () => {
693
+ vi.useFakeTimers({ shouldAdvanceTime: true })
694
+ server.jobs = [batch({ review: { pending: 3, approved: 0, rejected: 0 } })]
695
+ server.items.job_417 = pending('job_417', 3)
696
+ server.approveAll = () => ok({ approved: 2, skipped: 0, remaining: 0 })
697
+ await renderJobs()
698
+ const dialog = await openJob('job_417', 'Review')
699
+ await waitFor(() => card('r02'))
700
+ fireEvent.click(within(card('r02')).getByRole('button', { name: 'Edit' }))
701
+ fireEvent.change(within(card('r02')).getByRole('textbox'), {
702
+ target: { value: 'Rewritten by hand.' },
703
+ })
704
+ await waitOutArming()
705
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Approve all 3' }))
706
+ await waitFor(() =>
707
+ expect(toast).toHaveBeenCalledWith('3 changes approved · saved as drafts', undefined),
708
+ )
709
+ const order = server.calls.filter((call) => call.method === 'POST').map((call) => call.endpoint)
710
+ expect(order).toEqual(['/jobs/job_417/review/r02', '/jobs/job_417/review/approve-all'])
711
+ expect(posts('POST', '/review/r02')[0]?.body).toEqual({
712
+ action: 'approve',
713
+ after: 'Rewritten by hand.',
714
+ })
715
+ })
716
+
717
+ it.each([
718
+ ['an emptied edit (400)', 400, 'The approved text is empty.'],
719
+ [
720
+ 'changed while saving (409)',
721
+ 409,
722
+ 'The document changed while this was being saved, so nothing was written. Try again.',
723
+ ],
724
+ ['a server error', 500, 'Internal server error'],
725
+ ])('stops approving all when an open edit is refused: %s', async (_, status, message) => {
726
+ vi.useFakeTimers({ shouldAdvanceTime: true })
727
+ server.jobs = [batch({ review: { pending: 3, approved: 0, rejected: 0 } })]
728
+ server.items.job_417 = pending('job_417', 3)
729
+ server.decide = () => ({ error: message, status })
730
+ server.approveAll = () => ok({ approved: 3, skipped: 0, remaining: 0 })
731
+ await renderJobs()
732
+ const dialog = await openJob('job_417', 'Review')
733
+ await waitFor(() => card('r02'))
734
+ fireEvent.click(within(card('r02')).getByRole('button', { name: 'Edit' }))
735
+ fireEvent.change(within(card('r02')).getByRole('textbox'), { target: { value: '' } })
736
+ await waitOutArming()
737
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Approve all 3' }))
738
+ await waitFor(() =>
739
+ expect(toast.error).toHaveBeenCalledWith('Approve all stopped', {
740
+ description:
741
+ '1 edited change couldn’t be approved, so nothing else was, and the AI’s text doesn’t replace an edit. Each change says why.',
742
+ }),
743
+ )
744
+ // The server's approve-all never ran, so it couldn't apply the AI's text to r02.
745
+ expect(posts('POST', '/approve-all')).toHaveLength(0)
746
+ expect(announced()).toContain(`Post r02: ${message}`)
747
+ expect(noteOf(card('r02'))).toContain(message)
748
+ expect(within(card('r02')).getByRole('textbox')).toBeTruthy()
749
+ expect(within(card('r02')).queryByText('Approved')).toBeNull()
750
+ })
751
+
752
+ it('locks every change while approving all', async () => {
753
+ vi.useFakeTimers({ shouldAdvanceTime: true })
754
+ server.jobs = [batch({ review: { pending: 3, approved: 0, rejected: 0 } })]
755
+ server.items.job_417 = pending('job_417', 3)
756
+ const approveEdit = server.decide
757
+ let editAnswered: () => void = () => {}
758
+ server.decide = (jobId, itemId, body) =>
759
+ new Promise<Reply>((resolve) => {
760
+ editAnswered = () => resolve(approveEdit(jobId, itemId, body) as Reply)
761
+ })
762
+ let bulkAnswered: (reply: Reply) => void = () => {}
763
+ server.approveAll = () =>
764
+ new Promise<Reply>((resolve) => {
765
+ bulkAnswered = resolve
766
+ })
767
+ await renderJobs()
768
+ const dialog = await openJob('job_417', 'Review')
769
+ await waitFor(() => card('r02'))
770
+ fireEvent.click(within(card('r02')).getByRole('button', { name: 'Edit' }))
771
+ const box = within(card('r02')).getByRole('textbox') as HTMLTextAreaElement
772
+ fireEvent.change(box, { target: { value: 'Mine.' } })
773
+ await waitOutArming()
774
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Approve all 3' }))
775
+
776
+ const locked = () => {
777
+ // The open edit is read-only, so what's sent is what's shown.
778
+ expect(box.readOnly).toBe(true)
779
+ fireEvent.change(box, { target: { value: 'Mine, changed.' } })
780
+ expect(box.value).toBe('Mine.')
781
+ // Another change can't be edited or decided.
782
+ const edit = within(card('r01')).getByRole('button', { name: 'Edit' })
783
+ expect(edit.getAttribute('aria-disabled')).toBe('true')
784
+ fireEvent.click(edit)
785
+ expect(within(card('r01')).queryByRole('textbox')).toBeNull()
786
+ fireEvent.click(within(card('r01')).getByRole('button', { name: 'Reject' }))
787
+ expect(posts('POST', '/review/r01')).toHaveLength(0)
788
+ }
789
+ // While the edit is being approved…
790
+ await waitFor(() => expect(posts('POST', '/review/r02')).toHaveLength(1))
791
+ locked()
792
+ // …and while the server's approve-all runs.
793
+ act(() => editAnswered())
794
+ await waitFor(() => expect(posts('POST', '/approve-all')).toHaveLength(1))
795
+ expect(posts('POST', '/review/r02')[0]?.body).toEqual({ action: 'approve', after: 'Mine.' })
796
+ const edit = within(card('r01')).getByRole('button', { name: 'Edit' })
797
+ expect(edit.getAttribute('aria-disabled')).toBe('true')
798
+ fireEvent.click(edit)
799
+ expect(within(card('r01')).queryByRole('textbox')).toBeNull()
800
+
801
+ bulkAnswered(ok({ approved: 2, skipped: 0, remaining: 0 }))
802
+ await waitFor(() => expect(edit.getAttribute('aria-disabled')).toBeNull())
803
+ fireEvent.click(edit)
804
+ expect(within(card('r01')).getByRole('textbox')).toBeTruthy()
805
+ })
806
+
807
+ it('still approves all when an open edit stays in review on a published document', async () => {
808
+ vi.useFakeTimers({ shouldAdvanceTime: true })
809
+ server.jobs = [batch({ review: { pending: 3, approved: 0, rejected: 0 } })]
810
+ server.items.job_417 = pending('job_417', 3)
811
+ const published =
812
+ 'This document is published, so the change stays in review. Approve it with Publish to apply it live.'
813
+ server.decide = () => ({ error: published, status: 409 })
814
+ // The server skips that change too: it's published and the job saves drafts.
815
+ server.approveAll = () => ok({ approved: 2, skipped: 1, remaining: 1 })
816
+ await renderJobs()
817
+ const dialog = await openJob('job_417', 'Review')
818
+ await waitFor(() => card('r02'))
819
+ fireEvent.click(within(card('r02')).getByRole('button', { name: 'Edit' }))
820
+ await waitOutArming()
821
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Approve all 3' }))
822
+ await waitFor(() => expect(posts('POST', '/approve-all')).toHaveLength(1))
823
+ await waitFor(() => expect(announced()).toContain(`Post r02: ${published}`))
824
+ })
825
+
826
+ it('approves all, asking again while changes are left that it didn’t get to', async () => {
827
+ vi.useFakeTimers({ shouldAdvanceTime: true })
828
+ server.jobs = [
829
+ batch({
830
+ status: 'succeeded',
831
+ lockedAt: null,
832
+ review: { pending: 45, approved: 0, rejected: 0 },
833
+ }),
834
+ ]
835
+ server.items.job_417 = pending('job_417', 45)
836
+ const rounds = [
837
+ { approved: 20, skipped: 2, remaining: 25 },
838
+ { approved: 20, skipped: 3, remaining: 5 },
839
+ { approved: 2, skipped: 3, remaining: 3 },
840
+ ]
841
+ server.approveAll = () => ok(rounds.shift() ?? { approved: 0, skipped: 0, remaining: 0 })
842
+ await renderJobs()
843
+ const dialog = await openJob('job_417', 'Review')
844
+ await waitFor(() => card('r01'))
845
+ await waitOutArming()
846
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Approve all 45' }))
847
+ await waitFor(() =>
848
+ expect(toast).toHaveBeenCalledWith('42 changes approved · saved as drafts', {
849
+ description:
850
+ '3 changes stayed in review. Changes to published documents need Publish, so approve those one at a time.',
851
+ }),
852
+ )
853
+ expect(posts('POST', '/approve-all')).toHaveLength(3)
854
+ })
855
+
856
+ it('hides Review from someone who can’t read the suggested changes', async () => {
857
+ vi.useFakeTimers({ shouldAdvanceTime: true })
858
+ server.jobs = [
859
+ batch({
860
+ status: 'succeeded',
861
+ lockedAt: null,
862
+ review: { pending: 6, approved: 0, rejected: 0 },
863
+ }),
864
+ ]
865
+ server.items.job_417 = pending('job_417', 6)
866
+ server.reviewError = {
867
+ status: 403,
868
+ message: 'Only people who can edit content can read suggested changes',
869
+ }
870
+ await renderJobs()
871
+ // The list's Review button asks for the Review tab.
872
+ fireEvent.click(
873
+ within(rowButton('job_417').closest('li')!).getByRole('button', { name: /Review/ }),
874
+ )
875
+ const dialog = await screen.findByRole('dialog')
876
+ await waitFor(() =>
877
+ expect(
878
+ within(dialog)
879
+ .getAllByRole('tab')
880
+ .map((tab) => tab.textContent),
881
+ ).toEqual(['Overview', 'Activity', 'Details']),
882
+ )
883
+ expect(
884
+ within(dialog).getByRole('tab', { name: 'Overview' }).getAttribute('aria-selected'),
885
+ ).toBe('true')
886
+ expect(within(dialog).queryByRole('button', { name: /Review \d/ })).toBeNull()
887
+
888
+ // The job changing doesn't ask again: the 403 stands while the drawer is open.
889
+ const asked = posts('GET', '/review').length
890
+ server.jobs = [
891
+ batch({
892
+ status: 'succeeded',
893
+ lockedAt: null,
894
+ updatedAt: iso(5_000),
895
+ review: { pending: 7, approved: 0, rejected: 0 },
896
+ }),
897
+ ]
898
+ await act(async () => {
899
+ await vi.advanceTimersByTimeAsync(6_000)
900
+ })
901
+ await waitFor(() => expect(dialog.textContent).toContain('Activity'))
902
+ expect(posts('GET', '/review')).toHaveLength(asked)
903
+ })
904
+
905
+ it('shows an SEO scan’s results, linking each issue to its screen', async () => {
906
+ const onNavigate = vi.fn()
907
+ const seo = batch({
908
+ id: 'job_414',
909
+ type: 'seo-scan',
910
+ status: 'succeeded',
911
+ title: 'SEO scan',
912
+ scope: 'All indexable pages',
913
+ lockedAt: null,
914
+ createdBy: { kind: 'cron', schedule: 'weekly-seo' },
915
+ payload: { trigger: 'cron' },
916
+ summary: 'Score is up 4 points from last week.',
917
+ done: 340,
918
+ total: 340,
919
+ finishedAt: iso(-60 * MIN),
920
+ })
921
+ delete seo.review
922
+ server.jobs = [seo]
923
+ server.details.job_414 = {
924
+ results: {
925
+ score: 78,
926
+ prevScore: 74,
927
+ found: 23,
928
+ resolved: 11,
929
+ categories: [
930
+ {
931
+ key: 'missing-meta',
932
+ label: 'Missing meta description',
933
+ count: 5,
934
+ href: '/seo/content',
935
+ },
936
+ { key: 'bad', label: 'Somewhere else', count: 1, href: 'javascript:alert(1)' },
937
+ ],
938
+ digest: { sentAt: iso(-60 * MIN), recipients: 3 },
939
+ },
940
+ }
941
+ await renderJobs(onNavigate)
942
+ const dialog = await openJob('job_414', 'Results')
943
+ await within(dialog).findByText('Site score')
944
+ expect(within(dialog).getByText('+4')).toBeTruthy()
945
+ expect(dialog.textContent).toContain('Fixed since last scan11')
946
+ expect(within(dialog).getByText('Score is up 4 points from last week.')).toBeTruthy()
947
+ const link = within(dialog).getByRole('link', { name: /Missing meta description/ })
948
+ expect(link.getAttribute('href')).toBe('/seo/content')
949
+ fireEvent.click(link)
950
+ expect(onNavigate).toHaveBeenCalledWith('/seo/content')
951
+ expect(within(dialog).queryByRole('link', { name: /Somewhere else/ })).toBeNull()
952
+ expect(dialog.textContent).toContain('SEO digest: Sent to 3 people')
953
+ })
954
+
955
+ it('lists activity newest first, folding older finished slices', async () => {
956
+ server.jobs = [batch({})]
957
+ const slice = (minutes: number, n: number): JobEvent[] => [
958
+ {
959
+ at: iso(-minutes * MIN),
960
+ kind: 'yielded',
961
+ message: 'Slice finished',
962
+ detail: `Processed ${n} of 42`,
963
+ durationMs: 11_800,
964
+ tokens: 920,
965
+ },
966
+ { at: iso(-minutes * MIN - 12_000), kind: 'claimed', message: 'Slice started' },
967
+ ]
968
+ server.events.job_417 = [
969
+ { at: iso(0), kind: 'claimed', message: 'Slice started' },
970
+ ...slice(1, 18),
971
+ ...slice(2, 17),
972
+ ...slice(3, 16),
973
+ ...slice(4, 15),
974
+ { at: iso(-19 * MIN), kind: 'queued', message: 'Queued' },
975
+ ]
976
+ await renderJobs()
977
+ const dialog = await openJob('job_417', 'Activity')
978
+ const list = await within(dialog).findByRole('list')
979
+ const titles = within(list)
980
+ .getAllByRole('listitem')
981
+ .map((entry) => entry.querySelector('b, button')?.textContent)
982
+ expect(titles).toEqual([
983
+ 'Slice started',
984
+ 'Slice finished',
985
+ 'Slice finished',
986
+ '2 earlier slices',
987
+ 'Slice started',
988
+ 'Queued',
989
+ ])
990
+ expect(list.textContent).toContain('Processed 18 of 42 · 11.8s · 920 tokens · yielded')
991
+ expect(list.textContent).toContain('Brandon West started it')
992
+ const fold = within(list).getByRole('button', { name: '2 earlier slices' })
993
+ fireEvent.click(fold)
994
+ expect(fold.getAttribute('aria-expanded')).toBe('true')
995
+ expect(list.textContent).toContain('Processed 15 of 42')
996
+ })
997
+
998
+ it('keeps an open fold open, with focus on it, as the job’s events grow', async () => {
999
+ vi.useFakeTimers({ shouldAdvanceTime: true })
1000
+ server.jobs = [batch({})]
1001
+ const finished = (minutes: number, n: number): JobEvent => ({
1002
+ at: iso(-minutes * MIN),
1003
+ kind: 'yielded',
1004
+ message: 'Slice finished',
1005
+ detail: `Processed ${n} of 42`,
1006
+ })
1007
+ server.events.job_417 = [
1008
+ { at: iso(0), kind: 'claimed', message: 'Slice started' },
1009
+ finished(1, 18),
1010
+ finished(2, 17),
1011
+ finished(3, 16),
1012
+ finished(4, 15),
1013
+ { at: iso(-19 * MIN), kind: 'queued', message: 'Queued' },
1014
+ ]
1015
+ await renderJobs()
1016
+ const dialog = await openJob('job_417', 'Activity')
1017
+ const fold = await within(dialog).findByRole('button', { name: '2 earlier slices' })
1018
+ fireEvent.click(fold)
1019
+ fold.focus()
1020
+
1021
+ // A slice finishes: the newest event moves the fold's newer edge.
1022
+ server.events.job_417 = [
1023
+ { at: iso(5_000), kind: 'claimed', message: 'Slice started' },
1024
+ finished(-0.05, 19),
1025
+ ...server.events.job_417.slice(1),
1026
+ ]
1027
+ server.jobs = [batch({ updatedAt: iso(5_000), done: 20 })]
1028
+ await act(async () => {
1029
+ await vi.advanceTimersByTimeAsync(6_000)
1030
+ })
1031
+ await waitFor(() => expect(dialog.textContent).toContain('Processed 19 of 42'))
1032
+ const after = within(dialog).getByRole('button', { name: '3 earlier slices' })
1033
+ expect(after).toBe(fold)
1034
+ expect(after.getAttribute('aria-expanded')).toBe('true')
1035
+ expect(document.activeElement).toBe(fold)
1036
+ })
1037
+
1038
+ it('shows the raw row, with the document ids counted', async () => {
1039
+ server.jobs = [batch({})]
1040
+ await renderJobs()
1041
+ const dialog = await openJob('job_417', 'Details')
1042
+ expect(dialog.textContent).toContain('The raw job row, for developers. Read only.')
1043
+ const raw = within(dialog).getByLabelText('Job row as JSON').textContent ?? ''
1044
+ expect(raw).toContain('"documentIds": "[3 ids]"')
1045
+ expect(raw).toContain('"id": "job_417"')
1046
+ })
1047
+ })