@bespokeagentics/microdots-host 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ })
@@ -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),