@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
package/CHANGELOG.md CHANGED
@@ -1,44 +1,17 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.78-rc.1
4
-
5
- ### Patch Changes
6
-
7
- - @akinon/next@2.0.78-rc.1
8
- - @akinon/theme-contract@2.0.78-rc.1
9
-
10
- ## 2.0.78-rc.0
11
-
12
- ### Patch Changes
13
-
14
- - Updated dependencies [0cf9ea239]
15
- - Updated dependencies [324f97d55]
16
- - Updated dependencies [51ea06888]
17
- - Updated dependencies
18
- - Updated dependencies [2fea4143]
19
- - Updated dependencies [b55acb768]
20
- - Updated dependencies [760258c1c]
21
- - Updated dependencies [143be2b9d]
22
- - Updated dependencies [7889b08fe]
23
- - Updated dependencies [9f8cd3bc5]
24
- - Updated dependencies [bfafa3f49]
25
- - Updated dependencies [57d7eb305]
26
- - Updated dependencies [d99a6a7d5]
27
- - Updated dependencies [9db81a714]
28
- - Updated dependencies [591e345e1]
29
- - Updated dependencies [4de5303c5]
30
- - Updated dependencies [95b139dc1]
31
- - Updated dependencies [1d00f2d06]
32
- - Updated dependencies [4ac7b2a1e]
33
- - Updated dependencies [e9598c71]
34
- - Updated dependencies [4998a9631]
35
- - Updated dependencies [804d2bd6c]
36
- - Updated dependencies [3909d3224]
37
- - Updated dependencies [6a3d8a631]
38
- - Updated dependencies [e18836b20]
39
- - Updated dependencies [b1111d7b]
40
- - @akinon/next@2.0.78-rc.0
41
- - @akinon/theme-contract@2.0.78-rc.0
3
+ ## 2.0.78
4
+
5
+ ### Patch Changes
6
+
7
+ - 18d2b061: ZERO-4880: Developer-authored custom sections
8
+
9
+ A brand writes a whole area — a header, a footer — as one section and places its children in JSX with `<ThemeRegion name="search" />`. Each region is seeded as a real composition block of a declared type, so the merchant gets that block's own panel, its shared controls and its part groups with no descriptor written by hand. The section root gets the section root panel, and `lockRootTransforms` keeps the five properties that trap a fixed descendant off it.
10
+
11
+ New: `defineCustomSection`, `registerCustomSections`, `ThemeRegion`, and the section catalog, which rides the existing `PLACEHOLDERS_AVAILABLE` frame as a sibling field with its own byte budget. The CSS generator now treats a `custom:` section as owning its own content box while still resolving its max-width token, which a brand component cannot do for itself.
12
+
13
+ - @akinon/next@2.0.78
14
+ - @akinon/theme-contract@2.0.78
42
15
 
43
16
  ## 2.0.77
44
17
 
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.78-rc.1",
4
+ "version": "2.0.78",
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.78-rc.1",
11
+ "@akinon/next": "2.0.78",
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.78-rc.1",
16
+ "@akinon/theme-contract": "2.0.78",
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.78-rc.1",
23
+ "@akinon/next": "2.0.78",
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",
@@ -46,6 +46,7 @@ const load = (): Loaded => {
46
46
  };
47
47
 
48
48
  const Promo = () => null;
49
+ const Header = () => null;
49
50
 
50
51
  describe('PLACEHOLDERS_AVAILABLE announce', () => {
51
52
  const postMessage = jest.fn();
@@ -124,6 +125,75 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
124
125
  ]);
125
126
  });
126
127
 
