@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,59 @@
1
+ import type { CustomSectionDefinition } from './define-section';
2
+
3
+ /**
4
+ * The RENDER half of section registration: which component draws a
5
+ * `custom:<vendor>:<name>` section.
6
+ *
7
+ * A map of its own rather than an entry in `sectionRendererRegistry`, for two
8
+ * measured reasons:
9
+ *
10
+ * 1. `sections/__tests__/custom-renderer-section-types.test.ts` asserts that
11
+ * `CUSTOM_RENDERER_SECTION_TYPES` — a hand-maintained literal the CSS
12
+ * generator reads because it cannot import the registry without closing a
13
+ * module cycle — equals `sectionRendererRegistry.registeredTypes()` exactly.
14
+ * A brand registering into that registry would make `registeredTypes()`
15
+ * non-static and break the lock on every build that loaded a brand barrel.
16
+ * 2. `SectionRendererProps` carries no `pageContext`, and widening it is a
17
+ * package-wide type edit for one consumer. `theme-section.tsx` dispatches to
18
+ * this map directly and hands the host whatever it needs.
19
+ *
20
+ * Directive-free: it stores values and imports nothing that calls a hook, so
21
+ * both graphs can hold a copy. Only the client graph ever fills one, because
22
+ * `registerCustomSections` is client-only for the same reason
23
+ * `registerCustomBlocks` is.
24
+ */
25
+ const definitions = new Map<string, CustomSectionDefinition>();
26
+
27
+ /**
28
+ * Registers the component for a type, refusing rather than overwriting.
29
+ *
30
+ * The refusal is the point. Both stores are checked at registration — this one
31
+ * and the catalog — because a type present in only one of them is a section the
32
+ * editor offers and the storefront cannot draw, or the reverse.
33
+ */
34
+ export const addSectionDefinition = (
35
+ type: string,
36
+ definition: CustomSectionDefinition
37
+ ): void => {
38
+ if (definitions.has(type)) {
39
+ throw new Error(
40
+ `[pz-theme] Custom section "${type}" already has a renderer in this build. ` +
41
+ `Registration refuses to overwrite it — the second component would draw every placement ` +
42
+ `of the first with no signal on either side.`
43
+ );
44
+ }
45
+
46
+ definitions.set(type, definition);
47
+ };
48
+
49
+ export const getCustomSectionDefinition = (
50
+ type: string
51
+ ): CustomSectionDefinition | undefined => definitions.get(type);
52
+
53
+ export const hasCustomSectionDefinition = (type: string): boolean =>
54
+ definitions.has(type);
55
+
56
+ /** Test-only. Module state outlives a test file otherwise. */
57
+ export const resetCustomSectionDefinitions = (): void => {
58
+ definitions.clear();
59
+ };
@@ -0,0 +1,125 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useMemo } from 'react';
4
+ import type { Block } from '../theme-block';
5
+ import type { Section } from '../theme-section';
6
+ import { CustomBlockContext } from './block-context';
7
+ import type { CustomBlockContextValue } from './block-context';
8
+ import { CustomBlockErrorBoundary } from './block-host';
9
+ import { finishCollection, getCollectSink, isCollecting } from './collect';
10
+ import type { CustomSectionDefinition } from './define-section';
11
+
12
+ /**
13
+ * Adapts a developer SECTION component to the composition.
14
+ *
15
+ * The section twin of `CustomBlockHost`, and deliberately its near-copy: it
16
+ * converts the section node into the same `CustomBlockContext` the block host
17
+ * provides, so `useThemeSetting`, `ThemeEditable` and `ThemeSlot` work
18
+ * identically inside a section without a second implementation of any of them.
19
+ * The one thing only a section carries is `declaredRegionNames`, which
20
+ * `ThemeRegion` reads.
21
+ *
22
+ * IT DRAWS NO BOX OF ITS OWN. `SectionWrapper` in `theme-section.tsx` already
23
+ * supplies the `<section data-section-id>`, the designer outline, the action
24
+ * toolbar and the `data-section-content` element the content-width rule targets
25
+ * — that wrapper is precisely what gives a custom section the full section root
26
+ * panel. A host that drew its own element would put an unstyleable div between
27
+ * the two and quietly break both.
28
+ */
29
+ export interface CustomSectionHostProps {
30
+ /** The `custom:<vendor>:<name>` this instance renders. */
31
+ type: string;
32
+ /** Picker label, reused by the error box. */
33
+ label: string;
34
+ definition: CustomSectionDefinition;
35
+ section: Section;
36
+ placeholderId: string;
37
+ isDesigner: boolean;
38
+ selectedBlockId?: string | null;
39
+ currentBreakpoint?: string;
40
+ }
41
+
42
+ export const CustomSectionHost = ({
43
+ type,
44
+ label,
45
+ definition,
46
+ section,
47
+ placeholderId,
48
+ isDesigner,
49
+ selectedBlockId,
50
+ currentBreakpoint = 'desktop'
51
+ }: CustomSectionHostProps) => {
52
+ const BrandSection = definition.component;
53
+
54
+ // Read during render, not in the effect: the sink has to be in the context
55
+ // BEFORE the developer component runs, because that is when
56
+ // `useThemeSetting`/`ThemeEditable` report themselves.
57
+ const collecting = isCollecting(type);
58
+
59
+ // Closing on commit rather than on the render call is what makes the
60
+ // StrictMode double render harmless — see `finishCollection`.
61
+ useEffect(() => {
62
+ finishCollection(type);
63
+ }, [type]);
64
+
65
+ // Read off the DEFINITION rather than the catalog descriptor, for the reason
66
+ // the block host spells out: runtime collection appends to the descriptor's
67
+ // slot list as slots render, and a list that grew mid-render would resolve a
68
+ // bare `<ThemeSlot />` onto whichever name happened to render first.
69
+ const declaredSlotNames = useMemo(
70
+ () => (definition.options.slots ?? []).map((slot) => slot.name ?? ''),
71
+ [definition]
72
+ );
73
+
74
+ const declaredRegionNames = useMemo(
75
+ () => (definition.options.regions ?? []).map((region) => region.name),
76
+ [definition]
77
+ );
78
+
79
+ const value = useMemo<CustomBlockContextValue>(
80
+ () => ({
81
+ node: section,
82
+ scope: 'section',
83
+ // A `Section` carries every field `Block` requires, so the compatibility
84
+ // alias stays type-correct. New readers take `node`.
85
+ block: section as unknown as Block,
86
+ placeholderId,
87
+ // The section IS the parent of its regions, so its own id is the address
88
+ // every editor action for a region child is sent with.
89
+ sectionId: section.id,
90
+ isDesigner,
91
+ currentBreakpoint,
92
+ selectedBlockId,
93
+ declaredSlotNames,
94
+ declaredRegionNames,
95
+ collect: collecting ? getCollectSink(type) : undefined
96
+ }),
97
+ [
98
+ type,
99
+ section,
100
+ placeholderId,
101
+ isDesigner,
102
+ currentBreakpoint,
103
+ selectedBlockId,
104
+ declaredSlotNames,
105
+ declaredRegionNames,
106
+ collecting
107
+ ]
108
+ );
109
+
110
+ return (
111
+ <CustomBlockContext.Provider value={value}>
112
+ <CustomBlockErrorBoundary
113
+ type={type}
114
+ label={label}
115
+ isDesigner={isDesigner}
116
+ >
117
+ <BrandSection />
118
+ </CustomBlockErrorBoundary>
119
+ </CustomBlockContext.Provider>
120
+ );
121
+ };
122
+
123
+ // Named for the React devtools tree: without this every custom section on the
124
+ // page appears as the same anonymous arrow function.
125
+ CustomSectionHost.displayName = 'CustomSectionHost';
@@ -0,0 +1,158 @@
1
+ import type {
2
+ CustomSettingDescriptor,
3
+ CustomSlotDescriptor
4
+ } from './types';
5
+
6
+ /**
7
+ * The wire grammar for developer-authored SECTIONS.
8
+ *
9
+ * A section reuses the block grammar for its type (`custom:<vendor>:<name>` —
10
+ * `CUSTOM_TYPE_PATTERN` in `types.ts`) because the two namespaces are checked
11
+ * against each other at registration: one type string can be claimed by a block
12
+ * or by a section, never by both. Sharing the pattern is what makes that check
13
+ * a single lookup instead of two spellings that could drift apart.
14
+ *
15
+ * Directive-free, like `types.ts` and for the same reason: `section-catalog.ts`
16
+ * is read from `placeholder-registry.ts`, which carries no directive of its own.
17
+ */
18
+
19
+ /**
20
+ * The property carrying a region's name on the block that was seeded for it.
21
+ *
22
+ * The exact mirror of `SLOT_NAME_PROPERTY`, and reserved for exactly the same
23
+ * reason: `block.label` is renameable from the layer tree (`RENAME_BLOCK`), so
24
+ * binding a region to its label would let a merchant detach the region by
25
+ * renaming a row, with nothing anywhere to say why the area went empty.
26
+ *
27
+ * A region is NOT a reserved-type wrapper node. It is an ordinary block of a
28
+ * real type — a `search-box`, an `image`, a `mini-basket` — that happens to
29
+ * carry this property, which is the whole point: the merchant gets that block's
30
+ * own panel, its shared controls and its part groups with no new machinery.
31
+ */
32
+ export const REGION_NAME_PROPERTY = 'regionName';
33
+
34
+ /**
35
+ * A region name. Deliberately the same shape as a slot name: it is never
36
+ * interpolated into a selector, but it must match LITERALLY between the
37
+ * component's `<ThemeRegion name="…">` and the block the editor seeded, across
38
+ * a JSON round trip.
39
+ */
40
+ export const REGION_NAME_PATTERN = /^[a-z][a-zA-Z0-9-]{0,31}$/;
41
+
42
+ /**
43
+ * Caps for the section catalog. Every one is a LITERAL and none is derived
44
+ * from `CUSTOM_LIMITS`, for the reason spelled out there: each is mirrored
45
+ * independently in the editor, and the receiver's answer to an over-cap catalog
46
+ * is to drop all of it. An expression here would let one number move on the
47
+ * sender alone and empty a brand's whole section band.
48
+ *
49
+ * `catalogBytes` is measured over the SECTION catalog on its own. The section
50
+ * payload rides as a sibling field of `customBlocks` rather than inside it
51
+ * (see `placeholder-registry.ts`), so the two budgets are separate on both
52
+ * sides — a brand with a large block palette does not lose its sections, and a
53
+ * single oversized section descriptor does not empty the block palette.
54
+ */
55
+ export const CUSTOM_SECTION_LIMITS = {
56
+ /**
57
+ * Lower than the block cap on purpose. A section descriptor is the larger of
58
+ * the two — it carries a region list with seeded properties and styles — and
59
+ * a brand needs far fewer of them: sections are pages and chrome regions,
60
+ * blocks are the vocabulary inside them.
61
+ */
62
+ sectionTypesPerBuild: 16,
63
+ settingsPerType: 24,
64
+ regionsPerType: 24,
65
+ slotsPerType: 8,
66
+ partsPerType: 32,
67
+ catalogBytes: 256 * 1024
68
+ } as const;
69
+
70
+ /**
71
+ * One child the developer named and PLACED in JSX.
72
+ *
73
+ * `block` is the built-in (or already-registered custom) block type the region
74
+ * is born as. The developer writes no descriptor for it: the block registry
75
+ * supplies the panel, the shared controls, the 161 advanced CSS controls and
76
+ * the part groups, exactly as it does for a block the merchant dropped by hand.
77
+ *
78
+ * `properties` and `styles` are the SEED — what the block looks like the moment
79
+ * the merchant adds the section, before anyone has touched it. They are written
80
+ * once, at insert; nothing re-applies them afterwards, so a merchant's edit is
81
+ * never overwritten by a later load.
82
+ */
83
+ export interface CustomRegionDescriptor {
84
+ name: string;
85
+ label?: string;
86
+ block: string;
87
+ properties?: Record<string, unknown>;
88
+ styles?: Record<string, unknown>;
89
+ /**
90
+ * The block's own `value`, for the block types that keep their content there
91
+ * rather than in `properties` — an `image` reads `block.value` for its src, a
92
+ * `text` reads it for its text. Without this a seeded logo would come up
93
+ * blank and the merchant would have to find and re-upload an asset the brand
94
+ * already ships.
95
+ *
96
+ * Seeded ONCE, at insert, like `properties` and `styles`.
97
+ */
98
+ value?: unknown;
99
+ }
100
+
101
+ export interface CustomSectionDescriptor {
102
+ type: string;
103
+ name: string;
104
+ label: string;
105
+ icon?: string;
106
+ group?: string;
107
+ /**
108
+ * Always true. A section always hosts children — its regions, its slots, or
109
+ * both — and the editor's `SectionRegistryItem` carries the same field, so
110
+ * this maps straight across rather than being re-derived there.
111
+ */
112
+ hasChildren: boolean;
113
+ settings: CustomSettingDescriptor[];
114
+ regions: CustomRegionDescriptor[];
115
+ slots: CustomSlotDescriptor[];
116
+ /** ONE FLAT LIST OF STRINGS — see `CustomBlockDescriptor.parts`. */
117
+ parts: string[];
118
+ partStates?: Record<string, string[]>;
119
+ /** The section root's styles at insert. Same seed rule as a region's. */
120
+ styles?: Record<string, unknown>;
121
+ /**
122
+ * Placeholder slugs this section is offered on. Absent means anywhere.
123
+ *
124
+ * A brand's header section has no meaning on the basket page, and the editor
125
+ * already has the machinery to scope a catalogue entry
126
+ * (`isTemplateOfferedForPlaceholder`) — this is the developer's handle on it.
127
+ */
128
+ placeholders?: string[];
129
+ /**
130
+ * Removes `rotate`, `scale`, `translate`, `filter` and `backdrop-filter` from
131
+ * the section root's advanced controls.
132
+ *
133
+ * Not tidiness. Each of those five creates a containing block for a
134
+ * `position: fixed` descendant, so setting any one of them on the root of a
135
+ * fixed header collapses it into the root's own box — measured at 1247px
136
+ * becoming 600px. A section whose design depends on a fixed or sticky child
137
+ * sets this and the trap stops being reachable from the panel.
138
+ */
139
+ lockRootTransforms?: boolean;
140
+ }
141
+
142
+ export interface CustomSectionCatalog {
143
+ vendor: string;
144
+ sections: CustomSectionDescriptor[];
145
+ }
146
+
147
+ /** The reason a region name is refused, or null. Shared by every door. */
148
+ export const regionNameError = (name: unknown): string | null => {
149
+ if (typeof name !== 'string' || !REGION_NAME_PATTERN.test(name)) {
150
+ return (
151
+ `region name ${JSON.stringify(name)} is not valid. Use a lowercase name ` +
152
+ `starting with a letter (letters, digits and hyphens, max 32 characters) — the name is ` +
153
+ `matched literally against the block the editor seeded for it`
154
+ );
155
+ }
156
+
157
+ return null;
158
+ };
@@ -298,7 +298,9 @@ export function ThemeEditable({
298
298
  });
