@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.
@@ -9,14 +9,17 @@ import {
9
9
  } from './rules.ts'
10
10
  import type { HostSlotManifest } from './slots.ts'
11
11
  import type { WireEnv } from './wire.ts'
12
+ import { ANCHOR_SLOT_ID } from './wire.ts'
12
13
 
13
14
  /**
14
- * The placement CHECKS — six of the spec's seven, run as a pure lint engine
15
+ * The placement CHECKS — the spec's seven, run as a pure lint engine
15
16
  * over `resolvePlacements`' output. Phase 5 work item 4 of
16
17
  * `wiki/plans/shipped/microdots-platform-phase-5-pages-design.md`;
17
18
  * the seven checks' severities and sentences are the handoff spec's, lifted
18
- * verbatim where the fixture wrote them. Check 5 (anchor selector crawl) is
19
- * deferred with anchors.
19
+ * verbatim where the fixture wrote them. Check 5 (anchor selector resolution)
20
+ * has an id and a reporter but NO verifier: no crawl exists, so every rendered
21
+ * anchor is reported unverifiable — a check that cannot run is never silently
22
+ * green (the Pages write-mode plan's D5).
20
23
  *
21
24
  * Reading B's qualifier lands here exactly as
22
25
  * `question-does-the-override-contest-consider-condition.md` predicted: a
@@ -32,8 +35,11 @@ export type PlacementCheckId =
32
35
  | 'slot-not-in-theme'
33
36
  | 'required-attribute-unset'
34
37
  | 'mounts-twice-overlapping'
38
+ | 'anchor-selector-unresolved'
35
39
  | 'over-weight-budget'
36
40
  | 'placed-twice-disjoint'
41
+ | 'over-slot-capacity'
42
+ | 'span-in-non-grid'
37
43
 
38
44
  export type PlacementCheckSeverity = 'error' | 'warning' | 'note'
39
45
 
@@ -188,6 +194,9 @@ const checkSlotInTheme = (
188
194
  const theme = `${slotManifest.theme.name}@${slotManifest.theme.version}`
189
195
  const issues = Array.getSomes(
190
196
  Array.map(rendered, (placement): Option.Option<PlacementIssue> =>
197
+ // An anchor is deliberately OUTSIDE the theme — `@anchor` is the
198
+ // escape hatch, not a missing declaration. Check 5 owns anchors.
199
+ placement.slotId === ANCHOR_SLOT_ID ||
191
200
  Array.some(slotManifest.slots, slot => slot.id === placement.slotId)
192
201
  ? Option.none()
193
202
  : Option.some({
@@ -253,6 +262,39 @@ const checkRequiredAttributes = (
253
262
  return { issues, unverifiable }
254
263
  }
255
264
 
265
+ /* ============================================================
266
+ Check 5 — anchor selector resolution. UNVERIFIABLE, deliberately.
267
+
268
+ No crawl exists, so whether a selector matches anything in the host's
269
+ painted DOM cannot be checked from here. The honest report is one
270
+ unverifiable entry per distinct selector among the rendered anchors —
271
+ never a green row, never a stored guess (the write-mode plan's D5).
272
+ The verifier lands with the crawl, under this same check id.
273
+ ============================================================ */
274
+
275
+ const checkAnchors = (
276
+ rendered: ReadonlyArray<ResolvedPlacement>,
277
+ ): PlacementCheckReport => {
278
+ const selectors = Array.dedupe(
279
+ Array.getSomes(
280
+ Array.map(rendered, placement =>
281
+ placement.slotId === ANCHOR_SLOT_ID
282
+ ? placement.selector
283
+ : Option.none<string>(),
284
+ ),
285
+ ),
286
+ )
287
+ return {
288
+ issues: [],
289
+ unverifiable: selectors.map(
290
+ (selector): UnverifiableCheck => ({
291
+ check: 'anchor-selector-unresolved',
292
+ reason: `no crawl has run — whether "${selector}" matches anything in the host cannot be verified`,
293
+ }),
294
+ ),
295
+ }
296
+ }
297
+
256
298
  /* ============================================================
257
299
  Checks 4 and 7 — the same dot twice on the route. Warning / note.
258
300
 
@@ -396,15 +438,95 @@ const checkWeightBudget = (
396
438
  })
397
439
  }
398
440
 
441
+ /* ============================================================
442
+ Checks 8 and 9 — the generated-layout widening
443
+ (`wiki/plans/active/microdots-platform-pages-generated-layout.md` §E).
444
+ DIAGNOSTICS, not runtime enforcement: no renderer refuses the state
445
+ either check reports, and both claim only what a manifest can make
446
+ verifiable — with no manifest they are silent (the existing check-2
447
+ unverifiable entry already covers that state; a second entry would
448
+ repeat it).
449
+ ============================================================ */
450
+
451
+ /* Check 8 — over slot capacity. Warning.
452
+
453
+ Fires ONCE per overflowing slot, on the first placement past the declared
454
+ capacity in stacking order. The claim is DISAGREEMENT between the
455
+ declaration and the placements — deliberately not "will not render", so
456
+ the sentence stays true whether or not any renderer ever enforces
457
+ capacity (today none does; the generated host stacks everything). */
458
+
459
+ const checkSlotCapacity = (
460
+ rendered: ReadonlyArray<ResolvedPlacement>,
461
+ slotManifest: HostSlotManifest | undefined,
462
+ ): PlacementCheckReport => {
463
+ if (slotManifest === undefined) return { issues: [], unverifiable: [] }
464
+ const issues = Array.getSomes(
465
+ slotManifest.slots.map((slot): Option.Option<PlacementIssue> => {
466
+ if (slot.capacity === undefined) return Option.none()
467
+ const capacity = slot.capacity
468
+ const occupants = Array.sort(
469
+ Array.filter(rendered, placement => placement.slotId === slot.id),
470
+ Order.mapInput(Order.Number, (placement: ResolvedPlacement) =>
471
+ Option.getOrElse(placement.order, () => 0),
472
+ ),
473
+ )
474
+ if (occupants.length <= capacity) return Option.none()
475
+ const first = occupants[capacity]
476
+ if (first === undefined) return Option.none()
477
+ return Option.some({
478
+ check: 'over-slot-capacity',
479
+ severity: 'warning',
480
+ placementId: first.id,
481
+ consequence: `Slot ${slot.id} declares capacity ${String(capacity)} and ${String(occupants.length)} placements resolve into it. The declaration and the placements disagree; nothing enforces capacity at runtime, so all ${String(occupants.length)} render.`,
482
+ action: 'Move a placement to another slot, or raise the capacity',
483
+ })
484
+ }),
485
+ )
486
+ return { issues, unverifiable: [] }
487
+ }
488
+
489
+ /* Check 9 — a span on a placement in a non-grid slot. Note.
490
+
491
+ The legal residue of a kind edit: spans go dormant on grid→band and
492
+ return if the slot becomes a grid again, so this is stated as dormancy,
493
+ never as an error demanding cleanup. */
494
+
495
+ const checkSpanInNonGrid = (
496
+ rendered: ReadonlyArray<ResolvedPlacement>,
497
+ slotManifest: HostSlotManifest | undefined,
498
+ ): PlacementCheckReport => {
499
+ if (slotManifest === undefined) return { issues: [], unverifiable: [] }
500
+ const kinds = new Map(
501
+ slotManifest.slots.map(slot => [slot.id, slot.kind] as const),
502
+ )
503
+ const issues = Array.getSomes(
504
+ Array.map(rendered, (placement): Option.Option<PlacementIssue> => {
505
+ if (Option.isNone(placement.span)) return Option.none()
506
+ const kind = kinds.get(placement.slotId)
507
+ if (kind === undefined || kind === 'grid') return Option.none()
508
+ return Option.some({
509
+ check: 'span-in-non-grid',
510
+ severity: 'note',
511
+ placementId: placement.id,
512
+ consequence: `A ${String(placement.span.value)}/12 span is set but ${placement.slotId} is a ${kind} slot, so the span is dormant. It returns the moment the slot becomes a grid again.`,
513
+ action: 'No action needed',
514
+ })
515
+ }),
516
+ )
517
+ return { issues, unverifiable: [] }
518
+ }
519
+
399
520
  /* ============================================================
400
521
  The engine.
401
522
  ============================================================ */
