@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,7 +1,37 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import {expect, fixture, html} from '@open-wc/testing';
3
+ import {PAGE_SECTION_MIME, PAGE_TYPE_MIME} from './page.types';
4
+ import type {PageSectionType, PageState} from './page.types';
3
5
  import type ZnPageBuilder from './page-builder.component';
4
6
 
7
+ /** Reaches the private registry to inspect a parsed template's PageSectionType. */
8
+ const registryOf = (el: ZnPageBuilder) =>
9
+ (el as unknown as { registry: { get: (type: string) => PageSectionType | undefined } }).registry;
10
+
11
+ const containerConfig = (over = {}) => JSON.stringify({
12
+ sections: [{
13
+ id: 'outer', type: 'row', data: {},
14
+ layout: {widths: [1, 2, 1], grow: false},
15
+ cells: [[{id: 'a', type: 'hero', data: {}}], [], []],
16
+ ...over,
17
+ }],
18
+ });
19
+
20
+ const selectFirst = async (el: ZnPageBuilder) => {
21
+ el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
22
+ await el.updateComplete;
23
+ };
24
+
25
+ const dragTo = (el: Element, mime: string, payload: string, kind: 'dragover' | 'drop') => {
26
+ const data = new Map([[mime, payload]]);
27
+ const event = new DragEvent(kind, {bubbles: true, cancelable: true, composed: true});
28
+ Object.defineProperty(event, 'dataTransfer', {
29
+ value: {types: [...data.keys()], getData: (k: string) => data.get(k) ?? '', setData: () => {}},
30
+ });
31
+ el.dispatchEvent(event);
32
+ return event;
33
+ };
34
+
5
35
  describe('<zn-page-builder>', () => {
6
36
  it('should render the shell', async () => {
7
37
  const el = await fixture<ZnPageBuilder>(html`
@@ -27,6 +57,58 @@ describe('<zn-page-builder>', () => {
27
57
  expect(categories).to.deep.equal(['Headers', 'Content']);
28
58
  });
29
59
 
60
+ it('should parse container/columns/widths/grow template attributes', async () => {
61
+ const el = await fixture<ZnPageBuilder>(html`
62
+ <zn-page-builder>
63
+ <template type="row" slot="config" label="Row" container widths="1, 2 1" grow></template>
64
+ <template type="cols" slot="config" label="Cols" container columns="4"></template>
65
+ <template type="bare" slot="config" label="Bare" container></template>
66
+ <template type="legacy" slot="config" label="Legacy" slots="6"></template>
67
+ </zn-page-builder>`);
68
+ await el.updateComplete;
69
+ const registry = registryOf(el);
70
+
71
+ // widths beats columns and tolerates mixed comma/whitespace separators.
72
+ const row = registry.get('row');
73
+ expect(row?.container).to.be.true;
74
+ expect(row?.defaultWidths).to.deep.equal([1, 2, 1]);
75
+ expect(row?.defaultGrow).to.be.true;
76
+
77
+ expect(registry.get('cols')?.defaultWidths).to.deep.equal([1, 1, 1, 1]);
78
+ expect(registry.get('cols')?.defaultGrow).to.be.false;
79
+
80
+ // bare `container` seeds DEFAULT_WIDTHS.
81
+ expect(registry.get('bare')?.defaultWidths).to.deep.equal([1, 1, 1]);
82
+ expect(registry.get('bare')?.defaultGrow).to.be.false;
83
+
84
+ // legacy `slots` keeps working, untouched by the new fields.
85
+ const legacy = registry.get('legacy');
86
+ expect(legacy?.slots).to.equal(6);
87
+ expect(legacy?.container).to.be.undefined;
88
+ expect(legacy?.defaultWidths).to.be.undefined;
89
+ expect(legacy?.defaultGrow).to.be.undefined;
90
+ });
91
+
92
+ it('should filter non-numeric widths tokens before falling back to columns/DEFAULT_WIDTHS', async () => {
93
+ const el = await fixture<ZnPageBuilder>(html`
94
+ <zn-page-builder>
95
+ <template type="typo" slot="config" label="Typo" container widths="1 x 2"></template>
96
+ <template type="rescue" slot="config" label="Rescue" container widths="abc" columns="4"></template>
97
+ <template type="garbage" slot="config" label="Garbage" container widths="abc"></template>
98
+ </zn-page-builder>`);
99
+ await el.updateComplete;
100
+ const registry = registryOf(el);
101
+
102
+ // A stray non-numeric token is dropped, not coerced to a column via sanitiseWidths.
103
+ expect(registry.get('typo')?.defaultWidths).to.deep.equal([1, 2]);
104
+
105
+ // A wholly non-numeric widths falls through to columns, not to a single column.
106
+ expect(registry.get('rescue')?.defaultWidths).to.deep.equal([1, 1, 1, 1]);
107
+
108
+ // A wholly non-numeric widths with no columns falls through to DEFAULT_WIDTHS.
109
+ expect(registry.get('garbage')?.defaultWidths).to.deep.equal([1, 1, 1]);
110
+ });
111
+
30
112
  it('should tuck the palette away via the canvas-edge chevron', async () => {
31
113
  const el = await fixture<ZnPageBuilder>(html`
32
114
  <zn-page-builder>
@@ -112,6 +194,35 @@ describe('<zn-page-builder>', () => {
112
194
  expect(el.state.sections[0].type).to.equal('gone'); // preserved through save
113
195
  });
114
196
 
197
+ it('should summarise a card with the chosen option label, not the stored value', async () => {
198
+ const el = await fixture<ZnPageBuilder>(html`
199
+ <zn-page-builder
200
+ config='{"sections":[{"id":"s1","type":"tile","data":{"icon":"folder","article":"1a2b3c"}}]}'>
201
+ <template type="tile" slot="config" label="Article">
202
+ <zn-icon-picker name="icon"></zn-icon-picker>
203
+ <zn-select name="article">
204
+ <zn-option value="1a2b3c">Getting Started</zn-option>
205
+ </zn-select>
206
+ </template>
207
+ </zn-page-builder>`);
208
+ await el.updateComplete;
209
+
210
+ const card = el.shadowRoot?.querySelector('zn-page-section-card');
211
+ expect(card?.getAttribute('summary')).to.equal('Getting Started');
212
+ });
213
+
214
+ it('should fall back to the first string value when nothing matches an option', async () => {
215
+ const el = await fixture<ZnPageBuilder>(html`
216
+ <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"title":"Welcome"}}]}'>
217
+ <template type="hero" slot="config" label="Hero">
218
+ <input name="title">
219
+ </template>
220
+ </zn-page-builder>`);
221
+ await el.updateComplete;
222
+
223
+ expect(el.shadowRoot?.querySelector('zn-page-section-card')?.getAttribute('summary')).to.equal('Welcome');
224
+ });
225
+
115
226
  it('should round-trip section data through the inspector name binding', async () => {
116
227
  const el = await fixture<ZnPageBuilder>(html`
117
228
  <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"title":"Before"}}]}'>
@@ -206,6 +317,34 @@ describe('<zn-page-builder>', () => {
206
317
  expect(el.state.sections[0].data.categories).to.deep.equal(['billing', 'setup']);
207
318
  });
208
319
 
320
+ // zn-icon-picker keeps `value` as a plain accessor over its reactive `icon`,
321
+ // so it only prefills if the stamped element is upgraded before assignment —
322
+ // otherwise the assignment shadows the setter and the picker renders empty.
323
+ it('should prefill a control whose value is a plain accessor', async () => {
324
+ const el = await fixture<ZnPageBuilder>(html`
325
+ <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"icon":"receipt"}}]}'>
326
+ <template type="hero" slot="config" label="Hero">
327
+ <zn-icon-picker name="icon" label="Icon" no-color no-library></zn-icon-picker>
328
+ </template>
329
+ </zn-page-builder>`);
330
+ await el.updateComplete;
331
+
332
+ el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
333
+ await el.updateComplete;
334
+
335
+ const picker = el.shadowRoot!.querySelector<HTMLElement & { value: string; icon: string }>(
336
+ '.inspector__form zn-icon-picker[name="icon"]')!;
337
+ expect(picker, 'stamped icon picker').to.exist;
338
+ expect(picker.value).to.equal('receipt');
339
+ expect(picker.icon, 'reached the reactive property, not an own shadowing one').to.equal('receipt');
340
+
341
+ picker.value = 'payments';
342
+ picker.dispatchEvent(new CustomEvent('zn-change', {bubbles: true}));
343
+ await el.updateComplete;
344
+
345
+ expect(el.state.sections[0].data.icon).to.equal('payments');
346
+ });
347
+
209
348
  it('should rename a section from the inspector', async () => {
210
349
  const el = await fixture<ZnPageBuilder>(html`
211
350
  <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{}}]}'>
@@ -224,6 +363,83 @@ describe('<zn-page-builder>', () => {
224
363
  expect(el.state.sections[0].label).to.equal('My Hero');
225
364
  });
226
365
 
366
+ it('should head the inspector with the section name and its type', async () => {
367
+ const el = await fixture<ZnPageBuilder>(html`
368
+ <zn-page-builder config='{"sections":[
369
+ {"id":"s1","type":"hero","label":"Top banner","data":{}},
370
+ {"id":"s2","type":"hero","data":{}}]}'>
371
+ <template type="hero" slot="config" label="Hero" icon="star">
372
+ <zn-input name="title" label="Title"></zn-input>
373
+ </template>
374
+ </zn-page-builder>`);
375
+ await el.updateComplete;
376
+
377
+ const cards = el.shadowRoot?.querySelectorAll('zn-page-section-card');
378
+ cards?.[0].dispatchEvent(new Event('click'));
379
+ await el.updateComplete;
380
+
381
+ const head = el.shadowRoot?.querySelector('[part="inspector-header"]');
382
+ expect(head, 'inspector header').to.exist;
383
+ expect(head?.querySelector('.inspector-head__title')?.textContent?.trim()).to.equal('Top banner');
384
+ expect(head?.querySelector('.inspector-head__type')?.textContent?.trim()).to.equal('Hero');
385
+ expect(head?.querySelector('zn-icon')?.getAttribute('src')).to.equal('star');
386
+ expect(el.shadowRoot?.querySelector('[part="inspector-body"] .inspector__rename'), 'rename in body').to.exist;
387
+
388
+ // An unnamed section's title already is the type label — don't print it twice.
389
+ cards?.[1].dispatchEvent(new Event('click'));
390
+ await el.updateComplete;
391
+ const unnamed = el.shadowRoot?.querySelector('[part="inspector-header"]');
392
+ expect(unnamed?.querySelector('.inspector-head__title')?.textContent?.trim()).to.equal('Hero');
393
+ expect(unnamed?.querySelector('.inspector-head__type'), 'no duplicate type line').to.not.exist;
394
+ });
395
+
396
+ it('should clear the selection from the inspector close button', async () => {
397
+ const el = await fixture<ZnPageBuilder>(html`
398
+ <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{}}]}'>
399
+ <template type="hero" slot="config" label="Hero"></template>
400
+ </zn-page-builder>`);
401
+ await el.updateComplete;
402
+
403
+ el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
404
+ await el.updateComplete;
405
+ expect(el.shadowRoot?.querySelector('[part="inspector"]')).to.exist;
406
+
407
+ el.shadowRoot?.querySelector<HTMLButtonElement>('.inspector-close')?.click();
408
+ await el.updateComplete;
409
+ expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'inspector closes').to.not.exist;
410
+ });
411
+
412
+ it('should hint when the selected type has no settings', async () => {
413
+ const el = await fixture<ZnPageBuilder>(html`
414
+ <zn-page-builder config='{"sections":[{"id":"s1","type":"divider","data":{}}]}'>
415
+ <template type="divider" slot="config" label="Divider"></template>
416
+ </zn-page-builder>`);
417
+ await el.updateComplete;
418
+
419
+ el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
420
+ await el.updateComplete;
421
+
422
+ expect(el.shadowRoot?.querySelector('.inspector-hint')).to.exist;
423
+ });
424
+
425
+ it('should lay stamped toggles out as a row, honouring a host-set position', async () => {
426
+ const el = await fixture<ZnPageBuilder>(html`
427
+ <zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{}}]}'>
428
+ <template type="hero" slot="config" label="Hero">
429
+ <zn-toggle name="showSearch" label="Show search"></zn-toggle>
430
+ <zn-toggle name="showNav" label="Show nav" label-position="top"></zn-toggle>
431
+ </template>
432
+ </zn-page-builder>`);
433
+ await el.updateComplete;
434
+
435
+ el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
436
+ await el.updateComplete;
437
+
438
+ const form = el.shadowRoot?.querySelector('.inspector__form');
439
+ expect(form?.querySelector('zn-toggle[name="showSearch"]')?.getAttribute('label-position')).to.equal('left');
440
+ expect(form?.querySelector('zn-toggle[name="showNav"]')?.getAttribute('label-position')).to.equal('top');
441
+ });
442
+
227
443
  it('should undo and redo a section add', async () => {
228
444
  const el = await fixture<ZnPageBuilder>(html`
229
445
  <zn-page-builder>
@@ -257,82 +473,208 @@ describe('<zn-page-builder>', () => {
257
473
  await expect(el).to.be.accessible();
258
474
  });
259
475
 
260
- it('should render a slot grid for container sections and fill slots via addSectionToSlot', async () => {
476
+ it('should pin a required-first section, inserting one when the page has none', async () => {
261
477
  const el = await fixture<ZnPageBuilder>(html`
262
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
263
- <template type="article-grid" slot="config" label="Article Grid" slots="6" accepts="article-tile"></template>
264
- <template type="article-tile" slot="config" label="Article"></template>
478
+ <zn-page-builder required-first="hero">
265
479
  <template type="hero" slot="config" label="Hero"></template>
480
+ <template type="rich-text" slot="config" label="Rich Text"></template>
266
481
  </zn-page-builder>`);
267
482
  await el.updateComplete;
268
483
 
269
- expect(el.shadowRoot?.querySelectorAll('.slot--empty')).to.have.length(6);
484
+ expect(el.state.sections.map(s => s.type), 'inserted into an empty page').to.deep.equal(['hero']);
485
+ // The submitted value carries the pinned section, so a save can't miss it.
486
+ expect((JSON.parse(el.value) as PageState).sections).to.have.length(1);
270
487
 
271
- expect(el.addSectionToSlot('hero', 'grid', 0), 'not in accepts').to.be.null;
272
- expect(el.addSectionToSlot('article-grid', 'grid', 0), 'no nested containers').to.be.null;
488
+ const card = el.shadowRoot!.querySelector('zn-page-section-card')!;
489
+ expect(card.hasAttribute('locked'), 'card is locked').to.be.true;
490
+ expect(card.getAttribute('draggable'), 'card is not draggable').to.equal('false');
491
+ // No "+" strip above it — the first drop zone follows the pinned card.
492
+ expect(el.shadowRoot!.querySelector('.canvas > *')?.tagName.toLowerCase()).to.equal('zn-page-section-card');
493
+ });
273
494
 
274
- const child = el.addSectionToSlot('article-tile', 'grid', 1);
495
+ it('should hoist an existing required-first section to the top of the page', async () => {
496
+ const el = await fixture<ZnPageBuilder>(html`
497
+ <zn-page-builder required-first="hero"
498
+ config='{"sections":[{"id":"t","type":"rich-text","data":{}},{"id":"h","type":"hero","data":{"title":"Help"}}]}'>
499
+ <template type="hero" slot="config" label="Hero"></template>
500
+ <template type="rich-text" slot="config" label="Rich Text"></template>
501
+ </zn-page-builder>`);
275
502
  await el.updateComplete;
276
- expect(child?.type).to.equal('article-tile');
277
- expect(el.state.sections[0].children?.[1]?.id).to.equal(child?.id);
278
- expect(el.shadowRoot?.querySelectorAll('.slot--empty')).to.have.length(5);
279
- expect(el.shadowRoot?.querySelectorAll('.slots zn-page-section-card')).to.have.length(1);
280
503
 
281
- expect(el.addSectionToSlot('article-tile', 'grid', 1), 'occupied slot').to.be.null;
504
+ expect(el.state.sections.map(s => s.id)).to.deep.equal(['h', 't']);
505
+ expect(el.state.sections[0].data, 'hoisted with its data intact').to.deep.equal({title: 'Help'});
282
506
  });
283
507
 
284
- it('should swap children when dropping one filled slot onto another', async () => {
508
+ it('should refuse to remove, reorder or displace the pinned section', async () => {
285
509
  const el = await fixture<ZnPageBuilder>(html`
286
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
287
- <template type="article-grid" slot="config" label="Article Grid" slots="4"></template>
288
- <template type="article-tile" slot="config" label="Article"></template>
510
+ <zn-page-builder required-first="hero"
511
+ config='{"sections":[{"id":"h","type":"hero","data":{}},{"id":"t","type":"rich-text","data":{}}]}'>
512
+ <template type="hero" slot="config" label="Hero"></template>
513
+ <template type="rich-text" slot="config" label="Rich Text"></template>
289
514
  </zn-page-builder>`);
290
515
  await el.updateComplete;
291
516
 
292
- const a = el.addSectionToSlot('article-tile', 'grid', 0)!;
293
- const b = el.addSectionToSlot('article-tile', 'grid', 1)!;
517
+ const pinnedCard = el.shadowRoot!.querySelector('zn-page-section-card')!;
518
+ expect(pinnedCard.shadowRoot!.querySelector('zn-button[title="Remove section"]'), 'no remove action').to.not.exist;
519
+
520
+ pinnedCard.dispatchEvent(new CustomEvent('page-card-remove', {bubbles: true, composed: true}));
521
+ pinnedCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Delete', bubbles: true, cancelable: true}));
294
522
  await el.updateComplete;
523
+ expect(el.state.sections.map(s => s.id), 'survives remove and Delete').to.deep.equal(['h', 't']);
295
524
 
296
- // Drag child A onto child B's card (slot 1) they swap places.
297
- const cards = el.shadowRoot!.querySelectorAll('.slots zn-page-section-card');
525
+ // Dropping another section on the topmost zone lands it below the pinned one.
298
526
  const dataTransfer = new DataTransfer();
299
- dataTransfer.setData('application/x-zn-page-section', a.id);
300
- cards[1].dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
527
+ dataTransfer.setData('application/x-zn-page-section', 't');
528
+ el.shadowRoot!.querySelector('.drop')!
529
+ .dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
301
530
  await el.updateComplete;
531
+ expect(el.state.sections.map(s => s.id), 'nothing lands above the pinned section').to.deep.equal(['h', 't']);
302
532
 
303
- expect(el.state.sections[0].children?.[0]?.id).to.equal(b.id);
304
- expect(el.state.sections[0].children?.[1]?.id).to.equal(a.id);
533
+ expect(el.addSection('rich-text', 0)).to.not.be.null;
534
+ expect(el.state.sections[0].id, 'addSection index 0 is clamped').to.equal('h');
305
535
  });
306
536
 
307
- it('should edit a slotted child through the inspector', async () => {
537
+ it('should seed layout and cells for an inserted required-first container', async () => {
308
538
  const el = await fixture<ZnPageBuilder>(html`
309
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{"title":"Old"}}]}]}'>
310
- <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
311
- <template type="article-tile" slot="config" label="Article">
312
- <input name="title">
313
- </template>
539
+ <zn-page-builder required-first="row">
540
+ <template type="row" slot="config" label="Row" container widths="1 2 1"></template>
541
+ </zn-page-builder>`);
542
+ await el.updateComplete;
543
+
544
+ const row = el.state.sections[0];
545
+ expect(row.type).to.equal('row');
546
+ expect(row.layout, 'the one guaranteed section is seeded like any other container').to.deep.equal({widths: [1, 2, 1], grow: false});
547
+ expect(row.cells).to.have.lengthOf(3);
548
+ });
549
+
550
+ it('should leave the page alone without required-first', async () => {
551
+ const el = await fixture<ZnPageBuilder>(html`
552
+ <zn-page-builder config='{"sections":[{"id":"t","type":"rich-text","data":{}}]}'>
553
+ <template type="rich-text" slot="config" label="Rich Text"></template>
314
554
  </zn-page-builder>`);
315
555
  await el.updateComplete;
316
556
 
317
- el.shadowRoot?.querySelector('.slots zn-page-section-card')?.dispatchEvent(new Event('click'));
557
+ expect(el.state.sections.map(s => s.type)).to.deep.equal(['rich-text']);
558
+ const card = el.shadowRoot!.querySelector('zn-page-section-card')!;
559
+ expect(card.hasAttribute('locked')).to.be.false;
560
+ expect(card.getAttribute('draggable')).to.equal('true');
561
+ });
562
+
563
+ it('should accept a palette drop anywhere on the empty canvas', async () => {
564
+ const el = await fixture<ZnPageBuilder>(html`
565
+ <zn-page-builder>
566
+ <template type="hero" slot="config" label="Hero"></template>
567
+ </zn-page-builder>`);
568
+ await el.updateComplete;
569
+
570
+ const canvas = el.shadowRoot!.querySelector('.canvas')!;
571
+ const dataTransfer = new DataTransfer();
572
+ dataTransfer.setData('application/x-zn-page-type', 'hero');
573
+
574
+ const over = new DragEvent('dragover', {dataTransfer, bubbles: true, cancelable: true});
575
+ canvas.dispatchEvent(over);
576
+ expect(over.defaultPrevented, 'canvas accepts the drag').to.be.true;
577
+
578
+ canvas.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
579
+ await el.updateComplete;
580
+ expect(el.state.sections).to.have.length(1);
581
+ expect(el.state.sections[0].type).to.equal('hero');
582
+ });
583
+
584
+ it('round-trips an old-shape config into the new cells shape', async () => {
585
+ const el = await fixture<ZnPageBuilder>(html`
586
+ <zn-page-builder config='{"sections":[{"id":"g1","type":"grid","data":{},"children":[{"id":"a","type":"hero","data":{"title":"Kept"}},null,null]}]}'>
587
+ <template type="grid" slot="config" label="Grid" slots="6"></template>
588
+ <template type="hero" slot="config" label="Hero"><zn-input name="title" label="Title"></zn-input></template>
589
+ </zn-page-builder>`);
590
+ await el.updateComplete;
591
+
592
+ const grid = el.state.sections[0];
593
+ expect(grid.children, 'legacy shape not written back').to.be.undefined;
594
+ expect(grid.layout).to.deep.equal({widths: [1, 1, 1], grow: false});
595
+ expect(grid.cells).to.have.lengthOf(6);
596
+ expect(grid.cells?.[0][0].data.title).to.equal('Kept');
597
+ });
598
+
599
+ it('seeds layout and cells when a container is added', async () => {
600
+ const el = await fixture<ZnPageBuilder>(html`
601
+ <zn-page-builder>
602
+ <template type="row" slot="config" label="Row" container widths="1 2 1"></template>
603
+ </zn-page-builder>`);
604
+ await el.updateComplete;
605
+
606
+ el.addSection('row');
607
+ await el.updateComplete;
608
+
609
+ const row = el.state.sections[0];
610
+ expect(row.layout).to.deep.equal({widths: [1, 2, 1], grow: false});
611
+ expect(row.cells).to.have.lengthOf(3);
612
+ });
613
+
614
+ it('renames and edits a section nested two container levels deep', async () => {
615
+ const config = JSON.stringify({
616
+ sections: [{
617
+ id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
618
+ cells: [[{
619
+ id: 'inner', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
620
+ cells: [[{id: 'deep', type: 'hero', data: {}}], []],
621
+ }], []],
622
+ }],
623
+ });
624
+ const el = await fixture<ZnPageBuilder>(html`
625
+ <zn-page-builder config=${config}>
626
+ <template type="row" slot="config" label="Row" container></template>
627
+ <template type="hero" slot="config" label="Hero"><input name="title"></template>
628
+ </zn-page-builder>`);
629
+ await el.updateComplete;
630
+
631
+ (el as unknown as {_select(id: string): void})._select('deep');
318
632
  await el.updateComplete;
319
633
 
320
634
  const input = el.shadowRoot?.querySelector<HTMLInputElement>('.inspector__form input[name="title"]');
321
- expect(input, 'stamped input for child').to.exist;
322
- expect(input!.value).to.equal('Old');
635
+ expect(input, 'inspector stamped for the nested section').to.exist;
636
+ input!.value = 'Deep title';
637
+ input!.dispatchEvent(new Event('input', {bubbles: true, composed: true}));
638
+ await el.updateComplete;
323
639
 
324
- input!.value = 'New';
325
- input!.dispatchEvent(new Event('change', {bubbles: true}));
640
+ const deep = el.state.sections[0].cells![0][0].cells![0][0];
641
+ expect(deep.data.title).to.equal('Deep title');
642
+ });
643
+
644
+ it('duplicates a container with fresh ids throughout', async () => {
645
+ const config = JSON.stringify({
646
+ sections: [{
647
+ id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
648
+ cells: [[{id: 'a', type: 'hero', data: {}}], []],
649
+ }],
650
+ });
651
+ const el = await fixture<ZnPageBuilder>(html`
652
+ <zn-page-builder config=${config}>
653
+ <template type="row" slot="config" label="Row" container></template>
654
+ <template type="hero" slot="config" label="Hero"></template>
655
+ </zn-page-builder>`);
326
656
  await el.updateComplete;
327
657
 
328
- expect(el.state.sections[0].children?.[0]?.data.title).to.equal('New');
658
+ el.shadowRoot?.querySelector('zn-page-section-card')
659
+ ?.dispatchEvent(new CustomEvent('page-card-duplicate', {bubbles: true, composed: true}));
660
+ await el.updateComplete;
661
+
662
+ expect(el.state.sections).to.have.lengthOf(2);
663
+ const ids = el.state.sections.flatMap(s => [s.id, ...(s.cells ?? []).flat().map(c => c.id)]);
664
+ expect(new Set(ids).size, 'every id unique').to.equal(ids.length);
329
665
  });
330
666
 
331
- it('should clear selection when removing a container holding the selected child', async () => {
667
+ it('removes a section from inside a cell', async () => {
668
+ const config = JSON.stringify({
669
+ sections: [{
670
+ id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
671
+ cells: [[{id: 'a', type: 'hero', data: {}}], []],
672
+ }],
673
+ });
332
674
  const el = await fixture<ZnPageBuilder>(html`
