@kubex/zinc 1.1.91 → 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 (30) 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 -516
  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/content-block/content-block.component.ts +3 -2
  12. package/src/components/icon-picker/icon-picker.component.ts +1 -1
  13. package/src/components/linked-select/linked-select.component.ts +22 -5
  14. package/src/components/page/page.scss +13 -7
  15. package/src/components/page-builder/page-builder.component.ts +52 -7
  16. package/src/components/page-builder/page-builder.scss +166 -9
  17. package/src/components/page-builder/page-builder.test.ts +134 -0
  18. package/src/components/page-builder/page.types.ts +23 -3
  19. package/src/components/page-nav/page-nav.scss +9 -1
  20. package/src/components/panel/panel.component.ts +5 -1
  21. package/src/components/priority-list/priority-list.component.ts +1 -0
  22. package/src/components/priority-list/priority-list.scss +2 -1
  23. package/src/components/schedule-builder/index.ts +12 -0
  24. package/src/components/schedule-builder/schedule-builder.component.ts +1543 -0
  25. package/src/components/schedule-builder/schedule-builder.scss +448 -0
  26. package/src/components/schedule-builder/schedule-builder.test.ts +344 -0
  27. package/src/components/toggle/toggle.component.ts +2 -1
  28. package/src/zinc.ts +1 -0
  29. package/docs/superpowers/plans/2026-08-03-theme-editor.md +0 -1536
  30. package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +0 -327
@@ -50,7 +50,6 @@
50
50
  --inspector-col: 0px;
51
51
 
52
52
  .inspector {
53
- padding: 0;
54
53
  border-left: none;
55
54
  }
56
55
  }
@@ -63,8 +62,10 @@
63
62
  --palette-col: 240px;
64
63
  }
65
64
 
65
+ // 300, not 280: below that the inspector's toggle rows wrap their switch
66
+ // under the label and the field rhythm breaks down.
66
67
  .builder:not(.builder--inspector-collapsed) {
67
- --inspector-col: 280px;
68
+ --inspector-col: 300px;
68
69
  }
69
70
  }
70
71
 
@@ -249,21 +250,177 @@ zn-page-palette-item {
249
250
  rgb(var(--zn-body));
250
251
  }
251
252
 
