@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.
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
+ })
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Layout GEOMETRY for a generated host — pure, no DOM.
3
+ *
4
+ * The ONE place where a slot's `kind`, `width` and a placement's `span`
5
+ * become CSS. The values deliberately mirror the Pages canvas so the two
6
+ * renderings of the same manifest agree structurally
7
+ * (`microdots/pages/src/canvas/app.ts` — `slotSizingClass` min-w-40/flex-1,
8
+ * `slotMinHeightClass` min-h-36/min-h-16, the grid-cols-12 wrapper and
9
+ * `spanClass`). The canvas migrating onto this module is the named follow-up
10
+ * of `wiki/plans/active/microdots-platform-pages-generated-layout.md`, out of
11
+ * scope there and here.
12
+ *
13
+ * Styles are returned as ordered `[property, value][]` pairs and applied as
14
+ * inline literals: a generated host must not depend on a Tailwind scanner
15
+ * having seen a class, and the page draws NO borders — the page is a page,
16
+ * the canvas draws the editor chrome. Fixed widths are expressed through
17
+ * `var(--slot-size-<id>, <declared>)` so `attachSlotResize` composes without
18
+ * either module knowing the other ran. No media queries: the flex floor is
19
+ * `min(160px, 100%)` so a narrow viewport compresses instead of overflowing.
20
+ */
21
+
22
+ import type { SlotSpec } from './slots.ts'
23
+ import { slotSizeVar } from './slotResize.ts'
24
+
25
+ /** An ordered inline-style listing, applied property by property — never via
26
+ * `cssText`, which would wipe `--slot-size-*` set by a resize handle. */
27
+ export type StylePairs = ReadonlyArray<readonly [string, string]>
28
+
29
+ export type LayoutRow = {
30
+ readonly row: number
31
+ readonly slots: ReadonlyArray<SlotSpec>
32
+ }
33
+
34
+ /**
35
+ * The declared layout: slots grouped by `row`, rows ascending, declared
36
+ * order within a row — the `declaredLayoutFor` contract, without the cards.
37
+ */
38
+ export const layoutRows = (
39
+ slots: ReadonlyArray<SlotSpec>,
40
+ ): ReadonlyArray<LayoutRow> =>
41
+ [...new Set(slots.map(slot => slot.row))]
42
+ .sort((a, b) => a - b)
43
+ .map(row => ({ row, slots: slots.filter(slot => slot.row === row) }))
44
+
45
+ /**
46
+ * One generated `<section>` per slot: label and description live in the
47
+ * section's chrome, the slot div holds ONLY mounted elements. This retires
48
+ * the authored hosts' "two independent lists" convention (sections and slots
49
+ * declared separately) on the generated path — a generated slot IS its
50
+ * section. Rows are chrome `div`s: no ids, never hidden by routing.
51
+ */
52
+ export const generatedSectionId = (slotId: string): string =>
53
+ `section-${slotId}`
54
+
55
+ /** A row of side-by-side slots. Mirrors the canvas row band (flex,
56
+ * items-stretch, gap-2) INCLUDING its overflow rule: a row whose declared
57
+ * fixed widths exceed the viewport scrolls inside its own band — the
58
+ * style guide's rule for wide content — so the PAGE never scrolls
59
+ * horizontally. Not a media query; the same literal styles at every
60
+ * width. */
61
+ export const rowStyle = (): StylePairs => [
62
+ ['display', 'flex'],
63
+ ['align-items', 'stretch'],
64
+ ['gap', '0.5rem'],
65
+ ['min-width', '0'],
66
+ ['overflow-x', 'auto'],
67
+ ]
68
+
69
+ /**
70
+ * The section is the sized flex child. Width-less slots flex with the
71
+ * canvas's 160px floor (as `min(160px, 100%)` so the floor never exceeds a
72
+ * narrow viewport); a declared width pins the section through the resize
73
+ * variable so a drag handle, if ever attached, wins over the declaration.
74
+ */
75
+ export const sectionStyle = (slot: SlotSpec): StylePairs =>
76
+ slot.width === undefined
77
+ ? [
78
+ ['flex', '1 1 0%'],
79
+ ['min-width', 'min(160px, 100%)'],
80
+ ]
81
+ : [
82
+ ['flex', 'none'],
83
+ // No space after the comma: happy-dom's CSS parser drops the
84
+ // declaration with one, and browsers accept both spellings.
85
+ ['width', `var(${slotSizeVar(slot.id)},${slot.width})`],
86
+ ]
87
+
88
+ /**
89
+ * The slot div's interior: rails run taller than the other kinds (the
90
+ * canvas's min-h-36 = 9rem vs min-h-16 = 4rem); a grid slot lays its
91
+ * children on twelve columns (the canvas's grid-cols-12 gap-1); every other
92
+ * kind stacks placements vertically.
93
+ */
94
+ export const slotStyle = (slot: SlotSpec): StylePairs => [
95
+ ['min-height', slot.kind === 'rail' ? '9rem' : '4rem'],
96
+ ...(slot.kind === 'grid'
97
+ ? ([
98
+ ['display', 'grid'],
99
+ ['grid-template-columns', 'repeat(12, minmax(0, 1fr))'],
100
+ ['gap', '0.25rem'],
101
+ ] as const)
102
+ : ([
103
+ ['display', 'flex'],
104
+ ['flex-direction', 'column'],
105
+ ['gap', '0.5rem'],
106
+ ] as const)),
107
+ ]
108
+
109
+ /** A placement's span inside a grid slot — the canvas's `spanClass`, with
110
+ * the same default of the full twelve columns. */
111
+ export const placementSpanStyle = (span: number | undefined): StylePairs => [
112
+ ['grid-column', `span ${String(span ?? 12)}`],
113
+ ]
package/src/slots.test.ts CHANGED
@@ -16,7 +16,7 @@ const RAW_MANIFEST = {
16
16
  slots: [
17
17
  { id: 'top-bar', kind: 'bar', row: 0 },
18
18
  { id: 'hero-band', kind: 'band', row: 1 },
19
- { id: 'app-list-slot', kind: 'rail', row: 2, width: '280px' },
19
+ { id: 'board-slot', kind: 'rail', row: 2, width: '280px' },
20
20
  { id: 'main-grid', kind: 'grid', row: 2, capacity: 4 },
21
21
  ],
22
22
  }
