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

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,16 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.82-beta.0
3
+ ## 2.0.82
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.82-beta.0
9
- - @akinon/theme-contract@2.0.82-beta.0
7
+ - d4a89926: ZERO-4816: enhance PostsSliderSection to utilize custom arrow blocks
8
+ - e6ba4d0a: ZERO-4816: add an optional quick view panel to the product slider sections
9
+ - f358b65b: Line breaks that apply only at chosen widths: a `<br data-pz-break="mobile tablet">` in a text block breaks the line at 1023px and under and disappears on the desktop, so one authored sentence can be two lines narrow and one line wide.
10
+ - b0722874: ZERO-4816: run a slider flush to its edges and offset the track only at the start position
11
+ - c41ea578: ZERO-4816: play YouTube Shorts and direct video files whose address carries a query
12
+ - @akinon/next@2.0.82
13
+ - @akinon/theme-contract@2.0.82
10
14
 
11
15
  ## 2.0.81
12
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.82-beta.0",
4
+ "version": "2.0.82",
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.82-beta.0",
11
+ "@akinon/next": "2.0.82",
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.82-beta.0",
16
+ "@akinon/theme-contract": "2.0.82",
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.82-beta.0",
23
+ "@akinon/next": "2.0.82",
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",
@@ -182,3 +182,90 @@ describe('LinkBlock', () => {
182
182
  expect(anchor(container)).toHaveAttribute('href', '#');
183
183
  });
184
184
  });
185
+
186
+ /**
187
+ * The double-drawn card box.
188
+ *
189
+ * `data-block-id` rides the designer wrapper this anchor renders inside, and the
190
+ * generated stylesheet paints the block's box on that wrapper. The wrapper is
191
+ * the grid item, so it stretches to its row; the anchor only hugs its content.
192
+ * A card with one border in the inspector therefore showed two on the page —
193
+ * one closing under the label, one at the bottom of the row.
194
+ */
195
+ describe('LinkBlock leaves the block box to the wrapper', () => {
196
+ const boxStyles = {
197
+ border: { desktop: '1px solid #d4d4d4' },
198
+ 'border-radius': { desktop: '12px' },
199
+ padding: { desktop: '16px' },
200
+ 'margin-bottom': { desktop: '8px' },
201
+ 'box-shadow': { desktop: '0 1px 2px #0001' },
202
+ outline: { desktop: '1px solid red' }
203
+ };
204
+
205
+ it('writes none of the box keys inline', () => {
206
+ const { container } = render(
207
+ <LinkBlock
208
+ {...props({
209
+ block: {
210
+ id: 'l',
211
+ properties: { href: '/foo' },
212
+ styles: boxStyles,
213
+ blocks: [child({ id: 'a', order: 0 })]
214
+ }
215
+ })}
216
+ />
217
+ );
218
+ const style = anchor(container).getAttribute('style') || '';
219
+ ['border', 'padding', 'margin', 'box-shadow', 'outline'].forEach(
220
+ (fragment) => expect(style).not.toContain(fragment)
221
+ );
222
+ });
223
+
224
+ it('still writes the layout keys inline, since the wrapper has one child', () => {
225
+ const { container } = render(
226
+ <LinkBlock
227
+ {...props({
228
+ block: {
229
+ id: 'l',
230
+ properties: { href: '/foo' },
231
+ styles: {
232
+ ...boxStyles,
233
+ display: { desktop: 'flex' },
234
+ 'flex-direction': { desktop: 'column' },
235
+ gap: { desktop: '8px' }
236
+ },
237
+ blocks: [child({ id: 'a', order: 0 })]
238
+ }
239
+ })}
240
+ />
241
+ );
242
+ const el = anchor(container);
243
+ expect(el.style.display).toBe('flex');
244
+ expect(el.style.flexDirection).toBe('column');
245
+ expect(el.style.gap).toBe('8px');
246
+ });
247
+
248
+ /**
249
+ * Painting the same colour on the wrapper and on the anchor leaves no
250
+ * artifact, and an iterator resolves a bound colour per row into this inline
251
+ * copy — the one shared stylesheet rule cannot hold a value per row.
252
+ */
253
+ it('still writes background inline, which duplication cannot spoil', () => {
254
+ const { container } = render(
255
+ <LinkBlock
256
+ {...props({
257
+ block: {
258
+ id: 'l',
259
+ properties: { href: '/foo' },
260
+ styles: {
261
+ ...boxStyles,
262
+ 'background-color': { desktop: 'rgb(255, 0, 0)' }
263
+ },
264
+ blocks: [child({ id: 'a', order: 0 })]
265
+ }
266
+ })}
267
+ />
268
+ );
269
+ expect(anchor(container).style.backgroundColor).toBe('rgb(255, 0, 0)');
270
+ });
271
+ });
@@ -92,3 +92,91 @@ describe('TextBlock rendering', () => {
92
92
  expect(div.getAttribute('style')).toBeNull();
93
93
  });