333
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{}}]}]}'>
334
- <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
335
- <template type="article-tile" slot="config" label="Article"></template>
675
+ <zn-page-builder config=${config}>
676
+ <template type="row" slot="config" label="Row" container></template>
677
+ <template type="hero" slot="config" label="Hero"></template>
336
678
  </zn-page-builder>`);
337
679
  await el.updateComplete;
338
680
 
@@ -340,125 +682,463 @@ describe('<zn-page-builder>', () => {
340
682
  el.addEventListener('zn-page-selection-change', e =>
341
683
  (lastSelection = (e as CustomEvent<{sectionId: string | null}>).detail.sectionId));
342
684
 
343
- // Select the child, then remove its parent container.
344
- el.shadowRoot?.querySelector('.slots zn-page-section-card')?.dispatchEvent(new Event('click'));
685
+ (el as unknown as {_select(id: string): void})._select('a');
345
686
  await el.updateComplete;
346
- expect(lastSelection).to.equal('c1');
687
+ expect(lastSelection).to.equal('a');
347
688
 
348
- el.shadowRoot?.querySelector('.container > zn-page-section-card')
349
- ?.dispatchEvent(new CustomEvent('page-card-remove'));
689
+ (el as unknown as {_removeSection(id: string): void})._removeSection('a');
350
690
  await el.updateComplete;
351
691
 
692
+ expect(el.state.sections[0].cells?.[0]).to.deep.equal([]);
352
693
  expect(lastSelection, 'selection cleared').to.be.null;
353
- expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'no ghost inspector').to.not.exist;
354
694
  });
355
695
 
356
- it('should move a top-level section into an empty slot but not an occupied one', async () => {
696
+ it('trims a growable container trailing empty row on commit, not only on read', async () => {
697
+ const config = JSON.stringify({
698
+ sections: [{
699
+ id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: true},
700
+ cells: [
701
+ [{id: 'a', type: 'hero', data: {}}], [{id: 'b', type: 'hero', data: {}}],
702
+ [{id: 'c', type: 'hero', data: {}}], [],
703
+ ],
704
+ }],
705
+ });
357
706
  const el = await fixture<ZnPageBuilder>(html`