299
299
  }
300
300
 
301
- const properties = (context.block.properties ?? {}) as Record<
301
+ // `node` rather than `block`: a field authored inside a custom SECTION lives
302
+ // in the section's own properties, which is where the editor writes it.
303
+ const properties = (context.node.properties ?? {}) as Record<
302
304
  string,
303
305
  unknown
304
306
  >;
@@ -0,0 +1,213 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useId } from 'react';
4
+ import { DesignerPlaceholder } from '../blocks/product-block-utils';
5
+ import { renderComposedChildren } from '../blocks/render-composed-children';
6
+ import { devWarn } from '../utils/dev-warn';
7
+ import { useCustomBlockContext } from './block-context';
8
+ import type { CustomBlockContextValue } from './block-context';
9
+ import { findRegionChild } from './region-nodes';
10
+ import { regionNameError } from './section-types';
11
+
12
+ export interface ThemeRegionProps {
13
+ /**
14
+ * Which declared region this places. Required — there is no unnamed region,
15
+ * because a section's direct children are the merchant's ordinary composed
16
+ * blocks and a nameless fallback would swallow all of them.
17
+ */
18
+ name: string;
19
+ /** Shown on the empty band in the designer. Defaults to the region name. */
20
+ label?: string;
21
+ }
22
+
23
+ /**
24
+ * Which `ThemeRegion` instance owns each NAME in this section instance.
25
+ *
26
+ * Keyed by the context VALUE rather than the section id, for the reasons the
27
+ * slot claim map documents: the scope is one section instance in one render
28
+ * pass, two concurrent server renders cannot see each other's claim, and the
29
+ * entry is collected with the context object instead of leaking.
30
+ *
31
+ * Two `<ThemeRegion name="search" />` in one component is a REAL duplicate —
32
+ * both resolve to the SAME seeded block, so the block would render twice and
33
+ * two `data-block-id` wrappers would answer to one id, which breaks selection
34
+ * on the canvas.
35
+ */
36
+ const regionOwners = new WeakMap<
37
+ CustomBlockContextValue,
38
+ Map<string, string>
39
+ >();
40
+
41
+ const claimRegion = (
42
+ context: CustomBlockContextValue,
43
+ name: string,
44
+ instanceId: string
45
+ ): boolean => {
46
+ const claims = regionOwners.get(context) ?? new Map<string, string>();
47
+ regionOwners.set(context, claims);
48
+
49
+ const owner = claims.get(name);
50
+ if (owner === undefined) {
51
+ claims.set(name, instanceId);
52
+ return true;
53
+ }
54
+
55
+ return owner === instanceId;
56
+ };
57
+
58
+ const releaseRegion = (
59
+ context: CustomBlockContextValue,
60
+ name: string,
61
+ instanceId: string
62
+ ): void => {
63
+ const claims = regionOwners.get(context);
64
+ if (claims?.get(name) === instanceId) claims.delete(name);
65
+ };
66
+
67
+ /**
68
+ * A child the developer NAMED and PLACED: the block seeded for this region
69
+ * renders exactly here.
70
+ *
71
+ * A REGION IS NOT A SLOT. A slot is an opening the merchant fills with whatever
72
+ * they choose; a region is one block the developer chose the type of, put in a
73
+ * particular place in the markup, and handed to the merchant to style, configure
74
+ * and — if they want — delete. The merchant cannot reorder it or insert beside
75
+ * it: the JSX owns the layout, and no drag in the layer tree can change where
76
+ * this renders.
77
+ *
78
+ * ONE ARRAY ELEMENT THROUGH THE SAME FUNCTION EVERY CONTAINER USES. Passing the
79
+ * seeded block through `renderComposedChildren` is what gives it the visibility
80
+ * filter, all six editor actions, its `data-block-id` wrapper and therefore the
81
+ * CSS the panel writes for it — for free, and identically to a block the
82
+ * merchant dropped by hand. Rendering `<ThemeBlock>` directly here would lose
83
+ * every one of those and each loss would be silent.
84
+ *
85
+ * A MISSING CHILD IS NOT AN ERROR. The merchant may have deleted it, which is an
86
+ * ordinary composition edit and must stay durable — a header whose language
87
+ * switcher was removed does not grow one back on the next load. The designer
88
+ * gets a labelled band so the hole is visible and the region can be re-added;
89
+ * the live storefront renders nothing.
90
+ */
91
+ export function ThemeRegion({ name, label }: ThemeRegionProps) {
92
+ const context = useCustomBlockContext();
93
+ // Stable for the life of the instance and distinct between two instances,
94
+ // which is what separates "the same region re-rendering" from "a second
95
+ // region claiming the same name".
96
+ const instanceId = useId();
97
+
98
+ const nameError = regionNameError(name);
99
+ const inSection = context?.scope === 'section';
100
+ const claimable = context && inSection && !nameError;
101
+ const ownsRegion = claimable ? claimRegion(context, name, instanceId) : false;
102
+
103
+ useEffect(() => {
104
+ if (!context || !claimable || !ownsRegion) return;
105
+
106
+ // Hand the claim back on unmount so a region behind a condition
107
+ // (`{isWide && <ThemeRegion name="search" />}`) does not lock the section
108
+ // out of that name for the rest of the page's life.
109
+ return () => releaseRegion(context, name, instanceId);
110
+ }, [context, claimable, ownsRegion, name, instanceId]);
111
+
112
+ if (!context) {
113
+ devWarn(
114
+ 'ThemeRegion',
115
+ 'A ThemeRegion rendered outside a custom section, so there is no ' +
116
+ 'composition to read the region from and nothing is rendered. Render ' +
117
+ 'this component through a section registered with registerCustomSections().'
118
+ );
119
+ return null;
120
+ }
121
+
122
+ const { node, isDesigner } = context;
123
+
124
+ if (!inSection) {
125
+ devWarn(
126
+ 'ThemeRegion',
127
+ `A ThemeRegion rendered inside the custom BLOCK "${node.type}". Regions are ` +
128
+ 'placed by a custom section and resolve against the section\'s own children; ' +
129
+ 'inside a block there is nothing to resolve. Use <ThemeSlot /> for a ' +
130
+ 'merchant-fillable opening in a block.'
131
+ );
132
+
133
+ return isDesigner ? (
134
+ <DesignerPlaceholder label="ThemeRegion only works inside a custom section" />
135
+ ) : null;
136
+ }
137
+
138
+ if (nameError) {
139
+ devWarn(
140
+ 'ThemeRegion',
141
+ `The custom section "${node.type}" renders a region that cannot be resolved: ${nameError}.`
142
+ );
143
+
144
+ return isDesigner ? (
145
+ <DesignerPlaceholder label={`Unresolved region — ${nameError}`} />
146
+ ) : null;
147
+ }
148
+
149
+ if (!ownsRegion) {
150
+ devWarn(
151
+ 'ThemeRegion',
152
+ `The custom section "${node.type}" renders the region "${name}" more than once. ` +
153
+ 'Both resolve to the same seeded block, so it would render twice and two wrappers ' +
154
+ 'would answer to one block id; the extra region renders nothing.'
155
+ );
156
+
157
+ return isDesigner ? (
158
+ <DesignerPlaceholder
159
+ label={`Duplicate region — "${name}" is already placed in this section`}
160
+ />
161
+ ) : null;
162
+ }
163
+
164
+ const declared = context.declaredRegionNames;
165
+
166
+ // A name the section never declared can never have been seeded, so this is a
167
+ // typo rather than a merchant edit — and the two look identical on screen.
168
+ // Warned once per render pass rather than swallowed, because the alternative
169
+ // is a developer staring at an empty band that the editor has no row for.
170
+ if (declared && !declared.includes(name)) {
171
+ devWarn(
172
+ 'ThemeRegion',
173
+ `The custom section "${node.type}" places a region "${name}" it never declared, so nothing ` +
174
+ `was ever seeded for it. Add it to the regions list in defineCustomSection(). ` +
175
+ `Declared: ${declared.length > 0 ? declared.join(', ') : '(none)'}.`
176
+ );
177
+ }
178
+
179
+ const child = findRegionChild(node, name);
180
+
181
+ if (!child) {
182
+ return isDesigner ? (
183
+ <DesignerPlaceholder
184
+ label={`${label ?? name} — removed, add it back from the editor`}
185
+ />
186
+ ) : null;
187
+ }
188
+
189
+ const children = renderComposedChildren({
190
+ blocks: [child],
191
+ placeholderId: context.placeholderId,
192
+ // The section IS the parent here, so its own id is the address every editor
193
+ // action for this child is sent with.
194
+ sectionId: context.sectionId,
195
+ isDesigner,
196
+ selectedBlockId: context.selectedBlockId,
197
+ currentBreakpoint: context.currentBreakpoint
198
+ });
199
+
200
+ // Empty is decided AFTER the pipeline, not from the array length: a region
201
+ // whose block is hidden renders nothing on the storefront and a selectable
202
+ // row in the designer, and both sides have to draw what they actually
203
+ // rendered.
204
+ if (children.length === 0) {
205
+ return isDesigner ? (
206
+ <DesignerPlaceholder label={`${label ?? name} — hidden`} />
207
+ ) : null;
208
+ }
209
+
210
+ return <>{children}</>;
211
+ }
212
+
213
+ export default ThemeRegion;
@@ -134,21 +134,22 @@ export function ThemeSlot({ label, name }: ThemeSlotProps) {
134
134
  if (!context) {
135
135
  devWarn(
136
136
  'ThemeSlot',
137
- 'A ThemeSlot rendered outside a custom block, so there is no composition ' +
138
- 'to read children from and nothing is rendered. Render this component ' +
139
- 'through a block registered with registerCustomBlocks().'
137
+ 'A ThemeSlot rendered outside a custom block or section, so there is no ' +
138
+ 'composition to read children from and nothing is rendered. Render this ' +
139
+ 'component through a unit registered with registerCustomBlocks() or ' +
140
+ 'registerCustomSections().'
140
141
  );
141
142
  return null;
142
143
  }
143
144
 
144
- const { block, isDesigner } = context;
145
+ const { node, isDesigner } = context;
145
146
 
146
147
  if (slotName === null) {
147
148
  const reason = (resolved as { error: string }).error;
148
149
 
149
150
  devWarn(
150
151
  'ThemeSlot',
151
- `The custom block "${block.type}" renders a slot that cannot be resolved: ${reason}.`
152
+ `The custom unit "${node.type}" renders a slot that cannot be resolved: ${reason}.`
152
153
  );
153
154
 
154
155
  return isDesigner ? (
@@ -161,7 +162,7 @@ export function ThemeSlot({ label, name }: ThemeSlotProps) {
161
162
 
162
163
  devWarn(
163
164
  'ThemeSlot',
164
- `The custom block "${block.type}" declares more than one ThemeSlot for ${named}. ` +
165
+ `The custom unit "${node.type}" declares more than one ThemeSlot for ${named}. ` +
165
166
  'Both resolve to the same slot node, so its children would render twice and two ' +
166
167
  'wrappers would answer to one block id; the extra slot renders nothing. Give each ' +
167
168
  'ThemeSlot its own name.'
@@ -180,7 +181,7 @@ export function ThemeSlot({ label, name }: ThemeSlotProps) {
180
181
  label === undefined ? { name: slotName } : { name: slotName, label }
181
182
  );
182
183
 
183
- const slotChildren = resolveSlotChildren(block, slotName);
184
+ const slotChildren = resolveSlotChildren(node, slotName);
184
185
 
185
186
  const children = renderComposedChildren({
186
187
  // Null is "this slot has no node yet", which renders exactly like an empty
@@ -111,7 +111,16 @@ export const RESERVED_SETTING_KEYS: readonly string[] = [
111
111
  'customCss',
112
112
  'publish-window',
113
113
  'typographyVariant',
114
- 'role'
114
+ 'role',
115
+ // The two reserved node properties. Neither is a panel key, so neither is
116
+ // here for the reason the ones above are — they are here because a setting
117
+ // sharing one of these names would write the SAME key on the same node the
118
+ // resolver reads: `useThemeSetting('regionName', …)` on a section writes
119
+ // `section.properties.regionName`, one level away from the region binding it
120
+ // would appear to be addressing. Refusing the name costs a developer nothing
121
+ // and closes a near-miss that would be very hard to see.
122
+ 'slotName',
123
+ 'regionName'
115
124
  ];
116
125
 
117
126
  /**
@@ -206,10 +206,12 @@ export function useThemeSetting<
206
206
 
207
207
  const breakpoint = context.currentBreakpoint;
208
208
 
209
- // `block.properties[key]`, never `block.value` — `value` is one slot per
210
- // block, so the second setting would overwrite the first, and the backend
211
- // drops attribute keys that were never declared.
212
- const stored = context.block.properties?.[key];
209
+ // `node.properties[key]`, never `node.value` — `value` is one slot per block,
210
+ // so the second setting would overwrite the first, and the backend drops
211
+ // attribute keys that were never declared. `node` rather than `block` so a
212
+ // setting declared inside a custom SECTION reads the section's own
213
+ // properties, which is where the editor writes it.
214
+ const stored = context.node.properties?.[key];
213
215
 
214
216
  // The sentinel again, for the same reason: handing `getResponsiveValue` an
215
217
  // undefined fallback would resolve an undeclared default to `''`, so a