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

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.
package/CHANGELOG.md CHANGED
@@ -1,36 +1,16 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.58-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea06888]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08fe]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f49]
18
- - Updated dependencies [57d7eb305]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a714]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d06]
25
- - Updated dependencies [4ac7b2a1e]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a9631]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b20]
32
- - @akinon/next@2.0.58-rc.0
33
- - @akinon/theme-contract@2.0.58-rc.0
3
+ ## 2.0.58
4
+
5
+ ### Patch Changes
6
+
7
+ - e11783b: ZERO-4790: Draw the modal being composed in the theme editor canvas, from the editor's live entry rather than the saved one — so a modal that has never been saved is visible, and chrome edits show up without a save and a reload
8
+ - b992ca5: ZERO-4790: Read the theme editor's modal registry in the preview, so a trigger pointed at a modal that has not been saved yet actually opens it
9
+ - 08f060d: ZERO-4790: Add composable modals — a global modal host, a block action vocabulary for triggering them, and auto-open with frequency capping
10
+ - 7eb9396: ZERO-4790: Add the modal addressing foundation — modal slug family, overlay bus and theme_modals registry
11
+ - Updated dependencies [08f060d]
12
+ - @akinon/theme-contract@2.0.58
13
+ - @akinon/next@2.0.58
34
14
 
35
15
  ## 2.0.57
36
16
 
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.58-rc.0",
4
+ "version": "2.0.58",
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.58-rc.0",
11
+ "@akinon/next": "2.0.58",
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.58-rc.0",
16
+ "@akinon/theme-contract": "2.0.58",
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.58-rc.0",
23
+ "@akinon/next": "2.0.58",
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",
@@ -9,6 +9,7 @@ import {
9
9
  getResponsiveValue,
10
10
  resolveThemeCssVariables
11
11
  } from '../utils';
12
+ import { useBlockAction } from '../hooks/use-block-action';
12
13
  import { BlockRendererProps } from './block-renderer-registry';
13
14
  import { useThemeSettingsContext } from '../theme-settings-context';
14
15
 
