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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +39 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-flow-block.test.tsx +211 -0
  4. package/src/__tests__/auth-flow-bus.test.ts +290 -0
  5. package/src/__tests__/auth-form-error-block.test.tsx +326 -0
  6. package/src/__tests__/auth-form-validation.test.tsx +204 -0
  7. package/src/__tests__/auth-step-title.test.tsx +202 -0
  8. package/src/__tests__/detached-submit-state.test.tsx +216 -0
  9. package/src/__tests__/forgot-password-composed.test.tsx +313 -0
  10. package/src/__tests__/otp-form-composed.test.tsx +487 -0
  11. package/src/__tests__/password-reset-step.test.tsx +269 -0
  12. package/src/__tests__/register-form-composed.test.tsx +739 -0
  13. package/src/__tests__/registry-parity.test.ts +9 -0
  14. package/src/auth/auth-field-errors.tsx +151 -0
  15. package/src/auth/auth-flow-bus.ts +415 -0
  16. package/src/auth/form-error-context.tsx +55 -0
  17. package/src/auth/form-submit-bus.ts +93 -0
  18. package/src/auth/otp-form-context.tsx +80 -0
  19. package/src/blocks/__tests__/button-block.test.tsx +55 -0
  20. package/src/blocks/__tests__/link-block.test.tsx +48 -30
  21. package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
  22. package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
  23. package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
  24. package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
  25. package/src/blocks/__tests__/text-block.test.tsx +37 -3
  26. package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
  27. package/src/blocks/auth-consent-block.tsx +214 -0
  28. package/src/blocks/auth-error-block.tsx +82 -0
  29. package/src/blocks/auth-field-block.tsx +270 -0
  30. package/src/blocks/auth-flow-block.tsx +251 -0
  31. package/src/blocks/auth-form-block.tsx +391 -47
  32. package/src/blocks/auth-step-block.tsx +81 -0
  33. package/src/blocks/auth-step-title-block.tsx +81 -0
  34. package/src/blocks/block-renderer-registry.tsx +18 -0
  35. package/src/blocks/button-block.tsx +91 -13
  36. package/src/blocks/forgot-password-block.tsx +318 -24
  37. package/src/blocks/link-block.tsx +22 -84
  38. package/src/blocks/otp-code-input-block.tsx +180 -0
  39. package/src/blocks/otp-form-block.tsx +1092 -0
  40. package/src/blocks/otp-phase-block.tsx +71 -0
  41. package/src/blocks/register-form-block.tsx +541 -74
  42. package/src/blocks/text-block.tsx +17 -40
  43. package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
  44. package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
  45. package/src/hooks/use-block-action.ts +87 -4
  46. package/src/hooks/use-overlay.ts +60 -9
  47. package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
  48. package/src/overlays/__tests__/modal-registry.test.ts +6 -1
  49. package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
  50. package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
  51. package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
  52. package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
  53. package/src/overlays/modal-preview-host.tsx +39 -2
  54. package/src/overlays/modal-registry.ts +344 -4
  55. package/src/overlays/overlay-surface.tsx +249 -19
  56. package/src/overlays/overlay-tokens.ts +94 -0
  57. package/src/overlays/theme-modal-mount.tsx +34 -4
  58. package/src/theme-block.tsx +6 -0
  59. package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
  60. package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
  61. package/src/utils/index.ts +107 -6
  62. package/src/utils/iterator-utils.ts +39 -2
  63. package/src/utils/part-styles.ts +4 -0
@@ -1,21 +1,55 @@
1
1
  'use client';
2
2
 
3
- import React, { useState } from 'react';
3
+ import React, { useContext, useState } from 'react';
4
4
  import { useForgotPasswordMutation } from '@akinon/next/data/client/user';
5
5
  import { getResponsiveValue, partAttrs } from '../utils';
6
6
  import { readToggle } from './product-block-utils';
