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