@akinon/pz-theme 2.0.49-beta.0 → 2.0.49

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,12 +1,17 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.49-beta.0
3
+ ## 2.0.49
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd75]
8
- - @akinon/next@2.0.49-beta.0
9
- - @akinon/theme-contract@2.0.49-beta.0
7
+ - 8b5f0b6: ZERO-4744: Let the theme editor preview behave like the live site on demand
8
+
9
+ The preview now honours `SET_PREVIEW_INTERACTION_MODE`, so the editor's "Use preview" toggle turns the canvas back into a storefront: links navigate, sliders and menus work, overlays trap focus, project-owned chrome stops swallowing the pointer, and publish windows and visibility rules apply as they do for a visitor. Editing affordances (selection chrome, click-to-select, pinned part states) come back when the designer returns.
10
+
11
+ The editor stays attached throughout — the selected breakpoint, sampled data sources and page-context discovery are unaffected — and the checkout payment internals remain sealed in both modes.
12
+
13
+ - @akinon/next@2.0.49
14
+ - @akinon/theme-contract@2.0.49
10
15
 
11
16
  ## 2.0.48
12
17
 
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.49-beta.0",
4
+ "version": "2.0.49",
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.49-beta.0",
11
+ "@akinon/next": "2.0.49",
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.49-beta.0",
16
+ "@akinon/theme-contract": "2.0.49",
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.49-beta.0",
23
+ "@akinon/next": "2.0.49",
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,313 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
+
4
+ import { __resetBrowseModeForTests, useBrowseMode } from '../browse-mode';
5
+ import { __resetEditorBridgeForTests } from '../editor-bridge';
6
+ import { ChromeFallback } from '../chrome/chrome-fallback';
7
+ import { CodeOwnedRegion } from '../chrome/code-owned-region';
8
+ import { ComposableRegionClient } from '../chrome/composable-region-client';
9
+ import ThemePlaceholderWrapper from '../theme-placeholder-wrapper';
10
+
11
+ /**
12
+ * Browse mode is the designer asking the canvas to stop being a canvas.
13
+ *
14
+ * Everything the editor needs — the forced breakpoint, the selection it is
15
+ * holding, the placeholder registration — has to survive that request, and
16
+ * everything the SHOPPER would not see has to go. The two halves are easy to
17
+ * conflate (they were one flag), so both directions are pinned here, plus the
18
+ * one thing browse mode must never do: unseal the payment internals.
19
+ */
20
+
21
+ const EDITOR_ORIGIN = 'http://localhost:5174';
22
+
23
+ const frameDocument = () =>
24
+ Object.defineProperty(window, 'top', {
25
+ configurable: true,
26
+ value: { name: 'designer' }
27
+ });
28
+
29
+ const setBrowseMode = (enabled: boolean, origin = EDITOR_ORIGIN) =>
30
+ act(() => {
31
+ fireEvent(
32
+ window,
33
+ new MessageEvent('message', {
34
+ origin,
35
+ data: {
36
+ type: 'SET_PREVIEW_INTERACTION_MODE',
37
+ data: { enabled }
38
+ }
39
+ })
40
+ );
41
+ });
42
+
43
+ const changeBreakpoint = (breakpoint: string) =>
44
+ act(() => {
45
+ fireEvent(
46
+ window,
47
+ new MessageEvent('message', {
48
+ origin: EDITOR_ORIGIN,
49
+ data: { type: 'CHANGE_BREAKPOINT', data: { breakpoint } }
50
+ })
51
+ );
52
+ });
53
+
54
+ const Probe = () => <span data-testid="probe">{String(useBrowseMode())}</span>;
55
+
56
+ beforeEach(() => {
57
+ __resetBrowseModeForTests();
58
+ __resetEditorBridgeForTests();
59
+ });
60
+
61
+ describe('the browse-mode flag', () => {
62
+ beforeAll(frameDocument);
63
+
64
+ it('follows the editor and ignores anyone else', () => {
65
+ render(<Probe />);
66
+ expect(screen.getByTestId('probe')).toHaveTextContent('false');
67
+
68
+ setBrowseMode(true);
69
+ expect(screen.getByTestId('probe')).toHaveTextContent('true');
70
+
71
+ // A page that framed the preview without proving itself must not be able
72
+ // to strip the designer's chrome — or, read the other way, to make a
73
+ // production embed think it is being edited.
74
+ setBrowseMode(false, 'https://evil.example');
75
+ expect(screen.getByTestId('probe')).toHaveTextContent('true');
76
+
77
+ setBrowseMode(false);
78
+ expect(screen.getByTestId('probe')).toHaveTextContent('false');
79
+ });
80
+
81
+ it('survives a remount, because the editor only says it once', () => {
82
+ const first = render(<Probe />);
83
+ setBrowseMode(true);
84
+ first.unmount();
85
+
86
+ // An in-iframe navigation remounts every placeholder; the mode frame is not
87
+ // repeated for each one, so a fresh mount has to read the standing answer.
88
+ render(<Probe />);
89
+ expect(screen.getByTestId('probe')).toHaveTextContent('true');
90
+ });
91
+ });
92
+
93
+ describe('a composed placeholder in browse mode', () => {
94
+ const section = {
95
+ id: 's1',
96
+ type: 'generic-section',
97
+ name: 'promo',
98
+ label: 'Promo Section',
99
+ order: 0,
100
+ hidden: false,
101
+ properties: {},
102
+ // Deliberately different per breakpoint: this is what proves the canvas
103
+ // still renders at the breakpoint the editor picked.
104
+ styles: { 'background-color': { desktop: 'red', mobile: 'blue' } },
105
+ blocks: [
106
+ {
107
+ id: 'b1',
108
+ type: 'group',
109
+ label: 'Promo Card',
110
+ order: 0,
111
+ hidden: false,
112
+ properties: {},
113
+ styles: {},
114
+ blocks: []
115
+ }
116
+ ]
117
+ } as any;
118
+
119
+ const renderCanvas = () => {
120
+ const postMessage = jest.fn();
121
+ Object.defineProperty(window, 'parent', {
122
+ configurable: true,
123
+ value: { postMessage }
124
+ });
125
+
126
+ const view = render(
127
+ <ThemePlaceholderWrapper
128
+ slug="home-body"
129
+ initialSections={[section]}
130
+ initialPlaceholderId="home-body"
131
+ isDesignMode
132
+ />
133
+ );
134
+
135
+ return { ...view, postMessage };
136
+ };
137
+
138
+ beforeAll(frameDocument);
139
+
140
+ it('drops the editing chrome and stops claiming clicks', () => {
141
+ const { container, postMessage } = renderCanvas();
142
+
143
+ const blockHost = () =>
144
+ container.querySelector('[data-block-id="b1"]') as HTMLElement;
145
+ const sectionHost = () =>
146
+ container.querySelector('[data-section-id="s1"]') as HTMLElement;
147
+
148
+ // A frame from the editor is what proves its origin to the bridge; without
149
+ // one there is no trusted target and every message is dropped by design.
150
+ changeBreakpoint('desktop');
151
+
152
+ // Editing: both hosts advertise themselves as selectable and a click is
153
+ // the editor's selection, not the page's.
154
+ expect(blockHost().className).toContain('cursor-pointer');
155
+ expect(sectionHost().className).toContain('cursor-pointer');
156
+
157
+ fireEvent.click(blockHost());
158
+ expect(
159
+ postMessage.mock.calls.some(([envelope]) => envelope?.type === 'SELECT_BLOCK')
160
+ ).toBe(true);
161
+
162
+ postMessage.mockClear();
163
+ setBrowseMode(true);
164
+
165
+ expect(blockHost().className).not.toContain('cursor-pointer');
166
+ expect(sectionHost().className).not.toContain('cursor-pointer');
167
+ expect(container.querySelector('[data-part-state-preview]')).toBeNull();
168
+
169
+ // The click belongs to the page now — no selection message at all.
170
+ fireEvent.click(blockHost());
171
+ expect(
172
+ postMessage.mock.calls.some(([envelope]) => envelope?.type === 'SELECT_BLOCK')
173
+ ).toBe(false);
174
+ });
175
+
176
+ it('keeps rendering at the breakpoint the editor selected', () => {
177
+ // The trap this pins: binding the forced breakpoint to the editing flag
178
+ // instead of "is the editor attached" repaints the desktop layout inside a
179
+ // mobile-width frame, so every toggle jumps.
180
+ const { container } = renderCanvas();
181
+ changeBreakpoint('mobile');
182
+
183
+ const css = () =>
184
+ Array.from(container.querySelectorAll('style'))
185
+ .map((tag) => tag.textContent || '')
186
+ .join('');
187
+
188
+ const mobileCss = css();
189
+ expect(mobileCss).toContain('blue');
190
+ expect(mobileCss).not.toContain('red');
191
+
192
+ setBrowseMode(true);
193
+ expect(css()).toBe(mobileCss);
194
+
195
+ setBrowseMode(false);
196
+ expect(css()).toBe(mobileCss);
197
+ });
198
+
199
+ it('gives the chrome back when the designer returns', () => {
200
+ const { container } = renderCanvas();
201
+
202
+ setBrowseMode(true);
203
+ setBrowseMode(false);
204
+
205
+ expect(
206
+ (container.querySelector('[data-block-id="b1"]') as HTMLElement).className
207
+ ).toContain('cursor-pointer');
208
+ });
209
+ });
210
+
211
+ describe('project-owned chrome in browse mode', () => {
212
+ beforeAll(frameDocument);
213
+
214
+ it('hands the brand markup back its pointer', () => {
215
+ // This wrapper is why an uncomposed header cannot be clicked in the editor
216
+ // at all: its markup is wrapped in a pointer-swallowing div under a
217
+ // takeover scrim. Browse mode is the whole answer to that.
218
+ const { container } = render(
219
+ <ChromeFallback slug="header">
220
+ <a href="/women" data-testid="menu-link">
221
+ Women
222
+ </a>
223
+ </ChromeFallback>
224
+ );
225
+
226
+ const swallowsPointer = () =>
227
+ Array.from(container.querySelectorAll<HTMLElement>('div')).some(
228
+ (node) => node.style.pointerEvents === 'none'
229
+ );
230
+
231
+ expect(swallowsPointer()).toBe(true);
232
+ expect(container.querySelector('[data-chrome-managed-by]')).not.toBeNull();
233
+
234
+ setBrowseMode(true);
235
+
236
+ expect(swallowsPointer()).toBe(false);
237
+ expect(container.querySelector('[data-chrome-managed-by]')).toBeNull();
238
+ expect(screen.getByTestId('menu-link')).toBeInTheDocument();
239
+
240
+ // And no takeover affordance on hover — a visitor is never offered one.
241
+ fireEvent.mouseEnter(screen.getByTestId('menu-link').parentElement!);
242
+ expect(
243
+ screen.queryByRole('button', { name: /edit in theme editor/i })
244
+ ).toBeNull();
245
+ });
246
+
247
+ it('renders nothing for an empty zone, exactly like the live site', () => {
248
+ const { container } = render(
249
+ <ChromeFallback slug="checkout-bottom-body" emptyZone />
250
+ );
251
+
252
+ expect(container.querySelector('[data-chrome-empty-zone]')).not.toBeNull();
253
+
254
+ setBrowseMode(true);
255
+ expect(container.querySelector('[data-chrome-empty-zone]')).toBeNull();
256
+ });
257
+
258
+ it('stops explaining a code-owned region while browsing', () => {
259
+ const { container } = render(
260
+ <CodeOwnedRegion label="checkout flow">
261
+ <div data-testid="flow">payment steps</div>
262
+ </CodeOwnedRegion>
263
+ );
264
+
265
+ const region = container.querySelector(
266
+ '[data-code-owned="checkout flow"]'
267
+ ) as HTMLElement;
268
+
269
+ fireEvent.mouseEnter(region);
270
+ expect(screen.getByText(/code-owned/i)).toBeInTheDocument();
271
+
272
+ setBrowseMode(true);
273
+ expect(screen.queryByText(/code-owned/i)).toBeNull();
274
+ // The wrapper itself never leaves — the flow below it must not remount.
275
+ expect(screen.getByTestId('flow')).toBeInTheDocument();
276
+ });
277
+ });
278
+
279
+ describe('the checkout body in browse mode', () => {
280
+ beforeAll(() => {
281
+ frameDocument();
282
+ window.__themeEditorDesigner = true;
283
+ });
284
+
285
+ afterAll(() => {
286
+ delete window.__themeEditorDesigner;
287
+ });
288
+
289
+ it('stays sealed — browse mode is not a licence to place an order', () => {
290
+ // Browse mode restores navigation and UI interaction. It deliberately does
291
+ // NOT reach into the payment internals: a designer walking the preview must
292
+ // not be able to submit a real order from inside the editor.
293
+ const { container } = render(
294
+ <ComposableRegionClient slug="checkout-page-body">
295
+ <button type="button" data-testid="place-order">
296
+ Place order
297
+ </button>
298
+ </ComposableRegionClient>
299
+ );
300
+
301
+ const region = () =>
302
+ container.querySelector(
303
+ '[data-chrome-region="checkout-page-body"]'
304
+ ) as HTMLElement;
305
+
306
+ expect(region().style.pointerEvents).toBe('none');
307
+
308
+ setBrowseMode(true);
309
+
310
+ expect(region().style.pointerEvents).toBe('none');
311
+ expect(screen.getByTestId('place-order')).toBeInTheDocument();
312
+ });
313
+ });
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore } from 'react';
4
+
5
+ import { isTrustedEditorEvent } from './editor-bridge';
6
+
7
+ /**
8
+ * Browse mode — the designer asking the canvas to behave like the live site.
9
+ *
10
+ * Editing needs the opposite of a working storefront: a click has to mean
11
+ * "select this block", so links resolve to `#`, sliders lose their arrows,
12
+ * menus never open and project-owned chrome swallows the pointer. That leaves
13
+ * no way to walk the page as a shopper — open a menu, follow a link, step
14
+ * through a flow — without leaving the editor, which is what the editor's
15
+ * "Use preview" toggle asks for over `SET_PREVIEW_INTERACTION_MODE`.
16
+ *
17
+ * Consumers treat this as the second half of a pair: `isDesigner` answers "is
18
+ * the editor attached" (it drives the forced breakpoint, data sampling and
19
+ * page-context discovery, all of which must survive the toggle), while
20
+ * `isDesigner && !browseMode` answers "are the editing affordances on" and is
21
+ * what sections and blocks receive.
22
+ *
23
+ * The flag lives on `window` rather than in a module variable for the same
24
+ * reason `__themeEditorDesigner` does: the editor sends its state once per
25
+ * document, so a component mounting later — an in-iframe route change
26
+ * remounts every placeholder — has to read the current answer instead of
27
+ * resetting it to the default.
28
+ */
29
+ declare global {
30
+ interface Window {
31
+ __themeEditorBrowseMode?: boolean;
32
+ }
33
+ }
34
+
35
+ const listeners = new Set<() => void>();
36
+ let listening = false;
37
+
38
+ const readFlag = (): boolean =>
39
+ typeof window !== 'undefined' && window.__themeEditorBrowseMode === true;
40
+
41
+ /** Server render is never the designer's canvas, so it never browses. */
42
+ const readServerFlag = (): boolean => false;
43
+
44
+ const handleMessage = (event: MessageEvent) => {
45
+ if (!isTrustedEditorEvent(event)) return;
46
+ if (event.data?.type !== 'SET_PREVIEW_INTERACTION_MODE') return;
47
+
48
+ const next = Boolean(event.data?.data?.enabled);
49
+ if (next === readFlag()) return;
50
+
51
+ window.__themeEditorBrowseMode = next;
52
+ listeners.forEach((listener) => listener());
53
+ };
54
+
55
+ /**
56
+ * The window listener is attached on first use and then kept for the life of
57
+ * the document — deliberately never removed. Placeholders unmount in bursts
58
+ * during an in-iframe navigation, and a mode frame arriving in that gap would
59
+ * otherwise leave the flag stale for the page that mounts next.
60
+ */
61
+ const subscribe = (listener: () => void): (() => void) => {
62
+ listeners.add(listener);
63
+
64
+ if (!listening && typeof window !== 'undefined') {
65
+ window.addEventListener('message', handleMessage);
66
+ listening = true;
67
+ }
68
+
69
+ return () => {
70
+ listeners.delete(listener);
71
+ };
72
+ };
73
+
74
+ /** Imperative read, for code that is not a component. */
75
+ export const isBrowseMode = readFlag;
76
+
77
+ export function useBrowseMode(): boolean {
78
+ return useSyncExternalStore(subscribe, readFlag, readServerFlag);
79
+ }
80
+
81
+ /** Test-only: forget the flag and every subscriber. */
82
+ export function __resetBrowseModeForTests(): void {
83
+ if (typeof window !== 'undefined') {
84
+ delete window.__themeEditorBrowseMode;
85
+ }
86
+ listeners.clear();
87
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useEffect, useState } from 'react';
4
4
 
