@adia-ai/web-components 0.0.14 → 0.0.15

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/README.md CHANGED
@@ -42,7 +42,9 @@ web-components/
42
42
  │ ├── provider.js global provider registration + router-ui
43
43
  │ ├── anchor.js popover + anchor-positioning
44
44
  │ ├── markdown.js lightweight markdown renderer
45
- └── transport.js SSE / streaming helpers for LLM adapters
45
+ ├── transport.js SSE / streaming helpers for LLM adapters
46
+ │ └── data-stream.js `data-stream-*` attribute trait (HTTP/SSE/WS,
47
+ │ signal-backed, refcounted shared transports)
46
48
 
47
49
  ├── components/ — 80 *-ui custom elements
48
50
  │ └── <tag>/
@@ -159,6 +161,46 @@ Accepts the four A2UI message kinds: `updateComponents`,
159
161
  normalizes LLM-emitted aliases (e.g. `Carousel` → `swiper-ui`) so generated
160
162
  output is robust to name drift.
161
163
 
164
+ ## Data streaming via `data-stream-*` attributes
165
+
166
+ Any element with a settable `.data` property — chart-ui, table-ui,
167
+ heatmap-ui, stat-ui, list-ui, etc. — can be fed from a backing
168
+ source via attributes alone. No per-component opt-in:
169
+
170
+ ```html
171
+ <!-- HTTP one-shot fetch, JSON -->
172
+ <chart-ui type="area" x="month" y="revenue"
173
+ data-stream-src="/api/revenue?range=3m"
174
+ data-stream-path="data"></chart-ui>
175
+
176
+ <!-- HTTP polling every 5s -->
177
+ <table-ui sortable striped
178
+ data-stream-src="/api/orders"
179
+ data-stream-interval="5000"></table-ui>
180
+
181
+ <!-- Server-Sent Events, append on each message -->
182
+ <heatmap-ui type="matrix" rows="7" cols="52"
183
+ data-stream-src="/sse/activity"
184
+ data-stream-mode="sse"
185
+ data-stream-merge="append"></heatmap-ui>
186
+
187
+ <!-- Spread a multi-property response onto the element -->
188
+ <stat-ui data-stream-src="/api/kpi"
189
+ data-stream-target="*"></stat-ui>
190
+ ```
191
+
192
+ Modes: HTTP (one-shot or polling), `sse` (`EventSource`), `ws`
193
+ (`WebSocket`). Formats: `json` (default), `csv`, `tsv`, `jsonl`,
194
+ `text` — auto-detected from URL extension or content-type. Two
195
+ elements with attribute-identical streams share one transport
196
+ (refcounted, signal-backed); explicit `data-stream-id` lets
197
+ unrelated configs share intentionally. Programmatic access via
198
+ the `streams` registry export from `core/data-stream.js`.
199
+
200
+ Implementation: `core/data-stream.js` (~360 lines). Full
201
+ attribute table + live demos:
202
+ [`/site/components/chart#data-stream`](./site/pages/components/chart/index.html).
203
+
162
204
  ## Build
163
205
 
