@akinon/pz-theme 2.0.107-rc.0 → 2.0.108-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.
package/CHANGELOG.md CHANGED
@@ -1,40 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.107-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d458]
35
- - Updated dependencies [dca12811]
36
- - @akinon/next@2.0.107-rc.0
37
- - @akinon/theme-contract@2.0.107-rc.0
3
+ ## 2.0.108-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.108-beta.0
9
+ - @akinon/theme-contract@2.0.108-beta.0
10
+
11
+ ## 2.0.107
12
+
13
+ ### Patch Changes
14
+
15
+ - da5a550: ZERO-5048: Export useCustomBlockContext, useOverlay and chrome mode helpers from the custom entry and add reference examples
16
+ - @akinon/next@2.0.107
17
+ - @akinon/theme-contract@2.0.107
38
18
 
39
19
  ## 2.0.106
40
20
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
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.107-rc.0",
4
+ "version": "2.0.108-beta.0",
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.107-rc.0",
11
+ "@akinon/next": "2.0.108-beta.0",
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.107-rc.0",
16
+ "@akinon/theme-contract": "2.0.108-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.107-rc.0",
24
+ "@akinon/next": "2.0.108-beta.0",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
package/readme.md CHANGED
@@ -21,3 +21,7 @@ export default async function Page() {
21
21
  return <ThemePlaceholder slug="homepage-body" />;
22
22
  }
23
23
  ```
24
+
25
+ ### Custom sections and blocks
26
+
27
+ Developer-authored sections and blocks are written against `@akinon/pz-theme/src/custom` — see [`src/custom/README.md`](./src/custom/README.md) and [`src/custom/README-sections.md`](./src/custom/README-sections.md). Reference examples live in `examples/header/` at the root of the pz-next repository; they are not shipped in the npm package.
@@ -9,6 +9,11 @@ Read [`README.md`](./README.md) first. Everything about `ThemeEditable`,
9
9
  `useThemeSetting`, `ThemeSlot`, `partAttrs` and the limits works identically
10
10
  inside a section; this file is only the difference.
11
11
 
12
+ For complete, commented sections to read alongside this file, see
13
+ `examples/header/` at the root of the pz-next repository: one header written at
14
+ six levels, from basic to advanced, each building on the previous one. They are
15
+ reference only — not shipped in the npm package and never imported by an app.
16
+
12
17
  ## When a section instead of a block
13
18
 
14
19
  A block, because it is dropped into a section the merchant created, gets the
@@ -80,9 +85,17 @@ export default defineCustomSection(StoreHeader, {
80
85
  Then, from the **client root only**:
81
86
 
82
87
  ```ts
88
+ import { registerCustomSections } from '@akinon/pz-theme/src/custom';
89
+ import StoreHeader from './store-header';
90
+
83
91
  registerCustomSections('acme', { header: StoreHeader });
84
92
  ```
85
93
 
94
+ Register the **default export** — the `defineCustomSection` result — not the
95
+ plain `StoreHeader` function. A plain component is accepted, but it registers
96
+ with no options at all, so its regions, slots, placeholders, seeded styles and
97
+ `lockRootTransforms` are silently dropped.
98
+
86
99
  ## `ThemeRegion` — the one genuinely new piece
87
100
 
88
101
  A region is a child you named, chose the type of, and placed. Declaring
@@ -310,11 +323,26 @@ and lose the second appearance.
310
323
  ```tsx
311
324
  'use client';
312
325
 
