@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.
- package/package.json +2 -2
- package/src/anchorMount.test.ts +124 -0
- package/src/anchorMount.ts +153 -0
- package/src/fillComposition.ts +1 -1
- package/src/index.ts +45 -3
- package/src/mountedRegistry.test.ts +1 -0
- package/src/mounting.test.ts +23 -0
- package/src/mounting.ts +10 -1
- package/src/placementChecks.test.ts +180 -4
- package/src/placementChecks.ts +132 -7
- package/src/renderLayout.test.ts +263 -0
- package/src/renderLayout.ts +345 -0
- package/src/rules.test.ts +85 -0
- package/src/rules.ts +9 -0
- package/src/slotLayout.test.ts +127 -0
- package/src/slotLayout.ts +113 -0
- package/src/slots.test.ts +37 -3
- package/src/slots.ts +10 -1
- package/src/wire.test.ts +80 -0
- package/src/wire.ts +48 -7
- package/src/wireEngine.test.ts +1 -1
|
@@ -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: '
|
|
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: '
|
|
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: '
|
|
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
|
|
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
|
+
})
|
package/src/wire.ts
CHANGED
|
@@ -126,11 +126,21 @@ export type PlacementCondition = typeof PlacementCondition.Type
|
|
|
126
126
|
export const PlacementSpan = S.Literals([12, 8, 6, 4, 3])
|
|
127
127
|
export type PlacementSpan = typeof PlacementSpan.Type
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The sentinel `slotId` of a placement pinned to a CSS selector in host-owned
|
|
131
|
+
* DOM instead of a declared slot. An anchor placement carries `selector`; the
|
|
132
|
+
* refinement below makes a half-anchor (either half without the other)
|
|
133
|
+
* unrepresentable, so a hand-edited file fails decode loudly and a mutator's
|
|
134
|
+
* `redecode()` refuses it as `undecodable` — never a silent half-record.
|
|
135
|
+
*/
|
|
136
|
+
export const ANCHOR_SLOT_ID = '@anchor'
|
|
137
|
+
|
|
129
138
|
/**
|
|
130
139
|
* Where one MicroDot element goes — widened in Phase 5 from `{tag, slotId}`
|
|
131
140
|
* only as far as a shipping check or the reading-B resolution needs, per the
|
|
132
|
-
* phase design's table (work item 2)
|
|
133
|
-
*
|
|
141
|
+
* phase design's table (work item 2), and again by the Pages write-mode plan
|
|
142
|
+
* (anchors: `'@anchor'` + `selector`). Every new field is `S.optionalKey`,
|
|
143
|
+
* because the existing `host-topology.json` files must decode unchanged —
|
|
134
144
|
* backward compatibility is a hard requirement, pinned by the Phase-4-shaped
|
|
135
145
|
* decode test in `./wire.test.ts`.
|
|
136
146
|
*
|
|
@@ -145,11 +155,14 @@ export type PlacementSpan = typeof PlacementSpan.Type
|
|
|
145
155
|
* three.
|
|
146
156
|
* - `span` — grid columns (grid slots only).
|
|
147
157
|
* - `order` — stacking order within the slot.
|
|
158
|
+
* - `selector` — the CSS selector an anchor placement pins to. Present
|
|
159
|
+
* exactly when `slotId === ANCHOR_SLOT_ID`.
|
|
148
160
|
*
|
|
149
|
-
* Deliberately NOT here, with their
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
161
|
+
* Deliberately NOT here, with their reasons: `resolved`/`resolvedAt` (check 5
|
|
162
|
+
* needs a crawl that does not exist — an anchor's resolution is reported
|
|
163
|
+
* UNVERIFIABLE, never stored as a guess) and `loading` (no check needs it and
|
|
164
|
+
* the runtime has one strategy — a stored `loading` the loader ignores would
|
|
165
|
+
* be a lie).
|
|
153
166
|
*/
|
|
154
167
|
export const TopologyPlacement = S.Struct({
|
|
155
168
|
id: S.optionalKey(S.String),
|
|
@@ -160,7 +173,17 @@ export const TopologyPlacement = S.Struct({
|
|
|
160
173
|
envs: S.optionalKey(S.Array(WireEnv)),
|
|
161
174
|
span: S.optionalKey(PlacementSpan),
|
|
162
175
|
order: S.optionalKey(S.Number),
|
|
163
|
-
|
|
176
|
+
selector: S.optionalKey(S.String),
|
|
177
|
+
}).pipe(
|
|
178
|
+
S.check(
|
|
179
|
+
S.makeFilter(
|
|
180
|
+
placement =>
|
|
181
|
+
(placement.slotId === ANCHOR_SLOT_ID) === ('selector' in placement) ||
|
|
182
|
+
`an anchor placement carries both slotId "${ANCHOR_SLOT_ID}" and a selector — never one half`,
|
|
183
|
+
{ expected: 'selector present exactly when slotId is "@anchor"' },
|
|
184
|
+
),
|
|
185
|
+
),
|
|
186
|
+
)
|
|
164
187
|
export type TopologyPlacement = typeof TopologyPlacement.Type
|
|
165
188
|
|
|
166
189
|
export const TopologyRoute = S.Struct({
|
|
@@ -244,9 +267,27 @@ export const TopologyHost = S.Struct({
|
|
|
244
267
|
id: S.String,
|
|
245
268
|
label: S.String,
|
|
246
269
|
ownedInputs: S.Array(S.Struct({ name: S.String, type: S.String })),
|
|
270
|
+
/**
|
|
271
|
+
* Who owns this host's layout DOM. Absent means `authored` — hand-written
|
|
272
|
+
* markup wins and `slotDom.ts` only backfills missing containers. Only an
|
|
273
|
+
* explicitly `generated` host renders its slot manifest as its page
|
|
274
|
+
* structure (`renderLayout.ts`) and accepts layout writes from Pages. The
|
|
275
|
+
* marker is HOST-level, not manifest-level, because the gate must be
|
|
276
|
+
* readable when NO manifest exists: `generateLayout` targets exactly that
|
|
277
|
+
* state, and `removeLayout` returns to it (plan ruling D4,
|
|
278
|
+
* `wiki/plans/active/microdots-platform-pages-generated-layout.md`).
|
|
279
|
+
*/
|
|
280
|
+
layout: S.optionalKey(S.Literals(['authored', 'generated'])),
|
|
247
281
|
})
|
|
248
282
|
export type TopologyHost = typeof TopologyHost.Type
|
|
249
283
|
|
|
284
|
+
/** The layout-ownership seam: absent means `authored`. Every gate — the
|
|
285
|
+
* proof host's renderer, the Pages service's layout mutators — reads the
|
|
286
|
+
* mode through here, never `topology.host.layout` directly. */
|
|
287
|
+
export const layoutModeOf = (topology: {
|
|
288
|
+
readonly host: TopologyHost
|
|
289
|
+
}): 'authored' | 'generated' => topology.host.layout ?? 'authored'
|
|
290
|
+
|
|
250
291
|
/**
|
|
251
292
|
* A whole host, as data: who it is, where its elements go, what is wired to
|
|
252
293
|
* what, and which events it merely watches (log-only taps — the demo host's
|
package/src/wireEngine.test.ts
CHANGED
|
@@ -614,7 +614,7 @@ describe('attachWireEngine', () => {
|
|
|
614
614
|
* click sent paths from host A into panes rendering host B. All four wires
|
|
615
615
|
* derive `live`, so the Wiring screen showed nothing wrong.
|
|
616
616
|
*/
|
|
617
|
-
test('two
|
|
617
|
+
test('two MicroDots emitting the same event name drive only the wire whose `from` matches', () => {
|
|
618
618
|
const table = mount('wiring-table')
|
|
619
619
|
const canvas = mount('pages-canvas')
|
|
620
620
|
const inspector = mount('pages-inspector')
|