164
206
  ```bash
@@ -885,15 +885,18 @@ class AdiaChart extends AdiaElement {
885
885
 
886
886
  try { this.#tipEl.showPopover(); } catch (_) { /* popover not supported */ }
887
887
 
888
- /* Follow the cursor — offset up-right, clamp to viewport */
888
+ /* Follow the cursor — centered horizontally above, clamp to viewport
889
+ with an 8px edge-pad, flip below when there's no room above. */
889
890
  const gap = 12;
891
+ const edgePad = 8;
890
892
  const { clientX, clientY } = event;
891
893
  const tw = this.#tipEl.offsetWidth || 0;
892
894
  const th = this.#tipEl.offsetHeight || 0;
893
- let x = clientX + gap;
895
+ let x = clientX - tw / 2;
894
896
  let y = clientY - th - gap;
895
- if (x + tw > window.innerWidth) x = clientX - tw - gap;
896
- if (y < 0) y = clientY + gap;
897
+ if (x < edgePad) x = edgePad;
898
+ if (x + tw > window.innerWidth - edgePad) x = window.innerWidth - tw - edgePad;
899
+ if (y < edgePad) y = clientY + gap;
897
900
  this.#tipEl.style.left = `${x}px`;
898
901
  this.#tipEl.style.top = `${y}px`;
899
902
  }
@@ -3,6 +3,12 @@
3
3
  * Import this single file to register all custom elements.
4
4
  */
5
5
 
6
+ /* Side-effect import — auto-attaches the document-level MutationObserver
7
+ that drives `data-stream-*` attribute behavior on any element with a
8
+ settable `.data` property (chart-ui, table-ui, heatmap-ui, stat-ui,
9
+ list-ui). See packages/web-components/core/data-stream.js. */
10
+ import '../core/data-stream.js';
11
+
6
12
  export { AdiaIcon } from './icon/icon.js';
7
13
  export { AdiaButton } from './button/button.js';
8
14
  export { AdiaInput } from './input/input.js';
@@ -23,6 +23,7 @@
23
23
  --input-easing: var(--a-easing);
24
24
 
25
25
  /* ── State: hover/focus ── */
26
+ --input-bg-hover: var(--a-ui-bg-hover);
26
27
  --input-fg-hover: var(--a-fg);
27
28
  --input-affix-fg-hover: var(--a-fg-subtle);
28
29
  --input-fg-focus: var(--a-fg);
@@ -72,6 +73,7 @@
72
73
  transition: border-color var(--input-duration) var(--input-easing);
73
74
  }
74
75
  :scope:not([disabled]) [slot="field"]:hover {
76
+ background: var(--input-bg-hover);
75
77
  border-color: var(--input-border-hover);
76
78
  color: var(--input-fg-hover);
77
79
  }
@@ -22,6 +22,7 @@
22
22
  --textarea-easing: var(--a-easing);
23
23
 
24
24
  /* ── State ── */
25
+ --textarea-bg-hover: var(--a-ui-bg-hover);
25
26
  --textarea-fg-hover: var(--a-fg);
26
27
  --textarea-label-fg-focus: var(--a-fg-subtle);
27
28
  --textarea-bg-disabled: var(--a-ui-bg-disabled);
@@ -66,6 +67,7 @@
66
67
  transition: border-color var(--textarea-duration) var(--textarea-easing);
67
68
  }
68
69
  :scope:not([disabled]) [slot="text"]:hover {
70
+ background: var(--textarea-bg-hover);
69
71
  border-color: var(--textarea-border-hover);
70
72
  color: var(--textarea-fg-hover);
71
73
  }
@@ -256,6 +256,7 @@ class AdiaTooltip extends AdiaElement {
256
256
  #positionAtPointer(x, y) {
257
257
  if (!this.#popover || x == null || y == null) return;
258
258
  const gap = 12;
259
+ const edgePad = 8;
259
260
  const popover = this.#popover;
260
261
  popover.style.position = 'fixed';
261
262
  popover.style.left = '0';
@@ -263,10 +264,16 @@ class AdiaTooltip extends AdiaElement {
263
264
  /* Force reflow to read offset dimensions now that content changed */
264
265
  const tw = popover.offsetWidth || 0;
265
266
  const th = popover.offsetHeight || 0;
266
- let px = x + gap;
267
+ /* Default: centered horizontally above the cursor, gap px clear of
268
+ the cursor. Clamp horizontally to viewport with edgePad on each
269
+ side (short tooltips near the viewport edge scoot inward rather
270
+ than drifting off-screen). Flip vertically when there's not
271
+ enough room above. */
272
+ let px = x - tw / 2;
267
273
  let py = y - th - gap;
268
- if (px + tw > window.innerWidth) px = x - tw - gap;
269
- if (py < 0) py = y + gap;
274
+ if (px < edgePad) px = edgePad;
275
+ if (px + tw > window.innerWidth - edgePad) px = window.innerWidth - tw - edgePad;
276
+ if (py < edgePad) py = y + gap;
270
277
  popover.style.left = `${px}px`;
271
278
  popover.style.top = `${py}px`;
272
279
  }
@@ -0,0 +1,465 @@
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 WARNED = new WeakSet();
62
+
63
+ /* Read-only public view of the streams registry. App code can do:
64
+ `import { streams } from '@adia-ai/web-components/core/data-stream';
65
+ streams.get('rev').signal.value` to read; subscribe via effect(). */
66
+ export const streams = {
67
+ get(id) { return STREAMS.get(id); },
68
+ has(id) { return STREAMS.has(id); },
69
+ keys() { return STREAMS.keys(); },
70
+ size() { return STREAMS.size; },
71
+ };
72
+
73
+ function attr(el, key) { return el.getAttribute(ATTRS[key]) ?? ''; }
74
+ function dispatch(el, name, detail) {
75
+ el.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
76
+ }
77
+ function warnOnce(el, msg) {
78
+ if (WARNED.has(el)) return;
79
+ WARNED.add(el);
80
+ console.warn(`[data-stream] ${msg}`);
81
+ }
82
+
83
+ /* Stable string hash of a config for auto-id. djb2-style; collisions
84
+ would be benign (two streams share a transport that fetches the same
85
+ thing) but practically impossible for distinct configs. */
86
+ function configHash(parts) {
87
+ const s = parts.join('|');
88
+ let h = 5381;
89
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
90
+ return `auto:${(h >>> 0).toString(36)}`;
91
+ }
92
+
93
+ function streamIdFor(el) {
94
+ const explicit = attr(el, 'id');
95
+ if (explicit) return `id:${explicit}`;
96
+ return configHash([
97
+ attr(el, 'src'), attr(el, 'mode'), attr(el, 'interval'),
98
+ attr(el, 'method'), attr(el, 'body'), attr(el, 'headers'),
99
+ attr(el, 'format'), attr(el, 'event'),
100
+ ]);
101
+ }
102
+
103
+ function streamConfigFor(el) {
104
+ return {
105
+ src: attr(el, 'src'),
106
+ mode: (attr(el, 'mode') || 'http').toLowerCase(),
107
+ interval: parseInt(attr(el, 'interval'), 10) || 0,
108
+ method: attr(el, 'method') || 'GET',
109
+ body: attr(el, 'body') || undefined,
110
+ headers: parseHeaders(attr(el, 'headers')),
111
+ format: attr(el, 'format'),
112
+ event: attr(el, 'event') || 'message',
113
+ };
114
+ }
115
+
116
+ function parseHeaders(raw) {
117
+ if (!raw) return undefined;
118
+ try { return JSON.parse(raw); } catch { return undefined; }
119
+ }
120
+
121
+ /* ── Format parsing (CSV / TSV / JSONL / text / json) ──────────────── */
122
+
123
+ function detectFormat(src, contentType) {
124
+ const url = src.toLowerCase().split('?')[0];
125
+ if (url.endsWith('.csv')) return 'csv';
126
+ if (url.endsWith('.tsv')) return 'tsv';
127
+ if (url.endsWith('.jsonl') || url.endsWith('.ndjson')) return 'jsonl';
128
+ if (url.endsWith('.txt')) return 'text';
129
+ const ct = (contentType || '').toLowerCase();
130
+ if (ct.includes('text/csv')) return 'csv';
131
+ if (ct.includes('text/tab-separated-values')) return 'tsv';
132
+ if (ct.includes('application/x-ndjson') ||
133
+ ct.includes('application/jsonl')) return 'jsonl';
134
+ if (ct.includes('text/plain') && !ct.includes('json')) return 'text';
135
+ return 'json';
136
+ }
137
+
138
+ function parseDelimited(text, delimiter) {
139
+ const rows = []; let row = []; let cell = ''; let inQuote = false;
140
+ for (let i = 0; i < text.length; i++) {
141
+ const c = text[i];
142
+ if (inQuote) {
143
+ if (c === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else inQuote = false; }
144
+ else cell += c;
145
+ } else {
146
+ if (c === '"' && cell === '') inQuote = true;
147
+ else if (c === delimiter) { row.push(cell); cell = ''; }
148
+ else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; }
149
+ else if (c === '\r') { /* skip */ }
150
+ else cell += c;
151
+ }
152
+ }
153
+ if (cell !== '' || row.length > 0) { row.push(cell); rows.push(row); }
154
+ if (rows.length === 0) return [];
155
+ const header = rows.shift();
156
+ return rows
157
+ .filter(r => !(r.length === 1 && r[0] === ''))
158
+ .filter(r => r.length === header.length)
159
+ .map(r => {
160
+ const obj = {};
161
+ for (let i = 0; i < header.length; i++) {
162
+ const v = r[i]; const n = +v;
163
+ obj[header[i]] = (v !== '' && Number.isFinite(n) && /^-?\d/.test(v)) ? n : v;
164
+ }
165
+ return obj;
166
+ });
167
+ }
168
+
169
+ function parseJSONL(text) {
170
+ const out = [];
171
+ for (const line of text.split('\n')) {
172
+ const t = line.trim();
173
+ if (!t || t.startsWith('//')) continue;
174
+ try { out.push(JSON.parse(t)); } catch { /* skip */ }
175
+ }
176
+ return out;
177
+ }
178
+
179
+ function parseBody(text, format) {
180
+ switch (format) {
181
+ case 'json': return JSON.parse(text);
182
+ case 'csv': return parseDelimited(text, ',');
183
+ case 'tsv': return parseDelimited(text, '\t');
184
+ case 'jsonl': return parseJSONL(text);
185
+ case 'text': return text;
186
+ default: return JSON.parse(text);
187
+ }
188
+ }
189
+
190
+ /* ── Transports (HTTP / SSE / WS) — write to stream.signal ─────────── */
191
+
192
+ function startTransport(stream) {
193
+ const { src, mode } = stream.opts;
194
+ if (!src) return { stop() {} };
195
+ if (mode === 'http') return startHTTP(stream);
196
+ if (mode === 'sse') return startSSE(stream);
197
+ if (mode === 'ws' || mode === 'websocket') return startWS(stream);
198
+ console.warn(`[data-stream] Unknown mode "${mode}". Use http, sse, or ws.`);
199
+ return { stop() {} };
200
+ }
201
+
202
+ function startHTTP(stream) {
203
+ const { src, interval, method, body, headers, format: explicit } = stream.opts;
204
+ const abort = new AbortController();
205
+ let timer = null;
206
+ let stopped = false;
207
+
208
+ const tick = async () => {
209
+ if (stopped) return;
210
+ try {
211
+ const r = await fetch(src, { method, headers, body, signal: abort.signal });
212
+ if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
213
+ const text = await r.text();
214
+ if (stopped) return;
215
+ const ct = r.headers.get('content-type') || '';
216
+ const format = explicit || detectFormat(src, ct);
217
+ let data;
218
+ try { data = parseBody(text, format); }
219
+ catch (e) { stream.lastError = `Parse error (${format}): ${e.message}`; return; }
220
+ stream.signal.value = data;
221
+ } catch (e) {
222
+ if (e.name === 'AbortError') return;
223
+ stream.lastError = e.message;
224
+ stream.errorTick = (stream.errorTick || 0) + 1;
225
+ /* Bump error counter signal so subscribers see it too. */
226
+ if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
227
+ }
228
+ };
229
+
230
+ tick();
231
+ if (interval > 0) timer = setInterval(tick, interval);
232
+
233
+ return {
234
+ stop() {
235
+ stopped = true;
236
+ abort.abort();
237
+ if (timer) clearInterval(timer);
238
+ },
239
+ };
240
+ }
241
+
242
+ function startSSE(stream) {
243
+ const { src, event: eventName, format: explicit } = stream.opts;
244
+ if (typeof EventSource === 'undefined') {
245
+ console.warn('[data-stream] EventSource not available; SSE mode is a no-op here.');
246
+ return { stop() {} };
247
+ }
248
+ const es = new EventSource(src);
249
+ es.addEventListener(eventName, (e) => {
250
+ try {
251
+ const format = explicit || 'json';
252
+ stream.signal.value = parseBody(e.data, format);
253
+ } catch (err) {
254
+ stream.lastError = `SSE parse error: ${err.message}`;
255
+ stream.errorTick = (stream.errorTick || 0) + 1;
256
+ if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
257
+ }
258
+ });
259
+ es.addEventListener('error', () => {
260
+ stream.lastError = 'SSE connection error';
261
+ stream.errorTick = (stream.errorTick || 0) + 1;
262
+ if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
263
+ });
264
+ return { stop() { es.close(); } };
265
+ }
266
+
267
+ function startWS(stream) {
268
+ const { src, format: explicit } = stream.opts;
269
+ if (typeof WebSocket === 'undefined') {
270
+ console.warn('[data-stream] WebSocket not available; ws mode is a no-op here.');
271
+ return { stop() {} };
272
+ }
273
+ const ws = new WebSocket(src);
274
+ ws.addEventListener('message', (e) => {
275
+ try {
276
+ const format = explicit || 'json';
277
+ stream.signal.value = parseBody(typeof e.data === 'string' ? e.data : String(e.data), format);
278
+ } catch (err) {
279
+ stream.lastError = `WebSocket parse error: ${err.message}`;
280
+ stream.errorTick = (stream.errorTick || 0) + 1;
281
+ if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
282
+ }
283
+ });
284
+ ws.addEventListener('error', () => {
285
+ stream.lastError = 'WebSocket error';
286
+ stream.errorTick = (stream.errorTick || 0) + 1;
287
+ if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
288
+ });
289
+ return { stop() { ws.close(); } };
290
+ }
291
+
292
+ /* ── Stream registry ───────────────────────────────────────────────── */
293
+
294
+ function acquireStream(id, opts) {
295
+ let stream = STREAMS.get(id);
296
+ if (stream) {
297
+ stream.refs++;
298
+ return stream;
299
+ }
300
+ stream = {
301
+ id,
302
+ opts,
303
+ signal: signal(null),
304
+ errorSignal: signal(0), /* monotonic — bumps on each transport-level error */
305
+ refs: 1,
306
+ transport: null,
307
+ lastError: null,
308
+ errorTick: 0,
309
+ };
310
+ STREAMS.set(id, stream);
311
+ stream.transport = startTransport(stream);
312
+ return stream;
313
+ }
314
+
315
+ function releaseStream(stream) {
316
+ stream.refs--;
317
+ if (stream.refs <= 0) {
318
+ stream.transport?.stop();
319
+ STREAMS.delete(stream.id);
320
+ }
321
+ }
322
+
323
+ /* ── Apply incoming data to the element ────────────────────────────── */
324
+
325
+ function resolvePath(obj, path) {
326
+ if (!path) return obj;
327
+ let cursor = obj;
328
+ for (const seg of path.split('.')) {
329
+ if (cursor == null) return null;
330
+ cursor = cursor[seg];
331
+ }
332
+ return cursor;
333
+ }
334
+
335
+ function applyData(el, raw, opts) {
336
+ let data = resolvePath(raw, opts.path);
337
+ if (data == null) return;
338
+
339
+ const target = opts.target || 'data';
340
+ const merge = opts.merge || 'replace';
341
+
342
+ if (target === '*') {
343
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
344
+ Object.assign(el, data);
345
+ dispatch(el, 'stream-update', { data });
346
+ return;
347
+ }
348
+ el.data = data;
349
+ dispatch(el, 'stream-update', { data });
350
+ return;
351
+ }
352
+
353
+ if ((merge === 'append' || merge === 'prepend') && Array.isArray(data) && Array.isArray(el[target])) {
354
+ el[target] = merge === 'append'
355
+ ? [...el[target], ...data]
356
+ : [...data, ...el[target]];
357
+ } else {
358
+ el[target] = data;
359
+ }
360
+ dispatch(el, 'stream-update', { data });
361
+ }
362
+
363
+ /* ── Per-element lifecycle ─────────────────────────────────────────── */
364
+
365
+ export function start(el) {
366
+ stop(el);
367
+ if (!el.isConnected) return;
368
+ const src = attr(el, 'src');
369
+ if (!src) return;
370
+
371
+ const id = streamIdFor(el);
372
+ const opts = streamConfigFor(el);
373
+ const stream = acquireStream(id, opts);
374
+
375
+ /* Per-element apply options — read fresh each effect run so live attr
376
+ edits to path/target/merge take effect without restart. */
377
+ const elState = { streamId: id, loaded: false, dispose: null };
378
+ ELEMENT_STREAMS.set(el, elState);
379
+
380
+ /* Subscribe to data + error signals via two effects so each can dispose
381
+ independently. The data effect short-circuits on initial null. */
382
+ const dataDispose = effect(() => {
383
+ const value = stream.signal.value;
384
+ if (value == null) return;
385
+ untracked(() => {
386
+ if (!elState.loaded) {
387
+ elState.loaded = true;
388
+ dispatch(el, 'stream-load', {});
389
+ }
390
+ const applyOpts = {
391
+ path: attr(el, 'path'),
392
+ target: attr(el, 'target') || 'data',
393
+ merge: attr(el, 'merge') || 'replace',
394
+ };
395
+ applyData(el, value, applyOpts);
396
+ });
397
+ });
398
+
399
+ const errorDispose = effect(() => {
400
+ const tick = stream.errorSignal.value;
401
+ if (tick === 0) return;
402
+ untracked(() => {
403
+ if (stream.lastError) dispatch(el, 'stream-error', { error: stream.lastError });
404
+ });
405
+ });
406
+
407
+ elState.dispose = () => { dataDispose(); errorDispose(); };
408
+ }
409
+
410
+ export function stop(el) {
411
+ const entry = ELEMENT_STREAMS.get(el);
412
+ if (!entry) return;
413
+ entry.dispose?.();
414
+ const stream = STREAMS.get(entry.streamId);
415
+ if (stream) releaseStream(stream);
416
+ ELEMENT_STREAMS.delete(el);
417
+ }
418
+
419
+ /* ── Document-level observer ───────────────────────────────────────── */
420
+
421
+ const ATTR_FILTER = Object.values(ATTRS);
422
+
423
+ function isStreamingEl(node) {
424
+ return node && node.nodeType === 1 && node.hasAttribute && node.hasAttribute(ATTRS.src);
425
+ }
426
+
427
+ function visitSubtree(root, fn) {
428
+ if (root.nodeType !== 1) return;
429
+ if (isStreamingEl(root)) fn(root);
430
+ if (root.querySelectorAll) {
431
+ root.querySelectorAll(`[${ATTRS.src}]`).forEach(fn);
432
+ }
433
+ }
434
+
435
+ const observer = new MutationObserver((mutations) => {
436
+ for (const m of mutations) {
437
+ if (m.type === 'attributes' && ATTR_FILTER.includes(m.attributeName)) {
438
+ const el = m.target;
439
+ if (isStreamingEl(el)) start(el);
440
+ else stop(el);
441
+ continue;
442
+ }
443
+ if (m.type === 'childList') {
444
+ m.addedNodes.forEach(n => visitSubtree(n, start));
445
+ m.removedNodes.forEach(n => visitSubtree(n, stop));
446
+ }
447
+ }
448
+ });
449
+
450
+ function bootstrap() {
451
+ if (typeof document === 'undefined') return;
452
+ observer.observe(document.documentElement, {
453
+ childList: true, subtree: true,
454
+ attributes: true, attributeFilter: ATTR_FILTER,
455
+ });
456
+ document.querySelectorAll(`[${ATTRS.src}]`).forEach(start);
457
+ }
458
+
459
+ if (typeof document !== 'undefined') {
460
+ if (document.readyState === 'loading') {
461
+ document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
462
+ } else {
463
+ bootstrap();
464
+ }
465
+ }
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.15",
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,15 @@
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
+ --a-link: light-dark(var(--a-accent-65-shade), var(--a-accent-65-tint));
153
+ --a-link-hover: light-dark(var(--a-accent-70-shade), var(--a-accent-70-tint));
154
+ --a-link-visited: light-dark(var(--a-accent-75-shade), var(--a-accent-75-tint));
155
+
143
156
  /* Accent text on accent solid bg — needs light text on dark-ish accent */
144
157
  --a-accent-text-strong: light-dark(var(--a-accent-05-shade), var(--a-accent-05-tint));
145
158
  --a-accent-text: light-dark(var(--a-accent-10-shade), var(--a-accent-10-tint));
@@ -466,7 +479,7 @@
466
479
  ══════════════════════════════════════════════════════════════ */
467
480
 
468
481
  --a-ui-bg: var(--a-canvas-0-scrim);
469
- --a-ui-bg-hover: var(--a-canvas-0-scrim);
482
+ --a-ui-bg-hover: var(--a-canvas-2-scrim);
470
483
  --a-ui-bg-active: var(--a-canvas-0);
471
484
  --a-ui-bg-selected: var(--a-canvas-bright);
472
485
  --a-ui-bg-disabled: var(--a-canvas-1);
@@ -600,15 +600,20 @@
600
600
 
601
601
  /* ── Links ── */
602
602
  :where(a) {
603
- color: var(--a-accent-strong);
603
+ color: var(--a-link);
604
604
  text-decoration: none;
605
605
  }
606
606
 
607
607
  :where(a:hover) {
608
+ color: var(--a-link-hover);
608
609
  text-decoration: underline;
609
610
  text-underline-offset: 2px;
610
611
  }
611
612
 
613
+ :where(a:visited) {
614
+ color: var(--a-link-visited);
615
+ }
616
+
612
617
  /* ── Size wrappers — custom inline elements for relative sizing ── */
613
618
  :where(smaller) {
614
619
  font-size: 0.875em;