@kubex/zinc 1.1.92 → 1.1.95

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 (53) hide show
  1. package/dist/custom-elements.json +2880 -453
  2. package/dist/vscode.html-custom-data.json +209 -22
  3. package/dist/web-types.json +452 -39
  4. package/dist/zn.d.ts +633 -61
  5. package/dist/zn.min.css +1 -1
  6. package/dist/zn.min.js +865 -560
  7. package/docs/pages/components/page-builder.md +143 -19
  8. package/docs/pages/components/schedule-builder.md +345 -0
  9. package/docs/pages/components/slash-menu.md +132 -6
  10. package/docs/pages/components/textarea.md +16 -0
  11. package/package.json +1 -1
  12. package/scss/_root.scss +7 -1
  13. package/src/components/alert/alert.scss +9 -13
  14. package/src/components/button/button.scss +5 -2
  15. package/src/components/chip/chip.scss +1 -1
  16. package/src/components/icon-picker/icon-picker.component.ts +1 -1
  17. package/src/components/inline-edit/inline-edit.component.ts +6 -1
  18. package/src/components/input/input.component.ts +12 -2
  19. package/src/components/linked-select/linked-select.component.ts +22 -5
  20. package/src/components/page/page.scss +20 -9
  21. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
  22. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
  23. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
  24. package/src/components/page-builder/page-builder.component.ts +535 -232
  25. package/src/components/page-builder/page-builder.scss +230 -19
  26. package/src/components/page-builder/page-builder.test.ts +790 -110
  27. package/src/components/page-builder/page-tree.test.ts +483 -0
  28. package/src/components/page-builder/page-tree.ts +329 -0
  29. package/src/components/page-builder/page.types.ts +98 -10
  30. package/src/components/page-nav/page-nav.scss +9 -1
  31. package/src/components/panel/panel.component.ts +5 -1
  32. package/src/components/priority-list/priority-list.component.ts +1 -0
  33. package/src/components/priority-list/priority-list.scss +2 -1
  34. package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
  35. package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
  36. package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
  37. package/src/components/schedule-builder/index.ts +12 -0
  38. package/src/components/schedule-builder/schedule-builder.component.ts +1543 -0
  39. package/src/components/schedule-builder/schedule-builder.scss +448 -0
  40. package/src/components/schedule-builder/schedule-builder.test.ts +344 -0
  41. package/src/components/settings-container/settings-container.scss +2 -1
  42. package/src/components/slash-item/slash-item.component.ts +1 -1
  43. package/src/components/slash-menu/slash-menu-items.ts +48 -0
  44. package/src/components/slash-menu/slash-menu.component.ts +134 -27
  45. package/src/components/slash-menu/slash-menu.scss +90 -12
  46. package/src/components/slash-menu/slash-menu.test.ts +107 -0
  47. package/src/components/textarea/textarea.component.ts +12 -2
  48. package/src/components/textarea/textarea.test.ts +2 -2
  49. package/src/components/toggle/toggle.component.ts +2 -1
  50. package/src/components/translations/translations.component.ts +5 -1
  51. package/src/zinc.ts +1 -0
  52. package/docs/superpowers/plans/2026-08-03-theme-editor.md +0 -1536
  53. package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +0 -327