358
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}},{"id":"t1","type":"article-tile","data":{}},{"id":"t2","type":"article-tile","data":{}}]}'>
359
- <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
360
- <template type="article-tile" slot="config" label="Article"></template>
707
+ <zn-page-builder config=${config}>
708
+ <template type="row" slot="config" label="Row" container grow></template>
709
+ <template type="hero" slot="config" label="Hero"></template>
361
710
  </zn-page-builder>`);
362
711
  await el.updateComplete;
363
712
 
364
- const dropOnSlot = (sectionId: string, slotSelector: string) => {
365
- const dataTransfer = new DataTransfer();
366
- dataTransfer.setData('application/x-zn-page-section', sectionId);
367
- el.shadowRoot!.querySelector(slotSelector)!
368
- .dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
369
- };
713
+ (el as unknown as {_removeSection(id: string): void})._removeSection('c');
714
+ await el.updateComplete;
370
715
 
371
- dropOnSlot('t1', '.slot--empty'); // into first empty slot
716
+ // Removing "c" leaves the last row (cells 2 and 3) all-empty trimmed immediately,
717
+ // not only the next time the container is read out.
718
+ expect(el.state.sections[0].cells, 'trailing empty row trimmed on commit').to.have.lengthOf(2);
719
+ expect(el.state.sections[0].cells?.flat().map(c => c.id)).to.deep.equal(['a', 'b']);
720
+ });
721
+
722
+ it('leaves a fixed container trailing empty row untouched after a commit', async () => {
723
+ const config = JSON.stringify({
724
+ sections: [{
725
+ id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
726
+ cells: [
727
+ [{id: 'a', type: 'hero', data: {}}], [{id: 'b', type: 'hero', data: {}}],
728
+ [{id: 'c', type: 'hero', data: {}}], [],
729
+ ],
730
+ }],
731
+ });
732
+ const el = await fixture<ZnPageBuilder>(html`
733
+ <zn-page-builder config=${config}>
734
+ <template type="row" slot="config" label="Row" container></template>
735
+ <template type="hero" slot="config" label="Hero"></template>
736
+ </zn-page-builder>`);
372
737
  await el.updateComplete;
373
- expect(el.state.sections.map(s => s.id)).to.deep.equal(['grid', 't2']);
374
- expect(el.state.sections[0].children?.[0]?.id).to.equal('t1');
375
738
 
376
- dropOnSlot('t2', '.slots zn-page-section-card'); // onto the occupied slot — rejected
739
+ (el as unknown as {_removeSection(id: string): void})._removeSection('c');
377
740
  await el.updateComplete;
378
- expect(el.state.sections.map(s => s.id)).to.deep.equal(['grid', 't2']);
379
- expect(el.state.sections[0].children?.[0]?.id).to.equal('t1');
741
+
742
+ // A fixed container's trailing empty row is its chosen layout — it must survive.
743
+ expect(el.state.sections[0].cells, 'trailing empty row survives').to.have.lengthOf(4);
744
+ expect(el.state.sections[0].cells?.[3]).to.deep.equal([]);
380
745
  });
381
746
 
382
- it('should swap children across different containers', async () => {
747
+ it('clears selection when removing an ancestor of the selected grandchild', async () => {
748
+ const config = JSON.stringify({
749
+ sections: [{
750
+ id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
751
+ cells: [[{
752
+ id: 'inner', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
753
+ cells: [[{id: 'deep', type: 'hero', data: {}}], []],
754
+ }], []],
755
+ }],
756
+ });
383
757
  const el = await fixture<ZnPageBuilder>(html`