128
+ it('carries the section catalog as a SIBLING field, not inside the block one', () => {
129
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
130
+ true;
131
+ const { registry, register } = load();
132
+
133
+ registry.registerPlaceholder('header');
134
+ register.registerCustomSections('zsazsazsu', { header: Header });
135
+ jest.advanceTimersByTime(100);
136
+
137
+ const [payload] = frames();
138
+ expect(payload.customSections.vendor).toBe('zsazsazsu');
139
+ expect(
140
+ payload.customSections.sections.map((entry) => entry.type)
141
+ ).toEqual(['custom:zsazsazsu:header']);
142
+ // Beside `customBlocks` and never nested inside it: the editor validates
143
+ // the block catalog element by element and drops the whole thing on an
144
+ // unexpected shape, so a `sections` key inside it would make every block in
145
+ // a build vanish from the palette of any editor older than this change.
146
+ // A build that ships only sections therefore posts no block field at all.
147
+ expect('customBlocks' in payload).toBe(false);
148
+ });
149
+
150
+ it('carries both catalogs in one frame when a build ships both', () => {
151
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
152
+ true;
153
+ const { registry, register } = load();
154
+
155
+ registry.registerPlaceholder('header');
156
+ register.registerCustomBlocks('acme', { 'promo-strip': Promo });
157
+ register.registerCustomSections('acme', { header: Header });
158
+ jest.advanceTimersByTime(100);
159
+
160
+ // One frame, two payloads. Each announce replaces what the editor holds,
161
+ // so a build shipping both must never have to send them in two frames.
162
+ expect(postMessage).toHaveBeenCalledTimes(1);
163
+ const [payload] = frames();
164
+ expect(payload.customBlocks.blocks).toHaveLength(1);
165
+ expect(payload.customSections.sections).toHaveLength(1);
166
+ });
167
+
168
+ it('gives a section-only build its second frame after a slow handshake', () => {
169
+ const { registry, register } = load();
170
+
171
+ register.registerCustomSections('zsazsazsu', { header: Header });
172
+ registry.registerPlaceholder('header');
173
+ jest.advanceTimersByTime(100);
174
+ expect('customSections' in frames()[0]).toBe(false);
175
+
176
+ // The re-announce latch has to consult BOTH catalogs. Checking only the
177
+ // block one would leave a brand that ships nothing but sections with an
178
+ // empty section band on exactly the loads where the cookie handshake lost
179
+ // the 100ms debounce — intermittently, and invisibly.
180
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
181
+ true;
182
+ window.dispatchEvent(
183
+ new MessageEvent('message', {
184
+ origin: EDITOR_ORIGIN,
185
+ source: window.parent as Window,
186
+ data: { type: 'SET_THEME_EDITOR_COOKIE', data: true }
187
+ })
188
+ );
189
+ jest.advanceTimersByTime(100);
190
+
191
+ expect(postMessage).toHaveBeenCalledTimes(2);
192
+ expect(
193
+ frames()[1].customSections.sections.map((entry) => entry.type)
194
+ ).toEqual(['custom:zsazsazsu:header']);
195
+ });
196
+
127
197
  it('omits the catalog key when the build registers no custom block', () => {
128
198
  (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
129
199
  true;
@@ -134,6 +204,9 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
134
204
 
135
205
  const [payload] = frames();
136
206
  expect(payload.placeholders).toEqual(['header']);
207
+ // Neither field, for a build that registers neither. The section payload
208
+ // is omitted on exactly the same terms as the block one.
209
+ expect('customSections' in payload).toBe(false);
137
210
  // Absent, not empty — a build with no custom blocks has to be
138
211
  // indistinguishable from one whose catalog is not this frame's business.
139
212
  expect('customBlocks' in payload).toBe(false);
@@ -0,0 +1,174 @@
1
+ import {
2
+ CHROME_MODE_ATTRIBUTE,
3
+ CHROME_MODE_DOCKED,
4
+ setChromeMode
5
+ } from '../chrome-mode';
6
+
7
+ /**
8
+ * The writer for the `docked` style state.
9
+ *
10
+ * pz-theme owns the vocabulary — the attribute name, the token and the selector
11
+ * that reads them (utils/part-styles.ts). The BRAND owns the signal, because
12
+ * only the brand knows which of its routes carry a hero. This function is the
13
+ * seam between the two, and the reason it exists at all is that the alternative
14
+ * is every brand hand-typing `data-chrome-mode` into `document.body`: a typo
15
+ * there produces no error, just a header that never changes appearance.
16
+ *
17
+ * Two behaviours carry real weight and are pinned hardest below.
18
+ *
19
+ * IT WRITES TO `body`, NOT THE SLOT. That is not a stylistic difference from
20
+ * `data-scroll-state`. It is what earns the selector its specificity bump over
21
+ * `scrolled` (both match on an inner page), and it is what lets a hero written
22
+ * to sit under a transparent bar answer to the mode even though it is nowhere
23
+ * near the chrome.
24
+ *
25
+ * ITS CLEANUP RESTORES RATHER THAN CLEARS — a SNAPSHOT, not a stack. Each
26
+ * cleanup replays the value its own call displaced, which unwinds correctly
27
+ * when calls nest (mount, re-stamp, unmount) and does NOT compose when two
28
+ * owners overlap. Both halves are pinned below, the second as a
29
+ * characterisation test, because the constraint it implies — one owner for the
30
+ * signal — is invisible in the signature and its symptom only appears while
31
+ * navigating.
32
+ */
33
+
34
+ const mode = () => document.body.getAttribute(CHROME_MODE_ATTRIBUTE);
35
+
36
+ afterEach(() => {
37
+ document.body.removeAttribute(CHROME_MODE_ATTRIBUTE);
38
+ });
39
+
40
+ describe('stamping', () => {
41
+ it('writes the docked token onto body', () => {
42
+ setChromeMode(CHROME_MODE_DOCKED);
43
+
44
+ expect(mode()).toBe('docked');
45
+ });
46
+
47
+ it('writes to body rather than to the chrome slot', () => {
48
+ // The selector is `body[data-chrome-mode~="docked"] &`. Stamping the slot
49
+ // instead would match nothing at all, silently.
50
+ document.body.innerHTML = '<div data-chrome-slot="header"></div>';
51
+
52
+ setChromeMode(CHROME_MODE_DOCKED);
53
+
54
+ expect(mode()).toBe('docked');
55
+ expect(
56
+ document
57
+ .querySelector('[data-chrome-slot="header"]')
58
+ ?.getAttribute(CHROME_MODE_ATTRIBUTE)
59
+ ).toBeNull();
60
+
61
+ document.body.innerHTML = '';
62
+ });
63
+
64
+ it('leaves nothing behind when cleared with null, so overlay is the default', () => {
65
+ setChromeMode(CHROME_MODE_DOCKED);
66
+ setChromeMode(null);
67
+
68
+ // Absent, not `data-chrome-mode=""`: the token match `~="docked"` would
69
+ // fail on an empty value either way, but an empty attribute is a puzzle in
70
+ // devtools for anyone debugging why a header looks wrong.
71
+ expect(mode()).toBeNull();
72
+ expect(document.body.hasAttribute(CHROME_MODE_ATTRIBUTE)).toBe(false);
73
+ });
74
+
75
+ it('is idempotent', () => {
76
+ setChromeMode(CHROME_MODE_DOCKED);
77
+ setChromeMode(CHROME_MODE_DOCKED);
78
+ setChromeMode(CHROME_MODE_DOCKED);
79
+
80
+ expect(mode()).toBe('docked');
81
+ });
82
+ });
83
+
84
+ describe('the cleanup', () => {
85
+ it('restores the value that was there before, not a blank', () => {
86
+ const restoreFirst = setChromeMode(CHROME_MODE_DOCKED);
87
+ const restoreSecond = setChromeMode(null);
88
+
89
+ expect(mode()).toBeNull();
90
+
91
+ restoreSecond();
92
+
93
+ expect(mode()).toBe('docked');
94
+
95
+ restoreFirst();
96
+
97
+ expect(mode()).toBeNull();
98
+ });
99
+
100
+ it('unwinds correctly when cleanups nest, which is the mount/unmount case', () => {
101
+ // One owner mounting, re-stamping, and unmounting: cleanups run last-in
102
+ // first-out, and each finds exactly the value its own call displaced.
103
+ const restoreOuter = setChromeMode(CHROME_MODE_DOCKED);
104
+ const restoreInner = setChromeMode(null);
105
+
106
+ restoreInner();
107
+ expect(mode()).toBe('docked');
108
+
109
+ restoreOuter();
110
+ expect(mode()).toBeNull();
111
+ });
112
+
113
+ it('restores its OWN snapshot, so the signal must have a single owner', () => {
114
+ // A characterisation test, not an endorsement. Each cleanup replays the
115
+ // value observed at ITS call, so cleanups running out of order do not
116
+ // compose: here the outgoing caller's cleanup wipes a mode the incoming
117
+ // caller set after it.
118
+ //
119
+ // That is fine for the way this is meant to be used — one brand header
120
+ // owning the signal and re-stamping as the route changes — and it is the
121
+ // reason that constraint is not optional. Two components both stamping
122
+ // during a route transition can land here, and the symptom is a transparent
123
+ // bar over an inner page's content: reachable only by navigating, so no
124
+ // component test would show it.
125
+ //
126
+ // If a second owner ever becomes real, this needs a counted stack rather
127
+ // than a snapshot, and this test is where that decision should be recorded.
128
+ const restoreOutgoing = setChromeMode(null);
129
+ setChromeMode(CHROME_MODE_DOCKED);
130
+
131
+ restoreOutgoing();
132
+
133
+ expect(mode()).toBeNull();
134
+ });
135
+
136
+ it('can be called more than once without drifting', () => {
137
+ const restore = setChromeMode(CHROME_MODE_DOCKED);
138
+
139
+ restore();
140
+ restore();
141
+
142
+ expect(mode()).toBeNull();
143
+ });
144
+ });
145
+
146
+ describe('on the server', () => {
147
+ it('is a safe no-op and still returns a callable cleanup', () => {
148
+ // The chrome mode is stamped from a brand's header, which renders on the
149
+ // server first. Touching `document` there would throw during SSR and take
150
+ // the whole page down, so the guard is not a nicety.
151
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
152
+
153
+ Object.defineProperty(globalThis, 'document', {
154
+ value: undefined,
155
+ configurable: true
156
+ });
157
+
158
+ try {
159
+ expect(typeof document).toBe('undefined');
160
+
161
+ const restore = setChromeMode(CHROME_MODE_DOCKED);
162
+
163
+ expect(typeof restore).toBe('function');
164
+ // The caller runs this in a cleanup it never guards itself.
165
+ expect(() => restore()).not.toThrow();
166
+ } finally {
167
+ if (descriptor) {
168
+ Object.defineProperty(globalThis, 'document', descriptor);
169
+ }
170
+ }
171
+
172
+ expect(typeof document).toBe('object');
173
+ });
174
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Whether the chrome sits OVER the page or in the flow above it.
3
+ *
4
+ * The third page-level style state, beside `scrolled` (chrome/scroll-state.tsx)
5
+ * and `overlay-open` (hooks/use-overlay.ts). All three exist for the same
6
+ * reason: a composition cannot see the page it is on, so the page has to say.
7
+ *
8
+ * WHAT IT IS FOR. Almost every storefront header has two appearances — over the
9
+ * home page's hero it is transparent with light content, and on a product or
10
+ * category page it sits above the body, solid, with dark content. Which one
11
+ * applies is a fact about the ROUTE, and a route is the one thing a composition
12
+ * has no way to ask about. Before this, a brand had to choose: keep the header
13
+ * in code and lose every merchant control, or compose it and lose the second
14
+ * appearance.
15
+ *
16
+ * WHO STAMPS IT. The brand, from its own header, exactly as the mega menu
17
+ * stamps `data-overlay-state`. pz-theme supplies the vocabulary and the
18
+ * selector; only the brand knows which of its routes carry a hero. `setChromeMode`
19
+ * below is the one writer, so no brand hand-types the attribute.
20
+ *
21
+ * DEFAULT IS OVERLAY, and that is deliberate: the attribute is absent until
22
+ * something declares otherwise, so every composition that existed before this
23
+ * state renders exactly as it did.
24
+ */
25
+
26
+ /** The attribute the renderer's `docked` selector matches on. */
27
+ export const CHROME_MODE_ATTRIBUTE = 'data-chrome-mode';
28
+
29
+ /** The style-state name this signal answers to (see utils/part-styles.ts). */
30
+ export const CHROME_MODE_DOCKED = 'docked';
31
+
32
+ export type ChromeMode = 'docked' | null;
33
+
34
+ /**
35
+ * Stamps or clears the mode on `<body>`.
36
+ *
37
+ * The BODY rather than the chrome slot, unlike `data-scroll-state`, and the
38
+ * difference is load-bearing in two ways. It is what lets the state outrank
39
+ * `scrolled` on specificity — see the selector in `utils/part-styles.ts` — and
40
+ * it is what lets a block anywhere on the page answer to it, not only one
41
+ * inside the chrome: a docked header changes the space the page starts in, and
42
+ * a hero that was written to sit under a transparent bar needs to know.
43
+ *
44
+ * Idempotent, and a no-op on the server. Returns a cleanup that restores what
45
+ * was there before, so two headers on one page (a route transition rendering
46
+ * both) cannot leave the attribute stuck.
47
+ */
48
+ export const setChromeMode = (mode: ChromeMode): (() => void) => {
49
+ if (typeof document === 'undefined') return () => undefined;
50
+
51
+ const { body } = document;
52
+ const previous = body.getAttribute(CHROME_MODE_ATTRIBUTE);
53
+
54
+ if (mode === null) body.removeAttribute(CHROME_MODE_ATTRIBUTE);
55
+ else body.setAttribute(CHROME_MODE_ATTRIBUTE, mode);
56
+
57
+ return () => {
58
+ if (previous === null) body.removeAttribute(CHROME_MODE_ATTRIBUTE);
59
+ else body.setAttribute(CHROME_MODE_ATTRIBUTE, previous);
60
+ };
61
+ };