@akinon/pz-theme 2.0.43-rc.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 +7 -31
  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,34 @@
1
+ import * as React from 'react';
2
+ import { getWidgetData } from '@akinon/next/data/server';
3
+
4
+ import { CompositionState, readCompositionState } from './composition-state';
5
+
6
+ /**
7
+ * The one server-side answer to "is this slug composed?".
8
+ *
9
+ * Two things now ask it for the same slug in one render — the ChromeSlot that
10
+ * decides whether to render the composition, and the ComposableRegion that
11
+ * decides whether to still offer the takeover — and asking twice would mean two
12
+ * requests for one placeholder on every checkout render. Memoizing per request
13
+ * also guarantees the two cannot reach opposite conclusions from two answers.
14
+ */
15
+ const load = async (slug: string): Promise<CompositionState> => {
16
+ try {
17
+ // The generic is the widget's attributes bag, not the widget wrapper
18
+ const data = await getWidgetData<{
19
+ theme_editor_placeholder?: unknown;
20
+ }>({ slug });
21
+
22
+ return readCompositionState(data);
23
+ } catch {
24
+ // No chrome widget answered — the caller keeps the project's own markup.
25
+ return { kind: 'missing' };
26
+ }
27
+ };
28
+
29
+ // `React.cache` exists on the RSC-capable React this package renders under;
30
+ // fall back to the raw function on older cores, where correctness is identical
31
+ // and the only cost is a second fetch.
32
+ const cache = (React as { cache?: <T>(fn: T) => T }).cache;
33
+
34
+ export const getSlugCompositionState = cache ? cache(load) : load;
@@ -58,6 +58,55 @@ export const readCompositionState = (data: WidgetLike): CompositionState => {
58
58
  return { kind: 'composed', sectionCount: layout.length };
59
59
  };
60
60
 
61
+ /**
62
+ * Whether the theme the editor just pushed composes this slug.
63
+ *
64
+ * The server decides takeover once, at request time, from the persisted order;
65
+ * the designer has to keep deciding it as the theme changes under the preview.
66
+ * Two components now ask that question — the fallback that steps aside and the
67
+ * region that offers the takeover — and they must never disagree about one
68
+ * slug, so the rule lives here rather than in either of them.
69
+ *
70
+ * Returns `null` when the frame says nothing about placeholders at all: that is
71
+ * not "uncomposed", it is "no answer", and the caller must leave its state
72
+ * alone rather than withdraw on an unrelated message.
73
+ */
74
+ export const isSlugComposedInFrame = (
75
+ frameData: unknown,
76
+ slug: string
77
+ ): boolean | null => {
78
+ const placeholders = (
79
+ frameData as { theme?: { placeholders?: unknown } } | undefined
80
+ )?.theme?.placeholders;
81
+
82
+ if (!Array.isArray(placeholders)) return null;
83
+
84
+ const placeholder = placeholders.find(
85
+ (item: { slug?: string; id?: string }) =>
86
+ item?.slug === slug || item?.id === slug
87
+ );
88
+ const sections = placeholder?.sections;
89
+
90
+ // Mirror the server's takeover rule exactly: only a *composed* section
91
+ // replaces the project markup, and a composed section is one that reached
92
+ // the saved order.
93
+ //
94
+ // The `native` exclusion guards against the retired native widget path
95
+ // rather than supporting it. That path styled a project component in place
96
+ // instead of replacing it, so its sections never entered the order; the
97
+ // runtime is gone, but the editor can still hold such a section in a stored
98
+ // theme and push it here, and withdrawing the project markup for one would
99
+ // show the designer an empty area no visitor ever sees. It goes when the
100
+ // editor-side machinery does.
101
+ return (
102
+ Array.isArray(sections) &&
103
+ sections.some(
104
+ (section: { hidden?: boolean; type?: string }) =>
105
+ !section?.hidden && section?.type !== 'native'
106
+ )
107
+ );
108
+ };
109
+
61
110
  /**
62
111
  * Dev-only explanation of a slot that kept the brand markup. One line per slug
63
112
  * per process (see `devWarn`), so a brand with no compositions at all does not
@@ -14,8 +14,9 @@ import { buildCheckoutPageContext } from './page-context';
14
14
  * composition authored on the basket page keeps resolving here and the
15
15
  * shipping/total figures that live only on the pre-order become bindable.
16
16
  *
17
- * The flow itself stays code-owned — this provider gives the zones data, it
18
- * does not hand the payment steps over.
17
+ * The flow's internals stay code-owned — this provider gives the zones data;
18
+ * placement of the flow is composed via checkout outlet blocks, never its
19
+ * behavior.
19
20
  */