5
+ import { useBrowseMode } from '../browse-mode';
5
6
  import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
6
7
  import { isSlugComposedInFrame } from './composition-state';
7
8
 
@@ -48,6 +49,7 @@ export function ChromeFallback({
48
49
  const [inDesigner, setInDesigner] = useState(false);
49
50
  const [isHovered, setIsHovered] = useState(false);
50
51
  const [isOverridden, setIsOverridden] = useState(initiallyOverridden);
52
+ const isBrowseMode = useBrowseMode();
51
53
 
52
54
  useEffect(() => {
53
55
  if (typeof window !== 'undefined' && window.self !== window.top) {
@@ -80,11 +82,20 @@ export function ChromeFallback({
80
82
  return () => window.removeEventListener('message', handleMessage);
81
83
  }, [slug]);
82
84
 
83
- if (!inDesigner) {
84
- // Composed on the live site: the composition owns the area, the brand
85
- // markup must never double-render under it. An empty zone has nothing to
86
- // show a visitor either way.
87
- if (initiallyOverridden || emptyZone) return null;
85
+ // Browse mode takes the same path as the live site, which matters most
86
+ // here: this wrapper is what makes an uncomposed header or footer inert in
87
+ // the designer, so its menus and links are exactly what a designer cannot
88
+ // reach while editing. No pointer swallowing, no takeover affordance, no
89
+ // dashed band — only what a visitor would get.
90
+ //
91
+ // `isOverridden` rather than the prop: a takeover that happened in this
92
+ // session has already pushed a composition, and the brand markup must stay
93
+ // out of its way. On the live site no theme frames arrive, so the two agree.
94
+ if (!inDesigner || isBrowseMode) {
95
+ // Composed: the composition owns the area, the brand markup must never
96
+ // double-render under it. An empty zone has nothing to show a visitor
97
+ // either way.
98
+ if (isOverridden || emptyZone) return null;
88
99
 
89
100
  return (
90
101
  <div data-chrome-fallback={slug} className={wrapperClassName}>
@@ -2,6 +2,8 @@
2
2
 
3
3
  import React, { useEffect, useState } from 'react';
4
4
 
5
+ import { useBrowseMode } from '../browse-mode';
6
+
5
7
  /**
6
8
  * Wraps markup that is deliberately not editable at all — no takeover, no
7
9
  * placement, nothing but an explanation. Without this wrapper, hovering the
@@ -36,6 +38,9 @@ export function CodeOwnedRegion({
36
38
  }) {
37
39
  const [inDesigner, setInDesigner] = useState(false);
38
40
  const [isHovered, setIsHovered] = useState(false);
41
+ // Browse mode wants the live site, and the note is editor chrome — the
42
+ // wrapper itself stays either way, so nothing remounts on the toggle.
43
+ const isBrowseMode = useBrowseMode();
39
44
 
40
45
  useEffect(() => {
41
46
  if (typeof window !== 'undefined' && window.self !== window.top) {
@@ -56,7 +61,7 @@ export function CodeOwnedRegion({
56
61
  >
57
62
  {children}
58
63
 
59
- {inDesigner && isHovered && (
64
+ {inDesigner && isHovered && !isBrowseMode && (
60
65
  <div
61
66
  style={{
62
67
  position: 'absolute',
@@ -113,6 +113,11 @@ export function ComposableRegionClient({
113
113
  // target, which only exists once a composition places one. Without
114
114
  // this, a composed-but-unplaced flow would leave a working "place
115
115
  // order" button on the canvas. The live site emits no value at all.
116
+ //
117
+ // Browse mode does NOT lift this, on purpose. It restores navigation
118
+ // and UI interaction across the canvas, but the payment internals stay
119
+ // sealed — a designer walking the preview must not be able to place a
120
+ // real order from inside the editor. Pinned by test.
116
121
  pointerEvents: inDesigner ? 'none' : undefined
117
122
  }}
118
123
  >
package/src/index.ts CHANGED
@@ -15,6 +15,10 @@ export * from './utils';
15
15
  export * from './hooks/use-designer-features';
16
16
  export * from './hooks/use-visibility-context';
17
17
 
18
+ // Brand code that renders its own designer-aware markup needs the same answer
19
+ // the package uses internally: is the editor attached, and is it editing.
20
+ export { isBrowseMode, useBrowseMode } from './browse-mode';
21
+
18
22
  export { WithDesignerFeatures } from './components/with-designer-features';
19
23
 
20
24
  export type { Section } from './theme-section';
@@ -13,6 +13,7 @@ import { isPublishWindowVisible } from './utils/publish-window';
13
13
  import { useThemePageContext } from './theme-page-context';
14
14
 
15
15
  import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
16
+ import { useBrowseMode } from './browse-mode';
16
17
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
17
18
  // Global flags that must survive component remounts (e.g. in-iframe route
18
19
  // changes): IFRAME_READY is sent once per document, and the designer flag
@@ -47,12 +48,16 @@ export default function ThemePlaceholderClient({
47
48
  const [isDesigner, setIsDesigner] = useState(
48
49
  () => typeof window !== 'undefined' && window.__themeEditorDesigner === true
49
50
  );
51
+ // Browse mode keeps the editor attached but hands the canvas back to the
52
+ // storefront; see browse-mode.ts for why the two flags are separate.
53
+ const isBrowseMode = useBrowseMode();
54
+ const isEditing = isDesigner && !isBrowseMode;
50
55
  // Null unless a ThemePageContextProvider sits above this placeholder; data
51
56
  // conditions then have nothing to read and resolve the same way on both sides.
52
57
  const pageContext = useThemePageContext();
53
58
  const visibilityContext = useVisibilityContext(currentBreakpoint, {
54
59
  pageContext,
55
- isDesigner
60
+ isDesigner: isEditing
56
61
  });
57
62
  const renderedSections = applyVisibilityRulesToSections(
58
63
  sections,
@@ -174,7 +179,7 @@ export default function ThemePlaceholderClient({
174
179
  .filter(
175
180
  (section) =>
176
181
  !section.hidden &&
177
- (isDesigner ||
182
+ (isEditing ||
178
183
  isPublishWindowVisible(section.properties?.['publish-window']))
179
184
  )
180
185
  .map((section) => (
@@ -186,7 +191,7 @@ export default function ThemePlaceholderClient({
186
191
  // all: the context is read for visibility rules and then dropped
187
192
  // before the section that needs it.
188
193
  pageContext={pageContext}
189
- isDesigner={isDesigner}
194
+ isDesigner={isEditing}
190
195
  isSelected={selectedSectionId === section.id}
191
196
  selectedBlockId={selectedBlockId}
192
197
  currentBreakpoint={currentBreakpoint}
@@ -21,6 +21,7 @@ import { isPublishWindowVisible } from './utils/publish-window';
21
21
  import { buildClientRequestUrl } from '@akinon/next/utils';
22
22
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
23
23
  import { parseEditorMessage, postToEditor } from './editor-bridge';
24
+ import { useBrowseMode } from './browse-mode';
24
25
 
25
26
  // Helper function to inject theme colors as CSS custom properties
26
27
  const injectThemeColors = (settings: Record<string, unknown>) => {
@@ -310,6 +311,18 @@ export default function ThemePlaceholderWrapper({
310
311
  isDesignMode ||
311
312
  (typeof window !== 'undefined' && window.__themeEditorDesigner === true)
312
313
  );
314
+ // Two different questions, and they used to share one answer:
315
+ //
316
+ // `isDesigner` — is the editor attached. It drives the plumbing that has to
317
+ // keep working while the designer browses: the forced breakpoint, sampled
318
+ // data sources, page-context discovery.
319
+ //
320
+ // `isEditing` — are the editing affordances on. This is what sections and
321
+ // blocks receive, so browse mode turns the canvas back into a storefront
322
+ // (links navigate, sliders slide, overlays trap focus, publish windows and
323
+ // visibility rules apply) without disturbing anything above.
324
+ const isBrowseMode = useBrowseMode();
325
+ const isEditing = isDesigner && !isBrowseMode;
313
326
  const [dataSources, setDataSources] = useState<any[]>(initialDataSources);
314
327
  const [themeSettings, setThemeSettings] = useState<Record<
315
328
  string,
@@ -719,6 +732,10 @@ export default function ThemePlaceholderWrapper({
719
732
  postToEditor({ type: action, data });
720
733
  };
721
734
 
735
+ // Bound to `isDesigner`, NOT to `isEditing`: the canvas renders at the
736
+ // breakpoint the editor selected, and dropping back to real media queries
737
+ // when the designer starts browsing would repaint a desktop layout inside a
738
+ // mobile-width frame — a layout jump on every toggle.
722
739
  const dynamicCSS = useMemo(
723
740
  () =>
724
741
  generateThemeCSS(sections, isDesigner ? currentBreakpoint : undefined),
@@ -726,7 +743,7 @@ export default function ThemePlaceholderWrapper({
726
743
  );
727
744
  const visibilityContext = useVisibilityContext(currentBreakpoint, {
728
745
  pageContext: resolvedPageContext,
729
- isDesigner
746
+ isDesigner: isEditing
730
747
  });
731
748
  const renderedSections = useMemo(
732
749
  () => applyVisibilityRulesToSections(sections, visibilityContext),
@@ -739,7 +756,12 @@ export default function ThemePlaceholderWrapper({
739
756
  // falls back to its empty state.
740
757
  <ThemePageContextProvider value={resolvedPageContext}>
741
758
  <ThemeSettingsProvider value={themeSettings}>
742
- <PartStatePreview isDesigner={isDesigner} />
759
+ {/*
760
+ Pins are released while browsing — a button frozen in its hover state
761
+ is not what the live site looks like. The editor re-sends the pinned
762
+ state when the designer comes back, so the panel and the canvas agree.
763
+ */}
764
+ <PartStatePreview isDesigner={isEditing} />
743
765
  {dynamicCSS && <style dangerouslySetInnerHTML={{ __html: dynamicCSS }} />}
744
766
  <div
745
767
  data-placeholder={slug}
@@ -751,7 +773,7 @@ export default function ThemePlaceholderWrapper({
751
773
  .filter(
752
774
  (section) =>
753
775
  !section.hidden &&
754
- (isDesigner ||
776
+ (isEditing ||
755
777
  isPublishWindowVisible(section.properties?.['publish-window']))
756
778
  )
757
779
  .map((section) => (
@@ -760,7 +782,7 @@ export default function ThemePlaceholderWrapper({
760
782
  section={section}
761
783
  placeholderId={placeholderSlug}
762
784
  pageContext={resolvedPageContext}
763
- isDesigner={isDesigner}
785
+ isDesigner={isEditing}
764
786
  isSelected={selectedSectionId === section.id}
765
787
  selectedBlockId={selectedBlockId}
766
788
  currentBreakpoint={currentBreakpoint}