@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.
- package/package.json +6 -6
- package/src/anchorMount.test.ts +124 -0
- package/src/anchorMount.ts +153 -0
- package/src/fillComposition.ts +1 -1
- package/src/index.ts +56 -3
- package/src/mountedRegistry.test.ts +1 -0
- package/src/mounting.test.ts +23 -0
- package/src/mounting.ts +10 -1
- package/src/placementChecks.test.ts +180 -4
- package/src/placementChecks.ts +132 -7
- package/src/registry.test.ts +289 -0
- package/src/registry.ts +158 -0
- package/src/registryFromManifest.test.ts +126 -0
- package/src/registryFromManifest.ts +92 -0
- package/src/renderLayout.test.ts +263 -0
- package/src/renderLayout.ts +345 -0
- package/src/rules.test.ts +85 -0
- package/src/rules.ts +9 -0
- package/src/slotLayout.test.ts +127 -0
- package/src/slotLayout.ts +113 -0
- package/src/slots.test.ts +37 -3
- package/src/slots.ts +10 -1
- package/src/wire.test.ts +80 -0
- package/src/wire.ts +60 -7
- package/src/wireEngine.test.ts +1 -1
|
@@ -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
|
+
}
|