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

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.62-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea06888]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08fe]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f49]
18
- - Updated dependencies [57d7eb305]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a714]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d06]
25
- - Updated dependencies [4ac7b2a1e]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a9631]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b20]
32
- - @akinon/next@2.0.62-rc.0
33
- - @akinon/theme-contract@2.0.62-rc.0
3
+ ## 2.0.62
4
+
5
+ ### Patch Changes
6
+
7
+ - 583e69c: ZERO-4778: Implement content width feature for sections and add related tests
8
+ - @akinon/next@2.0.62
9
+ - @akinon/theme-contract@2.0.62
34
10
 
35
11
  ## 2.0.61
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.62-rc.0",
4
+ "version": "2.0.62",
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.62-rc.0",
11
+ "@akinon/next": "2.0.62",
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.62-rc.0",
16
+ "@akinon/theme-contract": "2.0.62",
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.62-rc.0",
23
+ "@akinon/next": "2.0.62",
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,92 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ import ThemeSection from '../theme-section';
5
+ import { ThemeSettingsProvider } from '../theme-settings-context';
6
+ import { SECTION_CONTENT_ATTRIBUTE } from '../utils/section-content-width';
7
+
8
+ /**
9
+ * The element the content-width rule is written against.
10
+ *
11
+ * A section holds its blocks in a wrapper that is `display: contents` — it
12
+ * generates no box, so the blocks are the section's own children as far as
13
+ * layout is concerned. That is load-bearing for every page already published: a
14
+ * section set to Flex arranges its blocks directly through it.
15
+ *
16
+ * It is also why the wrapper cannot simply be given a width. These hold the
17
+ * switch: a box only when the section constrains its content, `contents`
18
+ * otherwise, and the attribute in both cases so the selector has something
19
+ * stable to name.
20
+ */
21
+
22
+ const section = (styles: Record<string, unknown>) =>
23
+ ({
24
+ id: 'section-1',
25
+ type: 'custom',
26
+ name: 'Band',
27
+ label: 'Band',
28
+ order: 0,
29
+ hidden: false,
30
+ properties: {},
31
+ styles,
32
+ blocks: [
33
+ {
34
+ id: 'text-1',
35
+ type: 'text',
36
+ label: 'Heading',
37
+ value: 'Spring',
38
+ properties: {},
39
+ styles: {},
40
+ order: 0,
41
+ hidden: false
42
+ }
43
+ ]
44
+ } as never);
45
+
46
+ const renderSection = (styles: Record<string, unknown>) => {
47
+ const { container } = render(
48
+ <ThemeSettingsProvider value={null}>
49
+ <ThemeSection
50
+ section={section(styles)}
51
+ placeholderId="home-body"
52
+ currentBreakpoint="desktop"
53
+ isDesigner={false}
54
+ />
55
+ </ThemeSettingsProvider>
56
+ );
57
+
58
+ return container.querySelector(`[${SECTION_CONTENT_ATTRIBUTE}]`);
59
+ };
60
+
61
+ describe('the box a content width is applied to', () => {
62
+ it('is always named, so the rule has something to select', () => {
63
+ const box = renderSection({});
64
+
65
+ expect(box).not.toBeNull();
66
+ expect(box?.parentElement?.tagName).toBe('SECTION');
67
+ });
68
+
69
+ it('generates no box until the section constrains its content', () => {
70
+ // `display: contents` is what today's compositions lay out through.
71
+ expect(renderSection({})).toHaveClass('contents');
72
+ });
73
+
74
+ it('becomes a real box the moment one is set', () => {
75
+ const box = renderSection({ 'content-width': '1024px' });
76
+
77
+ expect(box).not.toHaveClass('contents');
78
+ expect(box).toHaveClass('w-full');
79
+ });
80
+
81
+ it('reads a width set on one breakpoint only', () => {
82
+ expect(renderSection({ 'content-width': { mobile: '640px' } })).toHaveClass(
83
+ 'w-full'
84
+ );
85
+ });
86
+
87
+ it('stays out of the way when Custom was chosen but nothing typed', () => {
88
+ expect(renderSection({ 'content-width': 'custom' })).toHaveClass(
89
+ 'contents'
90
+ );
91
+ });
92
+ });
@@ -260,3 +260,108 @@ describe('SliderBlock — arrow button geometry', () => {
260
260
  ).toBe('28px');
261
261
  });
262
262
  });