253
+ // Right column — anatomy shared with flow-builder's inspector (icon tile +
254
+ // title/type head over a scrolling body), so the two builders read alike.
252
255
  .inspector {
253
256
  display: flex;
254
257
  flex-direction: column;
255
- gap: 12px;
256
- padding: 14px;
257
258
  border-left: 1px solid rgb(var(--zn-border-color));
258
- overflow-y: auto;
259
+ overflow: hidden; // the body scrolls, not the panel — the head stays put
260
+ animation: inspector-in 0.18s ease;
261
+
262
+ &-head {
263
+ display: flex;
264
+ align-items: center;
265
+ gap: 12px;
266
+ flex: 0 0 auto;
267
+ padding: 16px;
268
+ border-bottom: 1px solid rgb(var(--zn-border-color));
269
+
270
+ &__icon {
271
+ --section-accent: rgb(var(--zn-color-primary));
272
+ display: flex;
273
+ align-items: center;
274
+ justify-content: center;
275
+ flex: 0 0 auto;
276
+ width: 34px;
277
+ height: 34px;
278
+ border-radius: 8px;
279
+ color: var(--section-accent);
280
+ background: color-mix(in srgb, var(--section-accent) 15%, transparent);
281
+ }
282
+
283
+ // Long section names truncate rather than pushing the close button out.
284
+ &__text {
285
+ flex: 1 1 auto;
286
+ min-width: 0;
287
+ }
288
+
289
+ &__title {
290
+ font-size: 0.875rem;
291
+ font-weight: 600;
292
+ }
293
+
294
+ &__type {
295
+ font-size: 0.75rem;
296
+ color: rgb(var(--zn-color-muted-text));
297
+ }
298
+
299
+ &__title,
300
+ &__type {
301
+ overflow: hidden;
302
+ text-overflow: ellipsis;
303
+ white-space: nowrap;
304
+ }
305
+ }
306
+
307
+ // Field typography and rhythm for the stamped config form. Host themes size
308
+ // form labels for full-width forms, which reads oversized in a ~343px panel —
309
+ // the --zn-input-* tokens inherit into every zinc control's shadow root, so
310
+ // retuning them here needs no per-control ::part() list.
311
+ &-body {
312
+ --pb-field-label-size: 0.8125rem;
313
+ --pb-field-note-size: 0.75rem;
314
+ --pb-field-gap: 22px;
315
+ --pb-label-gap: 6px;
316
+ --pb-note-gap: 5px;
317
+ --zn-input-label-font-size-medium: var(--pb-field-label-size);
318
+ --zn-input-label-font-weight: 600;
319
+ --zn-input-label-line-height: 1.3;
320
+ --zn-input-label-color: rgb(var(--zn-text));
321
+ --zn-input-help-text-font-size-medium: var(--pb-field-note-size);
322
+ --zn-input-help-text-color: rgb(var(--zn-color-muted-text));
323
+ --zn-toggle-margin: 0;
324
+
325
+ display: flex;
326
+ flex-direction: column;
327
+ flex: 1;
328
+ min-height: 0;
329
+ overflow-y: auto;
330
+ // Extra bottom padding so the last field isn't flush against the scroll end.
331
+ padding: 18px 16px 24px;
332
+ gap: var(--pb-field-gap);
333
+
334
+ // A field's own internal spacing comes from --zn-spacing-* tokens, which
335
+ // zinc leaves to the host theme — undefined here, so those declarations
336
+ // drop out and every field collapses onto the flex gap alone. Set the
337
+ // rhythm explicitly instead: tight inside a field, wide between them, so
338
+ // a label reads as belonging to the control below it rather than floating
339
+ // between two.
340
+ ::part(form-control-label) {
341
+ margin-bottom: var(--pb-label-gap);
342
+ }
343
+
344
+ ::part(form-control-input) {
345
+ margin: 0;
346
+ }
347
+
348
+ ::part(form-control-help-text) {
349
+ margin-top: var(--pb-note-gap);
350
+ }
259
351
 
352
+ // zn-toggle sizes its own label and description from a fixed type scale
353
+ // rather than the --zn-input-label-* tokens, so bring it into line through
354
+ // its parts — otherwise a toggle row outweighs every field around it.
355
+ zn-toggle::part(label) {
356
+ font-size: var(--pb-field-label-size);
357
+ font-weight: 600;
358
+ }
359
+
360
+ zn-toggle::part(description) {
361
+ font-size: var(--pb-field-note-size);
362
+ margin-top: var(--pb-note-gap);
363
+ }
364
+
365
+ // The icon picker's trigger is a content-width button, which reads as a
366
+ // stranded chip in a column of full-width fields. Fill the row (the clear
367
+ // button, when there is a value, still takes its space beside it).
368
+ zn-icon-picker::part(trigger) {
369
+ flex: 1 1 auto;
370
+ }
371
+
372
+ > *,
373
+ .inspector__form > * {
374
+ margin-block: 0;
375
+ }
376
+
377
+ // The stamped wrapper is a layout box, not a field — it carries the same
378
+ // rhythm as the body so the host's controls space identically.
379
+ > .inspector__form {
380
+ display: flex;
381
+ flex-direction: column;
382
+ gap: var(--pb-field-gap);
383
+ }
384
+ }
385
+
386
+ &-hint {
387
+ margin: 0;
388
+ font-size: 0.75rem;
389
+ color: rgb(var(--zn-color-muted-text));
390
+ }
260
391
  }
261
392
 
