@akinon/pz-theme 2.0.78-rc.2 → 2.0.79-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 (46) hide show
  1. package/CHANGELOG.md +10 -37
  2. package/package.json +4 -4
  3. package/src/__tests__/custom-catalog-announce.test.ts +73 -0
  4. package/src/chrome/__tests__/chrome-mode.test.ts +174 -0
  5. package/src/chrome/chrome-mode.ts +61 -0
  6. package/src/custom/README-sections.md +289 -0
  7. package/src/custom/README.md +7 -0
  8. package/src/custom/__tests__/custom-section-bindings.test.tsx +327 -0
  9. package/src/custom/__tests__/define-section.test.tsx +481 -0
  10. package/src/custom/__tests__/module-boundaries.test.ts +2 -0
  11. package/src/custom/__tests__/public-api.test.tsx +123 -0
  12. package/src/custom/__tests__/region-node-wire.test.tsx +302 -0
  13. package/src/custom/__tests__/region-nodes.test.ts +161 -0
  14. package/src/custom/__tests__/theme-editable.test.tsx +22 -10
  15. package/src/custom/__tests__/theme-region.test.tsx +331 -0
  16. package/src/custom/__tests__/theme-slot.test.tsx +18 -10
  17. package/src/custom/__tests__/use-theme-setting.test.tsx +17 -9
  18. package/src/custom/block-context.ts +53 -10
  19. package/src/custom/block-host.tsx +13 -1
  20. package/src/custom/collect.ts +26 -5
  21. package/src/custom/define-section.tsx +163 -0
  22. package/src/custom/index.ts +83 -1
  23. package/src/custom/region-nodes.ts +72 -0
  24. package/src/custom/register.ts +419 -48
  25. package/src/custom/section-catalog.ts +98 -0
  26. package/src/custom/section-definitions.ts +59 -0
  27. package/src/custom/section-host.tsx +125 -0
  28. package/src/custom/section-types.ts +158 -0
  29. package/src/custom/theme-editable.tsx +3 -1
  30. package/src/custom/theme-region.tsx +213 -0
  31. package/src/custom/theme-slot.tsx +8 -7
  32. package/src/custom/types.ts +10 -1
  33. package/src/custom/use-theme-setting.ts +6 -4
  34. package/src/hooks/use-designer-features.tsx +41 -0
  35. package/src/index.ts +25 -1
  36. package/src/placeholder-registry.ts +34 -5
  37. package/src/sections/__tests__/custom-renderer-section-types.test.ts +29 -0
  38. package/src/sections/__tests__/section-part-selection.test.tsx +277 -0
  39. package/src/sections/custom-renderer-section-types.ts +19 -0
  40. package/src/sections/section-wrapper.tsx +12 -1
  41. package/src/theme-section.tsx +162 -3
  42. package/src/utils/__tests__/docked-state-styles.test.ts +249 -0
  43. package/src/utils/__tests__/section-content-width.test.ts +28 -0
  44. package/src/utils/__tests__/section-max-width.test.ts +53 -0
  45. package/src/utils/index.ts +31 -3
  46. package/src/utils/part-styles.ts +17 -1
@@ -6,6 +6,7 @@ import { twMerge } from 'tailwind-merge';
6
6
  import clsx from 'clsx';
7
7
  import { useEffect, useMemo, useRef } from 'react';
8
8
  import SectionWrapper from './sections/section-wrapper';
9
+ import { resolveSectionPartName } from './hooks/use-designer-features';
9
10
  import sectionRendererRegistry from './sections/section-renderer-registry';
