@akinon/pz-theme 2.0.164-rc.1 → 2.0.164

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 (46) hide show
  1. package/CHANGELOG.md +8 -58
  2. package/package.json +4 -4
  3. package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
  4. package/src/__tests__/theme-settings-defaults.test.tsx +395 -0
  5. package/src/__tests__/theme-text-defaults-code-owned.test.tsx +340 -0
  6. package/src/blocks/__tests__/breadcrumb-theme-separator.test.tsx +148 -0
  7. package/src/blocks/__tests__/social-icons-theme-fallback.test.tsx +268 -0
  8. package/src/blocks/__tests__/text-block.test.tsx +6 -34
  9. package/src/blocks/accordion-block.tsx +10 -0
  10. package/src/blocks/hotspot-block.tsx +6 -1
  11. package/src/blocks/listing-breadcrumb-block.tsx +10 -1
  12. package/src/blocks/order-detail-block.tsx +95 -113
  13. package/src/blocks/product-breadcrumb-block.tsx +9 -1
  14. package/src/blocks/social-glyphs.tsx +31 -0
  15. package/src/blocks/social-icons-list-block.tsx +72 -8
  16. package/src/blocks/text-block.tsx +15 -6
  17. package/src/chrome/__tests__/theme-page-up.test.tsx +522 -0
  18. package/src/chrome/theme-page-up.tsx +303 -0
  19. package/src/chrome/use-editor-theme-settings.ts +104 -0
  20. package/src/components/with-designer-features.tsx +27 -0
  21. package/src/custom/README.md +219 -100
  22. package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
  23. package/src/custom/collect.ts +9 -4
  24. package/src/custom/property-types.ts +15 -15
  25. package/src/custom/use-theme-setting.ts +5 -21
  26. package/src/editor-locale-bridge.tsx +11 -39
  27. package/src/index.ts +15 -0
  28. package/src/overlays/theme-modals.tsx +10 -0
  29. package/src/sections/__tests__/section-wrapper.test.tsx +76 -0
  30. package/src/sections/coupon-banner-section.tsx +15 -1
  31. package/src/sections/section-wrapper.tsx +7 -0
  32. package/src/sections/tabs-section.tsx +13 -0
  33. package/src/theme-placeholder-wrapper.tsx +50 -3
  34. package/src/theme-section.tsx +15 -0
  35. package/src/utils/__tests__/svg-image-source.test.ts +123 -0
  36. package/src/utils/__tests__/theme-settings-css.test.ts +801 -0
  37. package/src/utils/__tests__/theme-settings-readers.test.ts +213 -0
  38. package/src/utils/__tests__/theme-settings-resolver.test.ts +232 -0
  39. package/src/utils/index.ts +36 -1
  40. package/src/utils/svg-image-source.ts +84 -0
  41. package/src/utils/theme-settings-css.ts +717 -0
  42. package/src/utils/theme-settings-readers.ts +205 -0
  43. package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
  44. package/src/blocks/order-line-actions.ts +0 -45
  45. package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
  46. package/src/utils/sanitize-rich-text.ts +0 -215
