@adia-ai/web-components 0.6.35 → 0.6.36

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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.6.36] — 2026-05-24
4
+
5
+ ### Fixed — `<tag-ui>` dismiss X + slotted icons now inherit the variant text color
6
+
7
+ - **Visible after the solid-default flip**: the dismiss X (`[slot="dismiss"]`) was hardcoded to `--a-fg-muted` (a neutral grey) regardless of the host's variant, so the X disappeared against saturated solid pills — a near-invisible grey X on the Info/Success/Warning/Error bgs. Same trap on any slotted leading icon: tag had no `currentColor` inheritance rule, so `<icon-ui>` children rendered in their own foreground color rather than the variant's.
8
+ - **Fix**: dismiss `color` token now defaults to `currentColor` at `opacity: 0.7` (quieter than the label, but tracks every variant — near-white X on saturated bg, dark X on warning amber, fg-muted X on quiet chrome). Hover restores `opacity: 1` and overlays a `color-mix(in oklch, currentColor 15%, transparent)` bg highlight — same color family as the host, not a stark neutral wash. NEW `:scope > icon-ui { color: currentColor; flex-shrink: 0 }` rule mirrors `<badge-ui>`'s convention so leading-icon legends/status chips read as a single color-coded unit.
9
+ - Two new component tokens for the dismiss opacity (`--tag-dismiss-opacity`, `--tag-dismiss-opacity-hover`) replace the prior color-swap tokens (`--tag-dismiss-fg-hover` removed — opacity-driven hover instead). Files: `components/tag/tag.css`.
10
+
11
+ ### Fixed — `<tag-ui variant="warning">` solid pill: bright amber bg instead of muddy mid-brown
12
+
13
+ - **Visible after the solid-default flip**: the canonical `--a-warning-bg` + `--a-warning-fg` pair collapses for warning. `--a-warning-fg` resolves to `--a-warning-10-shade` (a dark brown — per `semantics.css:309` "warning is light-colored — text on warning fills should be dark"), but `--a-warning-bg` (= `--a-warning-strong` = `-50`) is a mid-tone amber. Dark text on mid-tone amber gives the muddy brown-on-brown look the user reported. The other family variants (info/success/danger) all have `-text-strong = -05-tint` (near-white) which contrasts cleanly against their `-50` saturated bg, so they don't hit this trap.
14
+ - **Fix at the tag layer**: `:scope[variant="warning"]` now uses the literal `--a-warning-20-tint` step (bright caution-tape amber, scheme-independent) as bg + keeps `--a-warning-fg` as dark text. Restores the bright-amber-with-dark-text shape `semantics.css` intends. Other family variants unchanged. Regression guard added that hard-fails any revert to `--a-warning-bg` for the warning solid bg.
15
+ - **Token-system follow-up flagged (not shipped)**: this is a symptom of `--a-warning-bg` pointing at the wrong step. Same muddy contrast likely surfaces wherever `--a-warning-bg` + `--a-warning-fg` is paired (`button-ui variant="warning"`, `chart-ui` warning bars, `rating-ui` filled fg, `progress-row variant="warning"`). A token-side fix that redirects `--a-warning-bg` to `--a-warning-20-tint` would resolve all consumers in one stroke but needs a visual sweep across those components first. Files: `components/tag/tag.{css,test.js}`.
16
+
17
+ ### Changed — `<tag-ui>` family variants default to **solid** fill (BREAKING-visual)
18
+
19
+ - **`<tag-ui variant="info|success|warning|danger">` now renders as a saturated bg + on-strong (near-white) text** — the chip IS the state, not a tinted metadata label. Aligns the chip vocabulary so consumers can read tag-ui as a status pill at a glance. Previously: muted tinted bg with `--a-{family}-bg` (saturated solid) text — an off-canonical pair that read as "passive metadata with bold color" and diverged from `<badge-ui>`'s canonical muted pair.
20
+ - **Opt-out**: NEW `[tone="muted"]` attribute drops a family variant back to the canonical muted pair `--a-{family}-muted` + `--a-{family}-text` (scheme-flipping text — the same pair `<badge-ui>` uses as its default). Use on metadata chips in dense lists where the saturated default would compete for attention.
21
+ - **The `default` variant (no family) is unchanged** — it stays as quiet `--a-bg-muted` + `--a-fg` chrome. Opt in to a high-contrast inverse stamp via `<tag-ui tone="solid">` (no variant); resolves to `--a-fg` bg + `--a-bg` fg.
22
+ - **Author migration**: existing `<tag-ui variant="info|success|warning|danger">` markup auto-flips to the new saturated default — every existing tag in the codebase becomes louder. If any specific surface needs the prior look, append `tone="muted"`. 75 occurrences across 30 substrate/app/playground files identified; review per surface.
23
+ - Source-grep regression guards added: 4 family variants verified to use the solid pair as default; 4 `[tone="muted"][variant=…]` rules verified to use the canonical muted pair; `[variant="default"]` verified unchanged; `[tone="solid"]` neutral inverse verified. Files: `components/tag/tag.{css,yaml,test.js,examples.html,a2ui.json}` + regenerated corpus catalog.
24
+
25
+ ### Fixed — placeholder pseudo no longer pushes the caret to its right after type-then-delete
26
+
27
+ - **Bug report**: with `<input-ui placeholder="Select country…">`, typing text then deleting it left the caret visually at the END of the placeholder text instead of at position 0. Same trap on `<textarea-ui>`, `<combobox-ui>`, and `<tags-input-ui>`. Root cause: the empty-state placeholder is implemented via `[data-empty]::before { content: attr(data-placeholder); }`, rendered as an **inline** pseudo box. When the contenteditable is empty, the caret IS at offset 0 of the empty text node — but the in-flow `::before` pseudo precedes the text content, occupying inline space, so the caret renders to the right of the pseudo's box. (Only happens after type-then-delete because that path leaves the contenteditable focused, making the caret visible at its true position-0 location.)
28
+ - **Fix**: pseudo is now `position: absolute; inset: 0; padding: inherit` — fills the host's content-box without occupying inline-flow space, so the caret renders at the actual content-start where it belongs. Host elements gain `position: relative` as the positioning anchor. For `<tags-input-ui>` + `<combobox-ui>` the pseudo also uses `display: flex; align-items: center` so the placeholder text vertically aligns with the host's flex-centered line-box.
29
+ - Regression guards added to `input.test.js` — three source-grep assertions (`:scope` declares `position: relative`, pseudo declares `position: absolute`, pseudo carries `inset: 0` + `padding: inherit`) that hard-fail if the in-flow shape ever re-appears.
30
+ - Files: `components/input/input.{css,test.js}`, `components/textarea/textarea.css`, `components/tags-input/tags-input.css`, `components/combobox/combobox.css`.
31
+
3
32
  ## [0.6.35] — 2026-05-24
