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

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 +39 -33
  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
@@ -12,6 +12,8 @@ import {
12
12
  import ThemeModalMount from './theme-modal-mount';
13
13
  import { useEditorModals } from './use-editor-modals';
14
14
  import { useHeldModal } from './use-modal-preview';
15
+ import { resolveModalChrome, resolveModalPresentation } from './modal-registry';
16
+ import { useOverlayBreakpoint } from './use-overlay-breakpoint';
15
17
 
16
18
  interface ModalPreviewHostProps {
17
19
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -63,6 +65,9 @@ export const ModalPreviewHost = ({
63
65
  }: ModalPreviewHostProps) => {
64
66
  const modal = useHeldModal();
65
67
  const editorModals = useEditorModals();
68
+ // Above the early return below: a hook that runs on some renders and not
69
+ // others is the one thing React cannot survive.
70
+ const breakpoint = useOverlayBreakpoint();
66
71
 
67
72
  const composition = (slug: string) => (
68
73
  <ThemePlaceholderWrapper
@@ -101,14 +106,33 @@ export const ModalPreviewHost = ({
101
106
 
102
107
  if (!modal) return triggerable;
103
108
 
104
- const { chrome, presentation } = modal;
109
+ /**
110
+ * Resolved, not read: a sheet's height and the weight of its backdrop are
111
+ * answered per device, so the values have to be settled for THIS breakpoint
112
+ * before any of them reaches a style.
113
+ */
114
+ const chrome = resolveModalChrome(modal, breakpoint);
115
+ /**
116
+ * The canvas has to show the shape the chosen device will get: a designer who
117
+ * switches the editor to mobile and still sees a centred dialog has no way to
118
+ * lay out the sheet they just asked for. The hook reads the editor's forced
119
+ * breakpoint inside the iframe, so this follows the viewport buttons.
120
+ */
121
+ const presentation = resolveModalPresentation(modal, breakpoint);
105
122
 
106
123
  // Every dismissal is inert: the designer has to be able to click the close
107
124
  // button and the backdrop they are colouring without the surface vanishing.
108
125
  const noop = () => undefined;
109
126
 
110
127
  const header = chrome.showClose ? (
111
- <OverlayCloseButton label={chrome.closeLabel} onClick={noop} />
128
+ <OverlayCloseButton
129
+ label={chrome.closeLabel}
130
+ onClick={noop}
131
+ top={chrome.closeTop}
132
+ right={chrome.closeRight}
133
+ size={chrome.closeSize}
134
+ elevate
135
+ />
112
136
  ) : null;
113
137
 
114
138
  const ariaLabel = chrome.ariaLabel || modal.name;
@@ -124,6 +148,12 @@ export const ModalPreviewHost = ({
124
148
  widthUnit={chrome.widthUnit}
125
149
  backdropColor={chrome.backdropColor}
126
150
  backdropOpacity={chrome.backdropOpacity}
151
+ // The canvas preview has to show what the storefront will: this host is
152
+ // where "I set the padding to 0 and nothing happened" came from the
153
+ // first time, because only the real mount was passing them through.
154
+ panelPadding={chrome.padding}
155
+ panelStretch={chrome.stretchContent}
156
+ topInset={chrome.topInset}
127
157
  header={header}
128
158
  >
129
159
  {composition(modal.slug)}
@@ -140,8 +170,15 @@ export const ModalPreviewHost = ({
140
170
  widthUnit={chrome.widthUnit}
141
171
  backdropColor={chrome.backdropColor}
142
172
  backdropOpacity={chrome.backdropOpacity}
173
+ // The canvas preview has to show what the storefront will: this host is
174
+ // where "I set the padding to 0 and nothing happened" came from the
175
+ // first time, because only the real mount was passing them through.
176
+ panelPadding={chrome.padding}
177
+ panelStretch={chrome.stretchContent}
143
178
  panelSide={chrome.panelSide}
144
179
  sheetHeight={chrome.sheetHeight}
180
+ showSheetHandle={chrome.showSheetHandle}
181
+ topInset={chrome.topInset}
145
182
  header={header}
146
183
  >
147
184
  {composition(modal.slug)}
@@ -8,6 +8,8 @@ import {
8
8
  OVERLAY_BACKDROP_COLOR,
9
9
  OVERLAY_BACKDROP_OPACITY,
10
10
  OVERLAY_WIDTH_UNITS,
11
+ SHEET_HEIGHT_FALLBACK,
12
+ isSheetHeightLength,
11
13
  type OverlayWidthUnit
12
14
  } from './overlay-tokens';
13
15
 
@@ -88,9 +90,72 @@ export interface ModalChrome {
88
90
  */
89
91
  widthUnit: OverlayWidthUnit;
90
92
  panelSide: 'left' | 'right';
91
- /** Sheet height as a percentage of the viewport. */
92
- sheetHeight: number;
93
+ /**
94
+ * Sheet height. A number is a percentage of the viewport (the historical
95
+ * form); a string is any CSS length, which is the only way to say what a
96
+ * percentage cannot — `calc(100dvh - 172px)` is "everything below the
97
+ * header", `fit-content` is "as tall as the composition needs".
98
+ */
99
+ sheetHeight: number | string;
93
100
  lockScroll: boolean;
101
+ /**
102
+ * The panel's inner padding in px. Optional because every modal saved before
103
+ * this existed has no value, and those must keep rendering with the 24 the
104
+ * surfaces have always applied.
105
+ */
106
+ padding?: number;
107
+ /**
108
+ * How far down the overlay starts, so a sticky site header is neither covered
109
+ * nor dimmed — the design's bottom sheet does not ride over the header. A
110
+ * number is px (the unit a header is measured in); a string is any length,
111
+ * including `var(--header-height)`. Absent means the overlay covers the
112
+ * viewport, which is what every modal published so far does.
113
+ */
114
+ topInset?: number | string;
115
+ /**
116
+ * Where the built-in close button sits inside the panel, and how big it is.
117
+ *
118
+ * The button is chrome: it is positioned against the PANEL, while a title row
119
+ * authored in the composition flows with its own padding. The two are in
120
+ * different coordinate systems, so they never line up by accident — and part
121
+ * styles cannot reach chrome (a part rule hangs off a `[data-block-id]`,
122
+ * which chrome has none of). These are the way to align them. Absent keeps
123
+ * the historical 12px / 12px / 18px every published modal renders with.
124
+ */
125
+ closeTop?: number | string;
126
+ closeRight?: number | string;
127
+ closeSize?: number | string;
128
+ /**
129
+ * The grab bar a sheet draws at its top centre. Absent reads as ON, because
130
+ * every sheet published so far has one and removing it silently would move
131
+ * their markup. A composition with its own header does not want it: the bar
132
+ * then reads as a stray line above the title.
133
+ */
134
+ showSheetHandle?: boolean;
135
+ /**
136
+ * Hands the panel's full height down to the composition, so a drawer can pin
137
+ * its CTA to the bottom edge instead of leaving it under the last field. Off
138
+ * by default and optional for the same backward-compatibility reason as
139
+ * `padding` — see `PANEL_STRETCH_CSS` in overlay-surface.tsx for the chain it
140
+ * turns on.
141
+ */
142
+ stretchContent?: boolean;
143
+ }
144
+
145
+ /**
146
+ * The chrome fields that may be answered again for a narrower breakpoint.
147
+ * Absent means "whatever the wider breakpoint said" — never a copied value, so
148
+ * a later change to the desktop answer still reaches the phone.
149
+ */
150
+ export interface ModalChromeOverrides {
151
+ sheetHeight?: number | string;
152
+ topInset?: number | string;
153
+ /** A title row is padded differently per device, so these follow it. */
154
+ closeTop?: number | string;
155
+ closeRight?: number | string;
156
+ closeSize?: number | string;
157
+ backdropColor?: string;
158
+ backdropOpacity?: number;
94
159
  }
95
160
 
96
161
  export interface ThemeModal {
@@ -101,7 +166,34 @@ export interface ThemeModal {
101
166
  /** `modal-<id>`, the widget slug the composition is saved under. */
102
167
  slug: string;
103
168
  presentation: ModalPresentation;
169
+ /**
170
+ * The presentation to use at a narrower breakpoint, when it differs.
171
+ *
172
+ * A centred modal is the wrong shape on a phone: the pattern every storefront
173
+ * reaches for there is a bottom sheet that fills the screen, with the CTA on
174
+ * its bottom edge. That is not a variant of the desktop dialog — it is a
175
+ * different presentation — and one scalar could not express both.
176
+ *
177
+ * A breakpoint left out follows the desktop choice, so every modal saved
178
+ * before this existed presents exactly as it did.
179
+ */
180
+ presentationByBreakpoint?: Partial<
181
+ Record<'tablet' | 'mobile', ModalPresentation>
182
+ >;
104
183
  chrome: ModalChrome;
184
+ /**
185
+ * Chrome values that differ at a narrower breakpoint.
186
+ *
187
+ * Only the settings whose right answer genuinely changes with the device: a
188
+ * sheet that should cover 92% of a phone is not the same measure as the 60%
189
+ * it gets on a desktop, and a backdrop dark enough behind a small sheet is
190
+ * too heavy behind a centred dialog. Everything else stays single-valued,
191
+ * because a control that can differ per device is a control a designer has
192
+ * to check three times.
193
+ */
194
+ chromeByBreakpoint?: Partial<
195
+ Record<'tablet' | 'mobile', ModalChromeOverrides>
196
+ >;
105
197
  autoTrigger?: ModalAutoTrigger;
106
198
  /**
107
199
  * Where this modal EXISTS — the same rule object sections and blocks already
@@ -191,6 +283,238 @@ const asClamped = (
191
283
  return Math.min(max, Math.max(min, numeric));
192
284
  };
193
285
 
286
+ /**
287
+ * Like `asClamped`, but absence survives.
288
+ *
289
+ * A setting whose default lives in the renderer (the panel's historical 24px
290
+ * padding) must come out of here as `undefined` when it was never authored —
291
+ * defaulting it in the parser would make every modal saved before the control
292
+ * existed claim a value it never had.
293
+ */
294
+ const asOptionalClamped = (
295
+ value: unknown,
296
+ min: number,
297
+ max: number
298
+ ): number | undefined => {
299
+ if (value === undefined || value === null || value === '') return undefined;
300
+
301
+ const numeric = Math.round(Number(value));
302
+ if (!Number.isFinite(numeric)) return undefined;
303
+
304
+ return Math.min(max, Math.max(min, numeric));
305
+ };
306
+
307
+ /**
308
+ * Reads the per-breakpoint presentation overrides.
309
+ *
310
+ * Absence is preserved rather than filled with the desktop value: an override
311
+ * that merely repeats the desktop choice would be indistinguishable from one a
312
+ * designer set deliberately, and the resolver below is the single place that
313
+ * decides what an absent breakpoint means.
314
+ */
315
+ const readPresentationByBreakpoint = (
316
+ value: unknown
317
+ ): ThemeModal['presentationByBreakpoint'] => {
318
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
319
+ return undefined;
320
+ }
321
+
322
+ const raw = value as Record<string, unknown>;
323
+ const overrides: Partial<Record<'tablet' | 'mobile', ModalPresentation>> = {};
324
+
325
+ (['tablet', 'mobile'] as const).forEach((breakpoint) => {
326
+ const candidate = raw[breakpoint];
327
+ if (
328
+ typeof candidate === 'string' &&
329
+ MODAL_PRESENTATIONS.includes(candidate as ModalPresentation)
330
+ ) {
331
+ overrides[breakpoint] = candidate as ModalPresentation;
332
+ }
333
+ });
334
+
335
+ return Object.keys(overrides).length > 0 ? overrides : undefined;
336
+ };
337
+
338
+ /**
339
+ * Reads the per-breakpoint chrome overrides.
340
+ *
341
+ * Each field goes through the same validation as its base counterpart — a junk
342
+ * colour or an out-of-range height must not become an override the base value
343
+ * could never have been. A breakpoint whose every field was rejected comes back
344
+ * absent rather than as an empty object nobody chose.
345
+ */
346
+ const readChromeByBreakpoint = (
347
+ value: unknown
348
+ ): ThemeModal['chromeByBreakpoint'] => {
349
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
350
+ return undefined;
351
+ }
352
+
353
+ const raw = value as Record<string, unknown>;
354
+ const overrides: Partial<Record<'tablet' | 'mobile', ModalChromeOverrides>> =
355
+ {};
356
+
357
+ (['tablet', 'mobile'] as const).forEach((breakpoint) => {
358
+ const entry = raw[breakpoint];
359
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return;
360
+
361
+ const fields = entry as Record<string, unknown>;
362
+ const next: ModalChromeOverrides = {};
363
+
364
+ if (
365
+ fields.sheetHeight !== undefined &&
366
+ fields.sheetHeight !== null &&
367
+ fields.sheetHeight !== ''
368
+ ) {
369
+ // Sentinel-free: an override that fails validation is dropped rather
370
+ // than falling back to 60, which would be an answer nobody gave.
371
+ const sheetHeight = asSheetHeight(fields.sheetHeight, '');
372
+ if (sheetHeight !== '') next.sheetHeight = sheetHeight;
373
+ }
374
+
375
+ if (
376
+ typeof fields.backdropColor === 'string' &&
377
+ HEX_COLOR_PATTERN.test(fields.backdropColor.trim())
378
+ ) {
379
+ next.backdropColor = fields.backdropColor.trim();
380
+ }
381
+
382
+ const backdropOpacity = asOptionalClamped(fields.backdropOpacity, 0, 100);
383
+ if (backdropOpacity !== undefined) next.backdropOpacity = backdropOpacity;
384
+
385
+ // A header is a different height on a phone, so this belongs per breakpoint.
386
+ const topInset = asChromeLength(fields.topInset);
387
+ if (topInset !== undefined) next.topInset = topInset;
388
+
389
+ (
390
+ [
391
+ ['closeTop', 200],
392
+ ['closeRight', 200],
393
+ ['closeSize', 64]
394
+ ] as const
395
+ ).forEach(([field, max]) => {
396
+ const measure = asChromeLength(fields[field], max);
397
+ if (measure !== undefined) next[field] = measure;
398
+ });
399
+
400
+ if (Object.keys(next).length > 0) overrides[breakpoint] = next;
401
+ });
402
+
403
+ return Object.keys(overrides).length > 0 ? overrides : undefined;
404
+ };
405
+
406
+ /**
407
+ * The chrome to render at one breakpoint.
408
+ *
409
+ * Overrides fall UP the chain exactly as the presentation does: a phone takes
410
+ * its own answer, then the tablet's, then the desktop's. Returns the SAME
411
+ * object when nothing overrides, so a modal without overrides is not merely
412
+ * equal but identical — no new object per render.
413
+ */
414
+ export const resolveModalChrome = (
415
+ modal: Pick<ThemeModal, 'chrome' | 'chromeByBreakpoint'>,
416
+ breakpoint?: string
417
+ ): ModalChrome => {
418
+ const overrides = modal.chromeByBreakpoint;
419
+ if (!overrides) return modal.chrome;
420
+
421
+ // Widest first: each layer overwrites what the wider one said.
422
+ const chain: (ModalChromeOverrides | undefined)[] = [];
423
+ if (breakpoint === 'tablet' || breakpoint === 'mobile') {
424
+ chain.push(overrides.tablet);
425
+ }
426
+ if (breakpoint === 'mobile') {
427
+ chain.push(overrides.mobile);
428
+ }
429
+
430
+ const applied = chain.filter(Boolean) as ModalChromeOverrides[];
431
+ if (applied.length === 0) return modal.chrome;
432
+
433
+ return applied.reduce<ModalChrome>(
434
+ (chrome, layer) => ({ ...chrome, ...layer }),
435
+ modal.chrome
436
+ );
437
+ };
438
+
439
+ /**
440
+ * The presentation to render at one breakpoint.
441
+ *
442
+ * Narrower breakpoints fall UP the chain rather than to the desktop value: a
443
+ * modal told to be a sheet on tablet should stay a sheet on a phone, which is
444
+ * the narrower device, unless the phone was given its own answer. Anything
445
+ * else would have a designer set two controls to say one thing.
446
+ */
447
+ export const resolveModalPresentation = (
448
+ modal: Pick<ThemeModal, 'presentation' | 'presentationByBreakpoint'>,
449
+ breakpoint?: string
450
+ ): ModalPresentation => {
451
+ const overrides = modal.presentationByBreakpoint;
452
+ if (!overrides) return modal.presentation;
453
+
454
+ if (breakpoint === 'mobile') {
455
+ return overrides.mobile ?? overrides.tablet ?? modal.presentation;
456
+ }
457
+
458
+ if (breakpoint === 'tablet') {
459
+ return overrides.tablet ?? modal.presentation;
460
+ }
461
+
462
+ return modal.presentation;
463
+ };
464
+
465
+ /**
466
+ * A sheet height, as a number of viewport percent or as a CSS length.
467
+ *
468
+ * The number keeps its historical clamp — 4000 is not a height. A string is
469
+ * kept only if it reads as a length: the surface resolves it, and a value
470
+ * carrying a brace or a semicolon would not be one. Junk falls back rather
471
+ * than passing through, so the panel is never left with no height at all.
472
+ */
473
+ const asSheetHeight = (
474
+ value: unknown,
475
+ fallback: number | string
476
+ ): number | string => {
477
+ if (typeof value === 'string') {
478
+ const trimmed = value.trim();
479
+ // A plain numeric string is the number, not a length: an editor input hands
480
+ // back "80", and `80` alone is not valid CSS.
481
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
482
+ return asClamped(trimmed, SHEET_HEIGHT_FALLBACK, 20, 100);
483
+ }
484
+
485
+ return isSheetHeightLength(trimmed) ? trimmed : fallback;
486
+ }
487
+
488
+ const numeric = Math.round(Number(value));
489
+ if (!Number.isFinite(numeric)) return fallback;
490
+
491
+ return Math.min(100, Math.max(20, numeric));
492
+ };
493
+
494
+ /**
495
+ * A measurement in px or as a written length. Absent stays absent: a value of
496
+ * `0` and no value at all render differently — only the second says "the
497
+ * designer never asked for this", which is what lets the renderer keep its own
498
+ * default.
499
+ */
500
+ const asChromeLength = (
501
+ value: unknown,
502
+ max = 600
503
+ ): number | string | undefined => {
504
+ if (value === undefined || value === null || value === '') return undefined;
505
+
506
+ if (typeof value === 'string') {
507
+ const trimmed = value.trim();
508
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
509
+ return asOptionalClamped(trimmed, 0, max);
510
+ }
511
+
512
+ return isSheetHeightLength(trimmed) ? trimmed : undefined;
513
+ }
514
+
515
+ return asOptionalClamped(value, 0, max);
516
+ };
517
+
194
518
  const readChrome = (value: unknown, name: string): ModalChrome => {
195
519
  const raw =
196
520
  value && typeof value === 'object' && !Array.isArray(value)
@@ -235,8 +559,20 @@ const readChrome = (value: unknown, name: string): ModalChrome => {
235
559
  : asClamped(raw.width, 80, 10, 100),
236
560
  widthUnit,
237
561
  panelSide: raw.panelSide === 'left' ? 'left' : 'right',
238
- sheetHeight: asClamped(raw.sheetHeight, 60, 20, 100),
239
- lockScroll: asToggle(raw.lockScroll, true)
562
+ sheetHeight: asSheetHeight(raw.sheetHeight, 60),
563
+ lockScroll: asToggle(raw.lockScroll, true),
564
+ // Read, not defaulted: 0 is a real answer ("no padding at all"), which is
565
+ // why absence has to stay distinguishable from it. The upper bound is a
566
+ // typo guard — 2400 is not a padding.
567
+ padding: asOptionalClamped(raw.padding, 0, 200),
568
+ // Absent reads as on: every sheet published before this toggle has a bar.
569
+ showSheetHandle: asToggle(raw.showSheetHandle, true),
570
+ topInset: asChromeLength(raw.topInset),
571
+ closeTop: asChromeLength(raw.closeTop, 200),
572
+ closeRight: asChromeLength(raw.closeRight, 200),
573
+ // A tap target below ~24px is hard to hit and above ~64px is not an icon.
574
+ closeSize: asChromeLength(raw.closeSize, 64),
575
+ stretchContent: asToggle(raw.stretchContent, false)
240
576
  };
241
577
  };
242
578
 
@@ -275,6 +611,10 @@ export const readThemeModal = (value: unknown): ThemeModal | null => {
275
611
  name,
276
612
  slug,
277
613
  presentation: asOption(raw.presentation, MODAL_PRESENTATIONS, 'modal'),
614
+ presentationByBreakpoint: readPresentationByBreakpoint(
615
+ raw.presentationByBreakpoint
616
+ ),
617
+ chromeByBreakpoint: readChromeByBreakpoint(raw.chromeByBreakpoint),
278
618
  chrome: readChrome(raw.chrome, name),
279
619
  autoTrigger: readAutoTrigger(raw.autoTrigger),
280
620
  visibilityRules: