@bespokeagentics/microdots-host 0.1.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bespokeagentics/microdots-host",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "MicroDots host shell — registry, loader, slot manifest and placement checks for mounting MicroDot custom elements. Raw TypeScript source; consume with Bun or Vite.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -18,8 +18,8 @@
18
18
  "./composition": "./src/compositionSpec.ts"
19
19
  },
20
20
  "dependencies": {
21
- "@bespokeagentics/microdots-authoring": "workspace:*",
22
- "@bespokeagentics/microdots-element": "workspace:*"
21
+ "@bespokeagentics/microdots-authoring": "0.1.1",
22
+ "@bespokeagentics/microdots-element": "0.1.2"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "effect": "4.0.0-rc.108"
@@ -0,0 +1,124 @@
1
+ import { beforeEach, describe, expect, test } from 'vitest'
2
+
3
+ import { mountAtAnchor } from './anchorMount.ts'
4
+
5
+ /**
6
+ * Anchors mount into host-owned DOM. The properties that fail silently when
7
+ * broken, each pinned here:
8
+ *
9
+ * 1. The container carries an id — Foldkit defects without one and
10
+ * `Runtime.embed` swallows the defect (blank, clean console).
11
+ * 2. The anchor's own children are never touched — the anchor belongs to the
12
+ * host, and `replaceChildren` on it is the loader's stated never.
13
+ * 3. "Not yet" resolves once the anchor appears; "not there" returns a
14
+ * `timed-out` OUTCOME, never a throw and never silence.
15
+ *
16
+ * `bundleUrl` points at an already-defined tag in every case: `loadMicroDot`
17
+ * short-circuits when `customElements.get(tag)` exists, so no import happens
18
+ * and the tests stay hermetic.
19
+ */
20
+
21
+ const TAG = 'anchor-test-dot'
22
+
23
+ // Define the tag once — loadMicroDot then never fetches the bundle.
24
+ if (customElements.get(TAG) === undefined) {
25
+ customElements.define(TAG, class extends HTMLElement {})
26
+ }
27
+
28
+ beforeEach(() => {
29
+ document.body.innerHTML = ''
30
+ })
31
+
32
+ describe('mountAtAnchor', () => {
33
+ test('mounts adjacent to an existing anchor, container id and all', async () => {
34
+ document.body.innerHTML =
35
+ '<section id="hero"><span class="kept">host copy</span></section>'
36
+ const outcome = await mountAtAnchor({
37
+ selector: '#hero',
38
+ tag: TAG,
39
+ bundleUrl: '/unused.js',
40
+ apiUrl: 'http://localhost:9999',
41
+ attributes: { symbol: 'FOLD' },
42
+ })
43
+ expect(outcome._tag).toBe('resolved')
44
+ if (outcome._tag !== 'resolved') return
45
+ // Adjacent (default 'after'): the container is the anchor's next sibling,
46
+ // and the anchor's own children are untouched.
47
+ expect(document.querySelector('#hero + div')).toBe(outcome.container)
48
+ expect(document.querySelector('#hero .kept')?.textContent).toBe('host copy')
49
+ expect(outcome.container.id).not.toBe('')
50
+ expect(outcome.element.tagName.toLowerCase()).toBe(TAG)
51
+ expect(outcome.element.getAttribute('api-url')).toBe(
52
+ 'http://localhost:9999',
53
+ )
54
+ expect(outcome.element.getAttribute('symbol')).toBe('FOLD')
55
+ })
56
+
57
+ test("'inside' appends — the anchor's existing children stay", async () => {
58
+ document.body.innerHTML = '<div id="panel"><p id="host-p">host</p></div>'
59
+ const outcome = await mountAtAnchor({
60
+ selector: '#panel',
61
+ tag: TAG,
62
+ bundleUrl: '/unused.js',
63
+ apiUrl: 'http://localhost:9999',
64
+ position: 'inside',
65
+ })
66
+ expect(outcome._tag).toBe('resolved')
67
+ if (outcome._tag !== 'resolved') return
68
+ expect(outcome.container.parentElement?.id).toBe('panel')
69
+ expect(document.querySelector('#host-p')?.textContent).toBe('host')
70
+ })
71
+
72
+ test('a LATE anchor mounts when it appears — "not yet" is not "not there"', async () => {
73
+ const pending = mountAtAnchor({
74
+ selector: '#late',
75
+ tag: TAG,
76
+ bundleUrl: '/unused.js',
77
+ apiUrl: 'http://localhost:9999',
78
+ timeoutMs: 2_000,
79
+ })
80
+ // The anchor appears after the mount was requested — the embedding-demo
81
+ // shape, whose whole page is written by script after load.
82
+ const late = document.createElement('div')
83
+ late.id = 'late'
84
+ document.body.appendChild(late)
85
+ const outcome = await pending
86
+ expect(outcome._tag).toBe('resolved')
87
+ if (outcome._tag !== 'resolved') return
88
+ expect(document.querySelector('#late + div')).toBe(outcome.container)
89
+ })
90
+
91
+ test('no match by the timeout returns timed-out and mounts nothing', async () => {
92
+ const outcome = await mountAtAnchor({
93
+ selector: '#never',
94
+ tag: TAG,
95
+ bundleUrl: '/unused.js',
96
+ apiUrl: 'http://localhost:9999',
97
+ timeoutMs: 50,
98
+ })
99
+ expect(outcome._tag).toBe('timed-out')
100
+ if (outcome._tag !== 'timed-out') return
101
+ expect(outcome.selector).toBe('#never')
102
+ expect(document.querySelector(TAG)).toBeNull()
103
+ })
104
+
105
+ test('two mounts of one tag get distinct container ids', async () => {
106
+ document.body.innerHTML = '<div id="a"></div><div id="b"></div>'
107
+ const first = await mountAtAnchor({
108
+ selector: '#a',
109
+ tag: TAG,
110
+ bundleUrl: '/unused.js',
111
+ apiUrl: 'http://localhost:9999',
112
+ })
113
+ const second = await mountAtAnchor({
114
+ selector: '#b',
115
+ tag: TAG,
116
+ bundleUrl: '/unused.js',
117
+ apiUrl: 'http://localhost:9999',
118
+ })
119
+ expect(first._tag).toBe('resolved')
120
+ expect(second._tag).toBe('resolved')
121
+ if (first._tag !== 'resolved' || second._tag !== 'resolved') return
122
+ expect(first.container.id).not.toBe(second.container.id)
123
+ })
124
+ })
@@ -0,0 +1,153 @@
1
+ import { loadMicroDot, createMicroDot } from './loader.ts'
2
+
3
+ /**
4
+ * Mounting a MicroDot at a DOM ANCHOR — a CSS selector into host-owned markup
5
+ * — instead of a declared slot. The Pages write-mode plan's D4; the design
6
+ * record is `wiki/framework/mounting/anchor-mounting-into-host-owned-dom.md`.
7
+ *
8
+ * The rules that shape this module, each with its reason:
9
+ *
10
+ * - **Resolve after paint.** The anchor is somebody else's element, typically
11
+ * built by the host's own script (the embedding-demo writes its whole page
12
+ * via `outerHTML` in `startHost()`), so `querySelector` before
13
+ * `DOMContentLoaded` proves nothing.
14
+ * - **"Not yet" is not "not there".** A missing anchor gets a
15
+ * `MutationObserver` until it appears or the timeout runs out. The two
16
+ * outcomes are distinguishable in the return value.
17
+ * - **A timeout is a RETURNED outcome plus a console line, never a throw and
18
+ * never silence.** A swallowed rejection here is this repo's signature
19
+ * blank-with-clean-console.
20
+ * - **Own container, with an id.** Foldkit keys HMR preservation off the
21
+ * container id and defects silently without one (`mountPoint.ts`), and the
22
+ * anchor itself must never be handed to `Runtime.embed`.
23
+ * - **Adjacent, never inside by default — and never `replaceChildren`.** The
24
+ * anchor is host-owned; `loader.ts:80-83` states the rule for slots and it
25
+ * binds twice as hard for an element the host may re-render.
26
+ */
27
+
28
+ export type AnchorMountOutcome =
29
+ | {
30
+ readonly _tag: 'resolved'
31
+ readonly element: HTMLElement
32
+ readonly container: HTMLElement
33
+ }
34
+ | {
35
+ readonly _tag: 'timed-out'
36
+ readonly selector: string
37
+ readonly waitedMs: number
38
+ }
39
+
40
+ export type AnchorMountOptions = {
41
+ readonly selector: string
42
+ readonly tag: string
43
+ readonly bundleUrl: string
44
+ readonly apiUrl: string
45
+ readonly attributes?: Readonly<Record<string, string>>
46
+ /** Where the container lands relative to the anchor. Default `'after'` —
47
+ * adjacent placement never disturbs the anchor's own children. */
48
+ readonly position?: 'before' | 'after' | 'inside'
49
+ /** How long to wait for a late anchor before giving up. Default 10s. */
50
+ readonly timeoutMs?: number
51
+ }
52
+
53
+ const DEFAULT_TIMEOUT_MS = 10_000
54
+
55
+ /** Ids handed out so far, per tag — a counter, never a live-DOM count, so an
56
+ * unmounted instance's id is never reused (the `mountPoint.ts` precedent). */
57
+ const handedOut = new Map<string, number>()
58
+
59
+ const anchorContainerId = (tag: string): string => {
60
+ const next = handedOut.get(tag) ?? 0
61
+ handedOut.set(tag, next + 1)
62
+ return `microdots-anchor-${tag}-${String(next)}`
63
+ }
64
+
65
+ const afterDomReady = (): Promise<void> =>
66
+ document.readyState === 'loading'
67
+ ? new Promise(resolve => {
68
+ document.addEventListener('DOMContentLoaded', () => resolve(), {
69
+ once: true,
70
+ })
71
+ })
72
+ : Promise.resolve()
73
+
74
+ /** The anchor now, or the anchor as soon as a mutation produces one, or
75
+ * `null` at the timeout. */
76
+ const awaitAnchor = (
77
+ selector: string,
78
+ timeoutMs: number,
79
+ ): Promise<Element | null> => {
80
+ const immediate = document.querySelector(selector)
81
+ if (immediate !== null) return Promise.resolve(immediate)
82
+ return new Promise(resolve => {
83
+ const observer = new MutationObserver(() => {
84
+ const found = document.querySelector(selector)
85
+ if (found !== null) {
86
+ clearTimeout(timer)
87
+ observer.disconnect()
88
+ resolve(found)
89
+ }
90
+ })
91
+ const timer = setTimeout(() => {
92
+ observer.disconnect()
93
+ resolve(null)
94
+ }, timeoutMs)
95
+ observer.observe(document.body, { childList: true, subtree: true })
96
+ })
97
+ }
98
+
99
+ const placeContainer = (
100
+ anchor: Element,
101
+ container: HTMLElement,
102
+ position: 'before' | 'after' | 'inside',
103
+ ): void => {
104
+ if (position === 'inside') {
105
+ // Inside is APPEND — the anchor's existing children are host-owned and
106
+ // stay exactly where they are. Never `replaceChildren` here.
107
+ anchor.appendChild(container)
108
+ return
109
+ }
110
+ anchor.insertAdjacentElement(
111
+ position === 'before' ? 'beforebegin' : 'afterend',
112
+ container,
113
+ )
114
+ }
115
+
116
+ /**
117
+ * Resolves the selector (waiting for a late anchor), loads the bundle in
118
+ * parallel with the wait, then mounts the configured element inside its own
119
+ * id-carrying container adjacent to (or inside) the anchor.
120
+ */
121
+ export const mountAtAnchor = async (
122
+ options: AnchorMountOptions,
123
+ ): Promise<AnchorMountOutcome> => {
124
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
125
+ const position = options.position ?? 'after'
126
+ const startedAt = performance.now()
127
+
128
+ // The bundle fetch does not depend on the anchor existing — start it now so
129
+ // a late anchor mounts fast once it appears.
130
+ const loading = loadMicroDot(options.bundleUrl, options.tag)
131
+
132
+ await afterDomReady()
133
+ const anchor = await awaitAnchor(options.selector, timeoutMs)
134
+ if (anchor === null) {
135
+ const waitedMs = Math.round(performance.now() - startedAt)
136
+ console.warn(
137
+ `mountAtAnchor: no element matched "${options.selector}" within ${String(waitedMs)}ms — <${options.tag}> not mounted`,
138
+ )
139
+ return { _tag: 'timed-out', selector: options.selector, waitedMs }
140
+ }
141
+
142
+ await loading
143
+ const element = createMicroDot(
144
+ options.tag,
145
+ options.apiUrl,
146
+ options.attributes ?? {},
147
+ )
148
+ const container = document.createElement('div')
149
+ container.id = anchorContainerId(options.tag)
150
+ container.appendChild(element)
151
+ placeContainer(anchor, container, position)
152
+ return { _tag: 'resolved', element, container }
153
+ }
@@ -83,7 +83,7 @@ export type FillCompositionFailure =
83
83
  }
84
84
 
85
85
  export class FillCompositionError extends Error {
86
- readonly name = 'FillCompositionError'
86
+ override readonly name = 'FillCompositionError'
87
87
  /**
88
88
  * Declared and assigned, NOT a `constructor(readonly failure: …)` parameter
89
89
  * property. A parameter property is TS syntax that needs code GENERATED for
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ export {
58
58
  */
59
59
  export {
60
60
  type WireState,
61
+ ANCHOR_SLOT_ID,
61
62
  deriveWireState,
62
63
  HostTopology,
63
64
  PlacementCondition,
@@ -67,6 +68,7 @@ export {
67
68
  TopologyOverview,
68
69
  TopologyPlacement,
69
70
  TopologyRoute,
71
+ layoutModeOf,
70
72
  topologyRouteTable,
71
73
  Wire,
72
74
  WireEnv,
@@ -216,6 +218,45 @@ export {
216
218
  pruneGeneratedContainers,
217
219
  } from './slotDom.ts'
218
220
 
221
+ /**
222
+ * Generated layout — the inversion of the rule above, for a host that
223
+ * OPTED IN (`layoutModeOf(topology) === 'generated'`): the data wins and
224
+ * `renderLayout` reconciles the page to it; `slotDom.ts` stays the
225
+ * authored-host fallback. `slotLayout.ts` is the one place kind→geometry
226
+ * lives — the values mirror the Pages canvas so both renderings of a
227
+ * manifest agree structurally. See
228
+ * `wiki/plans/active/microdots-platform-pages-generated-layout.md`.
229
+ */
230
+ export {
231
+ type LayoutRow,
232
+ type StylePairs,
233
+ generatedSectionId,
234
+ layoutRows,
235
+ placementSpanStyle,
236
+ rowStyle,
237
+ sectionStyle,
238
+ slotStyle,
239
+ } from './slotLayout.ts'
240
+
241
+ export {
242
+ type RenderLayoutReport,
243
+ LAYOUT_ATTRIBUTE,
244
+ renderLayout,
245
+ } from './renderLayout.ts'
246
+
247
+ /**
248
+ * Mounting at a DOM anchor — a CSS selector into host-owned markup instead of
249
+ * a declared slot. Resolve-after-paint, "not yet" observed via mutations,
250
+ * timeout as a returned outcome, own id-carrying container, adjacent by
251
+ * default and never `replaceChildren`. See
252
+ * `wiki/framework/mounting/anchor-mounting-into-host-owned-dom.md`.
253
+ */
254
+ export {
255
+ type AnchorMountOptions,
256
+ type AnchorMountOutcome,
257
+ mountAtAnchor,
258
+ } from './anchorMount.ts'
259
+
219
260
  /**
220
261
  * Drag-resize for slots that declare a `resize` axis — the remaining half of
221
262
  * the slot-manifest generalisation in
@@ -235,9 +276,10 @@ export {
235
276
  } from './slotResize.ts'
236
277
 
237
278
  /**
238
- * The placement checks engine — Phase 5 work item 4: six of the spec's seven
239
- * checks as a pure lint over the resolution's output (check 5 defers with
240
- * anchors). Unverifiable checks are reported, never silently skipped.
279
+ * The placement checks engine — Phase 5 work item 4: the spec's seven checks
280
+ * as a pure lint over the resolution's output (check 5 as its unverifiable
281
+ * reporter only — no crawl exists). Unverifiable checks are reported, never
282
+ * silently skipped.
241
283
  */
242
284
  export {
243
285
  type PlacementCheckContext,
@@ -32,6 +32,7 @@ const placement = (tag: string): ResolvedPlacement => ({
32
32
  values: {},
33
33
  span: Option.none(),
34
34
  order: Option.none(),
35
+ selector: Option.none(),
35
36
  state: 'active',
36
37
  overriddenBy: [],
37
38
  })
@@ -182,6 +182,29 @@ describe('pendingMounts', () => {
182
182
  'winner',
183
183
  ])
184
184
  })
185
+
186
+ test('drops an anchor placement — the slot loop must never ensureSlot("@anchor")', () => {
187
+ const topology = topologyOf([
188
+ route('/home', [
189
+ { id: 'slotted', tag: 'promo-banner', slotId: 'hero-slot' },
190
+ {
191
+ id: 'anchored',
192
+ tag: 'price-ticker',
193
+ slotId: '@anchor',
194
+ selector: '#hero .cta-row',
195
+ },
196
+ ]),
197
+ ])
198
+ const resolved = resolvePlacements(topology, '/home', 'dev')
199
+ // The anchor is IN the resolution — screens render it — but not in the
200
+ // pending mounts: a shell's slot loop feeding `@anchor` into `ensureSlot`
201
+ // would fabricate a `<div id="@anchor">`. Anchors mount only through
202
+ // `mountAtAnchor`, in a host that opts in.
203
+ expect(resolved.map(item => item.id)).toEqual(['slotted', 'anchored'])
204
+ expect(pendingMounts(resolved, new Set()).map(item => item.id)).toEqual([
205
+ 'slotted',
206
+ ])
207
+ })
185
208
  })
