@adia-ai/web-components 0.0.11 → 0.0.13

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.
@@ -18,7 +18,8 @@
18
18
  --select-bg-hover: var(--a-ui-bg-hover);
19
19
  --select-border: var(--a-ui-border);
20
20
  --select-border-hover: var(--a-ui-border-hover);
21
- --select-border-focus: var(--a-ui-border-active);
21
+ --select-focus-ring: var(--a-focus-ring);
22
+ --select-focus-ring-invalid: var(--a-focus-ring-invalid);
22
23
  --select-label-fg: var(--a-ui-text-muted);
23
24
  --select-placeholder-fg: var(--a-ui-text-placeholder);
24
25
  --select-caret-fg: var(--a-ui-text-muted);
@@ -97,9 +98,14 @@
97
98
  }
98
99
  :scope:focus-visible { outline: none; }
99
100
  :scope:focus-visible [slot="trigger"] {
100
- border-color: var(--select-border-focus);
101
+ /* Canonical ring via L3 token (see semantics.css FOCUS block). */
102
+ box-shadow: var(--select-focus-ring);
101
103
  color: var(--select-fg);
102
104
  }
105
+ :scope[aria-invalid="true"]:focus-visible [slot="trigger"],
106
+ :scope[error]:focus-visible [slot="trigger"] {
107
+ box-shadow: var(--select-focus-ring-invalid);
108
+ }
103
109
  :scope:focus-visible::before {
104
110
  color: var(--select-fg-subtle);
105
111
  }
@@ -156,7 +162,6 @@
156
162
  --select-bg: transparent;
157
163
  --select-border: transparent;
158
164
  --select-border-hover: transparent;
159
- --select-border-focus: transparent;
160
165
  }