384
- <zn-page-builder
385
- config='{"sections":[{"id":"g1","type":"article-grid","data":{},"children":[{"id":"a1","type":"article-tile","data":{}}]},{"id":"g2","type":"article-grid","data":{},"children":[{"id":"b1","type":"article-tile","data":{}}]}]}'>
386
- <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
387
- <template type="article-tile" slot="config" label="Article"></template>
758
+ <zn-page-builder config=${config}>
759
+ <template type="row" slot="config" label="Row" container></template>
760
+ <template type="hero" slot="config" label="Hero"></template>
388
761
  </zn-page-builder>`);
389
762
  await el.updateComplete;
390
763
 
391
- // Drag a1 (in g1) onto b1's card (slot 0 of g2) — they trade containers.
392
- const dataTransfer = new DataTransfer();
393
- dataTransfer.setData('application/x-zn-page-section', 'a1');
394
- const g2Card = el.shadowRoot!.querySelectorAll('.slots zn-page-section-card')[1];
395
- g2Card.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
764
+ let lastSelection: string | null | undefined;
765
+ el.addEventListener('zn-page-selection-change', e =>
766
+ (lastSelection = (e as CustomEvent<{sectionId: string | null}>).detail.sectionId));
767
+
768
+ (el as unknown as {_select(id: string): void})._select('deep');
396
769
  await el.updateComplete;
770
+ expect(lastSelection).to.equal('deep');
397
771
 
398
- expect(el.state.sections[0].children?.[0]?.id).to.equal('b1');
399
- expect(el.state.sections[1].children?.[0]?.id).to.equal('a1');
772
+ // Removing the top-level ancestor must clear selection of a grandchild two cell levels down.
773
+ (el as unknown as {_removeSection(id: string): void})._removeSection('outer');
774
+ await el.updateComplete;
775
+
776
+ expect(el.state.sections).to.have.lengthOf(0);
777
+ expect(lastSelection, 'selection cleared at depth 2').to.be.null;
400
778
  });
401
779
 
402
- it('should undo a slot mutation and duplicate a child into the next empty slot', async () => {
780
+ it('duplicates a section living in a cell directly below itself in the same stack', async () => {
781
+ const config = JSON.stringify({
782
+ sections: [{
783
+ id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
784
+ cells: [[{id: 'a', type: 'hero', data: {}}], []],
785
+ }],
786
+ });
403
787
  const el = await fixture<ZnPageBuilder>(html`
