@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
@@ -1,13 +1,36 @@
1
- import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
2
1
  import {
2
+ cloneWithNewIds,
3
+ containerCells,
4
+ containerColumns,
5
+ containerDepth,
6
+ containerGrow,
7
+ containerHeight,
8
+ containerWidths,
9
+ extractSection,
10
+ findSection,
11
+ insertIntoCell,
12
+ normaliseCells,
13
+ normaliseGrowth,
14
+ normaliseSections,
15
+ patchSection,
16
+ recolumnCells,
17
+ trimTrailingEmptyCells,
18
+ } from './page-tree';
19
+ import { type CSSResultGroup, html, type PropertyValues, type TemplateResult, unsafeCSS } from 'lit';
20
+ import {
21
+ DEFAULT_WIDTHS,
22
+ defaultLayout,
3
23
  emptyPageState,
4
24
  generateSectionId,
25
+ isContainer,
26
+ MAX_COLUMNS,
27
+ MAX_CONTAINER_LEVELS,
28
+ MAX_WIDTH,
5
29
  PAGE_SECTION_MIME,
6
30
  PAGE_TYPE_MIME,
7
31
  type PageSection,
8
32
  type PageSectionType,
9
33
  type PageState,
10
- sectionChildren,
11
34
  sectionSummary,
12
35
  } from './page.types';
13
36
  import { FormControlController, validValidityState } from '../../internal/form';
@@ -22,14 +45,11 @@ import ZnIcon from '../icon';
22
45
  import ZnInput from '../input';
23
46
  import ZnPagePaletteItem from './modules/page-palette-item';
24
47
  import ZnPageSectionCard from './modules/page-section-card';
48
+ import ZnToggle from '../toggle';
25
49
 
26
50
  import styles from './page-builder.scss';
27
51
 
28
52
  const HISTORY_LIMIT = 50;
29
- /** Sections beyond this are dropped (with a warning) when external state is applied. */
30
- const MAX_SECTIONS = 500;
31
- /** Per-container children beyond this are dropped when external state is applied. */
32
- const MAX_CHILDREN = 24;
33
53
  /** Builder width below which the palette auto-collapses — keep in sync with the @container query in page-builder.scss. */
34
54
  const NARROW_WIDTH = 768;
35
55
 
@@ -48,6 +68,14 @@ function timeAgo(ms: number): string {
48
68
  return `${Math.floor(h / 24)}d ago`;
49
69
  }
50
70
 
71
+ /** Where a section lives inside a container cell. */
72
+ interface CellOwner {
73
+ containerId: string;
74
+ cellIndex: number;
75
+ index: number;
76
+ columns: number;
77
+ }
78
+
51
79
  /**
52
80
  * @summary A config-driven page composer: a palette of predefined section types, a linear
53
81
  * canvas of section cards, and an inspector for editing each section's content.
@@ -65,10 +93,14 @@ function timeAgo(ms: number): string {
65
93
  * @event zn-page-selection-change - Emitted when the selected section changes. `event.detail.sectionId`.
66
94
  *
67
95
  * @slot config - `<template type="…">` declarations; never displayed. Each template's attributes
68
- * (type, label, icon, icon-library, color, category, description, slots, accepts) declare a
69
- * palette entry and its content declares the inspector form for that type. `slots` makes the
70
- * section a container with that many child slots; `accepts` is a comma-separated list of the
71
- * type keys its slots allow.
96
+ * (type, label, icon, icon-library, color, category, description, container, columns, widths,
97
+ * grow, slots, accepts) declare a palette entry and its content declares the inspector form for
98
+ * that type. `container` makes the type a container whose editor-configurable layout starts from
99
+ * `columns` (seeds equal widths) or `widths` (explicit weights, wins over `columns`); `grow` seeds
100
+ * a growable instance. `accepts` is a comma-separated list of the type keys its cells allow —
101
+ * omitted on a `container` type, any type is allowed subject to the nesting cap. `slots` is
102
+ * @deprecated: it declares a fixed-slot container of `DEFAULT_WIDTHS` columns pinned to that many
103
+ * cells, and keeps the old any-non-container-type rule when `accepts` is omitted.
72
104
  * @slot header-left - Actions shown on the left of the header bar.
73
105
  * @slot header-right - Actions shown on the right of the header bar.
74
106
  *
@@ -77,6 +109,8 @@ function timeAgo(ms: number): string {
77
109
  * @csspart palette - The left palette panel.
78
110
  * @csspart canvas - The centre section-card canvas.
79
111
  * @csspart inspector - The right panel while a section is selected.
112
+ * @csspart inspector-header - The inspector's fixed header (icon, section name, type, close).
113
+ * @csspart inspector-body - The inspector's scrolling form area.
80
114
  */
