@akinon/pz-theme 2.0.83-rc.0 → 2.0.84-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 (63) hide show
  1. package/CHANGELOG.md +39 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-flow-block.test.tsx +211 -0
  4. package/src/__tests__/auth-flow-bus.test.ts +290 -0
  5. package/src/__tests__/auth-form-error-block.test.tsx +326 -0
  6. package/src/__tests__/auth-form-validation.test.tsx +204 -0
  7. package/src/__tests__/auth-step-title.test.tsx +202 -0
  8. package/src/__tests__/detached-submit-state.test.tsx +216 -0
  9. package/src/__tests__/forgot-password-composed.test.tsx +313 -0
  10. package/src/__tests__/otp-form-composed.test.tsx +487 -0
  11. package/src/__tests__/password-reset-step.test.tsx +269 -0
  12. package/src/__tests__/register-form-composed.test.tsx +739 -0
  13. package/src/__tests__/registry-parity.test.ts +9 -0
  14. package/src/auth/auth-field-errors.tsx +151 -0
  15. package/src/auth/auth-flow-bus.ts +415 -0
  16. package/src/auth/form-error-context.tsx +55 -0
  17. package/src/auth/form-submit-bus.ts +93 -0
  18. package/src/auth/otp-form-context.tsx +80 -0
  19. package/src/blocks/__tests__/button-block.test.tsx +55 -0
  20. package/src/blocks/__tests__/link-block.test.tsx +48 -30
  21. package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
  22. package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
  23. package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
  24. package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
  25. package/src/blocks/__tests__/text-block.test.tsx +37 -3
  26. package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
  27. package/src/blocks/auth-consent-block.tsx +214 -0
  28. package/src/blocks/auth-error-block.tsx +82 -0
  29. package/src/blocks/auth-field-block.tsx +270 -0
  30. package/src/blocks/auth-flow-block.tsx +251 -0
  31. package/src/blocks/auth-form-block.tsx +391 -47
  32. package/src/blocks/auth-step-block.tsx +81 -0
  33. package/src/blocks/auth-step-title-block.tsx +81 -0
  34. package/src/blocks/block-renderer-registry.tsx +18 -0
  35. package/src/blocks/button-block.tsx +91 -13
  36. package/src/blocks/forgot-password-block.tsx +318 -24
  37. package/src/blocks/link-block.tsx +22 -84
  38. package/src/blocks/otp-code-input-block.tsx +180 -0
  39. package/src/blocks/otp-form-block.tsx +1092 -0
  40. package/src/blocks/otp-phase-block.tsx +71 -0
  41. package/src/blocks/register-form-block.tsx +541 -74
  42. package/src/blocks/text-block.tsx +17 -40
  43. package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
  44. package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
  45. package/src/hooks/use-block-action.ts +87 -4
  46. package/src/hooks/use-overlay.ts +60 -9
  47. package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
  48. package/src/overlays/__tests__/modal-registry.test.ts +6 -1
  49. package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
  50. package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
  51. package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
  52. package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
  53. package/src/overlays/modal-preview-host.tsx +39 -2
  54. package/src/overlays/modal-registry.ts +344 -4
  55. package/src/overlays/overlay-surface.tsx +249 -19
  56. package/src/overlays/overlay-tokens.ts +94 -0
  57. package/src/overlays/theme-modal-mount.tsx +34 -4
  58. package/src/theme-block.tsx +6 -0
  59. package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
  60. package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
  61. package/src/utils/index.ts +107 -6
  62. package/src/utils/iterator-utils.ts +39 -2
  63. package/src/utils/part-styles.ts +4 -0
