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

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,12 +1,14 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.84-beta.0
3
+ ## 2.0.84
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.84-beta.0
9
- - @akinon/theme-contract@2.0.84-beta.0
7
+ - cef43540: ZERO-4848: Add close text and styling options for modals
8
+ - cef43540: Composed modal chrome takes the designer's own styles and a visible close label
9
+ - cef43540: Video player inherits the block's corner radius, so a rounded video block rounds its iframe
10
+ - @akinon/next@2.0.84
11
+ - @akinon/theme-contract@2.0.84
10
12
 
11
13
  ## 2.0.83
12
14
 
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.84-beta.0",
4
+ "version": "2.0.84",
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.84-beta.0",
11
+ "@akinon/next": "2.0.84",
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.84-beta.0",
16
+ "@akinon/theme-contract": "2.0.84",
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.84-beta.0",
23
+ "@akinon/next": "2.0.84",
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",
@@ -13,6 +13,48 @@ const renderBlock = (value: any, properties?: any, styles?: any) =>
13
13
  );
14
14
 
15
15
  describe('VideoBlock', () => {
16
+ /**
17
+ * A designer's Corner Radius lands on the block's `[data-block-id]` box, and
18
+ * the player is its direct child. An iframe paints its own square corners
19
+ * straight over a rounded parent, so the radius was written, saved, shown in
20
+ * the panel and visibly did nothing — the only way to get it was to also
21
+ * find `overflow: hidden` in the All CSS families. Inheriting is what makes
22
+ * the one control the designer reached for enough on its own.
23
+ */
24
+ describe('the corner radius of the player itself', () => {
25
+ it('is inherited by the iframe, so a rounded block rounds the player', () => {
26
+ const { container } = renderBlock(
27
+ 'https://www.youtube.com/watch?v=abc123'
28
+ );
29
+
30
+ expect(
31
+ (container.querySelector('iframe') as HTMLIFrameElement).style
32
+ .borderRadius
33
+ ).toBe('inherit');
34
+ });
35
+
36
+ it('is inherited by a direct video file too', () => {
37
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4');
38
+
39
+ expect(
40
+ (container.querySelector('video') as HTMLVideoElement).style
41
+ .borderRadius
42
+ ).toBe('inherit');
43
+ });
44
+
45
+ it('needs no clipping context, so full screen still escapes the box', () => {
46
+ // `overflow: hidden` on the wrapper would have been the other fix, and
47
+ // it imposes a clipping context on a player that legitimately overflows.
48
+ const { container } = renderBlock(
49
+ 'https://www.youtube.com/watch?v=abc123'
50
+ );
51
+
52
+ expect(
53
+ (container.querySelector('iframe') as HTMLIFrameElement).style.overflow
54
+ ).toBe('');
55
+ });
56
+ });
57
+
16
58
  describe('YouTube URLs -> iframe', () => {
17
59
  it('renders an iframe for a youtube.com watch URL', () => {
18
60
  const { container } = renderBlock(
@@ -208,6 +208,18 @@ const VideoBlock = ({
208
208
  );
209
209
  }
210
210
 
211
+ /**
212
+ * `borderRadius: 'inherit'` on the player, not on the wrapper.
213
+ *
214
+ * A designer's Corner Radius lands on the block's `[data-block-id]` box, and
215
+ * the player is its direct child — an `<iframe>` for YouTube and Vimeo. An
216
+ * iframe paints its own square corners straight over a rounded parent, so
217
+ * the radius was written, saved, shown in the panel and visibly did nothing;
218
+ * the only way to get it was to also find `overflow: hidden` in the All CSS
219
+ * families. Inheriting costs nothing when no radius is set (it resolves to
220
+ * 0) and needs no clipping context, which `overflow: hidden` would impose on
221
+ * a player that legitimately overflows in full screen.
222
+ */
211
223
  if (parsed.kind === 'embed') {
212
224
  return (
213
225
  <iframe
@@ -221,9 +233,15 @@ const VideoBlock = ({
221
233
  width: '100%',
222
234
  height: 'auto',
223
235
  aspectRatio: ratio,
224
- border: 'none'
236
+ border: 'none',
237
+ borderRadius: 'inherit'
238
+ }
239
+ : {
240
+ width: '100%',
241
+ height: '400px',
242
+ border: 'none',
243
+ borderRadius: 'inherit'
225
244
  }
226
- : { width: '100%', height: '400px', border: 'none' }
227
245
  }
228
246
  allow="autoplay; fullscreen; picture-in-picture"
229
247
  allowFullScreen
@@ -246,8 +264,13 @@ const VideoBlock = ({
246
264
  playsInline={properties.playsInline !== false}
247
265
  style={
248
266
  ratio
249
- ? { width: '100%', aspectRatio: ratio, objectFit: 'cover' }
250
- : { width: '100%', height: 'auto' }
267
+ ? {
268
+ width: '100%',
269
+ aspectRatio: ratio,
270
+ objectFit: 'cover',
271
+ borderRadius: 'inherit'
272
+ }
273
+ : { width: '100%', height: 'auto', borderRadius: 'inherit' }
251
274
  }
252
275
  />
253
276
  );
@@ -0,0 +1,168 @@
1
+ import { act, render } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ jest.mock('next/navigation', () => ({
5
+ usePathname: () => '/',
6
+ useSearchParams: () => new URLSearchParams()
7
+ }));
8
+
9
+ jest.mock('next-auth/react', () => ({
10
+ useSession: () => ({ status: 'unauthenticated' })
11
+ }));
12
+
13
+ jest.mock('@akinon/next/hooks/use-localization', () => ({
14
+ useLocalization: () => ({ locale: 'en', currency: 'USD' })
15
+ }));
16
+
17
+ import { __resetEditorBridgeForTests } from '../../editor-bridge';
18
+ import { clearAutoOpenRecord } from '../modal-frequency';
19
+ import { readThemeModals, ThemeModal } from '../modal-registry';
20
+ import ModalPreviewHost from '../modal-preview-host';
21
+ import {
22
+ __resetOverlayBusForTests,
23
+ openModal,
24
+ registerModalHost
25
+ } from '../overlay-bus';
26
+ import ThemeModalMount from '../theme-modal-mount';
27
+ import { __resetEditorModalsForTests } from '../use-editor-modals';
28
+ import { __resetModalPreviewForTests } from '../use-modal-preview';
29
+
30
+ /**
31
+ * The chrome a designer dresses: the panel, the backdrop, the ✕ and its text.
32
+ *
33
+ * Everything these assert used to be unreachable. The panel's radius,
34
+ * background and shadow are INLINE styles on an element no part rule could
35
+ * name — a part selector hangs off a `[data-block-id]` and chrome has none —
36
+ * so the fix had to both invent a selector for it and beat an inline style.
37
+ * That is why every case here checks the `!important`: without it a control
38
+ * would be written, saved, shown in the panel and quietly do nothing, which is
39
+ * strictly worse than having no control at all.
40
+ *
41
+ * Each case runs through BOTH hosts. Nothing else pins that the storefront
42
+ * mount and the canvas preview agree, and that gap has already shipped a bug
43
+ * once (see the comment in `modal-preview-host.tsx`).
44
+ */
45
+
46
+ const EDITOR_ORIGIN = 'http://localhost:5174';
47
+
48
+ const mkModal = (
49
+ overrides: Record<string, unknown> = {},
50
+ chrome: Record<string, unknown> = {}
51
+ ): ThemeModal =>
52
+ readThemeModals([
53
+ { id: 'dressed', name: 'Dressed', chrome, ...overrides }
54
+ ])[0];
55
+
56
+ /** The storefront path: a real trigger opens a saved modal. */
57
+ const openOnStorefront = (modal: ThemeModal) => {
58
+ render(
59
+ <ThemeModalMount modal={modal}>
60
+ <p>composed content</p>
61
+ </ThemeModalMount>
62
+ );
63
+ act(() => {
64
+ openModal(modal.id);
65
+ });
66
+ };
67
+
68
+ /** The canvas path: the editor holds the modal open over the page. */
69
+ const holdInCanvas = (modal: ThemeModal) => {
70
+ render(<ModalPreviewHost />);
71
+ act(() => {
72
+ window.dispatchEvent(
73
+ new MessageEvent('message', {
74
+ origin: EDITOR_ORIGIN,
75
+ data: {
76
+ type: 'PREVIEW_OPEN_MODAL',
77
+ data: { modalId: modal.id, modal }
78
+ }
79
+ })
80
+ );
81
+ });
82
+ };
83
+
84
+ const HOSTS: Array<[string, (modal: ThemeModal) => void]> = [
85
+ ['the storefront mount', openOnStorefront],
86
+ ['the canvas preview', holdInCanvas]
87
+ ];
88
+
89
+ /** Every rule the surface emitted, as one string. */
90
+ const emittedCss = () =>
91
+ Array.from(document.querySelectorAll('style'))
92
+ .map(tag => tag.innerHTML)
93
+ .join('\n');
94
+
95
+ beforeEach(() => {
96
+ __resetOverlayBusForTests();
97
+ __resetModalPreviewForTests();
98
+ __resetEditorModalsForTests();
99
+ __resetEditorBridgeForTests();
100
+ registerModalHost();
101
+ clearAutoOpenRecord('dressed');
102
+ document.body.style.overflow = '';
103
+ });
104
+
105
+ describe.each(HOSTS)('chrome styles through %s', (_name, open) => {
106
+ it('sends a bare css key to the panel, and wins against its inline default', () => {
107
+ open(mkModal({ styles: { 'border-radius': { desktop: '0px' } } }));
108
+
109
+ const css = emittedCss();
110
+ // Scoped to this modal's panel — not to the overlay root, and not to
111
+ // every modal on the page.
112
+ expect(css).toContain('[data-theme-modal="dressed"]');
113
+ expect(css).toContain('[data-part="modal"]');
114
+ expect(css).toMatch(/border-radius:\s*0px\s*!important/);
115
+ });
116
+
117
+ it('sends a namespaced key to the part it names', () => {
118
+ open(
119
+ mkModal({
120
+ styles: { 'overlay/background-color': { desktop: 'rgb(1, 2, 3)' } }
121
+ })
122
+ );
123
+
124
+ // Note the alpha: the colour pipeline normalises every paint to rgba(),
125
+ // so the assertion pins what actually reaches the sheet.
126
+ expect(emittedCss()).toMatch(
127
+ /\[data-part="overlay"\][\s\S]*background-color:\s*rgba\(1, 2, 3, 1\)\s*!important/
128
+ );
129
+ });
130
+
131
+ it('carries a part state through as its own rule', () => {
132
+ open(mkModal({ styles: { 'close/hover/color': { desktop: 'red' } } }));
133
+
134
+ expect(emittedCss()).toMatch(/\[data-part="close"\][^{]*:hover/);
135
+ });
136
+
137
+ it('emits nothing at all when the designer has styled nothing', () => {
138
+ open(mkModal());
139
+
140
+ // Not an empty <style> either: the legacy golden pins this markup.
141
+ expect(emittedCss()).not.toContain('[data-theme-modal="dressed"]');
142
+ });
143
+
144
+ it('keeps the custom-CSS hatch out of the declaration stream', () => {
145
+ open(
146
+ mkModal({
147
+ styles: { customCss: { desktop: '.promo { color: red; }' } }
148
+ })
149
+ );
150
+
151
+ const css = emittedCss();
152
+ expect(css).toContain('.promo');
153
+ // A multi-line body inside a declaration would produce a broken rule.
154
+ expect(css).not.toMatch(/customCss\s*:/);
155
+ });
156
+ });
157
+
158
+ describe('the ownership guard', () => {
159
+ it('does not dress a panel block nested inside the modal', () => {
160
+ openOnStorefront(
161
+ mkModal({ styles: { 'border-radius': { desktop: '20px' } } })
162
+ );
163
+
164
+ // The guard is what keeps `[data-part="modal"]` from matching a nested
165
+ // block's own part of the same name.
166
+ expect(emittedCss()).toContain(':not(');
167
+ });
168
+ });
@@ -0,0 +1,118 @@
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ jest.mock('next/navigation', () => ({
5
+ usePathname: () => '/',
6
+ useSearchParams: () => new URLSearchParams()
7
+ }));
8
+
9
+ jest.mock('next-auth/react', () => ({
10
+ useSession: () => ({ status: 'unauthenticated' })
11
+ }));
12
+
13
+ jest.mock('@akinon/next/hooks/use-localization', () => ({
14
+ useLocalization: () => ({ locale: 'en', currency: 'USD' })
15
+ }));
16
+
17
+ import { clearAutoOpenRecord } from '../modal-frequency';
18
+ import { readThemeModals, ThemeModal } from '../modal-registry';
19
+ import {
20
+ __resetOverlayBusForTests,
21
+ openModal,
22
+ registerModalHost
23
+ } from '../overlay-bus';
24
+ import ThemeModalMount from '../theme-modal-mount';
25
+ import { __resetModalPreviewForTests } from '../use-modal-preview';
26
+
27
+ /**
28
+ * A visible word beside the ✕.
29
+ *
30
+ * `closeLabel` could never do this: it reaches only `aria-label`, so a designer
31
+ * asking for "✕ Close" got a screen-reader-only string and an icon. No CSS
32
+ * control could supply it either — the `<span>` did not exist. Hence a chrome
33
+ * field rather than a style, and hence the last case here: the button without
34
+ * one has to stay byte-identical, because the legacy size-guide panel renders
35
+ * this same component and live storefronts style its markup.
36
+ */
37
+
38
+ const mkModal = (chrome: Record<string, unknown> = {}): ThemeModal =>
39
+ readThemeModals([{ id: 'newsletter', name: 'Newsletter', chrome }])[0];
40
+
41
+ const openWith = (chrome: Record<string, unknown> = {}) => {
42
+ render(
43
+ <ThemeModalMount modal={mkModal(chrome)}>
44
+ <p>composed content</p>
45
+ </ThemeModalMount>
46
+ );
47
+
48
+ act(() => {
49
+ openModal('newsletter');
50
+ });
51
+
52
+ // The LAST one: a test that opens twice to compare two settings stacks two
53
+ // containers, and the first match would answer for the previous render.
54
+ return Array.from(
55
+ document.querySelectorAll('[data-part="close"]')
56
+ ).pop() as HTMLElement;
57
+ };
58
+
59
+ beforeEach(() => {
60
+ __resetOverlayBusForTests();
61
+ __resetModalPreviewForTests();
62
+ registerModalHost();
63
+ clearAutoOpenRecord('newsletter');
64
+ document.body.style.overflow = '';
65
+ });
66
+
67
+ describe('the text beside the close button', () => {
68
+ it('survives the config parser, and stays absent when never authored', () => {
69
+ expect(mkModal({ closeText: 'Kapat' }).chrome.closeText).toBe('Kapat');
70
+ expect(mkModal().chrome.closeText).toBeUndefined();
71
+ // An empty string is an absence, not "a text that is blank".
72
+ expect(mkModal({ closeText: ' ' }).chrome.closeText).toBeUndefined();
73
+ });
74
+
75
+ it('renders inside the button, as its own styleable part', () => {
76
+ const button = openWith({ closeText: 'Kapat' });
77
+
78
+ expect(screen.getByText('Kapat')).toBeInTheDocument();
79
+ expect(
80
+ button.querySelector('[data-part="close-text"]')?.textContent
81
+ ).toBe('Kapat');
82
+ // Still a real button, so the text is inside it rather than beside it.
83
+ expect(button.tagName).toBe('BUTTON');
84
+ });
85
+
86
+ it('leaves the accessible name to closeLabel, which may differ', () => {
87
+ const button = openWith({
88
+ closeText: 'Şimdi değil',
89
+ closeLabel: 'Close the newsletter dialog'
90
+ });
91
+
92
+ expect(button.getAttribute('aria-label')).toBe(
93
+ 'Close the newsletter dialog'
94
+ );
95
+ });
96
+
97
+ it('puts the text where the designer asked', () => {
98
+ expect(openWith({ closeText: 'Kapat' }).style.flexDirection).toBe('row');
99
+ expect(
100
+ openWith({ closeText: 'Kapat', closeTextSide: 'before' }).style
101
+ .flexDirection
102
+ ).toBe('row-reverse');
103
+ // Only 'before' flips it; anything else is the historical order.
104
+ expect(mkModal({ closeTextSide: 'sideways' }).chrome.closeTextSide).toBe(
105
+ undefined
106
+ );
107
+ });
108
+
109
+ it('changes nothing about the button when there is no text', () => {
110
+ const button = openWith();
111
+
112
+ expect(button.querySelector('[data-part="close-text"]')).toBeNull();
113
+ // The golden fixture pins exactly these, and nothing else.
114
+ expect(button.style.display).toBe('');
115
+ expect(button.style.gap).toBe('');
116
+ expect(button.style.flexDirection).toBe('');
117
+ });
118
+ });
@@ -0,0 +1,117 @@
1
+ import { act, fireEvent, render } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ jest.mock('next/navigation', () => ({
5
+ usePathname: () => '/',
6
+ useSearchParams: () => new URLSearchParams()
7
+ }));
8
+
9
+ jest.mock('next-auth/react', () => ({
10
+ useSession: () => ({ status: 'unauthenticated' })
11
+ }));
12
+
13
+ jest.mock('@akinon/next/hooks/use-localization', () => ({
14
+ useLocalization: () => ({ locale: 'en', currency: 'USD' })
15
+ }));
16
+
17
+ import { __resetEditorBridgeForTests } from '../../editor-bridge';
18
+ import { __resetPlaceholderRegistryForTests } from '../../placeholder-registry';
19
+ import ModalPreviewHost from '../modal-preview-host';
20
+ import { readThemeModals, ThemeModal } from '../modal-registry';
21
+ import { __resetOverlayBusForTests } from '../overlay-bus';
22
+ import { __resetEditorModalsForTests } from '../use-editor-modals';
23
+ import { __resetModalPreviewForTests } from '../use-modal-preview';
24
+
25
+ /**
26
+ * The seam between the two repos, and the one `modal-preview-host.test.tsx`
27
+ * cannot pin: it stands the placeholder wrapper in for a stub, which is
28
+ * precisely the component whose mount ANNOUNCES the modal's slug.
29
+ *
30
+ * That announce is the editor's only channel for learning a modal exists:
31
+ * its left-hand tree lists the regions the frame reported, so a preview host
32
+ * that drew the surface without registering its region would open a modal the
33
+ * designer could look at and never fill. Rendered here with the real wrapper.
34
+ */
35
+
36
+ const EDITOR_ORIGIN = 'http://localhost:5174';
37
+
38
+ const postMessage = jest.fn();
39
+
40
+ const framesOfType = (type: string) =>
41
+ postMessage.mock.calls
42
+ .map(([message]) => message)
43
+ .filter(message => message?.type === type);
44
+
45
+ const mkModal = (): ThemeModal =>
46
+ readThemeModals([{ id: 'newsletter', name: 'Newsletter' }])[0];
47
+
48
+ const hold = (modal: ThemeModal) =>
49
+ act(() => {
50
+ fireEvent(
51
+ window,
52
+ new MessageEvent('message', {
53
+ origin: EDITOR_ORIGIN,
54
+ data: {
55
+ type: 'PREVIEW_OPEN_MODAL',
56
+ data: { modalId: modal.id, modal }
57
+ }
58
+ })
59
+ );
60
+ });
61
+
62
+ describe('the region a held modal announces', () => {
63
+ beforeAll(() => {
64
+ // `postToEditor` drops everything unless the document is framed.
65
+ Object.defineProperty(window, 'top', {
66
+ configurable: true,
67
+ value: { name: 'designer' }
68
+ });
69
+ Object.defineProperty(window, 'parent', {
70
+ configurable: true,
71
+ value: { postMessage }
72
+ });
73
+ });
74
+
75
+ beforeEach(() => {
76
+ jest.useFakeTimers();
77
+ postMessage.mockClear();
78
+ delete (window as { __iframeReadySent?: boolean }).__iframeReadySent;
79
+ __resetModalPreviewForTests();
80
+ __resetEditorModalsForTests();
81
+ __resetOverlayBusForTests();
82
+ __resetEditorBridgeForTests();
83
+ __resetPlaceholderRegistryForTests();
84
+ });
85
+
86
+ afterEach(() => {
87
+ __resetPlaceholderRegistryForTests();
88
+ jest.useRealTimers();
89
+ });
90
+
91
+ it('reports the modal slug to the editor, so its region can be listed', () => {
92
+ render(<ModalPreviewHost />);
93
+
94
+ // Nothing is held yet: no modal region exists to report.
95
+ act(() => {
96
+ jest.advanceTimersByTime(500);
97
+ });
98
+ expect(
99
+ framesOfType('PLACEHOLDERS_AVAILABLE').flatMap(
100
+ frame => frame.data?.placeholders ?? []
101
+ )
102
+ ).not.toContain('modal-newsletter');
103
+
104
+ hold(mkModal());
105
+ act(() => {
106
+ jest.advanceTimersByTime(500);
107
+ });
108
+
109
+ const announced = framesOfType('PLACEHOLDERS_AVAILABLE').flatMap(
110
+ frame => frame.data?.placeholders ?? []
111
+ );
112
+
113
+ // The whole set travels on every frame, never a delta — the editor
114
+ // replaces what it holds.
115
+ expect(announced).toContain('modal-newsletter');
116
+ });
117
+ });
@@ -0,0 +1,95 @@
1
+ import {
2
+ buildCustomCss,
3
+ buildPartCustomCss,
4
+ PART_CUSTOM_CSS_PREFIX
5
+ } from '../utils/custom-css';
6
+ import {
7
+ escapeCssAttributeValue,
8
+ neutralizeStyleTagBreakout
9
+ } from '../utils/css-safety';
10
+ import { generatePartCSS } from '../utils';
11
+ import {
12
+ buildPartScopeSelector,
13
+ buildPartStyleKey,
14
+ isPartStyleKey
15
+ } from '../utils/part-styles';
16
+ import type { ThemeModal } from './modal-registry';
17
+
18
+ /**
19
+ * The designer's styles for a modal's CHROME, as CSS.
20
+ *
21
+ * Chrome is the one surface a part rule could not already reach: every part
22
+ * selector hangs off a `[data-block-id]`, and the panel, the backdrop and the
23
+ * close button belong to no block — they are drawn by `overlay-surface.tsx`
24
+ * from inline styles. Inline styles outrank any stylesheet, which is why the
25
+ * emitter here is `generatePartCSS` and not a hand-rolled one: it is the path
26
+ * that already rewrites every declaration `!important`, so "no radius at all"
27
+ * beats the renderer's historical `4px` instead of being written, saved, shown
28
+ * in the panel and quietly doing nothing.
29
+ *
30
+ * NEVER import this from `modal-registry.ts`. That module is a server read
31
+ * path, policed by `__tests__/server-read-path-client-free.test.ts`, and
32
+ * `../utils` pulls React in.
33
+ */
34
+
35
+ /**
36
+ * The parts a modal's chrome offers. `modal` is the panel itself, which is
37
+ * also what a BARE css key means — see the key grammar on `ThemeModal.styles`.
38
+ */
39
+ export const MODAL_CHROME_PARTS = [
40
+ 'modal',
41
+ 'overlay',
42
+ 'close',
43
+ 'close-text',
44
+ 'handle'
45
+ ] as const;
46
+
47
+ export type ModalChromePart = (typeof MODAL_CHROME_PARTS)[number];
48
+
49
+ export const buildModalChromeCss = (
50
+ modal: Pick<ThemeModal, 'id' | 'styles'>,
51
+ targetBreakpoint?: string
52
+ ): string => {
53
+ const styles = modal.styles;
54
+ if (!styles || typeof styles !== 'object' || Array.isArray(styles)) return '';
55
+
56
+ const entries = Object.entries(styles as Record<string, unknown>);
57
+ if (entries.length === 0) return '';
58
+
59
+ // The modal's own root carries this attribute in both hosts, so one anchor
60
+ // serves the canvas and the storefront identically.
61
+ const anchor = `[data-theme-modal="${escapeCssAttributeValue(modal.id)}"]`;
62
+
63
+ const hatch: Record<string, unknown> = {};
64
+ const parted: Record<string, unknown> = {};
65
+
66
+ entries.forEach(([key, value]) => {
67
+ // Split BEFORE the part emitter sees them: `customCss/close` parses as the
68
+ // part "customCss" with the property "close", and a multi-line CSS body
69
+ // inside a declaration produces a broken rule.
70
+ if (
71
+ key === 'customCss' ||
72
+ key === 'custom-css' ||
73
+ key.startsWith(PART_CUSTOM_CSS_PREFIX)
74
+ ) {
75
+ hatch[key] = value;
76
+ return;
77
+ }
78
+
79
+ // A bare key is the panel. Namespacing it here — rather than emitting the
80
+ // root bucket against the anchor as well — is what makes double
81
+ // application structurally impossible.
82
+ parted[isPartStyleKey(key) ? key : buildPartStyleKey('modal', key)] = value;
83
+ });
84
+
85
+ let css = generatePartCSS(anchor, parted, targetBreakpoint);
86
+
87
+ css += buildCustomCss(
88
+ { customCss: hatch.customCss ?? hatch['custom-css'] },
89
+ buildPartScopeSelector(anchor, 'modal') ?? anchor,
90
+ targetBreakpoint
91
+ );
92
+ css += buildPartCustomCss(hatch, anchor, targetBreakpoint);
93
+
94
+ return neutralizeStyleTagBreakout(css);
95
+ };