@bespokeagentics/microdots-host 0.1.2 → 0.2.0

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.
@@ -0,0 +1,263 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ import { renderLayout } from './renderLayout.ts'
4
+ import { ensureSection, ensureSlot, pruneGeneratedContainers } from './slotDom.ts'
5
+ import { generatedSectionId } from './slotLayout.ts'
6
+ import type { SlotSpec } from './slots.ts'
7
+
8
+ /**
9
+ * The reconciler's contract, clause by clause — every one of these fails
10
+ * silently in the browser when broken: a slot div without an id renders
11
+ * nothing on a clean console; a rebuild instead of a reconcile restarts
12
+ * every mounted custom element on every poll; a prune of a non-empty slot
13
+ * throws away a user's mounted dot.
14
+ */
15
+
16
+ const slot = (
17
+ overrides: Partial<SlotSpec> & Pick<SlotSpec, 'id'>,
18
+ ): SlotSpec => ({
19
+ kind: 'band',
20
+ row: 0,
21
+ ...overrides,
22
+ })
23
+
24
+ const manifestOf = (slots: ReadonlyArray<SlotSpec>): { slots: ReadonlyArray<SlotSpec> } => ({
25
+ slots,
26
+ })
27
+
28
+ let root: HTMLElement
29
+
30
+ beforeEach(() => {
31
+ document.body.replaceChildren()
32
+ root = document.createElement('div')
33
+ root.id = 'generated-layout'
34
+ document.body.appendChild(root)
35
+ })
36
+
37
+ const MARKETING = [
38
+ slot({ id: 'site-header', kind: 'bar', row: 0, label: 'Header' }),
39
+ slot({ id: 'hero', kind: 'band', row: 1, label: 'Hero', description: 'The lead.' }),
40
+ slot({ id: 'sidebar', kind: 'rail', row: 2, width: '150px', label: 'Sidebar' }),
41
+ slot({ id: 'main', kind: 'grid', row: 2, label: 'Main' }),
42
+ slot({ id: 'aside', kind: 'rail', row: 2, width: '172px', label: 'Aside' }),
43
+ ]
44
+
45
+ describe('renderLayout — creation', () => {
46
+ test('every created slot div carries its declared id — the invariant', () => {
47
+ const report = renderLayout(manifestOf(MARKETING), root)
48
+ expect(report.created).toBe(5)
49
+ MARKETING.forEach(s => {
50
+ const div = document.getElementById(s.id)
51
+ expect(div).not.toBeNull()
52
+ expect(div?.getAttribute('data-microdots-layout')).toBe('slot')
53
+ expect(document.getElementById(generatedSectionId(s.id))).not.toBeNull()
54
+ })
55
+ })
56
+
57
+ test('rows group as declared: three row divs, the middle row holds rail/grid/rail', () => {
58
+ renderLayout(manifestOf(MARKETING), root)
59
+ const rows = Array.from(root.children).filter(
60
+ el => el.getAttribute('data-microdots-layout') === 'row',
61
+ )
62
+ expect(rows.length).toBe(3)
63
+ const middle = rows[2]
64
+ expect(
65
+ Array.from(middle?.children ?? []).map(el => el.id),
66
+ ).toEqual(['section-sidebar', 'section-main', 'section-aside'])
67
+ })
68
+
69
+ test('label and description render in section chrome, not in the slot div', () => {
70
+ renderLayout(manifestOf(MARKETING), root)
71
+ const heroSection = document.getElementById('section-hero')
72
+ expect(heroSection?.textContent).toContain('Hero')
73
+ expect(heroSection?.textContent).toContain('The lead.')
74
+ expect(document.getElementById('hero')?.childElementCount).toBe(0)
75
+ })
76
+
77
+ test('geometry lands as inline styles: fixed rail width via the resize var, grid columns', () => {
78
+ renderLayout(manifestOf(MARKETING), root)
79
+ const sidebarSection = document.getElementById('section-sidebar')
80
+ expect(sidebarSection?.getAttribute('style')).toContain(
81
+ 'var(--slot-size-sidebar,150px)',
82
+ )
83
+ const mainSlot = document.getElementById('main')
84
+ expect(mainSlot?.getAttribute('style')).toContain(
85
+ 'repeat(12, minmax(0, 1fr))',
86
+ )
87
+ })
88
+
89
+ test('a generated host with no manifest renders an empty layout (D6)', () => {
90
+ renderLayout(manifestOf(MARKETING), root)
91
+ const report = renderLayout(manifestOf([]), root)
92
+ expect(report.removed).toBe(5)
93
+ expect(root.querySelectorAll('section').length).toBe(0)
94
+ expect(root.children.length).toBe(0)
95
+ })
96
+ })
97
+
98
+ describe('renderLayout — idempotence and reconciliation', () => {
99
+ test('equal input keeps node identity and reports all zeros', () => {
100
+ renderLayout(manifestOf(MARKETING), root)
101
+ const heroBefore = document.getElementById('section-hero')
102
+ const slotBefore = document.getElementById('hero')
103
+ const report = renderLayout(manifestOf(MARKETING), root)
104
+ expect(report).toEqual({
105
+ created: 0,
106
+ relabelled: 0,
107
+ moved: 0,
108
+ removed: 0,
109
+ deferred: [],
110
+ })
111
+ expect(document.getElementById('section-hero')).toBe(heroBefore)
112
+ expect(document.getElementById('hero')).toBe(slotBefore)
113
+ })
114
+
115
+ test('a copy edit reconciles textContent in place — no remount of the mounted child', () => {
116
+ renderLayout(manifestOf(MARKETING), root)
117
+ const mounted = document.createElement('div')
118
+ mounted.textContent = 'a mounted dot'
119
+ document.getElementById('hero')?.appendChild(mounted)
120
+
121
+ const edited = MARKETING.map(s =>
122
+ s.id === 'hero' ? { ...s, label: 'Big hero', description: 'New copy.' } : s,
123
+ )
124
+ const report = renderLayout(manifestOf(edited), root)
125
+ expect(report.relabelled).toBe(1)
126
+ expect(report.moved).toBe(0)
127
+ expect(document.getElementById('section-hero')?.textContent).toContain(
128
+ 'Big hero',
129
+ )
130
+ // The mounted element is the SAME node — copy never remounts.
131
+ expect(document.getElementById('hero')?.firstElementChild).toBe(mounted)
132
+ })
133
+
134
+ test('a row move relocates only the affected section and reports it', () => {
135
+ renderLayout(manifestOf(MARKETING), root)
136
+ const heroSection = document.getElementById('section-hero')
137
+ const moved = MARKETING.map(s => (s.id === 'hero' ? { ...s, row: 2 } : s))
138
+ const report = renderLayout(manifestOf(moved), root)
139
+ expect(report.moved).toBe(1)
140
+ expect(report.created).toBe(0)
141
+ // Same node, new row div — the move is a reparent, not a rebuild.
142
+ expect(document.getElementById('section-hero')).toBe(heroSection)
143
+ const rows = Array.from(root.children).filter(
144
+ el => el.getAttribute('data-microdots-layout') === 'row',
145
+ )
146
+ expect(rows[1]?.querySelector('#section-hero')).not.toBeNull()
147
+ })
148
+
149
+ test('an in-row order swap moves exactly one section', () => {
150
+ renderLayout(manifestOf(MARKETING), root)
151
+ const swapped = [
152
+ MARKETING[0],
153
+ MARKETING[1],
154
+ MARKETING[3], // main before sidebar
155
+ MARKETING[2],
156
+ MARKETING[4],
157
+ ].flatMap(s => (s === undefined ? [] : [s]))
158
+ const report = renderLayout(manifestOf(swapped), root)
159
+ expect(report.moved).toBe(1)
160
+ const middle = Array.from(root.children).filter(
161
+ el => el.getAttribute('data-microdots-layout') === 'row',
162
+ )[2]
163
+ expect(Array.from(middle?.children ?? []).map(el => el.id)).toEqual([
164
+ 'section-main',
165
+ 'section-sidebar',
166
+ 'section-aside',
167
+ ])
168
+ })
169
+
170
+ test('a kind flip clears the stale grid properties from the slot div', () => {
171
+ renderLayout(manifestOf(MARKETING), root)
172
+ const flipped = MARKETING.map(s =>
173
+ s.id === 'main' ? { ...s, kind: 'band' as const } : s,
174
+ )
175
+ renderLayout(manifestOf(flipped), root)
176
+ const style = document.getElementById('main')?.getAttribute('style') ?? ''
177
+ expect(style).not.toContain('grid-template-columns')
178
+ expect(style).toContain('flex-direction: column')
179
+ })
180
+
181
+ test('a resize variable set on the section survives re-render', () => {
182
+ renderLayout(manifestOf(MARKETING), root)
183
+ const section = document.getElementById('section-sidebar')
184
+ section?.style.setProperty('--slot-size-sidebar', '220px')
185
+ renderLayout(manifestOf(MARKETING), root)
186
+ expect(
187
+ document
188
+ .getElementById('section-sidebar')
189
+ ?.style.getPropertyValue('--slot-size-sidebar'),
190
+ ).toBe('220px')
191
+ })
192
+ })
193
+
194
+ describe('renderLayout — markup wins', () => {
195
+ test('a slot id owned by non-layout markup is untouched and deferred, with a warning', () => {
196
+ const stranger = document.createElement('div')
197
+ stranger.id = 'hero'
198
+ stranger.textContent = 'hand-written'
199
+ document.body.appendChild(stranger)
200
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
201
+ const report = renderLayout(manifestOf(MARKETING), root)
202
+ expect(report.deferred).toContain('hero')
203
+ expect(report.created).toBe(4)
204
+ expect(document.getElementById('hero')).toBe(stranger)
205
+ expect(stranger.textContent).toBe('hand-written')
206
+ expect(warn).toHaveBeenCalled()
207
+ warn.mockRestore()
208
+ })
209
+
210
+ test('a section id owned by non-layout markup defers the same way', () => {
211
+ const stranger = document.createElement('section')
212
+ stranger.id = 'section-hero'
213
+ document.body.appendChild(stranger)
214
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
215
+ const report = renderLayout(manifestOf(MARKETING), root)
216
+ expect(report.deferred).toContain('hero')
217
+ expect(document.getElementById('section-hero')).toBe(stranger)
218
+ warn.mockRestore()
219
+ })
220
+ })
221
+
222
+ describe('renderLayout — pruning', () => {
223
+ test('a stale empty section is pruned; a non-empty one is deferred until unmounted', () => {
224
+ renderLayout(manifestOf(MARKETING), root)
225
+ const mounted = document.createElement('div')
226
+ document.getElementById('hero')?.appendChild(mounted)
227
+
228
+ const withoutHeroAndHeader = MARKETING.filter(
229
+ s => s.id !== 'hero' && s.id !== 'site-header',
230
+ )
231
+ const report = renderLayout(manifestOf(withoutHeroAndHeader), root)
232
+ // site-header (empty) pruned; hero (mounted) deferred.
233
+ expect(report.removed).toBe(1)
234
+ expect(report.deferred).toEqual(['hero'])
235
+ expect(document.getElementById('section-site-header')).toBeNull()
236
+ expect(document.getElementById('section-hero')).not.toBeNull()
237
+
238
+ // The mount loop unmounts, the next pass completes the prune.
239
+ mounted.remove()
240
+ const second = renderLayout(manifestOf(withoutHeroAndHeader), root)
241
+ expect(second.removed).toBe(1)
242
+ expect(document.getElementById('section-hero')).toBeNull()
243
+ })
244
+
245
+ test('pruneGeneratedContainers never deletes a declared-but-empty generated slot', () => {
246
+ renderLayout(manifestOf(MARKETING), root)
247
+ // The slotDom pruner looks for data-generated, which layout nodes never
248
+ // carry — the two generation paths are provably disjoint.
249
+ const removed = pruneGeneratedContainers(root)
250
+ expect(removed).toBe(0)
251
+ expect(document.getElementById('hero')).not.toBeNull()
252
+ })
253
+
254
+ test('slotDom-generated containers and layout sections coexist', () => {
255
+ renderLayout(manifestOf(MARKETING), root)
256
+ const section = ensureSection('legacy-section', root, 'x')
257
+ ensureSlot('legacy-slot', section, 'x')
258
+ const report = renderLayout(manifestOf(MARKETING), root)
259
+ // The reconciler leaves slotDom's containers alone entirely.
260
+ expect(report).toMatchObject({ created: 0, removed: 0 })
261
+ expect(document.getElementById('legacy-slot')).not.toBeNull()
262
+ })
263
+ })
@@ -0,0 +1,345 @@
1
+ /**
2
+ * The generated-layout DOM reconciler — a manifest rendered AS the page.
3
+ *
4
+ * `slotDom.ts` is the authored-host fallback: markup wins, generation only
5
+ * backfills. This module is the inversion for a host that opted in
6
+ * (`layoutModeOf(topology) === 'generated'`): the DATA wins, and the DOM
7
+ * under `generatedRoot` is reconciled to match it — created, relabelled,
8
+ * moved and pruned in place rather than rebuilt, because rebuilding remounts
9
+ * every custom element on every poll.
10
+ *
11
+ * The contract, clause by clause (each one a test in
12
+ * `renderLayout.test.ts`):
13
+ *
14
+ * - Every created slot div receives its declared `id` unconditionally — the
15
+ * repo's single most expensive failure is a mount container without one
16
+ * (see `wiki/patterns-and-traps/mount-container-needs-an-id.md`).
17
+ * - Idempotent: equal input keeps node identity and reports all zeros.
18
+ * - An id already owned by non-layout markup is never touched: the slot is
19
+ * `deferred` with a warning. Markup wins even in generated mode.
20
+ * - Label/description changes reconcile via `textContent` in place — copy
21
+ * edits never remount a mounted dot.
22
+ * - A section moves only when its row or in-row order actually differs; a
23
+ * structural move carries its mounted children with it, which restarts
24
+ * them (ruling D11 — accepted v1 semantics, minimized here).
25
+ * - A stale section is pruned only while its slot div is EMPTY; a non-empty
26
+ * one is `deferred` — the mount loop unmounts first, the next pass prunes.
27
+ * - Style application sets and removes only this module's own properties,
28
+ * never `--slot-size-*` (a resize handle's writes survive every pass).
29
+ *
30
+ * Rows are unkeyed positional chrome; sections are keyed by slot id. The
31
+ * marker attribute is distinct from `slotDom.ts`'s `data-generated` so
32
+ * `pruneGeneratedContainers` provably never deletes a declared-but-empty
33
+ * generated slot.
34
+ */
35
+
36
+ import type { SlotSpec } from './slots.ts'
37
+ import {
38
+ generatedSectionId,
39
+ layoutRows,
40
+ rowStyle,
41
+ sectionStyle,
42
+ slotStyle,
43
+ type StylePairs,
44
+ } from './slotLayout.ts'
45
+
46
+ /** What a generated node IS: `row` and `chrome` are unkeyed structure,
47
+ * `section` and `slot` are keyed by the slot's id. */
48
+ export const LAYOUT_ATTRIBUTE = 'data-microdots-layout'
49
+
50
+ export type RenderLayoutReport = {
51
+ readonly created: number
52
+ readonly relabelled: number
53
+ readonly moved: number
54
+ readonly removed: number
55
+ /** Slot ids left alone this pass: id collisions with non-layout markup,
56
+ * and stale sections still holding mounted elements. */
57
+ readonly deferred: ReadonlyArray<string>
58
+ }
59
+
60
+ /** Every property either style function may emit — the removal catalog, so a
61
+ * kind flip (grid → band) clears `grid-template-columns` instead of leaving
62
+ * it to fight the new `display`. Custom properties are deliberately outside
63
+ * the catalog: `--slot-size-*` belongs to `attachSlotResize`. */
64
+ const SECTION_STYLE_PROPERTIES = [
65
+ 'display',
66
+ 'flex-direction',
67
+ 'flex',
68
+ 'min-width',
69
+ 'width',
70
+ 'gap',
71
+ ] as const
72
+ const SLOT_STYLE_PROPERTIES = [
73
+ 'min-height',
74
+ 'display',
75
+ 'grid-template-columns',
76
+ 'gap',
77
+ 'flex-direction',
78
+ ] as const
79
+
80
+ const applyStyle = (
81
+ element: HTMLElement,
82
+ pairs: StylePairs,
83
+ catalog: ReadonlyArray<string>,
84
+ ): void => {
85
+ const declared = new Set(pairs.map(([property]) => property))
86
+ catalog.forEach(property => {
87
+ if (!declared.has(property)) element.style.removeProperty(property)
88
+ })
89
+ pairs.forEach(([property, value]) =>
90
+ element.style.setProperty(property, value),
91
+ )
92
+ }
93
+
94
+ const layoutRole = (node: Element): string | null =>
95
+ node.getAttribute(LAYOUT_ATTRIBUTE)
96
+
97
+ /** The section's inner arrangement: chrome above the slot, stacked. */
98
+ const sectionInnerStyle: StylePairs = [
99
+ ['display', 'flex'],
100
+ ['flex-direction', 'column'],
101
+ ['gap', '0.5rem'],
102
+ ]
103
+
104
+ const buildSection = (slot: SlotSpec): HTMLElement => {
105
+ const section = document.createElement('section')
106
+ section.id = generatedSectionId(slot.id)
107
+ section.setAttribute(LAYOUT_ATTRIBUTE, 'section')
108
+
109
+ const chrome = document.createElement('div')
110
+ chrome.setAttribute(LAYOUT_ATTRIBUTE, 'chrome')
111
+ const label = document.createElement('div')
112
+ label.setAttribute(LAYOUT_ATTRIBUTE, 'chrome')
113
+ label.style.setProperty('font-weight', '600')
114
+ label.textContent = slot.label ?? ''
115
+ const description = document.createElement('div')
116
+ description.setAttribute(LAYOUT_ATTRIBUTE, 'chrome')
117
+ description.style.setProperty('opacity', '0.7')
118
+ description.textContent = slot.description ?? ''
119
+ chrome.appendChild(label)
120
+ chrome.appendChild(description)
121
+
122
+ const slotDiv = document.createElement('div')
123
+ // Never omit, never rename, never defer to a caller: a mount container
124
+ // without an id is the silent failure this repo has paid for the most.
125
+ slotDiv.id = slot.id
126
+ slotDiv.setAttribute(LAYOUT_ATTRIBUTE, 'slot')
127
+
128
+ section.appendChild(chrome)
129
+ section.appendChild(slotDiv)
130
+ return section
131
+ }
132
+
133
+ const chromeTexts = (
134
+ section: HTMLElement,
135
+ ): { label: Element; description: Element } | null => {
136
+ const chrome = Array.from(section.children).find(
137
+ child => layoutRole(child) === 'chrome',
138
+ )
139
+ if (chrome === undefined) return null
140
+ const label = chrome.children[0]
141
+ const description = chrome.children[1]
142
+ if (label === undefined || description === undefined) return null
143
+ return { label, description }
144
+ }
145
+
146
+ const slotDivOf = (section: HTMLElement): HTMLElement | null => {
147
+ const found = Array.from(section.children).find(
148
+ child => layoutRole(child) === 'slot',
149
+ )
150
+ return found instanceof HTMLElement ? found : null
151
+ }
152
+
153
+ /**
154
+ * Reconcile the DOM under `generatedRoot` to the manifest. Only the theme's
155
+ * `slots` matter to geometry, so the parameter is exactly that slice — a
156
+ * generated host with NO manifest passes `{ slots: [] }` and gets the legal
157
+ * "opted in, nothing declared" empty layout (ruling D6).
158
+ */
159
+ export const renderLayout = (
160
+ manifest: { readonly slots: ReadonlyArray<SlotSpec> },
161
+ generatedRoot: HTMLElement,
162
+ ): RenderLayoutReport => {
163
+ const rows = layoutRows(manifest.slots)
164
+ let created = 0
165
+ let relabelled = 0
166
+ let moved = 0
167
+ let removed = 0
168
+ const deferred: string[] = []
169
+
170
+ // Row chrome is unkeyed, but reuse is by MAJORITY, not position: each
171
+ // declared row claims the existing row div already holding most of its
172
+ // sections, so a row merging away moves the one section that changed
173
+ // rather than reparenting (and remounting) every section below it.
174
+ const existingRowDivs = Array.from(generatedRoot.children).filter(
175
+ child => layoutRole(child) === 'row',
176
+ )
177
+ const claimed = new Set<Element>()
178
+ const claims = rows.map(row => {
179
+ const counts = new Map<Element, number>()
180
+ row.slots.forEach(rowSlot => {
181
+ const section = document.getElementById(generatedSectionId(rowSlot.id))
182
+ if (section === null || layoutRole(section) !== 'section') return
183
+ const parent = section.parentElement
184
+ if (
185
+ parent === null ||
186
+ layoutRole(parent) !== 'row' ||
187
+ parent.parentElement !== generatedRoot ||
188
+ claimed.has(parent)
189
+ )
190
+ return
191
+ counts.set(parent, (counts.get(parent) ?? 0) + 1)
192
+ })
193
+ const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]
194
+ if (best === undefined) return null
195
+ claimed.add(best[0])
196
+ return best[0]
197
+ })
198
+ const unclaimed = existingRowDivs.filter(div => !claimed.has(div))
199
+ const rowDivs = claims.map(claim => {
200
+ if (claim !== null) return claim
201
+ const reuse = unclaimed.shift()
202
+ if (reuse !== undefined) {
203
+ claimed.add(reuse)
204
+ return reuse
205
+ }
206
+ const rowDiv = document.createElement('div')
207
+ rowDiv.setAttribute(LAYOUT_ATTRIBUTE, 'row')
208
+ generatedRoot.appendChild(rowDiv)
209
+ return rowDiv
210
+ })
211
+ // Row divs must appear top-to-bottom in declared-row order; reorder with
212
+ // the same skip-the-strangers cursor the sections use below.
213
+ const rowDivSet = new Set(rowDivs)
214
+ let rowCursor: ChildNode | null = generatedRoot.firstChild
215
+ rowDivs.forEach(rowDiv => {
216
+ while (
217
+ rowCursor !== null &&
218
+ rowCursor !== rowDiv &&
219
+ !(rowCursor instanceof Element && rowDivSet.has(rowCursor))
220
+ ) {
221
+ rowCursor = rowCursor.nextSibling
222
+ }
223
+ if (rowCursor === rowDiv) {
224
+ rowCursor = rowCursor.nextSibling
225
+ } else {
226
+ generatedRoot.insertBefore(rowDiv, rowCursor)
227
+ }
228
+ })
229
+ rowDivs.forEach(rowDiv => {
230
+ if (rowDiv instanceof HTMLElement) applyStyle(rowDiv, rowStyle(), [])
231
+ })
232
+
233
+ const desiredSectionIds = new Set(
234
+ manifest.slots.map(slot => generatedSectionId(slot.id)),
235
+ )
236
+
237
+ rows.forEach((row, rowIndex) => {
238
+ const rowDiv = rowDivs[rowIndex]
239
+ if (!(rowDiv instanceof HTMLElement)) return
240
+
241
+ // Resolve or build each desired section, deferring on foreign ids.
242
+ const desired = row.slots.flatMap(slot => {
243
+ const sectionId = generatedSectionId(slot.id)
244
+ const existingSection = document.getElementById(sectionId)
245
+ if (existingSection !== null && layoutRole(existingSection) !== 'section') {
246
+ console.warn(
247
+ `[renderLayout] id "${sectionId}" is owned by non-layout markup — leaving it alone (markup wins)`,
248
+ )
249
+ deferred.push(slot.id)
250
+ return []
251
+ }
252
+ if (existingSection === null) {
253
+ const slotCollision = document.getElementById(slot.id)
254
+ if (slotCollision !== null && layoutRole(slotCollision) !== 'slot') {
255
+ console.warn(
256
+ `[renderLayout] slot id "${slot.id}" is owned by non-layout markup — leaving it alone (markup wins)`,
257
+ )
258
+ deferred.push(slot.id)
259
+ return []
260
+ }
261
+ const built = buildSection(slot)
262
+ created += 1
263
+ return [{ slot, section: built, isNew: true }]
264
+ }
265
+ // Copy reconciles in place — textContent only, never a rebuild.
266
+ const texts = chromeTexts(existingSection)
267
+ if (texts !== null) {
268
+ const nextLabel = slot.label ?? ''
269
+ const nextDescription = slot.description ?? ''
270
+ let changed = false
271
+ if (texts.label.textContent !== nextLabel) {
272
+ texts.label.textContent = nextLabel
273
+ changed = true
274
+ }
275
+ if (texts.description.textContent !== nextDescription) {
276
+ texts.description.textContent = nextDescription
277
+ changed = true
278
+ }
279
+ if (changed) relabelled += 1
280
+ }
281
+ return [{ slot, section: existingSection, isNew: false }]
282
+ })
283
+
284
+ const desiredSet = new Set<Element>(desired.map(entry => entry.section))
285
+
286
+ // Minimal-move ordering: advance a cursor past nodes that are staying
287
+ // put (deferred strangers, stale sections awaiting their prune); insert
288
+ // only the sections whose row or order actually differs.
289
+ let cursor: ChildNode | null = rowDiv.firstChild
290
+ desired.forEach(entry => {
291
+ while (
292
+ cursor !== null &&
293
+ cursor !== entry.section &&
294
+ !(cursor instanceof Element && desiredSet.has(cursor))
295
+ ) {
296
+ cursor = cursor.nextSibling
297
+ }
298
+ if (cursor === entry.section) {
299
+ cursor = cursor.nextSibling
300
+ } else {
301
+ rowDiv.insertBefore(entry.section, cursor)
302
+ if (!entry.isNew) moved += 1
303
+ }
304
+ if (entry.section instanceof HTMLElement) {
305
+ applyStyle(entry.section, [
306
+ ...sectionInnerStyle,
307
+ ...sectionStyle(entry.slot),
308
+ ], SECTION_STYLE_PROPERTIES)
309
+ const slotDiv = slotDivOf(entry.section)
310
+ if (slotDiv !== null) {
311
+ applyStyle(slotDiv, slotStyle(entry.slot), SLOT_STYLE_PROPERTIES)
312
+ }
313
+ }
314
+ })
315
+ })
316
+
317
+ // Prune stale sections — but ONLY once their slot div is empty. A section
318
+ // still holding a mounted element is deferred: the caller's mount loop
319
+ // unmounts on its own schedule, and the next pass completes the prune.
320
+ Array.from(
321
+ generatedRoot.querySelectorAll(`section[${LAYOUT_ATTRIBUTE}="section"]`),
322
+ ).forEach(section => {
323
+ if (desiredSectionIds.has(section.id)) return
324
+ if (!(section instanceof HTMLElement)) return
325
+ const slotDiv = slotDivOf(section)
326
+ if (slotDiv !== null && slotDiv.childElementCount > 0) {
327
+ deferred.push(slotDiv.id)
328
+ return
329
+ }
330
+ section.remove()
331
+ removed += 1
332
+ })
333
+
334
+ // Stale row chrome goes once it is empty; rows are structure, not
335
+ // content, so an unclaimed div holding a deferred section stays put.
336
+ Array.from(generatedRoot.children)
337
+ .filter(child => layoutRole(child) === 'row')
338
+ .forEach(rowDiv => {
339
+ if (!rowDivSet.has(rowDiv) && rowDiv.childElementCount === 0) {
340
+ rowDiv.remove()
341
+ }
342
+ })
343
+
344
+ return { created, relabelled, moved, removed, deferred }
345
+ }