package/CHANGELOG.md CHANGED
@@ -1,63 +1,13 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.164-rc.1
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [8853822c]
8
- - @akinon/next@2.0.164-rc.1
9
- - @akinon/theme-contract@2.0.164-rc.1
10
-
11
- ## 2.0.164-rc.0
12
-
13
- ### Patch Changes
14
-
15
- - 8e040731e: ZERO-5235: let brands hang actions on order detail lines
16
- - c761b9fd: ZERO-5296: fall back to the storefront default language in the text block
17
- - 17a598dae: ZERO-5296: report the storefront default language to the editor
18
- - 3b05a407d: ZERO-5232: allow richtext settings on custom blocks
19
- - Updated dependencies [86187b1c2]
20
- - Updated dependencies [08390a87]
21
- - Updated dependencies [0cf9ea239]
22
- - Updated dependencies [324f97d55]
23
- - Updated dependencies [4c3465ae]
24
- - Updated dependencies [51ea06888]
25
- - Updated dependencies
26
- - Updated dependencies [549da545]
27
- - Updated dependencies [2fea41430]
28
- - Updated dependencies [b55acb768]
29
- - Updated dependencies [760258c1c]
30
- - Updated dependencies [143be2b9d]
31
- - Updated dependencies [7889b08fe]
32
- - Updated dependencies [9f8cd3bc5]
33
- - Updated dependencies [d635d345]
34
- - Updated dependencies [bfafa3f49]
35
- - Updated dependencies [d860c917]
36
- - Updated dependencies [ac6cada1]
37
- - Updated dependencies [e90c0e02]
38
- - Updated dependencies [dc7f9ca5]
39
- - Updated dependencies [57d7eb305]
40
- - Updated dependencies [0145dc3f]
41
- - Updated dependencies [d99a6a7d5]
42
- - Updated dependencies [92086b29]
43
- - Updated dependencies [227b7408]
44
- - Updated dependencies [9db81a714]
45
- - Updated dependencies [591e345e1]
46
- - Updated dependencies [4de5303c5]
47
- - Updated dependencies [95b139dc1]
48
- - Updated dependencies [1d00f2d06]
49
- - Updated dependencies [4ac7b2a1e]
50
- - Updated dependencies [4998a9631]
51
- - Updated dependencies [804d2bd6c]
52
- - Updated dependencies [3909d3224]
53
- - Updated dependencies [6a3d8a631]
54
- - Updated dependencies [e18836b20]
55
- - Updated dependencies [b1111d7b1]
56
- - Updated dependencies [1595d458e]
57
- - Updated dependencies [dca128112]
58
- - Updated dependencies [9a7b0937]
59
- - @akinon/next@2.0.164-rc.0
60
- - @akinon/theme-contract@2.0.164-rc.0
3
+ ## 2.0.164
4
+
5
+ ### Patch Changes
6
+
7
+ - 64548e48: ZERO-5483: Render the redesigned theme settings on the storefront. The theme's colour roles (`linkColor`, `headingColor`, `textColor`, `footerBackgroundColor`, `borderColor`), custom colours and the typography styles managed by the new settings page are published as `--theme-*` variables (server `<style>` and live in the designer, with deleted keys removed) and, only for keys that are set, as low-specificity defaults inside text-block rich text and the composed footer, so a theme that never touches the new settings renders as before. `pageUpEnabled` turns on a back-to-top button mounted by `ThemeModals` (also exported as `ThemePageUp`; icon from `pageUpIcon`, else the theme's `up-arrow` icon, rendered as an image; lifted above fixed bottom bars and floating buttons in its corner, or by `--pz-bottom-overlay-offset`). Breadcrumb blocks without their own separator use `breadcrumbSeparator`; the social icons list can opt into the theme's `socialNetworks` with `useThemeSocialNetworks`, and `x` draws the X mark. `@akinon/theme-contract` types the new settings keys and typography fields. projectzeronext's stock header uses the theme's `logo`, `brandName` and `logoWidthDesktop`/`logoWidthMobile`, and the root layout adds the theme's `favicon` next to the PWA icons.
8
+ - Updated dependencies [64548e48]
9
+ - @akinon/theme-contract@2.0.164
10
+ - @akinon/next@2.0.164
61
11
 
62
12
  ## 2.0.163
