@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/package.json +6 -6
- package/src/anchorMount.test.ts +124 -0
- package/src/anchorMount.ts +153 -0
- package/src/fillComposition.ts +1 -1
- package/src/index.ts +56 -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/registry.test.ts +289 -0
- package/src/registry.ts +158 -0
- package/src/registryFromManifest.test.ts +126 -0
- package/src/registryFromManifest.ts +92 -0
- 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 +60 -7
- package/src/wireEngine.test.ts +1 -1
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: '
|
|
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
|
+
})
|