@kubex/zinc 1.1.68 → 1.1.70

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.
Files changed (37) hide show
  1. package/dist/custom-elements.json +1719 -282
  2. package/dist/vscode.html-custom-data.json +160 -29
  3. package/dist/web-types.json +395 -70
  4. package/dist/zn.d.ts +395 -16
  5. package/dist/zn.min.js +580 -408
  6. package/docs/pages/components/collapsible.md +6 -2
  7. package/docs/pages/components/icon-picker.md +14 -0
  8. package/docs/pages/components/input.md +19 -1
  9. package/docs/pages/components/preview-frame-demo.njk +33 -4
  10. package/docs/pages/components/preview-frame.md +20 -0
  11. package/docs/pages/components/theme-editor.md +303 -0
  12. package/docs/superpowers/plans/2026-08-03-theme-editor.md +1536 -0
  13. package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +327 -0
  14. package/package.json +1 -1
  15. package/src/components/collapsible/collapsible.component.ts +21 -23
  16. package/src/components/dropdown/dropdown.component.ts +9 -0
  17. package/src/components/editor/editor.component.ts +20 -21
  18. package/src/components/file/file.component.ts +15 -0
  19. package/src/components/icon-picker/icon-picker.component.ts +150 -16
  20. package/src/components/icon-picker/icon-picker.scss +32 -0
  21. package/src/components/input/input.component.ts +44 -21
  22. package/src/components/input/input.scss +134 -42
  23. package/src/components/input/input.test.ts +45 -0
  24. package/src/components/preview-frame/preview-frame.component.ts +126 -16
  25. package/src/components/preview-frame/preview-frame.scss +27 -0
  26. package/src/components/preview-frame/preview-frame.test.ts +253 -0
  27. package/src/components/theme-editor/index.ts +12 -0
  28. package/src/components/theme-editor/theme-editor.component.ts +918 -0
  29. package/src/components/theme-editor/theme-editor.scss +309 -0
  30. package/src/components/theme-editor/theme-editor.test.ts +1752 -0
  31. package/src/events/events.ts +2 -0
  32. package/src/events/zn-theme-change.ts +13 -0
  33. package/src/events/zn-theme-submit.ts +9 -0
  34. package/src/types/web-test-runner-commands.d.ts +6 -0
  35. package/src/zinc.ts +1 -0
  36. package/tsconfig.json +7 -1
  37. package/web-test-runner.config.js +3 -1
@@ -172,4 +172,49 @@ describe('<zn-input>', () => {
172
172
  expect(secondInput.value).to.equal('');
173
173
  });
174
174
  });
175
+
176
+ describe('type="range"', () => {
177
+ const contextNote = (el: ZnInput) =>
178
+ el.shadowRoot!.querySelector('.form-control__label-context-note')!.textContent!.trim();
179
+
180
+ it('adopts the native midpoint when no value is set', async () => {
181
+ const el = await fixture<ZnInput>(html`<zn-input type="range" min="0" max="10"></zn-input>`);
182
+ await el.updateComplete;
183
+
184
+ expect(el.value).to.equal('5');
185
+ });
186
+
187
+ it('displays the value with its suffix in the context note', async () => {
188
+ const el = await fixture<ZnInput>(
189
+ html`<zn-input type="range" label="Radius" min="0" max="2" step="0.5" value="0.5" value-suffix="rem"></zn-input>`
190
+ );
191
+ await el.updateComplete;
192
+
193
+ expect(contextNote(el)).to.equal('0.5rem');
194
+ });
195
+
196
+ it('updates the context note as the value changes', async () => {
197
+ const el = await fixture<ZnInput>(
198
+ html`<zn-input type="range" label="Radius" min="0" max="10" value="4" value-suffix="px"></zn-input>`
199
+ );
200
+ await el.updateComplete;
201
+
202
+ const input = el.shadowRoot!.querySelector('.input__control') as HTMLInputElement;
203
+ input.value = '7';
204
+ input.dispatchEvent(new Event('input'));
205
+ await el.updateComplete;
206
+
207
+ expect(el.value).to.equal('7');
208
+ expect(contextNote(el)).to.equal('7px');
209
+ });
210
+
211
+ it('prefers an explicit context-note over the value', async () => {
212
+ const el = await fixture<ZnInput>(
213
+ html`<zn-input type="range" label="Radius" context-note="Custom" value="4"></zn-input>`
214
+ );
215
+ await el.updateComplete;
216
+
217
+ expect(contextNote(el)).to.equal('Custom');
218
+ });
219
+ });
175
220
  });
