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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +31 -7
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-flow-block.test.tsx +211 -0
  4. package/src/__tests__/auth-flow-bus.test.ts +290 -0
  5. package/src/__tests__/auth-form-error-block.test.tsx +326 -0
  6. package/src/__tests__/auth-form-validation.test.tsx +204 -0
  7. package/src/__tests__/auth-step-title.test.tsx +202 -0
  8. package/src/__tests__/detached-submit-state.test.tsx +216 -0
  9. package/src/__tests__/forgot-password-composed.test.tsx +313 -0
  10. package/src/__tests__/otp-form-composed.test.tsx +487 -0
  11. package/src/__tests__/password-reset-step.test.tsx +269 -0
  12. package/src/__tests__/register-form-composed.test.tsx +739 -0
  13. package/src/__tests__/registry-parity.test.ts +9 -0
  14. package/src/auth/auth-field-errors.tsx +151 -0
  15. package/src/auth/auth-flow-bus.ts +415 -0
  16. package/src/auth/form-error-context.tsx +55 -0
  17. package/src/auth/form-submit-bus.ts +93 -0
  18. package/src/auth/otp-form-context.tsx +80 -0
  19. package/src/blocks/__tests__/button-block.test.tsx +55 -0
  20. package/src/blocks/__tests__/link-block.test.tsx +48 -30
  21. package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
  22. package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
  23. package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
  24. package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
  25. package/src/blocks/__tests__/text-block.test.tsx +37 -3
  26. package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
  27. package/src/blocks/auth-consent-block.tsx +214 -0
  28. package/src/blocks/auth-error-block.tsx +82 -0
  29. package/src/blocks/auth-field-block.tsx +270 -0
  30. package/src/blocks/auth-flow-block.tsx +251 -0
  31. package/src/blocks/auth-form-block.tsx +391 -47
  32. package/src/blocks/auth-step-block.tsx +81 -0
  33. package/src/blocks/auth-step-title-block.tsx +81 -0
  34. package/src/blocks/block-renderer-registry.tsx +18 -0
  35. package/src/blocks/button-block.tsx +91 -13
  36. package/src/blocks/forgot-password-block.tsx +318 -24
  37. package/src/blocks/link-block.tsx +22 -84
  38. package/src/blocks/otp-code-input-block.tsx +180 -0
  39. package/src/blocks/otp-form-block.tsx +1092 -0
  40. package/src/blocks/otp-phase-block.tsx +71 -0
  41. package/src/blocks/register-form-block.tsx +541 -74
  42. package/src/blocks/text-block.tsx +17 -40
  43. package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
  44. package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
  45. package/src/hooks/use-block-action.ts +87 -4
  46. package/src/hooks/use-overlay.ts +60 -9
  47. package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
  48. package/src/overlays/__tests__/modal-registry.test.ts +6 -1
  49. package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
  50. package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
  51. package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
  52. package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
  53. package/src/overlays/modal-preview-host.tsx +39 -2
  54. package/src/overlays/modal-registry.ts +344 -4
  55. package/src/overlays/overlay-surface.tsx +249 -19
  56. package/src/overlays/overlay-tokens.ts +94 -0
  57. package/src/overlays/theme-modal-mount.tsx +34 -4
  58. package/src/theme-block.tsx +6 -0
  59. package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
  60. package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
  61. package/src/utils/index.ts +107 -6
  62. package/src/utils/iterator-utils.ts +39 -2
  63. package/src/utils/part-styles.ts +4 -0
@@ -4,7 +4,10 @@ import React from 'react';
4
4
  import { partAttrs } from '../utils';
5
5
  import {
6
6
  OVERLAY_BACKDROP_COLOR,
7
- OVERLAY_BACKDROP_OPACITY
7
+ OVERLAY_BACKDROP_OPACITY,
8
+ SHEET_HEIGHT_FALLBACK,
9
+ isSheetHeightLength,
10
+ resolveOverlayTopInset
8
11
  } from './overlay-tokens';