20
21
  export const LiveCheckoutPageContext = ({
21
22
  path,
@@ -40,12 +40,22 @@ export const PLACEHOLDER_SLUGS = {
40
40
  accountNotifications: 'account-notifications-body',
41
41
  accountStockAlerts: 'account-stock-alerts-body',
42
42
 
43
- // Order flow. Checkout itself stays code-owned — payment, 3DS and the step
44
- // machine are not composable — so the editor only gets the zones above and
45
- // below the flow, never the flow.
43
+ // Order flow. Checkout internals stay sealed — payment forms, 3DS, agreements
44
+ // and place-order exist only in code — but PLACEMENT is composable: the body
45
+ // composition arranges where the real flow, steps and summary render via
46
+ // checkout outlet blocks.
47
+ //
48
+ // Two zones only, and they mean exactly what they say: a band above the flow
49
+ // and a band below it, for a brand that wants trust content without
50
+ // rebuilding the page. A third "summary" zone existed briefly and was
51
+ // removed: it rendered as a second full-width band under the flow (the
52
+ // summary column is client-owned, so a slot could never land inside it), so
53
+ // it was indistinguishable from the bottom band — and anything meant to sit
54
+ // beside the summary now belongs in the body composition, around the
55
+ // summary outlet.
56
+ checkoutBody: 'checkout-page-body',
46
57
  checkoutTop: 'checkout-top-body',
47
58
  checkoutBottom: 'checkout-bottom-body',
48
- checkoutSummary: 'checkout-summary-body',
49
59
  orderSuccess: 'order-success-body',
50
60
  orderTracking: 'order-tracking-body',
51
61
 
@@ -66,9 +66,9 @@ const DEFAULT_RULES: RouteSlugRule[] = [
66
66
 
67
67
  // Order flow
68
68
  [/^\/orders\/completed/, PLACEHOLDER_SLUGS.orderSuccess],
69
- // Checkout is code-owned; the designer composes the zone above the flow
70
- // (the zone below shares the page and is added by the editor's scope).
71
- [/^\/orders\/checkout/, PLACEHOLDER_SLUGS.checkoutTop],
69
+ // Checkout body: the composition arranges WHERE the code-owned flow renders
70
+ // (via checkout outlet blocks); it can never replace the flow itself.
71
+ [/^\/orders\/checkout/, PLACEHOLDER_SLUGS.checkoutBody],
72
72
  [/^\/anonymous-(orders|tracking)/, PLACEHOLDER_SLUGS.orderTracking],
73
73
 
74
74
  // Commerce
@@ -99,6 +99,9 @@ const PAGE_MOUNTED_SLUGS = new Set<string>([
99
99
  PLACEHOLDER_SLUGS.productListing,
100
100
  PLACEHOLDER_SLUGS.basket,
101
101
  PLACEHOLDER_SLUGS.orderSuccess,
102
+ // The checkout layout mounts this slot itself. A root catch-all must skip it:
103
+ // mounting it with fallback={children} would make the payment flow replaceable.
104
+ PLACEHOLDER_SLUGS.checkoutBody,
102
105
  PLACEHOLDER_SLUGS.accountShell,
103
106
  PLACEHOLDER_SLUGS.accountMenu,
104
107
  PLACEHOLDER_SLUGS.accountDashboard,
@@ -18,6 +18,40 @@ interface UseDesignerFeaturesProps {
18
18
  blockInfo?: BlockInfo;
19
19
  }
20
20
 
21
+ /**
22
+ * Posts the canvas's block/part selection to the editor. Extracted so
23
+ * renderers that manage their own designer click handling (the checkout
24
+ * outlet blocks select parts inside TELEPORTED code-owned markup, where no
25
+ * React handler of ours exists) reuse the exact message the wrapper sends.
26
+ */
27
+ export function postBlockSelection({
28
+ placeholderId,
29
+ sectionId,
30
+ blockId,
31
+ blockType,
32
+ blockLabel,
33
+ partName
34
+ }: {
35
+ placeholderId: string;
36
+ sectionId: string;
37
+ blockId: string;
38
+ blockType?: string;
39
+ blockLabel?: string;
40
+ partName?: string | null;
41
+ }) {
42
+ postToEditor({
43
+ type: 'SELECT_BLOCK',
44
+ data: {
45
+ placeholderId,
46
+ sectionId,
47
+ blockId,
48
+ blockType,
49
+ blockLabel,
50
+ partName: partName || undefined
51
+ }
52
+ });
53
+ }
54
+
21
55
  export function useDesignerFeatures({
22
56
  blockId,
23
57
  placeholderId,
@@ -46,16 +80,13 @@ export function useDesignerFeatures({
46
80
  : null;
47
81
 
48
82
  if (window.parent) {
49
- postToEditor({
50
- type: 'SELECT_BLOCK',
51
- data: {
52
- placeholderId,
53
- sectionId,
54
- blockId,
55
- blockType: blockInfo?.type,
56
- blockLabel: blockInfo?.label,
57
- partName: partName || undefined
58
- }
83
+ postBlockSelection({
84
+ placeholderId,
85
+ sectionId,
86
+ blockId,
87
+ blockType: blockInfo?.type,
88
+ blockLabel: blockInfo?.label,
89
+ partName
59
90
  });
60
91
  }
61
92
  }
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export type { Section } from './theme-section';
21
21
  export type { Block } from './theme-block';
22
22
 
23
23
  export { default as ChromeSlot } from './chrome/chrome-slot';
24
+ export { default as ComposableRegion } from './chrome/composable-region';
24
25
  export { ChromeFallback } from './chrome/chrome-fallback';
25
26
  export { CodeOwnedRegion } from './chrome/code-owned-region';
26
27
  export { PreviewAuthStateReporter } from './chrome/preview-auth-state';
@@ -60,3 +61,8 @@ export {
60
61
  normalizePath
61
62
  } from './chrome/route-slug';
62
63
  export type { RouteSlugRule } from './chrome/route-slug';
64
+
65
+ export { CheckoutPiecesProvider } from './chrome/checkout-pieces-provider';
66
+ export { CheckoutPiece } from './chrome/checkout-piece';
67
+ export { CHECKOUT_PIECE_KEYS } from './chrome/checkout-pieces';
68
+ export type { CheckoutPieceKey } from './chrome/checkout-pieces';
@@ -0,0 +1,374 @@
1
+ import { generateThemeCSS } from '../index';
2
+ import {
3
+ containsCheckoutFlowOutlet,
4
+ containsCheckoutOutlet,
5
+ isHidingDeclaration,
6
+ stripHidingCustomCss,
7
+ stripHidingStyles
8
+ } from '../checkout-safety';
9
+ import { applyVisibilityRulesToSections } from '../visibility-rules';
10
+
11
+ /**
12
+ * I1, enforced where every authoring route converges.
13
+ *
14
+ * The editor's descriptors refuse to offer a hide-capable control on the flow
15
+ * outlet, but that guards one surface. Three routes go around it: an ANCESTOR
16
+ * block (the flow is portaled inside every parent's wrapper), the scoped
17
+ * CUSTOM CSS hatch, and a composition authored outside this repo entirely
18
+ * (the AI generator, the legacy widget editor, a direct API write). The CSS
19
+ * generator and the visibility-rule pass see all of them, so the rule lives
20
+ * there: styling may change how checkout looks, never whether it works.
21
+ */
22
+
23
+ const section = (blocks: unknown[]) =>
24
+ [{ id: 'sec', blocks }] as unknown as Parameters<typeof generateThemeCSS>[0];
25
+
26
+ const flowOutlet = (over: Record<string, unknown> = {}) => ({
27
+ id: 'flow-outlet',
28
+ type: 'checkout-flow-outlet',
29
+ ...over
30
+ });
31
+
32
+ describe('isHidingDeclaration', () => {
33
+ it.each([
34
+ ['display', 'none'],
35
+ ['display', 'NONE'],
36
+ ['display', 'none !important'],
37
+ ['visibility', 'hidden'],
38
+ ['visibility', 'collapse'],
39
+ ['pointer-events', 'none'],
40
+ ['content-visibility', 'hidden'],
41
+ ['opacity', '0'],
42
+ ['opacity', '0.0'],
43
+ ['opacity', '.02'],
44
+ ['opacity', '0%']
45
+ ])('treats %s: %s as hiding', (property, value) => {
46
+ expect(isHidingDeclaration(property, value)).toBe(true);
47
+ });
48
+
49
+ it.each([
50
+ ['display', 'flex'],
51
+ ['display', 'grid'],
52
+ ['visibility', 'visible'],
53
+ ['pointer-events', 'auto'],
54
+ ['opacity', '0.9'],
55
+ ['opacity', '1'],
56
+ // Values we cannot read are left alone rather than guessed at.
57
+ ['opacity', 'var(--fade)'],
58
+ ['color', 'transparent'],
59
+ ['width', '0']
60
+ ])('leaves %s: %s alone', (property, value) => {
61
+ expect(isHidingDeclaration(property, value)).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe('subtree detection', () => {
66
+ it('finds the flow outlet at any depth, and the block itself', () => {
67
+ expect(containsCheckoutFlowOutlet(flowOutlet())).toBe(true);
68
+ expect(
69
+ containsCheckoutFlowOutlet({
70
+ type: 'group',
71
+ blocks: [{ type: 'group', blocks: [flowOutlet()] }]
72
+ })
73
+ ).toBe(true);
74
+ expect(containsCheckoutFlowOutlet({ type: 'group', blocks: [] })).toBe(
75
+ false
76
+ );
77
+ });
78
+
79
+ it('separates "any outlet" from "the flow outlet"', () => {
80
+ const summaryOutlet = { type: 'checkout-summary-outlet' };
81
+
82
+ expect(containsCheckoutOutlet(summaryOutlet)).toBe(true);
83
+ expect(containsCheckoutFlowOutlet(summaryOutlet)).toBe(false);
84
+ });
85
+
86
+ it('survives the shapes the three call sites actually pass', () => {
87
+ expect(containsCheckoutOutlet(null)).toBe(false);
88
+ expect(containsCheckoutOutlet(undefined)).toBe(false);
89
+ expect(containsCheckoutOutlet({ blocks: null })).toBe(false);
90
+ expect(containsCheckoutOutlet({ type: 42, blocks: 'nope' })).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe('stripHidingStyles', () => {
95
+ it('drops only the offending breakpoint, so the flow stays on the page', () => {
96
+ expect(
97
+ stripHidingStyles(
98
+ { display: { desktop: 'flex', mobile: 'none' } },
99
+ 'root-only'
100
+ )
101
+ ).toEqual({ display: { desktop: 'flex' } });
102
+ });
103
+
104
+ it('drops the key entirely when every breakpoint hides', () => {
105
+ expect(
106
+ stripHidingStyles(
107
+ { visibility: { desktop: 'hidden', mobile: 'hidden' } },
108
+ 'root-only'
109
+ )
110
+ ).toEqual({});
111
+ });
112
+
113
+ it('keeps every layout value untouched', () => {
114
+ const styles = {
115
+ display: { desktop: 'flex' },
116
+ opacity: { desktop: '0.9' },
117
+ 'flex-direction': { desktop: 'column' },
118
+ width: { desktop: '64%' }
119
+ };
120
+
121
+ expect(stripHidingStyles(styles, 'root-only')).toEqual(styles);
122
+ });
123
+
124
+ it('guards critical parts but leaves the informational summary hideable', () => {
125
+ const styles = {
126
+ 'place-order-button/display': { desktop: 'none' },
127
+ 'payment-option/pointer-events': { desktop: 'none' },
128
+ 'agreement/opacity': { desktop: '0' },
129
+ 'summary-row/display': { desktop: 'none' },
130
+ 'summary-total/opacity': { desktop: '0' }
131
+ };
132
+
133
+ expect(stripHidingStyles(styles, 'critical-parts')).toEqual({
134
+ 'summary-row/display': { desktop: 'none' },
135
+ 'summary-total/opacity': { desktop: '0' }
136
+ });
137
+ });
138
+
139
+ it('leaves an ancestor part key alone — it addresses the ancestor own markup', () => {
140
+ const styles = { 'title/display': { desktop: 'none' } };
141
+
142
+ expect(stripHidingStyles(styles, 'root-only')).toEqual(styles);
143
+ });
144
+
145
+ it('reaches a state-qualified part key', () => {
146
+ expect(
147
+ stripHidingStyles(
148
+ { 'payment-option/selected/display': { desktop: 'none' } },
149
+ 'critical-parts'
150
+ )
151
+ ).toEqual({});
152
+ });
153
+ });
154
+
155
+ describe('stripHidingCustomCss', () => {
156
+ it('removes the hiding declaration and keeps the rest of the rule', () => {
157
+ const css = stripHidingCustomCss(
158
+ '[data-block-id="x"] [data-part="place-order-button"] { display: none; border-radius: 4px; }'
159
+ );
160
+
161
+ expect(css).not.toContain('display: none');
162
+ expect(css).toContain('border-radius: 4px');
163
+ });
164
+
165
+ it('handles consecutive hiding declarations', () => {
166
+ const css = stripHidingCustomCss(
167
+ '.a { display:none;visibility:hidden;pointer-events:none;color:red }'
168
+ );
169
+
170
+ expect(css).not.toMatch(/display\s*:\s*none/);
171
+ expect(css).not.toMatch(/visibility\s*:\s*hidden/);
172
+ expect(css).not.toMatch(/pointer-events\s*:\s*none/);
173
+ expect(css).toContain('color:red');
174
+ });
175
+
176
+ it('leaves legitimate custom CSS alone', () => {
177
+ const css = '.a { display: flex; opacity: 0.85; visibility: visible }';
178
+
179
+ expect(stripHidingCustomCss(css)).toBe(css);
180
+ });
181
+ });
182
+
183
+ describe('generateThemeCSS — checkout flow can never be hidden', () => {
184
+ it('drops a display:none seeded onto a submit-critical part', () => {
185
+ const css = generateThemeCSS(
186
+ section([
187
+ flowOutlet({
188
+ styles: {
189
+ 'place-order-button/display': { desktop: 'none' },
190
+ 'payment-option/pointer-events': { desktop: 'none' },
191
+ 'payment-option/border-radius': { desktop: '8px' }
192
+ }
193
+ })
194
+ ])
195
+ );
196
+
197
+ expect(css).not.toContain('display: none');
198
+ expect(css).not.toContain('pointer-events: none');
199
+ // The style that only changes how it LOOKS still lands.
200
+ expect(css).toContain('border-radius: 8px !important');
201
+ });
202
+
203
+ it('drops a hide from an ANCESTOR block wrapping the outlet', () => {
204
+ const css = generateThemeCSS(
205
+ section([
206
+ {
207
+ id: 'flow-column',
208
+ type: 'group',
209
+ styles: {
210
+ visibility: { desktop: 'hidden' },
211
+ 'pointer-events': { desktop: 'none' },
212
+ display: { desktop: 'flex', mobile: 'none' },
213
+ width: { desktop: '64%' }
214
+ },
215
+ blocks: [flowOutlet()]
216
+ }
217
+ ])
218
+ );
219
+
220
+ expect(css).not.toContain('visibility: hidden');
221
+ expect(css).not.toContain('pointer-events: none');
222
+ expect(css).not.toContain('display: none');
223
+ // The column is still a column.
224
+ expect(css).toContain('display: flex');
225
+ expect(css).toContain('width: 64%');
226
+ });
227
+
228
+ it('drops a hide from the SECTION wrapping the outlet', () => {
229
+ const css = generateThemeCSS([
230
+ {
231
+ id: 'sec',
232
+ styles: { display: { desktop: 'none' } },
233
+ blocks: [flowOutlet()]
234
+ }
235
+ ] as never);
236
+
237
+ expect(css).not.toContain('display: none');
238
+ });
239
+
240
+ it('closes the custom-CSS hatch on the outlet and on an ancestor', () => {
241
+ const outletCss = generateThemeCSS(
242
+ section([
243
+ flowOutlet({
244
+ properties: {
245
+ customCss:
246
+ '& [data-part="place-order-button"] { display: none } & { border: 1px solid red }'
247
+ }
248
+ })
249
+ ])
250
+ );
251
+
252
+ expect(outletCss).not.toContain('display: none');
253
+ expect(outletCss).toContain('border: 1px solid red');
254
+
255
+ const ancestorCss = generateThemeCSS(
256
+ section([
257
+ {
258
+ id: 'flow-column',
259
+ type: 'group',
260
+ properties: { customCss: '& { visibility: hidden }' },
261
+ blocks: [flowOutlet()]
262
+ }
263
+ ])
264
+ );
265
+
266
+ expect(ancestorCss).not.toContain('visibility: hidden');
267
+ });
268
+
269
+ it('leaves an unrelated block free to hide itself', () => {
270
+ const css = generateThemeCSS(
271
+ section([
272
+ {
273
+ id: 'promo',
274
+ type: 'group',
275
+ styles: { display: { mobile: 'none' } },
276
+ properties: { customCss: '& { visibility: hidden }' }
277
+ }
278
+ ])
279
+ );
280
+
281
+ expect(css).toContain('display: none');
282
+ expect(css).toContain('visibility: hidden');
283
+ });
284
+ });
285
+
286
+ describe('visibility rules never gate a checkout outlet', () => {
287
+ // The claim latches once at page mount and both rule inputs are wrong at
288
+ // that instant (session 'loading', breakpoint defaulted to desktop), so a
289
+ // rule-hidden outlet misses the latch and the composed placement is dead
290
+ // for the whole page load — while the canvas shows it working.
291
+ const mobileOnly = {
292
+ visibilityRules: { enabled: true, allowedBreakpoints: ['mobile'] }
293
+ };
294
+
295
+ const context = { authState: 'loading', breakpoint: 'desktop' } as never;
296
+
297
+ it('keeps the outlet mounted despite an unsatisfied rule', () => {
298
+ const [applied] = applyVisibilityRulesToSections(
299
+ [
300
+ {
301
+ id: 'sec',
302
+ blocks: [{ id: 'o', type: 'checkout-flow-outlet', properties: mobileOnly }]
303
+ }
304
+ ] as never,
305
+ context
306
+ );
307
+
308
+ expect(applied.blocks[0].hidden).toBe(false);
309
+ });
310
+
311
+ it('keeps an ANCESTOR of the outlet mounted too', () => {
312
+ const [applied] = applyVisibilityRulesToSections(
313
+ [
314
+ {
315
+ id: 'sec',
316
+ blocks: [
317
+ {
318
+ id: 'col',
319
+ type: 'group',
320
+ properties: mobileOnly,
321
+ blocks: [{ id: 'o', type: 'checkout-flow-outlet' }]
322
+ }
323
+ ]
324
+ }
325
+ ] as never,
326
+ context
327
+ );
328
+
329
+ expect(applied.blocks[0].hidden).toBe(false);
330
+ });
331
+
332
+ it('keeps the SECTION holding the outlet mounted', () => {
333
+ const [applied] = applyVisibilityRulesToSections(
334
+ [
335
+ {
336
+ id: 'sec',
337
+ properties: mobileOnly,
338
+ blocks: [{ id: 'o', type: 'checkout-summary-outlet' }]
339
+ }
340
+ ] as never,
341
+ context
342
+ );
343
+
344
+ expect(applied.hidden).toBe(false);
345
+ });
346
+
347
+ it('still honours the explicit hidden flag — that one is deliberate', () => {
348
+ const [applied] = applyVisibilityRulesToSections(
349
+ [
350
+ {
351
+ id: 'sec',
352
+ blocks: [{ id: 'o', type: 'checkout-flow-outlet', hidden: true }]
353
+ }
354
+ ] as never,
355
+ context
356
+ );
357
+
358
+ expect(applied.blocks[0].hidden).toBe(true);
359
+ });
360
+
361
+ it('leaves every other block ruled as before', () => {
362
+ const [applied] = applyVisibilityRulesToSections(
363
+ [
364
+ {
365
+ id: 'sec',
366
+ blocks: [{ id: 'promo', type: 'group', properties: mobileOnly }]
367
+ }
368
+ ] as never,
369
+ context
370
+ );
371
+
372
+ expect(applied.blocks[0].hidden).toBe(true);
373
+ });
374
+ });