94
94
  });
95
+
96
+ /**
97
+ * Which half of a text block's style map this div wears.
98
+ *
99
+ * Not none of it. A bound value (`{{item.badgeColor}}`) is deliberately stripped
100
+ * from the shared `[data-block-id]` rule — every row of an iterator shares that
101
+ * id, and one rule cannot hold a different colour per row — so inline is the
102
+ * only place a per-row value can land. `iterator-bindings.test.tsx` is where
103
+ * that contract is asserted.
104
+ *
105
+ * Margin and padding are the exception, and they were the bug. They describe the
106
+ * block's box, the stylesheet already puts them on the wrapper this div renders
107
+ * inside, and a box inside a box compounds: measured on a live page, nine blocks
108
+ * carried the same margin at both levels, so 29px under a heading was applied
109
+ * twice with nothing in the inspector to explain the second copy.
110
+ */
111
+ describe('TextBlock leaves the block box geometry to the wrapper', () => {
112
+ it('writes no margin inline, at any side', () => {
113
+ const { container } = renderBlock('hello', {
114
+ 'margin-bottom': { desktop: '29px' },
115
+ 'margin-top': { desktop: '12px' },
116
+ 'margin-left': { desktop: '4px' },
117
+ 'margin-right': { desktop: '4px' }
118
+ });
119
+
120
+ const div = container.querySelector('div') as HTMLDivElement;
121
+
122
+ expect(div.getAttribute('style')).toBeNull();
123
+ });
124
+
125
+ it('writes no padding inline, shorthand or longhand', () => {
126
+ const { container } = renderBlock('hello', {
127
+ padding: { desktop: '20px' },
128
+ 'padding-left': { desktop: '8px' }
129
+ });
130
+
131
+ const div = container.querySelector('div') as HTMLDivElement;
132
+
133
+ expect(div.getAttribute('style')).toBeNull();
134
+ });
135
+
136
+ it('recognises the camelCase spelling published JSON may carry', () => {
137
+ const { container } = renderBlock('hello', {
138
+ marginBottom: { desktop: '29px' },
139
+ paddingTop: { desktop: '10px' }
140
+ });
141
+
142
+ const div = container.querySelector('div') as HTMLDivElement;
143
+
144
+ expect(div.getAttribute('style')).toBeNull();
145
+ });
146
+
147
+ it('still wears everything else, which per-row bindings depend on', () => {
148
+ const { container } = renderBlock('hello', {
149
+ 'background-color': { desktop: '#00a86b' },
150
+ color: { desktop: '#610529' },
151
+ 'border-radius': { desktop: '4px' },
152
+ 'margin-bottom': { desktop: '29px' }
153
+ });
154
+
155
+ const div = container.querySelector('div') as HTMLDivElement;
156
+
157
+ expect(div.style.backgroundColor).toBe('rgb(0, 168, 107)');
158
+ expect(div.style.color).toBe('rgb(97, 5, 41)');
159
+ expect(div.style.borderRadius).toBe('4px');
160
+ expect(div.style.marginBottom).toBe('');
161
+ });
162
+
163
+ it('does not leave an empty style attribute when only geometry was set', () => {
164
+ const { container } = renderBlock('hello', {
165
+ 'margin-bottom': { desktop: '29px' }
166
+ });
167
+
168
+ const div = container.querySelector('div') as HTMLDivElement;
169
+
170
+ expect(div.getAttribute('style')).toBeNull();
171
+ });
172
+
173
+ it('still hosts the content, which is the only job this div has', () => {
174
+ const { container } = renderBlock('<p>hello</p>', {
175
+ padding: { desktop: '20px' }
176
+ });
177
+
178
+ const div = container.querySelector('div') as HTMLDivElement;
179
+
180
+ expect(div.querySelector('p')?.textContent).toBe('hello');
181
+ });
182
+ });
@@ -2,10 +2,10 @@ import React from 'react';
2
2
  import { render, screen } from '@testing-library/react';
