@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
@@ -27,6 +27,10 @@ are bound by their `name` attribute: values prefill from the section's data and
27
27
  description="Large banner with optional search">
28
28
  <zn-input name="title" label="Title"></zn-input>
29
29
  <zn-input name="subtitle" label="Subtitle"></zn-input>
30
+ <zn-input name="gradient" label="Background gradient" placeholder="linear-gradient(135deg, #00aaff, #aa00ff)"
31
+ help-text="CSS gradient painted behind the hero. Leave empty for the theme default."></zn-input>
32
+ <zn-toggle name="showSearch" label="Show search"
33
+ description="Draws the site search box in the banner."></zn-toggle>
30
34
  </template>
31
35
  <template type="article-list" slot="config" label="Article List" icon="list" category="Content"
32
36
  description="A list of KB articles">
@@ -40,6 +44,24 @@ Types can also be registered programmatically via `sectionTypes` /
40
44
  `registerSectionTypes()`, including a `renderConfig(section, update)` callback for
41
45
  inspector bodies that need real logic.
42
46
 
47
+ ## The inspector
48
+
49
+ Selecting a section opens the inspector, which heads the panel with the section type's
50
+ icon and colour, the section's name, its type (shown only where a renamed section makes
51
+ it useful), and a close button that clears the selection. Below that, a **Section name**
52
+ field renames the section (the card's label), followed by the type's stamped config form.
53
+ A type whose template has no content gets a hint in place of the form rather than an
54
+ empty panel.
55
+
56
+ Because the panel is narrow, the inspector sets its own form-label and help-text
57
+ typography rather than inheriting your app's full-width form styling, and its `gap` is
58
+ the single source of spacing between fields — a control's own outer margins are zeroed
59
+ so they can't stack with it. Stamped `<zn-toggle>`s are laid out as a row, with the
60
+ label and description left and the switch right; set `label-position` yourself on a
61
+ toggle to opt out.
62
+
63
+ Style the panel through `inspector`, `inspector-header` and `inspector-body` parts.
64
+
43
65
  ## JavaScript API
44
66
 
45
67
  - `state` — get/set the current `PageState`. The getter returns a deep copy; the setter
@@ -0,0 +1,345 @@
1
+ ---
2
+ meta:
3
+ title: Schedule Builder
4
+ description: Builds a weekly opening-hours schedule as a drag-to-paint calendar or a compact list of time ranges, and posts the result as JSON.
5
+ layout: component
6
+ ---
7
+
8
+ ```html:preview
9
+ <zn-schedule-builder
10
+ name="opening-hours"
11
+ value='{
12
+ "timezone": "Europe/London",
13
+ "days": {
14
+ "mon": ["08:00-18:00"],
15
+ "tue": ["08:00-18:00"],
16
+ "wed": ["08:00-18:00"],
17
+ "thu": ["08:30-18:00"],
18
+ "fri": ["08:00-18:00"],
19
+ "sat": ["09:00-13:00"],
20
+ "sun": []
21
+ },
22
+ "exceptions": [
23
+ {"label": "All-hands offsite", "date": "2026-08-28", "ranges": ["08:00-13:00"]},
24
+ {"label": "Systems maintenance", "date": "2026-09-14", "ranges": ["10:00-18:00"]},
25
+ {"label": "Christmas Eve — early close", "date": "2026-12-24", "ranges": ["08:00-13:00"]}
26
+ ]
27
+ }'>
28
+ </zn-schedule-builder>
29
+ ```
30
+
31
+ :::tip
32
+ This component works with standard `<form>` elements. Please refer to the section
33
+ on [form controls](/getting-started/form-controls) to learn more about form submission and client-side validation.
34
+ :::
35
+
36
+ ## The Value
37
+
38
+ The schedule is serialised to JSON and exposed three ways:
39
+
40
+ - as the `value` attribute/property — a JSON string, which is what gets submitted with the form;
41
+ - as the `schedule` property — the same data as a plain object;
42
+ - as the `zn-change` event, emitted whenever the schedule changes.
43
+
44
+ ```json
45
+ {
46
+ "timezone": "UTC",
47
+ "days": {
48
+ "mon": [{ "start": "08:00", "end": "18:00" }],
49
+ "tue": [{ "start": "08:00", "end": "12:00" }, { "start": "13:30", "end": "18:00" }],
50
+ "wed": [], "thu": [], "fri": [], "sat": [], "sun": []
51
+ },
52
+ "exceptions": [
53
+ { "label": "Christmas Eve — early close", "date": "2026-12-24", "ranges": [{ "start": "08:00", "end": "13:00" }] }
54
+ ]
55
+ }
56
+ ```
57
+
58
+ Ranges are always sorted, and overlapping or touching ranges are merged, so `08:00–12:00` plus
59
+ `12:00–18:00` becomes a single `08:00–18:00`. Times are 24 hour `HH:MM` regardless of the
60
+ `time-format` used for display, and `timezone` names the timezone they are expressed in — see
61
+ [Timezones](#timezones).
62
+
63
+ When you write the value yourself you can use the `"08:00-18:00"` shorthand instead of
64
+ `{"start": "08:00", "end": "18:00"}`, and you can pass a bare day map without the `days` wrapper.
65
+ Both are normalised on the way in.
66
+
67
+ ## Examples
68
+
69
+ ### Views
70
+
71
+ The builder has two views. `calendar` is a drag-to-paint week grid; `form` is a compact list of time
72
+ ranges that fits inside a normal settings form. Use the `view` attribute to choose the starting
73
+ view, and `no-toggle` to remove the switcher and lock the component to one of them.
74
+
75
+ ```html:preview
76
+ <zn-schedule-builder
77
+ view="form"
78
+ value='{"mon":["08:00-18:00"],"tue":["08:00-18:00"],"wed":["08:00-12:00","13:30-18:00"],"thu":["08:00-18:00"],"fri":["08:00-16:00"],"sat":["09:00-13:00"],"sun":[]}'>
79
+ </zn-schedule-builder>
80
+ ```
81
+
82
+ In the form view, click a range to edit it, and use **Add range** to split a day into multiple
83
+ periods. In the calendar view, drag across the grid to open hours and drag over open hours to close
84
+ them — a drag can span several days at once.
85
+
86
+ ### Timezones
87
+
88
+ A schedule has two timezones: the one it is **stored** in, and the one it is **shown** in.
89
+
90
+ - `save-timezone` is the timezone the value is written in. It defaults to `UTC` as soon as the
91
+ schedule is timezone-aware, so hours land in your database in one canonical zone.
92
+ - `display-timezone` is the timezone the grid and the list are drawn in. It defaults to
93
+ `save-timezone`, and accepts `auto` for the viewer's own timezone.
94
+ - `show-timezone` adds a picker so the viewer can read the same schedule in any timezone.
95
+
96
+ Switching the display timezone never changes the value — the same hours are simply labelled in
97
+ another zone, and `zn-change` doesn't fire. Edits work the other way around: you drag or type in the
98
+ zone you are looking at, and the component converts back to `save-timezone` before storing.
99
+
100
+ ```html:preview
101
+ <zn-schedule-builder
102
+ id="tz-demo"
103
+ show-timezone
104
+ display-timezone="Europe/London"
105
+ start-hour="0"
106
+ end-hour="24"
107
+ hide-summary
108
+ value='{"timezone":"UTC","days":{"mon":["08:00-18:00"],"tue":["08:00-18:00"],"wed":["08:00-18:00"],"thu":["08:00-18:00"],"fri":["08:00-16:00"]}}'>
109
+ </zn-schedule-builder>
110
+
111
+ <pre id="tz-demo-output" style="margin-top: 1rem"></pre>
112
+
113
+ <script>
114
+ const tzBuilder = document.querySelector('#tz-demo');
115
+ const tzOutput = document.querySelector('#tz-demo-output');
116
+
117
+ const printTz = () => (tzOutput.textContent = `stored (UTC):\n${JSON.stringify(tzBuilder.schedule.days, null, 2)}`);
118
+
119
+ tzBuilder.addEventListener('zn-change', printTz);
120
+ printTz();
121
+ </script>
122
+ ```
123
+
124
+ Pick a timezone far from UTC and the blocks slide, wrapping onto the next day — or onto Monday, from
125
+ the end of Sunday — while the stored hours below stay put.
126
+
127
+ #### Which Timezones the Picker Offers
128
+
129
+ `timezones` takes IANA names, or one of three named sets:
130
+
131
+ | Set | Zones | What it is |
132
+ | --- | --- | --- |
133
+ | `en` | 6 | US Eastern, Central, Mountain and Pacific, the UK, and Australia — listed under those names rather than their IANA ones. For an English-speaking audience who would rather not read `America/Los_Angeles`. |
134
+ | `offsets` | ~42 | One zone per UTC offset. The default — enough to read a schedule from anywhere without a long list, though the city standing in for each offset is arbitrary. |
135
+ | `common` | ~90 | Every offset in use, plus the business and population centres that share one — so Berlin, Paris, Madrid and Rome all appear rather than one standing for the rest. Includes the half and quarter-hour zones (India, Iran, Nepal, Newfoundland, central Australia, Chatham). |
136
+ | `all` | ~420 | Everything `Intl.supportedValuesOf('timeZone')` reports. Complete, but full of aliases and zones nobody selects. |
137
+
138
+ `common` is the one to reach for whenever real users pick their own timezone; `offsets` suits an
139
+ internal tool where the offset is all that matters; `en` suits a product whose customers are all in
140
+ the US, the UK or Australia.
141
+
142
+ ```html:preview
143
+ <zn-schedule-builder
144
+ show-timezone
145
+ timezones="en"
146
+ display-timezone="Europe/London"
147
+ view="form"
148
+ value='{"timezone":"UTC","days":{"mon":["13:00-21:00"],"tue":["13:00-21:00"]}}'>
149
+ </zn-schedule-builder>
150
+ ```
151
+
152
+ ```html:preview
153
+ <zn-schedule-builder
154
+ show-timezone
155
+ timezones="common"
156
+ display-timezone="auto"
157
+ view="form"
158
+ value='{"timezone":"UTC","days":{"mon":["13:00-21:00"],"tue":["13:00-21:00"]}}'>
159
+ </zn-schedule-builder>
160
+ ```
161
+
162
+ Sets and explicit names can be mixed — `timezones="en Asia/Tokyo"` adds Tokyo to the six above, and a
163
+ set's friendly name wins over a later plain listing of the same zone. The viewer's own timezone plus
164
+ both configured zones are always added, so the current selection is never missing from the list.
165
+
166
+ ```html:preview
167
+ <zn-schedule-builder
168
+ show-timezone
169
+ display-timezone="America/New_York"
170
+ timezones="UTC Europe/London America/New_York Asia/Tokyo"
171
+ view="form"
172
+ value='{"timezone":"UTC","days":{"mon":["13:00-21:00"],"tue":["13:00-21:00"]}}'>
173
+ </zn-schedule-builder>
174
+ ```
175
+
176
+ Reading the hours in code follows the same split: `schedule`, `value` and `getDay()` are in the save
177
+ timezone, while `displayedDays` and `setDisplayDay()` are in the display timezone.
178
+
179
+ :::tip
180
+ A weekly pattern has no date of its own, so there is no way to know whether daylight saving applies
181
+ to it. Offsets are resolved against today by default; set `reference-date` to a `YYYY-MM-DD` date to
182
+ pin them to a specific point in the year.
183
+ :::
184
+
185
+ ### Open and Closed Labels
186
+
187
+ `open-label` and `closed-label` name the two states. They appear in the calendar legend, against
188
+ days with no hours, and in the exception annotations, so use whatever wording fits the thing being
189
+ scheduled — availability, cover, staffing, opening hours.
190
+
191
+ ```html:preview
192
+ <zn-schedule-builder
193
+ open-label="On call"
194
+ closed-label="Off"
195
+ start-hour="8"
196
+ end-hour="20"
197
+ hide-summary
198
+ value='{"mon":["09:00-17:00"],"tue":["09:00-17:00"],"wed":["09:00-17:00"],"thu":["09:00-17:00"],"fri":["09:00-13:00"]}'>
199
+ </zn-schedule-builder>
200
+ ```
201
+
202
+ ### Labels and Help Text
203
+
204
+ ```html:preview
205
+ <zn-schedule-builder
206
+ label="Opening hours"
207
+ help-text="Customers can only book appointments during these hours."
208
+ view="form"
209
+ value='{"mon":["09:00-17:00"],"tue":["09:00-17:00"],"wed":["09:00-17:00"],"thu":["09:00-17:00"],"fri":["09:00-17:00"]}'>
210
+ </zn-schedule-builder>
211
+ ```
212
+
213
+ ### Grid Range and Granularity
214
+
215
+ `start-hour` and `end-hour` set the window the calendar draws, and `interval` sets both the grid
216
+ granularity and the step of the time inputs in the form view. The interval must be one of `5`, `10`,
217
+ `15`, `20`, `30` or `60` minutes.
218
+
219
+ ```html:preview
220
+ <zn-schedule-builder
221
+ start-hour="7"
222
+ end-hour="13"
223
+ interval="15"
224
+ hide-summary
225
+ value='{"mon":["08:00-12:00"],"tue":["08:15-11:45"],"wed":["08:00-12:00"],"thu":["08:00-12:00"],"fri":["08:00-11:00"]}'>
226
+ </zn-schedule-builder>
227
+ ```
228
+
229
+ ### Exceptions
230
+
231
+ Exceptions are dated deviations from the weekly pattern. The builder never edits them — it displays
232
+ them, and carries them through the value untouched — so the surrounding application stays in charge
233
+ of how they are created.
234
+
235
+ Hours an exception removes are drawn in a lighter shade in the calendar, and annotated against the
236
+ day in the form view. An exception can be a single `date`, or a `from`/`to` window, and can either
237
+ close the day outright (`closed`) or replace its hours (`ranges`).
238
+
239
+ Only exceptions that change the repeating weekly pattern are drawn against it: ones that name their
240
+ `days`, and ones whose date window covers at least a full week. A one-off date is a single
241
+ occurrence rather than a pattern, so it is carried through the value untouched but leaves the grid
242
+ alone.
243
+
244
+ ```html:preview
245
+ <zn-schedule-builder
246
+ view="form"
247
+ value='{
248
+ "days": {
249
+ "mon": ["08:00-18:00"],
250
+ "tue": ["08:00-18:00"],
251
+ "wed": ["08:00-18:00"],
252
+ "thu": ["08:00-18:00"],
253
+ "fri": ["08:00-16:00"],
254
+ "sat": ["09:00-13:00"],
255
+ "sun": []
256
+ },
257
+ "exceptions": [
258
+ {"label": "Summer Fridays", "from": "2026-06-01", "to": "2026-08-29", "days": ["fri"], "ranges": ["08:00-16:00"]},
259
+ {"label": "Bank holiday", "date": "2026-08-31", "closed": true}
260
+ ]
261
+ }'>
262
+ </zn-schedule-builder>
263
+ ```
264
+
265
+ ### Week Start and Time Format
266
+
267
+ ```html:preview
268
+ <zn-schedule-builder
269
+ week-start="sun"
270
+ time-format="12"
271
+ view="form"
272
+ value='{"mon":["08:00-18:00"],"sat":["09:00-13:00"],"sun":["10:00-16:00"]}'>
273
+ </zn-schedule-builder>
274
+ ```
275
+
276
+ ### Readonly and Disabled
277
+
278
+ Use `readonly` to display a schedule without any editing affordances, and `disabled` to grey it out
279
+ and take it out of form submission.
280
+
281
+ ```html:preview
282
+ <zn-schedule-builder
283
+ readonly
284
+ view="form"
285
+ value='{"mon":["08:00-18:00"],"tue":["08:00-18:00"],"wed":["08:00-18:00"],"thu":["08:00-18:00"],"fri":["08:00-18:00"],"sat":["09:00-13:00"]}'>
286
+ </zn-schedule-builder>
287
+ ```
288
+
289
+ ### Reading the Value from JavaScript
290
+
291
+ ```html:preview
292
+ <zn-schedule-builder
293
+ id="schedule-demo"
294
+ view="form"
295
+ value='{"mon":["09:00-17:00"],"tue":["09:00-17:00"]}'>
296
+ </zn-schedule-builder>
297
+
298
+ <pre id="schedule-demo-output" style="margin-top: 1rem"></pre>
299
+
300
+ <script>
301
+ const builder = document.querySelector('#schedule-demo');
302
+ const output = document.querySelector('#schedule-demo-output');
303
+
304
+ const print = () => (output.textContent = JSON.stringify(builder.schedule, null, 2));
305
+
306
+ builder.addEventListener('zn-change', print);
307
+ print();
308
+ </script>
309
+ ```
310
+
311
+ ### In a Form
312
+
313
+ The schedule posts as a single JSON field named after the control.
314
+
315
+ ```html:preview
316
+ <form class="schedule-form">
317
+ <zn-schedule-builder
318
+ name="opening-hours"
319
+ label="Opening hours"
320
+ required
321
+ view="form"
322
+ value='{"mon":["09:00-17:00"],"tue":["09:00-17:00"],"wed":["09:00-17:00"],"thu":["09:00-17:00"],"fri":["09:00-17:00"]}'>
323
+ </zn-schedule-builder>
324
+
325
+ <br />
326
+ <zn-button type="submit">Submit</zn-button>
327
+ </form>
328
+
329
+ <script>
330
+ const form = document.querySelector('.schedule-form');
331
+
332
+ form.addEventListener('submit', event => {
333
+ event.preventDefault();
334
+ const data = new FormData(form);
335
+ alert(data.get('opening-hours'));
336
+ });
337
+ </script>
338
+ ```
339
+
340
+ ## Accessibility
341
+
342
+ The form view is fully keyboard operable: ranges are buttons, editing a range gives you two native
343
+ time inputs, and <kbd>Enter</kbd> or <kbd>Escape</kbd> closes the editor. The calendar view is a
344
+ pointer-driven surface, so offer the form view (the default toggle does) wherever keyboard-only
345
+ users need to edit a schedule.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.92",
3
+ "version": "1.1.94",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -14,10 +14,6 @@
14
14
 
15
15
  :host([appearance="solid"]) {
16
16
  background-color: rgb(var(--zn-panel));
17
-
18
- .wrapper:before {
19
- backdrop-filter: none;
20
- }
21
17
  }
22
18
 
23
19
  .alert-icon {
@@ -28,20 +24,20 @@ p {
28
24
  margin: 0;
29
25
  }
30
26
 
27
+ // No backdrop-filter here, deliberately. This used to carry a
28
+ // `::before { inset: 0; z-index: -1; backdrop-filter: blur(3px) }`, which forces
29
+ // a compositor render surface per alert and re-blurs whenever anything behind it
30
+ // repaints. Alerts are the one component that routinely appears dozens of times
31
+ // on a page (one per warning, per row), so that scaled into hundreds of render
32
+ // surfaces and could stop the compositor rasterising the page at all. The host
33
+ // background is `rgba(--zn-panel, 0.8)` over a flat panel, so the blur was not
34
+ // perceptible anyway — it only ever mattered over a high-frequency backdrop,
35
+ // which is not where alerts sit.
31
36
  .wrapper {
32
37
  position: relative;
33
38
  border: 1px solid rgb(var(--zn-border-color)) !important;
34
39
  width: 100%;
35
40
  height: 100%;
36
-
37
- &:before {
38
- border-radius: 10px;
39
- position: absolute;
40
- content: '';
41
- inset: 0;
42
- z-index: -1;
43
- backdrop-filter: blur(3px);
44
- }
45
41
  }
46
42
 
47
43
  :host([center]) .content {
@@ -10,7 +10,7 @@
10
10
  font-size: var(--zn-text-chip-font-size);
11
11
  font-family: var(--zn-font-family-sans);
12
12
  font-weight: var(--zn-text-chip-font-weight);
13
- border-radius: var(--zn-border-radius-small);
13
+ border-radius: var(--zn-border-radius-x-large);
14
14
  padding-inline: var(--zn-spacing-x-small);
15
15
  padding-block: 0;
16
16
  height: var(--zn-spacing-medium);
@@ -319,7 +319,7 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
319
319
 
320
320
  return html`
321
321
  <div part="form-control"
322
- class="form-control ${hasLabel ? 'form-control--has-label' : ''} ${hasHelpText ? 'form-control--has-help-text' : ''}">
322
+ class="form-control form-control--medium ${hasLabel ? 'form-control--has-label' : ''} ${hasHelpText ? 'form-control--has-help-text' : ''}">
323
323
 
324
324
  <label part="form-control-label" class="form-control__label"
325
325
  aria-hidden=${hasLabel ? 'false' : 'true'}>
@@ -12,8 +12,9 @@ interface linkedSelectOption {
12
12
  [key: string]: string;
13
13
  }
14
14
 
15
+ // Each group is a flat value -> label map, keyed on the linked select's value.
15
16
  interface linkedSelectOptions {
16
- [key: string]: linkedSelectOption[];
17
+ [key: string]: linkedSelectOption;
17
18
  }
18
19
 
19
20
  /**
@@ -46,6 +47,9 @@ export default class ZnLinkedSelect extends ZincElement implements ZincFormContr
46
47
  @property({attribute: 'cache-key'}) cacheKey: string = "";
47
48
  @property() label: string = "";
48
49
 
50
+ /** Automatically select the first option of the linked group when no value is set. */
51
+ @property({attribute: 'select-first', type: Boolean}) selectFirst = false;
52
+
49
53
  @query('zn-select') input: ZnSelect;
50
54
 
51
55
  private linkedSelectElement: HTMLSelectElement | ZnSelect;
@@ -123,7 +127,10 @@ export default class ZnLinkedSelect extends ZincElement implements ZincFormContr
123
127
  }
124
128
 
125
129
  public handleLinkedSelectChange = () => {
126
- this.value = "";
130
+ // The inner select only auto-selects on slotchange, which lit skips when it
131
+ // can reuse the option elements of the outgoing group, so the first option of
132
+ // the new group is resolved here rather than left to the select.
133
+ this.value = this.selectFirst ? Object.keys(this.currentOptions())[0] ?? "" : "";
127
134
  this.requestUpdate();
128
135
  this.formControlController.updateValidity();
129
136
  };
@@ -137,19 +144,29 @@ export default class ZnLinkedSelect extends ZincElement implements ZincFormContr
137
144
  this.value = (e.target as ZnSelect).value as string;
138
145
  }
139
146
 
140
- render() {
147
+ /** The options of the group the linked select currently points at. */
148
+ private currentOptions(): linkedSelectOption {
149
+ if (!this.options) {
150
+ return {};
151
+ }
152
+
141
153
  let selected = this.linkedSelectElement?.value as string;
142
- if (!selected && this.options) {
154
+ if (!selected) {
143
155
  selected = Object.keys(this.options)[0];
144
156
  }
145
157
 
146
- const options: linkedSelectOption[] = selected ? this.options[selected] : [];
158
+ return (selected ? this.options[selected] : {}) ?? {};
159
+ }
160
+
161
+ render() {
162
+ const options = this.currentOptions();
147
163
  return html`
148
164
  <zn-select part="select"
149
165
  class="linked-select"
150
166
  name="${this.name}"
151
167
  id="main-input"
152
168
  cache-key="${this.cacheKey}"
169
+ ?select-first="${this.selectFirst}"
153
170
  value="${this.value}"
154
171
  @zn-change=${this.handleSelectChange}
155
172
  label="${this.label}">
@@ -57,8 +57,20 @@
57
57
  background: linear-gradient(225deg, rgba(233, 227, 255, 1) 0%, rgba(214, 246, 245, 1) 40%, rgba(233, 227, 255, 1) 100%), linear-gradient(180deg, rgba(129, 153, 217, 0) 0%, rgba(0, 0, 0, 1) 100%);
58
58
 
59
59
 
60
+ // Held still deliberately. This gradient used to drift via
61
+ // `animation: page-header 25s ease infinite`, which animates
62
+ // `background-position` — a paint-only property, so it can never be
63
+ // composited. That repainted the header every frame for as long as the page
64
+ // was open, and because the header is `position: sticky` its paint chunk
65
+ // spans the whole page, so every backdrop-filter behind it (zn-alert, for
66
+ // one) was re-blurred each frame as well. On content-heavy pages that put
67
+ // enough work into each frame that tiles never finished rasterising: blank
68
+ // regions, content appearing only as you scrolled, and a dead inspector
69
+ // overlay. `0% 7%` is frame zero of the old keyframes, so the header looks as
70
+ // it did. If the drift is wanted back it has to move a compositable property
71
+ // (transform on a clipped, contained layer), not background-position.
60
72
  background-size: 150% 150%;
61
- animation: page-header 25s ease infinite;
73
+ background-position: 0% 7%;
62
74
 
63
75
  .header:not(.header--has-navigation) {
64
76
  padding-bottom: var(--zn-base-gap);
@@ -91,12 +103,6 @@
91
103
  }
92
104
  }
93
105
 
94
- @keyframes page-header {
95
- 0%{background-position:0% 7%}
96
- 50%{background-position:100% 94%}
97
- 100%{background-position:0% 7%}
98
- }
99
-
100
106
  .header {
101
107
  display: flex;
102
108
  flex-direction: column;
@@ -77,6 +77,8 @@ function timeAgo(ms: number): string {
77
77
  * @csspart palette - The left palette panel.
78
78
  * @csspart canvas - The centre section-card canvas.
79
79
  * @csspart inspector - The right panel while a section is selected.
80
+ * @csspart inspector-header - The inspector's fixed header (icon, section name, type, close).
81
+ * @csspart inspector-body - The inspector's scrolling form area.
80
82
  */
81
83
  export default class ZnPageBuilder extends ZincElement {
82
84
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -1017,6 +1019,21 @@ export default class ZnPageBuilder extends ZincElement {
1017
1019
  const form = document.createElement('div');
1018
1020
  form.className = 'inspector__form';
1019
1021
  form.append(type.configTemplate.content.cloneNode(true));
1022
+ // Custom elements cloned out of a <template> are inert until they are
1023
+ // connected, so their accessors do not exist yet. Assigning `value` to one
1024
+ // would define an own property that permanently shadows the setter the
1025
+ // element defines on upgrade — Lit only replays properties it declares
1026
+ // reactive, so a hand-written `value` accessor (zn-icon-picker) would keep
1027
+ // its default and the control would render empty. Upgrade first.
1028
+ //
1029
+ // Toggles default to a stacked label, which reads badly in a narrow panel —
1030
+ // put the switch on the label's right unless the host asked for a position.
1031
+ // Set before the upgrade, while `label-position` can only be an authored
1032
+ // attribute and never one zn-toggle reflected from its own default.
1033
+ form.querySelectorAll<HTMLElement>('zn-toggle:not([label-position])').forEach(toggle => {
1034
+ toggle.setAttribute('label-position', 'left');
1035
+ });
1036
+ customElements.upgrade(form);
1020
1037
  form.querySelectorAll<HTMLElement>('[name]').forEach(control => {
1021
1038
  const name = control.getAttribute('name')!;
1022
1039
  const value = section.data[name];
@@ -1029,7 +1046,9 @@ export default class ZnPageBuilder extends ZincElement {
1029
1046
  (control as HTMLInputElement).value = String(value);
1030
1047
  }
1031
1048
  });
1032
- this._form = form;
1049
+ // A content-free template stamps no controls — leave the form null so the
1050
+ // inspector shows its "no settings" hint rather than a blank gap.
1051
+ this._form = form.childElementCount ? form : null;
1033
1052
  }
1034
1053
 
1035
1054
  private _isBooleanControl(control: HTMLElement, value: unknown): boolean {
@@ -1069,22 +1088,48 @@ export default class ZnPageBuilder extends ZincElement {
1069
1088
  const section = this._selectedSection();
1070
1089
  if (!section) return html``;
1071
1090
  const type = this.registry.get(section.type);
1091
+ const hasConfig = Boolean(type?.renderConfig) || this._form !== null;
1092
+ const title = section.label ?? type?.label ?? section.type;
1093
+ const typeLabel = type?.label ?? section.type;
1072
1094
  return html`
1073
1095
  <aside part="inspector" class="inspector">
1074
- <zn-input
1075
- class="inspector__rename"
1076
- label="Section name"
1077
- .value="${section.label ?? type?.label ?? ''}"
1078
- @zn-change="${(e: Event) => this._renameSection(section.id, String((e.target as ZnInput).value ?? ''))}"></zn-input>
1096
+ <div part="inspector-header" class="inspector-head">
1097
+ <span
1098
+ class="inspector-head__icon"
1099
+ style="--section-accent:${type?.color ?? 'rgb(var(--zn-color-primary))'}">
1100
+ <zn-icon src="${type?.icon ?? 'widgets'}" library="${ifDefined(type?.iconLibrary)}" size="18"></zn-icon>
1101
+ </span>
1102
+ <div class="inspector-head__text">
1103
+ <div class="inspector-head__title">${title}</div>
1104
+ ${title === typeLabel ? '' : html`
1105
+ <div class="inspector-head__type">${typeLabel}</div>`}
1106
+ </div>
1107
+ <button
1108
+ type="button"
1109
+ class="inspector-close"
1110
+ title="Close section settings"
1111
+ aria-label="Close section settings"
1112
+ @click="${() => this._select(null)}">
1113
+ <zn-icon src="x@lu" size="18"></zn-icon>
1114
+ </button>
1115
+ </div>
1079
1116
  <div
1080
- class="inspector__body"
1117
+ part="inspector-body"
1118
+ class="inspector-body"
1081
1119
  @change="${this._onInspectorInput}"
1082
1120
  @zn-change="${this._onInspectorInput}"
1083
1121
  @input="${this._onInspectorInput}"
1084
1122
  @zn-input="${this._onInspectorInput}">
1123
+ <zn-input
1124
+ class="inspector__rename"
1125
+ label="Section name"
1126
+ .value="${section.label ?? type?.label ?? ''}"
1127
+ @zn-change="${(e: Event) => this._renameSection(section.id, String((e.target as ZnInput).value ?? ''))}"></zn-input>
1085
1128
  ${type?.renderConfig
1086
1129
  ? type.renderConfig(section, data => this._updateSectionData(section.id, data))
1087
1130
  : this._form}
1131
+ ${hasConfig ? '' : html`
1132
+ <p class="inspector-hint">This section type has no settings.</p>`}
1088
1133
  </div>
1089
1134
  </aside>`;
1090
1135
  }