81
115
  export default class ZnPageBuilder extends ZincElement {
82
116
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -87,6 +121,7 @@ export default class ZnPageBuilder extends ZincElement {
87
121
  'zn-input': ZnInput,
88
122
  'zn-page-palette-item': ZnPagePaletteItem,
89
123
  'zn-page-section-card': ZnPageSectionCard,
124
+ 'zn-toggle': ZnToggle,
90
125
  };
91
126
 
92
127
  private readonly formControlController = new FormControlController(this);
@@ -103,6 +138,14 @@ export default class ZnPageBuilder extends ZincElement {
103
138
  @property({ reflect: true }) heading = '';
104
139
  @property({ reflect: true }) subheading = '';
105
140
 
141
+ /**
142
+ * Section type key that must lead the page. The builder hoists an existing section of
143
+ * that type to the top, or inserts an empty one, and pins it there: it can't be
144
+ * removed, reordered or dragged into a slot, and nothing can be dropped above it.
145
+ * Its content stays fully editable in the inspector.
146
+ */
147
+ @property({ attribute: 'required-first', reflect: true }) requiredFirst = '';
148
+
106
149
  /** Section types to make available, registered into the internal registry. */
107
150
  @property({ attribute: false }) sectionTypes: PageSectionType[] = [];
108
151
 
@@ -137,10 +180,10 @@ export default class ZnPageBuilder extends ZincElement {
137
180
  /** Index of the drop zone whose "+" type picker is open, if any. */
138
181
  @state() private _pickerIndex: number | null = null;
139
182
  @state() private _dragOverIndex: number | null = null;
140
- /** The container slot a drag is currently over, if any. */
141
- @state() private _slotDragOver: { containerId: string; index: number } | null = null;
142
- /** The container slot whose "+" type picker is open, if any. */
143
- @state() private _slotPicker: { containerId: string; index: number } | null = null;
183
+ /** The cell a drag is currently over, if any. */
184
+ @state() private _cellDragOver: { containerId: string; cellIndex: number; insertIndex: number } | null = null;
185
+ /** The cell whose "+" type picker is open, if any. */
186
+ @state() private _cellPicker: { containerId: string; cellIndex: number } | null = null;
144
187
  /** The stamped config form for the selected section; rebuilt on selection change. */
145
188
  @state() private _form: HTMLDivElement | null = null;
146
189
 
@@ -222,6 +265,15 @@ export default class ZnPageBuilder extends ZincElement {
222
265
  }
223
266
  }
224
267
 
268
+ // Idempotent, so it does not matter whether this or handleConfigChange runs first.
269
+ @watch('requiredFirst')
270
+ handleRequiredFirstChange() {
271
+ const sections = this._requireFirst(this._state.sections);
272
+ if (sections === this._state.sections) return;
273
+ this._state = { sections };
274
+ this.defaultValue = JSON.stringify(this._state);
275
+ }
276
+
225
277
  @watch('sectionTypes')
226
278
  handleSectionTypesChange() {
227
279
  this.registry.registerAll(this.sectionTypes ?? []);
@@ -389,6 +441,18 @@ export default class ZnPageBuilder extends ZincElement {
389
441
  const type = el.getAttribute('type');
390
442
  if (!type) return null;
391
443
  const slots = parseInt(el.getAttribute('slots') ?? '', 10);
444
+ // `container` is the flag; `columns`/`widths`/`grow` only apply when it's present, so a
445
+ // legacy `slots`-only declaration is untouched by the new fields.
446
+ const container = el.hasAttribute('container');
447
+ let defaultWidths: number[] | undefined;
448
+ if (container) {
449
+ const widths = (el.getAttribute('widths') ?? '')
450
+ .trim().split(/[\s,]+/).filter(Boolean).map(Number).filter(n => !Number.isNaN(n));
451
+ const columns = parseInt(el.getAttribute('columns') ?? '', 10);
452
+ defaultWidths = widths.length
453
+ ? widths
454
+ : columns > 0 ? Array.from({ length: columns }, () => 1) : [...DEFAULT_WIDTHS];
455
+ }
392
456
  return {
393
457
  type,
394
458
  label: el.getAttribute('label') ?? type,
@@ -400,6 +464,9 @@ export default class ZnPageBuilder extends ZincElement {
400
464
  configTemplate: el,
401
465
  slots: slots > 0 ? slots : undefined,
402
466
  accepts: el.getAttribute('accepts')?.split(',').map(s => s.trim()).filter(Boolean),
467
+ container: container ? true : undefined,
468
+ defaultWidths,
469
+ defaultGrow: container ? el.hasAttribute('grow') : undefined,
403
470
  };
404
471
  }
405
472
 
@@ -417,101 +484,74 @@ export default class ZnPageBuilder extends ZincElement {
417
484
 
418
485
  /** Normalises and installs an externally provided state; resets selection. */
419
486
  private _applyExternalState(next: PageState) {
420
- const seen = new Set<string>();
421
- let clippedChildren = false;
422
- // Nesting is one level deep by design — grandchildren in malformed input are
423
- // dropped, which also bounds the recursion. Duplicate ids get regenerated so
424
- // selection/patching can never target two sections at once, and children are
425
- // capped (slots are single-digit by design; MAX_SECTIONS alone would still
426
- // admit one section with a huge children array).
427
- const normalise = (s: PageSection, depth: number): PageSection => {
428
- const id = !s.id || seen.has(s.id) ? generateSectionId() : s.id;
429
- seen.add(id);
430
- if (depth === 0 && (s.children?.length ?? 0) > MAX_CHILDREN) clippedChildren = true;
431
- return {
432
- id,
433
- type: s.type,
434
- label: s.label,
435
- data: structuredClone(s.data ?? {}),
436
- ...(s.children && depth === 0
437
- ? { children: s.children.slice(0, MAX_CHILDREN).map(c => (c && typeof c.type === 'string' ? normalise(c, depth + 1) : null)) }
438
- : {}),
439
- };
440
- };
441
- const incoming = (next?.sections ?? []).filter(s => s && typeof s.type === 'string');
442
- if (incoming.length > MAX_SECTIONS) {
443
- console.warn(`<zn-page-builder> config has ${incoming.length} sections; keeping the first ${MAX_SECTIONS}`);
444
- }
445
- const sections = incoming.slice(0, MAX_SECTIONS).map(s => normalise(s, 0));
446
- if (clippedChildren) {
447
- console.warn(`<zn-page-builder> some sections had more than ${MAX_CHILDREN} children; extras were dropped`);
448
- }
487
+ const { sections, warnings } = normaliseSections(next?.sections, key => this.registry.get(key));
488
+ warnings.forEach(warning => console.warn(`<zn-page-builder> ${warning}`));
449
489
  this._history = [];
450
490
  this._redoStack = [];
451
- this._state = { sections };
491
+ this._state = { sections: this._requireFirst(sections) };
452
492
  this.defaultValue = JSON.stringify(this._state);
453
493
  this._selectedId = null;
454
494
  this._pickerIndex = null;
495
+ this._cellPicker = null;
455
496
  this._offerRestoreIfNewer();
456
497
  }
457
498
 
458
- /** Finds a section by id, searching top-level sections and slotted children. */
459
- private _findSection(id: string | null): PageSection | undefined {
460
- if (!id) return undefined;
461
- for (const s of this._state.sections) {
462
- if (s.id === id) return s;
463
- const child = s.children?.find(c => c?.id === id);
464
- if (child) return child;
465
- }
466
- return undefined;
499
+ // --- The pinned leading section (`required-first`) ---------------------------
500
+
501
+ /**
502
+ * Id of the section pinned to the top of the page, or null when `required-first`
503
+ * is unset. Derived from the state rather than stored on it, so nothing about the
504
+ * lock leaks into the persisted config.
505
+ */
506
+ private get _pinnedId(): string | null {
507
+ const first = this._state.sections[0];
508
+ return this.requiredFirst && first?.type === this.requiredFirst ? first.id : null;
467
509
  }
468
510
 
469
- /** New sections array with the section patched wherever it lives (top level or slot). */
470
- private _patchSection(id: string, patch: (s: PageSection) => PageSection): PageSection[] {
471
- return this._state.sections.map(s => {
472
- if (s.id === id) return patch(s);
473
- if (s.children?.some(c => c?.id === id)) {
474
- return { ...s, children: s.children.map(c => (c?.id === id ? patch(c) : c)) };
475
- }
476
- return s;
477
- });
511
+ private _isPinned(id: string): boolean {
512
+ return this._pinnedId === id;
478
513
  }
479
514
 
480
- /** Detaches a section wherever it lives: removed from the top level, or its slot nulled. */
481
- private _extract(id: string): [PageSection | undefined, PageSection[]] {
482
- let removed: PageSection | undefined;
483
- const sections: PageSection[] = [];
484
- for (const s of this._state.sections) {
485
- if (s.id === id) {
486
- removed = s;
487
- continue;
488
- }
489
- const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
490
- if (slot !== -1) {
491
- removed = s.children![slot] ?? undefined;
492
- sections.push({ ...s, children: s.children!.map((c, i) => (i === slot ? null : c)) });
493
- } else {
494
- sections.push(s);
495
- }
496
- }
497
- return [removed, sections];
515
+ /** Lowest top-level index a section may be added or moved to. */
516
+ private get _firstFreeIndex(): number {
517
+ return this._pinnedId === null ? 0 : 1;
518
+ }
519
+
520
+ /**
521
+ * Sections reordered so `required-first` leads the page: an existing section of that
522
+ * type is hoisted to the front, otherwise an empty one is prepended. Returns the
523
+ * argument unchanged when there is nothing to do, so callers can compare by identity.
524
+ */
525
+ private _requireFirst(sections: PageSection[]): PageSection[] {
526
+ if (!this.requiredFirst || sections[0]?.type === this.requiredFirst) return sections;
527
+ const at = sections.findIndex(s => s.type === this.requiredFirst);
528
+ if (at === -1) return [this._newSection(this.requiredFirst), ...sections];
529
+ const hoisted = [...sections];
530
+ hoisted.unshift(...hoisted.splice(at, 1));
531
+ return hoisted;
498
532
  }
499
533
 
500
- /** Installs a new state from a user edit and notifies listeners. */
534
+ /**
535
+ * Installs a new state from a user edit and notifies listeners. Re-applies
536
+ * growth normalisation across the tree first, so a growable container never
537
+ * carries a trailing empty row past this point — the read path (`containerCells`)
538
+ * already hides it, but state/value/zn-page-change/auto-save must not diverge
539
+ * from what the canvas renders.
540
+ */
501
541
  private _commit(next: PageState) {
502
- this._state = next;
542
+ this._state = { sections: normaliseGrowth(next.sections, key => this.registry.get(key)) };
503
543
  this.emit('zn-page-change', { detail: { state: this.state } });
504
544
  }
505
545
 
506
546
  private _selectedSection(): PageSection | undefined {
507
- return this._findSection(this._selectedId);
547
+ return findSection(this._state.sections, this._selectedId);
508
548
  }
509
549
 
510
550
  private _select(id: string | null) {
511
551
  if (this._selectedId === id) return;
512
552
  this._selectedId = id;
513
553
  this._pickerIndex = null;
514
- this._slotPicker = null;
554
+ this._cellPicker = null;
515
555
  this.emit('zn-page-selection-change', { detail: { sectionId: id } });
516
556
  }
517
557
 
@@ -543,44 +583,38 @@ export default class ZnPageBuilder extends ZincElement {
543
583
 
544
584
  // --- Section mutations ------------------------------------------------------
545
585
 
586
+ /** A fresh section of a registered type, seeded with layout/cells when it's a container. */
587
+ private _newSection(type: string): PageSection {
588
+ const sectionType = this.registry.get(type);
589
+ const section: PageSection = { id: generateSectionId(), type, data: {} };
590
+ if (isContainer(sectionType)) {
591
+ section.layout = defaultLayout(sectionType);
592
+ section.cells = containerCells(section, sectionType);
593
+ }
594
+ return section;
595
+ }
596
+
546
597
  /** Adds a section of a registered type at `index` (default: end). Returns null for unknown types. */
547
598
  addSection(type: string, index?: number): PageSection | null {
548
599
  if (!this.registry.has(type)) return null;
549
600
  this._pushHistory();
550
- const section: PageSection = { id: generateSectionId(), type, data: {} };
601
+ const section = this._newSection(type);
551
602
  const sections = [...this._state.sections];
552
- sections.splice(index ?? sections.length, 0, section);
603
+ sections.splice(Math.max(index ?? sections.length, this._firstFreeIndex), 0, section);
553
604
  this._commit({ sections });
554
605
  this._select(section.id);
555
606
  return section;
556
607
  }
557
608
 
558
- /** Adds a new section of a registered type into a container's slot. Returns null if not allowed. */
559
- addSectionToSlot(type: string, containerId: string, slotIndex: number): PageSection | null {
560
- const sectionType = this.registry.get(type);
561
- const container = this._findSection(containerId);
562
- const containerType = container ? this.registry.get(container.type) : undefined;
563
- if (!sectionType || sectionType.slots || !container || !containerType?.slots) return null;
564
- if (slotIndex < 0 || slotIndex >= containerType.slots) return null;
565
- if (containerType.accepts && !containerType.accepts.includes(type)) return null;
566
- const children = sectionChildren(container, containerType);
567
- if (children[slotIndex]) return null;
568
- const section: PageSection = { id: generateSectionId(), type, data: {} };
569
- children[slotIndex] = section;
570
- this._pushHistory();
571
- this._commit({ sections: this._patchSection(containerId, s => ({ ...s, children })) });
572
- this._select(section.id);
573
- return section;
574
- }
575
-
576
609
  private _removeSection(id: string) {
577
- const [removed, sections] = this._extract(id);
610
+ if (this._isPinned(id)) return;
611
+ const [removed, sections] = extractSection(this._state.sections, id);
578
612
  if (!removed) return;
579
613
  this._pushHistory();
580
- // Clear selection for the removed section AND anything inside it.
581
- if (this._selectedId === id || removed.children?.some(c => c?.id === this._selectedId)) {
582
- this._select(null);
583
- }
614
+ // Clear selection for the removed section AND anything inside it, at any depth.
615
+ const stillSelected = (s: PageSection): boolean =>
616
+ s.id === this._selectedId || (s.cells ?? []).some(cell => cell.some(stillSelected));
617
+ if (stillSelected(removed)) this._select(null);
584
618
  this._commit({ sections });
585
619
  }
586
620
 
@@ -588,36 +622,54 @@ export default class ZnPageBuilder extends ZincElement {
588
622
  const index = this._state.sections.findIndex(s => s.id === id);
589
623
  if (index !== -1) {
590
624
  this._pushHistory();
591
- const copy = structuredClone(this._state.sections[index]);
592
- copy.id = generateSectionId();
593
- copy.children = copy.children?.map(c => (c ? { ...c, id: generateSectionId() } : null));
625
+ const copy = cloneWithNewIds(this._state.sections[index]);
594
626
  const sections = [...this._state.sections];
595
627
  sections.splice(index + 1, 0, copy);
596
628
  this._commit({ sections });
597
629
  this._select(copy.id);
598
630
  return;
599
631
  }
600
- // A slotted child duplicates into its container's next empty slot, if any.
601
- for (const s of this._state.sections) {
602
- const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
603
- if (slot === -1) continue;
604
- const type = this.registry.get(s.type);
605
- if (!type?.slots) return;
606
- const children = sectionChildren(s, type);
607
- const empty = children.findIndex(c => !c);
608
- if (empty === -1) return;
609
- const copy = structuredClone(children[slot]!);
610
- copy.id = generateSectionId();
611
- children[empty] = copy;
612
- this._pushHistory();
613
- this._commit({ sections: this._patchSection(s.id, x => ({ ...x, children })) });
614
- this._select(copy.id);
615
- return;
616
- }
632
+ // A section inside a cell duplicates directly below itself in that stack.
633
+ const owner = this._findCellOwner(id);
634
+ if (!owner) return;
635
+ const copy = cloneWithNewIds(findSection(this._state.sections, id)!);
636
+ this._pushHistory();
637
+ this._commit({
638
+ sections: insertIntoCell(
639
+ this._state.sections, owner.containerId, owner.cellIndex, owner.index + 1, copy, owner.columns
640
+ ),
641
+ });
642
+ this._select(copy.id);
643
+ }
644
+
645
+ /** Locates a section living inside a container cell. */
646
+ private _findCellOwner(id: string): CellOwner | undefined {
647
+ const search = (sections: PageSection[]): CellOwner | undefined => {
648
+ for (const section of sections) {
649
+ const cells = section.cells ?? [];
650
+ for (let cellIndex = 0; cellIndex < cells.length; cellIndex++) {
651
+ const index = cells[cellIndex].findIndex(child => child.id === id);
652
+ if (index !== -1) {
653
+ return {
654
+ containerId: section.id,
655
+ cellIndex,
656
+ index,
657
+ columns: containerColumns(section, this.registry.get(section.type)),
658
+ };
659
+ }
660
+ const deeper = search(cells[cellIndex]);
661
+ if (deeper) return deeper;
662
+ }
663
+ }
664
+ return undefined;
665
+ };
666
+ return search(this._state.sections);
617
667
  }
618
668
 
619
- /** Moves a section (top-level or slotted) to a top-level position. */
669
+ /** Moves a section (top-level or inside a cell) to a top-level position. */
620
670
  private _moveSection(id: string, index: number) {
671
+ if (this._isPinned(id)) return;
672
+ index = Math.max(index, this._firstFreeIndex);
621
673
  const from = this._state.sections.findIndex(s => s.id === id);
622
674
  if (from !== -1) {
623
675
  const to = index > from ? index - 1 : index;
@@ -629,59 +681,22 @@ export default class ZnPageBuilder extends ZincElement {
629
681
  this._commit({ sections });
630
682
  return;
631
683
  }
632
- const [moved, sections] = this._extract(id);
684
+ const [moved, sections] = extractSection(this._state.sections, id);
633
685
  if (!moved) return;
634
686
  this._pushHistory();
635
687
  sections.splice(index, 0, moved);
636
688
  this._commit({ sections });
637
689
  }
638
690
 
639
- /**
640
- * Moves a section into a container's slot. Dropping onto an occupied slot swaps
641
- * the two children (slot-to-slot reordering); top-level sections and containers
642
- * only enter empty slots / never enter slots respectively.
643
- */
644
- private _moveToSlot(id: string, containerId: string, slotIndex: number) {
645
- const moved = this._findSection(id);
646
- const container = this._findSection(containerId);
647
- const containerType = container ? this.registry.get(container.type) : undefined;
648
- if (!moved || !container || !containerType?.slots || id === containerId) return;
649
- if (this.registry.get(moved.type)?.slots) return; // no containers inside slots
650
- if (containerType.accepts && !containerType.accepts.includes(moved.type)) return;
651
- if (slotIndex < 0 || slotIndex >= containerType.slots) return;
652
-
653
- const target = container.children?.[slotIndex] ?? null;
654
- if (target?.id === id) return;
655
- const fromTop = this._state.sections.some(s => s.id === id);
656
- if (target && fromTop) return; // top-level sections only drop on empty slots
657
-
658
- this._pushHistory();
659
- const sections = structuredClone(this._state.sections);
660
- const containerRef = sections.find(s => s.id === containerId)!;
661
- containerRef.children = Array.from({ length: containerType.slots }, (_, i) => containerRef.children?.[i] ?? null);
662
- const movedCopy = structuredClone(moved);
663
-
664
- if (fromTop) {
665
- sections.splice(sections.findIndex(s => s.id === id), 1);
666
- } else {
667
- // Swap: the occupant (or null) takes the source slot.
668
- for (const s of sections) {
669
- const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
670
- if (slot !== -1) {
671
- s.children![slot] = target ? structuredClone(target) : null;
672
- break;
673
- }
674
- }
675
- }
676
- containerRef.children[slotIndex] = movedCopy;
677
- this._commit({ sections });
678
- }
679
-
680
691
  // --- Canvas drag & drop -----------------------------------------------------
681
692
 
693
+ /** Id of the section currently being dragged — dataTransfer is unreadable during dragover. */
694
+ private _draggingId: string | null = null;
695
+
682
696
  private _onCardDragStart(e: DragEvent, id: string) {
683
697
  if (!e.dataTransfer) return;
684
698
  e.stopPropagation(); // a child card's drag must not also start its container's
699
+ this._draggingId = id;
685
700
  e.dataTransfer.setData(PAGE_SECTION_MIME, id);
686
701
  e.dataTransfer.effectAllowed = 'move';
687
702
  }
@@ -692,24 +707,6 @@ export default class ZnPageBuilder extends ZincElement {
692
707
  return types.includes(PAGE_TYPE_MIME) || types.includes(PAGE_SECTION_MIME);
693
708
  }
694
709
 
695
- private _onSlotDragOver(e: DragEvent, containerId: string, index: number) {
696
- if (!this._isPageDrag(e)) return;
697
- e.preventDefault();
698
- e.stopPropagation();
699
- this._dragOverIndex = null;
700
- this._slotDragOver = { containerId, index };
701
- }
702
-
703
- private _onSlotDrop(e: DragEvent, containerId: string, index: number) {
704
- e.preventDefault();
705
- e.stopPropagation();
706
- this._slotDragOver = null;
707
- const typeKey = e.dataTransfer?.getData(PAGE_TYPE_MIME);
708
- const sectionId = e.dataTransfer?.getData(PAGE_SECTION_MIME);
709
- if (typeKey) this.addSectionToSlot(typeKey, containerId, index);
710
- else if (sectionId) this._moveToSlot(sectionId, containerId, index);
711
- }
712
-
713
710
  private _onZoneDragOver(e: DragEvent, index: number) {
714
711
  if (!this._isPageDrag(e)) return;
715
712
  e.preventDefault();
@@ -739,6 +736,124 @@ export default class ZnPageBuilder extends ZincElement {
739
736
  this._onZoneDrop(e, this._state.sections.length);
740
737
  };
741
738
 
739
+ // --- Container cell drag & drop ----------------------------------------------
740
+
741
+ /**
742
+ * Whether `typeKey`'s registered type is allowed into this container by its
743
+ * `accepts`/`slots=` rule — the nesting depth cap is a separate concern,
744
+ * checked by each caller against its own notion of how tall the dropped
745
+ * subtree is.
746
+ */
747
+ private _acceptsType(container: PageSection, typeKey: string): boolean {
748
+ const containerType = this.registry.get(container.type);
749
+ const dragged = this.registry.get(typeKey);
750
+ if (!dragged || !isContainer(containerType)) return false;
751
+ if (containerType!.accepts) return containerType!.accepts.includes(typeKey);
752
+ // The deprecated `slots=` alias keeps its stricter non-containers-only rule.
753
+ if (!containerType!.container) return !isContainer(dragged);
754
+ return true;
755
+ }
756
+
757
+ /**
758
+ * Whether `typeKey` may be dropped into this container's cells from the
759
+ * palette. A newly created section always has height 1, so the depth cap
760
+ * only needs the target's own depth.
761
+ */
762
+ private _acceptsInCell(container: PageSection, typeKey: string): boolean {
763
+ const dragged = this.registry.get(typeKey);
764
+ // The nesting depth cap is enforced independently of `accepts` — an explicit
765
+ // allow-list must not be able to punch through MAX_CONTAINER_LEVELS.
766
+ if (dragged && isContainer(dragged) && containerDepth(this._state.sections, container.id) >= MAX_CONTAINER_LEVELS) {
767
+ return false;
768
+ }
769
+ return this._acceptsType(container, typeKey);
770
+ }
771
+
772
+ /**
773
+ * Whether a dragged, already-placed section may land in this container's
774
+ * cells. Unlike a palette drop, the moved subtree can already be several
775
+ * containers tall (it may itself hold a nested container), so the cap has to
776
+ * account for that height, not just the target's depth: dropping `moved` at
777
+ * a new depth of `containerDepth(container) + 1` puts its own deepest
778
+ * descendant at `containerDepth(container) + containerHeight(moved)`.
779
+ */
780
+ private _canMoveIntoCell(container: PageSection, sectionId: string): boolean {
781
+ if (this._isPinned(sectionId)) return false;
782
+ if (sectionId === container.id) return false;
783
+ const moved = findSection(this._state.sections, sectionId);
784
+ if (!moved) return false;
785
+ // Refuse dropping a container into its own subtree.
786
+ if (findSection([moved], container.id)) return false;
787
+ if (containerDepth(this._state.sections, container.id) + containerHeight(moved) > MAX_CONTAINER_LEVELS) {
788
+ return false;
789
+ }
790
+ return this._acceptsType(container, moved.type);
791
+ }
792
+
793
+ private _onCellDragOver(e: DragEvent, containerId: string, cellIndex: number, insertIndex: number) {
794
+ if (!this._isPageDrag(e)) return;
795
+ const container = findSection(this._state.sections, containerId);
796
+ if (!container) return;
797
+ // Claim the event before deciding accept/refuse — a nested cell is the deepest
798
+ // target and must not let a refusal bubble up into an ancestor cell that accepts.
799
+ e.stopPropagation();
800
+ const types = e.dataTransfer ? Array.from(e.dataTransfer.types) : [];
801
+ // A section drag is validated here from its own tracked id, since dataTransfer
802
+ // values are unreadable during a real dragover (protected mode).
803
+ if (types.includes(PAGE_SECTION_MIME) && this._draggingId && !this._canMoveIntoCell(container, this._draggingId)) {
804
+ return;
805
+ }
806
+ // A type-key drag's payload is unreadable during a real dragover too, so this
807
+ // only enforces `accepts`/depth when the payload happens to be readable; the
808
+ // definitive check is on drop.
809
+ const typeKey = types.includes(PAGE_TYPE_MIME) ? e.dataTransfer?.getData(PAGE_TYPE_MIME) : '';
810
+ if (typeKey && !this._acceptsInCell(container, typeKey)) return;
811
+ e.preventDefault();
812
+ this._dragOverIndex = null;
813
+ this._cellDragOver = { containerId, cellIndex, insertIndex };
814
+ }
815
+
816
+ private _onCellDrop(e: DragEvent, containerId: string, cellIndex: number, insertIndex: number) {
817
+ e.preventDefault();
818
+ e.stopPropagation();
819
+ this._cellDragOver = null;
820
+ const typeKey = e.dataTransfer?.getData(PAGE_TYPE_MIME);
821
+ const sectionId = e.dataTransfer?.getData(PAGE_SECTION_MIME);
822
+ if (typeKey) this.addSectionToCell(typeKey, containerId, cellIndex, insertIndex);
823
+ else if (sectionId) this._moveToCell(sectionId, containerId, cellIndex, insertIndex);
824
+ }
825
+
826
+ /** Adds a new section of a registered type into a container cell. */
827
+ addSectionToCell(type: string, containerId: string, cellIndex: number, insertIndex = 0): PageSection | null {
828
+ const container = findSection(this._state.sections, containerId);
829
+ if (!container || !this._acceptsInCell(container, type)) return null;
830
+ const section = this._newSection(type);
831
+ this._pushHistory();
832
+ this._commit({
833
+ sections: insertIntoCell(
834
+ this._state.sections, containerId, cellIndex, insertIndex, section,
835
+ containerColumns(container, this.registry.get(container.type))
836
+ ),
837
+ });
838
+ this._select(section.id);
839
+ return section;
840
+ }
841
+
842
+ private _moveToCell(id: string, containerId: string, cellIndex: number, insertIndex: number) {
843
+ const container = findSection(this._state.sections, containerId);
844
+ if (!container || !this._canMoveIntoCell(container, id)) return;
845
+ const columns = containerColumns(container, this.registry.get(container.type));
846
+ const [moved, without] = extractSection(this._state.sections, id);
847
+ if (!moved) return;
848
+ this._pushHistory();
849
+ this._commit({ sections: insertIntoCell(without, containerId, cellIndex, insertIndex, moved, columns) });
850
+ }
851
+
852
+ /** Types offerable in a container's cells. */
853
+ private _cellTypes(container: PageSection): PageSectionType[] {
854
+ return this.registry.all().filter(t => this._acceptsInCell(container, t.type));
855
+ }
856
+
742
857
  private _onCardKeydown(e: KeyboardEvent, id: string) {
743
858
  if (e.key === 'Delete' || e.key === 'Backspace') {
744
859
  e.preventDefault();
@@ -864,7 +979,7 @@ export default class ZnPageBuilder extends ZincElement {
864
979
  Drag sections here to build your page
865
980
  </div>` : ''}
866
981
  ${sections.map((section, i) => html`
867
- ${this._renderDropZone(i)}
982
+ ${i === 0 && this._pinnedId !== null ? '' : this._renderDropZone(i)}
868
983
  ${this._renderCard(section, i)}
869
984
  `)}
870
985
  ${this._renderDropZone(sections.length)}
@@ -879,10 +994,11 @@ export default class ZnPageBuilder extends ZincElement {
879
994
  extraClass = ''
880
995
  ) {
881
996
  const type = this.registry.get(section.type);
997
+ const pinned = this._isPinned(section.id);
882
998
  return html`
883
999
  <zn-page-section-card
884
1000
  class="${extraClass}"
885
- draggable="true"
1001
+ draggable="${pinned ? 'false' : 'true'}"
886
1002
  tabindex="0"
887
1003
  label="${section.label ?? type?.label ?? section.type}"
888
1004
  summary="${type ? sectionSummary(section, type) : `Unknown type "${section.type}"`}"
@@ -891,6 +1007,7 @@ export default class ZnPageBuilder extends ZincElement {
891
1007
  color="${ifDefined(type?.color)}"
892
1008
  ?selected="${this._selectedId === section.id}"
893
1009
  ?unknown="${!type}"
1010
+ ?locked="${pinned}"
894
1011
  @click="${(e: Event) => {
895
1012
  e.stopPropagation();
896
1013
  this._select(section.id);
@@ -904,59 +1021,92 @@ export default class ZnPageBuilder extends ZincElement {
904
1021
  }
905
1022
 
906
1023
  private _renderCard(section: PageSection, index: number) {
907
- const type = this.registry.get(section.type);
908
- const card = this._renderSectionCard(section, {
1024
+ return this._renderNode(section, {
909
1025
  over: e => this._onZoneDragOver(e, index + 1),
910
1026
  drop: e => this._onZoneDrop(e, index + 1),
911
1027
  });
912
- if (!type?.slots) return card;
1028
+ }
1029
+
1030
+ /**
1031
+ * A card, or — for a container — its card plus its own cell grid. Shared by the
1032
+ * top-level section list and container-cell stacks, so nesting renders at every level.
1033
+ */
1034
+ private _renderNode(
1035
+ section: PageSection,
1036
+ drop: { over: (e: DragEvent) => void; drop: (e: DragEvent) => void },
1037
+ extraClass = ''
1038
+ ): TemplateResult {
1039
+ const type = this.registry.get(section.type);
1040
+ const card = this._renderSectionCard(section, drop, extraClass);
1041
+ if (!isContainer(type)) return card;
913
1042
  return html`
914
1043
  <div class="container">
915
1044
  ${card}
916
- <div class="slots">
917
- ${sectionChildren(section, type).map((child, i) => this._renderSlot(section, child, i))}
918
- </div>
1045
+ ${this._renderCells(section, type)}
919
1046
  </div>`;
920
1047
  }
921
1048
 
922
- private _renderSlot(container: PageSection, child: PageSection | null, index: number) {
923
- if (child) {
924
- return this._renderSectionCard(child, {
925
- over: e => this._onSlotDragOver(e, container.id, index),
926
- drop: e => this._onSlotDrop(e, container.id, index),
927
- }, 'slot__card');
928
- }
929
- const active = this._slotDragOver?.containerId === container.id && this._slotDragOver.index === index;
930
- const pickerOpen = this._slotPicker?.containerId === container.id && this._slotPicker.index === index;
1049
+ /** A container's cell grid: one track per width, one stack per cell. */
1050
+ private _renderCells(container: PageSection, type: PageSectionType | undefined): TemplateResult {
1051
+ const widths = containerWidths(container, type);
1052
+ const cells = containerCells(container, type);
1053
+ const grow = containerGrow(container, type);
1054
+ // A growable container never persists a trailing empty row, so the extra
1055
+ // row of drop targets is added here at render time.
1056
+ const rendered = grow ? [...cells, ...Array.from({ length: widths.length }, () => [] as PageSection[])] : cells;
1057
+ return html`
1058
+ <div class="cells" style="grid-template-columns:${widths.map(w => `${w}fr`).join(' ')}">
1059
+ ${rendered.map((stack, cellIndex) => this._renderCell(container, stack, cellIndex))}
1060
+ </div>`;
1061
+ }
1062
+
1063
+ private _renderCell(container: PageSection, stack: PageSection[], cellIndex: number): TemplateResult {
1064
+ const active = this._cellDragOver?.containerId === container.id
1065
+ && this._cellDragOver.cellIndex === cellIndex;
1066
+ const pickerOpen = this._cellPicker?.containerId === container.id
1067
+ && this._cellPicker.cellIndex === cellIndex;
931
1068
  return html`
932
1069
  <div
933
- class="slot slot--empty ${active ? 'slot--active' : ''}"
934
- @dragover="${(e: DragEvent) => this._onSlotDragOver(e, container.id, index)}"
1070
+ class="cell ${active ? 'cell--active' : ''} ${stack.length ? '' : 'cell--empty'}"
1071
+ @dragover="${(e: DragEvent) => this._onCellDragOver(e, container.id, cellIndex, stack.length)}"
935
1072
  @dragleave="${() => {
936
- if (this._slotDragOver?.containerId === container.id && this._slotDragOver.index === index) {
937
- this._slotDragOver = null;
1073
+ if (this._cellDragOver?.containerId === container.id && this._cellDragOver.cellIndex === cellIndex) {
1074
+ this._cellDragOver = null;
938
1075
  }
939
1076
  }}"
940
- @drop="${(e: DragEvent) => this._onSlotDrop(e, container.id, index)}"
1077
+ @drop="${(e: DragEvent) => this._onCellDrop(e, container.id, cellIndex, stack.length)}"
941
1078
  @click="${(e: Event) => {
942
1079
  e.stopPropagation();
943
- this._slotPicker = pickerOpen ? null : { containerId: container.id, index };
1080
+ this._cellPicker = pickerOpen ? null : { containerId: container.id, cellIndex };
944
1081
  }}">
945
- <zn-icon src="add" size="16"></zn-icon>
1082
+ ${stack.map((child, i) => html`
1083
+ ${this._renderCellStrip(container, cellIndex, i)}
1084
+ ${this._renderNode(child, {
1085
+ over: e => this._onCellDragOver(e, container.id, cellIndex, i),
1086
+ drop: e => this._onCellDrop(e, container.id, cellIndex, i),
1087
+ }, 'cell__card')}
1088
+ `)}
1089
+ ${this._renderCellStrip(container, cellIndex, stack.length)}
1090
+ ${stack.length === 0 ? html`<zn-icon src="add" size="16"></zn-icon>` : ''}
946
1091
  ${pickerOpen
947
- ? this._renderTypePicker(this._slotTypes(container), t => this.addSectionToSlot(t, container.id, index))
1092
+ ? this._renderTypePicker(this._cellTypes(container), t => this.addSectionToCell(t, container.id, cellIndex))
948
1093
  : ''}
949
1094
  </div>`;
950
1095
  }
951
1096
 
952
- /** Types allowed in a container's slots: non-containers, filtered by its accepts list. */
953
- private _slotTypes(container: PageSection): PageSectionType[] {
954
- const containerType = this.registry.get(container.type);
955
- return this.registry.all().filter(t =>
956
- !t.slots && (!containerType?.accepts || containerType.accepts.includes(t.type)));
1097
+ /** Thin insertion target between two cards in a stack. */
1098
+ private _renderCellStrip(container: PageSection, cellIndex: number, insertIndex: number) {
1099
+ const active = this._cellDragOver?.containerId === container.id
1100
+ && this._cellDragOver.cellIndex === cellIndex
1101
+ && this._cellDragOver.insertIndex === insertIndex;
1102
+ return html`
1103
+ <div
1104
+ class="cell__strip ${active ? 'cell__strip--active' : ''}"
1105
+ @dragover="${(e: DragEvent) => this._onCellDragOver(e, container.id, cellIndex, insertIndex)}"
1106
+ @drop="${(e: DragEvent) => this._onCellDrop(e, container.id, cellIndex, insertIndex)}"></div>`;
957
1107
  }
958
1108
 
959
- /** The one type-picker template both drop zones and slot cells render. */
1109
+ /** The one type-picker template both drop zones and container cells render. */
960
1110
  private _renderTypePicker(types: PageSectionType[], pick: (type: string) => void) {
961
1111
  return html`
962
1112
  <div class="picker" @click="${(e: Event) => e.stopPropagation()}">
@@ -966,7 +1116,6 @@ export default class ZnPageBuilder extends ZincElement {
966
1116
  class="picker__item"
967
1117
  @click="${() => {
968
1118
  this._pickerIndex = null;
969
- this._slotPicker = null;
970
1119
  pick(type.type);
971
1120
  }}">
972
1121
  <zn-icon src="${type.icon ?? 'widgets'}" library="${ifDefined(type.iconLibrary)}" size="14"></zn-icon>
@@ -1017,6 +1166,21 @@ export default class ZnPageBuilder extends ZincElement {
1017
1166
  const form = document.createElement('div');
1018
1167
  form.className = 'inspector__form';
1019
1168
  form.append(type.configTemplate.content.cloneNode(true));
1169
+ // Custom elements cloned out of a <template> are inert until they are
1170
+ // connected, so their accessors do not exist yet. Assigning `value` to one
1171
+ // would define an own property that permanently shadows the setter the
1172
+ // element defines on upgrade — Lit only replays properties it declares
1173
+ // reactive, so a hand-written `value` accessor (zn-icon-picker) would keep
1174
+ // its default and the control would render empty. Upgrade first.
1175
+ //
1176
+ // Toggles default to a stacked label, which reads badly in a narrow panel —
1177
+ // put the switch on the label's right unless the host asked for a position.
1178
+ // Set before the upgrade, while `label-position` can only be an authored
1179
+ // attribute and never one zn-toggle reflected from its own default.
1180
+ form.querySelectorAll<HTMLElement>('zn-toggle:not([label-position])').forEach(toggle => {
1181
+ toggle.setAttribute('label-position', 'left');
1182
+ });
1183
+ customElements.upgrade(form);
1020
1184
  form.querySelectorAll<HTMLElement>('[name]').forEach(control => {
1021
1185
  const name = control.getAttribute('name')!;
1022
1186
  const value = section.data[name];
@@ -1029,7 +1193,9 @@ export default class ZnPageBuilder extends ZincElement {
1029
1193
  (control as HTMLInputElement).value = String(value);
1030
1194
  }
1031
1195
  });
1032
- this._form = form;
1196
+ // A content-free template stamps no controls — leave the form null so the
1197
+ // inspector shows its "no settings" hint rather than a blank gap.
1198
+ this._form = form.childElementCount ? form : null;
1033
1199
  }
1034
1200
 
1035
1201
  private _isBooleanControl(control: HTMLElement, value: unknown): boolean {
@@ -1057,34 +1223,170 @@ export default class ZnPageBuilder extends ZincElement {
1057
1223
 
1058
1224
  private _updateSectionData(id: string, patch: Record<string, unknown>) {
1059
1225
  this._pushHistory();
1060
- this._commit({ sections: this._patchSection(id, s => ({ ...s, data: { ...s.data, ...patch } })) });
1226
+ this._commit({ sections: patchSection(this._state.sections, id, s => ({ ...s, data: { ...s.data, ...patch } })) });
1061
1227
  }
1062
1228
 
1063
1229
  private _renameSection(id: string, label: string) {
1064
1230
  this._pushHistory();
1065
- this._commit({ sections: this._patchSection(id, s => ({ ...s, label: label || undefined })) });
1231
+ this._commit({ sections: patchSection(this._state.sections, id, s => ({ ...s, label: label || undefined })) });
1232
+ }
1233
+
1234
+ /** Replaces a container's layout, keeping cells consistent with it. */
1235
+ private _setLayout(
1236
+ id: string,
1237
+ next: (current: { widths: number[]; grow: boolean; cells: PageSection[][] }) => {
1238
+ widths: number[];
1239
+ grow: boolean;
1240
+ cells: PageSection[][];
1241
+ }
1242
+ ) {
1243
+ const container = findSection(this._state.sections, id);
1244
+ if (!container) return;
1245
+ const type = this.registry.get(container.type);
1246
+ const current = {
1247
+ widths: containerWidths(container, type),
1248
+ grow: containerGrow(container, type),
1249
+ cells: containerCells(container, type),
1250
+ };
1251
+ const { widths, grow, cells } = next(current);
1252
+ this._pushHistory();
1253
+ this._commit({
1254
+ sections: patchSection(this._state.sections, id, section => ({
1255
+ ...section,
1256
+ layout: { widths, grow },
1257
+ cells: normaliseCells(cells, widths.length, grow),
1258
+ })),
1259
+ });
1260
+ }
1261
+
1262
+ private _setColumns(id: string, columns: number) {
1263
+ this._setLayout(id, current => {
1264
+ const count = Math.min(Math.max(Math.floor(columns) || 1, 1), MAX_COLUMNS);
1265
+ const widths = Array.from({ length: count }, (_, i) => current.widths[i] ?? 1);
1266
+ return { widths, grow: current.grow, cells: recolumnCells(current.cells, count) };
1267
+ });
1268
+ }
1269
+
1270
+ private _setWidth(id: string, column: number, width: number) {
1271
+ const container = findSection(this._state.sections, id);
1272
+ if (!container) return;
1273
+ const columns = containerWidths(container, this.registry.get(container.type)).length;
1274
+ if (column < 0 || column >= columns) return;
1275
+ this._setLayout(id, current => {
1276
+ const widths = [...current.widths];
1277
+ widths[column] = Math.min(Math.max(Math.floor(width) || 1, 1), MAX_WIDTH);
1278
+ return { ...current, widths };
1279
+ });
1280
+ }
1281
+
1282
+ private _setGrow(id: string, grow: boolean) {
1283
+ this._setLayout(id, current => ({ ...current, grow }));
1284
+ }
1285
+
1286
+ /** Pads with empty rows, or trims trailing empty rows down to the last occupied one. */
1287
+ private _setRows(id: string, rows: number) {
1288
+ this._setLayout(id, current => {
1289
+ const columns = current.widths.length;
1290
+ const wanted = Math.max(Math.floor(rows) || 1, 1);
1291
+ const target = wanted * columns;
1292
+ const cells = trimTrailingEmptyCells(current.cells);
1293
+ while (cells.length < target) cells.push([]);
1294
+ return { ...current, cells };
1295
+ });
1296
+ }
1297
+
1298
+ private _renderLayoutGroup(section: PageSection, type: PageSectionType | undefined) {
1299
+ if (!isContainer(type)) return '';
1300
+ const widths = containerWidths(section, type);
1301
+ const grow = containerGrow(section, type);
1302
+ const rows = containerCells(section, type).length / widths.length;
1303
+ return html`
1304
+ <div class="layout-group" @click="${(e: Event) => e.stopPropagation()}">
1305
+ <div class="layout-group__title">Layout</div>
1306
+ <zn-input
1307
+ class="layout-group__columns"
1308
+ type="number"
1309
+ label="Columns"
1310
+ min="1"
1311
+ max="${MAX_COLUMNS}"
1312
+ .value="${String(widths.length)}"
1313
+ @zn-change="${(e: Event) => this._setColumns(section.id, Number((e.target as ZnInput).value))}"></zn-input>
1314
+ <div class="layout-group__widths">
1315
+ <span class="layout-group__label">Widths</span>
1316
+ <div class="layout-group__weights">
1317
+ ${widths.map((width, column) => html`
1318
+ <zn-input
1319
+ type="number"
1320
+ min="1"
1321
+ max="${MAX_WIDTH}"
1322
+ aria-label="Column ${column + 1} width"
1323
+ .value="${String(width)}"
1324
+ @zn-change="${(e: Event) => this._setWidth(section.id, column, Number((e.target as ZnInput).value))}"></zn-input>`)}
1325
+ </div>
1326
+ </div>
1327
+ <zn-toggle
1328
+ label="Keep adding rows"
1329
+ label-position="left"
1330
+ description="Offers a new row as the last one fills."
1331
+ ?checked="${grow}"
1332
+ @zn-input="${(e: Event) => this._setGrow(section.id, Boolean((e.target as HTMLInputElement).checked))}"></zn-toggle>
1333
+ ${grow ? '' : html`
1334
+ <zn-input
1335
+ type="number"
1336
+ label="Rows"
1337
+ min="1"
1338
+ .value="${String(rows)}"
1339
+ @zn-change="${(e: Event) => this._setRows(section.id, Number((e.target as ZnInput).value))}"></zn-input>`}
1340
+ </div>`;
1066
1341
  }
1067
1342
 
1068
1343
  private _renderInspector() {
1069
1344
  const section = this._selectedSection();
1070
1345
  if (!section) return html``;
1071
1346
  const type = this.registry.get(section.type);
1347
+ const hasConfig = Boolean(type?.renderConfig) || this._form !== null;
1348
+ const title = section.label ?? type?.label ?? section.type;
1349
+ const typeLabel = type?.label ?? section.type;
1072
1350
  return html`
1073
1351
  <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>
1352
+ <div part="inspector-header" class="inspector-head">
1353
+ <span
1354
+ class="inspector-head__icon"
1355
+ style="--section-accent:${type?.color ?? 'rgb(var(--zn-color-primary))'}">
1356
+ <zn-icon src="${type?.icon ?? 'widgets'}" library="${ifDefined(type?.iconLibrary)}" size="18"></zn-icon>
1357
+ </span>
1358
+ <div class="inspector-head__text">
1359
+ <div class="inspector-head__title">${title}</div>
1360
+ ${title === typeLabel ? '' : html`
1361
+ <div class="inspector-head__type">${typeLabel}</div>`}
1362
+ </div>
1363
+ <button
1364
+ type="button"
1365
+ class="inspector-close"
1366
+ title="Close section settings"
1367
+ aria-label="Close section settings"
1368
+ @click="${() => this._select(null)}">
1369
+ <zn-icon src="x@lu" size="18"></zn-icon>
1370
+ </button>
1371
+ </div>
1079
1372
  <div
1080
- class="inspector__body"
1373
+ part="inspector-body"
1374
+ class="inspector-body"
1081
1375
  @change="${this._onInspectorInput}"
1082
1376
  @zn-change="${this._onInspectorInput}"
1083
1377
  @input="${this._onInspectorInput}"
1084
1378
  @zn-input="${this._onInspectorInput}">
1379
+ ${this._renderLayoutGroup(section, type)}
1380
+ <zn-input
1381
+ class="inspector__rename"
1382
+ label="Section name"
1383
+ .value="${section.label ?? type?.label ?? ''}"
1384
+ @zn-change="${(e: Event) => this._renameSection(section.id, String((e.target as ZnInput).value ?? ''))}"></zn-input>
1085
1385
  ${type?.renderConfig
1086
1386
  ? type.renderConfig(section, data => this._updateSectionData(section.id, data))
1087
1387
  : this._form}
1388
+ ${hasConfig ? '' : html`
1389
+ <p class="inspector-hint">This section type has no settings.</p>`}
1088
1390
  </div>
1089
1391
  </aside>`;
1090
1392
  }
@@ -1097,7 +1399,8 @@ export default class ZnPageBuilder extends ZincElement {
1097
1399
  class="builder ${this._selectedId ? 'builder--inspecting' : ''} ${this.paletteCollapsed ? 'builder--palette-collapsed' : ''} ${this.inspectorCollapsed ? 'builder--inspector-collapsed' : ''}"
1098
1400
  @dragend="${() => {
1099
1401
  this._dragOverIndex = null;
1100
- this._slotDragOver = null;
1402
+ this._cellDragOver = null;
1403
+ this._draggingId = null;
1101
1404
  }}">
1102
1405
  <header part="header" class="header" ?hidden="${!hasHeader}">
1103
1406
  <div class="header__group">