@akinon/pz-theme 2.0.101-rc.0 → 2.0.102-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,38 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.101-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 [b1111d7b]
34
- - @akinon/next@2.0.101-rc.0
35
- - @akinon/theme-contract@2.0.101-rc.0
3
+ ## 2.0.102-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.102-beta.0
9
+ - @akinon/theme-contract@2.0.102-beta.0
10
+
11
+ ## 2.0.101
12
+
13
+ ### Patch Changes
14
+
15
+ - aa1754c0: ZERO-5018: Hold any state still for the designer, and apply every value one saves — a state can now be pinned on the element a root rule lands on (a block wrapper or a section band, not just a named part inside one), `overlay-open` and `docked` gained the page-level levers they have always been advertised with and never had, `opacity` authored in a state reaches the stylesheet instead of being dropped as a helper key, a state rule routed to a renderer's own root carries `!important` so it beats the inline styles that element already has, an unknown state name is refused rather than written into the element's own rule as an invalid declaration, part and state keys no longer leak into React inline styles, and the input block marks its control as its root so its border, background and focus ring reach the control rather than the transparent wrapper around it. The announce tells the editor which of these pins this build can perform, so an editor serving a storefront on an older theme keeps exactly the behaviour it has today.
16
+ - @akinon/next@2.0.101
17
+ - @akinon/theme-contract@2.0.101
36
18
 
37
19
  ## 2.0.100
38
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.101-rc.0",
4
+ "version": "2.0.102-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.101-rc.0",
11
+ "@akinon/next": "2.0.102-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.101-rc.0",
16
+ "@akinon/theme-contract": "2.0.102-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.101-rc.0",
24
+ "@akinon/next": "2.0.102-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",
@@ -80,10 +80,35 @@ const blockProps = {
80
80
  currentBreakpoint: 'desktop'
81
81
  };
82
82
 
83
+ /**
84
+ * The pin listener checks its origin like every other inbound listener in the
85
+ * package, so a bare same-window dispatch is refused. A localhost origin on a
86
+ * port that is not jsdom's own is trusted outside production without any
87
+ * allowlist — the same allowance local editor + storefront development runs on.
88
+ */
89
+ const EDITOR_ORIGIN = 'http://localhost:4400';
90
+
91
+ beforeAll(() => {
92
+ Object.defineProperty(window, 'parent', {
93
+ configurable: true,
94
+ value: { postMessage: () => undefined }
95
+ });
96
+ });
97
+
98
+ beforeEach(() => {
99
+ process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS = EDITOR_ORIGIN;
100
+ });
101
+
102
+ afterEach(() => {
103
+ delete process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS;
104
+ });
105
+
83
106
  const pin = (blockId: string, partName: string, state: string | null) =>