7
+ import {
8
+ AuthFieldErrors,
9
+ AuthFieldErrorsProvider,
10
+ clearAuthFieldError,
11
+ collectAuthFieldErrors
12
+ } from '../auth/auth-field-errors';
13
+ import {
14
+ AuthFormErrorProvider,
15
+ containsAuthErrorBlock
16
+ } from '../auth/form-error-context';
17
+ import {
18
+ clearFormSubmitState,
19
+ publishFormSubmitState
20
+ } from '../auth/form-submit-bus';
21
+ import {
22
+ AuthFlowIdContext,
23
+ resolveAuthStepTarget,
24
+ setAuthStep
25
+ } from '../auth/auth-flow-bus';
26
+ import { renderComposedChildren } from './render-composed-children';
7
27
  import { BlockRendererProps } from './block-renderer-registry';
8
28
 
9
29
  /**
10
30
  * Functional auth block: e-mail input that triggers the password reset mail
11
31
  * through the v1 `forgotPassword` mutation. Behaviour is fixed (commerce sends
12
32
  * the mail); the editor owns the labels and the styling.
33
+ *
34
+ * Composable, the same way the Auth Form is: with children the form draws THEM
35
+ * — an `auth-field` for the address, a Form Error block for the message, the
36
+ * CTA an ordinary Button — and reads the address back out of `FormData` on
37
+ * submit. Left empty it renders the fixed row exactly as before, so a published
38
+ * composition is untouched. Its pieces were reachable only as STYLE parts,
39
+ * which meant a brand could recolour the input but not put copy above it, move
40
+ * the message, or replace the button with the drawer's own bottom-bar CTA.
41
+ *
13
42
  * Properties: submitLabel, busyLabel, successLabel, emailLabel,
14
- * emailPlaceholder, showLabels, errorLabel, previewSuccess.
15
- * Parts: form, field, label, input, error, submit, success.
43
+ * emailPlaceholder, showLabels, errorLabel, previewSuccess, requiredMessage,
44
+ * invalidMessage, formHtmlId, showSubmit, backLabel, backStepTarget,
45
+ * successStepTarget.
46
+ * Parts: form, field, label, input, error, submit, success, back.
16
47
  */
