@bespokeagentics/microdots-host 0.1.2 → 0.1.3
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 +2 -2
- package/src/anchorMount.test.ts +124 -0
- package/src/anchorMount.ts +153 -0
- package/src/fillComposition.ts +1 -1
- package/src/index.ts +45 -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/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 +48 -7
- package/src/wireEngine.test.ts +1 -1
|
@@ -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
|
+
}
|
package/src/rules.test.ts
CHANGED
|
@@ -139,6 +139,7 @@ describe('resolvePlacements', () => {
|
|
|
139
139
|
values: {},
|
|
140
140
|
span: Option.none(),
|
|
141
141
|
order: Option.none(),
|
|
142
|
+
selector: Option.none(),
|
|
142
143
|
state: 'active',
|
|
143
144
|
overriddenBy: [],
|
|
144
145
|
},
|
|
@@ -595,4 +596,88 @@ describe('resolvePlacements', () => {
|
|
|
595
596
|
resolvePlacements(topology, '/home', 'dev').map(item => item.state),
|
|
596
597
|
).toEqual(['active', 'active'])
|
|
597
598
|
})
|
|
599
|
+
|
|
600
|
+
// The failure mode: the contest keys on `(tag, slotId)`, and every anchor
|
|
601
|
+
// shares the one sentinel slotId — without the selector qualifier, a route
|
|
602
|
+
// anchoring a tag at one selector would silently override a rule anchoring
|
|
603
|
+
// the same tag somewhere else on the page entirely.
|
|
604
|
+
describe('anchors', () => {
|
|
605
|
+
test('the selector surfaces on the resolved placement', () => {
|
|
606
|
+
const topology = topologyOf([
|
|
607
|
+
route('/home', [
|
|
608
|
+
{
|
|
609
|
+
id: 'a1',
|
|
610
|
+
tag: 'promo-banner',
|
|
611
|
+
slotId: '@anchor',
|
|
612
|
+
selector: '#hero .cta-row',
|
|
613
|
+
},
|
|
614
|
+
]),
|
|
615
|
+
])
|
|
616
|
+
expect(
|
|
617
|
+
resolvePlacements(topology, '/home', 'dev').map(item => item.selector),
|
|
618
|
+
).toEqual([Option.some('#hero .cta-row')])
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
test('same tag at the SAME selector contests across layers', () => {
|
|
622
|
+
const topology = topologyOf(
|
|
623
|
+
[
|
|
624
|
+
route('/home', [
|
|
625
|
+
{
|
|
626
|
+
id: 'winner',
|
|
627
|
+
tag: 'promo-banner',
|
|
628
|
+
slotId: '@anchor',
|
|
629
|
+
selector: '#hero',
|
|
630
|
+
},
|
|
631
|
+
]),
|
|
632
|
+
],
|
|
633
|
+
[
|
|
634
|
+
patternRule('r1', '/*', [
|
|
635
|
+
{
|
|
636
|
+
id: 'loser',
|
|
637
|
+
tag: 'promo-banner',
|
|
638
|
+
slotId: '@anchor',
|
|
639
|
+
selector: '#hero',
|
|
640
|
+
},
|
|
641
|
+
]),
|
|
642
|
+
],
|
|
643
|
+
)
|
|
644
|
+
expect(
|
|
645
|
+
resolvePlacements(topology, '/home', 'dev').map(item => ({
|
|
646
|
+
id: item.id,
|
|
647
|
+
state: item.state,
|
|
648
|
+
})),
|
|
649
|
+
).toEqual([
|
|
650
|
+
{ id: 'loser', state: 'overridden' },
|
|
651
|
+
{ id: 'winner', state: 'active' },
|
|
652
|
+
])
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
test('same tag at DIFFERENT selectors never contests — different places', () => {
|
|
656
|
+
const topology = topologyOf(
|
|
657
|
+
[
|
|
658
|
+
route('/home', [
|
|
659
|
+
{
|
|
660
|
+
id: 'route-anchor',
|
|
661
|
+
tag: 'promo-banner',
|
|
662
|
+
slotId: '@anchor',
|
|
663
|
+
selector: '#hero',
|
|
664
|
+
},
|
|
665
|
+
]),
|
|
666
|
+
],
|
|
667
|
+
[
|
|
668
|
+
patternRule('r1', '/*', [
|
|
669
|
+
{
|
|
670
|
+
id: 'rule-anchor',
|
|
671
|
+
tag: 'promo-banner',
|
|
672
|
+
slotId: '@anchor',
|
|
673
|
+
selector: 'footer',
|
|
674
|
+
},
|
|
675
|
+
]),
|
|
676
|
+
],
|
|
677
|
+
)
|
|
678
|
+
expect(
|
|
679
|
+
resolvePlacements(topology, '/home', 'dev').map(item => item.state),
|
|
680
|
+
).toEqual(['active', 'active'])
|
|
681
|
+
})
|
|
682
|
+
})
|
|
598
683
|
})
|
package/src/rules.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
TopologyPlacement,
|
|
9
9
|
WireEnv,
|
|
10
10
|
} from './wire.ts'
|
|
11
|
+
import { ANCHOR_SLOT_ID } from './wire.ts'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Rules, THE matcher, and the reading-B resolution — Phase 5 work item 3 of
|
|
@@ -228,6 +229,9 @@ export type ResolvedPlacement = {
|
|
|
228
229
|
readonly values: Readonly<Record<string, string>>
|
|
229
230
|
readonly span: Option.Option<PlacementSpan>
|
|
230
231
|
readonly order: Option.Option<number>
|
|
232
|
+
/** The CSS selector of an anchor placement (`slotId === ANCHOR_SLOT_ID`).
|
|
233
|
+
* `None` for every slot-placed record. */
|
|
234
|
+
readonly selector: Option.Option<string>
|
|
231
235
|
readonly state: ResolvedState
|
|
232
236
|
readonly overriddenBy: ReadonlyArray<PlacementOverride>
|
|
233
237
|
}
|
|
@@ -413,6 +417,10 @@ export const resolvePlacements = (
|
|
|
413
417
|
other.layer > item.layer &&
|
|
414
418
|
other.placement.tag === item.placement.tag &&
|
|
415
419
|
other.placement.slotId === item.placement.slotId &&
|
|
420
|
+
// Two anchors of one tag at DIFFERENT selectors are different places —
|
|
421
|
+
// they never contest. Slot-placed records are unaffected.
|
|
422
|
+
(item.placement.slotId !== ANCHOR_SLOT_ID ||
|
|
423
|
+
other.placement.selector === item.placement.selector) &&
|
|
416
424
|
conditionsOverlap(other.condition, item.condition)
|
|
417
425
|
? Option.some({
|
|
418
426
|
by: other.source,
|
|
@@ -441,6 +449,7 @@ export const resolvePlacements = (
|
|
|
441
449
|
values: item.placement.values ?? {},
|
|
442
450
|
span: Option.fromNullishOr(item.placement.span),
|
|
443
451
|
order: Option.fromNullishOr(item.placement.order),
|
|
452
|
+
selector: Option.fromNullishOr(item.placement.selector),
|
|
444
453
|
state,
|
|
445
454
|
overriddenBy,
|
|
446
455
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
generatedSectionId,
|
|
5
|
+
layoutRows,
|
|
6
|
+
placementSpanStyle,
|
|
7
|
+
rowStyle,
|
|
8
|
+
sectionStyle,
|
|
9
|
+
slotStyle,
|
|
10
|
+
} from './slotLayout.ts'
|
|
11
|
+
import type { SlotSpec } from './slots.ts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The geometry module is the ONE place kind→CSS lives, and its numbers must
|
|
15
|
+
* mirror the Pages canvas (`microdots/pages/src/canvas/app.ts`): the 160px
|
|
16
|
+
* flex floor, the 9rem/4rem rail min-heights, the twelve-column grid, the
|
|
17
|
+
* span default of 12. A second set of numbers anywhere else is the drift
|
|
18
|
+
* risk R7 of the plan.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const slot = (overrides: Partial<SlotSpec> & Pick<SlotSpec, 'id'>): SlotSpec => ({
|
|
22
|
+
kind: 'band',
|
|
23
|
+
row: 0,
|
|
24
|
+
...overrides,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('layoutRows', () => {
|
|
28
|
+
test('groups by row ascending, declared order within a row', () => {
|
|
29
|
+
const slots = [
|
|
30
|
+
slot({ id: 'aside', kind: 'rail', row: 2 }),
|
|
31
|
+
slot({ id: 'header', kind: 'bar', row: 0 }),
|
|
32
|
+
slot({ id: 'sidebar', kind: 'rail', row: 2 }),
|
|
33
|
+
slot({ id: 'hero', kind: 'band', row: 1 }),
|
|
34
|
+
]
|
|
35
|
+
const rows = layoutRows(slots)
|
|
36
|
+
expect(rows.map(r => r.row)).toEqual([0, 1, 2])
|
|
37
|
+
// Declared order within row 2: aside first — it appeared first.
|
|
38
|
+
expect(rows[2]?.slots.map(s => s.id)).toEqual(['aside', 'sidebar'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('an empty manifest yields no rows', () => {
|
|
42
|
+
expect(layoutRows([])).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('non-contiguous row numbers still group — the renderer is positional', () => {
|
|
46
|
+
const rows = layoutRows([
|
|
47
|
+
slot({ id: 'a', row: 5 }),
|
|
48
|
+
slot({ id: 'b', row: 2 }),
|
|
49
|
+
])
|
|
50
|
+
expect(rows.map(r => r.row)).toEqual([2, 5])
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('generatedSectionId', () => {
|
|
55
|
+
test('prefixes the slot id', () => {
|
|
56
|
+
expect(generatedSectionId('hero')).toBe('section-hero')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('the style pair functions', () => {
|
|
61
|
+
test('rowStyle is a flex band that scrolls inside itself when over-wide', () => {
|
|
62
|
+
expect(rowStyle()).toEqual([
|
|
63
|
+
['display', 'flex'],
|
|
64
|
+
['align-items', 'stretch'],
|
|
65
|
+
['gap', '0.5rem'],
|
|
66
|
+
['min-width', '0'],
|
|
67
|
+
['overflow-x', 'auto'],
|
|
68
|
+
])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('a width-less slot flexes with the canvas 160px floor, viewport-capped', () => {
|
|
72
|
+
expect(sectionStyle(slot({ id: 'main', kind: 'grid' }))).toEqual([
|
|
73
|
+
['flex', '1 1 0%'],
|
|
74
|
+
['min-width', 'min(160px, 100%)'],
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('a declared width pins through the resize variable so attachSlotResize composes', () => {
|
|
79
|
+
expect(sectionStyle(slot({ id: 'sidebar', kind: 'rail', width: '150px' }))).toEqual([
|
|
80
|
+
['flex', 'none'],
|
|
81
|
+
['width', 'var(--slot-size-sidebar,150px)'],
|
|
82
|
+
])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('rails run taller; other kinds stack at the 4rem floor', () => {
|
|
86
|
+
expect(slotStyle(slot({ id: 'r', kind: 'rail' }))[0]).toEqual([
|
|
87
|
+
'min-height',
|
|
88
|
+
'9rem',
|
|
89
|
+
])
|
|
90
|
+
expect(slotStyle(slot({ id: 'b', kind: 'bar' }))[0]).toEqual([
|
|
91
|
+
'min-height',
|
|
92
|
+
'4rem',
|
|
93
|
+
])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('a grid slot lays twelve columns; other kinds stack vertically', () => {
|
|
97
|
+
const grid = slotStyle(slot({ id: 'main', kind: 'grid' }))
|
|
98
|
+
expect(grid).toContainEqual(['display', 'grid'])
|
|
99
|
+
expect(grid).toContainEqual([
|
|
100
|
+
'grid-template-columns',
|
|
101
|
+
'repeat(12, minmax(0, 1fr))',
|
|
102
|
+
])
|
|
103
|
+
const band = slotStyle(slot({ id: 'hero', kind: 'band' }))
|
|
104
|
+
expect(band).toContainEqual(['display', 'flex'])
|
|
105
|
+
expect(band).toContainEqual(['flex-direction', 'column'])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('placementSpanStyle defaults to the full twelve columns', () => {
|
|
109
|
+
expect(placementSpanStyle(4)).toEqual([['grid-column', 'span 4']])
|
|
110
|
+
expect(placementSpanStyle(undefined)).toEqual([['grid-column', 'span 12']])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('no function emits a media query anywhere', () => {
|
|
114
|
+
const all = [
|
|
115
|
+
...rowStyle(),
|
|
116
|
+
...sectionStyle(slot({ id: 'a' })),
|
|
117
|
+
...sectionStyle(slot({ id: 'b', width: '200px' })),
|
|
118
|
+
...slotStyle(slot({ id: 'c', kind: 'grid' })),
|
|
119
|
+
...slotStyle(slot({ id: 'd', kind: 'rail' })),
|
|
120
|
+
...placementSpanStyle(6),
|
|
121
|
+
]
|
|
122
|
+
all.forEach(([property, value]) => {
|
|
123
|
+
expect(property).not.toContain('@media')
|
|
124
|
+
expect(value).not.toContain('@media')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
})
|