263
+
264
+ /**
265
+ * The offsets the panel offers on the arrow's icon block used to reach the
266
+ * theme JSON and stop there — the button was pinned by `left-4 top-1/2
267
+ * -translate-y-1/2` and only its size was read. These cover both halves of
268
+ * honouring them: the value lands inline, and the class default for that axis
269
+ * gets out of the way instead of fighting it.
270
+ */
271
+ describe('SliderBlock — arrow button placement', () => {
272
+ const arrow = (label: string, styles: Record<string, unknown>) =>
273
+ child(label === 'Left Arrow Icon' ? 'left' : 'right', {
274
+ type: 'icon',
275
+ label,
276
+ styles
277
+ });
278
+
279
+ const renderArrows = (
280
+ leftStyles: Record<string, unknown>,
281
+ rightStyles: Record<string, unknown> = {}
282
+ ) =>
283
+ render(
284
+ <SliderBlock
285
+ {...props({
286
+ isDesigner: true,
287
+ block: {
288
+ ...props().block,
289
+ blocks: [
290
+ arrow('Left Arrow Icon', leftStyles),
291
+ child('s1'),
292
+ arrow('Right Arrow Icon', rightStyles)
293
+ ]
294
+ }
295
+ })}
296
+ />
297
+ );
298
+
299
+ const previous = (container: HTMLElement) =>
300
+ container.querySelector('button[aria-label="Previous"]') as HTMLElement;
301
+ const next = (container: HTMLElement) =>
302
+ container.querySelector('button[aria-label="Next"]') as HTMLElement;
303
+
304
+ it('falls back to the class defaults when no offset is authored', () => {
305
+ const { container } = renderArrows({});
306
+ const button = previous(container);
307
+
308
+ expect(button.className).toContain('left-4');
309
+ expect(button.className).toContain('top-1/2');
310
+ expect(button.className).toContain('-translate-y-1/2');
311
+ expect(button.style.left).toBe('');
312
+ });
313
+
314
+ it('honours an authored left, including 0', () => {
315
+ const { container } = renderArrows({ left: 0 });
316
+ const button = previous(container);
317
+
318
+ expect(button.style.left).toBe('0px');
319
+ // The class would not override the inline value, but leaving it in place
320
+ // is what made `right` unreachable below — so it goes.
321
+ expect(button.className).not.toContain('left-4');
322
+ });
323
+
324
+ it('drops the horizontal default so an authored right is not over-constrained', () => {
325
+ // left-4 + an inline right + a width is three constraints on one axis, and
326
+ // CSS resolves that by ignoring the right — the arrow would not move.
327
+ const { container } = renderArrows({ right: '24px' });
328
+ const button = previous(container);
329
+
330
+ expect(button.style.right).toBe('24px');
331
+ expect(button.className).not.toContain('left-4');
332
+ });
333
+
334
+ it('drops the centering translate when a vertical offset is authored', () => {
335
+ const { container } = renderArrows({ top: '0px' });
336
+ const button = previous(container);
337
+
338
+ expect(button.style.top).toBe('0px');
339
+ expect(button.className).not.toContain('-translate-y-1/2');
340
+ expect(button.className).not.toContain('top-1/2');
341
+ });
342
+
343
+ it('keeps the vertical default when only a horizontal offset is authored', () => {
344
+ const { container } = renderArrows({ left: '8px' });
345
+ const button = previous(container);
346
+
347
+ expect(button.className).toContain('top-1/2');
348
+ expect(button.className).toContain('-translate-y-1/2');
349
+ });
350
+
351
+ it('places each arrow from its own block', () => {
352
+ const { container } = renderArrows({ left: '10px' }, { right: '90px' });
353
+
354
+ expect(previous(container).style.left).toBe('10px');
355
+ expect(next(container).style.right).toBe('90px');
356
+ expect(next(container).className).not.toContain('right-4');
357
+ // The left arrow's own axis default is untouched by the right arrow.
358
+ expect(previous(container).className).not.toContain('left-4');
359
+ });
360
+
361
+ it('still positions absolutely with a z-index', () => {
362
+ const { container } = renderArrows({ left: '10px' });
363
+
364
+ expect(previous(container).className).toContain('absolute');
365
+ expect(previous(container).className).toContain('z-10');
366
+ });
367
+ });
@@ -437,12 +437,42 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
437
437
  if (radius) geometry.borderRadius = radius;
438
438
  else if (Object.keys(corners).length === 0) geometry.borderRadius = '50%';
439
439
 
440
+ // The editor offers these four on the arrow's icon block, but only the
441
+ // sizes above were ever read — so an authored offset reached the theme
442
+ // JSON and stopped there, leaving the arrow pinned to the class default
443
+ // with no feedback that the field did nothing.
444
+ (['left', 'right', 'top', 'bottom'] as const).forEach((key) => {
445
+ const value = read(key);
446
+ if (value) geometry[key] = value;
447
+ });
448
+
440
449
  return { ...geometry, ...corners };
441
450
  };
442
451
 
443
452
  const leftArrowButtonStyle = arrowGeometry(leftArrowIconBlock);
444
453
  const rightArrowButtonStyle = arrowGeometry(rightArrowIconBlock);
445
454
 
455
+ /**
456
+ * The class defaults step aside per axis for whatever the author set.
457
+ * An inline `left` already outranks the `left-4` class, but the pair that
458
+ * would survive is the harmful one: `left-4` alongside an authored `right`
459
+ * over-constrains the box and CSS drops the `right`, and the centering
460
+ * translate would shift an authored `top` by half the button's height.
461
+ */
462
+ const arrowPlacementClass = (
463
+ style: React.CSSProperties,
464
+ horizontalFallback: string
465
+ ) =>
466
+ clsx(
467
+ 'absolute z-10',
468
+ style.left === undefined && style.right === undefined
469
+ ? horizontalFallback
470
+ : undefined,
471
+ style.top === undefined && style.bottom === undefined
472
+ ? 'top-1/2 -translate-y-1/2'
473
+ : undefined
474
+ );
475
+
446
476
  const handleLeftArrowClick = (e: React.MouseEvent) => {
447
477
  e.preventDefault();
448
478
  e.stopPropagation();
@@ -467,7 +497,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
467
497
  handleLeftArrowClick(e);
468
498
  }}
469
499
  className={clsx(
470
- 'absolute left-4 top-1/2 -translate-y-1/2 z-10',
500
+ arrowPlacementClass(leftArrowButtonStyle, 'left-4'),
471
501
  isDesigner &&
472
502
  selectedBlockId === leftArrowIconBlock?.id &&
473
503
  'ring-2 ring-blue-500 ring-offset-2'
@@ -507,7 +537,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
507
537
  handleRightArrowClick(e);
508
538
  }}
