@adia-ai/a2ui-runtime 0.3.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/CHANGELOG.md +215 -0
- package/README.md +87 -0
- package/controllers/accordion.js +73 -0
- package/controllers/base.js +68 -0
- package/controllers/data-stream.js +281 -0
- package/controllers/form.js +81 -0
- package/controllers/index.js +6 -0
- package/controllers/selection.js +82 -0
- package/controllers/state-machine.js +135 -0
- package/controllers/toggle.js +40 -0
- package/dockables/action.js +152 -0
- package/dockables/base.js +30 -0
- package/dockables/controller.js +97 -0
- package/dockables/data-source.js +103 -0
- package/dockables/index.js +6 -0
- package/dockables/lifecycle.js +84 -0
- package/dockables/provider.js +59 -0
- package/index.js +45 -0
- package/package.json +31 -0
- package/registry.js +205 -0
- package/renderer.js +395 -0
- package/stream.js +243 -0
- package/surface-manifest.js +294 -0
- package/surface.js +222 -0
- package/wire-factory.js +134 -0
- package/wiring-engine.js +209 -0
- package/wiring-registry.js +342 -0
package/surface.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface — A dock host where typed objects connect and disconnect cleanly.
|
|
3
|
+
*
|
|
4
|
+
* A Surface is the runtime representation of a rendered UI region.
|
|
5
|
+
* It holds a data model, resolved params, and a registry of docked objects
|
|
6
|
+
* (controllers, data sources, actions, providers, lifecycle hooks).
|
|
7
|
+
*
|
|
8
|
+
* Dockables attach via dock(surface) and clean up via undock().
|
|
9
|
+
* The Surface doesn't know what dockables do internally — it just manages
|
|
10
|
+
* their lifecycle and provides a shared context.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── Dock order (lowest docks first, undocks last) ──
|
|
14
|
+
const DOCK_ORDER = { provider: 0, controller: 1, source: 2, action: 3, lifecycle: 4 };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} DockEntry
|
|
18
|
+
* @property {import('./dockables/base.js').Dockable} dockable
|
|
19
|
+
* @property {Function|null} cleanup — returned from dock()
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export class Surface {
|
|
23
|
+
/** @type {string} */
|
|
24
|
+
surfaceId;
|
|
25
|
+
|
|
26
|
+
/** @type {Map<string, DockEntry>} keyed by dockable.id */
|
|
27
|
+
#docked = new Map();
|
|
28
|
+
|
|
29
|
+
/** @type {object} reactive-ish data model */
|
|
30
|
+
#model = {};
|
|
31
|
+
|
|
32
|
+
/** @type {object} resolved params (route, store, literal) */
|
|
33
|
+
#params = {};
|
|
34
|
+
|
|
35
|
+
/** @type {Map<string, Set<Function>>} model watchers keyed by path */
|
|
36
|
+
#watchers = new Map();
|
|
37
|
+
|
|
38
|
+
/** @type {HTMLElement} surface root element */
|
|
39
|
+
#rootElement;
|
|
40
|
+
|
|
41
|
+
/** @type {Map<string, HTMLElement>} componentId → DOM element */
|
|
42
|
+
#elements;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} surfaceId
|
|
46
|
+
* @param {HTMLElement} rootElement — the surface's root DOM node
|
|
47
|
+
* @param {Map<string, HTMLElement>} elements — componentId → element map
|
|
48
|
+
*/
|
|
49
|
+
constructor(surfaceId, rootElement, elements) {
|
|
50
|
+
this.surfaceId = surfaceId;
|
|
51
|
+
this.#rootElement = rootElement;
|
|
52
|
+
this.#elements = elements;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Context (passed to dockables) ─────────────────────────
|
|
56
|
+
|
|
57
|
+
/** @returns {SurfaceContext} */
|
|
58
|
+
get context() {
|
|
59
|
+
return {
|
|
60
|
+
surfaceId: this.surfaceId,
|
|
61
|
+
getElement: (id) => this.#elements.get(id) || null,
|
|
62
|
+
getRootElement: () => this.#rootElement,
|
|
63
|
+
getModel: (path) => path ? getPath(this.#model, path) : this.#model,
|
|
64
|
+
setModel: (path, value) => this.#setModel(path, value),
|
|
65
|
+
getDockable: (kind, id) => this.#getDockable(kind, id),
|
|
66
|
+
listDockables: (kind) => this.#listDockables(kind),
|
|
67
|
+
emit: (adiaEvent) => this.#emit(adiaEvent),
|
|
68
|
+
getParam: (key) => this.#params[key],
|
|
69
|
+
watchModel: (path, fn) => this.#watchModel(path, fn),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Dock Protocol ─────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Dock a new object. Calls dockable.dock(context).
|
|
77
|
+
* @param {import('./dockables/base.js').Dockable} dockable
|
|
78
|
+
*/
|
|
79
|
+
dock(dockable) {
|
|
80
|
+
// If same id already docked, redock (hot-swap)
|
|
81
|
+
if (this.#docked.has(dockable.id)) {
|
|
82
|
+
this.undock(dockable.id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const cleanup = dockable.dock(this.context);
|
|
86
|
+
this.#docked.set(dockable.id, {
|
|
87
|
+
dockable,
|
|
88
|
+
cleanup: typeof cleanup === 'function' ? cleanup : null,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Dock multiple objects in dependency order.
|
|
94
|
+
* @param {import('./dockables/base.js').Dockable[]} dockables
|
|
95
|
+
*/
|
|
96
|
+
dockAll(dockables) {
|
|
97
|
+
const sorted = [...dockables].sort(
|
|
98
|
+
(a, b) => (DOCK_ORDER[a.kind] ?? 9) - (DOCK_ORDER[b.kind] ?? 9)
|
|
99
|
+
);
|
|
100
|
+
for (const d of sorted) this.dock(d);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Undock by id. Calls cleanup then dockable.undock().
|
|
105
|
+
* @param {string} id
|
|
106
|
+
*/
|
|
107
|
+
undock(id) {
|
|
108
|
+
const entry = this.#docked.get(id);
|
|
109
|
+
if (!entry) return;
|
|
110
|
+
entry.cleanup?.();
|
|
111
|
+
entry.dockable.undock();
|
|
112
|
+
this.#docked.delete(id);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Undock everything in reverse dependency order.
|
|
117
|
+
*/
|
|
118
|
+
undockAll() {
|
|
119
|
+
const entries = [...this.#docked.entries()].sort(
|
|
120
|
+
(a, b) => (DOCK_ORDER[b[1].dockable.kind] ?? 9) - (DOCK_ORDER[a[1].dockable.kind] ?? 9)
|
|
121
|
+
);
|
|
122
|
+
for (const [id] of entries) this.undock(id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Undock specific ids.
|
|
127
|
+
* @param {string[]} ids
|
|
128
|
+
*/
|
|
129
|
+
undockMany(ids) {
|
|
130
|
+
for (const id of ids) this.undock(id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Model ─────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/** Set initial model state (before docking). */
|
|
136
|
+
setInitialModel(model) {
|
|
137
|
+
Object.assign(this.#model, model);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Set resolved params. */
|
|
141
|
+
setParams(params) {
|
|
142
|
+
Object.assign(this.#params, params);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Update element map (after re-render). */
|
|
146
|
+
updateElements(elements) {
|
|
147
|
+
this.#elements = elements;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Private ───────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
#setModel(path, value) {
|
|
153
|
+
setPath(this.#model, path, value);
|
|
154
|
+
// Notify watchers for this path and any parent paths
|
|
155
|
+
for (const [watchPath, fns] of this.#watchers) {
|
|
156
|
+
if (path === watchPath || path.startsWith(watchPath + '/')) {
|
|
157
|
+
for (const fn of fns) fn(getPath(this.#model, watchPath));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#watchModel(path, fn) {
|
|
163
|
+
if (!this.#watchers.has(path)) this.#watchers.set(path, new Set());
|
|
164
|
+
this.#watchers.get(path).add(fn);
|
|
165
|
+
return () => {
|
|
166
|
+
const set = this.#watchers.get(path);
|
|
167
|
+
set?.delete(fn);
|
|
168
|
+
if (set?.size === 0) this.#watchers.delete(path);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#getDockable(kind, id) {
|
|
173
|
+
const entry = this.#docked.get(id);
|
|
174
|
+
if (entry && entry.dockable.kind === kind) return entry.dockable;
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#listDockables(kind) {
|
|
179
|
+
const result = [];
|
|
180
|
+
for (const { dockable } of this.#docked.values()) {
|
|
181
|
+
if (!kind || dockable.kind === kind) result.push(dockable);
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#emit(adiaEvent) {
|
|
187
|
+
const target = adiaEvent.target
|
|
188
|
+
? this.#elements.get(adiaEvent.target)
|
|
189
|
+
: this.#rootElement;
|
|
190
|
+
if (!target) return;
|
|
191
|
+
|
|
192
|
+
target.dispatchEvent(new CustomEvent(adiaEvent.event, {
|
|
193
|
+
bubbles: true,
|
|
194
|
+
detail: adiaEvent,
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── JSON Pointer helpers (simplified, "/" delimited) ────────
|
|
200
|
+
|
|
201
|
+
function getPath(obj, path) {
|
|
202
|
+
if (!path || path === '/') return obj;
|
|
203
|
+
const keys = path.replace(/^\//, '').split('/');
|
|
204
|
+
let current = obj;
|
|
205
|
+
for (const key of keys) {
|
|
206
|
+
if (current == null) return undefined;
|
|
207
|
+
current = current[key];
|
|
208
|
+
}
|
|
209
|
+
return current;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function setPath(obj, path, value) {
|
|
213
|
+
if (!path || path === '/') return;
|
|
214
|
+
const keys = path.replace(/^\//, '').split('/');
|
|
215
|
+
let current = obj;
|
|
216
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
217
|
+
const key = keys[i];
|
|
218
|
+
if (current[key] == null) current[key] = {};
|
|
219
|
+
current = current[key];
|
|
220
|
+
}
|
|
221
|
+
current[keys[keys.length - 1]] = value;
|
|
222
|
+
}
|
package/wire-factory.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire Factory — Translates wireComponents messages into typed Dockable objects.
|
|
3
|
+
*
|
|
4
|
+
* This is the bridge between the LLM's declarative JSON and the runtime dock system.
|
|
5
|
+
* Each section of the wireComponents message maps to a Dockable type:
|
|
6
|
+
*
|
|
7
|
+
* data.sources → DataSourceDock[]
|
|
8
|
+
* state.controllers → ControllerDock[]
|
|
9
|
+
* actions → ActionDock[]
|
|
10
|
+
* provides → ProviderDock
|
|
11
|
+
* lifecycle → LifecycleDock
|
|
12
|
+
* state.model → set directly on Surface
|
|
13
|
+
*/
|
|
14
|
+
import { ControllerDock } from './dockables/controller.js';
|
|
15
|
+
import { DataSourceDock } from './dockables/data-source.js';
|
|
16
|
+
import { ActionDock } from './dockables/action.js';
|
|
17
|
+
import { ProviderDock } from './dockables/provider.js';
|
|
18
|
+
import { LifecycleDock } from './dockables/lifecycle.js';
|
|
19
|
+
import { resolveController, resolveData, resolveHandler } from './wiring-registry.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Adapt a registry handler (old ctx shape) to the dock signature (config, surfaceCtx, domEvent).
|
|
23
|
+
* The old handlers read from ctx.action.*, ctx.event, ctx.source, ctx.dataModel, ctx.updateModel, etc.
|
|
24
|
+
* The new signature passes (config, surfaceCtx, domEventOrResult).
|
|
25
|
+
*/
|
|
26
|
+
function adaptHandler(handlerFn) {
|
|
27
|
+
return async (config, surfaceCtx, domEventOrResult) => {
|
|
28
|
+
// Build a backward-compatible HandlerContext from the new args
|
|
29
|
+
const ctx = {
|
|
30
|
+
// Config props spread as "action" (old handlers read ctx.action.path, ctx.action.uri, etc.)
|
|
31
|
+
action: config,
|
|
32
|
+
// DOM event if present
|
|
33
|
+
event: domEventOrResult instanceof Event ? domEventOrResult : null,
|
|
34
|
+
source: domEventOrResult instanceof Event ? domEventOrResult.target : null,
|
|
35
|
+
// Model access
|
|
36
|
+
dataModel: surfaceCtx.getModel(),
|
|
37
|
+
updateModel: (path, value) => surfaceCtx.setModel(path, value),
|
|
38
|
+
// Controllers lookup
|
|
39
|
+
controllers: Object.fromEntries(
|
|
40
|
+
surfaceCtx.listDockables('controller').map(d => [d.id, d.controller])
|
|
41
|
+
),
|
|
42
|
+
// Params
|
|
43
|
+
params: {},
|
|
44
|
+
// Data resolver
|
|
45
|
+
resolveData,
|
|
46
|
+
};
|
|
47
|
+
return handlerFn(ctx);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a handler resolver function that looks up from the registry
|
|
53
|
+
* and wraps with the adapter.
|
|
54
|
+
* @returns {(name: string) => Function|null}
|
|
55
|
+
*/
|
|
56
|
+
function createHandlerResolver() {
|
|
57
|
+
const cache = new Map();
|
|
58
|
+
return (name) => {
|
|
59
|
+
if (cache.has(name)) return cache.get(name);
|
|
60
|
+
const raw = resolveHandler(name);
|
|
61
|
+
if (!raw) return null;
|
|
62
|
+
const adapted = adaptHandler(raw);
|
|
63
|
+
cache.set(name, adapted);
|
|
64
|
+
return adapted;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a controller class resolver (async, lazy-loaded).
|
|
70
|
+
* @returns {(type: string) => Promise<Function|null>}
|
|
71
|
+
*/
|
|
72
|
+
function createControllerResolver() {
|
|
73
|
+
return async (type) => resolveController(type);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a data resolver that uses the wiring registry.
|
|
78
|
+
* @returns {(uri: string, ctx: object) => Promise<*>}
|
|
79
|
+
*/
|
|
80
|
+
function createDataResolver() {
|
|
81
|
+
return async (uri, ctx) => resolveData(uri, {});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Translate a wireComponents message into an array of Dockable objects.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} msg — wireComponents message
|
|
88
|
+
* @returns {{ dockables: import('./dockables/base.js').Dockable[], initialModel: object|null }}
|
|
89
|
+
*/
|
|
90
|
+
export function createDockables(msg) {
|
|
91
|
+
const dockables = [];
|
|
92
|
+
const handlerResolver = createHandlerResolver();
|
|
93
|
+
const controllerResolver = createControllerResolver();
|
|
94
|
+
const dataResolver = createDataResolver();
|
|
95
|
+
|
|
96
|
+
// Data sources
|
|
97
|
+
if (msg.data?.sources) {
|
|
98
|
+
for (const source of msg.data.sources) {
|
|
99
|
+
dockables.push(new DataSourceDock(source, dataResolver));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Controllers
|
|
104
|
+
if (msg.state?.controllers) {
|
|
105
|
+
for (const ctrl of msg.state.controllers) {
|
|
106
|
+
dockables.push(new ControllerDock(ctrl, controllerResolver));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Actions
|
|
111
|
+
if (msg.actions) {
|
|
112
|
+
for (const action of msg.actions) {
|
|
113
|
+
dockables.push(new ActionDock(action, handlerResolver));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Provides
|
|
118
|
+
if (msg.provides) {
|
|
119
|
+
const provides = Array.isArray(msg.provides) ? msg.provides : [msg.provides];
|
|
120
|
+
for (const p of provides) {
|
|
121
|
+
dockables.push(new ProviderDock(p));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Lifecycle
|
|
126
|
+
if (msg.lifecycle) {
|
|
127
|
+
dockables.push(new LifecycleDock(msg.lifecycle, handlerResolver));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Initial model (not a dockable — set directly on Surface)
|
|
131
|
+
const initialModel = msg.state?.model || null;
|
|
132
|
+
|
|
133
|
+
return { dockables, initialModel };
|
|
134
|
+
}
|
package/wiring-engine.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiring Engine — Processes wireComponents messages via the Surface dock system.
|
|
3
|
+
*
|
|
4
|
+
* This is the bridge between the renderer (which receives raw messages)
|
|
5
|
+
* and the Surface (which manages dockable objects). It:
|
|
6
|
+
*
|
|
7
|
+
* 1. Creates/retrieves a Surface for the surfaceId
|
|
8
|
+
* 2. Resolves params from the message
|
|
9
|
+
* 3. Translates the message sections into typed Dockables via wire-factory
|
|
10
|
+
* 4. Docks them in dependency order on the Surface
|
|
11
|
+
*
|
|
12
|
+
* The external API is unchanged: process(msg), teardown(surfaceId), teardownAll().
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Surface } from './surface.js';
|
|
16
|
+
import { createDockables } from './wire-factory.js';
|
|
17
|
+
|
|
18
|
+
export class WiringEngine {
|
|
19
|
+
/** @type {Map<string, Surface>} */
|
|
20
|
+
#surfaces = new Map();
|
|
21
|
+
|
|
22
|
+
/** @type {(surfaceId: string, path: string, data: unknown) => void} */
|
|
23
|
+
#updateDataModel;
|
|
24
|
+
|
|
25
|
+
/** @type {(surfaceId: string, componentId: string) => HTMLElement | null} */
|
|
26
|
+
#getElement;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {(surfaceId: string, path: string, data: unknown) => void} opts.updateDataModel
|
|
31
|
+
* @param {(surfaceId: string, componentId: string) => HTMLElement | null} opts.getElement
|
|
32
|
+
*/
|
|
33
|
+
constructor({ updateDataModel, getElement }) {
|
|
34
|
+
this.#updateDataModel = updateDataModel;
|
|
35
|
+
this.#getElement = getElement;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Process a wireComponents message.
|
|
40
|
+
* Multiple calls for the same surfaceId are additive (new dockables added,
|
|
41
|
+
* same-id dockables hot-swapped).
|
|
42
|
+
*
|
|
43
|
+
* @param {object} message
|
|
44
|
+
*/
|
|
45
|
+
async process(message) {
|
|
46
|
+
const { surfaceId } = message;
|
|
47
|
+
if (!surfaceId) return;
|
|
48
|
+
|
|
49
|
+
let surface = this.#surfaces.get(surfaceId);
|
|
50
|
+
if (!surface) {
|
|
51
|
+
surface = this.#createSurface(surfaceId);
|
|
52
|
+
this.#surfaces.set(surfaceId, surface);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Resolve params (before creating dockables, they may need params)
|
|
57
|
+
if (message.data?.params) {
|
|
58
|
+
surface.setParams(this.#resolveParams(message.data.params));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Translate message → dockables
|
|
62
|
+
const { dockables, initialModel } = createDockables(message);
|
|
63
|
+
|
|
64
|
+
// Set initial model state before docking
|
|
65
|
+
if (initialModel) {
|
|
66
|
+
surface.setInitialModel(initialModel);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Handle explicit undocking
|
|
70
|
+
if (message.undock) {
|
|
71
|
+
surface.undockMany(message.undock);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Dock all in dependency order
|
|
75
|
+
// Some dockables have async dock() (controllers), so we dock sequentially
|
|
76
|
+
// by kind to maintain order guarantees
|
|
77
|
+
for (const dockable of dockables) {
|
|
78
|
+
await surface.dock(dockable);
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.warn(`Wiring: error processing wireComponents for "${surfaceId}":`, err.message);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Tear down all wiring for a surface.
|
|
87
|
+
* @param {string} surfaceId
|
|
88
|
+
*/
|
|
89
|
+
teardown(surfaceId) {
|
|
90
|
+
const surface = this.#surfaces.get(surfaceId);
|
|
91
|
+
if (!surface) return;
|
|
92
|
+
surface.undockAll();
|
|
93
|
+
this.#surfaces.delete(surfaceId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Tear down everything.
|
|
98
|
+
*/
|
|
99
|
+
teardownAll() {
|
|
100
|
+
for (const surfaceId of this.#surfaces.keys()) this.teardown(surfaceId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get a surface for inspection/testing.
|
|
105
|
+
* @param {string} surfaceId
|
|
106
|
+
* @returns {Surface|null}
|
|
107
|
+
*/
|
|
108
|
+
getSurface(surfaceId) {
|
|
109
|
+
return this.#surfaces.get(surfaceId) || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get a wired surface's state (for debugging/inspection).
|
|
114
|
+
* @param {string} surfaceId
|
|
115
|
+
*/
|
|
116
|
+
getSurfaceState(surfaceId) {
|
|
117
|
+
const surface = this.#surfaces.get(surfaceId);
|
|
118
|
+
if (!surface) return null;
|
|
119
|
+
const dockables = surface.context.listDockables();
|
|
120
|
+
return {
|
|
121
|
+
surfaceId,
|
|
122
|
+
controllerCount: dockables.filter(d => d.kind === 'controller').length,
|
|
123
|
+
sourceCount: dockables.filter(d => d.kind === 'source').length,
|
|
124
|
+
actionCount: dockables.filter(d => d.kind === 'action').length,
|
|
125
|
+
providerCount: dockables.filter(d => d.kind === 'provider').length,
|
|
126
|
+
hasLifecycle: dockables.some(d => d.kind === 'lifecycle'),
|
|
127
|
+
totalDocked: dockables.length,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Private ────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create a Surface wired to the renderer's element map.
|
|
135
|
+
*/
|
|
136
|
+
#createSurface(surfaceId) {
|
|
137
|
+
// Create an element proxy that delegates to the renderer's getElement
|
|
138
|
+
const elementsProxy = {
|
|
139
|
+
get: (componentId) => this.#getElement(surfaceId, componentId),
|
|
140
|
+
has: (componentId) => !!this.#getElement(surfaceId, componentId),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// The root element is the surface container in the renderer
|
|
144
|
+
const rootElement = this.#getElement(surfaceId, 'root') || document.createElement('div');
|
|
145
|
+
|
|
146
|
+
// Build a Map-like wrapper so Surface can use elements.get()
|
|
147
|
+
const elements = new Proxy(new Map(), {
|
|
148
|
+
get(target, prop) {
|
|
149
|
+
if (prop === 'get') return (id) => elementsProxy.get(id);
|
|
150
|
+
if (prop === 'has') return (id) => elementsProxy.has(id);
|
|
151
|
+
return target[prop];
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Wrap updateDataModel so dockables that call ctx.setModel
|
|
156
|
+
// also flow through to the renderer's data binding system
|
|
157
|
+
const surface = new Surface(surfaceId, rootElement, elements);
|
|
158
|
+
|
|
159
|
+
// Monkey-patch setModel on context to also notify the renderer
|
|
160
|
+
const originalContext = surface.context;
|
|
161
|
+
const origSetModel = originalContext.setModel;
|
|
162
|
+
const updateDataModel = this.#updateDataModel;
|
|
163
|
+
Object.defineProperty(surface, 'context', {
|
|
164
|
+
get() {
|
|
165
|
+
const ctx = originalContext;
|
|
166
|
+
return {
|
|
167
|
+
...ctx,
|
|
168
|
+
setModel(path, value) {
|
|
169
|
+
origSetModel(path, value);
|
|
170
|
+
updateDataModel(surfaceId, path, value);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return surface;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve parameter declarations to values.
|
|
181
|
+
* @param {Record<string, { from: string, key: string }>} params
|
|
182
|
+
* @returns {Record<string, string>}
|
|
183
|
+
*/
|
|
184
|
+
#resolveParams(params) {
|
|
185
|
+
const resolved = {};
|
|
186
|
+
for (const [name, spec] of Object.entries(params)) {
|
|
187
|
+
switch (spec.from) {
|
|
188
|
+
case 'route': {
|
|
189
|
+
const hash = location.hash.slice(1);
|
|
190
|
+
const match = hash.match(new RegExp(`${spec.key}/([^/]+)`));
|
|
191
|
+
if (match) resolved[name] = match[1];
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case 'query': {
|
|
195
|
+
const url = new URL(location.href);
|
|
196
|
+
const val = url.searchParams.get(spec.key);
|
|
197
|
+
if (val) resolved[name] = val;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case 'literal':
|
|
201
|
+
resolved[name] = spec.value;
|
|
202
|
+
break;
|
|
203
|
+
default:
|
|
204
|
+
if (typeof spec === 'string') resolved[name] = spec;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return resolved;
|
|
208
|
+
}
|
|
209
|
+
}
|