3
3
  import VideoBlock from '../video-block';
4
4
 
5
- const renderBlock = (value: any, properties?: any) =>
5
+ const renderBlock = (value: any, properties?: any, styles?: any) =>
6
6
  render(
7
7
  <VideoBlock
8
- block={{ value, properties } as any}
8
+ block={{ value, properties, styles } as any}
9
9
  placeholderId="p1"
10
10
  sectionId="s1"
11
11
  isDesigner={false}
@@ -194,6 +194,211 @@ describe('VideoBlock', () => {
194
194
  });
195
195
  });
196
196
 
197
+ /**
198
+ * A Shorts link used to render the grey "Invalid video URL" box: it contains
199
+ * `youtube.com`, so it entered the YouTube branch, matched none of the three
200
+ * shapes the parser knew, and fell out with an empty id.
201
+ */
202
+ describe('YouTube Shorts', () => {
203
+ it('embeds a Shorts link', () => {
204
+ const { container } = renderBlock(
205
+ 'https://www.youtube.com/shorts/AbC123xyz'
206
+ );
207
+ expect(container.querySelector('iframe')).toHaveAttribute(
208
+ 'src',
209
+ 'https://www.youtube.com/embed/AbC123xyz'
210
+ );
211
+ });
212
+
213
+ it('carries the playback properties a watch URL carries', () => {
214
+ const { container } = renderBlock(
215
+ 'https://www.youtube.com/shorts/AbC123xyz',
216
+ { autoplay: true, loop: true }
217
+ );
218
+ expect(container.querySelector('iframe')).toHaveAttribute(
219
+ 'src',
220
+ 'https://www.youtube.com/embed/AbC123xyz?autoplay=1&loop=1&playlist=AbC123xyz'
221
+ );
222
+ });
223
+
224
+ it('drops a share query rather than baking it into the id', () => {
225
+ const { container } = renderBlock(
226
+ 'https://youtube.com/shorts/AbC123xyz?feature=share'
227
+ );
228
+ expect(container.querySelector('iframe')).toHaveAttribute(
229
+ 'src',
230
+ 'https://www.youtube.com/embed/AbC123xyz'
231
+ );
232
+ });
233
+
234
+ it('stands itself up vertical without the author saying so', () => {
235
+ // A Short is filmed 9:16; the historic 400px box letterboxes it.
236
+ const { container } = renderBlock(
237
+ 'https://www.youtube.com/shorts/AbC123xyz'
238
+ );
239
+ const iframe = container.querySelector('iframe') as HTMLIFrameElement;
240
+ expect(iframe.style.aspectRatio).toBe('9/16');
241
+ expect(iframe.style.height).not.toBe('400px');
242
+ });
243
+ });
244
+
245
+ /**
246
+ * The direct-file test was anchored at the end of the whole URL, so every
247
+ * address carrying a query — the ordinary shape of an uploaded asset — was
248
+ * rejected.
249
+ */
250
+ describe('direct video files', () => {
251
+ it('plays a file whose URL carries a signature query', () => {
252
+ const url = 'https://cdn.example.com/clip.mp4?sig=abc&exp=123';
253
+ const { container } = renderBlock(url);
254
+ expect(container.querySelector('video')).toHaveAttribute('src', url);
255
+ });
256
+
257
+ it('reads the extension case-insensitively, on a dotted path', () => {
258
+ const url = 'https://cdn.example.com/a.b.c/clip.WEBM?x=1';
259
+ expect(renderBlock(url).container.querySelector('video')).toHaveAttribute(
260
+ 'src',
261
+ url
262
+ );
263
+ });
264
+
265
+ it('refuses a page that merely mentions a file in its query', () => {
266
+ // The trap an unanchored regex would fall into.
267
+ renderBlock('https://example.com/watch?ref=clip.mp4');
268
+ expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
269
+ });
270
+
271
+ it('plays an extensionless URL when the author says it is a file', () => {
272
+ const url = 'https://cdn.example.com/asset/9f2a';
273
+ const { container } = renderBlock(url, { sourceType: 'file' });
274
+ expect(container.querySelector('video')).toHaveAttribute('src', url);
275
+ });
276
+
277
+ it('refuses the same URL when they have not', () => {
278
+ renderBlock('https://cdn.example.com/asset/9f2a');
279
+ expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
280
+ });
281
+
282
+ it('plays a clip served from the storefront itself', () => {
283
+ const { container } = renderBlock('/media/clip.mp4');
284
+ expect(container.querySelector('video')).toHaveAttribute(
285
+ 'src',
286
+ '/media/clip.mp4'
287
+ );
288
+ });
289
+ });
290
+
291
+ describe('other YouTube shapes', () => {
292
+ it('embeds a /live/ link', () => {
293
+ const { container } = renderBlock(
294
+ 'https://www.youtube.com/live/LiveIdAbc'
295
+ );
296
+ expect(container.querySelector('iframe')).toHaveAttribute(
297
+ 'src',
298
+ 'https://www.youtube.com/embed/LiveIdAbc'
299
+ );
300
+ });
301
+
302
+ it('accepts a nocookie link as input', () => {
303
+ const { container } = renderBlock(
304
+ 'https://www.youtube-nocookie.com/embed/AbC123xyz'
305
+ );
306
+ expect(container.querySelector('iframe')).toHaveAttribute(
307
+ 'src',
308
+ 'https://www.youtube.com/embed/AbC123xyz'
309
+ );
310
+ });
311
+
312
+ it('is not fooled by a host that merely mentions youtube', () => {
313
+ renderBlock('https://evil.example.com/?u=youtube.com/watch?v=abc123');
314
+ expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
315
+ });
316
+ });
317
+
318
+ /**
319
+ * Both of these satisfied the old extension test and were handed straight to
320
+ * `<video src>`.
321
+ */
322
+ describe('schemes it refuses', () => {
323
+ it.each([
324
+ 'javascript:alert(1)//x.mp4',
325
+ 'data:text/html,<script>a</script>//x.mp4'
326
+ ])('refuses %s', (url) => {
327
+ renderBlock(url);
328
+ expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
329
+ });
330
+ });
331
+
332
+ describe('aspect ratio', () => {
333
+ it('takes the authored ratio', () => {
334
+ const { container } = renderBlock(
335
+ 'https://www.youtube.com/watch?v=abc123',
336
+ undefined,
337
+ { 'aspect-ratio': { desktop: '16/9' } }
338
+ );
339
+ const iframe = container.querySelector('iframe') as HTMLIFrameElement;
340
+ expect(iframe.style.aspectRatio).toBe('16/9');
341
+ // Not `100%`: inside an auto-height box that resolves to zero, which is
342
+ // exactly the box a ratio-styled block has.
343
+ expect(iframe.style.height).toBe('auto');
344
+ });
345
+
346
+ it('keeps the historic box when nothing is authored', () => {
347
+ // The backwards-compatibility lock: every published video keeps its size.
348
+ const { container } = renderBlock(
349
+ 'https://www.youtube.com/watch?v=abc123'
350
+ );
351
+ const iframe = container.querySelector('iframe') as HTMLIFrameElement;
352
+ expect(iframe.style.height).toBe('400px');
353
+ expect(iframe.style.aspectRatio).toBeFalsy();
354
+ });
355
+
356
+ it('lets the author override a Short’s vertical default', () => {
357
+ const { container } = renderBlock(
358
+ 'https://www.youtube.com/shorts/AbC123xyz',
359
+ undefined,
360
+ { 'aspect-ratio': { desktop: '1/1' } }
361
+ );
362
+ expect(
363
+ (container.querySelector('iframe') as HTMLIFrameElement).style
364
+ .aspectRatio
365
+ ).toBe('1/1');
366
+ });
367
+
368
+ it('treats `auto` as nothing authored', () => {
369
+ const { container } = renderBlock(
370
+ 'https://www.youtube.com/watch?v=abc123',
371
+ undefined,
372
+ { 'aspect-ratio': { desktop: 'auto' } }
373
+ );
374
+ expect(
375
+ (container.querySelector('iframe') as HTMLIFrameElement).style.height
376
+ ).toBe('400px');
377
+ });
378
+
379
+ it('never collapses to nothing inside an auto-height box', () => {
380
+ // `height: 100%` of an auto-height parent is zero. A ratio-styled block
381
+ // is exactly that box, so the player rendered but was invisible.
382
+ const { container } = renderBlock(
383
+ 'https://www.youtube.com/shorts/AbC123xyz'
384
+ );
385
+ const iframe = container.querySelector('iframe') as HTMLIFrameElement;
386
+ expect(iframe.style.height).not.toBe('100%');
387
+ expect(iframe.style.height).toBe('auto');
388
+ });
389
+
390
+ it('shapes a direct file too', () => {
391
+ const { container } = renderBlock(
392
+ 'https://cdn.example.com/clip.mp4',
393
+ undefined,
394
+ { 'aspect-ratio': { desktop: '9/16' } }
395
+ );
396
+ const video = container.querySelector('video') as HTMLVideoElement;
397
+ expect(video.style.aspectRatio).toBe('9/16');
398
+ expect(video.style.objectFit).toBe('cover');
399
+ });
400
+ });
401
+
197
402
  describe('non-string value (BUG-4 fix)', () => {
198
403
  it('renders the invalid placeholder instead of crashing on a non-string value', () => {
199
404
  renderBlock(123 as any);
@@ -20,7 +20,7 @@ export const findItemTemplate = (
20
20
  currentBreakpoint = 'desktop'
21
21
  ): Block | null =>
22
22
  (block.blocks || []).find(
23
- child => readRole(child, currentBreakpoint) === ITEM_TEMPLATE_ROLE
23
+ (child) => readRole(child, currentBreakpoint) === ITEM_TEMPLATE_ROLE
24
24
  ) ?? null;
25
25
 
26
26
  export const hasItemTemplate = (
@@ -54,9 +54,7 @@ export const publishesProductItems = (blockType?: string): boolean =>
54
54
  */
55
55
  const PRODUCT_PK_FIELDS = ['productPk', 'pk'];
56
56
 
57
- const readProductPk = (
58
- item: Record<string, unknown>
59
- ): number | undefined => {
57
+ const readProductPk = (item: Record<string, unknown>): number | undefined => {
60
58
  for (const field of PRODUCT_PK_FIELDS) {
61
59
  const value = item[field];
62
60
  if (value === undefined || value === null || value === '') continue;
@@ -94,6 +92,48 @@ export const resolveItemProduct = (
94
92
  return { ...item, pk } as ThemeProduct;
95
93
  };
96
94
 
95
+ /**
96
+ * The section types whose SLIDES are products.
97
+ *
98
+ * The same declared-not-sniffed rule as `PRODUCT_ITEM_LIST_TYPES`, one level
99
+ * up: a slider repeats its rows from the section's own data source rather
100
+ * than through `ItemTemplateList`, so no item scope is opened for it and the
101
+ * list-type allow-list above never sees it. `flash-deals-slider` is the type
102
+ * both the Flash Deals and the Product Slider templates publish;
103
+ * `posts-slider` shares the same renderer but its rows are blog posts, and a
104
+ * favourite heart pointed at a post id is precisely the bug the declaration
105
+ * exists to prevent.
106
+ */
107
+ const PRODUCT_SLIDER_SECTION_TYPES = new Set(['flash-deals-slider']);
108
+
109
+ export const publishesProductSlides = (sectionType?: string): boolean =>
110
+ typeof sectionType === 'string' &&
111
+ PRODUCT_SLIDER_SECTION_TYPES.has(sectionType);
112
+
113
+ /**
114
+ * A slider row seen as the ambient product, or null when this section
115
+ * publishes no products or the row is one of the empty placeholders the
116
+ * designer draws against.
117
+ *
118
+ * "Is there a product here" cannot be a truthiness test: the slider's own
119
+ * expansion writes `products[index] || {}`, so every clone carries an object
120
+ * whether or not the data source returned a row for it. Falling back to the
121
+ * pk rule is what keeps an unmatched slide behaving as the plain link it is
122
+ * today instead of opening a panel with nothing in it.
123
+ */
124
+ export const resolveSliderRowProduct = (
125
+ sectionType: string | undefined,
126
+ item: Record<string, unknown> | null | undefined
127
+ ): ThemeProduct | null => {
128
+ if (!publishesProductSlides(sectionType)) return null;
129
+ if (!item || Object.keys(item).length === 0) return null;
130
+
131
+ const pk = readProductPk(item);
132
+ if (pk === undefined) return null;
133
+
134
+ return { ...item, pk } as ThemeProduct;
135
+ };
136
+
97
137
  /**
98
138
  * What a product row shows in the editor when the page has no live data.
99
139
  *
@@ -123,7 +163,13 @@ export const DESIGNER_SAMPLE_PRODUCT: ThemeProduct = {
123
163
  attribute_name: 'Size',
124
164
  options: [
125
165
  { label: 'S', value: 's', in_stock: true, is_selectable: true },
126
- { label: 'M', value: 'm', in_stock: true, is_selectable: true, is_selected: true },
166
+ {
167
+ label: 'M',
168
+ value: 'm',
169
+ in_stock: true,
170
+ is_selectable: true,
171
+ is_selected: true
172
+ },
127
173
  { label: 'L', value: 'l', in_stock: false, is_selectable: false }
128
174
  ]
129
175
  }
@@ -10,6 +10,45 @@ import { useBlockAction } from '../hooks/use-block-action';
10
10
  import { BlockRendererProps } from './block-renderer-registry';
11
11
  import ThemeBlock from '../theme-block';
12
12
 
13
+ /**
14
+ * The lines and gaps of the block's box, which this anchor must not draw a
15
+ * second time.
16
+ *
17
+ * `data-block-id` sits on the designer wrapper this anchor renders inside, and
18
+ * the generated stylesheet already paints the block's box there. Copying these
19
+ * keys inline as well draws them twice at two different sizes: the wrapper is
20
+ * the grid item and stretches to fill its row, while the anchor only hugs its
21
+ * content. Measured on a live page: one border set on a card rendered as two,
22
+ * one closing under the label and another at the bottom of the row. Padding
23
+ * and margin compound the same way — the spacing came out doubled.
24
+ *
25
+ * `background` is deliberately NOT here. Painting the same colour on both
26
+ * boxes is idempotent, so there is no artifact to fix, and an iterator resolves
27
+ * a bound colour per row into this inline copy — the shared stylesheet rule
28
+ * cannot hold a different value per row, so stripping it would lose it.
29
+ *
30
+ * Layout keys stay inline too. The wrapper's only child is this anchor, so
31
+ * `display: flex` up there arranges nothing — the anchor is what lays out the
32
+ * card's image and label.
33
+ *
34
+ * A row-bound `border-color` is the one thing this gives up: it would tint a
35
+ * border the anchor no longer has. Row data carries prices and colours for the
36
+ * content, not the outline of the box it sits in.
37
+ */
38
+ const BOX_STYLE_PREFIXES = [
39
+ 'margin',
40
+ 'padding',
41
+ 'border',
42
+ 'outline',
43
+ 'box-shadow'
44
+ ];
45
+
46
+ const isBoxStyleKey = (normalizedKey: string): boolean =>
47
+ BOX_STYLE_PREFIXES.some(
48
+ (prefix) =>
49
+ normalizedKey === prefix || normalizedKey.startsWith(`${prefix}-`)
50
+ );
51
+
13
52
  import { postToEditor } from '../editor-bridge';
14
53
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
15
54
  const LinkBlock = ({
@@ -137,6 +176,10 @@ const LinkBlock = ({
137
176
  if (styleEntry) {
138
177
  const val = getResponsiveValue(styleEntry, currentBreakpoint);
139
178
  if (val !== undefined && val !== null) {
179
+ if (isBoxStyleKey(normalizedKey)) {
180
+ return;
181
+ }
182
+
140
183
  const camelKey = normalizedKey.replace(/-([a-z])/g, (_, letter) =>
141
184
  letter.toUpperCase()
142
185
  );