4
33
 
5
34
  ### Fixed — `<popover-ui>` + `<menu-ui>` yaml `slots:` blocks declare the canonical `trigger` / `content` vocabulary (FB-62)
@@ -130,11 +130,23 @@
130
130
  line-height: 1.4;
131
131
  white-space: nowrap;
132
132
  overflow: hidden;
133
+ /* Positioning context for the [data-empty]::before placeholder pseudo
134
+ (kept out of inline flow so the caret renders at content-start). */
135
+ position: relative;
133
136
  }
137
+ /* Empty-state placeholder. Absolutely positioned (NOT inline) so an
138
+ in-flow pseudo box doesn't push the caret-at-position-0 to the right
139
+ of the placeholder text after type-then-delete. Same pattern
140
+ input-ui + textarea-ui + tags-input-ui use. */
134
141
  [data-input][data-empty]::before {
135
142
  content: attr(data-placeholder);
136
143
  color: var(--combobox-placeholder-fg, var(--combobox-placeholder-fg-default));
137
144
  pointer-events: none;
145
+ position: absolute;
146
+ inset: 0;
147
+ padding: inherit;
148
+ display: flex;
149
+ align-items: center;
138
150
  }
139
151
 
140
152
  [data-clear] {
@@ -485,7 +485,7 @@ export class UIDateRangePicker extends UIFormElement {
485
485
  // Matches select-ui + calendar-picker-ui's anchorPopover pattern.
486
486
  this.#anchorCleanup?.();
487
487
  this.#anchorCleanup = anchorPopover(this.#triggerRef, this.#popoverRef, {
488
- placement: this.getAttribute('placement') || 'bottom-start',
488
+ placement: this.getAttribute('placement') || 'bottom',
489
489
  gap: 4,
490
490
  });
491
491
  document.addEventListener('pointerdown', this.#onOutside);
@@ -62,11 +62,14 @@
62
62
  }
63
63
 
64
64
  /* ── Block 2 — BASE ── */
65
+ /* Host is a transparent inline wrapper — trigger button-ui paints its
66
+ own surface. Mirrors the datetime-picker decision (2026-05-24): a host
67
+ background paint creates an off-axis rectangle inside field-ui because
68
+ the field-ui's value-cell expands beyond the inline-block host. */
65
69
  :scope {
66
70
  box-sizing: border-box;
67
71
  position: relative;
68
72
  display: inline-block;
69
- background: var(--date-range-picker-bg, var(--date-range-picker-bg-default));
70
73
  color: var(--date-range-picker-fg, var(--date-range-picker-fg-default));
71
74
  font-size: var(--a-ui-size);
72
75
  }
@@ -38,11 +38,17 @@
38
38
  }
39
39
 
40
40
  /* ── Block 2 — BASE ── */