186
209
 
187
210
  // The failure mode this function exists for, verbatim from the D1 extraction:
package/src/mounting.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Array } from 'effect'
2
2
 
3
3
  import type { ResolvedPlacement } from './rules.ts'
4
+ import { ANCHOR_SLOT_ID } from './wire.ts'
4
5
 
5
6
  /**
6
7
  * What a shell does with a resolution's output on the way to the DOM — the
@@ -39,6 +40,12 @@ import type { ResolvedPlacement } from './rules.ts'
39
40
  * matching the order the shells append in.
40
41
  *
41
42
  * Fully overridden placements are dropped first: they render for nobody.
43
+ *
44
+ * ANCHOR placements are dropped too: the slot loop's `ensureSlot` would
45
+ * otherwise fabricate a `<div id="@anchor">` — a lie. Anchors mount through
46
+ * `mountAtAnchor` in a host that opts in (the embedding-demo precedent);
47
+ * neither first-party shell does, and a shell that wants to must call it
48
+ * itself rather than have the slot loop guess.
42
49
  */
43
50
  export const pendingMounts = (
44
51
  resolved: ReadonlyArray<ResolvedPlacement>,
@@ -48,7 +55,9 @@ export const pendingMounts = (
48
55
  Array.filter(
49
56
  resolved,
50
57
  placement =>
51
- placement.state !== 'overridden' && !mountedTags.has(placement.tag),
58
+ placement.state !== 'overridden' &&
59
+ placement.slotId !== ANCHOR_SLOT_ID &&
60
+ !mountedTags.has(placement.tag),
52
61
  ),
53
62
  (a, b) => a.tag === b.tag,
54
63
  )
@@ -37,6 +37,7 @@ const placed = (
37
37
  values: {},
38
38
  span: Option.none(),
39
39
  order: Option.none(),
40
+ selector: Option.none(),
40
41
  state: 'active',
41
42
  overriddenBy: [],
42
43
  ...overrides,
@@ -207,6 +208,77 @@ describe('checkPlacements — check 2: slot not in the theme', () => {
207
208
  },
208
209
  ])
209
210
  })