@@ -16,15 +16,16 @@ for you, as [`zn-textarea`](/components/textarea#slash-menu-quick-insertions) do
16
16
  slash-items="Brand name={{BRAND_NAME}}, Legal entity={{LEGAL_ENTITY}}"></zn-textarea>
17
17
  ```
18
18
 
19
- Slot one into a textarea when you want the panel's own settings — `heading`, `max-items`, `placement`, `empty-text`, or
20
- its width — declared in markup. The textarea then drives your menu instead of building its own:
19
+ Slot one into a textarea when you want the panel's own settings — `max-items`, `placement`, `empty-text`, its width, or
20
+ the `heading` the list is announced by — declared in markup. The textarea then drives your menu instead of building its
21
+ own:
21
22
 
22
23
  ```html:preview
23
24
  <zn-textarea label="Terms and conditions" rows="6" help-text="Type / to insert">
24
25
  <zn-slash-menu slot="slash-menu" heading="Replacement strings" max-items="6" style="--slash-menu-width: 360px">
25
- <zn-slash-item icon="tag@lu" label="Brand name" value="{{BRAND_NAME}}"></zn-slash-item>
26
- <zn-slash-item icon="building@lu" label="Legal entity" value="{{LEGAL_ENTITY}}"></zn-slash-item>
27
- <zn-slash-item icon="scale@lu" label="Jurisdiction" value="{{JURISDICTION}}"></zn-slash-item>
26
+ <zn-slash-item group="Merchant" icon="tag@lu" label="Brand name" value="{{BRAND_NAME}}"></zn-slash-item>
27
+ <zn-slash-item group="Merchant" icon="building@lu" label="Legal entity" value="{{LEGAL_ENTITY}}"></zn-slash-item>
28
+ <zn-slash-item group="Policy" icon="scale@lu" label="Jurisdiction" value="{{JURISDICTION}}"></zn-slash-item>
28
29
  </zn-slash-menu>
29
30
  </zn-textarea>
30
31
  ```
@@ -107,9 +108,134 @@ The menu doesn't listen for keys itself — whatever owns the field decides whic
107
108
  </script>
108
109
  ```
109
110
 
111
+ ### Grouping Items
112
+
113
+ An item's `group` puts a heading above it. The heading is drawn whenever the group changes going down the list, so
114
+ items sharing a group must be declared together — the menu lists them in the order it is given rather than gathering
115
+ them for you. Leave the group off and an item is listed under no heading at all; declared first, those lead the list,
116
+ which is how a handful of favourites can sit above named sections.
117
+
118
+ ```html:preview
119
+ <zn-textarea label="Privacy policy" rows="7" help-text="Type / to see the sections, or 'company' to search across them">
120
+ <zn-slash-menu slot="slash-menu" style="--slash-menu-width: 340px">
121
+ <zn-slash-item icon="star@lu" label="Merchant block" value="{{MERCHANT_BLOCK}}"></zn-slash-item>
122
+ <zn-slash-item group="Merchant" icon="tag@lu" label="Brand name" keywords="company" value="{{BRAND_NAME}}"></zn-slash-item>
123
+ <zn-slash-item group="Merchant" icon="building@lu" label="Legal entity" keywords="company" value="{{LEGAL_ENTITY}}"></zn-slash-item>
124
+ <zn-slash-item group="Customer" icon="user@lu" label="Customer name" value="{{CUSTOMER_NAME}}"></zn-slash-item>
125
+ <zn-slash-item group="Customer" icon="mail@lu" label="Customer email" value="{{CUSTOMER_EMAIL}}"></zn-slash-item>
126
+ <zn-slash-item group="Policy" icon="scale@lu" label="Jurisdiction" value="{{JURISDICTION}}"></zn-slash-item>
127
+ <zn-slash-item group="Policy" icon="calendar@lu" label="Refund window" value="{{REFUND_DAYS}} days"></zn-slash-item>
128
+ </zn-slash-menu>
129
+ </zn-textarea>
130
+ ```
131
+
132
+ Group headings are only a structure for browsing: a query ranks every match on merit, and the headings follow whatever
133
+ order that leaves. Set `order` on an item to pin its place within a match band, and use `keywords` to make it findable
134
+ by terms that aren't in its label.
135
+
136
+ Driving the menu yourself, the same thing is a `group` on each item:
137
+
138
+ ```html:preview
139
+ <zn-button id="grouped-anchor">Open the menu</zn-button>
140
+ <zn-slash-menu id="grouped-menu"></zn-slash-menu>
141
+ <div id="grouped-log" style="margin-top: 1rem; font-family: monospace; font-size: 0.875rem;"></div>
142
+
143
+ <script type="module">
144
+ const anchor = document.getElementById('grouped-anchor');
145
+ const menu = document.getElementById('grouped-menu');
146
+ const log = document.getElementById('grouped-log');
147
+
148
+ await customElements.whenDefined('zn-slash-menu');
149
+
150
+ menu.items = [
151
+ {label: 'Paragraph', value: '', icon: 'type@lu', group: 'Basic blocks'},
152
+ {label: 'Heading 1', value: '# ', icon: 'heading-1@lu', group: 'Basic blocks'},
153
+ {label: 'Heading 2', value: '## ', icon: 'heading-2@lu', group: 'Basic blocks'},
154
+ {label: 'To-do list', value: '- [ ] ', icon: 'square-check@lu', group: 'Lists'},
155
+ {label: 'Bulleted list', value: '- ', icon: 'list@lu', group: 'Lists'},
156
+ {label: 'Callout', value: 'NOTE: ', icon: 'info@lu', group: 'Advanced'},
157
+ {label: 'Quote', value: '> ', icon: 'quote@lu', group: 'Advanced'}
158
+ ];
159
+ menu.hideKeys = true;
160
+ menu.anchor = anchor;
161
+
162
+ anchor.addEventListener('click', () => menu.open ? menu.hide() : menu.show());
163
+
164
+ menu.addEventListener('zn-slash-item-select', (event) => {
165
+ log.textContent = `selected ${event.detail.item.label} from ${event.detail.item.group}`;
166
+ menu.hide();
167
+ });
168
+ </script>
169
+ ```
170
+
171
+ ### Keyboard Hints
172
+
173
+ A footer pinned to the bottom of the panel spells out the keys the menu answers to. The list scrolls beneath it, so
174
+ the hints stay in view. Use `hide-hints` on menus driven entirely by the mouse, or where the surrounding UI already
175
+ explains the shortcuts.
176
+
177
+ ```html:preview
178
+ <zn-textarea label="Terms and conditions" rows="4" help-text="Type / to see the hints"
179
+ slash-items="Brand name={{BRAND_NAME}}, Legal entity={{LEGAL_ENTITY}}, Jurisdiction={{JURISDICTION}}">
180
+ <zn-slash-menu slot="slash-menu" heading="Replacement strings"></zn-slash-menu>
181
+ </zn-textarea>
182
+
183
+ <zn-textarea label="Internal note" rows="4" help-text="Type / — no hints"
184
+ slash-items="Brand name={{BRAND_NAME}}, Legal entity={{LEGAL_ENTITY}}, Jurisdiction={{JURISDICTION}}">
185
+ <zn-slash-menu slot="slash-menu" heading="Replacement strings" hide-hints></zn-slash-menu>
186
+ </zn-textarea>
187
+ ```
188
+
189
+ ### Recently Used
190
+
191
+ Set `recent-key` and the menu remembers what was chosen there, listing the most recent of those items above the rest
192
+ under their own heading. The key is where the menu is used — `page-body`, `ticket-reply` — so each place keeps its own
193
+ history in `localStorage`, and two fields that should share one can share a key. `max-recent` caps the section
194
+ (3 by default), `recent-heading` names it, and `clearRecent()` forgets the lot. The section stands aside as soon as
195
+ there is a query, when the ranked matches are the better answer.
196
+
197
+ It reads as one more group, so it sits naturally above [grouped items](#grouping-items) — the section's own heading, then
198
+ the sections the list already had. Insert a few from the first field below to see it fill:
199
+
200
+ ```html:preview
201
+ <zn-textarea label="Privacy policy" rows="6" help-text="Type / and insert a few — they come back to the top">
202
+ <zn-slash-menu slot="slash-menu" recent-key="docs-grouped" style="--slash-menu-width: 340px">
203
+ <zn-slash-item group="Merchant" icon="tag@lu" label="Brand name" value="{{BRAND_NAME}}"></zn-slash-item>
204
+ <zn-slash-item group="Merchant" icon="building@lu" label="Legal entity" value="{{LEGAL_ENTITY}}"></zn-slash-item>
205
+ <zn-slash-item group="Customer" icon="user@lu" label="Customer name" value="{{CUSTOMER_NAME}}"></zn-slash-item>
206
+ <zn-slash-item group="Customer" icon="mail@lu" label="Customer email" value="{{CUSTOMER_EMAIL}}"></zn-slash-item>
207
+ <zn-slash-item group="Policy" icon="scale@lu" label="Jurisdiction" value="{{JURISDICTION}}"></zn-slash-item>
208
+ </zn-slash-menu>
209
+ </zn-textarea>
210
+
211
+ <zn-button id="forget-recent" style="margin-top: 1rem">Forget them</zn-button>
212
+
213
+ <script type="module">
214
+ import {clearRecentSlashItems} from '/dist/zn.min.js';
215
+
216
+ document.getElementById('forget-recent').addEventListener('click', () => {
217
+ clearRecentSlashItems('docs-grouped');
218
+ clearRecentSlashItems('docs-terms');
219
+ });
220
+ </script>
221
+ ```
222
+
223
+ Where the items below carry no heading of their own, a rule closes the section off instead. This field shares nothing
224
+ with the one above — each key is its own history:
225
+
226
+ ```html:preview
227
+ <zn-textarea label="Terms and conditions" rows="5" help-text="Type / and insert a few — the rule marks where they end"
228
+ slash-recent-key="docs-terms"
229
+ slash-items="Brand name={{BRAND_NAME}}, Legal entity={{LEGAL_ENTITY}}, Jurisdiction={{JURISDICTION}},
230
+ Customer name={{CUSTOMER_NAME}}, Support email={{SUPPORT_EMAIL}}"></zn-textarea>
231
+ ```
232
+
233
+ `zn-input`, `zn-inline-edit`, `zn-translations` and `zn-remarkd-editor` take the same `slash-recent-key`. On a menu you
234
+ slot in yourself, or drive with `SlashMenuController`, set `recent-key` on the `zn-slash-menu` directly.
235
+
110
236
  ### Truncating Long Lists
111
237
 
112
- `max-items` caps how many items are rendered; the rest are reported in a footer rather than silently dropped. The panel
238
+ `max-items` caps how many items are rendered; the rest are reported in a footer rather than silently dropped. The list
113
239
  scrolls when its content exceeds `--slash-menu-max-height`.
114
240
 
115
241
  ```html:preview
@@ -501,6 +501,22 @@ the merge fields an application allows.
501
501
  </script>
502
502
  ```
503
503
 
504
+ #### Recently Used Items
505
+
506
+ `slash-recent-key` remembers what was inserted here and lists those items above the rest next time, under a
507
+ "Recently used" heading. The key names the place the field is used, so each keeps its own history — see
508
+ [`zn-slash-menu`](/components/slash-menu#recently-used) for the menu's own settings.
509
+
510
+ ```html:preview
511
+ <zn-textarea
512
+ label="Refund policy"
513
+ rows="5"
514
+ slash-recent-key="docs-textarea-refunds"
515
+ help-text="Type / and insert a couple — they come back to the top"
516
+ slash-items="Brand name={{BRAND_NAME}}, Legal entity={{LEGAL_ENTITY}}, Jurisdiction={{JURISDICTION}},
517
+ Refund window={{REFUND_DAYS}}, Support email={{SUPPORT_EMAIL}}"></zn-textarea>
518
+ ```
519
+
504
520
  #### Changing the Trigger
505
521
 
506
522
  Set `slash-trigger` to any characters. Using `{{` lets someone who already knows the token they want type it directly
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.92",
3
+ "version": "1.1.95",
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",
package/scss/_root.scss CHANGED
@@ -350,8 +350,14 @@ ul.square {
350
350
 
351
351
  // Frosted-glass inactive tabs for the zn-page tab navbar (consumed in
352
352
  // components/navbar/navbar.scss, scoped there to :host-context(zn-page)).
353
+ //
354
+ // The translucent background carries the frosted look on its own. Do NOT put a
355
+ // backdrop-filter here: navbar.scss applies it per inactive tab, so each tab
356
+ // becomes its own render surface and re-samples + re-blurs its backdrop every
357
+ // frame. On a page with a normal number of tabs that alone pushed GPU draw to
358
+ // ~8ms/frame and dropped frames on every scroll.
353
359
  --navbar-page-inactive-tab-background: rgba(255, 255, 255, 0.5);
354
- --navbar-page-inactive-tab-filter: blur(2px);
360
+ --navbar-page-inactive-tab-filter: none;
355
361
 
356
362
  .zn-primary {
357
363
  color: rgb(var(--zn-color-primary));
@@ -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 {
@@ -456,8 +456,11 @@
456
456
  inset-inline-start: 0;
457
457
  bottom: 0;
458
458
  border-left: solid 1px;
459
- mix-blend-mode: multiply;
460
- color: rgb(var(--zn-border-color));
459
+ // PERF: a blend mode here gave every non-first button in every group its own
460
+ // render surface, and the :not(:hover) above meant hovering a group added and
461
+ // removed surfaces — re-running layerize and re-compositing on pointer move.
462
+ // A semi-transparent border reads the same over the button fills.
463
+ color: rgba(var(--zn-border-color), 0.85);
461
464
  }
462
465
  }
463
466
 
@@ -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'}>
@@ -117,12 +117,15 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
117
117
  /** The characters that open the slash menu. */
118
118
  @property({ attribute: 'slash-trigger' }) slashTrigger = '/';
119
119
 
120
- /** The heading shown above the slash menu's items. */
120
+ /** The name the slash menu's list is announced by. */
121
121
  @property({ attribute: 'slash-heading' }) slashHeading = 'Insert';
122
122
 
123
123
  /** Hides the insertion keys normally shown against the slash menu's items. */
124
124
  @property({ attribute: 'slash-hide-keys', type: Boolean }) slashHideKeys = false;
125
125
 
126
+ /** Lists the slash menu items most recently chosen here above the rest, remembered under this key. */
127
+ @property({ attribute: 'slash-recent-key' }) slashRecentKey = '';
128
+
126
129
  /** Resolves additional slash menu items each time the menu opens. JavaScript only. */
127
130
  @property({ attribute: false }) slashItemsProvider?: (query: string) => SlashMenuItem[] | Promise<SlashMenuItem[]>;
128
131
 
@@ -466,6 +469,7 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
466
469
  slash-trigger="${this.slashTrigger}"
467
470
  slash-heading="${this.slashHeading}"
468
471
  slash-preset="${this.slashPreset}"
472
+ slash-recent-key="${this.slashRecentKey}"
469
473
  ?slash-hide-keys="${this.slashHideKeys}"
470
474
  .slashItems="${this.slashItems}"
471
475
  .slashItemsProvider="${this.slashItemsProvider}"
@@ -489,6 +493,7 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
489
493
  slash-trigger="${this.slashTrigger}"
490
494
  slash-heading="${this.slashHeading}"
491
495
  slash-preset="${this.slashPreset}"
496
+ slash-recent-key="${this.slashRecentKey}"
492
497
  ?slash-hide-keys="${this.slashHideKeys}"
493
498
  .slashItems="${this.slashItems}"
494
499
  .slashItemsProvider="${this.slashItemsProvider}"
@@ -276,12 +276,18 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
276
276
  /** The characters that open the slash menu. */
277
277
  @property({attribute: 'slash-trigger'}) slashTrigger = '/';
278
278
 
279
- /** The heading shown above the slash menu's items. */
279
+ /** The name the slash menu's list is announced by. */
280
280
  @property({attribute: 'slash-heading'}) slashHeading = 'Insert';
281
281
 
282
282
  /** Hides the insertion keys normally shown against the slash menu's items. */
283
283
  @property({attribute: 'slash-hide-keys', type: Boolean}) slashHideKeys = false;
284
284
 
285
+ /**
286
+ * Lists the items most recently chosen here above the rest, remembered in `localStorage` under this
287
+ * key. Share a key between the fields that should share a history; leave unset to offer no such list.
288
+ */
289
+ @property({attribute: 'slash-recent-key'}) slashRecentKey = '';
290
+
285
291
  /**
286
292
  * Resolves additional items each time the menu opens, for lists that come from elsewhere (e.g. an
287
293
  * API). Receives the current query and may return a promise. JavaScript only.
@@ -1100,7 +1106,11 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
1100
1106
  </div>
1101
1107
  ${this.hasSlashMenu
1102
1108
  ? html`
1103
- <zn-slash-menu part="slash-menu" heading=${this.slashHeading} ?hide-keys=${this.slashHideKeys}></zn-slash-menu>`
1109
+ <zn-slash-menu
1110
+ part="slash-menu"
1111
+ heading=${this.slashHeading}
1112
+ recent-key=${this.slashRecentKey}
1113
+ ?hide-keys=${this.slashHideKeys}></zn-slash-menu>`
1104
1114
  : ''}
1105
1115
  <slot name="slash-menu"></slot>
1106
1116
  <slot name="slash-items" hidden></slot>
@@ -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);
@@ -81,8 +93,13 @@
81
93
  background-repeat: no-repeat;
82
94
  background-position: bottom center;
83
95
  background-size: cover;
84
- mix-blend-mode: color-burn;
85
- opacity: 0.2;
96
+ // PERF: mix-blend-mode forces .page__header .header — which contains the tab
97
+ // navbar — into its own render surface with a backdrop readback. Dropped in
98
+ // favour of plain alpha compositing. This is a small visual change to the
99
+ // aurora wave (it no longer darkens what it sits on); restore the blend mode
100
+ // if the look regresses, but expect the GPU cost back with it.
101
+ //mix-blend-mode: color-burn;
102
+ opacity: 0.04;
86
103
  pointer-events: none;
87
104
  }
88
105
 
@@ -91,12 +108,6 @@
91
108
  }
92
109
  }
93
110
 
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
111
  .header {
101
112
  display: flex;
102
113
  flex-direction: column;
@@ -39,6 +39,8 @@ export default class ZnPageSectionCard extends ZincElement {
39
39
  @property({type: Boolean, reflect: true}) selected = false;
40
40
  /** Set when the section's type has no registered template — renders greyed. */
41
41
  @property({type: Boolean, reflect: true}) unknown = false;
42
+ /** Set when the builder pins this section to the page — drops the remove action. */
43
+ @property({type: Boolean, reflect: true}) locked = false;
42
44
 
43
45
  protected updated(changed: PropertyValues) {
44
46
  super.updated(changed);
@@ -67,6 +69,10 @@ export default class ZnPageSectionCard extends ZincElement {
67
69
  <span class="card__label">${this.label}</span>
68
70
  ${this.summary ? html`<span class="card__summary">${this.summary}</span>` : ''}
69
71
  </span>
72
+ ${this.locked ? html`
73
+ <span class="card__lock" title="This section cannot be removed">
74
+ <zn-icon src="lock" size="14"></zn-icon>
75
+ </span>` : ''}
70
76
  <span class="card__actions" @keydown="${this._actionKeydown}">
71
77
  <zn-button
72
78
  class="card__action"
@@ -77,15 +83,16 @@ export default class ZnPageSectionCard extends ZincElement {
77
83
  title="Duplicate section"
78
84
  aria-label="Duplicate section"
79
85
  @click="${(e: Event) => this._action(e, 'page-card-duplicate')}"></zn-button>
80
- <zn-button
81
- class="card__action"
82
- icon-button="small"
83
- plain
84
- icon="delete"
85
- icon-size="14"
86
- title="Remove section"
87
- aria-label="Remove section"
88
- @click="${(e: Event) => this._action(e, 'page-card-remove')}"></zn-button>
86
+ ${this.locked ? '' : html`
87
+ <zn-button
88
+ class="card__action"
89
+ icon-button="small"
90
+ plain
91
+ icon="delete"
92
+ icon-size="14"
93
+ title="Remove section"
94
+ aria-label="Remove section"
95
+ @click="${(e: Event) => this._action(e, 'page-card-remove')}"></zn-button>`}
89
96
  </span>
90
97
  </div>
91
98
  `;
@@ -15,6 +15,11 @@
15
15
  cursor: grabbing;
16
16
  }
17
17
 
18
+ :host([locked]),
19
+ :host([locked]:active) {
20
+ cursor: default;
21
+ }
22
+
18
23
  .card {
19
24
  display: flex;
20
25
  align-items: center;
@@ -77,6 +82,14 @@
77
82
  text-overflow: ellipsis;
78
83
  }
79
84
 
85
+ // Always visible, unlike the hover actions — it is what explains their absence.
86
+ .card__lock {
87
+ display: flex;
88
+ flex: 0 0 auto;
89
+ align-items: center;
90
+ opacity: 0.45;
91
+ }
92
+
80
93
  // Space is always reserved (visibility, not display) so the card never
81
94
  // changes size or shifts its text when the actions appear on hover.
82
95
  .card__actions {
@@ -41,6 +41,15 @@ describe('<zn-page-section-card>', () => {
41
41
  expect(hostSawKeydown, 'host keydown suppressed for button activation keys').to.be.false;
42
42
  });
43
43
 
44
+ it('should drop the remove action and show a lock when locked', async () => {
45
+ const el = await fixture<ZnPageSectionCard>(html`
46
+ <zn-page-section-card label="Hero" locked></zn-page-section-card>`);
47
+ expect(el.hasAttribute('locked')).to.be.true;
48
+ expect(el.shadowRoot!.querySelector('zn-button[title="Remove section"]'), 'remove gone').to.not.exist;
49
+ expect(el.shadowRoot!.querySelector('zn-button[title="Duplicate section"]'), 'duplicate kept').to.exist;
50
+ expect(el.shadowRoot!.querySelector('.card__lock'), 'lock indicator').to.exist;
51
+ });
52
+
44
53
  it('should reflect selected and unknown states', async () => {
45
54
  const el = await fixture<ZnPageSectionCard>(html`
46
55
  <zn-page-section-card label="Hero" selected unknown></zn-page-section-card>`);