@bespokeagentics/create-microdots-host 0.1.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.
Files changed (60) hide show
  1. package/LICENSE +202 -0
  2. package/package.json +26 -0
  3. package/src/cli.ts +50 -0
  4. package/src/index.ts +215 -0
  5. package/templates/base/README.md +49 -0
  6. package/templates/base/_gitignore +7 -0
  7. package/templates/base/host/index.html +33 -0
  8. package/templates/base/host/package.json +10 -0
  9. package/templates/base/host/public/themes/neutral-dark.css +109 -0
  10. package/templates/base/host/public/themes/neutral.css +109 -0
  11. package/templates/base/host/src/main.ts +202 -0
  12. package/templates/base/host/src/topology.test.ts +42 -0
  13. package/templates/base/host/topology.json +84 -0
  14. package/templates/base/host/vite.base.ts +41 -0
  15. package/templates/base/microdots/pulse/package.json +20 -0
  16. package/templates/base/microdots/pulse/service/handlers.ts +36 -0
  17. package/templates/base/microdots/pulse/service/main.ts +30 -0
  18. package/templates/base/microdots/pulse/src/app.ts +275 -0
  19. package/templates/base/microdots/pulse/src/client.ts +14 -0
  20. package/templates/base/microdots/pulse/src/contract.ts +32 -0
  21. package/templates/base/microdots/pulse/src/element.ts +24 -0
  22. package/templates/base/microdots/pulse/src/entry.ts +1 -0
  23. package/templates/base/microdots/pulse/src/surface.ts +30 -0
  24. package/templates/base/microdots/pulse/src/tokens.css +30 -0
  25. package/templates/base/microdots/scope-picker/package.json +20 -0
  26. package/templates/base/microdots/scope-picker/service/handlers.ts +15 -0
  27. package/templates/base/microdots/scope-picker/service/main.ts +30 -0
  28. package/templates/base/microdots/scope-picker/src/app.ts +256 -0
  29. package/templates/base/microdots/scope-picker/src/client.ts +18 -0
  30. package/templates/base/microdots/scope-picker/src/contract.ts +27 -0
  31. package/templates/base/microdots/scope-picker/src/element.ts +21 -0
  32. package/templates/base/microdots/scope-picker/src/entry.ts +1 -0
  33. package/templates/base/microdots/scope-picker/src/surface.ts +23 -0
  34. package/templates/base/microdots/scope-picker/src/tokens.css +30 -0
  35. package/templates/base/scripts/build-output.test.ts +71 -0
  36. package/templates/base/scripts/build-output.ts +28 -0
  37. package/templates/base/scripts/build.ts +37 -0
  38. package/templates/base/scripts/check-tokens.ts +63 -0
  39. package/templates/base/scripts/dev.ts +78 -0
  40. package/templates/base/scripts/discover.test.ts +133 -0
  41. package/templates/base/scripts/discover.ts +137 -0
  42. package/templates/base/scripts/dot-vite.ts +28 -0
  43. package/templates/base/scripts/emit-manifests.ts +154 -0
  44. package/templates/base/tsconfig.json +18 -0
  45. package/templates/base/types.d.ts +9 -0
  46. package/templates/base/vitest.config.ts +8 -0
  47. package/templates/plain/host/src/styles.css +46 -0
  48. package/templates/plain/host/vite.config.template.ts +4 -0
  49. package/templates/plain/microdots/pulse/src/styles.css +17 -0
  50. package/templates/plain/microdots/pulse/vite.config.template.ts +4 -0
  51. package/templates/plain/microdots/scope-picker/src/styles.css +19 -0
  52. package/templates/plain/microdots/scope-picker/vite.config.template.ts +4 -0
  53. package/templates/plain/package.json +39 -0
  54. package/templates/tailwind/host/src/styles.css +28 -0
  55. package/templates/tailwind/host/vite.config.template.ts +5 -0
  56. package/templates/tailwind/microdots/pulse/src/styles.css +24 -0
  57. package/templates/tailwind/microdots/pulse/vite.config.template.ts +5 -0
  58. package/templates/tailwind/microdots/scope-picker/src/styles.css +25 -0
  59. package/templates/tailwind/microdots/scope-picker/vite.config.template.ts +5 -0
  60. package/templates/tailwind/package.json +41 -0