@@ -79,7 +79,7 @@ describe('HostSlotManifest', () => {
79
79
  JSON.stringify({
80
80
  ...RAW_MANIFEST,
81
81
  slots: [
82
- { id: 'app-list-slot', kind: 'rail', row: 2, width: '280px', resize: 'x' },
82
+ { id: 'board-slot', kind: 'rail', row: 2, width: '280px', resize: 'x' },
83
83
  { id: 'hero-band', kind: 'band', row: 1, resize: 'y' },
84
84
  ],
85
85
  }),
@@ -91,9 +91,43 @@ describe('HostSlotManifest', () => {
91
91
  const parsed: unknown = JSON.parse(
92
92
  JSON.stringify({
93
93
  theme: RAW_MANIFEST.theme,
94
- slots: [{ id: 'app-list-slot', kind: 'rail', row: 2, resize: 'both' }],
94
+ slots: [{ id: 'board-slot', kind: 'rail', row: 2, resize: 'both' }],
95
95
  }),
96
96
  )
97
97
  expect(Option.isNone(decodeManifest(parsed))).toBe(true)
98
98
  })
99
+
100
+ // The generated-layout widening: `label`/`description` are explicit
101
+ // human-authored copy (never tag-inferred), and a pre-widening manifest
102
+ // must decode byte-identically — the hard sequencing gate of
103
+ // `wiki/plans/active/microdots-platform-pages-generated-layout.md` §A.
104
+ test('decodes label and description without inventing either', () => {
105
+ const parsed: unknown = JSON.parse(
106
+ JSON.stringify({
107
+ ...RAW_MANIFEST,
108
+ slots: [
109
+ {
110
+ id: 'hero',
111
+ kind: 'band',
112
+ row: 1,
113
+ label: 'Hero',
114
+ description: 'The lead message.',
115
+ },
116
+ { id: 'main', kind: 'grid', row: 2, label: 'Main' },
117
+ ],
118
+ }),
119
+ )
120
+ expect(decodeManifest(parsed)).toEqual(Option.some(parsed))
121
+ })
122
+
123
+ test('absent label and description stay absent — copy is authored, never defaulted', () => {
124
+ const parsed: unknown = JSON.parse(JSON.stringify(RAW_MANIFEST))
125
+ const decoded = decodeManifest(parsed)
126
+ expect(Option.isSome(decoded)).toBe(true)
127
+ if (Option.isSome(decoded)) {
128
+ const first = decoded.value.slots[0]
129
+ expect(first !== undefined && 'label' in first).toBe(false)
130
+ expect(first !== undefined && 'description' in first).toBe(false)
131
+ }
132
+ })
99
133
  })
