@akinon/pz-theme 2.0.78-rc.1 → 2.0.78

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 +12 -39
  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
@@ -0,0 +1,481 @@
1
+ import React from 'react';
2
+
3
+ import blockRendererRegistry from '../../blocks/block-renderer-registry';
4
+ import { resetCustomCatalog } from '../catalog';
5
+ import { resetCustomCollection } from '../collect';
6
+ import { defineCustomBlock } from '../define';
7
+ import {
8
+ CUSTOM_SECTION_MARKER,
9
+ defineCustomSection,
10
+ isCustomSectionDefinition
11
+ } from '../define-section';
12
+ import { registerCustomBlocks, registerCustomSections } from '../register';
13
+ import {
14
+ getCustomSectionDescriptor,
15
+ getCustomSectionTypes,
16
+ resetCustomSectionCatalog
17
+ } from '../section-catalog';
18
+ import {
19
+ getCustomSectionDefinition,
20
+ hasCustomSectionDefinition,
21
+ resetCustomSectionDefinitions
22
+ } from '../section-definitions';
23
+ import { CUSTOM_SECTION_LIMITS } from '../section-types';
24
+ import { isCustomThemeType } from '../types';
25
+
26
+ /**
27
+ * Neither registry has an `unregister` — a build registers once and a
28
+ * storefront never takes a type back. A test file registers dozens of times
29
+ * into that same module state, so it reaches the private Map directly, exactly
30
+ * as define.test.tsx does.
31
+ */
32
+ const rendererMap = (): Map<string, unknown> =>
33
+ (blockRendererRegistry as unknown as { renderers: Map<string, unknown> })
34
+ .renderers;
35
+
36
+ const clearCustomRenderers = (): void => {
37
+ const map = rendererMap();
38
+ Array.from(map.keys())
39
+ .filter(isCustomThemeType)
40
+ .forEach((type) => map.delete(type));
41
+ };
42
+
43
+ const Header = () => <header>header</header>;
44
+ const ProductPage = () => <main>product</main>;
45
+
46
+ const HEADER = 'custom:acme:header';
47
+
48
+ const headerDescriptor = () => getCustomSectionDescriptor(HEADER);
49
+
50
+ beforeEach(() => {
51
+ resetCustomCatalog();
52
+ resetCustomCollection();
53
+ resetCustomSectionCatalog();
54
+ resetCustomSectionDefinitions();
55
+ clearCustomRenderers();
56
+ });
57
+
58
+ describe('defineCustomSection', () => {
59
+ it('tags the wrapper with a string marker, not a Symbol', () => {
60
+ const definition = defineCustomSection(Header, { label: 'Header' });
61
+
62
+ // A brand can resolve two physical copies of pz-theme at once. A
63
+ // `Symbol()` per copy would make one copy read the other's definition as a
64
+ // bare component and silently drop every option declared here.
65
+ expect(definition.$$pzCustomSection).toBe(CUSTOM_SECTION_MARKER);
66
+ expect(typeof CUSTOM_SECTION_MARKER).toBe('string');
67
+ expect(isCustomSectionDefinition(definition)).toBe(true);
68
+ expect(isCustomSectionDefinition(Header)).toBe(false);
69
+ });
70
+
71
+ it('accepts a component wrapped in memo, which is ordinary React', () => {
72
+ const definition = defineCustomSection(
73
+ React.memo(Header) as unknown as typeof Header
74
+ );
75
+
76
+ expect(isCustomSectionDefinition(definition)).toBe(true);
77
+ });
78
+
79
+ it('throws on a value that could never render, naming what it received', () => {
80
+ expect(() =>
81
+ defineCustomSection(undefined as unknown as typeof Header)
82
+ ).toThrow(/expects a React component, received undefined/);
83
+ });
84
+
85
+ it('refuses a record entry that is not a component at all', () => {
86
+ // The shape a missing default export arrives in: `{ header: undefined }`
87
+ // from a barrel whose file exports nothing. Named at the record key,
88
+ // because that is the only identifier the developer can search for.
89
+ expect(() =>
90
+ registerCustomSections('acme', {
91
+ header: undefined as unknown as typeof Header
92
+ })
93
+ ).toThrow(/Custom section "header" is neither a React component/);
94
+ });
95
+ });
96
+
97
+ describe('registerCustomSections — the offered-implies-renderable invariant', () => {
98
+ it('puts every registered section in BOTH stores, and nothing else in either', () => {
99
+ registerCustomSections('acme', {
100
+ header: defineCustomSection(Header, { label: 'ZSA header' }),
101
+ // A bare component: someone will forget the wrapper, and a section with
102
+ // no declared options is a complete section.
103
+ 'product-page': ProductPage
104
+ });
105
+
106
+ const expected = new Set([HEADER, 'custom:acme:product-page']);
107
+
108
+ expect(new Set(getCustomSectionTypes())).toEqual(expected);
109
+ expect(hasCustomSectionDefinition(HEADER)).toBe(true);
110
+ // A type in the catalog and not in the definitions is a section the editor
111
+ // offers and the storefront cannot draw; the reverse is one the merchant
112
+ // can never place. Both directions are checked.
113
+ getCustomSectionTypes().forEach((type) => {
114
+ expect(hasCustomSectionDefinition(type)).toBe(true);
115
+ });
116
+ expect(getCustomSectionDefinition('custom:acme:product-page')?.component).toBe(
117
+ ProductPage
118
+ );
119
+ });
120
+
121
+ it('registers a bare component under the record key, not the component name', () => {
122
+ // Minification renames `Header` to a single letter; the record key is the
123
+ // only source that survives a production build.
124
+ registerCustomSections('acme', { header: Header });
125
+
126
+ expect(headerDescriptor()?.name).toBe('header');
127
+ expect(headerDescriptor()?.label).toBe('header');
128
+ });
129
+ });
130
+
131
+ describe('registerCustomSections — one type namespace, two stores', () => {
132
+ it('refuses a type a custom BLOCK already claimed', () => {
133
+ // Blocks and sections share the `custom:<vendor>:<name>` grammar, so
134
+ // nothing downstream can tell them apart from the string alone: the editor
135
+ // resolves a section through its section catalog and a block through its
136
+ // block catalog, and a type in both resolves differently depending on
137
+ // which lookup ran.
138
+ registerCustomBlocks('acme', {
139
+ header: defineCustomBlock(Header, { label: 'Header block' })
140
+ });
141
+
142
+ expect(() => registerCustomSections('acme', { header: Header })).toThrow(
143
+ /already registered in this build, as a block or as a section/
144
+ );
145
+ });
146
+
147
+ it('refuses a type a custom SECTION already claimed, from the BLOCK door', () => {
148
+ // The other direction, and it was one-way for a while: the block door
149
+ // checked only the block stores, so a section registered first and a block
150
+ // of the same type second was silently accepted — leaving one type present
151
+ // in all four stores, resolving as whichever the caller happened to ask.
152
+ // A namespace guarded in one direction is not a namespace.
153
+ registerCustomSections('acme', { header: Header });
154
+
155
+ expect(() =>
156
+ registerCustomBlocks('acme', {
157
+ header: defineCustomBlock(Header, { label: 'Header block' })
158
+ })
159
+ ).toThrow(/already registered in this build, as a block or as a section/);
160
+ });
161
+
162
+ it('refuses a second registration of the same section type', () => {
163
+ registerCustomSections('acme', { header: Header });
164
+
165
+ expect(() =>
166
+ registerCustomSections('acme', { header: ProductPage })
167
+ ).toThrow(/already registered/);
168
+ });
169
+
170
+ it('leaves the first entry unregistered in both stores when a later one is invalid', () => {
171
+ // A half-registered barrel is worse than a rejected one: the Add Section
172
+ // band would offer a section the build can draw and hide one it cannot.
173
+ expect(() =>
174
+ registerCustomSections('acme', {
175
+ header: Header,
176
+ 'product-page': defineCustomSection(ProductPage, {
177
+ regions: [{ name: 'Gallery', block: 'image' }]
178
+ })
179
+ })
180
+ ).toThrow(/"Gallery" is not valid/);
181
+
182
+ expect(getCustomSectionTypes()).toEqual([]);
183
+ expect(hasCustomSectionDefinition(HEADER)).toBe(false);
184
+ });
185
+ });
186
+
187
+ describe('registerCustomSections — naming', () => {
188
+ it('throws on a vendor that is not a valid segment', () => {
189
+ expect(() => registerCustomSections('Acme Inc', { header: Header })).toThrow(
190
+ /vendor "Acme Inc"/
191
+ );
192
+ });
193
+
194
+ it('throws on a section name that is not a valid segment', () => {
195
+ expect(() => registerCustomSections('acme', { Header_Bar: Header })).toThrow(
196
+ /"Header_Bar"/
197
+ );
198
+ });
199
+ });
200
+
201
+ describe('registerCustomSections — declared regions', () => {
202
+ it('carries the name, the block type and the seed through untouched', () => {
203
+ registerCustomSections('acme', {
204
+ header: defineCustomSection(Header, {
205
+ regions: [
206
+ { name: 'logo', label: 'Logo', block: 'image' },
207
+ {
208
+ name: 'basket',
209
+ block: 'mini-basket',
210
+ properties: { showCount: { desktop: true } },
211
+ styles: { 'margin-left': '16px' }
212
+ }
213
+ ]
214
+ })
215
+ });
216
+
217
+ // The name is what `<ThemeRegion name>` matches against the seeded block,
218
+ // and the seed is written straight into the composition, so both have to
219
+ // survive registration verbatim.
220
+ expect(headerDescriptor()?.regions).toEqual([
221
+ { name: 'logo', label: 'Logo', block: 'image' },
222
+ {
223
+ name: 'basket',
224
+ block: 'mini-basket',
225
+ properties: { showCount: { desktop: true } },
226
+ styles: { 'margin-left': '16px' }
227
+ }
228
+ ]);
229
+ });
230
+
231
+ it('throws on a name no seeded block could be matched by', () => {
232
+ expect(() =>
233
+ registerCustomSections('acme', {
234
+ header: defineCustomSection(Header, {
235
+ regions: [{ name: 'Store Search', block: 'search-box' }]
236
+ })
237
+ })
238
+ ).toThrow(/"Store Search" is not valid/);
239
+ });
240
+
241
+ it('throws naming a region declared twice', () => {
242
+ expect(() =>
243
+ registerCustomSections('acme', {
244
+ header: defineCustomSection(Header, {
245
+ regions: [
246
+ { name: 'logo', block: 'image' },
247
+ { name: 'logo', block: 'text' }
248
+ ]
249
+ })
250
+ })
251
+ ).toThrow(/declares the region "logo" twice/);
252
+ });
253
+
254
+ it('throws on a region with no block type — the merchant would see nothing to open', () => {
255
+ expect(() =>
256
+ registerCustomSections('acme', {
257
+ header: defineCustomSection(Header, {
258
+ regions: [{ name: 'logo', block: '' }]
259
+ })
260
+ })
261
+ ).toThrow(/with no block type/);
262
+ });
263
+
264
+ it.each(['properties', 'styles'])(
265
+ 'throws on a %s seed that is not the shape the composition stores',
266
+ (field) => {
267
+ expect(() =>
268
+ registerCustomSections('acme', {
269
+ header: defineCustomSection(Header, {
270
+ regions: [
271
+ {
272
+ name: 'logo',
273
+ block: 'image',
274
+ [field]: ['margin-left', '16px']
275
+ } as never
276
+ ]
277
+ })
278
+ })
279
+ ).toThrow(/is not a plain object/);
280
+ }
281
+ );
282
+
283
+ it('throws when one name is declared as both a region and a slot', () => {
284
+ // Two rows with one name in the layer tree. The resolvers never collide —
285
+ // they read different properties — but the merchant has no way to tell
286
+ // which row is which.
287
+ expect(() =>
288
+ registerCustomSections('acme', {
289
+ header: defineCustomSection(Header, {
290
+ regions: [{ name: 'extras', block: 'image' }],
291
+ slots: [{ name: 'extras' }]
292
+ })
293
+ })
294
+ ).toThrow(/as both a region and a slot/);
295
+ });
296
+ });
297
+
298
+ describe('registerCustomSections — caps', () => {
299
+ const many = <T,>(count: number, build: (index: number) => T): T[] =>
300
+ Array.from({ length: count }, (_, index) => build(index));
301
+
302
+ it('refuses more regions than the editor would seed', () => {
303
+ expect(() =>
304
+ registerCustomSections('acme', {
305
+ header: defineCustomSection(Header, {
306
+ regions: many(CUSTOM_SECTION_LIMITS.regionsPerType + 1, (index) => ({
307
+ name: `r${index}`,
308
+ block: 'image'
309
+ }))
310
+ })
311
+ })
312
+ ).toThrow(/regions, over the 24 cap/);
313
+ });
314
+
315
+ it('refuses more slots than the announce should carry', () => {
316
+ expect(() =>
317
+ registerCustomSections('acme', {
318
+ header: defineCustomSection(Header, {
319
+ slots: many(CUSTOM_SECTION_LIMITS.slotsPerType + 1, (index) => ({
320
+ name: `s${index}`
321
+ }))
322
+ })
323
+ })
324
+ ).toThrow(/slots, over the 8 cap/);
325
+ });
326
+
327
+ it('refuses more settings than the panel should carry', () => {
328
+ expect(() =>
329
+ registerCustomSections('acme', {
330
+ header: defineCustomSection(Header, {
331
+ settings: many(
332
+ CUSTOM_SECTION_LIMITS.settingsPerType + 1,
333
+ (index) => ({ key: `k${index}`, type: 'text' as const })
334
+ )
335
+ })
336
+ })
337
+ ).toThrow(/settings, over the 24 cap/);
338
+ });
339
+
340
+ it('refuses more parts than the editor would build control groups for', () => {
341
+ expect(() =>
342
+ registerCustomSections('acme', {
343
+ header: defineCustomSection(Header, {
344
+ parts: many(CUSTOM_SECTION_LIMITS.partsPerType + 1, (index) =>
345
+ `p${index}`
346
+ )
347
+ })
348
+ })
349
+ ).toThrow(/parts, over the 32 cap/);
350
+ });
351
+
352
+ it('refuses a build declaring more sections than the announce should carry', () => {
353
+ const record: Record<string, typeof Header> = {};
354
+ many(CUSTOM_SECTION_LIMITS.sectionTypesPerBuild + 1, (index) => index).forEach(
355
+ (index) => {
356
+ record[`section-${index}`] = Header;
357
+ }
358
+ );
359
+
360
+ expect(() => registerCustomSections('acme', record)).toThrow(
361
+ /would declare 17 custom sections, over the 16 cap/
362
+ );
363
+ // Refused whole: the cap is enforced before anything is written, so the
364
+ // build does not end up with 16 of the 17 registered.
365
+ expect(getCustomSectionTypes()).toEqual([]);
366
+ });
367
+ });
368
+
369
+ describe('registerCustomSections — the descriptor on the wire', () => {
370
+ it('survives its own JSON round trip unchanged', () => {
371
+ registerCustomSections('acme', {
372
+ header: defineCustomSection(Header, {
373
+ label: 'ZSA header',
374
+ icon: 'layout',
375
+ group: 'Chrome',
376
+ settings: [
377
+ {
378
+ key: 'tone',
379
+ type: 'select',
380
+ label: 'Tone',
381
+ options: ['light', 'dark'],
382
+ defaultValue: 'light'
383
+ }
384
+ ],
385
+ regions: [{ name: 'logo', label: 'Logo', block: 'image' }],
386
+ slots: [{ name: 'extras', label: 'Extra header actions' }],
387
+ parts: { bar: { states: ['hover'] } },
388
+ styles: { 'padding-top': '12px' },
389
+ placeholders: ['header'],
390
+ lockRootTransforms: true
391
+ })
392
+ });
393
+
394
+ const descriptor = headerDescriptor();
395
+
396
+ expect(descriptor).toEqual(JSON.parse(JSON.stringify(descriptor)));
397
+ });
398
+
399
+ it('keeps the three section-only fields, which nothing else carries', () => {
400
+ registerCustomSections('acme', {
401
+ header: defineCustomSection(Header, {
402
+ styles: { 'padding-top': '12px' },
403
+ placeholders: ['header', 'header-mobile'],
404
+ lockRootTransforms: true
405
+ })
406
+ });
407
+
408
+ const descriptor = headerDescriptor();
409
+
410
+ // `lockRootTransforms` is the one that is not tidiness: rotate, scale,
411
+ // translate, filter and backdrop-filter each create a containing block for
412
+ // a `position: fixed` descendant, so one of them on the root collapses a
413
+ // fixed header into the root's own box — measured at 1247px becoming
414
+ // 600px. Losing this field in transit puts the trap back on the panel.
415
+ expect(descriptor?.lockRootTransforms).toBe(true);
416
+ expect(descriptor?.placeholders).toEqual(['header', 'header-mobile']);
417
+ expect(descriptor?.styles).toEqual({ 'padding-top': '12px' });
418
+ });
419
+
420
+ it('says hasChildren even for a section that declares neither region nor slot', () => {
421
+ // Unlike a block, which earns it by declaring a slot. A section always
422
+ // hosts children, and the editor reads the same field off its own registry
423
+ // entry — a false here makes the section undroppable.
424
+ registerCustomSections('acme', { header: Header });
425
+
426
+ expect(headerDescriptor()?.hasChildren).toBe(true);
427
+ expect(headerDescriptor()?.regions).toEqual([]);
428
+ expect(headerDescriptor()?.slots).toEqual([]);
429
+ });
430
+
431
+ it('omits an undeclared optional field rather than sending it undefined', () => {
432
+ registerCustomSections('acme', { header: Header });
433
+
434
+ // Through `unknown`: a descriptor is an interface with no index
435
+ // signature, and jest never type-checks this file — the only tsc that
436
+ // reaches it is the template app's, where the direct cast is an error.
437
+ const descriptor = headerDescriptor() as unknown as Record<
438
+ string,
439
+ unknown
440
+ >;
441
+
442
+ // `undefined` does not survive postMessage, and a descriptor carrying the
443
+ // key would diverge from its own round trip — which is what
444
+ // `assertSerializable` refuses.
445
+ expect('icon' in descriptor).toBe(false);
446
+ expect('placeholders' in descriptor).toBe(false);
447
+ expect('lockRootTransforms' in descriptor).toBe(false);
448
+ });
449
+
450
+ it('throws naming the path of a value JSON would drop', () => {
451
+ expect(() =>
452
+ registerCustomSections('acme', {
453
+ header: defineCustomSection(Header, {
454
+ regions: [
455
+ {
456
+ name: 'logo',
457
+ block: 'image',
458
+ // A function cannot cross postMessage; without the mechanical
459
+ // catch the editor would seed this region with no styles and
460
+ // nothing would report it.
461
+ styles: { 'margin-left': () => '16px' }
462
+ }
463
+ ]
464
+ })
465
+ })
466
+ ).toThrow(/does not survive JSON/);
467
+ });
468
+
469
+ it('joins the declared parts to the field-derived ones in one list', () => {
470
+ registerCustomSections('acme', {
471
+ header: defineCustomSection(Header, {
472
+ settings: [
473
+ { key: 'tagline', type: 'text', label: 'Tagline', editable: true }
474
+ ],
475
+ parts: ['bar', 'row']
476
+ })
477
+ });
478
+
479
+ expect(headerDescriptor()?.parts).toEqual(['field-tagline', 'bar', 'row']);
480
+ });
481
+ });
@@ -78,7 +78,9 @@ describe('src/custom module boundaries', () => {
78
78
  'block-context.ts',
79
79
  'block-host.tsx',
80
80
  'register.ts',
81
+ 'section-host.tsx',
81
82
  'theme-editable.tsx',
83
+ 'theme-region.tsx',
82
84
  'theme-slot.tsx',
83
85
  'use-theme-setting.ts'
84
86
  ]);
