@adia-ai/web-components 0.0.13 → 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 +45 -3
- package/components/card/card.css +29 -0
- package/components/chart/chart.a2ui.json +43 -6
- package/components/chart/chart.css +224 -0
- package/components/chart/chart.js +1056 -31
- package/components/chart/chart.yaml +62 -6
- package/components/chart-legend/chart-legend.a2ui.json +139 -0
- package/components/chart-legend/chart-legend.css +124 -0
- package/components/chart-legend/chart-legend.js +185 -0
- package/components/chart-legend/chart-legend.yaml +133 -0
- package/components/code/code-editor.js +58 -0
- package/components/code/code.a2ui.json +59 -0
- package/components/code/code.css +78 -2
- package/components/code/code.js +146 -8
- package/components/code/code.yaml +42 -0
- package/components/heatmap/heatmap.js +62 -13
- package/components/index.js +7 -0
- package/components/input/input.css +2 -0
- package/components/stat/stat.a2ui.json +3 -0
- package/components/stat/stat.css +32 -0
- package/components/stat/stat.yaml +6 -0
- package/components/textarea/textarea.css +2 -0
- package/components/tooltip/tooltip.a2ui.json +29 -4
- package/components/tooltip/tooltip.css +111 -0
- package/components/tooltip/tooltip.js +207 -12
- package/components/tooltip/tooltip.yaml +38 -4
- package/core/data-stream.js +465 -0
- package/core/icons.js +35 -1
- package/core/provider.js +1 -1
- package/package.json +1 -1
- package/styles/colors/semantics.css +19 -5
- package/styles/components.css +1 -0
- package/styles/typography.css +6 -1
|
@@ -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/core/icons.js
CHANGED
|
@@ -84,6 +84,14 @@ try {
|
|
|
84
84
|
weightModules = EMPTY_WEIGHTS;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/* Flag guarding the missing-icon warn below. In Vite dev, `weightModules`
|
|
88
|
+
is populated synchronously by the glob rewrite, so the registry is ready
|
|
89
|
+
before any icon-ui element connects. In non-Vite static deploys, the
|
|
90
|
+
manifest load is async and arrives some time after first paint — warning
|
|
91
|
+
during that window would false-positive for every icon on the page.
|
|
92
|
+
Flipped to `true` in the manifest `.then()` handler below. */
|
|
93
|
+
let registryReady = hasViteGlob;
|
|
94
|
+
|
|
87
95
|
// Non-Vite environments (plain static serving): fetch the build-time
|
|
88
96
|
// manifest in the background and rebuild `weightModules` with lazy
|
|
89
97
|
// fetch-based loaders. No top-level await — the module finishes loading
|
|
@@ -102,6 +110,7 @@ if (!hasViteGlob) {
|
|
|
102
110
|
});
|
|
103
111
|
return [weight, Object.fromEntries(entries)];
|
|
104
112
|
}));
|
|
113
|
+
registryReady = true;
|
|
105
114
|
// icon-ui elements that asked for an icon before the manifest loaded
|
|
106
115
|
// gave up silently (resolveLoader returned null on EMPTY_WEIGHTS).
|
|
107
116
|
// Re-request every <icon-ui name> now that a real loader map is in
|
|
@@ -114,7 +123,25 @@ if (!hasViteGlob) {
|
|
|
114
123
|
if (name) loadIcon(name, weight);
|
|
115
124
|
}
|
|
116
125
|
}
|
|
117
|
-
}).catch(() => { /* keep EMPTY_WEIGHTS */ });
|
|
126
|
+
}).catch(() => { /* keep EMPTY_WEIGHTS — registryReady stays false */ });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* Track which (name, weight) pairs we've already warned about so the
|
|
130
|
+
console stays readable even when a missing icon is used many times on
|
|
131
|
+
a page (e.g. a broken Phosphor name in a list row repeated 50×). */
|
|
132
|
+
const warnedMissingIcons = new Set();
|
|
133
|
+
function warnMissingIcon(name, weight) {
|
|
134
|
+
const key = cacheKey(name, weight);
|
|
135
|
+
if (warnedMissingIcons.has(key)) return;
|
|
136
|
+
warnedMissingIcons.add(key);
|
|
137
|
+
const weightNote = weight !== DEFAULT_WEIGHT ? ` at weight="${weight}"` : '';
|
|
138
|
+
const alias = ICON_ALIASES[name];
|
|
139
|
+
const aliasNote = alias ? ` (alias "${name}" → "${alias}" — also not found)` : '';
|
|
140
|
+
console.warn(
|
|
141
|
+
`[icon-ui] Icon "${name}"${weightNote} not found in the registry${aliasNote}. ` +
|
|
142
|
+
`Check the name against @phosphor-icons/core, use an alias in ICON_ALIASES, ` +
|
|
143
|
+
`or register a custom SVG via registerIcon().`
|
|
144
|
+
);
|
|
118
145
|
}
|
|
119
146
|
|
|
120
147
|
/**
|
|
@@ -213,6 +240,13 @@ async function loadIcon(name, weight = DEFAULT_WEIGHT) {
|
|
|
213
240
|
|
|
214
241
|
if (!loader) {
|
|
215
242
|
pending.delete(key);
|
|
243
|
+
// Warn once per (name, weight) — but only if the registry is
|
|
244
|
+
// known-complete. In non-Vite pre-manifest mode, `weightModules` is
|
|
245
|
+
// empty and every lookup fails legitimately until the manifest
|
|
246
|
+
// resolves; warning there would be noise. The manifest `.then()`
|
|
247
|
+
// handler above re-triggers loadIcon for every element, so misses
|
|
248
|
+
// that survive the re-query are the ones worth warning about.
|
|
249
|
+
if (registryReady) warnMissingIcon(name, weight);
|
|
216
250
|
return;
|
|
217
251
|
}
|
|
218
252
|
|
package/core/provider.js
CHANGED
|
@@ -26,7 +26,7 @@ import { AdiaElement } from './element.js';
|
|
|
26
26
|
import { BaseController } from './controller.js';
|
|
27
27
|
|
|
28
28
|
// ═══════════════════════════════════════════════════════════════
|
|
29
|
-
//
|
|
29
|
+
// ADIA PROVIDER (inline — tiny base class)
|
|
30
30
|
// ═══════════════════════════════════════════════════════════════
|
|
31
31
|
|
|
32
32
|
class AdiaProvider extends AdiaElement {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-components",
|
|
3
|
-
"version": "0.0.
|
|
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-
|
|
84
|
-
--a-canvas-text-muted: light-dark(var(--a-neutral-
|
|
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
|
|
|
@@ -97,8 +101,9 @@
|
|
|
97
101
|
|
|
98
102
|
/* L3 — short-alias matrix (the consumable API for neutral surfaces) */
|
|
99
103
|
--a-bg: var(--a-canvas-0);
|
|
100
|
-
--a-bg-
|
|
101
|
-
--a-bg-
|
|
104
|
+
--a-bg-scrim: var(--a-canvas-1-scrim);
|
|
105
|
+
--a-bg-subtle: var(--a-canvas-1);
|
|
106
|
+
--a-bg-muted: var(--a-canvas-2);
|
|
102
107
|
--a-bg-strong: var(--a-canvas-4);
|
|
103
108
|
--a-bg-hover: var(--a-canvas-3);
|
|
104
109
|
--a-bg-active: var(--a-canvas-pressed);
|
|
@@ -139,6 +144,15 @@
|
|
|
139
144
|
--a-accent-active: var(--a-accent-60);
|
|
140
145
|
--a-accent-selected: var(--a-accent-50);
|
|
141
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
|
+
|
|
142
156
|
/* Accent text on accent solid bg — needs light text on dark-ish accent */
|
|
143
157
|
--a-accent-text-strong: light-dark(var(--a-accent-05-shade), var(--a-accent-05-tint));
|
|
144
158
|
--a-accent-text: light-dark(var(--a-accent-10-shade), var(--a-accent-10-tint));
|
|
@@ -465,7 +479,7 @@
|
|
|
465
479
|
══════════════════════════════════════════════════════════════ */
|
|
466
480
|
|
|
467
481
|
--a-ui-bg: var(--a-canvas-0-scrim);
|
|
468
|
-
--a-ui-bg-hover: var(--a-canvas-
|
|
482
|
+
--a-ui-bg-hover: var(--a-canvas-2-scrim);
|
|
469
483
|
--a-ui-bg-active: var(--a-canvas-0);
|
|
470
484
|
--a-ui-bg-selected: var(--a-canvas-bright);
|
|
471
485
|
--a-ui-bg-disabled: var(--a-canvas-1);
|
package/styles/components.css
CHANGED
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
@import "../components/grid/grid.css";
|
|
54
54
|
@import "../components/stack/stack.css";
|
|
55
55
|
@import "../components/chart/chart.css";
|
|
56
|
+
@import "../components/chart-legend/chart-legend.css";
|
|
56
57
|
@import "../components/popover/popover.css";
|
|
57
58
|
@import "../components/accordion/accordion.css";
|
|
58
59
|
@import "../components/divider/divider.css";
|
package/styles/typography.css
CHANGED
|
@@ -600,15 +600,20 @@
|
|
|
600
600
|
|
|
601
601
|
/* ── Links ── */
|
|
602
602
|
:where(a) {
|
|
603
|
-
color: var(--a-
|
|
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;
|