@kubex/zinc 1.1.68 → 1.1.69

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 (30) hide show
  1. package/dist/custom-elements.json +1417 -275
  2. package/dist/vscode.html-custom-data.json +149 -27
  3. package/dist/web-types.json +351 -64
  4. package/dist/zn.d.ts +325 -14
  5. package/dist/zn.min.js +495 -374
  6. package/docs/pages/components/collapsible.md +6 -2
  7. package/docs/pages/components/preview-frame-demo.njk +33 -4
  8. package/docs/pages/components/preview-frame.md +20 -0
  9. package/docs/pages/components/theme-editor.md +269 -0
  10. package/docs/superpowers/plans/2026-08-03-theme-editor.md +1536 -0
  11. package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +327 -0
  12. package/package.json +1 -1
  13. package/src/components/collapsible/collapsible.component.ts +21 -23
  14. package/src/components/collapsible/collapsible.scss +10 -0
  15. package/src/components/dropdown/dropdown.component.ts +9 -0
  16. package/src/components/editor/editor.component.ts +20 -21
  17. package/src/components/preview-frame/preview-frame.component.ts +126 -16
  18. package/src/components/preview-frame/preview-frame.scss +27 -0
  19. package/src/components/preview-frame/preview-frame.test.ts +253 -0
  20. package/src/components/theme-editor/index.ts +12 -0
  21. package/src/components/theme-editor/theme-editor.component.ts +761 -0
  22. package/src/components/theme-editor/theme-editor.scss +309 -0
  23. package/src/components/theme-editor/theme-editor.test.ts +1584 -0
  24. package/src/events/events.ts +2 -0
  25. package/src/events/zn-theme-change.ts +13 -0
  26. package/src/events/zn-theme-submit.ts +9 -0
  27. package/src/types/web-test-runner-commands.d.ts +6 -0
  28. package/src/zinc.ts +1 -0
  29. package/tsconfig.json +7 -1
  30. package/web-test-runner.config.js +3 -1