262
- .inspector__body,
263
- .inspector__form {
393
+ .inspector-close {
264
394
  display: flex;
265
- flex-direction: column;
266
- gap: 12px;
395
+ align-items: center;
396
+ justify-content: center;
397
+ flex: 0 0 auto;
398
+ width: 28px;
399
+ height: 28px;
400
+ padding: 0;
401
+ border: none;
402
+ border-radius: 6px;
403
+ color: rgb(var(--zn-color-muted-text));
404
+ background: transparent;
405
+ cursor: pointer;
406
+
407
+ &:hover {
408
+ color: rgb(var(--zn-text));
409
+ background: rgb(var(--zn-color-tab));
410
+ }
411
+ }
412
+
413
+ // The panel slides in from the right as a section is selected (as flow-builder).
414
+ @keyframes inspector-in {
415
+ from {
416
+ opacity: 0;
417
+ transform: translateX(8px);
418
+ }
419
+
420
+ to {
421
+ opacity: 1;
422
+ transform: translateX(0);
423
+ }
267
424
  }
268
425
 
269
426
  .declarations {
@@ -112,6 +112,35 @@ describe('<zn-page-builder>', () => {
112
112
  expect(el.state.sections[0].type).to.equal('gone'); // preserved through save
113
113
  });
114
114
 
115
+ it('should summarise a card with the chosen option label, not the stored value', async () => {
116
+ const el = await fixture<ZnPageBuilder>(html`
117
+ <zn-page-builder
118
+ config='{"sections":[{"id":"s1","type":"tile","data":{"icon":"folder","article":"1a2b3c"}}]}'>
119
+ <template type="tile" slot="config" label="Article">
120
+ <zn-icon-picker name="icon"></zn-icon-picker>
121
+ <zn-select name="article">
122
+ <zn-option value="1a2b3c">Getting Started</zn-option>
123
+ </zn-select>
124
+ </template>
125
+ </zn-page-builder>`);
126
+ await el.updateComplete;
127
+
128
+ const card = el.shadowRoot?.querySelector('zn-page-section-card');
129
+ expect(card?.getAttribute('summary')).to.equal('Getting Started');
130
+ });
131
+
132
+ it('should fall back to the first string value when nothing matches an option', async () => {
133
+ const el = await fixture<ZnPageBuilder>(html`
134
+ <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"title":"Welcome"}}]}'>
135
+ <template type="hero" slot="config" label="Hero">
136
+ <input name="title">
137
+ </template>
138
+ </zn-page-builder>`);
139
+ await el.updateComplete;
140
+
141
+ expect(el.shadowRoot?.querySelector('zn-page-section-card')?.getAttribute('summary')).to.equal('Welcome');
142
+ });
143
+
115
144
  it('should round-trip section data through the inspector name binding', async () => {
116
145
  const el = await fixture<ZnPageBuilder>(html`
117
146
  <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"title":"Before"}}]}'>
@@ -206,6 +235,34 @@ describe('<zn-page-builder>', () => {
206
235
  expect(el.state.sections[0].data.categories).to.deep.equal(['billing', 'setup']);
207
236
  });
208
237
 
238
+ // zn-icon-picker keeps `value` as a plain accessor over its reactive `icon`,
239
+ // so it only prefills if the stamped element is upgraded before assignment —
240
+ // otherwise the assignment shadows the setter and the picker renders empty.
241
+ it('should prefill a control whose value is a plain accessor', async () => {
242
+ const el = await fixture<ZnPageBuilder>(html`
243
+ <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"icon":"receipt"}}]}'>
244
+ <template type="hero" slot="config" label="Hero">
245
+ <zn-icon-picker name="icon" label="Icon" no-color no-library></zn-icon-picker>
246
+ </template>
247
+ </zn-page-builder>`);
248
+ await el.updateComplete;
249
+
250
+ el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
251
+ await el.updateComplete;
252
+
253
+ const picker = el.shadowRoot!.querySelector<HTMLElement & { value: string; icon: string }>(
254
+ '.inspector__form zn-icon-picker[name="icon"]')!;
255
+ expect(picker, 'stamped icon picker').to.exist;
256
+ expect(picker.value).to.equal('receipt');
257
+ expect(picker.icon, 'reached the reactive property, not an own shadowing one').to.equal('receipt');
258
+
259
+ picker.value = 'payments';
260
+ picker.dispatchEvent(new CustomEvent('zn-change', {bubbles: true}));
261
+ await el.updateComplete;
262
+
263
+ expect(el.state.sections[0].data.icon).to.equal('payments');
264
+ });
265
+
209
266
  it('should rename a section from the inspector', async () => {
210
267
  const el = await fixture<ZnPageBuilder>(html`
211
268
  <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{}}]}'>
@@ -224,6 +281,83 @@ describe('<zn-page-builder>', () => {
224
281
  expect(el.state.sections[0].label).to.equal('My Hero');
225
282
  });
226
283
 