10
11
  import {
11
12
  buildIteratorBlock,
@@ -22,6 +23,10 @@ import {
22
23
  import { safeDeepClone } from './utils/safe-clone';
23
24
 
24
25
  import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
26
+ import { DesignerPlaceholder } from './blocks/product-block-utils';
27
+ import { CustomSectionHost } from './custom/section-host';
28
+ import { getCustomSectionDefinition } from './custom/section-definitions';
29
+ import { isCustomThemeType } from './custom/types';
25
30
  import { ThemeSectionDataProvider } from './theme-section-data-context';
26
31
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
27
32
  export interface Section {
@@ -159,12 +164,22 @@ export default function ThemeSection({
159
164
  e.stopPropagation();
160
165
  onSelect(effectiveSection.id);
161
166
 
167
+ // The same helper the wrapper uses. A generic section carries no
168
+ // developer-authored parts today, but the two paths posting different
169
+ // payload shapes for one message is how a receiver ends up written
170
+ // against whichever one its author happened to read.
171
+ const partName = resolveSectionPartName(
172
+ e.target as Element | null,
173
+ e.currentTarget as Element
174
+ );
175
+
162
176
  if (window.parent) {
163
177
  postToEditor({
164
178
  type: 'SELECT_SECTION',
165
179
  data: {
166
180
  placeholderId,
167
- sectionId: effectiveSection.id
181
+ sectionId: effectiveSection.id,
182
+ ...(partName ? { partName } : {})
168
183
  }
169
184
  });
170
185
  }
@@ -456,6 +471,135 @@ export default function ThemeSection({
456
471
  return null;
457
472
  };
458
473
 
474
+ // A DEVELOPER-AUTHORED SECTION WINS OVER EVERYTHING BELOW, including the tab
475
+ // hatch two lines down. That hatch claims any section containing a `tab`
476
+ // block, and a custom section's regions may legitimately contain one — a tab
477
+ // strip inside a brand's own product page is ordinary content, and letting
478
+ // TabsSection take the whole section over it would replace the brand's markup
479
+ // with a tab strip and nothing would say why.
480
+ //
481
+ // It renders inside the SAME `SectionWrapper` a registered renderer gets.
482
+ // That wrapper is what supplies `<section data-section-id>`, the designer
483
+ // outline, the action toolbar and the `data-section-content` element the
484
+ // content-width rule targets — i.e. it is exactly what gives a custom section
485
+ // the full section root panel, which is the whole reason this is a section
486
+ // rather than a block.
487
+ //
488
+ // The blocks inside it get the SAME resolution the generic loop gives its own
489
+ // top-level blocks. This branch used to skip it on the reasoning that no
490
+ // registered renderer resolves bindings either — which read the situation
491
+ // backwards. A registered renderer draws its own content; a custom section's
492
+ // regions and slots hold the MERCHANT's blocks, the same blocks they authored
493
+ // under a generic section root. Skipping the pass meant a `{{...}}` token, a
494
+ // `properties.dataBinding` and an `isIterator` block all fell back to their
495
+ // authored value the moment a brand rewrote a working composed header as a
496
+ // custom section — no console output in the designer, none on the storefront,
497
+ // nothing in the diff. `useThemePageContext()` is still how the section's own
498
+ // JSX reads commerce data; it was never a route for the merchant's blocks.
499
+ if (isCustomThemeType(effectiveSection.type)) {
500
+ const sectionDefinition = getCustomSectionDefinition(
501
+ effectiveSection.type
502
+ );
503
+
504
+ if (!sectionDefinition) {
505
+ devWarn(
506
+ 'ThemeSection',
507
+ `No renderer registered for custom section type: "${effectiveSection.type}". ` +
508
+ `The composition holds a section this build cannot draw — check that the barrel calling ` +
509
+ `registerCustomSections() is imported from the CLIENT ROOT, and that the section name ` +
510
+ `still matches. Nothing is rendered in its place.`
511
+ );
512
+
513
+ // Never the generic block loop. A section whose type this build does not
514
+ // register would otherwise spill its seeded region blocks onto the page
515
+ // as loose top-level content, in an order the developer never chose.
516
+ // The designer still gets a selectable, deletable band.
517
+ return isDesigner ? (
518
+ <SectionWrapper
519
+ section={effectiveSection}
520
+ placeholderId={placeholderId}
521
+ isDesigner={isDesigner}
522
+ isSelected={isSelected}
523
+ onSelect={onSelect}
524
+ onMoveUp={onMoveUp}
525
+ onMoveDown={onMoveDown}
526
+ onDuplicate={onDuplicate}
527
+ onToggleVisibility={onToggleVisibility}
528
+ onDelete={onDelete}
529
+ onRename={onRename}
530
+ >
531
+ <DesignerPlaceholder
532
+ label={`${effectiveSection.label || effectiveSection.type} — this build does not register this section`}
533
+ />
534
+ </SectionWrapper>
535
+ ) : null;
536
+ }
537
+
538
+ // ONE RESOLVED BLOCK PER AUTHORED BLOCK, and the top-level count must not
539
+ // change. `findRegionChild` matches by `properties.regionName` over
540
+ // `section.blocks`, so a pass that spliced siblings in would slide every
541
+ // later region onto the wrong block. `resolveIteratorsRecursively` and
542
+ // `resolveBlockBindings` are both block-in/block-out — a repeat is expanded
543
+ // INSIDE the iterator block, never beside it — so `.map` is the whole
544
+ // guarantee. Written as a plain `.map` rather than `useMemo` because this
545
+ // sits after a conditional return and a hook here would be conditional.
546
+ //
547
+ // BINDINGS RUN UNCONDITIONALLY, where the generic path below gates them on
548
+ // `shouldApplyGenericBindings`. That gate asks "is this section type
549
+ // literally `custom`, or is there a page-context source, or a page
550
+ // context?" — and a `custom:vendor:name` type is not the string `custom`,
551
+ // so reusing the gate would silently drop bindings for a custom section
552
+ // whose data comes from a collection or static source. The gate exists to
553
+ // avoid resolving where there is nothing to resolve against, and
554
+ // `resolveBlockBindings` already answers that itself: with no binding root
555
+ // it walks the tree and returns it unchanged. Unconditional is therefore
556
+ // the reading that keeps a merchant's binding working wherever it worked
557
+ // before the area was rewritten, which is the regression this fixes.
558
+ const resolvedSection = {
559
+ ...effectiveSection,
560
+ blocks: effectiveSection.blocks.map((block) =>
561
+ resolveBlockBindings({
562
+ block: resolveIteratorsRecursively(block),
563
+ sectionDataSource: effectiveSection.dataSource,
564
+ pageContext,
565
+ isDesigner,
566
+ currency: storefrontCurrency
567
+ })
568
+ )
569
+ };
570
+
571
+ return (
572
+ <SectionWrapper
573
+ // The wrapper reads the section's id, type, label and styles and never
574
+ // its children, so it keeps the object it always had; only the host,
575
+ // which is what `ThemeRegion`/`ThemeSlot` read their children out of,
576
+ // is handed the resolved one.
577
+ section={effectiveSection}
578
+ placeholderId={placeholderId}
579
+ isDesigner={isDesigner}
580
+ isSelected={isSelected}
581
+ onSelect={onSelect}
582
+ onMoveUp={onMoveUp}
583
+ onMoveDown={onMoveDown}
584
+ onDuplicate={onDuplicate}
585
+ onToggleVisibility={onToggleVisibility}
586
+ onDelete={onDelete}
587
+ onRename={onRename}
588
+ >
589
+ <CustomSectionHost
590
+ type={effectiveSection.type}
591
+ label={effectiveSection.label || effectiveSection.type}
592
+ definition={sectionDefinition}
593
+ section={resolvedSection}
594
+ placeholderId={placeholderId}
595
+ isDesigner={Boolean(isDesigner)}
596
+ selectedBlockId={selectedBlockId}
597
+ currentBreakpoint={currentBreakpoint}
598
+ />
599
+ </SectionWrapper>
600
+ );
601
+ }
602
+
459
603
  const hasTabBlocks = effectiveSection.blocks.some(
460
604
  (block) => block.type === 'tab'
461
605
  );
@@ -515,7 +659,22 @@ export default function ThemeSection({
515
659
  );
516
660
  }
517
661
 
518
- const resolveIteratorsRecursively = (block: Block): Block => {
662
+ /*
663
+ * A FUNCTION DECLARATION, NOT A `const` ARROW, AND THAT IS LOAD-BEARING.
664
+ *
665
+ * The developer-authored-section branch above needs this resolver, and that
666
+ * branch has to stay ABOVE the `hasTabBlocks` hatch: the hatch claims any
667
+ * section holding a `tab` block, and a custom section's regions may
668
+ * legitimately hold one. Moving the custom return down here to reach the
669
+ * resolver would hand a brand's own product page to TabsSection; moving this
670
+ * body up would relocate ~450 lines of related/upsell normalisation into the
671
+ * middle of the branch chain. A declaration is hoisted to the top of the
672
+ * component body, so the branch can call it where it stands and neither
673
+ * order has to change. Everything it closes over — `effectiveSection`,
674
+ * `pageContext`, `isDesigner`, `currentBreakpoint` — is initialised long
675
+ * before either call site, so there is no temporal-dead-zone read.
676
+ */
677
+ function resolveIteratorsRecursively(block: Block): Block {
519
678
  const normalizedType = String(effectiveSection.type || '').toLowerCase();
520
679
  const normalizedVariant = String(
521
680
  effectiveSection.properties?.dataSourceVariant || ''
@@ -965,7 +1124,7 @@ export default function ThemeSection({
965
1124
  ...resolvedBlock,
966
1125
  blocks: resolvedBlock.blocks.map(resolveIteratorsRecursively)
967
1126
  };
968
- };
1127
+ }
969
1128
 
970
1129
  return (
971
1130
  <ThemeSectionDataProvider items={sectionDataItems}>
@@ -0,0 +1,249 @@
1
+ import { generateThemeCSS } from '../index';
2
+ import {
3
+ PART_STYLE_STATES,
4
+ buildPartSelectors,
5
+ buildRootStateSelectors,
6
+ parseRootStateKey,
7
+ splitPartStyles
8
+ } from '../part-styles';
9
+ import type { Section } from '../../theme-section';
10
+
11
+ /**
12
+ * `docked` — the third page-level style state, beside `scrolled` and
13
+ * `overlay-open`.
14
+ *
15
+ * It exists for the header with two appearances: over the home page's hero it
16
+ * is transparent with light content, and everywhere else it sits above the
17
+ * page, solid, with dark content. Which one applies is a fact about the ROUTE,
18
+ * and a route is the one thing a composition cannot ask about — so the brand
19
+ * stamps `data-chrome-mode` on `body` and the composition styles against it.
20
+ *
21
+ * The thing worth pinning is not that the state works. It is the `body` in its
22
+ * selector.
23
+ *
24
+ * ON AN INNER PAGE BOTH STATES MATCH AT ONCE. The shopper scrolls a docked
25
+ * header, so `[data-scroll-state~="scrolled"]` and `body[data-chrome-mode~=
26
+ * "docked"]` are both true of the same band, and both rules set the same
27
+ * property. Every other ancestor-matched state weighs (0,2,0) against an
28
+ * element selector; the `body` makes this one (0,2,1), so the mode wins on
29
+ * specificity rather than on whichever rule the generator happened to write
30
+ * last. That is the right answer and not merely a chosen one: `scrolled`
31
+ * describes how far the page has slid under a bar that floats over it, and once
32
+ * the bar is docked the question has no meaning.
33
+ *
34
+ * WHY THERE IS NO `getComputedStyle` TEST BELOW, and why adding one would make
35
+ * this file worse: jsdom has no cascade worth the name. Its `getComputedStyle`
36
+ * resolves competing rules by SOURCE ORDER ALONE and ignores specificity
37
+ * entirely — measured here, an `#id` rule loses to a later `div` rule. A
38
+ * cascade test would therefore assert the opposite of what a browser does, pass
39
+ * today for the wrong reason, and keep passing after someone deleted the very
40
+ * qualifier it was written to protect. So the weight is asserted directly.
41
+ */
42
+
43
+ const DOCKED = 'body[data-chrome-mode~="docked"]';
44
+ const SCROLLED = '[data-scroll-state~="scrolled"]';
45
+ const SECTION = '[data-section-id="sec-1"]';
46
+
47
+ const section = (
48
+ id: string,
49
+ styles: Record<string, unknown>,
50
+ blocks: unknown[] = []
51
+ ) =>
52
+ ({
53
+ id,
54
+ type: 'custom',
55
+ properties: {},
56
+ styles,
57
+ order: 0,
58
+ hidden: false,
59
+ blocks
60
+ }) as unknown as Section;
61
+
62
+ const block = (id: string, styles: Record<string, unknown>, type = 'group') => ({
63
+ id,
64
+ type,
65
+ label: 'Block',
66
+ properties: {},
67
+ styles,
68
+ order: 0,
69
+ hidden: false,
70
+ blocks: []
71
+ });
72
+
73
+ /**
74
+ * Weighs the two selectors this file is about, and nothing else.
75
+ *
76
+ * Deliberately naive: it counts attribute selectors and leading type selectors,
77
+ * which is all either selector contains. A general specificity parser would be
78
+ * a second implementation to get wrong, and the honest alternative — asking the
79
+ * DOM — does not exist here (see the note above).
80
+ */
81
+ const weigh = (selector: string) => ({
82
+ attributes: (selector.match(/\[/g) || []).length,
83
+ types: (selector.match(/(^|[\s>+~])[a-z][a-z0-9-]*/g) || []).length
84
+ });
85
+
86
+ afterEach(() => {
87
+ document.body.removeAttribute('data-chrome-mode');
88
+ document.body.innerHTML = '';
89
+ });
90
+
91
+ describe('the `body` qualifier is load-bearing — do not tidy it away', () => {
92
+ it('keeps `body` in the docked selector, which is the only thing that outranks scrolled', () => {
93
+ const [selector] = buildRootStateSelectors(SECTION, 'docked');
94
+
95
+ // Asserted as literal text, because the qualifier reads like a redundant
96
+ // ancestor to anyone who has not read why it is there.
97
+ expect(selector).toBe(`${DOCKED} ${SECTION}`);
98
+ });
99
+
100
+ it('outweighs the scrolled selector, which it ties with on attributes alone', () => {
101
+ const [docked] = buildRootStateSelectors(SECTION, 'docked');
102
+ const [scrolled] = buildRootStateSelectors(SECTION, 'scrolled');
103
+
104
+ // Same attribute count: this is the tie, and it is why order would decide.
105
+ expect(weigh(docked).attributes).toBe(weigh(scrolled).attributes);
106
+ // The type selector is the entire margin of victory.
107
+ expect(weigh(docked).types).toBeGreaterThan(weigh(scrolled).types);
108
+ });
109
+
110
+ it('matches the same band as scrolled on an inner page, so the tie is real', () => {
111
+ // Not hypothetical: a docked header is scrolled the moment the shopper
112
+ // moves, and both rules then target one element and one property.
113
+ document.body.setAttribute('data-chrome-mode', 'docked');
114
+ document.body.innerHTML =
115
+ '<div data-chrome-slot="header" data-scroll-state="scrolled">' +
116
+ '<div data-section-id="sec-1"></div>' +
117
+ '</div>';
118
+
119
+ const [docked] = buildRootStateSelectors(SECTION, 'docked');
120
+ const [scrolled] = buildRootStateSelectors(SECTION, 'scrolled');
121
+
122
+ expect(document.querySelectorAll(docked)).toHaveLength(1);
123
+ expect(document.querySelectorAll(scrolled)).toHaveLength(1);
124
+ });
125
+
126
+ it('matches nothing when the brand stamps nothing, so overlay stays the default', () => {
127
+ // Every composition published before this state existed carries no
128
+ // attribute, and has to render exactly as it did.
129
+ document.body.innerHTML = '<div data-section-id="sec-1"></div>';
130
+
131
+ const [docked] = buildRootStateSelectors(SECTION, 'docked');
132
+
133
+ expect(document.querySelectorAll(docked)).toHaveLength(0);
134
+ });
135
+
136
+ it('matches whole tokens only, so `undocked` is not docked', () => {
137
+ document.body.setAttribute('data-chrome-mode', 'undocked');
138
+ document.body.innerHTML = '<div data-section-id="sec-1"></div>';
139
+
140
+ const [docked] = buildRootStateSelectors(SECTION, 'docked');
141
+
142
+ expect(document.querySelectorAll(docked)).toHaveLength(0);
143
+ });
144
+ });
145
+
146
+ describe('the generated CSS', () => {
147
+ it('emits both states for one band, with docked written FIRST', () => {
148
+ const css = generateThemeCSS([
149
+ section('sec-1', {
150
+ 'background-color': 'transparent',
151
+ // Declared before `scrolled` on purpose: source order favours the
152
+ // scrolled rule, so if this pair ever came out the wrong way round in a
153
+ // browser, order is not what would be saving it.
154
+ ':docked/background-color': '#ffffff',
155
+ ':scrolled/background-color': '#f5f5f5'
156
+ })
157
+ ]);
158
+
159
+ expect(css).toContain(
160
+ `${DOCKED} ${SECTION} {\n background-color: rgba(255, 255, 255, 1);\n}`
161
+ );
162
+ expect(css).toContain(
163
+ `${SCROLLED} ${SECTION} {\n background-color: rgba(245, 245, 245, 1);\n}`
164
+ );
165
+
166
+ expect(css.indexOf(`${DOCKED} ${SECTION}`)).toBeLessThan(
167
+ css.indexOf(`${SCROLLED} ${SECTION}`)
168
+ );
169
+ });
170
+
171
+ it('emits a docked rule for a block wrapper too', () => {
172
+ const css = generateThemeCSS([
173
+ section('sec-1', {}, [block('bar', { ':docked/color': '#4a0e26' })])
174
+ ]);
175
+
176
+ expect(css).toContain(
177
+ `${DOCKED} [data-block-id="bar"] {\n color: #4a0e26;\n}`
178
+ );
179
+ });
180
+
181
+ it('never emits the raw key as a declaration', () => {
182
+ const css = generateThemeCSS([
183
+ section('sec-1', { ':docked/background-color': '#ffffff' })
184
+ ]);
185
+
186
+ expect(css).not.toContain(':docked/background-color:');
187
+ });
188
+ });
189
+
190
+ describe('docked as a part state', () => {
191
+ it('conditions a part on the page without losing the ownership guard', () => {
192
+ const [selector] = buildPartSelectors('[data-section-id="sec-1"]', {
193
+ part: 'bar',
194
+ state: 'docked',
195
+ styles: {}
196
+ });
197
+
198
+ // The whole compound, guard included, is substituted into the ancestor
199
+ // pattern — so a docked rule can no more reach into a nested block than the
200
+ // stateless one can.
201
+ //
202
+ // Asserted as text rather than by querying the DOM on purpose: the guard
203
+ // uses `:where()`, and the nwsapi build in this workspace's node_modules
204
+ // cannot parse it (it is the cause of the three known-red suites here). A
205
+ // DOM assertion would fail for a reason that has nothing to do with docked.
206
+ expect(selector).toBe(
207
+ `${DOCKED} [data-section-id="sec-1"] [data-part="bar"]` +
208
+ ':not(:where([data-section-id="sec-1"] [data-block-id] *))'
209
+ );
210
+ });
211
+
212
+ it('parses `bar/docked/color` as a part key and buckets it', () => {
213
+ const { root, parts } = splitPartStyles({
214
+ color: '#ffffff',
215
+ 'bar/docked/color': '#4a0e26'
216
+ });
217
+
218
+ expect(root).toEqual({ color: '#ffffff' });
219
+ expect(parts).toEqual([
220
+ { part: 'bar', state: 'docked', styles: { color: '#4a0e26' } }
221
+ ]);
222
+ });
223
+
224
+ it('emits the part rule through the generator', () => {
225
+ const css = generateThemeCSS([
226
+ section('sec-1', { 'bar/docked/color': '#4a0e26' })
227
+ ]);
228
+
229
+ expect(css).toContain(
230
+ `${DOCKED} [data-section-id="sec-1"] [data-part="bar"]`
231
+ );
232
+ });
233
+ });
234
+
235
+ describe('the vocabulary', () => {
236
+ it('lists docked in PART_STYLE_STATES, so a brand may declare it', () => {
237
+ // `defineCustomSection` validates declared part states against this list
238
+ // and drops anything absent, so a state missing from it is a control that
239
+ // silently does nothing.
240
+ expect(PART_STYLE_STATES).toContain('docked');
241
+ });
242
+
243
+ it('accepts `:docked/<prop>` as a root state key on any element', () => {
244
+ expect(parseRootStateKey(':docked/background-color')).toEqual({
245
+ state: 'docked',
246
+ property: 'background-color'
247
+ });
248
+ });
249
+ });
@@ -258,6 +258,34 @@ describe('a section that arranges its children keeps arranging them', () => {
258
258
  expect(ruleFor(css, BAND_SELECTOR)).toContain('display: flex');
259
259
  expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1024px');
260
260
  });
261
+
262
+ it('moves nothing on a developer-authored section either', () => {
263
+ // The half of the split that is NOT the max-width half. A brand component
264
+ // draws its own root, so moving `display`/`flex-direction` onto the content
265
+ // wrapper would make that root a flex ITEM and silently re-lay-out markup
266
+ // the developer wrote by hand — while the Max Width token beside it still
267
+ // has to be resolved here, because the component has never heard of it.
268
+ const css = generateThemeCSS(
269
+ section(
270
+ {
271
+ 'content-width': '1024px',
272
+ 'max-width': 'narrow',
273
+ display: 'flex',
274
+ 'flex-direction': 'column'
275
+ },
276
+ { type: 'custom:acme:hero' }
277
+ )
278
+ );
279
+
280
+ const band = ruleFor(css, BAND_SELECTOR);
281
+ expect(band).toContain('display: flex');
282
+ expect(band).toContain('flex-direction: column');
283
+ expect(band).toContain('max-width: 56rem');
284
+
285
+ const content = ruleFor(css, CONTENT_SELECTOR);
286
+ expect(content).toContain('max-width: 1024px');
287
+ expect(content).not.toContain('display: flex');
288
+ });
261
289
  });
262
290
 
263
291
  describe('the setting is additive', () => {
@@ -135,6 +135,59 @@ describe('a custom-renderer section keeps resolving its own max-width', () => {
135
135
  });
136
136
  });
137
137
 
138
+ /**
139
+ * A DEVELOPER-AUTHORED SECTION SPLITS THE ONE PREDICATE IN TWO.
140
+ *
141
+ * "Owns the box its children sit in" and "translates the max-width token
142
+ * itself" were the same question for all 18 built-in renderers, so one boolean
143
+ * answered both. A brand component answers them differently: it draws a real
144
+ * container, so the child-layout keys stay on the band — but it has never heard
145
+ * of `narrow`, so nothing downstream would turn the token into a length.
146
+ */
147
+ describe('a developer-authored section cannot translate the token itself', () => {
148
+ const brandSection = (styles: Record<string, unknown>) =>
149
+ section(styles, { type: 'custom:acme:hero' });
150
+
151
+ it('resolves the token to a real length rather than emitting a dropped declaration', () => {
152
+ // Left unresolved this emits `max-width: narrow`, the browser drops the
153
+ // declaration, and the section's Max Width control does nothing at all —
154
+ // the exact failure `resolveSectionMaxWidthTokens` exists to fix,
155
+ // reintroduced on the newest surface.
156
+ const css = generateThemeCSS(brandSection({ 'max-width': 'narrow' }));
157
+
158
+ expect(css).toContain('max-width: 56rem');
159
+ expect(css).not.toContain('max-width: narrow');
160
+ });
161
+
162
+ it('centres it, because nothing else will', () => {
163
+ // A built-in renderer pairs the token with `mx-auto` on the container it
164
+ // owns. A brand component pairs it with nothing, so the auto margins have
165
+ // to come from here or a constrained band sits against the left edge.
166
+ const css = generateThemeCSS(brandSection({ 'max-width': 'normal' }));
167
+
168
+ expect(css).toContain('max-width: 80rem');
169
+ expect(css).toContain('margin-left: auto');
170
+ expect(css).toContain('margin-right: auto');
171
+ });
172
+
173
+ it('resolves per breakpoint, inside the media query', () => {
174
+ const css = generateThemeCSS(
175
+ brandSection({ 'max-width': { desktop: 'narrow', mobile: 'none' } })
176
+ );
177
+
178
+ expect(css).toContain('max-width: 56rem');
179
+ expect(css).toContain('max-width: none');
180
+ expect(css).not.toContain('max-width: narrow');
181
+ });
182
+
183
+ it('still leaves a real CSS length the author typed alone', () => {
184
+ const css = generateThemeCSS(brandSection({ 'max-width': '640px' }));
185
+
186
+ expect(css).toContain('max-width: 640px');
187
+ expect(css).not.toContain('margin-left: auto');
188
+ });
189
+ });
190
+
138
191
  describe('the scale is one table, not two', () => {
139
192
  // The class and the length are the same measurement: Tailwind's max-w-4xl is
140
193
  // 56rem and max-w-7xl is 80rem. A test would not catch them drifting if they
@@ -18,6 +18,7 @@ import {
18
18
  stripHidingStyles
19
19
  } from './checkout-safety';
20
20
  import { hasBindingToken } from './iterator-utils';
21
+ import { isCustomThemeType } from '../custom/types';
21
22
  import {
22
23
  BREAKPOINT_FALLBACKS,
23
24
  MOBILE_MEDIA_QUERY,
@@ -707,6 +708,14 @@ interface Block {
707
708
 
708
709
  interface Section {
709
710
  id: string;
711
+ /**
712
+ * Read to decide whether the section owns the box its children sit in: a
713
+ * registered renderer, the tab hatch, or a developer-authored
714
+ * `custom:<vendor>:<name>` type. Optional because published JSON written by
715
+ * an older editor may omit it, and `hasCustomSectionRenderer` already treats
716
+ * a missing type as "generic".
717
+ */
718
+ type?: string;
710
719
  styles?: ResponsiveStyles;
711
720
  properties?: Record<string, unknown>;
712
721
  blocks?: Block[];
@@ -1467,7 +1476,26 @@ export const generateThemeCSS = (
1467
1476
  const { root: sectionRoot } = splitPartStyles(sectionStyles);
1468
1477
 
1469
1478
  if (Object.keys(sectionRoot).length > 0) {
1470
- const customRenderer = hasCustomSectionRenderer(section);
1479
+ // ONE PREDICATE, TWO QUESTIONS, AND A DEVELOPER-AUTHORED SECTION
1480
+ // ANSWERS THEM DIFFERENTLY.
1481
+ //
1482
+ // "Owns the box its children sit in" decides whether the child-layout
1483
+ // keys stay on the band. True for a custom section: its component draws
1484
+ // a real container, so moving `display`/`flex-direction`/`gap` onto the
1485
+ // content wrapper would make the developer's own root a flex item and
1486
+ // silently re-lay-out the authored markup.
1487
+ //
1488
+ // "Translates the max-width token itself" is the opposite. Each of the
1489
+ // 18 built-in renderers turns `narrow`/`normal` into a real length on
1490
+ // the container it owns. A brand component has never heard of the
1491
+ // token, so leaving it unresolved would emit `max-width: narrow`, the
1492
+ // browser would drop the declaration, and the section's Max Width
1493
+ // control would do nothing — with the auto-centring never firing
1494
+ // either. That is the exact failure `resolveSectionMaxWidthTokens`
1495
+ // exists to fix, and it would be reintroduced on the newest surface.
1496
+ const ownsContentBox = hasCustomSectionRenderer(section);
1497
+ const resolvesOwnMaxWidth =
1498
+ ownsContentBox && !isCustomThemeType(section.type);
1471
1499
 
1472
1500
  // The content width leaves the band before anything else is decided
1473
1501
  // about it: it is not a declaration a section ever carries, and on the
@@ -1475,13 +1503,13 @@ export const generateThemeCSS = (
1475
1503
  // utils/section-content-width.ts for why that move exists.
1476
1504
  const { section: band, content } = splitSectionContentWidth(
1477
1505
  sectionRoot,
1478
- { moveLayout: !customRenderer }
1506
+ { moveLayout: !ownsContentBox }
1479
1507
  );
1480
1508
 
1481
1509
  if (Object.keys(band).length > 0) {
1482
1510
  css += generateElementCSS(
1483
1511
  sectionSelector,
1484
- customRenderer ? band : resolveSectionMaxWidthTokens(band),
1512
+ resolvesOwnMaxWidth ? band : resolveSectionMaxWidthTokens(band),
1485
1513
  targetBreakpoint
1486
1514
  );
1487
1515
  }
@@ -81,7 +81,23 @@ const STATE_SELECTORS: Record<string, string[]> = {
81
81
  // hero, opaque while its menu panel is open. The panel cannot express it —
82
82
  // the header is not inside the panel — and the header cannot know it without
83
83
  // the page saying so.
84
- 'overlay-open': ['[data-overlay-state~="open"] &']
84
+ 'overlay-open': ['[data-overlay-state~="open"] &'],
85
+ /**
86
+ * The third page-level state: the chrome is DOCKED above the page rather than
87
+ * sitting over it. `body` carries `data-chrome-mode` (see
88
+ * chrome/chrome-mode.ts), stamped by the brand, because only the brand knows
89
+ * which of its routes carry a hero.
90
+ *
91
+ * THE `body` QUALIFIER IS A DELIBERATE SPECIFICITY BUMP, not tidiness. Every
92
+ * other ancestor-matched state weighs (0,2,0) against an element selector,
93
+ * which ties with `scrolled` — and on an inner page BOTH match at once, so a
94
+ * tie would be settled by whichever declaration the generator happened to
95
+ * emit last. Adding the element name makes this (0,2,1), so the mode wins.
96
+ * That is the correct answer rather than an arbitrary one: `scrolled`
97
+ * describes how far the page has moved under a bar that floats over it, and
98
+ * once the bar is docked that question no longer has a meaning.
99
+ */
100
+ docked: ['body[data-chrome-mode~="docked"] &']
85
101
  };
86
102
 
87
103
  export const PART_STYLE_STATES = Object.keys(STATE_SELECTORS);