63
13
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.164-rc.1",
4
+ "version": "2.0.164",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.164-rc.1",
11
+ "@akinon/next": "2.0.164",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.164-rc.1",
16
+ "@akinon/theme-contract": "2.0.164",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.164-rc.1",
24
+ "@akinon/next": "2.0.164",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -28,7 +28,6 @@ const SETTINGS_LOCALES = [
28
28
  const mockLocalization = {
29
29
  locale: 'tr',
30
30
  locales: SETTINGS_LOCALES as Array<Record<string, unknown>>,
31
- defaultLocaleValue: 'tr',
32
31
  setLocale: jest.fn()
33
32
  };
34
33
 
@@ -107,7 +106,6 @@ beforeEach(() => {
107
106
  postMessage.mockClear();
108
107
  mockLocalization.locale = 'tr';
109
108
  mockLocalization.locales = SETTINGS_LOCALES;
110
- mockLocalization.defaultLocaleValue = 'tr';
111
109
  mockLocalization.setLocale = jest.fn();
112
110
  delete (window as { __iframeReadySent?: boolean }).__iframeReadySent;
113
111
  });
@@ -146,8 +144,7 @@ describe('inside the editor', () => {
146
144
  { value: 'en', label: 'English' },
147
145
  { value: 'tr', label: 'Türkçe' },
148
146
  { value: 'de', label: 'de' }
149
- ],
150
- defaultLocale: 'tr'
147
+ ]
151
148
  }
152
149
  })
153
150
  ]);
@@ -157,34 +154,6 @@ describe('inside the editor', () => {
157
154
  expect(targetOrigin).toBe(EDITOR_ORIGIN);
158
155
  });
159
156
 