9
12
  import type { OverlayPresentation, OverlayWidthUnit } from './overlay-tokens';
10
13
 
@@ -101,6 +104,57 @@ export const resolveOverlayBackdrop = (
101
104
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
102
105
  };
103
106
 
107
+ /**
108
+ * How a stretched panel hands its height down to the composition.
109
+ *
110
+ * The markup between the panel and the designer's blocks is fixed and ours:
111
+ * `<div data-placeholder>` then `<section data-section-id>` (whose content
112
+ * box is `display: contents`, so the section lays the block wrappers out
113
+ * directly). These rules make that fixed chain fill the panel; what happens
114
+ * INSIDE the section stays the designer's (a section set to Flex column with
115
+ * "space between" pins its last block to the drawer's bottom edge). The auth
116
+ * journey wrappers are carried along via `:has` so a flow's active step can
117
+ * fill the drawer without the designer hand-writing the chain.
118
+ *
119
+ * The chain has to be UNBROKEN, which is the whole difficulty: height is not
120
+ * inherited, it is passed down link by link, and one wrapper left at `auto`
121
+ * strands everything below it. That is why the composed forms are in the list
122
+ * too — an OTP form sits between the step and its phase, so a phase can only
123
+ * fill the drawer if the form does. A block that should NOT grow opts out with
124
+ * its own Flex control (`flex: 0 0 auto`), which outranks these rules.
125
+ */
126
+ const AUTH_JOURNEY_PARTS = [
127
+ 'data-auth-flow',
128
+ 'data-auth-step',
129
+ 'data-otp-phase',
130
+ 'data-auth-form',
131
+ 'data-register-form',
132
+ 'data-otp-form',
133
+ 'data-forgot-password'
134
+ ];
135
+
136
+ const strandsOf = (attribute: string) => ({
137
+ /** The designer wrapper the generated stylesheet targets. */
138
+ wrapper: `[data-modal-stretch] [data-block-id]:has(>[${attribute}])`,
139
+ /** Our own element inside it. */
140
+ element: `[data-modal-stretch] [${attribute}]`
141
+ });
142
+
143
+ const PANEL_STRETCH_CSS = [
144
+ '[data-modal-stretch]>[data-placeholder]{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}',
145
+ '[data-modal-stretch]>[data-placeholder]>section{flex:1 1 auto;min-height:0}',
146
+ `${AUTH_JOURNEY_PARTS.map((attribute) => strandsOf(attribute).wrapper).join(
147
+ ','
148
+ )}{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}`,
149
+ `${AUTH_JOURNEY_PARTS.map((attribute) => strandsOf(attribute).element).join(
150
+ ','
151
+ )}{flex:1 1 auto;min-height:0}`
152
+ ].join('\n');
153
+
154
+ export const PanelStretchStyles = () => (
155
+ <style dangerouslySetInnerHTML={{ __html: PANEL_STRETCH_CSS }} />
156
+ );
157
+
104
158
  /** The panel's own box, before geometry places it. */