41
+ /* Host is a transparent inline wrapper. Painting `background` on the host
42
+ used to create an off-axis rectangle behind the trigger when the host
43
+ sat inside a field-ui (the field-ui's value-cell expanded but the
44
+ inline-block host only sized to its child). Trigger button-ui paints
45
+ its own surface — host stays transparent.
46
+ 2026-05-24 QA: "odd background colors and block element container for
47
+ inline trigger" — consumer report on /site/components/datetime-picker. */
41
48
  :scope {
42
49
  box-sizing: border-box;
43
50
  position: relative;
44
51
  display: inline-block;
45
- background: var(--datetime-picker-bg, var(--datetime-picker-bg-default));
46
52
  color: var(--datetime-picker-fg, var(--datetime-picker-fg-default));
47
53
  font-size: var(--a-ui-size);
48
54
  }
@@ -121,6 +121,10 @@ input-ui:not([disabled]) [slot="field"]:hover [slot="suffix"] {
121
121
  outline: none;
122
122
  white-space: nowrap;
123
123
  overflow: hidden;
124
+ /* Positioning context for the [data-empty]::before placeholder
125
+ pseudo, which is taken out of inline flow so the caret renders
126
+ at the actual content-start (not after the pseudo box). */
127
+ position: relative;
124
128
  }
125
129
 
126
130
  /* Text (native input — password only) */
@@ -138,11 +142,21 @@ input-ui:not([disabled]) [slot="field"]:hover [slot="suffix"] {
138
142
  color: var(--input-placeholder-fg, var(--input-placeholder-fg-default));
139
143
  }
140
144
 
141
- /* Placeholder (contenteditable only) */
145
+ /* Placeholder (contenteditable only).
146
+ Out-of-flow positioning is load-bearing — an in-flow ::before with
147
+ `content: attr(...)` puts the pseudo box INLINE before the empty
148
+ text node, which means the caret-at-position-0 visually renders to
149
+ the RIGHT of the placeholder text (after the user types and deletes).
150
+ `position: absolute; inset: 0; padding: inherit` makes the pseudo
151
+ fill the host's content-box without occupying any inline-flow space,
152
+ so the caret renders at the actual content-start where it belongs. */
142
153
  span[slot="text"][data-empty]::before {
143
154
  content: attr(data-placeholder);
144
155
  color: var(--input-placeholder-fg, var(--input-placeholder-fg-default));
145
156
  pointer-events: none;
157
+ position: absolute;
158
+ inset: 0;
159
+ padding: inherit;
146
160
  }
147
161
 
148
162
  /* ── Number mode (type="number") ──
@@ -10,9 +10,15 @@
10
10
  */
11
11
 
12
12
  import { describe, it, expect, beforeEach } from 'vitest';
13
+ import { readFileSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, resolve } from 'node:path';
13
16
  import '../../core/element.js';
14
17
  import './input.js';
15
18
 
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const INPUT_CSS = readFileSync(resolve(__dirname, 'input.css'), 'utf8');
21
+
16
22
  const tick = () => new Promise((r) => queueMicrotask(r));
17
23
 
18
24
  function mount(html) {
@@ -220,3 +226,37 @@ describe('input-ui — §220 (v0.5.9) throttle parity', () => {
220
226
  expect(events).toEqual([]);
221
227
  });
222
228
  });