211
+
212
+ test('does not fire for an anchor — @anchor is the escape hatch, not a missing declaration', () => {
213
+ const report = checkPlacements(
214
+ [
215
+ placed('p1', 'promo-banner', '@anchor', {
216
+ selector: Option.some('#hero .cta-row'),
217
+ }),
218
+ ],
219
+ contextOf([manifestOf('promo', ['promo-banner'])]),
220
+ )
221
+ expect(issuesFor(report, 'slot-not-in-theme')).toEqual([])
222
+ })
223
+ })
224
+
225
+ // The failure mode: no crawl exists, so an anchor's resolution CANNOT be
226
+ // checked — and a screen that says nothing about it reads as green. Check 5
227
+ // ships as a reporter only: one unverifiable entry per distinct selector,
228
+ // never an issue, never a stored guess (the write-mode plan's D5).
229
+ describe('checkPlacements — check 5: anchor selectors are unverifiable', () => {
230
+ test('reports one unverifiable entry per distinct rendered selector', () => {
231
+ const report = checkPlacements(
232
+ [
233
+ placed('p1', 'promo-banner', '@anchor', {
234
+ selector: Option.some('#hero'),
235
+ }),
236
+ placed('p2', 'contact-form', '@anchor', {
237
+ selector: Option.some('#hero'),
238
+ }),
239
+ placed('p3', 'price-ticker', '@anchor', {
240
+ selector: Option.some('footer'),
241
+ }),
242
+ ],
243
+ contextOf([
244
+ manifestOf('promo', ['promo-banner']),
245
+ manifestOf('contact', ['contact-form']),
246
+ manifestOf('price', ['price-ticker']),
247
+ ]),
248
+ )
249
+ expect(
250
+ report.unverifiable.filter(
251
+ entry => entry.check === 'anchor-selector-unresolved',
252
+ ),
253
+ ).toEqual([
254
+ {
255
+ check: 'anchor-selector-unresolved',
256
+ reason: expect.stringContaining('"#hero"'),
257
+ },
258
+ {
259
+ check: 'anchor-selector-unresolved',
260
+ reason: expect.stringContaining('"footer"'),
261
+ },
262
+ ])
263
+ expect(issuesFor(report, 'anchor-selector-unresolved')).toEqual([])
264
+ })
265
+
266
+ test('an entirely overridden anchor reports nothing — it renders for nobody', () => {
267
+ const report = checkPlacements(
268
+ [
269
+ placed('p1', 'promo-banner', '@anchor', {
270
+ selector: Option.some('#hero'),
271
+ state: 'overridden',
272
+ }),
273
+ ],
274
+ contextOf([manifestOf('promo', ['promo-banner'])]),
275
+ )
276
+ expect(
277
+ report.unverifiable.filter(
278
+ entry => entry.check === 'anchor-selector-unresolved',
279
+ ),
280
+ ).toEqual([])
281
+ })
210
282
  })