84
107
  act(() => {
85
108
  window.dispatchEvent(
86
109
  new MessageEvent('message', {
110
+ origin: EDITOR_ORIGIN,
111
+ source: window.parent as Window,
87
112
  data: {
88
113
  type: 'PREVIEW_PART_STATE',
89
114
  data: { blockId, partName, state }
@@ -243,16 +268,17 @@ describe('SearchBoxBlock under force-open', () => {
243
268
  expect(document.querySelector('[data-search-backdrop]')).toBeNull();
244
269
  expect(document.querySelector('[data-part="body"]')).toBeNull();
245
270
  // And with no backdrop the panel makes no claim about the page behind it.
246
- expect(
247
- document.querySelector('[data-search-overlay]')
248
- ).not.toHaveAttribute('aria-modal');
271
+ expect(document.querySelector('[data-search-overlay]')).not.toHaveAttribute(
272
+ 'aria-modal'
273
+ );
249
274
  });
250
275
 
251
276
  it('moves focus into the field on open and hands it back on close', () => {
252
277
  renderBox(false, {});
253
278
 
254
- const trigger = document.querySelector('[data-part="trigger"]') as
255
- HTMLElement;
279
+ const trigger = document.querySelector(
280
+ '[data-part="trigger"]'
281
+ ) as HTMLElement;
256
282
  trigger.focus();
257
283
  fireEvent.click(trigger);
258
284
 
@@ -270,8 +296,9 @@ describe('SearchBoxBlock under force-open', () => {
270
296
  it('leaves focus on the icon when the composition turned it off', () => {
271
297
  renderBox(false, {}, { autoFocus: false });
272
298
 
273
- const trigger = document.querySelector('[data-part="trigger"]') as
274
- HTMLElement;
299
+ const trigger = document.querySelector(
300
+ '[data-part="trigger"]'
301
+ ) as HTMLElement;
275
302
  trigger.focus();
276
303
  fireEvent.click(trigger);
277
304
 
@@ -363,7 +390,9 @@ describe('MegaMenuBlock under force-open', () => {
363
390
  try {
364
391
  renderMenu(true, { forceOpen: true });
365
392
 
366
- fireEvent.mouseLeave(document.querySelector('[data-mega-menu]') as Element);
393
+ fireEvent.mouseLeave(
394
+ document.querySelector('[data-mega-menu]') as Element
395
+ );
367
396
  act(() => {
368
397
  jest.advanceTimersByTime(2000);
369
398
  });
@@ -450,7 +479,10 @@ describe('NavMenuBlock under force-open', () => {
450
479
  });
451
480
 
452
481
  describe('HotspotBlock under force-open', () => {
453
- const renderHotspot = (isDesigner: boolean, styles: Record<string, unknown>) =>
482
+ const renderHotspot = (
483
+ isDesigner: boolean,
484
+ styles: Record<string, unknown>
485
+ ) =>
454
486
  render(
455
487
  <HotspotBlock
456
488
  block={mkBlock('hotspot', { title: 'Hot Title' }, styles)}
@@ -468,9 +500,7 @@ describe('HotspotBlock under force-open', () => {
468
500
  it('leaves the popup mounted when the close button is clicked', () => {
469
501
  const { container } = renderHotspot(true, { forceOpen: true });
470
502
 
471
- fireEvent.click(
472
- container.querySelector('.text-gray-400') as Element
473
- );
503
+ fireEvent.click(container.querySelector('.text-gray-400') as Element);
474
504
 
475
505
  expect(screen.getByText('Hot Title')).toBeInTheDocument();
476
506
  });
@@ -553,7 +583,9 @@ describe('PanelBlock under force-open', () => {
553
583
  it('leaves the panel mounted when the close button is clicked', () => {
554
584
  renderPanel(true, { forceOpen: true });
555
585
 
556
- fireEvent.click(document.querySelector('[data-size-guide-close]') as Element);
586
+ fireEvent.click(
587
+ document.querySelector('[data-size-guide-close]') as Element
588
+ );
557
589
 
558
590
  expect(
559
591
  document.querySelector('[data-part="modal"][data-state="open"]')
@@ -3,6 +3,11 @@
3
3
  import React, { useEffect, useMemo, useState } from 'react';
4
4
  import { useLocalization } from '@akinon/next/hooks';
5
5
  import { getResponsiveValue, resolveThemeCssVariables } from '../utils';
6
+ import {
7
+ isPartStyleKey,
8
+ isRootStateKey,
9
+ partAttrs
10
+ } from '../utils/part-styles';
6
11
  import { BlockRendererProps } from './block-renderer-registry';
7
12
  import { useThemeSettingsContext } from '../theme-settings-context';
8
13
 
@@ -68,6 +73,13 @@ const InputBlock = ({
68
73
  }
69
74
 
70
75
  Object.keys(block.styles).forEach((key) => {
76
+ // Routing, not a property: `:focus/border-color` names this control in a
77
+ // state and is turned into a real rule by the generated stylesheet. Left
78
+ // in, it becomes the React style prop `:focus/borderColor` — a warning
79
+ // and dead weight. This loop builds its own object rather than calling
80
+ // `getCSSStyles`, so it has to say so itself.
81
+ if (isPartStyleKey(key) || isRootStateKey(key)) return;
82
+
71
83
  const styleValue = getResponsiveValue(
72
84
  block.styles[key],
73
85
  currentBreakpoint
@@ -114,6 +126,16 @@ const InputBlock = ({
114
126
  disabled={disabled}
115
127
  style={inputStyles}
116
128
  onChange={handleChange}
129
+ /*
130
+ * The block's REAL root, and the reason this type is in
131
+ * INNER_ROOT_BLOCK_TYPES: everything a designer sets on an Input — the
132
+ * border, the radius, the background, the focus ring — belongs to this
133
+ * control, not to the transparent div the designer wrapper draws around
134
+ * it. Marking it routes the generated root rule here instead of onto the
135
+ * wrapper, which is also what lets a state at the root match anything at
136
+ * all: a div is never focused and never disabled.
137
+ */
138
+ {...partAttrs('root')}
117
139
  />
118
140
  );
119
141
  };
@@ -157,9 +157,7 @@ describe('the chrome slot scroll signal', () => {
157
157
 
158
158
  unmount();
159
159
  expect(observers[0].disconnected).toBe(true);
160
- expect(
161
- document.querySelector('[data-scroll-state-sentinel]')
162
- ).toBeNull();
160
+ expect(document.querySelector('[data-scroll-state-sentinel]')).toBeNull();
163
161
  });
164
162
 
165
163
  it('keeps the observer while one of several slots unmounts', () => {
@@ -188,11 +186,36 @@ describe('the chrome slot scroll signal', () => {
188
186
  });
189
187
  });
190
188
 
189
+ /**
190
+ * The pin listener checks its origin like every other inbound listener in the
191
+ * package, so a bare same-window dispatch is refused. A localhost origin on a
192
+ * port that is not jsdom's own is trusted outside production without any
193
+ * allowlist — the same allowance local editor + storefront development runs on.
194
+ */
195
+ const EDITOR_ORIGIN = 'http://localhost:4400';
196
+
197
+ beforeAll(() => {
198
+ Object.defineProperty(window, 'parent', {
199
+ configurable: true,
200
+ value: { postMessage: () => undefined }
201
+ });
202
+ });
203
+
204
+ beforeEach(() => {
205
+ process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS = EDITOR_ORIGIN;
206
+ });
207
+
208
+ afterEach(() => {
209
+ delete process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS;
210
+ });
211
+
191
212
  describe('previewing the scroll state in the designer', () => {
192
213
  const previewState = (state: string | null) =>
193
214
  fireEvent(
194
215
  window,
195
216
  new MessageEvent('message', {
217
+ origin: EDITOR_ORIGIN,
218
+ source: window.parent as Window,
196
219
  data: { type: 'PREVIEW_PART_STATE', data: { state } }
197
220
  })
198
221
  );
@@ -51,11 +51,45 @@ export const setChromeMode = (mode: ChromeMode): (() => void) => {
51
51
  const { body } = document;
52
52
  const previous = body.getAttribute(CHROME_MODE_ATTRIBUTE);
53
53
 
54
- if (mode === null) body.removeAttribute(CHROME_MODE_ATTRIBUTE);
55
- else body.setAttribute(CHROME_MODE_ATTRIBUTE, mode);
54
+ observedMode = mode;
55
+ writeChromeMode(pinnedMode ?? observedMode);
56
56
 
57
57
  return () => {
58
- if (previous === null) body.removeAttribute(CHROME_MODE_ATTRIBUTE);
59
- else body.setAttribute(CHROME_MODE_ATTRIBUTE, previous);
58
+ observedMode = previous === CHROME_MODE_DOCKED ? CHROME_MODE_DOCKED : null;
59
+ writeChromeMode(pinnedMode ?? observedMode);
60
60
  };
61
61
  };
62
+
63
+ /**
64
+ * What the brand asked for, and what the designer asked to SEE instead.
65
+ *
66
+ * Kept apart because they answer different questions and change on different
67
+ * clocks. `setChromeMode` above is the brand's setter and its cleanup restores
68
+ * the mode as it stood when it was called — correct for a route transition,
69
+ * wrong for a pin, because the brand is free to change routes while a pin is
70
+ * held and the restore would silently write the pin away.
71
+ */
72
+ let observedMode: ChromeMode = null;
73
+ let pinnedMode: ChromeMode = null;
74
+
75
+ const writeChromeMode = (mode: ChromeMode) => {
76
+ if (typeof document === 'undefined') return;
77
+
78
+ if (mode === null) document.body.removeAttribute(CHROME_MODE_ATTRIBUTE);
79
+ else document.body.setAttribute(CHROME_MODE_ATTRIBUTE, mode);
80
+ };
81
+
82
+ /**
83
+ * The designer's override, released with `null`.
84
+ *
85
+ * Which mode a route gets is the one thing a composition cannot ask about —
86
+ * only the brand knows which of its routes carry a hero — so this is also the
87
+ * only way a designer can ever SEE the docked half of the header they are
88
+ * styling. Mirrors `pinScrollState`: a layer over what the brand set, never a
89
+ * capture of it.
90
+ */
91
+ export const pinChromeMode = (mode: ChromeMode): void => {
92
+ if (pinnedMode === mode) return;
93
+ pinnedMode = mode;
94
+ writeChromeMode(pinnedMode ?? observedMode);
95
+ };
@@ -67,10 +67,35 @@ const accordion = (id: string, inner?: unknown) => ({
67
67
  ]
68
68
  });
69
69
 
70
+ /**
71
+ * The pin listener checks its origin like every other inbound listener in the
72
+ * package, so a bare same-window dispatch is refused. A localhost origin on a
73
+ * port that is not jsdom's own is trusted outside production without any
74
+ * allowlist — the same allowance local editor + storefront development runs on.
75
+ */
76
+ const EDITOR_ORIGIN = 'http://localhost:4400';
77
+
78
+ beforeAll(() => {
79
+ Object.defineProperty(window, 'parent', {
80
+ configurable: true,
81
+ value: { postMessage: () => undefined }
82
+ });
83
+ });
84
+
85
+ beforeEach(() => {
86
+ process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS = EDITOR_ORIGIN;
87
+ });
88
+
89
+ afterEach(() => {
90
+ delete process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS;
91
+ });
92
+
70
93
  const pin = (blockId: string, partName: string, state: string | null) =>
71
94
  act(() => {
72
95
  window.dispatchEvent(
73
96
  new MessageEvent('message', {
97
+ origin: EDITOR_ORIGIN,
98
+ source: window.parent as Window,
74
99
  data: {
75
100
  type: 'PREVIEW_PART_STATE',
76
101
  data: { blockId, partName, state }
@@ -0,0 +1,219 @@
1
+ import React from 'react';
2
+ import { act, render } from '@testing-library/react';
3
+ import { CHROME_MODE_ATTRIBUTE, setChromeMode } from '../../chrome/chrome-mode';
4
+ import { OVERLAY_STATE_ATTRIBUTE } from '../../hooks/use-overlay';
5
+ import ThemeBlock from '../../theme-block';
6
+ import { PartStatePreview } from '../part-state-preview';
7
+
8
+ /**
9
+ * Pinning a state on a ROOT, and on the page.
10
+ *
11
+ * The editor may now offer a state chip on any element that can really be in
12
+ * that state — a card that lights up under the pointer, a link that shows its
13
+ * focus ring — and a chip is only honest if the canvas can hold the state
14
+ * still while the colour is picked. Three of those pins did not exist:
15
+ *
16
+ * - a root address stamped nothing at all, because the handler demanded a
17
+ * part name and then searched the block's DESCENDANTS for it;
18
+ * - `overlay-open` and `docked` stamped the element while their selectors
19
+ * read `body`, so both chips have been decoration since they shipped.
20
+ */
21
+
22
+ const EDITOR_ORIGIN = 'http://localhost:4400';
23
+
24
+ beforeAll(() => {
25
+ Object.defineProperty(window, 'parent', {
26
+ configurable: true,
27
+ value: { postMessage: () => undefined }
28
+ });
29
+ });
30
+
31
+ const pin = (blockId: string, partName: string, state: string | null): void => {
32
+ act(() => {
33
+ window.dispatchEvent(
34
+ new MessageEvent('message', {
35
+ origin: EDITOR_ORIGIN,
36
+ source: window.parent as Window,
37
+ data: {
38
+ type: 'PREVIEW_PART_STATE',
39
+ data: { blockId, partName, state }
40
+ }
41
+ })
42
+ );
43
+ });
44
+ };
45
+
46
+ const text = (id: string) => ({
47
+ id,
48
+ type: 'text',
49
+ label: 'Text',
50
+ value: '<p>copy</p>',
51
+ properties: {},
52
+ styles: {},
53
+ order: 0,
54
+ hidden: false
55
+ });
56
+
57
+ const group = (id: string, children: unknown[]) => ({
58
+ id,
59
+ type: 'group',
60
+ label: 'Group',
61
+ properties: {},
62
+ styles: {},
63
+ order: 0,
64
+ hidden: false,
65
+ blocks: children
66
+ });
67
+
68
+ const stateOf = (root: HTMLElement, id: string): string | null =>
69
+ root
70
+ .querySelector<HTMLElement>(`[data-block-id="${id}"]`)
71
+ ?.getAttribute('data-state') ?? null;
72
+
73
+ const renderCanvas = (block: unknown) =>
74
+ render(
75
+ <>
76
+ <PartStatePreview isDesigner />
77
+ <ThemeBlock block={block as never} isDesigner />
78
+ </>
79
+ );
80
+
81
+ afterEach(() => {
82
+ document.body.removeAttribute(OVERLAY_STATE_ATTRIBUTE);
83
+ document.body.removeAttribute(CHROME_MODE_ATTRIBUTE);
84
+ });
85
+
86
+ describe('pinning a state on the element the root rule lands on', () => {
87
+ it('stamps the block wrapper when no part is named', () => {
88
+ const { container } = renderCanvas(group('grp', [text('grp-copy')]));
89
+
90
+ pin('grp', '', 'hover');
91
+
92
+ expect(stateOf(container, 'grp')).toBe('hover');
93
+ });
94
+
95
+ it('releases it again', () => {
96
+ const { container } = renderCanvas(group('grp', [text('grp-copy')]));
97
+
98
+ pin('grp', '', 'hover');
99
+ pin('grp', '', null);
100
+
101
+ expect(stateOf(container, 'grp')).toBeNull();
102
+ });
103
+
104
+ it('swaps one state for another without leaving the first behind', () => {
105
+ const { container } = renderCanvas(group('grp', [text('grp-copy')]));
106
+
107
+ pin('grp', '', 'hover');
108
+ pin('grp', '', 'focus');
109
+
110
+ expect(stateOf(container, 'grp')).toBe('focus');
111
+ });
112
+
113
+ // The wrapper is the element the rule names, and a block inside it marks a
114
+ // wrapper of its own. Stamping both would light up rules that belong to the
115
+ // nested block.
116
+ it('stamps only the addressed block, not the ones inside it', () => {
117
+ const { container } = renderCanvas(
118
+ group('outer', [group('inner', [text('inner-copy')])])
119
+ );
120
+
121
+ pin('outer', '', 'hover');
122
+
123
+ expect(stateOf(container, 'outer')).toBe('hover');
124
+ expect(stateOf(container, 'inner')).toBeNull();
125
+ });
126
+
127
+ it('does nothing for an address the canvas cannot resolve', () => {
128
+ const { container } = renderCanvas(group('grp', [text('grp-copy')]));
129
+
130
+ pin('gone', '', 'hover');
131
+
132
+ expect(container.querySelectorAll('[data-state]')).toHaveLength(0);
133
+ });
134
+ });
135
+
136
+ /**
137
+ * The two page states. Neither is a fact about the element: both are matched
138
+ * through an attribute on `body`, so stamping the element lights nothing up.
139
+ */
140
+ describe('pinning a state that belongs to the page', () => {
141
+ it('holds the overlay state on the body', () => {
142
+ renderCanvas(group('grp', [text('grp-copy')]));
143
+
144
+ pin('', '', 'overlay-open');
145
+
146
+ expect(document.body.getAttribute(OVERLAY_STATE_ATTRIBUTE)).toBe('open');
147
+ });
148
+
149
+ it('holds the docked mode on the body', () => {
150
+ renderCanvas(group('grp', [text('grp-copy')]));
151
+
152
+ pin('', '', 'docked');
153
+
154
+ expect(document.body.getAttribute(CHROME_MODE_ATTRIBUTE)).toBe('docked');
155
+ });
156
+
157
+ it('releases one page state when another is asked for', () => {
158
+ renderCanvas(group('grp', [text('grp-copy')]));
159
+
160
+ pin('', '', 'overlay-open');
161
+ pin('', '', 'docked');
162
+
163
+ expect(document.body.getAttribute(OVERLAY_STATE_ATTRIBUTE)).toBeNull();
164
+ expect(document.body.getAttribute(CHROME_MODE_ATTRIBUTE)).toBe('docked');
165
+ });
166
+
167
+ it('releases a page state when an element state is asked for', () => {
168
+ const { container } = renderCanvas(group('grp', [text('grp-copy')]));
169
+
170
+ pin('', '', 'docked');
171
+ pin('grp', '', 'hover');
172
+
173
+ expect(document.body.getAttribute(CHROME_MODE_ATTRIBUTE)).toBeNull();
174
+ expect(stateOf(container, 'grp')).toBe('hover');
175
+ });
176
+
177
+ // The brand owns the mode; the pin only layers over it. Restoring what the
178
+ // pin captured would write away a route change made while it was held.
179
+ it('gives the brand its mode back when the pin is released', () => {
180
+ renderCanvas(group('grp', [text('grp-copy')]));
181
+ setChromeMode('docked');
182
+
183
+ pin('', '', 'overlay-open');
184
+ pin('', '', null);
185
+
186
+ expect(document.body.getAttribute(CHROME_MODE_ATTRIBUTE)).toBe('docked');
187
+ setChromeMode(null);
188
+ });
189
+
190
+ it('leaves the page clean when the canvas unmounts', () => {
191
+ const view = renderCanvas(group('grp', [text('grp-copy')]));
192
+
193
+ pin('', '', 'overlay-open');
194
+ view.unmount();
195
+
196
+ expect(document.body.getAttribute(OVERLAY_STATE_ATTRIBUTE)).toBeNull();
197
+ });
198
+ });
199
+
200
+ describe('an untrusted frame', () => {
201
+ it('cannot pin anything', () => {
202
+ const { container } = renderCanvas(group('grp', [text('grp-copy')]));
203
+
204
+ act(() => {
205
+ window.dispatchEvent(
206
+ new MessageEvent('message', {
207
+ origin: 'http://evil.example',
208
+ source: window.parent as Window,
209
+ data: {
210
+ type: 'PREVIEW_PART_STATE',
211
+ data: { blockId: 'grp', partName: '', state: 'hover' }
212
+ }
213
+ })
214
+ );
215
+ });
216
+
217
+ expect(stateOf(container, 'grp')).toBeNull();
218
+ });
219
+ });
@@ -2,7 +2,10 @@
2
2
 
3
3
  import { useEffect } from 'react';
4
4
 
5
+ import { CHROME_MODE_DOCKED, pinChromeMode } from '../chrome/chrome-mode';
5
6
  import { SCROLL_STYLE_STATE, pinScrollState } from '../chrome/scroll-state';
7
+ import { isTrustedEditorEvent } from '../editor-bridge';
8
+ import { OVERLAY_PIN_STATE, pinOverlayState } from '../hooks/use-overlay';
6
9
 
7
10
  /**
8
11
  * Pins a part's state open while the designer styles it.
@@ -43,34 +46,92 @@ export const PartStatePreview = ({ isDesigner }: { isDesigner: boolean }) => {
43
46
  });
44
47
  };
45
48
 
49
+ const releasePagePins = () => {
50
+ pinScrollState(null);
51
+ pinOverlayState(false);
52
+ pinChromeMode(null);
53
+ };
54
+
46
55
  const handleMessage = (event: MessageEvent) => {
56
+ // The only designer listener in this package that did not check. Same
57
+ // guard as every other inbound message: a wrong origin, or a protocol the
58
+ // other side has moved past, is refused rather than acted on.
59
+ if (!isTrustedEditorEvent(event)) return;
47
60
  if (event.data?.type !== 'PREVIEW_PART_STATE') return;
48
61
 
49
62
  const { blockId, partName, state } = event.data.data || {};
50
63
  clear();
51
64
 
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.
65
+ // A PAGE state is not a fact about the element. `scrolled` is matched
66
+ // through `data-scroll-state` on the chrome slot; `overlay-open` and
67
+ // `docked` through attributes on `body`. Stamping any of the three onto
68
+ // the element lights nothing up — which is exactly how two of the three
69
+ // chips the editor has been showing came to be decoration. Each now has a
70
+ // lever of its own, and every message releases the ones it is not asking
71
+ // for, so the canvas is never left frozen in a state the designer has
72
+ // moved on from.
73
+ //
74
+ // These branches stay ABOVE the address check, as `scrolled` always has:
75
+ // a page state needs no element, which is also why a root chip appeared
76
+ // to work for `scrolled` while sending an empty address.
58
77
  if (state === SCROLL_STYLE_STATE) {
78
+ pinOverlayState(false);
79
+ pinChromeMode(null);
59
80
  pinScrollState('scrolled');
60
81
  return;
61
82
  }
62
83
 
63
- pinScrollState(null);
84
+ if (state === OVERLAY_PIN_STATE) {
85
+ pinScrollState(null);
86
+ pinChromeMode(null);
87
+ pinOverlayState(true);
88
+ return;
89
+ }
64
90
 
65
- if (!blockId || !partName || !state) return;
91
+ if (state === CHROME_MODE_DOCKED) {
92
+ pinScrollState(null);
93
+ pinOverlayState(false);
94
+ pinChromeMode(CHROME_MODE_DOCKED);
95
+ return;
96
+ }
66
97
 
67
- const block = document.querySelector<HTMLElement>(
68
- `[data-block-id="${CSS.escape(String(blockId))}"]`
98
+ releasePagePins();
99
+
100
+ if (!blockId || !state) return;
101
+
102
+ const id = CSS.escape(String(blockId));
103
+ // A block wrapper or a section band: the generated root-state rule names
104
+ // one element by id either way, and a section owns parts too. Resolving
105
+ // both here is what lets the editor address a section without the message
106
+ // growing a field — the payload already carries everything it needs.
107
+ const host = document.querySelector<HTMLElement>(
108
+ `[data-block-id="${id}"], [data-section-id="${id}"]`
69
109
  );
70
- if (!block) return;
110
+ if (!host) return;
111
+
112
+ const pinOn = (element: HTMLElement) => {
113
+ const existing = element.getAttribute('data-state') || '';
114
+ // The pinned token is remembered, not the whole value: the renderer
115
+ // is free to change the element's real state while it is pinned.
116
+ element.setAttribute(PREVIEW_ATTRIBUTE, String(state));
117
+ element.setAttribute(
118
+ 'data-state',
119
+ existing ? `${existing} ${state}` : String(state)
120
+ );
121
+ };
122
+
123
+ // No part named: the ROOT was addressed, and a root-state rule lands on
124
+ // this element itself. A block whose renderer draws its own root is
125
+ // addressed as the part "root" instead — that is where the generator
126
+ // routes the rule — and the branch below already reaches exactly that
127
+ // element, ownership guard included.
128
+ if (!partName) {
129
+ pinOn(host);
130
+ return;
131
+ }
71
132
 
72
133
  Array.from(
73
- block.querySelectorAll<HTMLElement>(
134
+ host.querySelectorAll<HTMLElement>(
74
135
  `[data-part="${CSS.escape(String(partName))}"]`
75
136
  )
76
137
  )
@@ -78,27 +139,24 @@ export const PartStatePreview = ({ isDesigner }: { isDesigner: boolean }) => {
78
139
  // search also finds the identically-named part of a block nested
79
140
  // inside this one. A part belongs to its closest `[data-block-id]`
80
141
  // ancestor — the same rule a click already uses to decide which part
81
- // it selected (hooks/use-designer-features). Stamping a nested block's
82
- // part would fire that block's own state rules and show the author a
83
- // canvas that disagrees with the storefront.
84
- .filter((element) => element.closest('[data-block-id]') === block)
85
- .forEach((element) => {
86
- const existing = element.getAttribute('data-state') || '';
87
- // The pinned token is remembered, not the whole value: the renderer
88
- // is free to change the element's real state while it is pinned.
89
- element.setAttribute(PREVIEW_ATTRIBUTE, String(state));
90
- element.setAttribute(
91
- 'data-state',
92
- existing ? `${existing} ${state}` : String(state)
93
- );
94
- });
142
+ // it selected (hooks/use-designer-features). A SECTION's own part is
143
+ // one with no block between it and the band; both readings are the
144
+ // guard the generator already emits.
145
+ .filter((element) => {
146
+ const owner = element.closest('[data-block-id]');
147
+ return owner === host || !(owner && host.contains(owner));
148
+ })
149
+ .forEach(pinOn);
95
150
  };
96
151
 
97
152
  window.addEventListener('message', handleMessage);
98
153
  return () => {
99
154
  window.removeEventListener('message', handleMessage);
100
155
  clear();
101
- pinScrollState(null);
156
+ // All three, not just the scroll pin: a module-level attribute that
157
+ // outlived the editor session would follow the user into the storefront
158
+ // they browse in that same tab.
159
+ releasePagePins();
102
160
  };
103
161
  }, [isDesigner]);
104
162
 
@@ -67,19 +67,53 @@ const stackListeners = new Set<() => void>();
67
67
  export const OVERLAY_STATE_ATTRIBUTE = 'data-overlay-state';
68
68
  export const OVERLAY_STYLE_STATE = 'open';
69
69
 
70
+ /**
71
+ * The STYLE-STATE name this signal answers to, which is NOT the attribute value.
72
+ *
73
+ * `data-overlay-state="open"` is what the selector matches; `overlay-open` is
74
+ * what the style key says and what the editor puts on the wire. They have
75
+ * always been two different strings, and a pin that compared against
76
+ * OVERLAY_STYLE_STATE would look right and never fire once.
77
+ */
78
+ export const OVERLAY_PIN_STATE = 'overlay-open';
79
+
80
+ /**
81
+ * The designer's override of the overlay state.
82
+ *
83
+ * A layer OVER the stack rather than a fake push, exactly as
84
+ * `chrome/scroll-state.tsx` layers a pinned state over its observer. A fake
85
+ * push would be popped by the next real overlay to close, and a pin a shopper
86
+ * interaction can cancel is worse than no pin at all.
87
+ *
88
+ * It has to exist because the canvas can never reach this state by itself: the
89
+ * push effect is gated on `active = open && !isDesigner`, so a panel
90
+ * force-opened for styling pushes nothing. A designer asking to see "while a
91
+ * panel is open" is therefore the only way this attribute is ever set in the
92
+ * editor.
93
+ */
94
+ let pinnedOverlay = false;
95
+
70
96
  const syncOverlayStateAttribute = () => {
71
97
  if (typeof document === 'undefined') return;
72
98
 
73
99
  // Driven off the stack rather than off each open/close call: two overlays
74
100
  // closing in either order must leave exactly one attribute state behind, and
75
- // the stack is already the thing that knows whether anything is left.
76
- if (overlayStack.length > 0) {
101
+ // the stack is already the thing that knows whether anything is left. The
102
+ // designer pin rides on top of it and never disturbs it, so releasing the pin
103
+ // restores whatever the page was really doing.
104
+ if (pinnedOverlay || overlayStack.length > 0) {
77
105
  document.body.setAttribute(OVERLAY_STATE_ATTRIBUTE, OVERLAY_STYLE_STATE);
78
106
  } else {
79
107
  document.body.removeAttribute(OVERLAY_STATE_ATTRIBUTE);
80
108
  }
81
109
  };
82
110
 
111
+ export const pinOverlayState = (pinned: boolean): void => {
112
+ if (pinnedOverlay === pinned) return;
113
+ pinnedOverlay = pinned;
114
+ syncOverlayStateAttribute();
115
+ };
116
+
83
117
  const notifyStack = () => {
84
118
  syncOverlayStateAttribute();
85
119
  stackListeners.forEach((listener) => listener());
@@ -606,7 +640,8 @@ export function useOverlay<T extends HTMLElement = HTMLElement>(
606
640
  return () => document.removeEventListener('keydown', handleKeyDown);
607
641
  }, [active, closeOnEscape, isTopmost]);
608
642
 
609
- const ariaModal = active && modal && claimsPage ? ('true' as const) : undefined;
643
+ const ariaModal =
644
+ active && modal && claimsPage ? ('true' as const) : undefined;
610
645
 
611
646
  const overlayProps = useMemo(
612
647
  () => ({ ref: panelRef, 'aria-modal': ariaModal }),
@@ -1,5 +1,9 @@
1
1
  import { getCatalogVendor, getCustomCatalog } from './custom/catalog';
2
2
  import { getCustomSectionCatalog } from './custom/section-catalog';
3
+ import {
4
+ STATE_PIN_CAPABILITIES,
5
+ type StatePinCapability
6
+ } from './state-pin-capabilities';
3
7
  import type { CustomSectionCatalog } from './custom/section-types';
4
8
  import type { CustomCatalog } from './custom/types';
5
9
  import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
@@ -52,6 +56,22 @@ interface PlaceholdersAvailablePayload {
52
56
  * with it.
53
57
  */
54
58
  customSections?: CustomSectionCatalog;
59
+
60
+ /**
61
+ * Which kinds of state pin THIS build can perform, so the editor can offer
62
+ * exactly the state chips this storefront is able to hold still.
63
+ *
64
+ * Additive, and deliberately a list of capability NAMES rather than a version
65
+ * number: the editor must not have to know which release added what, and one
66
+ * Omnitron serves channels whose storefronts pin their own theme version.
67
+ *
68
+ * A build that does not send it is read as the baseline every shipped version
69
+ * can do — pinning the scroll state — so a storefront that has not upgraded
70
+ * keeps exactly the chip row it has today rather than losing it to a
71
+ * handshake it has never heard of. `scroll` is therefore not listed here:
72
+ * listing it would make an old storefront look LESS capable than it is.
73
+ */
74
+ statePins?: StatePinCapability[];
55
75
  }
56
76
 
57
77
  const isFramed = (): boolean =>
@@ -103,6 +123,8 @@ const flush = () => {
103
123
 
104
124
  const customSections = getCustomSectionCatalog(getCatalogVendor());
105
125
  if (customSections) data.customSections = customSections;
126
+
127
+ data.statePins = STATE_PIN_CAPABILITIES;
106
128
  }
107
129
 
108
130
  postToEditor({
@@ -0,0 +1,28 @@
1
+ /**
2
+ * What this build can hold still for a designer, announced to the editor.
3
+ *
4
+ * The editor offers a state chip only where it can also SHOW the state, so it
5
+ * has to be told what the storefront in front of it is able to pin — one
6
+ * Omnitron serves channels whose storefronts pin their own theme version, and
7
+ * "the repo has it" is not a fact the editor may assume.
8
+ *
9
+ * - `element` a state is stamped on the addressed element, a block wrapper
10
+ * and a section band included.
11
+ * - `overlay` `overlay-open` is held on the page.
12
+ * - `chrome-mode` `docked` is held on the page.
13
+ *
14
+ * `scroll` is absent on purpose: every version that ever shipped could do it
15
+ * and the editor assumes it, so listing it would make an old storefront look
16
+ * less capable than it is.
17
+ *
18
+ * Its own module rather than a constant beside the component that implements
19
+ * it: the placeholder registry announces this, and it must not have to pull a
20
+ * client component in to say one list of strings.
21
+ */
22
+ export type StatePinCapability = 'element' | 'overlay' | 'chrome-mode';
23
+
24
+ export const STATE_PIN_CAPABILITIES: readonly StatePinCapability[] = [
25
+ 'element',
26
+ 'overlay',
27
+ 'chrome-mode'
28
+ ];
@@ -37,7 +37,7 @@ const blockOf = (overrides: Partial<Block>): Block =>
37
37
  styles: {},
38
38
  blocks: [],
39
39
  ...overrides
40
- }) as Block;
40
+ } as Block);
41
41
 
42
42
  const sectionWith = (block: Partial<Block>) =>
43
43
  [
@@ -75,9 +75,10 @@ describe('a renderer that draws its own root', () => {
75
75
  expect(css).not.toContain('[data-part="root"]');
76
76
  });
77
77
 
78
- it('covers the three renderers that mark their root', () => {
78
+ it('covers exactly the renderers that mark their root', () => {
79
79
  expect([...INNER_ROOT_BLOCK_TYPES].sort()).toEqual([
80
80
  'button',
81
+ 'input',
81
82
  'link',
82
83
  'text'
83
84
  ]);
@@ -0,0 +1,158 @@
1
+ import { generateThemeCSS, getCSSStyles } from '../index';
2
+
3
+ /**
4
+ * The values a root state saves, and where they are allowed to end up.
5
+ *
6
+ * A root state used to be reachable on a chrome band and nowhere else, so three
7
+ * holes in this path stayed dormant. Offering states on every element opens all
8
+ * three at once: an authored value that produces no declaration, a routing key
9
+ * turned into a React style prop, and an unknown state name written into the
10
+ * element's own rule as junk.
11
+ */
12
+
13
+ const block = (id: string, styles: Record<string, unknown>) => ({
14
+ id,
15
+ type: 'group',
16
+ label: 'Group',
17
+ properties: {},
18
+ styles,
19
+ order: 0,
20
+ hidden: false,
21
+ blocks: []
22
+ });
23
+
24
+ const section = (
25
+ id: string,
26
+ styles: Record<string, unknown>,
27
+ blocks: unknown[] = []
28
+ ) => ({
29
+ id,
30
+ type: 'custom',
31
+ label: 'Section',
32
+ properties: {},
33
+ styles,
34
+ order: 0,
35
+ blocks
36
+ });
37
+
38
+ const css = (styles: Record<string, unknown>) =>
39
+ generateThemeCSS([section('sec', {}, [block('blk', styles)]) as never]);
40
+
41
+ describe('a value authored in a state', () => {
42
+ /**
43
+ * `opacity` is a HELPER key on a block root — it feeds the hover/alpha maths
44
+ * rather than being emitted. In a STATE bucket it is a real CSS property, as
45
+ * it has always been on a part, and the panel offers a slider for it.
46
+ */
47
+ it('emits opacity, which the panel has always offered and the sheet dropped', () => {
48
+ expect(css({ ':hover/opacity': '50' })).toContain('opacity');
49
+ });
50
+
51
+ it('emits an ordinary property in the same bucket', () => {
52
+ expect(css({ ':hover/background-color': '#00ff00' })).toContain(
53
+ '[data-block-id="blk"]:hover'
54
+ );
55
+ });
56
+
57
+ // The alternative selector IS the pin hook: the canvas writes the token onto
58
+ // the element and the same rule applies, which is what lets the author see
59
+ // what they are picking.
60
+ it('matches the pinned token beside the real pseudo-class', () => {
61
+ expect(css({ ':hover/background-color': '#00ff00' })).toContain(
62
+ '[data-block-id="blk"][data-state~="hover"]'
63
+ );
64
+ });
65
+ });
66
+
67
+ describe('the full CSS surface in a state', () => {
68
+ /**
69
+ * The editor offers every CSS property inside a transient state, on the
70
+ * grounds that nothing written into a hover outlives the hover. That is only
71
+ * true if the generator writes them — a property it dropped would be a
72
+ * control that saves and does nothing.
73
+ */
74
+ it.each([
75
+ 'clip-path',
76
+ 'backdrop-filter',
77
+ 'mix-blend-mode',
78
+ 'letter-spacing',
79
+ 'z-index'
80
+ ])('emits %s authored in a state', (property) => {
81
+ const sheet = css({ [`:hover/${property}`]: 'inherit' });
82
+
83
+ expect(sheet).toContain('[data-block-id="blk"]:hover');
84
+ expect(sheet).toContain(`${property}: inherit`);
85
+ });
86
+ });
87
+
88
+ describe('a state name this build does not know', () => {
89
+ it('is refused rather than written into the element rule', () => {
90
+ // `:visited` is the deliberate absence: a link really has it, but the
91
+ // preview cannot put an element into it, so it is refused rather than
92
+ // offered against a canvas that can never show it.
93
+ const sheet = css({
94
+ ':visited/color': '#123456',
95
+ color: '#000000'
96
+ });
97
+
98
+ expect(sheet).not.toContain('visited');
99
+ // The rest of the element's rule is untouched.
100
+ expect(sheet).toContain('color: #000000');
101
+ });
102
+
103
+ it('is refused for a section band too', () => {
104
+ const sheet = generateThemeCSS([
105
+ section('sec', { ':visited/color': '#123456' }) as never
106
+ ]);
107
+
108
+ expect(sheet).not.toContain(':visited/color');
109
+ });
110
+ });
111
+
112
+ describe('a routing key never becomes an inline style', () => {
113
+ /**
114
+ * `panel/color` means "the panel, in the base state" and `:hover/color` means
115
+ * "this element, while hovered". Both become real rules against selectors an
116
+ * inline object cannot express; passed through they are React style props
117
+ * named `panel/color` and `:hover/color` — a dev warning and dead weight, and
118
+ * on the inner-root path a token-valued one would be inlined onto the exact
119
+ * element the generated rule targets.
120
+ */
121
+ it('drops root-state and part keys, and keeps the real ones', () => {
122
+ expect(
123
+ getCSSStyles({
124
+ ':hover/background-color': '#00ff00',
125
+ 'panel/color': '#ff0000',
126
+ 'background-color': '#0000ff'
127
+ })
128
+ // Composited through the background-opacity maths, as any real property
129
+ // on this path is — the point here is that it is the ONLY key left.
130
+ ).toEqual({ backgroundColor: 'rgba(0, 0, 255, 1)' });
131
+ });
132
+
133
+ it('drops an unknown state key as well, since it is still routing', () => {
134
+ expect(getCSSStyles({ ':visited/color': '#123456' })).toEqual({});
135
+ });
136
+ });
137
+
138
+ /**
139
+ * The focus a CONTAINER has.
140
+ *
141
+ * A wrapper is not focusable, so `focus` never matches it, and the input's own
142
+ * `focus` paints the control rather than the box around it. This is the one
143
+ * state a container can genuinely be in that no tag gives it — and the
144
+ * commonest form pattern there is.
145
+ */
146
+ describe('a container whose contents are focused', () => {
147
+ it('emits the real pseudo-class', () => {
148
+ expect(css({ ':focus-within/border-color': '#00ff00' })).toContain(
149
+ '[data-block-id="blk"]:focus-within'
150
+ );
151
+ });
152
+
153
+ it('matches the pinned token too, so the canvas can hold it', () => {
154
+ expect(css({ ':focus-within/border-color': '#00ff00' })).toContain(
155
+ '[data-block-id="blk"][data-state~="focus-within"]'
156
+ );
157
+ });
158
+ });
@@ -23,7 +23,11 @@ import type { Section } from '../../theme-section';
23
23
 
24
24
  const SLOT = '[data-scroll-state~="scrolled"]';
25
25
 
26
- const section = (id: string, styles: Record<string, unknown>, blocks: unknown[] = []) =>
26
+ const section = (
27
+ id: string,
28
+ styles: Record<string, unknown>,
29
+ blocks: unknown[] = []
30
+ ) =>
27
31
  ({
28
32
  id,
29
33
  type: 'custom',
@@ -32,9 +36,13 @@ const section = (id: string, styles: Record<string, unknown>, blocks: unknown[]
32
36
  order: 0,
33
37
  hidden: false,
34
38
  blocks
35
- }) as unknown as Section;
39
+ } as unknown as Section);
36
40
 
37
- const block = (id: string, styles: Record<string, unknown>, type = 'group') => ({
41
+ const block = (
42
+ id: string,
43
+ styles: Record<string, unknown>,
44
+ type = 'group'
45
+ ) => ({
38
46
  id,
39
47
  type,
40
48
  label: 'Block',
@@ -177,13 +185,30 @@ describe('the block and section root rules', () => {
177
185
  // or it would style the transparent designer wrapper the block sits in.
178
186
  // Ownership guard included: a routed root rule is a descendant selector,
179
187
  // and a nested block marks a root of its own.
188
+ //
189
+ // `!important` on this path only, exactly as a part rule carries it and for
190
+ // the identical reason: the routed element is the one the renderer inlines
191
+ // its own styles onto, and inline outranks any stylesheet. A stock button
192
+ // is seeded a background colour that way, so without this a state colour
193
+ // would be written, saved, shown in the panel and never seen.
180
194
  expect(css).toContain(
181
195
  `${SLOT} [data-block-id="blk-1"] [data-part="root"]` +
182
196
  `:not(:where([data-block-id="blk-1"] [data-block-id] *))` +
183
- ` {\n color: #111111;\n}`
197
+ ` {\n color: #111111 !important;\n}`
184
198
  );
185
199
  });
186
200
 
201
+ it('leaves the wrapper path unforced, so custom CSS can still win', () => {
202
+ const css = generateThemeCSS([
203
+ section('sec-1', {}, [block('blk-1', { ':scrolled/color': '#111111' })])
204
+ ]);
205
+
206
+ expect(css).toContain(
207
+ `${SLOT} [data-block-id="blk-1"] {\n color: #111111;\n}`
208
+ );
209
+ expect(css).not.toContain('color: #111111 !important');
210
+ });
211
+
187
212
  it('never emits the raw key as a declaration', () => {
188
213
  const css = generateThemeCSS([
189
214
  section('sec-1', { ':scrolled/background-color': '#ffffff' })
@@ -9,6 +9,8 @@ import {
9
9
  buildInnerRootSelectors,
10
10
  buildPartSelectors,
11
11
  buildRootStateSelectors,
12
+ isPartStyleKey,
13
+ isRootStateKey,
12
14
  splitPartStyles
13
15
  } from './part-styles';
14
16
  import { buildCustomCss, buildPartCustomCss } from './custom-css';
@@ -172,8 +174,7 @@ export const colorToRgba = (color: string, alpha: number): string | null => {
172
174
  const g = Number(commaRgb[2]);
173
175
  const b = Number(commaRgb[3]);
174
176
  if ([r, g, b].some((v) => Number.isNaN(v))) return null;
175
- const authored =
176
- commaRgb[4] !== undefined ? Number(commaRgb[4]) : 1;
177
+ const authored = commaRgb[4] !== undefined ? Number(commaRgb[4]) : 1;
177
178
  const effective = Number.isNaN(authored) ? alpha : authored * alpha;
178
179
  return `rgba(${r}, ${g}, ${b}, ${effective})`;
179
180
  }
@@ -545,7 +546,12 @@ const resolveSectionMaxWidthTokens = <T extends Record<string, unknown>>(
545
546
  export const INNER_ROOT_BLOCK_TYPES: ReadonlySet<string> = new Set([
546
547
  'text',
547
548
  'link',
548
- 'button'
549
+ 'button',
550
+ // The block's root IS a form control. Until this, its styles landed twice —
551
+ // on the wrapper through the sheet and on the control through the renderer's
552
+ // own inline styles — and no state at its root could match anything, because
553
+ // the element the rule reached was a div.
554
+ 'input'
549
555
  ]);
550
556
 
551
557
  /**
@@ -668,6 +674,21 @@ export const getCSSStyles = (
668
674
  : null;
669
675
 
670
676
  Object.keys(styles).forEach((key) => {
677
+ // Part and root-state keys carry ROUTING, not a property: `panel/color`
678
+ // means "the panel, in the base state" and `:hover/background-color` means
679
+ // "this element, while hovered". Both are turned into real rules by
680
+ // `generatePartCSS` / `generateRootStateCSS` against selectors an inline
681
+ // object cannot express. Passed through, they become React style props
682
+ // named `panel/color` and `:hover/backgroundColor` — a dev warning, dead
683
+ // weight, and on the inner-root path a token-valued one would be inlined
684
+ // onto the exact element the generated rule targets.
685
+ //
686
+ // Harmless until now only because a root-state key could be authored on a
687
+ // chrome band and nowhere else. It is checked here rather than in each of
688
+ // the renderers that inline this output, because this is where they all
689
+ // converge.
690
+ if (isPartStyleKey(key) || isRootStateKey(key)) return;
691
+
671
692
  const value = getResponsiveValue(styles[key], breakpoint);
672
693
  if (value === undefined || value === null) return;
673
694
 
@@ -1439,13 +1460,22 @@ export const generatePartCSS = (
1439
1460
  /**
1440
1461
  * Rules for a block or section's own root in a state.
1441
1462
  *
1442
- * Not `!important`, unlike the part rules above. Those need it because the
1443
- * element they target carries the renderer's own inline styles; a block wrapper
1444
- * or a section band does not, and the only rule this has to beat is the plain
1445
- * root rule emitted a few lines earlier — which it already does on specificity,
1446
- * a state selector being strictly longer than the stateless one. Forcing it
1447
- * would buy nothing and cost the custom-CSS hatch its `!important`, which is
1448
- * the designer's documented way to win an argument with a generated rule.
1463
+ * `!important` ONLY on the inner-root path, and the split is the whole point.
1464
+ * A block wrapper and a section band carry no renderer inline styles, so the
1465
+ * only rule a state there has to beat is the plain root rule emitted a few
1466
+ * lines earlier — which it already beats on specificity, a state selector
1467
+ * being strictly longer. Forcing it would buy nothing and would cost the
1468
+ * custom-CSS hatch its `!important`, the designer's documented way to win an
1469
+ * argument with a generated rule.
1470
+ *
1471
+ * The INNER root is the opposite case and always was. That element carries
1472
+ * `getInnerRootInlineStyles()` — every token-carrying value, plus `color` once
1473
+ * text-opacity has been composited into it — and inline outranks any
1474
+ * stylesheet. `mergeButtonStylesWithThemeDefaults` seeds a background colour
1475
+ * onto every button the editor creates, so without this a hover background on
1476
+ * a stock button was authored against an inline style it could never beat. It
1477
+ * is the same trade `generatePartCSS` has made since it was written, for the
1478
+ * identical reason, and the same replacement is used so the two cannot drift.
1449
1479
  */
1450
1480
  const generateRootStateCSS = (
1451
1481
  elementSelector: string,
@@ -1471,11 +1501,29 @@ const generateRootStateCSS = (
1471
1501
  : buildRootStateSelectors(elementSelector, bucket.state);
1472
1502
  if (selectors.length === 0) return;
1473
1503
 
1474
- css += generateElementCSS(
1504
+ const rules = generateElementCSS(
1475
1505
  selectors.join(',\n'),
1476
1506
  bucket.styles as ResponsiveStyles,
1477
- targetBreakpoint
1507
+ targetBreakpoint,
1508
+ // `opacity` is a HELPER key on a block ROOT — it feeds the hover/alpha
1509
+ // maths rather than being emitted — so without this flag every
1510
+ // `:<state>/opacity` the editor offers is dropped on the floor. A state
1511
+ // BUCKET has no such helper: there it is a real CSS property, exactly as
1512
+ // it is on a part, which is why `generatePartCSS` has always passed this.
1513
+ { treatHelperKeysAsCss: true }
1478
1514
  );
1515
+
1516
+ if (!rules) return;
1517
+
1518
+ css += targetsInnerRoot
1519
+ ? rules.replace(
1520
+ /^(\s*)([a-z-]+):\s*([^;\n]+);/gm,
1521
+ (line, indent, property, value) =>
1522
+ value.includes('!important')
1523
+ ? line
1524
+ : `${indent}${property}: ${value} !important;`
1525
+ )
1526
+ : rules;
1479
1527
  });
1480
1528
 
1481
1529
  return css;
@@ -39,6 +39,22 @@
39
39
  const STATE_SELECTORS: Record<string, string[]> = {
40
40
  hover: [':hover', '[data-state~="hover"]'],
41
41
  focus: [':focus-visible', ':focus', '[data-state~="focus"]'],
42
+ /**
43
+ * The focus a CONTAINER has: something inside it is focused.
44
+ *
45
+ * The one state a wrapper can genuinely be in that no tag gives it, and the
46
+ * commonest form control pattern there is — a field whose box lights up while
47
+ * the input inside it has the caret. Without it a Group holding an Input, an
48
+ * auth field and a card wrapping a link were all unstyleable on focus: the
49
+ * wrapper is not focusable, so `focus` never matches it, and the input's own
50
+ * `focus` paints the control rather than the box around it.
51
+ *
52
+ * It was left out once on the grounds that the renderer had no entry for it,
53
+ * which was true and is a circle. The pin token is what makes it offerable
54
+ * rather than merely emittable: `:focus-within` cannot be held while the
55
+ * designer reaches for a colour picker, exactly as `:hover` cannot.
56
+ */
57
+ 'focus-within': [':focus-within', '[data-state~="focus-within"]'],
42
58
  active: [':active', '[data-state~="active"]'],
43
59
  // Native controls, ARIA and our own data-state all mean the same thing here,
44
60
  // so each is covered rather than forcing renderers into one convention.
@@ -213,6 +229,18 @@ export const parsePartStyleKey = (key: string): ParsedPartKey | null => {
213
229
  export const isPartStyleKey = (key: string): boolean =>
214
230
  parsePartStyleKey(key) !== null;
215
231
 
232
+ /**
233
+ * Whether the key addresses this element IN A STATE, known state or not.
234
+ *
235
+ * Deliberately the marker rather than `parseRootStateKey`, because the callers
236
+ * are asking "is this routing?" and not "can I render it?". An unknown state
237
+ * name is still routing — it is simply routing nobody can honour — and the one
238
+ * thing it must never become is a declaration or a React style prop. No real
239
+ * CSS property begins with the marker, so nothing legitimate answers yes.
240
+ */
241
+ export const isRootStateKey = (key: string): boolean =>
242
+ Boolean(key) && key[0] === ROOT_STATE_MARKER;
243
+
216
244
  /** Builds the style key a descriptor writes into. */
217
245
  export const buildPartStyleKey = (
218
246
  part: string,
@@ -337,6 +365,15 @@ export const splitPartStyles = (
337
365
  return;
338
366
  }
339
367
 
368
+ // A key that LOOKS like a root state and is not one: an unknown state name,
369
+ // reaching here from a brand catalog this build does not know, from an
370
+ // editor that has shipped ahead of this storefront, or from a hand-edited
371
+ // theme. Left on the root bucket it is emitted as the declaration
372
+ // `:focus-within/color: #123456` inside the element's BASE rule — invalid,
373
+ // silently dropped by the browser, invisible to everyone. Dropped here it
374
+ // is simply not written.
375
+ if (isRootStateKey(key)) return;
376
+
340
377
  const parsed = parsePartStyleKey(key);
341
378
 
342
379
  if (!parsed) {