@@ -18,6 +19,7 @@ const ButtonBlock = ({
18
19
  isDesigner = false
19
20
  }: BlockRendererProps) => {
20
21
  const themeSettings = useThemeSettingsContext();
22
+ const action = useBlockAction(block, { currentBreakpoint, isDesigner });
21
23
  const { locale } = useLocalization();
22
24
  const [emailSubscription] = useEmailSubscriptionMutation();
23
25
  const [feedbackModal, setFeedbackModal] = useState({
@@ -386,6 +388,20 @@ const ButtonBlock = ({
386
388
  return;
387
389
  }
388
390
 
391
+ /**
392
+ * An authored action wins over the href.
393
+ *
394
+ * A button that both opens a modal and navigates would race itself, so the
395
+ * action calls `preventDefault` and this returns rather than falling
396
+ * through into the newsletter submit path below. With no action authored
397
+ * the hook is inert and everything published before this existed behaves
398
+ * exactly as it did.
399
+ */
400
+ if (action.isActive) {
401
+ action.onClick?.(e);
402
+ return;
403
+ }
404
+
389
405
  if (tag === 'button' && buttonType === 'submit') {
390
406
  e.preventDefault();
391
407
  }
@@ -566,7 +582,12 @@ const ButtonBlock = ({
566
582
  if (tag === 'button') {
567
583
  return (
568
584
  <>
569
- <button type={buttonType} style={finalStyles} onClick={handleClick}>
585
+ <button
586
+ type={buttonType}
587
+ style={finalStyles}
588
+ onClick={handleClick}
589
+ {...action.ariaProps}
590
+ >
570
591
  {content}
571
592
  </button>
572
593
  {feedbackModalElement}
@@ -582,6 +603,7 @@ const ButtonBlock = ({
582
603
  rel={target === '_blank' ? 'noopener noreferrer' : undefined}
583
604
  style={finalStyles}
584
605
  onClick={handleClick}
606
+ {...action.ariaProps}
585
607
  >
586
608
  {content}
587
609
  </a>
@@ -6,6 +6,7 @@ import { useLocalization } from '@akinon/next/hooks';
6
6
  import { LocaleUrlStrategy } from '@akinon/next/localization';
7
7
  import { urlLocaleMatcherRegex, urlSchemes } from '@akinon/next/utils';
8
8
  import { getResponsiveValue } from '../utils';
9
+ import { useBlockAction } from '../hooks/use-block-action';
9
10
  import { BlockRendererProps } from './block-renderer-registry';
10
11
  import ThemeBlock from '../theme-block';
11
12
 
@@ -20,6 +21,22 @@ const LinkBlock = ({
20
21
  isDesigner = false
21
22
  }: BlockRendererProps) => {
22
23
  const { locale, defaultLocaleValue, localeUrlStrategy } = useLocalization();
24
+ const action = useBlockAction(block, { currentBreakpoint, isDesigner });
25
+
26
+ /**
27
+ * An authored action replaces the navigation rather than joining it: a link
28
+ * that both opens a modal and follows its href would race itself. With no
29
+ * action authored this is exactly the designer-only preventDefault the link
30
+ * always had.
31
+ */
32
+ const handleClick = (event: React.MouseEvent<HTMLElement>) => {
33
+ if (isDesigner) {
34
+ event.preventDefault();
35
+ return;
36
+ }
37
+
38
+ if (action.isActive) action.onClick?.(event);
39
+ };
23
40
 
24
41
  const href = block.properties?.href
25
42
  ? String(getResponsiveValue(block.properties.href, currentBreakpoint, '#'))
@@ -136,11 +153,8 @@ const LinkBlock = ({
136
153
  target={isDesigner ? undefined : target}
137
154
  rel={isDesigner ? undefined : rel}
138
155
  style={inlineStyles}
139
- onClick={(e) => {
140
- if (isDesigner) {
141
- e.preventDefault();
142
- }
143
- }}
156
+ onClick={handleClick}
157
+ {...action.ariaProps}
144
158
  >
145
159
  <div style={{ padding: '20px', color: '#6b7280' }}>
146
160
  Empty link block
@@ -155,11 +169,8 @@ const LinkBlock = ({
155
169
  target={isDesigner ? undefined : target}
156
170
  rel={isDesigner ? undefined : rel}
157
171
  style={inlineStyles}
158
- onClick={(e) => {
159
- if (isDesigner) {
160
- e.preventDefault();
161
- }
162
- }}
172
+ onClick={handleClick}
173
+ {...action.ariaProps}
163
174
  >
164
175
  {block.blocks
165
176
  .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
@@ -8,24 +8,21 @@ import {
8
8
  resolveOption
9
9
  } from '../utils';
10
10
  import { useOverlay } from '../hooks/use-overlay';
11
+ import {
12
+ CentredModalSurface,
13
+ LayeredOverlaySurface,
14
+ OverlayCloseButton,
15
+ OverlayPresentation,
16
+ OVERLAY_PRESENTATIONS,
17
+ clampOverlayNumber as clampNumber
18
+ } from '../overlays/overlay-surface';
11
19
  import ThemeBlock from '../theme-block';
12
20
  import { BlockRendererProps } from './block-renderer-registry';
13
21
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
14
22
 
15
- const clampNumber = (
16
- value: unknown,
17
- fallback: number,
18
- min: number,
19
- max: number
20
- ) => {
21
- const numeric = Math.round(Number(value));
22
- if (!Number.isFinite(numeric)) return fallback;
23
- return Math.min(max, Math.max(min, numeric));
24
- };
25
-
26
- type Presentation = 'modal' | 'drawer' | 'sheet' | 'anchored';
23
+ type Presentation = OverlayPresentation;
27
24
 
28
- const PRESENTATIONS: Presentation[] = ['modal', 'drawer', 'sheet', 'anchored'];
25
+ const PRESENTATIONS = OVERLAY_PRESENTATIONS;
29
26
 
30
27
  /**
31
28
  * Generic overlay surface: a trigger that opens a panel the author fills.
@@ -303,245 +300,78 @@ const PanelBlock = ({
303
300
  }
304
301
 
305
302
  const closeButton = showClose ? (
306
- <button
307
- type="button"
308
- data-size-guide-close=""
309
- {...partAttrs('close')}
310
- aria-label={closeLabel}
303
+ <OverlayCloseButton
304
+ label={closeLabel}
311
305
  onClick={dismiss}
312
- style={{
313
- position: 'absolute',
314
- top: '12px',
315
- right: '12px',
316
- border: 'none',
317
- background: 'transparent',
318
- color: 'inherit',
319
- cursor: 'pointer',
320
- padding: 0
321
- }}
322
- >
323
- <svg
324
- width="18"
325
- height="18"
326
- viewBox="0 0 24 24"
327
- fill="none"
328
- stroke="currentColor"
329
- strokeWidth="1.6"
330
- strokeLinecap="round"
331
- >
332
- <path d="M6 6l12 12M18 6L6 18" />
333
- </svg>
334
- </button>
306
+ markerAttrs={{ 'data-size-guide-close': '' }}
307
+ />
335
308
  ) : null;
336
309
 
337
310
  /**
338
- * The centred modal keeps the shape it published with: ONE node that is both
339
- * the fixed root and the backdrop, carrying `data-part="overlay"`, with the
340
- * panel centred inside it by flex. Refactoring it into the root + backdrop
341
- * sibling pair the other presentations use would move markup live
342
- * storefronts already style, for no gain — so the new modes get their own
343
- * branch instead. `panel-legacy-golden.json` is what holds this to it.
311
+ * Both branches come from `overlays/overlay-surface.tsx`, so this block and
312
+ * the composed modal host draw the same surface. The legacy
313
+ * `data-size-guide-*` / `data-panel-*` markers stay this block's own: live
314
+ * storefronts style them and `panel-legacy-golden.json` pins them byte for
315
+ * byte, which is also what proves the extraction moved nothing.
344
316
  */
317
+ const header = (
318
+ <>
319
+ {closeButton}
320
+ {titleNode}
321
+ </>
322
+ );
323
+
345
324
  const modalOverlay = (
346
- <div
347
- data-size-guide-overlay=""
348
- {...partAttrs('overlay', { open: true })}
349
- onClick={() => {
350
- if (closeOnOverlay) dismiss();
351
- }}
352
- style={{
353
- position: 'fixed',
354
- inset: 0,
355
- zIndex: 1000,
356
- display: 'flex',
357
- alignItems: 'center',
358
- justifyContent: 'center',
359
- padding: '16px',
360
- background: 'rgba(0, 0, 0, 0.5)'
325
+ <CentredModalSurface
326
+ overlayProps={overlay.overlayProps}
327
+ ariaLabel={modalTitle || triggerLabel}
328
+ closeOnOverlay={closeOnOverlay}
329
+ onDismiss={dismiss}
330
+ width={modalWidth}
331
+ anchorAlign={anchorAlign}
332
+ anchorOffset={anchorOffset}
333
+ header={header}
334
+ markers={{
335
+ root: { 'data-size-guide-overlay': '' },
336
+ panel: {
337
+ 'data-size-guide-content': '',
338
+ 'data-size-guide-mode': 'modal'
339
+ }
361
340
  }}
362
341
  >
363
- <div
364
- data-size-guide-content=""
365
- data-size-guide-mode="modal"
366
- {...partAttrs('modal', { open: true })}
367
- role="dialog"
368
- // Carries the panel ref and the `aria-modal` claim, which is now
369
- // made only while this really is the dialog on top. Spread after
370
- // `role` so the attribute order stays exactly where it was.
371
- //
372
- // The primitive asks a panel to carry `tabIndex={-1}` as its focus
373
- // target of last resort; this one cannot be given it without
374
- // moving markup a published storefront already renders. The gap
375
- // only shows in a composition with the close button off AND no
376
- // focusable child, where focus simply stays put — the same place
377
- // it stayed before the migration.
378
- {...overlay.overlayProps}
379
- aria-label={modalTitle || triggerLabel}
380
- // The backdrop closes the modal; clicks on the panel itself must
381
- // not bubble up into that handler.
382
- onClick={(event) => event.stopPropagation()}
383
- style={{
384
- position: 'relative',
385
- width: '100%',
386
- maxWidth: `${modalWidth}px`,
387
- maxHeight: '80vh',
388
- overflow: 'auto',
389
- padding: '24px',
390
- borderRadius: '4px',
391
- background: '#ffffff',
392
- color: '#111827',
393
- textAlign: 'left'
394
- }}
395
- >
396
- {closeButton}
397
- {titleNode}
398
- {renderChildren()}
399
- </div>
400
- </div>
342
+ {renderChildren()}
343
+ </CentredModalSurface>
401
344
  );
402
345
 
403
- const backdrop = showBackdrop ? (
404
- <div
405
- data-panel-backdrop=""
406
- {...partAttrs('overlay', { open: true })}
407
- onClick={() => {
408
- if (closeOnOverlay) dismiss();
409
- }}
410
- style={{
411
- position: 'absolute',
412
- inset: 0,
413
- background: 'rgba(0, 0, 0, 0.5)'
414
- }}
415
- />
416
- ) : null;
417
-
418
- const panelSurface: React.CSSProperties = {
419
- overflow: 'auto',
420
- padding: '24px',
421
- background: '#ffffff',
422
- color: '#111827',
423
- textAlign: 'left',
424
- outline: 'none'
425
- };
426
-
427
- const anchorPlacement: React.CSSProperties =
428
- anchorAlign === 'center'
429
- ? { left: '50%', transform: 'translateX(-50%)' }
430
- : anchorAlign === 'end'
431
- ? { right: 0 }
432
- : { left: 0 };
433
-
434
- const panelGeometry: React.CSSProperties =
435
- presentation === 'drawer'
436
- ? {
437
- position: 'absolute',
438
- top: 0,
439
- bottom: 0,
440
- [panelSide === 'left' ? 'left' : 'right']: 0,
441
- width: `min(${modalWidth}px, 92vw)`,
442
- boxShadow: '0 0 24px rgba(0, 0, 0, 0.2)'
443
- }
444
- : presentation === 'sheet'
445
- ? {
446
- position: 'absolute',
447
- left: 0,
448
- right: 0,
449
- bottom: 0,
450
- width: '100%',
451
- maxHeight: `${sheetHeight}vh`,
452
- boxShadow: '0 -12px 32px rgba(0, 0, 0, 0.18)'
453
- }
454
- : {
455
- // Anchored hangs off the block root rather than a fixed layer, so it
456
- // travels with the trigger the way a dropdown must.
457
- position: 'absolute',
458
- top: `calc(100% + ${anchorOffset}px)`,
459
- zIndex: 1000,
460
- width: `${modalWidth}px`,
461
- maxWidth: '92vw',
462
- maxHeight: '80vh',
463
- borderRadius: '4px',
464
- boxShadow: '0 12px 32px rgba(0, 0, 0, 0.18)',
465
- ...anchorPlacement
466
- };
467
-
468
- const panel = (
469
- <div
470
- data-size-guide-content=""
471
- data-panel-presentation={presentation}
472
- {...partAttrs('modal', { open: true })}
473
- role="dialog"
474
- // Spread after `role` for the same attribute-order reason the modal
475
- // branch documents.
476
- {...overlay.overlayProps}
477
- aria-label={modalTitle || triggerLabel}
478
- // Focus target of last resort, for a panel whose every control the
479
- // designer turned off. The centred modal deliberately does without it:
480
- // adding it there would move published markup.
481
- tabIndex={-1}
482
- onClick={(event) => event.stopPropagation()}
483
- style={{
484
- ...panelSurface,
485
- ...panelGeometry,
486
- // Turn events back on for the panel itself when the see-through root
487
- // below is letting them pass.
488
- pointerEvents: showBackdrop ? undefined : 'auto'
346
+ const layeredOverlay = (
347
+ <LayeredOverlaySurface
348
+ presentation={presentation}
349
+ overlayProps={overlay.overlayProps}
350
+ ariaLabel={modalTitle || triggerLabel}
351
+ showBackdrop={showBackdrop}
352
+ closeOnOverlay={closeOnOverlay}
353
+ onDismiss={dismiss}
354
+ width={modalWidth}
355
+ panelSide={panelSide}
356
+ sheetHeight={sheetHeight}
357
+ anchorAlign={anchorAlign}
358
+ anchorOffset={anchorOffset}
359
+ header={header}
360
+ markers={{
361
+ root: { 'data-panel-root': '' },
362
+ backdrop: { 'data-panel-backdrop': '' },
363
+ backdropRoot: { 'data-panel-backdrop-root': '' },
364
+ panel: {
365
+ 'data-size-guide-content': '',
366
+ 'data-panel-presentation': presentation
367
+ },
368
+ handle: { 'data-panel-handle': '' }
489
369
  }}
490
370
  >
491
- {presentation === 'sheet' && (
492
- <div
493
- data-panel-handle=""
494
- {...partAttrs('handle')}
495
- style={{
496
- width: '40px',
497
- height: '4px',
498
- margin: '0 auto 12px',
499
- borderRadius: '999px',
500
- background: '#d1d5db'
501
- }}
502
- />
503
- )}
504
- {closeButton}
505
- {titleNode}
506
371
  {renderChildren()}
507
- </div>
372
+ </LayeredOverlaySurface>
508
373
  );
509
374
 
510
- /**
511
- * Drawer and sheet live on a fixed layer over the page; anchored lives in
512
- * the block's own flow so it travels with the trigger. With the backdrop off
513
- * that fixed layer would still be an invisible sheet of glass over the whole
514
- * viewport, so events pass through it and the panel turns them back on for
515
- * itself.
516
- */
517
- const layeredOverlay =
518
- presentation === 'anchored' ? (
519
- <>
520
- {showBackdrop && (
521
- <div
522
- data-panel-backdrop-root=""
523
- style={{ position: 'fixed', inset: 0, zIndex: 999 }}
524
- >
525
- {backdrop}
526
- </div>
527
- )}
528
- {panel}
529
- </>
530
- ) : (
531
- <div
532
- data-panel-root=""
533
- style={{
534
- position: 'fixed',
535
- inset: 0,
536
- zIndex: 1000,
537
- pointerEvents: showBackdrop ? undefined : 'none'
538
- }}
539
- >
540
- {backdrop}
541
- {panel}
542
- </div>
543
- );
544
-
545
375
  return (
546
376
  <div
547
377
  data-size-guide=""
@@ -24,7 +24,8 @@ describe('readThemeConfig', () => {
24
24
  })
25
25
  ).toEqual({
26
26
  settings: { mainMenuSticky: true },
27
- dataSources: [{ id: 'ds-1' }]
27
+ dataSources: [{ id: 'ds-1' }],
28
+ modals: []
28
29
  });
29
30
  });
30
31
 
@@ -38,7 +39,8 @@ describe('readThemeConfig', () => {
38
39
  })
39
40
  ).toEqual({
40
41
  settings: { mainMenuSticky: true },
41
- dataSources: [{ id: 'ds-1' }]
42
+ dataSources: [{ id: 'ds-1' }],
43
+ modals: []
42
44
  });
43
45
  });
44
46
 
@@ -52,7 +54,45 @@ describe('readThemeConfig', () => {
52
54
  })
53
55
  ).toEqual({
54
56
  settings: { mainMenuSticky: false },
55
- dataSources: [{ id: 'ds-1' }]
57
+ dataSources: [{ id: 'ds-1' }],
58
+ modals: []
59
+ });
60
+ });
61
+
62
+ it('reads the modal registry alongside the rest', () => {
63
+ expect(
64
+ readThemeConfig({
65
+ attributes: {
66
+ theme_modals: JSON.stringify([
67
+ { id: 'newsletter', name: 'Newsletter', presentation: 'drawer' }
68
+ ])
69
+ }
70
+ })
71
+ ).toEqual({
72
+ settings: null,
73
+ dataSources: [],
74
+ modals: [
75
+ {
76
+ id: 'newsletter',
77
+ name: 'Newsletter',
78
+ slug: 'modal-newsletter',
79
+ presentation: 'drawer',
80
+ chrome: {
81
+ ariaLabel: 'Newsletter',
82
+ showClose: true,
83
+ closeLabel: 'Close',
84
+ showBackdrop: true,
85
+ closeOnBackdrop: true,
86
+ closeOnEscape: true,
87
+ width: 640,
88
+ panelSide: 'right',
89
+ sheetHeight: 60,
90
+ lockScroll: true
91
+ },
92
+ autoTrigger: undefined,
93
+ visibilityRules: undefined
94
+ }
95
+ ]
56
96
  });
57
97
  });
58
98
 
@@ -66,16 +106,21 @@ describe('readThemeConfig', () => {
66
106
  theme_dataSources: JSON.stringify([{ id: 'ds-1' }])
67
107
  }
68
108
  })
69
- ).toEqual({ settings: null, dataSources: [{ id: 'ds-1' }] });
109
+ ).toEqual({ settings: null, dataSources: [{ id: 'ds-1' }], modals: [] });
70
110
 
71
111
  error.mockRestore();
72
112
  });
73
113
 
74
114
  it('answers empty for a brand with no theme at all', () => {
75
- expect(readThemeConfig(null)).toEqual({ settings: null, dataSources: [] });
115
+ expect(readThemeConfig(null)).toEqual({
116
+ settings: null,
117
+ dataSources: [],
118
+ modals: []
119
+ });
76
120
  expect(readThemeConfig({ attributes: {} })).toEqual({
77
121
  settings: null,
78
- dataSources: []
122
+ dataSources: [],
123
+ modals: []
79
124
  });
80
125
  });
81
126
  });
@@ -90,7 +135,8 @@ describe('getThemeConfig', () => {
90
135
 
91
136
  await expect(getThemeConfig()).resolves.toEqual({
92
137
  settings: null,
93
- dataSources: []
138
+ dataSources: [],
139
+ modals: []
94
140
  });
95
141
  });
96
142
  });
@@ -79,6 +79,47 @@ export type PlaceholderSlug =
79
79
  /** Custom pages created from the editor resolve to `page-<slug>` */
80
80
  export const customPageSlug = (slug: string) => `page-${slug}`;
81
81
 
82
+ /**
83
+ * Modals created from the editor resolve to `modal-<id>`.
84
+ *
85
+ * A modal is the one composable surface with no route behind it: the designer
86
+ * creates it, names it, and opens it from anywhere. So its slug is derived from
87
+ * a GENERATED id rather than from the name — renaming "Newsletter" to "Sign up"
88
+ * must not orphan the composition saved under it — and never from a path,
89
+ * because a modal has none.
90
+ */
91
+ export const MODAL_SLUG_PREFIX = 'modal-';
92
+
93
+ /**
94
+ * Ids this slug family accepts. Deliberately a REFUSAL rather than the
95
+ * sanitising the path-derived helper above does.
96
+ *
97
+ * Sanitising maps several inputs onto one output, and for a content key that is
98
+ * not a tidy-up, it is a collision: two modals whose ids differ only by a
99
+ * character the cleaner strips would share one slug and silently overwrite each
100
+ * other's composition — the exact failure the table at the top of this file
101
+ * exists to prevent. The ids are ours (the editor generates them), so anything
102
+ * failing this pattern is a bug upstream and is better refused loudly than
103
+ * rewritten quietly.
104
+ */
105
+ const MODAL_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
106
+
107
+ /** `null` for an id this family cannot address — callers skip that modal. */
108
+ export const modalSlug = (id: string): string | null =>
109
+ MODAL_ID_PATTERN.test(id) ? `${MODAL_SLUG_PREFIX}${id}` : null;
110
+
111
+ export const isModalSlug = (slug: string): boolean =>
112
+ typeof slug === 'string' && slug.startsWith(MODAL_SLUG_PREFIX);
113
+
114
+ /** The inverse of `modalSlug`, for turning a placeholder back into a modal id. */
115
+ export const modalIdFromSlug = (slug: string): string | null => {
116
+ if (!isModalSlug(slug)) return null;
117
+
118
+ const id = slug.slice(MODAL_SLUG_PREFIX.length);
119
+
120
+ return MODAL_ID_PATTERN.test(id) ? id : null;
121
+ };
122
+
82
123
  /**
83
124
  * Slug for a brand page that has no canonical placeholder — every bespoke
84
125
  * content route (about us, campaigns, returns policy…) gets one derived from