@akinon/pz-theme 2.0.95-rc.0 → 2.0.95

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 (62) hide show
  1. package/CHANGELOG.md +8 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-captcha.test.tsx +261 -0
  4. package/src/__tests__/auth-commerce-events.test.tsx +5 -1
  5. package/src/__tests__/auth-destination.test.tsx +348 -0
  6. package/src/__tests__/auth-error-reading.test.ts +232 -0
  7. package/src/__tests__/auth-flow-scope.test.tsx +162 -0
  8. package/src/__tests__/auth-form-error-block.test.tsx +2 -0
  9. package/src/__tests__/auth-form-success-block.test.tsx +429 -0
  10. package/src/__tests__/auth-form-validation.test.tsx +2 -0
  11. package/src/__tests__/auth-slot-coverage.test.ts +9 -1
  12. package/src/__tests__/auth-step-title.test.tsx +2 -0
  13. package/src/__tests__/block-action-audience.test.ts +154 -0
  14. package/src/__tests__/detached-submit-state.test.tsx +80 -2
  15. package/src/__tests__/forgot-password-composed.test.tsx +2 -0
  16. package/src/__tests__/form-state-composition.test.tsx +484 -0
  17. package/src/__tests__/modal-login-close.test.tsx +366 -0
  18. package/src/__tests__/otp-form-composed.test.tsx +2 -0
  19. package/src/__tests__/password-reset-step.test.tsx +2 -0
  20. package/src/__tests__/register-form-composed.test.tsx +253 -4
  21. package/src/__tests__/registry-parity.test.ts +12 -0
  22. package/src/__tests__/reset-password-composed.test.tsx +385 -0
  23. package/src/auth/auth-captcha.tsx +125 -0
  24. package/src/auth/auth-destination.ts +168 -0
  25. package/src/auth/auth-field-errors.tsx +31 -0
  26. package/src/auth/form-state-context.tsx +170 -0
  27. package/src/auth/form-success-context.tsx +56 -0
  28. package/src/auth/read-auth-error.ts +332 -0
  29. package/src/blocks/__tests__/slider-block.test.tsx +265 -1
  30. package/src/blocks/account-button-block.tsx +25 -0
  31. package/src/blocks/add-to-cart-block.tsx +15 -0
  32. package/src/blocks/auth-field-block.tsx +13 -3
  33. package/src/blocks/auth-flow-block.tsx +45 -3
  34. package/src/blocks/auth-form-block.tsx +337 -54
  35. package/src/blocks/auth-success-block.tsx +85 -0
  36. package/src/blocks/block-renderer-registry.tsx +8 -0
  37. package/src/blocks/button-block.tsx +43 -13
  38. package/src/blocks/captcha-block.tsx +61 -0
  39. package/src/blocks/change-password-form-block.tsx +15 -0
  40. package/src/blocks/forgot-password-block.tsx +132 -9
  41. package/src/blocks/form-state-block.tsx +84 -0
  42. package/src/blocks/newsletter-form-block.tsx +16 -0
  43. package/src/blocks/otp-form-block.tsx +161 -67
  44. package/src/blocks/profile-form-block.tsx +13 -0
  45. package/src/blocks/register-form-block.tsx +362 -110
  46. package/src/blocks/reset-password-block.tsx +717 -0
  47. package/src/blocks/slider-block.tsx +170 -4
  48. package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
  49. package/src/chrome/placeholder-slugs.ts +3 -0
  50. package/src/chrome/route-slug.ts +11 -0
  51. package/src/index.ts +10 -0
  52. package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
  53. package/src/overlays/__tests__/modal-registry.test.ts +5 -1
  54. package/src/overlays/modal-chrome-css.ts +8 -1
  55. package/src/overlays/modal-host-context.tsx +66 -0
  56. package/src/overlays/modal-preview-host.tsx +7 -2
  57. package/src/overlays/modal-registry.ts +38 -1
  58. package/src/overlays/overlay-loading.tsx +144 -0
  59. package/src/overlays/overlay-surface.tsx +23 -0
  60. package/src/overlays/overlay-tokens.ts +12 -0
  61. package/src/overlays/theme-modal-mount.tsx +85 -6
  62. package/src/utils/visibility-rules.ts +114 -0
@@ -10,6 +10,7 @@ import {
10
10
  OverlayCloseButton,
11
11
  OverlayHandleProps
12
12
  } from './overlay-surface';
