@kubex/zinc 1.1.68 → 1.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/custom-elements.json +1719 -282
  2. package/dist/vscode.html-custom-data.json +160 -29
  3. package/dist/web-types.json +395 -70
  4. package/dist/zn.d.ts +395 -16
  5. package/dist/zn.min.js +580 -408
  6. package/docs/pages/components/collapsible.md +6 -2
  7. package/docs/pages/components/icon-picker.md +14 -0
  8. package/docs/pages/components/input.md +19 -1
  9. package/docs/pages/components/preview-frame-demo.njk +33 -4
  10. package/docs/pages/components/preview-frame.md +20 -0
  11. package/docs/pages/components/theme-editor.md +303 -0
  12. package/docs/superpowers/plans/2026-08-03-theme-editor.md +1536 -0
  13. package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +327 -0
  14. package/package.json +1 -1
  15. package/src/components/collapsible/collapsible.component.ts +21 -23
  16. package/src/components/dropdown/dropdown.component.ts +9 -0
  17. package/src/components/editor/editor.component.ts +20 -21
  18. package/src/components/file/file.component.ts +15 -0
  19. package/src/components/icon-picker/icon-picker.component.ts +150 -16
  20. package/src/components/icon-picker/icon-picker.scss +32 -0
  21. package/src/components/input/input.component.ts +44 -21
  22. package/src/components/input/input.scss +134 -42
  23. package/src/components/input/input.test.ts +45 -0
  24. package/src/components/preview-frame/preview-frame.component.ts +126 -16
  25. package/src/components/preview-frame/preview-frame.scss +27 -0
  26. package/src/components/preview-frame/preview-frame.test.ts +253 -0
  27. package/src/components/theme-editor/index.ts +12 -0
  28. package/src/components/theme-editor/theme-editor.component.ts +918 -0
  29. package/src/components/theme-editor/theme-editor.scss +309 -0
  30. package/src/components/theme-editor/theme-editor.test.ts +1752 -0
  31. package/src/events/events.ts +2 -0
  32. package/src/events/zn-theme-change.ts +13 -0
  33. package/src/events/zn-theme-submit.ts +9 -0
  34. package/src/types/web-test-runner-commands.d.ts +6 -0
  35. package/src/zinc.ts +1 -0
  36. package/tsconfig.json +7 -1
  37. package/web-test-runner.config.js +3 -1
@@ -109,11 +109,15 @@ Use the `count-element` attribute to count specific elements instead of all chil
109
109
 
110
110
  ### Flush Content
111
111
 
112
- Use the `flush` attribute to remove the left padding/margin from the content area, making it align with the edges.
112
+ Use the `flush` attribute to remove the horizontal padding from both the content
113
+ area and the header, so the caption and the content align with the container's
114
+ edges. This matches `flush` on [`zn-pane`](/components/pane/),
115
+ [`zn-panel`](/components/panel/) and [`zn-tabs`](/components/tabs/), and suits a
116
+ narrow column where an indent wastes width.
113
117
 
114
118
  ```html:preview
115
119
  <zn-collapsible caption="Flush Content"
116
- description="No left padding on content"
120
+ description="No horizontal padding on the header or content"
117
121
  flush>
118
122
  <zn-button>This button aligns with the left edge</zn-button>
119
123
  <p>Content starts at the container edge</p>
@@ -87,6 +87,20 @@ Use the `avatar` library to generate initials from a name.
87
87
  <zn-icon-picker name="av" label="Avatar" icon="John Smith" library="avatar" no-color></zn-icon-picker>
88
88
  ```
89
89
 
90
+ ### Upload Image
91
+
92
+ By default the picker only offers library icons. Add the `allow-upload` attribute to enable a secondary "Upload Image" mode in the dialog, letting users upload an image file instead of picking an icon. Use the `accept` attribute to restrict the accepted file types (defaults to `image/*`).
93
+
94
+ An uploaded file replaces the icon value: on submit, the file is sent under `name` in place of the icon string — the two are mutually exclusive. The chosen file is also available via the element's `file` property.
95
+
96
+ ```html:preview
97
+ <zn-icon-picker name="logo" label="Logo" allow-upload></zn-icon-picker>
98
+ ```
99
+
100
+ ```html:preview
101
+ <zn-icon-picker name="photo" label="Photo (PNG only)" allow-upload accept="image/png"></zn-icon-picker>
102
+ ```
103
+
90
104
  ### Help Text
91
105
 