284
+ it('should head the inspector with the section name and its type', async () => {
285
+ const el = await fixture<ZnPageBuilder>(html`
286
+ <zn-page-builder config='{"sections":[
287
+ {"id":"s1","type":"hero","label":"Top banner","data":{}},
288
+ {"id":"s2","type":"hero","data":{}}]}'>
289
+ <template type="hero" slot="config" label="Hero" icon="star">
290
+ <zn-input name="title" label="Title"></zn-input>
291
+ </template>
292
+ </zn-page-builder>`);
293
+ await el.updateComplete;
294
+
295
+ const cards = el.shadowRoot?.querySelectorAll('zn-page-section-card');
296
+ cards?.[0].dispatchEvent(new Event('click'));
297
+ await el.updateComplete;
298
+
299
+ const head = el.shadowRoot?.querySelector('[part="inspector-header"]');
300
+ expect(head, 'inspector header').to.exist;
301
+ expect(head?.querySelector('.inspector-head__title')?.textContent?.trim()).to.equal('Top banner');
302
+ expect(head?.querySelector('.inspector-head__type')?.textContent?.trim()).to.equal('Hero');
303
+ expect(head?.querySelector('zn-icon')?.getAttribute('src')).to.equal('star');
304
+ expect(el.shadowRoot?.querySelector('[part="inspector-body"] .inspector__rename'), 'rename in body').to.exist;
305
+
306
+ // An unnamed section's title already is the type label — don't print it twice.
307
+ cards?.[1].dispatchEvent(new Event('click'));
308
+ await el.updateComplete;
309
+ const unnamed = el.shadowRoot?.querySelector('[part="inspector-header"]');
310
+ expect(unnamed?.querySelector('.inspector-head__title')?.textContent?.trim()).to.equal('Hero');
311
+ expect(unnamed?.querySelector('.inspector-head__type'), 'no duplicate type line').to.not.exist;
312
+ });
313
+
314
+ it('should clear the selection from the inspector close button', async () => {
315
+ const el = await fixture<ZnPageBuilder>(html`
316
+ <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{}}]}'>
317
+ <template type="hero" slot="config" label="Hero"></template>
318
+ </zn-page-builder>`);
319
+ await el.updateComplete;
320
+
321
+ el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
322
+ await el.updateComplete;
323
+ expect(el.shadowRoot?.querySelector('[part="inspector"]')).to.exist;
324
+
325
+ el.shadowRoot?.querySelector<HTMLButtonElement>('.inspector-close')?.click();
326
+ await el.updateComplete;
327
+ expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'inspector closes').to.not.exist;
328
+ });
329
+
330
+ it('should hint when the selected type has no settings', async () => {
331
+ const el = await fixture<ZnPageBuilder>(html`
332
+ <zn-page-builder config='{"sections":[{"id":"s1","type":"divider","data":{}}]}'>
333
+ <template type="divider" slot="config" label="Divider"></template>
334
+ </zn-page-builder>`);
335
+ await el.updateComplete;
336
+
337
+ el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
338
+ await el.updateComplete;
339
+
340
+ expect(el.shadowRoot?.querySelector('.inspector-hint')).to.exist;
341
+ });
342
+
343
+ it('should lay stamped toggles out as a row, honouring a host-set position', async () => {
344
+ const el = await fixture<ZnPageBuilder>(html`
345
+ <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{}}]}'>
346
+ <template type="hero" slot="config" label="Hero">
347
+ <zn-toggle name="showSearch" label="Show search"></zn-toggle>
348
+ <zn-toggle name="showNav" label="Show nav" label-position="top"></zn-toggle>
349
+ </template>
350
+ </zn-page-builder>`);
351
+ await el.updateComplete;
352
+
353
+ el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
354
+ await el.updateComplete;
355
+
356
+ const form = el.shadowRoot?.querySelector('.inspector__form');
357
+ expect(form?.querySelector('zn-toggle[name="showSearch"]')?.getAttribute('label-position')).to.equal('left');
358
+ expect(form?.querySelector('zn-toggle[name="showNav"]')?.getAttribute('label-position')).to.equal('top');
359
+ });
360
+
227
361
  it('should undo and redo a section add', async () => {
228
362
  const el = await fixture<ZnPageBuilder>(html`
229
363
  <zn-page-builder>
@@ -68,8 +68,28 @@ export function generateSectionId(): string {
68
68
  return `s-${Date.now().toString(36)}-${(sectionCounter++).toString(36)}`;
69
69
  }
70
70
 
71
- /** Card summary: the section's first non-empty string value, else the type description. */
71
+ /**
72
+ * The visible text of the option a value was chosen from, so a card summarises a
73
+ * select by what the user picked rather than by the opaque id it stores.
74
+ */
75
+ function optionLabel(type: PageSectionType | undefined, name: string, value: string): string {
76
+ const control = type?.configTemplate?.content.querySelector(`[name="${CSS.escape(name)}"]`);
77
+ const escaped = CSS.escape(value);
78
+ const option = control?.querySelector(`option[value="${escaped}"], zn-option[value="${escaped}"]`);
79
+ return option?.textContent?.trim() ?? '';
80
+ }
81
+
82
+ /**
83
+ * Card summary: the label of the first value chosen from a select, else the first
84
+ * non-empty string value, else the type description. Options win over field order
85
+ * so a tile reads as its linked item however its other fields were filled in.
86
+ */
72
87
  export function sectionSummary(section: PageSection, type?: PageSectionType): string {
73
- const first = Object.values(section.data).find(v => typeof v === 'string' && v.trim() !== '');
74
- return (first as string) ?? type?.description ?? '';
88
+ const strings = Object.entries(section.data)
89
+ .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].trim() !== '');
90
+ for (const [name, value] of strings) {
91
+ const label = optionLabel(type, name, value);
92
+ if (label) return label;
93
+ }
94
+ return strings[0]?.[1] ?? type?.description ?? '';
75
95
  }
@@ -56,16 +56,23 @@
56
56
  display: flex;
57
57
  flex-direction: column;
58
58
  gap: var(--zn-spacing-medium);
59
+
60
+ padding-top: var(--zn-spacing-medium);
61
+ }
62
+
63
+ .navigation-group:first-of-type {
64
+ padding-top: 0;
59
65
  }
60
66
 
61
67
  .navigation-group h4 {
62
68
  margin: 0;
63
- padding: var(--zn-spacing-medium) 0;
69
+ padding: 0;
64
70
  color: rgb(var(--zn-text-heading));
65
71
  font-size: var(--zn-font-size-medium);
66
72
  font-weight: var(--zn-font-weight-semibold);
67
73
  }
68
74
 
75
+
69
76
  .navigation-group .navigation-item {
70
77
  color: rgb(var(--zn-text));
71
78
  font-size: var(--zn-font-size-medium);
@@ -73,6 +80,7 @@
73
80
  display: flex;
74
81
  align-items: center;
75
82
  gap: var(--zn-spacing-small);
83
+ min-height: var(--zn-icon-size);
76
84
  }
77
85
 
78
86
  .navigation-item.active {
@@ -76,6 +76,10 @@ export default class ZnPanel extends ZincElement {
76
76
  const hasActionSlot = this.hasSlotController.test('actions');
77
77
  const hasFooterSlot = this.hasSlotController.test('footer');
78
78
  const hasHeader = this.caption || hasActionSlot;
79
+ // Transparent panels with no vertical body padding have nothing to separate the header from,
80
+ // so the underline is just a stray rule across the page.
81
+ const isFlushY = this.flush || this.tabbed || this.flushY;
82
+ const underlineHeader = !this.headerBorderless && !(this.transparent && isFlushY);
79
83
 
80
84
  return html`
81
85
  <div class="${classMap({
@@ -98,7 +102,7 @@ export default class ZnPanel extends ZincElement {
98
102
  ${hasHeader ? html`
99
103
  <zn-header class="${classMap({
100
104
  "panel__header": true,
101
- "panel__header--underline": !this.headerBorderless,
105
+ "panel__header--underline": underlineHeader,
102
106
  })}"
103
107
  icon="${this.icon}"
104
108
  caption="${this.caption}"
@@ -51,6 +51,7 @@ export interface PriorityItem {
51
51
  *
52
52
  * @cssproperty --zn-priority-list-item-gap - The gap between list items. Defaults to `var(--zn-spacing-2x-small)`.
53
53
  * @cssproperty --zn-priority-list-item-padding - The padding inside each item. Defaults to `var(--zn-spacing-small) var(--zn-spacing-medium)`.
54
+ * @cssproperty --zn-priority-list-actions-gap - The gap between an item's slotted actions. Defaults to `var(--zn-spacing-x-small)`.
54
55
  * @cssproperty --zn-priority-list-handle-color - The color of the drag handle. Defaults to `var(--zn-color-neutral-500)`.
55
56
  * @cssproperty --zn-priority-list-priority-color - The color of the priority number. Defaults to `var(--zn-color-neutral-600)`.
56
57
  */
@@ -7,6 +7,7 @@
7
7
 
8
8
  --zn-priority-list-item-gap: var(--zn-spacing-2x-small);
9
9
  --zn-priority-list-item-padding: var(--zn-spacing-small) var(--zn-spacing-medium);
10
+ --zn-priority-list-actions-gap: var(--zn-spacing-small);
10
11
  --zn-priority-list-handle-color: var(--zn-color-neutral-400);
11
12
  --zn-priority-list-priority-color: var(--zn-color-neutral-600);
12
13
  }
@@ -134,7 +135,7 @@
134
135
  .priority-list__actions {
135
136
  display: flex;
136
137
  align-items: center;
137
- gap: var(--zn-spacing-2x-small);
138
+ gap: var(--zn-priority-list-actions-gap);
138
139
  flex-shrink: 0;
139
140
  // Re-enable pointer events so action buttons are clickable
140
141
  pointer-events: auto;
@@ -0,0 +1,12 @@
1
+ import ZnScheduleBuilder from './schedule-builder.component';
2
+
3
+ export * from './schedule-builder.component';
4
+ export default ZnScheduleBuilder;
5
+
6
+ ZnScheduleBuilder.define('zn-schedule-builder');
7
+
8
+ declare global {
9
+ interface HTMLElementTagNameMap {
10
+ 'zn-schedule-builder': ZnScheduleBuilder;
11
+ }
12
+ }