@akinon/pz-theme 2.0.79-rc.0 → 2.0.79

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,17 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.79-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 [2fea4143]
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 [e9598c71]
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.79-rc.0
35
- - @akinon/theme-contract@2.0.79-rc.0
3
+ ## 2.0.79
4
+
5
+ ### Patch Changes
6
+
7
+ - 945e153d: ZERO-4669: Keep the designer action toolbar reachable at the top of the canvas.
8
+
9
+ The toolbar is portaled to `<body>` (ZERO-4651), so nothing can clip it any more — but hanging above its host still draws it at a negative y when the host sits at the viewport top: the first section at scroll 0, and any section in a chrome slot pinned with `position: sticky; top: 0`, which reports the same box at every scroll position and so was unreachable permanently. The toolbar now flips inside its host when there is not enough room above it on screen, while the portal frame stays pinned to the host's box.
10
+
11
+ Also adds the `@akinon/pz-theme` content globs to the app's Tailwind config: without them the toolbar's own utilities (`-top-0.5`, `top-1`, `-translate-y-full`, `bg-[#4482ff]`) are never generated, so the designer chrome rendered unstyled. The app template carries the same two lines, so scaffolded brand apps are not shipping unstyled designer chrome either.
12
+
13
+ - @akinon/next@2.0.79
14
+ - @akinon/theme-contract@2.0.79
36
15
 
37
16
  ## 2.0.78