92
106
  Add descriptive text below the picker with the `help-text` attribute.
@@ -136,7 +136,7 @@ the [Properties table](#properties) for the full list of options.
136
136
 
137
137
  #### Color Input
138
138
 
139
- The color input type displays a clickable color swatch on the left that opens the browser's native color picker. The color value is shown as editable text in the input field, allowing users to either pick a color visually or enter a color value directly.
139
+ The color input type displays a clickable color swatch on the right that opens the browser's native color picker. The color value is shown as editable text in the input field, allowing users to either pick a color visually or enter a color value directly.
140
140
 
141
141
  Use the `color-format` attribute to specify the format: `hex` (default), `rgb`, or `oklch`.
142
142
 
@@ -159,6 +159,24 @@ Use the `color-format` attribute to specify the format: `hex` (default), `rgb`,
159
159
  <br />
160
160
  ```
161
161
 
162
+ #### Range Input
163
+
164
+ The range input type renders as a bare track with no field chrome. The current value is displayed above the track, on
165
+ the right, and updates as the thumb is dragged. Use `value-suffix` to append a unit to that value, and `min`, `max` and
166
+ `step` to control the range.
167
+
168
+ ```html:preview
169
+ <zn-input type="range" label="Base radius" min="0" max="2" step="0.125" value="0.5" value-suffix="rem"></zn-input>
170
+ <br />
171
+ <zn-input type="range" label="Volume" min="0" max="100" value="70" value-suffix="%">
172
+ <div slot="help-text">Set <code>context-note</code> to replace the value readout with your own text.</div>
173
+ </zn-input>
174
+ <br />
175
+ <zn-input type="range" label="Small" size="small" min="0" max="10" value="4"></zn-input>
176
+ <br />
177
+ <zn-input type="range" label="Disabled" min="0" max="10" value="4" disabled></zn-input>
178
+ ```
179
+
162
180
  #### Other Input Types
163
181
 
164
182
  ```html:preview
@@ -9,12 +9,12 @@ permalink: /components/preview-frame-demo/index.html
9
9
  ---
10
10
  {% raw %}
11
11
  <div class="shell">
12
- <p id="waiting">Waiting for hp-preview:config…</p>
12
+ <p id="waiting">Waiting for the host…</p>
13
13
 
14
14
  <div class="card" id="card" hidden>
15
15
  <div class="card__accent"></div>
16
- <h2 id="merchant"></h2>
17
- <p class="card__amount" id="amount"></p>
16
+ <h2 id="merchant">Merchant</h2>
17
+ <p class="card__amount" id="amount">£24.99</p>
18
18
  <label>Card number
19
19
  <input type="text" placeholder="4242 4242 4242 4242" disabled>
20
20
  </label>
@@ -26,7 +26,7 @@ permalink: /components/preview-frame-demo/index.html
26
26
  <input type="text" placeholder="123" disabled>
27
27
  </label>
28
28
  </div>
29
- <button id="pay" type="button"></button>
29
+ <button id="pay" type="button">Pay now</button>
30
30
  </div>
31
31
  </div>
32
32
 
@@ -106,6 +106,16 @@ permalink: /components/preview-frame-demo/index.html
106
106
  font-weight: 600;
107
107
  cursor: pointer;
108
108
  }
109
+
110
+ body.is-dark {
111
+ background: #101014;
112
+ color: #f4f4f5;
113
+ }
114
+
115
+ body.is-dark .card {
116
+ background: #18181b;
117
+ border-color: #33333a;
118
+ }
109
119
  </style>
110
120
 
111
121
  <script>
@@ -133,6 +143,25 @@ permalink: /components/preview-frame-demo/index.html
133
143
  post({type: 'hp-preview:rendered'});
134
144
  });
135
145
 
146
+ // The theme half of the protocol: applied independently of the config, so
147
+ // the editor can push values before any config exists.
148
+ window.addEventListener('message', e => {
149
+ const data = e.data;
150
+ if (data?.type !== 'hp-preview:theme') return;
151
+
152
+ const values = data.values || {};
153
+ document.body.classList.toggle('is-dark', data.mode === 'dark');
154
+ const card = document.getElementById('card');
155
+ card.hidden = false;
156
+ if (values.background) card.style.background = values.background;
157
+ if (values.accent) card.style.setProperty('--accent', values.accent);
158
+ if (values.radius !== undefined && values.radius !== '') {
159
+ card.style.borderRadius = values.radius + 'px';
160
+ }
161
+ document.getElementById('waiting').hidden = true;
162
+ post({type: 'hp-preview:rendered'});
163
+ });
164
+
136
165
  post({type: 'hp-preview:ready'});