404
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
405
- <template type="article-grid" slot="config" label="Article Grid" slots="3"></template>
406
- <template type="article-tile" slot="config" label="Article"></template>
788
+ <zn-page-builder config=${config}>
789
+ <template type="row" slot="config" label="Row" container></template>
790
+ <template type="hero" slot="config" label="Hero"></template>
407
791
  </zn-page-builder>`);
408
792
  await el.updateComplete;
409
793
 
410
- const child = el.addSectionToSlot('article-tile', 'grid', 0)!;
411
- el.undo();
412
- expect(el.state.sections[0].children?.[0] ?? null, 'undo empties the slot').to.be.null;
413
- el.redo();
414
- expect(el.state.sections[0].children?.[0]?.id).to.equal(child.id);
794
+ (el as unknown as {_duplicateSection(id: string): void})._duplicateSection('a');
795
+ await el.updateComplete;
796
+
797
+ const stack = el.state.sections[0].cells?.[0] ?? [];
798
+ expect(stack, 'copy lands right after the original in the same stack').to.have.lengthOf(2);
799
+ expect(stack[0].id).to.equal('a');
800
+ expect(stack[1].id, 'fresh id').to.not.equal('a');
801
+ expect(stack[1].type).to.equal('hero');
802
+ });
803
+
804
+ it('renders one column per width with the weights as fr units', async () => {
805
+ const el = await fixture<ZnPageBuilder>(html`
806
+ <zn-page-builder config=${containerConfig()}>
807
+ <template type="row" slot="config" label="Row" container></template>
808
+ <template type="hero" slot="config" label="Hero"></template>
809
+ </zn-page-builder>`);
810
+ await el.updateComplete;
811
+
812
+ const grid = el.shadowRoot?.querySelector<HTMLElement>('.cells');
813
+ expect(grid, 'cell grid').to.exist;
814
+ expect(grid!.style.gridTemplateColumns).to.equal('1fr 2fr 1fr');
815
+ expect(el.shadowRoot?.querySelectorAll('.cell')).to.have.lengthOf(3);
816
+ });
415
817
 
416
- // Duplicate the child: the copy takes the next empty slot with a fresh id.
818
+ it('inserts a palette drop into a cell stack', async () => {
819
+ const el = await fixture<ZnPageBuilder>(html`
820
+ <zn-page-builder config=${containerConfig()}>
821
+ <template type="row" slot="config" label="Row" container></template>
822
+ <template type="hero" slot="config" label="Hero"></template>
823
+ </zn-page-builder>`);
417
824
  await el.updateComplete;
418
- el.shadowRoot?.querySelector('.slots zn-page-section-card')
419
- ?.dispatchEvent(new CustomEvent('page-card-duplicate'));
825
+
826
+ const cell = el.shadowRoot!.querySelectorAll('.cell')[1]!;
827
+ dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
420
828
  await el.updateComplete;
421
- const children = el.state.sections[0].children ?? [];
422
- expect(children[1]?.type).to.equal('article-tile');
423
- expect(children[1]?.id).to.not.equal(children[0]?.id);
829
+
830
+ expect(el.state.sections[0].cells?.[1].map(c => c.type)).to.deep.equal(['hero']);
424
831
  });
425
832
 
426
- it('should select a card with Enter and delete a child with Delete via keyboard', async () => {
833
+ it('stacks a second section into the same cell', async () => {
427
834
  const el = await fixture<ZnPageBuilder>(html`
