@adia-ai/web-components 0.0.14 → 0.0.16

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 (41) hide show
  1. package/README.md +43 -1
  2. package/components/alert/alert.css +5 -0
  3. package/components/alert/alert.js +4 -2
  4. package/components/button/button.js +4 -1
  5. package/components/chart/chart.js +7 -4
  6. package/components/chat/chat-input.js +13 -2
  7. package/components/description-list/description-list.js +4 -3
  8. package/components/field/field.css +113 -63
  9. package/components/field/field.js +44 -142
  10. package/components/icon/icon.a2ui.json +1 -1
  11. package/components/icon/icon.css +16 -0
  12. package/components/icon/icon.js +18 -0
  13. package/components/icon/icon.yaml +6 -2
  14. package/components/index.js +7 -0
  15. package/components/input/input.a2ui.json +1 -1
  16. package/components/input/input.css +21 -23
  17. package/components/input/input.js +36 -9
  18. package/components/input/input.yaml +3 -1
  19. package/components/option-card/option-card.a2ui.json +262 -0
  20. package/components/option-card/option-card.css +215 -0
  21. package/components/option-card/option-card.js +158 -0
  22. package/components/option-card/option-card.yaml +234 -0
  23. package/components/rating/rating.a2ui.json +10 -0
  24. package/components/rating/rating.yaml +8 -0
  25. package/components/segment/segment.a2ui.json +5 -0
  26. package/components/segment/segment.css +2 -0
  27. package/components/segment/segment.js +21 -1
  28. package/components/segment/segment.yaml +5 -0
  29. package/components/textarea/textarea.css +3 -1
  30. package/components/textarea/textarea.js +2 -2
  31. package/components/tooltip/tooltip.js +10 -3
  32. package/core/data-stream.js +486 -0
  33. package/core/form.js +5 -0
  34. package/core/index.js +2 -0
  35. package/core/streams-bridge.js +96 -0
  36. package/package.json +1 -1
  37. package/styles/colors/semantics.css +21 -3
  38. package/styles/components.css +1 -0
  39. package/styles/prose.css +3 -7
  40. package/styles/tokens.css +7 -4
  41. package/styles/typography.css +6 -1