@@ -0,0 +1,139 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ import {
5
+ INNER_ROOT_BLOCK_TYPES,
6
+ generateThemeCSS,
7
+ getInnerRootInlineStyles
8
+ } from '../index';
9
+ import TextBlock from '../../blocks/text-block';
10
+ import type { Block } from '../../theme-block';
11
+
12
+ /**
13
+ * Where a block's root styles are applied — once.
14
+ *
15
+ * A handful of renderers draw their own root element rather than letting the
16
+ * designer wrapper be the box: a text is a `<div>` with the copy in it, a link
17
+ * is an `<a>`, a button is a `<button>`. Those renderers also inlined the
18
+ * block's whole style object on that element, while the generated stylesheet
19
+ * wrote the same declarations onto the wrapper around it. A padding of 24 was
20
+ * therefore rendered as 24 outside AND 24 inside, and a designer who typed one
21
+ * number saw two gaps.
22
+ *
23
+ * The fix is one rule: for these types the sheet targets the element the
24
+ * renderer draws (`data-part="root"`), and the renderer inlines only what a
25
+ * stylesheet cannot say.
26
+ */
27
+
28
+ const blockOf = (overrides: Partial<Block>): Block =>
29
+ ({
30
+ id: 'blk',
31
+ type: 'text',
32
+ label: 'Block',
33
+ value: 'Hello',
34
+ order: 0,
35
+ hidden: false,
36
+ properties: {},
37
+ styles: {},
38
+ blocks: [],
39
+ ...overrides
40
+ }) as Block;
41
+
42
+ const sectionWith = (block: Partial<Block>) =>
43
+ [
44
+ {
45
+ id: 'sec-1',
46
+ styles: {},
47
+ blocks: [blockOf(block)]
48
+ }
49
+ ] as unknown as Parameters<typeof generateThemeCSS>[0];
50
+
51
+ describe('a renderer that draws its own root', () => {
52
+ it('is the element the stylesheet targets, so a padding lands once', () => {
53
+ const css = generateThemeCSS(
54
+ sectionWith({ id: 'txt', type: 'text', styles: { padding: '24px' } })
55
+ );
56
+
57
+ expect(css).toContain('[data-block-id="txt"] [data-part="root"] {');
58
+ // The wrapper must NOT also carry it: that second application is the bug.
59
+ expect(css).not.toContain('[data-block-id="txt"] {');
60
+ });
61
+
62
+ it('leaves every other block type on the wrapper', () => {
63
+ const css = generateThemeCSS(
64
+ sectionWith({ id: 'img', type: 'image', styles: { padding: '24px' } })
65
+ );
66
+
67
+ // Published compositions render against the wrapper rule; only the types
68
+ // whose renderer marks its root may be moved.
69
+ expect(css).toContain('[data-block-id="img"] {');
70
+ expect(css).not.toContain('[data-part="root"]');
71
+ });
72
+
73
+ it('covers the three renderers that mark their root', () => {
74
+ expect([...INNER_ROOT_BLOCK_TYPES].sort()).toEqual([
75
+ 'button',
76
+ 'link',
77
+ 'text'
78
+ ]);
79
+ });
80
+ });
81
+
82
+ describe('what still has to be inlined', () => {
83
+ it('is nothing, for a plain value the sheet can write', () => {
84
+ expect(
85
+ getInnerRootInlineStyles({ padding: '24px', 'font-size': '16px' })
86
+ ).toEqual({});
87
+ });
88
+
89
+ it('is a theme token, which only the renderer can resolve', () => {
90
+ const inline = getInnerRootInlineStyles(
91
+ { color: 'var(--theme-primary)', padding: '24px' },
92
+ { primaryColor: '#00a86b' } as never
93
+ );
94
+
95
+ // Normalised to rgba the same way every colour is, alpha 1 for "no
96
+ // opacity set" — what matters is that the token became a real colour.
97
+ expect(inline).toEqual({ color: 'rgba(0, 168, 107, 1)' });
98
+ });
99
+
100
+ it('is a colour with text-opacity folded into it', () => {
101
+ // The generated CSS treats text-opacity as a helper key and never applies
102
+ // it to `color`, so this composition only exists inline.
103
+ const inline = getInnerRootInlineStyles({
104
+ color: '#00a86b',
105
+ 'text-opacity': '40'
106
+ });
107
+
108
+ expect(inline).toEqual({ color: 'rgba(0, 168, 107, 0.4)' });
109
+ });
110
+
111
+ it('is whatever an iterator row resolved for itself', () => {
112
+ // One rule serves every clone, so a per-row value has nowhere else to live.
113
+ const inline = getInnerRootInlineStyles(
114
+ { 'background-color': '#e63946', 'border-radius': '4px' },
115
+ undefined,
116
+ 'desktop',
117
+ ['background-color']
118
+ );
119
+
120
+ expect(inline).toEqual({ backgroundColor: 'rgba(230, 57, 70, 1)' });
121
+ });
122
+ });
123
+
124
+ describe('the text block', () => {
125
+ it('marks its root and inlines no padding of its own', () => {
126
+ const { container } = render(
127
+ <TextBlock
128
+ block={blockOf({ styles: { padding: '24px', color: '#111111' } })}
129
+ currentBreakpoint="desktop"
130
+ />
131
+ );
132
+
133
+ const root = container.querySelector('[data-part="root"]') as HTMLElement;
134
+
135
+ expect(root).not.toBeNull();
136
+ expect(root.style.padding).toBe('');
137
+ expect(root.style.color).toBe('');
138
+ });
139
+ });
@@ -303,15 +303,21 @@ describe('a style value can carry a binding', () => {
303
303
  // through, so the row gets a real rgba rather than a flat swatch.
304
304
  expect(painted[0].style.backgroundColor).toBe('rgba(0, 168, 107, 0.4)');
305
305
  expect(painted[1].style.backgroundColor).toMatch(/230, 57, 70/);
306
- // The unbound property is still on every row.
307
- expect(painted[0].style.borderRadius).toBe('4px');
306
+ // The UNBOUND property is deliberately not inlined: it belongs to the one
307
+ // rule every clone shares (see INNER_ROOT_BLOCK_TYPES). Inlining it as
308
+ // well applied it twice — once from the rule, once from here — which is
309
+ // what doubled every padding a designer set on a text block.
310
+ expect(painted[0].style.borderRadius).toBe('');
308
311
  });
