@designcrowd/library.brand-page 6.2.11 → 6.2.12

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 (576) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-DDcraD5S.js → CreatedOnBrandCrowd-de-de-BtVKllNQ.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-DtOeaisf.js → CreatedOnBrandCrowd-en-us-CX5V0v8u.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-BZpmJPew.js → CreatedOnBrandCrowd-es-es-CbA02PCL.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-CqRwC4e7.js → CreatedOnBrandCrowd-fr-ca-BQAKruBF.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-CqRwC4e7.js → CreatedOnBrandCrowd-fr-fr-BQAKruBF.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-DId9Dfjh.js → CreatedOnBrandCrowd-pt-br-Yk_yzB3s.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-DId9Dfjh.js → CreatedOnBrandCrowd-pt-pt-Yk_yzB3s.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-BTDfmOcm.js → CreatedOnDcom-de-de-DJZQbPSD.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-CeAu9Ayx.js → CreatedOnDcom-en-us-BJvNAa4l.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-WCv0K41H.js → CreatedOnDcom-es-es-CUwB1FRY.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-BlfevQDY.js → CreatedOnDcom-fr-ca-CK54U4oh.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-Cvt7MHPZ.js → CreatedOnDcom-fr-fr-DuUNBGrM.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-DipZuylv.js → CreatedOnDcom-pt-br-BfODpFNV.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-BCw4lcKN.js → CreatedOnDcom-pt-pt-Cf6u2GCg.js} +1 -1
  15. package/lib/src/brand-page/__fixtures__/production-text-style-resolver.fixture.d.ts +16 -0
  16. package/lib/src/brand-page/__fixtures__/production-text-style-resolver.fixture.js +35 -0
  17. package/lib/src/brand-page/components/contact-form/ContactForm.mixin.d.ts +3 -0
  18. package/lib/src/brand-page/components/contact-form/ContactForm.mixin.js +19 -13
  19. package/lib/src/brand-page/components/mailing-list/MailingList.mixin.d.ts +3 -1
  20. package/lib/src/brand-page/components/mailing-list/MailingList.mixin.js +17 -2
  21. package/lib/src/brand-page/components/navigation/button-carousel/ButtonCarouselNavigation.mixin.d.ts +11 -3
  22. package/lib/src/brand-page/components/navigation/button-carousel/ButtonCarouselNavigation.mixin.js +35 -1
  23. package/lib/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.d.ts +8 -2
  24. package/lib/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.js +27 -3
  25. package/lib/src/brand-page/components/page/Page.mixin.d.ts +5 -2
  26. package/lib/src/brand-page/components/page/Page.mixin.js +33 -27
  27. package/lib/src/brand-page/components/page-footer/PageFooter.mixin.d.ts +7 -2
  28. package/lib/src/brand-page/components/page-footer/PageFooter.mixin.js +21 -1
  29. package/lib/src/brand-page/components/payment/Payment.mixin.d.ts +5 -2
  30. package/lib/src/brand-page/components/payment/Payment.mixin.js +22 -2
  31. package/lib/src/brand-page/components/payment/support-me/SupportMe.mixin.d.ts +6 -2
  32. package/lib/src/brand-page/components/payment/support-me/SupportMe.mixin.js +32 -1
  33. package/lib/src/brand-page/components/social-links/SocialLinks.stories.js +0 -3
  34. package/lib/src/brand-page/components/testimonial-section/__fixtures__/data.js +0 -3
  35. package/lib/src/brand-page/components/text/Text.stories.js +0 -17
  36. package/lib/src/brand-page/constants/font.constants.d.ts +62 -0
  37. package/lib/src/brand-page/constants/font.constants.js +35 -0
  38. package/lib/src/brand-page/models/index.d.ts +23 -24
  39. package/lib/src/brand-page/models/index.js +20 -7
  40. package/lib/src/brand-page/models/navigation-styles.model.d.ts +1 -1
  41. package/lib/src/brand-page/models/navigation-styles.model.js +17 -8
  42. package/lib/src/brand-page/models/payment.model.d.ts +11 -6
  43. package/lib/src/brand-page/models/styles.model.d.ts +53 -7
  44. package/lib/src/brand-page/models/styles.model.js +115 -27
  45. package/lib/src/brand-page/utils/color-palette.util.d.ts +43 -23
  46. package/lib/src/brand-page/utils/color-palette.util.js +65 -27
  47. package/lib/src/brand-page/utils/color.util.d.ts +13 -9
  48. package/lib/src/brand-page/utils/color.util.js +56 -15
  49. package/lib/src/brand-page/utils/container-text-color.util.d.ts +46 -13
  50. package/lib/src/brand-page/utils/container-text-color.util.js +321 -41
  51. package/lib/src/brand-page/utils/index.d.ts +1 -0
  52. package/lib/src/brand-page/utils/index.js +1 -0
  53. package/lib/src/brand-page/utils/migration/migration-20.js +6 -2
  54. package/lib/src/brand-page/utils/migration/migration-21.js +34 -6
  55. package/lib/src/brand-page/utils/migration/migration-22.d.ts +99 -0
  56. package/lib/src/brand-page/utils/migration/migration-22.js +909 -0
  57. package/lib/src/brand-page/utils/migration.util.js +17 -0
  58. package/lib/src/brand-page/utils/model.util.d.ts +4 -0
  59. package/lib/src/brand-page/utils/model.util.js +4 -0
  60. package/lib/src/brand-page/utils/personalisation.util.js +9 -1
  61. package/lib/src/brand-page/utils/rte-role-lift.util.d.ts +72 -0
  62. package/lib/src/brand-page/utils/rte-role-lift.util.js +275 -0
  63. package/lib/src/brand-page/utils/style.util.d.ts +5 -1
  64. package/lib/src/brand-page/utils/style.util.js +34 -11
  65. package/lib/src/brand-page/utils/text-role-css.util.d.ts +19 -0
  66. package/lib/src/brand-page/utils/text-role-css.util.js +186 -0
  67. package/lib/src/brand-page/utils/text-role-reconnect.util.d.ts +90 -0
  68. package/lib/src/brand-page/utils/text-role-reconnect.util.js +434 -0
  69. package/lib/src/brand-page/utils/text-style.util.d.ts +53 -23
  70. package/lib/src/brand-page/utils/text-style.util.js +122 -22
  71. package/lib/src/brand-page/utils/view-model.util.d.ts +9 -1
  72. package/lib/src/brand-page/utils/view-model.util.js +124 -111
  73. package/lib/src/brand-page/view-models.d.ts +1 -0
  74. package/lib/src/brand-page-maker/components/action-bar/ActionBar.mixin.d.ts +4 -1
  75. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +13 -2
  76. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.js +4 -4
  77. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMakerTemplateSettings.mixin.d.ts +4 -1
  78. package/lib/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.js +0 -10
  79. package/lib/src/brand-page-maker/components/brand-page-maker/__fixtures__/content-settings-items.fixture.js +0 -1
  80. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +23 -138
  81. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.js +0 -28
  82. package/lib/src/brand-page-maker/components/brandpage-settings/ReplaceImageSettingsForBrandPageSettings.mixin.d.ts +4 -1
  83. package/lib/src/brand-page-maker/components/navigation/Navigation.mixin.d.ts +1 -1
  84. package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +5 -2
  85. package/lib/src/brand-page-maker/components/seo-settings/UrlSettings.mixin.d.ts +4 -1
  86. package/lib/src/brand-page-maker/components/social-share/SocialShare.mixin.d.ts +4 -1
  87. package/lib/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.d.ts +4 -1
  88. package/lib/src/brand-page-maker/composables/ai-chat/useAiMetadataBuilder.d.ts +1 -0
  89. package/lib/src/brand-page-maker/composables/ai-chat/useAiMetadataBuilder.js +1 -0
  90. package/lib/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.js +1 -0
  91. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/RoleTypographyFill.d.ts +16 -0
  92. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/RoleTypographyFill.js +61 -0
  93. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/RteClipboard.d.ts +11 -0
  94. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/RteClipboard.js +236 -0
  95. package/lib/src/brand-page-maker/composables/rich-text-editor/font.d.ts +2 -1
  96. package/lib/src/brand-page-maker/composables/rich-text-editor/font.js +25 -6
  97. package/lib/src/brand-page-maker/composables/rich-text-editor/formatting.d.ts +17 -4
  98. package/lib/src/brand-page-maker/composables/rich-text-editor/formatting.js +204 -17
  99. package/lib/src/brand-page-maker/composables/rich-text-editor/hyperlink.d.ts +1 -1
  100. package/lib/src/brand-page-maker/composables/rich-text-editor/hyperlink.js +29 -31
  101. package/lib/src/brand-page-maker/composables/rich-text-editor/keepMarksOnEmpty.d.ts +20 -0
  102. package/lib/src/brand-page-maker/composables/rich-text-editor/keepMarksOnEmpty.js +60 -0
  103. package/lib/src/brand-page-maker/composables/rich-text-editor/spacing.d.ts +2 -1
  104. package/lib/src/brand-page-maker/composables/rich-text-editor/spacing.js +22 -6
  105. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.d.ts +10 -4
  106. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.js +319 -275
  107. package/lib/src/brand-page-maker/constants/config-settings-constant.js +1 -5
  108. package/lib/src/brand-page-maker/convertors/text-css-style-options.convertor.js +6 -2
  109. package/lib/src/brand-page-maker/convertors/text-size-value.convertor.d.ts +1 -1
  110. package/lib/src/brand-page-maker/convertors/text-size-value.convertor.js +3 -1
  111. package/lib/src/brand-page-maker/default-getters/payment-text-and-border-color.default-getter.js +7 -14
  112. package/lib/src/brand-page-maker/default-getters/text-color.default-getter.js +6 -13
  113. package/lib/src/brand-page-maker/models/content-settings.d.ts +8 -0
  114. package/lib/src/brand-page-maker/models/content-settings.js +112 -81
  115. package/lib/src/brand-page-maker/stores/admin-logo-settings/admin-logo-settings-store-utils.d.ts +2 -2
  116. package/lib/src/brand-page-maker/stores/admin-logo-settings/admin-logo-settings-store-utils.js +5 -2
  117. package/lib/src/brand-page-maker/stores/admin-logo-settings/admin-logo-settings-store.js +4 -3
  118. package/lib/src/brand-page-maker/stores/ai-checkpoint/ai-checkpoint-store.d.ts +1257 -117
  119. package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.d.ts +6 -1
  120. package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +17 -9
  121. package/lib/src/brand-page-maker/stores/history/history-store.d.ts +59 -5
  122. package/lib/src/brand-page-maker/stores/history/history-store.js +22 -2
  123. package/lib/src/brand-page-maker/utils/apply-color-strategy.js +26 -8
  124. package/lib/src/brand-page-maker/utils/index.d.ts +2 -1
  125. package/lib/src/brand-page-maker/utils/index.js +2 -1
  126. package/lib/src/brand-page-maker/utils/rich-text-editor.util.d.ts +117 -15
  127. package/lib/src/brand-page-maker/utils/rich-text-editor.util.js +435 -281
  128. package/lib/src/brand-page-maker/utils/rte-authoring.util.d.ts +4 -0
  129. package/lib/src/brand-page-maker/utils/rte-authoring.util.js +101 -0
  130. package/lib/src/brand-page-maker/utils/rte-clipboard.util.d.ts +126 -0
  131. package/lib/src/brand-page-maker/utils/rte-clipboard.util.js +307 -0
  132. package/lib/src/brand-page-maker/utils/rte-sanitize.util.js +13 -3
  133. package/lib/src/{bundled-translations-zfiA5Tsw.js → bundled-translations-yGrvPE5i.js} +17 -17
  134. package/lib/src/{bundled-translations.de-DE-ChsdSyAP.js → bundled-translations.de-DE-L4Znf8Hs.js} +17 -17
  135. package/lib/src/{bundled-translations.es-ES-DflsWQK0.js → bundled-translations.es-ES-D6iticrK.js} +15 -15
  136. package/lib/src/{bundled-translations.fr-CA-fpDRuZ52.js → bundled-translations.fr-CA-BgAVE-9k.js} +25 -25
  137. package/lib/src/{bundled-translations.fr-FR-Bub73Zt-.js → bundled-translations.fr-FR-B8GbyqxQ.js} +17 -17
  138. package/lib/src/{bundled-translations.pt-BR-BlnWE9TB.js → bundled-translations.pt-BR-DjPU3Db7.js} +25 -25
  139. package/lib/src/{bundled-translations.pt-PT-DmzXlnaS.js → bundled-translations.pt-PT-BaepaDpr.js} +23 -23
  140. package/lib/src/bundles/bundled-translations.de-DE.json +21 -2
  141. package/lib/src/bundles/bundled-translations.es-ES.json +21 -2
  142. package/lib/src/bundles/bundled-translations.fr-CA.json +21 -2
  143. package/lib/src/bundles/bundled-translations.fr-FR.json +21 -2
  144. package/lib/src/bundles/bundled-translations.json +21 -2
  145. package/lib/src/bundles/bundled-translations.pt-BR.json +21 -2
  146. package/lib/src/bundles/bundled-translations.pt-PT.json +21 -2
  147. package/lib/src/clients/ai-assistant-client.d.ts +1 -0
  148. package/lib/src/composables/useTranslate.d.ts +1 -1
  149. package/lib/src/composables/useTranslate.js +1 -1
  150. package/lib/src/css/library.brand-page-brandCrowd.min.css +303 -207
  151. package/lib/src/css/library.brand-page-brandPage.min.css +303 -207
  152. package/lib/src/css/library.brand-page-designCom.min.css +303 -207
  153. package/lib/src/css/library.brand-page-preview.min.css +303 -207
  154. package/lib/src/index.d.ts +2 -1
  155. package/lib/src/index.mjs +195 -191
  156. package/lib/src/{lib-1AqtEmvQ.js → lib-kGRlD3_P.js} +36855 -33893
  157. package/lib/src/lib.js +2 -1
  158. package/lib/src/metadata-builder/builders/footer-builder.js +4 -5
  159. package/lib/src/metadata-builder/builders/global-role-propagation.d.ts +58 -0
  160. package/lib/src/metadata-builder/builders/global-role-propagation.js +246 -0
  161. package/lib/src/metadata-builder/builders/global-styles-builder.d.ts +1 -0
  162. package/lib/src/metadata-builder/builders/global-styles-builder.js +116 -45
  163. package/lib/src/metadata-builder/builders/header-builder.js +13 -4
  164. package/lib/src/metadata-builder/builders/section-builders/about-section-builder.js +3 -3
  165. package/lib/src/metadata-builder/builders/section-builders/banner-section-builder.js +3 -3
  166. package/lib/src/metadata-builder/builders/section-builders/booking-section-builder.js +3 -3
  167. package/lib/src/metadata-builder/builders/section-builders/contact-form-builder.js +4 -4
  168. package/lib/src/metadata-builder/builders/section-builders/images-section-builder.js +3 -3
  169. package/lib/src/metadata-builder/builders/section-builders/mailing-list-builder.js +2 -2
  170. package/lib/src/metadata-builder/builders/section-builders/mailing-list-section-builder.js +2 -2
  171. package/lib/src/metadata-builder/builders/section-builders/profile-section-builder.js +4 -4
  172. package/lib/src/metadata-builder/builders/section-builders/shapes-section-builder.js +3 -3
  173. package/lib/src/metadata-builder/builders/section-builders/social-links-builder.js +8 -2
  174. package/lib/src/metadata-builder/builders/section-builders/testimonial-section-builder.js +2 -2
  175. package/lib/src/metadata-builder/builders/section-builders/text-section-builder.js +60 -29
  176. package/lib/src/metadata-builder/builders/section-field-maps.js +1 -0
  177. package/lib/src/metadata-builder/builders/section-field-utils.d.ts +48 -19
  178. package/lib/src/metadata-builder/builders/section-field-utils.js +128 -52
  179. package/lib/src/metadata-builder/builders/section-rules/global-styles.rules.d.ts +4 -2
  180. package/lib/src/metadata-builder/builders/section-rules/global-styles.rules.js +28 -12
  181. package/lib/src/metadata-builder/builders/section-rules/social-links.rules.js +5 -1
  182. package/lib/src/metadata-builder/helpers/rte.helpers.d.ts +1 -1
  183. package/lib/src/metadata-builder/helpers/rte.helpers.js +97 -40
  184. package/lib/src/metadata-builder/metadata-executor.js +23 -1
  185. package/lib/src/metadata-builder/models.d.ts +2 -0
  186. package/lib/src/metadata-builder/parsers/ai-toolcall-parser.d.ts +18 -19
  187. package/lib/src/metadata-builder/parsers/section-tool-arguments.d.ts +18 -1
  188. package/lib/src/metadata-builder/sanitizers/rte-schema-check.js +1 -1
  189. package/lib/src/metadata-builder/sanitizers/sanitizer-rules.d.ts +0 -1
  190. package/lib/src/metadata-builder/sanitizers/sanitizer-rules.js +5 -8
  191. package/lib/src/metadata-builder/sanitizers/section-args-validator.d.ts +7 -16
  192. package/lib/src/metadata-builder/sanitizers/section-args-validator.js +95 -22
  193. package/lib/src/shared/color-palette/section-color-matches.js +9 -25
  194. package/lib/src/shared/color-strategies/color-strategies.d.ts +15 -0
  195. package/lib/src/shared/color-strategies/color-strategies.js +31 -0
  196. package/lib/src/shared/color-strategies/rte-color.d.ts +8 -0
  197. package/lib/src/shared/color-strategies/rte-color.js +16 -0
  198. package/lib/src/shared/color-strategies/rte-field-pairs.d.ts +23 -0
  199. package/lib/src/shared/color-strategies/rte-field-pairs.js +28 -0
  200. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +9 -135
  201. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.js +1 -27
  202. package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +12 -2
  203. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItem.mixin.d.ts +7 -0
  204. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +75 -6
  205. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.js +18 -7
  206. package/lib/src/shared/content-settings/components/focused-content-item-settings/FocusedContentItemSettings.mixin.d.ts +6 -1
  207. package/lib/src/shared/content-settings/components/focused-content-item-settings/FocusedContentItemSettings.mixin.js +0 -7
  208. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +1 -0
  209. package/lib/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.js +8 -4
  210. package/lib/src/shared/content-settings/components/text-style-field/TextStyleField.mixin.d.ts +1255 -26
  211. package/lib/src/shared/content-settings/components/text-style-field/TextStyleField.mixin.js +203 -59
  212. package/lib/src/shared/content-settings/store/content-settings-store.d.ts +6 -0
  213. package/lib/src/shared/content-settings/store/content-settings-store.js +105 -8
  214. package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +5 -1
  215. package/lib/src/shared/navigation-style-settings/store/index.d.ts +7 -3
  216. package/lib/src/shared/navigation-style-settings/store/index.js +40 -15
  217. package/lib/src/shared/page-settings/components/delete-page-modal/DeletePageModal.mixin.d.ts +1 -1
  218. package/lib/src/shared/page-settings/components/new-page-modal/NewPageModal.mixin.d.ts +1 -0
  219. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +17 -5
  220. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +70 -13
  221. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.js +10 -2
  222. package/lib/src/shared/page-settings/store/page-settings-store.d.ts +2 -1
  223. package/lib/src/shared/page-settings/store/page-settings-store.js +17 -0
  224. package/lib/src/shared/publish-brand-page-with-state/PublishBrandPageModalWithState.mixin.d.ts +4 -1
  225. package/lib/src/shared/rte-extensions/AutoLink.d.ts +17 -0
  226. package/lib/src/{brand-page-maker/composables/rich-text-editor/extensions → shared/rte-extensions}/AutoLink.js +67 -51
  227. package/lib/src/{brand-page-maker/composables/rich-text-editor/extensions → shared/rte-extensions}/MaxLength.js +4 -1
  228. package/lib/src/shared/rte-extensions/TextEffects.d.ts +28 -0
  229. package/lib/src/shared/rte-extensions/TextEffects.js +96 -0
  230. package/lib/src/shared/rte-extensions/ViewDomParser.d.ts +2 -0
  231. package/lib/src/shared/rte-extensions/ViewDomParser.js +21 -0
  232. package/lib/src/shared/rte-extensions.util.d.ts +25 -0
  233. package/lib/src/{brand-page-maker/composables/rich-text-editor/getRteExtensions.js → shared/rte-extensions.util.js} +62 -7
  234. package/lib/src/shared/rte-link.util.d.ts +1 -0
  235. package/lib/src/shared/rte-link.util.js +1 -0
  236. package/lib/src/shared/rte-render.util.d.ts +112 -0
  237. package/lib/src/shared/rte-render.util.js +255 -0
  238. package/lib/src/shared/rte-shape.util.d.ts +6 -0
  239. package/lib/src/shared/rte-shape.util.js +18 -0
  240. package/lib/src/shared/side-bar/components/SideBar.mixin.d.ts +2 -1
  241. package/lib/src/shared/side-bar/components/SideBarTabButton.mixin.d.ts +1 -0
  242. package/lib/src/shared/side-bar/store/side-bar-store.d.ts +2 -0
  243. package/lib/src/shared/side-bar/store/side-bar-store.js +9 -0
  244. package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +155 -15
  245. package/lib/src/shared/style-settings/components/StyleSettings.mixin.js +7 -1
  246. package/lib/src/shared/style-settings/store/style-settings-store.d.ts +1 -9
  247. package/lib/src/shared/style-settings/store/style-settings-store.js +3 -33
  248. package/lib/src/shared/style-settings/text-style-size-order.d.ts +2 -0
  249. package/lib/src/shared/style-settings/text-style-size-order.js +13 -0
  250. package/lib/src/shared/text-role-typography.util.d.ts +64 -0
  251. package/lib/src/shared/text-role-typography.util.js +183 -0
  252. package/lib/src/utils/minify-brand-page-metadata.d.ts +16 -1
  253. package/lib/src/utils/minify-brand-page-metadata.js +31 -9
  254. package/package.json +1 -1
  255. package/src/brand-page/__fixtures__/migration-22-v21/edited-brandsite-sections.v21.json +982 -0
  256. package/src/brand-page/__fixtures__/migration-22-v21/new-brandcontact-every-section.v21.json +1231 -0
  257. package/src/brand-page/__fixtures__/migration-22-v21/new-brandpage-every-section.v21.json +1231 -0
  258. package/src/brand-page/__fixtures__/migration-22-v21/new-brandsite-every-section.v21.json +1243 -0
  259. package/src/brand-page/__fixtures__/migration-22-v21/real-brandcontact.v21.json +755 -0
  260. package/src/brand-page/__fixtures__/migration-22-v21/real-brandpage-general-link.v21.json +527 -0
  261. package/src/brand-page/__fixtures__/migration-22-v21/real-brandpage-links.v21.json +755 -0
  262. package/src/brand-page/__fixtures__/migration-22-v21/real-brandpage-social-links.v21.json +514 -0
  263. package/src/brand-page/__fixtures__/migration-22-v21/real-brandsite-about.v21.json +459 -0
  264. package/src/brand-page/__fixtures__/migration-22-v21/real-brandsite-footer.v21.json +469 -0
  265. package/src/brand-page/__fixtures__/migration-22-v21/real-brandsite-header-banner.v21.json +481 -0
  266. package/src/brand-page/__fixtures__/migration-22-v21/real-brandsite-sections.v21.json +800 -0
  267. package/src/brand-page/__fixtures__/production-text-style-resolver.fixture.ts +52 -0
  268. package/src/brand-page/components/about-section/AboutSection.vue.test.ts +0 -10
  269. package/src/brand-page/components/contact-form/ContactForm.mixin.test.ts +127 -0
  270. package/src/brand-page/components/contact-form/ContactForm.mixin.ts +31 -14
  271. package/src/brand-page/components/contact-form/ContactForm.vue +26 -12
  272. package/src/brand-page/components/contact-form/ContactForm.vue.test.ts +60 -0
  273. package/src/brand-page/components/mailing-list/MailingList.mixin.test.ts +92 -0
  274. package/src/brand-page/components/mailing-list/MailingList.mixin.ts +28 -3
  275. package/src/brand-page/components/mailing-list/MailingList.vue +9 -4
  276. package/src/brand-page/components/mailing-list/MailingList.vue.test.ts +63 -0
  277. package/src/brand-page/components/navigation/button-carousel/ButtonCarouselNavigation.mixin.ts +51 -1
  278. package/src/brand-page/components/navigation/button-carousel/ButtonCarouselNavigation.test.ts +114 -0
  279. package/src/brand-page/components/navigation/button-carousel/ButtonCarouselNavigation.vue +8 -2
  280. package/src/brand-page/components/navigation/navigation-carousel/NavigationCarousel.test.ts +182 -0
  281. package/src/brand-page/components/navigation/navigation-carousel/NavigationCarousel.vue +59 -12
  282. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.mixin.ts +40 -3
  283. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.vue +15 -7
  284. package/src/brand-page/components/navigation/navigation-side-menu/NavigationSideMenu.vue.test.ts +133 -3
  285. package/src/brand-page/components/page/Page.mixin.ts +41 -34
  286. package/src/brand-page/components/page/Page.vue +17 -6
  287. package/src/brand-page/components/page/Page.vue.test.ts +91 -1
  288. package/src/brand-page/components/page-footer/PageFooter.mixin.ts +32 -2
  289. package/src/brand-page/components/page-footer/PageFooter.test.ts +79 -0
  290. package/src/brand-page/components/page-footer/PageFooter.vue +12 -4
  291. package/src/brand-page/components/payment/Payment.mixin.ts +34 -2
  292. package/src/brand-page/components/payment/Payment.test.ts +188 -0
  293. package/src/brand-page/components/payment/Payment.vue +12 -5
  294. package/src/brand-page/components/payment/RequestPaymentStripe.vue +1 -0
  295. package/src/brand-page/components/payment/support-me/SupportMe.mixin.ts +49 -2
  296. package/src/brand-page/components/payment/support-me/SupportMe.test.ts +100 -0
  297. package/src/brand-page/components/payment/support-me/SupportMe.vue +7 -1
  298. package/src/brand-page/components/payment/support-me/SupportMe.vue.test.ts +66 -0
  299. package/src/brand-page/components/save-contact/SaveContactWidget.test.ts +100 -0
  300. package/src/brand-page/components/save-contact/SaveContactWidget.vue +6 -3
  301. package/src/brand-page/components/shop/PaymentModal.vue +2 -6
  302. package/src/brand-page/components/shop/PaymentShippingAddress.vue +6 -0
  303. package/src/brand-page/components/shop/Shop.vue +15 -2
  304. package/src/brand-page/components/shop/Shop.vue.test.ts +74 -2
  305. package/src/brand-page/components/social-links/SocialLinks.stories.ts +0 -3
  306. package/src/brand-page/components/social-links/SocialLinks.vue.test.ts +0 -3
  307. package/src/brand-page/components/testimonial-section/__fixtures__/data.ts +0 -3
  308. package/src/brand-page/components/text/Text.stories.ts +0 -17
  309. package/src/brand-page/components/text-wrapper/TextWrapper.parity.test.ts +136 -0
  310. package/src/brand-page/components/text-wrapper/TextWrapper.test.ts +358 -0
  311. package/src/brand-page/components/text-wrapper/TextWrapper.vue +82 -22
  312. package/src/brand-page/constants/font.constants.ts +37 -0
  313. package/src/brand-page/models/footer-icon-color.test.ts +22 -0
  314. package/src/brand-page/models/index.ts +50 -28
  315. package/src/brand-page/models/navigation-styles.model.test.ts +77 -1
  316. package/src/brand-page/models/navigation-styles.model.ts +26 -9
  317. package/src/brand-page/models/payment.model.ts +15 -6
  318. package/src/brand-page/models/styles.model.test.ts +104 -0
  319. package/src/brand-page/models/styles.model.ts +181 -47
  320. package/src/brand-page/utils/__snapshots__/migration.util.test.ts.snap +619 -115
  321. package/src/brand-page/utils/__snapshots__/migration2.util.test.ts.snap +150 -9
  322. package/src/brand-page/utils/__snapshots__/model.util.test.ts.snap +300 -18
  323. package/src/brand-page/utils/color-palette.util.test.ts +126 -1
  324. package/src/brand-page/utils/color-palette.util.ts +92 -26
  325. package/src/brand-page/utils/color.util.test.ts +62 -29
  326. package/src/brand-page/utils/color.util.ts +77 -14
  327. package/src/brand-page/utils/container-text-color.util.test.ts +672 -26
  328. package/src/brand-page/utils/container-text-color.util.ts +472 -46
  329. package/src/brand-page/utils/index.ts +1 -0
  330. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/edited-brandsite-sections.v22.json +1191 -0
  331. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/new-brandcontact-every-section.v22.json +2026 -0
  332. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/new-brandpage-every-section.v22.json +2017 -0
  333. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/new-brandsite-every-section.v22.json +2031 -0
  334. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/real-brandcontact.v22.json +1145 -0
  335. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/real-brandpage-general-link.v22.json +645 -0
  336. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/real-brandpage-links.v22.json +1136 -0
  337. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/real-brandpage-social-links.v22.json +662 -0
  338. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/real-brandsite-about.v22.json +638 -0
  339. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/real-brandsite-footer.v22.json +610 -0
  340. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/real-brandsite-header-banner.v22.json +660 -0
  341. package/src/brand-page/utils/migration/__snapshots__/migration-22-output/real-brandsite-sections.v22.json +1132 -0
  342. package/src/brand-page/utils/migration/migration-01.ts +293 -237
  343. package/src/brand-page/utils/migration/migration-02.ts +8 -1
  344. package/src/brand-page/utils/migration/migration-20.ts +6 -3
  345. package/src/brand-page/utils/migration/migration-20_5.test.ts +5 -2
  346. package/src/brand-page/utils/migration/migration-21.test.ts +34 -0
  347. package/src/brand-page/utils/migration/migration-21.ts +46 -10
  348. package/src/brand-page/utils/migration/migration-22.fixed-input.test.ts +43 -0
  349. package/src/brand-page/utils/migration/migration-22.test.ts +4103 -0
  350. package/src/brand-page/utils/migration/migration-22.text-font-family.test.ts +72 -0
  351. package/src/brand-page/utils/migration/migration-22.ts +1336 -0
  352. package/src/brand-page/utils/migration.util.test.ts +146 -32
  353. package/src/brand-page/utils/migration.util.ts +18 -0
  354. package/src/brand-page/utils/model.util.ts +4 -0
  355. package/src/brand-page/utils/personalisation.util.test.ts +148 -3
  356. package/src/brand-page/utils/personalisation.util.ts +9 -1
  357. package/src/brand-page/utils/rte-role-lift.util.ts +409 -0
  358. package/src/brand-page/utils/style.util.test.ts +35 -2
  359. package/src/brand-page/utils/style.util.ts +45 -11
  360. package/src/brand-page/utils/text-role-css.util.test.ts +525 -0
  361. package/src/brand-page/utils/text-role-css.util.ts +321 -0
  362. package/src/brand-page/utils/text-role-reconnect.util.test.ts +604 -0
  363. package/src/brand-page/utils/text-role-reconnect.util.ts +727 -0
  364. package/src/brand-page/utils/text-style.util.test.ts +305 -0
  365. package/src/brand-page/utils/text-style.util.ts +198 -24
  366. package/src/brand-page/utils/view-model.page-data.test.ts +232 -0
  367. package/src/brand-page/utils/view-model.util.test.ts +954 -133
  368. package/src/brand-page/utils/view-model.util.ts +187 -139
  369. package/src/brand-page/view-models.ts +4 -0
  370. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.blank-site.test.ts +57 -0
  371. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.ts +9 -5
  372. package/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.ts +0 -10
  373. package/src/brand-page-maker/components/brand-page-maker/__fixtures__/content-settings-items.fixture.ts +0 -1
  374. package/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.ts +0 -44
  375. package/src/brand-page-maker/components/rich-text-editor/RichTextEditor.vue +253 -200
  376. package/src/brand-page-maker/components/shop/AddOrEditProductContainer.vue +1 -0
  377. package/src/brand-page-maker/components/template-preview/TemplatePreview.vue +1 -0
  378. package/src/brand-page-maker/composables/ai-chat/useAiMetadataBuilder.ts +2 -0
  379. package/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.ts +1 -0
  380. package/src/brand-page-maker/composables/rich-text-editor/block-typography.test.ts +519 -0
  381. package/src/brand-page-maker/composables/rich-text-editor/extensions/FontFamily.test.ts +1 -1
  382. package/src/brand-page-maker/composables/rich-text-editor/extensions/MaxLength.test.ts +26 -1
  383. package/src/brand-page-maker/composables/rich-text-editor/extensions/RoleTypographyFill.test.ts +110 -0
  384. package/src/brand-page-maker/composables/rich-text-editor/extensions/RoleTypographyFill.ts +99 -0
  385. package/src/brand-page-maker/composables/rich-text-editor/extensions/RteClipboard.ts +397 -0
  386. package/src/brand-page-maker/composables/rich-text-editor/extensions/ViewDomParser.test.ts +139 -0
  387. package/src/brand-page-maker/composables/rich-text-editor/font.ts +36 -5
  388. package/src/brand-page-maker/composables/rich-text-editor/formatting.test.ts +375 -1
  389. package/src/brand-page-maker/composables/rich-text-editor/formatting.ts +301 -17
  390. package/src/brand-page-maker/composables/rich-text-editor/hyperlink.test.ts +82 -1
  391. package/src/brand-page-maker/composables/rich-text-editor/hyperlink.ts +28 -34
  392. package/src/brand-page-maker/composables/rich-text-editor/keepMarksOnEmpty.test.ts +80 -0
  393. package/src/brand-page-maker/composables/rich-text-editor/keepMarksOnEmpty.ts +75 -0
  394. package/src/brand-page-maker/composables/rich-text-editor/spacing.ts +31 -6
  395. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.test.ts +2485 -0
  396. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +435 -337
  397. package/src/brand-page-maker/constants/config-settings-constant.ts +0 -6
  398. package/src/brand-page-maker/convertors/text-css-style-options.convertor.test.ts +43 -0
  399. package/src/brand-page-maker/convertors/text-css-style-options.convertor.ts +6 -2
  400. package/src/brand-page-maker/convertors/text-size-value.convertor.test.ts +24 -0
  401. package/src/brand-page-maker/convertors/text-size-value.convertor.ts +17 -13
  402. package/src/brand-page-maker/default-getters/payment-text-and-border-color.default-getter.ts +7 -22
  403. package/src/brand-page-maker/default-getters/text-color.default-getter.ts +5 -21
  404. package/src/brand-page-maker/models/content-settings.test.ts +708 -0
  405. package/src/brand-page-maker/models/content-settings.ts +133 -82
  406. package/src/brand-page-maker/stores/admin-logo-settings/admin-logo-settings-store-utils.test.ts +63 -1
  407. package/src/brand-page-maker/stores/admin-logo-settings/admin-logo-settings-store-utils.ts +16 -3
  408. package/src/brand-page-maker/stores/admin-logo-settings/admin-logo-settings-store.test.ts +34 -0
  409. package/src/brand-page-maker/stores/admin-logo-settings/admin-logo-settings-store.ts +4 -2
  410. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.test.ts +185 -1
  411. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +22 -34
  412. package/src/brand-page-maker/stores/history/history-store.ts +35 -2
  413. package/src/brand-page-maker/utils/__snapshots__/apply-color-strategy.golden.test.ts.snap +528 -0
  414. package/src/brand-page-maker/utils/__snapshots__/rte-render-parity.test.ts.snap +12 -0
  415. package/src/brand-page-maker/utils/apply-color-strategy.golden.test.ts +2 -0
  416. package/src/brand-page-maker/utils/apply-color-strategy.test.ts +181 -3
  417. package/src/brand-page-maker/utils/apply-color-strategy.ts +36 -4
  418. package/src/brand-page-maker/utils/index.ts +2 -1
  419. package/src/brand-page-maker/utils/rich-text-editor.util.test.ts +447 -410
  420. package/src/brand-page-maker/utils/rich-text-editor.util.ts +610 -506
  421. package/src/brand-page-maker/utils/rte-authoring.util.test.ts +244 -0
  422. package/src/brand-page-maker/utils/rte-authoring.util.ts +175 -0
  423. package/src/brand-page-maker/utils/rte-clipboard.util.test.ts +558 -0
  424. package/src/brand-page-maker/utils/rte-clipboard.util.ts +473 -0
  425. package/src/brand-page-maker/utils/rte-features.test.ts +99 -3
  426. package/src/brand-page-maker/utils/rte-render-parity.test.ts +1 -1
  427. package/src/brand-page-maker/utils/rte-render.util.test.ts +230 -0
  428. package/src/brand-page-maker/utils/rte-sanitize-json.test.ts +1 -1
  429. package/src/brand-page-maker/utils/rte-sanitize.util.ts +14 -3
  430. package/src/brand-page-maker/utils/rte-schema-version.test.ts +102 -0
  431. package/src/bundles/bundled-translations.de-DE.json +21 -2
  432. package/src/bundles/bundled-translations.es-ES.json +21 -2
  433. package/src/bundles/bundled-translations.fr-CA.json +21 -2
  434. package/src/bundles/bundled-translations.fr-FR.json +21 -2
  435. package/src/bundles/bundled-translations.json +21 -2
  436. package/src/bundles/bundled-translations.pt-BR.json +21 -2
  437. package/src/bundles/bundled-translations.pt-PT.json +21 -2
  438. package/src/clients/ai-assistant-client.ts +1 -0
  439. package/src/composables/useTranslate.ts +2 -1
  440. package/src/lib.ts +2 -2
  441. package/src/metadata-builder/builders/footer-builder.test.ts +70 -45
  442. package/src/metadata-builder/builders/footer-builder.ts +4 -8
  443. package/src/metadata-builder/builders/global-role-propagation.test.ts +907 -0
  444. package/src/metadata-builder/builders/global-role-propagation.ts +430 -0
  445. package/src/metadata-builder/builders/global-styles-builder.test.ts +1020 -0
  446. package/src/metadata-builder/builders/global-styles-builder.ts +194 -48
  447. package/src/metadata-builder/builders/header-builder.test.ts +112 -40
  448. package/src/metadata-builder/builders/header-builder.ts +18 -8
  449. package/src/metadata-builder/builders/section-builders/about-section-builder.ts +5 -3
  450. package/src/metadata-builder/builders/section-builders/banner-section-builder.ts +5 -3
  451. package/src/metadata-builder/builders/section-builders/booking-section-builder.ts +5 -3
  452. package/src/metadata-builder/builders/section-builders/contact-form-builder.ts +7 -4
  453. package/src/metadata-builder/builders/section-builders/images-section-builder.ts +5 -3
  454. package/src/metadata-builder/builders/section-builders/mailing-list-builder.ts +3 -2
  455. package/src/metadata-builder/builders/section-builders/mailing-list-section-builder.ts +3 -2
  456. package/src/metadata-builder/builders/section-builders/profile-section-builder.ts +7 -7
  457. package/src/metadata-builder/builders/section-builders/shapes-section-builder.ts +5 -3
  458. package/src/metadata-builder/builders/section-builders/social-links-builder.test.ts +2 -2
  459. package/src/metadata-builder/builders/section-builders/social-links-builder.ts +21 -10
  460. package/src/metadata-builder/builders/section-builders/support-me-builder.test.ts +10 -0
  461. package/src/metadata-builder/builders/section-builders/testimonial-section-builder.ts +3 -2
  462. package/src/metadata-builder/builders/section-builders/text-section-builder.ts +111 -61
  463. package/src/metadata-builder/builders/section-field-maps.ts +1 -0
  464. package/src/metadata-builder/builders/section-field-utils.test.ts +545 -2
  465. package/src/metadata-builder/builders/section-field-utils.ts +202 -69
  466. package/src/metadata-builder/builders/section-rules/global-styles.rules.ts +38 -13
  467. package/src/metadata-builder/builders/section-rules/social-links.rules.ts +5 -0
  468. package/src/metadata-builder/helpers/rte.helpers.test.ts +101 -0
  469. package/src/metadata-builder/helpers/rte.helpers.ts +129 -43
  470. package/src/metadata-builder/metadata-executor.test.ts +167 -0
  471. package/src/metadata-builder/metadata-executor.ts +34 -1
  472. package/src/metadata-builder/models.ts +2 -0
  473. package/src/metadata-builder/parsers/ai-toolcall-parser.ts +23 -7
  474. package/src/metadata-builder/parsers/section-tool-arguments.ts +21 -1
  475. package/src/metadata-builder/sanitizers/__tests__/section-args-validator.test.ts +215 -0
  476. package/src/metadata-builder/sanitizers/rte-schema-check.ts +1 -1
  477. package/src/metadata-builder/sanitizers/sanitizer-rules.ts +4 -8
  478. package/src/metadata-builder/sanitizers/section-args-validator.ts +136 -53
  479. package/src/shared/color-palette/section-color-matches.test.ts +15 -11
  480. package/src/shared/color-palette/section-color-matches.ts +9 -28
  481. package/src/shared/color-strategies/color-strategies.ts +35 -0
  482. package/src/shared/color-strategies/rte-color.test.ts +1 -1
  483. package/src/shared/color-strategies/rte-color.ts +19 -0
  484. package/src/shared/color-strategies/rte-field-pairs.test.ts +26 -5
  485. package/src/shared/color-strategies/rte-field-pairs.ts +33 -0
  486. package/src/shared/content-settings/components/color-matches-panel/ColorMatchesPanel.test.ts +43 -66
  487. package/src/shared/content-settings/components/color-matches-panel/ColorMatchesPanel.vue +7 -1
  488. package/src/shared/content-settings/components/content-settings/ContentSettings.mixin.ts +1 -48
  489. package/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.ts +33 -7
  490. package/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.test.ts +128 -1
  491. package/src/shared/content-settings/components/draggable-item-list/DraggableItemList.vue +1 -0
  492. package/src/shared/content-settings/components/focused-content-item-settings/FocusedContentItemSettings.mixin.ts +0 -6
  493. package/src/shared/content-settings/components/logo-selection-field/LogoSelection.vue +3 -0
  494. package/src/shared/content-settings/components/logo-size-field/LogoSizeSlider.vue +3 -0
  495. package/src/shared/content-settings/components/logo-size-field/LogoSizeSliderField.vue +1 -0
  496. package/src/shared/content-settings/components/testimonial-items-field/TestimonialItemsField.vue +1 -0
  497. package/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.test.ts +56 -3
  498. package/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.ts +11 -4
  499. package/src/shared/content-settings/components/text-style-field/TextStyleField.mixin.test.ts +335 -25
  500. package/src/shared/content-settings/components/text-style-field/TextStyleField.mixin.ts +248 -70
  501. package/src/shared/content-settings/components/text-style-field/TextStyleField.vue +113 -25
  502. package/src/shared/content-settings/components/text-style-field/i18n/text-style-field.de-DE.json +17 -10
  503. package/src/shared/content-settings/components/text-style-field/i18n/text-style-field.es-ES.json +17 -10
  504. package/src/shared/content-settings/components/text-style-field/i18n/text-style-field.fr-CA.json +17 -10
  505. package/src/shared/content-settings/components/text-style-field/i18n/text-style-field.fr-FR.json +17 -10
  506. package/src/shared/content-settings/components/text-style-field/i18n/text-style-field.json +8 -1
  507. package/src/shared/content-settings/components/text-style-field/i18n/text-style-field.pt-BR.json +17 -10
  508. package/src/shared/content-settings/components/text-style-field/i18n/text-style-field.pt-PT.json +17 -10
  509. package/src/shared/content-settings/components/text-style-role-dropdown/TextStyleRoleDropdown.test.ts +141 -0
  510. package/src/shared/content-settings/components/text-style-role-dropdown/TextStyleRoleDropdown.vue +136 -0
  511. package/src/shared/content-settings/store/content-settings-store.test.ts +491 -6
  512. package/src/shared/content-settings/store/content-settings-store.ts +159 -17
  513. package/src/shared/logo-settings/components/EditLogoSettings.vue +1 -0
  514. package/src/shared/logo-settings/components/LogoVariations.vue +1 -0
  515. package/src/shared/navigation-style-settings/components/navigation-style-settings/NavigationStyleSettings.vue +1 -0
  516. package/src/shared/navigation-style-settings/store/index.test.ts +295 -0
  517. package/src/shared/navigation-style-settings/store/index.ts +65 -15
  518. package/src/shared/page-settings/components/header-settings-panel/HeaderSettingsPanel.vue +2 -0
  519. package/src/shared/page-settings/components/page-settings/PageElement.vue +1 -0
  520. package/src/shared/page-settings/components/page-settings-section-styles/HeaderButtonSettings.test.ts +44 -4
  521. package/src/shared/page-settings/components/page-settings-section-styles/HeaderButtonSettings.vue +13 -4
  522. package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.navigation.test.ts +89 -0
  523. package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.text-style.test.ts +91 -0
  524. package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.ts +14 -2
  525. package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.vue +2 -0
  526. package/src/shared/page-settings/store/page-settings-store.text-style.test.ts +84 -0
  527. package/src/shared/page-settings/store/page-settings-store.ts +30 -1
  528. package/src/shared/rte-extensions/AutoLink.test.ts +98 -0
  529. package/src/{brand-page-maker/composables/rich-text-editor/extensions → shared/rte-extensions}/AutoLink.ts +87 -58
  530. package/src/{brand-page-maker/composables/rich-text-editor/extensions → shared/rte-extensions}/MaxLength.ts +4 -1
  531. package/src/shared/rte-extensions/TextEffects.ts +151 -0
  532. package/src/shared/rte-extensions/ViewDomParser.ts +27 -0
  533. package/src/{brand-page-maker/composables/rich-text-editor/getRteExtensions.ts → shared/rte-extensions.util.ts} +66 -9
  534. package/src/shared/rte-link.util.ts +2 -0
  535. package/src/shared/rte-render.util.ts +385 -0
  536. package/src/shared/rte-shape.util.ts +29 -0
  537. package/src/shared/side-bar/store/side-bar-store.test.ts +27 -1
  538. package/src/shared/side-bar/store/side-bar-store.ts +11 -0
  539. package/src/shared/style-settings/components/StyleSettings.mixin.ts +7 -3
  540. package/src/shared/style-settings/components/StyleSettings.test.ts +116 -0
  541. package/src/shared/style-settings/components/StyleSettings.vue +75 -25
  542. package/src/shared/style-settings/components/i18n/style-settings.de-DE.json +32 -20
  543. package/src/shared/style-settings/components/i18n/style-settings.es-ES.json +32 -20
  544. package/src/shared/style-settings/components/i18n/style-settings.fr-CA.json +32 -20
  545. package/src/shared/style-settings/components/i18n/style-settings.fr-FR.json +32 -20
  546. package/src/shared/style-settings/components/i18n/style-settings.json +13 -3
  547. package/src/shared/style-settings/components/i18n/style-settings.pt-BR.json +32 -20
  548. package/src/shared/style-settings/components/i18n/style-settings.pt-PT.json +32 -20
  549. package/src/shared/style-settings/components/text-style-options-settings-with-state/TextStyleOptionsSettingsWithState.vue +14 -34
  550. package/src/shared/style-settings/store/style-settings-store.ts +3 -49
  551. package/src/shared/style-settings/text-style-size-order.test.ts +76 -0
  552. package/src/shared/style-settings/text-style-size-order.ts +28 -0
  553. package/src/shared/template-settings/components/TemplatesSettings.vue +4 -0
  554. package/src/shared/text-role-typography.util.ts +277 -0
  555. package/src/utils/minify-brand-page-metadata.test.ts +102 -7
  556. package/src/utils/minify-brand-page-metadata.ts +62 -8
  557. package/lib/src/brand-page-maker/composables/rich-text-editor/extensions/AutoLink.d.ts +0 -2
  558. package/lib/src/brand-page-maker/composables/rich-text-editor/getRteExtensions.d.ts +0 -28
  559. package/lib/src/brand-page-maker/utils/rte-render.util.d.ts +0 -63
  560. package/lib/src/brand-page-maker/utils/rte-render.util.js +0 -106
  561. package/lib/src/brand-page-maker/utils/rte-shape.d.ts +0 -18
  562. package/lib/src/brand-page-maker/utils/rte-shape.js +0 -21
  563. package/lib/src/metadata-builder/builders/global-font-propagation.d.ts +0 -41
  564. package/lib/src/metadata-builder/builders/global-font-propagation.js +0 -146
  565. package/src/brand-page-maker/utils/rte-render.util.ts +0 -179
  566. package/src/brand-page-maker/utils/rte-shape.ts +0 -29
  567. package/src/metadata-builder/builders/global-font-propagation.test.ts +0 -401
  568. package/src/metadata-builder/builders/global-font-propagation.ts +0 -238
  569. package/src/shared/style-settings/components/StyleSettingsSection.vue +0 -8
  570. /package/lib/src/{brand-page-maker/composables/rich-text-editor/extensions → shared/rte-extensions}/FontFamily.d.ts +0 -0
  571. /package/lib/src/{brand-page-maker/composables/rich-text-editor/extensions → shared/rte-extensions}/FontFamily.js +0 -0
  572. /package/lib/src/{brand-page-maker/composables/rich-text-editor/extensions → shared/rte-extensions}/LetterSpacing.d.ts +0 -0
  573. /package/lib/src/{brand-page-maker/composables/rich-text-editor/extensions → shared/rte-extensions}/LetterSpacing.js +0 -0
  574. /package/lib/src/{brand-page-maker/composables/rich-text-editor/extensions → shared/rte-extensions}/MaxLength.d.ts +0 -0
  575. /package/src/{brand-page-maker/composables/rich-text-editor/extensions → shared/rte-extensions}/FontFamily.ts +0 -0
  576. /package/src/{brand-page-maker/composables/rich-text-editor/extensions → shared/rte-extensions}/LetterSpacing.ts +0 -0