137
166
  </script>
138
167
  {% endraw %}
@@ -27,6 +27,26 @@ The example below embeds the [demo embed page](/components/preview-frame-demo/),
27
27
 
28
28
  The frame always fills the panel; `zoom` (0–1, default `1`) zooms the previewed page out browser-style — e.g. `zoom="0.4"` renders the content at 40% size with correspondingly more of the page visible. `min-height` (default `480`) sets the visible panel height in pixels.
29
29
 
30
+ Set `fill` to make the panel fill its container's height instead — `min-height` then becomes a floor rather than the height, for hosts (like [`zn-theme-editor`](/components/theme-editor/)) whose layout already stretches the panel to match a taller sibling. `zoom` is ignored when `fill` is set, since its oversize maths needs a known pixel height to scale against, which `fill` deliberately doesn't have.
31
+
32
+ `device` constrains and centres the preview to `desktop` (full width), `tablet`
33
+ (768px) or `mobile` (390px), resizing the iframe itself so the embedded page's
34
+ media queries fire. `setTheme({mode, values})` posts an `hp-preview:theme` message and
35
+ replays it after each ready handshake, which is how
36
+ [`zn-theme-editor`](/components/theme-editor/) drives a live preview.
37
+
38
+ The panel behind the preview is a dot grid, so the frame's bounds stay visible
39
+ instead of blending into the page — at `tablet` or `mobile` the dots fill the
40
+ gutters either side of the narrowed iframe. Tune it with
41
+ `--zn-preview-frame-dot-spacing` (default `20px`) and
42
+ `--zn-preview-frame-dot-opacity` (default `0.08`). The iframe itself is given an
43
+ opaque background, so the dots never show through the previewed page.
44
+
45
+ Set `backdrop="panel"` to swap the dot grid for a plain `rgb(var(--zn-panel))`
46
+ fill — used by [`zn-theme-editor`](/components/theme-editor/)'s `standalone`
47
+ mode, where the frame is already inside its own bordered panel. `backdrop="dots"`
48
+ is the default.
49
+
30
50
  ## Live Form Updates
31
51
 
32
52
  In a real deployment, editing a watched form auto-saves it and the preview refreshes with the newly saved config. This docs site is static, so the example simulates the save: form changes are encoded into a `data:` payload URI and `refresh()` re-runs the fetch → `hp-preview:config` cycle — the same path a real save triggers.
