@prenta/admin 0.92.0 → 0.93.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 (374) hide show
  1. package/CHANGELOG.md +552 -0
  2. package/design-system/tokens.css +173 -71
  3. package/design-system/tokens.json +244 -63
  4. package/design-system/tokens.scss +203 -98
  5. package/design-system/tokens.tailwind.cjs +93 -38
  6. package/dist/AdminRoot.d.ts +27 -6
  7. package/dist/AdminRoot.d.ts.map +1 -1
  8. package/dist/AdminRoot.js.map +1 -1
  9. package/dist/__tests__/components/breakpoint-controls.render.test.d.ts +2 -0
  10. package/dist/__tests__/components/breakpoint-controls.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/components/breakpoint-controls.render.test.js +51 -0
  12. package/dist/__tests__/components/breakpoint-controls.render.test.js.map +1 -0
  13. package/dist/__tests__/components/canvas-edit-tip.render.test.d.ts +2 -0
  14. package/dist/__tests__/components/canvas-edit-tip.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/components/canvas-edit-tip.render.test.js +37 -0
  16. package/dist/__tests__/components/canvas-edit-tip.render.test.js.map +1 -0
  17. package/dist/__tests__/components/editor-rail.render.test.d.ts +2 -0
  18. package/dist/__tests__/components/editor-rail.render.test.d.ts.map +1 -0
  19. package/dist/__tests__/components/editor-rail.render.test.js +138 -0
  20. package/dist/__tests__/components/editor-rail.render.test.js.map +1 -0
  21. package/dist/__tests__/components/media-block.render.test.d.ts +2 -0
  22. package/dist/__tests__/components/media-block.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/components/media-block.render.test.js +108 -0
  24. package/dist/__tests__/components/media-block.render.test.js.map +1 -0
  25. package/dist/__tests__/components/page-audit-panel.render.test.d.ts +2 -0
  26. package/dist/__tests__/components/page-audit-panel.render.test.d.ts.map +1 -0
  27. package/dist/__tests__/components/page-audit-panel.render.test.js +59 -0
  28. package/dist/__tests__/components/page-audit-panel.render.test.js.map +1 -0
  29. package/dist/__tests__/components/reflow-meter.render.test.d.ts +2 -0
  30. package/dist/__tests__/components/reflow-meter.render.test.d.ts.map +1 -0
  31. package/dist/__tests__/components/reflow-meter.render.test.js +94 -0
  32. package/dist/__tests__/components/reflow-meter.render.test.js.map +1 -0
  33. package/dist/__tests__/components/seo-char-count.test.js +24 -6
  34. package/dist/__tests__/components/seo-char-count.test.js.map +1 -1
  35. package/dist/__tests__/components/serp-preview.render.test.d.ts +2 -0
  36. package/dist/__tests__/components/serp-preview.render.test.d.ts.map +1 -0
  37. package/dist/__tests__/components/serp-preview.render.test.js +25 -0
  38. package/dist/__tests__/components/serp-preview.render.test.js.map +1 -0
  39. package/dist/__tests__/lib/breakpoints.test.d.ts +2 -0
  40. package/dist/__tests__/lib/breakpoints.test.d.ts.map +1 -0
  41. package/dist/__tests__/lib/breakpoints.test.js +67 -0
  42. package/dist/__tests__/lib/breakpoints.test.js.map +1 -0
  43. package/dist/__tests__/lib/canvas-edit-chrome.test.d.ts +2 -0
  44. package/dist/__tests__/lib/canvas-edit-chrome.test.d.ts.map +1 -0
  45. package/dist/__tests__/lib/canvas-edit-chrome.test.js +44 -0
  46. package/dist/__tests__/lib/canvas-edit-chrome.test.js.map +1 -0
  47. package/dist/__tests__/lib/media-format.test.d.ts +2 -0
  48. package/dist/__tests__/lib/media-format.test.d.ts.map +1 -0
  49. package/dist/__tests__/lib/media-format.test.js +70 -0
  50. package/dist/__tests__/lib/media-format.test.js.map +1 -0
  51. package/dist/__tests__/lib/page-audit-input.test.d.ts +2 -0
  52. package/dist/__tests__/lib/page-audit-input.test.d.ts.map +1 -0
  53. package/dist/__tests__/lib/page-audit-input.test.js +122 -0
  54. package/dist/__tests__/lib/page-audit-input.test.js.map +1 -0
  55. package/dist/__tests__/lib/page-editor-service.test.js +2 -0
  56. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  57. package/dist/__tests__/lib/page-redirect.test.d.ts +2 -0
  58. package/dist/__tests__/lib/page-redirect.test.d.ts.map +1 -0
  59. package/dist/__tests__/lib/page-redirect.test.js +26 -0
  60. package/dist/__tests__/lib/page-redirect.test.js.map +1 -0
  61. package/dist/__tests__/lib/rail-width.test.d.ts +2 -0
  62. package/dist/__tests__/lib/rail-width.test.d.ts.map +1 -0
  63. package/dist/__tests__/lib/rail-width.test.js +69 -0
  64. package/dist/__tests__/lib/rail-width.test.js.map +1 -0
  65. package/dist/__tests__/lib/reflow-baseline.test.d.ts +2 -0
  66. package/dist/__tests__/lib/reflow-baseline.test.d.ts.map +1 -0
  67. package/dist/__tests__/lib/reflow-baseline.test.js +56 -0
  68. package/dist/__tests__/lib/reflow-baseline.test.js.map +1 -0
  69. package/dist/__tests__/lib/reflow-probe.test.d.ts +2 -0
  70. package/dist/__tests__/lib/reflow-probe.test.d.ts.map +1 -0
  71. package/dist/__tests__/lib/reflow-probe.test.js +73 -0
  72. package/dist/__tests__/lib/reflow-probe.test.js.map +1 -0
  73. package/dist/__tests__/styles/canvas-container-queries.test.d.ts +2 -0
  74. package/dist/__tests__/styles/canvas-container-queries.test.d.ts.map +1 -0
  75. package/dist/__tests__/styles/canvas-container-queries.test.js +87 -0
  76. package/dist/__tests__/styles/canvas-container-queries.test.js.map +1 -0
  77. package/dist/__tests__/styles/packaging.test.d.ts +2 -0
  78. package/dist/__tests__/styles/packaging.test.d.ts.map +1 -0
  79. package/dist/__tests__/styles/packaging.test.js +31 -0
  80. package/dist/__tests__/styles/packaging.test.js.map +1 -0
  81. package/dist/__tests__/styles/tokens-drift.test.d.ts +2 -0
  82. package/dist/__tests__/styles/tokens-drift.test.d.ts.map +1 -0
  83. package/dist/__tests__/styles/tokens-drift.test.js +92 -0
  84. package/dist/__tests__/styles/tokens-drift.test.js.map +1 -0
  85. package/dist/__tests__/views/admin-renderer-compat.test.d.ts +2 -0
  86. package/dist/__tests__/views/admin-renderer-compat.test.d.ts.map +1 -0
  87. package/dist/__tests__/views/admin-renderer-compat.test.js +43 -0
  88. package/dist/__tests__/views/admin-renderer-compat.test.js.map +1 -0
  89. package/dist/__tests__/views/admin-section-renderers.test.js +52 -4
  90. package/dist/__tests__/views/admin-section-renderers.test.js.map +1 -1
  91. package/dist/__tests__/views/editor-top-bar.render.test.js +1 -1
  92. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  93. package/dist/__tests__/views/page-tab.render.test.d.ts +2 -0
  94. package/dist/__tests__/views/page-tab.render.test.d.ts.map +1 -0
  95. package/dist/__tests__/views/page-tab.render.test.js +151 -0
  96. package/dist/__tests__/views/page-tab.render.test.js.map +1 -0
  97. package/dist/__tests__/views/section-class-parity.test.d.ts +2 -0
  98. package/dist/__tests__/views/section-class-parity.test.d.ts.map +1 -0
  99. package/dist/__tests__/views/section-class-parity.test.js +81 -0
  100. package/dist/__tests__/views/section-class-parity.test.js.map +1 -0
  101. package/dist/__tests__/views/sections-panel-design-owned.render.test.d.ts +2 -0
  102. package/dist/__tests__/views/sections-panel-design-owned.render.test.d.ts.map +1 -0
  103. package/dist/__tests__/views/sections-panel-design-owned.render.test.js +86 -0
  104. package/dist/__tests__/views/sections-panel-design-owned.render.test.js.map +1 -0
  105. package/dist/components/BreakpointPicker.d.ts +18 -0
  106. package/dist/components/BreakpointPicker.d.ts.map +1 -0
  107. package/dist/components/BreakpointPicker.js +31 -0
  108. package/dist/components/BreakpointPicker.js.map +1 -0
  109. package/dist/components/CanvasEditTip.d.ts +6 -0
  110. package/dist/components/CanvasEditTip.d.ts.map +1 -0
  111. package/dist/components/CanvasEditTip.js +22 -0
  112. package/dist/components/CanvasEditTip.js.map +1 -0
  113. package/dist/components/EditorRail.d.ts +36 -0
  114. package/dist/components/EditorRail.d.ts.map +1 -0
  115. package/dist/components/EditorRail.js +104 -0
  116. package/dist/components/EditorRail.js.map +1 -0
  117. package/dist/components/EditorRailTabs.d.ts +32 -0
  118. package/dist/components/EditorRailTabs.d.ts.map +1 -0
  119. package/dist/components/EditorRailTabs.js +45 -0
  120. package/dist/components/EditorRailTabs.js.map +1 -0
  121. package/dist/components/MediaBlock.d.ts +49 -0
  122. package/dist/components/MediaBlock.d.ts.map +1 -0
  123. package/dist/components/MediaBlock.js +50 -0
  124. package/dist/components/MediaBlock.js.map +1 -0
  125. package/dist/components/ReflowMeter.d.ts +28 -0
  126. package/dist/components/ReflowMeter.d.ts.map +1 -0
  127. package/dist/components/ReflowMeter.js +42 -0
  128. package/dist/components/ReflowMeter.js.map +1 -0
  129. package/dist/components/ZoomChip.d.ts +17 -0
  130. package/dist/components/ZoomChip.d.ts.map +1 -0
  131. package/dist/components/ZoomChip.js +17 -0
  132. package/dist/components/ZoomChip.js.map +1 -0
  133. package/dist/components/breakpoint-icons.d.ts +3 -0
  134. package/dist/components/breakpoint-icons.d.ts.map +1 -0
  135. package/dist/components/breakpoint-icons.js +16 -0
  136. package/dist/components/breakpoint-icons.js.map +1 -0
  137. package/dist/components/page-tab/AuthorPicker.d.ts +31 -0
  138. package/dist/components/page-tab/AuthorPicker.d.ts.map +1 -0
  139. package/dist/components/page-tab/AuthorPicker.js +95 -0
  140. package/dist/components/page-tab/AuthorPicker.js.map +1 -0
  141. package/dist/components/page-tab/DirectiveSwitch.d.ts +38 -0
  142. package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -0
  143. package/dist/components/page-tab/DirectiveSwitch.js +29 -0
  144. package/dist/components/page-tab/DirectiveSwitch.js.map +1 -0
  145. package/dist/components/seo/EditorSeoScoreBadge.d.ts +8 -1
  146. package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -1
  147. package/dist/components/seo/EditorSeoScoreBadge.js +2 -2
  148. package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -1
  149. package/dist/components/seo/PageAuditPanel.d.ts +14 -0
  150. package/dist/components/seo/PageAuditPanel.d.ts.map +1 -0
  151. package/dist/components/seo/PageAuditPanel.js +59 -0
  152. package/dist/components/seo/PageAuditPanel.js.map +1 -0
  153. package/dist/components/seo/SeoCharCount.d.ts +19 -7
  154. package/dist/components/seo/SeoCharCount.d.ts.map +1 -1
  155. package/dist/components/seo/SeoCharCount.js +30 -16
  156. package/dist/components/seo/SeoCharCount.js.map +1 -1
  157. package/dist/components/seo/SeoEditorPane.d.ts +15 -1
  158. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  159. package/dist/components/seo/SeoEditorPane.js +118 -21
  160. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  161. package/dist/components/seo/SerpPreview.d.ts +20 -0
  162. package/dist/components/seo/SerpPreview.d.ts.map +1 -0
  163. package/dist/components/seo/SerpPreview.js +6 -0
  164. package/dist/components/seo/SerpPreview.js.map +1 -0
  165. package/dist/hooks/usePageAudit.d.ts +41 -0
  166. package/dist/hooks/usePageAudit.d.ts.map +1 -0
  167. package/dist/hooks/usePageAudit.js +68 -0
  168. package/dist/hooks/usePageAudit.js.map +1 -0
  169. package/dist/hooks/useReflowGuard.d.ts +47 -0
  170. package/dist/hooks/useReflowGuard.d.ts.map +1 -0
  171. package/dist/hooks/useReflowGuard.js +52 -0
  172. package/dist/hooks/useReflowGuard.js.map +1 -0
  173. package/dist/lib/breakpoints.d.ts +51 -0
  174. package/dist/lib/breakpoints.d.ts.map +1 -0
  175. package/dist/lib/breakpoints.js +84 -0
  176. package/dist/lib/breakpoints.js.map +1 -0
  177. package/dist/lib/canvas-edit-chrome.d.ts +22 -0
  178. package/dist/lib/canvas-edit-chrome.d.ts.map +1 -0
  179. package/dist/lib/canvas-edit-chrome.js +81 -0
  180. package/dist/lib/canvas-edit-chrome.js.map +1 -0
  181. package/dist/lib/media-format.d.ts +55 -0
  182. package/dist/lib/media-format.d.ts.map +1 -0
  183. package/dist/lib/media-format.js +93 -0
  184. package/dist/lib/media-format.js.map +1 -0
  185. package/dist/lib/page-audit-input.d.ts +45 -0
  186. package/dist/lib/page-audit-input.d.ts.map +1 -0
  187. package/dist/lib/page-audit-input.js +53 -0
  188. package/dist/lib/page-audit-input.js.map +1 -0
  189. package/dist/lib/page-editor-service.d.ts +12 -0
  190. package/dist/lib/page-editor-service.d.ts.map +1 -1
  191. package/dist/lib/page-editor-service.js +6 -0
  192. package/dist/lib/page-editor-service.js.map +1 -1
  193. package/dist/lib/page-redirect.d.ts +39 -0
  194. package/dist/lib/page-redirect.d.ts.map +1 -0
  195. package/dist/lib/page-redirect.js +38 -0
  196. package/dist/lib/page-redirect.js.map +1 -0
  197. package/dist/lib/rail-width.d.ts +23 -0
  198. package/dist/lib/rail-width.d.ts.map +1 -0
  199. package/dist/lib/rail-width.js +59 -0
  200. package/dist/lib/rail-width.js.map +1 -0
  201. package/dist/lib/reflow-baseline.d.ts +45 -0
  202. package/dist/lib/reflow-baseline.d.ts.map +1 -0
  203. package/dist/lib/reflow-baseline.js +38 -0
  204. package/dist/lib/reflow-baseline.js.map +1 -0
  205. package/dist/lib/reflow-probe.d.ts +159 -0
  206. package/dist/lib/reflow-probe.d.ts.map +1 -0
  207. package/dist/lib/reflow-probe.js +228 -0
  208. package/dist/lib/reflow-probe.js.map +1 -0
  209. package/dist/lib/seo-service.d.ts +10 -0
  210. package/dist/lib/seo-service.d.ts.map +1 -1
  211. package/dist/lib/seo-service.js.map +1 -1
  212. package/dist/prenta-admin.css +1 -1
  213. package/dist/views/page-editor/CanvasMetaLine.d.ts +19 -0
  214. package/dist/views/page-editor/CanvasMetaLine.d.ts.map +1 -0
  215. package/dist/views/page-editor/CanvasMetaLine.js +23 -0
  216. package/dist/views/page-editor/CanvasMetaLine.js.map +1 -0
  217. package/dist/views/page-editor/EditorCanvas.d.ts +34 -8
  218. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  219. package/dist/views/page-editor/EditorCanvas.js +68 -34
  220. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  221. package/dist/views/page-editor/EditorTopBar.d.ts +14 -1
  222. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  223. package/dist/views/page-editor/EditorTopBar.js +8 -17
  224. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  225. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  226. package/dist/views/page-editor/PageSectionEditor.js +275 -53
  227. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  228. package/dist/views/page-editor/PageTab.d.ts +36 -0
  229. package/dist/views/page-editor/PageTab.d.ts.map +1 -0
  230. package/dist/views/page-editor/PageTab.js +65 -0
  231. package/dist/views/page-editor/PageTab.js.map +1 -0
  232. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  233. package/dist/views/page-editor/SectionInspector.js +63 -19
  234. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  235. package/dist/views/page-editor/SectionsPanel.d.ts +30 -5
  236. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  237. package/dist/views/page-editor/SectionsPanel.js +38 -7
  238. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  239. package/dist/views/page-editor/SitePreviewFrame.d.ts.map +1 -1
  240. package/dist/views/page-editor/SitePreviewFrame.js +3 -0
  241. package/dist/views/page-editor/SitePreviewFrame.js.map +1 -1
  242. package/dist/views/page-editor/reflow-context.d.ts +28 -0
  243. package/dist/views/page-editor/reflow-context.d.ts.map +1 -0
  244. package/dist/views/page-editor/reflow-context.js +14 -0
  245. package/dist/views/page-editor/reflow-context.js.map +1 -0
  246. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -1
  247. package/dist/views/page-editor/sections/ByTheNumbersSection.js +1 -1
  248. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -1
  249. package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -1
  250. package/dist/views/page-editor/sections/CtaBannerSection.js +1 -1
  251. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -1
  252. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -1
  253. package/dist/views/page-editor/sections/FeatureHighlightSection.js +2 -2
  254. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -1
  255. package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -1
  256. package/dist/views/page-editor/sections/GallerySection.js +7 -3
  257. package/dist/views/page-editor/sections/GallerySection.js.map +1 -1
  258. package/dist/views/page-editor/sections/HeroBannerSection.js +1 -1
  259. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -1
  260. package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -1
  261. package/dist/views/page-editor/sections/MissionSection.js +1 -1
  262. package/dist/views/page-editor/sections/MissionSection.js.map +1 -1
  263. package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -1
  264. package/dist/views/page-editor/sections/PullQuoteSection.js +1 -1
  265. package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -1
  266. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -1
  267. package/dist/views/page-editor/sections/RelatedPostsSection.js +1 -1
  268. package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -1
  269. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  270. package/dist/views/page-editor/sections/SectionRenderer.js +49 -24
  271. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  272. package/dist/views/page-editor/sections/admin-renderers.d.ts +47 -15
  273. package/dist/views/page-editor/sections/admin-renderers.d.ts.map +1 -1
  274. package/dist/views/page-editor/sections/admin-renderers.js +27 -15
  275. package/dist/views/page-editor/sections/admin-renderers.js.map +1 -1
  276. package/dist/views/page-editor/sections/parts.d.ts +0 -1
  277. package/dist/views/page-editor/sections/parts.d.ts.map +1 -1
  278. package/dist/views/page-editor/sections/parts.js +41 -16
  279. package/dist/views/page-editor/sections/parts.js.map +1 -1
  280. package/dist/views/page-editor/viewports.d.ts +7 -14
  281. package/dist/views/page-editor/viewports.d.ts.map +1 -1
  282. package/dist/views/page-editor/viewports.js +7 -12
  283. package/dist/views/page-editor/viewports.js.map +1 -1
  284. package/dist/views/post-editor/PostEditorCanvas.d.ts +2 -1
  285. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  286. package/dist/views/post-editor/PostEditorCanvas.js +5 -2
  287. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  288. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  289. package/dist/views/post-editor/PostSectionEditor.js +2 -20
  290. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  291. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -1
  292. package/dist/views/post-editor/PostTemplateEditor.js +4 -1
  293. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
  294. package/package.json +5 -3
  295. package/src/AdminRoot.tsx +27 -6
  296. package/src/__tests__/components/breakpoint-controls.render.test.tsx +57 -0
  297. package/src/__tests__/components/canvas-edit-tip.render.test.tsx +41 -0
  298. package/src/__tests__/components/editor-rail.render.test.tsx +192 -0
  299. package/src/__tests__/components/media-block.render.test.tsx +126 -0
  300. package/src/__tests__/components/page-audit-panel.render.test.tsx +64 -0
  301. package/src/__tests__/components/reflow-meter.render.test.tsx +144 -0
  302. package/src/__tests__/components/seo-char-count.test.tsx +26 -6
  303. package/src/__tests__/components/serp-preview.render.test.tsx +32 -0
  304. package/src/__tests__/lib/breakpoints.test.ts +84 -0
  305. package/src/__tests__/lib/canvas-edit-chrome.test.ts +56 -0
  306. package/src/__tests__/lib/media-format.test.ts +89 -0
  307. package/src/__tests__/lib/page-audit-input.test.ts +144 -0
  308. package/src/__tests__/lib/page-editor-service.test.ts +2 -0
  309. package/src/__tests__/lib/page-redirect.test.ts +29 -0
  310. package/src/__tests__/lib/rail-width.test.ts +86 -0
  311. package/src/__tests__/lib/reflow-baseline.test.ts +64 -0
  312. package/src/__tests__/lib/reflow-probe.test.ts +95 -0
  313. package/src/__tests__/styles/canvas-container-queries.test.ts +103 -0
  314. package/src/__tests__/styles/packaging.test.ts +38 -0
  315. package/src/__tests__/styles/tokens-drift.test.ts +110 -0
  316. package/src/__tests__/views/admin-renderer-compat.test.ts +48 -0
  317. package/src/__tests__/views/admin-section-renderers.test.tsx +70 -4
  318. package/src/__tests__/views/editor-top-bar.render.test.tsx +1 -1
  319. package/src/__tests__/views/page-tab.render.test.tsx +226 -0
  320. package/src/__tests__/views/section-class-parity.test.ts +98 -0
  321. package/src/__tests__/views/sections-panel-design-owned.render.test.tsx +104 -0
  322. package/src/components/BreakpointPicker.tsx +114 -0
  323. package/src/components/CanvasEditTip.tsx +46 -0
  324. package/src/components/EditorRail.tsx +219 -0
  325. package/src/components/EditorRailTabs.tsx +117 -0
  326. package/src/components/MediaBlock.tsx +223 -0
  327. package/src/components/ReflowMeter.tsx +125 -0
  328. package/src/components/ZoomChip.tsx +39 -0
  329. package/src/components/breakpoint-icons.ts +17 -0
  330. package/src/components/page-tab/AuthorPicker.tsx +240 -0
  331. package/src/components/page-tab/DirectiveSwitch.tsx +86 -0
  332. package/src/components/seo/EditorSeoScoreBadge.tsx +9 -1
  333. package/src/components/seo/PageAuditPanel.tsx +192 -0
  334. package/src/components/seo/SeoCharCount.tsx +55 -24
  335. package/src/components/seo/SeoEditorPane.tsx +213 -31
  336. package/src/components/seo/SerpPreview.tsx +41 -0
  337. package/src/hooks/usePageAudit.ts +98 -0
  338. package/src/hooks/useReflowGuard.ts +96 -0
  339. package/src/lib/breakpoints.ts +107 -0
  340. package/src/lib/canvas-edit-chrome.ts +82 -0
  341. package/src/lib/media-format.ts +99 -0
  342. package/src/lib/page-audit-input.ts +104 -0
  343. package/src/lib/page-editor-service.ts +18 -0
  344. package/src/lib/page-redirect.ts +60 -0
  345. package/src/lib/rail-width.ts +58 -0
  346. package/src/lib/reflow-baseline.ts +70 -0
  347. package/src/lib/reflow-probe.ts +299 -0
  348. package/src/lib/seo-service.ts +10 -0
  349. package/src/styles/prenta-tokens.css +120 -42
  350. package/src/styles/theme.css +130 -0
  351. package/src/views/page-editor/CanvasMetaLine.tsx +50 -0
  352. package/src/views/page-editor/EditorCanvas.tsx +109 -50
  353. package/src/views/page-editor/EditorTopBar.tsx +35 -43
  354. package/src/views/page-editor/PageSectionEditor.tsx +419 -148
  355. package/src/views/page-editor/PageTab.tsx +433 -0
  356. package/src/views/page-editor/SectionInspector.tsx +82 -41
  357. package/src/views/page-editor/SectionsPanel.tsx +281 -140
  358. package/src/views/page-editor/SitePreviewFrame.tsx +3 -0
  359. package/src/views/page-editor/reflow-context.ts +43 -0
  360. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +3 -2
  361. package/src/views/page-editor/sections/CtaBannerSection.tsx +3 -1
  362. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +3 -2
  363. package/src/views/page-editor/sections/GallerySection.tsx +7 -3
  364. package/src/views/page-editor/sections/HeroBannerSection.tsx +2 -2
  365. package/src/views/page-editor/sections/MissionSection.tsx +4 -2
  366. package/src/views/page-editor/sections/PullQuoteSection.tsx +3 -1
  367. package/src/views/page-editor/sections/RelatedPostsSection.tsx +2 -1
  368. package/src/views/page-editor/sections/SectionRenderer.tsx +69 -29
  369. package/src/views/page-editor/sections/admin-renderers.tsx +54 -15
  370. package/src/views/page-editor/sections/parts.tsx +46 -18
  371. package/src/views/page-editor/viewports.ts +13 -26
  372. package/src/views/post-editor/PostEditorCanvas.tsx +18 -11
  373. package/src/views/post-editor/PostSectionEditor.tsx +1 -56
  374. package/src/views/post-editor/PostTemplateEditor.tsx +3 -0
