@kubex/zinc 1.1.92 → 1.1.94

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 (29) hide show
  1. package/dist/custom-elements.json +1738 -110
  2. package/dist/vscode.html-custom-data.json +136 -4
  3. package/dist/web-types.json +307 -4
  4. package/dist/zn.d.ts +298 -2
  5. package/dist/zn.min.js +708 -515
  6. package/docs/pages/components/page-builder.md +22 -0
  7. package/docs/pages/components/schedule-builder.md +345 -0
  8. package/package.json +1 -1
  9. package/src/components/alert/alert.scss +9 -13
  10. package/src/components/chip/chip.scss +1 -1
  11. package/src/components/icon-picker/icon-picker.component.ts +1 -1
  12. package/src/components/linked-select/linked-select.component.ts +22 -5
  13. package/src/components/page/page.scss +13 -7
  14. package/src/components/page-builder/page-builder.component.ts +52 -7
  15. package/src/components/page-builder/page-builder.scss +166 -9
  16. package/src/components/page-builder/page-builder.test.ts +134 -0
  17. package/src/components/page-builder/page.types.ts +23 -3
  18. package/src/components/page-nav/page-nav.scss +9 -1
  19. package/src/components/panel/panel.component.ts +5 -1
  20. package/src/components/priority-list/priority-list.component.ts +1 -0
  21. package/src/components/priority-list/priority-list.scss +2 -1
  22. package/src/components/schedule-builder/index.ts +12 -0
  23. package/src/components/schedule-builder/schedule-builder.component.ts +1543 -0
  24. package/src/components/schedule-builder/schedule-builder.scss +448 -0
  25. package/src/components/schedule-builder/schedule-builder.test.ts +344 -0
  26. package/src/components/toggle/toggle.component.ts +2 -1
  27. package/src/zinc.ts +1 -0
  28. package/docs/superpowers/plans/2026-08-03-theme-editor.md +0 -1536
  29. package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +0 -327