38
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.79-rc.0",
4
+ "version": "2.0.79",
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.79-rc.0",
11
+ "@akinon/next": "2.0.79",
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.79-rc.0",
16
+ "@akinon/theme-contract": "2.0.79",
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.79-rc.0",
23
+ "@akinon/next": "2.0.79",
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",
@@ -27,12 +27,17 @@ jest.mock('react', () => {
27
27
  const getWidgetData = jest.fn();
28
28
  const getWidgetSchemaData = jest.fn();
29
29
  const getCollectionWidgetData = jest.fn();
30
+ // Named preview snapshot reader (draft mode only). Resolves null by default:
31
+ // "not a preview", the live path.
32
+ const getPreviewSnapshot = jest.fn(async () => null as unknown);
30
33
 
31
34
  jest.mock('@akinon/next/data/server', () => ({
32
35
  getWidgetData: (...args: unknown[]) => getWidgetData(...args),
33
36
  getWidgetSchemaData: (...args: unknown[]) => getWidgetSchemaData(...args),
34
37
  getCollectionWidgetData: (...args: unknown[]) =>
35
- getCollectionWidgetData(...args)
38
+ getCollectionWidgetData(...args),
39
+ getPreviewSnapshot: (...args: unknown[]) =>
40
+ (getPreviewSnapshot as (...a: unknown[]) => unknown)(...args)
36
41
  }));
37
42
 
38
43
  jest.mock(
@@ -123,3 +128,76 @@ describe('shared placeholder widget fetch', () => {
123
128
  expect(attempts).toBe(3);
124
129
  });
125
130
  });
131
+
132
+ describe('named preview snapshots', () => {
133
+ beforeEach(() => {
134
+ getWidgetData.mockReset();
135
+ getWidgetSchemaData.mockReset();
136
+ getCollectionWidgetData.mockReset();
137
+ getPreviewSnapshot.mockReset();
138
+ getPreviewSnapshot.mockResolvedValue(null);
139
+ });
140
+
141
+ const blob = {
142
+ version: 1,
143
+ themeSettings: { mainMenuSticky: true },
144
+ dataSources: [],
145
+ sections: [
146
+ {
147
+ id: 'sec-preview',
148
+ type: 'custom',
149
+ label: 'Preview hero',
150
+ properties: {},
151
+ styles: {},
152
+ blocks: [],
153
+ order: 0
154
+ }
155
+ ]
156
+ };
157
+
158
+ it('composes the slot and loads the sections from the blob, never the live widget', async () => {
159
+ getPreviewSnapshot.mockResolvedValue(blob);
160
+ getWidgetData.mockResolvedValue(placeholderPayload('preview-slot-a'));
161
+
162
+ const state = await getSlugCompositionState('preview-slot-a');
163
+ const loaded = await loadPlaceholderSections('preview-slot-a');
164
+
165
+ expect(state).toEqual({ kind: 'composed', sectionCount: 1 });
166
+ expect(loaded?.sections.map((s) => s.id)).toEqual(['sec-preview']);
167
+ expect(loaded?.placeholderId).toBe('preview-slot-a');
168
+ expect(loaded?.themeSettings).toEqual({ mainMenuSticky: true });
169
+ // The blob owns the render: no per-section or theme-config reads.
170
+ expect(getWidgetData).not.toHaveBeenCalled();
171
+ expect(getWidgetSchemaData).not.toHaveBeenCalled();
172
+ });
173
+
174
+ it('an empty blob withdraws the slot even when live is composed', async () => {
175
+ getPreviewSnapshot.mockResolvedValue({ ...blob, sections: [] });
176
+ getWidgetData.mockResolvedValue(placeholderPayload('preview-slot-b'));
177
+
178
+ expect(await getSlugCompositionState('preview-slot-b')).toEqual({
179
+ kind: 'empty'
180
+ });
181
+ expect((await loadPlaceholderSections('preview-slot-b'))?.sections).toEqual(
182
+ []
183
+ );
184
+ });
185
+
186
+ it('falls through to the live path without a blob or when the reader throws', async () => {
187
+ getPreviewSnapshot.mockRejectedValue(new Error('backend down'));
188
+ getWidgetData.mockResolvedValue(placeholderPayload('preview-slot-c'));
189
+ getWidgetSchemaData.mockResolvedValue({
190
+ schema: { 'sec-a': { metadata: { blocks: [] } } }
191
+ });
192
+
193
+ expect(await getSlugCompositionState('preview-slot-c')).toEqual({
194
+ kind: 'composed',
195
+ sectionCount: 1
196
+ });
197
+ expect(
198
+ (await loadPlaceholderSections('preview-slot-c'))?.sections.map(
199
+ (s) => s.id
200
+ )
201
+ ).toEqual(['sec-a']);
202
+ });
203
+ });
@@ -27,6 +27,14 @@ jest.mock('../theme-config-source', () => ({
27
27
  getThemeConfig: () => themeConfig()
28
28
  }));
29
29
 
30
+ // A named preview's snapshot owns the theme settings; null = not a preview.
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ const previewSnapshot = jest.fn<any, [string]>(() => null);
33
+
34
+ jest.mock('../preview-snapshot-source', () => ({
35
+ getPlaceholderPreviewSnapshot: (slug: string) => previewSnapshot(slug)
36
+ }));
37
+
30
38
  // The placeholder is an async server component that reaches for the data
31
39
  // layer; what the slot decides about itself is what is under test.
32
40
  jest.mock('../../theme-placeholder', () => ({
@@ -221,3 +229,35 @@ describe('ChromeSlot sticky', () => {
221
229
  expect(themeConfig).not.toHaveBeenCalled();
222
230
  });
223
231
  });
232
+
233
+ describe('ChromeSlot sticky in a named preview', () => {
234
+ afterEach(() => {
235
+ cleanup();
236
+ previewSnapshot.mockReset();
237
+ previewSnapshot.mockReturnValue(null);
238
+ });
239
+
240
+ it('follows the preview snapshot, not the live theme-config', async () => {
241
+ compositionState.mockResolvedValue(composed);
242
+ themeConfig.mockResolvedValue({ settings: { mainMenuSticky: false } });
243
+ previewSnapshot.mockResolvedValue({
244
+ version: 1,
245
+ sections: [{ id: 'sec' }],
246
+ themeSettings: { mainMenuSticky: true }
247
+ });
248
+
249
+ await renderSlot({ slug: 'header', fallback: <div>brand</div> });
250
+
251
+ expect(slotElement('header').dataset.sticky).toBe('true');
252
+ });
253
+
254
+ it('falls back to the live theme-config when the preview has no blob', async () => {
255
+ compositionState.mockResolvedValue(composed);
256
+ themeConfig.mockResolvedValue({ settings: { mainMenuSticky: true } });
257
+ previewSnapshot.mockResolvedValue(null);
258
+
259
+ await renderSlot({ slug: 'header', fallback: <div>brand</div> });
260
+
261
+ expect(slotElement('header').dataset.sticky).toBe('true');
262
+ });
263
+ });
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import ThemePlaceholder from '../theme-placeholder';
3
3
  import { warnAboutUncomposedSlot } from './composition-state';
4
4
  import { getSlugCompositionState } from './composition-source';
5
+ import { getPlaceholderPreviewSnapshot } from './preview-snapshot-source';
5
6
  import { getThemeConfig } from './theme-config-source';
6
7
  import { ChromeFallback } from './chrome-fallback';
7
8
  import { ChromeScrollState } from './scroll-state';
@@ -79,9 +80,16 @@ export default async function ChromeSlot({
79
80
  warnAboutUncomposedSlot(slug, state);
80
81
 
81
82
  // Only read when the mounting code said nothing, and memoized per request, so
82
- // a page full of slots costs at most one theme-config read.
83
+ // a page full of slots costs at most one theme-config read. In a named
84
+ // preview the snapshot blob owns the theme settings (theme-config is never
85
+ // preview-transformed), so the pin must follow the blob the placeholder
86
+ // renders from — otherwise "sticky header" toggled in the preview would
87
+ // only show once published.
83
88
  const themeSettings =
84
- sticky === undefined ? (await getThemeConfig()).settings : null;
89
+ sticky === undefined
90
+ ? (await getPlaceholderPreviewSnapshot(slug))?.themeSettings ??
91
+ (await getThemeConfig()).settings
92
+ : null;
85
93
  const pinned = resolveSlotSticky({
86
94
  slug,
87
95
  sticky,
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  import { CompositionState, readCompositionState } from './composition-state';
4
+ import { getPlaceholderPreviewSnapshot } from './preview-snapshot-source';
4
5
  import { getWidgetDataWithRetry } from './retrying-widget-data';
5
6
 
6
7
  // `React.cache` exists on the RSC-capable React this package renders under;
@@ -49,6 +50,16 @@ export const getPlaceholderWidget = cache
49
50
  */
50
51
  const load = async (slug: string): Promise<CompositionState> => {
51
52
  try {
53
+ // A named preview (draft mode) composes from its snapshot, not from the
54
+ // live placeholder: a slug composed only in the preview must still take
55
+ // the slot over there, and one emptied in the preview must give it back.
56
+ const preview = await getPlaceholderPreviewSnapshot(slug);
57
+ if (preview) {
58
+ return preview.sections.length > 0
59
+ ? { kind: 'composed', sectionCount: preview.sections.length }
60
+ : { kind: 'empty' };
61
+ }
62
+
52
63
  const data = await getPlaceholderWidget(slug);
53
64
 
54
65
  return readCompositionState(data);
@@ -0,0 +1,51 @@
1
+ import * as React from 'react';
2
+ import * as serverData from '@akinon/next/data/server';
3
+
4
+ /**
5
+ * Whole-placeholder snapshot the theme editor writes for a named preview
6
+ * (`<slug>-preview-<id>`, `preview_snapshot` attribute). Sections arrive in
7
+ * render shape. See akinon-next's `getPreviewSnapshot` for the contract.
8
+ */
9
+ export interface PreviewSnapshot {
10
+ version: number;
11
+ savedAt?: string;
12
+ themeSettings?: Record<string, unknown> | null;
13
+ dataSources?: unknown[];
14
+ sections: unknown[];
15
+ }
16
+
17
+ type GetPreviewSnapshot = (args: {
18
+ slug: string;
19
+ }) => Promise<PreviewSnapshot | null>;
20
+
21
+ const fetchPreviewSnapshot = async (
22
+ slug: string
23
+ ): Promise<PreviewSnapshot | null> => {
24
+ // v1 cores predate widget preview: no reader means never a preview. The
25
+ // reader itself answers null outside draft mode or without a preview id.
26
+ const fn = (
27
+ serverData as unknown as { getPreviewSnapshot?: GetPreviewSnapshot }
28
+ ).getPreviewSnapshot;
29
+ if (!fn) return null;
30
+
31
+ try {
32
+ return (await fn({ slug })) ?? null;
33
+ } catch {
34
+ // A failed preview read must fall back to the live render, never blank it.
35
+ return null;
36
+ }
37
+ };
38
+
39
+ // `React.cache` exists on the RSC-capable React this package renders under;
40
+ // fall back to the raw function on older cores, where correctness is identical
41
+ // and the only cost is a second fetch.
42
+ const cache = (React as { cache?: <T>(fn: T) => T }).cache;
43
+
44
+ /**
45
+ * The one preview-snapshot read per placeholder per request — shared by the
46
+ * slot's composed/uncomposed decision and the section loader so the two can
47
+ * never disagree about which content a preview shows.
48
+ */
49
+ export const getPlaceholderPreviewSnapshot = cache
50
+ ? cache(fetchPreviewSnapshot)
51
+ : fetchPreviewSnapshot;
@@ -0,0 +1,338 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
+ import ActionToolbar from '../action-toolbar';
4
+
5
+ /**
6
+ * The toolbar is portaled to <body> and pinned onto its host's viewport box, so
7
+ * everything here is read off `document`, never off the render container, and
8
+ * the host has to carry `[data-block-id]` / `[data-section-id]` — that is the
9
+ * selector the toolbar resolves its host with.
10
+ *
11
+ * These are the placement cases; that the portal escapes every clipping
12
+ * declaration a composition can set is covered in
13
+ * `src/__tests__/designer-chrome-clipping.test.tsx`.
14
+ */
15
+
16
+ /** Mirrors the component's own constants: 40px tall + the 2px `-top-0.5`. */
17
+ const SPACE_NEEDED_ABOVE = 42;
18
+ const CHROME_Z_BASE = 2147483000;
19
+
20
+ const rectAt = (top: number, left = 40) => ({
21
+ top,
22
+ left,
23
+ right: left + 300,
24
+ bottom: top + 200,
25
+ width: 300,
26
+ height: 200,
27
+ x: left,
28
+ y: top
29
+ });
30
+
31
+ /**
32
+ * A complete rect for the host and a zero rect for everything else — the shape
33
+ * `getBoundingClientRect` really has. A partial mock leaves `rect.left`
34
+ * undefined, which used to turn the every-render measure into an infinite
35
+ * render loop; `nonFiniteHostRect` below pins the guard that stops it.
36
+ */
37
+ const ZERO_RECT = {
38
+ top: 0,
39
+ left: 0,
40
+ right: 0,
41
+ bottom: 0,
42
+ width: 0,
43
+ height: 0,
44
+ x: 0,
45
+ y: 0
46
+ };
47
+
48
+ const mockHostRect = (rect: ReturnType<typeof rectAt>) =>
49
+ jest
50
+ .spyOn(Element.prototype, 'getBoundingClientRect')
51
+ .mockImplementation(function (this: Element) {
52
+ const value = this.hasAttribute('data-block-id') ? rect : ZERO_RECT;
53
+ return { ...value, toJSON: () => value } as DOMRect;
54
+ });
55
+
56
+ const renderToolbar = (
57
+ props: Partial<React.ComponentProps<typeof ActionToolbar>> = {}
58
+ ) =>
59
+ render(
60
+ <div data-block-id="b1">
61
+ <ActionToolbar label="My Section" {...props} />
62
+ </div>
63
+ );
64
+
65
+ const toolbarEl = () =>
66
+ document.querySelector('[data-action-toolbar]') as HTMLElement;
67
+
68
+ const frameEl = () => toolbarEl().parentElement as HTMLElement;
69
+
70
+ /** Every re-measure but a re-render is coalesced into a frame. */
71
+ const flushFrame = () =>
72
+ act(() => {
73
+ jest.advanceTimersByTime(20);
74
+ });
75
+
76
+ describe('ActionToolbar placement', () => {
77
+ beforeEach(() => jest.useFakeTimers());
78
+
79
+ afterEach(() => {
80
+ jest.useRealTimers();
81
+ jest.restoreAllMocks();
82
+ });
83
+
84
+ it('hangs above the host when there is room above it on screen', () => {
85
+ mockHostRect(rectAt(200));
86
+ renderToolbar();
87
+
88
+ expect(toolbarEl().className).toContain('-top-0.5');
89
+ expect(toolbarEl().className).toContain('-translate-y-full');
90
+ expect(toolbarEl().className).not.toContain('top-1');
91
+ });
92
+
93
+ /**
94
+ * The case the portal alone does not solve. Hanging above a host at the
95
+ * viewport top draws the toolbar at y = -42, off-screen, and nothing scrolls
96
+ * above 0 — rename / duplicate / delete would be unreachable. A chrome slot
97
+ * pinned with `position: sticky; top: 0` reports this rect at *every* scroll
98
+ * position, so there it is unreachable permanently.
99
+ */
100
+ it('flips inside the host when the host sits at the viewport top', () => {
101
+ mockHostRect(rectAt(0));
102
+ renderToolbar();
103
+
104
+ expect(toolbarEl().className).toContain('top-1');
105
+ expect(toolbarEl().className).not.toContain('-translate-y-full');
106
+ expect(toolbarEl().className).not.toContain('-top-0.5');
107
+ });
108
+
109
+ /**
110
+ * The flip must be expressed in the toolbar's own classes and nowhere else.
111
+ * Clamping or offsetting the frame instead would break the contract that the
112
+ * frame is pinned to the host's box
113
+ * (`designer-chrome-clipping.test.tsx` → 'pins a nested block toolbar…').
114
+ */
115
+ it('leaves the frame pinned to the host box when the toolbar flips', () => {
116
+ mockHostRect(rectAt(0, 220));
117
+ renderToolbar();
118
+
119
+ expect(toolbarEl().className).toContain('top-1');
120
+ expect(frameEl().style.position).toBe('fixed');
121
+ expect(frameEl().style.top).toBe('0px');
122
+ expect(frameEl().style.left).toBe('220px');
123
+ });
124
+
125
+ it('flips one pixel short of the room it needs and not one pixel over', () => {
126
+ mockHostRect(rectAt(SPACE_NEEDED_ABOVE - 1));
127
+ const short = render(
128
+ <div data-block-id="b1">
129
+ <ActionToolbar label="My Section" />
130
+ </div>
131
+ );
132
+ expect(toolbarEl().className).toContain('top-1');
133
+ short.unmount();
134
+
135
+ mockHostRect(rectAt(SPACE_NEEDED_ABOVE));
136
+ renderToolbar();
137
+ expect(toolbarEl().className).toContain('-translate-y-full');
138
+ });
139
+
140
+ /**
141
+ * Documented limitation, asserted so that fixing it is a deliberate change:
142
+ * once the host's top has scrolled *past* the viewport edge, neither branch
143
+ * puts the toolbar on screen — flipped it lands at `origin.top + 4`. The
144
+ * in-tree predecessor had the same hole. The fix is to slide the toolbar
145
+ * down inside the host by a computed amount, which needs the host's height
146
+ * carried in the measured origin.
147
+ */
148
+ it('still flips, but cannot rescue a host scrolled above the viewport', () => {
149
+ mockHostRect(rectAt(-300));
150
+ renderToolbar();
151
+
152
+ expect(toolbarEl().className).toContain('top-1');
153
+ // The frame is where the host is, i.e. off-screen; the flip does not clamp.
154
+ expect(frameEl().style.top).toBe('-300px');
155
+ });
156
+
157
+ it('repositions and re-evaluates placement on scroll', () => {
158
+ const spy = mockHostRect(rectAt(200));
159
+ renderToolbar();
160
+ expect(frameEl().style.top).toBe('200px');
161
+ expect(toolbarEl().className).toContain('-translate-y-full');
162
+
163
+ spy.mockRestore();
164
+ mockHostRect(rectAt(0));
165
+ fireEvent.scroll(window);
166
+ flushFrame();
167
+
168
+ expect(frameEl().style.top).toBe('0px');
169
+ expect(toolbarEl().className).toContain('top-1');
170
+ expect(toolbarEl().className).not.toContain('-translate-y-full');
171
+ });
172
+
173
+ it('repositions and re-evaluates placement on resize', () => {
174
+ const spy = mockHostRect(rectAt(0));
175
+ renderToolbar();
176
+ expect(frameEl().style.top).toBe('0px');
177
+ expect(toolbarEl().className).toContain('top-1');
178
+
179
+ spy.mockRestore();
180
+ mockHostRect(rectAt(200));
181
+ fireEvent.resize(window);
182
+ flushFrame();
183
+
184
+ expect(frameEl().style.top).toBe('200px');
185
+ expect(toolbarEl().className).toContain('-translate-y-full');
186
+ });
187
+
188
+ /**
189
+ * Move Up / Move Down are buttons on this toolbar: they move the host to a
190
+ * different y without resizing it, scrolling it or touching its attributes,
191
+ * so no listener fires. The canvas re-renders, so a render has to be a
192
+ * measuring point too.
193
+ */
194
+ it('re-evaluates placement on a re-render that moved the host', () => {
195
+ const spy = mockHostRect(rectAt(200));
196
+ const { rerender } = renderToolbar();
197
+ expect(toolbarEl().className).toContain('-translate-y-full');
198
+
199
+ spy.mockRestore();
200
+ mockHostRect(rectAt(0));
201
+ rerender(
202
+ <div data-block-id="b1">
203
+ <ActionToolbar label="My Section" isLocked />
204
+ </div>
205
+ );
206
+
207
+ expect(toolbarEl().className).toContain('top-1');
208
+ });
209
+
210
+ /**
211
+ * The measure runs on every render and its only brake is an unchanged
212
+ * origin, so a coordinate that is not a number (`NaN !== NaN`) would set a
213
+ * fresh origin every commit and render forever.
214
+ */
215
+ it('ignores an unreadable rect instead of re-rendering forever', () => {
216
+ mockHostRect(rectAt(200));
217
+ renderToolbar();
218
+ expect(frameEl().style.top).toBe('200px');
219
+
220
+ jest.restoreAllMocks();
221
+ jest
222
+ .spyOn(Element.prototype, 'getBoundingClientRect')
223
+ .mockReturnValue({ top: 10 } as DOMRect);
224
+
225
+ expect(() => {
226
+ fireEvent.scroll(window);
227
+ flushFrame();
228
+ }).not.toThrow();
229
+
230
+ // Left exactly where the last good measurement put it.
231
+ expect(frameEl().style.top).toBe('200px');
232
+ expect(frameEl().style.left).toBe('40px');
233
+ });
234
+
235
+ it('marks the toolbar with data-action-toolbar for outside-click guards', () => {
236
+ mockHostRect(rectAt(200));
237
+ renderToolbar();
238
+
239
+ // `accordion-block` reads this off the click target to tell chrome clicks
240
+ // from content clicks; portaled events still bubble through the React tree.
241
+ expect(toolbarEl()).toBeInTheDocument();
242
+ });
243
+ });
244
+
245
+ describe('ActionToolbar stacking', () => {
246
+ beforeEach(() => jest.useFakeTimers());
247
+
248
+ afterEach(() => {
249
+ jest.useRealTimers();
250
+ jest.restoreAllMocks();
251
+ });
252
+
253
+ /**
254
+ * At <body> the toolbar no longer inherits the host's stacking context, so
255
+ * the frame carries an absolute z-index and `zIndex` is only the relative
256
+ * order of block chrome (default 30) over section chrome (20).
257
+ */
258
+ it('stacks the frame above page chrome and leaves the toolbar unstyled', () => {
259
+ mockHostRect(rectAt(200));
260
+ renderToolbar();
261
+
262
+ expect(frameEl().style.zIndex).toBe(String(CHROME_Z_BASE + 30));
263
+ expect(toolbarEl().style.zIndex).toBe('');
264
+ });
265
+
266
+ it('orders section chrome below block chrome', () => {
267
+ mockHostRect(rectAt(200));
268
+ renderToolbar({ zIndex: 20 });
269
+
270
+ expect(frameEl().style.zIndex).toBe(String(CHROME_Z_BASE + 20));
271
+ expect(Number(frameEl().style.zIndex)).toBeLessThan(CHROME_Z_BASE + 30);
272
+ });
273
+
274
+ /**
275
+ * `zIndex` is added to a base that already sits just under the 32-bit
276
+ * maximum browsers clamp z-index to, so only a small range is usable: a
277
+ * larger value silently ties with everything else past the clamp instead of
278
+ * ordering above it.
279
+ */
280
+ it('keeps the default inside the range a browser can represent', () => {
281
+ mockHostRect(rectAt(200));
282
+ renderToolbar();
283
+
284
+ expect(Number(frameEl().style.zIndex)).toBeLessThanOrEqual(2147483647);
285
+ });
286
+ });
287
+
288
+ describe('ActionToolbar rename', () => {
289
+ beforeEach(() => jest.useFakeTimers());
290
+
291
+ afterEach(() => {
292
+ jest.useRealTimers();
293
+ jest.restoreAllMocks();
294
+ });
295
+
296
+ it('starts rename on label click and commits the trimmed value on Enter', () => {
297
+ mockHostRect(rectAt(200));
298
+ const onRename = jest.fn();
299
+ renderToolbar({ onRename });
300
+
301
+ fireEvent.click(screen.getByText('My Section'));
302
+ const input = screen.getByDisplayValue('My Section');
303
+ fireEvent.change(input, { target: { value: ' Renamed ' } });
304
+ fireEvent.keyDown(input, { key: 'Enter' });
305
+
306
+ expect(onRename).toHaveBeenCalledWith('Renamed');
307
+ });
308
+
309
+ it('cancels rename on Escape without calling onRename', () => {
310
+ mockHostRect(rectAt(200));
311
+ const onRename = jest.fn();
312
+ renderToolbar({ onRename });
313
+
314
+ fireEvent.click(screen.getByText('My Section'));
315
+ const input = screen.getByDisplayValue('My Section');
316
+ fireEvent.change(input, { target: { value: 'Changed' } });
317
+ fireEvent.keyDown(input, { key: 'Escape' });
318
+
319
+ expect(onRename).not.toHaveBeenCalled();
320
+ expect(screen.getByText('My Section')).toBeInTheDocument();
321
+ });
322
+
323
+ /**
324
+ * The renaming input's `border-2` makes the control strip 44px, so a
325
+ * measured height would move the flip threshold mid-interaction and could
326
+ * pull the toolbar out from under the cursor. The height is a constant
327
+ * precisely so it cannot.
328
+ */
329
+ it('does not move the toolbar when a rename starts', () => {
330
+ mockHostRect(rectAt(200));
331
+ renderToolbar({ onRename: jest.fn() });
332
+ const before = toolbarEl().className;
333
+
334
+ fireEvent.click(screen.getByText('My Section'));
335
+
336
+ expect(toolbarEl().className).toBe(before);
337
+ });
338
+ });
@@ -30,6 +30,27 @@ const HOST_SELECTOR = '[data-block-id],[data-section-id]';
30
30
  */
31
31
  const CHROME_Z_BASE = 2147483000;
32
32
 
33
+ /**
34
+ * The toolbar's own height, as a constant rather than a measurement: it is
35
+ * fully determined by this file's markup — the control strip is `h-10` and the
36
+ * label column is a `text-sm` 20px line box inside `p-2.5` — and nothing about
37
+ * the host or the composition can change it. Measuring would need a ref on an
38
+ * element that does not exist until `origin` is known, i.e. a second pass and
39
+ * a frame of wrong placement, and it would make the placement depend on
40
+ * `isRenaming` (the input's `border-2` makes the row 44px), so starting a
41
+ * rename could move the toolbar out from under the cursor.
42
+ */
43
+ const TOOLBAR_HEIGHT = 40;
44
+
45
+ /** The `-top-0.5` nudge above the host's edge, in pixels. */
46
+ const TOOLBAR_TOP_OFFSET = 2;
47
+
48
+ /**
49
+ * Room the toolbar needs above the host to be fully on screen. Hanging above,
50
+ * it occupies `[origin.top - 42, origin.top - 2]`.
51
+ */
52
+ const SPACE_NEEDED_ABOVE = TOOLBAR_HEIGHT + TOOLBAR_TOP_OFFSET;
53
+
33
54
  // `useLayoutEffect` would warn during SSR; the toolbar only ever mounts in the
34
55
  // designer, but the module is part of a server-rendered package.
35
56
  const useIsomorphicLayoutEffect =
@@ -140,6 +161,13 @@ export default function ActionToolbar({
140
161
  left: rect.left + host.clientLeft
141
162
  };
142
163
 
164
+ // A coordinate that is not a number would defeat the bail-out below
165
+ // (`NaN !== NaN`), and this effect runs on every render — so the measure
166
+ // would set a fresh origin every commit and re-render forever. Leaving
167
+ // the toolbar where it is is also the right answer: an unreadable rect
168
+ // is a failed measurement, not a moved host.
169
+ if (!Number.isFinite(next.top) || !Number.isFinite(next.left)) return;
170
+
143
171
  setOrigin((previous) =>
144
172
  previous && previous.top === next.top && previous.left === next.left
145
173
  ? previous
@@ -232,10 +260,38 @@ export default function ActionToolbar({
232
260
  handleCancelRename();
233
261
  }
234
262
  };
263
+
264
+ /**
265
+ * Portaled to <body>, no ancestor can clip the toolbar any more, so the one
266
+ * reason left to draw it *inside* its host is that there is no room above it
267
+ * on screen: a host whose top is at the viewport edge would have its toolbar
268
+ * drawn at a negative y, painted off-screen with no scroll position that
269
+ * reveals it — and rename / duplicate / delete exist nowhere else. Two hosts
270
+ * sit there in the canvas: the top-most section at scroll 0, and anything in
271
+ * a chrome slot pinned with `position: sticky; top: 0`, which reports
272
+ * `rect.top === 0` at every scroll position and so is unreachable
273
+ * permanently.
274
+ *
275
+ * This covers `0 <= origin.top < SPACE_NEEDED_ABOVE` only. A host whose top
276
+ * has scrolled *past* the viewport edge keeps an off-screen toolbar, exactly
277
+ * as it did when the toolbar rendered in-tree; fixing that means sliding the
278
+ * toolbar down inside the host by a computed amount, which needs the host's
279
+ * height carried in `origin`.
280
+ *
281
+ * Derived from `origin` during render rather than kept in its own state:
282
+ * `origin` is the only input, so a second state set from an effect would buy
283
+ * an extra render and one painted frame in the old place. It cannot feed
284
+ * back either — the toolbar is `fixed` inside a zero-size frame at <body>
285
+ * and contributes no layout, so flipping it can never move the host.
286
+ */
287
+ const placeInside = origin !== null && origin.top < SPACE_NEEDED_ABOVE;
288
+
235
289
  const toolbar = (
236
290
  <div
237
291
  data-action-toolbar=""
238
- className="absolute -top-0.5 left-0 -translate-y-full flex items-center pointer-events-auto gap-1 min-w-max"
292
+ className={`absolute left-0 flex items-center pointer-events-auto gap-1 min-w-max ${
293
+ placeInside ? 'top-1' : '-top-0.5 -translate-y-full'
294
+ }`}
239
295
  >
240
296
  <div className="flex items-center gap-1">
241
297
  {isRenaming ? (
@@ -8,6 +8,10 @@ import { Section } from './theme-section';
8
8
  import { devWarn } from './utils/dev-warn';
9
9
  import { validateThemeSectionsInDev } from './schemas/theme-schema';
10
10
  import { getPlaceholderWidget } from './chrome/composition-source';
11
+ import {
12
+ getPlaceholderPreviewSnapshot,
13
+ PreviewSnapshot
14
+ } from './chrome/preview-snapshot-source';
11
15
  import { getThemeConfig, ThemeConfig } from './chrome/theme-config-source';
12
16
 
13
17
  const { getWidgetData } = serverData;
@@ -58,6 +62,135 @@ const getWidgetSchemaData = async <T,>(args: {
58
62
  return directFetch<T>(`/widgets/${args.widgetSlug}/schema/`);
59
63
  };
60
64
 
65
+ /**
66
+ * Resolves a section's data source reference into the object the renderer
67
+ * expects, fetching collection data for collection-backed sources. Shared by
68
+ * the live render path and the preview-snapshot path so the two cannot drift.
69
+ */
70
+ async function resolveSectionDataSource(
71
+ dataSourceId: string | undefined,
72
+ dataSources: ThemeConfig['dataSources'],
73
+ sectionSlug: string
74
+ ): Promise<any | undefined> {
75
+ if (!dataSourceId) {
76
+ return undefined;
77
+ }
78
+
79
+ const dataSource = dataSources.find((ds) => ds.id === dataSourceId);
80
+
81
+ if (!dataSource) {
82
+ devWarn(
83
+ 'ThemePlaceholder',
84
+ `Section "${sectionSlug}" references data source "${dataSourceId}" which was not found; rendering placeholder items`
85
+ );
86
+ return undefined;
87
+ }
88
+
89
+ if (!dataSource.details) {
90
+ return dataSource;
91
+ }
92
+
93
+ // Only fetch collection data if we have a valid widget slug
94
+ // (not the section's own slug which contains config, not products)
95
+ const collectionSlug = dataSource.details.collection?.slug;
96
+ if (!collectionSlug) {
97
+ // The saved products from the editor (details.collection.products)
98
+ // will be used as fallback
99
+ return dataSource;
100
+ }
101
+
102
+ try {
103
+ let collectionData = await getWidgetData<{
104
+ [key: string]: unknown;
105
+ }>({
106
+ slug: collectionSlug
107
+ });
108
+
109
+ if (!collectionData) {
110
+ collectionData = await getCollectionWidgetData<{
111
+ [key: string]: unknown;
112
+ }>({
113
+ slug: collectionSlug
114
+ });
115
+ }
116
+
117
+ if (collectionData) {
118
+ return {
119
+ ...dataSource,
120
+ details: {
121
+ ...dataSource.details,
122
+ collection: {
123
+ ...dataSource.details.collection,
124
+ data: collectionData
125
+ }
126
+ }
127
+ };
128
+ }
129
+ } catch (error) {
130
+ console.error(
131
+ `Error fetching widget data for section ${sectionSlug}:`,
132
+ error
133
+ );
134
+ }
135
+
136
+ return dataSource;
137
+ }
138
+
139
+ /**
140
+ * Preview (draft mode): the theme editor writes the whole placeholder as one
141
+ * snapshot blob — sections arrive in render shape, so none of the per-widget
142
+ * fetches of the live path happen. The blob also owns the theme settings and
143
+ * data sources it was saved with.
144
+ */
145
+ async function loadFromPreviewSnapshot(
146
+ slug: string,
147
+ snapshot: PreviewSnapshot
148
+ ): Promise<LoadedPlaceholder> {
149
+ const dataSources: ThemeConfig['dataSources'] = Array.isArray(
150
+ snapshot.dataSources
151
+ )
152
+ ? (snapshot.dataSources as any[])
153
+ : [];
154
+
155
+ const resolved = await Promise.all(
156
+ snapshot.sections.map(async (raw: any): Promise<Section | null> => {
157
+ if (!raw?.id) {
158
+ return null;
159
+ }
160
+
161
+ return {
162
+ id: raw.id,
163
+ type: raw.type || 'default',
164
+ name: raw.name || raw.id,
165
+ label: raw.label || raw.id,
166
+ properties: raw.properties || {},
167
+ styles: raw.styles || {},
168
+ blocks: Array.isArray(raw.blocks) ? raw.blocks : [],
169
+ order: raw.order ?? 0,
170
+ hidden: raw.hidden || false,
171
+ dataSourceId: raw.dataSourceId,
172
+ dataSource: await resolveSectionDataSource(
173
+ raw.dataSourceId,
174
+ dataSources,
175
+ raw.id
176
+ )
177
+ };
178
+ })
179
+ );
180
+
181
+ const sections = resolved.filter((s): s is Section => s !== null);
182
+
183
+ validateThemeSectionsInDev(sections);
184
+
185
+ return {
186
+ sections,
187
+ placeholderId: slug,
188
+ dataSources,
189
+ themeSettings:
190
+ (snapshot.themeSettings as Record<string, unknown> | null) ?? null
191
+ };
192
+ }
193
+
61
194
  export interface LoadedPlaceholder {
62
195
  sections: Section[];
63
196
  placeholderId: string;
@@ -81,6 +214,12 @@ export async function loadPlaceholderSections(
81
214
  slug: string
82
215
  ): Promise<LoadedPlaceholder | null> {
83
216
  try {
217
+ // Absent/malformed blob (or no draft mode) falls through to the live path.
218
+ const previewSnapshot = await getPlaceholderPreviewSnapshot(slug);
219
+ if (previewSnapshot) {
220
+ return loadFromPreviewSnapshot(slug, previewSnapshot);
221
+ }
222
+
84
223
  // theme-config is optional for placeholder rendering, and the reader is
85
224
  // memoized per request — a page with several placeholders, or a ChromeSlot
86
225
  // asking the same question to decide whether it is pinned, reads it once.
@@ -224,60 +363,11 @@ export async function loadPlaceholderSections(
224
363
  (getBlockValue('dataSourceId') as string | undefined) ||
225
364
  (getBlockValue('selectedDataSourceId') as string | undefined);
226
365
 
227
- const dataSource = dataSourceId
228
- ? dataSources.find((ds) => ds.id === dataSourceId)
229
- : undefined;
230
-
231
- if (dataSourceId && !dataSource) {
232
- devWarn(
233
- 'ThemePlaceholder',
234
- `Section "${sectionSlug}" references data source "${dataSourceId}" which was not found; rendering placeholder items`
235
- );
236
- }
237
-
238
- let dataSourceWithData = dataSource;
239
- if (dataSource && dataSource.details) {
240
- // Only fetch collection data if we have a valid widget slug
241
- // (not the section's own slug which contains config, not products)
242
- const collectionSlug = dataSource.details.collection?.slug;
243
- if (collectionSlug) {
244
- try {
245
- let collectionData = await getWidgetData<{
246
- [key: string]: unknown;
247
- }>({
248
- slug: collectionSlug
249
- });
250
-
251
- if (!collectionData) {
252
- collectionData = await getCollectionWidgetData<{
253
- [key: string]: unknown;
254
- }>({
255
- slug: collectionSlug
256
- });
257
- }
258
-
259
- if (collectionData) {
260
- dataSourceWithData = {
261
- ...dataSource,
262
- details: {
263
- ...dataSource.details,
264
- collection: {
265
- ...dataSource.details.collection,
266
- data: collectionData
267
- }
268
- }
269
- };
270
- }
271
- } catch (error) {
272
- console.error(
273
- `Error fetching widget data for section ${sectionSlug}:`,
274
- error
275
- );
276
- }
277
- }
278
- // If no collection slug, the saved products from editor
279
- // (details.collection.products) will be used as fallback
280
- }
366
+ const dataSourceWithData = await resolveSectionDataSource(
367
+ dataSourceId,
368
+ dataSources,
369
+ sectionSlug
370
+ );
281
371
 
282
372
  const section: Section = {
283
373
  id: sectionSlug,