428
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{}}]}]}'>
429
- <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
430
- <template type="article-tile" slot="config" label="Article"></template>
835
+ <zn-page-builder config=${containerConfig()}>
836
+ <template type="row" slot="config" label="Row" container></template>
837
+ <template type="hero" slot="config" label="Hero"></template>
431
838
  </zn-page-builder>`);
432
839
  await el.updateComplete;
433
840
 
434
- const childCard = el.shadowRoot!.querySelector('.slots zn-page-section-card')!;
435
- childCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
841
+ const cell = el.shadowRoot!.querySelectorAll('.cell')[0]!;
842
+ dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
436
843
  await el.updateComplete;
437
- expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'Enter selects').to.exist;
438
844
 
439
- childCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Delete', bubbles: true, cancelable: true}));
845
+ expect(el.state.sections[0].cells?.[0]).to.have.lengthOf(2);
846
+ });
847
+
848
+ it('accepts a container into a cell at level 1 and refuses one at level 2', async () => {
849
+ const nestedConfig = JSON.stringify({
850
+ sections: [{
851
+ id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
852
+ cells: [[{id: 'inner', type: 'row', data: {}, layout: {widths: [1, 1], grow: false}, cells: [[], []]}], []],
853
+ }],
854
+ });
855
+ const el = await fixture<ZnPageBuilder>(html`
856
+ <zn-page-builder config=${nestedConfig}>
857
+ <template type="row" slot="config" label="Row" container></template>
858
+ <template type="hero" slot="config" label="Hero"></template>
859
+ </zn-page-builder>`);
440
860
  await el.updateComplete;
441
- expect(el.state.sections[0].children?.[0] ?? null, 'Delete removes the child').to.be.null;
861
+
862
+ const outerCell = el.shadowRoot!.querySelector('.cell')!;
863
+ const accepted = dragTo(outerCell, PAGE_TYPE_MIME, 'row', 'dragover');
864
+ expect(accepted.defaultPrevented, 'level 2 accepted').to.be.true;
865
+
866
+ // The inner "row" is itself a container, so its own cells render nested inside
867
+ // the outer cell — a `.cell` inside a `.cell` is deterministically the inner one.
868
+ const innerCell = el.shadowRoot?.querySelectorAll('.cell .cell')[0];
869
+ expect(innerCell, 'nested cell found').to.exist;
870
+ const refused = dragTo(innerCell!, PAGE_TYPE_MIME, 'row', 'dragover');
871
+ expect(refused.defaultPrevented, 'level 3 refused').to.be.false;
442
872
  });
443
873
 
444
- it('should accept a palette drop anywhere on the empty canvas', async () => {
874
+ it('does not let accepts override the nesting depth cap', async () => {
875
+ const nestedConfig = JSON.stringify({
876
+ sections: [{
877
+ id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
878
+ cells: [[{id: 'inner', type: 'row', data: {}, layout: {widths: [1, 1], grow: false}, cells: [[], []]}], []],
879
+ }],
880
+ });
445
881
  const el = await fixture<ZnPageBuilder>(html`
