@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
package/CHANGELOG.md CHANGED
@@ -1,36 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.43-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea23]
8
- - Updated dependencies [324f97d5]
9
- - Updated dependencies [51ea0688]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08f]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f4]
18
- - Updated dependencies [57d7eb30]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a71]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d0]
25
- - Updated dependencies [4ac7b2a1]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a963]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b2]
32
- - @akinon/next@2.0.43-rc.0
33
- - @akinon/theme-contract@2.0.43-rc.0
3
+ ## 2.0.43
4
+
5
+ ### Patch Changes
6
+
7
+ - 0e384d73: ZERO-4730: Make the checkout page composable with a checkout pieces bridge, outlet blocks and a body takeover region
8
+ - @akinon/next@2.0.43
9
+ - @akinon/theme-contract@2.0.43
34
10
 
35
11
  ## 2.0.42
36
12
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.43-rc.0",
4
+ "version": "2.0.43",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.43-rc.0",
11
+ "@akinon/next": "2.0.43",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.43-rc.0",
16
+ "@akinon/theme-contract": "2.0.43",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.43-rc.0",
23
+ "@akinon/next": "2.0.43",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,420 @@
1
+ import React, { useState } from 'react';
2
+ import { act, fireEvent, render, within } from '@testing-library/react';
3
+
4
+ import { CheckoutPiece } from '../chrome/checkout-piece';
5
+ import { CheckoutPiecesProvider } from '../chrome/checkout-pieces-provider';
6
+ import {
7
+ announceCheckoutPiece,
8
+ getCheckoutFlowVariantOverride,
9
+ getCheckoutOutletClaim,
10
+ isCheckoutOutletWinner,
11
+ isCheckoutPieceMounted,
12
+ isThemeEditorDesigner,
13
+ registerCheckoutOutlet,
14
+ resetCheckoutPiecesForTests,
15
+ retractCheckoutPiece,
16
+ subscribeCheckoutPieces,
17
+ unregisterCheckoutOutlet,
18
+ updateCheckoutOutletFlowVariant
19
+ } from '../chrome/checkout-pieces';
20
+
21
+ /**
22
+ * The checkout pieces bridge carries the feature's safety invariants, so each
23
+ * one is pinned here rather than eyeballed:
24
+ *
25
+ * - byte-identical uncomposed page: an unclaimed piece adds ZERO wrapper DOM;
26
+ * - one React tree: teleporting is `createPortal`, so child state survives;
27
+ * - flow never lost: a latched outlet that disappears sends the piece home,
28
+ * and a live page never latches onto an outlet that arrived late;
29
+ * - sealed reuse: outside the provider the wrapper never teleports anything.
30
+ */
31
+
32
+ const outletNodes: HTMLElement[] = [];
33
+
34
+ const createOutletNode = () => {
35
+ const node = document.createElement('div');
36
+ document.body.appendChild(node);
37
+ outletNodes.push(node);
38
+
39
+ return node;
40
+ };
41
+
42
+ beforeEach(() => resetCheckoutPiecesForTests());
43
+
44
+ afterEach(() => {
45
+ outletNodes.forEach((node) => node.remove());
46
+ outletNodes.length = 0;
47
+ });
48
+
49
+ const Counter = () => {
50
+ const [count, setCount] = useState(0);
51
+
52
+ return (
53
+ <button type="button" onClick={() => setCount((value) => value + 1)}>
54
+ count:{count}
55
+ </button>
56
+ );
57
+ };
58
+
59
+ describe('CheckoutPiece on the live site', () => {
60
+ beforeAll(() => {
61
+ // No editor handshake happened: the designer flag is unset.
62
+ delete window.__themeEditorDesigner;
63
+ });
64
+
65
+ it('adds zero DOM when nothing claims the piece', () => {
66
+ const { container } = render(
67
+ <CheckoutPiecesProvider>
68
+ <CheckoutPiece piece="flow">
69
+ <span data-x="1">hi</span>
70
+ </CheckoutPiece>
71
+ </CheckoutPiecesProvider>
72
+ );
73
+
74
+ // Exact string equality: no wrapper element, no attribute — the uncomposed
75
+ // checkout page must stay byte-identical to a build without the bridge.
76
+ expect(container.innerHTML).toBe('<span data-x="1">hi</span>');
77
+ });
78
+
79
+ it('never teleports outside the provider, even with an outlet registered', () => {
80
+ const outletNode = createOutletNode();
81
+ registerCheckoutOutlet('flow', { id: 'outlet-a', node: outletNode });
82
+
83
+ const { container } = render(
84
+ <CheckoutPiece piece="flow">
85
+ <span>in place</span>
86
+ </CheckoutPiece>
87
+ );
88
+
89
+ expect(container.innerHTML).toBe('<span>in place</span>');
90
+ expect(outletNode).toBeEmptyDOMElement();
91
+ });
92
+
93
+ it('teleports into an outlet registered before mount and keeps child state', () => {
94
+ const outletNode = createOutletNode();
95
+ registerCheckoutOutlet('flow', { id: 'outlet-a', node: outletNode });
96
+
97
+ const { container, rerender } = render(
98
+ <CheckoutPiecesProvider>
99
+ <CheckoutPiece piece="flow">
100
+ <Counter />
101
+ </CheckoutPiece>
102
+ </CheckoutPiecesProvider>
103
+ );
104
+
105
+ // Not in place — the child lives inside the outlet node.
106
+ expect(container).toBeEmptyDOMElement();
107
+
108
+ fireEvent.click(within(outletNode).getByRole('button'));
109
+ expect(within(outletNode).getByRole('button')).toHaveTextContent('count:1');
110
+
111
+ // A parent re-render must not remount the portaled child (one React tree).
112
+ rerender(
113
+ <CheckoutPiecesProvider>
114
+ <CheckoutPiece piece="flow">
115
+ <Counter />
116
+ </CheckoutPiece>
117
+ </CheckoutPiecesProvider>
118
+ );
119
+
120
+ expect(within(outletNode).getByRole('button')).toHaveTextContent('count:1');
121
+ });
122
+
123
+ it('ignores an outlet registered after the piece latched unclaimed', () => {
124
+ const { container } = render(
125
+ <CheckoutPiecesProvider>
126
+ <CheckoutPiece piece="flow">
127
+ <span>home</span>
128
+ </CheckoutPiece>
129
+ </CheckoutPiecesProvider>
130
+ );
131
+
132
+ expect(container.innerHTML).toBe('<span>home</span>');
133
+
134
+ const outletNode = createOutletNode();
135
+ act(() => {
136
+ registerCheckoutOutlet('flow', { id: 'late', node: outletNode });
137
+ });
138
+
139
+ // The unclaimed latch is permanent until navigation: a publish applies on
140
+ // the next full load, never mid-session under a payment surface.
141
+ expect(container.innerHTML).toBe('<span>home</span>');
142
+ expect(outletNode).toBeEmptyDOMElement();
143
+ });
144
+
145
+ it('returns home when the latched outlet disappears', () => {
146
+ const outletNode = createOutletNode();
147
+ registerCheckoutOutlet('flow', { id: 'outlet-a', node: outletNode });
148
+
149
+ const { container } = render(
150
+ <CheckoutPiecesProvider>
151
+ <CheckoutPiece piece="flow">
152
+ <span>flow</span>
153
+ </CheckoutPiece>
154
+ </CheckoutPiecesProvider>
155
+ );
156
+
157
+ expect(container).toBeEmptyDOMElement();
158
+
159
+ act(() => unregisterCheckoutOutlet('flow', 'outlet-a'));
160
+
161
+ // Ordering must never become impossible: with the outlet gone the flow
162
+ // renders in its default in-place position again.
163
+ expect(container.innerHTML).toBe('<span>flow</span>');
164
+ expect(outletNode).toBeEmptyDOMElement();
165
+ });
166
+ });
167
+
168
+ describe('CheckoutPiece in a framed but NOT edited window', () => {
169
+ // A production storefront legitimately iframed (partner portal, kiosk
170
+ // shell) is framed but never receives the editor handshake. It must keep
171
+ // live semantics: the one-shot latch, no claim-following — a portaled flow
172
+ // hosting a payment iframe must never be re-targeted mid-session.
173
+ beforeAll(() => {
174
+ delete window.__themeEditorDesigner;
175
+ Object.defineProperty(window, 'top', {
176
+ configurable: true,
177
+ value: { name: 'embedder' }
178
+ });
179
+ });
180
+
181
+ afterAll(() => {
182
+ Object.defineProperty(window, 'top', {
183
+ configurable: true,
184
+ value: window
185
+ });
186
+ });
187
+
188
+ it('ignores a late outlet: being framed is not the same as being edited', () => {
189
+ const { container } = render(
190
+ <CheckoutPiecesProvider>
191
+ <CheckoutPiece piece="flow">
192
+ <span>home</span>
193
+ </CheckoutPiece>
194
+ </CheckoutPiecesProvider>
195
+ );
196
+
197
+ expect(container.innerHTML).toBe('<span>home</span>');
198
+
199
+ const outletNode = createOutletNode();
200
+ act(() => {
201
+ registerCheckoutOutlet('flow', { id: 'late', node: outletNode });
202
+ });
203
+
204
+ expect(container.innerHTML).toBe('<span>home</span>');
205
+ expect(outletNode).toBeEmptyDOMElement();
206
+ });
207
+
208
+ it('never inherits a duplicate outlet claim when the winner dies', () => {
209
+ const first = createOutletNode();
210
+ const second = createOutletNode();
211
+ registerCheckoutOutlet('flow', { id: 'first', node: first });
212
+ registerCheckoutOutlet('flow', { id: 'second', node: second });
213
+
214
+ const { container } = render(
215
+ <CheckoutPiecesProvider>
216
+ <CheckoutPiece piece="flow">
217
+ <span>flow</span>
218
+ </CheckoutPiece>
219
+ </CheckoutPiecesProvider>
220
+ );
221
+
222
+ expect(first.innerHTML).toBe('<span>flow</span>');
223
+
224
+ // The latched outlet dies mid-session (e.g. its section errored away).
225
+ act(() => unregisterCheckoutOutlet('flow', 'first'));
226
+
227
+ // The claim passes to the duplicate in the registry, but the piece must
228
+ // come HOME, not chase it — retargeting would remount a payment iframe.
229
+ expect(container.innerHTML).toBe('<span>flow</span>');
230
+ expect(second).toBeEmptyDOMElement();
231
+ });
232
+ });
233
+
234
+ describe('CheckoutPiece in the designer', () => {
235
+ beforeAll(() => {
236
+ // The canvas is detected by the editor-proven flag, set only after a
237
+ // trusted SET_THEME_EDITOR_COOKIE through the origin-guarded bridge —
238
+ // never by mere framing.
239
+ window.__themeEditorDesigner = true;
240
+ });
241
+
242
+ afterAll(() => {
243
+ delete window.__themeEditorDesigner;
244
+ });
245
+
246
+ it('follows registration, removal and re-registration on the canvas', () => {
247
+ const { container } = render(
248
+ <CheckoutPiecesProvider>
249
+ <CheckoutPiece piece="flow">
250
+ <span>flow</span>
251
+ </CheckoutPiece>
252
+ </CheckoutPiecesProvider>
253
+ );
254
+
255
+ expect(container.innerHTML).toBe('<span>flow</span>');
256
+
257
+ const outletNode = createOutletNode();
258
+ act(() => {
259
+ registerCheckoutOutlet('flow', { id: 'outlet-a', node: outletNode });
260
+ });
261
+
262
+ // Late registration teleports here — the author must see the edit land.
263
+ expect(container).toBeEmptyDOMElement();
264
+ expect(outletNode.innerHTML).toBe('<span>flow</span>');
265
+
266
+ act(() => unregisterCheckoutOutlet('flow', 'outlet-a'));
267
+
268
+ expect(container.innerHTML).toBe('<span>flow</span>');
269
+ expect(outletNode).toBeEmptyDOMElement();
270
+
271
+ act(() => {
272
+ registerCheckoutOutlet('flow', { id: 'outlet-b', node: outletNode });
273
+ });
274
+
275
+ expect(container).toBeEmptyDOMElement();
276
+ expect(outletNode.innerHTML).toBe('<span>flow</span>');
277
+ });
278
+
279
+ it('adopts designer semantics when the handshake lands after mount', () => {
280
+ // The canvas bootstraps live-first: the flag rides a postMessage and can
281
+ // arrive after the page hydrated. The bridge re-checks it on pieces-store
282
+ // notifications, and every claim change notifies the store — so the flag
283
+ // is always seen before designer/live behavior could diverge.
284
+ delete window.__themeEditorDesigner;
285
+
286
+ const { container } = render(
287
+ <CheckoutPiecesProvider>
288
+ <CheckoutPiece piece="flow">
289
+ <span>flow</span>
290
+ </CheckoutPiece>
291
+ </CheckoutPiecesProvider>
292
+ );
293
+
294
+ expect(container.innerHTML).toBe('<span>flow</span>');
295
+
296
+ const outletNode = createOutletNode();
297
+ act(() => {
298
+ // SET_THEME_EDITOR_COOKIE landed, then the first canvas edit mounts an
299
+ // outlet — the registration notifies the store, the bridge re-checks.
300
+ window.__themeEditorDesigner = true;
301
+ registerCheckoutOutlet('flow', { id: 'outlet-a', node: outletNode });
302
+ });
303
+
304
+ expect(container).toBeEmptyDOMElement();
305
+ expect(outletNode.innerHTML).toBe('<span>flow</span>');
306
+ });
307
+ });
308
+
309
+ describe('checkout pieces registry', () => {
310
+ it('resolves duplicate outlets first-wins', () => {
311
+ const first = createOutletNode();
312
+ const second = createOutletNode();
313
+
314
+ registerCheckoutOutlet('summary', { id: 'first', node: first });
315
+ registerCheckoutOutlet('summary', { id: 'second', node: second });
316
+
317
+ expect(getCheckoutOutletClaim('summary')?.id).toBe('first');
318
+ expect(isCheckoutOutletWinner('summary', 'first')).toBe(true);
319
+ expect(isCheckoutOutletWinner('summary', 'second')).toBe(false);
320
+
321
+ // A duplicate id (an effect re-run) must not queue a second claim.
322
+ registerCheckoutOutlet('summary', { id: 'first', node: second });
323
+ expect(getCheckoutOutletClaim('summary')?.node).toBe(first);
324
+
325
+ // The next-in-line outlet inherits the claim when the winner leaves.
326
+ unregisterCheckoutOutlet('summary', 'first');
327
+ expect(getCheckoutOutletClaim('summary')?.id).toBe('second');
328
+
329
+ unregisterCheckoutOutlet('summary', 'second');
330
+ expect(getCheckoutOutletClaim('summary')).toBeNull();
331
+ });
332
+
333
+ it('reads the flow variant preference from the first-wins flow claim only', () => {
334
+ expect(getCheckoutFlowVariantOverride()).toBeNull();
335
+
336
+ const first = createOutletNode();
337
+ const second = createOutletNode();
338
+
339
+ registerCheckoutOutlet('flow', { id: 'first', node: first });
340
+ // A claim without a preference means inherit.
341
+ expect(getCheckoutFlowVariantOverride()).toBeNull();
342
+
343
+ registerCheckoutOutlet('flow', {
344
+ id: 'second',
345
+ node: second,
346
+ flowVariant: 'one-page'
347
+ });
348
+ // The duplicate outlet's preference never steers — first-wins.
349
+ expect(getCheckoutFlowVariantOverride()).toBeNull();
350
+
351
+ unregisterCheckoutOutlet('flow', 'first');
352
+ expect(getCheckoutFlowVariantOverride()).toBe('one-page');
353
+ });
354
+
355
+ it('updates a registration in place: no reorder, notify only on change', () => {
356
+ const first = createOutletNode();
357
+ const second = createOutletNode();
358
+
359
+ registerCheckoutOutlet('flow', {
360
+ id: 'first',
361
+ node: first,
362
+ flowVariant: 'one-page'
363
+ });
364
+ registerCheckoutOutlet('flow', { id: 'second', node: second });
365
+
366
+ const listener = jest.fn();
367
+ const unsubscribe = subscribeCheckoutPieces(listener);
368
+
369
+ updateCheckoutOutletFlowVariant('flow', 'first', 'step-by-step');
370
+ expect(listener).toHaveBeenCalledTimes(1);
371
+ expect(getCheckoutFlowVariantOverride()).toBe('step-by-step');
372
+ // Still first-wins: the update never re-queues the registration.
373
+ expect(getCheckoutOutletClaim('flow')?.id).toBe('first');
374
+ expect(getCheckoutOutletClaim('flow')?.node).toBe(first);
375
+
376
+ // Same value again: nothing changed, nobody re-renders.
377
+ updateCheckoutOutletFlowVariant('flow', 'first', 'step-by-step');
378
+ expect(listener).toHaveBeenCalledTimes(1);
379
+
380
+ // Clearing back to inherit.
381
+ updateCheckoutOutletFlowVariant('flow', 'first', undefined);
382
+ expect(getCheckoutFlowVariantOverride()).toBeNull();
383
+
384
+ // Unknown ids and pieces are a no-op, never a crash.
385
+ updateCheckoutOutletFlowVariant('flow', 'ghost', 'one-page');
386
+ updateCheckoutOutletFlowVariant('steps', 'first', 'one-page');
387
+ expect(getCheckoutFlowVariantOverride()).toBeNull();
388
+
389
+ unsubscribe();
390
+ });
391
+
392
+ it('detects the designer only through the editor-proven flag', () => {
393
+ delete window.__themeEditorDesigner;
394
+ expect(isThemeEditorDesigner()).toBe(false);
395
+
396
+ window.__themeEditorDesigner = true;
397
+ expect(isThemeEditorDesigner()).toBe(true);
398
+
399
+ delete window.__themeEditorDesigner;
400
+ });
401
+
402
+ it('refcounts piece presence across announce and retract', () => {
403
+ expect(isCheckoutPieceMounted('steps')).toBe(false);
404
+
405
+ announceCheckoutPiece('steps');
406
+ announceCheckoutPiece('steps');
407
+ expect(isCheckoutPieceMounted('steps')).toBe(true);
408
+
409
+ retractCheckoutPiece('steps');
410
+ expect(isCheckoutPieceMounted('steps')).toBe(true);
411
+
412
+ retractCheckoutPiece('steps');
413
+ expect(isCheckoutPieceMounted('steps')).toBe(false);
414
+
415
+ // A stray retract must not push the count negative and mask a later mount.
416
+ retractCheckoutPiece('steps');
417
+ announceCheckoutPiece('steps');
418
+ expect(isCheckoutPieceMounted('steps')).toBe(true);
419
+ });
420
+ });
@@ -62,11 +62,11 @@ describe('ChromeFallback empty zone in the designer', () => {
62
62
  value: { postMessage }
63
63
  });
