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

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,40 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.138-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d458e]
35
- - Updated dependencies [dca128112]
36
- - @akinon/next@2.0.138-rc.0
37
- - @akinon/theme-contract@2.0.138-rc.0
3
+ ## 2.0.138
4
+
5
+ ### Patch Changes
6
+
7
+ - 4c44342: ZERO-5187: Render an action link block as a button role, not an anchor
8
+ - @akinon/next@2.0.138
9
+ - @akinon/theme-contract@2.0.138
38
10
 
39
11
  ## 2.0.137
40
12
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
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.138-rc.0",
4
+ "version": "2.0.138",
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.138-rc.0",
11
+ "@akinon/next": "2.0.138",
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.138-rc.0",
16
+ "@akinon/theme-contract": "2.0.138",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.138-rc.0",
24
+ "@akinon/next": "2.0.138",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,337 @@
1
+ import React from 'react';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+
4
+ /**
5
+ * Recursive stub. Two child types are drawn as the real thing, because the
6
+ * whole point of these tests is what the ROOT does to interactive
7
+ * descendants: a `link` child reaches the real renderer, and a `button` child
8
+ * emits a native `<button>` the way `button-block` does. Everything else is a
9
+ * plain box that still forwards its own children.
10
+ */
11
+ jest.mock('../../theme-block', () => {
12
+ const ReactLocal = require('react');
13
+
14
+ const Stub = ({ block, ...rest }: any): any => {
15
+ if (block?.type === 'link') {
16
+ const Real = require('../link-block').default;
17
+ return ReactLocal.createElement(Real, { block, ...rest });
18
+ }
19
+
20
+ if (block?.type === 'button') {
21
+ return ReactLocal.createElement(
22
+ 'button',
23
+ { type: 'button', 'data-block-id': block.id },
24
+ block.label ?? 'CTA'
25
+ );
26
+ }
27
+
28
+ return ReactLocal.createElement(
29
+ 'div',
30
+ { 'data-block-id': block?.id },
31
+ (block?.blocks ?? []).map((child: any) =>
32
+ ReactLocal.createElement(Stub, { key: child.id, block: child, ...rest })
33
+ )
34
+ );
35
+ };
36
+
37
+ return { __esModule: true, default: Stub };
38
+ });
39
+
40
+ jest.mock('@akinon/next/hooks', () => ({
41
+ __esModule: true,
42
+ useLocalization: () => ({
43
+ t: (p: string) => p,
44
+ setLocale: () => {},
45
+ setCurrency: () => {},
46
+ locale: 'tr-tr',
47
+ currency: 'TRY',
48
+ locales: [{ value: 'tr-tr' }],
49
+ currencies: ['TRY'],
50
+ defaultLocaleValue: 'tr-tr',
51
+ defaultCurrencyCode: 'TRY',
52
+ localeUrlStrategy: 'hide-default-locale'
53
+ })
54
+ }));
55
+
56
+ import LinkBlock from '../link-block';
57
+ import {
58
+ ACTION_ROOT_RESET_CSS,
59
+ compositionUsesActionRoot,
60
+ generateThemeCSS
61
+ } from '../../utils';
62
+
63
+ const VOID_ELEMENTS = new Set([
64
+ 'area',
65
+ 'base',
66
+ 'br',
67
+ 'col',
68
+ 'embed',
69
+ 'hr',
70
+ 'img',
71
+ 'input',
72
+ 'link',
73
+ 'meta',
74
+ 'param',
75
+ 'source',
76
+ 'track',
77
+ 'wbr'
78
+ ]);
79
+
80
+ /**
81
+ * The invariant, checked on the markup the server actually streams.
82
+ *
83
+ * It cannot be checked through jsdom or Testing Library: both hand back the
84
+ * tree the HTML parser already REPAIRED, so an anchor nested in an anchor
85
+ * reads as perfectly valid there. That is why a hundred block tests never saw
86
+ * this. Walking the serialized string is the only place the authored nesting
87
+ * is still visible.
88
+ *
89
+ * Counting elements is not enough either: `<button>` inside `<button>` is
90
+ * repaired by MOVING the inner control out, which leaves the element count
91
+ * unchanged. Only the shape shows it.
92
+ */
93
+ const invalidNestings = (html: string): string[] => {
94
+ const found: string[] = [];
95
+ const stack: string[] = [];
96
+ const tagPattern =
97
+ /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/g;
98
+
99
+ let match: RegExpExecArray | null;
100
+ while ((match = tagPattern.exec(html)) !== null) {
101
+ const isClosing = Boolean(match[1]);
102
+ const name = match[2].toLowerCase();
103
+ const selfClosing = Boolean(match[4]);
104
+
105
+ if (VOID_ELEMENTS.has(name)) continue;
106
+
107
+ if (isClosing) {
108
+ const open = stack.lastIndexOf(name);
109
+ if (open >= 0) stack.length = open;
110
+ continue;
111
+ }
112
+
113
+ if (selfClosing) continue;
114
+
115
+ // `<a>` is a formatting element, so the parser reparents a nested one.
116
+ // `<button>` inside `<button>` is repaired too. `<a>` inside `<button>`
117
+ // is not — but a span root makes all three moot, which is the point.
118
+ if (name === 'a' && stack.includes('a')) found.push('a-in-a');
119
+ if (name === 'button' && stack.includes('button')) {
120
+ found.push('button-in-button');
121
+ }
122
+
123
+ stack.push(name);
124
+ }
125
+
126
+ return found;
127
+ };
128
+
129
+ const props = (block: Record<string, unknown>) =>
130
+ ({
131
+ block,
132
+ placeholderId: 'p',
133
+ sectionId: 's',
134
+ isDesigner: false,
135
+ currentBreakpoint: 'desktop'
136
+ } as any);
137
+
138
+ const acting = (over: Record<string, unknown> = {}) => ({
139
+ id: 'trigger',
140
+ type: 'link',
141
+ properties: {
142
+ href: { desktop: '#' },
143
+ actionType: { desktop: 'open-modal' },
144
+ actionTarget: { desktop: 'm-ad7cbfp3' }
145
+ },
146
+ ...over
147
+ });
148
+
149
+ describe('link block element choice', () => {
150
+ it('renders a span with a button role when the block performs an action', () => {
151
+ const html = renderToStaticMarkup(<LinkBlock {...props(acting())} />);
152
+
153
+ expect(html).toContain('<span');
154
+ expect(html).toContain('role="button"');
155
+ expect(html).toContain('tabindex="0"');
156
+ expect(html).toContain('data-action-root');
157
+ // No dead destination in the status bar and none in the a11y tree.
158
+ expect(html).not.toContain('<a ');
159
+ expect(html).not.toContain('href=');
160
+ // A native button would reopen the nesting hazard this file exists for.
161
+ expect(html).not.toContain('<button');
162
+ });
163
+
164
+ it('keeps a real anchor when the block navigates', () => {
165
+ const html = renderToStaticMarkup(
166
+ <LinkBlock
167
+ {...props({
168
+ id: 'nav',
169
+ type: 'link',
170
+ properties: { href: { desktop: '/yatak-odasi/' } }
171
+ })}
172
+ />
173
+ );
174
+
175
+ expect(html).toContain('<a ');
176
+ expect(html).toContain('href="/yatak-odasi/"');
177
+ expect(html).not.toContain('role="button"');
178
+ });
179
+
180
+ it('keeps the dialog aria on whichever element it renders', () => {
181
+ const html = renderToStaticMarkup(<LinkBlock {...props(acting())} />);
182
+
183
+ expect(html).toContain('aria-haspopup="dialog"');
184
+ });
185
+ });
186
+
187
+ describe('interactive nesting', () => {
188
+ /**
189
+ * The shape that took the zsa-zsa-zsu homepage down: a card whose root link
190
+ * opens a modal, with a second link nested inside it carrying `href: "#"`
191
+ * and no action of its own. Four of these shipped in section-q2jfk, and the
192
+ * parser turned 91 anchors into 103.
193
+ */
194
+ const cardWithNestedLink = acting({
195
+ id: 'block-urclp',
196
+ blocks: [
197
+ { id: 'block-sc9a5', type: 'image' },
198
+ {
199
+ id: 'block-s2px2',
200
+ type: 'group',
201
+ blocks: [
202
+ {
203
+ id: 'block-mznaq',
204
+ type: 'link',
205
+ properties: { href: { desktop: '#' } },
206
+ blocks: [{ id: 'block-pt6mw', type: 'icon' }]
207
+ }
208
+ ]
209
+ }
210
+ ]
211
+ });
212
+
213
+ /**
214
+ * The regression a native `<button>` root would have introduced, and the
215
+ * reason the root is a span: an ordinary product card that opens a quick
216
+ * view and carries its own add-to-cart. This was SAFE before the element
217
+ * changed at all (`a > button` is not repaired), so turning it into
218
+ * `button > button` would have broken markup that worked.
219
+ */
220
+ const cardWithNativeButton = acting({
221
+ id: 'card',
222
+ blocks: [
223
+ { id: 'img', type: 'image' },
224
+ {
225
+ id: 'body',
226
+ type: 'group',
227
+ blocks: [
228
+ { id: 'cta', type: 'button', label: 'Sepete ekle' },
229
+ { id: 'price', type: 'text' }
230
+ ]
231
+ }
232
+ ]
233
+ });
234
+
235
+ it('emits no invalid nesting for the card that broke the homepage', () => {
236
+ const html = renderToStaticMarkup(<LinkBlock {...props(cardWithNestedLink)} />);
237
+
238
+ expect(invalidNestings(html)).toEqual([]);
239
+ expect(html.startsWith('<span')).toBe(true);
240
+ // The inner link stays a real link; only the root stopped being one.
241
+ expect((html.match(/<a /g) ?? []).length).toBe(1);
242
+ });
243
+
244
+ it('emits no invalid nesting for an action card holding a native button', () => {
245
+ const html = renderToStaticMarkup(<LinkBlock {...props(cardWithNativeButton)} />);
246
+
247
+ expect(invalidNestings(html)).toEqual([]);
248
+ expect(html).toContain('<button type="button" data-block-id="cta"');
249
+ });
250
+
251
+ it('still catches the nesting when neither link acts', () => {
252
+ // Guards the guard. With no action the root is an anchor again, and a
253
+ // nested link is the original defect — which only the descendant-side
254
+ // guard can fix, not the root element choice.
255
+ const html = renderToStaticMarkup(
256
+ <LinkBlock
257
+ {...props({
258
+ ...cardWithNestedLink,
259
+ properties: { href: { desktop: '/koleksiyonlar/' } }
260
+ })}
261
+ />
262
+ );
263
+
264
+ expect(invalidNestings(html)).toContain('a-in-a');
265
+ });
266
+
267
+ it('detects button-in-button, so the native-button root cannot come back', () => {
268
+ // The branch above can only prove itself on markup that actually carries
269
+ // the shape. Element counts are equal here (5 and 5), which is exactly
270
+ // why a count-based check is not enough.
271
+ const repaired =
272
+ '<div><button type="button"><div><img></div>' +
273
+ '<button type="button">CTA</button></button></div>';
274
+
275
+ expect(invalidNestings(repaired)).toContain('button-in-button');
276
+ });
277
+ });
278
+
279
+ describe('action root cursor rule', () => {
280
+ const composition = (blocks: unknown[]) => [{ id: 's1', blocks }] as any;
281
+
282
+ it('is emitted first, ahead of every section rule', () => {
283
+ const css = generateThemeCSS(
284
+ composition([
285
+ {
286
+ id: 'trigger',
287
+ type: 'link',
288
+ properties: { actionType: { desktop: 'open-modal' } }
289
+ }
290
+ ])
291
+ );
292
+
293
+ expect(css.startsWith(ACTION_ROOT_RESET_CSS)).toBe(true);
294
+ });
295
+
296
+ it('gives back the pointer an hrefless span does not get', () => {
297
+ expect(ACTION_ROOT_RESET_CSS).toContain('cursor:pointer');
298
+ // A span needs nothing else: it inherits typography and keeps the
299
+ // anchor's display. Resetting more would shadow authored styles.
300
+ expect(ACTION_ROOT_RESET_CSS).not.toContain('appearance');
301
+ expect(ACTION_ROOT_RESET_CSS).not.toContain('!important');
302
+ expect(ACTION_ROOT_RESET_CSS).not.toContain('data-block-id');
303
+ });
304
+
305
+ it('is left out of a composition where nothing acts', () => {
306
+ const css = generateThemeCSS(
307
+ composition([
308
+ { id: 'nav', type: 'link', properties: { href: { desktop: '/x/' } } }
309
+ ])
310
+ );
311
+
312
+ expect(css).not.toContain('data-action-root');
313
+ expect(generateThemeCSS([])).toBe('');
314
+ });
315
+
316
+ it('finds an acting link however deep it is nested', () => {
317
+ expect(
318
+ compositionUsesActionRoot(
319
+ composition([
320
+ {
321
+ id: 'group',
322
+ type: 'group',
323
+ blocks: [
324
+ // Scalar rather than per-breakpoint: both shapes reach the
325
+ // store, so both have to be recognised.
326
+ {
327
+ id: 'inner',
328
+ type: 'link',
329
+ properties: { actionType: 'scroll-to' }
330
+ }
331
+ ]
332
+ }
333
+ ])
334
+ )
335
+ ).toBe(true);
336
+ });
337
+ });
@@ -212,7 +212,11 @@ const FavoriteButtonBlock = ({
212
212
  // click, the quick-view opener — which has nothing to do with navigation.
213
213
  if (
214
214
  event.currentTarget instanceof Element &&
215
- event.currentTarget.closest('a')
215
+ // `[data-action-root]` is a link block whose action made it a span
216
+ // instead of an anchor — still a card whose action the heart must not
217
+ // fire. `link-block` also reads `defaultPrevented`, so the two guards
218
+ // cover each other.
219
+ event.currentTarget.closest('a, [data-action-root]')
216
220
  ) {
217
221
  event.stopPropagation();
218
222
  }
@@ -10,6 +10,7 @@ import {
10
10
  } from '../utils';
11
11
  import { useThemeSettingsContext } from '../theme-settings-context';
12
12
  import { useBlockAction } from '../hooks/use-block-action';
13
+ import { NATIVE_KEYBOARD_CONTROLS } from '../components/with-designer-features';
13
14
  import { BlockRendererProps } from './block-renderer-registry';
14
15
  import ThemeBlock from '../theme-block';
15
16
 
@@ -38,6 +39,16 @@ const LinkBlock = ({
38
39
  return;
39
40
  }
40
41
 
42
+ /**
43
+ * A child that already handled this click owns it. The favourite heart
44
+ * cancels its own default and then stops the bubble only when it finds an
45
+ * ancestor anchor (`blocks/favorite-button-block.tsx`), so on a card whose
46
+ * root is not an anchor the heart's click arrived here as well and also
47
+ * fired the card's action — adding to favourites AND opening the modal.
48
+ * Reading `defaultPrevented` covers every such child, not just that one.
49
+ */
50
+ if (event.defaultPrevented) return;
51
+
41
52
  if (action.isActive) action.onClick?.(event);
42
53
  };
43
54
 
@@ -108,34 +119,106 @@ const LinkBlock = ({
108
119
  block.rowStyleKeys
109
120
  );
110
121
 
111
- if (!block.blocks || block.blocks.length === 0) {
112
- return (
122
+ /**
123
+ * A LINK BLOCK WHOSE JOB IS AN ACTION IS NOT A LINK.
124
+ *
125
+ * `useBlockAction` already cancels the element's default for every action it
126
+ * performs, so the href on such a block never navigates. The anchor was a
127
+ * control wearing a link's clothes: it announced itself as a link, put a
128
+ * destination in the status bar that no click would follow, and — because an
129
+ * anchor is a formatting element — made the HTML parser reparent any link a
130
+ * merchant nested inside it. That hands the browser a tree react-dom never
131
+ * rendered, and with no Suspense boundary over composed content React 18
132
+ * answers a hydration mismatch by client-rendering the whole document.
133
+ *
134
+ * IT IS A SPAN WITH role="button", NOT A NATIVE <button>. A native button
135
+ * looks like the obvious answer and is the wrong one: `<button>` inside
136
+ * `<button>` is repaired by the parser exactly as `<a>` inside `<a>` is, so
137
+ * an action card holding any button-emitting child — an add-to-cart, a
138
+ * favourite heart, a quick-view trigger — would trade one broken shape for
139
+ * another, and the element count stays the same, so a count-based check
140
+ * cannot even see it. A span is neither a formatting element nor a special
141
+ * one: it is never reparented and never reparents, whatever a merchant puts
142
+ * inside it. It also keeps the anchor's own box (`display: inline`) and
143
+ * inherits typography the same way, so nothing moves.
144
+ *
145
+ * `role="button"` plus a keyboard handler is the shape this package already
146
+ * uses for every other block that owns an action
147
+ * (`components/with-designer-features.tsx`).
148
+ *
149
+ * Navigation is untouched: a block with a destination and no action is still
150
+ * a real anchor. The choice reads the same properties on the server and on
151
+ * the first client render, so it cannot itself introduce a mismatch.
152
+ */
153
+ const ownsAction = action.isActive;
154
+
155
+ /**
156
+ * Enter and Space, the two keys a button answers. A key pressed on a real
157
+ * control inside the card belongs to that control — cancelling it would
158
+ * swallow the control's own activation and make a space impossible to type
159
+ * into a field. Its click still bubbles here, so the keyboard does what the
160
+ * mouse does.
161
+ */
162
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
163
+ if (!ownsAction || isDesigner) return;
164
+
165
+ if (
166
+ event.target !== event.currentTarget &&
167
+ event.target instanceof Element &&
168
+ event.target.matches(NATIVE_KEYBOARD_CONTROLS)
169
+ ) {
170
+ return;
171
+ }
172
+
173
+ if (event.key !== 'Enter' && event.key !== ' ') return;
174
+
175
+ // Space would scroll the page underneath the modal it just opened.
176
+ event.preventDefault();
177
+ action.onClick?.(event as unknown as React.MouseEvent<HTMLElement>);
178
+ };
179
+
180
+ const rootProps = {
181
+ ...partAttrs('root'),
182
+ style: inlineStyles,
183
+ onClick: handleClick,
184
+ ...action.ariaProps
185
+ };
186
+
187
+ const renderRoot = (children: React.ReactNode) =>
188
+ ownsAction ? (
189
+ <span
190
+ role="button"
191
+ tabIndex={0}
192
+ onKeyDown={handleKeyDown}
193
+ /**
194
+ * The hook the cursor rule in `generateThemeCSS` hangs on, and what a
195
+ * brand stylesheet written against `a` can be widened to. An hrefless
196
+ * span gets no pointer cursor from the browser.
197
+ */
198
+ data-action-root=""
199
+ {...rootProps}
200
+ >
201
+ {children}
202
+ </span>
203
+ ) : (
113
204
  <NextLink
114
205
  href={isDesigner ? '#' : formattedHref}
115
206
  target={isDesigner ? undefined : target}
116
207
  rel={isDesigner ? undefined : rel}
117
- {...partAttrs('root')}
118
- style={inlineStyles}
119
- onClick={handleClick}
120
- {...action.ariaProps}
208
+ {...rootProps}
121
209
  >
122
- <div style={{ padding: '20px', color: '#6b7280' }}>
123
- Empty link block
124
- </div>
210
+ {children}
125
211
  </NextLink>
126
212
  );
213
+
214
+ if (!block.blocks || block.blocks.length === 0) {
215
+ return renderRoot(
216
+ <div style={{ padding: '20px', color: '#6b7280' }}>Empty link block</div>
217
+ );
127
218
  }
128
219
 
129
- return (
130
- <NextLink
131
- href={isDesigner ? '#' : formattedHref}
132
- target={isDesigner ? undefined : target}
133
- rel={isDesigner ? undefined : rel}
134
- {...partAttrs('root')}
135
- style={inlineStyles}
136
- onClick={handleClick}
137
- {...action.ariaProps}
138
- >
220
+ return renderRoot(
221
+ <>
139
222
  {block.blocks
140
223
  .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
141
224
  .sort((a, b) => (a.order || 0) - (b.order || 0))
@@ -189,7 +272,7 @@ const LinkBlock = ({
189
272
  />
190
273
  );
191
274
  })}
192
- </NextLink>
275
+ </>
193
276
  );
194
277
  };
195
278
 
@@ -17,7 +17,7 @@ import {
17
17
  } from '../utils/property-switches';
18
18
 
19
19
  /** Elements that already act on Enter/Space themselves. */
20
- const NATIVE_KEYBOARD_CONTROLS =
20
+ export const NATIVE_KEYBOARD_CONTROLS =
21
21
  'a[href], button, input, select, textarea, summary, [contenteditable]:not([contenteditable="false"])';
22
22
 
23
23
  interface WithDesignerFeaturesProps {
@@ -1792,6 +1792,61 @@ const collectBlockCSS = (
1792
1792
  return css;
1793
1793
  };
1794
1794
 
1795
+ /**
1796
+ * The one thing a link block loses when its action makes it a span rather
1797
+ * than an anchor: the pointer cursor the browser gives an href for free.
1798
+ *
1799
+ * Nothing else needs resetting — a span inherits typography and keeps the
1800
+ * anchor's `display: inline`, which is exactly why the action root is a span
1801
+ * and not a native `<button>` (see `blocks/link-block.tsx`). One attribute
1802
+ * selector is (0,1,0), under the (0,2,0) every section and block rule below
1803
+ * carries, so an authored `cursor` wins on specificity as well as on source
1804
+ * order.
1805
+ */
1806
+ export const ACTION_ROOT_RESET_CSS =
1807
+ '[data-action-root]{cursor:pointer}\n';
1808
+
1809
+ /**
1810
+ * Mirrors `isInteractiveAction` in `hooks/use-block-action` — every action
1811
+ * type except the two that navigate — without importing it: that module reads
1812
+ * `getResponsiveValue` from this one, and the cycle would bite at module-init
1813
+ * time. Derived from the rule rather than from a copied list, so a new action
1814
+ * type is covered the day it is added.
1815
+ *
1816
+ * `link-block` is the only renderer that turns an action into a span root
1817
+ * today; widen this the day another one does.
1818
+ */
1819
+ const isButtonRenderingAction = (value: unknown): boolean =>
1820
+ typeof value === 'string' &&
1821
+ value !== '' &&
1822
+ value !== 'none' &&
1823
+ value !== 'link';
1824
+
1825
+ /**
1826
+ * Deliberately broader than the renderer's own test, which also requires a
1827
+ * resolved target: a link block with an action and no target still renders as
1828
+ * an anchor, so the reset goes unused. An unused 170-byte rule costs nothing;
1829
+ * a missing one would let the browser's button chrome through.
1830
+ */
1831
+ const blockUsesActionRoot = (block: Block): boolean => {
1832
+ if (block.type === 'link') {
1833
+ const authored = (block.properties as Record<string, unknown> | undefined)
1834
+ ?.actionType;
1835
+
1836
+ const authoredPerBreakpoint =
1837
+ authored && typeof authored === 'object'
1838
+ ? Object.values(authored as Record<string, unknown>)
1839
+ : [authored];
1840
+
1841
+ if (authoredPerBreakpoint.some(isButtonRenderingAction)) return true;
1842
+ }
1843
+
1844
+ return (block.blocks ?? []).some(blockUsesActionRoot);
1845
+ };
1846
+
1847
+ export const compositionUsesActionRoot = (sections: Section[]): boolean =>
1848
+ sections.some((section) => (section.blocks ?? []).some(blockUsesActionRoot));
1849
+
1795
1850
  export const generateThemeCSS = (
1796
1851
  sections: Section[],
1797
1852
  targetBreakpoint?: string,
@@ -1808,7 +1863,13 @@ export const generateThemeCSS = (
1808
1863
  // and they must lose a specificity tie to anything a section or block emits
1809
1864
  // for itself, which source order settles. Emitted only when the composition
1810
1865
  // actually carries a scoped break — see utils/line-breaks.ts.
1811
- let css = compositionUsesScopedLineBreaks(sections)
1866
+ // Same contract as the line-break rules below: global, emitted ahead of
1867
+ // every section selector so source order settles any specificity tie, and
1868
+ // only when the composition actually contains a link block that renders as
1869
+ // a button — see ACTION_ROOT_RESET_CSS.
1870
+ let css = compositionUsesActionRoot(sections) ? ACTION_ROOT_RESET_CSS : '';
1871
+
1872
+ css += compositionUsesScopedLineBreaks(sections)
1812
1873
  ? buildLineBreakCss(hatchBreakpoint)
1813
1874
  : '';
1814
1875