@@ -0,0 +1,486 @@
1
+ /**
2
+ * data-stream — attribute-driven data ingestion, signal-backed.
3
+ *
4
+ * Architecture (signal-based, refcounted):
5
+ *
6
+ * ┌──────────────────────────────────────────────────────────┐
7
+ * │ STREAMS: Map<streamId, { signal, refs, transport, opts }>│
8
+ * └──────────────────────────────────────────────────────────┘
9
+ * │ first ref creates transport
10
+ * │ each tick / message: signal.value = parsed
11
+ * │ last ref ends transport
12
+ * ▼
13
+ * ┌──────────────────────────────────────────────────────────┐
14
+ * │ Per-element effect: │
15
+ * │ effect(() => applyData(el, stream.signal.value, opts)) │
16
+ * │ Subscribes to the shared signal — fires on every update. │
17
+ * └──────────────────────────────────────────────────────────┘
18
+ *
19
+ * Stream identity:
20
+ * - Explicit `data-stream-id="..."` shares across elements.
21
+ * - When unset, the id is a stable hash of (src, mode, interval,
22
+ * method, body, headers, format, event). Two elements with
23
+ * attribute-identical configs share one transport automatically.
24
+ *
25
+ * Universal applicability — any element with a settable property:
26
+ * <chart-ui data-stream-src="/api/series" data-stream-path="data">
27
+ * <table-ui data-stream-src="/api/orders" data-stream-interval="5000">
28
+ * <stat-ui data-stream-src="/api/kpi" data-stream-target="*">
29
+ * <text-ui data-stream-src="/api/range" data-stream-target="textContent">
30
+ * <heatmap-ui data-stream-src="/sse/cells" data-stream-mode="sse">
31
+ *
32
+ * Programmatic access: `streams` is exported as a read-only Map of
33
+ * stream-id → signal so app code can subscribe with `effect()` or read
34
+ * `.value` directly.
35
+ *
36
+ * Events (all bubble from the element):
37
+ * stream-load — first signal value received for this element
38
+ * stream-update — each subsequent value, detail.data = the new value
39
+ * stream-error — transport-level error, detail.error = message
40
+ */
41
+
42
+ import { signal, effect, untracked } from './signals.js';
43
+
44
+ const ATTRS = {
45
+ src: 'data-stream-src',
46
+ mode: 'data-stream-mode',
47
+ interval: 'data-stream-interval',
48
+ path: 'data-stream-path',
49
+ event: 'data-stream-event',
50
+ method: 'data-stream-method',
51
+ headers: 'data-stream-headers',
52
+ body: 'data-stream-body',
53
+ target: 'data-stream-target',
54
+ merge: 'data-stream-merge',
55
+ format: 'data-stream-format',
56
+ id: 'data-stream-id',
57
+ };
58
+
59
+ const STREAMS = new Map(); /* streamId → { signal, refs, transport, opts } */
60
+ const ELEMENT_STREAMS = new WeakMap(); /* el → { streamId, dispose, loaded } */
61
+ const PENDING = new Map(); /* streamId → array of whenStream resolvers */
62
+ const WARNED = new WeakSet();
63
+
64
+ /* Read-only public view of the streams registry. App code can do:
65
+ `import { streams } from '@adia-ai/web-components/core/data-stream';
66
+ streams.get('rev').signal.value` to read; subscribe via effect(). */
67
+ export const streams = {
68
+ get(id) { return STREAMS.get(id); },
69
+ has(id) { return STREAMS.has(id); },
70
+ keys() { return STREAMS.keys(); },
71
+ size() { return STREAMS.size; },
72
+ };
73
+
74
+ /* Resolves with the stream entry once it's registered. If the stream
75
+ already exists, resolves synchronously on the next microtask; if not,
76
+ resolves the moment the first DOM consumer (or any caller of
77
+ acquireStream) creates it. Useful for cross-package adapters
78
+ (streams-bridge, A2UI surfaces) that may run before the DOM source
79
+ has connected. The promise never rejects — pair with AbortSignal at
80
+ the caller if you need cancellation. */
81
+ export function whenStream(id) {
82
+ if (STREAMS.has(id)) return Promise.resolve(STREAMS.get(id));
83
+ return new Promise((resolve) => {
84
+ if (!PENDING.has(id)) PENDING.set(id, []);
85
+ PENDING.get(id).push(resolve);
86
+ });
87
+ }
88
+
89
+ function attr(el, key) { return el.getAttribute(ATTRS[key]) ?? ''; }
90
+ function dispatch(el, name, detail) {
91
+ el.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
92
+ }
93
+ function warnOnce(el, msg) {
94
+ if (WARNED.has(el)) return;
95
+ WARNED.add(el);
96
+ console.warn(`[data-stream] ${msg}`);
97
+ }
98
+
99
+ /* Stable string hash of a config for auto-id. djb2-style; collisions
100
+ would be benign (two streams share a transport that fetches the same
101
+ thing) but practically impossible for distinct configs. */
102
+ function configHash(parts) {
103
+ const s = parts.join('|');
104
+ let h = 5381;
105
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
106
+ return `auto:${(h >>> 0).toString(36)}`;
107
+ }
108
+
109
+ function streamIdFor(el) {
110
+ const explicit = attr(el, 'id');
111
+ if (explicit) return `id:${explicit}`;
112
+ return configHash([
113
+ attr(el, 'src'), attr(el, 'mode'), attr(el, 'interval'),
114
+ attr(el, 'method'), attr(el, 'body'), attr(el, 'headers'),
115
+ attr(el, 'format'), attr(el, 'event'),
116
+ ]);
117
+ }
118
+
119
+ function streamConfigFor(el) {
120
+ return {
121
+ src: attr(el, 'src'),
122
+ mode: (attr(el, 'mode') || 'http').toLowerCase(),
123
+ interval: parseInt(attr(el, 'interval'), 10) || 0,
124
+ method: attr(el, 'method') || 'GET',
125
+ body: attr(el, 'body') || undefined,
126
+ headers: parseHeaders(attr(el, 'headers')),
127
+ format: attr(el, 'format'),
128
+ event: attr(el, 'event') || 'message',
129
+ };
130
+ }
131
+
132
+ function parseHeaders(raw) {
133
+ if (!raw) return undefined;
134
+ try { return JSON.parse(raw); } catch { return undefined; }
135
+ }
136
+
137
+ /* ── Format parsing (CSV / TSV / JSONL / text / json) ──────────────── */
138
+
139
+ function detectFormat(src, contentType) {
140
+ const url = src.toLowerCase().split('?')[0];
141
+ if (url.endsWith('.csv')) return 'csv';
142
+ if (url.endsWith('.tsv')) return 'tsv';
143
+ if (url.endsWith('.jsonl') || url.endsWith('.ndjson')) return 'jsonl';
144
+ if (url.endsWith('.txt')) return 'text';
145
+ const ct = (contentType || '').toLowerCase();
146
+ if (ct.includes('text/csv')) return 'csv';
147
+ if (ct.includes('text/tab-separated-values')) return 'tsv';
148
+ if (ct.includes('application/x-ndjson') ||
149
+ ct.includes('application/jsonl')) return 'jsonl';
150
+ if (ct.includes('text/plain') && !ct.includes('json')) return 'text';
151
+ return 'json';
152
+ }
153
+
154
+ function parseDelimited(text, delimiter) {
155
+ const rows = []; let row = []; let cell = ''; let inQuote = false;
156
+ for (let i = 0; i < text.length; i++) {
157
+ const c = text[i];
158
+ if (inQuote) {
159
+ if (c === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else inQuote = false; }
160
+ else cell += c;
161
+ } else {
162
+ if (c === '"' && cell === '') inQuote = true;
163
+ else if (c === delimiter) { row.push(cell); cell = ''; }
164
+ else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; }
165
+ else if (c === '\r') { /* skip */ }
166
+ else cell += c;
167
+ }
168
+ }
169
+ if (cell !== '' || row.length > 0) { row.push(cell); rows.push(row); }
170
+ if (rows.length === 0) return [];
171
+ const header = rows.shift();
172
+ return rows
173
+ .filter(r => !(r.length === 1 && r[0] === ''))
174
+ .filter(r => r.length === header.length)
175
+ .map(r => {
176
+ const obj = {};
177
+ for (let i = 0; i < header.length; i++) {
178
+ const v = r[i]; const n = +v;
179
+ obj[header[i]] = (v !== '' && Number.isFinite(n) && /^-?\d/.test(v)) ? n : v;
180
+ }
181
+ return obj;
182
+ });
183
+ }
184
+
185
+ function parseJSONL(text) {
186
+ const out = [];
187
+ for (const line of text.split('\n')) {
188
+ const t = line.trim();
189
+ if (!t || t.startsWith('//')) continue;
190
+ try { out.push(JSON.parse(t)); } catch { /* skip */ }
191
+ }
192
+ return out;
193
+ }
194
+
195
+ function parseBody(text, format) {
196
+ switch (format) {
197
+ case 'json': return JSON.parse(text);
198
+ case 'csv': return parseDelimited(text, ',');
199
+ case 'tsv': return parseDelimited(text, '\t');
200
+ case 'jsonl': return parseJSONL(text);
201
+ case 'text': return text;
202
+ default: return JSON.parse(text);
203
+ }
204
+ }
205
+
206
+ /* ── Transports (HTTP / SSE / WS) — write to stream.signal ─────────── */
207
+
208
+ function startTransport(stream) {
209
+ const { src, mode } = stream.opts;
210
+ if (!src) return { stop() {} };
211
+ if (mode === 'http') return startHTTP(stream);
212
+ if (mode === 'sse') return startSSE(stream);
213
+ if (mode === 'ws' || mode === 'websocket') return startWS(stream);
214
+ console.warn(`[data-stream] Unknown mode "${mode}". Use http, sse, or ws.`);
215
+ return { stop() {} };
216
+ }
217
+
218
+ function startHTTP(stream) {
219
+ const { src, interval, method, body, headers, format: explicit } = stream.opts;
220
+ const abort = new AbortController();
221
+ let timer = null;
222
+ let stopped = false;
223
+
224
+ const tick = async () => {
225
+ if (stopped) return;
226
+ try {
227
+ const r = await fetch(src, { method, headers, body, signal: abort.signal });
228
+ if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
229
+ const text = await r.text();
230
+ if (stopped) return;
231
+ const ct = r.headers.get('content-type') || '';
232
+ const format = explicit || detectFormat(src, ct);
233
+ let data;
234
+ try { data = parseBody(text, format); }
235
+ catch (e) { stream.lastError = `Parse error (${format}): ${e.message}`; return; }
236
+ stream.signal.value = data;
237
+ } catch (e) {
238
+ if (e.name === 'AbortError') return;
239
+ stream.lastError = e.message;
240
+ stream.errorTick = (stream.errorTick || 0) + 1;
241
+ /* Bump error counter signal so subscribers see it too. */
242
+ if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
243
+ }
244
+ };
245
+
246
+ tick();
247
+ if (interval > 0) timer = setInterval(tick, interval);
248
+
249
+ return {
250
+ stop() {
251
+ stopped = true;
252
+ abort.abort();
253
+ if (timer) clearInterval(timer);
254
+ },
255
+ };
256
+ }
257
+
258
+ function startSSE(stream) {
259
+ const { src, event: eventName, format: explicit } = stream.opts;
260
+ if (typeof EventSource === 'undefined') {
261
+ console.warn('[data-stream] EventSource not available; SSE mode is a no-op here.');
262
+ return { stop() {} };
263
+ }
264
+ const es = new EventSource(src);
265
+ es.addEventListener(eventName, (e) => {
266
+ try {
267
+ const format = explicit || 'json';
268
+ stream.signal.value = parseBody(e.data, format);
269
+ } catch (err) {
270
+ stream.lastError = `SSE parse error: ${err.message}`;
271
+ stream.errorTick = (stream.errorTick || 0) + 1;
272
+ if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
273
+ }
274
+ });
275
+ es.addEventListener('error', () => {
276
+ stream.lastError = 'SSE connection error';
277
+ stream.errorTick = (stream.errorTick || 0) + 1;
278
+ if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
279
+ });
280
+ return { stop() { es.close(); } };
281
+ }
282
+
283
+ function startWS(stream) {
284
+ const { src, format: explicit } = stream.opts;
285
+ if (typeof WebSocket === 'undefined') {
286
+ console.warn('[data-stream] WebSocket not available; ws mode is a no-op here.');
287
+ return { stop() {} };
288
+ }
289
+ const ws = new WebSocket(src);
290
+ ws.addEventListener('message', (e) => {
291
+ try {
292
+ const format = explicit || 'json';
293
+ stream.signal.value = parseBody(typeof e.data === 'string' ? e.data : String(e.data), format);
294
+ } catch (err) {
295
+ stream.lastError = `WebSocket parse error: ${err.message}`;
296
+ stream.errorTick = (stream.errorTick || 0) + 1;
297
+ if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
298
+ }
299
+ });
300
+ ws.addEventListener('error', () => {
301
+ stream.lastError = 'WebSocket error';
302
+ stream.errorTick = (stream.errorTick || 0) + 1;
303
+ if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
304
+ });
305
+ return { stop() { ws.close(); } };
306
+ }
307
+
308
+ /* ── Stream registry ───────────────────────────────────────────────── */
309
+
310
+ function acquireStream(id, opts) {
311
+ let stream = STREAMS.get(id);
312
+ if (stream) {
313
+ stream.refs++;
314
+ return stream;
315
+ }
316
+ stream = {
317
+ id,
318
+ opts,
319
+ signal: signal(null),
320
+ errorSignal: signal(0), /* monotonic — bumps on each transport-level error */
321
+ refs: 1,
322
+ transport: null,
323
+ lastError: null,
324
+ errorTick: 0,
325
+ };
326
+ STREAMS.set(id, stream);
327
+ stream.transport = startTransport(stream);
328
+ if (PENDING.has(id)) {
329
+ const resolvers = PENDING.get(id);
330
+ PENDING.delete(id);
331
+ for (const r of resolvers) r(stream);
332
+ }
333
+ return stream;
334
+ }
335
+
336
+ function releaseStream(stream) {
337
+ stream.refs--;
338
+ if (stream.refs <= 0) {
339
+ stream.transport?.stop();
340
+ STREAMS.delete(stream.id);
341
+ }
342
+ }
343
+
344
+ /* ── Apply incoming data to the element ────────────────────────────── */
345
+
346
+ function resolvePath(obj, path) {
347
+ if (!path) return obj;
348
+ let cursor = obj;
349
+ for (const seg of path.split('.')) {
350
+ if (cursor == null) return null;
351
+ cursor = cursor[seg];
352
+ }
353
+ return cursor;
354
+ }
355
+
356
+ function applyData(el, raw, opts) {
357
+ let data = resolvePath(raw, opts.path);
358
+ if (data == null) return;
359
+
360
+ const target = opts.target || 'data';
361
+ const merge = opts.merge || 'replace';
362
+
363
+ if (target === '*') {
364
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
365
+ Object.assign(el, data);
366
+ dispatch(el, 'stream-update', { data });
367
+ return;
368
+ }
369
+ el.data = data;
370
+ dispatch(el, 'stream-update', { data });
371
+ return;
372
+ }
373
+
374
+ if ((merge === 'append' || merge === 'prepend') && Array.isArray(data) && Array.isArray(el[target])) {
375
+ el[target] = merge === 'append'
376
+ ? [...el[target], ...data]
377
+ : [...data, ...el[target]];
378
+ } else {
379
+ el[target] = data;
380
+ }
381
+ dispatch(el, 'stream-update', { data });
382
+ }
383
+
384
+ /* ── Per-element lifecycle ─────────────────────────────────────────── */
385
+
386
+ export function start(el) {
387
+ stop(el);
388
+ if (!el.isConnected) return;
389
+ const src = attr(el, 'src');
390
+ if (!src) return;
391
+
392
+ const id = streamIdFor(el);
393
+ const opts = streamConfigFor(el);
394
+ const stream = acquireStream(id, opts);
395
+
396
+ /* Per-element apply options — read fresh each effect run so live attr
397
+ edits to path/target/merge take effect without restart. */
398
+ const elState = { streamId: id, loaded: false, dispose: null };
399
+ ELEMENT_STREAMS.set(el, elState);
400
+
401
+ /* Subscribe to data + error signals via two effects so each can dispose
402
+ independently. The data effect short-circuits on initial null. */
403
+ const dataDispose = effect(() => {
404
+ const value = stream.signal.value;
405
+ if (value == null) return;
406
+ untracked(() => {
407
+ if (!elState.loaded) {
408
+ elState.loaded = true;
409
+ dispatch(el, 'stream-load', {});
410
+ }
411
+ const applyOpts = {
412
+ path: attr(el, 'path'),
413
+ target: attr(el, 'target') || 'data',
414
+ merge: attr(el, 'merge') || 'replace',
415
+ };
416
+ applyData(el, value, applyOpts);
417
+ });
418
+ });
419
+
420
+ const errorDispose = effect(() => {
421
+ const tick = stream.errorSignal.value;
422
+ if (tick === 0) return;
423
+ untracked(() => {
424
+ if (stream.lastError) dispatch(el, 'stream-error', { error: stream.lastError });
425
+ });
426
+ });
427
+
428
+ elState.dispose = () => { dataDispose(); errorDispose(); };
429
+ }
430
+
431
+ export function stop(el) {
432
+ const entry = ELEMENT_STREAMS.get(el);
433
+ if (!entry) return;
434
+ entry.dispose?.();
435
+ const stream = STREAMS.get(entry.streamId);
436
+ if (stream) releaseStream(stream);
437
+ ELEMENT_STREAMS.delete(el);
438
+ }
439
+
440
+ /* ── Document-level observer ───────────────────────────────────────── */
441
+
442
+ const ATTR_FILTER = Object.values(ATTRS);
443
+
444
+ function isStreamingEl(node) {
445
+ return node && node.nodeType === 1 && node.hasAttribute && node.hasAttribute(ATTRS.src);
446
+ }
447
+
448
+ function visitSubtree(root, fn) {
449
+ if (root.nodeType !== 1) return;
450
+ if (isStreamingEl(root)) fn(root);
451
+ if (root.querySelectorAll) {
452
+ root.querySelectorAll(`[${ATTRS.src}]`).forEach(fn);
453
+ }
454
+ }
455
+
456
+ const observer = new MutationObserver((mutations) => {
457
+ for (const m of mutations) {
458
+ if (m.type === 'attributes' && ATTR_FILTER.includes(m.attributeName)) {
459
+ const el = m.target;
460
+ if (isStreamingEl(el)) start(el);
461
+ else stop(el);
462
+ continue;
463
+ }
464
+ if (m.type === 'childList') {
465
+ m.addedNodes.forEach(n => visitSubtree(n, start));
466
+ m.removedNodes.forEach(n => visitSubtree(n, stop));
467
+ }
468
+ }
469
+ });
470
+
471
+ function bootstrap() {
472
+ if (typeof document === 'undefined') return;
473
+ observer.observe(document.documentElement, {
474
+ childList: true, subtree: true,
475
+ attributes: true, attributeFilter: ATTR_FILTER,
476
+ });
477
+ document.querySelectorAll(`[${ATTRS.src}]`).forEach(start);
478
+ }
479
+
480
+ if (typeof document !== 'undefined') {
481
+ if (document.readyState === 'loading') {
482
+ document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
483
+ } else {
484
+ bootstrap();
485
+ }
486
+ }
package/core/form.js CHANGED
@@ -188,10 +188,15 @@ export class AdiaFormElement extends AdiaElement {
188
188
  * slots; the per-control `label` attr renders via an inert shadow
189
189
  * slot without `[for]`, so click-to-focus doesn't work.
190
190
  *
191
+ * Subclasses that have promoted `label` to a first-class API with
192
+ * proper a11y wiring (e.g. input-ui's inline-leading caption with
193
+ * aria-labelledby) opt out by setting `static labelDeprecated = false`.
194
+ *
191
195
  * One-shot per class so the console isn't spammed on docs pages.
192
196
  */
193
197
  #warnDeprecatedLabel() {
194
198
  const ctor = this.constructor;
199
+ if (ctor.labelDeprecated === false) return;
195
200
  if (!ctor.properties?.label) return;
196
201
  if (!this.hasAttribute('label')) return;
197
202
  if (AdiaFormElement.#labelWarned.has(ctor)) return;
package/core/index.js CHANGED
@@ -23,3 +23,5 @@ export * from './icons.js';
23
23
  export * from './markdown.js';
24
24
  export * from './transport.js';
25
25
  export * from './polyfills.js';
26
+ export { streams, whenStream } from './data-stream.js';
27
+ export * from './streams-bridge.js';
@@ -0,0 +1,96 @@
1
+ /**
2
+ * streams-bridge — proxy data-stream signals into A2UI surface data
3
+ * models (OD-CHART-16, option b).
4
+ *
5
+ * effect()
6
+ * data-stream signal ──────────────────────▶ renderer.process({
7
+ * (any consumer: type: 'updateDataModel',
8
+ * chart-ui, table-ui, surfaceId, path, value
9
+ * headless app code) })
10
+ *
11
+ * Lives in `web-components/core/` rather than `a2ui-utils/` because
12
+ * the dependency direction is web-components → a2ui-utils, not the
13
+ * reverse. The bridge duck-types the renderer (it needs `.process()`,
14
+ * nothing else), so it works with any A2UI-protocol consumer — the
15
+ * actual `A2UIRenderer` from `@adia-ai/a2ui-utils`, a custom renderer,
16
+ * or a test stub.
17
+ *
18
+ * Contract:
19
+ * - One-way only: stream → data model. A2UI writes back to its model
20
+ * (e.g. via `update-data-model` wiring actions) do NOT propagate
21
+ * into the stream signal. If you need bidirectional state, the
22
+ * stream is the wrong substrate — use a wiring action.
23
+ * - Tear down with the returned dispose function. Dispose detaches
24
+ * the effect; it does NOT release the stream's refcount, because
25
+ * the bridge isn't the source of truth for the stream's lifecycle
26
+ * (the DOM consumer or a separate `acquireStream` caller is).
27
+ * - `select` uses dot-separated path syntax (matches
28
+ * `data-stream-path` on the trait). `path` uses slash-separated
29
+ * syntax (matches A2UI bindings).
30
+ *
31
+ * Usage:
32
+ * const dispose = bridgeStream(renderer, {
33
+ * surfaceId: 'main',
34
+ * streamId: 'id:rev', // explicit data-stream-id
35
+ * path: 'metrics/revenue', // A2UI data-model path
36
+ * select: 'data', // optional: sub-path within stream
37
+ * });
38
+ * // ... later
39
+ * dispose();
40
+ *
41
+ * // For DOM-first sources where the stream may register after the
42
+ * // bridge call returns:
43
+ * const dispose = await bridgeStreamAsync(renderer, opts);
44
+ */
45
+
46
+ import { effect } from './signals.js';
47
+ import { streams, whenStream } from './data-stream.js';
48
+
49
+ function resolveSelect(obj, dottedPath) {
50
+ if (!dottedPath) return obj;
51
+ let cursor = obj;
52
+ for (const seg of dottedPath.split('.')) {
53
+ if (cursor == null) return null;
54
+ cursor = cursor[seg];
55
+ }
56
+ return cursor;
57
+ }
58
+
59
+ function attach(renderer, stream, { surfaceId, path, select }) {
60
+ return effect(() => {
61
+ const value = stream.signal.value;
62
+ if (value == null) return;
63
+ const out = select ? resolveSelect(value, select) : value;
64
+ renderer.process({ type: 'updateDataModel', surfaceId, path, value: out });
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Bridge a registered stream into a renderer's surface data model.
70
+ * If the stream is not yet registered, logs a warning and returns a
71
+ * no-op dispose. Use {@link bridgeStreamAsync} when the stream might
72
+ * register after this call (e.g. DOM source connecting on the same
73
+ * tick as the bridge wiring).
74
+ */
75
+ export function bridgeStream(renderer, opts) {
76
+ const { streamId } = opts;
77
+ const stream = streams.get(streamId);
78
+ if (!stream) {
79
+ console.warn(
80
+ `[streams-bridge] stream "${streamId}" not registered — ` +
81
+ `call bridgeStreamAsync() to wait, or ensure the DOM source is ` +
82
+ `connected before bridging.`,
83
+ );
84
+ return () => {};
85
+ }
86
+ return attach(renderer, stream, opts);
87
+ }
88
+
89
+ /**
90
+ * Async variant — awaits {@link whenStream} before attaching the
91
+ * effect. Resolves once the bridge is wired (returns the dispose fn).
92
+ */
93
+ export async function bridgeStreamAsync(renderer, opts) {
94
+ const stream = await whenStream(opts.streamId);
95
+ return attach(renderer, stream, opts);
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "AdiaUI web components — vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-utils.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -78,10 +78,14 @@
78
78
  /* Text on neutral surfaces — low shade steps = dark text for light mode.
79
79
  Dark-mode tint spread widened: text→subtle gap 25→35 steps,
80
80
  subtle→disabled gap 10→15 steps, for clearer visual hierarchy. */
81
+ /* Subtle + muted lifted one fine-step shade-side to clear WCAG AA
82
+ 4.5:1 on canvas-0/1/2 in both schemes. Subtle ↔ muted gap
83
+ compresses from 10 to 5 step-fractions — visual hierarchy
84
+ leans more on font-weight + size and less on shade contrast. */
81
85
  --a-canvas-text-strong: light-dark(var(--a-neutral-05-shade), var(--a-neutral-05-tint));
82
86
  --a-canvas-text: light-dark(var(--a-neutral-30-shade), var(--a-neutral-30-tint));
83
- --a-canvas-text-subtle: light-dark(var(--a-neutral-40-shade), var(--a-neutral-40-tint));
84
- --a-canvas-text-muted: light-dark(var(--a-neutral-50-shade), var(--a-neutral-50-tint));
87
+ --a-canvas-text-subtle: light-dark(var(--a-neutral-35-shade), var(--a-neutral-35-tint));
88
+ --a-canvas-text-muted: light-dark(var(--a-neutral-40-shade), var(--a-neutral-40-tint));
85
89
  --a-canvas-text-disabled: light-dark(var(--a-neutral-60-shade), var(--a-neutral-60-tint));
86
90
  --a-canvas-text-inverse: var(--a-neutral-10);
87
91
 
@@ -140,6 +144,20 @@
140
144
  --a-accent-active: var(--a-accent-60);
141
145
  --a-accent-selected: var(--a-accent-50);
142
146
 
147
+ /* Link family — derived from accent steps deeper than --a-accent-strong
148
+ (step-50) so links clear WCAG AA 4.5:1 on canvas-0/1/2 in both
149
+ schemes. Anchor color in `typography.css` reads --a-link rather
150
+ than --a-accent-strong; emphasis on hover comes from a tighter
151
+ chroma + darker step rather than full saturation jump. */
152
+ /* Polarity matches `--a-accent-N` family wrappers: at fine steps >50,
153
+ `tint` is the lower-L (deeper) primitive and `shade` is the higher-L
154
+ primitive — the naming inverts past the convergence point. Light
155
+ mode wants dark-on-light, so we pick `tint` (deep accent); dark
156
+ mode wants light-on-dark, so we pick `shade` (lifted accent). */
157
+ --a-link: light-dark(var(--a-accent-65-tint), var(--a-accent-65-shade));
158
+ --a-link-hover: light-dark(var(--a-accent-70-tint), var(--a-accent-70-shade));
159
+ --a-link-visited: light-dark(var(--a-accent-75-tint), var(--a-accent-75-shade));
160
+
143
161
  /* Accent text on accent solid bg — needs light text on dark-ish accent */
144
162
  --a-accent-text-strong: light-dark(var(--a-accent-05-shade), var(--a-accent-05-tint));
145
163
  --a-accent-text: light-dark(var(--a-accent-10-shade), var(--a-accent-10-tint));
@@ -466,7 +484,7 @@
466
484
  ══════════════════════════════════════════════════════════════ */
467
485
 
468
486
  --a-ui-bg: var(--a-canvas-0-scrim);
469
- --a-ui-bg-hover: var(--a-canvas-0-scrim);
487
+ --a-ui-bg-hover: var(--a-canvas-2-scrim);
470
488
  --a-ui-bg-active: var(--a-canvas-0);
471
489
  --a-ui-bg-selected: var(--a-canvas-bright);
472
490
  --a-ui-bg-disabled: var(--a-canvas-1);
@@ -17,6 +17,7 @@
17
17
  @import "../components/textarea/textarea.css";
18
18
  @import "../components/check/check.css";
19
19
  @import "../components/radio/radio.css";
20
+ @import "../components/option-card/option-card.css";
20
21
  @import "../components/switch/switch.css";
21
22
  @import "../components/slider/slider.css";
22
23
  @import "../components/select/select.css";