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

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,351 @@
1
+ /**
2
+ * Every scoped `<style>` a block writes reaches the document byte for byte.
3
+ *
4
+ * Each of these was a text child, `<style>{css}</style>`. React 18's server
5
+ * renderer escapes text children and `<style>` is raw text, so every quoted
6
+ * attribute selector below was served as `[data-…=&quot;…&quot;]` and dropped:
7
+ * grid column counts, icon sizes, the collapsible clip and the announcement
8
+ * rotation were all missing from first paint until the client re-rendered.
9
+ *
10
+ * These tests run React 19, which writes `<style>` text verbatim, so the mocked
11
+ * runtime puts React 18's escaping back (see `jest/react-18-style-text.ts`).
12
+ * Lottie's loading keyframes are the one text-child `<style>` left: static CSS
13
+ * with no character React escapes.
14
+ */
15
+ import React from 'react';
16
+ import { renderToString } from 'react-dom/server';
17
+
18
+ jest.mock('react/jsx-runtime', () =>
19
+ require('../../../jest/react-18-style-text').jsxRuntime
20
+ );
21
+
22
+ jest.mock('../../theme-block', () => ({
23
+ __esModule: true,
24
+ default: ({ block }: { block: { id: string } }) => (
25
+ <div data-testid="tb" data-id={block.id} />
26
+ )
27
+ }));
28
+
29
+ /**
30
+ * The jest moduleNameMapper folds every `@akinon/next/data/client/*` subpath
31
+ * onto one module, so this one factory serves the address, wishlist, misc and
32
+ * product hooks alike. Hooks not named here keep the generic mock's answers.
33
+ */
34
+ function mockDataClient() {
35
+ const generic = jest.requireActual('@akinon/next/data/client/misc');
36
+ const overrides: Record<string, unknown> = {
37
+ useGetWidgetQuery: () => ({
38
+ data: {
39
+ products: [
40
+ {
41
+ pk: 57,
42
+ name: 'Wool Coat',
43
+ absolute_url: '/wool-coat',
44
+ price: '4999.00',
45
+ currency_type: 'TRY'
46
+ }
47
+ ]
48
+ },
49
+ isFetching: false
50
+ }),
51
+ useGetStoresQuery: () => ({
52
+ data: {
53
+ results: [
54
+ {
55
+ pk: 1,
56
+ name: 'Kanyon AVM',
57
+ address: 'Büyükdere Cad. No:185',
58
+ phone_number: '+902123531500',
59
+ latitude: '41.0678',
60
+ longitude: '29.0092',
61
+ city: { name: 'İstanbul' }
62
+ }
63
+ ]
64
+ },
65
+ isLoading: false
66
+ })
67
+ };
68
+
69
+ return new Proxy(generic, {
70
+ get: (target, prop) =>
71
+ typeof prop === 'string' && prop in overrides
72
+ ? overrides[prop]
73
+ : target[prop]
74
+ });
75
+ }
76
+
77
+ jest.mock('@akinon/next/data/client/misc', mockDataClient);
78
+
79
+ jest.mock('@akinon/next/redux/hooks', () => ({
80
+ __esModule: true,
81
+ useAppDispatch: () => () => undefined,
82
+ useAppSelector: () => undefined
83
+ }));
84
+
85
+ import AnnouncementBarBlock from '../announcement-bar-block';
86
+ import CollapsibleBlock from '../collapsible-block';
87
+ import FavouritesGridBlock from '../favourites-grid-block';
88
+ import IconButtonBlock from '../icon-button-block';
89
+ import IconGroupBlock from '../icon-group-block';
90
+ import ProductGalleryBlock from '../product-gallery-block';
91
+ import ProductGridBlock from '../product-grid-block';
92
+ import ProductRecommendationsBlock from '../product-recommendations-block';
93
+ import StoreLocatorBlock from '../store-locator-block';
94
+ import { ThemePageContextProvider } from '../../theme-page-context';
95
+
96
+ const block = (
97
+ id: string,
98
+ type: string,
99
+ properties: Record<string, unknown> = {},
100
+ blocks: unknown[] = [],
101
+ over: Record<string, unknown> = {}
102
+ ): any => ({
103
+ id,
104
+ type,
105
+ label: type,
106
+ value: '',
107
+ properties,
108
+ styles: {},
109
+ order: 0,
110
+ hidden: false,
111
+ blocks,
112
+ ...over
113
+ });
114
+
115
+ const base = {
116
+ placeholderId: 'p',
117
+ sectionId: 's',
118
+ isDesigner: false,
119
+ currentBreakpoint: 'desktop'
120
+ };
121
+
122
+ /** A child marked as the row layout, which is what turns custom rows on. */
123
+ const rowTemplate = block(
124
+ 'row-layout',
125
+ 'group',
126
+ { role: { desktop: 'item-template' } },
127
+ [block('row-text', 'text')]
128
+ );
129
+
130
+ const iconButton = (id: string) =>
131
+ block(id, 'icon-button', {
132
+ icon: { desktop: '<svg viewBox="0 0 24 24"><rect /></svg>' },
133
+ iconSize: { desktop: '32' }
134
+ });
135
+
136
+ const iconGroup = (id: string) =>
137
+ block(id, 'icon-group', {}, [block('visa', 'icon'), block('troy', 'icon')], {
138
+ styles: { size: { desktop: '24' } }
139
+ });
140
+
141
+ const withPage = (value: Record<string, unknown> | null, element: React.ReactElement) => (
142
+ <ThemePageContextProvider value={value}>{element}</ThemePageContextProvider>
143
+ );
144
+
145
+ /** Every `<style>` body in the server HTML — what the browser parses as CSS. */
146
+ const servedSheets = (html: string): string[] =>
147
+ Array.from(
148
+ html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g),
149
+ (match) => match[1]
150
+ );
151
+
152
+ const cases: Array<[string, () => React.ReactElement, string[]]> = [
153
+ [
154
+ 'announcement bar rotation',
155
+ () => (
156
+ <AnnouncementBarBlock
157
+ {...base}
158
+ block={block(
159
+ 'free-shipping-bar',
160
+ 'announcement-bar',
161
+ { messages: 'Free shipping over 500 TL|30-day returns' },
162
+ [rowTemplate]
163
+ )}
164
+ />
165
+ ),
166
+ [
167
+ '[data-announcement-id="free-shipping-bar"] [data-part="message-list"] > *:not(:nth-child(1))'
168
+ ]
169
+ ],
170
+ [
171
+ 'collapsible clip',
172
+ () => (
173
+ <CollapsibleBlock
174
+ {...base}
175
+ block={block('size-guide', 'collapsible', {}, [
176
+ block('fit-notes', 'text'),
177
+ block('measurements', 'text', {}, [], { order: 1 })
178
+ ])}
179
+ />
180
+ ),
181
+ [
182
+ "@property --pz-collapse-fade{syntax:'<number>';",
183
+ '[data-pz-collapsible="size-guide"] [data-part="clip"][data-state~="open"]'
184
+ ]
185
+ ],
186
+ [
187
+ 'icon button markup size',
188
+ () => <IconButtonBlock {...base} block={iconButton('basket-icon')} />,
189
+ ['[data-icon-button-id="basket-icon"] svg{width:32px;height:32px;}']
190
+ ],
191
+ [
192
+ 'icon group size',
193
+ () => <IconGroupBlock {...base} block={iconGroup('payment-icons')} />,
194
+ [
195
+ '[data-icon-group-id="payment-icons"] svg,[data-icon-group-id="payment-icons"] img{width:24px;height:24px;}'
196
+ ]
197
+ ],
198
+ [
199
+ 'favourites grid columns',
200
+ () => (
201
+ <FavouritesGridBlock
202
+ {...base}
203
+ block={block('saved-for-later', 'favourites-grid', {}, [rowTemplate])}
204
+ />
205
+ ),
206
+ [
207
+ '[data-favourites-grid-id="saved-for-later"]{grid-template-columns:repeat(4,minmax(0,1fr));}'
208
+ ]
209
+ ],
210
+ [
211
+ 'product grid columns',
212
+ () =>
213
+ withPage(
214
+ {
215
+ listing: {
216
+ title: 'Coats',
217
+ path: '/coats',
218
+ products: [
219
+ { pk: 1, name: 'Wool Coat', url: '/wool-coat', image: '/wool.jpg', price: 4999 },
220
+ { pk: 2, name: 'Trench Coat', url: '/trench-coat', image: '/trench.jpg', price: 3999 }
221
+ ],
222
+ facets: [],
223
+ sorters: [],
224
+ pagination: { current_page: 1, count: 2 }
225
+ }
226
+ },
227
+ <ProductGridBlock {...base} block={block('category-listing', 'product-grid')} />
228
+ ),
229
+ [
230
+ '[data-product-grid-id="category-listing"]{grid-template-columns:repeat(4,minmax(0,1fr));}'
231
+ ]
232
+ ],
233
+ [
234
+ 'product gallery grid and zoom hint',
235
+ () =>
236
+ withPage(
237
+ {
238
+ product: {
239
+ pk: 1,
240
+ name: 'Wool Coat',
241
+ images: [
242
+ { url: 'https://cdn.example.com/front.jpg', alt: 'front' },
243
+ { url: 'https://cdn.example.com/back.jpg', alt: 'back' }
244
+ ]
245
+ }
246
+ },
247
+ <ProductGalleryBlock
248
+ {...base}
249
+ block={block('coat-gallery', 'product-gallery', {
250
+ layout: 'grid',
251
+ enableZoom: true,
252
+ zoomHint: 'Hover to zoom'
253
+ })}
254
+ />
255
+ ),
256
+ [
257
+ '[data-product-gallery-id="coat-gallery"] [data-part="grid"]{grid-template-columns:repeat(2,minmax(0,1fr));}',
258
+ '@media (hover:none){[data-product-gallery-id="coat-gallery"] [data-part="zoom-hint"]{display:none;}}'
259
+ ]
260
+ ],
261
+ [
262
+ 'product recommendations card width',
263
+ () =>
264
+ withPage(
265
+ {},
266
+ <ProductRecommendationsBlock
267
+ {...base}
268
+ block={block('complete-the-look', 'product-recommendations', {
269
+ collectionId: 'winter-coats'
270
+ })}
271
+ />
272
+ ),
273
+ [
274
+ '[data-product-recommendations-id="complete-the-look"] [data-product-recommendations-track]{grid-auto-columns:calc('
275
+ ]
276
+ ],
277
+ [
278
+ 'store locator grid columns',
279
+ () =>
280
+ withPage(
281
+ null,
282
+ <StoreLocatorBlock
283
+ {...base}
284
+ block={block('nearby-stores', 'store-locator', {
285
+ columns: 2,
286
+ columnsMobile: 1
287
+ })}
288
+ />
289
+ ),
290
+ [
291
+ '[data-store-locator-id="nearby-stores"]{grid-template-columns:repeat(2,minmax(0,1fr));}'
292
+ ]
293
+ ]
294
+ ];
295
+
296
+ describe('the harness serves <style> text the way React 18 did', () => {
297
+ it('escapes a text child, so a text-child sheet cannot pass below', () => {
298
+ // If the components ever stopped receiving the mocked runtime, every
299
+ // assertion in this file would pass against the old markup without saying
300
+ // so. Required here rather than written as JSX: this file's own JSX binds
301
+ // the runtime before the mock is registered, a component module does not.
302
+ const { jsx } = require('react/jsx-runtime');
303
+ expect(
304
+ renderToString(jsx('style', { children: '[data-x="a"]>b' }))
305
+ ).toBe('<style>[data-x=&quot;a&quot;]&gt;b</style>');
306
+ });
307
+ });
308
+
309
+ describe.each(cases)('%s', (_name, element, expected) => {
310
+ it('is served verbatim', () => {
311
+ const css = servedSheets(renderToString(element())).join('\n');
312
+
313
+ expected.forEach((literal) => expect(css).toContain(literal));
314
+ expect(css).not.toMatch(/&(?:quot|gt|lt|amp|#x27);/);
315
+ });
316
+ });
317
+
318
+ describe('an id that tries to close the tag stays inside the sheet', () => {
319
+ /**
320
+ * Verbatim output is only safe because the breakout is neutralized: these
321
+ * three scope with an id that is attribute-escaped but not reduced to a
322
+ * safe character set, so a crafted id reaches the CSS whole.
323
+ */
324
+ const HOSTILE_ID = 'size-guide"]</style><img src=x onerror=alert(1)>';
325
+
326
+ it.each<[string, () => React.ReactElement]>([
327
+ [
328
+ 'collapsible',
329
+ () => (
330
+ <CollapsibleBlock
331
+ {...base}
332
+ block={block(HOSTILE_ID, 'collapsible', {}, [
333
+ block('fit-notes', 'text')
334
+ ])}
335
+ />
336
+ )
337
+ ],
338
+ ['icon button', () => <IconButtonBlock {...base} block={iconButton(HOSTILE_ID)} />],
339
+ ['icon group', () => <IconGroupBlock {...base} block={iconGroup(HOSTILE_ID)} />]
340
+ ])('%s', (_name, element) => {
341
+ // The collapsible's `<noscript>` copy is a separate raw-text context this
342
+ // change did not touch, so only the sheets outside it are asserted.
343
+ const html = renderToString(element()).replace(
344
+ /<noscript>[\s\S]*?<\/noscript>/g,
345
+ ''
346
+ );
347
+
348
+ expect(html).toContain('<\\/style><img');
349
+ expect(html).not.toMatch(/<\/style><img/i);
350
+ });
351
+ });
@@ -3,6 +3,7 @@
3
3
  import React, { useEffect, useMemo, useState } from 'react';
4
4
  import ThemeBlock, { Block } from '../theme-block';
5
5
  import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { neutralizeStyleTagBreakout } from '../utils/css-safety';
6
7
  import { BlockRendererProps } from './block-renderer-registry';
7
8
  import { readToggle } from './product-block-utils';
8
9
  import {
@@ -318,7 +319,15 @@ const AnnouncementBarBlock = ({
318
319
  width: '100%'
319
320
  }}
320
321
  >
321
- {rotationCss && <style>{rotationCss}</style>}
322
+ {rotationCss && (
323
+ // Verbatim, not a text child: React 18's server renderer would escape
324
+ // the selector's quotes and `>` (see `css-safety.ts`).
325
+ <style
326
+ dangerouslySetInnerHTML={{
327
+ __html: neutralizeStyleTagBreakout(rotationCss)
328
+ }}
329
+ />
330
+ )}
322
331
 
323
332
  <div
324
333
  data-announcement-messages=""
@@ -15,6 +15,7 @@ import { useBlockAction } from '../hooks/use-block-action';
15
15
  import { useFormSubmitState } from '../auth/form-submit-bus';
16
16
  import { BlockRendererProps } from './block-renderer-registry';
17
17
  import { useThemeSettingsContext } from '../theme-settings-context';
18
+ import { loadGoogleFontStylesheet } from '../utils/google-fonts';
18
19
 
19
20
  const ButtonBlock = ({
20
21
  block,
@@ -193,39 +194,10 @@ const ButtonBlock = ({
193
194
  )
194
195
  : 'inherit';
195
196
 
197
+ // Shared with the placeholder and its server-rendered links, so this builds
198
+ // the same href and stands down for a stylesheet the document already has.
196
199
  useEffect(() => {
197
- if (!fontFamily || fontFamily === 'inherit') return;
198
-
199
- const cleanFontFamily = fontFamily.replace(/['"]/g, '').trim();
200
-
201
- const systemFonts = [
202
- 'Arial',
203
- 'Helvetica',
204
- 'Times New Roman',
205
- 'Georgia',
206
- 'Courier New',
207
- 'Verdana'
208
- ];
209
- if (systemFonts.includes(cleanFontFamily)) return;
210
-
211
- const fontParam = `${cleanFontFamily.replace(
212
- /\s+/g,
213
- '+'
214
- )}:wght@100;300;400;500;600;700;800;900`;
215
- const fontUrl = `https://fonts.googleapis.com/css2?family=${fontParam}&display=swap`;
216
-
217
- const linkId = `google-font-${cleanFontFamily
218
- .replace(/\s+/g, '-')
219
- .toLowerCase()}`;
220
- const existingLink = document.getElementById(linkId);
221
- if (existingLink) return;
222
-
223
- const link = document.createElement('link');
224
- link.href = fontUrl;
225
- link.rel = 'stylesheet';
226
- link.id = linkId;
227
-
228
- document.head.appendChild(link);
200
+ loadGoogleFontStylesheet(fontFamily);
229
201
  }, [fontFamily]);
230
202
 
231
203
  const toAlpha = (value: unknown, fallback = 100): number | null => {
@@ -14,7 +14,10 @@ import {
14
14
  MOBILE_MEDIA_QUERY_COMPACT,
15
15
  TABLET_MEDIA_QUERY_COMPACT
16
16
  } from '../utils/breakpoints';
17
- import { escapeCssAttributeValue } from '../utils/css-safety';
17
+ import {
18
+ escapeCssAttributeValue,
19
+ neutralizeStyleTagBreakout
20
+ } from '../utils/css-safety';
18
21
  import { BlockRendererProps } from './block-renderer-registry';
19
22
  import { readToggle } from './product-block-utils';
20
23
  import { renderComposedChildren } from './render-composed-children';
@@ -510,7 +513,14 @@ const CollapsibleBlock: React.FC<BlockRendererProps> = ({
510
513
  : undefined
511
514
  }
512
515
  >
513
- <style>{css}</style>
516
+ {/*
517
+ * Verbatim, not a text child: React 18's server renderer would escape the
518
+ * selectors' quotes and `@property`'s `'<number>'` (see `css-safety.ts`).
519
+ * The id is only attribute-escaped, so the breakout is neutralized here.
520
+ */}
521
+ <style
522
+ dangerouslySetInnerHTML={{ __html: neutralizeStyleTagBreakout(css) }}
523
+ />
514
524
  <noscript dangerouslySetInnerHTML={{ __html: noScriptCss }} />
515
525
 
516
526
  <div
@@ -16,6 +16,7 @@ import {
16
16
  MOBILE_MEDIA_QUERY_COMPACT,
17
17
  TABLET_MEDIA_QUERY_COMPACT
18
18
  } from '../utils/breakpoints';
19
+ import { neutralizeStyleTagBreakout } from '../utils/css-safety';
19
20
  import { normalizeListingProduct } from './listing-context';
20
21
  import {
21
22
  buildCommerceItem,
@@ -406,14 +407,20 @@ const FavouritesGridBlock = ({
406
407
  gap: gridGap || 'var(--favourites-grid-gap, 16px)'
407
408
  }}
408
409
  >
409
- <style>
410
- {`[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
411
- `${MOBILE_MEDIA_QUERY_COMPACT}{[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsMobile},minmax(0,1fr));}}` +
412
- // After the mobile query, and only when authored.
413
- (columnsTablet !== null
414
- ? `${TABLET_MEDIA_QUERY_COMPACT}{[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsTablet},minmax(0,1fr));}}`
415
- : '')}
416
- </style>
410
+ {/* Verbatim, not a text child: React 18's server renderer would escape
411
+ the selector's quotes (see `css-safety.ts`). */}
412
+ <style
413
+ dangerouslySetInnerHTML={{
414
+ __html: neutralizeStyleTagBreakout(
415
+ `[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
416
+ `${MOBILE_MEDIA_QUERY_COMPACT}{[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsMobile},minmax(0,1fr));}}` +
417
+ // After the mobile query, and only when authored.
418
+ (columnsTablet !== null
419
+ ? `${TABLET_MEDIA_QUERY_COMPACT}{[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsTablet},minmax(0,1fr));}}`
420
+ : '')
421
+ )
422
+ }}
423
+ />
417
424
 
418
425
  {customCards ??
419
426
  favourites.map((favourite, index) => {
@@ -3,7 +3,10 @@
3
3
  import React from 'react';
4
4
  import { Image } from '@akinon/next/components/image';
5
5
  import { getResponsiveValue } from '../utils';
6
- import { escapeCssAttributeValue } from '../utils/css-safety';
6
+ import {
7
+ escapeCssAttributeValue,
8
+ neutralizeStyleTagBreakout
9
+ } from '../utils/css-safety';
7
10
  import { useThemeSettingsContext } from '../theme-settings-context';
8
11
  import { BlockRendererProps } from './block-renderer-registry';
9
12
  import { DesignerPlaceholder } from './product-block-utils';
@@ -158,7 +161,13 @@ const IconButtonBlock = ({
158
161
  {...labelAttrs}
159
162
  style={{ display: 'inline-flex' }}
160
163
  >
161
- <style>{markupCss}</style>
164
+ {/* Verbatim, not a text child: see `css-safety.ts`. The id is only
165
+ attribute-escaped, so the breakout is neutralized here. */}
166
+ <style
167
+ dangerouslySetInnerHTML={{
168
+ __html: neutralizeStyleTagBreakout(markupCss)
169
+ }}
170
+ />
162
171
  <span dangerouslySetInnerHTML={{ __html: source.markup }} />
163
172
  </span>
164
173
  );
@@ -2,7 +2,10 @@
2
2
 
3
3
  import React from 'react';
4
4
  import { getResponsiveValue } from '../utils';
5
- import { escapeCssAttributeValue } from '../utils/css-safety';
5
+ import {
6
+ escapeCssAttributeValue,
7
+ neutralizeStyleTagBreakout
8
+ } from '../utils/css-safety';
6
9
  import { BlockRendererProps } from './block-renderer-registry';
7
10
  import { DesignerPlaceholder } from './product-block-utils';
8
11
  import { renderComposedChildren } from './render-composed-children';
@@ -110,7 +113,11 @@ const IconGroupBlock = ({
110
113
  ...(tint ? { color: tint } : null)
111
114
  }}
112
115
  >
113
- <style>{sizeCss}</style>
116
+ {/* Verbatim, not a text child: see `css-safety.ts`. The id is only
117
+ attribute-escaped, so the breakout is neutralized here. */}
118
+ <style
119
+ dangerouslySetInnerHTML={{ __html: neutralizeStyleTagBreakout(sizeCss) }}
120
+ />
114
121
  {children}
115
122
  </span>
116
123
  );
@@ -3,6 +3,7 @@
3
3
  import React, { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useOverlay } from '../hooks/use-overlay';
5
5
  import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { neutralizeStyleTagBreakout } from '../utils/css-safety';
6
7
  import { BlockRendererProps } from './block-renderer-registry';
7
8
  import { hasItemTemplate, ItemTemplateList } from './item-template';
8
9
  import {
@@ -722,9 +723,15 @@ const ProductGalleryBlock = ({
722
723
  const hintNeedsHoverGate = zoomHint !== '' && enableZoom && !enableViewer;
723
724
 
724
725
  const hintHoverGateNode = hintNeedsHoverGate ? (
725
- <style>
726
- {`@media (hover:none){[data-product-gallery-id="${galleryId}"] [data-part="zoom-hint"]{display:none;}}`}
727
- </style>
726
+ // Verbatim, not a text child: React 18's server renderer would escape the
727
+ // selector's quotes (see `css-safety.ts`).
728
+ <style
729
+ dangerouslySetInnerHTML={{
730
+ __html: neutralizeStyleTagBreakout(
731
+ `@media (hover:none){[data-product-gallery-id="${galleryId}"] [data-part="zoom-hint"]{display:none;}}`
732
+ )
733
+ }}
734
+ />
728
735
  ) : null;
729
736
 
730
737
  const hintNode =
@@ -1427,10 +1434,15 @@ const ProductGalleryBlock = ({
1427
1434
  width: '100%'
1428
1435
  }}
1429
1436
  >
1430
- <style>
1431
- {`[data-product-gallery-id="${galleryId}"] [data-part="grid"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
1432
- `@media (max-width:1023px){[data-product-gallery-id="${galleryId}"] [data-part="grid"]{grid-template-columns:repeat(${gridColumnsMobile},minmax(0,1fr));}}`}
1433
- </style>
1437
+ {/* Verbatim, not a text child: see `css-safety.ts`. */}
1438
+ <style
1439
+ dangerouslySetInnerHTML={{
1440
+ __html: neutralizeStyleTagBreakout(
1441
+ `[data-product-gallery-id="${galleryId}"] [data-part="grid"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
1442
+ `@media (max-width:1023px){[data-product-gallery-id="${galleryId}"] [data-part="grid"]{grid-template-columns:repeat(${gridColumnsMobile},minmax(0,1fr));}}`
1443
+ )
1444
+ }}
1445
+ />
1434
1446
 
1435
1447
  {hintHoverGateNode}
1436
1448