@akinon/pz-theme 2.0.51-rc.0 → 2.0.51
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.51
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- Updated dependencies
|
|
11
|
-
- Updated dependencies [2fea414]
|
|
12
|
-
- Updated dependencies [b55acb768]
|
|
13
|
-
- Updated dependencies [760258c1c]
|
|
14
|
-
- Updated dependencies [143be2b9d]
|
|
15
|
-
- Updated dependencies [7889b08f]
|
|
16
|
-
- Updated dependencies [9f8cd3bc5]
|
|
17
|
-
- Updated dependencies [bfafa3f49]
|
|
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 [4ac7b2a1e]
|
|
26
|
-
- Updated dependencies [e9598c7]
|
|
27
|
-
- Updated dependencies [4998a9631]
|
|
28
|
-
- Updated dependencies [804d2bd6]
|
|
29
|
-
- Updated dependencies [3909d3224]
|
|
30
|
-
- Updated dependencies [6a3d8a63]
|
|
31
|
-
- Updated dependencies [e18836b20]
|
|
32
|
-
- @akinon/next@2.0.51-rc.0
|
|
33
|
-
- @akinon/theme-contract@2.0.51-rc.0
|
|
3
|
+
## 2.0.51
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 3f806d3: ZERO-4747: Stop the chrome placeholder painting a background behind composed sections
|
|
8
|
+
- @akinon/next@2.0.51
|
|
9
|
+
- @akinon/theme-contract@2.0.51
|
|
34
10
|
|
|
35
11
|
## 2.0.50
|
|
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.51
|
|
4
|
+
"version": "2.0.51",
|
|
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.51
|
|
11
|
+
"@akinon/next": "2.0.51",
|
|
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.51
|
|
16
|
+
"@akinon/theme-contract": "2.0.51",
|
|
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.51
|
|
23
|
+
"@akinon/next": "2.0.51",
|
|
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,153 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
// The Node build, not the browser one: it is the path a storefront actually
|
|
4
|
+
// server-renders through, and the browser build wants a MessageChannel jsdom
|
|
5
|
+
// does not have.
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server.node';
|
|
7
|
+
|
|
8
|
+
import ThemePlaceholderWrapper from '../theme-placeholder-wrapper';
|
|
9
|
+
import type { Section } from '../theme-section';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Two defects that shared one element.
|
|
13
|
+
*
|
|
14
|
+
* The placeholder wrapper painted `var(--theme-background)` from an inline
|
|
15
|
+
* style, and it sits behind every section of the slot. A section asking for
|
|
16
|
+
* `background-color: transparent` — the shipped default — therefore still had
|
|
17
|
+
* the theme colour behind it, which is why a composed header could never sit
|
|
18
|
+
* over a hero.
|
|
19
|
+
*
|
|
20
|
+
* The variable it painted with is written from an effect, so the two renders
|
|
21
|
+
* of that same element disagreed: the server sent an unresolved var (nothing
|
|
22
|
+
* painted) and hydration resolved it to the theme colour. A visible flip on
|
|
23
|
+
* every first load.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const THEME_BACKGROUND = '#ffffff';
|
|
27
|
+
const THEME_SETTINGS = { backgroundColor: THEME_BACKGROUND };
|
|
28
|
+
|
|
29
|
+
const transparentSection = {
|
|
30
|
+
id: 'composed-header',
|
|
31
|
+
type: 'generic-section',
|
|
32
|
+
name: 'header',
|
|
33
|
+
label: 'Header',
|
|
34
|
+
order: 0,
|
|
35
|
+
hidden: false,
|
|
36
|
+
properties: {},
|
|
37
|
+
styles: { 'background-color': { desktop: 'transparent' } },
|
|
38
|
+
blocks: [
|
|
39
|
+
{
|
|
40
|
+
id: 'logo',
|
|
41
|
+
type: 'group',
|
|
42
|
+
label: 'Logo',
|
|
43
|
+
order: 0,
|
|
44
|
+
hidden: false,
|
|
45
|
+
properties: {},
|
|
46
|
+
styles: {},
|
|
47
|
+
blocks: []
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
} as unknown as Section;
|
|
51
|
+
|
|
52
|
+
const composedSlot = (
|
|
53
|
+
settings: Record<string, unknown> | null = THEME_SETTINGS
|
|
54
|
+
) => (
|
|
55
|
+
<ThemePlaceholderWrapper
|
|
56
|
+
slug="header"
|
|
57
|
+
initialSections={[transparentSection]}
|
|
58
|
+
initialPlaceholderId="header"
|
|
59
|
+
isDesignMode={false}
|
|
60
|
+
initialThemeSettings={settings}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const placeholderOf = (root: ParentNode) =>
|
|
65
|
+
root.querySelector('.theme-placeholder') as HTMLElement;
|
|
66
|
+
|
|
67
|
+
const ssrDocument = (element: React.ReactElement) =>
|
|
68
|
+
new DOMParser().parseFromString(
|
|
69
|
+
renderToStaticMarkup(element),
|
|
70
|
+
'text/html'
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
document.body.style.backgroundColor = '';
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('a transparent section in a composed slot', () => {
|
|
78
|
+
it('has nothing painted behind it', () => {
|
|
79
|
+
const { container } = render(composedSlot());
|
|
80
|
+
|
|
81
|
+
const section = container.querySelector(
|
|
82
|
+
'[data-section-id="composed-header"]'
|
|
83
|
+
) as HTMLElement;
|
|
84
|
+
expect(section).not.toBeNull();
|
|
85
|
+
|
|
86
|
+
// Every ancestor up to the render root, not just the immediate one: a
|
|
87
|
+
// colour anywhere on that path is a colour the section cannot see through.
|
|
88
|
+
for (
|
|
89
|
+
let node: HTMLElement | null = section;
|
|
90
|
+
node && node !== container;
|
|
91
|
+
node = node.parentElement
|
|
92
|
+
) {
|
|
93
|
+
expect(node.style.backgroundColor).toBe('');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
expect(placeholderOf(container).getAttribute('style')).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('still asks for transparent in the generated CSS', () => {
|
|
100
|
+
// The section keeps expressing the request; this pins that the fix removed
|
|
101
|
+
// what was covering it, not the request itself.
|
|
102
|
+
const { container } = render(composedSlot());
|
|
103
|
+
|
|
104
|
+
const css = Array.from(container.querySelectorAll('style'))
|
|
105
|
+
.map((tag) => tag.textContent || '')
|
|
106
|
+
.join('');
|
|
107
|
+
|
|
108
|
+
expect(css).toContain('[data-section-id="composed-header"]');
|
|
109
|
+
expect(css).toContain('transparent');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('leaves the page colour to the body', () => {
|
|
113
|
+
// Removing the wrapper's paint must not lose the colour: on a slot that
|
|
114
|
+
// overlaps nothing, the page still looks exactly as it did.
|
|
115
|
+
const { container } = render(composedSlot());
|
|
116
|
+
|
|
117
|
+
expect(document.body.style.backgroundColor).toBe('rgb(255, 255, 255)');
|
|
118
|
+
expect(placeholderOf(container).style.backgroundColor).toBe('');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('the placeholder background across hydration', () => {
|
|
123
|
+
it('is the same on the server and after the effects run', () => {
|
|
124
|
+
const server = placeholderOf(ssrDocument(composedSlot()));
|
|
125
|
+
const { container } = render(composedSlot());
|
|
126
|
+
|
|
127
|
+
expect(server.getAttribute('style')).toBe(
|
|
128
|
+
placeholderOf(container).getAttribute('style')
|
|
129
|
+
);
|
|
130
|
+
expect(server.getAttribute('style')).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('server-renders the page colour instead of waiting for the effect', () => {
|
|
134
|
+
// The flip this pins: the colour used to exist only as an inline style
|
|
135
|
+
// written by `injectThemeColors`, so the first paint was the browser
|
|
136
|
+
// default and the second was the theme.
|
|
137
|
+
expect(renderToStaticMarkup(composedSlot())).toContain(
|
|
138
|
+
`body{background-color:${THEME_BACKGROUND}}`
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
render(composedSlot());
|
|
142
|
+
expect(document.body.style.backgroundColor).toBe('rgb(255, 255, 255)');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('emits no background rule when the theme has no colour', () => {
|
|
146
|
+
const markup = renderToStaticMarkup(composedSlot(null));
|
|
147
|
+
|
|
148
|
+
// A slot rendered before the theme config resolves must not guess a
|
|
149
|
+
// colour for the page — the section's own rule is all that is left.
|
|
150
|
+
expect(markup).not.toContain('body{background-color');
|
|
151
|
+
expect(placeholderOf(ssrDocument(composedSlot(null)))).not.toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -22,6 +22,7 @@ import { buildClientRequestUrl } from '@akinon/next/utils';
|
|
|
22
22
|
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
23
23
|
import { parseEditorMessage, postToEditor } from './editor-bridge';
|
|
24
24
|
import { useBrowseMode } from './browse-mode';
|
|
25
|
+
import { neutralizeStyleTagBreakout } from './utils/css-safety';
|
|
25
26
|
|
|
26
27
|
// Helper function to inject theme colors as CSS custom properties
|
|
27
28
|
const injectThemeColors = (settings: Record<string, unknown>) => {
|
|
@@ -114,6 +115,34 @@ const injectThemeColors = (settings: Record<string, unknown>) => {
|
|
|
114
115
|
}
|
|
115
116
|
};
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* The page background as real CSS, emitted from the same render that produced
|
|
120
|
+
* the markup.
|
|
121
|
+
*
|
|
122
|
+
* `injectThemeColors` can only run in an effect, so the server painted the
|
|
123
|
+
* browser default and the browser repainted the theme colour a frame later —
|
|
124
|
+
* a visible flip on every first load. The settings are already in hand when
|
|
125
|
+
* the server renders (they arrive as `initialThemeSettings`), so the same
|
|
126
|
+
* declarations the effect will write are emitted up front and the two paints
|
|
127
|
+
* agree. The effect still runs: it is what keeps the colour live while the
|
|
128
|
+
* editor pushes UPDATE_THEME, and its inline values outrank this rule.
|
|
129
|
+
*/
|
|
130
|
+
const buildThemeBackgroundCss = (
|
|
131
|
+
settings: Record<string, unknown> | null
|
|
132
|
+
): string => {
|
|
133
|
+
const backgroundColor = settings?.backgroundColor;
|
|
134
|
+
|
|
135
|
+
if (typeof backgroundColor !== 'string' || !backgroundColor.trim()) {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Editor-controlled value on its way into a <style> tag — same breakout
|
|
140
|
+
// neutralization the generated section CSS goes through.
|
|
141
|
+
const value = neutralizeStyleTagBreakout(backgroundColor.trim());
|
|
142
|
+
|
|
143
|
+
return `:root{--theme-background:${value}}body{background-color:${value}}`;
|
|
144
|
+
};
|
|
145
|
+
|
|
117
146
|
// Helper function to load Google Font
|
|
118
147
|
const loadGoogleFont = (fontFamily: string, fontWeight?: string) => {
|
|
119
148
|
if (!fontFamily || fontFamily === 'inherit') return;
|
|
@@ -741,6 +770,10 @@ export default function ThemePlaceholderWrapper({
|
|
|
741
770
|
generateThemeCSS(sections, isDesigner ? currentBreakpoint : undefined),
|
|
742
771
|
[sections, isDesigner, currentBreakpoint]
|
|
743
772
|
);
|
|
773
|
+
const themeBackgroundCSS = useMemo(
|
|
774
|
+
() => buildThemeBackgroundCss(themeSettings),
|
|
775
|
+
[themeSettings]
|
|
776
|
+
);
|
|
744
777
|
const visibilityContext = useVisibilityContext(currentBreakpoint, {
|
|
745
778
|
pageContext: resolvedPageContext,
|
|
746
779
|
isDesigner: isEditing
|
|
@@ -762,12 +795,20 @@ export default function ThemePlaceholderWrapper({
|
|
|
762
795
|
state when the designer comes back, so the panel and the canvas agree.
|
|
763
796
|
*/}
|
|
764
797
|
<PartStatePreview isDesigner={isEditing} />
|
|
798
|
+
{themeBackgroundCSS && (
|
|
799
|
+
<style dangerouslySetInnerHTML={{ __html: themeBackgroundCSS }} />
|
|
800
|
+
)}
|
|
765
801
|
{dynamicCSS && <style dangerouslySetInnerHTML={{ __html: dynamicCSS }} />}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
802
|
+
{/*
|
|
803
|
+
Deliberately unpainted. This wrapper sits behind every section of the
|
|
804
|
+
slot, so any colour on it showed through a section that asked for
|
|
805
|
+
`background-color: transparent` — the shipped default. That is why a
|
|
806
|
+
composed header could not sit over a hero: the section was transparent
|
|
807
|
+
and the wrapper behind it was not. The page colour belongs to `body`
|
|
808
|
+
(see buildThemeBackgroundCss), which paints the same pixels everywhere
|
|
809
|
+
the slot does not overlap something else.
|
|
810
|
+
*/}
|
|
811
|
+
<div data-placeholder={slug} className="theme-placeholder">
|
|
771
812
|
{renderedSections
|
|
772
813
|
.sort((a, b) => a.order - b.order)
|
|
773
814
|
.filter(
|