229
+
230
+ // ── Placeholder caret-position regression guard ─────────────────────
231
+ //
232
+ // Per the v0.6.35 bug report: with the `::before { content: attr(data-placeholder) }`
233
+ // pseudo rendered in inline flow, typing-then-deleting left the caret
234
+ // visually at the END of the placeholder text (not at position 0) because
235
+ // the in-flow pseudo box pushed the caret rendering position to its right.
236
+ // Fix: pseudo is `position: absolute` so it doesn't occupy inline-flow
237
+ // space; the host carries `position: relative` as its anchor.
238
+ // These tests assert the CSS-source contract that prevents accidental
239
+ // revert to the in-flow pseudo shape.
240
+
241
+ describe('input-ui — CSS source contract: placeholder pseudo is out of flow', () => {
242
+ it('host [slot="text"] declares position: relative (pseudo anchor)', () => {
243
+ expect(INPUT_CSS).toMatch(
244
+ /\[slot="text"\]\s*\{[^}]*position:\s*relative/s
245
+ );
246
+ });
247
+
248
+ it('[data-empty]::before is position: absolute (not inline-flow)', () => {
249
+ expect(INPUT_CSS).toMatch(
250
+ /span\[slot="text"\]\[data-empty\]::before\s*\{[^}]*position:\s*absolute/s
251
+ );
252
+ });
253
+
254
+ it('[data-empty]::before fills the host content-box (inset: 0 + padding: inherit)', () => {
255
+ expect(INPUT_CSS).toMatch(
256
+ /span\[slot="text"\]\[data-empty\]::before\s*\{[^}]*inset:\s*0/s
257
+ );
258
+ expect(INPUT_CSS).toMatch(
259
+ /span\[slot="text"\]\[data-empty\]::before\s*\{[^}]*padding:\s*inherit/s
260
+ );
261
+ });
262
+ });
@@ -40,12 +40,14 @@ export class UISearch extends UIFormElement {
40
40
  this.setAttribute('role', 'search');
41
41
 
42
42
  if (!this.querySelector('input-ui')) {
43
+ const size = this.getAttribute('size');
43
44
  this.innerHTML = `<input-ui
44
45
  type="search"
45
46
  prefix="magnifying-glass"
46
47
  suffix="x-circle"
47
48
  placeholder="${this.placeholder}"
48
49
  ${this.disabled ? 'disabled' : ''}
50
+ ${size ? `size="${size}"` : ''}
49
51
  ></input-ui>`;
50
52
  }
51
53
 
@@ -290,6 +290,7 @@ export class UITableToolbar extends UIElement {
290
290
  // name renders as literal text before the icon registry resolves.
291
291
  const search = document.createElement('search-ui');
292
292
  search.setAttribute('data-search', '');
293
+ search.setAttribute('size', 'sm'); // match toolbar buttons + badge + filter chips
293
294
  search.setAttribute('placeholder', this.placeholder);
294
295
  search.setAttribute('debounce', String(SEARCH_DEBOUNCE));
295
296
  search.addEventListener('search', this.#onSearch);
@@ -44,6 +44,15 @@
44
44
  "description": "Tag label. Renderer routes this to the `text` attribute, rendered via CSS attr(text) on ::after.",
45
45
  "$ref": "common_types.json#/$defs/DynamicString"
46
46
  },
47
+ "tone": {
48
+ "description": "Fill style — orthogonal to [variant]. Default (`solid`) for family\nvariants is a saturated bg with on-strong (near-white) text — the\nchip IS the state. Opt-out via `tone=\"muted\"` for a tinted bg with\nscheme-paired text (matches <badge-ui>'s default look) when the\nchip is metadata in a dense list rather than a status stamp. The\n`default` variant stays quiet chrome regardless of tone unless\n`tone=\"solid\"` is set explicitly (high-contrast neutral inverse).\n",
49
+ "type": "string",
50
+ "enum": [
51
+ "solid",
52
+ "muted"
53
+ ],
54
+ "default": "solid"
55
+ },
47
56
  "variant": {
48
57
  "description": "Semantic variant — `default | info | success | warning | danger`.",
49
58
  "type": "string",
@@ -42,13 +42,20 @@ export class UITag extends UIElement {
42
42
  // `tag.textContent = …` working natively; the prop declaration
43
43
  // broke that path. v0.5.x §327.
44
44
  variant: { type: String, default: 'default', reflect: true },
45
+ // `tone` is read-only via attribute selectors (CSS), not by the
46
+ // class — declared in yaml for documentation + a2ui hint but NOT
47
+ // in `static properties` so the runtime doesn't reflect an
48
+ // ever-present `tone="solid"` attribute onto every <tag-ui>
49
+ // (which would defeat the variant-default cascade and force CSS
50
+ // selectors to compete with reflected defaults). Author sets
51
+ // `[tone]` directly when opting in.
45
52
  size: { type: String, default: 'md', reflect: true },
46
53
  removable: { type: Boolean, default: false, reflect: true },
47
54
  disabled: { type: Boolean, default: false, reflect: true },
48
55
  };
49
56
 
50
57
  static parts = {
51
- dismiss: '<button slot="dismiss" type="button" aria-label="Remove"><icon-ui name="x"></icon-ui></button>',
58
+ dismiss: '<button slot="dismiss" type="button" aria-label="Remove"><icon-ui name="x" weight="bold"></icon-ui></button>',
52
59
  };
53
60
 
54
61
  static template = () => null;
@@ -27,11 +27,21 @@ tag-ui[removable]:not([disabled]):hover {
27
27
  --tag-duration-default: var(--a-duration-fast);
28
28
  --tag-easing-default: var(--a-easing);
29
29
 
30
- /* ── Dismiss ── */
30
+ /* ── Dismiss ──
31
+ Rest = inherit the host's text color at reduced opacity (so the X
32
+ reads as quieter chrome than the label, but tracks every variant
33
+ — info/success/danger get a near-white X on saturated bg; warning
34
+ gets a dark-brown X on bright amber; default gets a fg-muted X on
35
+ quiet chrome). Hover restores full opacity + drops a translucent
36
+ overlay in the same color family for the affordance.
37
+ Pre-v0.6.36 used `--a-fg-muted` / `--a-fg` directly, which gave
38
+ a neutral-grey X on saturated solid pills — the X disappeared
39
+ against the variant bg. */
31
40
  --tag-dismiss-radius-default: var(--a-radius-full);
32
- --tag-dismiss-fg-default: var(--a-fg-muted);
33
- --tag-dismiss-fg-hover-default: var(--a-fg);
34
- --tag-dismiss-bg-hover-default: var(--a-bg-muted);
41
+ --tag-dismiss-fg-default: currentColor;
42
+ --tag-dismiss-opacity-default: 0.85;
43
+ --tag-dismiss-opacity-hover-default: 1;
44
+ --tag-dismiss-bg-hover-default: color-mix(in oklch, currentColor 18%, transparent);
35
45
  text-align: start; /* §text-align-reset — blocks inheritance from centered ancestors */
36
46
  }
37
47
 
@@ -64,37 +74,81 @@ tag-ui[removable]:not([disabled]):hover {
64
74
  content: attr(text);
65
75
  }
66
76
 
67
- /* ── Variants ── */
77
+ /* ── Variants ──
78
+ Default tone is `solid` for the four family variants:
79
+ `--a-{family}-bg` (saturated surface) + `--a-{family}-fg` (on-strong
80
+ text, near-white in both schemes by design). Reads as a status pill
81
+ where the chip IS the state. Opt out per-tag via [tone="muted"] for
82
+ metadata-chip surfaces in dense lists. */
68
83
  :scope[variant="info"] {
69
- --tag-bg-default: var(--a-info-muted);
70
- --tag-fg-default: var(--a-info-bg);
84
+ --tag-bg-default: var(--a-info-bg);
85
+ --tag-fg-default: var(--a-info-fg);
71
86
  }
72
87
 
73
88
  :scope[variant="success"] {
74
- --tag-bg-default: var(--a-success-muted);
75
- --tag-fg-default: var(--a-success-bg);
89
+ --tag-bg-default: var(--a-success-bg);
90
+ --tag-fg-default: var(--a-success-fg);
76
91
  }
77
92
 
93
+ /* `--a-warning-bg` (= --a-warning-strong / -50) is too dark to read
94
+ against `--a-warning-fg` (= -text-strong / -10-shade), giving a
95
+ muddy brown-on-brown chip. Per the semantics.css comment "warning
96
+ is light-colored — text on warning fills should be dark", the
97
+ intent is bright-amber-bg + dark-text. The literal `-20-tint`
98
+ step is bright in BOTH schemes (independent of light-dark swap),
99
+ so warning solid keeps the same caution-tape look across modes.
100
+ The other family variants stay on `-bg` + `-fg` because their
101
+ -text-strong is `-05-tint` (near-white) which contrasts fine
102
+ against their saturated `-strong` bg. */
78
103
  :scope[variant="warning"] {
79
- --tag-bg-default: var(--a-warning-muted);
80
- --tag-fg-default: var(--a-warning-bg);
104
+ --tag-bg-default: var(--a-warning-20-tint);
105
+ --tag-fg-default: var(--a-warning-fg);
81
106
  }
82
107
 
83
108
  :scope[variant="danger"] {
84
- --tag-bg-default: var(--a-danger-muted);
85
- --tag-fg-default: var(--a-danger-bg);
109
+ --tag-bg-default: var(--a-danger-bg);
110
+ --tag-fg-default: var(--a-danger-fg);
86
111
  }
87
112
 
88
- /* `default` is a semantic alias of the base same tokens as the
89
- unstyled `:scope`, declared explicitly so the yaml enum and the
90
- CSS contract agree. Most consumers omit `variant` and inherit the
91
- base; the explicit selector lets `<tag-ui variant="default">`
92
- render identically without falling through to base. */
113
+ /* `default` (no family) stays as quiet chromea stark fg/bg-inverse
114
+ would be too loud for the no-variant case. Opt in to the inverse
115
+ stamp via `[tone="solid"]` when you want a high-contrast neutral pill. */
93
116
  :scope[variant="default"] {
94
117
  --tag-bg-default: var(--a-bg-muted);
95
118
  --tag-fg-default: var(--a-fg);
96
119
  }
97
120
 
121
+ /* ── Tone modifier — orthogonal to [variant] ──
122
+ `[tone="muted"]` opts each family variant OUT of the solid default
123
+ into the canonical muted pair: `--a-{family}-muted` (tinted surface)
124
+ + `--a-{family}-text` (scheme-flipping text). Same shape <badge-ui>
125
+ uses as its default. Use on metadata chips in dense lists where the
126
+ saturated default would compete for attention. */
127
+ :scope[tone="muted"][variant="info"] {
128
+ --tag-bg-default: var(--a-info-muted);
129
+ --tag-fg-default: var(--a-info-text);
130
+ }
131
+ :scope[tone="muted"][variant="success"] {
132
+ --tag-bg-default: var(--a-success-muted);
133
+ --tag-fg-default: var(--a-success-text);
134
+ }
135
+ :scope[tone="muted"][variant="warning"] {
136
+ --tag-bg-default: var(--a-warning-muted);
137
+ --tag-fg-default: var(--a-warning-text);
138
+ }
139
+ :scope[tone="muted"][variant="danger"] {
140
+ --tag-bg-default: var(--a-danger-muted);
141
+ --tag-fg-default: var(--a-danger-text);
142
+ }
143
+
144
+ /* `[tone="solid"]` on the neutral default (or no variant) inverts the
145
+ chrome — solid fg-color bg with bg-color text. High-contrast neutral
146
+ stamp. Explicit opt-in; the variant-less default stays quiet chrome. */
147
+ :scope[tone="solid"]:not([variant="info"]):not([variant="success"]):not([variant="warning"]):not([variant="danger"]) {
148
+ --tag-bg-default: var(--a-fg);
149
+ --tag-fg-default: var(--a-bg);
150
+ }
151
+
98
152
  /* Size handled by universal [size] attribute system. */
99
153
 
100
154
  /* hover rule moved outside @scope — see Safari 17.x bug note at top. */
@@ -105,6 +159,15 @@ tag-ui[removable]:not([disabled]):hover {
105
159
  box-shadow: var(--tag-focus-ring, var(--tag-focus-ring-default));
106
160
  }
107
161
 
162
+ /* ── Slotted icons (leading) ──
163
+ Icons placed inside the tag (e.g. `<icon-ui name="check">`) inherit
164
+ the host's text color so legend / status chips read as a single
165
+ color-coded unit. Mirrors `<badge-ui>`'s convention. */
166
+ :scope > icon-ui {
167
+ color: currentColor;
168
+ flex-shrink: 0;
169
+ }
170
+
108
171
  /* ── Dismiss button ── */
109
172
  [slot="dismiss"] {
110
173
  display: inline-flex;
@@ -118,13 +181,14 @@ tag-ui[removable]:not([disabled]):hover {
118
181
  cursor: pointer;
119
182
  border-radius: var(--tag-dismiss-radius, var(--tag-dismiss-radius-default));
120
183
  color: var(--tag-dismiss-fg, var(--tag-dismiss-fg-default));
184
+ opacity: var(--tag-dismiss-opacity, var(--tag-dismiss-opacity-default));
121
185
  --a-icon-size: 0.875rem;
122
186
  order: 1; /* push dismiss to end so layout reads [text] [×] */
123
- transition: color var(--tag-duration, var(--tag-duration-default)) var(--tag-easing, var(--tag-easing-default)), background var(--tag-duration, var(--tag-duration-default)) var(--tag-easing, var(--tag-easing-default));
187
+ transition: opacity var(--tag-duration, var(--tag-duration-default)) var(--tag-easing, var(--tag-easing-default)), background var(--tag-duration, var(--tag-duration-default)) var(--tag-easing, var(--tag-easing-default));
124
188
  }
125
189
 
126
190
  [slot="dismiss"]:hover {
127
- color: var(--tag-dismiss-fg-hover, var(--tag-dismiss-fg-hover-default));
191
+ opacity: var(--tag-dismiss-opacity-hover, var(--tag-dismiss-opacity-hover-default));
128
192
  background: var(--tag-dismiss-bg-hover, var(--tag-dismiss-bg-hover-default));
129
193
  }
130
194
 
@@ -41,6 +41,80 @@ describe('tag-ui — content: attr(text) gating', () => {
41
41
  // [icon] + [dismiss] slot rendering and for proper :scope[text]
42
42
  // gap rendering when text IS set.
43
43
  expect(TAG_CSS).toMatch(/:scope\s*\{[^}]*display:\s*inline-flex/);
44
- expect(TAG_CSS).toMatch(/:scope\s*\{[^}]*gap:\s*var\(--tag-gap\)/);
44
+ // Post OD-5 sweep: tokens read via `var(--prop, var(--prop-default))`
45
+ // chain so consumer-named overrides AND --a-* surface overrides both work.
46
+ expect(TAG_CSS).toMatch(/:scope\s*\{[^}]*gap:\s*var\(--tag-gap,\s*var\(--tag-gap-default\)\)/);
47
+ });
48
+ });
49
+
50
+ // ── Tone defaults (solid by default for families; muted as opt-out) ──
51
+ //
52
+ // Default tone is `solid` for family variants — the base `:scope[variant=…]`
53
+ // rules use the saturated-fill pair `--a-{family}-bg` + `--a-{family}-fg`
54
+ // (the chip IS the state). `[tone="muted"]` opts back into the canonical
55
+ // muted pair `--a-{family}-muted` + `--a-{family}-text` (same look as
56
+ // <badge-ui>'s default). The `default` (no-family) variant stays quiet
57
+ // chrome unless `[tone="solid"]` is set explicitly (then it inverts
58
+ // to fg/bg for a high-contrast neutral stamp).
59
+
60
+ describe('tag-ui — variant defaults to solid fill', () => {
61
+ it.each([
62
+ ['info'],
63
+ ['success'],
64
+ ['danger'],
65
+ ])('[variant="%s"] base rule uses --a-{family}-bg + --a-{family}-fg', (family) => {
66
+ const block = new RegExp(
67
+ `:scope\\[variant="${family}"\\]\\s*\\{[^}]*` +
68
+ `--tag-bg-default:\\s*var\\(--a-${family}-bg\\)[^}]*` +
69
+ `--tag-fg-default:\\s*var\\(--a-${family}-fg\\)`,
70
+ 's'
71
+ );
72
+ expect(TAG_CSS).toMatch(block);
73
+ });
74
+
75
+ it('[variant="warning"] uses the literal --a-warning-20-tint bg (NOT --a-warning-bg)', () => {
76
+ // The token-system convention `-bg` + `-fg` collapses for warning
77
+ // because `--a-warning-fg` is dark (`-10-shade`) but `--a-warning-bg`
78
+ // (= `-strong` = `-50`) is mid-tone amber — dark-on-mid is muddy.
79
+ // The literal `-20-tint` step is bright in both schemes, restoring
80
+ // the caution-tape look the semantics.css comment intends.
81
+ expect(TAG_CSS).toMatch(
82
+ /:scope\[variant="warning"\]\s*\{[^}]*--tag-bg-default:\s*var\(--a-warning-20-tint\)[^}]*--tag-fg-default:\s*var\(--a-warning-fg\)/s
83
+ );
84
+ // Negative — guard against accidental revert to the muddy pair.
85
+ expect(TAG_CSS).not.toMatch(
86
+ /:scope\[variant="warning"\]\s*\{[^}]*--tag-bg-default:\s*var\(--a-warning-bg\)/s
87
+ );
88
+ });
89
+ });
90
+
91
+ describe('tag-ui — [tone="muted"] opts out to canonical muted pair', () => {
92
+ it.each([
93
+ ['info'],
94
+ ['success'],
95
+ ['warning'],
96
+ ['danger'],
97
+ ])('[tone="muted"][variant="%s"] uses --a-{family}-muted + --a-{family}-text', (family) => {
98
+ const block = new RegExp(
99
+ `:scope\\[tone="muted"\\]\\[variant="${family}"\\]\\s*\\{[^}]*` +
100
+ `--tag-bg-default:\\s*var\\(--a-${family}-muted\\)[^}]*` +
101
+ `--tag-fg-default:\\s*var\\(--a-${family}-text\\)`,
102
+ 's'
103
+ );
104
+ expect(TAG_CSS).toMatch(block);
105
+ });
106
+ });
107
+
108
+ describe('tag-ui — neutral default + explicit solid stamp', () => {
109
+ it('[variant="default"] stays quiet chrome (--a-bg-muted + --a-fg)', () => {
110
+ expect(TAG_CSS).toMatch(
111
+ /:scope\[variant="default"\]\s*\{[^}]*--tag-bg-default:\s*var\(--a-bg-muted\)[^}]*--tag-fg-default:\s*var\(--a-fg\)/s
112
+ );
113
+ });
114
+
115
+ it('[tone="solid"] without a family inverts to fg/bg high-contrast stamp', () => {
116
+ expect(TAG_CSS).toMatch(
117
+ /:scope\[tone="solid"\]:not\(\[variant="info"\]\)[\s\S]*?--tag-bg-default:\s*var\(--a-fg\)[\s\S]*?--tag-fg-default:\s*var\(--a-bg\)/
118
+ );
45
119
  });
46
120
  });
