@akinon/pz-theme 2.0.43-beta.0 → 2.0.43

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 (35) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/checkout-pieces.test.tsx +420 -0
  4. package/src/__tests__/chrome-empty-zone.test.tsx +3 -3
  5. package/src/__tests__/code-owned-region.test.tsx +30 -6
  6. package/src/__tests__/composable-region.test.tsx +481 -0
  7. package/src/__tests__/part-styles.test.ts +24 -4
  8. package/src/__tests__/registry-parity.test.ts +3 -0
  9. package/src/__tests__/route-slug.test.ts +11 -0
  10. package/src/blocks/__tests__/checkout-outlet-blocks.test.tsx +623 -0
  11. package/src/blocks/block-renderer-registry.tsx +6 -0
  12. package/src/blocks/checkout-flow-outlet-block.tsx +104 -0
  13. package/src/blocks/checkout-outlet-base.tsx +241 -0
  14. package/src/blocks/checkout-steps-outlet-block.tsx +57 -0
  15. package/src/blocks/checkout-summary-outlet-block.tsx +43 -0
  16. package/src/chrome/checkout-piece.tsx +145 -0
  17. package/src/chrome/checkout-pieces-provider.tsx +28 -0
  18. package/src/chrome/checkout-pieces.ts +173 -0
  19. package/src/chrome/chrome-fallback.tsx +8 -26
  20. package/src/chrome/chrome-slot.tsx +25 -26
  21. package/src/chrome/code-owned-region.tsx +21 -9
  22. package/src/chrome/composable-region-client.tsx +228 -0
  23. package/src/chrome/composable-region.tsx +58 -0
  24. package/src/chrome/composition-source.ts +34 -0
  25. package/src/chrome/composition-state.ts +49 -0
  26. package/src/chrome/live-checkout-page-context.tsx +3 -2
  27. package/src/chrome/placeholder-slugs.ts +14 -4
  28. package/src/chrome/route-slug.ts +6 -3
  29. package/src/hooks/use-designer-features.tsx +41 -10
  30. package/src/index.ts +6 -0
  31. package/src/utils/__tests__/checkout-safety.test.ts +374 -0
  32. package/src/utils/checkout-safety.ts +177 -0
  33. package/src/utils/index.ts +47 -8
  34. package/src/utils/part-styles.ts +4 -0
  35. package/src/utils/visibility-rules.ts +31 -8
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The last line of defence for "a composition may restyle checkout, never
3
+ * break it".
4
+ *
5
+ * The editor's descriptors already refuse to offer a hide-capable control on
6
+ * the checkout flow outlet. That guard covers exactly one authoring surface.
7
+ * Published compositions do not all come from it — the AI generator, the
8
+ * legacy widget editor and direct API writes all produce the same JSON, and
9
+ * two routes bypass the descriptors even inside the editor:
10
+ *
11
+ * - an ANCESTOR block. The flow is portaled into the outlet's wrapper, which
12
+ * sits inside every parent block's wrapper, so `display:none` on the
13
+ * surrounding column hides the real payment flow just as effectively.
14
+ * - the scoped CUSTOM CSS hatch, which by design emits whatever it is given
15
+ * (`& [data-part="place-order-button"] { display: none }`).
16
+ *
17
+ * The CSS generator is the one place every route converges, so the rule is
18
+ * enforced here: a declaration that would remove the checkout flow from the
19
+ * page, or from the shopper's pointer, is dropped before it is emitted.
20
+ *
21
+ * SCOPE, stated honestly: this drops the declarations that unambiguously
22
+ * remove an element or its interactivity — `display:none`, `visibility`
23
+ * hidden/collapse, `pointer-events:none`, `content-visibility:hidden` and a
24
+ * effectively-zero `opacity`. It is not a general "can a designer make this
25
+ * hard to see" filter: nothing here stops a 1px-tall box or white-on-white
26
+ * text, and it is not meant to. Layout values a real composition needs
27
+ * (`display:flex`, `opacity:.9`) pass through untouched — this only ever
28
+ * subtracts the values that end ordering.
29
+ */
30
+
31
+ import { parsePartStyleKey } from './part-styles';
32
+
33
+ export const CHECKOUT_FLOW_OUTLET_TYPE = 'checkout-flow-outlet';
34
+
35
+ export const CHECKOUT_OUTLET_BLOCK_TYPES = [
36
+ CHECKOUT_FLOW_OUTLET_TYPE,
37
+ 'checkout-steps-outlet',
38
+ 'checkout-summary-outlet'
39
+ ] as const;
40
+
41
+ const OUTLET_TYPES = new Set<string>(CHECKOUT_OUTLET_BLOCK_TYPES);
42
+
43
+ /**
44
+ * Parts whose disappearance is a LOOK change, not a broken order: the summary
45
+ * is informational and the flow outlet hosts it only because the mobile sheet
46
+ * re-renders it inside the flow subtree. Everything else the flow stamps —
47
+ * sections, payment options, the payment panel, continue and place-order, the
48
+ * agreement row, the step indicator — is on the path to a placed order.
49
+ */
50
+ const INFORMATIONAL_PART_PREFIX = 'summary';
51
+
52
+ const FLOW_OUTLET_TYPES = new Set<string>([CHECKOUT_FLOW_OUTLET_TYPE]);
53
+
54
+ /**
55
+ * True when the node IS, or contains at any depth, one of the given types.
56
+ * Accepts `unknown` on purpose: sections and blocks come from three different
57
+ * shapes across the codebase (theme JSON, the editor's Block, the renderer's
58
+ * ThemeBlockData) and every caller only needs `type` plus `blocks`.
59
+ */
60
+ const subtreeHasType = (node: unknown, types: Set<string>): boolean => {
61
+ if (!node || typeof node !== 'object') return false;
62
+
63
+ const { type, blocks } = node as { type?: unknown; blocks?: unknown };
64
+
65
+ if (typeof type === 'string' && types.has(type)) return true;
66
+
67
+ return Array.isArray(blocks)
68
+ ? blocks.some((child) => subtreeHasType(child, types))
69
+ : false;
70
+ };
71
+
72
+ export const containsCheckoutFlowOutlet = (node: unknown): boolean =>
73
+ subtreeHasType(node, FLOW_OUTLET_TYPES);
74
+
75
+ export const containsCheckoutOutlet = (node: unknown): boolean =>
76
+ subtreeHasType(node, OUTLET_TYPES);
77
+
78
+ /**
79
+ * A CSS declaration that takes the element off the page or out of reach.
80
+ * Values arrive as authored, so they are trimmed and lower-cased first; a
81
+ * value we cannot read as a number (a var(), a calc()) is left alone rather
82
+ * than guessed at.
83
+ */
84
+ export const isHidingDeclaration = (
85
+ property: string,
86
+ value: unknown
87
+ ): boolean => {
88
+ if (typeof value !== 'string' && typeof value !== 'number') return false;
89
+
90
+ const name = String(property).trim().toLowerCase();
91
+ const raw = String(value).trim().toLowerCase().replace(/\s*!important$/, '');
92
+
93
+ switch (name) {
94
+ case 'display':
95
+ return raw === 'none';
96
+ case 'visibility':
97
+ return raw === 'hidden' || raw === 'collapse';
98
+ case 'pointer-events':
99
+ return raw === 'none';
100
+ case 'content-visibility':
101
+ return raw === 'hidden';
102
+ case 'opacity': {
103
+ const numeric = raw.endsWith('%')
104
+ ? Number.parseFloat(raw) / 100
105
+ : Number.parseFloat(raw);
106
+ return Number.isFinite(numeric) && numeric < 0.1;
107
+ }
108
+ default:
109
+ return false;
110
+ }
111
+ };
112
+
113
+ /** Responsive buckets are per breakpoint, so only the offending one is dropped. */
114
+ const stripHidingFromValue = (property: string, value: unknown): unknown => {
115
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
116
+ const kept = Object.entries(value as Record<string, unknown>).filter(
117
+ ([, breakpointValue]) => !isHidingDeclaration(property, breakpointValue)
118
+ );
119
+
120
+ // Dropping the mobile bucket makes it fall through to desktop, which is
121
+ // the safe direction: the flow stays on the page at every width.
122
+ return kept.length > 0 ? Object.fromEntries(kept) : undefined;
123
+ }
124
+
125
+ return isHidingDeclaration(property, value) ? undefined : value;
126
+ };
127
+
128
+ /**
129
+ * A block's styles with every order-breaking declaration removed.
130
+ *
131
+ * `partScope` decides how part keys are treated: 'critical-parts' keeps the
132
+ * informational `summary-*` groups hideable (the mobile sheet and the totals
133
+ * are not on the ordering path), 'root-only' leaves every part key alone and
134
+ * is what an ancestor block gets — an ancestor's part keys address its own
135
+ * markup, never the teleported flow.
136
+ */
137
+ export const stripHidingStyles = (
138
+ styles: Record<string, unknown> | undefined | null,
139
+ partScope: 'root-only' | 'critical-parts'
140
+ ): Record<string, unknown> => {
141
+ const safe: Record<string, unknown> = {};
142
+
143
+ Object.entries(styles || {}).forEach(([key, value]) => {
144
+ const parsed = parsePartStyleKey(key);
145
+
146
+ const guarded = parsed
147
+ ? partScope === 'critical-parts' &&
148
+ !parsed.part.startsWith(INFORMATIONAL_PART_PREFIX)
149
+ : true;
150
+
151
+ if (!guarded) {
152
+ safe[key] = value;
153
+ return;
154
+ }
155
+
156
+ const property = parsed ? parsed.property : key;
157
+ const kept = stripHidingFromValue(property, value);
158
+
159
+ if (kept !== undefined) safe[key] = kept;
160
+ });
161
+
162
+ return safe;
163
+ };
164
+
165
+ /**
166
+ * The same rule for the custom-CSS hatch, applied to the generated text.
167
+ *
168
+ * The hatch is free-form, so this works declaration by declaration: the
169
+ * leading delimiter is preserved and only the declaration itself is removed,
170
+ * which keeps every surrounding rule and every other declaration intact.
171
+ */
172
+ export const stripHidingCustomCss = (css: string): string =>
173
+ css.replace(
174
+ /(^|[;{])(\s*)(display|visibility|pointer-events|content-visibility|opacity)(\s*:\s*)([^;}]*)/gi,
175
+ (match, delimiter, space, property, colon, value) =>
176
+ isHidingDeclaration(property, value) ? delimiter : match
177
+ );
@@ -7,6 +7,12 @@ import {
7
7
  } from './css-safety';
