@zalify/storefront-kit 0.1.9 → 0.1.12

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.
@@ -16,7 +16,7 @@
16
16
  * later message must match it, and nothing but `bridge:ready` is ever
17
17
  * posted before the pin.
18
18
  */
19
- import { type Device, type SectionGroupData, type SettingsBag, type TemplateData, type ThemeEditorManifest } from '../schemas/index.ts';
19
+ import { type Device, type EditorBootstrap, type EditorCapability, type SectionGroupData, type SettingsData, type TemplateData, type ThemeEditorManifest } from '../schemas/index.ts';
20
20
  export declare const DATA_HOVERED_ATTR = "data-z-hovered";
21
21
  export interface FrameBridgeOptions {
22
22
  /** Template currently rendered ("index", "product", …). */
@@ -25,6 +25,10 @@ export interface FrameBridgeOptions {
25
25
  hash: string;
26
26
  /** Serve the editor manifest (static import or dynamic build). */
27
27
  getManifest: () => ThemeEditorManifest | Promise<ThemeEditorManifest>;
28
+ /** Capabilities supported by this deployed storefront runtime. */
29
+ capabilities?: EditorCapability[];
30
+ /** Serve the complete Site Editor bootstrap payload. */
31
+ getBootstrap?: () => EditorBootstrap | Promise<EditorBootstrap>;
28
32
  /**
29
33
  * Hot-apply an updated template in memory (optimistic edit). Return
30
34
  * false (or throw) if the app cannot apply without a reload.
@@ -33,7 +37,7 @@ export interface FrameBridgeOptions {
33
37
  templateName: string;
34
38
  template: TemplateData;
35
39
  groups?: Record<string, SectionGroupData>;
36
- settingsData?: SettingsBag;
40
+ settingsData?: SettingsData;
37
41
  }) => boolean | Promise<boolean>;
38
42
  onDeviceChange?: (device: Device) => void;
39
43
  /** Overrides for tests / non-browser hosts. */
@@ -33,6 +33,21 @@ function rectOf(element) {
33
33
  const rect = element.getBoundingClientRect();
34
34
  return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
35
35
  }
36
+ function visibleRectOf(element) {
37
+ const ownRect = rectOf(element);
38
+ if (ownRect.width > 0 && ownRect.height > 0)
39
+ return ownRect;
40
+ const childRects = [...element.children]
41
+ .map(rectOf)
42
+ .filter((rect) => rect.width > 0 && rect.height > 0);
43
+ if (childRects.length === 0)
44
+ return null;
45
+ const left = Math.min(...childRects.map((rect) => rect.x));
46
+ const top = Math.min(...childRects.map((rect) => rect.y));
47
+ const right = Math.max(...childRects.map((rect) => rect.x + rect.width));
48
+ const bottom = Math.max(...childRects.map((rect) => rect.y + rect.height));
49
+ return { x: left, y: top, width: right - left, height: bottom - top };
50
+ }
36
51
  function pathNodeOf(target) {
37
52
  if (!(target instanceof Element))
38
53
  return null;
@@ -47,6 +62,19 @@ export function mountFrameBridge(options) {
47
62
  const doc = win.document;
48
63
  let editorOrigin = null;
49
64
  let lastHeight = 0;
65
+ let selectedPath = null;
66
+ const selectionHighlight = doc.createElement('div');
67
+ selectionHighlight.id = 'zalify-editor-highlight';
68
+ selectionHighlight.setAttribute('aria-hidden', 'true');
69
+ Object.assign(selectionHighlight.style, {
70
+ position: 'fixed',
71
+ display: 'none',
72
+ pointerEvents: 'none',
73
+ boxSizing: 'border-box',
74
+ border: '2px solid #2563eb',
75
+ zIndex: '2147483647',
76
+ });
77
+ (doc.body ?? doc.documentElement).append(selectionHighlight);
50
78
  const post = (message) => {
51
79
  // bridge:ready is the only pre-pin message; everything else waits.
52
80
  const target = editorOrigin ?? '*';
@@ -63,6 +91,43 @@ export function mountFrameBridge(options) {
63
91
  const selector = `[${DATA_PATH_ATTR}="${CSS.escape(path)}"]`;
64
92
  doc.querySelector(selector)?.setAttribute(attr, '1');
65
93
  };
94
+ const syncSelectionHighlight = () => {
95
+ if (selectedPath === null) {
96
+ selectionHighlight.style.display = 'none';
97
+ return;
98
+ }
99
+ const selector = `[${DATA_PATH_ATTR}="${CSS.escape(selectedPath)}"]`;
100
+ const node = doc.querySelector(selector);
101
+ const rect = node ? visibleRectOf(node) : null;
102
+ if (!rect) {
103
+ selectionHighlight.style.display = 'none';
104
+ return;
105
+ }
106
+ Object.assign(selectionHighlight.style, {
107
+ display: 'block',
108
+ left: `${rect.x}px`,
109
+ top: `${rect.y}px`,
110
+ width: `${rect.width}px`,
111
+ height: `${rect.height}px`,
112
+ });
113
+ };
114
+ const selectionResizeObserver = new win.ResizeObserver(syncSelectionHighlight);
115
+ const setSelection = (path) => {
116
+ setUnique(DATA_SELECTED_ATTR, path);
117
+ selectedPath = path;
118
+ selectionResizeObserver.disconnect();
119
+ if (path !== null) {
120
+ const selector = `[${DATA_PATH_ATTR}="${CSS.escape(path)}"]`;
121
+ const node = doc.querySelector(selector);
122
+ if (node) {
123
+ selectionResizeObserver.observe(node);
124
+ for (const child of node.children) {
125
+ selectionResizeObserver.observe(child);
126
+ }
127
+ }
128
+ }
129
+ syncSelectionHighlight();
130
+ };
66
131
  const reportRects = () => {
67
132
  const rects = [...doc.querySelectorAll(`[${DATA_PATH_ATTR}]`)].map((node) => ({
68
133
  path: node.getAttribute(DATA_PATH_ATTR),
@@ -95,11 +160,18 @@ export function mountFrameBridge(options) {
95
160
  editorOrigin = event.origin;
96
161
  }
97
162
  options.onDeviceChange?.(message.payload.device);
98
- setUnique(DATA_SELECTED_ATTR, message.payload.selectedPath);
163
+ setSelection(message.payload.selectedPath);
164
+ // The first measurement can run before bridge:init arrives. It is
165
+ // intentionally not posted until the editor origin is pinned, so
166
+ // invalidate the cached value and publish it now.
167
+ queueMicrotask(() => {
168
+ lastHeight = 0;
169
+ measure();
170
+ });
99
171
  break;
100
172
  }
101
173
  case 'block:select':
102
- setUnique(DATA_SELECTED_ATTR, message.payload.path);
174
+ setSelection(message.payload.path);
103
175
  // Rects let the host scroll its full-height canvas to the
104
176
  // selection (the frame itself has no scrollbar in that mode).
105
177
  reportRects();
@@ -149,6 +221,21 @@ export function mountFrameBridge(options) {
149
221
  post({ type: 'manifest:response', payload: { manifest } });
150
222
  break;
151
223
  }
224
+ case 'editor:bootstrap:request': {
225
+ if (!options.getBootstrap) {
226
+ post({
227
+ type: 'bridge:error',
228
+ payload: {
229
+ code: 'bootstrap-unsupported',
230
+ message: 'frame cannot provide Site Editor bootstrap data',
231
+ },
232
+ });
233
+ break;
234
+ }
235
+ const bootstrap = await options.getBootstrap();
236
+ post({ type: 'editor:bootstrap:response', payload: { bootstrap } });
237
+ break;
238
+ }
152
239
  }
153
240
  };
154
241
  const handleClick = (event) => {
@@ -159,9 +246,26 @@ export function mountFrameBridge(options) {
159
246
  if (!node)
160
247
  return;
161
248
  const path = node.getAttribute(DATA_PATH_ATTR);
162
- setUnique(DATA_SELECTED_ATTR, path);
249
+ setSelection(path);
163
250
  post({ type: 'block:clicked', payload: { path, rect: rectOf(node) } });
164
251
  };
252
+ const handleWheel = (event) => {
253
+ // Both previews are fixed device viewports. Let ordinary wheel gestures
254
+ // scroll the storefront; pinch/Ctrl+wheel remains a canvas zoom gesture.
255
+ if (!event.ctrlKey)
256
+ return;
257
+ event.preventDefault();
258
+ post({
259
+ type: 'viewport:wheel',
260
+ payload: {
261
+ deltaX: event.deltaX,
262
+ deltaY: event.deltaY,
263
+ ctrlKey: event.ctrlKey,
264
+ clientX: event.clientX,
265
+ clientY: event.clientY,
266
+ },
267
+ });
268
+ };
165
269
  /** Suppress the page's own interactivity (capture phase, doc root). */
166
270
  const suppress = (event) => {
167
271
  event.preventDefault();
@@ -188,7 +292,7 @@ export function mountFrameBridge(options) {
188
292
  });
189
293
  };
190
294
  })();
191
- const measure = () => {
295
+ function measure() {
192
296
  // documentElement.scrollHeight never drops below the viewport, so a
193
297
  // full-height host iframe would ratchet upward forever; the body's
194
298
  // border box tracks actual content in both directions.
@@ -197,15 +301,19 @@ export function mountFrameBridge(options) {
197
301
  if (height === lastHeight || height <= 0)
198
302
  return;
199
303
  lastHeight = height;
304
+ syncSelectionHighlight();
200
305
  post({ type: 'height:changed', payload: { height } });
201
- };
306
+ }
202
307
  const resizeObserver = new win.ResizeObserver(measure);
203
308
  resizeObserver.observe(doc.documentElement);
204
309
  if (doc.body)
205
310
  resizeObserver.observe(doc.body);
206
311
  win.addEventListener('message', handleMessage);
312
+ win.addEventListener('resize', syncSelectionHighlight);
313
+ win.addEventListener('scroll', syncSelectionHighlight, true);
207
314
  doc.addEventListener('click', handleClick, true);
208
315
  doc.addEventListener('mousemove', handleMouseMove, true);
316
+ doc.addEventListener('wheel', handleWheel, { capture: true, passive: false });
209
317
  for (const type of SUPPRESSED_EVENTS) {
210
318
  doc.addEventListener(type, suppress, true);
211
319
  }
@@ -215,20 +323,26 @@ export function mountFrameBridge(options) {
215
323
  contractVersion: CONTRACT_VERSION,
216
324
  templateName: options.templateName,
217
325
  hash: options.hash,
326
+ capabilities: options.capabilities ?? [],
218
327
  },
219
328
  });
220
329
  measure();
221
330
  return {
222
331
  unmount: () => {
223
332
  win.removeEventListener('message', handleMessage);
333
+ win.removeEventListener('resize', syncSelectionHighlight);
334
+ win.removeEventListener('scroll', syncSelectionHighlight, true);
224
335
  doc.removeEventListener('click', handleClick, true);
225
336
  doc.removeEventListener('mousemove', handleMouseMove, true);
337
+ doc.removeEventListener('wheel', handleWheel, true);
226
338
  for (const type of SUPPRESSED_EVENTS) {
227
339
  doc.removeEventListener(type, suppress, true);
228
340
  }
229
341
  resizeObserver.disconnect();
230
- setUnique(DATA_SELECTED_ATTR, null);
342
+ selectionResizeObserver.disconnect();
343
+ setSelection(null);
231
344
  setUnique(DATA_HOVERED_ATTR, null);
345
+ selectionHighlight.remove();
232
346
  },
233
347
  notifySynced: (hash) => {
234
348
  post({ type: 'template:synced', payload: { hash } });
@@ -5,7 +5,7 @@
5
5
  * when the iframe reloads (sandbox HMR hard-refresh, crash recovery),
6
6
  * and exposes typed send/subscribe surfaces.
7
7
  */
8
- import { type Device, type FrameMessage, type HostMessage } from '../schemas/index.ts';
8
+ import { type Device, type EditorCapability, type FrameMessage, type HostMessage } from '../schemas/index.ts';
9
9
  export interface HostBridgeOptions {
10
10
  iframe: HTMLIFrameElement;
11
11
  /** Origin the preview is served from (the sandbox preview URL). */
@@ -16,6 +16,10 @@ export interface HostBridgeOptions {
16
16
  onMessage?: (message: FrameMessage) => void;
17
17
  /** Called when the frame speaks an incompatible contract version. */
18
18
  onIncompatible?: (frameVersion: string) => void;
19
+ /** Capabilities that must be present before the frame becomes writable. */
20
+ requiredCapabilities?: EditorCapability[];
21
+ /** Called with the required capabilities missing from bridge:ready. */
22
+ onMissingCapabilities?: (capabilities: EditorCapability[]) => void;
19
23
  /** Overrides for tests / non-browser hosts. */
20
24
  window?: Window;
21
25
  }
@@ -38,6 +38,13 @@ export function createHostBridge(options) {
38
38
  }
39
39
  const message = data;
40
40
  if (message.type === 'bridge:ready') {
41
+ const offered = new Set(message.payload.capabilities ?? []);
42
+ const missing = (options.requiredCapabilities ?? []).filter((capability) => !offered.has(capability));
43
+ if (missing.length) {
44
+ ready = false;
45
+ options.onMissingCapabilities?.(missing);
46
+ return;
47
+ }
41
48
  // First load and every iframe reload land here: (re)init.
42
49
  ready = true;
43
50
  post({
@@ -10,6 +10,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
10
10
  */
11
11
  import { useMemo } from 'react';
12
12
  import { colorSchemes, themeSettings } from './settings';
13
+ import { getThemeStoreVersion } from './store';
13
14
  import { googleFontsHrefsFromSettings, parseFontHandle, } from "../../commerce/google-fonts.js";
14
15
  function buildCss() {
15
16
  const s = themeSettings;
@@ -106,7 +107,8 @@ const FONT_CSS_LOADER = [
106
107
  '})(l[i])}',
107
108
  ].join('');
108
109
  export function CssVariables({ nonce, fonts, } = {}) {
109
- const { css, links } = useMemo(() => ({ css: buildCss(), links: googleFontsHrefs() }), []);
110
+ const themeVersion = getThemeStoreVersion();
111
+ const { css, links } = useMemo(() => ({ css: buildCss(), links: googleFontsHrefs() }), [themeVersion]);
110
112
  // Server-resolved fonts (the next/font pattern): @font-face rules are
111
113
  // inlined and the latin woff2 files preloaded, so the font downloads
112
114
  // with the HTML and usually beats first paint — no async stylesheet,
@@ -13,7 +13,7 @@
13
13
  * Server loaders: import {loadSectionData} from '@zalify/storefront-kit/react/server'.
14
14
  */
15
15
  export * from './engine/types';
16
- export { installTheme, getTemplate, getSectionGroup, parseThemeJson, } from './engine/store';
16
+ export { installTheme, getTemplate, getSectionGroup, getThemeStoreVersion, parseThemeJson, } from './engine/store';
17
17
  export type { ThemeSchema, InstallThemeOptions } from './engine/store';
18
18
  export { themeSettings, colorSchemes, getColorScheme } from './engine/settings';
19
19
  export { t } from './engine/translate';
@@ -14,7 +14,7 @@
14
14
  */
15
15
  // Engine
16
16
  export * from './engine/types';
17
- export { installTheme, getTemplate, getSectionGroup, parseThemeJson, } from './engine/store';
17
+ export { installTheme, getTemplate, getSectionGroup, getThemeStoreVersion, parseThemeJson, } from './engine/store';
18
18
  export { themeSettings, colorSchemes, getColorScheme } from './engine/settings';
19
19
  export { t } from './engine/translate';
20
20
  export { ThemeTemplate, SectionGroup, RenderSection, RenderSections, } from './engine/render';
@@ -6,7 +6,7 @@
6
6
  * Both sides validate `event.origin` and the message envelope before
7
7
  * acting; versions negotiate on CONTRACT_VERSION's major component.
8
8
  */
9
- import type { SectionGroupData, SettingsBag, TemplateData } from './data.ts';
9
+ import type { SectionGroupData, SettingsData, TemplateData } from './data.ts';
10
10
  import type { ThemeEditorManifest } from './manifest.ts';
11
11
  export declare const BRIDGE_NAMESPACE = "zalify-editor-bridge";
12
12
  /** Query param that switches a theme preview into editor mode. */
@@ -16,6 +16,35 @@ export declare const DATA_PATH_ATTR = "data-z-path";
16
16
  /** DOM attribute the bridge sets on the selected node. */
17
17
  export declare const DATA_SELECTED_ATTR = "data-z-selected";
18
18
  export type Device = 'desktop' | 'mobile';
19
+ export type EditorCapability = 'editor-bootstrap-v1' | 'apply-template-v1' | 'apply-groups-v1' | 'apply-settings-v1' | 'preview-navigation-v1';
20
+ export type PreviewResourceType = 'index' | 'product' | 'collection' | 'page' | 'blog' | 'article' | 'cart' | 'search' | 'list-collections' | '404';
21
+ export interface PreviewContext {
22
+ id: string;
23
+ title: string;
24
+ url: string;
25
+ resourceType: PreviewResourceType;
26
+ }
27
+ export interface EditorBootstrap {
28
+ revision: string;
29
+ manifest: ThemeEditorManifest;
30
+ templates: Array<{
31
+ name: string;
32
+ writePath: string;
33
+ data: TemplateData;
34
+ preview?: PreviewContext;
35
+ }>;
36
+ groups: Array<{
37
+ name: string;
38
+ writePath: string;
39
+ data: SectionGroupData;
40
+ }>;
41
+ settings: {
42
+ writePath: string;
43
+ schema: ThemeEditorManifest['settingsSchema'];
44
+ resolvedData: SettingsData;
45
+ };
46
+ previewContexts: Partial<Record<'product' | 'collection' | 'page' | 'blog' | 'article', PreviewContext>>;
47
+ }
19
48
  export interface DOMRectLike {
20
49
  x: number;
21
50
  y: number;
@@ -60,14 +89,15 @@ export type HostMessage = BridgeEnvelope<'bridge:init', {
60
89
  templateName: string;
61
90
  template: TemplateData;
62
91
  groups?: Record<string, SectionGroupData>;
63
- settingsData?: SettingsBag;
92
+ settingsData?: SettingsData;
64
93
  }> | BridgeEnvelope<'device:set', {
65
94
  device: Device;
66
- }> | BridgeEnvelope<'manifest:request', Record<string, never>>;
95
+ }> | BridgeEnvelope<'manifest:request', Record<string, never>> | BridgeEnvelope<'editor:bootstrap:request', Record<string, never>>;
67
96
  export type FrameMessage = BridgeEnvelope<'bridge:ready', {
68
97
  contractVersion: string;
69
98
  templateName: string;
70
99
  hash: string;
100
+ capabilities: EditorCapability[];
71
101
  }> | BridgeEnvelope<'block:clicked', {
72
102
  path: string;
73
103
  rect: DOMRectLike;
@@ -81,10 +111,18 @@ export type FrameMessage = BridgeEnvelope<'bridge:ready', {
81
111
  }>;
82
112
  }> | BridgeEnvelope<'manifest:response', {
83
113
  manifest: ThemeEditorManifest;
114
+ }> | BridgeEnvelope<'editor:bootstrap:response', {
115
+ bootstrap: EditorBootstrap;
84
116
  }> | BridgeEnvelope<'template:synced', {
85
117
  hash: string;
86
118
  }> | BridgeEnvelope<'height:changed', {
87
119
  height: number;
120
+ }> | BridgeEnvelope<'viewport:wheel', {
121
+ deltaX: number;
122
+ deltaY: number;
123
+ ctrlKey: boolean;
124
+ clientX: number;
125
+ clientY: number;
88
126
  }> | BridgeEnvelope<'navigation', {
89
127
  templateName: string;
90
128
  url: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalify/storefront-kit",
3
- "version": "0.1.9",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "description": "The Zalify storefront SDK: framework-agnostic commerce logic (/commerce), the theme contract types and validators (/schemas), the canvas-editor bridge (/editor), and the React theme engine + shared components (/ui, /react/server). Consumed as TypeScript source inside the zalify-storefronts monorepo; published as compiled ESM + d.ts.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -26,10 +26,12 @@ import {
26
26
  isCompatibleVersion,
27
27
  type Device,
28
28
  type DOMRectLike,
29
+ type EditorBootstrap,
30
+ type EditorCapability,
29
31
  type FrameMessage,
30
32
  type HostMessage,
31
33
  type SectionGroupData,
32
- type SettingsBag,
34
+ type SettingsData,
33
35
  type TemplateData,
34
36
  type ThemeEditorManifest,
35
37
  } from '../schemas/index.ts';
@@ -45,6 +47,10 @@ export interface FrameBridgeOptions {
45
47
  getManifest: () =>
46
48
  | ThemeEditorManifest
47
49
  | Promise<ThemeEditorManifest>;
50
+ /** Capabilities supported by this deployed storefront runtime. */
51
+ capabilities?: EditorCapability[];
52
+ /** Serve the complete Site Editor bootstrap payload. */
53
+ getBootstrap?: () => EditorBootstrap | Promise<EditorBootstrap>;
48
54
  /**
49
55
  * Hot-apply an updated template in memory (optimistic edit). Return
50
56
  * false (or throw) if the app cannot apply without a reload.
@@ -53,7 +59,7 @@ export interface FrameBridgeOptions {
53
59
  templateName: string;
54
60
  template: TemplateData;
55
61
  groups?: Record<string, SectionGroupData>;
56
- settingsData?: SettingsBag;
62
+ settingsData?: SettingsData;
57
63
  }) => boolean | Promise<boolean>;
58
64
  onDeviceChange?: (device: Device) => void;
59
65
  /** Overrides for tests / non-browser hosts. */
@@ -77,6 +83,22 @@ function rectOf(element: Element): DOMRectLike {
77
83
  return {x: rect.x, y: rect.y, width: rect.width, height: rect.height};
78
84
  }
79
85
 
86
+ function visibleRectOf(element: Element): DOMRectLike | null {
87
+ const ownRect = rectOf(element);
88
+ if (ownRect.width > 0 && ownRect.height > 0) return ownRect;
89
+
90
+ const childRects = [...element.children]
91
+ .map(rectOf)
92
+ .filter((rect) => rect.width > 0 && rect.height > 0);
93
+ if (childRects.length === 0) return null;
94
+
95
+ const left = Math.min(...childRects.map((rect) => rect.x));
96
+ const top = Math.min(...childRects.map((rect) => rect.y));
97
+ const right = Math.max(...childRects.map((rect) => rect.x + rect.width));
98
+ const bottom = Math.max(...childRects.map((rect) => rect.y + rect.height));
99
+ return {x: left, y: top, width: right - left, height: bottom - top};
100
+ }
101
+
80
102
  function pathNodeOf(target: EventTarget | null): HTMLElement | null {
81
103
  if (!(target instanceof Element)) return null;
82
104
  return target.closest<HTMLElement>(`[${DATA_PATH_ATTR}]`);
@@ -103,6 +125,20 @@ export function mountFrameBridge(
103
125
  const doc = win.document;
104
126
  let editorOrigin: string | null = null;
105
127
  let lastHeight = 0;
128
+ let selectedPath: string | null = null;
129
+
130
+ const selectionHighlight = doc.createElement('div');
131
+ selectionHighlight.id = 'zalify-editor-highlight';
132
+ selectionHighlight.setAttribute('aria-hidden', 'true');
133
+ Object.assign(selectionHighlight.style, {
134
+ position: 'fixed',
135
+ display: 'none',
136
+ pointerEvents: 'none',
137
+ boxSizing: 'border-box',
138
+ border: '2px solid #2563eb',
139
+ zIndex: '2147483647',
140
+ });
141
+ (doc.body ?? doc.documentElement).append(selectionHighlight);
106
142
 
107
143
  const post = (message: Omit<FrameMessage, 'z' | 'v'>): void => {
108
144
  // bridge:ready is the only pre-pin message; everything else waits.
@@ -123,6 +159,47 @@ export function mountFrameBridge(
123
159
  doc.querySelector(selector)?.setAttribute(attr, '1');
124
160
  };
125
161
 
162
+ const syncSelectionHighlight = (): void => {
163
+ if (selectedPath === null) {
164
+ selectionHighlight.style.display = 'none';
165
+ return;
166
+ }
167
+ const selector = `[${DATA_PATH_ATTR}="${CSS.escape(selectedPath)}"]`;
168
+ const node = doc.querySelector(selector);
169
+ const rect = node ? visibleRectOf(node) : null;
170
+ if (!rect) {
171
+ selectionHighlight.style.display = 'none';
172
+ return;
173
+ }
174
+ Object.assign(selectionHighlight.style, {
175
+ display: 'block',
176
+ left: `${rect.x}px`,
177
+ top: `${rect.y}px`,
178
+ width: `${rect.width}px`,
179
+ height: `${rect.height}px`,
180
+ });
181
+ };
182
+
183
+ const selectionResizeObserver = new win.ResizeObserver(
184
+ syncSelectionHighlight,
185
+ );
186
+ const setSelection = (path: string | null): void => {
187
+ setUnique(DATA_SELECTED_ATTR, path);
188
+ selectedPath = path;
189
+ selectionResizeObserver.disconnect();
190
+ if (path !== null) {
191
+ const selector = `[${DATA_PATH_ATTR}="${CSS.escape(path)}"]`;
192
+ const node = doc.querySelector(selector);
193
+ if (node) {
194
+ selectionResizeObserver.observe(node);
195
+ for (const child of node.children) {
196
+ selectionResizeObserver.observe(child);
197
+ }
198
+ }
199
+ }
200
+ syncSelectionHighlight();
201
+ };
202
+
126
203
  const reportRects = (): void => {
127
204
  const rects = [...doc.querySelectorAll(`[${DATA_PATH_ATTR}]`)].map(
128
205
  (node) => ({
@@ -155,11 +232,18 @@ export function mountFrameBridge(
155
232
  editorOrigin = event.origin;
156
233
  }
157
234
  options.onDeviceChange?.(message.payload.device);
158
- setUnique(DATA_SELECTED_ATTR, message.payload.selectedPath);
235
+ setSelection(message.payload.selectedPath);
236
+ // The first measurement can run before bridge:init arrives. It is
237
+ // intentionally not posted until the editor origin is pinned, so
238
+ // invalidate the cached value and publish it now.
239
+ queueMicrotask(() => {
240
+ lastHeight = 0;
241
+ measure();
242
+ });
159
243
  break;
160
244
  }
161
245
  case 'block:select':
162
- setUnique(DATA_SELECTED_ATTR, message.payload.path);
246
+ setSelection(message.payload.path);
163
247
  // Rects let the host scroll its full-height canvas to the
164
248
  // selection (the frame itself has no scrollbar in that mode).
165
249
  reportRects();
@@ -208,6 +292,21 @@ export function mountFrameBridge(
208
292
  post({type: 'manifest:response', payload: {manifest}});
209
293
  break;
210
294
  }
295
+ case 'editor:bootstrap:request': {
296
+ if (!options.getBootstrap) {
297
+ post({
298
+ type: 'bridge:error',
299
+ payload: {
300
+ code: 'bootstrap-unsupported',
301
+ message: 'frame cannot provide Site Editor bootstrap data',
302
+ },
303
+ });
304
+ break;
305
+ }
306
+ const bootstrap = await options.getBootstrap();
307
+ post({type: 'editor:bootstrap:response', payload: {bootstrap}});
308
+ break;
309
+ }
211
310
  }
212
311
  };
213
312
 
@@ -218,10 +317,27 @@ export function mountFrameBridge(
218
317
  event.stopPropagation();
219
318
  if (!node) return;
220
319
  const path = node.getAttribute(DATA_PATH_ATTR)!;
221
- setUnique(DATA_SELECTED_ATTR, path);
320
+ setSelection(path);
222
321
  post({type: 'block:clicked', payload: {path, rect: rectOf(node)}});
223
322
  };
224
323
 
324
+ const handleWheel = (event: WheelEvent): void => {
325
+ // Both previews are fixed device viewports. Let ordinary wheel gestures
326
+ // scroll the storefront; pinch/Ctrl+wheel remains a canvas zoom gesture.
327
+ if (!event.ctrlKey) return;
328
+ event.preventDefault();
329
+ post({
330
+ type: 'viewport:wheel',
331
+ payload: {
332
+ deltaX: event.deltaX,
333
+ deltaY: event.deltaY,
334
+ ctrlKey: event.ctrlKey,
335
+ clientX: event.clientX,
336
+ clientY: event.clientY,
337
+ },
338
+ });
339
+ };
340
+
225
341
  /** Suppress the page's own interactivity (capture phase, doc root). */
226
342
  const suppress = (event: Event): void => {
227
343
  event.preventDefault();
@@ -249,7 +365,7 @@ export function mountFrameBridge(
249
365
  };
250
366
  })();
251
367
 
252
- const measure = (): void => {
368
+ function measure(): void {
253
369
  // documentElement.scrollHeight never drops below the viewport, so a
254
370
  // full-height host iframe would ratchet upward forever; the body's
255
371
  // border box tracks actual content in both directions.
@@ -259,15 +375,19 @@ export function mountFrameBridge(
259
375
  );
260
376
  if (height === lastHeight || height <= 0) return;
261
377
  lastHeight = height;
378
+ syncSelectionHighlight();
262
379
  post({type: 'height:changed', payload: {height}});
263
- };
380
+ }
264
381
  const resizeObserver = new win.ResizeObserver(measure);
265
382
  resizeObserver.observe(doc.documentElement);
266
383
  if (doc.body) resizeObserver.observe(doc.body);
267
384
 
268
385
  win.addEventListener('message', handleMessage);
386
+ win.addEventListener('resize', syncSelectionHighlight);
387
+ win.addEventListener('scroll', syncSelectionHighlight, true);
269
388
  doc.addEventListener('click', handleClick, true);
270
389
  doc.addEventListener('mousemove', handleMouseMove, true);
390
+ doc.addEventListener('wheel', handleWheel, {capture: true, passive: false});
271
391
  for (const type of SUPPRESSED_EVENTS) {
272
392
  doc.addEventListener(type, suppress, true);
273
393
  }
@@ -278,6 +398,7 @@ export function mountFrameBridge(
278
398
  contractVersion: CONTRACT_VERSION,
279
399
  templateName: options.templateName,
280
400
  hash: options.hash,
401
+ capabilities: options.capabilities ?? [],
281
402
  },
282
403
  });
283
404
  measure();
@@ -285,14 +406,19 @@ export function mountFrameBridge(
285
406
  return {
286
407
  unmount: () => {
287
408
  win.removeEventListener('message', handleMessage);
409
+ win.removeEventListener('resize', syncSelectionHighlight);
410
+ win.removeEventListener('scroll', syncSelectionHighlight, true);
288
411
  doc.removeEventListener('click', handleClick, true);
289
412
  doc.removeEventListener('mousemove', handleMouseMove, true);
413
+ doc.removeEventListener('wheel', handleWheel, true);
290
414
  for (const type of SUPPRESSED_EVENTS) {
291
415
  doc.removeEventListener(type, suppress, true);
292
416
  }
293
417
  resizeObserver.disconnect();
294
- setUnique(DATA_SELECTED_ATTR, null);
418
+ selectionResizeObserver.disconnect();
419
+ setSelection(null);
295
420
  setUnique(DATA_HOVERED_ATTR, null);
421
+ selectionHighlight.remove();
296
422
  },
297
423
  notifySynced: (hash) => {
298
424
  post({type: 'template:synced', payload: {hash}});
@@ -12,6 +12,7 @@ import {
12
12
  isBridgeMessage,
13
13
  isCompatibleVersion,
14
14
  type Device,
15
+ type EditorCapability,
15
16
  type FrameMessage,
16
17
  type HostMessage,
17
18
  } from '../schemas/index.ts';
@@ -26,6 +27,10 @@ export interface HostBridgeOptions {
26
27
  onMessage?: (message: FrameMessage) => void;
27
28
  /** Called when the frame speaks an incompatible contract version. */
28
29
  onIncompatible?: (frameVersion: string) => void;
30
+ /** Capabilities that must be present before the frame becomes writable. */
31
+ requiredCapabilities?: EditorCapability[];
32
+ /** Called with the required capabilities missing from bridge:ready. */
33
+ onMissingCapabilities?: (capabilities: EditorCapability[]) => void;
29
34
  /** Overrides for tests / non-browser hosts. */
30
35
  window?: Window;
31
36
  }
@@ -71,6 +76,15 @@ export function createHostBridge(options: HostBridgeOptions): HostBridge {
71
76
  }
72
77
  const message = data as FrameMessage;
73
78
  if (message.type === 'bridge:ready') {
79
+ const offered = new Set(message.payload.capabilities ?? []);
80
+ const missing = (options.requiredCapabilities ?? []).filter(
81
+ (capability) => !offered.has(capability),
82
+ );
83
+ if (missing.length) {
84
+ ready = false;
85
+ options.onMissingCapabilities?.(missing);
86
+ return;
87
+ }
74
88
  // First load and every iframe reload land here: (re)init.
75
89
  ready = true;
76
90
  post({
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import {useMemo} from 'react';
11
11
  import {colorSchemes, themeSettings} from './settings';
12
+ import {getThemeStoreVersion} from './store';
12
13
  import {
13
14
  googleFontsHrefsFromSettings,
14
15
  parseFontHandle,
@@ -136,9 +137,10 @@ export function CssVariables({
136
137
  nonce,
137
138
  fonts,
138
139
  }: {nonce?: string; fonts?: ResolvedGoogleFontCss | null} = {}) {
140
+ const themeVersion = getThemeStoreVersion();
139
141
  const {css, links} = useMemo(
140
142
  () => ({css: buildCss(), links: googleFontsHrefs()}),
141
- [],
143
+ [themeVersion],
142
144
  );
143
145
 
144
146
  // Server-resolved fonts (the next/font pattern): @font-face rules are
@@ -19,6 +19,7 @@ export {
19
19
  installTheme,
20
20
  getTemplate,
21
21
  getSectionGroup,
22
+ getThemeStoreVersion,
22
23
  parseThemeJson,
23
24
  } from './engine/store';
24
25
  export type {ThemeSchema, InstallThemeOptions} from './engine/store';
@@ -6,7 +6,11 @@
6
6
  * Both sides validate `event.origin` and the message envelope before
7
7
  * acting; versions negotiate on CONTRACT_VERSION's major component.
8
8
  */
9
- import type {SectionGroupData, SettingsBag, TemplateData} from './data.ts';
9
+ import type {
10
+ SectionGroupData,
11
+ SettingsData,
12
+ TemplateData,
13
+ } from './data.ts';
10
14
  import type {ThemeEditorManifest} from './manifest.ts';
11
15
 
12
16
  export const BRIDGE_NAMESPACE = 'zalify-editor-bridge';
@@ -21,6 +25,56 @@ export const DATA_SELECTED_ATTR = 'data-z-selected';
21
25
 
22
26
  export type Device = 'desktop' | 'mobile';
23
27
 
28
+ export type EditorCapability =
29
+ | 'editor-bootstrap-v1'
30
+ | 'apply-template-v1'
31
+ | 'apply-groups-v1'
32
+ | 'apply-settings-v1'
33
+ | 'preview-navigation-v1';
34
+
35
+ export type PreviewResourceType =
36
+ | 'index'
37
+ | 'product'
38
+ | 'collection'
39
+ | 'page'
40
+ | 'blog'
41
+ | 'article'
42
+ | 'cart'
43
+ | 'search'
44
+ | 'list-collections'
45
+ | '404';
46
+
47
+ export interface PreviewContext {
48
+ id: string;
49
+ title: string;
50
+ url: string;
51
+ resourceType: PreviewResourceType;
52
+ }
53
+
54
+ export interface EditorBootstrap {
55
+ revision: string;
56
+ manifest: ThemeEditorManifest;
57
+ templates: Array<{
58
+ name: string;
59
+ writePath: string;
60
+ data: TemplateData;
61
+ preview?: PreviewContext;
62
+ }>;
63
+ groups: Array<{
64
+ name: string;
65
+ writePath: string;
66
+ data: SectionGroupData;
67
+ }>;
68
+ settings: {
69
+ writePath: string;
70
+ schema: ThemeEditorManifest['settingsSchema'];
71
+ resolvedData: SettingsData;
72
+ };
73
+ previewContexts: Partial<
74
+ Record<'product' | 'collection' | 'page' | 'blog' | 'article', PreviewContext>
75
+ >;
76
+ }
77
+
24
78
  export interface DOMRectLike {
25
79
  x: number;
26
80
  y: number;
@@ -96,18 +150,24 @@ export type HostMessage =
96
150
  templateName: string;
97
151
  template: TemplateData;
98
152
  groups?: Record<string, SectionGroupData>;
99
- settingsData?: SettingsBag;
153
+ settingsData?: SettingsData;
100
154
  }
101
155
  >
102
156
  | BridgeEnvelope<'device:set', {device: Device}>
103
- | BridgeEnvelope<'manifest:request', Record<string, never>>;
157
+ | BridgeEnvelope<'manifest:request', Record<string, never>>
158
+ | BridgeEnvelope<'editor:bootstrap:request', Record<string, never>>;
104
159
 
105
160
  /* ------------------------- frame -> host ---------------------------- */
106
161
 
107
162
  export type FrameMessage =
108
163
  | BridgeEnvelope<
109
164
  'bridge:ready',
110
- {contractVersion: string; templateName: string; hash: string}
165
+ {
166
+ contractVersion: string;
167
+ templateName: string;
168
+ hash: string;
169
+ capabilities: EditorCapability[];
170
+ }
111
171
  >
112
172
  | BridgeEnvelope<'block:clicked', {path: string; rect: DOMRectLike}>
113
173
  | BridgeEnvelope<'block:hovered', {path: string | null; rect?: DOMRectLike}>
@@ -116,8 +176,19 @@ export type FrameMessage =
116
176
  {rects: Array<{path: string; rect: DOMRectLike}>}
117
177
  >
118
178
  | BridgeEnvelope<'manifest:response', {manifest: ThemeEditorManifest}>
179
+ | BridgeEnvelope<'editor:bootstrap:response', {bootstrap: EditorBootstrap}>
119
180
  | BridgeEnvelope<'template:synced', {hash: string}>
120
181
  | BridgeEnvelope<'height:changed', {height: number}>
182
+ | BridgeEnvelope<
183
+ 'viewport:wheel',
184
+ {
185
+ deltaX: number;
186
+ deltaY: number;
187
+ ctrlKey: boolean;
188
+ clientX: number;
189
+ clientY: number;
190
+ }
191
+ >
121
192
  | BridgeEnvelope<'navigation', {templateName: string; url: string}>
122
193
  | BridgeEnvelope<'bridge:error', {code: string; message: string}>;
123
194