105
159
  export const overlayPanelSurface: React.CSSProperties = {
106
160
  overflow: 'auto',
@@ -118,12 +172,47 @@ export interface OverlayGeometryInput {
118
172
  /** Unit of `width`; px unless the composed modal's chrome says otherwise. */
119
173
  widthUnit?: OverlayWidthUnit;
120
174
  panelSide?: string;
121
- /** Sheet height as a percentage of the viewport. */
122
- sheetHeight?: number;
175
+ /**
176
+ * Sheet height. A number is vh (the historical form); a string is any CSS
177
+ * length, so `calc(100dvh - 172px)` can say "everything below the header".
178
+ */
179
+ sheetHeight?: number | string;
123
180
  anchorAlign?: 'start' | 'center' | 'end';
124
181
  anchorOffset?: number;
182
+ /**
183
+ * Whether the panel hands its height to the composition. Only the sheet
184
+ * reads it: a drawer is already full height and a centred dialog sizes
185
+ * itself in the surface rather than here.
186
+ */
187
+ panelStretch?: boolean;
125
188
  }
126
189
 
190
+ /**
191
+ * A sheet's height, as a CSS length.
192
+ *
193
+ * A bare number is the historical form and means vh, which is what every
194
+ * published sheet carries. A string is taken as written, so a designer can say
195
+ * what a percentage cannot: `calc(100dvh - 172px)` is "everything below the
196
+ * header", and `fit-content` is "as tall as the composition needs".
197
+ *
198
+ * Rejected values fall back to the historical 60vh rather than reaching the
199
+ * browser: a declaration the browser drops would leave the panel with no height
200
+ * at all, and one carrying a brace or a semicolon would not be a length.
201
+ */
202
+ export const resolveSheetHeight = (
203
+ value: number | string | undefined
204
+ ): string => {
205
+ if (typeof value === 'number' && Number.isFinite(value)) {
206
+ return `${value}vh`;
207
+ }
208
+
209
+ if (typeof value === 'string' && isSheetHeightLength(value)) {
210
+ return value.trim();
211
+ }
212
+
213
+ return `${SHEET_HEIGHT_FALLBACK}vh`;
214
+ };
215
+
127
216
  /**
128
217
  * Where the panel sits for a given presentation.
129
218
  *
@@ -136,9 +225,10 @@ export const resolveOverlayPanelGeometry = ({
136
225
  width,
137
226
  widthUnit = 'px',
138
227
  panelSide = 'right',
139
- sheetHeight = 60,
228
+ sheetHeight = 60 as number | string,
140
229
  anchorAlign = 'start',
141
- anchorOffset = 8
230
+ anchorOffset = 8,
231
+ panelStretch = false
142
232
  }: OverlayGeometryInput): React.CSSProperties => {
143
233
  if (presentation === 'drawer') {
144
234
  return {
@@ -158,7 +248,17 @@ export const resolveOverlayPanelGeometry = ({
158
248
  right: 0,
159
249
  bottom: 0,
160
250
  width: '100%',
161
- maxHeight: `${sheetHeight}vh`,
251
+ /**
252
+ * A stretched sheet is TOLD its height rather than merely capped at it.
253
+ * `maxHeight` alone leaves the panel as tall as its content, so the flex
254
+ * column stretch turns on with no free space to hand out and a CTA given
255
+ * `margin-top: auto` does not move — which reads as the setting doing
256
+ * nothing. Unstretched, the cap stays the whole rule: a short sheet
257
+ * should still hug its content.
258
+ */
259
+ ...(panelStretch
260
+ ? { height: resolveSheetHeight(sheetHeight) }
261
+ : { maxHeight: resolveSheetHeight(sheetHeight) }),
162
262
  boxShadow: '0 -12px 32px rgba(0, 0, 0, 0.18)'
163
263
  };
164
264
  }