package/src/slots.ts CHANGED
@@ -43,14 +43,23 @@ export type SlotResizeAxis = typeof SlotResizeAxis.Type
43
43
  * sharing a row sit side by side (the fixture's sidebar / main / aside).
44
44
  * `width` is a CSS length for a fixed `rail` (`'280px'`); `capacity` is how
45
45
  * many placements the slot is meant to hold. `resize` is the drag axis, if
46
- * any. All three of those are `optionalKey` (not `optional`) so the decoded
46
+ * any. All optionals are `optionalKey` (not `optional`) so the decoded
47
47
  * Type is exact-optional under `exactOptionalPropertyTypes`, matching the
48
48
  * rest of the topology schemas.
49
+ *
50
+ * `label` and `description` are EXPLICIT human-authored copy, written in the
51
+ * Pages inspector — the resolution of
52
+ * `wiki/framework/composition/gap-a-generated-section-has-no-copy-to-render.md`
53
+ * by that page's own proposed remedy. Copy is never inferred from a tag, a
54
+ * placement, or a storage key; absent means nobody has written any yet, and a
55
+ * generated section renders without it rather than inventing some.
49
56
  */
50
57
  export const SlotSpec = S.Struct({
51
58
  id: S.String,
52
59
  kind: SlotKind,
53
60
  row: S.Number,
61
+ label: S.optionalKey(S.String),
62
+ description: S.optionalKey(S.String),
54
63
  width: S.optionalKey(S.String),
55
64
  capacity: S.optionalKey(S.Number),
56
65
  resize: S.optionalKey(SlotResizeAxis),
package/src/wire.test.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  TopologyPlacement,
15
15
  type Wire,
16
16
  deriveWireState,
17
+ layoutModeOf,
17
18
  topologyRouteTable,
18
19
  } from './wire.ts'
19
20
 
@@ -554,6 +555,13 @@ describe('the shipped host-topology.json files', () => {
554
555
  const parsed = topologyOnDisk('../../../apps/platform/host-topology.json')
555
556
  expect(decodeTopology(parsed)).toEqual(Option.some(parsed))
556
557
  })
558
+
559
+ test('the embedding-demo topology still decodes, unchanged — the anchor host', () => {
560
+ const parsed = topologyOnDisk(
561
+ '../../../apps/embedding-demo/host-topology.json',
562
+ )
563
+ expect(decodeTopology(parsed)).toEqual(Option.some(parsed))
564
+ })
557
565
  })
558
566
 
559
567
  const decodePlacement = S.decodeUnknownOption(TopologyPlacement)
@@ -592,6 +600,41 @@ describe('TopologyPlacement — the Phase-5 widening', () => {
592
600
  })
593
601
  })
594
602
 