446
- <zn-page-builder>
882
+ <zn-page-builder config=${nestedConfig}>
883
+ <template type="row" slot="config" label="Row" container accepts="row"></template>
884
+ </zn-page-builder>`);
885
+ await el.updateComplete;
886
+
887
+ // The inner "row" is itself a container at level 2; "row" also being in its own
888
+ // `accepts` list must not let a level-3 drop through.
889
+ const innerCell = el.shadowRoot?.querySelectorAll('.cell .cell')[0];
890
+ expect(innerCell, 'nested cell found').to.exist;
891
+ dragTo(innerCell!, PAGE_TYPE_MIME, 'row', 'drop');
892
+ await el.updateComplete;
893
+
894
+ const inner = el.state.sections[0].cells![0][0];
895
+ expect(inner.cells?.[0], 'level 3 drop refused despite accepts listing its own type').to.deep.equal([]);
896
+ });
897
+
898
+ // Both caps above drive only PAGE_TYPE_MIME, where a freshly created section always
899
+ // has height 1 — a moved section can already carry nested containers of its own,
900
+ // which is exactly what this exercises via PAGE_SECTION_MIME.
901
+ it('refuses moving an already-placed container whose own nesting would push past the cap', async () => {
902
+ const config = JSON.stringify({
903
+ sections: [
904
+ {
905
+ id: 'A', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
906
+ cells: [[{
907
+ id: 'B', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
908
+ cells: [[{id: 'article', type: 'hero', data: {}}], []],
909
+ }], []],
910
+ },
911
+ {id: 'C', type: 'row', data: {}, layout: {widths: [1, 1], grow: false}, cells: [[], []]},
912
+ ],
913
+ });
914
+ const el = await fixture<ZnPageBuilder>(html`
915
+ <zn-page-builder config=${config}>
916
+ <template type="row" slot="config" label="Row" container></template>
447
917
  <template type="hero" slot="config" label="Hero"></template>