64
64
 
65
- render(<ChromeFallback slug="checkout-summary-body" emptyZone />);
65
+ render(<ChromeFallback slug="checkout-bottom-body" emptyZone />);
66
66
 
67
67
  // A frame from the editor is what proves its origin to the bridge;
68
68
  // without one there is no trusted target and the message is dropped.
69
- pushTheme([], 'checkout-summary-body');
69
+ pushTheme([], 'checkout-bottom-body');
70
70
 
71
71
  fireEvent.click(
72
72
  screen.getByRole('button', { name: /edit in theme editor/i })
@@ -76,7 +76,7 @@ describe('ChromeFallback empty zone in the designer', () => {
76
76
  const [envelope, targetOrigin] = postMessage.mock.calls[0];
77
77
  expect(envelope).toMatchObject({
78
78
  type: 'REQUEST_CHROME_OVERRIDE',
79
- data: { slug: 'checkout-summary-body' }
79
+ data: { slug: 'checkout-bottom-body' }
80
80
  });
81
81
  expect(envelope.protocolVersion).toBeGreaterThan(0);
82
82
  expect(targetOrigin).toBe(EDITOR_ORIGIN);
@@ -4,10 +4,14 @@ import { render, screen, fireEvent } from '@testing-library/react';
4
4
  import { CodeOwnedRegion } from '../chrome/code-owned-region';
5
5
 
6
6
  /**
7
- * The checkout flow offers no takeover affordance on purpose, but silence
8
- * reads as breakage: a designer hovering it sees the same nothing a bug would
9
- * produce. These tests pin the explainer that fills that gap — and pin that
10
- * it never leaks to visitors and never blocks the flow it wraps.
7
+ * Some regions offer no takeover on purpose, but silence reads as breakage: a
8
+ * designer hovering one sees the same nothing a bug would produce. These tests
9
+ * pin the explainer that fills that gap — and pin that it never leaks to
10
+ * visitors and never blocks what it wraps.
11
+ *
12
+ * The reference app's checkout no longer mounts this: its body IS composable,
13
+ * so it takes `ComposableRegion` and a real affordance. The template app's
14
+ * checkout, which has no body zone at all, still does.
11
15
  */
12
16
 
13
17
  describe('CodeOwnedRegion in the designer', () => {
@@ -18,7 +22,7 @@ describe('CodeOwnedRegion in the designer', () => {
18
22
  });
19
23
  });
20
24
 
21
- it('explains the region on hover and says where the composable zones are', () => {
25
+ it('explains the region on hover without promising outlet placement', () => {
22
26
  render(
23
27
  <CodeOwnedRegion label="checkout flow">
24
28
  <div data-testid="flow">payment steps</div>
@@ -31,12 +35,32 @@ describe('CodeOwnedRegion in the designer', () => {
31
35
 
32
36
  const note = screen.getByText(/checkout flow is code-owned/i);
33
37
  expect(note).toBeInTheDocument();
34
- expect(note.textContent).toMatch(/zones above and below/i);
38
+ expect(note.textContent).toMatch(/is code-owned/i);
39
+ expect(note.textContent).toMatch(/internals cannot be edited here/i);
40
+ // The default used to promise placement through outlet blocks, which is
41
+ // false for any mount without a composable body zone — and outlets only
42
+ // register inside one. A mount that has the zone passes its own note.
43
+ expect(note.textContent).not.toMatch(/outlet blocks/i);
35
44
 
36
45
  fireEvent.mouseLeave(screen.getByTestId('flow').parentElement!);
37
46
  expect(screen.queryByText(/code-owned/i)).not.toBeInTheDocument();
38
47
  });
39
48
 
49
+ it('lets the mounting layout replace the default sentence with its own note', () => {
50
+ render(
51
+ <CodeOwnedRegion label="checkout flow" note="Compose the Checkout Body zone.">
52
+ <div data-testid="flow">payment steps</div>
53
+ </CodeOwnedRegion>
54
+ );
55
+
56
+ fireEvent.mouseEnter(screen.getByTestId('flow').parentElement!);
57
+
58
+ expect(
59
+ screen.getByText('Compose the Checkout Body zone.')
60
+ ).toBeInTheDocument();
61
+ expect(screen.queryByText(/is code-owned —/)).not.toBeInTheDocument();
62
+ });
63
+
40
64
  it('never intercepts the pointer, so the flow stays usable in the preview', () => {
41
65
  render(
42
66
  <CodeOwnedRegion label="checkout flow">