@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.
@@ -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
- // NANO PROVIDER (inline — tiny base class)
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.13",
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
 
@@ -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-subtle: var(--a-canvas-2);
101
- --a-bg-muted: var(--a-canvas-1);
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-0-scrim);
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);
@@ -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";
@@ -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;