448
918
  </zn-page-builder>`);
449
919
  await el.updateComplete;
450
920
 
451
- const canvas = el.shadowRoot!.querySelector('.canvas')!;
452
- const dataTransfer = new DataTransfer();
453
- dataTransfer.setData('application/x-zn-page-type', 'hero');
921
+ // Document order for a top-level container "A" (nesting container "B" in its
922
+ // first cell) followed by sibling top-level container "C": [A0, B0, B1, A1, C0, C1].
923
+ const cCell0 = el.shadowRoot!.querySelectorAll('.cell')[4];
924
+ dragTo(cCell0, PAGE_SECTION_MIME, 'A', 'drop');
925
+ await el.updateComplete;
454
926
 
455
- const over = new DragEvent('dragover', {dataTransfer, bubbles: true, cancelable: true});
456
- canvas.dispatchEvent(over);
457
- expect(over.defaultPrevented, 'canvas accepts the drag').to.be.true;
927
+ expect(el.state.sections.map(s => s.id), 'A stays top-level, C stays untouched').to.deep.equal(['A', 'C']);
928
+ expect(el.state.sections[1].cells).to.deep.equal([[], []]);
929
+ });
458
930
 
459
- canvas.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
931
+ it('still moves an already-placed flat container into another container cell', async () => {
932
+ const config = JSON.stringify({
933
+ sections: [
934
+ {id: 'flat', type: 'row', data: {}, layout: {widths: [1], grow: false}, cells: [[]]},
935
+ {id: 'target', type: 'row', data: {}, layout: {widths: [1, 1], grow: false}, cells: [[], []]},
936
+ ],
937
+ });
938
+ const el = await fixture<ZnPageBuilder>(html`
939
+ <zn-page-builder config=${config}>
940
+ <template type="row" slot="config" label="Row" container></template>
941
+ </zn-page-builder>`);
460
942
  await el.updateComplete;
461
- expect(el.state.sections).to.have.length(1);
462
- expect(el.state.sections[0].type).to.equal('hero');
943
+
944
+ // Document order: [flat0, target0, target1].
945
+ const targetCell0 = el.shadowRoot!.querySelectorAll('.cell')[1];
946
+ dragTo(targetCell0, PAGE_SECTION_MIME, 'flat', 'drop');
947
+ await el.updateComplete;
948
+
949
+ expect(el.state.sections.map(s => s.id), 'flat is no longer top-level').to.deep.equal(['target']);
950
+ expect(el.state.sections[0].cells?.[0].map(c => c.id)).to.deep.equal(['flat']);
951
+ });
952
+
953
+ it('refuses a container type into a legacy slots= container with no explicit accepts', async () => {
954
+ const config = JSON.stringify({
955
+ sections: [{id: 'g1', type: 'grid', data: {}, cells: [[], [], []]}],
956
+ });
957
+ const el = await fixture<ZnPageBuilder>(html`
958
+ <zn-page-builder config=${config}>
959
+ <template type="grid" slot="config" label="Grid" slots="3"></template>
960
+ <template type="row" slot="config" label="Row" container></template>
961
+ <template type="hero" slot="config" label="Hero"></template>
962
+ </zn-page-builder>`);
963
+ await el.updateComplete;
964
+
965
+ const cell = el.shadowRoot!.querySelectorAll('.cell')[0]!;
966
+ dragTo(cell, PAGE_TYPE_MIME, 'row', 'drop');
967
+ await el.updateComplete;
968
+ expect(el.state.sections[0].cells?.[0], 'container refused by legacy slots alias').to.deep.equal([]);
969
+
970
+ dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
971
+ await el.updateComplete;
972
+ expect(el.state.sections[0].cells?.[0].map(c => c.type), 'non-container still accepted').to.deep.equal(['hero']);
973
+ });
974
+
975
+ it('keeps enforcing accepts', async () => {
976
+ const el = await fixture<ZnPageBuilder>(html`
977
+ <zn-page-builder config=${containerConfig()}>
978
+ <template type="row" slot="config" label="Row" container accepts="tile"></template>
979
+ <template type="hero" slot="config" label="Hero"></template>
980
+ <template type="tile" slot="config" label="Tile"></template>
981
+ </zn-page-builder>`);
982
+ await el.updateComplete;
983
+
984
+ const cell = el.shadowRoot!.querySelectorAll('.cell')[1]!;
985
+ dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
986
+ await el.updateComplete;
987
+ expect(el.state.sections[0].cells?.[1], 'hero refused').to.deep.equal([]);
988
+
989
+ dragTo(cell, PAGE_TYPE_MIME, 'tile', 'drop');
990
+ await el.updateComplete;
991
+ expect(el.state.sections[0].cells?.[1].map(c => c.type)).to.deep.equal(['tile']);
992
+ });
993
+
994
+ it('offers a trailing row for a growable container and extends on drop', async () => {
995
+ const growConfig = JSON.stringify({
996
+ sections: [{
997
+ id: 'outer', type: 'row', data: {},
998
+ layout: {widths: [1, 1], grow: true},
999
+ cells: [[{id: 'a', type: 'hero', data: {}}], []],
1000
+ }],
1001
+ });
1002
+ const el = await fixture<ZnPageBuilder>(html`
1003
+ <zn-page-builder config=${growConfig}>
1004
+ <template type="row" slot="config" label="Row" container grow></template>
1005
+ <template type="hero" slot="config" label="Hero"></template>
1006
+ </zn-page-builder>`);
1007
+ await el.updateComplete;
1008
+
1009
+ expect(el.state.sections[0].cells, 'no trailing empty row persisted').to.have.lengthOf(2);
1010
+ const cells = el.shadowRoot?.querySelectorAll('.cell');
1011
+ expect(cells!.length, 'a trailing row is rendered').to.equal(4);
1012
+
1013
+ dragTo(cells![2], PAGE_TYPE_MIME, 'hero', 'drop');
1014
+ await el.updateComplete;
1015
+ expect(el.state.sections[0].cells).to.have.lengthOf(4);
1016
+ });
1017
+
1018
+ it('shows layout controls only for containers', async () => {
1019
+ const el = await fixture<ZnPageBuilder>(html`
1020
+ <zn-page-builder config=${containerConfig()}>
1021
+ <template type="row" slot="config" label="Row" container></template>
1022
+ <template type="hero" slot="config" label="Hero"></template>
1023
+ </zn-page-builder>`);
1024
+ await el.updateComplete;
1025
+ await selectFirst(el);
1026
+ expect(el.shadowRoot?.querySelector('.layout-group'), 'container shows layout').to.exist;
1027
+
1028
+ const plain = await fixture<ZnPageBuilder>(html`
1029
+ <zn-page-builder config='{"sections":[{"id":"h","type":"hero","data":{}}]}'>
1030
+ <template type="hero" slot="config" label="Hero"></template>
1031
+ </zn-page-builder>`);
1032
+ await plain.updateComplete;
1033
+ await selectFirst(plain);
1034
+ expect(plain.shadowRoot?.querySelector('.layout-group'), 'non-container has none').to.not.exist;
1035
+ });
1036
+
1037
+ it('re-chunks losslessly when the editor narrows the columns', async () => {
1038
+ // Trailing empty cells on the pre-edit row are load-bearing for this test: with
1039
+ // grow false, the downstream normaliseCells pass does not trim, so only
1040
+ // recolumnCells's own trim-then-pad determines the final length. A passthrough
1041
+ // that skips recolumnCells would carry all 5 stacks through and land on 6 cells
1042
+ // after padding to 2 columns, not 4.
1043
+ const config = JSON.stringify({
1044
+ sections: [{
1045
+ id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1, 1, 1, 1], grow: false},
1046
+ cells: [
1047
+ [{id: 'a', type: 'hero', data: {}}],
1048
+ [{id: 'b', type: 'hero', data: {}}],
1049
+ [{id: 'c', type: 'hero', data: {}}],
1050
+ [],
1051
+ [],
1052
+ ],
1053
+ }],
1054
+ });
1055
+ const el = await fixture<ZnPageBuilder>(html`
1056
+ <zn-page-builder config=${config}>
1057
+ <template type="row" slot="config" label="Row" container></template>
1058
+ <template type="hero" slot="config" label="Hero"></template>
1059
+ </zn-page-builder>`);
1060
+ await el.updateComplete;
1061
+
1062
+ (el as unknown as {_setColumns(id: string, n: number): void})._setColumns('outer', 2);
1063
+ await el.updateComplete;
1064
+
1065
+ const row = el.state.sections[0];
1066
+ expect(row.layout?.widths).to.have.lengthOf(2);
1067
+ expect(row.cells?.flat().map(c => c.id), 'nothing lost or reordered').to.deep.equal(['a', 'b', 'c']);
1068
+ expect(row.cells, 'trailing empties trimmed before re-chunking, not carried through').to.have.lengthOf(4);
1069
+ });
1070
+
1071
+ it('sets a per-column width', async () => {
1072
+ const el = await fixture<ZnPageBuilder>(html`
1073
+ <zn-page-builder config=${containerConfig()}>
1074
+ <template type="row" slot="config" label="Row" container></template>
1075
+ <template type="hero" slot="config" label="Hero"></template>
1076
+ </zn-page-builder>`);
1077
+ await el.updateComplete;
1078
+
1079
+ (el as unknown as {_setWidth(id: string, col: number, w: number): void})._setWidth('outer', 1, 3);
1080
+ await el.updateComplete;
1081
+ expect(el.state.sections[0].layout?.widths).to.deep.equal([1, 3, 1]);
1082
+ });
1083
+
1084
+ it('clamps a row reduction at the last occupied row', async () => {
1085
+ const config = JSON.stringify({
1086
+ sections: [{
1087
+ id: 'outer', type: 'row', data: {}, layout: {widths: [2], grow: false},
1088
+ cells: [[{id: 'a', type: 'hero', data: {}}], [{id: 'b', type: 'hero', data: {}}], []],
1089
+ }],
1090
+ });
1091
+ const el = await fixture<ZnPageBuilder>(html`
1092
+ <zn-page-builder config=${config}>
1093
+ <template type="row" slot="config" label="Row" container></template>
1094
+ <template type="hero" slot="config" label="Hero"></template>
1095
+ </zn-page-builder>`);
1096
+ await el.updateComplete;
1097
+
1098
+ const api = el as unknown as {_setRows(id: string, n: number): void};
1099
+ api._setRows('outer', 3);
1100
+ await el.updateComplete;
1101
+ expect(el.state.sections[0].cells).to.have.lengthOf(3);
1102
+
1103
+ api._setRows('outer', 1);
1104
+ await el.updateComplete;
1105
+ expect(el.state.sections[0].cells?.flat().map(c => c.id), 'content survives').to.deep.equal(['a', 'b']);
1106
+ });
1107
+
1108
+ it('undoes a layout change', async () => {
1109
+ const el = await fixture<ZnPageBuilder>(html`
1110
+ <zn-page-builder config=${containerConfig()}>
1111
+ <template type="row" slot="config" label="Row" container></template>
1112
+ <template type="hero" slot="config" label="Hero"></template>
1113
+ </zn-page-builder>`);
1114
+ await el.updateComplete;
1115
+
1116
+ (el as unknown as {_setColumns(id: string, n: number): void})._setColumns('outer', 2);
1117
+ await el.updateComplete;
1118
+ expect(el.state.sections[0].layout?.widths).to.have.lengthOf(2);
1119
+
1120
+ el.undo();
1121
+ await el.updateComplete;
1122
+ expect(el.state.sections[0].layout?.widths).to.deep.equal([1, 2, 1]);
1123
+ });
1124
+
1125
+ it('flips grow from the layout group toggle', async () => {
1126
+ const el = await fixture<ZnPageBuilder>(html`
1127
+ <zn-page-builder config=${containerConfig()}>
1128
+ <template type="row" slot="config" label="Row" container></template>
1129
+ <template type="hero" slot="config" label="Hero"></template>
1130
+ </zn-page-builder>`);
1131
+ await el.updateComplete;
1132
+ await selectFirst(el);
1133
+
1134
+ const toggle = el.shadowRoot?.querySelector('.layout-group zn-toggle') as HTMLElement & { checked: boolean };
1135
+ expect(toggle, 'grow toggle').to.exist;
1136
+ toggle.checked = true;
1137
+ // zn-toggle only ever emits zn-input (never zn-change) on interaction — this is the
1138
+ // exact event the layout group's listener must be bound to.
1139
+ toggle.dispatchEvent(new CustomEvent('zn-input', {bubbles: true}));
1140
+ await el.updateComplete;
1141
+
1142
+ expect(el.state.sections[0].layout?.grow, 'toggle flips grow in state').to.be.true;
463
1143
  });
464
1144
  });