@@ -6,11 +6,21 @@ import ZincElement from '../../internal/zinc-element';
6
6
 
7
7
  import styles from './preview-frame.scss';
8
8
 
9
+ const DEVICE_WIDTHS = {
10
+ desktop: '100%',
11
+ tablet: '768px',
12
+ mobile: '390px',
13
+ } as const;
14
+
15
+ export type PreviewFrameDevice = keyof typeof DEVICE_WIDTHS;
16
+
9
17
  /**
10
18
  * @summary Embeds a live preview iframe and drives the hp-preview postMessage
11
19
  * protocol: answers the frame's ready handshake with a config payload fetched
12
- * from data-uri, auto-saves watched forms on change, and refreshes the
13
- * preview after each save.
20
+ * from data-uri, auto-saves watched forms on change, refreshes the preview
21
+ * after each save (its own, or a shell-driven save of a `refresh-on` form),
22
+ * and accepts a theme payload via setTheme() that is retained and replayed
23
+ * after every ready handshake.
14
24
  * @documentation https://zinc.style/components/preview-frame
15
25
  * @status experimental
16
26
  * @since 1.0
@@ -18,8 +28,12 @@ import styles from './preview-frame.scss';
18
28
  * @event zn-error - Emitted when the preview reports a render error or a save fails.
19
29
  *
20
30
  * @csspart base - The component's base wrapper.
31
+ * @csspart stage - The device-width wrapper around the iframe.
21
32
  * @csspart iframe - The preview iframe.
22
33
  * @csspart error - The error overlay.
34
+ *
35
+ * @cssproperty --zn-preview-frame-dot-spacing - Spacing of the backdrop dot grid (`backdrop="dots"`). Defaults to 20px.
36
+ * @cssproperty --zn-preview-frame-dot-opacity - Opacity of the backdrop dots (`backdrop="dots"`). Defaults to 0.08.
23
37
  */