@@ -51,6 +51,20 @@ props:
51
51
  - success
52
52
  - warning
53
53
  - danger
54
+ tone:
55
+ description: |
56
+ Fill style — orthogonal to [variant]. Default (`solid`) for family
57
+ variants is a saturated bg with on-strong (near-white) text — the
58
+ chip IS the state. Opt-out via `tone="muted"` for a tinted bg with
59
+ scheme-paired text (matches <badge-ui>'s default look) when the
60
+ chip is metadata in a dense list rather than a status stamp. The
61
+ `default` variant stays quiet chrome regardless of tone unless
62
+ `tone="solid"` is set explicitly (high-contrast neutral inverse).
63
+ type: string
64
+ default: solid
65
+ enum:
66
+ - solid
67
+ - muted
54
68
  events:
55
69
  remove:
56
70
  description: Fired when the dismiss button is activated.
@@ -518,12 +518,19 @@ export class UITagsInput extends UIFormElement {
518
518
  #handleChipRemove(e) {
519
519
  const chip = e.target.closest('tag-ui[data-index]');
520
520
  if (!chip) return;
521
- // Stop the bubbling `remove` from <tag-ui> tag-ui auto-removes the node
522
- // itself on dispatch, but we own the list. Rebuild to keep the source of
523
- // truth in `#value`.
521
+ // tag-ui's `remove` CustomEvent bubbles SYNCHRONOUSLY before tag-ui's
522
+ // own `this.remove()` runs (see tag/tag.class.js:59-63 dispatch then
523
+ // remove). If we react inside the bubble (call `#renderChips()` now),
524
+ // the DOM still contains the about-to-be-removed chip and the
525
+ // positional reconcile in `#renderChips()` mutates the WRONG chip's
526
+ // text — visually erasing one or more adjacent chips. Pre-emptively
527
+ // detach the source chip ourselves so the DOM matches `#value` before
528
+ // render. tag-ui's later `this.remove()` is a harmless no-op on an
529
+ // already-detached node.
524
530
  e.stopPropagation();
525
531
  const idx = Number(chip.dataset.index);
526
532
  if (!Number.isInteger(idx)) return;
533
+ chip.remove();
527
534
  this.#removeByIndex(idx, 'chip-click');
528
535
  }
529
536
 
@@ -110,14 +110,23 @@
110
110
  white-space: pre-wrap;
111
111
  overflow-wrap: anywhere;
112
112
  cursor: text;
113
+ /* Positioning context for the [data-empty]::before placeholder pseudo
114
+ (kept out of inline flow so the caret renders at content-start). */
115
+ position: relative;
113
116
  }