13
+ import { ModalHostIdProvider } from './modal-host-context';
13
14
  import ThemeModalMount from './theme-modal-mount';
14
15
  import { useEditorModals } from './use-editor-modals';
15
16
  import { useHeldModal } from './use-modal-preview';
@@ -167,7 +168,9 @@ export const ModalPreviewHost = ({
167
168
  chromeCss={chromeCss}
168
169
  header={header}
169
170
  >
170
- {composition(modal.slug)}
171
+ <ModalHostIdProvider value={modal.id}>
172
+ {composition(modal.slug)}
173
+ </ModalHostIdProvider>
171
174
  </CentredModalSurface>
172
175
  ) : (
173
176
  <LayeredOverlaySurface
@@ -193,7 +196,9 @@ export const ModalPreviewHost = ({
193
196
  chromeCss={chromeCss}
194
197
  header={header}
195
198
  >
196
- {composition(modal.slug)}
199
+ <ModalHostIdProvider value={modal.id}>
200
+ {composition(modal.slug)}
201
+ </ModalHostIdProvider>
197
202
  </LayeredOverlaySurface>
198
203
  );
199
204
 
@@ -7,6 +7,7 @@ import type { VisibilityRules } from '../utils/visibility-rules';
7
7
  import {
8
8
  OVERLAY_BACKDROP_COLOR,
9
9
  OVERLAY_BACKDROP_OPACITY,
10
+ OVERLAY_LOADING_DELAY_MAX,
10
11
  OVERLAY_WIDTH_UNITS,
11
12
  SHEET_HEIGHT_FALLBACK,
12
13
  isSheetHeightLength,
@@ -152,6 +153,30 @@ export interface ModalChrome {
152
153
  */
153
154
  closeText?: string;
154
155
  closeTextSide?: 'before' | 'after';
156
+ /**
157
+ * Whether the panel shows a loading mark while its composition is still
158
+ * committing. Absent reads as ON, which cannot change how a published modal
159
+ * looks: the layer is invisible for its first `loadingDelay` ms, and a
160
+ * composition that commits in the same frame — every small one — has
161
+ * revealed itself long before that. What it buys is the large ones, where
162
+ * the panel's white surface is on screen for a beat with nothing in it.
163
+ *
164
+ * Off restores the single commit: the composition renders with the panel and
165
+ * nothing is drawn over it.
166
+ */
167
+ showLoading?: boolean;
168
+ /**
169
+ * How long the panel may be empty before the mark fades in, in ms. Absent
170
+ * means the historical-safe 150 — see `OVERLAY_LOADING_DELAY`.
171
+ */
172
+ loadingDelay?: number;
173
+ /**
174
+ * Words beside the mark. Absence-preserving for the same reason as
175
+ * `closeText`, and for one more: this package has no dictionary, so a
176
+ * default would be an English string on every storefront. Authored text is
177
+ * the only text.
178
+ */
179
+ loadingLabel?: string;
155
180
  }
156
181
 
157
182
  /**
@@ -602,7 +627,19 @@ const readChrome = (value: unknown, name: string): ModalChrome => {
602
627
  // a close text must not come out of here claiming an empty one, and the
603
628
  // side only means something once the text exists.
604
629
  closeText: asString(raw.closeText) || undefined,
605
- closeTextSide: raw.closeTextSide === 'before' ? 'before' : undefined
630
+ closeTextSide: raw.closeTextSide === 'before' ? 'before' : undefined,
631
+ // Absent reads as on: the layer is invisible until the delay has passed,
632
+ // so turning it on for every published modal changes nothing a shopper
633
+ // sees except on the modals that were already keeping them waiting.
634
+ showLoading: asToggle(raw.showLoading, true),
635
+ // Read, not defaulted, on the same grounds as `padding`: 0 is a real
636
+ // answer ("show it at once"), so absence has to stay distinguishable.
637
+ loadingDelay: asOptionalClamped(
638
+ raw.loadingDelay,
639
+ 0,
640
+ OVERLAY_LOADING_DELAY_MAX
641
+ ),
642
+ loadingLabel: asString(raw.loadingLabel) || undefined
606
643
  };
607
644
  };
608
645
 
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { partAttrs } from '../utils';
5
+ import { OVERLAY_LOADING_DELAY } from './overlay-tokens';
6
+
7
+ /**
8
+ * What a modal shows while its composition is still arriving.
9
+ *
10
+ * The gap this exists for is not a fetch — a composed modal's content ships
11
+ * with the page, in the layout's payload — it is the COMMIT. Opening a drawer
12
+ * mounts the whole composition at once, and a large one (ninety blocks, a
13
+ * text block carrying half a megabyte of pasted markup) takes long enough
14
+ * that the panel's own white surface is on screen well before anything is in
15
+ * it. A shopper reads that as a broken drawer, which is why the host reveals
16
+ * the composition in a transition and puts this underneath in the meantime:
17
+ * the panel and this layer are the cheap commit, and they paint immediately.
18
+ *
19
+ * Two consequences follow from "the main thread is busy" and both shape the
20
+ * implementation:
21
+ *
22
+ * - The reveal is a CSS animation, not a timer. A `setTimeout` scheduled to
23
+ * show a spinner cannot fire while the thread it lives on is committing
24
+ * the very tree it is waiting for.
25
+ * - The layer carries no shopper-facing copy of its own. pz-theme has no
26
+ * dictionary — it renders authored text — so an invented "Loading…" would
27
+ * be an untranslatable English string on every storefront. The dialog
28
+ * carries `aria-busy` instead, and a designer who wants words authors them.
29
+ */
30
+
31
+ /**
32
+ * Re-exported from `overlay-tokens.ts`, which owns them so the server-side
33
+ * registry can clamp the authored delay without importing this client module.
34
+ *
35
+ * `OVERLAY_LOADING_DELAY` is long enough that a composition which commits in
36
+ * the same frame — the common case, and the one this must not disturb — never
37
+ * flashes a spinner, and short enough that it is not itself the pause it
38
+ * exists to explain.
39
+ */
40
+ export {
41
+ OVERLAY_LOADING_DELAY,
42
+ OVERLAY_LOADING_DELAY_MAX
43
+ } from './overlay-tokens';
44
+
45
+ /** How long the layer takes to fade in once the delay has passed. */
46
+ const OVERLAY_LOADING_FADE = 120;
47
+
48
+ /**
49
+ * The reveal and the spin. Keyframes cannot be written inline, and the
50
+ * `prefers-reduced-motion` answer cannot either — a shopper who asked for
51
+ * less motion still gets the affordance, just still.
52
+ *
53
+ * Scoped through the layer's own part attribute rather than emitted bare, so
54
+ * a page with two overlays up does not have two authors for one selector.
55
+ */
56
+ const OVERLAY_LOADING_CSS = [
57
+ '@keyframes pz-overlay-loading-in{from{opacity:0}to{opacity:1}}',
58
+ '@keyframes pz-overlay-loading-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}',
59
+ '@media (prefers-reduced-motion: reduce){' +
60
+ '[data-part="loading"] [data-part="loading-mark"]{animation:none}}'
61
+ ].join('');
62
+
63
+ export interface OverlayLoadingLayerProps {
64
+ /**
65
+ * Visible text beside the mark, if the designer authored one. Absent means
66
+ * the mark alone — see the note on copy above.
67
+ */
68
+ label?: string;
69
+ /** ms before the layer fades in. See `OVERLAY_LOADING_DELAY`. */
70
+ delay?: number;
71
+ }
72
+
73
+ /**
74
+ * The layer itself: a mark centred in the panel, invisible for its first
75
+ * `delay` ms.
76
+ *
77
+ * In FLOW, not absolutely positioned, and that is deliberate: the panel is
78
+ * `overflow: auto`, so `inset: 0` would anchor to the scrollable content box
79
+ * and scroll away rather than stay centred — and a stretched panel is a flex
80
+ * column, where an out-of-flow child leaves the column with nothing to size.
81
+ * In flow, `flex: 1 1 auto` fills a stretched drawer and `min-height` gives a
82
+ * centred dialog something to be while it is empty. Nothing is drawn over the
83
+ * composition either way: the host renders one or the other, never both.
84
+ *
85
+ * Inert regardless, because the panel underneath is already listening for
86
+ * clicks it must keep receiving.
87
+ */
88
+ export const OverlayLoadingLayer = ({
89
+ label,
90
+ delay = OVERLAY_LOADING_DELAY
91
+ }: OverlayLoadingLayerProps) => (
92
+ <div
93
+ {...partAttrs('loading')}
94
+ /**
95
+ * Hidden from assistive tech when it is a bare spinner: the dialog's own
96
+ * `aria-busy` is the fact a screen reader needs, and a wordless status
97
+ * region announced the instant a drawer opens talks over the dialog's
98
+ * name. Authored text is real content and stays readable.
99
+ */
100
+ aria-hidden={label ? undefined : true}
101
+ style={{
102
+ display: 'flex',
103
+ // Fills a stretched panel, and is ignored by one that is not a flex
104
+ // column — where the min-height is what keeps the mark off the top edge.
105
+ flex: '1 1 auto',
106
+ minHeight: '160px',
107
+ flexDirection: 'column',
108
+ alignItems: 'center',
109
+ justifyContent: 'center',
110
+ gap: '12px',
111
+ pointerEvents: 'none',
112
+ opacity: 0,
113
+ animation:
114
+ `pz-overlay-loading-in ${OVERLAY_LOADING_FADE}ms linear ` +
115
+ `${delay}ms forwards`
116
+ }}
117
+ >
118
+ <style dangerouslySetInnerHTML={{ __html: OVERLAY_LOADING_CSS }} />
119
+ <span
120
+ {...partAttrs('loading-mark')}
121
+ style={{
122
+ width: '24px',
123
+ height: '24px',
124
+ borderRadius: '999px',
125
+ // `currentColor` so the mark inherits the panel's text colour: a
126
+ // designer who darkened the panel does not also have to restyle this.
127
+ border: '2px solid currentColor',
128
+ borderTopColor: 'transparent',
129
+ opacity: 0.45,
130
+ animation: 'pz-overlay-loading-spin 700ms linear infinite'
131
+ }}
132
+ />
133
+ {label ? (
134
+ <span
135
+ {...partAttrs('loading-text')}
136
+ style={{ fontSize: '13px', opacity: 0.7 }}
137
+ >
138
+ {label}
139
+ </span>
140
+ ) : null}
141
+ </div>
142
+ );
143
+
144
+ export default OverlayLoadingLayer;
@@ -478,6 +478,21 @@ export interface OverlaySurfaceProps extends OverlayGeometryInput {
478
478
  chromeCss?: string;
479
479
  /** Rendered inside the panel, above the authored content. */
480
480
  header?: React.ReactNode;
481
+ /**
482
+ * Drawn over the panel while the composition is still committing — see
483
+ * `overlay-loading.tsx` for why a large one needs it.
484
+ *
485
+ * A node rather than a flag because only the composed modal host has one:
486
+ * the panel block passes nothing, so its markup — pinned byte for byte by
487
+ * `panel-legacy-golden.json` — cannot move.
488
+ */
489
+ loading?: React.ReactNode;
490
+ /**
491
+ * Marks the dialog as still filling. The attribute is the accessible half of
492
+ * `loading`: a wordless spinner tells a screen reader nothing, `aria-busy`
493
+ * tells it exactly what is happening. Absent leaves no attribute at all.
494
+ */
495
+ panelBusy?: boolean;
481
496
  children: React.ReactNode;
482
497
  /**
483
498
  * Legacy marker attributes per node. Order matters: they are spread BEFORE
@@ -530,6 +545,8 @@ export const CentredModalSurface = ({
530
545
  topInset,
531
546
  chromeCss,
532
547
  header,
548
+ loading,
549
+ panelBusy,
533
550
  children,
534
551
  markers
535
552
  }: Omit<
@@ -565,6 +582,7 @@ export const CentredModalSurface = ({
565
582
  role="dialog"
566
583
  {...overlayProps}
567
584
  aria-label={ariaLabel}
585
+ {...(panelBusy ? { 'aria-busy': true as const } : null)}
568
586
  // The backdrop closes the modal; clicks on the panel itself must not
569
587
  // bubble up into that handler.
570
588
  onClick={(event) => event.stopPropagation()}
@@ -593,6 +611,7 @@ export const CentredModalSurface = ({
593
611
  <style dangerouslySetInnerHTML={{ __html: chromeCss }} />
594
612
  ) : null}
595
613
  {header}
614
+ {loading}
596
615
  {children}
597
616
  </div>
598
617
  </div>
@@ -626,6 +645,8 @@ export const LayeredOverlaySurface = ({
626
645
  topInset,
627
646
  chromeCss,
628
647
  header,
648
+ loading,
649
+ panelBusy,
629
650
  children,
630
651
  markers
631
652
  }: OverlaySurfaceProps) => {
@@ -652,6 +673,7 @@ export const LayeredOverlaySurface = ({
652
673
  role="dialog"
653
674
  {...overlayProps}
654
675
  aria-label={ariaLabel}
676
+ {...(panelBusy ? { 'aria-busy': true as const } : null)}
655
677
  // Focus target of last resort, for a panel whose every control the
656
678
  // designer turned off.
657
679
  tabIndex={-1}
@@ -685,6 +707,7 @@ export const LayeredOverlaySurface = ({
685
707
  <OverlaySheetHandle markerAttrs={markers?.handle} />
686
708
  )}
687
709
  {header}
710
+ {loading}
688
711
  {children}
689
712
  </div>
690
713
  );
@@ -33,6 +33,18 @@ export const OVERLAY_WIDTH_UNITS: OverlayWidthUnit[] = ['px', '%', 'vw'];
33
33
  export const OVERLAY_BACKDROP_COLOR = '#000000';
34
34
  export const OVERLAY_BACKDROP_OPACITY = 50;
35
35
 
36
+ /**
37
+ * How long a panel may be empty before its loading mark fades in, in ms, and
38
+ * the longest delay a designer may ask for.
39
+ *
40
+ * Here rather than beside the layer that animates it for the reason at the top
41
+ * of this file: the REGISTRY clamps the authored value, and the registry is
42
+ * parsed on the server. `overlay-loading.tsx` re-exports both so client
43
+ * callers keep one import path.
44
+ */
45
+ export const OVERLAY_LOADING_DELAY = 150;
46
+ export const OVERLAY_LOADING_DELAY_MAX = 2000;
47
+
36
48
  /**
37
49
  * How tall a sheet is, and what a designer is allowed to write for it.
38
50
  *
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { useEffect, useRef } from 'react';
3
+ import React, { startTransition, useEffect, useRef, useState } from 'react';
4
4
 
5
5
  import { useOverlay } from '../hooks/use-overlay';
6
6
  import { useVisibilityContext } from '../hooks/use-visibility-context';
@@ -18,6 +18,8 @@ import {
18
18
  LayeredOverlaySurface,
19
19
  OverlayCloseButton
20
20
  } from './overlay-surface';
21
+ import { OverlayLoadingLayer } from './overlay-loading';
22
+ import { ModalHostIdProvider } from './modal-host-context';
21
23
  import { useEditorKnowsModal } from './use-editor-modals';
22
24
  import { useModalPreview } from './use-modal-preview';
23
25
  import { useOverlayBreakpoint } from './use-overlay-breakpoint';
@@ -134,6 +136,53 @@ const ThemeModalMount = ({
134
136
  isDesigner ? breakpoint : undefined
135
137
  );
136
138
 
139
+ /**
140
+ * Whether the composition is revealed in its own commit, with a loading
141
+ * layer under it until it lands.
142
+ *
143
+ * Never in the canvas: a designer composing a modal must see their blocks
144
+ * the instant they select it, and the editor's own render is not the commit
145
+ * this is about.
146
+ */
147
+ const showsLoading = chrome.showLoading !== false && !isEditing;
148
+ const [revealed, setRevealed] = useState(!showsLoading);
149
+
150
+ /**
151
+ * A TRANSITION, not a timer.
152
+ *
153
+ * The pause this covers is the commit itself: a composed modal's content
154
+ * ships with the page, so opening a large one — ninety blocks, a text block
155
+ * carrying half a megabyte of pasted markup — is one long synchronous render
156
+ * during which nothing at all can be scheduled, a `setTimeout` that would
157
+ * show a spinner least of all. Rendering the composition at transition
158
+ * priority splits it off the urgent commit that draws the panel, so the
159
+ * panel and the loading layer paint first and the browser is free in
160
+ * between.
161
+ *
162
+ * It costs the fast path one extra commit and nothing visible: the layer is
163
+ * invisible for its first `loadingDelay` ms, which a small composition has
164
+ * long since outrun. And because `act()` flushes transitions, a test that
165
+ * opens a modal still sees its content in the same tick.
166
+ *
167
+ * Reset on close so a reopen starts from the cheap commit again — the bus is
168
+ * module state, so a mount lives far longer than one open.
169
+ */
170
+ useEffect(() => {
171
+ if (!showsLoading) {
172
+ setRevealed(true);
173
+
174
+ return;
175
+ }
176
+
177
+ if (!opened) {
178
+ setRevealed(false);
179
+
180
+ return;
181
+ }
182
+
183
+ startTransition(() => setRevealed(true));
184
+ }, [opened, showsLoading]);
185
+
137
186
  /**
138
187
  * In the canvas the overlay is fully inert: it never steals focus into the
139
188
  * page the designer is styling, never freezes it, never claims `aria-modal`
@@ -148,8 +197,17 @@ const ThemeModalMount = ({
148
197
  // be a lie.
149
198
  modal: isCentred ? true : chrome.showBackdrop,
150
199
  lockScroll: chrome.lockScroll && (isCentred || chrome.showBackdrop),
151
- closeOnEscape: chrome.closeOnEscape,
152
- fallbackFocusRef: rootRef
200
+ closeOnEscape: chrome.closeOnEscape
201
+ /**
202
+ * No `fallbackFocusRef` on purpose. It used to point at `rootRef`, which
203
+ * cannot take focus (the div carries no `tabIndex={-1}`) and is detached
204
+ * by the time the hand-back runs anyway — `if (!opened) return null`
205
+ * removes it in the same commit that closes the modal. `useOverlay`
206
+ * verifies that focus actually landed, so the branch was dead; naming a
207
+ * target that cannot work only hides the real gap, which is a modal
208
+ * nobody clicked open (an auto-triggered popup) having no opener to hand
209
+ * focus back to.
210
+ */
153
211
  });
154
212
 
155
213
  if (!opened) return null;
@@ -171,6 +229,20 @@ const ThemeModalMount = ({
171
229
 
172
230
  const ariaLabel = chrome.ariaLabel || modal.name;
173
231
 
232
+ /**
233
+ * The layer, and the composition it stands in for. Both are absent once the
234
+ * composition has landed, so nothing is drawn over it and no attribute is
235
+ * left claiming the dialog is still filling.
236
+ */
237
+ const loading =
238
+ showsLoading && !revealed ? (
239
+ <OverlayLoadingLayer
240
+ label={chrome.loadingLabel}
241
+ delay={chrome.loadingDelay}
242
+ />
243
+ ) : null;
244
+ const body = revealed ? children : null;
245
+
174
246
  const surface = isCentred ? (
175
247
  <CentredModalSurface
176
248
  overlayProps={overlay.overlayProps}
@@ -186,8 +258,10 @@ const ThemeModalMount = ({
186
258
  topInset={chrome.topInset}
187
259
  chromeCss={chromeCss}
188
260
  header={header}
261
+ loading={loading}
262
+ panelBusy={Boolean(loading)}
189
263
  >
190
- {children}
264
+ {body}
191
265
  </CentredModalSurface>
192
266
  ) : (
193
267
  <LayeredOverlaySurface
@@ -209,8 +283,10 @@ const ThemeModalMount = ({
209
283
  panelStretch={chrome.stretchContent}
210
284
  chromeCss={chromeCss}
211
285
  header={header}
286
+ loading={loading}
287
+ panelBusy={Boolean(loading)}
212
288
  >
213
- {children}
289
+ {body}
214
290
  </LayeredOverlaySurface>
215
291
  );
216
292
 
@@ -224,7 +300,10 @@ const ThemeModalMount = ({
224
300
  data-theme-modal={modal.id}
225
301
  data-theme-modal-state="open"
226
302
  >
227
- {surface}
303
+ {/* Tells the composition which modal it is inside, so a form that
304
+ finishes can close THIS one — see `modal-host-context.tsx`. Renders
305
+ no element, so the published markup is untouched. */}
306
+ <ModalHostIdProvider value={modal.id}>{surface}</ModalHostIdProvider>
228
307
  </div>
229
308
  );
230
309
  };
@@ -532,6 +532,109 @@ export const evaluateVisibilityRules = (
532
532
  const isVisibilityRuleExempt = (node: Block | Section): boolean =>
533
533
  containsCheckoutOutlet(node);
534
534
 
535
+
536
+ /**
537
+ * A per-breakpoint value, read the way the renderers read one.
538
+ *
539
+ * A local reader rather than `getResponsiveValue` from `../utils`: this module
540
+ * is pure and is walked on the server read path, and that barrel pulls in the
541
+ * React helpers.
542
+ */
543
+ const readResponsive = (value: unknown, breakpoint?: string): unknown => {
544
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
545
+ return value;
546
+ }
547
+
548
+ const record = value as Record<string, unknown>;
549
+ const chain = [
550
+ breakpoint,
551
+ ...(breakpoint ? BREAKPOINT_FALLBACKS[breakpoint] ?? [] : []),
552
+ 'desktop'
553
+ ];
554
+
555
+ for (const key of chain) {
556
+ if (key && record[key] !== undefined) return record[key];
557
+ }
558
+
559
+ return undefined;
560
+ };
561
+
562
+ /**
563
+ * Who a block's ACTION is for.
564
+ *
565
+ * Visibility already answers "who is this block for"; this answers the same
566
+ * question about the action wired onto it, which is not the same thing. The
567
+ * account button is the case that forced it: it renders its own link — to the
568
+ * account for a member, to the login page for a guest — while a designer wires
569
+ * "open the login drawer" onto the block itself. Both then fire on one click,
570
+ * so a signed-in shopper landed on their account page with the login drawer
571
+ * open over it.
572
+ *
573
+ * Resolved HERE, in the pass that already knows who the shopper is, rather
574
+ * than in `use-block-action`: that hook runs for every block on the page, and
575
+ * making it read the session would make a SessionProvider mandatory for the
576
+ * whole theme rather than for the placeholder wrapper that already has one.
577
+ */
578
+ export const BLOCK_ACTION_AUDIENCES = ['all', 'guest', 'authenticated'] as const;
579
+
580
+ export type BlockActionAudience = (typeof BLOCK_ACTION_AUDIENCES)[number];
581
+
582
+ const MODAL_ACTION_TYPES = new Set([
583
+ 'open-modal',
584
+ 'close-modal',
585
+ 'toggle-modal'
586
+ ]);
587
+
588
+ /**
589
+ * The audience a block's action is scoped to.
590
+ *
591
+ * Absent means `all`, EXCEPT for an account button opening a modal: a member
592
+ * clicking their own account button never wants the login drawer, so the
593
+ * default there is `guest`. That is a behaviour change on published
594
+ * compositions, and a deliberate one — the alternative is the drawer covering
595
+ * the account page it just navigated to. A brand that really wants it for
596
+ * everyone says `all`.
597
+ */
598
+ const resolveActionAudience = (
599
+ block: Block,
600
+ currentBreakpoint?: string
601
+ ): BlockActionAudience => {
602
+ const authored = String(
603
+ readResponsive(block.properties?.actionAudience, currentBreakpoint) ?? ''
604
+ ).trim();
605
+
606
+ if ((BLOCK_ACTION_AUDIENCES as readonly string[]).includes(authored)) {
607
+ return authored as BlockActionAudience;
608
+ }
609
+
610
+ const actionType = String(
611
+ readResponsive(block.properties?.actionType, currentBreakpoint) ?? ''
612
+ ).trim();
613
+
614
+ if (block.type === 'account-button' && MODAL_ACTION_TYPES.has(actionType)) {
615
+ return 'guest';
616
+ }
617
+
618
+ return 'all';
619
+ };
620
+
621
+ /**
622
+ * Whether the action runs for this shopper.
623
+ *
624
+ * `loading` counts as a guest: the session is unknown for the first client
625
+ * render, and withholding the login drawer through it would break the common
626
+ * case to fix the rare one.
627
+ */
628
+ const actionRunsFor = (
629
+ audience: BlockActionAudience,
630
+ authState: unknown
631
+ ): boolean => {
632
+ if (audience === 'all') return true;
633
+ if (audience === 'authenticated') return authState === 'authenticated';
634
+
635
+ return authState !== 'authenticated';
636
+ };
637
+
535
638
  const applyBlockVisibility = (
536
639
  block: Block,
537
640
  context: VisibilityRuleContext
@@ -541,8 +644,19 @@ const applyBlockVisibility = (
541
644
  isVisibilityRuleExempt(block) ||
542
645
  evaluateVisibilityRules(block.properties?.visibilityRules, context);
543
646
 
647
+ /**
648
+ * An action this shopper is not the audience for is REMOVED rather than
649
+ * ignored at click time, so the wrapper never claims `aria-haspopup="dialog"`
650
+ * for a dialog it will not open either.
651
+ */
652
+ const audience = resolveActionAudience(block, context.breakpoint);
653
+ const properties = actionRunsFor(audience, context.authState)
654
+ ? block.properties
655
+ : { ...(block.properties || {}), actionType: 'none' };
656
+
544
657
  return {
545
658
  ...block,
659
+ properties,
546
660
  hidden: Boolean(block.hidden) || !isVisibleByRules,
547
661
  blocks
548
662
  };