17
48
  const ForgotPasswordBlock = ({
18
49
  block,
50
+ placeholderId,
51
+ sectionId,
52
+ selectedBlockId,
19
53
  currentBreakpoint = 'desktop',
20
54
  isDesigner = false
21
55
  }: BlockRendererProps) => {
@@ -50,46 +84,286 @@ const ForgotPasswordBlock = ({
50
84
  currentBreakpoint,
51
85
  false
52
86
  );
87
+ /**
88
+ * The copy for an address left empty or typed wrongly — the brand's own
89
+ * sentence in place of the browser's bubble. See `auth-field-errors.tsx`.
90
+ */
91
+ const requiredMessage = prop('requiredMessage', 'This field is required.');
92
+ const invalidMessage = prop('invalidMessage', 'Please check this field.');
93
+ /**
94
+ * Detached submit, the same pair the other auth forms carry: `formHtmlId`
95
+ * names the <form> so a Button block elsewhere can submit it through the
96
+ * native `form` attribute — which is how a drawer pins its CTA to the bottom
97
+ * bar — and `showSubmit` off hides the built-in button.
98
+ */
99
+ const formHtmlId = prop('formHtmlId', '').trim();
100
+ const showSubmit = readToggle(
101
+ block.properties?.showSubmit,
102
+ currentBreakpoint,
103
+ true
104
+ );
105
+ /**
106
+ * The way back once the mail is on its way.
107
+ *
108
+ * Only shown in the success state, and only when the designer named a step:
109
+ * before submitting, the flow's own Back button is the way back, but AFTER
110
+ * submitting there is nothing left to do in this step and "check your inbox"
111
+ * with no exit is a dead end. Left empty the block behaves exactly as it
112
+ * did — the success message and nothing else.
113
+ */
114
+ const backLabel = prop('backLabel', 'Back to log in');
115
+ const backStepTarget = prop('backStepTarget', '').trim();
116
+
117
+ /**
118
+ * Where the flow goes once the mail is on its way.
119
+ *
120
+ * The alternative to composing the success state inside this block: a step of
121
+ * the surrounding flow, authored out of ordinary blocks — a heading, the
122
+ * brand's own illustration, a "check your inbox" line, a Button back to the
123
+ * login step — which is more than a message and a link could ever be, and
124
+ * needs no new kind of block to express. Left empty the block shows its own
125
+ * success copy, exactly as it did.
126
+ *
127
+ * Same shape as the register form's `otpStepTarget` handoff, for the same
128
+ * reason: the form knows it has finished, the flow knows what to show next.
129
+ */
130
+ const successStepTarget = prop('successStepTarget', '').trim();
131
+
132
+ const contextFlowId = useContext(AuthFlowIdContext);
133
+ const backStep = resolveAuthStepTarget(backStepTarget, contextFlowId);
134
+ const successStep = resolveAuthStepTarget(successStepTarget, contextFlowId);
135
+
136
+ /**
137
+ * Composed mode: the address is a child `auth-field`, the message a Form
138
+ * Error block, the CTA a Button. Same contract as every other auth form —
139
+ * children present means they draw the form, empty means the fixed row
140
+ * renders exactly as it always did.
141
+ */
142
+ const childBlocks = (block.blocks || [])
143
+ .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
144
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
145
+ const isComposed = childBlocks.length > 0;
53
146
 
54
147
  const [email, setEmail] = useState('');
55
148
  const [error, setError] = useState('');
56
- const [forgotPassword, { isLoading, isSuccess }] = useForgotPasswordMutation();
149
+ // Per FIELD, as opposed to `error`, which is what commerce answered about
150
+ // the request as a whole.
151
+ const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
152
+ const [forgotPassword, { isLoading, isSuccess }] =
153
+ useForgotPasswordMutation();
154
+
155
+ /**
156
+ * A message describes what the field WAS: it goes as soon as it is edited.
157
+ * Both events, because a keystroke raises `input` while autofill raises
158
+ * `change`.
159
+ */
160
+ /**
161
+ * A detached CTA (a Button carrying this form's id) is outside this subtree
162
+ * and cannot see `isLoading` — so the form publishes it. See
163
+ * `auth/form-submit-bus.ts`.
164
+ */
165
+ React.useEffect(() => {
166
+ if (!formHtmlId) return undefined;
167
+
168
+ publishFormSubmitState(formHtmlId, isLoading);
169
+
170
+ return () => clearFormSubmitState(formHtmlId);
171
+ }, [formHtmlId, isLoading]);
172
+
173
+ const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
174
+ const target = event.target as { name?: string } | null;
57
175
 
58
- const submit = async (event: React.FormEvent) => {
176
+ setFieldErrors((current) =>
177
+ clearAuthFieldError(current, target?.name || '')
178
+ );
179
+ };
180
+
181
+ const submit = async (event: React.FormEvent<HTMLFormElement>) => {
59
182
  event.preventDefault();
60
183
  setError('');
61
184
 
62
185
  // Submitting inside the designer would send a real reset mail.
63
186
  if (isDesigner || isLoading) return;
64
187
 
188
+ // The form carries `noValidate`, so this is what stops an empty submit —
189
+ // and it says so under the field rather than in a browser bubble.
190
+ const { errors: nextFieldErrors, firstInvalid } = collectAuthFieldErrors(
191
+ event.currentTarget,
192
+ { required: requiredMessage, invalid: invalidMessage }
193
+ );
194
+
195
+ setFieldErrors(nextFieldErrors);
196
+
197
+ if (firstInvalid) {
198
+ firstInvalid.focus();
199
+
200
+ return;
201
+ }
202
+
203
+ // Composed: the address lives in a child Auth Field, so it is read off the
204
+ // form the way every other composed auth form reads its fields, rather
205
+ // than from state no child writes to.
206
+ const address = isComposed
207
+ ? String(new FormData(event.currentTarget).get('email') ?? '').trim()
208
+ : email;
209
+
65
210
  try {
66
- await forgotPassword({ email }).unwrap();
211
+ await forgotPassword({ email: address }).unwrap();
212
+
213
+ // The mail is away and there is nothing left to do in this step, so a
214
+ // flow that named a step for it moves there instead of swapping this
215
+ // block's own contents.
216
+ if (successStep) {
217
+ setAuthStep(successStep.flowId, successStep.stepKey);
218
+ }
67
219
  } catch {
68
220
  setError(errorLabel);
69
221
  }
70
222
  };
71
223
 
224
+ const backControl = backStep ? (
225
+ <button
226
+ type="button"
227
+ data-forgot-password-back=""
228
+ {...partAttrs('back')}
229
+ onClick={() => {
230
+ // The canvas has its own step switcher; a click here must not move the
231
+ // flow under the designer's feet. Same guard as `use-block-action`.
232
+ if (isDesigner) return;
233
+
234
+ setAuthStep(backStep.flowId, backStep.stepKey);
235
+ }}
236
+ style={{
237
+ alignSelf: 'flex-start',
238
+ padding: 0,
239
+ margin: 0,
240
+ border: 'none',
241
+ background: 'transparent',
242
+ color: 'inherit',
243
+ font: 'inherit',
244
+ textDecoration: 'underline',
245
+ cursor: 'pointer'
246
+ }}
247
+ >
248
+ {backLabel}
249
+ </button>
250
+ ) : null;
251
+
72
252
  // The success copy replaces the form so the shopper is not invited to submit
73
253
  // the same address twice; the root attribute is kept so styling still applies.
74
254
  if ((isSuccess && !isDesigner) || (isDesigner && previewSuccess)) {
75
255
  return (
76
256
  <div
77
257
  data-forgot-password=""
258
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
259
+ className="w-full"
78
260
  data-forgot-password-state="success"
79
261
  {...partAttrs('success')}
80
262
  role="status"
81
263
  >
82
264
  {successLabel}
265
+ {backControl}
83
266
  </div>
84
267
  );
85
268
  }
86
269
 
270
+ if (isComposed) {
271
+ /**
272
+ * The composed form: the children carry the address (`auth-field`), the
273
+ * message (a Form Error block) and whatever chrome the design wants around
274
+ * them, and submit reads the address back out of `FormData`. The built-in
275
+ * button stays available under its toggle so a fresh composition works
276
+ * before the designer has wired their own CTA.
277
+ */
278
+ const errorIsAuthored = containsAuthErrorBlock(childBlocks);
279
+
280
+ return (
281
+ <AuthFormErrorProvider value={error}>
282
+ <AuthFieldErrorsProvider value={fieldErrors}>
283
+ <form
284
+ id={formHtmlId || undefined}
285
+ data-forgot-password=""
286
+ data-forgot-password-composed=""
287
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
288
+ className="w-full"
289
+ data-forgot-password-state={isLoading ? 'busy' : 'idle'}
290
+ {...partAttrs('form')}
291
+ onSubmit={submit}
292
+ // Our own message under the field instead of the browser's bubble
293
+ // over it; the constraints it reads stay on the controls.
294
+ noValidate
295
+ onInput={handleInput}
296
+ onChange={handleInput}
297
+ style={{
298
+ display: 'flex',
299
+ flexDirection: 'column',
300
+ gap: 'var(--forgot-password-gap, 12px)'
301
+ }}
302
+ >
303
+ {renderComposedChildren({
304
+ blocks: childBlocks,
305
+ placeholderId,
306
+ sectionId,
307
+ isDesigner,
308
+ selectedBlockId,
309
+ currentBreakpoint
310
+ })}
311
+
312
+ {/* The composition's own Form Error block draws the message where
313
+ the design wants it; this node is the fallback for one that has
314
+ not placed it. See `auth/form-error-context.tsx`. */}
315
+ {error && !errorIsAuthored && (
316
+ <div
317
+ data-forgot-password-error=""
318
+ {...partAttrs('error')}
319
+ role="alert"
320
+ >
321
+ {error}
322
+ </div>
323
+ )}
324
+
325
+ {showSubmit && (
326
+ <button
327
+ type="submit"
328
+ data-forgot-password-submit=""
329
+ {...partAttrs('submit', {
330
+ loading: isLoading,
331
+ disabled: isLoading
332
+ })}
333
+ disabled={isLoading}
334
+ aria-busy={isLoading}
335
+ style={{
336
+ padding: '12px 20px',
337
+ background: 'var(--forgot-password-cta-bg, #111827)',
338
+ color: 'var(--forgot-password-cta-color, #ffffff)',
339
+ border: 'none',
340
+ cursor: isLoading ? 'not-allowed' : 'pointer',
341
+ opacity: isLoading ? 0.7 : 1
342
+ }}
343
+ >
344
+ {isLoading && busyLabel ? busyLabel : submitLabel}
345
+ </button>
346
+ )}
347
+ </form>
348
+ </AuthFieldErrorsProvider>
349
+ </AuthFormErrorProvider>
350
+ );
351
+ }
352
+
87
353
  return (
88
354
  <form
355
+ id={formHtmlId || undefined}
89
356
  data-forgot-password=""
357
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
358
+ className="w-full"
90
359
  data-forgot-password-state={isLoading ? 'busy' : 'idle'}
91
360
  {...partAttrs('form')}
92
361
  onSubmit={submit}
362
+ // Our own message under the field instead of the browser's bubble over
363
+ // it; `required` below is still what decides.
364
+ noValidate
365
+ onInput={handleInput}
366
+ onChange={handleInput}
93
367
  style={{
94
368
  display: 'flex',
95
369
  flexDirection: 'column',
@@ -112,17 +386,35 @@ const ForgotPasswordBlock = ({
112
386
  autoComplete="email"
113
387
  placeholder={emailPlaceholder || undefined}
114
388
  aria-label={showLabels ? undefined : emailLabel}
389
+ aria-invalid={fieldErrors.email ? true : undefined}
115
390
  required
116
391
  {...partAttrs('input')}
117
392
  style={{
118
393
  width: '100%',
119
- border: '1px solid var(--forgot-password-border, #d1d5db)',
394
+ border: `1px solid ${
395
+ fieldErrors.email
396
+ ? 'var(--forgot-password-border-invalid, #dc2626)'
397
+ : 'var(--forgot-password-border, #d1d5db)'
398
+ }`,
120
399
  background: 'transparent',
121
400
  color: 'inherit',
122
401
  padding: '10px 12px',
123
402
  outline: 'none'
124
403
  }}
125
404
  />
405
+ {fieldErrors.email && (
406
+ <span
407
+ data-forgot-password-field-error="email"
408
+ {...partAttrs('error')}
409
+ role="alert"
410
+ style={{
411
+ color: 'var(--forgot-password-error-color, #dc2626)',
412
+ fontSize: '0.875em'
413
+ }}
414
+ >
415
+ {fieldErrors.email}
416
+ </span>
417
+ )}
126
418
  </label>
127
419
 
128
420
  {error && (
@@ -131,23 +423,25 @@ const ForgotPasswordBlock = ({
131
423
  </div>
132
424
  )}
133
425
 
134
- <button
135
- type="submit"
136
- data-forgot-password-submit=""
137
- {...partAttrs('submit', { loading: isLoading, disabled: isLoading })}
138
- disabled={isLoading}
139
- aria-busy={isLoading}
140
- style={{
141
- padding: '12px 20px',
142
- background: 'var(--forgot-password-cta-bg, #111827)',
143
- color: 'var(--forgot-password-cta-color, #ffffff)',
144
- border: 'none',
145
- cursor: isLoading ? 'not-allowed' : 'pointer',
146
- opacity: isLoading ? 0.7 : 1
147
- }}
148
- >
149
- {isLoading && busyLabel ? busyLabel : submitLabel}
150
- </button>
426
+ {showSubmit && (
427
+ <button
428
+ type="submit"
429
+ data-forgot-password-submit=""
430
+ {...partAttrs('submit', { loading: isLoading, disabled: isLoading })}
431
+ disabled={isLoading}
432
+ aria-busy={isLoading}
433
+ style={{
434
+ padding: '12px 20px',
435
+ background: 'var(--forgot-password-cta-bg, #111827)',
436
+ color: 'var(--forgot-password-cta-color, #ffffff)',
437
+ border: 'none',
438
+ cursor: isLoading ? 'not-allowed' : 'pointer',
439
+ opacity: isLoading ? 0.7 : 1
440
+ }}
441
+ >
442
+ {isLoading && busyLabel ? busyLabel : submitLabel}
443
+ </button>
444
+ )}
151
445
  </form>
152
446
  );
153
447
  };
@@ -5,50 +5,16 @@ import NextLink from 'next/link';
5
5
  import { useLocalization } from '@akinon/next/hooks';
6
6
  import { LocaleUrlStrategy } from '@akinon/next/localization';
7
7
  import { urlLocaleMatcherRegex, urlSchemes } from '@akinon/next/utils';
8
- import { getResponsiveValue } from '../utils';
8
+ import {
9
+ getInnerRootInlineStyles,
10
+ getResponsiveValue,
11
+ partAttrs
12
+ } from '../utils';
13
+ import { useThemeSettingsContext } from '../theme-settings-context';
9
14
  import { useBlockAction } from '../hooks/use-block-action';
10
15
  import { BlockRendererProps } from './block-renderer-registry';
11
16
  import ThemeBlock from '../theme-block';
12
17
 
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
-
52
18
  import { postToEditor } from '../editor-bridge';
53
19
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
54
20
  const LinkBlock = ({
@@ -60,6 +26,7 @@ const LinkBlock = ({
60
26
  isDesigner = false
61
27
  }: BlockRendererProps) => {
62
28
  const { locale, defaultLocaleValue, localeUrlStrategy } = useLocalization();
29
+ const themeSettings = useThemeSettingsContext();
63
30
  const action = useBlockAction(block, { currentBreakpoint, isDesigner });
64
31
 
65
32
  /**
@@ -144,50 +111,19 @@ const LinkBlock = ({
144
111
  return trimmedHref || '#';
145
112
  }, [href, defaultLocaleValue, locale, localeUrlStrategy]);
146
113
 
147
- const getStyleEntry = (kebabKey: string) => {
148
- if (!block.styles) return undefined;
149
- const styles = block.styles as Record<string, unknown>;
150
- const camelKey = kebabKey.replace(/-([a-z])/g, (_, letter) =>
151
- letter.toUpperCase()
152
- );
153
- return styles[kebabKey] ?? styles[camelKey];
154
- };
155
-
156
- const normalizeKey = (key: string): string => {
157
- if (key.includes('-')) return key;
158
- return key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
159
- };
160
-
161
- const inlineStyles: React.CSSProperties = {};
162
- if (block.styles) {
163
- const keys = Object.keys(block.styles);
164
- keys.forEach((key) => {
165
- const normalizedKey = normalizeKey(key);
166
-
167
- if (
168
- normalizedKey === 'hover-flex' ||
169
- normalizedKey === 'hover-child-flex' ||
170
- normalizedKey === 'first-child-flex'
171
- ) {
172
- return;
173
- }
174
-
175
- const styleEntry = getStyleEntry(normalizedKey);
176
- if (styleEntry) {
177
- const val = getResponsiveValue(styleEntry, currentBreakpoint);
178
- if (val !== undefined && val !== null) {
179
- if (isBoxStyleKey(normalizedKey)) {
180
- return;
181
- }
182
-
183
- const camelKey = normalizedKey.replace(/-([a-z])/g, (_, letter) =>
184
- letter.toUpperCase()
185
- );
186
- (inlineStyles as any)[camelKey] = val;
187
- }
188
- }
189
- });
190
- }
114
+ /**
115
+ * Only the values the generated stylesheet cannot produce; the rest now
116
+ * arrives as a rule against this element (`data-part="root"`). Inlining the
117
+ * whole style object here applied every padding twice — once on the designer
118
+ * wrapper, once on the anchor — and shadowed the media queries and `:hover`
119
+ * the same stylesheet emits.
120
+ */
121
+ const inlineStyles = getInnerRootInlineStyles(
122
+ block.styles as Record<string, unknown> | undefined,
123
+ themeSettings,
124
+ currentBreakpoint,
125
+ block.rowStyleKeys
126
+ );
191
127
 
192
128
  if (!block.blocks || block.blocks.length === 0) {
193
129
  return (
@@ -195,6 +131,7 @@ const LinkBlock = ({
195
131
  href={isDesigner ? '#' : formattedHref}
196
132
  target={isDesigner ? undefined : target}
197
133
  rel={isDesigner ? undefined : rel}
134
+ {...partAttrs('root')}
198
135
  style={inlineStyles}
199
136
  onClick={handleClick}
200
137
  {...action.ariaProps}
@@ -211,6 +148,7 @@ const LinkBlock = ({
211
148
  href={isDesigner ? '#' : formattedHref}
212
149
  target={isDesigner ? undefined : target}
213
150
  rel={isDesigner ? undefined : rel}
151
+ {...partAttrs('root')}
214
152
  style={inlineStyles}
215
153
  onClick={handleClick}
216
154
  {...action.ariaProps}