603
+ // The failure mode: a hand-edited half-anchor — a selector on a slot
604
+ // placement, or `@anchor` with no selector — decoding fine and rendering as
605
+ // whichever half a reader happens to trust. The refinement makes the
606
+ // half-record unrepresentable, so it dies at decode and a mutator's
607
+ // `redecode()` refuses it as `undecodable`.
608
+ describe('TopologyPlacement — anchors (the write-mode widening)', () => {
609
+ const ANCHOR_PLACEMENT = {
610
+ id: 'emb-readout',
611
+ tag: 'readout-view',
612
+ slotId: '@anchor',
613
+ selector: '#widget-slot',
614
+ envs: ['dev'],
615
+ }
616
+
617
+ test('an anchor placement round-trips decode → encode byte-identically', () => {
618
+ const parsed: unknown = JSON.parse(JSON.stringify(ANCHOR_PLACEMENT))
619
+ expect(Option.map(decodePlacement(parsed), encodePlacement)).toEqual(
620
+ Option.some(parsed),
621
+ )
622
+ })
623
+
624
+ test('rejects @anchor without a selector', () => {
625
+ const { selector: _selector, ...withoutSelector } = ANCHOR_PLACEMENT
626
+ const parsed: unknown = JSON.parse(JSON.stringify(withoutSelector))
627
+ expect(Option.isNone(decodePlacement(parsed))).toBe(true)
628
+ })
629
+
630
+ test('rejects a selector on a slot placement', () => {
631
+ const parsed: unknown = JSON.parse(
632
+ JSON.stringify({ ...ANCHOR_PLACEMENT, slotId: 'hero-slot' }),
633
+ )
634
+ expect(Option.isNone(decodePlacement(parsed))).toBe(true)
635
+ })
636
+ })
637
+
595
638
  // The failure mode: `rules` and `slotManifest` are the Phase-5 keys the
596
639
  // Pages screen and the resolution read — a topology carrying all three rule
597
640
  // kinds that fails to decode is a platform host that never starts.
@@ -631,3 +674,40 @@ describe('HostTopology — the Phase-5 keys', () => {
631
674
  expect(decodeTopology(parsed)).toEqual(Option.some(parsed))
632
675
  })
633
676
  })
677
+
678
+ // The generated-layout opt-in (ruling D4): host-level, absent = authored.
679
+ // The accessor is THE seam every gate reads — a topology written before the
680
+ // key existed must behave exactly as an authored host.
681
+ describe('layoutModeOf', () => {
682
+ test('absent layout key means authored', () => {
683
+ const parsed: unknown = JSON.parse(JSON.stringify(RAW_TOPOLOGY))
684
+ const decoded = decodeTopology(parsed)
685
+ expect(Option.isSome(decoded)).toBe(true)
686
+ if (Option.isSome(decoded)) {
687
+ expect('layout' in decoded.value.host).toBe(false)
688
+ expect(layoutModeOf(decoded.value)).toBe('authored')
689
+ }
690
+ })
691
+
692
+ test('an explicitly generated host decodes unchanged and reads generated', () => {
693
+ const raw = {
694
+ ...RAW_TOPOLOGY,
695
+ host: { ...RAW_TOPOLOGY.host, layout: 'generated' },
696
+ }
697
+ const parsed: unknown = JSON.parse(JSON.stringify(raw))
698
+ const decoded = decodeTopology(parsed)
699
+ expect(decoded).toEqual(Option.some(parsed))
700
+ if (Option.isSome(decoded)) {
701
+ expect(layoutModeOf(decoded.value)).toBe('generated')
702
+ }
703
+ })
704
+
705
+ test('rejects a layout value that is not authored or generated', () => {
706
+ const raw = {
707
+ ...RAW_TOPOLOGY,
708
+ host: { ...RAW_TOPOLOGY.host, layout: 'freeform' },
709
+ }
710
+ const parsed: unknown = JSON.parse(JSON.stringify(raw))
711
+ expect(Option.isNone(decodeTopology(parsed))).toBe(true)
712
+ })
713
+ })