8
8
  import { buildPartSelectors, splitPartStyles } from './part-styles';
9
9
  import { buildCustomCss } from './custom-css';
10
+ import {
11
+ CHECKOUT_FLOW_OUTLET_TYPE,
12
+ containsCheckoutFlowOutlet,
13
+ stripHidingCustomCss,
14
+ stripHidingStyles
15
+ } from './checkout-safety';
10
16
  import { hasBindingToken } from './iterator-utils';
11
17
  import {
12
18
  BREAKPOINT_FALLBACKS,
@@ -18,6 +24,7 @@ export * from './publish-window';
18
24
  export * from './part-styles';
19
25
  export * from './custom-css';
20
26
  export * from './breakpoints';
27
+ export * from './checkout-safety';
21
28
 
22
29
  export type ImageLoadingStrategy = 'eager' | 'lazy';
23
30
  export type ImageFetchPriority = 'high' | 'auto' | 'low';
@@ -490,6 +497,12 @@ const resolveEntryValue = (
490
497
 
491
498
  interface Block {
492
499
  id: string;
500
+ /**
501
+ * Read only to recognise the checkout outlets: a block that is or holds the
502
+ * flow outlet has the real payment flow portaled into its wrapper, so its
503
+ * hide-capable declarations are dropped (see utils/checkout-safety.ts).
504
+ */
505
+ type?: string;
493
506
  styleSourceId?: string;
494
507
  styles?: ResponsiveStyles;
495
508
  /** Set by blocks that name their real root with `data-part="root"`. */
@@ -1125,14 +1138,29 @@ const collectBlockCSS = (
1125
1138
  let css = '';
1126
1139
 
1127
1140
  blocks.forEach((block) => {
1128
- if (block.styles && Object.keys(block.styles).length > 0) {
1141
+ // A block that IS or CONTAINS the checkout flow outlet has the real
1142
+ // payment flow portaled into its wrapper, so anything that hides the
1143
+ // wrapper hides checkout. The outlet itself additionally guards its part
1144
+ // keys; an ancestor's part keys address its own markup and are left
1145
+ // alone. See utils/checkout-safety.ts.
1146
+ const guardsCheckoutFlow = containsCheckoutFlowOutlet(block);
1147
+ const blockStyles = guardsCheckoutFlow
1148
+ ? stripHidingStyles(
1149
+ block.styles,
1150
+ block.type === CHECKOUT_FLOW_OUTLET_TYPE
1151
+ ? 'critical-parts'
1152
+ : 'root-only'
1153
+ )
1154
+ : block.styles;
1155
+
1156
+ if (blockStyles && Object.keys(blockStyles).length > 0) {
1129
1157
  const selector = `[data-block-id="${escapeCssAttributeValue(
1130
1158
  block.styleSourceId || block.id
1131
1159
  )}"]`;
1132
1160
  // Root styles ignore part keys, so a part override never leaks onto the
1133
1161
  // block wrapper (a padding meant for the dropdown would otherwise
1134
1162
  // reposition the whole block).
1135
- const { root } = splitPartStyles(block.styles);
1163
+ const { root } = splitPartStyles(blockStyles);
1136
1164
 
1137
1165
  if (Object.keys(root).length > 0) {
1138
1166
  // `data-block-id` sits on the designer wrapper, not on the element the
@@ -1148,18 +1176,20 @@ const collectBlockCSS = (
1148
1176
  css += generateElementCSS(rootSelector, root, targetBreakpoint);
1149
1177
  }
1150
1178
 
1151
- css += generatePartCSS(selector, block.styles, targetBreakpoint);
1179
+ css += generatePartCSS(selector, blockStyles, targetBreakpoint);
1152
1180
  }
1153
1181
 
1154
1182
  if (block.properties) {
1155
1183
  const selector = `[data-block-id="${escapeCssAttributeValue(
1156
1184
  block.styleSourceId || block.id
1157
1185
  )}"]`;
1158
- css += buildCustomCss(
1186
+ const customCss = buildCustomCss(
1159
1187
  block.properties as Record<string, unknown>,
1160
1188
  selector,
1161
1189
  targetBreakpoint
1162
1190
  );
1191
+
1192
+ css += guardsCheckoutFlow ? stripHidingCustomCss(customCss) : customCss;
1163
1193
  }
1164
1194
 
1165
1195
  if (block.blocks && block.blocks.length > 0) {
@@ -1181,10 +1211,17 @@ export const generateThemeCSS = (
1181
1211
  section.id
1182
1212
  )}"]`;
1183
1213
 
1184
- if (section.styles && Object.keys(section.styles).length > 0) {
1214
+ // A section wrapping the flow outlet hides checkout exactly as an
1215
+ // ancestor block does, and its own part keys are its own markup.
1216
+ const guardsCheckoutFlow = containsCheckoutFlowOutlet(section);
1217
+ const sectionStyles = guardsCheckoutFlow
1218
+ ? stripHidingStyles(section.styles, 'root-only')
1219
+ : section.styles;
1220
+
1221
+ if (sectionStyles && Object.keys(sectionStyles).length > 0) {
1185
1222
  // Sections went straight to generateElementCSS, so a part key on a
1186
1223
  // section was emitted as the invalid declaration `dropdown/color:`.
1187
- const { root: sectionRoot } = splitPartStyles(section.styles);
1224
+ const { root: sectionRoot } = splitPartStyles(sectionStyles);
1188
1225
 
1189
1226
  if (Object.keys(sectionRoot).length > 0) {
1190
1227
  css += generateElementCSS(
@@ -1196,17 +1233,19 @@ export const generateThemeCSS = (
1196
1233
 
1197
1234
  css += generatePartCSS(
1198
1235
  sectionSelector,
1199
- section.styles,
1236
+ sectionStyles,
1200
1237
  targetBreakpoint
1201
1238
  );
1202
1239
  }
1203
1240
 
1204
1241
  if (section.properties) {
1205
- css += buildCustomCss(
1242
+ const customCss = buildCustomCss(
1206
1243
  section.properties as Record<string, unknown>,
1207
1244
  sectionSelector,
1208
1245
  targetBreakpoint
1209
1246
  );
1247
+
1248
+ css += guardsCheckoutFlow ? stripHidingCustomCss(customCss) : customCss;
1210
1249
  }
1211
1250
 
1212
1251
  if (section.blocks && section.blocks.length > 0) {
@@ -45,6 +45,10 @@ const STATE_SELECTORS: Record<string, string[]> = {
45
45
  ],
46
46
  checked: [':checked', '[data-state~="checked"]', '[aria-checked="true"]'],
47
47
  open: ['[data-state~="open"]', '[aria-expanded="true"]'],
48
+ // A finished step/section in a flow (checkout steps, wizard sections).
49
+ // data-state only, on purpose: no ARIA attribute honestly means "completed",
50
+ // so inventing an ARIA analog here would let unrelated markup match.
51
+ completed: ['[data-state~="completed"]'],
48
52
  invalid: ['[aria-invalid="true"]', '[data-state~="invalid"]'],
49
53
  loading: ['[data-state~="loading"]'],
50
54
  empty: ['[data-state~="empty"]'],
@@ -1,6 +1,7 @@
1
1
  import { Block } from '../theme-block';
2
2
  import { Section } from '../theme-section';
3
3
  import { BREAKPOINT_FALLBACKS } from './breakpoints';
4
+ import { containsCheckoutOutlet } from './checkout-safety';
4
5
  import { devWarn } from './dev-warn';
5
6
 
6
7
  export type VisibilityAuthState = 'all' | 'authenticated' | 'guest';
@@ -507,15 +508,38 @@ export const evaluateVisibilityRules = (
507
508
  return true;
508
509
  };
509
510
 
511
+ /**
512
+ * Checkout outlets are exempt from visibility rules — theirs, and those of
513
+ * every block or section wrapping them.
514
+ *
515
+ * An outlet is not content, it is a POSITION: the piece it claims latches
516
+ * once, at page mount, and never re-targets. Both context inputs a rule reads
517
+ * are wrong at exactly that moment — `useSession()` still reports 'loading'
518
+ * on the first client render, and the breakpoint is initialised to 'desktop'
519
+ * before the resize effect corrects it — so a rule that is genuinely
520
+ * satisfied on the shopper's device still hides the outlet through that first
521
+ * render. The outlet then misses the latch, and because late registration on
522
+ * live is ignored by design, the composed placement stays dead for the whole
523
+ * page load while the designer canvas (which follows the live claim instead
524
+ * of latching) shows it working.
525
+ *
526
+ * Rather than race the latch, the outlet is kept mounted. Little is lost:
527
+ * conditional placement was never expressible anyway (a claim is
528
+ * breakpoint-unaware and permanent), the steps and summary outlets stay
529
+ * hideable through their Visibility style, and the editor drops the
530
+ * Visibility Rules box for these blocks so the panel tells the same story.
531
+ */
532
+ const isVisibilityRuleExempt = (node: Block | Section): boolean =>
533
+ containsCheckoutOutlet(node);
534
+
510
535
  const applyBlockVisibility = (
511
536
  block: Block,
512
537
  context: VisibilityRuleContext
513
538
  ): Block => {
514
539
  const blocks = block.blocks?.map(child => applyBlockVisibility(child, context));
515
- const isVisibleByRules = evaluateVisibilityRules(
516
- block.properties?.visibilityRules,
517
- context
518
- );
540
+ const isVisibleByRules =
541
+ isVisibilityRuleExempt(block) ||
542
+ evaluateVisibilityRules(block.properties?.visibilityRules, context);
519
543
 
520
544
  return {
521
545
  ...block,
@@ -530,10 +554,9 @@ export const applyVisibilityRulesToSections = (
530
554
  ): Section[] =>
531
555
  sections.map(section => {
532
556
  const blocks = section.blocks.map(block => applyBlockVisibility(block, context));
533
- const isVisibleByRules = evaluateVisibilityRules(
534
- section.properties?.visibilityRules,
535
- context
536
- );
557
+ const isVisibleByRules =
558
+ isVisibilityRuleExempt(section) ||
559
+ evaluateVisibilityRules(section.properties?.visibilityRules, context);
537
560
 
538
561
  return {
539
562
  ...section,