@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-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea0688]
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-rc.0",
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-rc.0",
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-rc.0",
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-rc.0",
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
- <div
767
- data-placeholder={slug}
768
- className="theme-placeholder"
769
- style={{ backgroundColor: 'var(--theme-background)' }}
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(