@bespokeagentics/microdots-host 0.1.0 → 0.1.2
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
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag-resize for slots that declare a `resize` axis.
|
|
3
|
+
*
|
|
4
|
+
* The host owns placement, so the host owns the splitters: a MicroDot must
|
|
5
|
+
* not reach into sibling panes, and a handle inside a tabbed slot would
|
|
6
|
+
* vanish when that tab is hidden. Each handle is therefore attached to the
|
|
7
|
+
* chrome ancestor that is a direct child of the section — the grid-area box
|
|
8
|
+
* — and writes a CSS custom property on the section. Overrides survive a
|
|
9
|
+
* reload in the same tab through sessionStorage. Named layout presets clear
|
|
10
|
+
* both representations; neither is ever written back to the topology (the
|
|
11
|
+
* Platform must not rewrite the screen it is standing on).
|
|
12
|
+
*
|
|
13
|
+
* Prototype clamps (`MicroDots Wiring.dc.html:774-783`): drawer 300–980,
|
|
14
|
+
* pane 46–(shell − 190). Copy on the handle is the prototype's
|
|
15
|
+
* "Drag to resize".
|
|
16
|
+
*/
|
|
17
|
+
import type { SlotResizeAxis, SlotSpec } from './slots.ts'
|
|
18
|
+
|
|
19
|
+
export const SLOT_RESIZE_HANDLE_CLASS = 'slot-resize-handle'
|
|
20
|
+
|
|
21
|
+
export const slotSizeVar = (slotId: string): string => `--slot-size-${slotId}`
|
|
22
|
+
|
|
23
|
+
const RESIZED_DATASET = 'resized'
|
|
24
|
+
const AXIS_X = 'x'
|
|
25
|
+
const AXIS_Y = 'y'
|
|
26
|
+
const X_MIN = 300
|
|
27
|
+
const X_MAX = 980
|
|
28
|
+
const Y_MIN = 46
|
|
29
|
+
const Y_HEADROOM = 190
|
|
30
|
+
const KEY_STEP = 8
|
|
31
|
+
const KEY_STEP_FAST = 40
|
|
32
|
+
const STORAGE_VERSION = 1
|
|
33
|
+
const STORAGE_PREFIX = 'microdots-slot-resize-v1:'
|
|
34
|
+
|
|
35
|
+
export type SlotResizeStorage = Pick<
|
|
36
|
+
Storage,
|
|
37
|
+
'getItem' | 'removeItem' | 'setItem'
|
|
38
|
+
>
|
|
39
|
+
|
|
40
|
+
type StoredSlotSize = {
|
|
41
|
+
readonly slotId: string
|
|
42
|
+
readonly axis: SlotResizeAxis
|
|
43
|
+
readonly px: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isAxis = (value: string): value is SlotResizeAxis =>
|
|
47
|
+
value === AXIS_X || value === AXIS_Y
|
|
48
|
+
|
|
49
|
+
const clamp = (value: number, lo: number, hi: number): number =>
|
|
50
|
+
Math.min(hi, Math.max(lo, value))
|
|
51
|
+
|
|
52
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
53
|
+
typeof value === 'object' && value !== null
|
|
54
|
+
|
|
55
|
+
export const slotResizeStorageKey = (sectionId: string): string =>
|
|
56
|
+
`${STORAGE_PREFIX}${sectionId}`
|
|
57
|
+
|
|
58
|
+
const storageFor = (
|
|
59
|
+
supplied: SlotResizeStorage | null | undefined,
|
|
60
|
+
): SlotResizeStorage | undefined => {
|
|
61
|
+
if (supplied !== undefined) return supplied ?? undefined
|
|
62
|
+
try {
|
|
63
|
+
return typeof window === 'undefined' ? undefined : window.sessionStorage
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const storageKeyFor = (section: HTMLElement): string | undefined =>
|
|
70
|
+
section.id === '' ? undefined : slotResizeStorageKey(section.id)
|
|
71
|
+
|
|
72
|
+
const decodeStoredSizes = (
|
|
73
|
+
raw: string | null,
|
|
74
|
+
): ReadonlyArray<StoredSlotSize> => {
|
|
75
|
+
if (raw === null) return []
|
|
76
|
+
try {
|
|
77
|
+
const decoded: unknown = JSON.parse(raw)
|
|
78
|
+
if (
|
|
79
|
+
!isRecord(decoded) ||
|
|
80
|
+
decoded['version'] !== STORAGE_VERSION ||
|
|
81
|
+
!Array.isArray(decoded['sizes'])
|
|
82
|
+
) {
|
|
83
|
+
return []
|
|
84
|
+
}
|
|
85
|
+
return decoded['sizes'].flatMap((candidate): Array<StoredSlotSize> => {
|
|
86
|
+
if (!isRecord(candidate)) return []
|
|
87
|
+
const slotId = candidate['slotId']
|
|
88
|
+
const axis = candidate['axis']
|
|
89
|
+
const px = candidate['px']
|
|
90
|
+
return typeof slotId === 'string' &&
|
|
91
|
+
slotId !== '' &&
|
|
92
|
+
typeof axis === 'string' &&
|
|
93
|
+
isAxis(axis) &&
|
|
94
|
+
typeof px === 'number' &&
|
|
95
|
+
Number.isFinite(px)
|
|
96
|
+
? [{ slotId, axis, px }]
|
|
97
|
+
: []
|
|
98
|
+
})
|
|
99
|
+
} catch {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const readStoredSizes = (
|
|
105
|
+
section: HTMLElement,
|
|
106
|
+
supplied: SlotResizeStorage | null | undefined,
|
|
107
|
+
): ReadonlyArray<StoredSlotSize> => {
|
|
108
|
+
const storage = storageFor(supplied)
|
|
109
|
+
const key = storageKeyFor(section)
|
|
110
|
+
if (storage === undefined || key === undefined) return []
|
|
111
|
+
try {
|
|
112
|
+
return decodeStoredSizes(storage.getItem(key))
|
|
113
|
+
} catch {
|
|
114
|
+
return []
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const writeStoredSizes = (
|
|
119
|
+
section: HTMLElement,
|
|
120
|
+
sizes: ReadonlyArray<StoredSlotSize>,
|
|
121
|
+
supplied: SlotResizeStorage | null | undefined,
|
|
122
|
+
): void => {
|
|
123
|
+
const storage = storageFor(supplied)
|
|
124
|
+
const key = storageKeyFor(section)
|
|
125
|
+
if (storage === undefined || key === undefined) return
|
|
126
|
+
try {
|
|
127
|
+
storage.setItem(key, JSON.stringify({ version: STORAGE_VERSION, sizes }))
|
|
128
|
+
} catch {
|
|
129
|
+
// Storage can be disabled, full, or denied by an embedding sandbox. The
|
|
130
|
+
// live CSS override remains useful even when persistence is unavailable.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const removeStoredSizes = (
|
|
135
|
+
section: HTMLElement,
|
|
136
|
+
supplied: SlotResizeStorage | null | undefined,
|
|
137
|
+
): void => {
|
|
138
|
+
const storage = storageFor(supplied)
|
|
139
|
+
const key = storageKeyFor(section)
|
|
140
|
+
if (storage === undefined || key === undefined) return
|
|
141
|
+
try {
|
|
142
|
+
storage.removeItem(key)
|
|
143
|
+
} catch {
|
|
144
|
+
// A preset still clears the DOM override when storage is unavailable.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The grid-area box: walk from the slot to the section's direct child.
|
|
150
|
+
* Nested tab panes (`#wiring-inspector-slot` inside `.wiring-drawer`)
|
|
151
|
+
* resize the drawer, not the hidden tab.
|
|
152
|
+
*/
|
|
153
|
+
export const chromeOfSlot = (
|
|
154
|
+
slot: HTMLElement,
|
|
155
|
+
section: HTMLElement,
|
|
156
|
+
): HTMLElement => {
|
|
157
|
+
let node: HTMLElement | null = slot
|
|
158
|
+
while (node !== null && node.parentElement !== section) {
|
|
159
|
+
node = node.parentElement
|
|
160
|
+
}
|
|
161
|
+
return node ?? slot
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const minFor = (axis: SlotResizeAxis): number =>
|
|
165
|
+
axis === AXIS_X ? X_MIN : Y_MIN
|
|
166
|
+
|
|
167
|
+
const maxFor = (axis: SlotResizeAxis, section: HTMLElement): number =>
|
|
168
|
+
axis === AXIS_X ? X_MAX : Math.max(Y_MIN, section.clientHeight - Y_HEADROOM)
|
|
169
|
+
|
|
170
|
+
const resizedAxesOf = (section: HTMLElement): ReadonlyArray<SlotResizeAxis> => {
|
|
171
|
+
const raw = section.dataset[RESIZED_DATASET]
|
|
172
|
+
if (raw === undefined || raw === '') return []
|
|
173
|
+
return raw.split(/\s+/).filter(isAxis)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const writeResized = (
|
|
177
|
+
section: HTMLElement,
|
|
178
|
+
axes: ReadonlyArray<SlotResizeAxis>,
|
|
179
|
+
): void => {
|
|
180
|
+
if (axes.length === 0) {
|
|
181
|
+
delete section.dataset[RESIZED_DATASET]
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const ordered: Array<SlotResizeAxis> = []
|
|
185
|
+
if (axes.includes(AXIS_X)) ordered.push(AXIS_X)
|
|
186
|
+
if (axes.includes(AXIS_Y)) ordered.push(AXIS_Y)
|
|
187
|
+
section.dataset[RESIZED_DATASET] = ordered.join(' ')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const applySlotSize = (
|
|
191
|
+
section: HTMLElement,
|
|
192
|
+
slotId: string,
|
|
193
|
+
axis: SlotResizeAxis,
|
|
194
|
+
px: number,
|
|
195
|
+
storage?: SlotResizeStorage | null,
|
|
196
|
+
): number => {
|
|
197
|
+
const next = clamp(Math.round(px), minFor(axis), maxFor(axis, section))
|
|
198
|
+
section.style.setProperty(slotSizeVar(slotId), `${String(next)}px`)
|
|
199
|
+
const axes = resizedAxesOf(section)
|
|
200
|
+
writeResized(section, axes.includes(axis) ? axes : [...axes, axis])
|
|
201
|
+
const stored = readStoredSizes(section, storage).filter(
|
|
202
|
+
entry => entry.slotId !== slotId,
|
|
203
|
+
)
|
|
204
|
+
writeStoredSizes(section, [...stored, { slotId, axis, px: next }], storage)
|
|
205
|
+
return next
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Restore only entries that still name a declared resizable slot and axis. */
|
|
209
|
+
export const restoreSlotResize = (
|
|
210
|
+
section: HTMLElement,
|
|
211
|
+
slots: ReadonlyArray<SlotSpec>,
|
|
212
|
+
storage?: SlotResizeStorage | null,
|
|
213
|
+
): void => {
|
|
214
|
+
const declared = new Map(
|
|
215
|
+
slots.flatMap(slot =>
|
|
216
|
+
slot.resize === undefined ? [] : [[slot.id, slot.resize] as const],
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
readStoredSizes(section, storage).forEach(entry => {
|
|
220
|
+
if (declared.get(entry.slotId) !== entry.axis) return
|
|
221
|
+
const rounded = Math.round(entry.px)
|
|
222
|
+
const maximum =
|
|
223
|
+
entry.axis === AXIS_Y && section.clientHeight === 0
|
|
224
|
+
? Math.max(Y_MIN, rounded)
|
|
225
|
+
: maxFor(entry.axis, section)
|
|
226
|
+
const next = clamp(rounded, minFor(entry.axis), maximum)
|
|
227
|
+
section.style.setProperty(slotSizeVar(entry.slotId), `${String(next)}px`)
|
|
228
|
+
const axes = resizedAxesOf(section)
|
|
229
|
+
writeResized(
|
|
230
|
+
section,
|
|
231
|
+
axes.includes(entry.axis) ? axes : [...axes, entry.axis],
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const clearSlotResize = (
|
|
237
|
+
section: HTMLElement,
|
|
238
|
+
slots: ReadonlyArray<SlotSpec>,
|
|
239
|
+
storage?: SlotResizeStorage | null,
|
|
240
|
+
): void => {
|
|
241
|
+
slots.forEach(slot => {
|
|
242
|
+
if (slot.resize === undefined) return
|
|
243
|
+
section.style.removeProperty(slotSizeVar(slot.id))
|
|
244
|
+
})
|
|
245
|
+
delete section.dataset[RESIZED_DATASET]
|
|
246
|
+
removeStoredSizes(section, storage)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
type Drag = {
|
|
250
|
+
readonly axis: SlotResizeAxis
|
|
251
|
+
readonly slotId: string
|
|
252
|
+
readonly startPointer: number
|
|
253
|
+
readonly startSize: number
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const attachSlotResize = (input: {
|
|
257
|
+
readonly section: HTMLElement
|
|
258
|
+
readonly slots: ReadonlyArray<SlotSpec>
|
|
259
|
+
readonly storage?: SlotResizeStorage | null
|
|
260
|
+
}): { readonly detach: () => void } => {
|
|
261
|
+
const { section, slots, storage } = input
|
|
262
|
+
restoreSlotResize(section, slots, storage)
|
|
263
|
+
const handles: Array<HTMLButtonElement> = []
|
|
264
|
+
const attached = new WeakMap<HTMLElement, Set<SlotResizeAxis>>()
|
|
265
|
+
let drag: Drag | undefined
|
|
266
|
+
let previousUserSelect = ''
|
|
267
|
+
|
|
268
|
+
const onPointerMove = (event: PointerEvent): void => {
|
|
269
|
+
if (drag === undefined) return
|
|
270
|
+
const pointer = drag.axis === AXIS_X ? event.clientX : event.clientY
|
|
271
|
+
applySlotSize(
|
|
272
|
+
section,
|
|
273
|
+
drag.slotId,
|
|
274
|
+
drag.axis,
|
|
275
|
+
drag.startSize - (pointer - drag.startPointer),
|
|
276
|
+
storage,
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const stopDrag = (): void => {
|
|
281
|
+
if (drag === undefined) return
|
|
282
|
+
drag = undefined
|
|
283
|
+
document.body.style.userSelect = previousUserSelect
|
|
284
|
+
document.removeEventListener('pointermove', onPointerMove)
|
|
285
|
+
document.removeEventListener('pointerup', stopDrag)
|
|
286
|
+
document.removeEventListener('pointercancel', stopDrag)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
slots.forEach(slot => {
|
|
290
|
+
const axis = slot.resize
|
|
291
|
+
if (axis === undefined) return
|
|
292
|
+
const slotEl = document.getElementById(slot.id)
|
|
293
|
+
if (slotEl === null) return
|
|
294
|
+
const chrome = chromeOfSlot(slotEl, section)
|
|
295
|
+
const seen = attached.get(chrome)
|
|
296
|
+
if (seen?.has(axis) === true) return
|
|
297
|
+
const nextSeen = seen ?? new Set<SlotResizeAxis>()
|
|
298
|
+
nextSeen.add(axis)
|
|
299
|
+
attached.set(chrome, nextSeen)
|
|
300
|
+
|
|
301
|
+
if (getComputedStyle(chrome).position === 'static') {
|
|
302
|
+
chrome.style.position = 'relative'
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const handle = document.createElement('button')
|
|
306
|
+
handle.type = 'button'
|
|
307
|
+
handle.className = SLOT_RESIZE_HANDLE_CLASS
|
|
308
|
+
handle.dataset['axis'] = axis
|
|
309
|
+
handle.title = 'Drag to resize'
|
|
310
|
+
handle.setAttribute('aria-label', 'Drag to resize')
|
|
311
|
+
handle.setAttribute(
|
|
312
|
+
'aria-orientation',
|
|
313
|
+
axis === AXIS_X ? 'vertical' : 'horizontal',
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
handle.addEventListener('pointerdown', event => {
|
|
317
|
+
if (event.button !== 0) return
|
|
318
|
+
event.preventDefault()
|
|
319
|
+
const startSize =
|
|
320
|
+
axis === AXIS_X ? chrome.offsetWidth : chrome.offsetHeight
|
|
321
|
+
drag = {
|
|
322
|
+
axis,
|
|
323
|
+
slotId: slot.id,
|
|
324
|
+
startPointer: axis === AXIS_X ? event.clientX : event.clientY,
|
|
325
|
+
startSize,
|
|
326
|
+
}
|
|
327
|
+
previousUserSelect = document.body.style.userSelect
|
|
328
|
+
document.body.style.userSelect = 'none'
|
|
329
|
+
document.addEventListener('pointermove', onPointerMove)
|
|
330
|
+
document.addEventListener('pointerup', stopDrag)
|
|
331
|
+
document.addEventListener('pointercancel', stopDrag)
|
|
332
|
+
if (typeof handle.setPointerCapture === 'function') {
|
|
333
|
+
handle.setPointerCapture(event.pointerId)
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
handle.addEventListener('keydown', event => {
|
|
338
|
+
const step = event.shiftKey ? KEY_STEP_FAST : KEY_STEP
|
|
339
|
+
const grow =
|
|
340
|
+
axis === AXIS_X ? event.key === 'ArrowLeft' : event.key === 'ArrowUp'
|
|
341
|
+
const shrink =
|
|
342
|
+
axis === AXIS_X ? event.key === 'ArrowRight' : event.key === 'ArrowDown'
|
|
343
|
+
if (!grow && !shrink) return
|
|
344
|
+
event.preventDefault()
|
|
345
|
+
const current = axis === AXIS_X ? chrome.offsetWidth : chrome.offsetHeight
|
|
346
|
+
applySlotSize(
|
|
347
|
+
section,
|
|
348
|
+
slot.id,
|
|
349
|
+
axis,
|
|
350
|
+
current + (grow ? step : -step),
|
|
351
|
+
storage,
|
|
352
|
+
)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
chrome.insertBefore(handle, chrome.firstChild)
|
|
356
|
+
handles.push(handle)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
detach: () => {
|
|
361
|
+
stopDrag()
|
|
362
|
+
handles.forEach(handle => {
|
|
363
|
+
handle.remove()
|
|
364
|
+
})
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
}
|
package/src/slots.test.ts
CHANGED
|
@@ -34,6 +34,25 @@ describe('HostSlotManifest', () => {
|
|
|
34
34
|
expect(decodeManifest(parsed)).toEqual(Option.some(parsed))
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
+
test('an absent layouts key stays absent — hosts without presets do not invent four', () => {
|
|
38
|
+
const parsed: unknown = JSON.parse(JSON.stringify(RAW_MANIFEST))
|
|
39
|
+
const decoded = decodeManifest(parsed)
|
|
40
|
+
expect(Option.isSome(decoded)).toBe(true)
|
|
41
|
+
if (Option.isSome(decoded)) {
|
|
42
|
+
expect('layouts' in decoded.value).toBe(false)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('decodes named layout presets without inventing a hint', () => {
|
|
47
|
+
const parsed: unknown = JSON.parse(
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
...RAW_MANIFEST,
|
|
50
|
+
layouts: [{ id: 'split' }, { id: 'map', hint: 'Map — canvas full height' }],
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
expect(decodeManifest(parsed)).toEqual(Option.some(parsed))
|
|
54
|
+
})
|
|
55
|
+
|
|
37
56
|
test('rejects a slot whose kind is not bar, band, rail or grid', () => {
|
|
38
57
|
const parsed: unknown = JSON.parse(
|
|
39
58
|
JSON.stringify({
|
|
@@ -43,4 +62,38 @@ describe('HostSlotManifest', () => {
|
|
|
43
62
|
)
|
|
44
63
|
expect(Option.isNone(decodeManifest(parsed))).toBe(true)
|
|
45
64
|
})
|
|
65
|
+
|
|
66
|
+
test('an absent resize key stays absent — a slot is not resizable by default', () => {
|
|
67
|
+
const parsed: unknown = JSON.parse(JSON.stringify(RAW_MANIFEST))
|
|
68
|
+
const decoded = decodeManifest(parsed)
|
|
69
|
+
expect(Option.isSome(decoded)).toBe(true)
|
|
70
|
+
if (Option.isSome(decoded)) {
|
|
71
|
+
decoded.value.slots.forEach(slot => {
|
|
72
|
+
expect('resize' in slot).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('decodes a resize axis without inventing the other', () => {
|
|
78
|
+
const parsed: unknown = JSON.parse(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
...RAW_MANIFEST,
|
|
81
|
+
slots: [
|
|
82
|
+
{ id: 'app-list-slot', kind: 'rail', row: 2, width: '280px', resize: 'x' },
|
|
83
|
+
{ id: 'hero-band', kind: 'band', row: 1, resize: 'y' },
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
expect(decodeManifest(parsed)).toEqual(Option.some(parsed))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('rejects a resize axis that is not x or y', () => {
|
|
91
|
+
const parsed: unknown = JSON.parse(
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
theme: RAW_MANIFEST.theme,
|
|
94
|
+
slots: [{ id: 'app-list-slot', kind: 'rail', row: 2, resize: 'both' }],
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
expect(Option.isNone(decodeManifest(parsed))).toBe(true)
|
|
98
|
+
})
|
|
46
99
|
})
|
package/src/slots.ts
CHANGED
|
@@ -27,13 +27,25 @@ import { Schema as S } from 'effect'
|
|
|
27
27
|
export const SlotKind = S.Literals(['bar', 'band', 'rail', 'grid'])
|
|
28
28
|
export type SlotKind = typeof SlotKind.Type
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Which edge of a slot the host may drag. `x` is the inline axis (a trailing
|
|
32
|
+
* rail's inner edge — the Wiring drawer); `y` is the block axis (a bottom
|
|
33
|
+
* band's top edge — the Wiring table pane). Absent means the slot is not
|
|
34
|
+
* resizable. The handle sits on the start edge of the chrome ancestor, which
|
|
35
|
+
* is the inner edge of a trailing pane; a leading rail does not set this
|
|
36
|
+
* until an edge field exists.
|
|
37
|
+
*/
|
|
38
|
+
export const SlotResizeAxis = S.Literals(['x', 'y'])
|
|
39
|
+
export type SlotResizeAxis = typeof SlotResizeAxis.Type
|
|
40
|
+
|
|
30
41
|
/**
|
|
31
42
|
* One declared slot. `row` is the vertical band the slot occupies — slots
|
|
32
43
|
* sharing a row sit side by side (the fixture's sidebar / main / aside).
|
|
33
44
|
* `width` is a CSS length for a fixed `rail` (`'280px'`); `capacity` is how
|
|
34
|
-
* many placements the slot is meant to hold.
|
|
35
|
-
* `optional`) so the decoded
|
|
36
|
-
* `exactOptionalPropertyTypes`, matching the
|
|
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
|
|
47
|
+
* Type is exact-optional under `exactOptionalPropertyTypes`, matching the
|
|
48
|
+
* rest of the topology schemas.
|
|
37
49
|
*/
|
|
38
50
|
export const SlotSpec = S.Struct({
|
|
39
51
|
id: S.String,
|
|
@@ -41,6 +53,7 @@ export const SlotSpec = S.Struct({
|
|
|
41
53
|
row: S.Number,
|
|
42
54
|
width: S.optionalKey(S.String),
|
|
43
55
|
capacity: S.optionalKey(S.Number),
|
|
56
|
+
resize: S.optionalKey(SlotResizeAxis),
|
|
44
57
|
})
|
|
45
58
|
export type SlotSpec = typeof SlotSpec.Type
|
|
46
59
|
|
|
@@ -51,8 +64,22 @@ export type SlotSpec = typeof SlotSpec.Type
|
|
|
51
64
|
* degrade honestly — slot checks are reported unverifiable, never silently
|
|
52
65
|
* green (see `./placementChecks.ts`).
|
|
53
66
|
*/
|
|
67
|
+
/**
|
|
68
|
+
* A named layout preset the host may apply to a screen. `id` is the
|
|
69
|
+
* `data-layout` value; `hint` is chrome copy from the declaring host, not a
|
|
70
|
+
* human name for a MicroDot. Absent `layouts` is a host that has not
|
|
71
|
+
* declared presets — the Wiring toolbar hides the control rather than
|
|
72
|
+
* inventing four.
|
|
73
|
+
*/
|
|
74
|
+
export const SlotLayoutPreset = S.Struct({
|
|
75
|
+
id: S.String,
|
|
76
|
+
hint: S.optionalKey(S.String),
|
|
77
|
+
})
|
|
78
|
+
export type SlotLayoutPreset = typeof SlotLayoutPreset.Type
|
|
79
|
+
|
|
54
80
|
export const HostSlotManifest = S.Struct({
|
|
55
81
|
theme: S.Struct({ name: S.String, version: S.String }),
|
|
56
82
|
slots: S.Array(SlotSpec),
|
|
83
|
+
layouts: S.optionalKey(S.Array(SlotLayoutPreset)),
|
|
57
84
|
})
|
|
58
85
|
export type HostSlotManifest = typeof HostSlotManifest.Type
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import corpusInput from '../evals/topology-regressions.p5.v1.json' with { type: 'json' }
|
|
4
|
+
import {
|
|
5
|
+
decodeTopologyRegressionCorpusSync,
|
|
6
|
+
observeTopologyRegressionCase,
|
|
7
|
+
topologyRegressionSurfaces,
|
|
8
|
+
} from './topologyRegression.ts'
|
|
9
|
+
|
|
10
|
+
describe('Phase 5 topology regression corpus', () => {
|
|
11
|
+
test('keeps the promoted layout and wire slices exact', () => {
|
|
12
|
+
const corpus = decodeTopologyRegressionCorpusSync(corpusInput)
|
|
13
|
+
const surfaces = topologyRegressionSurfaces(corpus)
|
|
14
|
+
expect(corpus.cases).toHaveLength(3)
|
|
15
|
+
for (const item of corpus.cases) {
|
|
16
|
+
expect(
|
|
17
|
+
observeTopologyRegressionCase({ item, surfaces }),
|
|
18
|
+
item.id,
|
|
19
|
+
).toEqual(item.expected)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('rejects arbitrary corpus and tool identity labels', () => {
|
|
24
|
+
expect(() =>
|
|
25
|
+
decodeTopologyRegressionCorpusSync({
|
|
26
|
+
...corpusInput,
|
|
27
|
+
corpusVersion: 'p5.future',
|
|
28
|
+
}),
|
|
29
|
+
).toThrow()
|
|
30
|
+
expect(() =>
|
|
31
|
+
decodeTopologyRegressionCorpusSync({
|
|
32
|
+
...corpusInput,
|
|
33
|
+
toolVersion: 'compose-from-selection@99',
|
|
34
|
+
}),
|
|
35
|
+
).toThrow()
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Schema as S } from 'effect'
|
|
2
|
+
|
|
3
|
+
import type { ManifestTag } from '@bespokeagentics/microdots-element'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type ComposeFromSelectionInput,
|
|
7
|
+
composeFromSelection,
|
|
8
|
+
} from './composeFromSelection.ts'
|
|
9
|
+
import { SlotKind } from './slots.ts'
|
|
10
|
+
|
|
11
|
+
const WireValueType = S.Literals(['string', 'number', 'boolean', 'json'])
|
|
12
|
+
const Ownership = S.Literals(['dot', 'host-input', 'environment'])
|
|
13
|
+
|
|
14
|
+
export const TOPOLOGY_REGRESSION_CORPUS_ID =
|
|
15
|
+
'microdots.topology-regressions.p5' as const
|
|
16
|
+
export const TOPOLOGY_REGRESSION_CORPUS_VERSION = 'p5.1' as const
|
|
17
|
+
export const TOPOLOGY_REGRESSION_TOOL_VERSION =
|
|
18
|
+
'compose-from-selection@1' as const
|
|
19
|
+
|
|
20
|
+
const TopologyRegressionSurfaceV1 = S.Struct({
|
|
21
|
+
tag: S.NonEmptyString,
|
|
22
|
+
attributes: S.Array(
|
|
23
|
+
S.Struct({ name: S.NonEmptyString, ownership: Ownership }),
|
|
24
|
+
),
|
|
25
|
+
events: S.Array(
|
|
26
|
+
S.Struct({
|
|
27
|
+
name: S.NonEmptyString,
|
|
28
|
+
fields: S.Record(S.String, WireValueType),
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const TopologyRegressionInputV1 = S.Struct({
|
|
34
|
+
brief: S.optionalKey(S.NonEmptyString),
|
|
35
|
+
hostId: S.NonEmptyString,
|
|
36
|
+
hostLabel: S.NonEmptyString,
|
|
37
|
+
tags: S.NonEmptyArray(S.NonEmptyString),
|
|
38
|
+
layout: S.Literals(['together', 'per-tag']),
|
|
39
|
+
slotKind: SlotKind,
|
|
40
|
+
wires: S.Array(
|
|
41
|
+
S.Struct({
|
|
42
|
+
from: S.NonEmptyString,
|
|
43
|
+
event: S.NonEmptyString,
|
|
44
|
+
field: S.NonEmptyString,
|
|
45
|
+
to: S.NonEmptyString,
|
|
46
|
+
input: S.NonEmptyString,
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export const TopologyRegressionObservationV1 = S.Struct({
|
|
52
|
+
hostId: S.NonEmptyString,
|
|
53
|
+
hostLabel: S.NonEmptyString,
|
|
54
|
+
generateCount: S.Natural,
|
|
55
|
+
routePaths: S.Array(S.NonEmptyString),
|
|
56
|
+
mountedTags: S.Array(S.NonEmptyString),
|
|
57
|
+
slotKinds: S.Array(SlotKind),
|
|
58
|
+
wirePlain: S.Array(S.NonEmptyString),
|
|
59
|
+
})
|
|
60
|
+
export type TopologyRegressionObservationV1 =
|
|
61
|
+
typeof TopologyRegressionObservationV1.Type
|
|
62
|
+
|
|
63
|
+
export const TopologyRegressionCaseV1 = S.Struct({
|
|
64
|
+
schemaVersion: S.Literal(1),
|
|
65
|
+
id: S.NonEmptyString,
|
|
66
|
+
title: S.NonEmptyString,
|
|
67
|
+
input: TopologyRegressionInputV1,
|
|
68
|
+
expected: TopologyRegressionObservationV1,
|
|
69
|
+
})
|
|
70
|
+
export type TopologyRegressionCaseV1 = typeof TopologyRegressionCaseV1.Type
|
|
71
|
+
|
|
72
|
+
export const TopologyRegressionCorpusV1 = S.Struct({
|
|
73
|
+
schemaVersion: S.Literal(1),
|
|
74
|
+
id: S.Literal(TOPOLOGY_REGRESSION_CORPUS_ID),
|
|
75
|
+
corpusVersion: S.Literal(TOPOLOGY_REGRESSION_CORPUS_VERSION),
|
|
76
|
+
toolVersion: S.Literal(TOPOLOGY_REGRESSION_TOOL_VERSION),
|
|
77
|
+
surfaces: S.NonEmptyArray(TopologyRegressionSurfaceV1),
|
|
78
|
+
cases: S.NonEmptyArray(TopologyRegressionCaseV1),
|
|
79
|
+
}).check(
|
|
80
|
+
S.makeFilter(
|
|
81
|
+
corpus =>
|
|
82
|
+
new Set(corpus.cases.map(item => item.id)).size === corpus.cases.length,
|
|
83
|
+
{ expected: 'unique topology regression case ids' },
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
export type TopologyRegressionCorpusV1 = typeof TopologyRegressionCorpusV1.Type
|
|
87
|
+
|
|
88
|
+
export const decodeTopologyRegressionCorpusSync = S.decodeUnknownSync(
|
|
89
|
+
TopologyRegressionCorpusV1,
|
|
90
|
+
{ onExcessProperty: 'error' },
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
export const topologyRegressionSurfaces = (
|
|
94
|
+
corpus: TopologyRegressionCorpusV1,
|
|
95
|
+
): ReadonlyArray<ManifestTag> =>
|
|
96
|
+
corpus.surfaces.map(surface => ({
|
|
97
|
+
tag: surface.tag,
|
|
98
|
+
attributes: surface.attributes.map(attribute => ({
|
|
99
|
+
name: attribute.name,
|
|
100
|
+
type: 'string',
|
|
101
|
+
required: false,
|
|
102
|
+
live: true,
|
|
103
|
+
ownership: attribute.ownership,
|
|
104
|
+
})),
|
|
105
|
+
events: surface.events.map(event => ({
|
|
106
|
+
name: event.name,
|
|
107
|
+
payload: {
|
|
108
|
+
ref: `@microdots/topology-regression/contract#${event.name}`,
|
|
109
|
+
jsonSchema: {
|
|
110
|
+
dialect: 'draft-2020-12',
|
|
111
|
+
schema: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: Object.fromEntries(
|
|
114
|
+
Object.entries(event.fields).map(([name, type]) => [
|
|
115
|
+
name,
|
|
116
|
+
{ type },
|
|
117
|
+
]),
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
definitions: {},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
})),
|
|
124
|
+
}))
|
|
125
|
+
|
|
126
|
+
export const observeTopologyRegressionCase = (input: {
|
|
127
|
+
readonly item: TopologyRegressionCaseV1
|
|
128
|
+
readonly surfaces: ReadonlyArray<ManifestTag>
|
|
129
|
+
}): TopologyRegressionObservationV1 => {
|
|
130
|
+
const selection: ComposeFromSelectionInput = input.item.input
|
|
131
|
+
const spec = composeFromSelection(selection, input.surfaces)
|
|
132
|
+
return {
|
|
133
|
+
hostId: spec.topology.host.id,
|
|
134
|
+
hostLabel: spec.topology.host.label,
|
|
135
|
+
generateCount: spec.generate.length,
|
|
136
|
+
routePaths: spec.topology.routes.map(route => route.path),
|
|
137
|
+
mountedTags: spec.topology.routes.flatMap(route =>
|
|
138
|
+
route.mounts.map(mount => mount.tag),
|
|
139
|
+
),
|
|
140
|
+
slotKinds: spec.topology.slotManifest?.slots.map(slot => slot.kind) ?? [],
|
|
141
|
+
wirePlain: spec.topology.wires.map(wire => wire.plain),
|
|
142
|
+
}
|
|
143
|
+
}
|