@@ -0,0 +1,4103 @@
1
+ import { describe, it, expect, beforeAll, vi } from 'vitest';
2
+ import {
3
+ seedRoleColours,
4
+ computeLegacyTargets,
5
+ reconnectContentRoles,
6
+ stripLiftContentRte,
7
+ stripLiftRteDoc,
8
+ foldSemanticFormattingMarks,
9
+ pinLegacyRunWeight,
10
+ bakeElementColours,
11
+ clearRoleColours,
12
+ stripRteStyleColours,
13
+ convertPlainRteFields,
14
+ migration22,
15
+ } from './migration-22';
16
+ import type { MigrationData, MigrationOptions } from '../migration.util';
17
+ import {
18
+ BRAND_PAGE_TYPES,
19
+ NavigationDesktop,
20
+ PageHeaderDesktop,
21
+ PageStyles,
22
+ TextStyles,
23
+ } from '../../models';
24
+ import type { Content, HeaderButton } from '../../models';
25
+ import {
26
+ mapToPageHeaderViewModel,
27
+ mapToViewModel,
28
+ resolveSaveContactTitleStyle,
29
+ } from '../view-model.util';
30
+ import { setLocaleAsync } from '@src/composables/useTranslate';
31
+ import { HEADER_DISPLAY } from '../../constants';
32
+ import {
33
+ createInitialBannerSectionContent,
34
+ createInitialBookingSectionContent,
35
+ createInitialButtonContent,
36
+ createInitialMailingListContent,
37
+ createInitialProfileContent,
38
+ } from '@brand-page-maker/models';
39
+ import type {
40
+ BrandPageModel,
41
+ TextStyleDeclaration,
42
+ TextStyleOptions,
43
+ } from '../../models';
44
+ import type { TextStyleType } from '../../models/styles.model';
45
+ import { isTiptapDocument } from '@brand-page-maker/utils';
46
+ import { resolveTextStyle, TEXT_STYLE_CASCADE_PROPS } from '../text-style.util';
47
+ import { DETACHED_UNSET_EFFECTIVE } from '../text-role-reconnect.util';
48
+ import * as textRoleCss from '../text-role-css.util';
49
+ import * as rteRoleLift from '../rte-role-lift.util';
50
+ import { applyTextContrast } from '../container-text-color.util';
51
+ import {
52
+ PRODUCTION_BUTTON_DEFAULTS,
53
+ PRODUCTION_HEADER_BUTTON_STYLE,
54
+ PRODUCTION_PROFILE_DEFAULTS,
55
+ productionTypography,
56
+ } from '../../__fixtures__/production-text-style-resolver.fixture';
57
+
58
+ const modelWithContent = (content: Record<string, unknown>): BrandPageModel =>
59
+ ({ pages: [{ contents: [content] }] }) as unknown as BrandPageModel;
60
+
61
+ // A model carrying the role options + button/palette styles that the colour bake reads.
62
+ const modelWith = (
63
+ content: Record<string, unknown>,
64
+ options: TextStyleOptions,
65
+ ): BrandPageModel =>
66
+ ({
67
+ pages: [{ contents: [content] }],
68
+ styles: {
69
+ textStyles: { textStyleOptions: options },
70
+ buttonStyles: { style: {} },
71
+ colors: [],
72
+ },
73
+ brandPageType: 'default',
74
+ }) as unknown as BrandPageModel;
75
+
76
+ // Read/write a role's colour through the retired-from-type property.
77
+ const roleColorOf = (style: unknown): string | undefined =>
78
+ (style as Record<string, unknown>).color as string | undefined;
79
+ const setRoleColor = (style: unknown, color: string): void => {
80
+ (style as Record<string, unknown>).color = color;
81
+ };
82
+
83
+ // The colour on the first text run's textStyle mark (after the RTE bake replaces the doc).
84
+ const runMarkColor = (doc: unknown): string | undefined => {
85
+ const run = (doc as { content: { content: { marks?: unknown[] }[] }[] })
86
+ .content[0].content[0];
87
+
88
+ return (run.marks as { type: string; attrs?: { color?: string } }[])?.find(
89
+ (mark) => mark.type === 'textStyle',
90
+ )?.attrs?.color;
91
+ };
92
+
93
+ const roleType = (style: unknown): string | undefined =>
94
+ (style as { textStyleType?: string }).textStyleType;
95
+
96
+ // A minimal Tiptap doc: one paragraph of text runs, each with a textStyle mark.
97
+ const docOf = (runs: { text: string; attrs?: Record<string, unknown> }[]) => ({
98
+ type: 'doc',
99
+ content: [
100
+ {
101
+ type: 'paragraph',
102
+ content: runs.map((r) => ({
103
+ type: 'text',
104
+ text: r.text,
105
+ marks: r.attrs ? [{ type: 'textStyle', attrs: { ...r.attrs } }] : [],
106
+ })),
107
+ },
108
+ ],
109
+ });
110
+
111
+ const attrsOf = (doc: ReturnType<typeof docOf>, i: number) =>
112
+ (doc.content[0].content[i].marks[0] as { attrs?: Record<string, unknown> })
113
+ ?.attrs;
114
+
115
+ // The seven typography properties a field renders with. An unset one renders its inherited
116
+ // default; family and size left unset inherit from the page, so they stay undefined here.
117
+ const lookOf = (
118
+ style: object | undefined,
119
+ role: object | undefined,
120
+ ): Record<string, string | undefined> => {
121
+ const resolved = resolveTextStyle(
122
+ style as TextStyleDeclaration | undefined,
123
+ role,
124
+ ) as Record<string, string | undefined>;
125
+
126
+ return Object.fromEntries(
127
+ TEXT_STYLE_CASCADE_PROPS.map((prop) => [
128
+ prop,
129
+ resolved[prop] || DETACHED_UNSET_EFFECTIVE[prop],
130
+ ]),
131
+ );
132
+ };
133
+
134
+ // Every property the field rendered before renders the same after; one it left to the page
135
+ // (a field on no role) is not compared.
136
+ const expectLookKept = (
137
+ after: object | undefined,
138
+ afterRole: object | undefined,
139
+ before: object | undefined,
140
+ beforeRole: object | undefined,
141
+ ): void => {
142
+ const look = lookOf(after, afterRole);
143
+
144
+ for (const [prop, value] of Object.entries(lookOf(before, beforeRole))) {
145
+ if (value !== undefined) expect(look[prop], prop).toBe(value);
146
+ }
147
+ };
148
+
149
+ const roleStyleOf = (
150
+ options: TextStyleOptions,
151
+ type: unknown,
152
+ ): TextStyleDeclaration | undefined =>
153
+ options[type as TextStyleType]?.style as TextStyleDeclaration | undefined;
154
+
155
+ // A site whose legacy `buttons` role differs from Button text in family, size and line height.
156
+ const withCustomButtons = (): TextStyleOptions => {
157
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
158
+
159
+ options.buttons.style.fontFamily = 'Oswald';
160
+ options.buttons.style.fontSize = '1.25em';
161
+
162
+ return options;
163
+ };
164
+
165
+ const siteWith = (
166
+ extra: Record<string, unknown>,
167
+ options: TextStyleOptions,
168
+ // A distinct site font (default 'lato') lets a test tell a real vote-driven winner apart from
169
+ // A2's fallback — both would otherwise read 'Lato' and hide a fallback-vs-vote bug (B1).
170
+ siteFontKey = 'lato',
171
+ ): BrandPageModel =>
172
+ ({
173
+ pages: [],
174
+ styles: {
175
+ textStyles: { textFontFamily: siteFontKey, textStyleOptions: options },
176
+ buttonStyles: { style: {} },
177
+ colors: [],
178
+ },
179
+ brandPageType: BRAND_PAGE_TYPES.brandSite,
180
+ ...extra,
181
+ }) as unknown as BrandPageModel;
182
+
183
+ const runMigration22 = async (model: BrandPageModel): Promise<BrandPageModel> =>
184
+ (
185
+ await migration22(
186
+ { brandPageModel: model } as MigrationData,
187
+ {} as MigrationOptions,
188
+ )
189
+ ).brandPageModel;
190
+
191
+ describe('migration-22 · computeLegacyTargets (D1 — per-site legacy → nearest tier)', () => {
192
+ it('pins paragraphs → paragraph1 and buttons → button', () => {
193
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
194
+
195
+ const targets = computeLegacyTargets(options);
196
+
197
+ expect(targets.paragraphs).toBe('paragraph1');
198
+ expect(targets.buttons).toBe('button');
199
+ });
200
+
201
+ it('maps titles/headings to the heading tier nearest their OLD size', () => {
202
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
203
+
204
+ options.titles.style.fontSize = '2.25em'; // nearest H4 (2.1875em)
205
+ options.headings.style.fontSize = '1.5em'; // nearest H5 (1.4375em)
206
+
207
+ const targets = computeLegacyTargets(options);
208
+
209
+ expect(targets.titles).toBe('heading4');
210
+ expect(targets.headings).toBe('heading5');
211
+ });
212
+
213
+ it('keeps titles and headings on DISTINCT tiers when their sizes collide', () => {
214
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
215
+
216
+ options.titles.style.fontSize = '2em';
217
+ options.headings.style.fontSize = '2em';
218
+
219
+ const targets = computeLegacyTargets(options);
220
+
221
+ expect(targets.titles).toBe('heading4'); // titles keeps the shared nearest
222
+ expect(targets.headings).not.toBe(targets.titles); // headings nudged off it
223
+ });
224
+
225
+ it('keeps headings distinct from titles even when headings has no parseable size', () => {
226
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
227
+
228
+ options.titles.style.fontSize = '2.25em'; // → heading4
229
+ // no parseable size → the fallback must still exclude titles' tier
230
+ delete (options.headings.style as unknown as Record<string, unknown>)
231
+ .fontSize;
232
+
233
+ const targets = computeLegacyTargets(options);
234
+
235
+ expect(targets.titles).toBe('heading4');
236
+ expect(targets.headings).not.toBe('heading4');
237
+ });
238
+ });
239
+
240
+ describe('migration-22 · seedRoleColours (A — colour only; roles keep §4 typography)', () => {
241
+ it('copies the paragraphs colour to paragraph1 (D3 base coat)', () => {
242
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
243
+
244
+ setRoleColor(options.paragraphs.style, 'var(--logo-text-color)');
245
+
246
+ seedRoleColours(options, computeLegacyTargets(options));
247
+
248
+ expect(roleColorOf(options.paragraph1.style)).toBe(
249
+ 'var(--logo-text-color)',
250
+ );
251
+ });
252
+
253
+ it('copies each old role colour onto its target tier', () => {
254
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
255
+
256
+ options.titles.style.fontSize = '2.25em'; // → heading4
257
+ setRoleColor(options.titles.style, '#111111');
258
+ setRoleColor(options.buttons.style, '#222222');
259
+
260
+ const targets = computeLegacyTargets(options);
261
+
262
+ seedRoleColours(options, targets);
263
+
264
+ expect(roleColorOf(options[targets.titles].style)).toBe('#111111');
265
+ expect(roleColorOf(options.button.style)).toBe('#222222');
266
+ });
267
+
268
+ it('leaves role TYPOGRAPHY at the §4 defaults (the reported bug)', () => {
269
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
270
+
271
+ // a site that customised its legacy Titles size must NOT push it onto the role
272
+ options.titles.style.fontSize = '2.25em';
273
+ setRoleColor(options.titles.style, '#111111');
274
+
275
+ seedRoleColours(options, computeLegacyTargets(options));
276
+
277
+ expect(options.heading1.style.fontSize).toBe('6.875em'); // §4 default, untouched
278
+ expect(options.heading4.style.fontSize).toBe('2.1875em'); // §4 default, untouched
279
+ });
280
+
281
+ it('no-ops a legacy role that carries no colour', () => {
282
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
283
+
284
+ delete (options.paragraphs.style as unknown as Record<string, unknown>)
285
+ .color;
286
+ const before = roleColorOf(options.paragraph1.style);
287
+
288
+ seedRoleColours(options, computeLegacyTargets(options));
289
+
290
+ expect(roleColorOf(options.paragraph1.style)).toBe(before);
291
+ });
292
+ });
293
+
294
+ describe('migration-22 · reconnectContentRoles (D1/D5 — nearest tier + size override)', () => {
295
+ const withRoles = () => TextStyles.GetDefaultTextStyle().textStyleOptions;
296
+
297
+ it('relinks legacy blocks to their target tier, keeping the old role size production drew over the block’s own (D1)', () => {
298
+ const options = withRoles();
299
+ const targets = computeLegacyTargets(options);
300
+ const titlesSize = options.titles.style.fontSize;
301
+ const content = {
302
+ titleStyle: {
303
+ textStyleType: 'titles',
304
+ fontSize: '3.5em',
305
+ } as Record<string, unknown>,
306
+ descriptionStyle: { textStyleType: 'paragraphs' },
307
+ buttonTextStyle: { textStyleType: 'buttons' },
308
+ };
309
+ const model = modelWithContent(content);
310
+
311
+ reconnectContentRoles(model, targets, options);
312
+
313
+ expect(content.titleStyle.textStyleType).toBe(targets.titles);
314
+ expect(content.descriptionStyle.textStyleType).toBe('paragraph1');
315
+ expect(content.buttonTextStyle.textStyleType).toBe('button');
316
+ // production drew the titles size, not the block's 3.5em; unset draws the tier's own
317
+ expect(
318
+ content.titleStyle.fontSize ?? options[targets.titles].style.fontSize,
319
+ ).toBe(titlesSize);
320
+ });
321
+
322
+ it('pins the old role typography onto a non-RTE block that inherited it (honour the render)', () => {
323
+ const options = withRoles();
324
+
325
+ options.paragraphs.style.fontSize = '1em';
326
+ options.paragraphs.style.lineHeight = '1.6';
327
+ const targets = computeLegacyTargets(options);
328
+ // a non-RTE block linked to paragraphs with no own size — used to inherit from the role
329
+ const content = { descriptionStyle: { textStyleType: 'paragraphs' } };
330
+ const model = modelWithContent(content);
331
+
332
+ reconnectContentRoles(model, targets, options);
333
+
334
+ const style = content.descriptionStyle as Record<string, unknown>;
335
+
336
+ expect(style.textStyleType).toBe('paragraph1');
337
+ expect(style.fontSize).toBe('1em'); // pinned from the old role → override
338
+ expect(style.lineHeight).toBe('1.6');
339
+ });
340
+
341
+ it('preserves an inherited size on a legacy RTE field as a run literal, not the new tier default', () => {
342
+ const options = withRoles();
343
+
344
+ options.titles.style.fontSize = '2.25em'; // → heading4 (whose §4 default is 2.1875em)
345
+ const targets = computeLegacyTargets(options);
346
+ // a legacy RTE title whose size was role-inherited: a bare run (no fontSize mark) and no
347
+ // fontSize on the style object.
348
+ const content = {
349
+ type: 'mailing-list',
350
+ title: docOf([{ text: 'Hi' }]),
351
+ titleStyle: { textStyleType: 'titles' },
352
+ };
353
+ const model = modelWithContent(content);
354
+
355
+ reconnectContentRoles(model, targets, options); // materialises styleObj.fontSize = 2.25em
356
+ stripLiftContentRte(model, options); // C bakes it onto the bare run as a literal
357
+
358
+ expect(content.titleStyle.textStyleType).toBe('heading4');
359
+ // the run renders 2.25em (old role size), NOT heading4's §4 2.1875em nor a role var
360
+ expect(attrsOf(content.title, 0)?.fontSize).toBe('2.25em');
361
+ });
362
+
363
+ it('pins a not-bold legacy RTE title to normal so its bold heading tier can’t bold it (C3)', () => {
364
+ const options = withRoles();
365
+ const targets = computeLegacyTargets(options);
366
+ // a legacy RTE title with a bare run (no bold mark) linked to `titles` — which had no weight, so
367
+ // it rendered normal. It reconnects to a heading tier whose §4 default is bold.
368
+ const content = {
369
+ type: 'mailing-list',
370
+ title: docOf([{ text: 'Hi' }]),
371
+ titleStyle: { textStyleType: 'titles' },
372
+ };
373
+ const model = modelWithContent(content);
374
+
375
+ reconnectContentRoles(model, targets, options); // materialises styleObj.fontWeight = normal
376
+ stripLiftContentRte(model, options); // C3 bakes normal onto the bare run
377
+
378
+ expect(roleType(content.titleStyle)).toMatch(/^heading/);
379
+ expect(attrsOf(content.title, 0)?.fontWeight).toBe('normal'); // NOT the tier's bold
380
+ });
381
+
382
+ it('reconnects a detached big block to a heading tier, keeping its props (D5)', () => {
383
+ const options = withRoles();
384
+ const content = {
385
+ titleStyle: {
386
+ textStyleType: undefined,
387
+ fontSize: '2em',
388
+ fontWeight: '700',
389
+ },
390
+ };
391
+ const model = modelWithContent(content);
392
+
393
+ reconnectContentRoles(model, computeLegacyTargets(options), options);
394
+
395
+ expect(content.titleStyle.textStyleType).toBe('heading4'); // nearest heading to 2em (H4 = 2.1875em)
396
+ expect(content.titleStyle.fontWeight).toBe('700'); // kept as override
397
+ });
398
+
399
+ it('pins a detached block’s unset weight and line height so a heading tier can’t bold it', () => {
400
+ const options = withRoles();
401
+ const content = {
402
+ // a normal-weight block resized big, then detached — no own weight/style/decoration
403
+ titleStyle: { textStyleType: undefined, fontSize: '2em' },
404
+ };
405
+ const model = modelWithContent(content);
406
+
407
+ reconnectContentRoles(model, computeLegacyTargets(options), options);
408
+
409
+ const style = content.titleStyle as Record<string, unknown>;
410
+
411
+ expect(style.textStyleType).toBe('heading4'); // gets heading identity…
412
+ expect(style.fontWeight).toBe('normal'); // …but NOT the tier's bold default
413
+ expect(style.lineHeight).toBe('normal');
414
+ // style and decoration: the tier leaves them unset, so the neutral draws the same unpinned
415
+ expect(style.fontStyle).toBeUndefined();
416
+ expect(style.textDecoration).toBeUndefined();
417
+ });
418
+
419
+ it('reconnects a detached small block to a paragraph tier (D5)', () => {
420
+ const options = withRoles();
421
+ const content = { textStyle: { fontSize: '0.75em' } };
422
+ const model = modelWithContent(content);
423
+
424
+ reconnectContentRoles(model, computeLegacyTargets(options), options);
425
+
426
+ expect(roleType(content.textStyle)).toBe('paragraph3');
427
+ });
428
+
429
+ it('reconnects a detached block with no size to paragraph1', () => {
430
+ const options = withRoles();
431
+ const content = { textStyle: { textAlign: 'center' } };
432
+ const model = modelWithContent(content);
433
+
434
+ reconnectContentRoles(model, computeLegacyTargets(options), options);
435
+
436
+ expect(roleType(content.textStyle)).toBe('paragraph1');
437
+ });
438
+
439
+ it.each([
440
+ [
441
+ 'mailing-list',
442
+ { fontSize: '1em', fontWeight: 'bold' },
443
+ { fontSize: '1em', fontWeight: 'bold' },
444
+ ],
445
+ ['support-me', {}, { fontWeight: 'bold' }],
446
+ ] as const)(
447
+ 'materialises the old no-role %s title defaults before reconnecting it',
448
+ (type, storedStyle, expected) => {
449
+ const options = withRoles();
450
+ const content = { type, titleStyle: { ...storedStyle } };
451
+
452
+ reconnectContentRoles(
453
+ modelWithContent(content),
454
+ computeLegacyTargets(options),
455
+ options,
456
+ );
457
+
458
+ expect(content.titleStyle).toMatchObject({
459
+ textStyleType: type === 'mailing-list' ? 'paragraph2' : 'paragraph1',
460
+ ...expected,
461
+ });
462
+ },
463
+ );
464
+
465
+ it('keeps an explicit no-role title weight over the old component default', () => {
466
+ const options = withRoles();
467
+ const content = {
468
+ type: 'mailing-list',
469
+ titleStyle: { fontWeight: 'normal' },
470
+ };
471
+
472
+ reconnectContentRoles(
473
+ modelWithContent(content),
474
+ computeLegacyTargets(options),
475
+ options,
476
+ );
477
+
478
+ const migrated = content.titleStyle as Record<string, string>;
479
+
480
+ expect(migrated.textStyleType).toBe('paragraph2');
481
+ expect(migrated.fontWeight).toBe('normal');
482
+ });
483
+
484
+ it('does not apply the old component default when a legacy role determined the title look', () => {
485
+ const options = withRoles();
486
+
487
+ options.titles.style.fontWeight = 'normal';
488
+ const content = {
489
+ type: 'mailing-list',
490
+ titleStyle: { textStyleType: 'titles' },
491
+ };
492
+
493
+ reconnectContentRoles(
494
+ modelWithContent(content),
495
+ computeLegacyTargets(options),
496
+ options,
497
+ );
498
+
499
+ expect((content.titleStyle as Record<string, unknown>).fontWeight).toBe(
500
+ 'normal',
501
+ );
502
+ });
503
+
504
+ it('materialises a no-role text block’s legacy textSize and line-height before reconnecting it', () => {
505
+ const options = withRoles();
506
+
507
+ options.paragraph1.style.lineHeight = '1.2';
508
+ const content = {
509
+ type: 'text',
510
+ textSize: { fontSize: '2em' },
511
+ textStyle: {},
512
+ };
513
+
514
+ reconnectContentRoles(
515
+ modelWithContent(content),
516
+ computeLegacyTargets(options),
517
+ options,
518
+ );
519
+
520
+ expect(content.textStyle).toMatchObject({
521
+ textStyleType: 'heading4',
522
+ fontSize: '2em',
523
+ lineHeight: '1.5',
524
+ });
525
+ expect(content).not.toHaveProperty('textSize');
526
+ });
527
+
528
+ it('keeps a text block’s explicit style over its legacy textSize defaults', () => {
529
+ const options = withRoles();
530
+
531
+ options.paragraph1.style.lineHeight = '1.2';
532
+ const content = {
533
+ type: 'text',
534
+ textSize: { fontSize: '2em', lineHeight: '1.4' },
535
+ textStyle: { fontSize: '1.5em', lineHeight: '1.3' },
536
+ };
537
+
538
+ reconnectContentRoles(
539
+ modelWithContent(content),
540
+ computeLegacyTargets(options),
541
+ options,
542
+ );
543
+
544
+ expect(content.textStyle).toMatchObject({
545
+ textStyleType: 'heading5',
546
+ fontSize: '1.5em',
547
+ lineHeight: '1.3',
548
+ });
549
+ });
550
+
551
+ it('folds a text block’s legacy alignment and colour into its persisted style', () => {
552
+ const options = withRoles();
553
+ const content = {
554
+ type: 'text',
555
+ textAlignment: { textAlign: 'left' },
556
+ textColor: { color: '#123456' },
557
+ textStyle: { textAlign: 'center' },
558
+ };
559
+
560
+ reconnectContentRoles(
561
+ modelWithContent(content),
562
+ computeLegacyTargets(options),
563
+ options,
564
+ );
565
+
566
+ expect(content.textStyle).toMatchObject({
567
+ textAlign: 'center',
568
+ color: '#123456',
569
+ });
570
+ expect(content).not.toHaveProperty('textAlignment');
571
+ expect(content).not.toHaveProperty('textColor');
572
+ });
573
+
574
+ it.each([
575
+ ['mailing-list', 'paragraph2'],
576
+ ['support-me', 'paragraph1'],
577
+ ] as const)(
578
+ 'keeps the old no-role %s title bold through the full migration',
579
+ async (type, role) => {
580
+ const options = withRoles();
581
+ const content = {
582
+ type,
583
+ title: docOf([{ text: 'Legacy title' }]),
584
+ titleStyle: {},
585
+ };
586
+ const model = await runMigration22(
587
+ siteWith({ pages: [{ contents: [content] }] }, options),
588
+ );
589
+ const after = model.pages[0].contents[0] as unknown as typeof content;
590
+
591
+ expect(roleType(after.titleStyle)).toBe(role);
592
+ expect(attrsOf(after.title, 0)?.fontWeight).toBe('bold');
593
+ },
594
+ );
595
+
596
+ it('keeps a no-role text block’s legacy textSize through the full migration', async () => {
597
+ const options = withRoles();
598
+ const content = {
599
+ type: 'text',
600
+ text: docOf([{ text: 'Legacy text' }]),
601
+ textSize: { fontSize: '2em' },
602
+ textStyle: {},
603
+ };
604
+ const model = await runMigration22(
605
+ siteWith({ pages: [{ contents: [content] }] }, options),
606
+ );
607
+ const after = model.pages[0].contents[0] as unknown as typeof content;
608
+
609
+ expect(roleType(after.textStyle)).toBe('heading4');
610
+ expect(attrsOf(after.text, 0)).toMatchObject({
611
+ fontSize: '2em',
612
+ lineHeight: '1.5',
613
+ });
614
+ expect(after).not.toHaveProperty('textSize');
615
+ });
616
+
617
+ it('skips null items and text items throughout the migration', async () => {
618
+ const options = withRoles();
619
+ const model = await runMigration22(
620
+ siteWith(
621
+ {
622
+ pages: [
623
+ {
624
+ contents: [
625
+ { type: 'images-section', items: [null] },
626
+ { type: 'text', textItems: [null], textStyle: {} },
627
+ ],
628
+ },
629
+ ],
630
+ },
631
+ options,
632
+ ),
633
+ );
634
+
635
+ expect(model.pages[0].contents).toHaveLength(2);
636
+ });
637
+
638
+ it.each(['none', 'garbage'])(
639
+ "reconnects a '%s' role by its size instead of leaving it as if canonical (R14)",
640
+ (type) => {
641
+ const options = withRoles();
642
+ const content = {
643
+ titleStyle: { textStyleType: type, fontSize: '2.5em' },
644
+ textStyle: { textStyleType: type, fontSize: '0.875em' },
645
+ descriptionStyle: { textStyleType: type },
646
+ };
647
+
648
+ reconnectContentRoles(
649
+ modelWithContent(content),
650
+ computeLegacyTargets(options),
651
+ options,
652
+ );
653
+
654
+ expect(roleType(content.titleStyle)).toBe('heading4');
655
+ expect(roleType(content.textStyle)).toBe('paragraph3');
656
+ expect(roleType(content.descriptionStyle)).toBe('paragraph1');
657
+ // the heading tier gives identity, not its bold default
658
+ expect((content.titleStyle as Record<string, unknown>).fontWeight).toBe(
659
+ 'normal',
660
+ );
661
+ },
662
+ );
663
+
664
+ it('leaves an already-canonical link untouched', () => {
665
+ const options = withRoles();
666
+ const content = { titleStyle: { textStyleType: 'heading2' } };
667
+ const model = modelWithContent(content);
668
+
669
+ reconnectContentRoles(model, computeLegacyTargets(options), options);
670
+
671
+ expect(content.titleStyle.textStyleType).toBe('heading2');
672
+ });
673
+
674
+ it('reconnects textItems[].textStyle', () => {
675
+ const options = withRoles();
676
+ const content = {
677
+ textItems: [{ textStyle: { textStyleType: 'paragraphs' } }],
678
+ };
679
+ const model = modelWithContent(content);
680
+
681
+ reconnectContentRoles(model, computeLegacyTargets(options), options);
682
+
683
+ expect(content.textItems[0].textStyle.textStyleType).toBe('paragraph1');
684
+ });
685
+
686
+ it('reconnects the footer copyright role (legacy → canonical), outside page.contents', () => {
687
+ const options = withRoles();
688
+ const model = modelWithContent({ type: 'shapes-section' });
689
+
690
+ (model as unknown as Record<string, unknown>).pageFooterDesktop = {
691
+ copyrightStyle: { textStyleType: 'paragraphs' },
692
+ };
693
+
694
+ reconnectContentRoles(model, computeLegacyTargets(options), options);
695
+
696
+ expect(
697
+ (
698
+ (model as unknown as { pageFooterDesktop: { copyrightStyle: unknown } })
699
+ .pageFooterDesktop.copyrightStyle as { textStyleType?: string }
700
+ ).textStyleType,
701
+ ).toBe('paragraph1');
702
+ });
703
+ });
704
+
705
+ // The look through the real view model (not `resolveTextStyle`), so renderer defaults are modelled:
706
+ // the button view model's 1.4 line height, the profile's sizes. An unset family draws the site font
707
+ // (Lato here), an unset size its parent's (1em), the rest their inherited defaults.
708
+ //
709
+ // BP-5969 (R1): page data now carries only a field's own typography — the role's values are a
710
+ // CSS var reference resolved at render, not merged in here. So this helper resolves the same way
711
+ // the renderer does (`roleTypographyStyle`): own, else the linked role's own definition (when
712
+ // `options` and a canonical `textStyleType` are given), else the no-role/unset default.
713
+ const drawn = (
714
+ style: (object & { textStyleType?: string }) | undefined,
715
+ options?: TextStyleOptions,
716
+ ): Record<string, string | undefined> => {
717
+ const record = (style ?? {}) as Record<string, string | undefined>;
718
+ const role =
719
+ options && record.textStyleType
720
+ ? (
721
+ options as unknown as Record<
722
+ string,
723
+ { style?: Record<string, string | undefined> }
724
+ >
725
+ )[record.textStyleType]?.style
726
+ : undefined;
727
+ const unset: Record<string, string | undefined> = {
728
+ ...DETACHED_UNSET_EFFECTIVE,
729
+ fontFamily: 'Lato',
730
+ fontSize: '1em',
731
+ };
732
+
733
+ return Object.fromEntries(
734
+ TEXT_STYLE_CASCADE_PROPS.map((prop) => [
735
+ prop,
736
+ record[prop] || role?.[prop] || unset[prop],
737
+ ]),
738
+ );
739
+ };
740
+
741
+ const pageStylesFor = (options: TextStyleOptions): PageStyles => {
742
+ const styles = new PageStyles();
743
+
744
+ styles.textStyles.textStyleOptions = options;
745
+
746
+ return styles;
747
+ };
748
+
749
+ const drawnHeaderButton = (
750
+ headerButton: HeaderButton,
751
+ options: TextStyleOptions,
752
+ ) =>
753
+ drawn(
754
+ mapToPageHeaderViewModel(
755
+ new PageHeaderDesktop({ headerButton }),
756
+ pageStylesFor(options),
757
+ [],
758
+ ).headerButtonViewModel?.titleStyle,
759
+ options,
760
+ );
761
+
762
+ const drawnContent = (
763
+ content: unknown,
764
+ options: TextStyleOptions,
765
+ pick: (viewModel: Record<string, any>) => object | undefined,
766
+ ) =>
767
+ drawn(
768
+ pick(
769
+ mapToViewModel(
770
+ pageStylesFor(options),
771
+ BRAND_PAGE_TYPES.brandSite,
772
+ {},
773
+ )(JSON.parse(JSON.stringify(content)) as Content) as Record<string, any>,
774
+ ),
775
+ options,
776
+ );
777
+
778
+ describe('migration-22 · header button (R7)', () => {
779
+ // Production drew a header button on `buttons` with only the role's typography, so its 1.5 line
780
+ // height, not the view model's 1.4, which only a button on no role drew.
781
+ it.each([
782
+ ['no text style', undefined, '1.5'],
783
+ ['buttons', { textStyleType: 'buttons' }, '1.5'],
784
+ ['no role', { fontSize: '1.25em', fontWeight: 'bold' }, '1.4'],
785
+ ] as const)(
786
+ 'moves a header button on %s to button on both viewports, look kept as production drew it',
787
+ async (_label, buttonTextStyle, lineHeight) => {
788
+ const options = withCustomButtons();
789
+ const headerButton = (): HeaderButton => ({
790
+ isVisible: true,
791
+ text: 'Contact us',
792
+ buttonLinkType: 'url',
793
+ ...(buttonTextStyle
794
+ ? { buttonTextStyle: { ...buttonTextStyle } as TextStyleDeclaration }
795
+ : {}),
796
+ });
797
+ const before = drawn(
798
+ productionTypography(
799
+ (buttonTextStyle ??
800
+ PRODUCTION_HEADER_BUTTON_STYLE) as TextStyleDeclaration,
801
+ options,
802
+ PRODUCTION_BUTTON_DEFAULTS,
803
+ ),
804
+ );
805
+
806
+ const model = await runMigration22(
807
+ siteWith(
808
+ {
809
+ pageHeaderDesktop: { headerButton: headerButton() },
810
+ pageHeaderMobile: { headerButton: headerButton() },
811
+ },
812
+ options,
813
+ ),
814
+ );
815
+
816
+ for (const viewport of [
817
+ model.pageHeaderDesktop,
818
+ model.pageHeaderMobile,
819
+ ]) {
820
+ const after = viewport.headerButton!;
821
+
822
+ expect(after.buttonTextStyle?.textStyleType).toBe('button');
823
+ expect(drawnHeaderButton(after, options)).toEqual(before);
824
+ expect(drawnHeaderButton(after, options).lineHeight).toBe(lineHeight);
825
+ }
826
+ },
827
+ );
828
+ });
829
+
830
+ // Every button element starts on Button text; what makes it different stays an override, and the
831
+ // look is unchanged through the view model (James Joung, 2026-09-28).
832
+ describe('migration-22 · section button (R14, Q2)', () => {
833
+ // Factories translate their labels, so the table builds each content after the locale is set.
834
+ const withButtonStyle =
835
+ (create: () => unknown, key: string, style: Record<string, unknown>) =>
836
+ (): Record<string, unknown> => ({
837
+ ...(create() as Record<string, unknown>),
838
+ [key]: { ...style },
839
+ });
840
+
841
+ beforeAll(async () => {
842
+ await setLocaleAsync('en-US');
843
+ });
844
+
845
+ it.each([
846
+ [
847
+ 'a banner button on buttons',
848
+ withButtonStyle(
849
+ () => createInitialBannerSectionContent(),
850
+ 'buttonTextStyle',
851
+ { textStyleType: 'buttons', textAlign: 'center' },
852
+ ),
853
+ 'buttonTextStyle',
854
+ (vm: Record<string, any>) => vm.buttonViewModel?.titleStyle,
855
+ PRODUCTION_BUTTON_DEFAULTS,
856
+ ],
857
+ [
858
+ 'a banner button on no role',
859
+ withButtonStyle(
860
+ () => createInitialBannerSectionContent(),
861
+ 'buttonTextStyle',
862
+ { fontSize: '1em', textAlign: 'center' },
863
+ ),
864
+ 'buttonTextStyle',
865
+ (vm: Record<string, any>) => vm.buttonViewModel?.titleStyle,
866
+ PRODUCTION_BUTTON_DEFAULTS,
867
+ ],
868
+ [
869
+ 'a booking button on buttons',
870
+ withButtonStyle(
871
+ () => createInitialBookingSectionContent(),
872
+ 'buttonTextStyle',
873
+ { textStyleType: 'buttons' },
874
+ ),
875
+ 'buttonTextStyle',
876
+ (vm: Record<string, any>) => vm.buttonViewModel?.titleStyle,
877
+ PRODUCTION_BUTTON_DEFAULTS,
878
+ ],
879
+ [
880
+ 'a link button on buttons',
881
+ withButtonStyle(
882
+ createInitialButtonContent('general-link', ''),
883
+ 'titleStyle',
884
+ { textStyleType: 'buttons', textAlign: 'center' },
885
+ ),
886
+ 'titleStyle',
887
+ (vm: Record<string, any>) => vm.titleStyle,
888
+ PRODUCTION_BUTTON_DEFAULTS,
889
+ ],
890
+ [
891
+ 'a banner button on no role at line height 1.2 (equal to Button text, which the view model would draw at 1.4)',
892
+ withButtonStyle(
893
+ () => createInitialBannerSectionContent(),
894
+ 'buttonTextStyle',
895
+ { lineHeight: '1.2', textAlign: 'center' },
896
+ ),
897
+ 'buttonTextStyle',
898
+ (vm: Record<string, any>) => vm.buttonViewModel?.titleStyle,
899
+ PRODUCTION_BUTTON_DEFAULTS,
900
+ ],
901
+ [
902
+ 'a mailing list button on no role',
903
+ withButtonStyle(
904
+ () => createInitialMailingListContent(),
905
+ 'buttonTextStyle',
906
+ { fontSize: '1.25em', textAlign: 'center' },
907
+ ),
908
+ 'buttonTextStyle',
909
+ (vm: Record<string, any>) => vm.buttonTextStyle,
910
+ {},
911
+ ],
912
+ [
913
+ 'a banner button on buttons carrying typography production ignored',
914
+ withButtonStyle(
915
+ () => createInitialBannerSectionContent(),
916
+ 'buttonTextStyle',
917
+ {
918
+ textStyleType: 'buttons',
919
+ fontSize: '2em',
920
+ fontWeight: '300',
921
+ lineHeight: '1.1',
922
+ textAlign: 'center',
923
+ },
924
+ ),
925
+ 'buttonTextStyle',
926
+ (vm: Record<string, any>) => vm.buttonViewModel?.titleStyle,
927
+ PRODUCTION_BUTTON_DEFAULTS,
928
+ ],
929
+ ] as const)(
930
+ 'moves %s to button, look kept as production drew it',
931
+ async (_label, createContent, key, pick, defaults) => {
932
+ const options = withCustomButtons();
933
+ const content = createContent();
934
+ const before = drawn(
935
+ productionTypography(
936
+ (content as Record<string, TextStyleDeclaration>)[key],
937
+ options,
938
+ defaults,
939
+ ),
940
+ );
941
+
942
+ const model = await runMigration22(
943
+ siteWith(
944
+ { pages: [{ contents: [JSON.parse(JSON.stringify(content))] }] },
945
+ options,
946
+ ),
947
+ );
948
+ const after = model.pages[0].contents[0] as unknown as Record<
949
+ string,
950
+ TextStyleDeclaration
951
+ >;
952
+
953
+ expect(after[key].textStyleType).toBe('button');
954
+ expect(drawnContent(after, options, pick)).toEqual(before);
955
+ },
956
+ );
957
+ });
958
+
959
+ // Production drew a profile field on a role with only the role's typography; on no role, the view
960
+ // model's defaults for an unset size and line height (1em / 1.5 title and description, 1.5em / 1.4
961
+ // name). The upgrade keeps whichever it drew.
962
+ describe('migration-22 · profile look (R14)', () => {
963
+ beforeAll(async () => {
964
+ await setLocaleAsync('en-US');
965
+ });
966
+
967
+ it.each([
968
+ ['titleStyle', { textStyleType: 'titles', textAlign: 'center' }],
969
+ ['descriptionStyle', { textStyleType: 'paragraphs', textAlign: 'center' }],
970
+ // its own size and line height never showed over the role
971
+ [
972
+ 'nameStyle',
973
+ { textStyleType: 'headings', fontSize: '3em', lineHeight: '1' },
974
+ ],
975
+ // 0.875em lands on Paragraph 3 (equal), which the view model would draw at its own 1em
976
+ ['descriptionStyle', { fontSize: '0.875em', textAlign: 'center' }],
977
+ ['nameStyle', { textAlign: 'center' }],
978
+ ] as const)(
979
+ 'keeps a profile %s look as production drew it',
980
+ async (key, style) => {
981
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
982
+ const content = {
983
+ ...createInitialProfileContent(),
984
+ [key]: { ...style },
985
+ };
986
+ const pick = (vm: Record<string, any>) => vm[key];
987
+ const before = drawn(
988
+ productionTypography(
989
+ style as TextStyleDeclaration,
990
+ options,
991
+ PRODUCTION_PROFILE_DEFAULTS[key],
992
+ ),
993
+ );
994
+
995
+ const model = await runMigration22(
996
+ siteWith(
997
+ { pages: [{ contents: [JSON.parse(JSON.stringify(content))] }] },
998
+ options,
999
+ ),
1000
+ );
1001
+
1002
+ expect(drawnContent(model.pages[0].contents[0], options, pick)).toEqual(
1003
+ before,
1004
+ );
1005
+ },
1006
+ );
1007
+ });
1008
+
1009
+ describe('migration-22 · navigation (R11)', () => {
1010
+ const NAV_COLOUR = 'var(--palette-1)';
1011
+
1012
+ it.each([
1013
+ [
1014
+ 'tab style on no role',
1015
+ 'tab',
1016
+ { fontSize: '1em', fontFamily: 'Lato', textAlign: 'left' },
1017
+ 'paragraph1',
1018
+ ],
1019
+ ['tab style on buttons', 'tab', { textStyleType: 'buttons' }, 'paragraph1'],
1020
+ [
1021
+ 'hamburger style on buttons',
1022
+ 'hamburger',
1023
+ { textStyleType: 'buttons' },
1024
+ 'button',
1025
+ ],
1026
+ [
1027
+ 'hamburger style on no role',
1028
+ 'hamburger',
1029
+ { fontSize: '1em', textAlign: 'left' },
1030
+ 'button',
1031
+ ],
1032
+ ] as const)(
1033
+ 'moves the navigation %s to %s on both viewports, look and colour kept',
1034
+ async (_label, place, style, expected) => {
1035
+ const options = withCustomButtons();
1036
+ const before = { ...style, color: NAV_COLOUR } as TextStyleDeclaration;
1037
+ const beforeRole = roleStyleOf(options, before.textStyleType);
1038
+ const afterRole = { ...roleStyleOf(options, expected) };
1039
+ const header = () => ({
1040
+ navigation:
1041
+ place === 'tab'
1042
+ ? { defaultButtonStyle: { ...before } }
1043
+ : { hamburgerMenuStyle: { defaultButtonStyle: { ...before } } },
1044
+ });
1045
+
1046
+ const model = await runMigration22(
1047
+ siteWith(
1048
+ { pageHeaderDesktop: header(), pageHeaderMobile: header() },
1049
+ options,
1050
+ ),
1051
+ );
1052
+
1053
+ for (const viewport of [
1054
+ model.pageHeaderDesktop,
1055
+ model.pageHeaderMobile,
1056
+ ]) {
1057
+ const after = (
1058
+ place === 'tab'
1059
+ ? viewport.navigation.defaultButtonStyle
1060
+ : viewport.navigation.hamburgerMenuStyle.defaultButtonStyle
1061
+ ) as TextStyleDeclaration;
1062
+
1063
+ expect(after.textStyleType).toBe(expected);
1064
+ expect(after.color).toBe(NAV_COLOUR);
1065
+ expectLookKept(after, afterRole, before, beforeRole);
1066
+ }
1067
+ },
1068
+ );
1069
+ });
1070
+
1071
+ // A nav link resolves `element || #000000` once linked (`getTextStyleByContentTextStyle`). Before,
1072
+ // one with no colour of its own drew its legacy role's colour (the production resolver), or on no
1073
+ // role inherited its parent's: a tab the header's colour, else the page base coat (the paragraphs
1074
+ // colour); the hamburger menu, beside the header under the page root (`Page.vue`), the base coat.
1075
+ describe('migration-22 · navigation colour (R11)', () => {
1076
+ const PARAGRAPHS = 'var(--palette-0)';
1077
+ const BUTTONS = 'var(--palette-2)';
1078
+ const HEADER = '#445566';
1079
+
1080
+ const drawnNavColours = (
1081
+ navigation: unknown,
1082
+ options: TextStyleOptions,
1083
+ ): [unknown, unknown] => {
1084
+ const vm = mapToPageHeaderViewModel(
1085
+ new PageHeaderDesktop({
1086
+ // a header with no display style is rebuilt with a default navigation
1087
+ displayStyle: HEADER_DISPLAY.DESKTOP_TAB_5,
1088
+ navigation: new NavigationDesktop(navigation as never),
1089
+ }),
1090
+ pageStylesFor(options),
1091
+ [],
1092
+ );
1093
+
1094
+ return [
1095
+ vm.navigation.defaultButtonStyle.color,
1096
+ vm.navigation.hamburgerMenuStyle.defaultButtonStyle.color,
1097
+ ];
1098
+ };
1099
+
1100
+ it.each([
1101
+ [
1102
+ 'on no role, under a coloured header',
1103
+ { fontSize: '1em' },
1104
+ HEADER,
1105
+ [HEADER, PARAGRAPHS],
1106
+ ],
1107
+ [
1108
+ 'on no role, with no header colour',
1109
+ { fontSize: '1em' },
1110
+ undefined,
1111
+ [PARAGRAPHS, PARAGRAPHS],
1112
+ ],
1113
+ ['on buttons', { textStyleType: 'buttons' }, HEADER, [BUTTONS, BUTTONS]],
1114
+ ] as const)(
1115
+ 'keeps the colour a navigation link %s drew with no colour of its own',
1116
+ async (_label, style, headerColour, expected) => {
1117
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1118
+
1119
+ (options.paragraphs.style as unknown as Record<string, unknown>).color =
1120
+ PARAGRAPHS;
1121
+ (options.buttons.style as unknown as Record<string, unknown>).color =
1122
+ BUTTONS;
1123
+
1124
+ const header = () => ({
1125
+ ...(headerColour ? { style: { color: headerColour } } : {}),
1126
+ navigation: {
1127
+ defaultButtonStyle: { ...style },
1128
+ hamburgerMenuStyle: { defaultButtonStyle: { ...style } },
1129
+ },
1130
+ });
1131
+
1132
+ const model = await runMigration22(
1133
+ siteWith(
1134
+ { pageHeaderDesktop: header(), pageHeaderMobile: header() },
1135
+ options,
1136
+ ),
1137
+ );
1138
+
1139
+ expect(
1140
+ drawnNavColours(model.pageHeaderDesktop.navigation, options),
1141
+ ).toEqual(expected);
1142
+ },
1143
+ );
1144
+ });
1145
+
1146
+ describe('migration-22 · save contact (R15)', () => {
1147
+ it('moves a brand.contact save contact button to button, keeping the buttons look and its settings', async () => {
1148
+ const options = withCustomButtons();
1149
+ // The widget drew the retired role's style directly (`SaveContactWidget.vue`, before this change).
1150
+ const before = drawn(options.buttons.style);
1151
+
1152
+ const model = await runMigration22(
1153
+ siteWith(
1154
+ {
1155
+ brandPageType: BRAND_PAGE_TYPES.brandContact,
1156
+ saveContactSettings: { isSavingEnabled: false, hideBranding: true },
1157
+ },
1158
+ options,
1159
+ ),
1160
+ );
1161
+
1162
+ const after = model.saveContactSettings?.buttonTextStyle;
1163
+
1164
+ // Button text plus only the values that draw differently from it: weight `normal` equals the
1165
+ // role, and style, decoration and spacing are neutrals Button text leaves unset, so a Button text
1166
+ // edit to those still repaints it (R15). `isSavingEnabled: false` means the widget never renders
1167
+ // (S4, Q12b's "nothing to show" rule), so it casts no A2 vote; Button text stays on Lato and
1168
+ // Save Contact's own drawn font (Oswald, the old `buttons` role's) survives as an override.
1169
+ expect(after).toEqual({
1170
+ textStyleType: 'button',
1171
+ fontFamily: 'Oswald',
1172
+ fontSize: '1.25em',
1173
+ lineHeight: '1.5',
1174
+ });
1175
+ expect(
1176
+ drawn(
1177
+ resolveSaveContactTitleStyle(after, pageStylesFor(options).textStyles),
1178
+ ),
1179
+ ).toEqual(before);
1180
+ expect(model.saveContactSettings).toMatchObject({
1181
+ isSavingEnabled: false,
1182
+ hideBranding: true,
1183
+ });
1184
+ });
1185
+
1186
+ it('creates the save contact settings with their defaults on a brand.contact site that has none', async () => {
1187
+ const model = await runMigration22(
1188
+ siteWith(
1189
+ { brandPageType: BRAND_PAGE_TYPES.brandContact },
1190
+ withCustomButtons(),
1191
+ ),
1192
+ );
1193
+
1194
+ expect(model.saveContactSettings).toMatchObject({
1195
+ isSavingEnabled: true,
1196
+ hideBranding: false,
1197
+ buttonTextStyle: { textStyleType: 'button' },
1198
+ });
1199
+ });
1200
+
1201
+ it.each([BRAND_PAGE_TYPES.brandSite, BRAND_PAGE_TYPES.default])(
1202
+ 'adds no save contact button style on a %s site',
1203
+ async (brandPageType) => {
1204
+ const withSettings = await runMigration22(
1205
+ siteWith(
1206
+ {
1207
+ brandPageType,
1208
+ saveContactSettings: { isSavingEnabled: true, hideBranding: false },
1209
+ },
1210
+ withCustomButtons(),
1211
+ ),
1212
+ );
1213
+ const withoutSettings = await runMigration22(
1214
+ siteWith({ brandPageType }, withCustomButtons()),
1215
+ );
1216
+
1217
+ expect(withSettings.saveContactSettings).toEqual({
1218
+ isSavingEnabled: true,
1219
+ hideBranding: false,
1220
+ });
1221
+ expect(withoutSettings.saveContactSettings).toBeUndefined();
1222
+ },
1223
+ );
1224
+ });
1225
+
1226
+ describe('migration-22 · role fonts on upgrade (R16)', () => {
1227
+ const HEADING_ROLES = [
1228
+ 'heading1',
1229
+ 'heading2',
1230
+ 'heading3',
1231
+ 'heading4',
1232
+ 'heading5',
1233
+ 'heading6',
1234
+ ] as const;
1235
+ const PARAGRAPH_ROLES = ['paragraph1', 'paragraph2', 'paragraph3'] as const;
1236
+
1237
+ const fontsOf = (options: TextStyleOptions, roles: readonly string[]) =>
1238
+ roles.map((role) => options[role as TextStyleType].style.fontFamily);
1239
+
1240
+ const banner = (
1241
+ overrides: Record<string, unknown> = {},
1242
+ ): Record<string, unknown> => ({
1243
+ type: 'banner-section',
1244
+ title: 'Banner title',
1245
+ description: 'Banner description',
1246
+ titleStyle: { textStyleType: 'titles' },
1247
+ descriptionStyle: { textStyleType: 'paragraphs' },
1248
+ buttonText: false,
1249
+ buttonTextStyle: { textStyleType: 'buttons' },
1250
+ ...overrides,
1251
+ });
1252
+
1253
+ const imagesSection = (
1254
+ overrides: Record<string, unknown> = {},
1255
+ ): Record<string, unknown> => ({
1256
+ type: 'images-section',
1257
+ title: '',
1258
+ description: '',
1259
+ items: [],
1260
+ ...overrides,
1261
+ });
1262
+
1263
+ it('T16.1/T16.2 — gives the heading roles the font most title/heading text shows, and the paragraph roles the font most description/paragraph text shows; the look survives the drop, and an unequal override stays', async () => {
1264
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1265
+
1266
+ options.titles.style.fontFamily = 'Anton';
1267
+ options.headings.style.fontFamily = 'Anton';
1268
+ options.paragraphs.style.fontFamily = 'Nunito';
1269
+
1270
+ const model = await runMigration22(
1271
+ siteWith(
1272
+ {
1273
+ pages: [
1274
+ {
1275
+ contents: [
1276
+ banner({
1277
+ titleStyle: { textStyleType: 'titles' },
1278
+ descriptionStyle: { textStyleType: 'paragraphs' },
1279
+ }),
1280
+ banner({
1281
+ titleStyle: { textStyleType: 'headings' },
1282
+ descriptionStyle: { textStyleType: 'paragraphs' },
1283
+ }),
1284
+ // A title stored as an RTE doc, baked in Anton (what the old role drew) — once its
1285
+ // font equals the winning role, the run must relink to the role var, not keep 'Anton'.
1286
+ banner({
1287
+ title: docOf([
1288
+ { text: 'Baked title', attrs: { fontFamily: 'Anton' } },
1289
+ ]),
1290
+ titleStyle: { textStyleType: 'titles' },
1291
+ }),
1292
+ // An own literal that differs from the winning role (Nunito) survives as an override.
1293
+ banner({
1294
+ descriptionStyle: { fontFamily: 'Lato', fontSize: '1em' },
1295
+ }),
1296
+ // The 'nunito' KEY spelling counts toward the Nunito vote (`voteFontName`), but is a
1297
+ // different literal from the winning role's NAME ('Nunito'), so it stays an override.
1298
+ banner({
1299
+ descriptionStyle: { fontFamily: 'nunito', fontSize: '1em' },
1300
+ }),
1301
+ ],
1302
+ },
1303
+ ],
1304
+ },
1305
+ options,
1306
+ ),
1307
+ );
1308
+ const after = model.styles!.textStyles!.textStyleOptions;
1309
+ const contents = model.pages[0].contents as unknown as Record<
1310
+ string,
1311
+ Record<string, unknown>
1312
+ >[];
1313
+
1314
+ expect(fontsOf(after, HEADING_ROLES)).toEqual(Array(6).fill('Anton'));
1315
+ expect(fontsOf(after, PARAGRAPH_ROLES)).toEqual(Array(3).fill('Nunito'));
1316
+
1317
+ // No title keeps a `fontFamily` override once it equals the role that now carries it.
1318
+ expect(contents[0].titleStyle.fontFamily).toBeUndefined();
1319
+ expect(contents[1].titleStyle.fontFamily).toBeUndefined();
1320
+
1321
+ // The baked run relinks to the role ref, since its literal ('Anton') now equals the role.
1322
+ const bakedTitle = contents[2].title as {
1323
+ content: {
1324
+ content: {
1325
+ marks?: { type: string; attrs?: Record<string, unknown> }[];
1326
+ }[];
1327
+ }[];
1328
+ };
1329
+ const bakedMark = bakedTitle.content[0].content[0].marks?.find(
1330
+ (mark) => mark.type === 'textStyle',
1331
+ );
1332
+
1333
+ expect(bakedMark?.attrs?.fontFamily).toBe('var(--role-font-family)');
1334
+
1335
+ // A Lato override differs from the winning role (Nunito) and survives.
1336
+ expect(contents[3].descriptionStyle.fontFamily).toBe('Lato');
1337
+
1338
+ // The 'nunito' key spelling differs (strictly) from the role's name ('Nunito') too.
1339
+ expect(contents[4].descriptionStyle.fontFamily).toBe('nunito');
1340
+ });
1341
+
1342
+ it('T16.3 — a tie goes to the first font found, reading the pages in nav (site) order from the home page, not the stored pages[] order', async () => {
1343
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1344
+
1345
+ options.paragraphs.style.fontFamily = 'Aleo'; // page "home" (routed second in `pages`)
1346
+ options.headings.style.fontFamily = 'Nunito'; // page "other" (stored first in `pages`)
1347
+
1348
+ const model = await runMigration22(
1349
+ siteWith(
1350
+ {
1351
+ pages: [
1352
+ {
1353
+ id: 'other',
1354
+ contents: [
1355
+ banner({ descriptionStyle: { textStyleType: 'headings' } }),
1356
+ ],
1357
+ },
1358
+ {
1359
+ id: 'home',
1360
+ contents: [
1361
+ banner({ descriptionStyle: { textStyleType: 'paragraphs' } }),
1362
+ ],
1363
+ },
1364
+ ],
1365
+ nav: {
1366
+ routes: [
1367
+ { id: 'r-home', pageId: 'home' },
1368
+ { id: 'r-other', pageId: 'other' },
1369
+ ],
1370
+ orders: [
1371
+ { routeId: 'r-home', subRoutes: [] },
1372
+ { routeId: 'r-other', subRoutes: [] },
1373
+ ],
1374
+ },
1375
+ },
1376
+ options,
1377
+ ),
1378
+ );
1379
+
1380
+ expect(
1381
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
1382
+ ).toEqual(Array(3).fill('Aleo'));
1383
+ });
1384
+
1385
+ it('T16.3 — reversing the nav order flips which page is read first', async () => {
1386
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1387
+
1388
+ options.paragraphs.style.fontFamily = 'Aleo'; // page "home", ranked second below
1389
+ options.headings.style.fontFamily = 'Nunito'; // page "other", ranked first below
1390
+
1391
+ const model = await runMigration22(
1392
+ siteWith(
1393
+ {
1394
+ pages: [
1395
+ {
1396
+ id: 'other',
1397
+ contents: [
1398
+ banner({ descriptionStyle: { textStyleType: 'headings' } }),
1399
+ ],
1400
+ },
1401
+ {
1402
+ id: 'home',
1403
+ contents: [
1404
+ banner({ descriptionStyle: { textStyleType: 'paragraphs' } }),
1405
+ ],
1406
+ },
1407
+ ],
1408
+ nav: {
1409
+ routes: [
1410
+ { id: 'r-home', pageId: 'home' },
1411
+ { id: 'r-other', pageId: 'other' },
1412
+ ],
1413
+ // 'other' ranked first this time — the OPPOSITE of the test above.
1414
+ orders: [
1415
+ { routeId: 'r-other', subRoutes: [] },
1416
+ { routeId: 'r-home', subRoutes: [] },
1417
+ ],
1418
+ },
1419
+ },
1420
+ options,
1421
+ ),
1422
+ );
1423
+
1424
+ expect(
1425
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
1426
+ ).toEqual(Array(3).fill('Nunito'));
1427
+ });
1428
+
1429
+ it('T16.3 — a tie WITHIN one page goes to the upper section, top to bottom', async () => {
1430
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1431
+
1432
+ options.paragraphs.style.fontFamily = 'Aleo'; // the upper section
1433
+ options.headings.style.fontFamily = 'Nunito'; // the lower section
1434
+
1435
+ const model = await runMigration22(
1436
+ siteWith(
1437
+ {
1438
+ pages: [
1439
+ {
1440
+ contents: [
1441
+ banner({ descriptionStyle: { textStyleType: 'paragraphs' } }),
1442
+ banner({ descriptionStyle: { textStyleType: 'headings' } }),
1443
+ ],
1444
+ },
1445
+ ],
1446
+ },
1447
+ options,
1448
+ ),
1449
+ );
1450
+
1451
+ expect(
1452
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
1453
+ ).toEqual(Array(3).fill('Aleo'));
1454
+ });
1455
+
1456
+ // Each run's own literal (Aleo, Nunito) votes and the block font (Oswald) does not — proves
1457
+ // per-run voting happens at all. A block-plus-literals implementation also passes this case
1458
+ // (see the tally comment below); the D13 case right after this one is what actually tells
1459
+ // the two apart.
1460
+ it("T16.4 — one vote for each font a doc's runs show", async () => {
1461
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1462
+
1463
+ const model = await runMigration22(
1464
+ siteWith(
1465
+ {
1466
+ pages: [
1467
+ {
1468
+ contents: [
1469
+ // Every run here carries its own literal — the block font (Oswald) must cast no
1470
+ // vote; a block-font-only implementation would instead read it from both runs.
1471
+ banner({
1472
+ description: docOf([
1473
+ { text: 'Aleo run', attrs: { fontFamily: 'Aleo' } },
1474
+ { text: 'Nunito run', attrs: { fontFamily: 'Nunito' } },
1475
+ ]),
1476
+ descriptionStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1477
+ }),
1478
+ banner({
1479
+ description: 'Plain Nunito description',
1480
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
1481
+ }),
1482
+ banner({
1483
+ description: 'Another plain Nunito description',
1484
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
1485
+ }),
1486
+ ],
1487
+ },
1488
+ ],
1489
+ },
1490
+ options,
1491
+ ),
1492
+ );
1493
+
1494
+ // Nunito 3 (the mixed doc's own Nunito run + the two plain descriptions) vs Aleo 1. This
1495
+ // tally is also produced by a block-plus-literals implementation (it would add Oswald once
1496
+ // more, but Nunito's 3 still wins) — it doesn't by itself rule that rule out; the case below
1497
+ // does.
1498
+ expect(
1499
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
1500
+ ).toEqual(Array(3).fill('Nunito'));
1501
+
1502
+ // The Aleo run's literal differs from the winning role and survives; the Nunito run's now
1503
+ // equals it and relinks to the role ref (look kept through `fillRteRunTypography`).
1504
+ const mixedDescription = (
1505
+ model.pages[0].contents as unknown as {
1506
+ description: {
1507
+ content: {
1508
+ content: {
1509
+ marks?: { type: string; attrs?: Record<string, unknown> }[];
1510
+ }[];
1511
+ }[];
1512
+ };
1513
+ }[]
1514
+ )[0].description;
1515
+ const runMark = (i: number) =>
1516
+ mixedDescription.content[0].content[i].marks?.find(
1517
+ (mark) => mark.type === 'textStyle',
1518
+ );
1519
+
1520
+ expect(runMark(0)?.attrs?.fontFamily).toBe('Aleo');
1521
+ expect(runMark(1)?.attrs?.fontFamily).toBe('var(--role-font-family)');
1522
+ });
1523
+
1524
+ it('T16.4 (D13) — a plain-Oswald banner, the mixed Aleo/Nunito doc under a block Oswald, and an Aleo-only doc under a block Nunito together tell the correct per-run rule apart from three wrong ones', async () => {
1525
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1526
+
1527
+ const model = await runMigration22(
1528
+ siteWith(
1529
+ {
1530
+ pages: [
1531
+ {
1532
+ contents: [
1533
+ banner({
1534
+ description: 'Plain Oswald description',
1535
+ descriptionStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1536
+ }),
1537
+ banner({
1538
+ description: docOf([
1539
+ { text: 'Aleo run', attrs: { fontFamily: 'Aleo' } },
1540
+ { text: 'Nunito run', attrs: { fontFamily: 'Nunito' } },
1541
+ ]),
1542
+ descriptionStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1543
+ }),
1544
+ banner({
1545
+ description: docOf([
1546
+ { text: 'Aleo-only run', attrs: { fontFamily: 'Aleo' } },
1547
+ ]),
1548
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
1549
+ }),
1550
+ ],
1551
+ },
1552
+ ],
1553
+ },
1554
+ options,
1555
+ ),
1556
+ );
1557
+
1558
+ // Correct rule: Oswald 1 (the plain banner's block vote) · Aleo 2 (both docs' own Aleo runs,
1559
+ // neither doc's block font votes since every run in each carries its own literal) · Nunito 1
1560
+ // (the mixed doc's own Nunito run) → Aleo wins outright. Three wrong rules all give Oswald
1561
+ // instead, so this is the case that rules each of them out:
1562
+ // - block-plus-literals (the block font AND every literal run vote): Oswald 2, Aleo 2,
1563
+ // Nunito 2 — a three-way tie, broken to Oswald as the first font read.
1564
+ // - block-only (a run's own literal never votes, only the block font): Oswald 2, Nunito 1.
1565
+ // - no-doc-vote (a doc with any literal run casts no vote at all, not even its runs'):
1566
+ // Oswald 1, uncontested.
1567
+ expect(
1568
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
1569
+ ).toEqual(Array(3).fill('Aleo'));
1570
+ });
1571
+
1572
+ it('T16.4 (B2) — a colour-only run mark (fontFamily: "") votes the block font, not nothing', async () => {
1573
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1574
+
1575
+ const model = await runMigration22(
1576
+ siteWith(
1577
+ {
1578
+ pages: [
1579
+ {
1580
+ contents: [
1581
+ banner({
1582
+ descriptionStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1583
+ }),
1584
+ banner({
1585
+ // A colour-only mark parsed from pasted HTML carries `fontFamily: ''`
1586
+ // (FontFamily.ts) — it must vote the block font (Nunito), not cast no vote.
1587
+ description: docOf([
1588
+ {
1589
+ text: 'Coloured run',
1590
+ attrs: { color: '#111111', fontFamily: '' },
1591
+ },
1592
+ ]),
1593
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
1594
+ }),
1595
+ banner({
1596
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
1597
+ }),
1598
+ ],
1599
+ },
1600
+ ],
1601
+ },
1602
+ options,
1603
+ ),
1604
+ );
1605
+
1606
+ // Nunito 2 (the coloured run's block font + the plain description) beats Oswald 1 — red
1607
+ // without the fix: the coloured run casts no vote, so it ties 1-1 and Oswald (voted first)
1608
+ // wins the tie-break instead.
1609
+ expect(
1610
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
1611
+ ).toEqual(Array(3).fill('Nunito'));
1612
+ });
1613
+
1614
+ it('T16.5 — testimonial name/position, profile name, itemTextStyle and success messages cast no vote', async () => {
1615
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1616
+
1617
+ const model = await runMigration22(
1618
+ siteWith(
1619
+ {
1620
+ pages: [
1621
+ {
1622
+ contents: [
1623
+ {
1624
+ type: 'testimonial-section',
1625
+ title: '',
1626
+ items: [
1627
+ {
1628
+ id: '1',
1629
+ name: 'A name',
1630
+ position: 'A position',
1631
+ testimonials: 'A quote',
1632
+ },
1633
+ ],
1634
+ nameStyle: { fontFamily: 'Oswald' },
1635
+ positionStyle: { fontFamily: 'Oswald' },
1636
+ testimonialStyle: { fontFamily: 'Oswald' },
1637
+ },
1638
+ {
1639
+ type: 'support-me',
1640
+ title: '',
1641
+ description: 'Support description',
1642
+ titleStyle: {},
1643
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
1644
+ headingStyle: { fontFamily: 'Oswald' }, // fixed i18n labels, not a vote source
1645
+ successMessageStyle: { fontFamily: 'Oswald' },
1646
+ itemTextStyle: { fontFamily: 'Oswald' },
1647
+ },
1648
+ ],
1649
+ },
1650
+ ],
1651
+ },
1652
+ options,
1653
+ ),
1654
+ );
1655
+
1656
+ expect(
1657
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
1658
+ ).toEqual(Array(3).fill('Nunito'));
1659
+ // No title/heading text anywhere on this site — the family keeps its default.
1660
+ expect(
1661
+ fontsOf(model.styles!.textStyles!.textStyleOptions, HEADING_ROLES),
1662
+ ).toEqual(Array(6).fill('Lato'));
1663
+ });
1664
+
1665
+ it("T16.6 — counts the font a legacy-role field DREW (the role's), not its own stored override; a title on Paragraphs counts for the headings", async () => {
1666
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1667
+
1668
+ options.paragraphs.style.fontFamily = 'Tenor Sans';
1669
+
1670
+ const model = await runMigration22(
1671
+ siteWith(
1672
+ {
1673
+ pages: [
1674
+ {
1675
+ contents: [
1676
+ banner({
1677
+ // Production ignored this field's own font (it drew the role's) — the count
1678
+ // must agree, or it would disagree with what B keeps.
1679
+ descriptionStyle: {
1680
+ textStyleType: 'paragraphs',
1681
+ fontFamily: 'Oswald',
1682
+ },
1683
+ // A title linked to the (legacy) Paragraphs role counts for the HEADINGS family.
1684
+ titleStyle: { textStyleType: 'paragraphs' },
1685
+ }),
1686
+ ],
1687
+ },
1688
+ ],
1689
+ },
1690
+ options,
1691
+ ),
1692
+ );
1693
+ const after = model.styles!.textStyles!.textStyleOptions;
1694
+
1695
+ expect(fontsOf(after, PARAGRAPH_ROLES)).toEqual(
1696
+ Array(3).fill('Tenor Sans'),
1697
+ );
1698
+ expect(fontsOf(after, HEADING_ROLES)).toEqual(Array(6).fill('Tenor Sans'));
1699
+ });
1700
+
1701
+ it("T16.6 — a description on no role with no font counts the site's own font, read from its stored KEY", async () => {
1702
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1703
+
1704
+ const model = await runMigration22(
1705
+ siteWith(
1706
+ {
1707
+ pages: [{ contents: [banner({ descriptionStyle: {} })] }],
1708
+ },
1709
+ options,
1710
+ 'anton', // the site's font KEY — resolves to the display name 'Anton'
1711
+ ),
1712
+ );
1713
+
1714
+ expect(
1715
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
1716
+ ).toEqual(Array(3).fill('Anton'));
1717
+ });
1718
+
1719
+ it('T16.7 — the button family counts the header button, a link button label and Save Contact, by field kind not role', async () => {
1720
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1721
+
1722
+ options.buttons.style.fontFamily = 'Oswald';
1723
+
1724
+ const model = await runMigration22(
1725
+ siteWith(
1726
+ {
1727
+ brandPageType: BRAND_PAGE_TYPES.brandSite,
1728
+ pageHeaderDesktop: {
1729
+ headerButton: {
1730
+ isVisible: true,
1731
+ text: 'Contact us',
1732
+ buttonLinkType: 'url',
1733
+ },
1734
+ },
1735
+ pageHeaderMobile: {
1736
+ headerButton: { isVisible: false, text: '', buttonLinkType: 'url' },
1737
+ },
1738
+ pages: [
1739
+ {
1740
+ contents: [
1741
+ {
1742
+ type: 'general-link',
1743
+ title: 'Visit us',
1744
+ titleStyle: { textStyleType: 'buttons' },
1745
+ url: 'https://example.com',
1746
+ },
1747
+ ],
1748
+ },
1749
+ ],
1750
+ },
1751
+ options,
1752
+ ),
1753
+ );
1754
+
1755
+ // Both the (visible, desktop) header button and the link button label drew `buttons`'s Oswald.
1756
+ expect(
1757
+ model.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
1758
+ ).toBe('Oswald');
1759
+ });
1760
+
1761
+ it('T16.7(b) — the header button counts once even when both desktop and mobile are visible', async () => {
1762
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1763
+
1764
+ const model = await runMigration22(
1765
+ siteWith(
1766
+ {
1767
+ pageHeaderDesktop: {
1768
+ headerButton: {
1769
+ isVisible: true,
1770
+ text: 'Contact us',
1771
+ buttonLinkType: 'url',
1772
+ buttonTextStyle: { fontFamily: 'Aleo', fontSize: '1em' },
1773
+ },
1774
+ },
1775
+ pageHeaderMobile: {
1776
+ headerButton: {
1777
+ isVisible: true,
1778
+ text: 'Contact us',
1779
+ buttonLinkType: 'url',
1780
+ buttonTextStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1781
+ },
1782
+ },
1783
+ pages: [
1784
+ {
1785
+ contents: [
1786
+ {
1787
+ type: 'general-link',
1788
+ title: 'Visit us',
1789
+ titleStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1790
+ url: 'https://example.com',
1791
+ },
1792
+ ],
1793
+ },
1794
+ ],
1795
+ },
1796
+ options,
1797
+ ),
1798
+ );
1799
+
1800
+ // Aleo (desktop, counted once, voted before the page content) wins the 1-1 tie against the
1801
+ // link button's Oswald vote — double-counting the mobile header too would make Oswald 2-1.
1802
+ expect(
1803
+ model.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
1804
+ ).toBe('Aleo');
1805
+ });
1806
+
1807
+ it('T16.7(b) — a profile title votes for the headings, not the button family', async () => {
1808
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1809
+
1810
+ const model = await runMigration22(
1811
+ siteWith(
1812
+ {
1813
+ pages: [
1814
+ {
1815
+ contents: [
1816
+ {
1817
+ type: 'profile',
1818
+ name: 'A name',
1819
+ title: 'Head of Design',
1820
+ description: '',
1821
+ titleStyle: { fontFamily: 'Anton', fontSize: '1em' },
1822
+ nameStyle: {},
1823
+ descriptionStyle: {},
1824
+ },
1825
+ ],
1826
+ },
1827
+ ],
1828
+ },
1829
+ options,
1830
+ ),
1831
+ );
1832
+ const after = model.styles!.textStyles!.textStyleOptions;
1833
+
1834
+ expect(fontsOf(after, HEADING_ROLES)).toEqual(Array(6).fill('Anton'));
1835
+ expect(after.button.style.fontFamily).toBe('Lato'); // no vote from this field
1836
+ });
1837
+
1838
+ it('T16.7(c) — a hidden header button, a banner with its button off and an empty title cast no vote', async () => {
1839
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1840
+
1841
+ options.buttons.style.fontFamily = 'Oswald'; // would win if anything voted
1842
+
1843
+ const model = await runMigration22(
1844
+ siteWith(
1845
+ {
1846
+ pageHeaderDesktop: {
1847
+ headerButton: {
1848
+ isVisible: false,
1849
+ text: 'Hidden',
1850
+ buttonLinkType: 'url',
1851
+ },
1852
+ },
1853
+ pages: [
1854
+ {
1855
+ contents: [
1856
+ banner({
1857
+ buttonText: false,
1858
+ buttonTextStyle: { textStyleType: 'buttons' },
1859
+ }),
1860
+ {
1861
+ type: 'general-link',
1862
+ title: '',
1863
+ titleStyle: { textStyleType: 'buttons' },
1864
+ url: '',
1865
+ },
1866
+ ],
1867
+ },
1868
+ ],
1869
+ },
1870
+ options,
1871
+ ),
1872
+ );
1873
+
1874
+ expect(
1875
+ model.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
1876
+ ).toBe('Lato');
1877
+ });
1878
+
1879
+ it('T16.7(d) — Save Contact votes button text on brand.contact only, from its own font when it has one', async () => {
1880
+ const withSaveContact = await runMigration22(
1881
+ siteWith(
1882
+ {
1883
+ brandPageType: BRAND_PAGE_TYPES.brandContact,
1884
+ pages: [{ contents: [{ type: 'profile', name: 'A name' }] }],
1885
+ saveContactSettings: {
1886
+ isSavingEnabled: true,
1887
+ hideBranding: false,
1888
+ buttonTextStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1889
+ },
1890
+ },
1891
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
1892
+ ),
1893
+ );
1894
+
1895
+ expect(
1896
+ withSaveContact.styles!.textStyles!.textStyleOptions.button.style
1897
+ .fontFamily,
1898
+ ).toBe('Oswald');
1899
+
1900
+ // brand.site: the same field never votes (the widget doesn't render there).
1901
+ const brandSite = await runMigration22(
1902
+ siteWith(
1903
+ {
1904
+ brandPageType: BRAND_PAGE_TYPES.brandSite,
1905
+ pages: [{ contents: [{ type: 'profile', name: 'A name' }] }],
1906
+ saveContactSettings: {
1907
+ isSavingEnabled: true,
1908
+ hideBranding: false,
1909
+ buttonTextStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1910
+ },
1911
+ },
1912
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
1913
+ ),
1914
+ );
1915
+
1916
+ expect(
1917
+ brandSite.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
1918
+ ).toBe('Lato');
1919
+ });
1920
+
1921
+ it('T16.7(d) — Save Contact casts no vote on a brand.contact site with no profile section (S4: the widget never renders without one)', async () => {
1922
+ const model = await runMigration22(
1923
+ siteWith(
1924
+ {
1925
+ brandPageType: BRAND_PAGE_TYPES.brandContact,
1926
+ // A page WITH a (non-profile) section, so this proves the no-vote rule keys off the
1927
+ // missing profile section specifically, not merely an empty `pages` array.
1928
+ pages: [{ contents: [banner()] }],
1929
+ saveContactSettings: {
1930
+ isSavingEnabled: true,
1931
+ hideBranding: false,
1932
+ buttonTextStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1933
+ },
1934
+ },
1935
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
1936
+ ),
1937
+ );
1938
+
1939
+ expect(
1940
+ model.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
1941
+ ).toBe('Lato');
1942
+ });
1943
+
1944
+ it('T16.7(e) — three link-button labels vote the button family, not headings (Q10: a link button is a button element); a profile title votes the headings', async () => {
1945
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1946
+
1947
+ const model = await runMigration22(
1948
+ siteWith(
1949
+ {
1950
+ brandPageType: BRAND_PAGE_TYPES.default,
1951
+ pages: [
1952
+ {
1953
+ contents: [
1954
+ {
1955
+ type: 'general-link',
1956
+ title: 'Visit us',
1957
+ titleStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1958
+ url: 'https://example.com',
1959
+ },
1960
+ {
1961
+ type: 'general-link',
1962
+ title: 'Follow us',
1963
+ titleStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1964
+ url: 'https://example.com',
1965
+ },
1966
+ {
1967
+ type: 'general-link',
1968
+ title: 'Email us',
1969
+ titleStyle: { fontFamily: 'Oswald', fontSize: '1em' },
1970
+ url: 'mailto:hi@example.com',
1971
+ },
1972
+ {
1973
+ type: 'profile',
1974
+ name: 'A name',
1975
+ title: 'Head of Design',
1976
+ description: '',
1977
+ titleStyle: { fontFamily: 'Anton', fontSize: '1em' },
1978
+ nameStyle: {},
1979
+ descriptionStyle: {},
1980
+ },
1981
+ ],
1982
+ },
1983
+ ],
1984
+ },
1985
+ options,
1986
+ ),
1987
+ );
1988
+ const after = model.styles!.textStyles!.textStyleOptions;
1989
+
1990
+ // If a link's title voted the headings instead, headings would flip to Oswald (3-1) and button
1991
+ // would stay on Lato (no vote) — both assertions below catch that misclassification.
1992
+ expect(after.button.style.fontFamily).toBe('Oswald');
1993
+ expect(fontsOf(after, HEADING_ROLES)).toEqual(Array(6).fill('Anton'));
1994
+ });
1995
+
1996
+ it('T16.7(f) — a direct section button (banner) counts its own non-blank buttonText, not just a link label or the header button', async () => {
1997
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
1998
+
1999
+ const model = await runMigration22(
2000
+ siteWith(
2001
+ {
2002
+ pages: [
2003
+ {
2004
+ contents: [
2005
+ banner({
2006
+ buttonText: 'Shop',
2007
+ buttonTextStyle: { fontFamily: 'Oswald', fontSize: '1em' },
2008
+ }),
2009
+ ],
2010
+ },
2011
+ ],
2012
+ },
2013
+ options,
2014
+ ),
2015
+ );
2016
+
2017
+ expect(
2018
+ model.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
2019
+ ).toBe('Oswald');
2020
+ });
2021
+
2022
+ it("T16.7(g) — a visible-but-blank desktop header button (S3) doesn't stop the visible mobile one from voting", async () => {
2023
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2024
+
2025
+ const model = await runMigration22(
2026
+ siteWith(
2027
+ {
2028
+ pageHeaderDesktop: {
2029
+ headerButton: { isVisible: true, text: '', buttonLinkType: 'url' },
2030
+ },
2031
+ pageHeaderMobile: {
2032
+ headerButton: {
2033
+ isVisible: true,
2034
+ text: 'Contact us',
2035
+ buttonLinkType: 'url',
2036
+ buttonTextStyle: { fontFamily: 'Oswald', fontSize: '1em' },
2037
+ },
2038
+ },
2039
+ },
2040
+ options,
2041
+ ),
2042
+ );
2043
+
2044
+ expect(
2045
+ model.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
2046
+ ).toBe('Oswald');
2047
+ });
2048
+
2049
+ it('T16.7(h) — a fixed-label button (request-payment) votes the button family from its own font (S3)', async () => {
2050
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2051
+
2052
+ const model = await runMigration22(
2053
+ siteWith(
2054
+ {
2055
+ pages: [
2056
+ {
2057
+ contents: [
2058
+ {
2059
+ type: 'request-payment',
2060
+ title: 'Support us',
2061
+ description: '',
2062
+ buttonTextStyle: { fontFamily: 'Oswald', fontSize: '1em' },
2063
+ },
2064
+ ],
2065
+ },
2066
+ ],
2067
+ },
2068
+ options,
2069
+ ),
2070
+ );
2071
+
2072
+ expect(
2073
+ model.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
2074
+ ).toBe('Oswald');
2075
+ });
2076
+
2077
+ it('T16.7(h) — support-me votes the button family the same way, with no persisted label to check for blank (S3)', async () => {
2078
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2079
+
2080
+ const model = await runMigration22(
2081
+ siteWith(
2082
+ {
2083
+ pages: [
2084
+ {
2085
+ contents: [
2086
+ {
2087
+ type: 'support-me',
2088
+ title: 'Buy me a coffee',
2089
+ description: '',
2090
+ buttonTextStyle: { fontFamily: 'Oswald', fontSize: '1em' },
2091
+ },
2092
+ ],
2093
+ },
2094
+ ],
2095
+ },
2096
+ options,
2097
+ ),
2098
+ );
2099
+
2100
+ expect(
2101
+ model.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
2102
+ ).toBe('Oswald');
2103
+ });
2104
+
2105
+ it("T16.7(h) — shop's fixed-label button votes only once it has a product to sell (S3)", async () => {
2106
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2107
+
2108
+ const shopSection = (
2109
+ products: Record<string, unknown>[],
2110
+ ): Record<string, unknown> => ({
2111
+ type: 'shop-section',
2112
+ title: '',
2113
+ description: '',
2114
+ products,
2115
+ buttonTextStyle: { fontFamily: 'Oswald', fontSize: '1em' },
2116
+ });
2117
+
2118
+ const noProducts = await runMigration22(
2119
+ siteWith({ pages: [{ contents: [shopSection([])] }] }, options),
2120
+ );
2121
+
2122
+ expect(
2123
+ noProducts.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
2124
+ ).toBe('Lato');
2125
+
2126
+ const oneProduct = await runMigration22(
2127
+ siteWith(
2128
+ { pages: [{ contents: [shopSection([{ id: '1' }])] }] },
2129
+ options,
2130
+ ),
2131
+ );
2132
+
2133
+ expect(
2134
+ oneProduct.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
2135
+ ).toBe('Oswald');
2136
+ });
2137
+
2138
+ it('T16.8 — nav, footer copyright and their fonts cast no vote', async () => {
2139
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2140
+
2141
+ const model = await runMigration22(
2142
+ siteWith(
2143
+ {
2144
+ pageHeaderDesktop: {
2145
+ navigation: { defaultButtonStyle: { fontFamily: 'Oswald' } },
2146
+ },
2147
+ pageFooterDesktop: { copyrightStyle: { fontFamily: 'Oswald' } },
2148
+ pages: [
2149
+ {
2150
+ contents: [
2151
+ banner({
2152
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2153
+ }),
2154
+ ],
2155
+ },
2156
+ ],
2157
+ },
2158
+ options,
2159
+ ),
2160
+ );
2161
+
2162
+ expect(
2163
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
2164
+ ).toEqual(Array(3).fill('Nunito'));
2165
+ });
2166
+
2167
+ it('T16.10 — a site with no counted text at all keeps every role on Lato', async () => {
2168
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2169
+
2170
+ const model = await runMigration22(siteWith({ pages: [] }, options));
2171
+ const after = model.styles!.textStyles!.textStyleOptions;
2172
+
2173
+ expect(
2174
+ fontsOf(after, [...HEADING_ROLES, ...PARAGRAPH_ROLES, 'button']),
2175
+ ).toEqual(Array(10).fill('Lato'));
2176
+ });
2177
+
2178
+ it('T16.10 (B1) — a family with no vote keeps Lato, even when the site text font is something else', async () => {
2179
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2180
+
2181
+ // A distinct site font (Anton) — red without the fix: a zero-vote family fell back to the
2182
+ // SITE font, not Lato.
2183
+ const model = await runMigration22(
2184
+ siteWith({ pages: [] }, options, 'anton'),
2185
+ );
2186
+ const after = model.styles!.textStyles!.textStyleOptions;
2187
+
2188
+ expect(
2189
+ fontsOf(after, [...HEADING_ROLES, ...PARAGRAPH_ROLES, 'button']),
2190
+ ).toEqual(Array(10).fill('Lato'));
2191
+ });
2192
+
2193
+ it('T16.10 — counted text that is all Lato wins on its own votes, not because it happens to match the site font', async () => {
2194
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2195
+
2196
+ const model = await runMigration22(
2197
+ siteWith(
2198
+ {
2199
+ pages: [
2200
+ {
2201
+ contents: [
2202
+ banner({
2203
+ descriptionStyle: { fontFamily: 'Lato', fontSize: '1em' },
2204
+ }),
2205
+ ],
2206
+ },
2207
+ ],
2208
+ },
2209
+ options,
2210
+ 'anton', // a DIFFERENT site font, so a Lato winner can only come from the vote
2211
+ ),
2212
+ );
2213
+
2214
+ expect(
2215
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
2216
+ ).toEqual(Array(3).fill('Lato'));
2217
+
2218
+ // S6: the counted field's own literal (Lato) now equals the winning role and drops, rather
2219
+ // than surviving as a redundant override.
2220
+ const contents = model.pages[0].contents as unknown as {
2221
+ descriptionStyle: { fontFamily?: string };
2222
+ }[];
2223
+
2224
+ expect(contents[0].descriptionStyle.fontFamily).toBeUndefined();
2225
+ });
2226
+
2227
+ it('T16.11 — a font outside the font list casts no vote, and its own text keeps it as an override', async () => {
2228
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2229
+
2230
+ const model = await runMigration22(
2231
+ siteWith(
2232
+ {
2233
+ pages: [
2234
+ {
2235
+ contents: [
2236
+ banner({
2237
+ descriptionStyle: { fontFamily: 'Calibri', fontSize: '1em' },
2238
+ }),
2239
+ banner({
2240
+ descriptionStyle: { fontFamily: 'Calibri', fontSize: '1em' },
2241
+ }),
2242
+ banner({
2243
+ descriptionStyle: { fontFamily: 'Calibri', fontSize: '1em' },
2244
+ }),
2245
+ banner({
2246
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2247
+ }),
2248
+ ],
2249
+ },
2250
+ ],
2251
+ },
2252
+ options,
2253
+ ),
2254
+ );
2255
+
2256
+ expect(
2257
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
2258
+ ).toEqual(Array(3).fill('Nunito'));
2259
+ });
2260
+
2261
+ it("T16.12 — each list item votes on its own, against the section's single paragraphStyle", async () => {
2262
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2263
+
2264
+ const model = await runMigration22(
2265
+ siteWith(
2266
+ {
2267
+ pages: [
2268
+ {
2269
+ contents: [
2270
+ imagesSection({
2271
+ paragraphStyle: { fontFamily: 'Oswald', fontSize: '1em' },
2272
+ items: [
2273
+ { id: '1', paragraph: 'one' },
2274
+ { id: '2', paragraph: 'two' },
2275
+ { id: '3', paragraph: 'three' },
2276
+ { id: '4', paragraph: 'four' },
2277
+ ],
2278
+ }),
2279
+ banner({
2280
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2281
+ }),
2282
+ banner({
2283
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2284
+ }),
2285
+ banner({
2286
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2287
+ }),
2288
+ ],
2289
+ },
2290
+ ],
2291
+ },
2292
+ options,
2293
+ ),
2294
+ );
2295
+
2296
+ // 4 item votes for Oswald vs 3 for Nunito.
2297
+ expect(
2298
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
2299
+ ).toEqual(Array(3).fill('Oswald'));
2300
+ });
2301
+
2302
+ it('T16.12 — an item with a blank paragraph casts no vote', async () => {
2303
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2304
+
2305
+ const model = await runMigration22(
2306
+ siteWith(
2307
+ {
2308
+ pages: [
2309
+ {
2310
+ contents: [
2311
+ imagesSection({
2312
+ paragraphStyle: { fontFamily: 'Oswald', fontSize: '1em' },
2313
+ items: [
2314
+ { id: '1', paragraph: 'one' },
2315
+ { id: '2', paragraph: 'two' },
2316
+ { id: '3', paragraph: '' }, // blank — no vote
2317
+ { id: '4', paragraph: ' ' }, // whitespace-only — no vote
2318
+ ],
2319
+ }),
2320
+ banner({
2321
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2322
+ }),
2323
+ banner({
2324
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2325
+ }),
2326
+ banner({
2327
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2328
+ }),
2329
+ ],
2330
+ },
2331
+ ],
2332
+ },
2333
+ options,
2334
+ ),
2335
+ );
2336
+
2337
+ // 2 real item votes for Oswald vs 3 for Nunito — the two blank items must not tip Oswald to
2338
+ // a tie or a win at 4.
2339
+ expect(
2340
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
2341
+ ).toEqual(Array(3).fill('Nunito'));
2342
+ });
2343
+
2344
+ it('T16.12 (S8) — a left/right-aligned images-section item past the render cutoff casts no vote', async () => {
2345
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2346
+
2347
+ const model = await runMigration22(
2348
+ siteWith(
2349
+ {
2350
+ pages: [
2351
+ {
2352
+ contents: [
2353
+ // Only the first 3 items render in a left-aligned layout
2354
+ // (`MAX_IMAGES_SECTION_ALIGNMENT_ITEMS_LENGTH`) — 3 real Oswald votes, not 4.
2355
+ imagesSection({
2356
+ displayStyle: 'left-aligned',
2357
+ paragraphStyle: { fontFamily: 'Oswald', fontSize: '1em' },
2358
+ items: [
2359
+ { id: '1', paragraph: 'one' },
2360
+ { id: '2', paragraph: 'two' },
2361
+ { id: '3', paragraph: 'three' },
2362
+ { id: '4', paragraph: 'four' }, // past the cutoff — no vote
2363
+ ],
2364
+ }),
2365
+ banner({
2366
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2367
+ }),
2368
+ banner({
2369
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2370
+ }),
2371
+ banner({
2372
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2373
+ }),
2374
+ banner({
2375
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2376
+ }),
2377
+ ],
2378
+ },
2379
+ ],
2380
+ },
2381
+ options,
2382
+ ),
2383
+ );
2384
+
2385
+ // 3 real item votes for Oswald vs 4 for Nunito — Nunito wins. Counting the excluded 4th item
2386
+ // too would tie it 4-4, and Oswald (voted first, the images-section comes first) would win
2387
+ // the tie-break instead.
2388
+ expect(
2389
+ fontsOf(model.styles!.textStyles!.textStyleOptions, PARAGRAPH_ROLES),
2390
+ ).toEqual(Array(3).fill('Nunito'));
2391
+ });
2392
+
2393
+ it("T16.12 (S5) — each item title votes the headings family against a shapes section's single headingStyle", async () => {
2394
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2395
+
2396
+ const model = await runMigration22(
2397
+ siteWith(
2398
+ {
2399
+ pages: [
2400
+ {
2401
+ contents: [
2402
+ imagesSection({
2403
+ type: 'shapes-section',
2404
+ headingStyle: { fontFamily: 'Anton', fontSize: '1em' },
2405
+ items: [
2406
+ { id: '1', title: 'One' },
2407
+ { id: '2', title: 'Two' },
2408
+ ],
2409
+ }),
2410
+ banner({
2411
+ titleStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2412
+ }),
2413
+ ],
2414
+ },
2415
+ ],
2416
+ },
2417
+ options,
2418
+ ),
2419
+ );
2420
+
2421
+ // 2 item votes for Anton vs 1 for the banner's own title (Nunito).
2422
+ expect(
2423
+ fontsOf(model.styles!.textStyles!.textStyleOptions, HEADING_ROLES),
2424
+ ).toEqual(Array(6).fill('Anton'));
2425
+ });
2426
+
2427
+ it("T16.12 (S5) — each item's buttonText votes the button family against an images section's single buttonTextStyle", async () => {
2428
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2429
+
2430
+ const model = await runMigration22(
2431
+ siteWith(
2432
+ {
2433
+ pages: [
2434
+ {
2435
+ contents: [
2436
+ imagesSection({
2437
+ buttonTextStyle: { fontFamily: 'Oswald', fontSize: '1em' },
2438
+ items: [
2439
+ { id: '1', buttonText: 'Shop' },
2440
+ { id: '2', buttonText: 'Buy' },
2441
+ ],
2442
+ }),
2443
+ banner({
2444
+ buttonText: 'Contact',
2445
+ buttonTextStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2446
+ }),
2447
+ ],
2448
+ },
2449
+ ],
2450
+ },
2451
+ options,
2452
+ ),
2453
+ );
2454
+
2455
+ // 2 item votes for Oswald vs 1 for the banner's own direct button (Nunito).
2456
+ expect(
2457
+ model.styles!.textStyles!.textStyleOptions.button.style.fontFamily,
2458
+ ).toBe('Oswald');
2459
+ });
2460
+
2461
+ it('T16.9 — a dropped (role-equal) override follows a later role font edit; a kept literal (outside the font list) does not', async () => {
2462
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2463
+
2464
+ const model = await runMigration22(
2465
+ siteWith(
2466
+ {
2467
+ pages: [
2468
+ {
2469
+ contents: [
2470
+ banner({
2471
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2472
+ }),
2473
+ banner({
2474
+ descriptionStyle: { fontFamily: 'Nunito', fontSize: '1em' },
2475
+ }),
2476
+ // Outside the font list — casts no vote, and keeps its own literal.
2477
+ banner({
2478
+ descriptionStyle: { fontFamily: 'Calibri', fontSize: '1em' },
2479
+ }),
2480
+ ],
2481
+ },
2482
+ ],
2483
+ },
2484
+ options,
2485
+ ),
2486
+ );
2487
+ const after = model.styles!.textStyles!.textStyleOptions;
2488
+ const contents = model.pages[0].contents as unknown as Record<
2489
+ string,
2490
+ Record<string, unknown>
2491
+ >[];
2492
+
2493
+ expect(after.paragraph1.style.fontFamily).toBe('Nunito');
2494
+ // A new item on Paragraph 1 (`addOption`'s shape) resolves the role's current font.
2495
+ expect(
2496
+ (
2497
+ resolveTextStyle(
2498
+ { textStyleType: 'paragraph1' } as unknown as TextStyleDeclaration,
2499
+ after.paragraph1.style,
2500
+ ) as Record<string, string>
2501
+ ).fontFamily,
2502
+ ).toBe('Nunito');
2503
+
2504
+ // A later role font edit reaches the content whose (role-equal) override B dropped…
2505
+ after.paragraph1.style.fontFamily = 'Aleo';
2506
+
2507
+ expect(
2508
+ (
2509
+ resolveTextStyle(
2510
+ contents[0].descriptionStyle as TextStyleDeclaration,
2511
+ after.paragraph1.style,
2512
+ ) as Record<string, string>
2513
+ ).fontFamily,
2514
+ ).toBe('Aleo');
2515
+ // …but not the Calibri block, whose literal survived (it never equalled the role).
2516
+ expect(
2517
+ (
2518
+ resolveTextStyle(
2519
+ contents[2].descriptionStyle as TextStyleDeclaration,
2520
+ after.paragraph1.style,
2521
+ ) as Record<string, string>
2522
+ ).fontFamily,
2523
+ ).toBe('Calibri');
2524
+ });
2525
+ });
2526
+
2527
+ describe('migration-22 · stripLiftRteDoc (C — baked → role var / literal)', () => {
2528
+ const role = { fontFamily: 'Lato', fontSize: '2em', lineHeight: '1.2' };
2529
+
2530
+ it('converts a baked attr that EQUALS the role to the role var token', () => {
2531
+ const doc = docOf([
2532
+ { text: 'Our ', attrs: { fontSize: '2em', color: 'red' } },
2533
+ { text: 'Mission', attrs: { fontSize: '2em', color: 'red' } },
2534
+ ]);
2535
+
2536
+ stripLiftRteDoc(doc, role);
2537
+
2538
+ expect(attrsOf(doc, 0)?.fontSize).toBe('var(--role-font-size)'); // referenced
2539
+ expect(attrsOf(doc, 1)?.fontSize).toBe('var(--role-font-size)');
2540
+ expect(attrsOf(doc, 0)?.color).toBe('red'); // colour untouched
2541
+ });
2542
+
2543
+ it('keeps a baked attr that DIFFERS from the role as a literal override', () => {
2544
+ const doc = docOf([
2545
+ { text: 'Our ', attrs: { fontSize: '3em' } },
2546
+ { text: 'Mission', attrs: { fontSize: '3em' } },
2547
+ ]);
2548
+
2549
+ stripLiftRteDoc(doc, role);
2550
+
2551
+ expect(attrsOf(doc, 0)?.fontSize).toBe('3em'); // kept literal (an override)
2552
+ });
2553
+
2554
+ it('reconciles per-run: role-equal → var, divergent → literal', () => {
2555
+ const doc = docOf([
2556
+ { text: 'Our ', attrs: { fontSize: '2em' } }, // equals role
2557
+ { text: 'Mission', attrs: { fontSize: '3em' } }, // one word differs
2558
+ ]);
2559
+
2560
+ stripLiftRteDoc(doc, role);
2561
+
2562
+ expect(attrsOf(doc, 0)?.fontSize).toBe('var(--role-font-size)');
2563
+ expect(attrsOf(doc, 1)?.fontSize).toBe('3em');
2564
+ });
2565
+
2566
+ it('leaves an already-referenced value untouched', () => {
2567
+ const doc = docOf([
2568
+ { text: 'hi', attrs: { fontSize: 'var(--role-font-size)' } },
2569
+ ]);
2570
+
2571
+ stripLiftRteDoc(doc, role);
2572
+
2573
+ expect(attrsOf(doc, 0)?.fontSize).toBe('var(--role-font-size)');
2574
+ });
2575
+
2576
+ it('fills a run missing a role-defined attr with the role ref (container applies nothing)', () => {
2577
+ // colour-only run: the role's typography must be referenced on the run itself, since the
2578
+ // container class no longer applies it.
2579
+ const doc = docOf([{ text: 'hi', attrs: { color: 'red' } }]);
2580
+
2581
+ stripLiftRteDoc(doc, role);
2582
+
2583
+ const attrs = attrsOf(doc, 0);
2584
+
2585
+ expect(attrs?.fontFamily).toBe('var(--role-font-family)'); // filled
2586
+ expect(attrs?.fontSize).toBe('var(--role-font-size)'); // filled
2587
+ expect(attrs?.lineHeight).toBe('var(--role-line-height)'); // filled
2588
+ expect(attrs?.letterSpacing).toBeUndefined(); // role has none → not filled
2589
+ expect(attrs?.color).toBe('red'); // colour untouched
2590
+ });
2591
+
2592
+ it('bakes a block-level typography override onto the runs as a literal (element applies nothing now)', () => {
2593
+ // colour-only run; the block (styleObj) overrides family to Inter (role is Lato).
2594
+ const doc = docOf([{ text: 'hi', attrs: { color: 'red' } }]);
2595
+
2596
+ stripLiftRteDoc(doc, role, { fontFamily: 'Inter' });
2597
+
2598
+ const attrs = attrsOf(doc, 0);
2599
+
2600
+ expect(attrs?.fontFamily).toBe('Inter'); // block override → literal on the run
2601
+ expect(attrs?.fontSize).toBe('var(--role-font-size)'); // matches role → ref
2602
+ expect(attrs?.lineHeight).toBe('var(--role-line-height)'); // matches role → ref
2603
+ });
2604
+
2605
+ it('keeps a per-run override even when the block also overrides (per-run wins)', () => {
2606
+ const doc = docOf([{ text: 'hi', attrs: { fontFamily: 'Georgia' } }]);
2607
+
2608
+ stripLiftRteDoc(doc, role, { fontFamily: 'Inter' });
2609
+
2610
+ // the run's own override wins over both the block override and the role
2611
+ expect(attrsOf(doc, 0)?.fontFamily).toBe('Georgia');
2612
+ });
2613
+
2614
+ it('never touches colour', () => {
2615
+ const doc = docOf([{ text: 'hi', attrs: { color: 'var(--x)' } }]);
2616
+
2617
+ stripLiftRteDoc(doc, role);
2618
+
2619
+ expect(attrsOf(doc, 0)?.color).toBe('var(--x)');
2620
+ });
2621
+
2622
+ it('skips a non-Tiptap value (legacy HTML string)', () => {
2623
+ expect(() => stripLiftRteDoc('<p>legacy</p>', role)).not.toThrow();
2624
+ });
2625
+ });
2626
+
2627
+ describe('migration-22 · foldSemanticFormattingMarks (C2 — marks → textStyle attrs)', () => {
2628
+ const markDoc = (runs: { text: string; marks?: string[] }[]) => ({
2629
+ type: 'doc',
2630
+ content: [
2631
+ {
2632
+ type: 'paragraph',
2633
+ content: runs.map((r) => ({
2634
+ type: 'text',
2635
+ text: r.text,
2636
+ marks: (r.marks ?? []).map((type) => ({ type })),
2637
+ })),
2638
+ },
2639
+ ],
2640
+ });
2641
+
2642
+ const firstRun = (doc: ReturnType<typeof markDoc>) =>
2643
+ doc.content[0].content[0] as {
2644
+ marks: { type: string; attrs?: Record<string, string> }[];
2645
+ };
2646
+ const attrsOf = (doc: ReturnType<typeof markDoc>) =>
2647
+ firstRun(doc).marks.find((m) => m.type === 'textStyle')?.attrs ?? {};
2648
+ const markTypes = (doc: ReturnType<typeof markDoc>) =>
2649
+ firstRun(doc).marks.map((m) => m.type);
2650
+
2651
+ it('folds a bold mark into a fontWeight literal and removes the mark (role not bold)', () => {
2652
+ const doc = markDoc([{ text: 'hi', marks: ['bold'] }]);
2653
+
2654
+ foldSemanticFormattingMarks(doc, { fontWeight: 'normal' });
2655
+
2656
+ expect(attrsOf(doc).fontWeight).toBe('bold'); // differs from role → literal override
2657
+ expect(markTypes(doc)).not.toContain('bold');
2658
+ });
2659
+
2660
+ it('re-links a bold mark to the role var when the role is bold', () => {
2661
+ const doc = markDoc([{ text: 'hi', marks: ['bold'] }]);
2662
+
2663
+ foldSemanticFormattingMarks(doc, { fontWeight: 'bold' });
2664
+
2665
+ expect(attrsOf(doc).fontWeight).toBe('var(--role-font-weight)');
2666
+ expect(markTypes(doc)).not.toContain('bold');
2667
+ });
2668
+
2669
+ it('composes underline + strike into one textDecoration attr and removes both marks', () => {
2670
+ const doc = markDoc([{ text: 'hi', marks: ['underline', 'strike'] }]);
2671
+
2672
+ foldSemanticFormattingMarks(doc, {});
2673
+
2674
+ expect(attrsOf(doc).textDecoration).toBe('underline line-through');
2675
+ expect(markTypes(doc)).not.toContain('underline');
2676
+ expect(markTypes(doc)).not.toContain('strike');
2677
+ });
2678
+
2679
+ it('leaves a run with no formatting marks untouched', () => {
2680
+ const doc = markDoc([
2681
+ {
2682
+ text: 'hi',
2683
+ marks: [], // will carry only a textStyle colour mark below
2684
+ },
2685
+ ]);
2686
+
2687
+ firstRun(doc).marks.push({ type: 'textStyle', attrs: { color: 'red' } });
2688
+
2689
+ foldSemanticFormattingMarks(doc, { fontWeight: 'bold' });
2690
+
2691
+ expect(attrsOf(doc)).toEqual({ color: 'red' }); // no weight added
2692
+ expect(markTypes(doc)).toEqual(['textStyle']);
2693
+ });
2694
+
2695
+ it('keeps an existing textStyle attr rather than overwriting it', () => {
2696
+ const doc = markDoc([{ text: 'hi', marks: ['bold'] }]);
2697
+
2698
+ firstRun(doc).marks.push({
2699
+ type: 'textStyle',
2700
+ attrs: { fontWeight: '900' },
2701
+ });
2702
+
2703
+ foldSemanticFormattingMarks(doc, { fontWeight: 'bold' });
2704
+
2705
+ expect(attrsOf(doc).fontWeight).toBe('900'); // pre-existing attr wins
2706
+ expect(markTypes(doc)).not.toContain('bold');
2707
+ });
2708
+ });
2709
+
2710
+ describe('migration-22 · pinLegacyRunWeight (C3 — not-bold legacy weight override)', () => {
2711
+ const runDoc = (attrs?: Record<string, string>) => ({
2712
+ type: 'doc',
2713
+ content: [
2714
+ {
2715
+ type: 'paragraph',
2716
+ content: [
2717
+ {
2718
+ type: 'text',
2719
+ text: 'hi',
2720
+ marks: attrs ? [{ type: 'textStyle', attrs }] : [],
2721
+ },
2722
+ ],
2723
+ },
2724
+ ],
2725
+ });
2726
+ const weightOf = (doc: ReturnType<typeof runDoc>) =>
2727
+ (
2728
+ doc.content[0].content[0].marks.find((m) => m.type === 'textStyle') as
2729
+ | { attrs?: Record<string, string> }
2730
+ | undefined
2731
+ )?.attrs?.fontWeight;
2732
+
2733
+ it('pins a not-bold legacy block to a `normal` literal under a bold heading tier', () => {
2734
+ const doc = runDoc();
2735
+
2736
+ // block weight resolved to `normal` by job B; the reconnected tier is bold.
2737
+ pinLegacyRunWeight(doc, 'bold', 'normal');
2738
+
2739
+ expect(weightOf(doc)).toBe('normal'); // literal override beats the tier's bold at render
2740
+ });
2741
+
2742
+ it('no-ops when block weight equals the tier (render var-fill follows the role)', () => {
2743
+ const doc = runDoc();
2744
+
2745
+ pinLegacyRunWeight(doc, 'bold', 'bold');
2746
+
2747
+ expect(weightOf(doc)).toBeUndefined(); // left bare → the fill supplies var(--role-font-weight)
2748
+ });
2749
+
2750
+ it('no-ops when the block carries no weight (a non-legacy/canonical block)', () => {
2751
+ const doc = runDoc();
2752
+
2753
+ pinLegacyRunWeight(doc, 'bold', undefined);
2754
+
2755
+ expect(weightOf(doc)).toBeUndefined();
2756
+ });
2757
+
2758
+ it('leaves a run that already carries a weight (a folded bold mark) untouched', () => {
2759
+ const doc = runDoc({ fontWeight: 'bold' });
2760
+
2761
+ pinLegacyRunWeight(doc, 'bold', 'normal');
2762
+
2763
+ expect(weightOf(doc)).toBe('bold'); // per-run weight is authoritative
2764
+ });
2765
+
2766
+ it('leaves a folded role-var weight untouched (bold + bare runs coexisting in one block)', () => {
2767
+ // a bold run under a bold tier folds to the role var — the pin must not overwrite it with the
2768
+ // block’s `normal`, or a mixed heading block would lose its bold runs.
2769
+ const doc = runDoc({ fontWeight: 'var(--role-font-weight)' });
2770
+
2771
+ pinLegacyRunWeight(doc, 'bold', 'normal');
2772
+
2773
+ expect(weightOf(doc)).toBe('var(--role-font-weight)');
2774
+ });
2775
+
2776
+ it('pins a block-bold override as a literal under a normal tier', () => {
2777
+ const doc = runDoc();
2778
+
2779
+ pinLegacyRunWeight(doc, 'normal', 'bold');
2780
+
2781
+ expect(weightOf(doc)).toBe('bold');
2782
+ });
2783
+
2784
+ it('skips a non-Tiptap value (legacy HTML string)', () => {
2785
+ expect(() =>
2786
+ pinLegacyRunWeight('<p>legacy</p>', 'bold', 'normal'),
2787
+ ).not.toThrow();
2788
+ });
2789
+ });
2790
+
2791
+ describe('migration-22 · bakeElementColours (D — freeze resolved colour)', () => {
2792
+ const optionsWithBaseCoat = (baseCoat: string): TextStyleOptions => {
2793
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2794
+
2795
+ setRoleColor(options.paragraph1.style, baseCoat);
2796
+
2797
+ return options;
2798
+ };
2799
+
2800
+ it('bakes the base coat onto a non-RTE field that has no colour', () => {
2801
+ const options = optionsWithBaseCoat('#123456');
2802
+ // headingStyle is a shapes per-item style — non-RTE, so baked onto style.color.
2803
+ const content = {
2804
+ type: 'shapes-section',
2805
+ headingStyle: { textStyleType: 'heading2' },
2806
+ };
2807
+
2808
+ bakeElementColours(modelWith(content, options), options);
2809
+
2810
+ expect((content.headingStyle as { color?: string }).color).toBe('#123456');
2811
+ });
2812
+
2813
+ it('prefers the role colour over the base coat', () => {
2814
+ const options = optionsWithBaseCoat('#000000');
2815
+
2816
+ setRoleColor(options.heading2.style, '#ff0000');
2817
+
2818
+ const content = {
2819
+ type: 'shapes-section',
2820
+ headingStyle: { textStyleType: 'heading2' },
2821
+ };
2822
+
2823
+ bakeElementColours(modelWith(content, options), options);
2824
+
2825
+ expect((content.headingStyle as { color?: string }).color).toBe('#ff0000');
2826
+ });
2827
+
2828
+ it('keeps a no-role field on the original base coat after reconnect assigns a heading role', async () => {
2829
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
2830
+
2831
+ options.titles.style.fontSize = '2.25em';
2832
+ setRoleColor(options.titles.style, '#ff0000');
2833
+ setRoleColor(options.paragraphs.style, '#000000');
2834
+
2835
+ const content = {
2836
+ type: 'banner-section',
2837
+ title: 'Unlinked heading',
2838
+ titleStyle: { fontSize: '2.25em' },
2839
+ };
2840
+ const model = await runMigration22(
2841
+ siteWith({ pages: [{ contents: [content] }] }, options),
2842
+ );
2843
+ const migrated = model.pages[0].contents[0] as unknown as {
2844
+ title: unknown;
2845
+ titleStyle: { textStyleType?: string; color?: string };
2846
+ };
2847
+
2848
+ expect(migrated.titleStyle.textStyleType).toBe('heading4');
2849
+ expect(runMarkColor(migrated.title)).toBe('#000000');
2850
+ });
2851
+
2852
+ it('leaves an already-coloured field untouched', () => {
2853
+ const options = optionsWithBaseCoat('#000000');
2854
+ const content = {
2855
+ type: 'shapes-section',
2856
+ headingStyle: { textStyleType: 'heading2', color: '#abcdef' },
2857
+ };
2858
+
2859
+ bakeElementColours(modelWith(content, options), options);
2860
+
2861
+ expect(content.headingStyle.color).toBe('#abcdef');
2862
+ });
2863
+
2864
+ it('never bakes onto buttonTextStyle (button label colour comes from buttonStyles)', () => {
2865
+ const options = optionsWithBaseCoat('#000000');
2866
+ const content = {
2867
+ type: 'banner-section',
2868
+ buttonTextStyle: { textStyleType: 'button' },
2869
+ };
2870
+
2871
+ bakeElementColours(modelWith(content, options), options);
2872
+
2873
+ expect(
2874
+ (content.buttonTextStyle as { color?: string }).color,
2875
+ ).toBeUndefined();
2876
+ });
2877
+
2878
+ it('fills an uncoloured RTE run with the resolved colour (into the mark)', () => {
2879
+ const options = optionsWithBaseCoat('#00aa00');
2880
+ const content = {
2881
+ type: 'banner-section',
2882
+ title: docOf([{ text: 'Hello' }]), // no attrs → uncoloured run
2883
+ titleStyle: { textStyleType: 'heading1' },
2884
+ };
2885
+
2886
+ bakeElementColours(modelWith(content, options), options);
2887
+
2888
+ expect(runMarkColor(content.title)).toBe('#00aa00');
2889
+ });
2890
+
2891
+ it('leaves an already-coloured RTE run alone (fill-only)', () => {
2892
+ const options = optionsWithBaseCoat('#000000');
2893
+ const content = {
2894
+ type: 'banner-section',
2895
+ title: docOf([{ text: 'Hello', attrs: { color: '#eeeeee' } }]),
2896
+ titleStyle: { textStyleType: 'heading1' },
2897
+ };
2898
+
2899
+ bakeElementColours(modelWith(content, options), options);
2900
+
2901
+ expect(runMarkColor(content.title)).toBe('#eeeeee');
2902
+ });
2903
+
2904
+ it('bakes shop title colour into the run mark (shop is RTE, BP-5969)', () => {
2905
+ const options = optionsWithBaseCoat('#654321');
2906
+ // shop is RTE now: B1 already made the title a doc, so D bakes colour into the mark.
2907
+ const content = {
2908
+ type: 'shop-section',
2909
+ title: docOf([{ text: 'My shop' }]),
2910
+ titleStyle: { textStyleType: 'heading1' },
2911
+ };
2912
+
2913
+ bakeElementColours(modelWith(content, options), options);
2914
+
2915
+ expect(runMarkColor(content.title)).toBe('#654321');
2916
+ });
2917
+
2918
+ it('bakes onto the style object when a normally-RTE field is a plain string (old site, no mark)', () => {
2919
+ const options = optionsWithBaseCoat('#00aa00');
2920
+ // banner title is normally a doc, but an un-migrated old site left it plain text — there
2921
+ // is no run mark to hold colour, so D must bake onto the style object like a non-RTE field.
2922
+ const content = {
2923
+ type: 'banner-section',
2924
+ title: 'Untouched legacy title',
2925
+ titleStyle: { textStyleType: 'heading1' },
2926
+ };
2927
+
2928
+ bakeElementColours(modelWith(content, options), options);
2929
+
2930
+ expect((content.titleStyle as { color?: string }).color).toBe('#00aa00');
2931
+ });
2932
+
2933
+ it('D then G leave a plain-string RTE field with a resolvable colour (totality gap closed)', () => {
2934
+ const options = optionsWithBaseCoat('#00aa00');
2935
+ const content = {
2936
+ type: 'banner-section',
2937
+ title: 'Untouched legacy title', // plain string → no mark
2938
+ titleStyle: { textStyleType: 'heading1' },
2939
+ };
2940
+ const model = modelWith(content, options);
2941
+
2942
+ bakeElementColours(model, options); // D bakes the resolved colour onto the object
2943
+ stripRteStyleColours(model); // G must NOT strip it — there's no mark to hold it
2944
+
2945
+ expect((content.titleStyle as { color?: string }).color).toBe('#00aa00');
2946
+ });
2947
+
2948
+ it('bakes the footer copyright colour (it lives outside page.contents)', () => {
2949
+ const options = optionsWithBaseCoat('#334455');
2950
+ const model = modelWith({ type: 'shapes-section' }, options);
2951
+
2952
+ // Footer copyright: role-linked, no colour of its own — must be frozen or it loses its
2953
+ // colour once E clears the role and the base coat is removed.
2954
+ (model as unknown as Record<string, unknown>).pageFooterDesktop = {
2955
+ copyrightStyle: { textStyleType: 'paragraph1', textAlign: 'center' },
2956
+ };
2957
+ (model as unknown as Record<string, unknown>).pageFooterMobile = {
2958
+ copyrightStyle: { textStyleType: 'paragraph1', textAlign: 'center' },
2959
+ };
2960
+
2961
+ bakeElementColours(model, options);
2962
+
2963
+ const footerColour = (m: BrandPageModel, key: string): string | undefined =>
2964
+ (m as unknown as Record<string, { copyrightStyle: { color?: string } }>)[
2965
+ key
2966
+ ].copyrightStyle.color;
2967
+
2968
+ expect(footerColour(model, 'pageFooterDesktop')).toBe('#334455');
2969
+ expect(footerColour(model, 'pageFooterMobile')).toBe('#334455');
2970
+ });
2971
+
2972
+ // FB-1 (QA scope): a plain text block on a dark section band freezes to the dark base coat.
2973
+ // bakeElementColours resolves colour from style → containerDefaults → role → base coat, and
2974
+ // NEVER reads the block's own section background. Only the container-bound types
2975
+ // (mailing-list / request-payment / support-me / contact-form) re-contrast against their
2976
+ // surface, so a plain text block on a dark band is baked dark = black-on-dark. This test
2977
+ // characterises the current behaviour; if migration ever contrasts plain text blocks, flip it.
2978
+ it('FB-1 — freezes the dark base coat onto a text block on a dark band (no band contrast)', () => {
2979
+ const options = optionsWithBaseCoat('#000000');
2980
+ const content = {
2981
+ type: 'banner-section',
2982
+ backgroundStyle: { backgroundColor: '#0a0a0a' }, // dark band — not read by the bake
2983
+ title: 'On a dark band', // plain string → colour bakes onto the style object
2984
+ titleStyle: { textStyleType: 'paragraph1' },
2985
+ };
2986
+
2987
+ bakeElementColours(modelWith(content, options), options);
2988
+
2989
+ expect((content.titleStyle as { color?: string }).color).toBe('#000000');
2990
+ });
2991
+
2992
+ describe('social-links icon fill (always stored, never read from the title)', () => {
2993
+ const socialLinks = (
2994
+ iconFillStyles?: BrandPageCssStyle,
2995
+ ): Record<string, unknown> => ({
2996
+ type: 'social-links',
2997
+ title: docOf([{ text: 'Follow us', attrs: { color: '#0000ff' } }]),
2998
+ titleStyle: { textStyleType: 'heading4' },
2999
+ ...(iconFillStyles ? { iconFillStyles } : {}),
3000
+ });
3001
+
3002
+ const iconFill = (content: Record<string, unknown>): string | undefined =>
3003
+ (content.iconFillStyles as BrandPageCssStyle | undefined)?.fill;
3004
+
3005
+ it('freezes the base coat the icons painted onto an unset fill', () => {
3006
+ const options = optionsWithBaseCoat('#123456');
3007
+ const content = socialLinks({});
3008
+
3009
+ bakeElementColours(modelWith(content, options), options);
3010
+
3011
+ expect(iconFill(content)).toBe('#123456');
3012
+ });
3013
+
3014
+ it('bakes a section with no iconFillStyles at all', () => {
3015
+ const options = optionsWithBaseCoat('#123456');
3016
+ const content = socialLinks();
3017
+
3018
+ bakeElementColours(modelWith(content, options), options);
3019
+
3020
+ expect(iconFill(content)).toBe('#123456');
3021
+ });
3022
+
3023
+ it('leaves an explicit icon fill untouched', () => {
3024
+ const options = optionsWithBaseCoat('#123456');
3025
+ const content = socialLinks({ fill: '#abcdef' });
3026
+
3027
+ bakeElementColours(modelWith(content, options), options);
3028
+
3029
+ expect(iconFill(content)).toBe('#abcdef');
3030
+ });
3031
+
3032
+ it('bakes over a stored empty-string fill, same as an absent one', () => {
3033
+ const options = optionsWithBaseCoat('#123456');
3034
+ const content = socialLinks({ fill: '' });
3035
+
3036
+ bakeElementColours(modelWith(content, options), options);
3037
+
3038
+ expect(iconFill(content)).toBe('#123456');
3039
+ });
3040
+
3041
+ // migration-22.ts's docblock names this job D's one exception (ADR-0020). Run through the
3042
+ // real pipeline (`runMigration22`), not hand-strung internal functions, so what composing
3043
+ // A and D actually does — not each in isolation — is what's proved.
3044
+ it('the v21 → A → D chain: no legacy paragraphs colour means no base coat for D to freeze', async () => {
3045
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
3046
+
3047
+ expect(roleColorOf(options.paragraphs.style)).toBeUndefined(); // the v21 fixture's premise
3048
+
3049
+ const content = socialLinks({});
3050
+ const model = await runMigration22(
3051
+ siteWith({ pages: [{ contents: [content] }] }, options),
3052
+ );
3053
+ const migrated = model.pages[0].contents[0] as unknown as Record<
3054
+ string,
3055
+ unknown
3056
+ >;
3057
+
3058
+ // No base coat for D to freeze → the icon gets the same NEW contrast colour a fresh
3059
+ // title would, not the title's own run mark colour (#0000ff).
3060
+ expect(iconFill(migrated)).toBeTruthy();
3061
+ expect(iconFill(migrated)).not.toBe('#0000ff');
3062
+ });
3063
+
3064
+ it('the v21 → A → D chain: a legacy paragraphs colour becomes the base coat D freezes', async () => {
3065
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
3066
+
3067
+ setRoleColor(options.paragraphs.style, '#123456');
3068
+
3069
+ const content = socialLinks({});
3070
+ const model = await runMigration22(
3071
+ siteWith({ pages: [{ contents: [content] }] }, options),
3072
+ );
3073
+ const migrated = model.pages[0].contents[0] as unknown as Record<
3074
+ string,
3075
+ unknown
3076
+ >;
3077
+
3078
+ // A seeds paragraph1 from the legacy `paragraphs` colour; D freezes THAT base coat,
3079
+ // not a fresh contrast colour.
3080
+ expect(iconFill(migrated)).toBe('#123456');
3081
+ });
3082
+
3083
+ it('with no base coat, bakes the contrast colour a new title gets — not the title mark', () => {
3084
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
3085
+ const content = socialLinks({});
3086
+ const model = modelWith(content, options);
3087
+ const newTitle = {
3088
+ type: 'social-links',
3089
+ titleStyle: { textStyleType: 'heading4' },
3090
+ } as unknown as Content;
3091
+
3092
+ bakeElementColours(model, options);
3093
+ applyTextContrast(newTitle, model.styles, model.brandPageType);
3094
+
3095
+ const contrastColour = (
3096
+ newTitle as unknown as { titleStyle: { color?: string } }
3097
+ ).titleStyle.color;
3098
+
3099
+ expect(contrastColour).toBeTruthy();
3100
+ expect(iconFill(content)).toBe(contrastColour);
3101
+ expect(iconFill(content)).not.toBe('#0000ff');
3102
+ });
3103
+ });
3104
+ });
3105
+
3106
+ describe('migration-22 · convertPlainRteFields (B1 — plain text → Tiptap doc)', () => {
3107
+ it('converts a plain-string RTE field to a Tiptap doc', async () => {
3108
+ const content = {
3109
+ type: 'banner-section',
3110
+ title: 'Hello world',
3111
+ titleStyle: { textStyleType: 'heading1' },
3112
+ };
3113
+
3114
+ await convertPlainRteFields(
3115
+ modelWithContent(content),
3116
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
3117
+ );
3118
+
3119
+ expect(isTiptapDocument(content.title)).toBe(true);
3120
+ expect(JSON.stringify(content.title)).toContain('Hello world');
3121
+ });
3122
+
3123
+ it('stamps textAlign from the style object onto the block node', async () => {
3124
+ const content = {
3125
+ type: 'banner-section',
3126
+ title: 'Centered',
3127
+ titleStyle: { textStyleType: 'heading1', textAlign: 'center' },
3128
+ };
3129
+
3130
+ await convertPlainRteFields(
3131
+ modelWithContent(content),
3132
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
3133
+ );
3134
+
3135
+ const doc = content.title as unknown as {
3136
+ content: { attrs?: { textAlign?: string } }[];
3137
+ };
3138
+
3139
+ expect(doc.content[0].attrs?.textAlign).toBe('center');
3140
+ });
3141
+
3142
+ it('bakes a not-bold legacy weight as a normal literal when converting under a bold tier (C3)', async () => {
3143
+ const content = {
3144
+ type: 'banner-section',
3145
+ title: 'Plain heading',
3146
+ // the post-reconnect state: linked to a bold heading tier, weight materialised to normal.
3147
+ titleStyle: { textStyleType: 'heading1', fontWeight: 'normal' },
3148
+ };
3149
+
3150
+ await convertPlainRteFields(
3151
+ modelWithContent(content),
3152
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
3153
+ );
3154
+
3155
+ const run = (
3156
+ content.title as unknown as {
3157
+ content: {
3158
+ content: {
3159
+ marks?: { type: string; attrs?: Record<string, string> }[];
3160
+ }[];
3161
+ }[];
3162
+ }
3163
+ ).content[0].content[0];
3164
+ const weight = run.marks?.find((m) => m.type === 'textStyle')?.attrs
3165
+ ?.fontWeight;
3166
+
3167
+ expect(weight).toBe('normal'); // literal override, NOT var(--role-font-weight) = bold
3168
+ });
3169
+
3170
+ // S4 — this job bakes the block override + role typography onto the run with migration-22's
3171
+ // local resolver snapshot. It preserves D3's current effective-role rule: a stored neutral is
3172
+ // literal only when it differs from what the role actually resolves to, not merely its raw value.
3173
+ it('bakes a neutral fontStyle/textDecoration/letterSpacing as a literal under a role that sets the opposite (D3)', async () => {
3174
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
3175
+
3176
+ options.heading1.style.fontStyle = 'italic';
3177
+ options.heading1.style.textDecoration = 'underline';
3178
+ options.heading1.style.letterSpacing = '0.5px';
3179
+
3180
+ const content = {
3181
+ type: 'banner-section',
3182
+ title: 'Plain heading',
3183
+ titleStyle: {
3184
+ textStyleType: 'heading1',
3185
+ fontStyle: 'normal',
3186
+ textDecoration: 'none',
3187
+ letterSpacing: 'normal',
3188
+ },
3189
+ };
3190
+
3191
+ await convertPlainRteFields(modelWithContent(content), options);
3192
+
3193
+ const run = (
3194
+ content.title as unknown as {
3195
+ content: {
3196
+ content: {
3197
+ marks?: { type: string; attrs?: Record<string, string> }[];
3198
+ }[];
3199
+ }[];
3200
+ }
3201
+ ).content[0].content[0];
3202
+ const attrs = run.marks?.find((m) => m.type === 'textStyle')?.attrs;
3203
+
3204
+ expect(attrs?.fontStyle).toBe('normal'); // literal, NOT var(--role-font-style) = italic
3205
+ expect(attrs?.textDecoration).toBe('none'); // literal, NOT var(--role-text-decoration)
3206
+ expect(attrs?.letterSpacing).toBe('normal'); // literal, NOT var(--role-letter-spacing)
3207
+ });
3208
+
3209
+ it('uses the migration-local typography snapshot, not the mutable render helper', async () => {
3210
+ const runtimeFill = vi
3211
+ .spyOn(textRoleCss, 'fillRteRunTypography')
3212
+ .mockImplementation(() => {
3213
+ throw new Error('runtime fill must not affect migration-22');
3214
+ });
3215
+ const runtimeWeightPin = vi
3216
+ .spyOn(rteRoleLift, 'pinLegacyRunWeight')
3217
+ .mockImplementation(() => {
3218
+ throw new Error('runtime C3 pin must not affect migration-22');
3219
+ });
3220
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
3221
+ const content = {
3222
+ type: 'banner-section',
3223
+ title: 'Frozen output',
3224
+ titleStyle: {
3225
+ textStyleType: 'heading1',
3226
+ fontStyle: 'normal',
3227
+ },
3228
+ };
3229
+
3230
+ try {
3231
+ await convertPlainRteFields(modelWithContent(content), options);
3232
+
3233
+ const attrs = (
3234
+ content.title as unknown as {
3235
+ content: {
3236
+ content: {
3237
+ marks: { type: string; attrs: Record<string, string> }[];
3238
+ }[];
3239
+ }[];
3240
+ }
3241
+ ).content[0].content[0].marks.find(
3242
+ (mark) => mark.type === 'textStyle',
3243
+ )?.attrs;
3244
+
3245
+ expect(runtimeFill).not.toHaveBeenCalled();
3246
+ expect(runtimeWeightPin).not.toHaveBeenCalled();
3247
+ expect(attrs?.fontStyle).toBe('var(--role-font-style)');
3248
+ } finally {
3249
+ runtimeFill.mockRestore();
3250
+ runtimeWeightPin.mockRestore();
3251
+ }
3252
+ });
3253
+
3254
+ it('converts shop title (plain string) to a Tiptap doc — RTE restored (BP-5969)', async () => {
3255
+ const content = {
3256
+ type: 'shop-section',
3257
+ title: 'My shop',
3258
+ titleStyle: { textStyleType: 'heading1' },
3259
+ };
3260
+
3261
+ await convertPlainRteFields(
3262
+ modelWithContent(content),
3263
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
3264
+ );
3265
+
3266
+ expect(isTiptapDocument(content.title)).toBe(true);
3267
+ });
3268
+
3269
+ it('leaves an existing Tiptap doc untouched (no reconversion)', async () => {
3270
+ const doc = docOf([{ text: 'Rich' }]);
3271
+ const content = {
3272
+ type: 'banner-section',
3273
+ title: doc,
3274
+ titleStyle: { textStyleType: 'heading1' },
3275
+ };
3276
+
3277
+ await convertPlainRteFields(
3278
+ modelWithContent(content),
3279
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
3280
+ );
3281
+
3282
+ expect(content.title).toBe(doc); // same reference — untouched
3283
+ });
3284
+
3285
+ it('converts a plain-string textItems[].text to a Tiptap doc', async () => {
3286
+ const content = {
3287
+ type: 'text',
3288
+ textItems: [
3289
+ { text: 'plain item', textStyle: { textStyleType: 'paragraph1' } },
3290
+ ],
3291
+ };
3292
+
3293
+ await convertPlainRteFields(
3294
+ modelWithContent(content),
3295
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
3296
+ );
3297
+
3298
+ expect(isTiptapDocument(content.textItems[0].text)).toBe(true);
3299
+ expect(JSON.stringify(content.textItems[0].text)).toContain('plain item');
3300
+ });
3301
+
3302
+ it('leaves an empty string alone', async () => {
3303
+ const content = {
3304
+ type: 'banner-section',
3305
+ title: '',
3306
+ titleStyle: { textStyleType: 'heading1' },
3307
+ };
3308
+
3309
+ await convertPlainRteFields(
3310
+ modelWithContent(content),
3311
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
3312
+ );
3313
+
3314
+ expect(content.title).toBe('');
3315
+ });
3316
+
3317
+ it('converts percent-encoded newlines (%0A) into hard breaks, not a literal run', async () => {
3318
+ const content = {
3319
+ type: 'banner-section',
3320
+ title: 'Line one%0ALine two',
3321
+ titleStyle: { textStyleType: 'heading1' },
3322
+ };
3323
+
3324
+ await convertPlainRteFields(
3325
+ modelWithContent(content),
3326
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
3327
+ );
3328
+
3329
+ expect(isTiptapDocument(content.title)).toBe(true);
3330
+
3331
+ const runs = (
3332
+ content.title as unknown as {
3333
+ content: { content: { type: string; text?: string }[] }[];
3334
+ }
3335
+ ).content[0].content;
3336
+
3337
+ // The line break survives as text + hardBreak + text — never a literal "%0A".
3338
+ expect(runs.some((n) => n.type === 'hardBreak')).toBe(true);
3339
+ expect(JSON.stringify(content.title)).not.toContain('%0A');
3340
+ expect(runs.filter((n) => n.type === 'text').map((n) => n.text)).toEqual([
3341
+ 'Line one',
3342
+ 'Line two',
3343
+ ]);
3344
+ });
3345
+
3346
+ // Regression (fa0e0db3 "Lift off"): a plain-text field carrying a block-level typography
3347
+ // override never passed through job C (still a string), so the converted run must be baked
3348
+ // here — else it holds colour only and the maker toolbar falls back to the role, showing the
3349
+ // wrong font/size while the renderer still paints the override (editor ≠ render).
3350
+ it('bakes the block-level typography override onto the converted run (role var or literal)', async () => {
3351
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
3352
+ const role = options.heading2.style;
3353
+
3354
+ role.fontFamily = 'Lato';
3355
+ role.fontSize = '5.25em';
3356
+ role.fontWeight = 'bold';
3357
+
3358
+ const content = {
3359
+ type: 'banner-section',
3360
+ title: 'Lift off',
3361
+ titleStyle: {
3362
+ textStyleType: 'heading2',
3363
+ fontFamily: 'Playwrite AT', // block override ≠ role → literal
3364
+ fontSize: '5.75em', // block override ≠ role → literal
3365
+ fontWeight: 'bold', // equals role → role var (follows role edits)
3366
+ fontStyle: 'normal', // inert neutral → role var
3367
+ textDecoration: 'none', // inert neutral → role var
3368
+ },
3369
+ };
3370
+
3371
+ await convertPlainRteFields(modelWithContent(content), options);
3372
+
3373
+ const attrs = (
3374
+ content.title as unknown as {
3375
+ content: {
3376
+ content: {
3377
+ marks: { type: string; attrs: Record<string, string> }[];
3378
+ }[];
3379
+ }[];
3380
+ }
3381
+ ).content[0].content[0].marks.find((m) => m.type === 'textStyle')?.attrs;
3382
+
3383
+ // Genuine overrides ride through as raw literals — the toolbar reads the real font + size.
3384
+ expect(attrs?.fontFamily).toBe('Playwrite AT');
3385
+ expect(attrs?.fontSize).toBe('5.75em');
3386
+ // Role-equal / inert-neutral attrs follow the role via its var (a role edit repaints them).
3387
+ expect(attrs?.fontWeight).toBe('var(--role-font-weight)');
3388
+ expect(attrs?.fontStyle).toBe('var(--role-font-style)');
3389
+ expect(attrs?.textDecoration).toBe('var(--role-text-decoration)');
3390
+ });
3391
+ });
3392
+
3393
+ describe('migration-22 · clearRoleColours (E — retire role colour)', () => {
3394
+ it('clears colour from every role definition', () => {
3395
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
3396
+
3397
+ setRoleColor(options.heading1.style, '#ff0000');
3398
+ setRoleColor(options.paragraph1.style, '#00ff00');
3399
+
3400
+ clearRoleColours(options);
3401
+
3402
+ expect(roleColorOf(options.heading1.style)).toBeUndefined();
3403
+ expect(roleColorOf(options.paragraph1.style)).toBeUndefined();
3404
+ // typography is untouched
3405
+ expect(options.heading1.style.fontSize).toBeDefined();
3406
+ });
3407
+ });
3408
+
3409
+ describe('migration-22 · stripRteStyleColours (G — colour-decouple RTE style objects)', () => {
3410
+ it('removes ONLY colour from an RTE-paired field (typography kept) and leaves non-RTE fields untouched', () => {
3411
+ const content = {
3412
+ type: 'shapes-section',
3413
+ // titleStyle/descriptionStyle are RTE-paired on shapes → colour removed, typography kept
3414
+ titleStyle: {
3415
+ textStyleType: 'heading1',
3416
+ color: '#ff0000',
3417
+ fontSize: '2em',
3418
+ textAlign: 'center',
3419
+ applyAutoScale: true,
3420
+ },
3421
+ // headingStyle is a per-item style → NOT RTE → left fully intact (colour included)
3422
+ headingStyle: {
3423
+ textStyleType: 'heading2',
3424
+ color: '#0000ff',
3425
+ fontSize: '1em',
3426
+ },
3427
+ };
3428
+
3429
+ stripRteStyleColours(modelWithContent(content));
3430
+
3431
+ // colour gone; typography/textAlign/applyAutoScale (a real block override) preserved.
3432
+ expect(content.titleStyle).toEqual({
3433
+ textStyleType: 'heading1',
3434
+ fontSize: '2em',
3435
+ textAlign: 'center',
3436
+ applyAutoScale: true,
3437
+ });
3438
+ expect(content.headingStyle).toEqual({
3439
+ textStyleType: 'heading2',
3440
+ color: '#0000ff',
3441
+ fontSize: '1em',
3442
+ });
3443
+ });
3444
+
3445
+ it('strips shop titleStyle colour once it is doc-backed (shop is RTE, BP-5969)', () => {
3446
+ const content = {
3447
+ type: 'shop-section',
3448
+ // colour now lives in the run mark, so G removes it from the style object; typography stays.
3449
+ title: docOf([{ text: 'My shop', attrs: { color: '#ff0000' } }]),
3450
+ titleStyle: {
3451
+ textStyleType: 'heading1',
3452
+ color: '#ff0000',
3453
+ fontSize: '2em',
3454
+ },
3455
+ };
3456
+
3457
+ stripRteStyleColours(modelWithContent(content));
3458
+
3459
+ expect(content.titleStyle).toEqual({
3460
+ textStyleType: 'heading1',
3461
+ fontSize: '2em',
3462
+ });
3463
+ });
3464
+
3465
+ it('keeps colour on a plain-string RTE field (no mark) but strips a doc-backed sibling', () => {
3466
+ const content = {
3467
+ type: 'banner-section',
3468
+ // plain string → colour lives on the object (its only home) → must survive G
3469
+ title: 'Legacy plain title',
3470
+ titleStyle: {
3471
+ textStyleType: 'heading1',
3472
+ color: '#ff0000',
3473
+ fontSize: '2em',
3474
+ },
3475
+ // real doc → colour lives in the mark → style-object colour is stripped
3476
+ description: docOf([{ text: 'Rich', attrs: { color: '#00ff00' } }]),
3477
+ descriptionStyle: {
3478
+ textStyleType: 'paragraph1',
3479
+ color: '#00ff00',
3480
+ fontSize: '1em',
3481
+ },
3482
+ };
3483
+
3484
+ stripRteStyleColours(modelWithContent(content));
3485
+
3486
+ expect(content.titleStyle).toEqual({
3487
+ textStyleType: 'heading1',
3488
+ color: '#ff0000',
3489
+ fontSize: '2em',
3490
+ });
3491
+ expect(content.descriptionStyle).toEqual({
3492
+ textStyleType: 'paragraph1',
3493
+ fontSize: '1em',
3494
+ });
3495
+ });
3496
+
3497
+ it('keeps colour on a plain-string textItems[].text but strips a doc-backed one', () => {
3498
+ const content = {
3499
+ type: 'text',
3500
+ textItems: [
3501
+ {
3502
+ text: 'legacy plain item',
3503
+ textStyle: { textStyleType: 'paragraph1', color: '#111111' },
3504
+ },
3505
+ {
3506
+ text: docOf([{ text: 'rich item', attrs: { color: '#222222' } }]),
3507
+ textStyle: { textStyleType: 'paragraph2', color: '#222222' },
3508
+ },
3509
+ ],
3510
+ };
3511
+
3512
+ stripRteStyleColours(modelWithContent(content));
3513
+
3514
+ expect(content.textItems[0].textStyle).toEqual({
3515
+ textStyleType: 'paragraph1',
3516
+ color: '#111111',
3517
+ });
3518
+ expect(content.textItems[1].textStyle).toEqual({
3519
+ textStyleType: 'paragraph2',
3520
+ });
3521
+ });
3522
+
3523
+ it('removes colour from the text section textStyle and each textItems[].textStyle (typography kept)', () => {
3524
+ const content = {
3525
+ type: 'text',
3526
+ textStyle: {
3527
+ textStyleType: 'paragraph1',
3528
+ color: '#ff0000',
3529
+ fontSize: '1em',
3530
+ },
3531
+ textItems: [
3532
+ {
3533
+ textStyle: {
3534
+ textStyleType: 'paragraph2',
3535
+ color: '#00ff00',
3536
+ applyAutoScale: false,
3537
+ },
3538
+ },
3539
+ ],
3540
+ };
3541
+
3542
+ stripRteStyleColours(modelWithContent(content));
3543
+
3544
+ expect(content.textStyle).toEqual({
3545
+ textStyleType: 'paragraph1',
3546
+ fontSize: '1em',
3547
+ });
3548
+ expect(content.textItems[0].textStyle).toEqual({
3549
+ textStyleType: 'paragraph2',
3550
+ applyAutoScale: false,
3551
+ });
3552
+ });
3553
+ });
3554
+
3555
+ // The version gate owns "runs once" (v→22), so this is hygiene, not a correctness gate
3556
+ // (legacy-migrations rule): a re-run over already-converted output must be a no-op.
3557
+ describe('migration-22 · migration22 (idempotency hygiene)', () => {
3558
+ const legacyModel = (): BrandPageModel =>
3559
+ ({
3560
+ pages: [
3561
+ {
3562
+ contents: [
3563
+ {
3564
+ type: 'banner-section',
3565
+ title: 'Hello',
3566
+ titleStyle: { textStyleType: 'titles' },
3567
+ },
3568
+ ],
3569
+ },
3570
+ ],
3571
+ styles: {
3572
+ textStyles: {
3573
+ textStyleOptions: TextStyles.GetDefaultTextStyle().textStyleOptions,
3574
+ },
3575
+ buttonStyles: { style: {} },
3576
+ colors: [],
3577
+ },
3578
+ brandPageType: 'default',
3579
+ }) as unknown as BrandPageModel;
3580
+
3581
+ it('a second run over converted output changes nothing', async () => {
3582
+ const once = await migration22(
3583
+ { brandPageModel: legacyModel() } as MigrationData,
3584
+ {} as MigrationOptions,
3585
+ );
3586
+ const snapshot = JSON.parse(JSON.stringify(once.brandPageModel));
3587
+
3588
+ const twice = await migration22(
3589
+ {
3590
+ brandPageModel: JSON.parse(JSON.stringify(once.brandPageModel)),
3591
+ } as MigrationData,
3592
+ {} as MigrationOptions,
3593
+ );
3594
+
3595
+ expect(twice.brandPageModel).toEqual(snapshot);
3596
+ });
3597
+ });
3598
+
3599
+ // Production drew the payment success message with the description's style (`Payment.vue`). The
3600
+ // upgrade gives it its own, copied from the reconnected description, so it draws the same.
3601
+ describe('migration-22 · payment success message (R11)', () => {
3602
+ const BUTTON_FILL = '#101010';
3603
+ const BUTTON_TEXT = '#fafafa';
3604
+
3605
+ const paymentSite = (
3606
+ content: Record<string, unknown>,
3607
+ options: TextStyleOptions,
3608
+ ): BrandPageModel =>
3609
+ siteWith(
3610
+ {
3611
+ pages: [{ contents: [content] }],
3612
+ styles: {
3613
+ textStyles: { textFontFamily: 'lato', textStyleOptions: options },
3614
+ buttonStyles: {
3615
+ style: { backgroundColor: BUTTON_FILL, color: BUTTON_TEXT },
3616
+ },
3617
+ colors: [],
3618
+ },
3619
+ },
3620
+ options,
3621
+ );
3622
+
3623
+ const paymentContent = (
3624
+ type: string,
3625
+ extra: Record<string, unknown>,
3626
+ ): Record<string, unknown> => ({
3627
+ id: `${type}-1`,
3628
+ type,
3629
+ title: 'Pay',
3630
+ description: 'Pay me',
3631
+ successMessage: '',
3632
+ ...extra,
3633
+ });
3634
+
3635
+ // A site whose Paragraphs role differs from Paragraph 1, so its look stays as overrides.
3636
+ const withCustomParagraphs = (): TextStyleOptions => {
3637
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
3638
+
3639
+ options.paragraphs.style.fontFamily = 'Oswald';
3640
+ options.paragraphs.style.fontSize = '1.125em';
3641
+
3642
+ return options;
3643
+ };
3644
+
3645
+ const viewModelOf = (
3646
+ content: unknown,
3647
+ model: BrandPageModel,
3648
+ ): Record<string, any> => {
3649
+ const styles = pageStylesFor(model.styles.textStyles.textStyleOptions);
3650
+
3651
+ styles.buttonStyles.style = { ...model.styles.buttonStyles.style };
3652
+
3653
+ return mapToViewModel(
3654
+ styles,
3655
+ BRAND_PAGE_TYPES.brandSite,
3656
+ {},
3657
+ )(clone(content) as Content) as Record<string, any>;
3658
+ };
3659
+
3660
+ const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
3661
+
3662
+ const withoutColour = (style: Record<string, unknown>) => {
3663
+ const { color: _color, ...rest } = style;
3664
+
3665
+ return rest;
3666
+ };
3667
+
3668
+ it.each([
3669
+ [
3670
+ 'support-me',
3671
+ 'a legacy role',
3672
+ { textStyleType: 'paragraphs', textAlign: 'center' },
3673
+ ],
3674
+ [
3675
+ 'request-payment',
3676
+ 'a legacy role',
3677
+ { textStyleType: 'paragraphs', textAlign: 'center' },
3678
+ ],
3679
+ [
3680
+ 'support-me',
3681
+ 'no role',
3682
+ { fontSize: '1.25em', fontFamily: 'Oswald', textAlign: 'center' },
3683
+ ],
3684
+ [
3685
+ 'request-payment',
3686
+ 'no role',
3687
+ { fontSize: '1.25em', fontFamily: 'Oswald', textAlign: 'center' },
3688
+ ],
3689
+ ] as const)(
3690
+ 'gives a %s success message the description on %s: its role and overrides, look kept',
3691
+ async (type, _label, descriptionStyle) => {
3692
+ const options = withCustomParagraphs();
3693
+ const before = drawn(
3694
+ productionTypography(
3695
+ descriptionStyle as TextStyleDeclaration,
3696
+ withCustomParagraphs(),
3697
+ ),
3698
+ );
3699
+
3700
+ const model = await runMigration22(
3701
+ paymentSite(
3702
+ paymentContent(type, { descriptionStyle: { ...descriptionStyle } }),
3703
+ options,
3704
+ ),
3705
+ );
3706
+ const after = model.pages[0].contents[0] as unknown as Record<
3707
+ string,
3708
+ any
3709
+ >;
3710
+
3711
+ expect(after.successMessageStyle).toBeDefined();
3712
+ expect(after.successMessageStyle).not.toBe(after.descriptionStyle);
3713
+ expect(withoutColour(after.successMessageStyle)).toEqual(
3714
+ withoutColour(after.descriptionStyle),
3715
+ );
3716
+
3717
+ const vm = viewModelOf(after, model);
3718
+
3719
+ expect(drawn(vm.successMessageStyle, options)).toEqual(before);
3720
+ expect(drawn(vm.successMessageStyle, options)).toEqual(
3721
+ drawn(vm.descriptionStyle, options),
3722
+ );
3723
+ },
3724
+ );
3725
+
3726
+ it.each(['support-me', 'request-payment'])(
3727
+ 'keeps the colour a %s success message drew from its description',
3728
+ async (type) => {
3729
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
3730
+
3731
+ // On the button fill, a description with no colour of its own drew the button text colour.
3732
+ const onFill = await runMigration22(
3733
+ paymentSite(
3734
+ paymentContent(type, {
3735
+ descriptionStyle: { textStyleType: 'paragraphs' },
3736
+ }),
3737
+ options,
3738
+ ),
3739
+ );
3740
+
3741
+ expect(
3742
+ viewModelOf(onFill.pages[0].contents[0], onFill).successMessageStyle
3743
+ .color,
3744
+ ).toBe(BUTTON_TEXT);
3745
+
3746
+ // Its own colour (migration-21's frame bake) it keeps.
3747
+ const ownColour = await runMigration22(
3748
+ paymentSite(
3749
+ paymentContent(type, {
3750
+ backgroundStyle: { backgroundColor: '#ecb6d7' },
3751
+ descriptionStyle: { textStyleType: 'paragraphs', color: '#654321' },
3752
+ }),
3753
+ TextStyles.GetDefaultTextStyle().textStyleOptions,
3754
+ ),
3755
+ );
3756
+
3757
+ expect(
3758
+ viewModelOf(ownColour.pages[0].contents[0], ownColour)
3759
+ .successMessageStyle.color,
3760
+ ).toBe('#654321');
3761
+ },
3762
+ );
3763
+
3764
+ it('keeps a success message style of its own, and leaves the message untouched', async () => {
3765
+ const options = TextStyles.GetDefaultTextStyle().textStyleOptions;
3766
+ const successMessage = {
3767
+ type: 'doc',
3768
+ content: [
3769
+ {
3770
+ type: 'paragraph',
3771
+ content: [
3772
+ {
3773
+ type: 'text',
3774
+ text: 'Thanks!',
3775
+ marks: [{ type: 'textStyle', attrs: { color: '#e11d48' } }],
3776
+ },
3777
+ ],
3778
+ },
3779
+ ],
3780
+ };
3781
+
3782
+ const model = await runMigration22(
3783
+ paymentSite(
3784
+ paymentContent('support-me', {
3785
+ successMessage: clone(successMessage),
3786
+ successMessageStyle: { textStyleType: 'heading6' },
3787
+ descriptionStyle: { textStyleType: 'paragraphs' },
3788
+ }),
3789
+ options,
3790
+ ),
3791
+ );
3792
+ const after = model.pages[0].contents[0] as unknown as Record<string, any>;
3793
+
3794
+ expect(after.successMessageStyle.textStyleType).toBe('heading6');
3795
+ expect(after.successMessage).toEqual(successMessage);
3796
+ });
3797
+
3798
+ // The old maker editor wrote `{ textAlign }` here, but production never drew it.
3799
+ it.each(['support-me', 'request-payment'])(
3800
+ 'replaces a %s success message style it never drew with a copy of the description',
3801
+ async (type) => {
3802
+ const options = withCustomParagraphs();
3803
+ const descriptionStyle = {
3804
+ textStyleType: 'paragraphs',
3805
+ textAlign: 'center',
3806
+ };
3807
+ const before = drawn(
3808
+ productionTypography(
3809
+ descriptionStyle as TextStyleDeclaration,
3810
+ withCustomParagraphs(),
3811
+ ),
3812
+ );
3813
+
3814
+ const model = await runMigration22(
3815
+ paymentSite(
3816
+ paymentContent(type, {
3817
+ descriptionStyle: { ...descriptionStyle },
3818
+ successMessageStyle: { textAlign: 'left' },
3819
+ }),
3820
+ options,
3821
+ ),
3822
+ );
3823
+ const after = model.pages[0].contents[0] as unknown as Record<
3824
+ string,
3825
+ any
3826
+ >;
3827
+
3828
+ expect(withoutColour(after.successMessageStyle)).toEqual(
3829
+ withoutColour(after.descriptionStyle),
3830
+ );
3831
+ expect(
3832
+ drawn(viewModelOf(after, model).successMessageStyle, options),
3833
+ ).toEqual(before);
3834
+ },
3835
+ );
3836
+
3837
+ it.each(['support-me', 'request-payment'])(
3838
+ 'starts a %s success message with no description on Paragraph 2, keeping the page defaults it drew',
3839
+ async (type) => {
3840
+ const options = withCustomParagraphs();
3841
+ const model = await runMigration22(
3842
+ paymentSite(paymentContent(type, { successMessage: '' }), options),
3843
+ );
3844
+ const after = model.pages[0].contents[0] as unknown as Record<
3845
+ string,
3846
+ any
3847
+ >;
3848
+ const vm = viewModelOf(after, model);
3849
+
3850
+ expect(after.successMessageStyle.textStyleType).toBe('paragraph2');
3851
+ // S5 — the drawn look it kept is pinned as literal overrides (the reconnect's inherited-
3852
+ // font-size/DETACHED_UNSET_EFFECTIVE pins), not paragraph2's real role default, so the
3853
+ // RHS stays `drawn(undefined)` (the pinned values happen to equal the generic no-role
3854
+ // draw). Passing `options` on the LHS only is what makes this catch a missing pin: an
3855
+ // un-pinned prop would fall through to paragraph2's REAL default (1.125em/1.5em) instead
3856
+ // of coincidentally matching the RHS, where the old comparison (no options either side)
3857
+ // couldn't tell the two apart.
3858
+ expect(drawn(vm.successMessageStyle, options)).toEqual(drawn(undefined));
3859
+ // It inherited the frame's colour, the button text colour on the button fill.
3860
+ expect(vm.successMessageStyle.color).toBe(BUTTON_TEXT);
3861
+ },
3862
+ );
3863
+
3864
+ // A rich-text run draws from its marks, filled at render against its role (`TextWrapper`); a role
3865
+ // var draws the role's value, or the neutral the role class emits for one it leaves unset.
3866
+ const drawnRun = (
3867
+ doc: unknown,
3868
+ style: Record<string, unknown> | undefined,
3869
+ role: Record<string, unknown> | undefined,
3870
+ ): Record<string, string | undefined> => {
3871
+ const filled = textRoleCss.fillRteRunTypography(
3872
+ doc as never,
3873
+ role as never,
3874
+ style as never,
3875
+ ) as {
3876
+ content: {
3877
+ content: {
3878
+ marks?: { type: string; attrs: Record<string, string> }[];
3879
+ }[];
3880
+ }[];
3881
+ };
3882
+ const attrs =
3883
+ filled.content[0].content[0].marks?.find(
3884
+ (mark) => mark.type === 'textStyle',
3885
+ )?.attrs ?? {};
3886
+ const neutral: Record<string, string> = {
3887
+ fontWeight: 'normal',
3888
+ fontStyle: 'normal',
3889
+ textDecoration: 'none',
3890
+ letterSpacing: 'normal',
3891
+ };
3892
+
3893
+ return Object.fromEntries(
3894
+ TEXT_STYLE_CASCADE_PROPS.map((prop) => {
3895
+ const value = attrs[prop];
3896
+
3897
+ if (!value) return [prop, drawn(undefined)[prop]];
3898
+
3899
+ if (value.startsWith('var(--role-'))
3900
+ return [prop, (role?.[prop] as string) || neutral[prop]];
3901
+
3902
+ return [prop, value.replace(/['"]/g, '')];
3903
+ }),
3904
+ );
3905
+ };
3906
+
3907
+ // migration-21 wrapped a non-empty message into a doc whose runs carry only colour, and
3908
+ // production's `TextWrapper` gave rich text no typography from its style object.
3909
+ it.each([
3910
+ ['support-me', 'a legacy role', { textStyleType: 'paragraphs' }],
3911
+ ['request-payment', 'a legacy role', { textStyleType: 'paragraphs' }],
3912
+ ['support-me', 'no role', { fontSize: '1.25em', fontFamily: 'Oswald' }],
3913
+ [
3914
+ 'request-payment',
3915
+ 'no role',
3916
+ { fontSize: '1.25em', fontFamily: 'Oswald' },
3917
+ ],
3918
+ ] as const)(
3919
+ 'keeps a rich-text %s success message drawing the page defaults, on the role of its description on %s',
3920
+ async (type, _label, descriptionStyle) => {
3921
+ const options = withCustomParagraphs();
3922
+ const successMessage = {
3923
+ type: 'doc',
3924
+ content: [
3925
+ {
3926
+ type: 'paragraph',
3927
+ attrs: { textAlign: 'center' },
3928
+ content: [
3929
+ {
3930
+ type: 'text',
3931
+ text: 'Thanks!',
3932
+ marks: [{ type: 'textStyle', attrs: { color: '#654321' } }],
3933
+ },
3934
+ ],
3935
+ },
3936
+ ],
3937
+ };
3938
+ const before = drawnRun(successMessage, undefined, undefined);
3939
+
3940
+ const model = await runMigration22(
3941
+ paymentSite(
3942
+ paymentContent(type, {
3943
+ successMessage: clone(successMessage),
3944
+ descriptionStyle: { ...descriptionStyle, textAlign: 'center' },
3945
+ }),
3946
+ options,
3947
+ ),
3948
+ );
3949
+ const after = model.pages[0].contents[0] as unknown as Record<
3950
+ string,
3951
+ any
3952
+ >;
3953
+ const style = after.successMessageStyle;
3954
+
3955
+ expect(style.textStyleType).toBe(after.descriptionStyle.textStyleType);
3956
+ expect(
3957
+ drawnRun(
3958
+ after.successMessage,
3959
+ style,
3960
+ roleStyleOf(options, style.textStyleType) as never,
3961
+ ),
3962
+ ).toEqual(before);
3963
+ expect(runMarkColor(after.successMessage)).toBe('#654321');
3964
+ },
3965
+ );
3966
+
3967
+ // migration-21 actually bakes the description's fontFamily/fontSize onto the run too
3968
+ // (migration-21.ts:760-765), not colour alone — a doc from that step already carries them, and
3969
+ // re-lifting it for migration-22's role must leave what it drew unchanged.
3970
+ it('keeps a rich-text support-me success message drawing what migration-21 already baked onto its runs', async () => {
3971
+ const options = withCustomParagraphs();
3972
+ const descriptionStyle = {
3973
+ fontSize: '1.25em',
3974
+ fontFamily: 'Oswald',
3975
+ textAlign: 'center' as const,
3976
+ };
3977
+ const successMessage = {
3978
+ type: 'doc',
3979
+ content: [
3980
+ {
3981
+ type: 'paragraph',
3982
+ attrs: { textAlign: 'center' },
3983
+ content: [
3984
+ {
3985
+ type: 'text',
3986
+ text: 'Thanks!',
3987
+ marks: [
3988
+ {
3989
+ type: 'textStyle',
3990
+ attrs: {
3991
+ color: '#654321',
3992
+ fontFamily: descriptionStyle.fontFamily,
3993
+ fontSize: descriptionStyle.fontSize,
3994
+ },
3995
+ },
3996
+ ],
3997
+ },
3998
+ ],
3999
+ },
4000
+ ],
4001
+ };
4002
+ const before = drawnRun(successMessage, undefined, undefined);
4003
+
4004
+ const model = await runMigration22(
4005
+ paymentSite(
4006
+ paymentContent('support-me', {
4007
+ successMessage: clone(successMessage),
4008
+ descriptionStyle,
4009
+ }),
4010
+ options,
4011
+ ),
4012
+ );
4013
+ const after = model.pages[0].contents[0] as unknown as Record<string, any>;
4014
+ const style = after.successMessageStyle;
4015
+
4016
+ expect(style.textStyleType).toBe(after.descriptionStyle.textStyleType);
4017
+ expect(
4018
+ drawnRun(
4019
+ after.successMessage,
4020
+ style,
4021
+ roleStyleOf(options, style.textStyleType) as never,
4022
+ ),
4023
+ ).toEqual(before);
4024
+ expect(runMarkColor(after.successMessage)).toBe('#654321');
4025
+ });
4026
+
4027
+ // The shop checkout (`PaymentModal.vue`) drew its message with only the description's colour.
4028
+ it.each([
4029
+ ['its own colour', { textStyleType: 'paragraphs', color: '#654321' }],
4030
+ ['its role colour', { textStyleType: 'paragraphs' }],
4031
+ ] as const)(
4032
+ 'starts the shop checkout success message on Paragraph 2, keeping the page defaults and the description colour (%s)',
4033
+ async (_label, descriptionStyle) => {
4034
+ const options = withCustomParagraphs();
4035
+
4036
+ (options.paragraphs.style as unknown as Record<string, string>).color =
4037
+ '#123456';
4038
+
4039
+ const model = await runMigration22(
4040
+ paymentSite(
4041
+ {
4042
+ id: 'shop-1',
4043
+ type: 'shop-section',
4044
+ title: 'Shop',
4045
+ description: '',
4046
+ successMessage: 'Thanks!',
4047
+ descriptionStyle: { ...descriptionStyle },
4048
+ successMessageStyle: { textAlign: 'left' },
4049
+ },
4050
+ options,
4051
+ ),
4052
+ );
4053
+ const after = model.pages[0].contents[0] as unknown as Record<
4054
+ string,
4055
+ any
4056
+ >;
4057
+ const vm = viewModelOf(after, model);
4058
+
4059
+ expect(after.successMessageStyle.textStyleType).toBe('paragraph2');
4060
+ // S5 — see the support-me/request-payment case above: the RHS stays `drawn(undefined)`
4061
+ // (the pinned drawn look happens to equal the generic no-role draw); passing `options` on
4062
+ // the LHS only is what makes this catch a missing pin.
4063
+ expect(drawn(vm.successMessageStyle, options)).toEqual(drawn(undefined));
4064
+ expect(vm.successMessageStyle.color).toBe(
4065
+ (descriptionStyle as { color?: string }).color ?? '#123456',
4066
+ );
4067
+ expect(vm.successMessageStyle.color).toBe(vm.descriptionStyle.color);
4068
+ expect(vm.successMessageStyle.textAlign).toBe('center');
4069
+ },
4070
+ );
4071
+
4072
+ // A description on no role and with no colour gave the modal `inherit`: fe-shared-lib's `Modal`
4073
+ // sets no colour, so it drew the page root's, the Paragraphs colour (`Page.mixin`
4074
+ // `rootElementStyles`). Never the colour of the tier the description reconnects to.
4075
+ it('keeps the page colour a shop checkout success message inherited from a description on no role', async () => {
4076
+ const options = withCustomParagraphs();
4077
+ const colourOf = (type: TextStyleType, color: string) => {
4078
+ (options[type].style as unknown as Record<string, string>).color = color;
4079
+ };
4080
+
4081
+ colourOf('paragraphs', '#123456');
4082
+ colourOf('paragraph3', '#abcdef');
4083
+
4084
+ const model = await runMigration22(
4085
+ paymentSite(
4086
+ {
4087
+ id: 'shop-1',
4088
+ type: 'shop-section',
4089
+ title: 'Shop',
4090
+ description: '',
4091
+ successMessage: 'Thanks!',
4092
+ // Paragraph 3's size, so the description reconnects to a tier with its own colour.
4093
+ descriptionStyle: { fontSize: '0.875em' },
4094
+ },
4095
+ options,
4096
+ ),
4097
+ );
4098
+ const after = model.pages[0].contents[0] as unknown as Record<string, any>;
4099
+
4100
+ expect(after.descriptionStyle.textStyleType).toBe('paragraph3');
4101
+ expect(viewModelOf(after, model).successMessageStyle.color).toBe('#123456');
4102
+ });
4103
+ });