@akinon/pz-theme 2.0.42-rc.0 → 2.0.42

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,36 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.42-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea23]
8
- - Updated dependencies [324f97d5]
9
- - Updated dependencies [51ea0688]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08f]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f4]
18
- - Updated dependencies [57d7eb30]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a71]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d0]
25
- - Updated dependencies [4ac7b2a1]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a963]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b2]
32
- - @akinon/next@2.0.42-rc.0
33
- - @akinon/theme-contract@2.0.42-rc.0
3
+ ## 2.0.42
4
+
5
+ ### Patch Changes
6
+
7
+ - 3521bb92: ZERO-4727: Add an empty-zone designer affordance for fallback-less chrome slots and a code-owned region explainer
8
+ - @akinon/next@2.0.42
9
+ - @akinon/theme-contract@2.0.42
34
10
 
35
11
  ## 2.0.41
36
12
 
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.42-rc.0",
4
+ "version": "2.0.42",
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.42-rc.0",
11
+ "@akinon/next": "2.0.42",
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.42-rc.0",
16
+ "@akinon/theme-contract": "2.0.42",
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.42-rc.0",
23
+ "@akinon/next": "2.0.42",
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",
@@ -0,0 +1,147 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
+
4
+ import { ChromeFallback } from '../chrome/chrome-fallback';
5
+
6
+ /**
7
+ * A slot without brand markup (the checkout zones) has no fallback to hover,
8
+ * so before the empty-zone mode an uncomposed one rendered as literally
9
+ * nothing in the designer — undiscoverable, unhoverable, no way in. These
10
+ * tests pin the band that fixes that, and pin that visitors never see it.
11
+ */
12
+
13
+ const EDITOR_ORIGIN = 'http://localhost:5174';
14
+
15
+ const pushTheme = (
16
+ sections: unknown[],
17
+ slug = 'checkout-top-body',
18
+ origin = EDITOR_ORIGIN
19
+ ) =>
20
+ act(() => {
21
+ fireEvent(
22
+ window,
23
+ new MessageEvent('message', {
24
+ origin,
25
+ data: {
26
+ type: 'UPDATE_THEME',
27
+ data: {
28
+ theme: {
29
+ placeholders: [{ slug, sections }]
30
+ }
31
+ }
32
+ }
33
+ })
34
+ );
35
+ });
36
+
37
+ describe('ChromeFallback empty zone in the designer', () => {
38
+ beforeAll(() => {
39
+ Object.defineProperty(window, 'top', {
40
+ configurable: true,
41
+ value: { name: 'designer' }
42
+ });
43
+ });
44
+
45
+ it('draws a visible band with the affordance for an uncomposed zone', () => {
46
+ render(<ChromeFallback slug="checkout-top-body" emptyZone />);
47
+
48
+ // Visible without hover: a hover-only control on an empty area is the
49
+ // exact invisibility this mode exists to fix.
50
+ expect(
51
+ screen.getByText(/checkout-top-body area has no composition yet/i)
52
+ ).toBeInTheDocument();
53
+ expect(
54
+ screen.getByRole('button', { name: /edit in theme editor/i })
55
+ ).toBeInTheDocument();
56
+ });
57
+
58
+ it('asks the editor to compose the zone when the affordance is used', () => {
59
+ const postMessage = jest.fn();
60
+ Object.defineProperty(window, 'parent', {
61
+ configurable: true,
62
+ value: { postMessage }
63
+ });
64
+
65
+ render(<ChromeFallback slug="checkout-summary-body" emptyZone />);
66
+
67
+ // A frame from the editor is what proves its origin to the bridge;
68
+ // without one there is no trusted target and the message is dropped.
69
+ pushTheme([], 'checkout-summary-body');
70
+
71
+ fireEvent.click(
72
+ screen.getByRole('button', { name: /edit in theme editor/i })
73
+ );
74
+
75
+ expect(postMessage).toHaveBeenCalledTimes(1);
76
+ const [envelope, targetOrigin] = postMessage.mock.calls[0];
77
+ expect(envelope).toMatchObject({
78
+ type: 'REQUEST_CHROME_OVERRIDE',
79
+ data: { slug: 'checkout-summary-body' }
80
+ });
81
+ expect(envelope.protocolVersion).toBeGreaterThan(0);
82
+ expect(targetOrigin).toBe(EDITOR_ORIGIN);
83
+ });
84
+
85
+ it('withdraws once the zone is composed and returns when emptied again', () => {
86
+ render(<ChromeFallback slug="checkout-top-body" emptyZone />);
87
+
88
+ pushTheme([{ id: 'section-abc', type: 'custom' }]);
89
+ expect(
90
+ screen.queryByText(/has no composition yet/i)
91
+ ).not.toBeInTheDocument();
92
+
93
+ pushTheme([]);
94
+ expect(screen.getByText(/has no composition yet/i)).toBeInTheDocument();
95
+ });
96
+
97
+ it('starts hidden when the server rendered a composition and returns on removal', () => {
98
+ render(
99
+ <ChromeFallback slug="checkout-bottom-body" emptyZone initiallyOverridden />
100
+ );
101
+
102
+ expect(
103
+ screen.queryByText(/has no composition yet/i)
104
+ ).not.toBeInTheDocument();
105
+
106
+ pushTheme([], 'checkout-bottom-body');
107
+ expect(screen.getByText(/has no composition yet/i)).toBeInTheDocument();
108
+ });
109
+
110
+ it('ignores a theme pushed from an untrusted origin', () => {
111
+ render(<ChromeFallback slug="checkout-top-body" emptyZone />);
112
+
113
+ pushTheme(
114
+ [{ id: 'section-abc', type: 'custom' }],
115
+ 'checkout-top-body',
116
+ 'https://evil.example'
117
+ );
118
+
119
+ // An embedding page must not be able to toggle designer chrome.
120
+ expect(screen.getByText(/has no composition yet/i)).toBeInTheDocument();
121
+ });
122
+ });
123
+
124
+ describe('ChromeFallback empty zone on the live site', () => {
125
+ beforeAll(() => {
126
+ Object.defineProperty(window, 'top', {
127
+ configurable: true,
128
+ value: window
129
+ });
130
+ });
131
+
132
+ it('renders nothing for an uncomposed zone', () => {
133
+ const { container } = render(
134
+ <ChromeFallback slug="checkout-top-body" emptyZone />
135
+ );
136
+
137
+ expect(container).toBeEmptyDOMElement();
138
+ });
139
+
140
+ it('renders nothing under a server-rendered composition', () => {
141
+ const { container } = render(
142
+ <ChromeFallback slug="checkout-top-body" emptyZone initiallyOverridden />
143
+ );
144
+
145
+ expect(container).toBeEmptyDOMElement();
146
+ });
147
+ });
@@ -0,0 +1,90 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+
4
+ import { CodeOwnedRegion } from '../chrome/code-owned-region';
5
+
6
+ /**
7
+ * The checkout flow offers no takeover affordance on purpose, but silence
8
+ * reads as breakage: a designer hovering it sees the same nothing a bug would
9
+ * produce. These tests pin the explainer that fills that gap — and pin that
10
+ * it never leaks to visitors and never blocks the flow it wraps.
11
+ */
12
+
13
+ describe('CodeOwnedRegion in the designer', () => {
14
+ beforeAll(() => {
15
+ Object.defineProperty(window, 'top', {
16
+ configurable: true,
17
+ value: { name: 'designer' }
18
+ });
19
+ });
20
+
21
+ it('explains the region on hover and says where the composable zones are', () => {
22
+ render(
23
+ <CodeOwnedRegion label="checkout flow">
24
+ <div data-testid="flow">payment steps</div>
25
+ </CodeOwnedRegion>
26
+ );
27
+
28
+ expect(screen.queryByText(/code-owned/i)).not.toBeInTheDocument();
29
+
30
+ fireEvent.mouseEnter(screen.getByTestId('flow').parentElement!);
31
+
32
+ const note = screen.getByText(/checkout flow is code-owned/i);
33
+ expect(note).toBeInTheDocument();
34
+ expect(note.textContent).toMatch(/zones above and below/i);
35
+
36
+ fireEvent.mouseLeave(screen.getByTestId('flow').parentElement!);
37
+ expect(screen.queryByText(/code-owned/i)).not.toBeInTheDocument();
38
+ });
39
+
40
+ it('never intercepts the pointer, so the flow stays usable in the preview', () => {
41
+ render(
42
+ <CodeOwnedRegion label="checkout flow">
43
+ <div data-testid="flow">payment steps</div>
44
+ </CodeOwnedRegion>
45
+ );
46
+
47
+ fireEvent.mouseEnter(screen.getByTestId('flow').parentElement!);
48
+
49
+ const overlay = screen.getByText(/code-owned/i).parentElement!;
50
+ expect(overlay).toHaveStyle({ pointerEvents: 'none' });
51
+ });
52
+
53
+ it('keeps the children rendered and interactive underneath', () => {
54
+ const onClick = jest.fn();
55
+ render(
56
+ <CodeOwnedRegion label="checkout flow">
57
+ <button type="button" onClick={onClick}>
58
+ continue
59
+ </button>
60
+ </CodeOwnedRegion>
61
+ );
62
+
63
+ fireEvent.mouseEnter(screen.getByRole('button').parentElement!);
64
+ fireEvent.click(screen.getByRole('button', { name: /continue/i }));
65
+
66
+ expect(onClick).toHaveBeenCalledTimes(1);
67
+ });
68
+ });
69
+
70
+ describe('CodeOwnedRegion on the live site', () => {
71
+ beforeAll(() => {
72
+ Object.defineProperty(window, 'top', {
73
+ configurable: true,
74
+ value: window
75
+ });
76
+ });
77
+
78
+ it('renders the children and no explainer, hovered or not', () => {
79
+ render(
80
+ <CodeOwnedRegion label="checkout flow">
81
+ <div data-testid="flow">payment steps</div>
82
+ </CodeOwnedRegion>
83
+ );
84
+
85
+ expect(screen.getByTestId('flow')).toBeInTheDocument();
86
+
87
+ fireEvent.mouseEnter(screen.getByTestId('flow').parentElement!);
88
+ expect(screen.queryByText(/code-owned/i)).not.toBeInTheDocument();
89
+ });
90
+ });
@@ -18,10 +18,11 @@ export function ChromeFallback({
18
18
  slug,
19
19
  children,
20
20
  wrapperClassName,
21
- initiallyOverridden = false
21
+ initiallyOverridden = false,
22
+ emptyZone = false
22
23
  }: {
23
24
  slug: string;
24
- children: React.ReactNode;
25
+ children?: React.ReactNode;
25
26
  /**
26
27
  * Applied to the live-site wrapper only. The designer overlay below needs
27
28
  * a real positioned box for its scrim, so it deliberately ignores this.
@@ -34,6 +35,14 @@ export function ChromeFallback({
34
35
  * project markup has to come back.
35
36
  */
36
37
  initiallyOverridden?: boolean;
38
+ /**
39
+ * A slot with no brand markup (the checkout zones) has nothing to size the
40
+ * hover overlay, so an uncomposed one is invisible in the designer and the
41
+ * area cannot be discovered at all. This mode draws a slim dashed band with
42
+ * the affordance instead; visitors never see it — the live site renders
43
+ * nothing, exactly as before.
44
+ */
45
+ emptyZone?: boolean;
37
46
  }) {
38
47
  const [inDesigner, setInDesigner] = useState(false);
39
48
  const [isHovered, setIsHovered] = useState(false);
@@ -91,8 +100,9 @@ export function ChromeFallback({
91
100
 
92
101
  if (!inDesigner) {
93
102
  // Composed on the live site: the composition owns the area, the brand
94
- // markup must never double-render under it.
95
- if (initiallyOverridden) return null;
103
+ // markup must never double-render under it. An empty zone has nothing to
104
+ // show a visitor either way.
105
+ if (initiallyOverridden || emptyZone) return null;
96
106
 
97
107
  return (
98
108
  <div data-chrome-fallback={slug} className={wrapperClassName}>
@@ -107,6 +117,62 @@ export function ChromeFallback({
107
117
  postToEditor({ type: 'REQUEST_CHROME_OVERRIDE', data: { slug } });
108
118
  };
109
119
 
120
+ if (emptyZone) {
121
+ // Unlike the brand-markup overlay, the band and its button stay visible
122
+ // without hover: there is no markup to obscure, and a hover-only control
123
+ // on an otherwise empty area is exactly the invisibility this mode fixes.
124
+ return (
125
+ <div
126
+ data-chrome-fallback={slug}
127
+ data-chrome-empty-zone=""
128
+ onMouseEnter={() => setIsHovered(true)}
129
+ onMouseLeave={() => setIsHovered(false)}
130
+ style={{
131
+ display: 'flex',
132
+ alignItems: 'center',
133
+ justifyContent: 'center',
134
+ flexWrap: 'wrap',
135
+ gap: '12px',
136
+ minHeight: '56px',
137
+ padding: '10px 16px',
138
+ margin: '8px 0',
139
+ border: `2px dashed ${isHovered ? '#60a5fa' : '#cbd5e1'}`,
140
+ borderRadius: '6px',
141
+ background: isHovered
142
+ ? 'rgba(37, 99, 235, 0.08)'
143
+ : 'rgba(148, 163, 184, 0.08)',
144
+ transition: 'background 120ms ease, border-color 120ms ease'
145
+ }}
146
+ >
147
+ <span
148
+ style={{
149
+ color: '#64748b',
150
+ fontSize: '13px',
151
+ fontWeight: 500
152
+ }}
153
+ >
154
+ The {slug} area has no composition yet
155
+ </span>
156
+ <button
157
+ type="button"
158
+ onClick={requestOverride}
159
+ style={{
160
+ background: '#2563eb',
161
+ color: '#ffffff',
162
+ border: 'none',
163
+ borderRadius: '6px',
164
+ padding: '8px 14px',
165
+ fontSize: '13px',
166
+ fontWeight: 600,
167
+ cursor: 'pointer'
168
+ }}
169
+ >
170
+ Edit in theme editor
171
+ </button>
172
+ </div>
173
+ );
174
+ }
175
+
110
176
  return (
111
177
  <div
112
178
  data-chrome-fallback={slug}
@@ -68,7 +68,10 @@ export default async function ChromeSlot({
68
68
  {/* The fallback ships hidden: on the live site it never renders, but
69
69
  in the designer it must be able to COME BACK when the last
70
70
  section is removed — without it in the tree, deleting a menu or
71
- header composition would blank the area until a full reload. */}
71
+ header composition would blank the area until a full reload. The
72
+ same applies to a fallback-less zone: its designer band has to be
73
+ able to return, or removing the last section leaves the area
74
+ undiscoverable again. */}
72
75
  {fallback ? (
73
76
  <ChromeFallback
74
77
  slug={slug}
@@ -77,7 +80,9 @@ export default async function ChromeSlot({
77
80
  >
78
81
  {fallback}
79
82
  </ChromeFallback>
80
- ) : null}
83
+ ) : (
84
+ <ChromeFallback slug={slug} emptyZone initiallyOverridden />
85
+ )}
81
86
  <ThemePlaceholder slug={slug} pageContext={pageContext} />
82
87
  </div>
83
88
  );
@@ -89,7 +94,12 @@ export default async function ChromeSlot({
89
94
  <ChromeFallback slug={slug} wrapperClassName={fallbackClassName}>
90
95
  {fallback}
91
96
  </ChromeFallback>
92
- ) : null}
97
+ ) : (
98
+ // No brand markup to take over: an uncomposed zone would render as
99
+ // nothing at all, so the designer gets a visible band instead. The
100
+ // live site keeps rendering nothing.
101
+ <ChromeFallback slug={slug} emptyZone />
102
+ )}
93
103
  {/* Empty until the designer overrides this area from the editor */}
94
104
  <ThemePlaceholder slug={slug} pageContext={pageContext} />
95
105
  </div>
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+
5
+ /**
6
+ * Wraps markup that is deliberately NOT composable — the checkout flow being
7
+ * the canonical case. Without this, hovering the area in the designer gives
8
+ * no feedback at all, which reads as "broken" rather than "code-owned": every
9
+ * other region either offers the takeover affordance or an empty-zone band.
10
+ *
11
+ * On the live site the wrapper is inert (one attribute-carrying div, no
12
+ * styles beyond `position: relative`). In the designer, hover draws a neutral
13
+ * outline and a note explaining why there is no edit affordance here and
14
+ * where the composable zones are. The overlay never intercepts the pointer,
15
+ * so the wrapped flow stays fully usable in the preview.
16
+ */
17
+ export function CodeOwnedRegion({
18
+ label,
19
+ children
20
+ }: {
21
+ /** Human-readable region name, e.g. "checkout flow". */
22
+ label: string;
23
+ children: React.ReactNode;
24
+ }) {
25
+ const [inDesigner, setInDesigner] = useState(false);
26
+ const [isHovered, setIsHovered] = useState(false);
27
+
28
+ useEffect(() => {
29
+ if (typeof window !== 'undefined' && window.self !== window.top) {
30
+ setInDesigner(true);
31
+ }
32
+ }, []);
33
+
34
+ return (
35
+ // The wrapper renders on the live site too: switching between a plain
36
+ // fragment and a wrapped tree at designer-detection time would remount
37
+ // the children — for the checkout flow that means refetching the whole
38
+ // checkout state right after hydration.
39
+ <div
40
+ data-code-owned={label}
41
+ style={{ position: 'relative' }}
42
+ onMouseEnter={() => setIsHovered(true)}
43
+ onMouseLeave={() => setIsHovered(false)}
44
+ >
45
+ {children}
46
+
47
+ {inDesigner && isHovered && (
48
+ <div
49
+ style={{
50
+ position: 'absolute',
51
+ inset: 0,
52
+ outline: '2px dashed #cbd5e1',
53
+ outlineOffset: '-2px',
54
+ // Never intercept the pointer: the region is explained, not locked.
55
+ pointerEvents: 'none',
56
+ display: 'flex',
57
+ alignItems: 'flex-start',
58
+ justifyContent: 'center',
59
+ zIndex: 30
60
+ }}
61
+ >
62
+ <span
63
+ style={{
64
+ // Sticky keeps the note readable while a tall region (the whole
65
+ // checkout flow) is scrolled under the pointer.
66
+ position: 'sticky',
67
+ top: '12px',
68
+ marginTop: '12px',
69
+ background: '#0f172a',
70
+ color: '#ffffff',
71
+ borderRadius: '9999px',
72
+ padding: '6px 14px',
73
+ fontSize: '12.5px',
74
+ fontWeight: 500,
75
+ boxShadow: '0 1px 3px rgba(0, 0, 0, 0.25)'
76
+ }}
77
+ >
78
+ The {label} is code-owned and cannot be composed — use the zones
79
+ above and below it
80
+ </span>
81
+ </div>
82
+ )}
83
+ </div>
84
+ );
85
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export type { Block } from './theme-block';
22
22
 
23
23
  export { default as ChromeSlot } from './chrome/chrome-slot';
24
24
  export { ChromeFallback } from './chrome/chrome-fallback';
25
+ export { CodeOwnedRegion } from './chrome/code-owned-region';
25
26
  export { PreviewAuthStateReporter } from './chrome/preview-auth-state';
26
27
  export { LiveBasketPageContext } from './chrome/live-basket-page-context';
27
28
  export {