@@ -18,6 +18,8 @@ import blockRendererRegistry from '../../blocks/block-renderer-registry';
18
18
  import type { BlockRendererProps } from '../../blocks/block-renderer-registry';
19
19
  import { resetCustomCatalog } from '../catalog';
20
20
  import { resetCustomCollection } from '../collect';
21
+ import { resetCustomSectionCatalog } from '../section-catalog';
22
+ import { resetCustomSectionDefinitions } from '../section-definitions';
21
23
 
22
24
  // Through the barrel, never the individual modules. That is the point of this
23
25
  // file: the three pieces each have their own suite, and every one of them
@@ -25,15 +27,24 @@ import { resetCustomCollection } from '../collect';
25
27
  // still leave 86 green tests behind — while `@akinon/pz-theme/src/custom`, the
26
28
  // path a brand actually types, resolved to nothing.
27
29
  import {
30
+ CUSTOM_SECTION_LIMITS,
28
31
  CUSTOM_SLOT_BLOCK_TYPE,
29
32
  CUSTOM_TYPE_PREFIX,
30
33
  defineCustomBlock,
34
+ defineCustomSection,
35
+ findRegionChild,
31
36
  getCustomCatalog,
37
+ getCustomSectionCatalog,
32
38
  isCustomThemeType,
39
+ isRegionNode,
33
40
  partAttrs,
41
+ REGION_NAME_PROPERTY,
42
+ regionNodeName,
34
43
  registerCustomBlocks,
44
+ registerCustomSections,
35
45
  SLOT_NAME_PROPERTY,
36
46
  ThemeEditable,
47
+ ThemeRegion,
37
48
  ThemeSlot,
38
49
  useThemeSetting
39
50
  } from '../index';