402
523
 
403
524
  /**
404
- * Runs checks 1, 2, 3, 4, 6 and 7 over a resolution's output. Pure: same
405
- * inputs, same report. Placements overridden ENTIRELY are excluded before
406
- * any check runs — they render for nobody — while partially-overridden ones
407
- * are checked like any other, the reading-B qualifier.
525
+ * Runs all seven checks over a resolution's output check 5 as its
526
+ * unverifiable reporter only. Pure: same inputs, same report. Placements
527
+ * overridden ENTIRELY are excluded before any check runs — they render for
528
+ * nobody — while partially-overridden ones are checked like any other, the
529
+ * reading-B qualifier.
408
530
  */
409
531
  export const checkPlacements = (
410
532
  resolved: ReadonlyArray<ResolvedPlacement>,
@@ -419,7 +541,10 @@ export const checkPlacements = (
419
541
  checkSlotInTheme(rendered, context.slotManifest),
420
542
  checkRequiredAttributes(rendered, context.manifests),
421
543
  checkTwins(rendered),
544
+ checkAnchors(rendered),
422
545
  checkWeightBudget(rendered, context),
546
+ checkSlotCapacity(rendered, context.slotManifest),
547
+ checkSpanInNonGrid(rendered, context.slotManifest),
423
548
  ]
424
549
  return {
425
550
  issues: Array.flatMap(reports, report => report.issues),
@@ -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
+ })