114
- /* Empty-state placeholder via ::before pseudo so the contenteditable
115
- surface participates in inline layout (the same pattern the input-ui
116
- + combobox-ui editable surfaces use). */
117
+ /* Empty-state placeholder. The pseudo is absolutely positioned (NOT
118
+ inline) so an in-flow pseudo box doesn't push the caret-at-position-0
119
+ to the right of the placeholder text after type-then-delete. Same
120
+ pattern input-ui + textarea-ui + combobox-ui use. */
117
121
  [data-inline-input][data-empty]::before {
118
122
  content: attr(data-placeholder);
119
123
  color: var(--tags-input-placeholder-fg, var(--tags-input-placeholder-fg-default));
120
124
  pointer-events: none;
125
+ position: absolute;
126
+ inset: 0;
127
+ padding: inherit;
128
+ display: flex;
129
+ align-items: center;
121
130
  }
122
131
 
123
132
  /* ── Spinner (async validator) ── */
@@ -60,6 +60,10 @@ textarea-ui:not([disabled]) [slot="text"]:hover {
60
60
  [slot="label"][label]::after { content: attr(label); }
61
61
 
62
62
  [slot="text"] {
63
+ /* Positioning context for the [data-empty]::before placeholder pseudo,
64
+ which is absolutely positioned (out of inline flow) so the caret
65
+ renders at content-start instead of after the placeholder text. */
66
+ position: relative;
63
67
  min-height: var(--textarea-min-height, var(--textarea-min-height-default));
64
68
  padding: var(--textarea-py, var(--textarea-py-default)) var(--textarea-px, var(--textarea-px-default));
65
69
  border: 1px solid var(--textarea-border, var(--textarea-border-default));
@@ -99,11 +103,16 @@ textarea-ui:not([disabled]) [slot="text"]:hover {
99
103
  color: var(--textarea-label-fg-focus, var(--textarea-label-fg-focus-default));
100
104
  }
101
105
 
102
- /* Placeholder */
106
+ /* Placeholder — see input.css companion comment. Out-of-flow positioning
107
+ prevents the inline pseudo box from pushing the caret position to the
108
+ right of the placeholder text after type-then-delete. */
103
109
  [slot="text"][data-empty]::before {
104
110
  content: attr(data-placeholder);
105
111
  color: var(--textarea-placeholder-fg, var(--textarea-placeholder-fg-default));
106
112
  pointer-events: none;
113
+ position: absolute;
114
+ inset: 0;
115
+ padding: inherit;
107
116
  }
108
117
 
109
118
  /* Disabled */
package/core/provider.js CHANGED
@@ -228,14 +228,31 @@ export class UIRouter extends UIProvider {
228
228
  }
229
229
  }
230
230
 
231
- html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
232
-
233
231
  // Template resolver hook — lets the site wrap content in a template before injection
234
232
  if (this.templateResolver) {
235
233
  html = await this.templateResolver(html, route);
236
234
  }
237
235
 
238
236
  this.innerHTML = html;
237
+ // Re-stamp executable <script> tags so consumer demos can wire data via
238
+ // imperative JS APIs (the documented `el.items = […]` / `el.invoice = {…}`
239
+ // pattern used by plan-picker, invoice-detail, list-window et al). HTML
240
+ // assigned via innerHTML does NOT execute embedded <script> elements per
241
+ // the HTML spec; we replicate the canonical standalone-wrapper rehydration
242
+ // pattern by cloning each script + swapping the inert original. `<script
243
+ // type="application/json" …>` blocks are left untouched so the receiving
244
+ // custom element's connectedCallback can absorb them (the documented
245
+ // in-band data pattern; see PlanPicker.#absorbInlinePlansScript et al).
246
+ for (const oldScript of this.querySelectorAll('script')) {
247
+ const type = oldScript.getAttribute('type') || '';
248
+ // Skip data blocks — those are consumed by the runtime, not executed.
249
+ if (type === 'application/json' || type === 'application/ld+json') continue;
250
+ const newScript = document.createElement('script');
251
+ if (type) newScript.type = type;
252
+ if (oldScript.src) newScript.src = oldScript.src;
253
+ else newScript.textContent = oldScript.textContent;
254
+ oldScript.replaceWith(newScript);
255
+ }
239
256
  this.scrollTo(0, 0);
240
257
  (this.closest('section') || this.parentElement)?.scrollTo(0, 0);
241
258