309
312
 
310
313
  it('paints nothing where the row carries no colour', () => {
311
314
  const [painted] = paintBadges([{ name: 'No badge' }]);
312
315
 
313
316
  expect(painted.style.backgroundColor).toBe('');
314
- expect(painted.getAttribute('style')).not.toContain('undefined');
317
+ // No inline attribute at all now that the unbound properties live on the
318
+ // shared rule — but the point stands: an unresolved binding must never
319
+ // reach the element as the string `undefined`.
320
+ expect(painted.getAttribute('style') ?? '').not.toContain('undefined');
315
321
  });
316
322
  });
317
323
  });
@@ -477,9 +477,11 @@ const resolveSectionMaxWidthTokens = <T extends Record<string, unknown>>(
477
477
  if (value && typeof value === 'object' && !Array.isArray(value)) {
478
478
  const rawEntries = Object.entries(value as Record<string, unknown>);
479
479
 
480
- if (rawEntries.some(([, breakpointValue]) =>
481
- isSectionMaxWidthToken(breakpointValue)
482
- )) {
480
+ if (
481
+ rawEntries.some(([, breakpointValue]) =>
482
+ isSectionMaxWidthToken(breakpointValue)
483
+ )
484
+ ) {
483
485
  constrained = true;
484
486
  }
485
487
 
@@ -525,6 +527,105 @@ const resolveSectionMaxWidthTokens = <T extends Record<string, unknown>>(
525
527
  return changed ? (resolved as T) : styles;
526
528
  };
527
529
 
530
+ /**
531
+ * Blocks whose renderer draws its OWN root element and marks it
532
+ * `data-part="root"`.
533
+ *
534
+ * Their generated root rule is routed to that element instead of onto the
535
+ * designer wrapper (see `rootSelector` in `collectBlockCSS`). Two things go
536
+ * wrong without it: a padding set on the block lands on the wrapper AND is
537
+ * inlined on the inner element by the renderer, so the shopper sees it twice;
538
+ * and a border or background set on a functional block stops at a transparent
539
+ * div that wraps the control instead of reaching it.
540
+ *
541
+ * A type not listed here keeps landing on the wrapper, which is what every
542
+ * other published block renders against today.
543
+ */
544
+ export const INNER_ROOT_BLOCK_TYPES: ReadonlySet<string> = new Set([
545
+ 'text',
546
+ 'link',
547
+ 'button'
548
+ ]);
549
+
550
+ /**
551
+ * The part of a block's root styles that the STYLESHEET cannot produce, and
552
+ * which a renderer drawing its own root therefore still has to inline:
553
+ *
554
+ * - values carrying a theme token, which only `resolveThemeCssVariables`
555
+ * turns into a real colour;
556
+ * - `color`, once `text-opacity` has to be composited into it — the generated
557
+ * CSS treats text-opacity as a helper key and never applies it.
558
+ *
559
+ * Everything else comes from the routed rule, which is the whole point: a
560
+ * padding inlined here as well would be applied twice, and — being inline — it
561
+ * would also outrank the media query and the `:hover` the same stylesheet
562
+ * emits for that element.
563
+ */
564
+ export const getInnerRootInlineStyles = (
565
+ rawStyles?: Record<string, unknown>,
566
+ themeSettings?: ThemeSettings,
567
+ breakpoint = 'desktop',
568
+ /**
569
+ * Properties an iterator clone resolved from its own row. They cannot come
570
+ * from the sheet at all: one rule serves every clone, so a per-row colour
571
+ * only exists here.
572
+ */
573
+ rowStyleKeys?: string[]
574
+ ): React.CSSProperties => {
575
+ if (!rawStyles) return {};
576
+
577
+ const resolved = getCSSStyles(rawStyles, themeSettings, breakpoint) as Record<
578
+ string,
579
+ unknown
580
+ >;
581
+ // The same read with no settings to resolve against: whatever differs is a
582
+ // value that carried a token.
583
+ const verbatim = getCSSStyles(rawStyles, undefined, breakpoint) as Record<
584
+ string,
585
+ unknown
586
+ >;
587
+
588
+ const opacityEntry =
589
+ (rawStyles as Record<string, unknown>)['text-opacity'] ??
590
+ (rawStyles as Record<string, unknown>).textOpacity;
591
+ const textOpacity = opacityEntry
592
+ ? Number(getResponsiveValue(opacityEntry, breakpoint, '100'))
593
+ : 100;
594
+ const compositesTextAlpha = Number.isFinite(textOpacity) && textOpacity < 100;
595
+
596
+ // The row's own properties, in the camelCase the resolved object uses. An
597
+ // opacity binding paints its companion colour, so it keeps that key instead.
598
+ const OPACITY_TARGETS: Record<string, string[]> = {
599
+ 'text-opacity': ['color'],
600
+ 'background-opacity': ['backgroundColor', 'background']
601
+ };
602
+ const rowKeys = new Set(
603
+ (rowStyleKeys ?? []).flatMap((key) => {
604
+ const normalized = key.includes('-')
605
+ ? key
606
+ : key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
607
+
608
+ return OPACITY_TARGETS[normalized] ?? [kebabToCamel(normalized)];
609
+ })
610
+ );
611
+
612
+ const kept: Record<string, unknown> = {};
613
+
614
+ Object.entries(resolved).forEach(([key, value]) => {
615
+ const carriesToken = value !== verbatim[key];
616
+
617
+ if (
618
+ carriesToken ||
619
+ rowKeys.has(key) ||
620
+ (key === 'color' && compositesTextAlpha)
621
+ ) {
622
+ kept[key] = value;
623
+ }
624
+ });
625
+
626
+ return kept as React.CSSProperties;
627
+ };
628
+
528
629
  export const getCSSStyles = (
529
630
  rawStyles?: Record<string, unknown>,
530
631
  themeSettings?: ThemeSettings,
@@ -1262,7 +1363,6 @@ const generateElementCSS = (
1262
1363
  const mobileHoverCss = buildHoverCss('mobile', ' ');
1263
1364
 
1264
1365
  if (mobileStyles.length > 0) {
1265
-
1266
1366
  css += `${MOBILE_MEDIA_QUERY} {\n ${selector} {\n ${mobileStyles.join(
1267
1367
  ';\n '
1268
1368
  )};\n }\n${mobileHoverCss}}\n`;
@@ -1281,7 +1381,6 @@ const generateElementCSS = (
1281
1381
  mobileStyles.length > 0 ? mobileHoverCss : buildHoverCss('desktop', ' ');
1282
1382
 
1283
1383
  if (tabletStyles.length > 0 || tabletHoverCss !== inheritedHoverCss) {
1284
-
1285
1384
  const tabletDeclarations =
1286
1385
  tabletStyles.length > 0
1287
1386
  ? ` ${selector} {\n ${tabletStyles.join(';\n ')};\n }\n`
@@ -1407,7 +1506,9 @@ const collectBlockCSS = (
1407
1506
  // A block that names its real root with `data-part="root"` gets its
1408
1507
  // own styles routed there instead. Blocks that do not are untouched,
1409
1508
  // which is what keeps published compositions rendering as before.
1410
- const rootSelector = block.stylesTargetRoot
1509
+ const targetsInnerRoot =
1510
+ block.stylesTargetRoot ?? INNER_ROOT_BLOCK_TYPES.has(block.type ?? '');
1511
+ const rootSelector = targetsInnerRoot
1411
1512
  ? `${selector} [data-part="root"]`
1412
1513
  : selector;
1413
1514
 
@@ -941,6 +941,39 @@ const isRowBoundStyleValue = (value: unknown): value is string =>
941
941
  * SAME object when nothing is bound, so a composition without style bindings
942
942
  * is not merely equal but identical — no new object, no changed CSS.
943
943
  */
944
+ /**
945
+ * Which properties a row resolves for itself.
946
+ *
947
+ * Read from the template's styles, while the tokens are still in them. The
948
+ * renderer needs the list because a per-row value is the one thing the shared
949
+ * stylesheet cannot carry: one rule serves every clone, so a badge colour that
950
+ * differs per product has to be inlined on the element. Everything else stays
951
+ * on the rule — see the note above `resolveStyleBindingsForRow`.
952
+ */
953
+ export const collectRowBoundStyleKeys = (styles: unknown): string[] => {
954
+ if (!isRecord(styles)) return [];
955
+
956
+ const keys: string[] = [];
957
+
958
+ Object.entries(styles).forEach(([key, value]) => {
959
+ if (typeof value === 'string') {
960
+ if (isRowBoundStyleValue(value)) keys.push(key);
961
+ return;
962
+ }
963
+
964
+ if (isRecord(value)) {
965
+ const boundBreakpoint = Object.values(value).some(
966
+ (breakpointValue) =>
967
+ typeof breakpointValue === 'string' &&
968
+ isRowBoundStyleValue(breakpointValue)
969
+ );
970
+ if (boundBreakpoint) keys.push(key);
971
+ }
972
+ });
973
+
974
+ return keys;
975
+ };
976
+
944
977
  const resolveStyleBindingsForRow = (
945
978
  styles: unknown,
946
979
  itemData: unknown
@@ -1132,12 +1165,16 @@ const replaceBlockValues = (
1132
1165
  // Only the bound properties are replaced; the rest of the block's styles
1133
1166
  // stay on the shared rule, so a card with one bound colour keeps the
1134
1167
  // whole stylesheet it had.
1168
+ const rowBoundStyleKeys = collectRowBoundStyleKeys(nextBlock.styles);
1135
1169
  const resolvedStyles = resolveStyleBindingsForRow(
1136
1170
  nextBlock.styles,
1137
1171
  itemData
1138
1172
  );
1139
1173
  if (resolvedStyles !== nextBlock.styles) {
1140
1174
  nextBlock.styles = resolvedStyles;
1175
+ // A renderer that draws its own root has to inline exactly these: the
1176
+ // shared rule cannot say a different colour per row.
1177
+ nextBlock.rowStyleKeys = rowBoundStyleKeys;
1141
1178
  }
1142
1179
 
1143
1180
  // A repeat inside a row means "the row's list": a colour-swatch strip
@@ -1330,8 +1367,8 @@ export const buildIteratorBlock = ({
1330
1367
  displayItems.length > 0
1331
1368
  ? displayItems.length
1332
1369
  : scopedItems
1333
- ? 0
1334
- : Math.max(iteratorCount, 1);
1370
+ ? 0
1371
+ : Math.max(iteratorCount, 1);
1335
1372
 
1336
1373
  const clonedBlocks = Array.from({ length: actualCount }, (_, index) => {
1337
1374
  // `??`, not `||`: an entry of a list of plain values can be falsy (0, '')
@@ -69,6 +69,10 @@ const STATE_SELECTORS: Record<string, string[]> = {
69
69
  available: ['[data-state~="available"]'],
70
70
  unavailable: ['[data-state~="unavailable"]'],
71
71
  unknown: ['[data-state~="unknown"]'],
72
+ // A price a campaign has cut. The card stamps it next to the struck retail
73
+ // figure (product-recommendations-block), so the discounted price can carry
74
+ // its own colour without a second block or a renderer flag.
75
+ discounted: ['[data-state~="discounted"]'],
72
76
  // The odd ones out: every state above is a fact about the ELEMENT and matches
73
77
  // on the element itself. These two are facts about the PAGE — the chrome slot
74
78
  // carries `data-scroll-state` (see chrome/scroll-state.tsx) and `body` carries