@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.
@@ -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
+ })