@@ -187,6 +198,8 @@ afterEach(() => {
187
198
  clearCustomRenderers();
188
199
  resetCustomCatalog();
189
200
  resetCustomCollection();
201
+ resetCustomSectionCatalog();
202
+ resetCustomSectionDefinitions();
190
203
  });
191
204
 
192
205
  describe('the documented barrel', () => {
@@ -361,6 +374,116 @@ describe('the advertised parts and the rendered DOM', () => {
361
374
  });
362
375
  });
363
376
 
377
+ /**
378
+ * The SECTION half of the same barrel. A brand writing a header imports
379
+ * `defineCustomSection`, `ThemeRegion` and `registerCustomSections` from this
380
+ * one path, and every one of them is a name the root re-exports by hand — a
381
+ * renamed symbol compiles here and fails at the brand.
382
+ */
383
+ describe('the section authoring surface', () => {
384
+ const HEADER = 'custom:acme:header';
385
+
386
+ /** The barrel's own documented example, unaltered. */
387
+ function ZsaHeader() {
388
+ return (
389
+ <header {...partAttrs('bar')}>
390
+ <ThemeRegion name="logo" />
391
+ <ThemeRegion name="basket" />
392
+ <ThemeSlot name="extras" label="Extra header actions" />
393
+ </header>
394
+ );
395
+ }
396
+
397
+ const registerHeader = () =>
398
+ registerCustomSections('acme', {
399
+ header: defineCustomSection(ZsaHeader, {
400
+ label: 'ZSA ZSA ZSU Header',
401
+ regions: [
402
+ { name: 'logo', label: 'Logo', block: 'image' },
403
+ { name: 'basket', label: 'Basket', block: 'mini-basket' }
404
+ ],
405
+ slots: [{ name: 'extras', label: 'Extra header actions' }],
406
+ parts: ['bar'],
407
+ placeholders: ['header'],
408
+ lockRootTransforms: true
409
+ })
410
+ });
411
+
412
+ it('describes a registered section through the same barrel that registered it', () => {
413
+ registerHeader();
414
+
415
+ const catalog = getCustomSectionCatalog('acme');
416
+ const descriptor = catalog?.sections.find(
417
+ (entry) => entry.type === HEADER
418
+ );
419
+
420
+ expect(catalog?.vendor).toBe('acme');
421
+ // A region is a child the developer NAMED, chose the type of and PLACED —
422
+ // so the block type is part of the description, and it is what buys the
423
+ // merchant that block's own panel.
424
+ expect(descriptor?.regions).toEqual([
425
+ { name: 'logo', label: 'Logo', block: 'image' },
426
+ { name: 'basket', label: 'Basket', block: 'mini-basket' }
427
+ ]);
428
+ expect(descriptor?.slots.map((slot) => slot.name)).toEqual(['extras']);
429
+ expect(descriptor?.parts).toEqual(['bar']);
430
+ expect(descriptor?.placeholders).toEqual(['header']);
431
+ expect(descriptor?.lockRootTransforms).toBe(true);
432
+ expect(descriptor?.hasChildren).toBe(true);
433
+ });
434
+
435
+ it('rides beside the block catalog rather than inside it', () => {
436
+ registerCustomSections('acme', { header: ZsaHeader });
437
+
438
+ // Two payloads, two byte budgets. The editor validates the BLOCK catalog
439
+ // element by element and drops the whole thing on an unexpected shape, so
440
+ // nesting sections inside it would make every block in a build vanish from
441
+ // the palette of any editor older than this change.
442
+ expect(getCustomSectionCatalog('acme')?.sections).toHaveLength(1);
443
+ expect(getCustomCatalog()?.blocks.map((block) => block.type)).toEqual(
444
+ expect.arrayContaining([PROMO])
445
+ );
446
+ expect(
447
+ getCustomCatalog()?.blocks.some((block) => block.type === HEADER)
448
+ ).toBe(false);
449
+ });
450
+
451
+ it('reads a seeded region back through the grammar it exports', () => {
452
+ // The editor mirrors `REGION_NAME_PROPERTY` and seeds the block; this
453
+ // package reads it back. Both sides are exported from here so the mirror
454
+ // can be diffed against one constant instead of a literal re-typed
455
+ // slightly wrong.
456
+ const seeded = {
457
+ id: 'block-h3n1a',
458
+ type: 'image',
459
+ label: 'Logo',
460
+ properties: { [REGION_NAME_PROPERTY]: 'logo' },
461
+ styles: {},
462
+ order: 0,
463
+ hidden: false
464
+ };
465
+
466
+ expect(REGION_NAME_PROPERTY).toBe('regionName');
467
+ expect(regionNodeName(seeded)).toBe('logo');
468
+ expect(isRegionNode(seeded)).toBe(true);
469
+ expect(findRegionChild({ blocks: [seeded] }, 'logo')).toBe(seeded);
470
+ });
471
+
472
+ it('publishes the section caps the editor mirrors as literals', () => {
473
+ // Every one is mirrored independently on the far side, and the receiver's
474
+ // answer to an over-cap catalog is to drop all of it — so a number moving
475
+ // on one side alone empties a brand's whole section band.
476
+ expect(CUSTOM_SECTION_LIMITS).toEqual({
477
+ sectionTypesPerBuild: 16,
478
+ settingsPerType: 24,
479
+ regionsPerType: 24,
480
+ slotsPerType: 8,
481
+ partsPerType: 32,
482
+ catalogBytes: 256 * 1024
483
+ });
484
+ });
485
+ });
486
+
364
487
  describe('the curated package root', () => {
365
488
  /**
366
489
  * Parsed from source rather than imported, exactly as