160
- it('posts the languages before the document reports ready', () => {
161
- // The editor holds every per-language write until IFRAME_READY and takes
162
- // "ready without languages" to mean they are never coming, so the order
163
- // of the two frames is part of the contract.
164
- render(placeholder('header'));
165
-
166
- const types = postMessage.mock.calls.map(([message]) => message?.type);
167
- expect(types).toContain('LOCALE_UPDATE');
168
- expect(types).toContain('IFRAME_READY');
169
- expect(types.indexOf('LOCALE_UPDATE')).toBeLessThan(
170
- types.indexOf('IFRAME_READY')
171
- );
172
- });
173
-
174
- it("posts the brand's default language, whichever it is", () => {
175
- // The editor's per-language text controls take the plain-string form to
176
- // mean this language; left to guess, the editor assumed English.
177
- mockLocalization.defaultLocaleValue = 'de';
178
-
179
- render(<EditorLocaleBridge />);
180
-
181
- expect(localeFrames()).toEqual([
182
- expect.objectContaining({
183
- data: expect.objectContaining({ locale: 'tr', defaultLocale: 'de' })
184
- })
185
- ]);
186
- });
187
-
188
157
  it("follows the editor's switch exactly once, with several placeholders mounted", () => {
189
158
  render(
190
159
  <>
@@ -0,0 +1,395 @@
1
+ import React from 'react';
2
+ import { act, fireEvent, render } from '@testing-library/react';
3
+ // The Node build: the path a storefront actually server-renders through.
4
+ import { renderToStaticMarkup } from 'react-dom/server.node';
5
+
6
+ import ThemePlaceholderWrapper, {
7
+ buildThemeElementDefaultsCss
8
+ } from '../theme-placeholder-wrapper';
9
+ import { __resetEditorBridgeForTests } from '../editor-bridge';
10
+ import { HOVER_REVEAL_CSS } from '../utils/property-switches';
11
+ import type { Section } from '../theme-section';
12
+
13
+ /**
14
+ * The redesigned Web Theme Settings reach the storefront as
15
+ * `:root` variables plus rich-text element defaults, painted from the
16
+ * placeholder wrapper's server `<style>` and kept live by `injectThemeColors`.
17
+ *
18
+ * Three promises are pinned here:
19
+ * 1. A theme saved by the old settings page renders byte-identically: the
20
+ * same `<style>` and not one `data-pz-*` attribute.
21
+ * 2. Once a key is set, the defaults reach plain rich text and nothing that
22
+ * authored its own colour or typography (a white-on-dark hero keeps its
23
+ * white headings; a text block with its own size is not retypeset).
24
+ * 3. The editor's LOAD_THEME / UPDATE_THEME keep both the `<style>` and the
25
+ * inline variables in step, removing what was deleted — but never the
26
+ * legacy colour variables.
27
+ */
28
+
29
+ const EDITOR_ORIGIN = 'http://localhost:5174';
30
+
31
+ const LEGACY_SETTINGS = {
32
+ primaryColor: '#000000',
33
+ secondaryColor: '#E95151',
34
+ color3: '#1F2937',
35
+ color4: '#CBCBCB',
36
+ backgroundColor: '#FFFFFF',
37
+ breadcrumbSeparator: '>',
38
+ typography: [
39
+ {
40
+ key: 'heading01',
41
+ fontFamily: 'Jost',
42
+ fontWeight: '500',
43
+ fontSize: 34,
44
+ color: '#FFF',
45
+ styles: []
46
+ },
47
+ {
48
+ key: 'body',
49
+ fontFamily: 'Jost',
50
+ fontWeight: '400',
51
+ fontSize: 12,
52
+ color: '#FFF',
53
+ styles: []
54
+ }
55
+ ]
56
+ };
57
+
58
+ /** The <style> the wrapper emitted for LEGACY_SETTINGS before the theme settings redesign. */
59
+ const LEGACY_STYLE =
60
+ ':root{--theme-background:#FFFFFF}body{background-color:#FFFFFF}' +
61
+ ':root{--theme-font-heading01:Jost;--theme-font-body:Jost}' +
62
+ HOVER_REVEAL_CSS;
63
+
64
+ const managed = (key: string, extra: Record<string, unknown> = {}) => ({
65
+ key,
66
+ fontFamily: 'Jost',
67
+ fontWeight: '600',
68
+ fontSize: 32,
69
+ lineHeight: 40,
70
+ letterSpacing: 0,
71
+ textTransform: 'none',
72
+ color: '#111111',
73
+ styles: [],
74
+ ...extra
75
+ });
76
+
77
+ const NEW_SETTINGS = {
78
+ ...LEGACY_SETTINGS,
79
+ linkColor: '#4482FF',
80
+ headingColor: '#222222',
81
+ textColor: '#555555',
82
+ borderColor: '#E2E2E2',
83
+ footerBackgroundColor: '#F4F4F4',
84
+ customColors: [{ id: 'custom-lx1', name: 'Kampanya', value: '#B0272C' }],
85
+ typography: [
86
+ managed('heading01'),
87
+ managed('body', { fontWeight: '400', fontSize: 14, lineHeight: 22 }),
88
+ managed('custom-lx2', { fontFamily: 'Lora', custom: true })
89
+ ]
90
+ };
91
+
92
+ const text = (id: string, value: string, styles = {}) => ({
93
+ id,
94
+ type: 'text',
95
+ label: id,
96
+ order: 0,
97
+ hidden: false,
98
+ properties: {},
99
+ styles,
100
+ value,
101
+ blocks: []
102
+ });
103
+
104
+ const SECTIONS = [
105
+ {
106
+ id: 'hero',
107
+ type: 'generic-section',
108
+ name: 'hero',
109
+ label: 'Hero',
110
+ order: 0,
111
+ hidden: false,
112
+ properties: {},
113
+ // White on dark: its rich text must keep inheriting the white.
114
+ styles: {
115
+ color: { desktop: '#ffffff' },
116
+ 'background-color': { desktop: '#000000' }
117
+ },
118
+ blocks: [
119
+ text(
120
+ 'hero-text',
121
+ '<h1 id="hero-h1">Big</h1><p id="hero-p">On <a id="hero-a" href="/x">dark</a></p>'
122
+ )
123
+ ]
124
+ },
125
+ {
126
+ id: 'body',
127
+ type: 'generic-section',
128
+ name: 'body',
129
+ label: 'Body',
130
+ order: 1,
131
+ hidden: false,
132
+ properties: {},
133
+ styles: {},
134
+ blocks: [
135
+ text(
136
+ 'plain-text',
137
+ '<h2 id="plain-h2">Title</h2><p id="plain-p">Para <a id="plain-a" href="/y">link</a></p><ul><li id="plain-li">One</li></ul><hr id="plain-hr">'
138
+ ),
139
+ {
140
+ ...text('sized-text', '<p id="sized-p">Sized</p>', {
141
+ 'font-size': { mobile: '18px' }
142
+ }),
143
+ order: 1
144
+ },
145
+ {
146
+ id: 'coloured-group',
147
+ type: 'group',
148
+ label: 'Group',
149
+ order: 2,
150
+ hidden: false,
151
+ properties: {},
152
+ styles: { color: { desktop: '#ff0000' } },
153
+ blocks: [text('inner-text', '<p id="inner-p">Red</p>')]
154
+ }
155
+ ]
156
+ }
157
+ ] as unknown as Section[];
158
+
159
+ const wrapper = (
160
+ settings: Record<string, unknown> | null,
161
+ slug = 'theme-homepage-body'
162
+ ) => (
163
+ <ThemePlaceholderWrapper
164
+ slug={slug}
165
+ initialSections={SECTIONS}
166
+ initialPlaceholderId={slug}
167
+ isDesignMode={false}
168
+ initialThemeSettings={settings}
169
+ />
170
+ );
171
+
172
+ const ssrDocument = (element: React.ReactElement) =>
173
+ new DOMParser().parseFromString(renderToStaticMarkup(element), 'text/html');
174
+
175
+ /** `selector{declarations}` pairs of a flat stylesheet (no nested blocks). */
176
+ const rulesOf = (css: string) =>
177
+ css
178
+ .split('}')
179
+ .filter(Boolean)
180
+ .map((rule) => {
181
+ const [selector, declarations] = rule.split('{');
182
+ return { selector, declarations };
183
+ });
184
+
185
+ /**
186
+ * What a rule with this declaration matches: element ids, and `root:<block>`
187
+ * for a text block's rich-text root.
188
+ */
189
+ const matchedBy = (doc: Document, css: string, declaration: string) => {
190
+ const rule = rulesOf(css).find((r) => r.declarations.includes(declaration));
191
+ if (!rule) throw new Error(`no rule with ${declaration}`);
192
+ return Array.from(doc.querySelectorAll(rule.selector))
193
+ .map((el) =>
194
+ el.hasAttribute('data-pz-rich-text')
195
+ ? `root:${el.parentElement?.getAttribute('data-block-id')}`
196
+ : el.id
197
+ )
198
+ .sort();
199
+ };
200
+
201
+ describe('a theme saved by the old settings page', () => {
202
+ it('server-renders the same <style> as before and no new attribute', () => {
203
+ const html = renderToStaticMarkup(wrapper(LEGACY_SETTINGS));
204
+ const firstStyle = html.match(/^<style>(.*?)<\/style>/)?.[1];
205
+
206
+ expect(firstStyle).toBe(LEGACY_STYLE);
207
+ expect(html).not.toContain('data-pz-');
208
+ expect(html).not.toContain('--theme-link');
209
+ });
210
+
211
+ it('gains no attribute on the client either', () => {
212
+ const { container, unmount } = render(wrapper(LEGACY_SETTINGS));
213
+ expect(container.querySelector('[data-pz-rich-text]')).toBeNull();
214
+ expect(container.querySelector('[data-pz-own-color]')).toBeNull();
215
+ expect(container.querySelector('[data-pz-own-type]')).toBeNull();
216
+ unmount();
217
+ });
218
+ });
219
+
220
+ describe('a theme that sets the new keys', () => {
221
+ const doc = () => ssrDocument(wrapper(NEW_SETTINGS));
222
+ const css = buildThemeElementDefaultsCss(NEW_SETTINGS);
223
+
224
+ it('emits the variables and the rules with the markup', () => {
225
+ const style = doc().querySelector('style')?.textContent ?? '';
226
+
227
+ expect(style).toContain(
228
+ ':root{--theme-link:#4482FF;--theme-heading:#222222;--theme-text:#555555;--theme-footer-background:#F4F4F4;--theme-border:#E2E2E2;--theme-custom-lx1:#B0272C;'
229
+ );
230
+ expect(style).toContain('--theme-line-height-custom-lx2:40px');
231
+ expect(style).toContain(css);
232
+ // Unchanged, and still ahead of the new rules.
233
+ expect(style.indexOf('--theme-font-body:Jost')).toBeLessThan(
234
+ style.indexOf('--theme-link')
235
+ );
236
+ });
237
+
238
+ it('marks every text root, and only the elements that authored values', () => {
239
+ const page = doc();
240
+ const ids = (selector: string) =>
241
+ Array.from(page.querySelectorAll(selector))
242
+ .map(
243
+ (el) =>
244
+ el.getAttribute('data-block-id') ??
245
+ el.getAttribute('data-section-id')
246
+ )
247
+ .sort();
248
+
249
+ expect(
250
+ page.querySelectorAll('[data-part="root"][data-pz-rich-text]')
251
+ ).toHaveLength(4);
252
+ expect(ids('[data-pz-own-color]')).toEqual(['coloured-group', 'hero']);
253
+ expect(ids('[data-pz-own-type]')).toEqual(['sized-text']);
254
+ });
255
+
256
+ it('colours plain rich text and leaves inherited colours alone', () => {
257
+ const page = doc();
258
+
259
+ // Not the hero (white section) nor the red group's text, which inherit.
260
+ expect(matchedBy(page, css, 'color:var(--theme-text)')).toEqual([
261
+ 'plain-li',
262
+ 'plain-p',
263
+ 'root:plain-text',
264
+ 'root:sized-text',
265
+ 'sized-p'
266
+ ]);
267
+ expect(matchedBy(page, css, 'color:var(--theme-heading)')).toEqual([
268
+ 'plain-h2'
269
+ ]);
270
+ expect(matchedBy(page, css, 'color:var(--theme-link)')).toEqual([
271
+ 'plain-a'
272
+ ]);
273
+ expect(matchedBy(page, css, 'border-color:var(--theme-border)')).toEqual([
274
+ 'plain-hr'
275
+ ]);
276
+ });
277
+
278
+ it('retypesets plain rich text and leaves authored typography alone', () => {
279
+ const page = doc();
280
+
281
+ // The hero only owns its colour: its h1 still takes heading01.
282
+ expect(
283
+ matchedBy(page, css, 'line-height:var(--theme-line-height-heading01)')
284
+ ).toEqual(['hero-h1']);
285
+ // Body: every rich-text root and p, except inside the sized text block.
286
+ expect(
287
+ matchedBy(page, css, 'line-height:var(--theme-line-height-body)')
288
+ ).toEqual([
289
+ 'hero-p',
290
+ 'inner-p',
291
+ 'plain-p',
292
+ 'root:hero-text',
293
+ 'root:inner-text',
294
+ 'root:plain-text'
295
+ ]);
296
+ // A custom style publishes variables only.
297
+ expect(css).not.toContain('custom-lx2');
298
+ });
299
+
300
+ it('paints only the composed footer placeholder', () => {
301
+ const footer = ssrDocument(wrapper(NEW_SETTINGS, 'footer'));
302
+ const body = doc();
303
+ const footerCss = buildThemeElementDefaultsCss({
304
+ footerBackgroundColor: '#F4F4F4'
305
+ });
306
+
307
+ expect(
308
+ footer.querySelectorAll(rulesOf(footerCss)[0].selector)
309
+ ).toHaveLength(1);
310
+ expect(body.querySelectorAll(rulesOf(footerCss)[0].selector)).toHaveLength(
311
+ 0
312
+ );
313
+ });
314
+ });
315
+
316
+ describe('the editor canvas', () => {
317
+ const root = document.documentElement;
318
+ const inline = (name: string) => root.style.getPropertyValue(name);
319
+ const styleText = (container: HTMLElement) =>
320
+ Array.from(container.querySelectorAll('style'))
321
+ .map((style) => style.textContent ?? '')
322
+ .join('');
323
+
324
+ const postFromEditor = (type: string, settings: Record<string, unknown>) =>
325
+ act(() => {
326
+ fireEvent(
327
+ window,
328
+ new MessageEvent('message', {
329
+ origin: EDITOR_ORIGIN,
330
+ data: { type, data: { theme: { settings } } }
331
+ })
332
+ );
333
+ });
334
+
335
+ beforeEach(() => {
336
+ __resetEditorBridgeForTests();
337
+ root.removeAttribute('style');
338
+ });
339
+
340
+ it('keeps the <style> and the inline variables in step, removing what was deleted', () => {
341
+ const { container, unmount } = render(wrapper(NEW_SETTINGS));
342
+
343
+ expect(inline('--theme-link')).toBe('#4482FF');
344
+ expect(inline('--theme-custom-lx1')).toBe('#B0272C');
345
+ expect(inline('--theme-font-custom-lx2')).toBe('Lora');
346
+ expect(inline('--theme-line-height-custom-lx2')).toBe('40px');
347
+ expect(inline('--theme-primary')).toBe('#000000');
348
+
349
+ postFromEditor('LOAD_THEME', { ...NEW_SETTINGS, linkColor: '#00AA00' });
350
+ expect(inline('--theme-link')).toBe('#00AA00');
351
+ expect(styleText(container)).toContain('--theme-link:#00AA00');
352
+ expect(container.querySelector('[data-pz-rich-text]')).not.toBeNull();
353
+
354
+ // The merchant hid the link role, deleted the custom colour and the
355
+ // custom style, and the primary colour is not in this message at all.
356
+ const removed = ['linkColor', 'customColors', 'primaryColor'];
357
+ const rest = Object.fromEntries(
358
+ Object.entries(NEW_SETTINGS).filter(([key]) => !removed.includes(key))
359
+ );
360
+ postFromEditor('UPDATE_THEME', {
361
+ ...rest,
362
+ typography: NEW_SETTINGS.typography.filter(
363
+ (variant) => variant.key !== 'custom-lx2'
364
+ )
365
+ });
366
+
367
+ expect(inline('--theme-link')).toBe('');
368
+ expect(inline('--theme-custom-lx1')).toBe('');
369
+ expect(inline('--theme-font-custom-lx2')).toBe('');
370
+ expect(inline('--theme-line-height-custom-lx2')).toBe('');
371
+ expect(styleText(container)).not.toContain('--theme-link');
372
+ expect(styleText(container)).not.toContain('custom-lx');
373
+ // Still set: the roles that remain, and the legacy colours, always.
374
+ expect(inline('--theme-text')).toBe('#555555');
375
+ expect(inline('--theme-line-height-body')).toBe('22px');
376
+ expect(inline('--theme-primary')).toBe('#000000');
377
+ unmount();
378
+ });
379
+
380
+ it('drops every marker once the last text default is removed', () => {
381
+ const { container, unmount } = render(wrapper(NEW_SETTINGS));
382
+ expect(container.querySelector('[data-pz-own-color]')).not.toBeNull();
383
+
384
+ postFromEditor('UPDATE_THEME', LEGACY_SETTINGS);
385
+
386
+ expect(container.querySelector('[data-pz-rich-text]')).toBeNull();
387
+ expect(container.querySelector('[data-pz-own-color]')).toBeNull();
388
+ expect(container.querySelector('[data-pz-own-type]')).toBeNull();
389
+ expect(inline('--theme-text')).toBe('');
390
+ expect(inline('--theme-line-height-heading01')).toBe('');
391
+ // Legacy family variables are still the old loop's to write.
392
+ expect(inline('--theme-font-body')).toBe('Jost');
393
+ unmount();
394
+ });
395
+ });