509
539
  className={clsx(
510
- 'absolute right-4 top-1/2 -translate-y-1/2 z-10',
540
+ arrowPlacementClass(rightArrowButtonStyle, 'right-4'),
511
541
  isDesigner &&
512
542
  selectedBlockId === rightArrowIconBlock?.id &&
513
543
  'ring-2 ring-blue-500 ring-offset-2'
@@ -631,7 +661,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
631
661
  <button
632
662
  onClick={handleLeftArrowClick}
633
663
  className={clsx(
634
- 'absolute left-4 top-1/2 -translate-y-1/2 z-10',
664
+ arrowPlacementClass(leftArrowButtonStyle, 'left-4'),
635
665
  selectedBlockId === leftArrowIconBlock?.id &&
636
666
  'ring-2 ring-blue-500 ring-offset-2'
637
667
  )}
@@ -662,7 +692,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
662
692
  <button
663
693
  onClick={handleRightArrowClick}
664
694
  className={clsx(
665
- 'absolute right-4 top-1/2 -translate-y-1/2 z-10',
695
+ arrowPlacementClass(rightArrowButtonStyle, 'right-4'),
666
696
  selectedBlockId === rightArrowIconBlock?.id &&
667
697
  'ring-2 ring-blue-500 ring-offset-2'
668
698
  )}
@@ -5,6 +5,7 @@ import ActionToolbar from '../components/action-toolbar';
5
5
  import { twMerge } from 'tailwind-merge';
6
6
  import clsx from 'clsx';
7
7
  import { Section } from '../theme-section';
8
+ import { SECTION_CONTENT_ATTRIBUTE } from '../utils/section-content-width';
8
9
 
9
10
  import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
10
11
  interface SectionWrapperProps {
@@ -153,7 +154,15 @@ export default function SectionWrapper({
153
154
  onRename={onRename}
154
155
  />
155
156
  )}
156
- <div className="relative z-10">{children}</div>
157
+ {/*
158
+ * Already a real box, so unlike the generic path this one needs no
159
+ * switch — only the attribute the content-width rule is written against.
160
+ * A section that renders through its own component also keeps its own
161
+ * `display`: this box has always been the thing that carried it.
162
+ */}
163
+ <div {...{ [SECTION_CONTENT_ATTRIBUTE]: '' }} className="relative z-10">
164
+ {children}
165
+ </div>
157
166
  </section>
158
167
  );
159
168
  }
@@ -15,6 +15,10 @@ import {
15
15
  import { useLocalization } from '@akinon/next/hooks';
16
16
  import { devWarn } from './utils/dev-warn';
17
17
  import { isNarrowBreakpoint } from './utils/breakpoints';
18
+ import {
19
+ SECTION_CONTENT_ATTRIBUTE,
20
+ hasSectionContentWidth
21
+ } from './utils/section-content-width';
18
22
  import { safeDeepClone } from './utils/safe-clone';
19
23
 
20
24
  import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
@@ -120,6 +124,8 @@ export default function ThemeSection({
120
124
 
121
125
  const sectionRef = useRef<HTMLElement>(null);
122
126
 
127
+ const constrainsContent = hasSectionContentWidth(effectiveSection.styles);
128
+
123
129
  // Bound prices are formatted deep inside the binding walk, which has no
124
130
  // React context to read. Publishing the storefront's locale before the
125
131
  // bindings resolve is what stops an en-GB brand rendering Turkish number
@@ -1002,7 +1008,19 @@ export default function ThemeSection({
1002
1008
  />
1003
1009
  )}
1004
1010
 
1005
- <div className={twMerge(clsx('contents'))}>
1011
+ {/*
1012
+ * `display: contents` until the section constrains its content, and a
1013
+ * real box the moment it does. The wrapper has to generate a box to be
1014
+ * given a width at all, but making it one unconditionally would change
1015
+ * every page already published: today the blocks are the section's own
1016
+ * children for layout purposes, so a section set to Flex arranges them
1017
+ * directly. When the box does appear, the section's child layout moves
1018
+ * onto it (splitSectionContentWidth) so that arrangement survives.
1019
+ */}
1020
+ <div
1021
+ {...{ [SECTION_CONTENT_ATTRIBUTE]: '' }}
1022
+ className={twMerge(clsx(constrainsContent ? 'w-full' : 'contents'))}
1023
+ >
1006
1024
  {effectiveSection.blocks
1007
1025
  .sort((a, b) => a.order - b.order)
1008
1026
  .filter((block) => (isDesigner ? true : !block.hidden))
@@ -0,0 +1,319 @@
1
+ import { generateThemeCSS } from '../index';
2
+ import {
3
+ SECTION_CONTENT_ATTRIBUTE,
4
+ hasSectionContentWidth,
5
+ splitSectionContentWidth
6
+ } from '../section-content-width';
7
+ import { MOBILE_MEDIA_QUERY, TABLET_MEDIA_QUERY } from '../breakpoints';
8
+ import type { Section } from '../../theme-section';
9
+
10
+ /**
11
+ * A band's colour and its measure are two decisions, and a section only had one
12
+ * control for them. `max-width` lands on the `<section>` itself, so holding the
13
+ * words to 1024px held the background to 1024px as well; the way round it was
14
+ * to add one of five prebuilt container sections and rebuild the band inside.
15
+ *
16
+ * `content-width` is the second decision. What these assert is the split: the
17
+ * constraint reaches the box inside the band and nothing else, everything that
18
+ * draws the band stays on it, and a section that does not use the setting emits
19
+ * the bytes it always emitted.
20
+ */
21
+
22
+ const section = (
23
+ styles: Record<string, unknown>,
24
+ over: Partial<Section> = {}
25
+ ): Section[] => [
26
+ {
27
+ id: 'sec-1',
28
+ type: 'custom',
29
+ name: 'Section',
30
+ label: 'Section',
31
+ order: 0,
32
+ hidden: false,
33
+ properties: {},
34
+ styles,
35
+ blocks: [],
36
+ ...over
37
+ } as unknown as Section
38
+ ];
39
+
40
+ const BAND_SELECTOR = '[data-section-id="sec-1"]';
41
+ const CONTENT_SELECTOR = `${BAND_SELECTOR} > [${SECTION_CONTENT_ATTRIBUTE}]`;
42
+
43
+ /**
44
+ * The declarations of one UNCONDITIONAL rule.
45
+ *
46
+ * Anchored to the start of a line, which is what keeps it from returning a
47
+ * rule nested inside a media query — those are written indented, and matching
48
+ * one of them would quietly turn "the desktop band declares no width" into
49
+ * "some breakpoint somewhere does not".
50
+ */
51
+ const ruleFor = (css: string, selector: string): string => {
52
+ const escaped = selector.replace(/[[\]().*+?^$|\\{}]/g, '\\$&');
53
+ return new RegExp(`^${escaped} \\{([^}]*)\\}`, 'm').exec(css)?.[1] ?? '';
54
+ };
55
+
56
+ describe('content width constrains the content and nothing else', () => {
57
+ it('writes the width onto the box inside the band, not the band', () => {
58
+ const css = generateThemeCSS(section({ 'content-width': '1024px' }));
59
+
60
+ expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1024px');
61
+ // The band is what a background paints across. A `max-width` here is the
62
+ // whole bug this setting exists to avoid.
63
+ expect(ruleFor(css, BAND_SELECTOR)).not.toContain('max-width');
64
+ });
65
+
66
+ it('centres it — a constrained column against the left edge is not what the control means', () => {
67
+ const rule = ruleFor(
68
+ generateThemeCSS(section({ 'content-width': '768px' })),
69
+ CONTENT_SELECTOR
70
+ );
71
+
72
+ expect(rule).toContain('margin-left: auto');
73
+ expect(rule).toContain('margin-right: auto');
74
+ // Without it the box is a flex item in any section that arranges its
75
+ // children, and a flex item shrinks to its content rather than reaching
76
+ // the width it is being capped at.
77
+ expect(rule).toContain('width: 100%');
78
+ });
79
+
80
+ it('leaves the background, the border and the padding on the full-width band', () => {
81
+ const css = generateThemeCSS(
82
+ section({
83
+ 'content-width': '1024px',
84
+ 'background-color': '#101010',
85
+ 'background-image': 'url(/hero.jpg)',
86
+ 'border-bottom': '1px solid #333',
87
+ 'padding-left': '16px',
88
+ 'padding-right': '16px'
89
+ })
90
+ );
91
+ const band = ruleFor(css, BAND_SELECTOR);
92
+
93
+ // Written as rgba: the generator folds the section's background opacity
94
+ // into the colour before it emits it.
95
+ expect(band).toContain('background-color: rgba(16, 16, 16, 1)');
96
+ expect(band).toContain('background-image: url(/hero.jpg)');
97
+ expect(band).toContain('border-bottom: 1px solid #333');
98
+ // The padding stays on the band on purpose: the constrained box sits
99
+ // inside the padding box, so on a screen narrower than the measure it is
100
+ // the padding that keeps the words off the edge of the phone.
101
+ expect(band).toContain('padding-left: 16px');
102
+ expect(ruleFor(css, CONTENT_SELECTOR)).not.toContain('padding');
103
+ });
104
+
105
+ it('never emits the helper keys themselves — neither is CSS', () => {
106
+ const css = generateThemeCSS(
107
+ section({ 'content-width': 'custom', 'content-width-custom': '900px' })
108
+ );
109
+
110
+ expect(css).not.toContain('content-width:');
111
+ expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 900px');
112
+ });
113
+
114
+ it('reads the camelCase spellings published JSON also carries', () => {
115
+ const css = generateThemeCSS(
116
+ section({ contentWidth: 'custom', contentWidthCustom: '60rem' })
117
+ );
118
+
119
+ expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 60rem');
120
+ expect(css).not.toContain('contentWidth');
121
+ });
122
+
123
+ it('treats a half-finished custom value as not authored yet', () => {
124
+ // Choosing Custom and typing nothing is mid-edit, not a decision to go
125
+ // full width — so nothing is written and the band renders as it did.
126
+ const css = generateThemeCSS(
127
+ section({ 'content-width': 'custom', 'content-width-custom': '' })
128
+ );
129
+
130
+ expect(css).not.toContain(SECTION_CONTENT_ATTRIBUTE);
131
+ });
132
+ });
133
+
134
+ describe('content width per breakpoint', () => {
135
+ it('constrains a desktop and lets a phone use the whole screen', () => {
136
+ const css = generateThemeCSS(
137
+ section({ 'content-width': { desktop: '1280px', mobile: 'full' } })
138
+ );
139
+
140
+ expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1280px');
141
+ expect(css).toContain(MOBILE_MEDIA_QUERY);
142
+ // `full` is the editor's word for it and is not CSS. It has to be emitted
143
+ // as something rather than dropped: a dropped declaration would leave the
144
+ // desktop rule cascading down onto the breakpoint that opted out of it.
145
+ expect(css).toContain('max-width: none');
146
+ expect(css).not.toContain('max-width: full');
147
+ });
148
+
149
+ it('lets a tablet take a measure of its own', () => {
150
+ const css = generateThemeCSS(
151
+ section({
152
+ 'content-width': { desktop: '1280px', tablet: '768px', mobile: 'full' }
153
+ })
154
+ );
155
+
156
+ expect(css).toContain(TABLET_MEDIA_QUERY);
157
+ // After the mobile query, which is the entire mechanism by which a tablet
158
+ // beats the value it would otherwise inherit.
159
+ expect(css.indexOf(TABLET_MEDIA_QUERY)).toBeGreaterThan(
160
+ css.indexOf(MOBILE_MEDIA_QUERY)
161
+ );
162
+ expect(css).toContain('max-width: 768px');
163
+ });
164
+
165
+ it('takes the free-value box per breakpoint too', () => {
166
+ const css = generateThemeCSS(
167
+ section({
168
+ 'content-width': { desktop: 'custom', mobile: '640px' },
169
+ 'content-width-custom': { desktop: '1100px' }
170
+ })
171
+ );
172
+
173
+ expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1100px');
174
+ expect(css).toContain('max-width: 640px');
175
+ });
176
+
177
+ it('emits one rule, not three, when one measure covers every width', () => {
178
+ const css = generateThemeCSS(section({ 'content-width': '1024px' }));
179
+
180
+ expect(css).not.toContain(MOBILE_MEDIA_QUERY);
181
+ expect(css).not.toContain(TABLET_MEDIA_QUERY);
182
+ });
183
+
184
+ it('resolves to one width on the editor canvas, which has no viewport to query', () => {
185
+ // The canvas is a desktop-width iframe simulating a phone, so the media
186
+ // queries a phone would match never fire there. It asks for one breakpoint
187
+ // and gets unconditional rules — which is the whole reason the constraint
188
+ // is emitted through the same generator as everything else.
189
+ const styles = { 'content-width': { desktop: '1280px', mobile: 'full' } };
190
+
191
+ expect(
192
+ ruleFor(generateThemeCSS(section(styles), 'mobile'), CONTENT_SELECTOR)
193
+ ).toContain('max-width: none');
194
+ expect(
195
+ ruleFor(generateThemeCSS(section(styles), 'desktop'), CONTENT_SELECTOR)
196
+ ).toContain('max-width: 1280px');
197
+ });
198
+ });
199
+
200
+ describe('a section that arranges its children keeps arranging them', () => {
201
+ it('moves the layout onto the box that now holds the blocks', () => {
202
+ // On the generic path the wrapper is `display: contents` until a content
203
+ // width appears, so the blocks are the section's own children for layout
204
+ // purposes. Turning the wrapper into a real box without this move would
205
+ // stack a row the designer already had — a break at the exact moment they
206
+ // first touch the setting.
207
+ const css = generateThemeCSS(
208
+ section({
209
+ 'content-width': '1024px',
210
+ display: 'flex',
211
+ 'justify-content': 'space-between',
212
+ gap: '24px'
213
+ })
214
+ );
215
+
216
+ const content = ruleFor(css, CONTENT_SELECTOR);
217
+ expect(content).toContain('display: flex');
218
+ expect(content).toContain('justify-content: space-between');
219
+ expect(content).toContain('gap: 24px');
220
+ expect(ruleFor(css, BAND_SELECTOR)).not.toContain('display');
221
+ });
222
+
223
+ it('leaves a hidden breakpoint hidden on the band itself', () => {
224
+ // `display: none` hides the whole band. Moved onto the inner box it would
225
+ // hide the content and leave the background painting over the gap.
226
+ const css = generateThemeCSS(
227
+ section({
228
+ 'content-width': '1024px',
229
+ display: { desktop: 'grid', mobile: 'none' }
230
+ })
231
+ );
232
+
233
+ expect(ruleFor(css, BAND_SELECTOR)).toBe('');
234
+ expect(css).toContain(`${MOBILE_MEDIA_QUERY} {`);
235
+ expect(css).toContain('display: none');
236
+ expect(ruleFor(css, CONTENT_SELECTOR)).toContain('display: grid');
237
+ });
238
+
239
+ it('leaves a plain display where it was', () => {
240
+ const css = generateThemeCSS(
241
+ section({ 'content-width': '1024px', display: 'block' })
242
+ );
243
+
244
+ expect(ruleFor(css, BAND_SELECTOR)).toContain('display: block');
245
+ });
246
+
247
+ it('moves nothing on a section that renders through its own component', () => {
248
+ // Those already draw a real box around their children, so the section's
249
+ // display already applies to it. Moving would change a page that renders
250
+ // correctly today.
251
+ const css = generateThemeCSS(
252
+ section(
253
+ { 'content-width': '1024px', display: 'flex' },
254
+ { type: 'stats-counter' }
255
+ )
256
+ );
257
+
258
+ expect(ruleFor(css, BAND_SELECTOR)).toContain('display: flex');
259
+ expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1024px');
260
+ });
261
+ });
262
+
263
+ describe('the setting is additive', () => {
264
+ it('changes nothing for a section that does not use it', () => {
265
+ const styles = {
266
+ 'background-color': '#fff',
267
+ display: 'flex',
268
+ gap: '16px',
269
+ 'padding-top': '40px'
270
+ };
271
+
272
+ expect(generateThemeCSS(section(styles))).not.toContain(
273
+ SECTION_CONTENT_ATTRIBUTE
274
+ );
275
+ // Returned by reference, so the generator is handed the same object it
276
+ // always was and produces the same bytes.
277
+ expect(splitSectionContentWidth(styles).section).toBe(styles);
278
+ });
279
+
280
+ it('leaves the section-level Max Width doing exactly what it did', () => {
281
+ // Two independent decisions, both honoured: the band held to 80rem and the
282
+ // content to 1024px inside it. Pages published against the older control
283
+ // must not move.
284
+ const css = generateThemeCSS(
285
+ section({ 'max-width': 'normal', 'content-width': '1024px' })
286
+ );
287
+
288
+ const band = ruleFor(css, BAND_SELECTOR);
289
+ expect(band).toContain('max-width: 80rem');
290
+ expect(band).toContain('margin-left: auto');
291
+ expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1024px');
292
+ });
293
+
294
+ it('scopes the rule to a direct child, so a nested band is never caught', () => {
295
+ expect(generateThemeCSS(section({ 'content-width': '640px' }))).toContain(
296
+ `> [${SECTION_CONTENT_ATTRIBUTE}]`
297
+ );
298
+ });
299
+ });
300
+
301
+ describe('hasSectionContentWidth', () => {
302
+ it.each([
303
+ ['nothing authored', undefined, false],
304
+ ['an empty selection', { 'content-width': '' }, false],
305
+ ['a length', { 'content-width': '768px' }, true],
306
+ ['full width', { 'content-width': 'full' }, true],
307
+ [
308
+ 'custom with a value',
309
+ { 'content-width': 'custom', 'content-width-custom': '900px' },
310
+ true
311
+ ],
312
+ ['custom with nothing typed', { 'content-width': 'custom' }, false],
313
+ ['a phone only', { 'content-width': { mobile: '640px' } }, true]
314
+ ])('answers %s', (_label, styles, expected) => {
315
+ expect(hasSectionContentWidth(styles as Record<string, unknown>)).toBe(
316
+ expected
317
+ );
318
+ });
319
+ });
@@ -27,6 +27,10 @@ import {
27
27
  isSectionMaxWidthToken,
28
28
  resolveSectionMaxWidthValue
29
29
  } from './section-max-width';
30
+ import {
31
+ SECTION_CONTENT_ATTRIBUTE,
32
+ splitSectionContentWidth
33
+ } from './section-content-width';
30
34
  import { hasCustomSectionRenderer } from '../sections/custom-renderer-section-types';
31
35
 
32
36
  export * from './publish-window';
@@ -35,6 +39,7 @@ export * from './custom-css';
35
39
  export * from './breakpoints';
36
40
  export * from './checkout-safety';
37
41
  export * from './section-max-width';
42
+ export * from './section-content-width';
38
43
 
39
44
  export type ImageLoadingStrategy = 'eager' | 'lazy';
40
45
  export type ImageFetchPriority = 'high' | 'auto' | 'low';
@@ -1426,13 +1431,36 @@ export const generateThemeCSS = (
1426
1431
  const { root: sectionRoot } = splitPartStyles(sectionStyles);
1427
1432
 
1428
1433
  if (Object.keys(sectionRoot).length > 0) {
1429
- css += generateElementCSS(
1430
- sectionSelector,
1431
- hasCustomSectionRenderer(section)
1432
- ? sectionRoot
1433
- : resolveSectionMaxWidthTokens(sectionRoot),
1434
- targetBreakpoint
1434
+ const customRenderer = hasCustomSectionRenderer(section);
1435
+
1436
+ // The content width leaves the band before anything else is decided
1437
+ // about it: it is not a declaration a section ever carries, and on the
1438
+ // generic path it takes the section's child layout with it — see
1439
+ // utils/section-content-width.ts for why that move exists.
1440
+ const { section: band, content } = splitSectionContentWidth(
1441
+ sectionRoot,
1442
+ { moveLayout: !customRenderer }
1435
1443
  );
1444
+
1445
+ if (Object.keys(band).length > 0) {
1446
+ css += generateElementCSS(
1447
+ sectionSelector,
1448
+ customRenderer ? band : resolveSectionMaxWidthTokens(band),
1449
+ targetBreakpoint
1450
+ );
1451
+ }
1452
+
1453
+ // Direct child, so a nested section's wrapper is never caught by an
1454
+ // ancestor's rule. Emitted after the band on purpose: the two address
1455
+ // different elements, but reading the sheet, the band is the thing the
1456
+ // constraint is being carved out of.
1457
+ if (content) {
1458
+ css += generateElementCSS(
1459
+ `${sectionSelector} > [${SECTION_CONTENT_ATTRIBUTE}]`,
1460
+ content as ResponsiveStyles,
1461
+ targetBreakpoint
1462
+ );
1463
+ }
1436
1464
  }
1437
1465
 
1438
1466
  css += generateRootStateCSS(
@@ -1441,11 +1469,7 @@ export const generateThemeCSS = (
1441
1469
  targetBreakpoint
1442
1470
  );
1443
1471
 
1444
- css += generatePartCSS(
1445
- sectionSelector,
1446
- sectionStyles,
1447
- targetBreakpoint
1448
- );
1472
+ css += generatePartCSS(sectionSelector, sectionStyles, targetBreakpoint);
1449
1473
  }
1450
1474
 
1451
1475
  if (section.properties) {
@@ -0,0 +1,337 @@
1
+ import { BREAKPOINTS, BREAKPOINT_FALLBACKS } from './breakpoints';
2
+
3
+ /**
4
+ * How wide the CONTENT of a band is allowed to get, as opposed to how wide the
5
+ * band is.
6
+ *
7
+ * A section already had a `max-width` (Narrow / Normal / None), but that key
8
+ * lands on the `<section>` itself: constraining it pulls the background colour,
9
+ * the background image and the border in with the text. What a design almost
10
+ * always wants is the other split — colour from edge to edge, words held to a
11
+ * measure and centred — and the only way to express it was to add one of five
12
+ * prebuilt "container" starters and rebuild the band inside it.
13
+ *
14
+ * `content-width` is that split as a setting. It never reaches the section's
15
+ * own box: it is resolved here into a rule for the wrapper every section
16
+ * already renders around its blocks (`data-section-content`), so the band stays
17
+ * full-bleed and only what is inside it is constrained. The section's
18
+ * horizontal padding keeps working, because the wrapper sits inside the padding
19
+ * box — which is what stops content touching the screen edge on a phone.
20
+ *
21
+ * The value is a real CSS length (`1024px`), not a token: the editor writes the
22
+ * scale it offers, so there is no vocabulary for the two repos to keep in sync.
23
+ * Only three spellings mean something here — `full` and `none` for "not
24
+ * constrained", and `custom`, which defers to the free-length box beside the
25
+ * select.
26
+ */
27
+ export const CONTENT_WIDTH_STYLE_KEY = 'content-width';
28
+ export const CONTENT_WIDTH_CUSTOM_STYLE_KEY = 'content-width-custom';
29
+
30
+ /** camelCase spellings, because published JSON carries both. */
31
+ const CONTENT_WIDTH_KEYS = [CONTENT_WIDTH_STYLE_KEY, 'contentWidth'] as const;
32
+ const CONTENT_WIDTH_CUSTOM_KEYS = [
33
+ CONTENT_WIDTH_CUSTOM_STYLE_KEY,
34
+ 'contentWidthCustom'
35
+ ] as const;
36
+
37
+ /** The attribute both render paths stamp on the box that holds the blocks. */
38
+ export const SECTION_CONTENT_ATTRIBUTE = 'data-section-content';
39
+
40
+ /** The option the free-length box is gated on. */
41
+ const CUSTOM = 'custom';
42
+
43
+ /**
44
+ * The two spellings of "do not constrain me". `full` is the editor's word and
45
+ * is not CSS, so it resolves to the one the browser understands — and it has to
46
+ * resolve to something rather than be dropped, because dropping it would let a
47
+ * desktop width cascade down to the breakpoint that explicitly opted out.
48
+ */
49
+ const UNCONSTRAINED = new Set(['full', 'none']);
50
+
51
+ type Styles = Record<string, unknown>;
52
+
53
+ const isResponsive = (value: unknown): value is Record<string, unknown> =>
54
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value);
55
+
56
+ const read = (styles: Styles, keys: readonly string[]): unknown => {
57
+ for (const key of keys) {
58
+ const value = styles[key];
59
+ if (value !== undefined) return value;
60
+ }
61
+ return undefined;
62
+ };
63
+
64
+ /**
65
+ * One breakpoint's value out of a responsive entry, following the same chain as
66
+ * `getResponsiveValue` — a tablet reads the mobile value before the desktop one
67
+ * — so what this resolves and what the rest of the renderer resolves cannot
68
+ * disagree.
69
+ */
70
+ const at = (value: unknown, breakpoint: string): unknown => {
71
+ if (!isResponsive(value)) return value;
72
+
73
+ const own = value[breakpoint];
74
+ if (own !== undefined) return own;
75
+
76
+ for (const next of BREAKPOINT_FALLBACKS[breakpoint] ?? []) {
77
+ if (value[next] !== undefined) return value[next];
78
+ }
79
+
80
+ return value.desktop;
81
+ };
82
+
83
+ /**
84
+ * The `max-width` one breakpoint should carry, or `''` for "nothing authored
85
+ * here" — which is not the same as "unconstrained": an unauthored breakpoint
86
+ * inherits, an explicitly full one overrides.
87
+ *
88
+ * A `custom` selection with an empty box is treated as unauthored rather than
89
+ * as full width, so a half-typed value behaves like the setting not being there
90
+ * yet instead of silently widening the band.
91
+ */
92
+ export const resolveContentWidthValue = (
93
+ choice: unknown,
94
+ customValue: unknown
95
+ ): string => {
96
+ if (choice === undefined || choice === null || choice === '') return '';
97
+
98
+ const normalized = String(choice).trim();
99
+ if (!normalized) return '';
100
+ if (UNCONSTRAINED.has(normalized.toLowerCase())) return 'none';
101
+
102
+ if (normalized.toLowerCase() === CUSTOM) {
103
+ const custom =
104
+ customValue === undefined || customValue === null
105
+ ? ''
106
+ : String(customValue).trim();
107
+ return custom;
108
+ }
109
+
110
+ return normalized;
111
+ };
112
+
113
+ /** Whether this section constrains its content at any breakpoint. */
114
+ export const hasSectionContentWidth = (
115
+ styles: Styles | undefined | null
116
+ ): boolean => {
117
+ if (!styles) return false;
118
+
119
+ const choice = read(styles, CONTENT_WIDTH_KEYS);
120
+ const custom = read(styles, CONTENT_WIDTH_CUSTOM_KEYS);
121
+ if (choice === undefined) return false;
122
+
123
+ if (!isResponsive(choice)) {
124
+ return resolveContentWidthValue(choice, at(custom, 'desktop')) !== '';
125
+ }
126
+
127
+ return BREAKPOINTS.some(
128
+ (breakpoint) =>
129
+ choice[breakpoint] !== undefined &&
130
+ resolveContentWidthValue(choice[breakpoint], at(custom, breakpoint)) !==
131
+ ''
132
+ );
133
+ };
134
+
135
+ /**
136
+ * Declarations that arrange a section's CHILDREN rather than draw the section.
137
+ *
138
+ * They matter here because of what the wrapper is: on the generic path it is
139
+ * `display: contents` today, so a section set to Flex lays its blocks out
140
+ * directly. Turning that wrapper into a real box to constrain it would make the
141
+ * blocks stack — the row a designer already had would break the moment they
142
+ * touched the new setting. Moving these onto the wrapper keeps the arrangement
143
+ * and changes only where it is declared; the section is left a plain block,
144
+ * which is what a `<section>` is anyway.
145
+ *
146
+ * The list is the arranging half of the box model only. Padding, background,
147
+ * border and position stay on the band, which is the entire point of the
148
+ * setting.
149
+ */
150
+ const CHILD_LAYOUT_KEYS = new Set([
151
+ 'flex-direction',
152
+ 'flex-wrap',
153
+ 'flex-flow',
154
+ 'justify-content',
155
+ 'justify-items',
156
+ 'align-items',
157
+ 'align-content',
158
+ 'place-items',
159
+ 'place-content',
160
+ 'gap',
161
+ 'row-gap',
162
+ 'column-gap',
163
+ 'grid-template-columns',
164
+ 'grid-template-rows',
165
+ 'grid-template-areas',
166
+ 'grid-auto-flow',
167
+ 'grid-auto-columns',
168
+ 'grid-auto-rows'
169
+ ]);
170
+
171
+ /** The `display` values that make an element arrange its children. */
172
+ const CHILD_LAYOUT_DISPLAYS = new Set([
173
+ 'flex',
174
+ 'inline-flex',
175
+ 'grid',
176
+ 'inline-grid'
177
+ ]);
178
+
179
+ const kebab = (key: string): string =>
180
+ key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
181
+
182
+ /**
183
+ * Splits `display` between the band and the wrapper, breakpoint by breakpoint.
184
+ *
185
+ * Only the arranging values move. `none` in particular must stay behind: it
186
+ * hides the whole band, and moving it would leave the background painting over
187
+ * a breakpoint the designer switched off.
188
+ */
189
+ const splitDisplay = (
190
+ value: unknown
191
+ ): { section?: unknown; content?: unknown } => {
192
+ const moves = (candidate: unknown): boolean =>
193
+ typeof candidate === 'string' &&
194
+ CHILD_LAYOUT_DISPLAYS.has(candidate.trim().toLowerCase());
195
+
196
+ if (!isResponsive(value)) {
197
+ return moves(value) ? { content: value } : { section: value };
198
+ }
199
+
200
+ const section: Record<string, unknown> = {};
201
+ const content: Record<string, unknown> = {};
202
+
203
+ Object.entries(value).forEach(([breakpoint, breakpointValue]) => {
204
+ if (moves(breakpointValue)) content[breakpoint] = breakpointValue;
205
+ else section[breakpoint] = breakpointValue;
206
+ });
207
+
208
+ return {
209
+ section: Object.keys(section).length > 0 ? section : undefined,
210
+ content: Object.keys(content).length > 0 ? content : undefined
211
+ };
212
+ };
213
+
214
+ /**
215
+ * The `max-width` entry the wrapper should carry, in whichever shape the
216
+ * authored value was in — a scalar stays a scalar, so a section constrained at
217
+ * every width emits one rule rather than three.
218
+ */
219
+ const buildMaxWidth = (styles: Styles): string | Record<string, string> => {
220
+ const choice = read(styles, CONTENT_WIDTH_KEYS);
221
+ const custom = read(styles, CONTENT_WIDTH_CUSTOM_KEYS);
222
+
223
+ if (!isResponsive(choice)) {
224
+ return resolveContentWidthValue(choice, at(custom, 'desktop'));
225
+ }
226
+
227
+ const entries: Array<[string, string]> = [];
228
+
229
+ BREAKPOINTS.forEach((breakpoint) => {
230
+ if (choice[breakpoint] === undefined) return;
231
+
232
+ const resolved = resolveContentWidthValue(
233
+ choice[breakpoint],
234
+ at(custom, breakpoint)
235
+ );
236
+
237
+ if (resolved !== '') entries.push([breakpoint, resolved]);
238
+ });
239
+
240
+ return Object.fromEntries(entries);
241
+ };
242
+
243
+ export interface SectionContentWidthSplit {
244
+ /** What the band itself still declares — always returned, never null. */
245
+ section: Styles;
246
+ /** The wrapper's own rule, or null when this section constrains nothing. */
247
+ content: Styles | null;
248
+ }
249
+
250
+ /**
251
+ * Takes a section root's styles apart into the band and the box inside it.
252
+ *
253
+ * `moveLayout` is false for a section that renders through its own component:
254
+ * those already draw a real box around their children, so the section's
255
+ * `display` already applies to that box and moving it would change a page that
256
+ * renders correctly today.
257
+ *
258
+ * A section with no content width authored gets its styles back by reference,
259
+ * so every composition that renders today produces byte-identical CSS.
260
+ */
261
+ export const splitSectionContentWidth = (
262
+ styles: Styles,
263
+ options: { moveLayout?: boolean } = {}
264
+ ): SectionContentWidthSplit => {
265
+ const maxWidth = hasSectionContentWidth(styles) ? buildMaxWidth(styles) : '';
266
+ const constrained =
267
+ typeof maxWidth === 'string'
268
+ ? maxWidth !== ''
269
+ : Object.keys(maxWidth).length > 0;
270
+
271
+ const carriesHelperKey = Object.keys(styles).some((key) => {
272
+ const normalized = kebab(key);
273
+ return (
274
+ normalized === CONTENT_WIDTH_STYLE_KEY ||
275
+ normalized === CONTENT_WIDTH_CUSTOM_STYLE_KEY
276
+ );
277
+ });
278
+
279
+ if (!constrained && !carriesHelperKey) {
280
+ return { section: styles, content: null };
281
+ }
282
+
283
+ const section: Styles = {};
284
+ const moved: Styles = {};
285
+
286
+ Object.entries(styles).forEach(([key, value]) => {
287
+ const normalized = kebab(key);
288
+
289
+ // Neither is a CSS property. Emitted as-is they would be a declaration the
290
+ // browser throws away, which is exactly the failure the old `max-width`
291
+ // token had on the default render path.
292
+ if (
293
+ normalized === CONTENT_WIDTH_STYLE_KEY ||
294
+ normalized === CONTENT_WIDTH_CUSTOM_STYLE_KEY
295
+ ) {
296
+ return;
297
+ }
298
+
299
+ if (!constrained || !options.moveLayout) {
300
+ section[key] = value;
301
+ return;
302
+ }
303
+
304
+ if (normalized === 'display') {
305
+ const split = splitDisplay(value);
306
+ if (split.section !== undefined) section[key] = split.section;
307
+ if (split.content !== undefined) moved[normalized] = split.content;
308
+ return;
309
+ }
310
+
311
+ if (CHILD_LAYOUT_KEYS.has(normalized)) {
312
+ moved[normalized] = value;
313
+ return;
314
+ }
315
+
316
+ section[key] = value;
317
+ });
318
+
319
+ if (!constrained) return { section, content: null };
320
+
321
+ return {
322
+ section,
323
+ // `width` first, and always: a wrapper that is a flex item — which is what
324
+ // it becomes inside a section that arranges its children — would otherwise
325
+ // shrink to its content and never reach the width it is being capped at.
326
+ // Auto margins are what centre it; they are inert wherever the width is
327
+ // unconstrained, so a desktop-only constraint still leaves a phone
328
+ // full-bleed.
329
+ content: {
330
+ width: '100%',
331
+ 'max-width': maxWidth,
332
+ 'margin-left': 'auto',
333
+ 'margin-right': 'auto',
334
+ ...moved
335
+ }
336
+ };
337
+ };