@@ -0,0 +1,219 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'
5
+ import {
6
+ RAIL_COLLAPSED_WIDTH,
7
+ RAIL_DEFAULT_WIDTH,
8
+ RAIL_MAX_WIDTH,
9
+ RAIL_MIN_WIDTH,
10
+ clampRailWidth,
11
+ readRailWidth,
12
+ writeRailWidth,
13
+ } from '../lib/rail-width.js'
14
+
15
+ /** What the rail hands its children so they can render a header. */
16
+ export interface RailState {
17
+ width: number
18
+ dragging: boolean
19
+ collapse: () => void
20
+ }
21
+
22
+ export interface EditorRailProps {
23
+ /** Short label for the collapsed strip — normally the active tab's name. */
24
+ collapsedLabel: string
25
+ children: (state: RailState) => React.ReactNode
26
+ }
27
+
28
+ /**
29
+ * The editor's left rail: resizable, collapsible, and width-persistent.
30
+ *
31
+ * Collapsing UNMOUNTS the rail and swaps in a narrow strip rather than
32
+ * animating its width to zero. A zero-width panel leaves a visible sliver of
33
+ * border and keeps its contents in the accessibility tree, so a screen-reader
34
+ * user lands inside a panel sighted users cannot see.
35
+ *
36
+ * Children are a render prop rather than plain nodes because the panel header
37
+ * needs the live width and drag state — the numbers the rail owns — and
38
+ * threading them through context for one consumer is more machinery than the
39
+ * problem deserves.
40
+ */
41
+ export function EditorRail({ collapsedLabel, children }: EditorRailProps) {
42
+ const [width, setWidth] = useState(RAIL_DEFAULT_WIDTH)
43
+ const [open, setOpen] = useState(true)
44
+ const [dragging, setDragging] = useState(false)
45
+ const railRef = useRef<HTMLDivElement>(null)
46
+
47
+ // Read persisted width after mount, never during render: there is no
48
+ // localStorage on the server, and seeding initial state from it would
49
+ // hydrate-mismatch.
50
+ useEffect(() => {
51
+ setWidth(readRailWidth())
52
+ }, [])
53
+
54
+ const applyWidth = useCallback((next: number) => {
55
+ const clamped = clampRailWidth(next)
56
+ setWidth(clamped)
57
+ writeRailWidth(clamped)
58
+ }, [])
59
+
60
+ const collapse = useCallback(() => setOpen(false), [])
61
+
62
+ // Pointer listeners go on `window`, not the handle: the pointer routinely
63
+ // outruns a 7px target mid-drag, and a handle-local listener would drop the
64
+ // drag the moment it did.
65
+ useEffect(() => {
66
+ if (!dragging) return
67
+
68
+ const onMove = (event: PointerEvent) => {
69
+ const left = railRef.current?.getBoundingClientRect().left ?? 0
70
+ applyWidth(event.clientX - left)
71
+ }
72
+ const stop = () => setDragging(false)
73
+
74
+ window.addEventListener('pointermove', onMove)
75
+ window.addEventListener('pointerup', stop)
76
+ window.addEventListener('pointercancel', stop)
77
+
78
+ // Lock cursor and selection for the duration. Without this the cursor
79
+ // flickers between col-resize and text as it crosses elements, and the
80
+ // drag selects the panel's copy.
81
+ const { body } = document
82
+ const prevCursor = body.style.cursor
83
+ const prevSelect = body.style.userSelect
84
+ body.style.cursor = 'col-resize'
85
+ body.style.userSelect = 'none'
86
+
87
+ return () => {
88
+ window.removeEventListener('pointermove', onMove)
89
+ window.removeEventListener('pointerup', stop)
90
+ window.removeEventListener('pointercancel', stop)
91
+ body.style.cursor = prevCursor
92
+ body.style.userSelect = prevSelect
93
+ }
94
+ }, [dragging, applyWidth])
95
+
96
+ if (!open) {
97
+ return (
98
+ <div
99
+ className="bg-card border-border flex shrink-0 flex-col items-center gap-3 border-r py-3"
100
+ style={{ width: RAIL_COLLAPSED_WIDTH }}
101
+ >
102
+ <button
103
+ type="button"
104
+ onClick={() => setOpen(true)}
105
+ aria-label={`Expand ${collapsedLabel} panel`}
106
+ title={`Expand ${collapsedLabel} panel`}
107
+ className="text-muted-foreground hover:bg-accent hover:text-accent-foreground flex h-6.5 w-6.5 items-center justify-center rounded-md transition-colors"
108
+ >
109
+ <PanelLeftOpen className="h-4 w-4" aria-hidden />
110
+ </button>
111
+ {/* Vertical label so the strip still says which panel is hidden. The
112
+ button's accessible name already carries it, hence aria-hidden. */}
113
+ <span
114
+ className="text-muted-foreground text-[10px] font-bold tracking-[0.8px] whitespace-nowrap uppercase [writing-mode:vertical-rl]"
115
+ aria-hidden
116
+ >
117
+ {collapsedLabel}
118
+ </span>
119
+ </div>
120
+ )
121
+ }
122
+
123
+ return (
124
+ <div
125
+ ref={railRef}
126
+ className={`bg-card border-border relative flex shrink-0 flex-col border-r ${
127
+ // A width transition during a drag fights the pointer: the panel
128
+ // chases the cursor a frame behind and the resize feels broken.
129
+ dragging ? '' : 'motion-safe:transition-[width] motion-safe:duration-(--mo-slow)'
130
+ }`}
131
+ style={{ width }}
132
+ >
133
+ <div className="flex min-h-0 flex-1 flex-col">{children({ width, dragging, collapse })}</div>
134
+
135
+ <div
136
+ // `separator` with an orientation and a value range is what makes this
137
+ // operable without a pointer. A bare div with a pointerdown handler is
138
+ // invisible to keyboard and assistive-tech users.
139
+ role="separator"
140
+ aria-orientation="vertical"
141
+ aria-label="Resize panel"
142
+ aria-valuenow={width}
143
+ aria-valuemin={RAIL_MIN_WIDTH}
144
+ aria-valuemax={RAIL_MAX_WIDTH}
145
+ tabIndex={0}
146
+ onPointerDown={(event) => {
147
+ event.preventDefault()
148
+ setDragging(true)
149
+ }}
150
+ onDoubleClick={() => applyWidth(RAIL_DEFAULT_WIDTH)}
151
+ onKeyDown={(event) => {
152
+ const step = event.shiftKey ? 48 : 16
153
+ if (event.key === 'ArrowLeft') {
154
+ event.preventDefault()
155
+ applyWidth(width - step)
156
+ } else if (event.key === 'ArrowRight') {
157
+ event.preventDefault()
158
+ applyWidth(width + step)
159
+ } else if (event.key === 'Home') {
160
+ event.preventDefault()
161
+ applyWidth(RAIL_DEFAULT_WIDTH)
162
+ }
163
+ }}
164
+ className="group absolute inset-y-0 -right-[3px] z-30 w-[7px] cursor-col-resize focus-visible:outline-none"
165
+ >
166
+ {/* The 2px line it draws sits inside a 7px hit area on purpose — a 2px
167
+ pointer target is a coin flip. */}
168
+ <span
169
+ aria-hidden
170
+ className={`pointer-events-none absolute inset-y-0 left-[2.5px] w-0.5 transition-colors ${
171
+ dragging
172
+ ? 'bg-brand'
173
+ : 'group-hover:bg-brand group-focus-visible:bg-brand bg-transparent'
174
+ }`}
175
+ />
176
+ </div>
177
+ </div>
178
+ )
179
+ }
180
+
181
+ /**
182
+ * Header strip under the rail's tabs: panel title, the live width while
183
+ * dragging, and the collapse control.
184
+ */
185
+ export function RailHeader({
186
+ title,
187
+ width,
188
+ dragging,
189
+ onCollapse,
190
+ }: {
191
+ title: string
192
+ width: number
193
+ dragging: boolean
194
+ onCollapse: () => void
195
+ }) {
196
+ return (
197
+ <div className="border-border flex items-center justify-between gap-2 border-b px-3.5 py-2.5">
198
+ <h2 className="text-foreground truncate text-[12.5px] font-semibold">{title}</h2>
199
+ <div className="flex shrink-0 items-center gap-1.5">
200
+ {/* Only while dragging: a permanent pixel readout is noise, but during
201
+ a resize it is the one number the user is actually aiming at. */}
202
+ {dragging && (
203
+ <span className="text-muted-foreground text-[11px] tabular-nums" aria-hidden>
204
+ {width}px
205
+ </span>
206
+ )}
207
+ <button
208
+ type="button"
209
+ onClick={onCollapse}
210
+ aria-label={`Collapse ${title} panel`}
211
+ title={`Collapse ${title} panel`}
212
+ className="text-muted-foreground hover:bg-accent hover:text-accent-foreground flex h-6.5 w-6.5 items-center justify-center rounded-md transition-colors"
213
+ >
214
+ <PanelLeftClose className="h-4 w-4" aria-hidden />
215
+ </button>
216
+ </div>
217
+ </div>
218
+ )
219
+ }
@@ -0,0 +1,117 @@
1
+ 'use client'
2
+
3
+ import { scoreBand } from '@prenta/core/seo/page-audit'
4
+
5
+ export type RailTabId = 'sections' | 'seo' | 'page'
6
+
7
+ export interface RailTab {
8
+ id: RailTabId
9
+ label: string
10
+ /** Panel title shown in the header below the strip. */
11
+ title: string
12
+ /** Count pill (Sections). */
13
+ count?: number
14
+ /**
15
+ * Score for the dot, banded by the page audit's own scale.
16
+ *
17
+ * `undefined` — not known yet; no dot is shown rather than a misleading one.
18
+ * `null` — deliberately not rated (a gate is off, e.g. the page is
19
+ * noindexed). Renders neutral, never as a low score.
20
+ */
21
+ score?: number | null
22
+ }
23
+
24
+ export interface EditorRailTabsProps {
25
+ tabs: RailTab[]
26
+ active: RailTabId
27
+ onChange: (id: RailTabId) => void
28
+ }
29
+
30
+ /**
31
+ * Three equal tabs across the top of the rail.
32
+ *
33
+ * Each tab carries a status glyph so the rail reads at a glance without being
34
+ * opened: a count for Sections, a band-coloured dot for SEO. The dot is
35
+ * accompanied by text in the accessible name — colour alone is not a signal
36
+ * for anyone who cannot distinguish these hues.
37
+ */
38
+ export function EditorRailTabs({ tabs, active, onChange }: EditorRailTabsProps) {
39
+ return (
40
+ <div role="tablist" aria-label="Editor panels" className="border-border flex border-b">
41
+ {tabs.map((tab) => {
42
+ const selected = tab.id === active
43
+ // Bands with `scoreBand` from the audit itself, NOT `gradeFromScore`.
44
+ // The two disagree between 55 and 59 — a page scoring 57 read "fair"
45
+ // in the rings and "poor" on the tab summarising them, which is the
46
+ // same class of disagreement this dot was rewired to eliminate.
47
+ const band = typeof tab.score === 'number' ? scoreBand(tab.score) : null
48
+ const unrated = tab.score === null
49
+
50
+ return (
51
+ <button
52
+ key={tab.id}
53
+ type="button"
54
+ role="tab"
55
+ id={`rail-tab-${tab.id}`}
56
+ aria-selected={selected}
57
+ aria-controls={`rail-panel-${tab.id}`}
58
+ tabIndex={selected ? 0 : -1}
59
+ onClick={() => onChange(tab.id)}
60
+ onKeyDown={(event) => {
61
+ // Arrow keys move between tabs — expected of a tablist, and the
62
+ // only way to reach a tab whose tabIndex is -1.
63
+ const index = tabs.findIndex((t) => t.id === active)
64
+ if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
65
+ event.preventDefault()
66
+ const delta = event.key === 'ArrowRight' ? 1 : -1
67
+ const next = tabs[(index + delta + tabs.length) % tabs.length]
68
+ if (next) {
69
+ onChange(next.id)
70
+ document.getElementById(`rail-tab-${next.id}`)?.focus()
71
+ }
72
+ }
73
+ }}
74
+ className={`flex flex-1 items-center justify-center gap-1.5 border-b-2 pt-2.5 pb-2 text-[12.5px] transition-colors ${
75
+ selected
76
+ ? 'border-brand text-brand font-semibold'
77
+ : 'text-muted-foreground hover:text-foreground border-transparent font-medium'
78
+ }`}
79
+ >
80
+ {tab.label}
81
+
82
+ {tab.count !== undefined && (
83
+ <span
84
+ className={`rounded-full px-1.5 text-[10.5px] font-semibold tabular-nums ${
85
+ selected ? 'bg-accent text-accent-foreground' : 'bg-muted text-muted-foreground'
86
+ }`}
87
+ >
88
+ {tab.count}
89
+ </span>
90
+ )}
91
+
92
+ {(band || unrated) && (
93
+ <>
94
+ <span
95
+ className={`h-[7px] w-[7px] shrink-0 rounded-full ${
96
+ band === 'good'
97
+ ? 'bg-success'
98
+ : band === 'fair'
99
+ ? 'bg-warning'
100
+ : band === 'poor'
101
+ ? 'bg-destructive'
102
+ : 'bg-muted-foreground/40'
103
+ }`}
104
+ aria-hidden
105
+ />
106
+ {/* Colour alone cannot carry the score. */}
107
+ <span className="sr-only">
108
+ {unrated ? 'not rated' : `score ${tab.score} of 100`}
109
+ </span>
110
+ </>
111
+ )}
112
+ </button>
113
+ )
114
+ })}
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1,223 @@
1
+ 'use client'
2
+
3
+ import { useState, type ReactNode } from 'react'
4
+ import { Check, ImageIcon, Trash2 } from 'lucide-react'
5
+ import { useApiData } from '../lib/useApiData.js'
6
+ import {
7
+ CROP_RATIO,
8
+ assetWash,
9
+ displayName,
10
+ formatBadge,
11
+ ratioLabel,
12
+ type MediaCrop,
13
+ } from '../lib/media-format.js'
14
+
15
+ interface MediaItem {
16
+ id: string
17
+ filename: string
18
+ storageKey: string
19
+ url?: string
20
+ altTag?: string
21
+ mimeType: string
22
+ width?: number
23
+ height?: number
24
+ }
25
+
26
+ export interface MediaBlockProps {
27
+ /** Current asset URL, or empty for the empty state. */
28
+ value: string
29
+ onChange: (url: string, item?: MediaItem) => void
30
+ /** The crop the template renders this image at. */
31
+ crop?: MediaCrop
32
+ /** Rows rendered between the frame and the toolbar (the social preview). */
33
+ children?: ReactNode
34
+ disabled?: boolean
35
+ /** Accessible label, e.g. "Hero image". */
36
+ label?: string
37
+ }
38
+
39
+ /**
40
+ * One component for every image — section slots and the social/OG card alike.
41
+ *
42
+ * ── The frame shows the TEMPLATE's crop, not the asset's ─────────────────
43
+ *
44
+ * A section image renders at 16:10 and a social card at 1.91:1 regardless of
45
+ * what was uploaded. Framing the asset's own ratio would tell an editor their
46
+ * portrait photo looks fine and then crop its subject out on the page. What
47
+ * the asset actually is gets reported in the toolbar instead, as a ratio label
48
+ * beside its filename, so both facts are visible and neither is implied.
49
+ *
50
+ * ── The toolbar is always visible ────────────────────────────────────────
51
+ *
52
+ * Never hover-only. Replace and Remove are the two things an editor comes here
53
+ * to do, and hiding them behind a hover makes them undiscoverable on touch and
54
+ * invisible to anyone scanning the panel.
55
+ *
56
+ * ── The picker opens inside the shell ────────────────────────────────────
57
+ *
58
+ * Not a modal. Choosing an image is a comparison against the slot it is going
59
+ * into, and a dialog covering the slot removes the only context that matters.
60
+ */
61
+ export function MediaBlock({
62
+ value,
63
+ onChange,
64
+ crop = 'section',
65
+ children,
66
+ disabled = false,
67
+ label = 'Image',
68
+ }: MediaBlockProps) {
69
+ const [picking, setPicking] = useState(false)
70
+ const { data } = useApiData<{ items?: MediaItem[] }>(picking ? '/media?pageSize=50' : null)
71
+ const items = (Array.isArray(data) ? data : (data?.items ?? [])).filter((i: MediaItem) =>
72
+ i.mimeType?.startsWith('image/'),
73
+ )
74
+
75
+ const ratio = CROP_RATIO[crop]
76
+ const name = value ? displayName(value) : ''
77
+
78
+ return (
79
+ <div className="border-border bg-card overflow-hidden rounded-[10px] border">
80
+ {value ? (
81
+ <div
82
+ className="w-full bg-cover bg-center"
83
+ style={{ aspectRatio: ratio, backgroundImage: assetWash(name) }}
84
+ >
85
+ <img
86
+ src={value}
87
+ alt=""
88
+ className="h-full w-full object-cover"
89
+ style={{ aspectRatio: ratio }}
90
+ />
91
+ </div>
92
+ ) : (
93
+ <button
94
+ type="button"
95
+ onClick={() => setPicking(true)}
96
+ disabled={disabled}
97
+ aria-label={`Choose ${label.toLowerCase()}`}
98
+ className="bg-muted hover:bg-accent group flex w-full items-center justify-center p-3 transition-colors disabled:opacity-50"
99
+ style={{ aspectRatio: ratio }}
100
+ >
101
+ <span className="border-border group-hover:border-primary group-hover:text-primary text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-1.5 rounded-lg border-[1.5px] border-dashed transition-colors">
102
+ <ImageIcon size={22} aria-hidden />
103
+ <span className="text-foreground text-[12.5px] font-semibold">No image selected</span>
104
+ <span className="text-[10.5px]">Choose one from the library</span>
105
+ </span>
106
+ </button>
107
+ )}
108
+
109
+ {/* The social block puts its preview rows here — between the frame and
110
+ the toolbar — so the share card and its upload target read as one
111
+ object rather than two stacked controls. */}
112
+ {children}
113
+
114
+ <div className="border-border flex items-center gap-2 border-t px-2.5 py-2">
115
+ {value ? (
116
+ <>
117
+ <span
118
+ className="text-foreground min-w-0 flex-1 truncate text-[11.5px] font-semibold"
119
+ title={name}
120
+ >
121
+ {name}
122
+ </span>
123
+ <span className="text-muted-foreground shrink-0 text-[10.5px] tabular-nums">
124
+ {formatBadge(value)}
125
+ </span>
126
+ <button
127
+ type="button"
128
+ onClick={() => setPicking((v) => !v)}
129
+ disabled={disabled}
130
+ className="border-border hover:bg-accent shrink-0 rounded-md border px-2 py-1 text-[11px] font-medium disabled:opacity-50"
131
+ >
132
+ Replace
133
+ </button>
134
+ <button
135
+ type="button"
136
+ onClick={() => onChange('')}
137
+ disabled={disabled}
138
+ aria-label={`Remove ${label.toLowerCase()}`}
139
+ className="text-muted-foreground hover:text-destructive shrink-0 rounded p-1 disabled:opacity-50"
140
+ >
141
+ <Trash2 size={13} aria-hidden />
142
+ </button>
143
+ </>
144
+ ) : (
145
+ <>
146
+ <span className="text-muted-foreground min-w-0 flex-1 text-[11.5px]">
147
+ No image selected
148
+ </span>
149
+ <button
150
+ type="button"
151
+ onClick={() => setPicking((v) => !v)}
152
+ disabled={disabled}
153
+ className="border-border hover:bg-accent shrink-0 rounded-md border px-2 py-1 text-[11px] font-medium disabled:opacity-50"
154
+ >
155
+ Choose
156
+ </button>
157
+ </>
158
+ )}
159
+ </div>
160
+
161
+ {picking && (
162
+ <div className="border-border bg-muted max-h-[262px] overflow-y-auto border-t p-2">
163
+ {items.length === 0 ? (
164
+ <p className="text-muted-foreground px-1 py-3 text-center text-[11px]">
165
+ No images in the library yet.
166
+ </p>
167
+ ) : (
168
+ <ul className="grid grid-cols-2 gap-2">
169
+ {items.map((item) => {
170
+ const url = item.url || item.storageKey
171
+ const selected = url === value
172
+ const itemRatio = ratioLabel(item.width, item.height)
173
+ return (
174
+ <li key={item.id}>
175
+ <button
176
+ type="button"
177
+ onClick={() => {
178
+ onChange(url, item)
179
+ setPicking(false)
180
+ }}
181
+ aria-current={selected || undefined}
182
+ className={`border-border bg-card w-full overflow-hidden rounded-lg border text-left transition-colors ${
183
+ selected ? 'border-primary' : 'hover:border-ring'
184
+ }`}
185
+ >
186
+ <span
187
+ className="relative block w-full"
188
+ style={{
189
+ aspectRatio: '16 / 10',
190
+ backgroundImage: assetWash(item.filename),
191
+ }}
192
+ >
193
+ {url && <img src={url} alt="" className="h-full w-full object-cover" />}
194
+ <span className="absolute top-1 left-1 rounded-[5px] bg-white/80 px-1.5 py-px text-[9.5px] font-extrabold tracking-wide text-zinc-800">
195
+ {formatBadge(item.filename, item.mimeType)}
196
+ </span>
197
+ {selected && (
198
+ <span className="bg-primary text-primary-foreground absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full">
199
+ <Check size={10} aria-hidden />
200
+ </span>
201
+ )}
202
+ </span>
203
+ <span className="block px-1.5 py-1">
204
+ <span className="text-foreground block truncate text-[10px] font-semibold">
205
+ {item.filename}
206
+ </span>
207
+ <span className="text-muted-foreground block text-[9px] tabular-nums">
208
+ {item.width && item.height
209
+ ? `${item.width} × ${item.height}${itemRatio ? ` · ${itemRatio}` : ''}`
210
+ : 'Dimensions unknown'}
211
+ </span>
212
+ </span>
213
+ </button>
214
+ </li>
215
+ )
216
+ })}
217
+ </ul>
218
+ )}
219
+ </div>
220
+ )}
221
+ </div>
222
+ )
223
+ }
@@ -0,0 +1,125 @@
1
+ 'use client'
2
+
3
+ import { AlertTriangle } from 'lucide-react'
4
+ import { BREAKPOINTS, getBreakpoint, type BreakpointId } from '../lib/breakpoints.js'
5
+ import type { ReflowGuardResult } from '../hooks/useReflowGuard.js'
6
+
7
+ export interface ReflowMeterProps {
8
+ guard: ReflowGuardResult
9
+ /** Character count of the current value. */
10
+ length: number
11
+ /** Breakpoint the canvas is currently showing — marked in the row of dots. */
12
+ device: BreakpointId
13
+ /** Switch the canvas to a breakpoint so the editor can look at the damage. */
14
+ onProof: (id: BreakpointId) => void
15
+ }
16
+
17
+ const DOT_TONE: Record<string, string> = {
18
+ same: 'bg-success/12 text-success',
19
+ grew: 'bg-warning/15 text-warning',
20
+ 'grew-badly': 'bg-destructive/12 text-destructive',
21
+ }
22
+
23
+ /**
24
+ * Per-field reflow readout: character count, line count where you're looking,
25
+ * one dot per breakpoint, and a proof prompt when wrapping changed.
26
+ *
27
+ * ── Why the dots are buttons ──────────────────────────────────────────────
28
+ *
29
+ * The guard's whole value is that it reports damage at a width you are NOT
30
+ * looking at. Telling an editor "this now runs three lines on mobile" while
31
+ * making them hunt for the breakpoint switcher wastes the finding. Each dot
32
+ * takes the canvas there.
33
+ *
34
+ * Colour is never the only signal: every dot carries its breakpoint's initials
35
+ * and an accessible name stating the line count and what it was published at,
36
+ * and the alert below repeats the finding in words.
37
+ */
38
+ export function ReflowMeter({ guard, length, device, onProof }: ReflowMeterProps) {
39
+ const here = guard.rows.find((r) => r.id === device)
40
+ const worst = guard.worst ? guard.rows.find((r) => r.id === guard.worst) : undefined
41
+
42
+ // Named breakpoints while the list is short enough to read; initials once it
43
+ // isn't; "every breakpoint" when it's all of them, which is shorter and is
44
+ // also the more useful statement.
45
+ const grown = guard.grown
46
+ const scope =
47
+ grown.length === BREAKPOINTS.length
48
+ ? 'every breakpoint'
49
+ : grown.length > 3
50
+ ? grown.map((r) => getBreakpoint(r.id).abbr).join(', ')
51
+ : grown.map((r) => getBreakpoint(r.id).name).join(', ')
52
+
53
+ return (
54
+ <>
55
+ <div className="mt-1.5 flex items-center gap-2">
56
+ <span className="text-muted-foreground text-[11px] whitespace-nowrap tabular-nums">
57
+ <b className="text-foreground/80 font-semibold">{length}</b> chars
58
+ {guard.delta !== 0 && (
59
+ <span className={guard.delta > 0 ? 'text-warning font-bold' : ''}>
60
+ {' '}
61
+ ({guard.delta > 0 ? '+' : ''}
62
+ {guard.delta})
63
+ </span>
64
+ )}
65
+ {here && (
66
+ <>
67
+ {' · '}
68
+ <b className="text-foreground/80 font-semibold">{here.lines}</b> line
69
+ {here.lines === 1 ? '' : 's'} here
70
+ </>
71
+ )}
72
+ </span>
73
+
74
+ {guard.rows.length > 0 && (
75
+ <div className="ml-auto flex gap-0.75">
76
+ {guard.rows.map((row) => {
77
+ const bp = getBreakpoint(row.id)
78
+ return (
79
+ <button
80
+ key={row.id}
81
+ type="button"
82
+ onClick={() => onProof(row.id)}
83
+ className={`flex h-4.25 w-4.25 items-center justify-center rounded-[5px] text-[8px] font-bold transition-transform hover:-translate-y-px ${
84
+ DOT_TONE[row.state] ?? 'bg-muted text-muted-foreground'
85
+ } ${device === row.id ? 'outline-primary outline-[1.5px] outline-offset-1' : ''}`}
86
+ title={`${bp.name} — ${row.lines} line${row.lines === 1 ? '' : 's'}${
87
+ row.diff > 0 ? ` (published: ${row.was}) — wrapping changed` : ''
88
+ }. Click to proof.`}
89
+ >
90
+ <span aria-hidden>{bp.abbr}</span>
91
+ {/* Leads with the breakpoint and its finding, not with the
92
+ action. The alert below carries a "Proof on ‹bp›" button,
93
+ and two controls with the same accessible name in one
94
+ region are indistinguishable to a screen reader. */}
95
+ <span className="sr-only">
96
+ {bp.name}: {row.lines} line{row.lines === 1 ? '' : 's'}
97
+ {row.diff > 0 ? `, was ${row.was} when published` : ''} — proof here
98
+ </span>
99
+ </button>
100
+ )
101
+ })}
102
+ </div>
103
+ )}
104
+ </div>
105
+
106
+ {worst && (
107
+ <div className="bg-warning/10 border-warning/30 mt-1.5 grid grid-cols-[auto_1fr] gap-x-2.5 gap-y-1 rounded-lg border p-2.5">
108
+ <AlertTriangle size={14} className="text-warning mt-px shrink-0" aria-hidden />
109
+ <p className="text-[11.5px] leading-normal">
110
+ Wrapping changed on <b className="text-warning font-bold">{scope}</b> —{' '}
111
+ {getBreakpoint(worst.id).name} now runs {worst.lines} lines instead of {worst.was}.
112
+ Proof it before publishing.
113
+ </p>
114
+ <button
115
+ type="button"
116
+ onClick={() => onProof(worst.id)}
117
+ className="border-warning text-warning hover:bg-warning/15 col-start-2 mt-0.5 justify-self-start rounded-md border px-2.5 py-1 text-[11px] font-semibold whitespace-nowrap transition-colors"
118
+ >
119
+ Proof on {getBreakpoint(worst.id).name}
120
+ </button>
121
+ </div>
122
+ )}
123
+ </>
124
+ )
125
+ }