@akinon/pz-theme 2.0.164-rc.1 → 2.0.165-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +16 -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
@@ -0,0 +1,801 @@
1
+ import {
2
+ buildThemeElementDefaultsCss,
3
+ buildThemeRoleVariablesCss,
4
+ collectThemeRootVariables,
5
+ hasAuthoredRootStyle,
6
+ hasThemeTextDefaults,
7
+ isThemeHexColor,
8
+ isThemeTypographyMetricVariable,
9
+ readManagedTypography,
10
+ readThemeCustomColors,
11
+ readThemeRoleColor,
12
+ syncThemeRootVariables,
13
+ themeOwnPartStyleAttributes,
14
+ themeOwnStyleAttributes,
15
+ THEME_OWN_COLOR_STYLE_KEYS,
16
+ THEME_OWN_TYPE_STYLE_KEYS
17
+ } from '../theme-settings-css';
18
+
19
+ /**
20
+ * The redesigned Web Theme Settings publish five colour roles,
21
+ * custom colours and managed typography to the storefront. Everything here is
22
+ * emitted ONLY for keys that are explicitly set and valid: a theme saved by
23
+ * the old settings page must produce not a single new byte.
24
+ */
25
+
26
+ /** What the old settings page saved: the seed variants plus five colours. */
27
+ const LEGACY = {
28
+ primaryColor: '#000000',
29
+ secondaryColor: '#E95151',
30
+ color3: '#1F2937',
31
+ color4: '#CBCBCB',
32
+ backgroundColor: '#FFFFFF',
33
+ typography: [
34
+ {
35
+ key: 'heading01',
36
+ fontFamily: 'Jost',
37
+ fontWeight: '500',
38
+ fontSize: 34,
39
+ color: '#FFF',
40
+ styles: []
41
+ },
42
+ {
43
+ key: 'heading02',
44
+ fontFamily: 'Jost',
45
+ fontWeight: '500',
46
+ fontSize: 34,
47
+ color: '#FFF',
48
+ styles: []
49
+ },
50
+ {
51
+ key: 'subheading',
52
+ fontFamily: 'Jost',
53
+ fontWeight: '600',
54
+ fontSize: 22,
55
+ color: '#FFF',
56
+ styles: []
57
+ },
58
+ {
59
+ key: 'body',
60
+ fontFamily: 'Jost',
61
+ fontWeight: '400',
62
+ fontSize: 12,
63
+ color: '#FFF',
64
+ styles: []
65
+ }
66
+ ]
67
+ };
68
+
69
+ /** A variant as the new typography screen writes it: all six fields. */
70
+ const managed = (key: string, extra: Record<string, unknown> = {}) => ({
71
+ key,
72
+ fontFamily: 'Jost',
73
+ fontWeight: '600',
74
+ fontSize: 32,
75
+ lineHeight: 40,
76
+ letterSpacing: 0,
77
+ textTransform: 'none',
78
+ color: '#111111',
79
+ styles: [],
80
+ ...extra
81
+ });
82
+
83
+ const R = ':where(.theme-placeholder [data-pz-rich-text])';
84
+ // Every exclusion sits inside :where(), so each element rule stays (0,0,1).
85
+ const XC =
86
+ ':not(:where([data-pz-own-color] *,[data-pz-rich-text] :is([style^="color"],[style*=";color"],[style*="; color"],font[color]) *))';
87
+ const XT =
88
+ ':not(:where([data-pz-own-type] *,[data-pz-rich-text] :is([style*="font-size"],[style*="font-family"],[style*="font-weight"],[style*="font:"],[style*="line-height"],[style*="letter-spacing"],[style*="text-transform"],font[size],font[face]) *))';
89
+ const NOT_IN_HEADING = ':not(:where(:is(h1,h2,h3,h4,h5,h6) *))';
90
+
91
+ describe('a theme that never used the new settings', () => {
92
+ it.each([
93
+ ['null', null],
94
+ ['undefined', undefined],
95
+ ['empty', {}],
96
+ ['legacy', LEGACY]
97
+ ])('emits nothing for %s settings', (_label, settings) => {
98
+ expect(buildThemeRoleVariablesCss(settings)).toBe('');
99
+ expect(buildThemeElementDefaultsCss(settings)).toBe('');
100
+ expect(collectThemeRootVariables(settings)).toEqual([]);
101
+ expect(hasThemeTextDefaults(settings)).toBe(false);
102
+ });
103
+
104
+ it('treats a legacy variant without lineHeight as unmanaged', () => {
105
+ expect(readManagedTypography(LEGACY)).toEqual([]);
106
+ // Empty-ish line heights do not opt a variant in either.
107
+ const settings = {
108
+ typography: [
109
+ managed('heading01', { lineHeight: undefined }),
110
+ managed('heading02', { lineHeight: null }),
111
+ managed('body', { lineHeight: '' }),
112
+ managed('link', { lineHeight: 'tall' }),
113
+ managed('caption', { lineHeight: Number.NaN })
114
+ ]
115
+ };
116
+ expect(readManagedTypography(settings)).toEqual([]);
117
+ expect(buildThemeRoleVariablesCss(settings)).toBe('');
118
+ expect(buildThemeElementDefaultsCss(settings)).toBe('');
119
+ });
120
+ });
121
+
122
+ describe('colour roles', () => {
123
+ const ROLES = {
124
+ linkColor: '#4482FF',
125
+ headingColor: '#111111',
126
+ textColor: '#555555',
127
+ footerBackgroundColor: '#F4F4F4',
128
+ borderColor: '#E2E2E2'
129
+ };
130
+
131
+ it('publish each set role as its variable, in role order', () => {
132
+ expect(buildThemeRoleVariablesCss(ROLES)).toBe(
133
+ ':root{--theme-link:#4482FF;--theme-heading:#111111;--theme-text:#555555;--theme-footer-background:#F4F4F4;--theme-border:#E2E2E2}'
134
+ );
135
+ expect(buildThemeRoleVariablesCss({ linkColor: ' #abc ' })).toBe(
136
+ ':root{--theme-link:#abc}'
137
+ );
138
+ });
139
+
140
+ it('emit one element rule per set role, and only that one', () => {
141
+ expect(buildThemeElementDefaultsCss(ROLES)).toBe(
142
+ [
143
+ `${R}${XC},${R} :is(p,li)${XC}{color:var(--theme-text)}`,
144
+ `${R} :is(h1,h2,h3,h4,h5,h6)${XC}{color:var(--theme-heading)}`,
145
+ `${R} a${XC}{color:var(--theme-link)}`,
146
+ `${R} hr{border-color:var(--theme-border)}`,
147
+ ':where(.theme-placeholder[data-placeholder="footer"]){background-color:var(--theme-footer-background)}'
148
+ ].join('')
149
+ );
150
+ expect(buildThemeElementDefaultsCss({ linkColor: '#4482FF' })).toBe(
151
+ `${R} a${XC}{color:var(--theme-link)}`
152
+ );
153
+ expect(
154
+ buildThemeElementDefaultsCss({ footerBackgroundColor: '#F4F4F4' })
155
+ ).toBe(
156
+ ':where(.theme-placeholder[data-placeholder="footer"]){background-color:var(--theme-footer-background)}'
157
+ );
158
+ });
159
+
160
+ it.each([
161
+ ['a rule breakout', '#fff}a{x:y}'],
162
+ ['a named colour', 'red'],
163
+ ['an rgb() colour', 'rgb(0, 0, 0)'],
164
+ ['a five-digit hex', '#12345'],
165
+ ['a hex without #', '111111'],
166
+ ['a style tag breakout', '</style><script>alert(1)</script>'],
167
+ ['a number', 0x111111]
168
+ ])('drop %s', (_label, value) => {
169
+ const settings = { linkColor: value, textColor: value };
170
+ expect(readThemeRoleColor(settings, 'linkColor')).toBeUndefined();
171
+ expect(buildThemeRoleVariablesCss(settings)).toBe('');
172
+ expect(buildThemeElementDefaultsCss(settings)).toBe('');
173
+ });
174
+
175
+ it('ignore hiddenColors: value presence is the only truth', () => {
176
+ expect(
177
+ buildThemeRoleVariablesCss({
178
+ linkColor: '#4482FF',
179
+ hiddenColors: ['linkColor']
180
+ })
181
+ ).toBe(':root{--theme-link:#4482FF}');
182
+ });
183
+
184
+ it('accept the four hex lengths and nothing else', () => {
185
+ ['#abc', '#abcd', '#AABBCC', '#AABBCCDD'].forEach((value) =>
186
+ expect(isThemeHexColor(value)).toBe(true)
187
+ );
188
+ ['#ab', '#abcde', '#abcdefa', '#GGGGGG', '', null].forEach((value) =>
189
+ expect(isThemeHexColor(value)).toBe(false)
190
+ );
191
+ });
192
+ });
193
+
194
+ describe('custom colours', () => {
195
+ it('publish --theme-<id> for every valid entry', () => {
196
+ const settings = {
197
+ customColors: [
198
+ { id: 'custom-1', name: 'Kampanya', value: '#B0272C' },
199
+ { id: 'custom-lx9k2a', name: 'Yeni', value: '#4482FF' }
200
+ ]
201
+ };
202
+ expect(buildThemeRoleVariablesCss(settings)).toBe(
203
+ ':root{--theme-custom-1:#B0272C;--theme-custom-lx9k2a:#4482FF}'
204
+ );
205
+ // Variables only: no element default for a custom colour.
206
+ expect(buildThemeElementDefaultsCss(settings)).toBe('');
207
+ });
208
+
209
+ it('drop bad ids and values, and keep the first entry of a duplicate id', () => {
210
+ const settings = {
211
+ customColors: [
212
+ { id: 'custom-1', value: '#111111' },
213
+ { id: 'custom-1', value: '#222222' },
214
+ { id: 'custom_2', value: '#333333' },
215
+ { id: 'Custom-3', value: '#444444' },
216
+ { id: 'custom-4;x', value: '#555555' },
217
+ { id: 'custom-', value: '#666666' },
218
+ { id: 'link', value: '#777777' },
219
+ { id: 'custom-5', value: 'red' },
220
+ { id: 'custom-6', value: '#fff}a{x:y}' },
221
+ null,
222
+ 'custom-7'
223
+ ]
224
+ };
225
+ expect(readThemeCustomColors(settings)).toEqual([
226
+ { id: 'custom-1', value: '#111111' }
227
+ ]);
228
+ expect(buildThemeRoleVariablesCss(settings)).toBe(
229
+ ':root{--theme-custom-1:#111111}'
230
+ );
231
+ expect(readThemeCustomColors({ customColors: 'nope' })).toEqual([]);
232
+ });
233
+ });
234
+
235
+ describe('managed typography', () => {
236
+ it('publishes the five metric variables of a managed variant', () => {
237
+ expect(
238
+ collectThemeRootVariables({
239
+ typography: [
240
+ managed('button', {
241
+ fontSize: 14,
242
+ lineHeight: 20,
243
+ letterSpacing: 0.5,
244
+ textTransform: 'uppercase'
245
+ })
246
+ ]
247
+ })
248
+ ).toEqual([
249
+ ['--theme-font-size-button', '14px'],
250
+ ['--theme-font-weight-button', '600'],
251
+ ['--theme-line-height-button', '20px'],
252
+ ['--theme-letter-spacing-button', '0.5px'],
253
+ ['--theme-text-transform-button', 'uppercase']
254
+ ]);
255
+ });
256
+
257
+ it('styles h1–h4, p (and the rich-text root) and a, never button/menu/caption/custom', () => {
258
+ const settings = {
259
+ typography: [
260
+ managed('heading01'),
261
+ managed('body', {
262
+ fontWeight: '400',
263
+ fontSize: 14,
264
+ lineHeight: 22
265
+ }),
266
+ managed('button'),
267
+ managed('menu'),
268
+ managed('caption'),
269
+ managed('custom-lx1', { custom: true })
270
+ ]
271
+ };
272
+ const all =
273
+ 'font-family:"Jost";font-size:var(--theme-font-size-K);font-weight:var(--theme-font-weight-K);line-height:var(--theme-line-height-K);letter-spacing:var(--theme-letter-spacing-K);text-transform:var(--theme-text-transform-K)';
274
+ expect(buildThemeElementDefaultsCss(settings)).toBe(
275
+ [
276
+ `${R} h1${XT}{${all.replace(/K\)/g, 'heading01)')}}`,
277
+ `${R}${XT},${R} p${XT}{${all.replace(/K\)/g, 'body)')}}`
278
+ ].join('')
279
+ );
280
+ expect(hasThemeTextDefaults(settings)).toBe(true);
281
+ expect(
282
+ hasThemeTextDefaults({
283
+ typography: [managed('button'), managed('custom-lx1')]
284
+ })
285
+ ).toBe(false);
286
+ });
287
+
288
+ it('maps every built-in key to its element', () => {
289
+ const css = buildThemeElementDefaultsCss({
290
+ typography: [
291
+ 'heading01',
292
+ 'heading02',
293
+ 'subheading',
294
+ 'heading04',
295
+ 'link'
296
+ ].map((key) => managed(key))
297
+ });
298
+ [
299
+ ['h1', 'heading01'],
300
+ ['h2', 'heading02'],
301
+ ['h3', 'subheading'],
302
+ ['h4', 'heading04'],
303
+ // A link inside a heading keeps the heading's size.
304
+ [`a${NOT_IN_HEADING}`, 'link']
305
+ ].forEach(([element, key]) =>
306
+ expect(css).toContain(
307
+ `${R} ${element}${XT}{font-family:"Jost";font-size:var(--theme-font-size-${key});`
308
+ )
309
+ );
310
+ });
311
+
312
+ it('quotes the family, so a name such as "Source Sans 3" stays a valid declaration', () => {
313
+ const familyOf = (fontFamily: string) =>
314
+ buildThemeElementDefaultsCss({
315
+ typography: [managed('heading01', { fontFamily })]
316
+ }).match(/font-family:([^;]*);/)?.[1];
317
+
318
+ expect(familyOf('Source Sans 3')).toBe('"Source Sans 3"');
319
+ expect(familyOf('Press Start 2P')).toBe('"Press Start 2P"');
320
+ expect(familyOf("'Exo 2'")).toBe('"Exo 2"');
321
+ // A stack keeps its generic family a keyword.
322
+ expect(familyOf('Roboto, sans-serif')).toBe('"Roboto",sans-serif');
323
+ expect(familyOf('"Baloo 2", Serif')).toBe('"Baloo 2",serif');
324
+ expect(familyOf('"",')).toBeUndefined();
325
+ });
326
+
327
+ it('normalises numbers from numbers and numeric strings', () => {
328
+ const [variant] = readManagedTypography({
329
+ typography: [
330
+ managed('body', {
331
+ fontSize: '32px',
332
+ lineHeight: ' 40 ',
333
+ letterSpacing: '-0.2px'
334
+ })
335
+ ]
336
+ });
337
+ expect(variant).toMatchObject({
338
+ size: 32,
339
+ lineHeight: 40,
340
+ letterSpacing: -0.2
341
+ });
342
+ expect(
343
+ collectThemeRootVariables({
344
+ typography: [managed('body', { letterSpacing: 0.1 + 0.2 })]
345
+ })
346
+ ).toContainEqual(['--theme-letter-spacing-body', '0.3px']);
347
+ });
348
+
349
+ it('clamps sizes to 1..400 and letter spacing to -50..50', () => {
350
+ const [variant] = readManagedTypography({
351
+ typography: [
352
+ managed('body', { fontSize: 1000, lineHeight: 0, letterSpacing: -80 })
353
+ ]
354
+ });
355
+ expect(variant).toMatchObject({
356
+ size: 400,
357
+ lineHeight: 1,
358
+ letterSpacing: -50
359
+ });
360
+ });
361
+
362
+ it('drops invalid fields one by one and keeps the rest', () => {
363
+ const settings = {
364
+ typography: [
365
+ managed('body', {
366
+ fontFamily: 'Evil;}*{color:red',
367
+ fontWeight: '650',
368
+ fontSize: Number.NaN,
369
+ letterSpacing: '1em',
370
+ textTransform: 'capitalize'
371
+ })
372
+ ]
373
+ };
374
+ expect(collectThemeRootVariables(settings)).toEqual([
375
+ ['--theme-line-height-body', '40px']
376
+ ]);
377
+ // No family declaration for a family that could end the declaration.
378
+ expect(buildThemeElementDefaultsCss(settings)).toBe(
379
+ `${R}${XT},${R} p${XT}{line-height:var(--theme-line-height-body)}`
380
+ );
381
+ });
382
+
383
+ it('accepts a numeric weight and skips the family declaration when none resolves', () => {
384
+ const settings = {
385
+ typography: [
386
+ managed('heading02', { fontWeight: 700, fontFamily: 'inherit' })
387
+ ]
388
+ };
389
+ expect(collectThemeRootVariables(settings)).toContainEqual([
390
+ '--theme-font-weight-heading02',
391
+ '700'
392
+ ]);
393
+ expect(buildThemeElementDefaultsCss(settings)).not.toContain(
394
+ 'font-family:'
395
+ );
396
+ });
397
+
398
+ it('drops a bad variant key and keeps the first of a duplicate key', () => {
399
+ const settings = {
400
+ typography: [
401
+ managed('body;}*{', {}),
402
+ managed('body', { fontSize: 14 }),
403
+ managed('body', { fontSize: 99 })
404
+ ]
405
+ };
406
+ expect(readManagedTypography(settings).map((v) => [v.key, v.size])).toEqual(
407
+ [['body', 14]]
408
+ );
409
+ });
410
+
411
+ it('only counts variables with the metric prefixes as metrics', () => {
412
+ expect(isThemeTypographyMetricVariable('--theme-font-size-body')).toBe(
413
+ true
414
+ );
415
+ expect(isThemeTypographyMetricVariable('--theme-text-transform-x')).toBe(
416
+ true
417
+ );
418
+ expect(isThemeTypographyMetricVariable('--theme-font-body')).toBe(false);
419
+ expect(isThemeTypographyMetricVariable('--theme-text')).toBe(false);
420
+ });
421
+ });
422
+
423
+ describe('the style tag', () => {
424
+ it('cannot be broken out of through any new key', () => {
425
+ const evil = '</style><script>alert(1)</script>';
426
+ const settings = {
427
+ linkColor: evil,
428
+ customColors: [{ id: 'custom-1', value: evil }],
429
+ typography: [
430
+ managed('body', {
431
+ fontFamily: evil,
432
+ fontWeight: evil,
433
+ textTransform: evil,
434
+ fontSize: evil
435
+ }),
436
+ managed(evil)
437
+ ]
438
+ };
439
+ const css =
440
+ buildThemeRoleVariablesCss(settings) +
441
+ buildThemeElementDefaultsCss(settings);
442
+ expect(css).not.toMatch(/<\/style/i);
443
+ expect(css).not.toContain('<script');
444
+ });
445
+ });
446
+
447
+ describe('the text-default gate', () => {
448
+ it('opens for text, heading, link and border colours only', () => {
449
+ expect(hasThemeTextDefaults({ textColor: '#555555' })).toBe(true);
450
+ expect(hasThemeTextDefaults({ headingColor: '#111111' })).toBe(true);
451
+ expect(hasThemeTextDefaults({ linkColor: '#111111' })).toBe(true);
452
+ expect(hasThemeTextDefaults({ borderColor: '#E2E2E2' })).toBe(true);
453
+ expect(hasThemeTextDefaults({ footerBackgroundColor: '#F4F4F4' })).toBe(
454
+ false
455
+ );
456
+ expect(
457
+ hasThemeTextDefaults({
458
+ customColors: [{ id: 'custom-1', value: '#111111' }]
459
+ })
460
+ ).toBe(false);
461
+ expect(hasThemeTextDefaults({ textColor: 'red' })).toBe(false);
462
+ });
463
+ });
464
+
465
+ describe('authored root styles', () => {
466
+ it('count a root colour at any breakpoint', () => {
467
+ expect(hasAuthoredRootStyle({ color: { mobile: '#fff' } }, ['color'])).toBe(
468
+ true
469
+ );
470
+ expect(hasAuthoredRootStyle({ color: '#fff' }, ['color'])).toBe(true);
471
+ expect(
472
+ hasAuthoredRootStyle({ color: 'var(--theme-primary)' }, ['color'])
473
+ ).toBe(true);
474
+ });
475
+
476
+ it('ignore empty values and part keys', () => {
477
+ expect(hasAuthoredRootStyle({ color: '' }, ['color'])).toBe(false);
478
+ expect(hasAuthoredRootStyle({ color: { desktop: '' } }, ['color'])).toBe(
479
+ false
480
+ );
481
+ expect(hasAuthoredRootStyle({ color: null }, ['color'])).toBe(false);
482
+ expect(hasAuthoredRootStyle({ 'label/color': '#fff' }, ['color'])).toBe(
483
+ false
484
+ );
485
+ expect(
486
+ hasAuthoredRootStyle({ 'background-color': '#000' }, ['color'])
487
+ ).toBe(false);
488
+ expect(hasAuthoredRootStyle({ ':scrolled/color': '' }, ['color'])).toBe(
489
+ false
490
+ );
491
+ expect(hasAuthoredRootStyle(undefined, ['color'])).toBe(false);
492
+ });
493
+
494
+ it('count a root-state colour or typography: the state rule paints the element', () => {
495
+ // A header band that only turns white once scrolled, a group that only
496
+ // recolours on hover: the rich text inside has to keep inheriting it.
497
+ expect(
498
+ hasAuthoredRootStyle({ ':scrolled/color': { desktop: '#ffffff' } }, [
499
+ 'color'
500
+ ])
501
+ ).toBe(true);
502
+ expect(hasAuthoredRootStyle({ ':hover/color': '#fff' }, ['color'])).toBe(
503
+ true
504
+ );
505
+ expect(
506
+ hasAuthoredRootStyle(
507
+ { ':hover/font-weight': { desktop: '700' } },
508
+ THEME_OWN_TYPE_STYLE_KEYS
509
+ )
510
+ ).toBe(true);
511
+ expect(
512
+ themeOwnStyleAttributes(
513
+ { ':scrolled/color': { desktop: '#ffffff' } },
514
+ { textColor: '#C8C8C8' }
515
+ )
516
+ ).toEqual({ 'data-pz-own-color': '' });
517
+ });
518
+
519
+ it('take colour or typography the caller paints itself', () => {
520
+ const owned = { color: true, type: true };
521
+ expect(
522
+ themeOwnStyleAttributes(null, { textColor: '#C8C8C8' }, owned)
523
+ ).toEqual({ 'data-pz-own-color': '', 'data-pz-own-type': '' });
524
+ expect(
525
+ themeOwnStyleAttributes({}, { textColor: '#C8C8C8' }, { type: true })
526
+ ).toEqual({ 'data-pz-own-type': '' });
527
+ // Still nothing on a theme without text defaults.
528
+ expect(themeOwnStyleAttributes(null, LEGACY, owned)).toBeNull();
529
+ });
530
+
531
+ it('count every typography property, kebab or camel case', () => {
532
+ THEME_OWN_TYPE_STYLE_KEYS.forEach((key) =>
533
+ expect(
534
+ hasAuthoredRootStyle(
535
+ { [key]: { desktop: '1' } },
536
+ THEME_OWN_TYPE_STYLE_KEYS
537
+ )
538
+ ).toBe(true)
539
+ );
540
+ expect(
541
+ hasAuthoredRootStyle(
542
+ { fontSize: { desktop: 18 } },
543
+ THEME_OWN_TYPE_STYLE_KEYS
544
+ )
545
+ ).toBe(true);
546
+ expect(
547
+ hasAuthoredRootStyle({ 'font-size': '18px' }, THEME_OWN_COLOR_STYLE_KEYS)
548
+ ).toBe(false);
549
+ });
550
+
551
+ it('become markers only while the theme has text defaults', () => {
552
+ const styles = { color: '#fff', 'font-size': '18px' };
553
+ expect(themeOwnStyleAttributes(styles, LEGACY)).toBeNull();
554
+ expect(themeOwnStyleAttributes(styles, null)).toBeNull();
555
+ expect(themeOwnStyleAttributes(styles, { linkColor: '#111111' })).toEqual({
556
+ 'data-pz-own-color': '',
557
+ 'data-pz-own-type': ''
558
+ });
559
+ expect(
560
+ themeOwnStyleAttributes({ color: '#fff' }, { linkColor: '#111111' })
561
+ ).toEqual({ 'data-pz-own-color': '' });
562
+ expect(
563
+ themeOwnStyleAttributes({ padding: '8px' }, { linkColor: '#111111' })
564
+ ).toBeNull();
565
+ });
566
+ });
567
+
568
+ describe('authored part styles', () => {
569
+ const settings = { textColor: '#C8C8C8' };
570
+
571
+ it('mark a part whose colour or typography is authored, at rest or in a state', () => {
572
+ expect(
573
+ themeOwnPartStyleAttributes(
574
+ { 'header/color': { desktop: '#ff0000' } },
575
+ 'header',
576
+ settings
577
+ )
578
+ ).toEqual({ 'data-pz-own-color': '' });
579
+ expect(
580
+ themeOwnPartStyleAttributes(
581
+ { 'header/open/color': { desktop: '#ff0000' } },
582
+ 'header',
583
+ settings
584
+ )
585
+ ).toEqual({ 'data-pz-own-color': '' });
586
+ expect(
587
+ themeOwnPartStyleAttributes(
588
+ { 'content/font-size': { desktop: '18px' }, 'content/color': '#111' },
589
+ 'content',
590
+ settings
591
+ )
592
+ ).toEqual({ 'data-pz-own-color': '', 'data-pz-own-type': '' });
593
+ });
594
+
595
+ it('ignore the root, other parts, other properties and themes without text defaults', () => {
596
+ const styles = {
597
+ color: '#000',
598
+ 'panel/color': '#111',
599
+ 'header/background-color': '#222',
600
+ ':hover/color': '#333'
601
+ };
602
+ expect(themeOwnPartStyleAttributes(styles, 'header', settings)).toBeNull();
603
+ expect(
604
+ themeOwnPartStyleAttributes({ 'header/color': '#111' }, 'header', LEGACY)
605
+ ).toBeNull();
606
+ expect(
607
+ themeOwnPartStyleAttributes(undefined, 'header', settings)
608
+ ).toBeNull();
609
+ });
610
+ });
611
+
612
+ /**
613
+ * The editor's rich-text colour tool (applyForeColor) wraps the selection in
614
+ * a span whose `style.color` it sets, so paragraphs, headings and links end
615
+ * up INSIDE an inline colour. Built here the same way, so the markup is what
616
+ * a browser serialises for it.
617
+ */
618
+ const foreColor = (color: string, html: string) => {
619
+ const span = document.createElement('span');
620
+ span.style.color = color;
621
+ span.innerHTML = html;
622
+ return span.outerHTML;
623
+ };
624
+
625
+ /** `selector{declarations}` pairs of a flat stylesheet. */
626
+ const rulesOf = (css: string) =>
627
+ css
628
+ .split('}')
629
+ .filter(Boolean)
630
+ .map((rule) => {
631
+ const [selector, declarations] = rule.split('{');
632
+ return { selector, declarations };
633
+ });
634
+
635
+ /** Ids of the elements matched by the rule that carries `declaration`. */
636
+ const matchedIds = (css: string, declaration: string) => {
637
+ const rule = rulesOf(css).find((r) => r.declarations.includes(declaration));
638
+ if (!rule) throw new Error(`no rule with ${declaration}`);
639
+ return Array.from(document.querySelectorAll(rule.selector))
640
+ .map((el) => el.id || 'root')
641
+ .sort();
642
+ };
643
+
644
+ const mountRichText = (html: string) => {
645
+ document.body.innerHTML = `<div class="theme-placeholder"><div data-pz-rich-text="">${html}</div></div>`;
646
+ };
647
+
648
+ describe('inline colour inside rich text', () => {
649
+ afterEach(() => {
650
+ document.body.innerHTML = '';
651
+ });
652
+
653
+ const css = buildThemeElementDefaultsCss({
654
+ textColor: '#555555',
655
+ linkColor: '#4482FF',
656
+ headingColor: '#222222'
657
+ });
658
+
659
+ it('is kept for everything the colour tool wrapped', () => {
660
+ mountRichText(
661
+ [
662
+ // Two paragraphs coloured at once.
663
+ foreColor(
664
+ '#ff0000',
665
+ '<p id="red-p1">First</p><p id="red-p2">Second</p>'
666
+ ),
667
+ // A sentence with a link in it.
668
+ `<p id="host-p">${foreColor(
669
+ '#ff0000',
670
+ 'Read <a id="red-a" href="/x">our story</a>'
671
+ )}</p>`,
672
+ // A link made on text that was already coloured.
673
+ `<p id="host-p2">${foreColor(
674
+ '#ff0000',
675
+ '<a id="red-a2" href="/y">linked</a>'
676
+ )}</p>`,
677
+ foreColor('#ff0000', '<h2 id="red-h2">Title</h2>'),
678
+ '<span style="font-weight: 700; color: rgb(0, 0, 255);"><a id="multi-a" href="/z">x</a></span>',
679
+ '<span style="font-weight:700;color:#00f"><a id="tight-a" href="/z">x</a></span>',
680
+ '<font color="#ff0000"><p id="font-p">Old</p></font>',
681
+ // Not a text colour: still the theme's.
682
+ '<span style="background-color: yellow;"><a id="bg-a" href="/w">hl</a></span>',
683
+ '<h2 id="plain-h2">Plain</h2><p id="plain-p">Para <a id="plain-a" href="/v">link</a></p><ul><li id="plain-li">One</li></ul>'
684
+ ].join('')
685
+ );
686
+
687
+ expect(matchedIds(css, 'color:var(--theme-text)')).toEqual([
688
+ 'host-p',
689
+ 'host-p2',
690
+ 'plain-li',
691
+ 'plain-p',
692
+ 'root'
693
+ ]);
694
+ expect(matchedIds(css, 'color:var(--theme-link)')).toEqual([
695
+ 'bg-a',
696
+ 'plain-a'
697
+ ]);
698
+ expect(matchedIds(css, 'color:var(--theme-heading)')).toEqual(['plain-h2']);
699
+ });
700
+ });
701
+
702
+ describe('inline typography and links in headings', () => {
703
+ afterEach(() => {
704
+ document.body.innerHTML = '';
705
+ });
706
+
707
+ const css = buildThemeElementDefaultsCss({
708
+ linkColor: '#4482FF',
709
+ typography: [
710
+ managed('heading01'),
711
+ managed('heading02'),
712
+ managed('body', { fontSize: 14, lineHeight: 22 }),
713
+ managed('link', { fontSize: 14, lineHeight: 22 })
714
+ ]
715
+ });
716
+
717
+ it('keep pasted inline typography and a heading size on its link', () => {
718
+ mountRichText(
719
+ [
720
+ '<span style="font-size: 20px;"><p id="big-p">Big</p></span>',
721
+ '<div style="line-height: 30px;"><h1 id="lh-h1">Tall</h1></div>',
722
+ '<font face="Georgia"><a id="face-a" href="/f">Serif</a></font>',
723
+ '<h2 id="h2"><a id="h2-a" href="/sale">Shop the sale</a></h2>',
724
+ '<h1 id="plain-h1">H</h1><p id="plain-p">P <a id="plain-a" href="/p">a</a></p>'
725
+ ].join('')
726
+ );
727
+
728
+ expect(
729
+ matchedIds(css, 'line-height:var(--theme-line-height-body)')
730
+ ).toEqual(['plain-p', 'root']);
731
+ expect(
732
+ matchedIds(css, 'line-height:var(--theme-line-height-heading01)')
733
+ ).toEqual(['plain-h1']);
734
+ expect(
735
+ matchedIds(css, 'line-height:var(--theme-line-height-heading02)')
736
+ ).toEqual(['h2']);
737
+ expect(
738
+ matchedIds(css, 'line-height:var(--theme-line-height-link)')
739
+ ).toEqual(['plain-a']);
740
+ // The link colour still reaches a link inside a heading.
741
+ expect(matchedIds(css, 'color:var(--theme-link)')).toEqual(
742
+ expect.arrayContaining(['h2-a', 'plain-a'])
743
+ );
744
+ });
745
+ });
746
+
747
+ describe('the inline variable sync', () => {
748
+ const fakeStyle = () => {
749
+ const values = new Map<string, string>();
750
+ return {
751
+ values,
752
+ setProperty: (name: string, value: string | null) => {
753
+ values.set(name, String(value));
754
+ },
755
+ removeProperty: (name: string) => {
756
+ values.delete(name);
757
+ return '';
758
+ }
759
+ };
760
+ };
761
+
762
+ it('sets the next variables and removes the ones that are gone', () => {
763
+ const style = fakeStyle();
764
+ let written = syncThemeRootVariables(
765
+ style,
766
+ [
767
+ ['--theme-link', '#111111'],
768
+ ['--theme-custom-1', '#222222']
769
+ ],
770
+ new Set()
771
+ );
772
+ expect(Object.fromEntries(style.values)).toEqual({
773
+ '--theme-link': '#111111',
774
+ '--theme-custom-1': '#222222'
775
+ });
776
+
777
+ written = syncThemeRootVariables(
778
+ style,
779
+ [['--theme-link', '#333333']],
780
+ written
781
+ );
782
+ expect(Object.fromEntries(style.values)).toEqual({
783
+ '--theme-link': '#333333'
784
+ });
785
+ expect(Array.from(written)).toEqual(['--theme-link']);
786
+ });
787
+
788
+ it('never writes or removes the five legacy colour variables', () => {
789
+ const style = fakeStyle();
790
+ style.values.set('--theme-primary', '#000000');
791
+ const written = syncThemeRootVariables(
792
+ style,
793
+ [['--theme-background', '#ff0000']],
794
+ new Set(['--theme-primary', '--theme-secondary'])
795
+ );
796
+ expect(Object.fromEntries(style.values)).toEqual({
797
+ '--theme-primary': '#000000'
798
+ });
799
+ expect(written.size).toBe(0);
800
+ });
801
+ });