@@ -0,0 +1,109 @@
1
+ :root,
2
+ [data-theme='neutral-dark'] {
3
+ color-scheme: dark;
4
+ --font-body: Inter, ui-sans-serif, system-ui, sans-serif;
5
+ --font-display: Inter, ui-sans-serif, system-ui, sans-serif;
6
+ --font-mono: 'SFMono-Regular', Consolas, monospace;
7
+ --fw-light: 300;
8
+ --fw-regular: 400;
9
+ --fw-medium: 500;
10
+ --fw-semibold: 600;
11
+ --fw-bold: 700;
12
+ --fs-caption: 0.75rem;
13
+ --fs-body-s: 0.82rem;
14
+ --fs-body-m: 0.92rem;
15
+ --fs-body-l: 1.08rem;
16
+ --fs-button: 0.88rem;
17
+ --fs-button-sm: 0.78rem;
18
+ --fs-button-lg: 0.98rem;
19
+ --fs-input: 0.9rem;
20
+ --fs-label: 0.75rem;
21
+ --fs-display-m: 1.5rem;
22
+ --fs-display-l: 2.5rem;
23
+ --fs-display-xl: 3.5rem;
24
+ --fs-mono: 0.86rem;
25
+ --fs-mono-sm: 0.76rem;
26
+ --lh-tight: 1.1;
27
+ --lh-display: 1.15;
28
+ --lh-heading: 1.25;
29
+ --lh-body: 1.55;
30
+ --lh-relaxed: 1.7;
31
+ --lh-code: 1.6;
32
+ --ls-tight: -0.01em;
33
+ --ls-normal: 0;
34
+ --ls-wide: 0.05em;
35
+ --ls-wider: 0.08em;
36
+ --ls-widest: 0.12em;
37
+ --ls-ultra: 0.16em;
38
+ --radius-sm: 0.35rem;
39
+ --radius-md: 0.55rem;
40
+ --radius-lg: 0.8rem;
41
+ --radius-xl: 1rem;
42
+ --radius-2xl: 1.25rem;
43
+ --radius-full: 9999px;
44
+ --gutter: 2rem;
45
+ --text-primary: #edf1f7;
46
+ --text-secondary: #b6c0cf;
47
+ --text-muted: #8d99aa;
48
+ --text-heading: #ffffff;
49
+ --text-link: #aeb8ff;
50
+ --text-link-hover: #ccd2ff;
51
+ --text-on-brand: #ffffff;
52
+ --text-wordmark: #dce1ff;
53
+ --text-accent: #b7c0ff;
54
+ --text-success: #77d99f;
55
+ --text-warning: #f0c56f;
56
+ --text-error: #ff9999;
57
+ --surface-page: #11151c;
58
+ --surface-default: #181e27;
59
+ --surface-raised: #202733;
60
+ --surface-elevated: #252d3a;
61
+ --surface-overlay: #2b3442;
62
+ --border-default: #3b4657;
63
+ --border-subtle: #2e3745;
64
+ --border-accent: #7181da;
65
+ --border-strong: #59677a;
66
+ --accent-default: #7181da;
67
+ --accent-strong: #93a1f0;
68
+ --accent-subtle: #272f58;
69
+ --accent-muted: #394474;
70
+ --accent-label: #b7c0ff;
71
+ --accent-fill: #596bd0;
72
+ --accent-fill-hover: #7181da;
73
+ --int-ghost: rgb(147 161 240 / 0.05);
74
+ --int-subtle: rgb(147 161 240 / 0.09);
75
+ --int-hover: rgb(147 161 240 / 0.14);
76
+ --int-active: rgb(147 161 240 / 0.2);
77
+ --int-glow: rgb(147 161 240 / 0.28);
78
+ --status-success-fg: #8be4ae;
79
+ --status-success-bg: #153624;
80
+ --status-success-border: #2f6f48;
81
+ --status-warning-fg: #f3ca77;
82
+ --status-warning-bg: #3b2d14;
83
+ --status-warning-border: #745722;
84
+ --status-error-fg: #ffabab;
85
+ --status-error-bg: #421e22;
86
+ --status-error-border: #7b383f;
87
+ --status-info-fg: #b8c4ff;
88
+ --status-info-bg: #232c51;
89
+ --status-info-border: #4b5b9d;
90
+ --brand-200: #cdd4ff;
91
+ --brand-300: #adb8f5;
92
+ --brand-400: #93a1f0;
93
+ --brand-500: #7181da;
94
+ --brand-600: #596bd0;
95
+ --brand-700: #4355b9;
96
+ --green: #77d99f;
97
+ --green-deep: #2f9b61;
98
+ --amber: #f0c56f;
99
+ --amber-deep: #d79324;
100
+ --red: #ff9999;
101
+ --red-deep: #d95555;
102
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.24);
103
+ --shadow-md: 0 8px 20px rgb(0 0 0 / 0.3);
104
+ --shadow-lg: 0 20px 48px rgb(0 0 0 / 0.38);
105
+ --shadow-card: 0 12px 32px rgb(0 0 0 / 0.28);
106
+ --shadow-glow: 0 0 0 5px rgb(147 161 240 / 0.16);
107
+ --shadow-focus: 0 0 0 3px rgb(147 161 240 / 0.34);
108
+ --shadow-button-hover: 0 7px 18px rgb(0 0 0 / 0.3);
109
+ }
@@ -0,0 +1,109 @@
1
+ :root,
2
+ [data-theme='neutral'] {
3
+ color-scheme: light;
4
+ --font-body: Inter, ui-sans-serif, system-ui, sans-serif;
5
+ --font-display: Inter, ui-sans-serif, system-ui, sans-serif;
6
+ --font-mono: 'SFMono-Regular', Consolas, monospace;
7
+ --fw-light: 300;
8
+ --fw-regular: 400;
9
+ --fw-medium: 500;
10
+ --fw-semibold: 600;
11
+ --fw-bold: 700;
12
+ --fs-caption: 0.75rem;
13
+ --fs-body-s: 0.82rem;
14
+ --fs-body-m: 0.92rem;
15
+ --fs-body-l: 1.08rem;
16
+ --fs-button: 0.88rem;
17
+ --fs-button-sm: 0.78rem;
18
+ --fs-button-lg: 0.98rem;
19
+ --fs-input: 0.9rem;
20
+ --fs-label: 0.75rem;
21
+ --fs-display-m: 1.5rem;
22
+ --fs-display-l: 2.5rem;
23
+ --fs-display-xl: 3.5rem;
24
+ --fs-mono: 0.86rem;
25
+ --fs-mono-sm: 0.76rem;
26
+ --lh-tight: 1.1;
27
+ --lh-display: 1.15;
28
+ --lh-heading: 1.25;
29
+ --lh-body: 1.55;
30
+ --lh-relaxed: 1.7;
31
+ --lh-code: 1.6;
32
+ --ls-tight: -0.01em;
33
+ --ls-normal: 0;
34
+ --ls-wide: 0.05em;
35
+ --ls-wider: 0.08em;
36
+ --ls-widest: 0.12em;
37
+ --ls-ultra: 0.16em;
38
+ --radius-sm: 0.35rem;
39
+ --radius-md: 0.55rem;
40
+ --radius-lg: 0.8rem;
41
+ --radius-xl: 1rem;
42
+ --radius-2xl: 1.25rem;
43
+ --radius-full: 9999px;
44
+ --gutter: 2rem;
45
+ --text-primary: #18202b;
46
+ --text-secondary: #465466;
47
+ --text-muted: #69778a;
48
+ --text-heading: #111827;
49
+ --text-link: #4355b9;
50
+ --text-link-hover: #2f3f9a;
51
+ --text-on-brand: #ffffff;
52
+ --text-wordmark: #25306f;
53
+ --text-accent: #4355b9;
54
+ --text-success: #17633b;
55
+ --text-warning: #82520b;
56
+ --text-error: #a12b2b;
57
+ --surface-page: #f4f6f9;
58
+ --surface-default: #ffffff;
59
+ --surface-raised: #f8fafc;
60
+ --surface-elevated: #ffffff;
61
+ --surface-overlay: #ffffff;
62
+ --border-default: #d5dce6;
63
+ --border-subtle: #e7ebf0;
64
+ --border-accent: #8d9ce8;
65
+ --border-strong: #9da9b8;
66
+ --accent-default: #5264c7;
67
+ --accent-strong: #35469f;
68
+ --accent-subtle: #e8ebff;
69
+ --accent-muted: #cdd4ff;
70
+ --accent-label: #35469f;
71
+ --accent-fill: #5264c7;
72
+ --accent-fill-hover: #4355b9;
73
+ --int-ghost: rgb(82 100 199 / 0.04);
74
+ --int-subtle: rgb(82 100 199 / 0.08);
75
+ --int-hover: rgb(82 100 199 / 0.12);
76
+ --int-active: rgb(82 100 199 / 0.17);
77
+ --int-glow: rgb(82 100 199 / 0.24);
78
+ --status-success-fg: #17633b;
79
+ --status-success-bg: #e8f7ee;
80
+ --status-success-border: #9bd5b1;
81
+ --status-warning-fg: #82520b;
82
+ --status-warning-bg: #fff5dc;
83
+ --status-warning-border: #e8c879;
84
+ --status-error-fg: #a12b2b;
85
+ --status-error-bg: #fff0f0;
86
+ --status-error-border: #efaaaa;
87
+ --status-info-fg: #2c478f;
88
+ --status-info-bg: #edf2ff;
89
+ --status-info-border: #aebce8;
90
+ --brand-200: #cdd4ff;
91
+ --brand-300: #adb8f5;
92
+ --brand-400: #8d9ce8;
93
+ --brand-500: #5264c7;
94
+ --brand-600: #4355b9;
95
+ --brand-700: #35469f;
96
+ --green: #2f9b61;
97
+ --green-deep: #17633b;
98
+ --amber: #d79324;
99
+ --amber-deep: #82520b;
100
+ --red: #d95555;
101
+ --red-deep: #a12b2b;
102
+ --shadow-sm: 0 1px 2px rgb(17 24 39 / 0.06);
103
+ --shadow-md: 0 6px 18px rgb(17 24 39 / 0.09);
104
+ --shadow-lg: 0 18px 42px rgb(17 24 39 / 0.13);
105
+ --shadow-card: 0 10px 28px rgb(17 24 39 / 0.08);
106
+ --shadow-glow: 0 0 0 5px rgb(82 100 199 / 0.14);
107
+ --shadow-focus: 0 0 0 3px rgb(82 100 199 / 0.3);
108
+ --shadow-button-hover: 0 6px 16px rgb(67 85 185 / 0.24);
109
+ }
@@ -0,0 +1,202 @@
1
+ import {
2
+ HostTopology,
3
+ attachWireEngine,
4
+ createMicroDotFromEntry,
5
+ ensureSection,
6
+ ensureSlot,
7
+ findEntry,
8
+ hrefFor,
9
+ loadMicroDot,
10
+ mountOverridesFor,
11
+ pendingMounts,
12
+ resolvePlacements,
13
+ routeForHash,
14
+ setMicroDotAttribute,
15
+ topologyRouteTable,
16
+ unroutedTags,
17
+ } from '@bespokeagentics/microdots-host'
18
+ import type { RouteDefinition } from '@bespokeagentics/microdots-host'
19
+ import { Schema as S } from 'effect'
20
+ import { registry } from 'virtual:microdots-registry'
21
+
22
+ import topologyDocument from '../topology.json'
23
+ import './styles.css'
24
+
25
+ const topology = S.decodeUnknownSync(HostTopology)(topologyDocument)
26
+ const routeTable = topologyRouteTable(topology)
27
+
28
+ const element = (id: string): HTMLElement => {
29
+ const found = document.getElementById(id)
30
+ if (found === null) throw new Error(`[host] #${id} is missing`)
31
+ return found
32
+ }
33
+
34
+ const oneSection = (route: RouteDefinition): string => {
35
+ const [sectionId, ...extra] = route.sectionIds
36
+ if (sectionId === undefined || extra.length > 0) {
37
+ throw new Error(
38
+ `[host] starter routes declare exactly one section; ${route.path} has ${route.sectionIds.length}`,
39
+ )
40
+ }
41
+ return sectionId
42
+ }
43
+
44
+ const unrouted = unroutedTags(
45
+ routeTable,
46
+ registry.map(entry => entry.tag),
47
+ )
48
+ if (unrouted.length > 0) {
49
+ throw new Error(`[host] registered tags with no route: ${unrouted.join(', ')}`)
50
+ }
51
+
52
+ const manifestSlots = new Set(
53
+ (topology.slotManifest?.slots ?? []).map(slot => slot.id),
54
+ )
55
+ const generatedRoot = element('generated-sections')
56
+ const sectionElements = new Map<string, HTMLElement>()
57
+ const slotSections = new Map<string, string>()
58
+
59
+ routeTable.routes.forEach(route => {
60
+ const sectionId = oneSection(route)
61
+ const section = ensureSection(sectionId, generatedRoot, 'route-section')
62
+ sectionElements.set(sectionId, section)
63
+ resolvePlacements(topology, route.path, 'dev')
64
+ .filter(placement => placement.state !== 'overridden')
65
+ .forEach(placement => {
66
+ if (!manifestSlots.has(placement.slotId)) {
67
+ throw new Error(`[host] slot ${placement.slotId} is absent from slotManifest`)
68
+ }
69
+ const previous = slotSections.get(placement.slotId)
70
+ if (previous !== undefined && previous !== sectionId) {
71
+ throw new Error(
72
+ `[host] slot ${placement.slotId} is assigned to both ${previous} and ${sectionId}`,
73
+ )
74
+ }
75
+ slotSections.set(placement.slotId, sectionId)
76
+ ensureSlot(placement.slotId, section, 'microdot-slot')
77
+ })
78
+ })
79
+
80
+ const unusedSlots = [...manifestSlots].filter(slot => !slotSections.has(slot))
81
+ if (unusedSlots.length > 0) {
82
+ throw new Error(`[host] slotManifest has unused slots: ${unusedSlots.join(', ')}`)
83
+ }
84
+
85
+ const nav = element('route-nav')
86
+ const navLinks = new Map<string, HTMLAnchorElement>()
87
+ routeTable.routes.forEach(route => {
88
+ const link = document.createElement('a')
89
+ link.href = hrefFor(route)
90
+ link.textContent = route.label
91
+ link.className = 'shell-nav__link'
92
+ nav.appendChild(link)
93
+ navLinks.set(route.path, link)
94
+ })
95
+
96
+ const mountedTags = new Set<string>()
97
+ const mountedElements = new Map<string, HTMLElement>()
98
+ let currentTheme = 'neutral'
99
+
100
+ const panel = (slot: HTMLElement, title: string, detail: string, error = false): void => {
101
+ const wrapper = document.createElement('div')
102
+ wrapper.className = error ? 'placeholder placeholder--error' : 'placeholder'
103
+ const heading = document.createElement('h2')
104
+ heading.textContent = title
105
+ const copy = document.createElement('p')
106
+ copy.textContent = detail
107
+ wrapper.append(heading, copy)
108
+ slot.replaceChildren(wrapper)
109
+ }
110
+
111
+ const mountRoute = async (route: RouteDefinition): Promise<void> => {
112
+ const pending = pendingMounts(
113
+ resolvePlacements(topology, route.path, 'dev'),
114
+ mountedTags,
115
+ )
116
+ pending.forEach(placement => mountedTags.add(placement.tag))
117
+
118
+ const outcomes = await Promise.allSettled(
119
+ pending.map(async placement => {
120
+ const slot = element(placement.slotId)
121
+ const entry = findEntry(registry, placement.tag)
122
+ if (entry === undefined) {
123
+ panel(
124
+ slot,
125
+ `<${placement.tag}>`,
126
+ 'Routed, not built. Add this dot when the route is ready.',
127
+ )
128
+ return
129
+ }
130
+ try {
131
+ await loadMicroDot(entry.bundle, entry.tag)
132
+ const mounted = createMicroDotFromEntry(
133
+ entry,
134
+ location.hostname,
135
+ mountOverridesFor(placement.values, { theme: currentTheme }),
136
+ )
137
+ slot.replaceChildren(mounted)
138
+ mountedElements.set(entry.tag, mounted)
139
+ } catch (error) {
140
+ panel(
141
+ slot,
142
+ `<${entry.tag}> failed to load`,
143
+ error instanceof Error ? error.message : String(error),
144
+ true,
145
+ )
146
+ }
147
+ }),
148
+ )
149
+ outcomes.forEach(outcome => {
150
+ if (outcome.status === 'rejected') console.error('[host] mount failed', outcome.reason)
151
+ })
152
+ }
153
+
154
+ const applyRoute = (): void => {
155
+ const route = routeForHash(routeTable, location.hash)
156
+ const visible = new Set(route.sectionIds)
157
+ sectionElements.forEach((section, id) => {
158
+ section.hidden = !visible.has(id)
159
+ })
160
+ navLinks.forEach((link, path) => {
161
+ if (path === route.path) link.setAttribute('aria-current', 'page')
162
+ else link.removeAttribute('aria-current')
163
+ })
164
+ document.title = route.title
165
+ void mountRoute(route)
166
+ }
167
+
168
+ const wireLog = element('wire-log')
169
+ attachWireEngine({
170
+ topology,
171
+ env: 'dev',
172
+ onTrace: trace => {
173
+ wireLog.textContent =
174
+ trace._tag === 'watch'
175
+ ? `watch ${trace.event}: ${JSON.stringify(trace.payload)}`
176
+ : `wire ${trace.wireId}: ${trace.delivery._tag}`
177
+ },
178
+ })
179
+
180
+ const themeSwitcherNode = element('theme-switcher')
181
+ const themeLinkNode = element('active-theme')
182
+ if (!(themeSwitcherNode instanceof HTMLSelectElement)) {
183
+ throw new Error('[host] #theme-switcher must be a select')
184
+ }
185
+ if (!(themeLinkNode instanceof HTMLLinkElement)) {
186
+ throw new Error('[host] #active-theme must be a link')
187
+ }
188
+ const themeSwitcher = themeSwitcherNode
189
+ const themeLink = themeLinkNode
190
+ const applyTheme = (): void => {
191
+ currentTheme = themeSwitcher.value
192
+ document.documentElement.setAttribute('data-theme', currentTheme)
193
+ themeLink.href = `/themes/${currentTheme}.css`
194
+ mountedElements.forEach(mounted => {
195
+ setMicroDotAttribute(mounted, 'theme', currentTheme)
196
+ })
197
+ }
198
+ themeSwitcher.addEventListener('change', applyTheme)
199
+ window.addEventListener('hashchange', applyRoute)
200
+
201
+ applyTheme()
202
+ applyRoute()
@@ -0,0 +1,42 @@
1
+ import { Schema as S } from 'effect'
2
+ import { describe, expect, test } from 'vitest'
3
+
4
+ import {
5
+ HostTopology,
6
+ topologyRouteTable,
7
+ unroutedTags,
8
+ } from '@bespokeagentics/microdots-host'
9
+
10
+ import { listMicroDots } from '../../scripts/discover.ts'
11
+ import topologyDocument from '../topology.json'
12
+
13
+ const topology = S.decodeUnknownSync(HostTopology)(topologyDocument)
14
+
15
+ describe('starter topology', () => {
16
+ test('omits the aggregate overview and uses / as fallback', () => {
17
+ expect(topology.overview).toBeUndefined()
18
+ expect(topologyRouteTable(topology).fallback.path).toBe('/')
19
+ })
20
+
21
+ test('uses one section per route so generated slots have one owner', () => {
22
+ expect(topology.routes.every(route => route.sectionIds.length === 1)).toBe(
23
+ true,
24
+ )
25
+ })
26
+
27
+ test('routes every registered starter tag', () => {
28
+ const tags = listMicroDots().flatMap(dot => dot.tags)
29
+ expect(unroutedTags(topologyRouteTable(topology), tags)).toEqual([])
30
+ })
31
+
32
+ test('declares the direct scope-to-pulse wire', () => {
33
+ const tags = listMicroDots().flatMap(dot => dot.tags)
34
+ const [wire] = topology.wires
35
+ expect(topology.wires).toHaveLength(1)
36
+ expect(wire?.transform._tag).toBe('direct')
37
+ expect(wire?.fieldType).toBe('string')
38
+ expect(wire?.inputType).toBe('string')
39
+ expect(tags).toContain(wire?.from)
40
+ expect(tags).toContain(wire?.to)
41
+ })
42
+ })
@@ -0,0 +1,84 @@
1
+ {
2
+ "host": {
3
+ "id": "starter-host",
4
+ "label": "MicroDots Host Starter",
5
+ "ownedInputs": []
6
+ },
7
+ "routes": [
8
+ {
9
+ "path": "/",
10
+ "label": "Live demo",
11
+ "title": "Live demo · MicroDots Host Starter",
12
+ "sectionIds": ["section-demo"],
13
+ "mounts": [
14
+ {
15
+ "id": "scope-picker-main",
16
+ "tag": "starter-scope-picker",
17
+ "slotId": "scope-picker-slot",
18
+ "order": 1
19
+ },
20
+ {
21
+ "id": "pulse-main",
22
+ "tag": "starter-pulse",
23
+ "slotId": "pulse-slot",
24
+ "values": { "scope-id": "general" },
25
+ "order": 2
26
+ }
27
+ ]
28
+ },
29
+ {
30
+ "path": "/reports",
31
+ "label": "Reports",
32
+ "title": "Reports · MicroDots Host Starter",
33
+ "sectionIds": ["section-reports"],
34
+ "mounts": [
35
+ {
36
+ "id": "reports-future",
37
+ "tag": "starter-reports",
38
+ "slotId": "reports-slot",
39
+ "order": 1
40
+ }
41
+ ]
42
+ },
43
+ {
44
+ "path": "/settings",
45
+ "label": "Settings",
46
+ "title": "Settings · MicroDots Host Starter",
47
+ "sectionIds": ["section-settings"],
48
+ "mounts": [
49
+ {
50
+ "id": "settings-future",
51
+ "tag": "starter-settings",
52
+ "slotId": "settings-slot",
53
+ "order": 1
54
+ }
55
+ ]
56
+ }
57
+ ],
58
+ "slotManifest": {
59
+ "theme": { "name": "starter-neutral", "version": "1.0.0" },
60
+ "slots": [
61
+ { "id": "scope-picker-slot", "kind": "band", "row": 1, "capacity": 1 },
62
+ { "id": "pulse-slot", "kind": "band", "row": 1, "capacity": 1 },
63
+ { "id": "reports-slot", "kind": "band", "row": 1, "capacity": 1 },
64
+ { "id": "settings-slot", "kind": "band", "row": 1, "capacity": 1 }
65
+ ]
66
+ },
67
+ "rules": [],
68
+ "wires": [
69
+ {
70
+ "id": "scope-to-pulse",
71
+ "from": "starter-scope-picker",
72
+ "event": "scope-selected",
73
+ "field": "id",
74
+ "fieldType": "string",
75
+ "to": "starter-pulse",
76
+ "input": "scope-id",
77
+ "inputType": "string",
78
+ "transform": { "_tag": "direct" },
79
+ "envs": ["dev", "preview", "prod"],
80
+ "plain": "When a scope is selected, reconfigure the pulse readout."
81
+ }
82
+ ],
83
+ "watch": [{ "event": "heartbeat-observed" }]
84
+ }
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import type { Plugin, UserConfig } from 'vite'
5
+
6
+ import { microDotRegistryPlugin } from '../scripts/discover.ts'
7
+
8
+ const BUNDLE_ROUTE = '/microdots/'
9
+ const bundleDirectory = fileURLToPath(
10
+ new URL('./public/microdots', import.meta.url),
11
+ )
12
+
13
+ const serveMicroDotBundles = (): Plugin => ({
14
+ name: 'starter:serve-microdot-bundles',
15
+ apply: 'serve',
16
+ configureServer(server) {
17
+ server.middlewares.use((request, response, next) => {
18
+ const url = request.url ?? ''
19
+ if (!url.startsWith(BUNDLE_ROUTE)) {
20
+ next()
21
+ return
22
+ }
23
+ const fileName = url.slice(BUNDLE_ROUTE.length).split('?')[0] ?? ''
24
+ const filePath = join(bundleDirectory, fileName)
25
+ if (fileName === '' || fileName.includes('..') || !existsSync(filePath)) {
26
+ next()
27
+ return
28
+ }
29
+ response.setHeader('Content-Type', 'text/javascript; charset=utf-8')
30
+ response.setHeader('Cache-Control', 'no-store')
31
+ response.end(readFileSync(filePath))
32
+ })
33
+ },
34
+ })
35
+
36
+ export const hostConfig = (plugins: ReadonlyArray<Plugin> = []): UserConfig => ({
37
+ plugins: [microDotRegistryPlugin(), serveMicroDotBundles(), ...plugins],
38
+ server: { port: 5190, strictPort: true },
39
+ preview: { port: 5190, strictPort: true },
40
+ build: { target: 'esnext' },
41
+ })
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@starter/pulse",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "microdot": {
7
+ "tags": ["starter-pulse"],
8
+ "port": 3202,
9
+ "bundle": "pulse.js"
10
+ },
11
+ "exports": { "./contract": "./src/contract.ts" },
12
+ "dependencies": {
13
+ "@bespokeagentics/microdots-element": "0.1.1",
14
+ "@bespokeagentics/microdots-runtime": "0.1.1",
15
+ "@bespokeagentics/microdots-theme": "0.1.1",
16
+ "@effect/platform-node": "4.0.0-rc.108",
17
+ "effect": "4.0.0-rc.108",
18
+ "foldkit": "^0.144.0"
19
+ }
20
+ }
@@ -0,0 +1,36 @@
1
+ import { Context, Effect, Layer } from 'effect'
2
+
3
+ import { type Heartbeat, PulseRpcs } from '../src/contract.ts'
4
+
5
+ export class PulseCounter extends Context.Service<
6
+ PulseCounter,
7
+ { readonly next: (scopeId: string) => Effect.Effect<number> }
8
+ >()('PulseCounter') {}
9
+
10
+ export const PulseCounterLive = Layer.sync(PulseCounter, () => {
11
+ const sequences = new Map<string, number>()
12
+ return {
13
+ next: scopeId =>
14
+ Effect.sync(() => {
15
+ const sequence = (sequences.get(scopeId) ?? 0) + 1
16
+ sequences.set(scopeId, sequence)
17
+ return sequence
18
+ }),
19
+ }
20
+ })
21
+
22
+ export const PulseHandlersLive = PulseRpcs.toLayer({
23
+ GetHeartbeat: ({ scopeId }) =>
24
+ Effect.gen(function* () {
25
+ const counter = yield* PulseCounter
26
+ const sequence = yield* counter.next(scopeId)
27
+ const heartbeat: Heartbeat = {
28
+ id: `${scopeId}-${sequence}`,
29
+ sequence,
30
+ observedAt: new Date().toISOString(),
31
+ service: 'pulse@3202',
32
+ scopeId,
33
+ }
34
+ return heartbeat
35
+ }),
36
+ }).pipe(Layer.provide(PulseCounterLive))
@@ -0,0 +1,30 @@
1
+ import { Layer } from 'effect'
2
+ import { RpcSerialization, RpcServer } from 'effect/unstable/rpc'
3
+ import {
4
+ makeRpcService,
5
+ runRpcService,
6
+ } from '@bespokeagentics/microdots-runtime/service'
7
+
8
+ import { PulseRpcs } from '../src/contract.ts'
9
+ import { PulseHandlersLive } from './handlers.ts'
10
+
11
+ const rpcLayer = RpcServer.layerHttp({
12
+ group: PulseRpcs,
13
+ path: '/rpc',
14
+ // Omitting this silently selects WebSocket while the browser POSTs HTTP.
15
+ protocol: 'http',
16
+ }).pipe(
17
+ Layer.provide(PulseHandlersLive),
18
+ Layer.provide(RpcSerialization.layerNdjson),
19
+ )
20
+
21
+ runRpcService(
22
+ makeRpcService({
23
+ rpcLayer,
24
+ port: 3202,
25
+ allowedOrigins: [
26
+ 'http://localhost:5190',
27
+ 'http://127.0.0.1:5190',
28
+ ],
29
+ }),
30
+ )