211
283
 
212
284
  // The failure mode: a manifest-required attribute nobody sets means the
@@ -367,12 +439,12 @@ describe('checkPlacements — check 6: over the page-weight budget', () => {
367
439
  // kill.
368
440
  const report = checkPlacements(
369
441
  [
370
- placed('p1', 'workbench-app-list', 'hero-slot'),
442
+ placed('p1', 'workbench-board', 'hero-slot'),
371
443
  placed('p2', 'workbench-brief', 'side-slot'),
372
444
  placed('p3', 'wiring-canvas', 'side-slot'),
373
445
  ],
374
446
  contextOf([
375
- manifestOf('workbench', ['workbench-app-list', 'workbench-brief'], {
447
+ manifestOf('workbench', ['workbench-board', 'workbench-brief'], {
376
448
  gzipBytes: 100 * KB,
377
449
  }),
378
450
  manifestOf('wiring', ['wiring-canvas'], { gzipBytes: 70 * KB }),
@@ -384,12 +456,12 @@ describe('checkPlacements — check 6: over the page-weight budget', () => {
384
456
  test('warns once over budget, naming the total and the largest bundle', () => {
385
457
  const report = checkPlacements(
386
458
  [
387
- placed('p1', 'workbench-app-list', 'hero-slot'),
459
+ placed('p1', 'workbench-board', 'hero-slot'),
388
460
  placed('p2', 'workbench-brief', 'side-slot'),
389
461
  placed('p3', 'wiring-canvas', 'side-slot'),
390
462
  ],
391
463
  contextOf([
392
- manifestOf('workbench', ['workbench-app-list', 'workbench-brief'], {
464
+ manifestOf('workbench', ['workbench-board', 'workbench-brief'], {
393
465
  gzipBytes: 100 * KB,
394
466
  }),
395
467
  manifestOf('wiring', ['wiring-canvas'], { gzipBytes: 90 * KB }),
@@ -591,3 +663,107 @@ describe('checkPlacements — the reading-B override qualifier', () => {
591
663
  ).toEqual(['loser'])
592
664
  })
593
665
  })
666
+
667
+ describe('checkPlacements — check 8: over slot capacity', () => {
668
+ // Diagnostics, not enforcement: nothing at runtime refuses the third
669
+ // placement in a capacity-2 slot — the generated host stacks it — so the
670
+ // claim is DISAGREEMENT between the declaration and the placements, which
671
+ // stays true regardless of renderer behavior.
672
+ const capacityManifest: HostSlotManifest = {
673
+ theme: { name: '@bespokeagentics/microdots-theme', version: '0.1.1' },
674
+ slots: [
675
+ { id: 'hero-slot', kind: 'band', row: 0, capacity: 2 },
676
+ { id: 'main-slot', kind: 'grid', row: 1 },
677
+ ],
678
+ }
679
+
680
+ test('warns once per overflowing slot, on the first placement past capacity in stacking order', () => {
681
+ const report = checkPlacements(
682
+ [
683
+ placed('p1', 'a-view', 'hero-slot', { order: Option.some(1) }),
684
+ placed('p2', 'b-view', 'hero-slot', { order: Option.some(2) }),
685
+ placed('p3', 'c-view', 'hero-slot', { order: Option.some(3) }),
686
+ placed('p4', 'd-view', 'hero-slot', { order: Option.some(4) }),
687
+ ],
688
+ contextOf([], { slotManifest: capacityManifest }),
689
+ )
690
+ const issues = issuesFor(report, 'over-slot-capacity')
691
+ expect(issues.length).toBe(1)
692
+ expect(issues[0]?.placementId).toBe('p3')
693
+ expect(issues[0]?.severity).toBe('warning')
694
+ expect(issues[0]?.consequence).toContain('capacity 2')
695
+ expect(issues[0]?.consequence).toContain('4 placements')
696
+ })
697
+
698
+ test('does not fire at or under capacity, or for a capacity-less slot', () => {
699
+ const report = checkPlacements(
700
+ [
701
+ placed('p1', 'a-view', 'hero-slot'),
702
+ placed('p2', 'b-view', 'hero-slot'),
703
+ placed('p3', 'c-view', 'main-slot'),
704
+ placed('p4', 'd-view', 'main-slot'),
705
+ placed('p5', 'e-view', 'main-slot'),
706
+ ],
707
+ contextOf([], { slotManifest: capacityManifest }),
708
+ )
709
+ expect(issuesFor(report, 'over-slot-capacity')).toEqual([])
710
+ })
711
+
712
+ test('stays silent with no manifest — the check-2 unverifiable entry already covers it', () => {
713
+ const report = checkPlacements(
714
+ [
715
+ placed('p1', 'a-view', 'hero-slot'),
716
+ placed('p2', 'b-view', 'hero-slot'),
717
+ placed('p3', 'c-view', 'hero-slot'),
718
+ ],
719
+ { manifests: [], env: 'dev' },
720
+ )
721
+ expect(issuesFor(report, 'over-slot-capacity')).toEqual([])
722
+ expect(
723
+ report.unverifiable.filter(entry => entry.check === 'over-slot-capacity'),
724
+ ).toEqual([])
725
+ })
726
+ })
727
+
728
+ describe('checkPlacements — check 9: a span in a non-grid slot', () => {
729
+ const kindsManifest: HostSlotManifest = {
730
+ theme: { name: '@bespokeagentics/microdots-theme', version: '0.1.1' },
731
+ slots: [
732
+ { id: 'hero-slot', kind: 'band', row: 0 },
733
+ { id: 'main-slot', kind: 'grid', row: 1 },
734
+ ],
735
+ }
736
+
737
+ test('notes a dormant span in a band slot — the legal residue of a kind edit', () => {
738
+ const report = checkPlacements(
739
+ [
740
+ placed('p1', 'a-view', 'hero-slot', { span: Option.some(6) }),
741
+ placed('p2', 'b-view', 'main-slot', { span: Option.some(6) }),
742
+ ],
743
+ contextOf([], { slotManifest: kindsManifest }),
744
+ )
745
+ const issues = issuesFor(report, 'span-in-non-grid')
746
+ expect(issues.length).toBe(1)
747
+ expect(issues[0]?.placementId).toBe('p1')
748
+ expect(issues[0]?.severity).toBe('note')
749
+ expect(issues[0]?.consequence).toContain('dormant')
750
+ })
751
+
752
+ test('does not fire without a span, in a grid, in an undeclared slot, or with no manifest', () => {
753
+ const noSpan = checkPlacements(
754
+ [placed('p1', 'a-view', 'hero-slot')],
755
+ contextOf([], { slotManifest: kindsManifest }),
756
+ )
757
+ expect(issuesFor(noSpan, 'span-in-non-grid')).toEqual([])
758
+ const undeclared = checkPlacements(
759
+ [placed('p2', 'b-view', 'phantom-slot', { span: Option.some(4) })],
760
+ contextOf([], { slotManifest: kindsManifest }),
761
+ )
762
+ expect(issuesFor(undeclared, 'span-in-non-grid')).toEqual([])
763
+ const bare = checkPlacements(
764
+ [placed('p3', 'c-view', 'hero-slot', { span: Option.some(4) })],
765
+ { manifests: [], env: 'dev' },
766
+ )
767
+ expect(issuesFor(bare, 'span-in-non-grid')).toEqual([])
768
+ })
769
+ })