@akinon/pz-theme 2.0.58-rc.0 → 2.0.59-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.
@@ -0,0 +1,104 @@
1
+ 'use client';
2
+
3
+ import {
4
+ closeModal,
5
+ closeTopModal,
6
+ openModal,
7
+ toggleModal
8
+ } from './overlay-bus';
9
+
10
+ /**
11
+ * Two ways in for markup React does not own.
12
+ *
13
+ * The block dictionary is not the only thing on a composed page. The `embed`
14
+ * block renders authored HTML, a brand still owns part of its own chrome, and
15
+ * an agency drops a snippet into a campaign banner. None of those can call a
16
+ * hook, and without a way in they simply cannot open a modal — which pushes
17
+ * people back to writing their own overlay, the outcome this whole system
18
+ * exists to avoid.
19
+ *
20
+ * <button data-pz-modal-open="newsletter">Sign up</button>
21
+ * <button data-pz-modal-close="newsletter">No thanks</button>
22
+ *
23
+ * window.pzTheme.openModal('newsletter');
24
+ *
25
+ * The listener is delegated from the document, so markup injected at any time
26
+ * works with no registration step.
27
+ */
28
+
29
+ const ATTRIBUTES = [
30
+ ['data-pz-modal-open', openModal],
31
+ ['data-pz-modal-close', closeModal],
32
+ ['data-pz-modal-toggle', toggleModal]
33
+ ] as const;
34
+
35
+ const handleDocumentClick = (event: MouseEvent) => {
36
+ const start = event.target as Element | null;
37
+ if (!start || typeof start.closest !== 'function') return;
38
+
39
+ for (const [attribute, action] of ATTRIBUTES) {
40
+ const trigger = start.closest(`[${attribute}]`);
41
+ if (!trigger) continue;
42
+
43
+ const id = trigger.getAttribute(attribute);
44
+ if (!id) continue;
45
+
46
+ // A trigger inside an <a> or a <form> must not also navigate or submit.
47
+ event.preventDefault();
48
+ action(id);
49
+
50
+ return;
51
+ }
52
+
53
+ if (start.closest('[data-pz-modal-close-top]')) {
54
+ event.preventDefault();
55
+ closeTopModal();
56
+ }
57
+ };
58
+
59
+ export interface PzThemeWindowApi {
60
+ openModal: typeof openModal;
61
+ closeModal: typeof closeModal;
62
+ toggleModal: typeof toggleModal;
63
+ closeTopModal: typeof closeTopModal;
64
+ }
65
+
66
+ declare global {
67
+ interface Window {
68
+ pzTheme?: PzThemeWindowApi;
69
+ }
70
+ }
71
+
72
+ let installed = false;
73
+
74
+ /**
75
+ * Install both entry points. Idempotent, because the modal host mounts it and
76
+ * a page could legitimately mount more than one host during a transition.
77
+ */
78
+ export const installModalDomBridge = (): (() => void) => {
79
+ if (typeof window === 'undefined' || installed) return () => undefined;
80
+
81
+ installed = true;
82
+ document.addEventListener('click', handleDocumentClick);
83
+
84
+ window.pzTheme = {
85
+ ...(window.pzTheme || {}),
86
+ openModal,
87
+ closeModal,
88
+ toggleModal,
89
+ closeTopModal
90
+ };
91
+
92
+ return () => {
93
+ installed = false;
94
+ document.removeEventListener('click', handleDocumentClick);
95
+ };
96
+ };
97
+
98
+ /** Test-only: forget that the bridge was installed. */
99
+ export const __resetModalDomBridgeForTests = (): void => {
100
+ if (typeof document !== 'undefined') {
101
+ document.removeEventListener('click', handleDocumentClick);
102
+ }
103
+ installed = false;
104
+ };
@@ -0,0 +1,104 @@
1
+ import { ModalFrequency } from './modal-registry';
2
+
3
+ /**
4
+ * How often an auto-opening modal may show itself.
5
+ *
6
+ * This is the difference between a welcome popup and a reason to leave the
7
+ * site. Nothing else in the theme system needs persistent per-visitor state,
8
+ * so it lives here rather than growing a general store.
9
+ *
10
+ * `once-per-session` uses sessionStorage (cleared with the tab, which is what
11
+ * a "visit" means to a shopper); the day and forever caps use localStorage.
12
+ * Both are wrapped: Safari in private mode throws on write, and an exception
13
+ * here would take the whole page down for a marketing banner.
14
+ */
15
+
16
+ const STORAGE_PREFIX = 'pz-theme:modal-seen:';
17
+
18
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
+
20
+ const storageFor = (frequency: ModalFrequency): Storage | null => {
21
+ if (typeof window === 'undefined') return null;
22
+
23
+ try {
24
+ return frequency === 'once-per-session'
25
+ ? window.sessionStorage
26
+ : window.localStorage;
27
+ } catch {
28
+ // Storage disabled entirely (some privacy modes, some embedded webviews).
29
+ return null;
30
+ }
31
+ };
32
+
33
+ const keyFor = (modalId: string) => `${STORAGE_PREFIX}${modalId}`;
34
+
35
+ /**
36
+ * Whether the cap still allows this modal to open.
37
+ *
38
+ * Fails OPEN when storage is unavailable: a shopper whose browser refuses
39
+ * storage should still see the newsletter offer once, and the alternative —
40
+ * suppressing it forever — is a silent regression nobody would trace back to
41
+ * a privacy setting.
42
+ */
43
+ export const canAutoOpen = (
44
+ modalId: string,
45
+ frequency: ModalFrequency
46
+ ): boolean => {
47
+ if (frequency === 'always') return true;
48
+
49
+ const storage = storageFor(frequency);
50
+ if (!storage) return true;
51
+
52
+ let raw: string | null = null;
53
+ try {
54
+ raw = storage.getItem(keyFor(modalId));
55
+ } catch {
56
+ return true;
57
+ }
58
+
59
+ if (!raw) return true;
60
+ if (frequency === 'once-per-session' || frequency === 'once-ever') {
61
+ return false;
62
+ }
63
+
64
+ // once-per-day: a stamp we cannot read is treated as "never shown" rather
65
+ // than as "shown forever", so a corrupted value self-heals on the next view.
66
+ const shownAt = Number(raw);
67
+ if (!Number.isFinite(shownAt)) return true;
68
+
69
+ return Date.now() - shownAt >= DAY_MS;
70
+ };
71
+
72
+ /** Record that the modal auto-opened. Never throws. */
73
+ export const markAutoOpened = (
74
+ modalId: string,
75
+ frequency: ModalFrequency
76
+ ): void => {
77
+ if (frequency === 'always') return;
78
+
79
+ const storage = storageFor(frequency);
80
+ if (!storage) return;
81
+
82
+ try {
83
+ storage.setItem(keyFor(modalId), String(Date.now()));
84
+ } catch {
85
+ // Quota or private mode. The modal simply shows again next time, which is
86
+ // the harmless end of the two possible failures.
87
+ }
88
+ };
89
+
90
+ /** Test-only helper, and what an editor "reset this modal" control would call. */
91
+ export const clearAutoOpenRecord = (modalId: string): void => {
92
+ if (typeof window === 'undefined') return;
93
+
94
+ [
95
+ () => window.sessionStorage.removeItem(keyFor(modalId)),
96
+ () => window.localStorage.removeItem(keyFor(modalId))
97
+ ].forEach((remove) => {
98
+ try {
99
+ remove();
100
+ } catch {
101
+ // Nothing to clear if storage is unavailable.
102
+ }
103
+ });
104
+ };
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ import { installModalDomBridge } from './modal-dom-bridge';
6
+ import { registerModalHost } from './overlay-bus';
7
+
8
+ /**
9
+ * Announces that `<ThemeModals />` is on the page.
10
+ *
11
+ * The host has to be mounted by the brand, in its root layout — one line, but
12
+ * one line sixteen brands each have to add. Without this, forgetting it
13
+ * produces the worst possible symptom: a trigger the designer configured
14
+ * correctly, in a theme that saved correctly, that does nothing at all when
15
+ * clicked, with no error anywhere to suggest why.
16
+ *
17
+ * With it, `openModal` can say the actual sentence.
18
+ */
19
+ export const ModalHostBeacon = () => {
20
+ useEffect(() => {
21
+ const unregister = registerModalHost();
22
+ // The `data-pz-modal-open` / `window.pzTheme` entry points live with the
23
+ // host: they can only work when there is something to open.
24
+ const uninstall = installModalDomBridge();
25
+
26
+ return () => {
27
+ unregister();
28
+ uninstall();
29
+ };
30
+ }, []);
31
+
32
+ return null;
33
+ };
34
+
35
+ export default ModalHostBeacon;
@@ -0,0 +1,159 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import ThemePlaceholderWrapper from '../theme-placeholder-wrapper';
6
+ import {
7
+ CentredModalSurface,
8
+ LayeredOverlaySurface,
9
+ OverlayCloseButton,
10
+ OverlayHandleProps
11
+ } from './overlay-surface';
12
+ import ThemeModalMount from './theme-modal-mount';
13
+ import { useEditorModals } from './use-editor-modals';
14
+ import { useHeldModal } from './use-modal-preview';
15
+
16
+ interface ModalPreviewHostProps {
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ dataSources?: any[];
19
+ themeSettings?: Record<string, unknown> | null;
20
+ pageContext?: Record<string, unknown> | null;
21
+ }
22
+
23
+ /**
24
+ * Nothing to grab and nothing to claim.
25
+ *
26
+ * The canvas is a workspace, not a shopper's viewport: no focus trap into the
27
+ * page being styled, no `aria-modal` hiding the rest of the document from the
28
+ * accessibility tree, no scroll lock, no Escape. Passing inert props rather
29
+ * than calling `useOverlay` with a flag says that plainly — this surface never
30
+ * takes anything over.
31
+ */
32
+ const INERT_OVERLAY: OverlayHandleProps = {
33
+ ref: undefined,
34
+ 'aria-modal': undefined
35
+ };
36
+
37
+ /**
38
+ * The modal a designer is composing, drawn in the canvas.
39
+ *
40
+ * This is the ONLY renderer for an editor-held modal, and it draws from the
41
+ * entry the editor pushes rather than from `theme-config`. That single choice
42
+ * is what makes the three cases behave the same:
43
+ *
44
+ * - a modal created seconds ago that the server has never heard of,
45
+ * - a saved modal whose chrome is being changed right now,
46
+ * - a saved modal being filled with blocks.
47
+ *
48
+ * All three need a surface on screen before anything is saved. A
49
+ * server-rendered surface could only ever manage the third, and only after a
50
+ * save and a reload.
51
+ *
52
+ * The composition inside comes from an ordinary `ThemePlaceholderWrapper`, the
53
+ * same one every other editable region on the page uses, so the modal's
54
+ * interior registers itself, accepts drops and answers selection with no
55
+ * special case anywhere in the editor. It starts empty on purpose: on
56
+ * `LOAD_THEME`/`UPDATE_THEME` the wrapper replaces its sections wholesale from
57
+ * the editor's copy, which is the authority while editing.
58
+ */
59
+ export const ModalPreviewHost = ({
60
+ dataSources,
61
+ themeSettings = null,
62
+ pageContext = null
63
+ }: ModalPreviewHostProps) => {
64
+ const modal = useHeldModal();
65
+ const editorModals = useEditorModals();
66
+
67
+ const composition = (slug: string) => (
68
+ <ThemePlaceholderWrapper
69
+ slug={slug}
70
+ initialSections={[]}
71
+ initialPlaceholderId={slug}
72
+ isDesignMode={false}
73
+ dataSources={dataSources}
74
+ initialThemeSettings={themeSettings}
75
+ initialPageContext={pageContext}
76
+ />
77
+ );
78
+
79
+ /**
80
+ * Every modal the editor knows, mounted the ordinary way.
81
+ *
82
+ * This is what lets a TRIGGER work while composing. The saved mounts can
83
+ * only offer modals the backend has stored, so pressing a trigger in browse
84
+ * mode did nothing at all for a modal created minutes ago — and nothing
85
+ * distinguished that from a broken trigger. These mounts answer the overlay
86
+ * bus exactly as the saved ones do, so an unsaved modal opens, dismisses and
87
+ * traps focus like a real one.
88
+ *
89
+ * The one the editor is holding open stands itself down here and is drawn
90
+ * below instead, as an inert editing surface.
91
+ */
92
+ const triggerable = (
93
+ <>
94
+ {editorModals.map((entry) => (
95
+ <ThemeModalMount key={entry.id} modal={entry} origin="editor">
96
+ {composition(entry.slug)}
97
+ </ThemeModalMount>
98
+ ))}
99
+ </>
100
+ );
101
+
102
+ if (!modal) return triggerable;
103
+
104
+ const { chrome, presentation } = modal;
105
+
106
+ // Every dismissal is inert: the designer has to be able to click the close
107
+ // button and the backdrop they are colouring without the surface vanishing.
108
+ const noop = () => undefined;
109
+
110
+ const header = chrome.showClose ? (
111
+ <OverlayCloseButton label={chrome.closeLabel} onClick={noop} />
112
+ ) : null;
113
+
114
+ const ariaLabel = chrome.ariaLabel || modal.name;
115
+
116
+ const surface =
117
+ presentation === 'modal' ? (
118
+ <CentredModalSurface
119
+ overlayProps={INERT_OVERLAY}
120
+ ariaLabel={ariaLabel}
121
+ closeOnOverlay={false}
122
+ onDismiss={noop}
123
+ width={chrome.width}
124
+ header={header}
125
+ >
126
+ {composition(modal.slug)}
127
+ </CentredModalSurface>
128
+ ) : (
129
+ <LayeredOverlaySurface
130
+ presentation={presentation}
131
+ overlayProps={INERT_OVERLAY}
132
+ ariaLabel={ariaLabel}
133
+ showBackdrop={chrome.showBackdrop}
134
+ closeOnOverlay={false}
135
+ onDismiss={noop}
136
+ width={chrome.width}
137
+ panelSide={chrome.panelSide}
138
+ sheetHeight={chrome.sheetHeight}
139
+ header={header}
140
+ >
141
+ {composition(modal.slug)}
142
+ </LayeredOverlaySurface>
143
+ );
144
+
145
+ return (
146
+ <>
147
+ {triggerable}
148
+ <div
149
+ id={`theme-modal-${modal.id}`}
150
+ data-theme-modal={modal.id}
151
+ data-theme-modal-state="preview"
152
+ >
153
+ {surface}
154
+ </div>
155
+ </>
156
+ );
157
+ };
158
+
159
+ export default ModalPreviewHost;
@@ -0,0 +1,289 @@
1
+ import { modalSlug } from '../chrome/placeholder-slugs';
2
+ import { devWarn } from '../utils/dev-warn';
3
+ import type { VisibilityRules } from '../utils/visibility-rules';
4
+
5
+ /**
6
+ * The modal registry: what modals this theme has, read off `theme-config`.
7
+ *
8
+ * A modal's CONTENT is an ordinary composition saved under its own slug, so
9
+ * nothing about the block tree lives here. This is only the index — the part
10
+ * the page needs before it can decide which modals to fetch at all.
11
+ */
12
+
13
+ export type ModalPresentation = 'modal' | 'drawer' | 'sheet';
14
+
15
+ export const MODAL_PRESENTATIONS: ModalPresentation[] = [
16
+ 'modal',
17
+ 'drawer',
18
+ 'sheet'
19
+ ];
20
+
21
+ export type ModalAutoTriggerEvent =
22
+ | 'none'
23
+ | 'page-load'
24
+ | 'delay'
25
+ | 'scroll-depth'
26
+ | 'exit-intent'
27
+ | 'idle'
28
+ | 'element-visible';
29
+
30
+ /**
31
+ * How often an auto-triggered modal may show itself. Without this a "welcome"
32
+ * popup reappears on every navigation, which is the single most common way
33
+ * this feature gets switched back off after launch.
34
+ */
35
+ export type ModalFrequency =
36
+ | 'always'
37
+ | 'once-per-session'
38
+ | 'once-per-day'
39
+ | 'once-ever';
40
+
41
+ export interface ModalAutoTrigger {
42
+ event: ModalAutoTriggerEvent;
43
+ /** `delay` and `idle`, in milliseconds. */
44
+ delayMs?: number;
45
+ /** `scroll-depth`, as a percentage of scrollable height. */
46
+ scrollPercent?: number;
47
+ /** `element-visible`, an authored `htmlId` elsewhere on the page. */
48
+ anchorId?: string;
49
+ frequency: ModalFrequency;
50
+ }
51
+
52
+ /**
53
+ * The frame around the authored content: what the surface does, not what is
54
+ * in it. Kept on the registry rather than inside the composition because the
55
+ * host needs it before it renders anything, and because a designer changing
56
+ * "close on backdrop" should not be editing a block.
57
+ */
58
+ export interface ModalChrome {
59
+ /**
60
+ * The dialog's accessible name. Defaults to the modal's own name — a dialog
61
+ * with no name is announced as just "dialog", which tells a screen reader
62
+ * user nothing about what just took over their page.
63
+ */
64
+ ariaLabel?: string;
65
+ showClose: boolean;
66
+ closeLabel: string;
67
+ showBackdrop: boolean;
68
+ closeOnBackdrop: boolean;
69
+ closeOnEscape: boolean;
70
+ /** Drawer/anchored width, and the centred modal's max width, in px. */
71
+ width: number;
72
+ panelSide: 'left' | 'right';
73
+ /** Sheet height as a percentage of the viewport. */
74
+ sheetHeight: number;
75
+ lockScroll: boolean;
76
+ }
77
+
78
+ export interface ThemeModal {
79
+ /** Generated, stable, and what every trigger points at. Never the name. */
80
+ id: string;
81
+ /** Designer-facing label. Free to change without touching saved content. */
82
+ name: string;
83
+ /** `modal-<id>`, the widget slug the composition is saved under. */
84
+ slug: string;
85
+ presentation: ModalPresentation;
86
+ chrome: ModalChrome;
87
+ autoTrigger?: ModalAutoTrigger;
88
+ /**
89
+ * Where this modal EXISTS — the same rule object sections and blocks already
90
+ * use, so route, auth state, locale, currency, breakpoint and page-data
91
+ * conditions all work with no second condition language to learn or maintain.
92
+ */
93
+ visibilityRules?: VisibilityRules;
94
+ }
95
+
96
+ const asString = (value: unknown): string =>
97
+ typeof value === 'string' ? value.trim() : '';
98
+
99
+ const asOption = <T extends string>(
100
+ value: unknown,
101
+ allowed: readonly T[],
102
+ fallback: T
103
+ ): T => (allowed.includes(value as T) ? (value as T) : fallback);
104
+
105
+ const asPositiveNumber = (value: unknown): number | undefined => {
106
+ const numeric = Number(value);
107
+
108
+ return Number.isFinite(numeric) && numeric >= 0 ? numeric : undefined;
109
+ };
110
+
111
+ const AUTO_TRIGGER_EVENTS: ModalAutoTriggerEvent[] = [
112
+ 'none',
113
+ 'page-load',
114
+ 'delay',
115
+ 'scroll-depth',
116
+ 'exit-intent',
117
+ 'idle',
118
+ 'element-visible'
119
+ ];
120
+
121
+ const FREQUENCIES: ModalFrequency[] = [
122
+ 'always',
123
+ 'once-per-session',
124
+ 'once-per-day',
125
+ 'once-ever'
126
+ ];
127
+
128
+ const readAutoTrigger = (value: unknown): ModalAutoTrigger | undefined => {
129
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
130
+ return undefined;
131
+ }
132
+
133
+ const raw = value as Record<string, unknown>;
134
+ const event = asOption(raw.event, AUTO_TRIGGER_EVENTS, 'none');
135
+
136
+ if (event === 'none') return undefined;
137
+
138
+ return {
139
+ event,
140
+ delayMs: asPositiveNumber(raw.delayMs),
141
+ scrollPercent: asPositiveNumber(raw.scrollPercent),
142
+ anchorId: asString(raw.anchorId) || undefined,
143
+ // Defaults to the safe end: an auto-opening modal that has not been told
144
+ // how often it may appear should not appear on every page view.
145
+ frequency: asOption(raw.frequency, FREQUENCIES, 'once-per-session')
146
+ };
147
+ };
148
+
149
+ const asToggle = (value: unknown, fallback: boolean): boolean =>
150
+ typeof value === 'boolean' ? value : fallback;
151
+
152
+ const asClamped = (
153
+ value: unknown,
154
+ fallback: number,
155
+ min: number,
156
+ max: number
157
+ ): number => {
158
+ const numeric = Math.round(Number(value));
159
+ if (!Number.isFinite(numeric)) return fallback;
160
+
161
+ return Math.min(max, Math.max(min, numeric));
162
+ };
163
+
164
+ const readChrome = (value: unknown, name: string): ModalChrome => {
165
+ const raw =
166
+ value && typeof value === 'object' && !Array.isArray(value)
167
+ ? (value as Record<string, unknown>)
168
+ : {};
169
+
170
+ const showClose = asToggle(raw.showClose, true);
171
+ const closeOnBackdrop = asToggle(raw.closeOnBackdrop, true);
172
+ const closeOnEscape = asToggle(raw.closeOnEscape, true);
173
+
174
+ return {
175
+ ariaLabel: asString(raw.ariaLabel) || name,
176
+ /**
177
+ * Forced back on when nothing else could dismiss the modal. A shopper
178
+ * sealed inside an overlay has lost the page, and this is the one setting
179
+ * combination where honouring every switch as authored produces exactly
180
+ * that — so the close button comes back rather than the designer finding
181
+ * out from a support ticket.
182
+ */
183
+ showClose: showClose || (!closeOnBackdrop && !closeOnEscape),
184
+ closeLabel: asString(raw.closeLabel) || 'Close',
185
+ showBackdrop: asToggle(raw.showBackdrop, true),
186
+ closeOnBackdrop,
187
+ closeOnEscape,
188
+ width: asClamped(raw.width, 640, 240, 1600),
189
+ panelSide: raw.panelSide === 'left' ? 'left' : 'right',
190
+ sheetHeight: asClamped(raw.sheetHeight, 60, 20, 100),
191
+ lockScroll: asToggle(raw.lockScroll, true)
192
+ };
193
+ };
194
+
195
+ /**
196
+ * Normalise ONE registry entry.
197
+ *
198
+ * Exported because the editor pushes a live entry over postMessage while a
199
+ * designer is composing a modal, and that entry has to survive exactly the
200
+ * same defaulting and rejection as one read off `theme-config` — otherwise the
201
+ * canvas would render a modal the published page never could.
202
+ */
203
+ export const readThemeModal = (value: unknown): ThemeModal | null => {
204
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
205
+
206
+ const raw = value as Record<string, unknown>;
207
+ const id = asString(raw.id);
208
+ const slug = modalSlug(id);
209
+
210
+ // An id this slug family cannot address has no composition to fetch, so the
211
+ // entry is dropped rather than rendered as an empty overlay a trigger can
212
+ // "open" to nothing. Named, because it means the editor wrote a bad id.
213
+ if (!slug) {
214
+ if (id) {
215
+ devWarn(
216
+ 'modalRegistry',
217
+ `Modal id "${id}" is not addressable (expected lowercase letters, digits and hyphens); the modal is ignored`
218
+ );
219
+ }
220
+ return null;
221
+ }
222
+
223
+ const name = asString(raw.name) || id;
224
+
225
+ return {
226
+ id,
227
+ name,
228
+ slug,
229
+ presentation: asOption(raw.presentation, MODAL_PRESENTATIONS, 'modal'),
230
+ chrome: readChrome(raw.chrome, name),
231
+ autoTrigger: readAutoTrigger(raw.autoTrigger),
232
+ visibilityRules:
233
+ raw.visibilityRules && typeof raw.visibilityRules === 'object'
234
+ ? (raw.visibilityRules as VisibilityRules)
235
+ : undefined
236
+ };
237
+ };
238
+
239
+ /**
240
+ * Parse the `theme_modals` attribute into the registry.
241
+ *
242
+ * Accepts the array itself or the JSON string the editor writes, matching how
243
+ * every other `theme_*` attribute is read. Malformed entries are dropped one by
244
+ * one: one bad modal must not cost the page the rest of them.
245
+ *
246
+ * Duplicate ids collapse to the first, because two entries claiming one id
247
+ * claim one slug, and letting both through would mean two surfaces racing to
248
+ * render the same composition.
249
+ */
250
+ export const readThemeModals = (value: unknown): ThemeModal[] => {
251
+ let parsed: unknown = value;
252
+
253
+ if (typeof parsed === 'string') {
254
+ if (!parsed.trim()) return [];
255
+
256
+ try {
257
+ parsed = JSON.parse(parsed);
258
+ } catch {
259
+ devWarn(
260
+ 'modalRegistry',
261
+ 'theme_modals is not valid JSON; the theme renders with no modals'
262
+ );
263
+ return [];
264
+ }
265
+ }
266
+
267
+ if (!Array.isArray(parsed)) return [];
268
+
269
+ const seen = new Set<string>();
270
+ const modals: ThemeModal[] = [];
271
+
272
+ parsed.forEach((entry) => {
273
+ const modal = readThemeModal(entry);
274
+ if (!modal) return;
275
+
276
+ if (seen.has(modal.id)) {
277
+ devWarn(
278
+ 'modalRegistry',
279
+ `Duplicate modal id "${modal.id}"; only the first entry is used`
280
+ );
281
+ return;
282
+ }
283
+
284
+ seen.add(modal.id);
285
+ modals.push(modal);
286
+ });
287
+
288
+ return modals;
289
+ };