@akinon/pz-theme 2.0.54-rc.0 → 2.0.55-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,36 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.54-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 [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08fe]
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 [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.54-rc.0
33
- - @akinon/theme-contract@2.0.54-rc.0
3
+ ## 2.0.55-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd75]
8
+ - @akinon/next@2.0.55-beta.0
9
+ - @akinon/theme-contract@2.0.55-beta.0
10
+
11
+ ## 2.0.54
12
+
13
+ ### Patch Changes
14
+
15
+ - 2de849f: ZERO-4749: Publish a scroll state on chrome slots and add a `scrolled` style state
16
+ - @akinon/next@2.0.54
17
+ - @akinon/theme-contract@2.0.54
34
18
 
35
19
  ## 2.0.53
36
20
 
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.54-rc.0",
4
+ "version": "2.0.55-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.54-rc.0",
11
+ "@akinon/next": "2.0.55-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.54-rc.0",
16
+ "@akinon/theme-contract": "2.0.55-beta.0",
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.54-rc.0",
23
+ "@akinon/next": "2.0.55-beta.0",
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,227 @@
1
+ import React from 'react';
2
+ import { fireEvent, render } from '@testing-library/react';
3
+
4
+ import {
5
+ ChromeScrollState,
6
+ SCROLL_STATE_ATTRIBUTE,
7
+ SCROLL_STATE_THRESHOLD,
8
+ pinScrollState
9
+ } from '../scroll-state';
10
+ import { PartStatePreview } from '../../components/part-state-preview';
11
+
12
+ /**
13
+ * The scroll signal a composition styles against.
14
+ *
15
+ * Two properties matter more than the attribute itself. The first is that the
16
+ * storefront pays nothing for it: this has to be an IntersectionObserver, not a
17
+ * scroll handler running on the main thread for every frame of every scroll on
18
+ * every page that mounts a slot. The second is that the designer can see the
19
+ * state, which for a page-level fact means pinning the page rather than
20
+ * stamping an element — the mechanism every other state uses does not reach it.
21
+ */
22
+
23
+ type ObserverRecord = {
24
+ callback: IntersectionObserverCallback;
25
+ observed: Element[];
26
+ disconnected: boolean;
27
+ };
28
+
29
+ const observers: ObserverRecord[] = [];
30
+
31
+ class RecordingIntersectionObserver {
32
+ private readonly record: ObserverRecord;
33
+
34
+ constructor(callback: IntersectionObserverCallback) {
35
+ this.record = { callback, observed: [], disconnected: false };
36
+ observers.push(this.record);
37
+ }
38
+
39
+ observe(target: Element) {
40
+ this.record.observed.push(target);
41
+ }
42
+
43
+ unobserve() {}
44
+
45
+ disconnect() {
46
+ this.record.disconnected = true;
47
+ }
48
+
49
+ takeRecords() {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ /** Drives the newest observer as the browser would when the sentinel crosses. */
55
+ const crossViewportEdge = (isIntersecting: boolean) => {
56
+ const record = observers[observers.length - 1];
57
+ record.callback(
58
+ [{ isIntersecting } as IntersectionObserverEntry],
59
+ null as unknown as IntersectionObserver
60
+ );
61
+ };
62
+
63
+ const slotState = (slug = 'header') =>
64
+ document
65
+ .querySelector(`div[data-chrome-slot="${slug}"]`)
66
+ ?.getAttribute(SCROLL_STATE_ATTRIBUTE);
67
+
68
+ const renderSlot = (slug = 'header') =>
69
+ render(
70
+ <div data-chrome-slot={slug}>
71
+ <ChromeScrollState />
72
+ </div>
73
+ );
74
+
75
+ const originalObserver = window.IntersectionObserver;
76
+
77
+ beforeEach(() => {
78
+ observers.length = 0;
79
+ window.IntersectionObserver =
80
+ RecordingIntersectionObserver as unknown as typeof IntersectionObserver;
81
+ });
82
+
83
+ afterEach(() => {
84
+ pinScrollState(null);
85
+ window.IntersectionObserver = originalObserver;
86
+ });
87
+
88
+ describe('the chrome slot scroll signal', () => {
89
+ it('stamps the state on every slot and follows the sentinel', () => {
90
+ renderSlot();
91
+
92
+ // Present from the first paint: a slot whose attribute only appeared on the
93
+ // first crossing would leave `[data-scroll-state~="scrolled"]` unmatchable
94
+ // and the authored default unexplained.
95
+ expect(slotState()).toBe('top');
96
+
97
+ crossViewportEdge(false);
98
+ expect(slotState()).toBe('scrolled');
99
+
100
+ crossViewportEdge(true);
101
+ expect(slotState()).toBe('top');
102
+ });
103
+
104
+ it('observes a zero-height sentinel on the body, at the threshold', () => {
105
+ renderSlot();
106
+
107
+ const sentinel = document.querySelector<HTMLElement>(
108
+ '[data-scroll-state-sentinel]'
109
+ );
110
+
111
+ expect(sentinel).not.toBeNull();
112
+ // On the body rather than in the slot: the element that wants this state is
113
+ // usually fixed or sticky, and a sentinel inside it would travel with it and
114
+ // never leave the viewport.
115
+ expect(sentinel?.parentElement).toBe(document.body);
116
+ expect(sentinel?.style.height).toBe('0px');
117
+ expect(sentinel?.style.top).toBe(`${SCROLL_STATE_THRESHOLD}px`);
118
+ expect(observers[0].observed).toEqual([sentinel]);
119
+ });
120
+
121
+ it('adds no scroll listener anywhere on the storefront path', () => {
122
+ const onWindow = jest.spyOn(window, 'addEventListener');
123
+ const onDocument = jest.spyOn(document, 'addEventListener');
124
+
125
+ renderSlot();
126
+ crossViewportEdge(false);
127
+
128
+ const listened = [...onWindow.mock.calls, ...onDocument.mock.calls].map(
129
+ ([type]) => type
130
+ );
131
+
132
+ expect(listened).not.toContain('scroll');
133
+
134
+ onWindow.mockRestore();
135
+ onDocument.mockRestore();
136
+ });
137
+
138
+ it('serves every slot from one observer and takes it down with the last', () => {
139
+ const { unmount } = render(
140
+ <>
141
+ <div data-chrome-slot="header">
142
+ <ChromeScrollState />
143
+ </div>
144
+ <div data-chrome-slot="footer">
145
+ <ChromeScrollState />
146
+ </div>
147
+ </>
148
+ );
149
+
150
+ // Both slots read the same scroll position, so a second observer would be
151
+ // a second answer to a question that has one.
152
+ expect(observers).toHaveLength(1);
153
+
154
+ crossViewportEdge(false);
155
+ expect(slotState('header')).toBe('scrolled');
156
+ expect(slotState('footer')).toBe('scrolled');
157
+
158
+ unmount();
159
+ expect(observers[0].disconnected).toBe(true);
160
+ expect(
161
+ document.querySelector('[data-scroll-state-sentinel]')
162
+ ).toBeNull();
163
+ });
164
+
165
+ it('keeps the observer while one of several slots unmounts', () => {
166
+ const { unmount } = renderSlot('footer');
167
+ renderSlot('header');
168
+
169
+ unmount();
170
+
171
+ expect(observers[0].disconnected).toBe(false);
172
+ crossViewportEdge(false);
173
+ expect(slotState('header')).toBe('scrolled');
174
+ });
175
+ });
176
+
177
+ describe('previewing the scroll state in the designer', () => {
178
+ const previewState = (state: string | null) =>
179
+ fireEvent(
180
+ window,
181
+ new MessageEvent('message', {
182
+ data: { type: 'PREVIEW_PART_STATE', data: { state } }
183
+ })
184
+ );
185
+
186
+ it('pins the page rather than the part, and releases it again', () => {
187
+ render(
188
+ <>
189
+ <PartStatePreview isDesigner />
190
+ <div data-chrome-slot="header">
191
+ <ChromeScrollState />
192
+ </div>
193
+ </>
194
+ );
195
+
196
+ expect(slotState()).toBe('top');
197
+
198
+ // The canvas is at the top of the page and the designer cannot both hold it
199
+ // scrolled and reach the colour picker — the pin is the whole point.
200
+ previewState('scrolled');
201
+ expect(slotState()).toBe('scrolled');
202
+
203
+ // Moving to any other state releases it: a canvas left frozen mid-page
204
+ // disagrees with the storefront for every style typed after it.
205
+ previewState('hover');
206
+ expect(slotState()).toBe('top');
207
+ });
208
+
209
+ it('lets the observer win again once the pin is released', () => {
210
+ render(
211
+ <>
212
+ <PartStatePreview isDesigner />
213
+ <div data-chrome-slot="header">
214
+ <ChromeScrollState />
215
+ </div>
216
+ </>
217
+ );
218
+
219
+ crossViewportEdge(false);
220
+ previewState('scrolled');
221
+ previewState(null);
222
+
223
+ // Released to what the sentinel actually reports, not to 'top': the
224
+ // designer may have scrolled the canvas while the pin was held.
225
+ expect(slotState()).toBe('scrolled');
226
+ });
227
+ });
@@ -4,6 +4,7 @@ import { warnAboutUncomposedSlot } from './composition-state';
4
4
  import { getSlugCompositionState } from './composition-source';
5
5
  import { getThemeConfig } from './theme-config-source';
6
6
  import { ChromeFallback } from './chrome-fallback';
7
+ import { ChromeScrollState } from './scroll-state';
7
8
  import {
8
9
  resolveSlotSticky,
9
10
  slotStickyStyle,
@@ -116,6 +117,9 @@ export default async function ChromeSlot({
116
117
  ) : emptyZoneBand ? (
117
118
  <ChromeFallback slug={slug} emptyZone initiallyOverridden />
118
119
  ) : null}
120
+ {/* Publishes `data-scroll-state` on the wrapper above, so a composition
121
+ can style a header differently once the page is scrolled past it. */}
122
+ <ChromeScrollState />
119
123
  {pinned && <StickySlotOverflow />}
120
124
  <ThemePlaceholder slug={slug} pageContext={pageContext} />
121
125
  </div>
@@ -139,6 +143,7 @@ export default async function ChromeSlot({
139
143
  // live site keeps rendering nothing.
140
144
  <ChromeFallback slug={slug} emptyZone />
141
145
  ) : null}
146
+ <ChromeScrollState />
142
147
  {pinned && <StickySlotOverflow />}
143
148
  {/* Empty until the designer overrides this area from the editor */}
144
149
  <ThemePlaceholder slug={slug} pageContext={pageContext} />
@@ -0,0 +1,162 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ /**
6
+ * "The page has scrolled away from the top", published as a styling signal.
7
+ *
8
+ * A header that floats transparently over a hero has to turn opaque once the
9
+ * shopper scrolls past it, and no rung of the composition could say that: a
10
+ * style value is static per breakpoint, and the only scroll listener this
11
+ * package ships repositions the designer's own toolbar. The signal is published
12
+ * as an attribute on the slot — `data-scroll-state="top" | "scrolled"` — so the
13
+ * CSS generator can match it through an ancestor selector and no block renderer
14
+ * has to know the state exists at all (see utils/part-styles.ts).
15
+ *
16
+ * Deliberately an IntersectionObserver over a sentinel rather than a scroll
17
+ * handler. A scroll handler runs on the main thread for every frame of every
18
+ * scroll, on every page that mounts a slot, whether or not the composition
19
+ * styles the state — the observer costs nothing until the sentinel crosses the
20
+ * viewport edge. Pinned by test.
21
+ *
22
+ * The sentinel lives on `document.body`, not inside the slot. The element that
23
+ * wants this state is almost always the one that is `position: fixed` or
24
+ * `sticky`, and a sentinel inside it would travel with it and never leave the
25
+ * viewport. Absolutely positioned at the threshold instead, "no longer
26
+ * intersecting" reads as exactly `scrollY > threshold` — the same reading brand
27
+ * sticky-header hooks take from `window.scrollY`.
28
+ *
29
+ * One observer serves the whole document: every slot on the page reaches the
30
+ * same conclusion about the same scroll position. Slots register, and the last
31
+ * one to unmount takes the sentinel down with it.
32
+ */
33
+
34
+ /** The attribute the renderer's `scrolled` selector matches on. */
35
+ export const SCROLL_STATE_ATTRIBUTE = 'data-scroll-state';
36
+
37
+ /** The style-state name this signal answers to (see utils/part-styles.ts). */
38
+ export const SCROLL_STYLE_STATE = 'scrolled';
39
+
40
+ /**
41
+ * How far the page scrolls before it counts as scrolled, in px. Matches the
42
+ * threshold brand sticky-header hooks already use, so a composition that
43
+ * replaces one flips at the same place the hand-written header did.
44
+ */
45
+ export const SCROLL_STATE_THRESHOLD = 100;
46
+
47
+ export type ScrollState = 'top' | 'scrolled';
48
+
49
+ /** Every chrome slot carries the state; the selector is half of the contract. */
50
+ const CHROME_SLOT_SELECTOR = 'div[data-chrome-slot]';
51
+
52
+ let sentinel: HTMLElement | null = null;
53
+ let observer: IntersectionObserver | null = null;
54
+ let registrations = 0;
55
+ let observedState: ScrollState = 'top';
56
+ let pinnedState: ScrollState | null = null;
57
+
58
+ const stampSlots = () => {
59
+ if (typeof document === 'undefined') return;
60
+
61
+ const state = pinnedState ?? observedState;
62
+
63
+ document
64
+ .querySelectorAll<HTMLElement>(CHROME_SLOT_SELECTOR)
65
+ .forEach((slot) => slot.setAttribute(SCROLL_STATE_ATTRIBUTE, state));
66
+ };
67
+
68
+ const createSentinel = (): HTMLElement => {
69
+ const element = document.createElement('div');
70
+
71
+ element.setAttribute('data-scroll-state-sentinel', '');
72
+ element.setAttribute('aria-hidden', 'true');
73
+ // Zero-sized and out of the pointer's way: it exists to be observed, never to
74
+ // be seen, hit or to take part in layout. `top` carries the threshold, which
75
+ // is what makes the crossing happen at the scroll position that flips state
76
+ // rather than at zero.
77
+ element.style.position = 'absolute';
78
+ element.style.top = `${SCROLL_STATE_THRESHOLD}px`;
79
+ element.style.left = '0px';
80
+ element.style.width = '0px';
81
+ element.style.height = '0px';
82
+ element.style.pointerEvents = 'none';
83
+
84
+ return element;
85
+ };
86
+
87
+ const start = () => {
88
+ if (observer || typeof document === 'undefined') return;
89
+ // A browser without IntersectionObserver is not worth a scroll handler
90
+ // fallback: the state simply stays at its authored default, which is the
91
+ // unscrolled look the page loads with.
92
+ if (typeof IntersectionObserver === 'undefined') return;
93
+
94
+ sentinel = createSentinel();
95
+ document.body.appendChild(sentinel);
96
+
97
+ observer = new IntersectionObserver((entries) => {
98
+ const entry = entries[entries.length - 1];
99
+ if (!entry) return;
100
+
101
+ observedState = entry.isIntersecting ? 'top' : 'scrolled';
102
+ stampSlots();
103
+ });
104
+
105
+ observer.observe(sentinel);
106
+ };
107
+
108
+ const stop = () => {
109
+ observer?.disconnect();
110
+ observer = null;
111
+ sentinel?.remove();
112
+ sentinel = null;
113
+ observedState = 'top';
114
+ };
115
+
116
+ /**
117
+ * Registers one slot with the shared observer and returns the release its
118
+ * effect has to run.
119
+ */
120
+ export const observeScrollState = (): (() => void) => {
121
+ registrations += 1;
122
+ start();
123
+ // A slot that mounted after the last crossing — a client route change, a late
124
+ // hydration — would otherwise carry no attribute until the next one.
125
+ stampSlots();
126
+
127
+ return () => {
128
+ registrations -= 1;
129
+
130
+ if (registrations <= 0) {
131
+ registrations = 0;
132
+ stop();
133
+ }
134
+ };
135
+ };
136
+
137
+ /**
138
+ * Holds the page in a scroll state while the designer styles it.
139
+ *
140
+ * Every other style state is a fact about an element, so the canvas pins it by
141
+ * writing `data-state` onto that element. This one is a fact about the page and
142
+ * is matched through an ancestor, so there is nothing on the element to write —
143
+ * and a designer cannot hold the canvas scrolled and reach the colour picker at
144
+ * the same time any more than they can hold a hover. Pinning here overrides
145
+ * what the observer sees until it is released with `null`.
146
+ */
147
+ export const pinScrollState = (state: ScrollState | null): void => {
148
+ pinnedState = state;
149
+ stampSlots();
150
+ };
151
+
152
+ /**
153
+ * Mounted by every ChromeSlot. Renders nothing: the slot element it stamps is
154
+ * the server-rendered wrapper it sits inside.
155
+ */
156
+ export const ChromeScrollState = () => {
157
+ useEffect(() => observeScrollState(), []);
158
+
159
+ return null;
160
+ };
161
+
162
+ export default ChromeScrollState;
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useEffect } from 'react';
4
4
 
5
+ import { SCROLL_STYLE_STATE, pinScrollState } from '../chrome/scroll-state';
6
+
5
7
  /**
6
8
  * Pins a part's state open while the designer styles it.
7
9
  *
@@ -47,6 +49,19 @@ export const PartStatePreview = ({ isDesigner }: { isDesigner: boolean }) => {
47
49
  const { blockId, partName, state } = event.data.data || {};
48
50
  clear();
49
51
 
52
+ // One state is not a fact about the element: `scrolled` is matched
53
+ // through `data-scroll-state` on the chrome slot, so writing it onto the
54
+ // part would light nothing up and the designer would type a colour into
55
+ // a canvas that never changes. The page is pinned instead — and released
56
+ // on every other message, so the canvas never stays frozen in a scrolled
57
+ // state after the designer has moved on to a different one.
58
+ if (state === SCROLL_STYLE_STATE) {
59
+ pinScrollState('scrolled');
60
+ return;
61
+ }
62
+
63
+ pinScrollState(null);
64
+
50
65
  if (!blockId || !partName || !state) return;
51
66
 
52
67
  const block = document.querySelector<HTMLElement>(
@@ -83,6 +98,7 @@ export const PartStatePreview = ({ isDesigner }: { isDesigner: boolean }) => {
83
98
  return () => {
84
99
  window.removeEventListener('message', handleMessage);
85
100
  clear();
101
+ pinScrollState(null);
86
102
  };
87
103
  }, [isDesigner]);
88
104
 
package/src/index.ts CHANGED
@@ -28,6 +28,14 @@ export { default as ChromeSlot } from './chrome/chrome-slot';
28
28
  export { default as ComposableRegion } from './chrome/composable-region';
29
29
  export { ChromeFallback } from './chrome/chrome-fallback';
30
30
  export { CodeOwnedRegion } from './chrome/code-owned-region';
31
+ // Brand CSS following the same signal a composition's `scrolled` state follows
32
+ // needs the attribute name itself, not a second copy of the string.
33
+ export {
34
+ ChromeScrollState,
35
+ SCROLL_STATE_ATTRIBUTE,
36
+ SCROLL_STATE_THRESHOLD,
37
+ SCROLL_STYLE_STATE
38
+ } from './chrome/scroll-state';
31
39
  export { PreviewAuthStateReporter } from './chrome/preview-auth-state';
32
40
  export { LiveBasketPageContext } from './chrome/live-basket-page-context';
33
41
  export {
@@ -0,0 +1,206 @@
1
+ import { generateThemeCSS } from '../index';
2
+ import { stripHidingStyles } from '../checkout-safety';
3
+ import {
4
+ buildPartSelectors,
5
+ buildRootStateKey,
6
+ parseRootStateKey,
7
+ splitPartStyles
8
+ } from '../part-styles';
9
+ import type { Section } from '../../theme-section';
10
+
11
+ /**
12
+ * `scrolled` as a style state, at all three rungs it has to reach.
13
+ *
14
+ * Every other state is a fact about an element and matches on the element, so
15
+ * it can be expressed as a selector SUFFIX. This one is a fact about the page,
16
+ * published on the chrome slot, so its selector is ancestor-conditioned — which
17
+ * is why the part-selector builder had to learn a placeholder, and why the
18
+ * root path (block wrapper, section band) needed a key grammar at all: the
19
+ * first thing a designer wants from a scroll state is a whole header band
20
+ * changing colour, and a band is a section, not a part inside one.
21
+ */
22
+
23
+ const SLOT = '[data-scroll-state~="scrolled"]';
24
+
25
+ const section = (id: string, styles: Record<string, unknown>, blocks: unknown[] = []) =>
26
+ ({
27
+ id,
28
+ type: 'custom',
29
+ properties: {},
30
+ styles,
31
+ order: 0,
32
+ hidden: false,
33
+ blocks
34
+ }) as unknown as Section;
35
+
36
+ const block = (id: string, styles: Record<string, unknown>, type = 'group') => ({
37
+ id,
38
+ type,
39
+ label: 'Block',
40
+ properties: {},
41
+ styles,
42
+ order: 0,
43
+ hidden: false,
44
+ blocks: []
45
+ });
46
+
47
+ describe('the part rule', () => {
48
+ it('conditions the part on the slot without losing the ownership guard', () => {
49
+ const [selector] = buildPartSelectors('[data-block-id="blk-1"]', {
50
+ part: 'logo',
51
+ state: 'scrolled',
52
+ styles: {}
53
+ });
54
+
55
+ expect(selector).toBe(
56
+ `${SLOT} [data-block-id="blk-1"] [data-part="logo"]` +
57
+ ':not(:where([data-block-id="blk-1"] [data-block-id] *))'
58
+ );
59
+ });
60
+
61
+ it('still refuses to reach a nested block that names the same part', () => {
62
+ const [selector] = buildPartSelectors('[data-block-id="outer"]', {
63
+ part: 'logo',
64
+ state: 'scrolled',
65
+ styles: {}
66
+ });
67
+
68
+ document.body.innerHTML =
69
+ '<div data-chrome-slot="header" data-scroll-state="scrolled">' +
70
+ '<div data-block-id="outer">' +
71
+ '<img id="own" data-part="logo" />' +
72
+ '<div data-block-id="inner"><img id="nested" data-part="logo" /></div>' +
73
+ '</div>' +
74
+ '</div>';
75
+
76
+ expect(
77
+ Array.from(document.querySelectorAll(selector)).map((el) => el.id)
78
+ ).toEqual(['own']);
79
+ });
80
+
81
+ it('matches nothing while the page is at the top', () => {
82
+ const [selector] = buildPartSelectors('[data-block-id="blk-1"]', {
83
+ part: 'logo',
84
+ state: 'scrolled',
85
+ styles: {}
86
+ });
87
+
88
+ document.body.innerHTML =
89
+ '<div data-chrome-slot="header" data-scroll-state="top">' +
90
+ '<div data-block-id="blk-1"><img data-part="logo" /></div>' +
91
+ '</div>';
92
+
93
+ expect(document.querySelectorAll(selector)).toHaveLength(0);
94
+ });
95
+ });
96
+
97
+ describe('the root-state key grammar', () => {
98
+ it('reads `:state/property` and refuses anything else', () => {
99
+ expect(parseRootStateKey(':scrolled/background-color')).toEqual({
100
+ state: 'scrolled',
101
+ property: 'background-color'
102
+ });
103
+ expect(parseRootStateKey(buildRootStateKey('hover', 'color'))).toEqual({
104
+ state: 'hover',
105
+ property: 'color'
106
+ });
107
+
108
+ // A bare property, a part key and an unknown state all stay where they are.
109
+ expect(parseRootStateKey('background-color')).toBeNull();
110
+ expect(parseRootStateKey('logo/background-color')).toBeNull();
111
+ expect(parseRootStateKey(':wobble/background-color')).toBeNull();
112
+ });
113
+
114
+ it('cannot be confused with a part that happens to be named after a state', () => {
115
+ const { root, parts, rootStates } = splitPartStyles({
116
+ 'background-color': '#fff',
117
+ 'scrolled/background-color': '#eee',
118
+ ':scrolled/background-color': '#000'
119
+ });
120
+
121
+ expect(root).toEqual({ 'background-color': '#fff' });
122
+ expect(parts).toEqual([
123
+ { part: 'scrolled', state: null, styles: { 'background-color': '#eee' } }
124
+ ]);
125
+ expect(rootStates).toEqual([
126
+ { state: 'scrolled', styles: { 'background-color': '#000' } }
127
+ ]);
128
+ });
129
+ });
130
+
131
+ describe('the block and section root rules', () => {
132
+ it('emits a scrolled rule for a section band, after its plain rule', () => {
133
+ const css = generateThemeCSS([
134
+ section('sec-1', {
135
+ 'background-color': 'transparent',
136
+ ':scrolled/background-color': '#ffffff'
137
+ })
138
+ ]);
139
+
140
+ expect(css).toContain(
141
+ '[data-section-id="sec-1"] {\n background-color: transparent;\n}'
142
+ );
143
+ // Through the same background-opacity path a plain root colour takes, so a
144
+ // state value is composed exactly like the value it overrides.
145
+ expect(css).toContain(
146
+ `${SLOT} [data-section-id="sec-1"] {\n background-color: rgba(255, 255, 255, 1);\n}`
147
+ );
148
+ // Order is the tie-breaker the whole cascade in this file relies on; the
149
+ // state also carries more specificity, but it must never be written first.
150
+ expect(css.indexOf('[data-section-id="sec-1"] {')).toBeLessThan(
151
+ css.indexOf(`${SLOT} [data-section-id="sec-1"] {`)
152
+ );
153
+ });
154
+
155
+ it('emits a scrolled rule for a block wrapper', () => {
156
+ const css = generateThemeCSS([
157
+ section('sec-1', {}, [block('blk-1', { ':scrolled/color': '#111111' })])
158
+ ]);
159
+
160
+ expect(css).toContain(
161
+ `${SLOT} [data-block-id="blk-1"] {\n color: #111111;\n}`
162
+ );
163
+ });
164
+
165
+ it('follows a block that routes its root styles to its real root part', () => {
166
+ const css = generateThemeCSS([
167
+ section('sec-1', {}, [
168
+ {
169
+ ...block('blk-1', { ':scrolled/color': '#111111' }),
170
+ stylesTargetRoot: true
171
+ }
172
+ ])
173
+ ]);
174
+
175
+ // The state has to land on the same element the stateless root rule does,
176
+ // or it would style the transparent designer wrapper the block sits in.
177
+ expect(css).toContain(
178
+ `${SLOT} [data-block-id="blk-1"] [data-part="root"] {\n color: #111111;\n}`
179
+ );
180
+ });
181
+
182
+ it('never emits the raw key as a declaration', () => {
183
+ const css = generateThemeCSS([
184
+ section('sec-1', { ':scrolled/background-color': '#ffffff' })
185
+ ]);
186
+
187
+ expect(css).not.toContain(':scrolled/background-color:');
188
+ });
189
+ });
190
+
191
+ describe('checkout safety', () => {
192
+ it('strips a hiding declaration written into a root state', () => {
193
+ // Without the root-state key being read for its property, `display: none`
194
+ // would arrive under a name nothing recognises and sail through the one
195
+ // filter that stands between a composition and an unorderable checkout.
196
+ expect(
197
+ stripHidingStyles(
198
+ {
199
+ ':scrolled/display': 'none',
200
+ ':scrolled/background-color': '#ffffff'
201
+ },
202
+ 'root-only'
203
+ )
204
+ ).toEqual({ ':scrolled/background-color': '#ffffff' });
205
+ });
206
+ });
@@ -28,7 +28,7 @@
28
28
  * subtracts the values that end ordering.
29
29
  */
30
30
 
31
- import { parsePartStyleKey } from './part-styles';
31
+ import { parsePartStyleKey, parseRootStateKey } from './part-styles';
32
32
 
33
33
  export const CHECKOUT_FLOW_OUTLET_TYPE = 'checkout-flow-outlet';
34
34
 
@@ -142,6 +142,11 @@ export const stripHidingStyles = (
142
142
 
143
143
  Object.entries(styles || {}).forEach(([key, value]) => {
144
144
  const parsed = parsePartStyleKey(key);
145
+ // A root-state key (`:scrolled/display`) addresses the element's own root,
146
+ // so it is guarded exactly as a bare root key is. Reading the property out
147
+ // of it is what makes that true: left as the whole key, `display: none`
148
+ // would arrive here under a name nothing recognises and sail through.
149
+ const rootState = parsed ? null : parseRootStateKey(key);
145
150
 
146
151
  const guarded = parsed
147
152
  ? partScope === 'critical-parts' &&
@@ -153,7 +158,7 @@ export const stripHidingStyles = (
153
158
  return;
154
159
  }
155
160
 
156
- const property = parsed ? parsed.property : key;
161
+ const property = (parsed ?? rootState)?.property ?? key;
157
162
  const kept = stripHidingFromValue(property, value);
158
163
 
159
164
  if (kept !== undefined) safe[key] = kept;
@@ -5,7 +5,11 @@ import {
5
5
  neutralizeStyleTagBreakout,
6
6
  escapeCssAttributeValue
7
7
  } from './css-safety';
8
- import { buildPartSelectors, splitPartStyles } from './part-styles';
8
+ import {
9
+ buildPartSelectors,
10
+ buildRootStateSelectors,
11
+ splitPartStyles
12
+ } from './part-styles';
9
13
  import { buildCustomCss, buildPartCustomCss } from './custom-css';
10
14
  import {
11
15
  CHECKOUT_FLOW_OUTLET_TYPE,
@@ -1278,6 +1282,41 @@ const generatePartCSS = (
1278
1282
  return css;
1279
1283
  };
1280
1284
 
1285
+ /**
1286
+ * Rules for a block or section's own root in a state.
1287
+ *
1288
+ * Not `!important`, unlike the part rules above. Those need it because the
1289
+ * element they target carries the renderer's own inline styles; a block wrapper
1290
+ * or a section band does not, and the only rule this has to beat is the plain
1291
+ * root rule emitted a few lines earlier — which it already does on specificity,
1292
+ * a state selector being strictly longer than the stateless one. Forcing it
1293
+ * would buy nothing and cost the custom-CSS hatch its `!important`, which is
1294
+ * the designer's documented way to win an argument with a generated rule.
1295
+ */
1296
+ const generateRootStateCSS = (
1297
+ elementSelector: string,
1298
+ styles: Record<string, unknown>,
1299
+ targetBreakpoint?: string
1300
+ ): string => {
1301
+ const { rootStates } = splitPartStyles(styles);
1302
+ if (rootStates.length === 0) return '';
1303
+
1304
+ let css = '';
1305
+
1306
+ rootStates.forEach((bucket) => {
1307
+ const selectors = buildRootStateSelectors(elementSelector, bucket.state);
1308
+ if (selectors.length === 0) return;
1309
+
1310
+ css += generateElementCSS(
1311
+ selectors.join(',\n'),
1312
+ bucket.styles as ResponsiveStyles,
1313
+ targetBreakpoint
1314
+ );
1315
+ });
1316
+
1317
+ return css;
1318
+ };
1319
+
1281
1320
  const collectBlockCSS = (
1282
1321
  blocks: Block[],
1283
1322
  targetBreakpoint?: string
@@ -1308,21 +1347,24 @@ const collectBlockCSS = (
1308
1347
  // block wrapper (a padding meant for the dropdown would otherwise
1309
1348
  // reposition the whole block).
1310
1349
  const { root } = splitPartStyles(blockStyles);
1350
+ // `data-block-id` sits on the designer wrapper, not on the element the
1351
+ // renderer actually draws — so a padding or border set on a functional
1352
+ // block stopped at a transparent div and never reached the control.
1353
+ // A block that names its real root with `data-part="root"` gets its
1354
+ // own styles routed there instead. Blocks that do not are untouched,
1355
+ // which is what keeps published compositions rendering as before.
1356
+ const rootSelector = block.stylesTargetRoot
1357
+ ? `${selector} [data-part="root"]`
1358
+ : selector;
1311
1359
 
1312
1360
  if (Object.keys(root).length > 0) {
1313
- // `data-block-id` sits on the designer wrapper, not on the element the
1314
- // renderer actually draws — so a padding or border set on a functional
1315
- // block stopped at a transparent div and never reached the control.
1316
- // A block that names its real root with `data-part="root"` gets its
1317
- // own styles routed there instead. Blocks that do not are untouched,
1318
- // which is what keeps published compositions rendering as before.
1319
- const rootSelector = block.stylesTargetRoot
1320
- ? `${selector} [data-part="root"]`
1321
- : selector;
1322
-
1323
1361
  css += generateElementCSS(rootSelector, root, targetBreakpoint);
1324
1362
  }
1325
1363
 
1364
+ // After the plain root rule, and against the same element: a state has to
1365
+ // be able to override what the stateless value painted.
1366
+ css += generateRootStateCSS(rootSelector, blockStyles, targetBreakpoint);
1367
+
1326
1368
  css += generatePartCSS(selector, blockStyles, targetBreakpoint);
1327
1369
  }
1328
1370
 
@@ -1393,6 +1435,12 @@ export const generateThemeCSS = (
1393
1435
  );
1394
1436
  }
1395
1437
 
1438
+ css += generateRootStateCSS(
1439
+ sectionSelector,
1440
+ sectionStyles,
1441
+ targetBreakpoint
1442
+ );
1443
+
1396
1444
  css += generatePartCSS(
1397
1445
  sectionSelector,
1398
1446
  sectionStyles,
@@ -24,12 +24,17 @@
24
24
  */
25
25
 
26
26
  /**
27
- * State segment -> the selector suffixes that express it.
27
+ * State segment -> the selector expressions that express it.
28
28
  *
29
29
  * Every state also matches `[data-state~="…"]`, including the ones that are
30
30
  * really pseudo-classes. That is what lets the editor pin a state open: a
31
31
  * designer cannot hover an element while typing a hover colour, so the canvas
32
32
  * writes the state onto the element and the same rule applies.
33
+ *
34
+ * An expression is normally a SUFFIX appended to the styled element's selector.
35
+ * One containing `&` is a pattern instead, and `&` is where that selector goes
36
+ * — the only way to express a state that is matched on an ANCESTOR rather than
37
+ * on the element itself.
33
38
  */
34
39
  const STATE_SELECTORS: Record<string, string[]> = {
35
40
  hover: [':hover', '[data-state~="hover"]'],
@@ -63,14 +68,38 @@ const STATE_SELECTORS: Record<string, string[]> = {
63
68
  // "unavailable" never satisfies the "available" selector.
64
69
  available: ['[data-state~="available"]'],
65
70
  unavailable: ['[data-state~="unavailable"]'],
66
- unknown: ['[data-state~="unknown"]']
71
+ unknown: ['[data-state~="unknown"]'],
72
+ // The odd one out: every state above is a fact about the ELEMENT and matches
73
+ // on the element itself. This one is a fact about the PAGE — the chrome slot
74
+ // carries `data-scroll-state` (see chrome/scroll-state.tsx) — so it is
75
+ // matched through an ancestor and no renderer has to stamp anything for it to
76
+ // apply. That is what lets a header become opaque on scroll, which nothing in
77
+ // a composition could previously say.
78
+ scrolled: ['[data-scroll-state~="scrolled"] &']
67
79
  };
68
80
 
69
81
  export const PART_STYLE_STATES = Object.keys(STATE_SELECTORS);
70
82
 
83
+ /** Marks where the styled element's selector goes in an ancestor-matched state. */
84
+ const STATE_ELEMENT_PLACEHOLDER = '&';
85
+
86
+ /**
87
+ * Places the styled element's selector into a state expression.
88
+ *
89
+ * A suffix is appended, which is what every element-matched state needs. A
90
+ * pattern is substituted into, which is what an ancestor-matched state needs.
91
+ */
92
+ const applyStateExpression = (element: string, expression: string): string =>
93
+ expression.includes(STATE_ELEMENT_PLACEHOLDER)
94
+ ? expression.split(STATE_ELEMENT_PLACEHOLDER).join(element)
95
+ : `${element}${expression}`;
96
+
71
97
  /** Keys are `part/prop` or `part/state/prop`; `/` never appears in a CSS property. */
72
98
  const PART_KEY_DELIMITER = '/';
73
99
 
100
+ /** Leads a root-state key (`:scrolled/background-color`); see below. */
101
+ const ROOT_STATE_MARKER = ':';
102
+
74
103
  export type ParsedPartKey = {
75
104
  part: string;
76
105
  state: string | null;
@@ -79,6 +108,11 @@ export type ParsedPartKey = {
79
108
 
80
109
  export const parsePartStyleKey = (key: string): ParsedPartKey | null => {
81
110
  if (!key || key.indexOf(PART_KEY_DELIMITER) === -1) return null;
111
+ // A root-state key has the same two segments as a part key, so without this
112
+ // it parses as a part literally named ":scrolled" — which is not merely wrong
113
+ // output: `stripHidingStyles` reads part keys as belonging to a nested part
114
+ // and would wave a `display: none` on the checkout wrapper straight through.
115
+ if (key[0] === ROOT_STATE_MARKER) return null;
82
116
 
83
117
  const segments = key.split(PART_KEY_DELIMITER).filter(Boolean);
84
118
 
@@ -108,6 +142,47 @@ export const buildPartStyleKey = (
108
142
  ? [part, state, property].join(PART_KEY_DELIMITER)
109
143
  : [part, property].join(PART_KEY_DELIMITER);
110
144
 
145
+ /**
146
+ * The same states, on the element's OWN root rather than on a part inside it.
147
+ *
148
+ * A part key could already say "this dropdown, while scrolled", but a block or
149
+ * section root could not say anything of the kind: its keys are bare CSS
150
+ * properties with nowhere to put a state. That leaves the most obvious thing a
151
+ * designer wants from a scroll state — a whole header band changing its
152
+ * background and text colour — unreachable, since the band is the section, not
153
+ * a part inside it.
154
+ *
155
+ * Root-state keys are `:<state>/<property>`. The leading marker is what keeps
156
+ * them unambiguous: a bare `scrolled/background` would be indistinguishable
157
+ * from a part literally named `scrolled`, and `:` cannot appear in a part name
158
+ * (`sanitizePartName` strips it), so nothing that parses as one can ever parse
159
+ * as the other.
160
+ */
161
+ export type ParsedRootStateKey = {
162
+ state: string;
163
+ property: string;
164
+ };
165
+
166
+ export const parseRootStateKey = (key: string): ParsedRootStateKey | null => {
167
+ if (!key || key[0] !== ROOT_STATE_MARKER) return null;
168
+
169
+ const segments = key
170
+ .slice(ROOT_STATE_MARKER.length)
171
+ .split(PART_KEY_DELIMITER)
172
+ .filter(Boolean);
173
+
174
+ if (segments.length !== 2) return null;
175
+
176
+ const [state, property] = segments;
177
+ if (!STATE_SELECTORS[state]) return null;
178
+
179
+ return { state, property };
180
+ };
181
+
182
+ /** Builds the style key a root-state descriptor writes into. */
183
+ export const buildRootStateKey = (state: string, property: string): string =>
184
+ `${ROOT_STATE_MARKER}${state}${PART_KEY_DELIMITER}${property}`;
185
+
111
186
  /**
112
187
  * `data-part` plus the states the renderer wants stylable. Renderers spread
113
188
  * this onto the element instead of hand-writing the attributes, so the
@@ -142,18 +217,45 @@ export type PartStyleBucket = {
142
217
  styles: Record<string, unknown>;
143
218
  };
144
219
 
220
+ /** A root/state bucket of styles, ready for the CSS generator. */
221
+ export type RootStateBucket = {
222
+ state: string;
223
+ styles: Record<string, unknown>;
224
+ };
225
+
145
226
  /**
146
- * Splits a block's styles into the root bucket and one bucket per
147
- * part/state pair, preserving insertion order so generated CSS is stable
227
+ * Splits a block's styles into the root bucket, one bucket per part/state pair
228
+ * and one per root state, preserving insertion order so generated CSS is stable
148
229
  * (a moving stylesheet would defeat caching and make diffs unreadable).
149
230
  */
150
231
  export const splitPartStyles = (
151
232
  styles: Record<string, unknown> | undefined | null
152
- ): { root: Record<string, unknown>; parts: PartStyleBucket[] } => {
233
+ ): {
234
+ root: Record<string, unknown>;
235
+ parts: PartStyleBucket[];
236
+ rootStates: RootStateBucket[];
237
+ } => {
153
238
  const root: Record<string, unknown> = {};
154
239
  const buckets = new Map<string, PartStyleBucket>();
240
+ const rootStateBuckets = new Map<string, RootStateBucket>();
155
241
 
156
242
  Object.entries(styles || {}).forEach(([key, value]) => {
243
+ // Before the part parse: a root-state key is not a part key, and left to
244
+ // the root bucket it would be emitted as the invalid declaration
245
+ // `:scrolled/background-color:`.
246
+ const rootState = parseRootStateKey(key);
247
+
248
+ if (rootState) {
249
+ const bucket = rootStateBuckets.get(rootState.state) ?? {
250
+ state: rootState.state,
251
+ styles: {}
252
+ };
253
+
254
+ bucket.styles[rootState.property] = value;
255
+ rootStateBuckets.set(rootState.state, bucket);
256
+ return;
257
+ }
258
+
157
259
  const parsed = parsePartStyleKey(key);
158
260
 
159
261
  if (!parsed) {
@@ -172,7 +274,11 @@ export const splitPartStyles = (
172
274
  buckets.set(bucketKey, bucket);
173
275
  });
174
276
 
175
- return { root, parts: Array.from(buckets.values()) };
277
+ return {
278
+ root,
279
+ parts: Array.from(buckets.values()),
280
+ rootStates: Array.from(rootStateBuckets.values())
281
+ };
176
282
  };
177
283
 
178
284
  /** Only `[A-Za-z0-9_-]` survives, so a part name cannot break out of the selector. */
@@ -226,10 +332,35 @@ export const buildPartSelectors = (
226
332
 
227
333
  if (!bucket.state) return [`${base}${owned}`];
228
334
 
229
- const suffixes = STATE_SELECTORS[bucket.state];
230
- if (!suffixes) return [];
335
+ const expressions = STATE_SELECTORS[bucket.state];
336
+ if (!expressions) return [];
337
+
338
+ return expressions.map((expression) =>
339
+ expression.includes(STATE_ELEMENT_PLACEHOLDER)
340
+ ? // An ancestor-matched state substitutes the whole compound, guard
341
+ // included, so the rule can no more leak into a nested block than the
342
+ // stateless one can.
343
+ applyStateExpression(`${base}${owned}`, expression)
344
+ : `${base}${expression}${owned}`
345
+ );
346
+ };
347
+
348
+ /**
349
+ * Selectors for a block or section's OWN root in a state.
350
+ *
351
+ * No ownership guard here, and none is needed: the selector already names one
352
+ * element by id, so there is nothing for it to leak into.
353
+ */
354
+ export const buildRootStateSelectors = (
355
+ elementSelector: string,
356
+ state: string
357
+ ): string[] => {
358
+ const expressions = STATE_SELECTORS[state];
359
+ if (!expressions) return [];
231
360
 
232
- return suffixes.map((suffix) => `${base}${suffix}${owned}`);
361
+ return expressions.map((expression) =>
362
+ applyStateExpression(elementSelector, expression)
363
+ );
233
364
  };
234
365
 
235
366
  /**