@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.
@@ -0,0 +1,281 @@
1
+ import { BaseController } from './base.js';
2
+
3
+ /**
4
+ * DataStreamController — pushes live data to chart components.
5
+ *
6
+ * Accepts: SSE URL, WebSocket URL, async iterable, polling function, or manual push.
7
+ * Accumulates data points in a buffer with configurable max size.
8
+ * Each new point triggers notify() → chart re-renders.
9
+ *
10
+ * Usage:
11
+ * const ctrl = new DataStreamController({ max: 20 });
12
+ * chart.controller = ctrl;
13
+ *
14
+ * // Manual push
15
+ * ctrl.commands.push({ month: 'Jan', revenue: 4200 });
16
+ *
17
+ * // SSE
18
+ * ctrl.commands.connect('sse', '/api/metrics');
19
+ *
20
+ * // WebSocket
21
+ * ctrl.commands.connect('ws', 'wss://api.example.com/stream');
22
+ *
23
+ * // Polling
24
+ * ctrl.commands.poll('/api/latest', 2000);
25
+ *
26
+ * // Async iterable
27
+ * ctrl.commands.consume(asyncIterable);
28
+ *
29
+ * // Stop
30
+ * ctrl.commands.stop();
31
+ */
32
+ export class DataStreamController extends BaseController {
33
+ static schema = Object.freeze({
34
+ name: 'data-stream',
35
+ state: { data: 'array', status: 'string', error: 'string' },
36
+ commands: ['push', 'pushMany', 'connect', 'poll', 'consume', 'stop', 'clear'],
37
+ attributes: ['data-stream-status'],
38
+ });
39
+
40
+ #data = [];
41
+ #max;
42
+ #status = 'idle'; // idle | connecting | streaming | error | stopped
43
+ #error = null;
44
+ #abort = null;
45
+ #throttle = 0; // ms — min interval between notify() calls. 0 = every push.
46
+ #sample = 1; // keep every Nth point. 1 = keep all.
47
+ #pendingNotify = null;
48
+ #pushCount = 0; // total pushes since connect (for sampling)
49
+
50
+ /**
51
+ * @param {Object} opts
52
+ * @param {number} [opts.max=100] — Maximum data points to retain (FIFO)
53
+ * @param {number} [opts.throttle=0] — Min ms between render notifications. 0 = every push.
54
+ * @param {number} [opts.sample=1] — Keep every Nth point. 1 = keep all.
55
+ * @param {Array} [opts.initial] — Initial data
56
+ */
57
+ constructor({ max = 100, throttle = 0, sample = 1, initial = [] } = {}) {
58
+ super();
59
+ this.#max = max;
60
+ this.#throttle = throttle;
61
+ this.#sample = Math.max(1, Math.round(sample));
62
+ this.#data = initial.slice(-max);
63
+ }
64
+
65
+ getState() {
66
+ return {
67
+ data: this.#data,
68
+ status: this.#status,
69
+ error: this.#error,
70
+ };
71
+ }
72
+
73
+ reflect() {
74
+ if (!this.host) return;
75
+ this.host.setAttribute('data-stream-status', this.#status);
76
+ }
77
+
78
+ onDisconnect() {
79
+ this.commands.stop();
80
+ }
81
+
82
+ #append(point) {
83
+ // Sampling: skip points when sample > 1
84
+ this.#pushCount++;
85
+ if (this.#sample > 1 && this.#pushCount % this.#sample !== 0) return;
86
+
87
+ this.#data.push(point);
88
+ if (this.#data.length > this.#max) {
89
+ this.#data = this.#data.slice(-this.#max);
90
+ }
91
+ this.#scheduleNotify();
92
+ }
93
+
94
+ #appendMany(points) {
95
+ // Sampling: filter points when sample > 1
96
+ if (this.#sample > 1) {
97
+ const sampled = [];
98
+ for (const p of points) {
99
+ this.#pushCount++;
100
+ if (this.#pushCount % this.#sample === 0) sampled.push(p);
101
+ }
102
+ this.#data.push(...sampled);
103
+ } else {
104
+ this.#data.push(...points);
105
+ }
106
+ if (this.#data.length > this.#max) {
107
+ this.#data = this.#data.slice(-this.#max);
108
+ }
109
+ this.#scheduleNotify();
110
+ }
111
+
112
+ #scheduleNotify() {
113
+ // No throttle: immediate
114
+ if (this.#throttle <= 0) {
115
+ this.notify();
116
+ return;
117
+ }
118
+ // Throttled: batch into one notify per interval
119
+ if (this.#pendingNotify !== null) return;
120
+ this.#pendingNotify = setTimeout(() => {
121
+ this.#pendingNotify = null;
122
+ this.notify();
123
+ }, this.#throttle);
124
+ }
125
+
126
+ #setStatus(status, error = null) {
127
+ this.#status = status;
128
+ this.#error = error;
129
+ this.notify();
130
+ }
131
+
132
+ commands = {
133
+ /** Push a single data point */
134
+ push: (point) => {
135
+ this.#append(point);
136
+ },
137
+
138
+ /** Push multiple data points at once */
139
+ pushMany: (points) => {
140
+ this.#appendMany(points);
141
+ },
142
+
143
+ /** Connect to SSE or WebSocket stream */
144
+ connect: (type, url) => {
145
+ this.commands.stop();
146
+ this.#abort = new AbortController();
147
+
148
+ if (type === 'sse') {
149
+ this.#connectSSE(url);
150
+ } else if (type === 'ws') {
151
+ this.#connectWS(url);
152
+ } else {
153
+ this.#setStatus('error', `Unknown stream type: ${type}`);
154
+ }
155
+ },
156
+
157
+ /** Poll a URL at an interval (ms) */
158
+ poll: (url, interval = 2000) => {
159
+ this.commands.stop();
160
+ this.#abort = new AbortController();
161
+ this.#startPolling(url, interval);
162
+ },
163
+
164
+ /** Consume an async iterable */
165
+ consume: (iterable) => {
166
+ this.commands.stop();
167
+ this.#abort = new AbortController();
168
+ this.#consumeIterable(iterable);
169
+ },
170
+
171
+ /** Stop all active streams */
172
+ stop: () => {
173
+ if (this.#abort) {
174
+ this.#abort.abort();
175
+ this.#abort = null;
176
+ }
177
+ if (this.#pendingNotify !== null) {
178
+ clearTimeout(this.#pendingNotify);
179
+ this.#pendingNotify = null;
180
+ }
181
+ if (this.#status === 'streaming' || this.#status === 'connecting') {
182
+ this.#setStatus('stopped');
183
+ }
184
+ },
185
+
186
+ /** Clear all data */
187
+ clear: () => {
188
+ this.#data = [];
189
+ this.notify();
190
+ },
191
+ };
192
+
193
+ #connectSSE(url) {
194
+ this.#setStatus('connecting');
195
+ const es = new EventSource(url);
196
+ const signal = this.#abort.signal;
197
+
198
+ signal.addEventListener('abort', () => es.close());
199
+
200
+ es.onopen = () => this.#setStatus('streaming');
201
+
202
+ es.onmessage = (e) => {
203
+ try {
204
+ const point = JSON.parse(e.data);
205
+ this.#append(point);
206
+ } catch { /* skip malformed */ }
207
+ };
208
+
209
+ es.onerror = () => {
210
+ if (!signal.aborted) {
211
+ this.#setStatus('error', 'SSE connection lost');
212
+ es.close();
213
+ }
214
+ };
215
+ }
216
+
217
+ #connectWS(url) {
218
+ this.#setStatus('connecting');
219
+ const ws = new WebSocket(url);
220
+ const signal = this.#abort.signal;
221
+
222
+ signal.addEventListener('abort', () => ws.close());
223
+
224
+ ws.onopen = () => this.#setStatus('streaming');
225
+
226
+ ws.onmessage = (e) => {
227
+ try {
228
+ const msg = JSON.parse(e.data);
229
+ if (Array.isArray(msg)) this.#appendMany(msg);
230
+ else this.#append(msg);
231
+ } catch { /* skip malformed */ }
232
+ };
233
+
234
+ ws.onerror = () => {
235
+ if (!signal.aborted) {
236
+ this.#setStatus('error', 'WebSocket error');
237
+ }
238
+ };
239
+
240
+ ws.onclose = () => {
241
+ if (!signal.aborted) {
242
+ this.#setStatus('stopped');
243
+ }
244
+ };
245
+ }
246
+
247
+ async #startPolling(url, interval) {
248
+ this.#setStatus('streaming');
249
+ const signal = this.#abort.signal;
250
+
251
+ while (!signal.aborted) {
252
+ try {
253
+ const res = await fetch(url, { signal });
254
+ const json = await res.json();
255
+ if (Array.isArray(json)) this.#appendMany(json);
256
+ else this.#append(json);
257
+ } catch (e) {
258
+ if (signal.aborted) break;
259
+ this.#setStatus('error', e.message);
260
+ break;
261
+ }
262
+ await new Promise(r => setTimeout(r, interval));
263
+ }
264
+ }
265
+
266
+ async #consumeIterable(iterable) {
267
+ this.#setStatus('streaming');
268
+ const signal = this.#abort.signal;
269
+
270
+ try {
271
+ for await (const point of iterable) {
272
+ if (signal.aborted) break;
273
+ if (Array.isArray(point)) this.#appendMany(point);
274
+ else this.#append(point);
275
+ }
276
+ if (!signal.aborted) this.#setStatus('idle');
277
+ } catch (e) {
278
+ if (!signal.aborted) this.#setStatus('error', e.message);
279
+ }
280
+ }
281
+ }
@@ -0,0 +1,81 @@
1
+ import { BaseController } from './base.js';
2
+
3
+ /**
4
+ * Form controller — tracks field values, validation, and dirty state.
5
+ * Sets [data-form-valid], [data-form-invalid], [data-form-dirty] on host.
6
+ */
7
+ export class FormController extends BaseController {
8
+ static schema = Object.freeze({
9
+ name: 'form',
10
+ state: { values: 'object', errors: 'object', dirty: 'boolean', valid: 'boolean' },
11
+ commands: ['set', 'validate', 'reset', 'setInitial'],
12
+ attributes: ['data-form-valid', 'data-form-invalid', 'data-form-dirty'],
13
+ });
14
+
15
+ #initial = {};
16
+ #values = {};
17
+ #errors = {};
18
+ #validators = {};
19
+
20
+ constructor(initial = {}) {
21
+ super();
22
+ this.#initial = { ...initial };
23
+ this.#values = { ...initial };
24
+ }
25
+
26
+ onDisconnect(host) {
27
+ host.removeAttribute('data-form-valid');
28
+ host.removeAttribute('data-form-invalid');
29
+ host.removeAttribute('data-form-dirty');
30
+ }
31
+
32
+ getState() {
33
+ const dirty = Object.keys(this.#values).some(k => this.#values[k] !== this.#initial[k]);
34
+ const valid = Object.keys(this.#errors).length === 0;
35
+ return { values: { ...this.#values }, errors: { ...this.#errors }, dirty, valid };
36
+ }
37
+
38
+ reflect() {
39
+ if (!this.host) return;
40
+ const { dirty, valid } = this.getState();
41
+ if (valid) {
42
+ this.host.setAttribute('data-form-valid', '');
43
+ this.host.removeAttribute('data-form-invalid');
44
+ } else {
45
+ this.host.setAttribute('data-form-invalid', '');
46
+ this.host.removeAttribute('data-form-valid');
47
+ }
48
+ if (dirty) this.host.setAttribute('data-form-dirty', '');
49
+ else this.host.removeAttribute('data-form-dirty');
50
+ }
51
+
52
+ #runValidation(field) {
53
+ const fn = this.#validators[field];
54
+ if (!fn) { delete this.#errors[field]; return; }
55
+ const result = fn(this.#values[field]);
56
+ if (result === true || result == null) delete this.#errors[field];
57
+ else this.#errors[field] = typeof result === 'string' ? result : 'Invalid';
58
+ }
59
+
60
+ commands = {
61
+ set: (field, value) => {
62
+ this.#values[field] = value;
63
+ this.#runValidation(field);
64
+ this.notify();
65
+ },
66
+ validate: (field, fn) => {
67
+ this.#validators[field] = fn;
68
+ this.#runValidation(field);
69
+ this.notify();
70
+ },
71
+ reset: () => {
72
+ this.#values = { ...this.#initial };
73
+ this.#errors = {};
74
+ for (const field of Object.keys(this.#validators)) this.#runValidation(field);
75
+ this.notify();
76
+ },
77
+ setInitial: (values) => {
78
+ this.#initial = { ...values };
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,6 @@
1
+ export { BaseController } from './base.js';
2
+ export { ToggleController } from './toggle.js';
3
+ export { SelectionController } from './selection.js';
4
+ export { FormController } from './form.js';
5
+ export { AccordionController } from './accordion.js';
6
+ export { DataStreamController } from './data-stream.js';
@@ -0,0 +1,82 @@
1
+ import { BaseController } from './base.js';
2
+
3
+ /**
4
+ * Selection controller — manages single or multi-select state.
5
+ * Sets [data-selection-active] on host, [data-selection-selected] on items.
6
+ */
7
+ export class SelectionController extends BaseController {
8
+ static schema = Object.freeze({
9
+ name: 'selection',
10
+ state: { selected: 'Set', multiple: 'boolean' },
11
+ commands: ['select', 'deselect', 'toggle', 'clear', 'selectAll'],
12
+ attributes: ['data-selection-active', 'data-selection-selected'],
13
+ });
14
+
15
+ #selected = new Set();
16
+ #multiple = false;
17
+
18
+ constructor({ multiple = false, initial = [] } = {}) {
19
+ super();
20
+ this.#multiple = multiple;
21
+ for (const key of initial) this.#selected.add(String(key));
22
+ }
23
+
24
+ onConnect(host) {
25
+ host.setAttribute('data-selection-active', '');
26
+ }
27
+
28
+ onDisconnect(host) {
29
+ this.#clearReflection(host);
30
+ host.removeAttribute('data-selection-active');
31
+ }
32
+
33
+ getState() {
34
+ return { selected: new Set(this.#selected), multiple: this.#multiple };
35
+ }
36
+
37
+ reflect() {
38
+ if (!this.host) return;
39
+ const items = this.host.querySelectorAll('[data-selection-key]');
40
+ for (const item of items) {
41
+ const key = item.getAttribute('data-selection-key');
42
+ if (this.#selected.has(key)) item.setAttribute('data-selection-selected', '');
43
+ else item.removeAttribute('data-selection-selected');
44
+ }
45
+ }
46
+
47
+ #clearReflection(host) {
48
+ const items = host.querySelectorAll('[data-selection-selected]');
49
+ for (const item of items) item.removeAttribute('data-selection-selected');
50
+ }
51
+
52
+ commands = {
53
+ select: (key) => {
54
+ key = String(key);
55
+ if (!this.#multiple) this.#selected.clear();
56
+ this.#selected.add(key);
57
+ this.notify();
58
+ },
59
+ deselect: (key) => {
60
+ this.#selected.delete(String(key));
61
+ this.notify();
62
+ },
63
+ toggle: (key) => {
64
+ key = String(key);
65
+ if (this.#selected.has(key)) this.#selected.delete(key);
66
+ else {
67
+ if (!this.#multiple) this.#selected.clear();
68
+ this.#selected.add(key);
69
+ }
70
+ this.notify();
71
+ },
72
+ clear: () => {
73
+ this.#selected.clear();
74
+ this.notify();
75
+ },
76
+ selectAll: (keys) => {
77
+ if (!this.#multiple) return;
78
+ for (const k of keys) this.#selected.add(String(k));
79
+ this.notify();
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * StateMachineController — a minimal XState-lite state chart for A2UI wiring.
3
+ *
4
+ * The LLM declares states + transitions in wireComponents. The runtime
5
+ * tracks current state, exposes it to CSS via `[data-state="…"]` on the
6
+ * host, and fires `stateEntered` / `stateExited` events. Transitions are
7
+ * triggered via commands (`send` named events or `transition` to a state).
8
+ *
9
+ * Goals (vs. the rest of the wiring system):
10
+ * - Expands the LLM's output surface beyond pure structure: it can emit a
11
+ * multi-step wizard, a validation flow, a loading/ready/error trio, etc.,
12
+ * without writing behavior code.
13
+ * - Declarative only — the catalog schema bounds what a state machine can
14
+ * express. No arbitrary JS. Side effects are wiring actions (like every
15
+ * other AdiaUI interactive surface).
16
+ *
17
+ * What's supported in this first pass (subset of statechart semantics):
18
+ * - Finite states with named transitions (`on: { NEXT: "review" }`)
19
+ * - Initial state (`initial: "welcome"`)
20
+ * - `send(event)` to fire a transition
21
+ * - `transition(state)` to jump to a state unconditionally
22
+ * - CSS reflection via `data-state` on the host
23
+ * - Entry/exit custom events
24
+ *
25
+ * Not yet (deliberately): guards, actions on transitions, hierarchical
26
+ * states, parallel states, history. Add when real exemplars demand them.
27
+ */
28
+
29
+ import { BaseController } from './base.js';
30
+
31
+ export class StateMachineController extends BaseController {
32
+ static schema = {
33
+ name: 'state-machine',
34
+ state: {
35
+ current: 'string',
36
+ history: 'string[]',
37
+ },
38
+ commands: ['send', 'transition', 'reset'],
39
+ attributes: ['data-state'],
40
+ config: {
41
+ initial: 'string',
42
+ states: 'object', // { [name]: { on: { [event]: targetState } } }
43
+ },
44
+ };
45
+
46
+ #current = '';
47
+ #initial = '';
48
+ #states = {};
49
+ #history = [];
50
+
51
+ onConnect(host) {
52
+ // Config read from the controller declaration. Falls back to any
53
+ // data-initial/data-states on the host for hand-authored surfaces.
54
+ const config = this.config || {};
55
+ this.#initial = config.initial
56
+ || host.dataset?.initial
57
+ || Object.keys(config.states || {})[0]
58
+ || '';
59
+ this.#states = this._validateStates(config.states || {});
60
+ this.#current = this.#initial;
61
+ this.#history = this.#current ? [this.#current] : [];
62
+ if (this.#current) this._emit('stateEntered', { state: this.#current, initial: true });
63
+ }
64
+
65
+ /** Public state. */
66
+ getState() {
67
+ return { current: this.#current, history: [...this.#history] };
68
+ }
69
+
70
+ reflect() {
71
+ if (!this.host) return;
72
+ if (this.#current) this.host.setAttribute('data-state', this.#current);
73
+ else this.host.removeAttribute('data-state');
74
+ }
75
+
76
+ commands = {
77
+ /** Fire a named event; if current state defines a transition for it, move. */
78
+ send: (eventName) => {
79
+ if (!eventName || !this.#current) return;
80
+ const transitions = this.#states[this.#current]?.on || {};
81
+ const target = transitions[eventName];
82
+ if (!target) return; // unknown event in this state — silent no-op
83
+ this._moveTo(target, { event: eventName });
84
+ },
85
+
86
+ /** Unconditional jump to a declared state. */
87
+ transition: (target) => {
88
+ if (!target || !(target in this.#states)) return;
89
+ this._moveTo(target, { forced: true });
90
+ },
91
+
92
+ /** Return to initial state. Fires exit + enter events. */
93
+ reset: () => {
94
+ if (!this.#initial) return;
95
+ this._moveTo(this.#initial, { reset: true });
96
+ },
97
+ };
98
+
99
+ // ── internals ──
100
+
101
+ _moveTo(target, detail) {
102
+ if (target === this.#current) return;
103
+ const prev = this.#current;
104
+ if (prev) this._emit('stateExited', { state: prev, target, ...detail });
105
+ this.#current = target;
106
+ this.#history.push(target);
107
+ this.notify(); // triggers reflect() + subscribers
108
+ this._emit('stateEntered', { state: target, from: prev, ...detail });
109
+ }
110
+
111
+ _emit(name, detail) {
112
+ if (!this.host) return;
113
+ this.host.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
114
+ }
115
+
116
+ _validateStates(states) {
117
+ // Shallow schema check: each state entry is an object; `on` is an object
118
+ // mapping event names → target state strings that exist in the map.
119
+ const out = {};
120
+ const names = Object.keys(states);
121
+ for (const name of names) {
122
+ const def = states[name] || {};
123
+ const on = {};
124
+ for (const [ev, target] of Object.entries(def.on || {})) {
125
+ if (typeof target !== 'string' || !(target in states)) {
126
+ console.warn(`[state-machine] state "${name}" transition "${ev}" → "${target}" — target not declared`);
127
+ continue;
128
+ }
129
+ on[ev] = target;
130
+ }
131
+ out[name] = { on };
132
+ }
133
+ return out;
134
+ }
135
+ }
@@ -0,0 +1,40 @@
1
+ import { BaseController } from './base.js';
2
+
3
+ /**
4
+ * Toggle controller — manages on/off state.
5
+ * Sets [data-toggle-on] on host when active.
6
+ */
7
+ export class ToggleController extends BaseController {
8
+ static schema = Object.freeze({
9
+ name: 'toggle',
10
+ state: { on: 'boolean' },
11
+ commands: ['toggle', 'set'],
12
+ attributes: ['data-toggle-on'],
13
+ });
14
+
15
+ #on = false;
16
+
17
+ constructor(initial = false) {
18
+ super();
19
+ this.#on = initial;
20
+ }
21
+
22
+ getState() {
23
+ return { on: this.#on };
24
+ }
25
+
26
+ onDisconnect(host) {
27
+ host.removeAttribute('data-toggle-on');
28
+ }
29
+
30
+ reflect() {
31
+ if (!this.host) return;
32
+ if (this.#on) this.host.setAttribute('data-toggle-on', '');
33
+ else this.host.removeAttribute('data-toggle-on');
34
+ }
35
+
36
+ commands = {
37
+ toggle: () => { this.#on = !this.#on; this.notify(); },
38
+ set: (v) => { this.#on = !!v; this.notify(); },
39
+ };
40
+ }