@@ -0,0 +1,303 @@
1
+ ---
2
+ meta:
3
+ title: Theme Editor
4
+ description: Theme controls on the left, a live preview frame on the right, with light/dark and device switching.
5
+ layout: component
6
+ fullWidth: true
7
+ ---
8
+
9
+ Put form controls in the default slot and give each a `name`. Every control is
10
+ per-mode: it holds a light value and a dark value, and the sidebar's mode
11
+ toggle swaps which one the control displays. Changing a control harvests every
12
+ named control's *currently displayed* value and pushes the active mode's set
13
+ into the embedded [preview frame](/components/preview-frame/) as an
14
+ `hp-preview:theme` message — no save, no fetch, no page reload.
15
+
16
+ The controls column runs the full height of the component, with its own
17
+ header row on top holding the light/dark mode toggle beside its caption. The
18
+ toolbar sits opposite it, above the preview only, and switches the width the
19
+ preview renders at: desktop (full width), tablet (768px) or mobile (390px).
20
+ Because the iframe itself is resized, the embedded page's own media queries
21
+ fire.
22
+
23
+ Set `controls-caption` and `preview-caption` to label each column's header
24
+ row — both are empty by default, rendering no text (the controls column's
25
+ header row still renders either way, so the two columns stay aligned).
26
+
27
+ The preview always fills its column, leaving no dead space beneath it.
28
+ `min-height` (default `480`) is a floor for that column, not a fixed height —
29
+ it's still forwarded to the [preview frame](/components/preview-frame/), which
30
+ uses it the same way.
31
+
32
+ Controls organize themselves into tabs with collapsible groups inside each:
33
+ give each one a `group` and a `category` and the editor builds the structure
34
+ around them, no slot names required.
35
+
36
+ ```html:preview
37
+ <zn-theme-editor
38
+ id="theme-editor-demo"
39
+ src="/components/preview-frame-demo/"
40
+ min-height="420"
41
+ controls-caption="Theme Builder"
42
+ preview-caption="Live Preview">
43
+ <zn-input group="Colors" category="Brand" name="accent" label="Accent" value="#6936f5" dark-value="#f5c542" type="color"></zn-input>
44
+ <zn-input group="Colors" category="Background" name="background" label="Background" value="#ffffff" dark-value="#18181b" type="color"></zn-input>
45
+ <zn-input group="Shapes" category="Radius" name="radius" label="Corner radius" type="number" value="4"></zn-input>
46
+ </zn-theme-editor>
47
+
48
+ <script>
49
+ document.getElementById('theme-editor-demo').frameOrigin = location.origin;
50
+ </script>
51
+ ```
52
+
53
+ :::tip
54
+ `frame-origin` must match the embed's origin exactly — messages from any other
55
+ origin are ignored. The example sets it at runtime because the docs site is
56
+ same-origin.
57
+ :::
58
+
59
+ ## Dark values
60
+
61
+ Give a control a `dark-value` attribute alongside `value` to author its dark
62
+ variant, as `accent` and `background` do above. A control with no
63
+ `dark-value` falls back to its `value` in dark mode, so adding dark support to
64
+ an existing editor is additive:
65
+
66
+ ```html
67
+ <zn-color-select name="accent" label="Accent" value="#6936f5" dark-value="#f5c542"></zn-color-select>
68
+ ```
69
+
70
+ For boolean controls (`zn-checkbox`, `zn-toggle`, `input[type=checkbox]`),
71
+ two separate rules apply depending on whether `dark-value` is present:
72
+
73
+ - **Present** — parsed as truthy: `dark-value="1"` or `dark-value="true"`
74
+ seeds the dark state checked, any other value seeds it unchecked.
75
+ - **Absent** — the dark state mirrors the control's own light `checked`
76
+ state, whatever that is (including `true`), the same fallback rule
77
+ non-boolean controls get.
78
+
79
+ ## Reading and Persisting Values
80
+
81
+ Every change emits `zn-theme-change` with `{values, mode, device}`, where
82
+ `values` is `{light, dark}` — both full sets, regardless of which mode is
83
+ active:
84
+
85
+ ```js
86
+ editor.addEventListener('zn-theme-change', event => {
87
+ console.log(event.detail.values.light, event.detail.values.dark);
88
+ });
89
+ ```
90
+
91
+ Set `action` to persist automatically instead — both sets are POSTed as
92
+ `FormData` on a longer debounce (`save-debounce`, default `1000`ms), with every
93
+ key bracketed by mode: `light[accent]`, `dark[accent]`, `light[radius]`,
94
+ `dark[radius]`. `mode` and `device` are view state and are never saved.
95
+
96
+ ```html
97
+ <zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example" action="/theme/save">
98
+ <zn-color-select name="accent" label="Accent" value="#6936f5" dark-value="#f5c542"></zn-color-select>
99
+ </zn-theme-editor>
100
+ ```
101
+
102
+ Saves are serialized: if changes land while a POST is in flight, exactly one
103
+ further save runs afterwards with the latest values.
104
+
105
+ ### Manual saving with a submit button
106
+
107
+ Set `submit-label` to render a built-in save button at the right of the toolbar,
108
+ opposite the device controls —
109
+ empty (the default) renders no button. Add `manual` to disable the debounced
110
+ auto-save entirely, so persistence only happens when the button is clicked;
111
+ the live preview keeps updating on every change either way, only saving
112
+ becomes explicit:
113
+
114
+ ```html
115
+ <zn-theme-editor
116
+ src="/embed?t=..." frame-origin="https://pay.example"
117
+ manual
118
+ submit-label="Save theme">
119
+ <zn-color-select name="accent" label="Accent" value="#6936f5" dark-value="#f5c542"></zn-color-select>
120
+ </zn-theme-editor>
121
+ ```
122
+
123
+ Clicking the button flushes any pending edit, then saves immediately through
124
+ the same single-slot save queue used for auto-save — it never opens a second
125
+ concurrent request. With `action` set, a successful save emits `zn-theme-submit`
126
+ carrying `{values}` (both `light` and `dark` sets); a failed save surfaces
127
+ through the same error strip and `zn-error` as auto-save. With no `action`,
128
+ nothing is POSTed but `zn-theme-submit` still fires, so a host can persist the
129
+ values itself:
130
+
131
+ ```js
132
+ editor.addEventListener('zn-theme-submit', event => {
133
+ console.log(event.detail.values.light, event.detail.values.dark);
134
+ });
135
+ ```
136
+
137
+ The button shows a loading state while its save is in flight and ignores
138
+ further clicks until it resolves.
139
+
140
+ ## Controls
141
+
142
+ Any Zinc form control works. Controls must carry `name` as an **attribute** —
143
+ `zn-checkbox` and `zn-toggle` contribute their `checked` state as a boolean,
144
+ everything else contributes `value`. Disabled and unnamed controls are skipped.
145
+ Every control is per-mode with no opt-in attribute, so mode-independent values
146
+ (like `radius` above) end up duplicated across `light` and `dark` — accepted
147
+ knowingly to keep the rule uniform.
148
+
149
+ The value store is the theme, not a mirror of the currently visible controls:
150
+ removing a control from the markup keeps its key in both value sets, and it
151
+ keeps being pushed to the preview and included in saves. Clear it server-side
152
+ or re-add the control under the same name to edit it again.
153
+
154
+ The `footer` slot holds actions beneath the controls:
155
+
156
+ ```html
157
+ <zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example">
158
+ <zn-color-select name="accent" label="Accent"></zn-color-select>
159
+ <zn-button slot="footer">Save</zn-button>
160
+ </zn-theme-editor>
161
+ ```
162
+
163
+ Set the controls column width with `--zn-theme-editor-controls-width`
164
+ (default `343px`, matching page-builder's palette). Below 768px the columns
165
+ stack.
166
+
167
+ ## Grouping controls with `group` and `category`
168
+
169
+ The simplest way to structure an editor is to let the controls describe their
170
+ own place in it. Leave `sections` unset and give each control a `group` (which
171
+ becomes a tab) and a `category` (a collapsible inside that tab) — the editor
172
+ builds the tabs and collapsibles from those labels and slots each control into
173
+ the right one for you, so there are no slot names to keep in sync by hand:
174
+
175
+ ```html
176
+ <zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example">
177
+ <zn-color-select group="Background &amp; Foreground" category="Colors"
178
+ name="background" label="Background" value="#ffffff"></zn-color-select>
179
+ <zn-color-select group="Background &amp; Foreground" category="Colors"
180
+ name="foreground" label="Foreground" value="#18181b"></zn-color-select>
181
+ <zn-input group="Background &amp; Foreground" category="Spacing"
182
+ name="gap" label="Gap" type="number" value="8"></zn-input>
183
+ <zn-input group="Typography" category="Family"
184
+ name="font" label="Font" value="Inter"></zn-input>
185
+ </zn-theme-editor>
186
+ ```
187
+
188
+ That renders a **Background & Foreground** tab holding *Colors* and *Spacing*
189
+ collapsibles, and a **Typography** tab holding *Family*. Labels are free text —
190
+ they're slugged into slot names internally, and two tabs can each hold a
191
+ category of the same name without colliding.
192
+
193
+ Either attribute works on its own: a control with only `group` sits directly in
194
+ its tab above any collapsibles, and one with only `category` becomes its own
195
+ top-level section. A control with neither stays in the default slot, ungrouped
196
+ above everything else. Tabs and collapsibles appear in the order the controls
197
+ first mention them, and controls added after mount are derived and slotted the
198
+ same way.
199
+
200
+ ### Declaring the structure explicitly with `sections`
201
+
202
+ Set `sections` to take full control instead — it disables the attribute
203
+ derivation entirely, and is the way to set a group's `description` or have it
204
+ render `open`. It takes a JSON array of `{name, caption, groups}`. Each section
205
+ becomes a `zn-tabs` tab; each entry in its `groups` — `{name, caption,
206
+ description?, open?}` — becomes a collapsible inside that tab, and a control
207
+ is assigned to a group with `slot="<group-name>"`:
208
+
209
+ ```html
210
+ <zn-theme-editor
211
+ src="/embed?t=..." frame-origin="https://pay.example"
212
+ sections='[
213
+ {"name":"colors","caption":"Colors","groups":[
214
+ {"name":"brand","caption":"Brand","open":true},
215
+ {"name":"semantic","caption":"Semantic"}
216
+ ]},
217
+ {"name":"shapes","caption":"Shapes","groups":[{"name":"radius","caption":"Radius"}]}
218
+ ]'>
219
+ <zn-color-select slot="brand" name="accent" label="Accent" value="#6936f5"></zn-color-select>
220
+ <zn-input slot="radius" name="radius" label="Corner radius" type="number" value="4"></zn-input>
221
+ </zn-theme-editor>
222
+ ```
223
+
224
+ A group with no assigned controls renders no collapsible, and a section none
225
+ of whose groups are populated renders no tab — the same "no chrome for empty
226
+ config" rule flat sections already followed. Every tab's panel stays mounted
227
+ while hidden (`zn-tabs` toggles visibility, never removes a panel), so
228
+ switching tabs never drops a value out of the theme, a preview push or a save.
229
+
230
+ The editor never shows a tab with everything shut: on load, and again whenever
231
+ a tab is clicked, its first collapsible expands unless one in that tab is
232
+ already open. `open: true` therefore only matters for picking *which* group
233
+ opens — and closing them all yourself sticks, since nothing reopens until the
234
+ next tab click.
235
+
236
+ ### Flat sections (no groups)
237
+
238
+ A section can omit `groups` and just take controls directly via
239
+ `slot="<section-name>"`, exactly as before nesting existed. `section-layout`
240
+ then decides the presentation — stacked `zn-collapsible`s (`"collapsible"`,
241
+ the default) or a `zn-tabs` strip (`"tabs"`) — and is otherwise ignored: once
242
+ *any* section has a populated `groups`, every section renders as a nested tab
243
+ regardless of `section-layout`. Stacked sections get the same load-time
244
+ expansion as tabs: the first one opens unless another already has `open`.
245
+
246
+ ```html
247
+ <zn-theme-editor
248
+ src="/embed?t=..." frame-origin="https://pay.example"
249
+ section-layout="tabs"
250
+ sections='[{"name":"colors","caption":"Colors"},{"name":"layout","caption":"Layout"}]'>
251
+ <zn-color-select slot="colors" name="accent" label="Accent" value="#6936f5"></zn-color-select>
252
+ <zn-input slot="layout" name="radius" label="Corner radius" type="number" value="4"></zn-input>
253
+ </zn-theme-editor>
254
+ ```
255
+
256
+ An author can also slot their own `zn-collapsible` into any named slot (or the
257
+ default slot) instead of relying on `groups` — its presentation is then
258
+ entirely its own; add `flush` yourself if you want it to run the full width of
259
+ the column.
260
+
261
+ ## Preview sources
262
+
263
+ Set `sources` to a JSON array of `{label, src}` to render a dropdown in the
264
+ toolbar, beside the device buttons, for switching which page the preview
265
+ loads:
266
+
267
+ ```html
268
+ <zn-theme-editor
269
+ src="/embed?t=..." frame-origin="https://pay.example"
270
+ sources='[{"label":"Checkout","src":"/embed/checkout"},{"label":"Storefront","src":"/embed/storefront"}]'>
271
+ <zn-color-select name="accent" label="Accent" value="#6936f5"></zn-color-select>
272
+ </zn-theme-editor>
273
+ ```
274
+
275
+ The first entry is the initial selection — it wins over an explicit `src` when
276
+ `sources` is non-empty. Selecting a different entry reloads the iframe; nothing
277
+ further is needed to keep the theme, since the frame retains the last pushed
278
+ payload and replays it once the reloaded page re-announces itself ready. Leave
279
+ `sources` unset (the default) and `src` behaves exactly as it always has, with
280
+ no dropdown rendered.
281
+
282
+ ## Collapsing the controls column
283
+
284
+ Set `controls-collapsed` to hide the controls column, or click the chevron
285
+ toggle that sits on the seam between the columns. Collapsing is purely a
286
+ layout change — it never affects harvested values or pushes a new theme to
287
+ the preview. Below the 768px stacked breakpoint the toggle is hidden, since
288
+ there's no side-by-side seam to tuck into — the editor also un-collapses
289
+ itself if it's already showing `controls-collapsed` when the layout narrows
290
+ that far, so the controls are never stuck unreachable.
291
+
292
+ ## Standalone panel
293
+
294
+ Set `standalone` to present the editor as its own bordered, rounded panel —
295
+ useful when it isn't already embedded in a page shell that provides that
296
+ chrome. It also switches the preview's [backdrop](/components/preview-frame/)
297
+ from the dot grid to a plain panel to match.
298
+
299
+ ```html
300
+ <zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example" standalone>
301
+ <zn-color-select name="accent" label="Accent" value="#6936f5" dark-value="#f5c542"></zn-color-select>
302
+ </zn-theme-editor>
303
+ ```