161
166
  :scope[variant="ghost"] [slot="trigger"]:hover {
162
167
  background: var(--select-ghost-bg-hover);
@@ -174,7 +179,7 @@ select-ui [slot="listbox"] {
174
179
  padding: var(--a-space-1);
175
180
  border: 1px solid var(--a-ui-border);
176
181
  border-radius: var(--a-radius);
177
- background: var(--a-bg-subtle);
182
+ background: var(--a-canvas-bright);
178
183
  box-shadow: var(--a-shadow-lg);
179
184
  max-height: 15rem;
180
185
  overflow-y: auto;
@@ -1,9 +1,14 @@
1
1
  /**
2
- * <slider-ui label="Width" value="63" min="0" max="200" step="1" suffix="rem"></slider-ui>
2
+ * <field-ui label="Width">
3
+ * <slider-ui value="63" min="0" max="200" step="1" suffix="rem"></slider-ui>
4
+ * </field-ui>
3
5
  *
4
- * Layout:
5
- * [label] [value] [suffix]
6
+ * Layout inside the field:
7
+ * [field label] [value] [suffix]
6
8
  * [====fill====●─────────────────track──────]
9
+ *
10
+ * Bare `<slider-ui label="…">` still works but logs a deprecation warning
11
+ * asking you to wrap in <field-ui> for proper label association.
7
12
  */
8
13
 
9
14
  import { AdiaFormElement } from '../../core/form.js';
@@ -5,7 +5,8 @@
5
5
  --textarea-fg: var(--a-ui-text);
6
6
  --textarea-border: var(--a-ui-border);
7
7
  --textarea-border-hover: var(--a-ui-border-hover);
8
- --textarea-border-focus: var(--a-ui-border-active);
8
+ --textarea-focus-ring: var(--a-focus-ring);
9
+ --textarea-focus-ring-invalid: var(--a-focus-ring-invalid);
9
10
  --textarea-radius: var(--a-radius);
10
11
  --textarea-min-height: calc(var(--a-size) * 2);
11
12
  --textarea-px: var(--a-ui-px);
@@ -69,9 +70,18 @@
69
70
  color: var(--textarea-fg-hover);
70
71
  }
71
72
  :scope:not([disabled]) [slot="text"]:focus {
72
- border-color: var(--textarea-border-focus);
73
+ /* Canonical ring via L3 token (see semantics.css FOCUS block).
74
+ `:focus` (not :focus-visible) is deliberate — the caret lives
75
+ in the contenteditable [slot="text"] span; :focus-visible on
76
+ the host wouldn't match the actual input surface. */
77
+ outline: none;
78
+ box-shadow: var(--textarea-focus-ring);
73
79
  color: var(--textarea-fg-hover);
74
80
  }
81
+ :scope[aria-invalid="true"]:not([disabled]) [slot="text"]:focus,
82
+ :scope[error]:not([disabled]) [slot="text"]:focus {
83
+ box-shadow: var(--textarea-focus-ring-invalid);
84
+ }
75
85
  :scope:not([disabled]) [slot="text"]:focus + [slot="label"],
76
86
  :scope:not([disabled]):focus-within [slot="label"] {
77
87
  color: var(--textarea-label-fg-focus);
@@ -18,6 +18,8 @@
18
18
  --upload-bg-disabled: var(--a-ui-bg-disabled);
19
19
  --upload-fg-disabled: var(--a-ui-text-disabled);
20
20
  --upload-border-disabled: var(--a-ui-border-disabled);
21
+ --upload-focus-ring: var(--a-focus-ring);
22
+ --upload-focus-ring-invalid: var(--a-focus-ring-invalid);
21
23
 
22
24
  /* ── Spacing ── */
23
25
  --upload-dropzone-pad: var(--a-space-6);
@@ -69,8 +71,9 @@
69
71
  color: var(--upload-fg-hover);
70
72
  }
71
73
  [data-dropzone]:focus-visible {
72
- outline: 2px solid var(--upload-border-hover);
73
- outline-offset: 2px;
74
+ /* Canonical ring via L3 token (see semantics.css FOCUS block). */
75
+ outline: none;
76
+ box-shadow: var(--upload-focus-ring);
74
77
  }
75
78
  [data-dropzone][data-dragover] {
76
79
  border-color: var(--upload-border-dragover);
@@ -0,0 +1,234 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { AdiaElement, signal, effect } from './element.js';
3
+
4
+ // ── Shared helpers ──
5
+
6
+ let registrationCounter = 0;
7
+ function registerTestElement(BaseImpl) {
8
+ const tag = `test-el-${++registrationCounter}`;
9
+ customElements.define(tag, BaseImpl);
10
+ return tag;
11
+ }
12
+
13
+ function mount(tag, attrs = {}) {
14
+ const el = document.createElement(tag);
15
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
16
+ document.body.appendChild(el);
17
+ return el;
18
+ }
19
+
20
+ // Signals flush via queueMicrotask — await one tick to let reactive
21
+ // effects settle after a mutation.
22
+ const tick = () => new Promise((r) => queueMicrotask(r));
23
+
24
+ describe('AdiaElement — construction + lifecycle', () => {
25
+ beforeEach(() => { document.body.innerHTML = ''; });
26
+
27
+ it('instantiates and calls connected() on mount, disconnected() on remove', () => {
28
+ const events = [];
29
+ class El extends AdiaElement {
30
+ connected() { events.push('connected'); }
31
+ disconnected() { events.push('disconnected'); }
32
+ }
33
+ const tag = registerTestElement(El);
34
+ const el = mount(tag);
35
+ expect(events).toEqual(['connected']);
36
+ el.remove();
37
+ expect(events).toEqual(['connected', 'disconnected']);
38
+ });
39
+
40
+ it('remount after disconnect re-runs connected()', () => {
41
+ const events = [];
42
+ class El extends AdiaElement {
43
+ connected() { events.push('connected'); }
44
+ disconnected() { events.push('disconnected'); }
45
+ }
46
+ const tag = registerTestElement(El);
47
+ const el = mount(tag);
48
+ el.remove();
49
+ document.body.appendChild(el);
50
+ expect(events).toEqual(['connected', 'disconnected', 'connected']);
51
+ });
52
+ });
53
+
54
+ describe('AdiaElement — properties', () => {
55
+ beforeEach(() => { document.body.innerHTML = ''; });
56
+
57
+ it('reads default value from static properties', () => {
58
+ class El extends AdiaElement {
59
+ static properties = { label: { type: String, default: 'hi' } };
60
+ }
61
+ const tag = registerTestElement(El);
62
+ const el = mount(tag);
63
+ expect(el.label).toBe('hi');
64
+ });
65
+
66
+ it('reflects Boolean properties to HTML attributes when reflect: true', () => {
67
+ class El extends AdiaElement {
68
+ static properties = { disabled: { type: Boolean, default: false, reflect: true } };
69
+ }
70
+ const tag = registerTestElement(El);
71
+ const el = mount(tag);
72
+ expect(el.hasAttribute('disabled')).toBe(false);
73
+ el.disabled = true;
74
+ expect(el.hasAttribute('disabled')).toBe(true);
75
+ el.disabled = false;
76
+ expect(el.hasAttribute('disabled')).toBe(false);
77
+ });
78
+
79
+ it('reflects String properties as attribute values', () => {
80
+ class El extends AdiaElement {
81
+ static properties = { variant: { type: String, default: 'neutral', reflect: true } };
82
+ }
83
+ const tag = registerTestElement(El);
84
+ const el = mount(tag);
85
+ el.variant = 'accent';
86
+ expect(el.getAttribute('variant')).toBe('accent');
87
+ });
88
+
89
+ it('parses incoming attributes back into typed properties', () => {
90
+ class El extends AdiaElement {
91
+ static properties = {
92
+ count: { type: Number, default: 0 },
93
+ active: { type: Boolean, default: false },
94
+ };
95
+ }
96
+ const tag = registerTestElement(El);
97
+ const el = mount(tag, { count: '42', active: '' });
98
+ expect(el.count).toBe(42);
99
+ expect(el.active).toBe(true);
100
+ });
101
+
102
+ it('does NOT reflect when reflect: false / omitted', () => {
103
+ class El extends AdiaElement {
104
+ static properties = { internal: { type: String, default: 'x' } };
105
+ }
106
+ const tag = registerTestElement(El);
107
+ const el = mount(tag);
108
+ el.internal = 'y';
109
+ expect(el.hasAttribute('internal')).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe('AdiaElement — reactive effect', () => {
114
+ beforeEach(() => { document.body.innerHTML = ''; });
115
+
116
+ it('calls render() on mount and when a reactive property changes', async () => {
117
+ let renders = 0;
118
+ class El extends AdiaElement {
119
+ static properties = { label: { type: String, default: 'a' } };
120
+ render() { renders++; void this.label; }
121
+ }
122
+ const tag = registerTestElement(El);
123
+ const el = mount(tag);
124
+ expect(renders).toBeGreaterThan(0);
125
+ const before = renders;
126
+ el.label = 'b';
127
+ await tick();
128
+ expect(renders).toBeGreaterThan(before);
129
+ });
130
+
131
+ it('stops re-rendering after disconnect', async () => {
132
+ let renders = 0;
133
+ class El extends AdiaElement {
134
+ static properties = { label: { type: String, default: 'a' } };
135
+ render() { renders++; void this.label; }
136
+ }
137
+ const tag = registerTestElement(El);
138
+ const el = mount(tag);
139
+ el.remove();
140
+ const frozen = renders;
141
+ el.label = 'c';
142
+ await tick();
143
+ expect(renders).toBe(frozen);
144
+ });
145
+ });
146
+
147
+ describe('AdiaElement — subscription-leak guard (0.0.10 regression)', () => {
148
+ beforeEach(() => { document.body.innerHTML = ''; });
149
+
150
+ it('child connected() reads do NOT subscribe an outer effect', async () => {
151
+ // The bug: when a parent effect called appendChild(child) while running,
152
+ // child.connectedCallback would synchronously call connected(), and any
153
+ // template-literal reads of child's reactive properties would subscribe
154
+ // the OUTER effect to the CHILD's signals. A later change to a child
155
+ // prop would spuriously re-run the parent effect.
156
+ //
157
+ // Fix: connected() is now wrapped in untracked(…).
158
+ class Child extends AdiaElement {
159
+ static properties = { value: { type: String, default: 'initial', reflect: true } };
160
+ connected() {
161
+ // Read our own reactive prop exactly the way template-literal
162
+ // interpolation would — this read must not subscribe an ancestor
163
+ // effect.
164
+ void this.value;
165
+ }
166
+ }
167
+ const tag = registerTestElement(Child);
168
+
169
+ const trigger = signal(0);
170
+ let outerRuns = 0;
171
+ let childRef = null;
172
+
173
+ const dispose = effect(() => {
174
+ outerRuns++;
175
+ void trigger.value;
176
+ if (!childRef) {
177
+ childRef = document.createElement(tag);
178
+ document.body.appendChild(childRef);
179
+ }
180
+ });
181
+
182
+ const outerRunsAfterMount = outerRuns;
183
+ expect(childRef).not.toBeNull();
184
+
185
+ // Now mutate the CHILD's signal — the outer effect MUST NOT re-run.
186
+ childRef.value = 'changed';
187
+ await tick();
188
+ expect(outerRuns).toBe(outerRunsAfterMount);
189
+
190
+ // Sanity check: mutating the trigger signal the outer effect DID
191
+ // subscribe to re-runs the effect, proving the effect is alive.
192
+ trigger.value = 1;
193
+ await tick();
194
+ expect(outerRuns).toBe(outerRunsAfterMount + 1);
195
+
196
+ dispose();
197
+ });
198
+ });
199
+
200
+ describe('AdiaElement — addTrait', () => {
201
+ beforeEach(() => { document.body.innerHTML = ''; });
202
+
203
+ it('applies a trait once, idempotent on repeat', () => {
204
+ let connects = 0;
205
+ const myTrait = () => ({
206
+ connect() { connects++; },
207
+ disconnect() {},
208
+ });
209
+
210
+ class El extends AdiaElement {}
211
+ const tag = registerTestElement(El);
212
+ const el = mount(tag);
213
+ el.addTrait(myTrait);
214
+ el.addTrait(myTrait);
215
+ expect(connects).toBe(1);
216
+ });
217
+
218
+ it('disconnects all applied traits on remove', () => {
219
+ let connects = 0, disconnects = 0;
220
+ const myTrait = () => ({
221
+ connect() { connects++; },
222
+ disconnect() { disconnects++; },
223
+ });
224
+ class El extends AdiaElement {
225
+ static traits = [myTrait];
226
+ }
227
+ const tag = registerTestElement(El);
228
+ const el = mount(tag);
229
+ expect(connects).toBe(1);
230
+ expect(disconnects).toBe(0);
231
+ el.remove();
232
+ expect(disconnects).toBe(1);
233
+ });
234
+ });
package/core/form.js CHANGED
@@ -178,8 +178,34 @@ export class AdiaFormElement extends AdiaElement {
178
178
  this.addEventListener('invalid', this.#onInvalid);
179
179
  this.addEventListener('input', this.#onInput);
180
180
  this.addEventListener('blur', this.#onBlur, true);
181
+ this.#warnDeprecatedLabel();
181
182
  }
182
183
 
184
+ /**
185
+ * Soft-deprecation warning for the per-control `label` attribute.
186
+ * Wrapping the control in `<field-ui label="…">` (Phase 1 shipped
187
+ * 2026-04-23) gives proper `<label for>` association and composition
188
+ * slots; the per-control `label` attr renders via an inert shadow
189
+ * slot without `[for]`, so click-to-focus doesn't work.
190
+ *
191
+ * One-shot per class so the console isn't spammed on docs pages.
192
+ */
193
+ #warnDeprecatedLabel() {
194
+ const ctor = this.constructor;
195
+ if (!ctor.properties?.label) return;
196
+ if (!this.hasAttribute('label')) return;
197
+ if (AdiaFormElement.#labelWarned.has(ctor)) return;
198
+ AdiaFormElement.#labelWarned.add(ctor);
199
+ // eslint-disable-next-line no-console
200
+ console.warn(
201
+ `[AdiaUI] <${this.localName} label="…"> is deprecated — wrap in ` +
202
+ `<field-ui label="…"> for proper label association. ` +
203
+ `Docs: https://ui-kit.exe.xyz/site/components/field`
204
+ );
205
+ }
206
+
207
+ static #labelWarned = new Set();
208
+
183
209
  disconnected() {
184
210
  super.disconnected();
185
211
  this.removeEventListener('invalid', this.#onInvalid);
package/core/index.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * core barrel — re-exports every public core module.
3
+ *
4
+ * Consumers who want to build on AdiaElement or use the reactive
5
+ * signal system directly do:
6
+ *
7
+ * import { AdiaElement } from '@adia-ai/web-components/core';
8
+ * import { signal, effect } from '@adia-ai/web-components/core';
9
+ *
10
+ * Exports are flat — every symbol from the re-exported files is
11
+ * available at this level. Symbol collisions between files are not
12
+ * allowed (there are none currently; keep it that way).
13
+ */
14
+
15
+ export * from './element.js';
16
+ export * from './form.js';
17
+ export * from './signals.js';
18
+ export * from './template.js';
19
+ export * from './controller.js';
20
+ export * from './provider.js';
21
+ export * from './anchor.js';
22
+ export * from './icons.js';
23
+ export * from './markdown.js';
24
+ export * from './transport.js';
25
+ export * from './polyfills.js';
package/core/markdown.js CHANGED
@@ -19,7 +19,10 @@ export function renderMarkdown(src) {
19
19
  while (i < lines.length) {
20
20
  const line = lines[i];
21
21
 
22
- // Fenced code block
22
+ // Fenced code block — when a language is named, emit <code-ui> so
23
+ // the site picks up the syntax-highlight upgrade shipped in
24
+ // SPEC-CODE-EDITOR-001. Unlabeled fences keep the bare <pre><code>
25
+ // form (no highlight needed; zero CM6 cost).
23
26
  const fence = line.match(/^```(\w*)/);
24
27
  if (fence) {
25
28
  const lang = fence[1];
@@ -30,7 +33,10 @@ export function renderMarkdown(src) {
30
33
  i++;
31
34
  }
32
35
  i++; // skip closing ```
33
- out.push(`<pre><code${lang ? ` data-lang="${lang}"` : ''}>${code.join('\n')}</code></pre>`);
36
+ const body = code.join('\n');
37
+ out.push(lang
38
+ ? `<code-ui language="${lang}">${body}</code-ui>`
39
+ : `<pre><code>${body}</code></pre>`);
34
40
  continue;
35
41
  }
36
42
 
package/index.css ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @adia-ai/web-components — the opinionated all-in-one stylesheet.
3
+ *
4
+ * <link rel="stylesheet" href="@adia-ai/web-components/css" />
5
+ *
6
+ * Imports, in cascade order: design tokens (primitives + semantics
7
+ * + typography — via the colors tree) → component styles → global
8
+ * resets.
9
+ *
10
+ * Pattern CSS (adia-chat, adia-editor, app-shell, etc.) is NOT
11
+ * bundled here — each page links its own patterns explicitly.
12
+ * Opinionated theme overrides live in `./styles/themes.css`; link
13
+ * them separately when you want the 8 named themes.
14
+ *
15
+ * If you need a subset, skip this file and link the layers directly:
16
+ * @adia-ai/web-components/styles/tokens.css
17
+ * @adia-ai/web-components/styles/components.css
18
+ * @adia-ai/web-components/styles/resets.css
19
+ * @adia-ai/web-components/styles/themes.css (optional)
20
+ * @adia-ai/web-components/styles/prose.css (optional)
21
+ * @adia-ai/web-components/styles/typography.css (standalone)
22
+ */
23
+
24
+ @import "./styles/tokens.css";
25
+ @import "./styles/components.css";
26
+ @import "./styles/resets.css";
package/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @adia-ai/web-components — main entry.
3
+ *
4
+ * import '@adia-ai/web-components';
5
+ * import { AdiaButton, AdiaAppShell } from '@adia-ai/web-components';
6
+ *
7
+ * Loading this file registers every component + pattern custom
8
+ * element (side effect of each module's `customElements.define(...)`
9
+ * call). Pair with `@adia-ai/web-components/css` (the all-in-one
10
+ * stylesheet) or link tokens/components/resets individually.
11
+ *
12
+ * If you only want a subset, use the subpath imports:
13
+ * import '@adia-ai/web-components/components/button';
14
+ * import { AdiaAppShell } from '@adia-ai/web-components/patterns';
15
+ */
16
+
17
+ export * from './components/index.js';
18
+ export * from './patterns/index.js';
package/package.json CHANGED
@@ -1,14 +1,22 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
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": {
7
- ".": "./index.js",
8
- "./css": "./index.css",
9
- "./a2ui": "./a2ui/index.js",
10
- "./core": "./core/index.js",
11
- "./patterns": "./patterns/index.js"
7
+ ".": "./index.js",
8
+ "./css": "./index.css",
9
+ "./a2ui": "./a2ui/index.js",
10
+ "./core": "./core/index.js",
11
+ "./core/*": "./core/*.js",
12
+ "./components": "./components/index.js",
13
+ "./components/*": "./components/*/*.js",
14
+ "./patterns": "./patterns/index.js",
15
+ "./patterns/*": "./patterns/*/*.js",
16
+ "./styles/*": "./styles/*",
17
+ "./traits": "./traits/index.js",
18
+ "./traits/*": "./traits/*.js",
19
+ "./package.json": "./package.json"
12
20
  },
13
21
  "files": [
14
22
  "core/",
@@ -1,6 +1,6 @@
1
1
  import { AdiaElement } from '../../core/element.js';
2
2
  import { renderMarkdown } from '../../core/markdown.js';
3
- import { streamChat } from '../../../gen-ui/llm/adapters/index.js';
3
+ import { streamChat } from '../../../a2ui/compose/llm/adapters/index.js';
4
4
 
5
5
  function escapeHTML(s) {
6
6
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -419,28 +419,55 @@
419
419
  --a-danger-border-invalid: var(--a-danger-border);
420
420
 
421
421
  /* ══════════════════════════════════════════════════════════════
422
- FOCUS
423
- ══════════════════════════════════════════════════════════════ */
424
-
425
- --a-focus-color: var(--a-accent-strong);
426
- --a-focus-width: 2px;
427
- --a-focus-offset: 2px;
428
- --a-focus-ring-shadow: 0 0 0 var(--a-focus-offset) var(--a-canvas-0),
422
+ FOCUS — canonical ring recipe shared by all form controls
423
+ ══════════════════════════════════════════════════════════════
424
+
425
+ The ring is a dual-layer box-shadow: an inner "gap" the canvas
426
+ color wide, then an outer ring the accent color wide. Tuning any
427
+ of the scalar tokens below cascades to every component that
428
+ consumes `--a-focus-ring`:
429
+
430
+ --a-focus-width — outer ring thickness
431
+ --a-focus-offset — inner gap thickness (distance from element
432
+ edge to ring)
433
+ --a-focus-color — ring color (default: accent-strong)
434
+ --a-focus-bg — gap color (default: the surface under the
435
+ control; normally matches --a-canvas-0)
436
+
437
+ Invalid / error state uses the same geometry but a danger color
438
+ via `--a-focus-ring-invalid`.
439
+
440
+ Components must NOT hardcode 2px / 4px / accent hex in their CSS;
441
+ consume either `--a-focus-ring` directly or an L3 alias like
442
+ `--{component}-focus-ring: var(--a-focus-ring)` (the more common
443
+ pattern — gives per-component override headroom without breaking
444
+ the canonical default). */
445
+
446
+ --a-focus-color: var(--a-accent-strong);
447
+ --a-focus-color-invalid: var(--a-danger);
448
+ --a-focus-width: 2px;
449
+ --a-focus-offset: 2px;
450
+ --a-focus-bg: var(--a-canvas-0);
451
+
452
+ --a-focus-ring-shadow: 0 0 0 var(--a-focus-offset) var(--a-focus-bg),
429
453
  0 0 0 calc(var(--a-focus-offset) + var(--a-focus-width)) var(--a-focus-color);
454
+ --a-focus-ring-shadow-invalid: 0 0 0 var(--a-focus-offset) var(--a-focus-bg),
455
+ 0 0 0 calc(var(--a-focus-offset) + var(--a-focus-width)) var(--a-focus-color-invalid);
430
456
 
431
- /* L3 */
432
- --a-ring: var(--a-focus-color);
433
- --a-ring-invalid: var(--a-danger);
434
- --a-focus-ring: var(--a-focus-ring-shadow);
457
+ /* L3 — what components consume. */
458
+ --a-ring: var(--a-focus-color);
459
+ --a-ring-invalid: var(--a-focus-color-invalid);
460
+ --a-focus-ring: var(--a-focus-ring-shadow);
461
+ --a-focus-ring-invalid: var(--a-focus-ring-shadow-invalid);
435
462
 
436
463
  /* ══════════════════════════════════════════════════════════════
437
464
  UI (FIELD) — Form input surfaces
438
465
  ══════════════════════════════════════════════════════════════ */
439
466
 
440
- --a-ui-bg: var(--a-neutral-0-scrim);
441
- --a-ui-bg-hover: var(--a-neutral-2-scrim);
467
+ --a-ui-bg: var(--a-canvas-0-scrim);
468
+ --a-ui-bg-hover: var(--a-canvas-0-scrim);
442
469
  --a-ui-bg-active: var(--a-canvas-0);
443
- --a-ui-bg-selected: var(--a-canvas-0-scrim);
470
+ --a-ui-bg-selected: var(--a-canvas-bright);
444
471
  --a-ui-bg-disabled: var(--a-canvas-1);
445
472
  --a-ui-bg-invalid: var(--a-danger-muted);
446
473