@bespokeagentics/microdots-host 0.1.0 → 0.1.1
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/evals/topology-regressions.p5.v1.json +105 -0
- package/package.json +6 -4
- package/src/composeFromSelection.test.ts +277 -0
- package/src/composeFromSelection.ts +376 -0
- package/src/compositionSpec.test.ts +311 -0
- package/src/compositionSpec.ts +802 -0
- package/src/compositionSpec.v2.test.ts +216 -0
- package/src/fillComposition.test.ts +240 -0
- package/src/fillComposition.ts +159 -0
- package/src/index.ts +140 -2
- package/src/mountedRegistry.test.ts +196 -0
- package/src/mounting.test.ts +8 -8
- package/src/mounting.ts +116 -0
- package/src/placementChecks.test.ts +12 -12
- package/src/rules.test.ts +4 -4
- package/src/rules.ts +1 -1
- package/src/slotDom.test.ts +151 -0
- package/src/slotDom.ts +111 -0
- package/src/slotResize.test.ts +392 -0
- package/src/slotResize.ts +367 -0
- package/src/slots.test.ts +53 -0
- package/src/slots.ts +30 -3
- package/src/topologyRegression.test.ts +37 -0
- package/src/topologyRegression.ts +143 -0
- package/src/topologySource.test.ts +137 -0
- package/src/topologySource.ts +97 -0
- package/src/tracePublisher.test.ts +95 -0
- package/src/tracePublisher.ts +117 -0
- package/src/wire.test.ts +13 -11
- package/src/wireEngine.test.ts +60 -36
- package/src/wireEngine.ts +13 -3
package/src/index.ts
CHANGED
|
@@ -74,12 +74,102 @@ export {
|
|
|
74
74
|
WireValueType,
|
|
75
75
|
} from './wire.ts'
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The composition IR a spec-filler emits and `compileComposition` consumes.
|
|
79
|
+
* Surface-derived JSON Schema is the smaller prompt: legal tags/events/attrs
|
|
80
|
+
* are unrepresentable to invent. See
|
|
81
|
+
* `wiki/plans/active/microdots-specialist-spec-filler.md`.
|
|
82
|
+
*/
|
|
83
|
+
export {
|
|
84
|
+
type CompositionIssue,
|
|
85
|
+
type JsonSchema,
|
|
86
|
+
CompositionSpec,
|
|
87
|
+
CompositionSpecV1,
|
|
88
|
+
CompositionSpecV2,
|
|
89
|
+
CompositionSpecVersioned,
|
|
90
|
+
GenerateDotSpec,
|
|
91
|
+
GenerateDotSpecV1,
|
|
92
|
+
catalogLocksEqual,
|
|
93
|
+
compositionJsonSchema,
|
|
94
|
+
decodeCompositionSpec,
|
|
95
|
+
decodeCompositionSpecSync,
|
|
96
|
+
decodeCompositionSpecVersioned,
|
|
97
|
+
decodeCompositionSpecVersionedSync,
|
|
98
|
+
encodeCompositionSpecV2Sync,
|
|
99
|
+
encodeTopologyJson,
|
|
100
|
+
migrateCompositionSpecV1,
|
|
101
|
+
validateComposition,
|
|
102
|
+
validateExistingSurfaceComposition,
|
|
103
|
+
} from './compositionSpec.ts'
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Constrained-decode a CompositionSpec from a brief. The JSON Schema is the
|
|
107
|
+
* prompt; `complete` is the model seam. See
|
|
108
|
+
* `wiki/plans/active/microdots-specialist-spec-filler.md`.
|
|
109
|
+
*/
|
|
110
|
+
export {
|
|
111
|
+
type CompleteCompositionJson,
|
|
112
|
+
type FillCompositionFailure,
|
|
113
|
+
type FillCompositionOptions,
|
|
114
|
+
FillCompositionError,
|
|
115
|
+
compositionPrompt,
|
|
116
|
+
compositionVocabulary,
|
|
117
|
+
extractCompositionJson,
|
|
118
|
+
fillComposition,
|
|
119
|
+
liveFillPrompt,
|
|
120
|
+
} from './fillComposition.ts'
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Catalog-picker → CompositionSpec. `generate[]` is always empty; unknown
|
|
124
|
+
* tags cannot be represented. See
|
|
125
|
+
* `wiki/plans/active/microdots-specialist-spec-filler.md`.
|
|
126
|
+
*/
|
|
127
|
+
export {
|
|
128
|
+
type CompositionLayout,
|
|
129
|
+
type CompositionWirePick,
|
|
130
|
+
type ComposeFromSelectionInput,
|
|
131
|
+
composeExistingSurfacesFromSelection,
|
|
132
|
+
composeFromSelection,
|
|
133
|
+
eventFieldsOf,
|
|
134
|
+
wireableAttributesOf,
|
|
135
|
+
} from './composeFromSelection.ts'
|
|
136
|
+
|
|
137
|
+
export {
|
|
138
|
+
TopologyRegressionCaseV1,
|
|
139
|
+
TopologyRegressionCorpusV1,
|
|
140
|
+
TopologyRegressionObservationV1,
|
|
141
|
+
decodeTopologyRegressionCorpusSync,
|
|
142
|
+
observeTopologyRegressionCase,
|
|
143
|
+
topologyRegressionSurfaces,
|
|
144
|
+
type TopologyRegressionCaseV1 as TopologyRegressionCase,
|
|
145
|
+
type TopologyRegressionCorpusV1 as TopologyRegressionCorpus,
|
|
146
|
+
type TopologyRegressionObservationV1 as TopologyRegressionObservation,
|
|
147
|
+
} from './topologyRegression.ts'
|
|
148
|
+
|
|
77
149
|
/**
|
|
78
150
|
* The host slot manifest — Phase 5 work item 1: a host declaring, as data and
|
|
79
151
|
* versioned by theme, where MicroDots may go. Lives inside
|
|
80
152
|
* `host-topology.json` as `HostTopology.slotManifest`.
|
|
81
153
|
*/
|
|
82
|
-
export {
|
|
154
|
+
export {
|
|
155
|
+
HostSlotManifest,
|
|
156
|
+
SlotKind,
|
|
157
|
+
SlotLayoutPreset,
|
|
158
|
+
SlotResizeAxis,
|
|
159
|
+
SlotSpec,
|
|
160
|
+
} from './slots.ts'
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Acquiring a topology at RUNTIME rather than importing it at build — Phase A
|
|
164
|
+
* of `wiki/plans/active/wiring-live-placement-on-a-remote-host.md`. The decode
|
|
165
|
+
* is unchanged; only WHEN it runs moves, which is why the failure had to become
|
|
166
|
+
* a typed error a shell can render rather than a throw at import.
|
|
167
|
+
*/
|
|
168
|
+
export {
|
|
169
|
+
type TopologyFailureReason,
|
|
170
|
+
TopologyUnavailable,
|
|
171
|
+
acquireTopology,
|
|
172
|
+
} from './topologySource.ts'
|
|
83
173
|
|
|
84
174
|
/**
|
|
85
175
|
* Rules, THE matcher, and the reading-B resolution — Phase 5 work item 3.
|
|
@@ -107,7 +197,42 @@ export {
|
|
|
107
197
|
* tag as the pending list is built, never after) and `mountOverridesFor`
|
|
108
198
|
* (resume overrides dropped when empty BEFORE the merge).
|
|
109
199
|
*/
|
|
110
|
-
export {
|
|
200
|
+
export {
|
|
201
|
+
type MountedRegistry,
|
|
202
|
+
makeMountedRegistry,
|
|
203
|
+
mountOverridesFor,
|
|
204
|
+
pendingMounts,
|
|
205
|
+
} from './mounting.ts'
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Containers for placements the build never knew about — Phase B of
|
|
209
|
+
* `wiki/plans/active/wiring-live-placement-on-a-remote-host.md`. Markup wins;
|
|
210
|
+
* these are the fallback for a slot a FETCHED topology added after the page was
|
|
211
|
+
* built.
|
|
212
|
+
*/
|
|
213
|
+
export {
|
|
214
|
+
ensureSection,
|
|
215
|
+
ensureSlot,
|
|
216
|
+
pruneGeneratedContainers,
|
|
217
|
+
} from './slotDom.ts'
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Drag-resize for slots that declare a `resize` axis — the remaining half of
|
|
221
|
+
* the slot-manifest generalisation in
|
|
222
|
+
* `wiki/plans/active/wiring-visual-parity-with-the-prototype.md`. Handles are
|
|
223
|
+
* host chrome; sizes are CSS variables on the section, never a topology write.
|
|
224
|
+
*/
|
|
225
|
+
export {
|
|
226
|
+
attachSlotResize,
|
|
227
|
+
chromeOfSlot,
|
|
228
|
+
clearSlotResize,
|
|
229
|
+
applySlotSize,
|
|
230
|
+
restoreSlotResize,
|
|
231
|
+
SLOT_RESIZE_HANDLE_CLASS,
|
|
232
|
+
type SlotResizeStorage,
|
|
233
|
+
slotResizeStorageKey,
|
|
234
|
+
slotSizeVar,
|
|
235
|
+
} from './slotResize.ts'
|
|
111
236
|
|
|
112
237
|
/**
|
|
113
238
|
* The placement checks engine — Phase 5 work item 4: six of the spec's seven
|
|
@@ -138,7 +263,20 @@ export {
|
|
|
138
263
|
type WireSkipReason,
|
|
139
264
|
type WireTrace,
|
|
140
265
|
type WireWrite,
|
|
266
|
+
WIRE_TRACE_EVENT,
|
|
141
267
|
applyTransform,
|
|
142
268
|
attachWireEngine,
|
|
143
269
|
planWires,
|
|
144
270
|
} from './wireEngine.ts'
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Host-document POST of engine traces — the seam Watch/Trace poll when the
|
|
274
|
+
* selected host is not this page. Empty url/key is a no-op (fail closed).
|
|
275
|
+
*/
|
|
276
|
+
export {
|
|
277
|
+
type PostedWireTrace,
|
|
278
|
+
type TracePublisher,
|
|
279
|
+
type TracePublisherOptions,
|
|
280
|
+
postWireTraces,
|
|
281
|
+
startTracePublisher,
|
|
282
|
+
} from './tracePublisher.ts'
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Option } from 'effect'
|
|
2
|
+
import { beforeEach, describe, expect, test } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { makeMountedRegistry, pendingMounts } from './mounting.ts'
|
|
5
|
+
import type { ResolvedPlacement } from './rules.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The unmount half of `activate`, and specifically the window that did not
|
|
9
|
+
* exist before it.
|
|
10
|
+
*
|
|
11
|
+
* `mounting.ts` exists because `filter`-then-`forEach` double-mounted: the
|
|
12
|
+
* whole predicate pass ran before anything latched, so one tag resolved twice
|
|
13
|
+
* survived twice. Adding an unmount path creates the MIRROR of that defect — a
|
|
14
|
+
* tag released while its mount is still awaiting, whose element then appends
|
|
15
|
+
* after the removal — and a MicroDot that comes back after the user deleted it
|
|
16
|
+
* is worse than one that never left. Every test below either pins the old
|
|
17
|
+
* behaviour that must survive or pins that window shut.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A resolved placement, spelled out rather than produced by `resolvePlacements`
|
|
22
|
+
* — `pendingMounts` reads only `tag` and `state`, and a literal keeps this
|
|
23
|
+
* suite about the registry instead of about the resolution.
|
|
24
|
+
*/
|
|
25
|
+
const placement = (tag: string): ResolvedPlacement => ({
|
|
26
|
+
id: `route:${tag}@${tag}-slot#0`,
|
|
27
|
+
tag,
|
|
28
|
+
slotId: `${tag}-slot`,
|
|
29
|
+
source: { _tag: 'route' },
|
|
30
|
+
condition: { who: 'any', device: 'any', locale: 'any' },
|
|
31
|
+
envs: ['dev', 'preview', 'prod'],
|
|
32
|
+
values: {},
|
|
33
|
+
span: Option.none(),
|
|
34
|
+
order: Option.none(),
|
|
35
|
+
state: 'active',
|
|
36
|
+
overriddenBy: [],
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A mounted element, in a slot, IN THE DOCUMENT.
|
|
41
|
+
*
|
|
42
|
+
* `isConnected` is the assertion that matters — "was this actually removed from
|
|
43
|
+
* the page" — and it is false for any detached tree, so a helper that builds
|
|
44
|
+
* one off-document would make every removal assertion pass for the wrong
|
|
45
|
+
* reason.
|
|
46
|
+
*/
|
|
47
|
+
const element = (): Element => {
|
|
48
|
+
const slot = document.createElement('div')
|
|
49
|
+
document.body.appendChild(slot)
|
|
50
|
+
const child = document.createElement('span')
|
|
51
|
+
slot.appendChild(child)
|
|
52
|
+
return child
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
document.body.replaceChildren()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('makeMountedRegistry', () => {
|
|
60
|
+
test('a latched tag is no longer pending — the claim survives the await', () => {
|
|
61
|
+
const mounted = makeMountedRegistry()
|
|
62
|
+
const resolved = [placement('a'), placement('b')]
|
|
63
|
+
|
|
64
|
+
expect(
|
|
65
|
+
pendingMounts(resolved, mounted.mountedTags()).map(p => p.tag),
|
|
66
|
+
).toEqual(['a', 'b'])
|
|
67
|
+
|
|
68
|
+
mounted.latch('a')
|
|
69
|
+
|
|
70
|
+
expect(
|
|
71
|
+
pendingMounts(resolved, mounted.mountedTags()).map(p => p.tag),
|
|
72
|
+
).toEqual(['b'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('retainOnly removes the element and reports what went', () => {
|
|
76
|
+
const mounted = makeMountedRegistry()
|
|
77
|
+
const a = element()
|
|
78
|
+
mounted.latch('a')
|
|
79
|
+
mounted.adopt('a', a, 'a-slot')
|
|
80
|
+
|
|
81
|
+
expect(a.isConnected).toBe(true)
|
|
82
|
+
|
|
83
|
+
const gone = mounted.retainOnly(new Map([['b', 'b-slot']]))
|
|
84
|
+
|
|
85
|
+
expect(gone).toEqual(['a'])
|
|
86
|
+
expect(a.isConnected).toBe(false)
|
|
87
|
+
expect(mounted.mountedTags().has('a')).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('a tag still in `keep` is untouched — navigation must not unmount', () => {
|
|
91
|
+
const mounted = makeMountedRegistry()
|
|
92
|
+
const a = element()
|
|
93
|
+
mounted.latch('a')
|
|
94
|
+
mounted.adopt('a', a, 'a-slot')
|
|
95
|
+
|
|
96
|
+
expect(mounted.retainOnly(new Map([['a', 'a-slot']]))).toEqual([])
|
|
97
|
+
expect(a.isConnected).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('THE MIRROR BUG: a tag released mid-mount does not append when its mount lands', () => {
|
|
101
|
+
const mounted = makeMountedRegistry()
|
|
102
|
+
mounted.latch('a')
|
|
103
|
+
|
|
104
|
+
// The topology changed while `mountMicroDot('a')` was still awaiting.
|
|
105
|
+
expect(mounted.retainOnly(new Map())).toEqual(['a'])
|
|
106
|
+
|
|
107
|
+
// ...and now the in-flight mount completes and hands over its element.
|
|
108
|
+
const late = element()
|
|
109
|
+
const adopted = mounted.adopt('a', late, 'a-slot')
|
|
110
|
+
|
|
111
|
+
expect(adopted).toBe(false)
|
|
112
|
+
expect(late.isConnected).toBe(false)
|
|
113
|
+
expect(mounted.mountedTags().has('a')).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('re-appearing after an unmount mounts cleanly — no stale element, no double latch', () => {
|
|
117
|
+
const mounted = makeMountedRegistry()
|
|
118
|
+
const first = element()
|
|
119
|
+
mounted.latch('a')
|
|
120
|
+
mounted.adopt('a', first, 'a-slot')
|
|
121
|
+
mounted.retainOnly(new Map())
|
|
122
|
+
|
|
123
|
+
// The tag comes back: it is pending again, and adopting a second element
|
|
124
|
+
// must not leave the first one anywhere.
|
|
125
|
+
expect(
|
|
126
|
+
pendingMounts([placement('a')], mounted.mountedTags()).map(p => p.tag),
|
|
127
|
+
).toEqual(['a'])
|
|
128
|
+
|
|
129
|
+
const second = element()
|
|
130
|
+
mounted.latch('a')
|
|
131
|
+
|
|
132
|
+
expect(mounted.adopt('a', second, 'a-slot')).toBe(true)
|
|
133
|
+
expect(first.isConnected).toBe(false)
|
|
134
|
+
expect(second.isConnected).toBe(true)
|
|
135
|
+
expect([...mounted.mountedTags()]).toEqual(['a'])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('a released tag is pending again, so a FAILED mount retries', () => {
|
|
139
|
+
const mounted = makeMountedRegistry()
|
|
140
|
+
mounted.latch('a')
|
|
141
|
+
mounted.release('a')
|
|
142
|
+
|
|
143
|
+
expect(
|
|
144
|
+
pendingMounts([placement('a')], mounted.mountedTags()).map(p => p.tag),
|
|
145
|
+
).toEqual(['a'])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('mountedTags is a copy — a caller cannot mutate the registry through it', () => {
|
|
149
|
+
const mounted = makeMountedRegistry()
|
|
150
|
+
mounted.latch('a')
|
|
151
|
+
|
|
152
|
+
const snapshot = mounted.mountedTags()
|
|
153
|
+
mounted.latch('b')
|
|
154
|
+
|
|
155
|
+
expect(snapshot.has('b')).toBe(false)
|
|
156
|
+
expect(mounted.mountedTags().has('b')).toBe(true)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('a placement that MOVES SLOTS is unmounted, so the next pass re-mounts it', () => {
|
|
160
|
+
const mounted = makeMountedRegistry()
|
|
161
|
+
const first = element()
|
|
162
|
+
mounted.latch('a')
|
|
163
|
+
mounted.adopt('a', first, 'a-slot')
|
|
164
|
+
|
|
165
|
+
// Same tag, still placed — but the topology now puts it somewhere else.
|
|
166
|
+
// A registry keyed on tag alone would see nothing to do here, and the dot
|
|
167
|
+
// would never move, which reads to a user as the edit being ignored.
|
|
168
|
+
const moved = mounted.retainOnly(new Map([['a', 'somewhere-else-slot']]))
|
|
169
|
+
|
|
170
|
+
expect(moved).toEqual(['a'])
|
|
171
|
+
expect(first.isConnected).toBe(false)
|
|
172
|
+
expect(
|
|
173
|
+
pendingMounts([placement('a')], mounted.mountedTags()).map(p => p.tag),
|
|
174
|
+
).toEqual(['a'])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('a tag still in the SAME slot is not disturbed by a topology change', () => {
|
|
178
|
+
const mounted = makeMountedRegistry()
|
|
179
|
+
const a = element()
|
|
180
|
+
mounted.latch('a')
|
|
181
|
+
mounted.adopt('a', a, 'a-slot')
|
|
182
|
+
|
|
183
|
+
expect(mounted.retainOnly(new Map([['a', 'a-slot']]))).toEqual([])
|
|
184
|
+
expect(a.isConnected).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('a tag still MID-MOUNT is judged on presence alone — it has no slot yet', () => {
|
|
188
|
+
const mounted = makeMountedRegistry()
|
|
189
|
+
mounted.latch('a')
|
|
190
|
+
|
|
191
|
+
// Present in the new topology, slot unknown: keep the claim rather than
|
|
192
|
+
// tearing down a mount that has not landed.
|
|
193
|
+
expect(mounted.retainOnly(new Map([['a', 'anywhere-slot']]))).toEqual([])
|
|
194
|
+
expect(mounted.mountedTags().has('a')).toBe(true)
|
|
195
|
+
})
|
|
196
|
+
})
|
package/src/mounting.test.ts
CHANGED
|
@@ -69,8 +69,8 @@ describe('pendingMounts', () => {
|
|
|
69
69
|
test('a tag resolved twice in one list is pending ONCE', () => {
|
|
70
70
|
const topology = topologyOf([
|
|
71
71
|
route('/price', [
|
|
72
|
-
{ tag: '
|
|
73
|
-
{ tag: '
|
|
72
|
+
{ tag: 'readout-view', slotId: 'price-slot' },
|
|
73
|
+
{ tag: 'readout-view', slotId: 'compare-slot' },
|
|
74
74
|
]),
|
|
75
75
|
])
|
|
76
76
|
const resolved = resolvePlacements(topology, '/price', 'dev')
|
|
@@ -95,7 +95,7 @@ describe('pendingMounts', () => {
|
|
|
95
95
|
],
|
|
96
96
|
[
|
|
97
97
|
patternRule('r1', '/*', [
|
|
98
|
-
{ tag: '
|
|
98
|
+
{ tag: 'readout-view', slotId: 'price-slot' },
|
|
99
99
|
]),
|
|
100
100
|
],
|
|
101
101
|
)
|
|
@@ -103,7 +103,7 @@ describe('pendingMounts', () => {
|
|
|
103
103
|
|
|
104
104
|
expect(resolved.length).toBe(6)
|
|
105
105
|
expect(pendingMounts(resolved, new Set()).map(item => item.tag)).toEqual([
|
|
106
|
-
'
|
|
106
|
+
'readout-view',
|
|
107
107
|
])
|
|
108
108
|
})
|
|
109
109
|
|
|
@@ -111,8 +111,8 @@ describe('pendingMounts', () => {
|
|
|
111
111
|
// Live today without any rules: the union is over routes, so one tag on
|
|
112
112
|
// two of them is in it twice.
|
|
113
113
|
const topology = topologyOf([
|
|
114
|
-
route('/price', [{ tag: '
|
|
115
|
-
route('/all', [{ tag: '
|
|
114
|
+
route('/price', [{ tag: 'readout-view', slotId: 'price-slot' }]),
|
|
115
|
+
route('/all', [{ tag: 'readout-view', slotId: 'price-slot' }]),
|
|
116
116
|
])
|
|
117
117
|
const resolved = overviewUnion(topology)
|
|
118
118
|
|
|
@@ -141,14 +141,14 @@ describe('pendingMounts', () => {
|
|
|
141
141
|
test('skips a tag already latched by an earlier activation', () => {
|
|
142
142
|
const topology = topologyOf([
|
|
143
143
|
route('/price', [
|
|
144
|
-
{ tag: '
|
|
144
|
+
{ tag: 'readout-view', slotId: 'price-slot' },
|
|
145
145
|
{ tag: 'fleet-health', slotId: 'fleet-slot' },
|
|
146
146
|
]),
|
|
147
147
|
])
|
|
148
148
|
const resolved = resolvePlacements(topology, '/price', 'dev')
|
|
149
149
|
|
|
150
150
|
expect(
|
|
151
|
-
pendingMounts(resolved, new Set(['
|
|
151
|
+
pendingMounts(resolved, new Set(['readout-view'])).map(item => item.tag),
|
|
152
152
|
).toEqual(['fleet-health'])
|
|
153
153
|
})
|
|
154
154
|
|
package/src/mounting.ts
CHANGED
|
@@ -88,3 +88,119 @@ export const mountOverridesFor = (
|
|
|
88
88
|
Object.entries(resume).filter(([, value]) => value !== ''),
|
|
89
89
|
),
|
|
90
90
|
})
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Which tags this shell has mounted, and the element each one produced.
|
|
94
|
+
*
|
|
95
|
+
* Until Phase B of `wiki/plans/active/wiring-live-placement-on-a-remote-host.md`
|
|
96
|
+
* both shells tracked this as a bare `Set<string>` that only ever grew, and
|
|
97
|
+
* that was deliberate and documented: "a MicroDot is mounted the FIRST time a
|
|
98
|
+
* route asks for it and never again, so an explicit *Unmount ticker* survives
|
|
99
|
+
* navigating away and back." **That stays true for ROUTE changes.** What a
|
|
100
|
+
* grow-only set cannot express is a TOPOLOGY change — a placement that has
|
|
101
|
+
* left the record entirely — and "appear, disappear, re-appear" is the whole
|
|
102
|
+
* proof.
|
|
103
|
+
*
|
|
104
|
+
* Two structures on purpose:
|
|
105
|
+
*
|
|
106
|
+
* - `latched` is every tag CLAIMED, whether its mount is in flight or done. It
|
|
107
|
+
* is what `pendingMounts` must see, so a claim made before an await cannot be
|
|
108
|
+
* claimed twice.
|
|
109
|
+
* - `elements` is the subset whose element exists and can be removed.
|
|
110
|
+
*
|
|
111
|
+
* **The in-flight window is the whole reason this is a type and not a Map.**
|
|
112
|
+
* `mounting.ts` already exists because `filter`-then-`forEach` double-mounted
|
|
113
|
+
* (2026-08-18 review, finding 1); an unmount path introduces the mirror of that
|
|
114
|
+
* defect — a tag released while its mount is still awaiting would append its
|
|
115
|
+
* element AFTER the removal, and a dot the user just deleted would silently
|
|
116
|
+
* come back. `adopt` refuses that append and cleans up, which is why a mount
|
|
117
|
+
* must hand its element here rather than appending and forgetting.
|
|
118
|
+
*/
|
|
119
|
+
export type MountedRegistry = {
|
|
120
|
+
/**
|
|
121
|
+
* The claimed tags, for `pendingMounts`. Derived per call rather than kept
|
|
122
|
+
* as a third structure, because a cached copy is one more thing that can
|
|
123
|
+
* disagree with the other two.
|
|
124
|
+
*/
|
|
125
|
+
readonly mountedTags: () => ReadonlySet<string>
|
|
126
|
+
/** Claim a tag BEFORE its mount is awaited. */
|
|
127
|
+
readonly latch: (tag: string) => void
|
|
128
|
+
/**
|
|
129
|
+
* Hand over the element a completed mount produced, and the slot it went
|
|
130
|
+
* into.
|
|
131
|
+
*
|
|
132
|
+
* The slot is recorded because a placement can MOVE: a topology edit that
|
|
133
|
+
* puts an already-mounted tag in a different slot leaves the tag placed, so
|
|
134
|
+
* a registry keyed on tag alone sees nothing to do and the dot never moves.
|
|
135
|
+
* That reads to a user as the edit being ignored.
|
|
136
|
+
*
|
|
137
|
+
* Returns `false` — and removes the element — when the tag was released
|
|
138
|
+
* while the mount was in flight. A caller may ignore the result; the
|
|
139
|
+
* cleanup has already happened.
|
|
140
|
+
*/
|
|
141
|
+
readonly adopt: (tag: string, element: Element, slotId: string) => boolean
|
|
142
|
+
/** Give up a claim whose mount FAILED, so the next activation retries it. */
|
|
143
|
+
readonly release: (tag: string) => void
|
|
144
|
+
/**
|
|
145
|
+
* Unmount every claimed tag that the topology no longer places THERE, and
|
|
146
|
+
* report which went.
|
|
147
|
+
*
|
|
148
|
+
* `keep` maps tag to the slot it is currently placed in, and is "placed
|
|
149
|
+
* ANYWHERE in the current topology", never "placed on the current route" —
|
|
150
|
+
* passing the latter would unmount on navigation and undo the documented
|
|
151
|
+
* behaviour above.
|
|
152
|
+
*
|
|
153
|
+
* A tag goes when it is absent from `keep` (deleted) OR when its slot has
|
|
154
|
+
* changed (moved). A moved tag is unmounted here and re-mounted by the very
|
|
155
|
+
* next `pendingMounts` pass, which is what makes a move look like a move
|
|
156
|
+
* rather than like nothing happening. A tag still mid-mount has no recorded
|
|
157
|
+
* slot yet and is judged on presence alone.
|
|
158
|
+
*/
|
|
159
|
+
readonly retainOnly: (
|
|
160
|
+
keep: ReadonlyMap<string, string>,
|
|
161
|
+
) => ReadonlyArray<string>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const makeMountedRegistry = (): MountedRegistry => {
|
|
165
|
+
const latched = new Set<string>()
|
|
166
|
+
const elements = new Map<string, Element>()
|
|
167
|
+
const slots = new Map<string, string>()
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
mountedTags: () => new Set(latched),
|
|
171
|
+
latch: tag => {
|
|
172
|
+
latched.add(tag)
|
|
173
|
+
},
|
|
174
|
+
adopt: (tag, element, slotId) => {
|
|
175
|
+
if (!latched.has(tag)) {
|
|
176
|
+
element.remove()
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
elements.set(tag, element)
|
|
180
|
+
slots.set(tag, slotId)
|
|
181
|
+
return true
|
|
182
|
+
},
|
|
183
|
+
release: tag => {
|
|
184
|
+
latched.delete(tag)
|
|
185
|
+
elements.delete(tag)
|
|
186
|
+
slots.delete(tag)
|
|
187
|
+
},
|
|
188
|
+
retainOnly: keep => {
|
|
189
|
+
const removed = Array.filter(Array.fromIterable(latched), tag => {
|
|
190
|
+
const wanted = keep.get(tag)
|
|
191
|
+
if (wanted === undefined) return true
|
|
192
|
+
// Mid-mount: no slot recorded yet, so presence is all there is to go
|
|
193
|
+
// on. The in-flight guard in `adopt` covers the rest.
|
|
194
|
+
const where = slots.get(tag)
|
|
195
|
+
return where !== undefined && where !== wanted
|
|
196
|
+
})
|
|
197
|
+
removed.forEach(tag => {
|
|
198
|
+
elements.get(tag)?.remove()
|
|
199
|
+
elements.delete(tag)
|
|
200
|
+
slots.delete(tag)
|
|
201
|
+
latched.delete(tag)
|
|
202
|
+
})
|
|
203
|
+
return removed
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -215,9 +215,9 @@ describe('checkPlacements — check 2: slot not in the theme', () => {
|
|
|
215
215
|
describe('checkPlacements — check 3: required attribute unset', () => {
|
|
216
216
|
test('warns when a required dot-owned attribute has no supplied value', () => {
|
|
217
217
|
const report = checkPlacements(
|
|
218
|
-
[placed('p1', '
|
|
218
|
+
[placed('p1', 'readout-view', 'hero-slot')],
|
|
219
219
|
contextOf([
|
|
220
|
-
manifestOf('price', ['
|
|
220
|
+
manifestOf('price', ['readout-view'], {
|
|
221
221
|
attributes: [requiredAttribute('symbol', 'dot')],
|
|
222
222
|
}),
|
|
223
223
|
]),
|
|
@@ -236,12 +236,12 @@ describe('checkPlacements — check 3: required attribute unset', () => {
|
|
|
236
236
|
test('does not warn when the resolved placement supplies the value', () => {
|
|
237
237
|
const report = checkPlacements(
|
|
238
238
|
[
|
|
239
|
-
placed('p1', '
|
|
239
|
+
placed('p1', 'readout-view', 'hero-slot', {
|
|
240
240
|
values: { symbol: 'FOLD' },
|
|
241
241
|
}),
|
|
242
242
|
],
|
|
243
243
|
contextOf([
|
|
244
|
-
manifestOf('price', ['
|
|
244
|
+
manifestOf('price', ['readout-view'], {
|
|
245
245
|
attributes: [requiredAttribute('symbol', 'dot')],
|
|
246
246
|
}),
|
|
247
247
|
]),
|
|
@@ -251,9 +251,9 @@ describe('checkPlacements — check 3: required attribute unset', () => {
|
|
|
251
251
|
|
|
252
252
|
test('exempts environment- and host-owned attributes — the loader and the host supply those', () => {
|
|
253
253
|
const report = checkPlacements(
|
|
254
|
-
[placed('p1', '
|
|
254
|
+
[placed('p1', 'readout-view', 'hero-slot')],
|
|
255
255
|
contextOf([
|
|
256
|
-
manifestOf('price', ['
|
|
256
|
+
manifestOf('price', ['readout-view'], {
|
|
257
257
|
attributes: [
|
|
258
258
|
requiredAttribute('api-url', 'environment'),
|
|
259
259
|
requiredAttribute('interview-id', 'host-input'),
|
|
@@ -542,7 +542,7 @@ describe('checkPlacements — the reading-B override qualifier', () => {
|
|
|
542
542
|
// two firing conditions, zero issues, because it renders for nobody.
|
|
543
543
|
const report = checkPlacements(
|
|
544
544
|
[
|
|
545
|
-
placed('loser', '
|
|
545
|
+
placed('loser', 'readout-view', 'ghost-slot', {
|
|
546
546
|
state: 'overridden',
|
|
547
547
|
overriddenBy: [
|
|
548
548
|
{
|
|
@@ -553,12 +553,12 @@ describe('checkPlacements — the reading-B override qualifier', () => {
|
|
|
553
553
|
},
|
|
554
554
|
],
|
|
555
555
|
}),
|
|
556
|
-
placed('winner', '
|
|
556
|
+
placed('winner', 'readout-view', 'hero-slot', {
|
|
557
557
|
values: { symbol: 'FOLD' },
|
|
558
558
|
}),
|
|
559
559
|
],
|
|
560
560
|
contextOf([
|
|
561
|
-
manifestOf('price', ['
|
|
561
|
+
manifestOf('price', ['readout-view'], {
|
|
562
562
|
attributes: [requiredAttribute('symbol', 'dot')],
|
|
563
563
|
}),
|
|
564
564
|
]),
|
|
@@ -569,7 +569,7 @@ describe('checkPlacements — the reading-B override qualifier', () => {
|
|
|
569
569
|
test('a partially overridden placement is still checked — it still renders for somebody', () => {
|
|
570
570
|
const report = checkPlacements(
|
|
571
571
|
[
|
|
572
|
-
placed('loser', '
|
|
572
|
+
placed('loser', 'readout-view', 'ghost-slot', {
|
|
573
573
|
state: 'partially-overridden',
|
|
574
574
|
overriddenBy: [
|
|
575
575
|
{
|
|
@@ -580,11 +580,11 @@ describe('checkPlacements — the reading-B override qualifier', () => {
|
|
|
580
580
|
},
|
|
581
581
|
],
|
|
582
582
|
}),
|
|
583
|
-
placed('winner', '
|
|
583
|
+
placed('winner', 'readout-view', 'hero-slot', {
|
|
584
584
|
condition: { who: 'any', device: 'any', locale: 'fr' },
|
|
585
585
|
}),
|
|
586
586
|
],
|
|
587
|
-
contextOf([manifestOf('price', ['
|
|
587
|
+
contextOf([manifestOf('price', ['readout-view'])]),
|
|
588
588
|
)
|
|
589
589
|
expect(
|
|
590
590
|
issuesFor(report, 'slot-not-in-theme').map(issue => issue.placementId),
|
package/src/rules.test.ts
CHANGED
|
@@ -122,7 +122,7 @@ describe('ruleMatches', () => {
|
|
|
122
122
|
describe('resolvePlacements', () => {
|
|
123
123
|
test('a no-rules topology resolves a single mount to the fully-defaulted identity', () => {
|
|
124
124
|
const topology = topologyOf([
|
|
125
|
-
route('/price', [{ tag: '
|
|
125
|
+
route('/price', [{ tag: 'readout-view', slotId: 'price-slot' }]),
|
|
126
126
|
])
|
|
127
127
|
// The whole record, pinned: defaults made explicit (condition axes to
|
|
128
128
|
// 'any', envs to all three, values to {}), route provenance, and the
|
|
@@ -130,8 +130,8 @@ describe('resolvePlacements', () => {
|
|
|
130
130
|
// issues depend on that id never changing shape.
|
|
131
131
|
expect(resolvePlacements(topology, '/price', 'dev')).toEqual([
|
|
132
132
|
{
|
|
133
|
-
id: '/price|0|route:
|
|
134
|
-
tag: '
|
|
133
|
+
id: '/price|0|route:readout-view@price-slot#0',
|
|
134
|
+
tag: 'readout-view',
|
|
135
135
|
slotId: 'price-slot',
|
|
136
136
|
source: { _tag: 'route' },
|
|
137
137
|
condition: { who: 'any', device: 'any', locale: 'any' },
|
|
@@ -147,7 +147,7 @@ describe('resolvePlacements', () => {
|
|
|
147
147
|
|
|
148
148
|
test('a no-rules topology resolves to exactly the route’s own mounts — the Phase-4 identity', () => {
|
|
149
149
|
const mounts: ReadonlyArray<TopologyPlacement> = [
|
|
150
|
-
{ tag: '
|
|
150
|
+
{ tag: 'readout-view', slotId: 'price-slot' },
|
|
151
151
|
{ tag: 'fleet-health', slotId: 'fleet-slot' },
|
|
152
152
|
]
|
|
153
153
|
const topology = topologyOf([route('/all', mounts)])
|
package/src/rules.ts
CHANGED
|
@@ -214,7 +214,7 @@ export type ResolvedState = 'active' | 'partially-overridden' | 'overridden'
|
|
|
214
214
|
*
|
|
215
215
|
* It is the declared id when the record has one AND that id is declared
|
|
216
216
|
* exactly once in this resolution; otherwise it is (or is qualified by) a
|
|
217
|
-
* synthesized fallback — `/price|0|route:
|
|
217
|
+
* synthesized fallback — `/price|0|route:readout-view@main-slot#0`: route
|
|
218
218
|
* path, layer, source key, tag, slot, index within its layer. See
|
|
219
219
|
* `resolvedIdOf` for the collision rule.
|
|
220
220
|
*/
|