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

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,36 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.53-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea0688]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08f]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f4]
18
- - Updated dependencies [57d7eb30]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a71]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d0]
25
- - Updated dependencies [4ac7b2a1]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a963]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b2]
32
- - @akinon/next@2.0.53-rc.0
33
- - @akinon/theme-contract@2.0.53-rc.0
3
+ ## 2.0.53
4
+
5
+ ### Patch Changes
6
+
7
+ - a406d24: ZERO-4748: Translate section max-width tokens into CSS lengths for default-rendered sections, and centre the section a token constrains unless the composition already sets a horizontal margin
8
+ - @akinon/next@2.0.53
9
+ - @akinon/theme-contract@2.0.53
34
10
 
35
11
  ## 2.0.52
36
12
 
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.53-rc.0",
4
+ "version": "2.0.53",
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.53-rc.0",
11
+ "@akinon/next": "2.0.53",
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.53-rc.0",
16
+ "@akinon/theme-contract": "2.0.53",
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.53-rc.0",
23
+ "@akinon/next": "2.0.53",
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",
@@ -0,0 +1,70 @@
1
+ import sectionRendererRegistry from '../section-renderer-registry';
2
+ import {
3
+ CUSTOM_RENDERER_SECTION_TYPES,
4
+ hasCustomSectionRenderer
5
+ } from '../custom-renderer-section-types';
6
+ import type { Section } from '../../theme-section';
7
+
8
+ const section = (over: Partial<Section> = {}): Section =>
9
+ ({
10
+ id: 's1',
11
+ type: 'custom',
12
+ name: 'S',
13
+ label: 'S',
14
+ order: 0,
15
+ hidden: false,
16
+ properties: {},
17
+ styles: {},
18
+ blocks: [],
19
+ ...over
20
+ } as unknown as Section);
21
+
22
+ /**
23
+ * The CSS generator cannot import the registry without closing a module cycle,
24
+ * so it reads a plain copy of the registered types. This is the lock that keeps
25
+ * the copy true: registering a renderer without listing it here would hand that
26
+ * section's outer element a max-width its own component already applies.
27
+ */
28
+ describe('the custom-renderer type list matches the registry', () => {
29
+ it('lists exactly the registered types', () => {
30
+ expect([...CUSTOM_RENDERER_SECTION_TYPES].sort()).toEqual(
31
+ sectionRendererRegistry.registeredTypes().sort()
32
+ );
33
+ });
34
+
35
+ it('agrees with the registry section by section', () => {
36
+ CUSTOM_RENDERER_SECTION_TYPES.forEach((type) => {
37
+ expect(sectionRendererRegistry.hasCustomRenderer(type)).toBe(true);
38
+ });
39
+ });
40
+ });
41
+
42
+ describe('hasCustomSectionRenderer', () => {
43
+ it('is false for the types the generic block loop renders', () => {
44
+ ['custom', 'default', 'hero', 'newsletter-signup-banner'].forEach(
45
+ (type) => {
46
+ expect(hasCustomSectionRenderer(section({ type }))).toBe(false);
47
+ }
48
+ );
49
+ });
50
+
51
+ it('is true for a registered type', () => {
52
+ expect(hasCustomSectionRenderer(section({ type: 'stats-counter' }))).toBe(
53
+ true
54
+ );
55
+ });
56
+
57
+ it('follows the tab hatch, which overrides the section type', () => {
58
+ // theme-section hands any section holding `tab` blocks to TabsSection
59
+ // whatever its own type says, so it owns its container like a registered
60
+ // type does.
61
+ expect(
62
+ hasCustomSectionRenderer(
63
+ section({
64
+ type: 'custom',
65
+ blocks: [{ id: 'b1', type: 'tab' }] as unknown as Section['blocks']
66
+ })
67
+ )
68
+ ).toBe(true);
69
+ });
70
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The section types that render through their own component instead of the
3
+ * generic block loop, as plain data.
4
+ *
5
+ * The registry itself cannot answer this question for the CSS generator: the
6
+ * generator is a leaf module that every section component imports, so importing
7
+ * the registry back into it closes a cycle in which the registry evaluates
8
+ * before the components it registers and silently stores `undefined` for them.
9
+ * The list is duplicated here on purpose and pinned to the registry by
10
+ * `__tests__/custom-renderer-section-types.test.ts`, which fails the moment a
11
+ * renderer is registered or removed without this file following.
12
+ */
13
+ export const CUSTOM_RENDERER_SECTION_TYPES: readonly string[] = [
14
+ 'before-after',
15
+ 'contact-form',
16
+ 'countdown-banner',
17
+ 'coupon-banner',
18
+ 'divider',
19
+ 'featured-product-spotlight',
20
+ 'find-in-store',
21
+ 'flash-deals-slider',
22
+ 'hover-showcase',
23
+ 'image-hotspot',
24
+ 'installment-options',
25
+ 'notification-banner',
26
+ 'order-tracking-lookup',
27
+ 'posts-slider',
28
+ 'pre-order-launch-banner',
29
+ 'shipping-threshold-progress',
30
+ 'stats-counter',
31
+ 'tabs'
32
+ ];
33
+
34
+ /**
35
+ * Mirrors how `theme-section` picks a renderer, including the tab hatch: a
36
+ * section holding `tab` blocks is rendered by `TabsSection` whatever its own
37
+ * type says, so it owns its layout container the same way a registered type
38
+ * does.
39
+ *
40
+ * Typed by shape rather than against `Section`, because the CSS generator
41
+ * carries its own structural copy of that interface and the two are not
42
+ * assignable to one another.
43
+ */
44
+ export const hasCustomSectionRenderer = (section: {
45
+ type?: string;
46
+ blocks?: { type?: string }[];
47
+ }): boolean => {
48
+ if (section.blocks?.some((block) => block.type === 'tab')) return true;
49
+
50
+ return CUSTOM_RENDERER_SECTION_TYPES.includes(section.type ?? '');
51
+ };
@@ -42,6 +42,10 @@ class SectionRendererRegistry {
42
42
  hasCustomRenderer(type: string): boolean {
43
43
  return this.renderers.has(type);
44
44
  }
45
+
46
+ registeredTypes(): string[] {
47
+ return [...this.renderers.keys()];
48
+ }
45
49
  }
46
50
 
47
51
  const sectionRendererRegistry = new SectionRendererRegistry();
@@ -7,7 +7,11 @@ import { twMerge } from 'tailwind-merge';
7
7
  import ThemeBlock, { Block } from '../theme-block';
8
8
  import { Section } from '../theme-section';
9
9
  import { useThemeSettingsContext } from '../theme-settings-context';
10
- import { getCSSStyles, getResponsiveValue } from '../utils';
10
+ import {
11
+ getCSSStyles,
12
+ getResponsiveValue,
13
+ getSectionMaxWidthClass
14
+ } from '../utils';
11
15
 
12
16
  import { postToEditor } from '../editor-bridge';
13
17
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
@@ -446,12 +450,7 @@ const StatsCounterSection: React.FC<StatsCounterSectionProps> = ({
446
450
  currentBreakpoint,
447
451
  'normal'
448
452
  );
449
- const maxWidthClass =
450
- maxWidth === 'narrow'
451
- ? 'max-w-4xl'
452
- : maxWidth === 'normal'
453
- ? 'max-w-7xl'
454
- : '';
453
+ const maxWidthClass = getSectionMaxWidthClass(maxWidth);
455
454
  const hasMaxWidth = maxWidth !== 'none' && maxWidth !== 'full';
456
455
 
457
456
  const filteredStyles = Object.fromEntries(
@@ -0,0 +1,283 @@
1
+ import { generateThemeCSS } from '../index';
2
+ import { SECTION_MAX_WIDTH_SCALE } from '../section-max-width';
3
+ import type { Section } from '../../theme-section';
4
+
5
+ /**
6
+ * `max-width` is offered on a section as Narrow / Normal / Full / None, and
7
+ * those tokens used to reach the stylesheet verbatim: `max-width: narrow` is a
8
+ * declaration the browser drops, so the control did nothing on every section
9
+ * the generic block loop renders — `custom`, `hero`, `newsletter-signup-banner`.
10
+ */
11
+
12
+ const section = (
13
+ styles: Record<string, unknown>,
14
+ over: Partial<Section> = {}
15
+ ): Section[] => [
16
+ {
17
+ id: 'sec-1',
18
+ type: 'custom',
19
+ name: 'Section',
20
+ label: 'Section',
21
+ order: 0,
22
+ hidden: false,
23
+ properties: {},
24
+ styles,
25
+ blocks: [],
26
+ ...over
27
+ } as unknown as Section
28
+ ];
29
+
30
+ describe('section max-width tokens reach CSS as lengths', () => {
31
+ it('translates narrow', () => {
32
+ const css = generateThemeCSS(section({ 'max-width': 'narrow' }));
33
+
34
+ expect(css).toContain('max-width: 56rem');
35
+ expect(css).not.toContain('max-width: narrow');
36
+ });
37
+
38
+ it('translates normal', () => {
39
+ const css = generateThemeCSS(section({ 'max-width': 'normal' }));
40
+
41
+ expect(css).toContain('max-width: 80rem');
42
+ expect(css).not.toContain('max-width: normal');
43
+ });
44
+
45
+ it('translates the camelCase spelling of the key too', () => {
46
+ const css = generateThemeCSS(section({ maxWidth: 'narrow' }));
47
+
48
+ expect(css).toContain('max-width: 56rem');
49
+ });
50
+
51
+ it('resolves `full` to the only real CSS spelling of "no constraint"', () => {
52
+ const css = generateThemeCSS(section({ 'max-width': 'full' }));
53
+
54
+ expect(css).toContain('max-width: none');
55
+ expect(css).not.toContain('max-width: full');
56
+ });
57
+
58
+ it('leaves `none` alone — it is already real CSS', () => {
59
+ expect(generateThemeCSS(section({ 'max-width': 'none' }))).toContain(
60
+ 'max-width: none'
61
+ );
62
+ });
63
+
64
+ it('translates per breakpoint, inside the media query', () => {
65
+ const css = generateThemeCSS(
66
+ section({ 'max-width': { desktop: 'narrow', mobile: 'none' } })
67
+ );
68
+
69
+ expect(css).toContain('max-width: 56rem');
70
+ expect(css).toContain('max-width: none');
71
+ expect(css).not.toContain('max-width: narrow');
72
+ });
73
+
74
+ it('translates for the breakpoint the designer canvas is showing', () => {
75
+ const css = generateThemeCSS(
76
+ section({ 'max-width': { desktop: 'none', mobile: 'normal' } }),
77
+ 'mobile'
78
+ );
79
+
80
+ expect(css).toContain('max-width: 80rem');
81
+ });
82
+ });
83
+
84
+ describe('only the known tokens are translated', () => {
85
+ it.each(['640px', '75%', '48rem', 'calc(100% - 2rem)', 'var(--shell)'])(
86
+ 'passes the real CSS value %s through untouched',
87
+ (value) => {
88
+ expect(generateThemeCSS(section({ 'max-width': value }))).toContain(
89
+ `max-width: ${value}`
90
+ );
91
+ }
92
+ );
93
+
94
+ it('passes an unrecognized word through rather than guessing', () => {
95
+ expect(generateThemeCSS(section({ 'max-width': 'wide' }))).toContain(
96
+ 'max-width: wide'
97
+ );
98
+ });
99
+
100
+ it('touches no other property that happens to carry a token word', () => {
101
+ const css = generateThemeCSS(
102
+ section({ width: 'narrow', 'min-width': 'normal' })
103
+ );
104
+
105
+ expect(css).toContain('width: narrow');
106
+ expect(css).toContain('min-width: normal');
107
+ });
108
+ });
109
+
110
+ describe('a custom-renderer section keeps resolving its own max-width', () => {
111
+ // Its component applies the token to the container it owns. Putting the
112
+ // length on the outer <section> as well would pull that element in from
113
+ // full-bleed and shrink the inner container by the section padding.
114
+ it('leaves the token on the section root untranslated', () => {
115
+ const css = generateThemeCSS(
116
+ section({ 'max-width': 'narrow' }, { type: 'stats-counter' })
117
+ );
118
+
119
+ expect(css).toContain('max-width: narrow');
120
+ expect(css).not.toContain('max-width: 56rem');
121
+ });
122
+
123
+ it('leaves it alone for a section reached through the tab hatch', () => {
124
+ const css = generateThemeCSS(
125
+ section(
126
+ { 'max-width': 'narrow' },
127
+ {
128
+ type: 'custom',
129
+ blocks: [{ id: 'b1', type: 'tab' }] as unknown as Section['blocks']
130
+ }
131
+ )
132
+ );
133
+
134
+ expect(css).toContain('max-width: narrow');
135
+ });
136
+ });
137
+
138
+ describe('the scale is one table, not two', () => {
139
+ // The class and the length are the same measurement: Tailwind's max-w-4xl is
140
+ // 56rem and max-w-7xl is 80rem. A test would not catch them drifting if they
141
+ // were declared in two places, which is why they are declared in one.
142
+ it('pairs each Tailwind class with the length that class means', () => {
143
+ expect(SECTION_MAX_WIDTH_SCALE).toEqual({
144
+ narrow: { className: 'max-w-4xl', length: '56rem' },
145
+ normal: { className: 'max-w-7xl', length: '80rem' }
146
+ });
147
+ });
148
+ });
149
+
150
+ /**
151
+ * A constrained width and `mx-auto` are one decision everywhere else in the
152
+ * product — every custom-renderer section pairs them — so the default path
153
+ * producing a left-aligned constrained column reads as a bug the first time a
154
+ * designer uses the control.
155
+ */
156
+ describe('a resolved token also centres the section', () => {
157
+ it('centres a narrow section', () => {
158
+ const css = generateThemeCSS(section({ 'max-width': 'narrow' }));
159
+
160
+ expect(css).toContain('max-width: 56rem');
161
+ expect(css).toContain('margin-left: auto');
162
+ expect(css).toContain('margin-right: auto');
163
+ });
164
+
165
+ it('centres a normal section', () => {
166
+ const css = generateThemeCSS(section({ 'max-width': 'normal' }));
167
+
168
+ expect(css).toContain('margin-left: auto');
169
+ expect(css).toContain('margin-right: auto');
170
+ });
171
+
172
+ it('centres the camelCase spelling of the key too', () => {
173
+ expect(generateThemeCSS(section({ maxWidth: 'narrow' }))).toContain(
174
+ 'margin-left: auto'
175
+ );
176
+ });
177
+
178
+ it('centres when only one breakpoint is constrained', () => {
179
+ // The auto margin is inert wherever the width is unconstrained, so mobile
180
+ // stays full-bleed without needing a rule of its own.
181
+ const css = generateThemeCSS(
182
+ section({ 'max-width': { desktop: 'narrow', mobile: 'none' } })
183
+ );
184
+
185
+ expect(css).toContain('margin-left: auto');
186
+ });
187
+
188
+ it.each(['none', 'full'])(
189
+ 'does not centre %s — it is not held to a width',
190
+ (value) => {
191
+ const css = generateThemeCSS(section({ 'max-width': value }));
192
+
193
+ expect(css).not.toContain('margin-left: auto');
194
+ expect(css).not.toContain('margin-right: auto');
195
+ }
196
+ );
197
+
198
+ it('does not centre a real CSS length it did not resolve', () => {
199
+ // Only a token is ours to interpret; a length the author typed carries no
200
+ // instruction about where the section sits.
201
+ const css = generateThemeCSS(section({ 'max-width': '640px' }));
202
+
203
+ expect(css).toContain('max-width: 640px');
204
+ expect(css).not.toContain('margin-left: auto');
205
+ });
206
+
207
+ it('leaves a custom-renderer section uncentred here', () => {
208
+ // Its own component pairs the token with mx-auto on the container it owns.
209
+ const css = generateThemeCSS(
210
+ section({ 'max-width': 'narrow' }, { type: 'stats-counter' })
211
+ );
212
+
213
+ expect(css).not.toContain('margin-left: auto');
214
+ });
215
+ });
216
+
217
+ describe('an authored horizontal margin wins over centring', () => {
218
+ it.each([
219
+ ['margin-left', '0px'],
220
+ ['margin-right', '0px'],
221
+ ['marginLeft', '24px'],
222
+ ['margin', '0 0 0 40px'],
223
+ ['margin-inline', '0']
224
+ ])('keeps the author %s', (key, value) => {
225
+ const css = generateThemeCSS(
226
+ section({ 'max-width': 'narrow', [key]: value })
227
+ );
228
+
229
+ expect(css).toContain('max-width: 56rem');
230
+ expect(css).not.toContain('margin-left: auto');
231
+ expect(css).not.toContain('margin-right: auto');
232
+ });
233
+
234
+ it('keeps an authored margin written per breakpoint', () => {
235
+ const css = generateThemeCSS(
236
+ section({
237
+ 'max-width': 'narrow',
238
+ 'margin-left': { desktop: '80px' }
239
+ })
240
+ );
241
+
242
+ expect(css).toContain('margin-left: 80px');
243
+ expect(css).not.toContain('margin-left: auto');
244
+ });
245
+
246
+ it('still centres when a vertical margin is the only one authored', () => {
247
+ const css = generateThemeCSS(
248
+ section({ 'max-width': 'narrow', 'margin-top': '40px' })
249
+ );
250
+
251
+ expect(css).toContain('margin-top: 40px');
252
+ expect(css).toContain('margin-left: auto');
253
+ });
254
+
255
+ it('centres past a horizontal margin key carrying no value', () => {
256
+ // The generator drops an empty value before it reaches CSS, so it is not
257
+ // the author having decided anything.
258
+ const css = generateThemeCSS(
259
+ section({ 'max-width': 'narrow', 'margin-left': '' })
260
+ );
261
+
262
+ expect(css).toContain('margin-left: auto');
263
+ });
264
+ });
265
+
266
+ describe('untouched styles are returned by reference', () => {
267
+ it('emits byte-identical CSS for a section with no max-width', () => {
268
+ const styles = { 'padding-top': '40px', 'background-color': '#fff' };
269
+
270
+ expect(generateThemeCSS(section(styles))).toBe(
271
+ generateThemeCSS(section(styles))
272
+ );
273
+ });
274
+
275
+ it('adds no margin to a section with no max-width', () => {
276
+ const css = generateThemeCSS(
277
+ section({ 'padding-top': '40px', 'background-color': '#fff' })
278
+ );
279
+
280
+ expect(css).not.toContain('margin-left');
281
+ expect(css).not.toContain('margin-right');
282
+ });
283
+ });
@@ -19,12 +19,18 @@ import {
19
19
  MOBILE_MEDIA_QUERY,
20
20
  TABLET_MEDIA_QUERY
21
21
  } from './breakpoints';
22
+ import {
23
+ isSectionMaxWidthToken,
24
+ resolveSectionMaxWidthValue
25
+ } from './section-max-width';
26
+ import { hasCustomSectionRenderer } from '../sections/custom-renderer-section-types';
22
27
 
23
28
  export * from './publish-window';
24
29
  export * from './part-styles';
25
30
  export * from './custom-css';
26
31
  export * from './breakpoints';
27
32
  export * from './checkout-safety';
33
+ export * from './section-max-width';
28
34
 
29
35
  export type ImageLoadingStrategy = 'eager' | 'lazy';
30
36
  export type ImageFetchPriority = 'high' | 'auto' | 'low';
@@ -349,6 +355,126 @@ const stripBoundStyleValues = <T extends Record<string, unknown>>(
349
355
  return changed ? (stripped as T) : styles;
350
356
  };
351
357
 
358
+ /**
359
+ * The ways a composition can already have placed the section horizontally, in
360
+ * both spellings the generator accepts. The `margin` shorthand is included
361
+ * because it sets the horizontal pair too.
362
+ */
363
+ const HORIZONTAL_MARGIN_KEYS = new Set([
364
+ 'margin',
365
+ 'margin-left',
366
+ 'margin-right',
367
+ 'margin-inline',
368
+ 'margin-inline-start',
369
+ 'margin-inline-end'
370
+ ]);
371
+
372
+ /**
373
+ * Whether the author positioned the section themselves. An empty value is not
374
+ * an answer — the generator drops those before they reach CSS — so only a real
375
+ * value counts as the author having decided where the section sits.
376
+ */
377
+ const authorsHorizontalMargin = (styles: Record<string, unknown>): boolean =>
378
+ Object.entries(styles).some(([key, value]) => {
379
+ if (!HORIZONTAL_MARGIN_KEYS.has(camelToKebab(key))) return false;
380
+
381
+ const isSet = (candidate: unknown): boolean =>
382
+ candidate !== undefined && candidate !== null && candidate !== '';
383
+
384
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
385
+ return Object.values(value as Record<string, unknown>).some(isSet);
386
+ }
387
+
388
+ return isSet(value);
389
+ });
390
+
391
+ /**
392
+ * `max-width` is the one section style the editor writes as a token rather than
393
+ * a CSS value, so it is the one that has to be translated before emission —
394
+ * `max-width: narrow` is a declaration the browser throws away, which is why
395
+ * the control appeared to do nothing on every section that renders through the
396
+ * default path.
397
+ *
398
+ * Resolving the token also centres the section, because a constrained width and
399
+ * `mx-auto` are one decision everywhere else in the product — every
400
+ * custom-renderer section pairs them. An authored horizontal margin wins: the
401
+ * designer who set one has already said where the section sits.
402
+ *
403
+ * Only the section root is rewritten, and only for sections the generic block
404
+ * loop renders. A custom-renderer section resolves the same token itself, onto
405
+ * the container it owns; giving its outer `<section>` the length as well would
406
+ * pull that element in from full-bleed and narrow the inner container by the
407
+ * section padding, changing pages that render correctly today.
408
+ *
409
+ * Styles with no token are returned by reference, so every composition that
410
+ * renders today produces byte-identical CSS.
411
+ */
412
+ const resolveSectionMaxWidthTokens = <T extends Record<string, unknown>>(
413
+ styles: T
414
+ ): T => {
415
+ let changed = false;
416
+ // `full` and `none` resolve too, but they mean "no constraint" and must not
417
+ // centre anything — only a width the section is actually held to.
418
+ let constrained = false;
419
+ const resolved: Record<string, unknown> = {};
420
+
421
+ Object.entries(styles).forEach(([key, value]) => {
422
+ if (key !== 'max-width' && key !== 'maxWidth') {
423
+ resolved[key] = value;
424
+ return;
425
+ }
426
+
427
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
428
+ const rawEntries = Object.entries(value as Record<string, unknown>);
429
+
430
+ if (rawEntries.some(([, breakpointValue]) =>
431
+ isSectionMaxWidthToken(breakpointValue)
432
+ )) {
433
+ constrained = true;
434
+ }
435
+
436
+ const entries = rawEntries.map(
437
+ ([breakpoint, breakpointValue]) =>
438
+ [breakpoint, resolveSectionMaxWidthValue(breakpointValue)] as const
439
+ );
440
+
441
+ if (
442
+ entries.some(
443
+ ([breakpoint, breakpointValue]) =>
444
+ breakpointValue !== (value as Record<string, unknown>)[breakpoint]
445
+ )
446
+ ) {
447
+ changed = true;
448
+ resolved[key] = Object.fromEntries(entries);
449
+ return;
450
+ }
451
+
452
+ resolved[key] = value;
453
+ return;
454
+ }
455
+
456
+ if (isSectionMaxWidthToken(value)) constrained = true;
457
+
458
+ const resolvedValue = resolveSectionMaxWidthValue(value);
459
+ if (resolvedValue !== value) changed = true;
460
+ resolved[key] = resolvedValue;
461
+ });
462
+
463
+ // A constrained section that is not centred reads as a bug the first time a
464
+ // designer uses the control: every custom-renderer section pairs the same
465
+ // token with `mx-auto`, so a left-aligned column is not what the control is
466
+ // understood to mean. Emitted as auto margins rather than per breakpoint
467
+ // because an auto margin is inert wherever the width is unconstrained — a
468
+ // token on desktop only still leaves mobile full-bleed.
469
+ if (constrained && !authorsHorizontalMargin(styles)) {
470
+ resolved['margin-left'] = 'auto';
471
+ resolved['margin-right'] = 'auto';
472
+ changed = true;
473
+ }
474
+
475
+ return changed ? (resolved as T) : styles;
476
+ };
477
+
352
478
  export const getCSSStyles = (
353
479
  rawStyles?: Record<string, unknown>,
354
480
  themeSettings?: ThemeSettings,
@@ -1260,7 +1386,9 @@ export const generateThemeCSS = (
1260
1386
  if (Object.keys(sectionRoot).length > 0) {
1261
1387
  css += generateElementCSS(
1262
1388
  sectionSelector,
1263
- sectionRoot,
1389
+ hasCustomSectionRenderer(section)
1390
+ ? sectionRoot
1391
+ : resolveSectionMaxWidthTokens(sectionRoot),
1264
1392
  targetBreakpoint
1265
1393
  );
1266
1394
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A section's `max-width` is authored as a token, not a length: the editor's
3
+ * section configs offer Narrow / Normal / Full / None. Every custom-renderer
4
+ * section translated those tokens itself, each carrying its own inline copy of
5
+ * the same two values, while sections that render through the default path had
6
+ * no translation anywhere — the token reached the generated stylesheet
7
+ * verbatim and the browser dropped the declaration, so the control did nothing.
8
+ *
9
+ * The scale lives here once, in both forms its two consumers need: a Tailwind
10
+ * class for the section components that compose one, and a CSS length for the
11
+ * stylesheet generator, which has no classes to compose. The two columns are
12
+ * the same measurement — `max-w-4xl` is 56rem and `max-w-7xl` is 80rem — and
13
+ * holding them in one table is what keeps them from drifting apart.
14
+ */
15
+ export const SECTION_MAX_WIDTH_SCALE = {
16
+ narrow: { className: 'max-w-4xl', length: '56rem' },
17
+ normal: { className: 'max-w-7xl', length: '80rem' }
18
+ } as const;
19
+
20
+ export type SectionMaxWidthToken = keyof typeof SECTION_MAX_WIDTH_SCALE;
21
+
22
+ export const isSectionMaxWidthToken = (
23
+ value: unknown
24
+ ): value is SectionMaxWidthToken =>
25
+ typeof value === 'string' &&
26
+ Object.prototype.hasOwnProperty.call(SECTION_MAX_WIDTH_SCALE, value);
27
+
28
+ /**
29
+ * The Tailwind class a constrained token maps to. `full`, `none`, a real CSS
30
+ * length and anything unrecognized all produce no class: they either mean "no
31
+ * constraint" or they are a value only the stylesheet can express.
32
+ */
33
+ export const getSectionMaxWidthClass = (value: unknown): string =>
34
+ isSectionMaxWidthToken(value) ? SECTION_MAX_WIDTH_SCALE[value].className : '';
35
+
36
+ /**
37
+ * The CSS value a section `max-width` should actually carry. `full` and `none`
38
+ * both mean "no constraint" in the editor's vocabulary but only `none` is real
39
+ * CSS, so `full` resolves to it rather than staying a dropped declaration.
40
+ * Everything else — `640px`, a `calc()`, a custom property — is returned
41
+ * untouched, because only the known tokens are ours to translate.
42
+ */
43
+ export const resolveSectionMaxWidthValue = (value: unknown): unknown => {
44
+ if (isSectionMaxWidthToken(value)) {
45
+ return SECTION_MAX_WIDTH_SCALE[value].length;
46
+ }
47
+
48
+ return value === 'full' ? 'none' : value;
49
+ };