313
- const isHome = usePathname() === '/';
326
+ import { usePathname } from 'next/navigation';
327
+ import { useEffect } from 'react';
328
+ import {
329
+ CHROME_MODE_DOCKED,
330
+ setChromeMode
331
+ } from '@akinon/pz-theme/src/custom';
332
+
333
+ function StoreHeader() {
334
+ const isHome = usePathname() === '/';
335
+
336
+ useEffect(() => setChromeMode(isHome ? null : CHROME_MODE_DOCKED), [isHome]);
314
337
 
315
- useEffect(() => setChromeMode(isHome ? null : CHROME_MODE_DOCKED), [isHome]);
338
+ // ...the header markup
339
+ }
316
340
  ```
317
341
 
342
+ Import both from `@akinon/pz-theme/src/custom`, like the rest of the section
343
+ API. The package root exports them too, but it pulls in `server-only`, so a
344
+ client header cannot import them from there.
345
+
318
346
  `setChromeMode` returns a cleanup, which is why it is returned straight from the
319
347
  effect. **Give the signal one owner.** The cleanup restores the value that call
320
348
  displaced — a snapshot, not a counted stack — so it unwinds correctly as one
@@ -16,6 +16,11 @@ and it names the children that go inside it rather than opening a hole. A custom
16
16
  *block* is the right shape for one element the merchant drops into a band they
17
17
  composed.
18
18
 
19
+ **Want a complete, commented example?** See `examples/header/` at the root of
20
+ the pz-next repository: one header written at six levels, from basic to
21
+ advanced, each building on the previous one. They are reference only — not
22
+ shipped in the npm package and never imported by an app.
23
+
19
24
  ## The shape
20
25
 
21
26
  A "Complete the look" row for a product page — the merchant retypes its
@@ -205,6 +210,22 @@ region nothing could ever go in) and a `max` that is not a positive whole number
205
210
  would otherwise empty a brand's entire palette, with the only explanation in a
206
211
  repo the developer is not working in.
207
212
 
213
+ ## Detecting the editor canvas
214
+
215
+ `useCustomBlockContext()` returns the context the block or section renders in,
216
+ or `null` outside one. Its `isDesigner` is what to branch on when the canvas
217
+ needs something the live site must not do — keep a menu open so the merchant
218
+ can style it, or stop a link from navigating on click.
219
+
220
+ ```ts
221
+ const isDesigner = useCustomBlockContext()?.isDesigner ?? false;
222
+ ```
223
+
224
+ `isDesigner` means "the editing affordances are on", not "the editor is
225
+ attached". The editor's **Use preview** toggle turns it off while the editor
226
+ stays open, so the canvas behaves like the live site: whatever you held open
227
+ closes and links navigate again.
228
+
208
229
  ## Styling parts
209
230
 
210
231
  The `field-<key>` parts come free from `ThemeEditable`. Everything else a block
@@ -0,0 +1,200 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import React from 'react';
4
+ import { render, screen } from '@testing-library/react';
5
+
6
+ import * as chromeMode from '../../chrome/chrome-mode';
7
+ import * as blockContext from '../block-context';
8
+ import * as overlay from '../../hooks/use-overlay';
9
+ import { resetCustomCollection } from '../collect';
10
+ import { CustomSectionHost } from '../section-host';
11
+ import type { Section } from '../../theme-section';
12
+
13
+ // Through the barrel, the path a brand's custom header actually types. The
14
+ // package root exports the chrome mode helpers too, but it pulls in
15
+ // `server-only`, so a client module cannot reach them there.
16
+ import {
17
+ CHROME_MODE_DOCKED,
18
+ defineCustomSection,
19
+ setChromeMode,
20
+ useCustomBlockContext,
21
+ useOverlay
22
+ } from '../index';
23
+ import type {
24
+ ChromeMode,
25
+ CustomBlockContextValue,
26
+ OverlayHandle,
27
+ UseOverlayOptions
28
+ } from '../index';
29
+
30
+ const HEADER = 'custom:brand:store-header';
31
+
32
+ const section = (): Section =>
33
+ ({
34
+ id: 'section-header',
35
+ type: HEADER,
36
+ label: 'Store header',
37
+ properties: {},
38
+ styles: {},
39
+ order: 0,
40
+ hidden: false,
41
+ blocks: []
42
+ } as unknown as Section);
43
+
44
+ /** Reports what a developer's own code would read from the hook. */
45
+ function EditorProbe() {
46
+ const context: CustomBlockContextValue | null = useCustomBlockContext();
47
+
48
+ return (
49
+ <div
50
+ data-testid="probe"
51
+ data-inside={context ? 'yes' : 'no'}
52
+ data-designer={String(context?.isDesigner ?? 'none')}
53
+ data-scope={context?.scope ?? 'none'}
54
+ />
55
+ );
56
+ }
57
+
58
+ const StoreHeader = defineCustomSection(EditorProbe, {
59
+ label: 'Store header'
60
+ });
61
+
62
+ const renderHost = (isDesigner: boolean) =>
63
+ render(
64
+ <CustomSectionHost
65
+ type={HEADER}
66
+ label="Store header"
67
+ definition={StoreHeader}
68
+ section={section()}
69
+ placeholderId="header"
70
+ isDesigner={isDesigner}
71
+ />
72
+ );
73
+
74
+ const probe = () => screen.getByTestId('probe');
75
+
76
+ describe('the custom entry exports ZERO-5048 added', () => {
77
+ it('re-exports the same bindings rather than second copies', () => {
78
+ expect(useCustomBlockContext).toBe(blockContext.useCustomBlockContext);
79
+ expect(setChromeMode).toBe(chromeMode.setChromeMode);
80
+ expect(CHROME_MODE_DOCKED).toBe(chromeMode.CHROME_MODE_DOCKED);
81
+ expect(CHROME_MODE_DOCKED).toBe('docked');
82
+ expect(useOverlay).toBe(overlay.useOverlay);
83
+ });
84
+
85
+ it('keeps use-overlay.ts client-safe: a client directive and react as its only import', () => {
86
+ const source = fs.readFileSync(
87
+ path.resolve(__dirname, '../../hooks/use-overlay.ts'),
88
+ 'utf8'
89
+ );
90
+ const imports = source.match(/^import[\s\S]*?from\s+['"]([^'"]+)['"]/gm);
91
+
92
+ expect(source.trimStart()).toMatch(/^['"]use client['"]/);
93
+ expect(source).not.toMatch(/server-only/);
94
+ expect(imports).toHaveLength(1);
95
+ expect(imports?.[0]).toMatch(/from 'react'$/);
96
+ });
97
+
98
+ it('keeps chrome-mode.ts free of imports and directives, so the barrel stays client-safe', () => {
99
+ const source = fs.readFileSync(
100
+ path.resolve(__dirname, '../../chrome/chrome-mode.ts'),
101
+ 'utf8'
102
+ );
103
+
104
+ expect(source).not.toMatch(/^import\s/m);
105
+ expect(source).not.toMatch(/server-only/);
106
+ expect(source.trimStart()).not.toMatch(/^['"]use (client|server)['"]/);
107
+ });
108
+ });
109
+
110
+ describe('useCustomBlockContext', () => {
111
+ beforeEach(() => {
112
+ resetCustomCollection();
113
+ });
114
+
115
+ it('returns null outside a custom block or section instead of throwing', () => {
116
+ render(<EditorProbe />);
117
+
118
+ expect(probe().getAttribute('data-inside')).toBe('no');
119
+ });
120
+
121
+ it('reports isDesigner true on the editing canvas', () => {
122
+ renderHost(true);
123
+
124
+ expect(probe().getAttribute('data-inside')).toBe('yes');
125
+ expect(probe().getAttribute('data-scope')).toBe('section');
126
+ expect(probe().getAttribute('data-designer')).toBe('true');
127
+ });
128
+
129
+ it('reports isDesigner false when the section receives the preview flag', () => {
130
+ renderHost(false);
131
+
132
+ expect(probe().getAttribute('data-designer')).toBe('false');
133
+ });
134
+
135
+ it('receives isDesigner with preview (browse mode) already folded in', () => {
136
+ // The documented promise: "Use preview" turns isDesigner off for the
137
+ // section. The host passes through what the placeholder hands it, and the
138
+ // placeholder hands sections `isDesigner && !isBrowseMode`, never the raw
139
+ // "editor attached" flag.
140
+ const wrapper = fs.readFileSync(
141
+ path.resolve(__dirname, '../../theme-placeholder-wrapper.tsx'),
142
+ 'utf8'
143
+ );
144
+
145
+ expect(wrapper).toMatch(/const isEditing = isDesigner && !isBrowseMode;/);
146
+ expect(wrapper).toMatch(/<ThemeSection[\s\S]*?isDesigner=\{isEditing\}/);
147
+ });
148
+ });
149
+
150
+ describe('setChromeMode from the custom entry', () => {
151
+ afterEach(() => {
152
+ document.body.removeAttribute('data-chrome-mode');
153
+ });
154
+
155
+ it('stamps the docked mode on body and restores it on cleanup', () => {
156
+ const mode: ChromeMode = CHROME_MODE_DOCKED;
157
+ const cleanup = setChromeMode(mode);
158
+
159
+ expect(document.body.getAttribute('data-chrome-mode')).toBe('docked');
160
+
161
+ cleanup();
162
+
163
+ expect(document.body.hasAttribute('data-chrome-mode')).toBe(false);
164
+ });
165
+ });
166
+
167
+ describe('useOverlay from the custom entry', () => {
168
+ /** A panel written the way a custom block writes one. */
169
+ function Panel(props: Omit<UseOverlayOptions, 'modal'>) {
170
+ const handle: OverlayHandle<HTMLDivElement> = useOverlay<HTMLDivElement>({
171
+ ...props,
172
+ modal: false
173
+ });
174
+
175
+ return props.open ? (
176
+ <div {...handle.overlayProps} role="dialog" aria-label="Menu" />
177
+ ) : null;
178
+ }
179
+
180
+ afterEach(() => {
181
+ document.body.removeAttribute('data-overlay-state');
182
+ });
183
+
184
+ it('sets the overlay-open page state on body while open, and clears it on close', () => {
185
+ const { rerender } = render(<Panel open />);
186
+
187
+ expect(document.body.getAttribute('data-overlay-state')).toBe('open');
188
+
189
+ rerender(<Panel open={false} />);
190
+
191
+ expect(document.body.hasAttribute('data-overlay-state')).toBe(false);
192
+ });
193
+
194
+ it('stays inert on the editor canvas', () => {
195
+ render(<Panel open isDesigner />);
196
+
197
+ expect(document.body.hasAttribute('data-overlay-state')).toBe(false);
198
+ expect(screen.getByRole('dialog').hasAttribute('aria-modal')).toBe(false);
199
+ });
200
+ });
@@ -29,7 +29,9 @@
29
29
  * export default defineCustomBlock(PromoStrip);
30
30
  *
31
31
  * // blocks/index.ts — imported from the CLIENT ROOT only
32
- * registerCustomBlocks('acme', { 'promo-strip': PromoStrip });
32
+ * import { registerCustomBlocks } from '@akinon/pz-theme/src/custom';
33
+ * import PromoStrip from './promo-strip';
34
+ * registerCustomBlocks('brand', { 'promo-strip': PromoStrip });
33
35
  *
34
36
  * The block NAME lives in that record, never in `Component.name`: every
35
37
  * production build minifies the function name, so a block authored as
@@ -71,11 +73,11 @@ export { registerCustomBlocks, registerCustomSections } from './register';
71
73
  *
72
74
  * The shape a brand writes:
73
75
  *
74
- * // views/header/zzz-header.tsx
76
+ * // views/header/store-header.tsx
75
77
  * import { defineCustomSection, ThemeRegion, ThemeSlot, partAttrs }
76
78
  * from '@akinon/pz-theme/src/custom';
77
79
  *
78
- * function ZsaHeader() {
80
+ * function StoreHeader() {
79
81
  * return (
80
82
  * <header {...partAttrs('bar')}>
81
83
  * <ThemeRegion name="logo" />
@@ -85,8 +87,8 @@ export { registerCustomBlocks, registerCustomSections } from './register';
85
87
  * );
86
88
  * }
87
89
  *
88
- * export default defineCustomSection(ZsaHeader, {
89
- * label: 'ZSA ZSA ZSU Header',
90
+ * export default defineCustomSection(StoreHeader, {
91
+ * label: 'Store header',
90
92
  * regions: [
91
93
  * { name: 'logo', label: 'Logo', block: 'image' },
92
94
  * { name: 'basket', label: 'Basket', block: 'mini-basket' }
@@ -96,7 +98,9 @@ export { registerCustomBlocks, registerCustomSections } from './register';
96
98
  * });
97
99
  *
98
100
  * // views/header/index.ts — imported from the CLIENT ROOT only
99
- * registerCustomSections('zsazsazsu', { header: ZsaHeader });
101
+ * import { registerCustomSections } from '@akinon/pz-theme/src/custom';
102
+ * import StoreHeader from './store-header';
103
+ * registerCustomSections('brand', { header: StoreHeader });
100
104
  *
101
105
  * A REGION AND A SLOT ARE DIFFERENT PROMISES. A region is a child the developer
102
106
  * named, chose the type of and PLACED; the merchant styles it, configures it,
@@ -129,6 +133,36 @@ export type {
129
133
  ThemeSettingOptions
130
134
  } from './use-theme-setting';
131
135
 
136
+ /**
137
+ * The context every custom unit renders inside, for the one question the
138
+ * components above cannot answer for a developer: is this the editor canvas?
139
+ *
140
+ * `isDesigner` is true only while the editing affordances are on. The editor's
141
+ * "Use preview" toggle (browse mode) turns it OFF for the section although the
142
+ * editor is still attached — `theme-placeholder-wrapper.tsx` hands sections
143
+ * `isDesigner && !isBrowseMode` — so anything kept open for styling closes and
144
+ * links navigate, exactly as on the live site.
145
+ *
146
+ * Returns null outside a custom block or section, never throws.
147
+ */
148
+ export { useCustomBlockContext } from './block-context';
149
+ export type { CustomBlockContextValue } from './block-context';
150
+
151
+ /**
152
+ * The overlay behaviour pz-theme's own drawers and menus use, for a custom
153
+ * block that opens a panel: Escape to close, an optional scroll lock and
154
+ * focus handling, and the `overlay-open` page state on `body` while it is
155
+ * open, which a header section can style with `:overlay-open/<property>`.
156
+ *
157
+ * Pass the block's `isDesigner`: on the editor canvas the hook is inert (no
158
+ * focus move, no scroll lock, no page state). `modal: false` leaves the page
159
+ * usable, so a trigger outside the panel can still close it.
160
+ * `hooks/use-overlay.ts` is 'use client' and imports only react, so it is
161
+ * safe to re-export here.
162
+ */
163
+ export { useOverlay } from '../hooks/use-overlay';
164
+ export type { OverlayHandle, UseOverlayOptions } from '../hooks/use-overlay';
165
+
132
166
  // What the editor bridge announces. Exported here rather than kept internal
133
167
  // because a brand that mounts its own announce path needs the same payload the
134
168
  // package would have sent, not a second construction of it.
@@ -185,3 +219,14 @@ export { findRegionChild, isRegionNode, regionNodeName } from './region-nodes';
185
219
  * `data-part="…"`, which `sanitizePartName` then has no chance to normalize.
186
220
  */
187
221
  export { partAttrs } from '../utils/part-styles';
222
+
223
+ /**
224
+ * The chrome mode writer, re-exported so a custom header section can declare
225
+ * its docked appearance from this module. The package root exports the same
226
+ * symbols but pulls in `server-only`, which a client module cannot import.
227
+ * `chrome/chrome-mode.ts` has no imports and no directive, and `setChromeMode`
228
+ * is a no-op without a `document`, so it is safe on either graph. Call it from
229
+ * an effect and return the cleanup it hands back.
230
+ */
231
+ export { CHROME_MODE_DOCKED, setChromeMode } from '../chrome/chrome-mode';
232
+ export type { ChromeMode } from '../chrome/chrome-mode';