24
38
  export default class ZnPreviewFrame extends ZincElement {
25
39
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -41,6 +55,15 @@ export default class ZnPreviewFrame extends ZincElement {
41
55
  */
42
56
  @property() watch = 'form[data-auto-save]';
43
57
 
58
+ /**
59
+ * Selector for forms whose saves are left to the shell but should still
60
+ * refresh the preview. These are never intercepted: the shell submits them
61
+ * (so its own response handling — alerts, refreshes — runs as normal) and the
62
+ * preview re-fetches its config once the shell reports the save complete.
63
+ * Set empty to disable.
64
+ */
65
+ @property({attribute: 'refresh-on'}) refreshOn = 'form';
66
+
44
67
  /** Debounce in ms between a form change and its auto-save. */
45
68
  @property({type: Number}) debounce = 400;
46
69
 
@@ -48,23 +71,46 @@ export default class ZnPreviewFrame extends ZincElement {
48
71
  * Zooms the previewed page out (0–1]. The frame always fills the panel;
49
72
  * zoom shrinks the content browser-style, so 0.4 shows the page at 40%
50
73
  * size with correspondingly more of it visible. 1 = natural size.
74
+ * Ignored when `fill` is set.
51
75
  */
52
76
  @property({type: Number}) zoom = 1;
53
77
 
54
78
  /**
55
79
  * The visible height (in CSS pixels) of the preview panel. Fixed rather
56
80
  * than measured, because a measured height would feed back into the
57
- * scaled iframe's layout box and grow unbounded.
81
+ * scaled iframe's layout box and grow unbounded. With `fill` set, this
82
+ * becomes a `min-height` floor instead of the height.
58
83
  */
59
84
  @property({type: Number, attribute: 'min-height'}) minHeight = 480;
60
85
 
86
+ /**
87
+ * Fills the panel's own column height instead of using a fixed
88
+ * `min-height` pixel height — for hosts (like zn-theme-editor) whose
89
+ * layout already stretches the column to match a taller sibling.
90
+ * `zoom` is ignored when set: its oversize maths depends on a known
91
+ * pixel height, which `fill` deliberately doesn't have.
92
+ */
93
+ @property({type: Boolean}) fill = false;
94
+
95
+ /**
96
+ * Constrains and centres the preview to a device width: `desktop` (100%),
97
+ * `tablet` (768px) or `mobile` (390px). The iframe element itself is
98
+ * narrowed, so the embedded page's own media queries fire.
99
+ */
100
+ @property({reflect: true}) device: PreviewFrameDevice = 'desktop';
101
+
102
+ /** Backdrop behind the stage: `dots` (default) is the canvas dot grid; `panel` is a plain `rgb(var(--zn-panel))` fill. */
103
+ @property({reflect: true}) backdrop: 'dots' | 'panel' = 'dots';
104
+
61
105
  @query('iframe') frame: HTMLIFrameElement;
62
106
 
63
107
  @state() private error = '';
64
108
 
65
109
  private _generation = 0;
110
+ private _theme: Record<string, unknown> | undefined;
66
111
 
67
112
  private readonly _watchedForms = new Set<HTMLFormElement>();
113
+ private readonly _refreshForms = new Set<HTMLFormElement>();
68
114
  private readonly _debounceTimers = new Map<HTMLFormElement, number>();
69
115
 
70
116
  // Forms are siblings in light DOM and get replaced when other content
@@ -95,6 +141,8 @@ export default class ZnPreviewFrame extends ZincElement {
95
141
  window.removeEventListener('message', this._onMessage);
96
142
  this._watchedForms.forEach(form => this._detachForm(form));
97
143
  this._watchedForms.clear();
144
+ this._refreshForms.forEach(form => form.removeEventListener('complete', this._onShellSave));
145
+ this._refreshForms.clear();
98
146
  }
99
147
 
100
148
  private readonly _onMessage = (e: MessageEvent) => {
@@ -105,7 +153,8 @@ export default class ZnPreviewFrame extends ZincElement {
105
153
  const data = e.data as {type?: string; message?: string} | undefined;
106
154
  switch (data?.type) {
107
155
  case 'hp-preview:ready':
108
- void this._sendConfig();
156
+ // config first: the embed applies the theme on top of a rendered page
157
+ void this._sendConfig().then(() => this._postTheme());
109
158
  break;
110
159
  case 'hp-preview:rendered':
111
160
  this.error = '';
@@ -121,7 +170,29 @@ export default class ZnPreviewFrame extends ZincElement {
121
170
  return this._sendConfig();
122
171
  }
123
172
 
173
+ /**
174
+ * Pushes a theme payload into the preview. The payload is retained and
175
+ * re-posted after every ready handshake, so a frame reload doesn't drop an
176
+ * in-progress theme.
177
+ */
178
+ setTheme(theme: Record<string, unknown>) {
179
+ this._theme = theme;
180
+ this._postTheme();
181
+ }
182
+
183
+ private _postTheme() {
184
+ if (!this._theme || !this.frameOrigin) return;
185
+ this.frame?.contentWindow?.postMessage(
186
+ {type: 'hp-preview:theme', ...this._theme},
187
+ this.frameOrigin
188
+ );
189
+ }
190
+
124
191
  private async _sendConfig() {
192
+ // A theme-editor-only setup has no config endpoint; fetch('') would return
193
+ // the host page's HTML and fail JSON parsing into the error overlay.
194
+ if (!this.dataUri || !this.frameOrigin) return;
195
+
125
196
  const generation = ++this._generation;
126
197
  try {
127
198
  const response = await fetch(this.dataUri, {
@@ -168,8 +239,33 @@ export default class ZnPreviewFrame extends ZincElement {
168
239
  form.addEventListener('zn-input', this._onChange);
169
240
  form.addEventListener('change', this._onChange);
170
241
  });
242
+
243
+ // Shell-saved forms: the shell fires 'complete' on the form it submitted.
244
+ const refreshMatched = new Set<HTMLFormElement>();
245
+ if (this.refreshOn) {
246
+ root.querySelectorAll(this.refreshOn).forEach(node => {
247
+ if (node instanceof HTMLFormElement && !matched.has(node)) refreshMatched.add(node);
248
+ });
249
+ }
250
+
251
+ this._refreshForms.forEach(form => {
252
+ if (!refreshMatched.has(form)) {
253
+ form.removeEventListener('complete', this._onShellSave);
254
+ this._refreshForms.delete(form);
255
+ }
256
+ });
257
+
258
+ refreshMatched.forEach(form => {
259
+ if (this._refreshForms.has(form)) return;
260
+ this._refreshForms.add(form);
261
+ form.addEventListener('complete', this._onShellSave);
262
+ });
171
263
  }
172
264
 
265
+ private readonly _onShellSave = () => {
266
+ void this._sendConfig();
267
+ };
268
+
173
269
  private _detachForm(form: HTMLFormElement) {
174
270
  form.removeEventListener('submit', this._onSubmit, {capture: true});
175
271
  form.removeEventListener('zn-change', this._onChange);
@@ -202,8 +298,15 @@ export default class ZnPreviewFrame extends ZincElement {
202
298
  const response = await fetch(form.getAttribute('action') || '', {
203
299
  method: 'POST',
204
300
  credentials: 'same-origin',
301
+ // 'download' streams the app's own status and headers through verbatim;
302
+ // without it the console proxy pagelet-wraps the response and a failed
303
+ // save is indistinguishable from a successful one.
304
+ headers: {'x-kx-fetch-style': 'download'},
205
305
  body: new FormData(form),
206
306
  });
307
+ // Apps report a failed save as 204 plus an alert header, which is `ok`.
308
+ const alert = response.headers.get('x-kubex-alert-danger');
309
+ if (alert) throw new Error(alert);
207
310
  if (!response.ok) {
208
311
  throw new Error(await response.text() || response.statusText);
209
312
  }
@@ -224,21 +327,28 @@ export default class ZnPreviewFrame extends ZincElement {
224
327
  // transformed back down, so the frame fills the panel while the content
225
328
  // renders smaller and more of the page is visible. Percentage width means
226
329
  // nothing is measured — no layout feedback loop.
227
- const iframeStyles = {
228
- width: `${100 / zoom}%`,
229
- height: `${this.minHeight / zoom}px`,
230
- transform: `scale(${zoom})`,
231
- transformOrigin: '0 0',
232
- };
233
- const containerStyles = {height: `${this.minHeight}px`};
330
+ const iframeStyles = this.fill
331
+ ? {width: '100%', height: '100%'}
332
+ : {
333
+ width: `${100 / zoom}%`,
334
+ height: `${this.minHeight / zoom}px`,
335
+ transform: `scale(${zoom})`,
336
+ transformOrigin: '0 0',
337
+ };
338
+ const containerStyles = this.fill
339
+ ? {height: '100%', minHeight: `${this.minHeight}px`}
340
+ : {height: `${this.minHeight}px`};
234
341
 
235
342
  return html`
236
343
  <div part="base" class="preview" style="${styleMap(containerStyles)}">
237
- <iframe part="iframe"
238
- src="${this.src}"
239
- title="Payment form preview"
240
- allow="local-network-access"
241
- style="${styleMap(iframeStyles)}"></iframe>
344
+ <div part="stage" class="preview__stage"
345
+ style="${styleMap({width: DEVICE_WIDTHS[this.device] ?? DEVICE_WIDTHS.desktop})}">
346
+ <iframe part="iframe"
347
+ src="${this.src}"
348
+ title="Payment form preview"
349
+ allow="local-network-access"
350
+ style="${styleMap(iframeStyles)}"></iframe>
351
+ </div>
242
352
  ${this.error ? html`
243
353
  <div part="error" class="preview__error">${this.error}</div>` : ''}
244
354
  </div>`;
@@ -2,16 +2,43 @@
2
2
  display: block;
3
3
  }
4
4
 
5
+ // So `fill` works without the consumer also having to grow the host.
6
+ :host([fill]) {
7
+ flex: 1 1 auto;
8
+ min-height: 0;
9
+ }
10
+
5
11
  .preview {
6
12
  position: relative;
7
13
  width: 100%;
8
14
  overflow: hidden;
15
+
16
+ // Matches page-builder's canvas.
17
+ background:
18
+ radial-gradient(circle, rgba(var(--zn-text), var(--zn-preview-frame-dot-opacity, 0.08)) 1px, transparent 1px)
19
+ 0 0 / var(--zn-preview-frame-dot-spacing, 20px) var(--zn-preview-frame-dot-spacing, 20px),
20
+ rgb(var(--zn-body));
21
+ }
22
+
23
+ // Higher specificity than .preview alone, so it wins without !important.
24
+ :host([backdrop="panel"]) .preview {
25
+ background: rgb(var(--zn-panel));
26
+ }
27
+
28
+ .preview__stage {
29
+ height: 100%;
30
+ max-width: 100%;
31
+ margin: 0 auto;
32
+ overflow: hidden;
9
33
  }
10
34
 
11
35
  iframe {
12
36
  display: block;
13
37
  width: 100%;
14
38
  border: 0;
39
+ // Iframes are transparent by default, so without this the dot grid would show
40
+ // through any embed that doesn't set its own background.
41
+ background: rgb(var(--zn-body));
15
42
  }
16
43
 
17
44
  .preview__error {
@@ -160,6 +160,41 @@ describe('<zn-preview-frame>', () => {
160
160
  expect(fetchCalls[0].uri).to.contain('/save');
161
161
  });
162
162
 
163
+ it('treats a 204 carrying an alert-danger header as a failed save', async () => {
164
+ window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
165
+ fetchCalls.push({uri: String(uri), init});
166
+ if (String(uri).includes('/save')) {
167
+ return Promise.resolve(new Response(null, {
168
+ status: 204,
169
+ headers: {'x-kubex-alert-danger': 'Unable to update page'}
170
+ }));
171
+ }
172
+ return Promise.resolve(new Response(JSON.stringify(PAYLOAD), {
173
+ status: 200,
174
+ headers: {'Content-Type': 'application/json'}
175
+ }));
176
+ };
177
+
178
+ const wrapper = await fixture(html`
179
+ <div>
180
+ <form action="/save" method="post" data-auto-save><input name="a" value="1"></form>
181
+ <zn-preview-frame
182
+ src="about:blank"
183
+ frame-origin="https://site.example"
184
+ data-uri="/payload"></zn-preview-frame>
185
+ </div>`);
186
+
187
+ const el = wrapper.querySelector('zn-preview-frame')!;
188
+ wrapper.querySelector('form')!.requestSubmit();
189
+
190
+ await waitUntil(() => el.shadowRoot!.querySelector('[part="error"]'));
191
+ expect(el.shadowRoot!.querySelector('[part="error"]')!.textContent)
192
+ .to.contain('Unable to update page');
193
+ // the preview must not be refreshed as though the save succeeded
194
+ expect(fetchCalls.filter(c => c.uri === '/payload').length).to.equal(0);
195
+ expect(fetchCalls[0].init?.headers).to.deep.equal({'x-kx-fetch-style': 'download'});
196
+ });
197
+
163
198
  it('does not save a form removed from the DOM mid-debounce', async () => {
164
199
  const wrapper = await fixture(html`
165
200
  <div>
@@ -179,6 +214,52 @@ describe('<zn-preview-frame>', () => {
179
214
  expect(fetchCalls.length).to.equal(0);
180
215
  });
181
216
 
217
+ it('refreshes the preview when the shell reports a form save complete', async () => {
218
+ const wrapper = await fixture(html`
219
+ <div>
220
+ <form action="/save" method="post"><input name="a" value="1"></form>
221
+ <zn-preview-frame
222
+ src="about:blank"
223
+ frame-origin="https://site.example"
224
+ data-uri="/payload"></zn-preview-frame>
225
+ </div>`);
226
+
227
+ const el = wrapper.querySelector('zn-preview-frame')!;
228
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
229
+ const posted: Record<string, unknown>[] = [];
230
+ (iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage = (msg: Record<string, unknown>) => posted.push(msg);
231
+
232
+ // the shell submitted the form itself and fires 'complete' on it
233
+ wrapper.querySelector('form')!.dispatchEvent(
234
+ new CustomEvent('complete', {bubbles: true, detail: {}}));
235
+
236
+ await waitUntil(() => fetchCalls.length === 1);
237
+ expect(fetchCalls[0].uri).to.equal('/payload');
238
+ // the save itself stays with the shell — the component must not post it
239
+ expect(fetchCalls.some(c => c.uri.includes('/save'))).to.equal(false);
240
+ await waitUntil(() => posted.length === 1);
241
+ expect(posted[0]['type']).to.equal('hp-preview:config');
242
+ });
243
+
244
+ it('does not intercept the submit of a refresh-on form', async () => {
245
+ const wrapper = await fixture(html`
246
+ <div>
247
+ <form action="/save" method="post"><input name="a" value="1"></form>
248
+ <zn-preview-frame
249
+ src="about:blank"
250
+ frame-origin="https://site.example"
251
+ data-uri="/payload"></zn-preview-frame>
252
+ </div>`);
253
+
254
+ let bubbled = false;
255
+ wrapper.addEventListener('submit', e => { bubbled = true; e.preventDefault(); });
256
+
257
+ wrapper.querySelector('form')!.requestSubmit();
258
+
259
+ expect(bubbled).to.equal(true);
260
+ expect(fetchCalls.length).to.equal(0);
261
+ });
262
+
182
263
  it('leaves forms without data-auto-save alone', async () => {
183
264
  const wrapper = await fixture(html`
184
265
  <div>
@@ -256,6 +337,45 @@ describe('<zn-preview-frame>', () => {
256
337
  expect(container.style.height).to.equal('600px');
257
338
  });
258
339
 
340
+ describe('fill', () => {
341
+ it('fills the container instead of a fixed pixel height, with no zoom transform', async () => {
342
+ const el = await fixture(html`
343
+ <zn-preview-frame
344
+ src="about:blank"
345
+ frame-origin="https://site.example"
346
+ data-uri="/payload"
347
+ min-height="480"
348
+ fill></zn-preview-frame>`);
349
+
350
+ const container = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
351
+ expect(container.style.height).to.equal('100%');
352
+ expect(container.style.minHeight).to.equal('480px');
353
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
354
+ expect(iframe.style.width).to.equal('100%');
355
+ expect(iframe.style.height).to.equal('100%');
356
+ expect(iframe.style.transform).to.equal('');
357
+ });
358
+
359
+ it('ignores zoom when fill is set', async () => {
360
+ const el = await fixture(html`
361
+ <zn-preview-frame
362
+ src="about:blank"
363
+ frame-origin="https://site.example"
364
+ data-uri="/payload"
365
+ zoom="0.4"
366
+ min-height="600"
367
+ fill></zn-preview-frame>`);
368
+
369
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
370
+ expect(iframe.style.transform).to.equal('');
371
+ expect(iframe.style.width).to.equal('100%');
372
+ expect(iframe.style.height).to.equal('100%');
373
+ const container = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
374
+ expect(container.style.height).to.equal('100%');
375
+ expect(container.style.minHeight).to.equal('600px');
376
+ });
377
+ });
378
+
259
379
  it('renders at natural size by default', async () => {
260
380
  const el = await fixture(FIXTURE);
261
381
  const iframe = el.shadowRoot!.querySelector('iframe')!;
@@ -265,4 +385,137 @@ describe('<zn-preview-frame>', () => {
265
385
  const container = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
266
386
  expect(container.style.height).to.equal('480px');
267
387
  });
388
+
389
+ it('defaults to a full-width desktop stage', async () => {
390
+ const el = await fixture(FIXTURE);
391
+ const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
392
+ expect(stage.style.width).to.equal('100%');
393
+ });
394
+
395
+ it('constrains the stage to the tablet width', async () => {
396
+ const el = await fixture(html`
397
+ <zn-preview-frame
398
+ src="about:blank"
399
+ frame-origin="https://site.example"
400
+ data-uri="/payload"
401
+ device="tablet"></zn-preview-frame>`);
402
+
403
+ const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
404
+ expect(stage.style.width).to.equal('768px');
405
+ // the iframe still sizes off the stage, so zoom maths is unaffected
406
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
407
+ expect(iframe.style.width).to.equal('100%');
408
+ });
409
+
410
+ it('constrains the stage to the mobile width', async () => {
411
+ const el = await fixture(html`
412
+ <zn-preview-frame
413
+ src="about:blank"
414
+ frame-origin="https://site.example"
415
+ data-uri="/payload"
416
+ device="mobile"></zn-preview-frame>`);
417
+
418
+ const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
419
+ expect(stage.style.width).to.equal('390px');
420
+ });
421
+
422
+ it('setTheme() posts an hp-preview:theme message', async () => {
423
+ const el = await fixture(FIXTURE);
424
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
425
+ const posted: {msg: Record<string, unknown>; origin: string}[] = [];
426
+ (iframe.contentWindow as {postMessage: (msg: Record<string, unknown>, origin: string) => void}).postMessage =
427
+ (msg: Record<string, unknown>, origin: string) => posted.push({msg, origin});
428
+
429
+ (el as HTMLElement & {setTheme: (t: Record<string, unknown>) => void})
430
+ .setTheme({mode: 'dark', values: {background: '#101014'}});
431
+
432
+ await waitUntil(() => posted.length === 1);
433
+ expect(posted[0].origin).to.equal('https://site.example');
434
+ expect(posted[0].msg['type']).to.equal('hp-preview:theme');
435
+ expect(posted[0].msg['mode']).to.equal('dark');
436
+ expect(posted[0].msg['values']).to.deep.equal({background: '#101014'});
437
+ });
438
+
439
+ it('replays the stored theme after a ready handshake', async () => {
440
+ const el = await fixture(FIXTURE);
441
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
442
+ const posted: Record<string, unknown>[] = [];
443
+ (iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage =
444
+ (msg: Record<string, unknown>) => posted.push(msg);
445
+
446
+ (el as HTMLElement & {setTheme: (t: Record<string, unknown>) => void})
447
+ .setTheme({mode: 'light', values: {background: '#ffffff'}});
448
+ await waitUntil(() => posted.length === 1);
449
+
450
+ // a frame reload re-announces readiness; the theme must survive it
451
+ ready(el);
452
+
453
+ await waitUntil(() => posted.length === 3);
454
+ expect(posted[1]['type']).to.equal('hp-preview:config');
455
+ expect(posted[2]['type']).to.equal('hp-preview:theme');
456
+ expect(posted[2]['values']).to.deep.equal({background: '#ffffff'});
457
+ });
458
+
459
+ it('setTheme() does not throw and posts nothing when frame-origin is unset', async () => {
460
+ const el = await fixture(html`
461
+ <zn-preview-frame src="about:blank" data-uri="/payload"></zn-preview-frame>`);
462
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
463
+ // Forward to the real postMessage (not mocked) so an unguarded '' target
464
+ // origin actually throws its SyntaxError, same as it would in production.
465
+ const realPostMessage = iframe.contentWindow!.postMessage.bind(iframe.contentWindow);
466
+ let calls = 0;
467
+ (iframe.contentWindow as {postMessage: (...args: unknown[]) => void}).postMessage =
468
+ (...args: unknown[]) => { calls++; return (realPostMessage as (...a: unknown[]) => void)(...args); };
469
+
470
+ expect(() => (el as HTMLElement & {setTheme: (t: Record<string, unknown>) => void})
471
+ .setTheme({mode: 'light', values: {}})).to.not.throw();
472
+
473
+ await new Promise(resolve => setTimeout(resolve, 50));
474
+ expect(calls).to.equal(0);
475
+ });
476
+
477
+ // The docs site's theme stylesheet (--zn-text/--zn-body/--zn-panel) isn't
478
+ // loaded in this test shell, so an undefined var() makes the whole
479
+ // declaration invalid - set them inline to get a real computed background.
480
+ it('renders a dot grid backdrop by default', async () => {
481
+ const el = await fixture(FIXTURE) as HTMLElement;
482
+ el.style.setProperty('--zn-text', '0, 0, 0');
483
+ el.style.setProperty('--zn-body', '255, 255, 255');
484
+
485
+ expect(el.getAttribute('backdrop')).to.equal('dots');
486
+ const preview = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
487
+ expect(getComputedStyle(preview).backgroundImage).to.not.equal('none');
488
+ });
489
+
490
+ it('renders a plain panel backdrop when backdrop="panel"', async () => {
491
+ const el = await fixture(html`
492
+ <zn-preview-frame
493
+ src="about:blank"
494
+ frame-origin="https://site.example"
495
+ data-uri="/payload"
496
+ backdrop="panel"></zn-preview-frame>`) as HTMLElement;
497
+ el.style.setProperty('--zn-panel', '240, 240, 240');
498
+
499
+ expect(el.getAttribute('backdrop')).to.equal('panel');
500
+ const preview = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
501
+ expect(getComputedStyle(preview).backgroundImage).to.equal('none');
502
+ });
503
+
504
+ it('skips the config fetch when data-uri is empty', async () => {
505
+ const el = await fixture(html`
506
+ <zn-preview-frame src="about:blank" frame-origin="https://site.example"></zn-preview-frame>`);
507
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
508
+ const posted: Record<string, unknown>[] = [];
509
+ (iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage =
510
+ (msg: Record<string, unknown>) => posted.push(msg);
511
+
512
+ (el as HTMLElement & {setTheme: (t: Record<string, unknown>) => void}).setTheme({mode: 'light', values: {}});
513
+ ready(el);
514
+
515
+ await new Promise(resolve => setTimeout(resolve, 100));
516
+ expect(fetchCalls.length).to.equal(0);
517
+ expect(el.shadowRoot!.querySelector('[part="error"]')).to.not.exist;
518
+ // theme still replays even with no config to send
519
+ expect(posted.filter(m => m['type'] === 'hp-preview:theme').length).to.equal(2);
520
+ });
268
521
  });
@@ -0,0 +1,12 @@
1
+ import ZnThemeEditor from './theme-editor.component';
2
+
3
+ export * from './theme-editor.component';
4
+ export default ZnThemeEditor;
5
+
6
+ ZnThemeEditor.define('zn-theme-editor');
7
+
8
+ declare global {
9
+ interface HTMLElementTagNameMap {
10
+ 'zn-theme-editor': ZnThemeEditor;
11
+ }
12
+ }