@@ -167,8 +267,8 @@ export const resolveOverlayPanelGeometry = ({
167
267
  anchorAlign === 'center'
168
268
  ? { left: '50%', transform: 'translateX(-50%)' }
169
269
  : anchorAlign === 'end'
170
- ? { right: 0 }
171
- : { left: 0 };
270
+ ? { right: 0 }
271
+ : { left: 0 };
172
272
 
173
273
  return {
174
274
  position: 'absolute',
@@ -186,13 +286,56 @@ export const resolveOverlayPanelGeometry = ({
186
286
  export interface OverlayCloseButtonProps {
187
287
  label: string;
188
288
  onClick: () => void;
289
+ /**
290
+ * Where the button sits inside the panel, and how big its glyph is.
291
+ *
292
+ * The button is positioned against the PANEL while a title row authored in
293
+ * the composition flows with its own padding, so the two never line up by
294
+ * accident. Absent keeps the historical 12 / 12 / 18 every published modal
295
+ * renders with.
296
+ */
297
+ top?: number | string;
298
+ right?: number | string;
299
+ size?: number | string;
300
+ /**
301
+ * Lift the button above the panel's authored content.
302
+ *
303
+ * A composed modal fills its panel with the designer's blocks, and a
304
+ * full-width one — a title row, a banner — paints OVER this button: it comes
305
+ * later in the DOM and the button, positioned but unlayered, loses. The click
306
+ * then lands on the heading and the ✕ does nothing at all, which is how this
307
+ * was found.
308
+ *
309
+ * A flag rather than always-on because the legacy size-guide panel renders
310
+ * the same button and its markup is pinned byte for byte by
311
+ * `panel-legacy-golden.json` — live storefronts style it. Only the composed
312
+ * hosts ask.
313
+ */
314
+ elevate?: boolean;
189
315
  /** Legacy marker attribute, e.g. `{ 'data-size-guide-close': '' }`. */
190
316
  markerAttrs?: Record<string, string>;
191
317
  }
192
318
 
319
+ /** px unless the author wrote a length. */
320
+ const asCssLength = (
321
+ value: number | string | undefined,
322
+ fallback: string
323
+ ): string => {
324
+ if (typeof value === 'number' && Number.isFinite(value)) return `${value}px`;
325
+ if (typeof value === 'string' && isSheetHeightLength(value)) {
326
+ return value.trim();
327
+ }
328
+
329
+ return fallback;
330
+ };
331
+
193
332
  export const OverlayCloseButton = ({
194
333
  label,
195
334
  onClick,
335
+ top,
336
+ right,
337
+ size,
338
+ elevate,
196
339
  markerAttrs
197
340
  }: OverlayCloseButtonProps) => (
198
341
  <button
@@ -203,18 +346,31 @@ export const OverlayCloseButton = ({
203
346
  onClick={onClick}
204
347
  style={{
205
348
  position: 'absolute',
206
- top: '12px',
207
- right: '12px',
349
+ top: asCssLength(top, '12px'),
350
+ right: asCssLength(right, '12px'),
208
351
  border: 'none',
209
352
  background: 'transparent',
210
353
  color: 'inherit',
211
354
  cursor: 'pointer',
212
- padding: 0
355
+ /**
356
+ * Untouched unless a size is asked for, and that is not fussiness: this
357
+ * button is the legacy size-guide panel's too, whose markup is pinned
358
+ * byte for byte by `panel-legacy-golden.json` because live storefronts
359
+ * style it. A hit area is worth having — the padding is what a thumb aims
360
+ * at, and the negative margin keeps the glyph where the offsets put it —
361
+ * but only a modal that asked for a size gets them.
362
+ */
363
+ ...(size === undefined
364
+ ? { padding: 0 }
365
+ : { padding: '8px', margin: '-8px', lineHeight: 0 }),
366
+ // Only has to beat static content, which is what the composition's
367
+ // blocks are; the panel's own layer sits at 1000 above the page.
368
+ ...(elevate ? { zIndex: 2 } : null)
213
369
  }}
214
370
  >
215
371
  <svg
216
- width="18"
217
- height="18"
372
+ width={size === undefined ? '18' : asCssLength(size, '18px')}
373
+ height={size === undefined ? '18' : asCssLength(size, '18px')}
218
374
  viewBox="0 0 24 24"
219
375
  fill="none"
220
376
  stroke="currentColor"
@@ -261,6 +417,29 @@ export interface OverlaySurfaceProps extends OverlayGeometryInput {
261
417
  backdropOpacity?: number;
262
418
  closeOnOverlay: boolean;
263
419
  onDismiss: () => void;
420
+ /**
421
+ * The panel's inner padding in px; absent means the historical 24. Owned by
422
+ * the composed modal's chrome — the panel block keeps its fixed surface.
423
+ */
424
+ panelPadding?: number;
425
+ /**
426
+ * Stretch the composed content to the panel's full height (see
427
+ * `ModalChrome.stretchContent`). The panel becomes a flex column and the
428
+ * scoped rules below walk the stretch through the placeholder markup.
429
+ */
430
+ panelStretch?: boolean;
431
+ /**
432
+ * The sheet's grab bar. Absent reads as shown, which is what every published
433
+ * sheet has — a composition with its own header turns it off so the bar does
434
+ * not read as a stray line above the title.
435
+ */
436
+ showSheetHandle?: boolean;
437
+ /**
438
+ * How far down the whole overlay layer starts, so a sticky site header is
439
+ * neither covered nor dimmed. Absent means the layer covers the viewport,
440
+ * which is what every published modal does.
441
+ */
442
+ topInset?: number | string;
264
443
  /** Rendered inside the panel, above the authored content. */
265
444
  header?: React.ReactNode;
266
445
  children: React.ReactNode;
@@ -278,6 +457,18 @@ export interface OverlaySurfaceProps extends OverlayGeometryInput {
278
457
  };
279
458
  }
280
459
 
460
+ /**
461
+ * The `top` a layer takes when the design keeps the site header clear, as a
462
+ * style object so both surfaces spread it the same way.
463
+ */
464
+ const topInsetStyle = (
465
+ value: number | string | undefined
466
+ ): React.CSSProperties | null => {
467
+ const resolved = resolveOverlayTopInset(value);
468
+
469
+ return resolved ? { top: resolved } : null;
470
+ };
471
+
281
472
  /**
282
473
  * The centred modal, kept as ONE node that is both the fixed root and the
283
474
  * backdrop.
@@ -298,6 +489,9 @@ export const CentredModalSurface = ({
298
489
  widthUnit,
299
490
  backdropColor,
300
491
  backdropOpacity,
492
+ panelPadding,
493
+ panelStretch,
494
+ topInset,
301
495
  header,
302
496
  children,
303
497
  markers
@@ -314,6 +508,11 @@ export const CentredModalSurface = ({
314
508
  style={{
315
509
  position: 'fixed',
316
510
  inset: 0,
511
+ // Same rule as the layered surface: this node IS the backdrop, so the
512
+ // inset is what keeps a sticky header visible and clickable. The dialog
513
+ // then centres in what is left, which is where a designer who asked for
514
+ // the header to stay clear expects it.
515
+ ...topInsetStyle(topInset),
317
516
  zIndex: 1000,
318
517
  display: 'flex',
319
518
  alignItems: 'center',
@@ -325,6 +524,7 @@ export const CentredModalSurface = ({
325
524
  <div
326
525
  {...markers?.panel}
327
526
  {...partAttrs('modal', { open: true })}
527
+ {...(panelStretch ? { 'data-modal-stretch': '' } : null)}
328
528
  role="dialog"
329
529
  {...overlayProps}
330
530
  aria-label={ariaLabel}
@@ -337,13 +537,21 @@ export const CentredModalSurface = ({
337
537
  maxWidth: resolveOverlayWidth(width, widthUnit),
338
538
  maxHeight: '80vh',
339
539
  overflow: 'auto',
340
- padding: '24px',
540
+ padding: `${panelPadding ?? 24}px`,
341
541
  borderRadius: '4px',
342
542
  background: '#ffffff',
343
543
  color: '#111827',
344
- textAlign: 'left'
544
+ textAlign: 'left',
545
+ ...(panelStretch
546
+ ? {
547
+ display: 'flex',
548
+ flexDirection: 'column' as const,
549
+ height: '80vh'
550
+ }
551
+ : null)
345
552
  }}
346
553
  >
554
+ {panelStretch && <PanelStretchStyles />}
347
555
  {header}
348
556
  {children}
349
557
  </div>
@@ -372,6 +580,10 @@ export const LayeredOverlaySurface = ({
372
580
  sheetHeight,
373
581
  anchorAlign,
374
582
  anchorOffset,
583
+ panelPadding,
584
+ panelStretch,
585
+ showSheetHandle,
586
+ topInset,
375
587
  header,
376
588
  children,
377
589
  markers
@@ -395,6 +607,7 @@ export const LayeredOverlaySurface = ({
395
607
  <div
396
608
  {...markers?.panel}
397
609
  {...partAttrs('modal', { open: true })}
610
+ {...(panelStretch ? { 'data-modal-stretch': '' } : null)}
398
611
  role="dialog"
399
612
  {...overlayProps}
400
613
  aria-label={ariaLabel}
@@ -404,6 +617,9 @@ export const LayeredOverlaySurface = ({
404
617
  onClick={(event) => event.stopPropagation()}
405
618
  style={{
406
619
  ...overlayPanelSurface,
620
+ ...(panelPadding !== undefined
621
+ ? { padding: `${panelPadding}px` }
622
+ : null),
407
623
  ...resolveOverlayPanelGeometry({
408
624
  presentation,
409
625
  width,
@@ -411,12 +627,17 @@ export const LayeredOverlaySurface = ({
411
627
  panelSide,
412
628
  sheetHeight,
413
629
  anchorAlign,
414
- anchorOffset
630
+ anchorOffset,
631
+ panelStretch
415
632
  }),
416
- pointerEvents: showBackdrop ? undefined : 'auto'
633
+ pointerEvents: showBackdrop ? undefined : 'auto',
634
+ ...(panelStretch
635
+ ? { display: 'flex', flexDirection: 'column' as const }
636
+ : null)
417
637
  }}
418
638
  >
419
- {presentation === 'sheet' && (
639
+ {panelStretch && <PanelStretchStyles />}
640
+ {presentation === 'sheet' && showSheetHandle !== false && (
420
641
  <OverlaySheetHandle markerAttrs={markers?.handle} />
421
642
  )}
422
643
  {header}
@@ -430,7 +651,12 @@ export const LayeredOverlaySurface = ({
430
651
  {showBackdrop && (
431
652
  <div
432
653
  {...markers?.backdropRoot}
433
- style={{ position: 'fixed', inset: 0, zIndex: 999 }}
654
+ style={{
655
+ position: 'fixed',
656
+ inset: 0,
657
+ ...topInsetStyle(topInset),
658
+ zIndex: 999
659
+ }}
434
660
  >
435
661
  {backdrop}
436
662
  </div>
@@ -446,6 +672,10 @@ export const LayeredOverlaySurface = ({
446
672
  style={{
447
673
  position: 'fixed',
448
674
  inset: 0,
675
+ // The inset is on the LAYER, not the panel: the backdrop lives here
676
+ // too, and a panel-only offset would leave the header dimmed and
677
+ // unclickable — which is exactly what the design does not do.
678
+ ...topInsetStyle(topInset),
449
679
  zIndex: 1000,
450
680
  pointerEvents: showBackdrop ? undefined : 'none'
451
681
  }}
@@ -32,3 +32,97 @@ export const OVERLAY_WIDTH_UNITS: OverlayWidthUnit[] = ['px', '%', 'vw'];
32
32
  /** Backdrop paint defaults — what every published surface shipped with. */
33
33
  export const OVERLAY_BACKDROP_COLOR = '#000000';
34
34
  export const OVERLAY_BACKDROP_OPACITY = 50;
35
+
36
+ /**
37
+ * How tall a sheet is, and what a designer is allowed to write for it.
38
+ *
39
+ * A number is a percentage of the viewport — the historical form every
40
+ * published sheet carries. A string is a CSS length, which is the only way to
41
+ * say what a percentage cannot: `calc(100dvh - 172px)` is "everything below the
42
+ * header", `fit-content` is "as tall as the composition needs".
43
+ *
44
+ * Lives here rather than beside the surface that applies it because the
45
+ * REGISTRY validates it, and the registry is parsed on the server — see the
46
+ * note at the top of this file for what importing a client module there costs.
47
+ */
48
+ const SHEET_HEIGHT_KEYWORDS = new Set([
49
+ 'auto',
50
+ 'fit-content',
51
+ 'min-content',
52
+ 'max-content',
53
+ 'stretch'
54
+ ]);
55
+
56
+ /** Units a viewport-sized panel is plausibly measured in. */
57
+ const SHEET_HEIGHT_UNIT =
58
+ '(px|%|vh|dvh|svh|lvh|vw|dvw|svw|lvw|rem|em|ch|ex|cm|mm|in|pt|pc|q)';
59
+
60
+ const SHEET_HEIGHT_LENGTH = new RegExp(
61
+ `^-?\\d*\\.?\\d+${SHEET_HEIGHT_UNIT}$`,
62
+ 'i'
63
+ );
64
+
65
+ const SHEET_HEIGHT_FUNCTION = /^(calc|min|max|clamp)\(.+\)$/i;
66
+
67
+ /**
68
+ * Whether a string is a height the browser will accept.
69
+ *
70
+ * Strict on purpose: an earlier, looser check let `tall` through as a "length",
71
+ * and a declaration the browser drops leaves the panel with NO height — a sheet
72
+ * collapsed to its content with the composition spilling out of it. A function
73
+ * is checked for length-ish contents rather than parsed: nothing here may carry
74
+ * a brace, a semicolon, a `url()` or a `var()`, which are how a value stops
75
+ * being a measurement.
76
+ */
77
+ export const isSheetHeightLength = (value: string): boolean => {
78
+ const trimmed = value.trim();
79
+
80
+ if (!trimmed || trimmed.length > 120) return false;
81
+ if (/[;{}]|url|expression/i.test(trimmed)) return false;
82
+ if (SHEET_HEIGHT_KEYWORDS.has(trimmed.toLowerCase())) return true;
83
+ if (SHEET_HEIGHT_LENGTH.test(trimmed)) return true;
84
+ // `var(--header-height)` and `env(safe-area-inset-top)`: a designer who
85
+ // knows the measure only as a variable has no other way to say it, and a
86
+ // custom property cannot break out of a declaration.
87
+ if (/^(var|env)\(--?[a-z0-9-]+(,[^;{}]*)?\)$/i.test(trimmed)) return true;
88
+
89
+ if (SHEET_HEIGHT_FUNCTION.test(trimmed)) {
90
+ // Inside a function only arithmetic, numbers, units, variables and nesting.
91
+ const body = trimmed.replace(/^(calc|min|max|clamp)\(/i, '').slice(0, -1);
92
+ return (
93
+ /^[0-9a-z\s().,%+*/_-]+$/i.test(body) &&
94
+ // A number or a variable has to appear, or this is words in parentheses.
95
+ (/\d/.test(body) || /var\(|env\(/i.test(body))
96
+ );
97
+ }
98
+
99
+ return false;
100
+ };
101
+
102
+ /**
103
+ * How far down the overlay layer starts.
104
+ *
105
+ * A bottom sheet in a design does not ride over the site header: the header
106
+ * stays visible AND clickable, and the dimming begins below it. That cannot be
107
+ * expressed as a panel height — the backdrop covers the header either way — so
108
+ * it is an inset on the layer itself, which the backdrop and the panel share.
109
+ *
110
+ * Same vocabulary as a sheet height: a number is px (the unit a header is
111
+ * measured in), a string is any length this module accepts.
112
+ */
113
+ export const resolveOverlayTopInset = (
114
+ value: number | string | undefined
115
+ ): string | undefined => {
116
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
117
+ return `${value}px`;
118
+ }
119
+
120
+ if (typeof value === 'string' && isSheetHeightLength(value)) {
121
+ return value.trim();
122
+ }
123
+
124
+ return undefined;
125
+ };
126
+
127
+ /** The default every sheet had before its height could be written out. */
128
+ export const SHEET_HEIGHT_FALLBACK = 60;
@@ -7,7 +7,11 @@ import { useVisibilityContext } from '../hooks/use-visibility-context';
7
7
  import { useBrowseMode } from '../browse-mode';
8
8
  import { evaluateVisibilityRules } from '../utils/visibility-rules';
9
9
  import { closeModal, useModalOpen } from './overlay-bus';
10
- import { ThemeModal } from './modal-registry';
10
+ import {
11
+ ThemeModal,
12
+ resolveModalChrome,
13
+ resolveModalPresentation
14
+ } from './modal-registry';
11
15
  import {
12
16
  CentredModalSurface,
13
17
  LayeredOverlaySurface,
@@ -37,7 +41,8 @@ interface ThemeModalMountProps {
37
41
 
38
42
  const isDesignerWindow = () =>
39
43
  typeof window !== 'undefined' &&
40
- (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner === true;
44
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner ===
45
+ true;
41
46
 
42
47
  /**
43
48
  * One composed modal: the bus decides whether it is open, `useOverlay` decides
@@ -102,7 +107,18 @@ const ThemeModalMount = ({
102
107
  const supersededByEditor = origin === 'saved' && editorOwnsModal;
103
108
 
104
109
  const opened = !isPreviewing && !supersededByEditor && isOpen && existsHere;
105
- const { chrome, presentation } = modal;
110
+ /**
111
+ * Resolved, not read: a sheet's height and the weight of its backdrop are
112
+ * answered per device, so the values have to be settled for THIS breakpoint
113
+ * before any of them reaches a style.
114
+ */
115
+ const chrome = resolveModalChrome(modal, breakpoint);
116
+ /**
117
+ * Resolved, not read: a modal is often a centred dialog on a desktop and a
118
+ * bottom sheet on a phone, and the shape decides the geometry, the backdrop
119
+ * and the sheet handle — so it has to be settled before any of them.
120
+ */
121
+ const presentation = resolveModalPresentation(modal, breakpoint);
106
122
  const isCentred = presentation === 'modal';
107
123
 
108
124
  /**
@@ -128,7 +144,14 @@ const ThemeModalMount = ({
128
144
  const dismiss = () => closeModal(modal.id);
129
145
 
130
146
  const header = chrome.showClose ? (
131
- <OverlayCloseButton label={chrome.closeLabel} onClick={dismiss} />
147
+ <OverlayCloseButton
148
+ label={chrome.closeLabel}
149
+ onClick={dismiss}
150
+ top={chrome.closeTop}
151
+ right={chrome.closeRight}
152
+ size={chrome.closeSize}
153
+ elevate
154
+ />
132
155
  ) : null;
133
156
 
134
157
  const ariaLabel = chrome.ariaLabel || modal.name;
@@ -143,6 +166,9 @@ const ThemeModalMount = ({
143
166
  widthUnit={chrome.widthUnit}
144
167
  backdropColor={chrome.backdropColor}
145
168
  backdropOpacity={chrome.backdropOpacity}
169
+ panelPadding={chrome.padding}
170
+ panelStretch={chrome.stretchContent}
171
+ topInset={chrome.topInset}
146
172
  header={header}
147
173
  >
148
174
  {children}
@@ -161,6 +187,10 @@ const ThemeModalMount = ({
161
187
  backdropOpacity={chrome.backdropOpacity}
162
188
  panelSide={chrome.panelSide}
163
189
  sheetHeight={chrome.sheetHeight}
190
+ showSheetHandle={chrome.showSheetHandle}
191
+ topInset={chrome.topInset}
192
+ panelPadding={chrome.padding}
193
+ panelStretch={chrome.stretchContent}
164
194
  header={header}
165
195
  >
166
196
  {children}
@@ -46,6 +46,12 @@ export interface Block {
46
46
  blocks?: Block[]; // Optional - only group blocks have nested blocks
47
47
  isIterator?: boolean;
48
48
  iteratorDataPath?: string;
49
+ /**
50
+ * Properties this clone resolved for itself from its row. One generated rule
51
+ * serves every clone, so these are the values that have to be inlined on the
52
+ * element — see `collectRowBoundStyleKeys` in utils/iterator-utils.ts.
53
+ */
54
+ rowStyleKeys?: string[];
49
55
  }
50
56
 
51
57
  interface ThemeBlockProps {