@@ -1,1536 +0,0 @@
1
- # zn-theme-editor Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Build `zn-theme-editor` — slotted form controls on the left that live-push theme values into an embedded `zn-preview-frame`, with a toolbar that switches the preview's light/dark mode and its desktop/tablet/mobile width.
6
-
7
- **Architecture:** `zn-preview-frame` gains two reusable capabilities (a `device` width property and a `setTheme()` method that replays its payload after each frame handshake). `zn-theme-editor` composes a frame internally, harvests values from its default slot by listening on the slot element, and pushes them over the existing `hp-preview` postMessage protocol. The frame stays the only thing that touches `contentWindow`, so origin checking lives in exactly one place.
8
-
9
- **Tech Stack:** Lit 3 + TypeScript, SCSS via `esbuild-sass-plugin`, `@open-wc/testing` on Web Test Runner + Playwright, lucide icons via `zn-icon`.
10
-
11
- **Spec:** `docs/superpowers/specs/2026-08-03-theme-editor-design.md`
12
-
13
- ## Global Constraints
14
-
15
- - **Never run `git` write commands.** The user's global rules prohibit commit/reset/checkout/stash. Read-only git (`log`, `diff`, `status`) is fine. There are no commit steps in this plan; each task ends with a verification step instead.
16
- - **Never run `npm run build`.** The user runs `npm run watch`, which rebuilds `dist/` incrementally; a full build kills it. Tests import `../../../dist/zn.min.js`, so after editing source, wait for the watch rebuild before running tests.
17
- - **Run tests with `npx web-test-runner --group <name>`.** `npm run test:component` is watch-mode only and hangs non-interactive shells. Group name = test filename stem (`theme-editor`, `preview-frame`). Piped output can appear empty in non-TTY shells — redirect to a file and read it.
18
- - **Lint only touched files:** `npx eslint <paths>`. Repo-wide `npm run lint` has ~485 pre-existing problems and `npm test` ~12 pre-existing failures (zn-navbar, zn-page, zn-select, zn-sp, flow modules) — those are not yours.
19
- - **Component file layout** (per CLAUDE.md): `src/components/<name>/<name>.component.ts`, `<name>.scss`, `<name>.test.ts`, `index.ts`.
20
- - **`useDefineForClassFields: false`** and `experimentalDecorators: true` — standard Lit decorators only.
21
- - **Comments minimal.** Explain only non-obvious decisions. Match surrounding style.
22
- - **`zn-button` overrides `click()`** and dispatches nothing. In tests, dispatch `new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})`.
23
- - Device widths are fixed at **desktop = `100%`, tablet = `768px`, mobile = `390px`**.
24
- - Debounce defaults: **push `150`ms, save `1000`ms**.
25
- - Controls width CSS property: **`--zn-theme-editor-controls-width`, default `280px`**.
26
-
27
- ---
28
-
29
- ## File Structure
30
-
31
- | File | Responsibility |
32
- |---|---|
33
- | `src/components/preview-frame/preview-frame.component.ts` | *Modify* — add `device`, `setTheme()`, stage wrapper, empty-`dataUri` guard |
34
- | `src/components/preview-frame/preview-frame.scss` | *Modify* — `.preview__stage` rules |
35
- | `src/components/preview-frame/preview-frame.test.ts` | *Modify* — append three tests |
36
- | `src/events/zn-theme-change.ts` | *Create* — `ZnThemeChangeEvent` type + global map entry |
37
- | `src/events/events.ts` | *Modify* — re-export the new type |
38
- | `src/components/theme-editor/theme-editor.component.ts` | *Create* — the component |
39
- | `src/components/theme-editor/theme-editor.scss` | *Create* — two-column layout, toolbar, error strip |
40
- | `src/components/theme-editor/theme-editor.test.ts` | *Create* — six tests |
41
- | `src/components/theme-editor/index.ts` | *Create* — export + `define()` |
42
- | `src/zinc.ts` | *Modify* — export `ThemeEditor` |
43
- | `docs/pages/components/theme-editor.md` | *Create* — docs page |
44
- | `docs/pages/components/preview-frame-demo.njk` | *Modify* — handle `hp-preview:theme` |
45
-
46
- Tasks 1–2 harden the frame and are independently useful. Task 3 lands a working editor (layout + push). Task 4 adds the toolbar. Task 5 adds persistence. Task 6 is docs.
47
-
48
- ---
49
-
50
- ### Task 1: `zn-preview-frame` — `device` width
51
-
52
- **Files:**
53
- - Modify: `src/components/preview-frame/preview-frame.component.ts` (add property; wrap iframe in `.preview__stage` in `render()`)
54
- - Modify: `src/components/preview-frame/preview-frame.scss`
55
- - Test: `src/components/preview-frame/preview-frame.test.ts` (append)
56
-
57
- **Interfaces:**
58
- - Consumes: nothing.
59
- - Produces: `device: 'desktop' | 'tablet' | 'mobile'` reflected property on `ZnPreviewFrame`, default `'desktop'`. Shadow DOM gains a `.preview__stage` div (also `part="stage"`) between `.preview` and the `iframe`.
60
-
61
- - [ ] **Step 1: Write the failing tests**
62
-
63
- Append inside the existing `describe('<zn-preview-frame>')` block in `src/components/preview-frame/preview-frame.test.ts`:
64
-
65
- ```ts
66
- it('defaults to a full-width desktop stage', async () => {
67
- const el = await fixture(FIXTURE);
68
- const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
69
- expect(stage.style.width).to.equal('100%');
70
- });
71
-
72
- it('constrains the stage to the tablet width', async () => {
73
- const el = await fixture(html`
74
- <zn-preview-frame
75
- src="about:blank"
76
- frame-origin="https://site.example"
77
- data-uri="/payload"
78
- device="tablet"></zn-preview-frame>`);
79
-
80
- const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
81
- expect(stage.style.width).to.equal('768px');
82
- // the iframe still sizes off the stage, so zoom maths is unaffected
83
- const iframe = el.shadowRoot!.querySelector('iframe')!;
84
- expect(iframe.style.width).to.equal('100%');
85
- });
86
-
87
- it('constrains the stage to the mobile width', async () => {
88
- const el = await fixture(html`
89
- <zn-preview-frame
90
- src="about:blank"
91
- frame-origin="https://site.example"
92
- data-uri="/payload"
93
- device="mobile"></zn-preview-frame>`);
94
-
95
- const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
96
- expect(stage.style.width).to.equal('390px');
97
- });
98
- ```
99
-
100
- - [ ] **Step 2: Run the tests to verify they fail**
101
-
102
- Run: `npx web-test-runner --group preview-frame > /tmp/pf.log 2>&1; cat /tmp/pf.log`
103
-
104
- Expected: the three new tests fail — `.preview__stage` does not exist, so `querySelector` returns `null` and the non-null assertion throws `Cannot read properties of null`. The pre-existing preview-frame tests must all still pass.
105
-
106
- - [ ] **Step 3: Add the property and the stage wrapper**
107
-
108
- In `src/components/preview-frame/preview-frame.component.ts`, add a module-level constant above the class declaration (after the imports):
109
-
110
- ```ts
111
- const DEVICE_WIDTHS = {
112
- desktop: '100%',
113
- tablet: '768px',
114
- mobile: '390px',
115
- } as const;
116
-
117
- export type PreviewFrameDevice = keyof typeof DEVICE_WIDTHS;
118
- ```
119
-
120
- Add the property next to the existing `zoom` / `minHeight` properties:
121
-
122
- ```ts
123
- /**
124
- * Constrains and centres the preview to a device width: `desktop` (100%),
125
- * `tablet` (768px) or `mobile` (390px). The iframe element itself is
126
- * narrowed, so the embedded page's own media queries fire.
127
- */
128
- @property({reflect: true}) device: PreviewFrameDevice = 'desktop';
129
- ```
130
-
131
- In `render()`, wrap the iframe. Replace the existing returned template with:
132
-
133
- ```ts
134
- return html`
135
- <div part="base" class="preview" style="${styleMap(containerStyles)}">
136
- <div part="stage" class="preview__stage"
137
- style="${styleMap({width: DEVICE_WIDTHS[this.device] ?? DEVICE_WIDTHS.desktop})}">
138
- <iframe part="iframe"
139
- src="${this.src}"
140
- title="Payment form preview"
141
- allow="local-network-access"
142
- style="${styleMap(iframeStyles)}"></iframe>
143
- </div>
144
- ${this.error ? html`
145
- <div part="error" class="preview__error">${this.error}</div>` : ''}
146
- </div>`;
147
- ```
148
-
149
- Leave `iframeStyles` and `containerStyles` exactly as they are — the iframe's percentage width now resolves against the stage instead of `.preview`, which is what keeps the zoom behaviour and its existing assertions intact.
150
-
151
- - [ ] **Step 4: Add the stage styles**
152
-
153
- In `src/components/preview-frame/preview-frame.scss`, add after the `.preview` rule:
154
-
155
- ```scss
156
- .preview__stage {
157
- height: 100%;
158
- max-width: 100%;
159
- margin: 0 auto;
160
- overflow: hidden;
161
- }
162
- ```
163
-
164
- `max-width: 100%` stops a tablet width from overflowing a narrower panel; `margin: 0 auto` centres the constrained widths and is a no-op at 100%.
165
-
166
- - [ ] **Step 5: Run the tests to verify they pass**
167
-
168
- Wait for the watch rebuild of `dist/zn.min.js` to finish, then:
169
-
170
- Run: `npx web-test-runner --group preview-frame > /tmp/pf.log 2>&1; cat /tmp/pf.log`
171
-
172
- Expected: all preview-frame tests pass, including the two pre-existing zoom tests (`zooms the content out…` asserting `250%` / `1500px` / container `600px`, and `renders at natural size by default` asserting `100%` / `480px`). If either zoom test broke, the stage is sized or nested wrongly — fix before moving on.
173
-
174
- - [ ] **Step 6: Verify lint and types**
175
-
176
- Run: `npx eslint src/components/preview-frame/preview-frame.component.ts src/components/preview-frame/preview-frame.test.ts`
177
- Run: `npx tsc --noEmit -p tsconfig.json`
178
-
179
- Expected: no errors in the touched files.
180
-
181
- ---
182
-
183
- ### Task 2: `zn-preview-frame` — `setTheme()` and the empty-`dataUri` guard
184
-
185
- **Files:**
186
- - Modify: `src/components/preview-frame/preview-frame.component.ts`
187
- - Test: `src/components/preview-frame/preview-frame.test.ts` (append)
188
-
189
- **Interfaces:**
190
- - Consumes: Task 1's `device`/stage (no direct dependency, same file).
191
- - Produces: `setTheme(theme: Record<string, unknown>): void` on `ZnPreviewFrame`. Posts `{type: 'hp-preview:theme', ...theme}` to the frame at `frameOrigin`, stores the payload, and re-posts it after every `hp-preview:ready` handshake. Also: `_sendConfig()` becomes a no-op when `dataUri` is empty.
192
-
193
- - [ ] **Step 1: Write the failing tests**
194
-
195
- Append inside the existing `describe('<zn-preview-frame>')` block:
196
-
197
- ```ts
198
- it('setTheme() posts an hp-preview:theme message', async () => {
199
- const el = await fixture(FIXTURE);
200
- const iframe = el.shadowRoot!.querySelector('iframe')!;
201
- const posted: {msg: Record<string, unknown>; origin: string}[] = [];
202
- (iframe.contentWindow as {postMessage: (msg: Record<string, unknown>, origin: string) => void}).postMessage =
203
- (msg: Record<string, unknown>, origin: string) => posted.push({msg, origin});
204
-
205
- (el as HTMLElement & {setTheme: (t: Record<string, unknown>) => void})
206
- .setTheme({mode: 'dark', values: {background: '#101014'}});
207
-
208
- await waitUntil(() => posted.length === 1);
209
- expect(posted[0].origin).to.equal('https://site.example');
210
- expect(posted[0].msg['type']).to.equal('hp-preview:theme');
211
- expect(posted[0].msg['mode']).to.equal('dark');
212
- expect(posted[0].msg['values']).to.deep.equal({background: '#101014'});
213
- });
214
-
215
- it('replays the stored theme after a ready handshake', async () => {
216
- const el = await fixture(FIXTURE);
217
- const iframe = el.shadowRoot!.querySelector('iframe')!;
218
- const posted: Record<string, unknown>[] = [];
219
- (iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage =
220
- (msg: Record<string, unknown>) => posted.push(msg);
221
-
222
- (el as HTMLElement & {setTheme: (t: Record<string, unknown>) => void})
223
- .setTheme({mode: 'light', values: {background: '#ffffff'}});
224
- await waitUntil(() => posted.length === 1);
225
-
226
- // a frame reload re-announces readiness; the theme must survive it
227
- ready(el);
228
-
229
- await waitUntil(() => posted.length === 3);
230
- expect(posted[1]['type']).to.equal('hp-preview:config');
231
- expect(posted[2]['type']).to.equal('hp-preview:theme');
232
- expect(posted[2]['values']).to.deep.equal({background: '#ffffff'});
233
- });
234
-
235
- it('skips the config fetch when data-uri is empty', async () => {
236
- const el = await fixture(html`
237
- <zn-preview-frame src="about:blank" frame-origin="https://site.example"></zn-preview-frame>`);
238
- const iframe = el.shadowRoot!.querySelector('iframe')!;
239
- const posted: Record<string, unknown>[] = [];
240
- (iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage =
241
- (msg: Record<string, unknown>) => posted.push(msg);
242
-
243
- (el as HTMLElement & {setTheme: (t: Record<string, unknown>) => void}).setTheme({mode: 'light', values: {}});
244
- ready(el);
245
-
246
- await new Promise(resolve => setTimeout(resolve, 100));
247
- expect(fetchCalls.length).to.equal(0);
248
- expect(el.shadowRoot!.querySelector('[part="error"]')).to.not.exist;
249
- // theme still replays even with no config to send
250
- expect(posted.filter(m => m['type'] === 'hp-preview:theme').length).to.equal(2);
251
- });
252
- ```
253
-
254
- Note the ordering assertion in the second test: config is posted before theme, because the embed generally needs its config applied before a theme lands on top of it.
255
-
256
- - [ ] **Step 2: Run the tests to verify they fail**
257
-
258
- Run: `npx web-test-runner --group preview-frame > /tmp/pf.log 2>&1; cat /tmp/pf.log`
259
-
260
- Expected: all three fail — `setTheme` is not a function. The empty-`data-uri` test additionally shows the bug being fixed: a `fetch('')` call and an error overlay.
261
-
262
- - [ ] **Step 3: Add the theme state and `setTheme()`**
263
-
264
- In `src/components/preview-frame/preview-frame.component.ts`, add a field next to `_generation`:
265
-
266
- ```ts
267
- private _theme: Record<string, unknown> | undefined;
268
- ```
269
-
270
- Add the public method next to `refresh()`:
271
-
272
- ```ts
273
- /**
274
- * Pushes a theme payload into the preview. The payload is retained and
275
- * re-posted after every ready handshake, so a frame reload doesn't drop an
276
- * in-progress theme.
277
- */
278
- setTheme(theme: Record<string, unknown>) {
279
- this._theme = theme;
280
- this._postTheme();
281
- }
282
-
283
- private _postTheme() {
284
- if (!this._theme) return;
285
- this.frame?.contentWindow?.postMessage(
286
- {type: 'hp-preview:theme', ...this._theme},
287
- this.frameOrigin
288
- );
289
- }
290
- ```
291
-
292
- - [ ] **Step 4: Replay the theme after the handshake**
293
-
294
- In `_onMessage`, change the `hp-preview:ready` branch from:
295
-
296
- ```ts
297
- case 'hp-preview:ready':
298
- void this._sendConfig();
299
- break;
300
- ```
301
-
302
- to:
303
-
304
- ```ts
305
- case 'hp-preview:ready':
306
- // config first: the embed applies the theme on top of a rendered page
307
- void this._sendConfig().then(() => this._postTheme());
308
- break;
309
- ```
310
-
311
- `_sendConfig()` already returns a promise and swallows its own errors into the overlay, so the theme replays either way.
312
-
313
- - [ ] **Step 5: Guard the empty `data-uri`**
314
-
315
- At the top of `_sendConfig()`, before `const generation = ++this._generation;`, insert:
316
-
317
- ```ts
318
- // A theme-editor-only setup has no config endpoint; fetch('') would return
319
- // the host page's HTML and fail JSON parsing into the error overlay.
320
- if (!this.dataUri) return;
321
- ```
322
-
323
- - [ ] **Step 6: Run the tests to verify they pass**
324
-
325
- Wait for the watch rebuild, then:
326
-
327
- Run: `npx web-test-runner --group preview-frame > /tmp/pf.log 2>&1; cat /tmp/pf.log`
328
-
329
- Expected: all preview-frame tests pass, new and pre-existing. In particular `refresh() re-fetches the payload…` must still pass — it sets `data-uri="/payload"`, so the guard does not affect it.
330
-
331
- - [ ] **Step 7: Verify lint and types**
332
-
333
- Run: `npx eslint src/components/preview-frame/preview-frame.component.ts src/components/preview-frame/preview-frame.test.ts`
334
- Run: `npx tsc --noEmit -p tsconfig.json`
335
-
336
- Expected: clean for touched files.
337
-
338
- ---
339
-
340
- ### Task 3: `zn-theme-editor` — component, layout, and value push
341
-
342
- **Files:**
343
- - Create: `src/events/zn-theme-change.ts`
344
- - Modify: `src/events/events.ts`
345
- - Create: `src/components/theme-editor/theme-editor.component.ts`
346
- - Create: `src/components/theme-editor/theme-editor.scss`
347
- - Create: `src/components/theme-editor/index.ts`
348
- - Modify: `src/zinc.ts`
349
- - Test: `src/components/theme-editor/theme-editor.test.ts`
350
-
351
- **Interfaces:**
352
- - Consumes: `ZnPreviewFrame.setTheme(theme)` from Task 2.
353
- - Produces:
354
- - `ZnThemeEditor` with properties `src`, `frameOrigin` (`frame-origin`), `dataUri` (`data-uri`), `mode` (`'light' | 'dark'`, reflected, default `'light'`), `device` (`'desktop' | 'tablet' | 'mobile'`, reflected, default `'desktop'`), `minHeight` (`min-height`, number, `480`), `debounce` (number, `150`).
355
- - Getter `values: Record<string, unknown>`.
356
- - Query `frame: ZnPreviewFrame`.
357
- - Protected method `_push(): void` (harvest → `frame.setTheme` → emit).
358
- - Event `zn-theme-change` with detail `{values: Record<string, unknown>; mode: 'light' | 'dark'; device: 'desktop' | 'tablet' | 'mobile'}`.
359
- - `action` and `saveDebounce` are added in Task 5; do not add them here.
360
-
361
- - [ ] **Step 1: Write the failing tests**
362
-
363
- Create `src/components/theme-editor/theme-editor.test.ts`:
364
-
365
- ```ts
366
- import '../../../dist/zn.min.js';
367
- import {expect, fixture, html, waitUntil} from '@open-wc/testing';
368
-
369
- type ThemeCall = Record<string, unknown>;
370
-
371
- interface Framelike extends HTMLElement {
372
- setTheme: (theme: ThemeCall) => void;
373
- }
374
-
375
- /**
376
- * Records what the editor pushes by replacing the internal frame's setTheme.
377
- * Returns the recorded calls; the first is the initial push from firstUpdated.
378
- */
379
- async function spyOnFrame(el: Element): Promise<ThemeCall[]> {
380
- const frame = el.shadowRoot!.querySelector('zn-preview-frame') as Framelike;
381
- const calls: ThemeCall[] = [];
382
- frame.setTheme = (theme: ThemeCall) => calls.push(theme);
383
- return calls;
384
- }
385
-
386
- describe('<zn-theme-editor>', () => {
387
- const FIXTURE = html`
388
- <zn-theme-editor src="about:blank" frame-origin="https://site.example" debounce="10">
389
- <zn-input name="radius" label="Radius" value="8"></zn-input>
390
- </zn-theme-editor>`;
391
-
392
- it('renders and is accessible', async () => {
393
- const el = await fixture(FIXTURE);
394
- await expect(el).to.be.accessible();
395
- });
396
-
397
- it('renders a preview frame and forwards its configuration', async () => {
398
- const el = await fixture(html`
399
- <zn-theme-editor
400
- src="about:blank"
401
- frame-origin="https://site.example"
402
- data-uri="/theme/config"
403
- min-height="600"></zn-theme-editor>`);
404
-
405
- const frame = el.shadowRoot!.querySelector('zn-preview-frame')!;
406
- expect(frame).to.exist;
407
- expect((frame as HTMLElement & {src: string}).src).to.equal('about:blank');
408
- expect((frame as HTMLElement & {frameOrigin: string}).frameOrigin).to.equal('https://site.example');
409
- expect((frame as HTMLElement & {dataUri: string}).dataUri).to.equal('/theme/config');
410
- expect((frame as HTMLElement & {minHeight: number}).minHeight).to.equal(600);
411
- });
412
-
413
- it('pushes the authored control defaults on first render', async () => {
414
- // firstUpdated fires inside fixture(), before a per-instance spy could be
415
- // installed — so patch the prototype for the duration of this test
416
- const proto = customElements.get('zn-preview-frame')!.prototype as unknown as Framelike;
417
- const original = proto.setTheme;
418
- const calls: ThemeCall[] = [];
419
- proto.setTheme = (theme: ThemeCall) => calls.push(theme);
420
-
421
- try {
422
- const el = await fixture(html`
423
- <zn-theme-editor src="about:blank" frame-origin="https://site.example">
424
- <zn-input name="radius" label="Radius" value="8"></zn-input>
425
- </zn-theme-editor>`);
426
-
427
- expect(calls.length).to.equal(1);
428
- expect(calls[0]['mode']).to.equal('light');
429
- expect(calls[0]['values']).to.deep.equal({radius: '8'});
430
- expect((el as HTMLElement & {values: Record<string, unknown>}).values)
431
- .to.deep.equal({radius: '8'});
432
- } finally {
433
- proto.setTheme = original;
434
- }
435
- });
436
-
437
- it('harvests and pushes updated values when a control changes', async () => {
438
- const el = await fixture(FIXTURE);
439
- const calls = await spyOnFrame(el);
440
-
441
- const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
442
- input.value = '16';
443
- input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
444
-
445
- await waitUntil(() => calls.length === 1);
446
- expect(calls[0]['mode']).to.equal('light');
447
- expect(calls[0]['values']).to.deep.equal({radius: '16'});
448
- });
449
-
450
- it('emits zn-theme-change with values, mode and device', async () => {
451
- const el = await fixture(FIXTURE);
452
- await spyOnFrame(el);
453
-
454
- let detail: {values: Record<string, unknown>; mode: string; device: string} | null = null;
455
- el.addEventListener('zn-theme-change', (e: Event) => {
456
- detail = (e as CustomEvent<{values: Record<string, unknown>; mode: string; device: string}>).detail;
457
- });
458
-
459
- const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
460
- input.value = '24';
461
- input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
462
-
463
- await waitUntil(() => detail !== null);
464
- expect(detail!.values).to.deep.equal({radius: '24'});
465
- expect(detail!.mode).to.equal('light');
466
- expect(detail!.device).to.equal('desktop');
467
- });
468
-
469
- it('reads booleans from checkboxes and skips disabled and unnamed controls', async () => {
470
- const el = await fixture(html`
471
- <zn-theme-editor src="about:blank" frame-origin="https://site.example">
472
- <zn-input name="radius" label="Radius" value="8"></zn-input>
473
- <zn-checkbox name="rounded" checked></zn-checkbox>
474
- <zn-input name="ignored" label="Ignored" value="x" disabled></zn-input>
475
- <zn-input label="No name" value="y"></zn-input>
476
- </zn-theme-editor>`);
477
-
478
- expect((el as HTMLElement & {values: Record<string, unknown>}).values)
479
- .to.deep.equal({radius: '8', rounded: true});
480
- });
481
-
482
- it('renders the footer slot only when it is used', async () => {
483
- const bare = await fixture(html`
484
- <zn-theme-editor src="about:blank" frame-origin="https://site.example"></zn-theme-editor>`);
485
- expect(bare.shadowRoot!.querySelector('[part="footer"]')).to.not.exist;
486
-
487
- const withFooter = await fixture(html`
488
- <zn-theme-editor src="about:blank" frame-origin="https://site.example">
489
- <zn-button slot="footer">Save</zn-button>
490
- </zn-theme-editor>`);
491
- expect(withFooter.shadowRoot!.querySelector('[part="footer"]')).to.exist;
492
- });
493
- });
494
- ```
495
-
496
- - [ ] **Step 2: Run the tests to verify they fail**
497
-
498
- Run: `npx web-test-runner --group theme-editor > /tmp/te.log 2>&1; cat /tmp/te.log`
499
-
500
- Expected: every test fails. `zn-theme-editor` is not a registered element, so `fixture` yields an unupgraded `HTMLElement` with a `null` `shadowRoot`.
501
-
502
- If the runner reports "no tests matched group theme-editor", the group is derived from the test filename — confirm the file is at `src/components/theme-editor/theme-editor.test.ts`.
503
-
504
- - [ ] **Step 3: Add the event type**
505
-
506
- Create `src/events/zn-theme-change.ts`:
507
-
508
- ```ts
509
- export type ZnThemeChangeEvent = CustomEvent<{
510
- values: Record<string, unknown>;
511
- mode: 'light' | 'dark';
512
- device: 'desktop' | 'tablet' | 'mobile';
513
- }>;
514
-
515
- declare global {
516
- interface GlobalEventHandlersEventMap {
517
- 'zn-theme-change': ZnThemeChangeEvent;
518
- }
519
- }
520
- ```
521
-
522
- Add to `src/events/events.ts`, alongside the other re-exports:
523
-
524
- ```ts
525
- export type {ZnThemeChangeEvent} from './zn-theme-change';
526
- ```
527
-
528
- A dedicated event is required: `src/events/zn-change.ts` types `zn-change`'s detail as `Record<PropertyKey, never>` for the whole library, so it cannot carry a payload. This mirrors `zn-flow-change`.
529
-
530
- - [ ] **Step 4: Write the component**
531
-
532
- Create `src/components/theme-editor/theme-editor.component.ts`:
533
-
534
- ```ts
535
- import {type CSSResultGroup, html, nothing, unsafeCSS} from 'lit';
536
- import {HasSlotController} from '../../internal/slot';
537
- import {property, query, state} from 'lit/decorators.js';
538
- import ZincElement from '../../internal/zinc-element';
539
- import ZnPreviewFrame from '../preview-frame';
540
-
541
- import styles from './theme-editor.scss';
542
-
543
- export type ThemeEditorMode = 'light' | 'dark';
544
- export type ThemeEditorDevice = 'desktop' | 'tablet' | 'mobile';
545
-
546
- // Controls whose state lives on `checked` rather than `value`.
547
- const BOOLEAN_CONTROLS = new Set(['zn-checkbox', 'zn-toggle']);
548
-
549
- interface HarvestableControl extends HTMLElement {
550
- name?: string;
551
- value?: unknown;
552
- checked?: boolean;
553
- disabled?: boolean;
554
- type?: string;
555
- }
556
-
557
- /**
558
- * @summary A theme editor: slotted form controls drive a live preview frame,
559
- * with a toolbar for the preview's light/dark mode and device width.
560
- * @documentation https://zinc.style/components/theme-editor
561
- * @status experimental
562
- * @since 1.0
563
- *
564
- * @dependency zn-preview-frame
565
- * @dependency zn-icon
566
- *
567
- * @event zn-theme-change - Emitted when the values, mode or device change.
568
- * @event zn-error - Emitted when a save fails.
569
- *
570
- * @slot - The theme controls, rendered in the left-hand column.
571
- * @slot footer - Actions pinned beneath the controls.
572
- *
573
- * @csspart base - The component's base wrapper.
574
- * @csspart controls - The left-hand controls column.
575
- * @csspart footer - The footer wrapper beneath the controls.
576
- * @csspart toolbar - The device and mode switcher above the preview.
577
- * @csspart preview - The preview column.
578
- * @csspart error - The inline error strip.
579
- *
580
- * @cssproperty --zn-theme-editor-controls-width - Width of the controls column.
581
- */
582
- export default class ZnThemeEditor extends ZincElement {
583
- static styles: CSSResultGroup = unsafeCSS(styles);
584
- static dependencies = {
585
- 'zn-preview-frame': ZnPreviewFrame,
586
- };
587
-
588
- /** URL of the preview shell page; forwarded to the frame. */
589
- @property() src = '';
590
-
591
- /** Expected origin of the iframe; forwarded to the frame. */
592
- @property({attribute: 'frame-origin'}) frameOrigin = '';
593
-
594
- /** Optional endpoint returning the base hp-preview:config payload. */
595
- @property({attribute: 'data-uri'}) dataUri = '';
596
-
597
- /** Which mode the preview renders in. Travels in the theme payload. */
598
- @property({reflect: true}) mode: ThemeEditorMode = 'light';
599
-
600
- /** Preview viewport width. Resizes the frame only; not part of the payload. */
601
- @property({reflect: true}) device: ThemeEditorDevice = 'desktop';
602
-
603
- /** Visible height of the preview panel, in pixels. */
604
- @property({type: Number, attribute: 'min-height'}) minHeight = 480;
605
-
606
- /** Debounce in ms between a control change and the push to the preview. */
607
- @property({type: Number}) debounce = 150;
608
-
609
- @query('zn-preview-frame') frame: ZnPreviewFrame;
610
-
611
- @query('slot:not([name])') private controlsSlot: HTMLSlotElement;
612
-
613
- @state() protected error = '';
614
-
615
- private readonly hasSlotController = new HasSlotController(this, 'footer');
616
-
617
- private _pushTimer?: number;
618
-
619
- /** The current values harvested from the slotted controls. */
620
- get values(): Record<string, unknown> {
621
- const values: Record<string, unknown> = {};
622
- const roots = this.controlsSlot?.assignedElements({flatten: true}) ?? [];
623
-
624
- for (const root of roots) {
625
- const candidates = [root, ...Array.from(root.querySelectorAll('[name]'))];
626
- for (const candidate of candidates) {
627
- const control = candidate as HarvestableControl;
628
- if (!control.getAttribute?.('name') || control.disabled) continue;
629
- const tag = control.tagName.toLowerCase();
630
- values[control.getAttribute('name')!] =
631
- BOOLEAN_CONTROLS.has(tag) || control.type === 'checkbox'
632
- ? !!control.checked
633
- : control.value;
634
- }
635
- }
636
-
637
- return values;
638
- }
639
-
640
- disconnectedCallback() {
641
- super.disconnectedCallback();
642
- if (this._pushTimer) window.clearTimeout(this._pushTimer);
643
- }
644
-
645
- protected firstUpdated() {
646
- // Push the authored defaults immediately so the preview never renders
647
- // un-themed and then snaps to the real values. The frame retains the
648
- // payload and replays it after its ready handshake.
649
- this._push();
650
- }
651
-
652
- /** Harvests the controls, pushes them into the preview, and announces it. */
653
- protected _push() {
654
- const values = this.values;
655
- this.frame?.setTheme({mode: this.mode, values});
656
- this._announce(values);
657
- }
658
-
659
- protected _announce(values: Record<string, unknown> = this.values) {
660
- this.emit('zn-theme-change', {detail: {values, mode: this.mode, device: this.device}});
661
- }
662
-
663
- protected _fail(message: string) {
664
- this.error = message;
665
- this.emit('zn-error', {detail: {message}});
666
- }
667
-
668
- private readonly _onControlChange = () => {
669
- if (this._pushTimer) window.clearTimeout(this._pushTimer);
670
- this._pushTimer = window.setTimeout(() => {
671
- this._pushTimer = undefined;
672
- this._push();
673
- }, this.debounce);
674
- };
675
-
676
- private readonly _onSlotChange = () => {
677
- this._push();
678
- };
679
-
680
- private readonly _onFrameError = (e: CustomEvent<{message?: string}>) => {
681
- // zn-error already bubbles and composes out to the host; just display it.
682
- this.error = e.detail.message ?? 'Preview failed to render';
683
- };
684
-
685
- render() {
686
- return html`
687
- <div part="base" class="editor">
688
- <div part="controls" class="editor__controls">
689
- <div class="editor__fields">
690
- <slot @slotchange="${this._onSlotChange}"
691
- @zn-change="${this._onControlChange}"
692
- @zn-input="${this._onControlChange}"
693
- @change="${this._onControlChange}"
694
- @input="${this._onControlChange}"></slot>
695
- </div>
696
- ${this.hasSlotController.test('footer') ? html`
697
- <div part="footer" class="editor__footer">
698
- <slot name="footer"></slot>
699
- </div>` : nothing}
700
- </div>
701
-
702
- <div part="preview" class="editor__preview">
703
- ${this.error ? html`
704
- <div part="error" class="editor__error">${this.error}</div>` : nothing}
705
- <zn-preview-frame
706
- src="${this.src}"
707
- frame-origin="${this.frameOrigin}"
708
- data-uri="${this.dataUri}"
709
- device="${this.device}"
710
- min-height="${this.minHeight}"
711
- @zn-error="${this._onFrameError}"></zn-preview-frame>
712
- </div>
713
- </div>`;
714
- }
715
- }
716
- ```
717
-
718
- Two details that matter:
719
-
720
- - The change listeners sit on the **`<slot>` element**, not on the host. Events from assigned light-DOM nodes propagate through the slot in the flattened tree, so this catches exactly the slotted controls and nothing from the editor's own shadow DOM.
721
- - `values` reads the `name` **attribute** via `getAttribute`, not the `.name` property — Zinc controls do not reflect `name`, and the slotted-markup design means authors always write it as an attribute.
722
-
723
- - [ ] **Step 5: Write the styles**
724
-
725
- Create `src/components/theme-editor/theme-editor.scss`:
726
-
727
- ```scss
728
- @use "../../wc";
729
-
730
- :host {
731
- display: block;
732
- --zn-theme-editor-controls-width: 280px;
733
- }
734
-
735
- .editor {
736
- display: flex;
737
- align-items: flex-start;
738
- gap: var(--zn-spacing-medium);
739
- }
740
-
741
- .editor__controls {
742
- display: flex;
743
- flex-direction: column;
744
- gap: var(--zn-spacing-medium);
745
- flex: 0 0 var(--zn-theme-editor-controls-width);
746
- max-width: 100%;
747
- }
748
-
749
- .editor__fields {
750
- display: flex;
751
- flex-direction: column;
752
- gap: var(--zn-spacing-small);
753
- }
754
-
755
- .editor__footer {
756
- display: flex;
757
- gap: var(--zn-spacing-small);
758
- padding-top: var(--zn-spacing-small);
759
- border-top: 1px solid rgb(var(--zn-border-color));
760
- }
761
-
762
- .editor__preview {
763
- flex: 1 1 auto;
764
- min-width: 0;
765
- display: flex;
766
- flex-direction: column;
767
- gap: var(--zn-spacing-small);
768
- }
769
-
770
- .editor__error {
771
- padding: var(--zn-spacing-small);
772
- border-radius: 4px;
773
- background: rgba(var(--zn-error), 0.1);
774
- border: 1px solid rgb(var(--zn-error));
775
- color: rgb(var(--zn-error));
776
- font-size: 0.8125rem;
777
- }
778
-
779
- @media (max-width: 768px) {
780
- .editor {
781
- flex-direction: column;
782
- }
783
-
784
- .editor__controls {
785
- flex-basis: auto;
786
- width: 100%;
787
- }
788
-
789
- .editor__preview {
790
- width: 100%;
791
- }
792
- }
793
- ```
794
-
795
- - [ ] **Step 6: Register and export the component**
796
-
797
- Create `src/components/theme-editor/index.ts`:
798
-
799
- ```ts
800
- import ZnThemeEditor from './theme-editor.component';
801
-
802
- export * from './theme-editor.component';
803
- export default ZnThemeEditor;
804
-
805
- ZnThemeEditor.define('zn-theme-editor');
806
-
807
- declare global {
808
- interface HTMLElementTagNameMap {
809
- 'zn-theme-editor': ZnThemeEditor;
810
- }
811
- }
812
- ```
813
-
814
- In `src/zinc.ts`, add an export next to the existing `PreviewFrame` line (currently line 113):
815
-
816
- ```ts
817
- export { default as ThemeEditor } from './components/theme-editor';
818
- ```
819
-
820
- - [ ] **Step 7: Run the tests to verify they pass**
821
-
822
- Wait for the watch rebuild of `dist/zn.min.js`, then:
823
-
824
- Run: `npx web-test-runner --group theme-editor > /tmp/te.log 2>&1; cat /tmp/te.log`
825
-
826
- Expected: all eight tests in the file pass.
827
-
828
- If `renders and is accessible` fails on a colour-contrast rule inside the error strip, that strip is not rendered in the accessible-check fixture (no error is set), so the violation is elsewhere — read the axe output rather than guessing.
829
-
830
- - [ ] **Step 8: Verify lint and types**
831
-
832
- Run: `npx eslint src/components/theme-editor/ src/events/zn-theme-change.ts src/events/events.ts src/zinc.ts`
833
- Run: `npx tsc --noEmit -p tsconfig.json`
834
-
835
- Expected: clean for touched files.
836
-
837
- ---
838
-
839
- ### Task 4: `zn-theme-editor` — device and mode toolbar
840
-
841
- **Files:**
842
- - Modify: `src/components/theme-editor/theme-editor.component.ts`
843
- - Modify: `src/components/theme-editor/theme-editor.scss`
844
- - Test: `src/components/theme-editor/theme-editor.test.ts` (append)
845
-
846
- **Interfaces:**
847
- - Consumes: `_push()`, `_announce()`, `mode`, `device` from Task 3.
848
- - Produces: a `part="toolbar"` element in the shadow DOM containing four native buttons: three device buttons matched by `[data-device="desktop|tablet|mobile"]` and one mode toggle matched by `[data-mode-toggle]`. Each carries `aria-label`; device buttons carry `aria-pressed`.
849
-
850
- - [ ] **Step 1: Write the failing tests**
851
-
852
- Append inside `describe('<zn-theme-editor>')`:
853
-
854
- ```ts
855
- it('device buttons set the frame device without re-pushing the theme', async () => {
856
- const el = await fixture(FIXTURE);
857
- const calls = await spyOnFrame(el);
858
-
859
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="mobile"]')!.click();
860
- await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
861
-
862
- expect((el as HTMLElement & {device: string}).device).to.equal('mobile');
863
- expect(el.getAttribute('device')).to.equal('mobile');
864
- const frame = el.shadowRoot!.querySelector('zn-preview-frame')!;
865
- expect((frame as HTMLElement & {device: string}).device).to.equal('mobile');
866
- // resizing the frame is not a theme change
867
- expect(calls.length).to.equal(0);
868
- });
869
-
870
- it('announces a device change on zn-theme-change', async () => {
871
- const el = await fixture(FIXTURE);
872
- let detail: {device: string} | null = null;
873
- el.addEventListener('zn-theme-change', (e: Event) => {
874
- detail = (e as CustomEvent<{device: string}>).detail;
875
- });
876
-
877
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="tablet"]')!.click();
878
-
879
- await waitUntil(() => detail !== null);
880
- expect(detail!.device).to.equal('tablet');
881
- });
882
-
883
- it('marks the active device button as pressed', async () => {
884
- const el = await fixture(FIXTURE);
885
- const desktop = el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="desktop"]')!;
886
- const mobile = el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="mobile"]')!;
887
- expect(desktop.getAttribute('aria-pressed')).to.equal('true');
888
- expect(mobile.getAttribute('aria-pressed')).to.equal('false');
889
-
890
- mobile.click();
891
- await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
892
-
893
- expect(desktop.getAttribute('aria-pressed')).to.equal('false');
894
- expect(mobile.getAttribute('aria-pressed')).to.equal('true');
895
- });
896
-
897
- it('toggling mode reflects the attribute and re-pushes with the new mode', async () => {
898
- const el = await fixture(FIXTURE);
899
- const calls = await spyOnFrame(el);
900
-
901
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
902
-
903
- await waitUntil(() => calls.length === 1);
904
- expect((el as HTMLElement & {mode: string}).mode).to.equal('dark');
905
- expect(el.getAttribute('mode')).to.equal('dark');
906
- expect(calls[0]['mode']).to.equal('dark');
907
- expect(calls[0]['values']).to.deep.equal({radius: '8'});
908
- });
909
- ```
910
-
911
- Native `<button>` elements are used precisely so `.click()` works here — `zn-button` overrides `click()` and dispatches nothing.
912
-
913
- - [ ] **Step 2: Run the tests to verify they fail**
914
-
915
- Run: `npx web-test-runner --group theme-editor > /tmp/te.log 2>&1; cat /tmp/te.log`
916
-
917
- Expected: the four new tests fail — `[data-device="mobile"]` and `[data-mode-toggle]` do not exist, so the non-null assertions throw. The eight tests from Task 3 must still pass.
918
-
919
- - [ ] **Step 3: Add the device metadata and handlers**
920
-
921
- In `theme-editor.component.ts`, add below the `BOOLEAN_CONTROLS` constant:
922
-
923
- ```ts
924
- const DEVICES: {id: ThemeEditorDevice; icon: string; label: string}[] = [
925
- {id: 'desktop', icon: 'monitor', label: 'Desktop'},
926
- {id: 'tablet', icon: 'tablet', label: 'Tablet'},
927
- {id: 'mobile', icon: 'smartphone', label: 'Mobile'},
928
- ];
929
- ```
930
-
931
- Add the handlers next to `_onSlotChange`:
932
-
933
- ```ts
934
- private readonly _setDevice = (device: ThemeEditorDevice) => {
935
- if (this.device === device) return;
936
- this.device = device;
937
- // device only resizes the frame — the embed reads its width from the
938
- // iframe box, so there's nothing new to push
939
- this._announce();
940
- };
941
-
942
- private readonly _toggleMode = () => {
943
- this.mode = this.mode === 'dark' ? 'light' : 'dark';
944
- this._push();
945
- };
946
- ```
947
-
948
- - [ ] **Step 4: Render the toolbar**
949
-
950
- In `render()`, insert the toolbar inside `.editor__preview`, between the error strip and `<zn-preview-frame>`:
951
-
952
- ```ts
953
- <div part="toolbar" class="editor__toolbar">
954
- <div class="editor__devices" role="group" aria-label="Preview width">
955
- ${DEVICES.map(d => html`
956
- <button type="button"
957
- class="editor__device"
958
- data-device="${d.id}"
959
- aria-label="${d.label}"
960
- aria-pressed="${this.device === d.id ? 'true' : 'false'}"
961
- @click="${() => this._setDevice(d.id)}">
962
- <zn-icon src="${d.icon}" library="lucide" size="16"></zn-icon>
963
- </button>`)}
964
- </div>
965
- <button type="button"
966
- class="editor__mode"
967
- data-mode-toggle
968
- aria-label="${this.mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}"
969
- @click="${this._toggleMode}">
970
- <zn-icon src="${this.mode === 'dark' ? 'sun' : 'moon'}" library="lucide" size="16"></zn-icon>
971
- </button>
972
- </div>
973
- ```
974
-
975
- Lucide SVGs render `aria-hidden="true"`, so each button's accessible name comes from its `aria-label`. That is why these are native buttons: `zn-button` does not forward an accessible name to its internal `<button>`, and icon-only Zinc buttons therefore fail the axe check.
976
-
977
- - [ ] **Step 5: Style the toolbar**
978
-
979
- Add to `theme-editor.scss`, after the `.editor__preview` rule:
980
-
981
- ```scss
982
- .editor__toolbar {
983
- display: flex;
984
- align-items: center;
985
- justify-content: flex-end;
986
- gap: var(--zn-spacing-small);
987
- }
988
-
989
- .editor__devices {
990
- display: flex;
991
- border: 1px solid rgb(var(--zn-border-color));
992
- border-radius: 4px;
993
- overflow: hidden;
994
- }
995
-
996
- .editor__device,
997
- .editor__mode {
998
- display: flex;
999
- align-items: center;
1000
- justify-content: center;
1001
- width: 30px;
1002
- height: 28px;
1003
- padding: 0;
1004
- border: 0;
1005
- background: transparent;
1006
- color: rgb(var(--zn-text));
1007
- cursor: pointer;
1008
- }
1009
-
1010
- .editor__device + .editor__device {
1011
- border-left: 1px solid rgb(var(--zn-border-color));
1012
- }
1013
-
1014
- .editor__device:hover,
1015
- .editor__mode:hover {
1016
- background: rgba(var(--zn-border-color), 0.5);
1017
- }
1018
-
1019
- .editor__device[aria-pressed="true"] {
1020
- background: rgb(var(--zn-primary));
1021
- color: rgb(var(--zn-primary-contrast, 255, 255, 255));
1022
- }
1023
-
1024
- .editor__mode {
1025
- border: 1px solid rgb(var(--zn-border-color));
1026
- border-radius: 4px;
1027
- }
1028
- ```
1029
-
1030
- - [ ] **Step 6: Run the tests to verify they pass**
1031
-
1032
- Wait for the watch rebuild, then:
1033
-
1034
- Run: `npx web-test-runner --group theme-editor > /tmp/te.log 2>&1; cat /tmp/te.log`
1035
-
1036
- Expected: all twelve tests pass, including `renders and is accessible` — if axe reports a contrast failure on `[aria-pressed="true"]`, adjust the pressed-state colours in the SCSS rather than removing the state.
1037
-
1038
- - [ ] **Step 7: Verify lint and types**
1039
-
1040
- Run: `npx eslint src/components/theme-editor/`
1041
- Run: `npx tsc --noEmit -p tsconfig.json`
1042
-
1043
- Expected: clean for touched files.
1044
-
1045
- ---
1046
-
1047
- ### Task 5: `zn-theme-editor` — optional auto-save
1048
-
1049
- **Files:**
1050
- - Modify: `src/components/theme-editor/theme-editor.component.ts`
1051
- - Test: `src/components/theme-editor/theme-editor.test.ts` (append)
1052
-
1053
- **Interfaces:**
1054
- - Consumes: `values`, `_fail()`, `_onControlChange` from Task 3.
1055
- - Produces: `action` (string, default `''`) and `saveDebounce` (`save-debounce`, number, default `1000`) properties. When `action` is set, control changes POST a `FormData` of the values.
1056
-
1057
- - [ ] **Step 1: Write the failing tests**
1058
-
1059
- Append inside `describe('<zn-theme-editor>')`:
1060
-
1061
- ```ts
1062
- describe('auto-save', () => {
1063
- let fetchCalls: {uri: string; init?: RequestInit}[];
1064
- const realFetch = window.fetch;
1065
-
1066
- beforeEach(() => {
1067
- fetchCalls = [];
1068
- window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
1069
- fetchCalls.push({uri: String(uri), init});
1070
- return Promise.resolve(new Response('', {status: 200}));
1071
- };
1072
- });
1073
-
1074
- afterEach(() => {
1075
- window.fetch = realFetch;
1076
- });
1077
-
1078
- it('does not POST when action is unset', async () => {
1079
- const el = await fixture(FIXTURE);
1080
- await spyOnFrame(el);
1081
-
1082
- const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1083
- input.value = '16';
1084
- input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1085
-
1086
- await new Promise(resolve => setTimeout(resolve, 100));
1087
- expect(fetchCalls.length).to.equal(0);
1088
- });
1089
-
1090
- it('POSTs the values to action on change', async () => {
1091
- const el = await fixture(html`
1092
- <zn-theme-editor
1093
- src="about:blank"
1094
- frame-origin="https://site.example"
1095
- action="/theme/save"
1096
- debounce="10"
1097
- save-debounce="10">
1098
- <zn-input name="radius" label="Radius" value="8"></zn-input>
1099
- <zn-checkbox name="rounded" checked></zn-checkbox>
1100
- </zn-theme-editor>`);
1101
- await spyOnFrame(el);
1102
-
1103
- const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1104
- input.value = '16';
1105
- input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1106
-
1107
- await waitUntil(() => fetchCalls.length === 1);
1108
- expect(fetchCalls[0].uri).to.equal('/theme/save');
1109
- expect(fetchCalls[0].init?.method).to.equal('POST');
1110
- const body = fetchCalls[0].init!.body as FormData;
1111
- expect(body.get('radius')).to.equal('16');
1112
- expect(body.get('rounded')).to.equal('1');
1113
- // mode and device are view state, never persisted
1114
- expect(body.get('mode')).to.equal(null);
1115
- expect(body.get('device')).to.equal(null);
1116
- });
1117
-
1118
- it('does not POST when only the mode or device changes', async () => {
1119
- const el = await fixture(html`
1120
- <zn-theme-editor
1121
- src="about:blank"
1122
- frame-origin="https://site.example"
1123
- action="/theme/save"
1124
- save-debounce="10">
1125
- <zn-input name="radius" label="Radius" value="8"></zn-input>
1126
- </zn-theme-editor>`);
1127
- await spyOnFrame(el);
1128
-
1129
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
1130
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="mobile"]')!.click();
1131
-
1132
- await new Promise(resolve => setTimeout(resolve, 100));
1133
- expect(fetchCalls.length).to.equal(0);
1134
- });
1135
-
1136
- it('surfaces a failed save and emits zn-error', async () => {
1137
- window.fetch = () => Promise.resolve(new Response('theme rejected', {status: 500}));
1138
-
1139
- const el = await fixture(html`
1140
- <zn-theme-editor
1141
- src="about:blank"
1142
- frame-origin="https://site.example"
1143
- action="/theme/save"
1144
- debounce="10"
1145
- save-debounce="10">
1146
- <zn-input name="radius" label="Radius" value="8"></zn-input>
1147
- </zn-theme-editor>`);
1148
- await spyOnFrame(el);
1149
-
1150
- let znError: CustomEvent<{message?: string}> | null = null;
1151
- el.addEventListener('zn-error', (e: Event) => { znError = e as CustomEvent<{message?: string}>; });
1152
-
1153
- const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1154
- input.value = '16';
1155
- input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1156
-
1157
- await waitUntil(() => el.shadowRoot!.querySelector('[part="error"]'));
1158
- expect(el.shadowRoot!.querySelector('[part="error"]')!.textContent).to.contain('theme rejected');
1159
- expect(znError).to.exist;
1160
- expect(znError!.detail.message).to.contain('theme rejected');
1161
- });
1162
-
1163
- it('runs exactly one follow-up save for changes made mid-flight', async () => {
1164
- let release: (() => void) | undefined;
1165
- const gate = new Promise<void>(resolve => { release = resolve; });
1166
- window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
1167
- fetchCalls.push({uri: String(uri), init});
1168
- return gate.then(() => new Response('', {status: 200}));
1169
- };
1170
-
1171
- const el = await fixture(html`
1172
- <zn-theme-editor
1173
- src="about:blank"
1174
- frame-origin="https://site.example"
1175
- action="/theme/save"
1176
- debounce="5"
1177
- save-debounce="5">
1178
- <zn-input name="radius" label="Radius" value="8"></zn-input>
1179
- </zn-theme-editor>`);
1180
- await spyOnFrame(el);
1181
-
1182
- const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
1183
- const change = (value: string) => {
1184
- input.value = value;
1185
- input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
1186
- };
1187
-
1188
- change('16');
1189
- await waitUntil(() => fetchCalls.length === 1); // in flight, gated
1190
-
1191
- change('24');
1192
- change('32');
1193
- await new Promise(resolve => setTimeout(resolve, 50));
1194
- expect(fetchCalls.length).to.equal(1); // queued, not stacked
1195
-
1196
- release!();
1197
- await waitUntil(() => fetchCalls.length === 2);
1198
- await new Promise(resolve => setTimeout(resolve, 50));
1199
- expect(fetchCalls.length).to.equal(2); // one follow-up, latest values
1200
- expect((fetchCalls[1].init!.body as FormData).get('radius')).to.equal('32');
1201
- });
1202
- });
1203
- ```
1204
-
1205
- That last test is the one that matters: without the single-slot queue, two overlapping POSTs can complete out of order and leave the server holding a stale value.
1206
-
1207
- - [ ] **Step 2: Run the tests to verify they fail**
1208
-
1209
- Run: `npx web-test-runner --group theme-editor > /tmp/te.log 2>&1; cat /tmp/te.log`
1210
-
1211
- Expected: `does not POST when action is unset` and `does not POST when only the mode or device changes` pass trivially (nothing POSTs yet); the other three fail because no request is ever made — `waitUntil` times out.
1212
-
1213
- - [ ] **Step 3: Add the properties**
1214
-
1215
- In `theme-editor.component.ts`, add after the `debounce` property:
1216
-
1217
- ```ts
1218
- /** Optional endpoint the values are POSTed to. Empty = no persistence. */
1219
- @property() action = '';
1220
-
1221
- /** Debounce in ms between a control change and the save POST. */
1222
- @property({type: Number, attribute: 'save-debounce'}) saveDebounce = 1000;
1223
- ```
1224
-
1225
- And the save state, next to `_pushTimer`:
1226
-
1227
- ```ts
1228
- private _saveTimer?: number;
1229
- private _saving = false;
1230
- private _saveQueued = false;
1231
- ```
1232
-
1233
- - [ ] **Step 4: Implement the save**
1234
-
1235
- Add these methods after `_push()`:
1236
-
1237
- ```ts
1238
- private _queueSave() {
1239
- if (!this.action) return;
1240
- if (this._saveTimer) window.clearTimeout(this._saveTimer);
1241
- this._saveTimer = window.setTimeout(() => {
1242
- this._saveTimer = undefined;
1243
- void this._save();
1244
- }, this.saveDebounce);
1245
- }
1246
-
1247
- // Saves serialize through a single slot: changes arriving mid-flight collapse
1248
- // into exactly one follow-up save, so overlapping POSTs can't land out of
1249
- // order and persist a stale value.
1250
- private async _save() {
1251
- if (this._saving) {
1252
- this._saveQueued = true;
1253
- return;
1254
- }
1255
- this._saving = true;
1256
-
1257
- try {
1258
- const body = new FormData();
1259
- for (const [name, value] of Object.entries(this.values)) {
1260
- body.append(name, typeof value === 'boolean' ? (value ? '1' : '') : String(value ?? ''));
1261
- }
1262
- const response = await fetch(this.action, {
1263
- method: 'POST',
1264
- credentials: 'same-origin',
1265
- body,
1266
- });
1267
- if (!response.ok) {
1268
- throw new Error(await response.text() || response.statusText);
1269
- }
1270
- this.error = '';
1271
- } catch (err) {
1272
- this._fail(err instanceof Error ? err.message : String(err));
1273
- } finally {
1274
- this._saving = false;
1275
- if (this._saveQueued) {
1276
- this._saveQueued = false;
1277
- void this._save();
1278
- }
1279
- }
1280
- }
1281
- ```
1282
-
1283
- - [ ] **Step 5: Trigger the save from control changes only**
1284
-
1285
- In `_onControlChange`, add the save alongside the push:
1286
-
1287
- ```ts
1288
- private readonly _onControlChange = () => {
1289
- if (this._pushTimer) window.clearTimeout(this._pushTimer);
1290
- this._pushTimer = window.setTimeout(() => {
1291
- this._pushTimer = undefined;
1292
- this._push();
1293
- this._queueSave();
1294
- }, this.debounce);
1295
- };
1296
- ```
1297
-
1298
- Do **not** call `_queueSave()` from `_toggleMode`, `_setDevice`, `_onSlotChange`, or `firstUpdated`: `mode` and `device` are view state and are never persisted, and the initial push carries only the values already on the server.
1299
-
1300
- Also clear the save timer on disconnect — update `disconnectedCallback`:
1301
-
1302
- ```ts
1303
- disconnectedCallback() {
1304
- super.disconnectedCallback();
1305
- if (this._pushTimer) window.clearTimeout(this._pushTimer);
1306
- if (this._saveTimer) window.clearTimeout(this._saveTimer);
1307
- }
1308
- ```
1309
-
1310
- - [ ] **Step 6: Run the tests to verify they pass**
1311
-
1312
- Wait for the watch rebuild, then:
1313
-
1314
- Run: `npx web-test-runner --group theme-editor > /tmp/te.log 2>&1; cat /tmp/te.log`
1315
-
1316
- Expected: all seventeen tests pass.
1317
-
1318
- - [ ] **Step 7: Verify lint and types**
1319
-
1320
- Run: `npx eslint src/components/theme-editor/`
1321
- Run: `npx tsc --noEmit -p tsconfig.json`
1322
-
1323
- Expected: clean for touched files.
1324
-
1325
- ---
1326
-
1327
- ### Task 6: Documentation
1328
-
1329
- **Files:**
1330
- - Create: `docs/pages/components/theme-editor.md`
1331
- - Modify: `docs/pages/components/preview-frame-demo.njk`
1332
- - Modify: `docs/pages/components/preview-frame.md`
1333
-
1334
- **Interfaces:**
1335
- - Consumes: everything from Tasks 1–5.
1336
- - Produces: a live docs example. The demo embed additionally handles `hp-preview:theme`.
1337
-
1338
- - [ ] **Step 1: Teach the demo embed the theme message**
1339
-
1340
- In `docs/pages/components/preview-frame-demo.njk`, inside the existing `<script>` block, add a handler before the final `post({type: 'hp-preview:ready'});` line:
1341
-
1342
- ```js
1343
- // The theme half of the protocol: applied independently of the config, so
1344
- // the editor can push values before any config exists.
1345
- window.addEventListener('message', e => {
1346
- const data = e.data;
1347
- if (data?.type !== 'hp-preview:theme') return;
1348
-
1349
- const values = data.values || {};
1350
- document.body.classList.toggle('is-dark', data.mode === 'dark');
1351
- const card = document.getElementById('card');
1352
- card.hidden = false;
1353
- if (values.background) card.style.background = values.background;
1354
- if (values.accent) card.style.setProperty('--accent', values.accent);
1355
- if (values.radius !== undefined && values.radius !== '') {
1356
- card.style.borderRadius = values.radius + 'px';
1357
- }
1358
- document.getElementById('waiting').hidden = true;
1359
- post({type: 'hp-preview:rendered'});
1360
- });
1361
- ```
1362
-
1363
- And add to the demo's `<style>` block:
1364
-
1365
- ```css
1366
- body.is-dark {
1367
- background: #101014;
1368
- color: #f4f4f5;
1369
- }
1370
-
1371
- body.is-dark .card {
1372
- border-color: #33333a;
1373
- }
1374
- ```
1375
-
1376
- The existing `hp-preview:config` handler is untouched, so both preview-frame examples keep working.
1377
-
1378
- - [ ] **Step 2: Write the docs page**
1379
-
1380
- Create `docs/pages/components/theme-editor.md` with exactly this content (the
1381
- outer fence below is four backticks so the page's own three-backtick examples
1382
- nest correctly — write the file starting at `---`):
1383
-
1384
- ````markdown
1385
- ---
1386
- meta:
1387
- title: Theme Editor
1388
- description: Theme controls on the left, a live preview frame on the right, with light/dark and device switching.
1389
- layout: component
1390
- ---
1391
-
1392
- Put form controls in the default slot and give each a `name`. Changing one
1393
- harvests every named control and pushes the values into the embedded
1394
- [preview frame](/components/preview-frame/) as an `hp-preview:theme` message —
1395
- no save, no fetch, no page reload.
1396
-
1397
- The toolbar above the preview switches the mode the preview renders in and the
1398
- width it renders at: desktop (full width), tablet (768px) or mobile (390px).
1399
- Because the iframe itself is resized, the embedded page's own media queries
1400
- fire.
1401
-
1402
- ```html:preview
1403
- <zn-theme-editor
1404
- id="theme-editor-demo"
1405
- src="/components/preview-frame-demo/"
1406
- min-height="420">
1407
- <zn-color-select name="accent" label="Accent"></zn-color-select>
1408
- <zn-input name="radius" label="Corner radius" type="number" value="12"></zn-input>
1409
- </zn-theme-editor>
1410
-
1411
- <script>
1412
- document.getElementById('theme-editor-demo').frameOrigin = location.origin;
1413
- </script>
1414
- ```
1415
-
1416
- :::tip
1417
- `frame-origin` must match the embed's origin exactly — messages from any other
1418
- origin are ignored. The example sets it at runtime because the docs site is
1419
- same-origin.
1420
- :::
1421
-
1422
- ## Reading and Persisting Values
1423
-
1424
- Every change emits `zn-theme-change` with `{values, mode, device}`, so a host
1425
- can drive its own save button:
1426
-
1427
- ```js
1428
- editor.addEventListener('zn-theme-change', event => {
1429
- console.log(event.detail.values);
1430
- });
1431
- ```
1432
-
1433
- Set `action` to persist automatically instead — the values are POSTed as
1434
- `FormData` on a longer debounce (`save-debounce`, default `1000`ms). `mode` and
1435
- `device` are view state and are never saved.
1436
-
1437
- ```html
1438
- <zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example" action="/theme/save">
1439
- <zn-color-select name="accent" label="Accent"></zn-color-select>
1440
- </zn-theme-editor>
1441
- ```
1442
-
1443
- Saves are serialized: if changes land while a POST is in flight, exactly one
1444
- further save runs afterwards with the latest values.
1445
-
1446
- ## Controls
1447
-
1448
- Any Zinc form control works. Controls must carry `name` as an **attribute** —
1449
- `zn-checkbox` and `zn-toggle` contribute their `checked` state as a boolean,
1450
- everything else contributes `value`. Disabled and unnamed controls are skipped.
1451
-
1452
- The `footer` slot holds actions beneath the controls:
1453
-
1454
- ```html
1455
- <zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example">
1456
- <zn-color-select name="accent" label="Accent"></zn-color-select>
1457
- <zn-button slot="footer">Save</zn-button>
1458
- </zn-theme-editor>
1459
- ```
1460
-
1461
- Set the controls column width with `--zn-theme-editor-controls-width`
1462
- (default `280px`). Below 768px the columns stack.
1463
- ````
1464
-
1465
- Do not use literal `{{ }}` anywhere in this page — docs markdown is
1466
- nunjucks-processed and would need `{% raw %}…{% endraw %}`.
1467
-
1468
- - [ ] **Step 3: Cross-link from the preview-frame page**
1469
-
1470
- In `docs/pages/components/preview-frame.md`, add a paragraph after the existing
1471
- sentence describing `zoom` and `min-height`:
1472
-
1473
- ```markdown
1474
- `device` constrains and centres the preview to `desktop` (full width), `tablet`
1475
- (768px) or `mobile` (390px), resizing the iframe itself so the embedded page's
1476
- media queries fire. `setTheme(values)` posts an `hp-preview:theme` message and
1477
- replays it after each ready handshake, which is how
1478
- [`zn-theme-editor`](/components/theme-editor/) drives a live preview.
1479
- ```
1480
-
1481
- - [ ] **Step 4: Verify the docs render and the example works**
1482
-
1483
- The running `npm run watch` rebuilds `_site/` and reloads BrowserSync. Open
1484
- `/components/theme-editor/` and confirm:
1485
-
1486
- - the two-column layout renders with the toolbar above the preview
1487
- - changing the accent colour recolours the card immediately
1488
- - changing the corner radius changes the card's border radius
1489
- - the mode toggle darkens the embed
1490
- - the device buttons narrow the iframe and highlight the active button
1491
- - `/components/preview-frame/` still works — both its examples
1492
-
1493
- If Eleventy fails the build, read its error before changing anything; an
1494
- unescaped `{{ }}` in markdown is the usual cause.
1495
-
1496
- - [ ] **Step 5: Final verification across the whole change**
1497
-
1498
- Run: `npx web-test-runner --group theme-editor > /tmp/te.log 2>&1; cat /tmp/te.log`
1499
- Run: `npx web-test-runner --group preview-frame > /tmp/pf.log 2>&1; cat /tmp/pf.log`
1500
- Run: `npx eslint src/components/theme-editor/ src/components/preview-frame/ src/events/zn-theme-change.ts src/events/events.ts src/zinc.ts`
1501
- Run: `npx tsc --noEmit -p tsconfig.json`
1502
-
1503
- Expected: both groups fully pass; lint and types clean for touched files.
1504
-
1505
- Report results honestly — if a test fails, say so with the output rather than
1506
- describing the work as complete.
1507
-
1508
- ---
1509
-
1510
- ## Self-Review Notes
1511
-
1512
- Spec coverage checked section by section:
1513
-
1514
- | Spec requirement | Task |
1515
- |---|---|
1516
- | `device` property, stage wrapper, zoom composition | 1 |
1517
- | `setTheme()` + ready replay | 2 |
1518
- | Empty-`dataUri` guard | 2 |
1519
- | Properties, slots, layout, controls width | 3 |
1520
- | Value harvesting incl. booleans/disabled/unnamed | 3 |
1521
- | Initial push of authored defaults | 3 |
1522
- | `zn-theme-change` event type | 3 |
1523
- | Theme message shape with `mode` | 3 |
1524
- | Toolbar: device segmented control + mode toggle, native buttons | 4 |
1525
- | Mode re-pushes, device only resizes | 4 |
1526
- | Optional `action` auto-save, FormData, no mode/device | 5 |
1527
- | Single-slot save queue | 5 |
1528
- | Error strip, `zn-error` on save failure, frame errors captured not re-emitted | 3 (capture) + 5 (save) |
1529
- | Tests as enumerated in the spec | 1, 2, 3, 4, 5 |
1530
- | Docs page + demo embed extension | 6 |
1531
-
1532
- Names verified consistent across tasks: `setTheme`, `_push`, `_announce`,
1533
- `_fail`, `_queueSave`, `_save`, `_onControlChange`, `_setDevice`,
1534
- `_toggleMode`, `values`, `DEVICE_WIDTHS`, `DEVICES`, `BOOLEAN_CONTROLS`,
1535
- `.preview__stage`, `[data-device]`, `[data-mode-toggle]`,
1536
- `zn-theme-change`.