@@ -0,0 +1,1584 @@
1
+ import '../../../dist/zn.min.js';
2
+ import {expect, fixture, html, waitUntil} from '@open-wc/testing';
3
+ import {setViewport} from '@web/test-runner-commands';
4
+
5
+ type ThemeCall = Record<string, unknown>;
6
+
7
+ interface Framelike extends HTMLElement {
8
+ setTheme: (theme: ThemeCall) => void;
9
+ }
10
+
11
+ /**
12
+ * Records what the editor pushes by replacing the internal frame's setTheme.
13
+ * Returns the recorded calls; the first is the initial push from firstUpdated.
14
+ */
15
+ function spyOnFrame(el: Element): ThemeCall[] {
16
+ const frame = el.shadowRoot!.querySelector('zn-preview-frame') as Framelike;
17
+ const calls: ThemeCall[] = [];
18
+ frame.setTheme = (theme: ThemeCall) => calls.push(theme);
19
+ return calls;
20
+ }
21
+
22
+ describe('<zn-theme-editor>', () => {
23
+ const FIXTURE = html`
24
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example" debounce="10">
25
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
26
+ </zn-theme-editor>`;
27
+
28
+ it('renders and is accessible', async () => {
29
+ const el = await fixture(FIXTURE);
30
+ await expect(el).to.be.accessible();
31
+ });
32
+
33
+ it('renders a preview frame and forwards its configuration', async () => {
34
+ const el = await fixture(html`
35
+ <zn-theme-editor
36
+ src="about:blank"
37
+ frame-origin="https://site.example"
38
+ data-uri="/theme/config"
39
+ min-height="600"></zn-theme-editor>`);
40
+
41
+ const frame = el.shadowRoot!.querySelector('zn-preview-frame')!;
42
+ expect(frame).to.exist;
43
+ expect((frame as HTMLElement & {src: string}).src).to.equal('about:blank');
44
+ expect((frame as HTMLElement & {frameOrigin: string}).frameOrigin).to.equal('https://site.example');
45
+ expect((frame as HTMLElement & {dataUri: string}).dataUri).to.equal('/theme/config');
46
+ expect((frame as HTMLElement & {minHeight: number}).minHeight).to.equal(600);
47
+ expect((frame as HTMLElement & {fill: boolean}).fill).to.be.true;
48
+ });
49
+
50
+ it('leaves no dead space beneath the preview when the controls column is taller than min-height', async () => {
51
+ const el = await fixture(html`
52
+ <zn-theme-editor
53
+ src="about:blank" frame-origin="https://site.example" min-height="200"
54
+ style="display: block; width: 700px;">
55
+ ${Array.from({length: 15}, (_, i) => html`
56
+ <zn-input name="field${i}" label="Field ${i}" value="x"></zn-input>`)}
57
+ </zn-theme-editor>`);
58
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
59
+ await new Promise(resolve => setTimeout(resolve, 50)); // let layout settle
60
+
61
+ const controls = el.shadowRoot!.querySelector('[part="controls"]')!;
62
+ const previewColumn = el.shadowRoot!.querySelector('[part="preview"]')!;
63
+ const frame = el.shadowRoot!.querySelector('zn-preview-frame')!;
64
+ const preview = frame.shadowRoot!.querySelector('.preview')!;
65
+
66
+ const controlsHeight = controls.getBoundingClientRect().height;
67
+ const previewColumnHeight = previewColumn.getBoundingClientRect().height;
68
+ const previewHeight = preview.getBoundingClientRect().height;
69
+
70
+ expect(controlsHeight).to.be.greaterThan(200); // taller than the min-height floor
71
+ // The sidebar is now full height and independent of the preview column's
72
+ // own height, so fill's target is the preview column, not the sidebar.
73
+ expect(previewHeight).to.be.closeTo(previewColumnHeight, 2);
74
+ });
75
+
76
+ it('pushes the authored control defaults on first render', async () => {
77
+ // firstUpdated fires inside fixture(), before a per-instance spy could be
78
+ // installed — so patch the prototype for the duration of this test
79
+ const proto = customElements.get('zn-preview-frame')!.prototype as unknown as Framelike;
80
+ const original = proto.setTheme;
81
+ const calls: ThemeCall[] = [];
82
+ proto.setTheme = (theme: ThemeCall) => calls.push(theme);
83
+
84
+ try {
85
+ const el = await fixture(html`
86
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
87
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
88
+ </zn-theme-editor>`);
89
+
90
+ expect(calls.length).to.equal(1);
91
+ expect(calls[0]['mode']).to.equal('light');
92
+ expect(calls[0]['values']).to.deep.equal({radius: '8'});
93
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>; dark: Record<string, unknown>}}).values)
94
+ .to.deep.equal({light: {radius: '8'}, dark: {radius: '8'}});
95
+ } finally {
96
+ proto.setTheme = original;
97
+ }
98
+ });
99
+
100
+ it('harvests and pushes updated values when a control changes', async () => {
101
+ const el = await fixture(FIXTURE);
102
+ const calls = spyOnFrame(el);
103
+
104
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
105
+ input.value = '16';
106
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
107
+
108
+ await waitUntil(() => calls.length === 1);
109
+ expect(calls[0]['mode']).to.equal('light');
110
+ expect(calls[0]['values']).to.deep.equal({radius: '16'});
111
+ });
112
+
113
+ it('emits zn-theme-change with values, mode and device', async () => {
114
+ const el = await fixture(FIXTURE);
115
+ spyOnFrame(el);
116
+
117
+ interface Detail {values: {light: Record<string, unknown>; dark: Record<string, unknown>}; mode: string; device: string}
118
+ let detail: Detail | null = null;
119
+ el.addEventListener('zn-theme-change', (e: Event) => {
120
+ detail = (e as CustomEvent<Detail>).detail;
121
+ });
122
+
123
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
124
+ input.value = '24';
125
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
126
+
127
+ await waitUntil(() => detail !== null);
128
+ // only light changed - the untouched dark set (seeded from the original '8') stays isolated
129
+ expect(detail!.values).to.deep.equal({light: {radius: '24'}, dark: {radius: '8'}});
130
+ expect(detail!.mode).to.equal('light');
131
+ expect(detail!.device).to.equal('desktop');
132
+ });
133
+
134
+ it('reads booleans from checkboxes and skips disabled and unnamed controls', async () => {
135
+ const el = await fixture(html`
136
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
137
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
138
+ <zn-checkbox name="rounded" checked></zn-checkbox>
139
+ <zn-input name="ignored" label="Ignored" value="x" disabled></zn-input>
140
+ <zn-input label="No name" value="y"></zn-input>
141
+ </zn-theme-editor>`);
142
+
143
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
144
+ .to.deep.equal({radius: '8', rounded: true});
145
+ });
146
+
147
+ it('pushes exactly once for an empty slot, then pushes again when a control is added', async () => {
148
+ const el = await fixture(html`
149
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example"></zn-theme-editor>`);
150
+ const calls = spyOnFrame(el);
151
+
152
+ const input = document.createElement('zn-input');
153
+ input.setAttribute('name', 'radius');
154
+ input.setAttribute('value', '8');
155
+ el.append(input);
156
+
157
+ await waitUntil(() => calls.length === 1);
158
+ expect(calls[0]['values']).to.deep.equal({radius: '8'});
159
+ });
160
+
161
+ it('renders the footer slot only when it is used', async () => {
162
+ const bare = await fixture(html`
163
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example"></zn-theme-editor>`);
164
+ expect(bare.shadowRoot!.querySelector('[part="footer"]')).to.not.exist;
165
+
166
+ const withFooter = await fixture(html`
167
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
168
+ <zn-button slot="footer">Save</zn-button>
169
+ </zn-theme-editor>`);
170
+ expect(withFooter.shadowRoot!.querySelector('[part="footer"]')).to.exist;
171
+ });
172
+
173
+ it('device buttons set the frame device without re-pushing the theme', async () => {
174
+ const el = await fixture(FIXTURE);
175
+ const calls = spyOnFrame(el);
176
+
177
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="mobile"]')!.click();
178
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
179
+
180
+ expect((el as HTMLElement & {device: string}).device).to.equal('mobile');
181
+ expect(el.getAttribute('device')).to.equal('mobile');
182
+ const frame = el.shadowRoot!.querySelector('zn-preview-frame')!;
183
+ expect((frame as HTMLElement & {device: string}).device).to.equal('mobile');
184
+ // resizing the frame is not a theme change
185
+ expect(calls.length).to.equal(0);
186
+ });
187
+
188
+ it('announces a device change on zn-theme-change', async () => {
189
+ const el = await fixture(FIXTURE);
190
+ let detail: {device: string} | null = null;
191
+ el.addEventListener('zn-theme-change', (e: Event) => {
192
+ detail = (e as CustomEvent<{device: string}>).detail;
193
+ });
194
+
195
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="tablet"]')!.click();
196
+
197
+ await waitUntil(() => detail !== null);
198
+ expect(detail!.device).to.equal('tablet');
199
+ });
200
+
201
+ it('marks the active device button as pressed', async () => {
202
+ const el = await fixture(FIXTURE);
203
+ const desktop = el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="desktop"]')!;
204
+ const mobile = el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="mobile"]')!;
205
+ expect(desktop.getAttribute('aria-pressed')).to.equal('true');
206
+ expect(mobile.getAttribute('aria-pressed')).to.equal('false');
207
+
208
+ mobile.click();
209
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
210
+
211
+ expect(desktop.getAttribute('aria-pressed')).to.equal('false');
212
+ expect(mobile.getAttribute('aria-pressed')).to.equal('true');
213
+ });
214
+
215
+ it('toggling mode reflects the attribute and re-pushes with the new mode', async () => {
216
+ const el = await fixture(FIXTURE);
217
+ const calls = spyOnFrame(el);
218
+
219
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
220
+
221
+ await waitUntil(() => calls.length === 1);
222
+ expect((el as HTMLElement & {mode: string}).mode).to.equal('dark');
223
+ expect(el.getAttribute('mode')).to.equal('dark');
224
+ expect(calls[0]['mode']).to.equal('dark');
225
+ expect(calls[0]['values']).to.deep.equal({radius: '8'});
226
+ });
227
+
228
+ it('dark-value seeds the dark set and write-back updates the control on toggle', async () => {
229
+ const el = await fixture(html`
230
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
231
+ <zn-input name="background" label="Background" value="#ffffff" dark-value="#000000"></zn-input>
232
+ </zn-theme-editor>`);
233
+ const calls = spyOnFrame(el);
234
+
235
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
236
+ await waitUntil(() => calls.length === 1);
237
+
238
+ expect(calls[0]['values']).to.deep.equal({background: '#000000'});
239
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
240
+ expect(input.value).to.equal('#000000');
241
+ });
242
+
243
+ it('displays dark values on first render when authored with mode="dark"', async () => {
244
+ const el = await fixture(html`
245
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example" mode="dark">
246
+ <zn-input name="background" label="Background" value="#ffffff" dark-value="#000000"></zn-input>
247
+ </zn-theme-editor>`);
248
+
249
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
250
+ expect(input.value).to.equal('#000000');
251
+ expect((el as HTMLElement & {activeValues: Record<string, unknown>}).activeValues)
252
+ .to.deep.equal({background: '#000000'});
253
+ });
254
+
255
+ it('falls back to the light value in dark mode when dark-value is absent', async () => {
256
+ const el = await fixture(FIXTURE);
257
+ const calls = spyOnFrame(el);
258
+
259
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
260
+ await waitUntil(() => calls.length === 1);
261
+
262
+ expect(calls[0]['values']).to.deep.equal({radius: '8'});
263
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
264
+ expect(input.value).to.equal('8');
265
+ });
266
+
267
+ it('keeps light and dark edits isolated', async () => {
268
+ const el = await fixture(FIXTURE);
269
+ const calls = spyOnFrame(el);
270
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
271
+
272
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click(); // -> dark, radius '8'
273
+ await waitUntil(() => calls.length === 1);
274
+
275
+ input.value = '99';
276
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
277
+ await waitUntil(() => calls.length === 2);
278
+ expect(calls[1]['values']).to.deep.equal({radius: '99'});
279
+
280
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click(); // -> light, untouched
281
+ await waitUntil(() => calls.length === 3);
282
+ expect(calls[2]['values']).to.deep.equal({radius: '8'});
283
+ expect(input.value).to.equal('8');
284
+ });
285
+
286
+ it('seeds a boolean control dark state from dark-value="1"', async () => {
287
+ const el = await fixture(html`
288
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
289
+ <zn-checkbox name="rounded" dark-value="1"></zn-checkbox>
290
+ </zn-theme-editor>`);
291
+ const calls = spyOnFrame(el);
292
+
293
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
294
+ await waitUntil(() => calls.length === 1);
295
+
296
+ expect(calls[0]['values']).to.deep.equal({rounded: true});
297
+ const checkbox = el.querySelector('zn-checkbox')! as HTMLElement & {checked: boolean};
298
+ expect(checkbox.checked).to.be.true;
299
+ });
300
+
301
+ it('falls back to the checked light state in dark mode when a boolean control has no dark-value', async () => {
302
+ const el = await fixture(html`
303
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
304
+ <zn-checkbox name="rounded" checked></zn-checkbox>
305
+ </zn-theme-editor>`);
306
+ const calls = spyOnFrame(el);
307
+
308
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
309
+ await waitUntil(() => calls.length === 1);
310
+
311
+ expect(calls[0]['values']).to.deep.equal({rounded: true});
312
+ const checkbox = el.querySelector('zn-checkbox')! as HTMLElement & {checked: boolean};
313
+ expect(checkbox.checked).to.be.true;
314
+ });
315
+
316
+ it('reads a native input[type=checkbox] as boolean', async () => {
317
+ const el = await fixture(html`
318
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
319
+ <input type="checkbox" name="rounded" checked>
320
+ </zn-theme-editor>`);
321
+
322
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
323
+ .to.deep.equal({rounded: true});
324
+ });
325
+
326
+ it('flushes a pending edit into the mode it was made in when toggled before the debounce fires', async () => {
327
+ const el = await fixture(html`
328
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example" debounce="50">
329
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
330
+ </zn-theme-editor>`);
331
+ const calls = spyOnFrame(el);
332
+
333
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
334
+ input.value = '99';
335
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
336
+ // toggle before the 50ms push debounce fires - no await in between
337
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
338
+
339
+ await waitUntil(() => calls.length >= 1);
340
+ // give the (now-cancelled) original debounce timer a chance to prove it's gone
341
+ await new Promise(resolve => setTimeout(resolve, 80));
342
+
343
+ expect(calls.length).to.equal(1);
344
+ expect(calls[0]['mode']).to.equal('dark');
345
+ // dark was never touched - the edit belongs to light, the mode it was made in
346
+ expect(calls[0]['values']).to.deep.equal({radius: '8'});
347
+
348
+ const values = (el as HTMLElement & {values: {light: Record<string, unknown>; dark: Record<string, unknown>}}).values;
349
+ expect(values.light).to.deep.equal({radius: '99'});
350
+ expect(values.dark).to.deep.equal({radius: '8'});
351
+ });
352
+
353
+ describe('grouped controls (zn-collapsible sections)', () => {
354
+ it('pushes exactly once at mount for a control wrapped in a zn-collapsible section', async () => {
355
+ // Prototype-spy technique (see the bare-control test above) - it's the
356
+ // only one that observes the mount push, since firstUpdated fires
357
+ // inside fixture(), before a per-instance spy could be installed.
358
+ const proto = customElements.get('zn-preview-frame')!.prototype as unknown as Framelike;
359
+ const original = proto.setTheme;
360
+ const calls: ThemeCall[] = [];
361
+ proto.setTheme = (theme: ThemeCall) => calls.push(theme);
362
+
363
+ try {
364
+ await fixture(html`
365
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
366
+ <zn-collapsible caption="Section" default="open">
367
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
368
+ </zn-collapsible>
369
+ </zn-theme-editor>`);
370
+
371
+ expect(calls.length).to.equal(1);
372
+ } finally {
373
+ proto.setTheme = original;
374
+ }
375
+ });
376
+
377
+ it('harvests a control wrapped in a zn-collapsible section', async () => {
378
+ const el = await fixture(html`
379
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
380
+ <zn-collapsible caption="Section" default="open">
381
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
382
+ </zn-collapsible>
383
+ </zn-theme-editor>`);
384
+
385
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
386
+ .to.deep.equal({radius: '8'});
387
+ });
388
+
389
+ it('harvests controls across two sections into one value set', async () => {
390
+ const el = await fixture(html`
391
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
392
+ <zn-collapsible caption="A" default="open">
393
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
394
+ </zn-collapsible>
395
+ <zn-collapsible caption="B" default="open">
396
+ <zn-checkbox name="rounded" checked></zn-checkbox>
397
+ </zn-collapsible>
398
+ </zn-theme-editor>`);
399
+
400
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
401
+ .to.deep.equal({radius: '8', rounded: true});
402
+ });
403
+
404
+ it('seeds and pushes a control added inside an existing section after mount', async () => {
405
+ const el = await fixture(html`
406
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
407
+ <zn-collapsible caption="Section" default="open">
408
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
409
+ </zn-collapsible>
410
+ </zn-theme-editor>`);
411
+ const calls = spyOnFrame(el);
412
+
413
+ const section = el.querySelector('zn-collapsible')!;
414
+ const input = document.createElement('zn-input');
415
+ input.setAttribute('name', 'spacing');
416
+ input.setAttribute('value', '4');
417
+ section.append(input);
418
+
419
+ await waitUntil(() => calls.length === 1);
420
+ expect(calls[0]['values']).to.deep.equal({radius: '8', spacing: '4'});
421
+
422
+ const values = (el as HTMLElement & {values: {light: Record<string, unknown>; dark: Record<string, unknown>}}).values;
423
+ expect(values.light).to.deep.equal({radius: '8', spacing: '4'});
424
+ expect(values.dark).to.deep.equal({radius: '8', spacing: '4'});
425
+ });
426
+
427
+ it('removing a section pushes, retaining the removed control\'s value rather than purging it', async () => {
428
+ const el = await fixture(html`
429
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
430
+ <zn-collapsible caption="A" default="open">
431
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
432
+ </zn-collapsible>
433
+ <zn-collapsible caption="B" default="open">
434
+ <zn-input name="spacing" label="Spacing" value="4"></zn-input>
435
+ </zn-collapsible>
436
+ </zn-theme-editor>`);
437
+ const calls = spyOnFrame(el);
438
+
439
+ el.querySelectorAll('zn-collapsible')[1].remove();
440
+
441
+ await waitUntil(() => calls.length === 1);
442
+ expect((calls[0]['values'] as Record<string, unknown>)['radius']).to.equal('8');
443
+ // Intentional: the store is the theme, not a mirror of visible controls.
444
+ expect((calls[0]['values'] as Record<string, unknown>)['spacing']).to.equal('4');
445
+ });
446
+
447
+ it('removing a single control from within a still-present section pushes the rest', async () => {
448
+ const el = await fixture(html`
449
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
450
+ <zn-collapsible caption="Section" default="open">
451
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
452
+ <zn-input name="spacing" label="Spacing" value="4"></zn-input>
453
+ </zn-collapsible>
454
+ </zn-theme-editor>`);
455
+ const calls = spyOnFrame(el);
456
+
457
+ el.querySelector('zn-input[name="spacing"]')!.remove();
458
+
459
+ await waitUntil(() => calls.length === 1);
460
+ expect((calls[0]['values'] as Record<string, unknown>)['radius']).to.equal('8');
461
+ expect((calls[0]['values'] as Record<string, unknown>)['spacing']).to.equal('4');
462
+ });
463
+
464
+ it('retains an edit made during the push debounce when a control is added elsewhere', async () => {
465
+ const el = await fixture(html`
466
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example" debounce="50">
467
+ <zn-collapsible caption="Section" default="open">
468
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
469
+ </zn-collapsible>
470
+ </zn-theme-editor>`);
471
+
472
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
473
+ input.value = '16';
474
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
475
+
476
+ const section = el.querySelector('zn-collapsible')!;
477
+ const added = document.createElement('zn-input');
478
+ added.setAttribute('name', 'spacing');
479
+ added.setAttribute('value', '4');
480
+ section.append(added); // no await - lands before the 50ms push debounce fires
481
+
482
+ await new Promise(resolve => setTimeout(resolve, 80));
483
+
484
+ const values = (el as HTMLElement & {values: {light: Record<string, unknown>; dark: Record<string, unknown>}}).values;
485
+ expect(values.light['radius']).to.equal('16');
486
+ expect(input.value).to.equal('16');
487
+ });
488
+
489
+ it('the light-DOM observer\'s config never includes attributes', async () => {
490
+ const el = await fixture(html`
491
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example"></zn-theme-editor>`);
492
+
493
+ const config = (el as unknown as {_controlsObserverConfig: MutationObserverInit})._controlsObserverConfig;
494
+ expect(config.childList).to.be.true;
495
+ expect(config.subtree).to.be.true;
496
+ expect(config.attributes).to.not.be.true;
497
+ });
498
+ });
499
+
500
+ it('clears a frame-sourced error on a subsequent control change', async () => {
501
+ const el = await fixture(FIXTURE);
502
+ spyOnFrame(el);
503
+
504
+ const frame = el.shadowRoot!.querySelector('zn-preview-frame')!;
505
+ frame.dispatchEvent(new CustomEvent('zn-error', {
506
+ bubbles: true, composed: true, detail: {message: 'preview blew up'}
507
+ }));
508
+ await waitUntil(() => el.shadowRoot!.querySelector('[part="error"]'));
509
+
510
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
511
+ input.value = '16';
512
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
513
+
514
+ await waitUntil(() => !el.shadowRoot!.querySelector('[part="error"]'));
515
+ });
516
+
517
+ describe('sections', () => {
518
+ it('with no sections configured, renders a single default slot with no section chrome', async () => {
519
+ const el = await fixture(FIXTURE);
520
+
521
+ expect(el.shadowRoot!.querySelectorAll('.editor__section').length).to.equal(0);
522
+ expect(el.shadowRoot!.querySelectorAll('slot:not([name])').length).to.equal(1);
523
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
524
+ .to.deep.equal({radius: '8'});
525
+ });
526
+
527
+ it('does not crash render when sections is malformed JSON', async () => {
528
+ const el = await fixture(html`
529
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example" sections="not-json">
530
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
531
+ </zn-theme-editor>`);
532
+
533
+ expect(el.shadowRoot!.querySelectorAll('.editor__section').length).to.equal(0);
534
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
535
+ .to.deep.equal({radius: '8'});
536
+ });
537
+
538
+ it('does not crash render when sections is valid JSON but not an array', async () => {
539
+ const el = await fixture(html`
540
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example" sections='{"a":1}'>
541
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
542
+ </zn-theme-editor>`);
543
+
544
+ expect(el.shadowRoot!.querySelectorAll('.editor__section').length).to.equal(0);
545
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
546
+ .to.deep.equal({radius: '8'});
547
+ });
548
+
549
+ it('harvests controls assigned to two sections into one value set', async () => {
550
+ const el = await fixture(html`
551
+ <zn-theme-editor
552
+ src="about:blank" frame-origin="https://site.example"
553
+ .sections="${[{name: 'colors', caption: 'Colors'}, {name: 'layout', caption: 'Layout'}]}">
554
+ <zn-color-select slot="colors" name="accent" value="#6936f5"></zn-color-select>
555
+ <zn-input slot="layout" name="radius" type="number" value="4"></zn-input>
556
+ </zn-theme-editor>`);
557
+
558
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
559
+ .to.deep.equal({accent: '#6936f5', radius: '4'});
560
+ expect(el.shadowRoot!.querySelectorAll('.editor__section').length).to.equal(2);
561
+ });
562
+
563
+ it('seeds a section control into both modes and writes it back on toggle', async () => {
564
+ const el = await fixture(html`
565
+ <zn-theme-editor
566
+ src="about:blank" frame-origin="https://site.example"
567
+ .sections="${[{name: 'colors', caption: 'Colors', open: true}]}">
568
+ <zn-input slot="colors" name="accent" value="#ffffff" dark-value="#000000"></zn-input>
569
+ </zn-theme-editor>`);
570
+ const calls = spyOnFrame(el);
571
+
572
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>; dark: Record<string, unknown>}}).values)
573
+ .to.deep.equal({light: {accent: '#ffffff'}, dark: {accent: '#000000'}});
574
+
575
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
576
+ await waitUntil(() => calls.length === 1);
577
+
578
+ expect(calls[0]['values']).to.deep.equal({accent: '#000000'});
579
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
580
+ expect(input.value).to.equal('#000000');
581
+ });
582
+
583
+ it('harvests an ungrouped control alongside section controls', async () => {
584
+ const el = await fixture(html`
585
+ <zn-theme-editor
586
+ src="about:blank" frame-origin="https://site.example"
587
+ .sections="${[{name: 'colors', caption: 'Colors', open: true}]}">
588
+ <zn-input slot="colors" name="accent" value="#6936f5"></zn-input>
589
+ <zn-input name="spacing" type="number" value="8"></zn-input>
590
+ </zn-theme-editor>`);
591
+
592
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
593
+ .to.deep.equal({accent: '#6936f5', spacing: '8'});
594
+ });
595
+
596
+ it('renders no chrome for a sections entry with no assigned controls', async () => {
597
+ const el = await fixture(html`
598
+ <zn-theme-editor
599
+ src="about:blank" frame-origin="https://site.example"
600
+ .sections="${[{name: 'colors', caption: 'Colors'}, {name: 'empty', caption: 'Empty'}]}">
601
+ <zn-input slot="colors" name="accent" value="#6936f5"></zn-input>
602
+ </zn-theme-editor>`);
603
+
604
+ const captions = Array.from(el.shadowRoot!.querySelectorAll('.editor__section'))
605
+ .map(section => section.getAttribute('caption'));
606
+ expect(captions).to.deep.equal(['Colors']);
607
+ });
608
+
609
+ it('controls-collapsed reflects and its toggle flips it without changing values or pushing', async () => {
610
+ const el = await fixture(FIXTURE);
611
+ const calls = spyOnFrame(el);
612
+
613
+ expect(el.hasAttribute('controls-collapsed')).to.be.false;
614
+
615
+ el.shadowRoot!.querySelector<HTMLButtonElement>('.panel-toggle')!.click();
616
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
617
+
618
+ expect((el as HTMLElement & {controlsCollapsed: boolean}).controlsCollapsed).to.be.true;
619
+ expect(el.hasAttribute('controls-collapsed')).to.be.true;
620
+ expect(calls.length).to.equal(0);
621
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
622
+ .to.deep.equal({radius: '8'});
623
+
624
+ el.shadowRoot!.querySelector<HTMLButtonElement>('.panel-toggle')!.click();
625
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
626
+ expect((el as HTMLElement & {controlsCollapsed: boolean}).controlsCollapsed).to.be.false;
627
+ });
628
+
629
+ it('pushes exactly once on mount for a control assigned to a configured section', async () => {
630
+ // Prototype-spy technique - firstUpdated fires inside fixture(), before a
631
+ // per-instance spy could be installed.
632
+ const proto = customElements.get('zn-preview-frame')!.prototype as unknown as Framelike;
633
+ const original = proto.setTheme;
634
+ const calls: ThemeCall[] = [];
635
+ proto.setTheme = (theme: ThemeCall) => calls.push(theme);
636
+
637
+ try {
638
+ await fixture(html`
639
+ <zn-theme-editor
640
+ src="about:blank" frame-origin="https://site.example"
641
+ .sections="${[{name: 'colors', caption: 'Colors', open: true}]}">
642
+ <zn-input slot="colors" name="accent" value="#6936f5"></zn-input>
643
+ </zn-theme-editor>`);
644
+
645
+ expect(calls.length).to.equal(1);
646
+ } finally {
647
+ proto.setTheme = original;
648
+ }
649
+ });
650
+
651
+ it('seeds and pushes a control added into a section that started with no assigned controls', async () => {
652
+ const el = await fixture(html`
653
+ <zn-theme-editor
654
+ src="about:blank" frame-origin="https://site.example"
655
+ .sections="${[{name: 'colors', caption: 'Colors', open: true}]}">
656
+ </zn-theme-editor>`);
657
+ const calls = spyOnFrame(el);
658
+ expect(el.shadowRoot!.querySelectorAll('.editor__section').length).to.equal(0);
659
+
660
+ const input = document.createElement('zn-input');
661
+ input.setAttribute('slot', 'colors');
662
+ input.setAttribute('name', 'accent');
663
+ input.setAttribute('value', '#6936f5');
664
+ el.append(input);
665
+
666
+ await waitUntil(() => calls.length === 1);
667
+ expect(calls[0]['values']).to.deep.equal({accent: '#6936f5'});
668
+ expect(el.shadowRoot!.querySelectorAll('.editor__section').length).to.equal(1);
669
+
670
+ const values = (el as HTMLElement & {values: {light: Record<string, unknown>; dark: Record<string, unknown>}}).values;
671
+ expect(values.light).to.deep.equal({accent: '#6936f5'});
672
+ expect(values.dark).to.deep.equal({accent: '#6936f5'});
673
+ });
674
+
675
+ it('renders the toolbar outside the preview column', async () => {
676
+ const el = await fixture(FIXTURE);
677
+
678
+ const toolbar = el.shadowRoot!.querySelector('[part="toolbar"]')!;
679
+ const preview = el.shadowRoot!.querySelector('[part="preview"]')!;
680
+ expect(toolbar).to.exist;
681
+ expect(preview.contains(toolbar)).to.be.false;
682
+ });
683
+ });
684
+
685
+ describe('column layout', () => {
686
+ it('the controls column spans the full height of the component, not just a shared row', async () => {
687
+ const el = await fixture(FIXTURE);
688
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
689
+
690
+ const controls = el.shadowRoot!.querySelector('[part="controls"]')!;
691
+ const controlsHeight = controls.getBoundingClientRect().height;
692
+ const hostHeight = el.getBoundingClientRect().height;
693
+
694
+ expect(controlsHeight).to.be.closeTo(hostHeight, 2);
695
+ });
696
+
697
+ it("the toolbar starts at the controls column's right edge, not the component's left edge", async () => {
698
+ const el = await fixture(FIXTURE);
699
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
700
+
701
+ const controls = el.shadowRoot!.querySelector('[part="controls"]')!;
702
+ const toolbar = el.shadowRoot!.querySelector('[part="toolbar"]')!;
703
+ const hostLeft = el.getBoundingClientRect().left;
704
+ const controlsRight = controls.getBoundingClientRect().right;
705
+ const toolbarLeft = toolbar.getBoundingClientRect().left;
706
+
707
+ expect(toolbarLeft).to.be.closeTo(controlsRight, 2);
708
+ expect(toolbarLeft).to.be.greaterThan(hostLeft + 1);
709
+ });
710
+
711
+ it('renders both captions when set', async () => {
712
+ const el = await fixture(html`
713
+ <zn-theme-editor
714
+ src="about:blank" frame-origin="https://site.example"
715
+ controls-caption="Theme Builder" preview-caption="Live Preview">
716
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
717
+ </zn-theme-editor>`);
718
+
719
+ const controlsHeader = el.shadowRoot!.querySelector('[part="controls-header"]')!;
720
+ const toolbar = el.shadowRoot!.querySelector('[part="toolbar"]')!;
721
+ expect(controlsHeader.textContent).to.contain('Theme Builder');
722
+ expect(toolbar.textContent).to.contain('Live Preview');
723
+ });
724
+
725
+ it('still renders the controls header row when controls-caption is empty', async () => {
726
+ const el = await fixture(FIXTURE);
727
+
728
+ const controlsHeader = el.shadowRoot!.querySelector('[part="controls-header"]');
729
+ expect(controlsHeader).to.exist;
730
+ expect(controlsHeader!.textContent?.trim()).to.equal('');
731
+ });
732
+ });
733
+
734
+ describe('tabbed sections (section-layout="tabs")', () => {
735
+ const TABBED_FIXTURE = html`
736
+ <zn-theme-editor
737
+ src="about:blank" frame-origin="https://site.example"
738
+ section-layout="tabs" debounce="10"
739
+ .sections="${[{name: 'colors', caption: 'Colors'}, {name: 'layout', caption: 'Layout'}]}">
740
+ <zn-color-select slot="colors" name="accent" label="Accent" value="#6936f5"></zn-color-select>
741
+ <zn-input slot="layout" name="radius" type="number" value="4"></zn-input>
742
+ </zn-theme-editor>`;
743
+
744
+ it('renders and is accessible', async () => {
745
+ const el = await fixture(TABBED_FIXTURE);
746
+ await expect(el).to.be.accessible();
747
+ });
748
+
749
+ it('renders a tab per visible section with the first active', async () => {
750
+ const el = await fixture(TABBED_FIXTURE);
751
+ const tabs = Array.from(el.shadowRoot!.querySelectorAll('li[tab]'));
752
+ const panels = Array.from(el.shadowRoot!.querySelectorAll('.editor__tab-panel'));
753
+ expect(tabs.length).to.equal(2);
754
+ // zn-tabs applies its initial selection after its own 10ms settle timer.
755
+ await waitUntil(() => panels[0].hasAttribute('selected'));
756
+ expect(panels[1].hasAttribute('selected')).to.be.false;
757
+ });
758
+
759
+ it('keeps a control in a non-active tab pane present, harvested and POSTed', async () => {
760
+ const fetchCalls: {uri: string; init?: RequestInit}[] = [];
761
+ const realFetch = window.fetch;
762
+ window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
763
+ fetchCalls.push({uri: String(uri), init});
764
+ return Promise.resolve(new Response('', {status: 200}));
765
+ };
766
+
767
+ try {
768
+ const el = await fixture(html`
769
+ <zn-theme-editor
770
+ src="about:blank" frame-origin="https://site.example"
771
+ action="/theme/save" section-layout="tabs" debounce="10" save-debounce="10"
772
+ .sections="${[{name: 'colors', caption: 'Colors'}, {name: 'layout', caption: 'Layout'}]}">
773
+ <zn-color-select slot="colors" name="accent" value="#6936f5"></zn-color-select>
774
+ <zn-input slot="layout" name="radius" type="number" value="4"></zn-input>
775
+ </zn-theme-editor>`);
776
+
777
+ // "colors" is the active tab by default; "layout" stays present but unselected.
778
+ const layoutPanel = Array.from(el.shadowRoot!.querySelectorAll('.editor__tab-panel'))[1];
779
+ expect((layoutPanel as HTMLElement).hasAttribute('selected')).to.be.false;
780
+ // the slot inside the unselected pane must still exist for harvesting to find
781
+ expect(layoutPanel!.querySelector('slot[name="layout"]')).to.exist;
782
+
783
+ const values = (el as HTMLElement & {values: {light: Record<string, unknown>}}).values;
784
+ expect(values.light).to.deep.equal({accent: '#6936f5', radius: '4'});
785
+
786
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
787
+ input.value = '8';
788
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
789
+
790
+ await waitUntil(() => fetchCalls.length === 1);
791
+ const body = fetchCalls[0].init!.body as FormData;
792
+ expect(body.get('light[accent]')).to.equal('#6936f5');
793
+ expect(body.get('light[radius]')).to.equal('8');
794
+ } finally {
795
+ window.fetch = realFetch;
796
+ }
797
+ });
798
+
799
+ it('clicking a tab switches the selected panel without pushing or harvesting a new value', async () => {
800
+ const el = await fixture(TABBED_FIXTURE);
801
+ await waitUntil(() => el.shadowRoot!.querySelector('.editor__tab-panel[selected]'));
802
+ const calls = spyOnFrame(el);
803
+ const tabs = el.shadowRoot!.querySelectorAll<HTMLElement>('li[tab]');
804
+ const panels = el.shadowRoot!.querySelectorAll('.editor__tab-panel');
805
+
806
+ tabs[1].click();
807
+ // clickTab() re-selects via its own 10ms settle timer too.
808
+ await waitUntil(() => (panels[1] as HTMLElement).hasAttribute('selected'));
809
+
810
+ expect((panels[0] as HTMLElement).hasAttribute('selected')).to.be.false;
811
+ expect(calls.length).to.equal(0);
812
+ });
813
+
814
+ it('with section-layout unset, renders collapsibles exactly as before', async () => {
815
+ const el = await fixture(html`
816
+ <zn-theme-editor
817
+ src="about:blank" frame-origin="https://site.example"
818
+ .sections="${[{name: 'colors', caption: 'Colors'}]}">
819
+ <zn-color-select slot="colors" name="accent" value="#6936f5"></zn-color-select>
820
+ </zn-theme-editor>`);
821
+
822
+ expect(el.shadowRoot!.querySelectorAll('.editor__section').length).to.equal(1);
823
+ expect(el.shadowRoot!.querySelector('zn-tabs')).to.not.exist;
824
+ });
825
+ });
826
+
827
+ describe('author-slotted collapsibles nested inside tabbed sections', () => {
828
+ const NESTED_FIXTURE = () => fixture(html`
829
+ <zn-theme-editor
830
+ src="about:blank" frame-origin="https://site.example"
831
+ section-layout="tabs" debounce="10"
832
+ .sections="${[{name: 'colors', caption: 'Colors'}, {name: 'layout', caption: 'Layout'}]}">
833
+ <zn-collapsible slot="colors" caption="Group A">
834
+ <zn-input name="a" label="A" value="1"></zn-input>
835
+ </zn-collapsible>
836
+ <zn-collapsible slot="colors" caption="Group B">
837
+ <zn-input name="b" label="B" value="2"></zn-input>
838
+ </zn-collapsible>
839
+ <zn-input slot="colors" name="c" label="C" value="3"></zn-input>
840
+ <zn-input slot="layout" name="d" label="D" value="4"></zn-input>
841
+ </zn-theme-editor>`);
842
+
843
+ it('harvests controls nested inside slotted collapsibles, in both modes', async () => {
844
+ const el = await NESTED_FIXTURE();
845
+ const values = (el as HTMLElement & {values: {light: Record<string, unknown>; dark: Record<string, unknown>}}).values;
846
+ expect(values.light).to.deep.equal({a: '1', b: '2', c: '3', d: '4'});
847
+ expect(values.dark).to.deep.equal({a: '1', b: '2', c: '3', d: '4'});
848
+ });
849
+
850
+ it('nested harvesting keeps the inactive-pane guarantee intact', async () => {
851
+ const el = await NESTED_FIXTURE();
852
+ const layoutPanel = Array.from(el.shadowRoot!.querySelectorAll('.editor__tab-panel'))[1];
853
+ expect((layoutPanel as HTMLElement).hasAttribute('selected')).to.be.false;
854
+ expect(layoutPanel!.querySelector('slot[name="layout"]')).to.exist;
855
+ });
856
+
857
+ });
858
+
859
+ describe('nested groups (sections + groups)', () => {
860
+ const NESTED_GROUPS_FIXTURE = () => fixture(html`
861
+ <zn-theme-editor
862
+ src="about:blank" frame-origin="https://site.example" debounce="10"
863
+ .sections="${[
864
+ {name: 'colors', caption: 'Colors', groups: [
865
+ {name: 'brand', caption: 'Brand'},
866
+ {name: 'semantic', caption: 'Semantic', open: true},
867
+ ]},
868
+ {name: 'shapes', caption: 'Shapes', groups: [
869
+ {name: 'radius', caption: 'Radius'},
870
+ ]},
871
+ ]}">
872
+ <zn-input slot="brand" name="brand" value="1"></zn-input>
873
+ <zn-input slot="semantic" name="semantic" value="2"></zn-input>
874
+ <zn-input slot="radius" name="radius" value="4"></zn-input>
875
+ </zn-theme-editor>`);
876
+
877
+ it('renders and is accessible', async () => {
878
+ const el = await NESTED_GROUPS_FIXTURE();
879
+ await expect(el).to.be.accessible();
880
+ });
881
+
882
+ it('renders zn-tabs with one collapsible per populated group', async () => {
883
+ const el = await NESTED_GROUPS_FIXTURE();
884
+ expect(el.shadowRoot!.querySelector('zn-tabs')).to.exist;
885
+ expect(el.shadowRoot!.querySelectorAll('li[tab]').length).to.equal(2);
886
+ expect(el.shadowRoot!.querySelectorAll('.editor__section').length).to.equal(3);
887
+ });
888
+
889
+ it("a control inside a non-active tab's group is still harvested, seeded into both modes, and POSTed", async () => {
890
+ const fetchCalls: {uri: string; init?: RequestInit}[] = [];
891
+ const realFetch = window.fetch;
892
+ window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
893
+ fetchCalls.push({uri: String(uri), init});
894
+ return Promise.resolve(new Response('', {status: 200}));
895
+ };
896
+
897
+ try {
898
+ const el = await fixture(html`
899
+ <zn-theme-editor
900
+ src="about:blank" frame-origin="https://site.example"
901
+ action="/theme/save" debounce="10" save-debounce="10"
902
+ .sections="${[
903
+ {name: 'colors', caption: 'Colors', groups: [{name: 'brand', caption: 'Brand'}]},
904
+ {name: 'shapes', caption: 'Shapes', groups: [{name: 'radius', caption: 'Radius'}]},
905
+ ]}">
906
+ <zn-input slot="brand" name="brand" value="1"></zn-input>
907
+ <zn-input slot="radius" name="radius" value="4"></zn-input>
908
+ </zn-theme-editor>`);
909
+
910
+ // "colors" (and its "brand" group) is the first, active tab; "shapes"
911
+ // and its "radius" group's control are not.
912
+ const values = (el as HTMLElement & {values: {light: Record<string, unknown>; dark: Record<string, unknown>}}).values;
913
+ expect(values.light).to.deep.equal({brand: '1', radius: '4'});
914
+ expect(values.dark).to.deep.equal({brand: '1', radius: '4'});
915
+
916
+ const input = el.querySelector('zn-input[name="brand"]')! as HTMLElement & {value: string};
917
+ input.value = '9';
918
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
919
+
920
+ await waitUntil(() => fetchCalls.length === 1);
921
+ const body = fetchCalls[0].init!.body as FormData;
922
+ expect(body.get('light[brand]')).to.equal('9');
923
+ expect(body.get('light[radius]')).to.equal('4');
924
+ expect(body.get('dark[radius]')).to.equal('4');
925
+ } finally {
926
+ window.fetch = realFetch;
927
+ }
928
+ });
929
+
930
+ it('a group with no assigned controls renders no collapsible', async () => {
931
+ const el = await fixture(html`
932
+ <zn-theme-editor
933
+ src="about:blank" frame-origin="https://site.example"
934
+ .sections="${[{name: 'colors', caption: 'Colors', groups: [
935
+ {name: 'brand', caption: 'Brand'},
936
+ {name: 'empty', caption: 'Empty'},
937
+ ]}]}">
938
+ <zn-input slot="brand" name="brand" value="1"></zn-input>
939
+ </zn-theme-editor>`);
940
+
941
+ const captions = Array.from(el.shadowRoot!.querySelectorAll('.editor__section'))
942
+ .map(section => section.getAttribute('caption'));
943
+ expect(captions).to.deep.equal(['Brand']);
944
+ });
945
+
946
+ it('a section with no populated groups renders no tab', async () => {
947
+ const el = await fixture(html`
948
+ <zn-theme-editor
949
+ src="about:blank" frame-origin="https://site.example"
950
+ .sections="${[
951
+ {name: 'colors', caption: 'Colors', groups: [{name: 'brand', caption: 'Brand'}]},
952
+ {name: 'empty-section', caption: 'Empty section', groups: [{name: 'empty-group', caption: 'Empty group'}]},
953
+ ]}">
954
+ <zn-input slot="brand" name="brand" value="1"></zn-input>
955
+ </zn-theme-editor>`);
956
+
957
+ expect(el.shadowRoot!.querySelectorAll('li[tab]').length).to.equal(1);
958
+ });
959
+
960
+ it('flat sections with no groups still honour section-layout="tabs"', async () => {
961
+ const el = await fixture(html`
962
+ <zn-theme-editor
963
+ src="about:blank" frame-origin="https://site.example" section-layout="tabs"
964
+ .sections="${[{name: 'colors', caption: 'Colors'}, {name: 'layout', caption: 'Layout'}]}">
965
+ <zn-input slot="colors" name="accent" value="1"></zn-input>
966
+ <zn-input slot="layout" name="radius" value="4"></zn-input>
967
+ </zn-theme-editor>`);
968
+
969
+ expect(el.shadowRoot!.querySelector('zn-tabs')).to.exist;
970
+ expect(el.shadowRoot!.querySelectorAll('.editor__section').length).to.equal(0);
971
+ });
972
+
973
+ it('does not crash render when groups is malformed', async () => {
974
+ const el = await fixture(html`
975
+ <zn-theme-editor
976
+ src="about:blank" frame-origin="https://site.example"
977
+ .sections="${[{name: 'colors', caption: 'Colors', groups: 'not-an-array'}]}">
978
+ <zn-input slot="colors" name="accent" value="1"></zn-input>
979
+ </zn-theme-editor>`);
980
+
981
+ expect(el.shadowRoot!.querySelectorAll('.editor__section').length).to.equal(1);
982
+ expect(el.shadowRoot!.querySelector('zn-tabs')).to.not.exist;
983
+ });
984
+ });
985
+
986
+ describe('preview sources', () => {
987
+ it('with sources unset, renders no dropdown and leaves src alone', async () => {
988
+ const el = await fixture(html`
989
+ <zn-theme-editor src="/embed/a" frame-origin="https://site.example"></zn-theme-editor>`);
990
+
991
+ expect(el.shadowRoot!.querySelector('zn-select.editor__sources')).to.not.exist;
992
+ const frame = el.shadowRoot!.querySelector('zn-preview-frame')! as HTMLElement & {src: string};
993
+ expect(frame.src).to.equal('/embed/a');
994
+ });
995
+
996
+ it('renders a dropdown, the first source winning over an explicit src, and switches the frame src on change', async () => {
997
+ const el = await fixture(html`
998
+ <zn-theme-editor
999
+ src="/embed/explicit" frame-origin="https://site.example"
1000
+ .sources="${[{label: 'Storefront', src: '/embed/a'}, {label: 'Checkout', src: '/embed/b'}]}">
1001
+ </zn-theme-editor>`);
1002
+
1003
+ const select = el.shadowRoot!.querySelector('zn-select.editor__sources')! as HTMLElement & {value: string};
1004
+ expect(select).to.exist;
1005
+ const frame = el.shadowRoot!.querySelector('zn-preview-frame')! as HTMLElement & {src: string};
1006
+ expect(frame.src).to.equal('/embed/a'); // first source wins over the explicit src
1007
+
1008
+ select.value = '1';
1009
+ select.dispatchEvent(new CustomEvent('zn-change', {bubbles: true, composed: true}));
1010
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
1011
+
1012
+ expect(frame.src).to.equal('/embed/b');
1013
+ });
1014
+
1015
+ it('keeps the same frame instance across a source switch, so its retained theme survives the reload', async () => {
1016
+ const el = await fixture(html`
1017
+ <zn-theme-editor
1018
+ src="about:blank" frame-origin="https://site.example"
1019
+ .sources="${[{label: 'A', src: 'about:blank'}, {label: 'B', src: 'about:blank?b'}]}">
1020
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1021
+ </zn-theme-editor>`);
1022
+
1023
+ const frameBefore = el.shadowRoot!.querySelector('zn-preview-frame')!;
1024
+ const select = el.shadowRoot!.querySelector('zn-select.editor__sources')! as HTMLElement & {value: string};
1025
+
1026
+ select.value = '1';
1027
+ select.dispatchEvent(new CustomEvent('zn-change', {bubbles: true, composed: true}));
1028
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
1029
+
1030
+ const frameAfter = el.shadowRoot!.querySelector('zn-preview-frame')! as HTMLElement & {src: string};
1031
+ // Same instance, not recreated - setTheme()'s retained payload (tested in
1032
+ // preview-frame's own suite) replays on this instance's next ready handshake.
1033
+ expect(frameAfter).to.equal(frameBefore);
1034
+ expect(frameAfter.src).to.equal('about:blank?b');
1035
+ });
1036
+ });
1037
+
1038
+ describe('standalone panel mode', () => {
1039
+ it('reflects the standalone attribute and forwards a panel backdrop to the frame', async () => {
1040
+ const el = await fixture(html`
1041
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example" standalone></zn-theme-editor>`);
1042
+
1043
+ expect(el.hasAttribute('standalone')).to.be.true;
1044
+ const frame = el.shadowRoot!.querySelector('zn-preview-frame')!;
1045
+ expect(frame.getAttribute('backdrop')).to.equal('panel');
1046
+ });
1047
+
1048
+ it('forwards a dots backdrop to the frame when standalone is unset', async () => {
1049
+ const el = await fixture(FIXTURE);
1050
+ const frame = el.shadowRoot!.querySelector('zn-preview-frame')!;
1051
+ expect(frame.getAttribute('backdrop')).to.equal('dots');
1052
+ });
1053
+ });
1054
+
1055
+ describe('narrow viewport recovery', () => {
1056
+ // A real resize needs a moment to reflow before @media queries are reliably
1057
+ // reflected in getComputedStyle - a plain rAF tick isn't always enough.
1058
+ const settle = () => new Promise(resolve => setTimeout(resolve, 100));
1059
+
1060
+ afterEach(async () => {
1061
+ await setViewport({width: 800, height: 600}); // restore the runner's default
1062
+ await settle();
1063
+ });
1064
+
1065
+ it('auto-collapses once when mounted on a narrow viewport', async () => {
1066
+ await setViewport({width: 400, height: 800});
1067
+ await settle();
1068
+ const el = await fixture(FIXTURE);
1069
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
1070
+ await settle();
1071
+
1072
+ expect((el as HTMLElement & {controlsCollapsed: boolean}).controlsCollapsed).to.be.true;
1073
+ });
1074
+
1075
+ it('keeps the panel-toggle usable when controls-collapsed is set post-connect while narrow', async () => {
1076
+ await setViewport({width: 400, height: 800});
1077
+ await settle();
1078
+ const el = await fixture(FIXTURE);
1079
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
1080
+ await settle();
1081
+
1082
+ // Mimics a host applying a persisted preference (or a reactive binding
1083
+ // re-asserting it) after the auto-collapse has already run.
1084
+ (el as HTMLElement & {controlsCollapsed: boolean}).controlsCollapsed = true;
1085
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
1086
+
1087
+ // Checked against the parsed stylesheet rather than computed style - some
1088
+ // engines can leave computed style stale on a shadow root reusing a
1089
+ // long-lived, already-adopted stylesheet across many prior fixtures.
1090
+ const sheets = Array.from(el.shadowRoot!.adoptedStyleSheets ?? []);
1091
+ const hidesUnconditionally = sheets.some(sheet => Array.from(sheet.cssRules).some(rule =>
1092
+ rule instanceof CSSMediaRule && rule.media.mediaText.includes('768') &&
1093
+ Array.from(rule.cssRules).some(inner =>
1094
+ inner instanceof CSSStyleRule && inner.selectorText === '.panel-toggle' && inner.style.display === 'none')));
1095
+ expect(hidesUnconditionally).to.be.false;
1096
+
1097
+ el.shadowRoot!.querySelector<HTMLButtonElement>('.panel-toggle')!.click();
1098
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
1099
+
1100
+ expect((el as HTMLElement & {controlsCollapsed: boolean}).controlsCollapsed).to.be.false;
1101
+ });
1102
+ });
1103
+
1104
+ describe('auto-save', () => {
1105
+ let fetchCalls: {uri: string; init?: RequestInit}[];
1106
+ const realFetch = window.fetch;
1107
+
1108
+ beforeEach(() => {
1109
+ fetchCalls = [];
1110
+ window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
1111
+ fetchCalls.push({uri: String(uri), init});
1112
+ return Promise.resolve(new Response('', {status: 200}));
1113
+ };
1114
+ });
1115
+
1116
+ afterEach(() => {
1117
+ window.fetch = realFetch;
1118
+ });
1119
+
1120
+ it('does not POST when action is unset', async () => {
1121
+ const el = await fixture(FIXTURE);
1122
+ spyOnFrame(el);
1123
+
1124
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1125
+ input.value = '16';
1126
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1127
+
1128
+ await new Promise(resolve => setTimeout(resolve, 100));
1129
+ expect(fetchCalls.length).to.equal(0);
1130
+ });
1131
+
1132
+ it('POSTs the values to action on change', async () => {
1133
+ const el = await fixture(html`
1134
+ <zn-theme-editor
1135
+ src="about:blank"
1136
+ frame-origin="https://site.example"
1137
+ action="/theme/save"
1138
+ debounce="10"
1139
+ save-debounce="10">
1140
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1141
+ <zn-checkbox name="rounded" checked></zn-checkbox>
1142
+ </zn-theme-editor>`);
1143
+ spyOnFrame(el);
1144
+
1145
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1146
+ input.value = '16';
1147
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1148
+
1149
+ await waitUntil(() => fetchCalls.length === 1);
1150
+ expect(fetchCalls[0].uri).to.equal('/theme/save');
1151
+ expect(fetchCalls[0].init?.method).to.equal('POST');
1152
+ const body = fetchCalls[0].init!.body as FormData;
1153
+ expect(body.get('light[radius]')).to.equal('16');
1154
+ // dark was seeded from the original '8' and is untouched by the light-only edit
1155
+ expect(body.get('dark[radius]')).to.equal('8');
1156
+ expect(body.get('light[rounded]')).to.equal('1');
1157
+ expect(body.get('dark[rounded]')).to.equal('1');
1158
+ // mode and device are view state, never persisted
1159
+ expect(body.get('mode')).to.equal(null);
1160
+ expect(body.get('device')).to.equal(null);
1161
+ });
1162
+
1163
+ it('POSTs both light and dark sets as bracketed keys', async () => {
1164
+ const el = await fixture(html`
1165
+ <zn-theme-editor
1166
+ src="about:blank"
1167
+ frame-origin="https://site.example"
1168
+ action="/theme/save"
1169
+ debounce="10"
1170
+ save-debounce="10">
1171
+ <zn-input name="background" label="Background" value="#ffffff" dark-value="#000000"></zn-input>
1172
+ </zn-theme-editor>`);
1173
+ spyOnFrame(el);
1174
+
1175
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1176
+ input.value = '#eeeeee';
1177
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1178
+
1179
+ await waitUntil(() => fetchCalls.length === 1);
1180
+ const body = fetchCalls[0].init!.body as FormData;
1181
+ expect(body.get('light[background]')).to.equal('#eeeeee');
1182
+ expect(body.get('dark[background]')).to.equal('#000000');
1183
+ });
1184
+
1185
+ it('toggling mode issues no POST', async () => {
1186
+ const el = await fixture(html`
1187
+ <zn-theme-editor
1188
+ src="about:blank"
1189
+ frame-origin="https://site.example"
1190
+ action="/theme/save"
1191
+ debounce="5"
1192
+ save-debounce="5">
1193
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1194
+ </zn-theme-editor>`);
1195
+ spyOnFrame(el);
1196
+
1197
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
1198
+
1199
+ await new Promise(resolve => setTimeout(resolve, 100));
1200
+ expect(fetchCalls.length).to.equal(0);
1201
+ });
1202
+
1203
+ it('suppresses the async color-normalization emit from write-back, issuing no POST', async () => {
1204
+ // dark-value is authored as rgb() against a hex value/colorFormat: writing
1205
+ // it back into the control makes zn-input's own @watch('value') handler
1206
+ // normalize it and emit zn-change from inside a later Lit update() - the
1207
+ // scenario the write-back suppression guard exists for.
1208
+ const el = await fixture(html`
1209
+ <zn-theme-editor
1210
+ src="about:blank"
1211
+ frame-origin="https://site.example"
1212
+ action="/theme/save"
1213
+ debounce="5"
1214
+ save-debounce="5">
1215
+ <zn-input name="background" type="color" label="Background" value="#ffffff" dark-value="rgb(0, 0, 0)"></zn-input>
1216
+ </zn-theme-editor>`);
1217
+ spyOnFrame(el);
1218
+
1219
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
1220
+
1221
+ await new Promise(resolve => setTimeout(resolve, 100));
1222
+ expect(fetchCalls.length).to.equal(0);
1223
+ });
1224
+
1225
+ it('toggling mode with a grouped checkbox causes exactly one push and no POST', async () => {
1226
+ // Does not by itself prove the attributes-vs-childList choice below -
1227
+ // see the config-pinning test for that. This only proves the toggle
1228
+ // itself stays clean when the control is nested in a section.
1229
+ const el = await fixture(html`
1230
+ <zn-theme-editor
1231
+ src="about:blank"
1232
+ frame-origin="https://site.example"
1233
+ action="/theme/save"
1234
+ debounce="5"
1235
+ save-debounce="5">
1236
+ <zn-collapsible caption="Section" default="open">
1237
+ <zn-checkbox name="rounded" checked dark-value="false"></zn-checkbox>
1238
+ </zn-collapsible>
1239
+ </zn-theme-editor>`);
1240
+ const calls = spyOnFrame(el);
1241
+
1242
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
1243
+
1244
+ await waitUntil(() => calls.length === 1);
1245
+ await new Promise(resolve => setTimeout(resolve, 100));
1246
+
1247
+ expect(calls.length).to.equal(1);
1248
+ expect(fetchCalls.length).to.equal(0);
1249
+ });
1250
+
1251
+ it('does not POST when only the mode or device changes', async () => {
1252
+ const el = await fixture(html`
1253
+ <zn-theme-editor
1254
+ src="about:blank"
1255
+ frame-origin="https://site.example"
1256
+ action="/theme/save"
1257
+ save-debounce="10">
1258
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1259
+ </zn-theme-editor>`);
1260
+ spyOnFrame(el);
1261
+
1262
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
1263
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="mobile"]')!.click();
1264
+
1265
+ await new Promise(resolve => setTimeout(resolve, 100));
1266
+ expect(fetchCalls.length).to.equal(0);
1267
+ });
1268
+
1269
+ it('surfaces a failed save and emits zn-error', async () => {
1270
+ window.fetch = () => Promise.resolve(new Response('theme rejected', {status: 500}));
1271
+
1272
+ const el = await fixture(html`
1273
+ <zn-theme-editor
1274
+ src="about:blank"
1275
+ frame-origin="https://site.example"
1276
+ action="/theme/save"
1277
+ debounce="10"
1278
+ save-debounce="10">
1279
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1280
+ </zn-theme-editor>`);
1281
+ spyOnFrame(el);
1282
+
1283
+ let znError: CustomEvent<{message?: string}> | null = null;
1284
+ el.addEventListener('zn-error', (e: Event) => { znError = e as CustomEvent<{message?: string}>; });
1285
+
1286
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1287
+ input.value = '16';
1288
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1289
+
1290
+ await waitUntil(() => el.shadowRoot!.querySelector('[part="error"]'));
1291
+ expect(el.shadowRoot!.querySelector('[part="error"]')!.textContent).to.contain('theme rejected');
1292
+ expect(znError).to.exist;
1293
+ expect(znError!.detail.message).to.contain('theme rejected');
1294
+ });
1295
+
1296
+ it('runs exactly one follow-up save for changes made mid-flight', async () => {
1297
+ let release: (() => void) | undefined;
1298
+ const gate = new Promise<void>(resolve => { release = resolve; });
1299
+ window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
1300
+ fetchCalls.push({uri: String(uri), init});
1301
+ return gate.then(() => new Response('', {status: 200}));
1302
+ };
1303
+
1304
+ const el = await fixture(html`
1305
+ <zn-theme-editor
1306
+ src="about:blank"
1307
+ frame-origin="https://site.example"
1308
+ action="/theme/save"
1309
+ debounce="5"
1310
+ save-debounce="5">
1311
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1312
+ </zn-theme-editor>`);
1313
+ spyOnFrame(el);
1314
+
1315
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1316
+ const change = (value: string) => {
1317
+ input.value = value;
1318
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1319
+ };
1320
+
1321
+ change('16');
1322
+ await waitUntil(() => fetchCalls.length === 1); // in flight, gated
1323
+
1324
+ change('24');
1325
+ change('32');
1326
+ await new Promise(resolve => setTimeout(resolve, 50));
1327
+ expect(fetchCalls.length).to.equal(1); // queued, not stacked
1328
+
1329
+ release!();
1330
+ await waitUntil(() => fetchCalls.length === 2);
1331
+ await new Promise(resolve => setTimeout(resolve, 50));
1332
+ expect(fetchCalls.length).to.equal(2); // one follow-up, latest values
1333
+ expect((fetchCalls[1].init!.body as FormData).get('light[radius]')).to.equal('32');
1334
+ });
1335
+ });
1336
+
1337
+ describe('submit button', () => {
1338
+ let fetchCalls: {uri: string; init?: RequestInit}[];
1339
+ const realFetch = window.fetch;
1340
+
1341
+ // zn-button overrides click() to call its handler directly without dispatching -
1342
+ // a real click event is needed for the theme-editor's own @click listener to fire.
1343
+ function clickButton(el: Element) {
1344
+ const button = el.shadowRoot!.querySelector('zn-button')!;
1345
+ button.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
1346
+ }
1347
+
1348
+ beforeEach(() => {
1349
+ fetchCalls = [];
1350
+ window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
1351
+ fetchCalls.push({uri: String(uri), init});
1352
+ return Promise.resolve(new Response('', {status: 200}));
1353
+ };
1354
+ });
1355
+
1356
+ afterEach(() => {
1357
+ window.fetch = realFetch;
1358
+ });
1359
+
1360
+ it('renders no button without submit-label', async () => {
1361
+ const el = await fixture(FIXTURE);
1362
+ expect(el.shadowRoot!.querySelector('zn-button')).to.not.exist;
1363
+ });
1364
+
1365
+ it('renders the button in the toolbar, not the footer, and leaves the footer slot working', async () => {
1366
+ const el = await fixture(html`
1367
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example" submit-label="Save theme">
1368
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1369
+ <span slot="footer">extra</span>
1370
+ </zn-theme-editor>`);
1371
+
1372
+ const button = el.shadowRoot!.querySelector('zn-button')!;
1373
+ expect(button).to.exist;
1374
+ expect(button.textContent?.trim()).to.equal('Save theme');
1375
+ expect(el.shadowRoot!.querySelector('[part="toolbar"] zn-button')).to.equal(button);
1376
+ expect(el.shadowRoot!.querySelector('[part="footer"] zn-button')).to.not.exist;
1377
+ expect(el.shadowRoot!.querySelector('[part="footer"] slot[name="footer"]')).to.exist;
1378
+ });
1379
+
1380
+ it('renders no footer at all when only submit-label is set', async () => {
1381
+ const el = await fixture(html`
1382
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example" submit-label="Save theme">
1383
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1384
+ </zn-theme-editor>`);
1385
+
1386
+ expect(el.shadowRoot!.querySelector('zn-button')).to.exist;
1387
+ expect(el.shadowRoot!.querySelector('[part="footer"]')).to.not.exist;
1388
+ });
1389
+
1390
+ it('clicking submits and POSTs exactly once', async () => {
1391
+ const el = await fixture(html`
1392
+ <zn-theme-editor
1393
+ src="about:blank" frame-origin="https://site.example"
1394
+ action="/theme/save" submit-label="Save theme">
1395
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1396
+ </zn-theme-editor>`);
1397
+
1398
+ clickButton(el);
1399
+
1400
+ await waitUntil(() => fetchCalls.length === 1);
1401
+ expect(fetchCalls[0].uri).to.equal('/theme/save');
1402
+ await new Promise(resolve => setTimeout(resolve, 50));
1403
+ expect(fetchCalls.length).to.equal(1);
1404
+ });
1405
+
1406
+ it('manual suppresses debounced auto-save but not preview pushes', async () => {
1407
+ const el = await fixture(html`
1408
+ <zn-theme-editor
1409
+ src="about:blank" frame-origin="https://site.example"
1410
+ action="/theme/save" manual debounce="10" save-debounce="10">
1411
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1412
+ </zn-theme-editor>`);
1413
+ const calls = spyOnFrame(el);
1414
+
1415
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1416
+ input.value = '16';
1417
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1418
+
1419
+ await waitUntil(() => calls.length === 1);
1420
+ expect(calls[0]['values']).to.deep.equal({radius: '16'});
1421
+
1422
+ await new Promise(resolve => setTimeout(resolve, 50));
1423
+ expect(fetchCalls.length).to.equal(0);
1424
+ });
1425
+
1426
+ it('submit flushes a pending edit so the POST carries the just-typed value', async () => {
1427
+ const el = await fixture(html`
1428
+ <zn-theme-editor
1429
+ src="about:blank" frame-origin="https://site.example"
1430
+ action="/theme/save" manual debounce="1000" submit-label="Save theme">
1431
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1432
+ </zn-theme-editor>`);
1433
+
1434
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1435
+ input.value = '99';
1436
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1437
+ // click before the (long) push debounce fires - no await in between
1438
+ clickButton(el);
1439
+
1440
+ await waitUntil(() => fetchCalls.length === 1);
1441
+ const body = fetchCalls[0].init!.body as FormData;
1442
+ expect(body.get('light[radius]')).to.equal('99');
1443
+ });
1444
+
1445
+ it('emits zn-theme-submit with both value sets', async () => {
1446
+ const el = await fixture(html`
1447
+ <zn-theme-editor
1448
+ src="about:blank" frame-origin="https://site.example"
1449
+ action="/theme/save" submit-label="Save theme">
1450
+ <zn-input name="background" label="Background" value="#ffffff" dark-value="#000000"></zn-input>
1451
+ </zn-theme-editor>`);
1452
+
1453
+ interface Detail {values: {light: Record<string, unknown>; dark: Record<string, unknown>}}
1454
+ let detail: Detail | null = null;
1455
+ el.addEventListener('zn-theme-submit', (e: Event) => {
1456
+ detail = (e as CustomEvent<Detail>).detail;
1457
+ });
1458
+
1459
+ clickButton(el);
1460
+
1461
+ await waitUntil(() => detail !== null);
1462
+ expect(detail!.values).to.deep.equal({light: {background: '#ffffff'}, dark: {background: '#000000'}});
1463
+ });
1464
+
1465
+ it('emits zn-theme-submit without POSTing when action is unset', async () => {
1466
+ const el = await fixture(html`
1467
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example" submit-label="Save theme">
1468
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1469
+ </zn-theme-editor>`);
1470
+
1471
+ let fired = false;
1472
+ el.addEventListener('zn-theme-submit', () => { fired = true; });
1473
+
1474
+ clickButton(el);
1475
+
1476
+ await waitUntil(() => fired);
1477
+ await new Promise(resolve => setTimeout(resolve, 50));
1478
+ expect(fetchCalls.length).to.equal(0);
1479
+ });
1480
+
1481
+ it('shows a loading state while the POST is in flight and does not stack a second submit', async () => {
1482
+ let release: (() => void) | undefined;
1483
+ const gate = new Promise<void>(resolve => { release = resolve; });
1484
+ window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
1485
+ fetchCalls.push({uri: String(uri), init});
1486
+ return gate.then(() => new Response('', {status: 200}));
1487
+ };
1488
+
1489
+ const el = await fixture(html`
1490
+ <zn-theme-editor
1491
+ src="about:blank" frame-origin="https://site.example"
1492
+ action="/theme/save" submit-label="Save theme">
1493
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1494
+ </zn-theme-editor>`);
1495
+
1496
+ clickButton(el);
1497
+ await waitUntil(() => fetchCalls.length === 1);
1498
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
1499
+
1500
+ const button = el.shadowRoot!.querySelector('zn-button')! as HTMLElement & {loading: boolean};
1501
+ expect(button.loading).to.be.true;
1502
+
1503
+ clickButton(el); // second click while in flight - must not stack
1504
+ await new Promise(resolve => setTimeout(resolve, 20));
1505
+ expect(fetchCalls.length).to.equal(1);
1506
+
1507
+ release!();
1508
+ await waitUntil(() => !button.loading);
1509
+ });
1510
+
1511
+ it('fires zn-theme-submit only once the save carrying the flushed value has genuinely settled', async () => {
1512
+ let releaseFirst: (() => void) | undefined;
1513
+ const gateFirst = new Promise<void>(resolve => { releaseFirst = resolve; });
1514
+ let secondBody: FormData | undefined;
1515
+
1516
+ window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
1517
+ fetchCalls.push({uri: String(uri), init});
1518
+ if (fetchCalls.length === 1) return gateFirst.then(() => new Response('', {status: 200}));
1519
+ secondBody = init!.body as FormData;
1520
+ return Promise.resolve(new Response('', {status: 200}));
1521
+ };
1522
+
1523
+ const el = await fixture(html`
1524
+ <zn-theme-editor
1525
+ src="about:blank" frame-origin="https://site.example"
1526
+ action="/theme/save" debounce="5" save-debounce="5" submit-label="Save theme">
1527
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1528
+ </zn-theme-editor>`);
1529
+
1530
+ const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1531
+ input.value = '50';
1532
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1533
+ await waitUntil(() => fetchCalls.length === 1); // first auto-save now in flight, gated
1534
+
1535
+ input.value = '99';
1536
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1537
+
1538
+ let fired = false;
1539
+ interface Detail {values: {light: Record<string, unknown>; dark: Record<string, unknown>}}
1540
+ let detail: Detail | null = null;
1541
+ el.addEventListener('zn-theme-submit', (e: Event) => {
1542
+ fired = true;
1543
+ detail = (e as CustomEvent<Detail>).detail;
1544
+ });
1545
+
1546
+ clickButton(el); // flushes '99' and bypasses the save debounce, but a save is already in flight
1547
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
1548
+ const button = el.shadowRoot!.querySelector('zn-button')! as HTMLElement & {loading: boolean};
1549
+ expect(button.loading).to.be.true;
1550
+
1551
+ await new Promise(resolve => setTimeout(resolve, 20));
1552
+ expect(fired).to.equal(false); // the flushed value hasn't been POSTed yet
1553
+ expect(fetchCalls.length).to.equal(1);
1554
+
1555
+ releaseFirst!();
1556
+ await waitUntil(() => fetchCalls.length === 2); // follow-up carrying '99'
1557
+ expect(secondBody?.get('light[radius]')).to.equal('99');
1558
+
1559
+ await waitUntil(() => fired);
1560
+ // dark was seeded from the original '8' at mount and is untouched by the light-only edits
1561
+ expect(detail!.values).to.deep.equal({light: {radius: '99'}, dark: {radius: '8'}});
1562
+ expect(button.loading).to.be.false;
1563
+ });
1564
+
1565
+ it('emits no zn-theme-submit when the settling save fails', async () => {
1566
+ window.fetch = () => Promise.resolve(new Response('rejected', {status: 500}));
1567
+
1568
+ const el = await fixture(html`
1569
+ <zn-theme-editor
1570
+ src="about:blank" frame-origin="https://site.example"
1571
+ action="/theme/save" submit-label="Save theme">
1572
+ <zn-input name="radius" label="Radius" value="8"></zn-input>
1573
+ </zn-theme-editor>`);
1574
+
1575
+ let fired = false;
1576
+ el.addEventListener('zn-theme-submit', () => { fired = true; });
1577
+
1578
+ clickButton(el);
1579
+ await waitUntil(() => el.shadowRoot!.querySelector('[part="error"]'));
1580
+ await new Promise(resolve => setTimeout(resolve, 20));
1581
+ expect(fired).to.equal(false);
1582
+ });
1583
+ });
1584
+ });